Compartilhar via


Prioridade de Resolução de Sobrecarga

Observação

Este artigo é uma especificação de recurso. A especificação serve como o documento de design para o recurso. Ela inclui alterações de especificação propostas, juntamente com as informações necessárias durante o design e o desenvolvimento do recurso. Esses artigos são publicados até que as alterações de especificação propostas sejam finalizadas e incorporadas na especificação ECMA atual.

Pode haver algumas divergências entre a especificação do recurso e a implementação concluída. Essas diferenças são capturadas nas notas pertinentes da reunião de design de idioma (LDM).

Você pode saber mais sobre o processo de adoção das especificações de recursos no padrão da linguagem C# no artigo sobre as especificações .

Problema do especialista: https://github.com/dotnet/csharplang/issues/7706

Resumo

Apresentamos um novo atributo, System.Runtime.CompilerServices.OverloadResolutionPriority, que pode ser usado por autores de API para ajustar a prioridade relativa de sobrecargas em um único tipo como um meio de orientar os consumidores de API a usar APIs específicas, mesmo que essas APIs normalmente sejam consideradas ambíguas ou não sejam escolhidas pelas regras de resolução de sobrecarga de C#.

Motivação

Os autores de API geralmente têm um problema sobre o que fazer com um membro depois que ele se torna obsoleto. Para fins de compatibilidade com versões anteriores, muitos manterão o membro existente com ObsoleteAttribute definido como erro em perpetuidade, a fim de evitar a interrupção de consumidores que atualizam binários no runtime. Isso afeta particularmente os sistemas de plug-in, em que o autor de um plug-in não controla o ambiente no qual o plug-in é executado. O criador do ambiente pode manter um método mais antigo presente, mas bloquear o acesso a ele para qualquer código recém-desenvolvido. No entanto, ObsoleteAttribute por si só não é suficiente. O tipo ou membro ainda está visível na resolução de sobrecarga e pode causar falhas indesejadas nesse processo quando existe uma alternativa totalmente válida, mas essa alternativa pode ser ambígua com o membro obsoleto, ou a presença do membro obsoleto faz com que a resolução de sobrecarga termine prematuramente, sem considerar o membro adequado. Para essa finalidade, queremos ter uma maneira de os autores de API orientarem a resolução de sobrecarga na solução da ambiguidade, para que possam evoluir suas áreas de superfície de API e direcionar os usuários para APIs com desempenho sem precisar comprometer a experiência do usuário.

A equipe de BCL (Bibliotecas de Classes Base) tem vários exemplos de onde isso pode ser útil. Alguns exemplos (hipotéticos) são:

  • Criar uma sobrecarga de Debug.Assert que utiliza CallerArgumentExpression para obter a expressão declarada, permitindo sua inclusão na mensagem e fazendo com que seja preferida em relação à sobrecarga existente.
  • Tornando string.IndexOf(string, StringComparison = Ordinal) preferencial ao invés de string.IndexOf(string). Isso teria que ser discutido como uma possível alteração significativa, mas alguns consideram que é a melhor opção padrão, e é mais provável que seja o que o usuário tinha em mente.
  • Uma combinação dessa proposta e CallerAssemblyAttribute permitiria que métodos com uma identidade de chamador implícita evitassem percorrer a pilha de chamadas de forma dispendiosa. Assembly.Load(AssemblyName) faz isso hoje, e pode ser muito mais eficiente.
  • Microsoft.Extensions.Primitives.StringValues expõe uma conversão implícita a string e string[]. Isso significa que é ambígua quando passada para um método com sobrecargas de params string[] e params ReadOnlySpan<string>. Esse atributo pode ser usado para priorizar uma das sobrecargas para evitar a ambiguidade.

Projeto detalhado

Prioridade de resolução de sobrecarga

Definimos um novo conceito, overload_resolution_priority, que é usado durante o processo de resolução de um grupo de métodos. overload_resolution_priority é um valor inteiro de 32 bits. Todos os métodos têm um overload_resolution_priority com padrão 0, que pode ser alterado aplicando OverloadResolutionPriorityAttribute a um método. Atualizamos a seção 12.6.4.1 da especificação C# da seguinte maneira (alteração em negrito):

