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

148

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

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

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

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

boot4life
источник
21
@Ben Надеюсь, вы имеете в виду это в хорошем смысле. Код с UB должен быть переписан, чтобы не вызывать UB. Это так просто. Черт, часто есть часто задаваемые вопросы, которые говорят вам, как этого добиться. Так что не реальная проблема ИМХО. Все хорошо.
Восстановите Монику
49
Я поражен, увидев людей, защищающих разыменование нулевых указателей в коде. Просто удивительно.
SergeyA
19
@Ben, использование неопределенного поведения было очень эффективной тактикой оптимизации в течение очень долгого времени. Я люблю это, потому что я люблю оптимизации, которые заставляют мой код работать быстрее.
SergeyA
17
Я согласен с SergeyA. Вся эта затея началась потому, что люди, кажется, зацикливаются на факте, который thisпередается как неявный параметр, поэтому они начинают использовать его так же, как если бы это был явный параметр. Это не. Когда вы разыменовываете null this, вы вызываете UB так же, как если бы вы разыменовывали любой другой нулевой указатель. Это все, что нужно. Если вы хотите передать nullptr, используйте явный параметр DUH . Он не будет медленнее, не будет более громоздким, и код, имеющий такой API, в любом случае глубоко во внутреннем пространстве, поэтому имеет очень ограниченную область применения. Конец истории, я думаю.
Восстановить Монику
41
Престижность GCC за нарушение цикла плохого кода -> неэффективный компилятор для поддержки плохого кода -> более плохой код -> более неэффективная компиляция -> ...
ММ

Ответы:

87

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

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

Если у тебя есть:

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 ++ (до стандартизации) подчеркивалось, что функции-члены являются синтаксическим сахаром для функции, в которой thisпараметр является неявным. Код был написан на C ++, преобразован в эквивалентный C и скомпилирован. Были даже явные примеры того, что сравнение thisсо значением null имеет смысл, и оригинальный компилятор Cfront также воспользовался этим. Исходя из фона C, очевидный выбор для проверки:

if(this == nullptr) return;      

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

И это работало на многих компиляторах много лет. Когда произошла стандартизация, это изменилось. И совсем недавно компиляторы начали использовать вызов функции-члена, где thisбытие было nullptrнеопределенным поведением, что означает, что это условие всегда 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();на сайте вызова.

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

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

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

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

jtlim
источник
6
Как может 1 == 0быть неопределенное поведение? Это просто false.
Йоханнес Шауб - лит
11
Сама проверка не является неопределенным поведением. Это просто всегда ложно, и поэтому устраняется компилятором.
SergeyA
15
Хм .. this == nullptrидиома - неопределенное поведение, потому что вы вызывали функцию-член для объекта nullptr до этого, который не определен. И компилятор может свободно пропустить проверку
jtlim
6
@ Джошуа, первый стандарт был опубликован в 1998 году. Что бы ни случилось до этого, это было то, чего хотела каждая реализация. Темные времена.
SergeyA
26
Хех, вау, я не могу поверить, что кто-то когда-либо писал код, который основывался на вызове функций экземпляра ... без экземпляра . Я бы инстинктивно использовал отрывок с надписью «Выполнить все проверки перед вызовом traverse_in_order», даже не думая о том, что thisкогда-либо можно будет обнулять. Я предполагаю, что, возможно, это является преимуществом изучения C ++ в эпоху, когда существует SO, чтобы укоренить опасности UB в моем мозгу и отговорить меня от таких странных хаков, как этот.
underscore_d
65

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

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

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

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

Восстановить Монику
источник
18
@ Бен, Кто бы ни написал этот код, он был неправ в первую очередь. Забавно, что вы называете такие ужасно испорченные проекты, как MFC, Qt и Chromium. Хорошее избавление от них.
SergeyA
19
@Ben, ужасные стили кодирования в Google мне хорошо известны. Код Google (по крайней мере, общедоступный) часто плохо написан, несмотря на то, что многие люди считают код Google ярким примером. Может быть, это заставит их пересмотреть свои стили кодирования (и рекомендации, пока они на нем).
SergeyA
18
@Ben Никто не имеет обратной замены Chromium на этих устройствах на Chromium, скомпилированный с использованием gcc 6. Перед тем, как Chromium будет скомпилирован с использованием gcc 6 и других современных компиляторов, его необходимо исправить. Это не огромная задача; то thisпроверки отбираются различными статическими анализаторами кода, так что это не так , как будто кто -то должен вручную охотиться их всех. Патч будет, вероятно, пара сотен строк тривиальных изменений.
Восстановите Монику
8
@Ben В практическом плане нулевое thisразыменование - это мгновенный сбой. Эти проблемы будут обнаружены очень быстро, даже если никто не захочет запускать статический анализатор над кодом. C / C ++ следует мантре «плати только за функции, которые ты используешь». Если вы хотите проверять, вы должны четко указывать о них, а это означает, что вы не должны их делать this, когда уже слишком поздно, поскольку компилятор предполагает, что thisон не равен нулю. В противном случае придется проверять this, и для 99,9999% кода такие проверки являются пустой тратой времени.
Восстановить Монику
10
Мой совет для тех, кто считает, что стандарт нарушен: используйте другой язык. Нет недостатка в C ++ - подобных языках, в которых нет возможности неопределенного поведения.
ММ
35

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

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

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

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

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

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

if (this)
    member_variable = 42;

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

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

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

Я могу.

