Функція ненароком скасовує опорний параметр - що пішло не так?


54

Сьогодні ми з’ясували причину неприємного помилки, який траплявся лише з перервами на певних платформах. Зникла наш код виглядав так:

class Foo {
  map<string,string> m;

  void A(const string& key) {
    m.erase(key);
    cout << "Erased: " << key; // oops
  }

  void B() {
    while (!m.empty()) {
      auto toDelete = m.begin();
      A(toDelete->first);
    }
  }
}

Проблема може здатися очевидною в цьому спрощеному випадку: Bпередає посилання на ключ A, який видаляє запис карти, перш ніж намагатися її надрукувати. (У нашому випадку вона не була надрукована, а використовується більш складним способом) Це, звичайно, невизначена поведінка, оскільки keyє звисаючим посиланням після виклику до erase.

Виправити це було тривіально - ми просто змінили тип параметра з const string&на string. Питання: як ми могли б уникнути цього помилки в першу чергу? Здається, обидві функції зробили правильно:

  • Aне має можливості знати, що keyстосується речі, яку він збирається знищити.
  • Bміг зробити копію, перш ніж передати її A, але чи не завдання завдання виклику вирішити, приймати параметри за значенням чи за посиланням?

Чи є якесь правило, якого ми не дотримувались?

Відповіді:


35

Aне має можливості знати, що keyстосується речі, яку він збирається знищити.

Хоча це правда, Aале знає такі речі:

  1. Її мета - щось знищити .

  2. Він бере параметр, який є абсолютно таким самим типом , який він знищить.

З огляду на ці факти, то можливо для Aзнищити свій власний параметр , якщо він приймає параметр як покажчик / посилання. Це не єдине місце в C ++, де потрібно вирішити такі міркування.

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

Слід зазначити, що це лише проблематично, оскільки Aпізніше має намір використовувати keyпараметр після видалення запису. Якби цього не було, то було б добре. Звичайно, тоді стає легким, щоб все працювало ідеально, тоді хтось змінює Aйого використання keyпісля того, як воно потенційно було знищено.

Це було б гарне місце для коментаря.

Чи є якесь правило, якого ми не дотримувались?

У C ++ ви не можете діяти за умови, що якщо сліпо слідувати набору правил, ваш код буде на 100% безпечним. У нас не може бути правил для всього .

Розглянемо пункт №2 вище. Aміг прийняти якийсь параметр типу, відмінного від ключа, але сам об'єкт міг бути суб'єктом ключа на карті. У C ++ 14 findможе приймати тип, відмінний від типу ключа, доки між ними існує дійсне порівняння. Отже, якщо це зробити m.erase(m.find(key)), ви можете знищити параметр, навіть якщо тип параметра не є ключовим типом.

Тож правило типу "якщо тип параметра та тип ключа однакові, прийміть їх за значенням", вас не врятує. Вам знадобиться більше інформації, ніж просто це.

Зрештою, вам потрібно звернути увагу на ваші конкретні випадки використання та здійснювати судження, проінформовані досвідом.


10
Ну, у вас може бути правило "ніколи не поділяйте стан, що змінюється", або це подвійне "ніколи не мутуйте спільний стан", але тоді ви б намагалися написати ідентифікуючий c ++
Caleth

7
@Caleth Якщо ви хочете використовувати ці правила, C ++, ймовірно, не є для вас мовою.
користувач253751

3
@Caleth Ви описуєте Іржа?
Малькольм

1
"Ми не можемо мати правила для всього". Так, ми можемо. cstheory.stackexchange.com/q/4052
Ouroborus

23

Я б сказав, так, є досить просте правило, яке ви порушили, і це врятувало б вас: принцип єдиної відповідальності.

Зараз Aпередається параметр, який він використовує як для видалення елемента з карти, так і для оброблення іншого (друк як показано вище, мабуть, щось інше в реальному коді). Поєднання цих обов'язків мені здається великим джерелом проблеми.

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

std::string &key = get_value_from_map();
destroy(key);
continue_to_use(key);

Зрозуміло, імена, які я використав, безсумнівно, роблять проблему очевиднішою, ніж справжні імена, але якщо імена взагалі значущі, вони майже впевнені, щоб зрозуміти, що ми намагаємось продовжувати використовувати посилання після її визнано недійсним. Проста зміна контексту робить проблему набагато очевиднішою.


