
Недавно я получил по почте от Сэма Джонсона этот вопрос. Вот слегка отредактированное письмо Сэма:
«Возьмём для примера этот код в локальной области видимости функции:
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<int N>
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++, сделав его максимально убедительным. Возможно, комитету оно понравится, или же он найдёт незамеченные мной недостатки... Посмотрим! Как бы то ни было, я буду сообщать о новостях в своём блоге.
Ещё раз благодарю Сэма Джонсона за этот вопрос!
