Pourquoi devrait remplacerait-on l'opérateur par défaut new
et delete
par un opérateur personnalisé new
et delete
?
C'est dans la continuité de Surcharger nouveau et supprimer dans la FAQ C++ extrêmement éclairante:
Surcharge de l'opérateur.
Une entrée de suivi pour ce FAQ est:
Comment écrire des opérateurs personnalisés conformes à la norme ISO C++ new
et delete
?
Remarque: La réponse est basée sur les leçons du C++ plus efficace de Scott Meyers.
(Remarque: il s'agit d'une entrée pour FAQ C++ de Stack Overflow . Si vous souhaitez critiquer l'idée de fournir un FAQ dans ce formulaire, puis la publication sur la méta qui a commencé tout cela serait l'endroit pour le faire. Les réponses à cette question sont surveillées dans le C++ chatroom , où le FAQ l'idée a commencé en premier lieu, donc votre réponse est très susceptible d'être lue par ceux qui ont eu l'idée.)
On peut essayer de remplacer les opérateurs new
et delete
pour plusieurs raisons, à savoir:
Il existe un certain nombre de façons dont une utilisation incorrecte de new
et delete
peut conduire aux bêtes redoutées de Comportement indéfini & Fuites de mémoire . Des exemples respectifs de chacun sont:
Utilisation de plusieurs delete
sur new
ed mémoire et ne pas appeler delete
sur la mémoire allouée à l'aide de new
.
Un opérateur surchargé new
peut conserver une liste des adresses allouées et l'opérateur surchargé delete
peut supprimer des adresses de la liste, puis il est facile de détecter de telles erreurs d'utilisation.
De même, diverses erreurs de programmation peuvent conduire à dépassements de données (écriture au-delà de la fin d'un bloc alloué) et sous-exécutions (écriture avant le début d'un bloc alloué).
Un opérateur surchargé new
peut surallouer des blocs et mettre des modèles d'octets connus ("signatures") avant et après la mémoire mise à la disposition des clients. L'opérateur surchargé supprime peut vérifier si les signatures sont toujours intactes. Ainsi, en vérifiant si ces signatures ne sont pas intactes, il est possible de déterminer qu'un dépassement ou un sous-exécution s'est produit pendant la durée de vie du bloc alloué, et la suppression de l'opérateur peut enregistrer ce fait, ainsi que la valeur du pointeur incriminé, aidant ainsi en fournissant une bonne information diagnostique.
Les opérateurs new
et delete
fonctionnent assez bien pour tout le monde, mais de manière optimale pour personne. Ce comportement provient du fait qu'ils sont conçus pour un usage général uniquement. Ils doivent s'adapter à des modèles d'allocation allant de l'allocation dynamique de quelques blocs qui existent pendant la durée du programme à l'allocation et à la désallocation constantes d'un grand nombre d'objets de courte durée. Finalement, l'opérateur new
et l'opérateur delete
fournis avec les compilateurs adoptent une stratégie intermédiaire.
Si vous avez une bonne compréhension des modèles d'utilisation dynamique de la mémoire de votre programme, vous pouvez souvent constater que les versions personnalisées de l'opérateur nouveau et de la suppression de l'opérateur sont plus performantes (plus rapides en performances ou nécessitent moins de mémoire jusqu'à 50%) que les versions par défaut. Bien sûr, à moins que vous ne soyez sûr de ce que vous faites, ce n'est pas une bonne idée de le faire (n'essayez même pas cela si vous ne comprenez pas les subtilités impliquées).
Avant de penser à remplacer new
et delete
pour améliorer l'efficacité comme mentionné au n ° 2, vous devez collecter des informations sur la façon dont votre application/programme utilise l'allocation dynamique. Vous pouvez souhaiter collecter des informations sur:
Répartition des blocs d'allocation,
Répartition des durées de vie,
Ordre des allocations (FIFO ou LIFO ou aléatoire),
Comprendre les modèles d'utilisation change sur une période de temps, la quantité maximale de mémoire dynamique utilisée, etc.
De plus, vous devrez parfois collecter des informations d'utilisation telles que:
Compter le nombre d'objets dynamiquement d'une classe,
Restreindre le nombre d'objets en cours de création à l'aide de l'allocation dynamique, etc.
Toutes ces informations peuvent être collectées en remplaçant les new
et delete
personnalisés et en ajoutant le mécanisme de collecte de diagnostic dans les new
et delete
surchargés.
new
:De nombreuses architectures informatiques nécessitent que des données de types particuliers soient placées en mémoire à des types d'adresses particuliers. Par exemple, une architecture peut nécessiter que des pointeurs se produisent à des adresses qui sont un multiple de quatre (c'est-à-dire être alignés sur quatre octets) ou que des doubles doivent se produire à des adresses qui sont un multiple de huit (c'est-à-dire être alignés sur huit octets). Le non-respect de ces contraintes peut entraîner des exceptions matérielles au moment de l'exécution. D'autres architectures sont plus tolérantes et peuvent lui permettre de fonctionner tout en réduisant les performances. L'opérateur new
fourni avec certains compilateurs ne garantit pas l'alignement sur huit octets pour les allocations dynamiques de doubles. Dans de tels cas, le remplacement de l'opérateur par défaut new
par un qui garantit l'alignement sur huit octets pourrait entraîner de grandes augmentations des performances du programme et peut être une bonne raison de remplacer new
et delete
les opérateurs.
Si vous savez que des structures de données particulières sont généralement utilisées ensemble et que vous souhaitez minimiser la fréquence des erreurs de page lorsque vous travaillez sur les données, il peut être judicieux de créer un segment de mémoire distinct pour les structures de données afin qu'elles soient regroupées en aussi peu pages que possible. des versions de placement personnalisées de new
et delete
peuvent permettre de réaliser un tel regroupement.
Parfois, vous voulez que les opérateurs nouveaux et supprimés fassent quelque chose que les versions fournies par le compilateur n'offrent pas.
Par exemple: Vous pouvez écrire un opérateur personnalisé delete
qui écrase la mémoire désallouée avec des zéros afin d'augmenter la sécurité des données d'application.
Tout d'abord, il existe en réalité un certain nombre d'opérateurs new
et delete
différents (un nombre arbitraire, en fait).
Tout d'abord, il y a ::operator new
, ::operator new[]
, ::operator delete
et ::operator delete[]
. Deuxièmement, pour toute classe X
, il y a X::operator new
, X::operator new[]
, X::operator delete
et X::operator delete[]
.
Entre ceux-ci, il est beaucoup plus courant de surcharger les opérateurs spécifiques à une classe que les opérateurs globaux - il est assez courant que l'utilisation de la mémoire d'une classe particulière suive un modèle suffisamment spécifique pour que vous puissiez écrire des opérateurs qui améliorent considérablement les valeurs par défaut. Il est généralement beaucoup plus difficile de prévoir l'utilisation de la mémoire de manière aussi précise ou précise à l'échelle mondiale.
Il convient également de mentionner que, bien que operator new
et operator new[]
sont séparés les uns des autres (de même pour tout X::operator new
et X::operator new[]
), il n'y a pas de différence entre les exigences des deux. L'un sera invoqué pour allouer un seul objet et l'autre pour allouer un tableau d'objets, mais chacun ne reçoit que la quantité de mémoire nécessaire et doit renvoyer l'adresse d'un bloc de mémoire (au moins) aussi grand.
En parlant d'exigences, il vaut probablement la peine de revoir les autres exigences1: les opérateurs globaux doivent être véritablement globaux - vous ne pouvez pas en placer un dans un espace de noms ou en créer un statique dans une unité de traduction particulière. En d'autres termes, il n'y a que deux niveaux auxquels des surcharges peuvent avoir lieu: une surcharge spécifique à une classe ou une surcharge globale. Les points intermédiaires tels que "toutes les classes dans l'espace de noms X" ou "toutes les allocations dans l'unité de traduction Y" ne sont pas autorisés. Les opérateurs spécifiques à la classe doivent être static
- mais vous n'êtes pas obligé de les déclarer statiques - ils le feront être statique, que vous les déclariez explicitement static
ou non. Officiellement, les opérateurs globaux renvoient beaucoup de mémoire alignée afin qu'elle puisse être utilisée pour un objet de tout type. Officieusement, il y a une petite marge de manœuvre sur un point: si vous obtenez une demande pour un petit bloc (par exemple, 2 octets), vous n'avez vraiment besoin que de fournir une mémoire alignée pour un objet jusqu'à cette taille, car vous essayez de stocker quelque chose de plus grand là-bas entraînerait de toute façon un comportement indéfini.
Après avoir couvert ces préliminaires, revenons à la question d'origine sur pourquoi vous souhaitez surcharger ces opérateurs. Tout d'abord, je dois souligner que les raisons de la surcharge des opérateurs globaux ont tendance à être sensiblement différentes des raisons de la surcharge des opérateurs spécifiques à la classe.
Comme c'est plus courant, je vais d'abord parler des opérateurs spécifiques à la classe. La principale raison de la gestion de la mémoire spécifique à une classe est la performance. Cela se présente généralement sous l'une ou l'autre (ou les deux) de deux formes: soit en améliorant la vitesse, soit en réduisant la fragmentation. La vitesse est améliorée par le fait que le gestionnaire de mémoire traitera uniquement avec des blocs d'une taille particulière, afin qu'il puisse retourner l'adresse de n'importe quel bloc libre plutôt que passer du temps à vérifier si un bloc est assez grand, diviser un bloc en deux s'il est trop grand, etc. La fragmentation est réduite (principalement) de la même manière - par exemple, pré-allouer un bloc assez grand pour N objets donne exactement l'espace nécessaire pour N objets; allouer la valeur de mémoire d'un objet allouera exactement l'espace pour un objet, et pas un seul octet de plus.
Il existe une plus grande variété de raisons pour surcharger les opérateurs de gestion de la mémoire globale. Beaucoup d'entre eux sont orientés vers le débogage ou l'instrumentation, tels que le suivi de la mémoire totale nécessaire à une application (par exemple, en préparation du portage vers un système embarqué), ou le débogage de problèmes de mémoire en montrant des décalages entre l'allocation et la libération de mémoire. Une autre stratégie courante consiste à allouer de la mémoire supplémentaire avant et après les limites de chaque bloc demandé et à écrire des modèles uniques dans ces zones. À la fin de l'exécution (et éventuellement à d'autres moments également), ces zones sont examinées pour voir si le code a écrit en dehors des limites allouées. Encore une autre consiste à tenter d'améliorer la facilité d'utilisation en automatisant au moins certains aspects de l'allocation ou de la suppression de mémoire, comme avec un garbage collector automatisé .
Un allocateur global non par défaut peut être également utilisé pour améliorer les performances. Un cas typique serait de remplacer un allocateur par défaut qui était juste lent en général (par exemple, au moins certaines versions de MS VC++ autour de 4.x appelleraient les fonctions système HeapAlloc
et HeapFree
pour chaque opération d'allocation/suppression). Une autre possibilité que j'ai vue dans la pratique s'est produite sur les processeurs Intel lors de l'utilisation des opérations SSE. Celles-ci fonctionnent sur des données de 128 bits. Bien que les opérations fonctionnent indépendamment de l'alignement, la vitesse est améliorée lorsque le les données sont alignées sur des limites de 128 bits. Certains compilateurs (par exemple, MS VC++ à nouveau2) n'ont pas nécessairement imposé l'alignement à cette frontière plus large, donc même si le code utilisant l'allocateur par défaut fonctionnerait, le remplacement de l'allocation pourrait fournir une amélioration substantielle de la vitesse pour ces opérations.
De nombreuses architectures informatiques nécessitent que des données de types particuliers soient placées en mémoire à des types d'adresses particuliers. Par exemple, une architecture peut nécessiter que des pointeurs se produisent à des adresses qui sont un multiple de quatre (c'est-à-dire être alignés sur quatre octets) ou que des doubles doivent se produire à des adresses qui sont un multiple de huit (c'est-à-dire être alignés sur huit octets). Le non-respect de ces contraintes peut entraîner des exceptions matérielles au moment de l'exécution. D'autres architectures sont plus tolérantes et peuvent lui permettre de fonctionner tout en réduisant les performances.
Pour clarifier: si une architecture nécessite par exemple que les données double
soient alignées sur huit octets, alors il n'y a rien à optimiser. Tout type d'allocation dynamique de la taille appropriée (par exemple malloc(size)
, operator new(size)
, operator new[](size)
, new char[size]
Où size >= sizeof(double)
) est garanti d'être correctement aligné. Si une implémentation n'offre pas cette garantie, elle n'est pas conforme. Changer operator new
Pour faire "la bonne chose" dans ce cas serait une tentative de "réparer" l'implémentation, pas une optimisation.
D'un autre côté, certaines architectures autorisent différents (ou tous) types d'alignement pour un ou plusieurs types de données, mais offrent des garanties de performances différentes selon l'alignement pour ces mêmes types. Une implémentation peut ensuite renvoyer une mémoire (là encore, en supposant une demande de taille appropriée) qui est alignée de manière sous-optimale et toujours conforme. C'est de cela qu'il s'agit.
Lié aux statistiques d'utilisation: budgétisation par sous-système. Par exemple, dans un jeu sur console, vous souhaiterez peut-être réserver une partie de la mémoire pour la géométrie du modèle 3D, une partie pour les textures, une partie pour les sons, une partie pour les scripts de jeu, etc. Des allocateurs personnalisés peuvent étiqueter chaque allocation par sous-système et émettre un avertissement lorsque les budgets individuels sont dépassés.
L'opérateur nouveau fourni avec certains compilateurs ne garantit pas l'alignement sur huit octets pour les allocations dynamiques de doubles.
Citation, s'il vous plaît. Normalement, le nouvel opérateur par défaut n'est que légèrement plus complexe qu'un wrapper malloc, qui, par la norme, renvoie une mémoire convenablement alignée pour TOUT type de données que le prend en charge l'architecture cible.
Non pas que je dis qu'il n'y a pas de bonnes raisons de surcharger de nouveaux et supprimer pour ses propres classes ... et vous en avez abordé plusieurs légitimes ici, mais ce qui précède n'en fait pas partie.
Il semble utile de répéter la liste de ma réponse de "Toute raison de surcharger le nouveau global et de le supprimer?" ici - voir cette réponse (ou bien autres réponses à cette question ) pour une discussion plus détaillée, des références et d'autres raisons. Ces raisons s'appliquent généralement aux surcharges d'opérateur local ainsi qu'aux surcharges par défaut/globales, ainsi qu'aux surcharges ou crochets C malloc
/calloc
/realloc
/free
.
Nous surchargeons les nouveaux opérateurs mondiaux de suppression et de suppression sur lesquels je travaille pour de nombreuses raisons:
- regroupement toutes les petites allocations - diminue les frais généraux, diminue la fragmentation, peut augmenter les performances des applications à petite allocation
- encadrer les allocations avec une durée de vie connue - ignorer toutes les libérations jusqu'à la fin de cette période, puis les libérer toutes ensemble (certes, nous le faisons plus avec des surcharges d'opérateur local que global)
- ajustement d'alignement - aux limites de la ligne de cache, etc.
- alloc fill - aide à exposer l'utilisation des variables non initialisées
- remplissage libre - aide à exposer l'utilisation de la mémoire précédemment supprimée
- retardé libre - augmentant l'efficacité du remplissage libre, augmentant parfois les performances
- sentinelles ou fenceposts - aidant à exposer les dépassements, les sous-exécutions et le pointeur sauvage occasionnel
- rediriger les allocations - pour tenir compte de NUMA, des zones de mémoire spéciales, ou même pour garder des systèmes séparés séparés en mémoire (par exemple, les langages de script intégrés ou les DSL)
- garbage collection ou cleanup - encore utile pour ces langages de script intégrés
- vérification du tas - vous pouvez parcourir la structure de données du tas chaque N alloue/libère pour vous assurer que tout semble correct
- comptabilité , y compris suivi des fuites et instantanés/statistiques d'utilisation (piles, âges d'allocation, etc.)
Je l'ai utilisé pour allouer des objets dans une arène de mémoire partagée spécifique. (Ceci est similaire à ce que @Russell Borogove a mentionné.)
Il y a des années, j'ai développé un logiciel pour la GROTTE . C'est un système VR multi-murs. Il utilisait un ordinateur pour piloter chaque projecteur; 6 était le maximum (4 murs, sol et plafond) tandis que 3 était plus commun (2 murs et le sol). Les machines communiquaient sur du matériel spécial à mémoire partagée.
Pour le supporter, j'ai dérivé de mes classes de scènes normales (non-CAVE) pour utiliser un nouveau "nouveau" qui mettait les informations de scène directement dans l'arène de la mémoire partagée. J'ai ensuite transmis ce pointeur aux rendus esclaves sur les différentes machines.