Почему расширенный оптимизатор GCC 6 нарушает практический код C++?


GCC 6 имеет новую функцию оптимизатора: предполагается, что this всегда не null и оптимизирует на основе этого.

распространение диапазона значений теперь предполагает, что этот указатель функций-членов C++ является ненулевым. Это устраняет общие проверки нулевого указателя но также нарушает некоторые несоответствующие кодовые базы (такие как Qt-5, Chromium, KDevelop). В качестве временного обхода-fno-delete-null-pointer-checks можно использовать. Неправильный код может быть идентифицируется с помощью -fsanitize=undefined.

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

почему это новое предположение нарушает практический код C++? существуют ли определенные шаблоны, где небрежные или неосведомленные программисты полагаются на это конкретное неопределенное поведение? Я не могу себе представить, чтобы кто-то писал if (this == NULL) потому что это так неестественно.

5 145

5 ответов:

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

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

если вы:

struct Node
{
    Node* left;
    Node* right;
};

в C, вы можете написать:

void traverse_in_order(Node* n) {
    if(!n) return;
    traverse_in_order(n->left);
    process(n);
    traverse_in_order(n->right);
}

в C++ приятно сделать это функцией-членом:

void Node::traverse_in_order() {
    // <--- What check should be put here?
    left->traverse_in_order();
    process();
    right->traverse_in_order();
}

в первые дни C++ (до стандартизации), было подчеркнуто, что функции-члены являются синтаксическим сахаром для функции, где

это происходит потому, что" практический " код был нарушен и включал неопределенное поведение для начала. Нет никаких причин использовать null this, кроме как микро-оптимизации, обычно очень преждевременной.

это опасная практика, так как настройка указателей из-за обхода иерархии классов может превратить null this в ненулевой. Итак, по крайней мере, класс, методы которого должны работать с null this должен быть последний класс без базового класса: он не может быть получен из чего-либо, и он не может быть получен из. Мы быстро отходим от practical к уродливый Хак-земли.

на практике код не должен быть уродливым:

struct Node
{
  Node* left;
  Node* right;
  void process();
  void traverse_in_order() {
    traverse_in_order_impl(this);
  }
private:
  static void traverse_in_order_impl(Node * n)
    if (!n) return;
    traverse_in_order_impl(n->left);
    n->process();
    traverse_in_order_impl(n->right);
  }
};

если у вас пустое дерево (например. root - это nullptr), это решение все еще полагается на неопределенное поведение, вызывая traverse_in_order с помощью nullptr.

если дерево пустое, a.k. a. a null Node* root, вам не предполагается, что он вызывает любые нестатические методы. Период. Совершенно нормально иметь C-подобный код дерева, который принимает указатель экземпляра явным параметром.

аргумент здесь, похоже, сводится к необходимости каким-то образом писать нестатические методы на объекты, которые могут быть вызваны из Указателя нулевого экземпляра. В этом нет необходимости. Способ написания такого кода C-with-objects по-прежнему намного лучше в мире C++, потому что он может быть по крайней мере безопасным для типа. В принципе, нуль this это такая микро-оптимизация, с такой узкой областью использования, что запрещение это ИМХО совершенно нормально. Никакой публичный API не должен зависеть от null this.

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

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

почему будет ли это новое предположение нарушать практический код C++?

Если практические код c++ опирается на неопределенное поведение, а затем изменения в этом неопределенном поведении могут его нарушить. Вот почему UB следует избегать, даже когда программа, полагающаяся на нее, работает так, как задумано.

существуют ли определенные шаблоны, где небрежные или неосведомленные программисты полагаются на это конкретное неопределенное поведение?

Я не знаю, если это широко распространение анти --шаблон, но неосведомленный программист может подумать, что они могут исправить свою программу от сбоя, выполнив:

if (this)
    member_variable = 42;

когда фактическая ошибка разыменования нулевого указателя где-то еще.

Я уверен, что если программист недостаточно информирован, они смогут придумать более продвинутые (анти) шаблоны, которые полагаются на этот UB.

Я не могу себе представить, чтобы кто-то писал if (this == NULL) потому что это так неестественный.

Я могу.

некоторые из " практических "(забавный способ написания" багги") кода, который был сломан, выглядел так:

void foo(X* p) {
  p->bar()->baz();
}

и он забыл учесть тот факт, что p->bar() иногда возвращает нулевой указатель, что означает, что разыменование его для вызова baz() неопределено.

не весь код, который был нарушен, содержал явное if (this == nullptr) или if (!p) return; проверка. Некоторые случаи были просто функциями, которые не имели доступа к переменным-членам, и поэтому для работы ЛАДНО. Например:

struct DummyImpl {
  bool valid() const { return false; }
  int m_data;
};
struct RealImpl {
  bool valid() const { return m_valid; }
  bool m_valid;
  int m_data;
};

template<typename T>
void do_something_else(T* p) {
  if (p) {
    use(p->m_data);
  }
}

template<typename T>
void func(T* p) {
  if (p->valid())
    do_something(p);
  else 
    do_something_else(p);
}

в этом коде при вызове func<DummyImpl*>(DummyImpl*) С нулевым указателем существует "концептуальное" разыменование указателя на вызов p->DummyImpl::valid(), но на самом деле эта функция-член просто возвращает false без доступа *this. Это return false может быть встроен, и поэтому на практике указатель не должен быть доступен вообще. Таким образом, с некоторыми компиляторами он работает нормально: нет segfault для разыменования null,p->valid() false, таким образом код вызывает do_something_else(p), которым проверяет наличие нулевых указателей, и поэтому ничего не делает. Никакой аварии или неожиданного поведения не наблюдается.

С GCC 6 Вы все равно получите вызов p->valid(), но компилятор выводит из этого выражения, что p должно быть ненулевым (иначе p->valid() было бы неопределенным поведением) и отмечает эту информацию. Что выведенная информация используется оптимизатором, так что если вызов do_something_else(p) вставляется, то if (p) проверка теперь считается избыточной, так как компилятор помнит, что это не null, и поэтому в строке кода:

template<typename T>
void func(T* p) {
  if (p->valid())
    do_something(p);
  else {
    // inlined body of do_something_else(p) with value propagation
    // optimization performed to remove null check.
    use(p->m_data);
  }
}

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

в этом примере ошибка в func, который должен был сначала проверить null (или вызывающие абоненты никогда не должны были вызывать его с null):

template<typename T>
void func(T* p) {
  if (p && p->valid())
    do_something(p);
  else 
    do_something_else(p);
}

важно помнить, что большинство оптимизаций, подобных этой, не являются случаем компилятора, говорящего: "ах, программист протестировал этот указатель против null, я удалю его, чтобы просто раздражать". Что происходит, так это то, что различные стандартные оптимизации, такие как встраивание и распространение диапазона значений, объединяются, чтобы сделать эти проверки избыточными, потому что они приходят после более ранней проверки или разыменования. Если компилятор знает, что указатель не равен нулю в точке A в функции, и указатель не изменяется до более поздней точки B в той же функции, то он знает, что он также не равен нулю в B. Когда происходит встраивание точки A и B могут быть куски кода, которые изначально были в отдельной функции, но теперь объединены в один фрагмент кода, и компилятор может применить свои знания, что указатель не null в нескольких местах. Это базовая, но очень важная оптимизация, и если бы компиляторы не делали этого, ежедневный код был бы значительно медленнее, и люди жаловались бы на ненужные ветви, чтобы повторно протестировать одни и те же условия.

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

здесь гораздо умнее человек, чем я объясняет очень подробно. (Он говорит о C, но ситуация та же там.)

почему это вредно?

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

в этом примере разработчик включил проверку на переполнение целого числа, используя assert, который завершит программу, если указана недопустимая длина. Команда GCC удалила проверку на том основании, что переполнение целого числа не определено, поэтому проверка может быть удалена. Это привело к тому, что реальные в дикой природе экземпляры этой кодовой базы были повторно сделаны уязвимыми после того, как проблема была зафиксированный.

читать все это. Этого достаточно, чтобы заставить тебя плакать.

хорошо, но как насчет этого?

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

 OPAQUEHANDLE ObjectType::GetHandle(){
    if(this==NULL)return DEFAULTHANDLE;
    return mHandle;

 }

 void DoThing(ObjectType* pObj){
     osfunction(pObj->GetHandle(), "BLAH");
 }

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

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

я все еще не понимаю

существует много кода, который написан так. Если кто-то просто перекомпилирует его, не меняя строку, каждый вызов DoThing(NULL) - это сбой ошибка - если Вам ПОВЕЗЕТ.

Если вам не повезло, вызовы сбоев ошибок становятся удаленным выполнением факторы уязвимости.

это может произойти даже автоматически. У вас есть автоматизированная система сборки, верно? Обновление его до последнего компилятора безвредно, не так ли? Но теперь это не так, если ваш компилятор - GCC.

хорошо, так скажите им!

они сказали. Они делают это с полным осознанием последствий.

но... зачем?

кто может сказать? Возможно:

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

или, возможно, что-то еще. Кто может сказать?