Y a-t-il des inconvénients à passer des structures par valeur en C, plutôt que de passer un pointeur?


157

Y a-t-il des inconvénients à passer des structures par valeur en C, plutôt que de passer un pointeur?

Si la structure est grande, il y a évidemment l'aspect performant de la copie de beaucoup de données, mais pour une structure plus petite, cela devrait être fondamentalement la même chose que de passer plusieurs valeurs à une fonction.

C'est peut-être encore plus intéressant lorsqu'il est utilisé comme valeur de retour. C n'a qu'une seule valeur de retour des fonctions, mais vous en avez souvent besoin. Une solution simple consiste donc à les mettre dans une structure et à la renvoyer.

Y a-t-il des raisons pour ou contre cela?

Puisque ce dont je parle ici n'est peut-être pas évident pour tout le monde, je vais donner un exemple simple.

Si vous programmez en C, vous commencerez tôt ou tard à écrire des fonctions qui ressemblent à ceci:

void examine_data(const char *ptr, size_t len)
{
    ...
}

char *p = ...;
size_t l = ...;
examine_data(p, l);

Ce n'est pas un problème. Le seul problème est que vous devez convenir avec votre collègue de l'ordre dans lequel les paramètres doivent être afin d'utiliser la même convention dans toutes les fonctions.

Mais que se passe-t-il lorsque vous souhaitez renvoyer le même type d'informations? Vous obtenez généralement quelque chose comme ceci:

char *get_data(size_t *len);
{
    ...
    *len = ...datalen...;
    return ...data...;
}
size_t len;
char *p = get_data(&len);

Cela fonctionne bien, mais est beaucoup plus problématique. Une valeur de retour est une valeur de retour, sauf que dans cette implémentation, ce n'est pas le cas. Il n'y a aucun moyen de dire à partir de ce qui précède que la fonction get_data n'est pas autorisée à regarder vers quoi pointe len. Et rien ne permet au compilateur de vérifier qu'une valeur est effectivement renvoyée via ce pointeur. Donc le mois prochain, quand quelqu'un d'autre modifie le code sans le comprendre correctement (parce qu'il n'a pas lu la documentation?), Il est cassé sans que personne ne s'en aperçoive, ou il commence à planter au hasard.

Donc, la solution que je propose est la structure simple

struct blob { char *ptr; size_t len; }

Les exemples peuvent être réécrits comme ceci:

void examine_data(const struct blob data)
{
    ... use data.tr and data.len ...
}

struct blob = { .ptr = ..., .len = ... };
examine_data(blob);

struct blob get_data(void);
{
    ...
    return (struct blob){ .ptr = ...data..., .len = ...len... };
}
struct blob data = get_data();

Pour une raison quelconque, je pense que la plupart des gens feraient instinctivement examine_data prendre un pointeur vers un blob struct, mais je ne vois pas pourquoi. Il obtient toujours un pointeur et un entier, il est juste beaucoup plus clair qu'ils vont ensemble. Et dans le cas de get_data, il est impossible de gâcher la manière dont j'ai décrit précédemment, car il n'y a pas de valeur d'entrée pour la longueur, et il doit y avoir une longueur retournée.


Pour ce que ça vaut, void examine data(const struct blob)c'est incorrect.
Chris Lutz du

Merci, l'avez changé pour inclure un nom de variable.
dkagedal le

1
"Il n'y a aucun moyen de dire d'après ce qui précède que la fonction get_data n'est pas autorisée à regarder vers quoi pointe len. Et rien ne permet au compilateur de vérifier qu'une valeur est effectivement renvoyée via ce pointeur." - cela n'a aucun sens pour moi (peut-être parce que votre exemple est un code invalide en raison des deux dernières lignes apparaissant en dehors d'une fonction); s'il vous plaît pouvez-vous élaborer?
Adam Spiers

2
Les deux lignes sous la fonction sont là pour illustrer comment la fonction est appelée. La signature de la fonction ne donne aucun indice sur le fait que l'implémentation ne devrait écrire que sur le pointeur. Et le compilateur n'a aucun moyen de savoir qu'il doit vérifier qu'une valeur est écrite dans le pointeur, de sorte que le mécanisme de valeur de retour ne peut être décrit que dans la documentation.
dkagedal

1
La principale raison pour laquelle les gens ne font pas cela plus souvent en C est historique. Avant C89, vous ne pouviez pas passer ou renvoyer des structures par valeur, donc toutes les interfaces système antérieures à C89 et devraient logiquement le faire (comme gettimeofday) utiliser des pointeurs à la place, et les gens prennent cela comme exemple.
zwol

