文字列比較の罠
突然の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
結果が違いますね! ていうかこんな挙動誰も知らないよ! 同じだと思うだろ普通!!! 職場でも、@pocketberserker が嵌ってくれました。
C# ではどうなってんだよ?と思ったのですが、そもそも C# では演算子を使った文字列比較は出来ないんですよねー。
また、List.sort
とEnumerable.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 している間に、@bleis がソースを読んで、@k_disposeが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.Core
はINVARIANT_CULTURE_STRING_COMPARISON
を指定せずにビルドされているようです。
String.CompareOrdinalは、文字に対応するChar
オブジェクトの数値を比較するメソッドです。
B
は0x42
、b
は0x62
だから、"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
が出てきました。
まとめ
文字列は比較する方法により結果が変わるので、コードレビューで意図通りの比較になっているか確認する等して、対策しましょう。
文字列比較用の演算子を定義するのも考えられますが、次の理由でやめたほうがいいと思います。
それでも俺はString.Compare
を使って欲しいんだ!って人は、INVARIANT_CULTURE_STRING_COMPARISON
を指定してFSharp.Core
をビルドすればいいみたいです。