この記事はC++ Advent Calendar 2016の20日目の記事です.
昨日は春野すずらんさんの『c++ で少し便利かもしれない行列ライブラリと超複素数ライブラリ作ってみた。』でした.
I(@wx257osn2)です.今年は教育実習だの卒研だのいろいろやってたら終わってしまった1年でした.そろそろのんびり生きたい.
追記(12/31) : なんとか年内に書き終わりました…大幅に遅れてしまい大変申し訳…
おことわり
当記事におけるC++とはバージョンの記載が無い場合主にC++1zを指します.また,当記事におけるWinAPIとはCOMなども含むWindowsで使用できるAPI全般を指すものとします.そして,後半ではVS2017RC1を使っておりますので,後半のコードを手元で動作させたい,という方は予めご用意ください.加えて,コードは記事掲載時点のコンパイラ・ライブラリを使用したものですので,将来的には動かなくなる可能性があります2.ご了承ください.
tl;dr
正常値とエラー値のいずれかを格納する型・expected
の紹介と,実際に応用した際にどの程度効果があるか,そしてその限界について見ていきます.一言で言えば,expected
はいいぞ.
はじめに
今年も1年いろいろなことがあったと思います3.技術界隈では記憶に新しいものとして,言語を跨いで話題になった単語で「null安全」なるものがありましたが,皆さんご存じでしょうか?
null安全 #とは
『null安全でない言語は、もはやレガシー言語だ』という記事4を発端5として一時期巷で騒がれていた単語です.記事中では,以下のような定義付けが為されています.
言語によって Optional や Option, Maybe, nullable type などの型で実現できる、 null が原因で実行時エラーを起こさない性質のこと
なるほど,確かにnull安全はあったほうがうれしいですね.**null安全な言語機能・ライブラリを使うとコードが綺麗になります.**C++にもC++1zよりstd::optional<T>
が入り,Boostを入れずともnull安全なコードが実現出来るようになります.
null安全は本当にうれしいのか
さて,突然前言をひっくり返しますが,本当にnull安全ってうれしいのでしょうか?6もちろんnull安全か否か,と言われればnull安全の方がうれしいのは確かです.でも,本当にnull安全で十分でしょうか?
そもそもnull安全の文脈における「null」ってなんなのでしょうか.
#include<sstream>
#include<optional>
std::optional<int> read_int(std::istream& is){
int t;
if(is >> t)
return t;
return std::nullopt;
}
#include<iostream>
void test(std::istream& is){
auto u = read_int(is);
std::cout << u.has_value();
if(u)
std::cout << " : " << *u;
std::cout << std::endl;
}
int main(){
std::cout << std::boolalpha;
{
std::stringstream ss;
test(ss); //false
}
{
std::stringstream ss;
ss << 30;
test(ss); //true : 30
}
}
false
true : 30
null安全における「null」(ここではstd::nullopt
)とは,「処理に失敗したことを表す値」のはずです.しかし現実として,「処理に失敗したことを表す値」は果たしてnull
(C++で言えばnullptr
)だけでしょうか?C++(とC++でよく使われるAPI)で考えてみてもパッと
nullptr
false
-
-1
など不正を表す実装定義の値-
GetLastError()
やerrno
などエラー情報の取得を別で行う必要がある物もある
-
-
FAILED(hr)
(HRESULT
型の値のうち不正値を表すもの) - 例外
の5種ぐらいは浮かびます.std::optional<T>
で実現するnull安全は,これらを全てstd::nullopt
として扱うことで実現されることになります.
しかし,GetLastError()
/errno
とHRESULT
と例外(と,物によりますが,実装定義の値7)はエラーの内容を含んでいます.これらを全てstd::nullopt
にまとめることで,「なぜ処理に失敗したのか」という情報を捨てることになります.つまり,正常系の記述には十分に有用ですが,異常系の記述に際して正しいエラーハンドリングは期待できないということです.私が思うに,**null安全では不十分8**です.
エラー情報もうまく扱いたい
null安全ではエラーの詳細な情報が握りつぶされてしまうという問題があることがわかりました.これは,不正値を単一の値で表しているからです.逆説的に,不正値としてエラーの詳細を持てばこの問題は解決します.HaskellにはEither
モナドというものがあり,Either a b
として失敗(Left a
)と成功(Right b
)のいずれか一方を格納する型をつくることができます.我々が本当に欲しかったのはこれです9.
しかし,C++はレガシーな言語なので,そんな便利なものは無いのでは…なんて思ったそこのアナタ,ちゃんとC++にもあります10.それが表題にもあるexpected
です.
expected
ptal/expected: What did you expect?
https://github.com/ptal/expected
この章では正常値または不正値のいずれかを格納する型expected
の機能について見ていきます.
この章のコードはグローバル名前空間でのusing namespace boost;
を前提とします11.
宣言と初期化
expected<T, E>
で型T
の正常値または型E
の不正値を格納する型となります12.型T
の値はexpected<T, E>
へ暗黙変換できます.型E
の値はmake_unexpected(E)
関数を通す必要があります13.また,expected<T>
とするとE
にはデフォルト型引数std::exception_ptr
が入り,任意の例外をエラー値として扱えるようになります.
expected<double> divide(double num, double den){
if(den == 0)
return make_unexpected(std::domain_error("divide by zero"));
return num / den;
}
正常値かどうかを確認する
expected<T, E>
に入っている値が正常値かどうかを確認するためには,valid()
メンバ関数を呼びます.
std::cout << std::boolalpha << divide(3, 2).valid() << ' ' << divide(3, 0).valid() << std::endl;
true false
また,expected<T, E>
にはexplicit operator bool()
が定義されているので,if
やwhile
の条件式とかではvalid()
メンバ関数を呼ばなくてもちゃんと中身の判定をしてくれます.
auto t = divide(3, 2);
if(t)
std::cout << "divide(3, 2) is valid" << std::endl;
std::cout << "divide(3, 0) is " << (divide(3, 0) ? "valid" : "invalid") << std::endl;
divide(3, 2) is valid
divide(3, 0) is invalid
値を取り出す
expected<T, E>
から正常値を取り出すためには,以下の2通りの方法があります.
value()
メンバ関数
中身が正常値かどうかをチェックし,正常値であれば中身を取り出します.エラー値であれば,例外として送出します.
std::cout << divide(3, 2).value() << std::endl;
try{
std::cout << divide(2, 0).value() << std::endl;
}
catch(std::logic_error& e){
std::cout << e.what() << std::endl;
}
1.5
divide by zero
operator *
中身が正常値かどうかをチェックすることなく,正常値であるものとして中身を取り出します.エラー値が入っていた場合の挙動は多くの場合未規定14です.値が正常であることが期待できる場合にのみ使用するべきでしょう.
auto t = divide(3, 2);
if(t)
std::cout << "divide(3, 2) = " << *t << std::endl;
auto s = divide(3, 0);
if(s)
std::cout << "divide(3, 0) is " << *s << std::endl;
else
std::cout << "divide(3, 0) is invalid" << std::endl;
//以下は未規定動作なので実行結果に意味はない
std::cout << *s << std::endl;
divide(3, 2) = 1.5
divide(3, 0) is invalid
1.42051e-316
ちなみに,正常値のメンバに対してoperator ->
で直接アクセスすることも可能です.これも中身のチェックはしません.
エラーを取り出す
expected<T, E>
からエラーを取り出すには,error()
メンバ関数を使用します.ただし,error()
メンバ関数はoperator *
同様中身がエラーであるかをチェックすることなくエラーであるとみなして中身を取り出すので,呼び出すときには中身がエラー値であることが分かった状態で呼び出すべきです.15
auto t = divide(3, 0);
if(!t){
try{
std::rethrow_exception(t.error());
}catch(std::logic_error& e){
std::cout << e.what() << std::endl;
}
}
divide by zero
モナドっぽく使う
ひとまずこれで最低限使うことは出来ますが,現状だと「double
型の変数を文字列に変換して返す,失敗した場合は"failed"
という文字列のアドレスを返す関数expected<std::string, const char*> to_str(double)
16」を使って「divide(double, double)
関数の値を文字列に変換し,変換後の文字列の長さを返す」といったプログラムを書きたい場合,以下のようなコードになってしまいます.
expected<std::string, const char*> to_str(double t)try{
return std::to_string(t);
}catch(std::exception&){
return make_unexpected("failed");
}
expected<std::string::size_type, const char*> func1(double t){
auto a = divide(3, t);
if(a){
auto b = to_str(*a);
if(b)
return b->size();
else
return make_unexpected(b.error());
}
else{
return make_unexpected("divide by zero");
}
}
無限にネストするやつだこれ.もうちょっとなんとかなって欲しいですね.一応,例外を使えばもうちょっとスッキリはします.
expected<std::string::size_type, const char*> func2(double t){
try{
auto&& a = divide(3, t).value();
auto&& b = to_str(a).value();
return b.size();
}catch(bad_expected_access<const char*>& e){
return make_unexpected(e.error());
}catch(...){
return make_unexpected("divide by zero");
}
}
でも,もうちょっと綺麗に使いたい.そこで,以下のメンバ関数を使用します.
map(F)
メンバ関数
map(F)
メンバ関数はT
型の値を受け取る関数f
を受け取り,中身が正常値ならf(value())
の結果を,中身が不正値ならそれを返します.expected<T, E>
からexpected<decltype(f(value())), E>
を得る操作です.
divide(3, 2).map([](double t){return int{t};}); //expected<int>{1};
divide(3, 0).map([](double t){return int{t};}); //expected<int>{unexpected_type<>{std::domain_error("divide by zero")}};
bind(F)
メンバ関数
bind(F)
メンバ関数はT
型の値を受け取りexpected<U, E>
(U
は任意の型)を返す関数f
を受け取り,中身が正常値ならf(value())
の結果を,中身が不正値ならそれを返します.expected<T, E>
からdecltype(f(value()))
(expected<U, E>
)を得る操作です.
divide(3, 2).bind([](double t){return expected<int>{static_cast<int>(t)};}); //expected<int>{1};
divide(3, 0).bind([](double t){return expected<int>{static_cast<int>(t)};}); //expected<int>{unexpected_type<>{std::domain_error("divide by zero")}};
then(F)
メンバ関数
then(F)
メンバ関数はexpected<T, E>
を受け取る関数f
を受け取り,f
に自身を渡した結果を返します.f
がexpected<U, X>
(U
,X
は任意の型)を返すのであればexpected<T, E>
からdecltype(f(expected<T, E>{}))
(expected<U, X>
)を,f
がexpected
でない型を返すのであればexpected<T, E>
からexpected<decltype(f(expected<T, E>{})), E>
を得る操作です.
divide(3, 2).then([](expected<double>&& t){
if(t) return int{*t};
else return 0;
}); //expected<int>{1};
divide(3, 0).then([](expected<double>&& t){
if(t) return int{*t};
else return make_unexpected(t.error());
}); //expected<int>{unexpected_type<>{std::domain_error("divide by zero")}};
catch_error(F)
メンバ関数
catch_error(F)
メンバ関数は関数f
を受け取り,中身が正常値であればそれを持った新たなexpected
を,中身が不正値であればf(error())
を返します.上の3つとは異なり型を変換することは出来ません(expected<T, E>
からexpected<T, E>
を得る操作です).
divide(3, 2).catch_error([](std::exception_ptr e){
try{
std::rethrow_exception(e);
}catch(std::exception& e){
std::cout << e.what() << std::endl;
}
return 0.;
}); //expected<double>{1.5};
divide(3, 0).catch_error([](std::exception_ptr e){
try{
std::rethrow_exception(e);
}catch(std::exception& e){
std::cout << e.what() << std::endl; //divide by zero
}
return 0.;
}); //expected<double>{0.};
divide by zero
適用してみる
以上4つのメンバ関数の中から,今回はthen
とbind
とmap
を使うことで,以下のようにスッキリ書けます.
expected<std::string::size_type, const char*> func(double t){
return divide(3, t).then([](auto&& d)->expected<double, const char*>{
if(d)
return *d;
else
return make_unexpected("divide by zero");
})
.bind(to_str)
.map([](auto&& s){return s.size();});
}
順を追って見てみましょう.まず,divide(3, t)
の値を得ます.次に,then
でdivide(3, t)
の結果によって処理を分岐しています.divide(3, t)
が正常値ならそれをそのまま返し,不正値なら"divide by zero"
を不正値として返します.これにより,expected<double, const char*>
型のオブジェクトとなります.
続いて,このexpected<double, const char*>
型のオブジェクトに対してbind(to_str)
を呼び出しています.これにより,オブジェクトの中身のdouble
型の値をto_str(double)
に渡した結果が返ってきます.そして,最後にmap([](auto&& s){return s.size();})
を呼び出しています.これで,to_str
の結果が正常値であればその文字列の長さが,不正値であればそれ("failed"
)がそのまま返ってくることになります.
その他
- コピー・ムーヴ初期化/代入,
swap()
,比較などは正しく中身を見て処理してくれます. - 有効なら値を取り出し,そうでなければ引数で渡したデフォルト値を返す
value_or(T)
などもあります. -
expected
がネスト(expected<expected<T, E>, E>
)した時,有効なら中のexpected<T, E>
を取り出し,無効なら外側の不正値を返すunwrap()
というメンバ関数もあります.
応用 : 既存APIへの適用
さて,単にエラーハンドリングが出来るライブラリが存在するだけではレガシー言語を脱することは出来ないそうなので4,このexpected
を実際にWinAPIに適用していきたいと思います.
その前に
今回盛大に遅刻しながら実際にWinAPIにexpected
を適用していったのですが,メチャクチャ不都合が出まくりました.例えば,expected
で使える型には結構制約があったり,他にも先程の「適用してみる」のようなエラー値の型を変換したいときにちょっと冗長だったりして元のだと段々不満になってきたので,今回expected
を作り直しました.というわけで,差異を以下にまとめます.
-
emap
メンバ関数の実装-
map(F)
メンバ関数はE
型の値を受け取る関数f
を受け取り,中身が正常値ならvalue()
の結果を,中身が不正値ならf(error())
を不正値として返します. -
expected<T, E>
からexpected<T, decltype(f(error()))>
を得る操作です. - これにより,「適用してみる」のコードが以下のようになります.よりスッキリとしましたね.
-
expected<std::string::size_type, const char*> func(double t){
return divide(3, t).emap([](auto&&){return make_unexpected("dvide by zero");})
.bind(to_str)
.map([](auto&& s){return s.size();});
}
-
make_unexpected<E>(values...)
の実装- in-placeにエラー型を作れます.
- 多重入れ子になった
expected<expected<...<expected<T, E>, ...>, E>, E>
から再帰的に中のexpected<T, E>
を取り出すunwrap_all
メンバ関数を実装- 正直使う機会がない方がいいと思います…
-
operator+
,operator++
,operator--
の実装- 元々のExpectedの設計17はC++的には正しいと思うのですが,一方でいちいち
value()
って打つのがメチャクチャ面倒だったのでチェック付きメンバアクセスが出来る演算子を勝手に生やしました. -
operator++
とoperator--
は共に後置です. -
operator+
とoperator++
がvalue()
と同様,operator--
がチェックして正常値のアドレスを返すという挙動になります.
- 元々のExpectedの設計17はC++的には正しいと思うのですが,一方でいちいち
-
has_error()
,error_or()
メンバ関数の実装- 元々のExpectedだとExpected algorithmとして外部関数で実装されていたものをメンバ関数に取り入れた形
-
map
などのメンバ関数に渡した関数f
で例外が発生した際,エラー型E
によっては例外をキャッチするように- これも元々のExpectedで事前にマクロを定義しておけば使える機能ではあったのですが,
E
がstd::exception
で構築できる型に制約される18という大問題があったので,E
によって例外をキャッチする実装としない実装に振り分けるようにしました.
- これも元々のExpectedで事前にマクロを定義しておけば使える機能ではあったのですが,
-
do_()
関数を追加- Haskellの
do
構文みたいな感じで,中でexpected
を展開した際不正値であればそこで処理を中断してその値を返す関数19です.
- Haskellの
-
expected<T&, E>
を受け付けるように -
noexcept
を書いた - 1ファイル化
- Boost依存を排除
- 逆に言えば
boost::exception_ptr
などのBoostの機能との親和性は損なわれているのでその辺は必要に応じてコードを追加しないとダメです.
- 逆に言えば
以上改造済みのexpected
の実装がこちらです.
WinAPIにexpected
を適用してみた
い つ も の
wx257osn2/will: WinAPI Wrapper Library
https://github.com/wx257osn2/will
willはインクルードパスの通ってるディレクトリにcloneした瞬間即使える,ヘッダオンリーのWinAPIラッパーライブラリです.
これまでwillではエラー処理はガン無視・エラー情報を握りつぶしてnullptrを返すなど,結構ムチャクチャやってきてましたが,今回全ライブラリのエラー処理にexpected
を使用する実装に転換しました20.これにより,柔軟なエラーハンドリング21が可能になりました.
サンプルコード
以下がwillを使って適当に画像をボカシかけて描画したり音声ファイルを再生22したりするサンプルです.
#include<will/window.hpp>
#include<will/graphics.hpp>
#include<will/audio.hpp>
#include<will/dwm.hpp>
int WINAPI _tWinMain(HINSTANCE hInst, HINSTANCE hPrevInst, LPTSTR pCmdLine, int showCmd){return *will::do_([&]{
auto _ =+ will::com_apartment::initialize(will::com_apartment::thread::multi);
auto wc =+ will::window::class_::register_(
will::window_class::property()
.background(CreateSolidBrush(0))
.class_name(_T("D2DTest"))
.instance_handle(hInst)
.style(CS_HREDRAW | CS_VREDRAW));
auto w =+ wc.create_window(
will::window::property()
.title(_T("D2DTest"))
.style(WS_OVERLAPPEDWINDOW)
.x(0).y(0)
.w(640)
.h(480));
will::blur_behind{}.enable()(w.get_hwnd());
auto d2ddevcont = will::hwnd_render_target::create(w);
auto bitmap = will::do_<will::hresult_error>([&]{
auto wic = +will::wic::create_factory();
return d2ddevcont--->create_bitmap(+wic.create_converter(L"test.png")).value();
});
auto effect = will::do_<will::hresult_error>([&]{return d2ddevcont--->create_effect(CLSID_D2D1GaussianBlur);}).value();
auto effect2 = will::do_<will::hresult_error>([&]{
const auto fs = will::two_dim::attribute<will::two_dim::wh<float>>(bitmap--->get_dip_size());
return d2ddevcont++.create_effect(CLSID_D2D1Scale)
++.set(D2D1_SCALE_PROP_CENTER_POINT, D2D1::Vector2F(0, 0))
++.set(D2D1_SCALE_PROP_SCALE, D2D1::Vector2F(640.f/fs.w, 480.f/fs.h))
++;
});
if(bitmap && effect && effect2)
*effect2 |= *effect |= *bitmap;
auto format = will::do_<will::hresult_error>([&]{return d2ddevcont--->create_format(will::dwrite::format::property().name(L"メイリオ").size(30.F)).value();});
auto brush = will::do_<will::hresult_error>([&]{return +d2ddevcont++.create_solid_color_brush(D2D1::ColorF(D2D1::ColorF::WhiteSmoke));});
const std::wstring str = L"test";
auto audio = will::media_foundation::startup().bind([&](auto&& mf){return will::audio::create(std::move(mf));});
audio.map([](auto&& a){a.set_volume(.5f);});
auto source = audio.bind([](auto&& a){return a.read_audio_file(L"test.m4a");});
will::audio::source::buffer<2> sound_buffer;
int t = 0;
w.messenger[WM_ERASEBKGND] = [](auto&&, auto&&, auto&&)->LRESULT{return 0;};
w.show();
while(true){
auto now = std::chrono::steady_clock::now();
source.map([&](auto&& s){
using namespace std::chrono_literals;
s.play(sound_buffer, 5500ms);
});
t += 10;
will::do_<will::hresult_error>([&]{effect++(D2D1_GAUSSIANBLUR_PROP_STANDARD_DEVIATION) = 5 * std::sin(t * 3.14f / 180) + 5.f;});
d2ddevcont.bind([&](auto&& dc){return dc.draw([&](auto&& x){
x.clear(D2D1::ColorF(0, 0.f));
if(effect2)
x.image(*effect2);
x.text(str, +format, D2D1::RectF(20, 20, 300, 100), +brush);
}).map([&]{
DwmFlush();
});});
const auto ret = w.message_loop(now, 60);
if(!ret)
return ret.get();
}
}).catch_exception<std::exception>([&](std::exception& e){
::MessageBoxA(nullptr, e.what(), "exception", MB_OK);
return 1;
});}
今回はtest.png
とtest.m4a
を読み込んで利用するプログラムとなっています.そしてこのプログラム,**メディアファイルがなければそれに関わる処理だけ飛ばして他の処理は実行します.**例えば,test.png
はあるけどtest.m4a
は無い,みたいな時に音声は再生しないけど画像の描画だけはする,という挙動になります.流石に
- COMの初期化に失敗する
- ウィンドウクラス・メインウィンドウの生成に失敗する
の2つはその場でプログラムを終了しますが,何故失敗したのかの原因は表示してくれます.
エラーハンドリングの方法
上のプログラムで使っている,エラーハンドリングを適切に行いながらプログラムを記述するための方法を紹介します.expected
を用いて提供されたAPIを使うときは,大体こんな感じになります.
if
チェックとoperator*
上のコードだとeffect
周りでちょっとだけ使ってます.無難ですが,処理内容がif
のスコープから外に出られないので当然値の生成には使えません23.副作用のみを期待する処理に対して使うことになります.
チェック付き値取り出し
value()
メンバ関数を呼ぶやつです.或いは,operator+
やoperator++
,operator--
24も同類です.今回のコードは主に演算子の方でやってます.こいつ単品ではエラーハンドリングはされないので,try
-catch
とかdo_()
とかを使って外でエラーハンドリングをする必要があります.
map
とbind
上のコードだと音声周りで使っています.単一のexpected
から値を取り出して処理する分には,中身(メンバ関数に渡す関数)は非常に素直に書き下せる,速度的にそこまで大きなオーバーヘッドがない,の2点が利点です.一方で,内部で発生したエラーを正しく外まで伝播させたい,複数のexpected
から値を取り出して処理したい,といった際にメチャクチャネストします25.あと,個人的にはmap
とbind
をよく間違えるのでつらいです.
do_()
今回新たに加えた関数do_()
を使ったもので,上のコードだと主に画像周りで使用しています.do_()
の中ではチェック付き値取り出しを好きなだけ行える26ので,特に複数のリソースや処理を扱いつつ不正値を外部に伝播する場合,中身はexpected
の値に片っ端から+
を付けていくだけであとはそのまま書き下せるのでかなり楽に書いていけます.ただし,ハンドリングの実装が例外なので失敗したときのコストが比較的重いです.この辺は完全なシンタックスシュガーでは無いのでHaskellほど上手くはいきません…
expected
のもたらす利便と限界
さて,willは便利なのですがこの記事の本題はexpected
.この節では実際にWinAPIにexpected
を適用した際に感じたexpected
のもたらす利便とその限界について見ていきます.
利便
統一されたインターフェース
記事冒頭で話した通り,世の中にはエラー情報の扱い方がいくつもあります.WinAPIではGetLastError()
とHRESULT
が主に使われますが,API毎に異なるエラー情報を正しくハンドリングするのは非常に面倒ですし統一性に欠けます.その点,expected
を用いることで統一的なインターフェースを提供でき,多くのエラー処理が同じように記述することが出来ます.また,APIによってはGetLastError()
ではエラー情報が得られないものなどもあります.この差もexpected
を使えばドキュメントの注釈ではなく,型レベルで示す事ができます.
関数のインターフェースが綺麗になる
エラー値を返すために外部変数に書き込みにいったり,逆に処理の成功・失敗を戻り値にしてしまうがために処理の対象は参照やアドレスで引数から受けたり,といった実装上の都合からくる関数のインターフェースを汚くする問題を解消してくれるので,引数で入力をとり,戻り値で出力を返す,という基本的で本質的なインターフェースを維持できるようになります.
null安全
チェック付き値取り出しなどを使えば得られた値がヌルでないという保証が得られるので,null安全です27.
限界
RAIIと相性が悪い
リソースの初期化と解放を変数の生成と破棄に紐付けるRAII28という考え方があり,C++では標準ライブラリでも多用されています.これは当然,コンストラクタで初期化処理・デストラクタで解放処理という流れになります.しかし,Expectedで値を返そうとするとどう足掻いても値を生成するためのヘルパ関数が必須になってしまいます29.
また,解放処理はより深刻です.デストラクタは当然値は返せませんので,外部変数への書き込みを除けばエラーを伝播する唯一の方法は例外送出となります.しかし,C++ではデストラクタで例外送出するのはタブーとなっています28.そのため,解放処理をメンバ関数で別に実装してそちらを呼び出してもらう,或いは解放処理の際のエラーは破棄してRAII,という形にせざるを得ません.当然解放済みか否かを判断するためのフラグが必要になることもあります30.
速度と構文を両立できない
上の「エラーハンドリングの方法」でも出てきましたが,言語側からの支援無しには実行速度と構文の書きやすさを両立するのは難しいですね…この辺はexpected
の限界というよりC++の限界かもしれません.
デバッガが使いにくくなる
operator+
やbind
など(特に後者,ネストしてると最悪)を使うと,デバッガでステップ実行する時にステップインすれば見知らぬライブラリのコードに飛ばされ,ステップオーバーすれば(特に後者は渡してる関数の)中身の処理をまるっと飛ばし,という感じでまともな使用感ではなくなります.カーソル位置がどの処理を指しているのか,ステップオーバーしていい処理とステップインすべき処理の差異を判別でき,ちまちまステップインしていかないと従前の平たいコードのようにはデバッグできないことになります.
まとめ
- C++で綺麗にエラーハンドリングをするためのExpectedというライブラリがある
- 汎用性も高く非常に便利
- C++でやっていくにはどうしても厳しい面も無いことはない
謝辞
Media Foundationのサンプル実装を提供してくれたAzaikaくん,ありがとうございます.ちゃんとwillに載せました.
Either
モナドや関数型プログラミング言語に関する質問,API設計に関する相談に乗ってくれたphiくん,ありがとうございました.この手の話はやっぱりHaskellマンに聞くと経験が豊富なので非常に助かりました.
謝罪
11日オーバーです!!!!!!!!!!!メチャクチャ遅れました!!!!!!!!本当にごめんなさい!!!!!!!!!!!!!!!
明日はnatsu1211さんの『c++でLINQ実装してみた話』です. 空いてるので誰か書こう. yohhoyさんの『XOR swap今昔物語: sequence pointからsequenced-beforeへの変遷』です.
-
そういえば,VSにTPLくるらしいですね.やったぜ. ↩
-
特に後半で出てくる自作のライブラリはインターフェースの破壊的変更をしまくっているので(やめろ),大体コミット毎にそれまでのコードが通らなくなります… ↩
-
鳥頭なのであんまり覚えてないんですけど. ↩
-
ところで,記事中ではC++はレガシーなコードベースがたくさんあって全部を
std::optional<T>
に対応させるのは現実的ではないからnull安全ではないレガシー言語,とされていますが,「うるせぇラッパーを書けよ」という感想です.そもそもコードベース全部使うわけでもないし,コードベースの無い言語で一から作るよりはラッパー書いた方が早いんじゃないかと思います,知らないですけど.あと,boost::optional<T>
の登場からもう10年以上経ってるので,対応してないコードベースがクソなだけだと思うしそんなクソコードさっさと捨てた方がいいと思います(適当) ↩ ↩2 -
私はそういう認識なんですけど合ってますかね? ↩
-
ここで述べる問題以外に,細かなパフォーマンスの低下(の可能性)といった ゼロオーバーヘッド原則と相反する 点について白山風露さんの『null安全な言語は、本当にゼロコストか』で言及されています. ↩
-
実装定義の値としてエラーの内容によって異なる値を返すもの. ↩
-
ここでいう「null安全」とは,上述の記事で定義されている通り「Optional や Option, Maybe, nullable type などの型で実現できる、 null が原因で実行時エラーを起こさない性質のこと」です. ↩
-
「顧客が本当に必要だったのはnull安全ではなく
Either
だったんだよ!」 勿論,正常値が特定の型で表され,それとは別に失敗が単一の不正値のみで表現される処理なのであればnullable typeで十分表現が可能です.しかし上述の通り現実としてはエラーの内容を知りたいですし,Either
相当のものも必要になってきます.ライブラリ実装ではなく言語機能としてnullable typeを提供している言語は同等のサポートをするために態々Either
相当のものを言語機能として提供する必要に迫られますが,正直これは筋が悪いんじゃねーかと思います(雑感) ↩ -
標準ライブラリにあるとは言っていない. ↩
-
あまり行儀は良くないのですが,後半で名前空間が
boost
ではなくなるのでそこをあまり表に出したくない,という事情からこのようにしました. ↩ -
Haskellの
Either a b
に対してexpected<b, a>
となるので気をつけましょう. ↩ -
make_unexpected(e)
によってunexpected_type<E>
型の値となり,これがexpected<T, E>
へ暗黙変換可能.ちなみに,unexpected_type
がunexpected
でないのは標準入りさせる段階でstd::unexpected()
との名前衝突を避けるためだと思われる. ↩ -
正常値とエラー値双方が同じ型である場合(
expected<T, T>
),operator *
でエラー値がチェックなしに取り出せてしまいます(この挙動は言語規格上保証されています). ↩ -
例えば正常値が入った
expected<double>
からerror()
でstd::exception_ptr
を取り出してstd::rethrow_exception(std::exception_ptr)
で例外を再送出しようとした場合,当然参照先はでたらめなのでdangling referenceなどが発生しえます. ↩ -
現実としてここでコケるのって多分
bad_alloc
だと思うので,その状態でstd::string
を返すのは無謀かなと思ってやめておきました(SSOのことを考えればおそらくどちらも動的メモリ確保は行われなさそうな気もするんですけど) ↩ -
operator*
でチェック無しのアクセス,value()
メンバ関数でチェックありのアクセス,という設計.std::vector
のoperator[]
とat()
メンバ関数みたいに,「演算子は速度優先,安全寄りに倒したやつはメンバ関数で提供」って考え方がある気がする[要出典]. ↩ -
実際には
error_traits
のmake_error_from_current_exception
内でmake_error
の部分が通るかどうか,というのが問題なので,error_traits<std::string>
をちゃんと作って,make_error
でe.what()
を使って構築する,といった実装を書けばstd::exception
から構築できない型でも例外を捕まえる実装で動作しますし,(あまりよろしくはないですが)例外を握りつぶす実装を書けばどんな型でも動くことは動きます.ただ,そんなerror_traits
をライブラリ側で提供するかと言われれば当然しません. ↩ -
正確には「発生した例外を不正値として外に返す」関数です.チェック付きのアクセスに失敗した際例外送出されることを利用しています. ↩
-
これにより対応コンパイラがVS2017RC以降となりました.やったぜ. ↩
-
当社比 可能にはなりましたが,もちろんエラーハンドリングをしない,という選択肢もとれます(その辺はユーザーに委ねられる). ↩
-
UIスレッドで音声周りを弄るのは一般にオススメされないので良い子のみんなはちゃんとサウンドスレッドを別に立ててそっちで弄ろうな!おっさんとの約束だぞ! サンプルコードなので許して欲しい ↩
-
リソースを生成して,それを束縛する変数を
if
のスコープに閉じ込めるとその後ろで使えないよね,というお話.その値を使うコードと使わないコードを両方書いてifで分岐すれば一応出来ますが,組合せ爆発するので考慮しないものとします. ↩ -
え?どこにも
operator--
が見当たらない?--->
の前半分です. ↩ -
今回だと,
effect2
の生成部分をbind
で書いたりするとそこそこネストします.また,willの実装では(オーバーヘッドを減らすために)主にこの手法を多用したため,8段bind
+1map
みたいな地獄が発生してます(逆に言えば,ライブラリでそこまでやってるのでユーザーコードではそこまで出てこないでも済んでいるわけです). ↩ -
チェックで不正値だった場合,その値を正しく外部に伝播させるためには
map
/bind
だと副作用のみを期待する処理でも(不正値を外部に伝播するためだけに)ネストしていく必要がありましたが,do_()
ならチェックさえかければ済む(チェックはoperator+
を適用するだけな)ので大分簡単になります. ↩ -
ここでいうnull安全とは,「null が原因で実行時エラーを起こさない性質のこと」です. ↩
-
勿論,エラーハンドリングを適切に行わずnull安全性を担保できない状況よりはマシですし,コンストラクタからExpectedを返す関数を呼び出してチェック付き値取り出しで中身を取り出せば一応コンストラクタからの初期化も可能です. ↩
-
こちらも,解放処理をデストラクタで呼び出して結果を無視すればデストラクタで例外を投げずに破棄できるようになるので実装する際そこまで重くはないですし,実際には標準ライブラリも概ね同様の方針(解放処理をメンバで呼び出して事前にエラーハンドリング込みの解放できる形式)で設計されているのですが…感覚的にちょっとね… / そもそも,解放処理で発生したエラーに対しての対応なんてログを出力するぐらいしか出来ないだろ,という話もある ↩