Journal Le bon sens et le C++

Posté par  (site web personnel) . Licence CC By‑SA.
Étiquettes :
7
14
nov.
2024

Demat' iNal.

On a l'impression, parfois, que dans un code compilé, pour savoir si une fonction (locale) est utilisée, il suffit de la supprimer, recompiler et si on obtient une erreur, elle était utilisée. Question de bon sens ! D'ailleurs c'est le cas en C [*]

Bien entendu, c'est faux, comme le montre le code C++ suivant:

#include <cstdio>

#ifndef REMOVE
static void foo(int x) {
    puts("foo(int)");
}
#endif

static void foo(float y) {
    puts("foo(float)");
}

int main() {
    foo(1);
}

Si REMOVE est défini, le code continue à compiler mais a un comportement différent à l'exécution. On peut forger des exemples similaires avec les conversions implicites.

Comme quoi

Le bon sens n'est rien d'autre qu'un ensemble de préjugés qui reposent dans votre esprit avant que vous n'ayez eu 18 ans — (Einstein semble-t-il)

se transpose bien à l'informatique :-)

[*] je pense !

  • # bah non

    Posté par  . Évalué à 2 (+1/-2). Dernière modification le 14 novembre 2024 à 13:15.

    Et ça ne marche pas non plus en java (je dirai même plus c'est pire), et encore moins en python !

    grep et consort restent des outils bien plus fiable pour s'assurer d'éviter de faire des boulettes, les IDE peuvent aussi donner des indications, mais une absence de résultat n'indique pas forcément une absence d'utilisation.

    ensuite si la fonction est locale un find in file sera bien plus efficace qu'une compilation.

    Et globalement tous les langages permettant introspection ne peuvent reposer sur une méthode aussi basique.

    Dison qu'on a pas le même bon sens.

    Il ne faut pas décorner les boeufs avant d'avoir semé le vent

    • [^] # Re: bah non

      Posté par  (site web personnel) . Évalué à 4 (+1/-0).

      Sinon il y a des outils dédiés type cppcheck qui peuvent faire une telle évaluation et faire un beau rapport à la fin.

      Cela évite ce genre de pièges même si cela n'est jamais parfait.

      • [^] # Re: bah non

        Posté par  . Évalué à 4 (+1/-0).

        Perso je me base surtout de la détection de code mort de l'ide qui doit être une interface ou un réimplémentation de cppcheck. Mais il lui arrive de rater des trucs, notamment a cause de certains template ou de #ifdef / #ifndef.

        grep donne un résultat qu'il faut regarder ensuite, mais sur du c++ il ne m'a jamais mis en défaut, comprendre par la des faux positifs; mais jamais une non détection.

        Sur le java c'est encore plus compliqué, car avec l’introspection les auto discover et autre y'a pas de solution fiable à 100% (ne surtout pas se fier à grep), encore qu'avec les dernières versions de java il me semble qu'on peut plus accéder aux private via introspection.

        Il ne faut pas décorner les boeufs avant d'avoir semé le vent

        • [^] # Re: bah non

          Posté par  . Évalué à 3 (+1/-0). Dernière modification le 18 novembre 2024 à 17:46.

          Perso je me base surtout de la détection de code mort

          D'ailleurs le code de l'OP ne compile pas avec -Werror=unused-function, dispo sous gcc et clang quand les 2 fonctions sont la, puisqu'une seule est appelée. Je suis malgré tout surpris de l'absence de warning sur la conversion implicite dans ce cas.

    • [^] # Re: bah non

      Posté par  . Évalué à 5 (+3/-1).

      Je n'ai pas trop compris pourquoi c'était censé marcher, on n'aurait pas le même genre de problème avec la redéfinition d'une méthode dans une classe dérivée? Tous les langages qui autorisent la redéfinition / surcharge vont se comporter de cette manière, quand on supprime l'appel le plus spécialisé, le compilateur va appeler l'appel moins spécialisé, etc.

      Et en fait c'est même pire pour les langages non-compilés qui vont écraser "silencieusement" les déclarations précédentes, puisque quand on supprime la fonction locale on va aller appeler du code qui était mort auparavant.

      Est-ce qu'il y a un contexte où la technique de "je supprime la fonction et ça ne marche plus" fonctionne autrement que par coup de bol?

    • [^] # Re: bah non

      Posté par  (site web personnel) . Évalué à 2 (+0/-0).

      Python n'étant pas vraiment un langage compilé (bien qu'il y ait une étape de compilation avant l'exécution hein), il était hors jeux.

      En fait ma remarque fait écho à une intuition courante comme quoi « si on retire un #include et que ça compile toujours, alors cet include est inutile ». Un corollaire de mon example est que non.

      Dison qu'on a pas le même bon sens.

      je viens de relire le journal, et à aucun moment il ne mentionne l'avis de l'auteur :-)

      • [^] # Re: bah non

        Posté par  . Évalué à 4 (+1/-0).

        je ne sais pas d'où vient cette idée, mais quiconque à travaillé sur du C/C++ sait que non c'est pas comme ça que ça marche.

        Souvent l'ordre des includes peut faire planter la compilation, des macros peuvent être définie dans les includes; je ne parle même pas du boulet qu'a fait un include guard (#ifndef machin … #define machin), en le recopiant d'un autre include

        J'ai même eu un projet où l'ordre des chemins d'includes (défini via la chaine de compilation) changeait le comportement (et oui c'était voulu).

        Donc non supprimer une fonction cantonnée au fichier, on vérifie via un search dans ledit fichier, et on vérifie que ce même fichier n'est pas inclus ailleurs car le #include "machin.cc" j'ai déjà vu aussi, et pour plus de sûreté on fait un grep et on analyse les résultats.

        Il ne faut pas décorner les boeufs avant d'avoir semé le vent

        • [^] # Re: bah non

          Posté par  . Évalué à 3 (+1/-0).

          Je crois qe les langages récents (rust/Go) retournent des messages lorsque du code n'est pas utilisé. De mémoire Erlang rale également lorsqu'on a du code mort dans un module. Celà dit je me demande si le compilateur est en mesure de traquer tout le code mort ou s'il a ses limites (j'avoue que je ne me suis jamais posé la question).

          • [^] # Re: bah non

            Posté par  . Évalué à 2 (+0/-1).

            Celà dit je me demande si le compilateur est en mesure de traquer tout le code mort ou s'il a ses limites (j'avoue que je ne me suis jamais posé la question).

            Ça doit dépendre du langage; quand la reflexivité est autorisée (eval()), tu ne peux pas vérifier, et j'imagine qu'il y a des constructions avec des pointeurs de fonction qui rendent le traquage du code mort très très complexe.

            Et puis tu as de toutes manières les trucs du genre

            if (check_P_equals_NP()) foo();

            qui fait que foo() est peut-être mort et peut-être pas…

            • [^] # Re: bah non

              Posté par  . Évalué à 3 (+0/-0).

              Ou plus immédiatement le problème de l'arrêt. Si on sait pas si "P" termine, on sait pas a fortiori, quand on concatène un programme P' pour former PP', si P' est mort ou pas.

              • [^] # Re: bah non

                Posté par  . Évalué à 2 (+0/-1).

                Bon, en fait j'ai quand même répondu avec un peu de mauvaise foi, parce que le compilateur sait ou non s'il a besoin de compiler, et s'il y a un appel dynamique, il compile de toutes manières, j'imagine. Je ne sais pas s'il évalue par exemple si les variables sont potentiellement modifiables

                const int truc = 2;
                if (truc == 2)
                  foo();
                

                vs

                int truc = 2;
                if (truc == 2)
                  foo();
                

                vs

                int truc = 2;
                bar(truc);
                if (truc == 2)
                  foo();
                

                parce que si void bar(int) ou void bar (const int&), alors foo() est mort, mais si void bar(int&), foo() est peut-être vivant, mais il y a moyen à partir du code de bar() de vérifier si la référence est potentiellement modifiée.

                • [^] # Re: bah non

                  Posté par  . Évalué à 3 (+0/-0). Dernière modification le 18 novembre 2024 à 10:56.

                  Les compilateurs font du :en:Single static assignment de nos jours donc ce type d'analyse est relativement simple je pense (après reformulation, une variable = une valeur, donc truc est toujours égal à 2 dans un tel cas). Après il suffit de propager les constantes et d'évaluer la condition, ici.

                  On peut aller beaucoup plus loin avec des analyses plus sophistiquées comme l'interprétation symbolique et des solveur SMT comme :en:Z3 Theorem Prover on peut trouver des expérimentations sur ce thème assez facilement avec Z3, tu peux prouver par exemple qu'une condition complexe sur un if est toujours fausse, donc éliminer les arêtes de l'intérieur du if du graphe de flot de contrôle ou graphe d'appel du code, et donc en déduire que le code ne sera jamais utilisé si il est déconnecté en conséquence, avec les éléments dont il dépend au passage.

                  Ça ne rend pas le problème décidable et les techniques absolument complètes, il y aura toujours des cas ou il sera impossible, mais potentiellement ça peut repousser l'horizon dans bien des cas raisonnables, voire la quasi totalité des cas ordinaires.

                  • [^] # Re: bah non

                    Posté par  . Évalué à 2 (+0/-1).

                    Après il suffit de propager les constantes et d'évaluer la condition, ici.

                    OK, dit comme ça ça semble un peu magique et j'imagine que ça ne gère pas les cas où la variable de départ est modifiée pour retrouver sa valeur initiale, mais de toutes manières quand on parle d'optimisation l'objectif reste quand même pragmatique, ça n'est pas grave si des cas particuliers ne sont pas optimisables…

                    et donc en déduire que le code ne sera jamais utilisé si il est déconnecté en conséquence, avec les éléments dont il dépend au passage.

                    En fait, l'utilité de tout ça dépend un peu des objectifs, non? S'il te faut 10 secondes de calcul pour déterminer si tu dois compiler ou pas 3 lignes de code, au final tu n'y gagnes pas grand chose…

                    • [^] # Re: bah non

                      Posté par  (site web personnel) . Évalué à 5 (+2/-0).

                      En fait, l'utilité de tout ça dépend un peu des objectifs, non? S'il te faut 10 secondes de calcul pour déterminer si tu dois compiler ou pas 3 lignes de code, au final tu n'y gagnes pas grand chose…

                      Sur un logiciels largement distribué ou avec des contraintes fortes il vaut mieux une compilation plus longue qui permet de gagner du temps au runtime que de ne rien faire. De même pour la sûreté, les langages fortement typés par exemple ont une réelle valeur ajoutée à ce sujet.

                      • [^] # Re: bah non

                        Posté par  . Évalué à 2 (+0/-0).

                        Si on va par là à moins d'avoir des builds distincts le mieux c'est encore de supprimer le code source plutôt que de complexifier le build.

                        https://linuxfr.org/users/barmic/journaux/y-en-a-marre-de-ce-gros-troll

                    • [^] # Re: bah non

                      Posté par  . Évalué à 2 (+0/-0).

                      Il me semble que ces optimisations sont par unité de compilation à partir du moment où tu a des linkage même statique, ce n'est plus qu'une analyse de la forme "est-ce que le symbole de ce .o est présent dans un autre .o ? ".

                      https://linuxfr.org/users/barmic/journaux/y-en-a-marre-de-ce-gros-troll

                    • [^] # Re: bah non

                      Posté par  . Évalué à 3 (+0/-0).

                      j'imagine que ça ne gère pas les cas où la variable de départ est modifiée pour retrouver sa valeur initiale

                      C'est le rôle de la propagation de constante. Le SSA transformera du code comme

                      int x = 3;
                      x=x+1;
                      x=x-1;

                      en

                      int x0 = 3;
                      x1=x0+1;
                      x2=x1-1;

                      Là dessus, dans un tel cas, on peut déduire (de la même manière que les constexpr (ou la métaprogrammation avec des template) en C++ si tu vois ce que c'est, que x1=4 et donc x2=3 rien qu'en évaluant les expressions.

                      Évidemment c'est pas possible si il y a trop de non constantes impliquées, mais le côté "propagation" c'est qu'une expression peut devenir constante une fois qu'on en a évalué d'autres.

                      S'il te faut 10 secondes de calcul pour déterminer si tu dois compiler ou pas 3 lignes de code, au final tu n'y gagnes pas grand chose…

                      Ça fait partie des phases que le compilateur fait typiquement, ce genre d'analyse (la propagation de constante et le SSA de nos jours). Les autres font partie d'analyse de code qu'on peut faire par ailleurs pour trouver des bugs par analyse statiques ou pour calculer des lifetime de Rust, et c'est pas inutile dans ce genre de cadre. Potentiellement en conjugaison de ce genre d'analyse c'est un effet de bord "gratuit".

                      Tu peux faire ça aussi uniquement lors de la phase de compilation finale avant déploiement, et les gains seront partout là ou ça va être déployé, sans forcément faire ça continûment pendant le développement ou ça peut être gênant. Effectivement les gains les plus importants potentiellement sont sur de plus grosses bases de code. C'est aussi les plus dures à analyser et les plus longues à compiler en général.

          • [^] # Re: bah non

            Posté par  . Évalué à 2 (+0/-0).

            Celà dit je me demande si le compilateur est en mesure de traquer tout le code mort ou s'il a ses limites (j'avoue que je ne me suis jamais posé la question).

            Aucune idée, mais LLVM est assez impressionnant de ce point de vue, l'outil (clang-tidy je crois?) sortant même les conditions qui font qu'un code ne peut jamais être exécuté.
            Forcément, il y a des limitations, mais bon sang, ça reste balèze et bien utile!

      • [^] # Re: bah non

        Posté par  . Évalué à 1 (+2/-0).

        Compilé, oui, mais en bytecode pour une machine virtuelle…

        Comme java quoi !

  • # Conversion implicite

    Posté par  (site web personnel) . Évalué à 9 (+7/-0). Dernière modification le 14 novembre 2024 à 13:16.

    Tu triches avec les conversions implicites…

    Allez :

    template <class T> void foo(T) = delete;
    

    Avant ton main et c'est bon.

    Ou la totale en C++20:

    template <class T> requires std::same_as(T, float) void foo(T x) {
        puts("foo(float)");
    }
    
  • # Beurk les conversions implicites

    Posté par  (site web personnel) . Évalué à 4 (+2/-0).

    Imaginez une API qui permet de sérialiser des données binaires en fonction du type en entrée :

    #include <iostream>
    #include <cstdint>
    
    void pack(uint8_t x)  { puts("uint8_t");  }
    void pack(uint16_t x) { puts("uint16_t"); }
    void pack(uint32_t x) { puts("uint32_t"); }
    void pack(uint64_t x) { puts("uint64_t"); }
    void pack(int8_t x)   { puts("int8_t");   }
    void pack(int16_t x)  { puts("int16_t");  }
    void pack(int32_t x)  { puts("int32_t");  }
    void pack(int64_t x)  { puts("int64_t");  }
    void pack(float x)    { puts("float");    }
    
    int main() {
        pack(1);
        return 0;
    }

    Résultat possible :

    $ ./a.out
    int32_t
    

    Du coup, si on veut sérialiser des entiers spécifiques à la suite on doit forcer un cast.

    pack(static_cast<uint8_t>(10));
    pack(static_cast<uint16_t>(1664));

    Perso je vote pour des spécialisations de template ou des noms explicites (comme packu64 mais moins C++/template/metaprogramming friendly)

    git is great because linus did it, mercurial is better because he didn't

    • [^] # Re: Beurk les conversions implicites

      Posté par  . Évalué à 2 (+0/-0).

      Perso je vote pour des spécialisations de template ou des noms explicites (comme packu64 mais moins C++/template/metaprogramming friendly)

      Moui. Perso je trouve les trucs avec des noms explicites, genre l'API d'opengl, assez pénibles et justement je trouve que sur ça, C++ est bien agréable.
      Par contre, je préférais avoir un warning sur le cas évoqué dans l'OP. J'étais persuadé qu'il était possible d'en avoir un (il y en a, mais sur la fonction non utilisé, ce qui est en effet considérable comme un bug dans le cas en question, mais…) pour les conversions de type implicite, mais apparemment ça ne marche pas dans ce cas précis. Le mot clé explicit ne peut pas non plus être utilisé dans ce cas, dommage.

      • [^] # Re: Beurk les conversions implicites

        Posté par  . Évalué à 2 (+0/-0).

        le problème dess conversions implicites c'est que ça devient vite une usine à bugs ( bon usine j'exagère paut-être un peu, mais il suffit d'un oubli passager de la règle de conversion pour avoir des choses qui peuvent mal se passer sans qu'on s'en rende compte aux tests).

        Sur d'autres points, le nommage explicite est souvent lourd (et parfois inutile), mais pour la conversion je pense que le fait d'être explicite évite des erreurs.

        • [^] # Re: Beurk les conversions implicites

          Posté par  . Évalué à 4 (+2/-0). Dernière modification le 20 novembre 2024 à 07:58.

          Entièrement d'accord, je serais tellement plus serein si le compilo pouvais détecter toutes les conversions implicites… mais ce n'est pas le cas.

          Cela dis, il est déjà possible d'en activer un petit paquet. De mémoire, en vrac et non exhaustif:

          • comparaison d'entiers signés vs non-signés;
          • conversion entre float et double;
          • conversion vers un type plus petit;

          Ensuite on peut prendre la bonne habitude d'utiliser explicit dans les constructeurs, opérateurs d'affection et opérateurs de conversion. Je suis récemment tombé sur un bug a cause d'un … qui avais ajouté un opérateur de conversion implicite vers bool à une classe qui interagit principalement avec des pointers… J'étais en colère quand j'ai fini par comprendre d'où venait le problème.
          Par pitié, si vous êtes profs ou même pas en fait, s'il vous plaît, insistez auprès des jeunes qu'il *ne faut pas définir de bool foo::operator() const { return bar; }: ça ne peut que mal se passer!

          C'est pas la panacée, certes, mais c'est déjà bien. Et quand un dev C critique cet aspect mais oublie manifestement de mentionner que les cast en C servent quasi à rien, que le C encore récemment permettait des trucs du style:

          int fubar()
          {
            return 0;
          }
          
          int main()
          {
            return fubar( 1, 2 ,3 );
          }
          

          je me demande quoi rétorquer. Mentionner la mauvaise foi, au bout d'un moment, ça fait un peu réchauffé, même si c'est la réalité.

  • # et Rust

    Posté par  (site web personnel, Mastodon) . Évalué à -1 (+1/-3).

    Et Rust se comporte à peu près comme C là dessus alors qu'il a la puissance de C++.

    PS comme @serge_sans_paille pour C il y a peut-être des cas tordu ou ce n'est pas le cas.

    Sous licence Creative common. Lisez, copiez, modifiez faites en ce que vous voulez.

  • # Map file

    Posté par  . Évalué à 2 (+1/-0). Dernière modification le 15 novembre 2024 à 09:57.

    Pour savoir si une fonction est utilisée en C ou Rust, je demande au linker de générer un Map file.
    Le linker de ARM y ajoute une liste des objets qu'il retire car non utilisé removing symbol blabla je crois que le ld dois pouvoir montrer ça aussi.
    Au pire je regarde les symboles inclus dans le binaire avec la commande nm.

    Ça ne marche peut être pas avec les fonctions inline.

    • [^] # Re: Map file

      Posté par  (site web personnel) . Évalué à 3 (+1/-0).

      Et surtout que si c'est une fonction locale à un fichier (donc static ou namespace anonyme) le compilateur met un warning si bien configuré

      C:/w/src/sigi/firmware/bootloader/main.c:46:13: warning: 'flash' defined but not used [-Wunused-function]
         46 | static void flash(const void *firmware, uint32_t firmware_len) {
      

      Pour moi c'est le moyen correct de vérifier qu'une fonction est utilisée ou non plutôt que raboter au hasard.

      Autrement, le linker fait les choses bien à ne pas inclure les symbols inutilisés dès lors qu'ils sont pas tous dans le même fichier.

      git is great because linus did it, mercurial is better because he didn't

  • # Polymorphisme

    Posté par  . Évalué à 5 (+3/-0).

    Après le polymorphisme rend évident que l'hypothèse n'est pas valide, en tout cas pas dans le cas général.

    struct A {
        virtual int foo() const {
            return 1;
        }
    };
    
    struct B: public A {
    #ifndef REMOVE
        virtual int foo() const {
            return 2;
        }
    #endif
    };
    auto bar = new B();
    bar.foo();

    à la syntaxe prêt

    https://linuxfr.org/users/barmic/journaux/y-en-a-marre-de-ce-gros-troll

    • [^] # Re: Polymorphisme

      Posté par  . Évalué à 3 (+3/-2). Dernière modification le 15 novembre 2024 à 15:55.

      Mode Chieur = ON

      à la syntaxe prêt

      à la faute d'orthographe près ;) (à moins qu'il n'y ait un jeu de mot que je n'ai pas compris )

      Mode Chieur = Off

  • # Bon sens

    Posté par  (site web personnel) . Évalué à 3 (+1/-0).

    En voyant le titre j'imaginais un rappel que l'ordre d'évaluation du C++ est parfois indéfini (contrairement à ce que la graphie pourrait laisser croire).

    Adhérer à l'April, ça vous tente ?

    • [^] # Re: Bon sens

      Posté par  . Évalué à 3 (+1/-0).

      Ah oui, je m'en rappelle de celui-la, d'UB… il m'a bien interloqué il y a ~15 ans, quand j'avais écrit un bout de code pour réduire le sucre syntaxique pour utiliser la PBNI (le truc pour injecter du code natif dans cet étron de PowerBuilder): j'avais un comportement différent entre le compilo de VisualStudio et gcc, je comprenais pas pourquoi.

      J'avais fait, de mémoire, un truc dans ce style: result = foo( ++i, ++i, ++i ); et bien sûr, VSC++ faisait tous les incréments avant l'appel, alors que gcc lui faisait ce que je supposais être logique, c'est à dire inc EAX; push EAX; inc EAX; push EAX pour le pseudo assembleur.

      J'aimerai bien pouvoir remettre la main sur ce bout de code pre-C++11, je me demande comment j'avais réussi certains trucs.
      Avec juste des templates (non variadiques à l'époque, et c'est pas plus mal) et quelques macros j'avais réussi a me débarrasser de toutes les redondances de l'API mal branlée de ce RAD pourri qui crash toutes les 30 minutes en moyenne et qui stocke les sources dans un format binaire.
      Ce n'était de mémoire pas un code super clean, mais bon, j'aimerai bien le revoir.

Envoyer un commentaire

Suivre le flux des commentaires

Note : les commentaires appartiennent à celles et ceux qui les ont postés. Nous n’en sommes pas responsables.