[Перевод] Что значит инициализировать int в C++?

703a4929eecdb8a6a09518df57aaae91.png

Недавно я получил по почте от Сэма Джонсона этот вопрос. Вот слегка отредактированное письмо Сэма:

«Возьмём для примера этот код в локальной области видимости функции:

int a;
a = 5;

Многие люди считают, что инициализация происходит в строке 1, потому что веб-сайты наподобие cppreference дают такое определение: «Инициализация переменной предоставляет его начальное значение на момент создания».

Однако я убеждён, что инициализация происходит в строке 2, потому что [в разных хороших книгах по C++] инициализация определяется как первое существенное значение, попадающее в переменную.

Можете ли вы сказать, какая строка считается инициализацией?»

Отличный вопрос. На Cppreference написано правильно, и для всех классовых типов ответ прост: объект инициализируется в строке 1 вызовом его стандартного конструктора.

Но (а вы ведь знали, что будет «но») для локального объекта фундаментального встроенного типа наподобие int ответ будет… чуть более сложным. И именно поэтому Сэм задал этот вопрос, ведь он знает, что язык достаточно свободно обращается с инициализацией таких локальных объектов по историческим причинам, имевшим в то время смысл.

Короткий ответ: вполне допустимо говорить, что переменная получает своё исходное значение в строке 2. Но заметьте, что я намеренно не сказал «Объект инициализируется в строке 2», к тому же и код, и этот ответ обходят молчанием более важный вопрос: «Ну ладно, а что, если код между строками 1 и 2 попробует считать значение объекта?»

Этот пост состоит из трёх частей:

  • До C++26 ситуация была достаточно неловкой. Но самое забавное то, как это описывается сегодня в Стандарте, ниже я не удержался от цитирования.

  • В C++26 мы сделали этот код безопасным по умолчанию, благодарить за это стоит Томаса Кёппе! Это был очень важный шаг.

  • В моём эксперименте Cpp2 эта проблема полностью исчезла, и все типы обрабатываются одинаково, с гарантированной безопасностью инициализации. Я хочу предложить такое решение для самого ISO C++ после C++26, чтобы ISO C++ мог эволюционировать и полностью избавиться от этой проблемы в будущем, если сложится консенсус о внесении такого изменения.

Давайте начнём с современности, со статус-кво, сложившегося до выпуска C++26…

Ответ до C++26: переменная никогда не «инициализируется»

В случае нескольких встроенных типов, например, int, ответ заключается в том. что в данном примере вообще не происходит инициализации, потому что (строго говоря) ни одна из строк не выполняет инициализацию. Если вас это удивляет, то вот объяснение:

  • В строке 1 объявляется неинициализированный объект. У него нет начального значения, ни явного, ни косвенного.

  • Далее в строке 2 присваивается «начальное значение». Эта операция перезаписывает биты объекта биты объекта и присваивает объекту то же значение, что и биты, инициализированные таким образом в строке 1…, но это присвоение, а не инициализация (конструкция).

Тем не менее, я думаю, разумно будет неформально назвать строку 2 «заданием начального значения» в том смысле, что это записывание в этот объект первого существенного для программы значения. С формальной точки зрения это не инициализация, но в конечном итоге биты становятся одинаковыми, и в хороших книгах строку 2 могут резонно называть «инициализацией a».

«Но постойте-ка», — может сказать кто-то. «Вчера вечером я читал Стандарт, и в [dcl.init] говорится, что строка 1 — это и есть «инициализация значением по умолчанию»! То есть строка 1 и есть инициализация!» На эти утверждения я могу ответить «да» и «нет». Давайте же взглянем на формальный точный и довольно забавный ответ из Стандарта, он просто великолепен: Стандарт действительно гласит, что в строке 1 объект инициализируется значением по умолчанию… но,  для типов наподобие int, термин «инициализируется значением по умолчанию» обозначает «инициализация не выполняется».

Я это не придумал, см. параграф 7 [dcl.init].

