文字列の長さは三つ、そして今日はどこの今日か
一言でいうと
「ニックネームは10文字まで」という1行には、長さが3つ隠れています。ストレージが数えるUTF-8のバイト数、Pythonのlen()が数えるコードポイント、人が数える文字です。さらに「同じ名前」と「今日」まで、人の目と機械の計算が分かれる場所を、数字で揃えてみます。
なぜ必要なのか
登録画面は「6文字まで」と言い、サーバーはlen(name) <= 6で検査し、ストレージの欄はバイトで測ります。英字だけを使うテストでは、3つが同じなので問題はありません。ところが、家族の絵文字が1つ入ってくると、画面では1文字なのに、len()は5、バイトは18になります。通知のプレビューをバイトで切ると、末尾に疑問符のひし形が表示され、Macから貼り付けたハングルの名前は、同じ名前なのに、重複検査を通過します。
日付も同じ形です。イベントはUTCで記録されますが、ユーザーは自分のタイムゾーンの「今日」を生きています。UTCの日付で日別の統計をまとめると、ソウルのユーザーの朝9時前の活動が前日に回ります。
どう動くのか
3種類の長さ: このPodのPython 3.12で測った例です。
| 文字列 | コードポイント | UTF-8バイト | 人が見る文字 |
|---|---|---|---|
a |
1 | 1 | 1 |
ß |
1 | 2 | 1 |
가(完成形ハングル) |
1 | 3 | 1 |
가(分解形: 子音字と母音字、NFD) |
2 | 6 | 1 |
é(eと結合アクセント) |
2 | 3 | 1 |
👍🏽(肌の色の修飾) |
2 | 8 | 1 |
| 家族の絵文字(3人をZWJでつなぐ) | 5 | 18 | 1 |
Pythonのドキュメントは、strを「コードポイントのイミュータブルなシーケンス」と定義しているので、len()はコードポイント数です。バイト数はエンコーディングが決めます。RFC 3629のUTF-8は、1文字を1–4オクテットで表し、続くオクテットはすべて10xxxxxxの形で、最初のオクテットが全体の長さを示します。そのため、RFCが述べるように、オクテットの流れのどこからでも、文字の境界を簡単に見つけられます。バイト上限で切るとき、10xxxxxxではない位置まで後ろへ戻るだけで、壊れた文字が残りません。
人が見る文字は、UAX #29のgrapheme clusterです。結合記号とZWJの前で切らないルール(GB9)、ハングルの初声L・中声V・終声Tを1音節にまとめるルール(GB6–GB8)、絵文字のZWJシーケンスのルール(GB11)、国旗のペアのルール(GB12・GB13)などでできています。標準ライブラリのunicodedataには、この境界を分ける関数がありません。そのため、ラボでは簡略化したルールを自分で書きます。結合記号・異体字セレクター・肌の色の修飾子・ZWJは前に付け、ZWJの次の文字も付け、ハングルの初声・中声・終声はL・V・Tのルールでまとめます。国旗、Prepend、SpacingMarkは扱わず、GB11の「前が絵文字でなければならない」という条件も省きました。実際のサービスなら、UAX #29を実装したライブラリを使い、そのライブラリが従うUnicodeのバージョンまで確認する必要があります。
同じ名前: UAX #15は、2種類の「同じ」を区別します。正準等価(canonical)は、「同じ抽象文字であり、正しく描画すればいつでも同じに見える」関係で、完成形の가と、分解した子音字と母音字のㄱ+ㅏがそうです。NFC・NFDがこれを揃えます。互換等価(compatibility)はより弱く、形が異なってもかまいません。全角のKとK、合字のfiとfiで、NFKC・NFKDが揃えます。同じ文書は、KC・KDが書式の区別を消すので、「任意のテキストに盲目的に適用してはならない」と警告しています。そのため、原文はそのまま残し、比較用のキーにだけ使います。
大文字小文字は、str.casefoldのドキュメントの例がすべてを語っています。ßはすでに小文字なので、lower()はそのままにしますが、casefold()はssに変えます。StraßeとSTRASSEは、lowerでは分かれ、casefoldでは出会います。
今日はどこの今日か: datetimeのドキュメントには、2つの文があります。1つは、aware datetimeにtimedeltaを足しても「タイムゾーンの調整は行われない」こと、もう1つは、同じtzinfoを持つaware同士を引くと、tzinfoを無視して壁時計の差を返すことです。zoneinfoのドキュメントの例が、その結果です。ロサンゼルスの12:00にtimedelta(days=1)を足すと、夏時間が終わった翌日でも12:00です。逆に、UTCに変換した瞬間に24時間を足すと、切り替え日に現地時刻が1時間ずれます。「毎日09:00」は、現地の日付ごとに09:00を作ってUTCに変換することであり、2つの予約の間の実際の間隔は、UTCに変換した後で引いて初めて、23時間・25時間が見えてきます。zoneinfoは、システムのIANAデータベースかtzdataパッケージを読むので、コンテナイメージにそのデータがあるかどうかも、確認すべき点です。
現場での姿
エンコーディングの判定・文字化け・見えない文字の除去は、「顧客データを扱う」コースのfde-dat-encodingラボが深く扱います。夏時間の切り替え日に存在しない時刻と、2回来る時刻をfoldで区別することは、「ログが未来から来た」コースが担当します。切り捨てられた文字列をerrors="ignore"で黙って飲み込み、0で終わるバッチがなぜ危険かは、「失敗したのに終了コードは 0 だった」コースが、ツールの規律として扱います。このモジュールは、その間の計算、つまり長さ・切り詰め・同一性・日付を、サービスのルールとして固定する場所です。
次のラボですること
ニックネーム360個のバイト数・コードポイント数・文字数を数え、簡略化した文字のルールを実装したあと、バイト上限と文字数上限で、壊れないように切り詰めます。NFC・casefold・NFKCを順に重ねながら、衝突する名前を数え、UTCのイベントを現地の日付でまとめ、夏時間をまたぐ「毎日09:00」の予約をUTCに変換したあと、すべてを登録審査の関数1つにまとめます。