LISPUSER
日本語処理Lisp isn't a language, it's a building material.Table of Contents
日本語処理
Common Lisp における文字列は「文字(Character)」の列だ。C 言語などでは、文字列は実際にはバイト列と等価なので、 この辺は考え方をかえる必要がある。もちろん、内部的には何かのエンコーディングによるバイト列を保持 しているわけだが、I/O や、バイト列との変換には external-format を指定して内部エンコーディングから 目的のエンコーディングへと変換する必要がある。
文字列 (Character の列) ---- external-format ----> バイト列 (unsigned-byte の列) 文字列 (Character の列) <---- external-format ---- バイト列 (unsigned-byte の列)
この external-format は、バイト列への変換以外にも文字列のやりとりなど I/O ストリームでも使用する。 サポートされているexternal-formatの種類や、その API については、処理系毎に異なるので各処理系のマニュアルを参照すること。 この章では文字列とexternal-formatを扱うための基本的な処理を提供する薄いラッパーを作成する。
文字
アルファベット、記号、改行とスペースからなる Lisp の最低限の文字からなる standard-char 型はすべての処理系で必ずサポートされている。 実際の処理系ではこれよりも沢山の文字が利用可能となっており、日本語等は standard-char を拡張した base-char もしくは extended-char という型に分類される。
base-char := standard-char + 拡張文字 character := base-char + extended-char = (standard-char + 拡張文字) + extended-char
次に例を示そう。AllegroCL では #\あ という日本語文字は standard-char ではないが、base-char であるため、 base-char の拡張文字としてあつかわれている事がわかる
CL-USER> (typep #\a 'standard-char) T CL-USER> (typep #\a 'base-char) T CL-USER> (typep #\a 'extended-char) NIL CL-USER> (typep #\a 'character) T CL-USER> (typep #\あ 'standard-char) NIL CL-USER> (typep #\あ 'base-char) T CL-USER> (typep #\あ 'extended-char) NIL CL-USER> (typep #\あ 'character) T
しかし、SBCL では以下のようになる。
CL-USER> (typep #\あ 'standard-char) NIL CL-USER> (typep #\あ 'base-char) NIL CL-USER> (typep #\あ 'extended-char) T CL-USER>
SBCL では日本語は extended-char 型となっていることがわかる。したがって、日本語をポータブルに扱うには base-char ではなく character 型を利用するのがよい。
処理系が日本語をサポートしているかどうかの目安としては、char-code-limit 変数の値を見るのが一つの目安になる。 日本語をサポートしているなら、この値は 65536 以上である場合が多いからだ。
CL-USER> char-code-limit 65536
現在では、内部表現に Unicode を用いている処理系が多いため、 char-code-limitの値が 1114112 であれば21bit Unicode、 65536であれば16bit Unicode(基本他言語面のみサポート)、という推測が可能である。
1) 16bit Unicodeサポートの処理系では、たとえばWindows Vistaで新たに利用可能となった漢字のように一部扱えない文字がある事に注意。 2) 内部表現が21bit Unicodeだからといって日本語が上手く扱えるとは限らない。たとえば現行バージョンのECLは内部表現は21bit Unicodeだがexternal-formatのサポートがないため、日本語を文字として扱う事はできない。
文字はあくまで文字型であり、C 言語のような数値ではない。文字は文字としか比較はできず、加算や減算など数値として扱う事はできない。 文字とバイトを変換するには char-code, code-char 関数を使う。char-code は文字を引数に取り、処理系の内部エンコーディングでのその文字の 値を返す。code-char は数字を引数にとり、内部エンコーディングでそのコードに対応する文字を返す。
CL-USER> (char-code #\a) 97 CL-USER> (code-char 97) #\a CL-USER> (char-code #\b) 98 CL-USER> (code-char 98) #\b CL-USER> (char-code #\あ) 12354 CL-USER> (code-char 12354) #\あ
char-code, code-char は ASCII 範囲は ASCII コード互換である事が多い。規格では ASCII 互換である事を要求はしていないが、 現在のほとんどの実装では英数字に関しては ASCII 互換を期待してもよいだろう。 マルチバイト文字に関しては処理系の内部エンコーディングに依存するため一般的な事はいえない。
文字列
文字列は、文字の配列である。したがって配列操作 (aref, subseq, fill …) などは全て文字列に対しても使用できる。 文字列の型には string, base-string, simple-string, simple-base-string という種類があるが、実用上は string さえ知っていれば問題ない。 日本語の使用を考えると base-string を意図して使うケースはあまりないからだ。
base-string とは ANSI Common Lisp では「(vector base-char) と等価な型」 であり、かつ「standard-char を保持できるもっとも効率的な文字列」となっている。 文字の例もあるように、base-char の範囲に日本語が収まっているとは限らないため 日本語の利用する場合には character の列である string 型を使うべきだ。
[footnote] ASCII範囲の文字しか使わないという条件であればbase-stringを使う事によりメモリ消費を抑える事ができる処理系もある。
EXTERNAL-FORMAT
文字列とバイト列の変換に使用する。EXTERNAL-FORMAT の詳細は implementation-depend であると規定されているため、 各実装毎に異なる。with-open-file やソケット回りの関数など :external-format 引数を取る関数はあまり悩む事はないだろうが、 処理系毎に異なる文字列とバイト列の変換について説明しよう。
AllegroCL
Unicode ベースの他言語サポートを備える。対応している external-format は :ascii, :utf8, :euc-jp, :shiftjis, :jis (iso-2022-jp)。 改行として:lf, :crlfをサポートしている。
CL-USER> (excl:string-to-octets "日本語" :external-format :shiftjis :null-terminate nil) #(147 250 150 123 140 234) CL-USER> (excl:octets-to-string #(147 250 150 123 140 234) :external-format :shiftjis) "日本語"
LispWorks
Unicode ベースの他言語サポートを備える。対応している external-format は :ascii, :utf-8, :euc-jp, :shiftjis, :jis (iso-2022-jp)。 改行として:lf, :cr, :crlfをサポートしている。
CL-USER> (external-format:encode-lisp-string "日本語" :shiftjis) #(147 250 150 123 140 234) CL-USER> (external-format:decode-external-string #(147 250 150 123 140 234) :shiftjis) "日本語"
SBCL
Unicode ベースの他言語サポートを備える。対応している external-format は :ascii, :utf-8, :euc-jp, :cp932。 改行文字の指定がexternal-formatに含まれない点と、JISコード (iso-2022-jp) が未サポートである点に注意。
CL-USER> (sb-ext:string-to-octets "日本語" :external-format :sjis :null-terminate nil) #(147 250 150 123 140 234) CL-USER> (sb-ext:octets-to-string #(147 250 150 123 140 234) :external-format :sjis) "日本語"
CLISP
Unicode ベースの他言語サポートを備える。対応している external-format は charset:utf-8, charset:euc-jp, charset:shiftjis, charset:iso-2022-jp。 また、改行として :unix, :mac, :dos が指定可能。変換ルーチンは GNU libiconv もしくは glibc 内の iconv を利用している。
CL-USER> (ext:convert-string-to-bytes "日本語" charset:sjis) #(147 250 150 123 140 234) CL-USER> (ext:convert-bytes-to-string #(147 250 150 123 140 234) charset:sjis) "日本語"
エンコーディング変換可搬ライブラリの作成
処理系毎に異なるexternal-forma 関連の処理の違いを吸収するラッパーライブラリを作成する。
パッケージの作成
(defpackage :ja (:use :common-lisp) (:export :make-encoding :encode :decode :guess)) (in-package :ja)
エンコーディングの作成
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 | 処理系のデフォルト | :default | |
:ascii | ASCII (8-bit Char) | (make-encoding :ascii) | |
:utf8 | UTF-8 | (make-encoding :utf8) | |
:sjis | Shift_JIS | (make-encoding :sjis) | |
:euc-jp | EUC-JP | (make-encoding :euc-jp) | |
:jis | JIS (ISO-2022-JP) | SBCLでは未サポート | (make-encoding :jis) |
また、改行コードは処理系がサポートしている限り UNIXで良く利用される :lf, Apple製OSでメジャーな :cr, Microsoft系 OS で使用される :crlf を指定できようにする。 これも、external-formatによる改行文字指定をサポートしないSBCLでは単に無視される。
(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 &aux (usb8-array (coerce vector '(vector (unsigned-byte 8))))) #+allegro (excl:octets-to-string usb8-array :external-format external-format :start start :end end) #+lispworks (external-format:decode-external-string usb8-array external-format :start start :end end) #+sbcl (sb-ext:octets-to-string usb8-array :external-format external-format :start start :end end) #+clisp (ext:convert-string-from-bytes usb8-array external-format :start start :end end))
バイト列の文字コード推定
バイト列を日本語文字列とみなして文字コードを推定する関数を Scheme 処理系 Gauche からの移植する。 オリジナルの関数は Scheme をつかってC言語ソース形式の状態表を作成し、それを C コンパイラでコンパイルしてモジュールを 作成している。Common Lispへの移植時にはeval-whenを用いて状態表を定数に展開し、その表を参照するようにした。 動作そのものはベクタをスキャンしつつ各文字コード別の状態表をつかってスコアを計算し、スコアをもとに文字コードを推定するというものである。
(eval-when (:compile-toplevel :load-toplevel :execute) (unless (fboundp 'resolve-states) (defclass <dfa> () ((name :initarg :name :accessor name-of) (states :initarg :states :accessor states-of) #+nil (instances :allocation :class :initform nil))) (defclass <state> () ((name :initarg :name :accessor name-of) (index :initarg :index :accessor index-of) (arcs :initarg :arcs :accessor arcs-of :initform nil))) (defclass <arc> () ((from-state :initarg :from-state :accessor from-state-of) (to-state :initarg :to-state :accessor to-state-of) (ranges :initarg :ranges :accessor ranges-of) (index :initarg :index :accessor index-of) (score :initarg :score :accessor score-of))) (defun resolve-states (state-defs) (let ((states (mapcar (lambda (d i) (make-instance '<state> :name (car d) :index i)) state-defs (loop for i from 0 below (length state-defs) collect i)))) (labels ((gen (s d i &aux (num-arcs (length (cdr d)))) (setf (arcs-of s) (mapcar (lambda (arc aindex) (make-instance '<arc> :from-state s :to-state (or (find-if (lambda (e) (eq (name-of e) (cadr arc))) states)) :ranges (car arc) :index aindex :score (caddr arc))) (cdr d) (loop repeat num-arcs for x from i collect x))) (+ i num-arcs)) (fold (fun state arg1 arg2) (if (or (null arg1) (null arg2)) state (fold fun (funcall fun (car arg1) (car arg2) state) (cdr arg1) (cdr arg2))))) (fold #'gen 0 states state-defs) states))) ;;; DFA (defmacro define-dfa (name &body states) (let ((name-st (intern (string-upcase (format nil "+~A-ST+" name)))) (name-ar (intern (string-upcase (format nil "+~A-AR+" name))))) `(unless (boundp ',name-st) (let ((dfa (make-instance '<dfa> :name ',name :states (resolve-states ',states)))) (defconstant ,name-st (apply #'vector (loop for state in (states-of dfa) collect (let ((vec (make-array 256 :initial-element -1))) (flet ((b2i (byte) (if (characterp byte) (char-code byte) byte))) (dolist (br (arcs-of state)) (dolist (range (ranges-of br)) (if (consp range) (fill vec (index-of br) :start (b2i (car range)) :end (+ (b2i (cadr range)) 1)) (setf (aref vec (b2i range)) (index-of br))))) vec))))) (defconstant ,name-ar (apply #'vector (loop for arc in (loop for state in (states-of dfa) appending (arcs-of state)) collect (cons (index-of (to-state-of arc)) (score-of arc))))))))) ;;;;; state data from Gauche's guess.scm ;;; EUC-JP (define-dfa eucj ;; first byte (init (((#x00 #x7f)) init 1.0d0) ; ASCII range ((#x8e) jis0201_kana 0.8d0) ; JISX 0201 kana ((#x8f) jis0213_2 0.95d0) ; JISX 0213 plane 2 (((#xa1 #xfe)) jis0213_1 1.0d0) ; JISX 0213 plane 1 ) ;; jis x 0201 kana (jis0201_kana (((#xa1 #xdf)) init 1.0d0) ) ;; jis x 0208 and jis x 0213 plane 1 (jis0213_1 (((#xa1 #xfe)) init 1.0d0)) ;; jis x 0213 plane 2 (jis0213_2 (((#xa1 #xfe)) init 1.0d0)) ) ;;; Shift_JIS (define-dfa sjis ;; first byte (init (((#x00 #x7f)) init 1.0d0) ;ascii (((#x81 #x9f) (#xe1 #xef)) jis0213 1.0d0) ;jisx0213 plane 1 (((#xa1 #xdf)) init 0.8d0) ;jisx0201 kana (((#xf0 #xfc)) jis0213 0.95d0) ;jisx0213 plane 2 (((#xfd #xff)) init 0.8d0)) ;vendor extension (jis0213 (((#x40 #x7e) (#x80 #xfc)) init 1.0d0)) ) ;;; UTF-8 (define-dfa utf8 (init (((#x00 #x7f)) init 1.0d0) (((#xc2 #xdf)) 1byte_more 1.0d0) (((#xe0 #xef)) 2byte_more 1.0d0) (((#xf0 #xf7)) 3byte_more 1.0d0) (((#xf8 #xfb)) 4byte_more 1.0d0) (((#xfc #xfd)) 5byte_more 1.0d0)) (1byte_more (((#x80 #xbf)) init 1.0d0)) (2byte_more (((#x80 #xbf)) 1byte_more 1.0d0)) (3byte_more (((#x80 #xbf)) 2byte_more 1.0d0)) (4byte_more (((#x80 #xbf)) 3byte_more 1.0d0)) (5byte_more (((#x80 #xbf)) 4byte_more 1.0d0)) ) ;;; ;;; JIS (ISO2022JP) ;;; ;; NB: for now, we just check the sequence of <ESC> $ or <ESC> '('. '(define-dfa jis (init ((#x1b) esc 1.0d0) (((#x00 #x1a) (#x1c #x1f)) init 1.0d0) ;C0 (((#x20 #x7f)) init 1.0d0) ;ASCII (((#xa1 #xdf)) init 0.7d0) ;JIS8bit kana ) (esc ((#x0d #x0a) init 0.9d0) ;cancel ((#\( ) esc-paren 1.0d0) ((#\$ ) esc-$ 1.0d0) ((#\& ) esc-& 1.0d0) ) (esc-paren ((#\B #\J #\H) init 1.0d0) ((#\I) jis0201kana 0.8d0) ) (esc-$ ((#\@ #\B) kanji 1.0d0) ((#\( ) esc-$-paren 1.0d0) ) (esc-$-paren ((#\D #\O #\P) kanji 1.0d0)) (esc-& ((#\@ ) init 1.0d0)) (jis0201kana ((#x1b) esc 1.0d0) (((#x20 #x5f)) jis0201kana 1.0d0)) (kanji ((#x1b) esc 1.0d0) (((#x21 #x7e)) kanji-2 1.0d0)) (kanji-2 (((#x21 #x7e)) kanji 1.0d0)) ) ))
ここまでが、EUC-JP, Shift_JIS, UTF-8, ISO-2022-JP の状態遷移表の定義である。 これらの表の定義は最終的に DEFCONSTANT で定義された定数ベクタに展開される。
次に、この状態遷移表をつかって文字コードを推定する GUESS である。
(defun guess (vector &optional (scheme :JP)) (case scheme ((:*JP :JP) (guess-jp vector)) (t (error "scheme parameter: supported :*JP only")))) (defun guess-jp (buffer &aux (len (length buffer))) (declare (optimize (speed 3) (space 0) (safety 0) (debug 0))) (macrolet ((dfa-init (dfa-st dfa-ar) `(vector ,dfa-st ,dfa-ar 0 1.0d0)) (score (dfa) `(svref ,dfa 3)) (state (dfa) `(svref ,dfa 2)) (arcs (dfa) `(svref ,dfa 1)) (states (dfa) `(svref ,dfa 0)) (dfa-alive (dfa) `(>= (the fixnum (state ,dfa)) (the fixnum 0))) (dfa-next (dfa ch) `(when (dfa-alive ,dfa) (when (>= (the fixnum (state ,dfa)) (the fixnum 0)) (let ((temp (svref (svref (states ,dfa) (state ,dfa)) ,ch))) (if (< (the fixnum temp) (the fixnum 0)) (setf (state ,dfa) -1) (setf (state ,dfa) (the fixnum (car (svref (arcs ,dfa) temp))) (score ,dfa) (* (the double-float (score ,dfa)) (the double-float (cdr (svref (arcs ,dfa) temp)))))))))) ;; utility (process-dfa (dfa ch value &rest others) `(when (dfa-alive ,dfa) (when (and ,@(mapcar (lambda (dfa) `(not (dfa-alive ,dfa))) others)) (return-from guess-body ,value)) (dfa-next ,dfa ,ch))) ;; result (iso-2022-jp () :jis) (euc-jp () :euc-jp) (shiftjis () :sjis) (utf-8 () :utf-8)) (block guess-body (let* ((eucj (dfa-init +eucj-st+ +eucj-ar+)) (sjis (dfa-init +sjis-st+ +sjis-ar+)) (utf8 (dfa-init +utf8-st+ +utf8-ar+)) (top nil)) (declare (dynamic-extent eucj sjis utf8 top)) (loop for c of-type fixnum across buffer for i of-type fixnum from 0 do (when (and (= (the fixnum c) (the fixnum #x1b)) (< i len)) (let ((c (aref buffer (the fixnum (1+ i))))) (when (or (= (the fixnum c) (the fixnum #x24)) ; $ (= (the fixnum c) (the fixnum #x28))) ; ( (return-from guess-body (iso-2022-jp))))) (process-dfa eucj c (euc-jp) sjis utf8) (process-dfa sjis c (shiftjis) eucj utf8) (process-dfa utf8 c (utf-8) sjis eucj) (when (and (not (dfa-alive eucj)) (not (dfa-alive sjis)) (not (dfa-alive utf8))) (return nil))) ;; pick highest score (when (dfa-alive eucj) (setf top eucj)) (when (dfa-alive utf8) (if top (when (<= (the double-float (score top)) (the double-float (score utf8))) (setf top utf8)) (setf top utf8))) (when (dfa-alive sjis) (if top (when (< (the double-float (score top)) (the double-float (score sjis))) (setf top sjis)) (setf top sjis))) (cond ((eq top eucj) (euc-jp)) ((eq top utf8) (utf-8)) ((eq top sjis) (shiftjis)) (t nil))))))
まとめ
ここで作成したライブラリ関数をつかって、Lispの外の世界とやりとりする場合にはバイト列を使うのが常套手段である。 Common Lispではストリームを開く際に、external-formatに日本語のようなマルチバイトエンコーディングを指定すると :element-type が 'characterとなり、バイト単位の I/O が不可能になってしまう実装が多い。したがって、画像データやインデック情報など文字列以外の情報と 文字列を同じストリームに入出力したい場合には external-format 指定を使うのではなく、:element-type '(unsigned-byte 8) を指定する。 そして、入力は一旦バイト列のバッファにデータを取り込み、decode をつかって Lisp文字列に変換し、 出力の際には encode を使って文字列をバイト列に変換してから書くことになる。
CL-USER> (with-open-file (s "sample.txt" :direction :output :if-exists :supersede :element-type '(unsigned-byte 8)) (write-sequence (jp:encode "日本語ヘッダ" (jp:make-encoding :shiftjis)) s) (write-sequence #(1 2 3 4 5 6 7 8 9 10) s))
このようなやり方は、ポータブルではあるが、特にストリームがバッファリングされているような場合にはストリームのバッファと、Lispレベル バイト列-文字列変換用のバッファが存在することになりメモリ効率が良くない。このような非効率さを避けるためには、 ストリームレベルで (unsigned-byte 8) の配列をバッファとして備え、I/O時に external-formatの処理を実施し、 かつバイト単位のI/Oも可能となるような仕組みが望ましい。
AllegroCLはまさにそのようなストリームSIMPLE-STREAMを実装している。 さきほどの I/O 処理を SIMPLE-STREAM をつかって書くと、次のようになる。
CL-USER> (with-open-file (s "sample.txt" :direction :output :if-exists :supersede :external-format (jp:make-encoding :sjis)) (write-sequence "日本語ヘッダ" s) (write-sequence #(1 2 3 4 5 6 7 8 9 10) s))
柔軟性、性能、使い勝手からいって、SIMPLE-STREAMは将来の事実上標準となるだろう。 ECLも将来的に実装する意向を表明していし、他の処理系でもいずれ利用可能になるはずだ。
[これだとAllegroCLのユーザー以外は悲しくなるような気もする…]