Pull to refresh

Comments 121

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Ок. Согласен. Такой синтаксис в 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;
}

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

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


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-нотация держит их все на одном уровне.

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

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

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

Когда-то давно многие ругались, что ошибки с 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, а не создавались временные объекты и конструкторы перемещения.

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

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

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

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

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

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

К сожалению, не получается вместе использовать 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 экранов шаблонной магии, т.к. не используются концепты. В стандартной библиотеке они будут использоваться и у вас опять будет одна ошибка.

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

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

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

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

Зачем?

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

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

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

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

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

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

это неудобный способ записи того же самого, но ошибку вы даже не текстом вернули а 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 делали так, чтобы не приходилось писать второй вариант кода, а можно было писать первый.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Есть проблемы
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 мне все еще не нравится - выше просто факты

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Думаете это уникальная для данной архитектуры операция?

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

Про исключения и старые добрые коды возврата хорошо написал Рэймонд Чен в заметке 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.

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

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


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


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

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

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

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

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

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


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

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

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

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


Во-первых, его “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; несмотря на всю обработку ошибок.


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

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

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

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

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

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


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


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

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


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

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

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

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

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

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

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


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

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

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

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

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

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

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


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

UFO landed and left these words here

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

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

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

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

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

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

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

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

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

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

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

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

Sign up to leave a comment.

Articles