C ++: pointeurs intelligents, pointeurs bruts, pas de pointeurs? [fermé]


48

Dans le cadre du développement de jeux en C ++, quels sont vos modèles préférés en ce qui concerne l’utilisation de pointeurs (qu’ils soient nuls, bruts, étendus, partagés ou encore entre puce et idiot)?

Vous pourriez envisager

  • propriété d'objet
  • facilité d'utilisation
  • politique de copie
  • aérien
  • références cycliques
  • plate-forme cible
  • utiliser avec des conteneurs

Réponses:


32

Après avoir essayé diverses approches, je me retrouve aujourd'hui dans l'alignement du Guide de style de Google C ++ :

Si vous avez réellement besoin de la sémantique du pointeur, scoped_ptr est excellent. Vous ne devez utiliser std :: tr1 :: shared_ptr que dans des conditions très spécifiques, par exemple lorsque des objets doivent être conservés par des conteneurs STL. Vous ne devriez jamais utiliser auto_ptr. [...]

De manière générale, nous préférons concevoir un code avec une propriété d'objet claire. La propriété d'objet la plus claire est obtenue en utilisant un objet directement en tant que champ ou variable locale, sans utiliser de pointeur. [..]

Bien qu'ils ne soient pas recommandés, les pointeurs comptés de références sont parfois le moyen le plus simple et le plus élégant de résoudre un problème.


14
Aujourd'hui, vous pouvez utiliser std :: unique_ptr au lieu de scoped_ptr.
Klaim

24

Je suis aussi le courant de pensée "forte appropriation". J'aime bien préciser que "cette classe possède ce membre" lorsque c'est approprié.

Je l'utilise rarement shared_ptr. Si je le fais, je fais un usage libéral weak_ptrchaque fois que je peux afin de pouvoir le traiter comme un descripteur d'objet au lieu d'augmenter le nombre de références.

Je l'utilise scoped_ptrpartout. Cela montre une propriété évidente. La seule raison pour laquelle je ne fais pas que des objets comme ce membre est parce que vous pouvez transférer les déclarer s'ils sont dans un scoped_ptr.

Si j'ai besoin d'une liste d'objets, j'utilise ptr_vector. C'est plus efficace et moins d'effets secondaires que d'utiliser vector<shared_ptr>. Je pense que vous pourriez ne pas être en mesure de déclarer le type dans le ptr_vector (ça fait un moment), mais la sémantique de ce mot en vaut la peine, à mon avis. Fondamentalement, si vous supprimez un objet de la liste, il est automatiquement supprimé. Cela montre également une propriété évidente.

Si j'ai besoin de référence à quelque chose, j'essaye d'en faire une référence au lieu d'un pointeur nu. Parfois, cela n’est pas pratique (c’est-à-dire que vous avez besoin d’une référence après la construction de l’objet). Quoi qu'il en soit, les références montrent évidemment que vous ne possédez pas l'objet et si vous suivez la sémantique d'un pointeur partagé partout ailleurs, les pointeurs nus n'entraînent généralement pas de confusion supplémentaire (surtout si vous suivez une règle "pas de suppression manuelle"). .

Avec cette méthode, un jeu iPhone sur lequel j'ai travaillé ne pouvait avoir qu'un seul deleteappel, et c'était dans le pont Obj-C vers C ++ que j'ai écrit.

En général, j'estime que la gestion de la mémoire est trop importante pour être laissée à l'homme. Si vous pouvez automatiser la suppression, vous devriez. Si la surcharge générée par shared_ptr est trop coûteuse au moment de l'exécution (en supposant que vous désactivez la prise en charge des threads, etc.), vous devriez probablement utiliser autre chose (un modèle de compartiment, par exemple) pour réduire vos allocations dynamiques.


1
Excellent résumé. Voulez-vous dire réellement shared_ptr par opposition à votre mention de smart_ptr?
jmp97

Oui, je voulais dire shared_ptr. Je vais arranger ça.
Tetrad

10

Utilisez le bon outil pour le travail.

Si votre programme peut générer des exceptions, assurez-vous que votre code est sensible aux exceptions. L’utilisation de pointeurs intelligents RAII et l’évitement de la construction en 2 phases sont de bons points de départ.

Si vous avez des références cycliques sans sémantique de propriété claire, vous pouvez envisager d'utiliser une bibliothèque de récupération de place ou de refactoriser votre conception.

