第7章「オブジェクト」

概要

ANSI Common Lispの第7章におけるオブジェクトシステム(CLOS: Common Lisp Object System)を紹介します。一部は第4章「型とクラス」と重複しますので、第4章も適宜ご参照ください。

CLOSの概要

Common Lisp Object System (CLOS) はLisp系言語における唯一の標準化されたオブジェクト指向プログラミングの技法です。CLOSはANSI Common Lispとして標準化されているため、主要な処理系であれば処理系の違いを全く意識せずに、外部ライブラリを使用することもなく、導入することができます。

CLOSの原型は1970年代の後半に登場した Flavors と呼ばれるオブジェクト指向型ドメイン固有言語(DSL)です。このDSLはMacLispの上に導入され、後に New Flavors へと進化して、Symbolics 社のLispマシンで全面的に採用されていました。当時のLispはOS記述言語でもあり、Lispマシンは全てがLispでできていたので、ウィンドウシステムなどを含む完全な「環境」として New Flavors が利用されていたようです。

オブジェクト指向言語の代表である Java が登場したのは1995年ですから、その20年ほど前にLispではオブジェクト指向が使われていたということになります。

Flavors というのはアイスクリームの「フレーバー」から取られた名前ですが、その名の通り、「バニラ」のような基本をベースとして様々なフレーバーを混ぜてオリジナルのアイスクリームを作るという「ミックスイン( Mix-in ))」が特徴でした。これは、現在では「多重継承」とも呼ばれており、複数のクラスからスロットを継承することができます。多重継承は他のオブジェクト指向言語ではほとんどサポートされていません。

CLOSはCommon Lispの一般の機能をベースに設計されていますし、最も基本的なオペレータであり、クラスを定義するdefclassでさえも「マクロ」として実装されていることから、Common Lispの上に設計されたオプショナルなもの、というイメージがありますが、実際にはANSI Common Lispの中核を成す概念です。第1版のCommon Lisp (CLtL1)には存在しなかったのですが、第2版(CLtL2)からは導入されており、ANSI標準では第4章が「型とクラス」であり、第7章がこの「オブジェクト」として定められています。Lispの基本である「リスト構造」については第14章で定められており、CLOSよりも後の章で定めらていますし、第9章の「コンディション」もCLOSをベースに設計されています。CLOSを意識せずにCommon Lispの他の機能だけを使うことも可能ですが、それはあまりにもったいないと言えると思います。

実は私はCommon LispからRacket (Scheme系言語の一種) に移行した時期がありますが、Common Lispに戻ってきた最大の理由がオブジェクト指向の扱いの違いでした。Racketは言語の核が小さくまとまっており、非常に多くの便利なライブラリを持っていますが、オブジェクト指向の実装がsendを中心とした「メッセージパッッシング」スタイルになっており、Lispらしさと相容れないものになっているように感じました。前述の Flavors も最初は Smalltalk の影響を受けてメッセージパッシングスタイルだったのですが、New Flavors の頃にはCLOSと同じ「総称関数」スタイルに進化していました。総称関数はオブジェクト指向の「メソッド」をLispの「関数」と同じように扱うための最も優れた技法だと思います。
「多重継承」と「総称関数」はCLOSが他のオブジェクト指向言語と異なる大きな特徴です。私が感じている「CLOSの特徴」を列挙してみます。
  1. CLOSではクラスとメソッドは互いに独立しています。
  2. CLOSではクラスは複数のクラスからスロットを引き継げる「多重継承」をサポートしています。
  3. CLOSのクラスとオブジェクトは徹底的に「動的」です。実行中にクラスを再定義することもできますし、インスタンスのクラスを変更することもできます。
  4. CLOSの総称関数は第1級のオブジェクト( first class obejct )です。普通の関数と同様に高階関数として利用することもできます。
  5. CLOSの総称関数は「多重ディスパッチ」をサポートしています。一般のオブジェクト指向言語では1つの引数のメソッドしか適用可能となりませんが、CLOSでは複数の引数から適用するメソッドを判定できます。
  6. CLOSの総称関数はメソッドを連結することもできます。メソッド連結の方法は複数用意されています。ほとんどないと思いますが、自分で定義することもできます。
  7. CLOSはCommon Lispで記述できるプログラミングパラダイムの一つですが、全てのCommon Lispプログラマにオブジェクト指向を押し付けているわけではありません。JavaやRubyでオブジェクト指向を使わずにプログラミングすることはできませんが、Common Lispではパラダイムの選択も含めて全てがプログラマに委ねられています。
ANSI Common Lispの第7章では様々なCLOSのオペレータが定められていますが、中にはCommon Lisp処理系の実装者向けのオペレータ(プログラマ向けではないもの)も含まれます。このページではそのようなものを除き、CLOSの基本と上記の特徴を感じることができるように、中級以上の機能も紹介します。初心者の場合は読み飛ばしても構いませんが、Common Lispを使う上ではCLOSを学ぶこともあると思いますので、その時に読み返してみてください。

CLOSによる抽象化の流れ

