自动类型转换示例

Nlohmann json库实现了非常强大的自动类型转换功能,举个例子:

#include<iostream>
#include<string>
#include"json.hpp"
 
using json = nlohmann::json;
int main() {
    // 无需手动指定类型即可实现cpp类型向JSON类的转换
    json j_int = 42;
    json j_float = 3.14;
    json j_string = "OOP";
    // 内置对STL容器转换的支持
    std::vector<int> vec = {1, 2, 3, 4};
    json j_vec = vec;
    // JSON类向C++类的转换
    auto i = j_int.get<int>(); // 42
    auto f = j_float.get<float>(); // 3.14
    auto s = j_string.get<std::string>(); // OOP
    auto v = j_vec.get<std::vector<int>>(); // [1, 2, 3, 4]
}

工作原理

这个自动转换系统是如何实现的呢?以从 C++ 类型向 JSON 类型转换为例,它主要涉及四个部分:basic_json的构造函数、adl_serializerto_json函数、to_json.hpp中的to_json_fn(一个函数对象)、to_json.hpp中多个重载的to_json函数(或用户重载的to_json函数)。

接下来我们将跟踪定义json j = some_object后的行为。

basic_json 的构造函数

定义json j = some_object后,首先会进入basic_json类的构造函数。

template < typename CompatibleType,
            typename U = detail::uncvref_t<CompatibleType>,
            detail::enable_if_t <
                !detail::is_basic_json<U>::value && detail::is_compatible_type<basic_json_t, U>::value, int > = 0 >
basic_json(CompatibleType && val) noexcept(noexcept(
        JSONSerializer<U>::to_json(std::declval<basic_json_t&>(),
                                    std::forward<CompatibleType>(val))))
{
    JSONSerializer<U>::to_json(*this, std::forward<CompatibleType>(val));
    set_parents();
    assert_invariant();
}

对于可兼容类型,这个模板函数会被实例化,从而调用序列化器JSONSerializer<U>(默认为adl_serializer<U>)的to_json函数。

什么是可兼容类型?简单来讲就是序列化器(特化或未特化的)含有相应类型的to_json函数.这个判断是通过type traits (类型萃取)的方式进行判断的,我们将在另一篇文章中讨论.

adl_serializerto_json函数

template<typename BasicJsonType, typename TargetType = ValueType>
static auto to_json(BasicJsonType& j, TargetType && val) noexcept(
    noexcept(::nlohmann::to_json(j, std::forward<TargetType>(val))))
-> decltype(::nlohmann::to_json(j, std::forward<TargetType>(val)), void())
{
    ::nlohmann::to_json(j, std::forward<TargetType>(val));
}

以TargetType为模板参数,这个函数调用nlohmann命名空间下的to_json函数对象.

to_json.hpp中的to_json_fn

to_json又是怎么调用到to_json_fn这个函数对象的呢?在to_json.hpp中(nlohmann命名空间下),有这样一段代码:

JSON_INLINE_VARIABLE constexpr const auto& to_json =
    detail::static_const<detail::to_json_fn>::value;

这段代码的作用是将nlohmann命名空间下的to_json定义为一个函数对象to_json_fn(detail命名空间下),后续会解释为什么需要采用间接的方式来调用这个函数对象.

struct to_json_fn
{
    template<typename BasicJsonType, typename T>
    auto operator()(BasicJsonType& j, T&& val) const noexcept(noexcept(to_json(j, std::forward<T>(val))))
    -> decltype(to_json(j, std::forward<T>(val)), void())
    {
        return to_json(j, std::forward<T>(val));
    }
};

这个函数对象调用的是一个无限定(非常重要!)的to_json函数.

to_json.hpp中多个重载的to_json函数

template<typename BasicJsonType>
inline void to_json(BasicJsonType& j, typename BasicJsonType::string_t&& s)
{
    external_constructor<value_t::string>::construct(j, std::move(s));
}

此处的external_constructor是一个模板类,用于安全地管理 union 的创建和修改.

自定义类型的类型转换(1)

为什么调用过程看似如此复杂?这涉及到该库精妙的结构设计.在讨论它之前,先让我们看看怎么使用该库实现自定义类型的类型转换.

#include<iostream>
#include<string>
#include<assert.h>
#include<memory.h>
#include"json.hpp"
 
using json = nlohmann::json;
 
struct Person {
    std::string name;
    int age;
    bool operator== (const Person& a) {
        return (name == a.name) && (age == a.age);
    }
};
 
void to_json(json& j, const Person& p) {
    j = {{"name", p.name}, {"age", p.age}};
}
void from_json(const json& j, Person& p) {
    j.at("name").get_to(p.name);
    j.at("age").get_to(p.age);
}
 
