Skip to content

Instantly share code, notes, and snippets.

@JoshCheek
Last active May 2, 2021 19:44
Show Gist options
  • Save JoshCheek/2641441 to your computer and use it in GitHub Desktop.
Save JoshCheek/2641441 to your computer and use it in GitHub Desktop.
Challenge to create your own struct

This is a response for one of our apprentices who wanted to learn more about how structs work.

TL;DR

Part of this is a response and part of it a challenge. If you are only interested in the challenge clone the repo and run rake (more detailed instructions here).

Introduction

I use Struct in a couple of ways. Doing a quick grep of directories that have made it onto this new computer, there are a couple of ways I use it.

As a superclass

Examples: 1, 2, 3

These are from my gem Surrogate, which helps with hand-rolled mocking. Since Struct.new returns a class, I can inherit from it. Whatever names I pass to Struct.new will become methods that my instances can access. It also defines an initializer for me if I want to use it. Note that it is important to use the setters and getters when doing this, rather than instance variables. This is a point I explicitly (and, admittedly, rantilly) make one of my other gems, Deject fourth paragraph. Also notice that I've done this several times in Deject's readme examples.

I usually use this approach to quickly scaffold out a simple class. Usually so simple that it's more of a data structure than an object. Meaning its purpose is to hold values rather than encapsulate behaviour -- in general, objects should not have setters, because it means you are taking their values and doing things with them or to them, but objects should be declarative interfaces that you interact with, not holders of values that you set and get. When these mix, you wind up with a code smell called "feature envy". For more on this, there's a decent blog called "tell, don't ask".

As a simple class

Similar to the above, but here the struct becomes the class itself (no need to inherit).

Examples: 1, 2

In these two, it is just a quick way to get a class with methods I can access. Notice I set them into constants so that they feel very similar to "normal" classes.

As a class with behaviour

A bit less common (as soon as these become decently complex, I move them into "real" classes, they usually serve just to prototype out the idea). But you can pass a block to Struct.new, and define any methods you want inside of there. The block gets class evaled, so methods you define in there will be available on instances of the struct. For simple object/data structures, this can be convenient, but these usually do still wind up turning into real classes pretty quickly.

Example: 1

Challenge

I often feel that the best way to learn about something is to try and implement it (or a scaled down version of it) yourself. If you'd like to try that, I included a spec for you which tests quite a bit of Struct's behaviour. You can implement your own in order to learn about the one provided by Ruby.

To try it out:

$ git clone git://gist.github.com/2641441.git
$ cd 2641441
$ rake

Then edit my_struct.rb and run rake until there are no more failures. I've set it up to stop testing after the first failure, so you can hopefully get a nice tdd style flow going.

Unfortunately most of the difficult things are right at the beginning, then it's smooth sailing after that. So don't give up, if you can get past the first several, you'll be in a good place to tackle the rest of them. If you decide to do it, you'll have to learn some "metaprogramming". I went through it myself to see what kinds of things I needed to do, so here are some pointers and tools to help you along the way.

Pointers and tools

SomeClass.new is just a method, you can define it yourself if you want it to behave differently.

Classes are instances of Class, you can get one by typing Class.new. In general class MyClass; end is the same as MyClass = Class.new(Object)

When you instantiate Class, you can pass a block that will be class_evaled. The examples all show strings being passed in, but don't do that, use the block form like this:

klass = Class.new { def hello() "world" end }
klass.new.hello # => "world"

Within a class context, you can say define_method(:name) { 'Josh' } and it will define for you an instance method called name, which will return the string 'Josh' when invoked.

Because these take blocks, they have access to variables defined in their enclosing environment:

target = "world" # note that this var must be defined before the block

greet_class = Class.new do
  define_method :hello do
    target
  end
end

greeter = greet_class.new
greeter.hello # => "world"

target = "universe"
greeter.hello # => "universe"

The method Hash.[] will turn arrays of associated objects into key/value pairs in a hash.

key_value_pairs = [[:name1, :value1], [:name2, :value2]]
Hash[key_value_pairs] # => {:name1=>:value1, :name2=>:value2}

You can get the block out of a method list with the ampersand def meth(arg, &block)

You can put an arg into the block slot of a method with the ampersand

largest_first = lambda { |a, b| b <=> a }
[2,3,7,3,5,1,6,0].sort &largest_first # => [7, 6, 5, 3, 3, 2, 1, 0]

In Closing

