Начнем издалека

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

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

Чтобы программа не падала, а выдавала осмысленное сообщение и давала возможность продолжить работу, надо было добавить проверку.

Например так (примеры на C):

if (divisor != 0) {
  x = dividend / divisor; 
} else {
  // Сделать что-то осмысленное
}

Или так:

m = malloc(100); // Выдели мне 100 байт памяти
if (m)  {// Если m не равно нулю, память выделилась успешно
  *m = 0; // Обращаемся к памяти по указателю m
} else {
  // выдаем осмысленное сообщение об ошибке, чтобы легче было отлаживать
}

Таким же образом обрабатывали ошибки других функций операционной системы:

int fd = fopen(path, "r+");
if (fd) { // Если fd не равен 0, то файл открылся
  // Читаем и пишем файл
} else {
  // выдаем осмысленное сообщение об ошибке или повторяем
}

Такие проверки писать очень неудобно. На каждый вызов функции ОС надо было написать несколько строк кода. Причем для разных функций проверки разные. Одни функции возвращают 0 в случае ошибки. Другие наоборот возвращают 0 при успешном завершении. Третьи для ошибки могут использовать код -1. Конечно есть библиотеки функций и макросов, которые сворачивают обработку ошибок до одной строки и в случае ошибки завершают программу с указанием где и почему упало.

Программисты часто ленились и просто игнорировали коды ошибок. Код начинал работать непредсказуемо.

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

Языки высокого уровня

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

Можно написать так (пример на C++):

matrix x(3,3), y(4,4);
auto z = x + y;

И сразу возникает вопрос, а что же должно произойти в таком коде? Сложить матрицы разных размеров нельзя. Но мы при этом должны вернуть матрицу. Это исключительная ситуация. Сделать, конечно, в этой ситуации ничего нельзя, надо завершить программу с осмысленным сообщением об ошибке.

Другой пример:

int sum;
double rate;
ifstream file(path); 
if(file.is_open()) {    // Оставили проверку кода завершения
  file >> sum >>  rate; // Чтение двух чисел из текстового файла
}

Если в файле не окажется двух чисел, то какие значения должны получить sum и rate? Снова исключительная ситуация. Можно предложить программистам после операций чтения проверять код последней ошибки в объекте file, но этого точно никто делать не будет. В данном случае завершать программу с ошибкой нельзя, возможно надо дать пользователю выбрать другой path.

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

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

Как исключения выбрасываются и ловятся

В большинстве языков обработчики исключений добавляются в код с помощью ключевых слов try\catch\finally, а в некоторых языка, например C++, есть автоматически обработчики при выходе из области для вызова деструкторов объектов (RAII). В catch обычно указывается тип исключения, которое надо перехватывать. Компилятор формирует таблицы обработчиков, записывая в исполняемый файл таблицы с адресами начала и конца блока try и ссылки на блоки catch и fnally.

Исключения выбрасываются с помощью ключевого слова throw. В этот момент вызывается функция рантайма языка, которая по таблицам обработчиков проверяет есть ли обработчик исключения в вашем коде. Если обработчик не найден или он не смог обработать исключение, то идет раскрутка стека. Рантайм переходит к вызывающей функции и ищет обработчик для нее и так по кругу.

Если обработчик не был найден после раскрутки всего стека, то программа завершается. Обработчики finally не останавливают процесс раскрутки стека, а только выполняют код (чаще всего освобождают ресурсы). Если найден обработчик catch, который сможет обработать исключение (для этого используется проверка типа) и вернуться к нормальному выполнению, то программа продолжится после блока этого обработчика.

Проблемы обработки исключений

Первая проблема в том, что поиск по таблицам, раскрутка стека, проверка типа исключения обработчика - дорогие операции. Чем более сложный код - тем дороже. Чем дальше catch и finally от throw - тем дороже.

Хорошо что обработка исключений практически не влияет на код, который исключения не выбрасывает. Но если исключение появляется, то "проигравший платит за всё". Поэтому чем меньше мы бросаем исключений, тем лучше.

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

Пример, был такой код (в этот раз на C#):

MyClass RetryOperation(string url) {
  while (true) {
    try {
      return Operation(url);    
    } catch (WebException) {
      Thread.Sleep(5000);
    }
  }  
}

MyClass Operation(string url) {
  var json = httpClient.Get(url);
  return JsonSerializer.Deserialize(json);
}

Этот код прекрасно работал, но со временем функция Operation разрослась дополнительными проверками и преобразованиями и уже с трудом умещалась на экране.

В один момент не очень грамотный программист добавил туда логирование ошибок:

MyClass Operation(string url) {
  MyClass obj;
  try {
    //... много кода...
    var json = httpClient.Get(url);
    //... много кода...  
    obj =  JsonSerializer.Deserialize(json);
    //... много кода...  
  } catch (Exception e) { // Перехыватывает все исключения
    Log(e.ToString());
  }
  return obj;
}

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

Конечно грамотный программист должен был перевыбросить исключение и вообще не ловить самый базовый тип исключения. И вообще не надо строить логику на исключениях. Но хотелось бы защититься по таких случаев средствами языка.

А надо ли вообще перехватывать исключения?

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

Такие исключения получили название usage exceptions - они говорят что вы неправильно используете объект или функцию и у вас ошибка в программе. К ним относятся ошибки аргументов функции, ошибки преобразований типов, ошибки арифметических операций над сложными объектами, ошибки состояния объекта. Всякими гайдлайнами их запрещено перехватывать. В готовой программе usage exceptions не должны возникать.

Другой тип исключений - когда функция не может выполнить свою работу и вернуть осмысленное значение и это никак не связно с общением программы с внешним миром. "Ключ не найден в словаре", "Ошибка парсинга строки в число" и... я что-то больше даже не придумал. Таких исключений в базовой библиотеке немного, но их любят изобретать программисты. Назовем их application exceptions. Такие исключения рекомендуется менять на try-pattern.

// было
try {
  var x = int.Parse(s); 
  // Используем x
} catch (FormatException) {
  // Не распарсилось  
}

//стало
if(int.TryParse(s, out var x)) {
  // Используем x
} else {
  // Не распарсилось
}

Третий тип исключений нам уже знаком - это IOExeption, WebException, SocketException, SqlException и прочие исключения, которые могут возникнуть из-за того, что программа общается с внешним миром. Назовем их io exceptions.

Четвертый тип - исключения рантайма, например OutOfMemory, StackOverflow, итд Их перехватывать нет смысла, зачастую это даже невозможно.

Итого: 3 из 4 типов исключений не надо перехватывать вообще. Под вопросом только io exceptions, но если мы применим для них try-pattern, то получим коды возврата как в старом добром C.

Функциональный взгляд

В мире функционального программирования не стали придумывать исключения. Там все функции, которые могут завершиться ошибкой возвращают union-тип (or-тип), то есть тип, который может принимать значение одного из нескольких, в нашем случае двух, типов. Или результат операции, или ошибка. Записывается как Result<T,Err>.

Функциональное программирование последние 15-20 лет активно проникает в мейнстрим, поэтому многие современные языки обзавелись такими возможностями, плюс появились новые языки, где такие возможности уже встроены.

Сразу же сотнями появились статьи, что исключения в C++\C#\Java\Python использовать нельзя и надо срочно взять новые типы на вооружение.

Но давайте будем честными. Это те же самые коды возврата.

Пример из документации к Rust:

    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => panic!("Problem opening the file: {:?}", error),
    };

Сколько принципиальных отличий от примера с fopen в начале статьи вы увидели?

Более того, если у вас в одной функции будут Result<T,SqlError> и Result<T,WebError> вы замучаетесь дружить их вместе.

