web-dev-qa-db-fra.com

Scalaire `nouveau T` vs tableau` nouveau T [1] `

Nous avons récemment découvert qu'un code utilisait new T[1] systématiquement (correctement mis en correspondance avec delete[]), et je me demande si cela est inoffensif, ou s'il y a des inconvénients dans le code généré (dans l'espace ou le temps/les performances). Bien sûr, cela était caché derrière des couches de fonctions et de macros, mais ce n'est pas la question.

Logiquement, il me semble que les deux sont similaires, mais le sont-ils?

Les compilateurs sont-ils autorisés à transformer ce code (en utilisant un littéral 1, pas une variable, mais via des couches de fonctions, que 1 se transforme en argument variable 2 ou 3 fois avant d'atteindre le code en utilisant ainsi new T[n]) dans un scalaire new T?

D'autres considérations/choses à savoir sur la différence entre ces deux?

39
ddevienne

Non, le compilateur n'est pas autorisé à remplacer new T[1] avec new T. operator new et operator new[] (et les suppressions correspondantes) sont remplaçables ([basic.stc.dynamic]/2). Un remplacement défini par l'utilisateur pourrait détecter lequel est appelé, de sorte que la règle de simulation ne permet pas ce remplacement.

Remarque: si le compilateur pouvait détecter que ces fonctions n'avaient pas été remplacées, il pourrait apporter cette modification. Mais rien dans le code source n'indique que les fonctions fournies par le compilateur sont remplacées. Le remplacement se fait généralement à link temps, simplement en reliant les versions de remplacement (qui masquent la version fournie par la bibliothèque); c'est généralement trop tard pour que le compilateur le sache.

9
Pete Becker

Si T n'a pas de destructeur trivial, alors pour les implémentations de compilateur habituelles, new T[1] a un surcoût par rapport à new T. La version du tableau allouera une zone de mémoire un peu plus grande, pour stocker le nombre d'éléments, donc à delete[], il sait combien de destructeurs doivent être appelés.

Donc, il a des frais généraux:

  • une zone mémoire un peu plus grande doit être allouée
  • delete[] sera un peu plus lent, car il a besoin d'une boucle pour appeler les destructeurs, au lieu d'appeler un simple destructeur (ici, la différence est la surcharge de la boucle)

Découvrez ce programme:

#include <cstddef>
#include <iostream>

enum Tag { tag };

char buffer[128];

void *operator new(size_t size, Tag) {
    std::cout<<"single: "<<size<<"\n";
    return buffer;
}
void *operator new[](size_t size, Tag) {
    std::cout<<"array: "<<size<<"\n";
    return buffer;
}

struct A {
    int value;
};

struct B {
    int value;

    ~B() {}
};

int main() {
    new(tag) A;
    new(tag) A[1];
    new(tag) B;
    new(tag) B[1];
}

Sur ma machine, il imprime:

single: 4
array: 4
single: 4
array: 12

Comme B a un destructeur non trivial, le compilateur alloue 8 octets supplémentaires pour stocker le nombre d'éléments (car il s'agit d'une compilation 64 bits, il a besoin de 8 octets supplémentaires pour ce faire) pour la version du tableau. Comme A fait un destructeur trivial, la version tableau de A n'a pas besoin de cet espace supplémentaire.


Remarque: comme le fait remarquer Deduplicator, l'utilisation de la version de la baie présente un léger avantage en termes de performances, si le destructeur est virtuel: à delete[], le compilateur n'a pas à appeler virtuellement le destructeur, car il sait que le type est T. Voici un cas simple pour le démontrer:

struct Foo {
    virtual ~Foo() { }
};

void fn_single(Foo *f) {
    delete f;
}

void fn_array(Foo *f) {
    delete[] f;
}

Clang optimise ce cas, mais GCC ne le fait pas: godbolt .

Pour fn_single, clang émet une vérification nullptr, puis appelle destructor+operator delete fonctionne virtuellement. Il doit procéder de cette manière, car f peut pointer vers un type dérivé, qui a un destructeur non vide.

Pour fn_array, clang émet une vérification nullptr, puis appelle directement à operator delete, sans appeler le destructeur, car il est vide. Ici, le compilateur sait que f pointe réellement vers un tableau d'objets Foo, il ne peut pas être un type dérivé, il peut donc omettre les appels aux destructeurs vides.

26
geza

La règle est simple: delete[] doit correspondre new[] et delete doivent correspondre à new: le comportement lors de l'utilisation de toute autre combinaison n'est pas défini.

Le compilateur permet en effet de tourner new T[1] en un simple new T (et gérer le delete[] de manière appropriée), en raison de la règle comme si. Je n'ai pas rencontré de compilateur qui le fasse cependant.

Si vous avez des réserves sur les performances, profilez-les.

5
Bathsheba