I've tried this in both Ruby 3.0.2 and Ruby 2.73, with similar results. It shouldn't matter for the problem at hand, as I've also tried this under different shells and ruby managers, but this is primarily being tested under:
I'm trying to use the poorly (or possibly even undocumented) DelegateClass from the Delegator class to create a facade for YAML::Store that allows me to read and write arbitrary keys to and from a YAML store. However, I clearly don't understand how to properly delegate to the YAML::Store instance, or to override or extend the functionality in the way that I want.
For simplicity, I wrote my example as a self-executing Ruby file names example.rb, so please scroll to the end to see the actual call to the classes. I'm hoping that my mistake is fairly trivial, but if I'm fundamentally misunderstanding how to actually perform the delegation of CollaboratorWithData#write and ollaboratorWithData#read to MultiWriter, please educate me.
Note: I know how to solve this problem by simply treating YAML::Store as an object instantiated within my class, or even as a separate object that inherits from YAML::Store (e.g. class MultiWriter < YAML::Store
) but I'm very much trying to understand how to properly use Forwardable, SimpleDelegator, and Delegate to wrap objects both in the general case and in this particular use case.
#!/usr/bin/env ruby
require 'delegate'
require 'yaml/store'
module ExampleDelegator
attr_accessor :yaml_store, :data
class CollaboratorWithData
def initialize
@yaml_store = MultiWriter.new
@data = {}
end
def some_data
{a: 1, b:2, c: [1, 2, 3]}
end
end
class MultiWriter < DelegateClass(YAML::Store)
attr_reader :store
def initialize file_name="store.yml", thread_safe=true
super
@store = self
end
def write **kwargs
@store.transaction { kwargs.each { |k, v| @store[k] = v } }
end
def read *keys
@store.transaction(read_only=true) { keys.map { |k| @store[k] } }
end
end
end
if __FILE__ == $0
include ExampleDelegator
c = CollaboratorWithData.new
c.data = c.some_data
c.write(c.data)
end
Traceback (most recent call last):
5: from ./example.rb:40:in `<main>'
4: from ./example.rb:40:in `new'
3: from ./example.rb:11:in `initialize'
2: from ./example.rb:11:in `new'
1: from ./example.rb:24:in `initialize'
/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/delegate.rb:71:in `initialize': wrong number of arguments (given 2, expected 1) (ArgumentError)
Please note that, if you look carefully at the invocation of YAML::Store#new, one of the possible signatures does take two arguments. I don't understand why it won't allow me to specify thread safety when I can do it in IRB:
foo = YAML::Store.new 'foo.yml', true
#=> #<Psych::Store:0x00007f9f520f52c8 @opt={}, @filename="foo.yml", @abort=false, @ultra_safe=false, @thread_safe=true, @lock=#<Thread::Mutex:0x00007f9f520f5138>>
Even if I take out the thread_safe argument, I still get NoMethodError from CollaboratorWithData when calling the delegated #write method, which leads me to believe that there are issues with my implementation of delegation beyond the initializer.
./example.rb
Traceback (most recent call last):
./example.rb:42:in `<main>': undefined method `write' for #<ExampleDelegator::CollaboratorWithData:0x00007ff86e0e15c8> (NoMethodError)
To go over some of your questions regarding delegation:
require 'forwardable'
class A
attr_reader :obj
extend Forwardable
def_delegator :@obj, :<<
def initialize(val)
@obj = val
end
end
a = A.new([])
a << 1
#=> [1]
a.obj
#=> [1]
Delegator: Inheriting from this class allows methods to be delegated to an Object passed via new
. This Object is internalized upon instantiation using the __setobj__
method and calls are forwarded via method_missing
; however the caveat here is that it uses 2 methods that are not defined by default(__setobj__
and __getobj__
) and you would need to define them in the inheriting class
SimpleDelegator: Inherits from Delegator
and shares its personality in most regards; however it predefines the 2 methods previously discussed. Generally inheriting from SimpleDelegator
is preferred over inheriting directly from the Delegator
class for its ease of use.
DelegateClass: Is similar to SimpleDelegator
however it creates a new anonymous class which defines all the instance methods of the class passed in as an argument and then your inheriting class inherits from this anonymous class directly which allows method via inheritance rather than using method_missing for delegation. e.g.
class A < Delegator;end
class B < DelegateClass(String);end
A.ancestors
#=> [A, Delegator, #<Module:0x00007fffcc1cc9c8>, BasicObject]
B.ancestors
#=> [B, #<Class:0x00007fffcc5603c8>, Delegator, #<Module:0x00007fffcc1cc9c8>, BasicObject]
A.instance_methods - Object.instance_methods
#=> [:__getobj__, :__setobj__, :method_missing, :marshal_dump, :marshal_load]
B.instance_methods - Object.instance_methods
#=> Array of all the public the instance methods of String plus the above
This is generally preferable when you are expecting the class to be instantiated with a specific type of Object because the Module injection avoids the full inheritance chain traversal that comes in to play with method_missing
. This does not mean that you cannot instantiate this class with another object (however if you do and that object defines methods not defined in the class argument passed to DelegateClass
it will fall back to the default method_missing
behavior)
Now to your Code
Your first error is the call to super
. The Delegator
expects 1 argument which an instance of the Object
being delegated to (YAML::Store
in your case) however you have defined 2 arguments (neither of which represents the object you wish to delegate to) and when you call super
both these arguments are forwarded on, thus the error.
Removing thread_safe
works because you now only have a single argument but your delegated object in this case is actually the String "store.yml"
For Example the following modification should work (Similar to the example provided in the Source Code
module ExampleDelegator
class MultiWriter < DelegateClass(YAML::Store)
attr_reader :store
def initialize file_name="store.yml", thread_safe=true
@store = YAML::Store.new(file_name,thread_safe)
super(@store)
end
end
end
Your second issue is that you are calling write
on the CollaboratorWithData
object which does not define this method and is not otherwise delegating.
While I am not 100% sure what your intent is here as there are some other oddities like attr_accessor
in the Module body which is then being included in the global space and as it stands I do not see a true reason for a Delegator
becuase you could just use the Object directly via the yaml_store
instance variable being set to an instance of YAML::Store
(as you already mentioned) but you can rewrite your code as follows to use delegation if you wish
require 'delegate'
require 'yaml/store'
require 'forwardable'
module ExampleDelegator
class CollaboratorWithData
extend Forwardable
def_delegators :@yaml_store, :read, :write
attr_reader :yaml_store
attr_accessor :data
def initialize(file_name="store.yml", thread_safe=true)
@yaml_store = MultiWriter.new(YAML::Store.new(file_name,thread_safe))
@data = {}
end
def some_data
{a: 1, b:2, c: [1, 2, 3]}
end
end
class MultiWriter < DelegateClass(YAML::Store)
def write **kwargs
transaction { kwargs.each { |k, v| @store[k] = v } }
end
def read *keys
transaction(read_only=true) { keys.map { |k| @store[k] } }
end
end
end