MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C#Lambda表达式与匿名函数使用精髓

2022-01-263.2k 阅读

C# Lambda 表达式与匿名函数基础概念

Lambda 表达式的定义与基本形式

在 C# 中,Lambda 表达式是一种简洁的表示可传递给方法或存储在委托类型变量中的匿名函数的方式。其基本形式为:(parameters) => expression(parameters) => { statements; }

这里的 parameters 是参数列表,可以为空、单个参数或多个参数。expression 是一个表达式,它会被计算并返回结果,而 { statements; } 是一个语句块,可包含多条语句,在语句块中需要显式使用 return 语句返回值(如果有返回值的话)。

例如,一个简单的 Lambda 表达式用于计算两个整数的和:

Func<int, int, int> add = (a, b) => a + b;
int result = add(3, 5);
Console.WriteLine(result); 

在上述代码中,(a, b) => a + b 就是一个 Lambda 表达式,它被赋值给了 Func<int, int, int> 类型的委托 add。这个 Lambda 表达式接受两个 int 类型的参数 ab,并返回它们的和。

匿名函数的概念

匿名函数是没有名称的函数。在 C# 中,匿名函数可以通过 delegate 关键字或者 Lambda 表达式来创建。使用 delegate 关键字创建匿名函数的语法如下:

delegate (parameters) { statements; }

例如:

Func<int, int, int> addWithDelegate = delegate (int a, int b) { return a + b; };
int result2 = addWithDelegate(2, 4);
Console.WriteLine(result2); 

这里通过 delegate 关键字创建了一个匿名函数,并将其赋值给 Func<int, int, int> 类型的委托 addWithDelegate

Lambda 表达式其实是匿名函数的一种更简洁的写法,在 C# 3.0 引入 Lambda 表达式后,它逐渐成为创建匿名函数的首选方式,因为其语法更紧凑、易读。

Lambda 表达式与匿名函数的参数

参数的类型推断

在 Lambda 表达式中,参数的类型通常可以由编译器根据上下文进行推断。例如:

List<int> numbers = new List<int> { 1, 2, 3, 4, 5 };
var evenNumbers = numbers.Where(n => n % 2 == 0);
foreach (var number in evenNumbers)
{
    Console.WriteLine(number);
}

n => n % 2 == 0 这个 Lambda 表达式中,编译器能够根据 numbersList<int> 类型,推断出参数 n 的类型为 int

但是,在某些情况下,可能需要显式指定参数类型。比如,当 Lambda 表达式需要作为泛型方法的参数,且编译器无法准确推断类型时:

static void PrintIf<T>(List<T> list, Func<T, bool> predicate)
{
    foreach (var item in list)
    {
        if (predicate(item))
        {
            Console.WriteLine(item);
        }
    }
}

List<string> words = new List<string> { "apple", "banana", "cherry" };
PrintIf(words, (string word) => word.Length > 5); 

在上述 PrintIf 方法调用中,由于泛型类型 Tstring,而编译器可能无法直接从 Lambda 表达式上下文推断出 word 的类型,所以显式指定了 word 的类型为 string

无参数和多参数的情况

Lambda 表达式可以有无参数的形式,例如:

Func<int> getRandomNumber = () => new Random().Next(1, 101);
int random = getRandomNumber();
Console.WriteLine(random); 

这里 () => new Random().Next(1, 101) 是一个无参数的 Lambda 表达式,它返回一个 1 到 100 之间的随机数。

对于多参数的 Lambda 表达式,除了前面提到的两个参数的例子,多个参数之间用逗号分隔。比如:

Func<int, int, int, int> multiplyAndAdd = (a, b, c) => a * b + c;
int result3 = multiplyAndAdd(2, 3, 4);
Console.WriteLine(result3); 

(a, b, c) => a * b + c 这个 Lambda 表达式接受三个 int 类型的参数,并返回 a 乘以 b 再加上 c 的结果。

Lambda 表达式与匿名函数的返回值

表达式主体 Lambda 的返回值

表达式主体 Lambda(即 (parameters) => expression 形式)会自动返回表达式的计算结果。例如:

