関数型言語のスタイル

関数型言語の記述スタイル

AutoLISP に限らず LISP 一般は関数型言語の中でも、ゆるい関数型です。人によっては LISP は関数型言語とは呼べないという人もいます。そのような状況ですから、手続き型言語のスタイルでプログラムを書いても、まったくかまわないのですが、少しでも関数型言語らしくプログラムを記述してみましょう。

AutoLISP で採用できる関数型言語のスタイルは次のようなものです。三つ挙げますが、相互に関連する話です。

  1. 代入はなるべく行わない
    代入は手続き型言語では必要なものであり、プログラムに欠かせないもののように思われます。しかし、代入は副作用をもたらす操作であり、関数型言語の話題で出てくる「参照透明性」を損なうものです。

    参照透明性とは、「式の評価をする際に、引数が同じならば、必ず同じ値に評価されること」などと説明されます。数学の sin 関数などは、参照透明性が保証されていると言っていいでしょう。

    しかし、その意味するところは、もうすこし複雑です。次のような計算をして返す関数があったとします。*val* 変数はグローバル変数としてどこかに存在しているとします。そのため add 関数を呼び出す際に *val* 変数の値がどのようになっているか判らず、参照透明性が保たれているコードとは呼べません。

    (defun add (num) (+ *val* num))

    ここで「どのようになっているか判らない」というのは、二つの意味があります。一つ目は *val* の値によって戻り値が異なることです。これは参照透明性としては良くないことですが、*val* に正しい値がセットされて、正しくプログラムが動いているときはかまわない話です。このような書き方をしなければならない場合もありますが、なるべく関数で使う値は引数で渡すようにしたほうが良いでしょう。しかし、あくまで *val* に正しい値がセットされている場合です。つまり二つ目の意味として、 *val* の値に正しい値がセットされているか判らないという問題があります。この関数を使うプログラムの中で、次のような内容の関数が、本来の意図とは異なる所で呼び出されていたとしますと、プログラムは正しく動きません。

    (defun resetVal () (setq *val* nil))

    つまり、*val* の値が不用意に改変される可能性があるので、参照透明性が決定的に損なわれています。これを関数型言語の思想は問題視するのです。そもそも、このような関数を使ったコードを書くこと自体が手続き型言語の発想とも言えますが、とにかく代入は諸悪の根源として関数型言語では嫌われます。LISP では大丈夫ですが、関数型「原理主義」の言語では、代入という操作を出来なくしたほど悪しきものと考える場合もあります。

  2. ループはなるべく書かない
    手続き型言語での for や while といったループを関数型言語では嫌います。ループを使わないでどうやってプログラムを書くのか、最初は怪訝に思われるでしょうが、これらは再帰関数や高階関数を使うことによって実現できます。なぜそうするのか?は、まず、先に挙げた代入を行わないで済むからです。そして、ループはループするいう目的で共通しているので、アルゴリズムの核の部分とは切り離して書くという目的です。また、AutoCAD や AutoLISP ではあまり関係ないことではありますが、一般的に並列処理による高速化が行いやすい実行コードを生成しやすいというメリットがあります。

    ただし、ゆるい関数型言語での AutoLISP ゆえに、プログラムの副作用を伴う部分、具体的にはユーザーとの対話やファイルの入出力といった所では、ループが必要になる場合があります。また、一般的にループを再帰関数にすることは実行速度では劣ることになりますが、コンピュータの実行速度が十分に上がった最近では、そのデメリットを考える必要は無くなったと言っていいでしょう。

  3. 共通部分と異なる部分の分離する
    先のループの話ともつながりますが、例えば mapcar などは関数を引数にとる高階関数です。そして引数に渡す関数をいろいろ変えることによって、いろいろな場合に使えるものになっています。ここまで汎用性があるのは特別ですが、関数を使って具体的に実行する内容を差し替えるという仕組みを意識していくと、共通部分と異なる部分のコードの分離が行いやすくなります。異なる部分が分離しやすいということは、プログラムの変更を柔軟に行え、拡張性があるということです。

歴史的にプログラムの記述方法のパラダイムとして、「構造化」や「オブジェクト指向」というものが提唱されてきました。それらと「関数型スタイル」は並んでいてもおかしくないものと思います。手続き型言語を使っている場合も、知っておいて損はないものです。

代入を避けて書く

代入を行わないで、どうやってプログラムを書けばよいのでしょうか。たいへん単純化したモデルで示したいと思います。

次のような、手続き型スタイルで書かれた関数があったとします。(x + 5) × 2 を返す関数です。

(defun calc (x / a)
  (setq a (+ x 5))
  (* a 2)
)

