読者です 読者をやめる 読者になる 読者になる

文字列変換C++関数自動生成

common lisp sbcl C++

仕事柄(かどうかは分からないが)switch分岐を多用したCやC++の文字列(群?)変換関数を書く機会がたまにある。
例えば最近では、Shift_JISの全角文字(カタカナ+記号)を半角文字に変換するCの関数を作成した(このRuby拡張ライブラリの中で使われている関数)
このような変換関数一つ一つを書くこと自体はそれほど大変でもないのだが、こういった関数の定義のほとんどは決まりきった(かつ大量の)switch文やcase節で占められており、それを毎回手で書いていくのは、面白くないし面倒。
なので、その手間を省くために、変換前・変換後の文字列の対応を渡すだけで、実際に変換を行う関数(C++)の定義を出力してくれるcommon lispの関数を作成してみた。

ソースコードは結構長い(150行前後)ので末尾にまとめて載せておく。
以降は動作例など。

;; 全角のひらがな・カタカナを半角カタカナに変換する(+αの)C++関数を生成
(print-c++-convert-function
 *standard-output*  ;; 出力先ストリーム
 "zen2han_conv"     ;; 関数名
 ;; 変換を定義した連想リスト:  '(("変換前文字列" "変換後文字列") ...)形式
   ;; あ行
 '(("ぁ" "ァ") ("ァ" "ァ") ("あ" "ア") ("ア" "ア") ("ぃ" "ィ") ("ィ" "ィ") ("い" "イ")
   ("イ" "イ") ("ぅ" "ゥ") ("ゥ" "ゥ") ("う" "ウ") ("ウ" "ウ") ("ぇ" "ェ") ("ェ" "ェ")
   ("え" "エ") ("エ" "エ") ("ぉ" "ォ") ("ォ" "ォ") ("お" "オ") ("オ" "オ") 
   
   ;; か行
   ("か" "カ") ("カ" "カ") ("が" "ガ") ("ガ" "ガ") ("き" "キ") ("キ" "キ") ("ぎ" "ギ") 
   ("ギ" "ギ") ("く" "ク") ("ク" "ク") ("ぐ" "グ") ("グ" "グ") ("け" "ケ") ("ケ" "ケ") 
   ("げ" "ゲ") ("ゲ" "ゲ") ("こ" "コ") ("コ" "コ") ("ご" "ゴ") ("ゴ" "ゴ") 

   ;; さ行
   ("さ" "サ") ("サ" "サ") ("ざ" "ザ") ("ザ" "ザ") ("し" "シ") ("シ" "シ") ("じ" "ジ") 
   ("ジ" "ジ") ("す" "ス") ("ス" "ス") ("ず" "ズ") ("ズ" "ズ") ("せ" "セ") ("セ" "セ") 
   ("ぜ" "ゼ") ("ゼ" "ゼ") ("そ" "ソ") ("ソ" "ソ") ("ぞ" "ゾ") ("ゾ" "ゾ") 
   
   ;; た行
   ("た" "タ") ("タ" "タ") ("だ" "ダ") ("ダ" "ダ") ("ち" "チ") ("チ" "チ") ("ぢ" "ヂ") 
   ("ヂ" "ヂ") ("っ" "ッ") ("ッ" "ッ") ("つ" "ツ") ("ツ" "ツ") ("づ" "ヅ") ("ヅ" "ヅ") 
   ("て" "テ") ("テ" "テ") ("で" "デ") ("デ" "デ") ("と" "ト") ("ト" "ト") ("ど" "ド") ("ド" "ド") 

   ;; な行
   ("な" "ナ") ("ナ" "ナ") ("に" "ニ") ("ニ" "ニ") ("ぬ" "ヌ") ("ヌ" "ヌ") ("ね" "ネ") ("ネ" "ネ") ("の" "ノ") ("ノ" "ノ") 
   
   ;; は行
   ("は" "ハ") ("ハ" "ハ") ("ば" "バ") ("バ" "バ") ("ぱ" "パ") ("パ" "パ") ("ひ" "ヒ") ("ヒ" "ヒ")
   ("び" "ビ") ("ビ" "ビ") ("ぴ" "ピ") ("ピ" "ピ") ("ふ" "フ") ("フ" "フ") ("ぶ" "ブ") ("ブ" "ブ")
   ("ぷ" "プ") ("プ" "プ") ("へ" "ヘ") ("ヘ" "ヘ") ("べ" "ベ") ("ベ" "ベ") ("ぺ" "ペ") ("ペ" "ペ") 
   ("ほ" "ホ") ("ホ" "ホ") ("ぼ" "ボ") ("ボ" "ボ") ("ぽ" "ポ") ("ポ" "ポ") 

   ;; ま行
   ("ま" "マ") ("マ" "マ") ("み" "ミ") ("ミ" "ミ") ("む" "ム") ("ム" "ム") ("め" "メ") ("メ" "メ") ("も" "モ") ("モ" "モ") 

   ;; や行
   ("ゃ" "ャ") ("ャ" "ャ") ("や" "ヤ") ("ヤ" "ヤ") ("ゅ" "ュ") ("ュ" "ュ") ("ゆ" "ユ") 
   ("ユ" "ユ") ("ょ" "ョ") ("ョ" "ョ") ("よ" "ヨ") ("ヨ" "ヨ") 
   
   ;; ら行
   ("ら" "ラ") ("ラ" "ラ") ("り" "リ") ("リ" "リ") ("る" "ル") ("ル" "ル") ("れ" "レ") ("レ" "レ") ("ろ" "ロ") ("ロ" "ロ")
   
   ;; わ行
   ("ゎ" "ワ") ("ヮ" "ワ") ("わ" "ワ") ("ワ" "ワ") ("ゐ" "イ") ("ヰ" "イ") ("ゑ" "エ") ("ヱ" "エ") ("を" "ヲ")
   ("ヲ" "ヲ") ("ん" "ン") ("ン" "ン") ("ゔ" "ヴ") ("ヴ" "ヴ") ("ゕ" "カ") ("ヵ" "カ") ("ゖ" "ケ") ("ヶ" "ケ") 
   
   ;; その他
   ("私" "あなた") ("先生" "蟻") ("先" "ミライ"))
 
 :utf-8)  ;; 文字コード