Правда в функциональных языках можно использовать монадный синтаксис для работы с Result<T,Err>. Гипотетический код на Scala:

def authenticate(userName: String, password: String): Either[AuthenticationError, User] =
  for {
    user <- findUserByName(userName)
    _ <- checkPassword(user, password)
    _ <- checkSubscription(user)
    _ <- checkUserStatus(user)
  } yield user

Это значительно сокращает объем кода при последовательном вызове нескольких методов. И, самое главное, помогает не пропустить проверку. Но опять-таки работает только если у вам один тип ошибки.

К сожалению монадный синтаксис доступен далеко не везде: Haskell, C# (linq), F# (computational expressions), Scala... из более-менее популярных языков все. Частично можно монадный синтаксис реализовать в Python и в Kotlin. Ни в Rust, ни в Go, ни в самый последний JS, ни в C++ такой синтаксис не завезли (по крайней мене на момент написания статьи).

Как проблемы решаются в Go и Rust

Напомню что основные проблемы: как не игнорировать Result<T,Err> и как не писать врукопашную сотни if.

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

Rust

В Rust есть специальный оператор ?, который автоматически добавляет проверку к выражению типа Result<T,Err>.

fn read_username_from_file() -> Result<String, io::Error> {
  let mut username = String::new();
  File::open("hello.txt")?.read_to_string(&mut username)?; //обратите внимание на знаки вопроса
  Ok(username)
}

превращается в:

fn read_username_from_file() -> Result<String, io::Error> {
  let username_file_result = File::open("hello.txt");

  let mut username_file = match username_file_result {
      Ok(file) => file,
      Err(e) => return Err(e),
  };

  let mut username = String::new();

  match username_file.read_to_string(&mut username) {
      Ok(_) => Ok(username),
      Err(e) => Err(e),
  }
}

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

fn main() {
  let file = File::open("hello.txt").unwrap(); // Если файл не открылся программа просто упадет
  let mut username = String::new();
  file.read_to_string(&mut username).unwrap();
}

А чтобы разработчик не забыл проверить результат есть макрос #[must_use]. С ним компилятор будет ругаться если вы ничего не сделаете со значением типа Result<T, Err>.

Go

В языке Go основные проблемы не решаются никак. Совсем никак.

func check(e error) {
    if e != nil {
        panic(e)
    }
}

f, err := os.Open("/tmp/dat")
check(err) 

_, err = f.Write(p0[a:b])
check(err)
_, err = f.Write(p1[c:d])
check(err)
_, err = f.Write(p2[e:f])
check(err)

Сразу хочется написать так:

f := check(os.Open("/tmp/dat"))

И это можно сделать, но тогда надо написать столько разных функций check сколько типов возвращаемых значений, так как генериков нет. Нет, это не шутка.

UPD. Генерики в Go появились спустя 5 лет, можно ли написать универсальную функцию check - надо уточнить.

В Go рекомендую использовать библиотеки с типами обертками, которые обработку ошибка прячут внутри себя. Чтобы потом писать так:

b := bufio.NewWriter(fd)
b.Write(p0[a:b])
b.Write(p1[c:d])
b.Write(p2[e:f])
// and so on
if b.Flush() != nil {
    return b.Flush()
}

Нет готовой подходящей - пиши свою. Сделать обобщенную обертку нельзя, генериков нет. А если вы просто проигнорите err, то вам никто ничего не скажет.

Это все при том, что в Go есть обработка исключений. Работает почти также как в C++\C#\Java, только ключевые слова panic\defer\recover, чтобы никто не догадался.

Заключение

Мы рассмотрели много способов обработки ошибок начиная от языков низкого уровня, заканчивая функциональщиной и строгой системой как в Rust, а заодно посмотрели как это не надо делать, на примере Go.

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

Для высокоуровневого кода я бы пока еще предпочел классический подход C#\Java, но при этом кидать исключения как можно реже, а перехватывать еще реже. Возможно на самом высоком уровне, чтобы сказать пользователю "что-то пошло не так, повторите операцию" и записать в лог. По сути для этого даже специально писать ничего не надо. Все нужно уже встроено во фреймворки, главное не испортить.

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

Какой способ лучше - решать вам.

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


  1. DreamingKitten
    16.01.2023 10:46
    -1

    1. В Go уже есть генерики.

    2. defer не относится к обработке паник, т.к. при панике отложенные функции не вызываются.


    1. Ztare
      16.01.2023 11:12
      +1

      вобще defer это стандартный способ перехвата паник с вызовом recover (пример). Откуда информация?


      1. DreamingKitten
        16.01.2023 11:20
        +1

        Да, вы правы, это я перепутал панику с выходом :(


  1. Sklott
    16.01.2023 10:49
    +3

    ни в C++ такой синтаксис не завезли (по крайней мене на момент написания статьи).

    В C++ можно сказать уже завезли. В С++23, ну т.е. стандарт еще не вышел, но имплементации уже есть котрые работают и на C++11. Это класс std::expected и монадные функции and_then(), transform(), or_else(), transform_error().

    И кстати, последняя функция конвертирует ошибку. Так что можно возвращать что-то другое наружу, а не "пробрасывать" внутреннюю ошибку.


    1. mayorovp
      16.01.2023 11:17
      +3

      Наличие типа-монады ещё не означает наличие монадического синтаксиса (также называемого do-нотацией).


      1. Sklott
        16.01.2023 12:01
        +3

        Я извиняюсь, в функциональных языках разбираюсь не очень, но чем вот этот синтаксис:

        std::expected<User, AuthenticationError> authenticate(const std::string& userName, const std::string& password)
        {
            return findUserByName(userName)
                .and_then([&](User user) {return checkPassword(user, password);})
                .and_then(checkSubscription)
                .and_then(checkUserStatus);
        }

        Принципиально отличается от этого?

        def authenticate(userName: String, password: String): Either[AuthenticationError, User] =
          for {
            user <- findUserByName(userName)
            _ <- checkPassword(user, password)
            _ <- checkSubscription(user)
            _ <- checkUserStatus(user)
          } yield user


        1. gandjustas Автор
          16.01.2023 12:35
          +1

          Ваш пример не эквивалентен тому, что в статье. В третьем и четвертом методе используется результат первого метода. Напилите аналогичный код на C++и поймете в чем разница.


          1. Sklott
            16.01.2023 15:37

            Я честно говоря Scala не знаю от слова "совсем". И о смысле этой функции только догадывался. Но если я правильно понял, что выражение вида _ <- func() означет "игнорируй возвращаемое значение". То аналогичный C++ код все равно останется практичски таким-же, если мы добавим анологичную функциональность.

            Можете вот тут посмотреть и поиграться с разными возвращаемыми значениями: https://godbolt.org/z/9GKbbqd5K


            1. gandjustas Автор
              16.01.2023 15:58
              +4

              Ну конечно же нет.

              Вы пытаетесь прицепиться к частности.

              В общем монадный синтаксис позволяет писать так:

                for {
                  x1 <- f1()
                  x2 <- f2(x1)
                  x3 <- f3(x1, x2)
                  x4 <- f3(x1, x2, x3)
                } yield x4

              Разворачивается это все не в последовательный вызов and_then\bind\flatMap

              Чтобы ваш пример заработал вы сделали IgnoreResult, которого нет в оригинале, и который вам никак не поможет развернуть в простой линейный код произвольное выражение в монадном синтаксисе.


              1. Sklott
                16.01.2023 16:47

                Ок. Согласен. Такой синтаксис в C++ пока не поддерживается.

                Но во-первых, для gcc/clang можно написать такой макрос, что вот это будет работать аналогично (или замутить подобное-же с использованием co_await):

                std::expected<Res, Err> func() {
                  auto x1 = TRY(f1());
                  auto x2 = TRY(f2(x1));
                  auto x3 = TRY(f3(x1, x2));
                  auto x4 = TRY(f4(x1, x2, x3));
                  return x4;
                }

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


          1. NekoiNemo
            18.01.2023 17:44

            Удалено т.к. отправил коммент не в ту ветку


        1. mayorovp
          16.01.2023 12:38
          +1

          Принципиальное отличие — в простоте замыкания значений, что особенно заметно при громоздком синтаксисе лямбд. Кстати, у вас примеры не эквивалентны, если второй пример переписывать на плюсы — получится так:


          std::expected<User, AuthenticationError> authenticate(const std::string& userName, const std::string& password)
          {
              return findUserByName(userName)
                  .and_then([&](User user) { 
                      return checkPassword(user, password)
                          .and_then([] { return checkSubscription(user) })
                          .and_then([] { return checkUserStatus(user) });
                  })
          }

          Каждая переменная, требуемая дальше следующего шага, в лямбда-синтаксисе добавляет 1-2 уровня лесенки. В то же время do-нотация держит их все на одном уровне.


          1. Sklott
            16.01.2023 13:07

            Если функции checkPassword(), checkSubscription() и checkUserStatus() в случае успеха возвращают не модифицировнный аргумент, то ваш код идентичен моему. А если модифицированный, то в большинстве случаев мой код будет более коректен.

            Если же эти функции возвращают не std::expected<User, AuthenticationError>, а например std::expected<void, AuthenticationError> , то все должно выглядеть несколько по-другому, но все равно без дикой вложенности.


            1. mayorovp
              16.01.2023 15:42

              Если для одного параметра иногда ещё можно обойтись обёртками вроде вашей IgnoreResult, то когда последняя функция принимает два или три параметра — вложенность становится уже неизбежной.


          1. Tuxman
            16.01.2023 22:37
            +3

            Когда-то давно многие ругались, что ошибки с std::vector или std::map выливаются в 5 экранов ошибок что-то там про шаблоны. К счастью, почти все компиляторы сегодня дают почти всегда одну строчку ошибки, но вот этот вот модальный std::expected - это 5 экранов ошибок шаблонной магии.

            Кстати, часто ещё приходится писать не просто std::expected<User, AuthenticationError>, а что-то типа std::expected<std::unique_ptr<User>, AuthenticationError>, что делает ошибки примерно на 10 экранов шаблонной магии. Плюс надо следить за тем, чтобы возвращаемый std::expected строился в таком случае std::in_place, а не создавались временные объекты и конструкторы перемещения.


          1. sergegers
            18.01.2023 02:17
            -1

            А здесь нужны лямбды типа boost::phoenix.


            1. Tuxman
              18.01.2023 05:44
              +1

              Примерно с С++17 необходимость в Boost отпадает. По двум причинам, во-первых, всё самое вкусное _уже_ перенесли в C++11 и следующие C++17, а во-вторых, Boost уже не торт, и все эти парсеры, лексеры и прочие и форматеры на столько медленные (и убогие), что использовать их не хочется, а хочется чего-то modern c++, всех этих compile time expressions.


              1. sergegers
                18.01.2023 09:44

                >  Boost уже не торт, и все эти парсеры, лексеры и прочие и форматеры на столько медленные (и убогие),

                это, конечно, вы сейчас подтвердите тестами.

                Пока super abbreviated lambdas не приняли аналогов boost::phoenix в языке нет.


    1. Tuxman
      16.01.2023 22:31

      Также в C++23 добавили монадные функции к std::optional (который появился с C++17). К сожалению, не получается вместе использовать std::expected и std::optional столь же красиво.

      Кстати, в своих проектах я уже некоторое время использую std::expected от https://github.com/TartanLlama/expected который работает начиная с древнего g++ 4.8 и clang++ 3.5.


      1. Sklott
        17.01.2023 11:59
        +1

        К сожалению, не получается вместе использовать std::expected и std::optional столь же красиво.

        "Если нельзя, но очень хочется, то можно" (с) Достаточно легко сделать адаптеры которые будут конвертить туда-обратно между std::expected и std::optional, как обычных значений, так и для монадных функций. Вот тут https://godbolt.org/z/9GKbbqd5K я сделал нечто подобное, но с void-ом. Заменить игнорирование значения, на конвертацию из std::optional и все. В обратную сторону - аналогично.

        Кстати, в своих проектах я уже некоторое время использую std::expected от https://github.com/TartanLlama/expected который работает начиная с древнего g++ 4.8 и clang++ 3.5.

        Вот из-за этого у вас и 10 экранов шаблонной магии, т.к. не используются концепты. В стандартной библиотеке они будут использоваться и у вас опять будет одна ошибка.


  1. DmitryZlobec
    16.01.2023 10:51

    Для высокоуровневого кода я бы пока еще предпочел классический подход C#\Java, но при этом кидать исключения как можно реже, а перехватывать еще реже. Возможно на самом высоком уровне, чтобы сказать пользователю "что-то пошло не так, повторите операцию" и записать в лог. По сути для этого даже специально писать ничего не надо. Все нужно уже встроено во фреймворки, главное не испортить.

    Это все-таки ближе к Fail safe. В случае с Fail fast будет не так.


  1. Ztare
    16.01.2023 10:53

    Про err в Go вам линтер в CI и в IDE скажут что вы не по понятиям поступаете и код не скомпилируется в идеале.
    Отличие подобного подхода от кодов ошибок в стандартизации канала для ошибок, на который уже можно обращать внимание линтерами и библиотеками.
    По поводу обработки практика всегда очевидная - на каждом слое приложения ошибки осмысленно должны перепаковываться в ошибки имени текущего слоя. И часто вызывающему коду не важно что именно случилось - для этого в том же Go ошибки возвращаются как базовый тип, отсюда есть удобные функции для перепаковки в стандартной библиотеке


    1. gandjustas Автор
      16.01.2023 11:55
      +3

      По поводу обработки практика всегда очевидная - на каждом слое приложения ошибки осмысленно должны перепаковываться в ошибки имени текущего слоя. 

      Зачем?

      И часто вызывающему коду не важно что именно случилось

      Тогда вдвойне непонятно зачем перепаковывать.


      1. Vadim_Aleks
        16.01.2023 11:59
        +5

        Подозреваю, чтобы в слое транспорта писать не catch(PgRowNotFound), а catch(UserNotFound)


        1. Ztare
          16.01.2023 12:09

          именно. Ошибки нижнего слоя логически не должны просачиваться наверх - это нарушение инкапсуляции и создает необоснованную привязку к реализации нижестоящих слоев. В go эта семантика дешевле в обработке и органичнее


        1. gandjustas Автор
          16.01.2023 12:19

          В статье я написал, что такие вещи рекомендуется делать через try-pattern, а не исключения.

          if (Finduser(var out user)) {
            // Юзер найден
          } else {
            // Юзер не найден
          }


          1. Ztare
            16.01.2023 13:34

            это неудобный способ записи того же самого, но ошибку вы даже не текстом вернули а boolean. Теперь примерьте ваш подход с отказом от out с заменой на несколько значений возвращаемых из функции + стандарт на тип ошибки (не bool)
            и получите подход из go


            1. gandjustas Автор
              16.01.2023 13:45

              это неудобный способ записи того же самого, но ошибку вы даже не текстом вернули а boolean.

              Давайте посмотрим полный код контроллера:

              IActionResult FundUser(string name) {
                if (_users.TryFindUserByName(name, var out user)) {
                  retrn Ok(user);
                } else {
                  retrn NotFound();
                }
              }

              Вы считаете, что этот код хуже, чем:

              IActionResult FundUser(string name) {
                try{
                  var user = _users.FindUserByName(name);
                  retrn Ok(user);
                } catch (UserNotFound e) {
                  retrn NotFound(e.Message);
                }
              }

              ?

              Вы сильно заблуждаетесь считая, что логика, основанная на перехвате исключений чем-то лучше try-pattern. Но то, что она заметно медленнее работает - это факт.

              Весь путь от .NET 2.0 до .NET Core делали так, чтобы не приходилось писать второй вариант кода, а можно было писать первый.


              1. Ztare
                16.01.2023 13:59
                +2

                я обсуждаю не exception как примитив конкретного языка, а то что при обработке исключительных ситуаций лучше иметь нормальную информацию о них соответствующую слою приложения. То что try catch дорогой не значит что нужно сразу уходить от него в примитивизм bool в качестве признака ошибки. try-pattern будет часто заражать весь стек вверх тем что если одна функция "не смогла", то и все вышестоящие тоже не смогут. Отсюда у вас все интерфейсы методов в приложении станут bool TrySomething(params, out returnType), это логично проистечет из повсеместного try-pattern. Go пришел именно к тому что вы предлагаете, и в теории в C# нет проблемы повторить этот подход. Ваши примеры очень простые, и вы не видите как этот подход будет выглядеть если на десятом уровне вызовов произойдет ошибка записи в БД или какая-нибудь исключительная ситуация в данных? Это значит 10 методов будут иметь try-pattern сигнатуру, в этом случае. А так как большинство приложений построены вокруг обращений к бд или внешним ресурсам (чистая математика редкая) - то у вас вообще других сигнатур не останется в приложении.


                1. amishaa
                  16.01.2023 14:39

                  Ещё одна проблема try-паттерна в том, что есть возможность обратиться к out даже если функция завершилась неуспехом.


                  1. gandjustas Автор
                    16.01.2023 15:22
                    -1

                    Вы удивитесь, но try-pattern начал внедряться примерно с появлением C#8, в котором появились nullable reference types. То есть проверки времени компиляции, что переменная, к которой обращается программист не null. Эти проверки прекрасно дружат с try-pattern за счет атрибута NotNullWhen для out параметра.


                1. gandjustas Автор
                  16.01.2023 15:18
                  +1

                  Вы ошибочно считаете, что надо все исключения заменить на try-pattern, но это не так.

                  В примере выше TryFindUserByName упадет с исключением если база данных недоступна или возникла какая-то проблема при материализации записи.

                  Заменять на try-pattern имеет смысл только те исключения, которые:

                  1. Будут перехватываться часто, а значит можно сделать что-то полезное.

                  2. Будут перехватываться близко к месту возникновения - в той же области или 1-2 уровня вызовов выше.


                  1. Ztare
                    16.01.2023 15:23
                    +1

                    Вот в этих условиях и приходим к стандартному по умолчанию try catch и редкому по месту try-pattern. Так к чему тогда обсуждение?


                    1. gandjustas Автор
                      16.01.2023 15:33

                      "Стандартный по умолчанию try catch" не нужен, в этом смысл.

                      Последние 10 лет, когда я приходил в существующий проект на C# я спорил что смогу снизить количество падений и непонятных ошибок в разы за неделю.

                      Поиском находил все try\catch, где catch ловил общий тип исключения или вообще без фильтра, не делал throw, дописывал везде throw или переписывал на частный тип исключения. После чего заставлял исправить вылетавшие эксепшены, не трогая try\catch.

                      Всегда работало.


                      1. Ztare
                        16.01.2023 15:46
                        -1

                        Как вы решали заразность try-pattern при замене исключений?


                      1. gandjustas Автор
                        16.01.2023 16:04
                        -1

                        Он не заразен.

                        Вы всегда заменяете код вида:

                        try {
                          var x = func();
                          // do something with x 
                        } catch (ConcreteExeption e){
                          //do something without x
                        }

                        в код вида:

                        if (tryFunc(out var x))
                          // do something with x 
                        } else {
                          //do something without x
                        }

                        Если у вас исключение не перехватывается или перехватывается только на самом высоком уровне для сообщения пользователю "что-то пошло не так", то его не надо превращать в try-pattern.


                      1. Ztare
                        16.01.2023 16:32

                        Мы вернулись к тому с чего начали - этот микропример не решает вопроса заразности.
                        //do something without x
                        99.9% случаев вы ничего не можете сделать кроме как прервать работу функции вернув ошибку выше и там также получив из этого метода ошибку вы тоже ничего сделать не сможете кроме как подняться выше...
                        Вот у меня вопрос про заразность - и возникает


                      1. gandjustas Автор
                        16.01.2023 18:33

                        Мне кажется я уже третий раз вам пытаюсь объяснить одно и то же. Надеюсь это будет последний.

                        Если у вас ошибка, с которой вы ничего не можете сделать - надо полагаться на обычные исключения в C#. Для этого не надо ничего писать. Ни try, ни catch, ни throw. Никакой try-pattern в этом случае не нужен.

                        В 0.1% случаев вы все-таки можете сделать что-то осмысленное в ответ на возникающую ошибку. Например вернуть HTTP-код 400 при отсутствии строки в резалтсете. Тогда не нужно использовать исключения, нужно писать try-pattern. Он не будет заразен, так как вы эту ошибку в любом случае обрабатываете. За пределы одного или пары методов проброс флага успеха и out-параметра не уйдет.

                        Мы вернулись к тому с чего начали - этот микропример не решает вопроса заразности.

                        Тогда я вам предлагаю найти более подходящий пример, а до этого не стоит продолжать ветку.


                      1. Ztare
                        16.01.2023 20:07
                        +1

                        Такое ощущение что у вас в проектах в стеке вызовов глубины больше 3х не бывает. Вот как мне кинуть 400 (или вообще что-то осмысленное) - нет строки в резалтсете когда 10 сервис по глубине обработки запроса не нашел нужную запись? Ну нет ее,
                        a) все 10 сервисов должны в вызове try-pattern иметь? - очевидно нет,
                        б) мне null вернуть чтобы оно упало непонятно где? - очевидно нет,
                        в) мне подогнать запрос так чтобы упало с sqlexception без записей? - очевидно нет,
                        г) throw вы мне делать запретили. Для меня это и есть 99% ситуаций когда я без зазрения совести кину throw, хоть он и бизнесовый наверно по сути. Более того я кину особое исключение которое мидлварь перепакует в правильный http ответ, с адекватным текстом
                        д) В теории есть вариант прокинуть именно это странное состояние в моделях всех вызовов сервисов выше, по всем правилам перепаковывая перехватывая по пути. Вы так сделаете ради реально редкого кейса?
                        е) затеять лютый рефакторинг и редизайн и избавится от самой возможности подобной ситуации не важно какой ценой?

                        Вы как поступаете в такой ситуации?


                      1. amishaa
                        16.01.2023 23:34

                        Так (а) от (г) отличается только явностью контракта.

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


                      1. Ztare
                        17.01.2023 00:06

                        Ну вот мне и интересно что по мнению автора нужно делать? )


                      1. Tuxman
                        17.01.2023 00:12

                        Бежать срочно смотреть в сторону прогрессивных новых языков. 2022ой принёс нам их 3 штуки, выбирай

                        Но, боюсь разочаровать здесь многих присутствующих, но "Это те же самые коды возврата" ;-)


                      1. amishaa
                        17.01.2023 00:31
                        +1

                        Я пытаюсь сказать, что если синтаксис try-pattern не слишком бойлерплейтный (как это сделано в расте), то вариант (а) не имеет очевидных недостатков по сравнению с (г). И выбирать, в случае раста, стоит именно его (а не panic/unwind, который и не рекомендуется для управления потоком выполнения).


                      1. Tuxman
                        17.01.2023 03:46

                        Проблема с "try-pattern не слишком бойлерплейтный", как и вообще с эксепшенами в том, что у нас в коде появляется ещё один не совсем очевидный путь выполнения программы.


                      1. amishaa
                        17.01.2023 13:05
                        +3

                        Зависит от того, как именно сэкономить. Растовский подход (как и Гошный) позволяет прямо по сигнатуре функции понять, что она может вернуть ошибку. Что позволяет в вызывающем коде понять, есть только один путь исполнения этой функции или два.


                      1. Ztare
                        17.01.2023 10:19

                        Да, если данные о сбое не теряет и перепаковывает по пути. В go так и сделано) В шарпе нам запрещают (г) в замен предлагают ничего - делайте (д) и (е)


              1. Vadim_Aleks
                16.01.2023 14:05

                А как при 1-ом подходе возвращать несколько вариантов результата функции? Например, хочется различать UserNotFound от UserNotActive или UserEmailNotVerified - плохой пример


                1. gandjustas Автор
                  16.01.2023 15:22

                  А я только начал писать длинный ответ, объясняя почему исключения не сделают код лучше.


            1. gandjustas Автор
              16.01.2023 13:46

              Теперь примерьте ваш подход с отказом от out на несколько значений возвращаемых из функции + стандарт на тип ошибки (не bool)и получите подход из go

              В C# этого не надо делать. Это только ухудшит код.


              1. Ztare
                16.01.2023 14:08

                Описал выше - ваш вариант хуже гошного.


                1. gandjustas Автор
                  16.01.2023 15:27
                  -2

                  Истинную верну невозможно победить


                  1. Ztare
                    16.01.2023 15:32
                    +1

                    Я не сказал что гошный хорош) Но это еще хуже. Гошный хотябы целостный и решает проблемы, не привнося сильно много новых


                    1. gandjustas Автор
                      16.01.2023 16:00
                      +3

                      Гошный не решает никаких проблем. Потому что он все ошибки превращает в try-pattern, даже те, которые мы вообще никогда не будем обрабатывать. Именно из-за этого в Go все вызывающие функции тоже превратятся в try-pattern и везде придется городить check.


                      1. Ztare
                        16.01.2023 16:55

                        Есть проблемы
                        1) разрабы игнорят ошибки - go явно требует результат присвоить, линтер даст по рукам
                        2) разрабы не перепаковывают ошибки по слоям - go дает простой способ
                        3) try-catch дорогой по ресурсам - go подход дешевый
                        4) try-catch передает управление вверх по стеку фиг знает куда - go требует всегда обрабатывать по месту
                        5) try-pattern ломает сигнатуру метода - в go это стандарт работы и выглядит аккуратно
                        6) try-pattern теряет причину ошибки - в go все на месте
                        x) проблемы из errorcode не вписываю
                        Новые проблемы
                        1) гребаные if err=!nil return повсеместно - решаются ide (сворачивают автоматом в конец выражения), может добавят какой сахарок в будущем
                        2) принято приводить возврат к базовому типу error и приходится на уровень выше иногда кастить типы - терпим, большинство библиотек в виде исходников, виды исключений доступны

                        check городить не нужно, нужно прям на месте что-то сделать или упаковать и кинуть выше. Сложный check нужен раз в тысячу лет. Так что проблем что все методы стали "try-pattern" нет - неявно многие методы в шарпе могут ошибку кинуть, а тут все явно и понятно

                        Но go мне все еще не нравится - выше просто факты


                      1. gandjustas Автор
                        16.01.2023 18:45

                        У вас очень странный перечень проблем. По сути try\catch и try-pattern это вещи противоположные. У них свои преимущества и недостатки. В одних случаях удобно одно, в других другое.

                        C# как язык и гайдлайны от Мелкософта предлагают использовать в разных случаях то, что удобнее.

                        Go предлагает только подход try-pattern, без возможности автоматизировать проверки и не игнорировать коды возврата. Линтеры и иде это хорошо, но так не работает. Большую часть кода на Go я читаю на сайте и SO. Там нет ни линтеров, ни IDE.


                      1. Ztare
                        16.01.2023 19:38

                        Это достаточно близко к описанию причин такого подхода от авторов языка. Этот подход рожден на основе богатого опыта крупной компании, да он серьезно ломает устои - принять его тяжело, но я с ним работал в крупном проекте и прочувствовал последствия, удобства и недостатки. Проблемы он реально решает причем не по месту, а глобально и привентивно.
                        Подход майкрософта однобокий - пользуйтесь тем и тем, но вот Exception это очень дорого и лучше ненадо, для бизнес исключений по сути нельзя. Удобная позиция не дать инструмента для полноценной замены но при этом сказать что этим вот не пользуйтесь - оно медленное.
                        Т.е. я должен под неоптимальность в рантайме подстраивать обработку бизнес процесса в коде на языке высокого уровня. При этом не мелочь какая-то по месту оптимизация, а прямо глобально архитектурно подстроиться.


  1. Vadim_Aleks
    16.01.2023 12:10
    +3

    Generic'и в Go есть, но реализовать check() в том виде, что вы указали, всё равно не выйдет. Нужна поддержка tuple'ов скорее

    Еще не затронулась тема, что не всегда ясно какое исключение может выкинуть функция. Не всегда ясно нужно ли писать try-catch. При http запросе, если status_code != 200 - это исключение? Приходится читать исходники и документацию, чтобы уточнить. В Rust это решается тем, что все возможные типы ошибки обычно описывают в Enum'е.

    В Java, кажется, первая проблема решается декларированием возможного (или даже конкретного?) исключения у каждого метода. И компилятор явно просит обработать такой случай

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

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


    1. gandjustas Автор
      16.01.2023 12:31
      +3

      Не всегда ясно нужно ли писать try-catch

      Об этом есть в статье. Скорее не нужно, чем нужно.

      Еще не затронулась тема, что не всегда ясно какое исключение может выкинуть функция.

      Важно не какое исключение может выкинуть функция, а какое исключение вы хотите перехватить, и зачем.

      В Java, кажется, первая проблема решается декларированием возможного (или даже конкретного?) исключения у каждого метода. 

      Это нерабочее решение.

      Во-первых оно не работает с функциями высокого порядка. Если функция A принимает на вход функцию B и вызывает её внутри себя, то вы не знаете заранее какие исключения выкинет B и не можете прописать нужные исключения для A. Декларация исключений не является частью сигнатуры функции.

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

      В-третьих необходимость обрабатывать исключения это неверно само по себе. Исключения в основном НЕ надо обрабатывать. Вы крайне редко можете сделать что-то осмысленное. Даже картинка статьи на это намекает.


      1. event1
        16.01.2023 20:57
        +3

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

        Абсурдности никакой нету. Просто идея оказалась неудачной. Всегда надо помнить, что Джаву придумали совсем не для того, для чего её использовали последние 20 лет. Не для бекендов веб-приложек.

        В-третьих необходимость обрабатывать исключения это неверно само по себе. Исключения в основном НЕ надо обрабатывать. Вы крайне редко можете сделать что-то осмысленное. Даже картинка статьи на это намекает.

        Пример. Вот у меня пользователь обращается к веб-приложению, которое, в свою очередь, обращается к базе данных. А в это время экскаватор выдернул сетевой кабель из базы данных. В результате, слой по работе с данными бросил какое-то исключение. Если их не надо обрабатывать, то всё моё приложение должно завершиться, а пользователь должен ждать таймаута и бешено жать F5, так получается?

        Такой подход не выдерживает никакой критики. Любой разработчик знает, что исключение надо залоггировать, пользователю показать красивую страницу 500, админу позвонить, соединение с базой закрыть и попытаться открыть заново и ещё тысячи всяких действий.


        1. amakhrov
          18.01.2023 02:05

          Поддержу @gandjustas, который не зря дописал "в основном не надо".

          В вашем примере с упавшей базой, обработку исключения будет выполнять фреймворк. При ошибке вернет 500, залогирует, вызовет ваш кастомный обработчик, если надо что-то еще сделать.

          Код же приложения этим заниматься не должен.


          1. event1
            18.01.2023 02:40

            А какая собственно разница? Фреймворк точно так же единообразно обрабатывает исключения в каком-то локальном месте


            1. gandjustas Автор
              18.01.2023 02:56

              Если это делает фреймворк - пусть делает. Скорее всего те, кто писали, немного разбираются в теме.

              Вам обрабатывать исключения не надо. Если вы делаете консольное приложение или сервис, работающий в фоне, то лучшее решение - упасть.


    1. gandjustas Автор
      16.01.2023 12:32
      +1

      Если приложение долго запускается, то давайте мы его завершим, чтобы оно опять прогревалось, только потому что одна функция одного обработчика выбросила исключение?

      Да. Разработчик быстро исправит эту функцию если приложение станет невозможно запустить.

      Если вы эту ошибку заметете под ковер, то это путь к провалу.

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


      1. Vadim_Aleks
        16.01.2023 12:41
        +3

        Да. Разработчик быстро исправит эту функцию если приложение станет невозможно запустить.

        Не быстро) В больших компаниях, где настроен бюрократичный CI/CD, потребуют от вас создать задачу, получить 2 апрува, выкатиться на dev/stg а лишь потом, с канареечным деплоем выкатываться на prod. И всё это время приложение по сути будет недоступно. Даунтайм на проде около часа.

        тесты помогают не запускать каждый раз приложение

        Идеальный случай) Никто не делает 100% покрытие кода в приложениях.

        Кстати, исключения плохо влияют на покрытие кода тестами. Цифра может быть 100%, но тесты все случаи не покрывают. Вы уже несколько раз упоминали, что исключения не надо обрабатывать, но часто ими пользуются неправильно. Если вы импортировали библиотеку, где throw на каждый чих - идите обрабатывать через try-catch :). В итоге получается, что нужно лезть и исходники библиотеки, чтобы понять как, когда и чем она выплёвывается


        1. gandjustas Автор
          16.01.2023 12:50

          В больших компаниях, где настроен бюрократичный CI/CD, потребуют от вас создать задачу, получить 2 апрува, выкатиться на dev/stg а лишь потом, с канареечным деплоем выкатываться на prod.

          Кто потребует? Тимлид? А как он принял задачу с падающим на старте приложением?


          1. mayorovp
            16.01.2023 13:04

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

            Очевидно же, что речь идёт об обработчике конечной точки API, который вызывается не на старте, а при определённом запросе пользователя. И, вероятно, ещё и не на всех комбинациях входных данных он падает.


            1. gandjustas Автор
              16.01.2023 13:09

              Ошибка в обработчике веб-запросов должна приводить не к полному падению приложения, а прекращению обработки запроса и возврату ошибки 500.


              1. mayorovp
                16.01.2023 14:18

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


    1. onyxmaster
      16.01.2023 13:07
      +1

      Checked-исключения в Java скорее создают новых проблем, а не решают старые :)


    1. autyan
      16.01.2023 16:10
      +1

      Generic'и в Go есть, но реализовать check() в том виде, что вы указали, всё равно не выйдет.

      Возможно, я что-то неправильно понял, но:

      func check[T any](val T, err error) T {
          if err != nil {
              panic(err)
          }
          return val
      }


      1. gandjustas Автор
        16.01.2023 16:11

        Это взял информацию из доки по Go и Staсkoverflow 2019 года. На современном Go так можно написать, но документация на golang.org предлагает вариант как в статье.


  1. Rio
    16.01.2023 12:14

    В начале статьи описывается "язык низкого уровня", где всё "просто числа" (т.е. ассемблер), а затем — примеры на Си. Нет, Си — не язык низкого уровня.

    int fd = fopen(path, "r+");

    Плохой пример. Насколько я знаю, fopen всегда, ещё со времён K&R С, возвращал указатель (FILE*), а не int.


    1. gandjustas Автор
      16.01.2023 12:42

      Вы удивитесь, но C это язык низкого уровня. По крайней мере на момент появления функции fopen. int вместо FILE* чтобы не загромождать код лишними деталями.

      Было бы более правильно использовать виндовый CreateFile для примера, потом что его возвращаемое значение это просто число, а не указатель, но это бы удлинило пример.


      1. Rio
        16.01.2023 15:48
        +2

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


        1. vvzvlad
          17.01.2023 19:47

          А какая разница применительно к низкому уровню? Ну не реальной железки, а абстрактной. Так вам все равно надо ручками память освобождать и указатели разыменовывать.


          1. Rio
            17.01.2023 21:35
            +1

            Высокоуровневые языки характеризуются именно использованием абстракций; управление памятью может быть любым, в том числе и ручным. Паскаль, например, вроде бы никто низкоуровневым языком не называет, хотя (как минимум, в его классических проявлениях в виде Turbo/Borland Pascal) управление памятью там тоже ручное.


            1. vvzvlad
              17.01.2023 21:40

              Разные сборщики мусора и управление памятью — это тоже часть абстракций. Оперируете не байтами в памяти, а абстрактными объектами. Возможность тягать туда-сюда обьекты, а не работать с указателями — это тоже абстракция.
              А вот то, что пишется не под конкретный процессор, а под абстрактную машину, ничего не дает — JAVA-байткод тоже абстрактным процессором выполняется, но чет он не очень высокоуровневый.


              1. Rio
                17.01.2023 22:07

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


                1. vvzvlad
                  18.01.2023 00:58

                  Так, а давайте тогда с другого конца-то зайдем. Если си не язык низкого уровня (неважно пока по какой причине), то какой язык достоин такого звания? Ассемблер?


                  1. Rio
                    18.01.2023 01:08
                    +1

                    Ага. Собственно, выше я об этом уже писал. Да и та же Википедия в данном случае дело говорит: https://ru.wikipedia.org/wiki/Низкоуровневый_язык_программирования


                    1. vvzvlad
                      18.01.2023 01:16

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


                      1. Rio
                        18.01.2023 02:01

                        Тезис был ответом на фразу автора статьи:

                        Вы удивитесь, но C это язык низкого уровня

                        Этим тезисом Стандарт явно говорит о том, что язык не привязан к конкретной архитектуре (как частично было на заре развития Си, когда его разрабатывали для конкретной архитектуры, PDP-11), что, в свою очередь, говорит в пользу высокоуровневости языка. На низкоуровневом ассемблере не напишешь кросплатформенный код; но, в то же время, мега-кросплатформенный Linux написан на Си.

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

                        Я не знаю, откуда пошла мода считать Си (и даже иногда плюсы) низкоуровневыми языками. Наверное, популярность языков уровня Python и пр. делает своё дело, по сравнению с ними кучу языков можно "низкоуровневым" назвать. Но общепринятое деление, это всё же низкий уровень — языки ассемблера (иногда Форт причисляют) и высокий уровень — по сути всё остальное.


                      1. vvzvlad
                        18.01.2023 02:03

                        Этим тезисом Стандарт явно говорит о том, что язык не привязан к конкретной архитектуре (как частично было на заре развития Си, когда его разрабатывали для конкретной архитектуры, PDP-11),

                        Но это слабый аргумент в пользу высокоуровневости. Слабее, чем «в си много абстракций по сравнению с ассемблером».

                        Но ок, вашу точку зрения понял, с моей не совсем совпадает (мне кажется, лучше сдвинуть градацию в другую сторону и не изобреть «сверхвысокоуровных» языков, а сделать логичную схему «машинные коды-асм-низкоуровневые-высокоуровневые»), но и шут с ним.


                      1. gandjustas Автор
                        18.01.2023 02:44
                        -1

                        Логично что язык не привязан к конкретной архитектуре, так как он должен работать не только на x64 и x86, но и на тех архитектурах, которые еще не придумали. Поэтому даже низкоуровневый язык не должен содержать полное отражение машинных команд, а должен содержать только то, что будет поддерживаться в разных архитектурах.

                        Например в C вы не найдете ничего связанного с командами RCR\RCL, так как они очень специфичны именно для x86\64.

                        А высокоуровневый язык, как уже сказали, должен не только содержать абстракции высокого уровня (не имеющие отражения в машинных командах), но и иметь средства по созданию новых абстракций.


                      1. Rio
                        18.01.2023 07:55
                        +1

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

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

                        Ссылку на обоснование моей позиции я привёл. Да, это вики, и может быть, кто-то посчитает некомильфо, но раз уж так получилось, что я здесь вроде как общепринятую терминологию защищаю, то в самый раз.


                      1. gandjustas Автор
                        18.01.2023 11:48
                        -1

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


  1. commanderxo
    16.01.2023 12:36
    +2

    Про исключения и старые добрые коды возврата хорошо написал Рэймонд Чен в заметке Cleaner, more elegant, and harder to recognize.


    Спойлер

    It’s easy to spot the difference between bad error-code-based code and not-bad error-code-based code: The not-bad error-code-based code checks error codes. The bad error-code-based code never does. Admittedly, it’s hard to tell whether the errors were handled correctly, but at least you can tell the difference between bad code and code that isn’t bad. (It might not be good, but at least it isn’t bad.)


    On the other hand, it is extraordinarily difficult to see the difference between bad exception-based code and not-bad exception-based code.


    Consequently, when I write code that is exception-based, I do not have the luxury of writing bad code first and then making it not-bad later. If I did that, I wouldn’t be able to find the bad code again, since it looks almost identical to not-bad code.


    My point isn’t that exceptions are bad. My point is that exceptions are too hard and I’m not smart enough to handle them.


    1. gandjustas Автор
      16.01.2023 12:45
      +3

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


    1. FanatPHP
      16.01.2023 14:34
      +2

      Муть какая-то. error-code-based code на порядок хуже exception-based code.


      Мало того, что он засоряет код приложения, и вместо трех строчек приходится писать 15 (причем собственно актуальный код просто тонет посреди кода-обработчика) — но, главное, он заставляет обрабатывать ошибку прямо на месте. Без возможности передать управление куда-то еще, выйти из модуля на уровень выше. То есть у тебя только два варианта — либо прибить приложение совсем, либо проигнорировать ошибку и выполнять код дальше.


      Его пример с иконкой я, честно скажу, не понимаю, но мне сильно кажется, что виноваты там не исключения. Я очень надеюсь, что этот дядя немного освоился с исключениями, за прошедшие с тех пор почти 20-то лет.


      1. amishaa
        16.01.2023 14:40
        +1

        главное, он заставляет обрабатывать ошибку прямо на месте.

        Нет, конечно. Есть возможность пробросить ошибку выше.


        1. FanatPHP
          16.01.2023 14:47
          +2

          На один уровень. А их, скажем, восемь.


          1. amishaa
            16.01.2023 14:57
            +1

            Так выбрасывать exception на восемь уровней вверх - это же плохая практика:
            - во-первых, легко случайно начать ловить его по дороге в рамках рефакторинга кода
            - во-вторых, он, вероятно, будет не того уровня абстракции.


            1. FanatPHP
              16.01.2023 15:09
              +2

              Послушайте, ну это уже пошла аргументация вида "у нас исключений нет, ну и не больно-то и хотелось". Не обязательно ловить через 8, даже если ловить через 3, то исключения сделают код на порядок чище. И через 8 тоже вполне себе нормальная практика, отрубить некритичный модуль.


              И, главное, без вот этих вот гирлянд из обработчиков на каждом уровне.


              1. amishaa
                16.01.2023 15:43
                +1

                Я не спорю, что гошная очень явная обработка на всех 8 уровнях - это больно. Более того, авторы языка это тоже понимают и предлагают второй механизм для этого (panic/recover).

                Растовская гораздо менее многословна, а ещё, в качестве бонуса, позволяет блоки except MyDetailedError e {throw new MyGneralError(e)} написать один раз для каждой пары Detailed/General, а дальше указывать только типы, такая конвертация будет случаться под капотом.


    1. mayorovp
      16.01.2023 15:57
      +1

      Я бы поспорил Рэймондлом Ченом, дважды.


      Во-первых, его “not-bad” пример кода выглядит как нагромождение лапши.


      Во-вторых, представим что в примере с иконкой проблемное свойство возвращает код ошибки и мы его обработали:


      NotifyIcon CreateNotifyIcon()
      {
       NotifyIcon icon = new NotifyIcon();
       icon.Text = "Blah blah blah";
       if (!icon.Show()) {
          // что-то пошло не так
          return null;
       }
       icon.Icon = new Icon(GetType(), "cool.ico");
       return icon;
      }

      Вроде и обработка ошибки есть, можно даже код ошибки сюда вкорячить — будет "not-bad". Но станет ли код рабочим, или будет как и раньше безусловно кидать ошибку? Возможно, для Чена это окажется сюрпризом, но этот код никогда не дойдёт до строчки return icon; несмотря на всю обработку ошибок.


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


  1. rezdm
    16.01.2023 12:46

    1. gandjustas Автор
      16.01.2023 12:53
      +1

      Это встроенные в Windows механизмы, ровно такие же, какие использует C++. Они по факту в винду перекочевали из компилятора и рантайма С++.

      Разница в том Windows SEH можно использовать в C.


  1. FanatPHP
    16.01.2023 13:19
    -1

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


    1. gandjustas Автор
      16.01.2023 13:22
      -2

      Если честно PHP в 2023 само по себе выглядит как анекдот.


      1. FanatPHP
        16.01.2023 13:51

        Нет, не выглядит. Отличный язык, который совмещает в себе простоту освоения, высокую скорость прототипирования и энтерпрайз возможности. Скорее всего, вы судите о нем с чужих слов десятилетней давности, либо по таким вот анекдотам, которые, увы, являются неизбежными вследствие низкого порога вхождения.


        Добавлю: в частности, если говорить про исключения, язык полностью следует сказанному в вашей статье. В плане выброса, в последних версиях РНР уже практически не осталось старых ошибок, они все переведены в исключения. То есть практика с if (m) полностью ушла в прошлое. Плюс РНР соединяет в себе лучшее из двух миров: как возможность использовать try..try..catch..finally в случае, когда ошибку действительно надо обработать, так и возможность не отлавливать большую часть исключений, но при этом настроить дефолтный обработчик исключений, который сделает красиво без необходимости заключать весь код приложения в один большой трай-кетч.


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


        1. gandjustas Автор
          16.01.2023 13:53
          +1

          Вы сейчас описали Python


          1. FanatPHP
            16.01.2023 14:09
            +1

            Одно другому не мешает. Да, я описал и Питон тоже. Из этого никак не следует, что РНР плох.


            Да, питон, скорее всего, заборет РНР на его поле, лет через 5-7, поделив поляну с Нодой и Шарпом. Но не потому что РНР так уж плох, а скорее из-за репутации, сложившейся из вот таких безответственных заявлений.


            1. gandjustas Автор
              16.01.2023 15:33
              -2

              Мне кажется это должно было случиться 5 лет назад.


              1. FanatPHP
                16.01.2023 16:34
                +2

                Вот видите — не случилось же. Значит, надо в консерватории что-то подправить :)


  1. AlexSky
    16.01.2023 14:42
    +1

    Если что-то такое происходило операционная система без капли смущения убивала вашу программу.

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


  1. DarkEld3r
    16.01.2023 16:28
    +1

    Это все при том, что в Go есть обработка исключений. Работает почти также как в C++\C#\Java, только ключевые слова panic\defer\recover, чтобы никто не догадался.

    Справедливости ради, в расте тоже: panic/catch_unwind/resume_unwind.


    Но принятые в языке умолчания имеют большое значение. В итоге придётся очень сильно постараться чтобы найти код, который использует панику как "традиционные" исключения.


    1. gandjustas Автор
      16.01.2023 18:48
      +1

      Я не знаю Rust и про возможность перехватить panic узнал от вас. В официальном гайде ни слова об этом нет.


    1. gandjustas Автор
      16.01.2023 19:01
      +1

      Документация что-то страшное говорит:

      Note that this function might not catch all panics in Rust.

      Это вообще можно использовать с предсказуемым результатом?


      1. amishaa
        16.01.2023 19:30
        +1

        Это зависит от параметров компиляции. Есть возможность скомпилировать так, чтобы panic приводил к завершению программы без unwind стэка, в таком случае поймать unwind не представляется возможным.


      1. DarkEld3r
        16.01.2023 21:25

        Уже ответили, но уточню, что стратегия обработки паники указывается на уроне "приложения". То есть если мы пишем не библиотеку, то можем полагаться на выбранную опцию.


        Кстати, про стратегию обработки паники всё-таки говорится в книге, хотя возможность перехвата и не упоминается.


  1. staticmain
    16.01.2023 18:11

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

    Пример (в псевдокоде, я очень давно не писал на С++):

    func1 {
        do1();
        if (!do1()) 
            throw(ENOINTERNETCONNECTION);
    
        do2();
        if (!do2())
            throw(ENOMYSQLCONNECTION);
    
        do3();
        if (!do3())
            throw(EMATHEXCEPTION);
    }
    
    func2 {
        try {
            func1();
        } catch e {
            ENOINTERNETCONNECTION : log("Нет интернета");
            EMATHEXCEPTION        : log("Учи матан");
        }
    }

    Можно заметить, что программист, написав func2 не написал обработчик для ENOMYSQLCONNECTION, он мог

    • Не знать, что том, что оно там есть, плохо прочитав документацию, а функция в библиотеке и исходники недоступны.

    • Нет документации\документация устарела, функция в библиотеке, исходников нет. Список возможных экспешенов недоступен

    • Программист знал, но упустил один из эксепшенов.

    И вот, в func1 вызывается ENOMYSQLCONNECTION, она летит в func2, в ней такого обработчика нет. И всё, эксепшн полетел выше, еще выше, там его совсем не ожидают и не обрабатывают. И вот он долетает либо до корневой, где все обычно делают обработку EALL, либо в функцию, которая тоже использует MySQL, но, возможно, другую. И она обрабатывает ENOMYSQLEXCEPTION. Вот только он относится не к этой базе, а к другой, из-за чего программист\пользователь получает в корне неверную информацию. Я знаю кассовую систему, которая на более высоком уровне использует MySQL, а более глубоко - Firebase. Написана она на языке, поддерживающем исключения, из-за чего при отсутствии обработки потери соединения на Firebase ENODATABASECONNECTION улетит наверх, туда, где это можно интерпретировать как ошибку доступа к MySQL.

    Решением этой проблемы было бы не давать скомпилировать код, пока программист не будет обрабатывать все исключения, которые функция может выбросить (с ней нужно хранить метаинформацию о списке ее выбрасываемых исключений). Либо программист делает в catch default ветку, либо обрабатывает все, либо explicitly указывает конкретное исключение, которое должно быть проброшено наверх.

    Только таким путем можно предотвратить последствия того, что исключение улетает наверх, туда, где его никто не ожидает.


    1. Sklott
      16.01.2023 18:20

      На самом деле уже с C++11 это можно сделать проще, правда и последствия жестче. Если объявить функцию func2 как noexcept, т.е. "гарантировать" что из нее не будет никаких исключений, то при появлении все-таки исключения будет вызван terminate(), что обычно приводит к завершению программы. Конечно это поможет не всегда. Но от таких случаев - вполне.


    1. gandjustas Автор
      16.01.2023 18:53
      +1

      В вашем примере скорее обратная проблема. Слишком много разных типов исключений выбрасывает функция. Достаточно одного - ЕНЕПОЛУЧИЛОСЬ, а внутри него уже запрятать информацию о том, что реально произошло. Или можно более ООПшно - сделать кучу наследников ЕНЕПОЛУЧИЛОСЬ, но в программе перехватывать только базовый, а потом уже разбираться.

      Короче вопрос не самих исключениях, а в том как ими пользоваться.


    1. event1
      16.01.2023 21:07
      +1

      Решением этой проблемы было бы не давать скомпилировать код, пока программист не будет обрабатывать все исключения, которые функция может
      выбросить (с ней нужно хранить метаинформацию о списке ее выбрасываемых исключений). Либо программист делает в catch default ветку, либо обрабатывает все, либо explicitly указывает конкретное исключение, которое должно быть проброшено наверх.

      И тут на сцену выходят checked exception из Java. Которые тащат за собой ещё одну кучу проблем в результате которых у нас и появились подходы из Руста и Го


  1. event1
    16.01.2023 21:15
    +2

    Вы верное указали проблемы исключений, но забыли про главный плюс: возможность все неожиданные ситуации обработать в одном специально отведённом месте, единообразным способом. То есть, чтобы не случилось в веб-приложении, пользователь всё-равно увидит аккуратную страницу с 500-ой ошибкой, детали будут записаны в лог и так далее. Языки, которые не позволяют вынести обработку исключений в специальное место лишают своих пользователей важного.

    Соответственно, отказ от исключений не должен выкидывать с водой и ребёнка. На мой взгляд, в русте это удалось, в го — нет.


    1. Vadim_Aleks
      16.01.2023 21:39

      обработку исключений в специальное место лишают своих пользователей важного

      Что важного можно передать пользователю после исключительной ситуации? Максимум trace_id, который достаётся из заголовков. Остальное ему не надо

      А если надо, то можно явно написать Middleware даже в том же Go


  1. NekoiNemo
    18.01.2023 17:52

    Стоит добавить что для Rust "?" работает не только для `Result<T, Error>` но и для `Option<T>`. И, если задействовать https://crates.io/crates/try-block, то им можно пользоваться "локально" а не выходя из всей функции (т.е. аналог for в Scala). Так же для Result, ? не требует чтобы ошибки были одного типа если существует From имплементация из этой ошибки в ту которая ожидается на выходе функцией (что есть очень болезненный момент в Scala где даже если ошибки наследуются от общего предка - компилятор не может само это сообразить и приходится прописывать все тайп-параметры руками при вызове таких функций в for).

    А на nightly, в процессе стабилизации, существует трейт который позволит использовать ? для произвольных типов для которых этот трейт выполнен.