Что будет с обработкой ошибок в С++2a

    image


    Пару недель назад прошла главная конференция в С++ мире — 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;
    }

    Выглядит не очень?


    1. Нельзя вернуть настоящее значение функции.
    2. Очень просто забыть обработать ошибку (когда вы последний раз вы проверяли код возврата у printf?).
    3. Приходится писать код обработки ошибок рядом с каждой функцией. Такой код сложнее читать.
      С помощью С++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 скорее всего появятся все возможные способы.


    Что почитать и посмотреть по теме


    1. Акттуальный proposal.
    2. Выступление про std::expected c CPPCON.
    3. Андрей Александреску про std::expected на C++ Russia.
    4. Более-менее свежее обсуждение proposal на Reddit.
    Поделиться публикацией
    Комментарии 76
      +3

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

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

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

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

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

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

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

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

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

                  –6

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

                    +5
                    А писать потом уничижетельные комментарии про C++ я буду обязан? :)
                      0

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

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

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



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

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

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

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

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

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

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

                                      +2
                                      Почему не панацея? Особенно если язык проверяет тотальность функций (а это немножко не то же самое, что case coverage analysis).
                                  –1

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

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

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

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


                                          int atoi(string val)
                                          {
                                             int result;
                                             Int32.TryParse( val, out result );
                                             return result;
                                          }
                                            0

                                            Ну или даже так:


                                            int atoi(string val)
                                            {
                                               Int32.TryParse(val, out int result);
                                               return result;
                                            }
                                              0
                                              Ну да, типа такого, правда там нужно было не только atoi. Самое неприятное, как я уже говорил — точка и запятая в десятичном разделителе.
                                                +1
                                                Разделители вообще не проблема. Все 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# подсказывает: осторожнее — без указания локали конвертация небезопасна.
                                    +1
                                    Почему называется «2a», а не «20»?
                                      0
                                      Видимо, потому, что шестнадцатеричное A соответствует десятеричному 10. Таким образом 2*A в шестнадцатеричной == 2*10 в десятеричной.
                                      Но это просто моя догадка в порядке бреда ))
                                        0
                                        Нет, исходно такая нумерация применялась потому что года выпуска стандарта не были известны заранее. Вот и выходило, что C++11 сначала называли сначала C+0x, а потом еще и С+1x, стандарт С++14 успел побыть C++1y, а стандарт C++17 когда-то назывался C++1z. Вот и C++2a из той же серии.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

                                                    Каким образом альтернативные способы описанные в статье решают эти проблемы?
                                                      0
                                                      Скорее всего часто, это когда расходы от исключений потенциально превышают расходы от if. Я бы замерил насколько исключение медленнее и на основе этого составил Иделаьную Пропорцию (тм). Но влом.
                                                      +1
                                                      Мне этот вариант нравится. Мы что-то похожее используем. Думаю для понятности можно некоторые 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)
                                                      }

                                                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                                      Самое читаемое