Home > Back-end >  How to define setter methods for all keys in a hash dynamically
How to define setter methods for all keys in a hash dynamically

Time:01-05

I am writing a class which has a hash data member. The hash is initialized to have a set of keys with default values in the constructor.

How do I then generate setter methods corresponding to all the keys in the hash?

Please consider the following example:

class MyClass
    def initialize
        @hash = {
            "key1" => "N/A",
            "key2" => "N/A",
            "key3" => "N/A",
        }
    end
end

c = MyClass.new
c.key1 = "value1" # <--- How do I make something like this possible?

I imagine using .each method and then a block which uses :define_method.

I had a look at this question and this question but I wasn't able to deduct a solution to my question from them.

CodePudding user response:

There are several ways to solve the problem, with different pros/cons.

  1. You can use define_singleton_method, (almost) as you suggested (this solution has a serious drawback, I'll get back to it later):
class MyClass
  def initialize
    @hash = {
        "key1" => "N/A",
        "key2" => "N/A",
        "key3" => "N/A",
    }

    @hash.each do |key, value|
      self.define_singleton_method "#{key}=" do |value|
        @hash[key] = value
      end
    end
  end
end

c = MyClass.new #=> #<MyClass:0x00000001451b76c8 @hash={"key1"=>"N/A", "key2"=>"N/A", "key3"=>"N/A"}>
c.key1 = "foo"
c #=> #<MyClass:0x00000001451b76c8 @hash={"key1"=>"foo", "key2"=>"N/A", "key3"=>"N/A"}>

The biggest pros of this method is its explicitness - we do create "real" instance methods.

The biggest disadvantage of this approach is that it creates setters only for the initial set of keys. If you add keys later there will be no setters for them. If you remove keys later, there the setters will be still defined and re-add keys silently when called (this might be not what you expect).

  1. Another way to solve the problem is using method_missing:
class MyClass
  def initialize
    @hash = {
        "key1" => "N/A",
        "key2" => "N/A",
        "key3" => "N/A",
    }
  end

  def method_missing(name, *args)
    if name.end_with?("=") && @hash.key?(name[0..-2])
      @hash[name[0..-2]] = args.first
    else
      super
    end
  end

  def respond_to_missing?(name)
    (name.end_with?("=") && @hash.key?(name[0..-2])) || super
  end
end

c = MyClass.new #=> #<MyClass:0x000000012e3ba090 @hash={"key1"=>"N/A", "key2"=>"N/A", "key3"=>"N/A"}>
c.key1 = "foo"
c #=> #<MyClass:0x000000012e3ba090 @hash={"key1"=>"foo", "key2"=>"N/A", "key3"=>"N/A"}>

It works with the actual data structure, checking if the key exists before delegating the call. Trying to set non-existing key will raise no method error.

The thing to remember here is that if you ever create an instance method with name key= where key is the key inside @hash, this setter magic won't work for this key (method_missing will never be called for this key). Another drawback of the 2nd approach compared to the 1st one to keep in mind is that we don't actually create setters per se. So inspecting instance's methods won't expose them.

CodePudding user response:

It seems that you have a slight misunderstanding of Ruby's object model, which is probably what hinders you in finding the solution yourself.

I am writing a class which has a hash data member.

Ruby doesn't have "data members". The @hash is not a "data member", it is an instance variable. And the instance variable is not a "member" of the class, it is a "member" of the instance, hence why it is called instance variable.

The hash is initialized to have a set of keys with default values in the constructor.

Ruby doesn't have "constructors". In Ruby, instantiating a new object works like this:

The Class class has an instance method called Class#allocate. As the name implies, this method allocates storage space for a new object. So, in your case, when we write something like

new_object = MyClass.allocate

this would allocate storage space for a new instance of MyClass, instantiate a new instance of MyClass in that storage space, return the newly instantiated object and bind it to the local variable new_object.

This newly instantiated object is completely empty, it does not contain any data (other than the standard object metadata such as the reference to its class), which means in particular, it has no instance variables.

If you want to initialize the object with a pre-defined set of instance variables, you would send a message to the object telling it to initialize itself. This is typically accomplished with an initializer method. By convention, this method is called initialize.

(Note: it is slightly more than just a convention, there is a tiny bit of language magic here as well, in that initialize is created with private accessibility by default, unlike other methods which are created with public accessibility by default.)

So, after allocating our object with Class#allocate, we would now initialize it:

new_object.initialize

or rather, since (as I mentioned above) initialize is private by default, we need to use BasicObject#__send__ to circumvent the access restriction:

new_object.__send__(:initialize)

Note: if you are familiar with Objective-C, you might recognize this:

MyClass newObject = [[MyClass alloc] init];

Remembering to always initialize a newly instantiated object is somewhat cumbersome, so there is a helper factory method called Class#new, which looks more or less like this:

class Class
  def new(...)
    obj = allocate
    obj.__send__(:initialize, ...)
    obj
  end
end

And there is a default implementation in BasicObject which has no parameters and does nothing:

class BasicObject
  def initialize; end
end

This guarantees that Class#new will never fail with a NoMethodError exception.

The important thing to realize here is that allocate, initialize, and new are methods like any other, they can be re-implemented (with the exception of allocate which needs to do some magic that can not be expressed in Ruby itself), they can be overridden, they can be monkey-patched, they can be intercepted, etc.

They are not "constructors", which are things that are kinda-sorta like methods but not really methods, with extra restrictions about inheritance, extra restrictions about what they are allowed to do, etc. They are just methods.

How do I then generate setter methods corresponding to all the keys in the hash?

[…]

I imagine using .each method and then a block which uses :define_method.

The simplest and smallest change to your existing code would be something like this:

class MyClass
  def initialize
    @hash = { key1: 'N/A', key2: 'N/A', key3: 'N/A' }

    @hash.each_key do |key|
      self.class.define_method(:"#{key}=") do |value|
        @hash[key] = value
      end
    end
  end
end

However, there is a problem with this: as explained above, MyClass#initialize is an instance method that is run by MyClass::new every time you create a new instance of MyClass. This means that every time the user creates a new object (except the first time), they will be annoyed with a plethora of method re-definition warnings:

MyClass.new

MyClass.new
# my_class.rb:6: warning: method redefined; discarding old key1=
# my_class.rb:6: warning: previous definition of key1= was here
# my_class.rb:6: warning: method redefined; discarding old key2=
# my_class.rb:6: warning: previous definition of key2= was here
# my_class.rb:6: warning: method redefined; discarding old key3=
# my_class.rb:6: warning: previous definition of key3= was here

MyClass.new
# my_class.rb:6: warning: method redefined; discarding old key1=
# my_class.rb:6: warning: previous definition of key1= was here
# my_class.rb:6: warning: method redefined; discarding old key2=
# my_class.rb:6: warning: previous definition of key2= was here
# my_class.rb:6: warning: method redefined; discarding old key3=
# my_class.rb:6: warning: previous definition of key3= was here

You can imagine that, in an application which might create dozens, hundreds, or even millions of objects, this can get really annoying really fast.

You could fix this problem by defining the writer methods on the singleton classes of the instantiated objects using Object#define_singleton_method like this:

class MyClass
  def initialize
    @hash = { key1: 'N/A', key2: 'N/A', key3: 'N/A' }

    @hash.each_key do |key|
      define_singleton_method(:"#{key}=") do |value|
        @hash[key] = value
      end
    end
  end
end

But that is not an elegant solution: the point of singleton methods is that each object can have its own distinct set of methods. But if you define the same methods for all objects, then what's the point? Just define the methods in the class, once, for all objects.

The way to handle this is to move the creation of the methods out of the initializer method and only run it once when the class itself is created, something like this:

class MyClass
  DEFAULT_HASH = { key1: 'N/A', key2: 'N/A', key3: 'N/A' }.freeze
  private_constant :DEFAULT_HASH

  DEFAULT_HASH.each_key do |key|
    define_method(:"#{key}=") do |value|
      @hash[key] = value
    end
  end

  def initialize
    @hash = DEFAULT_HASH.dup
  end
end

There is some duplication in the creation of the default hash, which we could refactor like this:

class MyClass
  DEFAULT_HASH_KEYS = %i[key1 key2 key3].freeze
  DEFAULT_HASH_VALUE = 'N/A'
  DEFAULT_HASH = DEFAULT_HASH_KEYS.zip([DEFAULT_HASH_VALUE].cycle).to_h.freeze
  private_constant :DEFAULT_HASH_KEYS, :DEFAULT_HASH_VALUE, :DEFAULT_HASH

  DEFAULT_HASH_KEYS.each do |key|
    define_method(:"#{key}=") do |value|
      @hash[key] = value
    end
  end

  def initialize
    @hash = DEFAULT_HASH.dup
  end
end

However, all of this functionality already exists in the form of the Struct class:

MyClass =
  Struct.new(:key1, :key2, :key3) do
    def initialize
      super(*members.map { 'N/A' })
    end
  end

This is all you need to do to get the desired behavior. In fact, this gives you a lot more than just the desired behavior. It also gives you a sensible implementation of equality (Struct#==) without you having to define it yourself, it gives you a sensible implementation of deconstruction for pattern matching in the form of (Struct#deconstruct and Struct#deconstruct_keys) without you having to define it yourself, etc. You will also have readers defined in addition to the writers, i.e. MyClass#key1, MyClass#key2, and MyClass#key3, in addition to MyClass#key1=, MyClass#key2=, and MyClass#key3=.

Note: one important thing to understand about Struct is that Struct::new does not return an instance of Struct, but a subclass of Struct, i.e. an instance of Class. This is unusual: normally, sending the new message to a class will return an instance of that class, e.g. Array.new will return an Array, Hash.new will return a Hash, MyClass.new will return a MyClass, and so on. But Struct.new will return a Class which is a subclass of Struct.

Related to Struct, there is also the ostruct library in the Ruby standard library, which is however more similar to a Hash than a Struct.

And there is the Data class, which is similar to Struct but for immutable value objects.

  • Related