Voltar

Métodos Especiais: Interfaces no Ruby

Continuando do post anterior, que falou sobre classes que usam o método #[], como a Hash, iremos aprender como programar para interfaces no Ruby.

Mas o que são interfaces?

O termo interface aqui, vai ser utilizado no seu sentido mais geral, que na minha definição é:

Um contrato entre dois ou mais componentes (blocos de código) que é capaz de comunicar quais funcionalidades estão disponíveis naquele componente, promovendo segurança e abstraindo a implementação.

Logo, as funcionalidades apresentadas a seguir, de alguma forma, definem maneiras específicas de realizar certas ações e caso não implementadas, levantam erros.

  1. Métodos especiais (esse post)
  2. Assinaturas de métodos
  3. Módulos, Classes e Constantes
  4. Duck typing (em breve…)
  5. Blocos (em breve…)

Métodos syntactic sugar

Assim como o Python com os Special Methods e a Lua com as Metatables, o Ruby também tem uma estratégia para sobrescrever operações básicas como a soma, subtração e acesso por índice (object[index] por exemplo).

Porém, diferente das linguagens citadas acima, podemos dizer que a abordagem do Ruby é mais “natural” e intuitiva, pois quase tudo são objetos:

1.class # => Integer

Outra consideração é que o Ruby nos deixa usar quase qualquer coisa como caracteres válidos para identificadores:

☁️ = "cloud"
def️ ➡️(arg)
  arg
end

➡️ ☁️ # => "cloud"

Logo, nada mais justo que o jeito de implementar essas operações seja definindo métodos. Depois desses exemplos, podemos começar a estudar os “métodos especiais” do Ruby!

class MyInt
  attr_reader :int

  def initialize(int)
    @int = int
  end

  def +(other)
    MyInt.new(self.int + other.int) # o self pode ser omitido nesse caso?
  end
end

Até agora não fizemos nada de diferente do que estamos acostumados, com exceção da definição do método +, que apesar de ter um nome diferente, aceita um argumento chamado other. Nesse método, retornamos uma nova instância de MyInt, com o atributo int sendo a soma do própria instância com o int de other. Isso parece uma soma 🤔. Vamos testar!

a = MyInt.new(5)
b = MyInt.new(7)
a.+(b) # => <MyInt @int=12>

# Funciona! Que tal omitirmos os parênteses?
a.+ b # => <MyInt @int=12>

# Bem, acho que deu pra entender onde vamos chegar né?
a + b # => <MyInt @int=12>

A última sentença normalmente daria um erro de sintaxe, pois estamos chamando um método sem usar ponto e separando com espaço, porém quando interpretador vê a sintaxe acima, ele “substitui” a + b por a.+(b)! São duas maneiras de escrever a mesma coisa, porém uma mais simples que a outra. No geral, chamamos isso de syntactic sugar, pois facilita a compreensão e escrita de programas!

E as classes que não foram criadas por nós? Elas também apresentam esse comportamento? Vamos testar.

1.+(4) # => 5
'Meu nome é '.+ 'Tomás' # => "Meu nome é Tomás"
[1, 'oi', {comida: 'ovo'}].+ [20] # => [1, "oi", {:comida=>"ovo"}, 20]

Logo, podemos perceber que somar na verdade significa invocar o método +(other) do primeiro operando, utilizando o segundo operando como o argumento other! Isso significa que agora nossas classes e módulos podem implementar a interface Soma!

Além da soma, podemos sobrescrever vários operadores, como +, -, /, *, **, <<, >>, > <, >=, <=, !=, ==, entre outros. É muito poder! Aqui vai um exemplo divertido para você testar.

module BrokenOperations
  def self.+(*)
    42
  end
  def self.!=(*)
    'Você sabe o que a sintaxe def method(*) faz?'
  end
  def self.>=(*)
    'Ela faz com que qualquer tipo de argumento passado seja ignorado!'
  end
end

BrokenOperations.+('OwO')
# => 42
BrokenOperations.!= 50
# => "Você sabe o que a sintaxe def method(*) faz?"
BrokenOperations >= nil
# => "Ela faz com que qualquer tipo de argumento passado seja ignorado!"

Implementando Acesso por indexação

Algumas Classes como Hash e Array implementam a interface de acesso por índice:

array = [1, 3, 7]
array[0] # => 1
# Agora que já vimos a parte anterior você consegue entender
# a implicação que a linha abaixo tem?
array.[](2) # => 7

# E as hashes?
hash = {comida: 'bolo', "idade" => 21, 18 => 81}
hash.[](:comida) # => "bolo"
hash.[] 'idade' # => 21
hash[18] # => 81

Sim, é isso mesmo que você está pensando. As classes Array e Hash definem o método []! Note também que o argumento que [] recebe pode ser de vários tipos diferentes! Para exemplificar, vamos criar um tipo especial de Array que pode ser acessado com strings:

class MyArray
  def initialize(array)
    @array = array
  end

  def [](index)
    int_index = index.is_a?(Integer) ? index : index.to_i
    @array[int_index]
  end
end

Ok. Veja que nesse exemplo implementamos [] recebendo o argumento index, que é convertido para inteiro se não for uma instância de Integer e atribuido a int_index. Após isso, utilizamos o bom e velho [] das Arrays que estamos acostumados a utilizar, porém com o int_index como argumento.

Note que no fim das contas, nossa classe somente “trata” (converte para inteiro) o argumento index antes de o passar para a o método [] de Array. Esse padrão é extremamente poderoso!

Vamos aos testes:

