CodeGym /课程 /C# SELF /委托:进阶场景

委托:进阶场景

C# SELF
第 54 级 , 课程 3
可用

1. 回调和异步编程

回调 (callback) — 把一个方法当作参数传入,在某个操作完成后调用它。经常用于异步操作、定时器、数据处理和用户界面。

示例 1: 带回调的异步操作

假设有个应用,用户输入请求,结果稍后才到(比如从网络)。拿到数据后我们想更新界面。


// 回调委托
public delegate void DataReceivedHandler(string result);

// 异步下载数据的机制(模拟)
public void DownloadDataAsync(DataReceivedHandler callback)
{
    // 假设下载需要时间(通过定时器模拟)
    Task.Delay(1000).ContinueWith(_ =>
    {
        string data = "搜索结果:<数据>";
        callback(data); // 调用回调委托
    });
}

// 使用:
DownloadDataAsync(result =>
{
    Console.WriteLine("收到: " + result);
});

这个思路能让你把拿数据的机制和拿到数据后干啥完全分开,代码更灵活。

2. 把委托当作方法参数:策略和比较器

常见需求:允许用户把“逻辑”(函数)传进你的方法,这样就能自定义如何比较、过滤或转换元素。

示例 2: 通过委托实现 Strategy 模式

比如我们有一个排序实现,但想让用户能按名字、按日期、按大小等不同方式排序。


public delegate bool CompareFunc(int a, int b);

public void BubbleSort(int[] arr, CompareFunc compare)
{
    for (int i = 0; i < arr.Length; i++)
    {
        for (int j = 0; j < arr.Length - 1; j++)
        {
            if (compare(arr[j], arr[j + 1]))
            {
                // 交换
                int temp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = temp;
            }
        }
    }
}

// 从大到小排序的比较函数
CompareFunc descending = (a, b) => a < b;

// 使用
int[] numbers = { 3, 1, 4, 2 };
BubbleSort(numbers, descending);
Console.WriteLine(string.Join(", ", numbers)); // 输出: 4, 3, 2, 1

这是在不改源代码的情况下把自己的“策略”注入到别人代码里的通用做法。

3. 匿名方法、lambda 表达式和委托

随着 C# 发展,为每个小功能都写一个类或方法很麻烦。幸运的是有匿名方法和 lambda,可以在“飞行中”创建委托。

示例 3: 把 lambda 当作委托


Func<int, int, int> operation = (x, y) => x * y;
int result = operation(3, 5); // 15

示例 4: 根据名字选择操作(switch + Delegates)


Func<int, int, int> op;
string userInput = "sum"; // "sub", "mul", "div"

switch (userInput)
{
    case "sum": op = (a, b) => a + b; break;
    case "sub": op = (a, b) => a - b; break;
    case "mul": op = (a, b) => a * b; break;
    case "div": op = (a, b) => a / b; break;
    default: throw new Exception("未知操作!");
}
Console.WriteLine(op(6, 2));

别忘了验证输入——委托能提升灵活性和可读性。

4. 委托和处理链(“责任链”)

因为委托支持多路广播,你可以很容易地搭建处理链。

示例 5: 过滤器链

假设我们有一堆“过滤器”,要依次处理字符串。


public delegate string StringFilter(string input);

string RemoveDigits(string input) => new string(input.Where(ch => !char.IsDigit(ch)).ToArray());
string ToUpper(string input) => input.ToUpper();

StringFilter filters = RemoveDigits;
filters += ToUpper;

// 委托会让字符串通过所有过滤器
string text = "你好123";
foreach (StringFilter filter in filters.GetInvocationList())
{
    text = filter(text);
}
Console.WriteLine(text); // 输出: "你好"

注意:如果直接调用 filters(text),只会返回最后一个处理器的值,而不是整个链的结果!如果你需要“传值”给后续处理器,像上面示例那样用 GetInvocationList() 逐个调用。

5. 委托用于动态绑定运行时行为

以前要替换行为常常需要额外的类和接口。用委托和 lambda,可以把很多“轻量级”的多态用函数表示。

示例 6: 带动态命令的机器人行为


public class Robot
{
    public event Action<string>? OnCommandReceived;

    public void ReceiveCommand(string command)
    {
        OnCommandReceived?.Invoke(command);
    }
}

// 使用:
var robot = new Robot();

robot.OnCommandReceived += cmd => Console.WriteLine($"机器人执行: {cmd}");
robot.OnCommandReceived += cmd =>
{
    if (cmd == "开机")
        Console.WriteLine("系统加载中...");
};
// 试试
robot.ReceiveCommand("开机");
robot.ReceiveCommand("向前移动");

这种做法常用于测试、原型、DI(dependency injection)容器,以及通过参数传递业务逻辑。

6. 委托作为对状态的订阅

假设有个类保存某个状态,状态变化时想通知所有订阅者,用委托(和事件)很方便实现。

示例 7: 带变化订阅的类


public class Notifier<T>
{
    private T _value = default!;
    public event Action<T>? ValueChanged;

    public T Value
    {
        get => _value;
        set
        {
            if (!Equals(_value, value))
            {
                _value = value;
                ValueChanged?.Invoke(_value);
            }
        }
    }
}

// 使用:
var intValue = new Notifier<int>();
intValue.ValueChanged += v => Console.WriteLine($"新值: {v}");
intValue.Value = 5; // 触发事件
intValue.Value = 10;

这个思路就是“极简版响应式编程”,是 MVVM、data binding 和很多现代 UI 框架的基础。

7. 委托、闭包和词法作用域

lambda 和匿名方法可以捕获外部上下文的变量(closure)。这很方便,但也会带来一些意外的问题。

示例 8: 捕获循环变量的陷阱


