Dicas de Design Orientado a Objetos com Ruby - Parte 2

Publicado por Luiz Cezer Marrone Filho no dia dev

Desenvolvimento de Software

Nesse segundo post da série de dicas sobre Design Orientado a Objetos, estarei dando continuidade ao assunto com algumas dicas dessa vez mais voltadas a dependência entre classes e objetos.

Se você ainda não leu o primeiro post, falei mais sobre como evitar uso de estruturas escondidas no código e como melhorar a coesão de classes e métodos utilizando o Single Responsibility Principle.

Entendendo as dependências

Antes de mais nada é preciso entender em que situações um objeto depende de outro e o que isso realmente pode acarretar no desenvolvimento.

  • Um objeto depende de outro quando uma mudança no Objeto/Classe A irá forçar uma mudança em B.
  • Ou então uma mudança em A poderá quebrar a implementação do Objeto/Classe B.

Dessa forma, uma classe que depende ou sabe muito da implementação de outra classe torna-se engessada, menos reaproveitável e mais complexa de alterá-la.

Reconhecendo uma dependência

Tomando como base o exemplo do código abaixo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# product.rb
class Product
  attr_reader :name, :price, :discount_percentage

  def initialize(name, price, discount_percentage)
    @name  = name
    @price = price
    @discount_percentage = discount_percentage
  end

  def price_with_discount
    DiscountCalculator.new(price, discount_percentage).calculate
  end
end

# discount_calculator.rb
class DiscountCalculator

  def initialize(price, discount_percentage)
    @price = price
    @discount_percentage = discount_percentage
  end

  def calculate
    price - discount
  end

  private

  def discount
    price * discount_percentage
  end

  attr_reader :price, :discount_percentage
end

É possível notar algumas características presentes em código onde existe uma alta dependência entre suas classes, sendo elas:

  • Uma classe conhecece o nome de outra classe internamente em seu código (a classe Product cria uma instância da classe DiscountCalculator).
  • Uma classe espera que outra classe dentro de sua implementação faça a chamada para determinado método (Product espera que classe DiscountCalculator responda ao método calculate).
  • Uma classe conhece quais argumentos a outra classe precisa (a classe Product sabe que DiscountCalculator, precisa do argumento price e discount_percentage).
  • A ordem dos argumentos que são passados para outra classe.
  • Uma classe possui um dependência explicita para outra classe (a classe Product chama explicitamente a classe DiscountCalculator).

Dessa forma, qualquer mudança feita na classe DiscountCalculator, desde mudança na sua inicialização, mudança em seus métodos, ou retorno do método calculate poderão forçar que ocorram mudanças na classe Product. Esse tipo de situação deve ser evitada ou minimizada no desenvolvimento de software afim de tornar cada classe mais coesa e menos acoplada.

É ideal que cada classe saiba apenas o suficiente para fazer o seu trabalho bem feito e nada mais além disso.

Porém como resolver esses problemas que são facilmente inseridos durante o desenvolvimento de software?

Irei listar algumas formas de como resolver ou ao menos minimizar essas situações.

Injeção de dependências

Novamente levando em conta o exemplo do código:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# product.rb
class Product
  attr_reader :name, :price, :discount_percentage

  def initialize(name, price, discount_percentage)
    @name  = name
    @price = price
    @discount_percentage = discount_percentage
  end

  def price_with_discount
    DiscountCalculator.new(price, discount_percentage).calculate
  end
end

# discount_calculator.rb
class DiscountCalculator

  def initialize(price, discount_percentage)
    @price = price
    @discount_percentage = discount_percentage
  end

  def calculate
    price - discount
  end

  private

  def discount
    price * discount_percentage
  end

  attr_reader :price, :discount_percentage
end

Product.new('Produto A', 10, 0.1).price_with_discount
# => 9.0

O maior problema nesse código é a dependência explícita que a classe Product tem para a classe DiscountCalculator, dessa forma além dos problemas citados anteriomente, a classe Product se torna menos reaproveitável. Com sua dependência explícita para DiscountCalculator, ela fecha seu escopo somente para calcular price_with_discount de instâncias de DiscountCalculator e caso uma outra classe também responda ao método calculate para calcular o desconto do produto, ela não pode ser utilizada para calcular price_with_discount devido a essa dependência.

Analisando novamente o código, fica claro que a classe Product não precisa saber qual é o objeto que responde ao método calculate, ela apenas precisa de um objeto que responda a tal método.

Levando isso em conta é possível fazer uma pequena alteração no código acima e garantir que ele funcione e fique mais flexível. Para realizar essa mudança, ao invés de utilizar diretamente a classe DiscountCalculator no método price_with_discount, a classe que irá calcular o desconto será injetada como dependência no momento da criação do objeto:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# discount_calculator.rb
class DiscountCalculator

  def initialize(product)
    @product = product
  end

  def calculate
    product.price - discount
  end

  private

  def discount
    product.price * product.discount_percentage
  end

  attr_reader :product
end

# product.rb
class Product
  attr_reader :name, :price, :discount_percentage, :discount_calculator

  def initialize(name, price, discount_percentage, discount_calculator)
    @name  = name
    @price = price
    @discount_percentage = discount_percentage
    @discount_calculator = discount_calculator
  end

  def price_with_discount
    discount_calculator.new(self).calculate
  end
end

Agora o método price_with_discount não possui uma dependência direta a DiscountCalculator, ele apenas utiliza um objeto que responda ao método calculate e faz seu cálculo. DiscountCalculator agora recebe uma instância de product para realizar o cálculo, evitando receber argumentos diretamente de outra classe.

Essas pequenas mudanças além de acabar com a dependência explícita, também deixaram o código de Product mais flexível e agora qualquer classe que responda ao método calculate poderá ser injetada conforme a necessidade.

Isolando as dependências

Nem sempre é possível aplicar a injeção de dependência, nessa situação existem algumas alternativas que podem ser utilizadas.

A primeira seria deixar a dependência explicita no momento da criação do objeto dependente e não no método dependente:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Product
  attr_reader :name, :price, :discount_percentage, :discount_calculator

  def initialize(name, price, discount_percentage)
    @name  = name
    @price = price
    @discount_percentage = discount_percentage
    @discount_calculator = DiscountCalculator.new(self)
  end

  def price_with_discount
    discount_calculator.calculate
  end
end

Dessa forma, sempre que uma nova instância de Product for criada, também já irá criar uma de DiscountCalculator para posteriomente ser utilizada dentro do método price_with_discount.

Uma outra alternativa é criar um método para isolar a criação da dependência:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Product
  attr_reader :name, :price, :discount_percentage

  def initialize(name, price, discount_percentage)
    @name  = name
    @price = price
    @discount_percentage = discount_percentage
  end

  def price_with_discount
    discount_calculator.calculate
  end

  private

  def discount_calculator
    @discount ||= DiscountCalculator.new(self)
  end
end

Essa solução é um pouco melhor, pois isolá-se a criação de DiscountCalculator em um método responsável por isso, deixando essa criação da dependência centralizada.

Por padrão Ruby também oferece o módulo Forwardable que provê uma forma simples de delegar métodos para outros objetos, conforme exemplo abaixo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
require 'forwardable'

# product.rb
class Product
  extend Forwardable

  attr_reader :name, :price, :discount_percentage

  def_delegator :@discount_calculator, :calculate, :price_with_discount

  def initialize(name, price, discount_percentage, discount_calculator)
    @name  = name
    @price = price
    @discount_percentage = discount_percentage
    @discount_calculator = discount_calculator.new(self)
  end
end

# discount_calculator.rb
class DiscountCalculator
  def initialize(product)
    @product = product
  end

  def calculate
    product.price - discount
  end

  private

  def discount
    product.price * product.discount_percentage
  end

  attr_reader :product
end

Product.new('Produto A', 10, 0.2, DiscountCalculator).price_with_discount
# => 8.0

Removendo a dependência da ordem dos argumentos

Desconfie de classes ou métodos que recebem muitos argumentos em suas chamadas, pois sempre que forem executados e a ordem dos argumentos não for a correta ou algum deles for nulo, a classe não será instanciada ou o método pode gerar uma exceção.

Pior do que isso, se a assinatura do método ou inicialização da classe mudar, toda essa mudança terá que ser propagada a todos os lugares onde eles são utilizados.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Customer

  attr_reader :name, :genre, :birthdate

  def new(name, genre, birthdate)
    @name      = name
    @genre     = genre
    @birthdate = birthdate
  end

  def age
    Date.today.year - @birthdate
  end
end

# Passando valores na ordem errada
Customer.new('male', 1990, 'Luiz')

Nesse exemplo um dos argumentos foi enviado na ordem trocada, o que irá ocasionar um erro na execução da classe.

Para minimizar os riscos dessa situação e eventuais problemas devido a order dos argumentos, é possível fazer o uso de um Hash que espere os argumentos, dessa forma a ordem enviada ao método e/ou classe não importa, o que importa é apenas que todos os argumentos necessários sejam passados, como no exemplo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Customer

  attr_reader :name, :genre

  def new(args = {})
    @name        = args[:name]
    @genre       = args[:genre]
    @birthdate   = args[:birthdate]
  end

  def age
    Date.today.year - @birthdate
  end
end

Customer.new({ genre: 'male', age: 1990, name: 'Luiz' })

Outra dica importante é sempre lembrar que métodos e/ou classes não devem receber muito argumentos, preferencialmente no máximo quatro como sugere Sandi Metz em suas regras para desenvolvedores.

Para facilitar nosso dia a dia aqui na Resultados Digitais fazemos uso do Reek para analisar nossos códigos e verificar bad smells como esse.

Utilização de valores padrões

Em alguns casos quando se trabalha com métodos que recebem um Hash de argumentos é uma boa prática mapear valores padrões, para que esses sejam utilizados em caso de uma das chaves não seja enviada como argumento.

Uma das maneiras é utilizando o método Hash#fetch e com ele definir valores padrões para cada uma das chaves:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Person
  attr_reader :name, :genre, :age

  def initialize(args = {})
    @name  = args.fetch(:name, 'Luiz')
    @genre = args.fetch(:genre, 'male')
    @age   = args.fetch(:age, 26)
  end
end

Person.new
# => #<Person:0x000000019e1ff0 @name="Luiz", @genre="male", @age=26>

Person.new(age: 30, name: 'Cezer')
# => #<Person:0x000000016aa8c8 @name="Cezer", @genre="male", @age=30>

Outra alternativa seria criando um método privado que retorne um hash com valores padrões:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Person
  attr_reader :name, :genre, :age

  def initialize(args = {})
    options = default_options.merge(args)

    @name  = options[:name]
    @genre = options[:genre]
    @age   = options[:age]
  end

  private

  def default_options
    { name: 'Luiz', genre: 'male', age: 26 }
  end
end

Person.new
# => #<Person:0x00000001527028 @name="Luiz", @genre="male", @age=26>

Person.new(age: 36)
# => #<Person:0x00000001320d88 @name="Luiz", @genre="male", @age=36>

Ou também criar uma constante com os valores padrões do Hash:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Person
  DEFAULT_VALUES = { name: 'Luiz', genre: 'male', age: 26 }

  attr_reader :name, :genre, :age

  def initialize(args = {})
    options = DEFAULT_VALUES.merge(args)

    @name  = options[:name]
    @genre = options[:genre]
    @age   = options[:age]
  end
end

Person.new
# => #<Person:0x00000001527028 @name="Luiz", @genre="male", @age=26>

Person.new(age: 36)
# => #<Person:0x00000001320d88 @name="Luiz", @genre="male", @age=36>

A partir da versão 2.0 do Ruby também é possível fazer uso de Keywords Argument, que permitem que os valores padrões de um Hash sejam definidos na assinatura do método.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Person
  attr_reader :name, :genre, :age

  def initialize(name: 'Luiz', genre: 'male', age: 26)
    @name  = name
    @genre = genre
    @age   = age
  end
end

Person.new
# => #<Person:0x00000001527028 @name="Luiz", @genre="male", @age=26>

Person.new(age: 36, name: 'Cezer')
# => #<Person:0x00000001320d88 @name="Cezer", @genre="male", @age=36>

Isolar dependências de API externas

Existem alguns casos se faz necessário o uso de alguma API externa ou serviço de terceiro, nessas situações quem está desenvolvendo não tem controle sobre a assinatura do método a ser chamado, apenas faz seu uso enviado os parâmetros requeridos.

Nesses casos é ideal criar um Wrapper ou um tipo de Adapter que englobe essa dependência externa e crie uma interface própria de sua aplicação, dessa forma isola-se o contato de sua aplicação com a aplicação externa somente nesse ponto do software.

Aqui na Resultados Digitais utilizamos Rollbar como ferramenta que nos ajuda a rastrear erros que podem ser lançados no software.

Para utilizar sua API, implementamos um Wrapper que centraliza a chamada direta a API do Rollbar em apenas um ponto da aplicação.

1
2
3
4
5
module ExceptionsWrapper
  def self.watch(*args)
    Rollbar.error(*args)
  end
end

Dessa forma, sempre que necessitamos que um código seja monitorado e envie uma notificação ao Rollbar, fazemos o uso desse Wrapper.

1
ExceptionsWrapper.watch('Some error', { account_id: 10000 })

Essa abordagem facilita muito uma possível troca de serviço de rastreamento de erros, já que com o Wrapper, apenas a chamada interna para a API precisa ser alterada, sem que seja necessário alterar todos os lugares onde o Wrapper é executado.

Considerações finais

  • Utilizar injeção de dependência pode gerar objetos menos acoplados, que podem ser usados em outros contextos e torna o software mais flexível.
  • Isolar a criação de dependências irá fazer os objetos se adaptarem mais facilmente a novas mudanças.
  • Sempre que utilizar um serviço de terceiro, prefira fazer um encapsulamento para uma classe de seu controle ao invés de fazer o uso direto do serviço.

E você, concorda com as práticas citadas, tem alguma prática diferente?

Compartilhe nos comentários.

Luiz Cezer Marrone Filho

Luiz Cezer Marrone Filho

Full Stack Developer

Comentários