array = MyArray.new([0,1,2,3,4])
# => <MyArray @array=[0, 1, 2, 3, 4]>

array["0"]
# => 0
array["2"]
# => 2

array[1.9]
# => 1
array[7/3r] # 7/3r é um racional literal e
# => 2      # representa exatamente sete terços ou 2,333...

Mas pera, que dois últimos exemplos foram esses?

Quando fizemos a implementação da nossa classe MyArray com o objetivo de acessar arrays utilizando strings, nós dependemos do método to_i, que é implementado na classe String, porém ele também é implementado em outras classes. Logo, toda classe que implemente o método #to_i poderá ser utilizado pela nossa implementação como índice! Descobrimos mais um tipo “interface” que será abordada adiante 😏

Além do uso “convencional”, sobrescrever operadores em Ruby também pode deixar nosso código mais expressivo, abrindo mais possibilidades de expressar intenção com o código, além de facilitar a vida de nós, programadores. Veja o exemplo a seguir, que se aproveita da interface [] para obter os benefícios acima.

class MyArray
  def self.[](*array)
    new(array)
  end
end

Note também a sintaxe utilizada para declarar o argumento de [], vamos falar disso mais tarde!

Com esse monkey patch, agora podemos instanciar objetos MyArray da seguinte forma:

array = MyArray[0,1,2,3,4]
# => <MyArray @array=[0, 1, 2, 3, 4]>

Muito mais prático e expressivo! Utilizamos [] como método de classe para instanciar uma nova MyArray.

Getters e Setters

Presentes em muitas linguagens orientadas a objetos, nossos velhos amigos também estão presentes em Ruby e podem servir de interface.

Vamos começar com os getters. Sua implementação é trivial e não depende de nenhum método especial:

class Person
  def initialize(name, age)
    @name = name
    @age = age
  end

  # getters
  def name
    @name
  end
  
  def age
    @age
  end
end

me = Person.new('Tomás', 21)
me.name # => "Tomás"
me.age # => 21

Como você pode ter imaginado, Getters em Ruby são métodos com o mesmo nome de seus atributos e só retornam o atributo. É fácil de ver que nem todo método é um getter.

As coisas ficam realmente interessantes com os setters e a constatação que eles são apenas um uso específico de sobrescrita de operadores! Veja o exemplo:

class Person
  def initialize(name, age)
    @name = name
    @age = age
  end

  # getters
  def name
    @name
  end
  
  def age
    @age
  end
  
  # setters
  def name=(name)
    @name = name
  end
  
  def age=(age)
    @age = age
  end
end

me = Person.new('Tomás', 21)
me.name # => "Tomás"

me.name=('Gustavo') 
# ou
me.name= 'Gustavo'
# ou
me.name = 'Gustavo'

me.name # => "Gustavo"

Logo percebemos que na verdade, métodos setters são a ponta do iceberg de um fenômeno maior, o mesmo que vimos até agora, métodos com syntactic sugar! Qualquer método com o nome do tipo algum_nome=(arg) pode ser reescrito como algum_nome = arg e não! Um método desse tipo não precisa corresponder a um atributo do objeto 😎

class Person
  def favorite_food=(name)
    puts "Minha comida favorita agora é #{name}"
  end
end

person = Person.new

person.favorite_food = 'Macarrão'
# Minha comida favorita agora é Macarrão
# => nil

Apesar de ser difícil encontrar um uso prático para uma atribuição que não atribua nada, é sim possível utilizar essa sintaxe desse jeito. Outro exemplo importante é o seguinte:

Note que não é uma boa prática implementar getters e setters na mão, pois além de já existirem os métodos attr para não poluir seu código com linhas quase idêncitas, escrever na marra é menos performático.

class MyArray
  def self.[](*array)
    new(array)
  end

  def initialize(array)
    @array = array
  end

  def [](index)
    int_index = index.is_a?(Integer) ? index : index.to_i
    @array[int_index]
  end
 
  def []=(index, value) # novo método
    int_index = index.is_a?(Integer) ? index : index.to_i
    @array[int_index] = value
  end
end

Note que há linhas idênticas, você consegue melhorar esse código?

Sim, é isso que eu quero dizer quando digo que o Ruby é intuitivo. Você consegue chutar o que o nosso novo método []= faz? Veja:

array = MyArray[0,1,2,3,4]
array["1"] # => 1
array.[]=("1", 100)
# ou
array.[]= "1", 100
# ou
array["1"] = 100

array["1"] # => 100

array # => <MyArray @array=[0, 100, 2, 3, 4]>

Agora podemos modificar o valor da nossa MyArray.

Conclusão

Nesse post, vimos que podemos reimplementar uma boa parte dos operadores da linguagem, de maneira a criar uma interface comum para classes personalizadas, que incluem novas funcionalidades. Como fizemos no exemplo acima, toda vez que quisermos implementar uma noção de busca e recuperação de dados, podemos implementar os métodos #[] e #[]=, assim, temos uma interface familiar (já usada por Arrays e Hashes) para nossa classe. Outro exemplo seriam classes com noções de soma, que poderiam implementar o método + para fornecer uma interface bem conhecida de qualquer pessoa estudante de matemática!

Para finalizar, tenho que destacar que esses recursos são muito úteis na construção de bibliotecas e frameworks, porém pouco úteis na programação do dia-a-dia, em que somente dependemos das interfaces disponibilizadas e não precisamos conhecer nenhum detalhe de implementação desses softwares para utiliza-los. Porém, acredito que a partir de hoje você estará um pouco mais apto a criar suas próprias abstrações, bibliotecas e frameworks!