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


Strings

Numbers

Date and Time

Arrays

Hashes

Files and Directories

Code Blocks and Iteration

Objects and Classes8

Modules and Namespaces

Reflection and Metaprogramming

XML and HTML

Graphics and Other File Formats

Databases and Persistence

Internet Services

Web Development Ruby on Rails

Web Services and Distributed Programming

Testing, Debugging, Optimizing, and Documenting

Packaging and Distributing Software

Automating Tasks with Rake

Multitasking and Multithreading

User Interface

Extending Ruby with Other Languages

System Administration



Ruby Cookbook
Ruby Cookbook (Cookbooks (OReilly))
ISBN: 0596523696
EAN: 2147483647
Year: N/A
Pages: 399

Flylib.com © 2008-2020.
If you may any questions please contact us: flylib@qtcs.net