I hope you have fun with this challenge, if you finish it, I'll send you my solution. Feel free to ask me any questions you have if you get stuck. Or, if you're pairing with Michael, you can ask him as well.

"Metaprogramming" (which is really just programming -- and the conventional way of thinking about programming in Ruby, with class and def and so forth is the real metaprogramming, that shit is crazy when you think about what it's actually doing) is a lot of fun, but don't let it get away from you :) It can often be difficult for people to reason about, so have mercy on your team and use it with discretion. In general, I rarely use it outside of gems, and only for very straightforward uses within my apps.

MyStruct = Struct
# delete the above and create your own:
# class MyStruct
# ...
# end
require_relative 'my_struct'
module ShouldaCouldaWouldaDID
refine BasicObject do
def should(matcher=nil, message=nil, &block)
::RSpec::Expectations::PositiveExpectationHandler.handle_matcher(self, matcher, message, &block)
end
end
end
RSpec.describe MyStruct, '.new' do
using ShouldaCouldaWouldaDID
it 'returns an anonymous class' do
described_class.new(:abc).should be_a_kind_of Class
described_class.new(:abc).name.should be_nil
end
it 'raises an ArgumentError if not given at least one argument' do
expect { described_class.new }.to raise_error ArgumentError, /wrong number of arguments/
end
it 'raises a TypeError for arguments which are not symbols' do
expect { described_class.new 123 }.to raise_error TypeError
end
specify 'the arguments define methods for instances of the returned class' do
instance = described_class.new(:foo, :bar).new
instance.should respond_to :foo
instance.should respond_to :bar
end
it 'takes a block which is class_evaled' do
klass = described_class.new(:abc) do
@some_ivar = :whatever
def instance_meth() 'instance value' end
def self.class_meth() 'class value' end
end
klass.instance_variable_get(:@some_ivar).should == :whatever
klass.new.instance_meth.should == 'instance value'
klass.class_meth.should == 'class value'
end
describe '.members' do
it 'returns the arguments' do
described_class.new(:foo, :bar) { members.should == [:foo, :bar] }
end
it 'is not affected by mutation' do
described_class.new(:foo, :bar) do
(members << :lol).should == [:foo, :bar, :lol]
members.should == [:foo, :bar]
end
end
end
describe 'the returned class' do
describe 'methods defined by the struct' do
specify 'they define a getter which returns the value they are initialized with' do
instance = described_class.new(:foo, :bar).new 1, 'two'
instance.foo.should == 1
instance.bar.should == 'two'
end
specify 'they return nil if they were not initialized' do
instance = described_class.new(:foo, :bar).new 1
instance.foo.should == 1
instance.bar.should == nil
end
specify 'they define a setter which can override the value' do
instance = described_class.new(:baz, :quux).new 1, 2
instance.baz = 3
instance.quux = 4
instance.baz.should == 3
instance.quux.should == 4
end
specify 'they already exist when the block to .new is called' do
described_class.new(:abc) do
new(123).abc.should == 123
end
end
end
describe '#[]' do
it 'accepts string/symbol keys that match the specified attributes' do
instance = described_class.new(:foo).new
instance[:foo]
instance['foo']
end
it 'raises a NameError for keys that do not match the specified attributes' do
instance = described_class.new(:foo).new
expect { instance[:bar] }.to raise_error NameError, "no member 'bar' in struct"
end
it 'returns the value set into the attributes' do
instance = described_class.new(:foo).new 1
instance[:foo].should == 1
instance['foo'].should == 1
instance.foo = 2
instance[:foo].should == 2
end
it 'is equivalent to the getter attributes' do
instance = described_class.new(:foo).new 1
instance.foo = 2
instance.foo.should == 2
instance[:foo].should == 2
end
it 'returns elements by index when given an integer' do
instance = described_class.new(:foo, :bar, :baz).new 11, 22, 33
instance[0].should == 11
instance[1].should == 22
instance[2].should == 33
end
it 'raises an IndexError when given an integer too large' do
instance = described_class.new(:foo, :bar, :baz).new 11, 22, 33
expect { instance[3] }.to raise_error IndexError, "offset 3 too large for struct(size:3)"
end
it 'converts unknown keys to integers using `to_int`, if possible' do
instance = described_class.new(:foo, :bar, :baz).new 11, 22, 33
key = Object.new
def key.to_int() 2 end
instance[key].should == 33
end
it 'raises a TypeError if it can\'t convert them to a symbol or integer' do
instance = described_class.new(:foo, :bar, :baz).new 11, 22, 33
expect { instance[//] }.to raise_error TypeError, "no implicit conversion of Regexp into Integer"
end
end
describe '#[]=' do
it 'accepts symbol/string keys that match the specified attributes' do
instance = described_class.new(:foo).new 1
instance[:foo] = 2
end
it 'sets the value that will be returned by the getter and by #[]' do
instance = described_class.new(:foo).new 1
instance[:foo] = 2
instance.foo.should == 2
instance['foo'] = 3
instance.foo.should == 3
end
it 'raises a NameError for keys that do not match the specified attributes' do
expect { described_class.new(:foo).new[:bar] = 1 }.to raise_error NameError, "no member 'bar' in struct"
expect { described_class.new(:foo).new['bar'] = 1 }.to raise_error NameError, "no member 'bar' in struct"
end
it 'raises an IndexError when given an integer too large' do
instance = described_class.new(:foo, :bar, :baz).new 11, 22, 33
expect { instance[3] = 44 }.to raise_error IndexError, "offset 3 too large for struct(size:3)"
end
it 'converts unknown keys to integers using `to_int`, if possible' do
instance = described_class.new(:foo, :bar, :baz).new 11, 22, 33
key = Object.new
def key.to_int() 2 end
instance[key] = 44
instance[key].should == 44
end
it 'raises a TypeError if it can\'t convert them to a symbol or integer' do
instance = described_class.new(:foo, :bar, :baz).new 11, 22, 33
expect { instance[//] = 55 }.to raise_error TypeError, "no implicit conversion of Regexp into Integer"
end
end
describe '#inspect' do
it 'identifies its type, keys, and inspected values' do
obj = Object.new
def obj.inspect() 'inspected :)' end
instance = described_class.new(:foo, :bar, :baz).new :abc, obj
instance.inspect.should == "#<struct foo=:abc, bar=inspected :), baz=nil>"
end
end
describe '#members' do
it 'returns an array of symbols representing the names of its struct attributes' do
described_class.new(:foo, :bar).new.members.should == [:foo, :bar]
end
end
describe '#select' do
it 'invokes the block passing in successive elements from struct, returning an array containing those elements for which the block returns a true value (equivalent to Enumerable#select)' do
lots = described_class.new(:a, :b, :c, :d, :e, :f)
l = lots.new(11, 22, 33, 44, 55, 66)
l.select { |v| (v % 2).zero? }.should == [22, 44, 66]
end
end
describe '#size' do
it 'returns the number of its struct attributes' do
described_class.new(:a,:b,:c).new.size.should == 3
end
end
describe '#values' do
it 'returns the values of its attributes, in the order they were defined' do
described_class.new(:foo, :bar).new('foo value', 'bar value').values.should == ['foo value', 'bar value']
end
end
describe 'enumerability' do
it 'defines each, which yields each of its attributes, in the order they were defined' do
names = []
described_class.new(:foo, :bar).new(:baz, 123).each { |name| names << name }
names.should == [:baz, 123]
end
it 'returns an enumerator if not given a block' do
described_class.new(:foo, :bar).new.each.should be_a_kind_of Enumerator
described_class.new(:foo, :bar).new(/a/, /b/).each.to_a.should == [/a/, /b/]
end
it 'mixes in Enumerable, giving it access to all the enumerable methods' do
described_class.new(:foo, :bar).new("abc", "def").each_with_index.to_a.should == [["abc", 0], ["def", 1]]
described_class.new(:a, :b, :c, :d, :e, :f).new(:ab, :ac, :bc, :ad, :cd, 'ae').grep(/a/).should == [:ab, :ac, :ad, 'ae']
end
end
end
end
require 'rspec/core/rake_task'
RSpec::Core::RakeTask.new :rspec do |t|
t.pattern = 'my_struct_spec.rb'
t.rspec_opts = ['--fail-fast', '--format', 'documentation']
end
task default: :rspec
@koriroys
Copy link

want to integrate this update for the #inspect test that you sent me?

describe '#inspect' do
  it 'identifies its type, keys, and inspected values' do
    obj = Object.new
    def obj.inspect() 'inspected :)' end 
    instance = described_class.new(:foo, :bar, :baz).new :abc, obj 
    instance.inspect.should == "#<struct foo=:abc, bar=inspected :), baz=nil>"                                                                                                                      
  end 
end 

@JoshCheek
Copy link
Author

Thanks, Kori. Added it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment