Between immutability and memoization, you might have to choose

Rémy Hannequin in France

Immutability with freeze

Since Ruby 2.3 and the magic comment # frozen_string_literal: true we are more used to dealing with frozen objects.

A typical pattern when dealing with POROs such as value objects is immutability. By definition, a value object represents a value and should not change.

class Distance
  def initialize(meters)
    @meters = meters
    freeze
  end

  def update(meters)
    @meters = meters
  end
end

distance = Distance.new(100)
distance.update(50)
# => can't modify frozen Distance (FrozenError)

But what happens if your value object is doing an expensive calculation? What if we need this calculation more than once? One could argue that expensive operations should be extracted into a different object and I would agree with that, but let’s do it anyway for the sake of the demonstration. Let’s use sleep to simulate something expensive.

class Distance
  def initialize(meters)
    @meters = meters
    freeze
  end

  def expensive_method
    sleep 3
    "hello"
  end
end

distance = Distance.new(100)
distance.expensive_method
# ... takes 3 seconds ...
# => "hello"
distance.expensive_method
# ... takes 3 more seconds ...
# => "hello"

Memoization

We usually use memoization to cache the result of an idempotent expensive operation.

Memoization is a technique to store the result of a method call so that subsequent calls with the same arguments return the cached result instead of recomputing it.

This makes sense with an immutable object as the internal attributes cannot change so there’s no reason the method’s logic would produce a different result, right?

class Distance
  def initialize(meters)
    @meters = meters
    freeze
  end

  def expensive_method
    @_expensive_method ||= begin
       sleep 3
       "result"
     end
  end
end

distance = Distance.new(100)
distance.expensive_method
# can't modify frozen Distance (FrozenError)

Because the object is frozen, it is not possible to add a new instance variable to cache the result of the expensive operation.

Pre-computation

There is a solution: Why not just freeze the object after calculating the result? In our current logic, nothing happens between initialisation and the first call to expensive_method so we can safely freeze the object after the calculation.

class Distance
  attr_reader :expensive_result

  def initialize(meters)
    @meters = meters
    @expensive_result = compute_expensive_calculation
    freeze
  end

  private

  def compute_expensive_calculation
    sleep 3
    "result"
  end
end

distance = Distance.new(100)
# ... takes 3 seconds ...
distance.expensive_result
# => "result"
distance.expensive_result
# => "result"
# Got the result immediately

Great! We can have both immutability and performance!

This is only covering this particular case. Let’s imagine something different: We have an object, we want it to be immutable, it exposes several methods with expensive calculations but we don’t always need all of them.

class Distance
  attr_reader :slow_to_compute, :very_slow_to_compute

  def initialize(meter)
    @meter = meter
    @slow_to_compute = compute_slow
    @very_slow_to_compute = compute_very_slow
    freeze
  end

  private

  def compute_slow
    sleep 3
    "that was slow"
  end

  def compute_very_slow
    sleep 5
    "that was very low"
  end
end

distance = Distance.new(100)
# => takes 3+5=8 seconds
distance.slow_to_compute

other_distance = Distance.new(200)
# => takes 3+5=8 seconds, again
other_distance.very_slow_to_compute

There is no reason to compute both values at initialization if we are not sure all of them will be necessary for the rest of the object’s lifecycle.

But don’t worry, there are plenty of other ways to deal with this.

Internal cache

freeze doesn’t recursively freeze an object. This means it will prevent you from modifying the object itself but you can still modify the internal of a mutable object it contains:

class Distance
  def initialize(meter)
    @meter = meter
    @cache = {}
    freeze
  end

  def slow_to_compute
    @cache[:slow_to_compute] ||= begin
       sleep 3
       "that was slow"
     end
  end

  def very_slow_to_compute
    @cache[:very_slow_to_compute] ||= begin
      sleep 5
      "that was very low"
    end
  end
end

distance = Distance.new(100)
# No delay
distance.slow_to_compute
# => takes 3 seconds
distance.slow_to_compute
# Got the result immediately

other_distance = Distance.new(200)
# No delay
other_distance.very_slow_to_compute
# => takes 5 seconds
other_distance.very_slow_to_compute
# Got the result immediately

