8

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

4 Answers 4

6

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
Sign up to request clarification or add additional context in comments.

3 Comments

I like the message I just changed mine to have a very similar structure although I gave you both getter and setter methods for the instance_variables. Sorry I wasn't as quick as you answering your own question :). Also note I updated my answer to accept strings or symbols and to format them as snake_case.
Don't you mean def self.new(*attribute_names_as_symbols)? Also, I believe you can create each read accessor with attr_reader sym. Interesting question.
@CarySwoveland Yes, that's a typo from my copy-paste. Thanks for catching that. You're also right that attr_reader is much simpler; I should have picked up on that!
1

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.

7 Comments

This isn't quite right -- as my example shows, Point.new(x: 12) should have given you an ArgumentError since you didn't specify y.
@JohnFeminella missed that part. Now it looks even uglier :( but functions as requested.
I don't understand why you are introducing the class variable, as attributes is visible with define_method.
@JohnFeminella cleaned it up a lot I like your error message better but this will accept strings or symbols and will convert them to downcase snake_case.
Seems to me it would be better without attributes.map!.
|
0

It sounds like you're looking for Ruby's built-in OpenStruct:

require 'ostruct'

foo = OpenStruct.new(bar: 1, 'baz' => 2)
foo # => #<OpenStruct bar=1, baz=2>

foo.bar # => 1
foo[:bar] # => 1
foo.baz # => 2
foo.baz = 3
foo # => #<OpenStruct bar=1, baz=3>

I think of OpenStruct as candy-coating on a Hash, where we can access and assign to the instance without any real constraints, unlike creating a real class with the normal accessors. We can pretend it's a hash, or a class with methods. It's a dessert topping, no it's a floor-wax, no, it's two things in one!

2 Comments

As I mentioned in the example, one of the desired properties is that ArgumentError or a similar exception is raised if you don't specify everything. Because an OpenStruct can be modified at any time it's not a good choice here. Additionally, OpenStruct is much slower than Struct, so if you're making a lot of them, it's expensive.
I've always considered OpenStruct as the class for people who weren't sure what they wanted. Your case needs more rigorous checking once the instance is created, which is definitely where OpenStruct fails and which I think is a down vote for OpenStruct. Perhaps your code would be a good addition to Ruby, and OpenStruct could be deprecated because of its slowness.
0

I was also hunting around for this, and eventually stumbled across this gem that does exactly this:

https://github.com/etiennebarrie/kwattr

class FooBar
  kwattr :foo, bar: 21
end

foobar = FooBar.new(foo: 42) # => #<FooBar @foo=42, @bar=21>
foobar.foo # => 42
foobar.bar # => 21

instead of

class FooBar
  attr_reader :foo, :bar

  def initialize(foo:, bar: 21)
    @foo = foo
    @bar = bar
  end
end

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.