他の言語のオブジェクト指向に触れたことがない方のために、CLOSによるオブジェクト指向抽象化の一般的な流れを簡単に説明しておきます。
  1. データ構造を考える。 自分のプログラムが扱うデータの構造を簡単にイメージします。例えば、「日時」を扱いたい場合、年・月・日・時・分・秒の6種類の数をまとめて扱えるようなデータ構造が必要です。場合によってはミリ秒やタイムゾーンなども必要かもしれません。
  2. データ構造の継承を考える。 プログラムの仕様が完全に定まっており、拡張の必要性が皆無の場合を除き、プログラムに拡張の余地を残すことはとても有意義です。効率的な拡張には「継承」が有効であり、データ構造に「継承」を使うことができないかどうかを検討してください。例えば「日時」は「日付」と「時刻」をミックスインしたフレーバーと考えることができます。
  3. データの初期化と変更の可能性を考える。 データがどのように初期化され、どのように変更される可能性があるかを考えてください。データの初期化とは、データ構造に具体的な値を入れる処理です。データの変更とは、データ構造の中の値を上書きする処理です。
  4. データの出力と利用方法を考える。 データの初期化を入力とすれば、最終的な利用は出力になります。どのような形式で出力するのか、出力されたデータは単に印字されるのか、それとも「返り値」として他の計算に使うのか、出口のイメージを考えてください。
  5. クラスを定義する。 defclassマクロで必要なデータ構造を実装しましょう。もちろん「継承」を積極的に活用してください。
  6. 初期化処理を定義する。 make-instance総称関数だけで単純に初期化できる場合は少ないかもしれませんので、initialize-instance総称関数で初期化の処理を定義します。
  7. メソッドを定義する。 defmethodマクロで出力(出口)に到るまでに必要な処理をメソッドとして定義していきます。また、メソッドは同名の定義が複数登場してきますから、defgenericマクロで共通事項である「引数の数」と「ドキュメント」を総称関数に定義しておきます。
  8. 普通のCommon Lispプログラムとして実装を続ける。 クラスと総称関数はCommon Lispの他の機能とうまく統合されており、その利用はCLOS以外の部分とほとんど区別されません。総称関数の多重ディスパッチにより適切なメソッドはCommon Lispが選択し、結合してくれますから、必要なタイミングでmake-instance総称関数でインスタンスを作り、必要な総称関数にインスタンスを引数として渡すという関数ベースのプログラミングスタイルでプログラムを構築していきます。
ポイントは3点あると思います。
  • データ構造を優先して考える。 他のオブジェクト指向言語ではクラスの中にデータ構造とメソッドを含めるため、データと処理を並行して考えがちですが、処理はデータ構造に依存するため、データ構造を優先して考えるべきです。優先する、というのは「先に考える」ことに加え、「妥協しない」ことも重要です。特にパフォーマンスが重要になる場合、データ構造が速度に大きな影響を与えます。
  • 入力と出力を考える。 プログラムはフィルタです。入ってくるデータのイメージと、出て行くデータのイメージがズレていると、内部でどんなに素晴らしい処理をしても無意味になります。逆に内部的な実装がたとえまずくても、想定した入力を正しい出力に変えることができればプログラムとして価値があります。改善は後でも構いません。
  • オブジェクト指向に依存しない。 オブジェクト指向は "Obejct-Oriented" であり、オブジェクトで「思考」する訳ではありません。CLOSを使いこなすには、CLOSに向いている部分と向いていない部分を判断しなければなりません。
では、次節からCLOSの概要を見ていきます。ぜひ、Common Lisp流の華麗なるオブジェクト指向を自分でも試して、楽しんでみてください。

サンプル1: 多重継承と初期化

この節では日付と時刻を表すdatetimeクラスの定義を通じて、多重継承とクラスの初期化について説明します。

多重継承

クラスの「継承」は、既存のクラスをスーパークラスとし、新しいサブクラスを定義するという抽象化の技法です。「多重継承」は複数のスーパークラスから新しいサブクラスを定義する、より一般的な「継承」の技法です。「継承」は第4章で説明したので、そちらを参照してください。

多重継承は多用しすぎると複雑化していくので、より簡潔に美しくプログラムを記述するという目的を阻害してしまう可能性があります。しかし、その本質はすでに述べた「ミックスイン」であり、複数のフレーバーを混ぜ合わせて新しいフレーバーを作るということに過ぎません。アイスクリームの製造でさえミックスインの技法を用いているのですから、プログラミングでも普通に使える技法であるべきです。

ここではdatetimeクラスを定義してみます。すでに述べた通り、datetimeクラスは年・月・日という「日付」の情報と、時・分・秒という「時刻」の情報を持ちます。最初から6つの情報を持つクラスとして定義することもできますが、「日付」と「時刻」を定義して、それらを「ミックスイン」することで定義することもできます。
datetimeクラスを初期化する際に、日時を示す文字列を渡してデータを初期化できるようにします。そのような初期化用の文字列はdatetimeのみならず「日付」や「時刻」でも使うので、よりベースとなるクラスを定義して、そちらのスロットを用意しておきます。つまり、ひし形の継承関係にします。
        (4)datetime-base
            /       \
           /         \
          /           \
  (2)simple-date  (3)simple-time
          \           /
           \         /
            \       /
           (1)datetime

多重継承の場合でも、継承の優先順位は決定されます。基本は「左から右へ」ということを覚えておけばそれほど困ることはないでしょう。このひし形継承の例であれば、最も特定的なクラスはdatetimeクラスであり、次はsimple-dateクラス、そしてsimple-timeクラス、最後にdatetime-baseクラスが最も特定的ではないクラスという順になります。

以下に、このひし形多重継承の定義サンプルを示します。
;;;; サンプル1-1: 多重継承

;;; 定義
(defclass datetime-base ()
  ((init-string :initarg :from
                :accessor init-string-of)))
; => #<STANDARD-CLASS DATETIME-BASE>

(defclass simple-date (datetime-base)
  ((year :accessor year-of)
   (month :accessor month-of)
   (date :accessor date-of)))
