Поделиться через


Приоритет разрешения перегрузки

Заметка

Эта статья является спецификацией компонентов. Спецификация служит проектным документом для функции. Включает предлагаемые изменения спецификации, а также информацию, необходимую во время проектирования и разработки функции. Эти статьи публикуются до тех пор, пока предложенные изменения спецификации не будут завершены и включены в текущую спецификацию ECMA.

Может возникнуть некоторое несоответствие между спецификацией компонентов и завершенной реализацией. Эти различия отражены в соответствующих заметках собраний по конструированию языка (LDM) .

Дополнительные сведения о процессе внедрения спецификаций функций в стандарт языка C# см. в статье о спецификациях .

Проблема чемпиона: https://github.com/dotnet/csharplang/issues/7706

Сводка

Мы введем новый атрибут, System.Runtime.CompilerServices.OverloadResolutionPriority, который можно использовать авторами API для корректировки относительного приоритета перегрузки в одном типе в качестве средства управления потребителями API для использования конкретных API, даже если эти API обычно считаются неоднозначными или в противном случае не выбираются правилами разрешения перегрузки C#.

Мотивация

Авторы API часто сталкиваются с проблемой, что делать с элементом после того, как он устарел. В целях обратной совместимости многие будут сохранять существующий элемент с установленной ошибкой ObsoleteAttribute на неопределённое время, чтобы избежать проблем у пользователей, которые обновляют бинарные файлы во время выполнения. Это особенно влияет на подключаемые системы, где автор подключаемого модуля не управляет средой, в которой запускается подключаемый модуль. Создатель среды может сохранить старый метод, но заблокировать доступ к нему для любого недавно разработанного кода. Однако ObsoleteAttribute само по себе не является достаточным. Тип или элемент всё ещё виден при выборе перегрузки и может привести к нежелательным сбоям, если существует подходящая альтернатива, но эта альтернатива либо неоднозначна с устаревшим элементом, либо присутствие устаревшего элемента приводит к тому, что выбор перегрузки завершается рано, не рассматривая хорошую альтернативу. Для этого мы хотим, чтобы авторы API могли управлять процессом разрешения перегрузки при устранении неоднозначности, так чтобы они могли развивать области применения API и направлять пользователей на использование эффективных API без ухудшения пользовательского опыта.

Команда библиотек базовых классов (BCL) содержит несколько примеров того, где это может оказаться полезным. Ниже приведены некоторые (гипотетические) примеры:

  • Создание перегрузки Debug.Assert, использующей CallerArgumentExpression для получения выражения, которое утверждается, чтобы его можно было включить в сообщение и чтобы оно было предпочтительнее по сравнению с существующей перегрузкой.
  • Придание предпочтения string.IndexOf(string, StringComparison = Ordinal) над string.IndexOf(string). Это следует рассмотреть как потенциальное критическое изменение, но есть мнение, что это лучший вариант по умолчанию и, вероятно, больше соответствует тому, что имел в виду пользователь.
  • Сочетание этого предложения и CallerAssemblyAttribute позволит методам, имеющим скрытую идентификацию вызывающего объекта, избежать тяжелых обходов стека. Assembly.Load(AssemblyName) делает это сегодня, и это может быть гораздо более эффективным.
  • Microsoft.Extensions.Primitives.StringValues предоставляет неявное преобразование как для string, так и для string[]. Это означает, что при передаче в метод с перегрузками params string[] и params ReadOnlySpan<string> возникает неоднозначность. Этот атрибут можно использовать для определения приоритета одной из перегрузок, чтобы предотвратить неоднозначность.

Подробный дизайн

Приоритет разрешения перегрузки

Мы определяем новую концепцию, overload_resolution_priority, которая используется в процессе разрешения группы методов. overload_resolution_priority — 32-разрядное целое значение. Все методы имеют приоритет разрешения перегрузки 0 по умолчанию, и это можно изменить, применяя OverloadResolutionPriorityAttribute к методу. Мы обновляем раздел §12.6.4.1 спецификации C# следующим образом (изменение в полужирным):

