web-dev-qa-db-fra.com

Créer des "classes" en C, sur la pile vs le tas?

Chaque fois que je vois une "classe" C (toute structure destinée à être utilisée en accédant à des fonctions qui prennent un pointeur sur elle comme premier argument), je les vois implémentées comme ceci:

typedef struct
{
    int member_a;
    float member_b;
} CClass;

CClass* CClass_create();
void CClass_destroy(CClass *self);
void CClass_someFunction(CClass *self, ...);
...

Et dans ce cas, CClass_create toujours mallocs sa mémoire et renvoie un pointeur sur celle-ci.

Chaque fois que je vois new apparaître inutilement en C++, cela semble généralement rendre fous les programmeurs C++, mais cette pratique semble acceptable en C. Qu'est-ce qui donne? Existe-t-il une raison pour laquelle les "classes" de structure allouées au tas sont si courantes?

47
Therhang

Il y a plusieurs raisons à cela.

  1. Utiliser des pointeurs "opaques"
  2. Manque de destructeurs
  3. Systèmes embarqués (problème de débordement de pile)
  4. Les conteneurs
  5. Inertie
  6. "Paresse"

Discutons-les brièvement.

Pour les pointeurs opaques , cela vous permet de faire quelque chose comme:

struct CClass_;
typedef struct CClass_ CClass;
// the rest as in your example

Ainsi, l’utilisateur ne voit pas la définition de struct CClass_, l’isolant ainsi des modifications qui y sont apportées et permettant d’autres choses intéressantes, telles que l’implémentation différente de la classe pour différentes plates-formes.

Bien entendu, cela interdit l'utilisation de variables de pile de CClass. Mais, OTOH, on peut voir que cela n'empêche pas d'allouer des objets CClass de manière statique (à partir d'un pool) - renvoyés par CClass_create ou peut-être une autre fonction comme CClass_create_static.

Manque de destructeurs - Le compilateur C ne détruisant pas automatiquement vos objets de pile CClass, vous devez le faire vous-même (en appelant manuellement la fonction de destructeur). Ainsi, le seul avantage qui reste est le fait que l'allocation de pile est, en général, plus rapide que l'allocation de tas. OTOH, vous n'avez pas besoin d'utiliser le tas - vous pouvez allouer à partir d'un pool, d'une arène ou autre, et cela peut être presque aussi rapide que l'allocation de pile, sans les problèmes potentiels d'allocation de pile décrits ci-dessous.

Systèmes embarqués - Stack n'est pas une ressource "infinie", vous savez. Bien sûr, c'est presque le cas pour la plupart des applications des systèmes d'exploitation "classiques" actuels (POSIX, Windows ...). Mais sur les systèmes embarqués, la pile peut être aussi basse que quelques Ko. C'est extrême, mais même les "gros" systèmes embarqués ont une pile en MB. Donc, il s’écoulera s’il est trop utilisé. Quand cela se produit, la plupart du temps, rien ne garantit ce qui va se passer - autant que je sache, en C et C++, c'est un "comportement indéfini". OTOH, CClass_create() peut renvoyer le pointeur NULL lorsque la mémoire est insuffisante, et vous pouvez le gérer.

Containers - Les utilisateurs de C++ aiment l’allocation de pile, mais si vous créez un std::vector sur la pile, son contenu sera alloué en tas. Vous pouvez modifier cela, bien sûr, mais c'est le comportement par défaut et il est beaucoup plus facile de dire: "tous les membres d'un conteneur sont affectés par tas" plutôt que d'essayer de comprendre comment gérer s'ils ne le sont pas.

Inertia - eh bien, le OO venait de Smalltalk. Tout y est dynamique, alors la traduction "naturelle" en C est la méthode "tout mettre sur le tas". Ainsi, les premiers exemples étaient comme ça et ils ont inspiré les autres pendant de nombreuses années.

" Laziness " - si vous savez que vous ne voulez que des objets empilés, vous avez besoin de quelque chose comme:

CClass CClass_make();
void CClass_deinit(CClass *me);

Toutefois, si vous souhaitez autoriser à la fois la pile et le tas, vous devez ajouter:

CClass *CClass_create();
void CClass_destroy(CClass *me);

C'est plus de travail à faire pour l'implémenteur, mais c'est aussi déroutant pour l'utilisateur. On peut créer des interfaces légèrement différentes, mais cela ne change rien au fait que vous avez besoin de deux ensembles de fonctions. 

