🔢

【.NET】CompareOptions.NumericOrderingフィールドの使い方や動作

に公開

はじめに

.NET 10 から文字列比較オプションであるCompareOptions列挙型にNumericOrderingフィールドが追加されました。
このフィールドを使うことで、文字列として数値を比較する場合やソートする場合に発生していた動作をほとんど解消することができる優れものだと私は思っています。
今回はCompareOptions.NumericOrderingフィールドについてまとめていこうと思います。

CompareOptions.NumericOrderingフィールドとは

公式ドキュメントには以下のように説明されており、つまり0から9の範囲の整数を表す文字を数値として比較を行います。

数字のシーケンス (Unicode 一般カテゴリ "Nd") を数値に基づいて並べ替える文字列比較。
例えば、「2」は「10」の前に来ます。小数点、マイナス記号、プラス記号などの数字以外の文字はシーケンスの一部とはみなされず、シーケンスの末尾として扱われます。このフラグは、インデックスメソッド(IndexOf(String, String, CompareOptions)やIsPrefix(String, String, CompareOptions)など)では無効です。

https://learn.microsoft.com/ja-jp/dotnet/api/system.globalization.compareoptions?view=net-10.0

具体的な動作について、サンプルコードから説明していきます。

デフォルトで比較、ソートした場合

例えば、1, 2, 3, 4, 5の文字を昇順ソートした場合、1, 2, 3, 4, 5でソートされます。

1から5の文字をソート
string[] numbers = ["3", "4", "5", "1", "2"];

foreach(var number in numbers.Order())
{
    Console.WriteLine(number); // 1 2 3 4 5 で出力されます。
}

しかし同じように 8, 9, 10, 11, 12 の文字をソートした場合、8, 9, 10, 11, 12 ではなく、10, 11, 12, 8, 9でソートされてしまいます。
これは文字のコード値に基づいて比較するため、"8"(ASCIIコード:56)や"9"(ASCIIコード:57)よりも"1"(ASCIIコード:49)のほうがASCIIコード値が小さいため、10, 11, 12が最初に来てしまいます。

8から12の文字をソート
string[] numbers = ["8", "11", "12", "9", "10",];

foreach(var number in numbers.Order())
{
    Console.WriteLine(number); // 10 11 12 8 9 で出力されます。
}

文字のコード値に基づいた比較を行うため、以下のようなプレフィックスに0がついている場合、当たり前ですが、違う文字として判定されます。

各StringComparerでの同値判定
// カルチャー非依存での比較
if (StringComparer.InvariantCulture.Equals("8", "08"))
{
    // ここには到達しない
    Console.WriteLine("8 と 08 は同じ");
}
// 序数比較
if (StringComparer.Ordinal.Equals("8", "08"))
{
    // ここには到達しない
    Console.WriteLine("8 と 08 は同じ");
}

CompareOptions.NumericOrderingフィールドを用いた比較、ソートした場合

そこでCompareOptions.NumericOrderingフィールドを用いたStringComparerを比較子として使うことで、文字のコード値ではなく数値として比較、ソートが可能になるため、同じように 8, 9, 10, 11, 12 の文字をソートすると、8, 9, 10, 11, 12でソートされます。

CompareOptions.NumericOrderingフィールドを用いて8から12の文字をソート
StringComparer numericOrderingComparer = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering);

string[] numbers = ["8", "11", "12", "9", "10",];

foreach(var number in numbers.Order(numericOrderingComparer))
{
    Console.WriteLine(number); // 8 9 10 11 12で出力されます。
}

以下のようなプレフィックスに0がついている場合でも、同じ数値として判定されます。

"8"と"08"
StringComparer numericOrderingComparer = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering);

if (numericOrderingComparer.Equals("8", "08"))
{
    // 到達する!
    Console.WriteLine("8 と 08 は同じ");
}

// string.Compareでも使用可能
if (string.Compare("8", "08", CultureInfo.InvariantCulture, CompareOptions.NumericOrdering) == 0)
{
    // 到達する
    Console.WriteLine("8 と 08 は同じ");
}

CompareInfo.GetHashCodeメソッドから取得されるハッシュコードも当たり前ですが、同じ値を取得できます。

ハッシュコード確認
var hashCode = CultureInfo.InvariantCulture.CompareInfo.GetHashCode("1", CompareOptions.NumericOrdering);
var hashCode2 = CultureInfo.InvariantCulture.CompareInfo.GetHashCode("01", CompareOptions.NumericOrdering);

if (hashCode == hashCode2)
{
    // 同じハッシュコードが取得できるため、到達する!
    Console.WriteLine("ハッシュコードは同じです。");
}

あとは以下のような文字列と数値の混合されているケースにも有効で、しっかりと数値に基づいてソートされます。

文字列と数値の混合されているケース
// NT、2000、98などは一旦除外しています。(あくまでサンプルなので...)
string[] windowsVersion = ["Windows 1.0", "Windows 2.0", "Windows 3.0", "Windows 7", "Windows 8", "Windows 8.1", "Windows 10", "Windows 11"];

foreach (var windows in windowsVersion.Order(numericOrderingComparer))
{
    Console.WriteLine(windows);
}
// "Windows 1.0" "Windows 2.0" "Windows 3.0" "Windows 7" "Windows 8" "Windows 8.1" "Windows 10" "Windows 11"

注意事項

使用できないメソッド

CompareInfo.IndexOfメソッドのようにインデックスを返すメソッドで使用した場合、実行時にエラーが発生します。CompareInfo.IndexOfメソッド以外だとCompareInfo.IsPrefixメソッドやCompareInfo.IsSuffixメソッドなどもエラーが発生するようです。

エラーが発生するケース
CompareInfo compareInfo = CultureInfo.InvariantCulture.CompareInfo;

