Featured image of post 第一章:反射语法基础

第一章:反射语法基础

实现反射需要用到的现代c++语法知识

第一章:反射语法基础

对于模板了解不多的同学可以先学习下模板基础,这里推荐博客:模板元编程教程 ,讲的很详细。


以下是我们在实现反射时会用到的部分语法介绍

为什么选择 C++20

  首先,实现一个反射功能就需要知道一个对象的:成员名称、成员的值,并且要有修改成员值的能力,而c++因为“零运行时开销”的设计初衷,导致反射迟迟没有被纳入标准(c++26已支持),因为反射往往意味着运行时元数据,这会带来额外内存和性能开销,与零开销原则冲突。

  不过我们可以通过一些其他的方式,实现我们自己的简单的反射功能,不过这肯定会不可避免的带来:

  • 代码体积膨胀(反射类型越多,体积越大)

  • 运行时内存增加(运行时保留类型元数据)

  不过在软件工程中,复杂度是守恒的,只会转移不会消失,所以一定程度的编码便利带来一定的开销也是可以接受的。

  通过查找资料发现,目前c++20的反射实现主要思路都是:

  • 通过结构化绑定和编译器内置的宏(MSVC:__FUNCSIG__clang、gcc:__PRETTY_FUNCTION__)来获得对象的成员名称
  • 通过结构化绑定获得对象的成员引用(可以获得和修改对象数据)

这里的__FUNCSIG__顾名思义是获得函数签名的宏,对于如下调用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
template <auto val>
inline void get_func_name_template() {
    std::cout << __FUNCSIG__ << std::endl;
}
struct Person {
    int m_age;
    std::string m_name;
};
static Person p;
get_func_name_template<&p.m_name>();
// MSVC C++20编译输出:void __cdecl get_func_name_template<&p.m_name>(void)

可以看到,输出中包含了我们需要的成员名称,对于这种固定模板,我们可以很容易的获得他的成员名称,但是如上代码在C++17是无法编译通过的,C++17需要如下格式:

1
get_func_name_template<&p>();

原因在于:C++17对于非类型模板参数的值类型限制很严格,不允许将对象的子对象地址作为模板入参,这就直接导致了C++20以下的版本通常只能通过如下方式手动注册对象的元信息:

1
REFLECTION(Person, m_age, m_name);

这就直接导致了想要一行代码实现序列化和反序列化,只能以C++20作为基础来实现。

而其他的限制通常都可以通过SFINAE来规避,只不过实现起来会更加复杂。

同时C++20还有很多方便我们实现的特性,如:支持模板的Lambda表达式conceptrequires等,能够大大提高我们的代码可读性,提高开发效率。

语法介绍

对模板Lambda表达式的支持

在 C++20 中,你可以让 Lambda 接收模板参数,这在编译期展开成员信息时非常方便。

1
2
3
4
5
auto print_indices = []<std::size_t... Is>(std::index_sequence<Is...>) {  // C++20支持模板
        ((std::cout << Is << " "), ...); // C++17 折叠表达式
    };
print_indices(std::make_index_sequence<5>{});
// 输出:0 1 2 3 4

这里的折叠表达式能够将模板参数包进行展开,这种写法在后面会经常用到,是反射利器。


concept 与 requires

C++17 的 SFINAE(Substitution Failure Is Not An Error)虽然可以限制模板的使用条件,但写法晦涩难懂,错误信息又长又吓人。C++20 的 concept / requires 让约束直接写在模板签名上,可读性高很多。

例子(判断类型是否可加):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
template <typename T>
concept Addable = requires(T a, T b) {
    { a + b } -> std::convertible_to<T>;
};
template <Addable T>
T add(T a, T b) {
    return a + b;
}
int main() {
    std::cout << add(3, 4) << "\n";    // OK
    // std::cout << add("a", "b");     // 编译错误,error C2672: “add”: 未找到匹配的重载函数
}

对比 C++17

1
2
3
4
template <typename T, typename = std::enable_if_t<
    std::is_convertible_v<decltype(std::declval<T>() + std::declval<T>()), T>
>>
T add(T a, T b) { return a + b; }  // SFINAE

是不是瞬间清爽了?这就是 concept / requires 的魅力。

concept相当于一个编译期的bool值,可以直接使用在模板中做类型约束

requires有以下几种写法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 1
template <typename T> requires std::integral<T> // 直接写在模板尾部
T multiply_by_two(T x) {}