This a perfectly working solution, but as with all solutions it has drawbacks.

It depends on your usage and the lifecycle of your object. Our examples are short and we can expect Distance objects to be quickly garbage-collected. But if you have a long-lived object, you might end up with a lot of internal caches that will never be used again. This is a memory retention pattern.

If you are caching values that will only be used once and never again, then you are getting no value for the cache. You should just do the expensive calculation directly.

External cache

Another solution is to use an external cache. Let’s take the example of an angle value object and let’s assume that your program will deal with common angles the majority of the time.

class AngleCache
  def initialize
    @cache = {}
  end

  def [](key)
    @cache[key]
  end

  def []=(key, value)
    @cache[key] = value
  end
end

class Angle
  attr_reader :radians

  def initialize(radians, cache:)
    @radians = radians
    @cache = cache
    freeze
  end

  def complicated_math
    @cache[@radians] ||= begin
      sleep 3
      42
    end
  end
end

cache = AngleCache.new
angle = Angle.new(Math::PI, cache: cache)

angle.complicated_math
42 # takes 3 seconds

angle.complicated_math
42 # instantly returns

other_angle = Angle.new(Math::PI, cache: cache)
other_angle.complicated_math
42 # instantly returns

The good thing about this approach is that it can reduce the computing time of the same expensive operation across the whole program. However, it still means a constant and growing memory footprint with more and more different angles.

Also, it is not thread-safe. In a multiple threads context, using cache this way could lead to race conditions due to threads trying to compute or store the same value at once. This can be solved using Mutex or gems like concurrent-ruby, but it brings even more complexity to your program, do you really need this or is this over-engineering?

Is memoization always worth it?

I would like to end this article by reducing the pressure we’re putting on ourselves to always be about memoization and performance. While memoization is a great tool, we certainly saw that it wasn’t solving everything and it brings its problems.

In some cases, actually, in many cases, the performance gain is not worth the effort. If you are not sure, just don’t do it, you can still measure and improve your program once you know it is slow.

One example of this is again with a value object.

Let’s say we have a Distance object and we need its value in different units multiple times. We might think that the conversion is expensive, or at least that it’s not optimized to do it every time.

Let’s measure an instruction using a memoized object and a non-memoized one.

require "benchmark"

class ImmutableDistance
  attr_reader :meters

  def initialize(meters)
    @meters = meters
    freeze
  end

  def miles
    @meters / 1609.34
  end
end

class MemoizedDistance
  attr_reader :meters

  def initialize(meters)
    @meters = meters
  end

  def miles
    @miles ||= @meters / 1609.34
  end
end

immutable_distance = ImmutableDistance.new(10)
memoized_distance = MemoizedDistance.new(10)
occurrences = 1_000_000

Benchmark.bmbm do |bench|
  bench.report "ImmutableDistance" do
    occurrences.times { immutable_distance.miles }
  end

  bench.report "MemoizedDistance" do
    occurrences.times { memoized_distance.miles }
  end
end


#                         user     system      total        real
# ImmutableDistance   0.046975   0.000104   0.047079 (  0.047169)
# MemoizedDistance    0.048263   0.000082   0.048345 (  0.048388)

We could expect ImmutableDistance#miles to be slower over a million calls, but as we can see, it’s doing just as good as MemoizedDistance#miles, even slightly faster, probably because the garbage collector is optimized for frozen objects.

Conclusion

Between immutability and memoization, you might have to choose, and there is no one-size-fits-all solution.

As always with performance, what’s important is to measure and understand your use case. Premature optimisation is often worse than no optimisation at all.

Forcing immutability on an object helps the interpreter optimize the code but it comes with a cost that either prevents other optimisations or forces you to adopt more complex patterns to achieve the same result.

The good thing is that Ruby is a very flexible language and you can always find a way to achieve what you want.

Also, immutability is not that common in the everyday code we write, so this whole article might be trying to solve a problem you didn’t have in the first place. If you still read up to this point, thank you and let’s catch up online to discuss other ways to improve our code!