Главная / Курсы / C++ по спирали / Время жизни и область видимости
# Глава 9. Время жизни и область видимости В этой главе мы разберем: - Область видимости (scope): из каких мест в коде переменная доступна. - Время жизни (lifetime): в какой момент переменная создается и разрушается. Но сначала рассмотрим, как переменные располагаются в памяти программы. ## Где живут переменные Исполняемый файл на C++ — это бинарный файл, разбитый на секции. Когда программа запускается, ОС загружает его в оперативную память. Под программу выделяется _адресное пространство._ Это абстракция физической памяти, которую ОС отдает процессу. Адресное пространство содержит состояние программы: ее код, [кучу](https://ru.wikipedia.org/wiki/%D0%9A%D1%83%D1%87%D0%B0_(%D0%BF%D0%B0%D0%BC%D1%8F%D1%82%D1%8C)) и [стек.](https://ru.wikipedia.org/wiki/%D0%A1%D1%82%D0%B5%D0%BA_%D0%B2%D1%8B%D0%B7%D0%BE%D0%B2%D0%BE%D0%B2) **Код программы** — это область памяти, в которой находятся: - Инструкции для выполнения программы процессором. - Значения всех используемых в коде литералов. - Переменные со статическим временем жизни. Они инициализируются на старте программы и уничтожаются при ее завершении. **Куча** также известна как динамическая память. Ее выделение и освобождение организуется из кода программы. **Стек** — это область памяти, устроенная по принципу [LIFO.](https://ru.wikipedia.org/wiki/%D0%A1%D1%82%D0%B5%D0%BA) Здесь размещается цепочка вызовов функций вместе с их аргументами и локальными переменными. _Упрощенно_ представим виртуальное адресное пространство однопоточного процесса. Допустим, оно занимает 32 Кб. Код программы находится в начале адресного пространства. Это удобно, ведь его размер известен заранее и не меняется. Стек и куча, напротив, могут расти и уменьшаться. Поэтому они расположены в противоположных концах адресного пространства и растут друг навстречу другу. ![Process memory](https://raw.githubusercontent.com/senjun-team/senjun-courses/cpp-chapter-9/illustrations/cpp/process_memory.jpg) {.illustration} Если программа работает в несколько потоков, ее адресное пространство организовано сложнее. Об этом будет рассказано в следующих главах. ## Время жизни Раccмотрим, какое время жизни может быть у переменной в программе на C++. - Автоматическое время жизни управляется автоматически компилятором. - Статическое время жизни длится от запуска программы и до ее завершения. - Динамическое время жизни управляется в рантайме из кода программы. - Thread-local время жизни длится от старта потока до его завершения. К переменными с **автоматическим временем жизни** относятся: - Локальные переменные. Они создаются внутри блока кода и разрушаются при выходе из него. - Аргументы функций. Они создаются при вызове функции и разрушаются при выходе из нее. Такие переменные находятся на стеке. Их время жизни детерминировано и _автоматически_ управляется компилятором. **Статическим временем жизни** обладают: - Глобальные переменные. Они создаются вне класса или функции. - Статические переменные и поля классов. При их объявлении указывается квалификатор `static`. Переменные со статическим временем жизни находятся в специальной области памяти, которая инициализируется _до_ входа в `main()`. Она располагаются в секции с кодом программы и на картинке выше помечена зеленым. Переменные с **динамическим временем жизни** размещаются в куче. Ее выделение и освобождение контролируется из кода программы. Следовательно, переменные с динамическим временем жизни требуют ручного управления. При их создании программист должен занимать под них память, а при уничтожении — освобождать. Вы уже сталкивались с динамическими объектами, хоть и косвенно. Например, они есть под капотом у контейнеров. Напрямую с динамическими переменными вы поработаете в главе про указатели. **Локальным для потока временем жизни** обладают переменные, объявленные с квалификатором `thread_local`. Это ключевое слово было введено в В C++11 для того, чтобы в многопоточной программе каждый поток работал со своей копией переменной. Подробнее рассмотрим такие переменные в главах про потоки и процессы. ## Локальные переменные Локальными называют переменные, созданные внутри функции или блока кода. Блок кода — это инструкции, объединенные фигурными скобками. Блоком является любая [составная инструкция.](/courses/cpp/chapters/cpp_chapter_0030/#block-compound-statement) Каждый блок кода создает новую область видимости. Объявленные внутри него переменные не доступны снаружи. Попытка обращения к ним извне приведет к ошибке компиляции. В этом примере область видимости локальной переменной `code` распространяется на цикл и все его вложенные блоки. За пределами `while` эта переменная недоступна. ```c++ {.example_for_playground .example_for_playground_001} import std; void handle_user_input() { while(true) { const std::string input = read_input(); const int code = parse_code(input); if (is_valid(code)) { std::println("Input: {}", code); // ... } } } int main() { handle_user_input(); } ``` Область видимости локальной переменной — блок, в котором она заведена. Так как у локальных переменных автоматическое время жизни, то они существуют в памяти с момента объявления и до выхода из блока. При выходе из блока переменная удаляется. Для классов и структур при этом вызывается [деструктор.](/courses/cpp/chapters/cpp_chapter_0050/#block-constructors-destructors) Что будет выведено в консоль? {.task_text} В случае ошибки компиляции напишите `err`. {.task_text} ```c++ {.example_for_playground} import std; int main() { std::vector<std::string> snapshot_files = { "/tmp/3881", "/tmp/4074" }; try { const std::size_t i = 2; const std::string path = snapshot_files.at(i); std::println(path); } catch(const std::out_of_range & e) { std::println("{}", i); } } ``` ```consoleoutput {.task_source #cpp_chapter_0090_task_0010} ``` Переменная `i` создана в блоке `try`, но обращение к ней происходит в блоке `catch`. {.task_hint} ```cpp {.task_answer} err ``` Локальные переменные и значения параметров функций живут на стеке. Он хранит последовательность вызовов функций и методов. Запуск функции приводит к помещению в стек нового фрейма. Фрейм — это область на стеке, содержащая: - Аргументы функции. - Адрес возврата. Он нужен, чтобы при выходе из функции продолжить выполнения с места, откуда она была вызвана. - Локальные переменные. Допустим, в запущенной программе выполняется строка 5: ```c++ {.example_for_playground .example_for_playground_002} import std; void handle_request(Request request) { std::println("Handling HTTP POST..."); // ... } void handle_requests() { for (Request r: get_requests()) handle_request(r); } int main() { handle_requests(); } ``` В этот момент стек вызовов будет содержать 4 фрейма: ![Stack frames](https://raw.githubusercontent.com/senjun-team/senjun-courses/cpp-chapter-9/illustrations/cpp/call_stack.jpg) {.illustration} Указатель вершины стека всегда смотрит на последний добавленный фрейм. При завершении функции он смещается к предыдущему фрейму, таким образом уменьшая количество фреймов в стеке. Иногда говорят, что _стек растет вниз_, имея ввиду, что новые фреймы добавляются в память с меньшими адресами, а самый первый фрейм находится в памяти с наибольшим адресом. Время жизни переменных на стеке известно заранее. Оно управляется компилятором. На это завязана магия [RAII,](/courses/cpp/chapters/cpp_chapter_0050/#block-raii) предполагающая захват некоего ресурса в конструкторе объекта и его освобождение в деструкторе. Деструктор срабатывает _автоматически,_ и программисту не приходится вручную вызывать код, отвечающий за освобождение ресурса. ## Глобальные переменные Переменные, созданные вне функции или класса, называют **глобальными.** Их область видимости — _как минимум_ начиная со строки с объявлением и до конца файла. Но в большинстве случаев она распространяется _вообще на всю программу._ Существует негласное, но распространенное правило: глобальные переменные объявляются после импорта библиотек и до определения классов и функций. Хотя никто не мешает объявить их в произвольном месте. ```c++ {.example_for_playground} import std; const int port = 8080; int main() { std::println("{}", port); } ``` ``` 8080 ``` У глобальных переменных статическое время жизни. Они создаются в памяти и инициализируются _до_ входа в `main()`, а разрушаются _после_ выхода из `main()`. В C++ вы можете завести переменную без присваивания значения: ```c++ double rps; ``` Мы [предупреждали,](/courses/cpp/chapters/cpp_chapter_0030/#block-initialization) что это плохая практика, которую _следует избегать._ Дело в том, что локальные переменные, не имеющие конструктора, без инициализации могут быть заполнены любым мусором. Обращение к такой переменной приведет к [UB.](/courses/cpp/chapters/cpp_chapter_0060/#block-ub) В отличие от локальных, глобальные переменные можно не инициализировать. Хотя делать так _тоже не рекомендуется._ Если у глобальной переменной нет конструктора, то она заполняется нулями. Вне зависимости от того, является ли нулевое значение корректным для данного типа. Например, не инициализированная глобальная переменная `operation` обнуляется и выходит за рамки принимаемых [перечислением](/courses/cpp/chapters/cpp_chapter_0050/#block-enum) значений. ```c++ {.example_for_playground} import std; int request_count; // 0, ok double rate; // 0.0, ok bool is_valid; // false, ok enum class Operation { INSERT = 1, DELETE, UPDATE }; Operation operation; // 0, not ok! int main() { std::println("{}", request_count); std::println("{}", rate); std::println("{}", is_valid); std::println("{}", static_cast<int>(operation)); } ``` ``` 0 0 false 0 ``` Глобальную переменную можно поместить в [пространство имен.](/courses/cpp/chapters/cpp_chapter_0050/#block-namespaces) Это ограничит ее область видимости, но не повлияет на время жизни. ```c++ {.example_for_playground} import std; namespace conf { const int port = 8080; } int main() { std::println("{}", conf::port); } ``` ``` 8080 ``` [Избегайте](https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#Ri-global) в своем коде глобальных переменных. Изменение глобальной переменной внутри функции — это нежелательный [побочный эффект,](https://ru.wikipedia.org/wiki/%D0%9F%D0%BE%D0%B1%D0%BE%D1%87%D0%BD%D1%8B%D0%B9_%D1%8D%D1%84%D1%84%D0%B5%D0%BA%D1%82_(%D0%BF%D1%80%D0%BE%D0%B3%D1%80%D0%B0%D0%BC%D0%BC%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D0%BD%D0%B8%D0%B5)) который затрудняет ее правильное использование и тестирование. Глобальные переменные создают лишние зависимости между разными частями проекта и повышают риск возникновения ошибок. ## Статические переменные {#block-static} Для создания статической переменной используется квалификатор `static`: ```c++ static int count = 0; ``` Компилятор инициализирует такую переменную единожды, а при повторном заходе в ее блок воспользуется сохраненным значением. Так как у статических переменных статическое время жизни, они существуют до самого завершения программы. Возьмем пример кода из параграфа про локальные переменные и сделаем переменную `n` статической. ```c++ {.example_for_playground} import std; void f() { for (std::size_t i = 0; i < 3; ++i) { static std::size_t n = 0; std::print("{} ", ++n); } std::println(""); } int main() { f(); f(); } ``` ``` 1 2 3 4 5 6 ``` Теперь при каждом следующем заходе в тело цикла компилятор использует сохраненное состояние `n` и не пересоздает ее. Вместо переменной с автоматическим временем жизни мы получили переменную со статическим временем жизни. Что будет выведено в консоль? {.task_text} В случае ошибки компиляции напишите `err`. {.task_text} ```c++ {.example_for_playground} import std; std::size_t f() { static int n = 0; return ++n; } int main() { f(); std::println("{}", f()); } ``` ```consoleoutput {.task_source #cpp_chapter_0090_task_0020} ``` Статическая переменная `n` инициализируется нулем. [Пре-инкремент](/courses/cpp/chapters/cpp_chapter_0020/#block-pre-increment) `n` сначала увеличивает ее на 1, а потом возвращает значение. {.task_hint} ```cpp {.task_answer} 2 ``` Будьте осторожны при заведении тяжелых статических объектов, ведь память из-под них не будет высвобождена до конца работы программы. Реализуйте функцию `fib()`, которая возвращает `n`-ное число последовательности Фибоначчи. Нумерация начинается с нуля. В последовательности Фибоначчи каждое следующее число равно сумме двух предыдущих. Начало последовательности выглядит так: 0, 1, 1, 2, 3, 5, 8, 13, ... {.task_text} Примеры работы функции: `fib(0) == 0`, `fib(1) == 1`, `fib(6) == 8`. {.task_text} **Функция должна кешировать** 40 первых чисел последовательности начиная со 2-го: 1, 2, 3, ... {.task_text} ```c++ {.task_source #cpp_chapter_0090_task_0030} int fib(int n) { } ``` В качестве кеша подойдет `std::array`, индексы которого соответствуют номерам чисел в последовательности Фибоначчи. {.task_hint} ```c++ {.task_answer} int fib(int n) { static std::array<int, 40> cache = {}; if (n <= 0) return 0; if (n == 1) return 1; if (n < cache.size()) { std::size_t i = n - 2; if (cache[i] != 0) return cache[i]; cache[i] = fib(n - 1) + fib(n - 2); return cache[i]; } return fib(n - 1) + fib(n - 2); } ``` Статическими можно делать не только локальные, но и глобальные переменные. Однако для глобальных переменных это имеет иное значение. Какое, вы [узнаете](/courses/cpp/chapters/cpp_chapter_0110/#block-static) в главе про сборку проекта. ## Затенение имен Стандарт C++ позволяет заводить во вложенных областях видимости переменные с одинаковыми именами. При обращении к такой переменной происходит [затенение](https://en.wikipedia.org/wiki/Variable_shadowing) (перекрытие) имен: приоритет отдается переменной вложенного блока. Выбрать глобальную переменную можно, написав перед ней оператор разрешения имен `::` без указания пространства имен. Это означает обращение к глобальной области видимости: В этом примере есть 3 переменные с именем `max_speed`: глобальная, локальная для функции `main()` и локальная во вложенном блоке. ```c++ {.example_for_playground} import std; double max_speed = 60.0; int main() { double max_speed = 90.0; { double max_speed = 120.0; std::println("{} {}", max_speed, ::max_speed); } std::println("{} {}", max_speed, ::max_speed); } ``` ``` 120 60 90 60 ``` Затенение имен приводит к массе непреднамеренных ошибок. Старайтесь его избегать. ## Вложенные блоки кода и RAII Как вы [помните,](/courses/cpp/chapters/cpp_chapter_0050/#block-raii) идиома RAII применяется для автоматического управления ресурсами, требующими парных действий. Например, для открытия и закрытия сетевого соединения. Чтобы реализовать RAII-класс, в его конструкторе описывают захват ресурса, а в деструкторе — освобождение. И в некоторых случаях освободить ресурс требуется еще до выхода из функции, заранее. В C++ распространена практика создания вложенного блока кода специально для контроля времени жизни переменной. Это выглядит как пара фигурных скобок, не относящихся к функции или управляющей конструкции: ```c++ int main() { // ... { int n = 1'000; // ... } // Здесь n разрушается // ... } ``` Рассмотрим типичный сценарий использования такого подхода. Он возникает при параллельной работе с переменной из нескольких потоков. Доступ к ней синхронизируется [мьютексом](https://ru.wikipedia.org/wiki/%D0%9C%D1%8C%D1%8E%D1%82%D0%B5%D0%BA%D1%81) `std::mutex`. Он гарантирует, что в каждый момент переменную читает или записывает максимум один поток. Но каждый раз вручную захватывать мьютекс _до обращения_ к переменной и отпускать его _после этого_ неудобно. Поэтому пользуются RAII-классом [std::unique_lock](https://en.cppreference.com/w/cpp/thread/unique_lock.html). В конструкторе он захватывает мьютекс, а в деструкторе отпускает. Для контроля времени жизни объекта `std::unique_lock` используется вложенный блок. Допустим, у нас есть класс `TaskRunner`, объекты которого из нескольких потоков работают с очередью задач `task_queue`. Она защищена мьютексом `task_queue_mutex`. Тогда метод для выполнения задачи мог бы выглядеть так: ```c++ void TaskRunner::exec_task() { Task task; { std::unique_lock<std::mutex> lock(task_queue_mutex); // блокируем мьютекс if (task_queue.empty()) return; task = task_queue.pop(); } // разблокируем exec(task); } ``` Перед вами код для замера времени выполнения алгоритма `std::sort()`. Воспользуйтесь им, чтобы написать RAII-класс `MeasureTime`. В конструкторе он сохраняет текущее время. А в деструкторе выводит в консоль разницу между настоящим моментом и сохраненным. Чтобы увидеть пример использования класса, откройте задачу в плэйграунде. {.task_text} ```c++ std::vector<int> numbers = random_vector(1e6); // https://en.cppreference.com/w/cpp/chrono/high_resolution_clock/now auto start = std::chrono::high_resolution_clock::now(); std::sort(numbers); auto finish = std::chrono::high_resolution_clock::now(); auto delta = std::chrono::duration_cast<std::chrono::milliseconds> (finish-start).count(); std::println("Duration: {} ms", delta); ``` ```c++ {.task_source #cpp_chapter_0090_task_0040} class MeasureTime { }; ``` В приватной секции класса заведите поле типа `std::chrono::time_point<std::chrono::high_resolution_clock>`. В конструкторе присвойте этому полю результат вызова `std::chrono::high_resolution_clock::now()`. В деструкторе заведите переменную, равную текущему значению времени. {.task_hint} ```c++ {.task_answer} class MeasureTime { public: MeasureTime() { start = std::chrono::high_resolution_clock::now(); } ~MeasureTime() { auto finish = std::chrono::high_resolution_clock::now(); auto delta = std::chrono::duration_cast<std::chrono::milliseconds> (finish-start).count(); std::println("Duration: {} ms", delta); } private: std::chrono::time_point<std::chrono::high_resolution_clock> start; }; ``` ## Инициализаторы в if и switch Инициализатор нужен, чтобы создать переменную и присвоить ей значение до выражения внутри круглых скобок управляющей конструкции. Вы уже [работали](/courses/cpp/chapters/cpp_chapter_0040/#block-for-explanation) с инициализатором в цикле `for`: ```c++ // инициализатор // |--------| for(int i = 0; i < n; ++i) { /* ... */ } ``` А в C++17 появилась возможность задавать инициализаторы в управляющих конструкциях `if` и `switch`: ```c++ if(init-statement; condition) { /* ... */ } switch(init-statement; condition) { /* ... */ } ``` Какую проблему решают инициализаторы? Разберем это на примере кода, который обрабатывает введенную пользователем команду: ```c++ {.example_for_playground .example_for_playground_003} std::string cmd = read_user_input(); if (cmd == "q") { std::println("Quitting..."); } else { std::println("Handling command {}", cmd); run_command(cmd); } // ... ``` У этого кода есть недостаток: область видимости переменной `cmd` больше, чем требуется. Переменная нужна только для условия, но объявлена _до_ него и доступна _после_ него. Перепишем пример выше с использованием инициализатора: ```c++ {.example_for_playground .example_for_playground_004} if (std::string cmd = read_user_input(); cmd == "q") { std::println("Quitting..."); } else { std::println("Handling command {}", cmd); run_command(cmd); } // ... ``` Теперь область видимости и время жизни `cmd` ограничены условием, в котором эта переменная используется. Вне его она недоступна. Инициализаторы в управляющих конструкциях — всего лишь синтаксический сахар над созданием новой области видимости с помощью вложенного блока: ```c++ {.example_for_playground .example_for_playground_005} // ... { std::string cmd = read_user_input(); if ( cmd == "q") { std::println("Quitting..."); } else { std::println("Handling command {}", cmd); run_command(cmd); } } // ... ``` [Старайтесь минимизировать](https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#Res-scope) область видимости переменных: это делает код более надежным и лаконичным. [Используйте инициализаторы](https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#es6-declare-names-in-for-statement-initializers-and-conditions-to-limit-scope) там, где они вам в этом помогут. Разумеется, инициализаторы опциональны: до этого момента вы использовали `if` и `switch` без них. Перед вами функция `add_alias()` для добавления псевдонима консольной команды и функция `get_description()`, по команде или псевдониму возвращающая описание. Функции плохо спроектированы: они завязаны на глобальные переменные. Кроме того, в коде допущена ошибка, приводящая к накапливанию пустых описаний команд. {.task_text} Перенесите логику из этого кода в класс `Commands`. Метод `get_description()` должен кидать исключение `std::out_of_range`, если команда не найдена. В условии `if` используйте инициализатор. {.task_text} ```c++ std::unordered_map<std::string, std::string> cmd_aliases = { {"rd", "rmdir"}, {"o", "less"} }; std::unordered_map<std::string, std::string> cmd_descriptions = { {"rmdir", "remove empty directories"}, {"less", "display the contents of a file"}, {"sed", "stream editor for transforming text"} }; void add_alias(std::string alias, std::string cmd) { cmd_aliases[alias] = cmd; } std::string get_description(std::string cmd) { auto it = cmd_aliases.find(cmd); if (it != cmd_aliases.end()) return cmd_descriptions[it->second]; return cmd_descriptions[cmd]; } ``` ```c++ {.task_source #cpp_chapter_0090_task_0050} class Commands { public: void add_alias(std::string alias, std::string cmd) { } void add_command(std::string cmd, std::string description) { } std::string get_description(std::string cmd) { } private: }; ``` Вы можете освежить в памяти [варианты вставки](/courses/cpp/chapters/cpp_chapter_0070/#block-insert) элементов в ассоциативный контейнер. {.task_hint} ```c++ {.task_answer} class Commands { public: void add_alias(std::string alias, std::string cmd) { cmd_aliases[alias] = cmd; } void add_command(std::string cmd, std::string description) { cmd_descriptions.emplace(cmd, description); } std::string get_description(std::string cmd) { if (auto it = cmd_aliases.find(cmd); it != cmd_aliases.end()) return cmd_descriptions.at(it->second); return cmd_descriptions.at(cmd); } private: std::unordered_map<std::string, std::string> cmd_aliases; std::unordered_map<std::string, std::string> cmd_descriptions; }; ``` ---------- ## Резюме - Время жизни переменной бывает автоматическим, статическим, динамическим и локальным для потока. - Статическое время жизни бывает у глобальных и статических переменных. - Автоматическое время жизни бывает у локальных переменных и значений параметров функций. - Временем жизни автоматических переменных управляет компилятор. Он вызывает деструктор, когда объект покидает свою область видимости. За счет этого в C++ работают RAII-классы. - Статической можно сделать и локальную, и глобальную переменную. - Блок кода создает область видимости. - Для ограничения области видимости используются инициализаторы в конструкциях `if` и `switch`. - При затенении имен приоритет отдается локальной переменной. - Избегайте в своем коде глобальных переменных и затенения имен. - Для доступа к глобальной области видимости используется оператор `::`.
Отправка...
Наша группа в telegram. Здесь можно задавать вопросы и общаться.
Задонатить. Если вам нравится курс, вы можете поддержать развитие площадки!