ククログ

株式会社クリアコード > ククログ > 研鑽Rubyプログラミング

研鑽Rubyプログラミング

Rubyコミッターの須藤です。

2023年4月に「すでにRubyをよく知っている」人向けの書籍研鑽Rubyプログラミングが出版されました。私はRubyをよく知っているので読む資格があるはず!

内容

Jeremy Evansさんはもりもりコードを書いているRubyコミッターです。そんな人が書いた内容なので、上級者なら知っていそうだけど中級者は知らないかも?というレベルの情報がバンバン出てきます。初心者はついていけないでしょう。あるいは書いている内容を鵜呑みにしてしまうかもしれません。

しかし、ここに書いている内容を鵜呑みにしてはいけません。サブタイトルが「実践的なコードのための原則とトレードオフ」とある通り、一部は「原則」としてベースの考えとしてもいいものですが、そうでないものは「トレードオフ」として考えなければいけません。「トレードオフ」ということはあちらを優先するとこちらがおろそかになるということです。ケースバイケースで適切なバランスを見つける必要があるということです。たとえ中級者でも本に書いていることを鵜呑みにする傾向があるRubyistはやめた方がいいかもしれません。

全体的に「知っておくと適切なトレードオフを考える材料が増えてよさそう」という情報が詰まっているので自分で判断できる中級者以上のRubyistは読むとよいでしょう。

なお、この本がおもしろかった人は次にAPIデザインケーススタディを読んで欲しいです。また違った視点の判断材料が増えるはずです。

私が思ったこと

せっかくなので読んでいて私が思ったことを紹介します。これもあなたのトレードオフを考える材料に加えてみてください。

17ページ:each_value.to_aはムダじゃない?

17ページあたりでは曲を検索する機能を例にしていろいろ説明しています。その中に次のコードがあります。

lookup = ->(album, track=nil) do
  if track
    albums.dig(album, track)
  else
    a = albums[album].each_value.to_a
    a.flatten!
    a.uniq!
    a
  end
end

私が気になるのはeach_value.to_aです。私ならvaluesと書きます。そっちの方が直感的でわかりやすいですし、たぶん、Enumeratorオブジェクトができないので速い気がします。本書ではちょいちょい「こっちの書き方よりあっちの書き方の方が速い」という話がでてくるので、なぜここでeach_value.to_aしているかわかりませんでした。

32ページ:alias_method :prepend, :includeはわかりにくいよー

32ページあたりでは「オープン・クローズドの原則」をRubyで実現する方法を紹介しています。その中に次のコードがあります。

class OpenClosed
  def self.meths(m)
    m.instance_methods + m.private_instance_methods
  end

  def self.include(*mods)
    mods.each do |mod|
      unless (meths(mod) & meths(self)).empty?
        raise "class closed for modification"
      end
    end
    super
  end

  singleton_class.alias_method :prepend, :include
end

OpenClosed.includeOpenClosed.prependの両方で同じチェックをしてから元のメソッドを呼び出したいというコードです。

本文ではsuperはエイリアス後の名前が使われるのでこれで動くとあるのですが、動いていない気がします。Ruby 3.0だと動いていたのかしら。

class OpenClosedAlias
  def self.meths(m)
    m.instance_methods + m.private_instance_methods
  end

  def self.include(*mods)
    mods.each do |mod|
      unless (meths(mod) & meths(self)).empty?
        raise "class closed for modification"
      end
    end
    super
  end

  singleton_class.alias_method :prepend, :include
end

class OpenClosedDefine
  def self.meths(m)
    m.instance_methods + m.private_instance_methods
  end

  def self.include(*mods)
    mods.each do |mod|
      unless (meths(mod) & meths(self)).empty?
        raise "class closed for modification"
      end
    end
    super
  end

  def self.prepend(*mods)
    mods.each do |mod|
      unless (meths(mod) & meths(self)).empty?
        raise "class closed for modification"
      end
    end
    super
  end
end

module A
end

module B
end

OpenClosedAlias.prepend(A)
OpenClosedAlias.prepend(B)
pp OpenClosedAlias.ancestors

OpenClosedDefine.prepend(A)
OpenClosedDefine.prepend(B)
pp OpenClosedDefine.ancestors
$ ruby -v /tmp/a.rb
ruby 3.3.0dev (2023-04-23T03:01:13Z master dafbaabc04) [x86_64-linux]
[OpenClosedAlias, B, A, Object, PP::ObjectMixin, Kernel, BasicObject]
[B, A, OpenClosedDefine, Object, PP::ObjectMixin, Kernel, BasicObject]

仮に動いていたとしてもこのコードは私にはわかりにくいので私はこういうコードは書きません。私なら共通部分をメソッドに切り出してそれぞれのメソッドから使うようにします。

