Common Lispを静的型付けの関数型言語にするCoaltonの紹介

目次

投稿日: 2023-12-11 Mon

Lisp Advent Calendar 2023 - Adventar の11日目の記事です。

Common Lisp を静的型付けの関数型言語にするライブラリである Coalton を紹介します。

まだバージョン1.0には達していない状況ですが、 活発に開発が進められていて確実に完成度が上がっている期待度の高いプロジェクトです。

この記事では主に Coalton を採用すると得られるメリットや、 マクロ、Common Lisp との相互運用などの気になる点をピックアップして紹介します。

1. Coalton とは

Coalton は Common Lisp を静的型付けの秀逸な型システムをもった関数型言語に拡張するライブラリです。

Coalton は他の通常の Common Lisp のライブラリと同じ方法で導入でき、 asdf で読み込むだけで簡単に使えます。

Coalton は他の Common Lisp のライブラリと共存可能であり、 Coalton のコードを生成するマクロを自分で定義して Coalton をさらに拡張することすら可能です。 Coalton の式は通常の Lisp コードと同じように、REPL で簡単に評価できます。 通常の Common Lisp と書き方が少し異なりますが、まぎれもなく Common Lisp のコードです。

Coalton を導入することで一般に言われるような静的型付き言語のメリットをすべて享受できます。 さらに、静的型付きの関数型プログラミング言語でよく採用される代数的データ型や型クラスを活用したプログラミングもできるようになります。

1.1. Coalton が示す Common Lisp の可能性

Coalton は Common Lisp の可能性に限界がないことを示す素晴しい事例の一つだと思います。 研究が進んで新しいプログラミングに関する概念が生まれたときに、 開発者の努力によって追従できるプログラミング言語環境というのはそう多くないでしょう。 Common Lisp は新しい技術を創造したり適応したりすることで進化できるプログラミング言語だということを Coalton が実際に証明しています。

2. Coalton の使い方を学ぶ方法

Coalton のリポジトリの docs にあるドキュメントが有効な情報源です。

まずは Intro to Coalton から読みましょう。 このドキュメントを上から下まで読むことで、Coalton を使い方が分かります。

examples には Coalton を使ったプログラムの例があるので、 こちらを参考にするのもおすすめです。

3. Coalton の魅力

Coalton を導入することで得られるメリットを重要度の高い順に紹介します。

3.1. 網羅性チェック

全ての場合を網羅できていないとき、プログラムは意図しない挙動を引き起こします。

たとえば、ステータスを見てそれぞれのステータスに対応した何らかの処理をする必要があるとします。 次のステータスを文字列に変換するコードを書いたとしましょう。

