久々に趣味コーディングでC#使ってたら、予想外の所でハマったので、ハマった所や調べた事、その他諸々を纏めてみます。
■やりたかった事と、ハマった所
やりたかったこと
- パスを相対パスか絶対パスか判定
- パスを連結
使ったもの
- Path.IsPathRooted
- Path.Combine
まぁ普通。
初歩的すぎて今更こんな所で躓くとは夢にも思ってなかったが、実際これを使って実装したら思いっきりバグってしまい、色々と調べる事に。。。
ツールの概要とハマったこと
大量にある画像ファイルをバッチ的に編集処理するツールを作ろうとしてて、編集した画像ファイルの保存先をオプション指定出来るようにしてたんだけど、その辺のパス関連処理で思いっきりバグりました。
具体的には、編集した画像ファイルの保存先として、以下の3つの機能を設けていました。
- 指定なし :元のファイルのある所と同じところに出力
- 絶対パス指定:指定されたパスに出力
- 相対パス指定:元のファイルの有る所のサブフォルダに出力
で、これを一つの「出力先パラメータ」設定でこの挙動を切り替えようとしていた訳です。
擬似コードで示すと、以下のようなロジックです。
parameter path; // 出力先パラメータ
parameter file; // 編集元ファイル
if ( path is ブランク )
{
// 1:元ファイルと同じ所に出力。
return file.Directory;
}
else if ( path is 絶対パス )
{
// 2:指定されたパスに出力。
return path;
}
else if ( path is 相対パス )
{
// 3:元ファイルのあるフォルダから見た相対パスのサブフォルダに出力。
return file.Directory + path;
}
で、この「絶対パスか否か」の判定に Path.IsPathRooted を、
「パスの連結」の処理に Path.Combine を、それぞれ使用した訳です。
誰だってそーする、俺だってそーする。
が、結果として**「相対パス」のつもりで与えていたパラメータに対して、思った通りに動作してくれませんでした。**
■問題の非直感的な動作に関して
ツールのコードでは問題が解り難くなるので、シンプルな検証コードを書いてきました。
Path.IsPathRootedの非直感的な動作
幾つかのパス文字列のパターンで検証。
コメントに書いているのは実行結果ではなく 「期待する挙動」 です。
★マークが付いているのは 「実行結果が期待と異なるケース」 です。
// IsPathRooted でチェックするパス文字列
string[] paths =
{
@"C", // 相対パス
@"C:", // 絶対パス
@"C:\", // 絶対パス
@"C:\hoge", // 絶対パス
@"moge", // 相対パス
@"\moge", // 相対パス ★
@"/moge", // 相対パス ★
@"\\unc\piyo", // 絶対パス
@"//unc/piyo", // 絶対パス
@"http://", // おまけ(一応、絶対パスかなぁ・・・)
};
// それぞれのパス文字列に関して、ルートパスか否か判定。
int maxlen = paths.Select( x => x.Length ).Max(); //出力整形のため。
foreach ( string path in paths )
{
bool isroot = Path.IsPathRooted( path );
Console.WriteLine( $"{path.PadRight(maxlen)} -> {isroot}" );
}
このコードの実行結果がこちら。
C -> False
C: -> True
C:\ -> True
C:\hoge -> True
moge -> False
\moge -> True ・・・★
/moge -> True ・・・★
\\unc\piyo -> True
//unc/piyo -> True
http:// -> False
上記の通り、★マークを付けた \moge
と /moge
のケースで、こちらの期待とは裏腹に true
が返って来ています。
Path.Combineの非直感的な動作
そして、上記 IsPathRooted と同様に Combine の方でも非直感的な動作結果となります。
// Combine で連結するパス文字列
var paths = new[] {
new { path1 = @"C:", path2 = @"Directory\Folder" },
new { path1 = @"C:\", path2 = @"Directory\Folder" },
new { path1 = @"C:\", path2 = @"\Directory\Folder" },
new { path1 = @"C:\Directory", path2 = @"Folder" },
new { path1 = @"C:\Directory\", path2 = @"Folder" },
new { path1 = @"C:\Directory\", path2 = @"\Folder" },
new { path1 = @"C:\", path2 = @"Directory\Folder\" },
new { path1 = @"C:\Directory\", path2 = @"Folder\" },
};
// それぞれのパス文字列に関して、連結結果を確認。
// ※気持ち的には、全てのケースで同じパスになっていて欲しい。
int maxlen1 = paths.Select( x => x.path1.Length ).Max(); // 出力整形のため。
int maxlen2 = paths.Select( x => x.path2.Length ).Max(); // 出力整形のため。
foreach ( var p in paths )
{
string combine = Path.Combine( p.path1, p.path2 );
string p1 = p.path1.PadRight(maxlen1);
string p2 = p.path2.PadRight(maxlen2);
Console.WriteLine( $"{p1} + {p2} => {combine}" );
}
ソース内コメントに書いてある通り、このテストケースの全てに於いて、ユーザの期待としては C:\Directory\Folder
という同じ結果になって欲しいというのが 直感的な期待 となります。
が、実行結果は以下のようになります。
C: + Directory\Folder => C:Directory\Folder
C:\ + Directory\Folder => C:\Directory\Folder
C:\ + \Directory\Folder => \Directory\Folder ★
C:\Directory + Folder => C:\Directory\Folder
C:\Directory\ + Folder => C:\Directory\Folder
C:\Directory\ + \Folder => \Folder ★
C:\ + Directory\Folder\ => C:\Directory\Folder\
C:\Directory\ + Folder\ => C:\Directory\Folder\
末尾の \
の有り無しはまぁ置いといて。
★マークを付けた2ケースに於いて、期待とは異なる残念な結果 になっています。
(一応明記しとくと、バグッてる訳じゃなく、仕様通りの挙動だそうです)
■PathのMSDNリファレンス
では、MSDNのリファレンスを確認してみましょう。
Path.IsPathRooted
メソッド概要:
指定したパス文字列にルートが含まれているかどうかを示す値を取得します。
戻り値:
path にルートが含まれている場合は true。それ以外の場合は false。
そうです。
「パスにルートが含まれているか否かを判定する」と書いてありますが、
「絶対パスか相対パスかを判定する」とは書かれていないのです。
※ここで言う「ルート」ってのが具体的に何の事を意味しているのか、と言うのが重要になりますね。正直、定義が良く解りませんが。
「パスが絶対パスか相対パスか調べたい」と言う要件でぐぐると、割りとすぐに「IsPathRootedメソッドを使え」と言う情報がヒットしますが、 このメソッドは絶対パスか相対パスかを判定するためのものではない と言うのが落とし穴になっている訳です。
Path.Combine
メソッド概要:
2 つの文字列を 1 つのパスに結合します。
戻り値:
結合されたパス。
指定したパスの 1 つが長さ 0 の文字列の場合、このメソッドは別のパスを返します。 path2 に絶対パスが含まれる場合、このメソッドは path2 を返します。
重要なのが、戻り値の説明に書かれた二文目の内容です。
「絶対パスが含まれる場合」とあります。
何となく動作結果から予想付いてましたが、ここでは IsPathRooted で判定している*(若しくはそれと同等の判定処理がある)*事が伺えますね。
※せっかくIsPathRootedメソッドの説明では「絶対パスか否か」と言う名言を避けていたのに、それを使っているCombineメソッドの方で「絶対パスが」と言っちゃってるのは、何と言うかちょっとアレですねって言う話。これではどちらかの説明、或いは実装が誤っていると言わざるを得ない。
余談
Combineの備考欄に何やら重要っぽい事が書かれてるみたいなんですが、どうも機械翻訳がいまいちイケてないみたいなので、あとで英語の原文を読んでみようかと思います。
■Pathクラス実装のコードリーディング
予想だけで記事を書いてもしょうがないので、実際に実装コードを読んでみましょう。
Path.IsPathRooted
まず、問題の IsPathRooted 先生。
if ((length >= 1 && (path[0] == DirectorySeparatorChar || path[0] == AltDirectorySeparatorChar)) || (length >= 2 && path[1] == VolumeSeparatorChar))
return true;
という判定処理がありますね。
ショートコーディング風でちょっと解り難いので、読み易く同等な処理として書き換えてみます。
まずは単純にコード整形。
if ( (length >= 1 && ( path[0] == DirectorySeparatorChar // '\'
|| path[0] == AltDirectorySeparatorChar ) ) // '/'
|| (length >= 2 && path[1] == VolumeSeparatorChar) ) // ':'
return true;
if文の中身がぐちゃぐちゃして気に食わないので、等価なコードに書き換え。
if ( length >= 1 )
{
// 先頭の文字が '\' 若しくは '/' の場合、絶対パスと判定。
if ( path[0] == DirectorySeparatorChar
|| path[0] == AltDirectorySeparatorChar ) return true;
}
if ( length >= 2 )
{
// 二文字目が ':' の場合、絶対パスと判定。
if ( length >= 2 && path[1] == VolumeSeparatorChar ) return true;
}
// 上記2パターン以外は全て相対パスと判定。
return false;
実装コード内の length
に関しては、単に IndexOutOfRangeException に対するガードとしての機能しか無いので、ぶっちゃけ贅肉ですね。
例えば拡張メソッドを使って以下のように書き換えちゃえばコードがスッキリします。(個人的な趣味)
public static char at( this string s, int i, char alter = '\0' )
{
int tail = s.Length - 1;
return i > tail ? alter : s[i];
}
// 先頭の文字が '\' 若しくは '/' の場合、絶対パスと判定。
char c1 = path.at(0);
if ( c1 == DirectorySeparatorChar
|| c1 == AltDirectorySeparatorChar ) return true;
// 二文字目が ':' の場合、絶対パスと判定。
char c2 = path.at(1);
if ( c2 == VolumeSeparatorChar ) return true;
// 上記2パターン以外は全て相対パスと判定。
return false;
※拡張メソッドが有りなら普通に FirstOrDefault 使えばいいじゃーん、って言う話もある。
Path.Combine
次に、内部的に IsPathRooted を呼び出していると予想される Combine 先生。
が、呼び出しているコア処理の CombineNoChecks さん。
if (IsPathRooted(path2))
return path2;
ハイ、予想通りいましたね。
MSDNに記載されていた「path2 に絶対パスが含まれる場合、このメソッドは path2 を返します」は、このコードを和訳したようなものです。
よりストレートに言えば IsPathRooted が true
になる場合、このメソッドは path2
を返します、だ。
繰り返しますが、 IsPathRooted の説明では「絶対パスか否か」を判定するとは言っておらず、「パスにルートを含むか否か」という表現でした。
しかし、これを利用しており、それ以外の条件が付与されていない実装である Combine の説明では「絶対パスが含まれる場合」と言っているので、間接的に IsPathRooted は絶対パスか否かを判定する(目的で使用される)と言っている訳です。
うーん、、、これって一体・・・。^^;
■所感
バグか否かという議論は一旦棚上げにしておくとしても、この IsPathRooted の挙動は 非直感的 であり 一般的な用途に対して不都合がある 実装だと思います。
そして、これがIsPathRootedだけの話であれば良かった。
「絶対パスか否かを判定する」目的のユーティリティを普通に自作して Path.IsPathRooted の使用を開発規約で非推奨にしちまえば話は簡単。
しかし、Combineが内部でIsPathRootedを使用しているので、芋蔓でそっちも書き換えたくなると言うのが厄介な所。
しかもCombineの方は実装が深い上に可変パラメータに対応する為に結構コテコテ書かれているので、代替するメソッドを用意するのも面倒くさい、というね。
ぶっちゃけた話、IsPathRootedが業務開発で出て来る事なんて稀なんで、割りとどうだって良いかなって感じですが、Combineに関しては出て来ない方がむしろ珍しいまである頻出メソッドです。
こっちの方が問題としては結構厄介ですよね。
冒頭でチラッと言いましたけど**「C#でパス結合するならPath.Combineを使うのが常識だ」**というのは良く言われる事だと思います。
数多くの入門サイトでも、この方法が強く推奨されていたり、この実装が一般的だからこれに合わせなさいと書かれていたりする事が殆どです。
あまりにも常識として定着し過ぎており、それを疑ったり検証する事すら稀なレベルなので、お恥ずかしながら自分も今日に至るまでこの問題に気付きませんでした。
■まとめ
自分達の実装しようとしている「目的」に対して、Combine・IsPathRootedの「仕様」がマッチしているかどうか、「手段」として妥当かどうかをきちんと確認しましょう。
そして、悲しいかなそれが手段として妥当でないという結論に至った場合、プロジェクト内の共通実装としてユーティリティを用意して、それを使うように周知徹底しましょう。
※代替となる実装手段は幾つかヒットしますが、既存のライブラリ実装の組み合わせではどれも微妙なクセがあるので、素直に文字列操作として自作するのが良さそうです、、、。
■参考文献
■ちょっと追記。
Javaだとどうなるか?
気になったので、JShellを起動してJavaだとどうなるか試してみた。
相当機能としてはPaths#getだと思うのでこいつを使ってみる。
期待通りにパスが連結されました。
求めている挙動はこういう挙動なので、この目的ではPath.Combineは使えないですね。