Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Metaprogrammatically defining Ruby methods that take keyword arguments?

Struct lets me create a new class that takes arguments and has some nice semantics. However, the arguments aren't required, and their order requires consulting the definition:

Point = Struct.new(:x, :y)

Point.new(111, 222)
#=> <point instance with x = 111, y = 222>

Point.new(111)
#=> <point instance with x = 111, y = nil>

I'd like something similar to a Struct, but which uses keyword arguments instead:

Point = StricterStruct.new(:x, :y)

Point.new(x: 111, y: 222)
#=> <point instance with x = 111, y = 222>

Point.new(x: 111)
#=> ArgumentError

That might look something like this:

module StricterStruct
  def self.new(*attributes)
    klass = Class.new
    klass.instance_eval { ... }

    klass
  end
end

But what should go in the braces to define an initialize method on klass such that:

  • it requires keyword arguments with no default value;
  • the keywords are given as an array of symbols in attributes; and
  • the initialize method assigns them to instance variables of the same name
like image 329
John Feminella Avatar asked Oct 24 '14 18:10

John Feminella


2 Answers

I wound up using a (surprisingly Pythonic) **kwargs strategy, thanks to the new features in Ruby 2.0+:

module StricterStruct
  def self.new(*attribute_names_as_symbols)
    c = Class.new
    l = attribute_names_as_symbols

    c.instance_eval {
      define_method(:initialize) do |**kwargs|
        unless kwargs.keys.sort == l.sort
          extra   = kwargs.keys - l
          missing = l - kwargs.keys

          raise ArgumentError.new <<-MESSAGE
            keys do not match expected list:
              -- missing keys: #{missing}
              -- extra keys:   #{extra}
          MESSAGE
        end

        kwargs.map do |k, v|
          instance_variable_set "@#{k}", v
        end
      end

      l.each do |sym|
        attr_reader sym
      end
    }

    c
  end
end
like image 60
John Feminella Avatar answered Nov 08 '22 03:11

John Feminella


I might be misunderstanding the question but are you looking for something like this?

module StricterStruct
  def self.new(*attributes)
    klass = Class.new
    klass.class_eval do 
      attributes.map!{|n| n.to_s.downcase.gsub(/[^\s\w\d]/,'').split.join("_")}
      define_method("initialize") do |args|
        raise ArgumentError unless args.keys.map(&:to_s).sort == attributes.sort
        args.each { |var,val| instance_variable_set("@#{var}",val) }
      end
      attr_accessor *attributes
    end
    klass
  end
end

Then

Point = StricterStruct.new(:x,:y)
#=> Point
p = Point.new(x: 12, y: 77)
#=> #<Point:0x2a89400 @x=12, @y=77>
p2 = Point.new(x: 17)
#=> ArgumentError
p2 = Point.new(y: 12)
#=> ArgumentError
p2 = Point.new(y:17, x: 22)
#=>  #<Point:0x28cf308 @y=17, @x=22>

If you want something more please explain as I think this meets your criteria at least my understanding of it. As it defines the methods and can take a "keyword"(Hash) argument and assign the proper instance variables.

If you want the arguments to be specified in the same order as they were defined just remove the sorts.

Also there might be cleaner implementations.

like image 24
engineersmnky Avatar answered Nov 08 '22 02:11

engineersmnky