Ruby 2.4 の Unicode サポートを徹底テスト──「最小驚きの原則」から見る文字列操作の落とし穴
Ruby 2.4 で導入された新機能の一つに、改善された Unicode サポートがあります。具体的には、upcase や downcase といったメソッドが期待通りに動作し、「ä」を「Ä」に、そして元に戻せるようになりました。この変更をきっかけに、2013 年に André Arko 氏がブログ記事「Strings in Ruby are UTF-8 now… right??」を公開して以来、どの程度 Unicode 対応が進んだのか気になり、調査を行いました。
技術的なバグを探すのではなく、「最小驚きの原則(Principle of Least Surprise)」の観点から、Ruby のすべての文字列メソッドをテストしました。具体的には、以下の 5 つの前提を満たすかどうかを検証しています。
- 異なる文字は異なるものとして扱われる:「e」と「ë」は、「e」と「E」が異なるのと同様に、別の文字として扱われるべきです。
- 単一の文字は単一の文字として数えられる: Unicode でどのように表現されていようと、一つの文字は一つの文字です。「e」も「ë」もそれぞれ 1 文字ですが、後者は 2 つのコードポイントで構成されます。
- 文字は不変である: 文字列を反転させても、個々の文字が変化してはなりません。
- 空白は空白として扱われる: トリッキーな Unicode 空白文字であっても、空白として処理されるべきです。
- 数字は数字として扱われる: 書き方に関わらず、数字の 2 は常に 2 として認識されるべきです。
残念ながら、Ruby の文字列操作メソッドの大半はこれらのテストをパスしません。Unicode 文字列を扱う際は、どのメソッドを使うか極めて慎重になる必要があります。
注記: 公開後、いくつかの読者から「テスト文字列を Unicode 正規化していれば、指摘された失敗の多くは起きなかったはずだ」との指摘を受けました。これは事実です。しかし、Ruby や Rails が自動的に文字列を正規化することはありません(私がテストしたアプリケーションのいずれにおいても)。これらのテストは常に「最悪のケース」を示すことを意図しており、その観点からは依然として有用だと考えています。
Ruby 2.4.0 における Unicode テスト結果
以下の表は、主要な文字列メソッドを対象に、結合文字(例:「ä」=「a」+結合ダイエレシス U+0308)を用いてテストした結果です。「Expected」は「最小驚きの原則」に基づく期待値、「Result」は実際の実行結果、「Verdict」は判定を示します。
| メソッド | テストコード | 期待値 | 実行結果 | 判定 |
|---|---|---|---|---|
| #% | "%s" % "noël" | "noël" | "noël" | OK |
| #* | "noël" * 2 | "noëlnoël" | "noëlnoël" | OK |
| #<< | "noël" << "ë" | "noëlë" | "noëlë" | OK |
| #<=> | "ä" <=> "z" | -1 | -1 | OK |
| #== | "ä" == "ä" | true | true | OK |
| #=~ | "ä" =~ /a./ | nil | 0 | 要注意 |
| #[] | "ä"[0] | "ä" | "a" | 要注意 |
| #[]= | "ä"[0] = "u" | "u" | "u" | OK |
| #b | "ä".b.encoding.to_s | "ASCII-8BIT" | "ASCII-8BIT" | OK |
| #bytes | "ä".bytes | [97, 204, 136] | [97, 204, 136] | OK |
| #bytesize | "ä".bytesize | 3 | 3 | OK |
| #byteslice | "ä".byteslice(1) | "\xCC" | "\xCC" | OK |
| #capitalize | "ä".capitalize | "Ä" | "Ä" | OK |
| #casecmp | "äa".casecmp("äz") | -1 | -1 | OK |
| #center | "ä".center(3) | " ä " | "ä " | 要注意 |
| #chars | "ä".chars | ["ä"] | ["a", "̈"] | 要注意 |
| #chomp | "ä\n".chomp | "ä" | "ä" | OK |
| #chop | "ä".chop | "" | "a" | 要注意 |
| #chr | "ä".chr | "ä" | "a" | 要注意 |
| #clear | "ä".clear | "" | "" | OK |
| #codepoints | "ä".codepoints | [97, 776] | [97, 776] | OK |
| #concat | "ä".concat("x") | "äx" | "äx" | OK |
| #count | "ä".count("a") | 0 | 1 | 要注意 |
| #crypt | "123".crypt("ää") == "123".crypt("aa") | false | false | OK |
| #delete | "ä".delete("a") | "ä" | "̈" | 要注意 |
| #downcase | "Ä".downcase | "ä" | "ä" | OK |
| #dump | "ä".dump | "\"a\\u0308\"" | "\"a\\u0308\"" | OK |
| #each_byte | "ä".each_byte.to_a | [97, 204, 136] | [97, 204, 136] | OK |
| #each_char | "ä".each_char.to_a | ["ä"] | ["a", "̈"] | 要注意 |
| #each_codepoint | "ä".each_codepoint.to_a | [97, 776] | [97, 776] | OK |
| #each_line | "ä".each_line.to_a | ["ä"] | ["ä"] | OK |
| #empty? | "ä".empty? | false | false | OK |
| #encode | "ä".encode("ASCII", undef: :replace) | "a?" | "a?" | OK |
| #encoding | "ä".encoding.to_s | "UTF-8" | "UTF-8" | OK |
| #end_with? | "ä".end_with?("ä") | true | true | OK |
| #eql? | "ä".eql?("a") | false | false | OK |
| #force_encoding | "ä".force_encoding("ASCII") | "a\xCC\x88" | "a\xCC\x88" | OK |
| #getbyte | "ä".getbyte(2) | 136 | 136 | OK |
| #gsub | "ä".gsub("a", "x") | "ä" | "ẍ" | 要注意 |
| #hash | "ä".hash == "a".hash | false | false | OK |
| #include? | "ä".include?("a") | false | true | 要注意 |
| #index | "ä".index("a") | nil | 0 | 要注意 |
| #replace | "ä".replace("u") | "u" | "u" | OK |
| #insert | "ä".insert(1, "u") | "äu" | "aü" | 要注意 |
| #inspect | "ä".inspect | "\"ä\"" | "\"ä\"" | OK |
| #intern | "ä".intern | :ä | :ä | OK |
| #length | "ä".length | 1 | 2 | 要注意 |
| #ljust | "ä".ljust(3, "_") | "ä__" | "ä_" | 要注意 |
| #lstrip | " ä".lstrip | "ä" | "ä" | OK |
| #match | "ä".match("a") | nil | #<MatchData "a"> | 要注意 |
| #next | "ä".next | "ä" | "b̈" | 要注意 |
| #ord | "ä".ord | 97 | 97 | OK |
| #partition | "händ".partition("a") | ["händ"] | ["h", "a", "̈nd"] | 要注意 |
| #prepend | "ä".prepend("ä") | "ää" | "ää" | OK |
| #replace (2) | "ä".replace("ẍ") | "ẍ" | "ẍ" | OK |
| #reverse | "händ".reverse | "dnäh" | "dn̈ah" | 要注意 |
| #rpartition | "händ".rpartition("a") | ["händ"] | ["h", "a", "̈nd"] | 要注意 |
| #rstrip | "line ".rstrip | "line" | "line " | 要注意 |
| #scrub | "ä".scrub | "ä" | "ä" | OK |
| #setbyte | s = "ä"; s.setbyte(0, "x".ord); s | "ẍ" | "ẍ" | OK |
| #size | "ä".size | 1 | 2 | 要注意 |
| #slice | "ä".slice(0) | "ä" | "a" | 要注意 |
| #split | "ä".split("a") | ["ä"] | ["", "̈"] | 要注意 |
| #squeeze | "ää".squeeze("ä") | "ä" | "ää" | 要注意 |
| #start_with? | "ä".start_with?("a") | false | true | 要注意 |
| #strip | " line ".strip | "line" | " line " | 要注意 |
| #sub | "ä".sub("a", "x") | "ä" | "ẍ" | 要注意 |
| #succ | "ä".succ | "b̈" | "b̈" | OK |
| #swapcase | "ä".swapcase | "Ä" | "Ä" | OK |
| #to_c | "١".to_c | (1+0i) | (0+0i) | 要注意 |
| #to_f | "١".to_f | 1.0 | 0.0 | 要注意 |
| #to_i | "١".to_i | 1 | 0 | 要注意 |
| #to_r | "١".to_r | (1/1) | (0/1) | 要注意 |
| #to_sym | "ä".to_sym | :ä | :ä | OK |
| #tr | "ä".tr("a", "b") | "ä" | "b̈" | 要注意 |
| #unpack | "ä".unpack("CCC") | [97, 204, 136] | [97, 204, 136] | OK |
| #upto | "ä".upto("c̈").to_a | ["ä", "b̈", "c̈"] | ["ä", "b̈", "c̈"] | OK |
| #valid_encoding? | "ä".valid_encoding? | true | true | OK |
まとめと対策
テスト結果からわかる通り、Ruby 2.4 で upcase/downcase などのケース変換が改善された一方で、文字単位で動作すべきメソッドの多くが依然としてコードポイント単位で動作し、「最小驚きの原則」を満たしていません。特に chars、each_char、length/size、reverse、gsub/sub、split など、日常的に使うメソッドが「要注意」判定となっています。
推奨される対策
- 文字列を NFC/NFD 正規化する:
String#unicode_normalize(Ruby 2.2+)を用いて、処理の前後に正規化を行う。 - グレム(書記素クラスタ)単位で操作する:
String#grapheme_clusters(Ruby 3.2+)や、unicode-display_width、unicode-grapheme-cluster-breakなどの gem を活用する。 - 正規表現では
\Xを使う: 書記素クラスタにマッチする\Xを用いると、結合文字列を一つの単位として扱えます。 - 数値変換は注意深く:
to_i等は ASCII 数字のみ対応。全角数字やアラビア・インディック数字(例:「١」)には対応していません。Unicode::Number等のライブラリを検討してください。
Unicode を正しく扱うには、「文字列=バイト列」でも「文字列=コードポイント列」でもなく、「文字列=書記素クラスタの列」として捉え、適切なライブラリや正規化を併用することが不可欠です。Ruby の進化に期待しつつ、現時点では慎重な実装を心がけましょう。
-
25 のベスト API テスト ツール
クラウド アプリと関連するプラットフォームの数が増えるにつれて、API テストはますます重要になっています。基本的に、API (アプリケーション プログラミング インターフェース) は、2 つのソフトウェア プログラムがやり取りしてデータを共有できるようにするメッセンジャーです。 .存在しないため、フロント エンドでは実行できない一種のソフトウェア テストです。この記事では、最高の無料 API テスト ツールをいくつか調べました。それでは、読み続けてください! 25 のベスト API テスト ツール API がソフトウェア開発の重要な側面になるにつれて、API テストはますます開発者と
-
17 の最高のモバイル テスト ツール
モバイル領域は急速に拡大しています。モバイル アプリケーションは、ビデオからモバイル バンキングまで、さまざまな形式で利用できるようになりました。莫大な収益性があるため、多くの企業がモバイル アプリ業界での存在感を確立しようとしています。アプリを作成するためのモバイル テストに最適なシミュレーターをお探しですか?見つけるお手伝いをいたします。この投稿では、Android と iOS のテスト範囲、効率、精度を向上させるのに役立つ最適なモバイル テスト ツールを見ていきます。 17 の最高のモバイル アプリ テスト ツール モバイル アプリの開発中に考慮すべきさまざまなプラットフォームと