0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「リーダブルコード 第四部 選抜テーマ」

Posted at

第14章 テストと読みやすさ

テストを読みやすくて保守しやすいものにする

著者見解

テストコードの可読性は、アプリケーションコードの可読性と同じくらい重要です。読みやすいテストは次の利点があります。

  • 何を期待しているのかが即座に分かるため、バグの原因追跡が速くなる。
  • 仕様の振る舞いをそのまま表現できるので、新しい開発者が挙動を理解しやすくなる。
  • リファクタリングのときに、安全に変更できることを保証するドキュメントになる。

テストを読みやすく保つ具体的なポイントとしては、次のようなものが挙げられます。

  • テスト名やアサーションは意味のある自然言語にする(何を検証しているか明確に)。
  • Arrange / Act / Assert(準備・実行・検証)の構造を守り、各部分の役割を明確にする。
  • テスト内に複雑なロジックを書かない(テストが壊れやすく、何を検証しているか分かりにくくなる)。
  • アサーションは失敗時に原因が分かるように具体的に書く(抽象的な true/false に頼らない)。
  • テストは速く、繰り返し実行できるようにする(遅いテストは実行頻度を下げ、寿命が短くなる)。
筆者所感

私はテストコードを「生きた仕様書」として扱うべきだと考えています。TDD や XP の思想に影響を受けており、読みやすいテストはチームの合意形成やオンボーディング、将来の変更に対する安全網として非常に有用です。実際、Word や Excel の仕様書よりも、IDE 上でジャンプできるテストコードの方が速く理解できることが多いです。

このテストのどこがダメなの?

著者見解
void Test1() {
    vector<ScoredDocument> docs;
    docs.resize(5);
    docs[0].url = "http://example.com";
    docs[0].score = -5.0;
    docs[1].url = "http://example.com";
    docs[1].score = 1;
    docs[2].url = "http://example.com";
    docs[2].score = 4;
    docs[3].url = "http://example.com";
    docs[3].score = -99998.7;
    docs[4].url = "http://example.com";
    docs[4].score = 3.0;
    SortAndFilterDocs(&docs);
    assert(docs.size() == 3);
    assert(docs[0].score == 4);
    assert(docs[1].score == 3.0);    
    assert(docs[2].score == 1);
}

上のテストコードには、少なくとも 8 個以上の問題点があります。本章の最後までに、それらすべてを見つけ出し、順に修正していきます。

筆者所感

本項目は問題提起のみを目的としているため、所感はありません。

テストを読みやすくする

著者見解

設計の一般原則として「重要でない詳細は隠し、重要な意図は目立たせる」べきです。前節のテストでは vector<ScoredDocument> の構築や各フィールド設定といった低レベルの詳細が目立っており、テストが何を検証しているのか直感的に分かりにくくなっていました。こういう場合はヘルパー関数で低レベルの構築を隠すと良いです。

まずは低レベルのオブジェクト生成を隠す簡単なヘルパー例です(誤字や不適切な代入を修正しています)。

void MakeScoredDoc(ScoredDocument* sd, double score, const std::string& url) {
    sd->score = score;
    sd->url = url;
}

void Test1() {
    std::vector<ScoredDocument> docs;
    docs.resize(5);
    MakeScoredDoc(&docs[0], -5.0, "http://example.com");
    MakeScoredDoc(&docs[1], 1.0,  "http://example.com");
    MakeScoredDoc(&docs[2], 4.0,  "http://example.com");
    MakeScoredDoc(&docs[3], -99998.7, "http://example.com");
    // ...
}

さらに便利にするため、コンテナに要素を追加するヘルパーにするとテスト本体はもっと簡潔になります。

void AddScoredDoc(std::vector<ScoredDocument>& docs, double score,
                  const std::string& url = "http://example.com") {
    ScoredDocument sd;
    sd.score = score;
    sd.url = url;
    docs.push_back(sd);
}

void Test1() {
    std::vector<ScoredDocument> docs;
    AddScoredDoc(docs, -5.0);
    AddScoredDoc(docs, 1.0);
    AddScoredDoc(docs, 4.0);
    AddScoredDoc(docs, -99998.7);
    AddScoredDoc(docs, 3.0);
    SortAndFilterDocs(&docs);
    // アサーション...
}

さらに考えると、このテストが本当に伝えたいのは「入力スコア列と期待される出力スコア列」なので、テスト呼び出しはより高レベルに書くのが望ましいです。例えば次のようにすれば、テストの意図が一目で分かります。

CheckScoresBeforeAfter("-5, 1, 4, -99998.7, 3", "4, 3, 1");

このためのヘルパー実装例を示します。文字列をパースして ScoredDocument の配列を作り、処理後を文字列に戻して比較します。

std::vector<ScoredDocument> ScoredDocsFromString(const std::string& scores) {
    std::vector<ScoredDocument> docs;
    std::string s = scores;
    std::replace(s.begin(), s.end(), ',', ' '); // <algorithm> が必要
    std::istringstream stream(s);               // <sstream> が必要
    double score;
    while (stream >> score) {
        AddScoredDoc(docs, score);
    }
    return docs;
}

std::string ScoredDocsToString(const std::vector<ScoredDocument>& docs) {
    std::ostringstream oss;
    for (size_t i = 0; i < docs.size(); ++i) {
        if (i > 0) oss << ", ";
        oss << docs[i].score;
    }
    return oss.str();
}

void CheckScoresBeforeAfter(const std::string& input, const std::string& expected_output) {
    std::vector<ScoredDocument> docs = ScoredDocsFromString(input);
    SortAndFilterDocs(&docs);
    std::string output = ScoredDocsToString(docs);
    assert(output == expected_output);
}

ヘルパー群を用意すれば、個々のテストは短く読みやすくなり、意図(仕様)が明確に伝わるようになります。

筆者所感

テスト用に切り出す関数は、できるだけ状態を持たない(副作用が少ない)純粋なものにすることを意識しています。状態を持つヘルパーは別のテストに影響を与え、テストの独立性を損なう原因になります。したがって、テスト用ヘルパーは

  • テストデータを生成する純関数(入力 → 出力)の形にする、
  • グローバル状態や外部リソース(ファイル、ネットワーク等)に依存させない、

ことを守るようにしています。こうすることでテストは「読みやすく・保守しやすい生きた仕様書」として機能し、チーム全体の理解やリファクタリングの安全性が向上します。

エラーメッセージを読みやすくする

著者見解

前節のテスト内で次のような単純なアサートを使っているとします。

assert(output == expected_output);

これが失敗した場合、多くの環境では次のようなあまり情報のないメッセージが表示されます。

Assertion failed: (output == expected_output),
    function CheckScoresBeforeAfter, file test.cc, line 37.

このメッセージを見ても、実際に outputexpected_output の中身が何だったのかは分かりません。どこが違うのかをすぐに把握できるようにするため、より説明的なアサーションやエラーメッセージを使うことが重要です。

たとえば Boost.Test を使えば、比較対象の値を出力してくれるアサーションを使えます。

BOOST_REQUIRE_EQUAL(output, expected_output)
test.cc(37): fatal error in "CheckScoresBeforeAfter": critical check
    output == expected_output failed "1, 3, 4" != "4, 3, 1"

このように、期待値と実際の値が併記されるとデバッグが格段に楽になります。さらに分かりやすくするためには、自分でメッセージを付けるか、差分をわかりやすく表示する仕組みを用意すると良いでしょう。

例:標準的な出力で補助情報を出す(簡易実装)

if (output != expected_output) {
    std::cerr << "CheckScoresBeforeAfter failed:\n";
    std::cerr << "  expected: " << expected_output << "\n";
    std::cerr << "  actual:   " << output << "\n";
    assert(false);
}

さらに改善するなら、行単位やトークン単位で差分を出すユーティリティを用意したり、テストフレームワークのメッセージ機能(Google Test の EXPECT_EQ / ASSERT_EQ など)を活用して、失敗時にわかりやすい情報を出すようにしてください。

筆者所感

ライブラリは便利ですが、新しすぎたり利用者が少ないものを導入する際は注意が必要です。頻繁な API 変更やメンテナンスコストが発生し得るため、導入の判断では「得られるベネフィット」と「将来の保守コスト」を比較した上で決めるのが良いです。

とはいえ、既存のテストフレームワークが提供する「わかりやすいアサーション」を積極的に使うだけでもデバッグ効率は大きく向上します。まずは手元のテストで期待値と実際の値が明示されるように整え、その上で必要に応じて差分表示や独自メッセージの導入を検討してください。

テストの適切な入力値を選択する

著者見解
CheckScoresBeforeAfter("-5, 1, 4, -99998.7, 3", "4, 3, 1");

このテストの入力値を適切にするには、テストが「その機能を最小限かつ確実に検証できる」ように値を選ぶことが重要です。ポイントは以下の通りです。

  • テスト対象の振る舞いを確実に引き出す最小限の値を使う(過剰に大量の値は不要)。
  • 複数の振る舞いがあるなら、一つのテストで全部を検証しようとせず、振る舞いごとに分ける。
  • 境界値や特殊ケース(負の値、ゼロ、重複、空、単一要素など)を意識する。

