2009年11月4日水曜日

使いやすいRubyのオブジェクト

Eric HodelさんのSegment7というブログより「Friendly Ruby Object」という記事を訳してみたいと思います。この記事は少し古い記事ですが、Rubyを使う上で大事な基礎事項だと思います。RubyのEnumerableモジュールなどはとても便利ですが、このモジュールを利用するにはある約束事を守る必要があります。この記事はその約束事やクラスに実装しておくと便利なメソッドなどについて解説しています。


使いやすいRubyのオブジェクト
Eric Hodel

Ruby Quickrefを補足するためオブジェクト同士を円滑に作用させる様々な方法についての記事です。ほとんどの例でRubyGemsのコードを使用しました。そのため、いくつかの例では次のリリースの
RubyGemsまで動作しない場合があります。

Enumerable

Enuerableモジュールは#eachメソッドと#mapや#each_with_indexといったよく知られたメソッドで構成されています。このモジュールをmix-inしたオブジェクトで#<=>メソッドが実装されていれば、#sort、#min、#maxなどの便利なメソッドが利用できます。

Gem::SourceInexでは、#eachメソッド経由で公開されているHashオブジェクトを内部的に次のように持っています:

class Gem::SourceIndex
  include Enumerable

  # ...

  def each(&block)
    @gems.each(&block)
  end
end

これは次のような場合に便利です:

dep = Gem::Dependency.new ARGV.shift, Gem::Requirement.default

found = Gem.source_index.any? do |name, spec|
  dep =~ spec
end

puts "found gem for #{dep.name}!" if found

Comparable

Comparableモジュールは#<=>メソッドを元に構成されており、種々の比較メソッドを提供します。
Gem::Specificationオブジェクトは、名前、バージョン、そして、プラットフォーム順でソートされます:

class Gem::Specification
 include Comparable

 def <=>(other)
   my_platform = Gem::Platform::RUBY == @platform ? -1 : 1
   other_platform = Gem::Platform::RUBY == other.platform ? -1 : 1
   [@name, @version, platform] <=>
     [other.name, other.version, other.platform]
  end
end

このメソッドは、「gem list」コマンドのように画面上に表示する際や内部的にgemをインストールする際にオブジェクトをソートするためにRubyGemsで使用されています。

上記のコードにおいて繰り返しを避ける方法や他のソートアルゴリズムを使っての速度を向上する方法については、他の記事の#sort_by and #sort_objを読んで下さい。


#to_sと#inspectメソッド

#to_sや#inspectメソッドを上書きすることで誰かがあなたのオブジェクトを参照したい場合にスクリーン一杯に不要な情報を出力しないようにすることができます。 ある意味、出力される情報の量を制限することでデバックの助けとなります。

Gem::Specificationの#to_sメソッドはたった2つの重要な属性のみを出力します:

class Gem::Specification
  def to_s
    "#<gem::specification name=#{@name} version=#{@version}>"
  end
end

Gem::Platformの#to_sメソッドは次のように読みやすい文字列を返します:

class Gem::Platform
  def to_a
    [@cpu, @os, @version]
  end

  def to_s
    to_a.compact.join '-'
  end
end

Gem::Versionの#inspectメソッドは、内部のインスタンス変数をすべて無視して、@versionのみを出力します:

class Gem::Version
  def inspect # :nodoc:
    "#<#{self.class} #{@version.inspect}>"
  end
end

case文での比較の#===と正規表現比較の#=~メソッド

#===メソッドは一般的によくオーバーライドされますが、#=~メソッドを実装することは、可読性を向上するのに有益になる場合があります。

Gem::Platformでは#===メソッドを次のようにオーバーライドします:

class Gem::Platform
  def ===(other)
    return nil unless Gem::Platform === other

    # cpu
    (@cpu == 'universal' or other.cpu == 'universal' or @cpu == other.cpu) and

    # os
    @os == other.os and

    # version
    (@version.nil? or other.version.nil? or @version == other.version)
  end
end

