しつこく Hub を読む

しつこく「Hub」を眺めている。語学学習に精読と多読の2つのアプローチがあるように、コードリーディングにも精読というのがあるはずで、試しにそれをやってみている。モヤッする「分かってない感」を感じたら、基本的に分かるまで調べるのが精読。そのためには、ブログエントリとして分かったことを書き出すのは良い方法のような気がしたのでやってみている。ほとんど誰も読んでないブログだとはいえ、アウトプットの形にするのは手間だし、何だか気分的には常に挫けそうなんだけど、「なんとなく分かった気になる」という厄介な悪癖を変えるには十分に時間に見合うと思う。

で、やってみて分かったけど、RubyのこともRubyのイディオムのことも、まるで分かってないということが良く分かった。「コードを読む」ということが何かも分かってなかったなと思った。なんとなく眺めてちゃ駄目だな。まず精読。そのうち多読ができればいいなと思っている。

コードを読むのは思ったよりずっと楽しい。理解できないところは「面倒だな」と感じるのだけど、まあ調べれば大抵分かる。で、そうやってイディオムや、コードのまとめ方、ライブラリの使い方を調べるのってプログラミング学習の王道なんだろうなと思った。今まで何をやっていたのだと思うようになった。これと同じ事をRuby以外の言語でもやりたい。

ともあれ。

