Tuesday, January 1, 2013

Rails, Ruby, And Type-Checking

Here's a pair of tweets:

I guess I was pontificating a little, but I want to go into more detail.

Rails does something brilliant with its association classes like belongs_to and has_one; it gives composition the same kind of emphasis and importance that inheritance already enjoys in every class-based object-oriented language (e.g., everything from C++ to Python).

Rails lets you declare composition right at the top of the file, right after you declare inheritance:

class Sub < Super
  has_one :association

This is part of ActiveRecord, but it's not intrinsic to databases. It's intrinsic to objects themselves. Imagine Rails never used a database. Would belongs_to and has_one still be useful? Of course they would. I really believe that languages should treat this as a core feature. If composition matters more than inheritance, but language features put a spotlight on inheritance while downplaying composition, then you're not dealing with an ideal design. There's a mismatch.

There's more to belongs_to than databases. And you can see it if you ask a simple question: if Rails never used a database, what difference would there be between belongs_to and attr_accessor? Say you wanted to copy this part of Rails. It would not be enough to do this:

alias :belongs_to :attr_accessor

And the reason it would not is because Rails association class methods use a type-checking system. Here's something you can do with attr_accessor:

Foo.attr_accessor :round_thing
@foo = Foo.new
@foo.round_thing = @square_thing

Wow! Look at you and your reckless shenanigans. You just put a square peg in a round hole! That's right - you didn't just talk about it. You did it. And you got away with it, too, because there's absolutely nothing in Ruby to stop you. You are mad, bad, and dangerous to know.

But if your database is full of RoundThing and SquareThing ActiveRecord models, then there might be something in Rails which stops you. Because in Rails, you can't do this:

Foo.validates_roundness_of :round_thing
@foo = Foo.new
@foo.round_thing = SquareThing.new

In fact, you can't even do this:

Foo.has_one :round_thing
@foo = Foo.new
@foo.round_thing = SquareThing.new

Of course, it's easy to implement basic opt-in type-checking in Ruby:

class Foo
  attr_reader :square_thing
  def square_thing=(thing_with_right_angles)
    raise "hell" unless thing_with_right_angles.is_a? SquareThing

But it's easier to read the Rails equivalent:

class Foo
  has_one :square_thing

And it makes a lot of sense to abstract that out into a pattern. That shortcut should, in my opinion, become a common idiom in future object-oriented languages.

Although the type-checking with association classes is very cut and dry, with its validations, Rails provides an extremely customizable and mostly optional type-checking system. This is one of the weirdest parts of Rails: a type-checking system for attributes that lives on the class which has the attributes, rather than on the attributes themselves. But it works really well. A lot of people have strong opinions about type-checking, calling it a terrible idea or an absolute necessity. Rails makes it easy for you to say "I'm going to use type-checking here, but I'm not going to use type-checking there."

Yet this also leads to inconsistent implementation. For example, you can easily bypass validations with update_attribute, which is less a matter of the type-checking system being optional, and more a matter of it just not being there sometimes, for unguessable reasons of its own.

When I lived in New Mexico, I once house-sat for a guy who had a "pet" which was half-dog and half-coyote. This "pet" was not really domesticated. It was less of a pet and more of a canine homie. You could really say "what up, dog?" and mean it in this situation. The animal was friendly, independent, and half-wild. It could effortlessly jump over a 7-foot fence, and it sometimes liked to wander into the woods for a week. That's kind of like the "type-checking system" in Rails. It disappears and reappears on its own schedule.

Coyote (Canis latrans)

Consider Gary Bernhardt's gem do_not_want:

>> User.new.update_attribute(:foo, 5)
DoNotWant::NotSafe: User#update_attribute isn't safe because it skips validation

From the readme:

In my experience, even experienced Rails developers don't know which ActiveRecord methods skip validations and callbacks. Quick: which of decrement, decrement!, and decrement_counter skip which? (Hint: they're all different.)

do_not_want prevents you from using methods which bypass validations and callbacks, forcing you to use a subset of Rails whose behavior is relatively consistent and predictable. I would not ever want to use do_not_want personally; in fact, I used to have a bad habit of always using update_attribute deliberately to avoid validations, but if memory serves, I picked up that bad habit from some very good programmers. It's tedious to use type-checking when you're a fan of dynamic languages.

Nonetheless, I might inflict do_not_want on my subordinates, if I ruled some Dilberty cubicle farm with an iron grip, and the power was starting to go to my head. It's the type of authoritarian imposition which might save you a lot of aggravation later on.

So, because of that tension, and the Perl-like inconsistency of the implementation, I can't call the sometimes-optional type-checking which Rails bolts onto Ruby (except when it doesn't) an ideal design. But I think it's better than many alternatives. Java, and similar languages, feature mandatory type-checking of a painfully severe and tiresome nature.

main static void final class LoquaciousBoilerplate extends Whatthefuckever {
  public final overspecified Whatnot whatnot;

Technically, this does enforce composition at a very specific level, but it's much too specific. Building a system like Rails in a language like that would have been close to impossible. This Java/C/C++/ALGOL legacy of mandatory type-checking, for every single variable, just plain sucks.

The only mandatory elements of Rails's type-checking are the classes you can assign to association methods, and the "files must define classes with matching names" constraint which the infamous "expected foo.rb to define Foo" error represents. You see it less these days, thanks to Bundler, but any experienced Rails developer has seen that error countless times, even though very few have ever seen it happen because foo.rb actually failed to define Foo. That almost never happens.

Instead, the error-throwing code is lodged inside a module method for loading missing constants. So you see it whenever a constant is missing; it usually means you forgot to require some utterly unrelated file. The circumstances which throw the error have very little to do with the error message. I've only ever worked with one other language which had this constraint; it was Java, and it never threw an error like this.

But it's easy to explain this error's frequency, and its well-deserved universal reputation for total aggravating worthlessness. You're bound to end up with fragile, buggy code if you're building type-checking into the process of file-loading without modifying File or any I/O-related classes. It's almost like an ad hoc, informally-specified, bug-ridden implementation of half of Java.

Moving the type-checking / file-loading code out of Rails and re-implementing it at the language level (in Rubinius, for instance) would make for a much cleaner design, but wouldn't be practical. By building an incomplete but distinct language on top of Ruby, Rails makes it easy to use any arbitrary Ruby gem, instead of only being able to use gems built specifically for use with Rails.

The Dart language says it has an optional type-checking system:

Map<String, dynamic> m = {
  'one': new Partridge(),
  'two': new TurtleDove(),
  'twelve': new Drummer()};

This Map function can then return a Drummer for 'twelve' or a Partridge for 'one'. It's probably more accurate to call this a statically-typed language with a special command which allows you to go into dynamically-typed mode. It's opt-out where Rails validations are opt-in.

It's my hope that the inconsistent type-checking in Rails becomes consistent, and that the do_not_want gem becomes totally obsolete. I also think that future language designers should consider implementing non-database versions of belongs_to and similar methods as OOP fundamentals.



You can have some of this in Perl if you use Moose.