Польза строгой типизации в C++: практический опыт

@
Наша программа обрабатывает сетевые пакеты, в частности, заголовки TCP/IP/etc. В них числовые значения — смещения, счетчики, адреса — представлены в сетевом порядке байтов (big-endian); мы же работаем на x86 (little-endian). В стандартных структурах, описывающих заголовки, эти поля представлены простыми целочисленными типами (uint32_t, uint16_t). После нескольких багов из-за того, что порядок байтов забыли преобразовать, мы решили заменить типы полей на классы, запрещающие неявные преобразования и нетипичные операции. Под катом — утилитарный код и конкретные примеры ошибок, которые выявила строгая типизация.

Порядок байтов


Ликбез для тех, кто не в курсе про порядок байтов (endianness, byte order). Более подробно уже было на «Хабре».

При обычной записи чисел слева идут от старшего (слева) к младшему (справа): 43210 = 4×102 + 3×101 + 2×100. Целочисленные типы данных имеют фиксированный размер, например, 16 бит (числа от 0 до 65535). В памяти они хранятся как два байта, например, 43210 = 01b016, то есть байты 01 и b0.

Напечатаем байты этого числа:

#include  // printf() #include  // uint8_t, uint16_t int main() { uint16_t value = 0x01b0; printf("%04x\n", value); const auto bytes = reinterpret_cast(&value); for (auto i = 0; i 

На обычных процессорах Intel или AMD (x86) получим следующее:

01b0 b0 01 

Байты в памяти расположены от младшего к старшему, а не как при записи чисел. Такой порядок называется little-endian (LE). То же верно для 4-байтовых чисел. Порядок байтов определяется архитектурой процессора. «Родной» для процессора порядок называется еще порядком ЦП или хоста (CPU/host byte order). В нашем случае host byte order — это little-endian.

Однако интернет рождался не на x86, и там порядок байтов был другой — от старшего к младшему (big-endian, BE). Его и стали использовать в заголовках сетевых протоколов (IP, TCP, UDP), поэтому big-endian еще называют сетевым порядком байтов (network byte order).

Пример: порт 443 (1bb16), по которому ходит HTTPS, в заголовках TCP записан байтами bb 01, которые при чтении дадут bb0116 = 47873.

// Все uint16_t и uint32_t здесь в сетевом порядке байтов. struct tcp_hdr { uint16_t th_sport; uint16_t th_dport; uint32_t th_seq; uint32_t th_ack; uint32_t th_flags2 : 4; uint32_t th_off : 4; uint8_t th_flags; uint16_t th_win; uint16_t th_sum; uint16_t th_urp; } __attribute__((__packed__)); tcp_hdr* tcp = ...; // указатель на часть сетевого пакета // Неправильно: dst_port в BE, а 443 в LE. if (tcp->dst_port == 443) { ... } // Неправильно: ++ оперирует LE, а sent_seq в BE. tcp->sent_seq++; 

Порядок байтов в числе можно преобразовывать. Например, для uint16_t есть стандартная функция htons() (host tonetwork for short integer — из порядка хоста в сетевой порядок для коротких целых) и обратная ей ntohs(). Аналогично для uint32_t есть htonl() и ntohl() (long — длинное целое).

// Правильно: сравниваем BE поле заголовка с BE значением. if (tcp->dst_port == htons(443)) { ... } // Сначала переводим BE значение из заголовка в LE, увеличиваем на 1, // затем переводим LE сумму обратно в BE. tcp->sent_seq = htonl(ntohl(tcp->sent_seq) + 1); 

К сожалению, компилятор не знает, откуда взялось конкретное значение переменной типа uint32_t, и не предупреждает, если смешать значения с разным порядком байтов и получить неверный результат.

Строгая типизация


Риск перепутать порядок байтов очевиден, как с ним бороться?

  • Code review. В нашем проекте это обязательная процедура. К сожалению, проверяющим меньше всего хочется вникать в код, который манипулирует байтами: «вижу htons() — наверное, автор обо всем подумал».
  • Дисциплина, правила наподобие: BE только в пакетах, все переменные в LE. Не всегда разумно, например, если нужно проверять порты по хэш-таблице, эффективнее хранить их в сетевом порядке байтов и искать «как есть».
  • Тесты. Как известно, они не гарантируют отсутствие ошибок. Данные могут быть неудачно подобраны (1.1.1.1 не меняется при преобразовании порядка байтов) или подогнаны под результат.

При работе с сетью нельзя абстрагироваться от порядка байтов, поэтому хотелось бы сделать так, чтобы его нельзя было проигнорировать при написании кода. Более того, у нас не просто число в BE — это номер порта, IP-адрес, номер последовательности TCP, контрольная сумма. Одно нельзя присваивать другому, даже если количество бит совпадает.

Решение известно — строгая типизация, то есть отдельные типы для портов, адресов, номеров. Кроме того, эти типы должны поддерживать конвертацию BE/LE. Boost.Endian нам не подходит, так как в проекте нет Boost.

Размер проекта около 40 тысяч строк на C++17. Если создать безопасные типы-обертки и переписать на них структуры заголовков, автоматически перестанут компилироваться все места, где есть работа с BE. Придется один раз пройтись по ним всем, зато новый код будет только безопасным.

