分享背景
之前学 C++ 的时候只对普通引用 type & 和 const 引用 const type & 有简单的了解,只停留在在会用的层面,后来在学习 C++11 的右值引用 type && 时了解到其可以通过引用 std::move 移动资源的所有权可以提升性能。
但是在一些大佬写的包含 C++ 模板的代码中发现大量使用了 T&& ,在模板中的语义和在非模板的语义竟然不一样,于是重新系统学习了下 C++ 一系列引用相关的知识,这篇分享也是个人在重新学习一系列别人的博客过程中的一个自我总结。
前言
区别于纯 C 指针满天飞导致的不安全与问题定位困难,C++ 引入了引用的相关概念:左值引用、const 引用、右值引用。
这些概念在 C++ 模板中又有新的语义:万能引用、引用折叠。
进而在 C++ 标准库的应用层面又扩展出了更高级的概念:移动语义、完美转发。
这些相关概念让 C++ 更灵活的同时学习曲线也更陡峭。
引用出现的历史原因及所解决的问题
纯 C 风格函数传参的问题
-
函数调用参数入栈的方式为值拷贝,参数类型所占空间大的时候效率低下,使用裸指针传参的方式可以大大提升函数调用效率,并且可以避免多份拷贝,减少内存资源占用。
-
裸指针传参的方式效率高且灵活,但是裸指针强大的特性及其在复杂工程中的使用不当导致了很多安全问题,例如同一个指针可以随时指向不同的内存或资源,从而可能会出现诸如内存泄漏、空指针访问、野指针访问、越界访问等,特别在多线程场景下定位更加困难。
引用(左值引用)的引入
为了解决纯 c 的裸指针太危险的问题,引入了引用(左值引用)的概念,即给变量取别名
// 变量 b 即变量 a 的另一个名称,二者指向同一块内存
int a = 1;
int& b = a;
在函数传参中使用
#include <cstdio>
int testFunc(int & param){
param = 2;
return 0;
}
int main(){
int a = 0;
testFunc(a);
printf("a = %d\n", a);
return 0;
}
// 输出: a = 2
可以看到对引用类型变量的操作和原绑定对象的操作一模一样,且作用于与原绑定对象相同的资源,通过这种方式,不能再使用像裸指针那样可能引起安全问题的操作。
左值引用
由于C++在后续版本中引入了另外一种专门引用右值的引用,为了区别,所以把上述 type & 形式的引用称为左值引用。
左值引用的编译器底层实现
看这么两个函数
void test01(){
int a = 100;
int & b = a;
b += 1;
}
void test02(){
int a = 100;
int * b = &a;
*b += 1;
}
编译成汇编
clang -S test.cpp -o test.s
.section __TEXT,__text,regular,pure_instructions
.build_version macos, 12, 0 sdk_version 12, 3
.globl __Z6test01v ## -- Begin function _Z6test01v
.p2align 4, 0x90
__Z6test01v: ## @_Z6test01v
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
movl $100, -4(%rbp)
leaq -4(%rbp), %rax
movq %rax, -16(%rbp)
movq -16(%rbp), %rax
movl (%rax), %ecx
addl $1, %ecx
movl %ecx, (%rax)
popq %rbp
retq
.cfi_endproc
## -- End function
.globl __Z6test02v ## -- Begin function _Z6test02v
.p2align 4, 0x90
__Z6test02v: ## @_Z6test02v
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
movl $100, -4(%rbp)
leaq -4(%rbp), %rax
movq %rax, -16(%rbp)
movq -16(%rbp), %rax
movl (%rax), %ecx
addl $1, %ecx
movl %ecx, (%rax)
popq %rbp
retq
.cfi_endproc
## -- End function
.subsections_via_symbols
可以看到上述 test01 和 test02 的汇编代码完全一样**。**
说明引用的本质即指针,只不过是编译器提供的语法糖,引入引用是期望从语法层面避免开发者直接使用裸指针。
从上述汇编代码结合源码也可以看到,左值引用可以看成是使用另一个变量对同一个内存块进行别名绑定。
左值引用使用的注意事项
-
一般不会无聊的在同一个作用域里给一个变量取别名,一般用于传参、返回值、类的属性等希望是同一个对象(同一片内存)传递的地方
-
由于是对变量取别名,因此需要作用于在存储单元里的命名对象(左值),即不可作用于立即数、将亡值等右值。
-
由于区别于指针的使用形式,c++编译器对引用做了限制,只能对左值进行一次绑定,之后不能再绑定其他左值,且不能只声明,初始化的时候必须 绑定一个左值。通过这种强行限制的方式来减少一点指针的坑。
左值、纯右值、将亡值
左值
语法上可以在等号左边的值,能够用 c语言的&取地址的表达式是左值表达式,即具名对象。
纯右值
语法上只能在等号右边的值或表达式,例如字面量(12、1.2、true/false 等)、临时变量(返回左值的表达式或函数调用),即不具名的对象
将亡值
返回右值引用的函数的调用表达式,表示"准备完蛋的东西,之后不能再使用"。C++11 增加的新概念,由于 C++11 引入了右值引用,返回右值引用意味内部资源将要被移动。被移动之后原表达式返回的对象不应该再被使用。
注意: 特殊的是字符串的字面量是左值,因为其编译之后是存储于文字常量区,即也是存在内存中,不过是只读内存,还是可以对其取地址。
const 引用
const 引用出现的历史原因及所解决的问题
左值引用设计之初是为了解决避免使用指针的问题,而且是一个具名对象的别名绑定,因此不能绑定右值。
因此对于如下的例子:
int test(int& a){
return a;
}
int main() {
test(1);
return 0;
}
上述编译报错: candidate function not viable: expects an l-value for 1st argument.
即左值引用并不能引用一个右值。
const 引用的引入
为了解决上述问题,早期 C++ 版本引入了 const 引用 const type &,使用一种比较取巧的方式来解决左值引用不能引用右值的问题。
例如针对上述例子做变更:
int test(const int& a){
return a;
}
int main(int argc, const char** argv) {
test(1);
return 0;
}
即 const 引用可以接收一个纯右值。
const 引用可以接收纯右值的原理
const int& a = 1;
等价于
const int temp = 1;
const int& a = temp;
即当 const 引用接收一个纯右值的时候,c++ 编译器会 "偷偷" 地加一行创建临时 const 左值,这样从编译器层面禁止了对 const 引用所绑定的值的修 改,只是用于避免指针操作的出现,来解决了引用接收右值的问题。
// test.cpp
void test03(){
const int & a = 100;
}
翻译成汇编代码
__Z6test03v: ## @_Z6test03v
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
movl $100, -12(%rbp)
leaq -12(%rbp), %rax
movq %rax, -8(%rbp)
popq %rbp
retq
.cfi_endproc
## -- End function
.subsections_via_symbols
从上述可以看到,确实是在栈内存中开辟了一块空间并赋值。
const 引用的好处与应用场景
好处:
作为参数类型时,相当于万能引用,既可以实现接收左值(普通的左值引用的概念加上 const 特性)也可以接收右值(编译器帮忙增加临时变量)
应用场景:
由于既可以接收左值,又可以接收右值的特性,可以作为c++拷贝构造函数与赋值拷贝运算符的参数类型,从而实现对象的拷贝。
右值引用
右值引用出现的历史原因及所解决的问题
const 引用存在的问题
C++11 之前,由于 const 引用既可以接收左值又可以接收右值,当一个对象拷贝或赋值给另一个对象时,C++ 编译器会自动调用到开发者定义的或编译器自动生成的拷贝构造方法或拷贝赋值赋值运算符。
这对于左值是没问题的,但是当对于被拷贝的对象时一个右值(表达式或函数对象生成的临时对象)时,将会出现先创建一个临时对象的情况,从而导致出现不必要的资源拷贝的动作。当对象类型占用空间比较大时,开销很大。
例如:
#include <iostream>
using namespace std;
int g_constructCount=0;
int g_copyConstructCount=0;
int g_destructCount=0;
struct A
{
A(){
cout<<"construct: "<<++g_constructCount<<endl;
}
A(const A& a) {
cout<<"copy construct: "<<++g_copyConstructCount <<endl;
}
~A() {
cout<<"destruct: "<<++g_destructCount<<endl;
}
};
A GetA()
{
return A();
}
int main() {
A a = GetA();
return 0;
}
GetA() 函数调用返回的是匿名对象,是一个右值,A a = GetA(); 在GetA()调用结束出栈时会生成一个临时对象
当关闭返回值优化的编译时
g++ -fno-elide-constructors -std=c++11 test.cpp
执行将得到输出:
construct: 1 // GetA() 中的 A()
copy construct: 1 // GetA() 中 A() 从 GetA() 出栈生成临时对象执行的拷贝构造函数
destruct: 1 // GetA() 中 A() 的析构函数
copy construct: 2 // GetA() 调用生成的临时对象拷贝给 A a 执行的拷贝构造
destruct: 2 // GetA() 调用生成的临时对象的析构函数
destruct: 3 // a 对象执行的析构函数
可以看到在没有返回值优化的情况下,拷贝构造函数调用了两次,一次是 GetA()函数内部创建的对象返回出来构造一个临时对象产生的,另一次是在 main 函数中构造 a 对象产生的。第二次的 destruct 是因为临时对象在构造 a 对象之后就销毁了。
如果开启返回值优化的话,输出结果将是:
construct: 1
destruct: 1
可以看到返回值优化将会把临时对象优化掉,但返回值优化不是 c++ 标准,是现代编译器的优化的结果。
如果 main() 改为:
int main() {
const A& a = GetA();
return 0;
}
关闭返回值优化的编译可以看到:
construct: 1 // GetA() 中的 A()
copy construct: 1 // GetA() 中 A() 从 GetA() 出栈生成临时对象执行的拷贝构造函数
destruct: 1
destruct: 2
上述 const A& a 可以直接引用临时量的右值,所以不会出现更多一次的拷贝。
相当于 const 引用延长了 GetA() 函数返回的临时对象的生命周期。C++11 之前经常使用这种方式来优化性能。
但是这样其实还是有问题的,因为返回的对象是 const 的,无法调用非 const 的方法来修改对象内部的状态。
右值引用的引入
基于上述 const 引用虽然能延长了临时对象的生命周期,但是却不能更改临时对象的问题,C++11 出了右值引用 type &&来解决这样的问题。
例如上述 main 函数改为
int main() {
A&& a = GetA();
return 0;
}
关闭返回值优化的编译可以看到:
construct: 1
copy construct: 1
destruct: 1
destruct: 2
结果与 const 引用一样。
右值引用的方式不仅从语言延长了 GetA() 内部 A() 的 A 对象从 GetA() 栈区到外部调用函数的栈区的拷贝的临时对象的生命周期,而且返回的对象可以去修改这个对象内部的属性。
并且通过少一次的拷贝优化了一些性能,但是和 const 引用一样,依然无法从语言层面减少还剩一次多余的资源拷贝(上述输出的 copy construct: 1)。
右值引用底层的原理
与 const 引用创建一个 const 类型的临时变量不同,右值引用引用一个立即数时,创建的是非 const 类型的临时变量:
int&& a = 1;
等价于
int temp = 1; // temp 为临时开辟的空间
int&& a = temp;
即右值引用绑定一个临时量时,相当于给这个临时量起别名,从而使匿名的临时量具名,而延长了其生命周期。
基于上述的底层原理,事实上 绑定右值的右值引用,其变量本身是个左值**。**
通过引入移动操作来避免资源不必要的拷贝
在不开启返回值优化的基础上,上述仅使用右值引用接收表达式返回临时值的方式依然无法避免栈区切换时多余一次的资源拷贝操作。为了解决此问题 C++11 引入了移动操作(移动构造与移动赋值运算符)来从语言层面解决该问题。
C++11 在引入右值引用的基础上增加设计了移动构造函数与移动赋值运算符,从而使赋值或者初始化时传入原类型的一个右值对象时,编译器自动链接到移动构造函数或移动赋值运算符。
此时只需在移动构造函数或移动赋值运算符内部实现的是指向资源的指针的重新指向即可。这样就避免了影响性能的资源拷贝的动作。
例如使用移动构造的情况下:
#include <iostream>
using namespace std;
int g_constructCount=0;
int g_copyConstructCount=0;
int g_moveConstructCount=0;
int g_destructCount=0;
class A {
public:
A():m_ptr(new int[10000]){
cout << "construct: " << ++g_constructCount << endl;
}
A(const A& a):m_ptr(new int(*a.m_ptr)){
cout << "copy construct: " << ++g_copyConstructCount << endl;
}
A(A&& a) :m_ptr(a.m_ptr){
a.m_ptr = nullptr;
cout << "move construct: " << ++g_moveConstructCount << endl;
}
~A(){
cout << "destruct: " << ++g_destructCount << endl;
delete [] m_ptr;
}
private:
int* m_ptr;
};
A GetA(){
return A();
}
int main(){
A a0;
A a = GetA();
return 0;
}
没开返回值优化输出:
construct: 1
move construct: 1
destruct: 1
move construct: 2
destruct: 2
destruct: 3
上述例子既定义了拷贝构造,又定义了移动构造,但是却都走到了移动构造函数里A(A&& a) ,并没有走到拷贝构造函数,虽然共创建了3个栈区对象,但有2个执行的都是移动构造,在这个移动构造函数内部,只对指针进行了赋值操作,相当于只是移动了资源的所有权,并没有对资源进行拷贝。
通过这种方式,从语言层面避免了消耗性能的资源拷贝操作,从而大大提升性能。
这样的情况对于 C++ 的标准库的容器的影响尤为明显,在 c++11 之前,由于没有右值引用与移动构造,任何直接向容器添加值类型元素的操作都可能会调用到拷贝构造函数,从而对于大资源引起严重的性能浪费问题,并在容器类型的变量值传递时也是如此,因此 C++11 出现之前,大部分开发者比较倾向于 c with class。
关键点
右值引用与移动构造能提升性能的关键在于编译器可以自动区分入参是右值还是左值,从而链接时调用的是移动构造(当存在时)还是拷贝构造。
右值引用使用注意
-
右值引用只能引用纯右值与将亡值,不能直接引用左值。
-
右值引用类型不能直接引用右值引用类型,这是因为右值引用类型在引用一个右值之后,相当于对匿名的临时量具名,这个右值引用类型的变量也就变成了一个左值,右值引用不能引用左值。例如如下会编译报错
右值引用符号 type&& 在模板中的不同语义
type && 在 C++ 模板中提升了其语义,不再是单纯的右值引用类型。
万能引用(universal reference)
如果一个变量或者参数被声明为 T&&,其中 T 是被推导的类型(模板中),那这个变量或者参数就是一个万能引用。万能引用的含义是这个参数的 T&& 既可以引用左值也可以右值,还能保持 const 属性。这是因为发生了引用折叠。
引用折叠(reference collapsing)
C++ 原则上不允许开发者显式写出引用的引用的代码:
class A;
A a1;
...
A& & w2 = w1;
// error! No such thing as “reference to reference”
但是在模板的场景中,泛型参数 T 的类型是被自动推导出来的,自动推导规则如下:
在对一个 universal reference 的模板参数进行类型推导时候,同一个类型的 lvalues 和 rvalues 被推导为稍微有些不同的类型。具体来说,类型 T 的 lvalues 被推导为 T&(i.e., lvalue reference to T),而类型 T 的 rvalues 被推导为 T(注意,虽然 lvalue 会被推导为 lvalue reference,但 rvalues 却不会被推导为 rvalue references)
和所有的引用一样,必须对万能引用进行初始化,而且正是万能引用的初始化时,由于 T 的自动推导,导致 T 实际上可能为引用类型,即可能出现 T&& => T& && 的情况。即出现了引用的引用的情况,由于这个是编译器自己推导的结果,为了避免编译器对这个代码报错,C++11 引入了一个叫做“引用折叠”(reference collapsing)的规则来处理某些像模板实例化这种情况下带来的"引用的引用"的问题。
在语言层面来说,因为有左值引用和右值引用两种音乐,那"引用的引用"就有四种可能的组合,C++11 对这4种组合做了如下定义:除了右值引用的右值引用会折叠为右值引用,其他引用的组合都折叠为左值引用:
T& && => T&
T& & => T&
T&& & => T&
T&& && => T&&
上述理解起来比较困难,简单记就是,在隐式推导的模板函数参数中出现万能引用 T&& 时,实参类型是一个左值,T&& => T&,实参类型为一个右值,T&& => T&&。
例如
当定义了如下模板时:
template<typename T>
void f(T&& t){};
对模板做隐式实例化传入一个右值:
f(10);
模板函数原型被推导为
void f(int&& t){}
对模板做隐式实例化传入一个左值:
int x = 10;
f(x);
模板函数原型被推导为
void f(int& t){}
只有作为模板的参数的时候才会出现引用折叠的特性,因为引用折叠是通过模板自动推导出来的,是编译器在编译时动态推导的结果。
万能引用配合引用折叠的应用
支持移动语义的强制移动 std::move
什么是移动语义?
C++11 之前只有拷贝语义,即拷贝构造函数与拷贝赋值操作符,拷贝语义严重影响了某些场景的下的性能,即某些场景并不需要逐个拷贝资源,而是希望能将资源的所有权从一处移动到另一处。
移动语义即 C++11 增加的移动构造函数与移动赋值操作运算符。
为什么要强制移动?
由上文可知,只有当入参为纯右值或将亡值时,且显式声明了移动构造函数或移动赋值操作符时,编译器才会将相关函数调用链接到移动构造函数或移动赋值操作符的版本,否则将默认链接到拷贝构造函数或拷贝赋值运算符。
某些场景希望具名的左值的对象,也能将其资源从一处移动到另外一处,即链接的是移动语义版本的相关函数,则需要使用 std::move 的方式把一个左值强制转换为一个右值,然后即能使编译器自动链接到移动语义的版本。
例如
class Foo{
public:
// data 即表示资源,资源的所有权被 Foo 的对象持有
int * data = new int[100000];
// ...
};
void doSomethingForFoo(Foo& paramfoo){
// 例如处理 paramfoo.data
// ...
}
// 参数为 Foo&& 说明入参是右值引用类型
// 外部调用的实参在被 processsFooData 处理后应该不再有效,因为资源已经被移动
void processsFooData(Foo&& paramfoo){
// 内部操作将 paramfoo.data 的所有权转移
// 例如
int * data = paramfoo.data;
paramfoo.data = nullptr;
// ...
}
int main(){
Foo foo{}; // a 是左值,具名对象
doSomethingForFoo(foo); // 需要做一些操作
// 希望将 foo 对象内部的资源强制移动到另一个地方处理,之后 foo 对象就不再使用
// 因此使用 std::move 将资源的所有权转移
processsFooData(std::move(foo));
// processsFooData(foo); // 报错,因为 Foo&& 右值引用不能接收左值
// foo 在被移动之后,不应再被使用,因为其内部资源已经被移动
// ...
return 0;
}
std::move 原理
由于右值引用是不能转换为左值的
int main(){
int a = 1;
int&& b = a;
}
// 编译报错:
// error: rvalue reference to type 'int' cannot bind to lvalue of type 'int'
⭐️但是一个左值可以被通过强转的方式转换为右值引用的类型:
int main(){
int a = 1;
int&& b = (int&&)a;
int&& c = static_cast<int&&>(a);
}
根据这个原理 std::move 通过模板封装了这样的操作:
template <typename T>
typename remove_reference<T>::type&& move(T&& t){
return static_cast<typename remove_reference<T>::type&&>(t);
}
remove_reference 也是一个模板,用于干掉模板参数的引用特性,从而保留最原始的参数类型:
template <typename T> struct remove_reference{
typedef T type;
};
template <class T> struct remove_reference<T&>{
typedef T type;
}
template <class T> struct remove_reference<T&&>{
typedef T type;
}
通过 remove_reference 的模板来实现获取传入 move 函数模板的模板参数 T 的去引用之后的类型。
实现完美转发 std::forward
什么是完美转发?
完美转发的含义即经过一层或多层函数调用传参时,依然能保留参数的左值或者右值的属性。
为什么需要完美转发?
例如 A(P p) -> B(P p) 的函数调用链路,若没有完美转发,实参 p 将在 A() 函数内部丢失是左值还是右值的属性。
例如
#include <iostream>
using namespace std;
void processValue(int& a){ cout << "lvalue" << endl; }
void processValue(int&& a){ cout << "rvalue" << endl; }
template <typename T>
void forwardValue(T&& val) {
processValue(val);
// processValue(std::forward<T>(val));
}
void Testdelcl() {
int i = 0;
forwardValue(i);
forwardValue(0);
}
int main(){
Testdelcl();
return 0;
}
输出
lvalue
lvalue
可以看到入参类型无论是左值还是右值类型, forwardValue 内部最终都调用到了processValue(int& a)的版本,这是因为虽然即使 val 通过引用折叠推断成 int&& 的类型,但是 int&& 并不是右值或将亡值,所以编译器解析不到 processValue(int&& a)的版本。
需要有一种机制,在转发 val 参数时,能保留其原有的左值或右值的属性。通过 std::forward 的转换即可
例如上述例子的 forwardValue改为
template <typename T>
void forwardValue(T&& val) {
processValue(std::forward<T>(val));
}
输出
lvalue
rvalue
由此即可实现在函数调用过程中,保留其左值或右值的属性。
完美转发的原理分析(复杂)
template <typename T> struct remove_reference{
typedef T type;
};
template <class T> struct remove_reference<T&>{
typedef T type;
}
template <class T> struct remove_reference<T&&>{
typedef T type;
}
// 第1个版本
template<typename _Tp>
constexpr _Tp&&
forward(typename std::remove_reference<_Tp>::type& __t) noexcept {
return static_cast<_Tp&&>(__t);
}
// 第2个版本
template<typename _Tp>
constexpr _Tp&&
forward(typename std::remove_reference<_Tp>::type&& __t) noexcept {
return static_cast<_Tp&&>(__t);
}
当执行 forwardValue(i); 时
forwardValue 的泛型参数 T 被推断为 int&,T&& 通过引用折叠规则变成 int&,即 val 的类型为 int&,std::forward<T>(val) 显式实例化,forward 模板的_Tp 即 int&,
由引用折叠的规则,_Tp&& => int& && => int&,static_cast<_Tp&&>(__t) 等价于强转为 int& 类型,因此执行到 processValue(int& a)
当执行 forwardValue(0); 时
forwardValue 的泛型参数 T 被推断为 int,T&& 通过引用折叠规则变成 int&&,即 val 的类型为 int&&,std::forward<T>(val) 显式实例化,forward 模板的_Tp 即 int&&
由引用折叠的规则,_Tp&& => int&& && => int&&,static_cast<_Tp&&>(__t) 等价于强转为 int&& 类型,因此执行到 processValue(int&& a)
总结
在模板函数中通过 std::forward,保留了参数左值或右值的属性,能完美的将参数传递转发到下层的调用函数中,而不会产生不必要的拷贝问题。
完美转发的应用
静态代理模式实现 AOP
#include <iostream>
// AOP 面向切面,通用的非核心业务逻辑与核心业务逻辑分离
template <typename RetT, typename... ARGS1, typename... ARGS2>
void MyForwarder(const char* pDesc,
RetT(*f)(ARGS1...),
ARGS2&& ...args) {
std::cout << "common log: "<< pDesc << std::endl;
// 前置的非核心业务的通用逻辑
std::cout << "----------------" << std::endl;
std::cout << "Do pre common business" << std::endl;
std::cout << "----------------" << std::endl;
// 核心的业务逻辑
f(std::forward<ARGS2>(args)...);
// 后置的非核心业务的通用逻辑
std::cout << "----------------" << std::endl;
std::cout << "Do rear common business" << std::endl;
std::cout << "----------------" << std::endl;
}
int test01(int a1){
std::cout << "exec func01 body: a1 = " << a1 << std::endl;
}
// a1 为左值引用,a2 为右值引用
int test02(int& a1, int&& a2){
std::cout << "exec func02 body: a1 = " << a1 << ", a2 = "<< a2 << std::endl;
}
int main(){
MyForwarder("will exe test01", test01, 1);
std::cout << std::endl << std::endl;
int a1 = 1;
int a2 = 2;
MyForwarder("will exe test02", test02, a1, std::move(a2));
return 0;
}
输出
common log: will exe test01
----------------
Do pre common business
----------------
exec func01 body: a1 = 1
----------------
Do rear common business
----------------
common log: will exe test02
----------------
Do pre common business
----------------
exec func02 body: a1 = 1, a2 = 2
----------------
Do rear common business
----------------