// 以下のメソッドなどは、実行時にエラーが発生します。
// System.ArgumentException: Value of flags is invalid. (Parameter 'options')
if (compareInfo.IndexOf("1", "01", CompareOptions.NumericOrdering) >= 0)
{
    // ...
}

// System.ArgumentException: Value of flags is invalid. (Parameter 'options')
if (compareInfo.IsPrefix("1", "01", CompareOptions.NumericOrdering))
{
    // ...
}

// System.ArgumentException: Value of flags is invalid. (Parameter 'options')
if (compareInfo.IsSuffix("1", "01", CompareOptions.NumericOrdering))
{
    // ...
}

環境によって動作が異なるケース

異なる言語/文字体系の同等の数字で比較しても、同じ数値として判定されるようです。
ただし、CompareOptions.NumericOrderingフィールドは、ランタイム内部で使用しているグローバリゼーション(国際化対応)のライブラリ依存のため、各プラットフォームや明示的に使用するライブラリを変更すると動作が異なります。

https://github.com/dotnet/runtime/pull/109861
https://learn.microsoft.com/ja-jp/dotnet/core/extensions/globalization-icu#use-nls-instead-of-icu

例えば、"1"とインド数字の1である"١"を比較するとCompareOptions.NumericOrderingフィールドを普通に使った場合、同じ数値として認識されます。

通常の動作
StringComparer numericOrderingComparer = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering);

// 1 は通常の数字
// ١ はアラビア語/インド語数字
if (numericOrderingComparer.Equals("1", "١"))
{
    // ここに到達する。
    Console.WriteLine("\"1\" と \"١\" は同じ判定");
}

次にグローバリゼーション(国際化対応)のライブラリを.NET標準のICUからレガシー版(.NET Frameworkで使用されてきたWindows 組み込み多言語対応データ)であるNLSに切り替えて動作を確認します。
変更方法はいくつかありますが、ここでは環境変数に

  • DOTNET_SYSTEM_GLOBALIZATION_USENLS=1

を設定し、レガシー版であるNLSを使用するようにします。
Visual Studioを使う場合には、デバッグプロファイルの環境変数に追加するだけで問題ありません。

VisualStudioでのDOTNET_SYSTEM_GLOBALIZATION_USENLSの設定例

設定して実行した場合、先ほどと同じコードを実行しても、"1"とインド数字の1である"١"が違う数値として認識されるため、動作が変更されます。

DOTNET_SYSTEM_GLOBALIZATION_USENLS=1の場合
StringComparer numericOrderingComparer = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering);

// 1 は通常の数字
// ١ はアラビア語/インド語数字
if (numericOrderingComparer.Equals("1", "١"))
{
    // ここに到達しない!
    Console.WriteLine("\"1\" と \"١\" は同じ判定");
}

それ以外にもWASM環境やiOSなどのApple系プラットフォームでも一部制限やサポートされていない場合もあるようなので、注意していきましょう。

https://github.com/dotnet/runtime/issues/109999

ベンチマーク

あとは、どれくらい他のオプションと比べて遅いか確認するため、1000件のforループを通常のループと各オプション指定のEnumerable.Order(デフォルト、StringSort、NumericOrdering)の4つで簡易比較しました。

ソースコード
var summaries = BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args).ToArray();

[MemoryDiagnoser]
public class CompareOptionsBenchmarks
{
    private static readonly StringComparer StringSortComparer = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.StringSort);
    private static readonly StringComparer NumericOrderingComparer = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering);

    private string[] numbers;

    [GlobalSetup]
    public void GlobalSetup()
    {
        numbers = Enumerable.Range(1, 1000).Select(i => $"Number {i}").ToArray();
    }

    [Benchmark]
    [MemoryRandomization]
    public int Default()
    {
        var count = 0;
        foreach (var number in numbers)
        {
            count++;
        }
        return count;
    }

    [Benchmark]
    [MemoryRandomization]
    public int Order_Default()
    {
        var count = 0;
        foreach (var number in numbers.Order())
        {
            count++;
        }
        return count;
    }

    [Benchmark]
    [MemoryRandomization]
    public int Order_StringSort()
    {
        var count = 0;
        foreach (var number in numbers.Order(StringSortComparer))
        {
            count++;
        }
        return count;
    }

    [Benchmark]
    [MemoryRandomization]
    public int Order_NumericOrdering()
    {
        var count = 0;
        foreach (var number in numbers.Order(NumericOrderingComparer))
        {
            count++;
        }
        return count;
    }

}

検証環境

  • Windows 11 x64
  • .NET 10( 10.0.101 )

計測結果

Method Mean Error StdDev Gen0 Allocated
Default 225.4 ns 4.42 ns 5.09 ns - -
Order_Default 331,958.0 ns 6,887.05 ns 20,306.62 ns 1.4648 12288 B
Order_StringSort 327,930.5 ns 6,473.42 ns 16,825.27 ns 1.4648 12256 B
Order_NumericOrdering 758,426.5 ns 15,151.64 ns 35,116.24 ns 0.9766 12256 B

結果は、想定通りですが、CompareOptions.NumericOrderingフィールドが一番遅い結果になりました。まあ、実際に高パフォーマンスを意識するような実装では使用しにくいですが、アプリケーション開発ではそこまで気にしない程度だと思うので、十分使用できると思われます。

まとめ

CompareOptions.NumericOrderingフィールド自体は、2015年ぐらいからすでにissueで議論がされてきました。(かなり長い..)

https://github.com/dotnet/runtime/issues/13979

結構前から議論されてきた機能が、.NET 10 でようやく実装されたということなので、ぜひみなさん .NET 10を使っていきましょう!

Discussion