Ruby on Rails is all about abstraction. It is important to make the code reusable and hold it all together. On the other hand too much abstraction might hamper the software and make code hard to understand. Today we will talk about the right balance between abstraction and special cases in Ruby on Rails development and learn how to use them correctly.
For or Each?
Newbies in Ruby on Rails are often puzzled with what should they use: for or each.
For is a special case and each is an abstraction. Let’s see how they look like:
For:
1 2 3 |
for student in students puts student.address end |
Each:
1 2 3 |
students.each do |student| puts student.address end |
For is not so commonly used in Ruby and there’s a reason for that: it creates a single concept and will work only for a single case. At the same time each is much easier to repurpose, it is widely used in so-called method and blocks approach used in Ruby on Rails. And the best thing about each is that one can build it himself in Ruby:
1 2 3 4 5 6 7 8 9 |
class Array def each(&block) unless empty? first, *rest = self block.call(first) rest.each(&block) end end end |
In the block above a number of basic methods are used, but we are sure pretty soon you will get totally familiar and comfortable with them.
At the same time you should keep in mind that for, being a special syntax, is not built on anything, so one can’t implement it himself.
Being able to implement idea using what you already got significantly reduces the amount of abstractions required, so we highly recommend this approach.
Nil or Array?
Another commonly used conception is nil. It is used in cases when there can be or can be not a value. For example there can be a field address, which some students have filled in and some have not. For those, who haven’t, nil is returned.
Let’s see how it works.
1 2 3 4 5 |
Student.new(address: "Green street 12").address => "Green street 12" Student.new(address: nil). address => nil |
Though looking pretty good, nil implies that we have to verify existence of the address first:
1 2 3 4 5 |
students.each do |student| unless student. address.nil? StudentMailer.update(student. address, update).deliver_now! end end |
Is this abstraction really necessary? We think that in such cases Array can be more efficient.
1 2 3 4 5 6 7 8 9 10 11 |
Student.new(addresses: ["Green street 12"]).address => ["Green street 12"] Student.new(address: []).address => [] students.each do |student| student.addresses.each do | address | StudentMailer.update(student.address, update).deliver_now! end end |
While nil says that there be or not be something, Array is saying that there’s unknown number of something, which is a much more generic solution. Nil can be only 1 or 0, whereas Array can be any number. No wonder we recommend avoiding abstract nil in such cases.
Conditionals or Null Object?
Every time you use conditionals, you are basically creating a special case. Common abstractions can easily replace them:
Conditional:
1 2 3 4 5 |
students.each do |student| unless student.address.nil? StudentMailer.update(student.address, update).deliver_now! end end |
Abstraction:
1 2 3 |
students.map(&:address).compact.each do |address| StudentMailer.update(address, update).deliver_now! end |
Null Object abstraction can also help you get rid of a bunch of special cases.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class Guest def admin? false end def can_edit?(post) false end def name "Guest" end end |
This calls lets you not bother whether the student is signed in. In another place you can repurpose it to define guests behavior.
How to use reduce abstraction?
If you want your code to be even easier to understand, we recommend to perform folding with the help of reduce abstraction. Let’s compare special case and abstraction.
Special cases:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
class Game def score result = 0 rounds.each { |round| result += round.score } result end def competitors result = [] rounds.each do |round| round.students.each do |student| result << student.address end end result end end |
Abstraction:
1 2 3 4 5 6 7 8 9 |
class Game def score rounds.reduce(0) { |result, round| result + round.score } end def competitors rounds.reduce([]) { |result, round| result + round.students.map(&:address) } end end |
Here one can add additional level of abstraction:
1 2 3 4 5 6 7 8 9 |
class Game def score rounds.sum(&:score) end def competitors rounds.map(&:students).flat_map(&:address) end end |
Sum and flat_map would allow providing a specialization for a fold.
As you see there’s no answer to what you should use: special cases or abstraction. The clue to success lies in keeping it balanced and providing the most unified solution possible.
Questions? Comments? Let’s talk about them in the comments section below.