Ruby Meta-Programming: Software Contracts
Update: I posted an updated version of this code sample on my projects page. The new version allows for custom contract definitions and looks a *lot* cleaner
When I was an undergrad, I spent a summer working on PLT Scheme. My project was to add software-enforced contracts to the HtDP learning languages.
Using the awesome power of Scheme's hygienic macros, I was able to let students specify their own contracts in their code, and signal runtime errors when they were violated.
Anyway, on to what this post is really about: I've been teaching myself Ruby for about a week now. It has some pretty cool meta-programming features that let you write things that give you similar functionality as a macro in Scheme or Lisp would. Moreover, Ruby (like Scheme) is dynamically typed-- in other words, if I tried to pass a string into a function that expects a number, nothing will go wrong until I try to do something to it that only numbers can do. A software contracts system for Ruby would help programmers catch these bugs early. So I was thinking about this, and I wondered: "How hard would it be to build a similar system for Ruby?"
The answer: not too hard at all. There are a few systems out there that do this already, such as the ruby-contract library, and a proof-of-concept by Andrew Hunt (of "Pragmatic Programmer" fame). Keeping with the hacker spirit, I decided to build a VERY simplified contract library as a to explore meta-programming in Ruby.
Here's my approach: I'll define a module Contracts, that when mixed into your class, would let you call a function 'contract' that takes two arguments: the name of the function the contract applies to and the type of its argument. Ideally, it would work this way:
# our testcase
require "contracts"
class TestContracts
extend Contracts
contract :hello, :number
def hello(x)
x.times { puts "hello!" }
end
end
t = Test_contracts.new
t.hello(2)
t.hello("asfsad")
# output:
# ruby test-contracts.rb
#hello!
#hello!
#test-contracts.rb:18: contract violation: argument of
#function 'hello' must be a(n) number (Contracts::ContractError)
Cool. Let's see if we can make it happen (it turns out I couldnt, exactly). Let's start with this contract function: It will rename the function given as its first argument to something else, and then define a new function that checks the contract and then calls the original function. For example, the above code would result in something like this being added to the TestContracts class:
# our goal
...
def hello(x)
if (x.kind_of?(Integer)) then
return __hello(x)
end
raise StandardError, "contract violation"
end
def __hello(x)
x.times { puts "hello!" }
end
...
The renaming aspect is simple: Ruby provides the "alias_method" function in the Module class that does this. Introducing the new bindings into the calling class can be accomplished by using the "class_eval" method from Module. All that is left is translating ":number" into the test for the if statement and generating the "hello" function's name, which is simple enough to do. Lets look at the resulting code:
#implementation 1
module Contracts
def contract(methodname, inputtype)
renamed = rename(methodname)
tester = parse_type(inputtype)
class_eval <<-endofeval
alias_method #{renamed.inspect}, #{methodname.inspect}
def #{methodname}(arg)
if #{tester}.call(arg) then
return #{renamed}(arg)
end
raise ContractError, "contract violation: argument of function '#{methodname}' must be a(n) #{inputtype}", caller
end
endofeval
end
class ContractError < StandardError
end
def parse_type(s)
case s
when :number then "proc {|x| x.kind_of?(Integer)}"
end
end
def rename(s)
("__" + s.id2name).intern
end
end
And everything works as planned: When contract is called, we compute the new name for the "hello" function and the test we need to call on the argument. Then using class_eval, we put all of the pieces togther, and produce code very similar to the "goal" code above.
A slight problem: If we run the test case exactly as above, we get the following error message:
./contracts.rb:6:in `class_eval': (eval):1:in `alias_method':
undefined method `hello' for class `Test_contracts' (NameError)
from (eval):1:in `class_eval'
from ./contracts.rb:6:in `class_eval'
from ./contracts.rb:6:in `contract'
from test-contracts.rb:6
??!?! But hello is right there! Actually, its not-- when the "contract" method is called, the definition of hello hasnt been executed yet... If we move the call to "contract" after the definition of "hello," the code works as we would like, and we are all happy. This probably isnt a big deal, but we would like to keep the contract call as close to the definition as possible, which is right BEFORE the definition, as in the test case.
Thankfully Ruby provides lots of hooks that let you do what you want when certain events are triggered. One of these hooks, "method_added" is run when a method defined in the class. So our new approach: when contract is called, we will store its arguments in a hash. When a method is added to the class, we check in this hash if it has a contract attached to it. If so, we call the old contract function. Here is my final implementation, which executes the test case as we wanted:
#final implementation
module Contracts
def contract(methodname, inputtype)
if not @definedcontracts
@definedcontracts = {}
end
@definedcontracts[methodname] = inputtype
end
def method_added(method)
input = @definedcontracts[method]
if input
@definedcontracts[method] = nil
contract_doit(method, input)
end
end
def contract_doit(methodname, inputtype)
renamed = rename(methodname)
tester = parse_type(inputtype)
class_eval <<-endofeval
alias_method #{renamed.inspect}, #{methodname.inspect}
def #{methodname}(arg)
if #{tester}.call(arg) then
return #{renamed}(arg)
end
raise ContractError, "contract violation: argument of function '#{methodname}' must be a(n) #{inputtype}", caller
end
endofeval
end
# ..... the rest is unchanged
end
Conclusion: We built a really simple contract checking library in Ruby to explore its cool meta-programming features. Ive been really impressed by Ruby so far-- its great to see these powerful meta-programming features in more mainstream languages.


1 Comments:
This post has been removed by the author.
Post a Comment
<< Home