LISPUSER

Common Lisp と日本語Lisp isn't a language, it's a building material.

Common Lisp と日本語

Common Lisp における文字列は「文字 (Character)」の列だ。 もしあなたがC言語のように文字列とバイト列の違いが解釈だけ、という思想に馴染んでいるのなら、この辺は考え方をかえる必要がある。 この章では、「文字」と「文字列」、そして日本語を扱う上で避けて通れない「EXTERNAL-FORMAT」を説明する。 また、サンプルとして処理系毎に異なる日本語関連 API をラップするライブラリを作成する。

文字

Common Lisp規格は文字 character と、characterを構成する三種類の文字型、standard-char, base-char, extended-char を定義する。 また、実装が国際化や数学記号なの特定領域のための文字を追加サポートする事も許している。大抵の実装では日本語は character のうちの base-char もしくは extended-char 型としてサポートされている。

  • standard-char : Common Lispの規格が要求する96種類のアルファベット、数字、記号
  • base-char : standard-char + 実装依存の追加分
  • extended-char : character のうち、 base-char に含まれないもの

次のような式で考えれば簡単だ。

:base-char 型 = standard-char + 拡張文字 :character 型 = base-char + extended-char = (standard-char + 拡張文字) + extended-char

ここで、規格は base-char が standard-char よりも大きい文字集合であることを要求しているだけなので、日本語のサポートを base-char に追加してもよい。 普段使う時にこんな違いを気にする必要があるか?と思うかもしれないが、移植性を考えるならばbase-charが日本語を含まない処理系を意識しておく必要がある。 さっきの character = base-char + extended-char という関係を思い出そう。日本語の文字がextended-charとして実装されている処理系ではbase-charの配列は 日本語を含む事ができないのだ。解決策は単純で、処理系間での移植性を考慮する場合にはcharacterを使えば良い。

ほとんどのCommon Lisp処理系は文字を内部では Unicode コードポイントとして保持している処理系が多い。大抵の処理系では以下のような結果になるはずである。

CL-USER> (char-code #\あ)
12354

ただし、これはCommon Lispの国際化サポートが Unicode ベースでなければならないという事を意味するわけではない。 単に実装が Unicode ベースというだけの事である。処理系毎のサポート状況は現在の時点 (2008年6月) では以下の通り。

処理系内部表現char-code-limit
-——————————————-————
AllegroCLUnicode (16bit) - BMPサポート65536
LispWorksUnicode (16bit) - BMPサポート65536
SBCLUnicode (21bit)1114112
CLISPUnicode (21bit)1114112
ECLUnicode (21bit)1114112
CMUCL8bit256

表にない処理系についても、char-code-limit を参照する事で日本語サポートの推定は可能だ。 ただし、内部表現が Unicode である事と、日本語が利用できる事は別であるので注意が必要である。 例えば Embedded Common Lisp 0.9k は char-code-limit のが 1114112 で内部表現は 21bit Unicode であるが、 後述する EXTERNAL-FORMAT がサポートされていないため、日本語の文字を認識する事はできない。

処理系の内部表現に 16bit Unicode と 21bit Unicode の二種類があるが、内部表現が 16bit の処理系では 一部の文字が文字オブジェクトとして扱えない。

;; 21bit Unicode をサポートした処理系
CL-USER> (code-char #x10000)
#\LINEAR_B_SYLLABLE_B008_A

;; 21bit Unicode をサポートしない処理系
CL-USER> (code-char #x10000)
NIL

16bit Unicodeに含まれない文字には、例えばWindows Vistaから新たに利用可能となった漢字の一部が該当する。

手持ちの処理系が文字型としてサポートしていない範囲の文字を扱うには、ちょうどC言語のように文字をバイト列として扱うことになる。

EXTENRAL-FORMAT

処理系内内で文字が Unicode コードポイントとして表現されていたとして、 世の中で使われている UTF-8 や EUC-JP、 ShiftJIS での日本語文字列表現と合わせるにはどうするか? Lisp 処理系が、ファイル等の Lisp の外側 (external) での文字エンコーディングを表現する extenral-format を通じて、変換機能を提供してくれる。

LISP内部表現 <---- external-format ---- ファイル / ストリーム
LISP内部表現 ---- external-format ----> ファイル / ストリーム

Common Lisp規格で要求されている EXTERNAL-FORMAT は :default だけであるが、Common Lisp処理系はこの仕様を実装毎に拡張することで、 日本語サポートを提供している。ただし、処理系毎の EXTERNAL-FORMAT サポート状況は次の表のようなバラバラな状態になっている。

処理系サポートされる EXTERNAL-FORMAT (一部抜粋)
————————————————————————————————————
AllegroCL:latin1 :ascii :8-bit :iso8859-1 :utf8 :euc-jp :jis :shiftjis
LispWorks:unicode :latin-1 :ascii :macos-roman :jis-x-208 :jis-x-212 :euc-jp :sjis :jis
SBCL:latin1 :utf-8 :eucjp :sjis
CLISPcharset:ascii charset:iso-8859-1 charset:utf-8 charset:euc-jp charset:shift-jis charset:iso-2022-jp

[注] SBCL では標準で iso-2022-jp がサポートされてない点は注意が必要である。外部ライブラリを使うなど、自力で対応する必要がある。

EXTERNAL-FORMATの表現は処理系事にキーワードであったり、リストであったり、オブジェクトであったりとバラバラである。 また、AllegroCL, LispWorks, CLISP は改行コードの取り扱いを EXTERNAL-FORMAT 内に含んでいるが、SBCL では含んでいないなど機能的にも 差異がある。

では早速EXTERNAL-FORMATを使ってみよう。手始めに、EXTERNAL-FORMATを指定して文字列を書き出し、それをバイト列として読み出してみよう。

CL-USER> (with-open-file (s "jstr_utf8.txt" :direction :output :if-exists :supersede :external-format :utf8)
           (write-sequence "日本語文字列" s))
"日本語文字列"
CL-USER> (with-open-file (s "jstr_sjis.txt" :direction :output :if-exists :supersede :external-format :sjis)
           (write-sequence "日本語文字列" s))
"日本語文字列"

CL-USER> (with-open-file (s "jstr_utf8.txt" :direction :input :element-type '(unsigned-byte 8))
           (let ((seq (make-sequence '(vector (unsigned-byte 8)) 18)))
             (read-sequence seq s)
             seq))
#(230 151 165 230 156 172 232 170 158 230 150 135 229 173 151 229 136 151)

CL-USER> (with-open-file (s "jstr_sjis.txt" :direction :input :element-type '(unsigned-byte 8))
           (let ((seq (make-sequence '(vector (unsigned-byte 8)) 12)))
             (read-sequence seq s)
             seq))
#(147 250 150 123 140 234 149 182 142 154 151 241)

external-format の指定によって外部表現がかわっている事が確認できる。 それぞれのバイト列を、Lispの文字列表現に戻すには、同じ external-format を指定して読み込む。

CL-USER> (with-open-file (s "jstr_utf8.txt" :direction :input :external-format :utf8)
           (let ((str (make-string 6)))
             (read-sequence str s)
             str))
"日本語文字列"

CL-USER> (with-open-file (s "jstr_sjis.txt" :direction :input :external-format :sjis)
           (let ((str (make-string 6)))
             (read-sequence str s)
             str))
"日本語文字列"

このように、文字ストリームに対してはEXTERNAL-FORMATを指定する事で、内部表現と外部の日本語エンコーディングとの変換を 処理してくれる。また、ソケット等のファイル以外のストリームに関しても処理系毎に external-format が指定できるように なっているはずなので、処理系附属のドキュメントを参照してほしい。

文字列とバイト列

Common Lispで日本語を扱う際に、「文字列」と「バイト列」を混在させて扱いたい場合に困った人は多いのではないだろうか。 たとえば次のようなコードを見てみよう。

(with-open-file (s "sample.dat" :direction :ouput :external-format :utf-8)
  (write-line "日本語" s)
  (write-sequence #(0 1 2 3 4 5 6 7 8 9) s))

これは、AllegroCL などの単一のストリームに対して、READ-CHAR / READ-BYTE の文字あるいはバイト単位をの I/O が行なえる BIVALENT-STREAM をサポートする処理系以外ではエラーとなる。文字ストリームの ELEMENT-TYPE が 'CHRACTER であるため、 (unsigned-byte 8) のベクタは型が違うため書き込めない。

この問題に対するもっともポータブルな答えは、ストリームを :element-type '(unsigned-byte 8) で開き、 文字列は処理系に備わっている関数をつかって (unsigned-byte 8) のバイト列に変換して書き込むというものだ。 各処理系に備わっている文字列とバイト列の変換を行う関数を次に示す。

処理系文字列 => バイト列バイト列 => 文字列
———–—————————–————————
AllegroCLext:string-to-octetsext:octets-to-string
LispWorksext:encode-lisp-stringext:decode-external-string
SBCLsb-ext:string-to-octetssb-ext:octets-to-string
CLISPext:convert-string-from-bytesext:convert-string-tom-bytes

たとえば、先程のコードを SBCL で記述すると次のようになる。

(with-open-file (s "jstr_mixed.txt"
                  :direction :output
                  :if-exists :supersede
                  :element-type '(unsigned-byte 8))
  (write-sequence (sb-ext:string-to-octets "日本語" :external-format :utf-8) s)
  (write-sequence #(0 1 2 3 4 5 6 7 8 9) s))

同様に、他の処理系でも一旦バイト列に変換した後 WRITE-SEQUENCE で書き込む事で日本語文字列とバイト列をストリームに 出力することができる。

すべてを (unsigned-byte 8) として取り扱うというやり方は移植性に優れているが、二つ短所がある。 一つ目は効率の問題だ。日本語 - バイト列 - ストリーム という変換は、メモリを余計に消費する。 特にストリームがバッファリングされている場合には……あぁ、神樣。 二つめは一部のエンコーディングとの相性が良くない点だ。エンコーディングにはShift_JISやEUC-JPのようにステートレスに Unicodeと変換可能なものばかりではなく、「状態」をもったものが存在する。たとえばISO-2022-JPは、日本語文字列 をエスケープシーケンス + 日本語を表わすバイト列 + エスケープシーケンスという構成になる。 文字列A、文字列Bの二つの日本語文字列をバイト列に変換した場合、それぞれの先頭と末尾にエスケープシーケンスが付加されてしまう。 実際にはエスケープシーケンスは日本語の開始と終了時に一つずつあればいいのだから、これは明らかに無駄である。 つまり、文字列を一つ一つバイト列に変換するよりも、最小限のエスケープシーケンスで済むようにストリーム側で 状態を保持して面倒を見てくれるような実装が望ましい。

AllegroCLにはSIMPLE-STREAMという新しいストリームの規格が提唱された。これは内部に (unsigned-byte 8) のバッファを 持ち、external-format の処理を備え、read-byte / read-char のようなバイト単位入出力と文字単位入出力が同じストリームに 実施できるようになったものである。規格は公開されており、現在 CMUCL, SBCL で試験的に実装がはじまっている。

エンコーディング変換可搬ライブラリの作成

EXTERNAL-FORMATの実装は15章で扱ったパスネーム以上に実装毎の差異が大きい。 以前にやったように、共通のインターフェースを作成してみよう。

パッケージの作成

(defpackage :jp (:nicknames :jp) (:use :common-lisp)
          (:export
           :make-encoding
           :encode
           :decode
           :guess))
(in-package :jp)

エンコーディングの作成

external-format を指定する方法は処理系毎に次の表のようになっている。

処理系external-format 指定子
———–————————————————————————
AllegroCLキーワードもしくは (crlf-base-ef [キーワード])
LispWorks'([キーワード] :eol-type [:lf/:cr/:crlf])
SBCLキーワードで指定
CLISP(ext:make-encoding "エンコーディング名" :line-terminator [:unix/:mac/:dos]

これらを統合して、共通なインターフェースを作成する。サポートする文字集合は以下の通り。

:default処理系のデフォルト
:asciiASCII (8-bit Char)
:utf8UTF-8
:sjisShift_JIS
:euc-jpEUC-JP
:jisJIS (ISO-2022-JP)SBCLでは未サポート

また、改行コードは処理系がサポートしている限り UNIXで良く利用される :lf, Apple製OSでメジャーな :cr, Microsoft系 OS で使用される :crlf を指定できようにする。

(defun make-encoding (charset &key (eol-style :lf))
  #+allegro
  (let ((charset (ecase charset
                 (:ascii :ascii)
                 (:utf8 :utf8)
                 (:sjis :shiftjis)
                 (:euc-jp :euc)))
      (func   (ecase eol-style
                (:lf #'identity)
                (:crlf #'excl:crlf-base-ef))))
    (funcall func charset))
  #+lispworks
  `(,(ecase charset
          (:asci :ascii)
          (:utf :utf-8)
          (:sjis :sjis)
          (:euc-jp :euc-jp)
           (:jis :jis)))
     :eol-style ,(ecase eol-style ((:cr :lf :crlf) eol-style)))
  #+sbcl (declare (ignore eol-style))
  #+sbcl
  (ecase charset
    (:ascii :ascii)
    (:utf8 :utf-8)
    (:sjis :cp932)
    (:euc-jp :eucjp))
  #+clisp
  (let ((charset (ecase charset
                 (:ascii "ISO-8859-1")
                 (:utf8 "UTF-8")
                 (:sjis "Shift_JIS")
                 (:euc-jp "EUC-JP")
                 (:jis "ISO-2022-JP")))
      (eol-style (ecase eol-style
                   (:cr :mac)
                   (:lf :unix)
                   (:crlf :dos))))
    (ext:make-encoding :charset charset
                   :line-terminator eol-style)))

ただし SBCL では改行コードの指定は EXTERNAL-FORMAT に含まれないため、指定しても効果はない。

文字列からバイト列への変換

Lisp文字列からバイト列へと変換するためのラッパー ENCODE を作成する。 :start, :end 引数を指定する事により 文字列の一部分を変換する事も可能である。処理系によっては、末尾をヌル文字で終端するオプションを備えているため、互換オプションを用意する。

(defun encode (string external-format &key (start 0) end (null-terminate nil))
  #+allegro
  (excl:string-to-octets string :external-format external-format :start start :end end :null-terminate null-terminate)
  #+lispworks
  (if null-terminate
      (concatenate '(vector (unsigned-byte 8)) (external-format:encode-lisp-string string external-format :start start :end end) #(0))
      (external-format:encode-lisp-string string external-format :start start :end end))
  #+sbcl
  (sb-ext:string-to-octets string :external-format external-format :start start :end end :null-terminate null-terminate)
  #+clisp
  (if null-terminate
      (concatenate '(vector (unsigned-byte 8)) (ext:convert-string-to-bytes string external-format :start start :end end) #(0))
      (ext:convert-string-to-bytes string external-format :start start :end end)))

これで、MAKE-ENCODING によって作成したエンコーディングを使って文字列からバイト列への変換が可能となる。 ただし、AllegroCL が備えている機能のうち、新しく確保した配列のかわりに指定した配列を書換える機能はポータブルに実現する方法がないため、サポートしない。

バイト列から文字列への変換

バイト列から文字列への変換を行うラッパー DECODE はもっと簡単な定義であ る。ここでも、AllegroCL の機能のうち、指定した配列を書換える機能はサポートされない。

(defun decode (vector external-format &key (start 0) end)
  #+allegro
  (excl:octets-to-string vector :external-format external-format :start start :end end)
  #+lispworks
  (external-format:decode-external-string vector external-format :start start :end end)
  #+sbcl
  (sb-ext:octets-to-string vector :external-format external-format :start start :end end)
  #+clisp
  (ext:convert-string-from-bytes vector external-format :start start :end end))

ラッパー処理の最適化

さて、ここまでで一応ラッパーは完成したが、このままでは単に性能が悪化するだけであるため、おもしろくない。 そこでCommon Lisp規格に含まれている最適化方式である define-compiler-macro と inline による最適化を実施してみよう。

define-compiler-macro はコンパイル時にのみ処理されるマクロである。 このマクロの有用なところは、コンパイル時に関数の引数に指定されたパラメータをチェックして変形が可能であるという点だ。 次のような関数呼び出しを見てみよう。

 (jp:encode "日本語" (jp:make-encoding :sjis))

これは、文字列、external-format の指定がすべて定数であるから、指定した引数から値を計算すると

 #(147 250 150 123 140 234)

と等価である。この変換をコンパイル時に実施するようにしてみよう。

まず最初に jp:encode と jp:raw-encode のように関数を分離する。jp:encode をコンパイルする時に、コンパイラマクロを 使って引数がすべて定数なら jp:raw-encode を呼び出してその場で結果を計算し、もし引数に変数が混ざっていた場合には 単なる jp:raw-encode の呼び出しへとマクロ展開をおこなう。

また、この際に合わせて関数呼び出しの階層が増えて効率が落ちないようにinline宣言をおこなって関数がインライン展開されるようにしておく。

(eval-when (:compile-toplevel :load-toplevel :execute)

  (declaim (inline raw-make-encoding))
  (defun raw-make-encoding (charset eol-style)
    ...)
  (defun make-encoding (charset &key (eol-style :crlf))
    (raw-make-encoding charset eol-style))

  (declaim (inline raw-encode))
  (defun raw-encode (string external-format start end null-terminate)
    ...)

  (defun encode (string external-format &key (start 0) end (null-terminate nil))
    (raw-encode string external-format start end null-terminate))

  (declaim (inline raw-decode))
  (defun raw-decode (vector external-format start end &aux (usb8-array (coerce vector '(vector (unsigned-byte 8)))))
    ...)
  (defun decode (vector external-format &key (start 0) end)
    (raw-decode vector external-format start end))

)

次に実際に encoe / decode 引数が定数の場合に値を展開するコンパイラマクロを定義する。 まず external-format 以外のパラメータをすべて定数であるかをチェックし、 さらに external-format が定数あるいは (make-encoding [定数]) の呼び出しであるかをチェックする。 すべてのチェックを通ったら、コンパイル時に値が決定できるという事なので、その場で encode を実施して 結果の文字列を返す。それ以外の場合は raw-encode の呼び出しを実施する。

(define-compiler-macro encode (string external-format &key (start 0) end (null-terminate nil))
  (if (and (every #'constantp (list string start end null-terminate))
         (or (constantp external-format)
             (and (consp external-format)
                  (eq (first external-format) 'make-encoding)
                  (every #'constantp (cdr external-format)))))
      (let ((ef (if (and (consp external-format) (eq (first external-format) 'make-encoding))
                  (apply #'make-encoding (cdr external-format))
                  external-format)))
      (raw-encode string ef start end null-terminate))
      `(raw-encode ,string ,external-format ,start ,end ,null-terminate)))

(define-compiler-macro decode (string external-format &key (start 0) end)
  (if (and (every #'constantp (list string start end))
         (or (constantp external-format)
             (and (consp external-format)
                  (eq (first external-format) 'make-encoding)
                  (every #'constantp (cdr external-format)))))
      (let ((ef (if (and (consp external-format) (eq (first external-format) 'make-encoding))
                  (apply #'make-encoding (cdr external-format))
                  external-format)))
      (raw-decode string ef start end))
      `(raw-decode ,string ,external-format ,start ,end)))

ここまですると、コンパイルした後には raw-decode がインライン展開された状態になり、関数呼び出しのオーバーヘッドが削減される。 ほとんどの処理系では encode/decode を使った関数を disassemble すると encode/decode は言うに及ばず raw-encode/raw-decode も 消えさって処理系の変換関数を呼び出しているのがわかるだろう。まずはコンパイラマクロによる最適化が効く例を御覧いただこう。

CL-USER> (disassemble (lambda () (funcall #'ja:encode "日本語" charset:cp932)))
 
Disassembly of function :LAMBDA
(CONST 0) = #(147 250 150 123 140 234)
0 required arguments
0 optional arguments
No rest parameter
No keyword parameters
2 byte-code instructions:
0     (CONST 0)                           ; #(147 250 150 123 140 234)
1     (SKIP&RET 1)
NIL

いかがだろうか?コンパイル後には変換後のベクタしか残っていない。 続けてコンパイラマクロによる最適化が効かない場合を見てみよう。

CL-USER> (disassemble (lambda () (funcall #'ja:encode "日本語" (if (= (random 2) 0) charset:cp932 charset:euc-jp))))

Disassembly of function :LAMBDA
(CONST 0) = 2
(CONST 1) = CHARSET:CP932
(CONST 2) = CHARSET:EUC-JP
(CONST 3) = "日本語"
(CONST 4) = :START
(CONST 5) = 0
(CONST 6) = :END
(CONST 7) = CONVERT-STRING-TO-BYTES
0 required arguments
0 optional arguments
No rest parameter
No keyword parameters
reads special variables: CHARSET:CP932 CHARSET:EUC-JP
17 byte-code instructions:
0     (CONST&PUSH 0)                      ; 2
1     (PUSH-UNBOUND 1)
3     (CALLS2&PUSH 247)                   ; RANDOM
5     (CALLS2&JMPIF 172 L21)              ; ZEROP
8     (GETVALUE&PUSH 2)                   ; CHARSET:EUC-JP
10    L10
10    (CONST&PUSH 3)                      ; "日本語"
11    (LOAD&PUSH 1)
12    (CONST&PUSH 4)                      ; :START
13    (CONST&PUSH 5)                      ; 0
14    (CONST&PUSH 6)                      ; :END
15    (NIL&PUSH)
16    (CALL 6 7)                          ; CONVERT-STRING-TO-BYTES
19    (SKIP&RET 2)
21    L21
21    (GETVALUE&PUSH 1)                   ; CHARSET:CP932
23    (JMP L10)
NIL

ここでも、encode はコンパイラマクロにより raw-encode に展開され、raw-encode はインライン展開された convert-string-to-bytes が呼び出されている。