Bien sûr, la raison "conteneurs" est aussi en partie une raison "paresse".

50
srdjan.veljkovic

En supposant que, comme dans votre question, CClass_create et CClass_destroy utilisent malloc/free, alors, pour moi, suivre est une mauvaise pratique:

void Myfunc()
{
  CClass* myinstance = CClass_create();
  ...

  CClass_destroy(myinstance);
}

car on pourrait éviter un malloc et un libre facilement:

void Myfunc()
{
  CClass myinstance;        // no malloc needed here, myinstance is on the stack
  CClass_Initialize(&myinstance);
  ...

  CClass_Uninitialize(&myinstance);
                            // no free needed here because myinstance is on the stack
}

avec

CClass* CClass_create()
{
   CClass *self= malloc(sizeof(CClass));
   CClass_Initialize(self);
   return self;
}

void CClass_destroy(CClass *self);
{
   CClass_Uninitialize(self);
   free(self);
}

void CClass_Initialize(CClass *self)
{
   // initialize stuff
   ...
}

void CClass_Uninitialize(CClass *self);
{
   // uninitialize stuff
   ...
}

En C++, nous préférons aussi faire ceci:

void Myfunc()
{
  CClass myinstance;
  ...

}

que ceci:

void Myfunc()
{
  CClass* myinstance = new CCLass;
  ...

  delete myinstance;
}

Afin d'éviter une variable new/delete inutile.

14
Jabberwocky

En C, lorsqu'un composant fournit une fonction "créer", son implémenteur contrôle également la manière dont le composant est initialisé. Ainsi, non seulement émule le operator new C++, mais également le constructeur de la classe.

Abandonner ce contrôle sur l'initialisation signifie beaucoup plus de contrôle d'erreur sur les entrées, donc garder le contrôle facilite la fourniture d'un comportement cohérent et prévisible.

Je prends également exception à malloc always utilisé pour allouer de la mémoire. Cela peut souvent être le cas, mais pas toujours. Par exemple, dans certains systèmes intégrés, vous constaterez que malloc/free n'est pas utilisé du tout. Les fonctions X_create peuvent attribuer d’autres manières, par exemple: à partir d'un tableau dont la taille est fixée au moment de la compilation.

9
Sigve Kolbeinson

Cela engendre beaucoup de réponses car il s’agit d’un peu basé sur les opinions. Je veux tout de même expliquer pourquoi je préfère personnellement que mes "objets C" soient alloués sur le tas. La raison est d'avoir tous mes champs cachés (parler: private) de consommer du code. Ceci s'appelle un pointeur opaque . En pratique, cela signifie que votre fichier d’en-tête ne définit pas la struct utilisée, il la déclare seulement. Conséquence directe, le code consommateur ne peut pas connaître la taille de la variable struct et par conséquent, l’allocation de pile devient impossible.

L'avantage est le suivant: consommer du code peut jamais dépendre de la définition de struct, cela signifie qu'il est impossible de rendre le contenu de struct incohérent de l'extérieur et vous évitez une recompilation inutile consommer du code lorsque la struct change.

Le premier problème est résolu dans c ++ en déclarant que les champs sont private. Mais la définition de votre class est toujours importée dans toutes les unités de compilation qui l'utilisent, ce qui rend nécessaire de les recompiler, même lorsque seuls vos membres private changent. La solution souvent utilisée dans c ++ est le modèle pimpl: placez tous les membres privés dans une seconde struct (ou: class) uniquement définie dans le fichier d'implémentation. Bien entendu, cela nécessite que votre pimpl soit alloué sur le tas.

