Capstone 1: a JSON value

A JSON value is one of six things, and the six are fixed by the format. std::variant is the type for exactly that situation: a closed set of alternatives, checked by the compiler, with std::visit for the operations.

The variant is recursive, because an array holds values and an object maps names to values. Lesson 133 showed the variant in passing; here it carries a document, and the parser of the previous lesson fills it.

The value type is also the place where a document is compared, copied and stored, and the variant gives all three for free. A class with a tag and a union would need a hand-written copy, comparison and destructor.

The accessors also make the value safe to pass around: a caller that receives a const value& can ask for an object, an array or a number and gets a pointer or nothing, never a surprise exception.

The six alternatives

The variant holds std::nullptr_t, bool, double, std::string, std::vector<value> and std::map<std::string, value>. The last two mention value, which is legal because a vector of an incomplete type is a complete declaration.

The program builds one value of each kind and prints the name of the alternative, using index(). The output is the six names in declaration order.

The alternative list is the format's type system, and the compiler checks every switch that handles it. A seventh kind would be a change to the type, not a silent extra case, which is the difference from a string tag.

#include <iostream> #include <map> #include <string> #include <variant> #include <vector> struct value { std::variant<std::nullptr_t, bool, double, std::string, std::vector<value>, std::map<std::string, value>> data; }; const char* kind(const value& item) { switch (item.data.index()) { case 0: return "null"; case 1: return "bool"; case 2: return "number"; case 3: return "string"; case 4: return "array"; default: return "object"; } } int main() { value nothing{nullptr}; value flag{true}; value number{1.5}; value text{std::string{"hi"}}; value list{std::vector<value>{}}; value record{std::map<std::string, value>{}}; std::cout << kind(nothing) << ' ' << kind(flag) << ' ' << kind(number) << ' '; std::cout << kind(text) << ' ' << kind(list) << ' ' << kind(record) << '\n'; }

g++ -std=c++23 -Wall -Wextra type.cpp -o value_kinds
./value_kinds

null bool number string array object

Accessors that return pointers

std::get_if<T>(&variant) returns a pointer to the alternative or nullptr. The accessors use it, so a caller can ask for an object on a number and get a null pointer instead of an exception.

The program builds an object with one number and checks the three accessors: the object is there, the array is not, and the number is 42. The output is 1 1 1 42.

The pointer form is the right interface for a value type because a JSON document is often shaped differently from what the code expects. The caller tests once and then uses a reference, which is the pattern of lesson 087 applied to a variant.

#include <iostream> #include <map> #include <string> #include <variant> #include <vector> struct value { std::variant<std::nullptr_t, bool, double, std::string, std::vector<value>, std::map<std::string, value>> data; }; const std::map<std::string, value>* as_object(const value& item) { return std::get_if<std::map<std::string, value>>(&item.data); } const std::vector<value>* as_array(const value& item) { return std::get_if<std::vector<value>>(&item.data); } int main() { std::map<std::string, value> members; members["n"] = value{42.0}; value document{members}; const auto* object = as_object(document); const auto* array = as_array(document); std::cout << (object != nullptr) << ' ' << object->size() << ' '; std::cout << (array == nullptr) << ' '; std::cout << std::get<double>(object->at("n").data) << '\n'; }

g++ -std=c++23 -Wall -Wextra access.cpp -o value_access
./value_access

1 1 1 42

A writer in one function

to_json switches on the alternative and recurses into arrays and objects. Numbers are formatted with std::format, which prints 36 rather than 36.000000, and strings get their quotes back.

The program builds {"name": "ada", "age": 36} and prints it as compact JSON. The map orders the keys, so age comes before name, which is valid JSON and not the order of the source.

The writer of lesson 140 adds escaping and indentation to this function. The shape stays: one case per alternative and one recursive call per child.

#include <format> #include <iostream> #include <map> #include <string> #include <variant> #include <vector> struct value { std::variant<std::nullptr_t, bool, double, std::string, std::vector<value>, std::map<std::string, value>> data; }; std::string to_json(const value& item) { switch (item.data.index()) { case 0: return "null"; case 1: return std::get<bool>(item.data) ? "true" : "false"; case 2: return std::format("{}", std::get<double>(item.data)); case 3: return "\"" + std::get<std::string>(item.data) + "\""; case 4: { std::string result = "["; for (const value& child : std::get<std::vector<value>>(item.data)) { if (result.size() > 1) { result += ','; } result += to_json(child); } return result + ']'; } default: { std::string result = "{"; bool first = true; for (const auto& [key, child] : std::get<std::map<std::string, value>>(item.data)) { if (!first) { result += ','; } first = false; result += "\"" + key + "\":" + to_json(child); } return result + '}'; } } } int main() { std::map<std::string, value> members; members["name"] = value{std::string{"ada"}}; members["age"] = value{36.0}; value document{members}; std::cout << to_json(document) << '\n'; }

