Pull to refresh

Comments 76

долго думали… и решили сделать как в расте :)

А в раст сделали как во множестве ФП языков…

Но try! или?.. нету, так что существенно менее удобно чем в расте.

упоминание try есть в конце статьи, а "?" не сразу появился, а после try.
фактически, try!/? это «явные исключения». Основное отличие в том, что исключения не превносят накладных расходов в позитивный сценарий исполнения.
Я ничего не понимаю в Rust, но в каждой статье про C++ находится кто-то, кто обязательно напишет, какой C++ ущербный по сравнению c Rust. Это симптом?
Это давно известный феномен Rust Evangelism Strikeforce.

Ну а если серьезно то похоже на rust, хоть я не уверен насчет дополнительного монадного синтаксиса описанного в статье, это что новое?

Про expected Александреску топил в 2012 channel9.msdn.com/Shows/Going+Deep/C-and-Beyond-2012-Andrei-Alexandrescu-Systematic-Error-Handling-in-C, посмотрим что в итоге из этого получится. Вот что еще попадалось интересного в последнее время про обработку ошибок в C++:

Просто «Немного теории» буквально слово в слово из 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.
Именно из-за этого и написал комментарий выше:
1) ошибки преобразовали в алгебраический тип.
2) для которого определены функции для chain-обработки.
3) try, который try!

очень похоже.

Так ведь это все уже десять тысяч лет как придумали в Хаскеле

Может вам стоит посмотреть на Rust и вы перестанете ничего не понимать и тоже будете считать что C++ ущербный по сравнению с ним?

Очевидно, нет. Но раз уж вас эта тема беспокоит, почему бы не изучить вопрос и не сформировать собственное мнение?

Чем плохо кидать из конструктора? Кажется, это даже хорошо и кажется это единственный нормальный способ сообщить об ошибки конструирования.

Касательно темы, сам пишу на плюсах, но в какое-то время сильно увлекся хаскелем. И монада maybe это одна из тех вещей, отсутствие которых сильно ощущается в плюсах после знакомства с хаскелем. Будет очень круто если завезут что-то такое.
Чем плохо кидать из конструктора?
Не вызывается деструктор. При этом, если в классе есть переменные со своими конструкторами, эти конструкторы будут вызваны, а деструкторы — нет (утечка ресурсов)
Вы не правы. Для всех полностью сконструированных объектов будут вызваны деструкторы. Если писать на нормальном C++ с RAII, то никаких утечек не будет. Плюсы же у такого подхода огромные. Например, не нужно будет в каждом методе проверял корректное ли сейчас состояние у объекта, т.к. в случае исключение объект не будет создан.
Я специально проверил. В С++ из Visual Studio 2017 деструктор не вызывается, но в GCC — вызывается. Это что, UB?
Очень странно, нет, это стандарт, напишите им в багтрекер, наверное =)
Ну может оптимизатор, вы проверили с О0?
В-общем разобрался.
Если вызывать компилятор с ключами по умолчанию, получается такое поведение.
Если добавить ключ /EHsc (enable C++ EH), деструктор вызывается.
Странно, что по умолчанию оно выключено.
У вас где-то ошибка, либо в коде, либо в тесте.
Кстати, есть еще малоизвестный нюанс с этими исключениями:
В случае если был вызван делегирующий конструктор, то объект считается полностью созданным и при исключении уже деструктор будет отрабатывать.
В каких-то случаях это необходимо учитывать =)
#include iostream

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(...) {}
}



Все вызывается, вы о чем?
Ну как видим, оно хотя бы выдало предупреждение. Почему по умолчанию это всего лишь предупреждение — непонятно, да.
Наверно я погорячился с «плохо».
Могу с натяжкой придумать 2 тонкости.

1. Как уже упоминали ранее, деструктор не будет вызван, но для делегирующего конструктора будет.
2. Это может приводить к boilerplate при написании биндингов для не RAII примитивов. Например, когда есть объект владеющий двумя файловыми дескрипторами.
Но это все мелочи.

Интересно, но похоже `std::expected` никак не поможет сообщить об ошибке из конструктора.

