sci

最果て風呂

mto を Elixir で実装してみる

関数型言語を触ろうと思い続けて何年も経ってしまいました。今回は Ruby っぽい書き味という噂の Elixir を試してみることにしました。関数型の中でも非純粋・動的型付けなので、手続型思考になっている自分には取っ付き易いかも?

環境変数の取得

irb の仲間、iex で確認していきます。まずは環境変数の取得から。System.get_env() を使うとのこと。文字列の結合には <> が使えるとのことなので、次のようにして辞書の場所を確定します。

System.get_env("MTODIC") <> "/kana-jisyo"

引数処理

引数の取得は System.argv() で、配列で返ってきます。配列の個数を調べるには Enum.count() が使えるので、Enum.count(System.argv()) のようにして個数を得ました。length(System.argv()) でも得ることができます。

ファイルの読み込み

典型的な雛形があるのでパク……参考にします。パターンマッチングを使っているらしいのですが、とりあえず「タプルの value に入ってる」という認識で進みます。

defmodule Mto do
  # 一気にファイルを読み込む
  def readFile(text) do
    {:ok, file} = File.read text
    IO.write file
  end
end
defmodule Mto do
  # 一行ずつファイルを読み込んで何かの処理をする
  def readFile(text) do
    {:ok, file} = File.open(text, [:read, :utf8])
    Enum.each(IO.stream(file, :line), fn(line) ->
      # 何かをする
      IO.inspect line
    end)
  end
end

正規表現

(~r/hoge/) を使うとのこと。これはそれぞれのモジュールで使い方が変わるようですが、名前が「それらしい」のでドキュメントでも調べやすいです。

内部辞書を作成する

ワンパターンですが、いつものようにファイルを一行ずつ読み込んで整形し、内部辞書を作成していきます。命令の列挙でおもいっきり手続型になっています。一応これで期待した文字列の出力はできるのですが、データ型として保持できないのでした。

defmodule Mto do
  # 一行ずつファイルを読み込む
  def createDict(dictpath) do
    {:ok, buf} = File.open(dictpath, [:read, :utf8])
    Enum.each(IO.stream(buf, :line), fn(line) ->
      # コメント行かどうか調べて、そうでなければ do 内を実行
      unless Regex.match?(~r/^;.*|^\n/, line) do
        # 備考の部分を削除
        string = Regex.replace(~r/\s+;.*\n/, line, "")
        # セパレーターで区切って配列に
        pairs = Regex.split(~r/\s* \/\s*/, string)
        # ここで配列の配列にしたいのだが...
        IO.inspect pairs
      end
    end)
  end
end
IO.write Mto.createDict(dictpath)

グローバル変数が無かったり変数への再代入ができないので、今までの手法は使うことができないのです。思考の転換が必要です。

繰り返し

Enum モジュールにたくさんあって何を使えば良いのか……。ファイルの読み込みの雛形で使われている Enum.each しか目に入りません。が、肝心の内部辞書を作成できていないので、回そうにも回せません。

悩む…

日記を書きながら気がついたのですが、Emacs Lisp でやった手法を試してみることにしました。それは次のようなものです。

ファイルをバッファに読み込んでそれを正規表現でコンスセルリストになるように整形して、最後に eval して Emacs 内に取り込むことにより辞書として使えるようにする。

実に泥臭い手法ですが、次のようにして何とか (自称) 内部辞書を生成することができました。ここに辿り着くまでにかなりの時間を要してしまいました。

defmodule Mto do
  def createDictNew(dictpath) do
    {:ok, file} = File.read dictpath
    buf = Regex.replace(~r/;.*/, file, "")

    # コメント行・備考を削除する
    tmp1 = Regex.replace(~r/ \//, buf, ",")
    #IO.inspect tmp1

    # 改行を「単語組」の区切りとする
    tmp2 = Regex.replace(~r/ +\n|\n+/, tmp1, ";")
    #IO.inspect tmp2

    # 先の区切りが複数になっているものを 1 つにする
    tmp3 = Regex.replace(~r/;+/, tmp2, ";")
    #IO.inspect tmp3

    # 余分な両末端の ';' を削除する
    tmp4 = Regex.replace(~r/^;|;$/, tmp3, "")
    # この時点でリストになっているはず
    #IO.inspect String.split(tmp4, [";"])

    ##tmp5 = List.first(Regex.split(~r/;/, tmp4))
    ## リストになっていればエラーにならず最初の要素が表示できるはず
    ##IO.inspect tmp5
    ##
    ##sampletext = "こんにちは、たなこふさん。こんにちは?"
    ## 「こんにちは」を「こんにちわ」へ置換することができるはず
    ## OK。デフォルトで s///g の挙動みたい
    ##IO.inspect String.replace(sampletext,
    ##  List.first(Regex.split(~r/,/, tmp5)),
    ##  List.last(Regex.split(~r/,/, tmp5)))
    ##
    ## IO.puts "Enum で"
    ## Enum.each(String.split(tmp4, [";"]),
    ##   fn(x) ->
    ##     #IO.inspect x
    ##     Enum.each(String.split(x, [","]), fn(x) ->
    ##       IO.inspect x
    ##     end)
    ##   end)

    _ = pairList(String.split(tmp4, [";"]))
  end

  # ドキュメントの Recursion のところにあったサンプルを参考にする。
  # map っぽい動きをする。2 つでセットなのだ。
  def pairList([head|tail]) do
    [String.split(head, [","]) | pairList(tail)]
  end

  def pairList([]) do
    []
  end
end
innerdict = Hoge.createDict(dictpath)
IO.inspect innerdict

dictpath として次の内容のファイルを読み込ませると

;; これはコメント行
;; 複数のコメント行
こんにちは /こんにちわ ;これは備考
こんばんは /こんばんわ ;これは備考
たなこふ /(´・_・`) ;これは備考
;; 突然のコメント行!続けて空行や備考なしのものを入れたり

くどう /せやかて
;-----
ありがとう /おおきに  ;これは備考(空白がたくさんあるよ)

次のような配列を得ることができます。

[
  ["こんにちは", "こんにちわ"],
  ["こんばんは", "こんばんわ"],
  ["たなこふ", "(´・_・`)"],
  ["くどう", "せやかて"],
  ["ありがとう", "おおきに"]
]