このような書き方のまずいところは、(* a 2) のところで、a の値が (+ x 5) であること、すなわち参照透明性を保証する書き方になっていない点です。つまり、次のような改変が容易に起こる構造になっています。

(defun calc (x / a)
  (setq a (+ x 5))
  (setq a 0)
  (* a 2)
)

ばかばかしい間違いに見えますが、これはたいへん単純化したモデルであることを思い出してください。複雑なプログラムを書いているときに、これと同じ事が起こりうることは察していただけると思います。

では、どのように書けばいいのか。つまりは、次のようなことが推奨されます。

(defun calc (x) (* (+ x 5) 2))

ここでは、2 を掛ける際に、相手は (+ x 5) であることが保証されており、参照透明性が保たれています。このような単純な例では、最初から最後の例のように書かれるとは思いますが、そこには深い考察が隠されているというわけです。

では代入は絶対に使ってはだめなのでしょうか。アルゴリズムの中には、同じ値を何度か計算に使う場合もあるでしょう。そのような同じ値を、使うたびに一から計算するのは全くの無駄と言えます。あくまで程度問題ではありますが、AutoLISP においては合理的な範囲内で代入は使っても良いと思います。

関数型言語スタイルで関数を書いていくと、だいたい次のような、最初に変数の初期化があり、次ぎに目的の式の計算といった、二段階構成となります。 初期化のための代入はセーフですが、ひとたび値が設定された後に値を変更する代入は、ちょっと立ち止まって避けられないか考えることをお勧めします。

(defun FOO (引数 / ローカル変数)
    (ローカル変数の初期化、すなわち代入)
    ((式) (式) (式) ...)
)

また、プログラムの内で副作用を伴う部分では、手続き型で事が進むので、AutoLISP では代入が避けられない場合もあります。

代入と拘束

「変数」という呼び方も多分に値が変化するニュアンスを含んでいますが、関数型言語では変数への代入のことを「拘束」または「バインド」と言います。

AutoLISP を使っている上で拘束は代入と実質同じ事ですが、「拘束」には、ひとたび値が与えられるとその後は値が変更されないというニュアンスが込められています。

ループを再帰関数で置き換える

次のようなリストのデータがあるとします。このような構造化されていない情報は、AutoCAD の ActiveX 関数を使って図面データベースからポリラインの頂点の列の情報を取った場合によく得られます。

(x0 y0 z0 x1 y1 z1 x2 y2 z2 ...)

これを次のような構造をもったリストに変換する関数を考えます。グループにする数は 2 や 3 が考えられますから、引数で渡して汎用性を持たせます。

((x0 y0 z0) (x1 y1 z1) (x2 y2 z2) ...)

手続き型スタイルで書くと次のようなものになるでしょうか。ループが while とrepeat の二重ループになっています。setq の代入により繰り返しごとに変数 alist や group result の値が変化しており、参照透明性が保たれているコードとは言えません。

(defun organizeGroup (alist capacity / result group)
  (while (<= capacity (length alist))
    (setq group nil)
    (repeat capacity
      (setq group (append group (list (car alist)))
            alist (cdr alist)
      )
    )
    (setq result (append result (list group)))
  )
  result
)

再帰関数とすることで次のように書けます。ループを消すことにこだわったので、二重ループが少し複雑な再帰関数となってしまいましたが、とにかくループは消せるものです。今回の場合は、内側の repeat のループは残した方が、わかりやすいかもしれません。また、再帰関数とすることで、代入の setq が消えたことに注目してください。

(defun organizeGroup:sub (alist index capacity group)
(if (< 0 index)
(organizeGroup:sub (cdr alist) (1- index) capacity (append group (list (car alist))))
(if (<= capacity (length alist))
(append (list group) (organizeGroup:sub alist capacity capacity nil))
(list group)
)
)
) (defun organizeGroup (alist capacity /) (organizeGroup:sub alist capacity capacity nil))

再帰関数とスタック

プログラミング言語の内部動作を多少ご存じの方ならば、関数呼び出しの際、引数はスタックと呼ばれる部分を介して受け渡しされることをご存じでしょう。再帰関数で代入を避けることが出来ましたが、相当することが関数への引数、すなわちスタックを利用して行われています。よって、代入を避けるためにスタック領域を消費するのが再帰関数です。

さて、AutoCAD のプログラムの場合、数十万という図形要素をループでスキャンすることはあり得ることです。こういったループを再帰関数で書くと、スタック領域を食いつぶしてエラーが発生する原因になります。ループ回数が尋常でないものは、普通に手続き型のループで書く方が安全です。

