TT Lab
はじめる
学ぶ 学習パス コース

良いサービスを作る CS — 教科書の概念を計測で学び直す

文字列の長さは三つ、そして今日はどこの今日か

TT Labで続きを見る

一言でいうと

「ニックネームは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つにまとめます。