WAYNETS.ORG

Game and Program

C#知识总结

作者:

发表于

Context Polling System Post-Processing Renderer

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) 对比

特性vardynamic
类型确定时间编译时运行时
类型检查编译时检查运行时检查
是否支持自动补全✅(有类型信息)❌(编译器不知其成员)
是否安全✅ 更安全❌ 容易出错
是否必须初始化✅(用于推断)❌(可以先声明后赋值)
使用场景一般日常开发与弱类型交互、反射、脚本语言等
性能高(无额外开销)低(需运行时检查)

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>ArrayListLinkedList<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,不能包含 privateprotected
  • 一个类可以实现多个接口(多重继承)。
  • 适用于行为的定义,而不提供具体实现
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” 关系(某种能力的集合)

refout 都是 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 参数返回两个值,方法内部必须对 quotientremainder 赋值。
  • Main() 中的 qr 无需初始化,但调用后它们的值会被赋予新的数据。

底层机制

  • out 变量在方法内部必须被赋值,否则编译器报错。
  • 适用于返回多个值的情况,类似 C++ 的 std::tie()out 形参。

(3)ref和out的核心区别

区别点refout
方法调用前是否必须初始化?必须有初始值可以是未初始化变量
方法内部是否必须赋值?可以不赋值必须赋值,否则编译错误
使用场景修改已有值,并返回修改结果返回额外结果,适用于返回多个值
底层机制传递变量的地址,可以读写传递变量的地址,但方法必须写入

(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()时直接声明变量
  • 适用于尝试转换字符串到intdouble等数据类型。

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)匿名方法的特点

  1. 没有方法名:它没有具体的方法名,而是直接被赋值给委托。
  2. 可以访问外部变量(闭包):匿名方法可以访问定义它时的外部变量。
  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!

委托的使用步骤:

  1. 声明委托MyDelegate
  2. 定义方法ShowMessage
  3. 创建委托实例指向方法
  4. 调用委托

(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)总结

委托的特点

  • 类型安全的函数指针,存储方法引用
  • 允许回调、事件处理、多播等功能。
  • 多播委托+= / -=)可以指向多个方法。
  • 内置委托ActionFuncPredicate)简化代码。
  • 事件(Event) 本质上是基于委托通知机制

什么时候用委托?

  • 事件驱动(Event) 机制
  • 回调函数(Callback)
  • 动态方法绑定

Leave a comment