つまり、もし、2つのGems::Platformオブジェクトが、同じCPU(アーキテクチャー)、もしくは、どちらかがユニバーサルであり、バージョン情報がある場合にお互いが同じバージョンであれば、それらのオブジェクトは同じであることになります。

次のようにgemパッケージをグループ化することができます:

platform_count = Hash.new 0

Gem.source_index.each do |name, spec|
  case spec.platform
  when Gem::Platform.new('linux') then
    platform_count['linux'] += 1
  # ...
  else
    platform_count['other'] += 1
  end
end

p platform_count

Gem::Dependencyは#=~メソッドをオーバーライドします。次のように右辺をGem::Dependencyに変換するので少し奇異に見えます:

class Gem::Dependency
  def =~(other)
    other = case other
            when self.class then
              other
            else
              return false unless other.respond_to? :name and
                                  other.respond_to? :version

              Gem::Dependency.new other.name, other.version
            end

    pattern = @name
    pattern = /\A#{Regexp.escape @name}\Z/ unless Regexp === pattern

    return false unless pattern =~ other.name

    reqs = other.version_requirements.requirements

    return false unless reqs.length == 1
    return false unless reqs.first.first == '='

    version = reqs.first.last

    version_requirements.satisfied_by? version
  end
end

このメソッドは次のようにフィルタとしても使うことができます:

dep = Gem::Dependency.new(/ruby/, Gem::Requirement.default)

ruby_named = Gem.source_index.select do |name, spec|
  dep =~ spec
end

p ruby_named.map { |name, spec| name }

Hashオブジェクトのキーとして

Rubyは2つの異なるオブジェクトが同じハッシュのキーであるかを判定するために#hashと#eql?メソッドを使用します。

次のようにGem::Versionでは、内部のバージョンを表す文字列を元にハッシュのキーとして利用できるようになっています:

class Gem::Version
  def hash
    @version.hash
  end

  def eql?(other)
    self.class === other and @version == other.version
  end
end

Gem::Versionでは、内部のバージョンを表す文字列は「1.3」や「1.3.0」のようになっています。この実装において、2つのバージョン文字列は異なるハッシュのキーとして扱われます。2つのハッシュのキーが同じであることを判定するために#==メソッドの代わりに#eql?メソッドを使用することは重要な特徴です。それは(とても便利だと思いませんが)興味深い挙動を表すためです。Gem::Versionでは、「1.3」というバージョンは「1.3.0」というバージョンと同じですが、ハッシュ内では異なるスロットに格納されています。

#intialize_copyメソッド

#initialize_copyメソッドは、#dupや#cloneメソッドがインスタンス変数以外のオブジェクト固有の状態をコピーする際に呼び出されます。複製元のオブジェクトは新しいインスタンスへ渡されます。#initialize_copyメソッドは、次のようにオブジェクト毎に持っているキャッシュを空にするために利用されることもあります:

def initialize_copy(other)
  @cache = []
end

#exceptionメソッド

#exceptionメソッドは、#raiseメソッドへ渡されるオブジェクトをExceptionオブジェクトへときゃすとするために呼び出されます。このメソッドはExceptionクラスのサブクラスを返す必要があります。オブジェクトをExceptionオブジェクトに変換するためにこのメソッドを使うことができ、次のようにこのメソッドに全ての例外発生コードを集約することができます:

class Result
  class Error < RuntimeError; end

  def initialize(json)
    @result = JSON.parse json
  end

  def exception(message = nil)
    Error.new "#{message} (#{@result['error']})"
  end

  def [](key)
    @result[key]
  end
end

r = Result.new open('http://example.com/api/blah').read

raise r if r['error']

Marshalクラス

Rubyはほとんどのオブジェクトを自動的に直列(Marshal)化します。しかしながら、再構築することができるキャッシュデータや保存しているデータのサイズを縮めるためにカスタムの形式が必要な場合があります。そうするためには2つの方法があります。古い方法としては、#_dumpと::_loadメソッドを使用する方法で、新しい方法は、優先度が高い#marshal_dumpと#marshal_loadメソッドを使用する方法です。新しい方法へ移行したい場合においても、古い方法で直列化されたオブジェクトを戻すために::_loadメソッドをそのままにしておくこともできます。

