CLOJURE for the BRAVE and TRUEでClojure学習 part6

はじめに

久しぶりの投稿になったがClojure の学習備忘録。 今回はChapter 7: Clojure Alchemy: Reading, Evaluation, and Macrosで以下を学ぶ

  • Clojureの評価モデル
  • Readerについて
  • Evaluatorについて

評価モデルこそ、Lispが他の言語と大きく異なる特徴的な部分だと理解できる。そしてマクロがどのように振る舞うのかというメカニズムについて理解深まる面白いパートである。

またスペシャルフォームが、なぜスペシャルと呼ばれるのかを理解できたことはうれしい章である。

目次

Clojure Alchemy: Reading, Evaluation, and Macros

賢者の石といえば、錬金術で最も有名なアイテムで、鉛から金を変化できるものだ。 Clojureには、賢者の石のように都合よく変更できるツール、マクロがある。

マクロを使えば、どんな表現でもClojureが実行できるもの変更してくれるので、 必要に応じて言語自体を拡張させることができる。

実際マクロの力をみてみよう。

(defmacro backwards
  [form]
  (reverse form))

(backwards (" backwards" " am" " I" str))

(" backwards" " am" " I" str)Clojureの文法に違反してるので評価できないのだが、 backwards マクロをつかえば、Clojureは正しく評価してくれる。 賢者の石が鉛から金を生成できるように、 backwards で新しい文法を作り出すこと、つまり好きなようにClojureを拡張できる。

本章では、マクロがかけるための概念的な部分について、 Clojureの評価モデル, reader, evaluator, macro expander について説明する。

An Overview of Clojure’s Evaluation Model

他のLisp言語と同様Clojureも、他の多くの言語と異なる評価モデルをもっており、 ソースコードread し、Clojureのデータ構造を作り出すフェーズがある。

その後データ構造は 評価 (evaluate)される。 Clojureはデータ構造を走査して、関数適用だったり、データ構造の種別に応じた変数探索といったアクションを行う。 たとえば、 (+ 1 2) という文字列が読み込まれると(read)、その結果は、第一要素の + シンボルのあとに、数値の1,2がつづくリストと分析される。 リストが評価(evaluate)されると、 + に一致する関数を見つけ出し、1,2が適用される。

このようなソースコードと評価の関係をもつ言語は、同図像性をもつ(ホモイコニック)と呼ばれる。 ホモイコニック言語では、コードはプログラマが操作できるデータ構造として推論される。

プログラミング言語には、コンパイラインタプリタがあり、それはユニコードで構成されたコードをマシン語に変換してくれる。 この変換で、 abstract syntax tree(AST) が構築され、あるデータ構造として表現される。 /evaluator/は、このASTをインプットとして受け取り、その木構造を操作してマシンコードなどをアウトプットとして生成してくれる。

多くの言語では、ASTのデータ構造にプログラミング言語からアクセスすることはできない。 プログラミング言語領域とコンパイラ領域は完全に分かたれ、合流することはありえない。 次の図は、非Lisp言語のコンパイルプロセスを示したものである。

f:id:poppon555:20200827135625j:plain

一方、ClojureLisp言語の場合は違う。 アクセス不可のASTを評価するのではなく、Lisp言語は素のデータ構造を評価する。 Clojure木構造を評価するが、木構造Clojureのリストや値で構成されている。

リストは、木構造を構成するに理想的な表現である。第一要素はルートとして、その後に続く要素はそのブランチとして扱われる。 ネストした木構造をつくりたければ、次の図の通り、ネストしたリストをつくればよい。

f:id:poppon555:20200827135722j:plain

まずはじめに、Clojurereader は 文字列の (+ 1 (* 6 7)) をネストしたリストに変換する。 そして、Clojureevaluator はそれを入力として受け取って、その評価結果を出力する。

f:id:poppon555:20200827135655j:plain

しかし、/evaluator/ はどこからのインプットなのかに関心をもたないので、 インプットが reader である必要はない。 そのため、 eval を使えば、プログラマが書いたコードを evaluator に直接送ることがができる。

(def addition-list (list + 1 2))
(eval addition-list)
; => 3

