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

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

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

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

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

Ответ 1

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

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

Если у вас есть:

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);
}

В С++ приятно сделать эту функцию-член:

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

В первые дни С++ (до стандартизации) было подчеркнуто, что эти функции-члены являются синтаксическим сахаром для функции, где этот параметр неявный. Код написан на С++, преобразован в эквивалентный C и скомпилирован. Были даже явные примеры того, что сравнение этого с нулевым значением имело смысл, и оригинальный компилятор Cfront тоже воспользовался этим. Поэтому, исходя из фона C, очевидным выбором для проверки является:

if(this == nullptr) return;      

Примечание: Бьярн Страуструп даже упоминает, что правила для this изменились за эти годы здесь

И это много лет работало над многими компиляторами. Когда стандартизация произошла, это изменилось. И совсем недавно компиляторы начали использовать функцию-член, где this является nullptr - это поведение undefined, что означает, что это условие всегда false, и компилятор может опустить его.

Это означает, что для любого обхода этого дерева вам нужно либо:

  • Выполняйте все проверки перед вызовом traverse_in_order

    void Node::traverse_in_order() {
        if(left) left->traverse_in_order();
        process();
        if(right) right->traverse_in_order();
    }
    

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

  • Не используйте функцию-член

    Это означает, что вы пишете старый код стиля C (возможно, как статический метод) и вызываете его с объектом явно как параметр. например. вы вернулись к написанию Node::traverse_in_order(node);, а не node->traverse_in_order(); на сайте вызова.

  • Я считаю, что самый простой/самый простой способ исправить этот конкретный пример таким образом, который соответствует стандарту, - это фактически использовать часовое node, а не nullptr.

    // static class, or global variable
    Node sentinel;
    
    void Node::traverse_in_order() {
        if(this == &sentinel) return;
        ...
    }
    

Ни один из первых двух вариантов не кажется привлекательным, и, хотя код может уйти от него, они использовали код с this == nullptr, вместо того чтобы использовать правильное исправление.

Я предполагаю, что некоторые из этих базовых кодов эволюционировали, когда в них проверяется this == nullptr.

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

Изменить 9 мая: добавлена ​​ссылка на комментарии Бьярне Строуструпта об изменении this

Ответ 2

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

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

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

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), это решение по-прежнему полагается на поведение undefined, вызывая traverse_in_order с помощью nullptr.

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

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

Ответ 3

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

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

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

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

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

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

if (this)
    member_variable = 42;

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

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

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

Я могу.

Ответ 4

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

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

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

Не весь код, который был сломан, содержит явные 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() является ложным, поэтому код вызывает do_something_else(p), который проверяет наличие нулевых указателей и, следовательно, ничего не делает. Никакого аварийного или неожиданного поведения не наблюдается.

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

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 (или вызывающие должны никогда не вызывать его с нулем):

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

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

Ответ 5

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

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

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

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

В этом примере разработчик включил проверку переполнения целых чисел, используя assert, которая завершит работу программы, если будет указана недопустимая длина. Команда GCC удалила проверку на основании того, что целочисленное переполнение undefined, поэтому проверка может быть удалена. Это привело к тому, что настоящие in-the-wild экземпляры этой кодовой базы были переделаны уязвимыми после исправления проблемы.

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

ОК, но как насчет этого?

Возвращение назад, когда была довольно распространенная идиома, которая шла примерно так:

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

 }

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

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

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

Я до сих пор не понимаю его

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

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

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

ОК, так скажите им!

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

но... почему?

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

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

Или, может быть, что-то еще. Кто может сказать?