(Самое время сказать: «Стандарт — это не туториал»… Иными словами, не стоит читать Стандарт для изучения языка. Стандарт достаточно чётко описывает действия C++, и нет ничего плохого в том, что он определяет всё таким образом, это совершенно нормально. Но он не написан для обывателя, и никто не обвинит вас, если вы подумаете, что «инициализация значением по умолчанию означает отсутствие инициализации» — это пример когнитивного диссонанса, оруэлловского двоемыслия (это не одно и то же) или пассивно-агрессивной провокации.)

Можно задать близкий этому вопрос: началось ли время жизни объекта после строки 1? Хорошие новости заключаются в том, что да, в строке 1 действительно началось время жизни неинициализированного объекта, согласно параграфу 1 [basic.life]. Но давайте не будем слишком вдаваться в разбор фразы о «пустой инициализации» из этого параграфа, потому что это ещё одно иносказание Стандарта той же концепции «это инициализация, хотя нет, мы просто пошутили». (Я ведь уже говорил, что Стандарт — это не туториал?) И, разумеется, это серьёзная проблема, ведь время жизни объекта уже началось, но он ещё не инициализирован предсказуемым значением. Это наихудшая проблема неинициализированной переменной, ведь считывание из неё может представлять угрозу для безопасности; это настоящее «неопределённое поведение», способное на что угодно, и нападающие могут использовать это свойство.

К счастью, в C++26 ситуация с безопасностью становится намного лучше…

C++26: всё становится лучше (на самом деле) и безопасным по умолчанию

Всего несколько месяцев назад (в марте 2024 года, на совещании в Токио) мы улучшили эту ситуацию в C++26, внедрив статью Томаса Кёппе P2795R5, «Erroneous behavior for uninitialized reads». Возможно, её название может показаться знакомым для читателей моего блога, ведь я упоминал её в своём отчёте о поездке в Токио.

В C++26 была создана новая концепция ошибочного поведения (erroneous behavior), которая лучше «неопределённого» или «неуточнённого», ведь она позволяет нам рассуждать о коде »который точно определён как ошибочный» (серьёзно, это почти прямая цитата из статьи), а поскольку код теперь точно определён, мы избавляемся от угрозы безопасности, связанной с «неопределённым поведением». Можно воспринимать это как инструмент Стандарта, позволяющий превратить некое поведение из «пугающе неопределённого» в «что ж, частично это наша вина, потому что мы позволили вам написать этот код, который значит не то, что должен значить, но на самом деле вы написали здесь баг, и мы поставим ограждение вокруг этой ямы с кольями, чтобы по умолчанию вы в неё не падали». И впервые эта концепция была применена к… барабанная дробь… неинициализированным локальным переменным.

И это очень важно, потому что означает, что строка 1 из исходного примера по-прежнему не инициализирована, но начиная с C++26 это становится «ошибочным поведением», то есть при сборке кода компилятором C++26 неопределённое поведение не может возникнуть при чтении неинициализированного значения. Да, из этого следует, что компилятор C++26 будет генерировать отличающийся от предыдущего код… Он гарантировано запишет известное компилятору ошибочное значение (но это не гарантирует, что на него может положиться программист, так что к нему по-прежнему ноль доверия), если есть хоть какая-то вероятность, что значение могут считать.

Это кажется несущественным, но на самом деле это важное улучшение, доказывающее, что комитет серьёзно настроен на активное изменение нашего языка в сторону его безопасности по умолчанию. Тенденцию увеличения объёмов безопасного по умолчанию кода мы будем наблюдать в ближайшем будущем C++, и это можно только приветствовать.

Пока вы ждёте, что ваш любимый компилятор C++26 добавит поддержку этого, можно получить аппроксимацию этой функции при помощи переключателя GCC или Clang -ftrivial-auto-var-init=pattern или при помощи переключателя MSVC /RTC1 (поторопитесь использовать их, если можете). Они дадут вам практически всё то, что даст C++26, за исключением, возможно, того, что не будут создавать диагностику (например, переключатель Clang создаёт диагностику, только если запустить Memory Sanitizer).

Например, рассмотрим, как это новое поведение по умолчанию препятствует утеканию секретов, на примере программы, скомпилированной с сегодняшним флагом и без него (ссылка на Godbolt):