このとおり、コードからClojureのリストを評価できた。 詳細の説明については追々していくとして、ここでは簡単な説明に留めると、 まずClojureはリストを評価して、 addition-list が参照するリストをみつだす。 そして、 + シンボルに対応する関数をみつけ、 1, 2 を引数としてその関数に適用する。 これは、プログラマのデータ構造であれ、/evaluator/ のデータ構造であれ同じである。 その結果、Clojureの力を最大限を発揮させることができるようになる。

(eval (concat addition-list [10]))
; => 13

(eval (list 'def 'lucky-number (concat addition-list [10])))
; => #'user/lucky-number

lucky-number
; => 13

このとおり直接 evaluator にコードを送ると、実行可能な形式に関数やデータが修正される。

Clojureはホモイコニックな言語である。つまり、コードはリスト形式で記述されるが、それがもうAST表現になっている。 自分が書いたコードが、 evaluator によって評価されるデータ構造と同じ表現であるため、 プログラムがどのように修正されるのか推論するのがたやすくなる。

マクロはこの変換をプログラマから簡単に行えるようにしたものである。 マクロについてより詳細に理解できるようにするため、残りはClojurereader, evaluator について説明する。

The Reader

リーダは、ファイルやREPLに記述されたコードをClojureのデータ構造に変換する。 プログラマが書いたユニコードを文字列を、Clojureの世界のリストや、ベクタ、マップ、シンボルなどに変換してくれる。 ここでは、リーダを直接操作しながら、より簡潔なコードを可能にしてくれる リーダマクロ について学習しよう。

Reading

REPLを起動して、以下の文字列を入力してみよう。

(str "To understand what recursion is," " you must first understand recursion.")

REPLに入力したものはただのユニコードの文字列なのだが、Clojureのデータ構造として判断される。 データ構造を文字列で表記したものは、 reader form と呼ばれる。 この例では、 str とその他2つの文字列からなるフォームを要素に持つリストを表現したフォームということになる。

この文字列がリーダに渡ると、対応するClojureのデータ構造が作られ、さらにそれが評価されると次の通り出力される。

"To understand what recursion is, you must first understand recursion."

読み込み(reading)と評価(evaluation)は、異なるプロセスなので、独立させて実行させることができる。 reader プロセスを直接操作するには、 read-string をつかう。 read-string に文字列を渡すと、リーダと同じようにデータ構造を返す。

(read-string "(+ 1 2)") ; 文字列のリストをわたす
; => (+ 1 2)            ; Clojureのリストに変換される

(list? (read-string "(+ 1 2)"))
; => true  変換したデータ構造がリストであること確認できる

(conj (read-string "(+ 1 2)") :zagglewag) ; Clojureのリストなのでconj関数も適用できる
; => (:zagglewag + 1 2)

そしてreaderの出力結果を評価することもできる。

(eval (read-string "(+ 1 2)"))
; => 3

ここまで紹介してきたものは、リーダフォームとデータ構造が一対一の対応関係があるものになる。 もう少しこの対応関係を紹介する。

  • () はリストのリーダフォーム
  • str はシンボルのリーダフォーム
  • [1 2] は2つの数値リーダフォームからなるベクタリーダフォーム
  • {:sound "hoot"} はキーリーダフォームと文字列リーダーフォームからなるマップリーダフォーム

繰り返しになるが、リーダフォームとは、Clojureのデータ構造を文字列表記したもの

しかしながら、リーダはもうすこし複雑な挙動をするときがある。

