2017年08月04日

文字と文字列、UTF-8

これら一連の記事は、numb-lambda インタープリタから始まる連載企画になっています。

今回は文字と文字列、そして UTF-8 を取り上げます。

numb-lambda は Java で書いてあります。したがって、文字、文字列に関しては UNICODE サポートがされています。これから、中間コードコンパイラを書いていくわけですが、実行環境(仮想マシン)は、C で書くことを想定しています。実行環境は REPL を備えています。そうすると、文字列の操作(走査)が必要になってきます。しかし、Java の機能は使えません。Java で書いてある現行の numb-lambda も使えません。これから、書く新しい numb-lambda は、Java の実行環境を必要としない代わりに、Java の機能は使えなくなってしまいます。*1 つまり、UNICODE を numb-lambda 自身でハンドリングしなければならないのです。新しい処理系では入出力にUTF-8のみをサポートします。

早速、numb-lambda を起動して次の例を試してみてください。

> #\南
#\南
> #\無
#\無
> #\λ
#\λ
> (char->integer #\λ)
955
> (integer->char 955)
#\λ
> (char=? #\λ #\λ)
#t
> (char=? #\南 #\無)
#f
> (char>? #\南 #\無)
#f
> (char->integer #\南)
21335
> (char->integer #\無)
28961
> (char->integer #\space)
32
> (char->integer #\newline)
10


文字リテラルは、#\ に続けて一文字を書きます。全角文字や半角文字の区別はありません。その他、#\space空白を、#\newline改行を表します。char->integer 関数は文字に対する文字コードを返す関数です。反対に integer->char 関数は文字コードに対応する文字を返します。その他、前回(オブジェクト指向?)、で紹介しました述語、char=?、char>? の利用例を示しています。

文字コードは UNICODE となっているので基数を16に変換する関数を書いてみましょう。

(define change-radix
  (lambda (x radix)
    (let loop ((x x)(acc '()))
      (if (zero? x)
        (if (null? acc) (cons 0 acc) acc)
          (loop (quotient x radix)
                (cons (remainder x radix) acc))))))

使い方は以下のようにします。

> (change-radix (char->integer #\λ) 16)
(3 11 11)


少し読み難いですか。それでは関数をもういくつか書きます。

(define list->string
  (lambda (lst tab)
    (let ((n (length lst)))
      (let ((s (make-string n #\0)))
        (let loop ((lst lst)(i 0))
          (if (null? lst)
            s
            (begin
              (string-set! s i (string-ref tab (car lst)))
              (loop (cdr lst) (+ i 1)))))))))

(define hex-list->string
  (lambda (lst m)
    (let ((s (string-append
               "00000000"
               (list->string lst "0123456789abcdef"))))
      (string-append
       "#x"
       (let ((n (string-length s)))
         (substring s (- n m) n))))))

実行してみましょう。

> (hex-list->string (change-radix (char->integer #\λ) 16) 8)
"#x000003bb"
> (begin
    (display (hex-list->string
               (change-radix (char->integer #\λ) 16) 8))
    (newline))
#x000003bb


ここで、#x000003bb は、16進数のリテラルの書き方です。
以上のように関数を組み合わせることによって、汎用から専用にデータを加工していくことができます。これが、関数型プログラミングの手法です。ただし、若干の副作用は用いています。string-set! などが、その代表です。このとおり、Scheme の文字列は mutable (書き変え可能)なのです。

新しく出て来た式を一通り見直してみましょう。

(zero? x) x0 かどうかを判定し、#t#f を返します。

(quotient x radix)xradix で除算します。小数点以下は切り捨てられます。

(remainder x radix) x が被除数、radix が除数であるときの剰余を返します。

(length lst) リスト lst の長さを返します。

(make-string n #\0) n 文字分の文字列を作ります。#\0 はオプションで、この文字で文字列全体を埋めます。

(string-set! s i c) 文字列 si 番目(i=0..n-1; n は文字列の長さ)の文字を c に書き変えます。

(string-ref s i) 文字列 の i 番目(i=0..n-1 ; n は文字列の長さ)の文字を返します。

(string-append s t u v w...) 文字列 s, t, u, v, w... を連結した文字列を返します。文字列は新しく作られたもので、s, t, u, v, w...破壊されません。

(string-length s) 文字列 s の文字数を返します。

(substring s m n) 文字列 sm 番目から n 番目までの部分文字列を返します。文字列全部は、(substring s 0 (string-length s)) に相当します。つまり、mn も 0 から数えます。

ここで、change-radix について残念な問題があります。

> (change-radix #xffffffff 16)
(-1)
> (change-radix #x80000000 16)
(-8 0 0 0 0 0 0 0)


numb-lambda において整数は、Java の int 型を用いています。これは、32ビットの符号付き整数(2の補数表現)です。したがって、MSB が立っていると負数となり、結果のリストも期待したものになりません。これを list->string に入力するとエラーとなってしまいます。change-radix の第一引数は、0~#x7fffffff までであれば期待通り機能します。

そこで、numb-lambda では、Scheme を拡張しビット毎の演算とシフトができるような関数を追加しています。以下は整数に対して適用可能な関数です。*2

bits-or ビット毎の論理和
bits-and ビット毎の論理積
bits-xor ビット毎の排他的論理和
bits-not ビットの反転
bits-shift-left 左シフト
bits-shift-right 右シフト
bits-shift-right 算術右シフト

change-radix ほど、汎用的なものは実現できませんが、2の累乗を基数とする数にならば対応可能です。今回は 16 を基数とする関数だけ書きましょう。

(define dec->hex-list
  (lambda (x)
    (let loop ((x x) (acc '()))
      (if (zero? x)
        (if (null? acc) (cons 0 acc) acc)
        (loop (bits-shift-right x 4)
              (cons (bits-and x #xf) acc))))))

動作させるとこのようになります。

> (hex-list->string
    (dec->hex-list (char->integer #\λ)) 8)
"#x000003bb"
> (begin
    (display (hex-list->string
               (dec->hex-list (char->integer #\λ) 16) 8))
    (newline))
#x000003bb


それでは、Wikipedia を参照しながら UNICODE 文字を UTF-8 にエンコードしてみます。まずは、基本多言語面だけ動作すれば良いでしょう。#\λ の文字コードは、#x000003bb でしたから、UTF-8 では 2 バイトになります。また、#\南#\無 は各 3 バイトです。

(define integer->utf-8
  (lambda (c)
    (cond
     ((and (>= c 0) (<= c #x7f)) (list c))
     ((and (>= c #x80) (<= c #x7ff))
      (list (bits-or #xc0 (bits-shift-right c 6))
        (bits-or #x80 (bits-and c #x3f))))
     ((and (>= c #x800) (<= c #xffff))
      (list (bits-or #xe0 (bits-shift-right c 12))
        (bits-or #x80 (bits-and
                         (bits-shift-right c 6) #x3f))
        (bits-or #x80 (bits-and c #x3f))))
     (else '()))))

(define integer->hex-string
  (lambda (c m)
    (hex-list->string (dec->hex-list c) m)))

> (map (lambda (x) (integer->hex-string x 2))
       (integer->utf-8 (char->integer #\南)))
("#xe5" "#x8d" "#x97")
> (map (lambda (x) (integer->hex-string x 2))
       (integer->utf-8 (char->integer #\無)))
("#xe7" "#x84" "#xa1")
> (map (lambda (x) (integer->hex-string x 2))
       (integer->utf-8 (char->integer #\λ)))
("#xce" "#xbb")


そして、これは、文字列中の各文字をUTF-8のリストにして、さらに、それを集めてリストにする関数。

(define string->utf-8-list
  (lambda (s)
    (let ((n (string-length s)))
      (let loop ((i 0)(acc '()))
        (if (>= i n)
            (reverse acc)
            (loop (+ i 1)
              (cons (map
                      (lambda (x)
                        (integer->hex-string x 2))
                      (integer->utf-8
                        (char->integer
                          (string-ref s i)))) acc)))))))

次のようにして使います。

> (string->utf-8-list "南無λ")
(("#xe5" "#x8d" "#x97") ("#xe7" "#x84" "#xa1") ("#xce" "#xbb"))


ここで登場する map 関数 *3 は次のようにして使用します。

> (map sqrt '(0 1 2 3 4 5 6 7 8))
(0.0 1.0 1.4142135623730951 1.7320508075688772 2.0 2.23606797749979 2.449489742783178 2.6457513110645907 2.8284271247461903)
> (map (lambda (x) (expt 2 x)) '(0 1 2 3 4 5 6 7 8))
(1.0 2.0 4.0 8.0 16.0 32.0 64.0 128.0 256.0)

つまり、最初の引数で与えた関数を次の引数として与えたリストの要素ひとつひとつに適用してリストを生成します。

今回は以上です。次回は integer->utf-8 を 1 文字が 6 バイトになるケースまでサポートします。これにより、U+0000 .. U+7FFFFFFF のコードを全て UTF-8 に変換できるようになります。また、この逆変換を行う関数を書きます。余裕があれば、numb-lambda を拡張して、以上を入出力可能にします。

*1 Java 版の新しい numb-lambda の実装も検討しています。完成した暁には晴れて Java 版 numb-lambda の全ソースが公開できます。

*2 通常の算術演算を組み合せても同様の関数を書くことが出来ると思われますが、それでは低速過ぎます。

*3 今のところ実装が不十分で仕様を満たしていません。