Compartir a través de


Prioridad en la resolución de sobrecargas

Nota

Este artículo es una especificación de características. La especificación actúa como documento de diseño de la característica. Incluye cambios de especificación propuestos, junto con la información necesaria durante el diseño y el desarrollo de la característica. Estos artículos se publican hasta que se finalizan los cambios de especificación propuestos e se incorporan en la especificación ECMA actual.

Puede haber algunas discrepancias entre la especificación de características y la implementación completada. Esas diferencias se recogen en las notas de la reunión de diseño de lenguaje (LDM) correspondientes.

Puede obtener más información sobre el proceso de adopción de especificaciones de características en el estándar del lenguaje C# en el artículo sobre las especificaciones de .

Resumen

Presentamos un nuevo atributo, System.Runtime.CompilerServices.OverloadResolutionPriority, que los autores de API pueden usar para ajustar la prioridad relativa de las sobrecargas dentro de un solo tipo, como un medio para orientar a los consumidores de la API a utilizar API específicas, incluso si esas API normalmente se considerarían ambiguas o, de lo contrario, no serían seleccionadas por las reglas de resolución de sobrecargas de C#.

Motivación

Los autores de API suelen encontrarse con un problema de qué hacer con un miembro después de que se haya obsoleto. Por motivos de compatibilidad con versiones anteriores, muchos mantendrán el miembro existente con set to error ObsoleteAttribute a perpetuidad, para evitar que los consumidores que actualicen los binarios en tiempo de ejecución sufran interrupciones. Esto afecta especialmente a los sistemas de complementos, donde el autor de un complemento no controla el entorno en el que se ejecuta el complemento. Es posible que el creador del entorno quiera mantener un método anterior presente, pero bloquear el acceso a él para cualquier código recién desarrollado. Sin embargo, ObsoleteAttribute por sí solo no es suficiente. El tipo o miembro sigue siendo visible en la resolución de sobrecargas y puede provocar errores de resolución de sobrecarga no deseados cuando hay una alternativa perfectamente buena, pero esa alternativa es ambigua con el miembro obsoleto o la presencia del miembro obsoleto hace que la resolución de sobrecarga finalice temprano sin considerar nunca el buen miembro. Para ello, queremos tener una manera de que los autores de API guíen la resolución de sobrecargas para resolver la ambigüedad, de modo que puedan evolucionar sus áreas expuestas de API y dirigir a los usuarios hacia las API de rendimiento sin tener que poner en peligro la experiencia del usuario.

El equipo de bibliotecas de clases base (BCL) tiene varios ejemplos de dónde esto puede resultar útil. Algunos ejemplos (hipotéticos) son:

  • La creación de una sobrecarga de Debug.Assert que utiliza CallerArgumentExpression para obtener la expresión que se afirma, de modo que pueda ser incluido en el mensaje, y hacer que sea preferido sobre la sobrecarga existente.
  • Preferir string.IndexOf(string, StringComparison = Ordinal) en lugar de string.IndexOf(string). Esto tendría que analizarse como un posible cambio disruptivo, pero hay opiniones de que es el mejor valor predeterminado y que es más probable que sea lo que el usuario pretendía.
  • Una combinación de esta propuesta y CallerAssemblyAttribute permitiría a los métodos que tienen una identidad de llamada implícita evitar costosos paseos por la pila. Assembly.Load(AssemblyName) lo hace hoy, y podría ser mucho más eficaz.
  • Microsoft.Extensions.Primitives.StringValues expone una conversión implícita a string y string[]. Esto significa que es ambiguo cuando se pasa a un método con ambas sobrecargas params string[] y params ReadOnlySpan<string>. Este atributo se podría usar para priorizar una de las sobrecargas para evitar la ambigüedad.

Diseño detallado

Prioridad de resolución de sobrecarga

Definimos un nuevo concepto, prioridad_de_resolución_de_sobrecarga, que se utiliza durante el proceso de resolución de un grupo de métodos. overload_resolution_priority es un valor entero de 32 bits. Todos los métodos tienen un overload_resolution_priority de 0 de forma predeterminada y esto se puede cambiar aplicando OverloadResolutionPriorityAttribute a un método. Actualizamos la sección §12.6.4.1 de la especificación de C# de la siguiente manera (cambio en en negrita):

