【Common Lisp】
文字や関数の書く「#」とリーダーマクロの
仕組み

  • Common Lispの '#\#' などの記法は、「リーダーマクロ」の仕組みから生まれています。
  • REPLの読み込み機能は特定の文字に出会うと、登録された関数を呼び出し、文字列をLispオブジェクトに変換します。
  • 文字と処理関数の対応は、readtableという内部テーブルでが管理されています。

関連記事

1. リーダーとリーダーマクロ

文字や関数を指す #\A#'car という書き方を最初に見て、「なぜこんな記号が必要なのか」と感じました。
あるいは、処理系依存のコードを書くときには、#+sbcl(func-a x y) #-sbcl(func-b x y)などのようにフィーチャーフラグを設定するところでも、 # 記号は登場します。

実は、これらはただの表記上の慣習ではなく、Common Lispの読み込み処理の仕組みと直結しています。
その仕組みを「リーダーマクロ」といいます。

リーダーとリーダーマクロ Read Eval Print リーダー:文字列 → Lispオブジェクト ‘(1 2 3) → (QUOTE (1 2 3)) リーダーマクロ 特定の文字に出会ったとき任意のコードを実行する仕組み

Common Lispのコードが評価されるまでには、読み込み、評価、印字という段階があります。
REPLという名前はread、eval、print、loopの頭文字を並べたものですが、インタラクティブな開発環境に限らず、Lsip処理系は読み込みと評価の往復で動作しています1

リーダー」はこのうち最初の段階を担い、文字の列をLispオブジェクトに変換します。
(+ 1 2) という文字列を読み込むと、シンボル + と整数 12 からなるリストというオブジェクトになります。

通常のプログラミング言語処理系の字句解析はスペースや括弧で区切られたトークンを処理します。
しかし、Common Lispのリーダーはそれだけでなく、特定の文字に出会ったとき任意のコードを実行できます。
これが「リーダーマクロ」の仕組みです2

1.1. ‘ が quote に変換される仕組み

たとえば ' はマクロ文字として登録されています。

リーダーは、 ' を読んだ瞬間に対応する関数が呼ばれます。
その関数はストリームから次のS式を読み、(quote ...) というリストを返します。

'(1 2 3)
; (SB-IMPL::READ-QUOTE (1 2 3))
; リーダーが返すもの => (QUOTE (1 2 3))
; evalが処理するもの => リスト (1 2 3)Code language: Lisp (lisp)

また、'x(quote x) は、リーダーの段階で完全に等価になります。
コンパイラはどちらも同じものとして受け取ります。

リーダーマクロのおかげで、見た目の括弧が深くならないようになっています。
糖衣構文(syntax sugar)を作っているのですね。

2. # はディスパッチマクロ文字

' は単独でマクロ文字として QUOTE の機能をしました。
それに対して、# は組み合わせて使います。

# はディスパッチマクロ文字 # #\A 文字オブジェクト #’car 関数オブジェクト #(1 2 3) ベクター # 単体では何もしない 次の文字とセットで処理関数が決まる

# は「ディスパッチマクロ文字」として登録されていて、# 単体では何もせず、次の文字とセットで処理する関数を決めます3
これが、さまざまな記法を生み出します。

記法リーダーが返すもの
#\A文字オブジェクト #\A
#'car(FUNCTION CAR)
#(1 2 3)ベクター #(1 2 3)
#b1010整数 10(2進数リテラル)
#+sbcl ...フィーチャー条件分岐

これらはすべて同じ仕組みの上にあります。
# の後の文字に対応する関数がストリームを受け取り、Lispオブジェクトを返します。

2.1. #\ が文字オブジェクトを作る理由

Lispでは、"A" と書くと文字列になり、65 と書けば整数です。

しかし、1文字そのものを表すオブジェクトが必要なときもあり、文字型という別の型を使います。
それを表記するのが #\ です。

#\A はアルファベットのAを、#\Space はスペース文字を指します4