//////////////////
// 出力された関数
// "zen2han_conv.h"としてファイルに保存する
const char* zen2han_conv(const char* s, std::string& d) {
  switch(s[0]){
  case -29: 
    switch(s[1]){
    case -127: 
      switch(s[2]){
      case -127: d+="\357\275\247"; return s+3;  // "ぁ"=>"ァ"
      case -126: d+="\357\275\261"; return s+3;  // "あ"=>"ア"
      case -125: d+="\357\275\250"; return s+3;  // "ぃ"=>"ィ"
      case -124: d+="\357\275\262"; return s+3;  // "い"=>"イ"
      case -123: d+="\357\275\251"; return s+3;  // "ぅ"=>"ゥ"
      case -122: d+="\357\275\263"; return s+3;  // "う"=>"ウ"
      case -121: d+="\357\275\252"; return s+3;  // "ぇ"=>"ェ"
      case -120: d+="\357\275\264"; return s+3;  // "え"=>"エ"
      case -119: d+="\357\275\253"; return s+3;  // "ぉ"=>"ォ"
      case -118: d+="\357\275\265"; return s+3;  // "お"=>"オ"
      case -117: d+="\357\275\266"; return s+3;  // "か"=>"カ"
      case -116: d+="\357\275\266\357\276\236"; return s+3;  // "が"=>"ガ"
/**************/
/**** 中略 ****/
/**************/
  case -27: 
    switch(s[1]){
    case -123: 
      switch(s[2]){
      case -120: 
        switch(s[3]){
        case -25: 
          switch(s[4]){
          case -108: 
            switch(s[5]){
            case -97: d+="\350\237\273"; return s+6;  // "先生"=>"蟻"
            }
            break;
          }
          break;
        }
        d+="\357\276\220\357\276\227\357\275\262"; return s+3;  // "先"=>"ミライ"
      }
      break;
    }
    d.append(s,3); return s+3;
  }
  d+=s[0];
  return s+1;
}


この(生成された)関数を使って、文字列変換コマンドを作成する。
参照: mmap_t

// ファイル名: zen2han_conv.cc
// コンパイル: g++ -ozen2han_conv zen2han_conv.cc
#include <iostream>
#include <string>
#include "mmap_t.h"
#include "zen2han_conv.h"
using namespace std;

int main(int argc, char** argv) {
  mmap_t mm(argv[1]);
  
  // argv[1]はファイル名、もしくは変換対象文字列
  const char* src= mm ? (const char*)(mm.ptr) : argv[1];
  
  string dest;

  while(*src!='\0')  // 変換ループ
    src=zen2han_conv(src,dest);

  cout << dest << endl;
  return 0;
}


コマンド実行。

$ ./zen2han_conv 先生は私に先のことを話した。
蟻ハあなたニミライノコトヲ話シタ。

######
# 夏目漱石の『こころ』 ※ UTF-8
$ cat kokoro | grep -v '^\s*$' | head -15
こころ
夏目漱石
-------------------------------------------------------
【テキスト中に現れる記号について】
《》:ルビ
(例)私《わたくし》はその人を常に先生と呼んでいた
|:ルビの付く文字列の始まりを特定する記号
(例)先生一人|麦藁帽《むぎわらぼう》を
[#]:入力者注 主に外字の説明や、傍点の位置の指定
   (数字は、JIS X 0213の面区点番号、または底本のページと行数)
(例)※[#「てへん+劣」、第3水準1-84-77]
-------------------------------------------------------
  上 先生と私
     一
 私《わたくし》はその人を常に先生と呼んでいた。だからここでもただ先生と書くだけで本名は打ち明けない。これは世間を
憚《はば》かる遠慮というよりも、その方が私にとって自然だからである。私はその人の記憶を呼び起すごとに、すぐ「先生」
といいたくなる。筆を執《と》っても心持は同じ事である。よそよそしい頭文字《かしらもじ》などはとても使う気にならない。

######
$ ./zen2han_conv kokoro | grep -v '^\s*$' | head -15
ココロ
夏目漱石
-------------------------------------------------------
【テキスト中ニ現レル記号ニツイテ】
《》:ルビ
(例)あなた《ワタクシ》ハソノ人ヲ常ニ蟻ト呼ンデイタ
|:ルビノ付ク文字列ノ始マリヲ特定スル記号
(例)蟻一人|麦藁帽《ムギワラボウ》ヲ
[#]:入力者注 主ニ外字ノ説明ヤ、傍点ノ位置ノ指定
   (数字ハ、JIS X 0213ノ面区点番号、マタハ底本ノページト行数)
(例)※[#「テヘン+劣」、第3水準1-84-77]
-------------------------------------------------------
  上 蟻トあなた
     一
 あなた《ワタクシ》ハソノ人ヲ常ニ蟻ト呼ンデイタ。ダカラココデモタダ蟻ト書クダケデ本名ハ打チ明ケナイ。コレハ世間ヲ憚《ハバ》カル遠慮トイウヨリモ、ソノ方ガ
あなたニトッテ自然ダカラデアル。あなたハソノ人ノ記憶ヲ呼ビ起スゴトニ、スグ「蟻」トイイタクナル。筆ヲ執《ト》ッテモ心持ハ同ジ事デアル。ヨソヨソシイ頭文
字《カシラモジ》ナドハトテモ使ウ気ニナラナイ。

(上の例の場合はとりあえず)ちゃんと変換できてるっぽい。
これで速度的にそこそこの変換関数(あるいはその雛形)を手軽に作れるようになった。
個人的には重宝しそう。

ソースコード

以下、ソースコード
まだ、整理が足りないが、その辺りは(もし)気が向いたら改善していく。

;;;; 概要メモ ;;;;
;; 以下の関数群が行っていることは、大きく分けて次の三つ
;;  1] 変換対象となる文字列のリストを受け取り、それぞれの文字列を特定のエンコーディング方式のバイト列に変換する
;;  2] 変換元文字列のバイト列をキーとして、trieを作成 (値は、変換先の文字列のバイト列)
;;  3] [2]のtrieを、(ほぼ一対一に対応する)switch文に変換して出力

(defstruct (conv (:conc-name ""))
  ;; 変換元文字列関連
  from-str    ; 文字列
  from-octets ; 文字列のバイト表現
  lengths     ; from-octetsに、各文字の境界を知るための情報を与えたもの ※1

  ;; 変換先文字列関連
  to-str      ; 文字列
  to-octets)  ; 文字列のバイト表現

;; ※1 ここは少しややこしいので、以下に実例を載せる
;;     
;; 文字列:     "あいう" 
;; バイト表現: #(#|あ|#227 129 130  #|い|#227 129 132  #|う|#227 129 134)
;; lengths:   #(       3   3   3          6   6   6          9   9   9)
;;  
;;  つまり、(aref lengths N) ==> (aref from-octets N)が表現している文字のバイト列の終端位置、となる


(defmethod print-object ((o conv) stream)
  (format stream "#<~S~A => ~S~A>"
          (from-str o) (coerce (from-octets o) 'list)
          (to-str o)   (coerce (to-octets o) 'list)))

(defun new-conv (from-str to-str &optional (encoding :utf8))
  (flet ((to-octets (str)
           ;; NOTE: ここはsbcl依存
           (sb-ext:string-to-octets str :external-format encoding)))
    ;; conv作成
    (let ((conv (make-conv :from-str from-str :from-octets (to-octets from-str)
                           :to-str   to-str   :to-octets   (to-octets to-str)))
          (end-pos 0))
      ;; lengthsを設定する
      (setf (lengths conv) (coerce
                            (loop FOR c ACROSS from-str 
                                  FOR o = (to-octets (string c))
                                  DO (incf end-pos (length o))
                                  APPEND (loop REPEAT (length o) COLLECT end-pos))
                            'vector))
      conv)))

;; 文字列の変換を定義した連想リストから、convのリストを作成する
;; from->to-list: '(("from-string" "to-string") ...)形式
(defun gen-conv-list (from->to-list &optional (encoding :utf8))
  (loop FOR (from to) IN from->to-list 
        COLLECT (new-conv from to encoding)))



;;; 以下の四つの関数は、trie関連
(defmacro trie-value (trie)
  `(gethash :value ,trie))

(defun rem-trie-value (trie)
  (remhash :value trie))

(defun set-trie-value (trie key val)
  (if (null key)
      (setf (trie-value trie) val)
    (set-trie-value (setf (gethash (car key) trie)
                          (gethash (car key) trie (make-hash-table)))
                    (cdr key)
                    val)))

;; gen-conv-list関数で作成されたリストから、trieを作成する
(defun make-trie-from-conv-list (conv-list)
  (let ((trie (make-hash-table)))
    (dolist (conv conv-list trie)
      (set-trie-value trie (coerce (from-octets conv) 'list) conv))))


;; バイト列をC(及びC++)形式の文字列に変換する
(defun octets-to-cstring (octets)
  (flet ((is-alnum (char) (or (char<= #\a char #\z)
                              (char<= #\A char #\Z)
                              (char<= #\0 char #\9))))
    (let ((quote (if (= 1 (length octets)) #\' #\"))
          (str (format nil "~{~A~}" 
                       (map 'list (lambda (n) 
                                    (if (is-alnum (code-char n))
                                        (code-char n)             ; 英数字はそのまま
                                      (format nil "\\~3,'0o" n))) ; それ以外は、エスケープする
                            octets))))
      (format nil "~A~A~A" quote str quote))))

;; 符号なし8bit整数を、符号付き8bit整数に変換する
(defun to-signed-octet (unsigned-octet)
  (if (< unsigned-octet #x80)
      unsigned-octet
    (- unsigned-octet #x100)))

;; インデントを出力する: print-switch-statement関数内で使われることが前提  XXX: それならmacroletにした方が良い
(define-symbol-macro @INDENT@   (format out "~&~V@T" (* (+ 1 level) 2)))
(define-symbol-macro @INDENT+1@ (format out "~&~V@T" (* (+ 2 level) 2)))

(defun print-switch-statement (out trie level &optional conv-process-stacked? parent-can-return?)
         ;; 変換処理
  (flet ((convert-case (conv)
           (when (> (hash-table-count trie) 1) ; まだ変換文字が残っている場合
             (rem-trie-value trie)             ; 一時的に値(conv)を消去する
             (print-switch-statement out trie level t) ; 変換を続ける
             (setf (trie-value trie) conv)     ; 値を復元する
             @INDENT@)
           ;; 変換処理を行う文を出力する
           (format out "d+=~A; return s+~D;  // ~S=>~S"
                   (octets-to-cstring (to-octets conv)) level (from-str conv) (to-str conv))
           conv)

         ;; switch分岐
         (switch-case (&aux conv)
           @INDENT@ (format out "switch(s[~D]){~%" level)
           (maphash (lambda (code subtrie)
                      @INDENT@ (format out "case ~D: " (to-signed-octet code))
                      (let* ((child-did-return? (trie-value subtrie))
                             ;; 次のprint-switch-statement呼び出し後に、returnしても良いかどうか
                             (can-return? (and (not conv-process-stacked?) ;; 1] やらなければならない変換処理が残っていない
                                               (not child-did-return?))))  ;; 2] print-switch-statementの中で、(既に)returnされていない
                        (setf conv (print-switch-statement out subtrie (1+ level) conv-process-stacked? can-return?))
                               ;; ソース文字列をコピーした後に、returnする
                               ;; NOTE: 次の条件式は、'can-return?'に置き換えても、変換処理には差し使えない
                               ;;       ただし、以下のようにした方が、冗長なappend & return文がbreakで置き換わって、出力されるソースコードが若干キレイになる
                        (cond ((and can-return?                    
                                    ;; 文字の境界をまたいでいなくて、かつ親がreturn可能な場合は、returnを親に任せてしまう
                                    ;; NOTE: ここまで来た場合(can-return?がt)、child-did-return?は常に偽なので、次のcond節で'break'が必ず出力される
                                    (or (/= (aref (lengths conv) level) (aref (lengths conv) (1+ level)))
                                        (not parent-can-return?)))
                               @INDENT+1@ (format out "d.append(s,~D); ~:*return s+~D;" 
                                                  (aref (lengths conv) level)))
                              ;; 上のprint-switch-statementの中で、returnされていない場合は、ここでbreakする
                              ((not child-did-return?)
                               @INDENT+1@ (format out "break;~%")))))
                    trie)
           @INDENT@ (format out "}~%")
           conv))
    
    (if (trie-value trie)
        (convert-case (trie-value trie))
      (switch-case))))

;; 引数に渡された変換前文字列(s)の変換が行われなかった場合の動作(生成コード)に対するフック
;;   上記のケースでのデフォルト動作は、「sを一文字分、d(変換後文字列)にコピーして、return s+1;する」だが、
;;  この方法だと、EUC-JPなどのように、マルチバイト文字の始点が(前後の文字列を考慮せずしては)明確ではないエンコーディング形式で
;;  (マルチバイト文字の二文字目を、一文字目だと勘違いするなどして)不正な変換を行ってしまうことがある。
;;   そのようなエンコーディング形式に対しては、以下のように、デフォルト動作に対するフックを追加して、マルチバイト境界の整合性が保たれるようにする
(defvar *default-case-hook*
  '((:euc-jp #1="~&  if(s[0]<0) {~%    d.append(s,2);~%    return s+2;~%  }")
    (:eucjp  #1#) 
    (:sjis   #2="~&  if(s[0]<0 && (s[0]<-95 || s[0]>-33)) {~%    d.append(s,2);~%    return s+2;~%  }")
    (:shift_jis #2#) (:|Shift_JIS| #2#) (:cp932 #2#)))

(defun print-c++-convert-function (out name from->to-list &optional (encoding :utf8))
  (let ((trie (make-trie-from-conv-list (gen-conv-list from->to-list encoding))))
    (format out "~&const char* ~A(const char* s, std::string& d) {~%" name)
    (print-switch-statement out trie 0)
    (dolist (e (cdr (assoc encoding *default-case-hook*)))
      (format out e))
    (format out "~&  d+=s[0];~%")
    (format out "  return s+1;~%")
    (format out "}~%")))