客户端编程规范

文章字数:12200

整理一下总结的客户端向的编程规范,尽可能做到有理有据,后续可能还会不定期修订。

头文件

Include

  • 头文件
    • 通过宏防止重复包含
    • #pragma once,所用的所有编译器均支持
    • 包含时尽量细粒化。例如,勿包含Core.h,而在核心中包含需要定义的特定头文件。
    • 尽量直接包含所需的头文件,以便进行细粒化包含。
    • #include 中插入空行以分割相关头文件, C 库, C++ 库, 其他库的 .h 和本项目内的 .h 是个好习惯。
    • 尽可能在cpp中引用其他头文件,避免头文件中直接引用某一类。
      • 可以考虑优先使用声明类的形式来在头文件中使用另一类而在cpp中再真正include,可以有效梳理结构避免循环引用。
    • 尽可能先根据:cpp对应头文件、C++标准库文件、框架头文件、第三方头文件、项目通用头文件、具体功能头文件的顺序来引用文件,方便理清文件引用结构。
    • 引用头文件时尽可能避免使用相对位置,比如....\NetDef.h,不利于文件梳理以及理解文件结构。
    • 避免使用反斜线\来标记路径,在一些编译器会存在异常,应使用斜线/来标记路径。

前置声明

  • 所谓「前置声明」(forward declaration)是类、函数和模板的纯粹声明,没伴随着其定义
    • 前置声明能够节省编译时间,多余的 #include 会迫使编译器展开更多的文件,处理更多的输入。
    • 前置声明能够节省不必要的重新编译的时间。 #include 使代码因为头文件中无关的改动而被重新编译多次。
    • 尽可能的使用前置声明,而非头文件,在CPP中包含对应的头文件
    • 减少编译的时间

内联函数

  • 内联函数的合理使用可提高代码执行效率
  • 当函数只有10行甚至更少时才将其定义为内联函数
  • 谨慎对待析构函数,析构函数往往比其表面看起来要更长,因为有隐含的成员和基类析构函数被调用
  • 包含循环和switch语句的函数内联通常得不偿失
  • 声明了内联也不一定会被编译器内联,虚函数和递归函数不会被正常内联
  • 类内部的函数一般会自动内联。所以某函数一旦不需要内联,其定义就不要再放在头文件里,而是放到对应的 cpp 文件里。这样可以保持头文件的类相当精炼,也很好地贯彻了声明与定义分离的原则。

范例

 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
#pragma once

// 头文件区
#include "CoreMinimal.h"
#include "UObject/ObjectMacros.h"
#include "UObject/UObjectBaseUtility.h"
#include "UObject/Object.h"

#include "TestObject.generated.h"

// 前置声明区
class AActor;
class AController;
class UPrimitiveComponent;
struct FAttachedActorInfo;

// 结构体定义
USTRUCT()
struct FTestStruct
{
    GENERATED_BODY()
    
public:
    UPROPERTY()
    int TestValue = 0;
    
    ...
};

// 委托定义

// 类体定义
UCLASS(BlueprintType, Blueprintable)
class ATestObject : public AActor
{
    GENERATED_BODY()

public:
    ATestObject();
    
    ....
};

// 内联函数定义
...

作用域

命名空间

  • 命名空间将全局作用域细分为独立的, 具名的作用域, 可有效防止全局作用域的命名冲突。
  • 禁止使用using namespace xxx;
  • 禁止使用内联命名空间
    1
    2
    3
    4
    5
    
    namespace X {
        inline namespace Y {
            void foo();
        }
    }
    
    • X::Y::foo与X::foo是等价的
    • 内联命名空间主要用来保持跨版本的 ABI 兼容性。(一般用不到)
    • 在头文件中使用匿名空间违背了C++的唯一定义原则(One Definition Rule(ODR))

匿名命名空间和静态变量

  • 内部链接性
    • 意味着你在这个文件中声明的这些标识符都不能在另一个文件中被访问。即使两个文件声明了完全一样名字的标识符,它们所指向的实体实际上是完全不同的。
  • 在 .cpp 文件中定义一个不需要被外部引用的变量时,可以将它们放在匿名命名空间或声明为 static
  • 不需要被外部引用的变量
  • 不要在头文件中使用
1
2
3
4
5
6
7
xxx.cpp

namespace {
    int xxx = 0;
}

static int xxx = 0;