Réponses:


202

Pour les petites structures (par exemple point, rect), le passage par valeur est parfaitement acceptable. Mais, mis à part la vitesse, il y a une autre raison pour laquelle vous devriez faire attention en passant / renvoyant de grandes structures par valeur: l'espace de pile.

Une grande partie de la programmation C est destinée aux systèmes embarqués, où la mémoire est limitée, et les tailles de pile peuvent être mesurées en Ko ou même en octets ... Si vous passez ou retournez des structures par valeur, des copies de ces structures seront placées sur la pile, provoquant potentiellement la situation d'après laquelle ce site est nommé ...

Si je vois une application qui semble avoir une utilisation excessive de la pile, les structures passées par valeur sont l'une des choses que je recherche en premier.


2
"Si vous passez ou retournez des structures par valeur, des copies de ces structures seront placées sur la pile" J'appellerais braindead toute chaîne d'outils qui le fait. Oui, c'est triste que tant de personnes le fassent, mais ce n'est pas quelque chose que la norme C appelle. Un compilateur sensé optimisera tout.
Réintégrer Monica le

1
@KubaOber C'est pourquoi cela ne se fait pas souvent: stackoverflow.com/questions/552134/…
Roddy

1
Existe-t-il une ligne définitive qui sépare une petite structure d'une grande structure?
Josie Thompson

63

Une raison de ne pas faire cela qui n'a pas été mentionnée est que cela peut causer un problème où la compatibilité binaire est importante.

Selon le compilateur utilisé, les structures peuvent être passées via la pile ou les registres en fonction des options / implémentation du compilateur

Voir: http://gcc.gnu.org/onlinedocs/gcc/Code-Gen-Options.html

-fpcc-struct-return

-freg-struct-return

Si deux compilateurs ne sont pas d'accord, les choses peuvent exploser. Inutile de dire que les principales raisons de ne pas le faire sont illustrées par la consommation de pile et des raisons de performances.


4
C'était le genre de réponse que je cherchais.
dkagedal

2
C'est vrai, mais ces options ne concernent pas le passage par valeur. ils concernent le retour de structures, ce qui est tout à fait différent. Rendre les choses par référence est généralement un moyen infaillible de se tirer une balle dans les deux pieds. int &bar() { int f; int &j(f); return j;};
Roddy

19

Pour vraiment répondre à cette question, il faut creuser profondément dans la terre de rassemblement:

(L'exemple suivant utilise gcc sur x86_64. N'importe qui est invité à ajouter d'autres architectures comme MSVC, ARM, etc.)

Prenons notre exemple de programme:

// foo.c

typedef struct
{
    double x, y;
} point;

void give_two_doubles(double * x, double * y)
{
    *x = 1.0;
    *y = 2.0;
}

point give_point()
{
    point a = {1.0, 2.0};
    return a;
}

int main()
{
    return 0;
}

Compilez-le avec des optimisations complètes

gcc -Wall -O3 foo.c -o foo

Regardez l'assemblage:

objdump -d foo | vim -

Voici ce que nous obtenons:

0000000000400480 <give_two_doubles>:
    400480: 48 ba 00 00 00 00 00    mov    $0x3ff0000000000000,%rdx
    400487: 00 f0 3f 
    40048a: 48 b8 00 00 00 00 00    mov    $0x4000000000000000,%rax
    400491: 00 00 40 
    400494: 48 89 17                mov    %rdx,(%rdi)
    400497: 48 89 06                mov    %rax,(%rsi)
    40049a: c3                      retq   
    40049b: 0f 1f 44 00 00          nopl   0x0(%rax,%rax,1)

00000000004004a0 <give_point>:
    4004a0: 66 0f 28 05 28 01 00    movapd 0x128(%rip),%xmm0
    4004a7: 00 
    4004a8: 66 0f 29 44 24 e8       movapd %xmm0,-0x18(%rsp)
    4004ae: f2 0f 10 05 12 01 00    movsd  0x112(%rip),%xmm0
    4004b5: 00 
    4004b6: f2 0f 10 4c 24 f0       movsd  -0x10(%rsp),%xmm1
    4004bc: c3                      retq   
    4004bd: 0f 1f 00                nopl   (%rax)

À l'exclusion des noplblocs, give_two_doubles()a 27 octets tandis que give_point()a 29 octets. En revanche, give_point()donne une instruction de moins quegive_two_doubles()

Ce qui est intéressant, c'est que nous remarquons que le compilateur a été en mesure d'optimiser movles variantes plus rapides de SSE2 movapdet movsd. De plus, give_two_doubles()déplace réellement les données vers et depuis la mémoire, ce qui ralentit les choses.

Apparemment, une grande partie de cela peut ne pas être applicable dans les environnements embarqués (c'est là que le terrain de jeu pour C est la plupart du temps de nos jours). Je ne suis pas un assistant d'assemblage, donc tout commentaire serait le bienvenu!


6
Compter le nombre d'instructions n'est pas du tout intéressant, à moins que vous ne puissiez montrer une énorme différence, ou compter des aspects plus intéressants tels que le nombre de sauts difficiles à prévoir, etc. Les propriétés de performance réelles sont beaucoup plus subtiles que le nombre d'instructions .
dkagedal

6
@dkagedal: Vrai. Rétrospectivement, je pense que ma propre réponse a été très mal écrite. Bien que je ne me sois pas beaucoup concentré sur le nombre d'instructions (je ne sais pas ce qui vous a donné cette impression: P), le point réel à souligner était que passer struct par valeur est préférable au passage par référence pour les petits types. Quoi qu'il en soit, le passage par valeur est préférable car c'est plus simple (pas de jonglage à vie, pas besoin de s'inquiéter que quelqu'un change vos données ou consttout le temps) et j'ai trouvé qu'il n'y a pas beaucoup de pénalité de performance (sinon de gain) dans la copie passe par valeur , contrairement à ce que beaucoup pourraient croire.
kizzx2

15

La solution simple sera de retourner un code d'erreur comme valeur de retour et tout le reste comme paramètre dans la fonction,
ce paramètre peut être une structure bien sûr mais ne voyez aucun avantage particulier en le passant par valeur, il suffit d'envoyer un pointeur.
Passer une structure par valeur est dangereux, vous devez faire très attention à ce que vous passez, rappelez-vous qu'il n'y a pas de constructeur de copie en C, si l'un des paramètres de structure est un pointeur, la valeur du pointeur sera copiée, cela peut être très déroutant et difficile à maintenir.

Juste pour compléter la réponse (crédit complet à Roddy ), l'utilisation de la pile est une autre raison de ne pas passer la structure par valeur, croyez-moi, le débogage du débogage de la pile est un vrai PITA.

Rejouer pour commenter:

Passer une structure par un pointeur signifie qu'une entité détient un droit de propriété sur cet objet et a une connaissance complète de ce qui doit être libéré et du moment. Passer une structure par valeur crée une référence cachée aux données internes de la structure (pointeurs vers une autre structure, etc.) à ce niveau est difficile à maintenir (possible mais pourquoi?).


6
Mais passer un pointeur n'est pas plus «dangereux» simplement parce que vous le mettez dans une structure, donc je ne l'achète pas.
dkagedal

Excellent point sur la copie d'une structure qui contient un pointeur. Ce point n'est peut-être pas très évident. Pour ceux qui ne savent pas à quoi il fait référence, effectuez une recherche sur copie profonde par rapport à copie superficielle.
zooropa

1
L'une des conventions de la fonction C est que les paramètres de sortie soient listés en premier avant les paramètres d'entrée, par exemple int func (char * out, char * in);
zooropa

Vous voulez dire comment, par exemple, getaddrinfo () met le paramètre de sortie en dernier? :-) Il existe un millier de conventions, et vous pouvez choisir celle que vous voulez.
dkagedal

10

Une chose que les gens ici ont oublié de mentionner jusqu'à présent (ou je l'ai négligée) est que les structures ont généralement un rembourrage!

struct {
  short a;
  char b;
  short c;
  char d;
}

Chaque caractère est de 1 octet, chaque court est de 2 octets. Quelle est la taille de la structure? Non, ce n'est pas 6 octets. Du moins pas sur les systèmes les plus couramment utilisés. Sur la plupart des systèmes, ce sera 8. Le problème est que l'alignement n'est pas constant, il dépend du système, donc la même structure aura un alignement différent et des tailles différentes sur différents systèmes.

Non seulement le rembourrage consommera davantage votre pile, mais cela ajoute également l'incertitude de ne pas être en mesure de prédire le remplissage à l'avance, à moins que vous ne sachiez comment votre système se remplit, puis regardez chaque structure que vous avez dans votre application et calculez la taille. pour ça. Passer un pointeur prend une quantité d'espace prévisible - il n'y a pas d'incertitude. La taille d'un pointeur est connue pour le système, elle est toujours égale, quel que soit l'aspect de la structure et les tailles des pointeurs sont toujours choisies de manière à être alignées et ne nécessitent aucun remplissage.


