Skip to content

Instantly share code, notes, and snippets.

@koriroys
Forked from JoshCheek/README.md
Created May 12, 2012 18:04
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save koriroys/2667949 to your computer and use it in GitHub Desktop.
Save koriroys/2667949 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 lib/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.

Poointers 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.

class MyStruct
def self.new(*method_names, &block)
raise ArgumentError, "wrong number of arguments (0 for 1+)" if method_names.empty?
method_names.each do |m|
raise TypeError unless m.kind_of? Symbol
end
Class.new do
include Enumerable
define_method :initialize do |*args|
@args = Hash[method_names.zip args]
end
define_method "[]".to_sym do |x|
raise NameError, "no member '#{x}' in struct" unless @args.has_key? x.to_sym
@args[x.to_sym]
end
define_method "[]=".to_sym do |x, val|
raise NameError, "no member '#{x}' in struct" unless @args.has_key? x.to_sym
@args[x.to_sym] = val
end
define_method :inspect do
a = @args.map {|k,v| "#{k}=" + v.inspect}.join(", ")
"#<struct #{a}>"
end
define_method :members do
method_names
end
define_method :size do
@args.size
end
define_method :values do
@args.values
end
def each(&block)
@args.values.each &block
end
method_names.each_with_index do |m, i|
getter = m.to_sym
setter = "#{m}=".to_sym
define_method getter do
@args[getter]
end
define_method setter do |val|
@args[getter] = val
end
end
class_eval &block if block
end
end
end
require 'my_struct'
describe MyStruct, '.new' do
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 (0 for 1+)"
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 '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
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
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 '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"
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
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.rspec_opts = ['--fail-fast', '--format', 'documentation']
end
task default: :rspec
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment