0%

C++元编程

C++模板元编程

C++模板、元编程、反射

我喜欢把这一篇叫做——C++与UE的缝合线

 

C++模板(C++11之后)

即允许将“类型”作为变量传递

int solve(int a){
    return a*2;
}

float solve(float a){
return a*2;
}

double solve(double a){
return a*2;
}

int main(){
int a=1;
float b=1.1f;
double c=1.111;
cout<<solve(a)<<endl;
cout<<solve(b)<<endl;
cout<<solve(c)<<endl;
}

不同参数列表或者返回值的函数使用函数重载函数进行定义,十分麻烦。

template<class T>//也可以template<typename T>
T solve
T solve(T a){
    return a*2;
}

int main(){
int a=1;
cout<<solve(a)<<endl;
return 0;
}

模板T可以自动判断参数类型来推导出自己是什么类型。

 

放在C++98或者之前(也许?的版本中,泛型编程的形式是这样的:

#define solve(T) _SOLVE_##T
#define SOLVE(T) (T a)     \
T _SOLVE_##T(T a){         \
    return a*2;			   \
}						   

SOLVE(int);
SOLVE(float);

int main(){
solve(int)(1);
solve(float)(1.0f);
}

最大的特点就是还没看呢,就被乱七八糟的符号糊脸。

其中宏定义中的T在预处理阶段就被替换,##表示连接两边的标识符,因为_SOLVE_T本身就属于需要替换的标志。

无论是定义阶段还是使用阶段都需要直接进行宏替换,这样明显不安全,没法调试,可读性差,而且本身就不省力。

 

template<class T>

然后就是这个神奇的模板关键字,本质上只是单纯“通知”编译器要进行模板编程了,不要报错。

 

SFINAE

在编译时,当编译器遇到一个实例化的模板时,比如上面的solve(a),会将a的类型进行推导,然后将得到的类型于可能的模板集中进行一个一个匹配,若匹配成功会将模板定义中的T类型替换为a的数据类型,若遇到第二个示例,则为为其单独生成一份针对a类型的模板代码。

所以理论上模板的缺点就是有可能导致代码膨胀冗余,也就是和内联函数一个道理,占用代码段内存过多,不过模板的优点倒是远远大于这些潜在问题。

单个匹配失败的情况下为Failure

所有匹配失败的情况下为Error——报错

 

模板特例

编译器在编译的时候偏向于调用普通函数而不是模板函数,所以有需要的情况下要把优先级高的类型单独写出,比如:

string solve(string a){
    return a+a;
}//string类型只能+

显式调用

solve<int>(1);

整数参数

这个还是C#lbj老师随口提到的一点,我摆出天际了还给我89分,点赞

比如template <typename T, int Size>

其实就是定义了一个常数,用于模板中常数使用。

总所周知模板匹配是在编译时完成的,所以模板中的任何内容都不能和运行时相关,而运行时相关编程形式作为学生思维的我们一般会直接联想到虚函数,但其实运行时的用户操作也属于很大一环。。。

比如int data[Size]

 

模板约束

在C++20之前使用SFINAE(替换失败不是错误)技术

template <typename T>
typename std::enable_if<std::is_integral<T>::value, void>::type
myFunction(T value) {
    // 函数实现
}

看着就怕。

C++20之后使用require或者consepts。

template <typename T>
requires std::is_integral_v<T>
void myFunction(T value) {
    // 函数实现
}
template <typename T>
concept Integral = std::is_integral_v<T>;

template <Integral T>
void myFunction(T value) {
// 函数实现
}

模板的惰性

就像上面所说的,编译器在处理模板函数的时候是编译阶段处理实例的时候再进行模板定义的实例化(说人话就是模板函数本身在编译时不会直接进行编译,而是等编译到main调用的时候再进行编译)。那么问题来了,如果模板定义和示例分文件进行编写,比如.h与.cpp文件,那么cpp文件中的调用会找不到h中的库模板(因为h中的代码还没有被编译,h模板中的内容还不知道是什么),于是出现报错。

 

模板编程典型例子

模板编程最典型的例子就是求阶乘。

阶乘n!正常思路就是n(n-1)(n-2)...1,也就是说写一个递归函数,维护一个结果值,每递归一次都拿n-1的值去乘以它。

int factorialRecursive(int n) {
    if (n == 0 || n == 1) {
        return 1;
    } else {
        return n * factorialRecursive(n - 1);
    }
}

总所周知递归调用涉及栈帧创建销毁,还会涉及内存占用等运行时开销。

