2
votes

Comment écrire correctement une fonction qui ajoute deux entiers en C

Je suis nouveau en C et j'ai une idée de ce qui est le plus optimisé et de la manière correcte de gérer les pointeurs, les valeurs, les références, etc.

J'ai commencé par créer un simple entier add fonction.

int
add_pointers(int *a, int *b) {
  return (*a) + (*b);
}

int
add_values(int a, int b) {
  return a + b;
}

void
main() {
  // these work
  int sum = add_values(1, 1);
  int a = 1;
  int b = 1;
  int c = add_values(a, b);

  // this works now
  int d = add_pointers(&a, &b);

  // not sure
  int e = add(*a, *b);
}

D'après ce que j'ai compris, faire add (a, b) copiera les valeurs dans la fonction, ce qui signifie qu'elle est plus lente en termes de performances que de passer des pointeurs. J'essaye donc de créer deux fonctions d'ajout, en renommant celle-ci en add_values.

int
add(int a, int b) {
  return a + b;
}

void
main() {
  // these work
  int sum = add(1, 1);
  int a = 1;
  int b = 1;
  int c = add(a, b);

  // this doesn't
  int d = add(&a, &b);
  int e = add(*a, *b);
}

Je me demande quelques choses:

  • S'il existe un moyen d'avoir le comportement de ces deux fonctions dans une fonction. Ou sinon (ou si les performances ne sont pas optimales dans ce cas), comment gérer généralement les deux cas, s'il ne s'agit que de 2 fonctions séparées avec une convention de dénomination ou quelque chose, etc.
  • Laquelle est la forme la plus optimisée. D'après ce que je comprends, c'est add_pointers car rien n'est copié. Mais alors vous ne pouvez pas faire un simple add (1, 1) , donc ce n'est pas amusant du point de vue de l'API.
  • c

    8 commentaires

    Passer des pointeurs n'est pas plus rapide que passer des entiers. Cela pourrait même être plus lent, sur une machine 64 bits, int sera de 32 bits, mais int * sera de 64 bits.


    Le passage des pointeurs est plus rapide pour la plupart des structures, pas pour les entiers.


    Et dans tous les cas, il ne faut pas s'inquiéter des performances à ce niveau, 99,9% du temps c'est négligeable.


    Je ne me demande pas seulement pour les nombres entiers, je voudrais savoir en général.


    En général, il n'est pas possible de faire ce que vous voulez. Vous pouvez le faire avec une fonction variadique, mais vous devrez ensuite passer un argument supplémentaire qui lui indique si vous passez des pointeurs ou des objets.


    Si vous ne modifiez pas les arguments, un bon optimiseur peut le détecter et éviter de faire des copies. Alors ne vous en faites pas.


    "add_pointers car rien n'est copié" - c'est probablement là que se trouve le malentendu, il y a quelque chose de copié, le pointeur est copié. Vous avez raison de dire que ce qu'il pointe n'est pas copié. S'il y a des performances à gagner, ce ne sera que si le pointeur qui est copié est plus petit que l'objet vers lequel il pointe. Ensuite, vous devez prendre en compte le code supplémentaire requis pour déréférencer le pointeur pour accéder aux données vers lesquelles il pointe, cette surcharge pourrait devoir être prise en compte.


    En C, tous les paramètres sont passés par valeur. int a est passé par valeur, la valeur étant le contenu de la mémoire de a . int * p est passé par valeur, cette valeur étant l'adresse contenue dans la variable - ou, en d'autres termes, le contenu de la mémoire de p . Idem pour char , double ** , etc. Tout est passé par valeur. Et si vous vous inquiétez de la différence de temps d'exécution entre le passage d'un int et le passage d'un int * , vous devriez probablement écrire quoi que ce soit dans l'assembleur et l'instruction de comptage cycles, plutôt que d'utiliser un langage plutôt de haut niveau comme C.


    3 Réponses :


    1
    votes

    Chaque fois que vous appelez une fonction avec des paramètres, vous copiez les valeurs des paramètres. Dans vos exemples, il s'agit simplement de savoir si vous copiez des valeurs de pointeur ou des valeurs entières. La copie d'un int ne sera pas sensiblement plus rapide ou plus lente que la copie d'un pointeur, mais avec un pointeur, vous avez une lecture supplémentaire de la mémoire chaque fois que vous déréférencer le pointeur.

    Pour tout type de données simple, il vaut mieux accepter les paramètres par valeur. Le seul moment où il est plus logique de passer un pointeur est si vous avez affaire à un tableau ou une struct qui peut être arbitrairement grand.


    4 commentaires

    Dans le cas des tableaux, ils sont effectivement passés en tant que pointeurs lorsqu'ils sont passés à une fonction. Passer un pointeur à un tableau serait en fait un pointeur vers un pointeur.


    @ChrisTaylor Un pointeur vers un tableau est un pointeur vers un tableau si nous parlons en termes de C (par exemple {char a [1]; & a;} ). Et pour accéder à un élément de tableau, vous avez besoin de deux déréférences, une pour obtenir le tableau (à partir d'un pointeur vers celui-ci) et une autre pour accéder à l'élément dans le tableau. Le premier déréférencement n'est rien de plus qu'une conversion de type (de pointeur en tableau, tout comme vous déréférencer un pointeur vers int et obtenir un int), dont le résultat se désintègre en pointeur vers l'élément lors du deuxième déréférencement. Et (uintptr_t) & a == (uintptr_t) & a [0]; serait vrai.


    @AlexeyFrunze - D'accord, mais au point dans le post, j'ai eu l'impression que cela pourrait être interprété car il y a un moyen de passer un tableau par valeur, ce qui n'est pas le cas, et ce pourrait être juste une terminologie, mais j'ai vu une interprétation potentielle impliquant qu'un pointeur vers un tableau aurait un sens * arr [] ou ** arr comme moyen de passer un tableau à une seule dimension à une fonction en tant que pointeur.


    @dbush, Votre prémisse est fausse, vous ne copiez peut-être pas de paramètres, s'ils sont stockés dans des registres, le compilateur sélectionnera les registres appropriés pour stocker les valeurs afin de les transmettre directement à la fonction. Aujourd'hui, les processeurs, avec environ 16 à 32 registres, permettent au compilateur de sélectionner le registre approprié pour transmettre les données à une nouvelle fonction. Le handicap ici est l'appel de fonction proprement dit, pas la copie de valeur des paramètres.



    1
    votes

    ce qui signifie qu'il est plus lent en termes de performances que de passer des pointeurs

    C'est là que vous vous êtes trompé. Sur un ordinateur 32 bits typique, int est 32 bits et les pointeurs sont 32 bits. Ainsi, la quantité réelle de données transmises est identique entre les deux versions. Cependant, l'utilisation de pointeurs peut se résumer à un code machine à accès indirect, donc il peut en fait produire du code moins inefficace dans certaines circonstances. Dans le cas général, int add (int a, int b) est probablement le plus efficace.

    En règle générale, il est correct de passer tous les types entiers et flottants standard par valeur aux fonctions. Mais les structures ou unions doivent être passées par des pointeurs.

    Dans ce cas particulier, le compilateur est susceptible de "incorporer" toute la fonction, en la remplaçant par une seule instruction d'ajout dans le code machine. Après quoi, tout le paramètre passant se transforme en un non-problème.

    Dans l'ensemble, ne réfléchissez pas trop aux performances en tant que débutant, c'est un sujet avancé et dépend du système spécifique. Au lieu de cela, concentrez-vous sur l'écriture de code aussi lisible que possible.


    0 commentaires

    1
    votes

    De mon point de vue, selon que vous avez un compilateur capable de développer des fonctions en ligne , le moyen le plus rapide de le faire est simplement:

        .file   "inline.c"
        .globl  main                    @ -- Begin function main
        .p2align        2
        .type   main,%function
        .code   32                      @ @main
    main:
        .fnstart
    @ %bb.0:
        .save   {r11, lr}
        push    {r11, lr}
        .setfp  r11, sp
        mov     r11, sp
        mov     r0, #3
        mov     r1, #2
        bl      sum                 <--- the call to the function.
        mov     r0, #0
        pop     {r11, pc}
    .Lfunc_end0:
        .size   main, .Lfunc_end0-main
        .cantunwind
        .fnend
    

    car le compilateur évitera probablement le plus souvent l'appel / le retour du sous-programme et utilisera la meilleure extension disponible dans chaque cas d'utilisation (dans la plupart des cas, cela sera intégré comme une seule instruction add r3, r8 . ) Cela peut prendre bien moins qu'un seul cycle d'horloge dans de nombreux processeurs multicœurs et pipelined d'aujourd'hui.

    Si vous n'avez pas accès à un tel compilateur, alors probablement la meilleure façon de simuler ce scénario consiste à:

    int add(int a, int b);
    
    int main()
    {
        int a = 3, b = 2, c = sum(a, b);
    }
    

    et vous serez en ligne, tout en conservant la notation de la fonction. Mais les locaux échouent avec cette réponse, car vous avez demandé une fonction, en fonction de la priorité des locaux :)

    Lorsque vous pensez à la meilleure façon de faire un appel de fonction, pensez d'abord que, pour les petits fonctions, la tâche la plus lourde que vous faites est de faire un appel de sous-programme du tout ... comme il faut du temps pour pousser l'adresse de retour, pensez que dans ce cas, le pire est de devoir appeler une fonction, juste pour ajouter deux paramètres (l'ajout de deux valeurs stockées dans les registres ne nécessite qu'une seule instruction, mais avec un appel de fonction, il en nécessite au moins trois --- l'appel, l'ajout et le retour) L'ajout ne demande pas beaucoup de temps si les addends sont déjà dans les registres, mais un appel de sous-programme nécessite normalement de pousser un registre dans la pile et de le faire apparaître plus tard. Ce sont deux accès mémoire, qui coûteront plus cher, même s'ils sont mis en cache dans le cache d'instructions.

    Bien sûr, si le compilateur sait que la fonction peut être mise en cache, et que vous l'utilisez plusieurs fois avec les mêmes paramètres dans une expression ou dans le même bloc, il peut mettre en cache la valeur de résultat à utiliser plus tard, et économiser le coût de la reconstitution de la somme. La chose devient comme si la meilleure façon de procéder était de voir à quel scénario exact nous avons affaire. Mais à ce stade, le coût majeur de l'ajout de deux nombres, est de loin, le coût de l'enfermer dans une enveloppe de fonction.

    EDIT

    J'ai essayé l'exemple suivant et compilé il dans une architecture arm7 (raspberry pi B +, freebsd, compilateur clang) et le résultat était loin que bon :)

    inline.c

        /* ... */
        .file   "inline.c"
        .globl  main                    @ -- Begin function main
        .p2align        2
        .type   main,%function
        .code   32                      @ @main
    main:
        .fnstart
    @ %bb.0:
        mov     r0, #0
        bx      lr
    .Lfunc_end0:
        .size   main, .Lfunc_end0-main
    

    résultant dans:

    inline int sum(int a, int b)
    {
        return a + b;
    }
    
    int main()
    {
        int a = 3, b = 2, c = sum(a, b);
    }
    

    Comme vous le voyez, le seul code pour main consistait à stocker la valeur de retour 0 dans r0 (exit code);)

    Juste au cas où je compilerais add comme une fonction de bibliothèque externe:

    #define add(a,b) ((a) + (b))
    

    donnerait:

    inline int add(int a, int b)
    {
        return a + b;
    }
    

    Vous pouvez voir que l'appel à la fonction sera fait, de toute façon, car le compilateur n'a pas été informé du type de fonction qu'il a sous la main (même si le résultat le code ne sera pas utilisé, car la fonction peut avoir des effets latéraux), et doit de toute façon être appelée.

    Au fait, et comme mentionné, la manière de passer des références à une fonction implique un déréférencement ces pointeurs, et t hat signifie normalement des accès à la mémoire (ce qui est beaucoup plus cher que d'ajouter deux registres ensemble)


    0 commentaires