- Common Lispでリストを生成する方法として、
loopマクロ、再帰とcons、mapcarとmapcan、dotimesとpushの4つのアプローチを比較しています。 loopはDSL的な構文で直感的に書けますが、Common Lisp固有の機能であり、S式の統一性の外側に別の構文規則を持ち込んでいます。- 再帰と
consやmapcarとmapcanはSchemeやHaskellと共通する考え方で、Lisp全般に通じる汎用的な発想です。 loopが標準採用された背景には、1980年代後半に理論的純粋さより現場での使いやすさを優先したCommon Lispの設計思想があります。
1. 方法の比較
(1 2 3 4 5) のようなリストを作る方法は一通りではありません。loop マクロ、再帰と cons、mapcar と mapcan、dotimes と push など、考え方の異なる複数のアプローチがあります。
書き方の違いを見ていくと、Lisp のリスト処理の構造が自然に浮かび上がってきます。
| 方法 | 考え方 | 他の Lisp との共通性 |
|---|---|---|
loop | DSL で宣言的に書く | Common Lisp 固有 |
再帰 + cons | 値を積み上げる構造再帰 | Scheme、Clojure でも同じ |
mapcar と mapcan | リストを変換・結合する高階関数 | Scheme の map、Haskell の map と concatMap と共通 |
dotimes + push | 手続き的に積み上げる | C の for に近い発想 |
Lisp らしさという観点では、再帰 + cons と mapcar + mapcan が他言語でも通じる考え方です。loop は Common Lisp を書くうえで強力ですが、Scheme や Clojure には存在しない概念です。
短く意図が明確なら loop for ... collect、リスト変換を重ねる処理なら mapcar と mapcan、再帰の構造を見せたいなら labels + cons という選び方が自然です。
1.1. loop マクロ
loop は Common Lisp に標準で含まれるマクロです1。
評価前に通常の Lisp コードへ展開されます。
英語風の句を組み合わせる DSL(特定用途向けの小言語)的な構文を持っており、他の Lisp 方言にはほぼ存在しない Common Lisp 固有の機能です。
(loop for i from 1 to 5 collect i)
; => (1 2 3 4 5)Code language: JavaScript (javascript)
for i from 1 to 5 が変数 i を 1 から 5 まで動かし、collect が各ステップの値をリストに積み上げます。to の上限は含まれます2。
上限を含めたくない場合は below を使います。
(loop for i from 0 below 5 collect i)
; => (0 1 2 3 4)Code language: JavaScript (javascript)
collect と append の違い
collect は値をそのまま追加します。append は各ステップの結果がリストであることを前提に、それを連結します。
;; collect: 値をそのまま積む
(loop for i from 1 to 3 collect (list i (* i i)))
; => ((1 1) (2 4) (3 9))
;; append: 各ステップの結果リストを連結して平らにする
(loop for i from 1 to 3 append (list i (* i i)))
; => (1 1 2 4 3 9)Code language: PHP (php)
nconc は append の破壊的版で、元のリスト構造を変更しながら連結します。
速度が必要な場面で使いますが、副作用に注意が必要です3。
三重ループで直積を作る
i、j、k の全組み合わせ(三次元直積)を列挙するには、loop を入れ子にします。
(loop for i from 0 to 2 append
(loop for j from 0 to 2 append
(loop for k from 0 to 2 collect
(list i j k))))Code language: JavaScript (javascript)
内側の collect が (i j 0) (i j 1) (i j 2) を作り、append が一段ずつ平らにします。
最終的に 27 要素のリストになります4。
条件フィルタリング
when と unless で条件付きの収集ができます5。
(loop for i from 1 to 10
when (evenp i) collect i)
; => (2 4 6 8 10)Code language: JavaScript (javascript)
集約
sum、count、maximize、minimize など、集約操作も句として使えます6。
(loop for i from 1 to 5 sum i)
; => 15
(loop for i from 1 to 5 maximize i)
; => 5Code language: JavaScript (javascript)
複数の返り値
collect と集約を同時に使いたい場合は、into で中間変数に蓄積し、finally で返します7。
(loop for i from 1 to 5
collect i into nums
sum i into total
finally (return (values nums total)))
; => (1 2 3 4 5)
; 15Code language: JavaScript (javascript)
loop のマクロ展開を確認する
loop が内部でどんなコードに変換されるかは macroexpand-1 で確認できます。
(macroexpand-1 '(loop for i from 0 to 2 collect i))
展開結果は処理系依存ですが、概念的にはこのような形になります。
(block nil
(let ((result nil))
(do ((i 0 (+ i 1)))
((> i 2) (nreverse result))
(push i result))))Code language: JavaScript (javascript)
do、let、block など通常の Lisp 構文に展開されているのがわかります8。
1.2. 再帰と cons
cons はリストの先頭に要素を追加する基本操作です。
(cons 1 '(2 3))
; => (1 2 3)
リストは内部的に (1 . (2 . (3 . nil))) という入れ子構造になっており、cons はその先頭セルを作ります9。
再帰を使うと、繰り返し処理ができます。
たとえば、appendで末尾に追加することで、連番のリストを生成できます。
(defun range (n)
(if (= n 0)
nil
(append (range (- n 1)) (list n))))
(range 5)
; => (1 2 3 4 5)Code language: PHP (php)
しかし、この方法は非効率です。append では、呼び出しのたびにリストの末尾を走査するからです。
小さなリストでは問題ありませんが、大きなリストには向きません。
末尾再帰とアキュムレータ
発想を転換して、逆順で追加していけば、appendではなく consを使えます。
(defun range (n)
(labels ((rec (i acc)
(if (= i 0)
acc
(rec (1- i) (cons i acc)))))
(rec n nil)))
(range 5)
; => (1 2 3 4 5)Code language: PHP (php)
n から 1 ずつ減らしながら cons で先頭に積むと、自然に昇順のリストができます。labels は関数内でだけ使えるローカル関数を定義する構文です。
Common Lisp でも SBCLなど多くの処理系で、末尾呼び出しは最適化されます10。
labelsでローカル再帰関数を作り、結果を蓄積する変数としてアキュムレータを使う書き方は Lisp の慣用表現として広く使われます。
この考え方は Scheme でも同じで、named let を使えば次のように書けます。
(define (range n)
(let loop ((i n) (acc '()))
(if (= i 0)
acc
(loop (- i 1) (cons i acc)))))
構文は違いますが構造は同じです11。
1.3. mapcar と mapcan
mapcar
mapcar はリストの各要素に関数を適用し、同じ長さのリストを返します。
(mapcar #'(lambda (x) (* x x)) '(1 2 3 4 5))
; => (1 4 9 16 25)Code language: PHP (php)
1+ など単引数の関数であればそのまま渡せます。
(mapcar #'1+ '(0 1 2 3 4))
; => (1 2 3 4 5)Code language: PHP (php)
「0 から n-1 のリストを 1 から n に変換する」という形で range を書くこともできます。
mapcan
mapcan は各要素に関数を適用した結果を nconc で連結します。
Scheme の flatmap に相当します12。
(mapcan #'(lambda (x) (list x (* x x))) '(1 2 3))
; => (1 1 2 4 3 9)Code language: PHP (php)
mapcar を使うと ((1 1) (2 4) (3 9)) という入れ子になりますが、mapcan は一段平らにします。
mapcan で直積を作る
先ほどの loop 三重ネストは mapcan と mapcar でも書けます。
(let ((xs '(0 1 2)))
(mapcan (lambda (i)
(mapcan (lambda (j)
(mapcar (lambda (k) (list i j k))
xs))
xs))
xs))
内側が mapcar、外側 2 層が mapcan という構造は loop 版と同じです。loop の collect が mapcar に、append が mapcan にそれぞれ対応しています。
1.4. dotimes と push
dotimes は指定回数だけ繰り返す手続き的なマクロです。
リストを走査する dolist と対になります13。
(defun range (n)
(let (result)
(dotimes (i n (nreverse result))
(push (1+ i) result))))
(range 5)
; => (1 2 3 4 5)Code language: JavaScript (javascript)
push は (setf result (cons v result)) の省略形で、リストの先頭に要素を追加します。
先頭に積んでいくので、最後に nreverse で順序を正します。nreverse は元のリスト構造を破壊的に反転します。
loop より低レベルで、C の for ループに近い書き方です。
手続き的に書きたいときや副作用を伴う処理が必要なときに使われます。
2. loop が示す Common Lisp の設計思想
loop マクロは、Lisp コミュニティの中でも賛否が分かれる存在です。
「Lisp らしくない」という批判は今も根強くあります。
その違和感の正体は、loop の構文が Lisp の基本原則と相容れない部分を持っているからです。
Lisp の構文はすべて (operator arg1 arg2 ...) という S 式で統一されています。
コードもデータも同じ構造で表現できるため、マクロがコードを自由に操作できます。
ところが loop の内部では for、from、collect、when といったキーワードが特別な意味を持ち、loop マクロ自身がそれを独自に解析します。
これは S 式の統一性の外側に、別の構文規則を持ち込んでいることになります14。
それでも loop が標準に採用されたのは、実際に書きやすく読みやすいからです。loop for i from 1 to 10 when (evenp i) collect i は、意図を英文に近い形で表現できます。
再帰や mapcan の入れ子で同じことを書くより、コードの見た目と処理の意図が一致しやすい場面があります。
Common Lisp が ANSI 規格として策定された 1980 年代後半は、Lisp が研究用途を超えて産業での実用を目指していた時期です。
異なる処理系に散在していた方言を統合する過程で、理論的な純粋さより現場での使いやすさを優先する選択が重ねられました15。loop はその象徴のひとつです。
Scheme が末尾再帰の保証や最小限の構文セットで「正しい Lisp」を追求したのとは対照的に、Common Lisp は「使える Lisp」として多様な道具を標準に抱え込みました。loop の存在はその判断を体現しています。
どちらの設計が優れているかという問いに答えはなく、何を優先するかという価値観の違いです16。
loopマクロの起源は Interlisp の Warren Teitelman が実装したイテレーション構文にさかのぼります。Lisp Machine Lisp と MacLisp を経由して Common Lisp 標準に採り込まれました。 – CLHS Section 1.1.2 – Historyfor/as句によるイテレーション制御の詳細な文法は CLHS Section 6.1.2.1 に定義されています。to・upto・below・downto・above・byなど複数のキーワードが使えます。 – CLHS Section 6.1.2.1nconcはrplacdを使って最後のセルの CDR を書き換えます。同じリストを二度nconcに渡すと循環リストが生成される危険があります。CLHS の例では(nconc x y)後にxの値が変化することが示されています。 – CLHS: Function NCONCloopマクロは展開時に変数束縛を確立するletまたはlambdaに相当する形式を生成します。変数の初期化順序はユーザーが指定した順に従うことが仕様で定められています。 – CLHS: 6.1.1.4 Expanding Loop Formsloopの条件句はif・when・unlessの 3 種類が使えます。else節とendによるネストにも対応しています。 – CLHS: Macro LOOPloopの集約句にはcollect・append・nconc(リスト系)とsum・count・maximize・minimize(数値系)があります。各句にはing形(summing・collecting等)の別名も存在します。 – CLHS Section 6.1.3into varを使うとloopはその値を自動で返さなくなるため、finally (return ...)で明示的に返す必要があります。loop-finishを使うとfinally節を実行しながら途中でループを終了できます。 – CLHS: Local Macro LOOP-FINISHloopの代替ライブラリとしてiterateがあります。loopに近い構文を持ちながら S 式の規則に従った設計になっており、独自のイテレーション構造を拡張定義できます。 – A Road to Common Lisp – Steve Loshcar(先頭要素)とcdr(残りのリスト)という名前は、初期の Lisp が動いた IBM 704 のレジスタ構造に由来します。carは “Contents of Address part of Register”、cdrは “Contents of Decrement part of Register” の略です。 – cons – Wikipedia- ANSI Common Lisp の仕様は末尾再帰の最適化(TCO)を処理系に要求していません。SBCL や Clozure CL は TCO に対応していますが、LispWorks や Allegro CL では引数の数や呼び出し構造によって最適化が適用されないケースがあります。 – Tail Call Optimisation in Common Lisp Implementations
- Scheme(R7RS)はすべての処理系に末尾再帰の最適化を義務付けています。「proper tail-recursive」と呼ばれるこの性質は、Steele と Sussman が設計した最初の Scheme インタプリタに由来し、アクターモデルの tail call と関数呼び出しが等価であるという観察から来ています。 – Revised7 Report on the Algorithmic Language Scheme
mapcanがnconcを使うため、渡した関数が返すリストを後から変更すると予期しない結果になることがあります。関数内でlistを使って毎回新しいリストを作るのが安全な使い方です。X3J13 は 1989 年に mapping 中の破壊的副作用に関する制限を追加しています。 – CLHS: Function MAPC, MAPCAR, MAPCAN…dotimesの構文は(dotimes (var count-form result-form) body)です。result-formはループ終了後に評価されて返り値になります。省略するとnilが返ります。 – CLHS: Macro DO, DO*loopのキーワードは通常の Lisp シンボルとして扱われますが、loopマクロが内部でそれらを解析する独自のパーサーを持っています。この設計は CLHS でも「ループ施設(The Loop Facility)」として独立した章に分離されており、仕様書自体がこの構文の特殊性を認識しています。 – CLHS: Macro LOOP- 1981 年の DARPA スポンサードの会議を機に Symbolics、SPICE プロジェクト、NIL プロジェクト、S-1 Lisp プロジェクトが協力して Common Lisp の設計を開始。1986 年に ANSI 標準化委員会 X3J13 が発足し、ポータビリティの強化・CLOS・条件システム・イテレーション機能などの目標が追加されました。 – Common Lisp – Wikipedia
- Scheme は Guy L. Steele Jr. と Gerald Jay Sussman が設計した Lisp の方言で、レキシカルスコープ・レキシカルクロージャ・ファーストクラス継続・簡潔な構文を主要な貢献として挙げられます。Common Lisp もレキシカルスコープとクロージャは Scheme から取り込みましたが、末尾再帰の保証は採用しませんでした。 – Scheme (programming language) – Wikipedia