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つの選択肢がありました。
- 例外を投げる(
std::out_of_rangeなど) std::optional<std::string>を返すstd::expected<std::string, Error>を返す
「キーが存在しない」は、KVストアを使う側にとって日常的に起こりうる正常系の一部です——例えばキャッシュの検索や、存在確認を兼ねた読み取りでは、キーが無いことの方が普通にあり得ます。C++の例外は本来「呼び出し側が普段は対処しない、稀な異常系」のために使うのが適切な設計だと考えているため、頻繁に起こりうる失敗を例外で表現するのは合わない、というのが1を外した理由です。
std::optionalとstd::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です。Storeがstd::expectedを返すおかげで、CLI側のエラーハンドリングもtry/catchを使わずif文だけで完結しています。
テスト環境
テストフレームワークには、過去に取り組んだcpp-networkプロジェクトに倣いdoctestをCMakeのFetchContentで導入しました。put→getの往復、上書き、remove、存在しないキーへの操作、size()の増減など、Storeの公開APIそれぞれについて素直な単体テストを書いています。ディスクI/Oが絡まないため、モックも一時ファイルの後始末も不要で、テスト自体は非常にシンプルです。
参考リンク
- cppreference: std::expected —— C++23で導入され、C++26でも中核的に使われるエラー表現の型です
- cppreference: std::unordered_map::insert_or_assign ——
putのupsert挙動の実体です