Double dispatch produces 'hides virtual functi

2019-07-21 14:54发布

问题:

I would like to implement interactions between two objects whose types are derived from a common base class. There is a default interaction and specific things may happen once objects of the same type interact. This is implemented using the following double dispatch scheme:

#include <iostream>

class A
{
public:
  virtual void PostCompose(A* other)
    {
      other->PreCompose(this);
    }
  virtual void PreCompose(A* other)
    {
      std::cout << "Precomposing with an A object" << std::endl;
    }
};

class B : public A
{
public:
  virtual void PostCompose(A* other) // This one needs to be present to prevent a warning
    {
      other->PreCompose(this);
    }
  virtual void PreCompose(A* other) // This one needs to be present to prevent an error
    {
      std::cout << "Precomposing with an A object" << std::endl;
    }
  virtual void PostCompose(B* other)
    {
      other->PreCompose(this);
    }
  virtual void PreCompose(B* other)
    {
      std::cout << "Precomposing with a B object" << std::endl;
    }
};

int main()
{
  A a;
  B b;
  a.PostCompose(&a); // -> "Precomposing with an A object"
  a.PostCompose(&b); // -> "Precomposing with an A object"
  b.PostCompose(&a); // -> "Precomposing with an A object"
  b.PostCompose(&b); // -> "Precomposing with a B object"
}

I have two, unfortunately quite different questions regarding this code:

  1. Do you think this is a reasonable approach? Would you suggest something different?
  2. If I omit the first two B methods, I get compiler warnings and errors that the last two B methods hide the A methods. Why is that? An A* pointer should not be cast to a B* pointer, or should it?

Update: I just found out that adding

using A::PreCompose;
using A::PostCompose;

makes the errors and warnings vanish, but why is this necessary?

Update 2: This is neatly explained here: http://www.parashift.com/c++-faq-lite/strange-inheritance.html#faq-23.9, thank you. What about my first question? Any comments on this approach?

回答1:

Double dispatch is usually implemented differently in C++, with the base class having all different versions (which makes it a maintenance nightmare, but that is how the language is). The problem with your attempt to double dispatch is that dynamic dispatch will find the most derived type B of the object on which you are calling the method, but then the argument has static type A*. Since A does not have an overload that takes B* as argument, then the call other->PreCompose(this) will implicitly upcast this to A* and you are left with single dispatch on the second argument.

As of the actual question: why is the compiler producing the warnings? why do I need to add the using A::Precompose directives?

The reason for that are the lookup rules in C++. Then the compiler encounters a call to obj.member(), it has to lookup the identifier member, and it will do so starting from the static type of obj, if it fails to locate member in that context it will move up in the hierarchy and lookup in the bases of the static type of obj.

Once the first identifier is found, lookup will stop and try to match the function call with the available overloads, and if the call cannot be matched it will trigger an error. The important bit here is that lookup will not look further up in the hierarchy if the function call cannot be matched. By adding the using base::member declaration, you are bringing the identifier member from the base class into the current scope.

Example:

struct base {
   void foo( const char * ) {}
   void foo( int ) {}
};
struct derived : base {
   void foo( std::string const & ) {};
};
int main() {
   derived d;
   d.foo( "Hi" );
   d.foo( 5 );
   base &b = d;
   b.foo( "you" );
   b.foo( 5 );
   d.base::foo( "there" );
}

When the compiler encounters the expression d.foo( "Hi" ); the static type of the object is derived, and lookup will check all member functions in derived, the identifier foo is located there, and lookup does not proceed upwards. The argument to the only available overload is std::string const&, and the compiler will add an implicit conversion, so even if there could be a best potential match (base::foo(const char*) is a better match than derived::foo(std::string const&) for that call) it will effectively call:

d.derived::foo( std::string("Hi") );

The next expression d.foo( 5 ); is processed similarly, lookup starts in derived and it finds that there is a member function there. But the argument 5 cannot be converted to std::string const & implicitly and the compiler will issue an error, even if there is a perfect match in base::foo(int). Note that this is an error in the call, not an error in the class definition.

When processing the third expression, b.foo( "you" ); the static type of the object is base (note that the actual object is derived, but the type of the reference is base&), so lookup will not search in derived but rather start in base. It finds two overloads, and one of them is a good match, so it will call base::foo( const char* ). The same goes for b.foo(5).

Finally, while adding the different overloads in the most derived class hide the overloads in the base, it does not remove them from the objects, so you can actually call the overload that you need by fully qualifying the call (which disables lookup and has the added side effect of skipping dynamic dispatch if the functions were virtual), so d.base::foo( "there" ) will not perform any lookup at all and just dispatch the call to base::foo( const char* ).

If you had added a using base::foo declaration to the derived class, you would add all the overloads of foo in base to the available overloads in derived, and the call d.foo( "Hi" ); would consider the overloads in base and find that the best overload is base::foo( const char* );, so it will actually be executed as d.base::foo( "Hi" );

In many cases, developers are not always thinking on how the lookup rules actually work, and it might be surprising that the call to d.foo( 5 ); fails without the using base::foo declaration, or worse, that the call to d.foo( "Hi" ); is dispatched to derived::foo( std::string const & ) when it is clearly a worse overload than base::foo( const char* ). That is one of the reasons why compilers warn when you hide member functions. The other good reason for that warning is that in many cases when you actually intended to override a virtual function you might end up mistakenly changing the signature:

struct base {
   virtual std::string name() const {
      return "base";
   };
};
struct derived : base {
   virtual std::string name() {        // missing const!!!!
      return "derived";
   }
}
int main() {
   derived d; 
   base & b = d;
   std::cout << b.name() << std::endl; // "base" ????
}

A small mistake while trying to override the member function name (forgetting the const qualifier) means that you are actually creating a different function signature. derived::name is not an override to base::name and thus a call to name through a reference to base will not be dispatched to derived::name!!!



回答2:

using A::PreCompose;
using A::PostCompose;
makes the errors and warnings vanish, but why is this necessary?

If you add new functions to your derived class with same name as your base class contains and if you don't override the virtual functions from base class, then new names hide the old names from the base class.

That is why you need to unhide them by explicity writing:

using A::PreCompose;
using A::PostCompose;

Other way to unhide them (in this particular case) is , override the virtual functions from base class which you've done in the code you've posted. I believe that code would compile just fine.



回答3:

Classes are scopes and the look up in a base class is described as looking up in an enclosing scope.

When looking up overload of a function, looking up in enclosing scope is not done if a function was found in the nested one.

A consequence of the two rules is the behaviour you experimented. Adding the using clauses import the definition from the enclosing scope and is the normal solution.