コードレベルの互換性を保ちつつ引数を増やす方法として省略可能引数を利用するという方法があります。ですが、末尾に CancellationToken を受け取りたい場合など、引数の最後に省略可能引数を追加できず、途中に追加したいというケースもなくはないと思います。そういった場合に、新たなシグネチャのメソッドに段階的に移行するための Tips をご紹介します。1
本題
次のようなソースがあったとします。
var hoge = new Hoge();
await hoge.DoFugaAsync("a", 1, CancellationToken.None);
public class Hoge
{
public async Task DoFugaAsync(string foo, int bar, CancellationToken cancellationToken)
{
await Task.Yield();
}
}
ここで、ある機能拡張によって int bar の後ろに bool? baz = null を増やしたいとします。
ただし、省略可能引数としているように、baz を指定したいのは特定の値のケースのみという事情があったとします。つまり、既存の呼び出し箇所においては、foo と bar と cancellationToken のみを指定した呼び出しをしたくて、baz は必要に応じて指定したいとします。
一方で、
Task DoFugaAsync(string foo, int bar, CancellationToken cancellationToken);
Task DoFugaAsync(string foo, int bar, bool? baz = null, CancellationToken cancellationToken);
なるオーバーロードが存在するのは紛らわしく煩雑なので、前者(旧シグネチャ)は Obsolete にして、後者(新シグネチャ)へ段階移行したいというシーンを考えてください。2
このとき、普通にオーバーロードの片方を Obsolete にしても思うようにいきません。
var hoge = new Hoge();
await hoge.DoFugaAsync("a", 1, cancellationToken: CancellationToken.None); // baz を指定しないと警告が出る
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~//
public class Hoge
{
[Obsolete]
public Task DoFugaAsync(string foo, int bar, CancellationToken cancellationToken)
=> DoFugaAsync(foo, bar, null, cancellationToken);
public async Task DoFugaAsync(string foo, int bar, bool? baz = null, CancellationToken cancellationToken = default)
{
await Task.Yield();
}
}
思うようにいかないというのは、baz を指定しないと Obsolete 側のオーバーロードへ呼び出しが解決されてしまうということです。
これは、オーバーロード解決の優先順として「引数の数が一致するもの」が優先されてしまうためです。
やりたいことは
-
bazが必要ないケースでは指定しないで済ませたい - 従来のメソッドは
Obsoleteにして新メソッドへ移行を促したい - すなわち、
cancellationToken:を付けた個所は移行完了とし、そうでない箇所は警告としたい
ということです。
これを実現するには、オーバーロード解決のルールインスタンスメソッド優先を利用します。
var hoge = new Hoge();
await hoge.DoFugaAsync("a", 1, CancellationToken.None); // 警告
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~//
await hoge.DoFugaAsync("a", 1, cancellationToken: CancellationToken.None); // 警告されない(インスタンスメソッド側が呼ばれる)
public class Hoge
{
public async Task DoFugaAsync(string foo, int bar, bool? baz = null, CancellationToken cancellationToken = default)
{
await Task.Yield();
}
}
public static class HogeExtensions
{
// Obsolete 側を拡張メソッドにして優先順位を下げる
[Obsolete]
public static Task DoFugaAsync(this Hoge hoge, string foo, int bar, CancellationToken cancellationToken)
=> hoge.DoFugaAsync(foo, bar, null, cancellationToken);
}
このように旧シグネチャは拡張メソッドとすることで、名前付き引数にしない状態では Obsolete な拡張メソッドが呼ばれ、名前付き引数で CancellationToken を渡すことでインスタンスメソッドである新メソッドが呼ばれるようになります。
一点、この方法はコードレベルの互換性はあるものの、アセンブリレベルでの互換性は維持されないため、メソッドの定義側と呼び出し側でアセンブリが分かれている、かつ定義側のアセンブリのみを差し替えたいケースでは利用できません。MissingMethodException がスローされる(はず)ので気を付けましょう。3
-
これはあくまで私が突発的に考えた方法なので、もしかしたらもっと良いやり方があるかもしれません。コメントお待ちしております。 ↩
-
今回であれば
Task DoFugaAsync(string foo, int bar, CancellationToken cancellationToken)とTask DoFugaAsync(string foo, int bar, bool baz, CancellationToken cancellationToken)のオーバーロードでいいじゃないかという意見もあると思いますが、例えばbool? baz = null, bool? qux = null, string quux = nullのように複数の省略可能引数を追加したく、かつその一部だけ指定するケースがある、などの場合にはオーバーロードでの対応は難しいと思います。また、引数をFugaParameterのようなクラスにする方法もあります。今回はクラスを定義せずに省略可能引数を使いたいケースを想定しています。 ↩ -
他にも、
DoFugaAsyncが仮想メソッドでオーバーライドされている可能性がある場合は挙動に差異が出る可能性があるなど、厳密には等価ではないため注意が必要です。 ↩