После идентификации членов-кандидатов и списка аргументов выбор лучшего элемента функции одинаков во всех случаях:

  • Во-первых, набор членов-кандидатов функции уменьшается до тех элементов функции, которые применимы к указанному списку аргументов (§12.6.4.2). Если этот сокращенный набор пуст, возникает ошибка во время компиляции.
  • Затем сокращенный набор участников-кандидатов сгруппирован по типу объявления. В каждой группе:
    • Члены функции-кандидаты упорядочены по приоритету разрешения перегрузки, обозначенному как . Если элемент является переопределением, overload_resolution_priority происходит из наименее производным объявления этого элемента.
    • Все члены, имеющие более низкий overload_resolution_priority, чем самый высокий, найденный в его объявленной группе типов, удаляются.
  • Затем сокращенные группы перекомбинируются в окончательный набор применимых членов функции-кандидатов.
  • Затем из множества применимых функций-кандидатов выбирается лучшая функция. Если набор содержит только один член функции, то этот элемент функции является лучшим элементом функции. В противном случае лучший элемент функции — это один элемент функции, который лучше, чем все остальные члены функции в отношении заданного списка аргументов, если каждый член функции сравнивается со всеми другими элементами функции, использующими правила в §12.6.4.3. Если нет ровно одного члена функции, который лучше всех остальных элементов функции, вызов члена функции является неоднозначным, и возникает ошибка во время привязки.

Например, эта функция приведет к тому, что следующий фрагмент кода будет печатать "Span", а не "Array":

using System.Runtime.CompilerServices;

var d = new C1();
int[] arr = [1, 2, 3];
d.M(arr); // Prints "Span"

class C1
{
    [OverloadResolutionPriority(1)]
    public void M(ReadOnlySpan<int> s) => Console.WriteLine("Span");
    // Default overload resolution priority
    public void M(int[] a) => Console.WriteLine("Array");
}

Эффект этого изменения заключается в том, что, как и при обрезке для большинства производных типов, мы добавляем финальную обрезку для приоритетов разрешения перегрузки. Так как эта обрезка происходит в самом конце процесса разрешения перегрузки, это приводит к тому, что базовый тип не может сделать свои элементы более приоритетными, чем любой производный тип. Это намеренно и предотвращает возникновение гонки вооружений, где базовый тип может всегда быть лучше, чем производный тип. Например:

using System.Runtime.CompilerServices;

var d = new Derived();
d.M([1, 2, 3]); // Prints "Derived", because members from Base are not considered due to finding an applicable member in Derived

class Base
{
    [OverloadResolutionPriority(1)]
    public void M(ReadOnlySpan<int> s) => Console.WriteLine("Base");
}

class Derived : Base
{
    public void M(int[] a) => Console.WriteLine("Derived");
}

Отрицательные числа могут использоваться, чтобы отметить конкретную перегрузку как более худшую по сравнению со всеми другими перегрузками по умолчанию.

overload_resolution_priority члена происходит из наименее производного объявления этого элемента. overload_resolution_priority не наследуется и не выводится из членов интерфейса, которые может реализовывать член типа, и если присутствует член Mx, реализующий интерфейсный член Mi, предупреждение не выдается, если Mx и Mi имеют разные overload_resolution_priorities.

NB: цель этого правила — реплицировать поведение модификатора params.

System.Runtime.CompilerServices.OverloadResolutionPriorityAttribute

Мы вводим следующий атрибут в BCL.

namespace System.Runtime.CompilerServices;

[AttributeUsage(AttributeTargets.Method | AttributeTargets.Constructor | AttributeTargets.Property, AllowMultiple = false, Inherited = false)]
public sealed class OverloadResolutionPriorityAttribute(int priority) : Attribute
{
    public int Priority => priority;
}

Все методы в C# имеют по умолчанию overload_resolution_priority 0, если они не помечены как OverloadResolutionPriorityAttribute. Если они связаны с этим атрибутом, то их overload_resolution_priority является целым значением, предоставленным первому аргументу атрибута.

Применение OverloadResolutionPriorityAttribute к следующим расположениям является ошибкой.

  • Свойства неиндексатора
  • Методы доступа к свойствам, индексатору или событиям
  • Операторы преобразования
  • Лямбд
  • Локальные функции
  • Финализаторы
  • Статические конструкторы

Атрибуты, встречаемые в этих расположениях в метаданных, игнорируются C#.

Ошибка заключается в применении OverloadResolutionPriorityAttribute в месте, где оно будет проигнорировано, например, при переопределении метода базового класса, так как приоритет определяется из самого раннего объявления элемента.

NB: это намеренно отличается от поведения модификатора params, который позволяет изменять или добавлять при игнорировании.

Возможность вызова членов

Важное предостережение для OverloadResolutionPriorityAttribute заключается в том, что он может сделать некоторые члены фактически недоступными для вызова из исходного кода. Например:

using System.Runtime.CompilerServices;

