UE4 C++开发中指针与引用的核心区别、应用场景与最佳实践
1. 项目概述:为什么UE4 C++开发者必须吃透&和*?
如果你正在用UE4做C++开发,无论是写游戏逻辑、插件,还是优化底层系统,有两个符号你几乎每天都会遇到,那就是&(引用)和*(指针)。很多刚接触UE4 C++的朋友,甚至一些有经验的开发者,对这两者的区别和具体应用场景都感到模糊。这直接导致代码里出现一些难以排查的bug,比如空指针访问、意外的数据修改,或者性能上的浪费。
在UE4的庞大框架里,&和*不仅仅是C++的基础语法,更是理解引擎内存管理、对象生命周期、蓝图交互和性能优化的基石。比如,为什么UPROPERTY()宏后面经常跟的是指针?为什么函数参数里有时用const FString&,有时又直接用FString?TArray<AActor*>和TArray<AActor&>哪个能用?这些问题都绕不开对这两个符号的深刻理解。
这篇文章,我就结合自己十多年在UE4项目里摸爬滚打的经验,把&和*在UE4 C++环境下的区别、用法、坑点以及最佳实践,掰开揉碎了讲清楚。目标很明确:让你看完之后,不仅能回答面试题,更能写出更安全、更高效、更符合UE4范式的代码。
2. 核心概念拆解:从内存模型理解本质区别
要真正搞懂&和*,不能只记语法,必须从它们在计算机内存中扮演的角色说起。这就像盖房子,指针(*)是给你一张写着地址的纸条,引用(&)是直接给这栋房子起了个别名。
2.1 指针(*):持有地址的“导航员”
指针的本质是一个变量,这个变量里存储的是另一个变量或对象在内存中的地址。你可以把它想象成一个遥控器,或者一张藏宝图。它本身不“是”那个对象,它只是“指向”那个对象。
1.1.1 基本语法与声明
// 声明一个整型指针,指向一个int类型变量的地址 int* IntPtr; // 声明一个指向AActor类对象的指针 AActor* ActorPtr; // 声明一个指向TArray容器的指针 TArray<FVector>* LocationArrayPtr;这里的*紧挨着类型名(int*)或变量名(*ActorPtr)都可以,但在UE4和现代C++风格中,更推荐int* IntPtr这种写法,强调“指向int的指针”是一个类型。
1.1.2 指针的核心操作指针有两大核心操作:取地址(&)和解引用(*)。这里容易混淆,同一个&符号,用在变量前面是“取地址运算符”,用在类型后面是“声明引用”。我们稍后讲引用时会再区分。
int Health = 100; int* HealthPtr = &Health; // &Health 取得Health变量的内存地址,赋值给指针 // 解引用:通过指针访问或修改其指向的对象 *HealthPtr = 150; // 等价于 Health = 150; UE_LOG(LogTemp, Warning, TEXT("Health is: %d"), *HealthPtr); // 输出1501.1.3 指针的特殊值:nullptr指针可以处于一种“未指向任何有效对象”的状态,在C++11及UE4中,我们用nullptr来表示这个空指针。这是指针与引用最关键的区别之一。
AActor* MyActor = nullptr; // 合法,指针初始化为空 if (MyActor != nullptr) { // 安全的操作,先检查指针是否有效 MyActor->Destroy(); }在UE4中,由于垃圾回收(Garbage Collection)机制的存在,一个UObject指针可能被自动置为nullptr。因此,在使用任何UObject指针前进行检查是至关重要的安全习惯。
2.2 引用(&):对象的“别名”
引用可以理解为某个已存在变量的另一个名字。一旦一个引用被初始化为某个变量,它就将一直作为那个变量的别名存在,无法再指向其他变量。它本身不占用额外的存储空间(在实现层面,编译器通常会将其当作指针处理,但对程序员透明)。
1.2.1 基本语法与声明
int Mana = 200; int& ManaRef = Mana; // ManaRef是Mana的引用,即别名 ManaRef = 250; // 直接修改ManaRef,就是在修改Mana UE_LOG(LogTemp, Warning, TEXT("Mana is: %d"), Mana); // 输出250注意,引用必须在声明时就被初始化,且之后不能改变其“绑定”的对象。
int A = 10; int B = 20; int& Ref = A; // 正确,Ref绑定到A // int& Ref2; // 错误!引用必须初始化 // Ref = &B; // 错误!不能将Ref重新绑定到B。这行代码意图是将B的地址赋值给Ref,但Ref是int&,不是指针。 Ref = B; // 注意!这行代码的含义是:将B的值(20)赋值给Ref所引用的对象(即A)。执行后,A的值变为20。1.2.2 引用作为函数参数(最常用场景)这是引用在UE4 C++开发中最有价值的地方之一:避免不必要的对象拷贝,提升性能。
// 低效做法:传值,会发生整个FVector的拷贝 void ProcessLocation_ByValue(FVector Location) { Location.X += 100.0f; // 修改的是拷贝的副本,不影响原数据 } // 高效做法:传常量引用,避免拷贝,且防止函数内部修改 void ProcessLocation_ByConstRef(const FVector& Location) { // Location.X += 100.0f; // 编译错误!const引用禁止修改 float XValue = Location.X; // 只读访问,安全高效 } // 需要修改原数据时的做法:传非常量引用 void ModifyLocation_ByRef(FVector& Location) { Location.X += 100.0f; // 直接修改调用方传入的原始对象 } // 调用示例 FVector PlayerPosition = GetPlayerLocation(); ProcessLocation_ByConstRef(PlayerPosition); // 高效,只读 ModifyLocation_ByRef(PlayerPosition); // 直接修改PlayerPosition对于UE4中常见的大型对象,如FString、TArray、TMap、FTransform等,在函数间传递时,几乎总是应该使用const &(如果不修改)或&(如果需要修改),而不是直接传值。
2.3 对比表格:指针 vs 引用
为了更直观地理解,我把核心区别总结成下面这个表格:
| 特性 | 指针 (*) | 引用 (&) |
|---|---|---|
| 本质 | 存储内存地址的变量 | 已存在变量的别名 |
| 是否可为空 | 可以,值为nullptr | 不可以,必须初始化且始终代表一个有效对象 |
| 能否重定向 | 可以,指向另一个地址 | 不可以,初始化后绑定关系不可变 |
| 内存占用 | 占用一个指针大小的内存(通常4或8字节) | 通常不占用额外存储(编译器实现细节) |
| 访问方式 | 需使用解引用运算符 (*) 或箭头运算符 (->) | 像普通变量一样直接使用 |
| 安全性 | 较低,需手动检查nullptr,易出错 | 较高,总是指向有效对象(理论上) |
| 主要用途 | 1. 动态内存分配 (new/delete)2. 可选参数(可传 nullptr)3. 需要重指向的场景 4. 实现多态(基类指针指向派生类) | 1. 函数参数传递,避免拷贝 2. 函数返回值(某些情况下) 3. 范围for循环( for (auto& Item : Array)) |
| 在UE4中的典型应用 | 存储UObject派生类的实例(AActor*,UWidget*) | 传递大型结构体或容器(const FString&,TArray<int32>&) |
3. 在UE4实战中的具体应用与选择策略
理解了基本概念,我们来看看在真实的UE4项目里,什么时候该用指针,什么时候该用引用。这个选择直接影响到代码的健壮性、可读性和性能。
3.1 必须使用指针的场景
2.1.1 管理UObject及其派生类的生命周期UE4的核心对象系统建立在UObject之上,包括AActor、UActorComponent等。这些对象由引擎的垃圾回收系统管理,我们通过指针来持有和操作它们。
UCLASS() class AMyCharacter : public ACharacter { GENERATED_BODY() public: // UPROPERTY宏必须用于指针成员,以便垃圾回收器跟踪引用 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Combat") AWeapon* EquippedWeapon; // 指向另一个AActor的指针 UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components") UCapsuleComponent* CapsuleComp; // 指向一个UActorComponent的指针 // 函数返回或参数也使用指针 AWeapon* FindBestWeapon() const; void PickUpWeapon(AWeapon* WeaponToPickup); };为什么必须是指针?因为UE4的反射系统和垃圾回收需要知道对象之间的引用关系。一个非指针的UObject成员变量意味着“值语义”的包含,这与UE4的对象模型不符,会导致编译错误或运行时问题。
2.1.2 需要表达“可能不存在”的语义当你需要一个可选的、可能为空的参数或返回值时,指针是唯一选择。
// 在场景中查找一个特定标签的Actor,可能找不到 AActor* FindActorByTag(const FName& Tag) { for (TActorIterator<AActor> It(GetWorld()); It; ++It) { if (It->ActorHasTag(Tag)) { return *It; // 返回找到的指针 } } return nullptr; // 找不到,返回空指针 } // 调用方必须检查返回值 AActor* FoundActor = FindActorByTag(FName("Target")); if (FoundActor) // 或者 if (FoundActor != nullptr) { // 安全地使用FoundActor FoundActor->SetActorHiddenInGame(true); } // 如果FoundActor是nullptr,这里什么也不做,程序不会崩溃如果试图用引用来实现这个函数,你将无法表示“未找到”的状态,调用方也无法安全地处理这种情况。
2.1.3 需要多态行为C++的多态(虚函数)需要通过基类指针或引用来调用派生类的方法。虽然引用也能实现多态,但指针更灵活,因为你可以有指针容器(如TArray<ACharacter*>),并且可以方便地置空或更换指向的对象。
UCLASS() class AEnemy : public ACharacter { GENERATED_BODY() public: virtual void PerformAttack() override { UE_LOG(LogTemp, Warning, TEXT("Enemy attacks!")); } }; UCLASS() class ABossEnemy : public AEnemy { GENERATED_BODY() public: virtual void PerformAttack() override { UE_LOG(LogTemp, Warning, TEXT("Boss launches special attack!")); } }; // 使用指针容器管理不同类型的敌人 TArray<AEnemy*> EnemyArray; EnemyArray.Add(NewObject<AEnemy>()); EnemyArray.Add(NewObject<ABossEnemy>()); for (AEnemy* Enemy : EnemyArray) { if (Enemy) { Enemy->PerformAttack(); // 多态调用,根据实际对象类型执行不同逻辑 } }3.2 应该优先使用引用的场景
2.2.1 函数参数传递(尤其是输入参数)这是引用最能发挥优势的地方。对于非UObject的大型数据(结构体、容器),使用const &传递可以避免昂贵的拷贝开销。
// 好:使用常量引用传递只读参数 float CalculateDistance(const FVector& PointA, const FVector& PointB) { return FVector::Distance(PointA, PointB); } // 好:使用引用传递需要修改的输出参数 void GetPlayerViewPoint(FVector& OutLocation, FRotator& OutRotation) const { OutLocation = GetActorLocation(); OutRotation = GetActorRotation(); } // 不好:传值,会导致整个TArray的深拷贝(性能杀手) void ProcessAllItems(TArray<FItemData> Items) { for (auto& Item : Items) { /* ... */ } } // 好:传常量引用,零拷贝开销 void ProcessAllItems(const TArray<FItemData>& Items) { for (const auto& Item : Items) { /* ... */ } } // 需要修改原容器时:传非常量引用 void RemoveInvalidItems(TArray<FItemData>& Items) { Items.RemoveAll([](const FItemData& Item){ return !Item.IsValid(); }); }经验法则:对于内置类型(int,float,bool等),传值开销很小,可以直接传值。对于其他所有类型,先考虑const &,如果需要修改,再考虑&。
2.2.2 范围for循环(Range-based for loop)在遍历容器时,使用引用可以避免容器元素的拷贝,尤其是当元素是复杂类型时。
TArray<FVector> PathPoints = GetPath(); // 不好:每次迭代都会拷贝一个FVector for (FVector Point : PathPoints) { Point.Z += 100.0f; // 修改的是拷贝,不影响原数组 } // 好:使用引用,直接操作原数组中的元素 for (FVector& Point : PathPoints) { Point.Z += 100.0f; // 直接修改原数组 } // 如果只需要读取,使用常量引用 float TotalLength = 0.0f; for (const FVector& Point : PathPoints) { TotalLength += Point.Size(); }2.2.3 实现操作符重载为了让自定义类型支持像内置类型一样的自然语法,操作符重载经常返回引用。
struct FMyVector { float X, Y, Z; // 重载+=操作符,返回引用以支持链式调用 (a += b += c) FMyVector& operator+=(const FMyVector& Other) { X += Other.X; Y += Other.Y; Z += Other.Z; return *this; // 返回当前对象的引用 } };3.3 模棱两可的场景与决策指南
有些情况似乎两者都可以,这时需要根据具体语义和未来扩展性来决定。
2.3.1 函数返回一个“找到”的对象如果函数保证总是返回一个有效对象(例如,获取玩家控制的Pawn),那么返回引用可能更清晰。但如果存在找不到的可能性,必须返回指针(或使用TOptional等现代C++类型)。
// 场景:获取游戏中的唯一玩家控制器,假定总是存在 APlayerController& GetFirstPlayerController() const { APlayerController* PC = GetWorld()->GetFirstPlayerController(); check(PC != nullptr); // 如果为空,断言失败,帮助在开发期发现问题 return *PC; // 解引用指针,返回引用 } // 场景:通过ID查找玩家,可能找不到 APlayerState* FindPlayerStateById(int32 PlayerId) { // ... 查找逻辑 return FoundPlayer ? FoundPlayer : nullptr; // 可能返回空 }2.3.2 类成员变量存储非UObject对象对于非UObject的复杂类型,如果该对象是当前类“拥有”的组成部分(组合关系),通常直接作为值成员。如果需要共享或可能为空,则使用指针(通常是智能指针TSharedPtr或TUniquePtr)或引用(但引用作为成员比较少见,因为必须在构造函数初始化列表中初始化)。
class FMyComplexSystem { private: // 值成员:MyConfig是FMySystemConfig类型的一部分,生命周期与FMyComplexSystem一致 FMySystemConfig MyConfig; // 智能指针:指向动态分配的资源,可能为空,所有权明确 TUniquePtr<FExternalData> ExternalDataPtr; // 原始指针:指向一个不由本类管理的对象(观察者模式) FSomeOtherSystem* ObservedSystem; // 需要小心生命周期管理! // 引用成员:必须在构造函数中初始化,且之后不能改变指向 // 通常用于注入依赖,表示一个必须存在的协作对象 IAudioInterface& AudioService; public: FMyComplexSystem(IAudioInterface& InAudioService) : AudioService(InAudioService) // 引用成员必须在初始化列表中初始化 { // ObservedSystem可能稍后设置,所以可以不在初始化列表 // MyConfig会调用默认构造函数 // ExternalDataPtr默认为空 } };在UE4中,对于非UObject的大型数据成员,直接作为值存储是常见做法,因为UE4的结构体(如FVector,FTransform)通常设计为轻量且可快速拷贝。如果需要多态,或者对象可能非常大且拷贝昂贵,才考虑使用指针或引用。
4. UE4特有的注意事项与深度解析
UE4在标准C++的基础上,引入了一套自己的对象模型和内存管理机制,这使得&和*的使用出现了一些特殊的规则和最佳实践。
4.1 UPROPERTY()宏与指针的强制绑定
这是UE4 C++中最具特色的一点。任何需要被UE4反射系统识别、参与序列化(保存/加载)、网络复制或垃圾回收的UObject派生类成员变量,必须声明为指针,并且用UPROPERTY()宏修饰。
UCLASS() class AMyGameMode : public AGameModeBase { GENERATED_BODY() public: // 正确:UObject派生类成员必须是指针,并用UPROPERTY修饰 UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Players") TSubclassOf<APawn> DefaultPawnClass; // TSubclassOf内部也是指针 UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components") USceneComponent* RootSceneComponent; UPROPERTY(BlueprintReadWrite, Category = "Game State") APlayerState* CurrentLeader; // 错误:不能将UObject派生类作为非指针成员(值语义) // AMyActor MyActorMember; // 编译错误! // 错误:即使是指针,缺少UPROPERTY(),垃圾回收时将无法追踪,可能导致悬空指针 // AMyActor* UnsafeActorPtr; };底层原理:UE4的垃圾回收器通过UPROPERTY()宏标记的指针来构建对象引用图。如果一个UObject指针没有被UPROPERTY()修饰,垃圾回收器就不知道有指针指向它,当其他引用都消失时,该对象会被错误地回收,留下一个指向无效内存的“悬空指针”,访问它会导致崩溃。
4.2 常量正确性(Const Correctness)在UE4中的运用
正确使用const与&、*的组合,是写出健壮接口的关键。它能明确表达函数的意图,防止意外修改,并帮助编译器进行优化。
3.2.1 常量指针 vs 指向常量的指针这是一个经典的容易混淆的点:
AActor* Ptr; // 非常量指针,指向非常量对象:可以改指针,也可以改对象 const AActor* PtrToConst; // 非常量指针,指向常量对象:可以改指针,不能通过指针改对象 AActor* const ConstPtr; // 常量指针,指向非常量对象:不能改指针,可以改对象 const AActor* const ConstPtrToConst; // 常量指针,指向常量对象:都不能改在UE4中,最常用的是const AActor*(指向常量对象的指针),用于表示函数不会修改该对象。
// 函数声明:承诺不会通过该指针修改Actor void DisplayActorInfo(const AActor* ActorToDisplay) { if (ActorToDisplay) { FString Name = ActorToDisplay->GetName(); FVector Loc = ActorToDisplay->GetActorLocation(); // ActorToDisplay->Destroy(); // 错误!不能通过const指针调用非const成员函数 } }3.2.2 常量引用作为函数参数这是UE4函数接口设计的黄金标准之一,用于传递大型只读参数。
// 优秀的函数签名 bool IsLocationInBounds(const FVector& TestLocation, const FBox& WorldBounds); void SortPlayerArray(TArray<APlayerState*>& PlayersToSort); // 需要修改容器本身 void GetSortedPlayers(const TArray<APlayerState*>& Players, TArray<APlayerState*>& OutSortedPlayers); // 输入只读,输出通过引用参数 // 在头文件中广泛应用的例子 (Engine/World.h) bool K2_LineTraceSingle(struct FHitResult& OutHit, const FVector& Start, const FVector& End, ...);个人心得:在设计函数时,先问自己:“这个参数需要被修改吗?”如果不需要,立刻加上const。这不仅是好习惯,更能让代码的使用者一眼明白你的意图,减少犯错的可能。
4.3 TArray, TMap, TSet与引用/指针的协作
UE4的容器类与引用、指针的配合使用非常频繁,这里有些细节需要注意。
3.3.1 容器存储指针当容器存储UObject指针时,配合UPROPERTY(),可以自动被垃圾回收管理。
UCLASS() class AInventory : public AActor { GENERATED_BODY() public: // TArray存储指针,UPROPERTY确保容器内指针被GC追踪 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Inventory") TArray<AItem*> Items; // 添加元素 void AddItem(AItem* NewItem) { if (NewItem && !Items.Contains(NewItem)) { Items.Add(NewItem); // 存储的是指针,不是拷贝 } } // 遍历元素 AItem* FindItemByName(const FString& Name) const { for (AItem* Item : Items) // 使用范围for循环,Item是指针 { if (Item && Item->GetItemName() == Name) { return Item; } } return nullptr; } };注意:TArray<AItem*>存储的是原始指针。如果这些AItem对象在其他地方被Destroy()了,这个数组里就会留下悬空指针。UE4的垃圾回收只针对UObject本身,不会自动把容器里的指针置空。安全做法是重写对象的BeginDestroy()或使用弱引用(TWeakObjectPtr)。
3.3.2 容器存储非指针对象与引用遍历当容器存储的是结构体等值类型时,在遍历修改时使用引用至关重要。
// 假设FPlayerScore是一个结构体 struct FPlayerScore { FString PlayerName; int32 Score; }; TArray<FPlayerScore> PlayerScores; // 需要更新分数时,使用引用遍历 void UpdateScore(const FString& PlayerName, int32 DeltaScore) { for (FPlayerScore& ScoreEntry : PlayerScores) // 注意是 FPlayerScore& { if (ScoreEntry.PlayerName == PlayerName) { ScoreEntry.Score += DeltaScore; // 直接修改原数组元素 break; } } } // 仅用于读取时,使用常量引用遍历,避免拷贝 int32 GetTotalScore() const { int32 Total = 0; for (const FPlayerScore& ScoreEntry : PlayerScores) // 注意是 const FPlayerScore& { Total += ScoreEntry.Score; } return Total; }4.4 智能指针(TUniquePtr, TSharedPtr)与原始指针的选用
现代C++推荐使用智能指针管理动态内存的所有权。UE4也提供了自己的智能指针:TUniquePtr(独占所有权)和TSharedPtr(共享所有权)。它们与原始指针(*)和引用(&)如何配合?
3.4.1 何时使用智能指针?
TUniquePtr<T>:当你拥有一个对象的唯一所有权,并且该对象不是UObject(UObject由GC管理)。当TUniquePtr离开作用域时,它会自动删除所指向的对象。适用于管理纯C++类的生命周期。#include "MyNonUObjectClass.h" class FMyResourceManager { private: TUniquePtr<FMyNonUObjectClass> ExclusiveResource; // 独占资源 public: FMyResourceManager() : ExclusiveResource(MakeUnique<FMyNonUObjectClass>()) { } // 析构时,ExclusiveResource会自动删除其指向的对象 };TSharedPtr<T>/TSharedRef<T>:当多个对象需要共享一个非UObject资源的所有权时使用。TSharedRef类似于引用,不能为空;TSharedPtr类似于指针,可以为空。引用计数降为0时自动删除对象。class FTextureData { /* ... */ }; TSharedPtr<FTextureData> SharedTextureData = MakeShared<FTextureData>(); // 多个地方可以持有这个共享指针 class FMaterialA { TSharedPtr<FTextureData> TextureRef; }; class FMaterialB { TSharedPtr<FTextureData> TextureRef; }; // 当所有Material都不再引用TextureData时,它会被自动释放。
3.4.2 与原始指针和引用的交互智能指针可以方便地获取其内部的原始指针,用于需要原始指针的API(比如很多UE4函数接口)。
void ProcessTexture(FTextureData* RawPtr) { /* ... */ } TSharedPtr<FTextureData> SmartPtr = MakeShared<FTextureData>(); // 将智能指针获取的原始指针传递给函数 ProcessTexture(SmartPtr.Get()); // .Get() 返回 T* // 如果函数不会修改对象,且你需要表达“可能为空”,可以这样: if (SmartPtr.IsValid()) { ProcessTexture(SmartPtr.Get()); } // 对于TSharedRef,因为它保证非空,可以直接解引用或获取指针 TSharedRef<FTextureData> SharedRef = MakeShared<FTextureData>(); FTextureData& DataRef = *SharedRef; // 转换为引用 FTextureData* DataPtr = &DataRef; // 或获取指针重要原则:在UE4中,对于UObject派生类,永远不要用new/delete或智能指针来管理其生命周期。UObject的生命周期由引擎的垃圾回收系统管理,使用NewObject<T>()或SpawnActor<T>()创建,并通过UPROPERTY()标记的原始指针或TWeakObjectPtr来引用。智能指针仅用于非UObject的纯C++类。
5. 常见陷阱、调试技巧与最佳实践总结
即使理解了概念,在实际编码中仍然会踩坑。下面是我在项目中总结的一些常见问题和应对策略。
5.1 悬空指针(Dangling Pointer)问题
这是使用指针时最危险的错误。指针指向的内存已经被释放,但指针本身还未被置空。
// 危险代码示例 ACharacter* GetLocalPlayerCharacter() { APlayerController* PC = GetWorld()->GetFirstPlayerController(); if (PC) { return PC->GetCharacter(); // 返回一个指针 } return nullptr; } void SomeFunction() { ACharacter* MyChar = GetLocalPlayerCharacter(); // ... 一些其他代码 ... if (MyChar) // 此时检查可能还是true { // 但如果在这期间,角色被Destroy了(比如死亡、关卡切换) MyChar->AddMovementInput(FVector::ForwardVector, 1.0f); // 崩溃! } }解决方案:
- 对于UObject:使用
IsValid()函数进行检查,它比简单的if (Ptr)更安全,因为它会检查指针是否为空以及对象是否处于待销毁状态(IsPendingKill)。if (IsValid(MyChar)) { MyChar->AddMovementInput(FVector::ForwardVector, 1.0f); } - 使用弱引用:如果不负责对象生命周期,只是观察者,使用
TWeakObjectPtr<T>。TWeakObjectPtr<ACharacter> WeakCharPtr = GetLocalPlayerCharacter(); // ... if (ACharacter* Char = WeakCharPtr.Get()) // .Get()会返回有效的指针或nullptr { Char->AddMovementInput(FVector::ForwardVector, 1.0f); } - 明确所有权:理清代码中谁“拥有”对象,谁只是“使用”对象。拥有者负责生命周期,使用者使用弱引用或共享指针(针对非UObject)。
5.2 引用绑定到临时对象(生命周期问题)
引用必须绑定到一个具有足够生命周期的对象上。绑定到临时对象(如函数返回值)是未定义行为。
// 错误示例 const FString& GetBadReference() { FString LocalString = TEXT("Hello"); return LocalString; // 返回局部变量的引用!LocalString在函数结束时被销毁。 } // 函数结束,LocalString内存被释放,返回的引用变成“悬空引用”。 void ProblematicCode() { const FString& BadRef = GetBadReference(); // BadRef绑定到了已销毁的内存 UE_LOG(LogTemp, Warning, TEXT("%s"), *BadRef); // 可能崩溃或输出乱码 }解决方案:
- 如果函数需要返回一个新创建的对象,直接返回值(编译器通常会进行返回值优化RVO)。
FString CreateGreeting(const FString& Name) { return FString::Printf(TEXT("Hello, %s!"), *Name); // 正确:返回FString对象 } - 如果需要返回一个已存在对象的引用,确保该对象的生命周期长于引用。
class AMyGameState { FString GlobalGameName; public: const FString& GetGameName() const { return GlobalGameName; } // 安全,返回成员变量的引用 };
5.3 指针和引用的误用导致性能或逻辑错误
4.3.1 不必要的指针拷贝对于小型结构体,使用指针反而可能因为间接寻址而降低性能,并增加代码复杂度。
// 不必要:FVector很小(3个float),传值或传引用即可 void ProcessVector(const FVector* InVector) { if (InVector) // 每次使用都要检查 { float X = InVector->X; } } // 更好:直接传常量引用 void ProcessVectorBetter(const FVector& InVector) { float X = InVector.X; // 无需检查,更简洁高效 }4.3.2 混淆指针和引用的修改语义
void TryToReassignPointer(AActor* ActorPtr) { AActor* NewActor = GetNewActor(); ActorPtr = NewActor; // 这只修改了函数内局部指针副本的指向,不影响实参! } void TryToReassignReference(AActor*& ActorRef) // 注意参数类型:指针的引用 { AActor* NewActor = GetNewActor(); ActorRef = NewActor; // 这会修改调用方传入的指针变量本身! } void Caller() { AActor* MyActor = GetActorA(); TryToReassignPointer(MyActor); // MyActor 仍然指向 GetActorA() 返回的对象 TryToReassignReference(MyActor); // MyActor 现在指向 GetNewActor() 返回的对象 }如果你想修改指针本身(即让指针指向另一个对象),你需要传递指针的引用(AActor*&)或指针的指针(AActor**)。而如果你想修改指针所指向的对象,直接通过指针解引用操作即可。
5.4 UE4 C++中&和*的最佳实践清单
根据上面的分析,我总结了一份在UE4开发中关于&和*的快速决策清单:
成员变量:
- UObject派生类:一律使用原始指针 (
T*),并且如果需要在蓝图中访问、需要被序列化或网络复制,必须加UPROPERTY()。 - 非UObject的大型对象/需要多态:考虑使用智能指针 (
TUniquePtr<T>,TSharedPtr<T>) 或原始指针(如果所有权明确且生命周期简单)。 - 小型结构体/POD类型:直接作为值成员。
- 引用成员:慎用,仅用于表示类所依赖的、生命周期更长的外部服务(依赖注入),且必须在构造函数初始化列表中初始化。
- UObject派生类:一律使用原始指针 (
函数参数:
- 内置类型 (int, float, bool):直接传值。
- UObject派生类:传指针 (
T*)。如果函数内不修改对象,用const T*。 - 其他所有类型 (FString, TArray, FVector等):
- 如果只读,用
const T&。 - 如果需要修改,用
T&。 - 如果可选(可能为空),用
T*(可传nullptr)。
- 如果只读,用
- 需要修改指针本身:用
T*&(指针的引用)。
函数返回值:
- 新创建的对象(非UObject):直接返回值(利用RVO)。
- 返回已存在的对象(且保证非空):返回
const T&或T&。 - 返回可能不存在的对象:返回
T*(可能为nullptr)或TOptional<T>。 - UObject派生类:返回
T*。
遍历容器:
for (const auto& Item : Container):只读遍历,无拷贝。for (auto& Item : Container):需要修改容器内元素。for (auto* Item : Container):容器内存储的是指针(如TArray<AActor*>)。
安全性:
- 使用任何UObject指针前,用
IsValid()检查。 - 不要返回局部变量的引用或地址。
- 明确每个指针的所有权(谁创建,谁销毁)。
- 对于非UObject的动态内存,优先使用智能指针而非原始
new/delete。
- 使用任何UObject指针前,用
理解&和*的区别,并能在UE4的上下文中正确应用,是写出高质量C++代码的关键一步。这不仅仅是语法问题,更关乎你对程序内存模型、对象生命周期和UE4引擎机制的理解。开始的时候可能会觉得需要刻意选择,但随着练习,这些选择会变成一种本能,你的代码也会因此变得更加清晰、高效和健壮。