そのコード、読んでみた:【Python】16ビットじゃ足りない世界をどう描く?JSON文字列とサロゲートペアの話

そのコード読んでみた#3

今回の読んでみた対象はこのお方:

📂 encoder.py の py_encode_basestring_ascii

py_encode_basestring_ascii(s)さん!ぱちぱち〜👏

名前からして「なんか文字列をエンコードするんでしょ?」くらいの雑な印象でしたが、中をのぞいてみたらまあ、なかなか手間ひまかけてます。

今回も、“コードを読む力”を育てるために、自分の好きな言語でコードを要約してみようというアプローチを試してみます。

今回が第3回です。

def py_encode_basestring_ascii(s):
    """Return an ASCII-only JSON representation of a Python string

    """
    def replace(match):
        s = match.group(0)
        try:
            return ESCAPE_DCT[s]
        except KeyError:
            n = ord(s)
            if n < 0x10000:
                return '\\u{0:04x}'.format(n)
                #return '\\u%04x' % (n,)
            else:
                # surrogate pair
                n -= 0x10000
                s1 = 0xd800 | ((n >> 10) & 0x3ff)
                s2 = 0xdc00 | (n & 0x3ff)
                return '\\u{0:04x}\\u{1:04x}'.format(s1, s2)
    return '"' + ESCAPE_ASCII.sub(replace, s) + '"'
目次

関数の中に…関数!?

まず目をひくのがこの構造。

def py_encode_basestring_ascii(s):
    def replace(match):
        ...

関数の中に関数がある!
しかもちゃんと名前ついてるし、なにこの入れ子弁当…。

この「関数内関数」、Pythonでは普通にアリです。スコープを閉じて、外から見えないようにするために使うんですね。

つまりこの replace() さんは、「わたしは py_encode_basestring_ascii にしか使われない運命なの…」という健気な存在。泣けます。

replace関数の中身を覗いてみた

def replace(match):
    s = match.group(0)

はい出た、.group(0)
これは Python の正規表現界隈でよく見るやつです。

例えるなら、正規表現でマッチしたテキストから「ここがほしい!」って部分を .group(n) で指定して抜き取る技。

正規表現ミニ講座(唐突)

import re

text = '電話番号は090-0000-0000です'
pattern = r'(\d{2,4})-(\d{2,4})-(\d{4})'
match = re.search(pattern, text)

print(match.group(0))  # 全体: 090-0000-0000
print(match.group(1))  # 最初の括弧: 090

この .group(0) は「とにかく全部ちょうだい!」の意味。
今回の replace() でも、マッチしたテキストをまるっと回収してます。
マッチしたテキストっていうのは、pattern = r'(\d{2,4})-(\d{2,4})-(\d{4})'の中にあるカッコで囲まれたものです!

  • (\d{2,4})
  • (\d{2,4})
  • (\d{4})
    .group(1)だと、カッコの中の1つ目をくださいっていう意味になります。

辞書で味付け、されなかったら調理スタート

try:
    return ESCAPE_DCT[s]
except KeyError:
    ...

マッチした文字が ESCAPE_DCT に登録されてれば、そいつを返して終了。
JSON的に危険そうな文字は、ここで安全な形に置き換えられてるわけです。

ESCAPE_DCT の中身はこちら:

ESCAPE_DCT = {
    '\\': '\\\\',
    '"': '\\"',
    '\b': '\\b',
    '\f': '\\f',
    '\n': '\\n',
    '\r': '\\r',
    '\t': '\\t',
}

なるほど。いわゆる「エスケープ文字」たちが登録済み。

KeyErrorが出たら、いよいよ本番

except KeyError:
    n = ord(s)
    if n < 0x10000:
        return '\\u{0:04x}'.format(n)

ord() で文字を Unicode コードポイントに変換し、
その値が 0x10000(=65536)未満なら、\uXXXX の形で返す。
いわゆる BMP(Basic Multilingual Plane) です。ふつうの文字はここに収まる。

でも65536超えてるやつ、どうすんの?

出ました サロゲートペア
UTF-16で出てくる、”1文字を2つの値で表す” あのめんどくさいやつ。

else:
    n -= 0x10000
    s1 = 0xd800 | ((n >> 10) & 0x3ff)
    s2 = 0xdc00 | (n & 0x3ff)
    return '\\u{0:04x}\\u{1:04x}'.format(s1, s2)

ざっくり言うとこう:

  • Unicode
  • Unicodeコードポイントが65536以上 → BMPに収まらない
  • なので 2つの16ビットの値(上位+下位)に分割
  • それぞれ \uXXXX に変換して、2連発で返す!

これで、絵文字や珍しい記号たちも正しくエンコードできるってわけです。

🎨 たとえば「😊(U+1F60A)」は \ud83d\ude0a って形になるよ。

サロゲートペアって?

JSONの\uXXXXは16ビットと規定されています。
Pythonも準拠しているので、その範囲を超えてしまう絵文字とかは、表現ができなくなってしまうんですね。

例:
絵文字 😊(U+1F60A)とかは:U+1F60A = 128522(10進数)
16ビットの最大値は65536なので、余裕で超過してしまっていますね。そんなときは、2つの16ビットの値に分けるのがサロゲートペアという仕組みを使います。

16ビット(65536)を超過するような場合は、サロゲートペアとして、2つの16ビット値に分割します。

  • 普通の文字列は、U+0000〜U+FFFF(0~65536)
  • 特別な文字は、U+10000〜U+10FFFF(65536〜1114111)

分割の仕組みとしては、
まずは、65536を超過しているかどうかが起点になるため、Unicode文字から65536を減算しますね。

そのうえで、上位10ビットと下位10ビットに分割する仕組みがあって、

  • 上位10ビットは、0xD800を加算したもの
  • 下位10ビットは、0xDC00を加算したもの

となります。

なので処理の流れとしては、

  1. n -= 0x10000:絵文字などのUnicode番号から65536を減算します。
  2. s1 = 0xd800 | ((n >> 10) & 0x3ff):s1は上位10ビットで、0xd800はサロゲートペアの上側の開始番号をさします。>>10は10ビット分右にずらしています。
    右にずらすというのは、上位ビットを取り出す
  3. s2は下位10ビットのことになります。
  4. return '\\u{0:04x}\\u{1:04x}'.format(s1, s2)は、サロゲートペアが2つの16ビットで表現されるため、2つの\uXXXXを連結して返却しています。

最後に、全部まとめてエスケープ

return '"' + ESCAPE_ASCII.sub(replace, s) + '"'

正規表現 ESCAPE_ASCII を使って、対象文字列 s をひとつずつチェックしながら、
さっきの replace() を順に適用!
最終的には 安全なJSON文字列として、クオートで囲って返してくれます。

まとめ:ASCIIに優しく、世界にやさしく

この関数、最初は「ただのエスケープ処理か〜」くらいの気持ちで読み始めたんですが…

中でやってることは結構えぐい!

  • 正規表現のマッチング
  • エスケープ処理の辞書管理
  • Unicodeとサロゲートペアの正しい扱い
  • 関数内関数による隠蔽と分離設計

なかなかどうして、職人の手仕事って感じですね。

JSONの裏には、こんな細やかな配慮が詰まっている…。
文字って、地味だけど、奥が深い!

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

目次