文字列比較の罠

突然のF# Advent Calendar 2014 - connpassの24日目です。

F# のdisりネタを紹介したいと思います! こんなふざけた挙動のくせに調子乗らないで下さい!

文字列比較の挙動

F# の文字列比較には2種類の方法があります。演算子(<, <=, >, >=)を使う方法とCompareToメソッドを使う方法です。

"B" < "b"
"B".CompareTo("b") < 0

それぞれの結果がどうなるか分かりますか?

> "B" < "b";;
val it : bool = true
> "B".CompareTo("b") < 0;;
val it : bool = false

結果が違いますね! ていうかこんな挙動誰も知らないよ! 同じだと思うだろ普通!!! 職場でも、@ が嵌ってくれました。

C# ではどうなってんだよ?と思ったのですが、そもそも C# では演算子を使った文字列比較は出来ないんですよねー。

また、List.sortEnumerable.OrderByの結果も違います。

> List.sort [ "a"; "b"; "A"; "B"; ];;
val it : string list = ["A"; "B"; "a"; "b"]
> [ "a"; "b"; "A"; "B"; ].OrderBy(fun x -> x);;
val it : IOrderedEnumerable<string> = seq ["a"; "A"; "b"; "B"]

そして、OCamlの実行結果です。

# "B" < "b";;
- : bool = true
# List.sort compare [ "a"; "b"; "A"; "B"; ];;
- : string list = ["A"; "B"; "a"; "b"]

どうやらOCamlの挙動と合わせたようです。確かにF#にはML互換のコンパイラオプションもありますが、そのオプションを使った時だけにして欲しかったなぁ……。

ListModuleを使ってEnumerable.OrderByと挙動を同じにしたい場合は、次のようにします。

> [ "a"; "b"; "A"; "B"; ] |> List.sortWith (fun x y -> x.CompareTo(y));;
val it : string list = ["a"; "A"; "b"; "B"]

ちなみに、こう書くと型推論に失敗しますが、これはまた別のdisりネタ。

List.sortWith (fun x y -> x.CompareTo(y)) [ "a"; "b"; "A"; "B"; ]

ドキュメント

この件のドキュメントは見つけられませんでした。誰か見つけたら教えてー。

実装

社内チャットで相談したところ、実装を見ようということになりました。俺が F# のリポジトリを clone している間に、@ がソースを読んで、@がILを読んで、サクッと解決されてしまいました。「ILに追い付いてきた」とか競ってました。なんなんコイツら。

(<)演算子

"B" < "b"

で出力されるILをILSpyで見てみると、

LanguagePrimitives.HashCompare.GenericLessThanIntrinsic<string>("B", "b");

と表示されます。CompareToを呼んでいないぞ? つぎはGenericLessThanIntrinsicの実装を見てみましょう。ファイルはprim-types.fs:1239辺りです。

let GenericLessThanIntrinsic (x:'T) (y:'T) = 
    try
        (# "clt" (GenericComparisonWithComparerIntrinsic fsComparer x y) 0 : bool #)
    with
        | e when System.Runtime.CompilerServices.RuntimeHelpers.Equals(e, NaNException) -> false

こんな感じにどんどん潜って行くと、次のコードに突き当たります。ファイルはprim-types.fs:957辺りです。

let rec GenericCompare (comp:GenericComparer) (xobj:obj,yobj:obj) = 
    (*if objEq xobj yobj then 0 else *)
      //System.Console.WriteLine("xobj = {0}, yobj = {1}, NaNs = {2}, PERMode.mode = {3}", [| xobj; yobj; box PER_NAN_PAIR_DETECTED; box PERMode.mode |])
      match xobj,yobj with 
       | null,null -> 0
       | null,_ -> -1
       | _,null -> 1
#if INVARIANT_CULTURE_STRING_COMPARISON
       // Use invariant culture comparison for strings
       | (:? string as x),(:? string as y) -> System.String.Compare(x, y, false, CultureInfo.InvariantCulture)
#else
       // Use Ordinal comparison for strings
       | (:? string as x),(:? string as y) -> System.String.CompareOrdinal(x, y)
#endif

なにやらINVARIANT_CULTURE_STRING_COMPARISONフラグでString.Compareを呼ぶかString.CompareOrdinalを呼ぶか分岐しています?

ここのILは、

internal static int GenericCompare(LanguagePrimitives.HashCompare.GenericComparer comp, object xobj, object yobj)
{
//...
        if (text != null)
        {
            string text2 = yobj as string;
            if (text2 != null)
            {
                string strB = text2;
                string strA = text;
                return string.CompareOrdinal(strA, strB);
            }

String.CompareOrdinalを呼んでいますね。つまり、FSharp.CoreINVARIANT_CULTURE_STRING_COMPARISONを指定せずにビルドされているようです。

String.CompareOrdinalは、文字に対応するCharオブジェクトの数値を比較するメソッドです。 B0x42b0x62だから、"B" < "b"の結果がtrueになるんですね。

List.sort

List.sortの場合は、同じようにして、次のコードにたどり着きます。ファイルはprim-types.fs:1269辺りです。

let inline GenericComparisonFast<'T> (x:'T) (y:'T) : int = 
     GenericComparisonIntrinsic x y
     //...
     when 'T : string = 
#if INVARIANT_CULTURE_STRING_COMPARISON
         // NOTE: we don't have to null check here because System.String.Compare
         // gives reliable results on null values.
         System.String.Compare((# "" x : string #) ,(# "" y : string #), false, CultureInfo.InvariantCulture)
#else
         // NOTE: we don't have to null check here because System.String.CompareOrdinal
         // gives reliable results on null values.
         System.String.CompareOrdinal((# "" x : string #) ,(# "" y : string #))
#endif

ここでもINVARIANT_CULTURE_STRING_COMPARISONが出てきました。

まとめ

文字列は比較する方法により結果が変わるので、コードレビューで意図通りの比較になっているか確認する等して、対策しましょう。

文字列比較用の演算子を定義するのも考えられますが、次の理由でやめたほうがいいと思います。

  • オリジナルの比較演算子(<, <=, >, >=)を使ってしまった時にコンパイルエラーにできない
  • List.sortの挙動は変わらない

それでも俺はString.Compareを使って欲しいんだ!って人は、INVARIANT_CULTURE_STRING_COMPARISONを指定してFSharp.Coreをビルドすればいいみたいです。