module AttrMemoized
This module, when included, decorates the receiver with several useful methods:
-
Both class and instance methods mutex are added, that can be used to guard any shared resources. Each class gets their own class attr_memoized_mutex. and each instance gets it's own separate attr_memoized_mutex.
-
new class method attr_memoized is added, the syntax is as follows:
attr_memoized :attribute_name, ..., -> { block returning a value }
-
the block in the definition above is called via instance_exec on the object (instance of a class) and therefore has access to all private methods. If the value is a symbol, it is expected to be a method.
-
multiple attribute names are allowed in the attr_memoized, but they will all be assigned the result of the block (last argument) when called. Therefore, typically you would use attr_memoized with one attribute at a time, unless you want to have several version of the same variable:
@example
class RandomNumbers include AttrMemoized attr_memoized :random, -> { rand(10) }, writer: false attr_memoized :seed, -> { Time.now.to_i % 57 } attr_memoized :number1, :number2, -> { self.class.incr! && rand(10) } @calls = 0 class << self attr_reader :calls def incr!; @calls = 0 unless defined(@calls); @calls += 1 ; end end end @rn = RandomNumbers.new # first call executes the block, and caches it @rn.number1 # => 3 # and it's saved now, and the block is no longer called @rn.number1 # => 3 @rn.number2 # => 9 @rn.number2 # => 9 # only 2 times did we ever call incr! method @rn.class.calls # => 2 # Now #seed is also lazy-loaded, and also cached @rn.seed # => 34 # And, we can change it thread safely! @rn.seed = 64; @rn.seed # => 64 # Not so with :random, which was defined without the writer: @rn.random # => 8 @rn.random = 34 # => NoMethodError: undefined method `random=' for #<RandomNumbers:0x007ffb28105178>
Constants
- LOCK
We are being a bit paranoid here, so yes we are creating a central lock used to initialize other class-specific attr_memoized_mutex.s. This should only be a problem if you are constantly defining new classes that include
AttrMemoized+
- SUPPORTED_INIT_TYPES
The types of initializers we support.
- VERSION
Attributes
Public Class Methods
A class method which, for each attribute in the list, creates a thread-safe memoized reader and writer (unless writer: false)
Memoized reader accepts reload: true
as an optional argument, which, if provided, forces reinitialization of the variable.
@example:
class LazyConnection include AttrMemoized attr_memoized :database_connection, -> { ActiveRecord::Base.connection } attr_memoized :redis_pool, -> { ConnectionPool.new { Redis.new } } end LazyConnection.new.database_connection #=> <ActiveRecord::Connection::PgSQL::Driver:0xff23234f....>
@param [Symbol] name of the attribute @param [Symbol] another name of the attribute, etc… @param [Proc,Symbol,Method] callable — something to call for lazy initialization @param [Hash] options — you can define arguments here to be passed to a method or proc
# File lib/attr_memoized.rb, line 105 def attr_memoized(*attributes, **opts) attributes = Array[*attributes] callable = attributes.pop unless SUPPORTED_INIT_TYPES.include?(callable.class) raise ArgumentError, "Invalid argument #{callable} to attr_memoized. Expecting one of: #{SUPPORTED_INIT_TYPES.map(&:to_s)}" end writer = opts.delete(:writer) attributes.each do |attribute| __define_attribute_writer(attribute, **opts) unless writer == false __define_attribute_reader(attribute, callable, **opts) end end
Memoized Reader only
# File lib/attr_memoized.rb, line 120 def attr_memoized_reader(*attrs, **opts) attr_memoized(*attrs, writer: false, **opts) end
# File lib/attr_memoized.rb, line 74 def included(base) base.class_eval do AttrMemoized::LOCK.synchronize do @attr_memoized_mutex ||= Mutex.new end # noinspection ALL class << self attr_reader :attr_memoized_mutex # A class method which, for each attribute in the list, # creates a thread-safe memoized reader and writer (unless writer: false) # # Memoized reader accepts <tt>reload: true</tt> as an optional argument, # which, if provided, forces reinitialization of the variable. # # @example: # # class LazyConnection # include AttrMemoized # attr_memoized :database_connection, -> { ActiveRecord::Base.connection } # attr_memoized :redis_pool, -> { ConnectionPool.new { Redis.new } } # end # # LazyConnection.new.database_connection # #=> <ActiveRecord::Connection::PgSQL::Driver:0xff23234f....> # # @param [Symbol] name of the attribute # @param [Symbol] another name of the attribute, etc... # @param [Proc,Symbol,Method] callable — something to call for lazy initialization # @param [Hash] options — you can define arguments here to be passed to a method or proc def attr_memoized(*attributes, **opts) attributes = Array[*attributes] callable = attributes.pop unless SUPPORTED_INIT_TYPES.include?(callable.class) raise ArgumentError, "Invalid argument #{callable} to attr_memoized. Expecting one of: #{SUPPORTED_INIT_TYPES.map(&:to_s)}" end writer = opts.delete(:writer) attributes.each do |attribute| __define_attribute_writer(attribute, **opts) unless writer == false __define_attribute_reader(attribute, callable, **opts) end end # Memoized Reader only def attr_memoized_reader(*attrs, **opts) attr_memoized(*attrs, writer: false, **opts) end private def __define_attribute_reader(attribute, callable, **opts) at_attribute = __at_var(attribute) define_method(attribute) do |*| __read_memoize(attribute, at_attribute, callable, **opts) end end def __define_attribute_writer(attribute, **_opts) at_attribute = __at_var(attribute) define_method("#{attribute}=".to_sym) do |value| with_lock { instance_variable_set(at_attribute, value) } value end end # Convert an attribute name into an @variable syntax def __at_var(attr) attr = attr.to_sym unless attr.is_a?(Symbol) @attr_cache ||= {} @attr_cache[attr] || (@attr_cache[attr] = "@#{attr}".to_sym) end end # instance method: uses the class +attr_memoized_mutex+ to create an instance # attr_memoized_mutex and then uses the instance attr_memoized_mutex to wrap instance's state # @return [Mutex] mutex def attr_memoized_mutex return @attr_memoized_mutex if @attr_memoized_mutex self.class.attr_memoized_mutex.synchronize { @attr_memoized_mutex ||= Mutex.new } end end end
Private Class Methods
Convert an attribute name into an @variable syntax
# File lib/attr_memoized.rb, line 142 def __at_var(attr) attr = attr.to_sym unless attr.is_a?(Symbol) @attr_cache ||= {} @attr_cache[attr] || (@attr_cache[attr] = "@#{attr}".to_sym) end
# File lib/attr_memoized.rb, line 126 def __define_attribute_reader(attribute, callable, **opts) at_attribute = __at_var(attribute) define_method(attribute) do |*| __read_memoize(attribute, at_attribute, callable, **opts) end end
# File lib/attr_memoized.rb, line 133 def __define_attribute_writer(attribute, **_opts) at_attribute = __at_var(attribute) define_method("#{attribute}=".to_sym) do |value| with_lock { instance_variable_set(at_attribute, value) } value end end
Public Instance Methods
This method offers “thread-local locking”: meaning that the synchronize block is never called twice from the same thread, thus avoiding deadlocks.
@param [Proc] block block to wrap in a synchronize unless we are already under one
# File lib/attr_memoized.rb, line 167 def with_lock(&block) if __locked? block.call else __with_thread_local_lock { attr_memoized_mutex.synchronize(&block) } end end
Private Instance Methods
This private method resolves the initializer argument and returns it's result.
# File lib/attr_memoized.rb, line 194 def __assign_value(attribute, at_attribute, callable, **opts) # reload the value of +var+ because we are now inside a synchronize block var = instance_variable_get(at_attribute) return var if var && !__reload?(opts) # now call whatever `was defined on +attr_memoized+ to get the actual value case callable when Symbol send(callable, **opts) when Method callable.call(**opts) when Proc instance_exec(&callable) else raise ArgumentError, "expected one of #{AttrMemoized::SUPPORTED_INIT_TYPES.map(&:to_s).join(', ')} for attribute #{attribute}, got #{callable.class}" end.tap do |result| instance_variable_set(at_attribute, result) end end
# File lib/attr_memoized.rb, line 224 def __locked? Thread.current[__object_lock_key] end
just a key into Thread.local
# File lib/attr_memoized.rb, line 220 def __object_lock_key @key ||= "this.#{object_id}".to_sym end
This private method is executed in order to initialize a memoized attribute.
@param [Symbol] attribute - name of the attribute @param [Symbol] at_attribute - symbol representing attribute instance variable @param [Proc, Method, Symbol] callable - what to call to get the uncached value @param [Hash] opts - additional options @option opts [Boolean] :reload - forces re-initialization of the memoized attribute
# File lib/attr_memoized.rb, line 185 def __read_memoize(attribute, at_attribute, callable, **opts) var = instance_variable_get(at_attribute) return var if var && !__reload?(opts) with_lock { __assign_value(attribute, at_attribute, callable, **opts) } instance_variable_get(at_attribute) end
Returns true
if opts
contains reload: true
# File lib/attr_memoized.rb, line 215 def __reload?(opts) opts.delete(:reload) end
# File lib/attr_memoized.rb, line 228 def __with_thread_local_lock raise ArgumentError, 'Already locked!' if __locked? Thread.current[__object_lock_key] = true yield if block_given? Thread.current[__object_lock_key] = nil end