Tuesday, November 25, 2008

Traits in Ruby

(This post is copied from my old blog, which has disappeared from the internets by and large. So it contains thoughts that I'm not currently thinking, and which have largely been superseded in these modern times. This was published Sat Feb 14, 2004.)

In a post to ruby-talk, Daniel Berger asked about any discussion of traits. Matz weighed in with the opinion that " "module" in Ruby and "trait" are very similar idea. " (Matz, ruby-talk 92858). Not content to let that rest, I looked up the original paper, and found some food for thought. So I've worked up something of a Ruby implementation of Traits, as presented by the paper.

Briefly, traits are proposed by the authors of the paper as a primitive unit of code reuse. Classes should be constructed by composing traits, and viewed normally as a receiver of messages, that is, relating normally to other classes. According to the paper, traits have the following properties:

  • A trait provides a set of methods that implement behaviour.
  • A trait requires a set of methods that parameterize the provided behaviour.
  • Traits do not specify any state variables, and the methods provided by traits never directly access state variables.
  • Traits can be composed: trait composition is symmetric and conflicting methods are excluded from the composition.
  • Traits can be nested, but the nesting has no semantics for classes - nested traits are equivalent to flattened traits.

From Traits: Composable Units of Behavior, Nathanael Scharli, Stephane Ducasse, Oscar Nierstrasz and Andrew Black

Superficially, it appears that modules do have these properties. But the paper does address the problems of mixin inheritence. These are total ordering: where mixins are added linearly to a class, one at a time, with later mixins overriding all named features of earlier mixins; a lack of control from the composite entity about the composition of mixins, occasionally requiring tricky code to obtain the final topology of the composed class; and fragile heirarchies: the proposition that a change to any module may cause silent changes and overrides to the composed class.

The example implementation of traits is done in Squeak. Over the course of my recent work, I've become almost conversant in the language, so I'll attempt to translate their example into Ruby. The example given is the ol' shape drawing trick. I hope nobody mentions anything about rectangles, squares, chickens or eggs in this thing. The TDrawing trait, in Ruby, becomes:

module TDrawing
# requires #bounds, #draw_on

def draw; draw_on( World.canvas ); end
def refresh; refresh_on( World.canvas ); end

def refresh_on( a_canvas )
a_canvas.form.defer_updates_in( bounds ) do
draw_on( a_canvas )
end
end
end

Note that the requirements for the mixin are expressed only as comments. This is similar to the Enumerable mixin problem - it requires a hook into #each to get it going, but this is only expressed in docs, or at runtime when you call an Enumerable method on an Enumerable descended object without #each. There are other rules to traits, such as glue methods, that need to be respected, too. Is it possible to trivially extend Ruby to handle a trait style composition? I'll start with the trivial requirement: requirements.

module Trait
class RequiredMethodMissing < RuntimeError; end

def append_features( mod )
@ids.each do | id |
unless mod.instance_methods( true ).include?( id.to_s )
raise Trait::RequiredMethodMissing, id.to_s
end
end
super mod
end

def required_methods( *ids )
@ids = ids
end
end

# example usage:

module T1
extend Trait
required_methods :blah
end

class A
def blah; end
include T1
end

Normally, I'd probably consider testing to be sufficient, to pick up the lack of hook method, rather than this quasi-static typing hack. But I'm just trying to stay faithful to the paper here, and it does have some "hard-documentation" style elegance. I'm going to tiptoe around the static typing thing, and move on to the next requirements: Class methods take precedence over Trait methods, and Trait methods take precedence over superclass methods. Time to write up some unit tests:

module Trait
class RequiredMethodMissing < RuntimeError; end

def append_features( mod )
@ids.each do | id |
unless mod.instance_methods( true ).include?( id.to_s )
raise Trait::RequiredMethodMissing, id.to_s
end
end
super mod
end

def extend_object( obj ) #callback on obj.extend( A_Trait )
append_features( obj.class )
end

def required_methods( *ids )
@ids = ids
end
end

module T1
extend Trait
required_methods :to_s, :blah
def bb; "T1#bb"; end
end

class A
def to_s; end
def blah; end
def bb; "A#bb"; end
end

class B < A
end

class C; end

require 'test/unit'
class TC_Trait < Test::Unit::TestCase
def test_requirements_met
a = A.new
assert_nothing_raised { a.extend( T1 ) }
end

def test_requirements_not_met
assert_raises( Trait::RequiredMethodMissing ) { C.new.extend( T1 ) }
end

def test_existing_method_not_overridden
a = A.new
a.extend T1
assert_equal "A#bb", a.bb
end

def test_super_method_overridden
b = B.new
b.extend T1
assert_equal "T1#bb", b.bb
end
end

They all pass, except for test_super_method_overridden. After a whole bunch of ugly hacking, I've come up with an ugly hack:

# in Trait
def append_features( mod )
@ids.each do | id |
unless mod.instance_methods( true ).include?( id.to_s )
raise Trait::RequiredMethodMissing, id.to_s
end
end
super_methods = mod.instance_methods( true ) - mod.instance_methods( false )
self.instance_methods( false ).each do | meth |
if super_methods.include?( meth )
alt_name = "_trait_#{self.class}_#{meth}"
send( :alias_method, alt_name, meth )
mod.module_eval <<-END_EVAL
def #{meth}( *args, &block )
#{alt_name}( *args, &block )
end
END_EVAL
end
end
super
end

The code needs some tidying, obviously, but it passes the tests. Moving on again. The paper describes Nested Traits, allowing Traits to be composed of other Traits. Nested traits are nasty, because if you include a Trait into another Trait, you need to avoid checking for requirements. Its only when a Trait is composed into a class should the sum of all requirements be added. I can test this with the following additions to the code:

module T2
extend Trait
required_methods :t2hook
include T1
def cc; "T2#cc"; end
end

The code, in its current state, will throw a RequiredMethodMissing error immediately on running, with this line here. The following code makes it all run ok:

# in module Trait
def append_features( mod )
@ids.each do | id |
unless mod.instance_methods( true ).include?( id.to_s )
raise Trait::RequiredMethodMissing, id.to_s
end
end if mod.is_a? Class
)

