Evitando condicionais com polimorfismo

Publicado por Thiago D. Chiarato no dia dev

Há algumas semanas tive que atualizar uma funcionalidade no RD Station e criar um novo comportamento para ela. Ele deveria ser semelhante ao já existente, mas com algumas exceções.

Fizemos um brainstorm para encontrar a melhor solução sem gerar duplicação de código. Então chegamos a um consenso. Por que não herdar o comportamento de uma classe base e utilizar herança, ou melhor dizendo, STI?

Entender padrões de projeto nem sempre é fácil e saber quando utilizá-los é ainda mais dificil. Por esse motivo, decidi compartilhar a minha experiência na tarefa.

Single Table Inheritance (STI) ou herança?

STI ou Single Table Inheritance é, sim, uma forma de herança. É basicamente uma forma de usar uma única tabela para refletir múltiplos modelos. Estes modelos herdam de um modelo base que, por sua vez, herda de ActiveRecord::Base.

O modelo relacional de banco de dados não suporta herança, desta forma, quando estamos mapeando nossos objetos relacionais para o banco de dados, precisamos definir como representar heranças de forma relacional, evitando Joins desnecessários entre múltiplas tabelas.

FOWLER, Martin. 2003

Como STI pode me ajudar a remover os IFs do meu código?

Imagine a seguinte situação. Você tem uma biblioteca e nessa biblioteca existem livros especializados em Agile, Design e Desenvolvimento de Software. Seguindo este cenário, poderíamos implementar nossa biblioteca da seguinte forma:

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
40
41
42
43
44
45
# models/library.rb
class Library < ActiveRecord::Base
  has_many :books
end

# models/book.rb
class Book < ActiveRecord::Base
  belongs_to :library

  attr_accessor :author, :title, :description, :price, :count, :book_type
  DOLLAR = 5.30

  def price
    case book_type
    when 'Agile'
      agile_books_price
    when 'Design'
      design_books_price
    when 'Development'
      development_books_price
    end
  end

  private

  def agile_books_price
    return price unless author == 'Bruno Ghisi'

    # Livros de Agile do Author Bruno Ghisi tem 5% de desconto
    price - 5 * price / 100
  end

  def design_books_price
    return price if count > 100

    # Os últimos 100 livros devem ter um acréscimo de 10%
    price + 10 * price / 100
  end

  def development_books_price
    # Livros de desenvolvimento são importados e portanto devem ter o seu preço
    # convertido em reais
    price * DOLLAR
  end
end

Há inúmeros problemas no código acima, por exemplo:

  • Adicionar uma nova categoria de livros resultaria na modificação da classe Book.
  • O método Book#price contém toda a lógica de todas as categorias, resultando em um método extenso.
  • Este provavelmente também não será o único lugar em que você irá checar o tipo do Livro.

Descomplicando com polimorfismo

Para começar, podemos criar 3 novas classes para cada categoria de livro da nossa biblioteca e herdar diretamente de Book:

1
2
3
4
5
6
7
8
9
10
11
# models/books/agile.rb
class Books::Agile < Book
end

# models/books/design.rb
class Books::Design < Book
end

# models/books/development.rb
class Books::Developmet < Book
end

Agora podemos extrair o código do método Book#price para suas respectivas classes:

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
# models/books/agile.rb
class Books::Agile < Book
  def price
    return price unless author == 'Bruno Ghisi'
    price - 5 * price / 100
  end
end

# models/books/design.rb
class Books::Design < Book
  def price
    return price if count > 100
    price + 10 * price / 100
  end
end

# models/books/development.rb
class Books::Developmet < Book
  def price
    price * DOLLAR
  end
end

# models/book.rb
class Book < ActiveRecord::Base
  DOLLAR = 5.30

  belongs_to :library

  attr_accessor :author, :title, :description, :price, :count, :book_type

  def price
    raise 'Abstract Method'
  end
end

Desta forma, delegamos a responsabilidade de cada categoria realizar as suas próprias regras. Além disso, a inclusão de novas categorias de livros não resultará na alteração das classes já existentes.

Migrando a base para utilizar STI

Até então, temos apenas herança pura, sem STI. Se nesse momento você salvar um objeto do tipo Books::Development e tentar recuperá-lo, terá apenas um tipo Book como retorno.

Por padrão, o ActiveRecord busca por uma coluna chamada type, onde ele possa salvar o tipo do modelo que está sendo inserido. Como já possuímos uma coluna type em nossa classe Book, podemos reaproveitá-la realizando um pequeno ajuste.

1
2
3
4
5
6
7
8
9
10
11
12
13
# models/book.rb
class Book < ActiveRecord::Base
  DOLLAR = 5.30

  belongs_to :library
  self.inheritance_column = :book_type

  attr_accessor :author, :title, :description, :price, :count, :book_type

  def price
    raise 'Abstract Method'
  end
end

Pro-Tips

Abaixo, levantei duas dicas para quem estiver usando ou quer utilizar STI:

  • Utilize scopes para facilitar a busca por tipos diferentes de livros.
1
2
3
scope :agile_books, -> { where(book_type: 'Books::Agile') }
scope :design_books, -> { where(book_type: 'Books::Design') }
scope :development_books, -> { where(book_type: 'Books::Development') }
  • Adicione delegates na classe Library.
1
delegate :agile_books, :design_books, :development_books to: :books

Agora temos nosso modelo funcionando com o padrão Single Table Inheritance. Quando instanciamos e salvamos um tipo Books::Development teremos sempre o mesmo comportamento de um livro de desenvovimento quando recuperá-lo do banco.

A adição de novas categorias de livros não exige ajustes na classe Book, basta criar um novo modelo e herdar da classe pai.

Com um código enxuto e de fácil manutenção, removemos todos os condicionais do primeiro exemplo sem reinventar a roda.

Thiago D. Chiarato

Thiago D. Chiarato

Full Stack Engineer

Comentários