古い方法の#_dumpメソッドを使うと直列化されたオブジェクトが(通常はMarshal化された)文字列形式が返ります。そして、古い方法の::_loadメソッドを使って、その文字列形式を戻します。::_loadはクラスメソッドであるため、オブジェクトを作成する必要があり、あるインスタンスでは重要である場合があることに留意して下さい。


新しい方法の#marshal_dumpメソッドを使うとObjectのインスタンスが返ります。そして、#marshal_loadメソッドはそのインスタンスを受け取ります。そのインスタンスはすでに生成されていますが、#initializeメソッドは呼び出されません。新しい方法では、既存のシンボルやオブジェクトの参照テーブルを利用するため、より小さいサイズの直列化されたデータをもたらします。

Gem::Specificationでは、#_dump/::loadを使用しており、下位および上位互換性であるように設計されているためにかなり複雑です。若干、簡略化した#_dump/::loadメソッドの実装を次に示します:

class Gem::Specification

  CURRENT_SPECIFICATION_VERSION = 2

  # number of fields per version
  MARSHAL_FIELDS = { -1 => 16, 1 => 16, 2 => 16 }

  def self._load(str)
    array = Marshal.load str

    spec = Gem::Specification.new
    spec.instance_variable_set :@specification_version, array[1]

 # validate object
    current_version = CURRENT_SPECIFICATION_VERSION

    field_count = if spec.specification_version > current_version then
                    spec.instance_variable_set :@specification_version,
                                               current_version
                    MARSHAL_FIELDS[current_version]
                  else
                    MARSHAL_FIELDS[spec.specification_version]
                  end

    if array.size < field_count then
      raise TypeError, "invalid Gem::Specification format #{array.inspect}"
    end

 # restore object
    spec.instance_variable_set :@rubygems_version,          array[0]
    # ...
    spec.instance_variable_set :@platform,                  array[16].to_s
    spec.instance_variable_set :@loaded,                    false

    spec
  end

  def _dump(limit)
    Marshal.dump [
      @rubygems_version,
      @specification_version,
      # ...
      @new_platform,
    ]
  end
end

Gem::Versionは、#marshal_dump/#marshal_loadを実装しており、内部のインスタンス変数は無視して、@version変数のみを直列化します。

class Gem::Version
  def marshal_dump
    [@version]
  end

  def marshal_load(array)
    self.version = array[0]
  end
end

PPによる出力整形化

ちょっとした工夫で、PPを使って簡単にオブジェクトを読みやすい出力を生成することができます。その出力をコピーして、スクリプト内にペーストすることも可能です。まず、自作のオブジェクトに#pretty_printメソッドを実装して、そこで、PrittyPrint#group、PrettyPrint#text、PrettyPrint#breakable、そして、PP#ppメソッドを使用します。riを使って、これらのメソッドの文書を参照することができます。Gem::DependencyとGem::Requirementの#pretty_printメソッドを次に示します:

class Gem::Dependency
  def pretty_print(q)
    q.group 1, 'Gem::Dependency.new(', ')' do
      q.pp @name
      q.text ','
      q.breakable

      q.pp @version_requirements

      q.text ','
      q.breakable

      q.pp @type
    end
  end
end

class Gem::Requirement
  def pretty_print(q)
    q.group 1, 'Gem::Requirement.new(', ')' do
      q.pp as_list
    end
  end

  def as_list
    normalize
    @requirements.map do |op, version| "#{op} #{version}" end
  end
end

これらのメソッドにより、次のように読みやすく、コピー&ペーストしやすい出力が生成されます:

require 'pp'

gem 'ParseTree'

pp Gem.loaded_specs["ParseTree"].dependencies

[Gem::Dependency.new("RubyInline",
  Gem::Requirement.new([">= 3.7.0"]),
  :runtime),
 Gem::Dependency.new("sexp_processor",
  Gem::Requirement.new([">= 3.0.0"]),
  :runtime),
 Gem::Dependency.new("hoe", Gem::Requirement.new([">= 1.8.0"]), :development)]

0 件のコメント: