iterate: 拡張可能でLispらしいループマクロ

概要

このページでは高機能ループマクロであるiterateについて簡単に説明します。ANSI標準の繰り返し構文については第6章「繰り返し」を参照してください。

iterateは以下の状態でロードされていることとします。
(asdf:load-system :iterate)
; => T

(use-package :iterate)
; => T

なお、Clozure CLの場合はiterateのシンボルのいくつかがcommon-lisp-userパッケージのシンボルと衝突するようです。一つ一つshadowing-importしても構いませんが、最初からパッケージを分けると問題を回避できます。
(asdf:load-system :iterate)
; => T

(defpackage :iter-test (:use :cl :iterate))
; => #<Package "ITER-TEST">

(in-package :iterate)
; => #<Package "ITERATE">

loopiterate

ANSI Common Lispには標準で極めて高機能なloopマクロが定められているため、多くのニーズはloopマクロの節(forcollectなど)を使用することで満たされると思います。

しかし、loopマクロで満たされないニーズもあります。
  • loopマクロの内部は独自の構文であるため、Lispらしくないと言われます。これは単に感覚的なものだけでなく、EmacsやVimなどのエディタで適切なインデントを自動で行うことができない、という明確な弱点も存在します。
  • loopマクロは高機能ですが、loopマクロの内部で使う独自の節を定義することはできず、拡張性はありません。
ANSI Common Lispの標準には含まれないサードパーティ製のマクロであるiterateは主にこの2点に対するアプローチを提供します。つまり、iterateloopと異なるのは、「自動インデントに対応したLispらしい構文を持つ拡張可能な繰り返しマクロ」であるという点に尽きます。

実際には様々な違いがありますので興味があれば、公式マニュアル(英語)のp.22 "Difference Between Iterate and Loop"を参照してください。当サイトでは標準仕様入門の第6章「繰り返し」でloopマクロについて網羅的に紹介しているため、このページではiterateマクロを網羅的に紹介するのではなく、loopとの違いに着目して要点を紹介していきます。

構文上の違い

カッコの有無

loopiterの最大の違いは、カッコの有無です。
(loop for i from 1 to 10
      collect i)
; => (1 2 3 4 5 6 7 8 9 10)

(iter (for i from 1 to 10)
      (collect i))
; => (1 2 3 4 5 6 7 8 9 10)

単純な違いですが、これによってEmacsにおける自動インデントなども変わってきますので、とても大きな違いです。

宣言のスタイル

そして、もう一つの大きな違いが、型の宣言の方法です。
(loop for i fixnum from 1 to 10
      collect i)
; => (1 2 3 4 5 6 7 8 9 10)

(iter (for i from 1 to 10)
      (declare (type fixnum i))
      (collect i))
; => (1 2 3 4 5 6 7 8 9 10)

iterateではdeclareシンボルを使うこともtheスペシャルオペレータを使うこともできます。
(iter (for (the fixnum i) from 1 to 10)
      (collect i))
; => (1 2 3 4 5 6 7 8 9 10)

変数束縛の位置

loopマクロは変数の束縛について、マクロ内の先頭部分で行う必要があります。他方、iterateでは場所は選びません。
(loop for x from 1 to 5
      do (print x)
      for y = (expt x 2)
      do (print y))
; WARNING: LOOP: FOR clauses should occur before the loop's main body
; 1 
; 1 
; 2 
; 4 
; 3 
; 9 
; 4 
; 16 
; 5 
; 25 
; => NIL

(iter (for x from 1 to 5)
      (print x)
      (for y = (expt x 2))
      (print y))
; 
; 1 
; 1 
; 2 
; 4 
; 3 
; 9 
; 4 
; 16 
; 5 
; 25 
; => NIL

節の違い

マクロ内の節については細かい違いが多くありますが、最も顕著なのはfor節です。

対象loopiterate
リストinin
ベクトルacrossin-vector
シーケンスなしin-sequence
文字列なしin-string
添字番号なしindex-of-vector,
index-of-sequence,
index-of-string
ハッシュテーブルbeing the
hash-keys in,
being the
hash-values in
in-hashtable
シンボルbeing the&nbsp
symbols in
in-package
公開シンボルbeing the
externl-symbols in
in-package ...
external-only t
ファイルなしin-file ...
using #'read-line
ストリームなしin-stream ...
using #'read-line

表を見ていただくと一目瞭然ですが、iterateはわかりやすいキーワードで豊富な機能が提供されています。

loopにはない機能

多値の束縛

ANSI Common Lisp標準のloopにもリストの分配機能はありますが、多値を直接束縛できる機能はありません。

他方、iterateにはvalues小節があるため、多値を分配束縛できます。
(iter (repeat 5)
      (for (values ss mm hh d m y) = (get-decoded-time))
      (format t "~a/~a/~a ~a:~a:~a~%" y m d hh mm ss)
      (sleep 1))
; 2018/3/20 23:49:14
; 2018/3/20 23:49:15
; 2018/3/20 23:49:16
; 2018/3/20 23:49:17
; 2018/3/20 23:49:18
; => NIL

previousによる前方参照

ループの最中に一つ手前の値を参照したい場合があります。
(loop for v in '(1 2 3 4 5)
      with b = 0
      collect (cons b v)
      do (setf b v))
; => ((0 . 1) (1 . 2) (2 . 3) (3 . 4) (4 . 5))

iterateにはこのような手前の値を参照する機能がついています。for節などでprevious小節を使うと、対象の変数の手前の値を参照して束縛することができます。
(iter (for v in '(1 2 3 4 5))
      (for b previous v initially 0)
      (collect (cons b v)))
; => ((0 . 1) (1 . 2) (2 . 3) (3 . 4) (4 . 5))

前方参照も1つ手前だけでなく、back小節を使うことで2つ以上遡ることもできます。
(iter (for v in '(1 2 3 4 5))
      (for b previous v initially 0 back 2)
      (collect (cons b v)))
; => ((0 . 1) (0 . 2) (1 . 3) (2 . 4) (3 . 5))

generate

ANSI標準のloopマクロにはない機能として、iterateには簡易的なジェネレータの機能がついています。ジェネレータはイテレーション(繰り返し処理)をより一般的に表現するものであり、「繰り返しの中で値がどのように変化するか」という処理と、「どのタイミングで値を変化させるか」というタイミングを分けて扱うことができます。

例えば、シーケンスの中からnil以外の値を行番号と共に表示するプログラムは、一般に以下のように書くことができるように見えます。
(defun non-nil-printer (seq)
  (iter (for i from 1)
        (for v in-sequence seq)
        (when v (format t "~a: ~a~%" i v))))
; => NON-NIL-PRINTER

この関数は、nilがない場合は正常に動作しますが、肝心のnilがある場合に、行番号がズレてしまいます。
(non-nil-printer '(a b c d e))
; 1: A
; 2: B
; 3: C
; 4: D
; 5: E
; => NIL

(non-nil-printer '(a b nil d e))
; 1: A
; 2: B
; 4: D
; 5: E
; => NIL

これは行番号が1から順に増えていくことはfor節で明示できても、どのタイミングで増やすか(増やさない場合はどのような時か)を明示できないため、条件分岐の有無に関わらず繰り返しの回数だけ値が変化することになります。

ジェネレータを使うと、値を増やすタイミングを明確に分離することができます。
(defun non-nil-printer (seq)
  (iter (generate i from 1)
        (for v in-sequence seq)
        (when v (format t "~a: ~a~%" (next i) v))))
; => NON-NIL-PRINTER

iterateのジェネレータはgenerate節で生成され、next節でイテレーション(値を順次変化させる処理)が行われます。when節の条件分岐を満たさない場合はnextが実行されないため、値が変化しないまま再度繰り返しを行うことが可能となります。
(non-nil-printer '(a b c d e))
; 1: A
; 2: B
; 3: C
; 4: D
; 5: E
; => NIL

(non-nil-printer '(a b nil d e))
; 1: A
; 2: B
; 3: D
; 4: E
; => NIL

iterateの拡張

iterateは拡張可能であることを前提に設計されているため、繰り返し処理を行いたいデータ構造に合わせた機能の追加を柔軟に行うことが可能です。

iterateマクロの拡張は、大きく分けると2通りの方法が用意されています。
  1. 節の追加: defmacro-clause
  2. データ構造の追加: defmacro-driver
どのようなデータから値を繰り返し取得するのか、という点が後者で、取り出した値をどのように処理するかというのが前者です。

iterateはすでにデータ構造も処理方法も多様な機能がありますので、そもそも拡張の必要性があるかどうかは十分に検討する必要があると思いますが、このページでは学習向けにサンプルを考えてみましたので、参考にしてみてください。

節の追加: defmacro-clause

この小節では「移動平均」をサンプルとして、iterateマクロの節の追加の方法を示します。

時系列データを扱う際、1期ごとの変動ではなく中長期的なトレンドを見たい場合、「移動平均法」を用いてデータを加工する場合があります。移動平均法は、隣り合うN個の標本の平均を取っていく処理で、データの種類にもよりますが、最も簡単な例だとN=3を使用し、t-1期・t期・t+1期の3つのデータの平均をt期の値とするパターンがあります。

今回は2017年の消費者物価指数のデータを移動平均化してみます。
(defparameter *cpi* #(100.0 99.8 99.9 100.3
                      100.4 100.2 100.1 100.3
                      100.5 100.6 100.9 101.2))
; => *CPI*

まずは関数を使用して、移動平均のアルゴリズムを実装します。
(defun moving-average (sequence)
  (iter (for v in-vector sequence)
        (for b2 previous v back 2)
        (for b1 previous v back 1)
        (with result = '(na))
        (when b2 (push (/ (+ b2 b1 v) 3) result))
        (finally (return (nreverse (push 'na result))))))
; => MOVING-AVERAGE

せっかくなのでpreviousを使用してみました。この場合、t-2期・t-1期・t期の3点の平均を取り、t-1期の値とする処理になりますが、結果は同じです。3期平均の場合は最初の1期目と最後のN期目の平均値は取れませんから、'na (Not Available)を置いています。
(moving-average *cpi*)
; => (NA 99.9 100.0 100.200005 100.30001 100.23334 100.19999 100.299995 100.46667
;     100.666664 100.9 NA)

このmoving-average関数をそのままマクロにしてしまえば、iterateの節になります。節を追加するにはdefmacro-clauseを使用します。マクロを記述するので、Alexandriawith-gensymsマクロを使用します。
(asdf:load-system :alexandria)
; => T

(defmacro-clause (MOVE expr)
  (alexandria:with-gensyms (b2 b1 result)
    `(progn (for ,b2 :previous ,expr :back 2)
            (for ,b1 :previous ,expr :back 1)
            (with ,result = '(na))
            (when ,b2 (push (/ (+ ,b2 ,b1 ,expr) 3) ,result))
            (finally (return (nreverse (push 'na ,result)))))))
; => (MOVE EXPR)

これでiterateに新しい節moveを導入できます。
(iter (for v in-vector *cpi*) (move v))
; => (NA 99.9 100.0 100.200005 100.30001 100.23334 100.19999 100.299995 100.46667
;     100.666664 100.9 NA)

defmacro-clauseのラムダリストは大文字がキーワード、小文字が変数として扱われます。

せっかくなので、move節をもう一歩拡張しましょう。
  • 変数束縛の追加: intoを使用できるようにする
  • 同義語の追加: movingも併用できるようにする
;; &optional引数も使用できる
(defmacro-clause (MOVE expr &optional INTO var)
  (alexandria:with-gensyms (b2 b1)
    ;; iterate::*result-var*は返り値用の内部変数
    (let ((result (or var iterate::*result-var*)))
      `(progn (for ,b2 :previous ,expr :back 2)
              (for ,b1 :previous ,expr :back 1)
              (with ,result = '(na))
              (when ,b2 (push (/ (+ ,b2 ,b1 ,expr) 3) ,result))
              (finally (setf ,result (nreverse (push 'na ,result))))))))
; WARNING: replacing clause (MOVE)
;         with (MOVE &OPTIONAL INTO)
; => (MOVE EXPR &OPTIONAL INTO VAR)

;; defsynonym で同義語を簡単に追加できる
(defsynonym moving move)
; => MOVE

intoを使うときは(or var iterate::*result-var*)という書き方を使用します。ダブルコロンを使用するスタイルは一般には推奨されません。なぜなら、exportされていないシンボルにアクセスすることになるからです。そのため、標準仕様入門の第11章「パッケージ」でもあえて紹介していません。しかし、iterateの場合は公式マニュアルに記載があるので、この書き方を用いても問題ありません。

defsynonymマクロは同義語を手軽に追加できます。一般にloopiterateには現在進行形も合わせて定義されているため、このように同義語を追加しておきます。

(iter (for v in-vector *cpi*)
      (move v into rslt)
      (finally (return (remove 'na rslt))))
; => (99.9 100.0 100.200005 100.30001 100.23334 100.19999 100.299995 100.46667
;     100.666664 100.9)

intoがあるとこのように返り値を細かくコントロールできます。removeはANSI Common Lisp標準の関数で、指定された要素をシーケンスから削除します。標準仕様入門の第17章「シーケンス」をご参照ください。

なお、defmacro-clauseで節を定義するとiterate内の構文シンボルはキーワードとしても同時に使用可能になります。
(iter (for v :in-vector *cpi*)
      (move v :into rslt)
      (finally (return (remove 'na rslt))))
; => (99.9 100.0 100.200005 100.30001 100.23334 100.19999 100.299995 100.46667
;     100.666664 100.9)

:in-vector:intoがキーワードでも正常に動作します。

データ構造の追加: defmacro-driver

この小節ではiterateを行列(2次元配列)向けに拡張する例を通じて、for/generate節の拡張、すなわちデータ構造の追加の方法を示します。

行列をイテレーションする場合、行単位、または列単位による繰り返しができると便利です。そこで、ある行を指定して全ての列を繰り返すin-rowsと、ある列を指定して全ての行を繰り返すin-colsを作ってみます。

for節を拡張するにはdefmacro-driverを使用します。
;; あらかじめalexandriaとarray-operationsをロードしておく
(asdf:load-system :alexandria)
; => T
(asdf:load-system :array-operations)
; => T

;; in-rowsの定義
(defmacro-driver (FOR var IN-ROWS v ROW r)
  (alexandria:with-gensyms (array length index)
    (let ((kwd (if generate 'generate 'for)))
      `(progn
         (with ,array = ,v)
         (with ,length = (aops:nrow ,array))
         (with ,index = -1)
         (,kwd ,var next (progn (incf ,index)
                                (when (<= ,length ,index) (terminate))
                                (aref ,array ,r ,index)))))))
; => (FOR VAR IN-ROWS V ROW R)

;; in-colsの定義
(defmacro-driver (FOR var IN-COLS v col c)
  (alexandria:with-gensyms (array length index)
    (let ((kwd (if generate 'generate 'for)))
      `(progn
         (with ,array = ,v)
         (with ,length = (aops:ncol ,array))
         (with ,index = -1)
         (,kwd ,var next (progn (incf ,index)
                                (when (<= ,length ,index) (terminate))
                                (aref ,array ,index, c)))))))
; => (FOR VAR IN-COLS V COL C)

defmacro-driverの内部では暗黙的にgenerate変数を使うことができます。これはforではなくgenerateでイテレーションする場合をキャッチするための変数です。

terminateiterateの節の一つで、繰り返しを終了することができます。

以下のように使います。
(defparameter *arr* #2a((1 2 3)(4 5 6)(7 8 9)))
; => *ARR*

;; 2行目の各列に対するイテレーション(横移動)
(iter (for v in-rows *arr* row 1) 
      (collect v result-type 'simple-vector))
; => #(4 5 6)

;; 2列目の各行に対するイテレーション(縦移動)
(iter (for v in-cols *arr* col 1) 
      (collect v result-type 'simple-vector))
; => #(2 5 8)

ついでに、各列の和を求める関数も定義してみます。
(defun sum-cols (array)
  (iter (for i :from 0 :below (aops:ncol array))
        (collect (iter (for v :in-cols array :col i) (sum v)))))
; => SUM-COLS

(sum-cols *arr*)
; => (12 15 18)

iterateはこのように対応可能なデータ構造を自由に増やすことができます。

0 件のコメント :

コメントを投稿