Много лет назад моя рутинная работа заключалась в поддержке большой базы кода на C++. Этот проект был настоящим кормильцем всей компании, и в нём предоставлялся публичный HTTP API, через который принимались онлайн-платежи. Речь шла об обработке платежей в размере миллиардов евро ежегодно.

Тогда меня ещё было не назвать опытным C++-разработчиком. Разумеется, я знал о неопределённом поведении, но как о чём-то абстрактном, о беде, которая приключается только с новичками. Как же я был неправ!  

В этой статье везде, где написано «структура», я имею в виду «структура или класс».

Багрепорт

Итак, однажды приходит мне багрепорт. Речь идёт о конечной точке HTTP, возвращающей простой отклик. Отклик должен проинформировать клиента, как прошла операция: успешно или с ошибкой:

{
  "error": false,
  "succeeded": true,
}

или

{
  "error": true,
  "succeeded": false,
}

По-моему, на практике здесь использовался не JSON, а какой-то формат с привязкой к форме, точно не помню, но в контексте данного бага это не важно.

Эта модель данных неидеальна, но в ней отражено, что именно делала программа. Очевидно, результат может быть равен либо «успех», либо «ошибка», но не оба этих значения вместе, и отсутствовать значение также не может (это исключающее «ИЛИ»).

А в багрепорте сообщалось, что клиент получил следующий отклик:

{
  "error": true,
  "succeeded": true
}

Хм, ладно. Это невозможно, значит, здесь действительно баг.

Разбор полётов

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

struct Response {
  bool error;
  bool succeeded;

  std::string data;
};

void handle() {
  Response response;
  
  try {
    // [..] много операций с базой данных, *не* касающихся `отклика`.

    response.succeeded = true;
  } catch(...) {
    response.error = true;
  }
  response.write();
}

Вот ссылка на godbolt, по которой приведён примерно такой же код.

Значение «успех» устанавливается в нужное поле ровно в одном месте. Значение «ошибка» — тоже. Нигде более код не затрагивает двух этих полей.

Тогда я рот открыл от удивления. Как такое возможно, что оба поля оказываются true? Код без изысков. Каждое поле устанавливается всего один раз и при этом исключительно. Кажется, что невозможно, чтобы оба поля одновременно имели значение true.

Верёвка достаточно длинная, чтобы на ней повеситься

И тут меня стала донимать интуиция бывалого C-разработчика — а вдруг виной всему Response response;? Всё на то указывает, верно? В C чтение полей из response: — верный путь к неопределённому поведению. Ведь структура C не инициализируется.

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

Я засел за стандарт C++ и корпел над ним часами. Хорошо было бы смонтировать этот процесс под саундтрек из 80-х, как в тех эпизодах из старых боевиков, где герой качает железо в спортзале. Если коротко — да, выяснилось, что правила отличаются настолько, что об этом можно было бы написать книгу. Более того, в C++ они варьируются от версии к версии. В некоторых условиях Response response; — совершенно нормально. А в других приводит к неопределённому поведению.

Суть такова: правило инициализации по умолчанию действует, когда переменная объявляется без инициализатора. Это довольно сложно, но здесь я постараюсь объяснить как можно проще.

Инициализация по умолчанию происходит в определённых обстоятельствах при работе с синтаксисом объекта T object; :

  1. Если по типу T не является ни структурой, ни массивом, например, int a;, то никакой инициализации не происходит. Очевидно, перед нами неопределённое поведение.

  2. Если T — это массив, например, std::string a[10];, то всё нормально: каждый элемент инициализируется по умолчанию. Но обратите внимание: для некоторых типов, например, int: int a[10] инициализация по умолчанию не предусмотрена; в таком случае все эти элементы остаются неинициализированными.

  3. Если T — это POD (простая структура данных, использовались в языке C++ до C++11. С выходом версии C++11 формулировка в стандарте изменилась, но под термином «Trivially Default Constructible» (тривиально по умолчанию конструируемая) структура понимается то же самое), напр., Foo foo; то никакой инициализации не производится. Напоминает ситуацию, в которой мы бы выполнили int a;, а потом прочитали a. Очевидно, перед нами неопределённое поведение.

  4. Если T — это структура, не являющаяся POD, напр., Bar bar;, то вызывается конструктор по умолчанию, отвечающий за инициализацию всех полей. Легко пропустить какой‑нибудь из них или вообще забыть реализовать конструктор по умолчанию, что, опять же, приведёт к неопределённому поведению.