#... etc

Passes the tests, although under the glaringly poor assumption that all Modules are Traits. I should really test for that foreseeable problem, but I'm going to ignore it for now. Next up: combining required_methods, and thinking of a better name for them. Add the following tests:

class D; def t2hook; end; end

#add to TC_Trait
def test_composed_trait_requirements_not_met
assert_raises( Trait::RequiredMethodMissing ) { D.new.extend( T2 ) }
end

def test_composed_trait_requirements_met
a = A.new
a.class.send( :define_method, :t2hook, proc{} )
assert_nothing_raised{ a.extend( T2 ) }
end

More hackery pokery makes this one pass. I need to get the existing Trait's required_methods, and add them to the composed Trait's, when the Traits are composed:

# in module Trait
def append_features( mod )
unless mod.is_a? Class
list_of_methods = ( [ mod.get_required_methods ] << @required_methods ).flatten
mod.required_methods( *list_of_methods.compact )
else
@required_methods.each do | id |
unless mod.instance_methods( true ).include?( id.to_s )
raise Trait::RequiredMethodMissing, id.to_s
end
end
end
# ... etc

The next hurdle is Conflict Resolution. By default, mixins in Ruby overwrite existing mixed in methods in the order of their inclusion. Traits are specified to allow aliases and method removal on inclusion: the Squeak code looks like:

"dynamic aliasing:"
traits: { TCircle @ {#circleHash -> #hash. #circleEqual: -> #=} . TDrawing .
TColor @ {#colorHash -> #hash. #colorEqual: -> #=} }
"dynamic removal:"
traits: { TCirle . TDrawing - {#=. #hash. #=} . TColor }

Even though this is a major part of the whole Traits business, I'm going to completely ignore its implementation right now, because I've done enough to form a few opinions on the subject, and I'm getting tired. I'd probably have to alias and override Object#extend and Module#include, or (better) write Object#extend_trait and Module#include_trait, to implement Conflict Resolution. I'll leave this as an exercise for the reader.

This hacked up implementation of Traits is a proof-of-concept that Traits can fit reasonably easily into Ruby, using straight Ruby without extensions. Without implementing anything, its possible to code in a Mixin-Oriented style in Ruby - composing classes from small, well-defined modules, paying attention to inclusion order and the names of methods across all of the mixins one includes. Developing Traits would make this informal, possibly difficult to scale process quite a bit easier - resolving clashes and ensuring, when a class is parsed, that it meets requirements for using a Mixin (failing early).

Despite that, I'm still not sold on the Traits concept. I'm not sure that sprinkling methods over a distributed set of Mixins would be fun to maintain, on a large scale. The Squeak implementation took advantage of the browser: you only ever see one method at a time in Squeak (smalltalk) code, anyway, and if the browser integrates with Traits, you could view and edit the methods as "part" of the class anyway. With Ruby, without a great IDE, you'd need to flick across files and use a bit of imagination to get a good "view" of your composed class - to even see what methods it's composed of. I believe that traits lose a good deal of their appeal without the Smalltalk browser, or a great Trait-aware IDE.

The final version of the Trait module, along with its tests, is presented below, if anyone cares to take it further. As a postscript, there's a bit of module magic code, far more mature than this, out there now: import-module, by Shin-ichiro HARA and aliasing_module by Nobu (which seems to handle the implementation of aliasing I neglected above).

module Trait
class RequiredMethodMissing < RuntimeError; end

def append_features( mod )
unless mod.is_a? Class #not the best check, probably...
list_of_methods = ( [ mod.get_required_methods ] << @required_methods ).flatten
mod.required_methods( *list_of_methods.compact )
else
@required_methods.each do | id |
unless mod.instance_methods( true ).include?( id.to_s )
raise Trait::RequiredMethodMissing, id.to_s
end
end
end
super_methods = mod.instance_methods( true ) - mod.instance_methods( false )
self.instance_methods( false ).each do | meth |
if super_methods.include?( meth )
alt_name = "_trait_#{self.class}_#{meth}"
send( :alias_method, alt_name, meth )
mod.module_eval <<-END_EVAL
def #{meth}( *args, &block )
#{alt_name}( *args, &block )
end
END_EVAL
end
end
super
end

def extend_object( obj )
append_features( obj.class )
end

def required_methods( *ids )
@required_methods = ids
end

def get_required_methods; @required_methods; end
end

#tests
if $0 == __FILE__

module T1
extend Trait
required_methods :to_s, :blah
def bb; "T1#bb"; end
end

module T2
extend Trait
required_methods :t2hook
include T1
def cc; "T2#cc"; end
end

class A
def to_s; end
def blah; end
def bb; "A#bb"; end
end

class B < A
end

class C; end

class D; def t2hook; end; end;

require 'test/unit'
class TC_Trait < Test::Unit::TestCase
def test_requirements_met
a = A.new
assert_nothing_raised { a.extend( T1 ) }
end

def test_requirements_not_met
assert_raises( Trait::RequiredMethodMissing ) { C.new.extend( T1 ) }
end

def test_existing_method_not_overridden
a = A.new
a.extend T1
assert_equal "A#bb", a.bb
end

def test_super_method_overridden
b = B.new
b.extend T1
assert_equal "T1#bb", b.bb
end

def test_composed_trait_requirements_not_met
assert_raises( Trait::RequiredMethodMissing ) { D.new.extend( T2 ) }
end

def test_composed_trait_requirements_met
a = A.new
a.class.send( :define_method, :t2hook, proc{} )
assert_nothing_raised{ a.extend( T2 ) }
end
end

end

No comments: