Lazy Attributes in Ruby
Lazy attributes are absent from Ruby, but a bit of creativity enables them to be programmed into the language.
Definition
A lazy attribute’s value is determined the first time it’s used. Technically, attributes in Ruby start with @
and cannot be made lazy. However, blurring the line between attributes and accessors leads to three implementations of accessor-based laziness.
Naive Approach
The naive approach is often found in the wild. It uses ||=
instead of =
to define an instance attribute. For example, a full name can be determined based on first and last names via:
class User
def full_name
@full_name ||= "#{first_name} #{last_name}"
end
end
If more than one statement is needed to determine the value they can be wrapped in a block.
This approach is succinct, but suffers from a serious, even fatal, flaw: nil
and false
will result in duplicate computations. attribute ||= value
is equivalent to attribute = attribute || value
, so if attribute
is falsey value
will always be evaluated.
Fortunately, this drawback can be eliminated.
Robust Approach
The naive approach conflated two things: checking for attribute’s existence and checking its boolean value. The ||=
trick works because referencing an undefined attribute returns nil
. A robust implementation of lazy attributes requires an explicit existence check.
Ruby offers defined?
to do exactly that. For example, full_name
from the previous section can be implemented as:
def full_name
return @full_name if defined?(@full_name)
@full_name = "#{first_name} #{last_name}"
end
Correctness was gained at the expense of brevity. Luckily, there is a way to regain that brevity.
Meta-programming
Ruby’s flexibility enables enhancing the language with a new construct that implements the robust approach. It’s actually much simpler than it sounds.
We’ll add a method named lazy
to Class
, so that it can be used in class definitions. It will take the attribute name as an argument and a block to determine its value. Let’s have a look at the implementation before discussing it:
def lazy name, &definition
variable_name = :"#@{name}"
define_method(name) do
if instance_variable_defined? variable_name
instance_variable_get variable_name
else
instance_variable_set variable_name, instance_eval(&definition)
end
end
end
There are a few things worth commenting.
First, variable_name
is set prior to method definition to avoid recomputing it on every method call.
Second, instance_variable_defined?
is used instead of defined?
. The reason is defined?
is a special construct; defined?(variable_name)
would always return true
because there is a variable named variable_name
.
Third, definition
has to be evaluated within the instance via instance_eval
. It cannot be called via definition.call
, as its body wouldn’t have access to instance attributes. For example, @first_name
and @last_name
from the previous example would be inaccessible.
The big question is: where should lazy
be put? In the olden days of monkey-patching it’d be defined on Class
directly or inside a module to extend specific classes with (which isn’t that bad). However, Ruby offers refinements for exactly that kind of patches. The LazyAttribute
refinement could look like this:
module LazyAttribute
# When Lazy is "activated" via using, patch the following class:
refine Class do
# Define the following method inside that class:
def lazy name, &definition
# ...
end
end
end
After the refinement is defined it’s enough to call using LazyAttribute
inside a class definition to gain access to lazy
. Meta-programming doesn’t have to be scary! The following snippet compares the before and after versions of a lazy accessor. It’s clear lazy
resulted in increased legibility.
# Without LazyAttribute
class User
def full_name
return @full_name if defined?(@full_name)
@full_name ||= "#{first_name} #{last_name}"
end
end
# With LazyAttribute
class User
extend LazyAttribute
lazy(:full_name) { "#{first_name} #{last_name}" }
end
Closing Thoughts
Ruby is elegant and extensible, which enables developers to enhance it with new facilities. It’s easy to abuse that power, but it does not mean it shouldn’t be used at all, especially that refinements are available.
I’m working on a gem that will enhance Ruby with that and other features borrowed from other programming languages. Expect to see more articles and updates in the coming months.
Enjoyed the article? Follow me on Twitter!
I regularly post about Ruby, Ruby on Rails, PostgreSQL, and Hotwire.