Everyone’s special…which is another way of saying no one is.
— The Incredibles
Developers live in a world of abstraction. Rails abstracts away the details of web requests, providing you with a world of routes and controllers in place of requests and responses. Ruby hides the fact that you’re constantly allocating and deallocating memory. Ruby doesn’t have to tell the computer how to allocate memory, though, because it’s written in C.
We can’t live without abstractions, because there’s too much detail in our world to hold in all at once. We have to build bigger concepts out of smaller concepts until we can fit them all in our human brains.
One of the great challenges of writing usable code is deciding what and when to abstract. We want just enough abstraction that we can hold the problem in our heads, but not so much that we can’t tell what’s actually going on.
One technique for keeping that balance is to use fewer abstractions by avoiding special cases. Specializations can be easy to learn because they’re more concrete, but we need more special cases to solve most problems because each special case only applies to a specific situation.
Custom Syntax
Let’s look at a special case in Ruby:
for user in users
puts user.email
end
The for
loop is rarely used in Ruby. Most authors prefer to write:
users.each do |user|
puts user.email
end
Why? One is a special case, and one is an abstraction.
The for
loop introduces new syntax and keywords for a single concept. The
contents will be performed for each item in a list. Once you’ve learned how
for
loops work, you know how to do one thing. You’ve gained one ability for
one keyword. That’s not a great return on your investment. If you’re going to
fit a lot of programming concepts in your head, you’ll need to do better!
The each
method builds on widely-used concepts from Ruby: methods and blocks.
To truly understand each
, you need a solid understanding of some more abstract
concepts. This takes longer, but you’ll be well-rewarded for your efforts:
almost every problem in Ruby is phrased in terms of objects, methods, and
blocks.
Another nice aspect of the each
abstraction is that we can build it ourselves
in Ruby:
class Array
def each(&block)
unless empty?
first, *rest = self
block.call(first)
rest.each(&block)
end
end
end
Our implementation introduces some new concepts we have to understand:
conditionals, the unless
keyword, the empty?
and call
methods, array
globbing, recursion, and block arguments. Veteran Rubyists may not realize how
many concepts are involved in what we consider a basic building block of
application logic, but you had to learn every one of these concepts at some
point to use them effectively.
On the other hand, we can’t implement a for
loop ourselves. We can’t break it
down any further, because it’s a special syntax and doesn’t build on other
concepts from Ruby.
If you can implement a new idea in terms of other ideas that are already defined, you don’t need as many abstractions in total. How many ideas can you eliminate from your program by building and reusing abstractions?
Empty Cases
Many abstractions are just a more specific version of another existing
abstraction. Let’s look at a popular example: nil
.
We use nil
to represent something that could be there, but isn’t. Some users
may have emails, while others don’t. Those users will return nil
when asked for
their email.
User.new(email: "user@example.com").email
=> "user@example.com"
User.new(email: nil).email
=> nil
This seems simple and we need to represent the case of a missing email address. However, this greatly complicates our code. We can’t just ask for an email and use it; we have to verify that it exists:
users.each do |user|
unless user.email.nil?
UserMailer.update(user.email, update).deliver_now!
end
end
This concept comes with a slew of other concepts and techniques for working
around it: nil?
, present?
, try
, and now even a special syntax (&.
).
This concept may be worth its cost in some situations, but can we sometimes do without it?
In our email case, how different is a nullable email from an array of emails?
User.new(emails: ["user@example.com"]).email
=> ["user@example.com"]
User.new(email: []).email
=> []
users.each do |user|
user.emails.each do |email|
UserMailer.update(user.email, update).deliver_now!
end
end
Our usage patterns are very similar, but the second example removes an abstraction entirely. Now we don’t worry about arrays and nils; we only worry about arrays.
This works because nil
is a specialization of Array
. An Array
represents a
situation where there is an unknown number of something. A nil
represents a
situation where there could either be one or zero of something.
Sometimes nil
may be just the right abstraction, but there are many ways to
represent an empty case. A signed out user can be nil
or an instance of a
Guest
class or an unsaved instance of your normal User
class. Which
introduces the best balance of low abstraction and low confusion for your
application?
Conditionals
Every conditional is a special case the users of a codebase will need to parse.
We can use more common abstractions to avoid them:
# Conditional
users.each do |user|
unless user.email.nil?
UserMailer.update(user.email, update).deliver_now!
end
end
# Abstraction
users.map(&:email).compact.each do |email|
UserMailer.update(email, update).deliver_now!
end
Using a Null Object, we can introduce another abstraction to eliminate many special cases:
class Guest
def admin?
false
end
def can_edit?(post)
false
end
def name
"Guest"
end
end
With this class in place, views will no longer need to check whether a user is signed in. We can decide how guests behave in one location and reuse that abstraction.
Folds
Many computations can be performed using an abstraction called “folding,”
represented by the reduce
(aka inject
) method in Ruby:
# Using special cases
class Game
def score
result = 0
rounds.each { |round| result += round.score }
result
end
def competitors
result = []
rounds.each do |round|
round.users.each do |user|
result << user.email
end
end
result
end
end
# Using abstraction
class Game
def score
rounds.reduce(0) { |result, round| result + round.score }
end
def competitors
rounds.reduce([]) { |result, round| result + round.users.map(&:email) }
end
end
In this case, there is another level of abstraction built on top of a fold:
class Game
def score
rounds.sum(&:score)
end
def competitors
rounds.map(&:users).flat_map(&:email)
end
end
Using sum
and flat_map
introduce a specialization of a fold. In this case,
which do you think is easier to understand?
Wrapping Up
Special cases aren’t special enough to break the rules.
— The Zen of Python
Almost everything you use in programming is an abstraction, but the trick is to decide how abstract you want to be. When coding try making your programs more abstract to see if you can eliminate special cases. Is it harder to understand without the clarity of specialization, or is it easier to follow because it uses a single level of abstraction?