ループを高階関数で実現する

先ほどの頂点のリストが OCS に基づく座標だったとしましょう。これらを他の図形と共通とする WCS の座標に変換するシーンを考えます。

まず手続き型のスタイルは次のようなものです。OCS を指定するために、ename 引数に頂点を取り出した元の図形の【図形名】を指定します。plist 引数は、先ほどの構造をつけられた頂点のリストです。ループがある関数はなじみがある形をしていますが、やはり setq の代入は必ず登場します。

(defun OCS->WCS (ename plist / result)
  (while plist
    (setq result (append result (trans (car plist) ename acWorld))
          plist  (cdr plist)
    )
  )
  result
)

しかし、リストからリストへの変換となると高階関数 mapcar の出番です。OCS->WCS という関数にするのも面倒なくらい簡単に書けます。匿名の関数が便利に使えるシーンです。そして、代入を行う必要もありません。

(defun OCS->WCS (ename plist)
  (mapcar (function (lambda (point) (trans point ename acWorld))) plist)
)

カウンターつきのループ

c言語で for ( i = 0 ; i < 10 ; i++) { ... }といったような、カウンターつきのループは便利でよく使われます。このループを消す方法を考えます。

再帰関数でインデックス付きのループを書くと次のような構造になります。「(式)」の部分には index を用いた手続きが書かれます。

(defun FOO (index to)
    (if (< index to)
     (progn
       (式)
       (FOO (1+ index) to)
     )
    )
)

このように定義した関数を「(FOO 0 10)」などと呼び出すと、必要な回数だけ「(式)」で書いた手続きが実行されます。ループごとの結果をリストで返すならば append 関数を使います。

(defun BOO (index to)
    (if (< index to)
     (append
       (list (式))
       (BOO (1+ index) to)
     )
    )
)

再帰関数の場合は、ループした状況によって返すリストに値を含めるか否かなど、柔軟な書き方が出来ます。一方、柔軟性には劣りますが、ループした回数と同じだけの数の結果を必ず得る場合には、高階関数を使った方法が簡便です。まず、インデックス i が取り得る値をすべてリスト化したものを生成する関数を用意します。

(defun range (s e /)
  (if (<= s e)
    (cons s (range (1+ s) e))
  )
)

使い方は以下の通りです。

_$ (range 0 9) ⏎
(0 1 2 3 4 5 6 7 8 9)
_$ (range -1 3) ⏎
(-1 0 1 2 3)

これで生成したリストと mapcar を組み合わせることで、カウンターつきのループと同じ事を容易に書くことができます。実例として【ライトウェイトポリライン】のセグメントの【ふくらみ】値のリストを得る場合を考えます。【ふくらみ】は ActiveX 関数メソッドにセグメントのインデックスを引数で渡して得ることができます。

関数は次ぎのようなります。LWP-ename 引数には対象の【ライトウェイトポリライン】の【図形名】を、numOfPoint 引数には頂点の総数を指定します。頂点のリストを得る方法は後で触れますが、ここではあらかじめ総数が判っているとします。

(defun LWP-BulgeList (LWP-ename numOfPoint / obj)
  (setq obj (vlax-ename->VLA-Object LWP-ename))
  (mapcar (function (lambda (index) (vla-GetBulge obj index)))
          (range 0 (1- numOfPoint))
  )
)

共通部分と異なる部分の分離する

これらを使って AutoCAD の【ライトウェイトポリライン】から頂点の情報を WCS で得る関数を作ってみます。LWP-ename 引数には【ライトウェイトポリライン】の【図形名】を指定します。【ライトウェイトポリライン】の頂点情報は、DXF グループコードによる連想リストでは、情報が頂点ごとに同じグループコードで複数現れ扱いにくいため、ActiveX 関数 vla-get-Coordinates を使う方法で頂点の配列を取り出します。セーフ配列となっているので、vlax-safearray->list 関数で LISP のリストに変換します。そして、XY座標ごとにグループ化した後、Elevation プロパティからのZ座標の情報を加え、最後に、頂点の座標はOCSによるものなので trans 関数でWCSの座標に変換します。

(defun LWP-PointList (LWP-ename / obj elevation)
(setq obj (vlax-ename->VLA-object ename)
elevation (vla-get-Elevation obj)
)
(mapcar
(function (lambda (point) (trans (append point (list elevation)) LWP-ename acWorld)))
(organizeGroup
(vlax-safearray->list (vlax-variant-value (vla-get-Coordinates obj)))
2
)
)
)