Depois que os membros da função candidata e a lista de argumentos tiverem sido identificados, a seleção do membro da melhor função será a mesma em todos os casos:

  • Primeiro, o conjunto de membros da função candidata é reduzido aos membros da função aplicáveis em relação à lista de argumentos determinada (§12.6.4.2). Se esse conjunto reduzido estiver vazio, ocorrerá um erro de tempo de compilação.
  • Em seguida, o conjunto reduzido de membros candidatos é agrupado de acordo com o tipo declarador. Em cada grupo:
    • Os membros da função candidata são ordenados por overload_resolution_priority. Se o membro for uma substituição, a overload_resolution_priority vem da declaração menos derivada desse membro.
    • Todos os membros que têm uma overload_resolution_priority inferior à mais alta encontrada em seu grupo de tipos declarantes são removidos.
  • Os grupos reduzidos são recombinados no conjunto final de membros da função candidata aplicável.
  • Em seguida, o melhor membro da função do conjunto de membros da função candidata aplicável é posicionado. Se o conjunto contiver apenas um membro de função, esse membro da função será o melhor membro da função. Caso contrário, o melhor membro da função é o membro de uma função que é melhor do que todos os outros membros da função em relação à lista de argumentos fornecida, desde que cada membro da função seja comparado a todos os outros membros da função usando-se as regras em §12.6.4.3. Se não existir exatamente um membro da função que seja melhor do que todos os outros, a invocação do membro da função será ambígua e ocorrerá um erro de tempo de vinculação.

Por exemplo, esse recurso faria com que o seguinte trecho de código exibisse "Span" em vez 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");
}

O efeito dessa alteração é que, como a partição para tipos mais derivados, adicionamos uma partição final para prioridade de resolução de sobrecarga. Como essa partição ocorre no final do processo de resolução de sobrecarga, isso significa que um tipo base não pode tornar seus membros mais prioritários do que qualquer tipo derivado. Isso é intencional e impede que uma corrida competitiva ocorra em que um tipo base possa tentar ser sempre melhor do que um tipo derivado. Por exemplo:

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

Números negativos têm permissão para serem usados e podem ser usados para marcar uma sobrecarga específica como pior do que todas as outras sobrecargas padrão.

A overload_resolution_priority de um membro vem da declaração menos derivada desse membro. A overload_resolution_priority não é herdada ou inferida de membros de interface que um membro do tipo pode implementar e, dependendo do membro Mx que implementa um membro da interface, Mi, nenhum aviso é emitido se Mx e Mi tiverem overload_resolution_priorities diferentes.

NB: A intenção dessa regra é replicar o comportamento do modificador params.

System.Runtime.CompilerServices.OverloadResolutionPriorityAttribute

Apresentamos o seguinte atributo ao 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 os métodos em C# têm um overload_resolution_priority padrão 0, salvo quando são atribuídos com OverloadResolutionPriorityAttribute. Se eles forem atribuídos com esse atributo, sua overload_resolution_priority será o valor inteiro fornecido ao primeiro argumento do atributo.

É um erro aplicar OverloadResolutionPriorityAttribute aos seguintes locais:

  • Propriedades não indexadoras
  • Acessadores de propriedade, indexador ou evento
  • Operadores de conversão
  • Lambdas
  • Funções locais
  • Finalizadores
  • Construtores estáticos

Os atributos encontrados nesses locais em metadados são ignorados por C#.

É um erro aplicar OverloadResolutionPriorityAttribute em um local que seria ignorado, como em uma substituição de um método base, pois a prioridade é lida da declaração menos derivada de um membro.

Observação: isso difere intencionalmente do comportamento do modificador do params, que permite a respecificação ou adição quando ignorado.

Capacidade de chamada de membros