; => #<STANDARD-CLASS SIMPLE-DATE>

(defclass simple-time (datetime-base)
  ((hour :accessor hour-of)
   (minute :accessor minute-of)
   (second :accessor second-of)))
; => #<STANDARD-CLASS SIMPLE-TIME>

(defclass datetime (simple-date simple-time)
  ())
; => #<STANDARD-CLASS DATETIME>

4つのクラスを定義していますが、これらの継承関係はdefclassマクロの第2引数であるスーパークラスのリストを見ればすぐに分かります。

参考: アクセッサ関数の名称について

なお、アクセッサ関数の名前のつけ方には主に3つの流儀があります。
  1. name-of型: -ofを付けるパターンです。名前の衝突に留意が必要ですが、簡潔かつ明瞭です。
  2. class-name型: 定義したクラス名とスロット名を繋げるパターンです。名前の衝突はおきにくいですが、冗長になりやすいです。また、今回のように継承を使ってクラス名が変化するような場合は分かりづらくなります。なお、構造体はこのようなスタイルで命名されます。
  3. :name型: キーワードシンボルをアクセッサとして使うパターンです。それほど一般的ではないですが、キーワードシンボルの賢い使い方かもしれません。

initialize-instance総称関数

defclassマクロで定義したクラスは第4章で使ったmake-instance総称関数で初期化しますが、スロットに値を初期値として設定するには:initargキーワード引数に指定したキーワードで値を直接渡さなければなりません。しかし、今回のように日時を表す初期化用の文字列だけを渡して、その文字列を解析してスロットに値を設定してほしいという場合には:initargキーワード引数は適していません。このように、初期化において特定の処理をしてほしい時に使うのが「コンストラクタ」です。

ANSI Common Lispで定められているCLOSではinitialize-instance総称関数がコンストラクタに該当します。initialize-instance総称関数は第1引数に初期化するオブジェクト(インスタンス)を取り、第2引数以降に:initargで使う初期化キーワード引数を可変長引数として取ります。

しかし、initialize-instance総称関数をそのまま使うことは一般的ではありません。なぜなら、initialize-instance総称関数が呼び出されるとき、スロットの値はまだ設定されていないので、自分で設定しなければならないからです。

そのため、initialize-instance総称関数の本体(基本メソッド)は標準で用意されているものを使うことが一般的です。標準で用意されているものとは、アイスクリームの「バニラフレーバー」のようなもので、クラスが「ミックスイン」できるように、CLOSのは関数(メソッド)もミックスインすることができます。ミックスインできる関数が「総称関数」と言うこともできるかもしれません。
initialize-instance総称関数に適したミックスインは「:after補助メソッド」です。:after補助メソッドは基本となるメソッドの後に続けて実行されるメソッドです。つまり、簡単に述べると以下のような順序で使うことになります。
  1. make-instance総称関数がinitialize-instance総称関数の基本メソッドを呼び出す。基本メソッドはあらかじめ用意されている「バニラフレーバー」を用いる。このフレーバーは:initargで定められたキーワード引数に基づいて初期化できるスロットを初期化する。
  2. initialize-instance総称関数の「:after補助メソッド」フレーバーを呼び出す。このフレーバーが呼び出される時点では一部のスロットは値が設定されているので、その値を使って別のスロットを初期化することもできる。
メソッドを定義するにはdefmethodマクロを用います。また、:after補助メソッドとして定義するには、メソッド名の次に:afterキーワードを付けて定義します。メソッドの呼び出しは自動的に行われるので、呼び出しのためのコードは必要ありませんが、呼び出しの順序は意識する必要があります。
詳しくは別の節で述べますが、:after補助メソッドは以下の特徴を持ちます。
  • 自動的に「メソッド結合」されます。継承関係にある:after補助メソッドは全て呼び出されます。
  • 結合の順序は「特定度の低い順」です。つまり、単一継承の場合はスーパークラスからサブクラスへの順序で、多重継承の場合は右から左への順序で呼び出されます。
このような「メソッド結合」も総称関数における「ミックスイン」と理解できます。基本メソッドと補助メソッドの関係もメソッド結合ですし、継承関係の特定度による結合もメソッド結合です。継承がクラスにとって根本的な機能であるのと同じように、メソッド結合は総称関数にとって根本的な機能です。
ここではdatetimeクラス向けに、メソッド結合を用いて初期化の処理(「コンストラクタ」)を定義します。datetimeクラスがsimple-dateクラスとsimple-timeクラスから継承することで簡単に定義できたように、datetime用のコンストラクタはsimple-date用のコンストラクタとsimple-time用のコンストラクタを結合(ミックスイン)することで出来上がります。

;;;; サンプル1-2: 初期化(initialize-instance)

;; 文字列を抽出して整数に変換する関数
(defun subseq-integer (string start end)
  (parse-integer (subseq string start end)))
; => SUBSEQ-INTEGER

;; simple-date用のコンストラクタ
;; 年月日の部分は文字列の最初なので、simple-dateでもdatetimeでも処理は同じ
(defmethod initialize-instance :after ((simple-date simple-date) &key)
  (with-slots (init-string year month date) simple-date
    (setf year (subseq-integer init-string 0 4))
    (setf month (subseq-integer init-string 4 6))
    (setf date (subseq-integer init-string 6 8))
    (format t "~a/~a/~a " year month date)))
; =>  #<STANDARD-METHOD :AFTER (#<STANDARD-CLASS SIMPLE-DATE>)>