// 2
template <typename T>
T multiply_by_two(T x) requires std::integral<T> {}  // 写在函数声明后

// 3 requires (参数列表) { 要检测的表达式; ... };  
template <typename T>
concept Incrementable = requires(T x) {  // 对模板参数做多重约束
    { ++x } -> std::same_as<T&>;  // 支持使用++运算符并且返回类型必须是 T&
    { --x };					// 支持--运算符
};

在c++20中,约束也被算作重载决议的一种,所以对于如下代码:

1
2
3
4
5
6
7
8
template <OutputStream Stream, typename T>
inline void to_json_value(Stream&& s, T&& object) requires is_custom_type_v<T>;

template <OutputStream Stream, typename T>
inline void to_json_value(Stream&& s, T&& object) requires is_sequence_container_v<T>;

template <OutputStream Stream, typename T>
inline void to_json_value(Stream&& s, T&& object) requires is_associative_container_v<T>;

通过使用requires能够非常清爽的实现对不同类型的不同处理,可读性很高,如果按照SFINAE写法如下:

1
2
template <class S, class T, std::enable_if_t<is_custom_type_v<T>, int> = 0>
void to_json_value(S&&, T&&);

其中对于类型S和T想要进一步约束还会更复杂。


结构化绑定(Structured Bindings)和tuple

结构化绑定是 C++17 引入的语法,让你可以用类似解构赋值的方式,直接把一个structtuple 拆成多个变量。

1
2
3
4
5
std::tuple<int, double, char> t{42, 3.14, 'x'};   // tuple
auto [i, d, c] = t;
struct Person {int m_age; std::string m_name; };  // struct
auto [a, n] = p;	 // 按值绑定
auto& [ra, rn] = p;  //  按引用绑定

在编译期反射中,结构化绑定是获取成员引用 tuple的核心手段之一。

std::tuple 是 C++ 标准库里的一种固定长度、多类型的集合容器,从定义看就非常适合作为结构体元信息的存储单位。

tuple构造方式如下:

1
std::tuple<int, double, std::string> t1(42, 3.14, "hello");

也可以通过std::make_tuple进行构造:

1
auto t = std::make_tuple(42, 3.14, std::string("hello"));  // 类型自动推导:std::tuple<int, double, std::string>

通过make_tuple构造tuple会通过值拷贝的方式进行构造

还可以通过std::tie进行构造:

1
auto t = std::tie(person.m_age, person.m_name);

这里构造出的tuple会持有原对象的引用,可以通过std::get拿到引用并对值做修改


可变参数模板与参数包展开(Variadic Templates & Pack Expansion)

这是 C++11 引入的重要语法,用来处理任意数量的模板参数

可变参数模板
1
2
3
4
5
6
7
8
template <typename... Args>
void print_all(Args... args) {
    (std::cout << ... << args) << "\n"; // C++17 折叠表达式
}

int main() {
    print_all(1, 2.5, "hello"); // 输出:12.5hello
}
参数包展开

参数包展开就是用 ... 把一段模式按参数包逐项替换,然后拼成一串。

1
2
3
4
template <typename... Ts>
using MyTuple = std::tuple<Ts...>;

using T = MyTuple<int, double, std::string>; // 等价于 tuple<int, double, string>

例子(反射中常见模式展开):

1
2
3
4
5
6
7
template <std::size_t... Is>
constexpr std::array<int, sizeof...(Is)> get_array(std::index_sequence<Is...>) {
    return std::array<int, sizeof...(Is)>{ { static_cast<int>(Is)... } };
}

get_array(std::make_index_sequence<5>{});
// 构造array: [1, 2, 3, 4, 5]

这里的 ... 就是参数包展开,它会依次替换 Is,展开为:{1, 2, 3, 4, 5}


本章总结

  • C++20 的三大升级模板 Lambdaconcept/requiresNTTP 放宽)是非侵入式反射的关键。
  • 结构化绑定让我们在编译期轻松获取成员引用。
  • concept 与 requires让模板约束变得优雅可读。
  • 可变参数模板与参数包展开让我们能在编译期遍历所有成员类型,并生成序列化/反序列化代码。

下一章,我们会在这些语法基础上,开始构建编译期静态反射的核心机制,看看如何一步步获取结构体的成员数量、成员引用 tuple、成员名称。

Licensed under CC BY-NC-SA 4.0
comments powered by Disqus

本博客已稳定运行 小时 分钟
共发表 2 篇文章 · 总计 5.11 k 字
本站总访问量