【Common Lisp】
キーワードシンボルと文字列の違い
(オブジェクト)

  • Common Lispのキーワードシンボル(:foo)と文字列("foo")は、どちらも名前のように使えるが、同一オブジェクトかどうかを比べるeqの挙動が根本的に異なる。
  • キーワードシンボルはinternの仕組みにより常に同一オブジェクトを返すためポインタ比較で済むが、文字列は書くたびに新しいオブジェクトを生成するためequalで文字を1つずつ照合する必要がある。
  • 固定の種別やハッシュテーブルのキーにはキーワードシンボルが適しており、ユーザー入力やファイル内容などプログラム外から来るデータには文字列を使う。

関連記事

1. まず動作の違いを見る

Common Lispには、:fooというキーワードシンボル(keyword)と、"foo"という文字列型(string)という、一見似たような使い方ができる2種類の値があります。

キーワードシンボル vs 文字列 :foo キーワード :foo → :FOO 大文字に正規化される (eq :foo :foo) → T 常に同一オブジェクト “foo” 文字列 “foo” → “foo” 大文字小文字を保持 (eq “foo” “foo”) → NIL 書くたびに新しいオブジェクト vs 決定的な違いは eq ── ポインタ比較か文字照合か

どちらも文字列や単語のデータとして使えますが、設計がまったく異なります。
REPLで並べると差がはっきりします。

:foo        ; => :FOO   (大文字に正規化される)
"foo"       ; => "foo"  (大文字小文字をそのまま保持)

(eq :foo :foo)      ; => T    (同一オブジェクト)
(eq "foo" "foo")    ; => NIL  (別オブジェクト)
(equal "foo" "foo") ; => T    (内容が同じなら真)Code language: Lisp (lisp)

決定的な違いは、eq です。
比較している「オブジェクト」は、メモリ上に確保された実体のことです。
データだけでなく、それが書かれた入れ物そのものを指します。
ちょうど同じ内容の書類が2枚あっても、それぞれ別の入れ物に存在していれば別物です。

:fooと書くたびに同一のオブジェクト、つまり同じ入れ物を指します。
しかし、"foo"は、基本的に書くたびに新しい入れ物を作って文字を入れます。

(setf kw :foo)
(eq kw :foo)   ; => T   (同じオブジェクトを指している)

(setf s "foo")
(eq s "foo")   ; => NIL (sと"foo"リテラルは別オブジェクト)Code language: Lisp (lisp)

文字列は、内容を書き換えられる可変オブジェクトです。
そのため、今書かれている内容が同じでも、「別物」になるのです。

1.1. 使い方の違いをコードで見る

文字列の内容を操作したり、そのまま出力したりするデータでは、文字列型(string)が適しています。

たとえば、ファイルパスは切り出しや結合が必要で、表示する文字列は大文字小文字を含めた見た目のまま扱いたい場合があります。

;; ファイルパスは文字列のまま扱う
(defun file-extension (path)
  (let ((dot (position #\. path :from-end t)))
    (when dot (subseq path (1+ dot)))))

(file-extension "report.pdf")     ; => "pdf"
(file-extension "archive.tar.gz") ; => "gz"Code language: Lisp (lisp)

中身を書き換えられる可変性と、大文字小文字を区別した入出力ができる点がキーワードシンボルにはないメリットです。

ただし、文字列のequalによる比較は、文字数が増えるほど比較コストが上がります。

概念的には長さが等しいかを確認してから、文字を先頭から1つずつchar=で照合していく処理です。

;; equalの概念的な実装
(defun my-string= (a b)
  (and (= (length a) (length b))
       (loop for ca across a
             for cb across b
             always (char= ca cb))))

(my-string= "foo" "foo")  ; => T
(my-string= "foo" "bar")  ; => NILCode language: Lisp (lisp)

1.2. キーワードシンボルの照合

キーワードシンボルは、この照合がゼロ回で済みます。
そのため、決まった種別を表すときに向いています。

たとえば、"red" "green" "blue"のように取りうる値が決まっている場合、キーワードシンボルを:red:green:blue を使うと速く比較できます。

;; 文字列として受け取った色名をキーワードシンボルに変換する
(defun classify-color (s)
  (intern (string-upcase s) :keyword))

(classify-color "red")    ; => :RED
(classify-color "green")  ; => :GREEN

;; eq で比較できる
(eq (classify-color "red") :red)  ; => TCode language: Lisp (lisp)

ただし、任意のユーザー入力や可変長のテキストをそのままinternし続けると、シンボルテーブルが際限なく膨らんでしまいます。
固定の種別に対応するとわかっているときにだけ使う設計が適切です。

この発想の自然な発展として、ハッシュテーブルのキーに使う場面があります。

;; キーワードシンボルをキーにする(デフォルトのeqlで動く)
(defparameter *config* (make-hash-table))
(setf (gethash :host *config*) "localhost")
(setf (gethash :port *config*) 5432)

(gethash :host *config*)  ; => "localhost"Code language: Lisp (lisp)

キーワードシンボルをキーにすれば:testの指定は不要です。
ポインタ比較で一致するからです。

一方、文字列キーで:test #'equalを忘れると、gethashが常にnilを返すバグになります。

;; 文字列をキーにする場合は :test #'equal が必須
(defparameter *config2* (make-hash-table :test #'equal))
(setf (gethash "host" *config2*) "localhost")
(gethash "host" *config2*)  ; => "localhost"Code language: Lisp (lisp)

1.3. キーワードとcase

キーワードでの種別の分岐にはcaseが使えます。

(defun handle-event (type)
  (case type
    (:click   (format t "クリック~%"))
    (:keydown (format t "キー入力~%"))
    (:scroll  (format t "スクロール~%"))
    (otherwise (format t "不明: ~a~%" type))))

(handle-event :click)    ; => クリック
(handle-event :keydown)  ; => キー入力Code language: Lisp (lisp)

文字列では、同じことをしようとしても caseは使えません。
caseは内部でeqlを使うため文字列が一致しないからです。
そこで、condstring=の組み合わせに書き換える必要があります。

キーワードシンボルは、Common Lispの関数定義にも現れます。
(make-array 10 :initial-element 0):initial-elementがそれです。
ホモイコニシティ、つまりコードとデータの同一性に深く関わる設計なので、後の節で改めて触れます。

1.4. ラベルとして使う(JSON)

「コードの中でラベルとして使うもの」はキーワードシンボルを使います。
関数の引数名、ハッシュテーブルのキー、状態や種別の識別子がこれにあたります。
比較が速く、宣言不要で、どのパッケージからでも同じ意味で使えます。

「プログラムの外から来るデータ」は文字列を使います。
ユーザー入力、ファイルの内容、ネットワークから受け取ったデータがこれにあたります。
内容は実行前には決まっておらず、internするコストをかけるべきでもありません。

たとえば、JSONデータを扱うときには、この境界線が意識されます。

;; JSONから読み込んだデータは文字列キー
(let ((data (parse-json "{\"host\": \"localhost\", \"port\": 5432}")))
  (gethash "host" data))  ; => "localhost"

;; Lisp内部で扱うときはキーワードシンボルに変換することが多い
(defun json->plist (json)
  (loop for (k v) on (hash->list json) by #'cddr
        collect (intern (string-upcase k) :keyword)
        collect v))Code language: Lisp (lisp)

JSONのキーを文字列のままLisp内部で処理すると、比較コストが上がり、:test #'equalを忘れるバグも起きやすくなります。
そこで、システム境界でキーワードシンボルに変換するのが一般的なパターンです。

1.5. キーワードは変更できない。

「書き換える必要があるか」も判断材料になります。

キーワードシンボルは定数として宣言されていて変更できません。
一方、文字列は(setf (char s 0) #\F)のように部分的に中身を変えられます。

;; キーワードシンボルは書き換えられない
(setf :foo 42)  
; => ERROR: :FOO is a constant

;; 文字列は書き換えられる
(let ((s (copy-seq "foo")))
  (setf (char s 0) #\F)
  s)  ; => "Foo"Code language: Lisp (lisp)

シンボルが実行時にも名前として生き続け、プログラムから操作できる設計が、Lispのマクロや動的な性格を支えています。
キーワードシンボルはパッケージ衝突のない安全なラベルとして、その恩恵を受けています。
文字列との使い分けを意識するだけで、Lispらしいコードに近づきます。

2. なぜこの設計になったか

シンボル、文字列、キーワードシンボルの3つは、最初から設計されていたわけではありません。
Lispの発展とともに、徐々に追加されてきました。

設計の歴史 1960年代 アトム 変数名・関数名 実行時にも保持 oblist(シンボルテーブル) McCarthy の Lisp 1970年代 文字列型 自然言語処理の需要 データとしてのテキスト MacLisp で追加 1980年代 キーワードシンボル 大規模ライブラリ設計 パッケージ衝突を回避 Common Lisp 標準化 3つの型は最初から設計されていたわけではない Lispの発展とともに段階的に追加された

2.1. アトムシンボル(1960年代)

McCarthyの原論文では、シンボルは「アトム(atom)」と呼ばれていました。

Lispコードの変数名や関数名が、「アトム」です。

(defun square (x) (* x x))
;; squareもxも、シンボルCode language: Lisp (lisp)

多くのプログラミング言語では、変数名はコンパイル時に消えてアドレスや数値になります。
しかし、Lispでは、evalやapplyでコードをデータとして操作します。
そのため、実行時にも変数名や関数名を参照できる必要がありました。

初期のLispは、oblistと呼ばれるシンボルテーブルを持ち、システムが実行時にそこからシンボルの値やプロパティリストにアクセスしていました。
プログラム自身がシンボルを操作できるように、実行時にシンボルを保持する形になったわけです。
つまり、コードの構成要素につける名前は、Lispの根幹にあります。

内部的には、シンボルはシンボルテーブル上のエントリへのポインタです。
たとえば、squareと書くたびにポインタが返ります。

Cなどの変数名と根本的に違うのは、このシンボルテーブルがコンパイル時だけでなく、実行時にもプログラムからアクセスできる点です。

2.2. 文字列(1970年代)

Lisp 1.5からMacLispへの移行期、LispがAI研究で広く使われるにつれて、自然言語処理や外部データを扱う需要が生まれました。

コードを操作するための名前ではなく、データとしてのテキストが必要になったわけです。
MacLispで文字列型が追加されたのはその文脈です。

;; シンボルはコードの名前
(defun greet (name) ...)

;; 文字列はデータ
(greet "Alice")Code language: Lisp (lisp)

greetnameなどのコードの構成要素はシンボルで、"Alice"のようなデータは文字列、という役割分担が生まれました。

2.3. キーワードシンボル(1980年代)

時は進み、1981年〜1984年のCommon Lispの標準化作業では、大規模なライブラリを設計することになります。

このライブラリ設定で、ある問題が浮上しました。
関数の引数が増えたときに「この引数は何を意味するか」をコード中で明示したいのに、シンボルはパッケージに属するため名前衝突が起きることです。

;; キーワードシンボルはどのパッケージからでも同一
(make-instance 'window :width 800 :height 600 :color :blue)Code language: Lisp (lisp)

そこで、どのパッケージから参照しても同じ意味で使えるラベルが必要になりました。
これがキーワードパッケージ誕生の動機です。
:width:heightなどのキーワードは、すべてkeywordパッケージに属し、どのパッケージからも同じように使い eqで比較できます。

3. C言語との比較で見えてくること

ほかの言語でキーワードに相当するデータ型として、列挙型 enum があります。

C言語との比較と intern の仕組み C の enum 整数に名前をつける ✔ 整数比較で高速 ✗ 事前の宣言が必要 ✗ 実行時に名前を失う コンパイル時に数値へ C の文字列 strcmp() で照合 char 配列のポインタ == では比較不可 Lisp intern の仕組み :foo を読む keyword テーブル検索 あり なし 同じポインタを返す 新規作成して 登録+返す (eq :foo :foo) → T 保証 → Java String.intern() / Ruby シンボル にも同様の発想

たとえば、C言語では、キーワードシンボルと同じ問題に別の方法を用意しています。
「ラベルを効率よく比較したい」とときに使うのが、Cのenumです。

typedef enum {
   CLICK, 
   KEYDOWN, 
   SCROLL 
} EventType;

void handle_event(EventType type) {
    switch (type) {
        case CLICK:   printf("クリック\n");  break;
        case KEYDOWN: printf("キー入力\n"); break;
        case SCROLL:  printf("スクロール\n"); break;
    }
}Code language: Arduino (arduino)

enumは、整数に名前をつけたものです(オブジェクトポインタではなく)。
比較するときは、CPUの整数比較で、高速です。
使える値は、型でチェックできますが、型と値をすべて宣言しなければなりません。
新しいラベルを追加するときには、定義を変える必要があるのです。

一方、Lispのキーワードシンボルは宣言不要で、書いたその場で手軽に使えます。
:clickと書けばそれだけで有効なラベルになります。

他方、Cでは「テキストデータを扱う」ときには、charの配列を使います。

char host[] = "localhost";

/* 内容比較にはstrcmpが必要 */
if (strcmp(host, "localhost") == 0) {
    printf("ローカル環境\n");
}Code language: Arduino (arduino)

Cの文字列はcharの配列をポインタで指したもので、同じ内容でも==では比較できません。
別の場所に確保された配列はアドレスが異なり、strcmpで文字を1つずつ照合する必要があります。

Common Lispの文字列が equalで比較するのと同じです。

3.1. internの仕組みとオブジェクト同一性

ただ、Cにはキーワードシンボルに相当するものはありません。

enumか文字列かを選ぶしかありません。
enumは高速だが事前に列挙が必要で、文字列は実行時に自由な内容を入れられますが、比較コストがかかります。

Lispのキーワードシンボルは柔軟で、ポインタ比較も効く設計になっています。
:fooと書くたびに同一オブジェクトが返る仕組みが、internです。

Lispリーダーが:fooを読んだとき、まずキーワードパッケージの内部テーブルを検索します。
"FOO"という名前のシンボルがあれば、そのポインタを返します。
なければ新しいシンボルを作ってテーブルに登録し、そのポインタを返します。

;; 明示的にinternすることもできる
(intern "FOO" :keyword)  ; => :FOO

;; 同一オブジェクトであることを確認
(eq (intern "FOO" :keyword) :foo)  ; => TCode language: Lisp (lisp)

一度生成されたポインタはプログラム終了まで保持されます。
:fooというポインタはプログラム全体で1つだけ存在し、一意のIDとして機能します。
比較はeq、つまりポインタの==で済みます。
文字列のequalが文字を1つずつ照合するのとは対照的です。

Cのenumが整数の==で比較できるのと速さは同じですが、仕組みは違います。
enumはコンパイル時に数値に置き換わりますが、Lispのキーワードシンボルは実行時にもシンボルオブジェクトとして存在します。
(symbol-name :foo)と書けば"FOO"が返ります。
enumにはこの操作がありません。

文字列は毎回ヒープ上に新しいオブジェクトを確保します。

;; 文字列は同じ内容でも別オブジェクト
(eq "foo" "foo")    ; => NIL(実装依存だが多くの場合NIL)
(equal "foo" "foo") ; => T

;; キーワードシンボルは常に同一オブジェクト
(eq :foo :foo)      ; => T(保証されている)Code language: Lisp (lisp)

このような設計は、JavaのString.intern()やRubyのシンボル(:foo)などにも影響を与えています。