Clap for C++: turning structs into command-line parsers
In Rust:
#[derive(Parser)]
struct Args {
#[arg(short, long)] verbose: bool,
#[arg(short, long, default_value_t = 1)] count: u32,
input: String,
}
You get --verbose/-v, --count/-c, a positional input, automatic --help, proper error messages — all from declaring the struct.
C++ today uses CLI11 or cxxopts, which require manual .add_option(...) calls per argument. With reflection + annotations from post 9, we can match Rust’s ergonomics.
Target API
struct Args {
[[=cli::short_('v')]] bool verbose = false;
[[=cli::short_('c'), =cli::default_(1u)]] unsigned count = 1;
[[=cli::positional{}]] std::string input;
};
int main(int argc, char** argv) {
auto parsed = cli::parse<Args>(argc, argv);
if (!parsed) { std::println(stderr, "{}", parsed.error()); return 1; }
auto const& args = *parsed;
if (args.verbose) std::println("counting to {}...", args.count);
std::println("processing {}", args.input);
}
Three annotations: short_, default_, positional. One entry point: cli::parse<Args>. Automatic --help. Output:
$ ./tool --help
Usage: tool [OPTIONS] <INPUT>
Arguments:
<INPUT>
Options:
-v, --verbose
-c, --count <COUNT> (default: 1)
-h, --help Print help
How it works
Same pattern as reflect_json. Walk the struct, dispatch on each field’s type, use annotations for customisation. The difference: we’re consuming argv, not producing a string.
The entry point
namespace cli {
template <typename Args>
std::expected<Args, std::string> parse(int argc, char const* const argv[]) {
Args result{};
std::vector<std::string_view> tokens(argv + 1, argv + argc);
// First pass: handle --help
for (auto t : tokens) if (t == "-h" || t == "--help") {
std::print("{}", usage<Args>());
std::exit(0);
}
// Second pass: parse flags and positionals
std::size_t pos_index = 0;
for (std::size_t i = 0; i < tokens.size(); ) {
auto t = tokens[i];
if (auto consumed = try_parse_option(result, tokens.subspan(i))) {
i += *consumed;
} else if (!t.starts_with('-')) {
if (auto ok = assign_positional(result, pos_index, t); !ok) return std::unexpected(ok.error());
++pos_index; ++i;
} else {
return std::unexpected(std::format("unknown option: {}", t));
}
}
return result;
}
} // namespace cli
try_parse_option and assign_positional are the reflection-driven bits.
try_parse_option
For each member of Args:
- If the token matches
--<long>or-<short>, and the field type isbool, set totrue, consume 1 token. - If it matches an option and the field needs a value (
int,std::string, etc.), parse the next token as that value, consume 2 tokens. - If no member matches, return
nullopt.
template <typename Args>
std::optional<std::size_t> try_parse_option(Args& result, std::span<std::string_view const> tokens) {
auto const& t = tokens[0];
constexpr auto ctx = std::meta::access_context::unchecked();
std::optional<std::size_t> consumed;
template for (constexpr auto m
: std::define_static_array(
std::meta::nonstatic_data_members_of(^^Args, ctx))) {
if constexpr (!std::meta::annotation_of_type<positional>(m).has_value()) {
constexpr auto long_name = long_name_of<m>(); // "verbose", "count"
constexpr auto short_name = short_name_of<m>(); // std::optional<char>{'v'}, {'c'}
bool matches = (t == std::format("--{}", long_name))
|| (short_name.has_value() && t.size() == 2
&& t[0] == '-' && t[1] == *short_name);
if (matches) {
using M = [: std::meta::type_of(m) :];
if constexpr (std::is_same_v<M, bool>) {
result.[: m :] = true;
consumed = 1;
} else if (tokens.size() >= 2) {
result.[: m :] = parse_scalar<M>(tokens[1]);
consumed = 2;
}
}
}
}
return consumed;
}
template for unrolls the dispatch. At runtime, the generated code is a flat if/else chain over field names — exactly what you’d hand-write.
Help-text generation
template <typename Args>
consteval std::string usage() {
std::string out = "Usage: prog [OPTIONS]";
constexpr auto ctx = std::meta::access_context::unchecked();
template for (constexpr auto m
: std::define_static_array(
std::meta::nonstatic_data_members_of(^^Args, ctx))) {
if constexpr (std::meta::annotation_of_type<positional>(m).has_value()) {
out += std::format(" <{}>",
to_upper(std::meta::identifier_of(m)));
}
}
out += "\n\nOptions:\n";
template for (constexpr auto m : members<Args>()) {
if constexpr (!std::meta::annotation_of_type<positional>(m).has_value()) {
out += format_option_line<m>();
}
}
return out;
}
Help text is computed at compile time and baked into the binary as a static string. No format string overhead at runtime — typing --help prints a char const*.
Annotations we add
namespace cli {
struct short_ {
char value;
bool operator==(short_ const&) const = default;
};
struct positional {
bool operator==(positional const&) const = default;
};
template <typename T>
struct default_ {
T value;
bool operator==(default_ const&) const = default;
};
struct help_ {
char const* text;
bool operator==(help_ const&) const = default;
};
consteval auto help(std::string_view s) { return help_{std::define_static_string(s)}; }
struct name_ {
char const* value;
bool operator==(name_ const&) const = default;
};
consteval auto name(std::string_view s) { return name_{std::define_static_string(s)}; }
} // namespace cli
Seven total — same pattern as the serde family in post 9.
Subcommands
Clap’s killer feature: subcommands. Represented as a std::variant of command structs:
struct AddCmd { std::string path; [[=cli::short_('f')]] bool force = false; };
struct RemoveCmd { std::string path; };
struct Cli {
bool verbose = false;
std::variant<AddCmd, RemoveCmd> command;
};
int main(int argc, char** argv) {
auto args = cli::parse<Cli>(argc, argv);
std::visit([](auto const& c) { run(c); }, args->command);
}
// ./tool add path/to/thing --force
// ./tool remove path/to/thing
The variant branch is chosen by the first non-flag token. reflect_cli walks the variant alternatives (via template_arguments_of(^^std::variant<...>)) and routes accordingly.
Sidebar: design alternatives for the annotation surface
The [[=cli::positional{0}]] / [[=cli::option{"-n", "--count"}]] pattern uses value-carrying structs per axis — the shape Barry Revzin’s reference example ships and that the P3394R4 examples canonise (empty tag struct for boolean markers, value-carrying struct for parameters). It’s the same split serde-rs settled on after years of evolution.
An equally valid alternative is enum class : unsigned bitflags for orthogonal boolean modifiers:
enum class cli_flags : unsigned {
none = 0,
required = 1 << 0,
hidden = 1 << 1,
env = 1 << 2,
repeatable = 1 << 3,
};
constexpr cli_flags operator|(cli_flags a, cli_flags b) {
return cli_flags(unsigned(a) | unsigned(b));
}
// Pre-named combinations stay short at call sites:
inline constexpr cli_flags required_env = cli_flags::required | cli_flags::env;
struct Args {
[[=cli::option{"--token"}, =required_env]] std::string api_token;
[[=cli::option{"--include", "-i"}, =cli_flags::repeatable]]
std::vector<std::string> includes;
};
The bitflag pattern actually works as advertised — the demo below walks the struct and prints which modifier bits each field carries:
Bitflags win when the modifier set is closed, the combinations recur, and the consumer wants the whole mask as a value — the canonical examples are std::ios::fmtflags and std::filesystem::perms. They lose on:
- Documentation surface — each combination is implicit; clap-rs’s most-cited derive pain (discussion #4090) is exactly this open-endedness.
- Per-bit lookup —
(get_annotation<cli_flags>(m) & required) != 0reads worse thanhas_annotation<required>(m). - Type-distinct queries —
annotations_of_with_type(M, ^^required)is purpose-built for tag types; bitfields force a single combined value through the same channel. - Invalid-combination rejection — overload resolution +
static_assertcan reject impossible tag pairs at compile time; every bit combination is syntactically valid.
And there’s a third orthogonal axis you should use first: the type signature. std::optional<int> count says “optional” without any annotation; std::vector<std::string> includes says “repeatable”. Clap-rs’s Option<T> / Vec<T> convention encodes those modifiers in the type system, not the attribute system, leaving the annotation surface for the genuinely declarative parts (flag names, defaults, env-var bindings). The C++ version follows the same lesson — your annotation taxonomy should cover only what the type system can’t say.
The demo below runs a shape_of<T>() consteval helper across the fields of an Args struct and prints the per-field shape derived from the type alone — no annotations involved:
What about CLI11 / cxxopts?
They’re fine libraries. The reflection version gives you:
- No per-argument call. Struct declaration is the spec.
- Type-safe assignment.
args.countis already auint32_t, not parsed later fromstd::string. - Automatic help. Generated from annotations + identifiers.
- Compile-time validation. Forgetting to handle a field is a compile error — you’ll know before shipping, not at runtime when a user passes
--missing-flag.
What you lose: less magical-looking .add_option(...) chains that may feel more discoverable to readers unfamiliar with reflection. Trade-off.
Limitations of the naive version
- No mutually-exclusive groups (yet). serde-analog:
#[arg(group = ...)]. Implementable via annotations, left as exercise. - No variable-length positionals.
std::vector<std::string> filescould collect trailing args, but the lexer needs a small addition. - No env-var fallback (
CLI_ENV_<NAME>). An annotation handles it.
These are three days of iteration each. The foundation is solid.
What’s next
- Post 13 — A tiny ORM — struct → SQL.
- Post 14 — Autowired DI — reflect constructor parameters.