rubyyamldelegateswrapperfacade

How do I properly use the Ruby DelegateClass to wrap YAML::Store?


Testing Environment

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:

Describing the Problem (Code and Errors in Subsequent Sections)

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.

Self-Executing Code File (Some Vertical Scrolling Required)

#!/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

Errors When Running File

Error in Initializer
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>>
Error in Method Delegation with Modified Initializer

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)

Solution

  • 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]
    
    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