内容概要
Lambda可以让代码简化很多,可维护性也能提高很多,但是它也有一些小细节,不小心的话,可能程序Crash了还不知道是哪里的问题。
【欢迎转载,请注明作者:房燕良,原文出处:游戏程序员的自我修养】
C++ Lambda 基础知识
Lambda ,就是希腊字母“λ”,据说是代表着“λ演算(lambda calculus)”。C++11开始支持Lambda,可以说它只是一个便利机制。Lambda能做的事情,本质上都可以手写代码完成,但是它确实太方便了!怎么说呢,还好以前没有认真学std::bind各种绕法,现在用lambda方便多了。
我们可以通过简单的例子初步认识一下:
int var1 = 100;
std::string var2 = "hello";
auto myLambda = [var1, &var2](int param) -> std::string {
var2.append(std::to_string(var1));
var2.append(std::to_string(param));
return var2;
};
std::cout << "fistLambda typeid = " << typeid(myLambda).name() << std::endl;
上面代码中由[]
开头的那一串就是lambda了。在大多数情况下我们就使用“lambda”这个名词就够了,但其实仔细想想,其中代码涉及到三个概念:
- lambda表达式(lambda expression)
- 闭包(closure)
- 闭包类(closure class)
例如,在上面这段代码中:
- 定义了一个变量:myLambda,它就是“闭包”
- myLambda 的类型是一个编译器生成的匿名的类,也就是“闭包类”;
- 这个闭包类是由等号右边的”lambda表达式”生成的,这个lambda表达式:
- 按值捕获了var1;按引用捕获了var2;
- 并且接受一个int型参数;
- 返回一个std::string对象
我们可以尝试把编译器自动生成的”闭包类”写出来,把“闭包”对象的构造也写出来,就应该能说明问题了。下面这段代码大体上和上面的代码等效:
int var1 = 100;
std::string var2 = "hello";
class MyClosureClass {
int var1;
std::string& var2;
public:
MyClosureClass(int inVar1, std::string& inVar2)
: var1(inVar1), var2(inVar2) {}
// not default constructible
MyClosureClass() = delete;
MyClosureClass(const MyClosureClass&) = default;
MyClosureClass(MyClosureClass&&) = default;
~MyClosureClass() = default;
// not copy assignable
MyClosureClass& operator=(const MyClosureClass&) = delete;
// function-call operator
std::string operator()(int param) {
var2.append(std::to_string(var1));
var2.append(std::to_string(param));
return var2;
}
};
auto myLambda = MyClosureClass(var1, var2);
std::cout << "myLambda: " << myLambda(2233) << std::endl;
class MyClosureClass 还可能包含一个自定义的类型转换操作符,用来把闭包对象转换成函数指针。
捕获列表“有坑”
lambda表达式的常用语法格式如下:
[ captures ] ( params ) -> return_type { body }
为了理解方便,只列出了常用元素,不全面。
其中比较值得一说的就是[captures]
:捕获列表了!
[captures]
支持多种写法,首先就是个人不推荐使用的两种默认捕获模式(default capture modes):
[=]
: 按值捕获当前作用域所有变量[&]
: 按引用捕获当前作用域所有变量
从性能、代码可维护性等方面都不建议使用这两种方式。比较常用的写法就是明确列出需要捕获的变量,例如:[var1, &var2]
, 其中var1
使用了“按值捕获”模式,var2
前面加了一个&
代表着它使用“按引用捕获”的模式。下面就分别讨论一下“按值捕获”和“按引用捕获”有什么坑。
按值捕获 & 捕获时机
按值捕获就是在创建闭包的时候,将当前作用域内的变量赋值到闭包类的成员变量中,这个比较好理解,但是也有一个小小的坑。请看下面代码:
FString LocalStr = TEXT("First string");
auto TestLambda = [LocalStr]() {
UE_LOG(LogTemp, Error, TEXT("String = %s ."), *LocalStr);
};
LocalStr = TEXT("Second string");
TestLambda();
当调用TestLambda()
的时候,也许会觉得意外,输出的还是:String = First string。这就是要注意的地方,当闭包生成的那一刻,被捕获的变量已经按值赋值的方式进行了捕获,后面那个LocalStr
对象再怎么变化,已经和闭包对象里面的值没有关系了。
如果按引用捕获,则可以跟踪LocalStr
的更新了,但是按引用捕获的坑更深。
按引用捕获 & 悬空引用
如果是在C#中使用 lambda 就简单很多了,它有自动垃圾回收、class对象全部是引用类型这些特性,而对于C++来说,对象的生命周期、内存管理这根弦始终要绷紧。在C++编程中,程序员有责任保证Lambda调用的时候,保证被捕获的变量仍然有效~!是的,责任在你,而不在编译器。如果不能很好理解这点,就会遇到悬空引用的问题!
悬空引用( dangling references )就是说我们创建了一个对象的引用类型的变量,但是被引用的对象被析构了、无效了。一般情况下,引用类型的变量必须在初始化的时候赋值,很少遇到这种情况,但是如果lambda被延迟调用,在调用时,已经脱离了当前的作用域,那么按引用捕获的对象就是悬空引用。
我们先来看一段代码:
FString LocalStr = TEXT("Local string");
auto TestLambda = [&LocalStr]() {
UE_LOG(LogTemp, Error, TEXT("String = %s ."), *LocalStr);
LocalStr = TEXT("Lambda string");
};
// 在这里直接调用是没问题的
TestLambda();
// 在Timer中调用,妥妥的Crash!
FTimerDelegate Delegate;
Delegate.BindLambda(TestLambda);
FTimerHandle TestTimer;
GetWorldTimerManager().SetTimer(TestTimer, Delegate, 1.0f, true);
上面这段的代码,在定义lambda之后立即调用则可以运行,同样一个labmda放入timer则会crash!这是为什么呢?
前面基本概念那一部分讲到了TestLambda
是一个闭包对象,它的类型是编译器生成的一个匿名的class。对于这个例子,我尝试把这个闭包类的核心部分写出来:
class MyLambdaClass {
FString& LocalStr;
public:
MyLambdaClass(FString& InLocalStr) :LocalStr(InLocalStr) {}
void operator()() const
{
UE_LOG(LogTemp, Error, TEXT("String = %s ."), *LocalStr);
LocalStr = TEXT("Lambda string");
}
};
看到上面这个class,应该就很清晰了:
TestLambda()
直接调用那一句,FString LocalStr
这个对象还在作用域内,所以可以执行;- 而在Timer执行的时候,
LocalStr
这个对象已经出了作用域,被析构了,这个时候Lambda中捕获的那个引用就变成了悬空引用啦,所以会导致Crash!
总之,使用各种 Delegate 的 “BindLambda” 的时候,要格外小心悬空引用的风险。
捕获UObject指针
虚幻的UObject具备自动垃圾回收机制,但这个机制是基于对象之间的引用关系的,也就是说一个 UObject 指针被捕获之后,还是可能被垃圾回收的。所以,对于延迟调用的lambda是不建议捕获UObject的;如果实在需要的话建议使用 FWeakObjectPtr ,例如这样:
AActor* TargetActor = FindMyTargetActor();
auto ObjectLambda = [ActorPtr = TWeakObjectPtr<AActor>(TargetActor)](const FVector& Offset) {
if (ActorPtr.IsValid()) {
AActor* TargetActor = ActorPtr.Get();
TargetActor->AddActorWorldOffset(Offset);
}
};
通过 FWeakObjectPtr 引用 UObject 指针不会影响对象的生命周期,在 FWeakObjectPtr::IsValid()
方法中默认会判断当前对象是不是 “Pending Kill” 状态。
如果希望持有某个UObject的强引用,保证它不被垃圾回收,那么建议不要用lambda,建议使用其他写法:
- 使用 Delegate 的 BindUObject 或者 BindUFunction 来处理;
- 如果是很复杂的代码,也可以用
UObject
或者FGCObject
的派生类来处理。
C++14的初始化捕获(init capture)
在上面UObject指针的例子中,捕获列表是这样写的:ActorPtr = TWeakObjectPtr<AActor>(TargetActor)
,这种写法就是C++14引入的新特性“初始化捕获”,也被称为广义捕获(generalized capture)。这个的语法是这样的:
- 等号左边的变量是声明在“闭包类” 里面的,它的类型由编译器自动推导;
- 等号右边的表达式,其作用域就是当前定义lambda的作用域,可以引用局部变量或者实参。
这个语法更有用的地方是:它可以把使用“移动语义”把局部变量移动到闭包中,类似这样:
FString SomeBigString;
// ...
auto MyLambda = [MyStr = MoveTemp(SomeBigString)] {
//....
};
延伸阅读
- Unreal Engine Coding Standard
- Lambda expressions (since C++11), cppreference.com