Recently, I developed a configuration DSL that would populate a database. I found myself writing a lot of repetitive code to handle the DSL syntax, and thought that this would be a good case to programatically generate the methods.

The basic syntax was of the form:

1
2
3
4
5
6
device DeviceClass do
  device_string_attribute "meaning of life"
  device_integer_attribute 42
  device_boolean_attribute true
  ...
end

One of the requirements was to throw an error if the wrong type of argument was passed as a parameter. The initial approach was to add conditions to check for the type and range, but I found this to be extremely repetitive. Enter metaprogramming. This allowed me to generate a base function template, and automatically generate the code for that function based on some parameters. The C language has a very similar feature, where I'd define a repetitive block as a macro and call that macro multiple times with different parameters.

1
2
3
4
#define FUNC_DEF(return_type, name, parameter) \
  return_type name(parameter) { \
    return parameter; \
  }

Obviously, the above snippet is a contrived example, but it serves to give you an idea of how to do something like this in C (the backslashes serve to continue the macro definition).

Ruby has the fantastic define_method. With it, I could do exactly what I had done in the C instance.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class DeviceClass
  def self.make_attribute(name, accept_class, accept_range)
    define_function name.to_s do |arg|
      if accept_class and not arg.is_a? accept_class
        raise "Argument is of the wrong type (expected #{accept_class})"
      end

      if accept_range and not accept_range.include? arg
        raise "Argument out of range (expected #{accept_range})"
      end

      instance_variable_set("@#{name}", arg)
    end
  end

  make_attribute(:device_string_attribute, String, nil)
  make_attribute(:device_integer_attribute, Integer, (0..100))
  make_attribute(:device_boolean_attribute, nil, [true, false])
end

The accept_class and accept_range parameters serve to define the accepted type and range of values that can be set in the DSL. The only limitation is that since Ruby doesn't have an explicit class for Boolean data types, we are limited to verifying that the user entered true or false, by checking the range.

Certainly, this was a limited example, and it does hamper performance slightly due to the multiple checks, but it serves to demonstrate how you can follow the DRY principle and make a lot of code that is mostly common but differs in subtle ways.


Comments

comments powered by Disqus