Возьмем следующую простенькую структуру
// пример утащен и изменен отсюда:
// https://twitter.com/hankadusikova/status/1626960604412928002
struct CharTable {
static_assert(CHAR_BIT == 8);
std::array<bool, 256> _is_whitespace {};
CharTable() {
_is_whitespace.fill(false);
}
bool is_whitespace(char c) const {
return this->_is_whitespace[c];
}
};
Все ли впорядке с этим безобидным методом is_whitespace
? Ну кроме того, что char
в C/C++ обычно восьмибитный, а в unicode есть пробельные символы, кодируемые 16 битами.
Давайте потестируем
int main() {
CharTable table;
char c = 128;
bool is_whitespace = table.is_whitespace(c);
std::cout << is_whitespace << "\n";
return is_whitespace;
}
При сборке с -fsanitize=undefined
получаем дивный результат
/opt/compiler-explorer/gcc-12.2.0/include/c++/12.2.0/array:61:36: runtime error: index 18446744073709551488 out of bounds for type 'bool [256]'
/opt/compiler-explorer/gcc-12.2.0/include/c++/12.2.0/array:61:36: runtime error: index 18446744073709551488 out of bounds for type 'bool [256]'
/app/example.cpp:14:38: runtime error: load of value 64, which is not a valid value for type 'bool'
Конкретное значение в третьей строке -- совершенно случайное. Было бы очень здорово стабильно увидеть 42, но увы.
Зато индекс в первых двух строках совсем не случайный.
Но погодите!
char c = 128;
это же точно меньше 256. Откуда 18446744073709551488
?
Будем разбираться. В деле замешаны две удачно разложенные ловушки.
-
С/C++ специфичная ловушка: знаковость типа
char
не специфицирована. В зависимости от платформы он может быть как знаковым, так и беззнаковым. На x86 чаще всего является знаковым. И изchar c = 128
получаетсяc = -128
. -
Ловушка, распространенная во многих языках, имеющих разные типы целых чисел, разной знаковости и длины. Например, Rust
pub fn main() {
let c : i8 = -5;
let c_direct_cast = c as u16;
let c_two_casts = c as u8 as u16;
println!("{c_direct_cast} != {c_two_casts}");
}
Мы увидим 65531 != 251
.
При преобразовании знакового целого меньшей длины к беззнаковому целому большей длины происходит знаковое расширение: старшие биты заполняются битом знака.
Тоже действует и в C/C++.
А теперь остается только взглянуть на сигнатуру std::array::operator[]
:
reference operator[]( size_type pos );
size_type
это беззнаковый size_t
. Под x86 он определенно больше чем char
.
Происходит прямой каст знакового char
в size_t
, знак расширяется, код ломается. Дело закрыто.
Со знаковым расширением иногда способны помочь статические анализаторы.
Нужно понимать что вы делаете при касте чисел и что хотите получить. Часто можно встретить конструкцию вида uint32_t extended_val = static_cast<uint32_t>(byte_val) & 0xFF
, чтоб гарантированно занулить верхние байты и избежать знакового расширения. Аналогичная конструкция может быть и при преобразовании int32 -> uint64
, и при любых других комбинациях -- только константу правильную писать не забывайте.
Из-за своей знаковой неспецифицированности тип char
очень опасен при работе с ним как с типом чисел. Крайне рекомендуется пользоваться соответствующими типами uint8_t
или int8_t
. Или другими подходящими, если на вашей целевой платформе в char
внезапно не 8 бит.