CLOJURE for the BRAVE and TRUEでClojure学習 part2

はじめに

Clojure の学習備忘録。 今回は、Chapter 4: Core Functions in Depth の途中まで。

Sequence とはなにかを説明した章であり、map, reduce といったいくつか重要な Sequence 関数が紹介される。

js と違って、
Clojure が hashMap も sequence として扱われる点は面白い。

Programing to Abstractions

Clojure の重要な概念に、抽象化プログラミングがある。抽象化プログラミングではない Emacs Lisp(elisp)の場合、 mapcar 関数を使えば、Clojuremap と同じように新しいリストを生成できる。しかし、elisp でハッシュマップに対して map 関数を適用したい場合、 maphash 関数が必要になる。一方 Clojure の場合、リストであれハッシュマップであれ、 同じ map 関数ですませられる。

elisp では同じ map 操作でもデータの構造に応じて異なる関数を使用しないといけないが、Clojure の場合ひとつだけでよいのである。 reduce 関数の場合でも同じである。

というのも Clojure は、データ構造ではなく、 抽象化シーケンス という観点で mapreduce 関数を定義しているからなのである。

あとで紹介する first, rest, cons といったシーケンスのコアとなる操作が適用できるデータ構造であれば、mapreduce その他シーケンス関数をいつでも Clojure は適用可能である。これこそ Clojure が意味する抽象化プログラミングであり、Clojure 哲学の中心的な考え方である。

それでは map 関数を例に抽象化シーケンスについてもう少し詳しくみてみよう。

Treating Lists, Vectors, Sete, And Maps as Sequences

特定のプログラミング言語関係なく map 操作の中心的な振る舞いを考えると、それは y1 = f(x1), y2 = f(x2), yn = f(xn)のように x というシーケンスから y の新しいシーケンスを生成することである。

ここでいうシーケンスというのは、順番に要素が並べられている集合や、あるいは要素に順番がない集合のことであったり、あるいは要素同士が前後関係をもった集合も考えられる。

map 操作やシーケンスの説明では、リスト、ベクタ、その他具体的なデータ構造がどうなのかをはっきり示さない。このようにできるだけ抽象的に考えプログラミングするよう Clojure は設計され、データ構造の抽象化という観点で関数が実装されている。

シーケンスのコアとなる関数 first, rest, cons が適用できるならば、 そのデータ構造は抽象化シーケンスを実現しているといえる。
リストや、ベクタ、セット、ハッシュマップは、みな抽象化シーケンスであるので、 map 関数を適用できるのである。

(defn titleize
  [topic]
  (str topic " fro the Brave and True"))
(map titleize ["Hamsters" "Ragnarok"])
(map titleize '("Empathy" "Decoratting"))
(map titleize #{"Elbows" "Soap Carving"})
(map #(titleize (second %)) {:uncomfortable-thing "Winking"})

はじめの2つは、ベクタやリストに map を適用している。
3つめは、順序のないセットに、4つめはハッシュマップに map が適用してる。

first ,rest, and cons

このセクションでは、Javascript で、リンクリストと3つの関数(first, rest, cons)を実装する。これら3つの関数を使って、 map 関数の組み立て方を説明する。

重要なのは、リンクリストを使った具体的な実装が、Clojure のおけるシーケンス抽象化とどのように異なるのか理解することであり、 特定のデータ構造における実装がどうなのかに着目しなくてよい。

あるデータ構造に対してシーケンス関数が適用できるかは、 first, rest, cons 関数が適用できるかどうかであり、 もし適用できるならば、そのデータ構造にはシーケンスライブラリが適用できる。

リンクリストとは、各ノードがリンクして一列のシーケンスになってるものである。

ここでは、javascript でリンクリストを実装する。
node3 はリンクリストの最後の要素で、next は null になる。

var node3 = {
  value: "last",
  next: null,
};

node2 の next は、node3 を node1 の next は、node2 を参照しており、 以上でリンクリストとなってる。

var node2 = {
  value: "second",
  next: node3,
};

var node1 = {
  value: "first",
  next: node2,
};

このリンクリストに対して 3 つの関数(first, rest, cons)を実装する。

各関数の内容は以下の通り first は、受け取った node の value を返し、
rest は、受け取った node のあとに続く value を返し、
cons は、新しい node をリストの先頭に追加する。

そしてこれら3つが実装できると、
それらを組み合わせて、 map, reduce, filter やその他シーケンス関数が実装できるようになる。

注目してほしいのは、 first, rest の引数名が node になっていることだ。

var first = function (node) {
  return node.value;
};

var rest = function (node) {
  return node.next;
};

var cons = function (newValue, node) {
  return {
    value: newValue,
    next: node,
  };
};

first(node1);
// => "first"

first(rest(node1));
// => "middle"

first(rest(rest(node1)));
// => "last"

var node0 = cons("new first", node1);
first(node0);
// => "new first"

first(rest(node0));
// => "first"

では、準備が整ったので 3つの関数を使って、 map を実装してみる。

var map = function (list, transform) {
  if (list === null) {
    return null;
  } else {
    return cons(transform(first(list)), map(rest(list), transform));
  }
};

map 関数は、リストの1つ目の要素を変換して、残りのリストに対して null が返すまで再帰的に適用していく。
全要素に "mapped!" を文字列追加させて、1つ目の要素取得するならば次のとおりになる。

first(
  map(node1, function (val) {
    return val + " mapped!";
  })
);

// => "first mapped!"

いけてるのは、 map 関数が first, rest, cons だけで実装されているということである。
どんなデータ構造であっても、 first, rest, cons が適用できるならば、 map は機能するのだ。

実際リンクリストだけではなく、配列でも map が機能することを確認しよう。

var first = function (array) {
  return array[0];
};

var rest = function (array) {
  var sliced = array.slice(1, array.length);
  if (sliced.length == 0) {
    return null;
  } else {
    return sliced;
  }
};

var cons = function (newValue, array) {
  return [newValue].concat(array);
};

var list = ["Transylvania", "Forks, WA"];
map(list, function (val) {
  return val + " mapped!";
});
// => ["Transylvania mapped!", "Forks, WA mapped!"]

ここでも、Javascript の Array 関数をつかって、 first, rest, cons を実装してる。
map 関数は、新しく定義された first, rest, cons を参照するようになるので、 array でも機能する。

これで first, rest, cons が実装できれば、 map やその他のシーケンス関数が自由に使用できることがわかるだろう。

Abstraction Through Indirection

しかし、 first 関数がどんなデータ構造でも適用できる説明がないので、上記説明では不十分である。Clojure では、これを2つの間接的な方法で実現している。

プログラミングの世界で間接というのは、1 つの名前が複数の関連した意味を持つという意味でよく使用される。この場合も、 first は複数の、データ構造に依存した意味を持っている。間接は、抽象化を可能にする。

ポリモーフィズムClojure が提供する間接の1つの手段である。詳細は割愛するが、ポリモーフィズム関数は、引数のタイプに応じて異なる機能を提供できる(引数の個数で別の機能を提供するマルチ Arity 関数とは違う) この点については、Chapater13 章で紹介する。

シーケンスに関しては、Clojure はもっと簡単な変換を通じて抽象的な関数が適用できるデータ構造を生成し、間接を実現している。seq 関数を使うのだ。

(seq '(1 2 3))
; => (1 2 3)

(seq [1 2 3])
; => (1 2 3)

(seq #{1 2 3})
; => (1 2 3)

(seq {:name "Bill Compton" :occupation "Dead mopey guy"})
; => ([:name "Bill Compton"] [:occupation "Dead mopey guy"])

ここでは、2 点注目することがある。
1つ目は、 seq 関数は、リストのように振る舞う値を必ず返す(これをシーケンスあるいは seq と呼ぶ)。
2つ目は、ハッシュマップに対して seq を適用すると、key, value を2要素からなるシーケンスを返す。 このため、ハッシュマップでもベクタのように map 関数を適用できるのである。4つ目の例がそれである。

into 関数を使えば、seq から map に戻すことができる。

(into {} (seq {:a 1 :b 2 :c 3}))
; => {:a 1, :c 3, :b 2}

以上から Clojure のシーケンス関数は、引数に seq 関数を適用している。抽象化シーケンスが実装されたデータ構造であれば、 reduce, filter, distict, group-by といった有名な関数が適用できるわけだ。

Seq Function Examples

map

今まで map 関数を見てきたので、今回は map の新しい機能2つを紹介する。 1つは複数のコレクションを引数に取る場合であり、もう一つは関数コレクションを引数に取る場合である。 またよく map で使う keyword マッピングも紹介する。

今まで見てきた map 関数は、次のようにひとつのコレクションを引数にとるもの。

(map inc [1 2 3])

しかし、複数のコレクションを map にわたすことができ、こんな感じである。

(map str ["a" "b" "c"] ["A" "B" "C"])

この処理は、次の処理とやってることおなじである。

(list (str "a" "A") (str "b" "B") (str "c" "C"))

コレクションの同じインデックスのものが、 str に渡されてマッピングされていく。コレクションの要素数が同じでないといけないことには注意しよう。

別の例でもみてみよう。1つは、過去4日間の人間から摂取した血のリットル数で、もうひとつは動物から摂取した血のリットル数のベクタである。 unify-diet-data 関数は、人間・動物の両方から一日の摂取量を引数にとり、ひとつのハッシュマップにまとめる関数である。

(def human-consumption   [8.1 7.3 6.6 5.0])
(def critter-consumption [0.0 0.2 0.3 1.1])
(defn unify-diet-data
  [human critter]
  {:human human
    :critter critter})
(map unify-diet-data human-consumption critter-consumption)

今度は、 map 関数コレクションを引数にとったときの処理についてみてみる。この機能を使えば、ひとまとめの計算処理が可能になる。

(def sum #(reduce + %))
(def avg #(/ (sum %) (count %)))
(defn stats
  [numbers]
  (map #(% numbers) [sum count avg]))

(stats [3 4 10])
; => (17 3 17/3)
(stats [80 1 44 13 6])
; => (144 5 144/5)

ここでは, stats 関数がベクタに可能のされた関数を numbers に対して一つずつ適用している。

Clojure 使いがよく map で使う、ハッシュマップコレクションから特定の key の value を取得する処理についても紹介する。

(def identities
  [{:alias "Batman" :real "Bruce Wayne"}
    {:alias "Spider-Man" :real "Peter Parker"}
    {:alias "Santa" :real "Your mom"}
    {:alias "Easter Bunny" :real "Your dad"}])

(map :real identities)
; => ("Bruce Wayne" "Peter Parker" "Your mom" "Your dad")

reduce

Chapter3 で reduce について紹介したが、ここではまた別の使い方を紹介する。

ひとつめは、ハッシュマップを受けおり、key はそのまま value を更新する処理である。

(reduce (fn [new-map [key val]]
          (assoc new-map key (inc val)))
        {}
        {:max 30 :min 10})
; => {:max 31, :min 11}

引数に受け取った {:max 30 :min 10} は、 ([:max 30] [:min 10]) として reduce 内部で使用されるのでうまくいくわけだ。

もうひとつの reduce の便利な使い方は、value に応じて key をフィルターするものである。次の例では、匿名関数を利用して value が 4 より大きいかどうかを判別し、小さい場合はカットしてる。だから 3.9 の key はなくなる。

(reduce (fn [new-map [key val]]
          (if (> val 4)
            (assoc new-map key val)
            new-map))
        {}
        {:human 4.1
          :critter 3.9})
; => {:human 4.1}

お持ち帰りしてほしいポイントは、 reduce 関数が初見よりもずっと柔軟な関数であるということである。reduce をつかって、map, filter, some と同じ処理ができるように実装してみてもよいだろう。

take, drop, take-while, and drop-while

take, drop はともに number と collection の2つの引数を受け取る。 take は 1 から n 番目までの要素を取得するのに対して、 drop では 1 から n 番目を除いた残りの要素を返す。

(take 3 [1 2 3 4 5 6 7 8 9 10])
; => (1 2 3)

(drop 3 [1 2 3 4 5 6 7 8 9 10])
; => (4 5 6 7 8 9 10)

姉妹関数の take-while, drop-while はよりおもしろい挙動をする。ともに返り値が真偽値である述語関数を受け取り、それを使って take あるいは drop を続けるかどうかを判断する

たとえば、ベクタ表記された食事日誌でしてみる。日誌は、月・日ごとに食したものが記録されている。

(def food-journal
  [{:month 1 :day 1 :human 5.3 :critter 2.3}
    {:month 1 :day 2 :human 5.1 :critter 2.0}
    {:month 2 :day 1 :human 4.9 :critter 2.1}
    {:month 2 :day 2 :human 5.0 :critter 2.5}
    {:month 3 :day 1 :human 4.2 :critter 3.3}
    {:month 3 :day 2 :human 4.0 :critter 3.8}
    {:month 4 :day 1 :human 3.7 :critter 3.9}
    {:month 4 :day 2 :human 3.7 :critter 3.6}])

匿名述語関数である #(< (:month %) 3) をつかって、1, 2 月の日誌データの取得できる。

(take-while #(< (:month %) 3) food-journal)
; => ({:month 1 :day 1 :human 5.3 :critter 2.3}
;     {:month 1 :day 2 :human 5.1 :critter 2.0}
;     {:month 2 :day 1 :human 4.9 :critter 2.1}
;     {:month 2 :day 2 :human 5.0 :critter 2.5})

take-while は、3 月の日誌データを読み取ると、匿名関数が false を返し、それ以降の処理をやめるのである。

同じような考え方で、 drop-whiletrue である限り要素を drop する。

(drop-while #(< (:month %) 3) food-journal)
; => ({:month 3 :day 1 :human 4.2 :critter 3.3}
;     {:month 3 :day 2 :human 4.0 :critter 3.8}
;     {:month 4 :day 1 :human 3.7 :critter 3.9}
;     {:month 4 :day 2 :human 3.7 :critter 3.6})

take-while, drop-while の両方を使用すれば、2, 3 月のデータを取得できる

(take-while #(< (:month %) 4)
            (drop-while #(< (:month %) 2) food-journal))
; => ({:month 2 :day 1 :human 4.9 :critter 2.1}
;     {:month 2 :day 2 :human 5.0 :critter 2.5}
;     {:month 3 :day 1 :human 4.2 :critter 3.3}
;     {:month 3 :day 2 :human 4.0 :critter 3.8})

filter and some

filter 関数を使えば述語関数が true を返すシーケンス内の全要素がえられる。たとえば、先程の食事日誌を使って、人間から摂取したリットル数が、5 未満のものをえよう。

(filter #(< (:human %) 5) food-journal)

; => ({:month 2 :day 1 :human 4.9 :critter 2.1}
;     {:month 3 :day 1 :human 4.2 :critter 3.3}
;     {:month 3 :day 2 :human 4.0 :critter 3.8}
;     {:month 4 :day 1 :human 3.7 :critter 3.9}
;     {:month 4 :day 2 :human 3.7 :critter 3.6})

これと同じ結果を、 take-while でも実現できるが、 take-while のほうが効率はよい。なぜなら、filter の場合全要素をなめるに対して、~take-while~ では述語関数が false を返した途端処理を止めるからである。

述語関数が true を返す要素を、コレクションが含むかどうかを知りたい場合は、 some をつかえばよい。some は、述語関数が true を初めて返した値を返す。

(some #(> (:critter %) 5) food-journal)
; => nil

(some #(> (:critter %) 3) food-journal)
; => true
(some #(and (> (:critter %) 3) %) food-journal)
; => {:month 3 :day 1 :human 4.2 :critter 3.3}

食事日誌には、動物から 5 リットル以上摂取したことが記録がないので、はじめの例では nil を、 一方 3 リットル以上摂取した記録はあるので true を返している。

注目するべきは、返り値が、日誌記録レコードではなく true であること。もしレコードがほしいならば次の通り。

(some #(and (> (:critter %) 3) %) food-journal)
; => {:month 3 :day 1 :human 4.2 :critter 3.3}

sort and sort-by

sort 関数を使えば昇順で並び替えできる。

(sort [3 1 2])
; => (1 2 3)

もっと凝った並び替えするならば、 sort-by が使える。関数をコレクションの要素に適用して、その戻り値でソートできる。

(sort-by count ["aaa" "c" "bb"])
; => ("c" "bb" "aaa")

sort を使えば, アルファベット順で "aaa" "c" "bb" となるに対して、 sort-by を使えば、count を適用した結果(3, 1 ,2)で並び替える。

concat

concat はシンプルで、あるシーケンスを別のシーケンスの末尾に追加する

(concat [1 2] [3 4])
; => (1 2 3 4)