Página Inicial > Arquitetura, Ruby > Cheeseburgers, Decorators e Ruby

Cheeseburgers, Decorators e Ruby

No post Cheeseburgers, Decorators e Mocks eu mostrei um exemplo prático de utilização do Design Pattern Decorator, que começa com um design usando herança, desaclopa usando composição e finalmente aplica Decorator. Tudo isso foi feito em .NET com C#. Agora vamos fazer o mesmo exemplo de Decorator Pattern utilizando Ruby. Para entender melhor o contexto do exemplo utilizado, sugiro que você leia antes o post anterior.

Imagem original de MarketFare Foods, Inc.

Imagem original de MarketFare Foods, Inc.

Além de Ruby, utilizarei o RSpec como ferramenta de testes unitários. No final do post há os links para baixar o código completo.

.
Cheeseburgers com Ruby
A principal diferença de implementação do Design Pattern Decorator em Ruby é a não utilização de classes abstratas (ou interfaces). No Ruby não existe classes abstratas ou interfaces, com isso eliminamos um elemento na interação entre os objetos.

Veja como fica o diagrama de classes com a estrutura do padrão:

Decorator em Ruby

Decorator em Ruby

Onde:

  • Component define a interface para objetos que podem ter responsabilidades acrescentadas aos mesmos dinamicante;
  • ConcreteComponent define um objeto para o qual responsabilidades adicionais podem ser atribuídas;
  • Decorator mantém uma referência para um objeto Component e acrescenta responsabilidades ao componente.

Vamos criar as classes do nosso exemplo, mas diferente do post anterior, vou mostrar primeiros os testes e depois as implementações.

Primeiro vamos fazer o teste da classe Cheeseburger, que servirá como ConcreteComponent.

describe Cheeseburger, " when created" do
  it "should have default calories and description" do
    cheeseburger = Cheeseburger.new
    cheeseburger.calories.should == 300
    cheeseburger.description.should == "Bread, Hamburger, Cheese"
  end
end

O teste fala por si só. Agora vamos à implementação.

class Cheeseburger
  attr_reader :description, :calories

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

O próximo passo são os testes da classe Sandwich, o nosso Component.

describe Sandwich do
  before(:all) do
    @real_sandwich = mock Cheeseburger
    @sandwich = Sandwich.new @real_sandwich
  end

  it "should call description in real sandwich" do
    @real_sandwich.should_receive(:description).once.and_return("Sandwich description")
    @sandwich.description.should == "Sandwich description"
  end

  it "should call calories in real sandwich" do
    @real_sandwich.should_receive(:calories).once.and_return(150)
    @sandwich.calories.should == 150
  end
end

O bloco before (linhas 2 a 5) é executado somente uma vez antes da execução dos testes. Nele criamos um mock da classe Cheeseburger que é passado como parâmetro na criação da instância da classe Sandwich. Os testes asseguram que os métodos description (linha 8 ) e calories (linha 13) são chamados nos mocks e seus valores são retornados corretamente (linhas 9 e 14).

Então vamos à implementação da classe Sandwich.

class Sandwich
  def initialize(real_sandwich)
    @real_sandwich = real_sandwich
  end

  def description
    @real_sandwich.description
  end

  def calories
    @real_sandwich.calories
  end
end

Agora só faltam nossos Decorators. Seguem os testes da classe Corn:

describe Corn do
  before(:all) do
    @sandwich = mock Sandwich
    @corn = Corn.new @sandwich
  end

  it "should add corn description to sandwich description" do
    @sandwich.should_receive(:description).once.and_return("Sandwich description")
    @corn.description.should == "Sandwich description, Corn"
  end

  it "should add corn calories to sandwich calories" do
    @sandwich.should_receive(:calories).once.and_return(100)
    @corn.calories.should == 170
  end
end

Da mesma forma que fizemos nos testes da classe Sandwich, criamos uma instância de Corn passando um mock da classe Sandwich no bloco before (linhas 2 a 5). Os testes asseguram que a descrição e as calorias do milho será adicionadas ao sanduíche.

Vamos implementar nossa classe Corn.

class Corn < Sandwich
  def initialize(real_sandwich)
    super real_sandwich
  end

  def description
    "#{@real_sandwich.description}, Corn"
  end

  def calories
    @real_sandwich.calories + 70
  end
end

A classe Corn herda a classe Sandwich e o método super chamado na linha 3 vai chamar o método initialize da classe base. O método description (linhas 6 a 8 ) é sobrescrito para retornar a descrição do sanduíche original juntamente com a descrição de milho. O método calories também é sobrescrito (linhas 10 a 12) e soma o retorno do método calories do sanduíche original com as calorias do milho.

O mesmo é feito para as classes OnionRings e PepperSauce, tanto na implementação como nos testes.

class OnionRings < Sandwich
  def initialize(real_sandwich)
    super real_sandwich
  end

  def description
    "#{@real_sandwich.description}, Onion Rings"
  end

  def calories
    @real_sandwich.calories + 140
  end
end
class PepperSauce < Sandwich
  def initialize(real_sandwich)
    super real_sandwich
  end

  def description
    "#{@real_sandwich.description}, Pepper Sauce"
  end

  def calories
    @real_sandwich.calories + 20
  end
end

.
E o nosso diagrama de classes fica assim:

Cheeseburgers com Ruby

Cheeseburgers com Ruby