また悩む……

そして、この辞書を使って読み込んだ文字列を順次置換していくということをしたいのですが、やはり置換した文字列の再代入ができないので手詰りになります。

sampletext = "こんにちは、たなこふさん。こんにちは?"
Enum.each(innerdict, fn(x) ->
  [first|[second|_]] = x
  IO.puts = String.replace(sampletext, first, second)
end)

期待している出力は一番最後の出力で

こんにちわ、(´・_・`)さん。こんにちわ?

なのですが、当然のように

こんにちは、(´・_・`)さん。こんにちは?

となってしまいます。毎回 "こんにちは、たなこふさん。こんにちは?" に対して処理をするのであって、置換処理をした後の "こんにちわ、たなこふさん。こんにちわ?" に対してではないからです。

余談ですが、上記無名関数のマッチを [head|tail] とするのではエラーになってしまいます。2 要素リストのリストなのですが、2 個目の値は「cdr の car」として取り出さないといけないのです。ですから [first|[second|_]] でないとうまくいきません。((a . b) (c . d)) となっているのではなく、((a b) (c d)) となっているからなのですね。

ここも再帰でやらないとダメなんでしょうね……。

ちなみに本番の辞書を読み込ませて IO.inspect length(innerdict) で要素の数を確認してみると、数字が合いませんでした。そのため、Print デバッグをしながら何度も何度も正規表現の部分を調整しました。

光明

日本語では情報が少ないので、'Elixir string replace enum' として検索すると、StackOverflow にドンピシャな回答例がありました。下記のようにすると、期待する文字列 "こんにちわ、(´・_・`)さん。こんにちわ?" を得ることができるようになりました。

辞書の要素すべてを回すのだから each !という固定観念が悪さをしていました。reduce を使うのですね!(each でも実現できるのでしょうけれど、それは追い追い)。

IO.write Enum.reduce(innerdict, sampletext, fn([first|[second|_]]), str ->
  String.replace(str, first, second) end)

部品が揃いましたのであとは組み立ててスクリプトを完成させるだけです。思考の転換が必要でかなり苦労しました。まだ少ししか触っていないので続けていきたいと思います。

追記

パイプライン演算子というものを知りまして……。これはシェルの | と同じように前の処理の結果を受けて次の処理に渡すというものだそうです。(今のわたしにとって) 気をつけるところは「続く関数の '第一引数' に渡すことができる」という部分です。

スクリプトを作成している時から createDict/1 関数内で tmp1 のような一時的な変数をたくさん使っていることが気になっていました。これをなんとかできそうです。

elixir forum や Stack Overflow を調べてみましたが、関数の任意の引数位置へは渡せないみたいです。createDict/1 内では Regex.replace/4 を多用しているのですが、これは第二引数に渡せないと |> を使えないのでした。

String.replace/4 でも正規表現を使った置換ができることに (今さらながら) 気づきました。これだと第一引数に渡せれば良いので、パイプライン演算子を使えることになります。

ということで何度か試行錯誤をして修正完了です。

  def createDict(jisyo) do
    {:ok, file} = File.read jisyo
    buf = String.replace(file, ~r/;.*/, "")  # コメント行・備考を削除
    |> String.replace(~r/ \//, ",")          # 「単語組」を作る
    |> String.replace(~r/\s+\n|\n+/, ";")    # 改行を「単語組」の区切りに
    |> String.replace(~r/;+/, ";")           # 複数の ';' を 1 つにする
    |> String.replace(~r/^;|;$/, "")         # 余分な両末端の ';' を削除
    _ = pairList(String.split(buf, [";"]))
  end