3
Що ж, це справедливе спостереження, воно стосується лише дуже вузького ставлення до цього випадку. Є чимало прикладів, коли SRP дотримується, і все ще існують проблеми функції, що потенційно виводить з ладу власний параметр.
Ben Voigt

5
@BenVoigt: Однак просто недійсна параметр не викликає проблем. Він продовжує використовувати параметр після його недійсності, що призводить до проблем. Але, зрештою, так, ти маєш рацію: хоча це врятувало б його в цьому випадку, є безсумнівно випадки, коли це недостатньо.
Джеррі Труну

3
Коли ви пишете спрощений приклад, вам потрібно опустити деякі деталі, а іноді виявляється, що одна з цих деталей була важливою. У нашому випадку Aнасправді шукали keyу двох різних картах і, якщо їх знайшли, видаляли записи плюс додаткове очищення. Тож незрозуміло, що наш Aпорушив СРП. Цікаво, чи варто мені оновлювати питання на цьому етапі.
Микола

2
Щоб розширитись на точку @BenVoigt: у прикладі Ніколая m.erase(key)несе першу відповідальність, а cout << "Erased: " << keyдругу - відповідальність, тому структура коду, показана у цій відповіді, насправді не відрізняється від структури коду в прикладі, але в в реальному світі проблема не була помічена. Принцип єдиної відповідальності не робить нічого для того, щоб забезпечити або навіть зробити його більш імовірним, що суперечливі послідовності поодиноких дій з'являться в безпосередній близькості від коду реального світу.
sdenham

10

Чи є якесь правило, якого ми не дотримувались?

Так, ви не змогли документувати функцію .

Без опису договору про передачу параметрів (конкретно частини, що стосується дійсності параметра - чи є це на початку виклику функції або протягом усієї дати), неможливо сказати, чи є помилка в реалізації (якщо контракт на виклик полягає в тому, що параметр дійсний при запуску виклику, функція повинна зробити копію перед виконанням будь-якої дії, яка може визнати недійсним параметр) або в абонента (якщо контрактом на виклик є те, що параметр повинен залишатися дійсним протягом усього виклику, абонент не може передайте посилання на дані, що змінюються в колекції).

Наприклад, сам стандарт C ++ визначає, що:

Якщо аргумент функції має недійсне значення (наприклад, значення поза доменом функції або покажчик, недійсний для його передбачуваного використання), поведінка не визначена.

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

Існує досить багато справжніх випадків, коли ця відмінність вступає в силу. Наприклад, додавання std::vector<T>до себе


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

@snowman, хоча цікаво, упорядкування UB абсолютно не пов'язане з тим, про що я обговорюю у цій відповіді, що є відповідальністю за забезпечення дійсності (так що UB ніколи не буває).
Ben Voigt

Це якраз моя думка: людина, яка пише код, повинна нести відповідальність за те, щоб уникнути UB, щоб уникнути цілої кролячої нори, наповненої проблемами.

@Snowman: Немає "однієї людини", яка записує весь код у проект. Це одна з причин того, що документація на інтерфейс настільки важлива. Інше полягає в тому, що чітко визначені інтерфейси зменшують кількість коду, про який потрібно заздалегідь обґрунтувати - для будь-якого нетривіального проекту просто неможливо, щоб хтось "відповідав" за думки про правильність кожного твердження.
Бен Войгт

Я ніколи не казав, що одна людина пише весь код. У якийсь момент програміст може переглядати функцію або писати код. Я хочу лише сказати, що той, хто дивиться на код, повинен бути обережним, оскільки на практиці UB заразний і розповсюджується з одного рядка коду на ширші сфери застосування, коли бере участь компілятор. Це повертається до вашої точки зору порушення договору функції: я згоден з вами, але констатую, що це може стати ще більшою проблемою.

2

Чи є якесь правило, якого ми не дотримувались?

Так, ви не змогли його правильно перевірити. Ви не самотні, і ви в потрібному місці для навчання :)


C ++ має багато не визначеної поведінки, не визначена поведінка проявляється в тонких і дратівливих способах.

