John Feminella
John Feminella

Reputation: 311496

Ruby delegation for an object without overriding constructor

One example of Ruby delegation is to use a SimpleDelegator:

class FooDecorator < SimpleDelegator
  include ActiveModel::Model
  # ...
end

That's very convenient, since anything not responded to by FooDecorator is passed to the underlying object.

But this overwrites the constructor's signature, which makes it incompatible with things like ActiveModel::Model that expect to see a particular signature.

Another example of Ruby delegation is to use Forwardable:

class Decorator
  include ActiveModel::Model
  extend Forwardable

  attr_accessor :members
  def_delegator  :@members, :bar, :baz
  def_delegators :@members, :a, :b, :c
end

But now you have to be explicit about which methods you want to delegate, which is brittle.

Is there a way to get the best of both worlds, where I...

Upvotes: 0

Views: 2607

Answers (2)

Jim Gay
Jim Gay

Reputation: 1488

Why do you want ActiveModel::Model? Do you truly need all of the features?

Could you merely do the same thing?

extend  ActiveModel::Naming
extend  ActiveModel::Translation
include ActiveModel::Validations
include ActiveModel::Conversion

I'm aware that changes to ActiveModel::Model could break your decorator in terms of the expected behavior, but you're coupling pretty tightly to it anyway.

Allowing modules to control constructors is a code smell. Might be ok, but you should second guess it and be well aware of why it does so.

ActiveModel::Model defines initialize to expect a hash. I'm not sure how you expect to get the particular object that you want to wrap. SimpleDelegator uses __setobj__ inside of it's constructor, so you can use that after including a module that overrides your constructor.

If you want automatic forwarding you can just define the methods you need on your decorator when you set the object. If you can control how your object is created, make a build method (or something like that) which calls the initialize that needs to be used for ActiveModel::Model and __setobj__ that's used for SimpleDelegator:

require 'delegate'
require 'forwardable'
class FooCollection < SimpleDelegator
  extend Forwardable
  include ActiveModel::Model

  def self.build(hash, obj)
    instance = new(hash)
    instance.send(:set_object, obj)
    instance
  end

  private

  def set_object(obj)
    important_methods = obj.methods(false) - self.class.instance_methods
    self.class.delegate [*important_methods] => :__getobj__
    __setobj__(obj)
  end
end

This allows you to used the ActiveModel interface but adds the SingleForwardable module to the singleton class of the decorator which gives you the delegate method. All you need to do then is pass it a collection of method names and a method to use to get the object for forwarding.

If you need to include or exclude particular methods, just change the way important_methods is created. I didn't think much about that, so double-check what's actually being used there before grabbing this code. For example, once the set_object method is called once, you can skip calling it later, but this is built to expect that all wrapped objects have the same interface.

As you pointed out on twitter, the draper gem uses the delegate method (from ActiveSupport) inside of method_missing. With that approach, each missed hit will incur the cost of opening the class and defining the method for forwarding. The upside is that it's lazy and you don't need to calculate which methods need to be forwarded and that hit is only incurred on the first miss; subsequent method calls won't be missed because you're defining that method. The code I made above will get all those methods and define them at once.

If you need more flexibility and expect your decorator to not be the same type of object you can use SingleForwardable for the same effect but it will define methods for each wrapped instance instead of affecting the decorator class:

require 'delegate'
require 'forwardable'
class FooCollection < SimpleDelegator
  include ActiveModel::Model

  def self.build(hash, obj)
    instance = new(hash)
    instance.set_object(obj)
    instance
  end

  def set_object(obj)
    important_methods = obj.methods(false) - self.class.instance_methods
    singleton_class.extend SingleForwardable
    singleton_class.delegate [*important_methods] => :__getobj__
    __setobj__(obj)
  end
end

But all of this is using SimpleDelegator and if you're not actually using method_missing, you can cut that out (assuming you've calculated the important_methods part correctly:

require 'forwardable'
class FooCollection
  include ActiveModel::Model

  def self.build(hash, obj)
    instance = new(hash)
    instance.set_object(obj)
    instance
  end

  def set_object(obj)
    important_methods = obj.methods(false)# - self.class.instance_methods
    singleton_class.extend SingleForwardable
    singleton_class.delegate [*important_methods] => :__getobj__
    __setobj__(obj)
  end

  def __getobj__
    @obj
  end

  def __setobj__(obj)
    __raise__ ::ArgumentError, "cannot forward to self" if self.equal?(obj)
    @obj = obj
  end
end

If you do that, however, it kills the use of super so you can't override a method defined on your wrapped object and call super to get the original value like you can with method_missing used in SimpleDelegator.

I wrote casting to add behavior to objects without worrying about wrappers. You can't override methods with it, but if all you're doing is adding new behaviors and new methods, then it will be much simpler to use by just adding a bucket of methods to an existing object. It's worth checking it out. I gave a presentation about the delegate and forwardable libraries at RubyConf 2013

Upvotes: 1

Chris Heald
Chris Heald

Reputation: 62648

Have you looked at the Delegator docs? You could basically re-implement your own Delegator subclass with the __getobj__ and __setobj__ methods. Or, you could just subclass SimpleDelegator and specify your own constructor that calls super(obj_to_delegate_to), no?

You could always just implement method_missing on your decorator and pass through any not-found method to the underlying object, too.

Edit: Here we go. The use of an intermediate inherited class breaks the super() chaining, allowing you to wrap the class as desired:

require 'active_model'
require 'delegate'

class Foo
  include ActiveModel::Model
  attr_accessor :bar
end

class FooDelegator < Delegator
  def initialize
    # Explicitly don't call super
  end

  def wrap(obj)
    @obj = obj
    self
  end

  def __getobj__
    @obj
  end
end

class FooDecorator < FooDelegator
  include ActiveModel::Model

  def name
    self.class.name
  end
end

decorator = FooDecorator.new.wrap(Foo.new bar: "baz")
puts decorator.name #=> "decorator"
puts decorator.bar  #=> "bar"

Upvotes: 3

Related Questions