Uma ressalva importante para OverloadResolutionPriorityAttribute é que ele pode tornar determinados membros efetivamente inacessíveis a partir do código-fonte. Por exemplo:

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 estes exemplos, as sobrecargas de prioridade padrão efetivamente se tornam vestigiais e só podem ser chamadas por meio de algumas etapas que demandam um esforço adicional.

  • Converta o método em um delegado e, em seguida, use esse delegado.
    • Para alguns cenários de variação de tipo de referência, como M3(object) priorizado sobre M3(string), essa estratégia falhará.
    • Métodos condicionais, como M2, também não podem ser chamados com essa estratégia, pois não podem ser convertidos em delegados.
  • Use o recurso de runtime UnsafeAccessor para chamá-lo por meio da assinatura correspondente.
  • Usar manualmente a reflexão para obter uma referência ao método e, em seguida, invocá-lo.
  • O código que não for recompilado continuará chamando métodos antigos.
  • O IL manuscrito pode especificar o que quiser.

Perguntas em aberto

Agrupamento de métodos de extensão (respondido)

Da forma em que escritos atualmente, os métodos de extensão são ordenados por prioridade somente dentro de seu próprio tipo. Por exemplo:

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

Ao fazer a resolução de sobrecarga para membros de extensão, não devemos ordenar pelo tipo declarado e, em vez disso, considerar todas as extensões no mesmo escopo?

Resposta

Sempre faremos agrupamentos. O exemplo acima imprimirá Ext2 ReadOnlySpan

Herança de atributo em substituições (respondida)

O atributo deve ser herdado? Se não, qual é a prioridade do membro substituto?
Se o atributo for especificado em um membro virtual, uma substituição desse membro deverá ser necessária para repetir o atributo?

Resposta

O atributo não será marcado como herdado. Examinaremos a declaração de menor derivação de um membro para determinar sua prioridade de resolução de sobrecarga.

Erro ou aviso de aplicação na substituição (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?
}

O que devemos fazer na aplicação de um OverloadResolutionPriorityAttribute em um contexto em que ele é ignorado, como uma substituição:

  1. Não faça nada, deixe-o ser ignorado silenciosamente.
  2. Emita um aviso de que o atributo será ignorado.
  3. Emita um erro informando que o atributo não é permitido.

3 é a abordagem mais cautelosa, se considerarmos que pode haver um espaço no futuro em que talvez queiramos permitir uma substituição para especificar esse atributo.

Resposta

Vamos utilizar 3 e bloquear o uso em locais onde seria ignorado.

Implementação de interface implícita (respondida)

Qual deve ser o comportamento de uma implementação de interface implícita? Deve ser necessário especificar OverloadResolutionPriority? Qual deve ser o comportamento do compilador quando ele encontra uma implementação implícita sem prioridade? Isso quase certamente acontecerá, pois uma biblioteca de interfaces pode ser atualizada, mas não uma implementação. O segredo com params é não especificar e não carregar o 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"); }
}

Nossas opções são:

  1. Siga params. OverloadResolutionPriorityAttribute não será carregado implicitamente nem será necessário especificá-lo.
  2. Carregue o atributo implicitamente.
  3. Não carregar o atributo implicitamente, exigir que ele seja especificado no site de chamada.
    1. Isso levanta uma pergunta extra: qual deverá ser o comportamento quando o compilador encontrar esse cenário com referências compiladas?

Resposta

Vamos com 1.

Outros erros de aplicativo (respondidos)

Há mais alguns locais como este que precisam ser confirmados. Elas incluem:

  • Operadores de conversão – A especificação nunca diz que os operadores de conversão passam pela resolução de sobrecarga, portanto, a implementação impede o uso desses membros. Isso deve ser confirmado?
  • Lambdas – Da mesma forma, lambdas nunca estão sujeitas à resolução de sobrecarga, portanto, são bloqueadas pela implementação. Isso deve ser confirmado?
  • Destruidores – novamente, bloqueados no momento.
  • Construtores estáticos – novamente, bloqueados no momento.
  • Funções locais - Elas não estão bloqueadas no momento, pois passam por resolução de sobrecarga, e você simplesmente não pode sobrecarregá-las. Isso é semelhante à forma como não ocorre erro quando o atributo é aplicado a um membro de um tipo que não possui sobrecarga. Esse comportamento deve ser confirmado?