;; simple-time用のコンストラクタ
;; 時分秒の部分は
;;    * simple-timeの場合: 最初から
;;    * datetimeの場合: 年月日が終わった後から
;; というように、パース(解析)する場所が異なる
(defmethod initialize-instance :after ((simple-time simple-time) &key)
  (with-slots (init-string hour minute second) simple-time
    (let ((start (if (< 6 (length init-string)) 9 0)))
      (setf hour (subseq-integer init-string start (+ start 2)))
      (setf minute (subseq-integer init-string (+ start 2) (+ start 4)))
      (setf second (subseq-integer init-string (+ start 4) (+ start 6))))
    (format t "~a:~a:~a " hour minute second)))
; => #<STANDARD-METHOD :AFTER (#<STANDARD-CLASS SIMPLE-TIME>)>
with-slotマクロは後の節で解説しますので、initialize-instance総称関数の:after補助メソッドを定義する方法だけを確認してください。このパターンはコンストラクタとしてよく用いられます。

総称関数の引数はすでに決まっているので、&keyの部分も含めて記述してください。

初期化例

以下がmake-instance総称関数でインスタンスを初期化し、オブジェクトを生成する例です。
;;;; サンプル1-3: 初期化(make-instance)

(defparameter d1 (make-instance 'simple-date :from "20171029"))
; 2017/10/29
; => D1

(defparameter t1 (make-instance 'simple-time :from "153630"))
; 15:36:30
; => T1

(defparameter now (make-instance 'datetime :from "20171029-153530"))
; 15:35:30 2017/10/29
; => NOW
nowの定義の際に、日付から表示されている点に注目してください。:after補助メソッドは特定度の「低い」順に実行されますから、多重継承の場合は右のスーパークラスのものから順に実行されることになります。今回はsimple-dateクラスを左に、simple-timeクラスを右に置いてdatetimeクラスを定義しているので、simple-timeクラスの方が特定度が低くなり、:after補助メソッドでは先に実行されます。

with-slotsマクロ

ここで、CLOSの基礎ではないですが、サンプルの中に使っているとても便利なマクロを紹介します。

CLOSのクラスで定義されたインスタンスのスロットに対してはアクセッサ関数を経由してアクセスしますが、一つのインスタンスの色々なスロットにアクセスしたい場合、記述が冗長になってしまいます。そこで、with-slotsマクロを使うとスロットへのアクセッサ関数を記述するためのコードをマクロにして、シンボルに束縛してくれます。

例えば、前節までの例ではyearスロットへのアクセスは(year-of instance)と記述することになりますが、このコード自体をメタ化すると'(year-of instance)というS式であり、このS式をローカルなシンボルyearにマクロとして束縛します。そうするとyearと記述するだけでマクロとして機能するのでアクセッサ関数のコードを記述しているのと等しくなります(実際はアクセッサ関数ではなく(slot-value instance 'year)と同じです)。

このようにマクロをシンボルそのものに登録する機能はsymbol-macroletスペシャルオペレータで実現されています。
with-slotsマクロはCLOSのソースコードを明確化・短縮化してくれますので、積極的に活用してください。なお、スロット名がレキシカル環境における他のシンボルと衝突してしまう場合は、スロット名を別のシンボルに束縛することもできます。letの束縛部分と同じ書式です。
;;; シンプルなwith-slots
(with-slots (slot1 slot2) instance1
  ...body...)

;;; 束縛を伴うwith-slots
(with-slots ((alias1 slot1)
             (alias2 slot2)) instance2
  ...body...)

サンプル2: ディスパッチとメソッド結合

前節までに見てきたように、総称関数は「ミックスイン」できるという大きな特徴がありますが、ミックスインしない方法もあります。つまり、様々なフレーバーを混ぜるのではなく、一つだけを選ぶというパターンです。

このような状況は「ディスパッチ」と呼ばれます。状況に応じて実行するメソッドを選択するのです。

ここで、「状況」とは引数の型(正確にはクラス)を意味します。つまり、総称関数に渡される引数の型に応じて自動的に実行するメソッドを選択する機能があるのです。

このようなメソッドのディスパッチ機能は多くのオブジェクト指向言語が有する特徴です。例えば、以下のようなコードがあるとします。
object.method("Hello")

このコードは、Common Lispでは以下のように表現できるでしょう。
(method object "Hello")
methodが実行したい総称関数だとすると、objectの型に応じた適切なメソッドが選択され、そこにobject"Hello"の2つの引数が渡されて実行されます。objectを前に出してドットで繋げているのは単に構文糖としての意味しかない場合もあるかもしれませんが、多くの場合は"Object Oriented"で記述するので、オブジェクトを手前に書くスタイルになっているのです。しかし、どちらも「第1引数の型でディスパッチする」という意味ではあまり違いがありません。

ANSI Common Lispではこのような単一ディスパッチはもちろんできますが、全ての引数の型に応じてメソッドを選択することができる「多重ディスパッチ」を採用しています。サンプル2では単一ディスパッチを、サンプル3では多重ディスパッチを示します。

ディスパッチ

例えば、simple-dateクラスとsimple-timeクラスの2種類でディスパッチを行うメソッドを想定します。機能は、スロットの値を「プロパティリスト」にして返すようにするとします。プロパティリストは「属性リスト」や "plist" などとも呼ばれ、keyvalue が交互に登場するリストのことです。key を手がかりに value を探すことができるので、アクセス速度は遅いですが要素数が少ない場合は手軽なデータ構造として用いられます。

ここでは型の変換を行うcoerce関数にちなんでcoerce-plistという名前の総称関数を定義してみましょう。
;;;; サンプル2-1: ディスパッチ
(defmethod coerce-plist ((simple-date simple-date))
  (with-slots (year month date) simple-date
    (list :year year :month month :date date)))
; => #<STANDARD-METHOD (#<STANDARD-CLASS SIMPLE-DATE>)>

(defmethod coerce-plist ((simple-time simple-time))
  (with-slots (hour minute second) simple-time
    (list :hour hour :minute minute :second second)))
; => #<STANDARD-METHOD (#<STANDARD-CLASS SIMPLE-DATE>)>
initialize-instance総称関数と同じです。引数の部分にクラス名をセットで指定します。
これで、メソッドが複数登録され、実行の際は自動で適切なメソッドが選択されます。
このようなシンプルなディスパッチは全体をCLOSとして設計している場合以外でも使うことができ、大変便利です。型(クラス)による分岐がない場合はdefunマクロを使用した方がディスパッチのための計算がないので高速ですが、typecaseマクロなどで型による分岐をしようとしている場合などはdefmethodマクロによるディスパッチも検討してみてください。CLOSは長い歴史のある技術で、キャッシュなどを使用してコンパイラが賢いコードを生成してくれますので、十分高速に動作します。ディスパッチの機能を関数の外に出すことで関数はより純粋に関数らしく定義することができ、ソースコードの可読性も高まります。

メソッド結合

さて、前節のようにシンプルなdefmethodマクロだけでディスパッチは可能ですが、simple-dateクラスとsimple-timeクラスのメソッドしか定義していないことに注目してください。これだけだとdatetimeクラスによるオブジェクトがcoerce-plist総称関数に引数として渡された場合、simple-dateクラス用のメソッドだけが実行されます。なぜならsimple-dateの方がsimple-timeよりも「左側」のスーパークラスとしてdatetimeクラスを定義したため、より特定的なクラスとなり、優先度が高くなっているからです。

しかし、datetimeクラスのオブジェクトに対してsimple-date用のメソッドしか適用しなければ、年・月・日のプロパティリストだけが生成され、時・分・秒の情報は生成されません。この問題を解決するには2つの方法があります。
  1. datetime用にも新たに定義する
  2. メソッド結合」を使う
1の選択肢は、分かりやすいかもしれませんが望ましくありません。せっかくCLOSを使っているのに、メソッドを単純なディスパッチとしてしか使用せず、本質的にsimple-datesimple-timeのミックスインであるdatetimeのために最初からメソッドを定義するというのは、抽象化に失敗していると言ってもいいかもしれません。

そこで、2の選択肢である「メソッド結合」を使います。メソッド結合自体はサンプル1のinitialize-instance総称関数の:after補助メソッドで使った通り、優先度に応じた適用可能なメソッドを順番に呼び出すというものです。:after補助メソッドは特定的でない順に呼び出されるのが特徴でしたが、一般の場合は特定的な順に呼ばれます。

しかし、今回の例で考えてみてください。initialize-instance総称関数のように副作用ベースでの動作(スロットの値を初期化したり出力したりする)が目的であれば単にメソッドを呼べばいいだけなのですが、今回のように返り値としてプロパティリストを返したい場合にも同じように使えるでしょうか。つまり、今回の例で求められているのは、適用可能なメソッドを順番に呼び出すだけでなく、その個々のメソッドの結果(返り値)までも「結合」しなければならないのです。つまり、メソッドの結合とメソッドの返り値の結合を合わせて行うような「メソッド結合」が必要なのです。

これを解決する方法の一つは大域変数を使うパターンです。メソッドよりも広いスコープを持つ変数を用意しておき、その値を書き換えていけば目的が達成されます。しかし、大域変数は名前の衝突のリスクがあるほか、関係のない他のメソッドからも参照できるため、信頼度が高いとは言えません。

そこでANSI Common Lispでは特別な「ビルトイン型メソッド結合」がいくつか用意されています。ここでは個々のメソッドの返り値を単に連結していくということが求められているので、appendビルトインメソッド結合かnconcビルトインメソッド結合を使うことができます。結果はどちらも変わりませんが、nconcはコピーを行わずに破壊的な操作をするため高速に処理できます。今回はこちらを使ってみます。
;;;; サンプル2-2: メソッド結合
(defgeneric coerce-plist (datetime)
  (:method-combination nconc)
  (:documentation "Convert from Date/Time/Datetime object to property list."))
; => #<STANDARD-GENERIC-FUNCTION COERCE-PLIST>

;; メソッド名の後に nconc を付けている点に注意
(defmethod coerce-plist nconc ((simple-date simple-date))
  (with-slots (year month date) simple-date
    (list :year year :month month :date date)))
; => #<STANDARD-METHOD NCONC (#<STANDARD-CLASS SIMPLE-DATE>)>

(defmethod coerce-plist nconc ((simple-time simple-time))
  (with-slots (hour minute second) simple-time
    (list :hour hour :minute minute :second second)))
; => #<STANDARD-METHOD NCONC (#<STANDARD-CLASS SIMPLE-DATE>)>
defgenericマクロは総称関数そのものを定義しますが、もしなければdefmethodが自動的に総称関数を定義してくれます。前節の例では暗黙的に総称関数が定義されています。
ビルトイン型のメソッド結合を用いる場合は、2つの指定を行います。
  1. defgenericマクロの本体部分において:method-combinationで指定しておく
  2. defmethodマクロのメソッド名に続けて指定する
nconcを指定したので、datetimeクラスのオブジェクトが渡されてきたときは2つのメソッドが順番に呼び出され、それぞれの返り値であるリストが連結されて最終的な返り値となります。

以下が実行例です。(サンプル1の実行例で使ったd1, t1, nowについてdefparameterマクロで定義済みとします。)
(coerce-plist d1)
; => (:YEAR 2017 :MONTH 10 :DATE 29)

(coerce-plist t1)
; => (:HOUR 15 :MINUTE 36 :SECOND 30)

(coerce-plist now)
; => (:YEAR 2017 :MONTH 10 :DATE 29 :HOUR 15 :MINUTE 35 :SECOND 30)
datetimeクラスであるnowオブジェクトはプロパティリストが連結されているのがわかると思います。datetimeクラス用のメソッドを新たに定義しなくても、ビルトイン型のメソッド結合を使うことでこのような処理も可能になります。

ビルトイン型のメソッド結合は+などの数に対して適用するものやandなど真偽値に使うものなど10種類が用意されています。そのうち1つは「標準結合」であり、これは「上書き戦略」に相当します。何も指定しなければこの標準結合が使われ、最も特定的なメソッドが一つだけ実行されます。前節の例は暗にこの標準結合を使用したディスパッチです。標準結合は:after:beforeなどの補助メソッドを含めることができるので、initialize-instance総称関数も標準結合の一例ということになります。

多くの場合は標準結合で足りると思いますが、必要であれば残り9種類のビルトイン型メソッド結合を試してみてください。

サンプル3: 多重ディスパッチと動的変更

CLOSの基本的な概念は前節までのサンプル1とサンプル2で説明し終わりました。この節で紹介するのは中級ですが、CLOSらしい機能であり、他のオブジェクト指向言語が導入できていない部分でもあります。関心があれば初心者でも読み進めてください。

前節で説明したように、CLOSは「単一ディスパッチ」だけでなく「多重ディスパッチ」も可能です。多重ディスパッチは全ての引数の型(クラス)を調べて、特定度の順番で適用するメソッドの順序を決めるという高度な戦略です。

ここでは、datetime関連のオブジェクト2つを引数に取り、第1引数と第2引数のスロットを吸収合併させるという動作を定義してみます。具体的にはsimple-datesimple-timeのように2つの引数を取り、手前の引数をdatetimeクラスに拡張し、後の引数のスロットをdatetimeクラスの該当スロットにコピーするという動作です。

この場合、多重ディスパッチが活躍します。なぜなら、datetimeクラスに拡張するのは第1引数ですが、第2引数のスロットをコピーしなければならないので、第2引数のオブジェクトのクラスも知る必要があるからです。多重ディスパッチを使えば特別なコードは必要なく、メソッド内部でオブジェクトのクラスを仮定して定義できるのです。

また、今回はオブジェクトのクラスを動的に変更するという動作も必要です。そのような動作はchange-class総称関数で制御できます。

以下が今回のサンプルコードです。
;;;; サンプル3: 多重ディスパッチと動的変更
(defgeneric nmerge (main-object sub-object)
  (:documentation "Merge main-object and sub-object.
This method destructively change main-object and return it."))

(defmethod nmerge ((main simple-date) (sub simple-time))
  (change-class main 'datetime)
  (setf (hour-of main) (hour-of sub))
  (setf (minute-of main) (minute-of sub))
  (setf (second-of main) (second-of sub))
  main)

(defmethod nmerge ((main simple-time) (sub simple-date))
  (change-class main 'datetime)
  (setf (year-of main) (year-of sub))
  (setf (month-of main) (month-of sub))
  (setf (date-of main) (date-of sub))
  main)

多重ディスパッチ

2つのdefmethodの定義内部を見ると、それぞれアクセスするスロットが異なっていますが、このようにスロットにアクセスするためにはオブジェクトに確実にスロットが存在しなければなりません。アクセスするスロットが第1引数のオブジェクトだけなら単一ディスパッチで十分ですが、第2引数以降のオブジェクトのスロットにアクセスしたい場合、多重ディスパッチを使うことでdefmethodの定義内部ではディスパッチのルールに合致したことが保証されるので、スロットの存在確認が不要です。

今回の例では(sub simple-time)でディスパッチが通ればsubオブジェクトにはhour, minute, secondの3つのスロットが含まれることは保証されます。
ただし、多重ディスパッチの動作を完全に理解するのは結構大変です。使用するクラスの継承の深さや複雑さ、ディスパッチする引数の数などを総合的に判断し、動作を適切に管理できる状態に留めましょう。

ちなみに、多重ディスパッチで「通したくない」ディスパッチルールがある場合は、defmethodマクロで独立して記述するのではなくdefgeneric:methodを使って記述することがあります。多くの場合はエラー(コンディション)を発生させるのですが、コンディションは第9章なので、ここでは単に表示する例として以下のようなパターンが考えられます。
(defgeneric nmerge (main-object sub-object)
  (:documentation "Merge main-object and sub-object.
    This method destructively change main-object and return it.")
  (:method ((main simple-date) (sub datetime))
    (declare (ignore main sub))
    (format t "Sub-object is DATETIME class, so can't merge it."))
  (:method ((main simple-time) (sub datetime))
    (declare (ignore main sub))
    (format t "Sub-object is DATETIME class, so can't merge it.")))

第2引数がdatetimeであればこちらの方が第1引数よりも大きくなってしまうため、小が大を吸収合併するような形式になってしまいます。このような状況を避けたい場合は、defgenericマクロに:methodを追加して例外的状況をキャッチします。また、:methodは複数導入することができます。

change-class総称関数

このサンプルでは第1引数のオブジェクトを破壊的にクラス変更しています。変更後のクラスはsimple-datesimple-timeのサプクラスであるdatetimeなのでスロットの値は実際には破棄されませんが、準拠するクラスは変わることになります。このように動的にクラスを変更するのはchange-class総称関数の役目です。
change-classを使うことは多くないかもしれませんが、以下の2つの条件が揃う場合には使うことがあるかもしれません。
  1. プログラムが常に稼働し続けるような場合。
  2. プログラムが進化・学習し続けるような場合。
これはロボットや人口知能プログラムに求められる特性でもあります。プログラムが常に稼働し続け、周りの状況に応じて新しい情報を取り込み、変化し続けるような場合は、どうしても実行中にオブジェクト(インスタンス)のクラスを変更する必要があるかもしれません。
change-class総称関数はCLOSの動的な性質を表す代表的な関数なので、頭の片隅に置いておいてください。

サンプル4: 出力の制御とメソッドの手動結合

CLOSの最後のサンプルとして、出力を制御するprint-object総称関数を紹介します。
ANSI Common Lispではprintprincという関数が定められており、様々なデータを表示することができます。表示を行う機能はCommon Lispのインタプリタにも組み込まれており、Read-Eval-Print Loop (REPL) と呼ばれています。

実は表示の機能の裏にはCLOSのディスパッチが活かされています。これらの関数はprint-objectという総称関数を暗黙的に呼び出すように設計されているのです。

ここまで3つのサンプルを読んだ方は「総称関数が定義されている」ということの意味が分かると思いますが、つまり「プログラマが関数を拡張できる」ということです。サンプル1のinitialize-instance総称関数のように、独自のメソッドを定義することで総称関数自体を拡張することができるのです。また、補助メソッドを組み合わせることで、メソッド結合を利用した複雑な制御フローにも対応することができます。

ここでは、datetime関連クラスのために以下のような表示機能を独自に追加します。
  1. クラスの名前を分かりやすく表示する
  2. スロットの値を使って日時を分かりやすく表示する
以下がサンプルコードです。
;;;; サンプル4: 出力の制御とメソッドの手動結合
(defmethod print-object :before ((object datetime-base) stream)
  (format stream "The class name is ~a.~%" (class-name (class-of object))))

(defmethod print-object ((object simple-date) stream)
  (with-slots (year month date) object
    (format stream "Date Part => ~a/~a/~a~%" year month date))
  (when (next-method-p)
    (call-next-method)))

(defmethod print-object ((object simple-time) stream)
  (with-slots (hour minute second) object
    (format stream "Time Part => ~a:~a:~a~%" hour minute second))
  (when (next-method-p)
    (call-next-method)))

print-obejct総称関数

print-object総称関数は2つの引数を取ります。総称関数の引数は、ANSI Common Lispの仕様では Method Signatures という項目で定めらています。print-objectの場合は以下のように書かれています。
print-object (object standard-object) stream

print-object (object structure-object) stream

これはprint-object総称関数が2つの引数objectstreamを取り、objectstandard-objectクラスまたはstructure-objectクラスであることを示しています。

独自のクラスを定義した時、そのインスタンスの表示をprintprincなどの標準的な表示関数で行いたい場合はprint-object総称関数に独自のクラス用のメソッドを追加します。普通に定義した場合、「標準結合」戦略が使われるので、最も特定的なメソッドのみが実行される「上書き」となります。インスタンスのように読み込み不可のオブジェクトを表示する場合はprint-unreadable-objectマクロを使うと#<...>のように表示できます。

しかし、print-obejct総称関数の標準の状態(バニラフレーバー)にはインスタンスを読み込み不可オブジェクトとして表示するようにあらかじめ定義されているので、それだけが目的ならバニラフレーバーを呼び出せばいいのです。このような時に用いる方法は2つあります。
  1. :beforeまたは:afterの補助メソッドを使う
  2. call-next-methodローカル関数で手動で呼び出す
補助メソッドはすでに説明しました。今回は:beforeを使用しています。:beforeは基本メソッドの前に実行されます。ここではベースとなるクラスに対して:beforeを使用しており、datetime関連クラスでは最初にこの:before補助メソッドが利用されます。

次節でcall-next-methodについて説明します。

call-next-methodローカル関数

ビルトイン型のメソッド結合を使用せず、標準結合を利用する場合は「上書き戦略」が基本ですが、call-next-methodローカル関数を使用すると次に特定的なメソッドを順番に呼び出すことができます。

「ローカル関数」とはメソッドの内部からしか呼び出せない関数です。call-next-methodの他にサンプルでも使用しているnext-method-pがあります。
call-next-methodローカル関数は次に適用可能なメソッドが存在しない場合、no-next-method総称関数を呼び出します。この関数が呼び出されるとエラー(コンディション)が通知されるため、デバッガが起動します。そのため、call-next-methodを使う時はnext-method-p`ローカル関数で次に適用可能なメソッドが存在するかどうかを調べてから呼び出します。
  • next-method-p: 次に適用可能なメソッドがあるかどうかを判定する
  • call-next-method: 次に適用可能なメソッドを呼び出す
  • no-next-method: 次に適用可能なメソッドが存在しない場合に呼び出され、エラーを通知する
なお、サンプル4のprint-object総称関数を定義すると、以下のように表示されます。
d1
; The class name is SIMPLE-DATE.
; Date Part => 2017/10/29
; => #<SIMPLE-DATE #x0000000200452A09>

t1
; The class name is SIMPLE-TIME.
; Time Part => 15:36:30
; => #<SIMPLE-TIME #x0000000200457141>

now
; The class name is DATETIME.
; Date Part => 2017/10/29
; Time Part => 15:35:30
; => #<DATETIME #x000000020045B881>

CLOSに関する主要オペレータ一覧

CLOSに関する主要なオペレータを列挙しておきます。これまでのサンプルで説明していない補足的なトピックについても掲載します。

定義

Macro defclass
クラスを定義します。すでに定義されている場合は再定義されます。再定義された場合は元のクラスを使ってインスタンス化されているオブジェクトも全て変更されます。change-classはあるクラスから別のクラスに動的に変更するオペレータですが、defclassによる再定義は同一のクラス定義の変更をインスタンスにも反映する点で少し異なります。
Macro defgeneric
総称関数を定義します。主にメソッド結合の指定とドキュメントの記述に使われます。defgenericが存在しない場合も総称関数は暗黙的に定義されます。なお、:after補助メソッド以外は特定度の高い順番にメソッドが結合される:most-specific-firstがデフォルトになっていますが、:most-specific-last:method-combinationで指定すると特定度の低い順に呼び出されるという「逆順」にすることもできます。
Macro defmethod
メソッドを定義します。メソッド名の後には補助メソッド指定子やビルトイン型メソッド結合指定子を付けることができます。
Macro define-method-combination
メソッド結合指定子を定義します。ビルトイン型のメソッド結合以外を利用する場合に使用します。

インスタンス

Generic Function: make-instance
クラスからインスタンスを生成します。初期値を設定できるほか、initialize-instance総称関数を暗黙的に呼び出すので、そちらをコンストラクタとして利用することができます。
Generic Function: initialize-instance
インスタンスの初期化の際に呼び出されます。通常は:after補助メソッドを追加的に定義し、コンストラクタとして利用します。直接呼び出す関数ではありません。
Generic Function: change-class
インスタンスのクラスを別のクラスに変更します。実行中でも動的に変更できます。
Function: class-of
インスタンスのクラスを取得します。CLOSではクラスもオブジェクトです。クラスの名前ではなくクラスの実体が取得されます。
Function: class-name
クラスの実体からクラスの名前を取得します。クラスの名前は文字列ではなくシンボルです。
Accessor: find-class
シンボルからクラスの実体への参照を取得します。 setfplace で使用するとクラスの実体を明示的に変更する(新しいクラスの実体に置き換える)ことができます。第2引数はオプショナル引数で、クラスが存在しない場合にエラーを通知するかどうかを真偽値で指定します。デフォルトはtです。

スロット

Function: slot-value
インスタンスのスロットにアクセスします。 setfplace で使用すると書き込みにも使うことができます。
Function: slot-exits-p
インスタンスがスロットを持っているかどうかを判定します。
Function: slot-boundp
インスタンスのスロットが束縛済みかどうかを判定します。もしスロットが存在しなければslot-missing総称関数が呼び出されます。
Function: slot-makunbound
スロットを未束縛の状態にします。なお、未束縛にするための一般的な(CLOSとは無関係な)関数はmakunboundで、シンボルの関数部分を未束縛にする関数はfmakunbound
Generic Function: slot-missing
インスタンスにスロットが存在しない場合に呼び出されます。通常はエラーを通知します。
Generic Function: slot-unbound
インスタンスのスロットが未束縛の場合に呼び出されます。通常はunbound-slotというエラーが通知されます。

便利系マクロ

Macro: with-slots
インスタンスに含まれるスロットへのアクセッサをレキシカルな変数に束縛します。スロット名を指定します。
Macro: with-accessors
with-slotsマクロと似ていますが、スロット名ではなくアクセッサを指定します。
Local Function: call-next-method
適用可能なメソッドのうち、特定度順で次に適用可能なメソッドを定義します。適用可能なメソッドがなかった場合はno-next-method総称関数が呼び出されます。
Local Function: next-method-p
次に適用可能なメソッドがあるかどうかを判定します。
Generic Function: no-next-method
次に適用可能なメソッドがなかった場合に呼び出されます。通常はエラーを通知します。
Generic Function: find-method
総称関数の中から指定子に合致するメソッドを取得します。引数は3つあり、第1引数は総称関数の実体、第2引数はメソッド結合に関する指定子のリスト、第3引数は探すメソッドの引数のクラスの実体のリストです。言葉では分かりにくいと思うので、このページの下部に利用例を掲載しています。このページで定義したメソッドを探し出す例です。
Generic Function: add-method
総称関数にメソッドを追加します。 defmethodマクロはメソッドの定義と総称関数への追加を行うため、add-methodを直接使うことはほとんどないでしょう。
Generic Function: remove-method
総称関数からメソッドを削除します。 第1引数は総称関数の実体、第2引数は削除するメソッドの実体です。メソッドの実体を取り出すためにfind-methodと共に用いられます。

find-method総称関数の利用例>
;; (defmethod nmerge ((main simple-date) (sub simple-time))
;;    ...)
;; を探す場合
(find-method #'nmerge '() (mapcar #'find-class '(simple-date simple-time)))
; => #<STANDARD-METHOD
;      (#<STANDARD-CLASS SIMPLE-DATE> #<STANDARD-CLASS SIMPLE-TIME>)>

;; (defmethod coerce-plist nconc ((simple-time simple-time))
;;    ...)
;; を探す場合
(find-method #'coerce-plist '(nconc) (list (find-class 'simple-time)))
; => #<STANDARD-METHOD NCONC (#<STANDARD-CLASS SIMPLE-TIME>)>

0 件のコメント :

コメントを投稿