2
Oui, mais le remplissage existe sans aucune dépendance au passage de la structure par valeur ou par référence.
Ilya

2
@dkagedal: Quelle partie de "différentes tailles sur différents systèmes" n'avez-vous pas compris? Simplement parce qu'il en est ainsi sur votre système, vous supposez qu'il doit en être de même pour tout autre système - c'est exactement pourquoi vous ne devriez pas passer par valeur. Échantillon modifié pour qu'il échoue également sur votre système.
Mecki

2
Je pense que les commentaires de Mecki sur le remplissage des structures sont pertinents en particulier pour les systèmes embarqués où la taille de la pile peut être un problème.
zooropa le

1
Je suppose que le revers de l'argument est que si votre structure est une structure simple (contenant quelques types primitifs), le passage par valeur permettra au compilateur de le jongler avec des registres - alors que si vous utilisez des pointeurs, les choses se terminent par la mémoire, qui est plus lente. Cela devient assez bas et dépend à peu près de votre architecture cible, si l'une de ces informations compte.
kizzx2

1
À moins que votre structure ne soit minuscule ou que votre processeur ait de nombreux registres (et les processeurs Intel n'en ont pas), les données se retrouvent sur la pile et c'est aussi de la mémoire et aussi rapide / lente que n'importe quelle autre mémoire. Un pointeur par contre est toujours petit et juste un pointeur et le pointeur lui-même finira généralement toujours dans un registre lorsqu'il est utilisé plus souvent.
Mecki

9

Je pense que votre question résume assez bien les choses.

Un autre avantage du passage des structures par valeur est que la propriété de la mémoire est explicite. On ne se demande pas si la structure provient du tas et à qui incombe la responsabilité de la libérer.


9

Je dirais que passer des structures (pas trop grandes) par valeur, à la fois en tant que paramètres et en tant que valeurs de retour, est une technique parfaitement légitime. Il faut bien sûr veiller à ce que la structure soit de type POD ou que la sémantique de copie soit bien spécifiée.

Mise à jour: Désolé, j'avais mon cap de réflexion C ++. Je me souviens d'une époque où il n'était pas légal en C de renvoyer une structure à partir d'une fonction, mais cela a probablement changé depuis. Je dirais toujours que c'est valable tant que tous les compilateurs que vous prévoyez d'utiliser prennent en charge la pratique.


Notez que ma question portait sur C, pas sur C ++.
dkagedal

Il est valide de renvoyer une structure à partir de la fonction mais pas utile :)
Ilya

1
J'aime la suggestion de llya d'utiliser le retour comme code d'erreur et paramètres pour renvoyer les données de la fonction.
zooropa

8

Voici quelque chose que personne n'a mentionné:

void examine_data(const char *c, size_t l)
{
    c[0] = 'l'; // compiler error
}

void examine_data(const struct blob blob)
{
    blob.ptr[0] = 'l'; // perfectly legal, quite likely to blow up at runtime
}

