Мы используем куки, чтобы пользоваться сайтом было удобно.
Хорошо
to the top
close form

Заполните форму в два простых шага ниже:

Ваши контактные данные:

Шаг 1
Поздравляем! У вас есть промокод!

Тип желаемой лицензии:

Шаг 2
Team license
Enterprise license
** Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности
close form
Запросите информацию о ценах
Новая лицензия
Продление лицензии
--Выберите валюту--
USD
EUR
RUB
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

close form
Бесплатная лицензия PVS‑Studio для специалистов Microsoft MVP
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

close form
Для получения лицензии для вашего открытого
проекта заполните, пожалуйста, эту форму
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

close form
Мне интересно попробовать плагин на:
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

close form
check circle
Ваше сообщение отправлено.

Мы ответим вам на


Если вы так и не получили ответ, пожалуйста, проверьте папку
Spam/Junk и нажмите на письме кнопку "Не спам".
Так Вы не пропустите ответы от нашей команды.

>
>
>
Сколько UB в моём компиляторе?

Сколько UB в моём компиляторе?

11 Июн 2024

У C и C++ программистов две головные боли в плане ошибок: утечки памяти и неопределённое поведение. И как вы догадались из названия, речь пойдёт о неопределённом поведении. И каком-то "моём" компиляторе. Если точнее, то о наборе компиляторов и инструментах для их разработки, а именно LLVM. Почему "моём"? Потому что мы очень любим Clang, входящий в состав LLVM, и пользуемся им на постоянной основе.

1130_LLVM_part2_ru/image1.png

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

Предисловие

Неопределённое поведение или Undefined Behavior (UB) — это когда программист написал код, который позволяют написать правила языка, и сделал это вроде бы правильно, но при этом программа работает неправильно. А может и правильно. Ведь UB — это когда стандарт вообще ничего не гарантирует по твоему коду.

Результат выполнения такой программы будет зависеть только от компилятора и целевой платформы. Из этого делаем вывод, что компиляторы могут сделать с вашей программой всё что угодно. Порой то, что вы и хотели. Но при этом оказывается, что ошибка просто умело скрывается, и вам повезло.

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

Например, когда на вашей стороне тесты пройдены, и продукт уже у клиента, но у него что-то не работает, хотя, казалось бы, работало пять минут назад у вас. И, конечно же, логов или понятных объяснений этому нет. Вернее, объяснение есть — это UB, но дойти до этого нужно трудным путём отладки.

Или возможен другой сценарий: ты обновляешь компилятор, собираешь им программу, и всё начинает ломаться. Все тесты красные. Жуть. И всё из-за пары строк кода, которые компилятор теперь стал использовать против вас.

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

Путеводитель C++ программиста по неопределённому поведению

Кстати, мы начали публиковать на нашем сайте книгу Дмитрия Свиридкина под редакцией Андрея Карпова про неопределённое поведение. Ссылка на первую часть. Заодно приглашаю подписаться на ежемесячный дайждест, чтобы не пропустить другие части книги и остальные интересные материалы.

Фрагмент N1

А начнём мы с бессмертной классики в мире ошибок. Разыменование нулевого указателя.

void LineTable::Dump(Stream *s, Target *target, Address::DumpStyle style,
                     Address::DumpStyle fallback_style, bool show_line_ranges) 
{
  const size_t count = m_entries.size();
  LineEntry line_entry;
  SupportFileSP prev_file;   // <=
  for (size_t idx = 0; idx < count; ++idx) {
    ConvertEntryAtIndexToLineEntry(idx, line_entry);
    line_entry.Dump(s, target, *prev_file != *line_entry.original_file_sp, // <=
                    style, fallback_style, show_line_ranges);
    s->EOL();
    prev_file = line_entry.original_file_sp;
  }
}

Предупреждение анализатора:

V522 Dereferencing of the null pointer 'prev_file' might take place. LineTable.cpp 363

Как мы видим, анализатор указывает на переменную prev_file. А вот что представляет из себя эта переменная, точнее её тип:

typedef std::shared_ptr<lldb_private::SupportFile> SupportFileSP;

При таком объявлении std::shared_ptr инициализируется нулём. Ну а разыменование нулевого указателя приводит к UB.

Для удобства заведём счётчик, который будет считать количество UB.

Счётчик UB: 0 —> 1.

Фрагмент N2

Обычно разные места в коде становятся причиной многих ошибок. Как насчёт того, чтобы увидеть, как одно место может стать причиной многих ошибок?