template
auto print(char (&a)[N]) { std::cout << std::string_view{a,N} << "\n"; }
 
auto f1() {
    char a[] = {'s', 'e', 'c', 'r', 'e', 't' };
    print(a);
}
 
auto f2() {
    char a[6];
    print(a);  // сегодня этот код, вероятно, выведет "secret"
}
 
auto f3() {
    char a[] = {'0', '1', '2', '3', '4', '5' };
    print(a);  // перезаписывает "secret"
}
 
int main() {
    f1();
    f2();
    f3();
}

Стандартно все три локальных массива используют одно и то же стековое хранилище, и после того, как f1 вернёт строку secret, она, вероятно, всё ещё будет находиться в стеке, ожидая, что на неё наложится массив f2.

В сегодняшнем C++ по умолчанию без -ftrivial-auto-var-init=pattern или /RTC1 функция  f2, вероятно, выведет secret. Что может вызвать, скажем так, проблемы безопасности и защиты. Такое неопределённое поведение правила отсутствия инициализации и создаёт плохую репутацию C++.

Но при использовании -ftrivial-auto-var-init=pattern компиляторов GCC и Clang или /RTC1 компилятора MSVC , а также начиная с C++26 и далее по умолчанию функция  f2 не приведёт к утечке секрета. Как иногда говорит Бьёрн в других контекстах, «Это прогресс!» А тем ворчунам, кто, возможно, хотел бы сказать: «Автор, я привык к небезопасному коду, избавление от небезопасного кода по умолчанию противоречит духу C++», отвечу, что (а) таково настоящее и (б) привыкайте к этому, потому что подобного в дальнейшем будет намного больше.

Дополнение: часто задают вопрос о том, почему бы не инициализировать переменную значением 0? Это предлагают постоянно, но это не лучший ответ по многим причинам. Вот две основные: (1) ноль не всегда бывает существенным для программы значением, так что инъецирование его часто приводит к замене одного бага другим; (2) часто он активно маскирует от санитайзеров сбои инициализации, поэтому мы не можем увидеть ошибку и сообщить о ней. Использование определённого реализацией хорошо известного «ошибочного» битового паттерна не приводит к таким проблемам.

Но это ведь C++, так что вы всегда можете при необходимости взять полный контроль в свои руки и получить максимальную производительность. Так что да, при сильном желании C++26 позволяет отказаться от этого, написав  [[indeterminate]], но каждое использование этого атрибута должно подвергаться проверке при каждом ревью кода и иметь чёткое оправдание в виде точных измерений производительности, демонстрирующих необходимость переопределения безопасного поведения по умолчанию:

int a [[indeterminate]] ;
    // Так в C++26 можно сказать "да, пожалуйста, сделай мне больно,
    // мне нужна эта старая опасная семантика"

После C++26: что ещё мы можем сделать?

Вот, какая у нас ситуация до C++26 (самые проблемные строки — 4 и 5):

// В современном C++ до C++26 для локальных переменных
 
// Применение фундаментального типа наподобие 'int'
int a;            // объявление без инициализации
std::cout << a;   // неопределённое поведение: чтение неинициализированной переменной
a = 5;            // присвоение (не инициализация)
std::cout << a;   // выводит 5
 
// Применение классового типа наподобие 'std::string'
string b;         // объявление с конструкцией по умолчанию
std::cout << b;   // выводит "": чтение сконструированного по умолчанию значения
b = "5";          // присвоение (не инициализация)
std::cout << b;   // выводит "5"

Стоит отметить, что строка 5 может и ничего не выводить… это неопределённое поведение, так что вам повезёт, если вопрос будет только в выводе и не выводе, ведь соответствующий стандартам компилятор, теоретически, может сгенерировать код, стирающий жёсткий диск, вызывающий nasal demons или приводящий к другим традиционным проказам неопределённого поведения.

А вот, с чего мы начинаем в C++26 (отличия находятся в строках 4 и 5):

// В C++26 для локальных переменных
 
