根据作者多年的从业经验,结合UnrealEngine虚幻引擎项目的最佳实践,整理出以下编码规范
本文尽可能做到有理有据,后续可能还会不定期修订。
最新修订时间:2026-01-06
如无特殊情况,应遵循以下编码规范,以防出现意外问题。
本文将尽可能列出编程中所遇见的各种情况,以及采用本编码规范所解决的问题。旨在让不同的编程人员面对同一需求时,能获得唯一的编码格式。如果文本未明确提及某种行为,那么可视为未定义行为,可结合自身实践来判断是否符合规范。
本文可能会使用以下标签说明规范执行的严格程度:说明提议推荐强制
原生C++可参考:C++项目编程规范
编程思路
推荐相同的接口如果框架提供了,除非要求严格保持一致,否则应优先使用框架提供的接口。
推荐对于可预见时间复杂度规模小于O(n)(n<100)的情况,代码的可维护性往往比运行效率更重要。
代码引用
版权声明
提议如果文件有可能被多个项目调用,那么在每个文件头最好添加版权声明,例如:
| |
头文件
- 强制不需要通过宏防止重复包含,而是应该使用
#pragma once,目前所使用的大部分编译器均支持。 - 建议include时尽量直接包含所需的最小单位的头文件。
- 例如,勿包含
Core.h,而在核心中包含需要定义的特定头文件。 - 这是为了避免在包含头文件时,引入不必要的依赖,导致编译时间增加且不利于头文件梳理。
- 例如,勿包含
- 建议在
#include中插入空行以分割相关头文件, C 库, C++ 库, 其他库的.h和本项目内的.h是个好习惯。 - 强制尽可能避免头文件中直接引用某一类,而是在cpp中引用其他头文件,。
- 可以考虑优先使用前置声明的形式来在头文件中使用另一类,而在cpp中再真正include,可以有效梳理结构避免循环引用。
- 建议尽可能先根据:cpp对应头文件、C++标准库文件、框架头文件、第三方头文件、项目通用头文件、具体功能头文件的顺序来引用文件,方便理清文件引用结构。
- 强制引用头文件时尽可能避免使用相对位置,比如....\NetDef.h,不利于文件梳理以及理解文件结构。
- 强制避免使用反斜线
\来标记路径,在一些编译器会存在异常,应使用斜线/来标记路径。
前置声明
所谓「前置声明」(forward declaration)是类、函数和模板的纯粹声明,没伴随着其定义。
说明前置声明能够节省不必要的重新编译的时间。多余的 #include 会迫使编译器展开更多的文件,处理更多的输入,使代码因为头文件中无关的改动而被重新编译多次。
内联函数
内联函数是一种特殊的函数,它的函数体会在调用点直接展开,而不是像普通函数那样跳转到函数体执行。
- 说明内联函数的合理使用可提高代码执行效率
- 提议当函数只有10行甚至更少时才将其定义为内联函数
- 强制谨慎对待析构函数,析构函数往往比其表面看起来要更长,因为有隐含的成员和基类析构函数被调用
- 建议包含循环和switch语句的函数内联通常得不偿失
- 说明声明了内联也不一定会被编译器内联,虚函数和递归函数不会被正常内联
- 建议类内部的函数一般会自动内联。所以某函数一旦不需要内联,其定义就不要再放在头文件里,而是放到对应的 cpp 文件里。这样可以保持头文件的类相当精炼,也很好地贯彻了声明与定义分离的原则。
范例
| |
作用域
命名空间
- 说明命名空间将全局作用域细分为独立的, 具名的作用域, 可有效防止全局作用域的命名冲突。
- 强制禁止使用using namespace xxx;
- 强制禁止使用内联命名空间
1 2 3 4 5namespace X { inline namespace Y { void foo(); } }- X::Y::foo与X::foo是等价的
- 内联命名空间主要用来保持跨版本的 ABI 兼容性。(一般用不到)
- 在头文件中使用匿名空间违背了C++的唯一定义原则(One Definition Rule(ODR))
匿名命名空间和静态变量
- 提议在 .cpp 文件中定义一个不需要被外部引用的变量时,可以将它们放在匿名命名空间或声明为
static。- 说明这个名字 只在当前
.cpp文件中可见,其他.cpp文件 完全访问不到。- 意味着你在这个文件中声明的这些标识符都不能在另一个文件中被访问。
- 即使两个文件声明了完全一样名字的标识符,它们所指向的实体实际上是完全不同的。
- 强制不要在头文件中使用
- 说明这个名字 只在当前
| |
局部变量
强制函数变量尽可能置于最小作用域,并在变量声明时进行初始化
- 离第一次使用越近越好,方便阅读者更容易定位变量声明的位置
- 局部变量在声明的同时进行显式值初始化,比起隐式初始化再赋值的两步过程要高效,同时也贯彻了计算机体系结构重要的概念「局部性(locality)」。
- 对象:循环作用域外声明要高效的多
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22int i; i = f(); // 坏——初始化和声明分离 int j = g(); // 好——初始化时声明 vector<int> v; v.push_back(1); // 用花括号初始化更好 v.push_back(2); vector<int> v = {1, 2}; // 好——v 一开始就初始化 // 低效的实现 for (int i = 0; i < 1000000; ++i) { Foo f; // 构造函数和析构函数分别调用 1000000 次! f.DoSomething(i); } Foo f; // 构造函数和析构函数只调用 1 次 for (int i = 0; i < 1000000; ++i) { f.DoSomething(i); }- 属于
if,while和for语句的变量应当在这些语句中正常地声明,这样子这些变量的作用域就被限制在这些语句中了,举例而言:
1while (const char* p = strchr(str, '/')) str = p + 1;
静态和全局变量
- 说明原生数据类型POD(Plain Old Data)
- Int, char, float等基本类型,POD类型的指针、数组、结构体
- 说明静态生存周期变量
- 全局变量,静态变量,静态类成员变量和函数静态变量
- 说明同一个编译单元(cpp)内是明确的,静态初始化优先于动态初始化,初始化顺序按照声明顺序进行,销毁则逆序,不同编译单元之间的初始化和销毁顺序则属于未明确行为
- 强制不允许用函数返回值来初始化POD变量,除非该函数不涉及任何全局变量(比如
getenv()或getpid())- 函数的作用域内的静态变量除外,毕竟他们的初始化顺序是有明确定义,只会在指令执行到它的声明那里才会发生
- 强制quick_exit替代exit,前者不会执行任何析构,也不会执行atexit绑定的任何handlers
- 建议禁止使用类的静态生存周期变量,因为在这种情况下构造和析构函数调用顺序是不确定的,他们会导致难以发现的bug。(对象A依赖对象B,但对象B早于A析构)
- 如果确实需要一个class类型的静态或全局变量,可以使用单例模式
类
构造函数
- 强制应该尽量避免在构造函数中进行复杂的初始化逻辑,以保证对象正确地构造完成。
- 强制不要在构造函数中调用自身的虚函数,这类调用时不会重定向到子类的虚函数实现,即使现在没有被子类重载,将来也是隐患
- C++中的虚函数允许在运行时动态绑定,即在运行时根据实际对象的类型来确定调用哪个虚函数实现。这样的动态绑定是通过虚函数表(vtable)来实现的。在构造函数中调用虚函数时,由于对象尚未完全构造完成,可能会导致虚函数表指针尚未被正确初始化,从而导致无法正确调用虚函数的实现。
- 当虚函数在构造函数中被调用时,如果基类的构造函数中调用了虚函数,由于动态绑定机制,可能会调用派生类中未被构造完全的函数。这样可能会导致对象的状态不正确,甚至产生未定义的行为。
隐式类型变换
- 建议不要定义隐式类型变换,对于转换运算符和单参数构造函数, 请使用
explicit关键字- 隐式类型转换会隐藏类型不匹配的错误. 有时, 目的类型并不符合用户的期望, 甚至用户根本没有意识到发生了类型转换.
- 隐式类型转换会让代码难以阅读, 尤其是在有函数重载的时候, 因为这时很难判断到底是哪个函数被调用.
可拷贝类型和可移动类型
- 建议如果需要显式定义拷贝和移动,否则就把隐式产生的拷贝和移动函数禁掉
- 如果需要就让你的类型可拷贝 / 可移动. 作为一个经验法则, 如果对于你的用户来说这个拷贝操作不是一眼就能看出来的, 那就不要把类型设置为可拷贝. 如果让类型可拷贝, 一定要同时给出拷贝构造函数和赋值操作的定义, 反之亦然. 如果让类型可移动, 同时移动操作的效率高于拷贝操作, 那么就把移动的两个操作 (移动构造函数和赋值操作) 也给出定义. 如果类型不可拷贝, 但是移动操作的正确性对用户显然可见, 那么把这个类型设置为只可移动并定义移动的两个操作
- 强制如果定义了拷贝/移动操作, 则要保证这些操作的默认实现是正确的. 记得时刻检查默认操作的正确性, 并且在文档中说明类是可拷贝的且/或可移动的
| |
- 建议如果你的类不需要拷贝 / 移动操作, 请显式地通过在
public域中使用= delete或其他手段禁用之.
| |
继承
- 强制如果类具有继承关系,析构函数需要是虚函数
| |
多重继承
建议最多只有一个基类是非抽象类,其他基类都是纯接口类
建议如果出现了菱形继承通常意味着设计出了问题
运算符重载
- 提议运算符重载会混淆视听,让人误以为耗时的操作和操作内建类型一样轻巧,需要谨慎使用
成员变量
强制一般不建议把成员变量标记为public,接口变更时更容易出现遗漏。并且一个public的变量,对象自身对其更难以控制,不利于约束其读写行为。考虑为其增加public的Set与Get相关接口来实现对其的操作。
比如如下例子:
| |
如果调用者错误使用了FString& SaveKey = StarsSaveGame->OverrideSaveKey,那么就会错误修改成员变量。
如果改成函数的返回则没有这种隐患,会在编译器得到警告:
Binding r-value to l-value reference is non-standard Microsoft C++ extension
| |
结构体
成员变量初始化
强制在代码中用到的指针一定要进行初始化,在栈上创建结构体时,指针并不会被UE初始化,会造成野指针。
如下:
| |
但并不是所有的成员变量都需要初始化,比如TArray、TMap等容器类,它们会在构造函数中初始化。对于有些结构直接靠赋值来初始化反倒有可能有问题,可参考:记录TWeakObjectPtr的一个比较坑的编译报错。此时如果需要初始化,可以再构造函数内进行,以避免与预期不符。
枚举
- 强制如果枚举值需要暴露给蓝图,那么他需要在
uint8范围内,否则在一些时候会有超出预期的问题。- 如果仅仅是为了反射,那么可以不遵守此规范。
- 提议如果想要大数值的枚举,又不想在蓝图中直接写死数值,可以考虑使用
FName来与之对应,推荐使用表格维护。
枚举值类型
强制枚举值应使用enum class定义并标识所使用的存储结构,而不应该直接使用enum。
enum无法指定底层所使用的数据类型。同一enum的存储结构在不同的编译器下可能不同,有的时候会造成代码的泛用性降低。改为enum class后,可以指定具体的存储结构,使得开发者对其结构更为可控。
enum存在向整形的隐式转换。隐式转换在一些时候虽然方便了开发者调用,但在错误复制或编码时没有明确的报错,因此不建议使用隐式转换。改为enum class后,如果需要进行转换可以使用static_cast来显式转换。
enum无法定义重名。enum为了解决这个问题一般需要在枚举值命名中加入类型名,从而导致在一些情境下枚举值较长。而enum class没有这种问题,可以按照设计的枚举值进行命名。
枚举值命名
强制枚举值类型命名为以E开头,如EUnitClientType,即使不暴露给蓝图也可以遵循此规范。
因为枚举值常常直接拿出值用作比较,使用此规范可以区分出枚举值和普通的类型,也方便代码补全工具查找指定枚举。
枚举值顺序
除非明确地知道修改顺序不会产生影响,否则不要修改枚举的顺序,有可能造成逻辑或者数据不兼容。
若枚举值的顺序不应被修改(一般是在配置表或其他逻辑中有调用),考虑用=写出其枚举值。
若枚举值无固定值,则可以默认视为枚举值顺序改变不影响逻辑。
枚举值举例
| |
异常
这里说的异常并不是指抛出的异常(
throw),而是指在代码中可能会出现的错误情况。
建议要注重异常处理,对于有可能报错的函数,可以考虑增加bool的返回值来标识是否正常处理。
建议如果有可能有多种错误,建议返回错误码来标识错误类型。
错误码
建议考虑使用EErrorId的枚举来表示错误码。
强制避免在逻辑中直接写死int32的错误值,在C++中使用错误码,在蓝图或者Lua中使用FName。
建议可以使用UErrorIdLibrary中的函数从枚举/int32/FName之间转换。
建议判断一个int32是否是OK可以使用UErrorLibrary::IsErrorIdOK。
强制判断一个int32是否是指定枚举需要将枚举转为int32判断。
强制避免int32转为枚举来判断,有可能出现不是一个合法的枚举而产生非预期结果。
函数
输入和输出
建议出于性能和安全性考虑,需要尽可能能按引用返回值,如果担心成员变量被修改可以使用const标识。
建议输入参数放在所有输出参数之前。
建议对于通过传入引用参数来获取输出的情况,需要注意在必要时对传入参数做初始化,防止函数执行提前结束而造成的输出与预期不一致。
| |
编写简短函数
建议如果函数特别长,应思考在不影响程序结构的前提下对其进行分割
说明编译器优化一方面是消除常用的子表达式。而函数越大,编译器进行辨识的工作量就越大。从而导致编译时间大大增长。
常量正确性
- 建议不要直接进行值传递,避免一次内存拷贝,传递const引用参数
| |
- 建议若方法不修改对象,将函数标记为常量函数
- 常量成员函数承诺不会修改类的成员变量,提高代码的安全性,防止意外的数据修改和潜在错误。
- 代码逻辑更清晰,一眼就能看出这是个只读函数
- 给编译器优化空间
- 避免调用者无法标记为常量函数
| |
- 若循环不修改容器,则在容器上使用常量迭代
| |
函数重载
- 若要使用函数重载, 则必须能让读者一看调用点就胸有成竹, 而不用花心思猜测调用的重载函数到底是哪一种. 这一规则也适用于构造函数
| |
- 如果函数单靠不同的参数类型而重载 (这意味着参数数量不变), 读者就得十分熟悉 C++ 五花八门的匹配规则, 以了解匹配过程具体到底如何。另外,如果派生类只重载了某个函数的部分变体,继承语义就容易令人困惑。
缺省参数
- 说明缺省参数实际上是函数重载语义的另一种实现方式
- 强制对于子类继承的虚函数,不允许使用缺省参数
- 虚函数是动态绑定,而缺省参数值是静态绑定。即虚函数是运行时确定类型,而缺省参数值是编译时就确定的。
- 如果重新定义的话,会使得程序使用基类虚函数的默认参数,这显然并不是你想要的结果。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66#include <stdlib.h> #include <stdio.h> #include <iostream> class Shape { public: enum Color { Red, Green, Blue, }; virtual void draw(Color color = Red) = 0; static std::string ColorToString(Color c) { switch (c) { case Red: return "Red"; case Green: return "Green"; case Blue: return "Blue"; default: return ""; } } }; class Rectangle : public Shape { public: virtual void draw(Color color = Green) override { std::cout << "Rectangle : " << ColorToString(color) << std::endl; } }; class Circle : public Shape { public: virtual void draw(Color color) override { std::cout << "Circle : " << ColorToString(color) << std::endl; } }; int main() { Rectangle R; Circle C; R.draw(); //输出:Rectangle : Green //C.draw(); //报错,静态绑定无法继承默认参数 Shape* pr = &R; Shape* pc = &C; pr->draw(Shape::Blue); //输出:Rectangle : Blue pc->draw(Shape::Blue); //输出:Circle : Blue // 静态绑定,使用基类的默认参数,完成派生类的动作 pr->draw(); //输出:Rectangle : Red pc->draw(); //输出:Circle : Red return 0; }
Lambda表达式
- 强制不要使用默认捕获(=, &),所有变量捕获都显式写出来
- 主要针对异步执行的Lambda表达式,当执行时有可能捕获的变量已经失效
- 同步的lambda表达式一般没有什么副作用,所以可以使用,例如:Foreach之类的
- 强制异步回调Lambda表达式应该捕获局部变量
- 强制Lambda不要捕获UObject裸指针
- 如果再等待异步回调执行之前,捕获的UObject裸指针已被GC且在原来的内存上又分配了新的UObject
- 如果此时在回调中对该过期了的UObject进行写入,则相当于在一个未知的内存上写入,可能把新的UObject写坏了。
- 可以使用TWeakObjectPtr来判断执行时的有效性
| |
配置
- 强制考虑热更配置的需求,谨慎对配置进行缓存,优先考虑以
RowName去查表。
回调
- 推荐谨慎使用回调,尤其需要注意是否存在跨帧逻辑与递归逻辑。
- 推荐谨慎缓存Delegate,有可能会使得闭包异常。
- 比如在Delegate的回调中,修改了这个Delegate,那么捕获的变量会存在异常。
标准库
推荐UE有自己的一套模板库,大部分情况下应该使用UE提供的版本。
但有一些例外,可以参考官方文档:标准库的使用
排版与格式
命名相关
- 强制命名(如类型或变量)中的每个单词需大写首字母,单词间通常无下划线。例如:
Health和UPrimitiveComponent,而非lastMouseCoordinates或delta_coordinates。 - 强制类型名前缀需使用额外的大写字母,用于区分其和变量命名。例如:
FSkin为类型名,而Skin则是FSkin的实例。 - 强制模板类的前缀为T。
- 强制继承自
UObject的类前缀为U。 - 强制继承自
AActor的类前缀为A。 - 强制继承自
SWidget的类前缀为S。 - 强制接口的前缀为I。
- 强制枚举的前缀为E。
- 强制布尔变量必须以b为前缀(例如
bPendingDestruction或bHasFadedIn)。 - 强制其他多数类均以F为前缀,而部分子系统则以其他字母为前缀。
- 强制Typedefs应以任何与其类型相符的字母为前缀:若为结构体的Typedefs,则使用F;若为
UObject的Typedefs,则使用U,以此类推。- 推荐特别模板实例化的Typedef不再是模板,并应加上相应前缀,例如:
1typedef TArray<FMytype> FArrayOfMyTypes; - 推荐即使不暴露给蓝图,也可以遵循此命名规范以适应UE的规范。
- 强制类型和变量的命名为名词。
- 强制方法名是动词,以描述方法的效果或未被方法影响的返回值。
- 强制所有返回布尔的函数应发起true/false的询问,如
IsVisible()或ShouldClearBuffer()。 - 推荐若函数参数通过引用传递,同时该值会写入函数,建议以"Out"做为函数参数命名的前缀(非必需)。此操作将明确表明传入该参数的值将被函数替换。
- 推荐若In或Out参数同样为布尔,以b作为In/Out的前缀,如
bOutResult。 - 强制返回值的函数应描述返回的值.命名应说明函数将返回的值。此规则对布尔函数极为重要。请参考以下两个范例方法:
- 强制RPC函数应把Server、Client、Multicast放在函数名前。
| |
- 推荐对于缩写单词,推荐仍然使用首字母大写其余小写的格式
- 可以避免陷入什么是缩写什么不是的争议中
- PHP->Php
- 对于复杂变量名可以获得更清晰的展示
- 如HTTPSubsystem->HttpSubsystem
- UE内部变量两者都有,以仅大写首字母为主
- 可以避免陷入什么是缩写什么不是的争议中
命名范例:
| |
代码格式
推荐一行不超过120个字符
强制尽量不使用非ASCII编码,文件编码必须为UTF-8
推荐缩进使用制表位\t
推荐大括号格式必须一致。在Epic的传统做法中,大括号固定被放在新行。请遵循此格式。
强制固定在单语句块中使用大括号。例如:
| |
- 强制if-else语句中的所有执行块都应该使用大括号。
- 此举是为防止编辑时出错——未使用大括号时,可能会意外地将另一行加入if块中。多余行不受if表达式控制,会成为较差代码。
- 条件编译的项目导致if/else语句中断时,也会造成不良结果。因此务必使用大括号。
| |
switch语句
- switch需要带有default分支,如果不需要执行逻辑,应写break。
- 超过一行的逻辑需要加大括号,break放在大括号外面。
- 如果只有一行return逻辑,可以省略break语句。
- 其他情况下,应将逻辑放在大括号里,大括号外面加break。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21switch (condition) { case 1: ... // 落入 case 2: ... break; case 3: ... return; case 4: case 5: ... break; default: break; }
注释
规范
- 推荐多描述怎么用How、为什么这么写Why,不需要描述这个是什么What,代码实现已经能清晰描述(或需要努力往清晰去实现)是什么。
- 推荐无所谓英不英文,代码是项目组内的人看的,方便大家读懂就行
- 函数注释
- 推荐使用Doxygen注释格式,对于常用标记UE可以正确识别
- 大概功能描述
- 各个参数的描述
- 返回值的描述
| |
原则
- 强制编写含义清晰的代码:
| |
- 推荐编写有用的注释:
| |
- 推荐不要对低质量代码进行注释——重新编写这些代码:
| |
- 强制不要让代码与注释自相矛盾:
| |
TODO
推荐对那些临时的, 短期的解决方案, 或已经够好但仍不完美的代码使用 TODO 注释。
推荐标记一些未完成的或完成的不尽如人意的地方, 这样一搜索, 就知道还有哪些活要干, 日志都省了。
| |
其他相关
Sizeof
推荐尽可能的使用sizeof(varname)代替sizeof(type)
推荐假设varname的类型变了,sizeof(type)大概率会忘改造成bug
迭代器
提议前置自增效率更高,少了一次拷贝,但是大部分编译器可以正确优化
强制警惕迭代器失效的情况,一般是在循环中对容器进行增加或删除操作
- TArray
- 增加导致的内存重分配
- 删除元素
- 插入元素
- TArray
死循环
- 强制无符号整数错误使用导致的死循环
| |
- 说明容器的size()返回类型size_t是无符号整数
| |
- 强制警惕while循环,条件永远不满足
浮点数
- 强制判断浮点数是否相等时,不能直接用==判断,因为浮点数的精度问题,会导致判断错误
| |
std::vector<bool>
- 强制尽量不要在vector中存放bool类型,vector为了做优化,它的内部存放的其实不是bool。
- 当然对于UE来说大部分时候都应该优先考虑使用TArray
空指针
- 强制空指针尽量使用nullptr而非NULL
1 2 3 4 5 6 7 8 9 10 11void func(char*) { cout << "char*"; } void func(int) { cout << "int"; } int main() { func(NULL); // 编译失败 error: call of overloaded ‘func(NULL)’ is ambiguous func(nullptr); // char* return 0; }
数值溢出
- 强制在使用FMath::RandRange时,需要注意数值溢出的情况。
| |
内存
常见问题
推荐避免使用原生数组,尽量使用UE容器
- 原生数组无法检查越界
强制空指针未判空
- 空指针导致的崩溃占大多数
- 防御性编程
强制警惕缓冲区溢出
- 谨慎使用内存操作相关的函数sprintf,printf,memcpy,memset,strcat,strcpy等等
- 上述函数拷贝内存的时候没有检查是否越界,如果发生越界会把其他不相干的内存写坏,导致无法预料的bug
1 2 3 4 5 6void foo() { char szName[10]; int n = 0; strcpy(szName, "asdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdf"); }强制禁止返回局部引用,内存会被销毁
1 2 3 4 5int* foo() { int xxx = 0; return &xxx; }强制防止指针未初始化
- UObject成员带UPROPERTY,会被自动初始化为nullptr
- 普通指针未初始化,指向任意地址
- if判断已经无效,向野指针内写入数据
- 注意:USTRUCT的指针成员就算带UPROPERTY也不会初始化位nullptr,需要手动初始化
强制不正确的类型转换
- 应该优先使用UE提供的Cast来进行UObject的类型转换
- 其次使用C++的类型转换,避免C类型转换
- static_cast,不使用C类型转换
- const_cast,去掉const限定符
- reinterpret_cast,指针类型和整形或其他指针之间进行不安全的相互转换
- 当程序执行不正确的类型转换时,就会发生不可预测的结果。这可能会导致程序崩溃写坏内存或产生不正确的输出。
1 2 3 4 5 6 7int main() { int num = 10; double *ptr = (double*)# // 不正确的类型转换 *ptr = 5.0; // 写越界 std::cout << *ptr << std::endl; return 0; }强制Memcpy, memset用于非POD类型,把vtable的指针给破坏掉了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26class A { public: A() {} virtual void test() { printf("%d", nn); } protected: int nn; }; class B : public A { public: B() {} virtual void test() override { A::test(); printf("B::test, %d", nn); } }; B b; memset(&b, 0, sizeof(b));强制函数内自销毁,导致后续代码访问或写入成员变量时写坏内存
- 某些状态会停掉行为树,而此函数在行为树Task节点中直接执行(Task内停掉行为树)
- 行为树Task节点尝试删掉行为树Owner的Actor(危险操作、应延迟一帧执行)
- 有自销毁操作时需小心,考虑清楚是否需要延迟一帧
强制循环操作没有取引用,导致发生了结构体拷贝
强制Lambda表达式自销毁,内部引用的捕获变量被销毁后使用引发的内存问题
1 2 3 4 5 6 7 8 9 10 11 12 13 14TWeakObjectPtr<UWorldManager> thisObject(this); Stars::Message::PvpTrigger triggerData = trigger; GetWorld()->GetTimerManager().SetTimer(PvPingTimerHandle, FTimerDelegate::CreateWeakLambda(this, [thisObject, triggerData](){ if (!thisObject.IsValid()) return; // Do something // 这里Lambda表达式被销毁,导致thisObject被销毁 thisObject->GetWorld()->GetTimerManager().ClearTimer(thisObject->PvPingTimerHandle); // 这里再使用thisObject就已经是不安全的了 } }), 1, true);
智能指针
- 强制在管理需要自己new的对象时优先使用智能指针
- 一个裸指针使用多个智能指针包裹
- 前一个智能指针销毁后导致内存被销毁,后面的再写入导致内存写坏
1 2 3 4 5int* rawPtr = new int(5); TSharedPtr<int> p1 = MakeShareable(rawPtr); TSharedPtr<int> p2 = MakeShareable(rawPtr); p1 = nullptr; *p2 = 7;- TSharedPtr
- 引用计数
- TUniquePtr
- 只能转移所有权
- 注意循环引用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30class B; class A { public: ~A() { std::cout << "~A" << std::endl; } TSharedPtr<B> b; }; class B { public: ~B() { std::cout << "~B" << std::endl; } TSharedPtr<A> a; }; int main() { TSharedPtr<A> a = MakeShared<A>(); TSharedPtr<B> b = MakeShared<B>(); a->b = b; b->a = a; a = nullptr; b = nullptr; return 0; }- TWeakPtr
- 解决循环引用
- 一个裸指针使用多个智能指针包裹
容器
强制警惕循环内删除与添加
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24void erase(std::vector<int> &vec, int a) { for (auto iter = vec.begin(); iter != vec.end();) { // 这个正确 if (*iter == a) { iter = vec.erase(iter); } else { ++iter; } } for (auto iter = vec.begin(); iter != vec.end(); ++iter) { // error if (*iter == a) { vec.erase(iter); // error } } }- 拷贝到一个新容器中再做循环删除
- 使用迭代器
强制注意内存重分配问题
- TArray
- TMap也存在此问题
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20struct TestStruct { int a = 0; bool b = false; float c = 0.0f; FString s; ~TestStruct() { UE_LOG(LogTemp, Log, TEXT("~TestStruct")); } }; TMap<int, TestStruct> structMap; TestStruct* val1 = &structMap.Emplace(1); TestStruct* val2 = &structMap.Emplace(2); for (int i=0;i<10000;i++) { structMap.Emplace(i); }
UE的内存管理
GC机制
UE使用标记清除算法实现GC,当对象的引用链不可达时,GC会回收该对象所占用的内存。在进行GC之前,UE会从根集出发对所有UObject对象进行标记,标记活动对象,并清除所有未被标记的非活动对象。
GC的触发通常在特定时机,如关卡切换、游戏循环周期等,以避免对游戏性能造成过多影响。
强制开发者在UE中需要注意正确管理对象引用,避免出现不必要的长期引用,以确保GC可以正确回收不再使用的内存资源,从而提高游戏性能和稳定性。
弱指针
- 强制通过UObject的弱指针判断其是否在生命周期内
- 通过GUObjectArray中的索引来指向具体的UObject
- 自增量校验指向的合法性
| |
强制警惕非主线程中使用UObject或者其弱指针的问题
- UObject是在主线程中被GC掉,所以非主线程中无法预料其生命周期
- 所以在非主线程中尽量不访问UObject或其弱指针,在外部主线程中将非主线程需要处理的数据全部备份好,通过Lambda表达式传递进去,任务处理完回到主线程后再访问UObject的弱指针
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43TWeakObjectPtr<UBuildingSearchProxy> WeakThis(this); uint8 Id = WeakThis->Id; FString SearchInput = WeakThis->SearchInput; TArray<FName> List = WeakThis->List; AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask, [WeakThis, Id, SearchInput, List] { if (!WeakThis.IsValid()) return; TSet<FName> SearchResult; for (const FName& Part : List) { if (Id != IdCounter) break; #if WITH_EDITOR if (Part.ToString().Contains(SearchInput)) { SearchResult.Add(Part); continue; } #endif const FItemTableRow* pRow = UDataManager::FindRow<FItemTableRow>(Part); if (pRow != nullptr && pRow->DisplayName.ToString().Contains(SearchInput)) { SearchResult.Add(Part); } } AsyncTask(ENamedThreads::GameThread, [WeakThis, SearchResult] { if (!WeakThis.IsValid()) return; if (WeakThis->Id == IdCounter) { WeakThis->PostSearchResult.Broadcast(SearchResult); } WeakThis->SetReadyToDestroy(); }); });推荐规避成员变量保存UE的接口裸指针
- 判断其生命周期,应保存UObject的弱指针TWeakObjectPtr,在使用的时候再将其Cast为接口指针
- 思路上,如果自己需要标识引用关系那么使用UPROPERTY配合TObjectPtr,否则使用TWeakObjectPtr。
1 2 3 4 5TWeakObjectPtr<UObject> CauserAttributePtr; //伤害造成者 TWeakObjectPtr<UObject> TakerAttributePtr; //伤害承受者 IAttributeInterface* GetCauserAttributeInterface() const; IAttributeInterface* GetTakerAttributeInterface() const;
网络
Proto
强制禁止直接缓存消息的Proto结构,内存有可能会被重新分配或回收。
强制如果需要保存调用项目封装的接口,或者使用Proto上的CopyFrom接口。
强制考虑使用FByteArray或者FByteCompressedArray来快捷保存通用数据。
日志
日志规范
- 强制各功能模块的重要事件,尽量记录(只要不是频繁重复发生)
- 推荐无用信息或过于频繁打印的就不要输出了(可通过Verbosity等级控制)
- 推荐新增加的模块,尽量记录足够还原现场的日志
- 强制涉及玩家数值的行为,如物品得失、经验获得、货币流通、装备升级等,尽量记录
- 强制如果数量太大可以考虑归并后打印
- 强制避免使用三字符序列
- 在C和C++中,三字符序列是一种特殊的字符序列,用两个问号(??)开头,并由另一个字符结尾。例如,"??=“代表#,”??/“代表\,等等。这些三字符序列是为了在早期的编码系统中处理一些没有对应字符的情况而设计的,但在现代的编码系统中,这些三字符序列已经不再使用。
- 当编译器在转换三字符序列时遇到问题时,就会报错:“trigraph converted to ’ ’ character”。这意味着编译器无法正确地将三字符序列转换为预期的字符,通常是因为在转换时出现了错误或者遇到了无法识别的三字符序列。
| |
引擎
UObject
- 强制构造函数中加载资源,应判断是否运行时动态创建的,CDO一般来说没必要进行加载
- 强制可复制变量有一些规定
- 不能是Public
- 不能是蓝图可写
- 为了强推PushModel,可复制变量的写必须在成员函数中执行,配合MARK_DIRTY宏
TMap
- 强制不要依赖TMap的顺序,尤其是涉及到与服务器同步的数据。
- 在很多语言中,Map的实现都是默认无序的
- 推荐谨慎对TMap进行遍历,注意遍历期间容器大小改变问题
- 详见容器-循环内删除与添加遍历:客户端编程规范
- 推荐谨慎对TMap的Value取引用,注意内存重分配问题
- 详见容器-内存重分配:客户端编程规范
- 强制永远不要使用Map[Key]的写法
- 查找使用Find函数代替
- 依赖Contains的拦截会造成多次查找,且判断没有原子性,容易被漏判
- UE的[]内部使用的是FindChecked的实现,当找不到时会尝试直接取无效值
蓝图
- 强制蓝图中局部变量不要搞一个大数组,很费(提出来作为一个数据表)
- 推荐避免使用大函数(一个函数包含太多蓝图节点),而是将大函数分割成多个小函数
- 使用Collapse将大函数变为小函数
- Pure函数
- 推荐简单函数声明为Pure,降低连线复杂度
- 强制避免使用Pure函数实现复杂功能逻辑,每个连接Puer节点的调用都会执行依次,影响性能。
- 条件判断
- 说明蓝图中的And/Or并不是短路运算,所有Pin都会执行,需要特别注意。
- 比如valid判断与调用实例函数放在一个And结点上,并不能实现拦截not valid的目的。
- 说明蓝图中的And/Or并不是短路运算,所有Pin都会执行,需要特别注意。
- 提示因为Pure函数每次都会执行,所以切记使用随机节点以及Pure里面包含随机的时候缓存临时变量;
数据表
- 强制配置表所有的引用都要使用软引用,c++代码和蓝图结构体都需要注意
- 如果是硬引用,当加载该资源的时候会强制加载所有硬依赖的资源,导致加载耗时过长,或者同步加载时卡顿
- CPP
- 使用TSoftClassPtr,TSoftObjectPtr,FSoftObjectPath等等
- 蓝图
- 使用SoftObjectReference和SoftClassReference
同步
- 强制尽量用PushModel
- 减少同步属性时比对是否发生变化所产生的消耗
- 推荐复杂数组推荐使用FastArraySerializer机制
- 说明Map无法在UE中使用Replication以及RPC
- 推荐FlushNetDormancy可以及时同步,有效避免一些网络休眠的问题,尤其在BaseActor或者其Component中
- 说明Unreal不提供CollisionPreset的变量同步,有碰撞修改请根据实际情况使用Replication或者RPC;
- 说明在蓝图中客户端重新设置变量或者服务器重新设置变量都会走RepNotify,只是当该值在服务器进行变更时会额外同步客户端一次;
Component
- 强制遍历各个Component的时候禁止调用自身的销毁,会影响数组的迭代器。
Lua
首先遵循UnLua实现的一些规定,然后参考Lua开发习惯与规范。
作者更为推荐放弃Lua,改为类型安全的编程语言,比如PuerTs。
CheckList
需要排查项目中是否存在以下问题:
裸指针成员变量未初始化为空
局部变量未初始化使用
数组越界
内存拷贝缓冲区溢出
UObject需要强引用的未用UPROPERTY包裹
UObject不需强引用的没有用弱指针
USTRUCT中包含UObject成员未用UPROPERTY
成员变量保存UE接口的裸指针
构造函数内调用虚函数
基类析构函数未声明成虚函数
指针未判空使用
异步回调捕获所有&,=
异步回调Lambda表达式传入裸指针
异步回调未判断Upvalue的生命周期
异步回调Lambda表达式引用捕获局部变量
Lambda表达式自销毁导致的Upvalue生命周期问题
使用异常机制
返回局部变量的引用或指针
手动管理内存的申请和回收未成对
不正确的类型转换
容器的循环内删除错误
智能指针循环引用
TArray取值未判断边界
TArray写入未判断边界
TArray前值指针或引用在触发内存重分配后野掉
TMap前值指针或引用再触发内存重分配后野掉
TMap取值前未判断是否存在
非主线程中对UObject的使用
使用类对象的静态存储周期变量
出现菱形继承
间接无限递归调用导致栈溢出
死循环
关键事件未加日志
该用PushModel未用
Memset, memcpy用于非POD结构
浮点数判断是否相等导致的问题
UE_LOG传入的参数类型不匹配导致崩溃
设置的Timer没有回收,导致Timer泄漏
除0问题
循环操作该用引用时没有用引用
尽量使用UE内置typedef的基础数据类型(如:int32, int64,不要使用long, size_t之类的)
可复制变量不能是Public,不能是蓝图可写
配置表所有的引用都要使用软引用
参考文献
Epic的虚幻引擎 C++ 代码规范 | 虚幻引擎 5.7 文档 | Epic Developer Community
Allar/ue5-style-guide: An attempt to make Unreal Engine 4 projects more consistent