bool Sema::checkStringLiteralArgumentAttr(const AttributeCommonInfo &CI,
                                          const Expr *E, StringRef &Str,
                                          SourceLocation *ArgLocation) 
{
  const auto *Literal = dyn_cast<StringLiteral>(E->IgnoreParenCasts());
  ....
}

Как можно заметить, указатель E разыменовывается в первой строке без проверки. А дальше произошло ЭТО.

Предупреждения анализатора:

  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 1801. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 1974. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 1984. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 1999. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 2046. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 2381. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 3188. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 3355. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 3376. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 3423. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 3529. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 3543. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 4328. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 5416. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 6353. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 6437. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 6447. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 6965. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 7096. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 7239. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 7467. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 7742. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 7772. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 7825. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 7842. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 7872. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 8224. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 8305. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 8455. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 8602. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 8819. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 8827. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 8870. SemaDeclAttr.cpp
  • V522 Dereferencing of the null pointer 'E' might take place. The null pointer is passed into 'checkStringLiteralArgumentAttr' function. Inspect the second argument. Check lines: 349, 977. SemaDeclAttr.cpp

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

Первое:

static void handleAssumumptionAttr(Sema &S, Decl *D, const ParsedAttr &AL) {
// Handle the case where the attribute has a text message.
StringRef Str;
SourceLocation AttrStrLoc;
if (!S.checkStringLiteralArgumentAttr(AL, 0, Str, &AttrStrLoc))
  return;
....
}

Второе:

static void handleWeakRefAttr(Sema &S, Decl *D, const ParsedAttr &AL) {
....
if (AL.getNumArgs() && S.checkStringLiteralArgumentAttr(AL, 0, Str))
....
}

Как мы видим, в обоих случаях вторым параметром передаётся 0.

"UB может убить вашего котёнка, даже если у вас его нет".

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

Помните про счётчик UB? Так вот: 1 —> 36.

Фрагмент N3

Теперь рассмотрим следующую структуру:

struct ForceCodegenLinking {
    ForceCodegenLinking() {
      // We must reference the passes in such a way that compilers will not
      // delete it all as dead code, even with whole program optimization,
      // yet is effectively a NO-OP. As the compiler isn't smart enough
      // to know that getenv() never returns -1, this will do the job.
      // This is so that globals in the translation units where these functions
      // are defined are forced to be initialized, populating various
      // registries.
      if (std::getenv("bar") != (char*) -1)
        return;

      (void) llvm::createFastRegisterAllocator();
      (void) llvm::createBasicRegisterAllocator();
      (void) llvm::createGreedyRegisterAllocator();
      (void) llvm::createDefaultPBQPRegisterAllocator();

      (void)llvm::createBURRListDAGScheduler(nullptr,
                                             llvm::CodeGenOptLevel::Default);
      (void)llvm::createSourceListDAGScheduler(nullptr,
                                               llvm::CodeGenOptLevel::Default);
      (void)llvm::createHybridListDAGScheduler(nullptr,
                                               llvm::CodeGenOptLevel::Default);
      (void)llvm::createFastDAGScheduler(nullptr,
                                         llvm::CodeGenOptLevel::Default);
      (void)llvm::createDefaultScheduler(nullptr,
                                         llvm::CodeGenOptLevel::Default);
      (void)llvm::createVLIWDAGScheduler(nullptr,
                                         llvm::CodeGenOptLevel::Default);
    }
  } ForceCodegenLinking; // Force link by creating a global definition.
}

В её конструкторе вызывается ряд функций по созданию неких сущностей. Нас интересуют только функции, которым передаются аргументы (их шесть штук). Вот, например, некоторые из них:

ScheduleDAGSDNodes *llvm::createBURRListDAGScheduler(SelectionDAGISel *IS,
                                                    CodeGenOptLevel OptLevel)
{
  const TargetSubtargetInfo &STI = IS->MF->getSubtarget();
  ....
}

Или

ScheduleDAGSDNodes* createDefaultScheduler(SelectionDAGISel *IS,
                                           CodeGenOpt::Level OptLevel) 
{
  const TargetLowering *TLI = IS->TLI;
  const TargetSubtargetInfo &ST = IS->MF->getSubtarget();
  ....
}

Если мы посмотрим на вызов функции в конструкторе, то увидим, что в качестве первого аргумента ей передаётся nullptr. А это как раз первый параметр IS, который в первой же строке функции разыменовывается.