Action[] actions = new Action[3];

for (int i = 0; i < 3; i++)
{
    actions[i] = () => Console.WriteLine(i);
}

foreach (var a in actions)
    a(); // 会打印三次 3 (!)

为什么?因为闭包引用的是同一个变量 i,循环结束后它等于 3。如果你想记住当时的 0、1、2,可以这样写:


for (int i = 0; i < 3; i++)
{
    int loopValue = i; // 保存当前值
    actions[i] = () => Console.WriteLine(loopValue);
}

这样就按预期工作了。闭包陷阱是新手用 lambda 时最常犯的错误之一!

8. 组合委托

多路委托(multicast)内部维护一个方法列表,你可以用 (+=) 添加,(-=) 删除处理器。

特点:按引用和签名删除


void Handler1() => Console.WriteLine("1");
void Handler2() => Console.WriteLine("2");

Action a = Handler1;
a += Handler2;
a -= Handler1; // 会只留下 Handler2
a?.Invoke();   // 会输出 "2"

示例:动态管理处理器


Action a = Handler1;
a += Handler1;
a -= Handler1; // 现在列表中还剩下一个 Handler1!

9. 委托的可扩展性与控制反转(IoC)

在大型应用中,组件常需要“调用”外部代码但保持解耦。委托能把扩展、插件和回调接入进来而不引入 tight coupling。

示例:在构造函数中注入行为


public class Greeter
{
    private readonly Func<string> _getName;

    public Greeter(Func<string> getName)
    {
        _getName = getName;
    }

    public void Greet() => Console.WriteLine($"你好, {_getName()}!");
}

// 注入不同的行为:
var greeter1 = new Greeter(() => "Anya");
var greeter2 = new Greeter(() => DateTime.Now.ToShortTimeString());

greeter1.Greet(); // 输出: "你好, Anya!"
greeter2.Greet(); // 输出: "你好, 14:35!"

在真实项目中,这种方式有助于编写可测试且易维护的代码。

10. 实用细节

委托在标准接口和 LINQ 中

如果你用 LINQ、集合或异步,你一定会遇到委托。

  • 很多方法比如 List<T>.Find, Array.Sort, Where, Select 都接受委托(Func<T, bool>, Comparison<T> 等)。
  • LINQ 方法让你传入过滤、转换、聚合逻辑——不用额外创建类。

示例:对象排序的比较器


var people = new[] { "Ivan", "Maria", "Petr" };
Array.Sort(people, (a, b) => a.Length.CompareTo(b.Length));
Console.WriteLine(string.Join(", ", people)); // Ivan, Petr, Maria

委托与柯里化(部分应用参数)

用匿名方法/lambda,你可以“固定”部分参数,得到新函数。

示例:部分应用


Func<int, int, int> sum = (x, y) => x + y;

// 创建一个总是加 10 的函数
Func<int, int> add10 = y => sum(10, y);

Console.WriteLine(add10(5)); // 15

委托比较的特点

在 C# 中,如果两个委托的调用列表(invocation list)相同,就可以比较相等(==)。


void Handler1() { }
void Handler2() { }

Action a1 = Handler1;
Action a2 = Handler1;
Console.WriteLine(a1 == a2); // True

Action a3 = Handler1; a3 += Handler2;
Action a4 = Handler1; a4 += Handler2;
Console.WriteLine(a3 == a4); // True

但如果委托是基于匿名方法或 lambda 构建的,比较的是实例本身。

委托的序列化

委托可以序列化,但只有当它们指向的方法在可序列化的类中且所有类型可用时才行。从 .NET 8 开始,BinaryFormatter 默认被禁用且被视为过时,在未来版本可能被移除;在生产场景中很少使用委托序列化。

委托和事件的区别:什么时候用哪个?

  • 委托是类型/变量,可以被显式调用。
  • 事件(event)限制了对委托的访问:外部只能订阅/取消订阅(+=/-=),而只能在类内部触发调用。
  • 事件总是某种委托类型,但不是所有委托都是事件。

如何选择?如果你希望逻辑能在类外定义,用委托。如果你要控制订阅/取消并保护内部变量,用事件。

11. 使用委托时的常见错误

错误 1:混淆委托和事件。
把公开的委托字段(public Action MyAction;)当作事件来用会破坏封装。外部代码可能意外或恶意地把所有订阅者覆盖掉(instance.MyAction = null;)或者直接调用它们,这会破坏类的逻辑。应使用 public event Action MyAction; 来保护接口。

错误 2:对多路委托返回值处理不当。
如果委托有返回值(比如 Func<string, int>),直接调用(myDelegate("test"))只会返回链中最后一个方法的结果。要获取所有订阅者的结果,需要遍历调用列表,使用 GetInvocationList()


// 遍历所有订阅者并获取结果
var list = myDelegate.GetInvocationList();
foreach (var d in list)
{
    var r = ((Func<string, int>)d)("test");
    Console.WriteLine(r);
}

错误 3:在闭包中捕获循环变量。
新手常见陷阱:在 for 循环中创建的 lambda 会捕获迭代器变量本身,而不是当次的值。


// 错误示例:所有 action 都会打印最后的 i 值
for (int i = 0; i < 3; i++) 
{
    actions[i] = () => Console.WriteLine(i);
}

// 正确示例:为每次迭代创建局部副本
for (int i = 0; i < 3; i++) 
{
    int copy = i;
    actions[i] = () => Console.WriteLine(copy);
}

错误 4:引起内存泄漏。
如果实例方法订阅了一个长生命周期对象的委托但没有取消订阅,就会泄漏。长生命周期对象会持有对订阅者的引用,GC 无法回收它。尤其是在生命周期有限的类(比如 UI 组件)里,注意取消订阅。

评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION