Ruby Memoization Patterns: Voorbij de Standaard ||= Operator
Elke Ruby-ontwikkelaar leert ||= in de eerste maand. Het is clean, idiomatisch, en dekt zo’n 60% van je memoization-behoeften. De overige 40% is waar het interessant wordt — en waar bugs zich verstoppen.
Dit is wat ||= werkelijk doet, waar het faalt, en wat je in plaats daarvan kunt gebruiken.
Hoe ||= Werkelijk Werkt
def current_user
@current_user ||= User.find(session[:user_id])
end
Dit wordt uitgebreid naar @current_user || (@current_user = User.find(session[:user_id])). De lookup draait één keer, en volgende aanroepen retourneren de gecachte instance variabele.
Simpel. Maar er is een bekende valkuil: ||= kan nil of false niet memoizen.
def admin?
@is_admin ||= User.find(id).admin? # Queryt opnieuw bij elke aanroep als admin? false retourneert
end
Als admin? false retourneert, evalueert @is_admin als falsy, en de query draait opnieuw bij elke aanroep. In een request dat 8 keer permissies checkt, zijn dat 8 database queries in plaats van 1.
Het defined? Pattern voor Nil/False Waarden
De klassieke fix gebruikt defined? om te checken of de variabele überhaupt is toegewezen:
def admin?
return @is_admin if defined?(@is_admin)
@is_admin = User.find(id).admin?
end
Dit werkt omdat defined?(@is_admin) "instance-variable" retourneert zodra het is toegewezen, ongeacht de waarde. Het memoizet correct nil, false, 0 — alles.
Ik heb dit pattern jarenlang in productie gebruikt. Het is betrouwbaar, maar verbose. Drie regels in plaats van één, en de intentie is niet direct duidelijk voor junior developers die de code lezen.
Methoden Met Argumenten Memoizen
||= valt compleet uit elkaar wanneer argumenten betrokken zijn:
# Broken: negeert het argument na de eerste aanroep
def user_permissions(role)
@permissions ||= Permission.where(role: role).pluck(:name)
end
Roep user_permissions(:editor) aan en daarna user_permissions(:admin) — je krijgt beide keren editor permissies. De fix is een hash-gebaseerde aanpak:
def user_permissions(role)
@permissions ||= {}
@permissions.fetch(role) do
@permissions[role] = Permission.where(role: role).pluck(:name)
end
end
Hash#fetch met een block voert het block alleen uit wanneer de key ontbreekt. Dit handelt nil-waarden ook correct af, omdat de key bestaat in de hash na toewijzing.
Voor methoden met meerdere argumenten, gebruik een array als key:
def shipping_cost(weight, destination)
@shipping_cache ||= {}
key = [weight, destination]
@shipping_cache.fetch(key) { @shipping_cache[key] = calculate_shipping(weight, destination) }
end
Let op het Geheugen
Hash-gebaseerde memoization groeit onbegrensd. Als je methode wordt aangeroepen met duizenden unieke argument-combinaties, bouw je een geheugenlek. In een langlopend Rails-proces maakt dit uit.
Een pattern dat ik in productie heb gebruikt om dit te begrenzen:
def geocode(address)
@geo_cache ||= {}
@geo_cache.clear if @geo_cache.size > 1000
@geo_cache.fetch(address) { @geo_cache[address] = Geocoder.search(address).first }
end
Grof maar effectief. Voor iets geavanceerder, kijk naar de lru_redux gem, die je een LRU-cache geeft met configureerbare maximale grootte. Sam Saffron (Discourse mede-oprichter) bouwde het specifiek voor dit soort use cases.
Thread Safety: Waar Memoization Gevaarlijk Wordt
Geen van bovenstaande patterns is thread-safe. In een multi-threaded server zoals Puma (de Rails 8 standaard), kunnen twee threads tegelijkertijd een niet-gememoizede methode aanroepen:
Thread A: checkt @result → nil
Thread B: checkt @result → nil
Thread A: berekent expensive_operation, wijst @result toe
Thread B: berekent expensive_operation, wijst @result toe (verspild werk)
Voor de meeste Rails-memoization is dit prima. Het slechtste geval is dubbele berekening, en instance variabelen op request-scoped objecten zijn van nature thread-geïsoleerd. Maar voor memoization op gedeelde objecten (singletons, class-level caches, objecten in Rails.application.config), heb je synchronisatie nodig.
class ExchangeRateService
include Singleton
def initialize
@mutex = Mutex.new
@rates = {}
end
def rate_for(currency)
@mutex.synchronize do
@rates.fetch(currency) do
@rates[currency] = fetch_rate_from_api(currency)
end
end
end
end
Het Mutex#synchronize block zorgt dat slechts één thread een ontbrekende rate berekent. Andere threads wachten en krijgen dan het gecachte resultaat.
Als je Ruby 3.2+ gebruikt, is Concurrent::Map uit de concurrent-ruby gem een schonere optie — het is een thread-safe hash die geen expliciete locking vereist:
require 'concurrent-ruby'
class ExchangeRateService
def initialize
@rates = Concurrent::Map.new
end
def rate_for(currency)
@rates.compute_if_absent(currency) { fetch_rate_from_api(currency) }
end
end
compute_if_absent is atomair — precies één thread voert het block uit voor een gegeven key.
Class-Level Memoization
Soms wil je memoizen op class-niveau, niet op instance-niveau. Gebruikelijk voor configuratie, schema-lookups, of alles wat hetzelfde is voor alle instances:
class Product < ApplicationRecord
def self.category_names
@category_names ||= Category.pluck(:name).freeze
end
end
De .freeze is bewust — het voorkomt per ongeluk mutatie van gedeelde state. Zonder dit zou een stuk code push op de array kunnen doen en elke aanroeper beïnvloeden.
De valkuil: class-level memoization blijft bestaan tussen requests in productie. Dit is meestal wat je wilt, maar betekent verouderde data als de onderliggende records wijzigen. In Rails kun je de cache invalideren bij relevante modelwijzigingen:
class Category < ApplicationRecord
after_commit :clear_product_cache
private
def clear_product_cache
Product.instance_variable_set(:@category_names, nil)
end
end
Het is niet fraai. Als je dit soort reactieve invalidatie vaak nodig hebt, ben je beter af met Rails’ ingebouwde caching met Rails.cache.fetch en expliciete expiratie.
De Memery Gem
Als je veel gememoizede methoden schrijft, extraheert de memery gem (Ruby 3.0+) de boilerplate:
require 'memery'
class WeatherService
include Memery
memoize def forecast(city)
WeatherAPI.fetch(city)
end
memoize def current_temp(city)
WeatherAPI.current(city)
end
end
Onder de motorkap gebruikt Memery Module#prepend om je methode te wrappen met hash-gebaseerde memoization. Het handelt argumenten af, en je kunt condition: meegeven om te bepalen wanneer memoization van toepassing is. De gem is klein (~200 regels) en heeft geen dependencies.
Één kanttekening: Memery memoizet per-instance. Als je 1000 WeatherService instances aanmaakt, krijgt elk zijn eigen cache. Voor gedeelde caching heb je nog steeds een class-level of singleton aanpak nodig.
Wanneer Niet Memoizen
Memoization is niet gratis. Elke gememoizede waarde is een instance variabele die leeft zolang het object leeft. In een typisch Rails-request:
- Controller actions: Objecten zijn kortlevend. Memoize vrij.
- Models geladen via associaties: Wees voorzichtig met memoizen op objecten die in bulk worden geladen. 500
Orderinstances die elk een berekendtotalmemoizen is prima. 500 instances die elk een geassocieerdecustomerquery memoizen is 500 gecachte ActiveRecord objecten in het geheugen. - Background jobs: Sidekiq workers worden hergebruikt tussen jobs. Als je memoizet op de worker instance, lekken waarden tussen jobs. Memoize op een job-scoped object, of gebruik Solid Queue dat verse instances aanmaakt.
Een snelle benchmark op Ruby 3.3.0 (YJIT ingeschakeld) toont de overhead van verschillende benaderingen:
Gewone method call (geen memo): 48.2M iteraties/sec
||= memoization: 45.7M iteraties/sec
defined? pattern: 39.1M iteraties/sec
Hash#fetch pattern: 28.3M iteraties/sec
Memery gem: 22.6M iteraties/sec
Mutex + Hash#fetch: 14.8M iteraties/sec
De conclusie: ||= voegt bijna geen overhead toe. Hash-gebaseerde patterns zijn ruwweg 40% langzamer voor het cache-hit pad, wat nog steeds ~28 miljoen lookups per seconde betekent — waarschijnlijk niet je bottleneck. De Mutex-versie is meetbaar langzamer, maar je betaalt die kosten alleen op gedeelde mutable state waar correctheid het vereist.
Het Juiste Pattern Kiezen
| Situatie | Pattern |
|---|---|
| Simpele waarde, nooit nil/false | ||= |
| Waarde kan nil of false zijn | defined? check |
| Methode heeft argumenten | Hash + fetch |
| Gedeeld tussen threads | Mutex of Concurrent::Map |
| Veel gememoizede methoden | Memery gem |
| Expiratie/invalidatie nodig | Rails.cache.fetch in plaats daarvan |
Begin met ||=. Upgrade wanneer het breekt. Grijp niet naar thread-safe patterns op request-scoped objecten — je voegt complexiteit toe voor een probleem dat daar niet bestaat.
FAQ
Werkt Ruby’s ||= operator met nil-waarden?
Nee. ||= behandelt zowel nil als false als “nog niet toegewezen” omdat het intern de || operator gebruikt. Als je methode legitiem nil of false retourneert, gebruik dan het defined?(@var) pattern om te checken of de variabele is gezet, ongeacht de waarde.
Is memoization in Ruby thread-safe?
Instance-level memoization op request-scoped objecten (controllers, service objecten aangemaakt per-request) is effectief thread-safe omdat elke thread met eigen object-instances werkt. Memoization op gedeelde objecten zoals singletons, class variabelen, of objecten opgeslagen in globale configuratie vereist expliciete synchronisatie met Mutex of Concurrent::Map.
Moet ik een gem gebruiken voor memoization of het handmatig schrijven?
Voor één of twee gememoizede methoden, schrijf het handmatig — de patterns zijn simpel genoeg. Als je een class hebt met vijf of meer gememoizede methoden, overweeg dan de memery gem om boilerplate te verminderen. Vermijd de oudere memoist gem op Ruby 3.x omdat het methoden monkey-patcht en problemen kan veroorzaken met method visibility wijzigingen in Ruby 3.0+.
Hoe wis ik gememoizede waarden in tests?
De simpelste aanpak is verse instances aanmaken in elke test. Als je class-level memoization test, voeg een reset! class methode toe die de gememoizede variabelen op nil zet, en roep het aan in je test setup of before block. De memery gem biedt clear_memery_cache! op instances voor dit doel.
Wanneer moet ik Rails.cache.fetch gebruiken in plaats van memoization?
Gebruik Rails.cache.fetch wanneer je tijdgebaseerde expiratie nodig hebt, cross-process sharing (meerdere Puma workers of servers), of cache-invalidatie gekoppeld aan modelwijzigingen. Memoization is per-object, alleen in-memory, en verdwijnt met het object. Als de gecachte waarde een enkel request moet overleven of gedeeld moet worden tussen processen, is het geen memoization-probleem — het is een caching-probleem.
About the Author
Roger Heykoop is een senior Ruby on Rails ontwikkelaar met 19+ jaar Rails ervaring en 35+ jaar ervaring in softwareontwikkeling. Hij is gespecialiseerd in Rails modernisering, performance optimalisatie, en AI-ondersteunde ontwikkeling.
Get in TouchRelated Articles
Need Expert Rails Development?
Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise
Schedule a Free Consultation