今回のケースでは「負のスコアはフィルタされる」「残るスコアは降順にソートされる」という振る舞いを検証するため、負のスコアは 1 件だけ含めれば十分です。したがって次のように単純化できます。

CheckScoresBeforeAfter("1, 2, -1, 3", "3, 2, 1");

また、1つのテストで全てを確認する必要はありません。例えば以下のように振る舞いを分けてテストした方が原因が特定しやすくなります。

  • ソートのみを検証するテスト(負値なし)
    CheckScoresBeforeAfter("2, 1, 3", "3, 2, 1");

  • 負値のフィルタリングを検証するテスト(ソートは副次的)
    CheckScoresBeforeAfter("1, -1, 2", "2, 1");

  • 重複処理を検証するテスト(重複を許可する/除去する仕様に応じて)
    CheckScoresBeforeAfter("5, 5, 2", "5, 5, 2"); // 重複を許可する仕様の場合

  • 空入力や単一要素、全て負値のテスト(境界ケース)
    CheckScoresBeforeAfter("", ""); // 空入力
    CheckScoresBeforeAfter("7", "7"); // 単一要素
    CheckScoresBeforeAfter("-1, -2", ""); // 全部フィルタされるケース

筆者所感

テスト入力値を決める際は「境界値解析(boundary value analysis)」が有効です。たとえば文字列長の仕様が「1文字以上、50文字以内」であれば、0、1、50、51 といった値をそれぞれ別テストにすることで境界の扱いを確実に検証できます。

テストの機能に名前をつける

著者見解

テストには、その目的や状況を表す説明的な名前を付けるべきです。たとえば次のような形式が扱いやすいです。

  • Test_<関数名>
  • Test_<関数名>_<状況>

名前が長くなっても気にする必要はありません。ほとんどのテスティングフレームワークは、失敗時にテスト関数名を表示するため、意味のある名前の方がデバッグ時に役立ちます。

また、テスト用ヘルパーの命名規則も意味が伝わるように決めておくとよいです。例えば、ヘルパーがアサーション(検証)を行う場合は Check...()、単にデータを組み立てるだけなら Make...() や Build...() のように命名すると役割が明確になります。

例:

void Test_SortAndFilter_RemovesNegativeScoresAndSortsDescending() {
    // ...
}

void CheckScoresBeforeAfter(const std::string& before, const std::string& after) {
    // アサーションを含むヘルパー
}
筆者所感

テスト名も、変数名や関数名と同じく意味のある名前を付けるべきだと考えています。テスト名は「一行で分かる簡潔な説明」を担い、詳細な補足が必要であればコメントで補足する、という役割分担が現実的で使いやすいです。つまり、

  • テスト名:何を検証しているかを簡潔に表す(第1レベルの説明)
  • コメント:なぜその振る舞いが期待されるのか、背景や注意点などの補足(第2レベルの説明)

このようにしておくと、テストが失敗したときに名前だけで大枠を把握でき、詳しく調べたい場合にコメントやヘルパー実装を参照すればよいので、オンボーディングやデバッグが楽になります。

このテストのどこがダメだったの?

著者見解
void Test1() {
    vector<ScoredDocument> docs;
    docs.resize(5);
    docs[0].url = "http://example.com";
    docs[0].score = -5.0;
    docs[1].url = "http://example.com";
    docs[1].score = 1;
    docs[2].url = "http://example.com";
    docs[2].score = 4;
    docs[3].url = "http://example.com";
    docs[3].score = -99998.7;
    docs[4].url = "http://example.com";
    docs[4].score = 3.0;
    SortAndFilterDocs(&docs);
    assert(docs.size() == 3);
    assert(docs[0].score == 4);
    assert(docs[1].score == 3.0);    
    assert(docs[2].score == 1);
}

これまでの議論を踏まえて、上記テストの問題点を列挙します。各項目はテストの可読性・保守性・有効性に関する欠陥です。

  • どうでも良い詳細が多すぎる
    テスト本来の意図(スコアの挙動)が見えにくく、低レベルなオブジェクト設定が目立っています。

  • テストが簡単に追加できない(コピペしがち)
    同様のテストを増やすたびにオブジェクト構築コードを繰り返す設計になっており、重複が増えます。

  • 失敗時のメッセージが役に立たない
    単純な assert では期待値と実際の結果が表示されず、デバッグに時間がかかります。

  • 一度にすべてをテストしようとしている
    ソート、フィルタリング、重複処理など複数の振る舞いを単一テストで検証しており、失敗時に原因の特定が困難です。

  • 入力値が単純でない(読みづらい/冗長)
    手作業でオブジェクトを並べる書き方だと、何をテストしているかが直感的に分かりにくいです。

  • 入力値が不完全(境界が考慮されていない)
    スコアが 0 の場合や端数、小数点の扱いなど、境界ケースが含まれていません。

  • 極端なケースを試していない
    空配列、巨大配列、同値スコア(重複)、極端に大きい/小さい値などのテストが欠けています。

  • テスト名に意味がない
    Test1 のような識別子は、失敗時にどの振る舞いが壊れたのか分からないため、説明的な名前を付けるべきです。