局部变量

  • 函数变量尽可能置于最小作用域,并在变量声明时进行初始化

    • 离第一次使用越近越好,方便阅读者更容易定位变量声明的位置
    • 局部变量在声明的同时进行显式值初始化,比起隐式初始化再赋值的两步过程要高效,同时也贯彻了计算机体系结构重要的概念「局部性(locality)」。
    • 对象:循环作用域外声明要高效的多
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    
    int 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, whilefor 语句的变量应当在这些语句中正常地声明,这样子这些变量的作用域就被限制在这些语句中了,举例而言:
    1
    
    while (const char* p = strchr(str, '/')) str = p + 1;
    

静态和全局变量

  • 原生数据类型POD(Plain Old Data)
    • Int, char, float等基本类型,POD类型的指针、数组、结构体
  • 静态生存周期变量
    • 全局变量,静态变量,静态类成员变量和函数静态变量
  • 禁止使用类的静态生存周期变量,因为在这种情况下构造和析构函数调用顺序是不确定的,他们会导致难以发现的bug。(对象A依赖对象B,但对象B早于A析构)
  • 不允许用函数返回值来初始化POD变量,除非该函数不涉及任何全局变量(比如 getenv()getpid()
  • 函数的作用域内的静态变量除外,毕竟他们的初始化顺序是有明确定义,只会在指令执行到它的声明那里才会发生
  • 同一个编译单元(cpp)内是明确的,静态初始化优先于动态初始化,初始化顺序按照声明顺序进行,销毁则逆序,不同编译单元之间的初始化和销毁顺序则属于未明确行为
  • quick_exit替代exit,前者不会执行任何析构,也不会执行atexit绑定的任何handlers
  • 如果确实需要一个class类型的静态或全局变量,可以使用单例模式

构造函数

  • 不要在构造函数中调用自身的虚函数,这类调用时不会重定向到子类的虚函数实现,即使现在没有被子类重载,将来也是隐患
    • C++中的虚函数允许在运行时动态绑定,即在运行时根据实际对象的类型来确定调用哪个虚函数实现。这样的动态绑定是通过虚函数表(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
26
27
28
29
30
31
32
33
34
35
ABaseMonster::ABaseMonster(const FObjectInitializer& ObjectInitializer)
    : BornLocation(FVector::ZeroVector)
    , bCanCrouch(true)
    , LocomotionComp(nullptr)
{
    AutoDestory = true;
    SpawnedByWaveSpawner = false;
    MonsterLevel = -1;
    SpawnType = ETypeofSpawn::None;

    NetCullDistanceSquared = FMath::Square(8000.f);

    AkForFoot = CreateDefaultSubobject<UAkComponent>(TEXT("AkForFoot"));
    AkForFoot->SetupAttachment(RootComponent);
    AkForFoot->PrimaryComponentTick.bAllowTickOnDedicatedServer = false;

    WeaponComponent = CreateDefaultSubobject<UStarsWeaponComponent>(TEXT("WeaponComponent"));
    
    SetCollisionEnabled(false);

    if (USkeletalMeshComponent* SKMesh = GetMesh())
    {
       //SKMesh->VisibilityBasedAnimTickOption = EVisibilityBasedAnimTickOption::OnlyTickPoseWhenRendered;
       //SKMesh->bEnableUpdateRateOptimizations = true;
    }

    if (AttributeManagerComponent)
    {
       AttributeManagerComponent->SetOwnerType(EAttributeOwnerType::Monster);
       SetTeamID(FGenericTeamId((uint8)EFactionType::MonsterFac));
    }
    RespawnRate = 1;

    bBeingCaptured = false;
}
  • 构造函数内不要调类似初始化的函数,因为初始化理论上会失败,如果执行失败返回了一个初始化失败的对象,后续怎么使用该对象是比较奇怪的

隐式类型变换

  • 不要定义隐式类型变换,对于转换运算符和单参数构造函数, 请使用 explicit 关键字
  • 隐式类型转换会隐藏类型不匹配的错误. 有时, 目的类型并不符合用户的期望, 甚至用户根本没有意识到发生了类型转换.
  • 隐式类型转换会让代码难以阅读, 尤其是在有函数重载的时候, 因为这时很难判断到底是哪个函数被调用.

可拷贝类型和可移动类型

  • 如果需要显式定义拷贝和移动,否则就把隐式产生的拷贝和移动函数禁掉
  • 如果需要就让你的类型可拷贝 / 可移动. 作为一个经验法则, 如果对于你的用户来说这个拷贝操作不是一眼就能看出来的, 那就不要把类型设置为可拷贝. 如果让类型可拷贝, 一定要同时给出拷贝构造函数和赋值操作的定义, 反之亦然. 如果让类型可移动, 同时移动操作的效率高于拷贝操作, 那么就把移动的两个操作 (移动构造函数和赋值操作) 也给出定义. 如果类型不可拷贝, 但是移动操作的正确性对用户显然可见, 那么把这个类型设置为只可移动并定义移动的两个操作
  • 如果定义了拷贝/移动操作, 则要保证这些操作的默认实现是正确的. 记得时刻检查默认操作的正确性, 并且在文档中说明类是可拷贝的且/或可移动的
1
2
3
4
5
6
7
8
class Foo {
public:
    Foo(Foo&& other) : field_(other.field) {}
    // 差, 只定义了移动构造函数, 而没有定义对应的赋值运算符.

private:
    Field field_;
};
  • 如果你的类不需要拷贝 / 移动操作, 请显式地通过在 public 域中使用 = delete 或其他手段禁用之.
1
2
3
// MyClass is neither copyable nor movable.
MyClass(const MyClass&) = delete;
MyClass& operator=(const MyClass&) = delete;

继承

  • 析构函数,虚函数
 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
class A
{
public:
    A()
    {
        p = new int(5);
    }
    ~A()
    {
        if (p)
        {
            delete p;
            p = nullptr;
        }
        
        printf("A::~A()");
    }
    
protected:
    int* p = nullptr;
};

class B : public A
{
public:
    B()
    {
        xx = new int(6);
    }
    
    ~B()
    {
        if (xx)
        {
            delete xx;
            xx = nullptr;
        }
        
        printf("B::~B()");
    }
    
protected:
    int* xx = nullptr;
};

B b;

多重继承

  • 最多只有一个基类是非抽象类,其他基类都是纯接口类

  • 菱形继承??通常意味着设计出了问题

接口

  • C++接口

  • 蓝图接口

    • BlueprintImplementableEvent

    • BlueprintNativeEvent

运算符重载

  • 运算符重载会混淆视听,让人误以为耗时的操作和操作内建类型一样轻巧

成员变量

一般不建议把成员变量标记为public,接口变更时更容易出现遗漏。

并且一个public的变量,对象自身对其更难以控制,不利于约束其读写行为。

考虑为其增加publicSetGet相关接口来实现对其的操作。

比如如下例子:

1
2
3
4
5
FString SaveKey = StarsSaveGame->OverrideSaveKey;
if (SaveKey.IsEmpty())
{
    SaveKey = Class->GetName();
}

如果调用者错误使用了FString& SaveKey = StarsSaveGame->OverrideSaveKey,那么就会错误修改成员变量。

如果改成函数的返回则没有这种隐患,会在编译器得到警告:

Binding r-value to l-value reference is non-standard Microsoft C++ extension

1
2
3
4
5
FString& SaveKey = StarsSaveGame->GetOverrideSaveKey();
if (SaveKey.IsEmpty())
{
    SaveKey = Class->GetName();
}

结构体

成员变量初始化

如下:用到的指针一定要进行初始化,在栈上创建结构体时,指针并不会被UE初始化,会造成野指针

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
USTRUCT(BlueprintType)
struct FDeathInfo
{
    GENERATED_BODY()
public:
    UPROPERTY(BlueprintReadOnly)
    EDeadCauserType DeadCauserType = EDeadCauserType::Common;
    /** 击杀者 */
    UPROPERTY(BlueprintReadOnly)
    AActor* Killer = nullptr;
    /** 被击杀者 */
    UPROPERTY(BlueprintReadOnly)
    AActor* Victim = nullptr;
};

枚举

如果枚举值需要暴露给蓝图,那么他需要在uint8范围内,否则在一些时候会有超出预期的问题。

如果想要大数值的枚举,又不想在蓝图中直接写死数值,可以考虑使用FName来与之对应,推荐使用表格维护。

枚举值类型

枚举值应使用enum class定义并标识所使用的存储结构,而不应该直接使用enum

枚举值类型理由

enum无法指定底层所使用的数据类型。同一enum的存储结构在不同的编译器下可能不同,有的时候会造成代码的泛用性降低。改为enum class后,可以指定具体的存储结构,使得开发者对其结构更为可控。

enum存在向整形的隐式转换。隐式转换在一些时候虽然方便了开发者调用,但在错误复制或编码时没有明确的报错,因此不建议使用隐式转换。改为enum class后,如果需要进行转换可以使用static_cast来显式转换。

enum无法定义重名。enum为了解决这个问题一般需要在枚举值命名中加入类型名,从而导致在一些情境下枚举值较长。而enum class没有这种问题,可以按照设计的枚举值进行命名。

枚举值命名

枚举值类型命名为以E开头,如EUnitClientType,即使不暴露给蓝图也可以遵循此规范。

因为枚举值常常直接拿出值用作比较,使用此规范可以区分出枚举值和普通的类型,也方便代码补全工具查找指定枚举。

枚举值顺序

除非明确地知道修改顺序不会产生影响,否则不要修改枚举的顺序,有可能造成逻辑或者数据不兼容。

若枚举值的顺序不应被修改(一般是在配置表或其他逻辑中有调用),考虑用=写出其枚举值。

若枚举值无固定值,则可以默认视为枚举值顺序改变不影响逻辑。

枚举值举例

1
2
3
4
5
6
enum class EGameType : uint8
{
    None,
    Local,
    Multiple,
};

异常

要注重异常处理,对于有可能报错的函数,可以考虑增加bool的返回值来标识是否正常处理。

如果有可能有多种错误,建议返回错误码来标识错误类型。

错误码

使用EErrorId的枚举来表示错误码。

避免在逻辑中直接写死int32的错误值,在C++中使用错误码,在蓝图或者Lua中使用FName

可以使用UErrorIdLibrary中的函数从枚举/int32/FName之间转换。

判断一个int32是否是OK可以使用UErrorLibrary::IsErrorIdOK

判断一个in32是否是指定枚举需要将枚举转为int32判断。

避免int32转为枚举来判断,有可能出现不是一个合法的枚举而产生非预期结果。

函数

输入和输出

  • 按值返回,否则按引用返回。避免返回指针,除非它可以为空。

  • 输入参数放在所有输出参数之前。

1
void TestFunction(int p1, bool p2, const FString& p3, bool& bOutResult1, FString& OutResult2);

编写简短函数

  • 如果函数特别长,应思考在不影响程序结构的前提下对其进行分割

  • 编译器优化一方面是消除常用的子表达式。而函数越大,编译器进行辨识的工作量就越大。从而导致编译时间大大增长。

常量正确性

  • 不要直接进行值传递,避免一次内存拷贝,传递const引用参数
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 差 - 返回常量数组
const TArray<FString> GetSomeArray();

// 优 - 返回常量数组的引用
const TArray<FString>& GetSomeArray();

// 差 - 传递数组
void TestSomething(TArray<FString> StringArray) {...}

// 优 - 传递常引用数组(避免一次内存拷贝)
void TestSomething(const TArray<FString>& StringArray) {...}

// 优 - 移动语义(避免一次内存拷贝)
void TestSomething(TArray<FString>&& StringArray) {...}
  • 若方法不修改对象,将函数标记为常量函数

    • 常量成员函数承诺不会修改类的成员变量,提高代码的安全性,防止意外的数据修改和潜在错误。

    • 代码逻辑更清晰,一眼就能看出这是个只读函数

    • 给编译器优化空间

    1
    2
    3
    4
    
    void FThing::SomeNonMutatingOperation() const
    {
        // 若此代码在FThing上被调用,其不会修改FThing
    }
    
    • 游戏中的例子:如果PrintAIData函数定位为const,则我们立马能确认函数体内的AIDataPack成员变量进行的都是只读操作,否则还要去判断是否有写入操作。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
void ABaseMonster::PrintAIData(FString& CompName, FString& TreeDescName,
    TArray<FString>& BBLine) const
{
    /*if (AIDataPack.ComponmentName.IsEmpty())
       return;*/

    CompName = AIDataPack.ComponmentName;
    TreeDescName = AIDataPack.TreeDescription;              
    AIDataPack.BlackboardDescription.ParseIntoArrayLines(BBLine, true);
    MARK_DIRTY??
}
  • 若循环不修改容器,则在容器上使用常量迭代
1
2
3
4
5
TArray<FString> StringArray;
for (const FString& Str : StringArray)
{
    // 此循环的主体不会修改StringArray
}

函数重载

  • 若要使用函数重载, 则必须能让读者一看调用点就胸有成竹, 而不用花心思猜测调用的重载函数到底是哪一种. 这一规则也适用于构造函数
1
2
3
4
5
6
class MyClass
{
public:
    void Analyze(const string &text);
    void Analyze(const char *text, size_t textlen);
};
  • 如果函数单靠不同的参数类型而重载 (这意味着参数数量不变), 读者就得十分熟悉 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表达式除外,例如:Foreach之类的
  • 异步回调Lambda表达式捕获局部变量

  • Lambda不要捕获UObject裸指针

    • 如果再等待异步回调执行之前,捕获的UObject裸指针已被GC且在原来的内存上又分配了新的UObject

    • 如果此时在回调中对该过期了的UObject进行写入,则相当于在一个未知的内存上写入,可能把新的UObject写坏了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 异步回调的情况
UStarsAssetsManager::Instance()->AsyncLoad(this, MeshInfo.MeshPath, this,
    [WeakThis, CallBack, CompPtr, MeshInfo, WithCollision](const FSoftObjectPath& ObjectPath)
{
    if (!CompPtr.IsValid() || !WeakThis.IsValid())
        return;
    ...
});

// 非异步的情况
SpecialElementNames.Empty();
ForeachRow<FSpecialElementTableRow>([&](const FName& RowName, const FSpecialElementTableRow& Row)
    {
       SpecialElementNames.Emplace(Row.Type, RowName);
    }
);

配置

  • 使用UDataManger来管理所有表格

  • 需要使用StarsGetDataTable等接口来读表,禁止使用UE自带的表格读取

    • 我们会对同结构表格在读取时进行合并处理
  • 考虑热更配置的需求,谨慎对配置进行缓存,优先考虑以RowName去查表。

回调

  • 谨慎使用回调,尤其需要注意是否存在跨帧逻辑与递归逻辑。

  • 谨慎缓存Delegate,有可能会使得闭包异常。

    • 比如在Delegate的回调中,修改了这个Delegate,那么捕获的变量会存在异常。

标准库

UE有自己的一套模板库,大部分情况下应该使用UE,下面是可以在UE中使用的标准库。

应在新代码中使用,在迁移旧代码时也应该使用。原子性(Atomic)将在所有受支持平台上高效推广。 TAtomic 仅实现了部分功能,Epic后续也不会继续进行维护和改善。
<type_traits>应在旧版UE特性(trait)和标准特性重叠的地方使用。
<initializer_list>用于支持初始化器(initializer)语法
正则表达式,UE没有自己的正则表达式方案
std::numeric_limits 可以完整使用。
这个头文件中只有浮点比较函数可以使用

排版与格式

命名相关

  • 命名(如类型或变量)中的每个单词需大写首字母,单词间通常无下划线。例如:HealthUPrimitiveComponent,而非 lastMouseCoordinatesdelta_coordinates

  • 类型名前缀需使用额外的大写字母,用于区分其和变量命名。例如:FSkin 为类型名,而 Skin 则是 FSkin 的实例。

  • 模板类的前缀为T。

  • 继承自 UObject 的类前缀为U。

  • 继承自 AActor 的类前缀为A。

  • 继承自 SWidget 的类前缀为S。

  • 接口的前缀为I。

  • 枚举的前缀为E。

  • 布尔变量必须以b为前缀(例如 bPendingDestructionbHasFadedIn)。

  • 其他多数类均以F为前缀,而部分子系统则以其他字母为前缀。

  • Typedefs应以任何与其类型相符的字母为前缀:若为结构体的Typedefs,则使用F;若为 Uobject 的Typedefs,则使用U,以此类推。

    • 特别模板实例化的Typedef不再是模板,并应加上相应前缀,例如:
    1
    
    typedef TArray<FMytype> FArrayOfMyTypes;
    
  • 即使不暴露给蓝图,也可以遵循此命名规范以适应UE的规范。

  • 类型和变量的命名为名词。

  • 方法名是动词,以描述方法的效果或未被方法影响的返回值。

  • 所有返回布尔的函数应发起true/false的询问,如IsVisible()或ShouldClearBuffer()

  • 若函数参数通过引用传递,同时该值会写入函数,建议以"Out"做为函数参数命名的前缀(非必需)。此操作将明确表明传入该参数的值将被函数替换。

  • 若In或Out参数同样为布尔,以b作为In/Out的前缀,如 bOutResult

  • 返回值的函数应描述返回的值.命名应说明函数将返回的值。此规则对布尔函数极为重要。请参考以下两个范例方法:

    1
    2
    3
    4
    5
    
    // True的意义是什么?
    bool CheckTea(FTea Tea);
    
    // 命名明确说明茶是新鲜的
    bool IsTeaFresh(FTea Tea);
    
  • 对于缩写单词,推荐仍然使用首字母大写其余小写的格式

    • 可以避免陷入什么是缩写什么不是的争议中

      • PHP->Php
    • 对于复杂变量名可以获得更清晰的展示

      • 如HTTPSubsystem->HttpSubsystem
    • UE内部变量两者都有,以仅大写首字母为主

  • 命名范例

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

代码格式

  • 一行不超过120个字符

  • 尽量不使用非ASCII编码,文件编码必须为UTF-8

  • 缩进使用制表位\t

  • 大括号格式必须一致。在Epic的传统做法中,大括号固定被放在新行。请遵循此格式。

  • 固定在单语句块中使用大括号。例如:

1
2
3
4
if (bThing)
{
    return;
}
  • if-else语句中的所有执行块都应该使用大括号。此举是为防止编辑时出错——未使用大括号时,可能会意外地将另一行加入if块中。多余行不受if表达式控制,会成为较差代码。条件编译的项目导致if/else语句中断时,也会造成不良结果。因此务必使用大括号。
1
2
3
4
5
6
7
8
if (bHaveUnrealLicense)
{
    InsertYourGameHere();
}
else
{
    CallMarkRein();
}
  • switch语句

    • 一定有Default条件,其中包含有break,以防在默认条件后添加新的条件。
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    
    switch (condition)
    {
        case 1:
            ...
            // 落入
        case 2:
            ...
            break;
    
        case 3:
            ...
            return;
    
        case 4:
        case 5:
            ...
            break;
    
        default:
            break;
    }
    

注释

规范

  • 无所谓英不英文,代码是项目组内的人看的,方便大家读懂就行

  • 函数注释

    • 大概功能描述

    • 各个参数的描述

    • 返回值的描述

    • 其他信息:可选择使用 @warning@note@see@deprecated 记载额外相关信息。此类注释应在其他注释后单列一行声明。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
/**
 * @brief 判断所给定义的道具是否是确定的
 *        比如如果是随机品质的定义,就不是确定的
 * @param ItemDef 所需检查的物品定义
 * @param CheckQualityNum 是否需要检查该道具是否仅有一个品质
 *                        如果检查并且发现该道具仅有一个品质
 *                        那么即使定义随机也会认为是唯一的
 * @return 返回值的意义
 */
UFUNCTION(BlueprintCallable, Category="Item")
static bool IsItemDefCertain(const FBagItemDef& ItemDef, bool CheckQualityNum);

原则

  • 编写含义清晰的代码:
1
2
3
4
5
// 错误示范:
t = s + l - b;

// 正确示范:
TotalLeaves = SmallLeaves + LargeLeaves - SmallAndLargeLeaves;
  • 编写有用的注释:
1
2
3
4
5
6
7
// 错误示范:
// increment Leaves
++Leaves;

// 正确示范:
// we know there is another tea leaf
++Leaves;
  • 不要对低质量代码进行注释——重新编写这些代码:
1
2
3
4
5
6
7
8
// 错误示范:
// total number of leaves is sum of
// small and large leaves less the
// number of leaves that are both
t = s + l - b;

// 正确示范:
TotalLeaves = SmallLeaves + LargeLeaves - SmallAndLargeLeaves;
  • 不要让代码与注释自相矛盾:
1
2
3
4
5
6
7
// 错误示范:
// never increment Leaves!
++Leaves;

// 正确示范:
// we know there is another tea leaf
++Leaves;

TODO

对那些临时的, 短期的解决方案, 或已经够好但仍不完美的代码使用 TODO 注释。

标记一些未完成的或完成的不尽如人意的地方, 这样一搜索, 就知道还有哪些活要干, 日志都省了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
while (PriorityQueue.Num() > 0)
{
    logger.CheckLoop((__FUNCTION__));
    //取出优先队列头
    auto& PopData = PriorityQueue.HeapTop();

    // 先判空一下,TODO: 后面查一下循环内销毁问题
    if (!PopData.SmeltComp.IsValid())
    {
       PriorityQueue.HeapPopDiscard(DataPredicate);
       continue;
    }
}

其他相关

Sizeof

  • 尽可能的使用sizeof(varname)代替sizeof(type)

  • 假设varname的类型变了,sizeof(type)大概率会忘改造成bug

迭代器

  • 前置自增效率更高,少了一次拷贝

  • 迭代器失效的情况

    • TArray

      • 增加导致的内存重分配

      • 删除元素

      • 插入元素

死循环

  • 无符号整数错误使用导致的死循环
1
for (unsigned int i = 10; i >= 0; --i) { ... }
  • 容器的size()返回类型size_t是无符号整数
1
2
3
4
5
6
std::vector<int> vec;
vec.push_back(1);
for (auto idx = vec.size(); idx >= 0; idx--)
{
    cout << "===== \n";
}
  • while循环,条件永远不满足

  • 死循环检查工具

    • FDeadLockDetector

      • 死循环时崩掉并打印主线程堆栈

浮点数

  • 判断浮点数是否相等
1
2
3
float f;
if (f == 0.2) {} // 错误用法
if (abs(f - 0.2) < 0.00001) {} // 正确用法

std::vector<bool>

  • 尽量不要在vector中存放bool类型,vector为了做优化,它的内部存放的其实不是bool。

空指针

  • 空指针尽量使用nullptr而非NULL

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

数值溢出

1
2
3
4
5
6
7
8
const int32 RetryToken = FMath::RandRange(0, INT_MAX); // 这里会随机出负数

##RandRange 实现
static FORCEINLINE int32 RandRange(int32 Min, int32 Max)
{
        const int32 Range = (Max - Min) + 1;        //INT_MAX的情况下,一定溢出
        return Min + RandHelper(Range);
}

内存

常见问题

  • 避免使用原生数组,尽量使用UE容器

    • 原生数组无法检查越界
  • 空指针未判空

    • 空指针导致的崩溃占大多数

    • 防御性编程

  • 缓冲区溢出

    • sprintf,printf,memcpy,memset,strcat,strcpy等等

    • 上述函数拷贝内存的时候没有检查是否越界,如果发生越界会把其他不相干的内存写坏,导致无法预料的bug

    1
    2
    3
    4
    5
    6
    
    void foo()
    {
        char szName[10];
        int n = 0;
        strcpy(szName, "asdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdf");
    }
    
  • 返回局部引用

    1
    2
    3
    4
    5
    
    int* foo()
    {
        int xxx = 0;
        return &xxx;
    }
    
  • 指针未初始化

    • UObject成员带UPROPERTY,会被自动初始化为nullptr

    • 普通指针未初始化,指向任意地址

      • if判断已经无效,向野指针内写入数据
    • 注意:USTRUCT的指针成员就算带UPROPERTY也不会初始化位null

  • 不正确的类型转换

    • 使用C++的类型转换,避免C类型转换

      • static_cast,不使用C类型转换

      • const_cast,去掉const限定符

      • reinterpret_cast,指针类型和整形或其他指针之间进行不安全的相互转换

    • 当程序执行不正确的类型转换时,就会发生不可预测的结果。这可能会导致程序崩溃写坏内存或产生不正确的输出。

    1
    2
    3
    4
    5
    6
    7
    
    int main() {
        int num = 10;
        double *ptr = (double*)&num; // 不正确的类型转换
        *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
    26
    
    class 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
    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
    
    TWeakObjectPtr<UWorldManager> thisObject(this);
    Stars::Message::PvpTrigger triggerData = trigger;
    GetWorld()->GetTimerManager().SetTimer(PvPingTimerHandle, FTimerDelegate::CreateWeakLambda(this, [thisObject, triggerData](){
        if (!thisObject.IsValid())
            return;
    
        if (thisObject->m_GuildManager==nullptr )
        {
            return;
        }
    
        int32 DefenEndTime = AStarsGameState::GetWorldDayZeroTime(thisObject.Get()) + triggerData.defendendtime();
        int32 worldTime = AStarsGameState::GetWorldTime(thisObject.Get());
    
        if (worldTime > DefenEndTime && thisObject->m_WorldInfo.PlanetPVPStatus != EPlanetPVPStatus::None && DefenEndTime > 86400)
        {
            //防守结束
            thisObject->m_WorldInfo.PlanetPVPStatus = EPlanetPVPStatus::None;
            thisObject->m_GuildManager->OnPlanetPVPStatusStart(EPlanetPVPStatus::None);
            thisObject->m_GuildManager->OnPvpBattleEnd();
            SetWorldInfomation(thisObject->m_WorldInfo);
        }
    
        if (AStarsGameState::GetWorldIntTime(thisObject.Get()) >= triggerData.enttime())
        {
            //完全结束
            thisObject->m_WorldInfo.PlanetPVPStatus=EPlanetPVPStatus::None;
            thisObject->m_WorldInfo.PVPStatusStartTime=0;
            thisObject->m_WorldInfo.PVPStatusEndTime=0;
            thisObject->m_WorldInfo.PVPDefenseStartTime=0;
            thisObject->m_WorldInfo.PVPDefenseEndTime=0;
            thisObject->m_WorldInfo.IsPlanetBattleStatues=false;
            thisObject->m_GuildManager->OnPlanetPVPStatusStart(EPlanetPVPStatus::None);
            thisObject->m_GuildManager->OnPVPBattleStateChanged(false);
            thisObject->m_GuildManager->OnPvpBattleEnd();
    
            // 这里Lambda表达式被销毁,导致thisObject被销毁
            thisObject->GetWorld()->GetTimerManager().ClearTimer(thisObject->PvPingTimerHandle);
    
            // 好在这里是读内存不是写,没有造成更坏的情况        
            SetWorldInfomation(thisObject->m_WorldInfo);
        }
    }), 1, true);
    

智能指针

  • 智能指针

    • 一个裸指针使用多个智能指针包裹

      • 前一个智能指针销毁后导致内存被销毁,后面的再写入导致内存写坏
    1
    2
    3
    4
    5
    
    int* 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
    30
    
    class 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
    24
    
    void 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
    20
    
    struct 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

    • 自增量校验指向的合法性

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
ATestActor* pActor = GetWorld()->SpawnActor<ATestActor>();

FTimerHandle handle;
GetWorld()->GetTimerManager().SetTimer(handle, [pActor]()
{
    // 完犊子了,野指针写入!!!
    pActor->Test();
    pActor->Destroy();
}, 2, false);

// 强制GC
pActor->Destroy();
CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS);

// 新的TestActor复用原有内存
ATestActor* pActor2 = GetWorld()->SpawnActor<ATestActor>();
  • 非主线程中使用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
    43
    
    TWeakObjectPtr<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的弱指针

    • 使用的时候再将其Cast为接口指针

    1
    2
    3
    4
    5
    
    TWeakObjectPtr<UObject> CauserAttributePtr; //伤害造成者
    TWeakObjectPtr<UObject> TakerAttributePtr; //伤害承受者
    
    IAttributeInterface* GetCauserAttributeInterface() const;
    IAttributeInterface* GetTakerAttributeInterface() const;
    

网络

Proto

禁止直接缓存消息的Proto结构,内存有可能会被重新分配或回收。

如果需要保存调用项目封装的接口,或者使用Proto上的CopyFrom接口。

考虑使用FByteArray或者FByteCompressedArray来快捷保存通用数据。

日志

现状:该有日志的地方没有,不该有的地方疯狂打,导致出bug时很难通过日志判断。

日志规范

  • 各功能模块的重要事件,尽量记录(只要不是频繁重复发生)

  • 无用信息或过于频繁打印的就不要输出了(可通过Verbosity等级控制)

  • 新增加的模块,尽量记录足够还原现场的日志

  • 涉及玩家数值的行为,如物品得失、经验获得、货币流通、装备升级等,尽量记录

  • 如果数量太大可以考虑归并后打印

  • 避免三字符序列

    • 在C和C++中,三字符序列是一种特殊的字符序列,用两个问号(??)开头,并由另一个字符结尾。例如,"??=“代表#,”??/“代表\,等等。这些三字符序列是为了在早期的编码系统中处理一些没有对应字符的情况而设计的,但在现代的编码系统中,这些三字符序列已经不再使用。

    • 当编译器在转换三字符序列时遇到问题时,就会报错:“trigraph converted to ’ ’ character”。这意味着编译器无法正确地将三字符序列转换为预期的字符,通常是因为在转换时出现了错误或者遇到了无法识别的三字符序列。

1
UE_LOG(LogAStar, Error, TEXT("Particle Type Shrink Error????!!"));

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,不能是蓝图可写

  • 配置表所有的引用都要使用软引用

参考文献

https://docs.unrealengine.com/4.27/zh-CN/ProductionPipelines/DevelopmentSetup/CodingStandard/

https://zh-google-styleguide.readthedocs.io/en/latest/google-cpp-styleguide/

https://www.zhihu.com/question/26901409/answer/1858690571

https://github.com/Icassell/UE4-Style-Guide-1

该内容采用 CC BY-NC-SA 4.0 许可协议。

如果对您有帮助或存在意见建议,欢迎在下方评论交流。

加载中...