36 points par GN⁺ 2025-08-29 | 1 commentaires | Partager sur WhatsApp
  • Les patterns de conception orientés objet permettent d’implémenter le polymorphisme et la modularité même dans un kernel écrit en C, rendant possible une conception système plus souple
  • L’utilisation d’une vtable (table de fonctions virtuelles) standardise l’interface des périphériques et des services, et permet de prendre en charge des comportements variés via des changements dynamiques à l’exécution
  • Les services du kernel et le scheduler fournissent, via des vtables, une interface cohérente pour des actions comme démarrer, arrêter ou redémarrer, tout en encapsulant les détails d’implémentation
  • Couplée aux modules kernel, cette approche permet le chargement dynamique de pilotes et l’extension du système sans recompilation
  • Cette approche offre de la souplesse et une liberté d’expérimentation, mais a pour inconvénient une certaine verbosité liée à une syntaxe complexe et au passage explicite des objets

Liberté et patterns orientés objet dans le développement d’OS

  • Développer son propre OS permet d’expérimenter librement, sans les contraintes de la collaboration ou des applications réelles
    • On est libéré des vulnérabilités de sécurité, de la maintenance du code et de la pression des releases
    • C’est l’un des attraits du développement d’OS, qui permet d’explorer des patterns de programmation non standard
  • L’article LWN “Object-oriented design patterns in the kernel” présente des cas où le kernel Linux met en œuvre des principes orientés objet en C
    • Le polymorphisme y est implémenté avec des structures contenant des pointeurs de fonction
    • Encapsulation, modularité et extensibilité permettent ainsi de profiter des avantages de l’orienté objet même dans un kernel bas niveau

Concept de base de la vtable

  • Une vtable est une structure contenant des pointeurs de fonction qui définit l’interface d’un objet
    • Exemple : une structure pour les opérations d’un périphérique
      struct device_ops {  
          void (*start)(void);  
          void (*stop)(void);  
      };  
      struct device {  
          const char *name;  
          const struct device_ops *ops;  
      };  
      
  • Des périphériques différents (par ex. netdev, disk) utilisent la même API, mais avec des implémentations distinctes
    • netdev.ops->start() invoque le comportement d’un périphérique réseau, tandis que disk.ops->start() appelle celui d’un périphérique disque
  • Changement à l’exécution : remplacer dynamiquement une vtable permet de modifier le comportement sans changer le code appelant
    • Avec une synchronisation appropriée, cela permet une évolution dynamique propre du comportement

Exemples d’application dans un OS

Gestion des services

  • Les services du kernel (gestionnaire réseau, pool de workers, serveur de fenêtres, etc.) sont gérés via une interface cohérente
    • Structure de service :
      struct service_ops {  
          void (*start)(void);  
          void (*stop)(void);  
          void (*restart)(void);  
      };  
      struct service {  
          pid_t pid;  
          const struct service_ops *ops;  
      };  
      
  • Chaque service implémente son propre comportement, mais les opérations démarrer/arrêter/redémarrer s’exécutent de manière standardisée depuis le terminal
  • Cela réduit le couplage entre le code et les services, et simplifie l’administration

Scheduler

  • Le scheduler peut prendre en charge différentes stratégies comme le round-robin, le plus court d’abord, FIFO ou l’ordonnancement par priorité
    • L’interface est simplifiée autour de yield, block, add, next
    • Définie via une vtable, elle permet de changer de politique d’ordonnancement à l’exécution
    • On peut ainsi modifier l’ensemble de la politique sans toucher au reste du kernel

Abstraction des fichiers

  • La structure file_operations de Linux met en œuvre la philosophie « tout est fichier »
  • Sockets, périphériques et fichiers texte exposent tous la même interface read/write
  • Le code en espace utilisateur peut donc fonctionner de manière cohérente sans connaître les détails d’implémentation

Couplage avec les modules kernel

  • Les modules kernel permettent de charger dynamiquement des pilotes ou des hooks en remplaçant une vtable
    • Comme avec les modules Linux, on peut étendre le kernel sans recompilation ni redémarrage
    • Lorsqu’une nouvelle fonctionnalité est ajoutée, il suffit de mettre à jour la vtable de la structure existante

Inconvénients

  • Complexité syntaxique :
    • Il faut passer explicitement l’objet, comme dans object->ops->start(object)
    • C’est plus verbeux que le passage implicite en C++
    • Les signatures de fonction sont elles aussi longues :
      static void object_start(struct object* this) {  
          this->id = ...  
      }  
      
  • Avantage : ce passage explicite rend plus claires les dépendances des fonctions et le couplage entre objet et comportement
    • Dans le code kernel, c’est un tradeoff pertinent entre complexité et clarté