Странный код. Возможно, разработчики LLVM действительно что-то знают и научились управлять UB. А может, они просто не любят котят :D

В каждой такой функции с нулевым первым аргументом происходит его разыменование.

Соответственно, срабатывания анализатора:

  • V522 Dereferencing of the null pointer might take place. The null pointer is passed into 'createBURRListDAGScheduler' function. Inspect the first argument. Check lines: 'ScheduleDAGRRList.cpp:3147', 'LinkAllCodegenComponents.h:40'.
  • V522 Dereferencing of the null pointer might take place. The null pointer is passed into 'createSourceListDAGScheduler' function. Inspect the first argument. Check lines: 'ScheduleDAGRRList.cpp:3161', 'LinkAllCodegenComponents.h:42'.
  • V522 Dereferencing of the null pointer might take place. The null pointer is passed into 'createHybridListDAGScheduler' function. Inspect the first argument. Check lines: 'ScheduleDAGRRList.cpp:3175', 'LinkAllCodegenComponents.h:44'.
  • ... (остальные три аналогичные)

Счётчик UB: 36 —> 42.

Фрагмент N4

Очередной фрагмент, очередное UB:

Value *CodeGenFunction::EmitX86BuiltinExpr(unsigned BuiltinID,
                                           const CallExpr *E) 
{
  ....
  unsigned SrcNumElts =
        cast<llvm::FixedVectorType>(Ops[1]->getType())->getNumElements();
  ....
  int Indices[16];
    for (unsigned i = 0; i != DstNumElts; ++i)
      Indices[i] = (i >= SrcNumElts) ? SrcNumElts + (i % SrcNumElts) : i;
  ....
}

Предупреждение анализатора:

V609 Mod by zero. Denominator 'SrcNumElts' == 0. CGBuiltin.cpp:14833

Анализатор говорит обратить внимание на SrcNumElts. Что ж, давайте разбираться.

Как мы видим, в цикле используется тернарный оператор. Условие проверки: i больше или равен SrcNumElts. То есть всегда, когда SrcNumElts == 0 (а проверок выше на это нет, и getNumElements может возвращать 0), у нас выполняется SrcNumElts + (i % SrcNumElts). Как известно, при делении на 0 (в том числе по модулю) поведение не определено.

Счётчик UB: 42 —> 43.

Фрагмент N5

Довольно небольшая функция, состоящая только из if-конструкций, и справедливое срабатывание анализатора:

static bool StopAtComponentPre(const Symbol &component) {
  if constexpr (componentKind == ComponentKind::Ordered) {
    // Parent components need to be iterated upon after their
    // sub-components in structure constructor analysis.
    return !component.test(Symbol::Flag::ParentComp);
  } else if constexpr (componentKind == ComponentKind::Direct) {
    return true;
  } else if constexpr (componentKind == ComponentKind::Ultimate) {
    return component.has<ProcEntityDetails>() ||
        IsAllocatableOrObjectPointer(&component) ||
        (component.has<ObjectEntityDetails>() &&
            component.get<ObjectEntityDetails>().type() &&
            component.get<ObjectEntityDetails>().type()->AsIntrinsic());
  } else if constexpr (componentKind == ComponentKind::Potential) {
    return !IsPointer(component);
  } else if constexpr (componentKind == ComponentKind::PotentialAndPointer) {
    return true;
  }
}

Предупреждение анализатора:

V591 Non-void function should return a value. tools.cpp:1278

Как мы видим, в функции нет return на случай, если все условия окажутся ложными. Такое может произойти, так как enum class ComponentKind содержит ещё одно значение Scope, не представленное здесь. В таком случае поведение не определено.

Причём неопределённое поведение не обязательно означает, что функция вернёт случайное значение (true или false). Это именно всё что угодно.

Счётчик UB: 43 —> 44.

Фрагмент N6

Вот этот фрагмент может показаться безопасным:

bool AppleObjCRuntimeV2::NonPointerISACache::EvaluateNonPointerISA(
    ObjCISA isa, ObjCISA &ret_isa) {
  ....
  if (index > m_indexed_isa_cache.size())
    return false;

  LLDB_LOGF(log, "AOCRT::NPI Evaluate(ret_isa = 0x%" PRIx64 ")",
          (uint64_t)m_indexed_isa_cache[index]);
  ....
}

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