De bonnes bibliothèques vous permettront de coder le concept plutôt que le type, de sorte que le type de pointeur que vous utilisez ne devrait pas avoir d'importance au-delà des problèmes de gestion des ressources.

Si vous travaillez dans un environnement multithread, assurez-vous de bien comprendre si votre objet est potentiellement partagé entre plusieurs threads. L'une des principales raisons d'envisager l'utilisation de boost :: shared_ptr ou de std :: tr1 :: shared_ptr est qu'elle utilise un nombre de références thread-safe.

Si vous vous inquiétez de la répartition séparée des comptes de référence, il existe plusieurs façons de contourner ce problème. En utilisant la bibliothèque boost :: shared_ptr, vous pouvez mettre en commun les compteurs de références ou utiliser boost :: make_shared (ma préférence) pour allouer l'objet et le nombre de références en une seule allocation, ce qui atténue la plupart des problèmes de mémoire cache. Vous pouvez éviter les conséquences néfastes de la mise à jour du nombre de références dans du code essentiel à la performance en conservant une référence à l'objet au plus haut niveau et en transmettant des références directes à l'objet.

Si vous avez besoin de propriété partagée mais que vous ne voulez pas payer le coût du comptage des références ou de la récupération de place, envisagez d'utiliser des objets immuables ou une copie sur écriture.

Gardez à l'esprit que vos plus grandes performances en matière de performances se situeront de loin au niveau de l'architecture, puis de l'algorithme. Bien que ces préoccupations de bas niveau soient très importantes, elles ne devraient être traitées que lorsque vous aurez résolu les problèmes majeurs. Si vous rencontrez des problèmes de performances au niveau des échecs de cache, vous avez toute une série de problèmes dont vous devez également prendre conscience, comme le partage erroné, qui n'ont rien à voir avec les pointeurs.

Si vous utilisez des pointeurs intelligents uniquement pour partager des ressources telles que des textures ou des modèles, utilisez une bibliothèque plus spécialisée telle que Boost.Flyweight.

Une fois que la nouvelle norme aura été adoptée, la sémantique des déplacements, les références rvalue et un transfert parfait faciliteront l'utilisation des objets et des conteneurs coûteux. Jusque-là, ne stockez pas de pointeurs avec une sémantique de copie destructive, telle que auto_ptr ou unique_ptr, dans un conteneur (concept standard). Pensez à utiliser la bibliothèque Boost.Pointer Container ou à stocker des pointeurs intelligents de propriété partagée dans Containers. Dans le code de performances critiques, vous pouvez envisager d'éviter ces deux éléments en faveur de conteneurs intrusifs tels que ceux de Boost.Intrusive.

La plateforme cible ne devrait pas trop influencer votre décision. Les appareils intégrés, les téléphones intelligents, les téléphones stupides, les ordinateurs et les consoles peuvent tous exécuter le code correctement. Les exigences du projet, telles que des budgets mémoire stricts ou l'absence d'allocation dynamique jamais / après le chargement, constituent des préoccupations plus valables et devraient influer sur vos choix.


3
La gestion des exceptions sur les consoles peut être un peu louche - le XDK en particulier est plutôt hostile aux exceptions.
Crashworks

1
La plate-forme cible devrait vraiment influencer votre conception. Le matériel qui transforme vos données peut parfois avoir une grande influence sur votre code source. L'architecture PS3 est un exemple concret dans lequel vous devez réellement intégrer le matériel dans la conception de votre gestion des ressources et de la mémoire, ainsi que dans votre moteur de rendu.
Simon

Je ne suis que légèrement en désaccord, particulièrement en ce qui concerne la GC. La plupart du temps, les références cycliques ne posent pas de problème pour les schémas comptés de références. Généralement, ces problèmes de propriété cycliques surviennent parce que les gens ne pensaient pas correctement à la propriété des objets. Ce n’est pas parce qu’un objet doit pointer sur quelque chose qu’il doit posséder ce pointeur. L'exemple fréquemment cité est celui des pointeurs arrières dans les arbres, mais le parent du pointeur dans un arbre peut être en toute sécurité un pointeur brut sans sacrifier la sécurité.
Tim Seguine

4

Si vous utilisez C ++ 0x, utilisez std::unique_ptr<T>.

Il n’a pas de surcoût de performance, contrairement à celui std::shared_ptr<T>qui a un surcoût de comptage de références. Unique_ptr possède son pointeur et vous pouvez en transférer la propriété avec la sémantique de déplacement de C ++ 0x . Vous ne pouvez pas les copier - seulement les déplacer.

