独学Common Lisp

判断する計算機、複雑なlambda

判断するLisp

これまでのプログラムでは「条件分岐」を行いませんでしたが、実際のプログラムでは条件分岐が多用されます。条件分岐が不要な計算とは、ある一定の解法に従えば必ず計算できる(計算が終わる)ものです。ただし、例えば公式に従っていてもゼロで割るような計算が出てきたときにエラーを発生させずに静かに0を返すような仕様にしたい場合など、「もし0なら」という判断が必要な場合もあります。私がよくあるのが、国際的なデータで統計が整備されていない国の数値が0になっていて、全てマップ関数で割ろうとするとエラーになってしまうので、0の時は割り算をせずに0を返すようなlambdaを書いてマップ関数に渡すことがあります。

いずれにせよ計算には条件分岐が必要であり、LISPは人工知能開発に多く用いられていたこともあって、機械自身が判断できるような条件分岐機能が充実しています。このページでは条件分岐に関するオペレータを紹介すると共に、これまで学んできたlambdaについてCommon Lispらしい実用的な拡張を確認していきます。

便利なカウンタ

今回扱おうと思っている問題は「カウンタ」です。カウンタというのは数を数える機械です。カウンタは以下の機能を持ちます。

  1. 最初に押すと1が出る
  2. 次に押すと前の数+1が出る
  3. 手順2を繰り返す

基本的な機能はこれだけですが、今回はいくつかの機能を付け足したいと考えています。

ほとんどの場合には基本的な機能だけで十分なので、基本的な機能と追加的な機能は分けておきたいと考えています。いざという時は追加的な機能も使える、という実装にしてみます。

setf, incf, decf

今回のカウンタには偏差値を求める時に使ったlambdaによるクロージャが使えそうです。あの時は平均点と標準偏差を内包した状態で得点に対する偏差値を計算して返すようなラムダ自体を返す関数を定義しました。「関数を返す関数」です。

今回も同じ性格を持ちます。カウンタはカウントしている数を保持しなければなりませんし、呼び出された時に計算ができなければなりません。そのため、「カウンタを作る関数」では「カウンタ関数」としてラムダ(レキシカルクロージャ)を返すように設計します。

しかし、今回が偏差値の時と違うのは、ラムダが保持する情報を更新しなければならないことです。偏差値計算にとっての平均点と標準偏差は固定的な定数でしたが、カウンタは呼び出される度に1を加算して値を更新していかなければなりません。

Common Lispにはlambdaがあってシンボルを束縛変数として使えますからC言語をはじめとする他の言語よりも値の更新、つまり破壊的代入が行われることは少ないでしょうが、実は結構便利なオペレータが用意されています。setfは代表的な代入オペレータで、また別のページで紹介しますがsetfで様々なデータ型に対してアクセスすることができます。

インクリメントとデクリメントも用意されており、incfdecfで値を1足して更新したり1引いて更新したりすることができます。今回は通常のカウンタ機能ではincfを使用し、追加的機能ではsetfを使用することになりそうです。

では情報がそろいましたので、これでカウンタを作ることができます。

(defun set-counter ()
  (let ((c 0))
    (lambda ()
      (incf c)
      c)))

(defparameter counter (set-counter))
(funcall counter)
; => 1

set-counterはカウンタそのものではなく、カウンタを作る関数です。カウンタとなるのはlambdaの部分で、その上のletで束縛された変数cがカウント数を保持します。letは変則的なlambdaであり、レキシカルスコープを作ることができますから、letの中に入っているラムダからはcを参照することができます。カウンタとしてのラムダ自身もレキシカルスコープを作りますから、計算を行うのに必要なcの情報を内包した状態で無名関数となり、counter変数にはそのラムダが定義されることになります。あとはfuncallで呼び出せば、incfで1が加算された後、カウンタ数を返しますのでカウンタとして機能します。

&optionalなラムダリスト

基本的な機能はできましたから、次は追加的な機能の実装に移ります。

まず、リセットモードの実装を考えますが、ここでlambdaを拡張しなければなりません。リセットするかどうかをラムダに伝えるにはlambdaの引数を使うのが自然ですが、基本的な機能だけでも動くようにしながら、必要であればリセットができるということを達成するためには「あってもなくてもよい引数」が必要です。これが&optionalな引数です。

lambdaの引数リストの中で&optionalと記述すると、続く引数はオプショナル引数になり、引数を指定しなかった場合のデフォルトの値を指定することもできます。

オプショナル引数を使う場合に合わせてよく使われるのが条件分岐です。オプショナル引数がある場合とない場合で処理を分けることで柔軟な関数にすることができるからです。今回であれば、何も指定しなければリセットせず、何か指定すればリセットするようにします。

ここでCommon Lispの条件分岐において重要なことを学ばなければなりません。tnilです。

