内容概要
虚幻引擎基于C++14标准开发,理解并运用好现代C++的语言特性对掌握Unreal C++就至关重要。转移语义是非常重要的,我们就从实用性的角度来理解一下这个概念。

【欢迎转载,请注明作者:房燕良,原文出处:游戏程序员的自我修养

所谓的“现代C++”,就是指C++ 11标准之后的C++语言,与之相对应的是“经典C++”,也就是C++ 98/03标准的C++语言。Unreal Engine 4使用C++14标准开发,用到了很多现代C++的特性,而且它不使用标准库,这可能就需要我们对现代C++理解的更清晰一点。于是,打算把自己对于虚幻引擎中的现代C++编程的理解整理成博客,希望对Unreal C++开发的朋友有点帮助。这一系列博客主要还是讲现代C++的基础编程为主,并注重在虚幻引擎开发中的实用性。

首先,我们来聊一聊转移语义(Move Semantics),这是C++11标准中最重要的一个特性了。也许你在网上看过一些相关文章,往往前面先是大篇幅的讲解什么是“右值引用”,让人看的昏昏欲睡,然而“右值引用(Rvalue References)”只是一种底层的语言机制,基于它才能实现所谓的转移语义(Move Semantics)完美转发(Perfect Forwarding)。在这里,咱们就直奔主题,从Why、What、How三个层面把转移语义搞明白,其中会用到“右值引用”,自然也能理解了。

转移语义解决什么问题?

“转移语义”真的不是啥玄妙的新技术,只是经典C++遗留问题的一个解决方案。在C++中默认使用值类型,值类型的变量之间只能执行“拷贝语义(Copy Semantics)”,而值拷贝对于那些管理着内部重量级资源的对象来说可能很昂贵,例如 std::vector 容器。

OK,咱们先不扯这些技术名词,来看个简单的例子吧。假设:我们要实现一个类,其内部管理一块内存,用来存储大量的数据。在C++中,一般会在构造的时候创建它们、在析构的时候要释放它们。经典C++执行对象复制的时候,需要通过自定义的拷贝构造函数和操作符来实现内部资源对象的复制。例如这样:

class MyString {
  char* mData;
  unsigned int mSize;
 public:
  MyString() : mData(NULL), mSize(0) {}
  ~MyString() {
    if (mData) delete[] mData;
  }
  MyString(const MyString& other) {
    if (other.mSize > 0 && other.mData) {
      mData = new char[other.mSize];
      mSize = other.mSize;
      memcpy(mData, other.mData, mSize);
    } else {
      mData = NULL;
      mSize = 0;
    }
  }
  ... ...
};

上述代码中使用了我已经多年为用过的裸指针,只是为了例子更直观,项目中并不提倡这样用啦。

当下面代码运行的时候,它就会在栈上开辟一个临时对象,然后再调用拷贝构造函数,进行一次内存拷贝,然后把原来那个临时对象析构掉。这太笨拙了吧?!对,就是这个问题!

MyString MakeXXXString() {
  MyString tmp("blah blah");
  tmp += "blah blah";
  return tmp;
}

MyString str = MakeXXXString();

在经典C++中,我们总是使用引用等方式,极力避免这种情况;而在现代C++中,你可能会看到越来越多的大对象按值传递,因为C++11标准中引入了“转移语义”。

如何实现转移语义?

为了获得更好的性能,上面那种情况下,理想的处理方式是把那个临时对象所管理的内部资源的所有权转移给新的对象。那么,怎么转移呢?你需要依C++ 11标准,来实现自己的转移构造函数(Move Constructor)转移赋值函数(Move Assignment)。具体代码如下:

class MyString {
  char* mData;
  unsigned int mSize;

 public:
    ... ...

  MyString(MyString&& rhs) {
    moveFrom(rhs);
  }

