Partage
  • Partager sur Facebook
  • Partager sur Twitter

Downcast et comparaison d'adresses

    17 janvier 2023 à 11:05:05

    Hello ! :)

    J'aurais voulu comparer que deux pointeurs (des shared_ptr dans mon cas) contiennent le même objet. Mais l'un deux a subi un downcast et les adresses divergent quelque peu dû à un héritage multiple.

    J'ai réussi à reproduire ce comportement dans ce petit exemple :

    #include <iostream>
    #include <memory>
    
    class A {
    private:
        int val;
    
    public:
        explicit A(int v) : val(v) {}
        virtual ~A() = default;
    };
    
    class B {
    public:
        virtual ~B() = default;
    };
    
    class C : public A, public B {
    public:
        explicit C(int v) : A(v) {}
    };
    
    int main()
    {
        std::shared_ptr<B> s = std::make_shared<C>(3);
        const auto s2 = std::dynamic_pointer_cast<C>(s);
        std::cout << "(1) " << s.get() << std::endl;
        std::cout << "(2) " << s2.get() << std::endl;
    
        std::shared_ptr<B> t = std::make_shared<C>(3);
    
        std::cout << std::boolalpha;
        std::cout << "(3) " << s.get() << " == " << s2.get() << " / " << (s == s2) << " / " << (s.get() == s2.get()) << std::endl;
        std::cout << "(4) " << s.get() << " == " << t.get() << " / " << (s == t) << " / " << (s.get() == t.get()) << std::endl;
    }

    Qui me donne la sortie suivante :

    (1) 0xcdec40
    (2) 0xcdec30
    (3) 0xcdec40 == 0xcdec30 / true / true
    (4) 0xcdec40 == 0xcdfc80 / false / false

    Les adresses après downcast `(1)` et `(2)` ne sont pas les mêmes. Cela vient de l'ordre dans l'héritage multiple si j'ai bien compris, les positions de A et C sont les mêmes, mais les membres de B sont stockés après ceux de A en mémoire.

    Cependant, malgré ce léger décalage, les comparaisons faites en `(3)` renvoient `true`, contrairement à celles faites en `(4)` avec un tout autre objet.
    Question : Est-ce que ce type de comparaison après downcast est safe ? Le true assure-t-il bien que l'objet est le même malgré les adresses différentes ? Et si oui, comment ça se fait, il compare les adresses d'origine du type dynamique ?

    Merci ! ;)

    -
    Edité par Maluna34 17 janvier 2023 à 11:07:14

    • Partager sur Facebook
    • Partager sur Twitter
      17 janvier 2023 à 12:56:00

      Normal. D'autant que tu as des bases multiples de même type.

      C'est volontaire la duplication des A?

      • Partager sur Facebook
      • Partager sur Twitter
      C++: Blog|FAQ C++ dvpz|FAQ fclc++|FAQ Comeau|FAQ C++lite|FAQ BS| Bons livres sur le C++| PS: Je ne réponds pas aux questions techniques par MP.
        17 janvier 2023 à 14:33:29

        Pardon. J'avais lu trop rapidement et croyais que B dérivait aussi de A. Et le tout non virtuellement.

        Pour moi comparer s et s2 n'a aucun sens, les pointeurs ne sont pas de même type. Possible que le shared_ptr voit qu'ils partagent la meme donnée tout simplement. J'avoue ne pas connaitre les détails sur ce point -- et pas plus intéressé que cela dans la mesure où je trouve que comparer des objets de types différents est très casse-gueule.

        • Partager sur Facebook
        • Partager sur Twitter
        C++: Blog|FAQ C++ dvpz|FAQ fclc++|FAQ Comeau|FAQ C++lite|FAQ BS| Bons livres sur le C++| PS: Je ne réponds pas aux questions techniques par MP.
          17 janvier 2023 à 15:26:26

          Merci pour la réponse ! :)
          Effectivement, il y a peut être un problème de logique dans la comparaison. Dans ce cas, je vais peut être préciser la démarche.

          Pour faire un parallèle avec ce que j'ai, j'ai une méthode `std::shared_ptr<ElementA> parseA();` qui parse et me retourne un element A, et l'ajoute à une liste polymorphique d'éléments en interne.
          La comparaison que je cherche à faire est dans mes tests unitaires. Je cherche à la suite d'un appel, à tester que mon élément A retourné contient bien l'ensemble des données membres correspondantes aux données parsées, et que dans la liste j'ai un élément de type A qui contient lui aussi l'ensemble des membres.

          std::shared_ptr<A> parseA() {
              list.push_back(a);
              return a;
          }
          
          void testParseA() {
              const auto elementA = parseA();
              // Check all members of elementA
          
              CHECK(list.size() == 1);
              // Check all members of stored element again
          }

          Je voulais donc m'assurer dans ces tests, que l'élément retourné (std::shared_ptr<A>) était le même que l'élément stocké (std::shared_ptr<Element>) pour éviter de vérifier l'ensemble des membres de mon objet 2 fois. D'où le test de deux types différents. Mais il y a peut être quelque chose dans le fonctionnement qui ne va pas.

          -
          Edité par Maluna34 17 janvier 2023 à 15:27:23

          • Partager sur Facebook
          • Partager sur Twitter
            17 janvier 2023 à 16:25:15

            C'est le genre de détails qui peut varier avec les options de compilation ou du compilateur lui-même. (RTTI etc...)

            Pour savoir comment c'est implémenté, vous pouvez regarder dans le code source de votre STL, non ?

            • Partager sur Facebook
            • Partager sur Twitter
            Je recherche un CDI/CDD/mission freelance comme Architecte Logiciel/ Expert Technique sur technologies Microsoft.
              17 janvier 2023 à 22:04:06

              Les types de s et s2 sont les mêmes par le parent commun: B. s2 de type C est upcasté implicitement en B puis la comparaison se fait sur 2 type B qui ont alors la même adresse. Ce n'est pas un downcast contraire à B -> C qu'il faut rendre explicite avec dynamic_cast.

              • Partager sur Facebook
              • Partager sur Twitter
                18 janvier 2023 à 1:29:04

                Maluna34 a écrit:

                Question : Est-ce que ce type de comparaison après downcast est safe ? Le true assure-t-il bien que l'objet est le même malgré les adresses différentes ? Et si oui, comment ça se fait, il compare les adresses d'origine du type dynamique ?

                Comme l'a indiqué jo_link_noir, quand tu compares (s.get() == s2.get()), c'est juste un égal entre 2 pointeurs. Et le comportement comme tout opérateur entre des types de base, on convertit vers le type compatible (ici la base B*) et on compare les valeurs qui désignent bien le même B.
                Donc sans ambiguïté dans ce cas, si l'égalité réussit c'est que c'est le même objet, si elle échoue les objets sont bien distincts.

                Mais, dans un cas d'un objet complexe. Le test peut retourner false avec 2 shared_ptr<> désignant pourtant le même objet.

                struct A {
                   int x = 0;
                   virtual ~A() = default;
                };
                struct B {
                   virtual ~B() = default;
                };
                struct D : private A {
                   A&  ad() { return *this; } // accès à la base private
                };
                struct C : public D, public A, public B {
                };
                
                int  main()
                {
                   std::shared_ptr<B>  bc = std::make_shared<C>();
                   auto  c = std::dynamic_pointer_cast<C>(bc);
                   std::shared_ptr<A>  ac = c;                                 // ici c'est le A visible
                   std::shared_ptr<A>  adc = std::shared_ptr<A>(c, &c->ad());  // force le pointeur sur le A caché dans D
                
                   std::cout << bc.get() << '\n';
                   std::cout << c.get() << '\n';
                   std::cout << ac.get() << '\n';
                   std::cout << adc.get() << '\n';
                
                   std::cout << std::boolalpha << (bc.get() == c.get()) << '\n';
                   std::cout << std::boolalpha << (ac.get() == c.get()) << '\n';
                   std::cout << std::boolalpha << (adc.get() == c.get()) << '\n';
                }

                Les 4 shared_ptr ici correspondent bien à l'unique objet C créé. Pourtant, le dernier test échoue. Et en plus ici, c.get() à la même valeur numérique que adc.get(), mais on a adc.get() != c.get() (et oui c.get() est convertit dans la base A* visible, les 2 A* sont différents!)
                L'exemple est tordu (double base dont une est private, création de adc en forçant le get() sur la base A private). Donc comparer les get() est risqué. Il faudrait comparer l'objet réel mais aucune fonction n'y donne accès. On peut contourner en écrivant:

                   std::cout << (c && bc && !bc.owner_before(c) && !c.owner_before(bc)) << '\n';    // bc n'est ni avant ni après c, c'est le même
                   std::cout << (c && ac && !ac.owner_before(c) && !c.owner_before(ac)) << '\n';    // ok idem
                   std::cout << (c && adc && !adc.owner_before(c) && !c.owner_before(adc)) << '\n'; // ok idem
                • Partager sur Facebook
                • Partager sur Twitter

                En recherche d'emploi.

                  18 janvier 2023 à 3:25:09

                  Je pense que tu compiles avec msvc, parce que gcc ou clang refuse le code à cause de l’ambiguïté d'accès sur A. La visibilité n'est pas un facteur prit en compte pour lever cette ambiguïté. J'ai fait quelques tests avec msvc sur godbolt et du moment qu'il y a un A public, il le prend.

                  <source>:14:22: warning: direct base 'A' is inaccessible due to ambiguity:
                      struct C -> struct D -> struct A
                      struct C -> struct A [-Winaccessible-base]
                  
                  • Partager sur Facebook
                  • Partager sur Twitter
                    18 janvier 2023 à 7:32:23

                    Salut,

                    Ceci dit, je ne sais pas ce que tu veux faire exactement, mais ton code est conceptuellement foireux, et ce, pour une raison bien simple : dés que l'héritage entre en ligne de compte, dés que tu pars sur une hiérarchie de classes, tes classes prennent ce que l'on appelle une "sémantique d'entité".

                    Pour faire simple, il s'agit  de classes pour lesquelles on ne veut surtout pas risquer de se retrouver, à un moment quelconque de l'exécution, avec deux instances en mémoire qui représenteraient exactement la même donnée.

                    On va donc, classiquement, prendre deux à trois mesures de bases:

                    • empêcher la copie : cela se fait très facilement depuis C++11 car il suffit de déclarer le constructeur de copie comme étant delete ( ex: MaClass(MaClasse const &) = delete )
                    • empêcher l'affectation : cela se fait tout aussi facilement car il suffit de déclarer l'opérateur d'affectation comme étant delete (ex MaClasse & operator=(MaClasse const &) = delete)
                    • éventuellement, fournir -- dans la classe de base -- un comportement qui permettra de comparer d'accéder à un attritbut comparable de la classe dont la valeur sera forcément différent pour chaque instance et qui pourra donc servir "d'identifiant" de l'instance (ex: le numéro de compte pour un compte bancaire, le numéro de chassis pour un véhicule, le numéro de registre nationnal pour une personne, ... )

                    La troisième mesure n'étant nécessaire que si tu es dans une situation dans laquelle tu dois pouvoir rechercher "régulièrement" une instance particulière parmis toutes celles qui existent, ou si tu dois régulièrement pouvoir t'assurer que tu travaille bien sur une instance particulière.

                    Ce qui importe dans tout ceci, c'est que tu n'as -- a priori -- absolument jamais besoin de faire une comparaison "globale" de deux instances, que ce soit par leur adresse mémoire ( le pointeur récupéré d'un pointeur intelligent) ou par comparaison de la donnée elle-même (qui sera de toutes manières refusée par le compilateur au motif que l'opérateur de comparaison == n'est pas défini "de base" pour la classe).

                    De plus, il faut bien comprendre que, lorsque l'héritage multiple entre en ligne de compte comme la situaiton que tu nous présente ici, les deux classes de bases n'ont absolument aucune raison de se connaitre: un A est un A et un B est un B.

                    Si tu connait une donnée comme étant "un (pointeur sur) B" comme c'est le cas ici, il n'y a absolument aucune raison que le compilateur sache quoi que ce soit des classes dérivées (de la classe C dans le cas présent), et donc que l'instance de B à laquelle tu essaye d'accéder (qui est en réalité un C) hérite bel et bien de A.

                    Or, le recours au fonctionnalités de transtypage (qu'il s'agisse de reinterpret_cast, de static_cast, de dynamic_cast ou de const_cast) offertes par le C++, ou pire de celle offerte par le C (le fameux (UnType * ) ) doit -- par principe -- être réservé aux cas "d'absolue nécessité" et devrait n'apparaitre dans ton code que de manière très umitée.

                    Pour ta propre tranquilité d'esprit, si tu as recours à l'héritage multiple (multiple), il est donc "plus que conseillé" -- si tu veux pouvoir profiter des fonctionnalités offertes par les différentes classes de base -- de maintenir les différentes instances en mémoire sous la forme de pointeurs sur ... la première classe qui hérite effectivement de toutes les classes de base (ou d'une des classes qui en dérivent s'il échoit).

                    Dans le cas présent, tu devrais donc utiliser des std::shared_ptr<C>, car cela t'évitera de devenir chauve avant l'age ;).

                    Ah, et tant qu'à faire...

                    Il faut savoir que la sémantique associée à l'héritage est totalement différente s'il s'agit d'un héritage public de s'il s'agit d'un hérage privé:

                    L'héritage publique implique qu'une instance de  la classe dérivée est "substituable à n'importe quelle instance de la classe de base" aux terme du LSP (Liskov Substitution Principle ou principe de substitution de Liskov), alors que l'héritage privé implique que la classe dérivée est "implémentée dans les termes de" la classe de base.

                    L'héritage public (qui est la forme d'héritage dont on parle généralement "par abus de langage") va donc te permettre de fournir un pointeur ou une référence sur l'instance de la classe dérivée à tout ce qui s'attend à manipuler un pointeur ou une référence de la classe de base, alors que l'héritage privé va simplement faire en sorte que les fonctions de la classe dérivées puissent accéder à tout ce qui est public ou protégé dans la classe de base (est également composée de tout ce qui est privé dans cette classe, même si ce n'est pas diréctement accessible).

                    On ne peut donc pas changer impunément la visibilité de l'héritage "sur un coup de tête", car la manoeuvre change totalement le résultat obtenu ;)

                    • Partager sur Facebook
                    • Partager sur Twitter
                    Ce qui se conçoit bien s'énonce clairement. Et les mots pour le dire viennent aisément.Mon nouveau livre : Coder efficacement - Bonnes pratiques et erreurs  à éviter (en C++)Avant de faire ce que tu ne pourras défaire, penses à tout ce que tu ne pourras plus faire une fois que tu l'auras fait

                    Downcast et comparaison d'adresses

                    × Après avoir cliqué sur "Répondre" vous serez invité à vous connecter pour que votre message soit publié.
                    × Attention, ce sujet est très ancien. Le déterrer n'est pas forcément approprié. Nous te conseillons de créer un nouveau sujet pour poser ta question.
                    • Editeur
                    • Markdown