Przeciążanie funkcji

Przeciążanie funkcji

Zazwyczaj różne funkcje powinny mieć różne nazwy, ale jeśli funkcje te wykonują zasadniczo te same działania tylko na obiektach różnych typów, to wygodniejszym rozwiązaniem może być zastosowanie dla nich tych samych nazw. Nadawanie kilku operacjom działającym na różnych typach tych samych nazw nazywa się przeciążaniem (ang. overloading). Technika ta jest wykorzystywana
także w implementacji podstawowych operacji w języku C++. Przykładowo: jest tylko jedna nazwa operacji dodawania (+), a przecież operatora tego można używać do sumowania liczb całkowitych, zmiennoprzecinkowych oraz ich kombinacji. Pomysł ten można łatwo rozszerzyć na funkcje definiowane przez użytkownika. Na przykład:

void print(int); // drukuje wartość typu int
void print(const char*); // drukuje łańcuch w stylu C

Dla kompilatora jedyną cechą wspólną tych funkcji jest nazwa. Funkcje te mogą być pod jakimś względem podobne do siebie, ale język nie ogranicza ani nie wspomaga programisty w tej kwestii. Zatem przeciążanie funkcji to przede wszystkim udogodnienie notacyjne. Jest ono szczególnie przydatne w przypadku funkcji o typowych nazwach, jak sqrt, print czy open. Gdy nazwa ma semantyczne znaczenie, to przeciążanie jest wręcz niezbędne. Dotyczy to na przykład operatorów, takich jak +, * czy << w przypadku konstruktorów (16.2.5, 17.1), oraz programowania ogólnego. Szablony pozwalają w systematyczny sposób definiować zestawy funkcji przeciążonych.

Automatyczne wybieranie przeciążonych funkcji

Gdy wywołana zostaje funkcja fct, kompilator musi w jakiś sposób wybrać jedną z dostępnych funkcji o tej nazwie. W tym celu porównuje typy rzeczywistych argumentów z typami parametrów wszystkich funkcji fct dostępnych w zakresie. Chodzi o to, by wywołać funkcję, która najlepiej pasuje do przekazanych argumentów, oraz zgłosić błąd kompilacji, jeśli nie ma pasującej funkcji. Na przykład:

void print(double);
void print(long);
void f()
{
    print(1L); // print(long)
    print(1.0); // print(double)
    print(1); // błąd niejednoznaczności: print(long(1)) czy print(double(1))?
}

Przy porównywaniu wersji funkcji brane są pod uwagę następujące kryteria:

1. Dokładne dopasowanie ? jeśli dopasowanie nie wymaga konwersji lub wymaga bardzo prostych konwersji (np. nazwy tablicy na wskaźnik, nazwy funkcji na wskaźnik do funkcji i T na const T).

2. Dopasowanie przy użyciu promocji ? dotyczy promocji całkowitoliczbowych (bool na int, char na int, short na int oraz ich odpowiedniki bez znaku) oraz float na double.

