En supposant un langage avec une sécurité de type inhérente (par exemple, pas JavaScript):
Étant donné une méthode qui accepte un SuperType
, nous savons que dans la plupart des cas, nous pourrions être tentés d'effectuer des tests de type pour choisir une action:
public void DoSomethingTo(SuperType o) {
if (o isa SubTypeA) {
o.doSomethingA()
} else {
o.doSomethingB();
}
}
Nous devrions généralement, sinon toujours, créer une seule méthode remplaçable sur le SuperType
et faire ceci:
public void DoSomethingTo(SuperType o) {
o.doSomething();
}
... dans lequel chaque sous-type reçoit sa propre implémentation doSomething()
. Le reste de notre application peut alors ignorer de manière appropriée si un SuperType
donné est vraiment un SubTypeA
ou un SubTypeB
.
Magnifique.
Mais, nous avons toujours des opérations similaires à is a
Dans la plupart, sinon la totalité, des langages de type sûr. Et cela suggère un besoin potentiel de tests de type explicites.
Donc, dans quelles situations, le cas échéant, nous devons ou nous devons nous effectuons des tests de type explicites?
Pardonnez mon absence d'esprit ou mon manque de créativité. Je sais que je l'ai déjà fait; mais, c'était honnêtement il y a si longtemps que je ne me souviens pas si ce que j'ai fait était bon! Et dans la mémoire récente, je ne pense pas avoir rencontré besoin de tester des types en dehors de mon JavaScript cowboy.
"Jamais" est la réponse canonique à "quand les tests de type sont-ils corrects?" Il n'y a aucun moyen de prouver ou d'infirmer cela; cela fait partie d'un système de croyances sur ce qui fait un "bon design" ou un "bon design orienté objet". C'est aussi du hokum.
Pour être sûr, si vous avez un ensemble intégré de classes et également plus d'une ou deux fonctions qui ont besoin de ce type de test de type direct, vous vous trompez probablement. Ce dont vous avez vraiment besoin, c'est d'une méthode implémentée différemment dans SuperType
et ses sous-types. Cela fait partie intégrante de la programmation orientée objet, et toutes les classes de raison et l'héritage existent.
Dans ce cas, le test de type explicite est erroné non pas parce que le test de type est intrinsèquement faux, mais parce que le langage a déjà une manière propre, extensible et idiomatique de réaliser la discrimination de type, et que vous ne l'avez pas utilisé. Au lieu de cela, vous êtes retombé dans un idiome primitif, fragile et non extensible.
Solution: utilisez l'idiome. Comme vous l'avez suggéré, ajoutez une méthode à chacune des classes, puis laissez l'héritage standard et les algorithmes de sélection de méthode déterminer quel cas s'applique. Ou si vous ne pouvez pas modifier les types de base, sous-classe et ajoutez votre méthode là-bas.
Voilà pour la sagesse conventionnelle, et pour certaines réponses. Quelques cas où le test de type explicite est logique:
C'est unique. Si vous aviez beaucoup de discrimination de type à faire, vous pourriez étendre les types ou la sous-classe. Mais non. Vous n'avez qu'un ou deux endroits où vous avez besoin de tests explicites, donc cela ne vaut pas la peine de revenir en arrière et de travailler dans la hiérarchie des classes pour ajouter les fonctions en tant que méthodes. Ou cela ne vaut pas l'effort pratique d'ajouter le type de généralité, les tests, les revues de conception, la documentation ou d'autres attributs des classes de base pour une utilisation aussi simple et limitée. Dans ce cas, l'ajout d'une fonction qui effectue des tests directs est rationnel.
Vous ne pouvez pas ajuster les classes. Vous pensez au sous-classement - mais vous ne pouvez pas. De nombreuses classes en Java, par exemple, sont désignées final
. Vous essayez d'ajouter un public class ExtendedSubTypeA extends SubTypeA {...}
Et le compilateur vous dit, en termes non équivoques, que ce que vous faites n'est pas possible. Désolé, grâce et sophistication du modèle orienté objet! Quelqu'un a décidé que vous ne pouviez pas étendre leurs types! Malheureusement, la plupart des bibliothèques standard sont final
, et la création de classes final
est un guide de conception courant. Une fin de fonction est ce qui reste à votre disposition.
BTW, cela ne se limite pas aux langues typées statiquement. Langage dynamique Python possède un certain nombre de classes de base qui, sous les couvertures implémentées en C, ne peuvent pas vraiment être modifiées. Comme Java, cela inclut la plupart des types standard.
Votre code est externe. Vous développez avec des classes et des objets provenant d'une gamme de serveurs de base de données, de moteurs de middleware et d'autres bases de code que vous ne pouvez pas contrôler. ou ajustez. Votre code n'est qu'un faible consommateur d'objets générés ailleurs. Même si vous pouviez sous-classe SuperType
, vous ne pourrez pas obtenir les bibliothèques dont vous dépendez pour générer des objets dans vos sous-classes. Ils vont vous remettre des instances des types qu'ils connaissent, pas vos variantes. Ce n'est pas toujours le cas ... parfois ils sont construits pour plus de flexibilité, et ils instancient dynamiquement des instances de classes que vous leur donnez. Ou ils fournissent un mécanisme pour enregistrer les sous-classes que vous souhaitez que leurs usines construisent. Les analyseurs XML semblent particulièrement efficaces pour fournir de tels points d'entrée; voir par exemple n exemple JAXB en Java ou lxml en Python . Mais la plupart des bases de code ne fournissent pas de telles extensions pas. Ils vont vous rendre les classes avec lesquelles ils ont été construits et qu'ils connaissent. Il ne sera généralement pas judicieux de proxy leurs résultats dans vos résultats personnalisés juste pour que vous puissiez utiliser un sélecteur de type purement orienté objet. Si vous voulez faire de la discrimination par type, vous devrez le faire de manière relativement grossière. Votre code de test de type semble alors tout à fait approprié.
Génériques de la personne pauvre/répartition multiple. Vous voulez accepter une variété de types différents dans votre code, et vous sentez qu'avoir un tableau de méthodes très spécifiques au type n'est pas gracieux. public void add(Object x)
semble logique, mais pas un tableau de addByte
, addShort
, addInt
, addLong
, addFloat
, addDouble
, addBoolean
, addChar
et addString
variantes (pour n'en nommer que quelques-unes). Avoir des fonctions ou des méthodes qui prennent un super-type élevé et ensuite déterminer quoi faire sur une base type par type - ils ne vont pas vous gagner le prix de pureté lors du symposium de conception annuel Booch-Liskov, mais abandonner le nom hongrois vous donnera une API plus simple. Dans un sens, vos tests is-a
Ou is-instance-of
Simulent des répartitions génériques ou multiples dans un contexte linguistique qui ne les prend pas en charge de manière native.
La prise en charge du langage intégré pour génériques et typage canard réduit le besoin de vérification de type en rendant plus probable "faire quelque chose de gracieux et approprié". La sélection multiple dispatch /interface vue dans des langues comme Julia et Go remplace de même les tests de type directs par des mécanismes intégrés pour la sélection basée sur le type de "quoi faire". Mais toutes les langues ne les prennent pas en charge. Java par exemple, est généralement à envoi unique, et ses idiomes ne sont pas très conviviaux pour la frappe de canard.
Mais même avec toutes ces fonctionnalités de discrimination de type - héritage, génériques, typage de canard et répartition multiple - il est parfois juste pratique d'avoir une seule routine consolidée qui fait que vous faites quelque chose en fonction du type de l'objet clair et immédiat. En métaprogrammation je l'ai trouvé essentiellement inévitable. Le fait de se rabattre sur des demandes de type directes constitue du "pragmatisme en action" ou du "codage sale" dépendra de votre philosophie de conception et de vos convictions.
La situation principale dont j'ai jamais eu besoin était la comparaison de deux objets, comme dans une méthode equals(other)
, qui pourrait nécessiter des algorithmes différents selon le type exact de other
. Même alors, c'est assez rare.
L'autre situation que j'ai rencontrée, encore très rarement, est après la désérialisation ou l'analyse, où vous en avez parfois besoin pour effectuer un cast en toute sécurité vers un type plus spécifique.
De plus, parfois vous avez juste besoin d'un hack pour contourner le code tiers que vous ne contrôlez pas. C'est une de ces choses que vous ne voulez pas vraiment utiliser régulièrement, mais vous êtes content qu'elle soit là quand vous en avez vraiment besoin.
Le cas standard (mais espérons-le rare) ressemble à ceci: si dans la situation suivante
public void DoSomethingTo(SuperType o) {
if (o isa SubTypeA) {
DoSomethingA((SubTypeA) o )
} else {
DoSomethingB((SubTypeB) o );
}
}
les fonctions DoSomethingA
ou DoSomethingB
ne peuvent pas être facilement implémentées en tant que fonctions membres de l'arborescence d'héritage de SuperType
/SubTypeA
/SubTypeB
. Par exemple, si
DoSomethingXXX
à cette bibliothèque signifierait introduire une dépendance interdite.Notez qu'il existe souvent des situations où vous pouvez contourner ce problème (par exemple, en créant un wrapper ou un adaptateur pour SubTypeA
et SubTypeB
, ou en essayant de réimplémenter DoSomething
complètement dans termes d'opérations de base de SuperType
), mais parfois ces solutions ne valent pas la peine ou rendent les choses plus compliquées et moins extensibles que de faire le test de type explicite.
Un exemple de mon travail d'hier: j'ai eu une situation où j'allais paralléliser le traitement d'une liste d'objets (de type SuperType
, avec exactement deux sous-types différents, où il est extrêmement peu probable qu'il y ait un jour plus). La version non parallélisée contenait deux boucles: une boucle pour les objets du sous-type A, appelant DoSomethingA
, et une deuxième boucle pour les objets du sous-type B, appelant DoSomethingB
.
Les méthodes "DoSomethingA" et "DoSomethingB" sont toutes deux des calculs chronophages, utilisant des informations de contexte qui ne sont pas disponibles au niveau des sous-types A et B. (il est donc inutile de les implémenter en tant que fonctions membres des sous-types). Du point de vue de la nouvelle "boucle parallèle", cela rend les choses beaucoup plus faciles en les traitant uniformément, j'ai donc implémenté une fonction similaire à DoSomethingTo
d'en haut. Cependant, l'examen des implémentations de "DoSomethingA" et "DoSomethingB" montre qu'elles fonctionnent très différemment en interne. Donc, essayer d'implémenter un "DoSomething" générique en étendant SuperType
avec beaucoup de méthodes abstraites n'allait pas vraiment fonctionner, ou signifierait de sur-concevoir complètement les choses.
Comme l'oncle Bob l'appelle:
When your compiler forgets about the type.
Dans l'un de ses épisodes Clean Coder, il a donné un exemple d'appel de fonction qui est utilisé pour renvoyer Employee
s. Manager
est un sous-type de Employee
. Supposons que nous ayons un service d'application qui accepte l'ID d'un Manager
et le convoque au bureau :) La fonction getEmployeeById()
renvoie un super-type Employee
, mais je veux vérifier si un gestionnaire est renvoyé dans ce cas d'utilisation.
Par exemple:
var manager = employeeRepository.getEmployeeById(empId);
if (!(manager is Manager))
throw new Exception("Invalid Id specified.");
manager.summon();
Ici, je vérifie si l'employé renvoyé par requête est en fait un gestionnaire (c'est-à-dire que je m'attends à ce que ce soit un gestionnaire et si cela échoue rapidement).
Pas le meilleur exemple, mais c'est l'oncle Bob après tout.
Mise à jour
J'ai mis à jour l'exemple autant que je me souvienne de mémoire.
Quand la vérification de type est-elle OK?
Jamais.
is
, ou (dans certaines langues, ou selon le scénario) car vous ne pouvez pas étendre le type sans modifier les internes de la fonction en effectuant la vérification is
.is
sont un signe fort que vous violez le Liskov Substitution Principle . Tout ce qui fonctionne avec SuperType
devrait être complètement ignorant de quels sous-types il peut y avoir.Cela dit, les contrôles is
peuvent être moins mauvais que les autres alternatives. Mettre toutes les fonctionnalités courantes dans une classe de base est difficile et conduit souvent à des problèmes plus graves. Utiliser une seule classe qui a un drapeau ou une énumération pour quel "type" l'instance est ... est pire qu'horrible, puisque vous étendez maintenant le contournement du système de type aux tous consommateurs.
En bref, vous devriez toujours considérer les vérifications de type comme une forte odeur de code. Mais comme pour toutes les directives, il y aura des moments où vous serez obligé de choisir entre quelle violation des directives est la moins offensante.
Si vous avez une grande base de code (plus de 100K lignes de code) et que vous êtes proche de l'expédition ou que vous travaillez dans une succursale qui devra plus tard être fusionnée, et donc il y a beaucoup de coûts/risques à changer beaucoup de choses.
Vous avez alors parfois l'option d'un grand réfracteur du système, ou de simples "tests de type" localisés. Cela crée une dette technique qui devrait être remboursée dès que possible, mais ce n'est souvent pas le cas.
(Il est impossible de trouver un exemple, car tout code suffisamment petit pour être utilisé comme exemple est également suffisamment petit pour que la meilleure conception soit clairement visible.)
Ou en d'autres termes, lorsque l'objectif est d'être payé votre salaire plutôt que d'obtenir des "votes positifs" pour la propreté de votre conception.
L'autre cas courant est le code d'interface utilisateur, lorsque, par exemple, vous affichez une interface utilisateur différente pour certains types d'employés, mais clairement vous ne voulez pas que les concepts d'interface utilisateur s'échappent dans toutes vos classes de "domaine".
Vous pouvez utiliser les "tests de type" pour décider quelle version de l'interface utilisateur afficher, ou avoir une table de recherche sophistiquée qui convertit des "classes de domaine" en "classes d'interface utilisateur". La table de recherche n'est qu'un moyen de masquer les "tests de type" en un seul endroit.
(Le code de mise à jour de la base de données peut avoir les mêmes problèmes que le code de l'interface utilisateur, mais vous n'avez généralement qu'un seul ensemble de code de mise à jour de la base de données, mais vous pouvez avoir de nombreux écrans différents qui doivent s'adapter au type d'objet affiché.)
L'implémentation de LINQ utilise de nombreux types de vérification des optimisations de performances possibles, puis une solution de repli pour IEnumerable.
L'exemple le plus évident est probablement la méthode ElementAt (petit extrait de la source .NET 4.5):
public static TSource ElementAt<TSource>(this IEnumerable<TSource> source, int index) {
IList<TSource> list = source as IList<TSource>;
if (list != null) return list[index];
// ... and then an enumerator is created and MoveNext is called index times
Mais il y a beaucoup d'endroits dans la classe Enumerable où un modèle similaire est utilisé.
Ainsi, l'optimisation des performances pour un sous-type couramment utilisé est peut-être une utilisation valide. Je ne sais pas comment cela aurait pu être mieux conçu.
Il y a un exemple qui revient souvent dans les développements de jeux, en particulier dans la détection de collision, qui est difficile à gérer sans l'utilisation d'une certaine forme de test de type.
Supposons que tous les objets de jeu dérivent d'une classe de base commune GameObject
. Chaque objet a une forme de collision de corps rigide CollisionShape
qui peut fournir une interface commune (pour dire la position de la requête, l'orientation, etc.) mais les formes de collision réelles seront toutes des sous-classes concrètes telles que Sphere
, Box
, ConvexHull
, etc. stockant des informations spécifiques à ce type d'objet géométrique (voir ici pour un exemple réel)
Maintenant, pour tester les collisions, j'ai besoin d'écrire une fonction pour chaque paire de types de formes de collision:
detectCollision(Sphere, Sphere)
detectCollision(Sphere, Box)
detectCollision(Sphere, ConvexHull)
detectCollision(Box, ConvexHull)
...
qui contiennent les mathématiques spécifiques nécessaires pour effectuer une intersection de ces deux types géométriques.
À chaque "tick" de ma boucle de jeu, je dois vérifier les paires d'objets pour les collisions. Mais je n'ai accès qu'à GameObject
s et à leurs CollisionShape
s correspondants. De toute évidence, j'ai besoin de connaître les types concrets pour savoir quelle fonction de détection de collision appeler. Même double expédition (ce qui n'est logiquement pas différent de la vérification du type de toute façon) ne peut pas aider ici *.
Dans la pratique, dans cette situation, les moteurs physiques que j'ai vus (Bullet et Havok) reposent sur des tests de type sous une forme ou une autre.
Je ne dis pas que c'est nécessairement une bonne solution, c'est juste que ce peut être le meilleur d'un petit nombre de solutions possibles à ce problème
* Techniquement, il est possible d'utiliser la double répartition d'une manière horrible et compliquée qui nécessiterait N (N + 1)/2 combinaisons (où N est le nombre de types de formes que vous avez) et ne ferait qu'obscurcir ce que vous faites réellement, c'est-à-dire trouver simultanément les types des deux formes, donc je ne considère pas que ce soit une solution réaliste.
Parfois, vous ne voulez pas ajouter une méthode commune à toutes les classes car ce n'est vraiment pas leur responsabilité d'effectuer cette tâche spécifique.
Par exemple, vous voulez dessiner certaines entités mais ne voulez pas leur ajouter directement le code de dessin (ce qui est logique). Dans les langues ne prenant pas en charge l'envoi multiple, vous pourriez vous retrouver avec le code suivant:
void DrawEntity(Entity entity) {
if (entity instanceof Circle) {
DrawCircle((Circle) entity));
else if (entity instanceof Rectangle) {
DrawRectangle((Rectangle) entity));
} ...
}
Cela devient problématique lorsque ce code apparaît à plusieurs endroits et que vous devez le modifier partout lors de l'ajout d'un nouveau type d'entité. Si tel est le cas, il peut être évité en utilisant le modèle Visitor, mais il est parfois préférable de simplement garder les choses simples et de ne pas trop les concevoir. Ce sont les situations où le test de type est OK.
Les tests de type et la coulée de caractères sont deux concepts très étroitement liés. Si étroitement lié que je me sens confiant de dire que vous devez jamais faire un test de type à moins que votre intention soit de taper objet basé sur le résultat.
Lorsque vous pensez à une conception orientée objet idéale, les tests de type (et la coulée) ne devraient jamais se produire. Mais j'espère que vous avez maintenant compris que la programmation orientée objet n'est pas idéale. Parfois, en particulier avec du code de niveau inférieur, le code ne peut pas rester fidèle à l'idéal. C'est le cas des ArrayLists en Java; puisqu'ils ne savent pas au moment de l'exécution quelle classe est stockée dans le tableau, ils créent Object[]
tableaux et les transtypez statiquement au type correct.
Il a été souligné qu'un besoin commun de test de type (et de transtypage de type) vient de la méthode Equals
, qui dans la plupart des langues est supposée prendre un Object
simple. L'implémentation devrait avoir des vérifications détaillées à effectuer si les deux objets sont du même type, ce qui nécessite de pouvoir tester de quel type ils sont.
Les tests de type reviennent également fréquemment en réflexion. Vous aurez souvent des méthodes qui renvoient Object[]
ou un autre tableau générique, et vous souhaitez extraire tous les objets Foo
pour une raison quelconque. Il s'agit d'une utilisation parfaitement légitime des tests de type et de la coulée.
En général, les tests de type sont mauvais lorsqu'ils couplent inutilement votre code à la façon dont une implémentation spécifique a été écrite. Cela peut facilement nécessiter un test spécifique pour chaque type ou combinaison de types, comme si vous souhaitez trouver l'intersection de lignes, de rectangles et de cercles, et la fonction d'intersection a un algorithme différent pour chaque combinaison. Votre objectif est de mettre tous les détails spécifiques à un type d'objet au même endroit que cet objet, car cela facilitera la maintenance et l'extension de votre code.
Le seul moment que j'utilise est en combinaison avec la réflexion. Mais même alors, la vérification dynamique est la plupart du temps, non codée en dur pour une classe spécifique (ou uniquement codée en dur pour des classes spéciales telles que String
ou List
).
Par vérification dynamique, je veux dire:
boolean checkType(Type type, Object object) {
if (object.isOfType(type)) {
}
}
et non codé en dur
boolean checkIsManaer(Object object) {
if (object instanceof Manager) {
}
}
Le test de type est un outil, utilisez-le judicieusement et il peut être un allié puissant. Utilisez-le mal et votre code commencera à sentir.
Dans notre logiciel, nous avons reçu des messages sur le réseau en réponse à des demandes. Tous les messages désérialisés partagent une classe de base commune Message
.
Les classes elles-mêmes étaient très simples, juste la charge utile sous forme de propriétés C # typées et de routines pour les rassembler et les démasquer (En fait, j'ai généré la plupart des classes en utilisant des modèles t4 à partir de la description XML du format du message)
Le code serait quelque chose comme:
Message response = await PerformSomeRequest(requestParameter);
// Server (out of our control) would send one message as response, but
// the actual message type is not known ahead of time (it depended on
// the exact request and the state of the server etc.)
if (response is ErrorMessage)
{
// Extract error message and pass along (for example using exceptions)
}
else if (response is StuffHappenedMessage)
{
// Extract results
}
else if (response is AnotherThingHappenedMessage)
{
// Extract another type of result
}
// Half a dozen other possible checks for messages
Certes, on pourrait faire valoir que l'architecture des messages pourrait être mieux conçue, mais elle a été conçue il y a longtemps et non pour C #, c'est donc ce qu'elle est. Ici, les tests de type ont résolu un vrai problème pour nous d'une manière pas trop minable.
Il convient de noter que C # 7.0 obtient correspondance de modèles (qui à bien des égards est un test de type sur les stéroïdes), cela ne peut pas être tout mauvais ...
Prenez un analyseur JSON générique. Le résultat d'une analyse réussie est un tableau, un dictionnaire, une chaîne, un nombre, un booléen ou une valeur nulle. Cela peut être n'importe lequel d'entre eux. Et les éléments d'un tableau ou les valeurs d'un dictionnaire peuvent à nouveau être de n'importe lequel de ces types. Puisque les données proviennent de l'extérieur de votre programme, vous devez accepter n'importe quel résultat (c'est-à-dire que vous devez l'accepter sans planter; vous pouvez rejeter un résultat qui n'est pas ce que vous attendez).
C'est acceptable dans le cas où vous devez prendre une décision impliquant deux types et cette décision est encapsulée dans un objet en dehors de cette hiérarchie de types. Par exemple, supposons que vous planifiez quel objet sera traité ensuite dans une liste d'objets en attente de traitement:
abstract class Vehicle
{
abstract void Process();
}
class Car : Vehicle { ... }
class Boat : Vehicle { ... }
class Truck : Vehicle { ... }
Supposons maintenant que notre logique commerciale soit littéralement "toutes les voitures ont priorité sur les bateaux et les camions". L'ajout d'une propriété Priority
à la classe ne vous permet pas d'exprimer cette logique métier proprement car vous vous retrouverez avec ceci:
abstract class Vehicle
{
abstract void Process();
abstract int Priority { get }
}
class Car : Vehicle { public Priority { get { return 1; } } ... }
class Boat : Vehicle { public Priority { get { return 2; } } ... }
class Truck : Vehicle { public Priority { get { return 2; } } ... }
Le problème est que maintenant, pour comprendre l'ordre de priorité, vous devez regarder toutes les sous-classes, ou en d'autres termes, vous avez ajouté le couplage aux sous-classes.
Vous devez bien sûr transformer les priorités en constantes et les mettre dans une classe par elles-mêmes, ce qui aide à maintenir la planification de la logique métier:
static class Priorities
{
public const int CAR_PRIORITY = 1;
public const int BOAT_PRIORITY = 2;
public const int TRUCK_PRIORITY = 2;
}
Cependant, en réalité, l'algorithme de planification est quelque chose qui pourrait changer à l'avenir et pourrait éventuellement dépendre de plus que du type. Par exemple, cela pourrait dire que "les camions de plus de 5 000 kg ont une priorité spéciale sur tous les autres véhicules". C'est pourquoi l'algorithme de planification appartient à sa propre classe, et c'est une bonne idée d'inspecter le type pour déterminer lequel doit aller en premier:
class VehicleScheduler : IScheduleVehicles
{
public Vehicle WhichVehicleGoesFirst(Vehicle vehicle1, Vehicle vehicle2)
{
if(vehicle1 is Car) return vehicle1;
if(vehicle2 is Car) return vehicle2;
return vehicle1;
}
}
C'est le moyen le plus simple de mettre en œuvre la logique métier et toujours le plus flexible pour les changements futurs.