Common Lispでは真偽を表す明確な型のようなものは存在せず、空集合だけが偽です。空集合とは'()のことです。quoteは引数を関数の適用ではなくデータ(リテラル)として扱うオペレータでした。引数が空(())の場合、Common Lispでは真偽判定における偽(nil)として扱われます。逆に、偽(nil)は空集合でもあります。

'()
; => NIL
(quote ())
; => NIL
nil
; => NIL

Lispにとってはどれも同じですが、人間にとっては空集合('())なのか偽(nil)なのかを使い分けてソースコードを記述すると見やすいので、文脈に応じて使い分けられます。

偽はnilですが、真はそれ以外の全てです。本当に、言葉通り全てです。例えば0は数値であり、真です。C言語から来た人は注意してください。

真はなんでもよいので本当に何を使ってもいいのですが、nilの対義語として歴史的に使われてきたシンボルであるtが用意されています。tは予約されたシンボルなので、変数名として使うと混乱のもとになりますからやめましょう。もっとも、真はnil以外の全てですから、誤ってtを何か別の値に束縛したとしてもそれがnil('())以外であれば真偽判定は正常に動くはずです。

t
; => T

宣言されていない変数を打ち込むとエラーになりますが、tは予約語なのでエラーにはならずt自身として評価されます。この点で、tnilは数と同じ評価すると自分自身になる値です。

もう一つだけ追加の知識が必要なのですが、ソースコードを見た方が早いと思うので&optional引数を用いたリセットモード付カウンタを示します。

(defun set-counter ()
  (let ((c 0))
    (lambda (&optional reset)
      (if reset
          (setf c 0)
          (progn
            (incf c)
            c)))))

lambda&optional引数としてresetがついています。デフォルトの値が指定されていないときはnilになります。値を指定しておく場合は(reset nil)のようにletの中と同じようにします。

ifスペシャルオペレータでresetを真偽判定します。何も指定されていない場合、resetnilですから、偽がデフォルトとして動作します。偽の場合の処理はprognという所です。

prognスペシャルオペレータは複数の処理を一つにまとめるオペレータです。progn全体の評価結果はprogn内の最後の式の評価結果になります。ifは真の場合の処理と偽の場合の処理をそれぞれ1つの式として与えなければならないので、複数の処理が必要な場合はprognで括ります。

もしカウンタが以下のような形で適用されると、カウンタの値がリセットされます。

(funcall counter t)
; => 0

tの部分はnil以外であれば何でもリセットできます。

(funcall counter 0)
; => 0

何も指定しないか、nilを指定した場合は普通のカウンタとして動作します。

(funcall counter)
; => 1
(funcall counter nil)
; => 2
(funcall counter '())
; => 3

&key キーワード

オプショナルキーワードはとても便利ですが、欠点があります。それは、増えると分かりにくいということです。

lambdaの引数はその順番で束縛されていくので、オプショナル引数がオプショナルとはいっても飛ばして指定することはできません。今回のように複数の機能を追加したい場合、オプショナル引数をどんどん足していくとオプショナルではなくなってしまいます。たとえnilでも、ある機能を指定するまでの全ての引数を指定しなければいけません。

このような時に便利なのが&keyによるキーワード引数です。

Common Lispにはキーワードという特別な機能が組み込まれています。これは別のページで説明するパッケージにより実現されているものですが、試しに:をつけて何かシンボルを入力してみてください。

:abc
; => :ABC

普通、定義されていないシンボルを入力するとエラーになりますが、コロンだけを付けた場合、キーワードパッケージという特別なパッケージに即時定義(インターン)され、そのキーワード自身として評価されます(Clozure CLの場合、インタプリタではコロンをコマンドとして使うので、「よく分からないコマンドです」のように表示されますが、エラーにはなりません)。試しにコロン一つではなくkeyword:を付けて入力してみましょう。

keyword:abc
; => :ABC

lambdaの中に&keyを記述すると、以後の引数はキーワード引数になります。lambdaの中では普通の引数のように指定することができ、関数を呼び出す側からは上述のようにキーワードスタイルで指定して変数を束縛させることができます。

&optionalにするか&keyにするかは判断の分かれる所ですが、最近はミックスして使わない方がいいと言われています。順番を気にしつつ、どのキーワードがどの機能かを調べながら使うのは混乱のもとになりますから、どちらか片方にするのが望ましいでしょう。

今回はリセットモードとプラス・マイナスモードは全く別の機能なので、キーワードを使って使い分けるようにします。

(defun set-counter ()
  (let ((c 0))
    (lambda (&key reset (add 0))
      (if reset
          (setf c 0)
          (if (zerop add)
              (progn
                (incf c)
                c)
              (progn
                (setf c (+ c add))
                c))))))

新しいオペレータが出て来ました。zeropはCommon Lispで「述語」と呼ばれる関数です。「述語」は真偽判定を専門に行う関数で、多くはpで終わる名前がついています。今回は名前の通りゼロかどうかの判定を行う関数で、ゼロの場合は通常のカウンタ、ゼロでない場合は指定された値を加算します。

おそらく最初は直感的にこのように書くのだと思いますが、実は工夫することで条件分岐を減らすことができます。着目すべきはzeropの条件分岐で真偽の処理に違いが少ないことです。機能を足したように見えますが、実は1を足すか指定された値を足すかの違いしかありません。なので、addの初期値を0ではなく1にすれば常にaddを足すだけで実装することができます。

(defun set-counter ()
  (let ((c 0))
    (lambda (&key reset (add 1))
      (if reset
          (setf c 0)
          (progn
            (setf c (+ c add))
            c)))))

(defparameter counter (set-counter))
(funcall counter)
; => 1
(funcall counter :add 10)
; => 11
(funcall counter :add -5)
; => 6
(funcall counter :reset t)
; => 0

:addというキーワードですが、負の値を指定することでマイナスモードも同時に実現できます。

条件分岐は便利ですが、ついつい不要な条件分岐をしてしまいがちです。人間も裁判のように判断を下す局面ではじっくり時間をかけて考えるように、計算機にとっても条件分岐は少ない方が効率的かつ安全に処理することができますから、ソースコードの可読性を損わない範囲で分岐を減らすべきです。

cond

さて、最後に値のチェックを行うセーフチェックを追加します。現在のコードでは:addに数以外を指定するとエラーになりますし、小数などの整数以外を指定した場合もそのまま計算してしまいますが、カウントは数の中でも特に整数でなければなりません。さらに、マイナスのカウントはありませんから、:addで値を操作する時にマイナスにならないような操作が必要です。

色々と考えていると、ifが深くなっていきそうな予感がします。ifのelseのifのelse...というように深く深くなってくるとソースコードの可読性が低くなりますから、Common Lispには複雑な条件分岐にも適したcondオペレータが用意されています。

条件分岐で気を使うのは、評価の順番です。評価の順番を間違えると期待通りに動きませんから、機能を追加しながら安全性を高める場合には、セーフチェックを先に行って処理対象外の事象を排除していくことが一般的です。今回は以下のような評価順序にします。

  1. :resetnil以外ならリセットする
  2. :addが整数でなければERRORシンボルを返す
  3. :addがカウンタの値よりも大きな負の値であればカウンタは0にする
  4. :addをカウンタの値に足す(デフォルトは1

では、以下にソースコードを示します。

(defun set-counter ()
  (let ((c 0))
    (lambda (&key reset (add 1))
      (cond (reset
             (setf c 0))
            ((not (integerp add))
             'ERROR)
            ((< (+ c add) 0)
             (setf c 0))
            (t
             (setf c (+ c add))
             c)))))

新しい述語としてintegerpを使っていますが、これが整数かどうかを判定します。not関数は真偽をひっくり返しますから、(not (integerp add))addは整数「でない」場合を判定します。'ERRORquote(')を使っていますので文字通りのERRORというシンボルが返されます。

condの最後でtを使っています。tは常に真ですから、最後の節はどのような場合にも真になります。デフォルト節として利用するときにtを使います。

condは各節をカッコで括って記述しますので、複数の式をそのまま記述できます。ifよりもcondを好む人もいますが、Common Lispではifが根源的なスペシャルオペレータとして仕様化されており、condifで記述できるマクロとして定義されています。もっとも、Common Lispではマクロもオペレータも関数も同じような効率で実行できるので、状況や好みに応じて好きに使い分けてください。

さらに、Common Lispにはwhenunlessというマクロもあります。これはifの真の処理だけ、偽の処理だけを行うオペレータですが、特化型だけに複数の処理をそのまま記述することができるため、prognが不要です。可読性の観点から、以下のような記述を推奨します。

おまけ: クロージャ型フィボナッチ数列生成関数

条件分岐は数学においてもとても強力です。今回用いたようなレキシカルクロージャと条件分岐を組み合わせるスタイルは数列の生成にも力を発揮します。

ifを一つだけ用いたフィボナッチ数列生成クロージャを返す関数の例を以下に示します。

(defun set-fib ()
  (let ((f1 0)
        (f2 1)
        (n  0))
    (lambda ()
      (if (< n 2)
          (let ((fib n))
            (incf n)
            (values fib fib))
          (let ((fib (+ f1 f2))
                (fibn n))
            (setf f1 f2)
            (setf f2 fib)
            (incf n)
            (values fib fibn))))))

(defparameter fib (set-fib))
(funcall fib)

せっかくなので前回用いたvaluesを使って、フィボナッチ数列の値と、それが何番目の数なのかという情報を合わせて返すようにしています。フィボナッチ数列は2個前と1個前の値で次の値が決まりますから、値を保持するクロージャを使えばとても簡単に実装できます。

参考文献


Copyright © 2017- satoshiweb.net All rights reserved.