这篇笔记主要是关于 C# 语言的。完整了解 Unity 的特性必须要从 C# 语言开始。这一部分虽然非常八股文,但是能够很好地帮助我们提高程序的性能,了解程序的行为。
类型
基本概念
C# 中一共分为两种类型。
值类型:int, short, long, sbyte, bool, float, char, struct, enum
引用类型: string, object, delegate, interface, class, array
他们之间有以下的区别:
| 值类型 | 引用类型 |
存储位置 | 内存栈 (Stack) | 内存托管堆 (Heap) |
存储速度 | 相对快 | 相对慢 |
表示含义 | 实际的数据内容 | 指向内存堆中的指针和引用 |
内存释放 | 自动释放 | GC 释放 |
继承于 | System.ValueType | System.Object |
在 C# 中,
内存栈:线程栈,存放值类型、引用类型在托管堆中的地址。栈中的值类型在其作用域结束后会被清理,效率很高。
托管堆:存放引用类型,托管堆中的对象由垃圾回收器(Garbage Collector, GC)管理和释放。
值与引用
单独的值类型变量(例如函数的局部变量)都是管理在栈上面的。
当值类型是自定义 class 的一个字段、属性时,它不会独立存储在栈上,而是随引用类型存储在托管堆上,此时它是引用类型的一部分。例如,
在这里,虽然 Point 是一个 struct (值类型),但是它作为 Shape (class,引用类型)的一个实例的字段时,它被存储在托管堆上。
所有的引用类型肯定是存放在托管堆上的。
struct (值类型)中定义引用类型字段,结构体本身存储在栈上,其引用变量字段只存储内存地址(内存地址本身也存储在栈上),指向堆中的引用示例。
将值类型的变量赋值给另一个变量(或者作为参数传递),会执行一次值复制。将引用类型的变量赋值给另一个引用类型的变量,它复制的值是引用对象的内存地址,因此赋值后就会多个变量指向同一个引用对象实例。例如,
out 和 ref 关键字
out 和 ref 关键字都指示编译器传递参数地址,在行为上是相同的;
ref 要求参数在使用之前要显式地初始化,out 要在方法内部初始化。例如,
out 和 ref 不可以用于重载。也就是说 void TestMethod(ref int a) 和 void TestMethod(out int b) 并不能被编译器理解为两个函数。
String 类型
特别地,string 作为引用类型有一定的特殊性。任何 string 的修改,实际上是 new 了一个新的 string。只要 new 了一个新的引用类型,就会在堆内申请新的空间。而此时栈内的副本也会指向堆内的新对象,因此 string 会发生改变,会成为新的对象,与原来的 string 就不再有关系了。
例如, 在下面的代码中,
这里会在堆上产生三个 string 实例:"a", "b", "ab"。原则上来说,字符串一经创建,就不会改变,任何改变都会产生新的字符串。
解决方案是当我们会频繁地修改一个字符串时,我们使用 StringBuilder 类代替 String。
StringBuilder 底层思路
大概就是一个支持扩容的 char 数组,空间不足时开辟原先数组大小的容量,新建的数组指向上一个已经用完的数组,本身也就不会调用 GC。
Garbage Collector / GC
Garbage Collector,简称 GC,是 C# 自带的垃圾回收器。它们负责管理内存中不再会被使用的内容。具体可以查阅 Unity Engine #1 Memory 的部分。这里简述一些概念。
基本概念
内存管理池:如上提及,Unity 内部有内存堆(heap)和内存栈(stack)两个部分。Stack 用来存储较小的、短暂的数据;Heap 用来存储较大的、较持久的数据。
只要变量是激活状态,对应的内存占用也就会是是使用状态(Allocated)。
一旦变量不再是激活状态,其占用的内存不再需要,就会被回收。Stack 上的回收快速,但是 Heap 上的垃圾并不是及时回收的。它的内存还会被标记为使用状态。不再使用的内存只会在 GC 阶段才会被回收。
GC 操作会相当大地影响性能,因此我们应该尽量避免 GC。
装箱 / 拆箱
装箱(Boxing)指的是将值类型转换为引用类型,或者由此值类型实现的任何接口类型的过程。由于值类型均隐式派生自System.ValueType,因此只有值类型才有装箱、拆箱两个状态,而引用类型一直都在箱子里。
实际操作:
- 在堆中申请内存(一个新的引用类型对象),大小为该值类型的大小,加上固定额外空间用作type handle和同步索引块。
- 将值类型的数据存入该引用类型对象中。
- 将堆上创建的对象的地址返回到一个引用类型变量上。
反过来的步骤就是拆箱。拆箱(Unboxing)是从引用类型到值类型(或者接口类型)的显式转换。
实际操作:
- 获取已装箱的对象的地址,检查对象实例,确保它是给定值类型的装箱值。
- 将该值从实例复制到值变量类型(即拷贝到栈上)。
装箱和拆箱会实际上产生更多的 GC。在 Unity 的装箱操作中,会在 Heap 上分配一个 System.Object 类型的引用来封装,其对应的缓存就会产生内存垃圾。即使代码中没有直接的对变量进行装箱操作,很有可能函数中或者第三方插件中也会存在这样的现象。我们应该尽量减少装箱拆箱操作。
例如在上图中,我们可以通过强制转换为 object 进行一个显式的装箱。但通常难以察觉的是在这里 value2 的隐式装箱。查看 ArrayList.Add 方法的定义,我们发现其定义为 public virtual int Add(object value),所以在上面第 15 行中的 list.Add(i) 中,i 被隐式地装箱,造成了性能损耗。
方法:
使用泛型。
yield return null 而不是 yield return 0.
减少不必要的 Log。
面向对象编程
众所周知,C# 和 C++ 类似,是一门 Object Oriented Programming。对于面向对象的语言,有很多重要的特征。
封装
封装(Encapsulate)指的是通过约束代码修改数据的程度,增强数据的安全性。通过隐藏对象的内部状态和功能实现细节,只暴露必要的操作接口的过程。
在 C# 中,封装是通过使用访问修饰符来控制成员的访问级别,例如:
- public:对任何类和成员都公开,没有访问限制。
- private:仅对当前类公开。
- protected: 对当前类和其派生类公开。
- internal: 只能在包含该类的程序集中访问该类。
注意一下 protected 修饰符的行为。
在这里,AnotherClass 并不是 BaseClass 的派生类,所以被 protected 修饰的 int 值类型变量 protectedField 只能被 BaseClass 和 DerivedClass 访问,而不能被 AnotherClass 访问。
继承
继承(inheritance)是另一个重要的特征。继承允许新创建的类(派生类)继承现有类(基类)的属性和方法。
在 C# 中,只支持单继承,也就是每个类只能继承自一个基类,但可以实现多个接口。
最简单的例子就是在 Unity 中如果我们新建一个新的脚本,它就是继承于 MonoBehavior 类的。所以新的脚本中都可以使用 MonoBehavior 里面已经定义好的函数。
在这里, Cube 就是 MonoBehavior 的派生类。MonoBehavior 就是 Cube 的基类。
注意,我们在新的脚本里面写的这些 MonoBehavior 函数的具体实现,虽然看起来像是重写,但实际上它们在 MonoBehavior 类中并没有被明确地定义为 void 方法或者 abstract 方法。它们实际上是由 Unity 引擎通过特定的机制特别调用的。Unity 引擎在运行时会检查 MonoBehavior 的派生类是否存在类似于 Start(), Update() 之类的特定的函数,如果存在的话,就会在规定的时间自动调用这些新的函数;因为这是一种特殊的反射机制,不是面向对象继承,所以我们不需要下面多态中提及的 override 关键字。
在 C# 中,子类不光继承父类的公有成员,也继承了私有成员,只是不可直接访问。
new 关键字
特别地,new 关键字在声明子类方法时有特殊的作用:阻断继承。例如,
在 C# 中,我们还可以通过 sealed 关键字来防止别的类继承该类;如果在方法上使用 sealed 关键字,则可以防止别的类重写该方法。这种机制叫做密封类。
多态
多态(Polymorphism)指的是同一个操作在不同的类实例上可以有不同的实现。
在 C# 中,多态是通过方法重写修饰符 override 和接口实现来实现的。它允许将派生类对象视为其基类类型的对象,同时保留其特有的行为。
这个例子可以用来解释多态。
我们的基类是 Animal,Dog 和 Cat 都是它的派生类。基类定义了函数 MakeSound,然后在不同的派生类中,我们都可以通过 override 来重写这个函数, 使它们各自有自己的定义方式。直接通过基类调用,甚至可以自动调用派生类的 MakeSound 方法。
抽象化
在 C# 中,我们可以通过抽象类(不能被实例化的类)和接口(定义了一组没有实现的方法)来实现抽象。
其中,抽象类可以包含抽象方法(没有实现的方法)和具体方法(有实现的方法)。可以包含构造函数、字段、方法、属性等成员。而抽象方法只能声明在抽象类中,其必须在非抽象的派生类中被重写。
下例为抽象类的代码案例。和上面的多态中的代码可以做一下对比,其实重点就在于函数的抽象(抽象类也可以包括非抽象方法)。
在这个例子中,MakeSound 是唯一的抽象方法(需要有 abstract 修饰)。Eat 则是一个具体方法。抽象方法需要在派生类 Dog 和 Cat 中得到具体实现。
接口(Interface)是一种完全抽象的结构,只定义成员的 signature 而不包含任何的实现,但是接口并不能包含字段、构造函数和析构函数。所有成员默认是公开的,也不能包含访问修饰符。
下例为接口的代码案例。
public interface IVehicle
{
void StartEngine(); // 启动引擎
void StopEngine(); // 停止引擎
void Drive(); // 驾驶
// 以上函数都没有具体的实现
}
public class Car : IVehicle
{
public void StartEngine()
{
Console.WriteLine("Car engine started.");
}
public void StopEngine()
{
Console.WriteLine("Car engine stopped.");
}
public void Drive()
{
Console.WriteLine("Car is driving.");
}
}
public class Boat : IVehicle
{
public void StartEngine()
{
Console.WriteLine("Boat engine started.");
}
public void StopEngine()
{
Console.WriteLine("Boat engine stopped.");
}
public void Drive()
{
Console.WriteLine("Boat is sailing.");
}
}
具体类去实现接口中的具体函数。但是,接口本身不能有任何的字段,只能有抽象方法。
对比而言,接口是被类实现的,而抽象类是被类继承的。接口只能声明方法不能有实现,而抽象类是可以有具体实现的。一个类可以实现多个接口,但是只能继承自一个类。抽象类里面可以有不同的访问级别,但是接口的所有成员都是默认 public 的。
结构体
结构体(Struct)与类的结构很类似,但是有很多本质的区别。
与类的区别
Struct 是值类型,存储在栈中,而类则是引用类型,存储在堆中。因此,Struct 的存取速度快, 容量小,只适合轻量级的对象,比如点坐标、矩形、圆、颜色等。
Struct 不能声明无参的构造函数,但是类可以。
Struct 定义时,成员不能初始化。定义结构体是,所有的成员都要自己赋值初始化。
声明结构体之后,可以用 new 创建构造对象,也可以不用 new。如果不用 new,在初始化所有字段之前,字段将保持未赋值状态,且对象不可用。
Struct 不能被继承。
Struct 不能被 static 修饰,但是类可以。
里氏替换原则
委托与事件
异步编程
Async 和 Await 关键字
协程
多线程编程
异常处理
优化
参考资料:
Comentarios