这篇笔记主要是关于 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 参数要求在方法内部必须给它一个新的值,即使传入的变量在外部已经有值,在方法内部也需要重新赋值。这意味着你不能只是对传入的值进行修改,而是要给这个变量明确地赋予一个新的值。例如,
out 和 ref 不可以用于重载。也就是说 void TestMethod(ref int a) 和 void TestMethod(out int a) 是无法被区分重载的。
String 类型
特别地,string 作为引用类型有一定的特殊性。任何 string 的修改,实际上是 new 了一个新的 string。只要 new 了一个新的引用类型,就会在堆内申请新的空间。而此时栈内的副本也会指向堆内的新对象,因此 string 会发生改变,会成为新的对象,与原来的 string 就不再有关系了。
解决方案是当我们会频繁地修改一个字符串时,我们使用 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。
装箱 / 拆箱
装箱指的是将值类型转换为 object 类型,或者由此值类型实现的任何接口类型的过程。
实际操作:
- 去 Heap 上 new 一个 Object 类的对象。
- 将值类型的数据存入该 Object 类对象中。
- 将 Heap 上创建的对象的地址返回到一个引用类型变量上。
反过来的步骤就是拆箱。拆箱是从 object 类型到值类型(或者接口类型)的显式转换。
实际操作:
- 获取已装箱的对象的地址,检查对象实例,确保它是给定值类型的装箱值。
- 将该值从实例复制到值变量类型。
装箱和拆箱会实际上产生更多的 GC。在 Unity 的装箱操作中,会在 Heap 上分配一个 System.Object 类型的引用来封装,其对应的缓存就会产生内存垃圾。即使代码中没有直接的对变量进行装箱操作,很有可能函数中或者第三方插件中也会存在这样的现象。我们应该尽量减少装箱拆箱操作。
方法:
使用泛型。
yield return null 而不是 yield return 0.
减少不必要的 Log。
面向对象编程
众所周知,C# 和 C++ 类似,是一门 Object Oriented Programming。对于面向对象的语言,有很多重要的特征。
封装
封装指的是通过约束代码修改数据的程度,增强数据的安全性。通过隐藏对象的内部状态和功能实现细节,只暴露必要的操作接口的过程。
在 C# 中,封装是通过使用访问修饰符来控制成员的访问级别,例如:
- public:对任何类和成员都公开,没有访问限制。
- private:仅对当前类公开。
- protected: 对当前类和其派生类公开。
- internal: 只能在包含该类的程序集中访问该类。
注意一下 protected 修饰符的行为。
public class BaseClass
{
protected int protectedField;
public void BaseMethod()
{
protectedField = 10; // 在本类中可访问
}
}
public class DerivedClass : BaseClass
{
public void DerivedMethod()
{
protectedField = 20; // 在派生类中可访问
}
}
public class AnotherClass
{
public void AnotherMethod()
{
BaseClass baseClass = new BaseClass();
// baseClass.protectedField = 30; // 这是不允许的,会导致编译错误
}
}
在这里,AnotherClass 并不是 BaseClass 的派生类,所以被 protected 修饰的 int 值类型变量 protectedField 只能被 BaseClass 和 DerivedClass 访问,而不能被 AnotherClass 访问。
继承
继承是另一个重要的特征。继承允许新创建的类(派生类)继承现有类(基类)的属性和方法。
在 C# 中,只支持单继承,也就是每个类只能继承自一个基类,但可以实现多个接口。
最简单的例子就是在 Unity 中如果我们新建一个新的脚本,它就是继承于 MonoBehavior 类的。所以新的脚本中都可以使用 MonoBehavior 里面已经定义好的函数。
public class Cube : MonoBehavior {
void Start(){}
void Update(){}
}
在这里, Cube 就是 MonoBehavior 的派生类。MonoBehavior 就是 Cube 的基类。
注意,我们在新的脚本里面写的这些 MonoBehavior 函数的具体实现,虽然看起来像是重写,但实际上它们在 MonoBehavior 类中并没有被明确地定义为 void 方法或者 abstract 方法。它们实际上是由 Unity 引擎通过特定的机制特别调用的。Unity 引擎在运行时会检查 MonoBehavior 的派生类是否存在类似于 Start(), Update() 之类的特定的函数,如果存在的话,就会在规定的时间自动调用这些新的函数;因为这是一种特殊的反射机制,不是面向对象继承,所以我们不需要下面多态中提及的 override 关键字。
在 C# 中,我们还可以通过 sealed 关键字来防止别的类继承该类;如果在方法上使用 sealed 关键字,则可以防止别的类重写该方法。这种机制叫做密封类。
多态
多态指的是同一个操作在不同的类实例上可以有不同的实现。
在 C# 中,多态是通过方法重写修饰符 override 和接口实现来实现的。它允许将派生类对象视为其基类类型的对象,同时保留其特有的行为。
这个例子可以用来解释多态。
// 基类
public class Animal
{
public virtual void MakeSound()
{
Console.WriteLine("Some sound");
}
}
// 派生类 Dog
public class Dog : Animal
{
public override void MakeSound()
{
Console.WriteLine("Bark");
}
}
// 派生类 Cat
public class Cat : Animal
{
public override void MakeSound()
{
Console.WriteLine("Meow");
}
}
class Program
{
static void Main(string[] args)
{
Animal myDog = new Dog();
Animal myCat = new Cat();
// 尽管 myDog 和 myCat 都是 Animal 类型,但它们调用了各自派生类的实现
myDog.MakeSound(); // 输出 "Bark"
myCat.MakeSound(); // 输出 "Meow"
// 也可以使用基类的类型来存储对象
Animal[] animals = new Animal[2];
animals[0] = new Dog();
animals[1] = new Cat();
foreach (var animal in animals)
{
animal.MakeSound(); // 分别输出 "Bark" 和 "Meow"
}
}
}
我们的基类是 Animal,Dog 和 Cat 都是它的派生类。基类定义了函数 MakeSound,然后在不同的派生类中,我们都可以通过 override 来重写这个函数, 使它们各自有自己的定义方式。直接通过基类调用,甚至可以自动调用派生类的 MakeSound 方法。
抽象化
在 C# 中,我们可以通过抽象类(不能被实例化的类)和接口(定义了一组没有实现的方法)来实现抽象。
其中,抽象类可以包含抽象方法(没有实现的方法)和具体方法(有实现的方法)。可以包含构造函数、字段、方法、属性等成员。而抽象方法只能声明在抽象类中,其必须在非抽象的派生类中被重写。
下例为抽象类的代码案例。和上面的多态中的代码可以做一下对比,其实重点就在于函数的抽象(抽象类也可以包括非抽象方法)。
public abstract class Animal
{
public abstract void MakeSound();
// 抽象类也可以包含非抽象方法
public void Eat()
{
Console.WriteLine("Animal is eating.");
}
}
public class Dog : Animal
{
public override void MakeSound()
{
Console.WriteLine("Bark");
}
}
public class Cat : Animal
{
public override void MakeSound()
{
Console.WriteLine("Meow");
}
}
在这个例子中,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 修饰,但是类可以。
里氏替换原则
Void 函数
委托与事件
异步编程
Async 和 Await 关键字
协程
多线程编程
异常处理
优化
参考资料:
Comments