だから私は、戻り値と例外の複合構造を使用するというこの(IMHO)非常に素晴らしいアイデアに出くわしました-_Expected<T>
_。これは、エラー処理の従来の方法(例外、エラーコード)の多くの欠点を克服します。
Andrei Alexandrescuの講演(C++での系統的エラー処理) および そのスライド を参照してください。
例外とエラーコードは基本的に同じ使用シナリオで、関数が何かを返すものと返さないものがあります。一方、_Expected<T>
_は、値を返す関数のみを対象としているようです。
だから、私の質問は次のとおりです。
Expected<T>
_を試した人はいますか?更新:
私は私の質問を明確にする必要があると思います。 _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)
などの別の関数があり、文字列をインプレースで変更し、戻り値がない場合、コードはどのようになりますか?
期待を試してみた人はいますか。実際には?
当たり前のことで、この話を見る前から使っていました。
このイディオムを、何も返さない関数(つまり、void関数)にどのように適用しますか?
スライドに示されているフォームには、いくつかの微妙な意味があります。
expected<void>
がある場合、これは当てはまりません。void
値には誰も関心がないため、例外は常に無視されるためです。アサーションと明示的なsuppress
メンバー関数を使用して、Alexandrescusクラスのexpected<T>
からの読み取りを強制するので、これを強制します。デストラクタからの例外の再スローは、正当な理由で許可されていないため、アサーションを使用して実行する必要があります。
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
}
Cっぽい言語だけに焦点を当てている人にとっては新しいように見えるかもしれませんが、sum-typesをサポートする言語を味わった私たちにとってはそうではありません。
たとえば、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
に特化したものです。
Matthieu M.が言ったように、これはC++にとって比較的新しいものですが、多くの関数型言語にとって新しいものではありません。
ここに2セントを追加したいと思います。私の意見では、難しさと違いの一部は「手続き型と機能型」のアプローチにあります。そして、Scala(ScalaとC++の両方に精通していて、より近い機能(オプション)があると感じているため)を使用したいと思います。 Expected<T>
)この違いを説明します。
Scalaには、Some(t)またはNoneのいずれかであるOption [T]があります。特に、Expected<void>
と道徳的に同等のOption [Unit]を持つこともできます。
Scalaでは、使用パターンは非常に似ており、isDefined()とget()の2つの関数を中心に構築されています。ただし、「map()」関数もあります。
私は「マップ」を「isDefined + get」と機能的に同等であると考えるのが好きです。
if (opt.isDefined)
opt.get.doSomething
になります
val res = opt.map(t => t.doSomething)
オプションを結果に「伝播」する
ここに、オプションを使用および作成するこの機能的なスタイルで、あなたの質問に対する答えがあると思います。
では、文字列をインプレースで変更し、戻り値を持たないtoUpper(s)などの別の関数がある場合、コードはどのようになりますか?
個人的には、文字列を変更しないか、少なくとも何も返しません。 Expected<T>
は「機能的な」概念であり、うまく機能するには機能的なパターンが必要です。toUpperは、新しい文字列を返すか、変更後に自分自身を返す必要があります。
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はタイプとは見なされませんでしたが、「タイプなし」(void foo()はPascalと同様と見なされていました)手順)。単位(関数型言語で使用される)は、「計算」を意味するタイプとしてより多く見られます。したがって、Option [Unit]を返すことは、「オプションで何かを実行した計算」と見なされるため、より意味があります。そして、Expected<void>
では、voidは同様の意味を想定しています。つまり、意図したとおりに機能すると(例外的なケースがない場合)、終了する(何も返さない)計算です。少なくとも、IMO!
したがって、ExpectedまたはOption [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(_ => ...)
どちらが良いですが、それでも理想からはほど遠いです。ここでは、たぶんモナドが明らかに勝っています...しかし、それは別の話です。
このビデオを見て以来、私は同じ質問について考えてきました。そして、これまでのところ、期待したことについて説得力のある議論は見つかりませんでした。私にとって、それはばかげているように見え、明快さと清潔さに反しています。私はこれまでに次のことを思いついた:
noexcept
でマークする必要があります。すべて。noexcept
としてマークされていないすべての関数は、try {} catch {}でラップする必要があります。これらのステートメントが当てはまる場合は、実装の詳細を確認せずにスローされる可能性のある例外がわからないという1つの欠点があるだけで、使いやすいインターフェイスを自己文書化しています。
クラス実装の内臓に何らかの例外がある場合(プライベートメソッドの奥深くなど)、インターフェイスメソッドでそれをキャッチし、Expectedを返す必要があるため、Expectedはコードにいくつかのオーバーヘッドを課します。何かを返すという概念を持っているメソッドにはかなり許容できると思いますが、設計上戻り値がないメソッドに混乱と混乱をもたらすと思います。私のほかに、何も返さないはずの何かから物を返すのは非常に不自然です。
コンパイラ診断で処理する必要があります。多くのコンパイラは、特定の標準ライブラリ構造の予想される使用法に基づいて、すでに警告診断を発行しています。 expected<void>
を無視すると警告が表示されます。