自然順（エクスプローラー互換）のソートコマンドをC#でささっと作る

# 0. はじめに

下記の記事の執筆過程において，Windows 付属の `SORT` コマンドに重複行を削除する `/UNIQUE` オプションがいつの間にか追加されていることを知った。

[今さら森博嗣「笑わない数学者」のビリヤードの問題を解く(13)～総括編](https://qiita.com/TETSURO1999/items/b1126f8dbdc2d946b8b9)

以下はその使用例である。`AnotherSolver2` コマンドは自作の計算プログラムであり，問題の解の一つを与えると他の解を求めてくれるというものだ。ただし重複する解を多数含んでおり，これらの重複解を削除するために `SORT` コマンドを使用した。

```:SORT コマンドの隠しオプション /UNIQUE を使う
AnotherSolver2 /R 1 2 9 8 14 4 43 7 6 10 5 24 | sort /unique
1 14 10 20 7 6 3 2 17 4 8 41
1 14 3 2 4 7 21 8 25 10 12 26
1 15 5 3 25 2 7 4 6 12 14 39
1 2 12 31 25 4 9 10 7 11 16 5
1 2 14 12 32 19 6 5 4 18 13 7
1 2 14 4 37 7 8 27 5 6 13 9
1 2 9 8 14 4 43 7 6 10 5 24
1 22 14 20 5 13 8 3 4 2 10 31
1 3 12 34 21 2 8 9 5 6 7 25
1 3 23 24 6 22 10 11 18 2 5 8
1 3 8 9 5 19 23 16 13 2 28 6
1 4 16 3 15 10 12 14 17 33 2 6
1 4 19 20 27 3 6 25 7 8 2 11
1 4 20 3 40 10 9 2 15 16 6 7
1 4 7 3 16 2 6 17 20 9 13 35
1 5 12 21 29 11 3 16 4 22 2 7
1 7 13 12 3 11 5 18 4 2 48 9
1 8 10 5 7 21 4 2 11 3 26 35
```

だがしかし，数値を単なる文字列として比較してしまっており，ソート順序が不自然で美しくない。ということで，自然な順のソートコマンドをささっと作ることにした。

# 1. 仕様案（お品書き）

- 入力ファイル名
  標準入力からリダイレクトされると標準入力から読み込む。入力ファイル名を指定すると指定した入力ファイルから読み込む。リダイレクトと入力ファイル名の指定が重なっている場合はリダイレクトを優先する。いずれも指定されていない場合はヘルプメッセージを表示する。
- 出力ファイル名
  ソート結果を指定した出力ファイルに書き込む。出力ファイル名の指定は `/O ファイル名` とする。オプション `/O` とファイル名の間にはスペースを入れる。これはコマンドラインから入力する際にファイル名補完機能を使えるようにするため。出力ファイル名の指定を省略すると標準出力に出力する。
- 文字コードの指定
  デフォルトではシステム規定値（日本語版 Windows だと Shift-JIS）に従うが，オプション `/UTF8` の指定により UTF8 に変更できる。
- 安定ソート
  自然順ソートもしくは大文字・小文字を区別しない場合，異なる文字列でも比較関数は一致とみなす場合がある。その場合，元の順序を保存するようにしたい。
- 逆順ソート
  オプション `/R` の指定により，逆順にソートできる。
- 重複行の削除
  オプション `/UNIQ` の指定により，重複行を削除できる。

# 2. ソート関数

手早く作りたいので Win32 API の `StrCmpLogicalW` を用いる。

- 文字列内の数字はテキストではなく数値とみなす。
- 大文字と小文字は区別しない。

自然順ソートと呼んでいるが，Windows エクスプローラーのファイル名のソート順でもあるため，筆者はエクスプローラー互換ソートとも呼んでいる。

```:StrCmpLogicalW によるソート例
2string
3string
20string
st2ring
st3ring
st20ring
string2
string3
string20
```

:::note
マイクロソフトによれば今後仕様は変わる可能性があり，正規の並べ替え用途には使用すべきではないとのこと。
:::

# 3. 実装方法

## 3.1 入力ファイルの読み込み

標準入力からのリダイレクトの場合もファイルからの読み込みの場合も同様に `StreamReader` としてオープンし，一行ずつ読み込んでリスト `list` に追加する。

```c#:入力ファイルの読み込み
var	list = new List<string>();
while( !reader.EndOfStream ) {
	string	s = reader.ReadLine();
	list.Add( s );
}
```

## 3.2 インデクス配列を作る

読み込んだリストを直接ソートするのではなく，行番号に相当するインデクス配列をソートすることにする。※安定ソートにするため

```c#:インデクス配列の生成
var	index = new int[list.Count];
for( int i = 0; i < index.Length; i++ )
	index[i] = i;
```

## 3.3 ソート本体

Win32 API の `StrCmpLogicalW` のラッパクラスである。

```c#:Win32 API の呼び出し
class LogicalStringComparer : IComparer<string> {
	[DllImport("shlwapi.dll", CharSet = CharSet.Unicode)]
	extern	static	int	StrCmpLogicalW( string s1, string s2 );
	public	int	Compare( string s1, string s2 ) {
		return StrCmpLogicalW( s1, s2 );
	}
}
```

文字列が等しい（と判断される）ときはインデクスの値で比較を行うため，安定ソートになる。※インデクスの値が一致することはない。

```c#:ソート本体
var	logical = new LogicalStringComparer();
Comparison<int>	compare = delegate( int a, int b ) {
	int	c = logical.Compare( list[a], list[b] );
	if( c != 0 ) return( c );
			else return( a - b );
};
Array.Sort( index, compare );
```

逆順の場合はインデクス配列を入れ替える。

```c#:逆順の場合
if( reverse_mode ) Array.Reverse( index );
```

## 3.4 ソート結果の出力

ソートした結果をそのまま出力する場合と重複行を削除する場合の２通りに対応する。

```c#:出力ファイルへの書き込み
if( unique_mode ) {
	string	s0 = null;
	for( int i = 0; i < index.Length; i++ ) {
		string	s1 = list[index[i]];
		if( logical.Compare( s0, s1 ) != 0 ) writer.WriteLine( s1 );
		s0 = s1;
	}
} else {
	for( int i = 0; i < index.Length; i++ ) {
		string	s1 = list[index[i]];
		writer.WriteLine( s1 );
	}
}
```

# 4. 実装コード

実装コードを以下に示す。エクスプローラー互換ソート（Explorer style SORT）なので `ESORT` と名付けた。

<details><summary><b>ESORT.CS の実装コードはコチラ</b></summary>

```c#:ESORT.CS
//------------------------------------------------------------------------------
// テキストファイルを自然順に（エクスプローラ互換で）ソートします。
//------------------------------------------------------------------------------
using System;
using System.IO;
using System.Text;
using System.Reflection;
using System.Collections.Generic;
using System.Runtime.InteropServices;
//------------------------------------------------------------------------------
// 論理ソートクラス
//------------------------------------------------------------------------------
class LogicalStringComparer : IComparer<string> {
	[DllImport("shlwapi.dll", CharSet = CharSet.Unicode)]
	extern	static	int	StrCmpLogicalW( string s1, string s2 );
	public	int	Compare( string s1, string s2 ) {
		return StrCmpLogicalW( s1, s2 );
	}
}
//------------------------------------------------------------------------------
// 自然順ソートクラス
//------------------------------------------------------------------------------
class ESORT {
	//--------------------------------------------------------------------------
	// グローバル変数
	//--------------------------------------------------------------------------
	static	string	 input_filename = "";				// 入力ファイル名
	static	string	output_filename = "";				// 出力ファイル名
	static	Encoding       encoding = Encoding.Default;	// 文字コード
	static	bool	   reverse_mode = false;			// 逆順に表示する
	static	bool		unique_mode = false;			// 重複行を削除する
	static	bool	 overwrite_mode = false;			// 出力ファイルへの上書を許可
	//--------------------------------------------------------------------------
	// ヘルプメッセージの表示
	//--------------------------------------------------------------------------
	static	int		Usage() {
		Console.Error.WriteLine( "テキストファイルを自然順に（エクスプローラ互換で）ソートします。" );
		Console.Error.WriteLine( "" );
		Console.Error.WriteLine( "ESORT(.EXE) (オプション) [入力ファイル名]" );
		Console.Error.WriteLine( "" );
		Console.Error.WriteLine( " 　[入力ファイル名]　入力ファイル名を指定します。" );
		Console.Error.WriteLine( " 　 　　　　　　　 　省略すると標準入力から入力します。" );
		Console.Error.WriteLine( "" );
		Console.Error.WriteLine( "/O [出力ファイル名]　出力ファイル名を指定します。" );
		Console.Error.WriteLine( "    　　　　　　　 　省略すると標準出力に出力します。" );
		Console.Error.WriteLine( "" );
		Console.Error.WriteLine( "   /R  逆順で表示します。" );
		Console.Error.WriteLine( "   /Y  出力ファイルへの上書きを許可します。" );
		Console.Error.WriteLine( "/UNIQ  重複する行を削除します。" );
		Console.Error.WriteLine( "/UTF8  文字コードを UTF8 に変更します。" );
		return( -1 );
	}
	//--------------------------------------------------------------------------
	// オプション解析
	//--------------------------------------------------------------------------
	static	int		GetOption( string[] args ) {
		for( int i = 0; i < args.Length; i++ ) {
			string	s = args[i];
			if( s[0] == '/' || s[0] == '-' ) {
				string	t = s.Substring( 1 );
				if( 0 == string.Compare( t, "O", true ) ) {
					if( output_filename != "" ) {
						Console.Error.WriteLine( "出力ファイル名は既に指定されています!!" );
						return( -1 );
					} else if( i >= args.Length ) {
						Console.Error.WriteLine( "出力ファイル名の指定がありません!!" );
						return( -1 );
					} else {
						output_filename = args[++i];
					}
				} else if( 0 == string.Compare( t, "R",    true ) ) {   reverse_mode = true;
				} else if( 0 == string.Compare( t, "Y",    true ) ) { overwrite_mode = true;
				} else if( 0 == string.Compare( t, "UTF8", true ) ) {       encoding = Encoding.UTF8;
				} else if( 0 == string.Compare( t, "UNIQ", true ) ) {    unique_mode = true;
				} else {
					Console.Error.WriteLine( "オプション {0} には対応していません!!", s );
					return( -1 );
				}
			} else if( input_filename != "" ) {
				Console.Error.WriteLine( "入力ファイル名は既に指定されています!!" );
				return( -1 );
			} else {
				input_filename = s;
			}
		}
		return( 0 );
	}
	//--------------------------------------------------------------------------
	// メイン関数
	//--------------------------------------------------------------------------
	static	int		Main( string[] args ) {
		//----------------------------------------------------------------------
		// ヘルプメッセージ
		//----------------------------------------------------------------------
		bool	redirect = Console.IsInputRedirected;
		if( !redirect && args.Length == 0 ) return Usage();
		//----------------------------------------------------------------------
		// オプション解析
		//----------------------------------------------------------------------
		if( GetOption( args ) != 0 ) return( -1 );
		//----------------------------------------------------------------------
		// 入力ファイルのオープン
		//----------------------------------------------------------------------
		StreamReader	reader;
		if( redirect ) {
			reader = new StreamReader( Console.OpenStandardInput(), encoding );
		} else if( input_filename == "" ) {
			Console.Error.WriteLine( "入力ファイルの指定がありません!!" );
			return( -1 );
		} else if( !File.Exists( input_filename ) ) {
			Console.Error.WriteLine( "入力ファイル {0} は存在しません!!", input_filename );
			return( -1 );
		} else {
			reader = new StreamReader( input_filename, encoding );
		}
		//----------------------------------------------------------------------
		// 入力ファイルの読み込み
		//----------------------------------------------------------------------
		var	list = new List<string>();
		while( !reader.EndOfStream ) {
			string	s = reader.ReadLine();
			list.Add( s );
		}
		//----------------------------------------------------------------------
		// 入力ファイルのクローズ
		//----------------------------------------------------------------------
		reader.Close();
		//----------------------------------------------------------------------
		// テキストのソート
		//----------------------------------------------------------------------
		var	logical = new LogicalStringComparer();
		var	index = new int[list.Count];
		for( int i = 0; i < index.Length; i++ )
			index[i] = i;
		Comparison<int>	compare = delegate( int a, int b ) {
			int	c = logical.Compare( list[a], list[b] );
			if( c != 0 ) return( c );
					else return( a - b );
		};
		Array.Sort( index, compare );
		if( reverse_mode ) Array.Reverse( index );
		//----------------------------------------------------------------------
		// 出力ファイルのオープン
		//----------------------------------------------------------------------
		StreamWriter	writer;
		if( output_filename == "" ) {
			writer = new StreamWriter( Console.OpenStandardOutput(), encoding );
		} else if( File.Exists( output_filename ) && !overwrite_mode ) {
			Console.Error.WriteLine( "出力ファイル {0} は存在します!!", output_filename );
			return( -1 );
		} else {
			writer = new StreamWriter( output_filename, false, encoding );
		}
		//----------------------------------------------------------------------
		// 出力ファイルへの書き込み
		//----------------------------------------------------------------------
		if( unique_mode ) {
			string	s0 = null;
			for( int i = 0; i < index.Length; i++ ) {
				string	s1 = list[index[i]];
				if( logical.Compare( s0, s1 ) != 0 ) writer.WriteLine( s1 );
				s0 = s1;
			}
		} else {
			for( int i = 0; i < index.Length; i++ ) {
				string	s1 = list[index[i]];
				writer.WriteLine( s1 );
			}
		}
		//----------------------------------------------------------------------
		// 出力ファイルのクローズ
		//----------------------------------------------------------------------
		writer.Close();
		return( 0 );
	}
}
```

</details>

# 5. 実行結果

引数なしで実行するとヘルプメッセージを表示する。

```:引数なしで実行した場合
esort
テキストファイルを自然順に（エクスプローラ互換で）ソートします。

ESORT(.EXE) (オプション) [入力ファイル名]

 　[入力ファイル名]　入力ファイル名を指定します。
 　 　　　　　　　 　省略すると標準入力から入力します。

/O [出力ファイル名]　出力ファイル名を指定します。
    　　　　　　　 　省略すると標準出力に出力します。

   /R  逆順で表示します。
   /Y  出力ファイルへの上書きを許可します。
/UNIQ  重複する行を削除します。
/UTF8  文字コードを UTF8 に変更します。
```

自然順（エクスプローラー互換）でソートする。

```:自然順ソートの場合
AnotherSolver2 /R 1 2 9 8 14 4 43 7 6 10 5 24 | esort /uniq
1 2 9 8 14 4 43 7 6 10 5 24
1 2 12 31 25 4 9 10 7 11 16 5
1 2 14 4 37 7 8 27 5 6 13 9
1 2 14 12 32 19 6 5 4 18 13 7
1 3 8 9 5 19 23 16 13 2 28 6
1 3 12 34 21 2 8 9 5 6 7 25
1 3 23 24 6 22 10 11 18 2 5 8
1 4 7 3 16 2 6 17 20 9 13 35
1 4 16 3 15 10 12 14 17 33 2 6
1 4 19 20 27 3 6 25 7 8 2 11
1 4 20 3 40 10 9 2 15 16 6 7
1 5 12 21 29 11 3 16 4 22 2 7
1 7 13 12 3 11 5 18 4 2 48 9
1 8 10 5 7 21 4 2 11 3 26 35
1 14 3 2 4 7 21 8 25 10 12 26
1 14 10 20 7 6 3 2 17 4 8 41
1 15 5 3 25 2 7 4 6 12 14 39
1 22 14 20 5 13 8 3 4 2 10 31
```

# 6. 参考文献

- [Learn/Windows Server/sort - microsoft](https://learn.microsoft.com/ja-jp/windows-server/administration/windows-commands/sort)
- [StrCmpLogicalW 関数 (shlwapi.h) - microsoft](https://learn.microsoft.com/ja-jp/windows/win32/api/shlwapi/nf-shlwapi-strcmplogicalw)
- [C# 自然順で文字列をソートする - Qiita](https://qiita.com/tomochan154/items/1a3048f2cd9755233b4f)
- [64bit版VBAで自然順ソート (StrCmpLogicalW) - Qiita](https://qiita.com/whalesky/items/59ef47e844736b834092)
- [Windowsのソートを実装する (Python,Windows) - Qiita](https://qiita.com/dengax/items/d20deab8262d5fa4559d)
- [安定ソートアルゴリズム - Qiita](https://qiita.com/taqu/items/5034e5163e93ea54cd80)
- [JavaScript で安定ソート - Qiita](https://qiita.com/odiak/items/00ed21c5874d1bec176a)