Una vez identificados los miembros de la función candidata y la lista de argumentos, la selección del mejor miembro de función es la misma en todos los casos:

  • En primer lugar, el conjunto de miembros candidatos de la función se reduce a aquellos miembros de función que son aplicables con respecto a la lista de argumentos dada (§12.6.4.2). Si este conjunto reducido está vacío, se produce un error en tiempo de compilación.
  • A continuación, el conjunto reducido de miembros candidatos se agrupa según el tipo declarado. Dentro de cada grupo:
    • Los miembros de función candidatos se ordenan por overload_resolution_priority. Si el miembro es una invalidación, el overload_resolution_priority se origina en la declaración menos derivada de ese miembro.
    • Se quitan todos los miembros que tienen un overload_resolution_priority inferior al más alto encontrado dentro de su grupo de tipos declarantes.
  • A continuación, los grupos reducidos se recombinan en el conjunto final de miembros de función candidatos aplicables.
  • A continuación, se localiza el mejor miembro de función del conjunto de miembros de función candidatos aplicables. Si el conjunto contiene solo un miembro de función, ese miembro de función es el mejor miembro de función. De lo contrario, el mejor miembro de función es el miembro de función que es mejor que todos los demás miembros de función con respecto a la lista de argumentos dada, siempre que cada miembro de función se compare con todos los demás miembros de función mediante las reglas de §12.6.4.3. Si no hay exactamente un miembro de función que sea mejor que todos los demás miembros de función, la invocación del miembro de función es ambigua y se produce un error en tiempo de enlace.

Por ejemplo, esta característica provocaría que el siguiente fragmento de código imprima "Span", en lugar de "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");
}

El efecto de este cambio es que, al igual que la poda para los tipos más derivados, añadimos una poda final para la prioridad de resolución de sobrecarga. Dado que esta poda se produce al final del proceso de resolución de sobrecargas, significa que un tipo base no puede hacer que sus miembros tengan mayor prioridad que cualquier tipo derivado. Esto es intencionado e impide que se produzca una carrera de armas donde un tipo base pueda intentar siempre ser mejor que un tipo derivado. Por ejemplo:

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");
}

Los números negativos pueden usarse y se pueden usar para marcar una sobrecarga específica como peor que todas las demás sobrecargas predeterminadas.

La overload_resolution_priority de un miembro procede de la declaración menos derivada de ese miembro. overload_resolution_priority no se hereda ni se infiere de ningún miembro de la interfaz que un miembro de tipo pueda implementar, y, dado un miembro Mx que implementa un miembro de interfaz Mi, no se emite ningún aviso si Mx y Mi tienen overload_resolution_priorities diferentes.

NB: La intención de esta regla es replicar el comportamiento del modificador params.

System.Runtime.CompilerServices.OverloadResolutionPriorityAttribute

Presentamos el siguiente atributo a la 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;
}

Todos los métodos de C# tienen un overload_resolution_priority predeterminado de 0, a menos que se atribuyan con OverloadResolutionPriorityAttribute. Si se les atribuye ese atributo, su overload_resolution_priority es el valor entero proporcionado al primer argumento del atributo.

Se produce un error al aplicar OverloadResolutionPriorityAttribute a las siguientes ubicaciones:

  • Propiedades no del indexador
  • Descriptores de acceso de propiedad, indexador o evento
  • Operadores de conversión
  • Lambdas
  • Funciones locales
  • Finalizadores
  • Constructores estáticos

Los atributos encontrados en estas ubicaciones en los metadatos son ignorados por C#.

Es un error aplicar OverloadResolutionPriorityAttribute en un lugar donde sería ignorado, como en una invalidación de un método base, ya que la prioridad se lee de la declaración menos derivada de un miembro.

NB: Esto difiere intencionadamente del comportamiento del modificador params, que permite reespecificar o añadir cuando se ignora.

Capacidad de llamada de los miembros

Una advertencia importante para OverloadResolutionPriorityAttribute es que puede hacer que ciertos miembros sean invocables desde el código fuente. Por ejemplo:

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) {}
}