3. Dopasowanie przy użyciu standardowych konwersji [np. int na double, double na int, double na long double, Pochodna* na Baza*, T* na void* oraz int na unsigned int.

4. Dopasowanie przy użyciu konwersji zdefiniowanych przez użytkownika (np. double na complex ).

5. Dopasowanie przy użyciu wielokropka (...) w deklaracji funkcji.

Jeśli zostaną znalezione dwa dopasowania, wywołanie zostaje odrzucone jako niejednoznaczne. Reguły dopasowywania są tak skomplikowane głównie z uwagi na uwzględnienie wyszukanych reguł dotyczących numerycznych typów wbudowanych w językach C i C++. Na przykład:

void print(int);
void print(const char*);
void print(double);
void print(long);
void print(char);
void h(char c, int i, short s, float f)
{
    print(c); // dokładnie dopasowanie: wywołanie print(char)
    print(i); // dokładnie dopasowanie: wywołanie print(int)
    print(s); // promocja całkowitoliczbowa: wywołanie print(int)
    print(f); // promocja float na double: print(double)
    print('a'); // dokładnie dopasowanie: wywołanie print(char)
    print(49); // dokładnie dopasowanie: wywołanie print(int)
    print(0); // dokładnie dopasowanie: wywołanie print(int)
    print("a"); // dokładnie dopasowanie: wywołanie print(const char*)
    print(nullptr); // promocja nullptr_t na const char*: wywołanie print(cost char*)
}

Wywołanie print(0) wybiera funkcję print(int), bo 0 jest typu int. Wywołanie print('a') wybiera funkcję print(char), bo 'a' jest typu char. Konwersje i promocje rozróżniamy dlatego, że preferujemy bezpieczne promocje, takie jak char na int, w stosunku do niebezpiecznych konwersji, takich jak int na char.

Na wybór wersji przeciążonej funkcji nie ma wpływu kolejność deklaracji tych funkcji. W przypadku szablonów funkcji reguły wybierania wersji są stosowane do wyniku specjalizacji na podstawie zestawu argumentów. Istnieją też osobne zasady dla przypadków, gdy użyta jest lista initializer_list (listy inicjacyjne mają pierwszeństwo, oraz dla argumentów szablonowych w postaci wartości prawostronnych.

Reguły wybierania przeciążonych funkcji są skomplikowane i czasami efekt ich zastosowania w postaci wywołania konkretnej funkcji jest zaskakujący dla programisty. Po co więc zaprzątać sobie tym głowę? Zastanówmy się, czym można zastąpić przeciążanie. Często wykonujemy podobne operacje na obiektach różnych typów. Nie stosując przeciążania, musielibyśmy
definiować po kilka takich samych funkcji, tylko o różnych nazwach:

void print_int(int);
void print_char(char);
void print_string(const char*); // łańcuch w stylu C
void g(int i, char c, const char* p, double d)
{
    print_int(i); // OK
    print_char(c); // OK
    print_string(p); // OK
    print_int(c); // OK? wywołuje print_int(int(c)), drukuje liczbę
    print_char(i); // OK? wywołuje print_char(char(i)), zawężenie
    print_string(i); // błąd
    print_int(d); // OK? wywołuje print_int(int(d)), zawężenie
}

W tym przypadku, w odróżnieniu od przeciążonej funkcji print(), musimy zapamiętać kilka nazw i nauczyć się prawidłowo ich używać. To jest żmudne, uniemożliwia programowanie ogólne oraz generalnie ściąga uwagę programisty na relatywnie niskopoziomowe kwestie. Jako że nie jest stosowane przeciążanie, używane są wszystkie standardowe konwersje argumentów.
To również może być przyczyną powstania wielu błędów. Na przykład w poprzednim przykładzie tylko jedno z czterech wywołań o wątpliwej semantyce zostałoby wykryte przez kompilator. W szczególności w dwóch z tych wywołań stosowane jest niekorzystne zawężanie. A zatem przeciążanie zwiększa szansę wykrycia i odrzucenia przez kompilator nieodpowiednich argumentów.

Przeciążanie a typ zwrotny

Typy zwrotne nie są brane pod uwagę przy wybieraniu wersji przeciążonej funkcji. Jest tak po to, aby wybieranie operatorów i funkcji było niezależne od kontekstu. Na przykład:

float sqrt(float);
double sqrt(double);
void f(double da, float fla)
{
    float fl = sqrt(da); // wywołanie sqrt(double)
    double d = sqrt(da); // wywołanie sqrt(double)
    fl = sqrt(fla); // wywołanie sqrt(float)
    d = sqrt(fla); // wywołanie sqrt(float)
}

Gdyby typy zwrotne były brane pod uwagę, to patrząc tylko na funkcję sqrt(), nie można by było stwierdzić, która funkcja została wywołana.

Przeciążanie a zakres

Przeciążanie odbywa się wśród elementów zbioru przeciążeniowego. Standardowo jest to zbiór funkcji znajdujących się w jednym zakresie. Funkcje zadeklarowane w innych zakresach nie będących przestrzeniami nazw nie są brane pod uwagę. Na przykład:

void f(int);
void g()
{
    void f(double);
    f(1); // wywołanie f(double)
}

Niewątpliwie funkcja f(int) byłaby najlepszym wyborem dla wywołania f(1), ale w branym pod uwagę zakresie znajduje się tylko funkcja f(double). W takich przypadkach można dodawać i usuwać lokalne deklaracje, aby uzyskać oczekiwany efekt. Jak zwykle celowe ukrywanie może być przydatną techniką, a niecelowe ukrywanie źródłem niespodzianek.

Klasa bazowa i klasa pochodna definiują osobne zakresy, a więc domyślnie nie ma możliwości przeciążenia funkcji między nimi. Na przykład: 

struct Base {
void f(int);
};
struct Derived : Base {
    void f(double);
};
void g(Derived& d)
{
    d.f(1); // wywołanie Derived::f(double);
}

Jeśli potrzebne jest przeciążanie między klasami lub między przestrzeniami nazw, można skorzystać z deklaracji lub dyrektyw using. W przypadku przestrzeni nazw przeciążanie można uzyskać poprzez wyszukiwanie zależne od argumentów. 

Wybieranie przeciążonych funkcji z wieloma argumentami

Zasady wyboru funkcji przeciążonej można wykorzystać do wybierania najbardziej odpowiedniej funkcji, gdy między typami występują znaczne różnice w wydajności lub precyzji. Na przykład:

int pow(int, int);
double pow(double, double);
complex pow(double, complex);
complex pow(complex, int);
complex pow(complex, complex);
void k(complex z)
{
    int i = pow(2,2); // wywołanie pow(int,int)
    double d = pow(2.0,2.0); // wywołanie pow(double,double)
    complex z2 = pow(2,z); // wywołanie pow(double,complex)
    complex z3 = pow(z,2); // wywołanie pow(complex,int)
    complex z4 = pow(z,z); // wywołanie pow(complex,complex)
}

W procesie wybierania jednej z wersji funkcji z dwoma lub większą liczbą argumentów znajdowane jest najlepsze dopasowanie dla każdego argumentu. Wywoływana jest funkcja najlepiej pasująca do jednego argumentu i lepiej lub równie dobrze pasująca do pozostałych argumentów w porównaniu z innymi funkcjami. Jeśli nie ma takiej funkcji, wywołanie zostaje odrzucone jako niejednoznaczne. Na przykład: 

void g()
{
    double d = pow(2.0,2); // błąd: pow(int(2.0),2) czy pow(2.0,double(2))?
}

To wywołanie jest niejednoznaczne, bo 2.0 najlepiej pasuje do pierwszego argumentu pow(double,double), a 2 do drugiego argumentu pow(int,int).

Ręczne wybieranie przeciążonej funkcji

Jeśli zadeklaruje się za mało (albo za dużo) przeciążonych wersji funkcji, mogą powstawać niejednoznaczne sytuacje. Na przykład:

void f1(char);
void f1(long);
void f2(char*);
void f2(int*);
void k(int i)
{
    f1(i); // niejednoznaczne: f1(char) czy f1(long)?
    f2(0); // niejednoznaczne: f2(char*) czy f2(int*)?
}

Jeśli jest to możliwe, spójrz na swój zestaw przeciążonych wersji funkcji i zastanów się, czy jest on odpowiedni, biorąc pod uwagę semantykę tej funkcji. Wiele problemów można rozwiązać poprzez dodanie wersji eliminującej niejednoznaczności. Na przykład dodanie:

inline void f1(int n) { f1(long(n)); }

wyeliminowałoby wszystkie niejednoznaczności podobne do f1(i) na rzecz większego typu long int.
Można też określić jawną konwersję typów dla konkretnego wywołania. Na przykład:

f2(static_cast(0));

To niestety jest najczęściej tylko nieeleganckie tymczasowe rozwiązanie. Wkrótce może pojawić się kolejne podobne wywołanie, z którym również trzeba będzie sobie poradzić. Niektórzy początkujący programiści C++ denerwują się, że kompilator ciągle zgłasza
powiadomienia o niejednoznacznościach. Natomiast doświadczeni programiści doceniają te komunikaty, bo wiedzą, że mogą one wskazywać na błędy projektowe.

 Język C++. Kompendium wiedzy. Wydanie IV, Autor: Bjarne Stroustrup, Wydawnictwo: Helion 

Podobne artykuły

Podziel się ze znajomymi tym artykułem - udostępnij na FB lub wyślij e-maila korzystając z poniższych opcji:

wszystkie oferty