Skip to content

Commit

Permalink
Support parameters with lazily-evaluated default values
Browse files Browse the repository at this point in the history
Lazily-evaluated defaults can be provided by supplying `Value.lazy { ... }` as a
default. The lazy block will be evaluated at each instantiation time.
  • Loading branch information
chrisandreae committed Oct 17, 2024
1 parent dfb0236 commit 1fe743f
Show file tree
Hide file tree
Showing 3 changed files with 67 additions and 10 deletions.
2 changes: 1 addition & 1 deletion lib/safe_values/version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module SafeValues
VERSION = "1.0.2"
VERSION = "1.1.0"
end
48 changes: 39 additions & 9 deletions lib/value.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
# `ValueType = Value.new(:a, :b, c: default_value)`. The default values to
# optional arguments are saved at class creation time and supplied as default
# constructor arguments to instances. Default values are aliased, so providing
# mutable defaults is discouraged.
# mutable defaults is discouraged. Lazily-evaluated defaults can be provided by
# supplying `Value.lazy { ... }` as a default. The lazy block will be evaluated
# at each instantiation time.
#
# Two instance constructors are provided, with positional and keyword arguments.
#
Expand All @@ -25,6 +27,8 @@
# Value types may be constructed with keyword arguments using `with`.
# For example: `ValueType.with(a: 1, b: 2, c: 3)`
class Value < Struct
LazyDefault = Struct.new(:proc)

class << self
def new(*required_args, **optional_args, &block)
arguments = {}
Expand All @@ -34,13 +38,24 @@ def new(*required_args, **optional_args, &block)

clazz = super(*arguments.keys, &nil)

# Undefine setters
arguments.keys.each do |arg|
clazz.send(:undef_method, :"#{arg}=")
end

# define class and instance methods in modules so that the class can
# override them
keyword_constructor = generate_keyword_constructor(arguments)
class_method_module = Module.new do
module_eval(keyword_constructor)
define_method(:__constructor_default) do |name|
optional_args.fetch(name)

optional_args.each do |name, value|
method_name = :"__constructor_default_#{name}"
if value.is_a?(LazyDefault)
define_method(method_name, &value.proc)
else
define_method(method_name) { value }
end
end
end
clazz.extend(class_method_module)
Expand All @@ -57,6 +72,21 @@ def new(*required_args, **optional_args, &block)
clazz
end

def lazy(&proc)
LazyDefault.new(proc)
end

# Value classes by default create immutable instances. In some cases we may
# want the construction advantages of Values without the immutability
# constraint. Allow subclasses to override this behaviour.
def mutable?
defined?(@mutable) && @mutable
end

def mutable!
@mutable = true
end

private

def validate_names(*params)
Expand All @@ -73,23 +103,23 @@ def validate_names(*params)
#
# For a Value.new(:a, b: x), will define the method:
#
# def initialize(a, b = self.class.__constructor_default(:b))
# def initialize(a, b = self.class.__constructor_default_b)
# super(a, b)
# freeze
# freeze unless self.class.mutable?
# end
def generate_constructor(arguments)
params = arguments.map do |arg_name, required|
if required
arg_name
else
"#{arg_name} = self.class.__constructor_default(:#{arg_name})"
"#{arg_name} = self.class.__constructor_default_#{arg_name}"
end
end

<<-SRC
def initialize(#{params.join(", ")})
super(#{arguments.keys.join(", ")})
freeze
freeze unless self.class.mutable?
end
SRC
end
Expand All @@ -100,15 +130,15 @@ def initialize(#{params.join(", ")})
#
# For a Value.new(:a, b: x), will define the (class) method:
#
# def with(a:, b: __constructor_default(:b))
# def with(a:, b: __constructor_default_b)
# self.new(a, b)
# end
def generate_keyword_constructor(arguments)
params = arguments.map do |arg_name, required|
if required
"#{arg_name}:"
else
"#{arg_name}: __constructor_default(:#{arg_name})"
"#{arg_name}: __constructor_default_#{arg_name}"
end
end

Expand Down
27 changes: 27 additions & 0 deletions spec/unit/value_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,33 @@
end
end

context "with lazy defaults" do
let(:next_value) do
x = 0
->() { x += 1 }
end

let(:value) { Value.new(a: Value.lazy(&next_value)) }

it "can construct an instance with positional lazy arguments" do
v = value.new
expect(v.a).to eq(1)
v = value.new
expect(v.a).to eq(2)
v = value.new(-1)
expect(v.a).to eq(-1)
end

it "can construct an instance with keyword lazy arguments" do
v = value.with()
expect(v.a).to eq(1)
v = value.with()
expect(v.a).to eq(2)
v = value.with(a: -1)
expect(v.a).to eq(-1)
end
end

context "with a class body" do
let(:value) do
Value.new(:a) do
Expand Down

0 comments on commit 1fe743f

Please sign in to comment.