Um exemplo bacana de coerção em Ruby

Um exemplo bacana de coerção em Ruby
eric
eric

Compartilhe

Ruby é cheio de características interessantes. Uma delas, muito importante, é a flexibilidade.

Por exemplo, as operações aritméticas em ruby (+, -, *, /) não são definidas por operadores reservados da linguagem. São definidas como métodos. Veja; quando executamos o código a seguir:

 2 + 2 

O que o interpretador entende é análogo a

 2.+(2) 

A primeira versão do código é apenas uma maneira mais limpa de representar a segunda.

Ok, fica mais limpo. Mas onde está a flexibilidade?

Como + é um método como outro qualquer, é possível que ele seja definido ou redefinido por um código nosso. Neste caso, o método + está definido na classe Fixnum, que define o comportamento do números inteiros. Nesse caso específico do objeto '2'. Sim, o literal '2' é uma instância de Fixnum.

Para exemplificar esse tipo de flexibilidade, vamos só por diversão criar uma classe que represente um intervalo de tempo em minutos. Quero usá-la da seguinte forma:

 intervalo = Intervalo.new(90) puts "#{intervalo.horas}:#{intervalo.minutos}" # => 1:30 

Para que isso seja possível, a classe Intervalo deve ter um construtor, um método chamado horas e outro chamado minutos.

 class Intervalo def initialize(minutos) @minutos = minutos end

def horas @minutos / 60 end

def minutos @minutos % 60 end end 

O método horas retorna o resultado inteiro da divisão do total de minutos por 60. E o método minutos retorna o resto inteiro dessa divisão.

Legal! Mas é meio cansativo ficar digitando "#{intervalo.horas}:#{intervalo.minutos}" em todo lugar que quisermos exibir um intervalo. Seria mais interessante poder digitar apenas intervalo.to_s. Ou seja, invocar a representação String do nosso objeto intervalo e ter o mesmo efeito.

No entanto, se executarmos o intervalo.to_s, vamos perceber que o retorno é algo como "#", e não o desejado 1:30.

Isso acontece porque o interpretador simplesmente não foi ensinado a representar como String um objeto da classe Intervalo. Ele simplesmente utiliza a definição presente na classe Object, ou seja, ele utiliza a implementação herdada do método to_s definida para qualquer Object. Precisamos, portanto, redefinir o método to_s para ter um comportamento específico na classe Intervalo. Simples:

 class Intervalo # ...

def to\_s "#{horas}:#{minutos}" end end 

A bem da verdade, ainda existe um probleminha.

 intervalo = Intervalo.new(61) puts intervalo.to\_s # => 1:1 

É mais interessante que os minutos sempre tenham duas casas. Para que isso aconteça, podemos usar sprintf que aceita uma máscara para definir a formatação. Logo, nosso método to_s fica melhor assim:

 class Intervalo # ...

def to\_s sprintf("%.1i:%.2i", horas, minutos) end end 

Ok. Mas e a história da soma? O que aconteceria se eu tentasse somar 30 minutos a um intervalo de 90 minutos? Teríamos um intervalo de 2 horas?

 intervalo = Intervalo.new(90) novo\_intervalo = intervalo + 30 

Isso falha. E a mensagem de erro nos avisa que o objeto intervalo (que é uma instância da classe Intervalo) não tem o método +. Lembre-se que

 intervalo + 30 

Equivale a

 intervalo.+(30) 

Para chegar ao resultado desejado, não é necessário nada de especial. Basta definir o método + dentro da classe Intervalo.

 class Intervalo # ...

def +(outros\_minutos) Intervalo.new(@minutos + outros\_minutos) end end 

Lindo! Agora é possível somar um intervalo a um número inteiro que representa os minutos que devem ser acrescentados.

 intervalo = Intervalo.new(90) novo\_intervalo = intervalo + 30 puts novo\_intervalo.to\_s # => 2:00 

Perceba que nosso objeto intervalo é imutável. A operação de adição retorna um novo objeto em vez de alterar o objeto original. Essa é uma prática que visa reduzir a complexidade do código.

Dá para ir mais longe. O que aconteceria se tentássemos somar dois intervalos?

 uma\_hora\_e\_meia = Intervalo.new(90) uma\_hora = Intervalo.new(60)

soma = uma\_hora\_e\_meia + uma\_hora 

Temos uma mensagem de erro que a príncipio parece meio críptica: "Intervalo can't be coerced into Fixnum". No entanto, ela revela que o interpretador está tentando 'coerce' ou seja forçar nosso objeto Intervalo a ser tratado como um objeto do tipo Fixnum (numérico). Por que isso acontece? Vamos olhar para dentro do método Intervalo#+. Para simplificar o entendimento, vamos expandir o método pra realizar seu trabalho em duas linhas.

 class Intervalo # ...

def +(outros\_minutos) novo\_valor = @minutos + outros\_minutos Intervalo.new(novo\_valor) end end 

No último exemplo, perceba que dentro do método Intervalo#+ quando fazemos a soma

 novo\_valor = @minutos + outros\_minutos 

Temos @minutos como sendo um objeto da classe Fixnum, ou seja, um número inteiro. E outros_minutos como sendo um objeto da classe Intervalo. Logo nosso problema, para simplificar, deriva da seguinte situação: Já é possível fazer a soma Intervalo + Fixnum; porém não é possível fazer a soma Fixnum + Intervalo. Veja:

 intervalo = Intervalo.new(90) soma\_intervalo\_mais\_inteiro = intervalo + 30 # => 2:00

