UE4代码编写标准

发布时间 2023-05-31 16:11:48作者: tomato-haha

# 代码编写标准

此文为Coding Standard (opens new window)的原创翻译,本文内容版权归原文所有,仅供学习,如需转载望注本文地址,翻译不易,谢谢理解。

在Epic,我们有一些编码标准和约定。这个文档不打算讨论或进行改进,相反,它反映了Epic的当前编码标准。

编码约定对程序员很重要,因为:

  • 软件生命周期的80%成本都是用在维护上。
  • 很难有一个软件在它的全部的生命周期里只被它的作者维护。
  • 代码约定提高了软件的可读性,让工程师更快和更完全地理解新代码。
  • 如果我们决定将源码公开给mod社区的开发者,我们希望它能被容易地理解。
  • 交叉编译器的兼容性实际需要这些约定。

如下的代码标准是以C++为中心,但这些标准的精神在无论什么语言中都是一样的。一些部分针对特定的语言会有等价的规则或例外。

# 组织类

类应该按使用者的思想来组织而不是按作者的思想。因为很多使用者会使用类的公开接口,他们首先应该被声明然后才是私有部分的实现。

# 版权声明

任何被Epic提供分发的源文件 (.h, .cpp, .xaml, etc.)必须要在文件第一行包含版权声明。这些声明的格式需要跟下面展示的完全一致:

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

如果这行缺失或没有被很好地格式化,CIS将会生成错误然后失败。

# 命名约定

  • 在类型或变量的命名中,每个字的第一个字母需要大写,在字之间不用下划线,比如UPrimitiveComponent是对的,lastMouseCoordinates或delta_coordinates是错的。
  • 类型名字需要以额外的大写字母为前缀,以此来将他们与变量名字区分开。比如,FSkin是类型名,Skin是FSkin的实例。
    • 模板类以T开头。

    • 继承UObject的类以U开头。

    • 继承AActor的类以A开头。

    • 继承SWidget的类以S开头。

    • 抽象接口类以I开头。

    • 枚举以E开头。

    • 布尔变量以b开头。

    • 大部分其他类以F开头,一些子系统可能使用其他字母。

    • Typedefs应该以合适类型的前缀开头:如果是一个结构体的typedef以F开头,如果是UObject的typedef以U开头等等。

      • 一些特定模板的实例化的typedef不在是模板,应该根据相应类型的前缀开头,比如:
      typedef TArray<FMytype> FArrayOfMyTypes;
      
      1
    • C#中的前缀省略了。

    • UnrealHeaderTool在大部分情况下需要正确的前缀,所以提供这些前缀很重要。

  • 类型和变量名字得是名词。
  • 方法名是能描述方法效果的动词,或者是能描述方法返回值的动词。

变量,方法和类名需要是清楚,没有二义性,可描述的。这些变量的作用域越大,一个好的变量名就越重要,永远不要使用缩写!

所有变量应该逐一声明,这样就能提供每个变量含义的注释。而且JavaDocs风格的注释也需要这样。你可以在一个变量前使用多行或单行注释,空行可以用来将变量分组。

所有返回布尔变量的函数名应该问一个真或假的问题,比如IsVisible()或ShouldClearBuffer()。

一个过程(没有返回值的函数)的名字应该是(动词+对象)。一个例外是,如果这个过程中的对象是在对象里面,这个对象被上下文理解,它的名字应该避免以"Handle"或"Process"开头因为动词是模棱两可的。

尽管不是必要的,我们建议将函数的引用参数以"Out"开头,表明了函数希望通过这个引用参数返回值,这让我们更清楚的知道这个参数的值会被函数中的值替换。

如果输入输出的参数是一个布尔值,请在In/Out加上前缀b,比如bOutResult。

有返回值的函数的名字应该描述下这个返回值,这个名字让我们清楚地知道函数应该返回啥,对于返回布尔值的函数尤其重要,看下下面两个实例:

// what does true mean?
bool CheckTea(FTea Tea);

// name makes it clear true means tea is fresh
bool IsTeaFresh(FTea Tea);
1
2
3
4
5

# 例子

float TeaWeight;
int32 TeaCount;
bool bDoesTeaStink;
FName TeaName;
FString TeaFriendlyName;
UClass* TeaClass;
USoundCue* TeaSound;
UTexture* TeaTexture;
1
2
3
4
5
6
7
8

