Enforcing Software Contracts
Credit: Maurice Codik
Problem
You want your methods to to validate their arguments, using techniques like duck typing and range validation, without filling your code with tons of conditions to test arguments.
Solution
Here's a Contracts module that you can mix in to your classes. Your methods can then define and enforce contracts.
module Contracts def valid_contract(input) if @user_defined and @user_defined[input] @user_defined[input] else case input when :number lambda { |x| x.is_a? Numeric } when :string lambda { |x| x.respond_to? :to_str } when :anything lambda { |x| true } else lambda { |x| false } end end end class ContractViolation < StandardError end def define_data(inputs={}.freeze) @user_defined ||= {} inputs.each do |name, contract| @user_defined[name] = contract if contract.respond_to? :call end end def contract(method, *inputs) @contracts ||= {} @contracts[method] = inputs method_added(method) end def setup_contract(method, inputs) @contracts[method] = nil method_renamed = "__#{method}".intern conditions = "" inputs.flatten.each_with_index do |input, i| conditions << %{ if not self.class.valid_contract(#{input.inspect}).call(args[#{i}]) raise ContractViolation, "argument #{i+1} of method '#{method}' must" + "satisfy the '#{input}' contract", caller end } end class_eval %{ alias_method #{method_renamed.inspect}, #{method.inspect} def #{method}(*args) #{conditions} return #{method_renamed}(*args) end } end def method_added(method) inputs = @ contracts[method] setup_contract(method, inputs) if inputs end end
You can call the define_data method to define contracts, and call the contract method to apply these contracts to your methods. Here's an example:
class TestContracts def hello(n, s, f) n.times { f.write "hello #{s}! " } end
The hello method takes as its arguments a positive number, a string, and a file-type object that can be written to. The Contracts module defines a :string contract for making sure an item is stringlike. We can define additional contracts as code blocks; these contracts make sure an object is a positive number, or an open object that supports the write method:
extend Contracts writable_and_open = lambda do |x| x.respond_to?('write') and x.respond_to?('closed?') and not x.closed? end define_data(:writable => writable_and_open, :positive => lambda {|x| x >= 0 })
Now we can call the contract method to create a contract for the three arguments of the hello method:
contract :hello, [:positive, :string, :writable] end
Here it is in action:
tc = TestContracts.new tc.hello(2, 'world', $stdout) # hello world! # hello world! tc.hello(-1, 'world', $stdout) # Contracts::ContractViolation: argument 1 of method 'hello' must satisfy the # 'positive' contract tc.hello(2, 3001, $stdout) # test-contracts.rb:22: argument 2 of method 'hello' must satisfy the # 'string' contract (Contracts::ContractViolation) closed_file = open('file.txt', 'w') { } tc.hello(2, 'world', closed_file) # Contracts::ContractViolation: argument 3 of method 'hello' must satisfy the # 'writable' contract
Discussion
The Contracts module uses many of Ruby's metaprogramming features to make these runtime checks possible. The line of code that triggers it all is this one:
contract :hello, [:positive, :string, :writable]
That line of code replaces the old implementation of hello with one that looks like this:
def hello(n,s,f) if not (n >= 0) raise ContractViolation, "argument 1 of method 'hello' must satisfy the 'positive' contract", caller end if not (s.respond_to? String) raise ContractViolation, "argument 2 of method 'hello' must satisfy the 'string' contract", caller end if not (f.respond_to?('write') and f.respond_to?('closed?') and not f.closed?) raise ContractViolation, "argument 3 of method 'hello' must satisfy the 'writable' contract", caller end return __hello(n,s,f) end def __hello(n,s,f) n.times { f.write "hello #{s}! " } end
The body of define_data is simple: it takes a hash that maps contract names to Proc objects, and adds each new contract definition to the user_defined hash of custom contracts for this class.
The contract method takes a method symbol and an array naming the contracts to impose on that method's arguments. It registers a new set of contracts by sending them to the method symbol in the @contracts hash. When Ruby adds a method definition to the class, it automatically calls the Contracts::method_added hook, passing in the name of the method name as the argument. Contracts::method_added checks whether or not the newly added method has a contract defined for it. If it finds one, it calls setup_contract.
All of the heavy lifting is done in setup_contract. This is how it works, step by step:
- Remove the method's information in @contracts. This prevents an infinite loop when we redefine the method using alias_method later.
- Generate the new name for the method. In this example, we simply append two underscores to the front.
- Create all of the code to test the types of the arguments. We loop through the arguments using Enumerable#each_with_index, and build up a string in the conditions variable that contains the code we need. The condition code uses the valid_contract method to translate a contract name (such as :number), to a Proc object that checks whether or not its argument satisfies that contract.
- Use class_eval to insert our code into the class that called extend Contracts. The code in the eval statment does the following:
- Call alias_method to rename the newly added method to our generated name.
- Define a new method with the original's name that checks all of our conditions and then calls the renamed function to get the original functionality.
See Also
- Recipe 13.14, "Validating Data with ActiveRecord"
- Ruby also has an Eiffel-style Design by Contract library, which lets you define invariants on classes, and pre-and post-conditions on methods; it's available as the dbc gem