Ви, ймовірно, ніколи не можете написати 100% безпечний код C ++, але ви, безумовно, можете зменшити ймовірність випадкового введення Невизначеної поведінки у свою кодову базу, використовуючи ряд інструментів.

  1. Попередження компілятора
  2. Статичний аналіз (розширена версія попереджень)
  3. Інструментальні тестові бінарні файли
  4. Загартовані виробничі бінарні товари

У вашому випадку я сумніваюся, що (1) та (2) допомогли б дуже багато, хоча загалом я раджу їх використовувати. А тепер давайте зосередимось на двох інших.

І gcc, і Clang містять -fsanitizeпрапор, який інструмент програм, які ви збираєте, щоб перевірити наявність різноманітних проблем. -fsanitize=undefinedнаприклад, буде ловити підписаний цілий цілий перелив / переповнення, зміщення на занадто велику кількість тощо ... У вашому конкретному випадку, -fsanitize=addressі -fsanitize=memory, швидше за все, виникне проблема ... за умови, що у вас є тест виклику функції. Для повноти -fsanitize=threadварто використовувати, якщо у вас є багатопотокова база даних коду. Якщо ви не можете реалізувати двійковий код (наприклад, у вас є сторонні бібліотеки без їх джерела), ви також можете використовувати, valgrindхоча це взагалі повільніше.

Останні компілятори також мають широкі можливості загартовування . Основна відмінність інструментальних бінарних файлів полягає в тому, що перевірки на загартовування розроблені так, щоб вони мали низький вплив на продуктивність (<1%), що робить їх придатними для виробничого коду в цілому. Найвідоміші - це перевірки CFI (Control Flow Integrity), які призначені для фольгування атак, що руйнують стеки, та віртуального виклику вказівників серед інших способів підриву потоку управління.

Суть обох (3) і (4) полягає в перетворенні переривчастої відмови в певний збій : вони обидва слідують принципу швидкого виходу з ладу . Це означає що:

  • це завжди виходить з ладу, коли наступаєш на міну
  • вона виходить з ладу негайно , вказуючи на помилку, а не на випадкове пошкодження пам'яті тощо.

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


0

@note: цей пост просто додає більше аргументів на додаток до відповіді Бена Войта .

Питання: як ми могли б уникнути цього помилки в першу чергу? Здається, обидві функції зробили правильно:

  • Немає способу знати, що ключ стосується речі, яку він збирається знищити.
  • B міг би зробити копію, перш ніж передати її в A, але чи не це завдання абонента вирішити, приймати параметри за значенням або за посиланням?

Обидві функції зробили правильно.

Проблема полягає в коді клієнта, який не враховував побічні ефекти виклику А.

C ++ не має прямого способу визначення побічних ефектів у мові.

Це означає, що ви (та ваша команда) переконайтеся, що такі речі, як побічні ефекти, видно у коді (як документація) та підтримуються разом із кодом (ви, мабуть, повинні врахувати документування попередніх умов, постових умов та інваріантів також з міркувань видимості).

Зміна коду:

class Foo {
  map<string,string> m;

  /// \sideeffect invalidates iterators
  void A(const string& key) {
    m.erase(key);
    cout << "Erased: " << key; // oops
  }
  ...

З цього моменту у вас на API є щось, що говорить про те, що вам слід пройти одиничний тест; Він також розповідає, як використовувати (а не використовувати) API.


-4

як ми могли в першу чергу уникнути цієї помилки?

Є лише один спосіб уникнути помилок: припинити писати код. Все інше якимось чином не вдалося.

Однак тестування коду на різних рівнях (одиничні тести, функціональні тести, інтеграційні тести, приймальні тести тощо) не тільки покращить якість коду, але й зменшить кількість помилок.


1
Це повна дурниця. Існує не один спосіб уникнути помилок. Хоча тривіально вірно, що єдиний спосіб повністю уникнути існування помилок - це ніколи не писати код, але також правда (і набагато корисніше), що існують різні процедури інженерії програмного забезпечення, яких ви можете дотримуватися, як спочатку писати код, так і при його тестуванні це може значно зменшити наявність помилок. Всі знають про фазу тестування, але найбільший вплив часто може мати найменша вартість, дотримуючись відповідальних дизайнерських практик та ідіом під час написання коду в першу чергу.
Коді Грей
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.