The Common Lisp Cookbook – 型システム

Table of Contents

The Common Lisp Cookbook – 型システム

📢 🎓 ⭐ Learn Common Lisp efficiently in videos, by the Cookbook's main contributor. Learn more.

🖊️ Discover a new Common Lisp and Coalton editor for beginners: mine and a new VSCode extension for Common Lisp: OLIVE.

Common Lisp には完全で柔軟な型システムと、それに対応する型を調査・検査・操作するツールがあります。独自の型を作成し、変数や関数に型宣言を追加し、それによってコンパイル時の警告やエラーを得られます。

型を持つのは値であり、変数ではない

C/C++ のような一部の言語とは異なり、Lisp の変数はオブジェクト1 のための 置き場所 にすぎません。変数に setf すると、オブジェクトがそこに「置かれ」ます。後で同じ変数に別の値を好きなように置けます。

これは、Common Lisp では オブジェクトが型を持つ のであり、変数は型を持たない、という事実を意味します。C/C++ の背景から来た場合、最初は驚くかもしれません。

例:

(defvar *var* 1234)
*VAR*

(type-of *var*)
(INTEGER 0 4611686018427387903)

関数 type-of は、与えられたオブジェクトの型を返します。返される結果は型指定子です。この場合、最初の要素が型で、残りはその型の追加情報(下限と上限)です。今のところ安全に無視できます。また、Lisp の整数には制限がないことも覚えておいてください。

では変数に setf してみましょう。

* (setf *var* "hello")
"hello"

* (type-of *var*)
(SIMPLE-ARRAY CHARACTER (5))

type-of が異なる結果を返すことが分かります。長さ 5 で内容の型が charactersimple-array です。これは、*var* が文字列 "hello" に評価され、関数 type-of が実際には変数 *var* ではなくオブジェクト "hello" の型を返すためです。

型階層

Lisp の型の継承関係は型のグラフから成り、すべての型の根は T です。例:

