Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Ruby: ruby-regexp_trie gemを読んでみた

Ruby: ruby-regexp_trie gemで文字列リストを効果的な正規表現に変換する

ruby-regexp_trie gemのソースコードを見ると予想以上に短く、1画面に収まるほどでした。

class RegexpTrie

  # @param [Array<String>] strings Set of patterns
  # @param [Fixnum,Boolean] option The second argument of Regexp.new()
  # @return [Regexp]
  def self.union(*strings, option: nil)
    new(*strings).to_regexp(option)
  end

  def initialize(*strings)
    @head = {}

    strings.flatten.each do |str|
      add(str)
    end
  end

これなら読めそうと思ったのですが、冒頭のクラスメソッドでnew(*strings)と書いているのを見てしばらく考えてしまいました😅。私は業務でRubyのコードを書いていないこともあって、この書き方は初めてでした。

おそらくRegexpTrie.unionでもRegexpTrie.newでもやれるようにするための書き方なのだろうと推測しました。以前書いた記事のmodule_functionをちょっと思い出しましたが、RegexpTrieはモジュールではなくクラスです。

[Ruby] module_functionでモジュールの特異メソッドを簡潔に書く

はみだしつっつき

いつもお世話になっているkazzさんに質問しました。

「ああ、クラスメソッドでそうやってnew()だけ書くのはときどきやりますよ🧐」「そうでしたか!ありがとうございます」「newの後処理とかもインスタンス生成側に要求したいときは、自分もしょっちゅうこれで書きますし😋」「クラスメソッドの引数をそのまま引数に渡しているんですね: 最初見たときは、『クラスメソッドなのにnewするの?』と思っちゃいました😅」

※クラスメソッドでnewするのは「典型的なfactory method」だとmorimorihogeさん&babaさんに教わりました🙇。

参考: Factory Method パターン - Wikipedia

処理を2つに分ける

「ruby-regexp_trieでプリントデバッグしながら処理を追ってみたんですけど、どうやら処理をaddbuildの2つに分けてやってることがわかりました」

  # @param [String] str
  def add(str)
    return self if !str || str.empty?

    entry = @head
    str.each_char do |c|
      entry[c] ||= {}
      entry = entry[c]
    end
    entry[:end] = nil
    self
  end

処理後の@head: {"課"=>{"長"=>{:end=>nil, "補"=>{"佐"=>{}}}}}

addは入力の配列を1文字ずつハッシュのキーにして上のようなツリーを組み立てて@headに保存しておいて、それが終わってからbuildでそのツリーを正規表現の形にビルドしていました」「そうそう、複雑な処理を2つに分けるというのはとってもいいこと❤️: 1つのループの中で何もかもやろうとするとどんどんつらくなりますし」「ですね!」

「特に上のaddeach_charの中に1つも分岐を書いてないのがいい👍: ループの中に分岐があればあるほどテストがつらくなるんですよ😢」「そうでしたか!」

  def build(entry)
    return nil if entry.include?(:end) && entry.size == 1

    alt = []
    cc = []
    q = false

    entry.keys.each do |c|
      if entry[c]
        recurse = build(entry[c])
        qc = Regexp.quote(c)
        if recurse
          alt.push(qc + recurse)
        else
          cc.push(qc)
        end
      else
        q = true
      end
    end

    cconly = alt.empty?
    unless cc.empty?
      alt.push(cc.size == 1 ? cc.first : "[#{cc.join('')}]")
    end

    result = alt.size == 1 ? alt.first : "(?:#{alt.join('|')})"
    if q
      if cconly
        "#{result}?"
      else
        "(?:#{result})?"
      end
    else
      result
    end
  end

「トライ木のロジックはbuildの方にまとまってますね」「なお、ツリーの仕様が変わらないのであればこういう書き方がいいんですけど、CSVのパースみたいに後から仕様変更されがちなコードでは、こうやってキレイに2つに分けられるとは限らないですね(↓関連記事見てね❤️)」「そうか、正規表現なら普通書き方が変わらないからこれがいいんですね」

関連記事

Ruby: CSVでヘッダとボディを同時に定義するやり方


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。