型を使って意味を表現する
この記事はC++アドベントカレンダー2017の11日目の記事です。
C++は静的型付けです。皆さん型使ってますか? 型をつけましょう。
どうせ型をつけるなら、上手くやっていきましょう。型を効果的に使えば、関数なんかも宣言を見ただけで何をするかだいたいわかるようになります。
型はコンパイラがチェックしてくれるという利点があります。これは利点です。コンパイラは謎のメッセージを出して怒り狂う予測できない荒ぶる神ではなく、こちらのミスを指摘してくれる優しいチューターです。こちらがしたいことを型のレベルで表現すれば、我々がミスをした時にコンパイラは助けになってくれます。
今回の記事の内容は、一言で言うと「起き得ない状態を起き得なくする」ということです。言葉を足すと、「意味的に起き得ない状態は取れないような型を使う」ということです。
例が必要でしょう。上記の気持ちを中心に据えて、いくつかの機能の使い方を紹介します。
optional
optionalは、「期待する型の有効な値を持つか、または値が存在しない」ということを意味する型です。
それはどういうことでしょう? 「起き得ない状態を起き得なくする」という観点から、これはどういう点で使えるのでしょう。
例:無いかも知れないメンバ変数
例えば何かのプロジェクトで、ユーザーはあるオブジェクトに正確な名前の他にエイリアスを付けられるとしましょう。どうなるでしょうか。
struct ID
{
std::string name;
std::string alias;
bool has_alias;
};
このクラスはどのような状態をとり得るでしょうか。
-
has_alias() == true
&&
!alias.empty()
-
has_alias() == true
&&
alias.empty()
-
has_alias() == false
&&
!alias.empty()
-
has_alias() == false
&&
alias.empty()
ここで、状態1は正しいです。has_alias
が真で、かつalias
が値を持っている。あり得る状態です。また、状態4も正しいです。has_alias
は偽であり、またalias
自体も空です。他はどうでしょう。状態2で、has_alias
が真なのにalias
が空なら、どうすればいいでしょうか。空文字列がalias
なのでしょうか。それを許可しているなら構いませんが、少し奇妙な状態です。また、状態3も怪しいです。has_alias
は偽なのに、alias
には何かが入っています。この場合どちらを優先させればいいのでしょうか。
このような実装をした場合、alias
の値とhas_alias
の値に齟齬があってはいけません。これらを操作するときは、全ての段階でこの2つを同時に扱い、常に値を同期させておかねばなりません。これは面倒です。
optionalを使うとどうなるでしょうか。
struct ID
{
std::string name;
std::optional<std::string> alias;
};
同じことを意味できていることは明らかです。alias
はoptionalです。あってもなくても構いません。そして値がある場合は、std::string
としてとり得る全ての値を取れます。
先に述べた問題が解消されていることにお気づきでしょうか。std::optional
なら、値が存在してある具体的な値を持つか、値がないかのどちらかです。値が存在しないのに具体的な値が入っていることはありません。alias.has_value()
かつalias.value().empty()
はあり得ますが、この場合aliasがないのだろうか、と悩むことはありません。alias
の値は あります。 なので平然と空文字列を使うか、エイリアスが空だというエラーを出していいわけです――少なくとも意味的には。
恐らく空文字エイリアスは、ユーザーの誤入力か、alias
を削除する際にstd::nullopt
の代入もしくはstd::optional::reset
の呼び出しでなくstd::string::clear
を使ってしまったことによるものでしょう。そうとわかれば修正も簡単です。
ここで少しstd::optional
に付随する文法を紹介しておきましょう。
{
std::optional<std::string> optstr;
std::cout << std::boolalpha << optstr.has_value() << std::endl; // "false"
}
{
std::optional<std::string> optstr = "Hello, optional!"s;
std::cout << std::boolalpha << optstr.has_value() << std::endl; // "true"
if(optstr.has_value())
{
std::cout << optstr.value() << std::endl; // Hello, optional!
}
optstr = std::nullopt; // 破棄
std::cout << std::boolalpha << optstr.has_value() << std::endl; // "false"
}
値があるかどうか確認するためにはstd::optional::has_value()
を、値にアクセスするためにはstd::optional::value()
を使います。また、既に値を持っているstd::optional
は、std::nullopt
を代入することによって無の状態に戻せます。
値を取り出すとき、もし無だったら代わりの値を使いたいという場合はあるでしょう。デフォルトの値が決まっているときはその方が便利です。そのときは、std::optional::value_or
を使います。
std::optional<int> answer = std::nullopt;
std::cout << answer.value_or(42) << std::endl; // 42
ちなみに値を持っていないstd::optional
の値を無理やり取り出そうとすると、std::bad_optional_access
例外が投げられます。
例:してもしなくても良い処理
他に、外部の状況によってクラスのメンバ内でするべきかどうか変わる処理、というのもあり得ます。特にその処理が外部からパラメータを受け取る場合を考えます。
例えば分子ビューワを作っているとしましょう。原子間の結合を棒で描きたいかどうか、その太さも外部から設定できるようにしたいとします。
class Visualizer
{
bool draw_bond_;
double bond_width_;
Molecule molecule_;
public:
void visualize();
void render_bond();
};
void Visualizer::visualize()
{
// do something...
if(this->draw_bond_)
{
this->render_bond();
}
return;
}
先ほどの例に比べると、デフォルトの値に苦労はしないかもしれません(もう少しよい例を思いつけばよかったのですが)。draw_bond_ == false
の時、bond_width
の値は恐らく0
でしょう。それでも、ならdraw_bond_
はなくて良いのではとか、引き算したら数値誤差でbond_width
が非常に小さいけれど0でない値になった場合はとか、考えないといけないエッジケースは少し残ります。
この場合も、std::optional
が使えます。外からパラメータ設定済みのオブジェクトを代入できるので、
class BondRenderer
{
double width;
public:
void render(Molecule const&);
}
class Visualizer
{
std::optional<BondRenderer> renderer_;
Molecule molecule_;
public:
void visualize();
};
void Visualizer::visualize()
{
// do stuff...
if(this->renderer)
{
this->renderer_->render(this->molecule_);
}
return;
}
こうすれば、関数を呼ばなくて良い時にそのデフォルトパラメータをどうするか考慮しなくて済みますし、フラグとパラメータの値を同期させておく手間もありません。
外部から来るかもしれないパラメータをデフォルト構築するというのは少し面倒です。デフォルトパラメータの値によってはわかりにくいバグが仕込まれることでしょう。そのパラメータをoptional
にしておくというのも悪くはないです。
が、今回の場合、存在するかしないかわからないのは「関数のパラメータ」というより「実行すべき関数そのもの」なので、関数オブジェクト(今回は呼び出しがoperator()
ではないですが……)を作ってそれをoptional
にしてした方が素直に思えます。
例:存在しない可能性のある値の取得
存在しない可能性のある値、と言われて何か思いつくでしょうか。例えば、std::vector::at
やstd::map::at
が返すものがそうです。std::vector::at
はインデックスがsize
を超えていればおかしな領域を見に行きますし、std::map::at
もキーがなければ返すものがありません。これらは現在std::out_of_range
を投げることでエラーを通知しますが、別の実装として以下のようなこともできたはずです。
std::optional<value_type const&> vector<T, Alloc>::at(size_type i) const noexcept;
std::optional<mapped_type const&> map<K, V, Cmp, Alloc>::at(key_type i) const noexcept;
このように実装していた場合、対応する値がなかった時、エラーが例外越しに通知されるのではなく、「無」が返ってきます。
const auto val = vec.at(N);
if(val.has_value())
{
// do stuff
std::cout << "vector has the N-th value!: " << val.value() << std::endl;
}
else
{
// fallback
std::cout << "vector does not have the N-th value..." << std::endl;
}
受け取った側は、このようにして値があるかどうかを確認することができます。というより、必要な値を得るためには、一度値があるかどうか確認する処理を書く必要があります。
ところで、古来からこのような用途にポインタが使われてきました。失敗するとnullptr
が返ってくる、ということです。面倒なことを全て脇に置けば、失敗したらnullptr
で、成功したら値を指すポインタが渡されるというのはまさしくoptional
と言えるでしょう。そのせいか、std::optional
ではポインタライクな文法を実現するための演算子が定義されています。
const auto val = optvec.at(N);
if(val)
{
std::cout << "vector has the N-th value!: " << *val
<< " and its first value is " << val->first << std::endl;
}
まさにポインタですね。この文法に馴染んでいた人は、このコードを理解するのに苦労しないでしょう。has_value
を明示的に書くこともできるので、ポインタでのやり取りに慣れていない人にはそちらの方がわかりやすいとは思いますが。
ではポインタを返せばよいではないか、と思う人のために、ポインタに比べての利点を挙げてみます。インターフェースがより豊富な点は重要です。わかりやすい名前の関数を使うことができ、コードが誰にとっても読みやすくなります。また無理にアクセスした時には未定義動作ではなく例外が飛び、デバッグが容易になります。
コンテナアクセス以外の返す値が無いかも知れない処理で使う場合は、これらの他にもメリットを挙げられます。optional
は動的確保を行わず、内部に確保した領域を使うので(N4659 23.6.3)、何度もnew
してオブジェクトがメモリ上の至るところに分散してキャッシュ効率が落ちたり、アクセスするときにポインタを経由する手間が増えたりしません。delete
の必要もなく、リソース管理が楽です。
これらがポインタよりもstd::optional
をおすすめする理由です。
variant
variantは、あり得る型のどれか一つだけを取る型です。ある時点で保持している値は最初に列挙しておいた型のどれか一つに対応する値であり、それ以外の型を持ったり同時に複数の型の値を持ったりはしません。
それはどういうことでしょう? 「起き得ない状態を起き得なくする」という観点から、これはどういう状況で使えるのでしょう。
例:ゲームAI
あなたがゲームを作っているとしましょう。そして敵キャラクターはある程度賢く、無警戒で何もしていない状態、規定のルートを巡回している通常の状態、異変を感知して調べようとしている状態、主人公を発見して追いかけてくる状態の4状態を取るとします。
std::variant
を使わない実装はどうなるでしょうか。
class Enemy
{
public:
enum class State : std::uint8_t
{
idling,
normal,
watching,
engaged,
};
State state;
Position position;
Position strange;
Identifier target;
};
変数名は5秒で決めたのであまり深く考えないで下さい。
state
はenum
で敵の状態を、position
は敵の現在位置を、strange
は異変を見つけた(主人公が敵からは見えないところで発砲したなど)大まかな位置、target
はプレイヤーなどを追いかけるための識別子とします。
このコードだと、例えばstrange
はwatching
状態でなければ必要がない値です。さらに、target
も戦闘中(engaged
)でないと意味を持ちません。とはいえこの実装では、状態に依らずこの値を持つことになります。すると、戦闘中でない敵のtarget
にアクセス「できる」ことになります。アクセスしないことになってはいても、不注意でしてしまったりできるわけなので、少し不安が残ります。
加えて、このクラスは本来必要ない量のメモリを要求します。同時に2状態を取ることはないので、strange
とtarget
を同時に保持できる量のメモリは必要ないはずですが、この実装ではその両方を格納できる量のメモリを要求します。
ミスの可能性は人間の根性・努力ではなく設計で回避しましょう。std::variant
は両方の問題を解決してくれます。
class Enemy
{
public:
struct Idling{};
struct Normal{};
struct Watch{Position strange;};
struct Engage{Identifier target;};
Position position;
std::variant<Idling, Normal, Watch, Engage> status;
};
std::variant
は同時に2つの状態になりはしないので、status
に格納されている値は常に上記4状態のどれか一つだけになります。これで、誤ってIdling
中にtarget
にアクセスしてしまう、というのは(悪意を持って非常に面倒で危険なことをしない限り)起き得なくなりました。
position
は常にありますが、これはIdling
でもこのキャラクターがフィールドに登場していると想定しているからです。つまり、どの状態でも必ず持つ値だと考えたからです。もしIdling
状態が意味するところが「敵は生成されているもののフィールドには存在しない」ということなら、position
もNormal, Watch, Engage
の中に入れてしまっていいでしょう。
また、std::variant
は格納すべき型のうち最大の型のサイズ分だけのメモリ(と、何を格納しているかを示すstd::size_t)しか要求しません。なので、メモリ量の問題も解決しています。
ではアクセスするにはどうしたらいいでしょうか。元のクラス、std::variant
を使わない場合は、これはenum
でスイッチするでしょう。
class Enemy
{
public:
enum class State : std::uint8_t
{
idling,
normal,
watching,
engaged,
};
State state;
Position position;
Position strange;
Identifier target;
};
void process(Enemy const& em)
{
switch(em.state)
{
case Enemy::State::idling:
return process_idling_enemy(em);
case Enemy::State::normal:
return process_normal_enemy(em);
case Enemy::State::watching:
return process_watching_enemy(em);
case Enemy::State::engaged:
return process_engaged_enemy(em);
default:
throw std::logic_error("invalid enum state");
}
}
これと同様のことは、std::variant
でもできなくはないです。std::variant::index()
は、どの型を保持しているかを意味するstd::size_t
を返すので、以下のようにできます。このstd::size_t
はstd::variant
に登録した順になります。
class Enemy
{
public:
struct Idling{};
struct Normal{};
struct Watch{Position strange;};
struct Engage{Identifier target;};
Position position;
std::variant<Idling, Normal, Watch, Engage> status;
};
void process(Enemy const& em)
{
switch(em.index())
{
case 0:
return process_idling_enemy(em);
case 1:
return process_normal_enemy(em);
case 2:
return process_watching_enemy(em);
case 3:
return process_engaged_enemy(em);
default:
throw std::logic_error("invalid state");
}
}
ただ、これではあまりわかりやすくはありません。というのも、std::variant::index
が返すのはenum
と違ってstd::size_t
なので、コードにマジックナンバーが入ってしまうからです。実際、上のswitch-case
はstd::variant
に何か新しい値を入れた時はそれぞれの関数の内部を全て書き換えなければなりません。例えば、即座に出てくる方法としては、std::variant
を持つクラス内でenum
を作って名前を付けることでしょう。
class Enemy
{
public:
enum class Status : std::size_t
{
idling = 0,
normal = 1,
watching = 2,
engaged = 3,
};
struct Idling{};
struct Normal{};
struct Watch{Position strange;};
struct Engage{Identifier target;};
Status which() const noexcept {return static_cast<Status>(status.index());}
Position position;
std::variant<Idling, Normal, Watch, Engage> status;
};
これでもできますが、若干面倒です。第一、このenum
の対応する値は状態が増えた時は変更しないといけません。その程度、自動でやってほしいではないですか。
std::variant
にはより強力なアクセサが用意されています。大抵の場合、std::variant
に入れるようなクラスは意味的な繋がりがあり、同じ関数を適用することは多いでしょう。つまり、オーバーロードによって一纏めにしておけるわけです。
それらのオーバーロードをまとめたvisitor
というものを定義して適用すると、内部的なオーバーロード解決によって適切なものが選ばれます。つまり、
struct processer
{
Position pos_;
void operator()(Idling const&);
void operator()(Normal const&);
void operator()(Watch const&);
void operator()(Engage const&);
};
のようなものを用意しておけば、
void process(Enemy const& em)
{
return std::visit(processor{em.position}, em.status);
}
のようにして一気に適用できます。
ちなみに、ここでは明示的に全状態のオーバーロードを書きましたが、ジェネリックに書くことも出来ます。
例:パーサ
他にも、値として整数、浮動小数点数、文字列、配列を持つような言語のパーサを書くとき、そのvalue
クラスはまさしくstd::variant
になります。値は言語が許可している型のいずれかになり、またいずれか一つだけだからです。
struct value;
using integer = std::int64_t;
using floating = double;
using string = std::string;
using array = std::vector<value>;
struct value
{
std::variant<integer, floating, string, array> holder_;
};
C++17からstd::vector
の宣言に、条件付きですが不完全型を使えるようになりました(N4659 26.3.11.2)。なので上のようなことができます。たぶん。不完全型でもポインタのサイズは決まるので、ポインタしかメンバに持たないようにすれば(std::array
以外の)コンテナは不完全型を格納できます(ただし、メンバ呼び出しは型が決まってからである必要があります)。
それ以前だと、不完全型を持つ部分はポインタを持つようにしてそこだけ動的に管理するか、不完全型を許容して標準と同等のインターフェースを提供するboost::container::vector
を使うとよいでしょう。
空にならない保証
ところで、std::variant
は「あり得る型のどれか一つだけ」を取る型です。これはつまり、「どれでもない状態」にはならない、要するに「空にならない」ということです。std::variant
が空にならないことは保証されています。
それでもたまに、std::variant
を空にしたいことはあるでしょう。空か、ある型か、別の型、という風に。また、これは少し実際的な話ですが、デフォルトコンストラクタを持たない型しか使わない時はstd::variant
を具体的な値なしで初期化することができなくなります。この問題は空の状態を許容することで解決しますし、そういう状態が回避不可能というのは、そもそもその型は空の状態を必要としているということでしょう。
というわけで、空の状態がほしいという要求と、「あり得る型のどれか一つだけ」しか取らないという安全性を同時に満たすために、std::monostate
が追加されました。これは、その名の通り一つの状態しか取らない型です。値が一つしかない以上、その値以外の奇妙なものには成り得ません。これは実質的に空であるかのように扱えます。
もちろん、std::optional<std::variant<A, B, C>>
でも良かったはずなのですが、std::visit
が空の状態とA, B, Cの全ての場合に対して同じやり方ではvisitできないため、std::monostate
が導入された、と私は理解しています。あと、std::optional<std::variant<A, B, C>>
だと本当にほんの少しだけサイズが大きくなる気がしますね。std::optional
は保持すべき値+保持フラグ(bool)を持つのでstd::variant
より1バイトだけ大きくなりますが、std::variant
に空の状態(1バイト)を追加した場合、その大きさは変わりません。入れるべき型が1バイト未満のものしかないということはまあないですし、何が入っているかはstd::size_t
で管理しているので追加の管理コストもありません。
expected (P0323R3)
std::expected<T, E>
は、期待する型の有効な値か、エラー型の値を持つ型です。
これは標準入りしているわけではありません。提案されただけです。ここで提案が見られます。なので、以降文法が変更される可能性がありますし、最悪入らないかもしれません。それゆえ深入りはしないことにしますが、アイデアの紹介はしておくべきだと判断しました。
これは、基本的にはstd::optional
ですが、エラー時にはエラー型E
を保持することになります。なので、std::variant<T, E>
と捉えることも可能でしょう。違いとしては、これはエラー処理に特化しているという点です。「無いかも知れない」std::optional
や、「この内のどれか」のstd::variant
とはその点で違っています。std::expected<T, E>
は、「成功すればT
、失敗すればE
」です。失敗の理由などを伝えることができる点がstd::optional
と異なり、状態に正しい方、エラーの方、という意味が付与されている点がstd::variant
と異なります。
例:失敗するかもしれない関数
提案からユースケースを引用して来ましょう。ゼロ除算が起きたことをエラーとして伝えたい場合、これまでは専用の例外型を投げたりしていました。
struct DivideByZero : public std::exception { ... };
double safe_divide(double i, double j)
{
if(j == 0) throw DivideByZero();
else return i / j
}
std::expected
とstd::errc
を使えば、例えば以下のようなエラーコードを先に用意しておき、
enum class arithmetic_errc
{
divide_by_zero, // 9 / 0 == ?
not_integer_division, // 5 / 2 == 2.5 (which is not an integer)
integer_divide_overflows, // INT_MIN / -1
};
それを持たせて返すことが可能です。
expected<double, errc> safe_divide(double i, double j)
{
if (j == 0) return unexpected(arithmetic_errc::divide_by_zero);
else return i / j;
}
値の取り出し方は、似た用途として使われているstd::optional
に揃えられています。
expected<double, errc> f1(double i, double j, double k)
{
auto q = safe_divide(j, k);
if(q) return i + *q;
else return q;
}
エラーを取り出す方法も似ています。
int divide2(int i, int j)
{
auto e = safe_divide(i, j);
if(!e)
switch(e.error().value()) {
case arithmetic_errc::divide_by_zero: return 0;
case arithmetic_errc::not_integer_division: return i / j; // Ignore.
case arithmetic_errc::integer_divide_overflows: return INT_MIN;
// No default! Adding a new enum value causes a compiler warning here,
// forcing an update of the code.
}
return *e;
}
失敗するかもしれないもの、例えば文字列をパースして整数を取り出す、というような処理は、std::optional
でもできますが、エラーの理由を伝えられる(文字列のおかしい部分を示す、など)点が便利な点です。std::variant
を使う場合、どちらがerror
型かということをユーザーが覚えていなければなりませんし、統一しなければなりません。std::expected
ならそれらの手間は省けます。
Phantom type
Phantom type、幽霊型は、定義に現れないパラメータ型を持つ型です。テンプレートに取った型が、実際には使われません。この型は、値やメモリ領域に何も追加しないままに、コンパイルにおける型チェック時にだけ現れ、実行時には消え失せるようなパラメータを追加します。
それはどういうことでしょう? 「起き得ない状態を起き得なくする」という観点から、これはどういう状況で使えるのでしょう。
例:処理順序のチェック
例えば、プログラムが入力を受け付けるとします。その入力は大抵の場合、正しい文法または値を持っているかどうか、処理が進行する前にプログラム側にチェックされなければならないでしょう。チェック以外にも、ある程度前処理をするかもしれません。字句解析とかですね。
const std::string input = read_input_data();
const std::optional<std::string> sanitized = diagnosis(input);
if(preprocessed.has_value())
execute_input_command(sanitized.value());
else
print_error("invalid input: %1%", input);
これもいいですが、少し不安な部分があります。というのも、execute_input_command
はただのstd::string
を受け取るので、diagnosis
を通さなくても実行できる、ということです。これはできれば忘れないでほしいのですが、人間は忘れる生き物です。長いコードを書きながら、全てを頭にしまっておく術はありません。
型のレベルでロジックを表現すれば、コンパイラは我々のミスを指摘してくれます。以下のような型を使いましょう。
template<typename tagT>
struct phantom{std::string value;};
struct raw{};
struct sanitized{};
using raw_input_string = phantom<raw>;
using sanitized_string = phantom<sanitized>;
raw_input_string read_input();
std::optional<sanitized_string> diagnosis(const raw_input_string&);
void execute_input_command(const sanitized_string&);
今、2つの同じ型の値があり、しかしコードの中ではその2つの意味合いは異なっているわけです。ここでやっているのは、値を変えないままに型の部分にだけパラメータを設定し、診断前の値が実行されてしまうことを防ぐということです。
コードは同様のままです(型推論さまさまですね!)。
const auto input = read_input_data();
const auto valid = diagnosis(input);
if(valid.has_value())
execute_input_command(valid.value());
else
print_error("invalid input: %1%", input);
ここでread_input_data
の返却値をそのままexecute_input_command
に渡すと、型が異なるのでエラーになります。execute_input_command
はsanitized_string
を受け取るのであって、raw_input_stirng
は受け取りません。
ここではだいぶ簡略化していますが、sanitized_string
のコンストラクタを定義してraw_input_string
をそのまま(diagnosis
関数を通さずに)使って構築できないようにしておくと、間違って入力を字句解析せずに実行してしまったりはしなくなります。
ここでphantom
はstd::string
を持っているだけで、実行時処理には何も追加されてはいません。つまり、コンパイラはこれらの型チェックに用いたタグや幽霊型を全て、実行時には消しされるということです。十分な最適化の後には、raw_input_string
もsanitized_string
もただのstd::string
になっていることでしょう。
例:Boost.Unitによる次元解析
Boost.Unitは単位演算用のライブラリで、様々な値に単位を型パラメータとして与えることができます。また、コンパイル時の型チェックによって次元解析を行い、書いた数式が物理的に妥当かどうかチェックをしてくれます。
#include <boost/units/systems/si.hpp>
#include <boost/units/systems/si/io.hpp>
boost::units::quantity<boost::units::si::length> len = 3 * boost::units::si::meter;
std::cout << len << std::endl; // 3 m
// boost::units::quantity<boost::units::si::velocity> vel = len; // compilation error!
boost::units::quantity<boost::units::si::velocity> vel = len / boost::units::si::seconds;
std::cout << vel << std::endl; // 3 m s^-1
ここでは、いくつかのよく使う物理量の次元やよく使う単位間の比が先に定義されています。それらのパラメータは型のパラメータとしてコンパイル時にチェックされるので、次元が合わないような値を代入しようとすると失敗します。
例えば、velocity
にlength
を入れようとすると失敗しますが、length
をtime
で割ってからなら成功します。単位の違いによる比率の調節は実行時にも残りますが、次元解析の分の型パラメータは最適化によって実行時には姿を消します。
あとがき
「型によって意味を表現する」という観点からテクニックを紹介してきました。
折角コンパイラを使っているので、ミスはできるだけコンパイル時に発見したいです。コードがその意味をそのまま表すように、型を見れば何をするかわかるようにした方がコードもシンプルになるでしょう。そういった場合に、型によってロジックを実装していくことが強力なツールになり得るということが伝わっていれば嬉しいです。
References
- CppCon 2016: Ben Deane "Using Types Effectively" https://www.youtube.com/watch?v=ojZbFIQSdl8
- 野良C++erの雑記帳: gintenlab 「それでも Boost.Optional を使う、大きく分けて2つくらいの理由」 http://d.hatena.ne.jp/gintenlabo/20100606/1275854791
- p0323r3 "Utility class to represent expected object" http://open-std.org/JTC1/SC22/WG21/docs/papers/2017/p0323r3.pdf
- Haskell wiki "Phantom type" https://wiki.haskell.org/Phantom_type
- Boost.Unit http://www.boost.org/doc/libs/1_65_1/doc/html/boost_units.html
- cpprefjp https://cpprefjp.github.io/
- cppreference http://en.cppreference.com/w/cpp
- N4659 https://github.com/cplusplus/draft
特に、このような記事を書こうと漠然と思っていた時にBen Deane氏の素晴らしいトークを見て色々と固まったので、この記事は彼のトークから強い影響を受けています。