C++で状態を扱ういくつかのパターン
プログラミングをしていると、なんらかの状態を制御しなければならないことが多い。
そのときにいくつかのパターンがあると思われるのでまとめておく。
なお、使いやすいのでC++を使用したが、ある程度別の言語でも応用できる話であると思う。
パターン1: 現在の状態と入力値を受け取って、出力値を新しい状態とともに返す関数
これは構造上は、最もシンプルなパターンと思われる。
コード例は以下のようになる。
#include <utility>
struct State { /*...*/ };
struct Input { /*...*/ };
struct Output { /*...*/ };
std::pair<State, Output> update(State state, Input in) {
State new_state;
Output out;
// ...
return {new_state, out};
}
このコードでは、状態を表すState
、入力値を表すInput
、出力値を表すOutput
という3つのデータ型を定義している。
update
関数は、現在の状態と入力値を受け取って、新しい状態と出力値のペアを返す。
現在の状態を新しい状態で更新する場合は、関数の外側で行う。
auto [new_state, out] = update(state, in);
state = new_state; // 実際の状態の更新
update
関数自体は副作用のない純粋な関数となる。
構造体が比較的大きい場合は、参照やポインタを使用して関数呼び出しの際の引数/戻り値の受け渡しのコストを削減することができるが、 本筋には関係ないので、コード例を示しておくに留める。
std::pair<std::unique_ptr<State>, std::unique_ptr<Output>> update(const State& state, const Input& in) {
auto new_state = std::make_unique<State>();
auto out = std::make_unique<Output>();
// ...
return {std::move(new_state), std::move(out)};
}
このパターンの良い点は、前回の状態が変更されないため、状態の履歴をリングバッファに残しておいたり、 実際に状態を更新するかどうかのバリデーションを行ったりといったことを関数の外側で行うことができる点である。
一方で、このパターンでは毎回新しい状態をメモリ上に生成するため、管理するState
のメモリフットプリントが大きい場合は
不必要にパフォーマンスを低下させる可能性もある。
パターン2: 状態の参照と入力値を受け取って、参照先を書き換えた上で出力値を返す関数
パターン1の欠点を解消するのがこの方法である。
Output update(State& state, Input in) {
Output out;
// ...
return out;
}
状態State
を使いまわし、インラインで書き換えるため、update
関数は純粋ではなくなるが、
毎回新しい状態をメモリ上に生成するということはなくなる。
参照ではなくポインタを使用することもできる。
Output update(State* state, Input in) {
Output out;
// ...
return out;
}
C言語の標準ライブラリのファイル操作系の関数などは、この方式を採用している。
char *fgets(char *str, int n, FILE *stream);
このfgets
関数の場合、3つめの引数であるFILE* stream
がファイルの状態を表している。
私の観測範囲だと、このパターンで参照とポインタのどちらを使用するかは流儀があるようである。
パターン3: 入力値を受け取って、クラスの状態を書き換えた上で出力値を返すメソッド
C++の場合、パターン2には糖衣構文が存在する。クラスのメソッド(C++の場合正式には、メンバ関数という)として
update
関数を実装する。
struct State {
/*...*/
Output update(Input in) {
Output out;
// ...
return out;
}
};
いわゆる、オブジェクト指向プログラミングの書き方であり、おそらく他言語含めて考えたとき最もメジャーな方法と思われる。
パターン4: 状態を参照でキャプチャし、入力値を受け取って、参照先の状態を書き換えた上で出力値を返すラムダ
これも意味論的にはパターン2,3と同様であるが、ラムダを使用するのが異なる。
State state;
auto update = [&state](Input in) -> Output {
Output out;
// ...
return out;
};
呼び出し側では、生成したラムダ関数を繰り返し使用する。
Input in;
Output out = update(in);
このパターンの欠点としては、ダングリング参照が発生しやすいことである。
update
関数を使う間は、キャプチャした状態state
の寿命が尽きないように注意しなければならない。
パターン5: 関数内static変数を使用する関数
プログラム中複数の状態を保つ必要がなく、ただ一つの状態だけを保持すれば良くて、 状態の生成と更新を処理として分けなくても良く、 更新も一つのパターンでOKであれば、関数内static変数を使用できる。
関数内static変数は、関数の最初の呼び出し時に一度だけ初期化される。
Output update(Input in) {
static State state;
Output out;
// ...
return out;
}
複数のインスタンスを持てないという大きな欠点はあるものの、時に有力な方法である。
パターン6: ファイルスコープのstatic変数/グローバル変数を使用する関数
パターン5の場合、stateを変更するパターンがupdate
のみという厳しい制約があったが、
状態変数を外側に出してやることで、複数の変更方法がかけるようになる。
static State state;
Output update(Input in) {
Output out;
// ...stateを更新
return out;
}
void reset() {
// ...stateをリセット
}
C言語では現在でも有力な手段だが、状態が変更にさらされるスコープが広くなるため、 真面目なプログラミングではできるだけ避けたほうが良いパターンである。