如何实现类似nlohmann/json的隐式初始化语法,使数组与对象初始化无歧义?
我太懂你这种想复刻nlohmann/json那种“随手写大括号就能构造数组/对象”的丝滑感的执念了!之前我也折腾过几乎一模一样的问题,核心矛盾就是编译器分不清{{"key", val}}到底该走对象构造还是数组构造——毕竟内层的大括号既可以看成键值对,也能看成数组元素的初始化列表。咱们一步步来解决这个歧义问题,先从C17的方案说起,再聊聊C20的优化。
问题根源拆解
你当前的代码里,两个初始化列表构造函数的重载会让编译器犯难:
- 对象构造函数:接受
std::initializer_list<std::pair<const char*, Node>> - 数组构造函数:接受
std::initializer_list<Node>
当你写{{"key1", 1}}时,内层的{"key1",1}既可以直接构造一个pair<const char*, Node>,也可以通过数组构造函数生成一个Node(数组类型),编译器觉得两个构造函数都可行,直接抛出歧义错误。
C++17解决方案:用SFINAE屏蔽歧义匹配
我们可以给数组构造函数加上SFINAE约束,让它只在初始化列表的元素不能转换为键值对时才参与重载决议。这样当遇到键值对形式的元素时,编译器只会匹配对象构造函数;遇到普通元素时,才会走数组构造逻辑。
修改后的代码如下:
#include <initializer_list> #include <iostream> #include <map> #include <string> #include <utility> #include <variant> #include <vector> #include <type_traits> // 新增头文件,用于SFINAE class Node { public: using value_type = std::variant< std::monostate, bool, double, std::string, std::vector<Node>, std::map<std::string, Node> >; // 基础类型的隐式构造函数保持不变 Node() = default; Node(bool value) : data(value) {} Node(int value) : data(static_cast<double>(value)) {} Node(double value) : data(value) {} Node(const char* value) : data(std::string(value)) {} Node(std::string value) : data(std::move(value)) {} // 对象构造函数:保持不变 Node(std::initializer_list<std::pair<const char*, Node>> init) { std::map<std::string, Node> m; for (const auto& p : init) { m[p.first] = p.second; } data = std::move(m); } // 数组构造函数:用SFINAE约束,排除能转换为键值对的元素 template <typename T> Node(std::initializer_list<T> init, std::enable_if_t<!std::is_constructible_v<std::pair<const char*, Node>, T>, int> = 0) { data = std::vector<Node>(init.begin(), init.end()); } // print方法保持不变 void print() const { if (std::holds_alternative<std::map<std::string, Node>>(data)) { std::cout << "Object with " << std::get<std::map<std::string, Node>>(data).size() << " keys\n"; } else if (std::holds_alternative<std::vector<Node>>(data)) { std::cout << "Array with " << std::get<std::vector<Node>>(data).size() << " elements\n"; } else { std::cout << "Other type\n"; } } private: value_type data; }; int main() { // 正常构造数组 Node array_node = {1, "two", 3.0, true}; array_node.print(); // 输出:Array with 4 elements // 现在可以正常构造对象,无歧义 Node object_node = {{"key1", 1}, {"key2", "value2"}, {"key3", 3.14}}; object_node.print(); // 输出:Object with 3 keys // 空初始化对应null节点,符合nlohmann/json的行为 Node null_node = {}; null_node.print(); // 输出:Other type return 0; }
原理说明
std::is_constructible_v<std::pair<const char*, Node>, T>会检查类型T能不能用来构造键值对。当T是可以转换为键值对的类型(比如内层的{"key", val}这种braced-init-list),这个表达式的值为true,!取反后变成false,SFINAE就会禁用这个数组构造函数,编译器只能选择对象构造函数。而当元素是普通类型(比如int、const char*)时,表达式为false,数组构造函数正常启用。
C++20优化:用概念(Concepts)让代码更清晰
C++20的概念特性可以让我们把SFINAE的约束写得更直观、可读性更强,不需要依赖晦涩的enable_if语法:
#include <concepts> // 新增头文件,用于Concepts // ... 其他代码保持不变 // 数组构造函数:用Concept约束替代SFINAE template <typename T> requires (!std::convertible_to<T, std::pair<const char*, Node>>) Node(std::initializer_list<T> init) { data = std::vector<Node>(init.begin(), init.end()); }
std::convertible_to<T, U>概念直接检查T是否能隐式转换为U,逻辑和SFINAE版本完全一致,但代码可读性提升了一大截,一眼就能看出来这个构造函数的适用场景。
额外细节:对齐nlohmann/json的空初始化行为
你提到Node n = {}要对应null节点,当前代码的默认构造函数已经做到了——Node() = default;会把data初始化为std::monostate,也就是variant的第一个状态,对应null类型,和nlohmann/json的行为一致。
内容来源于stack exchange