Если верить Б. Страуструпу, то исключения были введены в язык из-за невозможности другим способом сообщить об ошибке конструктора. Логично — экземпляр класса должен быть либо создан с соблюдением инвариантов, либо нет смысла продолжать дальше. Прямо укладывается в одну из главных идей Страуструпа, что экземпляры пользовательских типов должны вести себя «как int».
Дальше обратили внимание, что если выбрасывать исключения из любой функции, то основной успешный сценарий в коде выглядит намного понятнее и выполняется быстрее. Стало возможным выбрасывать исключения из любого места кода, сделали удобный механизм try-catch и понеслось…
Я лично сторонник ограничений на то, как и когда можно выбрасывать исключения и подход с паниками из Go и Rust мне нравится больше: если считаешь нужным, то выбрасывай, это легко. Но ловить громоздко и труднее, чем через try-catch, так что делают это только если другого пути нет (ну в идеале так).
В целом, диалектика локальной/нелокальной обработки ошибок — одна из сложнейших проблем в программировании вообще, а не только в C++. Последний, правда, добавил проблему безопасности исключений, тоже удовольствие.
Если верить Б. Страуструпу, то исключения были введены в язык из-за невозможности другим способом сообщить об ошибке конструктора.
Кроме конструкторов такая же ситуация и с перегрузкой операторов. Там так же исключения — это практически единственный нормальный способ сообщить об ошибке.
Спасибо, я как-то не учитывал этот случай. Но мой главный пафос в том, что асинхронные (по сути, хотя настоящие асинхронные ошибки, это, конечно, только сигналы и SEH) и не-локально обрабатываемые ошибки во всех других случаях надо использовать намного реже, чем это делается по факту. Чисто инженерный вопрос: взвесить плюсы и минусы и решить, какую стратегию обработки ошибок использовать здесь и сейчас. То, что в C++ надо думать чуть ли не над каждой строчкой — это и преимущество, и проклятье языка, не согласны?
А я свой комментарий писал не в пику вашему, а как дополнение.
То, что в C++ надо думать чуть ли не над каждой строчкой — это и преимущество, и проклятье языка, не согласны?
Согласен. Но имея опыт работы с разными языками, исхожу из того, что как только нам приходится делать что-то нетривиальное и/или новое, то думать приходится вне зависимости от языка. Некоторые языки, которые изначально более безопасные (скажем, выполняют ряд проверок в run-time, как в Pascal/Modula-2/Ada/Rust/..., используют GC, как Java/C#/Eiffel/OCaml/...), несколько повышают коэффициент спокойного сна у разработчика. Но думать при это все равно нужно много.

Более интересен подход со static exceptions
https://old.reddit.com/r/cpp/comments/9owiju/exceptions_may_finally_get_fixed/
Объявить тип исключения явно в заголовке, заставить конвертировать всё, что вылетает из функции, в него. Получается std::expected, но под видом существующих исключений.

А я честно признаюсь — нравятся коды возврата, люблю явность во всем. Мне не нравится что функция может выбросить исключение где-то в глубине стека вызовов (особенно чужого, например библиотечного кода), там его никто не обработает, и оно вывалится у меня. По сути это еще круче чем goto — это целый скрытый слой передачи управления, который нужно отслеживать параллельно основному коду.
А поддерживать актуальность таблиц кодов возврата и обработку всех соответствующих кодов легко?
В языке, упоминание которого вслух тут вызывает бурные эмоции, очень легко все организовать так, что это будет гарантировать сам компилятор.

И как же?
На сколько я знаю, там есть подобие [nodiscard] и предупреждение в switch о необработанных case. Но это не панацея.

UFO landed and left these words here
UFO landed and left these words here
Полностью разделяю ваше понимание того, зачем нужны исключения и когда их стоит использовать. Что еще могут сделать авторы библиотеки (к примеру, работы с json) в случае некорректных данных, кроме как выбросить исключение? Можно сообщить об ошибке и через код возврата, иногда поддерживаются оба варианта:
JsonNode parse(const std::string&); //exception on error 
bool parse(const std::string&, JsonNode& result); //returns false on error

Думаю, минус из-за неудачного примера: в случае ошибки компиляции программа в принципе не запустится.
Исключения на то и исключения что они не происходят во время нормального выполнения программы. Считаю, что пример парсинга строки и дизайн этой функции с выкидыванием исключения — дурацкий. Невозможность распарсить строку для parseInt(), вполне штатная ситуация, на мой взгляд.
Когда мне после С++ пришлось писать что-то на C#, я плевался от того что какая-то функция типа parseInt не парсила пустые строки как нули (в Си atoi вернет именно ноль для пустой строки). Но это еще что… а вот то что на разных системах разные локали, и где-то десятичный разделитель «точка» а где-то «запятая», и если данные сохранены в текстовом файле на одной машине а читаются на другой, и из-за этого сыпятся исключения… в общем весь парсинг пришлось делать вручную на весьма низком уровне, вместо того чтобы пользоваться готовыми решениями:)
int atoi(string val)
{
   int result = Int32.TryParse( val, out result ) ? result : 0;
  return result;
}

Проще вот так:


int atoi(string val)
{
   int result;
   Int32.TryParse( val, out result );
   return result;
}
Ну да, типа такого, правда там нужно было не только atoi. Самое неприятное, как я уже говорил — точка и запятая в десятичном разделителе.
Разделители вообще не проблема. Все parse- и convert-функции опционально принимают локаль, которую можно указать сферически-вакуумной (из идеального мира с десятичным разделителем «точка»)

decimal.TryParse(text, NumberStyles.Number, NumberFormatInfo.InvariantInfo, out result))