Но всё же ошибка тут имеется. Если звёзды сойдутся таким образом, что переменная index будет равна m_indexed_isa_cache.size(), то случится оно. Да-да, выход за границу массива и, как следствие, неопределённое поведение.

Предупреждение анализатора:

V557 Array overrun is possible. The 'index' index is pointing beyond array bound. AppleObjCRuntimeV2.cpp 3308

Для исправления достаточно написать вот так:

if (index >= m_indexed_isa_cache.size())
    return false;

И абсолютно такое же срабатывание, но чуть ниже по коду:

Предупреждение анализатора:

V557 Array overrun is possible. The 'index' index is pointing beyond array bound. AppleObjCRuntimeV2.cpp 3311

Счётчик UB: 44 —> 46.

Фрагмент N7

Как гласит пословица, "всему своё время". В приведённом же фрагменте время проверки указателя оказалось позже, чем его разыменование в списке инициализации конструктора:

lldb_private::formatters::StdlibCoroutineHandleSyntheticFrontEnd::
    StdlibCoroutineHandleSyntheticFrontEnd(lldb::ValueObjectSP valobj_sp)
    : SyntheticChildrenFrontEnd(*valobj_sp) {
  if (valobj_sp)
    Update();
}

Предупреждение анализатора:

V664 The 'valobj_sp' pointer is being dereferenced on the initialization list before it is verified against null inside the body of the constructor function. Check lines: 99, 100. Coroutines.cpp

Счётчик UB: 46 —> 47.

Фрагмент N8

В последнем фрагменте вернёмся к тому, с чего всё начиналось — к разыменованию нулевого указателя. Уже, правда, в другом месте.

void SetInsertPoint(Instruction *I) {
  BB = I->getParent();
  InsertPt = I->getIterator();
  assert(InsertPt != BB->end() && "Can't read debug loc from end()");
  SetCurrentDebugLocation(I->getStableDebugLoc());
}

Предупреждение анализатора:

V522 Dereferencing of the null pointer 'I' might take place. The null pointer is passed into 'SetInsertPoint' function. Inspect the first argument. Check lines: 'IRBuilder.h:188', 'OMPIRBuilder.cpp:5983'.

Анализатор советует обратить внимание на параметр I. Что ж, последуем его совету и посмотрим место вызова этой функции:

if (UnreachableInst *ExitTI =
        dyn_cast<UnreachableInst>(ExitBB->getTerminator())) {
  CurBBTI->eraseFromParent();
  Builder.SetInsertPoint(ExitBB);
} else {
  Builder.SetInsertPoint(ExitTI);
}

Ещё одно странное место. Судя по названию указателей (ExitTI и ExitBB) и функций, которые через них вызываются (getTerminator), у меня сложилось впечатление, что их специально разыменовывают нулевыми, чтобы аварийно завершить выполнение программы :D (хотя на самом деле результат будет неопределённым).

Смотрите, мы получаем нашего "терминатора" ExitTI из ExitBB->getTerminator(), предварительно кастанув его к UnreachableInst. Всё это происходит в условии конструкции if. Если он равен нулю, то выполняется else-ветвь, где он передаётся в качестве аргумента функции и тут же разыменовывается. Аста ла виста, бейби.

Счётчик UB: 47 —> 48.

Послесловие про разыменование нулевого указателя

Тема разыменования нулевого указателя так же глубока, как кроличья нора. Или ещё глубже. Предлагаем развлечь себя вот таким замечательным докладом про *(char*)0 = 0;

Если захочется ещё чего-то такого интересного, то предлагаем заглянуть в "Подборка крутых докладов по С++ за 2023 год".

Заключение

Счётчик UB показывает 48, а статья подходит к концу.

Мы рассмотрели примеры неопределённого поведения в коде, и некоторые из них выглядели удивительно. Ещё удивительнее, что их не нашли сразу, когда писали такой код. Ведь обычно UB содержится в менее очевидных местах, а значит обнаружить его ещё труднее.

Хочется отметить, что поиск UB и других ошибок — это непрерывный процесс, требующий постоянного внимания и усилий. Даже опытные разработчики могут столкнуться с неожиданными проявлениями неопределённого поведения и прочих ошибок, особенно при работе с новыми библиотеками или сложными и большими системами.

Поэтому я рекомендую не пренебрегать предупреждениями компилятора и статических анализаторов и всегда улучшать способы проверки своего кода.

Популярные статьи по теме


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

Следующие комментарии next comments
close comment form