エンジニアのソフトウェア的愛情

または私は如何にして心配するのを止めてプログラムを・愛する・ようになったか

Ruby の NKF.nkf のオプションを式で演算する

Ruby には nkf コマンドを利用するための NKF というライブラリがあります。

docs.ruby-lang.org

文字コードを変換する関数 NKF.nkf オプションは、nkf コマンドのオプションと同じものを文字列で指定します。

# -W 入力に UTF-8 を仮定する
# -s Shift_JIS を出力する
# -Lw 改行として CRLF を出力する
# --katakana-hiragana 片仮名を平仮名に、平仮名を片仮名に変換する
NKF.nkf('-W -s -Lw --katakana-hiragana', 'ソフトウェア的愛情')
#=> "\x{82BB}\x{82D3}\x{82C6}\x{82A4}\x{82A5}\x{82A0}\x{9349}\x{88A4}\x{8FEE}"
$ echo ソフトウェア的愛情 | nkf -W -s -Lw --katakana-hiragana | hexdump -C

00000000  82 bb 82 d3 82 c6 82 a4  82 a5 82 a0 93 49 88 a4  |.............I..|
00000010  8f ee 0d 0a                                       |....|
00000014

これを演算子を使って簡便に記述できないか、と考えてみたのがきっかけでした。

演算子を使ったオプションの組み立てのアイディア

演算子を使って」というのがどういうことかというと。 例えば UTF-8 から Shift_JIS への変換であれば、こんなふうに書けたらということです。

utf_8 >> sjis

オブジェクトから見れば演算子も 1 引数のメソッドです。

class Foo
  def initialize(lhs, rhs)
    # ...
  end
end

class Bar
  def >>(rhs)
    Foo.new(self, rhs)
  end
end

bar1 = Bar.new
bar2 = Bar.new
bar1 >> bar2 #=> Foo.new(bar1, bar2)

これを踏まえてオプションを組み立てる方法を考えます。

入力エンコーディングと出力エンコーディングを組み立てる

#>> の左側が入力を右側が出力を表すので、レシーバのオブジェクトが入力の指定になり引数のオブジェクトが出力の指定になります。

ここでの実装では、各々のオブジェクトが入力の指定と出力の指定を自分で知っていて、#>> の中でどちらを利用するか選択することにしました。

演算の結果として生成されるオブジェクトに #to_s を定義し、文字列として表示できるようにしています。

class NKF_OPT
  def initialize(input, output)
    @input = input
    @output = output
  end

  def to_s
    "#{@input} #{@output}"
  end

  Encoding = Data.define(:input, :output) do
    def >>(output)
      NKF_OPT.new(input, output.output)
    end
  end
end

実行。

sjis = NKF_OPT::Encoding.new('-S', '-s')
euc_jp = NKF_OPT::Encoding.new('-E', '-e')
utf_8 = NKF_OPT::Encoding.new('-W', '-w')

puts utf_8 >> sjis
#=> -W -s

puts sjis >> euc_jp
#=> -S -e

入力と出力を指定できるようになりました。

そのほかのオプションを指定する

エンコーディングの他にもオプションが複数あります。

これらのオプションは #+ を使って追加できるようにしてみます。

まずエンコーディング以外のオプションは @options に格納するようにします。

  def initialize(input, output, options = [])
    @input = input
    @output = output
    @options = options
  end

オプション文字列をオブジェクトとして扱えるようにデータ型を追加します。

  Option = Data.define(:option) do
    def to_s
      option
    end
  end

次に #+ を定義してオプションを追加できるようにします。 ここで self を返すことで複数のオプションを #+ で次々に連結して指定できるようにしています。

  def +(option)
    @options << option
    self
  end

オプションが出力されるように #to_s も修正します。

  def to_s
    [@input, @output, *@options.map(&:to_s)].join(' ')
  end

コード全体は次のようになります。

class NKF_OPT
  def initialize(input, output, options = [])
    @input = input
    @output = output
    @options = options
  end

  def +(option)
    @options << option
    self
  end

  def to_s
    [@input, @output, *@options.map(&:to_s)].join(' ')
  end

  Encoding = Data.define(:input, :output) do
    def >>(output)
      NKF_OPT.new(input, output.output)
    end
  end

  Option = Data.define(:option) do
    def to_s
      option
    end
  end
end

実行。

sjis = NKF_OPT::Encoding.new('-S', '-s')
utf_8 = NKF_OPT::Encoding.new('-W', '-w')

crlf = NKF_OPT::Option.new('-Lw')

puts (utf_8 >> sjis) + crlf
#=> -W -s -Lw

追加のオプションを指定できるようになりました。

しかしここで >> よりも + の方が結合が強いため、utf_8 >> sjis + crlf と記述すると utf_8 >> (sjis + crlf) と解釈され、期待する結果が得られません。

puts utf_8 >> sjis + crlf
#=> undefined method '+' for an instance of NKF_OPT::Encoding (NoMethodError)

次にこれを改善します。

演算の順序を制御する

とはいえ演算の結合の順序は Ruby の言語仕様として定義されているので変更することはできません。 utf_8 >> (sjis + crlf) の解釈を受け入れた上で、最終的に期待する出力を得られるようにすることを目指します。

ここでは Encoding にもオプションを格納できるようにすることにします。 冗長になりますが実現が簡単な方法を選びました。