Convert.ToDecimal(text, CultureInfo.InvariantCulture);

Либо можно указать «парсить по-русски» (т.е. с разделителем запятой и датами в стиле 31.12.2018)
DateTime.TryParse(text, new CultureInfo("ru-RU"), DateTimeStyles.None, out result)


Об этом, кстати, R# подсказывает: осторожнее — без указания локали конвертация небезопасна.
Видимо, потому, что шестнадцатеричное A соответствует десятеричному 10. Таким образом 2*A в шестнадцатеричной == 2*10 в десятеричной.
Но это просто моя догадка в порядке бреда ))
Нет, исходно такая нумерация применялась потому что года выпуска стандарта не были известны заранее. Вот и выходило, что C++11 сначала называли сначала C+0x, а потом еще и С+1x, стандарт С++14 успел побыть C++1y, а стандарт C++17 когда-то назывался C++1z. Вот и C++2a из той же серии.

Однако такое написание все равно вызывает недоумение, поскольку уже принят трехлетний цикл выпуска стандартов, и год выпуска C++20 уже известен.
Год подготовки документа известен, но может боятся именно утверждения стандарта (если он затянется на несколько месяцев и выйдет внезапный С++21)
А в примере с std::optional
std::optional result = safeAdd(a, b);
точно так можно писать, т.е у std::optional появился каст к внутреннему типу? Разве не — *a, *b?
Ну и зачем писать вот так:
    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();
Первый ваш вариант еще хуже авторского: слишком много отступов, которые к тому же будут меняться при изменении числа шагов. Этому антипаттерну даже есть название — «If Ok».

Второй вариант гораздо лучше, но его может быть затруднительно отлаживать. Кстати, пустые строки почти ничего не стоят, можно за них не беспокоиться.
Он читается лучше, т.к. при чтении банально меньше нужно елозить глазами вниз по экрану. И антипаттерн он только в C++, в «чистом» C, где широко используются коды ошибок, это — обычное дело. Конечно, больше 3-х уровней отступов нормальные люди без острой необходимости не делают.

P.S. «Антипаттерн» — обычное слово-идеон. Причисление к паттернам и антипаттернам, как правило, происходит на волне очередного хайпа. В своё время что только не объявляли антипаттерном: коды возврата, null-объекты, goto, более одного return, отсутствие Yoda-style в сравнении. А оказалось, что и коды возврата, и null-объект — вполне рабочие подходы, goto широко используется в системном программировании на C для обработки ошибок, yoda-style используют не только лишь не все, а вообще мало кто.
If Ok в чистом Си — точно такой же антипаттерн. Его недостатки не зависят от языка.

Не вижу каким образом он позволяет меньше «елозить» глазами по экрану. Напротив, код автора можно читать последовательно, в то время как при чтении вашего кода приходится каждый раз смотреть в самый низ метода чтобы убедиться что у оператора if нет ветки else.

А уж во что If Ok превращается при слияниях в гите…
В C ему альтернатива ровно одна: goto. Если есть другие варианты, то их, пожалуйста, в студию.

Не самая плохая альтернатива (до тех пор, пока метка наподобие fail в функции одна).

Ну вот не нравится многим C++-сникам goto, не любят они его вплоть до включения в coding style запрета на goto.

Исходный вариаент кода — плох многословностью, copy-paste-ом, множественными return.

Вариант с несколькими уровнями вложенности — да, если их больше 2-х это напрягает, хотя, на мой вкус, всё равно читается лучше, чем то, что было изначально.

