Ta lekcja jest kontynuacją 12.10 -- Przejdź adres.
Przekazywanie adresu dla „opcjonalnych” argumentów
Jednym z bardziej powszechnych zastosowań przekazywania adresu jest umożliwienie funkcji przyjęcia „opcjonalnego” argumentu. Łatwiej to zilustrować na przykładzie niż opisać:
#include <iostream>
void printIDNumber(const int *id=nullptr)
{
if (id)
std::cout << "Your ID number is " << *id << ".\n";
else
std::cout << "Your ID number is not known.\n";
}
int main()
{
printIDNumber(); // we don't know the user's ID yet
int userid { 34 };
printIDNumber(&userid); // we know the user's ID now
return 0;
}Ten przykład wyświetla:
Your ID number is not known. Your ID number is 34.
W tym programie funkcja printIDNumber() posiada jeden parametr przekazywany przez adres i domyślnie ustawiony na nullptr. Wewnątrz main() wywołujemy tę funkcję dwukrotnie. Przy pierwszym wywołaniu nie znamy ID użytkownika, dlatego wywołujemy printIDNumber() bez argumentu. Parametr id domyślnie przyjmuje wartość nullptr, a funkcja wypisuje Your ID number is not known.. Dla drugiego wywołania mamy już poprawny identyfikator, więc wywołujemy printIDNumber(&userid). Słowo kluczowe id parametr otrzymuje adres userid, więc funkcja drukuje Your ID number is 34..
Jednak w wielu przypadkach przeciążenie funkcji jest lepszą alternatywą, aby osiągnąć ten sam wynik:
#include <iostream>
void printIDNumber()
{
std::cout << "Your ID is not known\n";
}
void printIDNumber(int id)
{
std::cout << "Your ID is " << id << "\n";
}
int main()
{
printIDNumber(); // we don't know the user's ID yet
int userid { 34 };
printIDNumber(userid); // we know the user is 34
printIDNumber(62); // now also works with rvalue arguments
return 0;
}Ma to wiele zalet: nie musimy się już martwić o dereferencje zerowe i możemy przekazywać literały lub inne rwartości jako argument.
Zmiana tego, na co wskazuje parametr wskaźnika
Kiedy przekazujemy adres do funkcji, adres ten jest kopiowany z argumentu do parametru wskaźnika (co jest w porządku, ponieważ kopiowanie adresu jest szybkie). Rozważmy teraz następujący program:
#include <iostream>
// [[maybe_unused]] gets rid of compiler warnings about ptr2 being set but not used
void nullify([[maybe_unused]] int* ptr2)
{
ptr2 = nullptr; // Make the function parameter a null pointer
}
int main()
{
int x{ 5 };
int* ptr{ &x }; // ptr points to x
std::cout << "ptr is " << (ptr ? "non-null\n" : "null\n");
nullify(ptr);
std::cout << "ptr is " << (ptr ? "non-null\n" : "null\n");
return 0;
}Ten program wypisuje:
ptr is non-null ptr is non-null
Jak widać zmiana adresu utrzymywanego przez parametr wskaźnika nie miała wpływu na adres utrzymywany w argumencie (ptr nadal wskazuje x). Gdy funkcja nullify() ptr2 otrzymuje kopię przekazanego adresu (w tym przypadku adresu posiadanego przez ptr, czyli adresu x). Kiedy funkcja zmienia to, na co ptr2 wskazuje, wpływa to tylko na kopię przechowywaną przez ptr2.
A co jeśli chcemy pozwolić funkcji na zmianę tego, na co wskazuje argument wskaźnika?
Przekazywanie adresu… przez odniesienie?
Tak, to jest coś. Podobnie jak możemy przekazać zwykłą zmienną przez referencję, możemy również przekazywać wskaźniki przez referencję. Oto ten sam program co powyżej, z ptr2 zmienionym tak, aby był referencją do adresu:
#include <iostream>
void nullify(int*& refptr) // refptr is now a reference to a pointer
{
refptr = nullptr; // Make the function parameter a null pointer
}
int main()
{
int x{ 5 };
int* ptr{ &x }; // ptr points to x
std::cout << "ptr is " << (ptr ? "non-null\n" : "null\n");
nullify(ptr);
std::cout << "ptr is " << (ptr ? "non-null\n" : "null\n");
return 0;
}Ten program wypisuje:
ptr is non-null ptr is null
Ponieważ refptr jest teraz referencją do wskaźnika, gdy ptr jest przekazywany jako argument, refptr jest związany do ptr. Oznacza to, że wszelkie zmiany w refptr są wprowadzane w ptr.
Na marginesie…
Ponieważ odniesienia do wskaźników są dość rzadkie, łatwo może być pomieszanie składni (czy jest to int*& lub int&*?). Dobra wiadomość jest taka, że jeśli zrobisz to od tyłu, kompilator popełni błąd, ponieważ nie możesz mieć wskaźnika do referencji (ponieważ wskaźniki muszą przechowywać adres obiektu, a referencje nie są obiektami). Następnie możesz to zmienić.
Dlaczego używanie 0 lub NULL nie jest już preferowane (opcjonalne)
W tym podrozdziale wyjaśnimy, dlaczego używanie 0 lub NULL nie jest już preferowane.
Literał 0 można interpretować albo jako literał całkowity, albo jako literał wskaźnika zerowego. W niektórych przypadkach może być niejednoznaczne, które z nich zamierzamy - i w niektórych z tych przypadków kompilator może założyć, że mamy na myśli jedno, mając na myśli drugie - z niezamierzonymi konsekwencjami dla zachowania naszego programu.
Definicja makra preprocesora NULL nie jest zdefiniowana w standardzie językowym. Można ją zdefiniować jako 0, 0L, ((void*)0) lub coś zupełnie innego.
W lekcji 11.1 -- Wprowadzenie do funkcji przeciążenie, omawialiśmy, że funkcje mogą być przeciążane (wiele funkcji może mieć tę samą nazwę, o ile można je rozróżnić liczbą lub rodzajem parametrów). Kompilator może dowiedzieć się, jakiej przeciążonej funkcji potrzebujesz na podstawie argumentów przekazanych w ramach wywołania funkcji.
Podczas używania 0 lub NULL, może to powodować problemy:
#include <iostream>
#include <cstddef> // for NULL
void print(int x) // this function accepts an integer
{
std::cout << "print(int): " << x << '\n';
}
void print(int* ptr) // this function accepts an integer pointer
{
std::cout << "print(int*): " << (ptr ? "non-null\n" : "null\n");
}
int main()
{
int x{ 5 };
int* ptr{ &x };
print(ptr); // always calls print(int*) because ptr has type int* (good)
print(0); // always calls print(int) because 0 is an integer literal (hopefully this is what we expected)
print(NULL); // this statement could do any of the following:
// call print(int) (Visual Studio does this)
// call print(int*)
// result in an ambiguous function call compilation error (gcc and Clang do this)
print(nullptr); // always calls print(int*)
return 0;
}Na maszynie autora (przy użyciu Visual Studio) zostanie wypisane:
print(int*): non-null print(int): 0 print(int): 0 print(int*): null
Podczas przekazywania wartości całkowitej 0 jako parametru kompilator preferuje print(int) za print(int*). Może to prowadzić do nieoczekiwanych rezultatów, gdy zamierzyliśmy print(int*) wywoływany z argumentem wskaźnika zerowego.
W przypadku, gdy NULL jest zdefiniowany jako wartość 0, print(NULL) wywoła również print(int), nie print(int*) jak można się spodziewać w przypadku literału wskaźnika zerowego. W przypadkach, gdy NULL nie jest zdefiniowany jako 0, może nastąpić inne zachowanie, takie jak wywołanie print(int*) lub błąd kompilacji.
Użycie nullptr usuwa tę niejednoznaczność (zawsze będzie wywoływać print(int*)), ponieważ nullptr będzie pasować tylko do typu wskaźnika.
std::nullptr_t (opcjonalnie)
Ponieważ nullptr można odróżnić od wartości całkowitych w przeciążeniach funkcji, musi mieć inny typ. Jaki więc jest typ nullptr? Odpowiedź jest taka, że nullptr ma typ std::nullptr_t (zdefiniowane w nagłówku <cstddef>). std::nullptr_t może zawierać tylko jedną wartość: nullptr! Choć może się to wydawać trochę głupie, jest przydatne w jednej sytuacji. Jeśli chcemy napisać funkcję, która akceptuje tylko argument nullptr literalny, możemy ustawić parametr jako std::nullptr_t.
#include <iostream>
#include <cstddef> // for std::nullptr_t
void print(std::nullptr_t)
{
std::cout << "in print(std::nullptr_t)\n";
}
void print(int*)
{
std::cout << "in print(int*)\n";
}
int main()
{
print(nullptr); // calls print(std::nullptr_t)
int x { 5 };
int* ptr { &x };
print(ptr); // calls print(int*)
ptr = nullptr;
print(ptr); // calls print(int*) (since ptr has type int*)
return 0;
}W powyższym przykładzie wywołanie funkcji print(nullptr) rozwiązuje funkcję print(std::nullptr_t) za print(int*) , ponieważ nie wymaga konwersji.
Jedynym przypadkiem, który może być nieco mylący, jest wywołanie funkcji print(ptr) gdy ptr wartość nullptr. Pamiętaj, że przeciążanie funkcji dopasowuje typy, a nie wartości i ptr ma typ int*. Dlatego print(int*) zostanie dopasowane. print(std::nullptr_t) w tym przypadku nie jest nawet brane pod uwagę, ponieważ typy wskaźników nie zostaną domyślnie przekonwertowane na a std::nullptr_t.
Prawdopodobnie nigdy nie będziesz musiał tego używać, ale dobrze jest wiedzieć na wszelki wypadek.
Jest tylko przekazywanie wartości
Teraz jeśli rozumiesz podstawowe różnice pomiędzy przekazywaniem przez referencję, adres i wartość, przejdźmy na chwilę do redukcjonizmu. :)
Chociaż kompilator często może całkowicie zoptymalizować odniesienia, istnieją przypadki, w których nie jest to możliwe i faktycznie potrzebne jest odniesienie. Odniesienia są zwykle implementowane przez kompilator za pomocą wskaźników. Oznacza to, że w tle przekazywanie przez referencję to w zasadzie tylko przekazywanie adresu.
W poprzedniej lekcji wspomnieliśmy, że przekazywanie adresu po prostu kopiuje adres od osoby wywołującej do wywoływanej funkcji - co oznacza po prostu przekazywanie adresu przez wartość.
Możemy zatem stwierdzić, że C++ naprawdę przekazuje wszystko według wartości! Właściwości przekazywania adresu (i odniesienia) wynikają wyłącznie z faktu, że możemy wyłuskać przekazany adres, aby zmienić argument, czego nie możemy zrobić w przypadku parametru o normalnej wartości!