g++ -std=c++23 -Wall -Wextra write.cpp -o value_write
./value_write

{"age":36,"name":"ada"}

The alternative that is not there

std::get<T> on a variant that holds another alternative throws std::bad_variant_access. The accessor with get_if is the version that returns a pointer, and it is the one a JSON reader should use.

The program stores a number and asks for a string. The catch prints the name of the exception, and the value is unchanged.

The exception is the right answer for a bug in the program, and the pointer is the right answer for data that may have another shape. A reader of a document from a file is the second case, and the tests of lesson 141 are the first.

#include <iostream> #include <map> #include <string> #include <variant> #include <vector> struct value { std::variant<std::nullptr_t, bool, double, std::string, std::vector<value>, std::map<std::string, value>> data; }; int main() { value item{42.0}; try { std::cout << std::get<std::string>(item.data) << '\n'; } catch (const std::bad_variant_access& error) { std::cout << "bad_variant_access\n"; } }

g++ -std=c++23 -Wall -Wextra wrong.cpp -o value_wrong
./value_wrong

bad_variant_access

Exercises

  1. Add an accessor that returns a double with a default for a missing or non-numeric value.
  2. Print the object with its keys sorted in reverse and compare the output.
  3. Add a `size` function that returns the number of children of an array or an object and 0 for the rest.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. Why is the value a std::variant?

    Answer

    A JSON value is one of a fixed set of kinds

    The format defines six kinds, which is exactly the closed set a variant models.

  2. What does std::get_if<T>(&variant) return when the alternative is not there?

    Answer

    nullptr

    The pointer form turns a mismatch into a testable value instead of an exception.

  3. What does index() report?

    Answer

    The position of the active alternative in the list

    The index is the alternative's number in the declaration, which is what a switch on kinds uses.

  4. What does this program print? #include <iostream> #include <map> #include <string> #include <variant> #include <vector> struct value { std::variant<std::nullptr_t, bool, double, std::string, std::vector<value>, std::map<std::string, value>> data; }; const char* kind(const value& v) { switch (v.data.index()) { case 0: return "null"; case 1: return "bool"; case 2: return "number"; case 3: return "string"; case 4: return "array"; default: return "object"; } } int main() { std::cout << kind(value{nullptr}) << ' ' << kind(value{true}) << ' ' << kind(value{1.5}) << ' ' << kind(value{std::string{"hi"}}) << ' ' << kind(value{std::vector<value>{}}) << ' ' << kind(value{std::map<std::string, value>{}}); }

    Answer

    null bool number string array object

    kind maps each index to the name of the alternative, in declaration order.

  5. What does this program print? #include <iostream> #include <map> #include <string> #include <variant> #include <vector> struct value { std::variant<std::nullptr_t, bool, double, std::string, std::vector<value>, std::map<std::string, value>> data; }; int main() { std::map<std::string, value> members; members["n"] = value{42.0}; value document{members}; auto* object = std::get_if<std::map<std::string, value>>(&document.data); auto* array = std::get_if<std::vector<value>>(&document.data); std::cout << (object != nullptr) << ' ' << object->size() << ' ' << (array == nullptr) << ' ' << std::get<double>(object->at("n").data); }

    Answer

    1 1 1 42

    The object exists with one member, the array accessor returns null, and the member is 42.

  6. What does this program print? #include <format> #include <iostream> #include <map> #include <string> int main() { std::map<std::string, std::string> members{{"name", "\"ada\""}}; members["age"] = "36"; std::string out = "{"; for (const auto& [key, value] : members) { if (out.size() > 1) out += ','; out += "\"" + key + "\":" + value; } out += '}'; std::cout << out; }

    Answer

    {"age":36,"name":"ada"}

    The map orders the keys, so age comes before name, and the values are written as text.

  7. What does std::get<T> throw on the wrong alternative?

    Answer

    std::bad_variant_access

    The variant reports the mismatch with its own exception type.

  8. What does this program print? #include <iostream> #include <string> #include <variant> int main() { std::variant<int, std::string> value{42}; try { std::cout << std::get<std::string>(value); } catch (const std::bad_variant_access&) { std::cout << "bad_variant_access"; } }

    Answer

    bad_variant_access

    The variant holds an int, and get<std::string> throws the mismatch.

  9. Why is a variant better than a string tag for the kind?

    Answer

    The compiler checks that every kind is handled

    A switch over the alternatives is checked, while a string tag can hold a typo.

  10. How does the writer handle an object?

    Answer

    It recurses over the map and writes key and value

    The object case writes each pair and calls to_json on the value.

The code on this page was built with g++ 15.2.0 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev