Episode #024: Incidental Change

Have you ever reviewed a code commit where there were so many trivial syntactic changes that the diff looked like a Christmas tree, making it hard to pick out the important changes in a sea of “diff noise”? Have you ever had tests fail because you modified an array, but forgot to move a comma?

In this episode, you'll learn how to use Ruby syntax features in a way that ensures one line changes truly only affect a single line.

Upgrade to download episode video.

Episode Script

Most Ruby coding conventions say to avoid explicit return statements where an implicit return can be used instead. Explicit returns can make a method harder to reason about, but there's another reason to want to avoid them.

Let's say we have a method which is responsible for coming up with the message that will be printed at the ends of receipts at a sandwich shop. Customers who eat at the shop often are rewarded with free sandwiches. This method lets them know when they've qualified.

#

def receipt_message(user)
  if user.qualifies_for_free_sandwich?
    return "Congratulations, you qualify for a free sandwich!"
  else
    return "Come again soon!"
  end
end
puts receipt_message(user)

Later, the shop manager decided to add another promotion: one out of every ten customers will get ten percent off their next order. So we capture the message generated by our previous code, and conditionally add some more text to it before returning.

#

def receipt_message(user)
  message = if user.qualifies_for_free_sandwich?
    return "Congratulations, you qualify for a free sandwich!"
  else
    return "Come again soon!"
  end
  if rand(10) == 0
    message << "\nBring this receipt back for a 10% discount!"
  end
  message
end
puts receipt_message(user)

Of course, this doesn't work, because the explicit returns force the method to end before it ever gets to the ten percent discount. We have to edit out the returns before the code works.

#

def receipt_message(user)
  message = if user.qualifies_for_free_sandwich?
    "Congratulations, you qualify for a free sandwich!"
  else
    "Come again soon!"
  end
  if rand(10) == 0
    message << "\nBring this receipt back for a 10% discount!"
  end
  message
end
puts receipt_message(user)

Let's take a look at the diff between the first version and this modified version.

***************
*** 3,12 ****
  user = OpenStruct.new(:qualifies_for_free_sandwich? => true)

  def receipt_message(user)
!   if user.qualifies_for_free_sandwich?
!     return "Congratulations, you qualify for a free sandwich!"
    else
!     return "Come again soon!"
    end
  end
  puts receipt_message(user)
--- 3,16 ----
  user = OpenStruct.new(:qualifies_for_free_sandwich? => true)

  def receipt_message(user)
!   message = if user.qualifies_for_free_sandwich?
!     "Congratulations, you qualify for a free sandwich!"
    else
!     "Come again soon!"
    end
+   if rand(10) == 0
+     message << "\nBring this receipt back for a 10% discount!"
+   end
+   message
  end
  puts receipt_message(user)

The two changed lines where return keywords were removed are just noise. They are a distraction in the diff that doesn't say anything about the change to the method's responsibility. If the returns hadn't been there in the first place, the change would have been both simpler to perform, and simpler to understand in retrospect.

Let's look at another code change. Here's a method that creates text “slugs” from titles, using the #tr_s method to squash and replace runs of non-alphanumeric characters with dashes.

def slugify(title)
  title.tr_s '^A-Za-z0-9', '-'
end
slugify "'Twas brillig, and the slithy toves..."

Let's say we also want to convert the string to all-lowercase. We have to surround the arguments to #tr_s with parentheses so that we can chain a call to downcase onto the end.

def slugify(title)
  title.tr_s('^A-Za-z0-9', '-').downcase
end
slugify "'Twas brillig, and the slithy toves..."

Again, we have a case of a semantic change (the addition of a call to #downcase) coupled with an incidental change (the addition of a pair of parentheses). I think this makes a good case for always including the parentheses when you care about the return value.

Let's look at some other quick examples of incidental change, and how to avoid it.

Have you ever moved or added an element to an array, only to have Ruby give you a syntax error because you missed a comma?

shopping_list = [
 :apple,
 :orange
 :banana,
]
# ~> -:4: syntax error, unexpected tSYMBEG, expecting ']'
# ~>  :banana,
# ~>   ^

Ruby will actually allow you to leave a dangling comma at the end of an array, preventing this kind of mistake.

shopping_list = [
 :apple,
 :banana,
 :orange,
]
shopping_list                   # => [:apple, :banana, :orange]

In Ruby 1.9, there is a similar syntax for chained message sends. We can put each message send on its own line, preceded with a dot.

def slugify(title)
  title
    .strip
    .tr_s('^A-Za-z0-9', '-')
    .downcase
end
slugify " Curioser and curioser"

This lets us move the messages around in the chain just by moving lines around, without worrying about leaving off a trailing dot.

In object-oriented programming, we try to observe the Single Responsibility Principle: for a given change in functionality, we should only have to change the program in one place. The idioms we've just looked at are some ways to observe this principle at the method level as well as at the level of the object model. We can add to and reorganize the internals of our methods without having to make distracting little incidental changes along the way.

That's all for now. Happy hacking!