Condividi tramite


CallerArgumentExpression

Nota

Questo articolo è una specifica di funzionalità. La specifica funge da documento di progettazione per la funzionalità. Include le modifiche specifiche proposte, insieme alle informazioni necessarie durante la progettazione e lo sviluppo della funzionalità. Questi articoli vengono pubblicati fino a quando le modifiche specifiche proposte non vengono completate e incorporate nella specifica ECMA corrente.

Potrebbero verificarsi alcune discrepanze tra la specifica di funzionalità e l'implementazione completata. Queste differenze vengono riportate nelle note pertinenti della riunione di progettazione del linguaggio (LDM) .

Ulteriori informazioni sul processo di adozione degli speclet delle funzionalità nello standard del linguaggio C# possono essere trovate nell'articolo sulle specifiche .

Problema del campione: https://github.com/dotnet/csharplang/issues/287

Sommario

Consentire agli sviluppatori di registrare le espressioni passate a un metodo, per abilitare messaggi di errore migliori nelle API di diagnostica/test e ridurre le digitazioni.

Motivazione

Quando una convalida di un'asserzione o di un argomento ha esito negativo, lo sviluppatore vuole sapere quanto più possibile su dove e perché ha avuto esito negativo. Tuttavia, le API di diagnostica odierne non facilitano completamente questa operazione. Si consideri il metodo seguente:

T Single<T>(this T[] array)
{
    Debug.Assert(array != null);
    Debug.Assert(array.Length == 1);

    return array[0];
}

Quando una delle asserzioni ha esito negativo, nell'analisi dello stack verranno forniti solo il nome file, il numero di riga e il nome del metodo. Lo sviluppatore non sarà in grado di indicare quale asserzione non è riuscita da queste informazioni: dovranno aprire il file e passare al numero di riga specificato per vedere cosa è andato storto.

Questo è anche il motivo per cui i framework di test devono fornire un'ampia gamma di metodi di asserzione. Con xUnit, Assert.True e Assert.False non vengono spesso usati perché non forniscono un contesto sufficiente su ciò che non è riuscito.

Anche se la situazione è un po' migliore per la convalida degli argomenti perché i nomi degli argomenti non validi vengono visualizzati allo sviluppatore, lo sviluppatore deve passare questi nomi alle eccezioni manualmente. Se l'esempio precedente è stato riscritto per usare la convalida degli argomenti tradizionale anziché Debug.Assert, l'aspetto sarà simile al seguente

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];
}

Si noti che nameof(array) deve essere passato a ogni eccezione, anche se è già chiaro dal contesto quale argomento sia non valido.

Progettazione dettagliata

Negli esempi precedenti, includendo la stringa "array != null" o "array.Length == 1" nel messaggio di assert, lo sviluppatore potrebbe determinare cosa è fallito. Immettere CallerArgumentExpression: si tratta di un attributo che il framework può usare per ottenere la stringa associata a un argomento del metodo specifico. Lo aggiungeremo a Debug.Assert così

public static class Debug
{
    public static void Assert(bool condition, [CallerArgumentExpression("condition")] string message = null);
}

Il codice sorgente nell'esempio precedente rimane invariato. Tuttavia, il codice effettivamente generato dal compilatore corrisponderebbe a

T Single<T>(this T[] array)
{
    Debug.Assert(array != null, "array != null");
    Debug.Assert(array.Length == 1, "array.Length == 1");

    return array[0];
}

Il compilatore riconosce appositamente l'attributo in Debug.Assert. Passa la stringa associata all'argomento a cui si fa riferimento nel costruttore dell'attributo (in questo caso, condition) al punto di chiamata. Quando una delle due asserzioni ha esito negativo, lo sviluppatore visualizzerà la condizione falsa e saprà quale è fallita.

Per la convalida degli argomenti, l'attributo non può essere usato direttamente, ma può essere usato tramite una classe helper:

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];
}

È in corso la proposta di aggiungere una classe helper al framework in https://github.com/dotnet/corefx/issues/17068. Se questa funzionalità del linguaggio è stata implementata, la proposta potrebbe essere aggiornata per sfruttare questa funzionalità.

Metodi di estensione

È possibile che il parametro this in un metodo di estensione venga riferito da CallerArgumentExpression. Per esempio:

public static void ShouldBe<T>(this T @this, T expected, [CallerArgumentExpression("this")] string thisExpression = null) {}

contestant.Points.ShouldBe(1337); // thisExpression: "contestant.Points"

thisExpression riceverà l'espressione corrispondente all'oggetto prima del punto. Se viene chiamato con la sintassi del metodo statico, ad esempio Ext.ShouldBe(contestant.Points, 1337), si comporterà come se il primo parametro non fosse contrassegnato this.

Deve essere sempre presente un'espressione corrispondente al parametro this. Anche se un'istanza di una classe chiama un metodo di estensione, ad esempio this.Single() dall'interno di un tipo di raccolta, il this viene imposto dal compilatore in modo che "this" venga passato. Se questa regola viene modificata in futuro, è possibile passare null o la stringa vuota.

Dettagli aggiuntivi

  • Analogamente agli altri attributi Caller*, ad esempio CallerMemberName, questo attributo può essere usato solo nei parametri con valori predefiniti.
  • Sono consentiti più parametri contrassegnati con CallerArgumentExpression, come illustrato in precedenza.
  • Lo spazio dei nomi dell'attributo sarà System.Runtime.CompilerServices.
  • Se null o una stringa che non è un nome di parametro (ad esempio, "notAParameterName") viene fornito, il compilatore passerà una stringa vuota.
  • Il tipo a cui è applicato il parametro CallerArgumentExpressionAttribute deve avere una conversione standard da string. Ciò significa che non sono consentite conversioni definite dall'utente da string e in pratica significa che il tipo di tale parametro deve essere string, objecto un'interfaccia implementata da string.

Svantaggi

  • Gli utenti che sanno come usare i decompilatori potranno visualizzare parte del codice sorgente nei siti di chiamata per i metodi contrassegnati con questo attributo. Questo può essere indesiderato/imprevisto per il software di origine chiusa.

  • Anche se questo non è un difetto nella funzionalità stessa, una fonte di preoccupazione potrebbe essere che esiste un'API Debug.Assert oggi che accetta solo un bool. Anche se l'overload che accetta un messaggio aveva il secondo parametro contrassegnato con questo attributo e reso facoltativo, il compilatore avrebbe comunque scelto quello senza messaggio nella risoluzione dell'overload. Pertanto, il sovraccarico senza messaggio deve essere rimosso per sfruttare questa funzionalità, il che rappresenterebbe una modifica al livello binario (anche se non del codice sorgente) che causerà un'interruzione.

Alternative

  • Se la possibilità di visualizzare il codice sorgente nei siti di chiamata per i metodi che usano questo attributo si dimostra essere un problema, possiamo fare in modo che gli effetti dell'attributo siano opzionali. Gli sviluppatori lo abiliteranno tramite un attributo [assembly: EnableCallerArgumentExpression] a livello di assembly inserito in AssemblyInfo.cs.
    • Nel caso in cui gli effetti dell'attributo non siano abilitati, la chiamata di metodi contrassegnati con l'attributo non sarebbe un errore, per consentire ai metodi esistenti di usare l'attributo e mantenere la compatibilità dell'origine. Tuttavia, l'attributo verrà ignorato e il metodo verrà chiamato con qualsiasi valore predefinito specificato.
// 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")
  • Per evitare che si verifichi il problema di compatibilità binaria ogni volta che si desidera aggiungere nuove informazioni sul chiamante a Debug.Assert, una soluzione alternativa consiste nell'aggiungere uno struct CallerInfo al framework che contiene tutte le informazioni necessarie sul chiamante.
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);
    }
}

Questo è stato originariamente proposto in https://github.com/dotnet/csharplang/issues/87.

Questo approccio presenta alcuni svantaggi:

  • Nonostante favorisca un approccio pay-for-play consentendo di specificare le proprietà necessarie, potrebbe comunque danneggiare significativamente le prestazioni allocando un array per le espressioni o chiamando MethodBase.GetCurrentMethod, anche quando l'asserzione passa.

  • Inoltre, mentre passando un nuovo flag all'attributo CallerInfo non sarà una modifica di rilievo, Debug.Assert non sarà garantito di ricevere effettivamente quel nuovo parametro dai siti di chiamata compilati in base a una versione precedente del metodo.

Domande non risolte

TBD

Riunioni di progettazione

N/D