Подтвердить что ты не робот

Как бы вы использовали Alexandrescu Expected <T> с функциями void?

Итак, я столкнулся с этим (IMHO) очень хорошей идеей использования составной структуры возвращаемого значения и исключения - Expected<T>. Он преодолевает многие недостатки традиционных методов обработки ошибок (исключения, коды ошибок).

См. Обсуждение Андрея Александреску (систематическая обработка ошибок на С++) и его слайды.

Исключения и коды ошибок имеют в основном одинаковые сценарии использования с функциями, которые возвращают что-то, а те, которые этого не делают. Expected<T>, с другой стороны, похоже, нацелен только на функции, возвращающие значения.

Итак, мои вопросы:

  • Кто-нибудь из вас пробовал Expected<T> на практике?
  • Как вы примените эту идиому к функциям, возвращающим ничего (т.е. функции void)?

Update:

Думаю, я должен уточнить свой вопрос. Специализация Expected<void> имеет смысл, но меня больше интересует, как она будет использоваться - последовательная идиома использования. Сама реализация является вторичной (и простой).

Например, Alexandrescu дает этот пример (немного отредактированный):

string s = readline();
auto x = parseInt(s).get(); // throw on error
auto y = parseInt(s); // won’t throw
if (!y.valid()) {
    // ...
}

Этот код "чистый" таким образом, что он просто протекает естественным образом. Нам нужна ценность - мы ее получаем. Однако при Expected<void> нужно было бы захватить возвращаемую переменную и выполнить некоторую операцию над ней (например, .throwIfError() или что-то еще), что не так элегантно. И, очевидно, .get() не имеет смысла с void.

Итак, как бы выглядел ваш код, если бы у вас была другая функция, скажем toUpper(s), которая изменяет строку на месте и не имеет возвращаемого значения?

4b9b3361

Ответ 1

Попросили ли вы кого-нибудь из вас Ожидаемый; на практике?

Это вполне естественно, я использовал его еще до того, как увидел этот разговор.

Как бы вы применили эту идиому к функциям, возвращающим ничего (то есть, функции void)?

Форма, представленная в слайдах, имеет некоторые незначительные последствия:

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

Это не выполняется, если у вас есть expected<void>, потому что, поскольку никто не заинтересован в значении void, исключение всегда игнорируется. Я бы это сделал, так как я бы принудительно читал из expected<T> в классе Alexandrescus, с утверждениями и явной функцией члена suppress. Повторное исключение из деструктора не допускается по уважительным причинам, поэтому это нужно делать с утверждениями.

template <typename T> struct expected;

#ifdef NDEBUG // no asserts
template <> class expected<void> {
  std::exception_ptr spam;
public:
  template <typename E>
  expected(E const& e) : spam(std::make_exception_ptr(e)) {}
  expected(expected&& o) : spam(std::move(o.spam)) {}
  expected() : spam() {}

  bool valid() const { return !spam; }
  void get() const { if (!valid()) std::rethrow_exception(spam); }
  void suppress() {}
};
#else // with asserts, check if return value is checked
      // if all assertions do succeed, the other code is also correct
      // note: do NOT write "assert(expected.valid());"
template <> class expected<void> {
  std::exception_ptr spam;
  mutable std::atomic_bool read; // threadsafe
public:
  template <typename E>
  expected(E const& e) : spam(std::make_exception_ptr(e)), read(false) {}
  expected(expected&& o) : spam(std::move(o.spam)), read(o.read.load()) {}
  expected() : spam(), read(false) {}

  bool valid() const { read=true; return !spam; }
  void get() const { if (!valid()) std::rethrow_exception(spam); }
  void suppress() { read=true; }

  ~expected() { assert(read); }
};
#endif

expected<void> calculate(int i)
{
  if (!i) return std::invalid_argument("i must be non-null");
  return {};
}

int main()
{
  calculate(0).suppress(); // suppressing must be explicit
  if (!calculate(1).valid())
    return 1;
  calculate(5); // assert fails
}

Ответ 2

Даже если это может показаться новым для кого-то, ориентированного исключительно на языки C-ish, тем, кто имеет вкус языков, поддерживающих типы сумм, это не так.

