Чому вдосконалений оптимізатор GCC 6 порушує практичний код C ++?


148

У GCC 6 є нова функція оптимізатора : вона передбачає, що thisце не завжди є нульовим і оптимізується на основі цього.

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

Документ про зміну чітко називає це небезпечним, оскільки воно порушує дивовижну кількість часто використовуваного коду.

Чому це нове припущення порушує практичний код C ++? Чи існують особливі зразки, коли необережні або неінформовані програмісти покладаються на цю конкретну невизначену поведінку? Я не можу уявити, щоб хто писав, if (this == NULL)бо це так неприродно.


21
@Ben Сподіваємось, ви це гарно розумієте. Код з UB слід переписати, щоб не викликати UB. Це так просто. Чорт забирай, часто виникають питання, які розповідають, як цього досягти. Отже, не справжнє питання ІМХО. Все добре.
Відновіть Моніку

49
Я вражений тим, що в коді захищають перенаправлення нульових покажчиків. Просто дивовижно.
СергійА

19
@Ben, використання невизначеної поведінки була дуже ефективною тактикою оптимізації протягом дуже тривалого часу. Мені подобається, тому що я люблю оптимізації, які змушують мій код працювати швидше.
СергійА

17
Я згоден із СергіємА. Весь brouhaha почався тому, що, здається, люди зупиняються на тому, що thisвін передається як неявний параметр, тож вони потім починають використовувати його так, ніби це був явний параметр. Це не. Коли ви перенаправляєте нульове значення цього, ви викликаєте UB так само, як ніби ви перенаправляли будь-який інший нульовий покажчик. Це все, що там є. Якщо ви хочете передати нульптри навколо, використовуйте явний параметр DUH . Це не буде повільніше, він не буде ніяким незграбним, і код, який має такий API, все одно знаходиться глибоко у внутрішніх місцях, тому має дуже обмежену сферу застосування. Я думаю, кінець історії.
Відновіть Моніку

41
Kudos для GCC для порушення циклу поганого коду -> неефективний компілятор для підтримки поганого коду -> більше поганого коду -> більш неефективна компіляція -> ...
MM

Відповіді:


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 скористався цим. Отож, виходячи з фону С, очевидним вибором для перевірки є:

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();на сайті для дзвінків.

  • Я вважаю, що найпростіший / найновіший спосіб виправити цей конкретний приклад таким чином, щоб відповідати стандартам - це фактично використовувати дозорний вузол, а не a nullptr.

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

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

Я здогадуюсь, що таким чином еволюціонували деякі з цих кодів, щоб this == nullptrперевірити їх.


6
Як може 1 == 0бути невизначена поведінка? Це просто false.
Йоханнес Шауб - ліб

11
Сама перевірка не є невизначеною поведінкою. Це просто завжди помилково, і таким чином усувається компілятором.
СергійА

15
Хм .. this == nullptrідіома - це невизначена поведінка, тому що ви викликали функцію-член на об’єкті nullptr до цього, який не визначений. І компілятор вільний опустити чек
jtlim

6
@Joshua, перший стандарт був опублікований у 1998 році. Що б раніше не було, те, що хотіла кожна реалізація. Темні століття.
СергійА

26
Хе, вау, я не можу повірити, що хтось писав код, який покладався на функції екземпляра виклику ... без екземпляра . Я б інстинктивно використав уривок із позначкою "Зробити всі перевірки перед тим, як зателефонувати на traverse_in_order", навіть не замислюючись про те, що thisколи-небудь буде нульовим. Я здогадуюсь, можливо, це і є користю від вивчення C ++ у віці, коли ТАК існує, щоб закріпити небезпеку UB в моєму мозку і відвернути мене від подібних химерних хак.
підкреслити_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 є nullptr), це рішення все ще покладається на невизначене поведінку, викликаючи traverse_in_order з nullptr.

Якщо дерево порожнє, він також є нульовим Node* root, ви не повинні викликати на ньому будь-які нестатичні методи. Період. Ідеально добре мати C-подібний код дерева, який бере вказівник екземпляра за явним параметром.

Аргумент тут, схоже, зводиться до того, що потрібно якось писати нестатичні методи на об'єктах, які можна було б викликати з нульового покажчика екземпляра. Немає такої потреби. Спосіб написання такого коду на C-об'єктах все ще приємніше у світі C ++, оскільки він може бути безпечним як мінімум. В основному, нуль this- це така мікрооптимізація, з таким вузьким полем використання, що заборонити її IMHO ідеально. Жоден публічний API не повинен залежати від нуля this.


18
@Ben, Хто написав цей код, помилявся, в першу чергу. Смішно, що ви називаєте такі жахливо розбиті проекти, як MFC, Qt та Chromium. Гарна загадка з ними.
СергійА

19
@Ben, страшні стилі кодування в Google мені добре відомі. Код Google (принаймні загальнодоступний) часто погано написаний, незважаючи на те, що багато людей вважають, що код Google є яскравим прикладом. Можливо, це змусить їх переглянути свої стилі кодування (та вказівки, поки вони знаходяться на ньому).
СергійА

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)тому що це так неприродно.

Я можу.


11
"Якщо практичний код c ++ покладається на невизначену поведінку, то зміни, які не визначена поведінка, можуть порушити його. Ось чому UB слід уникати" цього * 1000
підкреслюється

if(this == null) PrintSomeHelpfulDebugInformationAboutHowWeGotHere(); Такий, як приємний для читання журнал послідовності подій, про який відладчик не може вам легко розповісти. Приємно налагоджувати це зараз, не витрачаючи годин на розміщення чеків скрізь, коли у великому наборі даних є раптова випадкова нуль, у коді ви ще не написали ... І правило UB про це було зроблено пізніше, після створення C ++. Це раніше було дійсним.
Стефан Хокенхолл