Resposta

Todos os locais listados acima são bloqueados.

Comportamento de Langversion (respondido)

No momento, a implementação só emite erros de langversion quando OverloadResolutionPriorityAttribute é aplicado, mas não com ou, a menos que realmente influencie algo. Essa decisão foi tomada porque há APIs que o BCL adicionará (agora e ao longo do tempo) que começarão a usar esse atributo. Se o usuário definir manualmente a versão do idioma como C# 12 ou versões anteriores, ele poderá ver esses membros e, dependendo do nosso comportamento de langversion, pode ser:

  • Se ignorarmos o atributo em C# <13, encontraremos um erro de ambiguidade, porque a API é verdadeiramente ambígua sem o atributo.
  • Se ocorrer um erro quando o atributo tiver afetado o resultado, encontraremos um erro que torna a API inutilizável. Isso será especialmente ruim porque Debug.Assert(bool) está sendo despriorizado em .NET 9 ou;
  • Se alterarmos a resolução de maneira silenciosa, podemos enfrentar comportamentos potencialmente diferentes entre versões distintas do compilador, caso uma compreenda o atributo e a outra não.

O último comportamento foi escolhido porque proporciona maior compatibilidade futura, mas o resultado alterado pode ser surpreendente para alguns usuários. Devemos confirmar isso ou escolher uma das outras opções?

Resposta

Vamos escolher a opção 1, ignorando silenciosamente o atributo em versões anteriores da linguagem.

Alternativas

Uma proposta anterior tentou especificar uma abordagem BinaryCompatOnlyAttribute, que foi muito pesada na exclusão de itens da visibilidade. No entanto, isso apresenta muitos problemas de implementação difíceis, que podem tornar a proposta muito forte para ser útil (impedindo, por exemplo, o teste de APIs antigas) ou tão fraca que perdem alguns dos objetivos originais (como a capacidade de ter uma API que, de outra forma, seria considerada ambígua, chamando uma nova API). Essa versão está replicada abaixo.

Proposta de BinaryCompatOnlyAttribute (obsoleto)

BinaryCompatOnlyAttribute

Projeto detalhado

System.BinaryCompatOnlyAttribute

Introduzimos um novo 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 {}

Quando aplicado a um membro de tipo, esse membro é tratado como inacessível em qualquer local pelo compilador, o que significa que ele não contribui para a pesquisa de membros, resolução de sobrecargas ou qualquer outro processo similar.

Domínios de acessibilidade

Atualizamos domínios de acessibilidade do §7.5.3 conforme segue:

O domínio de acessibilidade de um membro consiste nas seções (possivelmente disjuntas) do texto do programa nas quais o acesso ao membro é permitido. Para determinar o domínio de acessibilidade de um membro, considera-se que um membro é de nível superior se não for declarado dentro de um tipo, enquanto um membro é considerado aninhado se for declarado dentro de outro tipo. Além disso, o texto do programa de um programa é definido como todo o texto contido em todas as unidades de compilação do programa, e o texto do programa de um tipo é definido como todo o texto contido nas type_declaration desse tipo (incluindo, possivelmente, tipos que estão aninhados dentro dele).

O domínio de acessibilidade de um tipo predefinido (como object, int ou double) é ilimitado.

O domínio de acessibilidade de um tipo não vinculado de nível superior T (§8.4.4) que é declarado em um programa P é definido da seguinte forma:

  • Se T estiver marcado com BinaryCompatOnlyAttribute, o domínio de acessibilidade de T será totalmente inacessível ao texto do programa de P e qualquer programa que faz referência a P.
  • Se a acessibilidade declarada de T for pública, o domínio de acessibilidade de T será o texto do programa de P e qualquer programa que faz referência a P.
  • Se a acessibilidade declarada de T for interna, o domínio de acessibilidade de T será o texto do programa de P.