Func<int, int> square = n => n * n;
int squaredValue = square(5);
Console.WriteLine(squaredValue); 

n => n * n 这个表达式主体 Lambda 中,它返回 n 自乘的结果,不需要显式的 return 关键字。

语句主体 Lambda 的返回值

语句主体 Lambda(即 (parameters) => { statements; } 形式)如果有返回值,则需要显式使用 return 语句。例如:

Func<int, int> factorial = n =>
{
    int result = 1;
    for (int i = 1; i <= n; i++)
    {
        result *= i;
    }
    return result;
};
int factorialValue = factorial(5);
Console.WriteLine(factorialValue); 

在这个语句主体 Lambda 中,通过 for 循环计算阶乘,最后使用 return 语句返回计算结果。

在委托和事件处理中的应用

作为委托参数

Lambda 表达式和匿名函数在作为委托参数时非常方便。例如,System.ActionSystem.Func 系列委托经常与 Lambda 表达式一起使用。

Action<int> printNumber = n => Console.WriteLine(n);
printNumber(10); 

Func<int, bool> isEven = n => n % 2 == 0;
bool isTwentyEven = isEven(20);
Console.WriteLine(isTwentyEven); 

这里 printNumber 是一个 Action<int> 类型的委托,它接受一个 int 参数并执行打印操作。isEven 是一个 Func<int, bool> 类型的委托,它接受一个 int 参数并返回一个 bool 值表示该数是否为偶数。

事件处理中的应用

在事件处理中,Lambda 表达式也能简化代码。假设我们有一个简单的按钮点击事件处理场景:

using System;
using System.Windows.Forms;

namespace LambdaEventExample
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
            button1.Click += (sender, e) =>
            {
                MessageBox.Show("Button Clicked!");
            };
        }
    }
}

在上述代码中,button1.Click += (sender, e) => { MessageBox.Show("Button Clicked!"); }; 使用 Lambda 表达式为按钮的 Click 事件添加了处理逻辑。sender 表示引发事件的对象,e 包含事件相关的数据。这里的 Lambda 表达式简洁地定义了按钮点击时要执行的操作。

在 LINQ 中的核心作用

LINQ 查询中的 Lambda 表达式

LINQ(Language - Integrated Query)是 C# 中强大的查询功能,Lambda 表达式在 LINQ 中扮演着核心角色。例如,使用 LINQ 对集合进行筛选、排序和投影等操作时,Lambda 表达式用于定义查询条件。

List<int> numbersList = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
var evenNumbersList = numbersList.Where(n => n % 2 == 0).OrderBy(n => n).Select(n => n * 2);
foreach (var number in evenNumbersList)
{
    Console.WriteLine(number);
}

在这个例子中,Where(n => n % 2 == 0) 使用 Lambda 表达式筛选出偶数,OrderBy(n => n) 对筛选后的结果按升序排序,Select(n => n * 2) 使用 Lambda 表达式将每个数乘以 2。

分组与聚合中的应用

在 LINQ 的分组和聚合操作中,Lambda 表达式同样不可或缺。例如,对一组学生成绩按成绩等级进行分组,并计算每个等级的学生人数:

class Student
{
    public string Name { get; set; }
    public int Score { get; set; }
}

List<Student> students = new List<Student>
{
    new Student { Name = "Alice", Score = 85 },
    new Student { Name = "Bob", Score = 70 },
    new Student { Name = "Charlie", Score = 92 },
    new Student { Name = "David", Score = 68 },
    new Student { Name = "Eve", Score = 88 }
};

var scoreGroups = students.GroupBy(s =>
{
    if (s.Score >= 90) return "A";
    if (s.Score >= 80) return "B";
    if (s.Score >= 70) return "C";
    if (s.Score >= 60) return "D";
    return "F";
})
.Select(g => new { Grade = g.Key, Count = g.Count() });

foreach (var group in scoreGroups)
{
    Console.WriteLine($"Grade {group.Grade}: {group.Count} students");
}

在上述代码中,GroupBy(s => {... }) 使用 Lambda 表达式定义分组依据,根据学生成绩划分等级。Select(g => new { Grade = g.Key, Count = g.Count() }) 使用 Lambda 表达式对分组结果进行投影,创建包含等级和该等级学生人数的新对象。

