Página Inicial > Arquitetura, Ruby > Cheeseburgers, Ruby e magia negra

Cheeseburgers, Ruby e magia negra

Vamos usar um pouco de magia negra do Ruby para encontrar uma alternativa à implementação clássica do Design Pattern Decorator apresentado pela GoF.

Imagem original de MarketFare Foods, Inc.

Imagem original de MarketFare Foods, Inc.

.
Este post é a continuação de dois anteriores:

Se você ainda não os leu, recomendo que o faça para entender o contexto do exemplo onde estamos aplicando o Design Pattern Decorator. O ponto onde paramos no último post foi o meu descontentamento em decorar um objeto Cheeseburger de uma forma não muito intuitiva.

Vamos relembrar nosso diagrama de classes:

Cheeseburgers com Ruby

Cheeseburgers com Ruby

.
E o nosso último teste:

describe Cheeseburger do
  it "should be an Cheeseburger with pepper sauce, onion rings and corn" do
    cheeseburger = Corn.new(OnionRings.new(PepperSauce.new(Cheeseburger.new)))
    cheeseburger.description.should == "Bread, Hamburger, Cheese, Pepper Sauce, Onion Rings, Corn"
    cheeseburger.calories.should == 530
  end
end

O teste espera que seja criado um cheeseburger com molho de pimenta, cebola e milho. O teste passa, mas a forma como o cheeseburger é decorado na linha 3 me desconforta.

Então vamos utilizar o poder e a flexibilidade do Ruby, a.k.a. black magic (ou magia negra), para melhorar essa situação. Mas antes vamos dar uma olhada como funcionam os módulos.

.
Um pouco sobre módulos
Módulos em Ruby são grupos de métodos, constantes e variáveis de classes. Os módulos não podem ser instanciados e não existe herança de módulos.

Vejamos um exemplo:

module MyLogging
  def info(text)
    puts "INFO: #{text}"
  end

  def warn(text)
    puts "WARNING: #{text}"
  end
end

.
Os métodos de instância de um módulo podem ser “misturados” em outras classes, é o chamado mixin. Podemos usar o método include para incluir módulos em classes:

class MyService
  include MyLogging

  def other_method
    # method code here
  end
end

service = MyService.new
service.info "Some message"
service.warn "Another message"

O método include recebe qualquer número de objetos Module, permitindo assim a inclusão de vários módulos em uma classe.

Outra maneira de “mixar” módulos em uma classe é através do método extend. A diferença é que dessa forma o módulo é incluído em um objeto instanciado e não na própria classe. Veja um exemplo:

module MyLogging
  def info(text)
    puts "INFO: #{text}"
  end

  def warn(text)
    puts "WARNING: #{text}"
  end
end

module MyMailer
  def send(to, subject, message)
    # method code here
  end
end

class MyService
  def other_method
    # method code here
  end
end

service = MyService.new
service.extend MyLogging
service.extend MyMailer

service.send "email@domain.com", "Subject", "The body of the e-mail"
service.info "Message sended."

O método extend insere um módulo na árvore de herança dos objetos, antes da sua classe regular.

Obs.: Módulos podem ser usados também como namespaces, mas não entrarei em detalhes sobre isso aqui.

.
Decorando com módulos
Agora vamos decorar nossos cheeseburgers de uma forma mais “Ruby Way”.

A primeira coisa a ser feita é transformar todas nossas classes decoradoras (Corn, PepperSauce e OnionRings) em módulos:

module Corn
  def description
    "#{super}, Corn"
  end

  def calories
    super + 70
  end
end
module OnionRings
  def description
    "#{super}, Onion Rings"
  end

  def calories
    super + 140
  end
end
module OnionRings
  def description
    "#{super}, Onion Rings"
  end

  def calories
    super + 140
  end
end

O método super irá chamar o método de mesmo nome da classe onde o módulo for incluído.

Podemos testar nossos módulos mixando-os em alguma classe que possua os métodos description e calories:

class FakeSandwich
  def description
    "Sandwich description"
  end

  def calories
    0
  end
end

describe Corn do
  before(:all) do
    @fake_sandwich = FakeSandwich.new
    @fake_sandwich.extend Corn
  end

  it "should add corn description to sandwich description" do
    @fake_sandwich.description.should == "Sandwich description, Corn"
  end

  it "should add corn calories to sandwich calories" do
    @fake_sandwich.calories.should == 70
  end
end

Nesse teste criamos a classe FakeSandwich e incluímos o módulo Corn nela (linha 14). Depois testamos os métodos description e calories, assegurando assim que suas implementações no módulo Corn estão corretas.

