Zmienne lokalne
Zmienne zdefiniowane wewnątrz treści funkcji nazywane są zmienne lokalne (w przeciwieństwie do zmiennymi globalnymi, które omówimy w następnym rozdziale):
int add(int x, int y)
{
int z{ x + y }; // z is a local variable
return z;
}Parametry funkcji są również ogólnie uważane za zmienne lokalne i uwzględnimy je jako takie:
int add(int x, int y) // function parameters x and y are local variables
{
int z{ x + y };
return z;
}W tej lekcji przyjrzymy się bardziej szczegółowo niektórym właściwościom zmiennych lokalnych.
Czas życia zmiennej lokalnej
W lekcji 1.3 -- Wprowadzenie do obiektów i zmiennych, omówiliśmy, w jaki sposób definicja zmiennej, taka jak int x; powoduje utworzenie instancji zmiennej podczas wykonywania tej instrukcji. Parametry funkcji są tworzone i inicjowane podczas wprowadzania funkcji, a zmienne w treści funkcji są tworzone i inicjowane w momencie definicji.
Na przykład:
int add(int x, int y) // x and y created and initialized here
{
int z{ x + y }; // z created and initialized here
return z;
}Naturalne pytanie uzupełniające brzmi: „więc kiedy utworzona zmienna zostaje zniszczona?”. Zmienne lokalne są niszczone w odwrotnej kolejności na końcu zestawu nawiasów klamrowych, w których są zdefiniowane (lub w przypadku parametru funkcji, na końcu funkcji).
int add(int x, int y)
{
int z{ x + y };
return z;
} // z, y, and x destroyed herePodobnie jak życie człowieka definiuje się jako czas pomiędzy jego narodzinami a śmiercią, życie obiektu definiuje się jako czas pomiędzy jego utworzeniem a zniszczeniem. Należy pamiętać, że tworzenie i niszczenie zmiennych ma miejsce podczas działania programu (tzw. czasie wykonywania), a nie w czasie kompilacji. Dlatego czas życia jest właściwością środowiska wykonawczego.
Dla zaawansowanych czytelników
Powyższe zasady dotyczące tworzenia, inicjalizacji i niszczenia są gwarancją. Oznacza to, że obiekty muszą zostać utworzone i zainicjowane nie później niż w momencie definicji i zniszczone nie wcześniej niż na końcu zestawu nawiasów klamrowych, w których są zdefiniowane (lub, w przypadku parametrów funkcji, na końcu funkcji).
W rzeczywistości specyfikacja C++ daje kompilatorom dużą elastyczność w określaniu, kiedy zmienne lokalne są tworzone i niszczone. Obiekty mogą być tworzone wcześniej lub niszczone później w celach optymalizacji. Najczęściej zmienne lokalne są tworzone w momencie wejścia do funkcji i niszczone w odwrotnej kolejności tworzenia w momencie wyjścia z funkcji. Omówimy to bardziej szczegółowo w przyszłej lekcji, kiedy będziemy mówić o stosie wywołań.
Oto nieco bardziej złożony program demonstrujący czas życia zmiennej o nazwie x:
#include <iostream>
void doSomething()
{
std::cout << "Hello!\n";
}
int main()
{
int x{ 0 }; // x's lifetime begins here
doSomething(); // x is still alive during this function call
return 0;
} // x's lifetime ends hereW powyższym programie czas życia x biegnie od punktu definicji do końca funkcji main. Obejmuje to czas spędzony podczas wykonywania funkcji doSomething.
Co się dzieje, gdy obiekt zostanie zniszczony?
W większości przypadków nic. Zniszczony obiekt po prostu staje się nieważny.
Dla zaawansowanych czytelników
Jeśli obiekt jest obiektem klasowym, przed zniszczeniem wywoływana jest specjalna funkcja zwana destruktorem. W wielu przypadkach destruktor nic nie robi i w takim przypadku nie są ponoszone żadne koszty. Destruktory wprowadzamy na lekcji 15.4 -- Wprowadzenie do destruktorów.
Każde użycie obiektu po jego zniszczeniu będzie skutkować niezdefiniowanym zachowaniem.
W pewnym momencie po zniszczeniu pamięć używana przez obiekt zostanie zwolniona (zwolniona na ponowne użycie).
Zakres lokalny (zakres blokowy)
Identyfikator zakres określa, gdzie identyfikator może być zobaczony i użyty w kodzie źródłowym. Kiedy identyfikator można zobaczyć i wykorzystać, mówimy, że jest to w zakresie. Gdy identyfikatora nie widać, nie możemy go użyć i mówimy, że jest to poza zakresem. Zasięg jest właściwością czasu kompilacji i próba użycia identyfikatora, gdy nie znajduje się on w zakresie, zakończy się błędem kompilacji.
Identyfikator zmiennej lokalnej ma zasięg lokalny. Identyfikator o zasięgu lokalnym (technicznie nazywanych zakres bloku) można stosować od punktu definicji do końca najbardziej wewnętrznej pary nawiasów klamrowych zawierających identyfikator (lub w przypadku parametrów funkcji na końcu funkcji). Dzięki temu zmienne lokalne nie mogą być użyte przed momentem definicji (nawet jeśli kompilator zdecyduje się je utworzyć wcześniej) lub po ich zniszczeniu. Zmienne lokalne zdefiniowane w jednej funkcji nie wchodzą w zakres innych wywoływanych funkcji.
Oto program demonstrujący zasięg zmiennej o nazwie x:
#include <iostream>
// x is not in scope anywhere in this function
void doSomething()
{
std::cout << "Hello!\n";
}
int main()
{
// x can not be used here because it's not in scope yet
int x{ 0 }; // x enters scope here and can now be used within this function
doSomething();
return 0;
} // x goes out of scope here and can no longer be usedW powyższym programie zmienna x wchodzi w zakres w momencie definicji. x wychodzi poza zakres na końcu najbardziej wewnętrznej pary nawiasów klamrowych zawierających identyfikator, który jest zamykającym nawiasem klamrowym funkcja main(). Należy zauważyć, że zmienna x nie znajduje się w zasięgu nigdzie wewnątrz funkcji doSomething. Fakt, że funkcja main wywołuje funkcję doSomething jest nieistotna w tym kontekście.
„Poza zakresem” vs „wyjście poza zakres”
Terminy „poza zakres” i „wyjście poza zakres” mogą być mylące dla nowych programistów.
Identyfikator jest poza zakresem wszędzie tam, gdzie nie można uzyskać do niego dostępu w kodzie. W powyższym przykładzie identyfikator x ma zakres od punktu definicji do końca funkcji main . Identyfikator x jest poza zakresem poza tym obszarem kodu.
Termin „wyjście poza zakres” jest zwykle stosowany do obiektów, a nie do identyfikatorów. Mówimy, że obiekt wychodzi poza zakres na końcu zakresu (końcowy nawias klamrowy), w którym obiekt został utworzony. W powyższym przykładzie obiekt o nazwie x wychodzi poza zakres na końcu funkcji main.
Życie zmiennej lokalnej kończy się w momencie, w którym wychodzi ona poza zakres, więc zmienne lokalne są w tym momencie niszczone.
Zauważ, że nie wszystkie typy zmiennych ulegają zniszczeniu, gdy wyjdą poza zakres. Zobaczymy tego przykłady na przyszłych lekcjach.
Kolejny przykład
Oto nieco bardziej złożony przykład. Pamiętaj, czas życia jest właściwością środowiska wykonawczego, a zakres jest właściwością czasu kompilacji, więc chociaż mówimy o obu w tym samym programie, są one wymuszane w różnych momentach.
#include <iostream>
int add(int x, int y) // x and y are created and enter scope here
{
// x and y are usable only within add()
return x + y;
} // y and x go out of scope and are destroyed here
int main()
{
int a{ 5 }; // a is created, initialized, and enters scope here
int b{ 6 }; // b is created, initialized, and enters scope here
// a and b are usable only within main()
std::cout << add(a, b) << '\n'; // calls add(5, 6), where x=5 and y=6
return 0;
} // b and a go out of scope and are destroyed hereParametry x i y są tworzone podczas wywoływania add funkcji, można je zobaczyć/użyć tylko w obrębie funkcji add i są niszczone na końcu add. Zmienne a i b są tworzone wewnątrz funkcji main, można je oglądać/używać tylko w ramach funkcji main i są niszczone na końcu main.
Aby lepiej zrozumieć, jak to wszystko do siebie pasuje, prześledźmy ten program bardziej szczegółowo. Dzieje się tak w następującej kolejności:
- Wykonanie rozpoczyna się od góry
main. mainzmiennąai dana jest wartość5.mainzmiennąbi dana jest wartość6.- Funkcja
addwywoływana jest z wartościami argumentów5i6. addparametryxiysą tworzone i inicjowane odpowiednio wartościami5i6.- Wyrażenie
x + yjest oceniany w celu uzyskania wartości11. addkopiuje wartość11z powrotem do wywołującegomain.addparametryyixsą zniszczone.mainwypisuje11do konsoli.mainzwraca0do systemu operacyjnego.mainzmiennebiasą zniszczone.
I gotowe.
Zauważ, że gdyby funkcja add miała być wywołane dwukrotnie, parametry x i y zostaną utworzone i zniszczone dwukrotnie — raz na każde wywołanie. W programie z dużą liczbą funkcji i wywołań funkcji zmienne są często tworzone i niszczone.
Separacja funkcjonalna
W powyższym przykładzie łatwo zobaczyć, że zmienne a i b są różnymi zmiennymi od x i y.
Rozważmy teraz następujące podobne program:
#include <iostream>
int add(int x, int y) // add's x and y are created and enter scope here
{
// add's x and y are visible/usable within this function only
return x + y;
} // add's y and x go out of scope and are destroyed here
int main()
{
int x{ 5 }; // main's x is created, initialized, and enters scope here
int y{ 6 }; // main's y is created, initialized, and enters scope here
// main's x and y are usable within this function only
std::cout << add(x, y) << '\n'; // calls function add() with x=5 and y=6
return 0;
} // main's y and x go out of scope and are destroyed hereW tym przykładzie jedyne, co zrobiliśmy, to zmieniliśmy nazwy zmiennych a i b wewnątrz funkcja main Do x i y. Ten program kompiluje się i działa identycznie, chociaż funkcje main i add obie mają zmienne o nazwach x i y. Dlaczego to działa?
Po pierwsze musimy zauważyć, że chociaż funkcje main i add obie mają zmienne o nazwach x i y, zmienne te są różne. x i y w funkcji main nie mają nic wspólnego z x i y w funkcji add -- tak się składa, że mają te same nazwy.
Po drugie, gdy znajdują się wewnątrz funkcji main, nazwy x i y odwołują się do zmiennych o zasięgu lokalnym x i y. Te zmienne można zobaczyć (i używać) tylko wewnątrz main. Podobnie, gdy znajdujemy się wewnątrz funkcji add, nazwy x i y odwołują się do parametrów funkcji x i y, które można zobaczyć (i wykorzystać) tylko wewnątrz add.
Krótko mówiąc, ani add ani main nie wiedzą, że druga funkcja ma zmienne o tych samych nazwach. Ponieważ zakresy nie nakładają się na siebie, dla kompilatora zawsze jest jasne, x i y do którego <<<M12>>>odwołuje się w dowolnym momencie.
Kluczowa informacja
Nazwy użyte dla parametrów funkcji lub zmiennych zadeklarowanych w treści funkcji są widoczne tylko w funkcji, która je deklaruje. Oznacza to, że zmienne lokalne w ramach funkcji mogą być nazywane bez względu na nazwy zmiennych w innych funkcjach. Pomaga to zachować niezależność funkcji.
W następnym rozdziale porozmawiamy więcej o zasięgu lokalnym i innych rodzajach zasięgu.
Gdzie definiować zmienne lokalne
We współczesnym C++ najlepszą praktyką jest to, że zmienne lokalne wewnątrz treści funkcji powinny być definiowane możliwie najbliżej ich pierwszego użycia:
#include <iostream>
int main()
{
std::cout << "Enter an integer: ";
int x{}; // x defined here
std::cin >> x; // and used here
std::cout << "Enter another integer: ";
int y{}; // y defined here
std::cin >> y; // and used here
int sum{ x + y }; // sum can be initialized with intended value
std::cout << "The sum is: " << sum << '\n';
return 0;
}W powyższym przykładzie każda zmienna jest definiowana tuż przed jej pierwszym użyciem. Nie ma co do tego rygorystycznych zasad — jeśli wolisz zamienić linie 5 i 6, nie ma problemu.
Najlepsza praktyka
Zdefiniuj zmienne lokalne tak blisko ich pierwszego użycia, jak to jest rozsądne.
Na marginesie…
Ze względu na ograniczenia starszych, bardziej prymitywnych kompilatorów, język C wymagał, aby wszystkie zmienne lokalne były definiowane na górze funkcji. Równoważny program w C++ korzystający z tego stylu wyglądałby następująco:
#include <iostream>
int main()
{
int x{}, y{}, sum{}; // how are these used?
std::cout << "Enter an integer: ";
std::cin >> x;
std::cout << "Enter another integer: ";
std::cin >> y;
sum = x + y;
std::cout << "The sum is: " << sum << '\n';
return 0;
}Ten styl jest nieoptymalny z kilku powodów:
- Zamierzone użycie tych zmiennych nie jest oczywiste w momencie definicji. Należy przeszukać całą funkcję, aby określić, gdzie i jak używana jest każda zmienna.
- Zamierzona wartość inicjująca może nie być dostępna na górze funkcji (np. nie możemy zainicjować
sumdo zamierzonej wartości, ponieważ nie znamy jeszcze wartościxiy). - Pomiędzy inicjatorem zmiennej a jej pierwszym użyciem może znajdować się wiele linii. Jeśli nie pamiętamy, jaką wartością została zainicjowana, będziemy musieli przewinąć z powrotem na początek funkcji, co rozprasza.
To ograniczenie zostało zniesione w standardzie języka C99.
Kiedy używać parametrów funkcji, a zmiennych lokalnych
Ponieważ zarówno parametry funkcji, jak i zmienna lokalna mogą być używane w treści funkcji, nowi programiści czasami mają trudności ze zrozumieniem, kiedy należy użyć każdego z nich. Parametru funkcji należy używać, gdy obiekt wywołujący przekaże wartość inicjującą jako argument. W przeciwnym razie należy użyć zmiennej lokalnej.
Użycie parametru funkcji, gdy należy użyć zmiennej lokalnej, prowadzi do kodu wyglądającego tak:
#include <iostream>
int getValueFromUser(int val) // val is a function parameter
{
std::cout << "Enter a value: ";
std::cin >> val;
return val;
}
int main()
{
int x {};
int num { getValueFromUser(x) }; // main must pass x as an argument
std::cout << "You entered " << num << '\n';
return 0;
}W powyższym przykładzie getValueFromUser() zdefiniowano val jako parametr funkcji. Z tego powodu main() musi zdefiniować x tak, aby mieć coś do przekazania jako argument. Jednak rzeczywista wartość x nigdy nie jest używana, a wartość, z którą val została zainicjowana, nigdy nie jest używana. Zmuszenie wywołującego do zdefiniowania i przekazania zmiennej, która nigdy nie jest używana, zwiększa niepotrzebną złożoność.
Właściwy sposób zapisania tej sytuacji byłby następujący:
#include <iostream>
int getValueFromUser()
{
int val {}; // val is a local variable
std::cout << "Enter a value: ";
std::cin >> val;
return val;
}
int main()
{
int num { getValueFromUser() }; // main does not need to pass anything
std::cout << "You entered " << num << '\n';
return 0;
}W tym przykładzie val jest teraz zmienną lokalną. main() jest teraz prostsze, ponieważ nie ma potrzeby definiowania ani przekazywania zmiennej do wywołania getValueFromUser().
Najlepsza praktyka
Gdy zmienna jest potrzebna w funkcji:
- Użyj parametru funkcji, gdy wywołujący to zrobi jako argument podaj wartość inicjalizacyjną zmiennej.
- W przeciwnym razie użyj zmiennej lokalnej.
Wprowadzenie do obiektów tymczasowych
A obiekt tymczasowy (czasami nazywany obiektem anonimowym) to nienazwany obiekt używany do przechowywania wartości potrzebnej tylko przez krótki okres czasu. Obiekty tymczasowe są generowane przez kompilator, gdy są potrzebne.
Istnieje wiele różnych sposobów tworzenia wartości tymczasowych, ale oto typowy sposób:
#include <iostream>
int getValueFromUser()
{
std::cout << "Enter an integer: ";
int input{};
std::cin >> input;
return input; // return the value of input back to the caller
}
int main()
{
std::cout << getValueFromUser() << '\n'; // where does the returned value get stored?
return 0;
}W powyższym programie funkcja getValueFromUser() zwraca wartość zapisaną w zmiennej lokalnej input z powrotem do rozmówcy. Ponieważ input zostanie zniszczony na końcu funkcji, wywołujący otrzyma kopię wartości, dzięki czemu będzie miała wartość, której będzie mógł użyć nawet po input jest zniszczony.
Ale gdzie jest przechowywana wartość kopiowana z powrotem do osoby wywołującej? Nie zdefiniowaliśmy żadnych zmiennych w main(). Odpowiedź jest taka, że zwracana wartość jest przechowywana w obiekcie tymczasowym. Ten obiekt tymczasowy jest następnie przekazywany do std::cout do wydrukowania.
Kluczowa informacja
Funkcja Return by value zwraca obiekt tymczasowy (który przechowuje kopię wartości zwracanej) do osoby wywołującej.
Obiekty tymczasowe w ogóle nie mają zasięgu (ma to sens, ponieważ zakres jest właściwością identyfikatora, a obiekty tymczasowe nie mają identyfikatora).
Obiekty tymczasowe są niszczone na końcu pełnego wyrażenia, w którym zostały utworzone. Oznacza to, że obiekty tymczasowe są zawsze niszczone przed wykonaniem następnej instrukcji.
W powyższym przykładzie obiekt tymczasowy utworzony w celu przechowywania zwracanej wartości getValueFromUser() zostaje zniszczony po std::cout << getValueFromUser() << '\n' wykonuje się.
W przypadku, gdy do inicjalizacji zmiennej używany jest obiekt tymczasowy, inicjalizacja następuje przed zniszczeniem obiektu tymczasowego.
We współczesnym C++ (zwłaszcza od C++ 17) kompilator ma wiele sztuczek, aby uniknąć generowania tymczasowych elementów tam, gdzie wcześniej było to konieczne. Na przykład, gdy używamy wartości zwracanej do inicjalizacji zmiennej, zwykle skutkuje to utworzeniem tymczasowej wartości zwracanej, a następnie użyciem tymczasowej wartości do inicjalizacji zmiennej. Jednak we współczesnym C++ kompilator często pomija tworzenie pliku tymczasowego i po prostu inicjuje zmienną bezpośrednio wartością zwracaną.
Podobnie w powyższym przykładzie, ponieważ zwracana wartość getValueFromUser() jest natychmiast wyprowadzany, kompilator może pominąć tworzenie i niszczenie tymczasowego pliku in main()i użyj wartości zwracanej przez getValueFromUser() bezpośrednio zainicjować parametr operator<<.
Czas quizu
Pytanie nr 1
Co wypisuje poniższy program?
#include <iostream>
void doIt(int x)
{
int y{ 4 };
std::cout << "doIt: x = " << x << " y = " << y << '\n';
x = 3;
std::cout << "doIt: x = " << x << " y = " << y << '\n';
}
int main()
{
int x{ 1 };
int y{ 2 };
std::cout << "main: x = " << x << " y = " << y << '\n';
doIt(x);
std::cout << "main: x = " << x << " y = " << y << '\n';
return 0;
}