以上の点を改善することで、テストは読みやすく、拡張しやすく、失敗時の原因特定もしやすくなります。

筆者所感

個人的には「テストステートメントが長くなっても必ずしも悪いとは限らない」と考えています。確かに理想としては短く高レベルに書くのが望ましいですが、結果としてヘルパー関数が乱立したり、逆に一つのヘルパーに詰め込みすぎて複雑になってしまったりすると、かえって可読性が下がることがあります。

重要なのはバランスです。ヘルパー関数は低レベルの重複を隠してテストを簡潔に保つために有効ですが、ヘルパーが多すぎて関連ロジックがあちこちに散らばると、テストの意図を追うのが難しくなります。テストは「生きた仕様書」であり続けるべきなので、読み手にとって何が一番分かりやすいか、失敗したときに原因を特定しやすいか、新しいケースを簡単に追加できるかを基準に、ヘルパーの抽出やテストの粒度を決めると良いでしょう。

テストに優しい開発

著者見解

コードには「テストしやすいもの」と「テストしにくいもの」があります。開発の段階で「テストを書くつもりで実装する」と、自然とテストしやすい設計になります。一般にテスト容易性を高める指針は次のとおりです。

  • グローバル変数や単一の巨大な状態に依存しない。
  • ファイル I/O やネットワーク、時間や乱数に直接依存しない(外部副作用を分離する)。
  • 小さく責務が明確なクラス・関数に分割する(Single Responsibility)。
  • 依存は注入(DI)して差し替え可能にし、インタフェースや抽象化を使って疎結合にする。
  • 純粋関数(同じ入力で常に同じ出力を返す)を多用する。
  • テストダブル(モック、スタブ、フェイク)で外部振る舞いを再現できるようにする。

こうした設計にすると、ユニットテストが書きやすく、テストの実行が速くなり、故に CI やローカルで頻繁にテストを回す文化が作りやすくなります。結果としてリファクタリングが安全に行え、保守性も向上します。

筆者所感

TDD(テスト駆動開発)はコード品質を高める有効な手法だと感じますが、導入にはコストや習熟が必要であり、すべてのチーム/プロジェクトに最初から強制すべきだとは思っていません。ケント・ベックのようにテストを手足のように扱えるようになると大きな効果がありますが、そこに到達するまでには学習と実践の時間が必要です。

  • 「テストしやすさを意識した設計」を最初から心がける(上の指針を取り入れる)。
  • チームの成熟度やスケジュールに応じて、TDD を段階的に導入する(いきなり全面適用は避ける)。
  • CI/自動化を整え、テストが実務の一部として自然に回るようにする。

要するに「テストありき」で設計することは非常に価値がありますが、手法は柔軟に選び、コストと効果のバランスを見ながら現実的に進めることが肝要だと考えています。

やりすぎ

著者見解

テストは重要ですが、やりすぎには注意が必要です。テストを書くこと自体に過度に注力して、本来の本番コードの読みやすさや設計を犠牲にしてしまっては本末転倒です。また、カバレッジ100%に固執するのも得策ではありません。一般に、重要な 90% のコードをしっかりテストする方が、残りの 10% に過度に時間を割くよりも現実的で効果的です。

過剰なテストや重要度の低いケースまで網羅したテストが増えると、少しの実装変更で大量のテストが壊れる事態が起こりやすくなります。その結果、リファクタリングが難しくなり開発速度が低下してしまい、プロダクト開発の阻害要因になり得ます。テストを書く際はコストと効果を意識し、どのレベルまでテストするかを実用的に判断してください。

筆者所感

私のチームでは Laravel を採用しており、データアクセスに Eloquent(ORM)を使っています。実際のプロジェクトでは、Eloquent 周りで例外をキャッチしてハンドリングする処理が散見されますが、その例外処理まですべてユニットテストで再現しようとすると、Eloquent をモックして例外を発生させるような手間が必要になります。公式にもあまり推奨されない手法を使ってまで細かい例外ケースをユニットテスト化するのは、コストに見合わないことが多いと感じます。

こうしたケースは「テストのやりすぎ」の典型例です。テストの目的は「信頼してリファクタリングできること」と「主要な振る舞いを保証すること」ですから、その目的に対して過剰な労力を割くべきではありません。

第15章 「分/時間カウンタ」を設計・実装する

