Пару недель назад прошла главная конференция в С++ мире — CPPCON.
Пять дней подряд с 8 утра и до 10 вечера шли доклады. Программисты всех конфессий обсуждали будущее С++, травили байки и думали как сделать С++ проще.
Удивительно много докладов были посвящены обработке ошибок. Устоявшиеся подходы не позволяют достичь максимальной производительности или могут порождать простыни кода.
Какие же нововведения ожидают нас в С++2a?
Немного теории
Условно все ошибочные ситуации в программе можно разделить на 2 большие группы:
- Фатальные ошибки.
- Не фатальные, или ожидаемые ошибки.
Фатальные ошибки
После них не имеет смысла продолжать выполнение.
Например это разыменование нулевого указателя, проезд по памяти, деление на 0 или нарушение других инвариантов в коде. Всё что нужно сделать при их возникновении — это сообщить максимум информации о проблеме и завершить программу.
В C++ слишком много уже достаточно способов что бы завершить программу:
Даже начинают появляться библиотеки для сбора данных о крешах (1, 2, 3).
Не фатальные ошибки
Это ошибки появления которых предусмотрены логикой работы программы. Например, ошибки при работе с сетью, конвертация невалидной строки в число и т.д. Появление таких ошибок в программе в порядке вещей. Для их обработки существует несколько общепринятых в С++ тактик.
О них мы и поговорим более подробно на простом примере:
Попробуем написать функцию void addTwo()
с использованием разных подходов к обработке ошибок.
Функция должна считать 2 строки, преобразовать их в int
и распечатать сумму. Нужно обработать ошибки IO, переполнение и конвертацию в число. Я буду опускать неинтересные детали реализации. Мы рассмотрим 3 основных подхода.
1. Исключения
// Считывает строку из консоли
// При ошибках IO выбрасывает std::runtime_error
std::string readLine();
// Преобразовывает строку в int
// В случае ошибки выбрасывает std::invalid_argument
int parseInt(const std::string& str);
// Складывает a и b
// в случае переполнения выбрасывает std::overflow_error
int safeAdd(int a, int b);
void addTwo() {
try {
std::string aStr = readLine();
std::string bStr = readLine();
int a = parseInt(aStr);
int b = parseInt(bStr);
std::cout << safeAdd(a, b) << std::endl;
} catch(const std::exeption& e) {
std::cout << e.what() << std::endl;
}
}
Исключения в С++ позволяют обрабатывать ошибки централизованно без лишней лапши в коде
,
но за это приходится расплачиваться целым ворохом проблем.
- накладные расходы при обработке исключений довольно большие, нельзя часто выбрасывать исключения.
- лучше не выбрасывать исключения из конструкторов/деструкторов и соблюдать RAII.
- по сигнатуре функции невозможно понять какое исключение может вылететь из функции.
- размер бинарного файла увеличивается за счёт дополнительного кода поддержки исключений.
2. Коды возврата
Классический подход унаследованный от C.
bool readLine(std::string& str);
bool parseInt(const std::string& str, int& result);
bool safeAdd(int a, int b, int& result);
void processError();
void addTwo() {
std::string aStr;
int ok = readLine(aStr);
if (!ok) {
processError();
return;
}
std::string bStr;
ok = readLine(bStr);
if (!ok) {
processError();
return;
}
int a = 0;
ok = parseInt(aStr, a);
if (!ok) {
processError();
return;
}
int b = 0;
ok = parseInt(bStr, b);
if (!ok) {
processError();
return;
}
int result = 0;
ok = safeAdd(a, b, result);
if (!ok) {
processError();
return;
}
std::cout << result << std::endl;
}
Выглядит не очень?
- Нельзя вернуть настоящее значение функции.
- Очень просто забыть обработать ошибку (когда вы последний раз вы проверяли код возврата у printf?).
- Приходится писать код обработки ошибок рядом с каждой функцией. Такой код сложнее читать.
С помощью С++17 и C++2a последовательно починим все эти проблемы.
3. C++17 и nodiscard
В C++17 появился атрибут nodiscard
.
Если указать его перед объявлением функции, то отсутствие проверки возвращаемого значения вызовет предупреждение компилятора.
[[nodiscard]] bool doStuff();
/* ... */
doStuff(); // Предупреждение компилятора!
bool ok = doStuff(); // Ок.
Так же nodiscard
можно указать для класса, структуры или enum class.
В таком случае действие атрибута распространится на все функции возвращающие значения типа помеченного nodiscard
.
enum class [[nodiscard]] ErrorCode {
Exists,
PermissionDenied
};
ErrorCode createDir();
/* ... */
createDir();
Я не буду приводить код с nodiscard
.
C++17 std::optional
В C++ 17 появился std::optional<T>
.
Посмотрим как код выглядит сейчас.
std::optional<std::string> readLine();
std::optional<int> parseInt(const std::string& str);
std::optional<int> safeAdd(int a, int b);
void addTwo() {
std::optional<std::string> aStr = readLine();
std::optional<std::string> bStr = readLine();
if (aStr == std::nullopt || bStr == std::nullopt){
std::cerr << "Some input error" << std::endl;
return;
}
std::optional<int> a = parseInt(*aStr);
std::optional<int> b = parseInt(*bStr);
if (!a || !b) {
std::cerr << "Some parse error" << std::endl;
return;
}
std::optional<int> result = safeAdd(*a, *b);
if (!result) {
std::cerr << "Integer overflow" << std::endl;
return;
}
std::cout << *result << std::endl;
}
Можно убрать in-out аргументы у функций и код станет чище.
Однако, мы теряем информацию о ошибке. Стало непонятно когда и что пошло не так.
Можно заменить std::optional
на std::variant<ResultType, ValueType>
.
Код получится по смыслу такой же как с std::optional
, но более громоздкой.
C++2a и std::expected
std::expected<ResultType, ErrorType>
— специальный шаблонный тип, он возможно попадёт в ближайший незавершённый стандарт.
У него 2 параметра.
ReusltType
— ожидаемое значение.ErrorType
— тип ошибки.
std::expected
может содержать либо ожидаемое значение, либо ошибку. Работа с этим типом это будет примерно такой:
std::expected<int, string> ok = 0; expected<int, string> notOk = std::make_unexpected("something wrong");
Чем же это отличается от обычного variant
? Что делает его особенным?
std::expected
будет монадой.
Предлагается поддержать пачку операций над std::expected
как над монадой: map
, catch_error
, bind
, unwrap
, return
и then
.
С использованием этих функций можно будет связывать вызовы функций в цепочку.
getInt().map([](int i)return i * 2;)
.map(integer_divide_by_2)
.catch_error([](auto e) return 0; );
Пусть у нас есть функции с возвращающие std::expected
.
std::expected<std::string, std::runtime_error> readLine();
std::expected<int, std::runtime_error> parseInt(const std::string& str);
std::expected<int, std::runtime_error> safeAdd(int a, int b);
Ниже только псевдокод, его нельзя заставить работать ни в одном современном компиляторе.
Можно попробовать позаимствовать из Haskell do-синтаксис записи операций над монадами. Почему бы не разрешить делать так:
std::expected<int, std::runtime_error> result = do {
auto aStr <- readLine();
auto bStr <- readLine();
auto a <- parseInt(aStr);
auto b <- parseInt(bStr);
return safeAdd(a, b)
}
Некотороые авторы предлагают такой синтаксис:
try {
auto aStr = try readLine();
auto bStr = try readLine();
auto a = try parseInt(aStr);
auto b = try parseInt(bStr);
std::cout result << std::endl;
return safeAdd(a, b)
} catch (const std::runtime_error& err) {
std::cerr << err.what() << std::endl;
return 0;
}
Компилятор автоматически преобразует такой блок кода в последовательность вызова функций. Если в какой-то момент функция вернёт не то что от нее ожидают, цепочка вычислений прервётся. Да и в качестве типа ошибки можно использовать уже существующие в стандарте типы исключений: std::runtime_error
, std::out_of_range
и т.д.
Если получится хорошо запроектировать синтаксис, то std::expected
позволит писать простой и эффективный код.
Заключение
Идеального способа для обработки ошибок не существует. До недавнего времени в С++ были почти все возможные способы обработки ошибок кроме монад.
В С++2a скорее всего появятся все возможные способы.
Что почитать и посмотреть по теме
Комментарии (74)
totally_nameless
19.10.2018 06:10Я ничего не понимаю в Rust, но в каждой статье про C++ находится кто-то, кто обязательно напишет, какой C++ ущербный по сравнению c Rust. Это симптом?
orcy
19.10.2018 06:25Это давно известный феномен Rust Evangelism Strikeforce.
Ну а если серьезно то похоже на rust, хоть я не уверен насчет дополнительного монадного синтаксиса описанного в статье, это что новое?
Про expected Александреску топил в 2012 channel9.msdn.com/Shows/Going+Deep/C-and-Beyond-2012-Andrei-Alexandrescu-Systematic-Error-Handling-in-C, посмотрим что в итоге из этого получится. Вот что еще попадалось интересного в последнее время про обработку ошибок в C++:
qthree
19.10.2018 07:22Просто «Немного теории» буквально слово в слово из Rust book списаны:
Rust groups errors into two major categories: recoverable and unrecoverable errors. For a recoverable error, such as a file not found error, it’s reasonable to report the problem to the user and retry the operation. Unrecoverable errors are always symptoms of bugs, like trying to access a location beyond the end of an array.
inv2004
19.10.2018 07:42Именно из-за этого и написал комментарий выше:
1) ошибки преобразовали в алгебраический тип.
2) для которого определены функции для chain-обработки.
3) try, который try!
очень похоже.
slonopotamus
19.10.2018 22:23-4Может вам стоит посмотреть на Rust и вы перестанете ничего не понимать и тоже будете считать что C++ ущербный по сравнению с ним?
totally_nameless
20.10.2018 00:11+2А писать потом уничижетельные комментарии про C++ я буду обязан? :)
slonopotamus
20.10.2018 00:49Очевидно, нет. Но раз уж вас эта тема беспокоит, почему бы не изучить вопрос и не сформировать собственное мнение?
nikitaevg
19.10.2018 07:41Чем плохо кидать из конструктора? Кажется, это даже хорошо и кажется это единственный нормальный способ сообщить об ошибки конструирования.
Касательно темы, сам пишу на плюсах, но в какое-то время сильно увлекся хаскелем. И монада maybe это одна из тех вещей, отсутствие которых сильно ощущается в плюсах после знакомства с хаскелем. Будет очень круто если завезут что-то такое.qw1
19.10.2018 15:28Чем плохо кидать из конструктора?
Не вызывается деструктор. При этом, если в классе есть переменные со своими конструкторами, эти конструкторы будут вызваны, а деструкторы — нет (утечка ресурсов)Videoman
19.10.2018 15:41Вы не правы. Для всех полностью сконструированных объектов будут вызваны деструкторы. Если писать на нормальном C++ с RAII, то никаких утечек не будет. Плюсы же у такого подхода огромные. Например, не нужно будет в каждом методе проверял корректное ли сейчас состояние у объекта, т.к. в случае исключение объект не будет создан.
qw1
19.10.2018 15:45Я специально проверил. В С++ из Visual Studio 2017 деструктор не вызывается, но в GCC — вызывается. Это что, UB?
mapron
19.10.2018 15:46Очень странно, нет, это стандарт, напишите им в багтрекер, наверное =)
Ну может оптимизатор, вы проверили с О0?qw1
19.10.2018 15:53В-общем разобрался.
Если вызывать компилятор с ключами по умолчанию, получается такое поведение.
Если добавить ключ /EHsc (enable C++ EH), деструктор вызывается.
Странно, что по умолчанию оно выключено.
mapron
19.10.2018 15:49Кстати, есть еще малоизвестный нюанс с этими исключениями:
В случае если был вызван делегирующий конструктор, то объект считается полностью созданным и при исключении уже деструктор будет отрабатывать.
В каких-то случаях это необходимо учитывать =)
mapron
19.10.2018 15:45#include class FieldT
{
public:
FieldT() { std::cout << "FieldT()\n"; }
~FieldT() { std::cout << "~FieldT()\n"; }
};
class Container
{
FieldT m_field;
public:
Container() { throw 1; }
};
int main()
{
try {
Container c;
}catch(...) {}
}
Все вызывается, вы о чем?shaggyboo Автор
20.10.2018 02:07Наверно я погорячился с «плохо».
Могу с натяжкой придумать 2 тонкости.
1. Как уже упоминали ранее, деструктор не будет вызван, но для делегирующего конструктора будет.
2. Это может приводить к boilerplate при написании биндингов для не RAII примитивов. Например, когда есть объект владеющий двумя файловыми дескрипторами.
Но это все мелочи.
Интересно, но похоже `std::expected` никак не поможет сообщить об ошибке из конструктора.
Ryppka
20.10.2018 17:09Если верить Б. Страуструпу, то исключения были введены в язык из-за невозможности другим способом сообщить об ошибке конструктора. Логично — экземпляр класса должен быть либо создан с соблюдением инвариантов, либо нет смысла продолжать дальше. Прямо укладывается в одну из главных идей Страуструпа, что экземпляры пользовательских типов должны вести себя «как int».
Дальше обратили внимание, что если выбрасывать исключения из любой функции, то основной успешный сценарий в коде выглядит намного понятнее и выполняется быстрее. Стало возможным выбрасывать исключения из любого места кода, сделали удобный механизм try-catch и понеслось…
Я лично сторонник ограничений на то, как и когда можно выбрасывать исключения и подход с паниками из Go и Rust мне нравится больше: если считаешь нужным, то выбрасывай, это легко. Но ловить громоздко и труднее, чем через try-catch, так что делают это только если другого пути нет (ну в идеале так).
В целом, диалектика локальной/нелокальной обработки ошибок — одна из сложнейших проблем в программировании вообще, а не только в C++. Последний, правда, добавил проблему безопасности исключений, тоже удовольствие.eao197
21.10.2018 09:56Если верить Б. Страуструпу, то исключения были введены в язык из-за невозможности другим способом сообщить об ошибке конструктора.
Кроме конструкторов такая же ситуация и с перегрузкой операторов. Там так же исключения — это практически единственный нормальный способ сообщить об ошибке.Ryppka
21.10.2018 10:25Спасибо, я как-то не учитывал этот случай. Но мой главный пафос в том, что асинхронные (по сути, хотя настоящие асинхронные ошибки, это, конечно, только сигналы и SEH) и не-локально обрабатываемые ошибки во всех других случаях надо использовать намного реже, чем это делается по факту. Чисто инженерный вопрос: взвесить плюсы и минусы и решить, какую стратегию обработки ошибок использовать здесь и сейчас. То, что в C++ надо думать чуть ли не над каждой строчкой — это и преимущество, и проклятье языка, не согласны?
eao197
21.10.2018 10:32А я свой комментарий писал не в пику вашему, а как дополнение.
То, что в C++ надо думать чуть ли не над каждой строчкой — это и преимущество, и проклятье языка, не согласны?
Согласен. Но имея опыт работы с разными языками, исхожу из того, что как только нам приходится делать что-то нетривиальное и/или новое, то думать приходится вне зависимости от языка. Некоторые языки, которые изначально более безопасные (скажем, выполняют ряд проверок в run-time, как в Pascal/Modula-2/Ada/Rust/..., используют GC, как Java/C#/Eiffel/OCaml/...), несколько повышают коэффициент спокойного сна у разработчика. Но думать при это все равно нужно много.
TargetSan
19.10.2018 08:00Более интересен подход со static exceptions
https://old.reddit.com/r/cpp/comments/9owiju/exceptions_may_finally_get_fixed/
Объявить тип исключения явно в заголовке, заставить конвертировать всё, что вылетает из функции, в него. Получается std::expected, но под видом существующих исключений.
NeoCode
19.10.2018 09:23А я честно признаюсь — нравятся коды возврата, люблю явность во всем. Мне не нравится что функция может выбросить исключение где-то в глубине стека вызовов (особенно чужого, например библиотечного кода), там его никто не обработает, и оно вывалится у меня. По сути это еще круче чем goto — это целый скрытый слой передачи управления, который нужно отслеживать параллельно основному коду.
doctorw
19.10.2018 10:42А поддерживать актуальность таблиц кодов возврата и обработку всех соответствующих кодов легко?
MUTbKA98
19.10.2018 15:02В языке, упоминание которого вслух тут вызывает бурные эмоции, очень легко все организовать так, что это будет гарантировать сам компилятор.
shaggyboo Автор
19.10.2018 15:19+1И как же?
На сколько я знаю, там есть подобие[nodiscard]
и предупреждение вswitch
о необработанныхcase
. Но это не панацея.0xd34df00d
19.10.2018 16:20Почему не панацея? Особенно если язык проверяет тотальность функций (а это немножко не то же самое, что case coverage analysis).
3dcryx
19.10.2018 11:15-2Исключения не должны быть обработаны в библиотеке. В чем тогда вообще их смысл?
Предпологается, что при выбрасывании исключения программа не может более выполнять поставленную задачу (ну например ошибка компиляции), а ловить это исключение где-нибудь наверху надо (и писать в лог/выводить на экран или еще чего делать). Таким образом обработка происходит только в одном месте и вам не нужно думать и знать о том какие ошибки могут произойти в стороннем коде.nikola-erm
20.10.2018 02:09Полностью разделяю ваше понимание того, зачем нужны исключения и когда их стоит использовать. Что еще могут сделать авторы библиотеки (к примеру, работы с json) в случае некорректных данных, кроме как выбросить исключение? Можно сообщить об ошибке и через код возврата, иногда поддерживаются оба варианта:
JsonNode parse(const std::string&); //exception on error bool parse(const std::string&, JsonNode& result); //returns false on error
Думаю, минус из-за неудачного примера: в случае ошибки компиляции программа в принципе не запустится.
Videoman
19.10.2018 12:00Исключения на то и исключения что они не происходят во время нормального выполнения программы. Считаю, что пример парсинга строки и дизайн этой функции с выкидыванием исключения — дурацкий. Невозможность распарсить строку для parseInt(), вполне штатная ситуация, на мой взгляд.
NeoCode
19.10.2018 19:45Когда мне после С++ пришлось писать что-то на C#, я плевался от того что какая-то функция типа parseInt не парсила пустые строки как нули (в Си atoi вернет именно ноль для пустой строки). Но это еще что… а вот то что на разных системах разные локали, и где-то десятичный разделитель «точка» а где-то «запятая», и если данные сохранены в текстовом файле на одной машине а читаются на другой, и из-за этого сыпятся исключения… в общем весь парсинг пришлось делать вручную на весьма низком уровне, вместо того чтобы пользоваться готовыми решениями:)
BalinTomsk
19.10.2018 23:16int atoi(string val) { int result = Int32.TryParse( val, out result ) ? result : 0; return result; }
DelphiCowboy
19.10.2018 11:05+1Почему называется «2a», а не «20»?
fireSparrow
19.10.2018 11:16Видимо, потому, что шестнадцатеричное A соответствует десятеричному 10. Таким образом 2*A в шестнадцатеричной == 2*10 в десятеричной.
Но это просто моя догадка в порядке бреда ))mayorovp
19.10.2018 11:19Нет, исходно такая нумерация применялась потому что года выпуска стандарта не были известны заранее. Вот и выходило, что C++11 сначала называли сначала C+0x, а потом еще и С+1x, стандарт С++14 успел побыть C++1y, а стандарт C++17 когда-то назывался C++1z. Вот и C++2a из той же серии.
Однако такое написание все равно вызывает недоумение, поскольку уже принят трехлетний цикл выпуска стандартов, и год выпуска C++20 уже известен.mapron
19.10.2018 15:50Год подготовки документа известен, но может боятся именно утверждения стандарта (если он затянется на несколько месяцев и выйдет внезапный С++21)
slava_phirsov
19.10.2018 12:52-1Ну и зачем писать вот так:
std::string aStr; int ok = readLine(aStr); if (!ok) { processError(); return; } std::string bStr; ok = readLine(bStr); if (!ok) { processError(); return; } int a = 0; ok = parseInt(aStr, a); if (!ok) { processError(); return; } int b = 0; ok = parseInt(bStr, b); if (!ok) { processError(); return; } int result = 0; ok = safeAdd(a, b, result); if (!ok) { processError(); return; } std::cout << result << std::endl;
«Лапшу» можно с любым механизмом обработки ошибок сотворить. А если вот так:
std::string aStr; if (readLine(aStr)) { std::string bStr; if (readLine(bStr)) { int a = 0, b = 0; if (parseInt(aStr, a) && parseInt(bStr, b) && safeAdd(a, b, result)) { std::cout << result << std::endl; return; } } } processError();
Или даже так — ценой, возможно, преждевременного создания объектов (надеюсь, компиляторы/библиотеки, когда-нибудь станут достаточно умными, чтобы сделать это чуть-чуть дешевле:
std::string aStr, bStr; int a, b, result; if ( readLine(aStr) && readLine(bStr) && parseInt(aStr, a) && parseInt(bStr, b) && safeAdd(a, b, result) ) std::cout << result << std::endl; else processError();
mayorovp
19.10.2018 13:31Первый ваш вариант еще хуже авторского: слишком много отступов, которые к тому же будут меняться при изменении числа шагов. Этому антипаттерну даже есть название — «If Ok».
Второй вариант гораздо лучше, но его может быть затруднительно отлаживать. Кстати, пустые строки почти ничего не стоят, можно за них не беспокоиться.slava_phirsov
19.10.2018 14:14Он читается лучше, т.к. при чтении банально меньше нужно елозить глазами вниз по экрану. И антипаттерн он только в C++, в «чистом» C, где широко используются коды ошибок, это — обычное дело. Конечно, больше 3-х уровней отступов нормальные люди без острой необходимости не делают.
P.S. «Антипаттерн» — обычное слово-идеон. Причисление к паттернам и антипаттернам, как правило, происходит на волне очередного хайпа. В своё время что только не объявляли антипаттерном: коды возврата, null-объекты, goto, более одного return, отсутствие Yoda-style в сравнении. А оказалось, что и коды возврата, и null-объект — вполне рабочие подходы, goto широко используется в системном программировании на C для обработки ошибок, yoda-style используют не только лишь не все, а вообще мало кто.mayorovp
19.10.2018 14:26+1If Ok в чистом Си — точно такой же антипаттерн. Его недостатки не зависят от языка.
Не вижу каким образом он позволяет меньше «елозить» глазами по экрану. Напротив, код автора можно читать последовательно, в то время как при чтении вашего кода приходится каждый раз смотреть в самый низ метода чтобы убедиться что у оператора if нет ветки else.
А уж во что If Ok превращается при слияниях в гите…slava_phirsov
19.10.2018 14:35В C ему альтернатива ровно одна: goto. Если есть другие варианты, то их, пожалуйста, в студию.
mayorovp
19.10.2018 15:02Не самая плохая альтернатива (до тех пор, пока метка наподобие fail в функции одна).
slava_phirsov
19.10.2018 19:34Ну вот не нравится многим C++-сникам goto, не любят они его вплоть до включения в coding style запрета на goto.
Исходный вариаент кода — плох многословностью, copy-paste-ом, множественными return.
Вариант с несколькими уровнями вложенности — да, если их больше 2-х это напрягает, хотя, на мой вкус, всё равно читается лучше, чем то, что было изначально.
Вариант с одним if-ом — плох тем, что даёт, пусть и небольшие, лишние накладные расходы на создание неиспользуемых объектов.
Ещё варианты есть? По-моему, альтернативы нет.
slava_phirsov
19.10.2018 14:41З.Ы. в исходном варианте множественный return == антипаттерн
Videoman
19.10.2018 15:37Если везде возвращать разные результаты, то, возможно — да. Если как в примере, то это принцип «Fail Fast». Т.е. нормальное выполнение у нас идет сверху-вниз, а выходы в случае ошибок, как можно быстрее. Такой подход, наоборот, предпочтительней, т.к. уменьшает количество вложенных блоков и не является антипаттерном.
slava_phirsov
19.10.2018 19:21А если везде возвращать один результат, то это уже нарушение DRY. Собственно, исходный вариант и есть хороший пример нарушения DRY
Videoman
19.10.2018 22:10Нет, здесь нет никакого нарушения DRY. Вы либо не понимаете в чем заключается принцип DRY, либо слишком буквально его трактуете. То что код return-ов одинаков это всего-лишь совпадение. В процессе разработки строчки никак не связаны и могут меняться а также удаляться независимо друг от друга.
mayorovp
20.10.2018 19:19-1Да нет, обычно они все-таки связаны. Такие вещи как освобождение ресурсов будут неизбежно дублироваться перед каждым return.
Antervis
19.10.2018 17:31+1а с чего вы взяли что «множественный return» является антипаттерном? Ведь на самом деле антипаттерном является возврат из функции в разные места (по сути, goto cond? A: B; где A и B вне функции). Множественный return же никто никогда не запрещал
slava_phirsov
19.10.2018 19:27-1Ну, погуглите one return only, будет много интересного. И, да, я не считаю это большой проблемой в исходном варианте, гораздо хуже там неоднократный copy-paste блока после if.
Antervis
19.10.2018 21:42+2Из релеватного по запросу «one return only» гугл возвращает несколько вопросов на SO аля «а нужен ли он?» и одна статья 2009-ого года от некоего Tom'а Dalling'а, который явно не слышал о RAII, ибо он приводит откровенно слабую аргументацию вида «ну вы же можете забыть почистить ресурсы». И даже больше скажу: в Си, без RAII, можно вместо mutiple return использовать goto в конец функции, где начинается очистка ресурсов.
Плюсы early return очевидны:
- значение ошибки рядом с условием её возникновения — легче отлаживать
- функция читается линейно, не надо помнить контекст каждого условного перехода
- легко убирать/добавлять/разделять/объединять проверки и отслеживать историю изменений
- наглядная гарантия что функция не делает ничего лишнего после нарушения инварианта
- можно объявлять переменные непосредственно перед их использованием
- мало отступов — разумно используется пространство экрана
- мало отступов — легко визуально сопоставить начало и конец блока
- early return консистентен с throw
- из моей практики — как правило код выходит короче (и в длину и в ширину)
Объективной аргументации за one return only я не встречал. Всё всегда сводится к вкусовщине, «я не использую split view», откровенно надуманным примерам или ссылкам на «ну вот он же так рекомендует» (то, что сделали вы). По факту, only return течение возникло от неправильной интерпретации сказанного Дейкстрой, после чего эта рекомендация попала в MISRA C, где её эффективность была опровергнута. И спустя 15 лет люди еще не разучились её применять…
Кстати, даже в первом из ваших вариантов у функции две точки возврата.Ryppka
20.10.2018 17:18Что интересно — на уровне сгенерированного кода множественный return обычно превращается в неявный goto + single return)))
А если серьезно — то за single return обычно «топят» в C, а в C++ из-за исключений требование single return'а чаще считают надуманным.
Лично мой подход (последнее время чаще пишу на C): если single return выглядит просто и понятно, то лучше использовать его, но если приходится извращаться, то в топку)))Antervis
20.10.2018 23:37Что интересно — на уровне сгенерированного кода множественный return обычно превращается в неявный goto + single return)))
логично, с учетом эвристики компиляторов «early return — холодная ветка»
amarao
19.10.2018 14:16-2Все возможные способы обработки ошибок, кроме Result, это как «все возможные способы выпить кофе, кроме как через ротовое отверстие». Да, есть методы, но они все… тревожащие.
Izulle
19.10.2018 14:28std::expected<int, std::runtime_error> result = do {
auto aStr <- readInt();
auto bStr <- readInt();
auto a <- readInt(aStr);
auto b <- readInt(bStr);
return safeAdd(a, b)
}
А тут действительно все имена функций должны быть одинаковые?shaggyboo Автор
19.10.2018 15:12Конечно должен. Сложно написать сразу без багов код, который нельзя скомпилировать.
slava_phirsov
19.10.2018 15:34К сожалению, не раскрыта тема (анти?)паттерна null-object, а по крайней мере в одном популярном фреймворке он просто-таки красной нитью…
gitKroz
19.10.2018 17:07накладные расходы при обработке исключений довольно большие, нельзя часто выбрасывать исключения.
На мой взгляд, формулировка может быть понята неправильно. Предлагаю уточнить.
В сравнении с if (...), когда исключения/ошибки не возникает, то код с исключениями быстрее. В случае возникновения исключения/ошибки, код с исключениями медленнее. Пруф. Да, получается, что исключения не нужно выбрасывать часто. По этой причине, не рекомендуют строить логику на исключениях, но в качестве обработчика ошибочных ситуаций исключения хороши.
Единственная проблема здесь состоит в определении слова «часто»: часто — это сколько? Я еще не слышал о реальных кейсах, когда исключения были бы узким местом быстродействия программы. А пока таких кейсов не наблюдается, учитывая другие плюсы исключений (отсутствие «лапши» в коде, требование языка отлавливать исключения), на мой взгляд использование исключений должно быть рекомендованным способом обработки ошибок.
лучше не выбрасывать исключения из конструкторов/деструкторов и соблюдать RAII.
Каким образом альтернативные способы описанные в статье решают эти проблемы?ledocool
19.10.2018 22:49Скорее всего часто, это когда расходы от исключений потенциально превышают расходы от if. Я бы замерил насколько исключение медленнее и на основе этого составил Иделаьную Пропорцию (тм). Но влом.
ivanrt
19.10.2018 17:15Мне этот вариант нравится. Мы что-то похожее используем. Думаю для понятности можно некоторые auto превратить в конкретные типы:
std::expected<int, std::runtime_error> result = do { // aStr не будет инициализирована в случае ошибки, мы сразу вывалимся из do-блока std::string aStr <- readInt(); std::string bStr <- readInt(); int a <- parseInt(aStr); int b <- parseInt(bStr); return safeAdd(a, b) }
inv2004
долго думали… и решили сделать как в расте :)
Kabdim
А в раст сделали как во множестве ФП языков…
slonopotamus
Но try! или?.. нету, так что существенно менее удобно чем в расте.
inv2004
упоминание try есть в конце статьи, а "?" не сразу появился, а после try.
Antervis
фактически, try!/? это «явные исключения». Основное отличие в том, что исключения не превносят накладных расходов в позитивный сценарий исполнения.