Para estos ejemplos, las sobrecargas de prioridad por defecto se convierten en vestigios, y solo son invocables a través de algunos pasos que requieren un esfuerzo extra:

  • Convertir el método en un delegado, y luego usar ese delegado.
    • Para algunos escenarios de varianza de tipo de referencia, como M3(object) que se priorizan sobre M3(string), esta estrategia fallará.
    • Los métodos condicionales, como M2, tampoco se podrían llamar con esta estrategia, ya que los métodos condicionales no se pueden convertir en delegados.
  • Usando la función de tiempo de ejecución UnsafeAccessor para llamarlo mediante la firma coincidente.
  • Usar manualmente la reflexión para obtener una referencia al método y, a continuación, invocarla.
  • El código que no se vuelve a compilar seguirá llamando a métodos antiguos.
  • La IL manuscrita puede especificar lo que quiera.

Preguntas abiertas

Agrupación de métodos de extensión (respondido)

Tal y como está redactado actualmente, los métodos de extensión se ordenan por prioridad solo dentro de su propio tipo. Por ejemplo:

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 {}

Al hacer la resolución de sobrecarga para miembros de extensión, ¿no deberíamos ordenar por tipo de declaración, y en su lugar considerar todas las extensiones dentro del mismo ámbito?

Respuesta

Siempre los agruparemos. El ejemplo anterior imprimirá Ext2 ReadOnlySpan

Herencia de atributos en las invalidaciones (respondido)

¿Se debe heredar el atributo? Si no es así, ¿cuál es la prioridad del miembro predominante?
Si el atributo se especifica en un miembro virtual, ¿debe ser necesario sobrescribir ese miembro para repetir el atributo?

Respuesta

El atributo no se marcará como heredado. Nos fijaremos en la declaración menos derivada de un miembro para determinar su prioridad en la resolución de sobrecargas.

Error de aplicación o advertencia en invalidación (respondido)

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?
}

¿Qué debemos hacer en la aplicación de un OverloadResolutionPriorityAttribute en un contexto en el que se ignora, como una invalidación:

  1. No haga nada, deje que se ignore silenciosamente.
  2. Emita una advertencia de que se omitirá el atributo.
  3. Emita un error que indica que el atributo no está permitido.

3 es el enfoque más cauteloso, si creemos que puede haber una oportunidad en el futuro en la que podríamos querer permitir una anulación para especificar este atributo.

Respuesta

Iremos con 3 y bloquearemos la aplicación en ubicaciones donde sería ignorada.

Implementación de interfaz implícita (respondida)

¿Cuál debe ser el comportamiento de una implementación de interfaz implícita? ¿Debe ser necesario especificar OverloadResolutionPriority? ¿Cuál debe ser el comportamiento del compilador cuando encuentra una implementación implícita sin prioridad? Esto ocurrirá casi sin duda, ya que se puede actualizar una biblioteca de interfaz, pero no una implementación. El arte previo aquí con params es no especificar, y no traspasar el valor:

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"); }
}

Nuestras opciones son:

  1. Siga a params. OverloadResolutionPriorityAttribute no se trasladará implícitamente ni será necesario especificarlo.
  2. Transferir el atributo implícitamente.
  3. No transfiera implícitamente el atributo, requiera que se especifique en el punto de llamada.
    1. Esto plantea una pregunta adicional: ¿cuál debe ser el comportamiento cuando el compilador encuentra este escenario con referencias compiladas?

Respuesta

Iremos con 1.

Errores adicionales de la aplicación (respondido)

Hay algunas ubicaciones más como y que deben confirmarse. Incluyen:

  • Operadores de conversión: la especificación nunca dice que los operadores de conversión pasen por la resolución de sobrecarga, por lo que la implementación bloquea la aplicación en estos miembros. ¿Debería confirmarse eso?
  • Lambdas: de forma similar, las expresiones lambda nunca están sujetas a la resolución de sobrecargas, por lo que la implementación las bloquea. ¿Debería confirmarse eso?
  • Destructores: de nuevo, bloqueados actualmente.
  • Constructores estáticos: nuevamente bloqueados.
  • Funciones locales: actualmente no están bloqueadas, porque se someten a la resolución de sobrecargas, pero no se pueden sobrecargar. Esto es similar a cómo no se produce un error cuando el atributo se aplica a un miembro de un tipo que no está sobrecargado. ¿Debería confirmarse este comportamiento?

