前書き
この記事はC++ Advent Calendar 2018 21日目の記事です。
- 20日目 みんなも使おうif constexpr
はじめに
プログラミング……というより何らかの仕組みの開発において、変更の可能性(カスタマイゼーションポイント)がある部分とそうでない部分を分離し、カスタマイズを簡単に適用できるようにすることが望ましいです。たとえば、Allocatorの概念は、メモリ割り当て方法の変更に対処するための抽象的な方法として使用されます。あるいは、基本型の振る舞いが派生型の仕様に従って変わる場合は、無駄なコードを書かないようにCRTPというパターンを使用します。あるいは、SFINAEを使って、表現が適合か不適合かを調べることもできます。そのため、 void_t
や検出イディオムなどが適用されます。
さて、本記事で紹介しようとするデザインのパターンは以下のような問題を解決するために備えるパターンです。
問題提起、あるいは序章
あれは今から36万…いや14000年前の話だったか......仕様書には入力がjson文字列の形を要求した。その入力を得るためまずjsonの文字列をパースしなければならないです。もちろんこれは簡単に解決することができます。あのときは省エネをして面倒を避けるため、直接にkazuho氏が開発したpicojson
を使うことにしました。
boost::string_view input = XXXXXXXXX;
using std::begin;
using std::end;
picojson::value input_v;
if(!picojson::parse(input_v, begin(input), end(input)).empty()){
// error handling
}
が、問題はこれだけではありません。仕様書に載った実装すべき機能によって入力オブジェクトの構造と引数の位置も違ってます。例えばある機能には入力が以下の形で載ってます
{ "param1": <引数param1、型はinteger>, "puyopuyo": { "param2": <引数param2、型はnumeric> }, "hogehoge": [ <引数param3、型はstring>, <引数param4、型はnumeric> ] }
その構造を応じて入力のjsonオブジェクトから引数を取り出すには、当該のオブジェクトが規定された構造と合うかどうかのを判定、合うときは構造によって引数を一つずつ取り出す機能の実装が必要。もちろん特定な構造によってその機能がコードで実装することができます。例えば以上の要件は以下のようなコードになります:
std::intmax_t param1;
double param2;
std::string param3;
double param4;
{
if(!input_v.is<picojson::object>){
// error handling
}
const picojson::object &input_obj = picojson::get<picojson::object>();
auto param1_it = input_obj.find("param1"s);
if(param1_it == input_obj.cend()){
// error handling
}
const picojson::value ¶m1_v = param1_it->second;
if(!param1_v.is<double>()){
// error handling
}
param1 = static_cast<std::intmax_t>(param1_v.get<double>());
auto puyopuyo_it = input_obj.find("puyopuyo"s);
if(puyopuyo_it == input_obj.cend()){
// error handling
}
const picojson::value &puyopuyo_v = puyopuyo_it->second;
if(!puyopuyo_v.is<picojson::object>()){
// error handling
}
const picojson::object &puyopuyo_obj = puyopuyo_v.get<picojson::object>();
auto param2_it = puyopuyo_obj.find("param2"s);
if(param2_it == puyopuyo_obj.cend()){
// error handling
}
const picojson::value ¶m2_v = param2_it->second;
if(!param2_v.is<double>()){
// error handling
}
param2 = param2_v.get<double>();
auto hogehoge_it = input_obj.find("hogehoge"s);
if(hogehoge_it == input_obj.cend()){
// error handling
}
const picojson::value &hogehoge_v = hogehoge_it->second;
if(!hogehoge_v.is<picojson::array>()){
// error handling
}
const picojson::array &hogehoge_arr = hogehoge_v.get<picojson::array>();
const picojson::value ¶m3_v = hogehoge_arr[0];
if(!param3_v.is<std::string>()){
// error handling
}
param3 = param3_v.get<std::string>();
const picojson::value ¶m4_v = hoge_hoge_arr[1];
if(!param4_v.is<double>()){
// error handling
}
param4 = param4_v.get<double>();
}
細やかな処理を省略しても結構長くなってしまい、可読性もほとんど保ってなさそうなコードになります。しかも違う機能によって入力が従うべきな構造も違ってます。全部の構造に対し上の書き方のように入力の適合さの確認を実装すると、結構大変な負債になってしまうでしょう。
コンビネータを組み合わせよう
以上のような問題は、大抵基本的な動きの実装が簡単に実装できますが、その基本的な動きの組み合わせに変更がかかりやすくなっています。仕様が変わり、その組み合わせが変わると、コードもそれを応じて結構長い変更を行わなければなりません。「コンビネータを組み合わせる」というテクニックは、そういう問題を楽に解決するために使われた抽象方法です。
「コンビネータ」…というのは、自分が作った用語かもれしませんが、一言というと組み合わせができて、作ったコードの行為がその組み合わせの流れに従って一定の機能ができることにするというものなんです。コンビネータは組み合わせの方法が使いやすさが高いと目指す(もちろん組み合わせの方法とその使いやすさが言語の機能によって違ってます)ので、前に言った「組み合わせの変更が多い」という問題がコンビネータの組み合わせと完璧な対応になります。
問題の解決
「jsonオブジェクトの構造の判定」という問題もコンビネータとその組み合わせで解決可能なんです。当時自分が選択した解決策が以下です:
- 基本的な動きを務まるコンビネータを決めます。
integer
/numeric
とstring
の型を持つjsonオブジェクトから引数の値を取り出すため、コンビネータがそういう風に設定してます。
auto param1_rule = rule::num_<std::intmax_t>() >> param1;
auto param2_rule = rule::num_<double>() >> param2;
auto param3_rule = rule::string_() >> param3;
auto param4_rule = rule::num<double>() >> param4;
- 組み合わせの方法を決めます。jsonオブジェクトだと
array
とobject
が他のオブジェクトから新たなオブジェクトを構築するので、以上のコンビネータの組み合わせのパターンによって定めます:
auto puypuyo_rule = rule::object_(
rule::pair_("param2"s, param2_rule)
);
auto hogehoge_rule = rule::array_()[
param3_rule,
param4_rule
];
auto input_rule = rule::object_(
rule::pair_("param1"s, param1_rule),
rule::pair_("puyopuyo"s, puyopuyo_rule),
rule::pair_("hogehoge"s, hogehoge_rule)
);
そしてその組んだものを適用するとこうなります:
std::intmax_t param1;
double param2;
std::string param3;
double param4;
auto input_rule = rule::object_(
rule::pair_("param1"s, rule::num_<std::intmax_t>() >> param1),
rule::pair_("puyopuyo"s, rule::object_(
rule::pair_("param2", rule::num_<double>() >> param2)
)),
rule::pair_("hogehoge"s, rule::array_()[
rule::string_() >> param3,
rule::num_<double>() >> param4
])
);
if(!input_rule.validate(input_v)){
// error handling
}
// do something with input parameters
最初の実装コードよりすっごく短くて分かりやすさも高いコードになったでしょう。
実装
実装は自明だと思います。要するには基本のコンビネータと組み合わせのによります。当時自分が実行時オーバーヘッドを避けたくてより簡単な実装方法を選びたいため、C++の型システムの力を以て型の組み合わせで「式テンプレート」という実装を選択することにしました。もちろん、実装方法はそれに限ることではありません。実行時オーバーヘッドを入れて、部分型多相でちょっとだけ複雑になる実装も可能だと思います。それにJavaやGolangなどの言語にはrank-2パラメータ多相さえもサポートしてないので部分型多相で複雑な実装しか行われないのでやっぱJavaやGolangはC++より難しい言語なのでしょう。
自分も自分が書いた実装を将来公開する予定です。が、将来公開すると予定するとは言ってません。
後書き
「組み合わせのロジックを変更しやすくなる」というのは本記事で紹介されたパターンが一番重視する効果です。ですが、その効果を達成するには決してそのパターンしかに依ることではありません。DSLも、式テンプレートも、その効果として相当良い抽象方法と思います。というより、本記事で紹介されたパターンは、言語の機能を善用して実装と使用がやすくなるDSLを作るというべきだと思います。