Вариант с одним if-ом — плох тем, что даёт, пусть и небольшие, лишние накладные расходы на создание неиспользуемых объектов.

Ещё варианты есть? По-моему, альтернативы нет.
З.Ы. в исходном варианте множественный return == антипаттерн
Если везде возвращать разные результаты, то, возможно — да. Если как в примере, то это принцип «Fail Fast». Т.е. нормальное выполнение у нас идет сверху-вниз, а выходы в случае ошибок, как можно быстрее. Такой подход, наоборот, предпочтительней, т.к. уменьшает количество вложенных блоков и не является антипаттерном.
А если везде возвращать один результат, то это уже нарушение DRY. Собственно, исходный вариант и есть хороший пример нарушения DRY
Нет, здесь нет никакого нарушения DRY. Вы либо не понимаете в чем заключается принцип DRY, либо слишком буквально его трактуете. То что код return-ов одинаков это всего-лишь совпадение. В процессе разработки строчки никак не связаны и могут меняться а также удаляться независимо друг от друга.

Да нет, обычно они все-таки связаны. Такие вещи как освобождение ресурсов будут неизбежно дублироваться перед каждым return.

а с чего вы взяли что «множественный return» является антипаттерном? Ведь на самом деле антипаттерном является возврат из функции в разные места (по сути, goto cond? A: B; где A и B вне функции). Множественный return же никто никогда не запрещал
Ну, погуглите one return only, будет много интересного. И, да, я не считаю это большой проблемой в исходном варианте, гораздо хуже там неоднократный copy-paste блока после if.
Из релеватного по запросу «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 лет люди еще не разучились её применять…

Кстати, даже в первом из ваших вариантов у функции две точки возврата.
Что интересно — на уровне сгенерированного кода множественный return обычно превращается в неявный goto + single return)))
А если серьезно — то за single return обычно «топят» в C, а в C++ из-за исключений требование single return'а чаще считают надуманным.
Лично мой подход (последнее время чаще пишу на C): если single return выглядит просто и понятно, то лучше использовать его, но если приходится извращаться, то в топку)))
Что интересно — на уровне сгенерированного кода множественный return обычно превращается в неявный goto + single return)))

логично, с учетом эвристики компиляторов «early return — холодная ветка»
Но Вы согласны, что ранний множественный return — это синтаксический сахар для множественного goto при single return? ))))
и я с превеликой радостью им пользуюсь
Все возможные способы обработки ошибок, кроме Result, это как «все возможные способы выпить кофе, кроме как через ротовое отверстие». Да, есть методы, но они все… тревожащие.
std::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)
}


А тут действительно все имена функций должны быть одинаковые?
Конечно должен. Сложно написать сразу без багов код, который нельзя скомпилировать.
К сожалению, не раскрыта тема (анти?)паттерна null-object, а по крайней мере в одном популярном фреймворке он просто-таки красной нитью…
накладные расходы при обработке исключений довольно большие, нельзя часто выбрасывать исключения.

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

В сравнении с if (...), когда исключения/ошибки не возникает, то код с исключениями быстрее. В случае возникновения исключения/ошибки, код с исключениями медленнее. Пруф. Да, получается, что исключения не нужно выбрасывать часто. По этой причине, не рекомендуют строить логику на исключениях, но в качестве обработчика ошибочных ситуаций исключения хороши.

Единственная проблема здесь состоит в определении слова «часто»: часто — это сколько? Я еще не слышал о реальных кейсах, когда исключения были бы узким местом быстродействия программы. А пока таких кейсов не наблюдается, учитывая другие плюсы исключений (отсутствие «лапши» в коде, требование языка отлавливать исключения), на мой взгляд использование исключений должно быть рекомендованным способом обработки ошибок.

лучше не выбрасывать исключения из конструкторов/деструкторов и соблюдать RAII.

Каким образом альтернативные способы описанные в статье решают эти проблемы?
Скорее всего часто, это когда расходы от исключений потенциально превышают расходы от if. Я бы замерил насколько исключение медленнее и на основе этого составил Иделаьную Пропорцию (тм). Но влом.
Мне этот вариант нравится. Мы что-то похожее используем. Думаю для понятности можно некоторые auto превратить в конкретные типы:
std::expected<std::string, std::runtime_error> readInt();
std::expected<int, std::runtime_error> parseInt(std::string);

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)
}
Sign up to leave a comment.

Articles