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:
-
@instance_cacheto be set as an empty hash if it is not already defined. -
@instance_cacheto be checked to see if "DE" is already a key β if it is not a new instance ofCountryis initialised and added to@instance_cache. - The instance for "DE" to be read from
@instance_cacheand 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.
Top comments (0)