  MyString& operator=(MyString&& rhs) {
    moveFrom(rhs);
    return *this;
  }

private:
  void moveFrom(MyString&& rhs){
    mData = rhs.mData;
    mSize = rhs.mSize;
    rhs.mData = nullptr;
    rhs.mSize = 0;
  }

};

在上面这段代码中,我们实现了一个转移构造函数(Move Constructor)和一个转移赋值操作符(Move Assignment),它们的核心操作都由moveFrom()函数实现。这个函数很简单,就是把原来那个对象中的内存指针和状态值复制到这个对象内,然后把原来那个对象的指针置空,这样那个对象在析构的时候就不会释放这块内存了。于是,也就完成了内部资源的所有权转移

如果你没有实现拷贝构造函数和拷贝赋值操作符,编译器会在需要的时候自动帮你实现一个;但是转移构造函数和转移赋值操作符则不会自动生成,如果你没有自己实现的话,编译器会转而调用拷贝构造函数或者拷贝赋值操作符。(C++编译器总是很热心的来帮倒忙)

上面的描述就是“转移语义”的一个最典型的场景。通过这个简单例子,我们先不谈艰涩的语言标准,先把问题和解决方法搞清楚。顺带说明一下,Move Semantics,很多人也译作“移动语义”,但是我认为“转移语义”更为贴切:它实现的对象内部资源的所有权转移!

在Unreal引擎中自定义的各种容器,包括TArray、TMap、TSet、FString等,它们也都和现代C++的标准STL容器一样实现了转移构造函数和转移赋值操作符,所以在C++代码中,**你可以大胆的返回一个大型的TArray临时对象或者FString数据,不会有性能惩罚**。那么对于C++/蓝图的互操作代码又如何呢?请见最后一小节

右值引用和std::move/MoveTemp

需要注意class MyString的转移构造函数和转移赋值操作符的参数类型是:MyString&&,有两个&符号,这个就是“右值引用”啦(并不是带&&就都是右值引用,还可能是所谓的万能引用:Universal Reference,先按下不表)! 因为编译器要明确区分参数类型,才能确定为你调用哪个构造函数或赋值操作符,也就是进行“拷贝”还是“转移”。上面那个MakeXXXString()函数的返回值,就是典型的“右值”。当class MyString具备转移构造函数之后,MyString str = MakeXXXString()这一句就不会再调用拷贝构造函数了,而是调用转移构造函数,而其内部实现只是内存指针的所有权转移。

说到这里,“转移语义”也就说明白了!但是,也许你注意到了引擎中还有一个MoveTemp模板函数(对应C++标准库中的std::move),这又是什么鬼呢?

简单来说std::move和Unreal的MoveTemp并没有移动什么,它们不负责移动任何东西,实际的移动操作是由对象的移动构造函数和移动赋值操作符完成的,也就是前面说的那些。MoveTemp的本质就是一个“强制类型转换”,使用它就可以把一个左值引用转换成右值引用。这个东西的名字,在C++标准制定过程中,曾有提议叫做“rvalue_cast”,但是最后还是选择了叫做“move”。我们可以理解为:move这个词更形象吧,因为经过MoveTemp之后的对象就死了。。。。它内部管理的数据/资源已经被转移走了。

我们来用TArray举个简单的例子:

TArray<int32> a1 = { 2,2,3,3 };
TArray<int32> a2 = MoveTemp(a1); 

a1本来是一个左值,通过MoveTemp强制把它移动到了a2;如果不使用MoveTemp的话,则会调用拷贝构造函数。这两行代码执行之后,a1.Num()a2.Num()各自会是多少呢?实际的情况是a1已经为空了,a2有那四个元素。

我们再通过一个例子来看一下MoveTemp如何发挥作用:

void AddString(FString Str)
{
	TArray<FString> StrVector;

#if 0
	StrVector.Push(Str);
#else
	StrVector.Push(MoveTemp(Str));
#endif
}

