Maîtriser la POO : un guide pratique de l'héritage, des interfaces et des classes abstraites
Publié: 2022-03-10Pour autant que je sache, il est rare de trouver du contenu éducatif dans le domaine du développement de logiciels qui fournit un mélange approprié d'informations théoriques et pratiques. Si je devais deviner pourquoi, je suppose que c'est parce que les personnes qui se concentrent sur la théorie ont tendance à se lancer dans l'enseignement, et les personnes qui se concentrent sur des informations pratiques ont tendance à être payées pour résoudre des problèmes spécifiques, en utilisant des langages et des outils spécifiques.
Il s'agit, bien sûr, d'une généralisation large, mais si nous l'acceptons brièvement pour les besoins des arguments, il s'ensuit que de nombreuses personnes (en aucun cas toutes les personnes) qui assument le rôle d'enseignant ont tendance à être soit pauvres, soit totalement incapables d'expliquer les connaissances pratiques pertinentes à un concept particulier.
Dans cet article, je ferai de mon mieux pour discuter de trois mécanismes de base que vous trouverez dans la plupart des langages de programmation orientée objet (POO) : l' héritage , les interfaces (c'est-à-dire les protocoles ) et les classes abstraites . Plutôt que de vous donner des explications verbales techniques et complexes sur ce qu'est chaque mécanisme, je ferai de mon mieux pour me concentrer sur ce qu'ils font et quand les utiliser.
Cependant, avant de les aborder individuellement, je voudrais discuter brièvement de ce que signifie donner une explication théoriquement valable, mais pratiquement inutile. J'espère que vous pourrez utiliser ces informations pour vous aider à parcourir différentes ressources pédagogiques et pour éviter de vous blâmer lorsque les choses n'ont pas de sens.
Différents degrés de connaissance
Connaître les noms
Connaître le nom de quelque chose est sans doute la forme de connaissance la moins profonde. En fait, un nom n'est généralement utile que dans la mesure où il est couramment utilisé par de nombreuses personnes pour désigner la même chose et/ou s'il aide à décrire la chose. Malheureusement, comme l'ont découvert tous ceux qui ont passé du temps dans ce domaine, de nombreuses personnes utilisent des noms différents pour la même chose (par exemple interfaces et protocoles ), les mêmes noms pour différentes choses (par exemple modules et composants ), ou des noms qui sont ésotériques pour le au point d'être absurde (ex. Soit Monade ). En fin de compte, les noms ne sont que des pointeurs (ou des références) vers des modèles mentaux, et ils peuvent être d'une utilité variable.
Pour rendre ce domaine encore plus difficile à étudier, je risquerais de supposer que pour la plupart des individus, écrire du code est (ou du moins était) une expérience tout à fait unique. Il est encore plus compliqué de comprendre comment ce code est finalement compilé en langage machine et représenté dans la réalité physique sous la forme d'une série d'impulsions électriques évoluant dans le temps. Même si l'on peut se rappeler les noms des processus, des concepts et des mécanismes qui sont employés dans un programme, il n'y a aucune garantie que les modèles mentaux que l'on crée pour de telles choses soient cohérents avec les modèles d'un autre individu ; encore moins s'ils sont objectivement exacts.
C'est pour ces raisons, à côté du fait que je n'ai pas une bonne mémoire naturelle pour le jargon, que je considère les noms comme l'aspect le moins important de savoir quelque chose. Cela ne veut pas dire que les noms sont inutiles, mais j'ai par le passé appris et utilisé de nombreux modèles de conception dans mes projets, pour découvrir le nom couramment utilisé des mois, voire des années plus tard.
Connaître les définitions verbales et les analogies
Les définitions verbales sont le point de départ naturel pour décrire un nouveau concept. Cependant, comme pour les noms, ils peuvent être plus ou moins utiles et pertinents ; cela dépend en grande partie des objectifs finaux de l'apprenant. Le problème le plus courant que je vois dans les définitions verbales est la connaissance supposée généralement sous la forme de jargon.
Supposons, par exemple, que je devais expliquer qu'un thread ressemble beaucoup à un process , sauf que les threads occupent le même espace d'adressage qu'un process donné. Pour quelqu'un qui est déjà familier avec les processus et les espaces d'adressage , j'ai essentiellement déclaré que les threads peuvent être associés à leur compréhension d'un processus (c'est-à-dire qu'ils possèdent plusieurs des mêmes caractéristiques), mais ils peuvent être différenciés en fonction d'une caractéristique distincte.
Pour quelqu'un qui ne possède pas cette connaissance, je n'ai au mieux rien compris, et au pire j'ai fait en sorte que l'apprenant se sente inadéquat d'une certaine manière pour ne pas savoir les choses que j'ai supposé qu'il devrait savoir. En toute honnêteté, cela est acceptable si vos apprenants doivent vraiment posséder de telles connaissances (comme enseigner à des étudiants diplômés ou à des développeurs expérimentés), mais je considère que c'est un échec monumental de le faire dans tout matériel de niveau d'introduction.
Il est souvent très difficile de fournir une bonne définition verbale d'un concept lorsqu'il ne ressemble à rien d'autre que l'apprenant ait vu auparavant. Dans ce cas, il est très important pour l'enseignant de sélectionner une analogie susceptible d'être familière à la personne moyenne, et également pertinente dans la mesure où elle véhicule bon nombre des mêmes qualités du concept.
Par exemple, il est extrêmement important pour un développeur de logiciels de comprendre ce que cela signifie lorsque des entités logicielles (différentes parties d'un programme) sont étroitement couplées ou faiblement couplées . Lors de la construction d'un abri de jardin, un menuisier débutant peut penser qu'il est plus rapide et plus facile de l'assembler en utilisant des clous au lieu de vis. Cela est vrai jusqu'au moment où une erreur est commise ou un changement dans la conception de l'abri de jardin nécessite la reconstruction d'une partie de l'abri.
À ce stade, la décision d'utiliser des clous pour coupler étroitement les parties de l'abri de jardin a rendu le processus de construction dans son ensemble plus difficile, probablement plus lent, et l'extraction des clous avec un marteau risque d'endommager la structure. À l'inverse, les vis peuvent prendre un peu plus de temps à assembler, mais elles sont faciles à retirer et présentent peu de risques d'endommager les parties voisines de la remise. C'est ce que je veux dire par faiblement couplé . Naturellement, il y a des cas où vous n'avez vraiment besoin que d'un clou, mais cette décision doit être guidée par la pensée critique et l'expérience.
Comme je l'expliquerai en détail plus tard, il existe différents mécanismes pour connecter ensemble des parties d'un programme qui fournissent différents degrés de couplage ; tout comme les clous et les vis . Bien que mon analogie vous ait peut-être aidé à comprendre ce que signifie ce terme d'une importance cruciale, je ne vous ai donné aucune idée de la façon de l'appliquer en dehors du contexte de la construction d'un abri de jardin. Cela m'amène au type de connaissance le plus important et à la clé pour comprendre en profondeur des concepts vagues et difficiles dans n'importe quel domaine de recherche ; bien que nous nous en tenions à écrire du code dans cet article.
Connaître le code
À mon avis, strictement en ce qui concerne le développement logiciel, la forme la plus importante de connaître un concept vient de pouvoir l'utiliser dans le code de l'application de travail. Cette forme de connaissance peut être atteinte simplement en écrivant beaucoup de code et en résolvant de nombreux problèmes différents ; les noms de jargon et les définitions verbales n'ont pas besoin d'être inclus.
D'après ma propre expérience, je me souviens avoir résolu le problème de la communication avec une base de données distante et une base de données locale via une interface unique (vous saurez bientôt ce que cela signifie si vous ne le savez pas déjà); plutôt que le client (quelle que soit la classe qui parle à l' interface ) ait besoin d'appeler explicitement la base de données distante et locale (ou même une base de données de test). En fait, le client n'avait aucune idée de ce qui se cachait derrière l'interface, je n'ai donc pas eu besoin de la modifier, qu'elle soit exécutée dans une application de production ou dans un environnement de test. Environ un an après avoir résolu ce problème, je suis tombé sur le terme "Facade Pattern", et peu de temps après le terme "Repository Pattern", qui sont les deux noms que les gens utilisent pour la solution décrite précédemment.
Tout ce préambule est, espérons-le, pour éclairer certaines des failles qui sont le plus souvent commises dans l'explication de sujets tels que l' héritage , les interfaces et les classes abstraites . Des trois, l' héritage est probablement le plus simple à utiliser et à comprendre. D'après mon expérience à la fois en tant qu'étudiant en programmation et en tant qu'enseignant, les deux autres sont presque invariablement un problème pour les apprenants à moins qu'une attention très particulière ne soit accordée pour éviter les erreurs discutées précédemment. À partir de ce moment, je ferai de mon mieux pour rendre ces sujets aussi simples qu'ils devraient l'être, mais pas plus simples.
Une note sur les exemples
Étant moi-même le plus à l'aise avec le développement d'applications mobiles Android, j'utiliserai des exemples tirés de cette plate-forme afin de pouvoir vous apprendre à créer des applications GUI tout en introduisant les fonctionnalités de langage de Java. Cependant, je n'entrerai pas dans tellement de détails que les exemples devraient être inintelligibles pour quelqu'un ayant une compréhension superficielle de Java EE, Swing ou JavaFX. Mon objectif ultime en discutant de ces sujets est de vous aider à comprendre ce qu'ils signifient dans le contexte de la résolution d'un problème dans à peu près n'importe quel type d'application.
Je voudrais également vous avertir, cher lecteur, qu'il peut parfois sembler que je sois inutilement philosophique et pédant à propos de mots spécifiques et de leurs définitions. La raison en est qu'il existe vraiment un fondement philosophique profond nécessaire pour comprendre la différence entre quelque chose de concret (réel) et quelque chose d'abstrait (moins détaillé qu'une chose réelle). Cette compréhension s'applique à beaucoup de choses en dehors du domaine de l'informatique, mais il est particulièrement important pour tout développeur de logiciel de saisir la nature des abstractions . Dans tous les cas, si mes mots vous manquent, les exemples dans le code ne le seront, espérons-le, pas.
Héritage et implémentation
Lorsqu'il s'agit de créer des applications avec une interface utilisateur graphique (GUI), l' héritage est sans doute le mécanisme le plus important pour permettre de créer rapidement une application.
Bien qu'il y ait un avantage moins compris à utiliser l' héritage qui sera discuté plus tard, le principal avantage est de partager l'implémentation entre les classes . Ce mot « mise en œuvre », du moins aux fins de cet article, a un sens distinct. Pour donner une définition générale du mot en anglais, je dirais que mettre en œuvre quelque chose, c'est le rendre réel .
Pour donner une définition technique propre au développement logiciel, je dirais qu'implémenter un logiciel, c'est écrire des lignes de code concrètes qui satisfont aux exigences dudit logiciel. Par exemple, supposons que j'écrive une méthode sum : private double sum(double first, double second){
private double sum(double first, double second){ //TODO: implement }
L'extrait ci-dessus, même si je l'ai fait jusqu'à écrire un type de retour ( double
) et une déclaration de méthode qui spécifie les arguments ( first, second
) et le nom qui peut être utilisé pour appeler ladite méthode ( sum
), il a pas été mis en œuvre . Pour l' implémenter , nous devons compléter le corps de la méthode comme suit :
private double sum(double first, double second){ return first + second; }
Naturellement, le premier exemple ne compilerait pas, mais nous verrons dans un instant que les interfaces sont un moyen d'écrire ce genre de fonctions non implémentées sans erreur.
Héritage en Java
Vraisemblablement, si vous lisez cet article, vous avez utilisé le mot-clé extends
Java au moins une fois. Les mécanismes de ce mot-clé sont simples et le plus souvent décrits à l'aide d'exemples liés à différents types d'animaux ou de formes géométriques ; Dog
et Cat
étendent Animal
, et ainsi de suite. Je suppose que je n'ai pas besoin de vous expliquer la théorie rudimentaire des types, alors passons directement au principal avantage de l' héritage en Java, via le mot-clé extends
.
Construire une application "Hello World" basée sur la console en Java est très simple. En supposant que vous possédez un compilateur Java ( javac ) et un environnement d'exécution ( jre ), vous pouvez écrire une classe qui contient une fonction principale comme ceci :
public class JavaApp{ public static void main(String []args){ System.out.println("Hello World"); } }
Construire une application graphique en Java sur presque toutes ses plates-formes principales (Android, Enterprise/Web, Desktop), avec un peu d'aide d'un IDE pour générer le squelette/code standard d'une nouvelle application, est également relativement facile grâce à la extends
mot-clé.
Supposons que nous ayons une mise en page XML appelée activity_main.xml
(nous construisons généralement des interfaces utilisateur de manière déclarative dans Android, via des fichiers de mise en page) contenant un TextView
(comme une étiquette de texte) appelé tvDisplay
:
<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:andro android:layout_width="match_parent" android:layout_height="match_parent"> <TextView android: android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" /> </FrameLayout>
Supposons également que nous voudrions que tvDisplay
dise "Hello World!" Pour ce faire, il suffit d'écrire une classe qui utilise le mot clé extends
pour hériter de la classe Activity
:
import android.app.Activity; import android.os.Bundle; import android.widget.TextView; public class MainActivity extends Activity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); ((TextView)findViewById(R.id.tvDisplay)).setText("Hello World"); }
L'effet d'hériter de l'implémentation de la classe Activity
peut être mieux apprécié en jetant un coup d'œil rapide à son code source. Je doute fortement qu'Android soit devenu la plate-forme mobile dominante si l'on avait besoin d' implémenter ne serait-ce qu'une petite partie des 8000+ lignes nécessaires pour interagir avec le système, juste pour générer une simple fenêtre avec du texte. L'héritage est ce qui nous permet de ne pas avoir à reconstruire le framework Android ou la plate-forme avec laquelle vous travaillez, à partir de zéro.
L'héritage peut être utilisé pour l'abstraction
Dans la mesure où il peut être utilisé pour partager l'implémentation entre les classes, l' héritage est relativement simple à comprendre. Cependant, il existe une autre manière importante d'utiliser l' héritage , qui est conceptuellement liée aux interfaces et aux classes abstraites dont nous parlerons bientôt.
S'il vous plaît, supposons pour le moment qu'une abstraction, utilisée dans le sens le plus général, soit une représentation moins détaillée d'une chose . Au lieu de qualifier cela d'une longue définition philosophique, j'essaierai de montrer comment les abstractions fonctionnent dans la vie quotidienne, et peu de temps après, je les discuterai expressément en termes de développement de logiciels.
Supposons que vous voyagez en Australie et que vous sachiez que la région que vous visitez abrite une densité particulièrement élevée de serpents taipan intérieurs (ils sont apparemment assez venimeux). Vous décidez de consulter Wikipédia pour en savoir plus à leur sujet en regardant des images et d'autres informations. Ce faisant, vous êtes maintenant parfaitement conscient d'un type particulier de serpent que vous n'avez jamais vu auparavant.
Les abstractions, les idées, les modèles, ou peu importe comment vous voulez les appeler, sont des représentations moins détaillées d'une chose. Il est important qu'ils soient moins détaillés que la vraie chose car un vrai serpent peut vous mordre ; les images sur les pages Wikipédia ne le font généralement pas. Les abstractions sont également importantes car les ordinateurs et les cerveaux humains ont une capacité limitée à stocker, communiquer et traiter les informations. Avoir suffisamment de détails pour utiliser ces informations de manière pratique, sans prendre trop de place en mémoire, c'est ce qui permet aux ordinateurs et aux cerveaux humains de résoudre des problèmes.
Pour relier cela à l' héritage , les trois sujets principaux dont je parle ici peuvent être utilisés comme des abstractions ou des mécanismes d' abstraction . Supposons que dans le fichier de mise en page de notre application "Hello World", nous décidions d'ajouter un ImageView
, Button
et ImageButton
:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:andro android:layout_width="match_parent" android:layout_height="match_parent"> <Button android: android:layout_width="wrap_content" android:layout_height="wrap_content"/> <ImageButton android: android:layout_width="wrap_content" android:layout_height="wrap_content"/> <ImageView android: android:layout_width="wrap_content" android:layout_height="wrap_content"/> </LinearLayout>
Supposons également que notre activité ait implémenté View.OnClickListener
pour gérer les clics :
public class MainActivity extends Activity implements View.OnClickListener { private Button b; private ImageButton ib; private ImageView iv; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); //... b = findViewById(R.id.imvDisplay).setOnClickListener(this); ib = findViewById(R.id.btnDisplay).setOnClickListener(this); iv = findViewById(R.id.imbDisplay).setOnClickListener(this); } @Override public void onClick(View view) { final int id = view.getId(); //handle click based on id... } }
Le principe clé ici est que Button
, ImageButton
et ImageView
héritent de la classe View
. Le résultat est que cette fonction onClick
peut recevoir des événements de clic d'éléments d'interface utilisateur disparates (bien que liés hiérarchiquement) en les référençant comme leur classe parente moins détaillée. C'est bien plus pratique que d'avoir à écrire une méthode distincte pour gérer chaque type de widget sur la plate- forme Android (sans parler des widgets personnalisés).
Interfaces et abstraction
Vous avez peut-être trouvé l'exemple de code précédent peu inspirant, même si vous avez compris pourquoi je l'ai choisi. Être capable de partager l' implémentation à travers une hiérarchie de classes est incroyablement utile, et je dirais que c'est la principale utilité de heritage . Quant à nous permettre de traiter un ensemble de classes qui ont une classe parente commune comme étant de type égal (c'est-à-dire comme la classe parente ), cette fonctionnalité d' héritage a une utilisation limitée.
Par limité, je parle de l'exigence pour les classes enfants d'être dans la même hiérarchie de classes afin d'être référencées via, ou connues sous le nom de classe parent. En d'autres termes, l' héritage est un mécanisme très restrictif pour l'abstraction . En fait, si je suppose que l'abstraction est un spectre qui se déplace entre différents niveaux de détail (ou d'information), je pourrais dire que l' héritage est le mécanisme d' abstraction le moins abstrait en Java.
Avant de discuter des interfaces , je voudrais mentionner qu'à partir de Java 8 , deux fonctionnalités appelées méthodes par défaut et méthodes statiques ont été ajoutées aux interfaces . J'en parlerai éventuellement, mais pour l'instant je voudrais qu'on fasse semblant qu'ils n'existent pas. C'est dans le but pour moi de faciliter l'explication de l'objectif principal de l'utilisation d'une interface , qui était initialement, et est sans doute toujours, le mécanisme d'abstraction le plus abstrait en Java .
Moins de détails signifie plus de liberté
Dans la section sur l' héritage , j'ai donné une définition du mot implémentation , qui était censée contraster avec un autre terme que nous allons maintenant aborder. Pour être clair, je ne me soucie pas des mots eux-mêmes, ou si vous êtes d'accord avec leur utilisation ; seulement que vous comprenez ce qu'ils pointent conceptuellement.
Alors que l' héritage est principalement un outil pour partager l' implémentation sur un ensemble de classes, nous pourrions dire que les interfaces sont principalement un mécanisme pour partager le comportement sur un ensemble de classes. Le comportement utilisé dans ce sens n'est vraiment qu'un mot non technique pour les méthodes abstraites . Une méthode abstraite est une méthode qui, en fait, ne peut pas contenir de corps de méthode :
public interface OnClickListener { void onClick(View v); }
La réaction naturelle pour moi, et un certain nombre de personnes que j'ai formées, après avoir d'abord examiné une interface , a été de se demander quelle pourrait être l'utilité de partager uniquement un type de retour , un nom de méthode et une liste de paramètres . À première vue, cela semble être un excellent moyen de créer du travail supplémentaire pour vous-même ou pour quiconque pourrait écrire la classe qui implements
l ' interface . La réponse est que les interfaces sont parfaites pour les situations où vous voulez qu'un ensemble de classes se comporte de la même manière (c'est-à-dire qu'elles possèdent les mêmes méthodes abstraites publiques), mais vous vous attendez à ce qu'elles implémentent ce comportement de différentes manières.
Pour prendre un exemple simple mais pertinent, la plate-forme Android possède deux classes qui sont principalement chargées de créer et de gérer une partie de l'interface utilisateur : Activity
et Fragment
. Il s'ensuit que ces classes auront très souvent l'obligation d'écouter les événements qui apparaissent lorsqu'un widget est cliqué (ou interagit autrement avec un utilisateur). Pour les besoins de la discussion, prenons un moment pour comprendre pourquoi l' héritage ne résoudra presque jamais un tel problème :
public class OnClickManager { public void onClick(View view){ //Wait a minute... Activities and Fragments almost never //handle click events exactly the same way... } }
Non seulement faire hériter nos activités et fragments de OnClickManager
rendrait impossible la gestion des événements d'une manière différente, mais le plus important est que nous ne pourrions même pas le faire si nous le voulions. Activity et Fragment étendent déjà une classe parente et Java n'autorise pas plusieurs classes parentes . Donc, notre problème est que nous voulons qu'un ensemble de classes se comporte de la même manière, mais nous devons avoir de la flexibilité sur la façon dont la classe implémente ce comportement . Cela nous ramène à l'exemple précédent de View.OnClickListener
:
public interface OnClickListener { void onClick(View v); }
Il s'agit du code source réel (qui est imbriqué dans la classe View
), et ces quelques lignes nous permettent d'assurer un comportement cohérent entre les différents widgets ( Views ) et contrôleurs d'interface utilisateur ( Activities, Fragments, etc. ).
L'abstraction favorise le couplage lâche
J'espère avoir répondu à la question générale sur la raison pour laquelle les interfaces existent en Java; parmi de nombreuses autres langues. D'un certain point de vue, ils ne sont qu'un moyen de partager du code entre les classes, mais ils sont délibérément moins détaillés afin de permettre différentes implémentations . Mais tout comme l' héritage peut être utilisé à la fois comme mécanisme de partage de code et d'abstraction (bien qu'avec des restrictions sur la hiérarchie des classes), il s'ensuit que les interfaces fournissent un mécanisme plus flexible pour l'abstraction .
Dans une section précédente de cet article, j'ai présenté le sujet du couplage lâche/serré par analogie à la différence entre l'utilisation de clous et de vis pour construire une sorte de structure. Pour récapituler, l'idée de base est que vous voudrez utiliser des vis dans des situations où la modification de la structure existante (qui peut être le résultat d'erreurs de correction, de modifications de conception, etc.) est susceptible de se produire. Les clous conviennent parfaitement lorsque vous avez juste besoin de fixer des parties de la structure ensemble et que vous ne vous inquiétez pas particulièrement de les démonter dans un proche avenir.
Les clous et les vis sont censés être analogues aux références concrètes et abstraites (le terme dépendances s'applique également) entre les classes. Juste pour qu'il n'y ait pas de confusion, l'exemple suivant démontrera ce que je veux dire :
class Client { private Validator validator; private INetworkAdapter networkAdapter; void sendNetworkRequest(String input){ if (validator.validateInput(input)) { try { networkAdapter.sendRequest(input); } catch (IOException e){ //handle exception } } } } class Validator { //...validation logic boolean validateInput(String input){ boolean isValid = true; //...change isValid to false based on validation logic return isValid; } } interface INetworkAdapter { //... void sendRequest(String input) throws IOException; }
Ici, nous avons une classe appelée Client
qui possède deux types de références . Notez que, en supposant que Client
n'a rien à voir avec la création de ses références (ce n'est vraiment pas le cas), il est découplé des détails d'implémentation d'une carte réseau particulière.
Il y a quelques implications importantes de ce couplage lâche . Pour commencer, je peux construire Client
dans un isolement absolu de toute implémentation de INetworkAdapter
. Imaginez un instant que vous travaillez dans une équipe de deux développeurs ; un pour construire le front-end, un pour construire le back-end. Tant que les deux développeurs sont tenus au courant des interfaces qui couplent leurs classes respectives, ils peuvent poursuivre le travail pratiquement indépendamment l'un de l'autre.
Deuxièmement, et si je vous disais que les deux développeurs pourraient vérifier que leurs implémentations respectives fonctionnent correctement, également indépendamment des progrès de l'autre ? C'est très simple avec les interfaces ; il suffit de construire un Test Double qui implements
l' interface appropriée :
class FakeNetworkAdapter implements INetworkAdapter { public boolean throwError = false; @Override public void sendRequest(String input) throws IOException { if (throwError) throw new IOException("Test Exception"); } }
En principe, ce que l'on peut observer, c'est que travailler avec des références abstraites ouvre la porte à une modularité et une testabilité accrues, ainsi qu'à des modèles de conception très puissants tels que Facade Pattern , Observer Pattern , etc. Ils peuvent également permettre aux développeurs de trouver un équilibre heureux entre la conception de différentes parties d'un système en fonction du comportement ( Program To An Interface ), sans s'enliser dans les détails de mise en œuvre.
Un dernier point sur les abstractions
Les abstractions n'existent pas de la même manière qu'une chose concrète . Cela se reflète dans le langage de programmation Java par le fait que les classes abstraites et les interfaces ne peuvent pas être instanciées.
Par exemple, cela ne compilerait certainement pas :
public class Main extends Application { public static void main(String[] args) { launch(args); } @Override public void start(Stage primaryStage) { //ERROR x2: Foo f = new Foo(); Bar b = new Bar() } private abstract class Foo{} private interface Bar{} }
En fait, l'idée de s'attendre à ce qu'une interface ou une classe abstraite non implémentée fonctionne au moment de l'exécution est tout aussi logique que de s'attendre à ce qu'un uniforme UPS flotte autour de la livraison des colis. Quelque chose de concret doit être derrière l' abstraction pour qu'elle soit utile ; même si la classe appelante n'a pas besoin de savoir ce qui se cache réellement derrière les références abstraites .
Classes abstraites : tout mettre ensemble
Si vous êtes arrivé jusqu'ici, je suis heureux de vous dire que je n'ai plus de tangentes philosophiques ou de phrases de jargon à traduire. En termes simples, les classes abstraites sont un mécanisme de partage de l' implémentation et du comportement à travers un ensemble de classes. Maintenant, j'admettrai tout de suite que je ne me retrouve pas souvent à utiliser des classes abstraites . Malgré tout, j'espère qu'à la fin de cette section, vous saurez exactement quand ils sont nécessaires.
Étude de cas sur le journal d'entraînement
Environ un an après avoir créé des applications Android en Java, je reconstruisais ma première application Android à partir de zéro. La première version était le genre de masse de code épouvantable que l'on attendrait d'un développeur autodidacte avec peu de conseils. Au moment où j'ai voulu ajouter de nouvelles fonctionnalités, il est devenu clair que la structure étroitement couplée que j'avais construite exclusivement avec des clous était si impossible à entretenir que je devais la reconstruire entièrement.
L'application était un journal d'entraînement conçu pour permettre un enregistrement facile de vos entraînements et la possibilité de sortir les données d'un entraînement passé sous forme de fichier texte ou image. Sans entrer dans trop de détails, j'ai structuré les modèles de données de l'application de telle sorte qu'il y avait un objet Workout
, qui comprenait une collection d'objets Exercise
(parmi d'autres champs qui ne sont pas pertinents pour cette discussion).
Alors que j'implémentais la fonctionnalité de sortie des données d'entraînement sur un type de support visuel, j'ai réalisé que je devais faire face à un problème : différents types d'exercices nécessiteraient différents types de sorties de texte.
Pour vous donner une idée approximative, j'ai voulu modifier les sorties en fonction du type d'exercice comme ceci :
- Haltère : 10 REPS @ 100 LBS
- Haltère : 10 REPS @ 50 LBS x2
- Poids corporel : 10 REPS @ Poids corporel
- Poids corporel + : 10 REPS @ Poids corporel + 45 LBS
- Chronométré : 60 SEC @ 100 LBS
Avant de continuer, notez qu'il y avait d'autres types (l'entraînement peut devenir compliqué) et que le code que je vais montrer a été réduit et modifié pour s'intégrer parfaitement dans un article.
Conformément à ma définition d'avant, le but de l'écriture d'une classe abstraite est d' implémenter tout (même l'état tel que les variables et les constantes ) qui est partagé par toutes les classes enfants de la classe abstraite . Ensuite, pour tout ce qui change dans lesdites classes enfants , créez une méthode abstraite :
abstract class Exercise { private final String type; protected final String name; protected final int[] repetitionsOrTime; protected final double[] weight; protected static final String POUNDS = "LBS"; protected static final String SECONDS = "SEC "; protected static final String REPETITIONS = "REPS "; public Exercise(String type, String name, int[] repetitionsOrTime, double[] weight) { this.type = type; this.name = name; this.repetitionsOrTime = repetitionsOrTime; this.weight = weight; } public String getFormattedOutput(){ StringBuilder sb = new StringBuilder(); sb.append(name); sb.append("\n"); getSetData(sb); sb.append("\n"); return sb.toString(); } /** * Append data appropriately based on Exercise type * @param sb - StringBuilder to Append data to */ protected abstract void getSetData(StringBuilder sb); //...Getters }
Je dis peut-être l'évidence, mais si vous avez des questions sur ce qui devrait ou ne devrait pas être implémenté dans la classe abstraite , la clé est de regarder n'importe quelle partie de l' implémentation qui a été répétée dans toutes les classes enfants.
Maintenant que nous avons établi ce qui est commun à tous les exercices, nous pouvons commencer à créer des classes enfants avec des spécialisations pour chaque type de sortie String :
Exercice d'haltères :
class BarbellExercise extends Exercise { public BarbellExercise(String type, String name, int[] repetitionsOrTime, double[] weight) { super(type, name, repetitionsOrTime, weight); } @Override protected void getSetData(StringBuilder sb) { for (int i = 0; i < repetitionsOrTime.length; i++) { sb.append(repetitionsOrTime[i]); sb.append(" "); sb.append(REPETITIONS); sb.append(" @ "); sb.append(weight[i]); sb.append(POUNDS); sb.append("\n"); } } }
Exercice d'haltères :
class DumbbellExercise extends Exercise { private static final String TIMES_TWO = "x2"; public DumbbellExercise(String type, String name, int[] repetitionsOrTime, double[] weight) { super(type, name, repetitionsOrTime, weight); } @Override protected void getSetData(StringBuilder sb) { for (int i = 0; i < repetitionsOrTime.length; i++) { sb.append(repetitionsOrTime[i]); sb.append(" "); sb.append(REPETITIONS); sb.append(" @ "); sb.append(weight[i]); sb.append(POUNDS); sb.append(TIMES_TWO); sb.append("\n"); } } }
Exercice de poids corporel :
class BodyweightExercise extends Exercise { private static final String BODYWEIGHT = "Bodyweight"; public BodyweightExercise(String type, String name, int[] repetitionsOrTime, double[] weight) { super(type, name, repetitionsOrTime, weight); } @Override protected void getSetData(StringBuilder sb) { for (int i = 0; i < repetitionsOrTime.length; i++) { sb.append(repetitionsOrTime[i]); sb.append(" "); sb.append(REPETITIONS); sb.append(" @ "); sb.append(BODYWEIGHT); sb.append("\n"); } } }
Je suis certain que certains lecteurs astucieux trouveront des choses qui auraient pu être abstraites de manière plus efficace, mais le but de cet exemple (qui a été simplifié à partir de la source originale) est de démontrer l'approche générale. Bien sûr, aucun article de programmation ne serait complet sans quelque chose qui puisse être exécuté. Il existe plusieurs compilateurs Java en ligne que vous pouvez utiliser pour exécuter ce code si vous souhaitez le tester (sauf si vous avez déjà un IDE) :
public class Main { public static void main(String[] args) { //Note: I actually used another nested class called a "Set" instead of an Array //to represent each Set of an Exercise. int[] reps = {10, 10, 8}; double[] weight = {70.0, 70.0, 70.0}; Exercise e1 = new BarbellExercise( "Barbell", "Barbell Bench Press", reps, weight ); Exercise e2 = new DumbbellExercise( "Dumbbell", "Dumbbell Bench Press", reps, weight ); Exercise e3 = new BodyweightExercise( "Bodyweight", "Push Up", reps, weight ); System.out.println( e1.getFormattedOutput() + e2.getFormattedOutput() + e3.getFormattedOutput() ); } }
Executing this toy application yields the following output: Barbell Bench Press
10 REPS @ 70.0LBS 10 REPS @ 70.0LBS 8 REPS @ 70.0LBS Dumbbell Bench Press 10 REPS @ 70.0LBSx2 10 REPS @ 70.0LBSx2 8 REPS @ 70.0LBSx2 Push Up 10 REPS @ Bodyweight 10 REPS @ Bodyweight 8 REPS @ Bodyweight
Further Considerations
Earlier, I mentioned that there are two features of Java interfaces (as of Java 8) which are decidedly geared towards sharing implementation , as opposed to behavior . These features are known as Default Methods and Static Methods .
I have decided not to go into detail on these features for the reason that they are most typically used in mature and/or large code bases where a given interface has many inheritors. Despite the fact that this is meant to be an introductory article, and I still encourage you to take a look at these features eventually, even though I am confident that you will not need to worry about them just yet.
I would also like to mention that there are other ways to share implementation across a set of classes (or even static methods ) in a Java application that does not require inheritance or abstraction at all. For example, suppose you have some implementation which you expect to use in a variety of different classes, but does not necessarily make sense to share via inheritance . A common pattern in Java is to write what is known as a Utility class, which is a simple class
containing the requisite implementation in a static method :
public class TimeConverterUtil { /** * Accepts an hour (0-23) and minute (0-59), then attempts to format them into an appropriate * format such as 12, 30 -> 12:30 pm */ public static String convertTime (int hour, int minute){ String unformattedTime = Integer.toString(hour) + ":" + Integer.toString(minute); DateFormat f1 = new SimpleDateFormat("HH:mm"); Date d = null; try { d = f1.parse(unformattedTime); } catch (ParseException e) { e.printStackTrace(); } DateFormat f2 = new SimpleDateFormat("h:mm a"); return f2.format(d).toLowerCase(); } }
Using this static method in an external class (or another static method ) looks like this:
public class Main { public static void main(String[] args){ //... String time = TimeConverterUtil.convertTime(12, 30); //... } }
Cheat Sheet
We have covered a lot of ground in this article, so I would like to spend a moment summarizing the three main mechanisms based on what problems they solve. Since you should possess a sufficient understanding of the terms and ideas I have either introduced or redefined for the purposes of this article, I will keep the summaries brief.
I Want A Set Of Child Classes To Share Implementation
Classic inheritance , which requires a child class to inherit from a parent class , is a very simple mechanism for sharing implementation across a set of classes. An easy way to decide if some implementation should be pulled into a parent class , is to see whether it is repeated in a number of different classes line for line. The acronym DRY ( Don't Repeat Yourself ) is a good mnemonic device to watch out for this situation.
While coupling child classes together with a common parent class can present some limitations, a side benefit is that they can all be referenced as the parent class , which provides a limited degree of abstraction .
I Want A Set Of Classes To Share Behavior
Sometimes, you want a set of classes to be capable of possessing certain abstract methods (referred to as behavior ), but you do not expect the implementation of that behavior to be repeated across inheritors.
By definition, Java interfaces may not contain any implementation (except for Default and Static Methods ), but any class which implements an interface , must supply an implementation for all abstract methods, otherwise, the code will not compile. This provides a healthy measure of flexibility and restriction on what is actually shared and does not require the inheritors to be of the same class hierarchy .
I Want A Set Of Child Classes To Share Behavior And Implementation
Although I do not find myself using abstract classes all over the place, they are perfect for situations when you require a mechanism for sharing both behavior and implementation across a set of classes. Anything which will be repeated across inheritors may be implemented directly in the abstract class
, and anything which requires flexibility may be specified as an abstract method .