Например, в Haskell у вас есть:

data Maybe a = Nothing | Just a

data Either a b = Left a | Right b

Где | читает и/или первый элемент (Nothing, Just, Left, Right) - это всего лишь "тег". По сути, типы сумм - это просто дискриминационные союзы.

Здесь у вас Expected<T> будет что-то вроде: Either T Exception со специализацией для Expected<void>, которая сродни Maybe Exception.

Ответ 3

Как сказал Маттиу М., это что-то относительно новое для С++, но ничего нового для многих функциональных языков.

Я хотел бы добавить свои 2 цента здесь: часть трудностей и различий можно найти, на мой взгляд, в "процедурном или функциональном" подходе. И я хотел бы использовать Scala (потому что я знаком как с Scala, так и с С++, и я считаю, что для иллюстрации этого различия есть средство (Option), которое ближе к Expected<T>).

В Scala у вас есть опция [T], которая является либо некоторым (t), либо None. В частности, также возможно иметь Option [Unit], который морально эквивалентен Expected<void>.

В Scala шаблон использования очень похож и построен вокруг двух функций: isDefined() и get(). Но он также имеет функцию "map()".

Мне нравится думать о "карте" как функциональном эквиваленте "isDefined + get":

if (opt.isDefined)
   opt.get.doSomething

становится

val res = opt.map(t => t.doSomething)

"распространение" варианта результата

Я думаю, что здесь, в этом функциональном стиле использования и составления вариантов, лежит ответ на ваш вопрос:

Итак, как выглядел бы ваш код, если бы у вас была другая функция, скажем, toUpper (s), которая изменяет строку на месте и не имеет возвращаемого значения?

Лично я НЕ модифицировал строку на месте, или, по крайней мере, я ничего не верну. Я вижу Expected<T> как "функциональную" концепцию, которая нуждается в функциональном шаблоне, чтобы работать хорошо: toUpper (s) должен был бы либо возвращать новую строку, либо возвращаться после модификации:

auto s = toUpper(s);
s.get(); ...

или с Scala -подобной картой

val finalS = toUpper(s).map(upperS => upperS.someOtherManipulation)

если вы не хотите следовать функциональному маршруту, вы можете просто использовать isDefined/valid и написать свой код более процедурным способом:

auto s = toUpper(s);
if (s.valid())
    ....

Если вы следуете этому маршруту (возможно, потому, что вам нужно), существует точка "void vs. unit", чтобы сделать: исторически, void не считался типом, но "no type" (void foo() считался подобно процедуре Паскаля). Единица (как используется в функциональных языках) больше рассматривается как тип, означающий "вычисление". Таким образом, возврат параметра [Unit] имеет больше смысла, рассматривая его как "вычисление, которое необязательно делало что-то". И в Expected<void>, void принимает аналогичный смысл: вычисление, которое, когда оно работает по назначению (где нет исключительных случаев), просто заканчивается (ничего не возвращает). По крайней мере, IMO!

Таким образом, использование ожидаемого или опции [Unit] можно рассматривать как вычисления, которые могут привести к результату, а может и нет. Их цепочка окажется сложной:

auto c1 = doSomething(s); //do something on s, either succeed or fail
if (c1.valid()) {
   auto c2 = doSomethingElse(s); //do something on s, either succeed or fail
   if (c2.valid()) { 
        ...

Не очень чистый.

Карта в Scala делает ее немного более чистой

doSomething(s) //do something on s, either succeed or fail
   .map(_ => doSomethingElse(s) //do something on s, either succeed or fail
   .map(_ => ...)

Это лучше, но все же далека от идеала. Здесь, возможно, монада явно выигрывает... но эта другая история.

Ответ 4

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

  • Ожидается, что, поскольку он имеет либо значение, либо исключение, мы не вынуждены использовать try {} catch() для каждой функции, которая может быть выбрана. Поэтому используйте его для каждой функции бросания, которая имеет возвращаемое значение
  • Каждая функция, которая не выбрасывает, должна быть отмечена noexcept. Каждый.
  • Каждая функция, которая ничего не возвращает и не помечена как noexcept, должна быть обернута try {} catch {}

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

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