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 jest zmienną lokalną
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) // parametry funkcji x i y są zmiennymi lokalnymi
{
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 i y utworzone i przechowywane tutaj
{
int z{ x + y }; // z utworzonego i dostępnego tutaj
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 i x zniszczone tutajPodobnie 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 }; // czas życia x zaczyna się tutaj
doSomething(); // x jest wciąż aktywny podczas wywołania tej funkcji
return 0;
} // czas życia x kończy się tutajW 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 nie wchodzi w zakres tej funkcji
void doSomething()
{
std::cout << "Hello!\n";
}
int main()
{
// x nie można tutaj użyć, ponieważ nie jest jeszcze objęty zakresem
int x{ 0 }; // x wchodzi tutaj w zakres i może być teraz używane w ramach tej funkcji
doSomething();
return 0;
} // x wykracza tutaj poza zakres i nie można go już używaćW 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) // Tworzone są x i y i wprowadź tutaj zakres
{
// x i y można używać tylko w ramach add()
return x + y;
} // y i x wychodzą poza zakres i są tutaj niszczone
int main()
{
int a{ 5 }; // a jest miejscem, inicjowane i wchodzi tutaj w zakres
int b{ 6 }; // b jest tworzony, inicjowany i wchodzi tutaj w zakres
// a i b można używać tylko w main()
std::cout << add(a, b) << '\n'; // wywołuje add(5, 6), gdzie x=5 i y=6
return 0;
} // b i a wychodzą poza zakres i zostają tutaj zniszczoneParametry 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) // zostaną utworzone x i y dodatku i wprowadź tutaj zakres
{
// x i y dodatku są widoczne/używane tylko w tej funkcji
return x + y;
} // y i x dodatku dodają poza zakresem i są tutaj niszczone
int main()
{
int x{ 5 }; // x maina jest miejscem, inicjowane i wchodzi tutaj w zakres
int y{ 6 }; // y maina są tworzone, inicjowane i wchodzą w zakres tutaj
// x i y maina można używać tylko w tej funkcji
std::cout << add(x, y) << '\n'; // wywołuje funkcję add() z x=5 i y=6
return 0;
} // y i x maina wychodzą poza zakres i są tutaj niszczoneW 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 zdefiniowano tutaj
std::cin >> x; // i użyty tutaj
std::cout << "Enter another integer: ";
int y{}; // y zdefiniowany tutaj
std::cin >> y; // i użyty tutaj
int sum{ x + y }; // suma może być zainicjowany zamierzoną wartością
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{}; // jak to jest używane?
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 jest funkcją parametryczną
{
std::cout << "Enter a value: ";
std::cin >> val;
return val;
}
int main()
{
int x {};
int num { getValueFromUser(x) }; // main musi przekazać x jako 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 jest zmienną lokalną
std::cout << "Enter a value: ";
std::cin >> val;
return val;
}
int main()
{
int num { getValueFromUser() }; // main nie musi przejść wszystko
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
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; // zwróć wartość wprowadzoną z powrotem do wywołującego
}
int main()
{
std::cout << getValueFromUser() << '\n'; // gdzie przechowywana jest zwrócona wartość?
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;
}
