ホーム » ユニコードの全角と半角を判定しよう

ユニコードの全角と半角を判定しよう


この記事は怖いくらいにわかりやすいユニコードの解説の続編であるし、Julia言語で入門するプログラミング(その13)の一部でもある。しかし、ユニコードの知識のある方はこのページ単体でも読み進められるようにしている。

さて、今から我々が取り組みたいのは、与えられた文字が全角文字なのか半角文字なのかを判定するというものである。

そのために利用するのが、

https://www.unicode.org/Public/UCD/latest/ucd/EastAsianWidth.txt

の情報である。これは、下記のサイトから辿り着くことができる。

Unicode® Standard Annex #11 EAST ASIAN WIDTH

EastAsianWidth.txtを解読しよう

EastAsianWidth.txtの読み方を知ろう。ファイルの一番上の方の「#」記号から始まる部分は説明書きなので飛ばす。本命は、以下のような記述の部分である。

0000..001F;N     # Cc    [32] <control-0000>..<control-001F>
0020;Na          # Zs         SPACE
0021..0023;Na    # Po     [3] EXCLAMATION MARK..NUMBER SIGN
  • まず、各行の先頭からセミコロンまでが、「ユニコードのコードポイント、または、コードポイントの範囲」を表している。(コードポイントがわからない人は怖いくらいにわかりやすいユニコードの解説を読むこと!)
  • 次に、セミコロンの直後の記号「N」や「Na」が「East_Asian_Width特性」と呼ばれるもので、これが文字幅を表現する記号である。
  • East_Asian_Width特性の後の「#」記号以降はコメントである。

全行例外なくこうなっている。East_Asian_Width特性の各記号の意味については、Wikipediaから引用しよう。

F(Fullwidth; 全角)- 互換分解特性 を持つ互換文字。文字の名前に “FULLWIDTH” を含む。いわゆる全角英数など。
H(Halfwidth; 半角)- 互換分解特性 を持つ互換文字。文字の名前に “HALFWIDTH” を含む。いわゆる半角カナなど。
W(Wide; 広)- 上記以外の文字で、従来文字コードではいわゆる全角であったもの。漢字や仮名文字、東アジアの組版にしか使われない記述記号(たとえば句読点)など。
Na(Narrow; 狭)- 上記以外の文字で、従来文字コードでは対応するいわゆる全角の文字が存在したもの。いわゆる半角英数など。
A(Ambiguous; 曖昧)- 文脈によって文字幅が異なる文字。東アジアの組版とそれ以外の組版の両方に出現し、東アジアの従来文字コードではいわゆる全角として扱われることがある。ギリシア文字やキリル文字など。
N(Neutral; 中立)- 上記のいずれにも属さない文字。東アジアの組版には通常出現せず、全角でも半角でもない。アラビア文字など。

東アジアの文字幅

これら各特性に対して、日本語でどのように扱うべきか、という指針が次のようになっている。

特性値Na(狭)またはN(中立)を持つ文字は、半角の文字 (halfwidth) として扱う。
特性値W(広)またはF(全角)を持つ文字は、全角の文字 (fullwidth) として扱う。
特性値H(半角)を持つ文字は、半角の文字 (halfwidth) として扱う。
特性値A(曖昧)を持つ文字は、全角の文字 (fullwidth) として扱う。

東アジアの文字幅

ここまできたら簡単だ。

テキストファイルの情報をもとに、コードポイントからEast_Asian_Width特性値を取得し、その特性値から半角か全角かを判定するようにしてあげればよい。

コードポイントからEast_Asian_Width特性値を取得

まずはテキストファイルの情報をもとに、コードポイントからEast_Asian_Width特性値を取得する対応をしよう。いきなりテキストファイルから取得するのはハードルが高いので、まずは、下記のような1行を対象にしよう。

0020;Na          # Zs         SPACE

与えられたコードポイントが、このコードポイントと一致しているかどうかを判定し、一致していればEast_Asian_Width特性値を取得する関数を作ってみよう。

まずはテストを作ろう。get_east_asian_width関数は、「取得に成功したかどうかの論理値」と、「取得できたeast_asian_width特性」のタプルを返すとしよう。

using Test
@testset "単一コードポイント" begin
    s = "0020;Na          # Zs         SPACE"
    @testset "正常取得" begin
        @test get_east_asian_width("0020", s) == (true, "Na")
    end
    @testset "境界値検査" begin 
        @test get_east_asian_width("001F", s) == (false, nothing)
        @test get_east_asian_width("0021", s) == (false, nothing)
    end
end

このテストを満たすget_east_asian_widthを作ることができれば第一ステップ完成だ。

正規表現

次に我々がすべきは、"0020;Na # Zs SPACE"という文字列から、コードポイントである「0020」とeast_asian_width特性である「Na」を抽出すると言う問題である。まあ、簡単と言えば簡単だ。文字列をセミコロンと#で分割して、空白文字を削ってやれば良い。実際、仕事ならそうすべきだ。

が、しかしここは職場ではないのだし(職場でこんなふざけたサイトを読んでいる人はいないよね?)、どうせなので、ここは「正規表現」という手法を学ぼう。

正規表現とは、文字列が特定のパターンに一致しているかどうかを知りたいときに便利なツールである。例えば、携帯電話番号として与えられた文字列が、「ハイフン区切りの数字3桁-3桁-4桁」というパターンに一致しているかを調べたい、というようなときに使われる。