Respuesta

Todas las ubicaciones enumeradas anteriormente están bloqueadas.

Comportamiento de Langversion (respondido)

La implementación actualmente solo emite errores de langversion cuando OverloadResolutionPriorityAttribute se aplica, no cuando realmente influye en algo. Esta decisión se tomó porque hay APIs que la BCL añadirá (tanto ahora como con el tiempo) que empezarán a utilizar este atributo; si el usuario vuelve a configurar manualmente su versión de lenguaje a C# 12 o anterior, es posible que vea estos miembros y, dependiendo de nuestro comportamiento de langversion, tampoco:

  • Si omitemos el atributo en C# <13, se produce un error de ambigüedad porque la API es verdaderamente ambigua sin el atributo o ;
  • Si se produce un error cuando el atributo afecta al resultado, encontraremos un error que indica que la API no se puede utilizar. Esto será especialmente malo porque Debug.Assert(bool) está siendo de-priorizado en .NET 9, o;
  • Si cambiamos silenciosamente la resolución, se produce un comportamiento potencialmente diferente entre diferentes versiones del compilador si uno entiende el atributo y otro no lo hace.

Se eligió el último comportamiento, ya que da como resultado la compatibilidad más directa, pero el resultado cambiante podría ser sorprendente para algunos usuarios. ¿Deberíamos confirmar esto o elegir una de las otras opciones?

Respuesta

Iremos con la opción 1, ignorando silenciosamente el atributo en versiones de lenguaje anteriores.

Alternativas

Una propuesta anterior trató de especificar un enfoque BinaryCompatOnlyAttribute, que era muy pesado en la eliminación de las cosas de la visibilidad. Sin embargo, esto tiene muchos problemas de implementación difíciles que significan que la propuesta es demasiado fuerte para ser útil (evitar pruebas de API antiguas, por ejemplo) o tan débil que falte algunos de los objetivos originales (por ejemplo, poder tener una API que, de otro modo, se consideraría ambigua llamar a una nueva API). Esa versión se replica a continuación.

Propuesta BinaryCompatOnlyAttribute (obsoleta)

BinaryCompatOnlyAttribute

Diseño detallado

System.BinaryCompatOnlyAttribute

Presentamos un nuevo atributo reservado:

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 {}

Cuando se aplica a un miembro de un tipo, ese miembro es tratado como inaccesible en cualquier lugar por el compilador, lo que significa que no contribuye a la búsqueda de miembros, resolución de sobrecargas, o cualquier otro proceso similar.

Dominios de accesibilidad

Actualizamos §7.5.3 Dominios de accesibilidad como sigue:

El dominio de accesibilidad de un miembro consta de las secciones (posiblemente desasociadas) del texto del programa en el que se permite el acceso al miembro. A efectos de definir el dominio de accesibilidad de un miembro, se dice que un miembro es de nivel superior si no está declarado dentro de un tipo, y se dice que un miembro está anidado si está declarado dentro de otro tipo. Además, el texto del programa de un programa se define como todo el texto contenido en todas las unidades de compilación del programa, y el texto del programa de un tipo se define como todo el texto contenido en los type_declarations de ese tipo (incluidos, posiblemente, los tipos anidados dentro del tipo).

El dominio de accesibilidad de un tipo predefinido (como object, into double) es ilimitado.

El dominio de accesibilidad de un tipo sin enlazar de nivel superior T (§8.4.4) que se declara en un programa P se define de la siguiente manera:

  • Si T está marcado con BinaryCompatOnlyAttribute, el dominio de accesibilidad de T es completamente inaccesible para el texto del programa de P y cualquier programa que haga referencia a P.
  • Si la accesibilidad declarada de T es pública, el dominio de accesibilidad de T es el texto del programa de P y cualquier programa que haga referencia a P.
  • Si la accesibilidad declarada de T es interna, el dominio de accesibilidad de T es el texto del programa de P.