Ce qu’il faut en retenir

  • Les vtables offrent un moyen simple de réduire la complexité tout en préservant la souplesse
    • Remplacement du comportement à l’exécution, maintien d’interfaces cohérentes, ajout facile de nouvelles fonctionnalités
  • Elles proposent une nouvelle manière d’implémenter une conception orientée objet en C, tout en soulignant le plaisir expérimental du développement d’OS
  • Ressource complémentaire : le projet xine (https://xine.sourceforge.net/hackersguide#id324430) montre comment gérer des variables privées avec des vtables
  • Le développement d’OS est un terrain d’expérimentation créative, et les patterns orientés objet y prouvent qu’ils restent des outils puissants même dans les systèmes bas niveau

1 commentaires

 
GN⁺ 2025-08-29
Avis sur Hacker News
  • Discussion autour d’un article expliquant que, bien que le noyau Linux soit écrit en C, il adopte des principes orientés objet, par exemple en utilisant des pointeurs de fonction dans des structures pour implémenter le polymorphisme. Ces techniques existaient bien avant la programmation orientée objet et étaient appelées « types de données abstraits (ADT) » ou abstraction de données. La différence essentielle entre ADT et OOP est que, dans un ADT, on peut omettre l’implémentation de certaines fonctions, alors qu’en OOP une implémentation est toujours nécessaire. Si l’on veut des fonctions optionnelles en OOP, il faut créer une classe supplémentaire pour chaque fonction optionnelle, puis l’hériter via héritage multiple à chaque implémentation et vérifier à l’exécution si l’objet est une instance de cette classe additionnelle. Avec un ADT, il suffit au contraire de vérifier si le pointeur de fonction vaut NULL
    • En Smalltalk et en Objective-C, la manière traditionnelle de faire de l’OOP consiste simplement à vérifier à l’exécution si un objet peut répondre à un message. Il est regrettable que l’essence de l’OOP ait été déformée par les patterns de conception excessivement centrés sur les classes en C++ et en Java
    • Accord global, avec la remarque qu’on utilise aussi ce pattern en C, tandis qu’en OOP traditionnelle on place souvent une implémentation par défaut ou un stub dans la base. Dans les langages OOP modernes ou orientés concepts, on peut aussi caster vers une interface n’utilisant qu’un sous-ensemble de l’API nécessaire. Go est un bon exemple
    • À propos de l’idée que ces techniques sont antérieures à la programmation orientée objet, on préfère dire que l’OOP a plutôt formalisé des patterns et paradigmes préexistants
    • Dans la plupart des langages OOP comme Java ou C#, on peut maintenant utiliser des lambdas, donc implémenter cela exactement comme en C. Les lambdas ne sont au fond que des pointeurs de fonction et peuvent être assignés directement à des variables d’instance. (Java a mis plus de dix ans à introduire les lambdas, et il existe même une vieille anecdote assez absurde selon laquelle Sun Microsystems aurait intenté un procès à Microsoft à propos d’une tentative d’ajouter des lambdas à Java)
    • L’héritage n’est pas obligatoire. On peut utiliser le pattern composite. Python ressemble aussi à cela dans la mesure où il faut passer explicitement le pointeur self/this/object, ce qui le rapproche de l’abstraction de données à la manière du C
  • Il y a quelques années, Peterpaul a développé un système orienté objet léger et agréable à utiliser au-dessus du C (repo). Pas besoin de passer l’objet explicitement, et même si la documentation est limitée, il existe une suite de tests complète (test 1, test 2)
    • Si vous voulez voir à quoi cela ressemble sans le sucre syntaxique de carbon, c’est visible ici. Il ne semble pas y avoir de prise en charge du polymorphisme paramétrique
    • Vala semble aussi être une tentative intéressante dans cette niche
  • L’auteur dit ne pas très bien connaître ce sujet, mais a l’impression que l’OP fait quelque chose de différent de ce que font les développeurs du noyau. En lisant l’article lié par l’OP, les vtables contiennent des pointeurs de fonction typés, alors que l’OP semble utiliser des pointeurs void. De plus, l’avantage principal mentionné par les développeurs du noyau est d’économiser de la mémoire en plaçant un seul pointeur de vtable dans chaque instance de structure, plutôt que plusieurs pointeurs de fonction. Autrement dit, l’économie mémoire est le point clé, alors que l’OP utilise cette vtable comme niveau d’indirection pour remplacer des méthodes à l’exécution et implémenter le polymorphisme. Ce pattern est donc différent de ce dont parlent les développeurs du noyau
    • L’OP ne parlait pas de pointeurs void, mais de void au sens de fonction sans paramètre et sans valeur de retour. Une vtable sert justement à implémenter le polymorphisme. Sans polymorphisme, on n’utiliserait pas de vtable du tout, ce qui économiserait encore plus de mémoire
  • À propos de l’idée qu’il est pénible de devoir passer explicitement l’objet à chaque fois, certains disent au contraire ne pas aimer le this implicite. On passe bien en pratique cette instance this en permanence, et le fait qu’elle soit explicite évite de confondre l’origine d’une variable : instance, globale ou autre
    • En C++ (et en Java), ne pas rendre obligatoire l’usage de this lors de la référence à un membre d’instance est considéré comme l’une des grandes erreurs de la syntaxe OOP
    • L’auteur pense que ce que l’article pointe, c’est la nécessité de mentionner deux fois l’objet dans object->ops->start(object) : une fois pour résoudre la vtable, une fois pour passer l’objet à l’implémentation C de la fonction
    • Pour clarifier l’appartenance des variables, on utilise souvent des conventions de nommage comme mFoo, m_Foo, foo_, etc. foo_ est préféré à this->foo car plus concis. Bien sûr, en C++ on peut aussi écrire this explicitement
    • Le this implicite rend l’écriture plus concise et, avec de vraies méthodes, évite de répéter le préfixe de structure dans chaque fonction. Par exemple, mystruct_dosmth(s); devient plus naturellement s->dosmth();
    • On peut aussi traiter cela plus astucieusement avec des macros
  • C’est via une présentation Tmux (présentation) que certains ont découvert ce pattern en C pour la première fois. Ils ont aussi rédigé un billet sur le sujet (article sur les commandes orientées objet de tmux)
  • Certains disent avoir implémenté cette approche dans quelques petits projets à l’université. C’était amusant de retrouver une sensation proche de l’OOP en C, mais si l’on n’est pas prudent, les problèmes peuvent rapidement devenir sérieux
  • Il faut noter qu’il s’agit d’un pattern utilisant l’interface, c’est-à-dire la vtable, le tableau de pointeurs de fonction, et non l’objet dans son ensemble. D’autres fonctionnalités orientées objet comme les classes ou l’héritage sont au contraire coûteuses et difficiles à suivre
    • L’héritage n’est au fond qu’une forme de composition de vtables. Une classe n’est elle-même qu’une combinaison de vtable et de variables de portée
    • En C, si l’on caste une struct via son premier membre, l’héritage de champs devient plus naturel qu’on ne le pense
    • Une vtable contient généralement des fonctions recevant un pointeur this. L’exemple de struct file_operations contient des pointeurs de fonction qui ne reçoivent pas de pointeur this, donc il est difficile d’y voir une véritable vtable
  • Certains créent des wrappers inline autour des fonctions de la vtable pour pouvoir écrire foo(thing, ...) au lieu de thing->vtable->foo(thing, ...)
  • Certains se sont toujours demandé pourquoi ce pattern n’a jamais été intégré au nouveau standard du C. Manifestement, beaucoup de gens réimplémentent sans cesse la même chose
    • Si l’on ajoute du sucre syntaxique, il faut alors gérer à la fois un usage officiellement autorisé et une sorte de fallback paraissant incomplet, c’est-à-dire la manière historique. L’un des avantages du C est justement de ne pas masquer la complexité dynamique. Quand il y a du dispatch dynamique, c’est toujours visible. Beaucoup de langages proposent déjà cette formalisation, mais la force propre du C est que la complexité y reste apparente. Cela pousse à n’utiliser le dispatch dynamique que lorsqu’il est réellement nécessaire. En plus, la syntaxe n’est pas si difficile
    • Il semble que du côté de High C Compiler, une tentative allant partiellement dans ce sens ait existé
  • Conseil très appuyé, fondé sur une expérience douloureuse : ne jamais utiliser ce pattern. Certains ont dû maintenir un gros code structuré ainsi et parlent d’un véritable cauchemar. La lisibilité est affreuse, le compilateur n’optimise pas les appels à travers pointeurs, l’outillage n’aide pas du tout, la syntaxe est maladroite, et les nouveaux arrivants doivent quasiment maîtriser l’intérieur d’un compilateur C++ pour simplement lire le code. Surtout, les bénéfices douteux de l’introduction de l’OOP ne compensent pas les dégâts potentiels à long terme sur la maintenance. Si c’est vraiment nécessaire, il vaut mieux utiliser directement C++
    • À la question de savoir ce qui était concrètement si cauchemardesque, certains répondent qu’un manque de sucre syntaxique rend au contraire plus visible le fait qu’un appel de fonction passe par du dispatch dynamique, ce qui améliore la lisibilité. On peut ainsi limiter ce mécanisme aux seuls endroits nécessaires. Ils disent aussi avoir lu un billet expliquant que le code dynamique en C, avec peu de pointeurs de fonction, est plus facile à optimiser. Il ne s’agit pas de réimplémenter un compilateur C++ à l’identique, mais simplement de comprendre l’essence de l’OOP pour l’implémenter naturellement. Enfin, à l’argument « ne transformez pas le C en C++ mal fait », ils répondent que c’est au contraire une approche très C, choisie justement parce qu’elle permet d’introduire du dynamique seulement là où on le souhaite.