* (describe 'integer)
COMMON-LISP:INTEGER
  [symbol]

INTEGER names the built-in-class #<BUILT-IN-CLASS COMMON-LISP:INTEGER>:
  Class precedence-list: INTEGER, RATIONAL, REAL, NUMBER, T
  Direct superclasses: RATIONAL
  Direct subclasses: FIXNUM, BIGNUM
  No direct slots.

INTEGER names a primitive type-specifier:
  Lambda-list: (&OPTIONAL (SB-KERNEL::LOW '*) (SB-KERNEL::HIGH '*))

関数 describe は、シンボル integer が追加情報として下限と上限を持つ基本型指定子であることを示します。同時に、これは組み込みクラスでもあります。なぜでしょうか。

多くの Common Lisp の型は CLOS クラスとして実装されています。一部の型は他の型の単なる「ラッパー」です。各 CLOS クラスは対応する型に対応づけられます。Lisp では、型は 型指定子 の使用によって間接的に参照されます。

関数 type-ofclass-of にはいくつか違いがあります。type-of は与えられたオブジェクトの型を型指定子の形式で返す一方、class-of は実装の詳細を返します。

* (type-of 1234)
(INTEGER 0 4611686018427387903)

* (class-of 1234)
#<BUILT-IN-CLASS COMMON-LISP:FIXNUM>

型の検査

関数 typep は、第 1 引数が第 2 引数で指定された型であるかを検査するために使えます。

* (typep 1234 'integer)
T

関数 subtypep は、ある型が別の型を継承しているかを調べるために使えます。2 つの値を返します。

例:

* (subtypep 'integer 'number)
T
T

* (subtypep 'string 'number)
NIL
T

引数の型に応じて異なる動作を行いたいことがあります。マクロ typecase が役に立ちます。

* (defun plus1 (arg)
    (typecase arg
      (integer (+ arg 1))
      (string (concatenate 'string arg "1"))
      (t 'error)))
PLUS1

* (plus1 100)
101 (7 bits, #x65, #o145, #b1100101)

* (plus1 "hello")
"hello1"

* (plus1 'hello)
ERROR

型指定子

型指定子は型を指定するフォームです。上で述べたように、関数 type-of の戻り値と typep の第 2 引数はいずれも型指定子です。

上で示したように、(type-of 1234)(INTEGER 0 4611686018427387903) を返します。この種の型指定子は複合型指定子と呼ばれます。これは、先頭が型を示すシンボルであるリストです。残りの部分は補足情報です。

* (typep #(1 2 3) '(vector number 3))
T

ここで型 vector の補足情報は、それぞれ要素型とサイズです。

複合型指定子の残りの部分は * にできます。これは「何でも」を意味します。たとえば、型指定子 (vector number *) は、任意個数の数値から成るベクトルを表します。

* (typep #(1 2 3) '(vector number *))
T

末尾の部分は省略でき、省略された要素は * として扱われます。

* (typep #(1 2 3) '(vector number))
T

* (typep #(1 2 3) '(vector))
T

おそらく想像したとおり、上の型指定子は次のように短くできます。

* (typep #(1 2 3) 'vector)
T

詳しくは CLHS page を参照してください。

新しい型を定義する

マクロ deftype を使って新しい型指定子を定義できます。

その引数リストは、複合型指定子の残りの部分の要素への直接の対応づけと理解できます。シンボルの型指定子を許すために、それらは省略可能として定義されます。

本体は、与えられた引数がこの型かどうかを検査するマクロであるべきです(defmacro を参照)。

たとえば member を使って列挙型を定義できます。

(deftype fruit () '(member :apple :orange :pear))

ここで新しいデータ型を定義しましょう。このデータ型は最大 10 要素の配列とします。また、各要素は 10 未満の数値とします。例として次のコードを見てください。

* (defun small-number-array-p (thing)
    (and (arrayp thing)
      (<= (length thing) 10)
      (every #'numberp thing)
      (every (lambda (x) (< x 10)) thing)))

* (deftype small-number-array (&optional type)
    `(and (array ,type 1)
          (satisfies small-number-array-p)))

* (typep #(1 2 3 4) '(small-number-array number))
T

* (typep #(1 2 3 4) 'small-number-array)
T

* (typep #(1 2 3 4 100) 'small-number-array)
NIL

* (small-number-array-p '(1 2 3 4 5 6 7 8 9 0 1))
NIL

実行時の型検査

Common Lisp はマクロ check-type による実行時の型検査をサポートしています。これは place と型指定子を引数として受け取り、place の内容が指定された型でない場合に type-error を通知します。

* (defun plus1 (arg)
    (check-type arg number)
    (1+ arg))
PLUS1

* (plus1 1)
2 (2 bits, #x2, #o2, #b10)

* (plus1 "hello")
; Debugger entered on #<SIMPLE-TYPE-ERROR expected-type: NUMBER datum: "Hello">

The value of ARG is "Hello", which is not of type NUMBER.
   [Condition of type SIMPLE-TYPE-ERROR]
...

コンパイル時の型検査

変数、関数引数などに対して、proclaimdeclaim(トップレベル)、declare(関数とマクロの内部)を通じて型の情報を与えられます。

しかし、CLOS の節 で紹介した :type スロットと同様に、型宣言の効果は Lisp 標準では未定義であり、処理系に依存します。そのため、Lisp コンパイラがコンパイル時の型検査を行う保証はありません。

とはいえ、それは可能です。そして SBCL は綿密な型検査を行う処理系です。

まず、Lisp はすでに単純な型の警告を出すことを思い出しましょう。次の関数は誤って文字列と数値を連結しようとしています。コンパイルすると型の警告が出ます。

(defconstant +foo+ 3)
(defun bar ()
  (concatenate 'string "+" +foo+))
; caught WARNING:
;   Constant 3 conflicts with its asserted type SEQUENCE.
;   See also:
;     The SBCL Manual, Node "Handling of Types"

例は単純ですが、他の言語にはない能力をすでに示しており、開発中に実際に役立ちます ;) では、さらに良くしていきます。

変数の型を宣言する

マクロ declaimtype という宣言の識別子とともに使います(他の識別子は “ftype, inline, notinline, optimize…” です)。

グローバル変数 *name* が文字列であると宣言しましょう。REPL では次を任意の順序で入力できます。

(declaim (type (string) *name*))
(defparameter *name* "book")

これを不正な型に設定しようとすると、ある処理系ではそのまま動くかもしれませんし、別の処理系では型エラーが出るかもしれません。

SBCL では simple-type-error が出ます。

(setf *name* :me)
Value of :ME in (THE STRING :ME) is :ME, not a STRING.
   [Condition of type SIMPLE-TYPE-ERROR]

たとえば LispWorks と ECL では、警告やエラーなしで実行できます。

(setf *name* :me)

*name*
:ME

独自の型でも同じことができます。list-of-strings 型を手早く宣言しましょう。

(defun list-of-strings-p (list)
  "Return t if LIST is non nil and contains only strings."
  (and (consp list)
       (every #'stringp list)))

(deftype list-of-strings ()
  `(satisfies list-of-strings-p))

では *all-names* 変数が文字列のリストであると宣言しましょう。

(declaim (type (list-of-strings) *all-names*))
;; そして不正な値で:
(defparameter *all-names* "")
;; まだ compile-time の時点で error を得ます:
Cannot set SYMBOL-VALUE of *ALL-NAMES* to "", not of type
(SATISFIES LIST-OF-STRINGS-P).
   [Condition of type SIMPLE-TYPE-ERROR]

型を合成する

型は合成できます。前の例に続けると:

(declaim (type (or null list-of-strings) *all-names*))

関数の入力型と出力型を宣言する

再び declaim マクロを使います。ただし単なる type ではなく ftype (function …) を使います。

(declaim (ftype (function (fixnum) fixnum) add))
;;                         ^^input ^^output [optional]
(defun add (n)
  (+ n  1))

これによりコンパイル時に良い型の警告が得られます。

関数を変更して、fixnum ではなく文字列を誤って返すようにすると、警告が出ます。

(defun add (n)
  (format nil "~a" (+ n  1)))
; caught WARNING:
;   Derived type of ((GET-OUTPUT-STREAM-STRING STREAM)) is
;     (VALUES SIMPLE-STRING &OPTIONAL),
;   conflicting with the declared function return type
;     (VALUES FIXNUM &REST T).

add を別の関数の中で、文字列を期待する箇所に使うと警告が出ます。

(defun bad-concat (n)
  (concatenate 'string (add n)))
; caught WARNING:
;   Derived type of (ADD N) is
;     (VALUES FIXNUM &REST T),
;   conflicting with its asserted type
;     SEQUENCE.

add を別の関数の中で使い、その関数が add の型と互換性がないように見える引数の型を宣言している場合も警告が出ます。

(declaim (ftype (function (string)) bad-arg))
(defun bad-arg (n)
    (add n))
; caught WARNING:
;   Derived type of N is
;     (VALUES STRING &OPTIONAL),
;   conflicting with its asserted type
;     FIXNUM.

これはすべて実際に コンパイル時 に起こります。REPL でも、Slime の単純な C-c C-c でも、ファイルを load するときでも同じです。

&key パラメータの宣言

&key (:argument type) を使います。

例:

(declaim (ftype (function (string &key (:n integer))) foo))
(defun foo (bar &key n) …)

&rest パラメータの宣言

これはやや分かりにくく、適切な場所に置いた declare が必要かもしれません。

以下では fruit 型を宣言し、単一の fruit 引数を使う関数を書きます。そのため placing-order をコンパイルすると期待どおり型の警告が出ます。

(deftype fruit () '(member :apple :orange :pear))

(declaim (ftype (function (fruit)) one-order))
(defun one-order (fruit)
  (format t "Ordering ~S~%" fruit))

(defun placing-order ()
  (one-order :bacon))

しかしこの版では &rest パラメータを使っており、型の警告は出なくなります。

(declaim (ftype (function (&rest fruit)) place-order))
(defun place-order (&rest selections)
  (dolist (s selections)
    (format t "Ordering ~S~%" s)))

(defun placing-orders ()
  (place-order :orange :apple :bacon)) ;; => type warning なし

宣言は正しいのですが、コンパイラはそれを検査しません。適切な場所に置いた declare によりコンパイル時の警告が戻ります。

(defun place-order (&rest selections)
  (dolist (s selections)
    (declare (type fruit s))      ;; <= declare
    (format t "Ordering ~S~%" s)))

(defun placing-orders ()
  (place-order :orange :apple :bacon))

=>

The value
  :BACON
is not of type
  (MEMBER :PEAR :ORANGE :APPLE)

移植性のあるコードでは、assert による実行時の検査を追加するでしょう。

クラススロットの型を宣言する

クラススロットは :type スロットオプションを受け取ります。しかし一般には、initform の型を検査するためには 使われません。2019 年 11 月にリリースされた version 1.5.9 以降の SBCL は、これらの警告を出すようになりました。つまり次のコードは:

(defclass foo ()
  ((name :type number :initform "17")))

コンパイル時に警告を通知します。

注: make-instance の実行中(コンパイル時ではありません)にスロットの型を検査する、データのシリアライズおよび契約ライブラリ sanity-clause も参照してください。

別の型検査の構文: defstar, serapeum

Serapeum ライブラリは、次のような短縮形を提供します。

 (-> mod-fixnum+ (fixnum fixnum) fixnum)
 (defun mod-fixnum+ (x y) ...)

Defstar ライブラリは、lambda list に型宣言を追加できる defun* マクロを提供します。次のように見えます。

(defun* sum ((a real) (b real))
   (+ a b))

さらに次も可能です。

制限

satisfies を含む複雑な型は、デフォルトでは関数本体の内部では検査されず、境界でだけ検査されます。多くのことはしてくれますが、SBCL は静的型付け言語ほど多くは行いません。

integer を文字列で誤って increment する次の例を考えてください。

(declaim (ftype (function () string) bad-adder))
(defun bad-adder ()
  (let ((res 10))
    (loop for name in '("alice")
       do (incf res name))  ;; <= bad
    (format nil "finally doing sth with ~a" res)))

この関数をコンパイルしても型に関する警告は通知されません。

しかし、問題のある行が関数の boundary にあれば warning が出ます。

(defun bad-adder ()
  (let ((res 10))
    (loop for name in  '("alice")
       return (incf res name))))
; in: DEFUN BAD-ADDER
;     (SB-INT:NAMED-LAMBDA BAD-ADDER
;         NIL
;       (BLOCK BAD-ADDER
;         (LET ((RES 10))
;           (LOOP FOR NAME IN *ALL-NAMES* RETURN (INCF RES NAME)))))
;
; caught WARNING:
;   Derived type of ("a hairy form" NIL (SETQ RES (+ NAME RES))) is
;     (VALUES (OR NULL NUMBER) &OPTIONAL),
;   conflicting with the declared function return type
;     (VALUES STRING &REST T).

loop body 内で the declaration を使ってコンパイル時の warning を得ることもできます。

       do (incf res (the string name)))

何を結論とできるでしょうか。これは code を小さな関数に分解するもう 1 つの理由です。

参考


  1. ここでの object という用語は Object-Oriented などとは関係ありません。「任意の Lisp datum」を意味します。 

Page source: ja/type.md

T
O
C