对基础知识的掌握比较重要,有时候工作或是切实开发几年,脱离了基础知识,等到参加一些注重基础的面试时,啥也不会了。这里会总结一下游戏工程的基石;开发也中很多小小不言的问题,也会聚沙成塔。
引用类型与值类型
引用类型和值类型最根本的区别就是其在内存分配上的差异:
Stack 栈:线程所管理和使用的栈,存放的是值类型和引用类型的地址。 GC Heap 堆:CLR加载到内存后,进程的可用地址空间分配的一段地址空间。引用类型分配在托管堆上,由GC管理。
举个简单的例子:
int a = 10;
// 变量 a 和它的值 10 都存在 Stack 上
string b = "Hello World"
// 变量 b 存在 Stack 上,其值是托管堆对象地址,这个地址指向字符串 “Hello World”,而这个字符串存在 Heap 上
类型的赋值与传递
值类型的变量赋值:会复制一次值; 引用类型的变量赋值:会复制一次存在 Stack 上的内存地址,指向同一个引用对象的值
C#程序中进行参数传递的时候,默认按值传递,值类型复制数据本身,形成独立的数据块,引用类型复制引用,指向同一实例。ref 和 out 也可以用作引用传参,它们之间又有什么异同呢? 首先我先讨论一下 ref 和 out 的异同点:
- 相同: 都可以使参数按照引用方式传递,这意味着对参数的改变将会引起原始变量的改变,因为它们本质上都是原始数据的引用;
- 差异:
- ref要求参数在使用之前要显式初始化,out要在方法内部初始化;
// ref: 需要在使用前初始化 number 的值 int number = 5; SomeFunc(ref number); // out: 例如Unity经常使用的 TryGetcomponent 函数 TryGetcomponent<ClassA>(out var target)
- out修饰的参数主要用于返回值,而ref修饰的参数主要用于修改;
那么Ref(和out)关键字可以在值类型的传参上实现跟引用类型一样的效果,那么在引用类型参数上加入ref关键字岂不是多此一举吗?答案是否定的,默认传参方式和引用型传参方式毫无疑问是不同的。
其实默认传参方式和引用型传参方式的不同完全是由进行操作的对象决定的,默认的值传参会复制 值(或引用)然后对这个值(或引用)进行操作;而引用型传参没有复制引用而是对原始变量的引用进行操作的。这也就导致了我上面写的它们的相同点:“对参数的改变将会引起原始变量的改变”。还是举个例子来说吧:
void SomeFunc(float target, ClassA person)
{
target = 30;
person.num = target;
person = null;
}
float num1 = 10f;
ClassA person1=new();
person1.num = 20f;
SomeFunc(num1, person1);
// 输出的结果为: num1 = 10; person1.num = 30
// 如果使用另一种 引用型传参
void SomeFunc(ref float target, ref ClassA person)
{
target = 30;
person.num = target;
person = null;
}
float num1 = 10f;
ClassA person1=new();
person1.num = 20f;
SomeFunc(num1, person1);
// 输出的结果为: num1 = 30; person1.num = null
为什么会产生这样的结果,还是留给读者们自己思考一下吧。
深拷贝和浅拷贝:
浅拷贝是两个地址指向同一个内存地址, 改变一个对象的值之后,另外一个对象的值也会相应改变,因为它们共用同一个地址指向的值; 深拷贝则是重新新建一个独立的对象,其值指向的地址也是新的,它与原始值之间是相互独立的.
值与引用类型的八股文
其实知道了上面的内容之后,值与引用类型的区别基本上都可以有更深刻的认识或者是可以自行解释了。
值类型和引用类型的区别
值类型 引用类型 存储方式 存储值本身 存储数据值的引用,数据值存在内存堆中 效率性能 高效 效率较低,需要寻址取数据 内存回收 用完即自动释放 由 GC 管理释放 语义差异 复制新对象 复制地址引用后传递 类型继承 无法派生 多态和继承等复杂的拓展 需要注意的是,C# 中的字符串是引用类型,但它有一些特殊的行为,后面再详细展开。
结构和类的区别
在C#中,结构体(struct)和类(class)是两种表示数据和行为的类型。它们有以下区别:
语义差异:结构体是值类型(value type),而类是引用类型(reference type)。这意味着当结构体赋值给另一个变量或传递给方法时,会复制整个结构体的值;而类则是通过引用传递,多个变量可以引用同一个对象。
内存分配:结构体通常分配在栈上,而类通常分配在托管堆(managed heap)上。由于栈上的内存分配和释放比堆上的操作更快,结构体适用于较小的数据类型,对于临时性的数据存储很有用。
默认构造函数:在类中,如果没有显式定义构造函数,编译器会提供一个默认的无参构造函数。而在结构体中,如果没有显式定义构造函数,那么会有一个默认的无参构造函数,但是结构体也可以定义自己的构造函数。
继承:类支持继承,可以派生出其他类,形成类的层次结构。而结构体不能继承其他结构体或类,它们只能实现接口。
可空性:结构体可以通过在其定义中使用 ? 来使其可空,即可为null。而类默认是可为null的,除非显式使用 notnull 或 nonnull 进行标记。
性能方面的考虑:由于结构体是值类型,它们的拷贝操作可能会导致性能开销。如果结构体较大,频繁进行复制操作可能会影响性能。在这种情况下,使用类可能更加合适。
这两种类型在使用上可能有部分相似的地方,但是实际上在项目里面除了一些特殊的使用需求外,应该没人会把它们混用吧,不过真的有混淆的时候,比如下面这个问题:
//定义了存放异常状态层数的结构体
public struct StatusInfo
{
public int stack;
... ... // others
}
//定义使用了结构体作为value的字典
public Dictionary<Status, StatusInfo> statusDict = new();
// 在某个函数中判断是否存在 Toxin 这个 Status,如果有就让它的异常状态层数 stack +1
if (statusDict.ContainsKey(Status.Toxin))
{
statusDict[Status.Toxin].stack+=1;
}
假设这样一个例子:这是一个用来检查玩家异常状态的逻辑,如果玩家身上的 statusDict 存在 Toxin,就让 statusDict 的值(StatusInfo类型的结构体) 中的 stack + 1.
在不看以下的答案的情况下,想想为什么会出错呢。
Answer: 当StatusInfo定义为Class时,则不会出现错误,那么错误原因是什么也就在于Struct和Class的区别上了。 struct属于值类型,要想改变struct里面元素的值,只能是new一个新的struct。在给字典的结构体值赋值时,实际上是对字典中存储的副本进行操作。由于结构体是值类型,对副本的修改不会影响字典中原始的结构体实例。
在用习惯了引用类型那样的容器之后,确实容易忽略这一点。那为什么类不会报错也很明了:
类是引用类型,字典存储的是引用,而不是对象的副本。因此,对字典中类的实例进行操作时,实际上是对原始对象进行操作。因此,当你尝试修改字典中存储的结构体值时,编译器会报错。结构体是不可变(immutable)的。如果你想在字典中存储可变的状态信息,可以考虑使用类而不是结构体。
值类型和引用类型的生命周期
值类型在作用域结束后释放; 引用类型由 GC 回收。
还记得吗:值类型存放在 Stack,引用类型分配在 GC Heap上,由 GC 管理。GC 是一个考点,后面会展开讲。
装箱和拆箱
简单的来说,装箱就是:将值类型对象封装到引用类型对象中,拆箱则是相反的过程:从装箱对象中提取值类型的过程。
不过根据我们上面写到的知识可以得出:
装箱会创建一个新的堆对象,并将值类型的值复制到堆对象中,使其可以在堆上进行操作。这样做的目的是为了使值类型能够在引用类型的上下文中使用,但同时也会带来一定的性能开销和内存消耗;拆箱会将装箱对象中的值复制到一个值类型变量中,使其可以在栈上进行操作。拆箱操作需要进行类型检查和数据复制,也会带来一定的性能开销。
装箱和拆箱可以在某些情况下带来方便,但频繁的装箱和拆箱操作可能会对性能造成影响。因此,在开发过程中,我们应该尽量避免不必要的装箱和拆箱操作,尤其是在性能要求较高的场景中。可以通过使用泛型来避免装箱和拆箱,或者使用值类型的特性来提高性能。
那为什么我们还是无法停止装箱和拆箱呢?拿装箱来说,有些时候我们的程序需要更为泛化的要求的时候就会需要装箱。好吧,这句话本身听起来就挺泛化的,让我们举例来说:
- 我有一个事件系统,它使用了Callback,有时候会将一些数据也传送出去,但是这些数据的类型有时候是int,有时候是float,有时候是GameObject甚至一些复杂的复合数据,但是我还是希望我能用一个类似泛型一样的方式处理它,所以我们就可以把它装箱成Object,只有需要这些数据的接收者会按照所需的数据进行拆箱来还原数据。
- 再比如因为泛型参数限制:在某些情况下,一个函数的参数类型是object,当你需要一个传参数比如Int类型的参数时,就需要装箱(Int->Object)。
装箱的优化
- 使用泛型集合:使用泛型集合类(例如 List
、Dictionary<TKey, TValue>)来存储值类型数据,这样可以避免将值类型进行装箱。 - 使用值类型特性和方法:值类型(如结构体)可以定义自己的方法和属性,通过直接操作值类型的数据,可以避免拆箱操作。如果可能的话,尽量使用值类型提供的方法来操作值类型的数据,而不是将其拆箱为引用类型。
- 使用接口约束:在需要接受值类型作为参数的方法或泛型中,可以使用接口约束来限制类型参数必须是值类型。这样可以确保传递给方法或泛型的参数是值类型,而不需要进行装箱操作。