アペフチ

Ruby 2.6のRefinementsが使いやすい

EPUB Parser 0.4.1をリリースした( https://kitaitimakoto.gitlab.io/epub-parser/file.CHANGELOG.html#_0_4_1 )。XMLライブラリーを切り替え可能にしているのだけど、そこでOgaも使えるようにしたというリリース。

Ruby 2.7の足音も近付いている昨今ですが、このOga対応中に2.6のRefinementsの使いやすさをすごく実感したので今日はそのことを話したい。

Refinementの用途 - ライブラリー間の差を吸収するアダプター

EPUB ParserではXMLライブラリーをREXMLOgaNokogiriから選べるようになっている。こういうのの実現は、多く、 XMLDocument::REXML のようなアダプターを作ってその中にライブラリーの詳細を隠蔽する。EPUB Parserの方からは共通の XMLDocument のAPIを使って、内部でアダプターがREXMLならREXMLのメソッド呼び出しに変換する、という方法だ。

が、EPUB Parserではそういうラッパーは用意しなかった。直接 REXML::Document とか Oga::XML::Document とかのメソッドを呼び出している。とは言っても、勿論、一々

doc =
  case @adapter
  when :Oga
    Oga.parse_xml(xml)
  when :Nokogiri
    Nokogiri.XML(xml)
  else
    REXML::Document.new(xml)
  end
xpath = "/container/rootfiles/rootfile"
rootfiles =
  case @adapter
  when :Oga
    doc.xpath(xpath)
  when :Nokogiri
    doc.xpath(xpath)
  else
    doc.each(xpath)
  end

などと条件分岐するわけではない。その代わりに用いたのがRefinement、というわけだ。

例えばEPUB Parser内でXPath式に基づいて要素を取得する場合には each_element_by_xpath というメソッドを呼び出すことにしているが、ご存じの通りどのXMLライブラリーにもこういう名前のメソッドは備わっていない。だから各ライブラリーのクラスにこのメソッドを新しく定義するのだけど、それをオープンクラスでやったり prependinclude でやると、EPUB Parserで読み込んだ状態では各XMLライブラリーに未知のメソッドが生えることになってしまう。EPUB Parserを使ってHTMLコンテンツを読み込んで、それを自分の好きなXMLライブラリーでパースすることも割とあると思うのだけど、その時に意図しないメソッドが生えているのは嫌だ。そこで活躍するのがRefinement。 prepend みたいにメソッドを定義したモジュールを読み込ませるんだけど、その読み込みは明示的に using した範囲に限られる。EPUB Parserの内部処理で using EPUB::Parser::XMLDocuemnt::Refinements とかして、そのモジュール内で each_element_by_xpath とか定義してても、ユーザーが自分のXMLライブラリーを使う時にはそんなメソッドは無くなっている。

REXMLとNokogiriの両方を使えるようにする際にこういう仕組みを導入した。今回、0.4.1のリリースでOga対応する時にもこれに則って必要な書くライブラリーを定義していった。特に困ることなく普通にRefinementを使って実装した後GitLabにプッシュするとCIが動く。そしてテストの失敗を報告してくる。手元の開発はRuby 2.6だけでやっていて、2.6用のCIでは当然成功しているんだけど、2.3 - 2.5では失敗していて、これを修正する間に「ああ、Ruby 2.6のRefinementは過去のバージョンと比べて自然に使えるようになってるんだなあ、便利だなあ」と実感した次第だ。

モジュールをrefineできるようになっている。

2.4、2.5と違い、2.3のCIではテストを開始する以前にエラーが発生している。Ruby 2.3のRefinementは、どうやらクラスしか refine できず、 refine Oga::XML::Traversal でエラーになっていた。

今ではモジュールも refine できて便利、と言うか、開発中はできることを疑わなかった。

シンボルをブロック化する時にRefinementが適用される

見出しだけだと何を言っているか分からないと思う。僕も分からない。こういうことだ。

[::Oga::XML::Document, ::Oga::XML::Node].each do |klass|
  refine klass do
    [
      [:document, ::Oga::XML::Document],
      [:element, ::Oga::XML::Element],
      [:text, ::Oga::XML::Text]
    ].each do |(type, klass)|
      define_method "#{type}?" do
        kind_of? klass
      end
    end
  end
end

こうすると、 using すると Oga.parse_xml(xml).element? とかができるようになる。のだけど、Ruby 2.6未満では次のメソッド呼び出しが失敗してしまっていた。

def root
  root_node.children.find(&:element?)
end

children がイテレートするXMLノードに element? なんてメソッドは無い」と言われてしまう。定義しているのになんで? 次のようにするとちゃんと element? メソッドでフィルタリングできる。

def root
  root_node.children.find {|child| child.element?}
end

こういうことができるのか調べようという発想すらなく、自然と

root_node.children.find(&:element?)

って書いていたので、この時にRefinementが適用されるのはRubyistにとって自然なことなんだと思う[1]。2.6はすごく自然だ。

respond_to?でもRefinementが考慮される

上の例で引き続き、

node.respond_to? :root

respond_to? を呼び出すと、2.5までは false が返って来てしまう。2.6はちゃん[2]true が返って来る。素晴らしい。


Refinementはリリースノートをざっと見るだけだとどういう意味を持つのか分からない改善とかあるけど、Ruby 2.6ではRefinementがより自然に使えるようになっていたんだなあ。2.7ではもっとよくなっているのか知ら。楽しみですね。


1. 主語がでかい。僕にとっては、です。
2. 僕にとって、ちゃんと。