1. C#的编译过程
第一步:源代码(.cs) → 中间语言 IL(.dll / .exe)
- 使用 C# 编译器(如
csc.exe)将源代码编译为 中间语言(IL),也称作 CIL(Common Intermediate Language)。 - 这一步是平台无关的。
第二步:IL → JIT 编译成本机机器码
当你运行程序时,CLR(Common Language Runtime)会将IL编译为当前操作系统上的机器码,这一步称为:JIT(Just-In-Time Compilation)即时编译。
也就是说,真正的本机代码(x64或ARM的机器码)是在运行时生成的。
第三步(可选):提前编译(AOT)
在一些平台(如 Unity、iOS)或使用.NET Native/.NET Core的某些模式下,也可以:使用 AOT(Ahead-Of-Time)预编译 IL → 机器码(跳过 JIT)。
这种模式可以提高启动速度,适用于移动平台、游戏或性能敏感系统。
C# 编译完成后,会得到一个 中间语言(IL) 文件,通常是以下两种形式之一:
- 可执行程序(.exe):如果项目是一个控制台程序或Windows应用。
- 类库(.dll):如果项目是一个类库项目,供其他程序引用。
这些文件都叫做程序集(Assembly),是 .NET 平台下的可执行单元。
2. C#的垃圾回收(GC,Garbage Collection)
(1)什么是GC?
GC是一种自动内存管理机制,用于自动释放程序中不再使用的对象所占用的内存。
不像C++,需要手动delete掉对象,C#会:
- 找出不再被引用的对象
- 自动释放其占用的内存
- 减少内存泄漏的可能性
GC管理的是托管堆中的对象。
托管堆:由 .NET 的 CLR(公共语言运行时)自动管理的一块堆内存空间,用来存储引用类型对象(如类的实例、数组、字符串等)。它的“托管”意思是:开发者无需手动分配和释放内存,而是由垃圾回收器(GC)自动完成内存管理。
| 特性 | 说明 |
|---|---|
| 自动分配和释放 | 创建对象时自动分配内存;不使用时GC回收 |
| 连续、紧凑分配 | 初期为提升性能,对象会按顺序分配在一块大区域中 |
| 只适用于引用类型 | 值类型通常分配在栈上(除非被装箱) |
| 有代际结构(Gen 0、1、2) | 优化 GC 性能(下面详讲) |
(2)GC的“分代收集”机制(Generational GC)
C#的GC把堆分为三代:
| 代号 | 说明 |
|---|---|
| 第0代(Gen 0) | 刚创建的新对象,生命周期通常很短 |
| 第1代(Gen 1) | 从第0代存活下来的对象 |
| 第2代(Gen 2) | 长时间存活的对象(例如全局缓存) |
为什么要分代?
大多数对象“生得快,死得也快”,把新对象放在Gen 0,这样可以频繁快速地清理它们,而不影响长生命周期的对象。
GC策略:
- GC会优先清理Gen 0,代价低、速度快。
- 如果内存压力大,才会清理Gen 1或Gen 2。
- Gen 2的GC被称为Full GC,代价较高,尽量避免频繁发生。
(3)GC的工作流程(标记-清除-压缩)
第一步:暂停世界(Stop the World)
暂停所有线程,进入GC模式。
第二步:标记阶段(Mark)
遍历对象图,标记所有仍然被引用的对象,这些对象称为“存活对象(Live Object)”。
标记从“根集合(GC Roots)”出发,典型的GC Root包括:
- 栈上活动变量(当前线程的局部变量)
- 静态字段
- CPU寄存器中的引用
- Finalizer队列中的对象
- P/Invoke的外部引用
使用图遍历算法(通常是递归或栈式 DFS/BFS)来遍历所有被引用的对象,并将它们打上“标记”。任何没有被标记的对象,就是“垃圾”对象。
比如:
- obj1被引用 → 标记obj1
- obj1引用obj2 → 标记obj2
- obj2引用obj3/obj4 → 标记obj3 和obj4
第三步:清除阶段(Sweep)
遍历堆上的所有对象,如果发现没有被标记的对象,则认为它无用,可以回收其所占的内存。
具体内容:
- GC清理未被标记的内存块,将其放回到空闲内存池。
- 标记位(或卡表)在这一步会被清除,为下一轮GC做准备。
- 这个阶段是不移动内存内容的,只是把“垃圾对象”标记为空。
第四步:压缩阶段(Compact)(仅对 Gen 2)
压缩(也称为整理)是为了消除内存碎片,将存活对象搬到一起,腾出连续的空闲空间,并更新引用地址。
在压缩过程需要:
- 将对象复制到新位置。
- 更新所有引用,包括栈上的引用、其他对象的字段引用等。这一步是最耗时的,因为涉及到大量内存搬运和引用更新。
如何优化这些阶段?
| 阶段 | 优化手段 |
|---|---|
| Mark | 写屏障(Write Barrier)优化增量标记过程 |
| Sweep | 分代收集,仅扫描Gen 0更快 |
| Compact | 跳过对短生命周期(Gen 0)压缩以提升效率 |
(4)GC 的触发时机
GC 会在以下时机触发:
- 当前代的堆空间已满(自动触发)
- 手动调用
GC.Collect()(不推荐) - 系统空闲时、分配大对象时(LOH)
(5)如何编写“GC友好”的代码(性能建议)
| 建议 | 原因 |
|---|---|
| 避免频繁创建临时对象 | 会造成 Gen 0 GC 频繁触发 |
使用StringBuilder替代字符串拼接 | 减少不必要的中间字符串 |
| 对象池化(Object Pool) | 减少新对象分配 |
| 尽量使用值类型(struct) | 小对象、无需分配到堆 |
避免手动调用 GC.Collect() | 会强制触发 Full GC,反而性能下降 |
对于 IDisposable 对象,使用 using | 及时释放非托管资源 |
(6)GC 的一些误区
| 误区 | 真相 |
|---|---|
| GC会自动清理所有内存 | 非托管内存不会被GC回收,需要手动释放 |
| GC.Collect() 可以提升性能 | 相反,会导致停顿,应避免手动调用 |
| 引用为 null,GC 会马上回收 | GC 回收是按需进行,不保证“立即”释放 |
3. 装箱和拆箱
装箱(Boxing):将一个值类型(如 int、struct)转换为引用类型(如 object、接口)。
拆箱(Unboxing):将一个已经被装箱的引用类型还原为原始值类型。
为什么需要装箱?
因为C#是一个强类型语言,当你把值类型赋值给一个object或接口变量时,编译器会将值类型“包裹在堆中的对象盒子”里,也就是进行“装箱”。
装箱和拆箱会带来额外的开销:
| 操作 | 成本 |
|---|---|
| 装箱 | 在堆上分配内存、复制值、产生 GC 压力 |
| 拆箱 | 类型检查 + 堆到栈的复制 |
| 错误拆箱 | 会抛出运行时异常 |
关键字var和dynamic
(1) var(隐式类型)
定义:
var是编译时隐式类型推断,即编译器在编译时会根据右边的表达式自动推断出具体的类型。
特点:
- 编译时确定类型:一旦推断出类型就不能更改。
- 必须立刻初始化,否则编译器无法推断类型。
- 性能和普通类型一样,无运行时开销。
- 无法赋值为
null除非类型可空(如int?)。
案例代码1:
var number = 42; // 实际是 int
var name = "ABC"; // 实际是 string
案例代码2:
var list = new List<int>(); // list 被推断为 List<int>
list.Add(1);
list.Add("hello"); // ❌ 编译错误,因为 list 是 List<int>
(2) dynamic(动态类型)
定义:
dynamic是运行时动态类型,告诉编译器“不要检查类型,运行时再决定”。
特点:
- 编译时不检查类型,编译器不会报错。
- 所有类型检查都延迟到运行时。
- 更灵活,但更容易出错。
- 会导致较多运行时开销,尤其在频繁使用时。
- 适合与 COM、Reflection、ExpandoObject、Script 等交互。
案例代码1:
dynamic x = 10;
x = "hello"; // ✅ OK
x = new List<int>(); // ✅ OK
案例代码2:
dynamic obj = "hello";
Console.WriteLine(obj.Length); // ✅ OK,运行时判断 string 有 Length
obj = 42;
Console.WriteLine(obj.Length); // ❌ 运行时报错:int 没有 Length 属性
(3) 对比
| 特性 | var | dynamic |
|---|---|---|
| 类型确定时间 | 编译时 | 运行时 |
| 类型检查 | 编译时检查 | 运行时检查 |
| 是否支持自动补全 | ✅(有类型信息) | ❌(编译器不知其成员) |
| 是否安全 | ✅ 更安全 | ❌ 容易出错 |
| 是否必须初始化 | ✅(用于推断) | ❌(可以先声明后赋值) |
| 使用场景 | 一般日常开发 | 与弱类型交互、反射、脚本语言等 |
| 性能 | 高(无额外开销) | 低(需运行时检查) |
1. List<T>, ArrayList和LinkedList<T>
(1) List<T>(泛型列表)
底层实现
- List<T>底层是一个动态数组,内部使用
T[]来存储元素。 - 当List<T>容量不足时,会扩容为原来的 2 倍,并进行数组拷贝(即创建一个新的更大的数组并复制元素)。
- List<T>是泛型集合,定义在
System.Collections.Generic中。 - 存储类型安全的元素,不需要装箱/拆箱,性能较优。
- 索引访问速度快,插入/删除速度取决于位置。
核心特性
- 动态数组(类似
T[],但大小可变)。 - 索引访问 O(1),因为底层是数组,支持
list[i]随机访问。 - 添加元素 O(1) 均摊,如果容量足够,直接添加;如果扩容,则需要 O(n) 进行数组拷贝。
- 删除/插入 O(n),尾部元素开销大,因为可能需要移动大量元素。
底层代码(简化版)
class List<T>
{
private T[] _items;
private int _size;
public void Add(T item)
{
if (_size == _items.Length) Expand();
_items[_size++] = item;
}
private void Expand()
{
int newCapacity = _items.Length * 2;
T[] newArray = new T[newCapacity];
Array.Copy(_items, newArray, _size);
_items = newArray;
}
}
适用场景
- 需要频繁随机访问元素(索引访问)。
- 元素数量变化不频繁,避免频繁扩容带来的性能开销。
- 适合顺序添加(
Add()操作快)。
(2) ArrayList(非泛型数组列表)
底层实现
ArrayList的底层也是动态数组,但它的存储类型是object,不支持泛型(即存储的是引用类型)。- 非类型安全,需要进行装箱/拆箱(boxing/unboxing),这会影响性能,且容易出错。
- 灵活,可存储不同类型。
核心特性
- 底层是
object[],所有元素都是object,无法使用泛型。 - 索引访问 O(1),和
List<T>相同。 - 添加/删除 O(n),因为数组可能需要扩容或移动元素。
- 装箱/拆箱问题:存储值类型时,需要进行装箱(Boxing),取出时再进行拆箱(Unboxing),性能较差。
装箱/拆箱示例
ArrayList arrayList = new ArrayList();
arrayList.Add(10); // 10 被装箱成 object
int num = (int)arrayList[0]; // 取出时需要拆箱
适用场景
- 几乎没有适用场景,因为
List<T>完全可以取代ArrayList。 - 老旧代码可能仍然使用
ArrayList,但新代码应该使用List<T>。
(3) LinkedList<T>
底层实现
LinkedList<T>底层是一个双向链表,每个节点存储:- 当前元素
Value - 指向前一个节点的
Previous - 指向后一个节点的
Next
- 当前元素
- 节点之间通过指针连接,非连续内存。但是由于存在指针,内存开销大。
- 每个节点是
LinkedListNode<T>。 - 不像
List<T>需要扩容,它的元素是动态分配的节点对象。
核心特性
- 插入/删除O(1)(只需修改指针,不需要移动元素)。
- 索引访问O(n)(必须遍历链表),不支持随机访问。
- 比
List<T>更适合频繁的插入/删除操作。
底层代码(简化版)
class LinkedListNode<T>
{
public T Value;
public LinkedListNode<T> Next;
public LinkedListNode<T> Previous;
}
class LinkedList<T>
{
private LinkedListNode<T> head;
private LinkedListNode<T> tail;
public void AddLast(T value)
{
var newNode = new LinkedListNode<T> { Value = value };
if (tail == null) head = tail = newNode;
else
{
tail.Next = newNode;
newNode.Previous = tail;
tail = newNode;
}
}
}
适用场景
- 频繁插入/删除元素,尤其是在列表的中间位置。
- 不需要索引访问(链表的随机访问效率低)。
- 需要快速添加/删除元素(如队列、双端队列、缓存系统)。
(4) 区别
| 对比项 | List<T> | ArrayList | LinkedList<T> |
|---|---|---|---|
| 底层结构 | 动态数组 | 动态数组 | 双向链表 |
| 类型安全 | ✅ 是(支持泛型) | ❌ 否(存储 object) | ✅ 是(支持泛型) |
| 索引访问 | O(1) | O(1) | O(n)(需要遍历) |
| 插入/删除(中间位置) | O(n)(需要移动元素) | O(n) | O(1)(修改指针) |
| 添加元素(尾部) | O(1)(均摊) | O(1)(均摊) | O(1) |
| 扩容机制 | 容量翻倍(可能 O(n) 拷贝) | 容量翻倍 | 无扩容,动态分配 |
| 装箱/拆箱 | ❌ 无 | ✅ 需要装箱/拆箱 | ❌ 无 |
| 适用场景 | 需要索引访问快,但插入/删除不频繁 | 不推荐(泛型替代) | 需要频繁插入/删除(如队列、缓存) |
2. 抽象类和接口
(1)抽象类(abstract class)
- 是一个类,可以包含 字段、属性、构造函数、非抽象方法 和 抽象方法。
- 不能直接实例化,必须由子类继承并实现其抽象方法。
- 可以有构造函数,用于初始化基类的状态。
- 适用于具有 共同行为的类,可以提供默认实现。
abstract class Animal
{
protected string name;
public Animal(string name) => this.name = name;
public abstract void MakeSound(); // 抽象方法,必须由子类实现
public virtual void Eat() // 普通方法,可以被子类重写
{
Console.WriteLine($"{name} is eating.");
}
}
(2)接口(interface)
- 不能包含字段、构造函数,只能包含方法、属性、事件、索引器的声明。
- 所有方法默认为
public abstract,不能包含private或protected。 - 一个类可以实现多个接口(多重继承)。
- 适用于行为的定义,而不提供具体实现。
interface IAnimal
{
void MakeSound(); // 只能定义方法,不能提供实现
void Eat();
}
(3)主要区别
| 特性 | 抽象类(abstract class) | 接口(interface) |
|---|---|---|
| 是否可以包含方法实现 | ✅ 可以(普通方法或 virtual 方法) | ❌ 不能(C# 8.0+ 可有 default 方法) |
是否可以有字段(field) | ✅ 可以(如 protected string name;) | ❌ 不可以(不能定义字段) |
| 是否可以有构造函数 | ✅ 可以(用于初始化) | ❌ 不可以 |
| 是否支持多重继承 | ❌ 不支持(只能继承一个类) | ✅ 支持(一个类可以实现多个接口) |
| 方法的访问修饰符 | ✅ 可以是 public / protected / private / internal | ❌ 全部默认为 public |
| 适用于何种情况 | 适用于 “is-a” 关系(某种类型的基类) | 适用于 “can-do” 关系(某种能力的集合) |
ref 和 out 都是 C# 中的参数修饰符,用于按引用传递参数,但它们的行为和使用场景有所不同。
3. ref和out的主要区别
(1)ref:引用传递
使用场景
- 当你想让方法修改传入的参数值,并且调用方法之前参数必须有初始值。
ref让调用者和方法共享同一块内存,方法内部的修改会影响外部变量。
示例
using System;
class Program
{
static void ModifyRef(ref int num)
{
num *= 2; // 直接修改外部变量
}
static void Main()
{
int value = 10; // 变量必须初始化
ModifyRef(ref value);
Console.WriteLine(value); // 输出:20
}
}
解释:
ModifyRef(ref value)传递的是变量的引用,方法内部修改后,value也会改变。
底层机制
ref传递的是变量的地址(指针),而不是值本身。- 等效于C++的
int&(引用)或指针传递int*。
(2)out:输出参数
使用场景
- 当你想让方法返回多个值,但方法调用前参数不需要初始化。
示例
using System;
class Program
{
static void Divide(int dividend, int divisor, out int quotient, out int remainder)
{
quotient = dividend / divisor; // ❗ `out` 变量必须赋值
remainder = dividend % divisor;
}
static void Main()
{
int q, r; // ❌ 不需要初始化
Divide(10, 3, out q, out r);
Console.WriteLine($"商: {q}, 余数: {r}"); // 输出:商: 3, 余数: 1
}
}
解释:
Divide()使用out参数返回两个值,方法内部必须对quotient和remainder赋值。Main()中的q和r无需初始化,但调用后它们的值会被赋予新的数据。
底层机制
out变量在方法内部必须被赋值,否则编译器报错。- 适用于返回多个值的情况,类似 C++ 的
std::tie()或out形参。
(3)ref和out的核心区别
| 区别点 | ref | out |
|---|---|---|
| 方法调用前是否必须初始化? | ✅ 必须有初始值 | ❌ 可以是未初始化变量 |
| 方法内部是否必须赋值? | ❌ 可以不赋值 | ✅ 必须赋值,否则编译错误 |
| 使用场景 | 修改已有值,并返回修改结果 | 返回额外结果,适用于返回多个值 |
| 底层机制 | 传递变量的地址,可以读写 | 传递变量的地址,但方法必须写入 |
(4)ref与out的特殊用法
结合TryParse()方法
C# 的 TryParse() 方法使用 out 参数来返回转换是否成功和转换后的值:
using System;
class Program
{
static void Main()
{
string input = "123"; //out变量可在方法调用时声明
if (int.TryParse(input, out int result))
{
Console.WriteLine($"转换成功: {result}"); // 输出: 123
}
else
{
Console.WriteLine("转换失败");
}
}
}
解释:
out int result允许在调用TryParse()时直接声明变量。- 适用于尝试转换字符串到
int、double等数据类型。
4. StringBuilder
在 C# 中,StringBuilder是一个用于操作字符串的类,它的主要作用是提高字符串操作(如拼接、修改)时的性能。
(1)为什么需要StringBuilder?
在 C# 中,string 是不可变类型(Immutable),这意味着每次修改字符串时,都会创建一个新的字符串对象,而原来的字符串会被丢弃(等待垃圾回收)。这种行为在进行大量字符串拼接时会导致性能下降。
示例:普通string连接
string result = "";
for (int i = 0; i < 10000; i++)
{
result += i.ToString(); // 每次都创建新字符串,效率低
}
- 每次拼接都会创建新的字符串对象,并导致 GC(垃圾回收)频繁回收旧对象,影响性能。
使用StringBuilder进行高效拼接
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++)
{
sb.Append(i.ToString()); // 直接修改同一个对象,效率高
}
string result = sb.ToString();
StringBuilder维护一个可变的字符缓冲区,避免了创建新的字符串对象,提高了性能。
(2)StringBuilder的底层实现
StringBuilder的底层是一个可扩展的char[] 数组(字符缓冲区),当缓冲区满了,它会自动扩展容量,而不会创建新的对象。
内部机制
StringBuilder初始时分配一个char[]数组(默认 16 个字符)。- 当数组满时,
StringBuilder会创建一个更大的数组(通常是 2 倍扩容),然后复制原有字符到新数组中。 - 这样,
StringBuilder避免了频繁创建新字符串对象,提高了性能。
示例:查看底层容量
StringBuilder sb = new StringBuilder(5); // 初始容量 5
sb.Append("Hello");
Console.WriteLine(sb.Capacity); // 输出 5
sb.Append(" World!");
Console.WriteLine(sb.Capacity); // 可能扩容到 10 或 16
- 初始容量5,如果
Append使内容超过 5,StringBuilder会自动扩容。
(3)StringBuilder常用方法
| 方法 | 作用 |
|---|---|
Append(string s) | 追加字符串 |
AppendLine(string s) | 追加字符串并换行 |
Insert(int index, string s) | 在指定索引插入字符串 |
Remove(int startIndex, int length) | 删除指定范围内的字符 |
Replace(string old, string new) | 替换字符串 |
ToString() | 转换为string |
示例
StringBuilder sb = new StringBuilder("Hello");
sb.Append(" World"); // 追加
sb.Insert(5, ","); // 插入逗号
sb.Replace("World", "C#"); // 替换
sb.Remove(6, 2); // 删除 ", "
Console.WriteLine(sb.ToString()); // 输出:HelloC#
(4)StringBuilder和string对比
| 特点 | string(不可变) | StringBuilder(可变) |
|---|---|---|
| 存储方式 | 字符串常量池 | 可扩展 char[] 数组 |
| 修改后是否创建新对象 | ✅ 是 | ❌ 否 |
| 适合场景 | 字符串较少修改 | 频繁修改(拼接、删除、插入) |
| 性能 | 较低(频繁创建新对象) | 高(修改同一对象) |
(5)何时使用StringBuilder?
- 需要大量字符串拼接时(如日志、HTML 生成)。
- 需要高效修改字符串(如大文本处理)。
- 避免
string频繁创建新对象,提升性能。
(6)总结
✅ StringBuilder用于高效修改字符串,避免 string 的不可变性导致的性能损失。
✅ 底层使用 char[] 作为缓冲区,支持自动扩容,比 string 更适合频繁修改的场景。
✅ 常用方法:Append()、Insert()、Remove()、Replace()、ToString()。
✅ 适用于高频字符串操作,如日志、HTML 生成、文本处理等。
5. 匿名方法(Anonymous Method)
匿名方法是一种没有名称的方法,可以通过delegate关键字在方法体内直接定义代码块。
- 它允许你在不单独定义方法的情况下,直接在代码中编写逻辑。
- 直接作为委托的参数传入方法中,不用另外声明一个方法。
(1)匿名方法的语法
基本语法:
delegate (参数列表) { 方法体 };
匿名方法可作为委托的参数
using System;
delegate void Greet(string name);
class Program
{
static void Main()
{
SayHello(delegate(string name)
{
Console.WriteLine("你好, " + name);
});
}
static void SayHello(Greet greetMethod)
{
greetMethod("张三");
}
}
(2)匿名方法 vs 普通方法
普通方法定义:
void SayHello(string msg)
{
Console.WriteLine("Hello " + msg);
}
匿名方法定义:
delegate(string msg) { Console.WriteLine("Hello " + msg); };
它可以直接赋值给委托,而不需要单独定义方法。
(3)匿名方法的特点
- 没有方法名:它没有具体的方法名,而是直接被赋值给委托。
- 可以访问外部变量(闭包):匿名方法可以访问定义它时的外部变量。
- 只能通过委托调用:匿名方法必须赋值给委托,不能独立使用。
示例:匿名方法访问外部变量
delegate void PrintDelegate();
class Program
{
static void Main()
{
int count = 10; // 外部变量
// 匿名方法可以访问外部变量
PrintDelegate print = delegate ()
{
Console.WriteLine("Count = " + count);
};
print(); // 输出:Count = 10
}
}
匿名方法可以访问count 变量,即闭包(Closure)机制。
(4)匿名方法 vs Lambda 表达式
匿名方法是 C# 2.0 引入的,而Lambda表达式(=> 语法)是C# 3.0引入的。 Lambda 表达式是匿名方法的简化写法。
🔹匿名方法
MyDelegate myDelegate = delegate (string msg)
{
Console.WriteLine(msg);
};
🔹Lambda 表达式(更简洁)
MyDelegate myDelegate = (msg) => Console.WriteLine(msg);
Lambda 表达式更推荐使用,因为它更简洁,性能也更好。
(5)什么时候使用匿名方法?
🔹短小的回调逻辑(如事件处理、简单的计算):
button.Click += delegate { Console.WriteLine("按钮被点击!"); };
🔹不需要多次复用的代码块:
MyDelegate showMessage = delegate (string msg) { Console.WriteLine(msg); };
✅ 避免定义单独方法,使代码更紧凑。
(6)总结
| 特性 | 匿名方法 | 普通方法 |
|---|---|---|
| 是否有方法名 | ❌ 没有 | ✅ 有 |
| 是否可以访问外部变量 | ✅ 可以 | ❌ 不能 |
| 代码结构 | 简洁,适用于局部 | 适用于全局逻辑 |
| 是否可以被复用 | ❌ 不能 | ✅ 适合复用 |
| 是否推荐 | 🚀 推荐改用 Lambda | ✅ 适用于复杂逻辑 |
✅ 结论:
- 匿名方法适用于 短小、局部的代码块,如 事件处理、回调函数。
- 但现代 C# 更推荐使用 Lambda 表达式(
=>),因为它更简洁!
6. 委托(delegate)
(1)什么是委托?
委托是一种类型安全的函数指针,可以存储方法的引用,并在需要时调用这些方法。
它允许方法作为参数传递,广泛用于事件处理、回调函数和异步编程等场景。
委托本质上是一个引用方法的对象,它可以指向一个或多个方法,并在需要时调用这些方法。
简单理解:
- 委托类似于 C/C++ 的函数指针,但类型安全。
- 委托可以存储静态方法或实例方法的引用。
- 委托可以指向多个方法(多播委托)。
- 委托在事件和回调函数中非常常用。
(2)委托的基本语法
定义委托:在C#中,委托是一个引用方法的类型,通常使用delegate关键字来定义。
// 声明一个委托,它可以引用任何返回 void、带有一个 string 参数的方法
public delegate void MyDelegate(string message);
注意:委托的方法签名必须与其指向的方法完全匹配。
使用委托:
using System;
class Program
{
// 1️⃣ 定义委托
delegate void MyDelegate(string msg);
// 2️⃣ 定义方法,与委托的签名匹配
static void ShowMessage(string message)
{
Console.WriteLine("Message: " + message);
}
static void Main()
{
// 3️⃣ 创建委托实例并指向方法
MyDelegate myDelegate = new MyDelegate(ShowMessage);
// 4️⃣ 通过委托调用方法
myDelegate("Hello, Delegate!");
}
}
运行结果:
Message: Hello, Delegate!
委托的使用步骤:
- 声明委托
MyDelegate - 定义方法
ShowMessage - 创建委托实例 并指向方法
- 调用委托
(3)多播委托(Multicast)
委托可以一次调用多个方法(即“多播”),使用 += 添加方法,-= 移除方法。
演示代码:
using System;
class Program
{
delegate void MyDelegate(string msg);
static void Method1(string message)
{
Console.WriteLine("Method1: " + message);
}
static void Method2(string message)
{
Console.WriteLine("Method2: " + message);
}
static void Main()
{
MyDelegate myDelegate;
// 多播委托:一个委托调用多个方法
myDelegate = Method1;
myDelegate += Method2;
// 调用委托
myDelegate("Hello!");
// 移除一个方法
myDelegate -= Method1;
myDelegate("Hello Again!");
}
}
运行结果:
Method1: Hello!
Method2: Hello!
Method2: Hello Again!
使用方法总结:
+=添加方法,可以让委托指向多个方法。-=移除方法,如果所有方法都被移除,委托变为null。
(4)委托作为参数(回调函数)
委托可以作为方法参数,即:回调函数(Callback)。
演示代码:
using System;
class Program
{
// 定义委托
delegate void PrintDelegate(string message);
// 方法:接收委托作为参数
static void PrintMessage(PrintDelegate printMethod, string msg)
{
printMethod(msg); // 调用传入的方法
}
static void ShowConsole(string message)
{
Console.WriteLine("Console: " + message);
}
static void Main()
{
// 传递方法作为参数
PrintMessage(ShowConsole, "Hello, Callback!");
}
}
运行结果
Console: Hello, Callback!
总结:
PrintDelegate委托作为参数,可以让方法PrintMessage调用不同的实现。
(5) 内置委托:Action、Func、Predicate
C# 提供了内置委托,可以避免手动声明委托类型。
Action<T>
- 代表返回void的委托,即没有返回值。
- 最多可以有 16 个参数,即16种重载。
Action<string> print = Console.WriteLine;
print("Hello, Action!"); // 直接调用
Func<T, TResult>
- 代表有返回值的委托
- 最后一个泛型参数是返回类型
Func<int, int, int> add = (a, b) => a + b;
Console.WriteLine(add(3, 5)); // 输出 8
Predicate
- 专门用于返回bool的委托
Predicate<int> isEven = num => num % 2 == 0;
Console.WriteLine(isEven(4)); // 输出 True
更多例子:
Action<string> log = (msg) => Console.WriteLine(msg);
Func<int, int, int> add = (a, b) => a + b;
(6)事件与委托
什么是event?
- event是一种特殊的委托封装器,它用于限制对委托的访问权限。
- 事件只能由定义它的类(发布者)触发(invoke),而外部只能订阅(+=)或取消订阅(-=)。
换句话说:
delegate是裸的函数指针event是加了“封装安全”的delegate
案例代码1:
// 声明委托类型
public delegate void MyEventHandler(string msg);
// 发布者类
public class Publisher
{
// 声明事件
public event MyEventHandler OnMessage;
public void SendMessage(string text)
{
// 触发事件
OnMessage?.Invoke(text);
}
}
// 订阅者类
public class Subscriber
{
public void ShowMessage(string msg)
{
Console.WriteLine("Received: " + msg);
}
}
使用方法:
Publisher p = new Publisher();
Subscriber s = new Subscriber();
p.OnMessage += s.ShowMessage;
p.SendMessage("Hello Event!");
输出:
Received: Hello Event!
为什么要用event封装一次?
如果将委托公开,那么可能会将它绑定的所有方法覆盖掉:
public MyDelegate OnSomething;
p.OnSomething = null; // 覆盖掉全部订阅者,危险!
而如果使用event封装,那么外部只能单独订阅或取消订阅,来修改绑定数量:
public event MyDelegate OnSomething;
p.OnSomething += Foo; // 订阅
p.OnSomething -= Foo; // 取消订阅
案例代码2:用于消息通知机制,如按钮点击。
using System;
class Button
{
public delegate void ClickHandler(); // 定义事件委托
public event ClickHandler OnClick; // 事件
public void Click()
{
if (OnClick != null) OnClick(); // 触发事件
}
}
class Program
{
static void Main()
{
Button button = new Button();
// 订阅事件
button.OnClick += () => Console.WriteLine("按钮被点击!");
button.Click(); // 触发事件
}
}
输出:
按钮被点击!
总结:
event关键字限制委托,只能在类内部触发,外部只能订阅/取消。
(7)委托 vs 接口
| 比较 | 委托 | 接口 |
|---|---|---|
| 用途 | 事件、回调、异步调用 | 代码复用、抽象方法 |
| 适用场景 | 动态方法调用 | 结构化 OOP 设计 |
| 方法个数 | 可以多个(多播) | 通常多个 |
| 绑定方法 | 运行时绑定 | 编译时绑定 |
(8)总结
委托的特点:
- 是类型安全的函数指针,存储方法引用。
- 允许回调、事件处理、多播等功能。
- 多播委托(
+=/-=)可以指向多个方法。 - 内置委托(
Action、Func、Predicate)简化代码。 - 事件(Event) 本质上是基于委托的通知机制。
什么时候用委托?
- 事件驱动(Event) 机制
- 回调函数(Callback)
- 动态方法绑定

Leave a comment