Класс числа в big-endian
#include  #include  #define PACKED __attribute__((packed)) constexpr auto bswap(uint16_t value) noexcept { return __builtin_bswap16(value); } constexpr auto bswap(uint32_t value) noexcept { return __builtin_bswap32(value); } template struct Raw { T value; }; template Raw(T) -> Raw; template struct BigEndian { using Underlying = T; using Native = T; constexpr BigEndian() noexcept = default; constexpr explicit BigEndian(Native value) noexcept : _value{bswap(value)} {} constexpr BigEndian(Raw raw) noexcept : _value{raw.value} {} constexpr Underlying raw() const { return _value; } constexpr Native native() const { return bswap(_value); } explicit operator bool() const { return static_cast(_value); } bool operator==(const BigEndian& other) const { return raw() == other.raw(); } bool operator!=(const BigEndian& other) const { return raw() != other.raw(); } friend std::ostream& operator


  • Заголовочный файл с этим типом будет включаться повсеместно, поэтому вместо тяжелого используется легковесный .
  • Вместо htons() и т. п. — быстрые интринсики компилятора. В частности, на них действует constant propagation, поэтому конструкторы constexpr.
  • Иногда уже есть значение uint16_t/uint32_t, находящееся в BE. Структура Raw с deduction guide позволяет удобно создать из него BigEndian.

Спорным моментом здесь является PACKED: считается, что упакованные структуры хуже поддаются оптимизации. Ответ один — мерить. Наши бенчмарки кода не выявили замедления. Кроме того, в случае сетевых пакетов положение полей в заголовке все равно фиксировано.

В большинстве случаев над BE не нужны никакие операции, кроме сравнения. Номера последовательностей требуется корректно складывать с LE:

using BE16 = BigEndian; using BE32 = BigEndian; struct Seqnum : BE32 { using BE32::BE32; template Seqnum operator+(Integral increment) const { static_assert(std::is_integral_v); return Seqnum{static_cast(native() + increment)}; } } PACKED; struct IP : BE32 { using BE32::BE32; } PACKED; struct L4Port : BE16 { using BE16::BE16; } PACKED; 

Безопасная структура заголовка TCP
enum TCPFlag : uint8_t { TH_FIN = 0x01, TH_SYN = 0x02, TH_RST = 0x04, TH_PUSH = 0x08, TH_ACK = 0x10, TH_URG = 0x20, TH_ECE = 0x40, TH_CWR = 0x80, }; using TCPFlags = std::underlying_type_t; struct TCPHeader { L4Port th_sport; L4Port th_dport; Seqnum th_seq; Seqnum th_ack; uint32_t th_flags2 : 4; uint32_t th_off : 4; TCPFlags th_flags; BE16 th_win; uint16_t th_sum; BE16 th_urp; uint16_t header_length() const { return th_off > 2; } uint8_t* payload() { return reinterpret_cast(this) + header_length(); } const uint8_t* payload() const { return reinterpret_cast(this) + header_length(); } }; static_assert(sizeof(TCPHeader) == 20); 

  • TCPFlag можно было бы сделать enum class, но на практике над флагами делается всего две операции: проверка вхождения (&) либо замена флагов на комбинацию (|) — путаницы не возникает.
  • Битовые поля оставлены примитивными, но сделаны безопасные методы доступа.
  • Названия полей оставлены классическими.

Результаты


Большинство правок были тривиальными. Код стал чище:

 auto tcp = packet->tcp_header(); - return make_response(packet, - cookie_make(packet, rte_be_to_cpu_32(tcp->th_seq)), - rte_cpu_to_be_32(rte_be_to_cpu_32(tcp->th_seq) + 1), - TH_SYN | TH_ACK); + return make_response(packet, cookie_make(packet, tcp->th_seq.native()), + tcp->th_seq + 1, TH_SYN | TH_ACK); } 

Отчасти типы документировали код:

- void check_packet(int64_t, int64_t, uint8_t, bool); + void check_packet(std::optional, std::optional, TCPFlags, bool); 

Неожиданно оказалось, что можно неправильно считать размер окна TCP, при этом будут проходить unit-тесты и даже гоняться трафик:

 // меняем window size auto wscale_ratio = options().wscale_dst - options().wscale_src; if (wscale_ratio  WINDOW_SIZE_MAX) { window_size = WINDOW_SIZE_MAX; } 

Пример логической ошибки: разработчик оригинального кода думал, что функция принимает BE, хотя на самом деле это не так. При попытке использовать Raw{} вместо 0 программа просто не компилировалась (к счастью, это лишь unit-тест). Тут же видим неудачный выбор данных: ошибка нашлась бы скорее, если бы использовался не 0, который одинаков в любом порядке байтов.

- auto cookie = cookie_make_inner(tuple, rte_be_to_cpu_32(0)); + auto cookie = cookie_make_inner(tuple, 0); 

Аналогичный пример: сначала компилятор указал на несоответствие типов def_seq и cookie, затем стало ясно, почему тест проходил раньше — такие константы.

- const uint32_t def_seq = 0xA7A7A7A7; - const uint32_t def_ack = 0xA8A8A8A8; + const Seqnum def_seq{0x12345678}; + const Seqnum def_ack{0x90abcdef}; ... - auto cookie = rte_be_to_cpu_32(_tcph->th_ack); + auto cookie = _tcph->th_ack; ASSERT_NE(def_seq, cookie); 

Итоги


В сухом остатке имеем:

  • Найден один баг и несколько логических ошибок в unit-тестах.
  • Рефакторинг заставил разобраться в сомнительных местах, читаемость возросла.
  • Производительность сохранилась, но могла бы снизиться — бенчмарки нужны.

Нам важны все три пункта, поэтому считаем, рефакторинг того стоил.

А вы страхуете себя от ошибок строгими типами?
Анализ
×