A nossa classe Cheeseburger não irá mudar (pelo menos por agora):

class Cheeseburger
  attr_reader :description, :calories

  def initialize
    @description = "Bread, Hamburger, Cheese"
    @calories = 300
  end
end

.
Agora vamos ver como fica aquele teste do início do post:

describe Cheeseburger do
  it "should be an Cheeseburger with pepper sauce, onion rings and corn" do
    cheeseburger = Cheeseburger.new
    cheeseburger.extend Corn, OnionRings, PepperSauce
    cheeseburger.description.should == "Bread, Hamburger, Cheese, Pepper Sauce, Onion Rings, Corn"
    cheeseburger.calories.should == 530
  end
end

Quando o método calories da variável cheeseburger é chamado na linha 6, estamos chamando o método calories do último decorador, ou seja, do módulo PepperSauce. Veja o que acontece:

  1. PepperSauce chama o método super, que irá chamar o método calories do módulo OnionRings;
  2. OnionRings chama o método super, que irá chamar o método calories do módulo Corn;
  3. Corn chama o método super, que irá chamar o método calories da classe Cheeseburger;
  4. Cheeseburger retorna 300 calorias;
  5. Corn adiciona suas 70 calorias ao retorno de Cheeseburger e retorna 370 calorias;
  6. OnionRings adiciona suas 140 calorias ao retorno de Corn e retorna 510 calorias;
  7. PepperSauce adicona suas 20 calorias ao retorno de OnionRings e retorna 530 calorias.

Para ficar mais intuitivo acrescenter ingredientes nos cheeseburgers, podemos criar um álias para o método extend com o nome de with:

class Cheeseburger
  attr_reader :description, :calories

  def initialize
    @description = "Bread, Hamburger, Cheese"
    @calories = 300
  end

  alias_method :with, :extend
end

.
E o teste modificado:

describe Cheeseburger do
  it "should be an Cheeseburger with pepper sauce, onion rings and corn" do
    cheeseburger = Cheeseburger.new
    cheeseburger.with Corn, OnionRings, PepperSauce
    cheeseburger.description.should == "Bread, Hamburger, Cheese, Pepper Sauce, Onion Rings, Corn"
    cheeseburger.calories.should == 530
  end
end

Hum… já está ficando melhor. Mas ainda temos uma questão: o último módulo adicionado é o primeiro a ser chamado. Sendo assim, continuamos decorando a classe Cheeseburger na ordem inversa que o método description irá retornar.

Para resolver isso de uma vez por todas, vamos remover o alias method e criar um novo método chamado with. Esse método irá receber um array de módulos e chamar o método extend passando esses módulos. Mas antes de passar o array de módulos para o método extend, vamor reverter sua ordem utilizando o método reverse.

class Cheeseburger
  attr_reader :description, :calories

  def initialize
    @description = "Bread, Hamburger, Cheese"
    @calories = 300
  end

  def with(*ingredients)
    self.extend *ingredients.reverse
  end
end

.
E olha como fica nosso teste agora:

describe Cheeseburger do
  it "should be an Cheeseburger with pepper sauce, onion rings and corn" do
    cheeseburger = Cheeseburger.new
    cheeseburger.with PepperSauce, OnionRings, Corn
    cheeseburger.description.should == "Bread, Hamburger, Cheese, Pepper Sauce, Onion Rings, Corn"
    cheeseburger.calories.should == 530
  end
end

Muito bom! Um novo cheeseburger com molho de pimenta, cebola e milho, exatamente na ordem como está descrito no nosso teste.

Se você quiser criar um cheeseburger com ingredientes utilizando somente uma linha, poderá fazer assim:

cheeseburger = Cheeseburger.new.with PepperSauce, OnionRings, Corn

.
Veja como fica o diagrama de classes com essas alterações:

Cheeseburgers com Ruby e módulos

Cheeseburgers com Ruby e módulos

Mas e quanto à classe Sandwich? Não precisamos mais dela, podemos apagá-la sem problemas. E como o Fabio Kung diz: “Apagar código é melhor do que escrever código bom”.

Em relação à implementação em C# feita no primeiro post, tivemos a redução de 6 artefatos (classes e interfaces) para 4 artefatos (classe e módulos). Isso totalizou a eliminação de 100 linhas de código.

.
Dúvidas, questionamentos, discórdias, sugestões? Deixe seu comentário.

O código completo você encontra disponível aqui no meu Github.

.
Referências:


Arquitetura, Ruby , , , , , ,