// Применение фундаментального типа наподобие 'int'
int a;            // декларация с неким ошибочным значением
std::cout << a;   // выводит ? или прекращает выполнение: чтение ошибочного значения
a = 5;            // присвоение (не инициализация)
std::cout << a;   // выводит 5
 
// Применение классового типа наподобие 'std::string'
string b;         // объявление с конструкцией по умолчанию
std::cout << b;   // выводит "": чтение сконструированного по умолчанию значения
b = "5";          // присвоение (не инициализация)
std::cout << b;   // выводит "5"

Хорошие новости: теперь наши жёсткие диски в безопасности: реализация может вывести значение или прервать выполнение, но неопределённого поведения не будет.

Мелким шрифтом: компиляторы C++26 обязаны заставить строку 4 переписать биты известным значением, и мотивированы сообщить о проблеме в строке 5 (но не обязаны этого делать).

В моём экспериментальном синтаксисе Cpp2 локальные переменные всех типов определяются так: a: some_type = initial_value;. Можно опустить часть с = initial_value , чтобы дать понять, что пространство стека выделено под переменную, но сама её инициализация отложена, после чего Cpp2 гарантирует инициализацию до использования; вы обязаны выполнить инициализацию позже при помощи = (например,  a = initial_value;), прежде чем как-то использовать переменную, что обеспечивает нам гибкость, например, позволяет использовать разные конструкторы для одной и той же переменной по разным путям ветвления. То есть эквивалентный пример будет таким (отличия от C++26 находятся в строках 4–6 и 10–12):

// Локальные переменные в моём синтаксисе Cpp2
 
// Применение фундаментального типа наподобие 'int'
a: int;              // выделяет пространство, без инициализации
// std::cout << a;   // недопустимо: нельзя использовать до инициализации!
a = 5;               // конструкция => реальная инициализация!
std::cout << a;      // выводит 5
 
// Применение классового типа наподобие 'std::string'
b: string;           // выделяет пространство, без инициализации
// std::cout << b;   // недопустимо: нельзя использовать до инициализации!
b = "5";             // конструкция => реальная инициализация!
std::cout << b;      // выводит "5"

В Cpp2 намеренно не оставлено простых способов отказаться от такой схемы и использовать переменную до её инициализации. Чтобы добиться этого, нужно создать в стеке массив сырых std::byte или что-то подобное, а затем выполнить unsafe_cast, чтобы притвориться, что это другой тип… Писать это длинно и сложно, ведь я считаю, что небезопасный код должен быть длинным и сложным в написании…, но его можно при необходимости написать, потому что такова природа C++:  я могу осуждать небезопасный код, который вы захотите написать ради производительности, но я до смерти буду защищать ваше право писать его при необходимости; C++ всегда позволяет залезть внутрь и взять управление на себя. Я стремлюсь перейти от модели «производительность по умолчанию, безопасность всегда доступна», в которой для обеспечения безопасности нужно прикладывать дополнительные усилия, к модели «безопасность по умолчанию, производительность всегда доступна». Я придумал для этого такую метафору: мне не хочется отбирать у программистов на C++ острые ножи, потому что шеф-поварам иногда нужны острые ножи;, но когда ножами не пользуются, мы просто хотим положить их в ящик, который нужно осознанно открывать, а не разбрасывать их по полу и постоянно напоминать людям, чтобы они смотрели под ноги.

Пока эта модель работает очень хорошо и обладает тройным преимуществом: производительность (инициализация не выполняется, пока вам это не нужно), гибкость (можно вызвать тот реальный конструктор, который мне нужен), безопасность (реальная «инициализация» с реальной конструкцией и никогда не возникает ситуации использования до инициализации). Думаю, когда-нибудь это может появиться и в ISO C++, и я намерен через год-два отправить предложение в этом стиле комитету ISO C++, сделав его максимально убедительным. Возможно, комитету оно понравится, или же он найдёт незамеченные мной недостатки… Посмотрим! Как бы то ни было, я буду сообщать о новостях в своём блоге.

Ещё раз благодарю Сэма Джонсона за этот вопрос!

Habrahabr.ru прочитано 17198 раз