Observação: a partir dessas definições, conclui-se que o domínio de acessibilidade de um tipo não vinculado de nível superior é sempre, no mínimo, o texto do programa no qual esse tipo é declarado. fim da observação

O domínio de acessibilidade para um tipo construído T<A₁, ..., Aₑ> é a interseção do domínio de acessibilidade do tipo genérico não vinculado T e os domínios de acessibilidade dos argumentos de tipo A₁, ..., Aₑ.

O domínio de acessibilidade de um membro aninhado M declarado em um tipo T dentro de um programa Pé definido da seguinte maneira (observando que M pode ser um tipo em si):

  • Se M estiver marcado com BinaryCompatOnlyAttribute, o domínio de acessibilidade de M será totalmente inacessível ao texto do programa de P e qualquer programa que faz referência a P.
  • Se a acessibilidade declarada de M for public, então o domínio de acessibilidade de M será igual ao de T.
  • Se a acessibilidade declarada de M for protected internal, a D será a união do texto do programa de P com o texto do programa de qualquer tipo derivado de T, declarado fora de P. O domínio de acessibilidade de M é a interseção do domínio de acessibilidade de T com D.
  • Se a acessibilidade declarada de M for private protected, a D será a interseção do texto do programa de P, do texto do programa de T e de qualquer tipo derivado de T. O domínio de acessibilidade de M é a interseção do domínio de acessibilidade de T com D.
  • Se a acessibilidade declarada de M for protected, a D será a união do texto do programa de T e o texto do programa de qualquer tipo derivado de T. O domínio de acessibilidade de M é a interseção do domínio de acessibilidade de T com D.
  • Se a acessibilidade declarada de M for internal, o domínio de acessibilidade de M será a interseção do domínio de acessibilidade de T com o texto de programa de P.
  • Se a acessibilidade declarada de M for private, o domínio de acessibilidade de M será o texto de programa de T.

O objetivo dessas adições é possibilitar que os membros marcados com BinaryCompatOnlyAttribute estejam completamente inacessíveis a qualquer local, não participem da pesquisa de membros e não possam afetar o restante do programa. Isso significa que eles não podem implementar membros da interface, não podem chamar uns aos outros e não podem ser substituídos (métodos virtuais), ocultados ou implementados (membros da interface). Se isso é excessivamente restritivo é o tema de várias perguntas em aberto abaixo.

Perguntas não resolvidas

Métodos virtuais e substituição

O que fazemos quando um método virtual é marcado como BinaryCompatOnly? As substituições em uma classe derivada podem nem estar no assembly atual, e pode ser que o usuário esteja procurando introduzir uma nova versão de um método que, por exemplo, só difere pelo tipo de retorno, algo para o qual o C# normalmente não permite sobrecarga de métodos. O que acontece com quaisquer substituições desse método anterior na recompilação? Eles têm permissão para substituir o membro BinaryCompatOnly se também estiverem marcados como BinaryCompatOnly?

Uso dentro da mesma DLL

Esta proposta afirma que os membros de BinaryCompatOnly não estão visíveis em nenhum lugar, nem mesmo no assembly que está sendo compilado no momento. Isso é muito rigoroso, ou os membros de BinaryCompatAttribute precisam se conectar uns aos outros?

Implementar membros de interface de forma implícita

Os membros de BinaryCompatOnly deveriam poder implementar membros da interface? Ou isso deveria ser impedido? Isso exigiria que, quando um usuário quisesse transformar uma implementação de interface implícita em BinaryCompatOnly, ele também teria que fornecer uma implementação de interface explícita, provavelmente clonando o corpo do membro BinaryCompatOnly, já que a implementação da interface explícita não seria mais capaz de ver o membro original.

Implementar membros da interface marcados com BinaryCompatOnly

O que fazemos quando um membro de interface é marcado como BinaryCompatOnly? O tipo ainda precisa fornecer uma implementação para esse membro; pode ser que devêssemos simplesmente afirmar que membros de interface não podem ser marcados como BinaryCompatOnly.