Почему в 100% наших программ есть undefined behavior

Почему в 100% наших программ есть undefined behavior

Sehnsucht (https://t.me/cxx95)

Для начала хочу сказать, что это - не кликбейт. Во всех программах, куда я коммичу на текущей РАБоте, присутствует самый настоящий undefined behavior. Это не умозрительный вывод (как в анекдоте "в любой сложной C++ программе обязательно есть UB") - в моем случае это конкретные места, которые можно найти простым грепом, и их довольно много. Но обо всем по порядку.


Перед этим вспомним, что есть несколько разных сортов "поведения", в числе их:

  • Undefined behavior (UB) - Поведение, на которое стандарт C++ не накладывает никаких требований. Компиляторы обычно полагают, что нет такой ситуации при которой код который содержит UB может выполниться, поэтому они могут делать неожиданные оптимизации: выкидывают if'ы, циклы, и прочее. Типичный пример - разыменование нулевого/неинициализированного указателя. Компилятор не обязан выводить warning/ошибку компиляции на UB. Начиная с C++23 можно целенаправленно создавать UB - std::unreachable().
  • Ill-formed, no diagnostic required (IFNDR) - Программа сломана, но компилятор не обязан говорить про поломку, потому что у него может не быть достаточно контекста. Типичный пример - нарушение One Definition Rule (ODR).
  • Implementation-defined behavior - Поведение зависящее от количества байт в int и прочих таргет-специфических вещей. Обычно где-то задокументировано.
  • Unspecified behavior - Такие вещи как состояние moved-from объекта. Обычно нигде не задокументировано.
  • Erroneous behavior [C++26] - Новый вид поведения, это некоторые случаи которые раньше были UB, но теперь от компиляторов тут ожидают warning/ошибку компиляцию.


UB в моем случае связан с понятием "объекта" в модели объектов C++. У каждого разработчика есть свое неформальное представление, что такое "объект", "ссылка", "указатель" и прочие вещи, но у них есть вполне формализованные определения.

По формальному представлению из стандарта, "объект" - это что-то, имеющее лайфтайм (lifetime) и занимающее память (storage). Лайфтайм это этакий костыль для C++ компилятора, нужный чтобы ассоциировать память с типом объекта, для слежки за aliasing rules; можно считать его "социальным конструктом".


Как многие знают, есть две стадии "создания объекта":

  1. Аллокация памяти
  2. Вызов конструктора

При аллокации на стеке (Obj obj;) или на куче (Obj* obj = new Obj;) происходят обе эти стадии, при placement new (Obj* obj = new (p) Obj;) только вторая, потому что первую уже кто-то сделал до этого. В реальности есть еще третья стадия:

3. Начало lifetime

Которая не генерирует никакого кода, а только обновляет стейт внутри компилятора. Описанные выше случаи производят стадии 1+2+3 или 2+3.


Пример, где на формальности забили:

void *p = std::malloc(n);
*reinterpret_cast<std::size_t*>(p) = n;

Вторая строка опасная - тут объекту std::size_t присваивается некоторое значение, но... Этого объекта там нет изначально, потому что мы не вызывали его конструктор! Технически, присутствует нарушение strict aliasing rules, который определяет, что объект можно трогать через указатель на его собственный тип T* или через char*/unsigned char*/std::byte* - так что происходит UB. Поэтому будет более безопасно переписать его так:

void *p = std::malloc(n);
new (p) std::size_t{n};

Так как std::size_t имеет тривиальный деструктор, больше ничего особого делать не надо - для конца лайфтайма этого объекта достаточно чтобы через std::free(p) сдохла занимаемая память. А так лайфтайм заканчивается в момент вызова деструктора объекта.


Еще неплохой пример, где видно как компилятор следит за лайфтаймами и иногда приходится ему помогать в редких кейсах:

template<typename T> struct X {
  T& r;
  auto& value(this auto& self) { return self.r; }
};

int main() {
  int n = 3;
  X<int> h{n};
  h.value()++;
  std::cout << n; // 4
  std::cout << h.value(); // 4

  int m = -3;
  // h = X<int>{m}; // не скомпилируется
  X<int>* p = new (static_cast<void*>(&h)) X<int>{m};
  std::cout << p->value(); // -3

  std::cout << h.value(); // UB (-3? 4? что-то еще?)
  std::cout << std::launder(&h)->value(); // -3
}

Здесь оригинальный объект h не был явно разрушен, и компилятор следя за лайфтаймами может вполне соптимизировать вызов h.value() == 4, поэтому приходится ставить противооптимизационный барьер std::launder(), чтобы компилятор понимал что надо потрогать биты в рантайме, а не смотреть только на исходники.


В каких случаях лайфтайм начинается? Выше были примеры через определение объекта (definition) и new-выражение. Но в целом правила начала лайфтайма это набор нескольких костылей, список случаев есть тут. Например, еще лайфтайм неявно начинается от вызовов некоторых унаследованных от С функций как std::malloc(), std::memcpy() и std::memmove(). Видимо, дело в том что огромные пласты кода пришли по наследству от языка С, а там никаких лайфтаймов нет как понятия, зато есть типичный код вида:

struct X { int a, b; };
X* p = static_cast<X*>(std::malloc(sizeof(X))); // вместо "new"
p->a = 1;
p->b = 2;

Так что в стандарте есть костыль, что пример выше - уже несколько лет как не является UB.

(Кстати, видимо поэтому memcpy/memmove нулевого размера это является UB - невозможно иметь объект нулевого размера)


Наконец посмотрим на наш случай. Я разрабатываю программы нескольких классов, например:

  • Чтение UDP-пакетов с онлайновой "маркет-датой" от биржи и реакция на них в торговой системе
  • Чтение/отправка TCP-пакетов с "транзакциями" от биржи / к бирже
  • Снова чтение UDP-пакетов с "маркет-датой", но исторических (из pcap-файлов), для генерации рисечерских данных
  • Чтение файлов имеющих кастомный бинарный формат

И во всех, во всех этих программах на лайфтайм забивается и код выглядит примерно так:

void parse_udp_packet(std::string_view buffer) {
  const auto *hdr = reinterpret_cast<const UdpHeader*>(buffer.data());
  const uint16_t len = hdr->length; // UB
  // ...
}

Здесь, конечно, нарушается string aliasing rule и оттого undefined behavior - конкретно в этом примере на момент чтения hdr->length. По факту, конечно, этот код работает в современных компиляторах - на правах жеста доброй воли.


Что с этим делать? Лично я не планирую ничего делать, тем более что таких reinterpret_cast'ов во всем коде примерно несколько тысяч. Но если представить, что мы бы захотели формально убрать UB, то придется выбрать нужный костыль, который стартует лайфтайм.

Например, можно сделать memcpy:

UdpHeader hdr;
std::memcpy(&hdr, buffer.data(), sizeof(UdpHeader));

Или сделать placement new объекта в самого себя, сделав 2+3 "стадии создания объекта":

UdpHeader* hdr = new (const_cast<char*>(buffer.data())) UdpHeader;


В C++23 добавили супер-костыль std::start_lifetime_as, который делает только третью стадию "создания объекта":

void parse_udp_packet(std::string_view buffer) {
  const auto *hdr = std::start_lifetime_as<UdpHeader>(buffer.data());
  const uint16_t len = hdr->length; // больше не UB!
  // ...
}


Почему-то до сих пор эта функция не реализована в компиляторах. Умельцы сделали такую реализацию из говна и палок:

template<class T>
requires (std::is_trivially_copyable_v<T> && std::is_implicit_lifetime_v<T>)
T* start_lifetime_as(void* p) noexcept {
    return std::launder(static_cast<T*>(std::memmove(p, p, sizeof(T))));
}

Здесь делается std::memmove объекта в самого себя, что как мы помним создает лайфтайм для объекта (потому что эта функция входит в список "магических функций"), и затем покрывается сверху другой "магической функцией" std::launder которая как помним используется для убирания ненужных оптимизаций. Планируется, что "настоящая" реализация данной функции будет иметь такой же эффект, но без "тела" memmove которое двигает байты, а впрочем компиляторы и так выбрасывают "тело" memmove (так как видят мув в самого себя) и оставляют только "магию" - то есть начало лайфтайма.


Впрочем, даже если бы эта функция была реализована в компиляторах, вряд ли бы я захотел поменять на нее все вхождения reinterpret_cast. Я бы предпочел, чтобы в стандарт добавился +1 костыль, что текущие касты начинают лайфтайм, чтобы не пришлось переписывать код.

Report Page