int i = 1;
var c = new C3();
c.M1(i); // Will call C3.M1(long), even though there's an identity conversion for M1(int)
c.M2(i); // Will call C3.M2(int, string), even though C3.M1(int) has less default parameters

class C3
{
    public void M1(int i) {}
    [OverloadResolutionPriority(1)]
    public void M1(long l) {}

    [Conditional("DEBUG")]
    public void M2(int i) {}
    [OverloadResolutionPriority(1), Conditional("DEBUG")]
    public void M2(int i, [CallerArgumentExpression(nameof(i))] string s = "") {}

    public void M3(string s) {}
    [OverloadResolutionPriority(1)]
    public void M3(object o) {}
}

В этих примерах перегрузки приоритета по умолчанию фактически становятся неэффективными и вызываются только через несколько шагов, которые требуют дополнительных усилий.

  • Преобразование метода в делегат, а затем использование этого делегата.
    • Для некоторых сценариев дисперсии ссылочных типов, таких как M3(object), который имеет приоритет над M3(string), эта стратегия завершится ошибкой.
    • Условные функции, такие как M2, невозможно будет вызвать с помощью этой стратегии, так как условные функции нельзя преобразовать в делегаты.
  • Использование функции среды выполнения UnsafeAccessor для вызова ее с помощью соответствующей подписи.
  • Используя рефлексию вручную, можно получить ссылку на метод, а затем вызвать его.
  • Код, который не перекомпилирован, будет продолжать вызывать старые методы.
  • Рукописный код IL может указать любое выбранное значение.

Открытые вопросы

Группирование методов расширения (ответ)

В настоящее время методы расширения упорядочены по приоритету только в рамках их собственного типа. Например:

new C2().M([1, 2, 3]); // Will print Ext2 ReadOnlySpan

static class Ext1
{
    [OverloadResolutionPriority(1)]
    public static void M(this C2 c, Span<int> s) => Console.WriteLine("Ext1 Span");
    [OverloadResolutionPriority(0)]
    public static void M(this C2 c, ReadOnlySpan<int> s) => Console.WriteLine("Ext1 ReadOnlySpan");
}

static class Ext2
{
    [OverloadResolutionPriority(0)]
    public static void M(this C2 c, ReadOnlySpan<int> s) => Console.WriteLine("Ext2 ReadOnlySpan");
}

class C2 {}

При разрешении перегрузки для членов расширения не следует отсортировать по декларативному типу и вместо этого рассмотреть все расширения в одной области?

Ответ

Мы всегда будем собираться. Приведенный выше пример выведет Ext2 ReadOnlySpan

Наследование атрибутов при переопределении (ответ получен)

Следует ли наследовать атрибут? Если нет, каков приоритет переопределяющего члена?
Если атрибут указан в виртуальном элементе, следует ли переопределить этот элемент, чтобы повторить атрибут?

Ответ

Атрибут не будет помечен как унаследованный. Мы рассмотрим наименее производное объявление члена, чтобы определить приоритет разрешения перегрузки.

Ошибка приложения или предупреждение при переопределении (ответ)

class Base
{
    [OverloadResolutionPriority(1)] public virtual void M() {}
}
class Derived
{
    [OverloadResolutionPriority(2)] public override void M() {} // Warn or error for the useless and ignored attribute?
}

Что следует сделать при применении OverloadResolutionPriorityAttribute в контексте, в котором оно игнорируется, например, переопределение:

  1. Ничего не делать, пусть это будет молча проигнорировано.
  2. Выдает предупреждение о том, что атрибут будет игнорироваться.
  3. Выдать ошибку, что атрибут не разрешён.

3 — это самый осторожный подход, если мы считаем, что в будущем может появиться ситуация, в которой мы могли бы захотеть разрешить переопределение для указания этого атрибута.

Ответ

Мы остановимся на 3 и заблокируем применение в местах, где это будет игнорироваться.

Реализация неявного интерфейса (ответ)

Что должно быть поведение неявной реализации интерфейса? Следует ли указывать OverloadResolutionPriority? Каково поведение компилятора при обнаружении неявной реализации без приоритета? Это почти наверняка произойдет, так как библиотека интерфейсов может быть обновлена, но не реализация. Предшествующий уровень техники здесь с params заключается в том, чтобы не указывать и не переносить значение.

using System;

var c = new C();
c.M(1, 2, 3); // error CS1501: No overload for method 'M' takes 3 arguments
((I)c).M(1, 2, 3);

interface I
{
    void M(params int[] ints);
}

class C : I
{
    public void M(int[] ints) { Console.WriteLine("params"); }
}

