エラーハンドリングを綺麗にこなすためのライブラリ・expectedの紹介と応用

  • 13
    いいね
  • 0
    コメント

この記事は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()/errnoHRESULTと例外(と,物によりますが,実装定義の値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()が定義されているので,ifwhileの条件式とかでは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に自身を渡した結果を返します.fexpected<U, X>(UXは任意の型)を返すのであればexpected<T, E>からdecltype(f(expected<T, E>{}))(expected<U, X>)を,fexpectedでない型を返すのであれば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つのメンバ関数の中から,今回はthenbindmapを使うことで,以下のようにスッキリ書けます.

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)の値を得ます.次に,thendivide(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--がチェックして正常値のアドレスを返すという挙動になります.
  • has_error()error_or()メンバ関数の実装
    • 元々のExpectedだとExpected algorithmとして外部関数で実装されていたものをメンバ関数に取り入れた形
  • mapなどのメンバ関数に渡した関数fで例外が発生した際,エラー型Eによっては例外をキャッチするように
    • これも元々のExpectedで事前にマクロを定義しておけば使える機能ではあったのですが,Estd::exceptionで構築できる型に制約される18という大問題があったので,Eによって例外をキャッチする実装としない実装に振り分けるようにしました.
  • do_()関数を追加
    • Haskellのdo構文みたいな感じで,中でexpectedを展開した際不正値であればそこで処理を中断してその値を返す関数19です.
  • 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.pngtest.m4aを読み込んで利用するプログラムとなっています.そしてこのプログラム,メディアファイルがなければそれに関わる処理だけ飛ばして他の処理は実行します.例えば,test.pngはあるけどtest.m4aは無い,みたいな時に音声は再生しないけど画像の描画だけはする,という挙動になります.流石に

  • COMの初期化に失敗する
  • ウィンドウクラス・メインウィンドウの生成に失敗する

の2つはその場でプログラムを終了しますが,何故失敗したのかの原因は表示してくれます.

エラーハンドリングの方法

 上のプログラムで使っている,エラーハンドリングを適切に行いながらプログラムを記述するための方法を紹介します.expectedを用いて提供されたAPIを使うときは,大体こんな感じになります.

ifチェックとoperator*

 上のコードだとeffect周りでちょっとだけ使ってます.無難ですが,処理内容がifのスコープから外に出られないので当然値の生成には使えません23.副作用のみを期待する処理に対して使うことになります.

チェック付き値取り出し

 value()メンバ関数を呼ぶやつです.或いは,operator+operator++operator--24も同類です.今回のコードは主に演算子の方でやってます.こいつ単品ではエラーハンドリングはされないので,try-catchとかdo_()とかを使って外でエラーハンドリングをする必要があります.

mapbind

 上のコードだと音声周りで使っています.単一のexpectedから値を取り出して処理する分には,中身(メンバ関数に渡す関数)は非常に素直に書き下せる,速度的にそこまで大きなオーバーヘッドがない,の2点が利点です.一方で,内部で発生したエラーを正しく外まで伝播させたい,複数のexpectedから値を取り出して処理したい,といった際にメチャクチャネストします25.あと,個人的にはmapbindをよく間違えるのでつらいです.

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への変遷』です.



  1. そういえば,VSにTPLくるらしいですね.やったぜ. 

  2. 特に後半で出てくる自作のライブラリはインターフェースの破壊的変更をしまくっているので(やめろ),大体コミット毎にそれまでのコードが通らなくなります… 

  3. 鳥頭なのであんまり覚えてないんですけど. 

  4. ところで,記事中ではC++はレガシーなコードベースがたくさんあって全部をstd::optional<T>に対応させるのは現実的ではないからnull安全ではないレガシー言語,とされていますが,「うるせぇラッパーを書けよ」という感想です.そもそもコードベース全部使うわけでもないし,コードベースの無い言語で一から作るよりはラッパー書いた方が早いんじゃないかと思います,知らないですけど.あと,boost::optional<T>の登場からもう10年以上経ってるので,対応してないコードベースがクソなだけだと思うしそんなクソコードさっさと捨てた方がいいと思います(適当) 

  5. 私はそういう認識なんですけど合ってますかね? 

  6. ここで述べる問題以外に,細かなパフォーマンスの低下(の可能性)といった ゼロオーバーヘッド原則と相反する 点について白山風露さんの『null安全な言語は、本当にゼロコストか』で言及されています. 

  7. 実装定義の値としてエラーの内容によって異なる値を返すもの. 

  8. ここでいう「null安全」とは,上述の記事で定義されている通り「Optional や Option, Maybe, nullable type などの型で実現できる、 null が原因で実行時エラーを起こさない性質のこと」です. 

  9. 「顧客が本当に必要だったのはnull安全ではなくEitherだったんだよ!」 勿論,正常値が特定の型で表され,それとは別に失敗が単一の不正値のみで表現される処理なのであればnullable typeで十分表現が可能です.しかし上述の通り現実としてはエラーの内容を知りたいですし,Either相当のものも必要になってきます.ライブラリ実装ではなく言語機能としてnullable typeを提供している言語は同等のサポートをするために態々Either相当のものを言語機能として提供する必要に迫られますが,正直これは筋が悪いんじゃねーかと思います(雑感) 

  10. 標準ライブラリにあるとは言っていない. 

  11. あまり行儀は良くないのですが,後半で名前空間がboostではなくなるのでそこをあまり表に出したくない,という事情からこのようにしました. 

  12. HaskellのEither a bに対してexpected<b, a>となるので気をつけましょう. 

  13. make_unexpected(e)によってunexpected_type<E>型の値となり,これがexpected<T, E>へ暗黙変換可能.ちなみに,unexpected_typeunexpectedでないのは標準入りさせる段階でstd::unexpected()との名前衝突を避けるためだと思われる. 

  14. 正常値とエラー値双方が同じ型である場合(expected<T, T>),operator *エラー値がチェックなしに取り出せてしまいます(この挙動は言語規格上保証されています). 

  15. 例えば正常値が入ったexpected<double>からerror()std::exception_ptrを取り出してstd::rethrow_exception(std::exception_ptr)で例外を再送出しようとした場合,当然参照先はでたらめなのでdangling referenceなどが発生しえます. 

  16. 現実としてここでコケるのって多分bad_allocだと思うので,その状態でstd::stringを返すのは無謀かなと思ってやめておきました(SSOのことを考えればおそらくどちらも動的メモリ確保は行われなさそうな気もするんですけど) 

  17. operator*でチェック無しのアクセス,value()メンバ関数でチェックありのアクセス,という設計.std::vectoroperator[]at()メンバ関数みたいに,「演算子は速度優先,安全寄りに倒したやつはメンバ関数で提供」って考え方がある気がする[要出典]. 

  18. 実際にはerror_traitsmake_error_from_current_exception内でmake_errorの部分が通るかどうか,というのが問題なので,error_traits<std::string>をちゃんと作って,make_errore.what()を使って構築する,といった実装を書けばstd::exceptionから構築できない型でも例外を捕まえる実装で動作しますし,(あまりよろしくはないですが)例外を握りつぶす実装を書けばどんな型でも動くことは動きます.ただ,そんなerror_traitsをライブラリ側で提供するかと言われれば当然しません. 

  19. 正確には「発生した例外を不正値として外に返す」関数です.チェック付きのアクセスに失敗した際例外送出されることを利用しています. 

  20. これにより対応コンパイラがVS2017RC以降となりました.やったぜ. 

  21. 当社比 可能にはなりましたが,もちろんエラーハンドリングをしない,という選択肢もとれます(その辺はユーザーに委ねられる). 

  22. UIスレッドで音声周りを弄るのは一般にオススメされないので良い子のみんなはちゃんとサウンドスレッドを別に立ててそっちで弄ろうな!おっさんとの約束だぞ! サンプルコードなので許して欲しい 

  23. リソースを生成して,それを束縛する変数をifのスコープに閉じ込めるとその後ろで使えないよね,というお話.その値を使うコードと使わないコードを両方書いてifで分岐すれば一応出来ますが,組合せ爆発するので考慮しないものとします. 

  24. え?どこにもoperator--が見当たらない? --->の前半分です. 

  25. 今回だと,effect2の生成部分をbindで書いたりするとそこそこネストします.また,willの実装では(オーバーヘッドを減らすために)主にこの手法を多用したため,8段bind+1mapみたいな地獄が発生してます(逆に言えば,ライブラリでそこまでやってるのでユーザーコードではそこまで出てこないでも済んでいるわけです). 

  26. チェックで不正値だった場合,その値を正しく外部に伝播させるためにはmap/bindだと副作用のみを期待する処理でも(不正値を外部に伝播するためだけに)ネストしていく必要がありましたが,do_()ならチェックさえかければ済む(チェックはoperator+を適用するだけな)ので大分簡単になります. 

  27. ここでいうnull安全とは,「null が原因で実行時エラーを起こさない性質のこと」です. 

  28. 詳しくはこちら 

  29. 勿論,エラーハンドリングを適切に行わずnull安全性を担保できない状況よりはマシですし,コンストラクタからExpectedを返す関数を呼び出してチェック付き値取り出しで中身を取り出せば一応コンストラクタからの初期化も可能です. 

  30. こちらも,解放処理をデストラクタで呼び出して結果を無視すればデストラクタで例外を投げずに破棄できるようになるので実装する際そこまで重くはないですし,実際には標準ライブラリも概ね同様の方針(解放処理をメンバで呼び出して事前にエラーハンドリング込みの解放できる形式)で設計されているのですが…感覚的にちょっとね… / そもそも,解放処理で発生したエラーに対しての対応なんてログを出力するぐらいしか出来ないだろ,という話もある