A type-safe, event-driven, thread-safe C++ configuration system with TOML persistence.
It treats configuration as strongly typed variables, supports rich STL and chrono types, provides change events, and allows full customization via Codec<T>.
- Features
- Dependencies
- Field Identity & Default Value Rules
- Operator Overload Support
- Value Access Semantics
- Supported Containers and Types
- Example
- Configuration Events
- Custom Type Serialization
- Thread Safety
- Design Notes
- Strongly typed fields
Field<T>/FieldValue<T>behave like normal variables- Compile-time constraint:
Serializable
- Automatic TOML mapping
- Hierarchical paths via
::(e.g.net::http::port) - Automatically builds nested TOML tables
- Hierarchical paths via
- Event-driven
VALUE_LOADwhen loaded from fileVALUE_CHANGEwhen modified at runtime
- Thread-safe
- Lock hierarchy: global config lock → field registry lock → per-field value lock
- Concurrent
load()/save()and reads/writes are race-free
- Extensible serialization
- Built-in support for many STL, chrono, and utility types
- User-defined types via
Codec<T>
- Batch save
- Changes are marked dirty
Config::save()persists all modified fields at once
- C++23
- ToruNiina/toml11 ≥ 4.4.0
- Neargye/magic_enum ≥ 0.9.7
- stdpp-event
#include "config.hpp"
using namespace stdpp::config;
Field<int> a("x");
Field<int> b("x", 255);
Field<int> c("x::y", 5);
Field<std::string> d("x", "default");- Fields are uniquely identified by their full path string + type signature
Internally the key is formed aspath + "#" + typeid(T).name()(e.g.x#int,x#NSt7__cxx1112basic_string...) - Fields with the same name and type share the same storage
- Fields with the same name but different type coexist independently — no exception thrown
- The first constructed field decides the default value
- Later declarations with the same name and type:
- Same type → reuse existing value, ignore default
- On
Config::load(): when a TOML value type doesn't match a field'sCodec<T>, the field is silently skipped (keeps its default value)
| Declaration | Internal Key | Shared with same key | Default Used |
|---|---|---|---|
Field<int> a("x") |
x#int |
yes | int{} |
Field<int> b("x", 255) |
x#int |
yes | ignored |
Field<std::string> d("x") |
x#NSt7... |
no (different type) | std::string{} |
Field<int> c("x::y", 5) |
x::y#int |
no | 5 |
FieldValue<T> behaves like T if T supports the operator.
=- assign from
T - assign from
FieldValue<T>
+ - * /+= -= *= /=
| & ^|= &= ^=
<< >><<= >>=
++x x++--x x--
i = field + 1i += fieldfield += other_field
All operators are only enabled if the underlying
Tsupports them.
Compound assignments (
+= -= *= /= <<= >>= |= &= ^=) and++/--automatically triggerVALUE_CHANGEand mark the field dirty, so the nextConfig::save()persists the result.
Reading a field always returns a copy of the underlying value, not a reference:
Field<int> x("x", 42);
int val = x; // copy: operator T()
int val2 = *x; // copy: operator*()
int val3 = x.copy(); // explicit copyFor direct reference access, use value_lock() with RAII:
auto lock = x.value_lock(); // locks value_mutex (exclusive)
*lock = 100; // modify in-place via reference
// ~FieldValueMutex() triggers VALUE_CHANGE automaticallyFor hot read paths, use read_lock() — a shared-lock, copy-free const view:
auto guard = x.read_lock(); // shared lock, no copy, no event
const int& v = *guard; // const reference to the current valueThe copy-by-default design ensures thread safety — reads never block the value for longer than necessary.
read_lock()avoids the copy entirely while multiple readers share the lock concurrently.
std::vector<T>std::list<T>std::deque<T>std::forward_list<T>std::array<T, N>
std::queue<T>std::stack<T>std::priority_queue<T>
std::set<T>std::multiset<T>std::map<K, V>std::multimap<K, V>std::unordered_map<K, V>
std::pair<T1, T2>std::tuple<Ts...>std::optional<T>std::variant<Ts...>std::expected<T, E>std::complex<T>std::bitset<N>std::filesystem::pathstd::atomic<T>
std::chrono::durationstd::chrono::hh_mm_ssstd::chrono::sys_timestd::chrono::year_month_daystd::chrono::zoned_time
std::unique_ptr<T>std::shared_ptr<T>
- Any
enumorenum class
Serialized as string names viamagic_enum
Field<int> port("server::port", 8080);
Field<int> port2("server::port", 8080); // Ignore 8080 Parameter
// port == port2
Field<std::vector<int>> vec("test::vec", {1,2,3});
Config::load("config.toml");
Field<std::optional<int>> opt("test::opt", std::nullopt);
Field<Test> mode("app::mode", Test::A);
opt = std::nullopt; // triggers VALUE_CHANGE
Config::save(); // change only
TOML:
[server]
port = 8080
[test]
vec = [1,2,3]
[test.opt]
has = false
# value = 114
[app]
mode = "A"auto h = port.add_event([](auto&, Event ev){
if(ev == Event::VALUE_CHANGE) { /* changed */ }
});Event types:
enum class Event {
VALUE_CHANGE,
VALUE_LOAD
};Define a Codec<T> specialization:
struct Point { int x; int y; };
template<>
struct Codec<Point> {
static toml::value encode(const Point& p) {
return { {"x", p.x}, {"y", p.y} };
}
static Point decode(const toml::value& v) {
return { v.at("x"), v.at("y") };
}
};Usage:
Field<Point> pos("window::pos", {10,20});Three-level lock hierarchy:
config_mutex– protects the config path and the parsed TOML document (file I/O, snapshots)field_mutex– protects the field registry (concurrent field registration is safe)per-field value_mutex– astd::shared_mutexprotecting a single field's value
Lock ordering is config_mutex → field_mutex; all other locks are acquired independently, so no deadlock cycle exists. Decoding runs outside the global lock on a deep-copied snapshot, and dirty-state tracking uses an atomic counter — concurrent load() / save() and save-during-field-registration cannot lose updates.
Manual access:
auto lock = field.value_lock(); // exclusive write guard (auto VALUE_CHANGE on destruction)
auto guard = field.read_lock(); // shared read guard (no copy, no event)Introduced by the hardening refactor (all compile-time visible):
Field<T>()/FieldValue<T>()default constructors are deleted — always use named constructors:Field<int>("x", 42)- Aliases
STR / OPT / MAP / PTR / EXPmoved intodetail— usestd::string,std::optional, ... directly - Binary
&onFieldValuewas removed (&=remains); built-in address-of is preserved - Requires C++23 (
std::expected, chrono formatting) — enforced at compile time
- Global static
Config - Fields cannot be removed at runtime
- Same-name different-type fields coexist independently via
#typesuffix - TOML value type mismatch during load silently skips the field (no exception)
- File is created only on first successful
save() save()returnsfalseon write failure (e.g. disk full) and keeps the dirty state for retrysys_timekeeps sub-second precision;filesystem::pathround-trips as UTF-8 (non-ASCII paths safe)- Invalid
year_month_dayvalues are rejected on load (field keeps its default)
94 assertions covering: 4-thread load/save stress, concurrent field registration, VALUE_CHANGE/VALUE_LOAD/event unsubscribe, round-trip of 26 codec types (including sub-second sys_time, UTF-8 paths, unique_ptr), error paths (type mismatch, invalid dates, save failure retry) and read/write lock concurrency — see test.cpp.