2327 字
12 分钟
[.NET/C#] 委托与 Lambda 表达式, 语句详解
2023-05-29

在 C# 中, 委托与 Lambda 是最常用的功能之一, 诸如多线程, 可迭代类型的 LINQ 操作, 回调, 都是借助委托完成的. 它在 C 语言对应的是函数指针, 在 C++ 中对应的是 std::function

委托 / Delegate#

在 C# 中, 委托是一种特殊的类型, 它用来存储一段逻辑(可执行的内容). 例如在声明字段或变量的时候, 我们指定类型为委托, 那么这个字段或变量就可以存下一段逻辑. 在你需要的时候, 就可以直接执行它.

void MethodA()
{
    // 这是一个方法, 里面包含一些逻辑
    // 这个方法没有参数, 也没有返回值
    Console.WriteLine("Hello world");
}

void MethodB()
{
    // 声明一个能存储下一段逻辑的变量
    // Action 表示无参数无返回值的逻辑
    Action someLogic;

    // 为其赋值, 可以直接将方法赋值过去
    someLogic = MethodA;

    // 调用这段逻辑
    // 因为它存储的就是 MethodA, 所以等同于调用 MethodA
    someLogic.Invoke();
}

事实上, 在使用委托之前, 我们需要定义一个委托类型, 在上述代码中使用的 Action, 就是系统预定义好的一个委托类型. 如果我们需要为带有特定参数, 特定返回值的一段逻辑定义一个类型, 可以使用 delegate 关键字.

// 定义一个无参数, 无返回值委托类型
// 其中, MyDelegate 是委托类型名, public 是访问修饰
public delegate void MyDelegate();

// 定义一个带有 int 参数, 返回值为 string 的委托类型
public delegate string MyDelegate2(int);

// 定义一个带有 int, float 参数, 返回值为 string 的委托类型
public delegate string MyDelegate3(int, float);

// 如果你想, 你也可以为委托类型定义中的参数写上名字
// 这个名字也只是为了可读性, 让用户知道这个参数是做什么用的
// 除此之外, 就么有什么别的用处了
public delegate string MyDelegate4(int value1, float value2);

要使用定义好的委托类型, 和最开始给出的示例代码一样, 只需要把他当成一个普通类型直接使用即可.

// 定义一个 someLogic 变量, 类型为 MyDelegate
// 同时, 为其赋初始值 MethodA
MyDelegate someLogic = MethodA;

你可以通过委托类型来存下任何你想要存下的逻辑, 只要类型和对应逻辑的参数返回值是匹配的即可. 下面是定义一个委托类型, 然后将 Console.WriteLine 这个方法存入变量的完整示例代码.

namespace TestConsole
{
    class Program
    {
        // 定义参数为 string, 无返回值的委托类型
        delegate void MyDelegate(string text);
        
        static void Main(string[] args)
        {
            // 定义变量
            MyDelegate someLogic;
            
            // 为其赋值
            // 因为 Console.WriteLine 有匹配的重载, 所以可以存入
            someLogic = Console.WriteLine;
        }
    }
}

不仅是诸如 Console.WriteLine 的静态方法可以存入, 非静态的方法也是可以存入的. 下面是一段将实例方法存入变量的完整示例代码.

namespace TestConsole
{
    class TestClass
    {
        public void TestMethod()
        {
            var x = 1;
            var y = 2;
            var z = x + y;
            System.Console.WriteLine(z);
        }
    }

    delegate void TestDelegate();

    class Program
    {
        static void Main(string[] args)
        {
            // 创建类型的实例
            TestClass ins = new TestClass();

            // 定义委托变量
            TestDelegate someLogic;

            // 将实例的方法存入到委托变量
            someLogic = ins.TestMethod;
        }
    }
}

匿名函数 / Lambda#

在上面的例子中, 我们将方法存入了委托变量中, 但如果每一段逻辑都需要定义一个方法, 那就太麻烦了. 使用 Lambda 表达式就是一个很不错的选择.

Action someLogic;

someLogic = () =>
{
    Console.WriteLine("Hello world");
};

在上面的代码中, 我们定义了一个无参无返回值的委托变量, 并使用了一个奇怪的语法为其赋值. 这个语法, 就是 Lambda 表达式. 其中括号表示参数, => 是 lambda 的符号, 后面的大括号是逻辑的内容. 因为无参数, 所以括号内是空的.

Lambda 语句的标准语法如下:

(输入参数) => { <多条语句> }

下面我们以一个执行加法计算的 Lambda 语句为例, 了解 Lambda 语句的具体写法.

// 定义一个传入两个 int 参数, 返回一个 int 结果的委托变量
Func<int, int, int> someLogic;

// 为委托变量赋值
someLogic = (int a, int b) =>
{
    return a + b;
};

// 执行委托, 并打印返回值
// 输出内容是 114514
Console.WriteLine(someLogic.Invoke(114000, 514));

Lambda 语句可以理解为一个没有名字的方法, 在上面的代码中, 我们所写的 Lambda 表达式传入两个 int, 并使用 return 返回他们的相加结果. 这个 Lambda 语句直接被赋值到 someLogic 变量中, 并在后续的代码中, 通过 someLogic 调用刚刚缩写的逻辑.

事实上, 因为编译器知道 someLogic 的委托类型需要传入两个 int, 所以在后面直接赋值的 Lambda 语句中, 参数的类型是可以省略的. 编译器会自动推导他们的类型.

// 省略参数的类型, 直接写参数名
someLogic = (a, b) =>
{
    return a + b;
};

如果 Lambda 语句的传入参数只有一个, 那么括号也是可以被省略的.

// 定义一个传入参数为 int, 无返回值的委托变量
Action<int> someLogic;

// 为其赋值
someLogic = integer =>
{
    Console.WriteLine(integer);
};

如果你不需要使用传入的参数, 可以使用 _ 将其丢弃:

// 定义一个传入参数为 int, 无返回值的委托变量
Action<int> someLogic;

// 为其赋值
// 这里的表达式没有使用传入的参数
someLogic = _ => Console.WriteLine("Hello world");

如果 Lambda 语句的方法体只有一个语句, 那么大括号是可以被省略的. 此时 Lambda 语句就叫做 Lambda 表达式了.

// 定义一个传入参数为 int, 无返回值的委托变量
Action<int> someLogic;

// 为其赋值
someLogic = integer => Console.WriteLine(integer);

如果你要在 Lambda 中使用 await, 可以在 Lambda 前添加 async 关键字, 使用方式和普通的方法是一样的.

// 定义一个无传入参数, 返回值为 Task<int> 的委托变量
Func<Task<int>> someLogic;

// 为其赋值, 并使用 async Lambda
someLogic = async () =>
{
    // 延时 1s
    await Task.Delay(1000);

    // 返回值
    return 114514;
}

如果只是单纯调用一个委托, 那么也可以不需要使用 Invoke, 直接把他当成方法, 后跟括号和参数即可.

Action<string> someLogic = str => Console.WriteLine(str);

someLogic.Invoke("Hello world");

// 等同于上面的 Invoke
someLogic("Hello world");

捕获 / Capture#

在使用 Lambda 语句/表达式的时候, Lambda 可以捕获到外部的变量, 字段, 或属性, 简单来讲就是, 你可以直接在 Lambda 内使用 Lambda 外的任何东西.

读取 Lambda 外的值, 并且 Lambda 能感知到外部对值的变更.

int num = 0;
Action action = () => Console.WriteLine(num);

// 输出 0
action.Invoke();

num += 123;

// 输出 123 (外部对变量变更时, lambda 仍然能输出正确的值)
action.Invoke();

对 Lamda 外部的值进行更改.

int num = 0;
Action action = () => num++;

for (int i = 0; i < 3; i++)
    action.Invoke();

// 输出 3
Console.WriteLine(num);

使用 / Usage#

下面列举几种使用 Lambda 语句/表达式的例子.

// 一句话启动新线程
new Thread(() =>
{
    int i = 0;
    while (true)
    {
        Console.WriteLine(i++);
        Thread.Sleep(1000);
    }
}).Start();

// 一句话启动新任务
Task.Run(async () =>
{
    int i = 0;
    while (true)
    {
        Console.WriteLine(i++);
        await Task.Delay(1000);
    }
});

// 生成 10 个随机数
List<int> numbers = Enumerable.Range(0, 10)
    .Select(_ => Random.Shared.Next())
    .ToList();

// 筛选上面随机数中大于 100 的
List<int> filteredNumbers = numbers
    .Where(num => num > 100)
    .ToList();

编译 / Compilation#

Lambda 本质上是一个语法糖, 在编译时, Lambda 会被编译成方法, 或者一个类, 然后其中包含的一个方法.

以下实例, 为了可读性, 均在编译器编译后结果的基础上进行了命名上的优化, 以方便理解.


示例一:捕获局部变量

using System;
public class Program {
    static void Main(string[] args) {
        int num = 0;
        Action action = () => Console.WriteLine(num);

        // 输出 0
        action.Invoke();

        num += 123;

        // 输出 123 (外部对变量变更时, lambda 仍然能输出正确的值)
        action.Invoke();
    }
}

编译后源码:

public class Program
{
    [CompilerGenerated]
    private sealed class LambdaClass
    {
        public int num;

        internal void LambdaMethod()
        {
            Console.WriteLine(num);
        }
    }

    private static void Main(string[] args)
    {
        LambdaClass lambdaInstance = new LambdaClass();
        lambdaInstance.num = 0;
        Action action = new Action(lambdaInstance.LambdaMethod);
        action();
        lambdaInstance.num += 123;
        action();
    }
}

可以看到, 在上面的示例中, Lambda 表达式被编译成了一个类, 并且捕获到的局部变量均变成了类的字段. 而不再是存储在栈上.

由此, 就诞生了一个问题, 当你尝试对一个被捕获的局部变量取地址的时候, 就会报错. 我们没办法取一个位于堆上的对象中. 堆上的对象会被 GC 移动. 或者说, 如果一个局部变量被取地址, 就意味着它需要在栈上, 而存储在栈上的值, 没办法被 Lambda 所捕获.


示例二:捕获实例字段

using System;
public class Program {
    int num = 0;
    
    void SomeMethod(string[] args) {
        Action action = () => Console.WriteLine(num);

        // 输出 0
        action.Invoke();

        num += 123;

        // 输出 123 (外部对变量变更时, lambda 仍然能输出正确的值)
        action.Invoke();
    }
}

编译后源码:

public class Program
{
    private int num = 0;

    private void SomeMethod(string[] args)
    {
        Action action = new Action(LambdaMethod);
        action();
        num += 123;
        action();
    }

    [CompilerGenerated]
    private void LambdaMethod()
    {
        Console.WriteLine(num);
    }
}
[.NET/C#] 委托与 Lambda 表达式, 语句详解
https://slimenull.com/posts/20230529214634/
作者
SlimeNull
发布于
2023-05-29
许可协议
CC BY-NC-SA 4.0