Важно различать первый и последний случай. В первом случае вызов конструктора по умолчанию пропускает сам компилятор. В последнем случае вызывается конструктор, заданный по умолчанию. Если конструктор по умолчанию в структуре не объявлен, то компилятор сгенерирует его за нас, а затем вызовет. Можете в этом убедиться, просмотрев сгенерированный ассемблер.

При возникновении такого бага имеем дело с последним случаем: тип Response не является простой структурой данных (поскольку содержит поле std::string data), поэтому вызывается конструктор по умолчанию. Response не реализует конструктор по умолчанию. Таким образом, компилятор сгенерирует конструктор по умолчанию за нас. Причём, в этом сгенерированном коде каждое поле структуры инициализируется по умолчанию. Таким образом, конструктор std::string вызывается применительно к полю data — и всё нормально. Кроме того, другие два поля не инициализируются каким-либо образом. Ой.

Кратко обобщим ситуацию:

Тип

Пример

Результат (Default Init)

Примитив (int, bool, etc)

int x;

Неопределённое поведение (мусорное значение)

простая / тривиальная структура

Point p;

Неопределённое поведение (все поля мусорные)

Массив объектов

std::string x[10];

Безопасно (все строки инициализируются)

Массив примитивов

int x[10];

Неопределённое (везде мусор)

Нетривиальная структура

Response r;

Вызывает конструктор по умолчанию (со структурами всё нормально, с примитивами получается нормально)

Любой тип (скобки)

T obj{};

Значение инициализировано (безопасно / заполнено нулями)

Следовательно, единственная возможность исправить структуру без необходимости править все точки вызовов — реализовать такой конструктор по умолчанию, который исправно инициализирует каждое поле:

struct Response {
  bool error;
  bool succeeded;

  std::string data;

  Response(): error{false}, succeeded{false}, data{} 
  {
  }
};

Вот ссылка на godbolt с разбором этого кода.

Разумеется, согласно правилу 6 (когда я начинал учить C++, речь шла о 3), теперь требуется реализовать и деструктор по умолчанию, конструктор перемещений по умолчанию и т.д, и т.п.

В качестве альтернативы можно определить значения по умолчанию для всех полей прямо в определении структуры, чтобы не определять конструктор по умолчанию:

struct Response {
  bool error = false;
  bool succeeded = false;

  std::string data;
}

В таком случае конструктор по умолчанию, сгенерированный компилятором, инициализирует все поля.

Что было потом

На момент описываемых событий я просто изменил точку вызова вот так:

  Response response{};

Вот ссылка на godbolt с разбором этого кода.

В таком случае поля error и succeeded принудительно инициализируются в ноль, и data также инициализируется по умолчанию. Причём, менять определение структуры не требуется.

Именно это я и порекомендовал коллегам по команде, обсуждая эту проблему: не дразните лукавого, просто при объявлении переменной всегда инициализируйте её в ноль.

Важно отметить, что в некоторых случаях синтаксис объявления Response response; полностью корректен — при соблюдении следующих условий:

  • По типу значение относится к массивам или к структурам, не являющимся POD и

  • Для каждого поля предусмотрен конструктор по умолчанию или значение по умолчанию, прописываемое прямо в определении структуры

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

Например:

struct Bar {
  std::string s;
  std::vector<std::string> vec;
};

int main() {
  Bar bar;

  // Выводит s=`` v.len=0
  // Неопределённого поведения нет
  printf("s=%s v.len=%zu\n", bar.s.c_str(), bar.vec.size());
}

Но, чтобы это узнать, необходимо (рекурсивно) проверить каждое поле или исходить из того, что каждый конструктор по умолчанию инициализирует каждое поле.

Наконец, стоит отметить, что при прочтении неинициализированного значения мы в любом случае получим неопределённое поведение. Если у вас просто есть неинициализированные поля — это ещё не неопределённое поведение. Если поля никогда не будут читаться, либо перед чтением в них будет записываться известное значение, то неопределённого поведения не возникнет.