@StephaneHockenhull Це для чого -fsanitize=null.
eerorika

@ user2079303 Проблеми: Це призведе до уповільнення виробничого коду до того моменту, коли ви не можете залишити реєстрацію під час роботи, що коштуватиме компанії багато грошей? Це збирається збільшити розмір і не вміститься у спалах? Це працює на всіх цільових платформах, включаючи Atmel? Чи можна -fsanitize=nullзаписати помилки на карту SD / MMC на штифтах №5,6,10,11 за допомогою SPI? Це не універсальне рішення. Деякі стверджують, що це суперечить об'єктно-орієнтованим принципам доступу до нульового об'єкта, проте деякі мови OOP мають нульовий об'єкт, яким можна керувати, тому це не є універсальним правилом OOP. 1/2
Стефан Хокенхолл

1
... регулярний вираз, який відповідає таким файлам? Скажімо, що, наприклад, якщо до lvalue звертається двічі, компілятор може консолідувати доступ, якщо код між ними робить будь-яку з кількох конкретних речей було б набагато простіше, ніж намагатися визначити точні ситуації, в яких коду дозволено отримати доступ до сховища.
supercat

25

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

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може бути накреслено, тому на практиці вказівник зовсім не потребує доступу. Так що, з деякими компіляторами, здається, це працює добре: немає ніякого сегмента за відміною нуля, p->valid()помилково, тому код викликає do_something_else(p), який перевіряє нульові покажчики, і так нічого не робить. Жодних збоїв чи несподіваної поведінки не спостерігається.

За допомогою GCC 6 ви все ще отримуєте дзвінок p->valid(), але компілятор тепер випливає з цього виразу, який pповинен бути ненульовим (інакше p->valid()було б невизначене поведінка) і робить помітку цієї інформації. Отримана інформація використовується оптимізатором, так що якщо виклик 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, яка повинна була спочатку перевірити нуль (або абоненти ніколи не повинні називати це null):

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?
jotik


3
@jotik, ^^^ що сказав ТС. Це було б можливо, але ви отримаєте це попередження ЗА ВСІЙ КОД, ВСЕ ЧАС . Поширення діапазону значень є однією з найпоширеніших оптимізацій, яка впливає майже на весь код, всюди. Оптимізатори просто бачать код, який можна спростити. Вони не бачать "шматка коду, написаного ідіотів, який хоче попередити, якщо їх тупий UB отримує оптимізацію". Компілятору непросто сказати різницю між "надмірною перевіркою, яку програміст хоче оптимізувати" та "надмірною перевіркою, на яку програміст думає, що допоможе, але є зайвою".
Джонатан Вейклі

1
Якщо ви хочете зафіксувати свій код, щоб видавати помилки під час виконання різних типів UB, включаючи недійсне використання this, тоді просто використовуйте-fsanitize=undefined
Jonathan Wakely,


-25

Стандарт C ++ важливим чином порушується. На жаль, замість того, щоб захистити користувачів від цих проблем, розробники GCC вирішили використовувати невизначене поведінку як привід для здійснення граничних оптимізацій, навіть коли їм було чітко пояснено, наскільки це шкідливо.

Тут набагато розумніша людина, ніж я пояснюю дуже докладно. (Він говорить про С, але там ситуація така ж).

Чому це шкідливо?

Проста перекомпіляція раніше працюючого захищеного коду з новою версією компілятора може ввести вразливості безпеки . Незважаючи на те, що нову поведінку можна відключити за допомогою прапора, очевидно, що існуючі файли не встановлюють цей прапор, очевидно. А оскільки жодне попередження не виробляється, розробнику не очевидно, що раніше обґрунтована поведінка змінилася.

У цьому прикладі розробник включив чек на ціле переповнення, використовуючи 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
Не погоджуйтеся з кожним рядом відповіді. Такі ж коментарі були зроблені для суворої оптимізації, і, сподіваємось, їх зараз звільняють. Рішення полягає в тому, щоб навчити розробників, а не перешкоджати оптимізації на основі поганих звичок розвитку.
СергійА

30
Я пішов і прочитав все, як ви сказали, і справді я плакав, але в основному від дурості Фелікса, на яку я не думаю, що ви намагалися зіткнутися ...
Майк Вайн

33
Захищений за марну розпусту. "Вони ... спеціально вводять помилки. Можливо, для іноземного уряду". Дійсно? Це не / г / змова.
isanae

31
Гідні програмісти знову і знову повторюють мантру , не посилаючись на невизначене поведінку , але ці неріки пішли вперед і все одно зробили це. І подивіться, що сталося. Я ні до чого не співчуваю. Це вина розробників, просто так. Їм потрібно брати на себе відповідальність. Пам'ятайте, що? Особиста відповідальність? Люди, які покладаються на вашу мантру, "але що робити на практиці !" саме так виникла ця ситуація в першу чергу. Уникнення подібних дурниць саме тому стандарти існують в першу чергу. Код до стандартів, і у вас не буде проблем. Період.
Гонки легкості на орбіті

18
"Просто перекомпілювати раніше працюючий захищений код з новою версією компілятора може ввести вразливості безпеки" - це завжди буває . Якщо ви не хочете призначити, що одна версія одного компілятора є єдиним компілятором, який буде дозволений на всю вічність. Чи пам’ятаєте ви ще тоді, коли ядро ​​Linux могло бути скомпільовано тільки з точно gcc 2.7.2.1? Проект gcc навіть розщедрився, тому що люди набридли буграпом. Минуло багато часу, щоб пройти це.
ММ
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.