Note que a classe Cheeseburger não herda de Sandwich. Como no Ruby as coisas são flexíveis, na hora de instanciar um Decorator (Corn, OnionRings ou PepperSauce) você não precisa necessariamente passar como parâmetro um objeto que herde da classe Sandwich. Basta que o objeto passado possua os métodos description e calories. Aqui é que entra em ação o duck typing do Ruby:

If it walks like a duck and quacks like a duck, I would call it a duck.

No nosso caso, se um o objeto se parece com um sanduíche, ou seja, tem uma descrição e quantidade de calorias, então nós o chamamos de saduíche. O duck typing permite a utilização de polimorfismo sem herança.

Agora já podemos montar os cheeseburgers de Itararé e Ilhéus, bem como outras variações. Veja alguns testes:

describe Cheeseburger do
  before(:each) do
    @cheeseburger = Cheeseburger.new
  end

  it "should be an Itarare Cheeseburger" do
    @cheeseburger = Corn.new @cheeseburger
    @cheeseburger.description.should == "Bread, Hamburger, Cheese, Corn"
    @cheeseburger.calories.should == 370
  end

  it "should be an Itarare Cheeseburger with onion rings" do
    @cheeseburger = Corn.new @cheeseburger
    @cheeseburger = OnionRings.new @cheeseburger
    @cheeseburger.description.should == "Bread, Hamburger, Cheese, Corn, Onion Rings"
    @cheeseburger.calories.should == 510
  end

  # Outros testes
end

A decoração de objetos segue quase a mesma linha do fizemos em C# no post anterior. Vamos detalhar o segundo teste para entendermos as diferenças. A variável @cheeseburger é decorada duas vezes, uma vez com a classe Corn (linha 13) e outra com a classe OnionRings (linha 15).

Quando o método calories da variável @cheeseburger é chamado na linha 16, estamos chamando o método calories do último decorador, ou seja, da classe OnionRings. Depois contamos com a delegação para adicionar as calorias dos ingredientes. Veja o acontece:

  1. OnionRings chama o método calories do objeto Corn que foi passado como parâmetro quando a classe OnionRings foi instanciada;
  2. Corn chama o método calories do objeto Cheeseburger que foi passado como parâmetro quando a classe Corn foi instanciada;
  3. Cheeseburger retorna 300 calorias;
  4. Corn adiciona suas 70 calorias ao retorno do objeto Cheeseburger e retorna 370 calorias;
  5. OnionRings adiciona suas 140 calorias ao retorno do objeto Corn e retorna 510 calorias.

Em relação à implementação em C#, o Ruby eliminou 4 passos.

.
Legal, mas nós podemos melhorar?
Sim, nós podemos.

A classe Sandwich não faz nada mais além de delegar as chamadas dos métodos description e calories. Como nós temos somente dois métodos, isso até que não é um problema. Mas e se tivéssemos, por exemplo, uns 10 métodos? Vai ser um saco ficar escrevendo esses métodos que delegam para outros métodos.

Para resolver isso, o Ruby nos fornece um módulo chamado Forwardable, que gera automaticamente todos esses métodos de delegação para nós. Vamos reescrever a classe Sandwich utilizando o módulo Fowardable.

require 'forwardable'

class Sandwich
  extend Forwardable

  def initialize(real_sandwich)
    @real_sandwich = real_sandwich
  end

  def_delegators :@real_sandwich, :description, :calories
end

O módulo Fowardable fornece o método def_delegators, que recebe dois ou mais parâmetros. O primeiro parâmetro é o nome da variável de instância que será usada para delegar as chamadas dos métodos. Os outros parâmetros são os nomes dos métodos que queremos delegar. Na linha 10, o método def_delegators adiciona os métodos description e calories para a classe Sandwich, e cada um deles irá delegar sua chamada para o respectivo método para o objeto armazenado na variável @real_sandwich.

Um detalhe importante é que estamos extendendo o módulo Fowardable (linha 4) ao invés de incluí-lo (utilizando o método include). Isso se deve ao fato que queremos adicionar métodos de classe e não métodos de instância.

Depois dessa alteração na classe Sandwich, rodamos os testes e tudo funciona. Mas algo que ainda me incomoda um pouco é ter que escrever uma linha de código para cada novo ingrediente que acrescentamos no cheeseburger. Vamos pegar como exemplo esse teste:

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

Na linha 2 criamos um cheeseburger e nas linhas seguintes adicionamos seus ingredientes. Se quisermos fazer a mesma coisa em somente uma linha, vai ficar assim:

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

Hum… A descrição do teste diz que deveria ser um cheeseburger com molho de pimenta, cebola e milho. Mas lendo o código, dá a impressão que estamos criando um milho com cebola, molho de pimenta e cheeseburger. E se a gente tentar deixar mais legível invertendo a ordem da criação dos objetos? Vamos alterar a linha 3 assim:

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

E adivinha o que acontece! O teste não passa! O método initialize da classe Cheeseburger não tem nenhum parâmetro, então é lançado um ArgumentError ao rodar o teste. Além disso, o método initialize da classe Corn precisa receber um parâmetro, o qual não foi passado. Mais um ArgumentError.

Você está conformado com isso? Eu não. Sendo assim, vamos ter que usar um pouco de magia negra para melhorar essa situação. Mas isso fica para o próximo post.

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

O código completo com todas as classes e seus testes está disponível aqui no meu Github.

.
Referências:


Arquitetura, Ruby , , , , , , , ,