Нам поможет статический анализ?

Компилятор (clang) не улавливает эту проблему, даже когда в нём включены все предупреждения. Это удручает — ещё бы, компилятор с готовностью генерирует и вызывает такой конструктор по умолчанию, который инициализирует не все поля. Таким образом, ожидается, что вызывающая сторона вручную заполнит все неинициализированные поля какими-то значениями? На мой взгляд, это нонсенс.  

clang-tidy проблему отлавливает. Но на момент описываемых событий механизм был несовершенным, вот что я тогда записал на этот счёт:

clang-tidy сообщает о данной проблеме при попытке передать такую переменную функции в качестве аргумента — только и всего. Мы же хотим выявить все проблемные точки, даже в тех случаях, когда значение не передаётся функции. Кроме того, clang-tidy сообщает только об одном выявленном случае — и выходит.

Правда, на данный момент ситуация как будто улучшилась, и компилятор сообщает обо всех проблемных точках, а не только о тех, что обнаруживаются в вызовах функций. Это отлично.

Также в моих заметках я писал, что cppcheck отлавливает такое без проблем, но теперь, пытаясь его использовать, обнаруживаю, что он видит не всё даже при --enable=all. То есть, может быть, либо программа немного деградировала, либо я использую её неправильно.

Нужно анализировать код во время выполнения

Вероятно, наиболее опытные специалисты по C или C++ сейчас в сердцах восклицают: да просто используйте санитайзер адресов (или коротко ASan)!

Давайте рассмотрим следующий проблематичный код:

$ clang++ main.cpp -Weverything -std=c++11 -g -fsanitize=address,undefined -Wno-padded
$ ./a.out
a.out(46953,0x1f7f4a0c0) malloc: nano zone abandoned due to inability to reserve vm space.
main.cpp:21:41: runtime error: load of value 8, which is not a valid value for type 'bool'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior main.cpp:21:41 
error=0 success=1

Отлично, мы выявили неопределённое поведение! Даже если сообщение об ошибке сформулировано не слишком понятно, ASan таким образом вам докладывает: «Это булево значение, я ожидал, что здесь будет 0 или 1, но в этой ячейке памяти я нашёл случайную 8».

В качестве альтернативы можно было бы воспользоваться Valgrind и получить тот же результат.

Но, это одновременно означает, что мы можем быть уверены в полном отсутствии неопределённого поведения в нашем коде, лишь обеспечив ему 100% покрытие тестами. Это серьёзное требование.

Кроме того, в рамках тех тестов, что я проводил, санитайзер адресов не всегда сообщал о проблеме. Такова природа этого инструмента: он консервативен и должен не допускать ложноположительных результатов, не утомлять пользователя уведомлениями, но именно поэтому он и не может отловить всех проблем.

Наконец, работа этих инструментов негативно сказывается на производительности и может немного осложнить процесс сборки.

Что насчёт санитайзера памяти?

В некоторых комментариях к оригиналу этой статьи читатели указывали, что есть другой инструмент для выявления именно таких проблем: санитайзер памяти.

Отличная штука, но на момент подготовки оригинала статьи этот инструмент не поддерживался ни под Windows, ни под macOS. Кроме того, как следует из документации, этот инструмент не работает со статическим связыванием.

То есть, в общем, он не очень удобен в использовании.

Что было потом

Тогда я написал плагин libclang, позволяющий выявлять другие случаи возникновения такой проблемы в базе кода во время сборки: https://github.com/gaultier/c/tree/master/libclang-plugin.

Забавно, что во всей базе кода нашёлся ещё всего один случай, который на поверку оказался ложноположительным результатом — просто оказалось, что вызывающая сторона выставляет неинициализированные поля, вот так:

Response response;
response.error = false;
response.success = true;

Не уверен, что этот плагин libclang до сих пор работает, поскольку слышал, что в API libclang часто вносятся принципиальные изменения.

Некоторые бонусные правила по обращению с C++

Помните все правила, которые мы обсудили выше? Хотите ещё? Что, если мы добавим к ним несколько лакомых специальных случаев?

