|
||||||||||||||||||||||||||||||||||||||||||||||||||
|
Le but de cet article est double. Il s'agit tout d'abord de vous familiariser avec quelques aspects clés de la programmation par objets et accessoirement, de vous fournir un module écrit en C que vous pourrez réutiliser dans vos programmes. Cet article se situe dans la même ligne que celui traitant de la locale.library et fait partie d'une série qui fera de temps en temps apparition dans Amiga News, sous un label qu'on pourrait pompeusement appeler "programmer proprement l'Amiga"... Afin de ne pas vous ennuyer avec un cours didactique, je vais concentrer cet article sur un exemple concret et nous allons ensemble examiner les mécanismes qui nous amènent naturellement à aborder le problème sous un aspect "orienté objet". Nous parlerons notamment de généricité (qui vous permet de manipuler des entités sans savoir exactement de quoi il s'agit) et d'abstraction de type (qui vous laisse libre de modifier l'implémentation de votre module sans que les applications qui les utilisent aient besoin d'être modifiées). Le problème J'ai choisi de prendre un exemple très concret étant donné que j'utilise régulièrement le code que je vous fournis à la fois sur mon Amiga et à mon travail. Il s'agit de développer un module qui soit facile à réutiliser et qui gère une petite "base de données". J'utilise des guillemets car il s'agit d'une version très simplifiée de ce qu'on entend habituellement par "base de données". En clair, notre module devra être capable de gérer un ensemble d'entités non définies à l'avance et de pouvoir les restituer à la demande. Problème simple, n'est-ce pas ? Ces "entités" pourraient être par exemple des structures représentant un état civil (nom, prénom, etc.), un inventaire, des fenêtres sur l'écran de votre Amiga. Première idée Il nous faut tout d'abord établir la façon de représenter ces entités. Attention, je ne parle pas de la constitution de ces entités mais de la façon dont la base sera stockée. En effet, si nous désirons pouvoir réutiliser ce module, il faut impérativement qu'il ne dépende pas des entités qu'il manipule. Ce module doit être générique. L'idée est donc d'obliger l'utilisateur du module à passer par les fonctions que nous lui fournissons pour manipuler cette base. Ainsi, il nous sera possible par la suite de modifier notre implémentation si nous le désirons sans que son application soit affectée. Que peut-il se passer si nous dérogeons à cette règle que nous nous fixons ? Je vais l'illustrer par un exemple. Mettons que nous choisissons de représenter notre base sous la forme d'un tableau parce que la première application que nous allons écrire se contente fort bien de cette représentation. Elle accèdera à la base tout simplement en indiçant ce tableau. La base serait représentée de la façon suivante :
Et on utiliserait ce genre de code pour afficher toute la base :
La nécessité de masquer Cette représentation a le mérite d'être très simple mais risque d'être difficile à réutiliser. En effet, supposons que dans le cadre d'un deuxième projet, vous ayez à nouveau besoin de ce module mais que la limitation à un maximum de cent entrées soit inacceptable. Tout votre code passe à la trappe et vous voilà contraint d'écrire une deuxième version de votre base de données. La solution simple serait de modifier la valeur MAX de votre module mais si vous augmentez la limite, vous ne la faites pas disparaître pour autant... La seule solution est de passer à une implémentation plus dynamique (à base de listes par exemple) et dans ce cas votre première application ne pourra pas utiliser cette deuxième version sans une réécriture qui vous prendra encore du temps. Ce procédé est clairement inefficace car il ne masque pas la représentation qu'il utilise au programmeur. Celui-ci est donc tenté de référencer directement des champs qui sont susceptibles de changer dans une autre implémentation. La solution est donc de cacher la représentation utilisée, bref d'utiliser un type abstrait. L'utilisateur manipulera ce type abstrait par le biais de fonctions. Le type abstrait peut être vu comme une espèce de boîte noire sur laquelle nous posons des leviers et l'utilisateur est obligé d'utiliser les leviers pour manipuler la boîte. Son contenu lui reste complètement opaque (mais pas à nous). Vers la bonne solution Examinons rapidement les premières fonctions qui nous semblent indispensables :
Deux remarques s'imposent Premièrement, il n'est plus fait référence à aucun type. Tout passe par des fonctions qui fournissent l'équivalent du premier code. Deuxièmement, les puristes objecteront que la référence à NULL a tendance à faire un trou dans l'abstraction de type. En effet, si on pousse le raisonnement jusqu'au bout, rien ne nous permet de supposer ce que sera la valeur de retour de DB_NextEntry() et si nous décidions à l'avenir de rendre une valeur différente de NULL pour signifier à l'appelant qu'on est arrivé au bout de la base de données, le code ne marcherait plus. On pourrait donc ajouter une fonction et réécrire la ligne de la façon suivante :
Ainsi, nous sommes encore plus libres quant à la façon d'implémenter notre module et nous nous sommes complètement détachés de tout type. Précisions sur l'implémentation Il ne vous est pas utile de comprendre les détails qui suivent pour utiliser le module donné en encart, aussi n'hésitez pas à sauter au paragraphe suivant si vous ne les comprenez pas. Maintenant que nos idées sont plus claires sur les fonctions de notre module, il reste à déterminer ses caractéristiques. La plus importante à mes yeux est que la gestion de la base soit totalement dynamique, c'est-à-dire qu'elle n'ait pas de contraintes sur le nombre d'entrées qu'elle peut contenir. Autrement dit, elle doit allouer de la mémoire au fur et à mesure que cela se révèle nécessaire. Maintenant, vous pouvez vous imposer d'autres contraintes sans aucune hésitation étant donné que notre module est basé sur un type abstrait et que ses modifications n'impliqueront aucune retouche aux applications l'utilisant. Vous pourriez par exemple améliorer la vitesse d'accès à la base. Vous pourrez immédiatement vérifier avec le programme de test si votre nouvelle implémentation marche toujours. Le principe pour lequel j'ai opté est le suivant :
Comment implémenter la généricité en C C'est la dernière chose qui reste à préciser. En effet, tout cela est bien beau mais il est temps maintenant d'en venir aux choses concrètes. Comment la base peut-elle manipuler des entités sans rien savoir sur elles ? En fait, ce n'est pas tout à fait exact. La base doit impérativement connaître une chose sur ces entités : leur taille. En effet, sans cette information, comment ferait-elle pour allouer la mémoire chaque fois que c'est nécessaire ? La taille de l'entrée à manipuler est donc notre paramètre de généricité. Le module tel quel peut être vu comme une coquille vide (inutile) qui se remplit quand on lui donne ce paramètre de généricité. A ce moment, le module est utilisable. On dit qu'on a créé une instance du module (l'hésite à parler de classe mais ce n'est pas éloigné). A partir du module générique, on peut créer une multitude d'instances qui se différencient par la taille des entrées manipulées. Avez-vous remarqué que nous venons d'atteindre le but fixé ? A savoir, pouvoir réutiliser ce module quelles que soient les entrées manipulées. Dorénavant, sous réserve de passer en paramètre la taille de ces entrées lors de la création de l'instance, le module sera capable de les manipuler (les stocker et les restituer) complètement sans savoir de quoi il s'agit ! En C (tout comme en C++, sauf que c'est plus masqué), la généricité se réalise par le biais de pointeurs et de coercition ("cast"). Le module reçoit des pointeurs génériques (de type Generic, qui est en fait "void *", ce qui correspond à un pointeur générique en C ANSI) et restitue des pointeurs. C'est à l'appelant de faire la coercition nécessaire car lui seul connaît le type de ce qu'il récupère. Une illustration rapide de ces concepts :
En clair : le module attend qu'on lui passe des arguments du type Generic et l'appelant veut recevoir des données de type struct "MaStructure *". L'échange se fait par des coercitions dans les deux sens. Autre exemple de généricité en C Un bon exemple de généricité figure dans le fichier database.c à la fonction DB_Sort(). Celui-ci trie la base de données pour produire un invariant fourni dans le fichier en-tête, qui se traduit en français : le premier élément de la base sera le "plus petit". Oui mais voilà : que signifie "le plus petit" quand on manipule des données abstraites ? Je vous rappelle que le module ne sait rien sur les données qu'il manipule : ce ne sont que des pointeurs à ses yeux. Comment effectuer un tri dans ce cas ? Bien entendu, seul l'appelant est capable de comparer deux entrées, et il doit donc l'enseigner au module afin que celui-ci exécute son tri. Ceci se fait par le passage en paramètre de la fonction de tri que le module appellera chaque fois qu'il aura à comparer deux entrées. Cette fonction doit simplement prendre en paramètre deux entrées et retourner "0" si les entrées sont "égales", un nombre négatif si la première est "plus petite" que la deuxième et un nombre positif sinon. Notez que j'utilise (là encore) des guillemets car les notions d'égalité et d'ordre peuvent être très largement étendues de cette façon. On peut par exemple prétendre que la couleur Rouge est supérieure à la couleur Blanche : il suffit de coder cette relation d'ordre dans la fonction passée en paramètre. Vous voyez où la généricité peut mener... Pour plus de détails, vous pouvez vous reporter à la documentation de la fonction qsort() dans la documentation de votre compilateur C. Elle illustre parfaitement cette notion. La structure du module Je vous suggère fortement de respecter l'architecture suivante afin de vous conformer à la norme en vigueur dans ce genre de programmation et de permettre aux programmeurs utilisant votre module de s'y retrouver. Votre travail doit venir en trois parties : 1. Un fichier en-tête (include, qui se termine par ".h") qui donne les prototypes de toutes les fonctions utilisables ainsi que les définitions de type principales. 2. Un ou plusieurs modules (.c) qui contiennent l'implémentation proprement dite. 3. Un ou plusieurs fichiers de tests qui font appel à toutes les fonctions de votre module afin de vérifier leur validité (la rédaction de ces fichiers de tests est sans conteste la partie la plus délicate du développement... et je l'ai un peu négligée dans l'exemple que je vous livre :-)). Les fonctions du fichier en-tête doivent être soigneusement commentées (en anglais si possible) car même si le programmeur dispose d'une documentation de votre module, c'est à ce fichier qu'il fera référence s'il a un doute. Dans les modules d'implémentation, séparez bien les fonctions privées (déclarées en "static" afin que le programmeur ne puisse pas les appeler) des fonctions exportées. Si vous suivez ces simples recommandations, la clarté de votre code y gagnera et vous faciliterez à la fois votre vie et celle d'éventuels utilisateurs... L'utilisation de ce module est très simple : il suffit à l'application d'inclure le fichier en-tête (database.h) et lors de la reliure, de se relier avec l'objet correspondant à l'implémentation (database.o). Voyez la ligne de compilation de datatest ci-dessous pour plus de détails. Instructions pour compiler ces exemples Avec SAS/C :
Avec DICE :
Je vous laisse maintenant vous référer au code que j'ai abondamment commenté. Nous avons passé en revue dans cet article plusieurs notions capitales de la programmation par objets : la généricité (pour produire du code réutilisable), l'abstraction de type (pour pouvoir librement modifier une implémentation), la notion d'instance (création effective d'un objet à partir d'une classe générique). J'espère vous avoir convaincu qu'il est possible de programmer proprement en C si on se tient à certaines règles qui vous épargneront bien des errements... database.h ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]()
|