Je ne me souviens pas quand j'ai écrit la classe générique la dernière fois. Chaque fois que je pense que j'en ai besoin après avoir réfléchi, je tire une conclusion que je n'ai pas.
La deuxième réponse à cette question m'a fait demander des éclaircissements (puisque je ne peux pas encore commenter, j'ai fait une nouvelle question).
Prenons donc le code donné comme exemple de cas où l'on a besoin de génériques:
public class Repository<T> where T : class, IBusinessOBject
{
T Get(int id)
void Save(T obj);
void Delete(T obj);
}
Il a des contraintes de type: IBusinessObject
Ma façon de penser habituelle est la suivante: la classe est contrainte d'utiliser IBusinessObject
, tout comme les classes qui utilisent ce Repository
le sont. Le référentiel stocke ces IBusinessObject
, les clients les plus susceptibles de ce Repository
voudront obtenir et utiliser des objets via l'interface IBusinessObject
. Alors pourquoi ne pas
public class Repository
{
IBusinessOBject Get(int id)
void Save(IBusinessOBject obj);
void Delete(IBusinessOBject obj);
}
Tho, l'exemple n'est pas bon, car c'est juste un autre type de collection et la collection générique est classique. Dans ce cas, la contrainte de type semble également étrange.
En fait, l'exemple class Repository<T> where T : class, IBusinessbBject
ressemble à peu près à class BusinessObjectRepository
tome. C'est la chose que les génériques sont faits pour corriger.
Le point est le suivant: les génériques sont-ils bons pour tout sauf les collections et les contraintes de type ne rendent-elles pas générique aussi spécialisé, comme le fait l'utilisation de cette contrainte de type au lieu du paramètre de type générique dans la classe?
Parlons d'abord du polymorphisme paramétrique pur et abordons le polymorphisme borné plus tard.
Que signifie le polymorphisme paramétrique? Eh bien, cela signifie qu'un type, ou plutôt un constructeur de type, est paramétré par un type. Étant donné que le type est transmis en tant que paramètre, vous ne pouvez pas savoir à l'avance ce qu'il pourrait être. Vous ne pouvez faire aucune hypothèse sur cette base. Maintenant, si vous ne savez pas ce que cela pourrait être, alors à quoi ça sert? Que pouvez-vous en faire?
Eh bien, vous pouvez le stocker et le récupérer, par exemple. C'est le cas que vous avez déjà mentionné: les collections. Afin de stocker un élément dans une liste ou un tableau, je n'ai besoin de rien savoir sur l'élément. La liste ou le tableau peut être complètement inconscient du type.
Mais qu'en est-il du type Maybe
? Si vous ne le connaissez pas, Maybe
est un type qui a peut-être une valeur et peut-être pas. Où l'utiliseriez-vous? Eh bien, par exemple, lorsque vous extrayez un élément d'un dictionnaire: le fait qu'un élément ne soit pas dans le dictionnaire n'est pas une situation exceptionnelle, donc vous ne devriez vraiment pas lever d'exception si l'élément n'est pas là. Au lieu de cela, vous renvoyez une instance d'un sous-type de Maybe<T>
, Qui a exactement deux sous-types: None
et Some<T>
. int.Parse
Est un autre candidat de quelque chose qui devrait vraiment retourner un Maybe<int>
Au lieu de lancer une exception ou la danse entière de int.TryParse(out bla)
.
Maintenant, vous pourriez faire valoir que Maybe
est un peu comme une liste qui ne peut avoir que zéro ou un élément. Et donc sorte-une sorte de collection.
Alors qu'en est-il de Task<T>
? C'est un type qui promet de renvoyer une valeur à un moment donné dans le futur, mais qui n'a pas nécessairement de valeur pour le moment.
Ou qu'en est-il de Func<T, …>
? Comment représenteriez-vous le concept d'une fonction d'un type à un autre si vous ne pouvez pas résumer les types?
Ou, plus généralement: considérant que l'abstraction et la réutilisation sont les deux opérations fondamentales du génie logiciel, pourquoi vous voulez pouvoir résumer sur des types?
Parlons maintenant du polymorphisme borné. Le polymorphisme borné est essentiellement le point de rencontre du polymorphisme paramétrique et du polymorphisme de sous-type: au lieu qu'un constructeur de type ne soit complètement inconscient de son paramètre de type, vous pouvez lier (ou contraindre) le type doit être un sous-type d'un type spécifié.
Revenons aux collections. Prenez une table de hachage. Nous avons dit plus haut qu'une liste n'a pas besoin de connaître quoi que ce soit sur ses éléments. Eh bien, une table de hachage le fait: elle doit savoir qu'elle peut les hacher. (Remarque: en C #, tous les objets sont hachables, tout comme tous les objets peuvent être comparés pour l'égalité. Ce n'est pas vrai pour tous les langages, cependant, et est parfois considéré comme une erreur de conception même en C #.)
Donc, vous voulez contraindre votre paramètre de type pour le type de clé dans la table de hachage à être une instance de IHashable
:
class HashTable<K, V> where K : IHashable
{
Maybe<V> Get(K key);
bool Add(K key, V value);
}
Imaginez si vous aviez à la place ceci:
class HashTable
{
object Get(IHashable key);
bool Add(IHashable key, object value);
}
Que feriez-vous avec un value
que vous sortez de là? Vous ne pouvez rien en faire, vous savez seulement que c'est un objet. Et si vous itérez dessus, tout ce que vous obtenez est une paire de quelque chose que vous savez est un IHashable
(ce qui ne vous aide pas beaucoup car il n'a qu'une seule propriété Hash
) et quelque chose que vous know est un object
(ce qui vous aide encore moins).
Ou quelque chose basé sur votre exemple:
class Repository<T> where T : ISerializable
{
T Get(int id);
void Save(T obj);
void Delete(T obj);
}
L'élément doit être sérialisable car il va être stocké sur le disque. Mais que se passe-t-il si vous avez ceci à la place:
class Repository
{
ISerializable Get(int id);
void Save(ISerializable obj);
void Delete(ISerializable obj);
}
Avec le cas générique, si vous mettez un BankAccount
dans, vous obtenez un BankAccount
en arrière, avec des méthodes et des propriétés comme Owner
, AccountNumber
, Balance
, Deposit
, Withdraw
, etc. Quelque chose avec lequel vous pouvez travailler. Maintenant, l'autre cas? Vous mettez un BankAccount
mais vous récupérez un Serializable
, qui n'a qu'une seule propriété: AsString
. Qu'allez-vous faire avec ça?
Il y a aussi quelques astuces que vous pouvez faire avec le polymorphisme borné:
La quantification délimitée par F est essentiellement l'endroit où la variable de type apparaît à nouveau dans la contrainte. Cela peut être utile dans certaines circonstances. Par exemple. comment écrire une interface ICloneable
? Comment écrivez-vous une méthode où le type de retour est le type de la classe d'implémentation? Dans une langue avec une fonctionnalité MyType , c'est simple:
interface ICloneable
{
public this Clone(); // syntax I invented for a MyType feature
}
Dans un langage avec polymorphisme borné, vous pouvez faire quelque chose comme ceci à la place:
interface ICloneable<T> where T : ICloneable<T>
{
public T Clone();
}
class Foo : ICloneable<Foo>
{
public Foo Clone()
{
// …
}
}
Notez que ce n'est pas aussi sûr que la version MyType, car rien n'empêche quelqu'un de simplement passer la "mauvaise" classe au constructeur de type:
class EvilBar : ICloneable<SomethingTotallyUnrelatedToBar>
{
public SomethingTotallyUnrelatedToBar Clone()
{
// …
}
}
Il se trouve que si vous avez des membres de type abstrait et un sous-typage, vous pouvez réellement vous passer complètement du polymorphisme paramétrique et toujours faire les mêmes choses. Scala se dirige dans cette direction, étant le premier langage majeur qui a commencé avec des génériques et essaie ensuite de les supprimer , qui est exactement l'inverse de par exemple Java et C #.
Fondamentalement, dans Scala, tout comme vous pouvez avoir des champs, des propriétés et des méthodes en tant que membres, vous pouvez également avoir des types. Et tout comme les champs et les propriétés et méthodes peuvent être laissés abstraits pour être implémentés dans une sous-classe plus tard, les membres de type peuvent également être laissés abstraits. Revenons aux collections, un simple List
, qui ressemblerait à quelque chose comme ça, s'il était supporté en C #:
class List
{
T; // syntax I invented for an abstract type member
T Get(int index) { /* … */ }
void Add(T obj) { /* … */ }
}
class IntList : List
{
T = int;
}
// this is equivalent to saying `List<int>` with generics
"les clients les plus susceptibles de ce référentiel voudront obtenir et utiliser des objets via l'interface IBusinessObject".
Non, ils ne le feront pas.
Considérons que l'IBusinessObject a la définition suivante:
public interface IBusinessObject
{
public int Id { get; }
}
Il définit simplement l'ID car il s'agit de la seule fonctionnalité partagée entre tous les objets métier. Et vous avez deux objets commerciaux réels: personne et adresse (puisque les personnes n'ont pas de rues et les adresses n'ont pas de noms, vous ne pouvez pas les contraindre à une interface commune avec une fonctionnalité des deux. Ce serait une conception terrible, violant la Principe de ségrégation de l'interface , le "je" dans SOLIDE )
public class Person : IBusinessObject
{
public int Id { get; private set; }
public string Name { get; private set; }
}
public class Address : IBusinessObject
{
public int Id { get; private set; }
public string City { get; private set; }
public string StreetName { get; private set; }
public int Number { get; private set; }
}
Maintenant, que se passe-t-il lorsque vous utilisez la version générique du référentiel:
public class Repository<T> where T : class, IBusinessObject
{
T Get(int id)
void Save(T obj);
void Delete(T obj);
}
Lorsque vous appelez la méthode Get sur le référentiel générique, l'objet renvoyé sera fortement typé, vous permettant d'accéder à tous les membres de la classe.
Person p = new Repository<Person>().Get(1);
int id = p.Id;
string name = p.Name;
Address a = new Repository<Address>().Get(1);
int id = a.Id;
string cityName = a.City;
int houseNumber = a.Number;
En revanche, lorsque vous utilisez le référentiel non générique:
public class Repository
{
IBusinessOBject Get(int id)
void Save(IBusinessOBject obj);
void Delete(IBusinessOBject obj);
}
Vous ne pourrez accéder aux membres qu'à partir de l'interface IBusinessObject:
IBussinesObject p = new Repository().Get(1);
int id = p.Id; //OK
string name = p.Name; //Oooops, you dont have "Name" defined on the IBussinesObject interface.
Ainsi, le code précédent ne se compilera pas en raison des lignes suivantes:
string name = p.Name;
string cityName = a.City;
int houseNumber = a.Number;
Bien sûr, vous pouvez lancer l'IBussinesObject dans la classe réelle, mais vous perdrez toute la magie de temps de compilation que les génériques autorisent (conduisant à des InvalidCastExceptions en cours de route), souffrira d'un cast inutilement ... Et même si vous ne le faites pas ne vous souciez pas du temps de compilation en vérifiant ni les performances (vous devriez), le casting après ne vous donnera certainement aucun avantage sur l'utilisation de génériques en premier lieu.
Le point est le suivant: les génériques sont-ils bons pour tout sauf les collections et les contraintes de type ne rendent-elles pas générique aussi spécialisé, comme le fait l'utilisation de cette contrainte de type au lieu du paramètre de type générique dans la classe?
Non. Vous pensez trop à Repository
, où il est à peu près la même chose. Mais ce n'est pas pour ça que les génériques sont là. Ils sont là pour les tilisateurs.
Le point clé ici n'est pas que le référentiel lui-même est plus générique. C'est que les utilisateurs sont plus spécialisés, c'est-à-dire que Repository<BusinessObject1>
et Repository<BusinessObject2>
sont de types différents, et en plus, si je prends un Repository<BusinessObject1>
, Je sais que j'obtiendrai BusinessObject1
se retirer de Get
.
Vous ne pouvez pas proposer ce typage fort à partir d'un simple héritage. La classe de référentiel que vous proposez ne fait rien pour empêcher les utilisateurs de confondre les référentiels pour différents types d'objet métier ou de garantir le bon type d'objet métier à revenir.