Ajoutant à cela: les langages modernes OOP (comme par exemple Java ou c # ) permettent d'allouer des objets (et généralement de décider s'il s'agit d'une pile ou d'un tas en interne) sans le code appelant. sachant à propos de leur définition.

8
user2371524

Je changerais le "constructeur" en void CClass_create(CClass*);

Il ne retournera pas une instance/référence de la structure, mais sera appelé sur une. 

Que cela soit alloué sur la "pile" ou dynamiquement, cela dépend entièrement des exigences de votre scénario d'utilisation. Quelle que soit la façon dont vous l'allouez, vous appelez simplement CClass_create() en passant la structure allouée en tant que paramètre.

{
    CClass stk;
    CClass_create(&stk);

    CClass *dyn = malloc(sizeof(CClass));
    CClass_create(dyn);

    CClass_destroy(&stk); // the local object lifetime ends here, dyn lives on
}

// and later, assuming you kept track of dyn
CClass_destroy(dyn); // destructed
free(dyn); // deleted

Veillez simplement à ne pas renvoyer de référence à un local (alloué sur la pile), car c’est UB.

Quelle que soit l'allocation, vous devrez appeler void CClass_destroy(CClass*); au bon endroit (la fin de la durée de vie de cet objet) et, si alloué de manière dynamique, libérer également la mémoire.

Faites la distinction entre allocation/désaffectation et construction/destruction, ce ne sont pas les mêmes (même si en C++, elles pourraient être automatiquement couplées).

3
dtech

En général, le fait que vous voyiez un * ne signifie pas qu'il a été malloc 'd. Vous auriez pu avoir un pointeur sur la variable globale static, par exemple; dans votre cas, en effet, CClass_destroy() ne prend aucun paramètre, ce qui suppose qu'il connaisse déjà certaines informations sur l'objet en cours de destruction.

De plus, les pointeurs, qu'ils soient ou non malloc 'd, constituent le seul moyen de modifier l'objet.

Je ne vois pas de raisons particulières pour utiliser le tas au lieu de la pile: vous n'utilisez pas moins de mémoire. Ce qui est nécessaire, cependant, pour initialiser de telles "classes" sont des fonctions init/destroy car la structure de données sous-jacente peut en réalité contenir des données dynamiques, d'où l'utilisation de pointeurs.

3
edmz

C manque certaines choses que les programmeurs C++ prennent pour acquis, à savoir.

  1. spécificateurs publics et privés
  2. constructeurs et destructeurs

Le gros avantage de cette approche est que vous pouvez masquer la structure dans votre fichier C et forcer la construction et la destruction correctes avec vos fonctions de création et destruction.

Si vous exposez la structure dans votre fichier .h, cela signifie que les utilisateurs peuvent accéder directement aux membres, ce qui rompt l’encapsulation. De plus, ne pas forcer la création permet une construction incorrecte de votre objet.

2
doron

Puisqu'une fonction ne peut renvoyer une structure allouée à une pile si elle ne contient aucun pointeur vers une autre structure allouée . Si elle ne contient que des objets simples (int, bool, floats, chars et array, mais aucun pointeur), vous peut l'allouer sur la pile. Mais vous devez savoir que si vous le retournez, il sera copié. Si vous souhaitez autoriser les pointeurs vers d'autres structures ou si vous souhaitez éviter la copie, utilisez tas.

Mais si vous pouvez créer la structure dans une unité top level et ne l'utiliser que dans des fonctions appelées et ne jamais la retourner, la pile est appropriée.

2
Serge Ballesta

Si le nombre maximal d'objets d'un même type devant exister simultanément est fixé, le système doit pouvoir faire quelque chose avec chaque instance "en direct", et les éléments en question ne consomment pas trop d'argent. L’approche n’est généralement ni une allocation de tas ni une allocation de pile, mais plutôt un tableau alloué statiquement ainsi que des méthodes "create" et "destroy". L'utilisation d'un tableau évite d'avoir à gérer une liste d'objets liés et permet de gérer le cas où un objet ne peut pas être détruit immédiatement car il est "occupé" [par exemple. si des données arrivent sur un canal via une interruption ou DMA lorsque le code de l'utilisateur décide de ne plus s'intéresser au canal et qu'il en dispose, le code d'utilisateur peut définir un indicateur de "disposition à la fin" et revenir sans avoir à s'inquiéter ayant une interruption en attente ou un stockage de remplacement DMA qui ne lui est plus attribué].

L'utilisation d'un pool d'objets de taille fixe de taille fixe rend l'attribution et la désaffectation beaucoup plus prévisibles que la prise de stockage à partir d'un segment de taille mixte. L’approche n’est pas géniale dans les cas où la demande est variable et que les objets occupent beaucoup d’espace (individuellement ou collectivement), mais lorsque la demande est généralement cohérente (par exemple, une application a besoin de 12 objets à tout moment, et parfois de 3 plus) cela peut marcher beaucoup mieux que les approches alternatives. Une des faiblesses est que toute configuration doit être effectuée à l’endroit où le tampon statique est déclaré ou doit être effectuée avec un code exécutable dans les clients. Il n'y a aucun moyen d'utiliser la syntaxe d'initialisation de variable sur un site client.