問題点

著者見解

Web サーバが直近 1 分間と直近 1 時間に転送したバイト数を把握したい、という要件があります。本章では、この要件を効率よく満たす方法を扱い、実務で使える実装手法を示します。

筆者所感

問題提起のみの項目のため所感は割愛します。

クラスのインターフェースを定義する

著者見解

まずは C++ で考えたクラスの最初のバージョンです。

class MinuteHourCounter {
    public:
        // カウントを追加する
        void Count(int num_bytes);
        // 直近1分間のカウントを返す
        int MinuteCount();
        // 直近1時間のカウントを返す
        int HourCount();
};

クラス名 MinuteHourCounter は用途が明確で良い名前です。一方でメソッド名や引数名には改善の余地があります。たとえば Count は「何をするのか」が曖昧で、「これまでの合計が欲しい」のか「何かをカウントして欲しい」のか判別しにくいです。動詞としては Add、Record、Observe などが候補になりますが、Increment は増分だけを扱う印象を与えるため適切でない場面もあります。今回は単純に「ある値を追加する」という意味で Add が分かりやすいでしょう。

引数名も具体的すぎるとインターフェースが特定の用途に縛られてしまいます。ここでは「バイト数」を扱う実装例ですが、クラス自体は単に「カウント」を受け取るだけでよいので、引数名は count とするのが汎用的です。

class MinuteHourCounter {
    public:
        void Add(int count);
        int MinuteCount();
        int HourCount();
};

次にコメントについて考えます。単に「カウントを追加する」とだけ書くコメントは冗長になりがちですが、インターフェースとしての振る舞い(時間窓でどのようにカウントが反映されるか)は重要な仕様なので、簡潔に記述しておくと利用者に親切です。たとえば Add の振る舞いを明確にするコメントは次のようになります。

// 新しいデータ点を追加する(count >= 0)
// 追加された count は直近 60 秒間の合計(MinuteCount)に反映される。
// 同じく追加された count は直近 3600 秒間の合計(HourCount)にも反映される。
void Add(int count);

また、MinuteCount / HourCount のコメントも「直近の何秒間を指すのか」を明記しておくと誤解がありません。

// 直近 60 秒間の累積カウントを返す
int MinuteCount();
// 直近 3600 秒間の累積カウントを返す
int HourCount();

最終的にはインターフェースは次のようになります。

class MinuteHourCounter {
    public:
        // 新しいデータ点を追加する(count >= 0)
        // 追加された count は直近 60 秒間の合計(MinuteCount)に反映される。
        // 同様に直近 3600 秒間の合計(HourCount)にも反映される。
        void Add(int count);

        // 直近 60 秒間の累積カウントを返す
        int MinuteCount();

        // 直近 3600 秒間の累積カウントを返す
        int HourCount();
};

筆者所感

コメントは情報を補うために有用ですが、多すぎるとレビューや読解の負担になります。インターフェースに書くコメントは、名前だけでは伝わらない「仕様の要点」を簡潔に示すべきです。逆に実装の細かい振る舞い(内部データ構造の詳細や実装トリック)は、インターフェースではなく実装側に置いておく方がよいと考えます。

試案1:素朴な解決策

著者見解

まずはタイムスタンプ付きの「イベント」列をそのまま保持して、要求を満たす素朴な実装を考えます。下記は C++ での概念的な実装例です(細部は環境に合わせて調整してください)。

#include <list>
#include <ctime>

class MinuteHourCounter {
    struct Event {
        Event(int count, std::time_t t) : count(count), time(t) {}
        int count;
        std::time_t time;
    };

    std::list<Event> events;

    // cutoff より新しいイベントの合計を返す(内部ヘルパー)
    int CountSince(std::time_t cutoff) const {
        int sum = 0;
        const std::time_t now_secs = std::time(nullptr);
        for (auto rit = events.rbegin(); rit != events.rend(); ++rit) {
            if (rit->time <= cutoff) break;
            sum += rit->count;
        }
        return sum;
    }

  public:
    // 新しいデータ点を追加する
    void Add(int count) {
        events.push_back(Event(count, std::time(nullptr)));
    }

    // 直近 60 秒間の合計を返す
    int MinuteCount() const {
        return CountSince(std::time(nullptr) - 60);
    }

    // 直近 3600 秒間の合計を返す
    int HourCount() const {
        return CountSince(std::time(nullptr) - 3600);
    }
};

この実装は正しく動作しますが、可読性・保守性の観点で改善できる点があります。

  • CountSince によって MinuteCount / HourCount の共通処理を抽出しているため、重複は減り読みやすくなっています
  • 変数名を cutoff にして「しきい時刻」を表すようにした点、reverse_iterator を rit とした点、for ループを通常の三項形式ではなく明確に書いた点は可読性の向上に寄与します

