OverloadResolutionPriority 属性

C# 13 で、オーバーロードの解決優先度を属性を付けて明示できる機能が入りました。 OverloadResolutionPriority 属性(System.Runtime.CompilerServices 名前空間)を使います。 名前通り優先度を指定できて、正の整数を指定すると優先度が上がって、負の整数なら下がります。

using System.Runtime.CompilerServices;

// IEnumerable<char> の方が選ばれる。
C.M1("");
C.M2("");

class C
{
    // 通常、インターフェイスよりも具体的な型の方が優先。
    public static void M1(string _) { }

    // 属性を付けて優先度を上げる。
    [OverloadResolutionPriority(1)]
    public static void M1(IEnumerable<char> _) { }

    // 属性を付けて優先度を下げる。
    [OverloadResolutionPriority(-1)]
    public static void M2(string _) { }

    public static void M2(IEnumerable<char> _) { }
}

ちなみに、オーバーロードできないメンバーにこの属性を付けるとコンパイル エラーになります。

using System.Runtime.CompilerServices;

namespace System.Runtime.CompilerServices
{
    // .NET 標準ライブラリ中の OverloadResolutionPriorityAttribute には
    // AttributeTargets.Method | Constructor | Property がついてる。
    // ここではあえてターゲットの制限を外した同名・同名前空間の型を定義。
    public sealed class OverloadResolutionPriorityAttribute(int priority) : Attribute
    {
        public int Priority => priority;
    }
}

class C
{
    [OverloadResolutionPriority(0)]
    static C() { }

    [OverloadResolutionPriority(0)]
    ~C() { }

    [OverloadResolutionPriority(0)]
    public int X { get; }

    [OverloadResolutionPriority(0)]
    public static implicit operator int(C x) => default!;
}

互換性問題

C# の言語機能が増えるにつれて、例えば「IEnumerable<T> よりも、ReadOnlySpan<T> 引数を使いたい」みたいなことが多々あります。 しかし、以前からあるメソッドを消すことができなくて、それは残したまま新しいオーバーロードを追加することになったりします。 (ライブラリ作者、特に、プラグイン提供するような場合、バイナリ互換(ソースコードの再コンパイルなしでも動く保証)を残すため、メソッドの削除はできなくなります。) ところが、互換性のために消すに消せない方のメソッドが、優先度が高すぎて困ったり、 オーバーロード解決できなくなって困るということが起こるようになってきました。

IEnumerable<T>ReadOnlySpan<T> の場合、C# 13 時点ではオーバーロード解決できなくなって困ります。 (この2者の問題であれば、C# 14 で Span<T>/ReadOnlySpan<T> の特別扱いが入って問題解消する予定です。)

// C# 13 時点だと IEnumerable と ReadOnlySpan を選べなくてコンパイル エラーになる。
C.M(new int[1]);

class C
{
    public static void M(IEnumerable<int> _) { }

    // ReadOnlySpan は C# 7.2 / .NET Core 2.1 / 2017年頃に入った。
    // パフォーマンス的に有利なので IEnumerable を置き換えたいことがある。
    public static void M(ReadOnlySpan<int> _) { }
}

他に、デフォルト引数が絡んだ場合に困ったりします。 具体的には、Debug.Assert や文字列がらみで困っているみたいです。

Debug.Assert は、C# 10 で導入された CallerArgumentExpression を使いたいものの、既存のオーバーロードに阻害されて呼びようがないという問題が出ています。

var x = int.Parse(Console.ReadLine());

// Debug.Assert(x > 0, "x > 0") になってほしいのに、1引数の方が呼ばれちゃう。
Debug.Assert(x > 0);

// System.Diagnostics.Debug からの抜粋
class Debug
{
    // 元々 bool 1引数のオーバーロードがある。
    public static void Assert(bool condition) { }

