loading...

Ruby Value Object Instance Caching

databasesponge profile image MetaDave 🇪🇺 ・3 min read

TL:DR

Value objects with few distinct values can be cached to save memory.

Problem

We love value objects, but they're not free of system impact. Each instance uses memory, and the less of that we use the better (looking at you ActiveRecord).

Some value objects would naturally be expect to have very many distinct values (ISBN, SSN, email address), and may even be unique, but some have very few. There are only a couple of hundred countries in the world to share among your 100,000 Customer records for example, and in practice you might only be using a small number of those.

If you wanted to categorise all of your customers according to country, or an attribute of the country, Customer.map(&:country).map(&:currency).uniq, then you would probably rather not initialise 100,000 instances of the Country value object.

Solution

Provide a caching mechanism, so multiple calls to Country.new("ES") return the same instance of the Country class.

One of the principles of value objects is that they be immutable, so this is fine.

class Country
  def self.new(code)
    @instance_cache ||= {}
    @instance_cache[code] = super(code) unless @instance_cache.key?(code)
    @instance_cache.fetch(code)
  end

  def initialize(code)
    @code = code
  end
end

Other memoisation techniques might be more pleasing to an individual. Choose one that works for you.

Calling Country.new("DE") causes:

  1. @instance_cache to be set as an empty hash if it is not already defined.
  2. @instance_cache to be checked to see if "DE" is already a key – if it is not a new instance of Country is initialised and added to @instance_cache.
  3. The instance for "DE" to be read from @instance_cache and implicitly returned.

As a bonus, this is also slightly faster than instantiating a new instance.

Demonstration

Consider two value objects:

class CountryCached
  def self.new(code)
    @instance_cache ||= {}
    @instance_cache[code] = super(code) unless @instance_cache.key?(code)
    @instance_cache.fetch(code)
  end

  def initialize(code)
    @code = code
  end
end

class CountryUncached
  def initialize(code)
    @code = code
  end
end
2.4.4 :001 > class CountryCached
2.4.4 :002?>     def self.new(code)
2.4.4 :003?>         @instance_cache ||= {}
2.4.4 :004?>         @instance_cache[code] = super(code) unless @instance_cache.key?(code)
2.4.4 :005?>         @instance_cache.fetch(code)
2.4.4 :006?>       end
2.4.4 :007?>   
2.4.4 :008 >       def initialize(code)
2.4.4 :009?>         @code = code
2.4.4 :010?>       end
2.4.4 :011?>   end
 => :initialize 
2.4.4 :012 > 
2.4.4 :013 >   class CountryUncached
2.4.4 :014?>     def initialize(code)
2.4.4 :015?>         @code = code
2.4.4 :016?>       end
2.4.4 :017?>   end
 => :initialize 
2.4.4 :018 > 
2.4.4 :019 >   
2.4.4 :020 >   CountryCached.new("DE")
 => #<CountryCached:0x00007f8bff83b430 @code="DE"> 
2.4.4 :021 > CountryCached.new("DE")
 => #<CountryCached:0x00007f8bff83b430 @code="DE"> 
2.4.4 :022 > CountryCached.new("FR")
 => #<CountryCached:0x00007f8bff037ce8 @code="FR"> 
2.4.4 :023 > CountryCached.new("DE") == CountryCached.new("DE")
 => true 
2.4.4 :024 > CountryCached.new("DE") == CountryCached.new("FR")
 => false 
2.4.4 :025 > 
2.4.4 :026 >   
2.4.4 :027 >   
2.4.4 :028 >   CountryUncached.new("DE")
 => #<CountryUncached:0x00007f8bfe877a08 @code="DE"> 
2.4.4 :029 > CountryUncached.new("DE")
 => #<CountryUncached:0x00007f8bfe85f958 @code="DE"> 
2.4.4 :030 > CountryUncached.new("FR")
 => #<CountryUncached:0x00007f8bfe854670 @code="FR"> 
2.4.4 :031 > CountryUncached.new("DE") == CountryUncached.new("DE")
 => false 
2.4.4 :032 > CountryUncached.new("DE") == CountryUncached.new("FR")
 => false 

For the cached value, the object ids for the two "DE" values is identical: 0x00007f8bff83b430. For the uncached value they are different: 0x00007f8bfe877a08 and 0x00007f8bfe85f958.

Note also one of the benefits, that the objects themselves are now directly comparable:

2.4.4 :023 > CountryCached.new("DE") == CountryCached.new("DE")
 => true 

vs

2.4.4 :031 > CountryUncached.new("DE") == CountryUncached.new("DE")
 => false 

So to summarise then ...

Caching instances of value objects by overriding the class new method:

  • Is not very difficult
  • Improves memory usage
  • Makes objects directly comparable

Nice.

Discussion

pic
Editor guide