UE4编码规范

Posted on   |     Views

编码规范的重要性

  • 一套软件 80% 的生命周期都是维护
  • 在软件的整个生命周期中,几乎不可能一直是软件的原始作者来对其进行维护
  • 编码规范可以改进软件的可读性,从而使得工程师可以快速并透彻地理解新的代码
  • 如果我们决定将源代码公布到 MOD 开发者社区,那么我们想让它通俗易懂
  • 其中大部分编码规范实际上是交叉编译器兼容性所要求的

类的组织结构

类的组织应该面向使用者而不是作者,因为大部分的使用者都要使用类的公共接口,因此在类中应该先声明公共部分,然后是私有部分

版权声明

Epic 发布时提供的任何源码文件 (.h, .cpp, .xaml 等等) 的第一行都必须是版权声明,并且声明必须完全符合以下格式

1
// Copyright 1998-2017 Epic Games, Inc. All Rights Reserved.

如果没有这行声明或者格式不正确,那么 CIS(持续集成系统)将会产生错误并导致集成失败。

命名规范

  • 名字中每个单词的首字母(比如类型或变量)要大写,并且单词间不要使用下划线。比如,HealthUPrimitiveComponent 是符合规范的,而 lastMouseCoordinatesdelta_coordinates 是不符合规范的。
  • 类型名称有一个额外的大写字母前缀,以便将类型名称和变量名称区分开来。比如,FSkin 是类型名称,而 SkinFSkin 的一个实例

    • 模板类以 T 为前缀
    • 继承 UObject 的类以 U 为前缀
    • 继承 AAtor 的类以 A 为前缀
    • 继承 SWidget 的类以 S 为前缀
    • 抽象接口类以 I 为前缀
    • 枚举类以 E 为前缀
    • 布尔变量必须以 b 为前缀(比如 bPendingDestruction,或 bHasFadedIn
    • 大部分其他类都以 F 为前缀, 但某些子系统使用其他字母
    • typedef 应该由根据源类型来添加前缀:F 则说明是 structtypedefU 则是 UObjecttypedef,以此类推

      • 对于一个模板的特定化实例来做的 typedef 则不再是模板,应该根据实例化类型来添加前缀。比如:

        1
        typedef TArray<FMyType> FArrayOfMyTypes;
    • C# 中忽略前缀

    • UnrealHeaderTool 在大部分情况下都要求使用正确的前缀,因此这些规则很重要
    • 类型和变量名称必须是名词
    • 方法(或函数,或过程)的名称是动词,动词可以描述该方法的作用,或者对于没有作用的方法可以用返回值来描述方法名称

变量、方法及类的名称应该清晰、明确且具有描述性。名称的作用域越大,取一个符合标准的具有描述性的名称的重要性便越强。避免过度缩写。

所有变量都应该一次仅声明一个,以便可以提供有关这个变量的含义的注释。同时,这也符合 JavaDocs 风格的要求。可以在变量前面使用多行或单行注释,可以留一个空行来给变量分类。

所有返回布尔值的函数都应该询问返回值是真还是假这个问题,比如 IsVisible()ShouldClearBuffer()

过程(没有返回值的函数)命名时应该使用一个强动词后面加一个目标对象。有一个例外,如果方法的目标对象就是调用该方法的对象自己,那么从上下文中就能确定这个对象。命名时要避免使用 HandleProcess 开头,这些动词表达的意思模糊不清。

如果一个参数是通过引用传入函数且该函数要修改此参数,那么推荐使用 Out 作为函数参数名称的前缀,但这不是强制要求。这样便显而易见地表示出传入到该参数中的值会被该函数所修改。

如果一个输入或输出参数还是一个布尔类型,那么仍然需要在 In/Out 前缀前添加 b 前缀,例如: bOutResult

对于有返回值的函数需要描述返回值,函数名称要清楚地表达出要返回的是什么值。这对于布尔函数尤为重要。对比以下两个示例方法

1
2
bool CheckTea(FTea Tea) {...} // what does true mean?
bool IsTeaFresh(FTea Tea) {...} // name makes it clear true means tea is fresh

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/** 茶重量(单位:千克)*/
float TeaWeight;

/** 茶叶数量 */
int32 TeaCount;

/** true 表示茶叶很香 */
bool bDoesTeaStink;

/** 茶叶的非人类可读 FName */
FName TeaName;

/** 茶叶的人类可读名称 */
FString TeaFriendlyName;

/** 要使用的是茶叶的哪一个类 */
UClass* TeaClass;

/** 倒茶的声音 */
USoundCue* TeaSound;

/** 茶叶的图片 */
UTexture* TeaTexture;

跨平台的 C++ 基础类型别名

  • bool 代表布尔值 (永远不要假设布尔值的大小)。BOOL 会导致编译失败
  • TCHAR 代表字符型 (永远不要假设 TCHAR 的大小)
  • uint8 代表无符号字节 (占1个字节)
  • int8 代表有符号的字节 (占1个字节)
  • uint16 代表无符号”短整型” (占2 个字节)
  • int16 代表有符号”短整型” (占2 个字节)
  • uint32 代表无符号整型 (占4字节)
  • int32 代表带符号整型 (占4字节)
  • uint64 代表无符号”四字” (8个字节)
  • int64 代表有符号”四字” (8个字节)
  • float 代表单精度浮点型 (占4个字节)
  • double 代表双精度浮点型 (占8个字节)
  • PTRINT 代表可以存放一个指针的整型 (永远不要假设 PTRINT 的大小)

在类型大小无关的场合使用 C++ 整型类型(大小会随着平台而改变)是可以的,但是在类型大小必须要明确的场合,一定要使用上面的类型。最简单方法,无论在什么地方,都使用上面列出的类型,这样就不会出错。

注释

注释是一种信息交流,而这种信息交流至关重要。关于注释,需要注意以下几点 (节选自 Kernighan & Pike 的 编程实践):

  • 编写具备自我注释的代码
1
2
3
4
5
// Bad:
t = s + l - b;

// Good:
TotalLeaves = SmallLeaves + LargeLeaves - SmallAndLargeLeaves;
  • 编写有用的注释
1
2
3
4
5
6
7
// Bad:
// 自增 Leaves
++Leaves;

// Good:
// 产生了另一片树叶
++Leaves;
  • 不要给可读性不好的代码添加注释 - 应该重写这段代码
1
2
3
4
5
6
7
8
// Bad:
// 茶叶的总量是
// 大茶叶和小茶叶的总量之和减去
// 既包含大茶叶又包含小茶叶的茶叶量
t = s + l - b;

// Good:
TotalLeaves = SmallLeaves + LargeLeaves - SmallAndLargeLeaves;
  • 注释不要和代码相冲突
1
2
3
4
5
6
7
// 不好:
// 永远不要让iLeaves进行自加运算!
++Leaves;

// 好:
// 产生了另一片树叶
++Leaves;

正确使用 const

const 既是说明文档(const is documentation),也会影响编译器,因此所有代码都要正确使用 const

如果函数不会修改传入参数,那么参数应该使用常指针或者常引用。如果函数不会修改对象内容,那么应该标记为 const。如果不修改容器内容,那么应该使用常量迭代器。例如

1
2
3
4
5
6
7
8
9
10
11
12
void SomeMutatingOperation(FThing& OutResult, const TArray<int32>& InArray); // 不会修改 InArray,OutResult可能会被修改

void FThing::SomeNonMutatingOperation() const
{
// 不会修改调用此函数的FThing对象
}

TArray<FString> StringArray;
for (const FString& : StringArray)
{
// 不会修改 StringArray 容器内容
}

函数的按值传递参数和局部变量也可以使用 const,这样明确表示这些对象不会被修改。如果这样做,要保证函数的声明和实现一致,因为这个也会影响 JavaDoc,例如

1
2
3
4
5
6
7
8
void AddSomeThings(const int32 Count);

void AddSomeThings(const int32 Count)
{

const int32 CountPlusOne = Count + 1;

// 在函数体内,Count和CountPlusOne都不会被修改
}

这里有个例外,就是当按值传递的参数会立刻被移动到一个容器中(参见 Move semantics),这种情况很少见,如

1
2
3
4
void FBlah::SetMemberArray(TArray<FString> InNewArray)
{
MemberArray = MoveTemp(InNewArray);
}

在指针类型后面加 const 表示该指针本身是常量(而不是表示指向常量内容),引用对象初始化后就不能被修改,因此引用不能这么做,例如:

1
2
3
4
5
// Const pointer to non-const object - pointer cannot be resassigned, but T can still be modified
T* const Ptr = ...;

// Illegal
T& const Ref = ...;

不要用 const 修饰返回类型,对于复杂对象来说这是一个 move semantics 的用法,对于内置类型会导致编译警告。这条规则仅适用于返回对象本身,不适用于返回引用或者指针的目标,例如

1
2
3
4
5
6
7
8
9
10
11
// Bad - returning a const array
const TArray<FString> GetSomeArray();

// Fine - returning a reference to a const array
const TArray<FString>& GetSomeArray();

// Fine - returning a pointer to a const array
const TArray<FString>* GetSomeArray();

// Bad - returning a const pointer to a const array
const TArray<FString>* const GetSomeArray();

注释示例

虚幻4使用基于 JavaDoc 来自动地从代码中提取注释并生成文档,所以这就要求遵循一些特定的注释格式规则。

以下示例展示了类、状态、方法和变量的注释格式。请记住注释应该是辅助加强代码的。代码是功能实现,注释表明了代码的目的。请确保在您更改一段代码意图时更新注释。

注意,正如下面 SteepSweeten 方法所呈现的,我们支持两种不同的参数注释风格。 Steep 所应用的 @param 风格是传统风格,但对于简单的函数来说,把参数介绍集成到函数的描述性注释中会使其看上去更加清晰,正如 Sweeten 中的注释所示。

UE4中应该仅包含一次方法注释,即在公开声明方法的地方提供该注释。方法注释应该仅包含和方法的函数调用相关的信息,包括可能和该函数调用相关的方法重载的任何信息。关于那些和函数调用无关的方法及其重载方法的实现细节,应该在方法实现内部进行注释

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
/** 可饮用对象的接口。*/
class IDrinkable
{
public:

/**
* 当玩家饮用该对象时调用。
* @param OutFocusMultiplier - 返回时,将包含乘数以应用于饮用者。
* @param OutThirstQuenchingFraction - 返回时,将包含饮用者的口渴值的分数值归0 (0-1)。
*/

virtual void Drink(float& OutFocusMultiplier, float& OutThirstQuenchingFraction) = 0;
};

/** 一杯茶 (茶) */
class FTea : public IDrinkable
{
public:

/**
* 根据浸泡茶的水的体积和水温来计算茶叶的变换口味值。
* @param VolumeOfWater - 以毫升计算的用于酿造的水量
* @param TemperatureOfWater - 以开氏度计算的水温
* @param OutNewPotency - 在浸泡开始后的茶叶效能,从0.97到1.04
* @return 会返回每分钟茶叶口味单位值 (TTU) 中茶叶浓度的改变
*/

float Steep(
float VolumeOfWater,
float TemperatureOfWater,
float& OutNewPotency
)
;


/** 对茶叶添加甜味剂,根据产生相同甜度的蔗糖的量来作为数量,以克计算。*/
void Sweeten(float EquivalentGramsOfSucrose);

/**在日本出售的茶叶的价格,以日元为单位。*/
float GetPrice() const
{

return Price;
}

// IDrinkable接口
virtual void Drink(float& OutFocusMultiplier, float& OutThirstQuenchingFraction);

private:

/** 以日元计算的茶叶的价值。*/
float Price;

/** 茶叶的甜味,根据产生相同甜度的蔗糖的量来作为数量,以克计算。*/
float Sweetness;
};

float FTea::Steep(float VolumeOfWater, float TemperatureOfWater, float& OutNewPotency)
{
...
}

void FTea::Sweeten(float EquivalentGramsOfSucrose)
{
...
}

void FTea::Drink(float& OutFocusMultiplier, float& OutThirstQuenchingFraction)
{
...
}

类注释中都包括哪些内容:

  • 这个类解决的问题的说明。以及为什么创建这个类

方法注释的所有组成部分的意思:

  • 首先是函数的作用,这记录了函数所解决的问题。正如上面所说的,注释表明意图 ,而代码记录实现
  • 然后是参数注释,每个参数注释应该包含了度量单位、期望值的范围、不符合要求的值、及 状态/错误 代码的意思
  • 然后是返回值注释,它说明了期望的返回值的信息,注释方法和给输出变量添加注释一样

C++ 11 和现代语法

虚幻引擎为了兼容大量不同的编译器,因此会谨慎使用新的语言特性。有时有些特性非常有用,那么会将其封装在宏里然后大量使用,但一般会等到所有编译器都支持才会开始使用。

我们正在使用现代编译器都进行了良好支持的特定的 C++ 11 语言特性,如范围 for 循环,移动(move semantics) 和 lambdas。在一些情况下,我们能够以预处理器条件句的形式对这些功能进行封装(诸如容器中的 rvalue 引用)。但是,对一些特定的语法功能,我们可能需要尽力避免,除非我们确信这个语法在新平台上可以被识别。

除非是下方特别指出的受支持的现代 C++ 编译器功能,否则应尽量不使用编译器的特定语法功能,除非它们被封装在预处理器宏或条件句中,并且被谨慎使用。

static_assert

当需要编译时断言时,可以使用该关键字

override and final

强烈推荐使用这两个关键字,可能在引擎的很多地方都没有使用,但是这些地方会被逐渐修复

nullptr

在所有使用 C 风格宏 NULL 的地方都应该换成 nullptr

其中的一个例外情况是 C++/CX 版本中的 nullptr (例如 Xbox One) 实际上是管理的无效引用类型。除了类型和一些模板实例关联外,它与原生 C++nullptr 基本兼容,因此出于兼容性的考量,应该使用 TYPE_OF_NULLPTR 宏而不是更为常见的 decltype(nullptr)

auto

除了以下几种情况外,应该不要在 C++ 代码中使用 auto 关键字。你应该总是明确知道你要初始化的对象的类型,这表示类型应该对阅读者总是可见,这个规则同样适用于 C# 中的 var 关键字

什么时候用 auto

  • 当你要将 lambda 绑定到一个变量,因为 lambda 类型是不可见的
  • 迭代器对象,但是只在迭代器类型非常详细以至于影响了可读性
  • 模版,当表达式类型难以被辨别时。这个是高级用法。

尽管很多 IDE 都可以提示变量类型,让变量类型对那些阅读代码的人可见仍然是非常重要的,因为 IDE 的提示依赖于代码处于可编译的状态,并且当使用 merge/diff 工具,或者使用文本编辑器阅读代码时,都会有不利影响

范围 for 循环

Range Based For 来使代码更加易懂并且更加易于维护。在迁移使用原有 TMap 迭代器的代码时,请注意原有的作为迭代器类型的函数 Key()Value() 现在成为底层键值 TPair 的简单键值域:

1
2
3
4
5
6
7
8
9
10
11
12
13
TMap<FString, int32> MyMap;

// Old style
for (auto It = MyMap.CreateIterator(); It; ++It)
{
UE_LOG(LogCategory, Log, TEXT("Key: %s, Value: %d"), It.Key(), *It.Value());
}

// New style
for (TPair<FString, int32>& Kvp : MyMap)
{
UE_LOG(LogCategory, Log, TEXT("Key: %s, Value: %d"), Kvp.Key, *Kvp.Value);
}

对于单独的迭代器类型,我们也有范围替换

1
2
3
4
5
6
7
8
9
10
11
12
// Old style
for (TFieldIterator<UProperty> PropertyIt(InStruct, EFieldIteratorFlags::IncludeSuper); PropertyIt; ++PropertyIt)
{
UProperty* Property = *PropertyIt;
UE_LOG(LogCategory, Log, TEXT("Property name: %s"), *Property->GetName());
}

// New style
for (UProperty* Property : TFieldRange<UProperty>(InStruct, EFieldIteratorFlags::IncludeSuper))
{
UE_LOG(LogCategory, Log, TEXT("Property name: %s"), *Property->GetName());
}
Lambda 和匿名函数

Lambda 现在可以在所有的编译器上使用,但是用法要谨慎。最好的Lambda 不仅仅只是一些长语句,特别应该用于更大的表达式或语句的一部分,例如作为通用算法中的谓词

1
2
3
4
5
// Find first Thing whose name contains the word "Hello"
Thing* HelloThing = ArrayOfThings.FindByPredicate([](const Thing& Th){ return Th.GetName().Contains(TEXT("Hello")); });

// Sort array in reverse order of name
AnotherArray.Sort([](const Thing& Lhs, const Thing& Rhs){ return Lhs.GetName() > Rhs.GetName(); });

请注意,有状态的 lambda 不能被分配给函数指针,而这在虚幻代码中会被大量使用

给重要的 lambda 和匿名函数添加注释时,应该要和普通函数的注释一样,多分几行写注释也没关系。

应该显示指定变量捕获规则 [&]和[=],而不是使用自动捕获,特别是那些大型 lambda 或者会延期执行的代码。错误地对一个变量使用捕获规则会导致不利的后果,而且时间越久越容易发生这种情况。

如果在捕获的变量的上下文之外执行 lambda,则指针的按值捕获和引用捕获可能导致意外的悬挂引用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void Func()
{

int32 Value = GetSomeValue();

// Lots of code

AsyncTask([&]()
{
// Value is invalid here
for (int Index = 0; Index != Value; ++Index)
{
// ...
}
});
}

由于不必要的拷贝操作,按值捕获可能会导致性能问题

1
2
3
4
5
6
7
8
9
10
11
12
void Func()
{

int32 ValueToFind = GetValueToFind();

// The lambda takes a copy of ArrayOfThings because it is accidentally captured by [=] when it was only meant to capture ValueToFind
FThing* Found = ArrayOfThings.FindByPredicate(
[=](const FThing& Thing)
{
return Thing.Value == ValueToFind && Thing.Index < ArrayOfThings.Num();
}
);
}

不小心捕获了 UObject 对象指针不会增加引用计数,换句话说可能会被 GC 回收掉

1
2
3
4
5
6
7
8
void Func(AActor* MyActor)
{

// 按值捕获的 MyActor 对象可能在执行lambda前就已经被回收了
AsyncTask([=]()
{
MyActor->DoSlowThing();
});
}

如果有任何成员变量被引用,那么即使使用 [=],自动捕获也会隐式地捕获 this 指针。 [=] gives the impression of the lambda having its own copy of the member when it doesn't

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void FStruct::Func()
{
int32 Local = 5;
Member = 5;

auto Lambda = [=]()
{
UE_LOG(LogTest, Log, TEXT("Local: %d, Member: %d"), Local, Member);
};

Local = 100;
Member = 100;

Lambda(); // Logs "Local: 5, Member: 100"
}

建议显式指定 lambda 的返回值类型,特别是大型 lambda 或者直接返回另一个函数的结果,理由同 auto 关键字一样

1
2
3
4
5
// Without the return type here, the return type is unclear
auto Lambda = []() -> FMyType
{
return SomeFunc();
};

自动捕获和隐式返回类型对于简单的 lambda 是可以接受的,例如在 Sort 调用中,语义是显而易见的,过渡明确反而会显得冗长。但是这些要得靠自己的判断了。

强类型枚举

应该始终使用枚举类来代替旧风格的命名空间枚举,包括常规枚举和 UENUM,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Old enum
UENUM()
namespace EThing
{
enum Type
{
Thing1,
Thing2
};
}

// New enum
UENUM()
enum class EThing : uint8
{
Thing1,
Thing2
};

同样也支持基于 uint8UPROPERTY,用来替换旧式的 TEnumAsByte<> 模版

1
2
3
4
5
6
7
// Old property
UPROPERTY()
TEnumAsByte<EThing::Type> MyProperty;

// New property
UPROPERTY()
EThing MyProperty;

用作标志的枚举类可以利用新的 ENUM_CLASS_FLAGS(EnumType 宏自动定义所有按位运算符

1
2
3
4
5
6
7
8
9
enum class EFlags
{
None = 0x00,
Flag1 = 0x01,
Flag2 = 0x02,
Flag3 = 0x04
};

ENUM_CLASS_FLAGS(EFlags)

一个例外是在 真值判断的上下文 中使用标志 - 这是语言的限制。 不过,可以在标志枚举中增加一个名为 None 的枚举值,将其设置为0用于比较:

1
2
3
4
5
// Old
if (Flags & EFlags::Flag1)

// New
if ((Flags & EFlags::Flag1) != EFlags::None)
移动语义

所有主要的容器类型 - TArray,TMap,TSet,FString - 都有移动构造函数和移动赋值运算符。 当通过值传递或返回这些类型时,它们通常会被自动调用,也可以通过使用 MoveTemp 显式调用,相当于 std::move

因为不需要拷贝一个临时对象,按值返回容器或字符串的性能提升很多。按值传递和 MoveTemp 的使用规则仍在确定中,但在代码中一些需要优化的场合已经可以见到它们的使用了。

第三方代码

当你修改引擎中用到的库时,请务必使用 //@UE4 来注释修改,以及说明为什么要进行修改。这样使得合并这些修改到新版本中更加容易,并且允许别人更加轻松地找到被修改的地方

所有针对引擎的第三方修改,都要用一个格式化的注释标记,以便能更加方便地被搜索到,例如

1
2
3
4
5
6
7
8
9
// @third party code - BEGIN PhysX
#include <PhysX.h>
// @third party code - END PhysX

// @third party code - BEGIN MSDN SetThreadName
// [http://msdn.microsoft.com/en-us/library/xcb2z8hs.aspx]
// Used to set the thread name in the debugger
...
//@third party code - END MSDN SetThreadName

代码格式

大括号

为大括号的风格争吵是很愚蠢的,Epic 一直另开一行写大括号,你照做就是了

单语句块也得使用大括号,例如

1
2
3
4
5
if (bThing)
{
// 就算if语句块中只有一行代码,也得加大括号
return;
}

if-else 语句中的每个执行块都应该是大括号。 这是为了防止编辑错误 - 当不使用大括号时,有人可能会无意间向 if 块添加另一行。 该行不会被 if 表达式控制,这就很糟糕了。 更糟的是当条件编译的时候还可能导致 if-else 中断时。 所以总是用大括号。

1
2
3
4
5
6
7
8
if (HaveUnrealLicense)
{
InsertYourGameHere();
}
else
{
CallMarkRein();
}

多重判断应该让每个 else if 的缩进和第一个 if 对齐,这样对读者来说结构更加清晰

1
2
3
4
5
6
7
8
9
10
11
12
if (TannicAcid < 10)
{
UE_LOG(LogCategory, Log, TEXT("Low Acid"));
}
else if (TannicAcid < 100)
{
UE_LOG(LogCategory, Log, TEXT("Medium Acid"));
}
else
{
UE_LOG(LogCategory, Log, TEXT("High Acid"));
}
制表符
  • 根据执行代码块来缩进代码
  • 使用制表符而不是空格来缩进代码。设置制表符为4个字符。但是,有时候也需要使用空格,以便无论一个制表符中包含多少个空格都可以保持代码对齐,例如在非制表符之后对齐代码时
  • C# 代码的时候,也使用制表符而不是空格。因为程序员经常在 C++C# 之间切换,所以最好保持设置一致。 Visual StudioC# 文件的默认设置为使用空格对齐,所以当修改虚幻代码时记得修改这个设置

Switch 语句

除了空 case(多个case具有相同的代码),switch case 语句应该明确标记一个 case 会运行到下一个 case。 每一个 case 都应该以 break return continue 或者 // falls through 结束

每一个 switch-case 语句块都应该包含一个 default case,并且以 break return continue 结束,防止有人在 default case 后新增一个 case

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
switch (condition)
{
case 1:
...
// falls through
case 2:
...
break;
case 3:
...
return;
case 4:
case 5:
...
break;
default:
break;
}

命名空间

你可以在适当的地方使用命名空间来组织你的类、函数、变量,只要遵循以下规则即可

  • 虚幻代码目前没有封装在全局命名空间中。所以需要小心以免在全局作用域内发生冲突,尤其是当引入第三方代码的时候
  • 不要把 using 声明放到全局作用域内,即使是放在 .cpp 文件中也不行(这会导致编译系统出现问题)
  • 可以把 using 声明放在另一个命名空间或函数体内
  • 注意如果把 using 声明放到一个命名空间内,那么它将继续存在于同一编译单元中其它地方出现的该命名空间中 。只要使其保持一致即可
  • 只有遵守上面的规则,才可以在头文件中安全地使用 using
  • 注意,进行前置声明的数据类型需要在其各自的命名空间中进行声明,否则将会出现链接错误
  • 如果在一个命名空间中声明了很多类/数据类型,那么在其他具有全局作用域的类中使用这些数据类型可能会很难。(比如,在类中声明函数时需要显式指定命名空间)。
  • 可以使用 using 来把命名空间中的特定变量在自己的作用域中进行别名设置(比如 using Foo::FBar), 但我们一般不会在虚幻引擎代码中进行这样的操作
  • UnrealHeaderTool 不支持命名空间,所以不要在定义 UCLASS USTRUCT 等这些数据结构时使用命名空间

物理依赖

  • 文件名不要加前缀,比如命名 Scene.cpp 而不是 UnScene.cpp,这样做通过减少用于消除文件的歧义性所需的字符数,使得在解决方案中使用像 Workspace WhizVisual AssistOpen File 这样的工具时更加方便
  • #pragma once 来防止头文件重复包含,现在所有的编译器都已经支持了这个预处理指令
  • 一般需要减少文件间的物理依赖
  • 尽量使用前向声明来代替#include包含
  • 包含头文件时要尽可能地包含具体文件,不要包含 Core.h 文件,而是包含 Core 中具有所需定义的特定头文件
  • 尽量包含直接需要的每个头文件,以便使得包含详细具体的头文件变得更加容易
  • 不要依赖于包含的另一个头文件中间接地包含的头文件
  • 不要通过另一个头文件来包含需要的内容,而是自己包含所需要的任何内容
  • Modules 代码分为私有和公有目录。所有可能会被其他模块使用的代码应该放在公有目录,其他的要放在私有目录。在旧的虚幻模块中,这些代码还是被命名为 SrcInc,但是这些其实和 Public Private意思一样而不是用来区分源文件和头文件的。
  • 不要把头文件设置为预编译头文件生成。 UnrealBuildTool 可以做的更好
  • 将大函数分解为逻辑相关的子函数。编译器的一个优化功能就是消除共同的子表达式,因此函数越大,编译器就需要做更多的工作来识别它们,导致编译时间大大增加
  • 小心使用内联函数,因为它们有可能会导致那些没有使用到它们的文件进行重编。内联函数应该仅仅用在简单的函数并且性能分析表明这么做有好处。
  • 更加谨慎地使用 FORCEINLINE,因为所有代码和局部变量都会在使用的地方展开,会导致和大函数一样的编译时长的问题

封装

使用保护关键字来保证封装性。类成员应该总是声明为 private 除非它们是 public/protected 接口的一部分。良好的封装依赖于自己良好的判断,注意,缺少访问控制会使之后在不破坏插件和现有项目的情况下进行重构变得异常困难。

如果一些成员仅仅会被派生类用到,那么应该用 protected 关键字

使用 final 关键字,如果你的类不想被派生

通用编码风格

  • 最小化依赖距离,当代码依赖于具有特定值的变量时,应该在恰好使用该变量之前设置该变量的值。在使用一个变量之前再声明并初始化它,不应该让变量的声明离第一次使用的地方过远,不然很可能会被其他人不小心在这中间的某个地方修改了。声明变量时应该立即初始化,防止使用一个未初始化的变量。
  • 如果文件中包含非 ASCII 字符,那么用 UTF8 编码来保存该文件
  • 运算符前后应该各有一个空格,例如 int32 TeaCount = 5 * 6;
  • 尽可能将方法分解为子方法。因为人更擅长由全局到具体而不是具体到全局。一个由一系列命名良好的子方法组成的函数比一个由所有子方法的具体实现函数组成的方法要更加好理解。
  • 在函数声明或者调用的地方,不要在函数名和后面的参数列表的括号直接加空格
  • 解决编译器警告。 编译器警告信息表示某些内容和其期望内容不符。如果确实无法解决这个问题,那么使用 #pragma 禁止该警告,这是最后一种补救方法
  • 在文件结尾留下一行空白行。 所有 .cpp.h 文件最后应该包括一行空白行,以便可以很好地同 gcc 编译器协同工作
  • 不要将 float 隐式转为 int32。这个操作很慢而且不是所有的编译器都支持。应该使用 appTrunc() 函数来执行转换,速度更快并且跨平台
  • 接口类(以 I 为前缀)应该总是抽象类并且不包含任何数据成员。接口类允许包含非纯虚成员函数,甚至是非虚函数和静态函数,只要它们是 inline 实现即可
  • 调试代码一般是有用的且经过改进的, 不要将其提交到源码控制工具中。 如果将调试代码和其他代码相混合,那么会导致其他代码非常难以理解。
  • 总是对字符常量使用 TEXT() 宏,否则从字符常量构造 FString 会导致不希望的转换过程
  • 避免在循环中重复执行相同的操作。将通用子表达式提到循环外面来避免冗余计算。用 static 来避免函数调用的全局冗余操作,例如,从一个字符常量构造 FName
  • 注意热加载,最小化依赖关系来减少遍历时间。不要对可能在重加载过程中改变的函数使用内联或者模版。只对那些不会在重加载过程中改变的数据使用 static
  • 使用中间变量来简化复杂表达式。如果你有一个复杂的表达式,将其分解为子表达式,然后将这些子表达式的结果声明为有意义的变量名并替换到父表达式中,会更加易读,比如
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 不好
if ((Blah->BlahP->WindowExists->Etc && Stuff) &&
!(bPlayerExists && bGameStarted && bPlayerStillHasPawn &&
IsTuesday())))
{
DoSomething();
}

// 好
const bool bIsLegalWindow = Blah->BlahP->WindowExists->Etc && Stuff;
const bool bIsPlayerDead = bPlayerExists && bGameStarted && bPlayerStillHasPawn && IsTuesday();
if (bIsLegalWindow && !bIsPlayerDead)
{
DoSomething();
}
  • 使用 virtualoverride 关键字来修饰一个重写的虚函数。当在派生类中重写一个虚函数时,必须同时使用 virtualoverride,例如
1
2
3
4
5
6
7
8
9
10
11
class A
{
public:
virtual void F() {}
};

class B : public A
{
public:
virtual void F() override;
};

注意虚幻中存在很多代码没有按照这个规则,但是这些代码会逐渐被修改掉

  • 指针和引用应该仅有一个空格,该空格位于指针/引用的右侧。 这样使得可以快速地在文件中查找某种特定类型的所有指针或引用。 请使用这种格式
1
2
3
4
5
6
// 好
FShaderType* Type

// 不好
FShaderType *Type
FShaderType * Type
  • 禁止遮蔽变量(Shadowed Variable),虽然 C++ 允许覆盖外部空间中的变量,但是这样会导致阅读代码时模糊不清,例如以下代码中包含了三个 Count 变量
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class FSomeClass
{
public:
void Func(const int32 Count)
{

// 这里函数参数Count覆盖了成员变量Count
for (int32 Count = 0; Count != 10; ++Count)
{
// 这里循环变量Count又覆盖了函数参数Count
// Use Count
}
}

private:
int32 Count; // 成员变量Count
};
  • 包含头文件时,应该尽量按照头文件字母顺序来排列,如
1
2
3
4
5
#include "GenericPlatform/GenericPlatformFile.h"
#include "HAL/ExceptionHandling.h"
#include "HAL/Filemanager.h"
#include "Misc/DateTime.h"
#include "Misc/OutputDeviceRedirector.h"

参考链接

UnrealEngine CodingStandard