Главная /
Курсы /
C++ по спирали /
Глава 13. Щадящее введение в инициализацию /
Основные способы инициализации
# Глава 13.1. Основные способы инициализации
Что может быть проще, чем завести переменную и проинициализировать ее значением? Ведь так?
В C++ сложилась удивительная для мейнстримного языка ситуация: [инициализация](https://en.cppreference.com/w/cpp/language/initialization.html) переменной — запутанное действо, способное сбить с толку даже опытного разработчика. Одних только вариантов инициализации целочисленной переменной насчитывается _больше десятка._
Способы инициализации определяют правила, по которым в переменную сохраняется значение при создании. Мы будем знакомиться с ними постепенно. В этой главе мы в первом приближении рассмотрим лишь некоторые из них:
```cpp
// default-initialization: инициализация по умолчанию
int n;
std::vector<int> v;
```
```cpp
// copy-initialization: копирующая инициализация
int n = 1;
std::vector<int> v = v_other;
```
```cpp
// direct-initialization: прямая инициализация
int n(1);
std::vector<int> v(5);
```
```cpp
// uniform-initialization: универсальная инициализация
int n{1};
std::vector<int> v{1, 2, 3};
```
## Default-initialization: инициализация по умолчанию {#block-default-initialization}
Вы можете завести переменную без присваивания значения:
```cpp
int count;
```
Это называется [инициализацией по умолчанию](https://en.cppreference.com/w/cpp/language/default_initialization) (default-initialization). Мы [предупреждали,](/courses/cpp/chapters/cpp_chapter_0030/#block-initialization) что ее следует избегать. А теперь объясним, почему.
Для [фундаментальных типов](/courses/cpp/chapters/cpp_chapter_0020/#block-fundamental-types) инициализация по умолчанию сводится к тотальному _отсутствию инициализации._ Компилятор выделяет под переменную область памяти, но не записывает туда никакого значения. В переменной может находиться что угодно!
```cpp {.example_for_playground}
import std;
int main()
{
double distance;
std::println("{}", distance); // UB
}
```
```
6.90910253244167e-310
```
В этом примере значение `distance` меняется от запуска к запуску. Если компилятор _на этот раз_ проинициализировал переменную нулем `0.0`, это ничего не гарантирует. Стандарт языка относит чтение неинициализированной переменной к [UB.](/courses/cpp/chapters/cpp_chapter_0060/#block-ub)
Не допускайте в своем коде UB. [Избегайте](https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#Res-always) инициализации по умолчанию.
Сколько раз в этом коде встречается UB? Напишите 0, если UB отсутствует. {.task_text}
```cpp {.example_for_playground}
import std;
void show_trip_info(int time, int dist)
{
std::println("Journey will take {} hours.", time);
std::println("You will cover {} kilometers.", dist);
}
int main()
{
int t; // hours
int d; // km
t = 5;
show_trip_info(t, d);
return d;
}
```
```consoleoutput {.task_source #cpp_chapter_0131_task_0010}
```
Переменная `d` остается неинициализированной. Обращение к ней происходит в момент вывода значения в консоль и в момент возврата значения из функции `main()`. {.task_hint}
```cpp {.task_answer}
2
```
Однако в двух случаях инициализация по умолчанию _действительно_ сохраняет в переменную предсказуемое значение. Это справедливо для:
- Объектов классов и структур, у которых есть конструктор по умолчанию, корректно инициализирующий поля.
- Переменных со [статическим временем жизни](/courses/cpp/chapters/cpp_chapter_0090/#block-static-lifetime). Для них вызывается конструктор по умолчанию. Если конструктора нет, переменная [заполняется нулями](/courses/cpp/chapters/cpp_chapter_0090/#block-zeroes) вне зависимости от того, является ли 0 корректным значением для переменной данного типа. В главе [«Время жизни и область видимости»](/courses/cpp/chapters/cpp_chapter_0090/) описывалось, как это работает.
В этом коде нет UB, потому что глобальные и `static` переменные имеют статическое время жизни.
```cpp {.example_for_playground}
import std;
bool healthcheck_ok;
int main()
{
static int max_rps;
std::println("{} {}", healthcheck_ok, max_rps); // Ok
}
```
В этом коде тоже нет UB, потому что `std::string` и `std::vector` — классы, а не фундаментальные типы. У них есть конструкторы по умолчанию, корректно инициализирующие объекты:
```cpp {.example_for_playground}
import std;
int main()
{
std::string uuid; // Пустая строка
std::vector<int> checksums; // Пустой вектор
std::println("{} {}", uuid, checksums); // Ok
}
```
Если так опасна инициализация по умолчанию, почему она вообще существует?
Во-первых, это наследие языка Си. Пол века назад никто не видел криминала в том, чтобы объявить переменную сейчас, а заполнить как-нибудь потом. Зато многие современные языки запрещают любые действия над неинициализированными переменными. В Go переменные всегда инициализируются значением по умолчанию: нулем, `false` и т.д. В Rust и Kotlin чтение неинициализированной переменной приводит к ошибке компиляции. Если вы пришли в мир C++ из таких языков, не теряйте бдительность!
Во-вторых, инициализация по умолчанию важна для разработки эффективного кода. Представьте офлайн навигатор. В нем заведена переменная, ожидающая сигнала от гироскопа. Зачем совершать избыточное действие и инициализировать ее заранее, если значение гарантированно придет с сенсора? Экономия заряда аккумулятора важнее.
Область видимости локальной переменной начинается на строке с ее объявлением. Как считаете, что произойдет на строке с объявлением переменной `n`? Выберите один из вариантов: {.task_text #block-use-not-initialized}
`err`: ошибка компиляции. {.task_text}
`ub`: неопределенное поведение. {.task_text}
`ok`: в `n` сохранится предсказуемое и корректное значение. {.task_text}
```cpp {.example_for_playground}
import std;
int main()
{
int n = 2 + n;
}
```
```consoleoutput {.task_source #cpp_chapter_0131_task_0020}
```
Из-за того, что область видимости переменной начинается со строки с ее объявлением, мы получили синтаксически корректное выражение. То есть ошибки компиляции не будет. Однако в этом выражении участвует еще не проинициализированный объект! Поэтому мы получаем неопределенное поведение. {.task_hint}
```cpp {.task_answer}
ub
```
## Copy-initialization: копирующая инициализация
Синтаксис [копирующей инициализации](https://en.cppreference.com/w/cpp/language/copy_initialization) (copy-initialization) унаследован от Си. Значение переменной указывается после оператора `=`:
```cpp
bool is_answer_correct = false;
```
Знак равенства может создать ложное впечатление, что происходит присваивание значения. Но копирующая инициализация — это не то же самое, что присваивание. Это именно инициализация нового объекта с использованием другого объекта. Отсюда и название.
```cpp
double distance = 4.6; // Копирующая инициализация
distance = 5.5; // Присваивание
```
Вы почувствуете разницу, когда познакомитесь с [перегрузками](/courses/cpp/chapters/cpp_chapter_0050/#block-overloading) конструкторов и операторов в классах. Они задают различное поведение для инициализации и присваивания.
У копирующей инициализации есть важное свойство: если тип переменной не совпадает с типом значения, которым она инициализируется, то выполняется неявное приведение типов.
### Неявное приведение типов {#block-implicit-conversion}
[Неявное приведение типов](https://en.cppreference.com/w/cpp/language/implicit_conversion) (implicit conversion) — это автоматическое преобразование одного типа в другой, выполняемое компилятором.
```cpp {.example_for_playground .example_for_playground_001}
int length = 8.7; // double -> int
double weight = true; // bool -> double
std::println("length={} weight={}", length, weight);
```
```
length=8 weight=1
```
В этом примере при инициализации целого числом с плавающей точкой произошло **сужающее преобразование** (narrowing conversion). А при инициализации числа с плавающей точкой типом `bool` — **расширяющее преобразование** (widening conversion). Сравним эти два вида преобразований.
Сужающее преобразование:
- Возможна потеря информации.
- Потенциально опасно. Может привести к потере информации или неправильному значению.
- Компилятор генерирует предупреждение либо ошибку, если ему передан флаг `-Werror`.
- Пример: `char c = 5000;`. Здесь `int` преобразуется к `char`. Значение 5000 гарантированно выходит за диапазон типа `char`.
Расширяющее преобразование:
- Потери информации нет.
- Всегда безопасно.
- Компилятор не генерирует предупреждений.
- Пример: `int n = 'x';`. Здесь тип `char` приводится к `int`. Диапазон целого знакового `int` шире, чем у `char`. Поэтому в `n` сохраняется ASCII-код латинского символа `x`.
Вот некоторые правила преобразований:
- `bool` к числу: `false` приводится к 0, `true` — к 1.
- Число к `bool`: 0 приводится к `false`, остальные значения — к `true`.
- Число с плавающей точкой к целому: дробная часть отбрасывается.
Так выглядит приведение типов при копирующей инициализации объекта класса:
```cpp {.example_for_playground}
import std;
class Array
{
public:
Array(std::size_t length)
{
len = length;
}
private:
std::size_t len;
};
int main()
{
Array arr = 3; // Вызывает конструктор Array(3)
}
```
Какое значение сохранится в переменную `total_dist`? {.task_text}
Напишите получившееся число, `ub` в случае неопределенного поведения, либо `err` в случае ошибки компиляции. {.task_text}
```cpp {.example_for_playground}
import std;
int main()
{
bool go_shopping = true;
bool go_to_work = false;
int dist_to_shop = 1; // km
int dist_to_work = 3; // km
int total_dist = go_shopping * dist_to_shop + go_to_work * dist_to_work;
}
```
```consoleoutput {.task_source #cpp_chapter_0131_task_0030}
```
В выражении, инициализирующем переменную `total_distance`, есть расширяющее преобразование `bool` к числу. {.task_hint}
```cpp {.task_answer}
1
```
Неявные преобразования часто приводят к проблемам. Особенно [опасны](https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#Res-narrowing) сужающие преобразования. Во многих современных языках неявные преобразования запрещены. Например, в Kotlin и Rust нельзя переменную типа `int` инициализировать через `float`. И наоборот.
А в C++ чтобы при создании переменной избежать неявных преобразований, вместо копирующей инициализации используют универсальную. О ней чуть позже.
### Запрет неявного преобразования аргументов конструктора
Неявное преобразование аргументов конструктора легко становится источником трудно обнаруживаемых ошибок:
```cpp {.example_for_playground .example_for_playground_002}
import std;
class Error
{
public:
Error(int code)
{
m_code = code;
}
// ...
private:
int m_code = 0;
};
void handle_error(Error err, int retries_count)
{
std::println("Handling error: {} Retries left: {}", err, retries_count);
// ...
}
int main()
{
Error err(503);
int retries = 2;
handle_error(retries, retries); // Упс!
}
```
```
Handling error: 2 Retries left: 2
```
Здесь в функцию `handle_error()` первым аргументом вместо объекта `err` случайно передано целое число `retries`. Тем не менее, этот код успешно скомпилировался и запустился. Дело в том, что компилятор выполнил неявное преобразование `int` к классу `Error`, вызвав конструктор `Error`, принимающий `int`.
Чтобы запретить неявные преобразования аргументов конструктора, пометьте его спецификатором [explicit](https://en.cppreference.com/w/cpp/language/explicit):
```cpp {.example_for_playground .example_for_playground_003}
import std;
class Error
{
public:
explicit Error(int code)
{
m_code = code;
}
// ...
private:
int m_code = 0;
};
void handle_error(Error err, int retries_count)
{
std::println("Handling error: {} Retries left: {}", err, retries_count);
// ...
}
int main()
{
Error err(503);
int retries = 2;
handle_error(retries, retries); // Упс!
}
```
```
main.cpp:36:5: error: no matching function for call to 'handle_error'
36 | handle_error(retries, retries);
| ^~~~~~~~~~~~
```
Теперь компилятор не может выполнить неявное преобразование и завершает сборку с ошибкой.
Спецификатор `explicit` запрещает применение копирующей инициализации, для которой необходимо неявное приведение типов:
```cpp {.example_for_playground .example_for_playground_004}
class Array
{
public:
explicit Array(std::size_t length)
{ /*... */ }
};
int main()
{
Array arr1 = 3; // Ошибка!
Array arr2(3); // Ок
}
```
Добавление `explicit` к конструктору класса напрашивается в заданиях на практику [«Скользящее среднее»,](/courses/cpp/practice/cpp_moving_average/) [«Интерпретатор Brainfuck»](/courses/cpp/practice/cpp_brainfuck_interpreter/) и [«LRU кеш».](/courses/cpp/practice/cpp_lru_cache/) Пометьте конструкторы классов из практики спецификатором `explicit` и убедитесь, что тесты по-прежнему проходят.
## Direct-initialization: прямая инициализация {#block-direct-initialization}
[Direct-initialization](https://en.cppreference.com/w/cpp/language/direct_initialization) (прямая инициализация) заключается в явном вызове конструктора и передаче в него аргументов. Она существует еще со времен C++98.
Так выглядит прямая инициализация для переменной фундаментального типа:
```cpp
double score(7.89);
```
Если в скобках не указывать значение, то произойдет инициализация значением по умолчанию:
```cpp
double f()
{
// ...
return double(); // 0.0
}
```
А это — прямая инициализация объекта класса:
```cpp {.example_for_playground}
import std;
struct TaskContext
{
// Конструктор по умолчанию
TaskContext()
{
parent_id = 0;
is_suspended = false;
}
// Конструктор с параметрами
TaskContext(int parent_proc_id, bool suspended)
{
parent_id = parent_proc_id;
is_suspended = suspended;
}
int parent_id;
bool is_suspended;
};
int main()
{
// Прямая инициализация
TaskContext tc(9034, true);
std::println("{} {}", tc.parent_id, tc.is_suspended);
}
```
```
9034 true
```
Прямая инициализация предназначена для явного вызова необходимого конструктора. Компилятор перебирает все перегрузки конструктора и определяет, какая лучше соответствует переданным аргументам. А для встроенных типов она ведет себя так же, как копирующая:
```cpp
int x(5); // То же самое, что int x = 5;
```
Скорее всего, вы будете часто использовать прямую инциализацию при работе с [контейнерами.](/courses/cpp/chapters/cpp_chapter_0070) У них есть множество перегрузок конструктора. У `std::vector` [их 12,](https://en.cppreference.com/w/cpp/container/vector/vector) в том числе для копирования из другого вектора или из диапазона.
Инициализация вектора 8-ю элементами. Элементы при этом инициализируются конструктором по умолчанию:
```cpp
std::vector<std::string> v(8); // Прямая инициализация
std::println("{}", v);
```
```
["", "", "", "", "", "", "", ""]
```
Инициализация вектора 4-мя элементами со значением "-":
```cpp
std::vector<std::string> v(4, "-"); // Прямая инициализация
std::println("{}", v);
```
```
["-", "-", "-", "-"]
```
Реализуйте [шаблонную функцию](/courses/cpp/chapters/cpp_chapter_0050/#block-templates) `count_unique()`, которая принимает вектор и возвращает количество его уникальных элементов. Реализация должна занять одну строку. Вам поможет [одна из перегрузок](https://en.cppreference.com/w/cpp/container/unordered_set/unordered_set.html) конструктора `std::unordered_set`. Она создает множество из элементов, на которые указывает диапазон, ограниченный парой итераторов. {.task_text}
```cpp {.task_source #cpp_chapter_0131_task_0040}
template<class T>
std::size_t count_unique(std::vector<T> v)
{
}
```
Воспользуйтесь перегрузкой, принимающей итераторы на диапазон. У получившегося временного объекта вызовите метод `size()`. {.task_hint}
```cpp {.task_answer}
template<class T>
std::size_t count_unique(std::vector<T> v)
{
return std::set(v.begin(), v.end()).size();
}
```
### The most vexing parse
У прямой инициализации есть подводный камень, имя которому [the most vexing parse](https://en.wikipedia.org/wiki/Most_vexing_parse). Если переводить дословно, то это «самый раздражающий синтаксический разбор». Речь идет о правиле: все, что компилятор может трактовать как [объявление](/courses/cpp/chapters/cpp_chapter_0010/#block-declaration-definition) (declaration), должно рассматриваться как таковое. Это правило синтаксического анализа раздражает разработчиков, когда они хотят завести переменную, а получают объявление функции.
Рассмотрим с виду невинный пример: {#block-the-most-vexing-parse}
```cpp {.example_for_playground}
import std;
int main()
{
int x();
}
```
С точки зрения разработчика конструкция `int x();` выглядит как создание переменной `x`, инициализированной нулем. А с точки зрения компилятора это объявление функции `x()`, возвращающей `int`.
Откройте этот пример в [плэйграунде.](https://senjun.ru/playground/cpp/) Перейдите в файл CMakeLists.txt и убедитесь, что компилятору передается [флаг -Werror.](/courses/cpp/chapters/cpp_chapter_0012/#block-flags) После этого попробуйте собрать проект и посмотрите, как выглядит ошибка компиляции, связанная с the most vexing parse.
А теперь покажем, как избежать столкновения с the most vexing parse.
## Uniform-initialization: универсальная инициализация {#block-uniform-initialization}
Универсальная инициализация (uniform-initialization) появилась в C++11. Она также известна как brace-initialization, потому что значение переменной указывается в фигурных скобках:
```cpp
bool is_answer_correct{false};
int count{1};
int count = {1}; // То же самое, что без '='
int count{}; // 0 - инициализация значением по умолчанию
```
Еще одно название этой инициализации — unicorn initialization или «единорожья инициализация».
 {.illustration}
Откуда такое название? Во-первых, скобка `}` похожа на рог единорога. Во-вторых, эта инициализация по меркам C++ творит магию: она запрещает неявное приведение типов.
```cpp
int a = 8.7; // ОК
int b{8.7}; // Ошибка компиляции
int c{static_cast<int>(8.7)}; // ОК
```
В этом примере для инициализации переменной `c` мы явно привели типы через `static_cast`. Вы [познакомились с этим способом](/courses/cpp/practice/cpp_moving_average/#block-static-cast) в практике «Скользящее среднее».
Универсальная инициализация обладает еще одним достоинством: она решает проблему the most vexing parse. Чтобы исправить ее в примере кода из предыдущего раздела, достаточно заменить прямую инициализацию на универсальную:
```cpp
// The most vexing parse: объявление функции x()
// int x();
// Инициализация переменной x
int x{};
```
Универсальная инициализация считается более предпочтительным и современным способом, чем копирующая и прямая инициализация.
Вы уже применяли универсальную инициализацию [при создании](/courses/cpp/chapters/cpp_chapter_0072/#block-initialization) контейнеров:
```cpp
std::set<std::size_t> s{4, 5};
std::vector<int> v = {1, 2, 3}; // То же самое, что и без '='
```
Она подходит и для инициализации классов:
```cpp {.example_for_playground}
import std;
struct Range
{
Range(std::size_t min_val, std::size_t max_val)
{
min = min_val;
max = max_val;
}
std::size_t min = 0;
std::size_t max = 0;
};
int main()
{
// Универсальная инициализация:
Range range{3, 607};
std::println("{} {}", range.min, range.max);
}
```
```
3 607
```
Универсальная инициализация может применяться для аргументов и возвращаемых значений функций:
```cpp {.example_for_playground}
import std;
std::pair<int, bool> get_val()
{
return {6, true}; // Универсальная инициализация
}
void show_val(std::pair<int, bool> p)
{
std::println("{}", p);
}
int main()
{
show_val({7, false}); // Универсальная инициализация
show_val(get_val());
}
```
```
(7, false)
(6, true)
```
По возможности [предпочитайте универсальную инициализацию.](https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#Res-list) Она:
- Запрещает неявные преобразования.
- Решает проблему the most vexing parse.
- Предоставляет единообразный синтаксис для инициализации встроенных типов, классов и стандартных контейнеров списком значений.
Комфортно ли вам читать код с универсальной инициализацией?
```cpp
for(int i{0}; i < 10; ++i)
{
// ...
}
for (auto it{v.rbegin()}; it != v.rend(); ++it)
{
// ...
}
```
Если такой код кажется вам странным и трудным для восприятия, то наш совет: больше практикуйтесь. Читайте чужой код в стиле современного С++ и почаще сами применяйте универсальную инициализацию.
Вам нужно исправить функцию `make_new_job()`, на первой строке которой есть инициализация, приводящая к the most vexing parse. {.task_text}
С точки зрения разработчика конструкция `LoadJob job(LoadStatus());` выглядит как создание переменной `job` с вызовом конструктора `LoadJob(LoadStatus status)` и передачей в него нового объекта типа `LoadStatus`. У компилятора другое мнение на этот счет: он расценивает это как объявление функции `job()`, принимающей другую функцию, которая возвращает `LoadStatus` и не принимает параметров. {.task_text}
```cpp {.task_source #cpp_chapter_0131_task_0050}
enum class LoadStatus
{
NotStarted,
InProgress,
Ok,
Failed,
Canceled
};
struct LoadJob
{
LoadJob(LoadStatus status)
{
load_status = status;
}
void start()
{
load_status = LoadStatus::InProgress;
}
LoadStatus load_status;
};
LoadJob make_new_job()
{
LoadJob job(LoadStatus());
job.start();
return job;
}
```
Вместо прямой инициализации переменной `job` используйте универсальную. {.task_hint}
```cpp {.task_answer}
enum class LoadStatus
{
NotStarted,
InProgress,
Ok,
Failed,
Canceled
};
struct LoadJob
{
LoadJob(LoadStatus status)
{
load_status = status;
}
void start()
{
load_status = LoadStatus::InProgress;
}
LoadStatus load_status;
};
LoadJob make_new_job()
{
LoadJob job{LoadStatus{}};
job.start();
return job;
}
```
----------
## Резюме
Подытожим главу рекомендациями по выбору способа инициализации переменных.
По умолчанию предпочитайте _универсальную инициализацию._ Она подходит для встроенных типов, классов, а также для заполнения контейнера значениями в момент создания:
```cpp
// Uniform-initialization
bool in_range{true};
std::pair<std::size_t, bool> res{read_data()};
std::set<int> ids{834, 244, 11};
```
А для выбора конкретной перегрузки конструктора применяйте _прямую инициализацию:_
```cpp
// Direct-initialization
std::string three_tildas('~', 3);
Message msg("...");
```
Наша группа в telegram. Здесь можно задавать вопросы и общаться.
Задонатить. Если вам нравится курс, вы можете поддержать развитие площадки!