(defpackage #:manage-status
  (:use #:coalton
        #:coalton-prelude))

(in-package #:manage-status)

(named-readtables:in-readtable coalton:coalton)

(coalton-toplevel
  (define-type Status
    Open
    Showing
    Closed)

  (define-instance (Into Status String)
    (define (into s)
      (match s
        ((Showing) "上映中")
        ((Open) "開場")
        ((Closed) "終了")))))

この後に Preparing というステータスを追加します。 このとき、Status が一つ増えたので使っている全ての箇所について漏れがないか確認する必要があります。

Coalton では網羅性チェックが機能するので、Status を網羅できていない箇所があればコンパイラが警告を出してくれます。

(defpackage #:manage-status
  (:use #:coalton
        #:coalton-prelude))

(in-package #:manage-status)

(named-readtables:in-readtable coalton:coalton)

(coalton-toplevel
  (define-type Status
    Preparing
    Open
    Showing
    Closed)

  (define-instance (Into Status String)
    (define (into s)
      (match s
        ((Showing) "上映中")
        ((Open) "開場")
        ((Closed) "終了")))))
; caught COMMON-LISP:STYLE-WARNING:
;   warn: Non-exhaustive match
;     --> /tmp/manage-status.lisp:18:6
;       |
;    18 |          (match s
;       |  ________^
;       | | _______-
;    19 | ||         ((Showing) "上映中")
;    20 | ||         ((Open) "開場")
;    21 | ||         ((Closed) "終了")))))
;       | ||____________________________- Missing case (PREPARING)
;       | |_____________________________^ non-exhaustive match
;   
; 
; compilation unit finished
;   caught 1 STYLE-WARNING condition

条件や場合を Coalton のデータ型で表現することで、網羅性に関するバグを減らすことができます。

3.2. 戻り値の型で実装を選択する機能

Common Lisp で型変換をするには coerce という関数を使いますが、残念ながらユーザーが定義したデータ型を変換するのには使えません。 そのため、Lisp では一般にユーザーが型変換をする関数を実装する場合にはだいたい次のような名前の関数を書いていると思います。

(defun foo-to-bar (foo)
  …)

(defun foo->bar (foo)
  …)

それに対し、Coalton には Into という型クラスがあり、全ての型変換を Into 型クラスのメソッド into のみで対応できます。

(define-instance (Into Foo Bar)
  (define (into foo)
    …))

CLOS のクラスと Coalton の型クラスには他にも違いがあるのですが、 ここでは Coalton の型クラスのメソッドでは戻り値の型で呼び出す実装を特定できるという点に注目します。

CLOS の総称関数を使うと引数のオブジェクトをもとにして実装を特定できますが、この仕組みでは戻り値の情報は使えません。 呼び出す実装を動的に決定している以上 coerce 関数のように変換先のヒントを与えない限りは変換関数を CLOS の総称関数の機能だけで実装するのは無理です。

;; bar クラスでディスパッチができない!
(defmethod into ((foo foo))
  …)

;; 実行時に呼び出したタイミングで foo の変換先を特定できない!
(into foo)

それに対し Coalton では into 関数の戻り値側の型をコンパイル時に知ることができるので戻り値の型から実装を特定できます。

(declare calc-bar (Bar -> …))
(define (calc-bar bar)
  …)

;; コンパイル時に型推論で (into foo) の変換先が Bar だと分かる!
(calc-bar (into foo))

CLOS の総称関数と比べて実装の特定に使える情報が一つ増えている分、ディスパッチの能力についていえば純粋にパワーアップしているといえます。

他にも MonoidApplicative, Monad など、 戻り値の型によるディスパッチができないと表現が難しい構造がいくつもありいずれも有用です。 Coalton を導入することでこういった抽象度の高い構造を扱うことができます。

3.3. 「null安全」になる

Coalton はnull安全であり、意図しないところに nil が流れついてきて実行時にエラーになってしまうという問題は生じません。

Coalton では Optional という型を使うことで結果がない可能性のある値を表現できます。 次のように Optional 型の値に対して不適切な関数を適用すると実行前に型エラーになります。

(defpackage #:optional-example
  (:use #:coalton
        #:coalton-prelude)
  (:local-nicknames
   (#:string #:coalton-library/string)))

(in-package #:optional-example)

(named-readtables:in-readtable coalton:coalton)

(coalton-toplevel
  ;; 連想リストから要素を取り出す
  (declare get-value (Eq :k => :k -> List (Tuple :k :v) -> Optional :v))
  (define (get-value key alist)
    (do ((Tuple _ value) <- (find (.< (== key) fst) alist))
        (pure value)))

  ;; 整形する
  (declare format (Integer -> String -> String))
  (define (format k v)
    (lisp String (k v)
      (cl:format nil "~R: ~a" k v)))

  ;; 連想配列からキーで取得して整形した文字列を返す
  (define (show key alist)
    (format key (get-value key alist))))
optional-example.lisp:1:1:
  read-error: 
    COMMON-LISP:READ error during COMMON-LISP:COMPILE-FILE:

      error: Type mismatch
      --> /tmp/optional-example.lisp:26:12
        |
     26 |      (format (get-value key alist))))
        |              ^^^^^^^^^^^^^^^^^^^^^ Expected type 'INTEGER' but got '(OPTIONAL :A)'


      (in form starting at line: 11, column: 0, position: 220)

次のように Optional を考慮して修正すれば、型エラーは解消します。

(defpackage #:optional-example
  (:use #:coalton
        #:coalton-prelude)
  (:local-nicknames
   (#:string #:coalton-library/string)))

(in-package #:optional-example)

(named-readtables:in-readtable coalton:coalton)

(coalton-toplevel
  ;; 連想リストから要素を取り出す
  (declare get-value (Eq :k => :k -> List (Tuple :k :v) -> Optional :v))
  (define (get-value key alist)
    (do ((Tuple _ value) <- (find (.< (== key) fst) alist))
        (pure value)))

  ;; 整形する
  (declare format (Integer -> String -> String))
  (define (format k v)
    (lisp String (k v)
      (cl:format nil "~R: ~a" k v)))

  ;; 連想配列からキーで取得して整形した文字列を返す
  ;; match で場合分けする場合
  (define (show/match key alist)
    (match (get-value key alist)
      ((Some value) (Some (format key value)))
      ((None) None)))

  ;; do 記法を使う場合
  (define (show/do key alist)
    (do (value <- (get-value key alist))
        (pure (format key value))))

  ;; map 関数を使う場合
  (define (show/map key alist)
    (map (format key) (get-value key alist))))

このように、nil を想定していない関数を nil に適用してしまう問題を完全に回避することができます。 Coalton を導入すれば関数の戻り値が nil である可能性を気にする手間から完全に解放されます。

4. Coalton でマクロを使う

Common Lisp には、Common Lisp のコードを生成するマクロを書くのが簡単という特徴があります。 Coalton は Common Lisp のコードの一部であるため、Common Lisp で Coalton のコードを書くのもまた簡単です。 そのため、Coalton をマクロを使ってさらに拡張することができます。 簡単にマクロが書けるのは他の静的型付きの関数型言語と比較して大きな強みといえるでしょう。

なお、マクロを書くのであれば少なくとも現時点では Coalton で書くのではなくて普通の Common Lisp で書いた方が楽です。 次に説明する validate-with-result でもマクロの実装は Coalton ではなくて Lisp で書いています。

4.1. 事例: validate-with-result

<2023-12-21 Thu> バリデーションの実装は Result と同型の型でうまくいき、本当に必要だったものは Applicative でした。Applicative によるバリデーションは tokyo.tojo.validation にて実装しているのでよかったら見てみてください。

私が Coalton で複数の項目をバリデーションしつつ文字列から適切な型に変換をするようなプログラムを書いていたのですが、 意外とこのような処理を楽にうまく書く方法がなかなか見つかりませんでした。 試行錯誤した結果した結果、マクロを使って構文を定義した方がいいと判断して validate-with-result というプログラムを実装しました。

tojoqk/validate-with-result: Syntax `let` for delaying result in Coalton.

例として、名前、年齢、状態の3つを文字列で渡し、それぞれをパースして適切な型に変換することを考えましょう。 まずはそれぞれのパースする関数を定義します。

(defpackage #:validate-with-result-example
  (:use #:coalton
        #:coalton-prelude)
  (:local-nicknames
   (#:string #:coalton-library/string)))

(in-package #:validate-with-result-example)

(named-readtables:in-readtable coalton:coalton)

(coalton-toplevel
  (define parse-name Ok)

  (define (parse-age x)
    (match (string:parse-int x)
      ((Some n)
       (if (<= 1 n)
           (Ok n)
           (Err (make-list "parse-age-error (negative)"))))
      ((None) (Err (make-list "parse-age-error")))))

  (define-type Status
    Sleeping
    Studying)

  (define (parse-status x)
    (match x
      ("sleeping" (Ok sleeping))
      ("studying" (Ok Studying))
      (_ (Err (make-list "parse-status-error"))))))

ここからが validate-with-result の出番です。

validate-with-result:let というマクロを使ってバリデーションを実行します。 構文は次のような感じです。

(validate-with-result:let ((<var1> <result-type-value1>)
                           (<var2> <result-type-value2>)
                           ...)
  <expr> ...)

全て Ok の場合は validate-with-result:let<expr> 部分の式 (Tuple3 name age status) の結果が返ります。

VALIDATE-WITH-RESULT-EXAMPLE> (coalton
                               (valdate-with-result:let ((name (parse-name "john"))
                                                         (age (parse-age "28"))
                                                         (status (parse-status "sleeping")))
                                 (Ok (Tuple3 name age status))))
#.(OK #.(TUPLE3 "john" 28 #.SLEEPING))

agestatus のパースに失敗した場合はそれぞれのエラーメッセージを返します。

VALIDATE-WITH-RESULT-EXAMPLE> (coalton
                               (validate-with-result:let ((name (parse-name "john"))
                                                          (age (parse-age "-28"))
                                                          (status (parse-status "playing")))
                                 (Ok (Tuple3 name age status))))
#.(ERR ("parse-age-error (negative)" "parse-status-error"))
VALIDATE-WITH-RESULT-EXAMPLE> 

こんな感じで Coalton の Reuslt 型を活用して簡単にバリデーションを実現できます。

このように Coalton でも通常の Common Lisp での開発と同じようにマクロを使うことで言語を拡張することができるのです。

5. Common Lisp との相互運用

詳細は Coalton-Lisp Interoperation を参照してください。

2024-02-17 追記

この記事を書いたときよりも Coalton-Lisp Interoperation の内容が充実しています。 実際に Coalton と Common Lisp で相互運用する場合にはこのドキュメントを読んで状況にあった対処をするのがよさそうです。

5.1. Coalton から Common Lisp を呼ぶ

lisp という構文を使うことで Coalton から Common Lisp のコードを実行できます。 たとえば次のようにして Common Lisp の format 関数を呼ぶことができます。

(defpackage #:awesome-format
  (:use #:coalton
        #:coalton-prelude))

(in-package #:awesome-format)

(named-readtables:in-readtable coalton:coalton)

(coalton-toplevel
  (declare awesome-format (Integer -> String -> String))
  (define (awesome-format i title)
    (lisp String (i title)
      (cl:format nil "~@:R ~a" i title))))

REPL:

AWESOME-FORMAT> (coalton (awesome-format 7 "タイトル"))
"VII タイトル"

ただし、Common Lisp のコードを呼び出すときに不適切な型を返してしまうと Coalton システムの健全性を損なう可能性があるため、 Coalton の中から Common Lisp のコードを呼ぶ場合には戻り値の型に注意が必要です。

5.2. Common Lisp から Coalton の関数を呼び出す

逆に Common Lisp から Coalton の関数を安全に呼ぶ方法ですが、 2023/12/10 の時点では安全に呼ぶ方法がドキュメントに記載されていません。

しかし、下記の PR がマージされているためおそらく call-coalton-function という関数を通して Common Lisp から Coalton の関数を呼ぶのが無難そうです。 Friendly interface for calling Coalton function-entry objects from CL

次のように Common Lisp のプログラムから Coalton の関数を呼び出すことができます。

(defpackage #:awesome-format
  (:use #:coalton
        #:coalton-prelude)
  (:export #:awesome-format))

(in-package #:awesome-format)

(named-readtables:in-readtable coalton:coalton)

(coalton-toplevel
  (declare awesome-format (Integer -> String -> String))
  (define (awesome-format i title)
    (lisp String (i title)
      (cl:format nil "~@:R ~a" i title))))

(defpackage #:awsome-format-cl
  (:use #:cl))

(in-package #:awsome-format-cl)

(defun show-title (i title)
  ;; Common Lisp から Coalton の関数を呼び出す
  (coalton:call-coalton-function awesome-format:awesome-format
                                 i
                                 title))

6. おわりに

Coalton を導入すると Common Lisp でも静的型付けのメリットを享受できるうえに、 網羅性チェックや、戻り値の型によるディスパッチ、null安全性という強力な機能を使うことができることを紹介しました。

そして Coalton でも Lisp の最大級の能力であるマクロを活用できることを説明し、 最後に Coalton と Common Lisp での相互運用が可能なことにについて簡単に説明しました。

このように Coalton には Common Lisp の開発に劇的な変化をもたらす可能性があります。 是非 Coalton について興味を持っていただければと思います。

著者: Masaya Tojo

Mastodon: @tojoqk

RSS を購読する

トップページに戻る