最简单粗暴的方法便是提前知道n的值,然后手动一个一个在函数内写出来。

比如:

int factorialRecursive() {
        return 5 * 4 * 3 * 2 * 1;
}//假如已知n为5

可以说这段代码对于用户来说是毫无意义的。。。(省略了函数调用,你就说快不快嘛)

所以有没有一种方法让程序自动生成形如n(n-1)(n-2)...1的代码段呢?

我们可以让模板递归直接为我们生成出来

// 模板递归方式计算阶乘
template <int N>
struct Factorial {
    static const int value = N * Factorial<N - 1>::value;
};

// 模板特化,定义0的阶乘为1
template <>
struct Factorial<0> {
static const int value = 1;
};

最后展开大概是这个样子(假如n为5):

// 展开 Factorial<5>
struct Factorial_5 {
    static const int value = 5 * Factorial_4::value;
};

// 展开 Factorial<4>
struct Factorial_4 {
static const int value = 4 * Factorial_3::value;
};

// 展开 Factorial<3>
struct Factorial_3 {
static const int value = 3 * Factorial_2::value;
};

// 展开 Factorial<2>
struct Factorial_2 {
static const int value = 2 * Factorial_1::value;
};

// 展开 Factorial<1>
struct Factorial_1 {
static const int value = 1 * Factorial_0::value;
};

// 模板特化 Factorial<0>
struct Factorial_0 {
static const int value = 1;
};

int main() {
// 使用展开后的值
std::cout << "Factorial of 5: " << Factorial_5::value << std::endl;
return 0;
}

 

元编程

元编程,即生成代码的代码。

和多态相似,元编程分为编译时元编程(静态多态),运行时元编程(动态多态)。

上文中说到的模板生成代码和宏生成代码,其实就是属于编译时元编程,我们聊元编程也许大多是聊编译时的元编程,因为只有编译时的元编程才充分展现了它自身的意义(为了性能)。

运行时元编程可能一般不会用“元编程”这个title去称呼它,而是直接说他自身的特点名字,比如“反射”、“动态加载库”等。当然,数据库编程经常用到的字符串形式代码生成也可以叫运行时元编程,除此以外用其他类型代码生成代码也属于运行时元编程,比如常用的python生成C++,lua生成C++。

 

反射

反射是一种在运行时获取和操作程序结构信息的机制,例如类的成员、方法、属性等。

得知这个定义时我的第一反应一直是visual studio中的debugger:

pFiWWyq.png

其实不是!!!

虽然debugger同时满足了“运行时”和“获取信息”两个条件,但其实他和反射几乎搭不上边。调试器的主要作用是允许程序员在程序运行时逐行执行代码,观察变量的值,检查调用堆栈,设置断点等。虽然调试器提供了一些运行时的信息,但它通常不提供对类型结构的直接访问和操作,这与反射的目标有所不同。

反射的目标往往能够在程序运行时直接进行更改,也就是“变量的获取”,比如这样:

pFif4gA.png

或者说(也可以说本文的最终目的)这样:

pFihWZV.png

拿细节面板出来举例的原因主要是这个东西对于初学者来说(比如我)是最明显能体现反射功能的,当你第一次打开UE引擎,创建某个Actor对象或某个蓝图时,它的细节面板能够显示对象所有属性。当这个细节面板被打开的那一瞬间其实就已经在不知不觉中放肆地在运用UE中的反射功能了,只是大多数初学者可能意识不到(比如我),毕竟“反射”这个词其实挺云里雾里的。

除了细节面板,UE反射机制还广泛运用在以下方面:

  • 序列化:反射可以帮助保存和加载游戏状态,包括游戏进度、配置设置等。
  • 垃圾回收:UE的垃圾回收系统可以通过反射来跟踪哪些对象正在被使用,哪些对象可以安全地删除。
  • 网络复制:在网络游戏中,反射被用来同步不同玩家的游戏状态。
  • 蓝图/C++通信和相互调用:反射允许蓝图(UE的视觉脚本系统)和C++代码相互通信和调用。

 

“反射”功能具体是如何实现的呢,这个和UE使用的一个叫“UE4 Reflection System”的系统相关,使用UCLASSUFUNCTIONUPROPERTY等宏标记对需要进行反射的数据进行标记,被标记了的数据才会被反射系统“注意”到,然后系统会将其暴露给蓝图系统或者其他功能。

 

上述所说的宏标记,就包含相应的元数据,这些元数据用于实现反射功能,允许在运行时动态对类信息进行获取。-----------待续