Hub::Contextは gitコマンドを実行して結果をキャッシュしたり設定を読み出すためのクラス。頭からつらつら読んでいって、最初から意味が分からないところがあった。以下。

    # Shells out to git to get output of its commands
    class GitReader
      attr_reader :executable

      def initialize(executable = nil, &read_proc)
        @executable = executable || 'git'
        # caches output when shelling out to git
        read_proc ||= lambda { |cache, cmd|
          result = %x{#{command_to_string(cmd)} 2>#{NULL}}.chomp
          cache[cmd] = $?.success? && !result.empty? ? result : nil
        }
        @cache = Hash.new(&read_proc)
      end

%xはプロセスをフォークして子プロセスとしてコマンドなどを実行する。バックティックで文字列を囲んでも同じ。似たようなものに Kernel#system もあるけど、こちらは戻り値がtrue/falseであってコマンドの出力は取れない。

GitReaderでは、%xを使ってエラー出力をNULLに捨てつつ結果をchompしてresultに入れている。それはいいけど、「$?.success?」ってなんだ?

Ruby(などのスクリプト言語)の特殊な記号ってググるのが難しい。やっとそれらしいページが出てきたと思ったら主に使われる記号類のリストとかで「$?」が出てなかったりする。こういうとき手元にちゃんとドキュメントを入れておくんだったとか、公式ドキュメントにブックマークしておくんだったと思ったりする。

で、今回気付いたけど、特殊記号を調べる簡単な方法は、Rubyに標準で添付されているenglish.rbを見ることかも。english.rbは以下のようにずらずらとエイリアスを付けるライブラリだけど、ドキュメント代わりにも使える。

alias $DEFAULT_INPUT           $<

# The process number of the program being executed. Read only.
alias $PID                     $$

# The process number of the program being executed. Read only.
alias $PROCESS_ID              $$

# The exit status of the last child process to terminate. Read
# only. Thread local.
alias $CHILD_STATUS            $?

# A +MatchData+ object that encapsulates the results of a successful
# pattern match. The variables <tt>$&</tt>, <tt>$`</tt>, <tt>$'</tt>,
# and <tt>$1</tt> to <tt>$9</tt> are all derived from
# <tt>$~</tt>. Assigning to <tt>$~</tt> changes the values of these
# derived variables.  This variable is local to the current
# scope. Thread local.
alias $LAST_MATCH_INFO         $~

# If set to any value apart from +nil+ or +false+, all pattern matches
# will be case insensitive, string comparisons will ignore case, and
# string hash values will be case insensitive. Deprecated
alias $IGNORECASE              $=

ということで、「$?」は子プロセスの終了コードを保持していることが分かった。$?は何者かというと、

%x{ls}
=> "Gemfile\nGemfile.lock\nHISTORY.md\nLICENSE\nREADME.md\nRakefile\nbin\netc\nfeatures\ngit-hooks\nhub.gemspec\nlib\nman\ntest\ntmp\n"
[3] pry(main)> $?.class
=> Process::Status

ということで、Process::Statusインスタンスだと分かる。POSIXではプロセスのステータスを16ビットの数値で保持していて、それをカプセル化しているインスタンスらしい。なるほど。シェルやプロセスとやり取りするクラスやライブラリってRubyには色々あるんだなということで、今回 Shelljoin というものがあるのも知った。

もう1つ分からないのは、

def initialize(executable = nil, &read_proc)
  @executable = executable || 'git'

と書くことと、

def initialize(executable = 'git', &read_proc)
  @executable = executable

と書くことの違い。executableは、gitコマンドの文字列が入る。デフォルトで「git」、もし環境変数の GIT が設定されていれば、それを優先する。以下のように。

    def git_reader
      @git_reader ||= GitReader.new ENV['GIT']
    end

些細な話かもしれないけど、もし動作に違いがないなら、executable が nil になることはないのだし、ここは引数にデフォルト値を書いたほうがストレートな気がする。同様に、read_procを外から渡せるようにしている理由もよく分からない。テストで stub out するためにこうしているような気がするけど、ここはこれ以上は追わない。

なるほど面白いと思ったのがハッシュのコンストラクタにlambdaを渡す用法。

Rubyでは Hash.new でデフォルト値を渡せる。存在しないkeyでアクセスしようとすると、このデフォルトの値が返ってくる。

h = Hash.new("Go Fish")
h["a"] = 100
h["b"] = 200
h["a"]           #=> 100
h["c"]           #=> "Go Fish"
# The following alters the single default object
h["c"].upcase!   #=> "GO FISH"
h["d"]           #=> "GO FISH"
h.keys           #=> ["a", "b"]

デフォルトオブジェクトが書き換え可能というのにちょっと驚いた。Rubyっぽい柔らかさだ。

で、Hash.new にはcallableオブジェクトも渡せる。すると、存在しないkeyでアクセスしようとすると、このブロックが起動される。このときブロック変数として、そのハッシュそのものとkeyが渡る。これを使うと以下のように何らかのコンピュテーションなりIO処理なりの実行結果をキャッシュするような機構が手軽に実現できる。

class C
  def initialize
    default_proc ||= lambda {|hash, key|
      puts "excecuted"
      hash[key] = key.length
    }
    @cache = Hash.new(&default_proc)
  end

  def read(key)
    @cache[key]
  end
end
irb(main):001:0> c = C.new
=> #<C:0x007ffae2227a88 @cache={}>
irb(main):002:0> c.read("hoge")
excecuted
=> 4
irb(main):003:0> c
=> #<C:0x007ffae2227a88 @cache={"hoge"=>4}>
irb(main):004:0> c.read("foo")
excecuted
=> 3
irb(main):005:0> c
=> #<C:0x007ffae2227a88 @cache={"hoge"=>4, "foo"=>3}>
irb(main):006:0> c.read("this is just a hash")
excecuted
=> 19
irb(main):007:0> c
=> #<C:0x007ffae2227a88 @cache={"hoge"=>4, "foo"=>3, "this is just a hash"=>19}>
irb(main):008:0> c.read("foo")
=> 3

これが、Hub::Context::GitReaderの @cache の挙動。

hubには、他にもいくつか Hash.new(&block)を使っているところがあって、設定関連の情報をYAMLでファイルに書きだしたり読みだしたりする Hub::GitHubAPI::FileStore には、@data の初期化が以下のようになっている。

    # Filesystem store suitable for Configuration
    class FileStore
      extend Forwardable
      def_delegator :@data, :[], :get
      def_delegator :@data, :[]=, :set

      def initialize filename
        @filename = filename
        @data = Hash.new {|d, host| d[host] = [] }
        load if File.exist? filename
      end

ブロック変数の名前の付け方が利用目的に沿う形になっているので最初分からなかったけど、ここで d にはhashそのものである@dataへの参照が渡される。hostのほうはアクセスしようとしたkeyのオブジェクトが渡る。FileStore#getと#setでアクセスする@dataのデータ表現はハッシュだけど、中身はホスト名を表すkeyに対して常に配列になっているということだ。

Hub::Context::GitReaderで次に面白いなと思ったのは、APIの晒し方。

実際にgitコマンドの出力を、コマンドを実行したりキャッシュしたりしつつ返すのはGitReader#readというメソッドだけど、これは、以下のように別名を付けられている。

module Hub
  module Context
    class GitReader
    :
    end

    module GitReaderMethods
      extend Forwardable

      def_delegator :git_reader, :read_config, :git_config
      def_delegator :git_reader, :read, :git_command

      def self.extended(base)
        base.extend Forwardable
        base.def_delegators :'self.class', :git_config, :git_command
      end
    end

    private

    def git_reader
      @git_reader ||= GitReader.new ENV['GIT']
    end

    include GitReaderMethods
    private :git_config, :git_command

git_configはgit_commandの特殊ケースなので、本質的には

  • GitReaderMethodsでGitReaderを読み書きするAPIを定義(宣言)している
  • そのAPIはGitReader#git_commandで、これは git_reader というオブジェクトにreadというメッセージを送ることと委譲が定義されている
  • git_readerはGitReaderのインスタンス
  • git_commandはprivate

GitReaderMethodsはコードの読み手に「ここに列挙しておきますよ」と伝える役割りかな。git_commandがprivateなのは、gitコマンド関連で情報や設定の読み出しの操作は全てGitReader内で完結していて、対応するメソッドが存在するはずだし、今後何か拡張するとしても、GitReader内にメソッドを足して、そこで直接git_commandを実行せよ、という設計ガイドラインみたいなもんだろうか。

あいや……、違うな。git_readerは、以下のようにLocalRepoの初期化時に投げ込まれている。GitReaderMethodsのようにモジュールを切り出すのは再利用のためと、テストの書きやすさのためもあるのかな。

    class LocalRepo < Struct.new(:git_reader, :dir)
      include GitReaderMethods

      :
      :
      :

    def local_repo(fatal = true)
        :
        :
          LocalRepo.new git_reader, current_dir
    end