まず Encoding もオプションを格納できるようにします。

  Encoding = Data.define(:input, :output, :options) do
    def initialize(input:, output:, options: [])
      super
    end

    # ...

同様に #+ も定義し、オプションを追加したオブジェクトを作成できるようにします。

    # ...

    def +(option)
      self.class.new(input, output, options + [option])
    end
  end

ここで Encoding オブジェクトを変更するような実装にしてしまうと、そのオブジェクトが指定するオプションの内容が変わってしまい都合がよくありません。

# sjis を変更できてしまうと…
sjis + crlf #=> sjis オブジェクトに crlf の内容が追加されてしまう
sjis + lf   #=> sjis と crlf と lf が指定されたことになる

このため新しいオプジェクトを作成して返すようにしています。

コードの全体は次のようになりました。

class NKF_OPT
  def initialize(input, output, options = [])
    @input = input
    @output = output
    @options = options
  end

  def +(option)
    @options << option
    self
  end

  def to_s
    [@input, @output, *@options.map(&:to_s)].join(' ')
  end

  Encoding = Data.define(:input, :output, :options) do
    def initialize(input:, output:, options: [])
      super
    end

    def >>(output)
      NKF_OPT.new(input, output.output, options + output.options)
    end

    def +(option)
      self.class.new(input, output, options + [option])
    end
  end

  Option = Data.define(:option) do
    def to_s
      option
    end
  end
end

実行。

sjis = NKF_OPT::Encoding.new('-S', '-s')
utf_8 = NKF_OPT::Encoding.new('-W', '-w')

crlf = NKF_OPT::Option.new('-Lw')

puts utf_8 >> sjis + crlf
#=> -W -s -Lw

括弧が不要になりました。

これまでの書き方も引き続き有効です。

puts (utf_8 >> sjis) + crlf
#=> -W -s -Lw

puts utf_8 >> sjis
#=> -W -s

追加のオプションも + で連結して指定できます。

katakana_hiragana = NKF_OPT::Option.new('--katakana-hiragana')

puts utf_8 >> sjis + crlf + katakana_hiragana
#=> -W -s -Lw --katakana-hiragana

NKF.nfk のオプションとして利用する

組み立てた文字列を実際に NKF のオプションとして利用してみます。

式のまま NKF.nkf の引数に渡したいところですが、今のままでは直接渡すことはできません。

require 'nkf'

NKF.nkf(utf_8 >> sjis + crlf + katakana_hiragana, 'ソフトウェア的愛情')
#=> no implicit conversion of NKF_OPT into String (TypeError)

文字列に変換する必要があります。

NKF.nkf((utf_8 >> sjis + crlf + katakana_hiragana).to_s, 'ソフトウェア的愛情')
#=> "\x{82BB}\x{82D3}\x{82C6}\x{82A4}\x{82A5}\x{82A0}\x{9349}\x{88A4}\x{8FEE}"

そして Ruby には文字列への暗黙の変換を可能にする仕組みが用意されています。

docs.ruby-lang.org

文字列への変換としてすでに #to_s を定義しているので、#to_str はこれのエイリアスとすることにします。

class NKF_OPT
  # ...
  alias to_str to_s
  # ...
end

これで式を NKF.nkf のオプションとして渡すことができるようになりました。

NKF.nkf(utf_8 >> sjis + crlf + katakana_hiragana, 'ソフトウェア的愛情')
#=> "\x{82BB}\x{82D3}\x{82C6}\x{82A4}\x{82A5}\x{82A0}\x{9349}\x{88A4}\x{8FEE}"

おまけ

ここまで来たのなら、オプションの式に文字列を流し込んで変換できるようにしてみます。

#<< を定義してその中で NKF.nkf を呼び出すようにしてみます。

class NKF_OPT
  # ...
  def <<(string)
    NKF.nkf(self, string)
  end
  # ...
end

これでオプションで組み立てた式に << で文字列を流し込むことで変換できる、変態的な記述が可能になりました。

utf_8 >> sjis + crlf + katakana_hiragana << 'ソフトウェア的愛情'
#=> "\x{82BB}\x{82D3}\x{82C6}\x{82A4}\x{82A5}\x{82A0}\x{9349}\x{88A4}\x{8FEE}"

余談

Factor に触れたとき、例えば数値を文字列へ変換する関数が number>string という名前になっていることを知りました。 他の言語ではあまり見かけない名前です。

number>string ( n -- str ) - Factor Documentation

10 number>string

このとき同時に頭に浮かんだのが、C++ の Boost でした。 Boost のパーサライブラリは演算子を使った変態ライブラリとして有名です。

group       = '(' >> expression >> ')';
factor      = integer | group;
term        = factor >> *(('*' >> factor) | ('/' >> factor));
expression  = term >> *(('+' >> term) | ('-' >> term));

ドキュメントから抜粋)

すぐに頭に浮かんだのですから「演算子を再定義してうまいことやる」機会がないか、どこかで思っていたのでしょう。

思いつきで書いたので、この記事を書くにあたって読み返してみると色々と拙いところが目につくんですが、意外に悪くないかもという気分になっています。

いつか読むはずっと読まない:Big 5

古生物学にはなぜか惹かれます。 今とは違う姿の生物たちが気になるのか、あるいは姿を変えつつも生命が現在まで繋がっていることが気になるのか。