Il peut également être utilisé dans des conteneurs std::vector<std::unique_ptr<T>>compatibles , par exemple , avec des performances binaires identiques std::vector<T*>, mais ne perdra pas de la mémoire si vous effacez des éléments ou effacez le vecteur. Cela a également une meilleure compatibilité avec les algorithmes STL que ptr_vector.

OMI pour de nombreuses raisons, il s'agit d'un conteneur idéal: accès aléatoire, sécurité des exceptions, évite les fuites de mémoire, temps système réduit pour la réaffectation des vecteurs (il suffit de déplacer les pointeurs dans les coulisses). Très utile à plusieurs fins.


3

C'est une bonne pratique de documenter quelles classes possèdent quels pointeurs. De préférence, vous utilisez uniquement des objets normaux, et aucun pointeur chaque fois que vous le pouvez.

Cependant, lorsque vous devez garder une trace des ressources, la seule option est de passer des pointeurs. Il y a quelques cas:

  • Vous obtenez le pointeur ailleurs, mais ne le gérez pas: utilisez simplement un pointeur normal et documentez-le de manière à ce qu'aucun codeur ne tente de le supprimer.
  • Vous obtenez le pointeur ailleurs et vous en tenez compte: utilisez un scoped_ptr.
  • Vous obtenez le pointeur ailleurs et vous en tenez compte, mais il a besoin d'une méthode spéciale pour le supprimer: utilisez shared_ptr avec une méthode de suppression personnalisée.
  • Vous avez besoin du pointeur dans un conteneur STL: il sera copié de sorte que vous ayez besoin de boost :: shared_ptr.
  • De nombreuses classes partagent le pointeur et il est difficile de savoir qui le supprimera: shared_ptr (le cas ci-dessus est en fait un cas spécial de ce point).
  • Vous créez le pointeur vous-même et vous n’en avez besoin que si vous ne pouvez pas utiliser un objet normal: scoped_ptr.
  • Vous créez le pointeur et vous le partagez avec d'autres classes: shared_ptr.
  • Vous créez le pointeur et vous le transmettez: utilisez un pointeur normal et documentez votre interface afin que le nouveau propriétaire sache qu'il doit gérer la ressource lui-même!

Je pense que cela couvre à peu près la façon dont je gère mes ressources en ce moment. Le coût en mémoire d'un pointeur tel que shared_ptr est généralement le double de celui d'un pointeur normal. Je ne pense pas que ces frais généraux soient trop importants, mais si vous manquez de ressources, vous devriez envisager de concevoir votre jeu de manière à réduire le nombre de pointeurs intelligents. Dans d’autres cas, je conçois simplement de bons principes comme les puces ci-dessus et le profileur me dira où j’aurai besoin de plus de vitesse.


1

En ce qui concerne les indicateurs spécifiques, je pense qu’ils devraient être évités tant que leur mise en œuvre ne correspond pas exactement à vos besoins. Leur coût est plus élevé que ce à quoi on pouvait s’attendre au départ. Ils fournissent une interface qui vous permet de passer outre les éléments essentiels de votre mémoire et de votre gestion des ressources.

En ce qui concerne le développement de logiciels, je pense qu’il est important de penser à vos données. Il est très important que vos données soient représentées en mémoire. La raison en est que la vitesse du processeur a augmenté beaucoup plus rapidement que le temps d'accès à la mémoire. Cela fait souvent de la mémoire cache le principal goulot d’étranglement de la plupart des jeux informatiques modernes. En alignant vos données linéairement en mémoire en fonction de l'ordre d'accès, le cache est beaucoup plus convivial. Ce type de solutions conduit souvent à des conceptions plus propres, à un code plus simple et à un code plus facile à déboguer. Les pointeurs intelligents mènent facilement à de fréquentes affectations dynamiques de mémoire dynamique, ce qui entraîne leur dispersion dans toute la mémoire.

Ce n’est pas une optimisation prématurée, c’est une décision saine qui peut et doit être prise le plus tôt possible. C'est une question de compréhension architecturale du matériel sur lequel votre logiciel sera exécuté et c'est important.

Edit: Il y a quelques points à considérer en ce qui concerne les performances des pointeurs partagés:

  • Le compteur de référence est attribué au tas.
  • Si vous utilisez la sécurité des threads activée, le comptage des références est effectué via des opérations imbriquées.
  • Le fait de passer le pointeur par valeur modifie le nombre de références, ce qui signifie que les opérations verrouillées utilisent probablement un accès aléatoire en mémoire (verrous + vraisemblablement des erreurs de cache).

