0%

C#细节

机制

委托

委托类似于函数指针:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//定义委托类型 / 定义函数指针类型
delegate void MyDelegateType(int a);

//根据类型创建实例对象
MyDelegateType my_delegate_instance=new ();
//注册指向函数
my_delegate_instance+=Func1;
my_delegate_instance+=Func2;
my_delegate_instance+=Func3;
//删除函数
my_delegate_instance-=Func3;
//唤醒函数
my_delegate_instance.Invoke(int_variable);
my_delegate_instance(int_variable);

在我们定义委托类型时,本质上编译器会生成一个继承自标准库的类,如下所示。这个类实际上会形成一个委托的闭包,其会包含调用所需的所有信息,如调用实例、待调用方法的位置。

1
2
3
4
5
6
7
8
9
10
11
12
class MyDelegateType :System.MulticastDelegate
{

//一些继承的重要字段
internal Object _target;//当委托注册了实例方法时,这个字段会填充实例对象,以便调用实例方法。
internal IntPtr _methodPtr;//指针,单播时使用,指向那个注册的方法。
private Object _invocationList;//方法链,多播时使用,指向多个单播委托实例。

//唤醒操作
public virtual void Invoke(int a);
//...其余字段和方法
}

对于单播委托,其会在_methodPtr中直接存储方法地址。然而正如上文所示,一个委托可以注册多个方法。实际上在注册多个方法后,C#会生成一个独立的委托实例,用来指向多个单播委托:

多播委托指向多个单播委托。每个单播委托才指向每个注册方法

注意,每次注册方法+=,都会new一个新的多播实例,并且原有的多播实例会被废弃,等待垃圾回收。如上图中,如果再注册一个函数Func3,多播委托A会被丢弃,然后会新new一个多播委托B,并指向三个单播委托实例。

另外在多播委托中,委托链的执行是顺序的,因此如果有一个抛出异常则会终止后续调用。

装箱拆箱

装箱实际上就是把值类型转换为Object引用类型,并且将原数据复制过去,最终返回该引用类型的地址。拆箱则是从装箱的Object类型中复制到栈上,重新构造一个栈上的值类型。 ## JIT和AOT

JIT即时编译是默认的C#编译流程,即在发布程序时首先将C#代码作为IL代码存储,直到在程序需要执行某段IL代码时,当场将IL翻译成机器码并加载运行。AOT提前编译,类似于C++的编译流程,发布时就将C#代码编译成机器码存储。

  • 由于JIT会在用户端编译,因此可以收集用户CPU和指令频率等相关信息来优化代码编译,提供比AOT理论上更高效的运行机器码。但是由于是在运行时生成新代码并执行,有些平台(IOS)不允许这种执行生成代码的权限。
  • JIT的用户端编译会大大增加应用的初始化启动时间,而AOT由于开箱即用,其启动时间有很明显的优势。同理,JIT的程序发布耗时和包体大小(不包含.NET运行时)比起AOT也有明显优势。

参考相关性能数据

标准类型

源码链接

string

不可变引用类型。注意字符串对象初始化之后就不会再修改,所有的修改操作都会new一个新的字符串对象,例如str1+str2。因此如果要构造变化的字符串,应该使用System.Text.StringBuilder。这是一个类似于List<char>的简单类,内部字段为char[],其所有修改都是基于同一个对象的引用上。

  • 为什么要设计为不可变?操作频繁,线程安全,并且如下可以留用复用。
  • 对于字面值字符串,编译器会不重复地写入到模块元数据中,以便复用,即字符串池。但是注意,其他动态创建的字符串不会引用字符串池,即ReferenceEqual为False。
  • 注意C#内char类型为2字节的Unicode码,而string的字节长度取决于你用的解码方式,例如默认UTF-8编码英文每字符1字节,中文每字符2字节。而UTF-16/Unicode编码内中英文都是每字符2字节。
  • System.Object默认实现一个ToString(),返回当前对象的真实类型名称。
  • string的GetHashCode基于字符串值计算。

两个操作有效率上的坑点:

  • String.Equals(str1 , str2, [StringComparison.Ordinal]):默认情况下,由于考虑了全球文化兼容性,string.Equals效率很慢。指定参数Ordinal使用字节比较模式,效率能够快10倍。
  • 正则表达式搜索:Regex.Match每次搜索会产生至少5000字节的堆垃圾,消耗巨大。如果要使用正则表达式最好分别构造模式串和匹配串,也能将内存消耗缩小到1/10。

List

底层为动态2倍扩容的数组。注意与纯数组Array不同的是,List<Struct>返回值也为结构体,不可引用和修改,而Array可以。

  • 由于扩容会频繁new数组,因此尽量在初始化时给定好一个合适的大小,减少扩容。注意初始化给定的只是capacity,List的Count依然为0,直接访问会发生访问越界。
  • 增删操作Insert和Remove底层都会使用Array.Copy进行数组移动,没有什么特别优化,因此每次增删复杂度为O(N)。
  • ToArray会new一个新的数组返回,而不是直接获取内部数组,因此会造成额外的内存分配。
  • Find为线性查找,Sort为快速排序。

衍生: LinkedList 是双向链表。 SortedList<TKey,TValue>通过拆分key和value两个数组来维持有序结构。 Queue 数组 + 队头队尾指针维护而成的队列。

另外由于List是引用类型,因此在Add(List)的地方需要注意这里Add的是引用,会随着List在外部的改变而改变。

Dictionary

底层为int[]? _bucketsEntry[] _entries双数组的Hash结构,是旧的Hashtable的泛型版本。

  • GetHashCode()的默认实现:引用实例用内存地址当做Key计算Hash值,而值实例使用字段值来当做key
  • Hash值冲突时使用了拉链法解决。
  • 动态扩容与List类似,但是每次容量x2后会进一步选择一个贴近的素数。
  • Remove只会重置目标条目,不会像List一样有数据移动。
  • 使用值作为Key比实例更快,因为不需要去计算内存地址。

衍生: HashSet Value和Key合体的Dictionary。 SortedSet 红黑树存储的有序HashSet。 SortedDictionary 红黑树存储的有序字典。

PriorityQueue

底层为数组表示的D叉最小堆 (TElement, TPriority)[] _nodes;

  • 插入时从底层往上冒泡插入 (和大学实现一样 hhh。
  • 删除时弹出堆顶,并由上往下逐层更新。
  • 仅.NET 6以上可用,像Unity内就用不了。