Наши варианты:

  1. Следуйте params. OverloadResolutionPriorityAttribute не будет неявно переноситься или быть обязательным для указания.
  2. Переносите атрибут неявно.
  3. Не переносите атрибут неявно, требуется, чтобы он был указан на сайте вызова.
    1. Это приводит к дополнительному вопросу: как должно себя вести компилятор, если он обнаруживает этот сценарий со скомпилированными ссылками?

Ответ

Мы выберем вариант 1.

Дальнейшие ошибки приложения (ответы)

Существует ещё несколько мест, таких как ,, которые должны быть подтверждены. К ним относятся:

  • Операторы преобразования — спецификация никогда не говорит, что операторы преобразования проходят через разрешение перегрузки, поэтому реализация блокирует приложение для этих элементов. Должно ли это быть подтверждено?
  • Лямбда-выражения никогда не подвергаются разрешению перегрузки, поэтому реализация предотвращает это. Должно ли это быть подтверждено?
  • Деструкторы — снова заблокированы в настоящее время.
  • Статические конструкторы — опять же, заблокированные в настоящее время.
  • Локальные функции — в настоящее время они не блокируются, так как они выполняют разрешение перегрузки, их просто нельзя перегружать. Это похоже на то, как мы не ошибаемся при применении атрибута к члену типа, который не перегружен. Должно ли это поведение быть подтверждено?

Ответ

Все перечисленные выше места заблокированы.

Поведение Langversion (ответ)

Реализация в настоящее время выдает ошибки langversion только при применении OverloadResolutionPriorityAttribute, а не или, если это действительно на что-либо влияет. Это решение было принято, так как существуют API, которые BCL будет добавлять как сейчас, так и в будущем, и которые начнут использовать этот атрибут, если пользователь вручную установит версию языка на C# 12 или более раннюю, он может видеть эти члены и, в зависимости от поведения langversion, либо:

  • Если атрибут в C# <13 игнорируется, возникает ошибка неоднозначности, так как API действительно неоднозначно без атрибута или;
  • Если мы ошибемся в момент, когда атрибут повлиял на результат, возникнет ошибка, указывающая на то, что API нельзя использовать. Это будет особенно плохо, так как Debug.Assert(bool) не приоритетен в .NET 9 или;
  • Если мы незаметно изменяем разрешение, могут возникнуть потенциальные различия в поведении между разными версиями компилятора, если одна версия понимает атрибут, а другая — нет.

Последнее поведение было выбрано, так как оно приводит к наибольшей совместимости вперед, но изменение результата может быть удивительно для некоторых пользователей. Следует ли подтвердить это или выбрать один из других вариантов?

Ответ

Мы будем идти с вариантом 1, молча игнорируя атрибут в предыдущих языковых версиях.

Альтернативы

предыдущее предложение пыталось указать BinaryCompatOnlyAttribute подход, который был очень тяжелым способом удаления вещей из видимости. Тем не менее, здесь множество сложных проблем реализации, которые либо означают, что предложение оказывается чрезмерно жестким, чтобы быть полезным (например, препятствуя тестированию старых API), либо настолько слабым, что упускает некоторые из первоначальных целей (например, возможность для API, который в противном случае считался бы неоднозначным, вызвать новый API). Эта версия реплицируется ниже.

Предложение BinaryCompatOnlyAttribute (устаревшее)

BinaryCompatOnlyAttribute

Подробный дизайн

System.BinaryCompatOnlyAttribute

Мы представляем новый зарезервированный атрибут:

namespace System;

// Excludes Assembly, GenericParameter, Module, Parameter, ReturnValue
[AttributeUsage(AttributeTargets.Class
                | AttributeTargets.Constructor
                | AttributeTargets.Delegate
                | AttributeTargets.Enum
                | AttributeTargets.Event
                | AttributeTargets.Field
                | AttributeTargets.Interface
                | AttributeTargets.Method
                | AttributeTargets.Property
                | AttributeTargets.Struct,
                AllowMultiple = false,
                Inherited = false)]
public class BinaryCompatOnlyAttribute : Attribute {}

При применении к элементу типа этот элемент рассматривается как недоступный в каждом расположении компилятором, что означает, что он не способствует поиску элементов, разрешению перегрузки или любому другому аналогичному процессу.

Домены доступности

Мы обновляем домены специальных возможностей §7.5.3, как следует:

область доступности члена состоит из (возможно, разрозненных) разделов текста программы, где разрешен доступ к члену. В целях определения домена специальных возможностей члена, член называется верхнего уровня, если он не объявлен в типе, и член называется вложенным, если он объявлен в другом типе. Кроме того, текст программы программы определяется как весь текст, содержащийся во всех единицах компиляции программы, и текст программы типа определяется как весь текст, содержащийся в type_declarationэтого типа (включая, возможно, типы, вложенные в тип).

Сфера доступности особенного типа (например, object, intили double) является неограниченной.

Домен видимости неограниченного типа верхнего уровня T (§8.4.4), объявленный в программе P, определяется следующим образом:

  • Если T помечен BinaryCompatOnlyAttribute, домен специальных возможностей T полностью недоступен для текста программы P и для любой программы, ссылающейся на P.
  • Если объявленная доступность T является общедоступной, то домен доступности T включает в себя текст программы P и любую программу, которая ссылается на P.
  • Если объявленная доступность T является внутренней, домен доступности T определяется текстом программы P.

Примечание. Из этих определений следует, что домен доступности неограниченного типа верхнего уровня всегда охватывает как минимум текст программы, в которой объявлен этот тип. конец примечания

Домен специальных возможностей для созданного типа T<A₁, ..., Aₑ> — это пересечение домена специальных возможностей несвязанного универсального типа T и доменов специальных возможностей аргументов типа A₁, ..., Aₑ.

Домен доступа вложенного члена M, объявленного в типе T в программе P, определяется следующим образом (заметьте, что сам M может быть типом):

  • Если M помечен BinaryCompatOnlyAttribute, домен доступности M полностью недоступен для текста программы P и любой программы, ссылающейся на P.
  • Если объявленная доступность M равна public, то домен доступности M является доменом доступности T.
  • Если объявленная доступность Mprotected internal, пусть D будет объединением текста программы P и текста программы любого типа, производного от T, которое объявлено за пределами P. Домен специальных возможностей M — это пересечение домена специальных возможностей T с D.
  • Если объявленная доступность Mprivate protected, давайте D быть пересечением текста программы P и текста программы T и любого типа, производным от T. Домен специальных возможностей M — это пересечение домена специальных возможностей T с D.
  • Если объявленная доступность Mprotected, пусть D будет объединением текста программы Tи текста программы любого типа, происходящего от T. Домен специальных возможностей M — это пересечение домена специальных возможностей T с D.
  • Если объявленная доступность M составляет internal, домен доступности M является пересечением домена доступности T с текстом программы P.
  • Если объявленная доступность M равна private, домен доступности M соответствует тексту программы T.

Цель этих дополнений заключается в том, чтобы сделать так, что члены, помеченные BinaryCompatOnlyAttribute, полностью недоступны из любого места, они не будут участвовать в поиске членов и не могут повлиять на остальную часть программы. Следовательно, это означает, что они не могут реализовать элементы интерфейса, не могут вызывать друг друга и не могут быть переопределены, скрыты или реализованы (виртуальные методы, элементы интерфейса). Является ли это слишком строгим является предметом нескольких открытых вопросов ниже.

Неразрешенные вопросы

Виртуальные методы и переопределение

Что делать, если виртуальный метод помечен как BinaryCompatOnly? Переопределения в производном классе могут даже не находиться в текущей сборке, и может быть, что пользователь хочет представить новую версию метода, который, например, отличается только по типу возвращаемого значения, то, что C# обычно не разрешает перегрузку. Что происходит с любыми переопределениями этого предыдущего метода при повторной компиляции? Разрешено ли переопределить элемент BinaryCompatOnly, если они также помечены как BinaryCompatOnly?

Использование в той же библиотеке DLL

Это предложение указывает, что члены BinaryCompatOnly не отображаются нигде, даже в конечной сборке, которая в данный момент компилируется. Это слишком строго, или членам BinaryCompatAttribute нужно, возможно, объединиться друг с другом?

Неявная реализация членов интерфейса

Могут ли члены BinaryCompatOnly реализовывать члены интерфейса? Или они должны быть запрещены делать это. Это потребует, чтобы, если пользователь хочет преобразовать неявную реализацию интерфейса в BinaryCompatOnly, он также должен предоставить явную реализацию интерфейса, вероятно, с использованием того же тела, что у элемента BinaryCompatOnly, так как явная реализация интерфейса больше не сможет обращаться к исходному элементу.

Реализация элементов интерфейса, помеченных BinaryCompatOnly

Что мы делаем, когда элемент интерфейса помечен как BinaryCompatOnly? Тип всё ещё должен предоставить реализацию для этого члена; возможно, нам просто следует сказать, что члены интерфейса не могут быть помечены как BinaryCompatOnly.