TL;DR
- Clojure には
forループも直接変更できる変数もない。 - Apache Commons の
StringUtils.indexOfAnyを、indexedとindex-filterを組み合わせて Clojure で書き直すと、分岐や変数が減り、どんなシーケンスと述語にも使える汎用的なコードになる。
内容
Clojure には for ループがないし、直接変更できる変数というものもない。間接的に変更できるリファレンスはあるが、それを変更するには特別な関数呼び出しが必要になる。ここでは Java の OSS(Apache Commons)を例に説明する。
StringUtils.indexOfAny
public static int indexOfAny(String str, char[] searchChars) {
if (isEmpty(str) || ArrayUtils.isEmpty(searchChars) {
return -1;
}
for (int i = 0; i < str.length(); i++) {
char ch = str.charAt(i);
for (int j = 0; j < searchChars.length; j++) {
if (searchChars[j] == ch) {
return i;
}
}
}
return -1;
}indexOfAny は str を順番に調べ、searchChars の中のいずれかの文字とマッチするものが見つかったらそのインデックスを返し、見つからなければ -1 を返す。
ドキュメントから、いくつかの実行例を挙げる。
StringUtils.indexOfAny(null, *); // -1
StringUtils.indexOfAny("", *); // -1
StringUtils.indexOfAny(*, null); // -1
StringUtils.indexOfAny(*, []); // -1
StringUtils.indexOfAny("zzabyycdxx", ['z', 'a']); // 0
StringUtils.indexOfAny("zzabyycdxx", ['b', 'y']); // 3
StringUtils.indexOfAny("aba", ['z']); // -1これを、Clojure 版の index-of-any としてステップバイステップで実装する。
indexed 関数の実装
まず、コレクションを取って各要素にインデックスを付けたものを返す indexed 関数を作る。
(defn indexed [coll]
(map-indexed vector coll))indexed は [インデックス 要素] のペアのシーケンスを返す。
(indexed "abcde")
; ([0 \a] [1 \b] [2 \c] [3 \d] [4 \e])index-filter 関数の実装
次に、文字列中の文字のうち、探すべき文字の集合にマッチするもののインデックスを得る。index-filter 関数は Clojure の filter に似ているが、マッチした要素そのものではなくインデックスを返す。
(defn index-filter [pred coll]
(when pred
(for [[idx elt] (indexed coll) :when (pred elt)] idx)))Clojure の for はループではなく、シーケンスの内包表記である。(indexed coll) が生成したインデックスと要素のペアは、(pred 要素) が真であるときだけ、それぞれ idx と elt に束縛される。
Clojure の集合は引数をメンバに含むかどうかの判定関数にもなる。そのため、文字の集合と文字列を index-filter に渡すと、文字列の中で文字の集合に含まれる文字のインデックスのリストが返ってくる。
(index-filter #{\a \b} "abcdbbb") ; (0 1 4 5 6)
(index-filter #{\a \b} "xyz") ; ()index-of-filter 関数の実装
今必要なのは最初のインデックスだけなので、index-filter の最初の結果だけを取ればよい。
(defn index-filter-any [pred coll]
(first (index-filter pred coll)))この関数に色々な入力を与えて試す。
(index-filter-any #{\z \a} "zzabyycdxx") ; 0
(index-filter-any #{\b \y} "zzabyycdxx") ; 3Clojure 版と Java 版のコードの違い
Clojure 版は、命令型のコードよりもどう比べても簡潔である。
| 行数 | 分岐 | 1 メソッドあたりの出口 | 変数 | |
|---|---|---|---|---|
| Java 版 | 14 | 4 | 3 | 3 |
| Clojure 版 | 6 | 1 | 1 | 0 |
この違いが生まれた理由は次のとおり。
- 命令型の
indexOfAnyは、文字列が空の場合、集合が空の場合、最終的にひとつもマッチしなかった場合といった、いくつかの特殊なケースを扱う必要がある。これにより、分岐や複数の出口ができてしまう。関数型のアプローチでは、こういった特殊なケースを扱わなくても動作する。厳密には、index-of-anyはマッチしないときにnilを返すのでindexOfAnyと異なる。しかし、「マッチしなかった」ことを示すのに-1を返すよりも、nilを返すほうが Clojure には合っている - 命令型の
indexOfAnyは、コレクション(文字列と文字の集合の両方)を順に見ていくためにローカル変数を必要とする。Clojure では、mapのような高階関数とforのようなシーケンス内包表記を使うので、index-of-anyは変数を必要としない
また、Clojure 版のコードのほうが、より汎用的である。Java にも関数型やジェネリクスの機能が取り入れられているので、次の点はすべて実現しようとすれば実現できるが、記述が複雑になる。
indexOfAnyは文字列しか探せないが、index-of-anyはどんなシーケンスでも受け取れるindexOfAnyは文字の集合からマッチを探すが、index-filterはどんな述語も取れるindexOfAnyは最初のマッチだけを返すが、index-filterはすべてのマッチを返し、他のfilterと組み合わせられる
index-of-any の汎用性を示すために、たとえばコインを投げて裏(:t)か表(:h)かを記録した列から、「3 度目の表は全体のうち何回目に出たか」を調べるコードを書いてみる。
(nth (index-filter #{:t} [:t :t :h :t :h :t :t :t :h :h]) 2) ; 3