这篇笔记主要是关于 C# 语言的。完整了解 Unity 的特性必须要从 C# 语言开始。这一部分虽然非常八股文,但是能够很好地帮助我们提高程序的性能,了解程序的行为。
类型
基本概念
C# 中一共分为两种类型。
值类型:int, bool, float, char, struct, enum
引用类型: string, object, delegate, interface, class, array
他们之间有以下的区别:
| 值类型 | 引用类型 |
存储位置 | 内存栈 | 内存堆 |
存储速度 | 相对快 | 相对慢 |
表示含义 | 实际的数据内容 | 指向内存堆中的指针和引用 |
内存释放 | 自动释放 | GC 释放 |
继承于 | System.ValueType | System.Object |
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 函数
参考资料:
Unity 游戏开发客户端面经——C#(初级):https://blog.csdn.net/Sea3752/article/details/127354146?spm=1001.2014.3001.5501
Comments