Monday, September 08, 2008

Domain Specific Languages don't follow the Principle of Least Surprise

Ola Bini gets it right, as usual, in Evil Hook Methods?, but I think you can actually take the idea a bit further.

DataMapper allows you to gain it's methods simply by an include statement.

class Category
include DataMapper::Resource
# ...
end

Ola points out that the include method should not add class methods. At least, that's not what it was designed to do. Include should (by way of append_features) add the constants, methods, and module variables of this module to the class or module that called include.

The problem for me is: Should DataMapper add it's methods to your class when you use Ruby methods as they were originally intended, or when you use DataMapper's Domain Specific Language (DSL).

If DataMapper is a framework and should be used traditionally then you should add it's methods in the following way.

class Category
include DataMapper::Resource
extend DataMapper::Resource::ClassMethods
# ...
end

However, you can't blame DataMapper for following the pattern that's been around since long before DataMapper. At this point I would consider the trick to definitely be an idiom even if it is an anti-pattern. The reality is that include has been stolen by those that prefer the simplest possible Domain Specific Language for adding their behavior.

Martin Fowler describes how a framework can have a traditional API as well as a thin veneer that allows you to use the framework in a more fluent way.

Unfortunately, in the Ruby world we've designed our veneer in a way that doesn't allow for traditional usage.

The other day I noticed something that I thought was equally interesting in the Java world. I was working on a test that used JMock and IntelliJ formatted my code as shown below.

    1 class PublisherTest extends TestCase {
2 Mockery mockery = new Mockery();
3
4 public void testNamesAreAnnoying() {
5 final Subscriber subscriber = context.mock(Subscriber.class);
6
7 mockery.checking(new Expectations() {
8 {
9 one (subscriber).receive(message);
10 }
11 });
12
13 // ...
14 }
15 }

Unimpressed by lines 8 and 10, I changed the code to look like the following snippet.

class PublisherTest extends TestCase {
Mockery mockery = new Mockery();

public void testNamesAreAnnoying() {
final Subscriber subscriber = context.mock(Subscriber.class);

mockery.checking(new Expectations() {{
one (subscriber).receive(message);
}});

// ...
}
}

Mike Ward said I shouldn't do that because the IntelliJ formatting properly shows an initializer for an anonymous class. Which is absolutely correct, but I don't want an anonymous class with an initializer, I want to use JMock's DSL for defining expectations. And, while the second version might not highlight how those expectations are set, that's not what I care about.

When I write the code I want to create expectations in the easiest way possible, and when I read the code I want the fact that they are expectations to be obvious. I don't think removing lines 8 and 10 reduces readability, in fact it may improve it. Truthfully, I don't care what tricks JMock uses to define it's DSL (okay, within reason), I only care that the result is the most readable option possible.

Back to DataMapper, I believe there's a superior option that allows them to have both a clean DSL and a traditional API. The following code would allow you to add methods as Ola desires (traditionally) and it would allow you to get everything with one method invocation for those that prefer DSL syntax.

class Object
def data_mapper_resource
include DataMapper::Resource
extend DataMapper::Resource::ClassMethods
end
end

class Category
include DataMapper::Resource
extend DataMapper::Resource::ClassMethods
# ...
end

class Entry
data_mapper_resource
# ...
end

The obvious drawback is if everyone starts adding methods to Object we may start to see method collision madness. Of course, if the method names are given decent names it shouldn't be an issue. It's not likely that someone else is going to want to define a method named data_mapper_resource.

Don't worry. For those of you who prefer complexity "just in case", I have a solution for you also.

module DataMapper; end
module DataMapper::Resource
def self.instance_behaviors
DataMapper::Resource::InstanceMethods
end

def self.class_behaviors
DataMapper::Resource::ClassMethods
end
end
module DataMapper::Resource::InstanceMethods
def instance_method
"instance method"
end
end
module DataMapper::Resource::ClassMethods
def class_method
"class method"
end
end

class Object
def become(mod)
include mod.instance_behaviors
extend mod.class_behaviors
end
end

class Category
include DataMapper::Resource::InstanceMethods
extend DataMapper::Resource::ClassMethods
# ...
end

class Entry
become DataMapper::Resource
end

Entry.class_method # => "class method"
Entry.new.instance_method # => "instance method"
Category.class_method # => "class method"
Category.new.instance_method # => "instance method"

11 comments:

  1. I'm afraid the margin of this blog post (or at least, it's utility for writing code snippets) is to narrow for the comment I want to write, so I've blogged about it instead.

    ReplyDelete
  2. A quick comment about your mention of "append_features": Append features is a deprecated precursor to the included method which does not require that you invoke super like append_features did.

    ReplyDelete
  3. I agree with Rick's comment in Ola Bini post.

    "Why not put the include line in the #do_funky_madness_on (alias become) method?"

    module Speakable
    def self.become(klass)
    klass.send :include, InstanceMethods
    klass.extend ClassMethods
    end
    end

    class Person
    Speakable.become(self)
    end

    ReplyDelete
  4. If the issue is clarity of "what is happening here?" perhaps you could alias #include to be #include_and_extend which you would use instead to tell the reader that including this module is going to also extend the class?

    ReplyDelete
  5. @charly: One obvious problem with that is that you're telling Speakable to become a Person, which seems arse about face somehow.

    The obvious method to use there is extend, but that's already taken for a method that would probably be better named extend_with or similar.

    Ho hum.

    ReplyDelete
  6. Why add these methods to Object instead of Class?

    ReplyDelete
  7. @ymendel, yes, that's probably better.

    Cheers, Jay

    ReplyDelete
  8. Linh Nguyen3:11 AM

    Do you expect any module passed to Object#become to implement the instance_behaviour and class_behaviour methods in your solution?

    class Object
    def become(mod)
    include mod.instance_behaviors
    extend mod.class_behaviors
    end
    end

    ReplyDelete
  9. By the way, it's "its" when talking about DataMapper's methods.

    At any rate, nice article! The `become' method is interesting, though I'm still working the internal debate on if I agree with it or not.

    ReplyDelete
  10. I agree in that modifying the expected behavior of "include" is bad practice, but IMO the notion that your "data_mapper_resource" gives the user different choices seems a bit backwards in terms of improving readability... In the case of Datamapper, the library is meant for a specific purpose. The more options you provide to do exactly the same would only add to confusion. If I'm new to DM and see both approaches in different codebases I will be rather confused and expect different behaviours.

    ReplyDelete

Note: Only a member of this blog may post a comment.