一方で、この設計には性能上の問題が残ります。

  • events が時間とともに無制限に大きくなる(古いイベントの削除を行っていない)
  • MinuteCount / HourCount が呼ばれるたびに末尾からスキャンするため、呼び出し頻度が高いとコストが大きい

これらの点は次の改良案で対処できます(後続項で扱います)。

筆者所感

この節では DRY(重複排除)原則に基づいて共通コードを抽出するリファクタリング例を示しました。一方で、DRY をドメインを越えて無闇に適用することには注意が必要です。ドメイン駆動設計(DDD)の文脈では、共有化はドメイン境界や責務を侵さない範囲で行うべきです。

  • 共有化するべきかは「本当に複数のドメインで再利用されるか」を基準に判断する
  • 共通化するなら Shared Kernel のような明確な場所に置き、ガバナンス(責任者、互換性ルール、バージョニング)を設ける
  • 共有する利点よりもドメインの独立性を優先すべきケース(例:ドメイン固有の ValueObject)では、重複を許容する

例えばメールアドレスのように不変的で幅広く使える概念は共有化して良い一方、レビュー ID のように特定ドメインに限定される概念は各ドメイン内に留めておく方が運用上楽になることが多いと感じています。

試案2:ベルトコンベヤー設計

著者見解

前節の素朴な実装は正しいものの、時間経過とともにイベントリストが増え続け、かつ MinuteCount()/HourCount() の呼び出しごとに末尾からスキャンして集計していたためパフォーマンス上の問題が残っていました。これを改善する方針は次の2点です。

  • 古くなったデータを適宜削除してメモリを管理すること
  • MinuteCount / HourCount が呼ばれたときに重い計算を行わなくて済むよう、集計値(minute_count, hour_count)を常に最新に保つこと

この方針に基づき、イベントを「ベルトコンベヤー」のように処理する設計にします。新しいイベントはまず minute のキューに入れ、一定時間経過したものを minute から hour に移し、さらに古くなった hour は削除する、という流れです。こうすることで集計は加算/減算のみで維持でき、問い合わせは高速になります。

実装例(概念的な C++):

#include <deque>
#include <ctime>

class MinuteHourCounter {
    struct Event {
        Event(int c, std::time_t t) : count(c), time(t) {}
        int count;
        std::time_t time;
    };

    std::deque<Event> minute_events; // 直近 60 秒間に入ったイベント(まだ移動していない)
    std::deque<Event> hour_events;   // 直近 3600 秒間に含まれるイベント(minute_events から移動してくる)
    int minute_count = 0; // minute_events の合計(=直近 60 秒の合計)
    int hour_count = 0;   // minute_events + hour_events の合計(=直近 3600 秒の合計)

    // now を与えて古いイベントを移動/削除して集計を最新にする
    void ShiftOldEvents(std::time_t now) {
        const std::time_t minute_ago = now - 60;
        const std::time_t hour_ago = now - 3600;

        // minute_events の先頭(最も古い)が 60 秒を超えていたら hour_events に移す
        while (!minute_events.empty() && minute_events.front().time <= minute_ago) {
            const Event ev = minute_events.front();
            minute_events.pop_front();
            minute_count -= ev.count;

            hour_events.push_back(ev);
            // hour_count は Add 時点で既に加算しているのでここでは変えない
        }

        // hour_events の先頭が 3600 秒を超えていたら完全に削除する
        while (!hour_events.empty() && hour_events.front().time <= hour_ago) {
            const Event ev = hour_events.front();
            hour_events.pop_front();
            hour_count -= ev.count;
        }
    }

  public:
    // 新しいデータ点を追加する
    void Add(int count) {
        const std::time_t now = std::time(nullptr);
        // 先に古いイベントを整理しておく
        ShiftOldEvents(now);

        // minute_events に追加し、集計を更新する
        minute_events.emplace_back(count, now);
        minute_count += count;
        hour_count += count; // hour_count は minute_events と hour_events の合計を維持する
    }

    // 直近 60 秒の合計を返す
    int MinuteCount() {
        ShiftOldEvents(std::time(nullptr));
        return minute_count;
    }

    // 直近 3600 秒の合計を返す
    int HourCount() {
        ShiftOldEvents(std::time(nullptr));
        return hour_count;
    }
};

この設計により、Add() と Count の操作は概ね軽くなります(古いイベントを移動/削除する処理は必要ですが、各イベントは最大で数回しか移動・削除されないためアモルタイズドで低コストになります)。また、MinuteCount()/HourCount() 呼び出し時には大きな線形スキャンが不要になり、即座に値を返せます。