class OpenClosed
  def self.meths(m)
    m.instance_methods + m.private_instance_methods
  end

  def self.ensure_closed(*mods)
    mods.each do |mod|
      unless (meths(mod) & meths(self)).empty?
        raise "class closed for modification"
      end
    end
  end

  def self.include(*mods)
    ensure_closed(*mods)
    super
  end

  def self.prepend(*mods)
    ensure_closed(*mods)
    super
  end
end

60ページ:*_listがイヤ

60ページあたりはローカル変数の命名について説明しています。その中に次のコードがあります。

options.each do |key_list, value_list|
  key_list.each do |key|
    value_list.each do |value|
      p [key, value]
    end
  end
end

私はここのkey_listvalue_listがイヤです。RubyにListクラスはないからです。リストじゃないものに_listとつけるのがイヤです。

だったらkey_arrayvalue_arrayならいいのかというとそれもイヤです。このkey/valueのコンテナーがArrayでなければいけない!!!というのなら、まぁ、key_array/value_arrayでもいいですけど、「複数のキーである」・「複数の値である」という情報で十分であることが多いので私はkeys/valuesがいいです。

options.each do |keys, values|
  keys.each do |key|
    values.each do |value|
      p [key, value]
    end
  end
end

でもねぇ、_listを使いたくなるときもあるんですよねぇ。どういうときかというと複数のkeysがあるときです。keysをさらに複数形にできないんですよねぇ。なので、 https://github.com/ruby/rss ではイヤだなぁと思いながらも_listを使っちゃっています。どういう名前付けがいいんだろうなぁ。

追記:_listじゃなくて_itemsは?案が出ました。よさそう。

66ページ:インスタンス変数のスコープはselfと一緒って説明すればよかったのか!

ここまでは私の考えと違うということを紹介しました。今度は「なるほどー」と思ったところを紹介します。

66ページあたりではインスタンス変数のスコープについて説明しています。そこで「インスタンス変数のスコープは、メソッドの暗黙的なレシーバー、つまりselfと必ず一致します」と説明していました。

別に私がインスタンス変数のスコープを説明する機会が多くてそのたびになんて説明すればいいか悩んでいたというわけではないのですが、こう説明すればいいということを知らなかったので「なるほどー」と思ったのでした。

137ページ:例外のバックトレースのコスト

次はまた私の考えと違うところになります。

137ページあたりでは例外の実行性能について説明しています。その中でバックトレースのコストが大きいのでバックトレースを無効にすることで高速化できるという説明があります。

この場合は私ならcatch/throwを使います。本文中でも言及されていますがバックトレースがないとデバッグがすごく難しくなります。それを防ぐために必ずその例外を処理しろと書いてあります。そのため、バックトレースをなくしてでも高速化したいときは範囲がわかった上で大域脱出をしたいときなはずです。

Rubyでcatch/throwを使うことは滅多にないのでそもそもこの機能があることを知らない人の方が多そうな気はします。私は大域脱出のためにたまに使います。

たとえば、grntestという全文検索エンジンGroonga用のテスティングフレームワークで使っています。

テスト開始前にcatchしてテスト中に中断したいときにthrowしています。たとえば、テスト中にテスト環境を確認して前提条件を満たしていないときにthrowして中断します。

他にもコマンドラインオプションの解析を途中でやめるときに使っています。具体的には--helpを見つけたらその時点でバージョンを出力して解析をやめます。そのときは次のようにthrow(tag, true)としてtrueを返すようにしています。このメソッドはテストが成功したらtrue、失敗したらfalseを返したいです。--versionは未知のコマンドラインオプションではないのでtrueを返したいケースです。OptionParserはデフォルトで--versionが動くようになっているのですが、そこではexitするようになっていてテストしにくいのでrunを終了するだけにしています。例外でも実現できますが、大域脱出のスコープが明確になるので私はこういうときはcatch/throwを使っています。(例外のバックトレースコストを気にしているわけではないです。)

def run(argv=ARGV.dup)
  catch do |tag|
    parser = create_option_parser(tag)
    parser.parse(argv)
    run_test
  end
end

def create_option_parser(tag)
  parser = OptionParser.new
  parser.on("--version", "Show version and exit") do
    puts(VERSION)
    throw(tag, true)
  end
  parser
end

189ページ:プラグインシステムのデメリット

189ページあたりはプラグインシステムのメリットを説明しています。しかし、ここでデメリットは説明していません。トレードオフを提供するという本書の方針に合わせてここでデメリットも説明して欲しかったです。ちなみに、そのまま読み進めたらデメリットも説明してありました。

209ページ:メタプログラミング

209ページからはメタプログラミングの適切な使いどころを説明しています。私は、上級者なら節度を守ってメタプログラミングを使って欲しいので、メタプログラミングの使いどころも説明するのは「わかっているな!」と思いました。

参考情報として私の判断基準も共有しておきます。それはメタプログラミングをして割に合うかの判断基準:処理を1箇所に局所化できるかです。

225ページ:ドメイン特化言語

