Module: FeatureEnvy::LazyAccessor

Defined in:
lib/feature_envy/lazy_accessor.rb

Overview

Lazy accessors.

### Definition

A lazy attribute is an attribute whose value is determined on first access. The same value is used on all subsequent access without running the code to determine its value again.

Lazy attributes are impossible in Ruby (see the discussion below), but lazy accessors are, and are provided by this module.

### Applications

Deferring expensive computations until needed and ensuring they’re performed at most once.

### Usage

  1. Enable the feature in a specific class via ‘extend FeatureEnvy::LazyAccessor` or …

  2. Enable the feature in a specific scope (given module and all modules and classes contained within) using a refinement via ‘using FeatureEnvy::LazyAccessor`.

  3. Define one or more lazy attributes via ‘lazy(:name) { definition }`.

  4. Do NOT read or write to the underlying attribute, e.g. ‘@name`; always use the accessor method.

  5. Lazy accessors are thread-safe: the definition block will be called at most once; if two threads call the accessor for the first time one will win the race to run the block and the other one will wait and reuse the value produced by the first.

  6. It’s impossible to reopen a class and add new lazy accessors after **the any class using lazy accessors has been instantiated**. Doing so would either make the code thread-unsafe or require additional thread-safety measures, potentially reducing performance.

### Discussion

Ruby attributes start with ‘@` and assume the default value of `nil` if not assigned explicitly. Real lazy attributes are therefore impossible to implement in Ruby. Fortunately accessors (i.e. methods used to obtain attribute values) are conceptually close to attributes and can be made lazy.

A naive approach found in many Ruby code bases looks like this:

“‘ruby def highest_score_user

@highest_score_user ||= find_highest_score_user

end “‘

It’s simple but suffers from a serious flaw: if ‘nil` is assigned to the attribute then subsequent access will result in another attempt to determine the attribute’s value.

The proper approach is much more verbose:

“‘ruby def highest_score_user

# If the underlying attribute is defined then return it no matter its value.
return @highest_score_user if defined?(@highest_score_user)

@highest_score_user = find_highest_score_user

end “‘

### Implementation Notes

  1. Defining a lazy accessor defines a method with that name. The corresponding attribute is not set before the accessor is called for the first time.

  2. The first time a lazy accessor is added to a class a special module is included into it. It provides an ‘initialize` method that sets `@lazy_attributes_mutexes` - a hash of mutexes protecting each lazy accessor.

Examples:

class User
  # Enable the feature via refinements.
  using FeatureEnvy::LazyAccessor

  # Lazy accessors can return nil and have it cached and reused in
  # subsequent calls.
  lazy(:full_name) do
    "#{first_name} #{last_name}" if first_name && last_name
  end

  # Lazy accessors are regular methods, that follow a specific structure,
  # so they can call other methods, including other lazy accessors.
  lazy(:letter_ending) do
    if full_name
      "Sincerely,\n#{full_name}"
    else
      "Sincerely"
    end
  end
end

Defined Under Namespace

Classes: Error

Class Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Class Attribute Details

.mutex_factoryObject (readonly)

A mutex factory used by the lazy accessors feature.



181
182
183
# File 'lib/feature_envy/lazy_accessor.rb', line 181

def mutex_factory
  @mutex_factory
end

Class Method Details

.define(klass, name, &definition) ⇒ Object

Defines a lazy accessor.

Required to share the code between the extension and refinement.



188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/feature_envy/lazy_accessor.rb', line 188

def define klass, name, &definition
  name = name.to_sym
  variable_name = :"@#{name}"
  mutex_name = LazyAccessor.mutex_factory.register klass, name

  klass.class_eval do
    # Include the lazy accessor initializer to ensure state related to
    # lazy accessors is initialized properly. There's no need to include
    # this module more than once.
    #
    # Question: is the inclusion check required? Brief testing indicates
    # it's not.
    if !include? Initialize
      include Initialize
    end

    [
      define_method(name) do
        mutex = instance_variable_get(mutex_name)
        if mutex # rubocop:disable Style/SafeNavigation
          mutex.synchronize do
            if instance_variable_defined?(mutex_name)
              instance_variable_set variable_name, instance_eval(&definition)
              remove_instance_variable mutex_name
            end
          end
        end

        instance_variable_get variable_name
      end
    ]
  end
end

Instance Method Details

#lazy(name, &definition) ⇒ Array<Symbol>

Defines a lazy accessor.

The ‘definition` block will be called once when the accessor is used for the first time. Its value is returned and cached for subsequent accessor use.

Parameters:

  • name (String|Symbol)

    accessor name.

Yield Returns:

  • the value to store in the underlying attribute and return on subsequent accessor use.

Returns:

  • (Array<Symbol>)

    the array containing the accessor name as a symbol; this is motivated by the built-in behavior of ‘attr_reader` and other built-in accessor definition methods.



120
121
122
# File 'lib/feature_envy/lazy_accessor.rb', line 120

def lazy name, &definition
  LazyAccessor.define self, name, &definition
end