# 可移植的C++基础类型别名

  • bool for boolean values (NEVER assume the size of bool). BOOL will not compile.
  • TCHAR for a character (NEVER assume the size of TCHAR).
  • uint8 for unsigned bytes (1 byte).
  • int8 for signed bytes (1 byte).
  • uint16 for unsigned "shorts" (2 bytes).
  • int16 for signed "shorts" (2 bytes).
  • uint32 for unsigned ints (4 bytes).
  • int32 for signed ints (4 bytes).
  • uint64 for unsigned "quad words" (8 bytes).
  • int64 for signed "quad words" (8 bytes).
  • float for single precision floating point (4 bytes).
  • double for double precision floating point (8 bytes).
  • PTRINT for an integer that may hold a pointer (NEVER assume the size of PTRINT).

使用C++的int和unsigned int类型,它的字节数可能会跨平台,这在整数字节数不重要的代码里是可接受的。但是在序列化或被复制的格式里必须指定明确的字节数类型。

# 注释

注释就是沟通,而沟通是极其重要的。下面列出了关于注释必要时刻谨记的事情。(出自Kernighan & Pike The Practice of Programming)

# 参考

  • 要写代码含义清楚,本身就像文档一样的代码:

    // Bad:
    t = s + l - b;
    
    // Good:
    TotalLeaves = SmallLeaves + LargeLeaves - SmallAndLargeLeaves;
    
    1
    2
    3
    4
    5
  • 写有用的注释

    // Bad:
    // increment Leaves
    ++Leaves;
    
    // Good:
    // we know there is another tea leaf
    ++Leaves;
    
    1
    2
    3
    4
    5
    6
    7
  • 不要注释不行的代码,要重写

    // Bad:
    // total number of leaves is sum of
    // small and large leaves less the
    // number of leaves that are both
    t = s + l - b;
    
    // Good:
    TotalLeaves = SmallLeaves + LargeLeaves - SmallAndLargeLeaves;
    
    1
    2
    3
    4
    5
    6
    7
    8
  • 不要写自相矛盾的代码

    // Bad:
    // never increment Leaves!
    ++Leaves;
    
    // Good:
    // we know there is another tea leaf
    ++Leaves;
    
    1
    2
    3
    4
    5
    6
    7

# 常量修饰