2
Vous m'avez perdu à "évité à tout prix". Ensuite, vous décrivez un type d’optimisation qui concerne rarement les jeux réels. La plupart des développements de jeux sont caractérisés par des problèmes de développement (retards, bugs, jouabilité, etc.) et non par un manque de performances du cache du processeur. Je suis donc totalement en désaccord avec l’idée que ce conseil n’est pas une optimisation prématurée.
kevin42

2
Je suis d'accord avec la conception initiale de la mise en page des données. Il est important de tirer parti des performances d'une console / d'un appareil mobile moderne et c'est quelque chose qui ne doit jamais être négligé.
Olly

1
C'est un problème que j'ai vu dans l'un des studios AAA où j'ai travaillé. Vous pouvez également écouter Mike Acton, architecte en chef chez Insomniac Games. Je ne dis pas que boost est une mauvaise bibliothèque, elle ne convient pas uniquement aux jeux très performants.
Simon

1
@ kevin42: La cohérence du cache est probablement la source principale des optimisations de bas niveau dans le développement de jeux. @Simon: La plupart des implémentations shared_ptr évitent les verrous sur toutes les plates-formes prenant en charge les fonctions de comparaison (swap), qui incluent les PC Linux et Windows, et, je crois, la Xbox.

1
@ Joe Wreschnig: C'est vrai, le cache-miss est toujours le plus probable, ce qui provoque l'initialisation d'un pointeur partagé (copie, création à partir d'un pointeur faible, etc.). Un cache-miss L2 sur un PC moderne équivaut à 200 cycles et sur un PPC (xbox360 / ps3), il est plus élevé. Avec un jeu intense, vous pouvez avoir jusqu'à 1 000 objets de jeu, étant donné que chaque objet de jeu peut disposer de quelques ressources. Nous examinons les problèmes pour lesquels la mise à l'échelle est une préoccupation majeure. Cela posera probablement des problèmes à la fin d'un cycle de développement (lorsque vous atteindrez un nombre élevé d'objets de jeu).
Simon

0

J'ai tendance à utiliser des pointeurs intelligents partout. Je ne suis pas sûr que ce soit une bonne idée, mais je suis paresseux et je ne vois aucun inconvénient réel [sauf si je voulais faire de l'arithmétique de pointeur de style C]. J'utilise boost :: shared_ptr parce que je sais que je peux le copier: si deux entités partagent une image, si l'une meurt, l'autre ne doit pas perdre l'image.

L'inconvénient est que si un objet supprime quelque chose qu'il désigne et qu'il possède, mais que quelque chose le pointe également, il n'est pas supprimé.


1
J'utilise aussi share_ptr un peu partout - mais aujourd'hui, j'essaie de déterminer si j'ai besoin de la propriété partagée pour certaines données. Dans le cas contraire, il pourrait être raisonnable de transformer ces données en un membre non pointeur de la structure de données parent. Je trouve que la propriété claire simplifie les conceptions.
jmp97

0

Les avantages de la gestion de la mémoire et de la documentation fournis par de bons pointeurs intelligents signifient que je les utilise régulièrement. Cependant, lorsque le profileur se lance et me dit qu'une utilisation particulière me coûte, je reviens à une gestion plus néolithique du pointeur.


0

