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!