Here is a fun little puzzle (not for me mind you - I've been trying at it for ages). Here's a snippet of code:
#include <initializer_list>
#include <iostream>
#include <map>
#include <string>
#include <utility>
#include <variant>
#include <vector>
class Node {
public:
using value_type = std::variant<
std::monostate,
bool,
double,
std::string,
std::vector<Node>,
std::map<std::string, Node>
>;
// Implicit constructors for basic types
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)) {}
// Initializer list constructors - attempting to mimic nlohmann/json behavior
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);
}
Node(std::initializer_list<Node> init) {
data = std::vector<Node>(init);
}
// For demonstration
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() {
// This works fine - creates an array
Node array_node = {1, "two", 3.0, true};
array_node.print();
// This causes ambiguity error - trying to create an object
// This causes compilation error:
// "more than one instance of constructor matches the argument list"
Node object_node = {{"key1", 1}, {"key2", "value2"}, {"key3", 3.14}};
// object_node.print();
return 0;
}
The expected behavior is:
- Node array_node = {1, "two", 3.0, true}; // Should create array
- Node object_node = {{"key1", 1}, {"key2", "value2"}}; // Should create object
The issue is that I can't get the compiler to disambiguate between the two intializer_list constructors for the object case because {"key1", 1}
for instance can be interpreted as either std::pair<const char*, Node>
or a Node
itself, so it's ambiguous as to which constructor to use.
I'm mainly looking for solutions in C++17, but C++20+ are also welcome.
Edit: I see there's a lot of people rightfully suggesting that this could be seen as a bad API design, and you can read Daniel McLaury's answer to find some details. However, the very popular nlohmann/json library's API has been designed as such; whether or not their design is flawed in this manner is not relevant to the question. The purpose of this question is to replicate their API design, an API that many people are familiar with. E.g., "what happens with Node n = {}
?" Well, we'll just look at how the json lib does it - turn it into a null node.
I traced back how nlohmann/json does it (https://github.com/nlohmann/json/blob/develop/include/nlohmann/json.hpp#L910), then projected it onto the problem outlined in this question. One benefit is that there's no fiddling around with templates, SFINAE, or other "dirty" compiler tricks. Another is that you can still explicitly call the array or object initialization directly rather than relying on deduction. I also like how it can all go in cpp file; no header bloating if you don't want. Here it is:
#include <algorithm>
#include <initializer_list>
#include <iostream>
#include <map>
#include <string>
#include <utility>
#include <variant>
#include <vector>
enum class value_t {
null, // null type
boolean, // boolean
number, // double (represents any numnber)
string, // string
array, // array
object // object
};
class Node {
public:
using value_type = std::variant<
std::monostate,
bool,
double,
std::string,
std::vector<Node>,
std::map<std::string, Node>
>;
using initializer_list_t = std::initializer_list<Node>;
// Implicit constructors for basic types
Node() : data(std::monostate{}), data_t(value_t::null) {}
Node(bool value) : data(value), data_t(value_t::boolean) {}
Node(int value) : data(static_cast<double>(value)), data_t(value_t::number) {}
Node(double value) : data(value), data_t(value_t::number) {}
Node(const char* value) : data(std::string(value)), data_t(value_t::string) {}
Node(std::string value) : data(std::move(value)), data_t(value_t::string) {}
// create a container (array or object) from an initializer list
Node(initializer_list_t init, bool type_deduction = true, value_t manual_type = value_t::array) {
// Early exit for empty initializer list
if (init.size() == 0) {
if (!type_deduction && manual_type == value_t::object) {
data_t = value_t::object;
data = std::map<std::string, Node>{};
} else {
data_t = value_t::array;
data = std::vector<Node>{};
}
return;
}
// Determine if this should be an object
bool should_be_object = false;
if (type_deduction) {
// Only check if all elements look like key-value pairs
should_be_object = std::all_of(init.begin(), init.end(),
[](const Node& n) {
if (n.data_t != value_t::array) return false;
const auto* arr = std::get_if<std::vector<Node>>(&n.data);
return arr && arr->size() == 2 &&
(*arr)[0].data_t == value_t::string;
});
} else if (manual_type == value_t::object) {
// Validate that all elements can be interpreted as key-value pairs
bool valid_object = std::all_of(init.begin(), init.end(),
[](const Node& n) {
if (n.data_t != value_t::array) return false;
const auto* arr = std::get_if<std::vector<Node>>(&n.data);
return arr && arr->size() == 2 &&
(*arr)[0].data_t == value_t::string;
});
if (!valid_object) {
throw std::runtime_error("error 301: cannot create object from initializer list.");
}
should_be_object = true;
}
if (should_be_object) {
// Create object
data_t = value_t::object;
auto& map_data = data.emplace<std::map<std::string, Node>>();
// Reserve space if possible (though std::map doesn't have reserve)
for (const auto& pair : init) {
const auto& vec = std::get<std::vector<Node>>(pair.data);
map_data.emplace(
std::get<std::string>(vec[0].data),
std::move(vec[1])
);
}
} else {
// Create array
data_t = value_t::array;
data = std::vector<Node>(init);
}
}
/// @brief explicitly create an array from an initializer list
static Node array(initializer_list_t init = {})
{
return Node(init, false, value_t::array);
}
/// @brief explicitly create an object from an initializer list
static Node object(initializer_list_t init = {})
{
return Node(init, false, value_t::object);
}
int size() const {
if (data_t == value_t::array) {
return std::get<std::vector<Node>>(data).size();
} else if (data_t == value_t::object) {
return std::get<std::map<std::string, Node>>(data).size();
} else {
return 1;
}
}
void print(int indent = 0) const {
std::string prefix(indent * 2, ' ');
if (std::holds_alternative<std::map<std::string, Node>>(data)) {
const auto& m = std::get<std::map<std::string, Node>>(data);
std::cout << "Object with " << m.size() << " keys\n";
for (const auto& [k, v] : m) {
std::cout << prefix << " \"" << k << "\": ";
v.print(indent + 1);
}
} else if (std::holds_alternative<std::vector<Node>>(data)) {
const auto& v = std::get<std::vector<Node>>(data);
std::cout << "Array with " << v.size() << " elements\n";
for (size_t i = 0; i < v.size(); ++i) {
std::cout << prefix << " [" << i << "]: ";
v[i].print(indent + 1);
}
} else if (std::holds_alternative<double>(data)) {
std::cout << "Number: " << std::get<double>(data) << "\n";
} else if (std::holds_alternative<std::string>(data)) {
std::cout << "String: \"" << std::get<std::string>(data) << "\"\n";
} else if (std::holds_alternative<bool>(data)) {
std::cout << "Bool: " << (std::get<bool>(data) ? "true" : "false") << "\n";
} else {
std::cout << "null\n";
}
}
private:
value_type data;
value_t data_t;
};
int main() {
auto& arr = Node::array;
auto& obj = Node::object;
// Creates an array
Node array_node = {1, "two", 3.0, true};
std::cout << "array_node: ";
array_node.print();
std::cout << "\n";
// Creates an object - {"key", value} aggregate-initializes std::pair
Node object_node = {{"key1", 1}, {"key2", "value2"}, {"key3", 3.14}};
std::cout << "object_node: ";
object_node.print();
std::cout << "\n";
// Nested structures
Node complex = {
{"array", {1, 2, 3}},
{"object", {{"nested", "value"}, {"count", 42}}},
{"mixed", {1, "two", {{"inner", "object"}}, true}}
};
std::cout << "complex: ";
complex.print();
Node crazy = {
{{}, {}, {{"one", 1}, {"two", 2}, {"three", {1, 2, 3}}, {"hey", "ho", "no"}}},
{{"one", 1}, {"two", 2}, {"three", arr({{"four", 4}, {"five", {3, {1, 2, 3}, 6.5, {{"one", 1}, {"two", 2}}}}, {"six", obj({})}})}},
{{{}, arr({obj({}), obj({}), arr({})}), {{}, {true, false, arr({{}, {false, 5.6}})}, {{}}}}},
{1, 2, 3, 4, {5, 6, 7, 8, {{"3", 4}}, {{1, 2}}, {1, 2}}, 8.6, true, false, {{false, {{"one", true}, {"two", false}}}}}
};
std::cout << "crazy: ";
crazy.print();
return 0;
}
Edit: there is an issue with this solution where Nodes are always copied when moving from the initializer list to the data member, because initializer lists always yield const elements. See my reply here on how that can be fixed if you're interested: https://stackoverflow.com/a/79685187/2220153