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