Les membres de a const structsont const, mais si ce membre est un pointeur (comme char *), il devient char *constplutôt que ce que const char *nous voulons vraiment. Bien sûr, nous pourrions supposer qu'il consts'agit d'une documentation d'intention et que quiconque enfreint ceci écrit du mauvais code (ce qu'ils sont), mais ce n'est pas suffisant pour certains (en particulier ceux qui viennent de passer quatre heures à rechercher la cause d'un crash).

L'alternative pourrait être de faire un struct const_blob { const char *c; size_t l }et de l'utiliser, mais c'est plutôt compliqué - cela entre dans le même problème de schéma de nommage que j'ai avec typedefles pointeurs ing. Ainsi, la plupart des gens s'en tiennent à n'avoir que deux paramètres (ou, plus probablement dans ce cas, à l'aide d'une bibliothèque de chaînes).


Oui, c'est parfaitement légal, et c'est aussi quelque chose que vous voulez faire parfois. Mais je suis d'accord que c'est une limitation de la solution struct que vous ne pouvez pas faire les pointeurs qu'ils pointent vers const.
dkagedal le

Un mauvais piège avec la struct const_blobsolution est que même si const_bloba des membres qui diffèrent de blobseulement par "indirect-const-ness", les types struct blob*à a struct const_blob*seront considérés comme distincts aux fins d'une règle stricte d'aliasing. Par conséquent, si le code convertit a blob*en a const_blob*, toute écriture ultérieure dans la structure sous-jacente en utilisant un type invalidera silencieusement tous les pointeurs existants de l'autre type, de sorte que toute utilisation invoquera un comportement indéfini (qui peut généralement être inoffensif, mais pourrait être mortel) .
supercat

5

La page 150 du tutoriel d'assemblage PC sur http://www.drpaulcarter.com/pcasm/ explique clairement comment C permet à une fonction de renvoyer une structure:

C permet également d'utiliser un type de structure comme valeur de retour d'une fonction. Evidemment, une structure ne peut pas être retournée dans le registre EAX. Différents compilateurs gèrent cette situation différemment. Une solution courante utilisée par les compilateurs consiste à réécrire en interne la fonction comme une fonction qui prend un pointeur de structure comme paramètre. Le pointeur est utilisé pour placer la valeur de retour dans une structure définie en dehors de la routine appelée.

J'utilise le code C suivant pour vérifier l'instruction ci-dessus:

struct person {
    int no;
    int age;
};

struct person create() {
    struct person jingguo = { .no = 1, .age = 2};
    return jingguo;
}

int main(int argc, const char *argv[]) {
    struct person result;
    result = create();
    return 0;
}

Utilisez "gcc -S" pour générer l'assembly pour ce morceau de code C:

    .file   "foo.c"
    .text
.globl create
    .type   create, @function
create:
    pushl   %ebp
    movl    %esp, %ebp
    subl    $16, %esp
    movl    8(%ebp), %ecx
    movl    $1, -8(%ebp)
    movl    $2, -4(%ebp)
    movl    -8(%ebp), %eax
    movl    -4(%ebp), %edx
    movl    %eax, (%ecx)
    movl    %edx, 4(%ecx)
    movl    %ecx, %eax
    leave
    ret $4
    .size   create, .-create
.globl main
    .type   main, @function
main:
    pushl   %ebp
    movl    %esp, %ebp
    subl    $20, %esp
    leal    -8(%ebp), %eax
    movl    %eax, (%esp)
    call    create
    subl    $4, %esp
    movl    $0, %eax
    leave
    ret
    .size   main, .-main
    .ident  "GCC: (Ubuntu 4.4.3-4ubuntu5) 4.4.3"
    .section    .note.GNU-stack,"",@progbits

La pile avant l'appel crée:

        +---------------------------+
ebp     | saved ebp                 |
        +---------------------------+
ebp-4   | age part of struct person | 
        +---------------------------+
ebp-8   | no part of struct person  |
        +---------------------------+        
ebp-12  |                           |
        +---------------------------+
ebp-16  |                           |
        +---------------------------+
ebp-20  | ebp-8 (address)           |
        +---------------------------+

La pile juste après avoir appelé create:

        +---------------------------+
        | ebp-8 (address)           |
        +---------------------------+
        | return address            |
        +---------------------------+
ebp,esp | saved ebp                 |
        +---------------------------+

2
Ici, nous avons deux problèmes. Le plus évident est que cela ne décrit pas du tout "comment C permet à une fonction de renvoyer une structure". Cela décrit uniquement comment cela peut être fait sur du matériel x86 32 bits, ce qui se trouve être l'une des architectures les plus limitées lorsque vous regardez le nombre de registres, etc. Le deuxième problème est que la façon dont les compilateurs C génèrent du code pour renvoyer des valeurs est dicté par l'ABI (sauf pour les fonctions non exportées ou intégrées). Et au fait, les fonctions inlines sont probablement l'un des endroits où le retour des structures est le plus utile.
dkagedal

Merci pour les corrections. Pour un détail complet de la convention d'appel, en.wikipedia.org/wiki/Calling_convention est une bonne référence.
Jingguo Yao

@dkagedal: Ce qui est important, ce n'est pas seulement que x86 fait les choses de cette façon, mais plutôt qu'il existe une approche "universelle" (c'est-à-dire celle-ci) qui permettrait aux compilateurs de toute plate-forme de prendre en charge les retours de tout type de structure qui n'est pas t tellement énorme que de faire sauter la pile. Alors que les compilateurs pour de nombreuses plates-formes utiliseront d'autres moyens plus efficaces pour gérer certaines valeurs de retour de type structure, il n'est pas nécessaire que le langage limite les types de retour de structure à ceux que la plate-forme peut gérer de manière optimale.
supercat

0

Je veux juste souligner l'un des avantages de la transmission de vos structures par valeur, c'est qu'un compilateur d'optimisation peut mieux optimiser votre code.

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.