V. Contraintes sur les types génériques▲
Bien, vous connaissez maintenant les bases. Il est temps de passer aux choses sérieuses, à savoir : les contraintes.
Comme leur nom l'indique, les contraintes permettent d'imposer des restrictions sur les types réels qui peuvent être utilisés pour remplacer un paramètre formel de type. Pour continuer la comparaison avec les paramètres de méthode : une contrainte est au type ce que le type est à la variable paramètre. Pas clair ? Bon, lorsque vous spécifiez un type pour un paramètre, vous ne pouvez lui transmettre que des valeurs qui sont compatibles avec ce type. Lorsque vous spécifiez une contrainte sur un paramètre de type, vous devez le remplacer par un type réel qui satisfait ces contraintes.
V-A. Quelles sont les contraintes possibles ?▲
Il n'existe qu'un nombre restreint de contraintes possibles. En fait, il n'en existe que de trois types, dont un pour lequel je n'ai pas trouvé d'utilité :-s.
On peut donc restreindre un type générique à être :
- un type classe descendant d'une classe donnée ;
- un type interface descendant d'une interface donnée, ou un type classe implémentant cette interface ;
- un type ordinal, flottant ou record ;
- un type classe qui possède un constructeur sans argument.
Pour imposer une contrainte à un paramètre générique, on note :
type
TStreamGenericType<T: TStream> = class
end
;
TIntfListGenericType<T: IInterfaceList> = class
end
;
TSimpleTypeGenericType<T: record
> = class
end
;
TConstructorGenericType<T: constructor
> = class
end
;
Ce qui impose, respectivement, que T devra être remplacé par la classe TStream ou une de ses descendantes ; par IInterfaceList ou une de ses descendantes ; par un type ordinal, flottant ou record (un type de valeur non null, selon la terminologie de Delphi) ; ou enfin par un type classe qui possède un constructeur public sans argument.
<T: class> doit être employé à la place de <T: TObject>… On comprend bien que class soit accepté, mais on se demande pourquoi TObject est rejeté.
Il est possible de combiner plusieurs contraintes interface, ou une contrainte classe et une à plusieurs contraintes interface. Dans ce cas, le type réel utilisé doit satisfaire toutes les contraintes en même temps. La contrainte constructor peut également être combinée avec des contraintes classe et/ou interface. Il est même possible de combiner record avec une ou plusieurs contraintes interface, mais je ne vois pas comment un type pourrait bien satisfaire les deux en même temps (en .NET, c'est possible, mais, pour l'instant, pas en Win32) !
Il se pourrait, mais ce n'est que pure spéculation de ma part, que ce soit en prévision d'une version future, dans laquelle le type Integer, par exemple, « implémenterait » l'interface IComparable<Integer>. Ce qui en ferait donc un type satisfaisant les deux contraintes d'une déclaration comme <T: record, IComparable<T>>.
On peut aussi utiliser une classe ou interface générique comme contrainte, avec donc un paramètre à spécifier. Ce paramètre peut être le type T lui-même. Par exemple, on peut vouloir imposer que le type d'éléments doive pouvoir se comparer avec lui-même. On utilisera alors :
type
TSortedList<T: IComparable<T>> = class
(TObject)
end
;
Si l'on veut en plus que T soit un type classe, on peut combiner :
type
TSortedList<T: class
, IComparable<T>> = class
(TObject)
end
;
V-B. Mais à quoi ça sert ?▲
« Je croyais que le but des génériques était justement d'écrire une fois le code pour tous les types. Quel est alors l'intérêt de restreindre les types possibles ? »
Eh bien cela permet au compilateur d'avoir plus d'informations sur le type utilisé. Par exemple, cela lui permet de savoir, avec un type <T: class>, qu'il est légitime d'appeler la méthode Free dessus. Ou avec un type <T: IComparable<T>>, qu'il est possible d'écrire Left.CompareTo(Right).
Pour illustrer cela, nous allons créer une classe fille de TTreeNode<T>, TObjectTreeNode<T: class>. À l'instar de TObjectList<T: class> qui propose de libérer automatiquement ses éléments lorsque la liste est détruite, notre classe libérera sa valeur étiquetée lors de la destruction.
En fait, cela fait donc très peu de code, que je vais donner en une fois :
type
{*
Structure arborescente générique dont les valeurs sont des objets
Lorsque le noeud est libéré, la valeur étiquetée est libérée également.
*}
TObjectTreeNode<T: class
> = class
(TTreeNode<T>)
public
destructor
Destroy; override
;
end
;
{--------------------}
{ TObjectTreeNode<T> }
{--------------------}
{*
[@inheritDoc]
*}
destructor
TObjectTreeNode<T>.Destroy;
begin
Value.Free;
inherited
;
end
;
Voilà, c'est tout. Le but est uniquement de montrer la technique. Pas d'avoir un truc exceptionnel.
Il y a deux choses à constater ici. D'abord, on peut faire hériter une classe générique d'une autre classe générique, en réutilisant le paramètre générique (ou pas, d'ailleurs).
Ensuite, dans l'implémentation des méthodes d'une classe générique avec contraintes, les contraintes ne doivent pas (et ne peuvent pas) être répétées.
Vous pouvez par ailleurs supprimer la contrainte et tenter de compiler. Le compilateur vous arrêtera sur l'appel à Free. En effet, Free n'est pas disponible sur n'importe quel type. Mais sur n'importe quelle classe, bien.
V-C. Une variante avec constructor▲
Vous pourriez aussi vouloir que les deux constructeurs sans paramètre AValue de TObjectTreeNode<T> créent un objet pour AValue au lieu d'utiliser Default(T) (qui, au passage, renvoie nil ici, car T est contraint à être une classe).
Vous pouvez, pour cela, utiliser la contrainte constructor, ce qui donne :
type
{*
Structure arborescente générique dont les valeurs sont des objets
Lorsqu'un nœud est créé sans valeur étiquetée, une nouvelle valeur est
créée avec le constructeur sans paramètre du type choisi.
Lorsque le noeud est libéré, la valeur étiquetée est libérée également.
*}
TCreateObjectTreeNode<T: class
, constructor
> = class
(TObjectTreeNode<T>)
public
constructor
Create(AParent: TTreeNode<T>); overload
;
constructor
Create; overload
;
end
;
implementation
{*
[@inheritDoc]
*}
constructor
TCreateObjectTreeNode<T>.Create(AParent: TTreeNode<T>);
begin
Create(AParent, T.Create);
end
;
{*
[@inheritDoc]
*}
constructor
TCreateObjectTreeNode<T>.Create;
begin
Create(T.Create);
end
;
À nouveau, si vous retirez la contrainte constructor ici, le compilateur marquera une erreur sur le T.Create.
VI. Paramétriser une classe avec plusieurs types▲
Comme vous avez pu vous en douter, il est possible de paramétriser une classe avec plusieurs types. Chacun, éventuellement, avec ses contraintes.
Ainsi, la classe TDictionary<TKey,TValue> prend en paramètres deux types. Le premier est le type des clefs, le second le type des éléments. Cette classe implémente une table de hachage.
Ne vous y trompez pas : TKey et TValue sont bien des paramètres génériques (formels), pas des types réels. Ne vous laissez pas prendre au piège par la notation.
La syntaxe de déclaration est un peu laxiste, sur ce point. Il est en effet possible de séparer les types par des virgules (,) ou par des points-virgules (;), éventuellement en mixant les deux quand il y a plus de deux types. Autant au niveau de la déclaration de la classe qu'au niveau de l'implémentation des méthodes. Par contre, au niveau de l'utilisation d'un type générique, vous devez utiliser des virgules !
Toutefois, si vous placez une ou plusieurs contraintes sur un type qui n'est pas le dernier de la liste, vous devrez utiliser un point-virgule pour le séparer du suivant. En effet, une virgule signalerait une seconde contrainte.
Aussi, permettez-moi de vous proposer une règle de style - qui n'est pas celle suivie par Embarcadero. Utilisez toujours des points-virgules dans la déclaration du type générique (là où vous êtes susceptible de pouvoir mettre des contraintes) et utilisez des virgules partout ailleurs (implémentation des méthodes, et utilisation du type).
Comme je n'ai pas de meilleur exemple de type générique à vous proposer que celle de TDictionary<TKey,TValue>, je vous conseille de consulter le code de cette classe (définie dans l'unité Generics.Collections, vous vous en doutiez probablement). En voici juste un extrait :
type
TPair<TKey,TValue> = record
Key: TKey;
Value: TValue;
end
;
// Hash table using linear probing
TDictionary<TKey,TValue> = class
(TEnumerable<TPair<TKey,TValue>>)
// ...
public
// ...
procedure
Add(const
Key: TKey; const
Value: TValue);
procedure
Remove(const
Key: TKey);
procedure
Clear;
property
Items[const
Key: TKey]: TValue read
GetItem write
SetItem; default
;
property
Count: Integer
read
FCount;
// ...
end
;
Comme vous l'avez déjà remarqué, TDictionary<TKey,TValue> utilise des noms de types génériques plus explicites que les T que nous avons utilisé jusqu'ici. Vous devriez faire de même, à chaque fois que le type a une signification particulière, comme c'est le cas ici. Et d'autant plus lorsqu'il y a plus d'un type paramétré.
VII. Autres types génériques▲
Jusqu'à présent, nous n'avons défini que des classes génériques. Pourtant, nous avons déjà rencontré des interfaces génériques (comme IComparer<T>) et vous venez de croiser un type record générique (TPair<TKey,TValue>).
Il est donc tout à fait possible de définir autant des interfaces ou des records génériques que des classes génériques. Il est également possible de déclarer un type tableau générique (statique ou dynamique), mais dont seul le type des éléments peut dépendre des types paramétrés ; mais il est peu probable que vous y trouviez une utilité réelle.
Il n'est donc pas possible de déclarer un type pointeur générique, ou un type ensemble générique :
type
TGenericPointer<T> = ^T; // erreur de compilation
TGenericSet<T> = set
of
T; // erreur de compilation