一方、欠点や留意点もあります。

  • 柔軟性の不足
    今回は「直近 60 秒」「直近 3600 秒」を想定した実装です。別の時間窓(例:24 時間)を増やすとデータ構造やロジックを拡張する必要があり、汎用化が面倒になる可能性があります。

  • メモリ使用量は「時間窓内のイベント数」に依存する
    イベント到着頻度が極端に高ければキューに溜まるイベント数が増え、メモリ消費が大きくなります。Add() の呼び出し頻度に依存しない固定メモリにしたい場合は、次章で示す「時間分割バケット(固定長の環))」のような手法が有効です。

  • 時刻の単位・精度や time() の呼び出し頻度
    time() を各所で呼ぶ設計はテストやモックを難しくするため、テスト可能性を考えるなら現在時刻を引数で注入できる API(例: Add(count, now) や Count(now))にすることを検討してください。

筆者所感

この設計は queue/deque をうまく使った典型的な応用例で、毎回全イベントを計算し直す代わりに「イベントを流していく」ことで効率よく集計を維持できます。私自身は競技プログラミングなどで基本的なデータ構造の使い方を学ぶ機会がありましたが、今回のような「キューを用いたスライディングウィンドウ集計」は実務でも非常に有用だと感じています。

試案3:時間バケツの設計

著者見解

前節で示した実装にはいくつかの問題点が残ります。ひとつは time_t が秒単位で丸められることによる粒度の問題(ミリ秒単位の精度を求めるなら別対応が必要)です。もうひとつはデータ数が増えるとメモリや処理が増える点です。

本案の考え方は「時間を小さな区間(バケツ)に分割し、バケツごとの合計を保持する」ことです。たとえば直近 1 時間を 60 個のバケツ(各バケツは 1 分間)に分ければ、精度は約 1/60(=1分単位)になります。十分な精度とメモリ効率のトレードオフとして有効な手法です。

複雑さを抑えるため、まずは「指定個数の時間バケツを追跡する」汎用クラス TrailingBucketCounter を設計します。

/*
  TrailingBucketCounter:
  - num_buckets 個のバケツを保持する(常に最新の num_buckets 分の時間を追跡)
  - 各バケツは secs_per_bucket 秒幅を表す
  - Add(count, now) で現在時刻 now のバケツに count を加える
  - TrailingCount(now) で now 時点での直近 (num_buckets * secs_per_bucket) 秒の合計を返す
  - now を引数で受けることでテスト容易性を高めている
*/
class TrailingBucketCounter {
public:
    TrailingBucketCounter(int num_buckets, int secs_per_bucket);
    void Add(int count, std::time_t now);
    int TrailingCount(std::time_t now);

private:
    // 実装で使う補助メンバは以下(実装は後述)
    // ConveyorQueue buckets;
    // int secs_per_bucket;
    // long long last_update_bucket; // 最後に Update したときのバケット番号
};

この TrailingBucketCounter を使って MinuteHourCounter を実装します。ここでは、直近 60 秒を 60 個の 1 秒バケツで追跡し、直近 3600 秒(=1時間)を 60 個の 60 秒バケツで追跡する例を示します。バケツ数や秒幅は要件に応じて調整してください。

class MinuteHourCounter {
    TrailingBucketCounter minute_counts; // 60 バケツ × 1 秒
    TrailingBucketCounter hour_counts;   // 60 バケツ × 60 秒
public:
    MinuteHourCounter()
      : minute_counts(60, 1), hour_counts(60, 60) {}

    // 外部から現在時刻を注入しない API も提供し、内部で time() を使う実装
    void Add(int count) {
        std::time_t now = std::time(nullptr);
        minute_counts.Add(count, now);
        hour_counts.Add(count, now);
    }

    int MinuteCount() {
        std::time_t now = std::time(nullptr);
        return minute_counts.TrailingCount(now);
    }

    int HourCount() {
        std::time_t now = std::time(nullptr);
        return hour_counts.TrailingCount(now);
    }
};

TrailingBucketCounter の内部実装は、個々のバケツを固定長で管理するデータ構造(ここでは ConveyorQueue)を使います。要点は「古いバケツを一括でシフトする」「各バケツの合計値を保持しておく」ことです。Update(now) により経過したバケツ数を計算して内部キューを動かします。

#include <deque>
#include <ctime>

class ConveyorQueue {
    std::deque<int> q;
    int max_items;
    int total_sum; // q に含まれる値の合計
public:
    ConveyorQueue(int max_items) : max_items(max_items), total_sum(0) {}

    int TotalSum() const { return total_sum; }

