Génériques vs interface commune?

20

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 l'utilisent Repository. Le référentiel stocke ces IBusinessObjects, les clients les plus susceptibles de Repositoryvouloir obtenir et utiliser des objets via l' IBusinessObjectinterface. Alors pourquoi ne pas

public class Repository
{
  IBusinessOBject Get(int id)
  void Save(IBusinessOBject obj);
  void Delete(IBusinessOBject obj);
}

Cependant, l'exemple n'est pas bon, car il s'agit simplement d'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 me class Repository<T> where T : class, IBusinessbBjectressemble à peu près class BusinessObjectRepository. 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?

jungle_mole
la source

Réponses:

24

Parlons d'abord du polymorphisme paramétrique pur et abordons le polymorphisme borné plus tard.

Polymorphisme paramétrique

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 Maybetype? Si vous ne le connaissez pas, Maybec'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: Noneet Some<T>. int.Parseest un autre candidat de quelque chose qui devrait vraiment retourner au Maybe<int>lieu de lancer une exception ou la int.TryParse(out bla)danse entière .

Maintenant, vous pourriez dire que Maybec'est un peu comme une liste qui ne peut avoir que zéro ou un élément. Et donc un peu une collection.

Et alors 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 alors 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 de l'ingénierie logicielle, pourquoi ne voudriez- vous pas pouvoir abstraire sur des types?

Polymorphisme borné

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 pas complètement conscient de son paramètre de type, vous pouvez lier (ou contraindre) le type à ê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 que le type de clé dans la table de hachage soit 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 une valuesortie 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(qui ne vous aide pas beaucoup car il n'a qu'une propriété Hash) et quelque chose que vous savez est un object(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, vous obtenez un BankAccountretour, avec des méthodes et des propriétés telles que Owner, AccountNumber, Balance, Deposit, Withdraw, etc. Quelque chose que vous pouvez travailler avec. Maintenant, l'autre cas? Vous mettez dans un BankAccountmais vous récupérer un Serializable, qui vient d' une propriété: AsString. Qu'allez-vous faire avec ça?

Il y a aussi quelques astuces que vous pouvez faire avec le polymorphisme borné:

Polymorphisme lié à F

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 écrivez-vous une ICloneableinterface? 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()
    {
        // …
    }
}

Membres de type abstrait

Il s'avère 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 essayant ensuite de les supprimer, ce qui est exactement l'inverse de Java et C # par exemple.

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, les propriétés et les 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 pris en charge 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
Jörg W Mittag
la source
je comprends, que l'abstraction sur les types est utile. je ne vois tout simplement pas son utilisation dans la "vraie vie". Func <> et Task <> et Action <> sont des types de bibliothèques. et merci je me souviens interface IFoo<T> where T : IFoo<T>aussi. c'est évidemment une application réelle. l'exemple est génial. mais pour une raison quelconque, je ne me sens pas satisfait. je veux plutôt me faire une idée quand c'est approprié et quand ça ne l'est pas. les réponses ici ont une certaine contribution à ce processus, mais je me sens toujours incomfy autour de tout cela. c'est étrange parce que les problèmes de langue ne me dérangent pas depuis si longtemps déjà.
jungle_mole
Excellent exemple. J'avais l'impression d'être de retour dans la salle de classe. +1
Chef_Code
1
@Chef_Code: J'espère que c'est un compliment :-P
Jörg W Mittag
Oui, ça l'est. Plus tard, j'ai réfléchi à la façon dont cela pourrait être perçu après avoir déjà commenté. Donc pour confirmer la sincérité ... Oui c'est un compliment rien d'autre.
Chef_Code
14

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 à ce sujet 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 utilisateurs .

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 je vais BusinessObject1en sortir Get.

Vous ne pouvez pas proposer ce typage fort à partir d'un simple héritage. Votre classe de référentiel proposée ne fait rien pour empêcher les gens 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.

DeadMG
la source
Merci, c'est logique. Mais l'intérêt d'utiliser cette fonctionnalité de langue très appréciée est-il aussi simple que d'aider les utilisateurs qui ont désactivé IntelliSense? (J'exagère un peu, mais je suis sûr que vous comprenez)
jungle_mole
@zloidooraque: IntelliSense ne sait pas non plus quel type d'objets est stocké dans un référentiel. Mais oui, vous pouvez faire n'importe quoi sans génériques si vous êtes prêt à utiliser des moulages à la place.
gexicide
@gexicide c'est le point: je ne vois pas où je dois utiliser les transtypages si j'utilise une interface commune. Je n'ai jamais dit "utiliser Object". Je comprends aussi pourquoi utiliser des génériques lors de l'écriture de collections (principe DRY). Probablement, ma question initiale aurait dû porter sur l'utilisation de génériques en dehors du contexte des collections.
jungle_mole
@zloidooraque: Cela n'a rien à voir avec l'environnement. Intellisense ne peut pas vous dire si un IBusinessObjectest un BusinessObject1ou un BusinessObject2. Il ne peut pas résoudre les surcharges en fonction du type dérivé qu'il ne connaît pas. Il ne peut pas rejeter le code qui passe dans le mauvais type. Il y a un million de bits de typage plus fort sur lesquels Intellisense ne peut absolument rien faire. Un meilleur support d'outillage est un bel avantage mais n'a rien à voir avec les raisons principales.
DeadMG
@DeadMG et c'est mon point: intellisense ne peut pas le faire: utilisez générique, alors il le pourrait? Est-ce que ça importe? lorsque vous obtenez l'objet par son interface, pourquoi le rabattre? si vous le faites, c'est un mauvais design, non? et pourquoi et qu'est-ce que "résoudre les surcharges"? l'utilisateur ne doit pas décider, si la méthode d'appel est basée sur un type dérivé ou non, s'il délègue l'appel de la bonne méthode au système (qui est le polymorphisme). et cela m'amène à nouveau à une question: les génériques sont-ils utiles en dehors des conteneurs? je ne discute pas avec vous, j'ai vraiment besoin de comprendre cela.
jungle_mole
13

"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 des interfaces , le "je" dans SOLID )

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 plus tard), 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.

Lucas Corsaletti
la source