今回の読んでみた対象はこのお方:
📂 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を加算したもの
となります。
なので処理の流れとしては、
- n -= 0x10000:絵文字などのUnicode番号から65536を減算します。
- s1 = 0xd800 | ((n >> 10) & 0x3ff):s1は上位10ビットで、0xd800はサロゲートペアの上側の開始番号をさします。
>>10
は10ビット分右にずらしています。
右にずらすというのは、上位ビットを取り出す - s2は下位10ビットのことになります。
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の裏には、こんな細やかな配慮が詰まっている…。
文字って、地味だけど、奥が深い!

コメント