Dane są klasy Base i Derived zdefiniowane jak poniżej:

#include <iostream>

class Base
{
public:
   virtual void printNumber(int n = 7)
   {
      std::cout << "Base::printNumber(int): " << n << '\n';
   }
};

class Derived : public Base
{
public:
   virtual void printNumber(int n = 42)
   {
      std::cout << "Derived::printNumber(int): " << n << '\n';
   }
};

int main()
{
   Base *b = new Derived;
   
   b->printNumber();
   
   delete b;
}

Uruchom w edytorze

Tworzymy na stercie obiekt typu Derived i reprezentujący go wskaźnik przypisujemy do wskaźnik typu Base *. Z pewnością mamy tu polimorficzne wywołanie  metody printNumber (wiązanie dynamiczne), zatem spodziewamy się następującego outputu:

Derived::printNumber(int): 42

Wiązanie Statyczne

Uruchomienie powyższego kodu skutkuje nieco innym outputem niż wcześniej założony:

Derived::printNumber(int): 7

Wywołana została prawidłowa metoda printNumber, czyli ta z klasy pochodnej Derived. Oznacza to, że wiązanie dynamiczne (ang. dynamic binding) zadziałało w tym przypadku zgodnie z oczekiwaniami (wywołanie polimorficzne metody). Inaczej jednak stało się z argumentem domyślnym tej metody – ten został bowiem ”wybrany” z klasy bazowej Base. Mamy więc w jednym miejscu ”pomieszane” wiązanie dynamiczne dla wywołanej metody (oczekiwane) oraz wiązanie statycznej dla jej argumentu domyślnego (nieoczekiwane). Jest to więc problem podobny jak w przypadku funkcji wirtualnych wywołanych w konstruktorze lub destruktorze, dla których zawsze wtedy występuje wiązane statycznie. Temat ten omawiałem w jednym z poprzednich artykułów: Kiedy Dynamiczne Wiązanie Typu Zawodzi Czyli Wirtualne Funkcje w Konstruktorze i Destruktorze.

Alternatywy

Generalnie należy unikać kodu, który wprowadza nieoczekiwane wiązanie statyczne (ang. static binding). W tym przypadku najlepiej jest unikać argumentów domyślnych dla funkcji wirtualnych, ponieważ może się to skończyć trudnymi w debugowaniu błędami w czasie wykonywania programu (ang. runtime). Będziemy mieć szczęście jeśli skończy się na błędzie kompilacji, który w powyższym kodzie wystąpi jeśli usuniemy argument domyślny dla metody printNumber z klasy Base:

#include <iostream>

class Base
{
public:
   virtual void printNumber(int n)
   {
      std::cout << "Base::printNumber(int): " << n << '\n';
   }
};

class Derived : public Base
{
public:
   virtual void printNumber(int n = 42)
   {
      std::cout << "Derived::printNumber(int): " << n << '\n';
   }
};

int main()
{
   Base *b = new Derived;
   
   b->printNumber();
   
   delete b;
}
main.cpp: In function ‘int main()’:
main.cpp:26:19: error: no matching function for call to ‘Base::printNumber()’
    b->printNumber();
                   ^
main.cpp:6:17: note: candidate: virtual void Base::printNumber(int)
    virtual void printNumber(int n)
                 ^~~~~~~~~~~
main.cpp:6:17: note:   candidate expects 1 argument, 0 provided

Jeżeli nie można uniknąć argumentów domyślnych, możliwym (ale naiwnym) rozwiązaniem mogłoby być konsekwentne powtarzanie tej samej wartości argumentu domyślnego (wgłąb hierarchii dziedziczenia) dla określonej funkcji wirtualnej, poczynając od klasy bazowej, a kończąc na klasie pochodnej:

#include <iostream>

class Base
{
public:
   virtual void printNumber(int n = 42)
   {
      std::cout << "Base::printNumber(int): " << n << '\n';
   }
};

class Derived : public Base
{
public:
   virtual void printNumber(int n = 42)
   {
      std::cout << "Derived::printNumber(int): " << n << '\n';
   }
};

int main()
{
   Base *b = new Derived;
   
   b->printNumber();
   
   delete b;
}

Uruchom w edytorze

Derived::printNumber(int): 42

Jest to jednak bardzo złe podejście, ponieważ wcześniej czy później, programiści z pewnością przestaną podążać za tą wytyczną. Istotnym problemem jest tutaj coraz trudniejsze utrzymywanie takiego kodu, bowiem zmiana wartości argumentu domyślnegoklasie bazowej spowoduje konieczność analogicznej modyfikacji wszystkich klas pochodnych. Jest to więc rozwiązanie, które bardzo źle się skaluje.

Lepszym rozwiązaniem będzie wyspecyfikowanie argumentu domyślnego tylko raz dla publicznej niewirtualnej funkcji klasie bazowej. Funkcja taka będzie dziedziczona przez klasy pochodne, ale jako niewirtualna nigdy nie powinna być redefiniowana (w przeciwieństwie do funkcji wirtualnych). Funkcja taka stanowi opakowanie (ang. wrapper) odpowiedzialne za wywołanie wiązanych dynamiczne odpowiednich funkcji wirtualnych, które jednocześnie nie muszą już obsługiwać argumentów domyślnych. Dzięki takiemu rozwiązaniu wymuszamy świadomie wiązanie statyczne tylko w jednym i bezpiecznym miejscu – niewirtualnej metodzie klasy bazowej:

#include <iostream>

class Base
{
public:    
   void printNumber(int n = 42)
   {
      this->printNumberImpl(n);
   }
    
protected:
   virtual void printNumberImpl(int n)
   {
      std::cout << "Base::printNumberImpl(int): " << n << '\n';
   }
};

class Derived : public Base
{
protected:
   virtual void printNumberImpl(int n)
   {
      std::cout << "Derived::printNumberImpl(int): " << n << '\n';
   }
};

int main()
{
   Base *b = new Derived;
   
   b->printNumber();
   
   delete b;
}

Uruchom w edytorze

Derived::printNumberImpl(int): 42

Technika ta znana jest jako wzorzec niewirtualnego interfejsu – NVI (ang. non-virtual interface). Zauważ iż funkcje wirtualnechronione (protected), a więc niedostępne z zewnątrz (w tym kontekście zachowują się jak private), a jedyną  publiczną (public) metodą jest właśnie funkcja niewirtualna stanowiąca nasz interfejs.

Podsumowanie

  • Argumenty domyślne wiązanie są statycznie
  • Należy unikać argumentów domyślnych dla funkcji wirtualnych
  • Stosuj argumenty domyślne wyłącznie dla funkcji niewirtualnych (wzorzec niewirtualnego interfejsu)