Ruby Science
Replace Conditional with Polymorphism
Conditional code clutters methods, makes extraction and reuse harder
and can lead to leaky concerns. Object-oriented languages like Ruby
allow developers to avoid conditionals using polymorphism. Rather than
using if
/else
or
case
/when
to create a conditional path for
each possible situation, you can implement a method differently in
different classes, adding (or reusing) a class for each situation.
Replacing conditional code allows you to move decisions to the best point in the application. Depending on polymorphic interfaces will create classes that don’t need to change when the application changes.
Uses
- Removes divergent change from classes that need to alter their behavior based on the outcome of the condition.
- Prevents shotgun surgery from adding new types.
- Removes feature envy by allowing dependent classes to make their own decisions.
- Makes it easier to remove duplicated code by taking behavior out of conditional clauses and private methods.
- Makes conditional logic easier to reuse, which makes it easier to avoid duplication.
- Replaces conditional logic with simple commands, following tell, don’t ask.
Example
This Question
class summarizes its answers differently
depending on its question_type
:
# app/models/question.rb
class Question < ActiveRecord::Base
include ActiveModel::ForbiddenAttributesProtection
SUBMITTABLE_TYPES = %w(Open MultipleChoice Scale).freeze
:maximum, presence: true, if: :scale?
validates :minimum, presence: true, if: :scale?
validates :question_type, presence: true, inclusion: SUBMITTABLE_TYPES
validates :title, presence: true
validates
:survey
belongs_to :answers
has_many :options
has_many
:options, reject_if: :all_blank
accepts_nested_attributes_for
def summary
case question_type
when 'MultipleChoice'
summarize_multiple_choice_answerswhen 'Open'
summarize_open_answerswhen 'Scale'
summarize_scale_answersend
end
def steps
..maximum).to_a
(minimumend
private
def scale?
== 'Scale'
question_type end
def summarize_multiple_choice_answers
= answers.count
total = answers.group(:text).order('COUNT(*) DESC').count
counts = counts.map do |text, count|
percents = (100.0 * count / total).round
percent "#{percent}% #{text}"
end
.join(', ')
percentsend
def summarize_open_answers
.order(:created_at).pluck(:text).join(', ')
answersend
def summarize_scale_answers
sprintf('Average: %.02f', answers.average('text'))
end
end
There are a number of issues with the summary
method:
- Adding a new question type will require modifying the method, leading to divergent change.
- The logic and data for summarizing every type of question and answer
is jammed into the
Question
class, resulting in a large class with obscure code. - This method isn’t the only place in the application where question types are checked, meaning that new types will cause shotgun surgery.
There are several ways to refactor to use polymorphism. In this chapter, we’ll demonstrate a solution that uses subclasses to replace type codes, which is one of the simplest solutions to implement. However, make sure to see the Drawbacks section in this chapter for alternative implementations.
Replace Type Code with Subclasses
Let’s replace this case statement with polymorphism by introducing a subclass for each type of question.
Our Question
class is a subclass of
ActiveRecord::Base
. If we want to create subclasses of
Question
, we have to tell ActiveRecord which subclass to
instantiate when it fetches records from the questions
table. The mechanism Rails uses for storing instances of different
classes in the same table is called single
table inheritance. Rails will take care of most of the details, but
there are a few extra steps we need to take when refactoring to single
table inheritance.
Single Table Inheritance (STI)
The first step to convert to STI is generally to create a new subclass for each type. However, the existing type codes are named “Open,” “Scale” and “MultipleChoice,” which won’t make good class names. Names like “OpenQuestion” would be better, so let’s start by changing the existing type codes:
# app/models/question.rb
def summary
case question_type
when 'MultipleChoiceQuestion'
summarize_multiple_choice_answerswhen 'OpenQuestion'
summarize_open_answerswhen 'ScaleQuestion'
summarize_scale_answersend
end
# db/migrate/20121128221331_add_question_suffix_to_question_type.rb
class AddQuestionSuffixToQuestionType < ActiveRecord::Migration
def up
.update(<<-SQL)
connection UPDATE questions SET question_type = question_type || 'Question'
SQL
end
def down
.update(<<-SQL)
connection UPDATE questions SET question_type = REPLACE(question_type, 'Question', '')
SQL
end
end
See commit b535171 for the full change.
The Question
class stores its type code as
question_type
. The Rails convention is to use a column
named type
, but Rails will automatically start using STI if
that column is present. That means that renaming
question_type
to type
at this point would
result in debugging two things at once: possible breaks from renaming
and possible breaks from using STI. Therefore, let’s start by just
marking question_type
as the inheritance column, allowing
us to debug STI failures by themselves:
# app/models/question.rb
'question_type' set_inheritance_column
Running the tests after this will reveal that Rails wants the subclasses to be defined, so let’s add some placeholder classes:
# app/models/open_question.rb
class OpenQuestion < Question
end
# app/models/scale_question.rb
class ScaleQuestion < Question
end
# app/models/multiple_choice_question.rb
class MultipleChoiceQuestion < Question
end
Rails generates URLs and local variable names for partials based on
class names. Our views will now be getting instances of subclasses like
OpenQuestion
rather than Question
, so we’ll
need to update a few more references. For example, we’ll have to change
lines like:
<%= form_for @question do |form| %>
To:
<%= form_for @question, as: :question do |form| %>
Otherwise, it will generate /open_questions
as a URL
instead of /questions
. See commit
c18ebeb for the full change.
At this point, the tests are passing with STI in place, so we can
rename question_type
to type
, following the
Rails convention:
# db/migrate/20121128225425_rename_question_type_to_type.rb
class RenameQuestionTypeToType < ActiveRecord::Migration
def up
:questions, :question_type, :type
rename_column end
def down
:questions, :type, :question_type
rename_column end
end
Now we need to build the appropriate subclass instead of
Question
. We can use a little Ruby meta-programming to make
that fairly painless:
# app/controllers/questions_controller.rb
def build_question
@question = type.constantize.new(question_params)
@question.survey = @survey
end
def type
[:question][:type]
paramsend
At this point, we’re ready to proceed with a regular refactoring.
Extracting Type-Specific Code
The next step is to move type-specific code from
Question
into the subclass for each specific type.
Let’s look at the summary
method again:
# app/models/question.rb
def summary
case question_type
when 'MultipleChoice'
summarize_multiple_choice_answerswhen 'Open'
summarize_open_answerswhen 'Scale'
summarize_scale_answersend
end
For each path of the condition, there is a sequence of steps.
The first step is to use extract method to move
each path to its own method. In this case, we already extracted methods
called summarize_multiple_choice_answers
,
summarize_open_answers
, and
summarize_scale_answers
, so we can proceed immediately.
The next step is to use move
method to move the extracted method to the appropriate class. First,
let’s move the method summarize_multiple_choice_answers
to
MultipleChoiceQuestion
and rename it to
summary
:
class MultipleChoiceQuestion < Question
def summary
= answers.count
total = answers.group(:text).order(Arel.sql('COUNT(*) DESC')).count
counts = counts.map do |text, count|
percents = (100.0 * count / total).round
percent "#{percent}% #{text}"
end
.join(', ')
percentsend
end
MultipleChoiceQuestion#summary
now overrides
Question#summary
, so the correct implementation will now be
chosen for multiple choice questions.
Now that the code for multiple choice types is in place, we repeat
the steps for each other path. Once every path is moved, we can remove
Question#summary
entirely.
In this case, we’ve already created all our subclasses, but you can use extract class to create them if you’re extracting each conditional path into a new class.
You can see the full change for this step in commit a08f801.
The summary
method is now much better. Adding new
question types is easier. The new subclass will implement
summary
and the Question
class doesn’t need to
change. The summary code for each type now lives with its type, so no
one class is cluttered up with the details.
Polymorphic Partials
Applications rarely check the type code in just one place. Running grep on our example application reveals several more places. Most interestingly, the views check the type before deciding how to render a question:
# app/views/questions/_question.html.erb<% if question.type == 'MultipleChoiceQuestion' -%>
<ol>
<% question.options.each do |option| -%>
<li>
<%= submission_fields.radio_button :text, option.text, id: dom_id(option) %>
<%= content_tag :label, option.text, for: dom_id(option) %>
</li>
<% end -%>
</ol>
<% end -%>
<% if question.type == 'ScaleQuestion' -%>
<ol>
<% question.steps.each do |step| -%>
<li>
<%= submission_fields.radio_button :text, step %>
<%= submission_fields.label "text_#{step}", label: step %>
</li>
<% end -%>
</ol>
<% end -%>
<% if question.type == 'OpenQuestion' -%>
<%= submission_fields.text_field :text %>
<% end -%>
In the previous example, we moved type-specific code into
Question
subclasses. However, moving view code would
violate MVC (introducing divergent change into
the subclasses) and, more importantly, it would be ugly and hard to
understand.
Rails has the ability to render views polymorphically. A line like this—
<%= render @question %>
—will ask @question
which view should be rendered by
calling to_partial_path
. As subclasses of
ActiveRecord::Base
, our Question
subclasses
will return a path based on their class name. This means that the above
line will attempt to render
open_questions/_open_question.html.erb
for an open
question, and so on.
We can use this to move the type-specific view code into a view for each type:
# app/views/open_questions/_open_question.html.erb<%= submission_fields.text_field :text %>
You can see the full change in commit 8243493.
Multiple Polymorphic Views
Our application also has different fields on the question form depending on the question type. Currently, that also performs type-checking:
# app/views/questions/new.html.erb<% if @question.type == 'MultipleChoiceQuestion' -%>
<%= form.fields_for(:options, @question.options_for_form) do |option_fields| -%>
<%= option_fields.input :text, label: 'Option' %>
<% end -%>
<% end -%>
<% if @question.type == 'ScaleQuestion' -%>
<%= form.input :minimum %>
<%= form.input :maximum %>
<% end -%>
We already used views like
open_questions/_open_question.html.erb
for showing a
question, so we can’t just put the edit code there. Rails doesn’t
support prefixes or suffixes in render
, but we can do it
ourselves easily enough:
# app/views/questions/new.html.erb<%= render "#{@question.to_partial_path}_form", question: @question, form: form %>
This will render
app/views/open_questions/_open_question_form.html.erb
for
an open question, and so on.
Drawbacks
It’s worth noting that, although this refactoring improved this particular example, replacing conditionals with polymorphism is not without its drawbacks.
Using polymorphism like this makes it easier to add new types, because adding a new type means that you just need to add a new class and implement the required methods. Adding a new type won’t require changes to any existing classes, and it’s easy to understand what the types are because each type is encapsulated within a class.
However, this change makes it harder to add new behaviors. Adding a new behavior will mean finding every type and adding a new method. Understanding the behavior becomes more difficult because the implementations are spread out among the types. Object-oriented languages lean toward polymorphic implementations, but if you find yourself adding behaviors much more often than adding types, you should look into using observers or visitors instead.
Using subclasses forces you to use inheritance instead of composition for reuse and separation of concerns. See composition over inheritance for more on this subject.
Also, using STI has specific disadvantages. See the chapter on STI for details.
Next Steps
- Check the new classes for duplicated code that can be pulled up into the superclass.
- Pay attention to changes that affect the new types, watching out for shotgun surgery that can result from splitting up classes.
Ruby Science
The canonical reference for writing fantastic Rails applications from authors who have created hundreds.