Contraintes sur les paramètres de type (Guide de programmation C#)

Les contraintes informent le compilateur sur les fonctionnalités que doit avoir un argument de type. Sans contrainte, l’argument de type peut être n’importe quel type. Le compilateur peut seulement deviner les membres de System.Object, qui est la classe de base par excellence de tous les types .NET. Pour plus d’informations, consultez Pourquoi utiliser des contraintes. Si le code client utilise un type qui ne satisfait pas à une contrainte, le compilateur émet une erreur. Les contraintes sont spécifiées à l’aide du mot clé contextuel where. Le tableau suivant liste plusieurs types de contrainte :

Contrainte Description
where T : struct L’argument de type doit être un type valeur non-nullable qui inclut des types record struct. Pour plus d’informations sur les types valeur pouvant accepter la valeur Null, consultez Types valeur pouvant accepter la valeur Null. Étant donné que tous les types valeur ont un constructeur sans paramètre accessible, déclaré ou implicite, la contrainte struct implique la contrainte new() et ne peut pas être associée à la contrainte new(). Vous ne pouvez pas combiner la contrainte struct avec la contrainte unmanaged.
where T : class L’argument de type doit être un type référence. Cette contrainte s’applique également à tous les types de classe, d’interface, de délégué ou de tableau. Dans un contexte pouvant accepter la valeur Null, T doit être un type référence non nullable.
where T : class? L’argument de type doit être un type référence, pouvant accepter la valeur Null ou non nullable. Cette contrainte s’applique également à tous les types de classe, d’interface, de délégué ou de tableau, notamment les enregistrements.
where T : notnull L’argument de type doit être un type non-nullable. L’argument peut être un type référence non nullable ou un type valeur non nullable.
where T : unmanaged L’argument de type doit être un type non généré non-nullable. La contrainte unmanaged implique la contrainte struct et ne peut pas être combinée avec les contraintes struct ou new().
where T : new() L’argument de type doit avoir un constructeur sans paramètre public. Quand vous utilisez la contrainte new() avec d’autres contraintes, elle doit être spécifiée en dernier. La contrainte new() ne peut pas être combinée avec les contraintes struct et unmanaged.
where T :<nom de la classe de base> L’argument de type doit être la classe de base spécifiée ou en dériver. Dans un contexte pouvant accepter la valeur Null, T doit être un type référence non nullable dérivé de la classe de base spécifiée.
where T :<nom de la classe de base >? L’argument de type doit être la classe de base spécifiée ou en dériver. Dans un contexte pouvant accepter la valeur Null, T peut être un type pouvant accepter la valeur Null ou non-nullable dérivé de la classe de base spécifiée.
where T :<nom de l’interface> L’argument de type doit être ou implémenter l’interface spécifiée. Plusieurs contraintes d’interface peuvent être spécifiées. L’interface qui impose les contraintes peut également être générique. Dans un contexte pouvant accepter la valeur Null, T doit être un type non nullable qui implémente l’interface spécifiée.
where T :<nom de l’interface >? L’argument de type doit être ou implémenter l’interface spécifiée. Plusieurs contraintes d’interface peuvent être spécifiées. L’interface qui impose les contraintes peut également être générique. Dans un contexte pouvant accepter la valeur Null, T peut être un type référence null, un type référence non-nullable ou un type valeur. T ne peut pas être un type valeur pouvant accepter la valeur Null.
where T : U L’argument de type fourni pour T doit être l’argument fourni pour U ou en dériver. Dans un contexte pouvant accepter la valeur Null, si U est un type référence non-nullable, T doit être un type référence non-nullable. Si U est un type référence pouvant accepter la valeur Null, T peut accepter la valeur Null ou être non-nullable.
where T : default Cette contrainte résout l’ambiguïté lorsque vous devez spécifier un paramètre de type sans contrainte lorsque vous remplacez une méthode ou fournissez une implémentation d’interface explicite. La contrainte default implique la méthode de base sans la contrainte class ou struct. Pour plus d’informations, consultez la proposition de spécification default de contrainte.

Certaines contraintes sont incompatibles et certaines contraintes doivent être dans un ordre spécifié :

  • Vous pouvez appliquer au maximum une des contraintes struct, class, class?, notnull, et unmanaged. Si vous fournissez une de ces contraintes, il doit s’agir de la première contrainte spécifiée pour ce paramètre de type.
  • La contrainte de classe de base, (where T : Base ou where T : Base?), ne peut pas être associée à l’une des contraintes struct, class, class?, notnull ou unmanaged.
  • Vous pouvez appliquer au maximum une contrainte de classe de base dans l’un des formulaires. Si vous souhaitez prendre en charge le type de base pouvant accepter la valeur Null, utilisez Base?.
  • Vous ne pouvez pas nommer en tant que contrainte le formulaire non-nullable et le formulaire pouvant accepter la valeur Null d’une interface.
  • La contrainte new() ne peut pas être combinée avec la contrainte struct ou unmanaged. Si vous spécifiez la contrainte new(), il doit s’agir de la dernière contrainte pour ce paramètre de type.
  • Vous pouvez appliquer uniquement la contrainte default sur des implémentations d'interface explicite ou de remplacement. Elle ne peut pas être associée aux contraintes struct ou class.

Pourquoi utiliser des contraintes

Les contraintes spécifient les fonctionnalités et les attentes d’un paramètre de type. La déclaration de ces contraintes signifie que vous pouvez utiliser les opérations et les appels de méthode du type de contrainte. Vous appliquez des contraintes au paramètre de type lorsque votre classe ou votre méthode générique utilise une opération sur les membres génériques au-delà de l’assignation simple, ce qui inclut l’appel de toute méthode non prise en charge par System.Object. Par exemple, la contrainte de classe de base indique au compilateur que seuls les objets de ce type ou dérivés de ce type peuvent remplacer cet argument de type. Une fois que le compilateur a cette garantie, il peut autoriser les méthodes de ce type à être appelées dans la classe générique. L’exemple de code suivant illustre la fonctionnalité que vous pouvez ajouter à la classe GenericList<T> (dans Introduction aux génériques) en appliquant une contrainte de classe de base.

public class Employee
{
    public Employee(string name, int id) => (Name, ID) = (name, id);
    public string Name { get; set; }
    public int ID { get; set; }
}

public class GenericList<T> where T : Employee
{
    private class Node
    {
        public Node(T t) => (Next, Data) = (null, t);

        public Node? Next { get; set; }
        public T Data { get; set; }
    }

    private Node? head;

    public void AddHead(T t)
    {
        Node n = new Node(t) { Next = head };
        head = n;
    }

    public IEnumerator<T> GetEnumerator()
    {
        Node? current = head;

        while (current != null)
        {
            yield return current.Data;
            current = current.Next;
        }
    }

    public T? FindFirstOccurrence(string s)
    {
        Node? current = head;
        T? t = null;

        while (current != null)
        {
            //The constraint enables access to the Name property.
            if (current.Data.Name == s)
            {
                t = current.Data;
                break;
            }
            else
            {
                current = current.Next;
            }
        }
        return t;
    }
}

La contrainte permet à la classe générique d’utiliser la propriété Employee.Name. La contrainte spécifie que tous les éléments de type T sont soit un objet Employee, soit un objet qui hérite de Employee, et rien d’autre.

Plusieurs contraintes peuvent être appliquées au même paramètre de type, et les contraintes elles-mêmes peuvent être des types génériques, comme suit :

class EmployeeList<T> where T : Employee, System.Collections.Generic.IList<T>, IDisposable, new()
{
    // ...
}

Lorsque vous appliquez la contrainte where T : class, évitez d’utiliser les opérateurs == et != sur le paramètre de type, car ces opérateurs testent uniquement l’identité des références, et non l’égalité des valeurs. Ce comportement se produit même si ces opérateurs sont surchargés dans un type qui est utilisé comme argument. Le code suivant illustre ce point ; la sortie a la valeur false même si la classe String surcharge l’opérateur ==.

public static void OpEqualsTest<T>(T s, T t) where T : class
{
    System.Console.WriteLine(s == t);
}

private static void TestStringEquality()
{
    string s1 = "target";
    System.Text.StringBuilder sb = new System.Text.StringBuilder("target");
    string s2 = sb.ToString();
    OpEqualsTest<string>(s1, s2);
}

Au moment de la compilation, le compilateur sait uniquement que T est un type référence et doit utiliser les opérateurs par défaut qui sont valides pour tous les types référence. Si vous devez tester l’égalité des valeurs, appliquez la contrainte where T : IEquatable<T> ou where T : IComparable<T> et implémentez l’interface sur toute classe utilisée pour construire la classe générique.

Utilisation de contraintes dans plusieurs paramètres

Vous pouvez appliquer des contraintes à plusieurs paramètres et plusieurs contraintes à un seul paramètre, comme indiqué dans l’exemple suivant :

class Base { }
class Test<T, U>
    where U : struct
    where T : Base, new()
{ }

Paramètres de type unbounded

Les paramètres de type qui n’ont aucune contrainte, tels que T dans la classe publique SampleClass<T>{}, sont appelés paramètres de type unbounded. Les paramètres de type unbounded obéissent aux règles suivantes :

  • Les opérateurs != et == ne peuvent pas être utilisés, car il n’est pas garanti que l’argument de type concret les prend en charge.
  • Ils peuvent être convertis vers et depuis System.Object ou être explicitement convertis vers tout type d’interface.
  • Vous pouvez les comparer à null. Si un paramètre illimité est comparé à null, la comparaison retourne toujours la valeur false si l’argument de type est un type valeur.

Paramètres de type en tant que contraintes

L’utilisation d’un paramètre de type générique comme contrainte est utile quand une fonction membre dotée de son propre paramètre de type doit contraindre ce paramètre au paramètre du type conteneur, comme le montre l’exemple suivant :

public class List<T>
{
    public void Add<U>(List<U> items) where U : T {/*...*/}
}

Dans l’exemple précédent, T est une contrainte de type dans le contexte de la méthode Add et un paramètre de type unbounded dans le contexte de la classe List.

Les paramètres de type peuvent également être utilisés comme contraintes dans les définitions de classes génériques. Le paramètre de type doit être déclaré entre crochets pointus, ainsi que tous les autres paramètres de type :

//Type parameter V is used as a type constraint.
public class SampleClass<T, U, V> where T : V { }

L’utilité des paramètres de type en tant que contraintes avec les classes génériques est limitée, car le compilateur ne peut rien deviner à propos du paramètre de type en dehors du fait qu’il dérive de System.Object. Utilisez des paramètres de type en tant que contraintes sur les classes génériques dans les scénarios dans lesquels vous souhaitez mettre en application une relation d’héritage entre deux paramètres de type.

Contrainte notnull

Vous pouvez utiliser la contrainte notnull pour spécifier que l’argument de type doit être un type valeur non nullable ou un type référence non nullable. Contrairement à la plupart d’autres contraintes, si un argument de type enfreint la contrainte notnull, le compilateur génère un avertissement au lieu d’une erreur.

La contrainte notnull a un effet uniquement lorsqu’elle est utilisée dans un contexte pouvant accepter la valeur Null. Si vous ajoutez la contrainte notnull dans un contexte inconscient pouvant accepter la valeur Null, le compilateur ne génère pas d’avertissements ou d’erreurs pour les violations de la contrainte.

Contrainte class

La contrainte class dans un contexte pouvant accepter la valeur Null spécifie que l’argument de type doit être un type référence non nullable. Dans un contexte pouvant accepter la valeur Null, lorsqu’un argument de type est un type référence pouvant accepter la valeur Null, le compilateur génère un avertissement.

Contrainte default

L’ajout de types référence pouvant accepter la valeur Null complique l’utilisation de T? dans un type ou une méthode générique. T? peut être utilisé avec la contrainte struct ou class, mais l’une d’elles doit être présente. Lorsque la contrainte class a été utilisée, T? fait référence au type de référence pouvant accepter la valeur nulle pour T. T? peut être utilisé lorsqu’aucune contrainte n’est appliquée. Dans ce cas, T? est interprété comme T? pour les types valeur et les types référence. Toutefois, si T est une instance de Nullable<T>, T? est identique à T. En d’autres termes, il ne devient pas T??.

Étant donné que T? peut désormais être utilisé sans contrainte class ou struct, des ambiguïtés peuvent survenir dans les remplacements ou les implémentations d’interface explicites. Dans ces deux cas, le remplacement n’inclut pas les contraintes, mais les hérite de la classe de base. Lorsque la classe de base n’applique pas la contrainte class ou struct, les classes dérivées doivent spécifier un remplacement qui s’applique à la méthode de base sans l’une ou l’autre contrainte. La méthode dérivée applique la contrainte default. La contrainte default ne clarifie ni la contrainte class ni la contrainte struct.

Contrainte non managée

Vous pouvez utiliser la contrainte unmanaged pour spécifier que le paramètre de type doit être un type non managé non-nullable. La contrainte unmanaged vous permet d’écrire des routines réutilisables à appliquer aux types qui peuvent être manipulés comme blocs de mémoire, comme illustré dans l’exemple suivant :

unsafe public static byte[] ToByteArray<T>(this T argument) where T : unmanaged
{
    var size = sizeof(T);
    var result = new Byte[size];
    Byte* p = (byte*)&argument;
    for (var i = 0; i < size; i++)
        result[i] = *p++;
    return result;
}

La méthode précédente doit être compilée dans un contexte unsafe, car elle utilise l’opérateur sizeof sur un type qui n’est pas connu pour être un type intégré. Sans la contrainte unmanaged, l’opérateur sizeof n’est pas disponible.

La contrainte unmanaged implique la contrainte struct et ne peut pas être combinée avec. Étant donné que la contrainte struct implique la contrainte new(), mais la contrainte unmanaged ne peut pas être combinée avec la contrainte new().

Contraintes de délégué

Vous pouvez utiliser System.Delegate ou System.MulticastDelegate comme contrainte de classe de base. Le CLR a toujours autorisé cette contrainte, contrairement au langage C#. La contrainte System.Delegate vous permet d’écrire du code qui fonctionne avec les délégués en mode type sécurisé. Le code suivant définit une méthode d’extension qui combine deux délégués du moment qu’ils sont du même type :

public static TDelegate? TypeSafeCombine<TDelegate>(this TDelegate source, TDelegate target)
    where TDelegate : System.Delegate
    => Delegate.Combine(source, target) as TDelegate;

Vous pouvez utiliser la méthode ci-dessus pour combiner des délégués qui sont du même type :

Action first = () => Console.WriteLine("this");
Action second = () => Console.WriteLine("that");

var combined = first.TypeSafeCombine(second);
combined!();

Func<bool> test = () => true;
// Combine signature ensures combined delegates must
// have the same type.
//var badCombined = first.TypeSafeCombine(test);

Si vous supprimez les commentaires de la dernière ligne, il ne sera pas compilé. first et test sont tous deux des types délégués, mais des types délégués différents.

Contraintes d’enum

Vous pouvez également spécifier le type System.Enum comme contrainte de classe de base. Le CLR a toujours autorisé cette contrainte, contrairement au langage C#. Les génériques utilisant System.Enum fournissent une programmation de type sécurisé aux résultats de cache issus de l’utilisation de méthodes statiques dans System.Enum. L’exemple suivant recherche toutes les valeurs valides d’un type enum, puis génère un dictionnaire qui mappe ces valeurs à sa représentation sous forme de chaîne.

public static Dictionary<int, string> EnumNamedValues<T>() where T : System.Enum
{
    var result = new Dictionary<int, string>();
    var values = Enum.GetValues(typeof(T));

    foreach (int item in values)
        result.Add(item, Enum.GetName(typeof(T), item)!);
    return result;
}

Enum.GetValues et Enum.GetName utilisent la réflexion, ce qui a des répercussions sur les performances. Vous pouvez appeler EnumNamedValues pour générer une collection qui est mise en cache et réutilisée, plutôt que de répéter les appels qui nécessitent la réflexion.

Vous pouvez l’utiliser comme montré dans l’exemple suivant pour créer un enum et générer un dictionnaire de ses valeurs et de ses noms :

enum Rainbow
{
    Red,
    Orange,
    Yellow,
    Green,
    Blue,
    Indigo,
    Violet
}
var map = EnumNamedValues<Rainbow>();

foreach (var pair in map)
    Console.WriteLine($"{pair.Key}:\t{pair.Value}");

Arguments de type implémentant l’interface déclarée

Certains scénarios nécessitent qu’un argument fourni pour un paramètre de type implémente cette interface. Par exemple :

public interface IAdditionSubtraction<T> where T : IAdditionSubtraction<T>
{
    public abstract static T operator +(T left, T right);
    public abstract static T operator -(T left, T right);
}

Ce modèle permet au compilateur C# de déterminer le type contenant pour les opérateurs surchargés, ou toute static virtual méthode ou static abstract. Il fournit la syntaxe afin que les opérateurs d’addition et de soustraction puissent être définis sur un type contenant. Sans cette contrainte, les paramètres et les arguments doivent être déclarés comme interface, plutôt que comme paramètre de type :

public interface IAdditionSubtraction<T> where T : IAdditionSubtraction<T>
{
    public abstract static IAdditionSubtraction<T> operator +(
        IAdditionSubtraction<T> left,
        IAdditionSubtraction<T> right);

    public abstract static IAdditionSubtraction<T> operator -(
        IAdditionSubtraction<T> left,
        IAdditionSubtraction<T> right);
}

La syntaxe précédente exigerait que les implémenteurs utilisent l’implémentation d’interface explicite pour ces méthodes. La fourniture de la contrainte supplémentaire permet à l’interface de définir les opérateurs en termes de paramètres de type. Les types qui implémentent l’interface peuvent implémenter implicitement les méthodes d’interface.

Voir aussi