In Progress
Unit 1, Lesson 21
In Progress

Invariant

Video transcript & code

In the preceding episode we teased apart some responsibilities, first onto separate lines of a method, and then into their own dedicated methods. If you haven't watched that episode yet you might want to go and watch it first, since I won't be reiterating the problem statement today.

The result of our edits was a class which reads as an almost textbook example of separation of concerns.

There's a method for incrementing values, which knows nothing about the constraints placed upon values.

There's a method which constrains the value, without caring what happened before.

And then there's a composed method, which is concerned only with putting the other operations in the right order.

And yet, there is a dark cloud threatening this elegance. If you are anything like me, something just feels very wrong about this solution. Because in between incrementing the value and then constraining the value, our object can momentarily have a value which is invalid according to our requirements!

Let's demonstrate. We'll create a GreenCheese object.

Green cheese, as you may recall, always adds two to its value each time it is aged.

We'll give it a starting value of 49, which is one less than the value cap of 50.

Then we'll age it once, and check its value.

Despite the increment unit of 2, the value has been correctly held down to the max allowed value of 50.

But if we check the value in between the increment and the constrain steps, it's 51.

class Cheese
  attr_reader :value

  def initialize(value)
    @value = value
  end

  def age
    increment_value
    @value                      # => 51
    constrain_value
  end

  private

  def increment_value
    @value += increment
  end

  def constrain_value
    @value = [@value, 50].min
  end

  def increment
    1
  end
end

class GreenCheese < Cheese
  private
  def increment
    2
  end
end

gc = GreenCheese.new(49)
gc.age
gc.value                        # => 50

Let's talk about why this feels wrong. When we say that the value can never go over 50, what we're talking about is an object invariant. An invariant is a promise, stating that from the point of view of users of an object, a certain property will always be true.

Let me say that again: from the point of view of users of an object, a certain property will always be true.

It's easy to think of invariants as being rules that apply to the values of instance variables of objects. But that point of view violates object-oriented encapsulation. An invariant is part of an object's contract with other objects. And the contract is only applicable to publicly visible behavior.

This means that between when a public method is called, and when it returns, the object can spend some time in an inconsistent state, and no invariants will be violated.

In the case of our Cheese class, we've taken advantage of this freedom to treat our instance variables as a kind of scratchpad or workspace. Instead of calculating the correct value all at once, we use this workspace to converge on the right value by the end of the method.

This kind of scratchpad-oriented, converging workflow can be extremely conducive to separation of concerns—as we've seen.

However, if it makes you too nervous—if, for instance, you're concerned about an exception leaving the value in an inconsistent state—we can instead employ a slightly more functional style.

We update the increment_value and constrain_value methods to take a value, and to return new values based on their input instead of updating instance data in-place.

Then we add code to the age method to pass the current value into increment_value, capture the result in a temporary local, pass that value through the constrain_value method, and finally assign the result to the @value instance variable.

class Cheese
  attr_reader :value

  def initialize(value)
    @value = value
  end

  def age
    new_value = increment_value(value)
    new_value = constrain_value(new_value)
    @value = new_value
  end

  private

  def increment_value(value)
    value + increment
  end

  def constrain_value(value)
    [value, 50].min
  end

  def increment
    1
  end
end

This doesn't read quite as nicely to my eyes. But it adds a transactional quality where the instance data isn't updated until the new value is fully calculated.

One further variation is to avoid the intermediate variables by passing the output of one method directly into the next.

class Cheese
  attr_reader :value

  def initialize(value)
    @value = value
  end

  def age
    @value = constrain_value(increment_value(value))
  end

  private

  def increment_value(value)
    value + increment
  end

  def constrain_value(value)
    [value, 50].min
  end

  def increment
    1
  end
end

But while this certainly gains back concision, I'm not sure I prefer it. I'm rather partial to code where each concern gets its own line. I especially like code where I can remove a concern or move a concern just by removing or moving a line, without any other touch-up needed. The compacted version loses this quality of malleability.

But whichever version you prefer, all three of the variations we've seen today maintain their invariants from the perspective of outside code.

That's all for today. Happy hacking!

Responses