From 1fe743f8aea43d7f18446403a2ea9d304424322d Mon Sep 17 00:00:00 2001 From: Chris Andreae Date: Thu, 17 Oct 2024 16:39:48 +0900 Subject: [PATCH] Support parameters with lazily-evaluated default values Lazily-evaluated defaults can be provided by supplying `Value.lazy { ... }` as a default. The lazy block will be evaluated at each instantiation time. --- lib/safe_values/version.rb | 2 +- lib/value.rb | 48 +++++++++++++++++++++++++++++++------- spec/unit/value_spec.rb | 27 +++++++++++++++++++++ 3 files changed, 67 insertions(+), 10 deletions(-) diff --git a/lib/safe_values/version.rb b/lib/safe_values/version.rb index eab65bd..c2c371b 100644 --- a/lib/safe_values/version.rb +++ b/lib/safe_values/version.rb @@ -1,3 +1,3 @@ module SafeValues - VERSION = "1.0.2" + VERSION = "1.1.0" end diff --git a/lib/value.rb b/lib/value.rb index 6af177c..5511f1e 100644 --- a/lib/value.rb +++ b/lib/value.rb @@ -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. # @@ -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 = {} @@ -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) @@ -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) @@ -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 @@ -100,7 +130,7 @@ 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) @@ -108,7 +138,7 @@ def generate_keyword_constructor(arguments) if required "#{arg_name}:" else - "#{arg_name}: __constructor_default(:#{arg_name})" + "#{arg_name}: __constructor_default_#{arg_name}" end end diff --git a/spec/unit/value_spec.rb b/spec/unit/value_spec.rb index 7c8995d..64ff8c0 100644 --- a/spec/unit/value_spec.rb +++ b/spec/unit/value_spec.rb @@ -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