---
title: Between immutability and memoization, you might have to choose
teaser: It is not always possible to have frozen AND performant objects in Ruby.
tags: ruby,performance
author: Rémy Hannequin
published_on: 2025-04-22
---

## 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
<abbr title="Plain Old Ruby Objects">POROs</abbr> such as [value objects] is
immutability. By definition, a value object represents a value and should not
change.

[value objects]: https://thoughtbot.com/blog/value-object-semantics-in-ruby#bonus

```rb
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.

```rb
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?

```rb
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.

[freeze the object after calculating the result]: https://thoughtbot.com/blog/ruby-memoization-and-alternatives#constructors

```rb
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.

```rb
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:

```rb
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.

```rb
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?

[`Mutex`]: https://ruby-doc.org/3.4.1/Thread/Mutex.html
[`concurrent-ruby`]: https://github.com/ruby-concurrency/concurrent-ruby

## 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.

```rb
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!