(#(+ 1 %) 3)
; => 4

これをリーダに読み込ませてみよう。

(read-string "(#(+ 1 %) 3)")
; => '((fn* (p1__6174#) (+ 1 p1__6174#)) 3)

これが一対一しない場合である。 (#(+ 1 %) 3) を読み込んむとリストを出力するが、 それは、 fn* シンボルと、シンボルを要素に持つベクタ、さらに3要素をもつリストをもっているわけだ。 どういうことだろうか?

Reader Macro

これの答えは、 reader macro によって (#(+ 1 %) 3) は変換されたということになる。 リーダマクロは、テキストをデータ構造に変換する一連のルールをもっている。 リーダマクロのおかげで、省略リーダフォームが展開されて、コードを簡潔にかける。 省略リーダフォームは、 macro character と設計されており、 ', #, @ が代表例である。

たとえば、シングルクォートは、次のように展開される。

(read-string "'(a b c)")
; => (quote (a b c))

deref リーダマクロも同じで、 @ のときに展開される。

(read-string "@var")
; => (clojure.core/deref var)

コメントアウト部分は省略して展開してくれる。

(read-string "; ignore!\n(+ 1 2)")
; => (+ 1 2)

以上が、リーダについてになる。次は、評価(evaluator)についてみていこう。

The Evaluator

f:id:poppon555:20200827135209j:plain

Clojureevaluator は、引数にデータ構造をうけとり、データ構造の種別に応じたルールを適用して、結果を返す関数と考えてよい。 シンボルを評価するときは、Clojureはシンボルの参照先をみつけだす。リストを評価するときは、リストの第一要素をみて関数、マクロ、スペシャルフォームらを呼び出す。 文字列や、数値、キーワードといった他の値は、それ自身として評価される。

たとえば、 (+ 1 2) とREPLに入力したときを考える。 上図に示したデータ構造が、 evaluator の送られる。

リストが送られたので、 evaluator はその第一要素の評価からスタートする。第一要素は+シンボルなので、対応する関数をみつけて次の処理に進む。 第一要素は関数だとわかったので、次にそのオペランドを評価しにいく。オペランドの1, 2は、リストやシンボルではないので、そのまま評価される。 最後に1,2をオペランドとして+関数に適用し、その結果を返す。

ここからは、 evaluator がそれぞれのデータ構造をどのように評価していくのかを詳しく紹介していく。

These Things Evaluate to Themselves

リストやシンボルではないデータ構造を評価するときは、データ構造それ自体が出力される。

true
; => true

false
; => false

{}
; => {}

:huzzah
; => :huzzah

空のリストも同様である。

()
; => ()

Symbols

プログラマの重要タスクの一つは、値と名前の関連づけであり、chapter3では, def, let や関数定義で学習したことである。 シンボル を使えば、関数、マクロ、データ、その他なんであれ名前をつけることがができ、その名前で評価することできる。 シンボルを評価するとき、Clojure名前空間からそれと関連付けられた値を見つけ出す。 最終的にシンボルは、 , スペシャルフォーム のいずれかとして判定される。 スペシャルフォームとは、Clojureに組み込まれた基本的な演算子のことである。

一般的にClojureがシンボルを評価するときは、次の通りになる。

  1. シンボルがスペシャルフォームかどうかを判定する。そうでないならば、
  2. ローカルバインドされたものかどうか判定する。そうでないならば、
  3. def で定義されて名前空間に存在するかどうか判定する。そうでないならば、
  4. 例外をなげる

まずスペシャルフォームの場合についてみてみよう。 if のようなスペシャルフォームは必ず演算子として使用されるため、リストの第一要素に現れる。

(if true :a :b)
; => :a

この例でも、ifはスペシャルフォームであり、演算子として使用されていることがわかる。もしスペシャルフォームをリストなしに使用すると例外が返される。

if
; => Unable to resolve symbol: if in this context

次は、ローカルバインディングについてみてみよう。/ローカルバインディング/ とは、 def を使わないでシンボルと値を関連付けさせることである。 例では、 let を使用してシンボル x に5をバインドしてる。 x を評価すると、シンボルxは、5として現れる。

(let [x 5]
  (+ x 3))
; => 8

名前空間上にxと15を対応付ければ、次の通りになる

(def x 15)
(+ x 3)
; => 18

次の例では、xは15にマッピングされているが、xは let で5にローカルバインディングされてもいる。 この場合、 let が優先される。

(def x 15)
(let [x 5]
  (+ x 3))
; => 8

ネストした場合、直近にあるバインディングが利用される。

(let [x 5]
  (let [x 6]
    (+ x 3)))
; => 9

関数もローカルバインディングを行っており、渡されたパラメータを関数内の引数にバインディングしてる。 次の例では、 exclaim は関数にマッピングされてる。関数内の exclamation パラメータは、関数の引数として渡されたものにバインドされる。

(defn exclaim
  [exclamation]
  (str exclamation "!"))

(exclaim "Hadoken")
; => "Hadoken!"

最後に、 map, inc について見てみよう。

(map inc [1 2 3])
; => (2 3 4)

Clojureがこのコードを評価するとき、まず map シンボルを評価し、その参照先関数を見つ出し、引数をそれに適用する。 map シンボルはmap関数を参照するが、関数それ自体ではないことに注意しよう。 文字列 fried saled がデータ構造であるのと同様、 map シンボルもデータ構造なのであり、関数それ自体ではないのだ。

(read-string ("+"))
; => +

(type (read-string "+"))
; => clojure.lang.Symbol

(list (read-string "+") 1 2)
; => (+ 1 2)

この例でも、プラスのシンボル + をデータ構造として扱ってのであり、参照する関数として扱ってるわけではない。 評価してはじめて、Clojureは参照する関数を見つけ出し、適用する。

(eval (list (read-string "+") 1 2))
; => 3

Lists

空リストを評価すれば、空のリストが返る

(eval (read-string "()"))

空でなければ、リストの第一要素を呼び出そうとするが、第一要素の性質によって異なってくる。

Function Calls

関数呼び出しならば、全オペランドが評価された後、関数の引数として渡される。 たとえば、 + シンボルの場合をみてみよう。

(+ 1 2)
; => 3

Clojureはリストの先頭が関数だとわかれば、続いて残りの要素を評価する。 オペランドの1,2は、シンボルやリストでないのでそれ自身として評価されて、そしてそれを関数に適用する。

ネストした関数呼び出しの場合はどうだろうか。

(+ 1 (+ 2 3))
; => 6

第2引数がリストになっているが、Clojureの先程と同じプロセスを踏む。 + シンボルを見つけては、その引数を評価する。 (+ 2 3) を評価するときは、第一要素を関数として判定し、残りの要素も評価する。このように再帰的に評価される。

Sepecial Forms

スペシャルフォームは、関数で実装できない機能をもつのでスペシャルと呼ばれる。

(if true 1 2)

この例では、シンボル if で始まるリストを評価している。 if シンボルはスペシャルフォームをつくるので、 true, 1, 2オペランドとして評価する。

スペシャルフォームの評価は、関数評価と異なる。たとえば、関数を呼び出したとき、全オペランドが評価される。 しかし if を使用するときは、特定のオペランドを評価してほしい場合となる。条件の真偽に応じて、特定のオペランドのみ評価させることができるのだ。

quote スペシャルフォームについてもみてみよう。

'(a b c)

これはリーダマクロによって、以下のように表現される。

(quote (a b c))

今まで見てきたとおり普通、 a シンボルがリストの第一要素なので、それを呼び出そうとする。 しかし、 quote スペシャルフォームは、「次のデータ構造を通常通り評価するのではなく、データ構造そのものを返してほしい]と評価方法をかえる。 その結果、シンボル a, b, c からなるリストを受け取る。

def, let, loop, fn, do, recur も同様で、関数と同じやり方で評価しない。 たとえば、 def, let を評価するときも、それの参照先をみつけたりしない。代わりに、シンボルと値の関連付けを作成してくれる。 このように、リーダからデータ構造が送られてくると、シンボルを走査してリストの先頭要素の関数やスペシャルフォームを呼び出す。 またマクロをリスト先頭要素にすると、また新しい評価方法が得られる。

Macros

マクロを理解するには、実際にコードをみてみよう。たとえば、Clojureで、 (+ 1 1) ではなく (1 + 1)中置記法で評価してほしいとする。 これはマクロではない。むしろ、ただ中置記法でかいたら評価できるようによしなに変更してほしいということを示したいだけである。

(read-string "(1 + 1)")
; => (1 + 1)

このリストを評価すれば例外をなげる

(eval (read-string "(1 + 1)"))
; => ClassCastException java.lang.Long cannot be cast to clojure.lang.IFn

しかし read-string が返すリストを評価できるよう適切に変換すれば、うまくいく。

(let [infix (read-string "(1 + 1)")]
  (list (second infix) (first infix) (last infix)))
; => (+ 1 1)     

これを評価すればもちもん2が出力される。

(eval
  (let [infix (read-string "(1 + 1)")]
  (list (second infix) (first infix) (last infix))))
; => 2     

うまくいったが不格好だ。そこでマクロなのである。マクロを使えば、Clojureが評価する前にリストを操作することができる。 マクロは関数に似ており、引数と返り値をもつ。また関数と同様、データ構造として機能する。 マクロがユニークなのは、評価プロセスに適するように振る舞えることである。 マクロは、 readerevaluator の間に位置するので、リーダが読み取ったデータ構造を evaluator にわたす前に変換することができる。

例をみてみよう。

(defmacro ignore-last-operand
  [function-call]
  (butlast function-call))

(ignore-last-operand (+ 1 2 10)) ;1
; => 3

(ignore-last-operand (+ 1 2 (println "look at me!!!")))
; => 3

1で、 ignore-last-operand マクロが引数として受け取るのは、評価後の13ではなく、 (+ 1 2 10) リストそれ自体である。 この振る舞いは、関数のと異なるものである。というのも、関数は呼び出す前に、全引数を評価し、ある引数は評価したり、評価しなかったりすることは不可能だからである。 それに対して、マクロの場合、オペランドは評価されない。具体的言うと、シンボルの参照先を解決せず、シンボルをそのまま受け取る。それはリストでも同じである。 つまり、リストの第一要素が、関数や、スペシャルフォーム、マクロとしてではなく、未評価のリストデータ構造として受け取る。

もうひとつ関数と異なるのは、関数の戻り値のデータ構造は評価されないが、マクロの場合評価される。 マクロの返り値が計算されるプロセスは、 マクロ展開(macro expansion) と呼ばれ、 macroexpand をつかえば、マクロが返す評価される前のデータ構造を確認することができる。

(macroexpand '(ignore-last-operand (+ 1 2 10)))
; => (+ 1 2)

(macroexpand '(ignore-last-operand (+ 1 2 (println "look at me!!!"))))
; => (+ 1 2)

ご覧の通り、どちらの結果も (+ 1 2) というリストになる。このリストが評価されると、3が出力されるわけだ。

(defmacro infix
  [infixed]
  (list (second infixed) 
        (first infixed) 
        (last infixed)))

(infix (1 + 2))
; => 3     

f:id:poppon555:20200827135554j:plain

マクロ展開のプロセスを絵にすると、上図のとおりである。

以上が評価プロセスにフィットさせるようなマクロの振る舞いである。 マクロのありがたみというのは、 (1 + 2) といったデータ構造をClojureが評価可能な形 (+ 1 2) に変更してくれることになる。 これが意味することは、プログラマClojure自身を拡張させるさせることができること、すなわちマクロが 文法抽象(syntactic abstraction) を可能にしているということである。

Syntactic Abstraction and the -> Macro

Clojureのコードは、関数呼び出しがネストする場合がよくあり、たとえば、次のようなコードもそうである。

(defn read-resource
  "Read a resource into a string"
  [path]
  (read-string (slurp (clojure.java.io/resource path))))

関数の中身を理解するには、括弧の一番深いところ、 (clojure.java.io/resorce path) をみつけて、 右から左へと関数の戻り値が次の関数の引数に送られていく流れを追わないといけない。 この右から左にコードを読んでいくという流れは、非Lispプログラマの読み方と逆になっている。 もっともClojureの書き方になれれば問題ではないが、左から右へ、上から下へコードを読んでいきたいなら、 -> マクロが使える。 このマクロは、スレッディングマクロ、あるいはスタビーマクロと呼ばれ、先程の処理を次のように書き直せる。

(defn read-source
  [path]
  (-> path
      clojure.java.io/resorce
      slurp
      read-string))

これなら最深括弧から外側の括弧へではなく、上から下に読むことができるわけだ。 まず、 pathio/resource の引数として渡され、その関数適用結果が slurp の引数として渡り、最後にslurpの出力結果が read-string の引数に渡っていく。

どちらのコードでも処理は同じになるが、スレッディングマクロを使ったほうが、上から下へという普段から慣れた書き方なので理解しやすい。 また -> マクロで括弧も省略されてるので、ビジュアル的にもみやすくもなっている。 これが 文法抽象 の力であり、Clojureの組み込み文法とは異なる書いたものを、人間が理解できる書き方で記述できるようにしてくれるのだ。