In Progress
Unit 1, Lesson 21
In Progress

Antique Shop

Video transcript & code

Let's say we are writing some software for a shop. This is the kind of software we would typically write a web application for, but for simplicity of exposition we're going to treat it as having an entirely text-based UI.

Our system has users, who have first names and last names.

User = Struct.new(:firstname, :lastname)

It has the ability to display a list of current deals to a user. Let's construct a user and call this method. We can see that the user is greeted based on the time of day. This is handled by a helper method called greet, which we'll take a look at in a moment.

require "./user"
require "./greet"

def show_deals(user)
  puts greet(user.firstname)
  puts
  puts "Here are today's super deals!"
  puts "..."
end

user = User.new("Tom", "Servo")

show_deals(user)

# >> Good morning, Tom
# >>
# >> Here are today's super deals!
# >> ...

Another of the system's functions is the ability to display a user's current info back to them, in case they want to update it. Let's go ahead and give this method a try as well.

require "./user"

def show_user_info(user)
  puts "Hello, #{user.firstname}"
  puts
  puts "Here is your current user info:"
  puts "First name: #{user.firstname}"
  puts "Last name: #{user.lastname}"
end

user = User.new("Tom", "Servo")

show_user_info(user)

# >> Hello, Tom
# >>
# >> Here is your current user info:
# >> First name: Tom
# >> Last name: Servo

One thing we notice about this method is that unlike the one for showing deals, it has a different kind of greeting.

It seems like it would be good to keep greeting style consistent. So let's re-use the greet helper we saw in the deals method. Then let's call the method to make sure it works correctly.

require "./user"
require "./greet"

def show_user_info(user)
  puts greet(user.firstname)
  puts
  puts "Here is your current user info:"
  puts "First name: #{user.firstname}"
  puts "Last name: #{user.lastname}"
end

user = User.new("Tom", "Servo")

show_user_info(user)

# >> Good morning, Tom
# >>
# >> Here is your current user info:
# >> First name: Good morning, Tom
# >> Last name: Servo

The greeting now looks good. But when we scan down lower, we get a shock: the line showing the current user's name is now messed up. Instead of just the first name, it contains a reiteration of the greeting.

Before we move on to diagnosing this peculiar problem, let's consider a few facts.

First, this code is the equivalent of a view template in a web application. In a real web app, a view might be large and broken up across multiple partial templates. And the rendered result might be significantly longer and busier than this little contrived example.

So in a real world situation, we might not even spot this new bug at first. And view code is not always comprehensively tested, so we might not catch it with a test, either.

But let's be optimistic and say that we do catch it with a test. Let's talk about the mental steps we have to go through next:

  1. First, we have to read the failing test and determine what it is testing. Is it just noting that we've updated the greeting? Do we just need to update the test? Or does it represent a legitimate regression?
  2. Once we've determined that there is a real regression, we have to follow the failure to the proximal source of the failure. In this case, it's the line listing the user's first name.
  3. But there is nothing wrong with this line, as far as we can tell. So we have to trace the problem to a recent change. In this case, our introduction of the greet helper method. We made the very reasonable assumption, founded on how we've already seen this helper used, that it was a simple function that would return a greeting based on the given name and the current time. Apparently we were mistaken in that assumption.
  4. Finally, we have to trace back to what caused this assumption to prove faulty.

I'm going over these steps explicitly, because I think it's important to reflect on the time and effort that must be expended every time we run into a change that causes seemingly unrelated code to break.

Tracing back into the definition of the greet method, we finally see the cause of the problem. This method uses String#prepend, which mutates the original string and returns it, rather than returning a modified copy.

def greet(name, now=Time.now)
  case now.hour
  when 0..11 then  name.prepend("Good morning, ")
  when 12..16 then name.prepend("Good afternoon, ")
  else             name.prepend("Good evening, ")
  end
end

greet("Tom", Time.new(2014, 11, 25, 11, 59))
# => "Good morning, Tom"

greet("Crow", Time.new(2014, 11, 25, 16, 59))
# => "Good afternoon, Crow"

greet("Mike", Time.new(2014, 11, 25, 23, 59))
# => "Good evening, Mike"

It seems as if our next step is clear: we should get rid of the prepends, and instead use idiomatic Ruby string interpolation.

But hold up… not so fast. Before we change a method which may be used in a number of places, we have to ensure that there is no code which depends on its current behavior. Even if that current behavior is broken.

How could any code possibly depend on greet mutating its argument? Well, let's take a look at another bit of view code. This method shows a user their purchase history. At the top, there are some variable assignments. The writer of this method obviously intended to assign the greeting to a variable, and then use that variable in the output code. But it appears that they got distracted at the last moment, because they slipped up and used the name variable instead of the greeting variable. But because the greet helper modifies its arguments, this method still works by accident, and the mistake was never spotted.

require "./user"
require "./greet"

def show_purchase_history(user)
  name = user.firstname
  greeting = greet(name)
  puts name
  puts
  puts "Here are your recent purchases:"
  puts "..."
end

user = User.new("Tom", "Servo")

show_purchase_history(user)

# >> Good morning, Tom
# >>
# >> Here are your recent purchases:
# >> ...

Now, I jumped us straight to this flawed method because I created it for this example. But in a real-world codebase, it could be quite a headache to track down every use of greet and audit it for these kinds of accidents. And we probably wouldn't even think to do so, since why would anyone depend on greet changing its argument??? Depending on our test coverage, our code might well make it into production with methods like show_purchase_history broken as a result of our "fix" to greet.

The basic problem here is something we've talked about before: referential transparency, or rather, the lack of referential transparency. To briefly review: a referentially transparent function takes input only in the form of arguments, and delivers output only in the form of its return value. It doesn't access any global data sources, and it doesn't cause any external side effects along the way.

We've talked about why it's important to eliminate implicit dependencies on global inputs in methods. But the flip side of referential transparency is that we should ensure that a method whose purpose is to return a value doesn't also modify program state in the process. Because the greet helper violated that rule, a tiny change has turned into a major hassle.

When presented like this, the importance of referential transparency may seem self-evident. But I have sometimes seen code similar to this method defended with an argument that goes something like this:

This is the only place it is used, and it works just fine right now.

This statement sounds reasonable in isolation. But multiply it by a thousand little design decisions justified using this rationale, and we find ourselves in a very fragile situation. The codebases resulting from this attitude are a lot like an overstuffed antique shop: when we pick up a teacup, a vase falls off a shelf at the other end of the store. When we turn to deal with the vase, we hear another crash behind us.

The shopkeeper might be able to find their way around the shop with no trouble. But woe betide any visitor. We all know the rule: you break it, you buy it!

The benefits of referential transparency are, well, clear: a referentially transparent method that works in one place can be used anywhere else, without issue and without breaking code at a distance. When writing code that's all about generating output, it pays off to keep things as referentially transparent as possible. Happy hacking!

Responses