深入理解 Lambda 表达式的本质

编译原理

当编译器遇到 Lambda 表达式时,它会将其转换为一个匿名方法或者一个表达式树,具体取决于上下文。如果 Lambda 表达式被用于委托类型的赋值,编译器通常会将其转换为一个匿名方法。例如:

Func<int, int> square = n => n * n;

编译器会将其转换为类似如下的匿名方法形式:

Func<int, int> square = delegate (int n) { return n * n; };

如果 Lambda 表达式用于支持表达式树的 API(如 System.Linq.Expressions 命名空间中的类型),编译器会将其转换为表达式树。例如:

Expression<Func<int, int>> squareExpression = n => n * n;

这里 squareExpression 就是一个表达式树,它可以在运行时被解析和执行,这种机制在动态查询生成等场景中非常有用。

闭包的概念与 Lambda 表达式

闭包是指一个函数能够访问并记住其定义时的词法环境,即使该函数在其他地方被调用。Lambda 表达式在 C# 中可以形成闭包。例如:

int outerVariable = 10;
Func<int> createClosure = () => outerVariable + 5;
int result4 = createClosure();
Console.WriteLine(result4); 

在这个例子中,createClosure 是一个 Lambda 表达式形成的闭包。它记住了外部变量 outerVariable,即使在 createClosure 被调用时,其定义时的词法环境可能已经不存在,但它仍然能够访问并使用 outerVariable

需要注意的是,当在循环中使用 Lambda 表达式并捕获循环变量时,可能会出现意外结果。例如:

List<Func<int>> functions = new List<Func<int>>();
for (int i = 0; i < 3; i++)
{
    functions.Add(() => i);
}
foreach (var func in functions)
{
    Console.WriteLine(func());
}

这里预期输出应该是 012,但实际输出是 333。原因是 Lambda 表达式捕获的是变量 i 本身,而不是 i 在每次迭代时的值。在循环结束后,i 的值变为 3,所以每个 Lambda 表达式调用时都返回 3。要解决这个问题,可以在每次迭代中创建一个新的变量来捕获当前值:

List<Func<int>> correctFunctions = new List<Func<int>>();
for (int i = 0; i < 3; i++)
{
    int local = i;
    correctFunctions.Add(() => local);
}
foreach (var func in correctFunctions)
{
    Console.WriteLine(func());
}

这样,每个 Lambda 表达式捕获的是不同的 local 变量,从而得到预期的输出 012

Lambda 表达式与匿名函数的性能考量

执行性能

一般来说,Lambda 表达式和匿名函数在执行性能上与普通命名函数相比,差异并不显著。在简单的情况下,编译器能够对它们进行优化,使得执行效率接近普通函数。例如,一个简单的加法 Lambda 表达式 (a, b) => a + b 和一个普通的命名加法函数在多次调用时,性能差异极小。

然而,在一些复杂的场景下,尤其是当 Lambda 表达式涉及到闭包或者频繁创建和销毁时,可能会有一些性能开销。例如,在循环中频繁创建包含闭包的 Lambda 表达式,可能会导致额外的内存分配和垃圾回收压力。

内存使用

Lambda 表达式形成闭包时,如果捕获了大量的外部变量,可能会增加内存使用。因为闭包需要记住这些外部变量的值,即使这些变量在其他地方可能不再需要。例如,一个 Lambda 表达式捕获了一个大的数组或复杂对象,那么这个闭包会占用额外的内存来存储这些引用。

另外,在使用表达式树时,由于表达式树是一种数据结构,它会占用一定的内存空间。尤其是当表达式树非常复杂,包含大量节点时,内存使用会显著增加。

在实际应用中,需要根据具体的场景和性能要求来权衡是否使用 Lambda 表达式和匿名函数。如果性能是关键因素,可能需要进行一些基准测试来评估它们对整体性能的影响。

常见错误与避免方法

参数类型不匹配错误

在使用 Lambda 表达式作为委托参数时,最常见的错误之一是参数类型不匹配。例如:

Func<int, bool> isGreaterThanTen = n => n > 10;
// 错误调用,参数类型不匹配
// bool result5 = isGreaterThanTen("15"); 

在上述代码中,isGreaterThanTen 期望的参数类型是 int,但错误地传入了一个 string 类型的参数。为了避免这种错误,在编写 Lambda 表达式时,要确保参数类型与委托定义的类型一致,并且在调用时传入正确类型的参数。

闭包相关错误

除了前面提到的循环中捕获变量的问题,在闭包中还可能出现变量生命周期相关的错误。例如:

class OuterClass
{
    private int value = 0;
    public Func<int> GetClosure()
    {
        return () => value++;
    }
}

OuterClass outer = new OuterClass();
Func<int> closure = outer.GetClosure();
outer = null;
int result6 = closure(); 

在这个例子中,closure 形成了闭包并捕获了 outer 对象的 value 字段。当 outer 被设置为 null 后,closure 仍然可以访问 value 字段并对其进行修改。这可能会导致意外的行为,尤其是在多线程环境下。为了避免这种错误,要清楚闭包捕获变量的生命周期和作用域,尽量避免在闭包中依赖可能会被意外修改或释放的对象。

表达式树相关错误

在使用表达式树时,可能会出现语法错误或者类型转换错误。例如:

Expression<Func<int, int>> incorrectExpression = n => n + "5"; 

在上述代码中,试图将 int 类型的 nstring 类型的 "5" 相加,这是不合法的操作,会导致编译错误。在构建表达式树时,要确保表达式的语法和类型转换是正确的,同时要熟悉 System.Linq.Expressions 命名空间中各种类型和方法的使用。

最佳实践与代码风格建议

保持简洁明了

Lambda 表达式的优势之一就是简洁。尽量保持 Lambda 表达式简短和易读,避免在其中编写复杂的逻辑。如果逻辑过于复杂,考虑将其提取到一个命名方法中,然后在 Lambda 表达式中调用该方法。例如:

bool IsValidEmail(string email)
{
    // 复杂的邮箱验证逻辑
    return email.Contains("@") && email.Contains(".");
}

List<string> emails = new List<string> { "test@example.com", "invalidemail", "another@test.org" };
var validEmails = emails.Where(IsValidEmail); 

这里将复杂的邮箱验证逻辑放在 IsValidEmail 方法中,在 Lambda 表达式 emails.Where(IsValidEmail) 中直接调用,使代码更清晰。

遵循一致的代码风格

在团队开发中,保持一致的代码风格非常重要。对于 Lambda 表达式的参数命名、大括号使用等方面,要遵循团队约定的代码风格。例如,在参数命名上,尽量使用有意义的名称,而不是简单的单字母命名(除非在非常简单且通用的场景下,如 n 表示数字)。

// 良好的参数命名
Func<int, int, bool> isSumGreaterThanLimit = (num1, num2) => num1 + num2 > 100;

// 不太好的参数命名
Func<int, int, bool> isSumGreater = (a, b) => a + b > 100; 

另外,在使用语句主体 Lambda 时,对于大括号的使用也要保持一致,要么总是使用大括号,即使只有一条语句,要么遵循团队约定的规则。

合理使用 Lambda 表达式与其他编程结构

Lambda 表达式虽然强大,但并不适用于所有场景。在一些情况下,普通的循环、条件语句等可能更合适。例如,在简单的遍历集合并执行操作时,foreach 循环可能比使用 LINQ 和 Lambda 表达式更直观:

List<int> numbersToPrint = new List<int> { 1, 2, 3, 4, 5 };
foreach (var number in numbersToPrint)
{
    Console.WriteLine(number);
}

相比之下:

List<int> numbersToPrint2 = new List<int> { 1, 2, 3, 4, 5 };
numbersToPrint2.ForEach(n => Console.WriteLine(n)); 

虽然第二种方式使用 Lambda 表达式实现了相同的功能,但 foreach 循环可能更容易理解,尤其是对于初学者。所以要根据具体场景,合理选择使用 Lambda 表达式还是其他编程结构。