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:
See Also
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