C++ 型システムで前提条件エラーを防ぐ

エラー処理シリーズの前のパートでは、アサーションについて話し、柔軟なアサーションを提供するデバッグ アサート ライブラリを作成しました。

アサーションは、関数の前提条件をチェックするための便利なツールですが、適切な型設計により、アサーションが必要な状況を防ぐことができます.C++ には優れた型システムがあります。それを活用してみましょう.

最近の CppCon で、Ben Deane は、私が聞いた限りでは、書体デザインについて素晴らしい講演をしました。残念ながら私は会議に出席していませんでした。彼のビデオはまだ公開されていませんが、スライドによると、彼の意見にはいくつかの重複があります。トークと私が言おうとしていること.しかし、私はこの投稿を何週間も計画し、シリーズ全体をそのためだけに作成したので、とにかく投稿することにしました.>

モチベーション

私は、C++ ドキュメント ジェネレーターである standardese に取り組んでいます。その性質上、多くの文字列を処理する必要があります。特に、文字列の末尾にある空白を消去する必要がある一般的なタスクです。これは非常に簡単な方法で行うことができ、「空白」の定義は状況によって異なるため、わざわざそのための別の関数を書く必要はありませんでした.

次のようなコードを使用しています:

while (is_whitespace(str.back())
 str.pop_back();

この 2 行を書き、コミットし、プッシュします。CI を通常どおり待つと、Windows のビルドが失敗したことを知らせるメールが届きます。困惑しています。 MacOSビルド! - ログを確認してください:テストの実行がタイムアウトしたようです。

うんざりして Windows を再起動し、そこでプロジェクトをビルドします。テストを実行すると、見事に設計されたデバッグ アサーションの失敗ダイアログが表示されます。

エラー メッセージを見て、私は顔を合わせて修正をコミットします:

while (!str.empty() && is_whitespace(str.back())
 str.pop_back();

時々文字列が空でした。 libstdc++ にはデフォルトで有効になっているアサーションがなく、たまたま期待どおりに動作しましたが、MSVC

私は DRY に従わなかった、libstdc++ はデフォルトで前提条件を検証しない、Appveyor はグラフィカルなアサーション ダイアログを好まない、MSVC は Linux で利用できない。

しかし、主な欠点は 09 の設計にあると私は主張します 適切に設計されていれば、コードはコンパイルされず、文字列が空である可能性があるという事実を思い出させ、15 分の時間を節約し、Windows を再起動する必要がありません。

どのように?型システムの助けを借りて.

解決策

問題の関数には、次のように簡略化された署名があります:

char& back();

文字列の最後の文字を返します。文字列が空の場合、最後の文字がないため、とにかくそれを呼び出すのは UB です。どうやってそれを知ることができますか?考えてみれば明らかなように思えます。コード>12 空の文字列の場合に返す必要がありますか?実際には「無効な」28 はありません であるため、何も返すことができません。

しかし、私はそれについて考えていませんでした.私はこの複雑なコメント解析アルゴリズムについて考えるのに忙しく、一部の人々がコメントに末尾の空白を入れて、その後のマークダウン解析を壊すという事実にうんざりしていました!

31 狭いコントラクトを持っている - 前提条件.狭いコントラクトを持つ関数は、広いコントラクトを持つ関数よりも間違いなく作業が困難です.したがって、可能な限り少ないコントラクトを狭くすることが実現可能な目標です.

この特定の関数では、問題は 49 には、空の文字列の場合に返される有効な文字がありません。しかし、この貧弱な関数を助けることができる C++17 の追加が 1 つあります:59 :

std::optional<char> back();

64 値を含むことも、値を含まないこともできます。値そのものが有効な型に対して無効な値を許可します。文字列が空でない場合、72 最後の文字を含むオプションを返します。ただし、文字列が空の場合は、オプションの null を返すことができます。関数を適切にモデル化したので、前提条件はもう必要ありません。

82 と仮定すると はこの署名を持っています.今、私は再びコメントの解析コードに集中し、末尾の空白を消去するための簡単な 2 行を書きます:

while (is_whitespace(str.back())
 str.pop_back();

90 104 を取る しかし 113 128 を返します 、そのため、コンパイル エラーが発生します - 私のマシンでは、すぐに.キャラクターを手に入れるために働きます。

もちろん、私はまだそれを台無しにすることができます - 132 のため 実際にはこの目的のために設計されていません:

while (is_whitespace(*str.back())

これはまったく同じ動作をし、おそらく MSVC でデバッグ アサーションを生成します。147 null オプションで呼び出してはならず、含まれている値を返します。

while (is_whitespace(str.back().value())

158 少なくとも空のオプションで例外をスローするように定義されているため、実行時に少なくとも確実に失敗します.しかし、両方のソリューションは、同じシグネチャを持つコードよりもまったくメリットがありません.抽象化、それらはそもそも存在すべきではありません!代わりに、実際に値を照会する必要がないようにするより高レベルの関数が必要です.そして、それが必要になる可能性のあるいくつかのケースでは、それは目立つ長い名前で、自分が何か悪いことをしていることに気付かせてくれます - 星一つではありません!

はるかに優れたソリューションは次のとおりです:

while (is_whitespace(str.back().value_or('\0'))

167 値または代替のいずれかを返します。この場合、nullオプションはnull文字を返します。これはたまたまループを終了するのに最適な値です.しかし、もちろん、常に適切な無効な値があるとは限りません.したがって、最良の解決策次のようになります:Change the signature of 179 180 を受け入れる .

ガイドライン I:適切な戻り値の型を使用する

何かを返す、または呼び出してはならない関数が多数あります。193 /207 217 のようなオプションの型を返すように設計することを検討してください。 .その後、事前条件チェックを行う必要はなく、型システム自体がエラーの防止に役立ち、ユーザーがエラーを検出して処理しやすくなります。

もちろん 229 は使えません エラーが発生する可能性があるすべての場所。一部のエラーは前提条件エラーではありません。そのような状況では、例外をスローするか、提案された 231 に似たものを使用します 有効な値またはエラー タイプのいずれかを返すことができます。

ただし、何かを返し、無効な状態で呼び出してはならない関数については、オプションの型を返すことを検討してください。

パラメータの前提条件

無効な状態の前提条件を扱いましたが、ほとんどの前提条件はパラメーターにあります。ただし、パラメーターの型を変更することで、前提条件も簡単に取り除くことができます。

たとえば、次の関数を考えてみましょう:

void foo(T* ptr)
{
 assert(ptr);
 …
}

署名を次のように変更します:

void foo(T& ref);

これでヌル ポインター値を渡すことはできなくなりました。渡した場合、それを逆参照して UB を実行したことは呼び出し元の責任です。

これはポインタ以外にも機能します:

void foo(int value)
{
 assert(value >= 0);
 …
}

署名を次のように変更します:

void foo(unsigned value);

現在、アンダーフローを行わずに負の値を渡すことはできません。残念なことに、C++ は符号付きから符号なしの型への暗黙的な変換を C から継承しているため、解決策は完全ではありませんが、意図は文書化されています。

ガイドライン II:適切な引数の型を使用する

引数の型を選択して、前提条件を排除し、代わりにコードに直接表示できるようにします。null であってはならないポインターがある場合参照を渡します。負であってはならない整数?符号なしにします。特定の名前付きの値のセットのみを持つことができる整数ですか?列挙型にします。

240 の一般的なラッパー タイプを自分で作成することもできます。 ! - コンストラクターは、次のように、「生の」値が特定の値を持つことをアサートします:

class non_empty_string
{
public:
 explicit non_empty_string(std::string str)
 : str_(std::move(str))
 {
 assert(!str_.empty());
 }

 std::string get() const
 {
 return str_;
 }

 … // other functions you might want

private:
 std::string str_;
};

この小さなラッパーを一般化するのは非常に簡単です。これを使用すると、意図が表現され、有効性をチェックする中心的な場所になります。また、すでにチェックされている値と無効な値の可能性を簡単に区別し、ドキュメントなしで前提条件を明確にすることもできます.

もちろん、この手法が常に可能であるとは限りません。慣例により特定のタイプが必要になる場合もあります。さらに、あらゆる場所で使用するのもやり過ぎになる可能性があります。ボイラープレート全体を書きます。

結論

C++ 型システムは、エラーを検出するのに十分強力です。

適切な関数設計により、関数自体から多くの前提条件を削除し、代わりにそれらを 1 か所に集中させることができます。前提条件を自然に表現できるセマンティックな引数の型と、関数が有効な値を返せない場合がある場合はオプションの戻り値の型を選択してください。

この投稿を書いているときに、前回の投稿のようなライブラリのアイデアを思いつきました.前提条件を自然な方法で表現する「セマンティック型」を簡単に使用できるようにする小さなライブラリを作成するかもしれません.しかし、私はしませんでした.この投稿をこれ以上遅らせたくないので、(まだ)していません。