Incidemment, en utilisant cette approche, il n’est pas nécessaire que le code client reçoive des pointeurs sur quoi que ce soit. Au lieu de cela, on peut identifier les ressources en utilisant le nombre entier de taille qui convient. De plus, si le nombre de ressources ne doit jamais dépasser le nombre de bits d'un int, il peut être utile que certaines variables d'état utilisent un bit par ressource. Par exemple, vous pouvez avoir les variables timer_notifications (écrites uniquement via le gestionnaire d’interruptions) et timer_acks (écrites uniquement via le code de ligne principale) et spécifier que le bit N de (timer_notifications ^ timer_acks) sera défini chaque fois que le temporisateur N voudra un service. Avec une telle approche, le code n'a besoin que de lire deux variables pour déterminer si un minuteur nécessite un service, plutôt que d'avoir à lire une variable pour chaque minuteur.

2
supercat

Est-ce que votre question "pourquoi en C il est normal d'allouer de la mémoire dynamiquement et en C++ ce n'est pas"?

C++ a beaucoup de constructions en place qui rendent les nouveaux constructeurs redondants.

Mais en C, vous ne pouvez pas le contourner.

1
Serve Laurijssen

C'est en fait un retour de bâton en C++, rendant le "nouveau" trop facile.

En théorie, l'utilisation de ce modèle de construction de classe en C est identique à l'utilisation de "nouveau" en C++, il ne devrait donc y avoir aucune différence. Cependant, la façon dont les gens ont tendance à penser les langues est différente, donc la façon dont les gens réagissent au code est différente.

En C, il est très courant de penser aux opérations exactes que l’ordinateur devra effectuer pour atteindre vos objectifs. Ce n'est pas universel, mais c'est un état d'esprit très commun. On suppose que vous avez pris le temps de faire l'analyse coûts/avantages du malloc/free.

En C++, il est devenu beaucoup plus facile d'écrire des lignes de code qui font beaucoup pour vous, sans même vous en rendre compte. Il est assez courant que quelqu'un écrive une ligne de code sans même se rendre compte qu'il a déjà fallu appeler 100 ou 200 nouveaux/supprimer! Cela a provoqué une réaction brutale, où le développeur C++ fanfare fanatiquement aux nouvelles et les supprime, de peur de se faire appeler accidentellement partout.

Ce sont, bien sûr, des généralisations. Les communautés C et C++ ne correspondent en aucun cas à ces moules. Cependant, si vous commencez à utiliser new au lieu de mettre des éléments sur le tas, cela peut en être la cause fondamentale.

1
Cort Ammon

C'est plutôt étrange que vous le voyiez si souvent. Vous devez avoir l'air d'un code "paresseux".

En C, la technique que vous décrivez est généralement réservée aux types de bibliothèque "opaques", c’est-à-dire aux types struct dont les définitions sont volontairement rendues invisibles pour le code du client. Comme le client ne peut pas déclarer de tels objets, l'idiome doit vraiment avoir une allocation dynamique dans le code de la bibliothèque "cachée".

Lorsque masquer la définition de la structure n'est pas obligatoire, un idiome C typique se présente généralement comme suit

typedef struct CClass
{
    int member_a;
    float member_b;
} CClass;

CClass* CClass_init(CClass* cclass);
void CClass_release(CClass* cclass);

La fonction CClass_init initialise l'objet *cclass et renvoie le même pointeur que le résultat. C'est à dire. l'appelant a la charge d'allouer de la mémoire pour l'objet et celui-ci peut l'attribuer comme bon lui semble

CClass cclass;
CClass_init(&cclass);
...
CClass_release(&cclass);

Un exemple classique de cet idiome serait pthread_mutex_t avec pthread_mutex_init et pthread_mutex_destroy.

L'utilisation de l'ancienne technique pour les types non-opaques (comme dans votre code d'origine) est généralement une pratique douteuse. Cela est tout à fait discutable en tant qu’utilisation gratuite de la mémoire dynamique en C++. Cela fonctionne, mais encore une fois, utiliser de la mémoire dynamique quand cela n’est pas nécessaire est aussi mal vu en C qu’en C++.

0
AnT