さて、AutoCADには【ライトウェイトポリライン】によく似た【ポリライン】という図形タイプがありますが、今度は【ポリライン】の頂点を得ることを考えてみます。【ポリライン】の特徴は、フィットカーブやスプラインといったようなカーブをつけられるところです。【ポリライン】の場合も、vla-get-Coordinates 関数で頂点の配列を得ることができますが、カーブに関する制御点などが混じることがあり純粋な頂点と区別をつけることができません。一方で DXF グループコードによる連想リストで情報を得る方法は、これは複合図形になっており【ポリライン】のレコードに続く頂点のレコードをたどる必要がありますが、頂点の種類に関する情報を得ることができます。よって、【ポリライン】に対しては DXF グループコードによる連想リストで情報を得るアプローチをとってみます。なお、【ポリライン】は 2D と 3D のタイプに分かれますが、ここでは 2D の【ポリライン】だけを考慮することにします。2D の【ポリライン】の頂点座標は OCS ですので WCS に変換します。

【ポリライン】の頂点を得る関数は次のようになります。ここでも、頂点の終わりまでたどる部分を、ループではなく再帰で実現しています。

(defun 2PL-PointList:sub (ename result / V-ename V-data d70)
  (setq V-ename (entnext ename)
        V-data  (entget V-ename)
        d70     (cdr (assoc 70 V-data))
  )
  (if (= (cdr (assoc 0 V-data)) "VERTEX")
    (2PL-PointList:sub
      V-ename
      (if (or (= d70 0) (= d70 16)) ; 0 : VERTEX , 16 = Spline frame control point
        (append result (list (cdr (assoc 10 V-data))))
        result
      )
    )
    result
  )
)

(defun 2PL-PointList (PL-ename)
(mapcar (function (lambda (point) (trans point PL-ename acWorld)))
(2PL-PointList:sub PL-ename nil)
)
)

ここからが本題です。頂点の座標を得る目的の関数が、【ライトウェイトポリライン】の場合と【ポリライン】の場合の二つができました。二つの関数をいちいち区別するのは面倒なので、図形の種類で自動的に必要な関数を呼び出してくれるインターフェース関数を作成します。インターフェース関数が共通部分、そしておのおの頂点を取得する関数が異なる部分となり、これらを統合しようというわけです。ケースによりますが、「ゆるく」統合されていると感じるのが良い統合の形です。「ゆるい」とは、簡単に組み替えたり、追加したりできる感じです。

図形のタイプを調べて if 関数や cond 関数で場合分けするのが、一つの方法です。図形タイプは、【ポリライン】の 2Dと 3D の区別が簡単に付くので ActiveX のアプローチで取得します。

(defun PointList (ename / obj-type)
  (setq obj-type (vla-get-ObjectName (vlax-ename->VLA-object ename)))
  (cond ((= obj-type "AcDbPolyline") (LWP-PointList ename))
        ((= obj-type "AcDb2dPolyline") (2PL-PointList ename))
  )
)

呼び出す関数を、連想リストで管理するとコードの部分には手を加えること無く、リストを書き足してやるだけで新しい図形タイプに対応できます。他にも図形タイプがいろいろあり、後から追加されることもあり得ると考えると、こちらの方が「ゆるい」統合になっていると言えるでしょう。

(setq *PointList:functable*
       (list (cons "AcDbPolyline" 'LWP-PointList)
             (cons "AcDb2dPolyline" '2PL-PointList)
       )
)

(defun PointList (ename)
  (apply
    (cdr (assoc (vla-get-ObjectName (vlax-ename->VLA-object ename)) *PointList:functable*))
    (list ename)
  )
)

実際にきちんと動くかテストしてみます。

_$ (PointList (car (entsel))) ⏎           ; ライトウエイトポリラインを選択
((702.035 -399.927 0.0) (370.702 329.163 0.0) (938.989 329.163 0.0) (1369.81 -469.318 0.0))
_$ (PointList (car (entsel))) ⏎           ; ポリライン(2D)を選択
((173.263 -814.709 0.0) (-187.527 -181.005 0.0) (320.222 -279.501 0.0) (902.057 -642.447 0.0) (798.177 -770.321 0.0))

最後に、図形タイプにより呼び出される関数が変わるのはオブジェクト指向のメソッドを連想します。世界が「もの」と「こと」で構成されているとするならば、「もの」を主軸にモデル化するのがオブジェクト指向で、「こと」を中心にモデル化するのが関数型言語と言えるような構図の時があります。そして、この二つはお互いに干渉はしません。二つを生かしながら一つのプログラムを作ることができます。AutoLISP は言語レベルでオブジェクト指向はサポートしていませんが、ここで片鱗が現れたように、オブジェクト指向をあきらめる必要はありません。