今回対応したいデータはもっと複雑だ。行の先頭から順に次のようなパターンで表現される文字列である。

  • 行頭
  • 0から9またはAからFの文字4桁
  • セミコロン記号(;)
  • F, H, W, Na, A, Nのいずれか
  • 0個以上の空白文字
  • シャープ記号(#)
  • 0個以上の任意の文字
  • 行末

このようなパターンを簡潔に定義することができるのが正規表現である。どんなものか見てみたほうが早いだろうから、早速紹介しよう。上記のパターンを表現する正規表現は次のようになる。

^[0-9A-F]{4};(F|H|W|Na|A|N) *#.*$

おいおい、待ってくれよ、まるで暗号じゃないか。最初に正規表現を見たときに私はそう思った。正規表現は情報が凝縮されているので、最初にみると圧倒されてしまうのだ。しかし、いくつかのルールを知れば、この記号の羅列が、上で説明したパターンをほぼ書き下しただけのものであるとわかるだろう。順番に解読していこう。

  • ^
    • ^” 記号は行頭を意味する
  • [0-9A-F]{4}
    • “[]”記号は集合を意味する。[0-9]と書くと0から9までの文字のいずれかに一致することを意味する。[0-9A-F]と書くと0から9、AからFまでの文字のいずれかに一致することを意味する。
    • さらに続く{4}は、直前のパターンを4回繰り返すということを意味している。
    • つまり、\[0-9A-F]{4}というのは、「0から9またはAからFの文字4桁」を意味している。
  • ;
    • これは単にセミコロン記号を意味している。
  • (F|H|W|Na|A|N)
    • |“で区切られた、F, H, W, Na, A, Nのいずれかということを意味している。
    • ()はグルーピングのためのもので、どこからどこまでが一塊であるかを明確にするものである。|記号での結合は優先度が低いので、よくグルーピングのために()で囲われる。
    • グルーピングのための()は、”|“記号以外にもよく使われる。
  • *
    • わかりづらいが、空白文字があり、その後に”*“記号が存在する。
    • *“記号は、直前のパターンを0回以上繰り返すということを意味している。
    • つまり、” *“というのは、0回以上の空白文字の繰り返しを意味している。
  • #
    • これは単にシャープ記号を意味している。
  • .*
    • .“記号は任意の1文字を意味している
    • *“記号は、直前のパターンを0回以上繰り返すということを意味している。
    • つまり、”.*“というのは、0回以上の任意文字の繰り返しを意味している。
  • $
    • $“記号は行末を意味している。

正規表現は様々な言語やライブラリでサポートされているが、細部で仕様が異なる場合があるが、ここで紹介したような基本的なパターンはどれも共通なはずだ。JuliaではPCREライブラリというものを使っている。

正規表現は、1文字あたりの情報量が多くてとっつきづらいが、そのぶん簡潔な表記で豊かな表現が可能なのだ。

Juliaでの正規表現

ではJuliaを使って正規表現と文字列をマッチさせてみよう。Juliaでは正規表現を取り扱うための方法が標準で用意されている。次のように、文字列の前にrをつけると、その文字列がただの文字列ではなく、正規表現であることを意味するのだ。

r"^[0-9A-F]{4};(F|H|W|Na|A|N) *#.*$"

ただの文字列ではなく正規表現であることを確認するために、replに入力してみよう。なお、Regexというのは、Regular expression、つまり正規表現の略である。

julia> reg = r"^[0-9A-F]{4};(F|H|W|Na|A|N) *#.*$"
julia> typeof(reg)
Regex

与えられた文字列が正規表現にマッチすることを確かめるだけであれば、occursin関数を使うと良い。

julia> reg = r"^[0-9A-F]{4};(F|H|W|Na|A|N) *#.*$"
julia> occursin(reg, "0020;Na    #Zs hogehoge") #マッチする文字列
true
julia> occursin(reg, "0020;") #マッチしない文字列
false

これでも十分有用である。だが、我々の目的には不十分だ。我々には「文字列が正規表現がマッチしたときには、文字列のどの部分が正規表現のどの部分にマッチしたか知りたい」というチョモランマよりも高い志があるのだ。

このようなときにはmatch関数を使う。match関数は、文字列が正規表現にどのようにマッチしたか、という情報を持つRegexMatch構造体を返却する。

julia> m = match(reg, "0020;Na    #Zs hogehoge")
RegexMatch("0020;Na    #Zs hogehoge", 1="Na")

RegexMatch構造体は色々な情報を持つが、今回我々が欲しいのはcapturesというものだ。capturesは、文字列から正規表現でマッチした部分を前から順に配列として抽出してくれる。

julia> m.captures
1-element Array{Union{Nothing, SubString{String}},1}:
 "Na"

おやおや困った。どう言うわけかNaの部分しかマッチしたパターンとして拾ってくれないようだ。我々はコードポイントである0020の部分も欲しいのだが、どうしたら良いだろうか?

そもそもmatch関数が、どのような基準でcapturesに放り込んでいるかと言うと、()でグルーピングされている部分である。正規表現では、どこからどこまでが、ひとかたまりであるかを明示するために()を使うが、ついでにキャプチャする基準としても()でのグルーピングを使うのだ。

そういうわけで、コードポイントの部分[0-9A-F]{4}にも()をつけるようにしよう。

julia> reg = r"^([0-9A-F]{4});(F|H|W|Na|A|N) *#.*$"
r"^([0-9A-F]{4});(F|H|W|Na|A|N) *#.*$"
julia> m = match(reg, "0020;Na    #Zs hogehoge")
RegexMatch("0020;Na    #Zs hogehoge", 1="0020", 2="Na")
julia> m.captures
2-element Array{Union{Nothing, SubString{String}},1}:
 "0020"
 "Na"

これでめでたく、1行の定義データから、コードポイントの情報とeast_asian_width特性の情報を抽出することができた。

さて、ここまでくればあと一息だ。コードポイントと定義行から、該当のコードポイントのeast_asian_width特性を取得する関数を作ろう。

function get_east_asian_width(コードポイント, 定義行)
    reg = r"^([0-9A-F]{4});(F|H|W|Na|A|N) *#.*$"
    m = match(reg, 定義行)
    if isnothing(m)
        return (false, nothing)
    end

    定義行中コードポイント = m.captures[1]
    if コードポイント != 定義行中コードポイント
        return (false, nothing)
    else
        east_asian_width = m.captures[2] 
        return (true, east_asian_width)
    end
end

範囲指定の定義行への対応

次は003F..0040のように範囲指定でのコードポイントにマッチしているかどうかを判定する関数を作りたい。

0041..005A;Na    # Lu    [26] LATIN CAPITAL LETTER A..LATIN CAPITAL LETTER Z

例によってテストを作っておこう。

@testset "範囲コードポイント" begin
    s = "0041..005A;Na    # Lu    [26] LATIN CAPITAL LETTER A..LATIN CAPITAL LETTER Z"
    @testset "正常取得" begin
        @test get_east_asian_width("0041", s) == (true, "Na")
        @test get_east_asian_width("0059", s) == (true, "Na")
        @test get_east_asian_width("005A", s) == (true, "Na")
    end
    @testset "境界値検査" begin 
        @test get_east_asian_width("0040", s) == (false, nothing)
        @test get_east_asian_width("005B", s) == (false, nothing)
    end
end

先ほどと同じく、正規表現でマッチするようにしよう。マッチさせるための正規表現は、先程のものを少しカスタマイズする。4桁の数値とだけマッチしていたものを、「4桁の数値..4桁の数値」という表記であってもマッチできるようにしている。

#変更前
^([0-9A-F]{4});(F|H|W|Na|A|N) *#.*$
#変更後
^([0-9A-F]{4}|[0-9A-F]{4}..[0-9A-F]{4});(F|H|W|Na|A|N) *#.*$

これでうまくマッチできるはずだ。

julia> reg = r"^([0-9A-F]{4}|[0-9A-F]{4}..[0-9A-F]{4});(F|H|W|Na|A|N) *#.*$"
julia> m = match(reg, "0041..005A;Na    # Lu    [26] LATIN CAPITAL LETTER A..LATIN CAPITAL LETTER Z")
julia> m.captures
2-element Array{Union{Nothing, SubString{String}},1}:
 "0041..005A"
 "Na"

あとは、与えられたコードポイントが0041..005Aの範囲に含まれることを確認する処理を作れば良いのだが・・・、どうやら我々の目の前の道は二手に分かれているようである。

健全でつまらない道

"0041..005A"という文字列を前にして、「ハハーン、r"([0-9A-F]{4})..([0-9A-F]{4})"で文字列をマッチして、左側を開始に、右側を終了にして範囲オブジェクトでも作ってあげればいいんだな」というのが、真っ当な道である。昼間のシャンゼリゼ通りのような、健全で健康的な空間。誰に憚ることもない、日の当たる場所だ。

テストを書くとこんなところだ。

@testset "parse範囲コードポイント" begin
    s = "0041..005A"
    @test parse範囲コードポイント(s) == 0x0041:0x005A
end

コードはこのようになるだろう。

  • まず、replace関数で、"0041..005A"という文字列を、"0x0041..0x005A"という文字列に変換している。
  • その後、正規表現で0x00410x005Aという部分を抽出している。
  • 最後にそれぞれをparse関数でInt型に変換している。(parseというのは日本語で言うと「構文解析」だ。単なる文字の列から意味を抽出するという意味だ。)
function 十六進数prefix付与(範囲コードポイント)
    function prefix付与(s)
        return "0x" * s
    end
    return replace(範囲コードポイント, r"[0-9A-F]{4}" => prefix付与)
end

function parse範囲コードポイント(範囲コードポイント)
    range = 十六進数prefix付与(範囲コードポイント) #"0x0041..0x005A"という文字列に変換
    reg = r"(0x[0-9A-F]{4})..(0x[0-9A-F]{4})"
    m = match(reg, range)
    開始 = parse(Int, m.captures[1])
    終了 = parse(Int, m.captures[2])
    return 開始:終了
end

これでテストは通る。しかし、この道は健全すぎる。なんと言うか、妖しげな魅力に欠けるのだ。第一さっき通った道と同じで退屈ではないかい?

不健全で魅力的な道

"0041..005A"という文字列を見たとき、こう思わなかっただろうか?

「範囲オブジェクト0x0041:0x005Aと似ているなあ」と。

その直感は大切にすべきだ。せっかくファイルの作者が「範囲指定っぽく」作ってくれているのだ。できればそれを「そのまま」使いたい。テキストファイルの文字列として与えられた"0041..005A"を文字列のまま、"0x0041..0x005A"へ、そして、"0x0041:0x005A"へと変換する。そうして、この文字列をそのままJuliaコードとして解釈するのだ。

我々は日々、親の仇のようにキーボードを叩きまくってJuliaのソースコードを量産している。これは言うまでもなく文字列だ。ただし、テキストファイルに「ソースコードとして」記述される文字列であり、Juliaのソースコード中に「文字列として」記述される文字列ではない。これらは通常明確に違うものだ。しかし、このように文字列として記述された文字列を、ソースコードとして取り扱うことのできる機能がJuliaには提供されているのだ。

プログラムの実行時になんらかの方法でソースコードを生成して、それをプログラムの一部として実行する、このような手法を一般的にメタプログラミングと呼ぶ。メタプログラミングは言語によってサポートされる度合いがかなり異なる。言語により得手不得手がはっきりしているのだ。Juliaはメタプログラミングがかなり得意な部類である。

我々が今から踏み入れるのは、このメタプログラミングという道である。先程のシャンゼリゼ通りとは打って変わった、薄暗い路地裏である。小学生以下は立ち入り禁止だ。

最初に断っておくと、メタプログラミングは難しい。普通のソースコードは静的である。すなわち、プログラムの実行中にソースコードは変化しない。一方、メタプログラミングはソースコードを動的に変化させる。プログラムの実行中に文字列を組み立て、それをソースコードとして実行するからだ。目に見えているソースコードと、実際に実行されるコードがかけ離れてしまうと、我々がプログラムについて確信を持って述べることができる事柄はずっと少なくなる。その文字列がプログラム自身が生み出すものであればまだ良いが、外部から与えられるものであればお手上げだ。

ただ、ここまで脅しておいてなんだが、今からやるのは非常に簡単な内容だ。なので身構える必要はない。ただ、通常のプログラミングとは随分違ったことをやろうとしていると言うことをはっきりさせておきたいだけだ。

先程と同様のテストコードを書いておこう。今回作る関数の名前は「eval範囲コードポイント」である。

@testset "eval範囲コードポイント" begin
    s = "0041..005A"
    @test eval範囲コードポイント(s) == 0x0041:0x005A
end

コード本体はこのようになる。

function eval範囲コードポイント(範囲コードポイント)
    range = 十六進数prefix付与(範囲コードポイント) #"0x0041..0x005A"という文字列に変換
    code_str = replace(range, ".." => ":") #"0x0041:0x005A"という文字列に変換
    expr = Meta.parse(code_str) #:(0x0041:0x005A) という抽象構文木に変換
    return eval(expr) #抽象構文木を評価
end

ごちゃごちゃコメントを入れているが、上の2行はまあ良いだろう。文字列を変換しているだけだ。下2行が重要だ。

  • Meta.parseという関数で、文字列を抽象構文木に変換している。
    • 抽象構文木の詳しい説明は別の機会に行うとして、ざっくり言うと実行可能なコードのことである。Meta.parse関数を通すことで、単なる文字列が実行可能なコードに変換されるのだ。
    • 抽象構文木は変数exprに代入する。expressionは式と意味する英語で、その略称である。
    • 抽象構文木は直接組み立てることもできる。:()で囲われたコードが抽象構文木である。[]で囲まれると配列を意味するように、""で囲まれると文字列を意味するように、:()で囲まれると抽象構文木を意味する。中に書かれたコードを外界から隔離しているイメージだ。
    • ちなみにここで出てきているMetaというのは、メタプログラミングのメタである。
  • evalという関数で、抽象構文木を評価している。
    • 抽象構文木を評価するというのは、要するに実行可能なコードを実際に実行するということである。抽象構文木が隔離したプログラムコードを外界に解き放つイメージだ。
    • こうして抽象構文木内部の範囲オブジェクト0x0041:0x005Aが現実にメモリに確保されるのである。
    • ちなみに、evalというのは、英語のevaluateの略である。

要するに、ソースコードを書き上げてからJuliaが実行している、

  1. ファイルに書かれたソースコード(文字列)から実行可能コードを生成
  2. 実際に実行する

というプロセスを、実行時にも小さな単位で行っていると言うことだ。

このようにすることで、プログラムの実行中に読み込んだテキストファイルの情報をあたかもソースコードのように見立てて取り扱うことができたのだ。

さて、Juliaはメタプログラミングが得意と書いたが、このように外部から取り込んだ文字列を、そのままプログラムコードとして解釈すると言うのは極めて異端的である。このようなことは通常すべきではない。悪意を持ったコードが外部から入力・実行される可能性があるためだ。しかし、最も簡単なメタプログラミングの例であるため、まずは紹介することにした。

メタプログラミングは難しいので、あまり乱用すべきではない。普通のコードで実現できるときには、あえてメタプログラミングをすべきではない。しかし慣れてきたらわかるが、メタプログラミングが上手くいくと、とても爽快な気分になるのだ。私は心の健康を保つためにもメタプログラミングに取り組むことをお勧めする。それがストレスの多い現代社会を生き抜いていくためのコツだ。新たな心理療法として提案すべきなのかもしれない。

ここまでをまとめると、次のような関数を作ると、正しくeast_asian_width特性が取得できるようになる。大きく変えた部分としては、単一コードポイントのパターンと範囲コードポイントのパターンで正規表現を分けているところである。正規表現は油断するとすぐに見づらくなってしまうのだ。後半部分が重複しているが、流石に片方だけ直してしまうことはないだろうと許容した。気になるのであれば共通化させても良い。

function get_east_asian_width(コードポイント, 定義行)
    reg単一コードポイント = r"^([0-9A-F]{4});(F|H|W|Na|A|N) *#.*$"
    reg範囲コードポイント = r"^([0-9A-F]{4}..[0-9A-F]{4});(F|H|W|Na|A|N) *#.*$"

    if occursin(reg単一コードポイント, 定義行) 
        m = match(reg単一コードポイント, 定義行)
        定義行中コードポイント = eval範囲コードポイント(m.captures[1])
        if コードポイント == 定義行中コードポイント
            east_asian_width = m.captures[2] 
            return (true, east_asian_width)
        else
            return (false, nothing)
        end
    elseif occursin(reg範囲コードポイント, 定義行)
        m = match(reg範囲コードポイント, 定義行)
        定義行中コードポイント = eval範囲コードポイント(m.captures[1])
        if コードポイント in 定義行中コードポイント
            east_asian_width = m.captures[2] 
            return (true, east_asian_width)
        else
            return (false, nothing)
        end
    else
        return (false, nothing)
    end
end

それにしても醜い関数である。要するに「正規表現にマッチするかの判定」と「マッチした結果の抽出」を、複数の正規表現に対して実行しているだけだ。

そのため、最終的にはメインの判断を行う処理には次のようなコードになってほしい。

function get_east_asian_width(コードポイント, 定義行)
    matchers = [
        EastAsianWidthMatcher_単一コードポイント()
        EastAsianWidthMatcher_範囲コードポイント()
    ]

    for matcher in matchers
        if occursin(matcher, 定義行)
            return east_asian_width特性抽出(matcher, コードポイント, 定義行)
        end
    end
    return (false, nothing)
end

これを実現するために、次のようにしている。

#単一コードポイント
struct EastAsianWidthMatcher_単一コードポイント
    reg
end

function EastAsianWidthMatcher_単一コードポイント()
    EastAsianWidthMatcher_単一コードポイント(r"^([0-9A-F]{4});(F|H|W|Na|A|N) *#.*$")
end

function Base.occursin(matcher::EastAsianWidthMatcher_単一コードポイント, 定義行)
    return occursin(matcher.reg, 定義行)
end

function east_asian_width特性抽出(matcher::EastAsianWidthMatcher_単一コードポイント, コードポイント, 定義行)
    m = match(matcher.reg, 定義行)
    定義行中コードポイント = eval範囲コードポイント(m.captures[1])
    if コードポイント == 定義行中コードポイント
        east_asian_width = m.captures[2] 
        return (true, east_asian_width)
    else
        return (false, nothing)
    end
end

#範囲コードポイント
struct EastAsianWidthMatcher_範囲コードポイント
    reg
end

function EastAsianWidthMatcher_範囲コードポイント()
    EastAsianWidthMatcher_範囲コードポイント(r"^([0-9A-F]{4}..[0-9A-F]{4});(F|H|W|Na|A|N) *#.*$")
end

function Base.occursin(matcher::EastAsianWidthMatcher_範囲コードポイント, 定義行)
    return occursin(matcher.reg, 定義行)
end

function east_asian_width特性抽出(matcher::EastAsianWidthMatcher_範囲コードポイント, コードポイント, 定義行)
    m = match(matcher.reg, 定義行)
    定義行中コードポイント = eval範囲コードポイント(m.captures[1])
    if コードポイント in 定義行中コードポイント
        east_asian_width = m.captures[2] 
        return (true, east_asian_width)
    else
        return (false, nothing)
    end
end

まあ、元のコードを切り貼りしただけだだが、これで正規表現のデータや細かいマッチ処理の部分と、それをどうコントロールするかという大きな手続きとに分解することができた。

EastAsianWidth.txtファイルを基にしたEast_Asian_Width特性の抽出

ついにEastAsianWidth.txt中の1行を対象に、

  • 与えられたコードポイントがその行で定義しているコードポイントにマッチするか判定
  • マッチするのであればEast_Asian_Width特性を取得する

という処理を作ることができたので、あとはこれをファイルの全行に対して実施すれば、与えられたコードポイントのEast_Asian_Width特性を得ることができる。

それを行ってくれるのが次のコードだ。ファイルを開き、1行ずつ読み込んだ結果を配列にしてlinesという変数に保持している。そして、linesの各行に対して、get_east_asian_widthを適用している。

@__DIR__というのは、このコードが書かれているファイルが置かれているフォルダを意味している。このプログラムファイルと同じフォルダに"EastAsianWidth.txt"が置かれているという状況を想定している。

function get_east_asian_width(コードポイント)
    path = joinpath(@__DIR__, "EastAsianWidth.txt")
    lines = open(path, "r") do f
        readlines(f)
    end
    for 定義行 in lines
        can_match, east_asian_width = get_east_asian_width(コードポイント, 定義行)
        if can_match
            return east_asian_width
        end
    end
end

さて、このコードはコードポイントを1つ判定するたびに、ファイルを開いて全行読み込んでいる。ディスクIOというのは最も遅い部類の処理なので、パフォーマンスの観点から言うと最悪のコードだ。が、まあ今のところは速度懸念も出ていないので、ひとまずこのままにしよう。気になるのであれば好きなように改善して欲しい。

East_Asian_Width特性を基にした全角半角判定

さあもうあと一息だ。east_asian_width特性から全角と半角を判定するコードを書こう。もう一度判定条件を記載しておく。

特性値Na(狭)またはN(中立)を持つ文字は、半角の文字 (halfwidth) として扱う。
特性値W(広)またはF(全角)を持つ文字は、全角の文字 (fullwidth) として扱う。
特性値H(半角)を持つ文字は、半角の文字 (halfwidth) として扱う。
特性値A(曖昧)を持つ文字は、全角の文字 (fullwidth) として扱う。

東アジアの文字幅

サクッと作ってしまおう。テストは次のようになる。is全角byEastAsianWidth特性というのが作りたい関数だ。

@testset "全角判定byEastAsianWidth特性" begin
    @testset "全角" begin
        @test is全角byEastAsianWidth特性("W") 
        @test is全角byEastAsianWidth特性("F")
        @test is全角byEastAsianWidth特性("A")
    end
    @testset "半角" begin
        @test !is全角byEastAsianWidth特性("Na") 
        @test !is全角byEastAsianWidth特性("N")
        @test !is全角byEastAsianWidth特性("H")
    end
end

実装はこうだ。

function is全角byEastAsianWidth特性(e)
    if !(e in ["Na", "N", "W", "F", "H", "A"])
        throw(DomainError("無効なeast_asian_width特性です"))
    end
    return e in ["W", "F", "A"]
end

簡単だった。

コードポイントを基にした全角半角判定

ようやくここがゴールだ。文字を1文字受け取りそのコードポイントを基に全角か半角かを判定する処理を作ろう。

テストはこんな感じだ。

@testset "全角半角判定" begin
    @testset "全角" begin
        @test is全角('あ') 
        @test is全角('ア')
        @test is全角('A')
        @test is全角('雨')
        @test is全角(' ')
        @test is全角('ー')
    end
    @testset "半角" begin
        @test is半角('a') 
        @test is半角('A')
        @test is半角('1')
        @test is半角('ア')
        @test is半角(' ')
        @test is半角('-')
    end
end

そして実装は次のようになる。

function is全角(c)    
    コードポイント = Int(c)
    e = get_east_asian_width(コードポイント)
    return is全角byEastAsianWidth特性(e)
end

function is半角(c)
    return !is全角(c)
end

ついに完成だ!

evalの正しい使い方

さて、現時点で心残りが一つだけある。それが下記の部分だ。ほとんど同様のコードで、違いは引数の型の部分と、「if コードポイント == 定義行中コードポイント」なのか、「if コードポイント in 定義行中コードポイント」なのか、という部分だけである。このような重複は極力排除したくなるのが人間の性だ。

#単一コードポイント
function east_asian_width特性抽出(matcher::EastAsianWidthMatcher_単一コードポイント, コードポイント, 定義行)
    m = match(matcher.reg, 定義行)
    定義行中コードポイント = eval範囲コードポイント(m.captures[1])
    if コードポイント == 定義行中コードポイント
        east_asian_width = m.captures[2] 
        return (true, east_asian_width)
    else
        return (false, nothing)
    end
end

#範囲コードポイント
function east_asian_width特性抽出(matcher::EastAsianWidthMatcher_範囲コードポイント, コードポイント, 定義行)
    m = match(matcher.reg, 定義行)
    定義行中コードポイント = eval範囲コードポイント(m.captures[1])
    if コードポイント in 定義行中コードポイント
        east_asian_width = m.captures[2] 
        return (true, east_asian_width)
    else
        return (false, nothing)
    end
end

ここで二つの処理の差異は、値や変数ではなく演算だ。値や変数が違うだけの処理であれば関数化することになるが、今回これら処理を共通化させるには、クロージャを使うのが自然だ。==にしろ、inにしろ、中置記法的に使うことが多いが、==(a, b)in(a, b)のように前置記法としても使うことができることに注意しよう。

function create_east_asian_width特性抽出(f)
    return function east_asian_width特性抽出(matcher, コードポイント, 定義行)
        m = match(matcher.reg, 定義行)
        定義行中コードポイント = eval範囲コードポイント(m.captures[1])
        if f(コードポイント, 定義行中コードポイント)
            east_asian_width = m.captures[2] 
            return (true, east_asian_width)
        else
            return (false, nothing)
        end
    end
end

function east_asian_width特性抽出(matcher::EastAsianWidthMatcher_単一コードポイント, コードポイント, 定義行)
    func = create_east_asian_width特性抽出(==)
    func(matcher, コードポイント, 定義行)
end

function east_asian_width特性抽出(matcher::EastAsianWidthMatcher_範囲コードポイント, コードポイント, 定義行)
    func = create_east_asian_width特性抽出(in)
    func(matcher, コードポイント, 定義行)
end

しかし、ここであえてevalを使う方法を紹介したい。今回はevalを紹介したということで、7月4日はeval記念日、という感じなのだが、正直なところヤクザな使い方しかしていないので、このまま終わるのは気がひけるのだ。俵万智だってお怒りになるだろう。

それでちゃんとしたevalの使い方をお伝えしたいのだが、最初にコードを書いてしまうとこんな感じだ。

for (tp, op) in [(:EastAsianWidthMatcher_単一コードポイント, :(==))
                 (:EastAsianWidthMatcher_範囲コードポイント, :in)]
    eval(quote
        function east_asian_width特性抽出(matcher::$tp, コードポイント, 定義行)
            m = match(matcher.reg, 定義行)
            定義行中コードポイント = eval範囲コードポイント(m.captures[1])
            if $op(コードポイント, 定義行中コードポイント)
                east_asian_width = m.captures[2] 
                return (true, east_asian_width)
            else
                return (false, nothing)
            end
        end        
    end)
end

いきなりこれだけ見てもわかりづらいだろうから、本質的には同じことを行なっている小さなサンプルコードを示そう。REPLに下記のように打ち込んでみて欲しい。

julia> for (f, op) in [(:add, :+), (:mul, :*)]
         eval(quote
           function $f(a, b)
             $op(a, b)
           end
         end)
       end

こうするとなんと、addmulという関数が使えるようになるのだ。

julia> add(2, 3)
5
julia> mul(2, 3)
6

evalというのは、プログラムコードの評価を行ってくれる関数だ。今回、evalの中には次のようなものを書いた。

quote
  function $f(a, b)
    $op(a, b)
  end
end

function 〜 end というのは関数の定義だ。それを囲んでいるquote 〜 endというのは抽象構文木を生成する構文だ。[1]上の方で紹介した:()と同じだが、複数行に渡るときにはquote 〜 endで囲う必要がある。つまり、quote 〜 endで囲まれたfunction 〜 endというのは、「evalで評価してもらったら関数を定義しまっせ」という抽象構文木なのだ。普通は関数の定義というのはソースコードにせっせと手で書いていくものなのだが、evalを使うことでプログラムの実行中に関数を定義することができるのだ。

もう一度下記の例に戻ろう。evalの中でどんな関数を定義しようとしているか見てみよう。

julia> for (f, op) in [(:add, :+), (:mul, :*)]
         eval(quote
           function $f(a, b)
             $op(a, b)
           end
         end)
       end

$fとか$opとか書いてある。これは「ここの部分はプログラム実行中に決める変数やで」というもので、外側のループで定義されている変数がここに代入されて、最終的な抽象構文木になる。例えばループの1周目では、(f, op) = (:add, :+)なので、quoteの中身は

function add(a, b)
  +(a, b)
end

となるのだ。なお、:addとか:+というように、先頭にコロンがついた記号列は「シンボル」と呼ばれる。抽象構文木はシンボルが並べられて作られている。シンボルは抽象構文木を組み立てる際の部品なのだ。そのため抽象構文木に組み込む変数に代入する値はシンボル型となる。これがaddとかmulという名前の関数が定義できた仕組みである。

ここまでくると最初のコードの理解も深まるというものだ。型とその際に使う演算を定義しておいて、実行時に関数を定義している。

for (tp, f) in [(:EastAsianWidthMatcher_単一コードポイント, :(==))
                (:EastAsianWidthMatcher_範囲コードポイント, :in)]
    eval(quote
        function east_asian_width特性抽出(matcher::$tp, コードポイント, 定義行)
            m = match(matcher.reg, 定義行)
            定義行中コードポイント = eval範囲コードポイント(m.captures[1])
            if $f(コードポイント, 定義行中コードポイント)
                east_asian_width = m.captures[2] 
                return (true, east_asian_width)
            else
                return (false, nothing)
            end
        end        
    end)
end

なお、eval(quote 〜 end)というのはよく出てくるので、これと等価なものに@evalというものがある。こうすると少し読みやすくなる。

for (tp, f) in [(:EastAsianWidthMatcher_単一コードポイント, :(==))
                (:EastAsianWidthMatcher_範囲コードポイント, :in)]
    @eval begin
        function east_asian_width特性抽出(matcher::$tp, コードポイント, 定義行)
            m = match(matcher.reg, 定義行)
            定義行中コードポイント = eval範囲コードポイント(m.captures[1])
            if $f(コードポイント, 定義行中コードポイント)
                east_asian_width = m.captures[2] 
                return (true, east_asian_width)
            else
                return (false, nothing)
            end
        end        
    end
end

evalの注意点

最後に、evalの注意点に触れておこう。それは変数のスコープについてである。eval中で変数の評価はグローバル環境で行われる。例えば、:(println(x))という構文をevalで評価することになったとしよう。この時、xの値が表示されることになるのだが、その際のxがグローバル変数を参照するのだ。

#グローバル変数でx = 10とする
julia> x = 10
10

#testという関数で、xをprintlnする構文をevalするようにする
julia> function test()
         eval(:(println(x)))
       end

#test2という関数で、ローカル変数xを1にした上で、test関数を呼ぶ
julia> function test2()
         x = 1
         test()
       end

#通常であればtest2のローカル変数のxの値が優先されるが、evalの時にはグローバル変数のxの値が優先される。
julia> test2()
10

この制約は知っておく必要があるので、押さえておこう。ちなみに、今回紹介した次のようなケースで、例えば関数内のローカル変数がグローバル環境で評価されるという問題になることはない。

for (tp, f) in [(:EastAsianWidthMatcher_単一コードポイント, :(==))
                (:EastAsianWidthMatcher_範囲コードポイント, :in)]
    eval(quote
        function east_asian_width特性抽出(matcher::$tp, コードポイント, 定義行)
            m = match(matcher.reg, 定義行)
            定義行中コードポイント = eval範囲コードポイント(m.captures[1])
            if $f(コードポイント, 定義行中コードポイント)
                east_asian_width = m.captures[2] 
                return (true, east_asian_width)
            else
                return (false, nothing)
            end
        end        
    end)
end

なぜならこの例でのevalが評価しているのは、先程のような変数の値ではなく、関数定義の構文だからである。定義された関数の呼び出しは通常の関数呼び出しで行われるので、関数内のローカル変数のスコープが変にグローバル環境を参照してしまうことはない。

それでは、実行時に組み立てた式をevalしたい時にはどうすれば良いのかというと、普通はこのように書くことになると思う。

#evalでローカルスコープの変数を使いたい時には、evalまで変数を引き渡してあげて、$で埋め込む
julia> function test(x)
         eval(:(println($x)))
       end

julia> function test2()
         x = 1
         test(x)
       end

julia> test2()
1

この例だと普通にprintlnすれば良いだけの内容ではあるが、もっと複雑に構文を組み立てて変数の値をevalする時にも必要な変数は引数として渡すと良い。もう少し言うと、このようなケースでは通常はマクロという機能を使う。関数の中でevalをすることはほとんどなく、マクロを使うことが多い。

おわりに

全角と半角の判定処理だけを書いてサラッと終わらせるつもりだったが、ついevalを使いたくなってしまい、流れでいろいろと説明してしまった。Juliaにはさらに別のメタプログラミング機能として、少し名前を出した「マクロ」がある。これもそのうち説明することになるだろう。本文中でも説明したがメタプログラミングは難しい。難しいが面白い。いろいろ試してみよう。

ソースコード

今回のソースコード一覧である。色々なパターンを説明したので、コメントアウトしている関数や呼びだされなくなった関数があるが、そのままにしている。

using Test

struct EastAsianWidthMatcher_単一コードポイント
    reg
end

function EastAsianWidthMatcher_単一コードポイント()
    EastAsianWidthMatcher_単一コードポイント(r"^([0-9A-F]{4});(F|H|W|Na|A|N) *#.*$")
end

function Base.occursin(matcher::EastAsianWidthMatcher_単一コードポイント, 定義行)
    return occursin(matcher.reg, 定義行)
end

#=
function east_asian_width特性抽出(matcher::EastAsianWidthMatcher_単一コードポイント, コードポイント, 定義行)
    func = create_east_asian_width特性抽出(==)
    func(matcher, コードポイント, 定義行)
end
=#

struct EastAsianWidthMatcher_範囲コードポイント
    reg
end

function EastAsianWidthMatcher_範囲コードポイント()
    EastAsianWidthMatcher_範囲コードポイント(r"^([0-9A-F]{4}..[0-9A-F]{4});(F|H|W|Na|A|N) *#.*$")
end

function Base.occursin(matcher::EastAsianWidthMatcher_範囲コードポイント, 定義行)
    return occursin(matcher.reg, 定義行)
end

#=
function east_asian_width特性抽出(matcher::EastAsianWidthMatcher_範囲コードポイント, コードポイント, 定義行)
    func = create_east_asian_width特性抽出(in)
    func(matcher, コードポイント, 定義行)
end
=#

#=
function create_east_asian_width特性抽出(f)
    return function east_asian_width特性抽出(matcher, コードポイント, 定義行)
        m = match(matcher.reg, 定義行)
        定義行中コードポイント = eval範囲コードポイント(m.captures[1])
        if f(コードポイント, 定義行中コードポイント)
            east_asian_width = m.captures[2] 
            return (true, east_asian_width)
        else
            return (false, nothing)
        end
    end
end
=#

for (tp, f) in [(:EastAsianWidthMatcher_単一コードポイント, :(==))
                (:EastAsianWidthMatcher_範囲コードポイント, :in)]
    @eval begin
        function east_asian_width特性抽出(matcher::$tp, コードポイント, 定義行)
            m = match(matcher.reg, 定義行)
            定義行中コードポイント = eval範囲コードポイント(m.captures[1])
            if $f(コードポイント, 定義行中コードポイント)
                east_asian_width = m.captures[2] 
                return (true, east_asian_width)
            else
                return (false, nothing)
            end
        end        
    end
end


function get_east_asian_width(コードポイント, 定義行)
    matchers = [
        EastAsianWidthMatcher_単一コードポイント()
        EastAsianWidthMatcher_範囲コードポイント()
    ]

    for matcher in matchers
        if occursin(matcher, 定義行)
            return east_asian_width特性抽出(matcher, コードポイント, 定義行)
        end
    end
    return (false, nothing)
end

function get_east_asian_width(コードポイント)
    path = joinpath(@__DIR__, "EastAsianWidth.txt")
    lines = open(path, "r") do f
        readlines(f)
    end
    for 定義行 in lines
        can_match, east_asian_width = get_east_asian_width(コードポイント, 定義行)
        if can_match
            return east_asian_width
        end
    end
end

function 十六進数prefix付与(範囲コードポイント)
    function prefix付与(s)
        return "0x" * s
    end
    return replace(範囲コードポイント, r"[0-9A-F]{4}" => prefix付与)
end

function parse範囲コードポイント(範囲コードポイント)
    range = 十六進数prefix付与(範囲コードポイント)
    reg = r"(0x[0-9A-F]{4})..(0x[0-9A-F]{4})"
    m = match(reg, range)
    開始 = parse(Int, m.captures[1])
    終了 = parse(Int, m.captures[2])
    return 開始:終了
end

function eval範囲コードポイント(範囲コードポイント)
    range = 十六進数prefix付与(範囲コードポイント) #"0x0041..0x005A"という文字列に変換
    code_str = replace(range, ".." => ":") #"0x0041:0x005A"という文字列に変換
    expr = Meta.parse(code_str) #:(0x0041:0x005A) という抽象構文木に変換
    return eval(expr) #抽象構文木を評価
end


function is全角byEastAsianWidth特性(e)
    if !(e in ["Na", "N", "W", "F", "H", "A"])
        throw(DomainError("無効なeast_asian_width特性です"))
    end
    return e in ["W", "F", "A"]
end

function is全角(c)    
    コードポイント = Int(c)
    e = get_east_asian_width(コードポイント)
    return is全角byEastAsianWidth特性(e)
end

function is半角(c)
    return !is全角(c)
end

@testset "単一コードポイント" begin
    s = "0020;Na          # Zs         SPACE"
    @testset "正常取得" begin
        @test get_east_asian_width(0x0020, s) == (true, "Na")
    end
    @testset "境界値検査" begin 
        @test get_east_asian_width(0x001F, s) == (false, nothing)
        @test get_east_asian_width(0x0021, s) == (false, nothing)
    end
end

@testset "範囲コードポイント" begin
    s = "0041..005A;Na    # Lu    [26] LATIN CAPITAL LETTER A..LATIN CAPITAL LETTER Z"
    @testset "正常取得" begin
        @test get_east_asian_width(0x0041, s) == (true, "Na")
        @test get_east_asian_width(0x0059, s) == (true, "Na")
        @test get_east_asian_width(0x005A, s) == (true, "Na")
    end
    @testset "境界値検査" begin 
        @test get_east_asian_width(0x0040, s) == (false, nothing)
        @test get_east_asian_width(0x005B, s) == (false, nothing)
    end
end

@testset "ファイルから取得" begin
    @testset "単一コードポイント" begin
        @test get_east_asian_width(0x0020) == "Na"
    end
    @testset "範囲コードポイント" begin
        @test get_east_asian_width(0x00BD) == "A"
    end
end

@testset "parse範囲コードポイント" begin
    s = "0041..005A"
    @test parse範囲コードポイント(s) == 0x0041:0x005A
end

@testset "eval範囲コードポイント" begin
    s = "0041..005A"
    @test eval範囲コードポイント(s) == 0x0041:0x005A
end

@testset "全角判定byEastAsianWidth特性" begin
    @testset "全角" begin
        @test is全角byEastAsianWidth特性("W") 
        @test is全角byEastAsianWidth特性("F")
        @test is全角byEastAsianWidth特性("A")
    end
    @testset "半角" begin
        @test !is全角byEastAsianWidth特性("Na") 
        @test !is全角byEastAsianWidth特性("N")
        @test !is全角byEastAsianWidth特性("H")
    end
end

@testset "全角半角判定" begin
    @testset "全角" begin
        @test is全角('あ') 
        @test is全角('ア')
        @test is全角('A')
        @test is全角('雨')
        @test is全角(' ')
        @test is全角('ー')
    end
    @testset "半角" begin
        @test is半角('a') 
        @test is半角('A')
        @test is半角('1')
        @test is半角('ア')
        @test is半角(' ')
        @test is半角('-')
    end
end

References

References
1 上の方で紹介した:()と同じだが、複数行に渡るときにはquote 〜 endで囲う必要がある。