Сегодня мы узнали причину неприятной ошибки, которая периодически возникала только на определенных платформах. Сложив наш код выглядел так:
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
, но разве не вызывающей стороне решать, принимать ли параметры по значению или по ссылке?
Есть ли какое-то правило, которому мы не смогли следовать?
Я бы сказал, да, есть довольно простое правило, которое вы нарушили, которое спасло бы вас: принцип единой ответственности.
Прямо сейчас
A
передается параметр, который он использует как для удаления элемента с карты, так и для другой обработки (печать, как показано выше, очевидно, что-то еще в реальном коде). Сочетание этих обязанностей выглядит для меня, как большая часть источника проблемы.Если у нас есть одна функция, которая просто удаляет значение из карты, и другая, которая просто выполняет обработку значения из карты, нам пришлось бы вызывать каждую из кода более высокого уровня, поэтому мы получили бы что-то вроде этого :
Конечно, имена, которые я использовал, несомненно, делают проблему более очевидной, чем реальные имена, но если имена вообще значимы, они почти наверняка прояснят, что мы пытаемся продолжать использовать ссылку после был признан недействительным. Простое изменение контекста делает проблему намного более очевидной.
источник
A
фактически искали наkey
двух разных картах и, если нашли, удалили записи плюс некоторая дополнительная очистка. Так что не понятно, что нашA
нарушил СРП. Интересно, мне следует обновить вопрос на этом этапе?m.erase(key)
он несет первую ответственность иcout << "Erased: " << key
несет вторую ответственность, поэтому структура кода, показанная в этом ответе, фактически не отличается от структуры кода в примере, но в В реальном мире проблема была упущена. Принцип единой ответственности не делает ничего для обеспечения или даже повышения вероятности того, что противоречащие друг другу последовательности отдельных действий появятся в непосредственной близости в реальном коде.Да, вам не удалось задокументировать функцию .
Без описания контракта на передачу параметров (в частности, части, относящейся к действительности параметра - в начале вызова функции или во всем), невозможно определить, есть ли ошибка в реализации (если контракт вызова является то, что параметр является действительным, когда начинается вызов, функция должна сделать копию перед выполнением любого действия, которое может сделать недействительным параметр) или в вызывающей стороне (если контракт вызова заключается в том, что параметр должен оставаться действительным в течение всего вызова, вызывающая сторона не может передать ссылку на данные внутри изменяемой коллекции).
Например, сам стандарт C ++ определяет, что:
но в нем не указывается, применяется ли это только к моменту вызова или во время выполнения функции. Однако во многих случаях ясно, что возможна только последняя, а именно, когда аргумент нельзя сохранить действительным, сделав копию.
Есть немало реальных случаев, когда это различие вступает в игру. Например, добавление
std::vector<T>
к себеисточник
Да, вы не смогли проверить это правильно. Ты не один, и ты в правильном месте, чтобы учиться :)
C ++ имеет много неопределенного поведения, неопределенное поведение проявляется тонкими и раздражающими способами.
Вы, вероятно, никогда не сможете написать 100% безопасный код C ++, но вы, безусловно, можете уменьшить вероятность случайного введения Undefined Behavior в вашу кодовую базу, используя ряд инструментов.
В вашем случае я сомневаюсь, что (1) и (2) очень помогли бы, хотя в целом я советую использовать их. А пока давайте сосредоточимся на двух других.
И gcc, и Clang имеют
-fsanitize
флаг, который используется инструментами, которые вы компилируете, для проверки множества проблем.-fsanitize=undefined
например, будет перехватывать целое число переполнения / переполнения, сдвига на слишком большое количество и т. д. ... В вашем конкретном случае,-fsanitize=address
и-fsanitize=memory
, скорее всего , возникнет проблема ... при условии, что у вас есть тест, вызывающий функцию. Для полноты,-fsanitize=thread
стоит использовать, если у вас есть многопоточная кодовая база. Если вы не можете реализовать двоичный файл (например, у вас есть сторонние библиотеки без их источника), то вы также можете использоватьvalgrind
его, хотя в целом он медленнее.Последние компиляторы также показывают возможности повышения благосостояния . Основное отличие от инструментальных бинарных файлов заключается в том, что проверки на закалку предназначены для того, чтобы оказывать низкое влияние на производительность (<1%), что делает их пригодными для производственного кода в целом. Самыми известными являются проверки CFI (целостность потока управления), которые предназначены для предотвращения атак с разбивкой стека и хай-джеккинга виртуальных указателей, в том числе для подрыва потока управления.
Цель обоих (3) и (4) - преобразовать прерывистый отказ в определенный отказ : оба они следуют принципу быстрого отказа . Это значит, что:
Объединение (3) с хорошим тестовым покрытием должно выявить большинство проблем, прежде чем они попадут в производство. Использование (4) в производстве может быть различием между надоедливой ошибкой и эксплойтом.
источник
@ примечание: этот пост просто добавляет больше аргументов поверх ответа Бена Фойгта .
Обе функции сделали правильную вещь.
Проблема в клиентском коде, который не учитывает побочные эффекты вызова A.
C ++ не имеет прямого способа указать побочные эффекты в языке.
Это означает, что вы (и ваша команда) должны убедиться, что такие вещи, как побочные эффекты, видны в коде (как документация) и поддерживаются в коде (вам, вероятно, следует рассмотреть возможность документирования предварительных условий, постусловий и инвариантов а также, из соображений наглядности).
Изменение кода:
С этого момента у вас есть что-то поверх API, которое говорит вам, что у вас должен быть модульный тест для него; Он также говорит вам, как использовать (а не использовать) API.
источник
Есть только один способ избежать ошибок: прекратить писать код. Все остальное не получилось каким-то образом
Однако тестирование кода на различных уровнях (модульные тесты, функциональные тесты, интеграционные тесты, приемочные тесты и т. Д.) Не только улучшит качество кода, но и уменьшит количество ошибок.
источник