225ページからはドメイン特化言語(DSL)について説明しています。RubyはDSLを作りやすい言語なので中級者以上ならたしかに知りたい話題だろうなぁと思います。

参考情報として私のDSL設計の知見を共有します。それはRubyで自然なDSLを作るコツ:値を設定するときはグループ化して代入です。

230ページ:SequelのAPI

230ページあたりではSequelを例にしてDSLの設計を説明しています。たしかにSequelのAPIはよくできているのでそれを汎用的にしたテーブル操作ライブラリーAPIをRed Data Toolsで提供しようと思っています。red-table-queryがそのライブラリーのリポジトリーなのですが、構想だけでまだ手を動かせていません。今年こそは実装したいな。。。

241ページ:パイプをクローズしていない

241ページあたりではRubyスクリプトの構文エラーをチェックする話をしています。その中に次のコードがあります。

Dir['/path/to/dir/**/*.rb'].each do |file|
  read, write = IO.pipe
  print '.'
  system('ruby', '-c', '--disable-gems', file,
         out: write, err: write)
  write.close
  output = read.read
  unless output.chomp == "Syntax OK"
    puts
    puts output
  end
end

readのパイプをクローズしていないのが気になります。私は、ファイルを開くときにFile.open {...}を使うように、パイプを開くときはIO.pipe {...}を使うべき派です。

あと、私は、systemの出力をパイプで受けるのは避けたい派です。出力が多すぎるとパイプのバッファーが埋まってしまって刺さってしまうからです。それを避けるには別スレッドでパイプから読み込み続けないといけないのですが、そうするとsystemが同期APIで(非同期APIより)読み書きしやすいという利点が失われてしまいます。なので、出力サイズが予測できないとき・大きくなるかもしれないとき(たとえばzstdcatの結果を受け取るとか)は、私はTempfileでファイルに出力しています。ファイルIOが発生するので遅くなるのですが、速度やストレージ容量を気にしなくてよいときはTempfileにします。

242ページ:テスト手法

242ページからはテスト手法のことを説明しています。その中で「開発後テスト」・「テスト駆動開発」・「ビヘイビア駆動開発」を説明しています。説明を読む感じではテスト駆動開発とビヘイビア駆動開発がキライなのかな?という印象を持ちました。

269ページ:経験豊富なプログラマーは機能削除がうれしい

269ページあたりは機能の削除について説明しています。その中で次のように書いてあります。

新米プログラマーは「機能を削除する」と聞くと、何か良くないことのように思うかもしれません。しかし経験豊富なプログラマーにとっては最も喜ばしい瞬間のひとつです。

そうなんですよ!

270ページ:__callee__を知らなかった

270ページあたりはメソッドの削除方法を説明しています。その中で次のコードがありました。

def method_to_be_removed
  warn("#{__callee__} is deprecated",
       uplevel: 1, category: :deprecated)
  # ...
end

私は__callee__があることを知りませんでした。__method__はたまに使うのでそれと同じかと思ったのですが、エイリアスされているときの挙動が違いました。__callee__はエイリアス先の名前になって__method__はエイリアスされても元の名前が返ってきます。

to_enum(__method__)__method__を使っていたんですが、これは__callee__を使うほうがいいのかしら。。。

313ページ:ウェブプログラミングの話いらなくない?

313ページからは第III部としてウェブプログラミングの話になります。著者が開発しているSequelとRodaを紹介したかったんだろうなぁとは思うのですが、この部はまるごと無い方がいいんじゃないかと思いました。

書くなら、この部を切り出してこの話題の専門書として分けてより周辺の情報も足してまとめた方がよい気がします。「Rubyプログラミング」という本書のスコープの範囲で書くとこのくらいの内容になってしまうのはしょうがないと思います。私の感覚ではこの話題は「Rubyプログラミング」というスコープから外れていると思うので抜いたほうがよかったと思いました。(経験豊富なプログラマーは機能削除がうれしい!)

363ページ:ブログを書く時間でバグを修正したほうがよい

363ページからは訳者あとがきになっています。その中で著者は「ブログを書く時間でバグを修正したほうがよい」というスタンスであるというエピソードが紹介されています。すごくその気持ちがわかります!今、私はブログを書いているけど!

私は「ドキュメントを書く時間でバグを修正したほうがよい」とも思ってしまっているのですが、著者はドキュメントをしっかり書いているので偉いなぁと思っています。

まとめ

2023年4月に研鑽Rubyプログラミングが出版されたので、その内容と私の感想を紹介しました。たしかに、(自分で判断できるはずの)中級者以上のRubyistに読んで欲しいです。この本がおもしろかったRubyistにはAPIデザインケーススタディも読んで欲しいです。

来月開催のRubyKaigi 2023には著者のJeremy Evansさんも訳者の角谷さんも参加するので読んだ人は感想を伝えましょう!ついでにサインももらうといいでしょう!