Compartilhar via


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*, como CallerMemberName, 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 de string. Isso significa que nenhuma conversão definida pelo usuário de string é permitida e, na prática, significa que o tipo desse parâmetro deve ser string, objectou interface implementada por string.

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 um bool. 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 em AssemblyInfo.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 struct CallerInfo 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 que Debug.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