C# で Windows エクスプローラー風の並び順を実装する(StrCmpLogicalW と IComparer)

既定の文字列ソートでは "item10" が "item2" より前に並んでしまう。本記事では Win32 API の StrCmpLogicalW を P/Invoke で呼び出し、IComparer を実装したクラスとしてエクスプローラーに近い自然順ソートを実現する方法を、メリット・デメリットとともに解説する。

概要

Windows エクスプローラーでファイル名を並べると、item1 item2 item10 のように数字部分が数値として扱われた「自然順(logical order)」で表示される。
一方、.NET の既定の文字列ソートでは item1 item10 item2 の順になり、エクスプローラーと並びが一致しない。
本記事では、Win32 API の StrCmpLogicalW を P/Invoke で呼び出し、IComparer<string>(および非ジェネリックの IComparer)を実装したクラスとして、エクスプローラーに近い並び順を実現する方法を扱う。
実装手順に加え、この方式のメリット・デメリットと代替案との比較も整理する。


前提・対象環境


問題

ファイル名やキー文字列を昇順で並べたいだけなのに、既定のソートではエクスプローラーと異なる並びになる。

var files = new List<string> { "item10", "item2", "item1", "item20", "item3" };
files.Sort();
// 実際の結果: item1, item10, item2, item20, item3
// 期待する結果: item1, item2, item3, item10, item20

List<T>.Sort()Comparer<string>.Default を用いるが、これは数字を「文字の並び」として比較する。
そのため item10item2 を比べると、5 文字目の 12 の大小で item10 が先に来てしまう。

同じ 5 件の文字列を 2 通りの方法で並べ替えた結果。既定のソートでは item1, item10, item2, item20, item3 の順、StrCmpLogicalW では item1, item2, item3, item10, item20 の順になっている。
上のコードと同じ入力を、既定の比較(左)と StrCmpLogicalW(右)で並べ替えた実行結果。右がエクスプローラーと同じ並びである。

原因・背景

通常の文字列比較は、この例のような ASCII の数字を含む文字列では、序数比較・カルチャ比較のいずれでも先頭から順に文字を突き合わせて大小を決める。
item10item2 は先頭の item まで等しく、次の文字が 1(U+0031)と 2(U+0032)である。
12 より小さいため、後続に何桁続こうと item10item2 より前と判定される。

これに対しエクスプローラーは、文字列中の連続した数字を 1 つの数値としてまとめて比較する。
この「数字を数値として扱う」比較を提供するのが、シェルが内部で使う Win32 API の StrCmpLogicalWshlwapi.dll)である。
StrCmpLogicalW は 2 つの Unicode 文字列を比較し、等しければ 0、第 1 引数が大きければ 1、小さければ -1 を返す。
比較は大文字小文字を区別しない。


解決方法

StrCmpLogicalWDllImport で P/Invoke 宣言し、IComparer<string> を実装したクラスの Compare メソッド内から呼び出す。
IComparer<string> を実装しておけば、List<T>.Sort にも LINQ の OrderBy にもそのまま渡せる。
非ジェネリックの IComparer も併せて実装しておくと、ListCollectionView など古い API からも利用できる。


実装例

以下は StrCmpLogicalW を包んだ比較クラスである。
shlwapi.dll の関数を Unicode 版として厳密に束縛するため、CharSet.UnicodeExactSpelling = true を指定する。
null を渡すと未定義動作となり得るため、比較前に null を明示的に処理する。

using System;
using System.Collections;
using System.Collections.Generic;
using System.Runtime.InteropServices;

public sealed class NaturalStringComparer : IComparer<string>, IComparer
{
    // shlwapi.dll の StrCmpLogicalW を P/Invoke で束縛する。
    // 数字の並びを数値として扱い、エクスプローラーに近い論理順で比較する。
    [DllImport("shlwapi.dll", CharSet = CharSet.Unicode, ExactSpelling = true)]
    private static extern int StrCmpLogicalW(string psz1, string psz2);

    // 状態を持たないため、共有インスタンスを 1 つ使い回せば十分である。
    public static NaturalStringComparer Instance { get; } = new NaturalStringComparer();

    public int Compare(string x, string y)
    {
        // null を API へ渡さないよう、事前に順序を確定させる。
        if (ReferenceEquals(x, y)) return 0;
        if (x is null) return -1;
        if (y is null) return 1;
        return StrCmpLogicalW(x, y);
    }

    // 非ジェネリック版。ListCollectionView など古い API 向け。
    // 型不一致は as で null に潰さず、契約どおり ArgumentException とする。
    int IComparer.Compare(object x, object y)
    {
        if (x != null && !(x is string)) throw new ArgumentException("string 型が必要である。", nameof(x));
        if (y != null && !(y is string)) throw new ArgumentException("string 型が必要である。", nameof(y));
        return Compare((string)x, (string)y);
    }
}

このクラスは状態を持たないため、Instance を使い回してよい。
実際のソートでは、List<T>.Sort と LINQ の OrderBy のいずれにも同じインスタンスを渡せる。

var files = new List<string> { "item10", "item2", "item1", "item20", "item3" };

// List<T>.Sort に IComparer<string> を渡してその場で並べ替える。
files.Sort(NaturalStringComparer.Instance);
// 結果: item1, item2, item3, item10, item20

// LINQ で新しいシーケンスとして並べ替える場合。
var ordered = files.OrderBy(f => f, NaturalStringComparer.Instance).ToList();
// 結果: item1, item2, item3, item10, item20

OrderBy の第 2 引数は IComparer<TKey> を受け取るため、キーセレクターで対象の文字列を返せば同じ比較器を再利用できる。
なお OrderByToList を使うには using System.Linq; が必要である。


注意点


代替案・比較

方法 メリット デメリット 適するケース
StrCmpLogicalW(本記事) エクスプローラーに近い並びが得られる・実装が数行で済む Windows 専用・ロケールに基づく言語的照合ではない・大文字小文字非区別・挙動がリリース間で変わり得る Windows デスクトップアプリでシェルと並びを揃えたい
自前の自然順比較(数字トークンを分割して数値比較) クロスプラットフォーム・挙動を完全に制御できる 実装量が多く、桁あふれや先頭ゼロなどの考慮が必要 .NET 5+ のクロスプラットフォーム環境
StringComparer.Ordinal 標準・高速でカルチャに依存しない安定した順序 数字を数値として扱わず自然順にならない 安定した順序が必要な内部データの照合
StringComparer.CurrentCulture 言語的に自然な照合が得られる カルチャに依存し順序が環境で変わる・数字を数値として扱わない ユーザー向け表示テキストの照合

まとめ

Windows デスクトップアプリで、ファイル一覧などをエクスプローラーに近い並び順にしたい場合は、StrCmpLogicalWIComparer<string> 実装で包む方式が最も手軽である。
数行のコードでシェルに近い並びが得られ、List<T>.Sort にも OrderBy にもそのまま渡せる。
ただし公式には canonical sorting 用途は非推奨とされ、挙動がリリース間で変わり得る点は前提として押さえておく。
一方、クロスプラットフォームでの動作や、大文字小文字の区別、永続化するキーの安定した順序が必要な場合は、この API の制約(Windows 専用・ロケールに基づく言語的照合ではない・リリース間で挙動が変わり得る)が問題になる。
その場合は数字トークンを自前で分割して数値比較する実装を選ぶ。
見た目をエクスプローラーに近づけることを最優先するなら StrCmpLogicalW、移植性と制御性を優先するなら自前実装、という基準で選択する。