Tutoriel : Explorer les fonctionnalités de C# 11 - membres virtuels statiques dans les interfaces

C# 11 et .NET 7 incluent des membres virtuels statiques dans les interfaces. Cette fonctionnalité vous permet de définir des interfaces qui incluent des opérateurs surchargés ou d’autres membres statiques. Une fois que vous avez défini des interfaces avec des membres statiques, vous pouvez utiliser ces interfaces comme contraintes pour créer des types génériques qui utilisent des opérateurs ou d’autres méthodes statiques. Même si vous ne créez pas d’interfaces avec des opérateurs surchargés, vous tirerez probablement parti de cette fonctionnalité et des classes mathématiques génériques activées par la mise à jour du langage.

Ce didacticiel vous montre comment effectuer les opérations suivantes :

  • Définissez des interfaces avec des membres statiques.
  • Utilisez des interfaces pour définir des classes qui implémentent des interfaces avec des opérateurs définis.
  • Créez des algorithmes génériques qui s’appuient sur des méthodes d’interface statiques.

Prérequis

Vous devez configurer votre ordinateur pour exécuter .NET 7, qui prend en charge C# 11. Le compilateur C# 11 est disponible à partir de Visual Studio 2022 version 17.3 ou du SDK .NET 7.

Méthodes d’interface abstraite statiques

Commençons avec un exemple. La méthode suivante retourne le point médian de deux nombres double :

public static double MidPoint(double left, double right) =>
    (left + right) / (2.0);

La même logique fonctionne pour n’importe quel type numérique : int, short, long, floatdecimal ou tout type qui représente un nombre. Vous devez disposer d’un moyen d’utiliser les opérateurs + et / et de définir une valeur pour 2. Vous pouvez utiliser l’interface System.Numerics.INumber<TSelf> pour écrire la méthode précédente en tant que méthode générique suivante :

public static T MidPoint<T>(T left, T right)
    where T : INumber<T> => (left + right) / T.CreateChecked(2);  // note: the addition of left and right may overflow here; it's just for demonstration purposes

Tout type qui implémente l’interface INumber<TSelf> doit inclure une définition pour operator + et pour operator /. Le dénominateur est défini par T.CreateChecked(2) pour créer la valeur 2 pour n’importe quel type numérique, ce qui force le dénominateur à être du même type que les deux paramètres. INumberBase<TSelf>.CreateChecked<TOther>(TOther) crée une instance du type à partir de la valeur spécifiée et lève une OverflowException si la valeur se situe en dehors de la plage représentable. (Cette implémentation a le potentiel de dépassement si left et right sont toutes les deux des valeurs suffisamment grandes. Il existe d’autres algorithmes qui peuvent éviter ce problème potentiel.)

Vous définissez des membres abstraits statiques dans une interface à l’aide de la syntaxe familière : vous ajoutez les modificateurs static et abstract à tout membre statique qui ne fournit pas d’implémentation. L’exemple suivant définit une interface IGetNext<T> qui peut être appliquée à n’importe quel type qui remplace operator ++ :

public interface IGetNext<T> where T : IGetNext<T>
{
    static abstract T operator ++(T other);
}

La contrainte que l’argument de type, T, implémente IGetNext<T> garantit que la signature de l’opérateur inclut le type contenant ou son argument de type. De nombreux opérateurs imposent que ses paramètres correspondent au type ou soient le paramètre de type contraint d’implémenter le type contenant. Sans cette contrainte, l’opérateur ++ n’a pas pu être défini dans l’interface IGetNext<T>.

Vous pouvez créer une structure qui crée une chaîne de caractères « A » où chaque incrément ajoute un autre caractère à la chaîne à l’aide du code suivant :

public struct RepeatSequence : IGetNext<RepeatSequence>
{
    private const char Ch = 'A';
    public string Text = new string(Ch, 1);

    public RepeatSequence() {}

    public static RepeatSequence operator ++(RepeatSequence other)
        => other with { Text = other.Text + Ch };

    public override string ToString() => Text;
}

Plus généralement, vous pouvez créer n’importe quel algorithme dans lequel vous pouvez définir que ++ signifie « produire la valeur suivante de ce type ». L’utilisation de cette interface produit du code et des résultats clairs :

var str = new RepeatSequence();

for (int i = 0; i < 10; i++)
    Console.WriteLine(str++);

L’exemple précédent génère la sortie suivante :

A
AA
AAA
AAAA
AAAAA
AAAAAA
AAAAAAA
AAAAAAAA
AAAAAAAAA
AAAAAAAAAA

Ce petit exemple illustre la motivation de cette fonctionnalité. Vous pouvez utiliser la syntaxe naturelle pour les opérateurs, les valeurs constantes et d’autres opérations statiques. Vous pouvez explorer ces techniques lorsque vous créez plusieurs types qui s’appuient sur des membres statiques, y compris des opérateurs surchargés. Définissez les interfaces qui correspondent aux capacités de vos types, puis déclarez la prise en charge de ces types pour la nouvelle interface.

Mathématiques génériques

Le scénario le plus intéressant pour autoriser les méthodes statiques, y compris les opérateurs, dans les interfaces est de prendre en charge les algorithmes mathématiques génériques. La bibliothèque de classes de base .NET 7 contient des définitions d’interface pour de nombreux opérateurs arithmétiques et des interfaces dérivées qui combinent de nombreux opérateurs arithmétiques dans une interface INumber<T>. Appliquons ces types pour créer un enregistrement Point<T> qui peut utiliser n’importe quel type numérique pour T. Le point peut être déplacé par certains XOffset et YOffset à l’aide de l’opérateur +.

Commencez par créer une application Console, à l’aide de dotnet new ou de Visual Studio.

L’interface publique pour Translation<T> et Point<T> doit ressembler au code suivant :

// Note: Not complete. This won't compile yet.
public record Translation<T>(T XOffset, T YOffset);

public record Point<T>(T X, T Y)
{
    public static Point<T> operator +(Point<T> left, Translation<T> right);
}

Vous utilisez le type record pour les types Translation<T> et Point<T> : les deux valeurs stockent deux valeurs, et elles représentent le stockage des données plutôt que le comportement sophistiqué. L’implémentation de operator + ressemblerait au code suivant :

public static Point<T> operator +(Point<T> left, Translation<T> right) =>
    left with { X = left.X + right.XOffset, Y = left.Y + right.YOffset };

Pour que le code précédent soit compilé, vous devez déclarer que T prend en charge l’interface IAdditionOperators<TSelf, TOther, TResult>. Cette interface inclut la méthode statique operator +. Elle déclare trois paramètres de type : un pour l’opérande gauche, un pour l’opérande droit et un pour le résultat. Certains types implémentent + pour différents types d’opérandes et de résultats. Ajoutez une déclaration que l’argument de type, T implémente IAdditionOperators<T, T, T> :

public record Point<T>(T X, T Y) where T : IAdditionOperators<T, T, T>

Après avoir ajouté cette contrainte, votre classe Point<T> peut utiliser le + pour son opérateur d’ajout. Ajoutez la même contrainte à la déclaration Translation<T> :

public record Translation<T>(T XOffset, T YOffset) where T : IAdditionOperators<T, T, T>;

La contrainte IAdditionOperators<T, T, T> empêche un développeur qui utilise votre classe de créer un Translation à l’aide d’un type qui ne respecte pas la contrainte pour l’ajout à un point. Vous avez ajouté les contraintes nécessaires au paramètre de type pour Translation<T> et Point<T> pour que ce code fonctionne. Vous pouvez effectuer des tests en ajoutant du code comme ci-dessus en suivant les déclarations de Translation et Point dans votre fichier Program.cs :

var pt = new Point<int>(3, 4);

var translate = new Translation<int>(5, 10);

var final = pt + translate;

Console.WriteLine(pt);
Console.WriteLine(translate);
Console.WriteLine(final);

Vous pouvez rendre ce code plus réutilisable en déclarant que ces types implémentent les interfaces arithmétiques appropriées. La première modification à apporter consiste à déclarer qui Point<T, T> implémente l’interface IAdditionOperators<Point<T>, Translation<T>, Point<T>>. Le type Point utilise différents types pour les opérandes et le résultat. Le type Point implémente déjà un operator + avec cette signature. L’ajout de l’interface à la déclaration est donc tout ce dont vous avez besoin :

public record Point<T>(T X, T Y) : IAdditionOperators<Point<T>, Translation<T>, Point<T>>
    where T : IAdditionOperators<T, T, T>

Enfin, lorsque vous effectuez un ajout, il est utile d’avoir une propriété qui définit la valeur d’identité additive pour ce type. Il existe une nouvelle interface pour cette fonctionnalité : IAdditiveIdentity<TSelf,TResult>. Une traduction de {0, 0} est l’identité additive : le point résultant est le même que l’opérande gauche. L’interface IAdditiveIdentity<TSelf, TResult> définit une propriété en lecture seule, AdditiveIdentity, qui retourne la valeur d’identité. La Translation<T> a besoin de quelques modifications pour implémenter cette interface :

using System.Numerics;

public record Translation<T>(T XOffset, T YOffset) : IAdditiveIdentity<Translation<T>, Translation<T>>
    where T : IAdditionOperators<T, T, T>, IAdditiveIdentity<T, T>
{
    public static Translation<T> AdditiveIdentity =>
        new Translation<T>(XOffset: T.AdditiveIdentity, YOffset: T.AdditiveIdentity);
}

Il y a quelques changements ici. Nous allons donc les parcourir un par un. Tout d’abord, vous déclarez que le type Translation implémente l’interface IAdditiveIdentity :

public record Translation<T>(T XOffset, T YOffset) : IAdditiveIdentity<Translation<T>, Translation<T>>

Vous pouvez ensuite essayer d’implémenter le membre d’interface comme indiqué dans le code suivant :

public static Translation<T> AdditiveIdentity =>
    new Translation<T>(XOffset: 0, YOffset: 0);

Le code précédent ne sera pas compilé, car 0 dépend du type. Réponse : Utilisez IAdditiveIdentity<T>.AdditiveIdentity pour 0. Ce changement signifie que vos contraintes doivent maintenant inclure que T implémente IAdditiveIdentity<T>. Cela entraîne l’implémentation suivante :

public static Translation<T> AdditiveIdentity =>
    new Translation<T>(XOffset: T.AdditiveIdentity, YOffset: T.AdditiveIdentity);

Maintenant que vous avez ajouté cette contrainte sur Translation<T>, vous devez ajouter la même contrainte à Point<T> :

using System.Numerics;

public record Point<T>(T X, T Y) : IAdditionOperators<Point<T>, Translation<T>, Point<T>>
    where T : IAdditionOperators<T, T, T>, IAdditiveIdentity<T, T>
{
    public static Point<T> operator +(Point<T> left, Translation<T> right) =>
        left with { X = left.X + right.XOffset, Y = left.Y + right.YOffset };
}

Cet exemple vous a donné un aperçu de la composition des interfaces pour les mathématiques génériques. Vous avez appris à :

  • Écrivez une méthode qui s’appuie sur l’interface INumber<T> afin qu’elle puisse être utilisée avec n’importe quel type numérique.
  • Créez un type qui s’appuie sur les interfaces d’ajout pour implémenter un type qui ne prend en charge qu’une seule opération mathématique. Ce type déclare sa prise en charge pour ces mêmes interfaces afin qu’il puisse être composé d’autres manières. Les algorithmes sont écrits à l’aide de la syntaxe la plus naturelle des opérateurs mathématiques.

Testez ces fonctionnalités et inscrivez des commentaires. Vous pouvez utiliser l’élément de menu Envoyer des commentaires dans Visual Studio ou créer un problème dans le référentiel roslyn sur GitHub. Générez des algorithmes génériques qui fonctionnent avec n’importe quel type numérique. Générez des algorithmes à l’aide de ces interfaces où l’argument de type ne peut implémenter qu’un sous-ensemble de capacités de type nombre. Même si vous ne créez pas de nouvelles interfaces qui utilisent ces capacités, vous pouvez expérimenter leur utilisation dans vos algorithmes.

Voir aussi