Есть некоторые типы, которые не провоцируют неопределённого поведения, даже если их значение не инициализировано, если используются определёнными способами:

  • std::byte

  • unsigned char

  • char, если основополагающее представление является беззнаковым (unsigned)

Например, следующий код совершенно нормален, и неопределённого поведения в нём нет:

    unsigned char c;     // “c” обладает неопределённым/ошибочным значением
 
    unsigned char d = c; // неопределённое/ошибочное поведение отсутствует,
                         // но “d” имеет неопределённое/ошибочное значение
 
    assert(c == d);  // соблюдается, но в обоих случаях целочисленное повышение приводит к 
                         // неопределённому/ошибочному поведению

И при применении ASan этот механизм работает безупречно. Clang может выбросить ряд предупреждений, но нормально справится с работой, и это валидный код (по меркам стандарта C++).

Теперь давайте попробуем воспользоваться (например) bool:

  bool c; 
  bool d = c;
  assert(c == d);

Это неопределённое поведение, сразу же провоцирующее ошибки в ASan! Даже если в коде останутся прежними как размер типов, так и компоновка стека!

Не представляю, почему в стандарте C++ потребовалось дополнительно мутить воду, но определённо резоны были. Верно?

Наскоро проверив эту тему, пришёл к выводу, что эти типы — специальные, и с их помощью можно прямо в коде манипулировать сырыми байтами или управлять буфером, а также не злить при этом компилятор. Пожалуй… это оправданно? 

Заключение

На мой взгляд, в этом баге заключена вся суть C++:

  • Синтаксис, на вид напоминающий C, но иногда делающий что‑то совершенно не так, как в C, причём, незаметно. Этот синтаксис может быть абсолютно корректным (например, при работе с массивом, а в некоторых случаях — и с типами, не относящимися к числу POD) или приводить к неопределённому поведению. Напомню, что C и C++ — это разные языки.

  • Компилятор не предупреждает о неопределённом поведении и, чтобы выявить его, приходится полагаться на сторонние инструменты. Такие инструменты всегда в чём‑то ограничены, а, кроме того, обычно работают медленно.

  • Компилятор с готовностью генерирует такой конструктор по умолчанию, который оставляет объект в полуинициализированном состоянии

  • Правила в стандарте сформулированы запутанно и меняются от версии к версии стандарта (или, как минимум, в формулировках). Я просто ещё раз отмечу, что в стандарте C++26 эти правила вновь изменились. Изменилась и формулировка. Уф.

  • В C++ так много способ инициализировать переменную — и большинство из них неправильные.

  • Чтобы код работал правильно, разработчик должен продумывать не только точку вызова, но и полное определение структуры, а также учитывать, не POD ли это.

  • Если добавить или удалить в структуре одно поле (напр., поле data), то компилятор сгенерирует в точках вызовов совершенно другой код.

  • Нужно иметь учёную степень в программерском крючкотворстве, чтобы понимать, что именно понимается в стандарте под неопределённым поведением, и как его можно спровоцировать.

Напротив, мне правда очень нравится подход в стиле «POD», взятый на вооружение во многих языках от C до Go и Rust: структура трактуется как простые данные. Либо компилятор вынуждает вас установить каждое поле в структуре прямо на этапе её создания, либо не принуждает вас — и в таком случае инициализирует в ноль все неупомянутые поля. Это настолько просто, что по определению корректно (но давайте не будем говорить о неинициализированных заполненных нулями участках между полями в C :/ ).

Наконец, я рад, что мне попался этот баг, поскольку благодаря нему я впервые осознал всю реалистичность и опасность неопределённого поведения по одной простой причине: при неопределённом поведении в коде написано одно, а программа ведёт себя совершенно по-другому. Читая код, вы никак не могли бы спрогнозировать, что программа поведёт себя так. Код больше не является источником истины. В программе возникают невозможные значения, словно в ваш компьютер попал космический луч и перещёлкнул несколько битов. Причём, не составляет труда легко и непринуждённо спровоцировать неопределённое поведение, самому того не заметив.

Программисты — тоже люди, и мы по-настоящему усваиваем важность какой-либо проблемы (повреждение или гонка данных, неопределённое поведение, т.д.), лишь когда обожжёмся на ней, и она испортит нам день.

