Очень многие программы на C++ становились и становятся жертвами подлого обмана со стороны этих двух братьев-близнецов. А также, естественно, невнимательности, спешки и автодополнения.
Почти все книжки по программированию на C++ учат, что при создании std::vector
желательно заранее резервировать память, особенно если вы знаете сколько у вас будет элементов в нем. Тогда при наполнении вектора он не будет реаллоцироваться, а значит ваша программа будет работать быстрее, не тратя время на перевыделение памяти.
Но вот беда. У std::vector
нет конструктора, в котором бы можно было указать: "Хочу пустой вектор, но с зарезервированной capacity = N".
у вектора есть другой конструктор -- заполняющий N
элементами по-умолчанию
// Создаст N пустых строк
std::vector<std::string> text(N);
Но это ведь не оптимально. Создать целый вектор, проинициализировать каждый элемент в нем, чтоб потом переписать их... Нет-нет, это неправильное использование C++!
У нас есть метод reserve()
, его надо вызвать после создания пустого вектора.
std::vector<std::string> text;
text.reserve(N);
Но снова напасть! Ведь у нас есть еще и метод resize()
, который также может принять ровно один аргумент, если элементы вектора конструируемы по-умолчанию.
И разумеется программисты их путают
- Имена короткие и начинаются одинаково
- Имеют схожий смысл
- Стоят рядом в выдаче автодополнения
- И в наши дни еще и ИИ-ассистент может не того из них посоветовать!
В итоге программист успешно создаст вектор в два раза больше чем хотелось и будет долго недоумевать, почему у него все интересующие элементы пустые
auto read_text(size_t N_lines) {
std::vector<std::string> text;
text.resize(N_lines); // Ай!
for (size_t line_no = 0; line_no < N_lines; ++line_no) {
std::string line;
std::getline(std::cin, line);
text.emplace_back(std::move(line));
}
return text;
}
Но никакого неопределенного поведения. Эх!
Но что если программист написал reserve()
там, где на самом деле требовался resize()
? Ну случайно. Причем эта случайность имеет довольно неплохой шанс: ведь выдача автодополнения часто упорядочена по алфавиту, а reserve
стоит в нем раньше.
std::pair<std::vector<std::byte>, size_t> read_text(std::istream& in, size_t buffer_len) {
std::vector<std::byte> buffer;
buffer.reserve(buffer_len);
in.read(reinterpret_cast<char*>(buffer.data()), buffer_len);
return {
std::move(buffer), static_cast<size_t>(in.gcount())
};
}
int main() {
auto [buffer, actual_size] = read_text(std::cin, 45);
for (size_t i = 0; i < actual_size; ++i) {
std::cout << static_cast<int>(buffer[i]) << "\n";
}
}
Программа будет успешно работать!
// Пример ввода/вывода
>> hello
104
101
108
108
111
И вот здесь стоит на минуту остановиться и отвлечься. Я множество раз видел на самых разных технических форумах заявления вида:
- С/C++ это языки для работы близко к железу.
- Undefined behavior это просто формальность.
- Есть программист знает, как работает память, как работает его программа и типы, он может эту ерунду игнорировать.
- И так далее.
Случай с reserve()
выше может быть использован в защиту такой спорной позиции.
Действительно. Я знаю, что
reserve()
действительно выделяет память так что диапазон[buffer.data(), buffer.data() + buffer_len)
валиден.std::istream::read
проинициализировал память в диапазоне[buffer.data(), buffer.data() + actual_size)
operator[]
вектора по умолчанию не проверяет переданный индекс- Итерации цикла доступа к вектору проходят в инициализированных пределах.
Поэтому все работает. Более того, оно даже работает без language undefined behaviour. Если вы скопируете реализацию std::vector к себе, удалите из нее все asserts с условными инструментациями санитайзеров, и станете пользоваться вот таким же странным образом, у вас в коде не будет неопределенного поведения. По крайней мере в этом примере.
Но. Это std::vector
. std
! И он декларирует library undefined behaviour -- вы обратились к элементу с индексом пределами [0, vector::size())
. А это не прощается.
Я не смог найти ни одного компилятора доступного онлайн, на котором бы можно было бы воспроизвести последовательность оптимизаций, приводящих к падению невероятной красоты. Но я видел несколько закрытых bug репортов в отношении Apple Clang, который такое проворачивал.
LLVM может генерировать под x86 инструкцию ud2
-- это недопустимая инструкция, часто используема как индикатор недостижимого кода. Если программа попытается ее выполнить, она умрет от сигнала SIGILL.
Код, который провоцирует неопределенное поведение может быть помечен как недостижимы и в дальнейшем заменен на ud2
или выброшен.
В нашем замечательном примере компилятору вполне известно, что buffer.size() == 0
. И его не меняли.
Так, например, если мы попробуем 1 в 1 переписать это же безобразие в Rust, агрессивно использующем возможности LLVM:
fn read(n: usize, mut reader: impl std::io::Read) -> std::io::Result<(Vec<u8>, usize)> {
// Резервируем память. Она будет неинициализированной
let mut buf = Vec::<u8>::with_capacity(n);
// unsafe Rust довольно сложен и формально здесь
// нельзя напрямую создавать &mut [u8] на неинициализированную память
// но "мы знаем что делаем" -- на результат это не повлияет. Пока.
let actual_size = reader.read(unsafe {
std::slice::from_raw_parts_mut(buf.as_mut_ptr(), n)
})?;
// В отличие от C++, в Rust можно сделать
// unsafe { buf.set_len(actual_size) };
// И сделать этот пример практически корректным.
// Но мы здесь собрались смотреть на Undefined Behavior
Ok((buf, actual_size))
}
pub fn main() {
let (buf, n) = read(42, std::io::stdin()).unwrap();
for i in 0..n {
println!("{}",
unsafe { buf.get_unchecked(i) }
)
}
}
В дебажной сборке мы упадем с ошибкой сегментации и сообщением
thread 'main' panicked at library/core/src/panicking.rs:220:5:
unsafe precondition(s) violated: slice::get_unchecked requires that the index is within the slice
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
thread caused non-unwinding panic. aborting.
Program terminated with signal: SIGSEGV
В релизной с -C opt-level=3
мы увидим пустой вывод и успешный выход.
И если посмотрим на сгенерированный код, то цикла мы в нем не обнаружим. Код обращения к элементам вектора был полностью выброшен как недостижимый. Спасибо аннотации assert_unsafe_precondition!(check_language_ub, ...)
.
example::main::h67df0b7f9b5f8d1a:
...
call qword ptr [rip + <std::io::stdio::Stdin as std::io::Read>::read::h30ce8d6974df759c@GOTPCREL]
mov esi, 42
test rax, rax
jne .LBB1_3 # Это unwrap
mov edx, 1
mov rdi, rbx
call qword ptr [rip + __rust_dealloc@GOTPCREL]
add rsp, 8
pop rbx
pop r14
ret
.LBB1_7:
"Ну это же Rust!" -- можно возразить, закатывая глаза. Да. Но это лишь дело времени, когда Clang начнет применять те же оптимизации к C++.
По-хорошему, конечно, не ошибаться и не путать reserve()
и resize()
...
Как выяснилось после множества экспериментов с разными утилитами, состояние диагностики подобного standard library level неопределенного поведения в C++, в 2024 году остается весьма плачевным
- Статические анализаторы, к сожалению, молчат.
- Санитайзеры по-умолчанию тоже не реагируют
_ITERATOR_DEBUG_LEVEL
от msvc молчаливо падает-fsanitize=address
перестает молчать только лишь с-stdlib=libc++
==1==ERROR: AddressSanitizer: container-overflow on address 0x504000000050 at pc 0x59481461d1b0 bp 0x7ffcf01b08b0 sp 0x7ffcf01b08a8
READ of size 1 at 0x504000000050 thread T0
Но стойте-стойте! А что если это не ошибка. Мы сознательно использовали reserve()
, так как он не инициализирует память. И хотели ее, как в примере с Rust, переписать какими-нибудь данными из файла и в конце изменить size()
. Но вектор просто не предоставляет такое API...
На этот случай в стандартной библиотеке C++ есть целых два более корректных способа
std::pair<std::unique_ptr<std::byte[]>, size_t> read_text(std::istream& in, size_t buffer_len) {
// Выделяем default-инициализированный буфер, но default инициализация
// массива тривиальных объектов это отсутствие инициализации.
auto buffer = std::make_unique_for_overwrite<std::byte[]>(buffer_len);
in.read(reinterpret_cast<char*>(buffer.get()), buffer_len);
return {
std::move(buffer), static_cast<size_t>(in.gcount())
};
}
Такой вариант также успешно работает как и первоначальный неправильный, но уже без неопределенного поведения
Но разумеется мы таким образом успешно потеряли информацию об оставшейся вместимости. Ведь она не привязана к unique_ptr
. Нужно привязать ее отдельно в рамках собственной структуры. Или забыть.
Да! Чудо случилось и в C++23 мы можем сделать почти так же замечательно эффективно как в Rust. Но только для "строк". Но ведь по старой доброй традиции из С, у нас строки это просто последовательность байт...
// Придется написать немного СharTraits магии, если мы хотим использовать std::basic_string c типом std::byte
struct ByteTraits {
using char_type = ::std::byte;
static char_type* copy(char_type* dst, char_type* src, size_t n) {
memcpy(dst, src, n);
return dst;
}
static void assign(char_type& dst, const char_type& src) {
dst = src;
}
};
std::basic_string<std::byte, ByteTraits> read_text(std::istream& in, size_t buffer_len) {
std::basic_string<std::byte, ByteTraits> buffer;
buffer.resize_and_overwrite(buffer_len, [&in](std::byte* buf, size_t len) {
in.read(reinterpret_cast<char*>(buf), len);
return static_cast<size_t>(in.gcount());
});
return buffer;
}
int main() {
auto buffer = read_text(std::cin, 45);
size_t actual_size = buffer.size();
std::cout << actual_size << std::endl;
for (size_t i = 0; i < actual_size; ++i) {
std::cout << static_cast<int>(buffer[i]) << "\n";
}
}
И ура! Оно также работает как ожидается.