一个 JSON 文件如何生成整套 C++ 选项 API——f3d options 代码生成机制图解与本地运行指南
【免费下载链接】f3dFast and minimalist 3D viewer.项目地址: https://gitcode.com/GitHub_Trending/f3/f3d
f3d 是一个主打"快而极简"的 3D 查看器,它的几十项渲染、交互、模型开关全部收进一个options对象,可选项多到让人怀疑这堆 C++ 方法是不是手写的。答案是否定的:整套options::set / get / reset / getRangeDomain的分支逻辑,其实是 CMake 在 configure 阶段读一个 JSON 文件后拼出来的。本文以 cmake/f3dOptions.cmake 的_parse_json_option为核心,带你看懂"JSON 如何变成 C++ 选项 API",读完后你能独立解释生成物长什么样、本地跑通一段等价代码,并判断这种"JSON 驱动生成"相比手写注册表是否合适。
上面这张 16:9 的defaultHDRI.png正是options里render.hdri.file(一个path类型选项)的兜底环境贴图——它是被选项系统"管着"的一张真实资源,用来让你直观感受一下options.json背后控制的是真材实料的渲染行为。
复制这段代码先跑起来:3 个选项的最小 API
在钻进 CMake 脚本前,先把"生成物最终长什么样"跑起来。下面这段 C++ 是f3d_generate_options()会往 library/private/options_generated.h.in 里注入内容的缩小等价版(只保留 3 个真实存在的选项),结构、命名、异常语义都与仓库生成物一致:
// driver.cxx —— 镜像 f3d_generate_options() 生成的 options_generated 命名空间 #include <iostream> #include <optional> #include <string> #include <variant> #include <vector> #include <stdexcept> using option_variant_t = std::variant<bool, int, double, std::string, std::vector<double>, std::vector<int>>; // 生成到 public/options.h 的 ${_options_struct}:有默认值 → 普通成员;无默认值 → std::optional struct options { struct scene { struct camera { std::optional<int> index; } camera; // scene.camera.index,无 default_value } scene; struct render { std::optional<double> line_width; // render.line_width,无 default_value struct grid { int subdivisions = 10; } grid; // render.grid.subdivisions,default_value: 10 } render; }; namespace options_generated { void set(options& opt, std::string_view name, const option_variant_t& value) { if (name == "scene.camera.index") opt.scene.camera.index = std::get<int>(value); else if (name == "render.line_width") opt.render.line_width = std::get<double>(value); else if (name == "render.grid.subdivisions") opt.render.grid.subdivisions = std::get<int>(value); else throw std::runtime_error("Option " + std::string(name) + " does not exist"); } option_variant_t get(const options& opt, std::string_view name) { if (name == "scene.camera.index") return opt.scene.camera.index.value(); else if (name == "render.line_width") return opt.render.line_width.value(); else if (name == "render.grid.subdivisions") return opt.render.grid.subdivisions; else throw std::runtime_error("Option " + std::string(name) + " does not exist"); } std::vector<std::string> getNames() { return { "scene.camera.index", "render.line_width", "render.grid.subdivisions" }; } } // namespace options_generated int main() { options opt; auto& g = options_generated; g::set(opt, "render.grid.subdivisions", 20); g::set(opt, "scene.camera.index", 2); g::set(opt, "render.line_width", 0.5); std::cout << "subdivisions = " << std::get<int>(g::get(opt, "render.grid.subdivisions")) << "\n"; std::cout << "camera.index = " << std::get<int>(g::get(opt, "scene.camera.index")) << "\n"; std::cout << "line_width = " << std::get<double>(g::get(opt, "render.line_width")) << "\n"; for (const auto& n : g::getNames()) std::cout << "name: " << n << "\n"; }复制源码后执行(g++即可,无需 cmake):
g++ -std=c++17 driver.cxx -o driver && ./driver输出概括:subdivisions = 20(带默认值的普通成员被改写)、camera.index = 2与line_width = 0.5(无默认值,被 set 后从"无值"变为"有值"),随后逐行打印出全部选项的点分名。这就建立了第一个直觉:所谓options的 API,本质是一个if (name == "…")的长分支 + 一个成员结构体,而这两样东西都不是人写的。
代码藏在哪儿:options.json 与 configure_file 的两段模板
真正的"隐藏"不在 URL 里,而在构建期。library/CMakeLists.txt调用:
include(f3dOptions) f3d_generate_options( INPUT_JSON "…/options.json" INPUT_PUBLIC_HEADER "…/public/options.h.in" INPUT_PRIVATE_HEADER "…/private/options_generated.h.in" DESTINATION "…/binary" OUTPUT_NAME "options")f3dOptions.cmake里做的事可以拆成三步:
# 1) 把 JSON 读进来登记为依赖,options.json 一改就触发重新 configure set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${…INPUT_JSON}) file(READ ${…INPUT_JSON} _options_json) _parse_json_option(${_options_json}) # 递归解析,往 13 个 list 变量里追加分支 # 2) 把每个 list 用 ";\n else " 之类的分隔符拼成一大串 C++ list(JOIN _options_setter ";\n else " _options_setter) # 3) 注入模板占位符,产出最终头文件 configure_file("…options.h.in" "…/public/options.h") configure_file("…options_generated.h.in" "…/private/options_generated.h")模板里的${_options_setter}、${_options_struct}、${_options_get_range_domain}就是"元数据槽位":CMake 解析 JSON 时给每个选项追加一条if (name == "…")片段,list(JOIN …)把它们用;\n else粘成一条链,configure_file再把粘好的代码填进槽位。换句话说,选项 API 的"源码"其实分散在 library/options.json(数据)和 13 个 CMake list 变量(拼接中间态)里,.h只是它们的投影。这也是CMAKE_CONFIGURE_DEPENDS登记 JSON 的原因——改 JSON 不改 C++,重配置后 API 自动变。
先搞懂一个约定:选项名是点分路径,而不是成员名
读这段生成逻辑前必须先记住一条约定,否则会处处踩坑:选项的"名字"是带点的完整路径,而不是 C++ 成员名。结构体里字段叫subdivisions,但对外名字是render.grid.subdivisions。
名字是递归时靠_option_basename一点点拼出来的:
# 遇到分组(没有 type 的对象)就压栈:basename += "grid." string(APPEND _option_basename "${_member_name}.") set(_option_name "${_option_basename}${_member_name}") # "render.grid." + "subdivisions"最小例子:vertices = scene / camera / index这层嵌套,成员名是index,可set("index", 2)会一路滑到else抛inexistent_exception,只有set("scene.camera.index", 2)才命中分支。混淆"成员名"与"选项名"是这套 API 最常见的错——它不像普通 C++ 那样拼错就编译报错,而是运行时静默走到else抛异常,再靠 library/src/options.cxx 里getClosestOption(走 Levenshtein 距离)给你"你是不是想写 xxx"的兜底提示。
逐个方法拆解:叶子判定、类型映射与默认值、三种 domain
叶子判定:type 字段是分水岭
_parse_json_option对每个键先看它是不是"叶子":
string(JSON _option_type ERROR_VARIABLE _option_type_error GET ${_cur_json} "type") string(JSON _option_type_type ERROR_VARIABLE _option_type_type_error TYPE ${_cur_json} "type") if (_option_type_error STREQUAL "NOTFOUND" AND ${_option_type_type} STREQUAL "STRING") # 有 type 且 type 是字符串 → 叶子选项,走下面的生成逻辑 else() # 没有 type(是个对象)→ 分组,压栈后递归 _parse_json_option(${_cur_json}) endif()为什么这样判断:分组节点(如grid)本身没有type,而叶子节点必有type且其值是字符串。这一条 if/else 把"结构"与"数据"彻底分开,后面所有生成都只对叶子发生。
类型映射:JSON 类型名到 C++ 类型
叶子拿到type后,映射成真正的 C++ 类型(节选自脚本中段的一长串elseif):
if(_option_type STREQUAL "int_vector") set(_option_actual_type "std::vector<int>") elseif(_option_type STREQUAL "string") set(_option_actual_type "std::string") elseif(_option_type STREQUAL "path") set(_option_actual_type "std::filesystem::path") set(_option_variant_type "std::string") set(_option_variant_convert ".string()") # get 时把 path 转回 string 塞进 variant elseif(_option_type STREQUAL "color") set(_option_actual_type "f3d::color_t") set(_option_variant_type "std::vector<double>") # ... ratio / direction / colormap / transform2d 同理注意_option_actual_type(结构体里真实存储类型,如f3d::color_t)和_option_variant_type(进option_variant_t的类型,如std::vector<double>)是两个东西——前者保证类型安全,后者是为了让所有选项都能装进那 6 种std::variant。path的.string()就是在两者之间做换算。
默认值还是 std::optional
if (_default_value_error STREQUAL "NOTFOUND") # 有 default_value → 普通成员,直接带初值 string(APPEND _options_struct "… ${_option_actual_type} ${_member_name} = ${_init};\n") list(APPEND _options_is_optional "if (name == \"${_option_name}\") return false") else() # 无 default_value → std::optional<T>,get 时靠 .value() 取 string(APPEND _options_struct "… std::optional<${_option_actual_type}> ${_member_name};\n") set(_optional_getter ".value()") endif()这里的后果链很关键:optional 选项在从未 set时调用get,.value()会抛std::bad_optional_access,被 library/private/options_generated.h.in 的外层try捕获后转成no_value_exception——这正是options.h里每个get都声明可能抛no_value_exception的原因。而普通成员永远"有值",isOptional对它们恒返回false。
三种 domain:range / enum / index
if(_option_domain_style STREQUAL "range") # 校验 min/max/increment 齐全,否则 FATAL_ERROR string(APPEND _options_domains_struct "… DomainRange<${_type}> ${_member_name} = {min, max, inc};\n") list(APPEND _options_increase_decrease "… options_tools::increaseDecrease<Up>(opt.${name}, opt.domains.${name})") elseif(_option_domain_style STREQUAL "enum") # 逐个取出 enum 值,拼成 DomainEnum elseif(_option_domain_style STREQUAL "index") # 仅允许 int,拼 DomainIndexdomain决定了一个选项"能怎么被调节":range支持increase/decrease(带上下限钳制),enum支持cycle(在列表里轮转),index两者皆可。三种 domain 的取值互斥——对range选项调用getEnumDomain会在生成好的分支里直接抛incompatible_exception,这是靠"错配即抛异常"保证语义清晰,而非返回空。
推演状态:一段嵌套 JSON 递归解析后生成了什么
拿下面这段真实子集走一遍递归,观察_option_basename与两份"输出"如何同步增长:
{ "scene": { "camera": { "index": { "type": "int", "domain": {"style": "index"} } } }, "render": { "line_width": { "type": "double" } } }| 步骤 | _option_basename | 生成的_option_name | 追加到_options_struct | 追加到_options_setter |
|---|---|---|---|---|
进入scene | ""→scene. | —(分组) | struct scene { | — |
进入camera | scene.→scene.camera. | —(分组) | struct camera { | — |
叶子index | scene.camera. | scene.camera.index | std::optional<int> index; | if (name == "scene.camera.index") opt.scene.camera.index = std::get<int>(value) |
| 弹出两层 | "" | — | } camera; } scene; | — |
进入render | ""→render. | —(分组) | struct render { | — |
叶子line_width | render. | render.line_width | std::optional<double> line_width; | if (name == "render.line_width") opt.render.line_width = std::get<double>(value) |
两个要点:其一,成员名永远是最内层键名(index),而选项名是缩进路径的快照(scene.camera.index),二者只在顶层不冲突时才偶然相同;其二,struct嵌套与if分支逐条同步生成,所以"结构"和"分派"永远对得上,不会漏配一个选项。整轮是单次线性遍历,每个叶子只做常数次list(APPEND),总开销 $O(N)$($N$ 为选项数)。
选型对照:JSON 驱动生成 vs 手写注册表
把这套"JSON 驱动生成"和常见的"手写注册表"放在一起看:
| 维度 | f3d:JSON + CMake 生成 | 手写注册表(如std::map<name, lambda>) |
|---|---|---|
| 加一个选项 | 改 library/options.json,重配置即可 | 手写成员 + 每个方法的分支 + 文档,多处同步 |
set/get运行时开销 | if/else链线性扫描,最坏 $O(N)$($N≈96$) | 哈希 $O(1)$ 平均 |
| 类型安全 | 编译期成员 +std::get<T>校验 | 多靠void*/variant运行时断言 |
| domain/默认值/可选性 | 数据里声明,自动落到 domain 结构 | 需逐字段手工维护 |
| 拼错名字 | 落到else抛异常,getClosestOption给建议 | 取决于实现 |
一句话选型:选项数量大、类型杂、还要对外暴露 SDK 文档时,"数据即代码"的生成式更省维护成本——加选项只碰一个 JSON;而选项极少、且要求 $O(1)$ 查找的场景,手写注册表更直接。进阶方向可以看仓库怎么给生成式"补强":getClosestOption用 library/src/levenshtein.cxx 的 Levenshtein 距离,把线性扫描的短板(拼错名)转成了"最接近建议",这正是生成式 API 常见的体验补丁。解析规则本身则被 library/testing/TestSDKOptionsIO.cxx 锁死,改 JSON 语义时它会立刻报错。
收尾要点
- 核心机制:选项 API 不是手写的——
f3d_generate_options()在 configure 期递归读options.json,把每个选项追加成if (name == "…")分支,再configure_file注入options.h.in/options_generated.h.in两个模板槽位,产出public/options.h与private/options_generated.h。 - 关键约定:选项名 = 点分路径(由
_option_basename递归拼接),成员名 = 最内层键;有default_value是普通成员、无则是std::optional,get未 set 时经.value()抛no_value_exception。 - 复杂度画像:生成期单次线性 $O(N)$;运行时
set/get是if/else链最坏 $O(N)$(非哈希),拼错名靠 Levenshtein 兜底。 - 适用场景:选项多、类型杂、需要统一 SDK 文档的 C++ 库。想继续深挖:cmake/f3dOptions.cmake(生成逻辑本体)、library/private/options_generated.h.in(生成的分派模板)、library/src/options.cxx(薄封装层与异常语义)。
【免费下载链接】f3dFast and minimalist 3D viewer.项目地址: https://gitcode.com/GitHub_Trending/f3/f3d
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考