Bulletproof Ruby Initialization
I always seem to wind up on the wrong side of the adoption curve in things. Take, for example, my Haskell adventures. I've been sporadically looking at and trying to learn Haskell since about the year 2000. The materials to do so have been few and far between and, as a result, the process has been a painful one full of frustration, abandonment, restarting in a never-ending cycle that, with each increment, only fitfully increases my knowledge of the language. So, of course, now that I'm semi-competent (and less-than-semi-sane) in the language, people have to make a book on the topic! You know, exactly the kind of book I needed seven years ago to make heads or tails of the language and its tools. Similar things happened to me with several languages, including my favourite parser generator, ANTLR.
Why do I bring this up? Well, because, for the first time in a long time I'm a Johnny-come-lately into a programming scene. Ruby slipped me by completely as it grew, so now that my attention has turned its way, there's actually a lot of good materials out there for learning it. The cream of a very creamy crop indeed is Hal Fulton's book The Ruby Way (2nd Edition). My only objection to this book is that I didn't have it right from the start!
The reason I like this book so much is that practically every page gives me something to think about in how to code Ruby. And on page 391 I found a real doozy – something that's going to change the shape of all my future Ruby coding.
The technique involved is quite simple: all it involves is writing initialize methods with passed-in blocks doing initialization. The author, in his typical style in this book, addressed the issue briefly, gave some sample code, and then departed the topic to discuss other matters while leaving me reeling at the possibilities. A few hours of intense experimentation later has given me a tool I will happily use in all of my Ruby coding from now on because it's ... well, it's just so damned cool!
You see, I have certain very strong opinions on the topic of object-oriented programming. Opinions on proper style that are broken in almost every piece of Java, C++, Python, Ruby – you name it, I've seen it broken! – code that exists. One of the biggies here is that I don't think it should be permissible to create an object which is not in a usable state; that is to say, once an object is created through whatever means, it should be immediately useful. You should not have to create the object, then set one attribute after another before you can pass it in to other objects for use, or call its methods to generate the information/calculation you desire. Sadly, most object-oriented code follows a pattern of create, initialize, set, set, set, set, use – a pattern that to me is just begging for errors.
This is a pattern I don't have to use anymore for my Ruby objects, no matter how complex.
My test case for this technique is a simple script that acts as a front-end to the horrific user interface of mencoder and easily transcodes from any .avi file into a format that the Nokia N800 Internet Tablet can display. Of interest is the initialize method. This example ties in everything I deduced from Hal Fulton's "fancy constructors":
def initialize &blockThis initialize method does five things that are of interest:
# set up some sane default values
self.N800Defaults
# let the user fine-tune these
instance_eval &block if block
# check for sanity and consistency
raise ArgumentError, "no maximum rate specified" unless @maximum_rate
raise ArgumentError, "no audio rate specified" unless @audio_rate
raise ArgumentError, "no maximum width specified" unless @maximum_width
raise ArgumentError, "no maximum height specified" unless @maximum_height
# calculate any parameters that haven't been over-ridden
@video_rate ||= @maximum_rate - @audio_rate
# protect ourself from changes
self.freeze
end
- It sets up some sane default values for the object based upon its expected use. Since this script is very much oriented toward converting video for the N800, the sane defaults are the ones useful for the N800. All that happens in the method called is that a set of instance variables are set with the best default values for the most-often-desired platform. A method call is used to avoid clutter and for reasons best left explained later.
- Next it evaluates the passed-in block of code from within its own context. Any code executed in that block changes this object's state. As you will see later, this means that instead of passing in a whole bunch of parameters, whether positional (with or without defaults), an array of values or even mimicking named parameters through the use of a hash, "parameters" are instead "passed" by setting the object's state in a block. This allows for them to be set by name, and, too, allows for them to be calculated on the fly or whatever else may be convenient for the user of this class. As you will also see later on, it permits the creation of "stock parameters" thus mimicking the ability to have many constructors.
- It sanity-checks whatever came in from the block. In this script I merely check that required values exist. They should exist, of course, given that I set up defaults at the top, but since the block could assign nil to any of those values, it's safest to check and be certain. Too, depending on the object, you don't always have the option of defaults. Sometimes things simply must be provided by the user.
- Any parameters which aren't assigned but are required are instead calculated.
- For the ultra-paranoid, the object can freeze itself once it's known to be in a stable and sane state.
transcoder = N800Conv::MenCoder.new do
self.N800Defaults
@maximum_width = arguments[:maximum_width] || @maximum_width
@maximum_height = arguments[:maximum_height] || @maximum_height
@maximum_rate = arguments[:maximum_rate] || @maximum_rate
@audio_rate = arguments[:audio_rate] || @audio_rate
@video_rate = arguments[:video_rate] || @video_rate
end
Here, again, I call the defaults. This is by design in that it is really a placeholder for code that sets whatever defaults I like. Perhaps my script is really targeting the N770. The MenCoder class can easily provide a single method which sets up values more suitable to that slower platform, thus keeping user code to a minimum. Too, I can easily expand the transcoding support for this script by just adding more default methods. The sky really is the limit for how much support I want to give my end-users – and, yet, I don't do so at the expense of my more experienced, more finicky expert users. Every value that can be tweaked is accessible. You just don't have to learn each and every one of them for casual use and for the most common use cases. Nor, in this case, does the end-user have to specify each and every parameter. Instead the end-user can tweak only those values which need adjustment away from the defaults, which, for most cases, means no command line options will be needed at all.
So what does the mysterious "self.N800Defaults" look like?
def N800Defaults
@maximum_rate = 800 # kbps
@audio_rate = 96 # default audio rate
@maximum_width = 400 # pixels
@maximum_height = 240 # pixels
end
In this case it's dirt-simple. Which means it's also dirt-simple to add, say, N810Defaults, N770Defaults or, if I get bigger aspirations, even iPhoneCrappyDisplayDefaults or whatever other platforms I choose to target.
The entire script used in snippets above can be picked up from my N800 Utilities Google Code page. A direct link to the script is http://n800utils.googlecode.com/files/n800conv-v1.0 (I strongly recommend renaming it so that the "-v1.0" at the end isn't there.)
