何をしようとしているか
関数型プログラミング言語にて、データ列を持つモジュールを作り、そこにtoString
関数を作ろうとしています。
例えば、以下のようなシグネチャーを持つモジュールになります。
signatureMY_MODULE=sigtypetvaltoString:t->stringend
よくやる畳み込みを使った解法とその問題点
このとき、自作モジュールのデータ列は、リストを使って実装するとします。
すると、以下のようにリストの畳み込み関数foldr
と、文字列結合関数^
を使って、toString
書くと見通しが良さそうです。
リストの他にも、array
やvector
などでも同様です。
typeelem=...(*何らかの要素 *)typet=elemlistfuntoStringdatas=foldr(fn(e,acc)=>(elemToStringe)^acc)""datas
例えば、[A, B, C, D]
のようなデータがあれば、ざっくり言うと、以下のように展開されながら動きます。foldr
を使って実装したので、右から処理されていきます。
toString [A, B, C, D]
=> "A" ^ ("B" ^ ("C" ^ ("D" ^ "")))
=> "ABCD"
しかし、このコードはとても遅いです。
具体的には、入力のデータの長さnに対してO(n^2)時間かかります。
なぜ畳み込みと文字列結合を組み合わせると遅いのか
SMLの文字列はイミュータブルであり、文字列の実体はchar vector
です。
そのため、文字列結合関数^
は、新たな文字列を生成して返します。
このような前提の上で、"X" ^ "Y"
のアルゴリズムは、例えば以下のようになります。1
- "X"の長さと"Y"の長さの合計の幅のバッファを確保する
- バッファの前半に"X"をコピーする
- バッファの後半に"Y"をコピーする
- バッファを結合された文字列として返す
よって、toString
関数の実行中に、途中生成される文字列は以下になります。
""
"D"
"D"
"C"
"CD"
"B"
"BCD"
"A"
"ABCD"
はい。
明らかに無駄な文字列が大量に生成されています。
この関数は、入力のデータの長さnに対して、文字のコピー回数がO(n^2)起こるため、遅いです。
解法
string
の実体はchar vector
のため、
徐々にデータが大きくなるようなループに使うと、
一時変数を作るコストがとても高いです。
よって、同じような場合でも、一時変数を作るコストが低いデータ構造を考えます。
これには、例えばリストがあげられます。
ここではリストを一時変数に使うことのコストの低さの説明のために、1 :: [2, 3]
のアルゴリズムを考えてみましょう。
ここで、[2, 3]
はSMLでのリストを表します。
1
を値に持つコンスセルを作る1
のコンスセルのポインタを、リスト[2, 3]
の参照にする1
のコンスセルの参照を、新たなリストとして返す
このように、リストのcons(::
)は、
新たなリストの生成時に元のリストを使いまわします。
それでは、一時変数にリストを使って先のtoString
関数を書き直してみます。
幸いにも、Standard ML Basis Libraryには、CharVector.fromList : char list -> string
という関数があります。
これを使い、char list
型で構築して、最後に文字列に変換すれば良いです。
funtoStringdatas=letvalcharList=foldr(fn(e,acc)=>(elemToChare)::acc)nildatasinCharVector.fromListcharListend
このtoString
関数は、ざっくり言うと、以下のように展開されながら動きます。
なお、#"A"
はSMLでのchar型のAを表します。
toString [A, B, C, D]
=> CharVector.fromList (#"A" :: #"B" :: #"C" :: "D" :: nil)
=> "ABCD"
このとき、生成されるデータは以下です。
#"D"
#"C"
#"B"
#"A"
[#"A", #"B", #"C", #"D"]
今度は無駄なデータの生成がなくなりました。2
実行時間の差の計測
実際のコンパイラで差が出るのかを確認します。
今回は、AtCoderなどでも利用されているMLtonコンパイラと、自分が好きなSML#コンパイラを使って、速度差が出るかを検証しました。
使ったテストコードを以下に示します。
このテストコードでは、長さ10,000のテストデータを作り、文字列へ変換してプリントします。
時間の計測にはtimeコマンドを使います。
問題のあるテストコード
structureS:sigdatatypeelem=A|BtypetvaltoString:t->stringend=structdatatypeelem=A|Btypet=elemlistfunelemToStringA="A"|elemToStringB="B"funtoStringdatas=foldl(fn(e,acc)=>(elemToStringe)^acc)""datasendfunmain()=let(* n個のAを持つデータをプリントする *)valn=10000valinput:S.t=List.tabulate(n,fn_=>S.A)valformat=S.toStringinputinprintformatendval()=main()
改善したテストコード
structureS:sigdatatypeelem=A|BtypetvaltoString:t->stringend=structdatatypeelem=A|Btypet=elemlist(* char listにしてからCharVector.fromListでstringにする *)funelemToCharA=#"A"|elemToCharB=#"B"funtoStringdatas=letvalcharList=foldr(fn(e,acc)=>elemToChare::acc)nildatasinCharVector.fromListcharListendendfunmain()=let(* n個のAを持つデータをプリントする *)valn=10000valinput:S.t=List.tabulate(n,fn_=>S.A)valformat=S.toStringinputinprintformatendval_=main()
計測結果
MLtonコンパイラでもSML#コンパイラでも如実に時間差が見られます。
特にMLtonコンパイラでは時間差が顕著となりました。
$mlton
MLton 20130715 (built Fri Apr 28 06:06:34 UTC 2017 on lcy01-11)
$make &&time ./test1 > /dev/null &&time ./test2 > /dev/null
mlton test1.sml
mlton test2.sml
real 0m0.123s
user 0m0.118s
sys 0m0.000s
real 0m0.002s
user 0m0.000s
sys 0m0.000s
$smlsharp
SML#3.5.0 (2019-12-24 17:29:31 JST)for x86_64-pc-linux-gnu with LLVM 7.0.0
$make &&time ./test1 > /dev/null &&time ./test2 > /dev/null
smlsharp -c -O2 test1.sml
smlsharp test1.smi -o test1
smlsharp -c -O2 test2.sml
smlsharp test2.smi -o test2
real 0m0.030s
user 0m0.013s
sys 0m0.026s
real 0m0.011s
user 0m0.014s
sys 0m0.000s
今回しなかった話
今回は例が簡単なデータ構造のため、一方向からの挿入さえ早ければよく、一時変数はリストで十分でした。
より複雑なデータ構造になり、複数の結合や削除を行う場合には、ツリー構造など適したものを選定する必要があります。
使う言語がBufferやSeqやArrayのようなものを持っており、そこから文字列を構築する方法があれば、より簡単に
まとめ
- 文字列は素朴なデータではないので、再帰などのループで扱うときには注意が必要
^
をfold
などのループ内で使ってはいけない- コストの低い一時変数を選び、最後に結合する方法を使う
追記
MLtonとかSML#の標準ライブラリを眺めた感じだと、stringを作りたいときはリストに詰めておいてString.concatするのが速いっぽい(MLtonだとcocatは最初に必要なだけ領域を確保してunsafeCopyで埋めてくれる https://t.co/4b8QJPWclh
— でこれき (@dico_leque) February 19, 2020
CharVector.fromList
や同じ動作をするString.implode
以外にも便利な関数が。
SML#の実装も眺めて見ましたが、やはりMLtonと同じく先にバッファを確保するため効率がよいです。String.concat : string list -> string
も活用していきましょう。
正確には、リスト
[#"A", ... , #"D"]
の各要素も共有されており、新たな#"A"
生成はされません。新たに生成されるデータは、このリストを表現するためのコンスセルになります。 ↩