    // C# 10 で導入された CallerArgumentExpression を使いたい。
    // けど、 Assert(condition) では1引数オーバーロードの方が優先されて、CallerArgumentExpression が役に立たない。
    public static void Assert(bool condition, [CallerArgumentExpression(nameof(condition))] string? message = null) { }
}

文字列がらみは、 .NET の負の遺産として有名なカルチャー依存問題(参考: 遅い環境依存)への対処として、IndexOf などのメソッドにデフォルト引数 StringComparison comparisonType = StringComparison.Ordinal を付けて、無指定の時の挙動を Ordinal に変えたいという話があります。 しかしこれも、1引数オーバーロードの方が優先度が高くてうまく働きません。

// IndexOf(value, StringComparison.Ordinal) で呼ばれてほしいけど、
// 残念ながら IndexOf(value) にしかならない。
String.IndexOf("àèò", "a");

// 本来は string クラスのインスタンスメソッド。デモ用に静的メソッド。
static class String
{
    // 1引数オーバーロードがいるので…
    public static void IndexOf(this string s, string value) => s.IndexOf(value);

    // デフォルト引数を付けたところで IndexOf(string value) の方が優先される。
    public static void IndexOf(
        this string s, string value,
        StringComparison comparisonType = StringComparison.Ordinal) // Ordinal をデフォルトに変えたい。
        => s.IndexOf(value, comparisonType);
}

これらの問題に OverloadResolutionPriority 属性が使えます。

using System.Runtime.CompilerServices;

C.M(new int[1]); // 無事、ReadOnlySpan の方が選ばれる。

class C
{
    [OverloadResolutionPriority(-1)]
    public static void M(IEnumerable<int> _) { }

    public static void M(ReadOnlySpan<int> _) { }
}
using System.Runtime.CompilerServices;

var x = int.Parse(Console.ReadLine());

// 無事、 Debug.Assert(x > 0, "x > 0") で呼ばれる。
Debug.Assert(x > 0);

class Debug
{
    [OverloadResolutionPriority(-1)]
    public static void Assert(bool condition) { }

    public static void Assert(bool condition, [CallerArgumentExpression(nameof(condition))] string? message = null) { }
}
using System.Runtime.CompilerServices;

// 無事、IndexOf(value, StringComparison.Ordinal) で呼ばれる。
String.IndexOf("àèò", "a");

static class String
{
    [OverloadResolutionPriority(-1)]
    public static void IndexOf(this string s, string value) => s.IndexOf(value);

    public static void IndexOf(
        this string s, string value,
        StringComparison comparisonType = StringComparison.Ordinal) // Ordinal をデフォルトに変えたい。
        => s.IndexOf(value, comparisonType);
}

ちなみに、OverloadResolutionPriority で優先度を下げたメソッドを呼び出すのはかなり困難になったりします。 場合によっては真っ当な方法で呼ぶ手段がなく、リフレクションや unsafe な手段でしか呼べなくなります。

using System.Runtime.CompilerServices;

// OverloadResolutionPriority(-1) のせいで、真っ当な方法ではどうやっても M(string) の方を呼べない。
C.M((string)"");

// リフレクションとか Unsafe な手段を使えば一応呼べなくはない。
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = nameof(C.M))]
static extern void M(C? c, string _);
M(default, "");

class C
{
    public static void M(object _) => Console.WriteLine("object");

    [OverloadResolutionPriority(-1)]
    public static void M(string _) => Console.WriteLine("string");
}

同一クラス内でのみ有効

OverloadResolutionPriority 属性による優先度の変更は、同一クラス内においてのみ有効です。 なので、以下のようなことはできません

  • 拡張メソッドでインスタンス メソッドを乗っ取り
  • 自作の拡張メソッドで他人の拡張メソッドを乗っ取り
  • 派生クラス内のオーバーロードで基底クラスのメソッドを乗っ取り

例えば以下のような所業はできません。

using System.Runtime.CompilerServices;

