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.

Комментарии (74)


  1. inv2004
    19.10.2018 04:27

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


    1. Kabdim
      19.10.2018 17:06

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


    1. slonopotamus
      19.10.2018 22:15

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


      1. inv2004
        20.10.2018 02:28

        упоминание try есть в конце статьи, а "?" не сразу появился, а после try.


      1. Antervis
        20.10.2018 13:20

        фактически, try!/? это «явные исключения». Основное отличие в том, что исключения не превносят накладных расходов в позитивный сценарий исполнения.


  1. totally_nameless
    19.10.2018 06:10

    Я ничего не понимаю в Rust, но в каждой статье про C++ находится кто-то, кто обязательно напишет, какой C++ ущербный по сравнению c Rust. Это симптом?


    1. 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++:


    1. 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.


      1. inv2004
        19.10.2018 07:42

        Именно из-за этого и написал комментарий выше:
        1) ошибки преобразовали в алгебраический тип.
        2) для которого определены функции для chain-обработки.
        3) try, который try!

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


        1. Gorthauer87
          19.10.2018 10:58

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


    1. slonopotamus
      19.10.2018 22:23
      -4

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


      1. totally_nameless
        20.10.2018 00:11
        +2

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


        1. slonopotamus
          20.10.2018 00:49

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


  1. nikitaevg
    19.10.2018 07:41

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

    Касательно темы, сам пишу на плюсах, но в какое-то время сильно увлекся хаскелем. И монада maybe это одна из тех вещей, отсутствие которых сильно ощущается в плюсах после знакомства с хаскелем. Будет очень круто если завезут что-то такое.


    1. qw1
      19.10.2018 15:28

      Чем плохо кидать из конструктора?
      Не вызывается деструктор. При этом, если в классе есть переменные со своими конструкторами, эти конструкторы будут вызваны, а деструкторы — нет (утечка ресурсов)


      1. Videoman
        19.10.2018 15:41

        Вы не правы. Для всех полностью сконструированных объектов будут вызваны деструкторы. Если писать на нормальном C++ с RAII, то никаких утечек не будет. Плюсы же у такого подхода огромные. Например, не нужно будет в каждом методе проверял корректное ли сейчас состояние у объекта, т.к. в случае исключение объект не будет создан.


        1. qw1
          19.10.2018 15:45

          Я специально проверил. В С++ из Visual Studio 2017 деструктор не вызывается, но в GCC — вызывается. Это что, UB?


          1. mapron
            19.10.2018 15:46

            Очень странно, нет, это стандарт, напишите им в багтрекер, наверное =)
            Ну может оптимизатор, вы проверили с О0?


            1. qw1
              19.10.2018 15:53

              В-общем разобрался.
              Если вызывать компилятор с ключами по умолчанию, получается такое поведение.
              Если добавить ключ /EHsc (enable C++ EH), деструктор вызывается.
              Странно, что по умолчанию оно выключено.


          1. Videoman
            19.10.2018 15:50

            У вас где-то ошибка, либо в коде, либо в тесте.


        1. mapron
          19.10.2018 15:49

          Кстати, есть еще малоизвестный нюанс с этими исключениями:
          В случае если был вызван делегирующий конструктор, то объект считается полностью созданным и при исключении уже деструктор будет отрабатывать.
          В каких-то случаях это необходимо учитывать =)


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



        Все вызывается, вы о чем?


        1. qw1
          19.10.2018 15:55

          Скрытый текст


          1. Cfyz
            21.10.2018 00:36

            Ну как видим, оно хотя бы выдало предупреждение. Почему по умолчанию это всего лишь предупреждение — непонятно, да.


    1. shaggyboo Автор
      20.10.2018 02:07

      Наверно я погорячился с «плохо».
      Могу с натяжкой придумать 2 тонкости.

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

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


      1. Ryppka
        20.10.2018 17:09

        Если верить Б. Страуструпу, то исключения были введены в язык из-за невозможности другим способом сообщить об ошибке конструктора. Логично — экземпляр класса должен быть либо создан с соблюдением инвариантов, либо нет смысла продолжать дальше. Прямо укладывается в одну из главных идей Страуструпа, что экземпляры пользовательских типов должны вести себя «как int».
        Дальше обратили внимание, что если выбрасывать исключения из любой функции, то основной успешный сценарий в коде выглядит намного понятнее и выполняется быстрее. Стало возможным выбрасывать исключения из любого места кода, сделали удобный механизм try-catch и понеслось…
        Я лично сторонник ограничений на то, как и когда можно выбрасывать исключения и подход с паниками из Go и Rust мне нравится больше: если считаешь нужным, то выбрасывай, это легко. Но ловить громоздко и труднее, чем через try-catch, так что делают это только если другого пути нет (ну в идеале так).
        В целом, диалектика локальной/нелокальной обработки ошибок — одна из сложнейших проблем в программировании вообще, а не только в C++. Последний, правда, добавил проблему безопасности исключений, тоже удовольствие.


        1. eao197
          21.10.2018 09:56

          Если верить Б. Страуструпу, то исключения были введены в язык из-за невозможности другим способом сообщить об ошибке конструктора.
          Кроме конструкторов такая же ситуация и с перегрузкой операторов. Там так же исключения — это практически единственный нормальный способ сообщить об ошибке.


          1. Ryppka
            21.10.2018 10:25

            Спасибо, я как-то не учитывал этот случай. Но мой главный пафос в том, что асинхронные (по сути, хотя настоящие асинхронные ошибки, это, конечно, только сигналы и SEH) и не-локально обрабатываемые ошибки во всех других случаях надо использовать намного реже, чем это делается по факту. Чисто инженерный вопрос: взвесить плюсы и минусы и решить, какую стратегию обработки ошибок использовать здесь и сейчас. То, что в C++ надо думать чуть ли не над каждой строчкой — это и преимущество, и проклятье языка, не согласны?


            1. eao197
              21.10.2018 10:32

              А я свой комментарий писал не в пику вашему, а как дополнение.

              То, что в C++ надо думать чуть ли не над каждой строчкой — это и преимущество, и проклятье языка, не согласны?
              Согласен. Но имея опыт работы с разными языками, исхожу из того, что как только нам приходится делать что-то нетривиальное и/или новое, то думать приходится вне зависимости от языка. Некоторые языки, которые изначально более безопасные (скажем, выполняют ряд проверок в run-time, как в Pascal/Modula-2/Ada/Rust/..., используют GC, как Java/C#/Eiffel/OCaml/...), несколько повышают коэффициент спокойного сна у разработчика. Но думать при это все равно нужно много.


  1. TargetSan
    19.10.2018 08:00

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


  1. NeoCode
    19.10.2018 09:23

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


    1. doctorw
      19.10.2018 10:42

      А поддерживать актуальность таблиц кодов возврата и обработку всех соответствующих кодов легко?


      1. MUTbKA98
        19.10.2018 15:02

        В языке, упоминание которого вслух тут вызывает бурные эмоции, очень легко все организовать так, что это будет гарантировать сам компилятор.


        1. shaggyboo Автор
          19.10.2018 15:19
          +1

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


          1. 0xd34df00d
            19.10.2018 16:20

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


    1. 3dcryx
      19.10.2018 11:15
      -2

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


      1. 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

        Думаю, минус из-за неудачного примера: в случае ошибки компиляции программа в принципе не запустится.


    1. Videoman
      19.10.2018 12:00

      Исключения на то и исключения что они не происходят во время нормального выполнения программы. Считаю, что пример парсинга строки и дизайн этой функции с выкидыванием исключения — дурацкий. Невозможность распарсить строку для parseInt(), вполне штатная ситуация, на мой взгляд.


      1. NeoCode
        19.10.2018 19:45

        Когда мне после С++ пришлось писать что-то на C#, я плевался от того что какая-то функция типа parseInt не парсила пустые строки как нули (в Си atoi вернет именно ноль для пустой строки). Но это еще что… а вот то что на разных системах разные локали, и где-то десятичный разделитель «точка» а где-то «запятая», и если данные сохранены в текстовом файле на одной машине а читаются на другой, и из-за этого сыпятся исключения… в общем весь парсинг пришлось делать вручную на весьма низком уровне, вместо того чтобы пользоваться готовыми решениями:)


        1. BalinTomsk
          19.10.2018 23:16

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


          1. mayorovp
            20.10.2018 19:18

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


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


            1. KvanTTT
              21.10.2018 01:34

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


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


  1. DelphiCowboy
    19.10.2018 11:05
    +1

    Почему называется «2a», а не «20»?


    1. fireSparrow
      19.10.2018 11:16

      Видимо, потому, что шестнадцатеричное A соответствует десятеричному 10. Таким образом 2*A в шестнадцатеричной == 2*10 в десятеричной.
      Но это просто моя догадка в порядке бреда ))


      1. mayorovp
        19.10.2018 11:19

        Нет, исходно такая нумерация применялась потому что года выпуска стандарта не были известны заранее. Вот и выходило, что C++11 сначала называли сначала C+0x, а потом еще и С+1x, стандарт С++14 успел побыть C++1y, а стандарт C++17 когда-то назывался C++1z. Вот и C++2a из той же серии.

        Однако такое написание все равно вызывает недоумение, поскольку уже принят трехлетний цикл выпуска стандартов, и год выпуска C++20 уже известен.


        1. mapron
          19.10.2018 15:50

          Год подготовки документа известен, но может боятся именно утверждения стандарта (если он затянется на несколько месяцев и выйдет внезапный С++21)


  1. Videoman
    19.10.2018 11:49

    А в примере с std::optional

    std::optional result = safeAdd(a, b);
    точно так можно писать, т.е у std::optional появился каст к внутреннему типу? Разве не — *a, *b?


    1. shaggyboo Автор
      19.10.2018 15:09

      Не досмотрел, поправил.


  1. 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();
    


    1. mayorovp
      19.10.2018 13:31

      Первый ваш вариант еще хуже авторского: слишком много отступов, которые к тому же будут меняться при изменении числа шагов. Этому антипаттерну даже есть название — «If Ok».

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


      1. slava_phirsov
        19.10.2018 14:14

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

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


        1. mayorovp
          19.10.2018 14:26
          +1

          If Ok в чистом Си — точно такой же антипаттерн. Его недостатки не зависят от языка.

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

          А уж во что If Ok превращается при слияниях в гите…


          1. slava_phirsov
            19.10.2018 14:35

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


            1. mayorovp
              19.10.2018 15:02

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


              1. slava_phirsov
                19.10.2018 19:34

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

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

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

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

                Ещё варианты есть? По-моему, альтернативы нет.


          1. slava_phirsov
            19.10.2018 14:41

            З.Ы. в исходном варианте множественный return == антипаттерн


            1. Videoman
              19.10.2018 15:37

              Если везде возвращать разные результаты, то, возможно — да. Если как в примере, то это принцип «Fail Fast». Т.е. нормальное выполнение у нас идет сверху-вниз, а выходы в случае ошибок, как можно быстрее. Такой подход, наоборот, предпочтительней, т.к. уменьшает количество вложенных блоков и не является антипаттерном.


              1. slava_phirsov
                19.10.2018 19:21

                А если везде возвращать один результат, то это уже нарушение DRY. Собственно, исходный вариант и есть хороший пример нарушения DRY


                1. Videoman
                  19.10.2018 22:10

                  Нет, здесь нет никакого нарушения DRY. Вы либо не понимаете в чем заключается принцип DRY, либо слишком буквально его трактуете. То что код return-ов одинаков это всего-лишь совпадение. В процессе разработки строчки никак не связаны и могут меняться а также удаляться независимо друг от друга.


                  1. mayorovp
                    20.10.2018 19:19
                    -1

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


            1. Antervis
              19.10.2018 17:31
              +1

              а с чего вы взяли что «множественный return» является антипаттерном? Ведь на самом деле антипаттерном является возврат из функции в разные места (по сути, goto cond? A: B; где A и B вне функции). Множественный return же никто никогда не запрещал


              1. slava_phirsov
                19.10.2018 19:27
                -1

                Ну, погуглите one return only, будет много интересного. И, да, я не считаю это большой проблемой в исходном варианте, гораздо хуже там неоднократный copy-paste блока после if.


                1. 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 лет люди еще не разучились её применять…

                  Кстати, даже в первом из ваших вариантов у функции две точки возврата.


                  1. Ryppka
                    20.10.2018 17:18

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


                    1. Antervis
                      20.10.2018 23:37

                      Что интересно — на уровне сгенерированного кода множественный return обычно превращается в неявный goto + single return)))

                      логично, с учетом эвристики компиляторов «early return — холодная ветка»


                      1. Ryppka
                        21.10.2018 10:28

                        Но Вы согласны, что ранний множественный return — это синтаксический сахар для множественного goto при single return? ))))


                        1. Antervis
                          21.10.2018 14:41

                          и я с превеликой радостью им пользуюсь


  1. amarao
    19.10.2018 14:16
    -2

    Все возможные способы обработки ошибок, кроме Result, это как «все возможные способы выпить кофе, кроме как через ротовое отверстие». Да, есть методы, но они все… тревожащие.


  1. Izulle
    19.10.2018 14:28

    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)
    }


    А тут действительно все имена функций должны быть одинаковые?


    1. shaggyboo Автор
      19.10.2018 15:12

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


  1. slava_phirsov
    19.10.2018 15:34

    К сожалению, не раскрыта тема (анти?)паттерна null-object, а по крайней мере в одном популярном фреймворке он просто-таки красной нитью…


  1. gitKroz
    19.10.2018 17:07

    накладные расходы при обработке исключений довольно большие, нельзя часто выбрасывать исключения.

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

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

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

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

    Каким образом альтернативные способы описанные в статье решают эти проблемы?


    1. ledocool
      19.10.2018 22:49

      Скорее всего часто, это когда расходы от исключений потенциально превышают расходы от if. Я бы замерил насколько исключение медленнее и на основе этого составил Иделаьную Пропорцию (тм). Но влом.


  1. 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)
    }