Implémentation du pointeur intelligent LibSass
LibSass utilise des pointeurs intelligents très similaires à shared_ptr, connu par Boost ou C++11. L'implémentation est un peu moins modulaire, car inutile. Plusieurs options de débogage à la compilation sont disponibles pour déboguer les cycles de vie de la mémoire.
Classes mémoire
SharedObj
Classe de base pour les implémentations de noeuds actuels. Cela garantit que chaque objet possède un compteur de référence et d'autres valeurs :
SharedPtr (classe de base pour SharedImpl)
Classe de base conservant le pointeur. Le compteur de références est entreposé directement dans l'objet pointeur (SharedObj).
SharedImpl (hérite de SharedPtr)
Il s'agit de la classe de base principale pour les objets que vous utilisez dans votre code. Elle garantit que la mémoire vers laquelle elle pointe sera supprimée lorsque toutes les copies vers le même objet/la même mémoire seront hors de portée.
Pour éviter au développeur de devoir saisir la classe modèle à chaque fois, nous avons créé des typedefs pour chaque spécialisation de noeud AST disponible :
- typedef SharedImpl<Number> Number_Obj;
- Number_Obj number = SASS_MEMORY_NEW(...);
Cycles de vie de la mémoire
Récupération de pointeurs
On utilise souvent le terme «récupération». Il désigne le moment où un pointeur brut, échappant à tout contrôle, est assigné à un objet compté par référence (XYZ_Obj = XYZ_Ptr). À partir de ce moment, la mémoire est automatiquement libérée dès que l'objet sort de la portée (mais seulement si le compteur de références atteint zéro). L'essentiel est que vous n'ayez pas à vous soucier de la gestion de la mémoire.
Détachement d'objets
Il est parfois impossible de renvoyer directement les objets comptés par référence (voir les problèmes de types de retour covariants invalides ci-dessous). Cependant, il est souvent nécessaire d'utiliser des objets de référence dans une fonction pour éviter les fuites en cas de déclenchement d'une erreur. Pour cela, vous pouvez utiliser le détachement, qui détache la mémoire du pointeur de l'objet compté par référence. Ainsi, lorsque l'objet compté par référence sort de la portée, la mémoire attachée ne sera pas libérée. Vous êtes alors à nouveau responsable de la libération de la mémoire (il suffit de l'assigner à nouveau à un objet compté par référence).
Références circulaires
Les implémentations de mémoire comptée par référence sont sujettes aux références circulaires. Ce problème peut être résolu en utilisant un ramasse-miettes multigénérationnel. Cependant, pour notre cas d'utilisation, cela semble excessif. Il n'existe actuellement aucun moyen pour les utilisateurs (code Sass) de créer des références circulaires. Nous pouvons donc contourner ce problème potentiel. Les développeurs doivent toutefois être conscients de cette limitation.
Il existe, à mon avis, deux endroits où des références circulaires peuvent se produire : le membre source de chaque sélecteur ; et le code d'extension (gestion des noeuds). Le moyen le plus simple d'éviter ce problème est d'affecter uniquement des clones d'objets complets à ces membres. Si vous savez que la durée de vie des objets est supérieure à celle de la référence créée, vous pouvez également stocker uniquement le pointeur brut. En cas de besoin, ce problème peut être résolu avec des pointeurs faibles.
Résolution des problèmes de types de retour covariants invalides
Si vous n'êtes pas familier avec le problème mentionné, vous pouvez vous renseigner sur les types de retour covariants et les fonctions virtuelles.
Ils ont rencontré ce problème au moins avec le modèle de visiteur CRTP (eval, expand, listize,...). Cela signifie qu'ils ne peuvent pas renvoyer directement les objets référencés. Ils sont obligés de renvoyer des pointeurs bruts, sinon ils auraient besoin de nombreux upcasts explicites et coûteux de la part des appelants/consommateurs.
Fonctions simples allouant de nouveaux noeuds AST
Lors de l'étape d'analyse, nous créons souvent de nouveaux objets et pouvons simplement renvoyer un pointeur unique (ce qui signifie que la propriété revient clairement à l'appelant). L'appelant/consommateur est responsable de la libération de la mémoire.
Le modèle ci-dessus est recommandé pour des cas aussi simples.
Allouer de nouveaux nouds AST dans des fonctions pouvant générer des erreurs.
L'exemple précédent comporte un inconvénient majeur, compte tenu de son implémentation plus réaliste générant une erreur. L'erreur peut se produire en profondeur dans une autre fonction. Dans ce cas, le stockage de pointeurs bruts à libérer entraînerait une fuite.
Avec cette fonction parse_integer, l'exemple précédent entraînerait une fuite de mémoire. C'est assez évident, car la mémoire allouée ne sera pas libérée, n'ayant jamais été affectée à une valeur SharedObj. Par conséquent, le code ci-dessus serait plus approprié :
- typedef Number* Number_Ptr;
- int parse_integer() {
- ... // faire l'analyse
- if (error) throw(error);
- return 42;
- }
- // cette fuite est due au retour du pointeur devant renvoyer Number_Obj à la place, bien que ce ne soit pas possible pour les virtuels !
- Number_Ptr parse_number() {
- Number_Obj nr = SASS_MEMORY_NEW(...);
- nr->value(parse_integer()); // déclencheurs
- return &nr; // Ptr depuis Obj
- }
- Number_Obj nr = parse_number();
- // sera désormais libéré automatiquement
L'exemple ci-dessus ne fonctionnera malheureusement pas tel quel, car nous renvoyons un Number_Ptr depuis cette fonction. Par conséquent, l'objet alloué dans la fonction est déjà supprimé lorsqu'il est récupéré par l'appelant. Une solution simple pour ce cas d'utilisation simplifié serait de remplacer le type de retour de parse_number par Number_Obj. C'est exactement ce que nous faisons dans l'analyseur. Cependant, comme indiqué précédemment, cela ne fonctionnera pas pour les fonctions virtuelles en raison de types de retour covariants non valides !
Renvoyer des objets gérés depuis des fonctions virtuelles
La solution simple serait de simplement créer une nouvelle copie sur la mémoire de tas et de la renvoyer. Mais cela semble une solution très peu élégante. Pourquoi ne pas simplement indiquer à l'objet de le traiter comme un objet nouvellement alloué ? Et c'est possible. J'ai ajouté une méthode de détachement indiquant à l'objet de survivre à la désallocation jusqu'à la prochaine récupération. Cela signifie qu'il y aura une fuite s'il n'est pas récupéré par le consommateur.
- typedef Number* Number_Ptr;
- int parse_integer() {
- ... // faire l'analyse
- if (error) throw(error);
- return 42;
- }
- Number_Ptr parse_number() {
- Number_Obj nr = SASS_MEMORY_NEW(...);
- nr->value(parse_integer()); // throws
- return nr.detach();
- }
- Number_Obj nr = parse_number();
- // sera désormais libéré automatiquement
Options de débogage à la compilation
Pour activer le débogage mémoire, vous devez définir DEBUG_SHARED_PTR. Cela peut être fait dans include/sass/base.h :
- #define DEBUG_SHARED_PTR
Cela affichera la mémoire perdue à la sortie sur stderr. Vous pouvez également utiliser setDbg(true) sur des variables spécifiques pour générer des événements d'augmentation, de diminution et autres.
Pourquoi réinventer la roue quand il existe shared_ptr de C++11 ?
Tout d'abord, l'implémentation d'une classe de pointeur intelligent n'est pas si difficile. Mais il y a des avantages plus importants :
- Meilleure compatibilité avec GCC 4.4 (dont la plupart du code est encore prêt à l'emploi)
- Non thread-safe (permet de libérer des performances sur certains compilateurs)
- Possibilité de suivre les allocations de mémoire à des fins de débogage
- Ajout de fonctionnalités supplémentaires si nécessaire (comme dans detach)
- Facultatif : implémentation optimisée de pointeur faible possible
Sécurité des processus léger
Comme indiqué précédemment, ce n'est pas thread-safe pour le moment. Mais ils n'en ont de toute façon pas besoin. Et ils supposent qu'ils ne partagerons probablement jamais de noeuds AST entre différents processus légers.