std::expectedで返すオンメモリKVストアを実装する——例外を使わずに「キーが無い」を表現する

LMDBやBoltDBのようなキーバリュー型DBエンジンを、C++26でゼロから実装していくプロジェクトを始めました。永続化・B-tree・トランザクション・レプリケーションといった要素を段階的に組み上げていく計画ですが、その第一歩として、まずディスクI/Oを一切持たないオンメモリのKVストアStoreを実装しました。std::unordered_mapをラップしただけの単純な型ですが、後続のB-treeベースの実装と挙動を揃えておくための土台になります。

型設計

// include/kvstore/store.hpp
enum class Error {
    key_not_found,
};

class Store {
public:
    void put(std::string key, std::string value);

    [[nodiscard]] std::expected<std::string, Error> get(const std::string& key) const;

    std::expected<void, Error> remove(const std::string& key);

    [[nodiscard]] std::size_t size() const noexcept;

private:
    std::unordered_map<std::string, std::string> table_;
};
// src/store.cpp
void Store::put(std::string key, std::string value) {
    table_.insert_or_assign(std::move(key), std::move(value));
}

std::expected<std::string, Error> Store::get(const std::string& key) const {
    auto it = table_.find(key);
    if (it == table_.end()) {
        return std::unexpected(Error::key_not_found);
    }
    return it->second;
}

std::expected<void, Error> Store::remove(const std::string& key) {
    if (table_.erase(key) == 0) {
        return std::unexpected(Error::key_not_found);
    }
    return {};
}

putは既存キーがあれば上書きするupsert(insert_or_assign)です。KVストアのputに「キーが既に存在したらエラーにする」という選択肢はほぼなく、上書きが自然な挙動なので、ここで例外的な分岐は用意していません。

例外ではなくstd::expectedを選んだ理由

getで「キーが見つからない」ことをどう表現するかには、大きく3つの選択肢がありました。

  1. 例外を投げる(std::out_of_rangeなど)
  2. std::optional<std::string>を返す
  3. std::expected<std::string, Error>を返す

「キーが存在しない」は、KVストアを使う側にとって日常的に起こりうる正常系の一部です——例えばキャッシュの検索や、存在確認を兼ねた読み取りでは、キーが無いことの方が普通にあり得ます。C++の例外は本来「呼び出し側が普段は対処しない、稀な異常系」のために使うのが適切な設計だと考えているため、頻繁に起こりうる失敗を例外で表現するのは合わない、というのが1を外した理由です。

std::optionalstd::expectedはどちらも「失敗しうる戻り値」を型で表現する点は同じですが、std::optionalは失敗の理由を運べません。今のStoreは失敗理由がkey_not_foundしかないので実質差は出ませんが、この先B-treeやPager層でI/Oエラーなど複数の失敗要因を扱うことが分かっているため、最初からstd::expected<T, Error>で統一しています。呼び出し側はif (auto result = store.get(key); result.has_value())のように、例外機構を使わずに正常系・異常系を分岐できます。

CLI

// apps/kvstore_cli.cpp(抜粋)
if (command == "put") {
    std::string key, value;
    iss >> key >> value;
    store.put(key, value);
    std::cout << "OK\n";
} else if (command == "get") {
    std::string key;
    iss >> key;
    if (auto result = store.get(key); result.has_value()) {
        std::cout << *result << "\n";
    } else {
        std::cout << "(not found)\n";
    }
}

標準入力からput/get/del/quitを読み取るだけの単純なREPLです。Storestd::expectedを返すおかげで、CLI側のエラーハンドリングもtry/catchを使わずif文だけで完結しています。

テスト環境

テストフレームワークには、過去に取り組んだcpp-networkプロジェクトに倣いdoctestをCMakeのFetchContentで導入しました。putgetの往復、上書き、remove、存在しないキーへの操作、size()の増減など、Storeの公開APIそれぞれについて素直な単体テストを書いています。ディスクI/Oが絡まないため、モックも一時ファイルの後始末も不要で、テスト自体は非常にシンプルです。

参考リンク