Комментарии (27)


  1. ulisma
    04.06.2026 18:50

    Интересно. Спасибо.


  1. tenzink
    04.06.2026 18:50

    В современном C++ не вижу ни одной причины не писать вот так:

    struct Response {
      bool error = false;
      bool succeeded = false;
    
      std::string data;
    };

    И все вопросы про инициализацию снимаются.

    P.S. статья по теме https://habr.com/ru/companies/jugru/articles/469465/ и легендарная гифка https://habrastorage.org/webt/bp/rd/ow/bprdow1rk6jtw5fzpm3bffzirps.gif


  1. blurman
    04.06.2026 18:50

    Зачем столько часов было тратить на изучение особенностей разных компиляторов? Понятно же, что поля не проинициализированы. Можно быстро исправить и потратить время на размышления о том, как плохо программировать на флагах.


    1. pavlushk0
      04.06.2026 18:50

      Можно поподробнее про программирование на флагах?


      1. blurman
        04.06.2026 18:50

        Программирование на флагах - это когда одно логическое состояние размазано по нескольким независимым булам. Как в статье: error и succeeded по смыслу взаимоисключающие, но как два bool дают 4 комбинации при 2 валидных. Ни одно поле не «владеет» ответом - единого источника правды нет, инвариант держится на дисциплине, а не на типе.

        И тут важный момент: инициализация полей структуру не спасает. Допустим, починили память:

        struct Response {
          bool error = false;
          bool succeeded = false;
          std::string data;
        };

        Языковой UB из статьи ушёл - мусора в памяти больше нет. Но {error:true, succeeded:true} по-прежнему может быть получено вручную в коде. Это уже не неопределённое поведение в смысле стандарта, а логический UB: тип разрешает состояние, которого по смыслу быть не может, и программа однажды в него попадёт. Инициализация лечит симптом, а не причину.

        Лечится тем, что за исход отвечает одно поле:

        enum class Outcome { Success, Failure };
        
        struct Response {
          Outcome outcome = Outcome::Failure;
          std::string data;
        };

        Теперь ответственность за состояние лежит на одном outcome, невозможных комбинаций физически нет, а новый код не сможет случайно выставить «и успех, и ошибку». UB в статье просто подсветил проблему программирования на флагах - корень в том, что взаимоисключающий выбор смоделировали через независимые флаги.


    1. LiliJulie
      04.06.2026 18:50

      Изучение стандарта никогда не бывает лишним. Человек один раз разобрался, как работает инициализация по умолчанию, и теперь точно не наступит на эти грабли снова


  1. rukhi7
    04.06.2026 18:50

    Феерично! Это идеальный пример того как ссылки на "неопределенное поведение" маскируют не понимание не только С++, но вообще основ программирования!

    Автору просто по фигу что если одно и тоже значение передавать два раза / скопировать в два поля, причем во второе с инверсией, то получается не два, а четыре возможных значения. По сути это и вызывает наивное удивление и жалобы о том что в С++ все очень неопределенно. Только при чем здесь С++. Если bool скопировать два раза то возможны ЧЕТЫРЕ варианта, чему здесь удивляться, при чем здесь С++ ?

    правильно будет что-то на базе:

    struct Response {
      
      std::optinal<bool> error;
      std::optinal<bool> succeeded;
    };

    или

    struct Response {
      
      bool error -> !res;
      bool succeeded -> res;
      privite:
      std::optinal<bool> res;
    };

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


  1. IIopy4uk
    04.06.2026 18:50

    По моему, с точки зрения архитектуры, структура передаваемого значения в виде

    { "error": false, "succeeded": true }

    означает, что проектировщики этого закладывали, что возможны ситуации, когда оба значения равны true или false, просто их надо правильно обрабатывать.

    Если была необходимость устанавливать в true только одного поля, передавалось бы только одно поле.


  1. ncix
    04.06.2026 18:50

    Ожидал продолжения, что исправление завалило прод с убытками на миллиарды евро


  1. peacemakerv
    04.06.2026 18:50

    Не знаю ни C, ни С++, поэтому стесняюсь спросить - а почему ОДНОЙ булевой переменной недостаточно в данной ситуации? Всего одной...


    1. LiliJulie
      04.06.2026 18:50

      Скорее всего историческое наследие. Сначала сделали success, потом кто-то решил, что нужен еще флаг error для совместимости с другим кривым апи. И так оно и поехало в прод


    1. tenzink
      04.06.2026 18:50

      Возможно нужно было 3 статуса: success, failure, error


      1. Groramar
        04.06.2026 18:50

        Мне кажется абсолютно логично было бы вернуть в этой ситуации одно перечисление с тремя состояниями. Взаимоисключение в этом случае было бы невозможно, а 'лишние' значения ловились бы элементарно приемником (что лучше всегда и делать)


        1. tenzink
          04.06.2026 18:50

          Тут остается только гадать за авторов. Второй bool мог появиться позже, чтобы не ломать совместимость


  1. nv13
    04.06.2026 18:50

    Автоматические переменные надо инициализировать если им что то сразу же не присваивается - не проще ли запомнить это простое правило, вместо изучения диалектов и имплементаций языка? Если эта переменная не в цикле на 100500 тыщ, то вообще всё равно, что там компилятор нагенерирвет на самое неэффективное, но идеологичесаи выдержанное решение)

    И да, разве сонар и компания не подсвечивают такое прямо в ide?


  1. Gutt
    04.06.2026 18:50

    Здесь прекрасно всё -- и зачем-то два поля в ответе с обязательно противоположными значениями (видимо, когда-то планировалось сделать error строкой и класть туда ошибку, но потом решили сделать какую-то дичь вместо этого), и наивная вера в то, что кто-то за тебя проинициализирует память.


  1. elmirius
    04.06.2026 18:50

    Правило 3 и правило 6 здесь вообще неактуальны - структура не управляет никакими ресурсами, которые нужно вручную копировать или перемещать. Ну это я так, для читающих комментарии, а по статье понятно, что новичок писал.


  1. pda0
    04.06.2026 18:50

    А в багрепорте сообщалось, что клиент получил следующий отклик:

    { "error": true, "succeeded": true }

    Это норма. :)


  1. LiliJulie
    04.06.2026 18:50

    База С++. Пишешь Response response;, получаешь мусор в памяти и гадаешь, в какой фазе луны компилятор решит это занулить)

    Правило одно: всегда инициализируй явно, даже если кажется, что оно само


  1. hren_sobachiy
    04.06.2026 18:50

    TL;DR: В пафосных конторах, которые обрабатывают платежи на милларды евро, работают такие же разъебаи как и ты. Но гонору-то на собесах!


  1. DoHelloWorld
    04.06.2026 18:50

    Не плюсы, но когда только начинал работать эмбеддером и прогал всякие мк, трогая всякие либы sdk, где железо инициализировалось заполнение структуры, выучил простое правило, заполняй все поля структуры которую выделил на стеке, даже если эти поля не нужны.


  1. tm1218
    04.06.2026 18:50

    Разве можно в C++ оставлять неинициализированные переменные. Опытный разработчик даже не будет поднимать такой вопрос.


    1. RootTool
      04.06.2026 18:50

      А почему нельзя? Например если ты уже знаешь что будешь перезаписывать память - то загулять не имеет смысла. На ПК кажется мелочью, но тот же embedded, где каждый байт программы на счету - это уже имеет хороший вес


      1. v_0ver
        04.06.2026 18:50

        А разве С\С++ компилятор не умеет выкидывать инициализацию если видит, что дальше память переписывается другим значением, и на этом протяжении не читается?

        Сдаётся мне, что все эти приседания с инициализацией переменных - наследие былых времён, когда оптимизация "dead store elimination" отсутствовала.


  1. tm1218
    04.06.2026 18:50

    я правильно понял, что в случаях: Response response и Response response{} - вызываются 2 разных конструктора, где в 1 предусмотрено только std::string(), а во 2 еще error() и success(), которые компилятор генерит исходя их кода использования объекта?


  1. RootTool
    04.06.2026 18:50

    Смысл всей басни такова: Не смотря C++, мы продолжаем его любить :)


    1. Groramar
      04.06.2026 18:50

      Мыши тоже, помню, плакали и кололись :)