CallerArgumentExpression
Nota
Este artigo é uma especificação de recurso. A especificação serve como o documento de design para o recurso. Ele 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 discrepâ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 de especificações parciais no padrão da linguagem C# no artigo sobre as especificações .
Problema do especialista: https://github.com/dotnet/csharplang/issues/287
Resumo
Permitir que os desenvolvedores capturem as expressões passadas para um método, para habilitar melhores mensagens de erro em APIs de diagnóstico/teste e reduzir pressionamentos de teclas.
Motivação
Quando uma validação de declaração ou argumento falha, o desenvolvedor deseja saber o máximo possível sobre onde e por que falhou. No entanto, as APIs de diagnóstico de hoje não facilitam totalmente isso. Considere o seguinte método:
T Single<T>(this T[] array)
{
Debug.Assert(array != null);
Debug.Assert(array.Length == 1);
return array[0];
}
Quando um dos asserts falhar, somente o nome do arquivo, o número da linha e o nome do método serão fornecidos no stack trace. O desenvolvedor não poderá saber qual afirmação falhou com essas informações -- ele terá que abrir o arquivo e navegar até o número de linha fornecido para ver o que deu errado.
Essa também é a razão pela qual as estruturas de teste precisam fornecer uma variedade de métodos de declaração. Com xUnit, Assert.True
e Assert.False
não são usados com frequência porque não fornecem contexto suficiente sobre o que falhou.
Embora a situação seja um pouco melhor para validação de argumento porque os nomes de argumentos inválidos são mostrados para o desenvolvedor, o desenvolvedor deve passar esses nomes para exceções manualmente. Se o exemplo acima tiver sido reescrito para usar a validação de argumento tradicional em vez de Debug.Assert
, ele se pareceria com
T Single<T>(this T[] array)
{
if (array == null)
{
throw new ArgumentNullException(nameof(array));
}
if (array.Length != 1)
{
throw new ArgumentException("Array must contain a single element.", nameof(array));
}
return array[0];
}
Observe que nameof(array)
deve ser passado para cada exceção, embora já esteja claro no contexto qual argumento é inválido.
Design detalhado
Nos exemplos acima, incluindo a cadeia de caracteres "array != null"
ou "array.Length == 1"
na mensagem de declaração ajudaria o desenvolvedor a determinar o que falhou. Insira CallerArgumentExpression
: é um atributo que a estrutura pode usar para obter a cadeia de caracteres associada a um argumento de método específico. Adicionaríamos isso a Debug.Assert
da seguinte forma
public static class Debug
{
public static void Assert(bool condition, [CallerArgumentExpression("condition")] string message = null);
}
O código-fonte no exemplo acima permaneceria o mesmo. No entanto, o código que o compilador realmente emite corresponderia a
T Single<T>(this T[] array)
{
Debug.Assert(array != null, "array != null");
Debug.Assert(array.Length == 1, "array.Length == 1");
return array[0];
}
O compilador reconhece especialmente o atributo em Debug.Assert
. Ele passa a cadeia de caracteres associada ao argumento referenciado no construtor do atributo (nesse caso, condition
) no site de chamada. Quando uma das assertivas falhar, será mostrada ao desenvolvedor a condição que é falsa e ele saberá qual delas falhou.
Para validação de argumento, o atributo não pode ser usado diretamente, mas pode ser usado por meio de uma classe auxiliar:
public static class Verify
{
public static void Argument(bool condition, string message, [CallerArgumentExpression("condition")] string conditionExpression = null)
{
if (!condition) throw new ArgumentException(message: message, paramName: conditionExpression);
}
public static void InRange(int argument, int low, int high,
[CallerArgumentExpression("argument")] string argumentExpression = null,
[CallerArgumentExpression("low")] string lowExpression = null,
[CallerArgumentExpression("high")] string highExpression = null)
{
if (argument < low)
{
throw new ArgumentOutOfRangeException(paramName: argumentExpression,
message: $"{argumentExpression} ({argument}) cannot be less than {lowExpression} ({low}).");
}
if (argument > high)
{
throw new ArgumentOutOfRangeException(paramName: argumentExpression,
message: $"{argumentExpression} ({argument}) cannot be greater than {highExpression} ({high}).");
}
}
public static void NotNull<T>(T argument, [CallerArgumentExpression("argument")] string argumentExpression = null)
where T : class
{
if (argument == null) throw new ArgumentNullException(paramName: argumentExpression);
}
}
static T Single<T>(this T[] array)
{
Verify.NotNull(array); // paramName: "array"
Verify.Argument(array.Length == 1, "Array must contain a single element."); // paramName: "array.Length == 1"
return array[0];
}
static T ElementAt<T>(this T[] array, int index)
{
Verify.NotNull(array); // paramName: "array"
// paramName: "index"
// message: "index (-1) cannot be less than 0 (0).", or
// "index (6) cannot be greater than array.Length - 1 (5)."
Verify.InRange(index, 0, array.Length - 1);
return array[index];
}
Uma proposta para adicionar essa classe auxiliar à estrutura está em andamento em https://github.com/dotnet/corefx/issues/17068. Se esse recurso de linguagem tiver sido implementado, a proposta poderá ser atualizada para aproveitar esse recurso.
Métodos de extensão
O parâmetro this
em um método de extensão pode ser referenciado por CallerArgumentExpression
. Por exemplo:
public static void ShouldBe<T>(this T @this, T expected, [CallerArgumentExpression("this")] string thisExpression = null) {}
contestant.Points.ShouldBe(1337); // thisExpression: "contestant.Points"
thisExpression
receberá a expressão correspondente ao objeto antes do ponto. Se for chamado com sintaxe de método estático, por exemplo, Ext.ShouldBe(contestant.Points, 1337)
, ele se comportará como se o primeiro parâmetro não tivesse sido marcado this
.
Sempre deve haver uma expressão correspondente ao parâmetro this
. Mesmo que uma instância de uma classe invoque um método de extensão em si mesma, como no caso de this.Single()
dentro de um tipo de coleção, o this
é exigido pelo compilador para que "this"
seja passado. Se essa regra for alterada no futuro, podemos considerar passar null
ou a cadeia de caracteres vazia.
Detalhes extras
- Assim como os outros atributos
Caller*
, comoCallerMemberName
, esse atributo só pode ser usado em parâmetros com valores padrão. - Vários parâmetros marcados com
CallerArgumentExpression
são permitidos, conforme mostrado acima. - O namespace do atributo será
System.Runtime.CompilerServices
. - Se
null
ou uma cadeia de caracteres que não é um nome de parâmetro (por exemplo,"notAParameterName"
) for fornecida, o compilador passará uma cadeia de caracteres vazia. - O tipo do parâmetro ao qual
CallerArgumentExpressionAttribute
é aplicado deve ter uma conversão padrão destring
. Isso significa que nenhuma conversão definida pelo usuário destring
é permitida e, na prática, significa que o tipo desse parâmetro deve serstring
,object
ou interface implementada porstring
.
Inconvenientes
As pessoas que sabem como usar descompiladores poderão ver parte do código-fonte em sites de chamada para métodos marcados com esse atributo. Isso pode ser indesejável/inesperado para software de origem fechada.
Embora isso não seja uma falha no recurso em si, uma fonte de preocupação pode ser que exista uma API
Debug.Assert
hoje que usa apenas umbool
. Ainda que a sobrecarga que recebe uma mensagem tenha seu segundo parâmetro marcado com esse atributo e se torne opcional, o compilador ainda escolheria a versão sem mensagem na resolução de sobrecarga. Portanto, a sobrecarga de ausência de mensagem teria que ser removida para tirar proveito desse recurso, que seria uma alteração binária (embora não seja uma alteração no código-fonte).
Alternativas
- Se conseguir ver o código-fonte em locais de chamada para métodos que utilizam esse atributo se mostrar problemático, podemos tornar os efeitos desse atributo opcionais. Os desenvolvedores habilitarão isso por meio de um atributo
[assembly: EnableCallerArgumentExpression]
que é aplicado a todo o assembly emAssemblyInfo.cs
.- Caso os efeitos do atributo não estejam habilitados, chamar métodos marcados com o atributo não seria um erro, para permitir que os métodos existentes usem o atributo e mantenham a compatibilidade de origem. No entanto, o atributo seria ignorado e o método seria chamado com qualquer valor padrão fornecido.
// Assembly1
void Foo(string bar); // V1
void Foo(string bar, string barExpression = "not provided"); // V2
void Foo(string bar, [CallerArgumentExpression("bar")] string barExpression = "not provided"); // V3
// Assembly2
Foo(a); // V1: Compiles to Foo(a), V2, V3: Compiles to Foo(a, "not provided")
Foo(a, "provided"); // V2, V3: Compiles to Foo(a, "provided")
// Assembly3
[assembly: EnableCallerArgumentExpression]
Foo(a); // V1: Compiles to Foo(a), V2: Compiles to Foo(a, "not provided"), V3: Compiles to Foo(a, "a")
Foo(a, "provided"); // V2, V3: Compiles to Foo(a, "provided")
- Para impedir que o problema de compatibilidade binária ocorra sempre que quisermos adicionar novas informações do chamador ao
Debug.Assert
, uma solução alternativa seria adicionar um structCallerInfo
ao framework que contém todas as informações necessárias sobre o chamador.
struct CallerInfo
{
public string MemberName { get; set; }
public string TypeName { get; set; }
public string Namespace { get; set; }
public string FullTypeName { get; set; }
public string FilePath { get; set; }
public int LineNumber { get; set; }
public int ColumnNumber { get; set; }
public Type Type { get; set; }
public MethodBase Method { get; set; }
public string[] ArgumentExpressions { get; set; }
}
[Flags]
enum CallerInfoOptions
{
MemberName = 1, TypeName = 2, ...
}
public static class Debug
{
public static void Assert(bool condition,
// If a flag is not set here, the corresponding CallerInfo member is not populated by the caller, so it's
// pay-for-play friendly.
[CallerInfo(CallerInfoOptions.FilePath | CallerInfoOptions.Method | CallerInfoOptions.ArgumentExpressions)] CallerInfo callerInfo = default(CallerInfo))
{
string filePath = callerInfo.FilePath;
MethodBase method = callerInfo.Method;
string conditionExpression = callerInfo.ArgumentExpressions[0];
//...
}
}
class Bar
{
void Foo()
{
Debug.Assert(false);
// Translates to:
var callerInfo = new CallerInfo();
callerInfo.FilePath = @"C:\Bar.cs";
callerInfo.Method = MethodBase.GetCurrentMethod();
callerInfo.ArgumentExpressions = new string[] { "false" };
Debug.Assert(false, callerInfo);
}
}
Isso foi originalmente proposto em https://github.com/dotnet/csharplang/issues/87.
Há algumas desvantagens dessa abordagem:
Apesar de ser eficiente em cenários de pagamento conforme o uso, permitindo que você especifique quais propriedades são necessárias, isso ainda pode afetar negativamente o desempenho ao alocar uma matriz para as expressões/chamar
MethodBase.GetCurrentMethod
mesmo quando a assertiva é aprovada.Além disso, embora passar um novo sinalizador para o atributo
CallerInfo
não seja uma alteração muito importante, não há garantia de queDebug.Assert
realmente receberá esse novo parâmetro de sites de chamada compilados contra uma versão antiga do método.
Perguntas não resolvidas
TBD
Reuniões de design
N/A
C# feature specifications