Je suis vieux, oldskool et un compteur de cycle. Dans mon propre travail, j'utilise des pointeurs bruts et aucune allocation dynamique au moment de l'exécution (à l'exception des pools eux-mêmes). Tout est mis en commun et la propriété est très stricte et jamais transférable. Si nécessaire, j’écris un allocateur personnalisé de petits blocs. Je m'assure qu'il y a un état pendant le jeu pour que chaque groupe se vide. Quand les choses deviennent poilues, je range les objets dans des poignées pour pouvoir les déplacer, mais je préfère ne pas. Les conteneurs sont des os personnalisés et extrêmement nus. Je ne réutilise pas non plus le code.
Bien que je ne discute jamais de la vertu de tous les pointeurs, conteneurs, itérateurs et autres astuces intelligentes, je suis connu pour sa capacité à coder extrêmement rapidement (et raisonnablement fiable - bien qu'il ne soit pas conseillé aux autres de passer dans mon code pour des raisons assez évidentes, comme des crises cardiaques et des cauchemars perpétuels).

Au travail, bien sûr, tout est différent, à moins que je ne crée le prototypage, ce que j’ai heureusement beaucoup à faire.


0

Presque aucune, bien que cela soit certes une réponse étrange, et probablement loin d’être appropriée pour tout le monde.

Mais j’ai trouvé beaucoup plus utile, dans mon cas personnel, de stocker toutes les occurrences d’un type particulier dans une séquence centrale à accès aléatoire (thread-safe) et de travailler plutôt avec des index 32 bits (adresses relatives, par exemple). , plutôt que des pointeurs absolus.

Pour un début:

  1. Il divise par deux les besoins en mémoire du pointeur analogique sur les plates-formes 64 bits. Jusqu'à présent, je n'ai jamais eu besoin de plus de 4,29 milliards d'instances d'un type de données particulier.
  2. Il s'assure que toutes les occurrences d'un type particulier T, ne seront jamais trop dispersées en mémoire. Cela tend à réduire les erreurs de cache pour tous les types de modèles d'accès, même en traversant des structures liées telles que des arbres si les nœuds sont liés ensemble en utilisant des index plutôt que des pointeurs.
  3. Les données parallèles deviennent faciles à associer en utilisant des tableaux parallèles peu coûteux (ou des tableaux fragmentés) au lieu d'arbres ou de tables de hachage.
  4. Les intersections d'ensembles peuvent être trouvées en temps linéaire ou mieux en utilisant, par exemple, un jeu de bits parallèle.
  5. Nous pouvons trier radicalement les index et obtenir un modèle d'accès séquentiel très convivial pour le cache.
  6. Nous pouvons suivre le nombre d'allocations d'un type de données particulier.
  7. Minimise le nombre d'endroits où des problèmes tels que la sécurité des exceptions doivent être traités, si vous vous souciez de ce genre de choses.

Cela dit, la commodité est un inconvénient, tout comme la sécurité. Nous ne pouvons pas accéder à une instance de Tsans avoir accès à la fois au conteneur et à l' index. Et un simple vieux int32_tne nous dit rien sur le type de données auquel il fait référence, il n’ya donc pas de type de sécurité. Nous pourrions accidentellement essayer d'accéder à l' Baraide d'un index Foo. Pour atténuer le deuxième problème, je fais souvent ce genre de chose:

struct FooIndex
{
    int32_t index;
};

Ce qui semble un peu ridicule, mais cela me redonne le type de sécurité afin que les personnes ne puissent pas accéder accidentellement à Barun index Foosans une erreur du compilateur. Pour le côté pratique, j’accepte seulement le léger inconvénient.

Une autre chose qui pourrait être un inconvénient majeur pour les gens est que je ne peux pas utiliser un polymorphisme basé sur l'héritage de type POO, car cela nécessiterait un pointeur de base pouvant pointer vers toutes sortes de sous-types différents avec des exigences de taille et d'alignement différentes. Mais je n'utilise pas beaucoup l'héritage ces jours-ci - je préfère l'approche ECS.

En ce shared_ptrqui me concerne , j'essaie de ne pas trop l'utiliser. La plupart du temps, j'estime qu'il n'est pas logique de partager la propriété, ce qui peut entraîner des fuites logiques. Souvent, au moins à un niveau élevé, une chose a tendance à appartenir à une chose. Là où j’ai souvent trouvé tentant de l’utiliser, shared_ptrc’était de prolonger la durée de vie d’un objet dans des endroits où la propriété n’était pas vraiment gérée, comme une fonction locale dans un thread pour s’assurer que cet objet n’est pas détruit avant la fin du thread. En l'utilisant.

Pour résoudre ce problème, au lieu d'utiliser shared_ptrGC ou quelque chose du genre, je privilégie souvent les tâches éphémères exécutées à partir d'un pool de threads, et le fais ainsi si ce thread demande à détruire un objet, que la destruction réelle soit reportée à un coffre-fort. moment où le système peut s’assurer qu’aucun thread n’a besoin d’accéder audit type d’objet.

Parfois, je finis toujours par utiliser le comptage par reflexion mais le traite comme une stratégie de dernier recours. Et il y a quelques cas où il est vraiment logique de partager la propriété, comme la mise en place d'une structure de données persistante, et j'estime qu'il est parfaitement logique de rechercher shared_ptrtout de suite.

Donc de toute façon, j'utilise principalement des index, et j'utilise avec parcimonie les pointeurs brut et intelligent. J'aime les index et les types de portes qu'ils ouvrent lorsque vous savez que vos objets sont stockés de manière contiguë et non dispersés dans la mémoire.

En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.