Nota: A partir de estas definiciones, se deduce que el dominio de accesibilidad de un tipo no enlazado de nivel superior siempre comprende al menos el texto del programa en el que se declara ese tipo. nota final

El dominio de accesibilidad de un tipo construido T<A₁, ..., Aₑ> es la intersección del dominio de accesibilidad del tipo genérico T no ligado y los dominios de accesibilidad de los argumentos del tipo A₁, ..., Aₑ.

El dominio de accesibilidad de un miembro anidado M declarado en un tipo T dentro de un programa P, se define de la siguiente manera (teniendo en cuenta que M sí mismo podría ser un tipo):

  • Si M está marcado con BinaryCompatOnlyAttribute, el dominio de accesibilidad de M es completamente inaccesible para el texto del programa de P y cualquier programa que haga referencia a P.
  • Si la accesibilidad declarada de M es public, el dominio de accesibilidad de M es el dominio de accesibilidad de T.
  • Si la accesibilidad declarada de M es protected internal, que D sea la unión del texto del programa de P y el texto del programa de cualquier tipo que derive de T, declarado fuera de P. El dominio de accesibilidad de M es la intersección del dominio de accesibilidad de T con D.
  • Si la accesibilidad declarada de M es private protected, deje que D sea la intersección del texto del programa de P y del texto del programa de T y de cualquier tipo derivado de T. El dominio de accesibilidad de M es la intersección del dominio de accesibilidad de T con D.
  • Si la accesibilidad declarada de M es protected, consideremos que D sea la unión del texto del programa de Ty el texto del programa de cualquier tipo derivado de T. El dominio de accesibilidad de M es la intersección del dominio de accesibilidad de T con D.
  • Si la accesibilidad declarada de M es internal, el dominio de accesibilidad de M es la intersección del dominio de accesibilidad de T con el texto del programa de P.
  • Si la accesibilidad declarada de M es private, el dominio de accesibilidad de M es el texto del programa de T.

El objetivo de estas adiciones es hacerlo para que los miembros marcados con BinaryCompatOnlyAttribute sean completamente inaccesibles para cualquier ubicación, no participarán en la búsqueda de miembros y no podrán afectar al resto del programa. En consecuencia, esto significa que no pueden implementar miembros de la interfaz, no pueden llamarse entre sí y no pueden anularse (métodos virtuales), ocultarse o implementarse (miembros de la interfaz). Si esto es demasiado estricto es el tema de varias preguntas abiertas a continuación.

Preguntas sin resolver

Métodos virtuales e invalidación

¿Qué hacemos cuando un método virtual está marcado como BinaryCompatOnly? Las invalidaciones de una clase derivada pueden no estar incluso en el ensamblado actual y podría ser que el usuario está buscando introducir una nueva versión de un método que, por ejemplo, solo difiere por tipo de valor devuelto, algo que C# no permite normalmente sobrecargar. ¿Qué pasa con las invalidaciones de ese método anterior en la recompilación? ¿Pueden invalidar el miembro BinaryCompatOnly si también están marcados como BinaryCompatOnly?

Uso dentro del mismo archivo DLL

Esta propuesta establece que los miembros BinaryCompatOnly no son visibles en ninguna parte, ni siquiera en el ensamblado que se está compilando. ¿Es eso demasiado estricto, o quizás los miembros de BinaryCompatAttribute necesitan encadenarse entre sí?

Implementación implícita de miembros de interfaz

¿Deben los miembros BinaryCompatOnly poder implementar miembros de la interfaz? ¿O se les debería impedir hacerlo? Esto requeriría que, cuando un usuario quiera convertir una implementación de interfaz implícita en BinaryCompatOnly, tendría que proporcionar adicionalmente una implementación de interfaz explícita, probablemente clonando el mismo cuerpo que el miembro de BinaryCompatOnly, ya que la implementación de interfaz explícita ya no podría acceder al miembro original.

Implementación de miembros de interfaz marcados BinaryCompatOnly

¿Qué hacemos cuando un miembro de interfaz se ha marcado como BinaryCompatOnly? El tipo aún debe proporcionar una implementación para ese miembro; puede que simplemente debamos decir que los miembros de interfaz no pueden marcarse como BinaryCompatOnly.