2010年8月27日金曜日

Rubyでメソッド定義のときに引数の型をチェック

例えば、以下は二つの文字列引数xをとり、x self xを連結するメソッドsandのRubyによる定義。

class String
  def sand(x)
    x + self + x end end

これは一見うまく動くようだが、

"hello".sand("'") # => 'hello'

実は文字列を指定しなければエラーとなる。

'hello'.sand(1) # =>
# ~> -:2:in `+': String can't be coerced into Fixnum (TypeError)
# ~>    from -:2:in `sand'
# ~>    from -:5

原因は、xに渡された1(Integer)の+メソッドは、String型を渡すとTypeErrorを投げるから。文字と数字が足せないのは当たり前なので、妥当だといえる。
このようなエラーを実行前にトラップするために、静的型付けが有用だとされてきたが、事実Rubyではこの初歩的なバグは実行時ならないとわからない。
もちろん言語仕様上コンパイル時にエラーを上げることなど不可能なので、エラー処理をわざわざ入れるというのはよくやることだ。ここでは、sandを「エラー時にはnilを返す」ようにする(ここは、例外を投げるようにしてもいいかもしれないが、とりあえず)。

class String
  def sand(x)
    if(x.is_a?(String))
      x + self + x end end end

これで、Integerを引数に指定するとnilを返すようになる。しかし、このような煩雑な処理を毎回書いていると冗長になるし、オブジェクト志向言語では、型ではなくて「特定のメソッドが定義されていれば」など込み入った定義もしたくなる。そこで、これらをできるだけ簡単に判断できるようなユーティリティを書いてみた。

def type_check(args, &proc)
  check_function = lambda{ |val, check|
    if check.nil?
      nil
    elsif check.respond_to?(:call)
      check.call(val)
    elsif check.is_a? Array
      val.__send__(*check)
    elsif check.is_a? Module
      val.is_a?(check) end }
  error = args.find{ |a| not(check_function.call(*a)) }
  if(error)
    warn "argument error: #{error[0].inspect} is not passed #{error[1].inspect}"
    nil
  else
    proc.call if proc end end

関数type_checkは、すべてのチェックにパスしたら渡されたブロックを実行してその結果を返す関数。いずれかのチェックに失敗したら、エラーを出力してnilを返す。
チェックは、値 => 判定式 のような連想配列もしくは[[値,判定式],...]のような配列。
使える判定式は、

  • nil ... 常にパス
  • [symbol, *args] ... 値.send(symbol, *args)がtrueを返せばパス
  • lambda{|x|} ... 値を引数に取る無名関数(厳密にはcallメソッドが定義されているもの)。trueを返せばパス。
  • Module ... その型とis_a?関係ならパス

例えば以下のように使う。

type_check([[1 , Integer], [2 ,[:is_a?, Integer]]]){ :passed } # => :passed

これを利用すれば、sandは以下のように定義できる。

class String
  def sand(x)
    type_check(x => String) do
      x + self + x end end end

利点は、 x => Stringは、「xはString」のように自然言語に読み替えやすいことか。このままでは微妙なので、型チェックを伴う無名関数を定義するメソッドを定義してみる。

def tclambda(*args, &proc)
  lambda{ |*a|
    if proc.arity >= 0
      if proc.arity != a.size
        raise ArgumentError.new("wrong number of arguments (#{a.size} for #{proc.arity})") end
    else
      if -(proc.arity+1) > a.size
        raise ArgumentError.new("wrong number of arguments (#{a.size} for #{proc.arity})") end end
    type_check(a.slice(0, args.size).zip(args)){
      proc.call(*a) } } end

type_checked_lambdaは、lambdaとほぼ同じように使うが、ブロック以外に引数を取る。それは、各引数のチェック式になる。
例えば、配列aryの中の数を2倍にして返したい場合、以下のようにできる。

ary.map(&tclambda(Integer){ |x| x * 2 })

こうすれば、aryに数値以外がはいっていれば、そこはnilとなる。かなりすっきり書けた。
これがあれば、型をチェックするdefを定義することもでき、それを使えばsandの定義はかなりすっきりする。

class Object
  def self.defun(method_name, *args, &proc)
    define_method(method_name, &tclambda(*args, &proc)) end end

class String
  defun(:sand, Integer) do |x|
    x + self + x end end

このように、やや強引ではあるが、型を定義したメソッドを定義できた。できるだけこれでメソッドを定義すれば、エラー箇所がより明確になるかもしれない。

動機

関数、とくに自分の書いたものは信用してしまいがちで、この類のエラーは後を立たない。今回は、静的型付け言語の引数チェックを取り入れられないかという実験の一環でコードを書いてみた。
こういったメタなコードを書くたび、Lispへの憧れが深まっていく(笑)。