eerorika
источник
11
«Если практический код на С ++ опирается на неопределенное поведение, то изменения в этом неопределенном поведении могут его сломать. Вот почему UB следует избегать» this * 1000
underscore_d
if(this == null) PrintSomeHelpfulDebugInformationAboutHowWeGotHere(); Например, удобный и удобный для чтения журнал последовательности событий, о которых отладчик не может вам легко рассказать. Получайте удовольствие отлаживая это сейчас, не тратя часов на размещение проверок везде, где есть неожиданный случайный ноль в большом наборе данных, в коде, который вы не написали ... И правило UB об этом было сделано позже, после создания C ++. Раньше был действительным.
Стефан Хоккенхалл,
@StephaneHockenhull Вот для чего -fsanitize=null.
eerorika
@ user2079303 Проблемы: Это замедлит рабочий код до такой степени, что вы не сможете оставить регистрацию во время работы, что будет стоить компании больших денег? Это собирается увеличить размер и не помещается во вспышку? Это работает на всех целевых платформах, включая Atmel? Можно ли -fsanitize=nullрегистрировать ошибки на SD / MMC-карте на контактах 5,6,10,11 с помощью SPI? Это не универсальное решение. Некоторые утверждают, что доступ к нулевому объекту противоречит объектно-ориентированным принципам, однако в некоторых языках ООП есть нулевой объект, с которым можно работать, поэтому это не универсальное правило ООП. 1/2
Стефан Хоккенхалл
1
... регулярное выражение, которое соответствует таким файлам? Сказать, что, например, если доступ к lvalue осуществляется дважды, компилятор может консолидировать доступы, если код между ними не выполняет какую-либо из нескольких конкретных задач , будет гораздо проще, чем пытаться определить точные ситуации, в которых коду разрешен доступ к хранилищу.
суперкат
25

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

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

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

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

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

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

Важно помнить, что большинство подобных оптимизаций не являются случаем того, что компилятор говорит: «ах, программист проверил этот указатель на ноль, я уберу его просто для раздражения». То, что происходит, - это то, что различные обычные оптимизации, такие как встраивание и распространение диапазона значений, объединяются, чтобы сделать эти проверки избыточными, потому что они идут после более ранней проверки или разыменования. Если компилятор знает, что указатель является ненулевым в точке A в функции, и указатель не изменяется до более поздней точки B в той же функции, то он знает, что он также ненулевой в B. Когда происходит встраивание точки A и B могут фактически быть фрагментами кода, которые изначально были в отдельных функциях, но теперь объединены в один фрагмент кода, и компилятор может применить свои знания о том, что указатель является ненулевым в большем количестве мест.

Джонатан Уэйкли
источник
Возможно ли, чтобы прибор GCC 6 выводил предупреждения во время компиляции, когда он сталкивается с такими случаями использования this?
Джотик
3
@jotik, ^^^ что сказал TC. Это было бы возможно, но вы получите это предупреждение ДЛЯ ВСЕХ КОДОВ, ВСЕХ ВРЕМЕНИ . Распространение диапазона значений является одной из наиболее распространенных оптимизаций, которая затрагивает почти весь код повсюду. Оптимизаторы просто видят код, который можно упростить. Они не видят «кусок кода, написанный идиотом, который хочет быть предупрежденным, если их тупой UB будет оптимизирован». Компилятору нелегко определить разницу между «избыточной проверкой того, что программист хочет оптимизироваться» и «избыточной проверкой, которая, по мнению программиста, поможет, но является избыточной».
Джонатан Уэйкли
1
Если вы хотите, чтобы ваш код давал ошибки времени выполнения для различных типов UB, включая недопустимое использование this, то просто используйте-fsanitize=undefined
Jonathan Wakely
-25

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

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

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

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

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

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

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

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

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

 }

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

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

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

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

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

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

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

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

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

но почему?

Кто может сказать? Может быть:

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

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

Бен
источник
32
Не соглашайтесь с каждой отдельной строкой ответа. Те же самые комментарии были сделаны для строгой оптимизации псевдонимов, и эти, мы надеемся, сейчас уволены. Решение состоит в том, чтобы обучить разработчиков, а не предотвратить оптимизацию, основанную на вредных привычках разработки.
SergeyA
30
Я пошел и прочитал все, что ты сказал, и действительно плакал, но в основном из-за глупости Феликса, которую я не думаю, что ты пытался объяснить ...
Майк Вайн
33
Проголосовал за бесполезную напыщенную речь. «Они ... вводят ошибки специально. Возможно, для иностранного правительства». В самом деле? Это не / r / заговор.
isanae
31
Приличные программисты снова и снова повторяют, что мантры не вызывают неопределенного поведения , однако эти нонки все равно пошли дальше и сделали это. И посмотри, что случилось. У меня нет сочувствия вообще. Это вина разработчиков, так просто. Они должны взять на себя ответственность. Помнишь это? Личная ответственность? Люди полагаются на вашу мантру "а как же на практике !" Именно так и возникла эта ситуация в первую очередь. Избежание подобной чепухи - вот почему стандарты существуют в первую очередь. Используйте стандарты, и у вас не будет проблем. Период.
Гонки легкости на орбите
18
«Простая перекомпиляция ранее работающего, безопасного кода с более новой версией компилятора может привести к появлению уязвимостей в безопасности», - так всегда бывает . Если вы не хотите поручить, что одна версия одного компилятора является единственным компилятором, который будет разрешен до конца вечности. Вы помните, когда ядро ​​linux могло быть скомпилировано только с gcc 2.7.2.1? Проект gcc даже был разветвлен, потому что людям надоело bullcrap. Прошло много времени, чтобы пройти это.
ММ