Name hiding
Name hiding (ukrywanie nazw) to dość zaskakująca cecha z którą spotyka się prędzej czy później każdy programista C++.
Weźmy pod uwagę poniższy kod:
struct Base {
void foo(std::string) { std::cout << "foo(string)" << std::endl; }
};
struct Derived : public Base {
void foo(int) { std::cout << "foo(int)" << std::endl; }
};
int main() {
Derived d;
d.foo("abc");
}
Intuicyjnie kod powinien się skompilować i wypisać na standardowe wyjście nazwę
wywołanej funkcji, w tym przypadku "foo(string)"
. Tak się jednak nie stanie,
ponieważ zgodnie z zasadami języka deklaracja funkcji w scopie podrzędnym
przykrywa (lub ukrywa, jak kto woli) deklarację w scopie nadrzędnym. Wobec tego
powyższy program nie dość, że nie wypisze “poprawnej” funkcji (z punktu widzenia
zdrowego na rozumie programisty), to na dodatek w ogóle się nie skompiluje, gdyż
kompilator nie zna żadnej konwersji z inta na stringa!
Dlaczego tak się dzieje?
Zanim opiszę sposoby umożliwiające wywołanie funkcji foo
z klasy bazowej,
powiem o powodzie, dla którego została ona ukryta.
Niektórzy próbują w jakiś sposób racjonalizować tę cechę języka C++. Nie będę przytaczał w tym miejscu konkretnych przykładów (są dostępne np. tu i tu), gdyż według mnie są całkowicie niepoprawne.
W moim odczuciu uzasadnieniem przykrywania metod jest fakt, że nie jest to cecha specyficzna jedynie dla klas po sobie dziedziczących, ale generalna dla normalnego przeszukiwania nazw (unqualified name lookup). Przypomnijmy sobie z grubsza jak się ono odbywa:
- programista żąda wywołania funkcji
foo
; - kompilator w celu odnalezienia funkcji o nazwie
foo
sprawdzi bieżący zakres (funkcję, klasę, namespace, itd…); - jeśli w bieżącym zakresie nie zostanie odnaleziona deklaracja funkcji
foo
, sprawdzane kolejno będą zakresy nadrzędne, aż do skutku (czyli maksymalnie do zakresu globalnego); - gdy zostanie odnaleziona funkcja
foo
lub pewna ilość jej przeładowań, kompilator przestaje sprawdzać następne zakresy nadrzędne, a do gry wchodzą kolejne cechy C++ takie jak sprawdzanie przeładowań pod kątem najlepszego dopasowania1.
Wynika to z faktu, że wywołując funkcję wewnątrz danego zakresu zazwyczaj mamy
na myśli użycie funkcji zadeklarowanej w tymże zakresie. Na przykład jeśli
zadeklarujemy funkcję bar::find
, to wewnątrz zakresu bar raczej będziemy
chcieli używać jej, niż jakiejkolwiek innej (np. z używanej przez nas, napisanej
w C biblioteki libHardcoreFindAlgorithms). Co więcej, dzięki przykryciu
funkcji find
z zakresów nadrzędnych, funkcja bar::find
może mieć dokładnie
taką samą sygnaturę, czyli być czymś w rodzaju specjalizacji.
Mam nadzieję, że następujący przykład doskonale to zilustruje:
namespace bar {
void foo(char) { }
namespace baz {
void foo(int) { }
void call() { foo('a'); }
}
}
int main() {
// call() wywoła foo(int), chociaż foo(char) mogłoby być
// najlepszym dopasowaniem
bar::baz::call()
}
Jak uwzględnić deklaracje ze scope’ów nadrzędnych?
Wróćmy do naszego pierwszego przykładu. Aby wywołać funkcję Base::foo(string)
możemy użyć następującej składni:
int main() {
Derived d;
d.Base::foo("abc");
}
Zanim jednak rzucimy się do edytorów tekstu, odpowiedzmy sobie na jedno bardzo
ważne pytanie: kiedy ostatnio widzieliśmy taką składnię? Poza ezoterycznymi
przykładami znalezionymi na wątpliwych blogach internetowych, nie sądzę aby
cieszyła się ona większą popularnością. Z tego powodu powinniśmy użyć innej
składni - dyrektywy using
. Pisząc using Base::foo;
udostępniamy wszystkie
przeładowania funkcji Base::foo
jako możliwe do wzięcia pod uwagę przy
przeszukiwaniu nazw w bieżącym zakresie. Zatem modyfikując klasę Derived
otrzymamy co następuje:
struct Derived : public Base {
using Base::foo;
void foo(int) { std::cout << "foo(int)" << std::endl; }
};
-
Oczywiście name lookup jest nieco bardziej skomplikowany; uwzględnia na przykład fakt istnienia funkcji szablonowych (czyt.: nie są one powodem przerwania przeszukiwania). Gdy w wyniku normalnego przeszukiwania nie zostanie odnaleziona odpowiednia funkcja, do gry wchodzi ADL, ale to już oddzielny temat… ↩