Partager via


CallerArgumentExpression

Remarque

Cet article est une spécification de fonctionnalité. La spécification sert de document de conception pour la fonctionnalité. Elle inclut les changements de spécification proposés, ainsi que les informations nécessaires à la conception et au développement de la fonctionnalité. Ces articles sont publiés jusqu'à ce que les changements proposés soient finalisés et incorporés dans la spécification ECMA actuelle.

Il peut y avoir des divergences entre la spécification de la fonctionnalité et l'implémentation réalisée. Ces différences sont capturées dans les notes de réunion de conception de langage (LDM) pertinentes .

Vous pouvez en savoir plus sur le processus d’adoption des speclets de fonctionnalités dans la norme de langage C# dans l’article sur les spécifications.

Problème de champion : https://github.com/dotnet/csharplang/issues/287

Résumé

Permettre aux développeurs de capturer les expressions passées à une méthode, afin d’activer de meilleurs messages d’erreur dans les API de diagnostic/test et de réduire les séquences de touches.

Motivation

Lorsqu’une validation d’assertion ou d’argument échoue, le développeur souhaite savoir le plus possible où et pourquoi il a échoué. Toutefois, les API de diagnostic d’aujourd’hui ne facilitent pas entièrement cette opération. Considérez la méthode suivante :

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

    return array[0];
}

Lorsque l’une des assertions échoue, seul le nom de fichier, le numéro de ligne et le nom de la méthode sont fournis dans la trace de la pile. Le développeur ne pourra pas savoir quelle assertion a échoué à partir de ces informations : il devra ouvrir le fichier et accéder au numéro de ligne fourni pour voir ce qui s’est passé.

C’est également la raison pour laquelle les frameworks de test doivent fournir une variété de méthodes d’assertion. Avec xUnit, Assert.True et Assert.False ne sont pas fréquemment utilisés, car ils ne fournissent pas suffisamment de contexte sur ce qui a échoué.

Bien que la situation soit un peu meilleure pour la validation des arguments, car les noms des arguments non valides sont affichés au développeur, le développeur doit passer ces noms à des exceptions manuellement. Si l’exemple ci-dessus a été réécrit pour utiliser la validation d’argument traditionnel au lieu de Debug.Assert, il ressemblerait à

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

Notez que nameof(array) vous devez passer à chaque exception, bien qu’elle soit déjà claire du contexte dans lequel l’argument n’est pas valide.

Conception détaillée

Dans les exemples ci-dessus, y compris la chaîne "array != null" ou "array.Length == 1" dans le message d’assertion, aiderait le développeur à déterminer ce qui a échoué. Entrée CallerArgumentExpression: il s’agit d’un attribut que l’infrastructure peut utiliser pour obtenir la chaîne associée à un argument de méthode particulier. Nous l’ajouterions pour Debug.Assert le faire

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

Le code source de l’exemple ci-dessus reste le même. Toutefois, le code émis par le compilateur correspondrait à

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

    return array[0];
}

Le compilateur reconnaît spécialement l’attribut sur Debug.Assert. Il transmet la chaîne associée à l’argument référencé dans le constructeur de l’attribut (dans ce cas, condition) sur le site d’appel. Lorsque l’une ou l’autre assertion échoue, le développeur affiche la condition qui a été false et connaîtra l’échec de celui-ci.

Pour la validation des arguments, l’attribut ne peut pas être utilisé directement, mais peut être utilisé par le biais d’une classe d’assistance :

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

Une proposition d’ajout d’une telle classe d’assistance à l’infrastructure est en cours à https://github.com/dotnet/corefx/issues/17068. Si cette fonctionnalité de langage a été implémentée, la proposition peut être mise à jour pour tirer parti de cette fonctionnalité.

Méthodes d’extension

Le this paramètre d’une méthode d’extension peut être référencé par CallerArgumentExpression. Par exemple:

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

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

thisExpression reçoit l’expression correspondant à l’objet avant le point. S’il est appelé avec une syntaxe de méthode statique, par exemple Ext.ShouldBe(contestant.Points, 1337), il se comporte comme si le premier paramètre n’a pas été marqué this.

Il doit toujours y avoir une expression correspondant au this paramètre. Même si une instance d’une classe appelle une méthode d’extension sur elle-même, par exemple this.Single() à partir d’un type de collection, elle this est mandatée par le compilateur."this" Si cette règle est modifiée à l’avenir, nous pouvons envisager de transmettre null ou de vider la chaîne.

Détails supplémentaires

  • Comme les autres Caller* attributs, tels que CallerMemberName, cet attribut peut uniquement être utilisé sur les paramètres avec des valeurs par défaut.
  • Plusieurs paramètres marqués avec CallerArgumentExpression sont autorisés, comme indiqué ci-dessus.
  • L’espace de noms de l’attribut sera System.Runtime.CompilerServices.
  • Si null ou une chaîne qui n’est pas un nom de paramètre (par exemple "notAParameterName") est fournie, le compilateur passe une chaîne vide.
  • Le type du paramètre CallerArgumentExpressionAttribute est appliqué pour avoir une conversion standard à partir de string. Cela signifie qu’aucune conversion string définie par l’utilisateur n’est autorisée, et dans la pratique, le type de ce paramètre doit être string, objectou une interface implémentée par string.

Inconvénients

  • Les personnes qui savent comment utiliser des décompileurs pourront voir certains du code source sur les sites d’appel pour les méthodes marquées avec cet attribut. Cela peut être indésirable/inattendu pour les logiciels sources fermées.

  • Bien que ce ne soit pas un défaut dans la fonctionnalité elle-même, une source de préoccupation peut être qu’il existe une Debug.Assert API aujourd’hui qui ne prend qu’un bool. Même si la surcharge prenant un message avait son deuxième paramètre marqué avec cet attribut et rendu facultatif, le compilateur choisirait toujours le message unique dans la résolution de surcharge. Par conséquent, la surcharge sans message doit être supprimée pour tirer parti de cette fonctionnalité, ce qui serait un changement binaire (bien qu’il ne s’agit pas d’une source).

Alternatives

  • Si vous êtes en mesure de voir le code source sur les sites d’appel pour les méthodes qui utilisent cet attribut s’avère être un problème, nous pouvons rendre les effets de l’attribut opt-in. Les développeurs l’aideront par le biais d’un attribut à l’échelle [assembly: EnableCallerArgumentExpression] de l’assembly qu’ils mettent.AssemblyInfo.cs
    • Dans le cas où les effets de l’attribut ne sont pas activés, l’appel de méthodes marquées avec l’attribut ne serait pas une erreur, afin d’autoriser les méthodes existantes à utiliser l’attribut et à maintenir la compatibilité de la source. Toutefois, l’attribut est ignoré et la méthode est appelée avec la valeur par défaut fournie.
// 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")
  • Pour éviter que le problème de compatibilité binaire se produise chaque fois que nous voulons ajouter de nouvelles informations Debug.Assertd’appelant, une autre solution consiste à ajouter un CallerInfo struct au framework qui contient toutes les informations nécessaires sur l’appelant.
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);
    }
}

Ceci a été proposé à l’origine à https://github.com/dotnet/csharplang/issues/87.

Cette approche présente quelques inconvénients :

  • Bien qu’il soit convivial pour le paiement à la lecture en vous permettant de spécifier les propriétés dont vous avez besoin, cela peut encore nuire considérablement en allouant un tableau pour les expressions/appels MethodBase.GetCurrentMethod même lorsque l’assertion passe.

  • En outre, lors du passage d’un nouvel indicateur à l’attribut CallerInfo ne sera pas une modification cassante, Debug.Assert ne sera pas garanti de recevoir réellement ce nouveau paramètre des sites d’appel compilés par rapport à une ancienne version de la méthode.

Questions non résolues

À déterminer

Concevoir des réunions

N/A