在上面这个函数中,我们可以直接调用StrVector.Push(Str),则会将Str进行对象拷贝之后添加到TArray,Str的值不变;然而,Str这个对象如果也没其他用处了,那就可以使用StrVector.Push(MoveTemp(Str))这种写法,它调用的则是void TArray::Push(ElementType&& Item)这个成员函数,执行移动语义,Str的内容被清空。

在 TaskGrah 使用移动语义

TaskGraph 是一种 “基于 Task 并行机制”,整体上是比操作基于线程的并行编程要更高级一点,也更易用一些。 UE4 的 TaskGraph 是非常好用的,例如它可以指定Task在哪个线程执行

TaskGraph的基本用法在引擎源代码注释里面有一个很好的说明,主要就是写下面这样一个class:

class FGenericTask
{
	TSomeType	SomeArgument;
public:
	FGenericTask(TSomeType InSomeArgument) // CAUTION!: Must not use references in the constructor args; use pointers instead if you need by reference
		: SomeArgument(InSomeArgument)
	{
		// Usually the constructor doesn't do anything except save the arguments for use in DoWork or GetDesiredThread.
	}
	~FGenericTask()
	{
		// you will be destroyed immediately after you execute. Might as well do cleanup in DoWork, but you could also use a destructor.
	}
	FORCEINLINE TStatId GetStatId() const
	{
		RETURN_QUICK_DECLARE_CYCLE_STAT(FGenericTask, STATGROUP_TaskGraphTasks);
	}

	[static] ENamedThreads::Type GetDesiredThread()
	{
		return ENamedThreads::[named thread or AnyThread];
	}
	void DoTask(ENamedThreads::Type CurrentThread, const FGraphEventRef& MyCompletionGraphEvent)
	{
		// The arguments are useful for setting up other tasks. 
		// Do work here, probably using SomeArgument.
		MyCompletionGraphEvent->DontCompleteUntil(TGraphTask<FSomeChildTask>::CreateTask(NULL,CurrentThread).ConstructAndDispatchWhenReady());
	}
};

这里有一个注释: ** Must not use references in the constructor args ** ,也就是说不支持引用类型。这时候就是“移动语义”可以发挥作用的地方了。

例如,咱们要做一个Task来分析一个巨大的字符串:

class MyStringParseTask
{
  FString mString;
public:
  MyStringParseTask(FString InString):mString(MoveTemp(InString))
  {}
  ....
}

我们使用mString(MoveTemp(InString))就可以减少一次数据的拷贝了。

蓝图/C++互操作中的移动语义

我分了几种情况进行了测试,下面逐个看一下:

C++返回右值的情况

假设我们有这样一个供蓝图调用的函数:

UFUNCTION(BlueprintCallable, Category = "Modern C++")
TArray<int> MakeBigArrayInCpp();

在”.generated.h”中生成的对应的Thunk函数是这样的:

DECLARE_FUNCTION(execMakeBigArrayInCpp) \
	{ \
		P_FINISH; \
		P_NATIVE_BEGIN; \
		*(TArray<int32>*)Z_Param__Result=P_THIS->MakeBigArrayInCpp(); \
		P_NATIVE_END; \
	} \

Z_Param__Result执行赋值操作会调用TArray的移动赋值操作符,所以这里并不会产生TArray内部数据的拷贝,也就是说:C++返回临时对象的情况是支持移动语义。

蓝图返回右值的情况

经测试,这个不支持!下面这个代码是可以编译通过,但是在蓝图中无法创建这个节点。

UFUNCTION(BlueprintImplementableEvent, Category = "Modern C++")
TArray<uint8> MakeBigArray();

函数参数的支持情况

使用C++编程的蓝图函数可以通过UPARAM来支持左值引用,来处理蓝图中的对象,类似下面这样:

 UFUNCTION(BlueprintCallable)
 static void DoSomething(UPARAM(ref) TArray<int> &InOutArray);

不过,这种方式不支持右值引用参数传递,这也是意料之中的,这种需求也极少有吧。

延伸阅读