使用const修饰的对象,如果想深究请看(const correctness)[https://isocpp.org/wiki/faq/const-correctness]。

常量可以是编译器指令,也可以看成是文档,所以所有的代码尽量应该是const-correct的。

它包括:

  • 如果参数并不打算被函数改变,尽量使用常量指针或引用来传递函数参数。
  • 如果方法不会改变对象,将它们设置成const。
  • 如果循环不打算改变容器请使用const迭代容器。

实例:

void SomeMutatingOperation(FThing& OutResult, const TArray<Int32>& InArray)
{
    // InArray will not be modified here, but OutResult probably will be
}

void FThing::SomeNonMutatingOperation() const
{
    // This code will not modify the FThing it is invoked on
}

TArray<FString> StringArray;
for (const FString& : StringArray)
{
    // The body of this loop will not modify StringArray
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

在按值传递参数的函数中,参数和本地变量也要尽量使用Const修饰,这告诉使用者在函数体内不会改变这些变量,会让它们更容易被理解,如果这样做的话确保声明和定义一定要匹配,因为这会影响JavaDoc进程。

例子:

void AddSomeThings(const int32 Count);

void AddSomeThings(const int32 Count)
{
    const int32 CountPlusOne = Count + 1;
    // Neither Count nor CountPlusOne can be changed during the body of the function
}
1
2
3
4
5
6
7

一个例外是按值传递的参数,最后会被移动到容器里(查看"Move semantics"),但是这种情况很少见。

例子:

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

要使一个指针自身变成常量请在声明中的类型后面添加const关键词,但是引用不能重新被赋值,所以不能用同样的方法让它变成常量。

例子:

// Const pointer to non-const object - pointer cannot be reassigned, but T can still be modified
T* const Ptr = ...;

// Illegal
T& const Ref = ...;
1
2
3
4
5

永远不要在返回值上使用const修饰,这会阻止复合类型的移动语义,对内置类型还会给编译警告。这个规则仅适用于返回自身的类型,不适用返回的指针或引用。

例子:

// 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();
1
2
3
4
5
6
7
8
9
10
11

# 格式样例

我们使用一个基于JavaDoc的系统来自动从代码中提取注释并用来构建文档,所以需要遵守一些注释的具体格式。

下面的例子示范了类,方法,和变量的注释格式。记住注释应该以代码为参数,代码记载了实现,而注释记载了实现意图。请更新你的注释在你改变了一段代码的使用意图后。

注意两种不同参数注释的格式都被支持,通过Steep和Sweeten两个方法展示。Steep方法使用的@param风格是传统的多行格式,但是对于简单函数来说,将参数和返回值一体化的写到函数描述性的注释中会更清楚,就像Sweeten例子中那样。一些特殊的注释标签像@see和@return应该仅在主要描述后面新起一行使用。

方法注释应该只在方法公开声明的地方包含一次。方法注释应该只包含和方法调用者或任何和调用者相关方法重写的信息。关于方法的实现和重写的细节,它们不和调用者相关,应该在方法实现的内部注释。

/** The interface for drinkable objects. */
class IDrinkable
{
public:
    /**
     * Called when a player drinks this object.
     * @param OutFocusMultiplier - Upon return, will contain a multiplier to apply to the drinker's focus.
     * @param OutThirstQuenchingFraction - Upon return, will contain the fraction of the drinker's thirst to quench (0-1).
     * @warning Only call this after the drink has been properly prepared.     
     */
    virtual void Drink(float& OutFocusMultiplier, float& OutThirstQuenchingFraction) = 0;
};

/** A single cup of tea. */
class FTea : public IDrinkable
{
public:
    /**
     * Calculate a delta-taste value for the tea given the volume and temperature of water used to steep.
     * @param VolumeOfWater - Amount of water used to brew in mL
     * @param TemperatureOfWater - Water temperature in Kelvins
     * @param OutNewPotency - Tea's potency after steeping starts, from 0.97 to 1.04
     * @return The change in intensity of the tea in tea taste units (TTU) per minute
     */
    float Steep(
        const float VolumeOfWater,
        const float TemperatureOfWater,
        float& OutNewPotency
    );

    /** Adds a sweetener to the tea, quantified by the grams of sucrose that would produce the same sweetness. */
    void Sweeten(const float EquivalentGramsOfSucrose);

    /** The value in yen of tea sold in Japan. */
    float GetPrice() const
    {
        return Price;
    }

    virtual void Drink(float& OutFocusMultiplier, float& OutThirstQuenchingFraction) override;

private:
    /** Price in Yen */
    float Price;

    /** Current level of sweet, in equivalent grams of sucrose */
    float Sweetness;
};

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

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

void FTea::Drink(float& OutFocusMultiplier, float& OutThirstQuenchingFraction)
{
    ...
}
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

一个类的注释都包括什么?

  • 对这个类要解决的问题的描述
  • 这个类为什么被创建?

多行方法注释的其他部分的含义?

  1. 函数用途:它记录了函数要解决的问题。正如上面所说的,注释里应该有代码实现意图,而代码记录了实现。
  2. 参数注释:每个参数注释应该包括:
    1. 计量单位
    2. 预期内的值
    3. "不可能"的值
    4. 状态和错误代码的含义
  3. 返回值注释:它记录了预期的返回值,就像输出变量被记录一样。为了防止冗余,如果函数唯一的用途是返回值,并且在函数用途中说明了,则不应该显式使用@return注释。
  4. 额外信息:@warning,@note,@see和@deprecated可被可选的添加到文档里来记录额外的相关信息,每个都应该在其他注释后面新起一行使用。

# C++的现代语法

Unreal引擎被设计成可大规模移植到多个C++编译器上,所以我们在使用这些特性时很小心,只有该特性被大部分编译器兼容的时候我们才支持。对有些很有用的特性我们用宏将他们封装然后广泛使用。但是,我们通常等某个C++最新标准被所有编译器都支持的时候才支持它。

我们使用了很多C++14的语言特性,这些特性似乎被现代很多的编译器支持,比如range-based-for,移动语义和lambda表达式内变量被捕获变量初始化。在一些情况中,我们将这些特性有条件地封装在预处理器中,比如容器中的右值引用。但是我们会决定完全不使用某项语言特性,直到我们有信心不会碰到不识别这些语法的新平台。

除非在下面被明确地指出(我们支持C++的新特性都会在下面列出来),你不应该使用针对某项编译器的语言特性除非他们被封装在预处理器宏中,或仅仅是被保守地使用。

# static_assert

当你需要一个编译时的断言时,可以使用这个关键字。

# Override和Final

这些关键字被强烈地建议使用。

# nullptr

nullptr在任何情况下应该替换所有C风格的NULL宏。

一个例外是在C++或CX的构建中(比如Xbox One),nullptr是实际被管理的null引用类型。大部分时候它和原生C++中的nullptr兼容除了在一些模板实例化的上下文中,所以为了兼容性你应该使用TYPE_OF_NULLPTR宏而不是更常见的decltype(nullptr)。

# Auto关键字

你不应该在C++中使用auto关键字,但是有一些例外情况,在下面列出来了。通常应该明确你在初始化什么类型,这意味类型对使用者应该明白可见。这条规则也适用于C#中的var关键字。

那什么时候可以使用auto呢?

  • 当你需要把一个lambda表达式绑定到一个变量上时,因为lambda类型是没法在代码里表示的。
  • For迭代器中的变量,但仅限于迭代器类型是冗长可读性差的场景。
  • 在模板代码中,在一些表达式类型不能被容易地辨别的高阶场景。

清晰可见的类型对于阅读代码的人来说很重要,使用Auto的话即便一些IDE有能力推断出类型,但这需要代码处于可编译的状态,而且它不支持使用merge/diff工具,当你在GitHub上单独阅读单个源文件时也很不方便。

如果你确定你在用一种可接受的方法使用auto时,要时刻记得正确地使用const,&,*就像你在使用类型名的时候一样,在使用auto时,这会推断出你想要的类型。

# Range-Based-For

它让代码更容易阅读和维护,当你移动使用旧的TMap迭代器代码时,注意以前旧的Key()和Value()函数,他们都是迭代器类型,现在只是键值对TPair中的键和值。

例子:

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
13

对于独立的迭代器,我们有很多替代方案。

实例:

// 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());
}
1
2
3
4
5
6
7
8
9
10
11
12

# Lambdas和匿名函数

Lambdas可被自由地使用,Lambdas最好实践一般长度上不会超过两行语句,尤其是作为一个更大表达式或语句中的一部分的时候,比如作为通用算法的断言时。

实例:

// 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
Algo::Sort(ArrayOfThings, [](const Thing& Lhs, const Thing& Rhs){ return Lhs.GetName() > Rhs.GetName(); });
1
2
3
4
5

请注意有状态的lambdas不能被赋值给我们经常使用的函数指针。

不重要的lambdas应该被等价的正规函数替换,不要因为添加注释而害怕将它们分割成多行。

应该使用显式捕获的变量而不是自动捕获的变量([&]和[=])。这对可读性,维护性和性能很重要,尤其在使用庞大lambdas和推迟执行的时候。它展示了作者的意图,所以在代码review的时候很容易看到其中的错误。不正确的捕获会产生消极的后果,随着时间推移更容易成为代码维护中的一个问题。

  • 如果lambda执行被推迟执行,按引用捕获和按值捕获指针(包括this指针)可能导致意外空引用。

    void Func()
    {
        int32 Value = GetSomeValue();
    
        // 诸多代码
    
        AsyncTask([&]()
        {
            // 此处数值无效
            for (int Index = 0; Index != Value; ++Index)
            {
                // ...
            }
        });
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
  • 按值捕获如果对非延迟执行的lambda进行不必要的复制,这可能会导致性能问题。

    void Func()
    {
        int32 ValueToFind = GetValueToFind();
    
        // 匿名函数意外被[=]捕捉(本应只捕捉ValueToFind),因此其会复制一个ArrayOfThings的副本
        FThing* Found = ArrayOfThings.FindByPredicate(
            [=](const FThing& Thing)
            {
                return Thing.Value == ValueToFind && Thing.Index < ArrayOfThings.Num();
            }
        );
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
  • 意外捕获的UObject指针对于GC是不可见的。

    void Func(AActor* MyActor)
    {
        //MyActor被会被GC回收
        AsyncTask([=]()
        {
            MyActor->DoSlowThing();
        });
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
  • 如果任何成员变量被引用,自动捕获会隐式捕获this变量,甚至[=]会让lambda按值拷贝任何东西。

    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"
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15

对于庞大的lambdas或者在返回另一个函数调用结果的时候应该使用显式的返回类型,这些情况以相同的方法使用auto关键字:

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

自动捕获和隐式返回类型对于不重要的,非延迟执行(non-deferred)的lambdas来说是可接受的,比如在分类方法调用中,如果不使用auto而使用显式指定的语义会让它显得冗余。

可能使用C++14中的初始化捕获特性:

TUniquePtr<FThing> ThingPtr = MakeUnique<FThing>();
AsyncTask([UniquePtr = MoveTemp(ThingPtr)]()
{
    // Use UniquePtr here
});
1
2
3
4
5

# 强枚举

枚举类应该替换所有旧式命名空间中的枚举,对于常规枚举和UENUMs都一样,比如:

// Old enum
UENUM()
namespace EThing
{
    enum Type
    {
        Thing1,
        Thing2
    };
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

同样UPROPERTY也有变通方法支持替换旧式TEnumAsByte<>,Enum的属性可以是任何大小,不仅仅是上面的字节:

// Old property
UPROPERTY()
TEnumAsByte<EThing::Type> MyProperty;

// New property
UPROPERTY()
EThing MyProperty;
1
2
3
4
5
6
7

但是,如果枚举要被暴露给蓝图,则需要基于uint8。

枚举类被用作标志位时,可以使用ENUM_CLASS_FLAGS(EnumType)宏来自动定义所有的比特位的操作符:

enum class EFlags
{
    None = 0x00,
    Flag1 = 0x01,
    Flag2 = 0x02,
    Flag3 = 0x04
};

ENUM_CLASS_FLAGS(EFlags)
1
2
3
4
5
6
7
8
9

一个例外是在真正的上下文中使用标志位-这是语言的限制。所有的标志位枚举应该有一个被设置为0的None枚举器在比较时用。

// Old
if (Flags & EFlags::Flag1)

// New
if ((Flags & EFlags::Flag1) != EFlags::None)
1
2
3
4
5

# 移动语义

所有主要的容器类型-TArray,TMap,TSet,FString-有很多移动构造器和移动语义操作符,它们在按值传参或返回值时经常被自动地使用,当然它们也可被MoveTemp(UE4中等价于C++的std::move一个函数)显式地调用。

在表现方式上按值返回容器或Strings可能会更好,没有临时拷贝对象的花销。关于按值传递和MoveTemp的规则还在建立的过程中,但是在代码库一些优化的地方已经有应用案例了。

# 默认成员初始化器

默认成员的初始化器可以在类自身内部定义类的默认部分:

UCLASS()
class UTeaOptions : public UObject
{
    GENERATED_BODY()

public:
    UPROPERTY()
    int32 MaximumNumberOfCupsPerDay = 10;

    UPROPERTY()
    float CupWidth = 11.5f;

    UPROPERTY()
    FString TeaType = TEXT("Earl Grey");

    UPROPERTY()
    EDrinkingStyle DrinkingStyle = EDrinkingStyle::PinkyExtended;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

像上面这样编码有如下好处:

  • 没必要在多个构造器中重复复制初始化器。
  • 不用去修复声明顺序和定义顺序。
  • 成员类型,标志属性和默认值都在一个地方,这可读性和维护性有很大的帮助。

但是,也有一些不好的方面:

  • 默认值的任何改变都需要所有依赖文件的重建。
  • 头文件不能在引擎的补丁版本中改变,所以这个风格会限制几种可能的修复。
  • 一些类型不能以这种方式初始化,比如基类,UObject的子类,forward-declared类型的指针,从构造器参数导出的值,需要多步初始化的成员。
  • 将一些初始化器放在头部,然后其余的放在.cpp文件中,这样会降低可读性和维护性。

你可以尽你所能判断啥时候用它们,但最重要的规则是,默认成员初始化器在游戏代码中要比在引擎代码中更有意义,最好对默认值使用配置文件。

# 第三方代码

当你想把自己代码变成一个我们在引擎中使用的库时,确保用//@UE4注释来标记你的改变,来解释为什么你会做出这些改变,这会更有助于这些改变合并到该库的新版本中,另外让许可证在你改变的部分中很容易地被找到。

任何包括在引擎里的第三方代码应该被容易搜寻的注释标记,比如:

// @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
1
2
3
4
5
6
7
8

# 代码格式

# 花括号

花括号之争始终是难缠的。Epic对花括号的标准是将它放到新的一行,请坚持这个原则。

在单个语句的语句块里也要常用花括号,比如:

if (bThing)
{
    return;
}
1
2
3
4

# If-Else

在if-else的语句中的每个执行块应该都要用花括号括起来,这样可以阻止编辑错误,如果没有使用花括号,一些人可能会不经意把其他行写到if代码块中,额外行不会被if表达式控制,还有可能导致条件编译的项目破坏if/else语句块。所以要常使用花括号。

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

多个if语句应该让每个else if都和它所对应的if有相同的缩进,这会让结构对读者来说更清晰。

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"));
}
1
2
3
4
5
6
7
8
9
10
11
12

# Tabs和缩进

在缩进你的代码时应遵循下面的标准:

  • 按执行块的逻辑(比如else和自己对应的if要用相同的缩进)来缩进代码。
  • 在行的开头要使用tabs而不是空格,将你的tab大小设置成4个字符,但是有些时候空格还是有必要的,它让代码对齐而不用关心tab里面空格的数量,比如你正对齐非tab字符后面的代码。
  • 如果你使用C#写代码,请也是用tab而不是空格,因为程序员可能经常在C#和C++之间切换,关于tab最好使用一致的设置,Visual Studio默认针对C#文件使用空格,所以在Unreal Engine的代码中工作时你要记着改变这个设置。

# Switch语句

Switch中的空case(和下面case内容一样的case)或者包含一个break,或者包含一个进入下个case的注释,不应该完全空。其他控制代码流程的命令(return,continue...)也应如此。

一定要有一个只包括一个break的默认case,这样可以防止有人在默认case下面还添加新的case。

switch (condition)
{
    case 1:
        ...
        // falls through

    case 2:
        ...
        break;

    case 3:
        ...
        return;

    case 4:
    case 5:
        ...
        break;

    default:
        break;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# 命名空间

你可以使用命名空间来合适地组织你的类,函数和变量。如果你使用他们,请遵循下面的规则:

  • Unreal代码不是被包裹在全局命名空间里的。尤其在使用或包含第三方代码的时候要注意,小心不要和全局范围内的冲突。
  • 使用声明:
    • 不要在全局范围内使用using声明,甚至在.cpp文件中,它可能会导致我们的"unity"构建系统出问题。
    • 在另一个命名空间或函数体内是可以使用using声明的。
    • 如果你在一个命名空间中使用using声明,这将导致该命名空间在该编译单元中的其他地方也会存在。当然如果你在其他地方也会引用该命名空间,也是没问题的。
    • 如果你遵循上面的规则你只能在头文件中安全地使用using声明。
  • 注意forward-declared类型需要在他们各自的命名空间中声明,如果你不这样做,会得到链接错误。
  • 如果在一个命名空间中声明了很多类和类型,在全局范围的类中使用这些类型是很困难的,比如当出现在类声明中时,函数特征标需要使用显式的命名空间。
  • 你可以使用声明来将命名空间中的某一具体变量引入到你的作用域中,比如Foo::FBar,但是我们并不经常在Unreal代码中这样做。
  • UnrealHeaderTool不支持命名空间,所以在定义UCLASSes, USTRUCTs等时不能使用它。

# 物理依赖

  • 文件名不应带有前缀,比如Scene.cpp而不是UScene.cpp。这让你更容易地使用一些工具(比如Workspace Whiz,Visual Assist),在解决方案中通过减少文件名的字母个数来快速找到所要的文件。

  • 所有的头文件应该使用#pragma once来防止多次被包含。注意我们用的所有编译器都支持该指令。

    #pragma once
    //<file contents>
    
    1
    2
  • 通常,尽量减少物理上的耦合。

  • 如果能用forward-declarations替代头文件那么尽量使用它。

  • 要尽量包含没有.h后缀的头文件,比如不要包含Core.h而是包含Core。

  • 尽量直接包含你所需的头文件。

  • 不要依赖于被另一个包含的头文件间接包含的头文件。

  • 不要依赖于其他头文件统一管理的头文件,要直接包含任何你想要的东西。

  • 模块分私有和公开源文件目录,任何被其他模块需要的定义必须是在公开目录下的头文件中,其他的应该是在私有文件目录中,注意在就得Unreal模块中,一些目录仍被称作"Src"和"Inc",但是在这些目录还是要用同样的方法分成公开和私有的,而不是打算将头文件与源文件分离开。

  • 不要为预编译头文件的生成而担心如何组织头文件,UnrealBuildTool会替你做好这些。

  • 将大的函数分成逻辑独立的子函数,编译器优化的一方面是消除相同的子表达式,函数越大,编译器就用在识别他们上的工作就越多,这将导致冗长的编译时间。

  • 不要使用太多内联函数,因为即便不使用它们的文件也会被强制重新构建。内联函数应该只用来不重要的访问器,有资料显示这样做是有好处的。

  • FORCEINLINE的使用要更保守,所有调用函数会被替换为相应的代码块和本地变量,这将同样导致大函数的构建时间问题。

# 封装

用protected关键字进行封装,除非他们是类的公开/保护接口的一部分,否则类成员应该总被声明为私有的,这个自己判断下。要记住,如果没有存取器,在不改变现有工程代码情况下,很难重构。

如果有些字段只被派生类使用,可以给他们提供保护存储器。

如果你的类并没有设计成要被继承,请使用final关键字声明。

# 一般格式问题

  • 将依赖路径最小化,当一段代码依赖于一个变量等于某个值时,试着在使用变量前设置变量值,在代码块的上方初始化变量时,不要让变量初始化和代码块之间隔很多空行或代码,让其他人在这些地方意外改变这些变量的值时意识不到变量和后面代码块之间的依赖,让变量和代码块紧邻让别人清楚为什么变量被初始化以及它在哪里使用。

  • 将方法尽可能分隔成多个子方法,当某个人在看一个很大的图片然后深究其中有意思的细节要比着眼于细节然后在此基础上构建很大的图片简单。同样的,理解一个按顺序调用命名良好的子程序的方法,要比理解包含同样功能代码的方法简单。

  • 在函数声明或调用的方法,不要在函数名和参数前的左括号之间添加空格。

  • 要解决编译器警告所提出的问题,编译器警告意味着有些地方不对,去修补这些地方,如果你实在无法解决它,请使用#pragma不让警告出现,但是这一般只作为最后的手段。

  • 在文件后面留一个空行,所有的.cpp和.h文件应该包含一个空行,这是为了和gcc兼容。

  • 调试代码在调试完后应该被删除,否则它和其他代码混在一起会让人阅读困难。

  • 通常针对字符串字面值使用TEXT()宏,没有该宏,构建FStrings的字符串字面值会在string转换的过程中出现预期之外的结果。

  • 不要在循环中重复冗余的操作,将通用的操作移出循环来避免冗余的计算,在有些时候要利用静态变量来避免函数调用间全局冗余的操作,比如从一个string字面值构建FName。

  • 要留心热加载,在迭代的时候要削减最小化依赖,不要使用内联或模板函数,因为它们在重载的时候可能会改变,在重载过程中对于保持不变的变量要使用静态变量。

  • 使用中间变量来简化复杂表达式。如果你有一个复杂表达式,将它分成多个子表达式会更有助于理解,将这些子表达式的结果赋值给中间变量,而这些中间变量的名字描述了子表达式在父表达式中的含义,比如:

    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();
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
  • 指针和引用声明时应该只有一个空格,也就是在*或&的右边,这有助于在文件中查找某个类型的指针或引用。

    要使用这种:
    FShaderType* Ptr
    
    不要使用这种:
    FShaderType *Ptr
    FShaderType * Ptr
    
    1
    2
    3
    4
    5
    6
  • 不要使用阴影变量,也就是变量和外层作用域中的变量名字一样,这会让变量的使用对于读者来说模糊不清,比如,在下面成员函数中有三个Count变量:

    class FSomeClass
    {
    public:
        void Func(const int32 Count)
        {
            for (int32 Count = 0; Count != 10; ++Count)
            {
                // Use Count
            }
        }
    
    private:
        int32 Count;
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
  • 不要在函数调用中使用匿名字面量,尽量让变量名体现出它的含义:

    // Old style
    Trigger(TEXT("Soldier"), 5, true);.
    
    // New style
    const FName ObjectName                = TEXT("Soldier");
    const float CooldownInSeconds         = 5;
    const bool bVulnerableDuringCooldown  = true;
    Trigger(ObjectName, CooldownInSeconds, bVulnerableDuringCooldown);
    
    1
    2
    3
    4
    5
    6
    7
    8

    对于其他读者来说这让代码的意图更明显,也避免了让读者一定要去查看函数声明才能理解它。

# API设计原则

  • 应该避免使用bool类型函数参数作为传入函数的标志变量。这些变量无法很好地表达其所代表的含义,而且在API要添加新功能时这些bool变量无法很好拓展。相反,更建议使用作用域枚举。

    // Old style
    FCup* MakeCupOfTea(FTea* Tea, bool bAddSugar = false, bool bAddMilk = false, bool bAddHoney = false, bool bAddLemon = false);
    FCup* Cup = MakeCupOfTea(Tea, false, true, true);
    
    // New style
    enum class ETeaFlags
    {
        None,
        Milk  = 0x01,
        Sugar = 0x02,
        Honey = 0x04,
        Lemon = 0x08
    };
    ENUM_CLASS_FLAGS(ETeaFlags)
    
    FCup* MakeCupOfTea(FTea* Tea, ETeaFlags Flags = ETeaFlags::None);
    FCup* Cup = MakeCupOfTea(Tea, ETeaFlags::Milk | ETeaFlags::Honey);
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17

    这个形式防止标志位传输过程中的意外,避免从指针和整数参数的意外转换,移除了重复冗余的默认值,更有效率。

    当bool值完全作为状态值传入像setter这样的函数时,是可接受的,比如void FWidget::SetEnabled(bool bEnabled)。

  • 避免使用过长的参数列表,如果一个函数需要很多参数时考虑使用一个专门的作用域数组:

    // Old style
    TUniquePtr<FCup[]> MakeTeaForParty(const FTeaFlags* TeaPreferences, uint32 NumCupsToMake, FKettle* Kettle, ETeaType TeaType = ETeaType::EnglishBreakfast, float BrewingTimeInSeconds = 120.0f);
    
    // New style
    struct FTeaPartyParams
    {
        const FTeaFlags* TeaPreferences       = nullptr;
        uint32           NumCupsToMake        = 0;
        FKettle*         Kettle               = nullptr;
        ETeaType         TeaType              = ETeaType::EnglishBreakfast;
        float            BrewingTimeInSeconds = 120.0f;
    };
    TUniquePtr<FCup[]> MakeTeaForParty(const FTeaPartyParams& Params);
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
  • 避免使用bool或FString来重载函数,这样可能会导致未预期的行为:

    void Func(const FString& String);
    void Func(bool bBool);
    
    Func(TEXT("String")); // Calls the bool overload!
    
    1
    2
    3
    4
  • 接口类(以"I"为前缀)应该总是抽象的,它不应该有成员变量。接口允许包含不是纯虚的方法,甚至可以包含非虚或静态方法,只要他们是内联的就可以。

  • 在声明重写方法时使用virtual和override关键字。在一个派生类中声明一个虚函数时,它会重写父类中的虚方法,你必须使用virtual和override关键字。比如:

    class A
    {
    public:
        virtual void F() {}
    };
    
    class B : public A
    {
    public:
        virtual void F() override;
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11

    由于最近override关键词的增加,还有很多现存的代码不符合这个规则,override关键字应该在和合适的时候被添加到里面。

# 针对平台的代码

针对某平台的代码应该总是抽象的,他们应该在平台相关的目录中的源文件里,比如:

Source/Runtime/Core/Private/[PLATFORM]/[PLATFORM]Memory.cpp
1

一般来说,你应该避免PLATFORM_[平台]的使用,比如PLATFORM_XBOXONE,你应该在一个命名为[平台]的目录中编码。

通过添加静态函数来拓展硬件抽象层,比如在FPlatformMisc中:

FORCEINLINE static int32 GetMaxPathLength()
{
    return 128;
}
1
2
3
4

不同平台稍后可以重写这个函数,或者返回一个和平台相关的常量值,或者是平台相关的API来决定这个结果。

FORCEINLINE函数和定义的函数在性能上是一样的。

在定义十分有必要的地方,创建新的#defines来描述适用某个平台的特定属性,比如PLATFORM_USE_PTHREADS。在Platform.h中设置默认值,然后在平台相关的Platform.h文件中为任何其他平台重写。

比如,在Platform.h我们有:

#ifndef PLATFORM_USE_PTHREADS 
    #define PLATFORM_USE_PTHREADS 1
#endif
1
2
3

在Windows/WindowsPlatform.h中是:

#define PLATFORM_USE_PTHREADS 0
1

跨平台的代码然后就可以直接使用而不用知道具体平台:

#if PLATFORM_USE_PTHREADS 
    #include "HAL/PThreadRunnableThread.h"
#endif
1
2
3

这样做的原因是,将引擎的平台相关细节集中起来让这些细节被完全包含在平台相关的源文件中,这样做更容易维护引擎的跨多平台特性,更易于移植到新的平台上而不用针对每个平台使用一套代码。

将平台相关的代码放在平台相关的文件夹里也是NDA平台要求的,比如PS4, XboxOne and Nintendo Switch。

保证代码编译和运行而不管[平台]子目录是否是现在正用的很重要。换句话说,跨平台代码应该从来都不应该依赖平台相关的代码。