(characterp #\A)  ; => T
(characterp "A")  ; => NIL

(char-code #\A)   ; => 65
(char= #\a #\A)   ; => NIL  (大文字と小文字は別)Code language: Lisp (lisp)

#\ の後に名前を続けると、スペースや改行のように印字できない文字も表記できます。

#\Space    ; スペース文字
#\Newline  ; 改行
#\Tab      ; タブCode language: Lisp (lisp)

2.2. #' が関数オブジェクトを取り出す理由

Common Lispでは、シンボルには値のセルと関数のセルが別々に存在します。
別の言い方をすると、関数名前空間と変数名前空間の両方があり、それぞれにシンボルを定義できます。

(defvar foo 42)
(defun foo (x) (* x 2))

foo       ; => 42  (値のセルを参照)
(foo 10)  ; => 20  (関数のセルを参照)Code language: Lisp (lisp)

ちょっとややこしい話ですが、foo と書いたとき、それが変数の値なのか関数なのかはコンテキストで決まります。
単純に言えば、S式の先頭にあれば関数名として、それ以外は変数名として扱われます。

ちなみに、これはCommon LispがLisp-2と呼ばれる設計を採用しているためです。SchemeなどのLisp-1とは異なる点です5

しかし、関数オブジェクトそのものを値として扱いたいときもあります。
たとえば、mapcarなどに関数に渡したいときには #' を使います。

(mapcar #'evenp '(1 2 3 4))
; => (NIL T NIL T)Code language: Lisp (lisp)

これは、#'foo はリーダーが (function foo) というリストに変換します。
function は特殊オペレーターで、関数のセルから関数オブジェクトを取り出します6

つまり、(mapcar (function evenp) '(1 2 3 4))と書いたのと同じことになります。

ラムダ式に使って関数オブジェクトとして取り出すこともできます。

(mapcar #'(lambda (x) (* x x)) '(1 2 3))
; => (1 4 9)Code language: Lisp (lisp)

3. フィーチャーフラグ #+#-

フィーチャーフラグの #+#- も同じ仕組みです。

フィーチャーフラグ #+ と #- #+ 条件に一致 → 読む #+sbcl (func-a x y) *features* に :SBCL あり → S式として返る コンパイラが処理する #- 条件に不一致 → スキップ #-sbcl (func-b x y) *features* に :SBCL なし → 文字列として読み飛ばし Lispとして解析すらされない

リーダーは # を読んで + の処理を呼び出します。
すると、その関数がパラメータが *features* リストに存在するか確認して、次のS式を読むかスキップするかを決めています。

#+sbcl
(format t "Running on SBCL~%")Code language: Lisp (lisp)

たとえば、*features*:SBCL がなければ、(format t ...) はS式としてリーダーから返ってきません。
コンパイラには最初から存在しないコードとして扱われます。

リーダーは文字列として読み飛ばすので、Lispとして解析すらされません。

#-common-lisp
この文字列はLispとして解析すらされないCode language: Lisp (lisp)

これが「コンパイル前に処理される」という意味です7

3.1. Cのプリプロセッサとの比較

C言語のプリプロセッサみたいな感じだね。

たしかに、C言語のプリプロセッサも、コンパイル前に #define#ifdef を処理します。
この「コンパイラが見る前に変換が済んでいる」という点はリーダーマクロとよく似ています。

しかし、重要な違いもあって、それは、変換の担い手です。

Cのプリプロセッサはテキスト置換に特化した専用ツールです。
Cのコードとは別の言語として動作します。
#define SQUARE(x) ((x)*(x)) はあくまで文字列の展開であり、Cの型や評価順序を知りません。
プリプロセッサでは、多重展開を防ぐために括弧を重ねるという工夫がありますが、このような工夫が必要なのはプリプロセッサがCの意味(semantics)を理解していないためです。

一方、リーダーマクロは、Lisp自身で書かれた関数で、Lisp処理系と一体になっています。
変換の過程でLispの全機能を使えます。
ストリームから任意の文字を読み込んで、型付きのLispオブジェクトを返します。
テキストではなくオブジェクトを生成するので、そこに文字列展開特有の落とし穴はありません。

4. マクロ文字と関数の対応(readtable)

リーダーは内部に「readtable」というテーブルを持っています。
ここには、文字とそれを処理する関数の対応が記録されています。

マクロ文字と関数の対応(readtable) readtable 文字 処理関数 READ-QUOTE # DISPATCH-MACRO-CHAR ( READ-LIST ; READ-COMMENT ユーザーが 拡張可能 set-macro- character copy-readtable

動作環境で現在有効になっているreadtableは *readtable* に格納されていて確認できます8

(loop for i from 0 to 255
      for ch = (code-char i)
      for fn = (and ch (get-macro-character ch))
      when fn
      collect (list ch fn))     Code language: Lisp (lisp)

私の環境(SBCL)では、以下の結果が出てきました。

((#\" #<FUNCTION SB-IMPL::READ-STRING>)
 (#\#
  #<FUNCTION (LAMBDA (STREAM CHAR)
               :IN
               SB-IMPL::%MAKE-DISPATCH-MACRO-CHAR) {12000E262B}>)
 (#\' #<FUNCTION SB-IMPL::READ-QUOTE>)
 (#\( SB-IMPL::READ-LIST)
 (#\) SB-IMPL::READ-RIGHT-PAREN)
 (#\, SB-IMPL::COMMA-CHARMACRO)
 (#\; #<FUNCTION SB-IMPL::READ-COMMENT>)
 (#\` SB-IMPL::BACKQUOTE-CHARMACRO))Code language: Lisp (lisp)

4.1. 自分でリーダーマクロを定義する

一文字にリーダーマクロを登録するなら set-macro-character を使います。

たとえば、以下のように定義すると、
{1 2 3} を読むと (list 1 2 3) というS式が返るようになります。

(set-macro-character #\{
  (lambda (stream char)
    (declare (ignore char))
    (let ((contents (read-delimited-list #\} stream t)))
      (cons 'list contents))))

(set-macro-character #\} (get-macro-character #\)))Code language: Lisp (lisp)

引数は文字と、ストリームと文字を受け取る関数です9

確認するには、read-from-stringで評価できます。

(eval (read-from-string "{1 2 3}"))
; => (1 2 3)Code language: Lisp (lisp)

# ディスパッチに追加することもできます。
それには set-dispatch-macro-character を使います。

たとえば、これで #! のリーダーマクロを定義すれば、 #!/usr/bin/env sbcl のようなshebang行を読み飛ばせます。

(set-dispatch-macro-character #\# #\!
  (lambda (stream sub-char arg)
    (declare (ignore sub-char arg))
    (read-line stream)
    (values)))Code language: Lisp (lisp)

(values) で何も返さないことで、その行はS式として存在しない扱いになります。

4.2. readtableを汚染しない方法

リーダーマクロを追加するとグローバルな *readtable* が変わります。
これは、他のコードへの影響が大きいです。

そこでそれを避けるには、コピーを作って動的に束縛します。

(defvar *my-readtable* (copy-readtable))

(let ((*readtable* *my-readtable*))
  ;; このスコープ内でだけ独自のリーダーマクロが有効
  (read-from-string "{1 2 3}"))Code language: Lisp (lisp)

ライブラリが独自の記法を導入するときは、ほぼ必ずこのパターンを使っています。
named-readtables というライブラリを使うと、readtableに名前をつけて管理しやすくなります10

5. まとめ

#\A#'car は特殊な構文ではなく、リーダーマクロという同じ仕組みから生まれています。
文字に処理関数を登録し、その関数がストリームを直接操作してLispオブジェクトを返す。
この仕組みを理解すると、Common Lispで見かける # 始まりの記法がすべて同じ原理の上に立っていることがわかります。
そしてその仕組みは、ユーザーが自分で拡張できるように設計されています。

  1. REPLはインタラクティブな開発環境として、Common Lispの処理系のほぼすべてが提供しています。SBCLやClozureCL、LispWorksなどはそれぞれ独自のREPLを持ちます。 – CLHS: Function READ
  2. リーダーのアルゴリズム全体はANSI仕様のSection 2.2に定められています。マクロ文字に対応する関数が0個または1個の値を返すことでリーダーの挙動が制御されます。 – CLHS: 2.2 Reader Algorithm
  3. ANSI仕様では # を「non-terminating dispatching macro character」と定義しています。# と次の文字の間に符号なし整数を挟むことができ、その整数は引数として関数に渡されます(例:#2A)。 – CLHS: 2.4.8 Sharpsign
  4. #\Space#\Newline はANSI標準の文字名として仕様で定められています。#\Tab は標準仕様には含まれませんが、ほぼすべての実装がサポートしています。また #\Linefeed#\Newline と同義となる実装も多くあります。 – CLHS: 2.4.8.1 Sharpsign Backslash
  5. Lisp-1とLisp-2という呼び名は、1988年のRichard P. GabrielとKent Pitmanの論文で定義されました。Lisp-2(Common Lispの設計)では関数名前空間と変数名前空間を分離することでマクロ展開の予測可能性を高められる、という利点があります。 – Technical Issues of Separation in Function Cells and Value Cells
  6. function 特殊オペレーターはANSI仕様Section 3.1.2.1.2.4に定義されています。ラムダ式に対しても #'(lambda ...) の形で使えます。なお funcallapply で呼び出す際は #' で取り出した関数オブジェクトをそのまま渡せます。 – CLHS: Special Operator FUNCTION
  7. *features* はキーワードシンボルのリストです。ANSI仕様では :ansi-cl がすべての準拠実装で存在することが保証されています。処理系固有のフィーチャーとしてSBCLなら :sbcl、ClozureCLなら :ccl が入ります。 – CLHS: Variable FEATURES
  8. readtableはANSI仕様でfirst-classオブジェクトとして定められており、copy-readtablemake-dispatch-macro-character などの関数で操作できます。 – CLHS: Readtables
  9. set-macro-character はANSI仕様で定義された標準関数です。リーダーマクロ関数が0個の値を返すと((values) を使うなど)、リーダーはそのフォームをなかったものとして扱い、次のS式の読み込みに進みます。 – CLHS: Function SET-MACRO-CHARACTER
  10. named-readtables はTobias C. Rittweilerが開発したライブラリで、readtableの名前空間をパッケージの名前空間と同様に扱えるようにします。defreadtablein-readtablemerge-readtables-into などのAPIがパッケージAPIに対応する形で設計されており、既存のCommon Lispユーザーが直感的に使えます。 – named-readtables on GitHub