    // num_shifted 分だけ時間を進める(後ろにゼロバケットを追加し、
    // 必要なら古いバケットを取り除く)
    void Shift(int num_shifted) {
        if (num_shifted <= 0) return;

        // 大きすぎるシフトはキューをクリアするのが高速
        if (num_shifted >= max_items) {
            q.clear();
            total_sum = 0;
            return;
        }

        // 後ろ側に num_shifted 個のゼロを追加し(新しい時間枠の分だけ)、
        // 古い要素が max_items を超えれば先頭から削除する
        for (int i = 0; i < num_shifted; ++i) {
            q.push_back(0);
        }
        // 削除する要素を先頭から取り出し total_sum を減らす
        while ((int)q.size() > max_items) {
            total_sum -= q.front();
            q.pop_front();
        }
    }

    // 末尾のバケツに加算する
    void AddToBack(int count) {
        if (q.empty()) {
            // 最低限 1 バケツは持たせておく
            q.push_back(0);
        }
        q.back() += count;
        total_sum += count;
    }
};

TrailingBucketCounter は ConveyorQueue を用いて、バケツのシフト(経過時間の反映)と追加・集計を行います。ここでは last_update_bucket を使い、前回処理したバケット番号との差分で Shift を決定します。

class TrailingBucketCounter {
    ConveyorQueue buckets;
    const int secs_per_bucket;
    long long last_update_bucket; // 初期値は -1(未初期化)
public:
    TrailingBucketCounter(int num_buckets, int secs_per_bucket_)
      : buckets(num_buckets),
        secs_per_bucket(secs_per_bucket_),
        last_update_bucket(-1) {}

    // now を受け取り内部状態を最新に更新する
    void Update(std::time_t now) {
        long long current_bucket = static_cast<long long>(now) / secs_per_bucket;
        if (last_update_bucket == -1) {
            // 初回は current_bucket をセットしてバケツを 1 個準備する
            last_update_bucket = current_bucket;
            buckets.Shift(1); // 最低限 1 バケツを持たせる
            return;
        }
        int num_shifted = static_cast<int>(current_bucket - last_update_bucket);
        if (num_shifted > 0) {
            buckets.Shift(num_shifted);
            last_update_bucket = current_bucket;
        }
    }

    void Add(int count, std::time_t now) {
        Update(now);
        buckets.AddToBack(count);
    }

    int TrailingCount(std::time_t now) {
        Update(now);
        return buckets.TotalSum();
    }
};

以上で、MinuteHourCounter は高速かつメモリ効率の良い実装になります。各イベントはバケツ(固定数)に集約されるため、メモリ使用が bounded(上限あり)で、Add / Count の処理も軽量です。

筆者所感

時間バケツ方式は「固定メモリで近似的に時間窓集計を行う」典型的な手法です。実務では精度・性能・メモリのトレードオフを考えながら、要件に合うバケツ幅と個数を選ぶことが大切です。また、現在時刻を引数で注入できるようにした設計はテストが容易になり、時間に依存するバグの検出・再現がしやすくなるため、とてもよい設計かと思いました。

3つの解決策を比較する

著者見解

以下は3つの設計案を比較した表です。各項目は概算であり、実装や要件によって変わりますが、設計上のトレードオフを把握するのに有用です。

解決策 コードの行数 HourCount()の計算量 メモリ使用量 HourCount()の誤差
素朴な解決策 約33行 O(N)(イベント数に比例) 制限なし(イベント数に比例して増加) 1/3600(秒単位丸めによる微小誤差)
ベルトコンベヤー設計 約55行 O(1)(アモルタイズド) O(1時間のイベント数)(高頻度だと大きくなる、例:~5M 要素など) 1/3600
時間バケツ設計(バケツ60個) 約98行 O(1) O(バケツ数)(例:約500バイト程度) 1/60(バケツ幅に依存)

これらを比較すると、時間バケツ設計はコード行数が他の案より多くなりますが、次の利点があります。

  • 性能が高く、問い合わせ(MinuteCount/HourCount)が高速で一定時間内に安定して応答できる
  • メモリ使用が固定(バケツ数に依存)で上限があるため、長時間運用でもメモリが増え続けない
  • バケツ数・幅を変えることで精度とメモリのトレードオフを容易に調整できる
  • クラス分割・責務分離を行っているため、可読性や再利用性が高い(読みやすい 100 行は、読みづらい 50 行よりも実務で有利なことが多い)
筆者所感

どの案を採るにしても、時刻の扱い(単位や同期)、テストの注入可能性(now を渡す設計)、および運用時のモニタリングを忘れてはいけません。特に高頻度のトラフィックでは想定より早くメモリやCPUが逼迫するため、事前にベンチマークと監視指標を用意しておくことが肝要です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?