「Macでは問題なく動くのに、Windowsに渡した途端にクラッシュする」という報告を受けたことがある人は少なくないはずだ。原因の多くは文字コードの不一致にある。今回は文字エンコーディングの基本を整理した上で、実際に自社のビルド工程で起きた事故と、それを二度と起こさないために書いたテストコードを見ていく。
エンコーディングとは何か
補足: 文字エンコーディングとは、コンピュータ内部で文字を保存・送信するために使う「文字とバイト列の対応表」のこと。同じ文字でも、どの対応表(エンコーディング)を使うかでバイト列の中身が変わる。
人間が「あ」という文字を見ているとき、コンピュータの内部ではその文字が何らかのバイト列として保存されている。「あ」をどんなバイト列に変換するかを決めるのがエンコーディングだ。
"あ".encode("utf-8") # b'\xe3\x81\x82'(3バイト)
"あ".encode("cp932") # b'\x82\xa0'(2バイト)
同じ「あ」という文字でも、UTF-8とcp932(Windowsの日本語ロケールで使われるエンコーディング。Shift_JISの拡張版)では変換後のバイト列が異なる。文字列をエンコードした側とデコードする側で使うエンコーディングが食い違うと、文字化けやエラーが発生する。
UTF-8は絵文字を含む世界中のほぼすべての文字を表現できるが、cp932は日本語(漢字・かな・全角記号)はカバーしていても、絵文字の大半には対応していない。ここが今回の事故の核心になる。
"あ".encode("cp932") # 成功: b'\x82\xa0'
"✅".encode("cp932") # UnicodeEncodeError
漢字やひらがなはcp932でも問題なくエンコードできる。だが絵文字はcp932の対応表に存在しないため、エンコードしようとした瞬間に UnicodeEncodeError が発生する。「日本語だから文字化けする」のではなく、「絵文字だから変換できない」というのが正確な理解だ。
なぜ「Macでは通ってWindowsでだけ落ちる」のか
Python でサブプロセスの出力をパイプ経由で受け取ったり、標準出力をキャプチャしたりするとき、明示的にエンコーディングを指定しなければ、実行環境の既定エンコーディング(ロケール由来)が使われる。macOSやLinuxのターミナルは基本的にUTF-8が既定なので、絵文字を含む文字列を出力してもそのまま通ってしまう。ところが日本語版Windowsの既定エンコーディングはcp932であるため、同じコードが同じ文字列を出力しようとした瞬間に UnicodeEncodeError で落ちる。
これが厄介なのは、開発機(Mac)で動作確認をしても再現しないという点だ。問題は実際にWindows環境でスクリプトを実行して初めて発覚する。
実際に起きた事故: ビルド前ゲートのクラッシュ
このアプリのビルドスクリプト build_app.py は、ビルド前のゲート処理として tools/bump_version.py --check をサブプロセスとして呼び出し、バージョン番号の整合性を確認している。
r = subprocess.run(
[sys.executable, "tools/bump_version.py", "--check"],
cwd=ROOT, capture_output=True,
)
bump_version.py は成功時に絵文字(✅など)を含むメッセージを標準出力に出す実装になっていた。macOS上でこのゲートを実行している間は何の問題もなかった。ところが日本語Windows環境でビルドを実行すると、capture_output=True で子プロセスの標準出力を受け取る際にcp932への変換が発生し、絵文字の箇所で UnicodeEncodeError が起きてサブプロセスがクラッシュした。build_app.py 側はこれを「バージョン番号整合性チェックに失敗した」という別のエラーとして誤表示してしまい、原因の特定に余計な時間がかかった。
「渡す前にMacで潰す」ための2段構えのテスト
この事故を受けて、同様の問題を二度と本番(Windowsビルド)で発覚させないためのガードとして tests/test_windows_cp932_safety.py を書いた。ポイントは、Mac上で実行するテストでありながら、Windowsで起きる文字コードの問題を検知できる設計にしたことだ。
1. 静的チェック — 対象ファイルの全文字を検証する
ビルド/リリース工程でサブプロセス実行または出力キャプチャされるスクリプトを CP932_CRITICAL_SCRIPTS というリストに列挙し、そのファイル内の全文字が cp932 でエンコード可能かを1文字ずつ検証する。
for ch in line:
try:
ch.encode("cp932")
except UnicodeEncodeError:
problems.append(f"{rel}:{lineno} '{ch}' は cp932 に変換できません")
これでファイルを実行しなくても「このスクリプトはWindowsの日本語ロケールで出力しようとすると落ちる文字を含んでいる」という事実を機械的に検出できる。修正方針は単純で、絵文字を [OK] [NG] [!] のようなASCII表記に置き換えるだけでよい。
2. 動作チェック — cp932の標準出力を実際に再現する
静的チェックだけでは「本当に実行時に落ちるか」までは確認できないため、PYTHONIOENCODING=cp932 と PYTHONUTF8=0 という環境変数を設定した子プロセスとして、実際に bump_version.py --check を実行するテストも用意した。
env = {**os.environ, "PYTHONIOENCODING": "cp932", "PYTHONUTF8": "0"}
r = subprocess.run(
[sys.executable, "tools/bump_version.py", "--check"],
cwd=ROOT, capture_output=True, env=env,
)
PYTHONIOENCODING はPythonの標準入出力が使うエンコーディングを強制的に上書きする環境変数で、PYTHONUTF8=0 はPython 3.7以降で既定になりつつあるUTF-8モードの自動有効化を止める指定だ。この2つを組み合わせることで、macOS上のPythonプロセスであっても「日本語Windowsのコンソールに出力する」状況を高い精度で再現できる。これにより、静的な文字コード表だけでは見えない、実行時の挙動まで含めて検証できる。
まとめ
文字エンコーディングの不一致による事故は、開発機のロケールがUTF-8であるほど発見が遅れやすい。特に「サブプロセスの出力をキャプチャする」「パイプ経由でテキストをやり取りする」処理は、明示的にエンコーディングを指定しない限り実行環境のロケールに依存するため、Mac上のテストだけでは見えない領域が残る。日本語のテキストそのものはcp932でも問題なく扱えるが、絵文字のような対応表の外側にある文字が紛れ込むと、日本語ロケールのWindows環境でだけ静かに壊れる。対策としては、①クロスプラットフォームで実行されるスクリプトの出力からは絵文字を避ける、②サブプロセスの出力を扱うコードでは環境変数を使ってターゲット環境のロケールを再現するテストを用意する、という2段構えが有効だ。