What are lazy attributes?
Lazy attributes are properties or variables in a programming language that are not computed until their value is specifically requested or accessed. Instead of being calculated immediately when the object is created or when the attribute is defined, lazy attributes defer their computation until needed. This can be useful for optimizing performance, especially when dealing with large or complex data structures, by only computing values when required.
Do we have lazy attributes in Ruby?
Ruby doesn't have built-in lazy attributes like some other languages. However, you can achieve lazy initialization of attributes in Ruby using various techniques. We can see lazy attributes in dry-initializer
, reform
, … . Let’s focus right now on example with dry-initializer
.
# game.rb
class Game
extend Dry::Initializer
option :board_id, default: -> { server.fetch_board_id }
option :duration, default: -> { server.fetch_game_duration }
option :max_players, default: -> { 4 }
# ...
end
The syntax involving lambda might appear unconventional when compared to other Ruby examples. However, a significant advantage of using lambda or proc is that it allows us to receive a value after invoking call
method. This capability proves especially beneficial when the default value is obtained from a database query.
To gain a better understanding of this feature, let's explore a simpler example involving hashes.
default_game_attributes = {
max_players: -> { server.fetch_max_players }
duration: -> { server.fetch_game_duration }
}
game_params = {
board_id: 1,
max_players: 2
}
game_attributes = {
**default_game_attributes,
**game_params
}
game_attributes
# => {
# board_id: 1,
# duration: #<Proc:0x000000010d6c3208@-e:1 (lambda)>
# max_players: 2
# }
The code above optimizes by saving a database query for fetching the max_players
value in the default_game_attributes
hash. However, it introduces a side effect: the duration
key now holds a lambda object instead of a number. This tradeoff needs careful consideration. Before using the game_attributes
hash elsewhere, we must resolve every callable object. This can be achieved with a simple method.
def resolve_lazy_hash(hsh)
hsh.deep_transform_values do |value|
value.respond_to?(:call) ? value.call : value
end
end
resolve_lazy_hash(game_attributes)
# => {
# board_id: 1,
# duration: 60
# max_players: 2
# }
Back to the class
# game.rb
class Game
extend Dry::Initializer
option :board_id, default: -> { server.fetch_board_id }
option :duration, default: -> { server.fetch_game_duration }
option :max_players, default: -> { 4 }
# ...
end
dry-initializer
operates by requiring a proc (or any callable object) for default values, making it impossible to assign raw values directly. In the example above, one additional requirement is needed: Game
class must have #server
method. Let's implement it.
# game.rb
class Game
extend Dry::Initializer
option :board_id, default: -> { server.fetch_board_id }
option :duration, default: -> { server.fetch_game_duration }
option :max_players, default: -> { 4 }
def server
@server ||= Server.new
end
end
# server.rb
class Server
def logger
logger ||= Logger.new(STDOUT)
end
def fetch_board_id
logger.info("#fetch_board_id called")
1
end
def fetch_game_duration
logger.info("#fetch_game_duration called")
60
end
end
Once we have everything we need, we can run it.
game = Game.new(board_id: 123)
pp game
# ...] INFO -- : #fetch_game_duration called
#<Game:0x000000010c27dfa0 @board_id=123, @duration=60, @max_players=4, @server=#<Game::Server:0x000000010c27db68>>
game = Game.new
pp game
# ...] INFO -- : #fetch_board_id called
# ...] INFO -- : #fetch_game_duration called
#<Game:0x0000000108a1efd8 @board_id=1, @duration=60, @max_players=4, @server=#<Game::Server:0x0000000108a1eb78>>
When are lazy attributes useful?
Hash and attributes are quite similar. Wrapping values with proc gives two features:
- access to instance methods
- postpone computations until we need the value