Inflector#transliterate が呼び出し元文字列を変更しないように修正
ActiveSupport::Inflector.transliterate が、非 UTF-8 かつ凍結されていない文字列を受け取ったときに内部で force_encoding / encode! を実行し、呼び出し元オブジェクトを副作用的に書き換えていた問題を解消し、常にコピー上で変換を行うようになりました。これにより、parameterize などの上位メソッドでも同様の予期しないミューテーションが起きなくなります。
背景
ActiveSupport::Inflector.transliterate は文字列のローマ字化を行うユーティリティで、以前は文字列が 凍結 (frozen) されている場合にだけ dup を行い、非凍結かつ非 UTF-8 の文字列に対しては直接エンコーディングを変更していました。その結果、次のようなコードで元の文字列が変化してしまいます。
s = "中文".encode(Encoding::GB18030)
ActiveSupport::Inflector.transliterate(s)
puts s.encoding # => UTF-8 (期待は GB18030)
このミューテーションは parameterize が内部で transliterate を呼び出す際にも伝搬し、呼び出し側が文字列オブジェクトの状態を保てないという予期しない副作用を引き起こしていました。実際に非凍結の GB18030 や US‑ASCII(非 ASCII バイトを含む)文字列でテストが失敗していたことが報告されています。
技術的な変更
string を常にコピーしてからエンコーディング変換を行うように変更しました。具体的には、return string.dup if string.ascii_only? の直後に string = string.dup を挿入し、凍結状態に関わらず新しいオブジェクトで処理を進めます。
@@
- return string.dup if string.ascii_only?
- string = string.dup if string.frozen?
+ return string.dup if string.ascii_only?
+
+ # Operate on a copy so the in-place force_encoding/encode! below never
+ # mutate the caller's (possibly non-frozen) string.
+ string = string.dup
早期リターンで ASCII のみの文字列は従来通り string.dup したオブジェクトを返すため、パフォーマンスへの影響は最小限に留められています。また、凍結文字列に対しても同様にコピーを作成するため、既存の API 互換性は保持されています。
テストスイートには 3 件の回帰テストが追加され、非凍結の GB18030、US‑ASCII(非 ASCII バイト)、および parameterize 呼び出し時に文字列が変更されないことを確認しています。これらのテストはすべて修正前は失敗し、修正後は緑になることが保証されています。
設計判断
string を無条件で dup する 方針は、条件分岐を減らしコードの可読性と安全性を高めることを目的としています。凍結チェックのみで dup を行う実装は、エンコーディング変換が副作用を持つことを見落としやすく、今回のバグの根本原因となっていました。
無条件 dup にしたことで、呼び出し元文字列が凍結かどうかに関わらずミューテーションが起きない保証が得られます。さらに、API のシグネチャや既存の動作は変わらず、string.dup のコストは文字列長に比例するものの、文字列変換自体が行われる前提条件であるため実質的な性能低下はほとんどありません。
この変更は Inflector 系メソッド全体に波及し、parameterize などの上位ユーティリティでも同様に安全な振る舞いが保証されます。結果として、Rails アプリケーションの文字列操作における予期せぬ副作用が排除され、開発者が文字列オブジェクトの状態を安心して扱えるようになりました。
まとめ
今回の PR は、ActiveSupport::Inflector.transliterate が呼び出し元文字列を変更しないようにするため、入力文字列を常にコピーしてからエンコーディング変換を行うというシンプルかつ安全な修正を導入しました。テストの追加により回帰が防止され、既存 API 互換性を保ちつつ Rails 全体の文字列処理の堅牢性が向上しています。