soma\_inteiro\_mais\_intervalo = 30 + intervalo # => "Intervalo can't be coerced into Fixnum" 

Lembrando que a última soma é traduzida para

 30.+(intervalo) 

Ou seja, é chamado o método + definido na classe Fixnum. Esse método está definido no core da linguagem e não faz nenhuma idéia do que possa ser um Intervalo.

No entanto, os idealizadores do Ruby pensaram numa maneira bem interessante de flexibilizar o comportamento do método Fixnum#+. Como a classe Fixnum não sabe somar Intervalos, ela delega esta responsabilidade de volta para a classe Intervalo. Ou seja, quando um Fixnum está sendo somado a algo que o Ruby não reconhece como número, ele solicita ao objeto desconhecido que devolva dois objetos compativeis. Isso é realizado por um método chamado coerce. A responsabilidade desse método é devolver dois objetos que possam ser somados. Por exemplo.

 class Intervalo # ... def coerce(numero\_inteiro) ```numero\_inteiro, @minutos
 end end 

Perceba que agora quando executar

 30 + intervalo 

Internamente, o objeto 30 vai chamar o método coerce do objeto intervalo para ter como retorno um par de objetos compatíveis com a operação de soma. Algo como

 intervalo.coerce(30) # => ```30, 90

Perceba agora que a operação de soma prosseguirá dentro do método Fixnum#+ usando estes dois objetos, que são perfeitamente 'somáveis'.

30.+(90) # => 120

Assim sendo a operação toda se resolve e temos como resultado da soma Fixnum + Intervalo um número inteiro, ou seja, um Fixnum.

 intervalo = Intervalo.new(90) soma = 30 + intervalo # ocorre uma chamada para intervalo.coerce(30) cujo retorno é ```30, 90
 e em seguida 30.+(90) retornando 120 puts soma # => 120 

Ótimo! A classe Fixnum está disposta a ser interoperável com qualquer outra classe. Desde que lhe seja dada uma maneira de entender como os objetos dessa nova classe querem ser somados a um Fixnum.

Bacana! Mas dá pra ficar melhor. A situação que temos agora é:

Intervalo + Fixnum => Intervalo Fixnum + Intervalo => Fixnum

Meio esquisito. Seria mais interessante que indepententemente da ordem dos operadores o resultado fosse sempre um Intervalo. E é aí que está o pulo do gato.

Perceba que podemos controlar completamente como vai ser a operação de soma realizada pelo Fixnum. Basta que nosso método coerce retorne um par de objetos apropriado.

Neste momento nosso método Intervalo#coerce retorna 30, 90 , ou seja,Fixnum, Fixnum . O que aconteceria se retornássemos ```Intervalo, Fixnum ? Ou seja

 class Intervalo def coerce(numero\_inteiro) ```Intervanlo.new(numero\_inteiro), @minutos
 end end 

Invertemos a soma!

 30 + Intervalo.new(90) 

Para algo como

 Intervalo.new(30) + 90 

Isso é lindo e genial! Pois o controle da operação de soma do Fixnum volta para o método + da classe Intervalo! E nesse método a operação de soma já está corretamente definida e retorna um objeto do tipo Intervalo :]

Assim temos

Intervalo + Fixnum => Intervalo Fixnum + Intervalo => Intervalo

E facilmente conseguiremos chegar a

Intervalo + Intervalo => Intervalo

Para que isso seja possível, precisamos olhar para nossa implementação do método Intervalo#+

 class Intervalo # ... def +(outros\_minutos) novo\_valor = @minutos + outros\_minutos Intervalo.new(novo\_valor) end end 

Agora, novo_valor pode ser Fixnum (quando estamos somando Intervalo + Fixnum) ou Intervalo (quando estamos somando Fixnum + Intervalo e usando coerção). Neste segundo caso, quando novo_valor é do tipo Intervalo, vamos ter um problema ao fazer Intervalo.new(novo_valor), pois o método Intervalo#initialize

 class Intervalo def initialize(minutos) @minutos = minutos end end 

Guardaria a representação interna @minutos como sendo um Intervalo. E @minutos deve ser sempre um Fixnum. Como resolver isso?

Para garantir que @minutos seja sempre do tipo Fixnum, basta forçar que o parâmetro minutos seja convertido para Fixnum indepententemente de sua natureza. Garantimos isso chamando minutos.to_i

 class Intervalo def initialize(minutos) @minutos = minutos.to\_i end end 

Portanto, quando um objeto do tipo Intervalo precisar ser representado como um inteiro, podemos fazer que ele devolva o número absoluto de minutos que o compõe. A convenção em Ruby para que um objeto seja convertido para número inteiro é que sua classe exponha um método chamado to_i. Logo

 class Intervalo # ... def to\_i @minutos end end 

Como estamos seguindo a conveção de definir um método chamado to_i, não precisamos nos preocupar quando o parâmetro minutos do construtor é do tipo Fixnum, pois nesta classe o método to_i já está definido e retorna o próprio objeto em que foi chamado. Bonito, não?

Referências: http://www.mutuallyhuman.com/blog/2011/01/25/class-coercion-in-ruby/ https://pragprog.com/book/ruby/programming-ruby

Veja outros artigos sobre Programação