35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English
Ruby Memoization Patterns: Voorbij de Standaard ||= Operator

Ruby Memoization Patterns: Voorbij de Standaard ||= Operator

TTB Software Development
Productie-geteste Ruby memoization patterns inclusief ||=, fetch-gebaseerde caching, multi-argument memoization en thread-safe benaderingen. Met benchmarks en valkuilen.

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 Order instances die elk een berekend total memoizen is prima. 500 instances die elk een geassocieerde customer query 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.

#ruby #performance #patterns
T

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 Touch

Share this article

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