// わざと System.Linq.Enumerable と競合するようにして、
namespace System.Linq;

static class FakeLinq
{
    // 優先度を最大限引き上げ。
    [OverloadResolutionPriority(int.MaxValue)]
    public static IEnumerable<TResult> Select<TSource, TResult>(
        this IEnumerable<TSource> source, Func<TSource, TResult> selector)
        => throw new Exception("Select は乗っ取った");
}
// FakeLinq の方が優先されたりはしない。
// 単に「Enumerable と FakeLinq 間で不明瞭」エラーに。
"abc".Select(c => (int)c);

また、OverloadResolutionPriority を付けることで逆にオーバーロード解決できなくなるようなこともありえます。

例えば、以下のように複数のクラスで複数の拡張メソッドが定義されていて、 全体でみれば1つだけ優先度が高くてオーバーロード解決できる場合を考えます。

// A.M(string), A.M(string, int), B.M(string, int) が同列で比較されて、
// デフォルト引数なしの A.M(string) が勝つ。
"".M();

static class A
{
    public static void M(this string s) => Console.WriteLine($"A.M({s})");
    public static void M(this string s, int i = 0) => Console.WriteLine($"A.M({s}, {i})");
}

static class B
{
    public static void M(this string s, int i = 0) => Console.WriteLine($"B.M({s}, {i})");
}

ここで、A.M のうちの1つに OverloadResolutionPriority を付けて優先度を変えてみます。 OverloadResolutionPriority は1つのクラス内でしか働かないので、A の中のどの M が選ばれるかにだけ影響します。 その結果、以下のように別のクラスの M と競合する可能性があります。

using System.Runtime.CompilerServices;

// OverloadResolutionPriority を付けたことで、A.M の中では A.M(string, int) が選ばれる。
// B.M は元々 B.M(string, int) しかない。
// A.M(string, int) と B.M(string, int) が競合してオーバーロード解決できなくなる。
"".M();

static class A
{
    public static void M(this string s) => Console.WriteLine($"A.M({s})");

    [OverloadResolutionPriority(1)]
    public static void M(this string s, int i = 0) => Console.WriteLine($"A.M({s}, {i})");
}

static class B
{
    public static void M(this string s, int i = 0) => Console.WriteLine($"B.M({s}, {i})");
}

余談: (疑似)戻り値オーバーロード

C# では戻り値だけが異なるオーバーロードを認めていません。 例えば以下のコードはコンパイル エラーになります。

class C
{
    public static async Task MAsync() { await Task.Yield(); }

    // Task を ValueTask に変更したいとして、互換性のために Task MAsync() を残すと…
    // 戻り値だけが違うオーバーロードは認められない。
    public static async ValueTask MAsync() { await Task.Yield(); }
}

ちょっと気持ち悪い回避策になりますが、デフォルト引数を悪用することでオーバーロードもどきを作れたりはします。 ところが、「引数なし」と「デフォルト引数持ち」なら前者の方が優先されるため、 追加した新しいオーバーロードもどきが呼ばれることはありません。

// 残念ながら Task MAsync() の方しか呼ばれない。
await C.MAsync();

// もちろんこうすれば ValueTask の方が呼ばれるものの、不格好すぎる。
await C.MAsync(default);

class C
{
    public static async Task MAsync() { await Task.Yield(); }

    // オーバーロードもどきとして、適当に使わないデフォルト値付きの引数を追加。
    public static async ValueTask MAsync(int _ = 0) { await Task.Yield(); }
}

これも一応、OverloadResolutionPriority 属性で解消できます。

using System.Runtime.CompilerServices;

// ValueTask 戻り値の方が呼ばれるように。
await C.MAsync();

class C
{
    [OverloadResolutionPriority(-1)]
    public static async Task MAsync() { await Task.Yield(); }

    public static async ValueTask MAsync(int _ = 0) { await Task.Yield(); }
}

更新履歴

ブログ