int main() {
    // 自定义类与JSON类间的类型转换
    Person p1 = {"Alice", 18};
    json j_person = p1;
    std::cout << j_person << std::endl;
    // {"age":18,"name":"Alice"}
    auto p2 = j_person.get<Person>();
    assert(p1 == p2); // true
}

可以看到,该库提供了很好的扩展性和非侵入性,自定义的类无需继承自某个特殊的基类,在类内也不需要书写与 json 相关的代码.仅仅通过在自定义类的命名空间下实现to_json/from_json函数即可实现类型转换.这实现了自定义类逻辑和序列化逻辑的低耦合. 要讨论这个强大的功能的具体实现方式,就不得不提到参数依赖查找(ADL)

参数依赖查找(ADL)

ADL(Argument Dependent Lookup)是一种 C++ 的特殊机制。

在正常的函数调用中,编译器只会在当前作用域、包含当前作用域的外部作用域以及全局作用域中查找函数名。

但当编译器遇到一个不带命名空间限定符的函数调用时,编译器会检查与函数有关的所有参数的类型(包括模板参数),并额外在这些类型所属的关联命名空间中查找与函数名匹配的函数。

回到该库的实现,在to_json_fn中调用的to_json函数正是无限定的,因此会触发ADL。编译器会自动在自定义类所处的命名空间中查找参数匹配的to_json函数,从而实现自定义类与 JSON 类间的转换。

因此,想实现自定义类型与 JSON 类型的相互转换的重点是在自定义类型的同个命名空间下进行to_json/from_json函数重载。这引出了一个问题,假如无法修改自定义类的命名空间(如第三方库或C++标准库中的类),怎么实现自定义类与JSON类的类型转换呢?

自定义类型的类型转换(2)

该库提供了另一种重载to_json/from_json函数的方法:adl_serializer的特化。 我们可以在nlohmann命名空间下直接对序列化器实现对应类型的特化版本,举个例子:

#include<iostream>
#include<string>
#include<assert.h>
#include<memory.h>
#include"json.hpp"
 
using json = nlohmann::json;
 
namespace nlohmann {
    template<class T>
    struct adl_serializer<std::shared_ptr<T>> {
        static void to_json(json& j, const std::shared_ptr<T>& ptr) {
            if(ptr) {
                j = *ptr;
            }
            else {
                j = nullptr;
            }
        }
        static void from_json(const json& j, std::shared_ptr<T>& ptr) {
            if(j.is_null()) {
                ptr = nullptr;
            }
            else {
                ptr.reset(new T(j.get<T>()));
            }
        }
    };
}
 
int main() {
    // 第三方库(以C++标准库为例)中的类与JSON类间的类型转换
    std::shared_ptr<Person> ptr1(&p1);
    json j_ptr = ptr1;
    std::cout << j_ptr << std::endl;
    auto ptr2 = j_ptr.get<std::shared_ptr<Person>>();
    // 只是以shared_ptr为例,该例并未实现两个指针指向同一个对象
    assert((*ptr1) == (*ptr2));
}

通过这种方法,我们依旧可以在不污染原命名空间的前提下实现第三方库的类型与 JSON 类的转换。 原理很简单,在构造函数中尝试调用序列化器的to_json函数时,编译器会优先匹配特化版本的序列化器。事实上,如果编译器检查未特化版本的序列化器,会发现找不到匹配类型的to_json函数(因为 ADL 查找仅在关联命名空间中进行),从而导致实例化失败,但根据 SFINAE 原则,这不会导致编译错误,仅仅是不会实例化这个模板而已。

精妙设计

为什么adl_serializer::to_json要调用nlohmann::to_json而不是直接调用无限定的to_json呢?

template<typename BasicJsonType, typename TargetType = ValueType>
static auto to_json(BasicJsonType& j, TargetType && val) noexcept(
    noexcept(::nlohmann::to_json(j, std::forward<TargetType>(val))))
-> decltype(::nlohmann::to_json(j, std::forward<TargetType>(val)), void())
{
    ::nlohmann::to_json(j, std::forward<TargetType>(val));
    // 这里为什么不替换成 to_json(j, std::forward<TargetType>(val)) ?
}

如果直接调用无限定的to_json,假如用户没有为自定义类实现to_json函数,那么在 ADL 的过程中,编译器找到的最佳匹配就会是adl_serializer中的to_json函数本身,从而导致死循环! 而通过调用nlohmann::to_json这个接口可以完美规避这个问题,经过这个接口,函数实际调用的是位于nlohmann::detail下的to_json_fn函数对象,此时再触发ADL,编译器便不会在nlohmann命名空间下进行查找,而是在detail命名空间下(默认类型的to_json重载实现的地方)和其他关联命名空间下进行查找。假如用户没有实现to_json函数,会产生一个清晰的编译期错误,而不是一个难以调试的运行时栈溢出

为什么adl_serializer::to_json要调用nlohmann::to_json而不是直接调用nlohmann::detail::to_json_fn

template<typename BasicJsonType, typename TargetType = ValueType>
static auto to_json(BasicJsonType& j, TargetType && val) noexcept(
    noexcept(::nlohmann::to_json(j, std::forward<TargetType>(val))))
-> decltype(::nlohmann::to_json(j, std::forward<TargetType>(val)), void())
{
    ::nlohmann::to_json(j, std::forward<TargetType>(val));
    // 这里为什么不替换成 nlohmann::detail::to_json_fn(j, std::forward<TargetType>(val)) ?
}

这种间接调用的设计主要是为了统一接口类型安全。detail命名空间是内部实现,不保证API的稳定性,如果采用直接调用的设计,假如在未来版本中进行了对detail内部的重构,那么就会直接破坏adl_serializer的实现。相反,在间接调用的设计下,仅需更改公共的API接口即可保证库中其他代码功能正常运行。这大大提高了代码的可维护性

补充

宏指令

macro_scope.hpp中有这样一段代码:

// Macros to simplify conversion from/to types
// 部分...是真正的省略号,并不是语法的一部分,详细代码参见源码
#define NLOHMANN_JSON_EXPAND( x ) x
#define NLOHMANN_JSON_GET_MACRO(_1, _2, ..., _63, _64, NAME,...) NAME
#define NLOHMANN_JSON_PASTE(...) NLOHMANN_JSON_EXPAND(NLOHMANN_JSON_GET_MACRO(__VA_ARGS__, \
        NLOHMANN_JSON_PASTE64, \
        NLOHMANN_JSON_PASTE63, \
        ...
        NLOHMANN_JSON_PASTE2, \
        NLOHMANN_JSON_PASTE1)(__VA_ARGS__))
#define NLOHMANN_JSON_PASTE2(func, v1) func(v1)
#define NLOHMANN_JSON_PASTE3(func, v1, v2) NLOHMANN_JSON_PASTE2(func, v1) NLOHMANN_JSON_PASTE2(func, v2)
// ...
#define NLOHMANN_JSON_PASTE64(func, v1, ..., v63) NLOHMANN_JSON_PASTE2(func, v1) NLOHMANN_JSON_PASTE63(func, v2, ..., v63)
 
#define NLOHMANN_JSON_TO(v1) nlohmann_json_j[#v1] = nlohmann_json_t.v1;
 
#define NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(Type, ...)  \
    template<typename BasicJsonType, nlohmann::detail::enable_if_t<nlohmann::detail::is_basic_json<BasicJsonType>::value, int> = 0> \
    void to_json(BasicJsonType& nlohmann_json_j, const Type& nlohmann_json_t) { NLOHMANN_JSON_EXPAND(NLOHMANN_JSON_PASTE(NLOHMANN_JSON_TO, __VA_ARGS__)) } \
    template<typename BasicJsonType, nlohmann::detail::enable_if_t<nlohmann::detail::is_basic_json<BasicJsonType>::value, int> = 0> \
    void from_json(const BasicJsonType& nlohmann_json_j, Type& nlohmann_json_t) { NLOHMANN_JSON_EXPAND(NLOHMANN_JSON_PASTE(NLOHMANN_JSON_FROM, __VA_ARGS__)) }
 

这段代码的作用十分强大,可以自动实现自定义类的to_json/from_json函数,举个例子:

#include<iostream>
#include<string>
#include<assert.h>
#include<memory.h>
#include"json.hpp"
 
using json = nlohmann::json;
 
struct Person {
    std::string name;
    int age;
    bool operator== (const Person& a) {
        return (name == a.name) && (age == a.age);
    }
};
 
// void to_json(json& j, const Person& p) {
//     j = {{"name", p.name}, {"age", p.age}};
// }
// void from_json(const json& j, Person& p) {
//     j.at("name").get_to(p.name);
//     j.at("age").get_to(p.age);
// }
// 以上两个函数与以下一条宏指令等价
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(Person, name, age)
 
int main() {
    // 自定义类与JSON类间的类型转换
    Person p1 = {"Alice", 18};
    json j_person = p1;
    std::cout << j_person << std::endl;
    // {"age":18,"name":"Alice"}
    auto p2 = j_person.get<Person>();
    assert(p1 == p2); // true
}

可以发现,原本的写法非常机械,而使用这条宏指令减少了许多编写代码的时间!那么,它的原理是什么呢?还是以to_json函数为例。

#define NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(Type, ...)  \
    template<typename BasicJsonType, nlohmann::detail::enable_if_t<nlohmann::detail::is_basic_json<BasicJsonType>::value, int> = 0> \
    void to_json(BasicJsonType& nlohmann_json_j, const Type& nlohmann_json_t) { NLOHMANN_JSON_EXPAND(NLOHMANN_JSON_PASTE(NLOHMANN_JSON_TO, __VA_ARGS__)) } 
// NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(Person, name, age) (to_json函数部分)被替换为
template<typename BasicJsonType, nlohmann::detail::enable_if_t<nlohmann::detail::is_basic_json<BasicJsonType>::value, int> = 0>
void to_json(BasicJsonType& nlohmann_json_j, const Person& nlohmann_json_t) { 
    NLOHMANN_JSON_EXPAND(NLOHMANN_JSON_PASTE(NLOHMANN_JSON_TO, name, age)) // 此处NLOHMANN_JSON_PASTE中的__VA_ARGS__被替换为name, age
}

首先,这条宏指令定义了一个这样的模板函数。它的参数正是我们需要的。接下来需要实现的就是展开对这个类的每个成员变量赋值,这需要用到两个重要的辅助宏:NLOHMANN_JSON_PASTENLOHMANN_JSON_GET_MACRO

先来看NLOHMANN_JSON_GET_PASTE,它的作用是模拟遍历__VA_ARGS__中的每个参数:

#define NLOHMANN_JSON_PASTE(...) NLOHMANN_JSON_EXPAND(NLOHMANN_JSON_GET_MACRO(__VA_ARGS__, \
        NLOHMANN_JSON_PASTE64, \
        NLOHMANN_JSON_PASTE63, \
        ...
        NLOHMANN_JSON_PASTE2, \
        NLOHMANN_JSON_PASTE1)(__VA_ARGS__))
// NLOHMANN_JSON_PASTE(NLOHMANN_JSON_TO, name, age) 被替换为
NLOHMANN_JSON_EXPAND(NLOHMANN_JSON_GET_MACRO(NLOHMANN_JSON_TO, name, age, 
        NLOHMANN_JSON_PASTE64, 
        NLOHMANN_JSON_PASTE63, 
        ...
        NLOHMANN_JSON_PASTE2, 
        NLOHMANN_JSON_PASTE1)(NLOHMANN_JSON_TO, name, age))
// 此处的NLOHMANN_JSON_PASTE<N>是处理N个参数的宏

NLOHMANN_JSON_GET_MACRO实现了巧妙的参数计数:

#define NLOHMANN_JSON_GET_MACRO(_1, _2, ..., _63, _64, NAME,...) NAME

NLOHMANN_JSON_PASTE的调用中,先传入了NLOHMANN_JSON_TO, name, age三个参数,再传入了从NLOHMANN_JSON_PASTE64至NLOHMANN_JSON_PASTE1共64个参数,因此总共传入了67个参数。而NAME被定义为第65个参数,在本例中,它就是NLOHMANN_JSON_PASTE3,刚好就是处理3个参数的宏!

并且我们不难发现,这种写法可以对于不同的参数数量自动调用不同的宏,是一个非常经典而有效的宏技巧。

NLOHMANN_JSON_PASTE<N>中会递归调用NLOHMANN_JSON_PASTE<N-1>NLOHMANN_JSON_PASTE2,而NLOHMANN_JSON_PASTE2被定义为:

#define NLOHMANN_JSON_PASTE2(func, v1) func(v1)

因此,最终NLOHMANN_JSON_PASTE(NLOHMANN_JSON_TO, name, age)被替换为:

NLOHMANN_JSON_TO(name) NLOHMANN_JSON_TO(age)

最后:

#define NLOHMANN_JSON_TO(v1) nlohmann_json_j[#v1] = nlohmann_json_t.v1;
// #v1的作用是将v1转化为一个字面量
// NLOHMANN_JSON_TO(name) NLOHMANN_JSON_TO(age) 被替换为
nlohmann_json_j["name"] = nlohmann_json_t.name;
nlohmann_json_j["age"] = nlohmann_json_t.age;

完美实现to_json函数功能。

该库还实现了多个类似的宏指令,可以帮助实现包括侵入和非侵入的多种自定义类的to_json/from_json函数,非常便捷,具体可查看源代码的README.md

总结分析

该库的代码实现有许多优越性:

  • 符合开闭原则:Nlohmann Json库的核心代码对外“关闭”,但对新类型的支持“开放”,可以通过添加 to_json/from_json 函数来进行拓展。
  • 非侵入式设计:不需要使自定义类继承自某个基类,也不需要在自定义类内添加与 JSON 有关的代码,实现了自定义类逻辑和序列化逻辑的低耦合。
  • 使用便捷:该库提供了便捷的扩展方式,还有宏指令等简化代码编写过程的处理,极大降低了使用难度。
  • 模块间低耦合:各个模块分工明确,耦合性低,便于理解和维护。