C--函数式编程-全-

C# 函数式编程(全)

原文:zh.annas-archive.org/md5/aafc09eb2134f48331d4bfed18100bc4

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎来到 使用 C#进行功能编程

本书旨在在 C#的背景下向您介绍功能编程的强大范式。随着 C#的不断发展,它越来越多地采用功能编程概念,允许开发者编写更简洁、可维护和健壮的代码。本书将引导您了解和应用 C#中的功能编程原则,从基本概念到高级技术。

本书面向的对象

本书面向希望通过学习功能编程技术来扩展其编程工具箱的 C#开发者。它适合熟悉面向对象编程的中级到高级程序员,并希望提高他们的技能。虽然不需要具备功能编程的先验知识,但为了充分利用本书,需要对 C#基础知识有扎实的理解。

本书涵盖的内容

第一章**,功能编程入门,介绍了功能编程的核心概念以及它们如何应用于 C#。

第二章**,表达式和语句,深入探讨了表达式和语句之间的区别,以及如何编写更具表现力的代码。

第三章**,纯函数和副作用,探讨了纯函数的概念以及如何在代码中最小化副作用。

第四章**,诚实函数、空值和 Option,讨论了诚实函数的重要性以及如何有效地处理空值。

第五章**,错误处理,介绍了功能编程的错误处理方法,超越了传统的 try-catch 块。

第六章**,高阶函数和委托,介绍了 C#中将函数作为一等公民的强大功能。

第七章**,函子和单子,探讨了这些高级功能编程概念及其在 C#中的实现。

第八章**,递归和尾调用,深入探讨了递归编程技术和优化。

第九章**,柯里化和部分应用,教授如何创建更灵活和可重用的函数。

第十章**,管道和组合,展示了如何组合函数以创建强大的数据处理管道。

第十一章**,反思与展望,总结了本书学到的关键概念,并提供了进一步在 C#中提高功能编程技能的指导。

为了充分利用本书

为了充分利用这本书,读者应该对 C# 基础知识有很好的掌握,包括面向对象编程概念。熟悉 LINQ 有助于但不强制要求。每一章都是基于前一章的,因此建议按顺序阅读本书。提供练习题以巩固所学概念。

书中涵盖的软件/硬件 操作系统要求
C# 12 Windows, macOS, 或 Linux
.NET 8

要跟随本书中的示例,您需要在您的机器上安装 .NET 8 SDK。推荐使用 Visual Studio 2022 或带有 C# 扩展的 Visual Studio Code 以获得最佳开发体验。本书中的所有代码示例都与 C# 12 和 .NET 8 兼容,但大多数也适用于更早的版本。

使用的约定

本书使用了多种文本约定。

文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号。以下是一个示例:“让我们深入一点,看看 Result 类的一般实现是什么样的。”

代码块设置如下:

bool IsBookPopular(Book book)
{
    if (book.AverageRating > 4.5 && book.NumberOfReviews > 1000)
    {
        return true;
    }
    return false;
}

粗体: 表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“使用 面向铁路编程ROP)重构它以改进错误处理流程。”

提示或重要注意事项

看起来像这样。

联系信息

欢迎读者反馈。

一般反馈: 如果您对本书的任何方面有疑问,请通过 customercare@packtpub.com 发送电子邮件,并在邮件主题中提及书名。

勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问 www.packtpub.com/support/errata 并填写表格。

盗版: 如果您在互联网上以任何形式遇到我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packtpub.com 联系我们,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com

分享您的想法

读完 使用 C# 进行函数式编程 后,我们很乐意听听您的想法!请 点击此处直接访问此书的亚马逊评论页面 并分享您的反馈。

您的评论对我们和科技社区都至关重要,并将帮助我们确保我们提供高质量的内容。

下载此书的免费 PDF 副本

感谢您购买此书!

您喜欢在路上阅读,但无法随身携带您的印刷书籍吗?

您的电子书购买是否与您选择的设备不兼容?

别担心,现在每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠不止于此,您还可以获得独家折扣、时事通讯和每日收件箱中的精彩免费内容。

按照以下简单步骤获取好处:

  1. 扫描二维码或访问以下链接

packt.link/free-ebook/978-1-80512-268-5

  1. 提交您的购买证明

  2. 就这些!我们将直接将您的免费 PDF 和其他好处发送到您的电子邮件。

第一部分:C#函数式编程的基础

在本第一部分,我们为理解函数式编程打下基础。我们将从介绍函数式编程的核心概念及其在 C#中的应用开始。您将了解表达式和语句之间的关键区别,以及如何编写更具表现力的代码。然后,我们将探讨纯函数以及如何最小化副作用,这些都是函数式编程的基本要素。最后,我们将讨论诚实函数和有效处理空值的方法,为更稳健的代码设计奠定基础。

本部分包含以下章节:

  • 第一章**,函数式编程入门

  • 第二章**,表达式和语句

  • 第三章**,纯函数和副作用

  • 第四章**,诚实函数、空值和 Option

第一章:函数式编程入门

函数式编程是一种基于将计算视为数学函数评估的方式来思考软件的方法。它避免改变状态和可变数据,而是专注于纯函数、不可变性和通过组合函数来解决复杂问题。通过坚持这些原则,函数式编程创建的代码更具可预测性、更容易理解,并且更不容易出错。

但为什么你应该考虑在你的项目中采用函数式编程呢?好处很多,包括以下内容:

  • 提高的可读性和可维护性:函数式代码通常更简洁、更易于表达,这使得它更容易阅读和维护。通过关注需要做什么而不是如何做,函数式编程促进了更清晰、更易于阅读的代码。

  • 增强的可测试性:纯函数对于给定的输入总是产生相同的输出,并且没有副作用,这使得它们更容易测试。这导致更全面和可靠的单元测试,从而提高代码质量并减少错误。

  • 改进的并发性和并行性:函数式编程中强调不可变性和避免共享状态,这使得它非常适合并发和并行处理。它减少了与竞态条件相关的风险,并允许更安全、更有效地使用多核处理器。

  • 可重用性和可组合性:函数式编程鼓励创建小型、专注的函数,这些函数可以轻松地在整个代码库中组合和重用。这促进了代码重用、模块化,并能够从简单的构建块构建复杂系统。

随着我们在这本书中的进展,我们将更详细地探讨这些好处,使你在项目中使用函数式编程更具吸引力。

函数式编程与命令式编程和面向对象编程的比较

要充分欣赏函数式编程的力量,了解它与其他范式(如命令式和面向对象编程)的不同是至关重要的。

命令式编程

命令式编程是许多语言中的传统方法。它侧重于明确指定解决问题的步骤顺序。这种风格严重依赖于可变状态和副作用,这可能导致代码更容易出错,并且随着代码库的增长,更难理解。

面向对象编程

面向对象编程OOP)将代码组织在对象周围,这些对象封装了数据和行为。面向对象编程非常适合模拟现实世界实体并促进封装。然而,它有时会导致复杂的层次结构和对象之间的紧密耦合,使得代码更难修改和测试。

函数式编程

与之相反,函数式编程强调纯函数和不可变数据。它将计算视为表达式的评估,而不是状态变化的序列。通过最小化副作用并关注函数的输入输出关系,函数式编程使得代码更加声明式和可组合。

混合范式

重要的是要注意,这些范式不是相互排斥的。现代编程语言,如 C#,支持命令式、面向对象和函数式编程风格的混合。关键是理解每个范式的优势和劣势,并根据手头的实际问题明智地应用它们。

函数式编程在 C#中的支持

C#在多年中经历了显著的发展,吸收了一系列函数式编程特性,使其成为支持该范式的强大语言。让我们更详细地看看这些特性以及它们如何支持函数式编程:

  • Lambda 表达式:这些提供了创建匿名函数的简洁语法,使得创建高阶函数变得容易。

  • LINQ:这提供了一套扩展方法,使得可以进行如过滤、映射和归约集合等函数式操作。

  • 不可变数据类型:如字符串和元组这样的数据类型保证一旦创建,它们的值就不能改变。

  • 模式匹配:这允许我们根据模式测试值并基于这些模式提取数据。

  • 委托和事件:这些允许你将函数视为一等公民,将它们作为参数传递并存储在变量中。

在整本书中,我们将探讨如何利用这些特性以函数式方法编写代码。接下来,让我们看看我所说的函数式方法是什么意思。

如何在 C#中编写函数式代码

在 C#中编写函数式代码意味着实现有助于我们编写函数式代码的概念和技术:

  • 表达式:通过优先考虑表达式而不是语句,我们可以编写更多关注预期结果的声明式代码,而不是实现它的步骤。

  • 纯函数:纯函数对于给定的输入总是产生相同的输出,并且没有副作用。它仅依赖于其输入参数,不修改任何外部状态。使用纯函数,我们可以创建更容易推理、测试和并行化的代码。

  • 诚实函数:诚实函数是纯函数的扩展,提供了清晰且无歧义的契约。它们明确传达其输入要求以及可能的输出场景,包括错误情况。诚实函数增强了代码的可读性、可维护性和错误处理。

  • 高阶函数:这些函数可以接受其他函数作为参数或返回函数作为结果。它们允许强大的抽象,并允许你创建可重用和可组合的代码。

  • 函子和单子:函子和单子是帮助你以函数式方式管理和组合计算的抽象。函子是一种类型,它定义了一个映射操作,允许你在保留其结构的同时将函数应用于函子内的值。另一方面,单子提供了一种将计算链式连接起来的方法,处理诸如错误传播和状态管理之类的复杂性。

如果这些概念和技术对你来说不熟悉,请不要担心。在这本书的整个过程中,我们将详细探讨它们,并使用实际的编码示例来帮助你理解如何在代码中使用它们。

一个实际例子——图书出版系统

让我们通过一个示例来考察使用图书出版系统场景演示的函数式编程概念:

public record Book(string Title, string Author, int Year, string Content);
// Pure function to validate a book
private static bool IsValid(Book book) =>
     !string.IsNullOrEmpty(book.Title) &&
     !string.IsNullOrEmpty(book.Author) &&
     book.Year > 1900 &&
     !string.IsNullOrEmpty(book.Content);
// Pure function to format a book
private static string FormatBook(Book book) =>
     $"{book.Title} by {book.Author} ({book.Year})";
// Higher-order function for processing books
private static IEnumerable<T> ProcessBooks<R>(
     IEnumerable<Book> books,
     Func<Book, bool> validator,
     Func<Book, T> formatter) =>
     books.Where(validator).Select(formatter);
public static void Main()
{
    var books = new List<Book>
         {
              new Book("The Great Gatsby", "F. Scott Fitzgerald", 1925, "In my younger and more vulnerable years..."),
              new Book("To Kill a Mockingbird", "Harper Lee", 1960, "When he was nearly thirteen, my brother Jem..."),
              new Book("Invalid Book", "", 1800, ""),
              new Book("1984", "George Orwell", 1949, "It was a bright cold day in April, and the clocks were striking thirteen.")
         };
    // Using our higher-order function to process books
    var formattedBooks = ProcessBooks(books, IsValid, FormatBook);
    Console.WriteLine("Processed books:");
    foreach (var book in formattedBooks)
    {
         Console.WriteLine(book);
    }
}

此示例展示了几个关键函数式编程概念:

  • Book 类型定义的 record,默认是不可变的。

  • IsValidFormatBook 是纯函数。它们对于相同的输入总是返回相同的输出,并且没有副作用。

  • ProcessBooks 是一个接受两个函数作为参数(验证器和格式化器)的更高阶函数。

  • ProcessBooks 函数。

  • 声明式风格:我们描述我们想要什么(有效的、格式化的书籍),而不是如何一步一步地实现它。

  • WhereSelect)与函数式编程原则相吻合。

此示例展示了如何将函数式编程应用于现实世界场景,例如图书出版系统。

如何结合函数式和面向对象范式

C# 的一大优势在于其能够无缝结合函数式和面向对象编程范式。通过利用两者的最佳特性,我们可以创建模块化、可重用且表达性强的代码。以下是一些结合函数式和面向对象编程的策略:

  • 不可变对象:不可变对象是线程安全的,更容易推理,并且与函数式编程原则相吻合

  • 扩展方法:这些方法允许我们在不修改原始实现的情况下增强类型的功能,从而促进更函数式和组合式的方法

  • 作为实例方法的更高阶函数:这种方法帮助我们封装行为,并为以函数式方式处理对象提供流畅且表达性强的 API

  • 依赖注入和组合:通过注入函数式依赖并根据行为组合对象,你可以实现更模块化和灵活的设计,这与函数式编程原则相一致

这些工具帮助我们结合函数式和面向对象编程技术,使我们的代码更具表达性和易于维护。

认识史蒂夫和朱莉娅

为了使我们在 C# 中进行函数式编程的旅程更加引人入胜和贴近实际,让我们介绍我们的主要角色:史蒂夫和朱莉娅。

史蒂夫是一位中级 C#软件开发者,他听说函数式编程可以帮助他编写更好的代码,在当前工作中更有价值,如果他决定追求新的机会,还能在求职者中占据优势。他渴望学习,但不确定从何开始。

另一方面,朱莉娅已经是 C#函数式编程的专家。她对这种范式充满热情,并乐于与他人分享她的知识。在整个书中,朱莉娅将为史蒂夫提供建议、指导和实际示例,帮助他掌握函数式编程概念。

随着我们逐步进入章节,我们将跟随史蒂夫的旅程,看他如何从朱莉娅那里学习,并将函数式编程技术应用于现实世界的场景中。

摘要

恭喜你迈出了掌握 C#函数式编程的第一步!在本章中,我们探讨了函数式、命令式和面向对象编程范式的区别。我们还深入研究了 C#的函数式特性,例如 lambda 表达式、LINQ、不可变数据类型、模式匹配和委托,以及它们如何支持函数式编程。

此外,我们还介绍了在 C#中编写函数式代码的概念和技术,例如表达式、纯函数、诚实函数、高阶函数、函子以及单子。最后,我们讨论了在 C#中将函数式编程和面向对象编程相结合的策略,使我们能够在项目中充分利用这两种范式的优点。

随着我们继续阅读下一章,我们将更深入地探讨这些概念,学习如何使用函数式原则编写更干净、更模块化、更易于测试的代码。

让我们开始吧!

第二章:表达式和语句

欢迎来到我们旅程的第一个动手实践章节!在这一章中,我们将讨论表达式和语句、lambda 表达式和表达式树。这些是我们将要讨论的主题:

  • 理解表达式和语句之间的区别

  • 使用表达式编写清晰和声明性的代码

  • 有效利用表达式成员、lambda 表达式、匿名方法和局部函数

  • 使用表达式树在运行时操作表达式

在我们深入之前,我想告诉你我重视你的时间,所以大多数章节都会从评估任务开始。这些任务并不总是需要解决,目的是帮助你衡量你对这个主题现有理解的程度。如果你觉得这些任务很容易,你可能现在想跳过这一章。反之,如果你觉得这些任务相当具有挑战性,你可能想为这一章投入更多的时间和精力。在每个带有任务的章节结束时,你将找到解决方案部分来检查你的答案。现在你知道了情况,让我们检查为这一章设计的三个任务。

任务 1 – 命名并计算所有表达式和所有语句

在下面的代码片段中命名并计算所有表达式和所有语句:

Tower mainTower = new(position: new Vector2(5, 5));
for (int level = 1; level <= mainTower.MaxLevel; level++)
{
     double upgradeCost = 100 * Math.Pow(1.5, level - 1);
     Console.WriteLine($"Upgrading to level {level} costs {upgradeCost} gold");
     if (playerGold >= upgradeCost)
     {
                  mainTower.Upgrade();
                  playerGold -= upgradeCost;
     }
}

任务 2 – 使用表达式而不是语句

将以下代码重构为使用表达式而不是语句:

string GetTowerDamageReport(IEnumerable<Tower> towers)
{
      int totalDamage = 0;
      foreach (Tower tower in towers)
      {
           if (tower.IsActive)
           {
                totalDamage += tower.Damage;
           }
      }
      return $"Active towers deal {totalDamage} total damage";
}

任务 3 – 创建一个表达式树

创建一个表达式树,它是 lambda 表达式 (baseDamage, level) => baseDamage * level。然后编译并调用它。

如果你百分之百确定你知道所有三个任务的答案,那么你可以自信地跳过这一章。然而,你可能会错过一些有用的东西,所以与其完全跳过这一章,你可能会想把它留到以后。无论如何,如果你有任何问题,或者任何东西变得不清楚,你都可以随时回来阅读。

理解表达式和语句之间的区别

在其核心,C# 中的 表达式 只是一段代码,它评估为一个值。简单的表达式包括常量、变量和方法调用。另一方面,语句 是一个独立的代码单元,执行一个动作。本质上,它是一个可执行的指令。理解某物的最佳方式是通过实践。所以,我们不再拖延,通过例子来查看表达式和语句。

表达式的示例

考虑以下 C# 代码:

var pagesPerChapter = 20;
var totalBookPages = pagesPerChapter * 10;

在这个片段中,20、pagesPerChapter10pagesPerChapter * 10 都是表达式。这些代码片段中的每一个都会评估为一个值。

语句的示例

现在,让我们识别语句:

var pagesPerChapter = 20;
var totalBookPages = pagesPerChapter * 10;

在这里,var pagesPerChapter = 20;var totalBookPages = pagesPerChapter * 10; 是语句。第一行指示程序声明一个 pagesPerChapter 变量并将其初始化为 20 的值。第二行指示程序将 pagesPerChapter 的值乘以 10 并保存到 totalBookPages 变量中。这两行都是独立的代码单元,执行动作,符合我们语句的定义。

表达式和语句之间的关键区别

虽然语句和表达式有时看起来很相似,但请记住,表达式产生一个值并且可以用在更大的表达式中。相比之下,语句执行一个动作,并作为方法或程序结构的一部分。

在 C#中,每个表达式都可以转换为语句,但并非每个语句都可以是表达式。例如,x = y + 2 是一个语句,其中 y + 2 是一个表达式。然而,一个 for 循环或一个 if 语句不能是表达式。

指导练习——在示例代码中找到表达式和语句

让我们锻炼一下你的知识。你能否在稍微复杂一点的代码片段中找到并计数所有表达式和语句?

int bookCount = 5;
for(int chapter = 1; chapter <= bookCount; chapter++)
{
     var wordCount = chapter * 1000;
     Console.WriteLine($"Chapter {chapter} contains {wordCount} words.");
}

在这里,我们有 8 个表达式和 4 个语句。具体来说:

  • 51chapterbookCountchapter <= bookCountchapter++1000chapter * 1000chapterwordCount$"Chapter {chapter} contains {``wordCount} words."

  • int bookCount = 5;for(int chapter = 1; chapter <= bookCount; chapter++)var wordCount = chapter * 1000;Console.WriteLine($"Chapter {chapter} contains {``wordCount} words.");

理解表达式和语句之间的区别有助于你编写更好的、更清晰的代码。随着你继续学习 C#,你会习惯这些基础知识,并能够编写更好的软件。继续用这些知识探索,让我们继续探索函数式编程。

如何使用表达式编写清晰简单的代码

使用简短清晰的代码使其容易理解它在做什么。这也使得你和其他人以后更容易阅读。在 C#中使用表达式可以帮助我们做到这一点。让我们学习如何用表达式塑造我们的代码。

表达式的力量——提高可读性和可维护性

表达式支持不可变性的概念,这是函数式编程的基石。由于表达式会评估为一个值并且不会修改我们程序的状态,因此它们允许我们编写更少出错、更容易推理和简单测试的代码。

有一天,史蒂夫收到了他老朋友艾琳的电话,她是一位著名的儿童书作家。她开始注意到,标题较长的书籍似乎更受欢迎。为了测试她的理论,她收集了所有畅销书的标题,并要求史蒂夫开发一个程序来计算与流行度相关的平均标题长度。

初始时,史蒂夫以他习惯的方式创建了程序:

double averageLength = 0;
foreach (string title in bookTitles)
{
    int titleLength = title.Length;
    averageLength += titleLength;
}
averageLength /= bookTitles.Length;

然而,代码看起来很冗长,他决定练习函数式方法并重写代码。他将 foreach 循环替换为一个简单的 Average 表达式,该表达式计算平均字符数:

var averageLength = bookTitles.Average(title => title.Length);

几乎像魔法一样,所有这些计算都变成了单行代码。一行更函数式、更简洁的代码,使用表达式而不是语句。

将语句转换为表达式的技巧

在 C# 中拥抱函数式编程的一大步是将你的语句尽可能转换为表达式。正如我们刚才看到的,LINQ(代表 Language INtegrated Query)在这个转换中可以成为一个强大的工具。

在上一个示例中,我们使用了 LINQ 的 Average 方法。这些是针对任何 IEnumerable<T> 可用的扩展方法,允许我们通过简单、表达性强的代码在集合上执行复杂操作。

我们可以进一步利用其他 LINQ 方法,例如 Where 用于过滤,OrderBy 用于排序,以及 Aggregate 用于将集合缩减为单个值。

此外,即使没有 LINQ 方法,代码也可以转换为符合函数式方法的格式。例如,我们可以将 if 语句转换为条件运算符:

// If-else statement
string bookStatus;
if (pageCount > 300)
{
     bookStatus = "Long read";
}
else
{
     bookStatus = "Quick read";
}
// Conditional operator
string bookStatus = pageCount > 300 ? "Long read" : "Quick read";

此外,所有 forwhileforeach 等循环都可以用递归方法替换,运行时它们将是表达式。此外,我们可以使用 Result 类型而不是异常和高级函数,这些将在后面的章节中讨论。

指导练习 - 使用表达式重构代码

埃米莉向史蒂夫提出请求,希望他能帮助她创建一个程序,用于显示她 YouTube 视频的观看次数。然而,埃米莉的频道包含私有和公开的视频,她只对公开视频的观看次数感兴趣。

史蒂夫编写了一个程序,其中主要的计数方法看起来是这样的:

string GetPublicVideosViewsMessage(IEnumerable<Video> videos)
{
     int totalPublicViews = 0;
     foreach (Video video in videos)
     {
          if (video.IsPublic)
          {
               totalPublicViews += video.Views;
          }
     }
     return $"Public videos have {totalPublicViews} views";
}

然后,史蒂夫认为他应该利用这个机会提高使用函数式方法和表达式而不是语句的能力。因此,他使用表达式和 LINQ 方法对他的代码进行了修改,使代码更清晰、更简短。现在的新版本看起来是这样的:

string GetPublicVideosViewsMessage(IEnumerable<Video> videos)
{
     var totalPublicViews = videos
               .Where(v => v.IsPublic)
               .Sum(v => v.Views);
     return $"Public videos have {totalPublicViews} views";
}

这是我们的改变:

  • if 语句已被 Where 方法替换。此方法过滤掉不满足特定条件的元素 - 在这种情况下,v.IsPublicfalse 的元素。

  • 手动将每个视频的观看次数添加到 totalPublicViews 的循环已被 Select 方法替换。此方法将每个元素转换为另一个元素 - 在这种情况下,它将每个视频 (v) 转换为其观看次数 (v.Views)。

  • 最后,Sum 方法将每个公开视频的观看次数相加,得到总数。

通过使用 LINQ 方法和表达式,生成的代码更清晰、更具声明性、更简洁。现在我们可以一眼看出代码的作用——计算所有公开视频的总观看次数——而不是它是如何做到的。这是 C# 中表达式的力量——它们允许编写更干净、更易于阅读的代码。

Lambda 表达式、表达式成员和匿名方法

现代 C# 语法提供了一套强大的工具,可以优雅而简洁地表达复杂的功能。让我们更仔细地看看这些语言特性以及我们如何使用它们来使我们的代码更功能化、更易于阅读和维护。

什么是 lambda 表达式?

Lambda 表达式,由 => 符号表示,是一种创建匿名函数的简洁方式。很可能,你在使用 LINQ 和类似的功能性编程结构时每天都在使用它们。

那么,让我们看看以下示例,其中我们定义一个 lambda 来平方一个数字:

Func<Book, int> getWordCount = book => book.PageCount * 250;

这定义了一个接受整数 x 并返回其平方的 lambda 表达式。然后我们可以这样使用这个函数:

int wordCount = getWordCount(book);

Lambda 表达式在参数类型和返回值方面提供了极大的灵活性。它们可以接受多个参数,返回复杂对象,甚至没有任何参数或返回值。

Lambda 表达式中的多个参数

当然,C# 中的方法可以包含多个参数,lambda 表达式也是如此。在 LINQ 中,演示这一点的最简单方法之一是 SelectMany

List<Publisher> publishers = GetPublishers();
List<Book> books = GetBooks();
var publisherBookPairs = publishers.SelectMany(
    publisher => books.Where(book => book.PublisherId == publisher.Id),
    (publisher, book) => new { PublisherName = publisher.Name, BookTitle = book.Title }
);

在这里,除了书籍集合和一个参数的 lambda 表达式 publisher => books.Where(book => book.PublisherId == publisher.Id) 之外,此方法还接受一个双参数的 lambda 表达式 (publisher, book) => new { PublisherName = publisher.Name, BookTitle = book.Title }。正如你所看到的,我们只需要添加括号就可以使用任意数量的变量。

Lambda 表达式的演变

作为在线 C# 课程中的教师,我喜欢向我的学生展示 lambda 表达式的“演变”。首先你需要知道的是,这种表达式只是方法上的语法糖。让我们看看这个例子:

bool IsBookPopular(Book book)
{
     if (book.AverageRating > 4.5 && book.NumberOfReviews > 1000)
     {
          return true;
     }
     return false;
}

此方法计算一本书是否受欢迎,并以命令式方式(告诉如何做事情,而不是做什么)编写。为了使其更短,我们可以用 return 替换 if

bool IsBookPopular(Book book)
{
     return book.AverageRating > 4.5 && book.NumberOfReviews > 1000;
}

为了让它更短,让我们使用表达式成员语法:

bool IsBookPopular(Book book) => book.AverageRating > 4.5 && book.NumberOfReviews > 1000;

如果我们尝试在 LINQ 的 Where 函数中使用相同的条件,它将看起来像这样:

books.Where(book => book.AverageRating > 4.5 && book.NumberOfReviews > 1000)

你看到相似之处了吗?这基本上是同一回事,因此我们可以将我们的函数用在 Where 方法中:

books.Where(book => IsBookPopular(book))

这里是另一个示例:

books.Where(IsBookPopular)

这是因为 Where 方法接受一个 Func<T, bool> 类型的参数,这基本上就是我们的 lambda 表达式。

现在让我告诉你,我们用来理解“lambda 表达式演变”的例子实际上并不是一个 lambda 表达式。Lambda 表达式是匿名方法的语法糖,我们在这里使用了表达式成员体。那么,让我们深入挖掘,了解这两个之间的区别。

理解匿名方法

正如其名所示,匿名方法是没有名称的方法。这种在它们被使用的地方直接编写无名称方法的能力,尤其是作为其他方法的参数,是函数式编程语言的一个重要特性。这里有一个有趣的事实:匿名方法是 C#中最古老的功能之一;它们是在 2.0 版本中引入的。

这里有一个简单的例子:

List<Video> videos = GetVideos();
videos.ForEach(delegate(Video video)
     {
         Console.WriteLine($"{video.Title}: {video.Views} views");
     });

在这个例子中,delegate(Video video){...}是一个匿名方法,正如你所见,它被直接用作ForEach方法的参数。

匿名方法是如何工作的?

匿名方法通过在编译时生成一个隐藏的方法来工作。编译器为该方法生成一个在 C#命名规则上下文中无效的唯一名称,确保不会与你的方法名称发生冲突。

何时使用匿名方法

当方法的逻辑不足以证明完整方法声明的合理性时,使用匿名方法特别合适。如果代码短小、易于理解,并且只在一个地方使用,匿名方法是一个不错的选择。

使用匿名方法的常见场景包括以下几种:

  • 使用 LINQ:LINQ 大量依赖于委托和匿名方法,尤其是在过滤、排序或投影数据时

  • 事件处理:在附加事件时,尤其是当事件处理代码简单时,可以使用匿名方法

  • 异步编程:任务和线程经常使用匿名方法

实际示例 - 在实际代码中应用这些功能

另一天在街上,史蒂夫遇到了他的老朋友 Konstatos,他是一个小型游戏工作室的创始人,该工作室开发移动游戏。Konstatos 说他想分析一组他称之为“新鲸鱼”的玩家的行为。通常,在某个事物上花费比其他人多得多的人被称为“鲸鱼”。因此,他需要使用两个条件来获取这个子集的玩家:首先,他们必须在一年前或之后注册过游戏,其次,他们必须在此之后至少花费了 10,000 美元。

史蒂夫欣然同意,现在,在之前任务中大量练习之后,他想出了这个函数式解决方案:

List<string> GetWhales(IEnumerable<Player> players, DateTime date, decimal minSpend)
{
     return players
     .Where(p => p.JoinDate > date)
     .Where(p => p.Spend > minSpend)
     .Select(p => p.Nickname)
     .ToList();
}

注意,条件被分解为两个Where方法。你可以只用一个Where方法来完成:

List<string> GetWhales(IEnumerable<Player> players, DateTime date, decimal minSpend)
{
     return players
     .Where(p => p.JoinDate > date && p.Spend > minSpend)
     .Select(p => p.Nickname)
     .ToList();
}

然而,当Where方法内部包含两个条件时,认知负载更大,这也是为什么首选第一种方法的原因。此外,第一种方法在进一步修改代码时减少了对受影响行数的修改,更容易扩展(你只需在新行上添加新的Where方法),并且引起更少的合并冲突。

好的,既然我们已经看到了匿名方法,让我们看看表达式成员。

表达式成员

表达式成员是一种语法快捷方式,允许使用类似 lambda 的语法定义方法、属性和其他成员,其中成员体由=>运算符后的单个表达式定义。

考虑以下传统的计算版税的方法:

public int CalculateRoyalty(Book book)
{
    if(book.CopiesSold < 10000)
    {
        return book.CopiesSold * 0.2;
    }
    else
    {
        return book.CopiesSold * 0.3;
    }
}

现在,让我们将其转换为表达式成员:

public int CalculateRoyalty(Book book) =>
    book.CopiesSold < 10000
       ? book.CopiesSold * 2
       : book.CopiesSold * 3;

我们将方法简化为单行(一行编写的功能),简洁的行。这种简化的简洁性提高了可读性,特别是对于简单的方法和属性。重要的是要记住,代码使用表达式成员并不是必须的,才能被认为是函数式风格。在我的工作中,我坚持只存在单行表达式成员的规则。如果方法体开始包含两行或更多,使用常规语法编写方法会更好,这样可以减少合并冲突并提高可读性。

在下一节中,我们将探讨强大的表达式树概念,并揭示其在 C#中的实用性。但在那之前,花些时间吸收这些概念,看看您如何可以使用它们来编写更具有表现力和简洁的代码。

练习 – 实现 lambda 表达式和匿名方法

为了让你在函数式方法上更多练习,这里有一个挑战,要求使用表达式成员、lambda 表达式和匿名方法重构以下代码:

public bool IsVideoTrending(Video video)
{
     int viewThreshold = CalculateViewThreshold(video.UploadDate);
     return video.Views > viewThreshold;
}
private int CalculateViewThreshold(DateTime uploadDate)
{
     int daysOld = (DateTime.Now - uploadDate).Days;
     return 1000 * daysOld;
}

虽然这相当容易,但它可以帮助我们明确标准方法和 lambda 表达式之间的区别,使我们的代码更加函数式。

表达式树及其在运行时操作表达式的方法

表达式树在 C#中提供了一种独特的功能:能够将代码作为数据来处理,并在运行时对其进行操作。它们是 LINQ 功能的核心,允许我们使用相同的查询语法对内存对象和外部数据源进行查询。让我们探索这个迷人的特性。从高层次来看,表达式树是一种以树形格式表示某些代码的数据结构,其中每个节点都是一个表达式。表达式树由 lambda 表达式构建,允许您将 lambda 内的代码作为数据来检查。

为了说明这一点,考虑一个简单的 lambda 表达式:

Func<int, int, int> add = (a, b) => a + b;

现在,让我们将其重写为二元表达式:

ParameterExpression a = Expression.Parameter(typeof(int), "a");
ParameterExpression b = Expression.Parameter(typeof(int), "b");
ParameterExpression c = Expression.Parameter(typeof(int), "c");
BinaryExpression addExpression = Expression.Add(a, b);

代码中的差异可能并不显著,但让我们看看我们变量的内部表示。这是我们的add

如您所见,它只有两个字段:Target,这是此方法所在的类,以及Method字段,包含方法信息。看起来并不多。现在,让我们看看addExpression

如您所见,表达式具有NodeTypeAdd和两个部分:LeftRight。从视觉上看,它可以表示如下:

完全不吓人,对吧?如果是这样,让我们继续到表达式树。

构建和操作表达式树

手动构建表达式树可以更深入地理解其结构。让我们重新创建我们的加法表达式:

// Define parameters
ParameterExpression a = Expression.Parameter(typeof(int), "a");
ParameterExpression b = Expression.Parameter(typeof(int), "b");
// Define body
BinaryExpression body = Expression.Add(a, b);
// Combine them
Expression<Func<int, int, int>> addExpression = Expression.Lambda<Func<int, int, int>>(body, a, b);

这段代码创建了一个与之前相同的表达式树,但结构更清晰。lambda 由一个体 (a + b) 和一个参数列表 (``a, b) 组成:

图片

现在,我们的表达式树有两个主要分支,BodyParameters

图片

这看起来更像是树。然而,它里面只有一个操作,而真实的表达式树通常包含多个操作。让我们添加一个乘法操作:

// Define parameters
ParameterExpression a = Expression.Parameter(typeof(int), "a");
ParameterExpression b = Expression.Parameter(typeof(int), "b");
ParameterExpression c = Expression.Parameter(typeof(int), "c");
// Define bodies for addition and multiplication
BinaryExpression addBody = Expression.Add(a, b);
BinaryExpression multiplyBody = Expression.Multiply(addBody, c);
// Combine them
Expression<Func<int, int, int, int>> combinedExpression = Expression.Lambda<Func<int, int, int, int>>(multiplyBody, a, b, c);

这个例子更有趣,其内部表示更大:

图片

这就是我们的修改后的视觉树看起来像什么:

图片

希望你现在对表达式树的外观有了更好的理解。这将帮助我们前进到更复杂的例子。

创建和操作复杂表达式树

前几天,Irene 请求 Steve 和她的出版商见面。看起来出版商想要一个可以轻松过滤流行书籍的程序。Steve 高兴地同意了,并为他们的系统创建了一个高级过滤器作为表达式树。

过滤器检查一本书的标题是否包含特定的关键词,其页数是否超过特定的限制,以及其评分是否高于某个阈值。因此,表达式树有三个不同的表达式:

// Define parameters
ParameterExpression book = Expression.Parameter(typeof(Book), "book");
ParameterExpression keyword = Expression.Parameter(typeof(string), "keyword");
ParameterExpression minPages = Expression.Parameter(typeof(int), "minPages");
ParameterExpression minRating = Expression.Parameter(typeof(double), "minRating");
// Define body
MethodCallExpression titleContainsKeyword = Expression.Call(
    Expression.Property(book, nameof(Book.Title)),
    typeof(string).GetMethod("Contains", new[] { typeof(string) }),
    keyword
);
BinaryExpression pagesGreaterThanMinPages = Expression.GreaterThan(
    Expression.Property(book, nameof(Book.Pages)),
    minPages
);
BinaryExpression ratingGreaterThanMinRating = Expression.GreaterThan(
    Expression.Property(book, nameof(Book.Rating)),
    minRating
);
// Combine expressions with 'AND' logical operator
BinaryExpression andExpression = Expression.AndAlso(
    Expression.AndAlso(titleContainsKeyword, pagesGreaterThanMinPages),
    ratingGreaterThanMinRating
);
// Combine parameters and body into a lambda expression
Expression<Func<Book, string, int, double, bool>> filterExpression = Expression.Lambda<Func<Book, string, int, double, bool>>(
    andExpression,
    book, keyword, minPages, minRating
);

在他们的发布系统中,他们需要做的唯一一件事就是使用 filterExpression 创建一个用于过滤书籍的委托,并使用它:

var filter = filterExpression.Compile();
var popularBooks = books
    .Where(book => filter(book, keyword, minPages, minRating))
    .ToList();

表达式树的真正力量来自于它们在运行时可以被操作的能力。你可以动态地构建、修改,甚至编译和运行表达式树。这是运行时代码生成的一个强大工具,并为 LINQ 和 Entity Framework 等技术提供了基础。

使用表达式树查询数据 – LINQ 以及更多

LINQ 在底层使用表达式树来实现对不同类型数据的相同查询语法。当你对一个 IQueryable<T> 编写 LINQ 查询时,你实际上是在构建一个表达式树。然后,这个树被传递给查询提供者,它将其转换为适当的格式(例如,数据库的 SQL)。

这是一个 LINQ 查询的例子,它被 Entity Framework(一个在 C# 中与数据库交互的流行工具)转换为 SQL:

var youngCustomers = dbContext.Customers
    .Where(c => c.Age < 30)
    .Select(c => new { c.Name, c.Age });

当这个查询运行时,Entity Framework 会生成一个表达式树,将其转换为 SQL,发送到数据库,并将结果实体化回对象。

指导练习 – 构建和操作表达式树

为了帮助你更好地理解表达式树,让我们看看这个练习。我们的目标是创建一个表示 lambda 表达式(x, y) => x * y的表达式树。这代表一个乘法操作。之后,我们将编译并调用这个表达式,实际上执行两个数字的乘法。

让我们分解步骤:

  1. 定义 lambda 表达式的参数。这些是xy,它们都是int类型:

    ParameterExpression x = Expression.Parameter(typeof(int), "x");
    ParameterExpression y = Expression.Parameter(typeof(int), "y");
    
  2. 构建 lambda 表达式的主体。这是x * y操作:

    BinaryExpression body = Expression.Multiply(x, y);
    
  3. 现在,我们将参数和主体组合成一个 lambda 表达式:

    Expression<Func<int, int, int>> multiplyExpression = Expression.Lambda<Func<int, int, int>>(body, x, y);
    
  4. 现在我们有了我们的表达式树,我们可以将其编译成一个委托:

    Func<int, int, int> multiply = multiplyExpression.Compile();
    
  5. 使用两个数字调用代理:

    int result = multiply(6, 7); // This returns 42
    

太棒了!我们已经成功创建了一个表示 lambda 表达式的表达式树,编译并调用了它。这是理解 C#中的表达式树如何允许我们使用代码作为数据,开启强大、动态编程可能性的基本步骤。

用不同的 lambda 表达式不断练习这些步骤。掌握表达式树让你能够充分利用 C#的潜力,赋予你诸如动态代码生成和操作、高级查询能力等能力。继续努力,你做得很好!

问题集和练习

在阅读了关于表达式和语句、lambda 表达式和表达式树的内容后,Steve 给 Julia 写了一封电子邮件,询问获得更多实践经验的最佳方法。Julia 祝贺 Steve 并发送了一个包含五点的列表,据她理解,每个试图学习这个主题的人都应该做:

  1. IEnumerable<T>和一个表达式树形式的谓词,并返回过滤后的结果。使用它根据字符串的长度过滤字符串列表。

  2. 重构一个类,将其传统方法版本转换为使用表达式成员的版本。比较这两个版本。

  3. 编写一个应用程序,该程序在运行时接受一个数学表达式作为字符串,将其转换为表达式树,并对其进行评估。该应用程序应支持加法、减法、乘法和除法等操作。

  4. 设计一个迷你查询语言用于内存中对象的查询。这种语言应支持基本操作,如过滤和排序。使用表达式树来实现它。

  5. 代码审查一个项目。在 GitHub 上找到一个使用 C#的开源项目,并检查代码以确定这些功能(表达式成员、lambda 表达式和匿名方法)的使用位置。分析它们如何有助于代码的可读性和可维护性。

练习

在本节中,你将帮助 Steve 以函数式编程的方式开发并重构他的塔防游戏。

练习 1

在下面的代码片段中命名并计算所有表达式和所有语句:

Tower mainTower = new(position: new Vector2(5, 5));
for (int level = 1; level <= mainTower.MaxLevel; level++)
{
     double upgradeCost = 100 * Math.Pow(1.5, level - 1);
     Console.WriteLine($"Upgrading to level {level} costs {upgradeCost} gold");
     if (playerGold >= upgradeCost)
     {
                  mainTower.Upgrade();
                  playerGold -= upgradeCost;
     }
}

练习 2

将下面的代码重构为使用表达式而不是语句

string GetTowerDamageReport(IEnumerable<Tower> towers)
{
      int totalDamage = 0;
      foreach (Tower tower in towers)
      {
           if (tower.IsActive)
           {
                totalDamage += tower.Damage;
           }
      }
      return $"Active towers deal {totalDamage} total damage";
}

练习 3

创建一个表示 lambda 表达式 (x, y) => x * y 的表达式树,然后编译并调用它来乘以两个数字。

解答

练习 1

Tower mainTower = new(position: new Vector2(5, 5));
for (int level = 1; level <= mainTower.MaxLevel; level++)
{
     double upgradeCost = 100 * Math.Pow(1.5, level - 1);
     Console.WriteLine($"Upgrading to level {level} costs {upgradeCost} gold");
     if (playerGold >= upgradeCost)
     {
                  mainTower.Upgrade();
                  playerGold -= upgradeCost;
     }
}

表达式:

  • new Vector2(5, 5)

  • 5 (x-coordinate)

  • 5 (y-coordinate)

  • new(position: new Vector2(5, 5))

  • 1

  • level

  • mainTower.MaxLevel

  • level <= mainTower.MaxLevel

  • level++

  • 100

  • 1.5

  • 1

  • level - 1

  • Math.Pow(1.5, level - 1)

  • 100 * Math.Pow(1.5, level - 1)

  • level (in string interpolation)

  • upgradeCost (in string interpolation)

  • $"Upgrading to level {level} costs {``upgradeCost} gold"

  • playerGold

  • upgradeCost

  • playerGold >= upgradeCost

  • upgradeCost (``in subtraction)

语句:

  • Tower mainTower = new(position: new Vector2(5, 5));

  • for (int level = 1; level <= mainTower.MaxLevel; level++)

  • double upgradeCost = 100 * Math.Pow(1.5, level - 1);

  • Console.WriteLine($"Upgrading to level {level} costs {``upgradeCost} gold");

  • if (playerGold >= upgradeCost)

  • mainTower.Upgrade();

  • playerGold -= upgradeCost;

总计:22 个表达式和 7 个语句

练习 2

string GetTowerDamageReport(IEnumerable<Tower> towers) =>
     $"Active towers deal {towers.Where(t => t.IsActive).Sum(t => t.Damage)} total damage";

这个重构版本使用 LINQ 表达式来过滤活动塔并计算它们的伤害总和,一行完成,消除了显式循环和条件语句的需求。

练习 3

ParameterExpression baseDamage = Expression.Parameter(typeof(int), "baseDamage");
ParameterExpression level = Expression.Parameter(typeof(int), "level");
BinaryExpression multiply = Expression.Multiply(baseDamage, level);
Expression<Func<int, int, int>> damageCalc = Expression.Lambda<Func<int, int, int>>(multiply, baseDamage, level);
// Compile the expression
Func<int, int, int> calculateDamage = damageCalc.Compile();
// Calculate tower damage
int towerDamage = calculateDamage(10, 5);
Console.WriteLine($"Tower damage: {towerDamage}");

这个解决方案创建了一个表示 (baseDamage, level) => baseDamage * level 的表达式树,将其编译成函数,然后调用该函数来根据塔的基础伤害(10)和等级(5)计算塔的伤害。

摘要

在本章中,我们深入探讨了 C# 中的函数式编程,重点关注表达式和语句,以及 C# 提供的强大工具来提升你的代码。让我们总结一下关键要点:

  • 我们学习了表达式和语句之间的区别。函数式编程通常更喜欢表达式,因为它们的风格简单直接。

  • 我们研究了表达式成员,它们提供了一种更短、更干净的方式来编写方法和属性。

  • 我们研究了 lambda 表达式和匿名方法。两者都有助于编写清晰、简洁和紧凑的代码。

  • 我们简要介绍了表达式树,这是 C# 中的一个特殊功能,允许我们处理类似代码的数据。这对于 LINQ 中的数据查询等非常有用。

在整个过程中,我们的目标是理解不仅如何使用这些工具,还要了解为什么以及何时它们是有帮助的。

接下来,我们将学习纯函数,一个方法为什么是“纯”的,以及副作用意味着什么。

第三章:纯函数和副作用

欢迎来到第三章!在这里,我们将深入探讨 C#中纯函数的世界。这一章全部关于帮助你理解纯函数的概念、它们的实际应用以及如何在代码中有效地使用它们。

这里是一个快速概述,让你了解可以期待什么:

  • 理解纯函数

  • 副作用

  • 最小化副作用的方法

  • 使用Pure属性标记纯函数

在你浏览内容的过程中,注意可操作见解和基于数据的推荐。以学习的热情来阅读这一章;到结束时,你将有一个坚实的基础来编写高效且干净的 C#程序。

正如我在上一章中建议的,我建议你检查你的知识水平,看看以下三个任务。如果你对如何解决它们有任何疑问,最好是现在就阅读这一章。如果你百分之百确信你可以闭着眼睛解决它们,那么现在先处理不太熟悉的话题可能更有益。让我们直接进入正题!

任务 1 – 重构为纯函数

史蒂夫的塔防游戏根据全局难度修改器计算伤害。重构这个函数以使其成为纯函数:

public double _difficultyModifier = 1.0;
public double CalculateDamage(Tower tower, Enemy enemy)
{
     return tower.BaseDamage * enemy.DamageMultiplier * _difficultyModifier;
}

任务 2 – 隔离副作用

游戏从文件中加载敌人数据,处理它,并更新游戏状态。重构这个函数以隔离其副作用:

public void LoadAndProcessEnemyData(string filePath)
{
     string jsonData = File.ReadAllText(filePath);
     List<Enemy> enemies = JsonConvert.DeserializeObject<List<Enemy>>(jsonData);
     foreach (var enemy in enemies)
     {
                  enemy.Health *= GameState.DifficultyLevel;
                  GameState.ActiveEnemies.Add(enemy);
     }
     Console.WriteLine($"Loaded {enemies.Count} enemies");
}

任务 3 – 使用Pure属性

通过将其改为纯函数并标记为Pure属性来重构以下方法:

public string GenerateEnemyCode(string enemyType, int level)
{
     var code = enemyType.Substring(0, 3) + level.ToString();
     return new string(code.OrderBy(c => c).ToArray());
}

如果这些任务很容易,你可能想先阅读一些你不太熟悉的话题。如果你有任何问题或不确定正确答案,不用担心——接下来,我们将深入探讨纯函数和副作用的概念,同时使用上一章中的角色——朱莉娅和史蒂夫。

一周后,朱莉娅给史蒂夫打电话说,如果他想要继续学习函数式编程,他需要理解纯函数和副作用的逻辑。

朱莉娅:纯函数是没有可观察副作用且具有确定输出值的函数——换句话说,没有在函数给定作用域之外发生的动作。这使得它们可预测且易于测试,同时也是高效软件开发的关键属性。在 C#代码中,我们通过使用不可变性和诸如readonlyconststatic等关键字来实现这一点。此外,还有一个特殊的属性用于标记纯函数

史蒂夫:哇!这一切都非常令人兴奋,但我什么都不懂。你能给我一些关于它的阅读材料吗?

朱莉娅给了他文章,史蒂夫开始阅读。

理解纯函数

纯函数在函数式编程中非常重要。它们有两个主要特性:

  • 确定性输出:对于任何给定的输入,纯净函数总是会生成相同的输出,这使得其行为极其可预测。这一特性简化了测试和调试的过程,因为给定相同的输入集,函数的输出始终是一致的。

  • 无可见的副作用:纯净函数不会影响或被外部状态影响。这意味着它不会修改任何外部变量或数据结构,甚至不会执行 I/O 操作。函数的唯一效果是它执行的计算和它提供的输出。

这两个特性使得纯净函数类似于数学函数。一个数学函数,f(x) = y,产生一个结果,y,它仅依赖于输入,x,并且不会被函数外部的东西所改变或影响。在编程中,纯净函数可以被看作是一个自包含的单元,它将输入转换为输出,而不受外部世界的干扰。

通过遵循这些特性,纯净函数有助于创建更健壮、可维护且更不易出错的代码。让我们进一步探讨这些优点和纯净函数的实际应用案例。

纯函数的示例

考虑一个确定需要打印多少本书才能达到目标数量的函数:

public static int BooksNeededToReachTarget(int currentPrintCount, int targetPrintCount)
{
    return targetPrintCount - currentPrintCount;
}

这个函数总是以相同的输入给出相同的结果,并且不会改变它之外的东西。

另一个例子可以是过滤出特定类型的书籍:

public static List<string> GetTitlesOfGenre(List<Book> books, string genre)
{
    return books.Where(b => b.Genre == genre).Select(b => b.Title).ToList();
}

这个函数也是纯净的。如果你给它相同的书籍列表,它总是会返回相同的标题列表。

纯函数的优点

纯函数提供几个显著的优势:

  • 可预测性和易于测试:由于它们的确定性,纯净函数高度可预测,这使得编写单元测试变得容易。对于特定的输入,你总是知道预期的输出,而且不需要模拟或设置外部依赖项进行测试。

  • 代码重用性和模块化:当纯净函数按照单一职责原则设计,专注于特定任务时,它们变得高度可重用。由于它们不依赖于外部状态,你可以移动这些函数,而不用担心破坏代码或增强其模块化。

  • 易于调试和维护:没有共享状态或副作用,调试纯净函数非常简单。如果有问题,通常在函数内部,这使得它容易发现和修复。纯净函数的隔离也促进了维护和更新,因为你可以更改一个函数,而不会影响代码的其他部分。

纯函数与非纯函数的比较

当分析纯函数和非纯函数时,每个函数的优缺点都变得明显。为了说明这一点,让我们以 Konstatos 的塔防手机游戏为例。在这款游戏中,不同的单位根据对每种塔的防御能力,从塔楼受到不同数量的伤害。每个单位类可以有一个包含这些伤害变化的字典:

private static Dictionary<TowerType, double> _damageModifiers = new Dictionary<TowerType, double>
{
    {TowerType.Cannon, 0.8},  // Takes 20% less damage from cannon towers
    {TowerType.Laser, 0.9}   // Takes 10% less damage from laser towers
};

为了确定塔对单位造成的伤害,单位类有一个看起来像这样的函数:

public double CalculateDamageFromTower(Tower tower)
{
    return tower.BaseDamage * _damageModifiers[tower.Type];
}

起初,你可能会认为这个函数是纯的。但因为它使用了可能会改变的后缀 _damageModifiers 变量,所以输出也可能改变,即使输入保持不变。这意味着函数依赖于它之外的东西,这对纯函数来说并不好。这可能导致错误,并使测试和修复问题变得更加困难。

这是我们如何使函数变得纯的方法:

public double CalculateDamageFromTower(Tower tower, Dictionary<TowerType, double> damageModifiers)
{
    return tower.BaseDamage * damageModifiers[tower.Type];
}

现在,通过直接将 damageModifiers 传递给函数,它不依赖于它之外的东西。这意味着如果你给它相同的输入,你总是会得到相同的输出。

你可能会想知道当函数本身已经可以看到它时,将字典传递给函数是否有意义。这是一个合理的观点。但这样做意味着函数不会秘密依赖于其参数之外的东西,这使得我们的代码更干净,更容易处理。

理解这两种函数之间的区别,并优先使用纯函数可以提高代码的质量。随着你更深入地学习 C# 中的函数式编程,这种理解将非常有价值。接下来,我们将讨论函数式编程中的副作用。

副作用

在开发他的塔防游戏时,Steve 注意到了一些意外的行为。单位从塔楼受到的伤害不一致。经过一番调查,他意识到伤害计算函数依赖于一个可能会不可预测地改变的全球变量——这是一个典型的副作用。

编程中的副作用指的是在执行函数之外发生的任何应用程序状态变化。这些变化可能包括修改全局或静态变量、改变函数参数的原始值、执行 I/O 操作,甚至抛出异常。副作用使函数的行为依赖于上下文,降低了可预测性,并可能增加错误。

常见副作用来源

在编写代码时,了解副作用可能来自哪里是很好的。副作用会使代码变得不可预测。让我们分析一些常见的来源。

全局变量

问题:使用全局变量可能导致意外的变化。如果一个函数改变了全局变量,它可能会影响程序的其他部分:

public static Dictionary<string, int> UserScores = new Dictionary<string, int>();
public static int UpdateUserScore(string userName, int scoreToAdd)
{
    if (UserScores.ContainsKey(userName))
    {
        UserScores[userName] += scoreToAdd;
    }
    else
    {
        UserScores[userName] = scoreToAdd;
    }
    return UserScores[userName];
}

UpdateUserScore 改变了 UserScores 字典。由于这个字典可以在任何地方访问,其他函数也可能改变它。这使得我们的函数变得不可预测。

解决方案:与全局变量相比,最好使用函数参数或将状态放在对象内部。例如,在这里,正如我们之前所做的那样,将字典作为参数传递以消除问题更好。

outref参数

问题:在 C#中使用outref可以改变传递给函数的原始数据:

public static void UpgradeTower(ref Tower tower, int level)
{
    tower = new Tower();
    tower.Damage = level * 10;
    tower.Hitpoints = level * 150;
}

UpgradeTower方法不仅更新了DamageHitpoints值,而且还改变了引用,使其不再指向原始的Tower对象。当然,在现实生活中几乎不可能看到这样的代码;通常,它并不那么直接,而是隐藏在其他方法中。这段代码是一个简化且有些丑陋的真实代码版本,以展示使用ref参数背后的理念。

解决方案:而不是改变数据,返回一个新的值是一个好主意。在这里,我们可以将方法重命名为GetLeveledUpTower并使其返回一个新的塔。

I/O 操作

问题:像保存到文件或数据库这样的操作会改变函数之外的数据:

public void SaveGameProgressToFile(string progressData, string filePath)
{
    File.WriteAllText(filePath, progressData);
}

SaveGameProgressToFile函数将游戏进度数据保存到文件。如果,例如,磁盘空间不足,这种动作可能会失败。因此,它是一个副作用,因为它依赖于我们函数之外的东西。

解决方案:将逻辑与像保存数据这样的动作分开是有帮助的。这使得代码更清晰,更容易理解。

异常处理

问题:考虑一个计算塔造成的伤害的函数:

public static double CalculateDamage(Tower tower, Unit unit)
{
    if (tower == null || unit == null)
    {
        throw new ArgumentException("The tower or unit is null.");
    }
    return tower.Damage * unit.DefenseModifier;
}

CalculateDamage函数如果塔或单位是null,则会抛出异常。抛出异常会改变我们程序的正常流程。如果没有处理,它可能会终止应用程序或导致意外行为。

解决方案:在这里,最好的做法是使用Either单子。然而,在我们讨论它之前,你可以使用一个名为double?的可空类型:

public static double? CalculateDamage(Tower tower, Unit unit)
{
    if (tower == null || unit == null)
    {
        return null;
    }
    return  tower.Damage * unit.DefenseModifier;
}

使用这个CalculateDamage方法,如果塔或单位是null,则方法返回null;否则,它计算伤害并返回。这样,我们避免了在常见场景中通过异常中断流程的副作用。然而,使用此方法的代码也必须修改,以便它可以处理返回null的情况。

了解异常可以是副作用的一个来源,有助于做出使我们的 C#代码更清晰和更可预测的设计选择。

副作用的后果

代码中存在副作用可能会导致各种问题:

  • 可预测性降低:具有副作用的函数的可预测性较低,因为它们的输出可能会根据外部状态而改变。这种可预测性的降低使得仅通过查看函数本身更难理解其功能。

  • 测试和调试难度增加:具有副作用的功能更难测试,因为它们需要正确的外部状态来产生预期的结果。调试也更加复杂,因为函数中的问题可能是由于外部状态的变化。

  • 并发问题:当多个线程同时访问和修改共享状态时,可能会出现并发问题,导致意外结果。

虽然这看起来可能并不立即有问题,但随着时间的推移,这些后果往往会累积,使得你的项目开发和维护成本非常高。

减少副作用的方法

虽然现实世界中的应用程序中的副作用不可避免,但关键是要控制和隔离它们,以便使代码更易于管理和预测。本节重点介绍通过在 C# 中使用 readonlyconststatic 和不可变性来最小化副作用的方法。

优先考虑不可变性

不变性是减少副作用的一种强大方式。不可变对象是在创建后其状态不能被改变的对象。在 C# 中,字符串就是一个不可变性的典型例子。对字符串的每一次操作都会产生一个新的字符串,而原始字符串保持不变。这个原则可以扩展到其他数据类型:

     Book originalBook = new Book("The Clean Coder", "Uncle Bob");
    /* Create a new book instance with the same title but a different author */
    Book updatedBook = originalBook with { Author = "Robert C. Martin" };
    // We can see that both copies exist
    Console.WriteLine(originalBook);
    Console.WriteLine(updatedBook);

在这个代码片段中,originalBook 被创建为一个具有特定标题和作者的 Book 实例,而 updatedBook 是使用 with 表达式创建的新 Book 实例。with 表达式用于创建一个新的记录,其某些属性是从现有记录修改而来的。在这里,它创建了一个新的 Book 值,其 Title 值与 originalBook 相同,但 Author 被设置为 "Robert C. Martin"

这种方法保持了不可变性,因为 originalBook 保持不变,任何“修改”都会导致一个新的实例。

使用 readonlyconst

readonlyconst 是 C# 中的两个关键字,可以使字段和变量不可变,从而减少副作用的可能性。

const 变量是隐式静态的,应该在值的编译时已知且永远不会改变时使用:

public const string PublishingHouseName = "Progressive Publishers";

另一方面,readonly 变量可以是实例级别的或静态的,它们的值可以在运行时设置(例如,在构造函数内部),但之后不能更改:

public readonly string Isbn = GenerateIsbn();

使用函数式编程原则

函数式编程原则旨在帮助最小化副作用。除了纯函数和不可变性之外,如表达式代替语句、使用高阶函数和函数组合等原则也可以帮助实现这一目标。虽然我们已经熟悉了前者,但高阶函数和函数组合将在后面的章节中讨论。所以,让我们继续前进——应用这些原则可以大大提高代码的可预测性和可维护性。

封装副作用

当副作用不可避免时,隔离它们至关重要。例如,如果一个函数必须写入文件,那么这应该是它的唯一责任。所有其他逻辑应尽可能分离到纯函数中。这样,副作用就被包含在内,而其余的代码不受影响:

图片

这里的想法是隔离副作用,使它们可预测、可见和管理。

最小化副作用策略对于构建可靠、高效和可维护的软件至关重要。通过实施这些策略,我们逐渐接近函数式编程范式,利用其优势和好处。

接下来,我们将讨论如何使用Pure属性来标记纯函数。

使用Pure属性标记纯函数

理解纯函数和副作用在我们代码中的作用对于在 C#中进行有效的函数式编程至关重要。但我们是怎样传达一个函数应该是纯的意图的呢?这就是Pure属性发挥作用的地方。

理解 C#中的 Pure 属性

在 C#中,Pure属性定义在System.Diagnostics.Contracts命名空间中,并作为声明性标签来指示一个方法是纯的。一个纯方法是指,给定相同的输入,它将始终返回相同的输出,并且不会产生任何可观察的副作用。

重要的是要注意,Pure属性主要用于在代码合约和静态检查工具中使用。运行时和编译器不强制执行方法的纯度,并且这个属性不会以任何方式改变方法的行为:

[Pure]
public static decimal CalculateRoyalty(decimal bookPrice, decimal royaltyPercent)
{
    return bookPrice * royaltyPercent / 100;
}

在这个例子中,我们有一个函数,它根据书籍的价格和版税百分比计算版税金额。它是一个纯函数,因为它总是对相同的输入返回相同的输出,并且没有可观察的副作用。

标记函数为纯的益处

使用Pure属性标记函数带来了一些好处:

  • 清晰性和意图:通过将函数标记为纯的,你向其他开发者传达了你的意图,即这个函数应该保持无副作用。

  • Pure属性帮助识别代码中的潜在问题

  • 优化机会:虽然 C#编译器目前没有利用这一点,但在某些语言和场景中,知道一个函数是纯的可以启用额外的编译器优化。

使用纯属性时的注意事项

当标记函数为纯时,请记住以下几点:

  • Pure属性不强制执行纯度。你可以将方法标记为纯的,它仍然可以有副作用。这个属性更多的是一种通信和分析工具。

  • Pure属性不能与 void 方法一起使用。

  • Pure属性对方法的运行时行为没有影响。它主要被某些静态分析工具使用,例如代码合约。

通过将函数标记为Pure属性,你对自己的函数行为做出了承诺,帮助他人(和工具)更好地理解你的代码。然而,记住属性只是一个工具,而不是万能的。确保函数纯度的责任仍然主要在于开发者。

练习

为了测试史蒂夫的理解,朱莉娅向他提出了三个与纯函数和副作用相关的编码挑战。“这些练习将有助于巩固这些概念,”她解释道。“试一试,如果有什么问题,请告诉我。”

练习 1

史蒂夫的塔防游戏根据全局难度修改器计算伤害。重构此函数以使其成为纯函数:

public static double difficultyModifier = 1.0;
public double CalculateDamage(Tower tower, Enemy enemy)
{
     return tower.BaseDamage * enemy.DamageMultiplier * difficultyModifier;
}

练习 2

史蒂夫的游戏从文件中加载敌人数据,处理它,并更新游戏状态。重构此函数以隔离其副作用:

public void LoadAndProcessEnemyData(string filePath)
{
     string jsonData = File.ReadAllText(filePath);
     List<Enemy> enemies = JsonConvert.DeserializeObject<List<Enemy>>(jsonData);
     foreach (var enemy in enemies)
     {
                  enemy.Health *= GameState.DifficultyLevel;
                  GameState.ActiveEnemies.Add(enemy);
     }
     Console.WriteLine($"Loaded {enemies.Count} enemies");
}

练习 3

通过将其转换为纯函数并标记为Pure属性来重构以下方法:

public string GenerateEnemyCode(string enemyType, int level)
{
     var code = enemyType.Substring(0, 3) + level.ToString();
     return new string(code.OrderBy(c => c).ToArray());
}

这些练习应该有助于巩固我们对所涵盖概念的理解。继续练习,继续实验,并记住——你写的每一行代码都是你在掌握 C#函数式编程旅程上迈出的一步。

解答

这里是本章练习的解答。

练习 1

一个纯函数不应该依赖于或修改其作用域之外的状态。因此,我们不应该依赖于全局的difficultyModifier值,而应该将其作为参数传递:

[Pure]
public double CalculateDamage(Tower tower, Enemy enemy, double difficultyModifier)
{
     return tower.BaseDamage * enemy.DamageMultiplier * difficultyModifier;
}

练习 2

为了隔离副作用,我们将纯逻辑与 I/O 操作和状态变更分开:

public interface IFileReader
{
     string ReadAllText(string filePath);
}
public interface IEnemyRepository
{
     void AddEnemies(IEnumerable<Enemy> enemies);
}
public interface ILogger
{
     void Log(string message);
}
public class EnemyProcessor
{
     private readonly IFileReader _fileReader;
     private readonly IEnemyRepository _enemyRepository;
     private readonly ILogger _logger;
     public EnemyProcessor(IFileReader fileReader, IEnemyRepository enemyRepository, ILogger logger)
     {
              _fileReader = fileReader;
              _enemyRepository = enemyRepository;
              _logger = logger;
     }
     public void LoadAndProcessEnemyData(string filePath, double difficultyLevel)
     {
              string jsonData = _fileReader.ReadAllText(filePath);
              List<Enemy> enemies = DeserializeEnemies(jsonData);
              List<Enemy> processedEnemies = AdjustEnemyHealth(enemies, difficultyLevel);
              _enemyRepository.AddEnemies(processedEnemies);
              _logger.Log($"Loaded {processedEnemies.Count} enemies");
     }
     [Pure]
     private List<Enemy> DeserializeEnemies(string jsonData)
     {
         return JsonConvert.DeserializeObject<List<Enemy>>(jsonData);
     }
     [Pure]
     private List<Enemy> AdjustEnemyHealth(List<Enemy> enemies, double difficultyLevel)
     {
              return enemies.Select(e => new Enemy
              {
                       Health = e.Health * difficultyLevel,
                  // Copy other properties...
              }).ToList();
     }
}

练习 3

这个有点棘手,因为函数已经是纯函数了。我们只需要添加Pure属性来向其他开发者和分析工具传达这种意图:

[Pure]
public string GenerateEnemyCode(string enemyType, int level)
{
     var code = enemyType.Substring(0, 3) + level.ToString();
     return new string(code.OrderBy(c => c).ToArray());
}

这些解答遵循函数式编程的原则,确保最小化副作用并使代码行为清晰。

因此,让我们来谈谈使用纯函数和最小化副作用的一些应该做和不应该做的事情。

这些是应该做的事情:

  • 努力编写更多的纯函数,因为它们是可预测的,易于理解和测试

  • 隔离副作用——也就是说,将它们与纯代码分开

  • 使用readonlyconststatic修饰符来提高不可变性并减少副作用

  • 使用Pure属性来传达意图,有助于代码分析和维护

这些是不应该做的事情:

  • 过度使用全局状态,因为它会导致高耦合并增加副作用的风险。

  • 在函数内部修改输入。这种改变可能导致意外行为。

  • 忘记Pure属性并不强制执行纯度。它是对开发者需要履行的承诺。

  • 忽略上下文。有时,一个非纯函数可以提供更好的解决方案。

概述

深入 C#函数式编程的世界是一次令人兴奋的旅程,而我们才刚刚开始。在本章中,我们探讨了纯函数和副作用的关键概念以及它们在编写更干净、更可预测和可维护的代码中的相应角色。让我们巩固我们所学的知识,并规划未来的课程。

纯函数在软件这个不可预测的宇宙中像一座灯塔一样屹立着。它们有一个明确的契约——相同的输入总是产生相同的输出,并且它们不涉及它们作用域之外的状态。这种简单性使它们可预测、易于测试,并且更易于并行化和优化。

然而,现实世界充满了副作用——读写数据库、调用 API、修改全局变量——这个列表可以一直继续下去。副作用是不可避免的,但如果没有得到控制,它们可能会引发混乱,使得代码难以推理和测试。为了在函数式编程中减轻这个问题,我们必须用不纯的代码包裹纯函数,从而保护它们免受副作用的影响。

在下一章中,我们将讨论一种新的函数类型——诚实函数。我们将讨论它们是什么,如何在 C#中使用它们,以及可空引用可能带来的危险。

第四章:诚实函数、nullOption

在本章中,我们讨论诚实函数的艺术和科学、null 的复杂性以及针对它们定制的 C# 工具。但在我们深入探讨之前,让我们制定路线图:

  • 理解诚实函数

  • 隐藏的 null 的问题

  • 拥抱可空引用类型的诚实

  • 除了 nullOption

  • 现实场景

在你浏览这一章的过程中,要寻找实用的建议和有证据支持的指南。带着好奇心来,到结束时,你将掌握编写更透明和健壮的 C# 代码的知识。

就像我们在前面的章节中所做的那样,让我们评估你的位置。这里有三个任务供你完成。如果你不确定如何应对它们,立即进入这一章。但是,如果你对自己的技能有信心,也许你可以快速浏览并跳到最具挑战性的部分。准备好了吗?让我们开始吧!

任务 1 – 重新设计诚实返回类型

这里有一个史蒂夫的塔防游戏中的函数,根据其 ID 获取一个塔。将其重构为返回一个表示潜在空值的诚实类型:

public Tower GetTower(int towerId)
{
     var tower = _gameState.GetTowerById(towerId);
     return tower;
}

任务 2 – 防止空输入

将以下函数重构为使用预条件来防止空输入,并在预条件未满足时抛出适当的异常:

public void UpgradeTower(TowerUpgradeInfo upgradeInfo)
{
     _gameState.UpgradeTower(upgradeInfo);
}

任务 3 – 使用可空类型进行模式匹配

给定以下类,编写一个方法,接收一个敌人并使用模式匹配返回其描述字符串:

public abstract class Enemy {}
public class GroundEnemy : Enemy
{
     public int Speed { get; set; }
     public int Armor { get; set; }
}
public class FlyingEnemy : Enemy
{
     public int Altitude { get; set; }
     public int DodgeChance { get; set; }
}
public class BossEnemy : Enemy
{
     public int Health { get; set; }
     public string SpecialAbility { get; set; }
}
public string GetEnemyDetails(Enemy? enemy)
{
     // Your code here
}

再次,如果你确信你知道所有三个任务的正确答案,你可以跳过这一章。当然,如果你有任何问题,随时可以回来。现在让我们来讨论诚实函数及其好处。

诚实函数 – 定义和理解

在与朱莉娅关于纯函数和副作用交谈几天后,史蒂夫渴望了解更多。他打电话给朱莉娅询问他接下来应该学习什么。

朱莉娅:让我们来谈谈诚实函数、nullOption 类型,她建议。“这些概念对于编写清晰、可预测的代码至关重要。”

史蒂夫:听起来很棒!但 诚实函数 究竟是什么?

朱莉娅:一个诚实函数在函数与其调用者之间提供了一个清晰、明确的契约,从而使得代码更加健壮,更不容易出错,并且更容易 推理 *。”

那么一个诚实函数究竟是什么呢?

简单来说,一个诚实函数是指其类型签名完全且准确地描述了其行为。如果一个函数声称它将接受一个整数并返回另一个整数,那么它就会做到这一点。没有任何隐藏的陷阱,没有突然抛出的异常,也没有未在函数签名中反映的全局或静态状态的变化。

考虑以下 C# 函数:

public static int Divide(int numerator, int denominator)
{
    return numerator / denominator;
}

初看,这个函数似乎履行了其承诺。它接受两个整数并返回它们的商。然而,当我们传递零作为分母时会发生什么?会抛出DivideByZeroException。这个函数并没有完全对我们诚实;其签名没有警告我们这个潜在的风险。这个函数的诚实版本会在其签名中明确指出失败的可能性。

那么,这种诚实概念如何与函数式编程和软件开发行业的更广泛背景相结合呢?在函数式编程中,函数是代码的构建块。这些函数描述其行为越清晰、越诚实,构建更大、更复杂的程序就越容易。每个函数都作为一个可靠的组件存在,其行为正如其签名所描述的那样,允许开发者自信地组合和重用这些函数。

当朱莉娅解释诚实函数时,史蒂夫的眼睛因理解而亮了起来。

史蒂夫:所以,就像我告诉我的队友我会在周五交付一个功能一样,我应该 真正地做到这一点吗

朱莉娅:没错!在编程中,我们的函数应该 同样可靠

现在,让我们深入探讨使用诚实函数的好处:

  • 提高可读性:诚实函数使代码更易于阅读和理解。无需深入研究实现细节即可了解函数的功能。函数的签名是一个合同,准确描述了其行为。

  • 增强可预测性:使用诚实函数,意外情况大大减少。函数的行为正是签名中描述的那样,导致运行时出现更少的意外错误和异常。

  • 提高可靠性:通过最小化意外情况并明确处理潜在的错误场景,诚实函数导致代码库更加健壮,能够承受现实世界使用的严酷考验。

考虑一个每个函数都是诚实的代码库。任何开发者,无论是否熟悉代码,都可以查看函数签名并立即了解其功能、所需条件和可能返回的内容,包括任何潜在的错误条件。这就像拥有一个文档齐全的代码库,而不需要冗长的文档。这就是诚实函数的力量和承诺。

在接下来的章节中,我们将探讨如何在 C#中实现诚实函数以及如何处理潜在的欺骗行为,例如空值或异常。系好安全带,因为我们对诚实与不诚实函数世界的探索才刚刚开始!

隐藏空值的弊端

史蒂夫回忆起他最近在塔防游戏中的一个错误。

史蒂夫:我想我遇到过这个问题。当我尝试升级一个 不存在的塔 时,我的游戏崩溃了

朱莉娅:这是一个典型的例子,让我们看看我们今天所学的内容如何防止这种情况发生

你是否曾经感到困惑,试图弄清楚为什么你的程序停止工作?大多数时候,问题来自众所周知的 NullReferenceException。本节探讨了 C# 和空值之间复杂的关系,指出了许多开发者面临的问题。

快速回顾一下——C# 和空值

为了理解我们当前的问题,让我们回顾一下。Tony Hoare,一位重要的计算机专家,称空引用为“一个巨大的错误”。这个想法是为开发者提供一个工具来显示值缺失的情况。起初,这似乎是一个好计划,但最终它导致了许多问题和错误,包括 C# 语言。

当 C# 被引入时,它采用了来自较老编程方法的一个想法,允许开发者使用空值来表示某物缺失。但随着时间的推移,这个简单的选择导致了大量的错误和混淆。

常见错误和令人烦恼的 NullReferenceException

所有 C# 开发者,无论是新手还是经验丰富的,都遇到过 NullReferenceException。当你尝试使用不存在的东西时,这个错误会发生。

想象一个获取用户信息的程序。你可能会期望总能找到用户:

var user = FindUserById(userId);
var fullName = $"{user.FirstName} {user.LastName}";

哎呀!如果 FindUserById 无法找到用户,第二行将抛出 NullReferenceException,如果不捕获它,将会终止整个线程的执行。这个错误发生是因为我们以为用户总是会存在的。这显示了隐藏的空值如何在我们的代码中引起意外的问题。

许多程序比这个例子大得多,这使得这些错误难以找到和修复。这些隐藏的问题可能长时间隐藏,在你最不期望的时候引发错误。

隐藏的空值可以被视为看不见的陷阱。它们可以捕捉到新手和经验丰富的程序员。此外,它们与函数式编程的主要理念相悖,函数式编程重视清晰和预期的结果。

并非全是坏事——空值的价值

容易把问题归咎于空值。但真正的问题是它的使用方式。如果使用清晰的方式来表示某物缺失,空值是有帮助的。问题在于其使用不明确,导致许多潜在的错误。

C# 与空值的旅程有起有落。但,正如我们将在下一节中学习的,C# 现在有多种处理空值的方法,使我们的代码更清晰、更直接。其中一种方法就是使用可空引用类型。

用可空引用类型拥抱诚实

在 C# 中处理空值一直是一个很大的挑战。许多软件开发者(包括我)都主张在代码审查清单中检查 NullReferenceException 作为一项强制性任务。在大多数情况下,仅通过查看拉取请求就可以轻松检查可能的空值,即使没有 IDE。最近,当微软引入了可空引用类型时,我们得到了帮助。因此,现在,编译器将加入我们寻找由空值引起的可能灾难的行列。

什么是可空引用类型?

简而言之,可空引用类型(或简称NRTs)是 C#中的一个特性,允许开发者明确地指出一个引用类型是否可以为空。有了这个特性,C#为我们提供了一个工具,从代码一开始就清楚地表达我们的意图。把它想象成一个路标,引导其他开发者(甚至是我们未来的自己)了解我们的代码应该期待什么。

没有 NRTs,C#中的每个引用类型都可能被赋值为 null。这会变成一个猜谜游戏。这个变量会具有值还是会被赋值为 null?现在,有了 NRTs,我们不再需要猜测。代码本身就在讲述这个故事。

让我们通过一个基本示例来理解这个概念:

string notNullable = "Hello, World!";
string? nullable = null;

在前面的代码片段中,notNullable 变量是一个普通的字符串,不能被赋值为 null(如果你尝试这样做,编译器会发出警告)。另一方面,自从 C# 8.0 开始,可空类型会明确地用 ? 标记,表示它可以被赋值为 null

在某些情况下,你可能想将 null 赋值给未标记为可空的变量。在这种情况下,为了抑制警告,你可以使用 ! 符号来让编译器知道你已经意识到自己在做什么,并且一切都在计划中进行:

string notNullable = "Hello, World!";
notNullable = null!;

NRTs(可空引用类型)最大的优点之一是,C# 编译器会警告你如果在使用可空值时可能正在执行有风险的操作。这就像有一个友好的向导始终在关注你的肩膀,确保你不会陷入常见的空值误用的陷阱。

例如,如果你尝试在未检查 null 的情况下访问可空引用类型的属性或方法,编译器会提前警告你。

转向 NRTs

对于那些有现有 C#项目的开发者来说,你可能想知道:如果我启用 NRTs,我的项目会被警告信息充斥吗? 答案是否定的。默认情况下,NRTs 是关闭的。你可以选择按文件启用此功能,以便平稳过渡。

NRTs 是解决长期存在的空引用挑战的好方法。通过在我们的代码中明确表示空值的存在,我们迈出了向清晰、安全和功能诚实的大步。最终,拥抱 NRTs 不仅使我们的代码更加健壮,而且确保了我们的意图,作为开发者,是透明的。

启用可空引用类型

要启用 NRTs,我们需要告诉 C#编译器我们已经准备好接受它的指导。这是通过一个简单的指令完成的:#nullable enable

在你的 .cs 文件开头放置以下内容:

#nullable enable

从文件的这个点开始,编译器创建了一个特定的可空上下文,并假设所有引用类型默认为不可空。如果你想使一个类型为可空,你必须明确地用 ? 标记它。

启用 NRTs 后,C#编译器成为你的安全网,指出代码中潜在的空值问题。无论何时你尝试将 null 赋值给没有 ? 标记的引用类型,或者尝试访问未检查的可空变量,编译器都会发出警告。

这里有一个例子:

string name = null; // This will trigger a warning
string? maybeName = null; // This is okay

禁用可空引用类型

在将项目过渡到使用 NRTs 的过程中,你的代码中可能会有一些部分你希望延迟过渡。你可以使用#nullable disable指令关闭这些特定部分的 NRTs:

#nullable disable

这告诉编译器恢复到旧的行为,将所有引用类型视为可能为 null。

你可能会想知道为什么 C#选择使用指令来实现这个特性。原因是灵活性。通过使用指令,开发者可以逐步将 NRTs(非空引用类型)引入到他们的项目中,一次一个文件,甚至一次一个代码段。这种分阶段的方法使得适应现有项目变得更加容易。

警告和注释选项

说到分阶段的方法,还有两个选项可以设置我们的可空上下文:warningsannotations。你可以通过编写以下内容来使用它们:

#nullable enable warnings

或者,你可以这样写:

#nullable enable annotations

这些选项的主要目的是为了简化将现有代码从完全禁用的 null 上下文迁移到完全启用上下文的过程。简而言之,我们希望通过开启warnings选项来获取解引用警告。当所有警告都修复后,我们可以切换到annotations。这个选项不会给我们带来任何警告,但它将开始将我们的变量视为不可为 null,除非用?标记声明。

要获取有关这些选项和生成文件中 null 上下文的信息,以及了解更多关于三种 nullability(不可知、可空和非空)的内容,我建议你阅读文章《可空引用类型》(learn.microsoft.com/en-us/dotnet/csharp/nullable-references)。你可能还想阅读文章“使用可空引用类型更新代码库以改进 null 诊断警告”(learn.microsoft.com/en-us/dotnet/csharp/nullable-migration-strategies)。

更大的图景——项目级设置

虽然指令对于细粒度控制很好,但你也可以为整个项目启用 NRTs。在项目设置中,或直接在.csproj文件中,将<Nullable>元素设置为启用:

<PropertyGroup>
    <Nullable>enable</Nullable>
</PropertyGroup>

这个设置将项目中的每个文件都视为从#nullable enable指令开始。当你为整个项目开启可空上下文时,你可能希望保留一些代码部分不生成警告,但在之后使用项目级别的可空上下文。在这种情况下,你可以使用restore选项:

#nullable enable
// The section of the code where nullable reference types are enabled.
#nullable restore

启用可空引用类型就像在之前昏暗的房间里打开一盏灯。它揭示了潜在的陷阱,并确保我们编写更安全、更清晰的代码。有了 C#提供的工具,我们对此功能既有细粒度的控制,也有广泛的控制,使得过渡到更透明的编码风格既可行又有益。

有意返回

在函数式编程领域,函数的主要目标是透明。透明意味着函数不仅应该做其名称暗示的事情,而且其返回类型应该提供对预期内容的清晰契约。让我们深入探讨在 C#中构建诚实的返回类型。

经验丰富的开发者知道,一个函数的名称或签名本身可能无法描述整个故事。考虑以下:

UserProfile GetUserProfile(int userId);

表面上看,这个函数似乎承诺根据用户 ID 获取用户资料。然而,疑问仍然存在。如果用户不存在怎么办?如果检索资料时出现错误怎么办?

现在考虑一个替代方案:

UserProfile? GetUserProfile(int userId);

通过简单地在返回类型中引入?,函数变得更加透明地表达了其意图。它暗示着:“我将尝试为这个 ID 获取用户资料。但有可能你会得到一个 null。”

UserProfile 和 UserProfile?之间的区别

虽然这种区别可能看起来微不足道,但其影响是深远的:

  • UserProfile?,你立刻知道有可能得不到用户资料。

  • 防御性编程:知道返回值可能为 null,你会自然地编写更安全的代码来处理这些情况。

  • 错误处理:而不是使用异常或错误代码来表示数据缺失,可空返回类型提供了一种清晰、类型安全的方式来表达缺失的可能性。

让我们看看实际应用:

var userProfile = GetUserProfile(userId);
if (userProfile is null)
{
    // Handle the scenario when the profile is not available
}
else
{
    // Proceed with the user profile data
}

正如你所见,借助 NRTs(非空引用类型),我们可以更清晰地理解代码并正确处理其结果。而我们为了实现这一点所做的,仅仅是稍微调整了方法签名。

尊重函数的契约

一个诚实的函数不仅仅表明可能返回 null,它还创造了一种“有意返回”的心态。当有意返回时,你不仅仅是返回数据;你是在传达一种状态。

考虑以下示例。

null检查并避免潜在的NullReferenceException

List<Order> GetOrdersForUser(int userId)
{
    return ordersRepository.FindByUserId(userId) ?? new List<Order>();
}

获取数据:在检索可能不存在的数据时,而不是抛出异常,可空类型可以更清晰地描绘情况:

Product? FindProductById(int productId)
{
    return productsRepository.GetById(productId);
}

透明的返回类型导致更少的意外和更健壮的代码。通过清楚地传达函数可以返回的内容,你做了以下事情:

  • 减少错误:因为开发者将处理他们可能忽视的其他场景

  • 提高清晰度:开发者花费更少的时间挖掘函数实现,依靠返回类型来指导行为

  • 建立信任:清晰的契约确保函数履行其承诺,在代码库中创造一种可靠性感

随着我们继续探索诚实的函数,请始终记住:你的函数既是执行者也是沟通者。让它们不仅完成其任务,而且透明地传达它们的意图和潜在结果。这样做,你构建了弹性系统,创建了清晰的契约,并鼓励一个更安全、更可预测的编码环境。

对函数输入提出诚实的要求

一个真正健壮的系统不仅关乎你返回什么,还关乎你接受什么。保护你的函数免受可能误导或有害的输入是创建可预测、容错应用程序的关键。

考虑这个常见的场景:你有一个期望特定类型输入的函数。然而,当 null 值悄悄进入时,你的函数会崩溃,导致臭名昭著的 NullReferenceException。为了减轻这种情况,C# 提供了一种使用可空引用类型要求函数输入诚实的方法。

假设你定义了一个函数如下:

public void UpdateUserProfile(UserProfile profile)
{
    // Some operations on profile
}

目的明确:函数期望 UserProfile。然而,什么阻止了开发者传递 null

可空引用类型拯救了

如我们之前讨论的,在 C# 8.0 中,可空引用类型增加了另一层保护。通过使用 #nullable enable 启用可空引用类型,编译器变成了你的守护者:

#nullable enable
public void UpdateUserProfile(UserProfile profile)
{
    // Some operations on profile
}

现在,如果任何开发者尝试将可空的 UserProfile 传递给函数,编译器将发出警告。这促使开发者走向正确的方向,并防止潜在的运行时错误。

然而,警告并不能保证不会使用 null,所以让我们看看另一种方法。在这里,我们用最简单的保护方法——直接的空检查来捍卫这个方法:

public void UpdateUserProfile(UserProfile profile)
{
    if (profile is null)
    {
        throw new ArgumentNullException(nameof(profile), "Profile cannot be null!");
    }
    // Some operations on profile
}

这个检查确保如果函数被提供了 null,它将立即停止执行并提供一个明确的原因。

使用先决条件和契约

契约和先决条件将保护的概念提升到了一个新的水平。通过定义一组在函数可以继续之前必须成立的规则,你使得函数的期望变得明确。

考虑微软提供的 CodeContracts (github.com/Microsoft/CodeContracts) 库。有了这个库,你可以使用更丰富的语法来确保函数的先决条件:

public void UpdateUserProfile(UserProfile profile)
{
    Contract.Requires<ArgumentNullException>(profile != null, "Profile cannot be null!");
    // Some operations on profile
}

这段代码更加简洁,但它以同样的方式保护我们的方法免受 null 值的影响。

使用内置检查

在 C# 6.0 中,我们得到了一种新的方法来防止空值 - ArgumentNullException.ThrowIfNull

public void UpdateUserProfile(UserProfile profile)
{
    ArgumentNullException.ThrowIfNull(profile);
    // Some operations on profile
}

现在,这段代码看起来更加整洁,也更容易阅读。此外,C# 7.0 中出现了一种更完善的方法来处理字符串:ArgumentException.ThrowIfNullOrEmpty。它不仅检查是否为空,还确保字符串不为空:

public void UpdateUserEmail(long userId, string email)
{
    ArgumentException.ThrowIfNullOrEmpty(email);
    // Updating the email after finding the user by ID
}

显式非空输入的力量

通过要求非空参数,你做了以下几件事:

  • 提高可预测性:函数的行为符合预期,因为恶意的 null 值不会使它们出轨

  • 提升开发者信心:有了清晰的契约,开发者可以调用函数而无需犹豫

  • 减少调试时间:在编译时捕捉潜在问题总是比运行时调试快

为了编写好的函数式代码,确保你接受的内容清晰,就像确保你返回的内容透明一样重要。通过要求函数输入诚实,您为编写既可靠又具有弹性的代码奠定了坚实的基础。

模式匹配和可空类型

模式匹配是 C#工具箱中的强大工具,它不仅使代码更具表现力,而且更清晰、更安全。当与可空类型结合使用时,模式匹配帮助我们保护代码免受潜在陷阱的影响。

模式匹配,自 C# 7.0 引入并在后续版本中增强,是一种允许您将值与模式进行比较的功能,当值符合模式时,提供从值中提取信息的方法。

考虑经典的switch语句,结合模式匹配进化而来:

object tower = GetRandomTower();
switch (tower)
{
    case ArcherTower a:
        Console.WriteLine($"It's an Archer Tower with a range of {a.Range}!");
        break;
    case CannonTower c:
        Console.WriteLine($"It's a Cannon Tower with an explosion radius of {c.ExplosionRadius}!");
        break;
    default:
        throw new Exception("Unknown tower type!");
        break;
}

在这里,ArcherTowerCannonTower是类型。如果towerArcherTower类型,它不仅进入相应的 case 块,还将它转换为ArcherTower类型,允许您直接访问其属性。

带有可空类型的模式匹配

模式匹配在处理可空类型时真正大放异彩。让我们考虑一个获取用户配置文件的情况:

UserProfile? profile = GetUserProfile(userId);

我们如何以类型安全和清晰的方式处理这种潜在的 null?请进入模式匹配。

使用“is”模式

理想情况下,我们的代码应该易于阅读,还有什么比使用普通英语更容易呢?“is”模式就是为了帮助实现这一点:

if (profile is null)
{
    Console.WriteLine("Profile not found.");
}
else
{
    Console.WriteLine($"Welcome, {profile.Name}!");
}

这立即使代码更易于阅读,清楚地区分了不同的情况。

带有属性模式的 switch 表达式

自 C# 8.0 引入的switch表达式与属性模式结合,提供了一种更简洁的方式来处理复杂条件:

public class Book
{
    public bool IsPublished { get; set; }
    public bool IsDraft { get; set; }
}
string bookStatus = book switch
{
    null => "No book found",
    { IsPublished: true, IsDraft: false } => "Published Book",
    { IsPublished: false, IsDraft: true } => "Draft Book",
    { IsPublished: false, IsDraft: false } => "Unpublished Book",
    _ => "Unknown book status"
};

在这里,我们不仅检查 null,还检查Book对象的特定属性,根据它们的值做出决策。

使用可空类型确保清晰性

模式匹配与可空类型的结合确保以下内容:

  • 安全性:通过显式处理潜在的 null,您降低了运行时错误的风险

  • 表现力:模式允许您将复杂的条件逻辑压缩成简洁、易读的结构

  • 可读性:不同条件和结果之间的清晰区分使开发者能够轻松理解流程。

当与可空类型结合使用时,模式匹配是任何 C#开发者的强大工具。它不仅简化了代码,而且增强了代码,使您能够编写对意外情况更具弹性的应用程序。使用它,您的代码将既易于编写,又可靠。

空对象模式

空对象模式是一种设计模式,它提供了一个对象作为给定接口中缺少的对象的代理。本质上,它在没有有意义的数据或行为的情况下提供默认行为。这种模式在预期有对象但实际没有,且不希望不断检查null的场景中特别有用。

null检查的问题

想象你正在开发一个系统,在这个系统中,你需要在User对象上执行一系列操作。现在,并不是每个用户都可能在系统中初始化,这通常会导致以下情况:

if (user != null)
{
    user.PerformOperation();
}

这对于单个检查可能看起来很无害。但当你代码库中充满了这样的null检查时,代码变得冗长且难以阅读。null检查的泛滥也可能掩盖主要业务逻辑,使得代码库更难以维护和理解。

空对象解决方案

空对象模式为这个问题提供了一个优雅的解决方案。而不是使用空引用来传达对象的缺失,你创建了一个实现预期接口但不做任何事情的对象——一个“空对象”。

这里有一个例子:

public interface IUser
{
    void PerformOperation();
}
public class User : IUser
{
    public void PerformOperation()
    {
        // Actual implementation here
    }
}
public class NullUser : IUser
{
    public void PerformOperation()
    {
        // Do nothing: this is a null object
    }
}

在用户不可用的情况下,你将返回NullUser的实例,而不是返回null

现在,当你想要执行操作时,你可以自信地这样做,而无需检查null

user.PerformOperation();

无论userRealUser还是NullUser,代码都不会抛出NullReferenceException

优势

实现空对象模式提供了几个关键优势:

  • 减少条件语句: 你可以减少显式null检查的数量,从而得到更干净、更易读的代码。

  • 安全性: null引用异常的风险大大降低。

  • 多态性: 通过将空对象与其他对象同等对待,你可以利用多态的力量,这可以简化并澄清代码。

  • 意图的清晰性: 空对象可以有有意义的名称,这使得在“什么都不做”或默认行为是故意的时非常清楚。

局限性和考虑因素

虽然空对象模式有其优点,但它并不总是最佳解决方案:

  • 开销: 对于非常大的系统或深度嵌套的结构,在各个地方引入空对象可能会增加开销

  • 复杂性: 如果接口或基类频繁更改,维护相应的空对象可能会变得繁琐

  • 隐藏的错误: 如果你不够小心,使用空对象可能会潜在地掩盖系统中的问题,因为它们提供了默认行为,可能会隐藏其他情况下由空引用暴露的问题。

空对象模式是 C#开发者工具箱中的强大工具。它不是万能的解决方案,但当你明智地应用它时,它可以极大地提高代码的清晰度和健壮性。像所有设计模式一样,理解何时何地应用它是至关重要的。所以不要急于在所有地方使用它,让我们看看一种更函数式的方法——Option类型。

超越null – 使用Option

Nullable<T>,它是 C#中内置的,是一个处理null值的好工具,但有一个更简单直接的结构来处理null值。Option是一种类型,它提供了更多表达性的工具来传达值的缺失或存在,作为可空类型的更丰富替代品。

Option的简要介绍

在其核心,Option类型可以被视为一个可能包含或不包含值的容器。通常,它表示为Some(包装一个值)或None(表示值的缺失)。

通常,Option的实现看起来像这样:

public struct Option<T>
{
     private readonly bool _isSome;
     private readonly T _value;
     public static Option<T> None => default;
     public static Option<T> Some(T value) => new Option<T>(value);
     Option(T value)
     {
          _value = value;
          _isSome = _value is not null;
     }
     public bool IsSome(out T value)
     {
          value = _value;
          return _isSome;
     }
}

虽然 C# 没有内置的Option类型,但你可以使用上面的类型或使用像 LanguageExt (github.com/louthy/language-ext)这样的库,它提供了这个功能。

现在,让我们看看我们如何利用Option类型:

public Option<UserProfile> GetUserProfile(int userId)
{
    var user = database.FindUserById(userId);
    return new Option<UserProfile>(user);
}

使用Option的另一种方法是编写以下内容:

public Option<UserProfile> GetUserProfile(int userId)
{
    var user = database.FindUserById(userId);
    return user is not null
   ? Option<UserProfile>.Some(user)
   : Option<UserProfile>.None;
}

虽然这段代码更直接,但之前的代码更简洁。

现在,当调用此函数时,我们可以以更表达的方式处理结果:

var profileOption = GetUserProfile(userId);
UserProfile profile;
if (!profileOption.IsSome(out profile))
{
     // Handle the scenario when the profile is not available
}
// Continue with profile operations

这种方法使处理潜在缺失的值变得明确和清晰。

当史蒂夫深入研究Option类型时,他想“这让我想起了我们在游戏中处理升级的方式,有时它们在那里,有时它们不在。”

Option类型相对于可空类型的优势

当涉及到处理可能缺失的值时,选择Option类型在你的代码中具有明显的优势:

  • SomeNone构造使得代码的意图一目了然。有值和无值之间有一个清晰的区分。

  • Option类型强制你处理SomeNone两种情况,减少了代码中的潜在疏忽。

  • Option类型通常与其他函数式方法一起工作,实现强大且简洁的转换。

Option和可空类型之间的相互作用

值得注意的是,虽然Option类型提供了一个强大的机制来管理可选值,但它并不完全取代可空类型。相反,将它们视为你的工具箱中的工具,每个都有其优势:

  • 当使用原生 C#构造或与期望它们的库/框架交互时,请使用可空引用类型

  • 在需要更丰富函数操作或构建具有函数式风格的库的场景中采用Option类型

Option 类型为 C#世界带来了纯函数式编程的滋味,提供了一套复杂的工具集来处理可选值。通过将这些结构集成到您的应用程序中,您可以提高代码的清晰度、稳健性和表现力。虽然学习曲线可能比可空类型更陡峭,但就代码质量和弹性而言,这些回报是值得努力的。

实际场景 – 有效处理空值

正如您可能已经看到的,处理空值不仅仅是一个理论上的问题;它是一个日常挑战。通过分析现实世界的情况,我们可以更好地理解在管理空值和确保稳健、可靠的应用程序中明确策略的必要性。

案例研究 – 管理 YouTube 视频

场景: 一个系统从数据库检索视频详细信息。并非每个查询的视频 ID 都会有一个相应的视频条目。

传统方法:

public Video GetVideoDetails(int videoId)
{
    var video = database.FindVideoById(videoId);
    if (video == null)
    {
        throw new VideoNotFoundException($"Video with ID {videoId} not found.");
    }
    return video;
}

选项方法:

public Option<Video> GetVideoDetails(int videoId)
{
    var video = database.GetVideoById(videoId);
    return new Option<Video>(video);
}

通过返回Video?,我们明确地表示视频可能不存在。调用函数可以使用模式匹配或直接空值检查来优雅地处理缺失情况。

案例研究 – 管理不同视频类型

场景: 随着 YouTube 开始支持各种视频格式和来源(例如,直播、360 度视频、标准上传),后端系统必须正确识别和处理每种视频类型。随着平台的日益复杂和新的视频格式被引入,使用传统的 if-else 语句来处理这些情况变得越来越繁琐且难以维护。模式匹配成为解决这一挑战的有效解决方案。

传统方法:

public string GetVideoDetails(Video video)
{
    if (video is LiveStream)
    {
        var liveStream = video as LiveStream;
        return $"Live Stream titled '{liveStream.Title}' is currently {liveStream.Status}.";
    }
    else if (video is Video360)
    {
        var video360 = video as Video360;
        return $"360-Degree Video titled '{video360.Title}' with a resolution of {video360.Resolution}.";
    }
    // ... and so on for other video types
    else
    {
        return "Unknown video type.";
    }
}

模式匹配方法:

public string GetVideoDetails(Video video)
{
    return video switch
    {
        LiveStream l => $"Live Stream titled '{l.Title}' is currently {l.Status}.",
        Video360 v => $"360-Degree Video titled '{v.Title}' with a resolution of {v.Resolution}.",
        StandardUpload s => $"Standard video titled '{s.Title}' uploaded on {s.UploadDate}.",
        _ => "Unknown video type."
    };
}

模式匹配提供了一种更优雅、简洁和可读的方法来处理不同的视频类型。随着 YouTube 引入新的视频格式或功能,它们可以无缝集成到GetVideoDetails函数中。这种现代方法减少了潜在的错误,简化了代码,并提高了可维护性。

案例研究 – 处理不存在的对象

场景: 随着 YouTube 在全球范围内的扩张,由于各种原因(如网络故障、数据迁移问题或区域内容限制),遇到不完整或缺失数据的情况越来越普遍。使用传统的空值检查变得越来越繁琐,导致代码库中逻辑分散。空对象模式提供了一种系统性的方法,通过提供一个默认对象而不是空引用来解决问题。

传统方法:

public Video GetVideo(int videoId)
{
    var video = database.FindVideoById(videoId);
    if (video == null)
    {
        throw new VideoNotFoundException($"Video with ID {videoId} not found.");
    }
    return video;
}

当显示视频时:

var video = GetVideo(videoId);
if(video != null)
{
    Display(video);
}
else
{
    ShowError("Video not found");
}

空对象模式方法:

首先,为视频创建一个默认对象:

public class NullVideo : Video
{
    public override string Title => "Video not available";
    public override string Description => "This video is currently not available.";
    // Other default properties or methods...
}

然后修改获取方法:

public Video GetVideo(int videoId)
{
    return database.FindVideoById(videoId) ?? new NullVideo();
}

然后显示视频:

var video = GetVideo(someId);
Display(video); // No special null handling here

通过采用空对象模式,YouTube 的视频管理系统成功地将与空或缺失数据相关的行为封装在默认的“空对象”中,从而实现了更统一和可预测的系统行为。它消除了代码库中散布的许多空值检查,减少了空引用异常的可能性,并增强了系统的健壮性和可维护性。

在现实世界场景中处理空值的影响

在现实生活中的情况下,我们如何处理代码中的空值可能会有很大的影响。让我们探讨一下处理空值如何产生差异:

  • NullReferenceException

  • 更清晰的意图:使用诸如可空引用类型和模式匹配等工具,我们的代码更透明地传达潜在的结果

  • 开发者信心:当系统的行为可预测时,开发者可以更有信心地进行集成和扩展

在现实世界的场景中,未知和不确定性很多。通过引入对潜在空值的清晰和透明处理,我们确保我们的应用程序既具有弹性又易于维护。无论您是在管理视频数据、处理表单还是与第三方服务集成,对空值管理的深思熟虑的方法都可以产生重大差异。

C# 中诚实的现实——为什么永远不会真正有诚实的函数

C# 是一种多范式语言,提供了许多功能,每个功能都是考虑到各种因素而设计的。当我们探讨“诚实函数”这一主题时,我们也必须承认,尽管 C# 的设计非常强大,但也存在一些权衡。

C# 语言设计的妥协

C# 的设计涉及某些权衡。让我们来检查它们并了解它们的影响:

  • 历史包袱:C# 在这些年中不断发展,添加了新功能同时确保向后兼容。这意味着较老、不那么“诚实”的做法将始终是语言的一部分。

  • 性能与安全性的权衡:提供低级性能导向功能和高级安全功能并不容易。有时,性能的要求可能会让开发者偏离纯粹的“诚实”结构。

  • 广泛的受众:C# 是为广泛的开发者设计的,从编写系统级代码的人到高级企业应用程序的开发者。因此,语言不能过于偏袒任何单一范式,包括函数式编程。

  • OutOfMemoryException,例如。虽然,我们可能会认为如果我们是代码的创造者,它就会按照我们的意愿行事,但别忘了公共语言运行时CLR)才是真正的掌控者,它可以干预我们的意图。

C#提供了丰富的功能,使开发者能够在不同的范式下构建解决方案。在我们追求诚实和清晰的过程中,我们应该认识到并尊重语言设计内在的权衡。此外,我们需要记住,我们的代码并不在一个理想的世界中运行,环境也会影响我们的程序的工作方式。

实用技巧和最佳实践

在我们探索 C#中函数式编程的细微差别及其处理 null 的方法时,一些实用的策略浮现出来。这些策略确保我们的应用程序保持稳健,同时受益于函数范式提供的增强清晰度和可预测性。

将现有代码库迁移以采用可空引用类型和 Option 的策略

让我们考虑将现有代码库迁移以采用可空引用类型和Option的策略:

  • #nullable enable 指令:

    #nullable enable
    // The section of the code where nullable reference types are enabled.
    #nullable restore
    
  • 使用分析工具:如 Roslyn 分析器(docs.microsoft.com/en-us/visualstudio/code-quality/roslyn-analyzers-overview)等工具可以帮助识别代码中的潜在 nullability 问题。

  • Option类型,确保你理解调用代码的影响。函数可能返回不同的类型,需要调整调用逻辑。

常见陷阱及其规避方法

让我们更深入地看看常见的陷阱,并了解如何有效地规避它们:

  • 过早假设非 null 值:即使启用了可空引用类型,也始终要验证输入,尤其是如果它们来自外部来源:

    public void ProcessData(string? data)
    {
        if (data is null)
        {
            throw new ArgumentNullException(nameof(data));
        }
        // Rest of the processing...
    }
    
  • Option类型功能强大,可能并不适合每个场景。对于 nullability 显而易见的简单情况,可空引用类型可能更为合适。

  • 忘记遗留代码:代码库中的旧部分可能不符合新的范式。在整合新旧代码时,要小心潜在的期望不匹配。

测试策略:处理 null 和 Option

让我们讨论如何以函数式编程的方式测试处理 null 和Option的代码。确保代码正确工作非常重要,现在我们将探讨如何做到这一点:

  • 使用 null 值的单元测试:确保单元测试覆盖将 null 值传递给函数的场景。这有助于在这些问题到达生产环境之前捕捉潜在的 null 相关问题:

    [Test]
    public void GetUser_NullInput_ThrowsException()
    {
        // arrange part of the test
        // act & assert
        Assert.Throws<ArgumentNullException>(() => sut.GetUser(null));
    }
    
  • Option类型,测试应涵盖SomeNone场景,以确保所有代码路径都得到验证:

    [Test]
    public void GetUser_PresetUserId_ReturnsProfile()
    {
        // arrange part of the test
        // act
        var result = sut.GetUser(123);
        // assert
        User user;
        if (!result.IsSome(out user))
        {
            Assert.Fail("Expected a user profile.");
        }
        // The rest of the assertions
    }
    
  • 集成测试:除了单元测试之外,还应该使用集成测试来验证不同组件之间的交互,尤其是在处理可能返回意外 null 值的数据库、API 或其他外部系统时。

在 C# 中学习函数式编程可能不是一件容易的事情,但你已经开始了这个旅程,并一直持续到现在的代码行。你做得很好,所以继续保持,让我们通过我们传统的三个练习来巩固你获得的知识。

练习

现在,史蒂夫已经了解了诚实函数、空值和 Option 类型,朱莉娅已经准备了一些挑战,帮助他将这些概念应用到他的塔防游戏中。让我们看看你是否能帮助史蒂夫解决这些问题!

练习 1

史蒂夫的游戏需要可靠地获取塔信息。重构这个函数以使用诚实的返回类型,明确指示塔可能找不到的情况:

public Tower GetTowerByPosition(Vector2 position)
{
     var tower = _gameMap.FindTowerAt(position);
     return tower;
}

练习 2

在游戏中,玩家可以将增强道具应用到塔上。重构这个函数以确保它能够优雅地处理空输入:

public void ApplyPowerUp(Tower tower, PowerUp powerUp)
{
     tower.ApplyPowerUp(powerUp);
     _gameState.UpdateTower(tower);
}

练习 3

史蒂夫希望向玩家提供他们面对的敌人的详细信息。使用他游戏中的敌人类,实现一个函数,为每种敌人类型生成描述性字符串:

public abstract class Enemy {}
public class Goblin : Enemy
{
     public int Strength { get; set; }
     public bool HasWeapon { get; set; }
}
public class Dragon : Enemy
{
     public int FireBreathDamage { get; set; }
     public int WingSpan { get; set; }
}
public class Wizard : Enemy
{
     public string[] Spells { get; set; }
     public int MagicPower { get; set; }
}
public string DescribeEnemy(Enemy? enemy)
{
     // Your implementation here
}

解决方案

练习 1

Option 返回类型纳入,以表示用户可能找到或不找到:

public Option<Tower> GetTowerByPosition(Vector2 position)
{
     var tower = _gameMap.FindTowerAt(position);
     return Option<Tower>.Some(tower);
}

练习 2

利用内置的空输入检查,如果用户配置文件为 null,则抛出异常:

public void ApplyPowerUp(Tower? tower, PowerUp? powerUp)
{
     ArgumentNullException.ThrowIfNull(tower, nameof(tower));
     ArgumentNullException.ThrowIfNull(powerUp, nameof(powerUp));
     tower.ApplyPowerUp(powerUp);
     _gameState.UpdateTower(tower);
}

练习 3

使用模式匹配优雅地处理潜在的空值:

public string DescribeEnemy(Enemy? enemy)
{
     return enemy switch
     {
                  Goblin g => $"A goblin with {g.Strength} strength, {(g.HasWeapon ? "armed" : "unarmed")}.",
                  Dragon d => $"A dragon with {d.FireBreathDamage} fire breath damage and a {d.WingSpan}m wingspan.",
                  Wizard w => $"A wizard with {w.MagicPower} magic power, knowing {w.Spells.Length} spells.",
                  null => "No enemy in sight.",
                  _ => "An unknown enemy approaches!"
     };
}

这些练习及其解决方案提供了对所涵盖概念的应用理解,指导你走向处理空值和诚实函数的实用且健壮的方法。继续实验,不断迭代,并始终遵循函数式编程的原则,以编写更清晰、更具弹性的 C# 代码。

摘要

随着我们通过诚实函数和 C# 中空值处理的复杂性的旅程即将结束,让我们反思我们的发现并展望未来。

我们已经探讨了 C# 中 null 的历史和影响。我们已经理解了它的细微差别、它的危险和它的力量。到现在,臭名昭著的 NullReferenceException 应该不再是你的敌人,而是一个你在房间另一边点头致意的老熟人,承认它的存在,但永远不会让它打扰你的日常生活。

诚实函数——或者明确声明其意图、输入和输出的函数——代表了向可预测性、清晰性和弹性的范式转变。在函数中拥抱诚实不仅仅是为了避免陷阱,更是为了拥抱一种透明度的哲学。通过这样做,我们创建的代码可以让其他开发者信任、理解和在此基础上构建。

我们已经深入研究了可空引用类型和模式匹配的领域,甚至触及了空对象模式和 Option 类型,所有这些都为我们提供了强大的工具,以精确和优雅地表达我们的意图。

然而,就像所有工具一样,我们必须记住,它们的强大之处在于它们的适当应用。C#的世界广阔无垠,虽然函数式编程原则提供了很多价值,但它们只是丰富多彩的画卷中的一个方面。选择何时应用这些概念,何时转向其他范式,取决于你,作为开发者。当我们把函数式范式添加到我们的习惯性编码方式中时,可能会引发不同的错误,我们最好准备好与他们一起工作。这就是为什么我邀请你阅读下一章,关于错误处理。

第二部分:高级函数式技术

在第一部分建立的基础之上,我们现在深入探讨更高级的函数式编程技术。我们将从探索错误处理的函数式方法开始,超越传统的 try-catch 块,到更优雅的解决方案。接下来,我们将介绍高阶函数和委托,解锁函数作为一等公民的力量。本节以对 functors 和 monads 的深入探讨结束,这些高级概念为管理代码中的复杂性提供了强大的工具。

本部分包含以下章节:

  • 第五章**,错误处理

  • 第六章**,高阶函数和委托

  • 第七章**,Functors 和 Monads

第五章:错误处理

欢迎来到第五章!您做得很好!在本章中,我们将讨论函数式编程提供的新方法来处理错误。我们将通过以下部分来实现这一点:

  • C#中的传统错误处理

  • 结果类型

  • 面向铁路编程(ROP)

  • 设计自己的错误处理机制

  • 功能性错误处理的实用技巧

  • 传统与函数式错误处理比较

  • 功能性错误处理中的模式和反模式

史蒂夫今天真的很沮丧,因为他花了过去三天的时间修复错误,以至于没有时间写哪怕一行新的代码。此外,基准测试显示,带有 try-catch 块的代码比没有它们的代码运行速度慢得多,而且他的代码中有很多这样的块。因此,他决定问茱莉亚是否有更好的方法来使用函数式方法处理错误。她给史蒂夫发了一篇关于使用函数式编程进行错误处理的详细文章。

如您所见,本章深入探讨了函数式技术,这将帮助您不仅处理错误,而且以干净、高效和可维护的方式进行错误处理。在我们深入探讨之前,让我们看看这三个自我评估任务。

任务 1 – 自定义错误类型和结果使用

这里是史蒂夫的塔防游戏中一个升级塔并返回布尔值的函数。将其重构为返回Result类型,当升级失败时返回自定义错误:

public bool UpgradeTower(Tower tower)
{
     // Tower upgrading logic...
     if (/* upgrade fails */)
     {
                  return false;
     }
     return true;
}

任务 2 – 利用 ROP 进行验证和处理

史蒂夫有一个涉及解析、验证和处理敌人生成的流程。使用面向铁路编程ROP)重构它以改进错误处理流程:

public void ProcessEnemySpawn(string enemyData)
{
     var parsedData = ParseEnemyData(enemyData);
     if (parsedData.IsValid)
     {
                  var validation = ValidateEnemySpawn(parsedData);
                  if (validation.IsValid)
                  {
                      SpawnEnemy(validation.Enemy);
                  }
     }
}

任务 3 – 使用函数式技术实现重试机制

编写一个函数,实现一个用于故障塔射击操作的重试机制,并返回Result类型。该函数应在返回错误之前重试操作指定次数:

public bool TowerFire(Tower tower, Enemy enemy)
{
     // Sometimes works and returns true
     // sometimes doesn't and returns false
}

如果这些任务对您来说很容易,您现在可以自由地跳过这一章,稍后再回来,当您阅读了所有其他章节或对错误处理有任何疑问时。

C#中的传统错误处理

每个 C#开发者,无论是新手还是专家,都遇到过 try-catch 块。它一直是防止意外行为和系统故障的主要保护措施。在我们了解函数式范式提供的内容之前,让我们回顾一下这种传统机制。

try-catch 块

try-catch 块尝试执行操作,如果失败,控制权将转移到 catch 块,确保应用程序不会崩溃。例如,假设我们正在进行一个简单的文件读取操作:

string content;
try
{
    content = File.ReadAllText("file.txt");
}
catch (FileNotFoundException ex)
{
    content = string.Empty;
    LogException(ex, "File not found. Check the file location.");
}
catch (IOException ex)
{
    content = string.Empty;
    LogException(ex, "An IO error occurred. Try again.");
}

在这里,我们根据抛出的异常类型记录不同的消息。

异常

C#提供了两种主要的异常类型类别:

  • NullReferenceExceptionIndexOutOfRangeException,或者我们刚刚遇到的:FileNotFoundExceptionIOException

  • 应用程序异常:这些是为特定应用程序需求创建的自定义异常。比如说,你正在开发一个电子商务平台,你需要一个库存不足的异常。下面是如何设计它的示例:

    public class OutOfStockException : Exception
    {
        public OutOfStockException(string itemName) : base($"{itemName} is out of stock.") { }
    }
    

    之后,检查项目的库存:

    if(item.Stock <= 0)
    {
        throw new OutOfStockException(item.Name);
    }
    

自定义异常使开发者能够传达特定的错误场景,确保错误处理具有信息性。

史蒂夫坐在椅子上,脸上写满了挫败感。他花了过去三天的时间在与他的塔防游戏中的虫子作斗争,代码库正变成一个 try-catch 块的迷宫。就在这时,他的手机响了。是朱莉娅发来的消息。

朱莉娅:游戏进展得怎么样了?

史蒂夫:不太好。我在错误处理中感到窒息。你有什么功能编程的智慧可以分享吗?

朱莉娅:事实上,我确实如此。让我告诉你一种更优雅的错误处理方式...

结果类型

与在异常发生后处理异常相比,如果我们设计代码来预测并优雅地传达错误会怎样?请欢迎Result类型,这是功能错误处理的基石。

在其核心,Result类型封装了成功值或错误。这听起来可能和异常相似,但有一个关键的区别:错误成为了一等公民,直接影响应用程序的流程。

与只能区分现有值和非现有值的Option类型相比,Result类型描述了发生的错误,更重要的是,它可以用于链式方法应用。我们将在本章后面讨论这项技术。

例如,传统上,一个方法可能会返回一个值或抛出一个异常:

public Product GetProduct(int id)
{
    var product = _productRepository.Get(id);
    if(product is null)
    {
        throw new ProductNotFoundException($"Product with ID {id} was not found.");
    }
    return product;
}

相比之下,使用Result类型,方法更明确地传达了其意图和可能的失败:

public Result<Product, string> GetProduct(int id)
{
    var product = _productRepository.Get(id);
    if(product is null)
    {
        return Result<Product, string>.Fail($"Product with ID {id} not found.");
    }
    return Result<Product, string>.Success(product);
}

这段代码更明确。没有隐藏的异常。没有意外的行为。只有清晰、坦诚的沟通。

实现结果类型

让我们进一步深入,看看Result类型的通用实现是什么样的:

public class Result<T, E>
{
    private T _value;
    private E _error;
    public bool IsSuccess { get; private set; }
    private Result(T value, E error, bool isSuccess)
    {
        _value = value;
        _error = error;
        IsSuccess = isSuccess;
    }
    public T Value
    {
        get
        {
            if (!IsSuccess) throw new InvalidOperationException("Cannot fetch Value from a failed result.");
            return _value;
        }
    }
    public E Error
    {
        get
        {
            if (IsSuccess) throw new InvalidOperationException("Cannot fetch Error from a successful result.");
            return _error;
        }
    }
    public static Result<T, E> Success(T value) => new Result<T, E>(value, default, true);
    public static Result<T, E> Fail(E error) => new Result<T, E>(default, error, false);
}

使用Result类型

使用Result类型导致了一种更系统化的错误处理方法:

var productResult = GetProduct(42);
if (productResult.IsSuccess)
{
    DisplayProduct(productResult.Value);
}
else
{
    ShowError(productResult.Error);
}

没有更多的分散的 try-catch 块。现在错误只是代码可以采取的另一种路径,导致更可预测和可维护的系统。

Result类型从根本上改变了我们看待错误的方式:不是作为突然的中断,而是作为预期的结果。随着我们进一步探讨,你将看到这个功能工具如何与其他高级技术集成,从而创造一种新的错误管理方法。

面向铁路编程(ROP)

史蒂夫对Result类型很感兴趣,但他仍然有疑问。

史蒂夫:这对单个操作来说很棒,但当我有一系列步骤都需要 成功时怎么办呢?

朱莉娅:我很高兴你问了。让我向你介绍 面向铁路编程(Railway-Oriented Programming)

函数式编程的核心是追求可预测性和清晰性。尽管传统的错误处理技术很强大,但它们通常会将错误处理逻辑分散在代码库中。受铁路道岔切换比喻的启发,ROP 提供了一种连贯、结构化的错误处理方法,使代码既具有表现力又简洁。

ROP 提供了一种管理一系列操作中错误的方法。将其视为管理两条并行轨道:成功轨道(快乐路径)和错误轨道。操作在快乐路径上顺序运行。然而,一旦遇到错误,流程就会转移到错误轨道,跳过后续操作。

Bind 的本质

ROP 的核心是 Bind 函数。它接受一个操作和一个后续操作,如果第一个操作成功,则执行后续操作。然而,如果发生错误,它将跳过第二个操作,并立即传播错误:

public static Result<Tout, E> Bind<Tin, Tout, E>(this Result<Tin, E> input, Func<Tin, Result<Tout, E>> bindFunc)
{
    return input.IsSuccess ? bindFunc(input.Value) : Result<Tout, E>.Fail(input.Error);
}

使用 Bind 连接操作

想象一系列步骤,我们做以下操作:

  1. 解析输入

  2. 验证解析后的数据

  3. 转换验证后的数据

  4. 存储转换后的数据

ROP 允许我们将这些步骤表达为一个连贯的链:

public Result<bool, string> HandleData(string input)
{
    return ParseInput(input)
           .Bind(parsedData => ValidateData(parsedData))
           .Bind(validData => TransformData(validData))
           .Bind(transformedData => StoreData(transformedData));
}

使用 ROP 组合错误处理

ROP 的一个优势是它促进了可组合的错误处理。你的应用程序的每个组件都可以定义自己的错误场景,当这些组件连接在一起时,组合操作可以处理更广泛的错误范围,而不会失去粒度。

考虑为用户输入、业务逻辑和数据库操作分别创建独立的组件。每个组件可以有自己的错误定义。当这些组件的操作连接在一起时,系统可以无缝地处理来自任何组件的错误,从而创建一个统一的错误处理策略。

public Result<DBResponse, CompositeError> ProcessUserRequest(string userInput)
{
    return GetUserInput(userInput)
           .Bind(inputData => ApplyBusinessLogic(inputData))
           .Bind(businessData => UpdateDatabase(businessData));
}

在这里,CompositeError 可能会封装来自输入验证、业务逻辑违规和数据库故障的错误。

处理多种错误类型

直接的 ROP 实现的一个挑战是它假设整个链中有一个统一的错误类型。然而,现实场景往往涉及多种错误类型。为了管理这一点,你可以引入一种机制来转换或映射错误类型:

public static Result<TOut, EOut> Bind<TIn, TOut, EIn, EOut>(
    this Result<TIn, EIn> input,
    Func<TIn, Result<TOut, EOut>> bindFunc,
    Func<EIn, EOut> errorMap)
{
    return input.IsSuccess ? bindFunc(input.Value) : Result<TOut, EOut>.Fail(errorMap(input.Error));
}

此增强的 Bind 函数将一种错误类型映射到另一种类型,从而实现更复杂和多样化的错误处理场景。

隔离的优势

ROP 隔离了错误处理,确保你的主要业务逻辑保持整洁。在阅读核心操作时,可以专注于主要逻辑,而不会被错误处理的复杂性所分散。

对于开发者来说,这种隔离简化了认知负荷。他们可以信任系统处理错误,并专注于构建主要逻辑。在调试时,ROP 的结构化特性使得问题可能偏离轨道的地方一目了然,从而简化了故障排除过程。

扩展 ROP 以支持异步操作

在现代应用程序中,许多方法都是异步的。可以使用 BindAsync 等技术将 ROP 适应异步操作:

public static async Task<Result<TOut, E>> BindAsync<TIn, TOut, E>(
    this Result<TIn, E> input,
    Func<TIn, Task<Result<TOut, E>>> bindFuncAsync)
{
    return input.IsSuccess ? await bindFuncAsync(input.Value) : Result<TOut, E>.Fail(input.Error);
}

使用 BindAsync,你现在可以像同步操作一样轻松地链式调用异步操作,使 ROP 在同步和异步环境中都变得灵活。

深入研究 ROP 后,我们见证了我们在感知和处理错误方面的范式转变。我们不再将错误视为异常事件,而是将它们整合到我们应用程序的逻辑中,从而产生更健壮、可读性和可维护的代码。

设计你自己的错误处理机制

当 Steve 开始重构他的代码时,他意识到预构建的解决方案并不完全适合他游戏的所有独特场景。

Steve: Julia,我认为我需要为我的游戏创建一些自定义的错误类型。这样可以吗?

Julia: 非常好。事实上,让我们谈谈你如何设计适合你 游戏需求 的自定义错误处理机制。

在创建自己的功能性错误处理时,Result 类型是一个很好的起点。让它足够通用,以适应不同的场景:

public class Result<TSuccess, TFailure>
{
    public TSuccess SuccessValue { get; }
    public TFailure FailureValue { get; }
    public bool IsSuccess { get; }
    //... Constructors and other methods ...
}

使用工厂方法进行创建

工厂方法提供清晰性和易用性:

public static class Result
{
    public static Result<T, string> Success<T>(T value) => new Result<T, string>(value, default, true);
    public static Result<T, string> Fail<T>(string error) => new Result<T, string>(default, error, false);
}

使用方法如下:

var successResult = Result.Success("Processed!");
var errorResult = Result.Fail("Oops! Something went wrong.");

使用 Bind 扩展

使用 Bind 方法来增加流畅性:

public Result<TOut, TFailure> Bind<TOut>(Func<TSuccess, Result<TOut, TFailure>> func)
{
    return IsSuccess ? func(SuccessValue) : new Result<TOut, TFailure>(default, FailureValue, false);
}

自定义错误类型

而不是仅仅使用字符串,创建特定的错误类型来传达详细的信息:

public class ValidationError
{
    public string FieldName { get; }
    public string ErrorDescription { get; }
    //… Constructor and methods ...
}

然后,按如下方式使用它们:

public Result<User, ValidationError> ValidateUser(User user)
{
    if (string.IsNullOrEmpty(user.Name))
    {
        return Result.Fail<User, ValidationError>(new ValidationError("Name", "Name cannot be empty."));
    }
    //... Other validations ...
    return Result.Success<User, ValidationError>(user);
}

利用扩展方法

扩展方法可以提供增强的可读性:

public static class ResultExtensions
{
    public static bool IsFailure<TSuccess, TFailure>(this Result<TSuccess, TFailure> result)
    {
        return !result.IsSuccess;
    }
}

按如下方式使用它们:

if (result.IsFailure())
{
    // Handle the failure scenario
}

与现有代码集成

通过封装旧方法,我们可以无缝地与非功能性代码集成:

public static Result<T, Exception> TryExecute<T>(Func<T> action)
{
    try
    {
        return Result.Success(action());
    }
    catch (Exception ex)
    {
        return Result.Fail<T, Exception>(ex);
    }
}

总是迭代和改进

自定义错误机制是活生生的实体。随着你的应用程序增长,根据反馈和新要求不断迭代和改进。

设计你自己的错误处理机制不仅赋予你量身定制的解决方案,而且加深了你对于函数式范式的理解。深入其中,动手实践,看看你的应用程序如何成为健壮性和清晰性的典范。

功能性错误处理的实用技巧

改进了一周后,Steve 取得了一些进展,但他感到所有这些新概念让他感到不知所措。

Steve: 我不确定我是否 做对了…

Julia: 别担心,在学习新的范式时感到这样很正常。让我分享一些实用的技巧,帮助你在这个 新领域 中导航。

使用选项避免 null

努力不要返回 null。听起来很简单,然而这是一个等待绊倒粗心大意的用户的陷阱。为什么?因为 null 很容易获得,然而,正确处理它们要困难得多,如果你做得不好,可能会导致级联失败:

public User FindUser(string login)
{
    // This can return null!
    return users.FirstOrDefault(u => u.Login.Equals(login));
}

转换如下:

public Option<User> FindUser(string login)
{
    var user = users.FirstOrDefault(u => u.Login.Equals(login));
    return user is not null ? Option.Some(user) : Option.None<User>();
}

记录错误

记录是至关重要的,但尽量避免破坏功能性方法的副作用。一个好的想法是将记录行为委托出去,保持函数的纯洁性:

public Result<Order, Error> ProcessOrder(int id, Action<string> logError)
{
    if (invalid(id))
    {
        logError($"Invalid order id: {id}");
        return Result.Fail<Order, Error>(new Error("Invalid ID"));
    }
    // ... process further ...
}

两种替换异常的策略

我知道在某些情况下,你的第一反应可能是使用 try-catch。抵制。使用这些策略来坚持函数式范式。

安全执行

创建一个以无异常方式执行任何代码的方法:

public static Result<T, E> SafelyExecute<T, E>(Func<T> function, E error)
{
    try
    {
        return Result.Success(function());
    }
    catch
    {
        return Result.Fail<T, E>(error);
    }
}

使用方法如下:

var orderResult = SafelyExecute(() => GetOrder(orderId), new DatabaseError("Failed getting order"));

回退

对于某些操作,你可以提供一个回退结果:

public Result<Order, string> GetOrderWithFallback(int orderId)
{
    var orderResult = GetOrder(orderId);
    return orderResult.IsSuccess ? orderResult : Result.Success(new DefaultOrder());
}

预测错误 - 使其可预测

而不是等待错误,预测它们。在使用之前验证你的数据。你可以手动完成或借助守卫子句:

public Result<Order, string> ProcessOrder(int id)
{
    if (id < 0)
    {
        return Result.Fail<Order, string>("ID cannot be negative.");
    }
    // ... further processing ...
}

拥抱组合

使用函数组合进行更清晰的错误处理:

var result = GetData()
             .Bind(Validate)
             .Bind(Process)
             .Bind(Save);

教育你的团队

最后,确保每个人都同意。一致的错误处理方法确保了清晰性和可靠性。

传统与函数式错误处理比较

每个开发者迟早都会遇到:编码时遇到错误。但随着编码世界的改变,我们处理这些问题的方法也在改变。让我们看看 C#中处理错误的老旧和新旧方法之间的明显差异,并了解为什么这种新方法越来越受欢迎。

传统方式

在传统的面向对象编程中,异常是首选机制:

  • 抛出异常:当事情出错时,我们依赖于系统或自定义异常:

    public User GetUser(int id)
    {
        if (id < 0)
            throw new ArgumentOutOfRangeException(nameof(id));
        // ... fetch the user ...
    }
    
  • 捕获异常:使用 try-catch 块来处理和可能恢复错误:

    try
    {
        var user = GetUser(-5);
    }
    catch (ArgumentOutOfRangeException ex)
    {
        Console.WriteLine(ex.Message);
    }
    

传统方式的优点如下:

  • 大多数开发者习惯于使用异常,使其成为一个被广泛理解的方法

  • 可以使用不同的异常类型进行细粒度错误处理

然而,也有一些缺点:

  • 异常可能会打断代码执行的正常流程

  • 异常处理引入了性能开销

  • 这可能很难推理,可能导致“异常地狱”

函数式方式

函数式编程更喜欢一种更优雅的错误处理形式:

  • 使用如下结构结果类型

    public Result<User, string> GetUser(int id)
    {
        if (id < 0)
        {
            return Result.Fail<User, string>("ID cannot be negative.");
        }
        // ... get and return the user ...
    }
    

    消费前面的函数变得简单:

    var userResult = GetUser(-5);
    if (userResult.IsFailure)
    {
        Console.WriteLine(userResult.Error);
    }
    
  • ROP

    var result = GetData()
                 .Bind(ValidateData)
                 .Bind(ProcessData);
    

函数式方式的优点如下:

  • 更清晰的意图和流程

  • 避免异常性能开销

  • 更容易的操作链

同样也有一些缺点:

  • 开发者可能需要学习新的概念

  • 与传统异常相比,粒度更少

比较分析

让我们更深入地看看在性能、可读性和可维护性方面,传统异常处理与函数式编程之间的差异:

  • 性能:由于创建异常对象和回滚堆栈的开销,传统的异常处理可能会较慢。函数式编程提供了一个更可预测的性能配置文件。

  • 可读性:try-catch 块可能会使代码杂乱无章,使其可读性降低。通常这些块还包含无法抛出异常的代码。函数式编程将错误封装在数据中,使代码流程明显。

  • 可维护性:传统方法分散错误处理,使维护变得复杂。函数式编程鼓励隔离的、纯函数,这简化了调试和测试。

转变

从函数式错误处理开始可能看起来很奇怪,尤其是如果你一直生活在传统范式下。但一旦你做出改变,好处是深远的。记住:

  • 从小开始。重构你的代码库的一部分,并观察差异。

  • 拥抱纯函数。它们将简化你的错误处理故事。

  • 教育你的团队。共同的理解至关重要。

总之,虽然传统的错误处理多年来一直为我们服务得很好,但函数式范式提供了一种更清新、更系统的方法。通过在我们的数据类型中将错误表示为一等公民,我们编写了更易于维护的代码。两者之间的选择通常取决于问题域、团队熟悉度和项目需求。但如果你想要清晰和表述性,函数式路径是正确的。

函数式错误处理中的模式和反模式

函数式编程重新定义了我们对错误处理的方法。通过将错误引入数据领域,我们确保了更安全、更可预测的代码。但与任何范式一样,有正确的方法和陷阱。让我们看看可以帮助你的模式以及反模式。

有几种模式可以帮助你以更函数式的方式处理错误。这些模式旨在提高代码的质量、可读性和可维护性。以下是一些需要考虑的关键模式:

  • 丰富的自定义 错误类型

    而不是使用通用的字符串或代码,使用详细的数据类型来描述错误:

    public Result<User, UserError> GetUser(int id)
    {
        if (id < 0)
            return Result.Fail<User, UserError>(new InvalidIdError(id));
        // ... other checks and logic ...
    }
    
  • 利用组合

    无缝地链式调用多个函数以保持清晰的逻辑流程:

    GetData()
        .Bind(Validate)
        .Bind(Process)
        .Bind(Store);
    
  • 模式匹配 与错误

    这确保了每个错误场景都得到了处理:

    switch (GetUser(5))
    {
        case Success<User> user:
            // Handle user
            break;
        case Failure<UserError> error when error.Value is InvalidIdError:
            // Handle invalid ID error
            break;
        // ... other cases ...
    }
    
  • 隔离 副作用

    保持你的核心逻辑纯净,并单独处理副作用,如日志记录或 I/O:

    public Result<TSuccess, TError> ComputeValue<TSuccess, TError>(Data data)
    {
        if (data.IsValid())
        {
            TSuccess value = PerformComputation(data);
            return new Result<TSuccess, TError>(value);
        }
        else
        {
            TError errorDetails = GetErrorDetails(data);
            return new Result<TSuccess, TError>(errorDetails);
        }
    }
    

    使用方法如下:

    var result = ComputeValue<MySuccessType, MyErrorType>(data);
    if (result.IsSuccess)
    {
        HandleSuccess(result.Value);
    }
    else
    {
        HandleError(result.Error);
    }
    

同时也有一些反模式可能会使你的错误处理更加复杂和容易出错:

  • Result 类型可能会让其他软件开发者感到困惑:

    public Result<int, string> Compute()
    {
        if (condition)
        {
            throw new Exception("Oops!");
        }
        // ... return some result ...
    }
    
  • 不明确的错误

    返回模糊的错误消除了函数式编程的表述性错误处理的价值:

    return Result.Fail<User, string>("Something went wrong.");
    
  • 忽略错误

    只获取值而不处理潜在的错误破坏了函数式编程错误处理的概念。一个例子是当你有一个 Result 类型作为方法输出,但没有检查它:

    var result = GetData();
    ProcessData(result.Value);
    
  • 使用自定义类型 过度复杂化

    虽然详细的错误类型是有益的,但为每个微小的偏差创建一个可能会使事情过于复杂。请不要这样做错误:

    public class NameMissingFirstCharacterError : NameError { /* ... */ }
    public class NameMissingLastCharacterError : NameError { /* ... */ }
    

练习

史蒂夫渴望将他对函数式错误处理的新知识应用到他的塔防游戏中。朱莉娅对他的热情印象深刻,向他提出了三个挑战来测试他的理解并改进他的代码。

练习 1

这是升级塔并返回布尔值的函数。将其重构为返回一个Result类型,当支付失败时返回自定义错误:

public bool UpgradeTower(Tower tower)
{
     // Tower upgrading logic...
     if (/* upgrade fails */)
     {
                  return false;
     }
     return true;
}

练习 2

史蒂夫有一个涉及解析、验证和处理敌人生成的流程。使用面向铁路编程重构它,以改进错误处理流程:

public void ProcessEnemySpawn(string enemyData)
{
     var parsedData = ParseEnemyData(enemyData);
     if (parsedData.IsValid)
     {
              var validation = ValidateEnemySpawn(parsedData);
              if (validation.IsValid)
              {
                  SpawnEnemy(validation.Enemy);
              }
     }
}

练习 3

编写一个函数,实现一个用于故障操作的重试机制,并返回一个Result类型。该函数应在返回错误之前重试操作指定次数:

public bool TowerFire(Tower tower, Enemy enemy)
{
     // Sometimes works and returns true
     // sometimes doesn't and returns false
}

尝试自己完成这些练习,完成后,你可以用以下解决方案来检查你的工作。

解决方案

练习 1

将方法重构为使用带有自定义错误的Result类型,封装失败详情:

public enum TowerUpgradeError
{
     InsufficientResources,
     MaxLevelReached,
     TowerDestroyed
}
public Result<bool, TowerUpgradeError> UpgradeTower(Tower tower)
{
     // Tower upgrading logic...
     if (/* insufficient resources */)
     {
                  return Result.Fail<bool, TowerUpgradeError>(TowerUpgradeError.InsufficientResources);
     }
     else if (/* max level reached */)
     {
                  return Result.Fail<bool, TowerUpgradeError>(TowerUpgradeError.MaxLevelReached);
     }
     else if (/* tower is destroyed */)
     {
                  return Result.Fail<bool, TowerUpgradeError>(TowerUpgradeError.TowerDestroyed);
     }
     return Result.Ok<bool, TowerUpgradeError>(true);
}

练习 2

史蒂夫使用面向铁路编程重构了他的敌人生成系统,创建了一个处理敌人数据的清晰管道:

public Result<Enemy, EnemySpawnError> ProcessEnemySpawn(string enemyData)
{
     return ParseEnemyData(enemyData)
                  .Bind(ValidateEnemySpawn)
                  .Bind(SpawnEnemy);
}
// Assume these methods are implemented to return Result<T, EnemySpawnError>
public Result<ParsedEnemyData, EnemySpawnError> ParseEnemyData(string data) { /* ... */ }
public Result<ValidatedEnemy, EnemySpawnError> ValidateEnemySpawn(ParsedEnemyData data) { /* ... */ }
public Result<Enemy, EnemySpawnError> SpawnEnemy(ValidatedEnemy enemy) { /* ... */ }

练习 3

对于故障的塔发射机制,史蒂夫实现了一个重试函数,在放弃之前尝试多次操作:

public Result<bool, string> TryTowerFire(Tower tower, Enemy enemy, int maxRetries)
{
     for (int attempt = 0; attempt < maxRetries; attempt++)
     {
                  if (TowerFire(tower, enemy))
                  {
                      return Result.Ok<bool, string>(true);
                  }
     }
     return Result.Fail<bool, string>($"Tower firing failed after {maxRetries} attempts.");
}

这些练习将带你从理解到在实际编码场景中应用函数式原则。它们鼓励你以函数式的方式思考和编码,将错误处理视为编码过程的一个组成部分,而不是事后考虑:

摘要

在本章中,我们从传统的错误处理方法进步到函数式方法。我们确定了函数式编程的优势、挑战、模式和反模式。

函数式编程不仅提供了一种编码方式,而且是一种思维方式的转变。通过将错误视为数据,我们受益于类型安全、表达性和可预测性。

然而,我们的目标不是消除所有异常和空值,而是创建更易于阅读和健壮的软件。幸运的是,随着 C#的发展,函数式错误处理正变得更容易和更集成。

就像所有范式一样,函数式编程不是万能的。虽然将错误视为数据可能很强大,但你必须记住代码运行的实际情况。网络故障、数据库中断和硬件故障是现实。在函数式纯度和现实世界实用主义之间取得平衡是关键。

在本章中,我们几次使用了委托,为了更好地理解它们以及在函数式编程中的作用,在下一章中我们将深入探讨高阶函数和委托的概念。

第六章:高阶函数与代表

在本章中,我们将深入研究 C#中的高阶函数和代表。这些概念在函数式编程中至关重要,并将帮助你编写更灵活、更易于维护的代码。

高阶函数仅仅是能够接受其他函数作为参数或返回一个函数的函数。这听起来可能很复杂,但别担心;我们将通过清晰的示例和解释来分解它。高阶函数是函数式编程的关键部分,允许你编写更简洁、更具有表现力的代码。

在 C#中,代表(Delegates)与高阶函数(Higher-order functions)密切相关。它们类似于方法变量,允许你将方法作为参数传递或将它们作为值存储。本章将帮助你理解如何在以下部分使用代表来实现高阶函数:

  • 理解高阶函数

  • 代表、动作、Func 和谓词

  • 回调、事件和匿名方法

  • 利用 LINQ 方法作为高阶函数

  • 案例研究 – 将所有内容整合在一起

  • 最佳实践和常见陷阱

按照我们前几章的传统,我们将从一次简短的自我评估开始这一章。下面有三个任务,旨在测试你对本章将要讨论的概念的理解。如果你对这些任务犹豫不决或感到困难,我建议你仔细阅读这一章。然而,如果你觉得它们很容易,这可能是一个专注于你知识不那么强的领域的良好机会。现在,让我们来看看这些任务。

任务 1 – 排序函数

编写一个程序,使用高阶函数根据史蒂夫游戏中的塔的输出伤害对塔列表进行排序。排序函数应作为代表传递。

任务 2 – 定制计算

创建一个方法,该方法接受一个Action和一个敌人列表。Action应对每个敌人的健康进行计算并打印结果。使用几个不同的Action测试你的方法,例如计算来自不同塔类型的伤害。

任务 3 – 比较

实现一个使用Func代表来比较两个塔基于其射程的方法。该方法应返回射程较长的塔。

理解高阶函数

在函数式编程中,高阶函数简单地说是一个至少执行以下操作之一的函数:

  • 接受一个或多个函数作为参数

  • 返回一个函数作为结果

是的,你没有听错!高阶函数将函数视为数据,像任何其他值一样传递。这导致前所未有的抽象水平和代码重用。

考虑一个类似于 YouTube 的视频管理系统,在这个系统中,高效处理大量视频至关重要。而不是为每种类型的视频过滤编写单独的函数,我们可以利用高阶函数来获得更优雅和可重用的解决方案。高阶函数可以抽象过滤逻辑,使代码更模块化和易于维护。以下是一个简化的示例:

public Func<Func<Video, bool>, IEnumerable<Video>> FilterVideos(IEnumerable<Video> videos)
{
    return filter =>
    {
        Console.WriteLine("Filtering videos...");
        var filteredVideos = videos.Where(filter).ToList();
        Console.WriteLine($"Filtered {filteredVideos.Count} videos.");
        return filteredVideos;
    };
}
// Usage
var allVideos = new List<Video> { /* Collection of videos */ };
var filterFunc = FilterVideos(allVideos);
var publicVideos = filterFunc(v => v.IsPublic);

在这个系统中,我们有一个 Video 对象的集合。我们希望根据不同的标准如可见性、长度或类型来过滤这些视频。为了实现这一点,我们创建了一个名为 FilterVideos 的高阶函数。这个函数接受一个视频集合,并返回另一个函数。返回的函数能够根据提供的谓词(定义过滤标准的函数)来过滤视频。这种设计使我们能够轻松地创建各种过滤器,而不必重复过滤逻辑,从而增强代码的重用和可读性。

高阶函数在函数式编程中的力量

高阶函数是函数式编程的基石,提供了稳健性和灵活性。它们将函数作为数据的能力,以及由此产生的抽象和通用性,可以在编程的各个方面看到。

高阶函数抽象和封装行为的能力是无与伦比的,这导致了显著的代码重用。例如,考虑一个在移动塔防游戏中的场景,我们需要各种类型的单位转换。而不是重复转换逻辑,我们可以通过高阶函数来抽象这一点。以下是一个说明性的示例:

public Func<Unit, Unit> CreateTransformation<T>(Func<Unit, T, Unit> transform, T parameter)
{
    return unit => transform(unit, parameter);
}
// Usage
Func<Unit, Unit> upgradeArmor = CreateTransformation((unit, bonus) => unit.UpgradeArmor(bonus), 10);
Unit myUnit = new Unit();
Unit upgradedUnit = upgradeArmor(myUnit);

在这个例子中,CreateTransformation 是一个高阶函数,它返回一个新的函数,封装了转换行为。它通过提供一种灵活的方式来应用不同的转换到游戏单位,从而促进了代码的重用和抽象。

使用更少的错误创建通用代码

高阶函数也有助于编写通用和灵活的代码,导致错误更少。通过封装通用行为,这些函数减少了编写的代码量,这使得代码更频繁地被测试,并且更不容易出错。

考虑一个在塔防游戏中对单位应用效果的函数。使用高阶函数,我们可以将不同的效果作为参数传递:

public Func<Unit, Unit> ApplyEffect(Func<Unit, Unit> effect)
{
    return unit =>
    {
        return effect(unit);
    };
}
// Usage
Func<Unit, Unit> applyFreeze = ApplyEffect(u => u.Freeze());
Unit enemyUnit = new Unit();
Unit affectedUnit = applyFreeze(enemyUnit);

在这里,ApplyEffect 允许对游戏单位应用各种效果,简化了代码库并减少了潜在的错误。

支持更声明式的编码风格

高阶函数促进了声明式编码风格。你描述你想要实现的目标,而不是如何实现它,这使得代码更易于阅读和维护。

在游戏效果示例中,我们声明性地指定我们想要对一个单位应用一个效果。效果如何应用的具体细节被封装在 ApplyEffect 函数中。

总之,函数式编程中的高阶函数非常有价值。它们使代码重用,减少错误,并支持声明式编码风格,使它们成为任何程序员工具箱中的强大工具。

委托、动作、funcs 和谓词

委托本质上是一种类型安全的函数指针,持有函数的引用。这种安全性至关重要,因为它确保函数的签名与委托定义的签名相匹配。委托使方法可以作为参数传递、从函数返回并存储在数据结构中,对于事件处理和其他动态功能来说必不可少。

Delegates

让我们将委托的概念应用到图书出版系统中。想象一下,当一本新书出版时,我们需要通知不同的部门。

首先,定义一个与通知函数签名匹配的委托:

public delegate void BookPublishedNotification(string bookTitle);

接下来,创建一个用于管理图书出版的类,其方法接受一个委托:

public class BookPublishingManager
{
    public void PublishBook(string bookTitle, BookPublishedNotification notifyDepartments)
    {
        // Publishing logic here
        notifyDepartments(bookTitle);
    }
}

现在,任何与委托签名匹配的函数都可以传递到 PublishBook 中,并在新书出版时被调用:

public void NotifyMarketingDepartment(string bookTitle)
{
    Console.WriteLine($"Marketing notified for the book: {bookTitle}");
}
// Usage
BookPublishingManager publishingManager = new BookPublishingManager();
publishingManager.PublishBook("Functional Programming in C# 12", NotifyMarketingDepartment);

在这个例子中,任何与 BookPublishedNotification 委托签名匹配的函数都可以传递给 PublishBook,并在书籍出版时被调用。这展示了委托在实际场景中的灵活性和动态性。

Actions

在函数式编程中,Actions 是一种不返回值的委托类型。它们非常适合执行执行动作但不需要返回结果的方法。这种简单性使 Actions 成为各种编程场景中的多功能工具。

考虑一个移动塔防游戏,其中某些事件,如生成敌人和触发效果,不需要返回值。我们可以使用 Action 委托来处理这些场景:

public class TowerDefenseGame
{
    public event Action<string> OnEnemySpawned;
    public void SpawnEnemy(string enemyType)
    {
        // Enemy spawning logic here
        OnEnemySpawned?.Invoke(enemyType);
    }
}
// Usage
TowerDefenseGame game = new TowerDefenseGame();
game.OnEnemySpawned += enemyType => Console.WriteLine($"Spawned {enemyType}");
game.SpawnEnemy("Goblin");

在这个例子中,OnEnemySpawned 是一个用于在生成敌人时通知的 Action 委托。Action 委托的简单性允许在游戏逻辑中实现清晰的事件处理。

Funcs

Funcs 是另一种内置委托,当需要返回值时使用。它们可以有 0 到 16 个输入参数,最后一个参数的类型总是返回类型。

在同一塔防游戏的上下文中,想象我们需要一个基于各种游戏参数计算分数的函数。这就是 Funcs 发挥作用的地方:

public class TowerDefenseGame
{
    public Func<int, int, double> CalculateScore;
    public double GetScore(int enemiesDefeated, int towersBuilt)
    {
        return CalculateScore?.Invoke(enemiesDefeated, towersBuilt) ?? 0;
    }
}
// Usage
TowerDefenseGame game = new TowerDefenseGame();
game.CalculateScore = (enemiesDefeated, towersBuilt) => enemiesDefeated * 10 + towersBuilt * 5;
double score = game.GetScore(50, 10);

在这里,CalculateScore 是一个 Func 委托,允许根据动态游戏因素灵活和自定义地计算游戏的分数。Funcs 提供了一种强大的方式来定义具有返回值的操作,增强了代码的灵活性和可重用性。

谓词

Predicate<T> 是一个代表包含一组标准并检查传递的参数是否满足这些标准的方法的委托。谓词委托方法必须接受一个输入参数并返回一个 bool 值。

在一个类似 YouTube 的视频管理系统里,我们可能会使用Predicate<Video>来根据某些标准过滤视频:

public class VideoManager
{
    IEnumerable<Video> _videos; // We assume it will be filled later
    public IEnumerable<Video> GetVideosMatching(Predicate<Video> criteria)
    {
        foreach (var video in _videos)
        {
            if (criteria(video))
            {
                yield return video;
            }
        }
    }
}
// Usage
VideoManager videoManager = new();
Predicate<Video> isPopular = video => video.Views > 100000;
List<Video> popularVideos = videoManager.GetVideosMatching(isPopular);

在这个例子中,GetVideosMatching方法接受一个Predicate<Video>委托来过滤视频。该方法遍历视频列表,并将满足谓词定义的标准的视频添加到结果列表中。它可以用Where写成一个单行代码,但使用yield return使其更具表达性。

因此,总结我们关于委托、动作、函数和谓词所学的所有内容,我们可以看到以下:

  • 委托:基础元素,允许方法被引用和传递,对于创建高阶函数至关重要

  • 动作:专门用于执行动作但不返回值的委托,简化了任务封装

  • 函数:返回结果的委托,适用于计算和转换

  • 谓词:一种总是返回布尔值的函数形式,标准化了条件检查

这些结构共同可以增强我们的编程,实现代码复用、更高的抽象层次以及灵活的函数式风格。

让我们继续探讨更多令人兴奋的结构。

回调、事件和匿名方法

回调是异步和事件驱动编程中的一个关键概念。它们本质上是指向方法的委托,允许它在稍后时间被调用。这促进了非阻塞代码执行,对于响应式应用至关重要。

想象一个图书出版系统,我们需要在图书出版后执行诸如发送通知等动作。在这里,回调可以在发布过程完成后通知系统的其他部分:

public delegate void BookPublishedCallback(string bookTitle);
public class BookPublishingManager
{
    public void PublishBook(string bookTitle, BookPublishedCallback callback)
    {
        // Book publishing logic here...
        callback(bookTitle);
    }
}
// Usage
BookPublishingManager manager = new BookPublishingManager();
manager.PublishBook("C# in Depth", title => Console.WriteLine($"{title} has been published!"));

在这个场景中,回调在图书出版后执行,提供了一种灵活且解耦的处理出版后流程的方式。

委托在事件中的作用

基于发布者-订阅者模型的事件,是委托的另一个强大应用。它们允许对象通知其他对象关于感兴趣事件的发生。

通过使用事件,可以进一步增强图书出版系统,提供更健壮和灵活的通知机制:

public class BookPublishingManager
{
    public event Action<string> OnBookPublished;
    public void PublishBook(string bookTitle)
    {
        // Book publishing logic here...
        OnBookPublished?.Invoke(bookTitle);
    }
}
// Usage
BookPublishingManager manager = new BookPublishingManager();
manager.OnBookPublished += title => Console.WriteLine($"{title} has been published!");
manager.PublishBook("Advanced C# Programming");

在这个版本中,OnBookPublished是一个订阅者可以监听的事件。当一本书出版时,事件被触发,所有订阅的方法都会被调用。这种模型增强了模块化,并减少了发布逻辑与其后续动作之间的耦合。

委托和匿名方法

匿名方法是未绑定到特定名称的方法。它们使用delegate关键字定义,可以用来创建委托的实例。匿名方法提供了一种在方法被调用的地方定义方法的方式,使代码更加简洁易读。

让我们创建一个简单的匿名方法,根据特定标准过滤视频对象列表,例如过滤出持续时间超过一定长度的视频。我们将使用匿名方法和FindAll方法来完成这个任务:

public class Video
{
    public string Title { get; set; }
    public int DurationInSeconds { get; set; }
}
List<Video> videos = new List<Video>
{
    new Video { Title = "Introduction to C#", DurationInSeconds = 300 },
    new Video { Title = "Advanced C# Techniques", DurationInSeconds = 540 },
    new Video { Title = "C# Functional Programming", DurationInSeconds = 420 }
};
List<Video> longVideos = videos.FindAll(delegate(Video video)
{
    return video.DurationInSeconds > 450; // Filtering videos longer than 450 seconds
});
foreach (Video video in longVideos)
{
    Console.WriteLine(video.Title);  // Outputs titles of videos longer than 450 seconds
}

在这个示例中,delegate(Video video) {...}是一个匿名方法,用于定义FindAll方法的条件,根据视频的持续时间过滤视频。这展示了匿名方法如何在实际场景中应用,例如在视频管理系统中的数据过滤。

通过利用委托创建回调、处理事件和定义匿名方法,我们获得了一组强大的工具,使我们能够编写更灵活、可维护的代码。

利用 LINQ 方法作为高阶函数

语言集成查询LINQ)在 C#中将查询功能集成到语言中,主要通过扩展方法来实现。这些方法遵循函数式编程原则,允许进行简洁且富有表现力的数据操作。我们将探讨 LINQ 如何在不同系统中有效地用于数据过滤、转换和聚合。

过滤

在视频管理系统中,我们可能需要根据视频的观看次数进行过滤。使用Where方法可以轻松实现这一点:

List<Video> videos = GetAllVideos();
 IEnumerable<Video> popularVideos = videos.Where(video => video.Views > 100000);
foreach(var video in popularVideos)
{
    Console.WriteLine(video.Title);
}

数据转换

在一个发布系统中,为了实现统一目录显示,可以将书名转换为大写,可以使用Select方法来完成:

List<Book> books = GetBooks();
var upperCaseTitles = books.Select(book => book.Title.ToUpper());
foreach(var title in upperCaseTitles)
{
    Console.WriteLine(title);
}

数据聚合

对于移动塔防游戏,使用Average方法可以有效地计算所有塔楼的平均伤害:

double averageGrade = students.Average(student => student.Grade);
Console.WriteLine($"Average Grade: {averageGrade}");

这些示例展示了 LINQ 作为高阶函数的力量,展示了它们如何在各种实际应用中处理复杂的数据操作,使代码更易读、可维护和有趣。

案例研究——综合运用所有技术

在这里,我们将汇集到目前为止所讨论的所有元素:高阶函数、委托、操作、funcs、断言和 LINQ 方法。我们将提供一个全面、真实的示例,并逐步分析代码。

想象我们正在开发一个移动塔防游戏。这个游戏涉及管理塔楼、处理敌军波次以及升级塔楼的能力。

这里是我们将要使用的类的一个概述:

public class Tower
{
    public string Type { get; set; }
    public int Damage { get; set; }
    public bool IsUpgraded { get; set; }
}
public class Game
{
    private List<Tower> _towers { get; set; }
    public IEnumerable<Tower> FilterTowers(Func<Tower, bool> predicate) { /* … */ }
    public event Action<Tower> TowerUpgraded;
    public void UpgradeTower(Tower tower) { /* … */ }
}

这里的Tower类代表了游戏的基本构建块——塔楼。每个塔楼都有一个类型、一个伤害等级和一个状态,表示它是否已被升级。这个类是游戏机制的基础,因为不同的塔楼可能会有各种效果和与之相关的策略。

Game类作为管理游戏逻辑的中心枢纽。它包含游戏中所有塔楼的列表。该类展示了高级函数式编程技术:

  • FilterTowers 方法是使用高阶函数在实际应用中的典范示例。通过接受一个 Func<Tower, bool> 作为谓词,它提供了一种灵活的方式来根据动态标准(如伤害等级、射程或升级状态)过滤塔。此方法利用了 LINQ,展示了它在简化数据操作任务方面的强大功能。

  • TowerUpgraded 事件与 UpgradeTower 方法的结合展示了动作和委托的使用。这种事件驱动的方法允许进行响应式编程,其中游戏的不同部分可以响应塔状态的变化,例如在塔升级时触发动画、声音或游戏逻辑更新。

代码的逐步分析和分析

现在,让我们给我们的方法添加一些逻辑,并编写使用它们的代码:

  1. FilterTowers 方法使用谓词(返回 boolFunc)根据特定标准选择塔,展示了高阶函数和 LINQ:

    public IEnumerable<Tower> FilterTowers(Func<Tower, bool> predicate)
    {
        return _towers.Where(predicate);
    }
    

    这种方法允许进行动态塔过滤,适应各种游戏场景和玩家策略。

  2. TowerUpgraded 事件展示了委托如何促进游戏中的事件处理:

    public void UpgradeTower(Tower tower)
    {
        if (!tower.IsUpgraded)
        {
            tower.IsUpgraded = true;
            TowerUpgraded?.Invoke(tower);
        }
    }
    

    此机制对于通知游戏的不同部分关于塔升级并保持游戏状态一致性至关重要。

  3. 与游戏交互:最后,让我们看看用户如何与库交互:

    Game game = new Game();
    // Filtering towers using a Predicate
    var highDamageTowers = game.FilterTowers(tower => tower.Damage > 50);
    // Subscribing to events with anonymous methods
    game.TowerUpgraded += tower => Console.WriteLine($"{tower.Type} was upgraded.");
    // Upgrading a tower
    var cannonTower = highDamageTowers.First();
    game.UpgradeTower(cannonTower);
    

    在这个片段中,我们可以看到游戏函数式编程特性的实际应用。从根据伤害过滤塔到处理塔升级,代码简洁、易于表达且有效。

本案例研究展示了在实际情况中使用谓词、事件、委托和高阶函数。它展示了函数式编程原则如何增强复杂移动游戏的开发和运营,从而实现更高效、更易于表达和更强大的编程。这些概念的集成为构建引人入胜且稳健的游戏机制提供了坚实的基础。

最佳实践和常见陷阱

本节将深入探讨使用高阶函数、委托、动作、funcs、谓词和 LINQ 的最佳实践。我们还将讨论开发者常犯的错误,并提供避免这些陷阱的解决方案。

在使用高阶函数工作时,以下是一些最佳实践:

  • 追求无状态函数:为了保持一致性和可预测性,努力确保您作为参数传递的函数是无状态的,这意味着它们不依赖于或改变自身之外的状态。这使得它们更具可预测性,并减少了副作用的可能性。

  • 拥抱不可变性:函数式编程的核心原则之一是不可变性。在将对象传递给高阶函数时,考虑它们是否可以变为不可变,以确保函数不会改变它们的状态。

  • 使用描述性名称:在传递函数时,很容易失去对每个函数所做事情的跟踪。因此,为你的函数和参数使用描述性名称以提高可读性。

一些常见的陷阱如下:

  • OrderByReverseCount 可能会耗费较多资源。始终测量查询的性能,并在必要时考虑替代方法。

  • 忽略委托的类型安全:虽然委托功能强大,但如果不小心使用,它们也可能绕过类型安全。始终确保委托签名与方法指向的方法相匹配,以避免运行时错误。

  • NullReferenceException

    myDelegate?.Invoke();
    
  • 匿名函数的误用:匿名函数可以使代码更简洁,但它们也可能隐藏复杂性并使代码更难测试。如果一个匿名函数超过几行长,或者足够复杂以至于需要单独测试,那么它可能应该是一个命名函数。

通过遵循这些最佳实践并避免常见错误,你可以编写干净、高效且易于维护的代码,充分利用函数式编程构造的强大功能。

练习

理论和概念只是学习旅程的一半。现在,是时候通过一些实际练习来亲自动手了。本章提供了一系列具有挑战性的问题,以测试你对所学概念的理解,并加强它们。在每个问题之后,你将找到一个建议的解决方案,以及详细的解释。

练习 1

编写一个程序,使用高阶函数根据 Steve 游戏中塔的伤害输出对塔列表进行排序。排序函数应作为委托传递。

练习 2

创建一个方法,该方法接受一个 Action 和一个敌人列表。Action 应对每个敌人的生命值执行计算并打印结果。使用几个不同的 Action 测试你的方法,例如计算来自不同塔类型的伤害。

练习 3

实现一个使用 Func 委托根据射程比较两个塔的方法。该方法应返回射程更长的塔。

解答

练习 1

Steve 使用委托实现了对塔的排序函数:

public class Tower
{
     public string Name { get; set; }
     public int Damage { get; set; }
}
public delegate int CompareTowers(Tower a, Tower b);
public static void SortTowers(List<Tower> towers, CompareTowers compare)
{
     towers.Sort((x, y) => compare(x, y));
}
// Usage:
List<Tower> towers = new List<Tower>
{
     new Tower { Name = "Archer", Damage = 10 },
     new Tower { Name = "Cannon", Damage = 20 },
     new Tower { Name = "Mage", Damage = 15 }
};
SortTowers(towers, (a, b) => b.Damage.CompareTo(a.Damage)); // Sort descending
foreach (var tower in towers)
{
     Console.WriteLine($"{tower.Name}: {tower.Damage} damage");
}

此解决方案创建了一个 CompareTowers 委托,它接受两个 Tower 对象并返回一个 int。然后 SortTowers 方法使用此委托对塔列表进行排序。

练习 2

对于敌人生命值的计算,Steve 创建了以下方法:

public class Enemy
{
     public string Name { get; set; }
     public int Health { get; set; }
}
public static void ProcessEnemies(List<Enemy> enemies, Action<Enemy> action)
{
     foreach (var enemy in enemies)
     {
                  action(enemy);
     }
}
// Usage:
List<Enemy> enemies = new List<Enemy>
{
     new Enemy { Name = "Goblin", Health = 50 },
     new Enemy { Name = "Orc", Health = 100 },
     new Enemy { Name = "Troll", Health = 200 }
};
// Calculate damage from arrow tower
ProcessEnemies(enemies, (e) => Console.WriteLine($"{e.Name} takes {e.Health * 0.1} damage from arrow tower"));
// Calculate damage from fire tower
ProcessEnemies(enemies, (e) => Console.WriteLine($"{e.Name} takes {e.Health * 0.2} damage from fire tower"));

此解答遍历敌人列表,并对每个敌人应用传入的操作。

练习 3

这是第三个问题的解决方案:

public class Tower
{
     public string Name { get; set; }
     public int Range { get; set; }
}
public static Tower GetLongerRangeTower(Tower t1, Tower t2, Func<Tower, Tower, Tower> compare)
{
     return compare(t1, t2);
}
// Usage:
Tower archer = new Tower { Name = "Archer", Range = 50 };
Tower cannon = new Tower { Name = "Cannon", Range = 30 };
Tower longerRange = GetLongerRangeTower(archer, cannon, (a, b) => a.Range > b.Range ? a : b);
Console.WriteLine($"{longerRange.Name} has the longer range of {longerRange.Range}");

此解答使用一个 Func 委托来比较两个塔的射程,并返回射程更长的那个。

记住,虽然这些解决方案有效,但可能还有其他同样有效的方案。这些练习的目的是加强所学概念,并探索不同的应用方式。

摘要

在我们总结本章关于 C# 函数式编程中的高阶函数和委托时,让我们停下来反思我们深入探讨的关键概念,并预测我们旅程的下一步:

  • 高阶函数:这些函数能够接收其他函数作为参数或返回它们,是促进代码重用、抽象和更声明式编程风格的基础。它们的通用性增强了代码的表达力,使我们能够用更少的代码完成更多的工作。

  • 委托、动作、funcs 和谓词:我们对这些关键函数式编程结构的探索揭示了它们的独特角色和区别。我们看到了它们如何有助于构建灵活且可靠的代码,每个都在更广泛的函数式范式扮演着特定的角色。

  • 委托用于回调、事件和匿名方法:委托是创建回调、管理事件和定义匿名方法的基础。它们使灵活的事件驱动编程结构成为可能,这对于响应性和交互式应用程序至关重要。

  • LINQ 作为高阶函数:我们发现了 LINQ 库在处理数据集合方面的巨大力量。重点在于 LINQ 方法如何体现高阶函数,为复杂的数据操作和查询提供优雅的解决方案。

  • 最佳实践和陷阱:我们以这些概念的有效应用和避免常见错误的关键最佳实践作为总结。这些见解对于编写干净、高效和可维护的代码至关重要。

本质上,这一章阐明了函数式编程的原则如何在 C# 中有效利用。我们看到,通过拥抱这些概念,开发者可以在他们的代码中实现更高的可读性、可维护性和健壮性。

当我们翻到下一章时,我们探索函数式编程深度的旅程仍在继续。我们将深入探究函数式和单子的迷人世界。这些高级概念将为您解锁新的抽象和组合层次。敬请期待;这将非常有趣!

第七章:函子和单子

从高阶函数和委托过渡到函子,我们进入了函数式编程的世界,函子是其中的关键角色。它们允许我们以结构化的方式处理包装值,如列表或计算结果。本章探讨了以下内容:

  • 函子

  • 函子法则

  • 应用函子和法则

  • 单子与单子法则

如同往常,以下三个自我检查任务可以帮助你理解函子和单子的现有知识。

任务 1 – 函子使用

给定一个表示塔列表的 Result<List<Tower>, string> 类型,其中 Tower 是一个包含 IdNameDamage 等属性的类,任务是用函子概念将一个函数应用到每个塔上,在名称末尾添加“(升级)”以表示塔已被升级:

public class Tower
{
    public int Id { get; set; }
    public string Name { get; set; }
    public int Damage { get; set; }
}
public Result<List<Tower>, string> UpgradeTowers(List<Tower> towers)
{
    // Write your code here
}

任务 2 – 应用函子

想象你有两个被 Result 类型包裹的函数:Result<Func<Tower, bool>, string> ValidateDamage 检查塔的伤害是否在可接受范围内,而 Result<Func<Tower, bool>, string> ValidateName 检查塔的名称是否符合某些标准。给定一个表示单个塔的 Result<Tower, string>,使用应用函子将这两个验证函数应用到塔上,确保两个验证都通过:

public Result<Func<Tower, bool>, string> ValidateDamage = new Result<Func<Tower, bool>, string>(tower => tower.Damage < 100);
public Result<Func<Tower, bool>, string> ValidateName = new Result<Func<Tower, bool>, string>(tower => tower.Name.Length > 5 && !tower.Name.Contains("BannedWord"));

任务 3 – 单子使用

给定一系列用于升级塔的操作——FetchTowerUpgradeTowerDeployTower——每个操作都可能失败并返回 Result<Tower, string>,使用单子概念将这些操作串联起来,针对给定的塔 ID。确保如果任何步骤失败,整个操作将短路并返回错误:

public Result<Tower, string> FetchTower(int towerId) { /* Fetches tower based on ID */ }
public Result<Tower, string> UpgradeTower(Tower tower) { /* Upgrades the tower and can fail */ }
public Result<Tower, string> DeployTower(Tower tower) { /* Attempts to deploy the tower */ }

如果你成功完成了所有三个任务,你真是太棒了!如果你现在遇到困难或者不知道如何解决这些任务,不要担心,完成这一章并解决这些问题后,你也会很棒。

什么是函子?

朱莉娅决定从史蒂夫的游戏中找一个类比来解释函子的概念。

朱莉娅:想象函子是你的塔防游戏中的一个特殊升级站。这个站可以提升任何塔,但总是输出一个塔——只是一个 改进版本

史蒂夫:这很有道理。所以它就像是一种一致的方式来转换事物,而不改变它们的 核心本质

术语函子起源于范畴论,这是一个处理复杂结构和映射的数学领域。在编程的世界里,我们采用这个概念的简化版本,使其适用于数据处理。简单来说,函子是特殊的容器,可以持有数据,并且能够将函数应用到它们持有的每一份数据上,同时保持整体结构不变。想象它们就像魔法盒子,可以改变里面的东西,而不会改变盒子本身。

然而,并非每个可以对其元素应用函数的数据容器都是函子。一个容器要被视为函子,需要遵守两个定律:

identity函数映射到函子应该得到相同的函子。换句话说,如果你将identity函数映射到一个函子上,该函子应该保持不变。

组合定律:将两个函数组合起来,然后将结果函数映射到函子上,应该与首先将一个函数映射到函子上,然后映射另一个函数相同。这意味着函子映射应该是可组合的,不依赖于它们应用的顺序。

我知道这听起来可能有点复杂,所以让我们详细讨论这些定律。

恒等定律

恒等定律指出,将identity函数应用于我们的容器返回相同的容器。“恒等函数”在这里是一个总是返回其输入的函数。在代码中,我们可以用Func<T, T>来表示它:

Func<T, T> identity = x => x;

这个函数的使用可以展示如下:

int number = 29;
int result = identity(number);
Console.WriteLine(result);
  // Output: 29
string text = "Hello Functional programming in C# readers!";
string resultText = identity(text);
Console.WriteLine(resultText);
  // Output: Hello Functional programming in C# readers!

初看起来,identity函数似乎没有用,但在函数式编程中的数学证明中它起着重要作用。回到函子,恒等定律意味着如果我们将恒等函数映射到我们的容器上,我们应该得到相同的结果。让我们继续讨论第二个定律。

组合定律

这个定律指出,要么我们将两个函数组合起来,然后将结果映射到我们的容器上,要么依次映射这些函数;结果将是相同的。为了理解它如何应用于我们的代码,让我们首先创建两个函数:

Book AddPages(Book book, int pages) => new Book { Title = book.Title, Pages = book.Pages + pages };
Book AppendSubtitle(Book book, string subtitle) => new Book { Title = $"{book.Title}: {subtitle}", Pages = book.Pages };

一个函数向书中添加页面并返回它;另一个向书的标题添加副标题并返回结果。相当简单,对吧?现在,借助这些函数,我们可以用代码表达我们的组合定律:

List<Book> books = new()
{
    new Book { Title = "C# Basics", Pages = 100 },
    new Book { Title = "Advanced C#", Pages = 200 }
};
// Apply AddPages to each book and then apply AppendSubtitle
var sequentialApplicationResult = books.Select(book => AddPages(book, 50)).Select(book => AppendSubtitle(book, "Updated Edition"));
// Apply AddPages then AppendSubtitle to each book
var combinedApplicationResult = books.Select(book => AppendSubtitle(AddPages(book, 50), "Updated Edition"));
// Print the results
Console.WriteLine("books.Select(AddPages).Select(AppendSubtitle): " + string.Join(", ", sequentialApplicationResult.Select(b => b.Title)));
Console.WriteLine("books.Select(book => AppendSubtitle(AddPages(book, 50))): " + string.Join(", ", combinedApplicationResult.Select(b => b.Title)));
// Output:
// books.Select(AddPages).Select(AppendSubtitle): C# Basics: Updated Edition, Advanced C#: Updated Edition
// books.Select(book => AppendSubtitle(AddPages(book, 50))): C# Basics: Updated Edition, Advanced C#: Updated Edition

如我们从输出中可以理解的,生成的集合是相等的,因此我们的List<Book>容器遵守组合定律。

创建我们自己的函子

注意,一个容器不需要包含一组元素就可以被视为函子。让我们回忆一下在第五章中使用的Result类型,并通过添加Map方法来增强它,以便能够应用于内部值:

public class Result<TValue, TError>
{
    private TValue _value;
    private TError _error;
    public bool IsSuccess { get; private set; }
    private Result(TValue value, TError error, bool isSuccess)
    {
        _value = value;
        _error = error;
        IsSuccess = isSuccess;
    }
    public TValue Value
    {
        get
        {
            if (!IsSuccess) throw new InvalidOperationException("Cannot fetch Value from a failed result.");
            return _value;
        }
    }
    public TError Error
    {
        get
        {
            if (IsSuccess) throw new InvalidOperationException("Cannot fetch Error from a successful result.");
            return _error;
        }
    }
    public static Result<TValue, TError> Success(TValue value) => new Result<TValue, TError>(value, default, true);
    public static Result<TValue, TError> Failure(TError error) => new Result<TValue, TError>(default, error, false);
    public Result<TResult, TError> Map<TResult>(Func<TValue, TResult> mapper)
    {
        return IsSuccess
            ? Result<TResult, TError>.Success(mapper(_value!))
            : Result<TResult, TError>.Failure(_error!);
    }
}

Map方法在容器包含值时将传入的函数应用于该值;否则,不调用任何函数,并返回错误结果。由于Result类型现在可以对底层值应用函数,它开始遵守两个函子定律。让我们通过以下示例来查看:

Book AddPages(Book book, int pages) => new Book { Title = book.Title, Pages = book.Pages + pages };
Book AppendSubtitle(Book book, string subtitle) => new Book { Title = $"{book.Title}: {subtitle}", Pages = book.Pages };
Func<Book, Book> identity = book => book;
var success = Result<Book, string>.Success(new Book { Title = "C# Basics", Pages = 100 });
var error = Result<Book, string>.Failure("Error message");
// Identity law
var successAfterIdentity = success.Map(identity);
// successAfterIdentity should have value "C# Basics", 100 pages
var errorAfterIdentity = error.Map(identity);
// errorAfterIdentity should have the "Error message" error
// Composition law
Func<Book, Book> composedFunction = book => AppendSubtitle(AddPages(book, 50), "Updated Edition");
var success = Result<Book, string>.Success(new Book { Title = "C# Basics", Pages = 100 });
// Applying composed function directly
var directComposition = success.Map(composedFunction);
// directComposition should hold value "C# Basics: Updated Edition", 150 pages
// Applying functions one after the other
var stepwiseComposition = success.Map(book => AddPages(book, 50)).Map(book => AppendSubtitle(book, "Updated Edition"));
// stepwiseComposition should also hold value "C# Basics: Updated Edition", 150 pages

虽然我们现在不能直接检索内部值,但可以使用调试器或LinqPad中的Dump()扩展方法来查看。正如我们所看到的,我们的Result类型变成了一个函子。

函子优势

将我们的Result<TValue, TError>类型转换为函子提供了几个简洁的优点,增强了函数式编程中的错误处理和操作结果:

  • 简化的错误处理:将成功和错误结果整合到一个结构中,简化错误管理

  • 可组合的操作:促进在成功结果上链式操作,具有自动错误传播,提高代码的可重用性

  • Map函数的意图很明确——在成功时转换值,或在出错时跳过,使代码更易于理解

  • 类型安全和清晰:类型签名中的显式成功和错误状态增强了可预测性和安全性,确保全面处理结果

听起来很吸引人,对吧?我们可以通过将其制作成一个应用函子来使我们的类变得更好。

应用函子

应用函子是一种允许将封装在函子内的函数应用于也封装在函子内的值的函子类型。这个概念可以转化为实现能够优雅地处理多层计算上下文的操作,例如错误处理或异步操作。

当函子允许我们将函数应用于封装的值时,应用函子通过允许将函数本身封装在上下文中来扩展这种能力。这种区别对于函数应用本身可能导致计算上下文(如失败、延迟或不确定性)的操作至关重要。让我们通过图书出版系统示例来看看这种区别。

考虑一个基于书籍销售计算版税的函数,以及另一个根据市场条件调整这些版税的函数。这两个函数可能由于各种原因而失败,它们的输出可能被封装在Result类型中以表示成功或失败。应用函子允许我们将这些可能失败的函数应用于可能失败的输入,协调复杂操作,优雅地处理多层潜在失败。

Result<Func<int, decimal>, string> CalculateRoyaltiesFunc = new Result<Func<int, decimal>, string>(sales => sales * 0.1m);
Result<Func<decimal, decimal>, string> AdjustRoyaltiesFunc = new Result<Func<decimal, decimal>, string>(royalties => royalties * 1.05m);

在这个例子中,CalculateRoyaltiesFunc是一个函数,它接受销售数量并计算版税为销售额的 10%。AdjustRoyaltiesFunc是一个函数,它接受一个初始版税金额,并通过乘以1.05的因子来调整以反映市场条件。

现在,让我们假设我们有一个Result<int, string>表示书籍销售数量,它也可能失败:

Result<int, string> salesResult = new Result<int, string>(150);

为了计算调整后的版税,我们首先将CalculateRoyaltiesFunc应用于salesResult,然后将AdjustRoyaltiesFunc应用于结果:

var royaltiesResult = salesResult
    .Apply(CalculateRoyaltiesFunc)
    .Apply(AdjustRoyaltiesFunc);
// the royaltiesResult holds the value 15.75

为了更好地理解,让我们假设我们的第一个函数返回一个错误:

Result<Func<int, decimal>, string> CalculateRoyaltiesFunc = Result<Func<int, decimal>, string>.Failure("Can't calculate royalties");

如果我们再次尝试计算 royaltiesResultIsSuccess 将为 false,并且 Error 属性将包含字符串 "Can't calculate royalties"。如果 AdjustRoyaltiesFunc 调用导致错误,也会发生相同的情况。两种方法都可能失败;然而,多亏了 Apply 方法,我们可以以安全的方式调用它们。听起来很棒,但这个 Apply 方法看起来是什么样子呢?

应用方法实现

为了实现应用函子模式,我们引入了 Apply 方法。该方法接受包含函数的 Result,并在当前 Result 实例中的值成功的情况下将其应用于该值。如果函数或值被包装在失败的 Result 中,Apply 方法会传播错误:

public Result<TResult, TError> Apply<TResult>(Result<Func<TValue, TResult>, TError> resultFunc)
{
    if (resultFunc.IsSuccess && this.IsSuccess)
    {
        return Result<TResult, TError>.Success(resultFunc.Value(this.Value));
    }
    else
    {
        var error = resultFunc.IsSuccess ? this._error! : resultFunc.Error;
        return Result<TResult, TError>.Failure(error);
    }
}

如您所见,这里没有什么特别之处。首先,我们确保当前容器状态和传入的 Result<Func<TValue, TResult>, TError> 状态都是成功的。然后,我们返回一个新的 Result<TResult, TError>。如果任一 IsSuccess 属性为 false,则返回相应的错误。然而,一个类要被认为是应用函子,不仅仅需要一个 Apply 方法;它必须遵守应用函子定律。

当 Steve 正在掌握函子的概念时,Julia 提出了一个新挑战。

Julia:现在,如果你想要一次性应用多个升级到塔上,但有些升级可能会失败,这时应用函子就派上用场了。

Steve:一次性应用多个升级?这真的可以简化我的升级系统!

应用函子定律

有四个应用函子定律:恒等、同态、交换和组合。让我们逐一介绍它们。

恒等定律

恒等定律指出,将恒等函数应用于 Result 包装的值应该产生没有任何变化的原始 Result

// Identity function
Func<int, int> identity = x => x;
// Result-wrapped value, representing, for example, a count of books
var bookCount = Result<int, string>.Success(10);
// Applying the identity function to the bookCount
var identityApplied = bookCount.Map(identity);
// The identity operation should not alter the original Result
Console.WriteLine(identityApplied.IsSuccess && identityApplied.Value == 10);  // Output: True

同态定律

这个定律表明,将一个函数应用于一个值然后包装它,与先包装值然后在该 Result 中应用函数是等价的:

Func<int, double> calculateRoyalties = sales => sales * 0.15;
int bookSales = 100;
// Applying function then wrapping
var directApplication = Result<double, string>.Success(calculateRoyalties(bookSales));
// Wrapping then applying function
var wrappedApplication = Result<int, string>.Success(bookSales).Map(calculateRoyalties);
// Both operations should yield the same result
Console.WriteLine(directApplication.IsSuccess && wrappedApplication.IsSuccess && directApplication.Value == wrappedApplication.Value);  // Output: True

交换定律

这个定律表明,将包装的函数应用于包装的值应该等同于应用一个将它的参数应用于包装值的函数。

对于这个定律,我们需要扩展我们的 Result 类型以支持将一个 Result 包装的函数应用于一个 Result 包装的值,这并不是由提供的 Result 类型结构直接支持的。然而,在一个支持这种功能的系统中,概念上的应用可能看起来像这样:

Result<Func<int, double>, string> wrappedCalculateRoyalties = new Result<Func<int, double>, string>(calculateRoyalties);
Result<int, string> salesResult = Result<int, string>.Success(bookSales);
// Wrapped function applied to wrapped value
var applied = salesResult.Apply(wrappedCalculateRoyalties);
// Equivalent to applying a function that takes a function and applies it to the value
Func<Func<int, double>, Result<double, string>> applyFuncToValue = func => Result<double, string>.Success(func(bookSales));
var interchangeResult = wrappedCalculateRoyalties.Map(applyFuncToValue);
// The results of applied and interchangeResult should be equivalent

组合定律

组合定律确保当我们组合两个或更多函数并将它们应用于一个函子时,函数应用的顺序不会影响结果。这个称为结合性的属性是组合定律的核心。它确保如果我们有函数 f、g 和 h,将它们组合并应用于函子 F 的结果,无论在组合过程中如何分组函数,都是相同的。

例如,考虑两个函数,f(x) = x + 1 和 g(x) = x * 3。根据组合定律,将 f 和 g 组合并应用于函子内部的值应该产生与将 f 应用到函子然后 g 相同的结果。用数学表达式来说,F.map(g(f(x))) 等价于 F.map(f).map(g)。

这种结合性质允许我们自信地推理组合函数,知道函数应用分组不会影响最终结果,当应用于函子时。这一原则增强了函数式编程的可预测性和可靠性,允许开发者简洁且安全地组合复杂的转换。

对于这个定律,类似于交换定律,我们需要一个机制在 Result 上下文中组合函数,而我们的当前 Result 类型定义并不直接支持。从概念上讲,它看起来像这样:

Func<int, double> calculateRoyalties = sales => sales * 0.15;
Func<double, double> adjustForMarket = royalties => royalties * 1.05;
// Composition of functions outside the Result context
Func<int, double> composed = sales => adjustForMarket(calculateRoyalties(sales));
// Applying composed function to a Result-wrapped value
var composedApplication = Result<int, string>.Success(bookSales).Map(composed);
// The result of composedApplication should be equivalent to applying each function within the Result context in sequence

正如你所见,我们的 Result 类型遵循所有这些定律,可以被视为一个应用函子。从这里,我们需要再迈出一步,使其成为一个单子。

单子

史蒂夫对函子很兴奋,但仍然难以看到它们在他游戏逻辑中的全部潜力。朱莉娅知道是时候引入单子了。

Julia:让我们将你的游戏升级系统再向前推进一步。想象一系列操作:检查玩家的金币,扣除费用,并应用升级。每一步都依赖于前一步的成功。这正是单子的优势所在。

史蒂夫:这听起来正是我升级系统所需要的。单子是如何处理这个问题的?

单子代表了我们对函子和应用函子概念探索的演变。虽然函子允许我们将函数映射到包装的值上,而应用函子使得我们可以将包装的函数应用于包装的值,但单子引入了以处理这些操作上下文的能力——无论是错误、列表、选项或其他计算上下文。因此,我们可以说单子是一个遵循某些额外定律的应用函子。

单子的本质在于其能够展开由返回包装值的函数应用所引起的包装层。这在执行多个操作时避免深层嵌套结构至关重要。让我们用一个来自我们出版系统的例子来分解这个概念。

想象一个场景,我们需要获取一份手稿,编辑它,然后格式化它以供出版。这些操作中的每一个都可能失败并返回 Result<TValue, TError>,导致嵌套的 Result<Result<...>> 类型。单子允许我们以更干净的方式按顺序执行这些操作。

Bind 方法

单子的关键在于 Bind 方法(在不同的语言和框架中通常被称为 flatMapSelectMany)。此方法将一个函数应用于包装的值,返回相同类型的包装器,然后展开结果:

public Result<TResult, TError> Bind<TResult>(Func<TValue, Result<TResult, TError>> func)
    {
        return IsSuccess ? func(_value!) : Result<TResult, TError>.Failure(_error!);
    }

使用 Bind,我们可以链式操作而不需要嵌套。让我们将其应用于我们的出版系统:

Result<Manuscript, string> FetchManuscript(int manuscriptId) { ... }
Result<EditedManuscript, string> EditManuscript(Manuscript manuscript) { ... }
Result<FormattedManuscript, string> FormatManuscript(EditedManuscript edited) { ... }
var manuscriptId = 101;
var publishingPipeline = FetchManuscript(manuscriptId)
    .Bind(EditManuscript)
    .Bind(FormatManuscript);

在这个管道中,只有前一个步骤成功时才会应用每个步骤,任何失败都会立即短路链。

单子法则

除了函子,单子也必须满足其法则以确保一致性和可预测性,它们有三个:左恒等、右恒等和结合律。

左恒等

包装一个值然后绑定到一个函数与直接应用该函数到值相同:

Func<int, Result<double, string>> calculateRoyalties = sales => new Result<double, string>(sales * 0.15);
int bookSales = 100;
var leftIdentity = Result<int, string>.Success(bookSales).Bind(calculateRoyalties);
var directApplication = calculateRoyalties(bookSales);
// leftIdentity should be equivalent to directApplication

右恒等

使用一个简单地重新包装值的函数绑定包装的值应该产生原始的包装值:

var manuscriptResult = Result<Manuscript, string>.Success(new Manuscript());
var rightIdentity = manuscriptResult.Bind(manuscript => Result<Manuscript, string>.Success(manuscript));
// rightIdentity should be equivalent to manuscriptResult

结合律

绑定操作的顺序不应影响:

var associativity1 = FetchManuscript(manuscriptId).Bind(EditManuscript).Bind(FormatManuscript);
var associativity2 = FetchManuscript(manuscriptId).Bind(manuscript => EditManuscript(manuscript).Bind(FormatManuscript));
// associativity1 should be equivalent to associativity2

利用单子

单子在涉及计算序列的操作中特别出色,其中每个步骤可能会失败或产生新的上下文。在我们的图书出版系统中,我们可以扩展这个模式来处理用户输入验证、数据库事务或网络调用,确保我们的代码保持清洁、可读和可维护:

Result<Publication, string> PublishManuscript(FormattedManuscript formatted) { ... }
var finalResult = FetchManuscript(manuscriptId)
    .Bind(EditManuscript)
    .Bind(FormatManuscript)
    .Bind(PublishManuscript);

这种方法不仅通过自动传播错误简化了错误处理,而且使快乐的路径代码保持清晰和直接,无需手动检查或嵌套条件。

单子就像智能容器,有助于管理一系列步骤,尤其是当你处理诸如错误或需要时间完成的任务等棘手情况时。通过掌握函子并提升到单子,你可以使你的代码不仅更具表现力,而且更容易维护和更可靠。想想我们如何在我们的图书出版示例中简化任务;单子和它们的伙伴们可以真正解开复杂的逻辑,使处理错误变得更加顺畅,使整个代码库更加友好,更容易工作。

关键要点

在我们总结关于函子和单子的这一章时,让我们花点时间总结一下我们学到了什么:

  • 函子的基本概念:函子对于数据处理的功能性编程至关重要。它们充当“魔法盒子”,允许我们对它们所持有的数据进行函数应用,在保持原始结构的同时转换其内容。

  • 并非所有容器都是函子:要使数据容器被视为函子,它必须遵守两个关键法则:恒等律和组合律。这些法则确保函子在其预期范式中操作时具有可预测性和一致性。

  • 恒等律:恒等律强调,将恒等函数(返回其输入的函数)映射到函子上应该使函子保持不变。这条法则强调了函子转换的非侵入性。

  • 组合律:组合律断言,函数组合和应用于函子的顺序不会影响最终结果。这条法则突出了函数在功能性编程中的可组合性和灵活性。

  • Result<TValue, TError>类型的示例中,我们探讨了如何实际实现函子以增强错误处理和操作结果。Map方法展示了函数在函子封装的值中的应用,遵循函子定律。

  • 应用函子:本章还介绍了应用函子的概念,它通过允许将包裹在函子中的函数应用到也包裹在函子中的值上,在基本函子之上构建。这种能力使得优雅地处理多层计算上下文成为可能。

  • 应用函子定律:应用函子受额外的定律约束,包括恒等性、同构性、交换性和结合性。这些定律进一步确保了应用函子在复杂操作中的可靠和可预测的行为。

  • 单子:讨论为函子和应用函子的概念演变到单子奠定了基础,单子通过允许操作链的连接来扩展函子和应用函子的概念,这些操作处理操作的上下文,如错误或异步计算。

最后,函子和单子不仅仅是理论结构;它们是实用的工具,可以将你的代码从普通转变为卓越。拥抱它们带来的机会,看看你的编程技能如何达到新的表达性和效率的高度。编码愉快!

练习

在解释了这些概念之后,Julia 挑战 Steve 应用他所学的知识。

Julia:既然你已经了解了基础知识,为什么不尝试使用单子重构你的游戏升级系统呢?这应该会使你的代码更加健壮,并且更容易 理解

Steve:这是一个很好的想法!我已能看到这如何简化我的一些更复杂的 游戏逻辑

练习 1

给定一个表示塔列表的Result<List<Tower>, string>类型,其中Tower是一个包含IdNameDamage等属性的类,任务是用函子概念将一个函数应用到每个塔上,在其名称末尾添加“(Upgraded)”以指示塔已被升级:

public class Tower
{
    public int Id { get; set; }
    public string Name { get; set; }
    public int Damage { get; set; }
}
public Result<List<Tower>, string> UpgradeTowers(List<Tower> towers)
{
    // Write your code here
}

练习 2

想象一下,你有两个被Result类型包裹的函数:Result<Func<Tower, bool>, string> ValidateDamage用于检查塔的伤害是否在可接受的范围内,而Result<Func<Tower, bool>, string> ValidateName用于检查塔的名称是否符合某些标准。给定一个表示单个塔的Result<Tower, string>,使用应用函子将这两个验证函数应用到塔上,确保两个验证都通过:

public Result<Func<Tower, bool>, string> ValidateDamage = new Result<Func<Tower, bool>, string>(tower => tower.Damage < 100);
public Result<Func<Tower, bool>, string> ValidateName = new Result<Func<Tower, bool>, string>(tower => tower.Name.Length > 5 && !tower.Name.Contains("BannedWord"));

练习 3

给定一系列用于升级塔的操作——FetchTowerUpgradeTowerDeployTower——每个操作都可能失败并返回Result<Tower, string>,使用单子概念将这些操作链在一起,针对给定的塔 ID。确保如果任何步骤失败,整个操作短路并返回错误:

public Result<Tower, string> FetchTower(int towerId) { /* Fetches tower based on ID */ }
public Result<Tower, string> UpgradeTower(Tower tower) { /* Upgrades the tower and can fail */ }
public Result<Tower, string> DeployTower(Tower tower) { /* Attempts to deploy the tower */ }

这些练习旨在帮助你们巩固本章学到的概念,并理解如何在不同的场景中应用它们。

解决方案

我希望你们都完成了所有的练习,只是想看看我的解决方案。如果没有,不要担心,一切都会随着经验而来。现在,让我们看看解决方案。

练习 1

使用 Map 方法将一个函数应用到列表中的每个塔上,该函数将其名字追加 “(Upgraded)”:

public Result<List<Tower>, string> UpgradeTowers(Result<List<Tower>, string> towersResult)
{
    return towersResult.Map(towers =>
        towers.Select(tower =>
        {
            tower.Name += " (Upgraded)";
            return tower;
        }).ToList());}

这个解决方案展示了函子如何封装数据和行为,允许在包含的数据上执行操作,同时保留整个操作(成功或失败)的上下文:

  • towersResult.Map(...): 这一行使用了 Map 函数,这是函子模式的基本功能。如果 Result 成功,它将应用给定的函数到包含的值上,而不影响外部的 Result 结构。

  • towers.Select(tower => ...): 在 Map 中,Select LINQ 方法遍历列表中的每个 Tower 对象,应用一个修改 Name 属性的 lambda 函数。

  • tower.Name += " (Upgraded)":这个操作将 "(Upgraded)" 追加到每个塔的名字上,表示它已经被升级。

练习 2

应用函子模式通过允许函数本身被封装在上下文中(如 Result)来扩展函子的功能。这个解决方案使用这种能力将多个封装在 Result 类型中的验证函数应用到单个也封装在 Result 中的视频上:

public Result<bool, string> ValidateTower(Result<Tower, string> towerResult)
{
    var damageValidated = towerResult.Apply(ValidateDamage);
    var nameValidated = towerResult.Apply(ValidateName);
    return damageValidated.Bind(damageResult =>
        nameValidated.Map(nameResult => damageResult && nameResult));
}

在这里,我们以可组合的方式处理多个潜在失败点,展示了在函数式错误处理中应用函子的强大能力:

  • towerResult.Apply(ValidateDamage)towerResult.Apply (ValidateName):这些行展示了应用函子的 Apply 方法。它接受包含函数的 Result 并将其应用于另一个包含值的 Result,有效地展开两者并将函数应用于值。

  • BindMap 的链式调用确保了如果任何验证失败,整个验证过程将短路并返回失败。否则,它将两个验证的结果合并成一个布尔值。

练习 3

单子通过允许返回封装类型的操作链(如 Result<Tower, string>)来扩展应用函子的概念,实现操作的无缝和错误传播序列:

public Result<Tower, string> ProcessAndDeployTower(int towerId)
{
    return FetchTower(towerId)
        .Bind(UpgradeTower)
        .Bind(DeployTower);
}

这个解决方案展示了单子管理上下文中依赖操作序列的能力,使错误处理更加直观和线性,从而显著简化了复杂业务逻辑:

  • FetchTower(towerId).Bind(UpgradeTower).Bind(DeployTower):这一行通过 Bind 方法展示了单子的本质。链中的每个函数(FetchTowerUpgradeTowerDeployTower)可能返回一个 Result<Tower, string>,而 Bind 确保只有前一个函数成功时,后续的函数才会执行。

  • 这里的单调模式保证了如果在过程中任何一步失败,错误会立即通过链传播,绕过后续步骤并返回失败结果。

概述

在本章中,我们讨论了函子及其在函数式编程中的作用。我们从基础知识开始,解释说函子就像智能容器。它们可以持有数据,并且可以在保持其原始形状的同时对数据进行函数应用。

我们研究了函子的运作方式,展示了它们如何允许我们在不拆包和重新打包容器的情况下对它们持有的数据进行函数应用。我们还涵盖了函子遵循的两个主要规则:恒等性和组合律。

通过示例,我们看到了函子可以在不同情况下使用,例如处理列表或更平滑地处理错误。我们探讨了创建自己的函子,这为定制我们的代码以适应我们的需求提供了新的方法。我们从函子到应用函子和单子的完整旅程,了解了每个这些“容器”遵循的法则,并在一些实际场景中看到了它们的应用。

随着本章的结束,你应该已经对在项目中使用函子和单子有了坚实的基础。它们是简单而强大的工具,可以在你处理编码方式上产生重大影响。接下来,在第第八章中,递归和尾调用,我们将深入递归领域,了解函数如何调用自身,并探讨尾调用如何使递归更高效。

第三部分:实用函数式编程

在这部分中,我们从理论转向实践,探索如何将函数式编程概念应用于现实世界场景。我们将从递归和尾调用开始,学习如何编写高效的递归函数。然后,我们将探讨柯里化和部分应用,这些技术允许你创建更灵活和可重用的函数。最后,我们将探讨如何组合函数以创建强大的数据处理管道,将本书中学到的许多概念结合起来。

本部分包含以下章节:

  • 第八章**,递归和尾调用

  • 第九章**,柯里化和部分应用

  • 第十章**,管道和组合

第八章:递归和尾调用

在本章中,我们将探讨递归的概念,这对于解决具有固有层次或重复结构的难题特别有效,例如目录遍历、解析嵌套数据格式或实现树形数据结构的算法。

随着我们深入递归,我们将探讨其两个主要组成部分:基本情况(base case)和递归情况(recursive case)。基本情况充当递归的停止信号,防止无限循环,而递归情况是函数向基本情况迈进的地方。除了这些情况之外,我们还将讨论以下主题:

  • 递归类型:简单递归和尾递归

  • 递归的挑战:栈溢出风险和性能考虑

  • C# 的递归特性:局部函数和模式匹配

  • 高级递归模式:相互递归和记忆化

  • 与迭代解决方案的比较:可读性和性能

  • 异步编程中的递归:异步递归

像往常一样,我们从一个自我评估开始,以衡量你对递归当前的理解。以下任务旨在测试你对我们将要涵盖的概念的掌握。如果你发现这些任务具有挑战性,那么这一章将是非常有价值的资源。另一方面,如果你轻松地解决了这些任务,你仍然可以发现递归的新见解和应用,或者继续阅读下一章。

任务 1 – 递归敌人计数

史蒂夫的游戏具有敌人波次的层次结构,其中每个波次可以包含单个敌人和子波次。实现一个递归函数 CountAllEnemies,该函数遍历 Wave 对象(可以包含 Enemy 对象和 Wave 对象),并返回在该波次及其所有子波次(飞行、装甲、快速等)中找到的敌人总数:

public interface IWaveContent {}
public class Enemy : IWaveContent
{
    public string Name { get; set; }
}
public class Wave : IWaveContent
{
    public List<IWaveContent> Contents { get; set; } = new();
}
// Implement this method
int CountAllEnemies(Wave wave)
{
    // Your recursive logic here
}

使用包含混合 Enemy 对象和 Wave 对象的 Wave 测试你的方法,以确保它准确计算所有敌人,包括嵌套子波中的敌人。

任务 2 – 波次生成

使用与任务 1 相同的波次结构,史蒂夫希望在游戏进行过程中生成越来越复杂的波次。实现一个递归函数 GenerateWave,该函数根据当前级别数量创建具有嵌套敌人子波次的 Wave 对象。

public interface IWaveContent {}
public class Enemy : IWaveContent
{
     public string Name { get; set; }
     public EnemyType Type { get; set; }
}
public class Wave : IWaveContent
{
     public List<IWaveContent> Contents { get; set; } = new();
}
public enum EnemyType
{
     Normal,
     Flying,
     Armored,
     Boss
}
// Implement this method
Wave GenerateWave(int levelNumber)
{
     // Your recursive logic here
}

此函数应随着级别数量的增加创建更复杂的波形结构。请考虑以下指南:

  • 每 5 个级别增加一个子波次。

  • 每个波次或子波次中的敌人数量应随着级别数量的增加而增加。

  • 随着级别的提升,引入更多样化的敌人类型。

每 10 个级别应包括一个 Boss 敌人。

使用不同的级别数量测试你的方法,以确保它生成适当的波形结构。

示例用法:

int currentLevel = 15;
Wave generatedWave = GenerateWave(currentLevel);
// Use the CountAllEnemies function from Task 1 to verify the total number of enemies
int totalEnemies = CountAllEnemies(generatedWave);
Console.WriteLine($"Level {currentLevel} wave contains {totalEnemies} total enemies");
// You can also implement a function to print the wave structure for verification
PrintWaveStructure(generatedWave);

任务 3 – 异步更新敌人状态

更新敌人的统计数据(如健康、速度或伤害)可能需要异步进行,特别是如果它涉及到从游戏服务器获取或同步信息。实现一个UpdateAllEnemyStatsAsync方法,该方法递归地遍历波次层次结构(包含敌人和子波次)并异步更新每个敌人的统计数据。

为了这个练习,使用UpdateStatsAsync(Enemy enemy)方法模拟异步更新操作,该方法返回Task。你的递归函数应在移动到下一个敌人之前等待每个统计数据更新的完成:

class Enemy
{
    public string Name { get; set; }
    // Assume other stat properties like Health, Speed, Damage
}
class Wave
{
    public List<object> Contents { get; set; } = new();
}
// Simulated asynchronous update method
async Task UpdateStatsAsync(Enemy enemy)
{
    // Simulate an asynchronous operation with a delay
    await Task.Delay(100); // Simulated delay
    Console.WriteLine($"Updated stats for enemy: {enemy.Name}");
}
// Implement this recursive async method
async Task UpdateAllEnemyStatsAsync(Wave wave)
{
    // Your recursive logic here
}

当你处理这些任务时,注意你如何将每个问题分解成更小的部分,以及你如何识别每个场景的基本情况和递归情况。这种初步的自我评估不仅会为你准备前面的概念,还会提供一个实际的应用背景。现在,让我们深入探讨递归。

介绍递归

随着史蒂夫继续开发他的塔防游戏,他发现自己难以处理复杂的嵌套结构敌波。他打电话给朱莉娅,希望她可能有一些见解。

朱莉娅:听起来你正在处理层次化的数据结构。你考虑过 使用递归吗?

史蒂夫:递归?这不是函数调用自身吗?这对我来说一直有点令人困惑

朱莉娅:没错,但它是一个处理嵌套结构的强大工具。让我们看看它如何帮助你 的游戏。

递归是一种编程技术,其中函数调用自身来解决问题。这就像将任务分解成相同类型的小任务。这种方法对于具有重复结构的任务非常有用,例如导航文件夹和文件、处理如树这样的数据结构或进行遵循模式的计算。

在递归中,有两个主要部分:基本情况和对递归情况。基本情况阻止递归无限进行。这是函数不再调用自身的地方。递归情况是函数调用自身,但使用原始问题的简化版本。

让我们将递归应用于一个实际例子。想象一下,我们需要计算一系列嵌套播放列表中视频的总观看次数,其中播放列表可以包含视频和其他播放列表。

我们可以这样编写一个递归函数来解决这个问题:

class Video : IContent
{
   public int Views {get; set;}
   // Other properties like title, duration, etc.
}
class Playlist : IContent
{
   public List<IContent> Contents; // Can contain both Videos and Playlists
}
int CountViews(IContent item)
{
   if (item is Video video)
   {
      // Base case: If the item is a video, return its view count.
      return video.Views;
   }
   if (item is Playlist playlist)
   {
      // Recursive case: If the item is a playlist, sum up the views of all contents.
      int totalViews = 0;
      foreach (var content in playlist.Contents)
      {
         totalViews += CountViews(content); // Recursively count views
      }
      return totalViews;
   }
   // In case the item is neither a Video nor a Playlist
   throw new ArgumentException($"Unsupported content type {item.GetType().Name}");
}

在这段代码中,CountViews是一个递归函数,它可以处理视频和播放列表。如果它遇到视频,它返回观看次数(基本情况)。如果它遇到播放列表,它遍历播放列表中的每个项目,并调用自身来计算观看次数,将所有观看次数加起来得到总数(递归情况)。

对于这类问题,递归非常强大,因为它简化了代码并使其更易于阅读,尤其是在处理嵌套或层次化数据时。然而,始终要有明确的基准情况,以防止函数无限期地调用自身。

递归思维

当管理复杂的视频层次结构,如将它们分类到类别和子类别中时,递归思维可以简化这个过程。递归思维意味着将一个大问题分解成相同问题的较小版本,直到它变得容易解决。

让我们以组织视频类别和子类别的树状结构为例。目标是遍历每个类别,访问其所有子类别,并组织每个类别中的视频。这项任务听起来很复杂,但递归通过一次处理一个类别(及其子类别)来简化了任务。

你可能会这样编写一个递归函数来完成这个任务:

class Category
{
    public List<Category> Subcategories;
    public List<Video> Videos;
    // Other properties like name, etc.
}
void OrganizeVideos(Category category)
{
    // First, go through each subcategory
    foreach (var subcategory in category.Subcategories)
    {
        OrganizeVideos(subcategory); // Recursive call to organize subcategories
    }
    // Now, organize the current category's videos
    // You can add sorting, filtering, or other logic here
    Console.WriteLine($"Organizing videos in category: {category.Name}");
}

在此代码中,OrganizeVideos 是一个递归函数。它查看一个类别,并为每个子类别调用自身,深入到层次结构的更深处。这是递归情况。在访问完所有子类别后,它然后在当前类别中组织视频。这就是你放置排序或组织逻辑的地方,但现在我们保持简单,使用一个 print 语句。

递归思维的美丽之处在于它如何简化对复杂层次结构的管理。你只需一次处理一个级别的视频组织,递归会为你处理层次结构的深度。就像之前的例子一样,有一个清晰的基准情况(在这种情况下,到达没有子类别的类别)可以确保递归不会无限期地进行。

现在,让我们看看一个示例,展示递归思维在解析嵌套 JSON 数据中的强大功能。考虑一个场景,我们需要处理表示图书出版系统目录的 JSON 字符串,并将其转换为相应的对象层次结构。这个例子将展示递归如何简化导航和构建复杂数据结构的任务。

假设我们有一个表示图书目录的 JSON 字符串,其中包含嵌套的流派和子流派:

{
  "catalog": {
    "name": "Book Catalog",
    "genres": [
      {
        "name": "Fiction",
        "subgenres": [
          {
            "name": "Mystery",
            "books": [
              {
                "title": "The Hound of the Baskervilles",
                "author": "Arthur Conan Doyle",
                «isbn": "9780141032435"
              },
              {
                "title": "Gone Girl",
                "author": "Gillian Flynn",
                «isbn": "9780307588371"
              }
            ]
          },
          {
            "name": "Science Fiction",
            "books": [
              {
                "title": "Dune",
                "author": "Frank Herbert",
                «isbn": "9780441013593"
              }
            ],
            "subgenres": [
              {
                "name": "Dystopian",
                "books": [
                  {
                    "title": "1984",
                    "author": "George Orwell",
                    «isbn": "9780451524935"
                  }
                ]
              }
            ]
          }
        ]
      },
      {
        "name": "Non-Fiction",
        "books": [
          {
            "title": "Sapiens: A Brief History of Humankind",
            "author": "Yuval Noah Harari",
            «isbn": "9780062316097"
          }
        ]
      }
    ]
  }
}

为了解析这个 JSON 字符串并创建相应的对象层次结构,我们定义了以下类:

class Catalog
{
    public string Name { get; set; }
    public List<Genre> Genres { get; set; }
}
class Genre
{
    public string Name { get; set; }
    public List<Book> Books { get; set; }
    public List<Genre> Subgenres { get; set; }
}
class Book
{
    public string Title { get; set; }
    public string Author { get; set; }
    public string ISBN { get; set; }
}

现在,让我们实现递归函数来解析 JSON 字符串并构建对象层次结构:

Catalog ParseCatalog(JsonElement json)
{
    Catalog catalog = new Catalog();
    catalog.Name = json.GetProperty("catalog").GetProperty("name").GetString();
    catalog.Genres = new List<Genre>();
    foreach (JsonElement genreJson in json.GetProperty("catalog").GetProperty("genres").EnumerateArray())
    {
        Genre genre = ParseGenre(genreJson);
        catalog.Genres.Add(genre);
    }
    return catalog;
}
Genre ParseGenre(JsonElement json)
{
    Genre genre = new Genre();
    genre.Name = json.GetProperty("name").GetString();
    genre.Books = new List<Book>();
    genre.Subgenres = new List<Genre>();
    if (json.TryGetProperty("books", out JsonElement booksJson))
    {
        foreach (JsonElement bookJson in booksJson.EnumerateArray())
        {
            Book book = ParseBook(bookJson);
            genre.Books.Add(book);
        }
    }
    if (json.TryGetProperty("subgenres", out JsonElement subgenresJson))
    {
        foreach (JsonElement subgenreJson in subgenresJson.EnumerateArray())
        {
            Genre subgenre = ParseGenre(subgenreJson);
            genre.Subgenres.Add(subgenre);
        }
    }
    return genre;
}
Book ParseBook(JsonElement json)
{
    Book book = new Book();
    book.Title = json.GetProperty("title").GetString();
    book.Author = json.GetProperty("author").GetString();
    book.ISBN = json.GetProperty("isbn").GetString();
    return book;
}

ParseCatalog 函数作为入口点,递归地为目录中的每个流派调用 ParseGenreParseGenre 然后,递归地为每个子流派调用自身,并为流派或子流派中的每本书调用 ParseBook

使用递归,我们可以有效地导航 JSON 字符串的嵌套结构,处理在父元素(目录和类别)上下文中子元素(流派、子流派和书籍)的解析。与需要显式管理多层嵌套和检查子流派和书籍存在性的迭代解决方案相比,这种方法的结果代码更干净、更易于维护。

递归的类型

根据递归调用方式和其在函数中的位置,递归可以分为两种主要类型:简单递归和尾递归。

简单递归

简单递归发生在函数直接调用自身时。这种类型是最常见且最容易理解的。让我们用它来计算视频类别和子类别层次结构中的总视频数量:

class Category
{
    public List<Category> Subcategories;
    public List<Video> Videos;
    // Constructor and other members
}
int CountTotalVideos(Category category)
{
    // Start with the current category's videos
    int count = category.Videos.Count;
    foreach (var subcategory in category.Subcategories)
    {
       // Add counts from subcategories
        count += CountTotalVideos(subcategory);
    }
    return count; // Return the total count
}

在此代码中,CountTotalVideos 函数计算给定类别中的所有视频,包括其子类别中的视频。它首先计算当前类别中的视频数量。然后,它遍历每个子类别,对每个子类别调用自身,并将它们的视频计数添加到总数中。

尾递归

尾递归是递归的一个特殊情况,其中递归调用是函数中的最后一个操作。它很重要,因为许多编译器会优化它以避免增加调用栈,这使得函数更高效,并防止栈溢出错误。

让我们来看一个例子,我们将视频类别树扁平化为一个包含视频的单列表。这个任务可以从尾递归优化中受益。

首先,我们需要对我们的方法进行轻微的修改,以允许尾递归。而不是直接返回结果,我们传递一个累加器——一个收集结果的容器:

void FlattenCategories(Category category, List<Video> accumulator)
{
    accumulator.AddRange(category.Videos); // Add current category's videos to the accumulator
    foreach (var subcategory in category.Subcategories)
    {
        FlattenCategories(subcategory, accumulator); // Recursive call with the same accumulator
    }
}

要使用此函数,您可以从一个空列表开始,并将其传递进去:

List<Video> allVideos = new();
FlattenCategories(rootCategory, allVideos);
// Now, allVideos contains all videos from all categories and subcategories

此函数是尾递归的,因为它的最后一个操作是递归调用(或添加到累加器,这并不改变递归的本质)。然而,值得注意的是,并非所有编程语言或编译器都会自动优化尾递归。例如,在.NET 中,尾调用优化由 CLR 决定,并且可能并不总是应用。尽管如此,编写尾递归函数对于效率和清晰度来说是一种良好的实践,尤其是在支持优化的语言和环境中。

递归的挑战

在编程中使用递归时,通常会遇到两个主要挑战:栈溢出的风险和性能考虑。让我们通过我们的角色深入探讨这些挑战。

当 Steve 开始在游戏中实现递归函数时,他遇到了一些问题。

Steve:Julia,当我有很多嵌套层时,我遇到了栈溢出错误。这是 怎么回事 *?

Julia:啊,你已经发现了递归的一个挑战。让我们来谈谈栈溢出的风险以及如何 缓解它 *。

栈溢出风险

栈溢出发生在调用栈中存储的信息过多——这是跟踪每个函数在其执行中位置的内存部分。如果递归函数调用自己太多次而没有达到基本情况,就会发生这种情况。

例如,当计算所有类别和子类别的视频总数时,如果层次结构非常深或存在循环引用(某个类别以某种方式包含了自己),CountVideos 函数可能会无限期地调用自己:

int CountVideos(Category category)
{
    // Start with the count of videos in the current category
    int count = category.Videos.Count;
    foreach (var subcategory in category.Subcategories)
    {
        count += CountVideos(subcategory); // Recursive call
    }
    return count;
}

如果分类结构非常深,这可能会导致成千上万的嵌套调用,每个调用都会向调用栈添加一个帧,从而可能引发栈溢出错误。

默认栈大小和限制

在使用递归时,了解默认栈大小带来的限制至关重要。栈是用于存储方法调用、局部变量和其他信息的内存区域。每次递归调用都会向栈添加一个新的帧,消耗一部分可用的栈空间。如果递归深度变得过大,可能会耗尽栈,导致栈溢出异常。

在 .NET 中,默认栈大小根据架构的不同而有所不同:

  • 32 位:1 MB

  • 64 位:4 MB

重要的是要注意,这些默认大小可能会变化,并且可能根据特定的运行时环境和配置而有所不同。

为了了解栈大小对递归的影响,让我们以先前的 CountVideos 函数为例。如果分类层次结构非常深,对 CountVideos 的递归调用会迅速消耗可用的栈空间。例如,如果栈大小为 1 MB,并且假设平均栈帧大小为 32 字节(为了简化),最大递归深度大约为 32,000(1 MB / 32 字节)。超过这个深度将导致栈溢出异常。

为了减轻栈溢出的风险,您可以采用几种技术:

  • 尾递归优化TCO):如果您的递归函数是尾递归的,编译器可能会优化它以避免向栈添加新的帧。然而,在 .NET 中,TCO 并不保证,并且取决于运行时的决定。

  • 迭代替代方案:使用循环和数据结构(如栈或队列)将递归算法转换为迭代算法。与递归算法相比,迭代解决方案通常具有更小的栈占用。

  • 通过 System.Threading.Thread.MaxStackSize 属性或配置运行时环境。

  • 限制递归深度:在您的递归函数中实现最大深度限制,以防止过度递归。这可以通过传递一个深度计数器作为参数,并检查预定义的限制来完成。

下面是 CountVideos 函数中限制递归深度的示例:

int CountVideos(Category category, int depth, int maxDepth)
{
    if (depth > maxDepth)
    {
        // Recursion depth limit exceeded
        throw new StackOverflowException("Maximum recursion depth exceeded");
    }
    int count = category.Videos.Count;
    foreach (var subcategory in category.Subcategories)
    {
        count += CountVideos(subcategory, depth + 1, maxDepth);
    }
    return count;
}

在这个修改后的版本中,CountVideos函数增加了额外的参数:depth用于跟踪当前递归深度,maxDepth用于指定允许的最大深度。如果depth超过maxDepth,则会抛出StackOverflowException以防止进一步的递归。

性能考虑

递归有时可能不如迭代解决方案高效,尤其是在不优化递归调用的语言和环境中。主要原因包括多次函数调用的开销以及在非尾递归情况下,维护调用栈所需的额外内存。

再次强调,在最佳情况下,智能编译器可以优化它以避免栈溢出,并使其运行得像循环一样高效。然而,并非所有环境都支持这种优化,并且没有这种优化,尾递归在性能上并不比简单递归有优势。

为了减轻深度递归导致栈溢出的风险,有时可以将递归函数重构为使用迭代方法,或者确保递归有一个保证终止的条件。在性能方面,通常是在递归的可读性和优雅性与迭代的效率之间权衡。在某些情况下,使用迭代算法可能是一个更实际的选择,尤其是在处理非常大的数据集或在未优化尾递归的环境中。

利用 C# 功能进行递归

C# 提供了几个功能,可以使编写递归函数更容易,代码更简洁。其中两个功能是局部函数和模式匹配。

局部函数

局部函数允许你在另一个函数体内定义函数。这在需要将所有逻辑封装在单个方法中,并保持递归部分独立以提高清晰性和可维护性时特别有用。

下面是一个示例,展示了如何使用局部函数递归处理视频类别并计数视频:

void ProcessAndCountVideosInCategory(Category category)
{
    int videoCount = 0;
    // Local function for recursion
    void CountVideos(Category cat)
    {
        foreach (var subcategory in cat.Subcategories)
      {
            CountVideos(subcategory); // Recursive call
        }
        videoCount += cat.Videos.Count;
    }
    CountVideos(category); // Start the recursion with the top-level category
    Console.WriteLine($"Total videos: {videoCount}");
}

在这个例子中,CountVideos是一个在ProcessAndCount VideosInCategory中定义的局部函数。它用于遍历视频类别的层次结构,计算所有子类别中的视频数量。总数保存在videoCount变量中,由于 C#的闭包功能,局部函数可以访问这个变量。

模式匹配

模式匹配通过允许你更简单地检查类型和条件,使得处理复杂数据变得更容易。它在需要处理不同类型或场景的递归函数中特别有用。

让我们看看模式匹配如何简化我们处理视频类别的函数:

void ProcessVideoCategory(Category category)
{
    switch (category)
    {
        case Category c when c.HasSubcategories:
            foreach (var subcategory in c.Subcategories)
            {
                ProcessVideoCategory(subcategory); // Recursive call
            }
            break;
        // Additional cases for other types or specific conditions
    }
}

在这个例子中,使用模式匹配来检查category是否有子类别。如果有,函数会递归地处理每个子类别。这种方法使代码更易于阅读,并消除了需要多个if语句或类型检查的需求。

本地函数和模式匹配都是强大的工具,尤其是在处理递归时。它们不仅使您的递归逻辑更易于理解,而且使您的代码更有组织性和简洁性。

高级递归模式

在更复杂的场景中,可以通过相互递归和缓存等技术将递归进一步扩展。这些高级模式可以优化性能并管理需要在不同操作之间交替的任务。

相互递归

当两个或更多函数在循环中相互调用时,发生相互递归。这种模式在您有一个需要在不同类型任务之间切换的问题时特别有用。想象一个场景,其中一个函数组织视频元数据,另一个验证它。每个函数都将其作为其过程的一部分调用另一个函数。

在书籍创建过程中,书籍通常在被视为出版就绪之前经历细致的编辑和审查周期。这个过程本质上是一个迭代过程,依赖于每个阶段的检查,非常适合相互递归模型。在这里,我们探讨了一个场景,即书籍的手稿在内容和格式方面都进行了编辑,每个过程都可能揭示需要对另一个过程进行进一步修改的需求。

考虑这样一个系统,在稿件内容(如叙事结构、人物发展等)初步编辑后,它必须格式化以满足出版标准(包括字体一致性、页边距设置和页眉/页脚内容)。然而,格式化过程可能会引入或揭示需要重新编辑的内容问题,说明了这些阶段之间的动态相互依赖性。

这里是一个概念性的实现:

class Manuscript
{
    public string Title { get; set; }
    public bool ContentEdited { get; set; }
    public bool FormatEdited { get; set; }
    public List<string> ContentIssues { get; set; }
    public List<string> FormatIssues { get; set; }
    public Manuscript(string title)
    {
        Title = title;
        ContentEdited = false;
        FormatEdited = false;
        ContentIssues = new();
        FormatIssues = new();
    }
}
class PublishingWorkflow
{
    public void EditContent(Manuscript manuscript)
    {
        Console.WriteLine($"Editing content for: {manuscript.Title}");
        // Simulate content editing and issue detection
        manuscript.ContentEdited = true;
        manuscript.ContentIssues.Clear(); // Assume content issues are resolved
        // Check for formatting issues
        manuscript.FormatIssues.Add("Inconsistent chapter titles");
        // Check if formatting needs review due to content edits
        if (manuscript.FormatIssues.Any())
        {
            EditFormat(manuscript);
        }
    }
    public void EditFormat(Manuscript manuscript)
    {
        Console.WriteLine($"Editing format for: {manuscript.Title}");
        // Simulate format editing
        manuscript.FormatEdited = true;
        manuscript.FormatIssues.Clear(); // Assume format issues are resolved
        // Formatting might reveal content issues or areas for improvement
        manuscript.ContentIssues.Add("Chapter 3 exceeds length limit");
        // Loop back to content editing if new issues are identified
        if (manuscript.ContentIssues.Any())
        {
            EditContent(manuscript);
        }
    }
}

在这个例子中,PublishingWorkflow 类包含两个相互递归的函数:EditContentEditFormatEditContent 处理叙事和文本的修正,而 EditFormat 确保稿件符合出版商的格式标准。一个阶段发现的问题可能导致回到另一个阶段,反映了为出版准备稿件的现实世界的复杂性。

这种相互递归有效地捕捉了书籍编辑和格式化的循环性质,确保内容质量和展示标准都不会受损。它突出了稿件经历的迭代改进过程,体现了内容编辑和格式化专家之间的协作努力,以实现出版就绪的书籍。通过这个模型,出版工作流程被优化以实现彻底性和质量,确保读者收到精心制作且专业呈现的书籍。

缓存

记忆化是一种通过缓存昂贵函数调用的结果并在相同的输入再次出现时重用这些结果来加速递归函数的技术。这可以显著减少重复使用相同参数调用的函数的计算时间,例如计算类别观看统计。

让我们探讨如何将记忆化应用于优化计算斐波那契数的递归函数——这是一个常见的场景,它说明了记忆化在递归算法中的强大作用:

public class FibonacciCalculator
{
    private Dictionary<int, long> memo = new();
    public long Calculate(int n)
    {
        // Base cases
        if (n == 0) return 0;
        if (n == 1) return 1;
        // Check if the result is already in the cache
        if (memo.ContainsKey(n))
        {
            return memo[n];
        }
        // Recursively calculate the nth Fibonacci number
        long result = Calculate(n - 1) + Calculate(n - 2);
        // Cache the result before returning
        memo[n] = result;
        return result;
    }
}

在这个实现中,FibonacciCalculator 类使用字典来缓存斐波那契计算的成果。当调用 Calculate 方法时,在执行计算之前,它会检查给定 n 的结果是否已经被缓存。如果是,则立即返回缓存的结果,避免进一步的递归调用。如果不是,它将进行递归计算,并在返回结果之前将其缓存。

这种计算斐波那契数的记忆化方法比没有记忆化的简单递归解决方案要高效得多。没有缓存的情况下,递归计算第 n 个斐波那契数的时间复杂度是指数级的(具体为 O(2^n)),这是因为重复计算相同的值。换句话说,每次对 Calculate(n) 的调用都会导致两个额外的调用:Calculate(n-1)Calculate(n-2),每个都类似地分支。唯一的例外是基例,当 n = 0n = 1 时。有了记忆化,复杂度降低到线性(O(n)),因为每个独特的斐波那契数直到 n 都只计算一次。

为了说明记忆化对递归函数调用效率的影响,让我们分析计算斐波那契数 n = 13n = 29n = 79 的具体案例,比较在有和没有记忆化时所需的函数调用次数。

对于 n = 13,总的函数调用次数将是 F(13) + F(12) + F(11) + ... + F(1) + F(0),这遵循斐波那契数列本身,导致我们得到 753 次调用。然而,如果我们使用记忆化,函数调用次数将只有 25。

对于 n = 29,我们必须调用我们的函数 1,664,079 次。另一方面,记忆化方法只需要 57 次调用。

最后,对于 n = 79,函数调用的次数会呈天文数字般增长,使得在这里计算确切的数字变得不切实际。它的数量级是万亿级别的。对于记忆化解决方案,只需要 157 次调用就足够了。

这种分析展示了记忆化在提升性能方面的强大作用,以及在使递归解决方案适用于复杂问题中的关键作用。通过利用记忆化,开发者可以在不承担额外计算成本的情况下使用递归。

总结来说,相互递归和记忆化是强大的技术,可以使你的递归解决方案更高效和强大。相互递归允许在相关任务之间优雅地交替,而记忆化通过避免重复计算来优化性能。

与迭代解决方案的比较

当管理视频播放列表或类似层次数据结构时,递归和迭代方法都有其适用之处。选择它们通常取决于可读性和性能考虑。让我们探讨这两种方法在视频管理系统中的比较。

可读性

递归自然适合可以将问题分解为更小、相似问题的场景。例如,使用递归遍历视频播放列表和子播放列表是直观理解的:

void TraversePlaylist(Playlist playlist)
{
    foreach (var item in playlist.Items)
    {
        switch (item)
        {
            case Video video:
                Console.WriteLine($"Video: {video.Title}");
                break;
            case Playlist subPlaylist:
                TraversePlaylist(subPlaylist); // Recursive call
                break;
        }
    }
}

这个递归函数清晰且反映了播放列表的层次结构。它易于阅读和理解,尤其是对于那些熟悉递归的人来说。

迭代解决方案,使用循环和数据结构如栈或队列,可以管理相同的任务,但通常需要更多的设置。播放列表遍历的迭代版本可能不那么直观:

void TraversePlaylistIteratively(Playlist playlist)
{
    Stack<Playlist> stack = new();
    stack.Push(playlist);
    while (stack.Count > 0)
    {
        Playlist current = stack.Pop();
        foreach (var item in current.Items)
        {
            switch (item)
            {
                case Video video:
                    Console.WriteLine($"Video: {video.Title}");
                    break;
                case Playlist subPlaylist:
                    stack.Push(subPlaylist); // Mimicking recursion
                    break;
            }
        }
    }
}

虽然有效,但迭代解决方案比递归方法更冗长,其逻辑不如递归方法直接。使用栈来模拟递归的调用栈也增加了复杂性。

性能

递归和迭代方法的性能特征可能因具体问题和实现而异。让我们检查包含 10 级子播放列表的视频播放列表的基准结果,每级有 10 个子播放列表:

这些结果提供了有趣的见解:

  • 递归方法大约快 14%,平均执行时间为 299.2 毫秒,而迭代方法的平均执行时间为 348.9 毫秒。

  • 递归方法在性能上略有较少的变化,误差和标准差值较小。

  • 与常见的假设相反,递归方法分配的内存(876 字节)比迭代方法(2840 字节)少,后者多出三倍以上。

这些发现挑战了传统观念,即迭代解决方案总是更有效:

  • 速度: 递归方法优于迭代方法,可能是因为编译器的优化或遍历任务的特定性质。

  • 内存使用: 意想不到的是,递归方法使用的内存显著更少。这可能是由于高效的尾调用优化或其他编译器对递归调用的优化。

  • 一致性: 递归方法在运行中的性能略更一致,这从较低的标准差中可以看出。

重要的是要注意,这些结果仅适用于这个特定的实现和数据集。可能影响结果的因素包括:

  • 播放列表结构的深度和广度

  • 遍历过程中执行的具体操作

  • 编译器的优化能力

运行时环境

总结来说,虽然传统智慧通常出于性能原因倾向于迭代方法,但我们的基准测试表明,对于某些层次结构,递归方法可能更有效。这强调了实证测试的重要性,而不是仅仅依赖一般原则。在递归和迭代之间进行选择时,不仅要考虑代码的可读性和问题结构,还要进行针对你特定用例的性能测试。

异步编程中的递归

异步编程已成为开发响应式应用程序的基石,尤其是在处理 I/O 密集型操作,如网络请求时。当你将递归与异步编程结合使用时,你可以高效地处理诸如从外部 API 获取和处理数据或跨网络管理视频内容等复杂任务。

异步递归允许你在不阻塞主线程的情况下执行递归操作,确保你的应用程序保持响应。例如,当从外部 API 获取视频数据,其中视频被组织成可能包含子类别的类别时,你可以递归地处理每个类别及其子类别,而不会冻结 UI。

下面是如何编写一个异步递归函数来处理视频的示例:

async Task ProcessVideosAsync(Category category)
{
    foreach (var subcategory in category.Subcategories)
    {
        await ProcessVideosAsync(subcategory); // Recursive call
    }
    // Asynchronous processing of current category videos
    foreach (var video in category.Videos)
    {
        await ProcessVideoAsync(video);
    }
}

在这个示例中,ProcessVideosAsync通过对自己进行递归调用处理每个子类别,确保覆盖了类别层次结构的所有级别。然后,它异步地处理当前类别中的每个视频。await的使用确保在移动到下一个操作之前,每个操作都是完整的,从而保持了操作顺序,而没有阻塞。

深入解释异步递归的工作原理

要了解异步递归在底层是如何工作的,让我们深入了解.NET 中的异步编程模型,并探讨状态机的使用以及与ThreadPool的交互。

在.NET 中,异步方法是通过状态机实现的。当调用异步方法时,编译器生成一个状态机来跟踪方法的执行状态。方法中的每个await表达式都标记了一个方法可以暂停的点,允许在等待的操作完成时执行其他工作。

当进行异步递归调用时,为每个递归调用创建一个状态机。状态机由.NET 运行时管理,它协调它们的执行和恢复。

下面是ProcessVideosAsync方法的状态机可能的一个简化表示:

class ProcessVideosAsyncStateMachine
{
   // State machine fields
   Category category;
   IEnumerator<Category> subcategoryEnumerator;
   IEnumerator<Video> videoEnumerator;
   TaskAwaiter<Task> recursiveCallAwaiter;
   TaskAwaiter<Task> processVideoAwaiter;
   int state;
   // MoveNext method
   void MoveNext()
   {
      switch (state)
      {
         case 0:
            subcategoryEnumerator = category.Subcategories.GetEnumerator();
            state = 1;
            goto case 1;
         case 1:
            if (subcategoryEnumerator.MoveNext())
            {
               var subcategory = subcategoryEnumerator.Current;
               recursiveCallAwaiter = ProcessVideosAsync(subcategory).GetAwaiter();
               if (!recursiveCallAwaiter.IsCompleted)
               {
                  state = 2;
                  recursiveCallAwaiter.OnCompleted(MoveNext);
                  return;
               }
            }
            else
            {
               state = 3;
               goto case 3;
            }
         case 2:
            recursiveCallAwaiter.GetResult();
            goto case 1;
         case 3:
            videoEnumerator = category.Videos.GetEnumerator();
            state = 4;
            goto case 4;
         case 4:
            if (videoEnumerator.MoveNext())
            {
               var video = videoEnumerator.Current;
               processVideoAwaiter = ProcessVideoAsync(video).GetAwaiter();
               if (!processVideoAwaiter.IsCompleted)
               {
                  state = 5;
                  processVideoAwaiter.OnCompleted(MoveNext);
                  return;
               }
            }
            else
            {
               state = 6;
               goto case 6;
            }
         case 5:
            processVideoAwaiter.GetResult();
            goto case 4;
         case 6:
            // Asynchronous operation completed
            break;
      }
   }
}

在这种状态机表示中,MoveNext 方法封装了异步递归函数的逻辑。它使用 switch 语句来处理异步操作的不同状态。await 表达式通过 TaskAwaiterOnCompleted 回调转换为异步延续。

当异步递归调用被 await 时,状态机被挂起,控制权返回给调用者。当 await 操作完成时,.NET 运行时会在 ThreadPool 线程上安排状态机的延续。

重要的是要注意,与同步递归调用相比,异步递归调用与 ThreadPool 的交互方式不同。异步递归调用不会消耗栈空间,而是由 ThreadPool 管理,ThreadPool 有有限的线程数。如果异步递归调用的数量超过了可用的 ThreadPool 线程数,ThreadPool 可能会创建额外的线程或将工作项排队,直到线程可用。

为了避免过载 ThreadPool 并确保资源利用效率,在使用异步递归时,请考虑以下最佳实践:

  • 限制递归深度:与同步递归类似,拥有一个终止递归的基本情况至关重要,以防止过多的递归调用。实现最大深度限制或使用其他条件来控制递归深度。

  • 使用 SemaphoreSlim 或 TPL Dataflow 来避免系统过载。

  • CancellationToken。这允许你在需要时优雅地取消递归操作,防止不必要的劳动和资源消耗。

  • 使用 try-catch 块来处理异常,并考虑使用如 Polly 这样的库来实现重试和断路器策略。

通过理解异步递归在底层的工作原理并遵循最佳实践,你可以有效地利用异步编程与递归结合的力量,构建响应迅速且高效的程序。

异步递归是一种强大的技术,它允许你在不阻塞主线程的情况下执行递归操作,即使在处理复杂的分层数据结构或远程 API 调用时,也能使你的应用程序保持响应。通过结合异步编程的优势和递归的优雅性,你可以为各种场景编写更高效、更易于维护的代码。

同步与异步递归

当涉及到实现递归算法时,我们可以在使用同步或异步方法之间进行选择。每种方法都有其自身的特点、优势和权衡。让我们通过遍历文件系统层次结构的示例来比较同步和异步递归代码。

这里是一个同步递归的例子:

void TraverseDirectory(string path)
{
    foreach (var file in Directory.GetFiles(path))
    {
        // Perform some operation on the file
        ProcessFile(file);
    }
    foreach (var subDirectory in Directory.GetDirectories(path))
    {
        // Recursively traverse subdirectories
        TraverseDirectory(subDirectory);
    }
}

在这个例子中,TraverseDirectory 函数接收一个目录路径作为输入,并递归遍历其子目录。对于遇到的每个文件,它调用 ProcessFile 函数对文件执行某些操作。该函数会阻塞,直到所有文件和子目录都被处理。

现在,让我们考虑相同示例的异步版本:

async Task TraverseDirectoryAsync(string path)
{
    var files = await Task.Run(() => Directory.GetFiles(path));
    foreach (var file in files)
    {
           // Perform some asynchronous operation on the file
           await ProcessFileAsync(file);
    }
    var subDirectories = await Task.Run(() => Directory.GetDirectories(path));
    foreach (var subDirectory in subDirectories)
    {
           // Recursively traverse subdirectories asynchronously
           await TraverseDirectoryAsync(subDirectory);
    }
}

在异步版本中,TraverseDirectoryAsync 函数使用 asyncawait 关键字来启用异步执行。它使用 Task.Run 在单独的线程上执行文件系统操作(Directory.GetFilesDirectory.GetDirectories),允许调用线程继续执行而不会阻塞。

假设 ProcessFileAsync 函数对每个文件执行某些异步操作,例如读取其内容或进行 API 调用。使用 await 关键字等待每个异步操作的完成,而不会阻塞调用线程。

现在我们来比较一下每种方法的优缺点:

  • 响应性:异步递归的主要优点是它允许在递归操作执行期间调用线程保持响应。在同步示例中,线程会阻塞,直到所有文件和子目录都被处理,这可能导致 UI 冻结或应用程序无响应。另一方面,异步递归允许线程在等待异步操作完成的同时继续执行其他任务。

  • 性能:异步递归可以通过允许多个操作并发执行来提高性能。在异步示例中,文件系统操作和文件处理可以并行进行,可能减少总体执行时间。然而,实际的性能提升取决于所执行操作的性质和可用的系统资源。

  • 资源利用:异步递归可以通过在等待 I/O 密集型操作完成的同时允许系统处理其他任务来帮助优化资源利用。在同步示例中,线程会阻塞,无法用于其他目的,直到递归操作完成。异步递归通过允许线程为其他任务释放出来,从而更好地利用系统资源。

  • asyncawaitTask 添加了一个额外的抽象层,并需要理解异步编程概念。错误处理和异常传播在异步代码中也变得更加复杂。

异步递归的场景

异步递归在涉及 I/O 密集型任务或长时间运行的 CPU 密集型操作的场景中特别有益。以下是一些示例:

  • 文件系统操作:遍历大型文件系统层次结构并在文件上执行 I/O 操作(如读取或写入数据)可以从异步递归中受益。它允许应用程序在异步执行文件操作时保持响应。

  • 网络操作:涉及发起网络请求或 API 调用的递归算法可以利用异步递归来防止阻塞调用线程。异步递归允许并发执行网络操作,从而提高整体性能。

  • 数据库操作:递归查询或涉及与数据库交互的操作可以使用异步递归实现。它允许应用程序在等待数据库操作完成的同时继续执行其他任务。

  • 复杂计算:执行复杂计算或计算的递归算法可以从异步递归中受益,特别是如果计算可以并行化。异步递归可以帮助将工作负载分配到多个线程或任务,从而可能提高整体执行时间。

重要的是要注意,并非所有递归算法都适合异步执行。异步递归在涉及 I/O 密集型任务或可以高效并行化的递归操作时最为有效。在递归操作主要是 CPU 密集型且无法并行化的情况下,同步递归可能更合适。

理解同步递归和异步递归之间的区别可以帮助你编写更高效的代码。

练习

为了帮助 Steve 将递归概念应用到他的塔防游戏中,Julia 准备了三个编程挑战。让我们看看你是否能帮助 Steve 解决这些问题!

练习 1

Steve 的游戏具有敌人波的层次结构,其中每个波可以包含单个敌人和子波。实现一个递归函数CountAllEnemies,该函数遍历一个Wave对象(可以包含Enemy对象和Wave对象),并返回该波及其所有子波中找到的敌人总数:

public interface IWaveContent {}
public class Enemy : IWaveContent
{
     public string Name { get; set; }
}
public class Wave : IWaveContent
{
     public List<IWaveContent> Contents { get; set; } = new();
}
// Implement this method
int CountAllEnemies(Wave wave)
{
     // Your recursive logic here
}

使用包含Enemy对象和Wave对象混合的Wave来测试你的方法,以确保它准确计算所有敌人,包括嵌套子波中的敌人。

练习 2

使用与任务 1 相同的波形结构,Steve 希望在游戏进行过程中生成越来越复杂的波形。实现一个递归函数GenerateWave,该函数根据当前关卡编号创建一个具有嵌套结构(敌人和小波)的Wave对象。

public interface IWaveContent {}
public class Enemy : IWaveContent
{
     public string Name { get; set; }
     public EnemyType Type { get; set; }
}
public class Wave : IWaveContent
{
     public List<IWaveContent> Contents { get; set; } = new();
}
public enum EnemyType
{
     Normal,
     Flying,
     Armored,
     Boss
}
// Implement this method
Wave GenerateWave(int levelNumber)
{
     // Your recursive logic here
}

此函数应随着关卡编号的增加创建更复杂的波形结构。请考虑以下指南:

  • 每过 5 个关卡,增加一个子波。

  • 每个波或子波中的敌人数量应随着关卡编号的增加而增加。

  • 随着关卡的推进,引入更多样化的敌人类型。

  • 每 10 个关卡应包括一个 Boss 敌人。

使用不同的级别数字测试你的方法,以确保它生成适当的波结构。

示例用法:

int currentLevel = 15;
Wave generatedWave = GenerateWave(currentLevel);
// Use the CountAllEnemies function from Task 1 to verify the total number of enemies
int totalEnemies = CountAllEnemies(generatedWave);
Console.WriteLine($"Level {currentLevel} wave contains {totalEnemies} total enemies");
// You can also implement a function to print the wave structure for verification
PrintWaveStructure(generatedWave);

练习 3

更新敌人的统计数据(如健康、速度或伤害)可能需要异步完成,特别是如果它涉及到从游戏服务器获取或同步信息。实现一个 UpdateAllEnemyStatsAsync 方法,该方法递归遍历波层次结构(包含敌人和子波)并异步更新每个敌人的统计数据。

为了进行这个练习,使用 UpdateStatsAsync(Enemy enemy) 方法模拟异步更新操作,该方法返回 Task。你的递归函数应在移动到下一个敌人之前等待每个敌人统计数据更新的完成:

class Enemy
{
     public string Name { get; set; }
     // Assume other stat properties like Health, Speed, Damage
}
class Wave
{
     public List<object> Contents { get; set; } = new();
}
// Simulated asynchronous update method
async Task UpdateStatsAsync(Enemy enemy)
{
     // Simulate an asynchronous operation with a delay
     await Task.Delay(100); // Simulated delay
     Console.WriteLine($"Updated stats for enemy: {enemy.Name}");
}
// Implement this recursive async method
async Task UpdateAllEnemyStatsAsync(Wave wave)
{
     // Your recursive logic here
}

通过处理这些任务,你将提高你的递归思维能力,管理复杂数据结构,并有效地利用异步编程技术。

解决方案

现在,让我们深入探讨这些练习的解决方案。像往常一样,这些解决方案代表了问题可以解决的一种方式。它们提供只是为了帮助你验证你的工作,并提供对递归问题不同方法的见解。

练习 1

int CountAllEnemies(Wave wave)
{
     int count = 0;
     foreach (var content in wave.Contents)
     {
                  switch (content)
                  {
                      case Enemy:
                          count++;
                          break;
                      case Wave subWave:
                          count += CountAllEnemies(subWave);
                          break;
                  }
     }
     return count;
}

这个解决方案展示了递归在导航嵌套波结构中的基本应用。它逐步计算敌人数量,并在遇到子波时进一步深入。

练习 2

public Wave GenerateWave(int levelNumber)
{
     Wave wave = new Wave();
     wave.Contents = new List<IWaveContent>();
     // Base number of enemies increases with level
     int baseEnemyCount = 5 + levelNumber;
     // Add normal enemies
     for (int i = 0; i < baseEnemyCount; i++)
     {
                  wave.Contents.Add(new Enemy { Name = "Normal Enemy", Type = EnemyType.Normal });
     }
     // Add flying enemies every 3 levels
     if (levelNumber % 3 == 0)
     {
                  int flyingEnemyCount = levelNumber / 3;
                  for (int i = 0; i < flyingEnemyCount; i++)
                  {
                      wave.Contents.Add(new Enemy { Name = "Flying Enemy", Type = EnemyType.Flying });
                  }
     }
     // Add armored enemies every 4 levels
     if (levelNumber % 4 == 0)
     {
                  int armoredEnemyCount = levelNumber / 4;
                  for (int i = 0; i < armoredEnemyCount; i++)
                  {
                      wave.Contents.Add(new Enemy { Name = "Armored Enemy", Type = EnemyType.Armored });
                  }
     }
     // Add a boss every 10 levels
     if (levelNumber % 10 == 0)
     {
                  wave.Contents.Add(new Enemy { Name = "Boss Enemy", Type = EnemyType.Boss });
     }
     // Add a sub-wave every 5 levels
     if (levelNumber > 5 && levelNumber % 5 == 0)
     {
                  Wave subWave = GenerateWave(levelNumber - 2);
                  wave.Contents.Add(subWave);
     }
     return wave;
}

为了使用和测试这个函数,Steve 可以实现一个辅助方法来打印波结构:

public void PrintWaveStructure(Wave wave, string indent = "")
{
     foreach (var content in wave.Contents)
     {
                  if (content is Enemy enemy)
                  {
                      Console.WriteLine($"{indent}{enemy.Type} Enemy");
                  }
                  else if (content is Wave subWave)
                  {
                      Console.WriteLine($"{indent}Sub-wave:");
                      PrintWaveStructure(subWave, indent + "  ");
                  }
     }
}
// Usage
int currentLevel = 15;
Wave generatedWave = GenerateWave(currentLevel);
Console.WriteLine($"Wave structure for level {currentLevel}:");
PrintWaveStructure(generatedWave);
int totalEnemies = CountAllEnemies(generatedWave);
Console.WriteLine($"Total enemies in the wave: {totalEnemies}");

这个解决方案展示了如何使用递归生成复杂的游戏结构。随着级别数字的增加,波变得更加具有挑战性,有更多的敌人、不同类型的敌人和嵌套的子波。函数的递归性质允许在游戏进展过程中轻松扩展并创建复杂的波模式。

练习 3

async Task UpdateAllEnemyStatsAsync(Wave wave)
{
     foreach (var content in wave.Contents)
     {
              switch (content)
              {
                  case Enemy enemy:
                      await UpdateStatsAsync(enemy);
                      break;
                  case Wave subWave:
                      await UpdateAllEnemyStatsAsync(subWave);
                      break;
              }
     }
}

这种 async 递归解决方案遍历波中的每个内容项,直接更新敌人的统计数据,并通过递归调用深入子波。使用 await 确保在每个波和子波内顺序处理更新,保持顺序并确保在继续之前完成完整性。

通过完成这些练习,你已经练习了将递归应用于解决不同问题。无论是计算嵌套结构中的项目数量,确定层次结构的深度,还是异步执行批量操作,递归都是你的软件开发工具包中的一个强大工具。

概述

在本章关于递归的整个过程中,我们探讨了递归如何让我们以干净和优雅的方式解决复杂问题。通过将任务分解成更小、更易于管理的部分,递归提供了一种直接的方法来解决自然具有层次结构或重复性的问题,例如将书籍组织成流派和子流派或处理书籍元数据。

我们首先理解了递归的本质,区分了基本情况与递归情况,并强调了始终要有清晰的基例以防止无限循环的重要性。然后,通过实际示例,我们展示了递归如何简化代码并提高可读性,尤其是在处理嵌套或分层数据结构时。

利用 C#的本地函数和模式匹配等特性,我们探讨了语言的能力如何增强我们的递归函数,使它们更易于阅读和维护。还介绍了高级递归模式,如相互递归和记忆化,展示了递归如何高效地扩展以处理更复杂的场景。

总之,本章旨在帮助你更深入地理解递归、其原理以及在现实世界场景中的实际应用,例如在图书出版系统中遇到的情况。随着你继续前进,你将学习到柯里化和部分应用以及它们在现实世界场景中的应用。

第九章:柯里化和部分应用

恭喜!你已经覆盖了这本书的超过 90%!你太棒了,我要给你一个虚拟的击掌!在本章中,我们将讨论柯里化和部分应用。我知道有一个特殊的关键字,partial,它允许我们将类、结构体或接口拆分成多个部分;然而,在函数式编程中,部分应用有着不同的含义。柯里化将具有多个参数的函数转换成一系列函数,每个函数只接受一个参数。这种转换允许参数的增量应用,其中每一步都会返回一个新的函数,等待下一个输入。另一方面,部分应用涉及将一些参数固定到函数中,从而产生一个参数更少的函数。这两种技术都在函数的参数在执行过程中不是同时可用的情况下非常有用,因此提供了在参数可用时应用这些参数的灵活性。

为了学习这些新技术,我们将通过以下部分进行学习:

  • 理解柯里化

  • 柯里化实现的步骤

  • 部分应用

  • 部分应用的应用领域

  • 挑战和限制

在继续之前,我不能不给你留下定期的自我检查任务来衡量你在阅读本章前后的知识水平。

任务 1 – 柯里化塔攻击函数

使用柯里化重构AttackEnemy函数,允许在游戏过程中预设towerType以供多次使用,同时接受动态输入的enemyIddamage

public void AttackEnemy(TowerTypes towerType, int enemyId, int damage)
{
   Console.WriteLine($"Tower {towerType} attacks enemy {enemyId} for {damage} damage.");
}

任务 2 – 游戏设置的柯里化部分应用

应用部分应用来创建一个函数,用于快速设置标准游戏设置,其中map是预定义的,但允许动态设置difficultyLevelisMultiplayer

public void SetGameSettings(string map, int difficultyLevel, bool isMultiplayer)
{
   Console.WriteLine($"Setting game on map {map} with difficulty {difficultyLevel}, multiplayer: {isMultiplayer}");
}

任务 3 – 为游戏功能进行柯里化权限检查

柯里化此函数,使其首先接受一个userRole,然后返回另一个函数,该函数接受一个feature,确定指定角色是否有权访问它:

public bool CheckGameFeatureAccess(UserRoles userRole, GameFeatures feature)
{
    return _gameFeatureManager.HasAccess(userRole, feature);
}

你可能认为这些方法已经是最优的,但它们的简单性是有意为之的。这些专注的任务让你在不受干扰的情况下练习函数式编程。像往常一样,这些任务只是供你自我评估,你并不需要轻易解决它们。所以,如果你对它们有任何困难,就继续阅读本章。到本章结束时,你将像专业人士一样应对这些挑战。记住这一点,希望你能有一个愉快的编码体验!

理解柯里化

柯里化是一种将具有多个参数的函数转换为接受单个参数的函数序列的技术。以数学家哈斯克尔·柯里命名,柯里化允许参数的部分应用,其中每个提供的参数都返回一个准备接受下一个输入的新函数。这种方法在想要在不同场景中重用函数的部分或在多步函数的步骤之间进行一些计算时很有帮助。

在深入概念之前,让我们回顾一下史蒂夫和朱莉娅的对话。

朱莉娅:嗨,史蒂夫,我看到你在函数式编程方面取得了很大的进步。今天,我们将探讨柯里化和部分应用这两种强大的技术,它们可以帮助你编写更可重用和 模块化的代码。

史蒂夫:嗨,朱莉娅!这听起来很有趣。我听说过这些概念,但从未真正理解如何在 C#中应用它们。你能为我解释一下吗?

朱莉娅:绝对是这样!让我们从柯里化开始。柯里化将具有多个参数的函数转换为一串函数,每个函数只接受一个参数。这种转换允许参数的增量应用,其中每一步都返回一个等待下一个输入的新函数。

史蒂夫:所以,它就像创建一个函数链,每个函数都接受 一个参数吗?

朱莉娅:没错!让我通过一个 YouTube 视频管理系统中的实际示例向你展示柯里化的应用。我们经常需要检查用户是否有执行不同操作(如查看、评论和 上传视频)的适当权限。

标准方法

通常,我们可能会使用这样的函数来检查权限:

public static bool CheckPermission(Actions action, UserRoles userRole)
{
     // Assume a method that checks a database or cache for permissions
     return _permissionsManager.HasPermission(action, userRole);
}

此函数有效,但每次检查同一角色的不同操作时都需要两个参数。

柯里化方法

通过柯里化此函数,我们创建了一个更适应的权限检查器,在会话期间处理同一用户角色的多个操作或在类似环境中非常有用:

public static Func<Actions, Func<UserRoles, bool>> CurryCheckPermission()
{
   return action => userRole =>
   {
      return _permissionsManager.HasPermission(action, userRole);
   };
}

按照以下方式使用柯里化方法:

var curriedPermissionChecker = CurryCheckPermission();
var checkViewerPermissions = curriedPermissionChecker(Actions.View);
var checkCommentPermissions = curriedPermissionChecker(Actions.Comment);
var checkUploadPermissions = curriedPermissionChecker(Actions.Upload);
bool canView = checkViewerPermissions(UserRoles.Admin);
bool canComment = checkCommentPermissions(UserRoles.Admin);
bool canUpload = checkUploadPermissions(UserRoles.Admin);

正如你在下面的示例中可以看到的,柯里化有几个优点:

可复用性:柯里化函数允许你预先定义操作并创建用于检查每个角色的特定函数。这在会话期间需要重复检查同一操作的多重角色时特别有益。

减少冗余代码:柯里化将操作与角色评估分离,在执行同一操作下的多个不同角色的多次检查时减少了重复代码。这提高了代码的可读性和可维护性。

部分应用便利性:当你知道所有后续检查都将针对该操作时,你可以部分应用该操作,这在会话或特定接口段专注于单一类型操作的场景中很常见。

柯里化的逐步实现

当朱莉娅解释完柯里化示例后,史蒂夫若有所思地点了点头。

Steve:我觉得我开始明白了。但我们如何在代码中实际实现柯里化呢?

Julia:这是一个很好的问题!让我们一步一步地分解它...

实现柯里化的过程实际上相当简单:

  1. 识别函数:

    选择一个接受多个参数的函数。如果您经常发现自己一次只使用一些参数,或者参数自然地按阶段分组,则该函数是柯里化的候选者。

  2. 定义柯里化函数:

    将多参数函数转换为一系列嵌套的单参数函数。每个函数返回另一个函数,该函数期望序列中的下一个参数。

  3. 使用 Func 委托实现:

    我们可以利用 Func 委托来实现柯里化函数。每个 Func 返回另一个 Func,直到所有参数都被考虑在内,最终返回最终值。

  4. 简化调用:

    尽管柯里化给函数调用增加了一层间接性,但它通过将其分解为可管理的步骤来简化过程,每个步骤都可以根据需要单独处理。

用例

虽然柯里化可能看起来有些繁琐,但它有许多有益的情况。让我们探讨一些常见的用例,看看柯里化如何应用于提高代码模块化和可重用性。

配置设置

当设置涉及多个参数的配置时,柯里化允许在整个应用程序中增量地指定这些设置。考虑一个例子,我们需要为每个用户组设置一个通知服务,限制每分钟发送的最大通知数:

public static Func<NotificationType, Func<int, Action<string>>> CurryNotificationConfig()
{
     return notificationType => maxNotificationsPerMinute => recipientEmail =>
     {
         Console.WriteLine($"Configuring {notificationType} notification for {recipientEmail} with max {maxNotificationsPerMinute} notifications per minute");
     };
}
// Usage
var configureNotifications = CurryNotificationConfig();
var configureEmailNotifications = configureNotifications(NotificationType.Email);
// Configure users to receive a maximum of 10 notifications per minute
var configureUserNotifications = configureEmailNotifications(10);
configureUserNotifications("alice@csharp-interview-preparation.com");
configureUserNotifications("bob@csharp-interview-preparation.com");
// Configure moderators to receive more notifications
var configureModeratorNotifications = configureEmailNotifications(50);
configureModeratorNotifications("moderator1@csharp-interview-preparation.com");
configureModeratorNotifications("moderator2@csharp-interview-preparation.com");
configureModeratorNotifications("moderator3@csharp-interview-preparation.com");
// Configure admins to receive even more notifications
var configureAdminNotifications = configureEmailNotifications(100);
configureAdminNotifications("admin1@csharp-interview-preparation.com");
configureAdminNotifications("admin2@csharp-interview-preparation.com");

在本例中,CurryNotificationConfig 函数接受通知类型、每分钟最大通知数和收件人电子邮件作为柯里化参数。每个用户组都有自己的设置,这使得为每个特定用户的配置更具可重用性。

事件处理

在事件驱动编程中,柯里化可以用于处理具有特定预填充参数的事件,简化事件处理程序逻辑。让我们考虑一个处理按钮点击事件的例子:

public static Func<string, Func<EventArgs, void>> CurryButtonClickHandler()
{
     return buttonName => eventArgs =>
     {
         Console.WriteLine($"Button {buttonName} clicked!");
         // Handle the button click event
     };
}
// Usage
var handleButtonClick = CurryButtonClickHandler();
var handleSaveClick = handleButtonClick("Save");
var handleCancelClick = handleButtonClick("Cancel");
// Attach event handlers
saveButton.Click += (sender, e) => handleSaveClick(e);
cancelButton.Click += (sender, e) => handleCancelClick(e);

通过对按钮点击处理程序进行柯里化,您可以创建针对每个按钮(handleSaveClickhandleCancelClick)的专用函数,并预先填充按钮名称。这简化了事件处理程序附加过程,并使代码更易于阅读和维护。

部分应用

柯里化促进了部分应用,其中可以通过在事先固定一些参数值来将具有许多参数的函数转换为具有较少参数的函数。我们将在下一节中详细讨论它,但现在让我们看看一个日志函数的例子:

public static Func<string, Func<string, void>> CurryLogMessage()
{
     return logLevel => message =>
     {
         Console.WriteLine($"{logLevel}: {message}");
         // Log the message with the specified log level
     };
}
// Usage
var logMessage = CurryLogMessage();
var logError = logMessage("ERROR");
var logWarning = logMessage("WARNING");
logError("An error occurred.");
logWarning("This is a warning message.");

在这种情况下,柯里化允许您部分应用 logLevel 参数,创建 logErrorlogWarning 函数以适应不同的日志级别。这减少了重复传递日志级别的需求,并使日志调用更加简洁和表达性强。

异步编程

柯里化可以通过将参数的准备与异步操作的执行分离来简化异步代码。以下是一个制作 HTTP 请求的示例:

public static Func<string, Func<Dictionary<string, string>, Func<CancellationToken, Task<string>>>> CurryHttpGetRequest()
{
     return url => headers => async cancellationToken =>
     {
         using (var client = new HttpClient())
         {
             foreach (var header in headers)
             {
                 client.DefaultRequestHeaders.Add(header.Key, header.Value);
             }
             return await client.GetStringAsync(url, cancellationToken);
         }
     };
}
// Usage
var getRequest = CurryHttpGetRequest();
var getWithUrl = getRequest("https://api.example.com/data");
var getWithHeaders = getWithUrl(new Dictionary<string, string>
{
     { "Authorization", "Bearer token123" },
     { "Content-Type", "application/json" }
});
string response = await getWithHeaders(CancellationToken.None);

通过柯里化 HTTP GET 请求函数,我们可以将 URL 的配置、头部信息和取消令牌分开。这允许我们编写更灵活和模块化的异步代码,其中每个请求方面都可以独立准备并在需要时组合。

通常,柯里化的目标是通过减少冗余并简化函数在应用程序不同部分的使用来简化代码。

部分应用

简而言之,部分应用涉及取一个接受多个参数的函数,提供其中一些参数,并返回一个只需要剩余参数的新函数。这与柯里化所做的非常相似。

柯里化将具有多个参数的函数转换为一串函数,每个函数只接受一个参数。这使得部分应用变得自然,因为柯里化函数返回的每个函数都可以被视为部分应用函数。

柯里化和部分应用之间的关键区别在于它们的实现和使用:

柯里化是关于转换函数结构本身,将多参数函数转换为一串单参数函数

另一方面,部分应用并不一定改变函数结构,但通过预先填充一些参数来减少它需要的参数数量

部分应用在涉及配置、重复性任务和预定义条件的情况下尤其有用。它简化了函数最终用户的接口,并通过在部分应用函数中封装常见参数,可以使代码库更容易维护。

史蒂夫挠了挠头,看起来有些困惑。

史蒂夫:朱莉娅,我有点难以区分柯里化和部分应用。它们看起来 非常相似

朱莉娅:史蒂夫,你 说得对,它们是相关的。关键区别在于它们的实现和使用方式。让我 进一步解释...

部分应用的应用领域

尽管部分应用在相当类似的场景中使用,但拥有多个参数的可能性使其更适用。以下是一些应用部分应用的好地方:

  • 配置管理:在配置在不同环境或应用程序的部分之间略有差异的系统,部分应用可以通过预先设置常见参数来简化配置管理。

  • 用户界面事件:在 GUI 编程中,事件处理器通常需要特定的参数,一旦设置就不会改变。部分应用允许开发者使用预定义的参数准备这些处理器,使代码更整洁且易于管理。

  • API 集成:在与外部 API 交互时,某些参数(如 API 密钥和用户令牌)在请求之间保持不变。将这些参数部分应用到 API 请求函数中可以简化函数调用,并通过隔离敏感数据来提高安全性。

  • 日志记录和监控:在应用程序中,日志记录通常涉及重复的信息,如日志级别和类别。部分应用可以创建更易于使用且减少错误发生可能性的专用日志记录函数。

  • 数据处理管道:在涉及数据转换和处理的场景中,函数通常需要特定的设置或操作以保持一致性。部分应用可以预先配置这些函数所需的设置,使管道更加模块化和可重用。

部分应用函数的配置设置示例

考虑到在内容管理系统中的场景,不同类型的内容可能需要特定的渲染设置。处理渲染的函数可能需要多个参数,但许多这些参数在内容类型之间是通用的。

标准渲染函数

假设我们有一个渲染内容的函数,它接受多个参数,并且只有在拥有所有这些参数的情况下才能使用:

public string RenderContent(string content, string format, int width, int height, string theme)
{
     // Render content based on the provided settings
     return $"Rendering {content} as {format} in {theme} theme with dimensions {width}x{height}";
}

部分应用函数

为了简化在整个应用程序中使用此功能,尤其是在大多数内容使用标准格式和主题的情况下,您可以部分应用这些常用参数:

public Func<string, int, int, string> RenderStandardContent()
{
     string defaultFormat = "HTML";
     string defaultTheme = "Light";
     return (content, width, height) => RenderContent(content, defaultFormat, width, height, defaultTheme);
}
var renderStandard = RenderStandardContent();
string renderedOutput = renderStandard("Hello, world!", 800, 600);
Console.WriteLine(renderedOutput);

这种方法使我们能够在不重复指定格式和主题的情况下使用renderStandard函数。你现在可能认为我们在这里可以使用默认参数,并消除所有这些部分应用。但如果我们不仅有“标准”的渲染方式,还有多种方式:桌面、手机和平板?这就是我们在“主要”的RenderContent函数中创建针对每种情况的具体函数,并在其中共享代码的时候。这看起来像是函数的某种继承。

在研究了柯里化和部分应用之后,史蒂夫不禁感到有些不知所措。他联系了朱莉娅,表达了他的担忧。

史蒂夫:朱莉娅,虽然我看到了这些技术的潜力,但我担心复杂性增加和潜在的性能问题。你是如何在你的项目中管理这些挑战的?

朱莉娅:史蒂夫,这些观点是有效的。确实,柯里化和部分应用有其挑战和局限性。让我们探讨一些这些挑战,并讨论克服它们的策略...

挑战和局限性

在研究了柯里化和部分应用之后,史蒂夫不禁感到有些不知所措。他联系了朱莉娅,表达了他的担忧。

史蒂夫:朱莉娅,虽然我看到了这些技术的潜力,但我担心复杂性增加和潜在的性能问题。你是如何在你的项目中管理这些挑战的?

朱莉娅:这些观点很有道理,史蒂夫。确实,柯里化和部分应用有其挑战和局限性。以下是一些例子:

  • 增加的复杂性:对于不熟悉函数式编程的开发者来说,柯里化和部分应用可能会使代码看起来更复杂,更难以理解,从而导致学习曲线更陡峭。

  • 性能开销:.NET 中的每个函数调用都涉及一定的开销,而柯里化通过将单个多参数函数转换为多个单参数函数来增加函数调用的数量。这可能会对性能产生潜在影响,尤其是在性能关键的应用中。

  • 调试难度:调试柯里化函数可能更具挑战性,因为数据流和执行流程分布在多个函数调用中,而不是集中在单个函数体内。

听起来有点吓人,但不必担心,因为我们可以使用以下策略来克服这些挑战:

  • 教育和培训:提供关于函数式编程概念的培训课程和资源,可以帮助团队成员理解和有效使用柯里化和部分应用。

  • 选择性使用:有选择地应用柯里化和部分应用,专注于它们能提供明确好处的地方,例如在配置管理和 API 交互中,而不是普遍应用。

  • 性能监控:始终监控柯里化在特定环境中的性能影响。使用分析工具来识别任何瓶颈,并在必要时重构代码以优化性能。

  • 增强的调试技术:利用高级调试工具和技术,如条件断点和调用栈分析,以更好地管理柯里化函数的调试。

  • 集成策略:在面向对象框架内工作时,逐步集成函数式编程技术,并确保它们补充而不是复杂化架构。

虽然柯里化和部分应用可能会引入一些复杂性和挑战,但有了正确的策略和工具,这些挑战可以有效地得到管理。

当他们讨论完柯里化和部分应用后,史蒂夫看起来很深思。

史蒂夫:这真的很启发人心,朱莉娅。我可以看出这些技术如何使我们的塔防游戏代码更加灵活 和易于维护。

朱莉娅:没错,史蒂夫!记住,就像编程中的任何工具一样,关键在于知道何时以及如何有效地应用这些 概念。

史蒂夫:谢谢,朱莉娅。我期待在下次 编码会议 中尝试这些!

朱莉娅微笑着,对史蒂夫在函数式编程概念上的热情和进步感到满意。

练习

在本章中,我们探讨了柯里化和部分应用的功能编程概念。现在,让我们将这些技术应用到移动塔防游戏的实际场景中。

练习 1

使用柯里化重构 AttackEnemy 函数,允许在游戏过程中预设 towerType 以供多次使用,同时接受 enemyIddamage 的动态输入:

public void AttackEnemy(TowerTypes towerType, int enemyId, int damage)
{
   Console.WriteLine($"Tower {towerType} attacks enemy {enemyId} for {damage} damage.");
}

练习 2

应用部分应用创建一个用于快速设置标准游戏设置的函数,其中 map 是预定义的,但允许动态设置 difficultyLevelisMultiplayer

public void SetGameSettings(string map, int difficultyLevel, bool isMultiplayer)
{
   Console.WriteLine($"Setting game on map {map} with difficulty {difficultyLevel}, multiplayer: {isMultiplayer}");
}

练习 3

将此函数柯里化,使其首先接受一个 userRole,然后返回另一个函数,该函数接受一个 feature,以确定指定角色是否有权访问它:

public bool CheckGameFeatureAccess(UserRoles userRole, GameFeatures feature)
{
    return _gameFeatureManager.HasAccess(userRole, feature);
}

这些练习旨在帮助你在移动塔防游戏的背景下应用柯里化和部分应用。通过练习这些技术,你将增强游戏代码库的功能性和可扩展性,使其更容易管理和扩展。记住,你练习这些函数式编程技术越多,你在构建复杂游戏逻辑方面的熟练程度就会越高。

解决方案

这里是关于移动塔防游戏中柯里化和部分应用练习的解决方案。这些解决方案展示了如何实现所讨论的概念,并提供了在游戏开发中实际使用这些概念的示例。

解决方案 1

要使用柯里化重构 AttackEnemy 函数,我们首先定义原始函数,然后将其转换:

public Func<int, int, void> CurriedAttack(TowerTypes towerType)
{
   return (enemyId, damage) =>
   {
      Console.WriteLine($"Tower {towerType} attacks enemy {enemyId} for {damage} damage.");
   };
}
var attackWithCannon = CurriedAttack(TowerTypes.Cannon);
attackWithCannon(1, 50); // Attack enemy 1 with 50 damage
attackWithCannon(2, 75); // Attack enemy 2 with 75 damage

此柯里化函数允许一次设置 towerType 并在多次攻击中重复使用,从而提高代码的可重用性并减少冗余。

解决方案 2

使用部分应用简化游戏设置配置:

public Func<int, bool, void> ConfigureWithMap(Maps map)
{
   return (difficultyLevel, isMultiplayer) =>
   {
      Console.WriteLine($"Setting game on map {map} with difficulty {difficultyLevel}, multiplayer: {isMultiplayer}");
   };
}
var configureForMapDesert = ConfigureWithMap(Maps.Desert);
// Configure for Desert map with difficulty 5 and multiplayer enabled
configureForMapDesert(5, true);
// Configure for Desert map with difficulty 3 and multiplayer disabled
configureForMapDesert(3, false);

此函数部分应用了映射设置,允许动态配置其他设置,例如 difficultyLevelisMultiplayer

解决方案 3

实现一个基于用户角色和动作的权限管理的柯里化函数:

public Func<GameFeatures, bool> CurriedCheckPermission(UserRoles userRole)
{
   return (feature) =>
   {
      return _gameFeatureManager.HasAccess(userRole, feature);
   };
}
var checkAdminPermissions = CurriedCheckPermission(UserRoles.Admin);
bool canEdit = checkAdminPermissions(GameFeatures.EditLevel);
bool canPlay = checkAdminPermissions(GameFeatures.PlayGame);
Console.WriteLine($"Admin permissions - Edit: {canEdit}, Play: {canPlay}");

此柯里化函数通过一次设置 userRole 并允许对各种功能进行动态检查,简化了权限检查过程,从而简化了权限管理流程。

这些解决方案展示了柯里化和部分应用在移动塔防游戏中的实际应用,提高了代码的结构和可维护性。通过实施这些技术,开发者可以增强游戏逻辑的灵活性和可读性。

摘要

在本章中,我们探讨了柯里化和部分应用。柯里化将多参数函数转换为一系列单参数函数,每个函数接受一个参数并返回另一个函数,该函数准备好接受下一个参数。这种技术特别有利于创建可配置和高度模块化的代码。

部分应用涉及固定函数的一些参数,并创建一个需要较少参数的新函数。这种方法在特定参数反复使用相同值的情况下非常有价值,因为它简化了函数调用并减少了冗余。

如果某些内容仍然不清楚,请使用本章提供的示例——修改它们,实验它们,并将它们整合到你的项目中。换句话说,看看它在实际中的应用。无论是简化配置管理、使事件处理更加直接,还是减少 API 交互的复杂性,柯里化和部分应用都能帮助你减少函数调用的复杂性,增强代码的模块化和可读性,并且总体上,将你的程序在函数式编程方面提升到新的水平。

在下一章中,我们将总结前几章中关于管道和组合所学的所有内容,增加更多现实世界的场景,并讨论更多高级主题,如错误处理、测试和性能考虑。

第十章:管道和组合

在本章中,我们将结合前几章的所有知识,首先讨论函数组合,它允许我们将简单的函数组合成更复杂的操作。然后,我们将看到如何使用 Pipe 方法构建管道。我们还将回顾如何创建可以优雅处理错误的单子管道。此外,还将介绍流畅接口技术,它有助于编写几乎可以像普通文本一样阅读的代码。

总结来说,本章带我们了解了以下主题:

  • 函数组合

  • 构建管道

  • 流畅的接口

  • 使用单子的高级组合

我不能背叛我们的传统,最后一次,为你准备了三个自检任务。

任务 1 – 敌军波处理管道

将一系列函数组合成一个管道,处理敌军波列表,应用增加难度(困难模式),验证结果,并使用以下代码将其转换为格式化的字符串:

public class EnemyWave
{
   public int WaveNumber { get; set; }
   public int EnemyCount { get; set; }
   public string Description { get; set; }
}
Func<EnemyWave, bool> validateWave = wave => wave.EnemyCount > 0;
Func<EnemyWave, EnemyWave> applyHardMode = wave =>
{
   wave.EnemyCount = (int)(wave.EnemyCount * 1.2); // +20% enemies
   return wave;
};
Func<EnemyWave, string> formatWave = wave => $"Wave {wave.WaveNumber}: {wave.Description} - {wave.EnemyCount} enemies";

任务 2 – 游戏数据文件处理

使用以下代码,将一系列单子函数组合成一个管道,处理游戏数据文件,读取其内容,处理它,并将结果写入另一个文件:

Func<string, Result<string>> readGameDataFile = path =>
{
   try
   {
       var content = File.ReadAllText(path);
       return Result<string>.Success(content);
   }
   catch (Exception ex)
   {
       return Result<string>.Failure($"Failed to read file: {ex.Message}");
   }
};
Func<string, Result<string>> processGameData = content =>
{
   // Simulate game data processing
   return Result<string>.Success(content.ToUpper());
};
Func<string, Result<bool>> writeGameDataFile = content =>
{
   try
   {
       File.WriteAllText("processed_game_data.txt", content);
       return Result<bool>.Success(true);
   }
   catch (Exception ex)
   {
       return Result<bool>.Failure($"Failed to write file: {ex.Message}");
   }
};

任务 3 – 使用柯里化和部分应用生成动态 SQL 查询

使用柯里化构建用于塔防游戏数据动态查询生成的函数,以及用于查询敌军类型和级别的部分应用函数。使用以下函数生成查询脚本:

Func<string, string, string, string> generateSqlQuery = (table, column, value) =>
     $"SELECT * FROM {table} WHERE {column} = '{value}'";

这些任务应该已经熟悉,因为它们在前几章中已经讨论过。然而,你可能仍然觉得你在组合、柯里化或部分应用方面的掌握还有提升空间。如果是这样,你非常欢迎继续阅读本章。

函数组合

史蒂夫带着兴奋和紧张的情绪看着朱莉娅。

史蒂夫:所以,我们终于要把这些碎片拼在一起了?我很兴奋,但有点 不知所措。

朱莉娅:别担心,史蒂夫。我们会一步步来。记住,这些概念是相互关联的。你已经学到了 很多!

史蒂夫:你说得对。我准备好了,从哪里 开始呢?

朱莉娅:让我们从函数组合开始。这是结合我们到目前为止所学内容的绝佳方式 ...

函数组合是将两个或更多函数组合起来产生一个新的函数的过程。让我们再加一点料,给我们的组合示例添加高阶函数。

考虑一个场景,我们需要转换用户数据列表。我们有以下高阶函数:

map:将函数应用于列表中的每个元素

filter:根据谓词过滤列表中的元素

我们将按以下方式定义这些函数:

Func<IEnumerable<string>, Func<string, string>, IEnumerable<string>> map = (list, func) => list.Select(func);
Func<IEnumerable<string>, Func<string, bool>, IEnumerable<string>> filter = (list, predicate) => list.Where(predicate);

接下来,让我们定义我们的转换和过滤函数:

Func<string, string> capitalize = input => char.ToUpper(input[0]) + input.Substring(1);
Func<string, bool> startsWithA = input => input.StartsWith("a");

我们现在可以组合 mapfilter 来创建一个一次性执行所有这些操作的功能:

Func<IEnumerable<string>, IEnumerable<string>> processUsers = users => map(filter(users, startsWithA), capitalize);

因此,我们让processUsers函数首先过滤列表,只包含以“a”开头的字符串,然后对剩余的每个字符串进行大写转换。当然,我们也可以只用一个processUsers方法来编写所有代码,但当前的解决方案允许我们在不同的地方重用小函数。这里的想法是将大方法替换为较小方法的组合。额外的优势是,小方法具有更低的认知负荷和循环复杂度,这使得它们更容易阅读和维护。

当朱莉亚解释完函数组合后,史蒂夫深思地点了点头。

史蒂夫:我想我开始明白这一切是如何结合在一起的。但我们如何在更大的应用中使用它呢?

朱莉亚:这是个好问题!这正是管道发挥作用的地方。它们允许我们以更结构化的方式链式调用这些组合函数。让我给你展示一下...

构建管道

在构建管道之前,让我们简要回顾一下前一章中的两个关键概念:柯里化和部分应用。这些技术对于创建灵活、可重用的函数组件至关重要,这些组件是优秀的管道构建块。

正如我们所学的,柯里化将接受多个参数的函数转换为一串函数,每个函数接受单个参数。以下是一个示例:

Func<int, int, int> add = (a, b) => a + b;
Func<int, Func<int, int>> curriedAdd = a => b => a + b;

相反,部分应用涉及将一些参数固定到函数中,产生一个具有较少参数的另一个函数:

Func<int, int, int> multiply = (a, b) => a * b;
Func<int, int> triple = x => multiply(3, x);

这些概念自然地引出了管道构建。通过柯里化函数或部分应用它们,我们创建了专门的、单用途的函数,这些函数可以轻松地组合成管道。这种方法使我们能够做到以下几点:

  • 将复杂操作分解为更简单、更易于管理的片段

  • 在不同的管道或上下文中重用这些片段

  • 通过链式调用这些专用函数来创建更具有表达性和可读性的代码

例如,考虑一个处理游戏数据的管道:

var processGameData =
     LoadData()
     .Then(ValidateData)
     .Then(TransformData)
     .Then(SaveData);

在这个管道中的每一步都可以是一个柯里化或部分应用的函数,这允许我们轻松地进行定制和重用。当我们进一步探索管道构建时,请记住柯里化和部分应用如何被利用来创建更灵活和强大的管道。

现在,让我们继续构建管道。

管道通过一系列处理步骤处理数据,每个步骤由一个函数表示。这种方法对于需要多个转换、验证或计算的任务特别有用。你很可能在使用 LINQ 操作集合时已经遇到了管道。

让我们考虑一个现实世界的场景:用于发布手稿的提取、转换、加载ETL)过程。这个过程涉及几个步骤:

  1. 从数据库中提取(查询)手稿

  2. 验证其内容

  3. 将其转换为所需的格式

  4. 提交(提交)以供发布

每个步骤都可以表示为一个函数,我们可以使用管道来简化这个过程。为此,让我们创建一个方法,将一系列函数应用于初始值,将每个函数的结果传递给下一个函数,并将其命名为Pipe

public static T Pipe<T>(this T source, params Func<T, T>[] funcs)
{
     return funcs.Aggregate(source, (current, func) => func(current));
}

让我们考虑书籍手稿处理:从数据库中查询手稿,验证其内容,将其转换为所需的格式,并最终提交以供出版:

public class Manuscript
{
     public string Content { get; set; }
     public bool IsValid { get; set; }
     public string FormattedContent { get; set; }
}
public Manuscript Query(Manuscript manuscript)
{
     // Simulate querying the manuscript from a database
     manuscript.Content = "Original manuscript content.";
     return manuscript;
}
public Manuscript Validate(Manuscript manuscript)
{
     // Simulate validating the manuscript
     manuscript.IsValid = !string.IsNullOrWhiteSpace(manuscript.Content);
     return manuscript;
}
public Manuscript Transform(Manuscript manuscript)
{
     // Simulate transforming the manuscript content
     if (manuscript.IsValid)
     {
         manuscript.FormattedContent = manuscript.Content.ToUpper();
     }
     return manuscript;
}
public Manuscript Submit(Manuscript manuscript)
{
     // Simulate submitting the manuscript for publication
     if (manuscript.IsValid)
     {
         Console.WriteLine($"Manuscript submitted: {manuscript.FormattedContent}");
     }
     else
     {
         Console.WriteLine("Manuscript validation failed. Submission aborted.");
     }
     return manuscript;
}

下面是如何在不使用Pipe方法的情况下执行此流程:

public void ExecutePublishingFlow(Manuscript manuscript)
{
     manuscript = Submit(
         Transform(
             Validate(
                 Query(
                     manuscript))));
}

现在,使用Pipe方法,我们的代码变得好 10 倍:

public void ExecutePublishingFlow(Manuscript manuscript)
{
     manuscript
         .Pipe(Query)
         .Pipe(Validate)
         .Pipe(Transform)
         .Pipe(Submit);
}

当然,这增加了一点点开销,程序可能运行得明显更慢,但阅读起来容易得多,也快得多!

性能考虑

谈到开销,虽然诸如组合和管道之类的函数式编程技术提供了更好的可读性和可维护性,但了解它们的性能影响很重要。当我们组合函数时,编译器生成一系列嵌套的方法调用。这可能导致多个栈帧分配,影响深层嵌套组合的性能。

为了更好地理解差异,让我们基准测试不同的方法:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
[MemoryDiagnoser]
public class FunctionalPerformance
{
    private IEnumerable<int> _numbers;
    [GlobalSetup]
    public void Setup()
    {
         _numbers = Enumerable.Range(0, 1_000_000_000);
    }
    [Benchmark]
    public List<int> ImperativeApproach()
    {
         var result = new List<int>();
         foreach (var num in _numbers)
         {
              if ((num * 3) % 4 == 0)
                   result.Add(num * 3);
         }
         return result;
    }
    [Benchmark]
    public List<int> FunctionalApproach()
    {
         return _numbers
              .Select(x => x * 3)
              .Where(x => x % 4 == 0)
              .ToList();
    }
    [Benchmark]
    public List<int> FunctionalWithPipeline()
    {
         Func<int, int> triple = x => x * 3;
         Func<int, bool> isMultipleOfFour = x => x % 4 == 0;
         return _numbers
              .Pipe(list => list.Select(triple))
              .Pipe(list => list.Where(isMultipleOfFour))
              .ToList();
    }
}

运行这些基准测试可能会得到以下类似的结果:

如我们所见,命令式方法的速度几乎是函数式方法的近两倍。然而,通常的 LINQ 管道和我们的管道性能几乎相同!虽然命令式方法表现出更好的性能,但重要的是要注意,函数式方法通常在代码可读性、可维护性和可组合性方面提供优势。

流畅接口

流畅接口是一种 API 模式,允许我们以可读和直观的方式链式调用方法。这个术语在 2005 年变得广为人知,但有些人仍然认为它只是方法链。然而,主要思想是使代码看起来像领域特定语言DSL)。让我们通过引入流畅接口技术来重构先前的示例。

首先,让我们定义一个类来封装处理手稿的管道步骤:

public class ManuscriptProcessor
{
     private Manuscript _manuscript;
     public ManuscriptProcessor(Manuscript manuscript)
     {
         _manuscript = manuscript;
     }
     public ManuscriptProcessor Query(Func<Manuscript, Manuscript> queryFunc)
     {
         // Simulate querying the manuscript from a database
         manuscript.Content = "Original manuscript content.";
         return this;
     }
     public ManuscriptProcessor Validate(Func<Manuscript, Manuscript> validateFunc)
     {
         _manuscript = validateFunc(_manuscript);
         return this;
     }
     public ManuscriptProcessor Transform(Func<Manuscript, Manuscript> transformFunc)
     {
         _manuscript = transformFunc(_manuscript);
         return this;
     }
     public Manuscript Submit()
     {
         // Simulate submitting the manuscript for publication
         if (manuscript.IsValid)
         {
            Console.WriteLine($"Manuscript submitted: {manuscript.FormattedContent}");
         }
         else
         {
            Console.WriteLine("Manuscript validation failed. Submission aborted.");
         }
         return _manuscript;
     }
}

使用流畅接口,我们可以更清晰地重写管道:

var manuscript = new Manuscript();
var processedManuscript = new ManuscriptProcessor(manuscript)
     .Query()
     .Validate(Validate)
     .Transform(Transform)
     .Submit();

如您所见,它几乎与使用Pipe方法的示例相同,但QuerySubmit方法不同。这是因为它们不依赖于可能改变逻辑的外部验证或转换规则,而是相当直接。当有足够的逻辑且未来可能不会改变时,使用流畅接口是个好主意。但如果没有,我们可以使用Pipe方法。

单子的高级组合

史蒂夫挠了挠头,看起来有些困惑。

史蒂夫:朱莉娅,我以为我们已经完成了关于单子的讨论。为什么我们要 重新讨论它们?

Julia: Steve,你的观察很敏锐。我们再次回到单子的概念,因为它们在组合复杂操作方面非常强大,尤其是在处理错误处理和异步过程时。让我向你展示它们如何融入我们的管道...

单子提供了一种链式操作机制。在之前的章节中,你学习了单子的基本概念和 Bind 方法。我们将使用 Bind 方法在更复杂的环境中链式操作,例如错误处理和异步处理。

在我们的第一个场景中,我们需要从外部 API 获取和处理用户数据。过程中的每一步都可能失败,我们需要优雅地处理这些错误。

首先,让我们回顾一下我们的 Result 单子定义:

public class Result<TValue, TError>
{
    private TValue _value;
    private TError _error;
    public bool IsSuccess { get; private set; }
    private Result(TValue value, TError error, bool isSuccess)
    {
         _value = value;
         _error = error;
         IsSuccess = isSuccess;
    }
    public TValue Value
    {
         get
         {
              if (!IsSuccess) throw new InvalidOperationException("Cannot fetch Value from a failed result.");
              return _value;
         }
    }
    public TError Error
    {
         get
         {
              if (IsSuccess) throw new InvalidOperationException("Cannot fetch Error from a successful result.");
              return _error;
         }
    }
    public static Result<TValue, TError> Success(TValue value) => new Result<TValue, TError>(value, default, true);
    public static Result<TValue, TError> Failure(TError error) => new Result<TValue, TError>(default, error, false);
    public Result<TResult, TError> Bind<TResult>(Func<TValue, Result<TResult, TError>> func)
    {
         return IsSuccess ? func(_value!) : Result<TResult, TError>.Failure(_error!);
    }
}

接下来,将我们之前的示例重写为使用 Result 类型:

public class Manuscript
{
     public string Content { get; set; }
     public bool IsValid { get; set; }
     public string FormattedContent { get; set; }
}
Func<int, Result<Manuscript, string>> queryManuscript = manuscriptId =>
{
     // Simulate querying the manuscript from a database
     if (manuscriptId > 0)
     {
         var manuscript = new Manuscript { Content = "Original manuscript content." };
         return Result<Manuscript, string>.Success(manuscript);
     }
     else
     {
         return Result<Manuscript, string>.Failure("Invalid manuscript ID");
     }
};
Func<Manuscript, Result<Manuscript, string>> validateManuscript = manuscript =>
{
     // Simulate validating the manuscript
     if (!string.IsNullOrWhiteSpace(manuscript.Content))
     {
         manuscript.IsValid = true;
         return Result<Manuscript, string>.Success(manuscript);
     }
     else
     {
         return Result<Manuscript, string>.Failure("Empty manuscript content");
     }
};
Func<Manuscript, Result<Manuscript, string>> transformManuscript = manuscript =>
{
     // Simulate transforming the manuscript content
     if (manuscript.IsValid)
     {
         manuscript.FormattedContent = manuscript.Content.ToUpper();
         return Result<Manuscript, string>.Success(manuscript);
     }
     else
     {
         return Result<Manuscript, string>.Failure("Invalid manuscript for transformation");
     }
};
Func<Manuscript, Result<bool, string>> submitManuscript = manuscript =>
{
     // Simulate submitting the manuscript for publication
     if (manuscript.IsValid)
     {
         Console.WriteLine($"Manuscript submitted: {manuscript.FormattedContent}");
         return Result<bool, string>.Success(true);
     }
     else
     {
         return Result<bool, string>.Failure("Manuscript validation failed. Submission aborted.");
     }
};

我们现在可以使用 Bind 来创建一个单调管道来组合这些函数:

Func<int, Result<bool, string>> processManuscript = manuscriptId =>
     queryManuscript(manuscriptId)
     .Bind(validateManuscript)
     .Bind(transformManuscript)
     .Bind(submitManuscript);

让我们使用管道来处理用户数据:

var result = processManuscript(129);
if (result.IsSuccess)
{
     Console.WriteLine("Manuscript processed and submitted successfully.");
}
else
{
     Console.WriteLine($"Error: {result.Error}");
}

预期的控制台输出如下:

Manuscript submitted: ORIGINAL MANUSCRIPT CONTENT.
Manuscript processed and submitted successfully.

再次,processManuscript 方法看起来与之前的方法非常相似;然而,这次它包括了优雅的错误处理。

现在让我们看看如何结合柯里化、部分应用和单调操作来创建一个健壮的错误处理管道:

// Curried function for Result monad
Func<Func<T, Result<U>>, Func<Result<T>, Result<U>>> curriedBind<T, U>() =>
     f => result => result.Bind(f);
// Partially applied functions for specific operations
var parseInput = curriedBind<string, int>()
     (s => int.TryParse(s, out int n) ? Result<int>.Success(n) : Result<int>.Failure("Parse failed"));
var validatePositive = curriedBind<int, int>()
     (n => n > 0 ? Result<int>.Success(n) : Result<int>.Failure("Number must be positive"));
var double = curriedBind<int, int>()
     (n => Result<int>.Success(n * 2));
// Composing a pipeline with monadic operations
Func<string, Result<int>> processInput =
     input => Result<string>.Success(input)
         .Pipe(parseInput)
         .Pipe(validatePositive)
         .Pipe(double);
var result = processInput("5");  // Success: 10
var error = processInput("-3");  // Failure: "Number must be positive"

这个例子展示了结合柯里化、部分应用和单调组合的力量。我们创建了一个管道,它解析输入,验证它,并执行转换,同时处理每一步中可能出现的错误。使用柯里化和部分应用函数使我们的管道既灵活又易于扩展。

当他们结束讨论时,Steve 看起来既疲惫又成就感十足。

Steve: 哇,Julia。这真是一段旅程。我从没想到当我们开始的时候,我会理解这些概念

Julia: Steve,你已经走了很长的路。你现在对函数式编程有什么看法

Steve: 我很兴奋开始在我们项目中应用这些概念。代码变得更加清晰和结构化真是太神奇了

Julia 微笑着,为 Steve 的进步感到自豪。

Julia: 很高兴听到这个,Steve。记住,熟能生巧。继续实验,不要害怕提问。准备好做一些练习来巩固我们学到的知识了吗

Steve: 当然!来吧

练习

练习 1

将一系列函数组合成一个管道,处理一系列敌军波次,应用增加难度(困难模式),验证结果,并使用以下代码将其转换为格式化的字符串:

public class EnemyWave
{
   public int WaveNumber { get; set; }
   public int EnemyCount { get; set; }
   public string Description { get; set; }
}
Func<EnemyWave, bool> validateWave = wave => wave.EnemyCount > 0;
Func<EnemyWave, EnemyWave> applyHardMode = wave =>
{
   wave.EnemyCount = (int)(wave.EnemyCount * 1.2); // +20% enemies
   return wave;
};
Func<EnemyWave, string> formatWave = wave => $"Wave {wave.WaveNumber}: {wave.Description} - {wave.EnemyCount} enemies";

练习 2

使用以下代码,将一系列单调函数组合成一个管道,处理游戏数据文件,读取其内容,处理它,并将结果写入另一个文件:

Func<string, Result<string>> readGameDataFile = path =>
{
   try
   {
       var content = File.ReadAllText(path);
       return Result<string>.Success(content);
   }
   catch (Exception ex)
   {
       return Result<string>.Failure($"Failed to read file: {ex.Message}");
   }
};
Func<string, Result<string>> processGameData = content =>
{
   // Simulate game data processing
   return Result<string>.Success(content.ToUpper());
};
Func<string, Result<bool>> writeGameDataFile = content =>
{
   try
   {
       File.WriteAllText("processed_game_data.txt", content);
       return Result<bool>.Success(true);
   }
   catch (Exception ex)
   {
       return Result<bool>.Failure($"Failed to write file: {ex.Message}");
   }
};

练习 3

使用柯里化构建一个用于动态查询生成塔防游戏数据的函数,并使用部分应用函数查询敌军类型和等级。使用以下函数生成查询脚本:

Func<string, string, string, string> generateSqlQuery = (table, column, value) =>
     $"SELECT * FROM {table} WHERE {column} = '{value}'";

解答

这里是上一节提供的练习题的解决方案。使用它们来确保你的理解,并纠正你可能犯的错误。

解决方案 1

首先,我们将这些函数组合起来创建一个事务处理管道:

Func<IEnumerable<EnemyWave>, IEnumerable<string>> processEnemyWaves = waves =>
     waves
         .Where(validateWave)
         .Select(applyHardMode)
         .Select(formatWave);

然后,我们对其进行测试:

var enemyWaves = new List<EnemyWave>
{
     new EnemyWave { WaveNumber = 1, EnemyCount = 50, Description = "Initial wave" },
     new EnemyWave { WaveNumber = 2, EnemyCount = 0, Description = "Empty wave" },
     new EnemyWave { WaveNumber = 3, EnemyCount = 100, Description = "Boss wave" }
};
var results = processEnemyWaves(enemyWaves);
foreach (var result in results)
{
     Console.WriteLine(result);
}

这是预期的结果:

Wave 1: Initial wave - 60 enemies
Wave 3: Boss wave - 120 enemies

解决方案 2

我们首先使用给定的函数创建单调管道:

Func<string, Result<bool>> processGameDataFile = path =>
     readGameDataFile(path)
         .Bind(processGameData)
         .Bind(writeGameDataFile);

让我们测试这个管道:

var result = processGameDataFile("game.dat");
if (result.IsSuccess)
{
     Console.WriteLine("The data file was processed successfully.");
}
else
{
     Console.WriteLine($"Error: {result.Error}");
}

这是预期的结果:

The data file was processed successfully.

解决方案 3

在这个解决方案中,我们首先创建函数的柯里化版本:

Func<string, Func<string, Func<string, string>>> curryGenerateSqlQuery = table => column => value => generateSqlQuery(table, column, value);

然后,我们用它来生成查询:

Func<string, string> typeQuery = value => generateQuery("Enemies", "Type", value);
Func<string, string> levelQuery = value => generateQuery("Enemies", "Level", value);

我们编写代码来使用它们:

Console.WriteLine(typeQuery("Goblin"));
Console.WriteLine(levelQuery("5"));

这是预期的结果:

SELECT * FROM Enemies WHERE Type = 'Goblin'
SELECT * FROM Enemies WHERE Level = '5'

完成这些练习后,你应该对如何在代码中使用管道、柯里化和部分应用有更好的理解。

摘要

在本章中,我们将前几章的知识整合起来,重新审视管道和组合。我们从函数组合开始,展示了如何使用高阶函数映射和过滤集合将简单函数组合成复杂操作。

然后,我们介绍了Pipe方法,它简化了管道的函数链。当应用于我们的图书出版系统示例时,它为查询、验证、转换和提交稿件提供了清晰的步骤。

然后,我们考察了流畅接口模式,它允许相当直观的方法链。ManuscriptProcessor类展示了流畅接口如何使我们的代码更具表达性和用户友好。我们还介绍了使用Result类型进行优雅错误处理的单子高级组合。

下一章将是我们旅程的最后一章,我真心希望你喜欢这个过程。所以,如果你还没有完成,请完成练习,我们下一章再见!

第四部分:结论与未来方向

在最后一部分,我们将反思我们通过 C#函数式编程所走过的旅程。我们将总结学习到的关键概念,加强你对如何将这些技术应用于编写更干净、更易于维护的代码的理解。我们还将展望你在函数式编程旅程中的下一步,提供如何进一步提高技能并跟上该领域不断发展的最佳实践的指导。

本部分包含以下章节:

  • 第十一章**,反思与展望

第十一章:反思与展望

恭喜你完成了对 C#函数式编程世界的探索之旅。在这本书中,我们探讨了函数式编程的关键概念和技术。让我们回顾我们所学的,并看看作为函数式程序员,你的下一步是什么。

主要概念和技术的总结

我们从基础知识开始——表达式、语句,以及理解什么使函数“纯净”。我们学习了如何编写没有副作用、清晰易读的代码。

接下来,我们探讨了以函数式方式处理错误。我们看到了如何使用Result等类型以及如面向铁路编程等方法,帮助我们编写强大、抗错误的代码,而不使用异常。

然后,我们转向了高阶函数和委托。这些工具让我们将函数视为一等公民,这有助于我们编写更抽象和可重用的代码。

我们还涵盖了函子和单子,将这些复杂的概念分解,以展示它们如何帮助我们管理程序中的复杂性。

递归和尾调用是另一个重要的话题。我们学习了如何以递归的方式思考问题,以及如何优化这些递归函数。

柯里化和部分应用教会了我们如何从通用函数中创建更专业的函数,从而提高了重用性和组合性。

最后,我们通过管道和函数组合将一切整合起来,学习了如何链式连接函数,以创建清晰、简洁且易于维护的代码。

在这次旅程中,我们看到了每个概念是如何建立在其他概念之上的。函数式编程不仅仅是一套技术,而是一种强调清晰性、安全性和模块化的思维方式。

与其他语言的比较

虽然 C#在支持函数式编程方面取得了巨大进步,但将其与专门为此风格设计的语言进行比较是有帮助的。

F#是.NET 家族的一部分,它通过不可变数据结构、模式匹配和计算表达式等特性,提供了一个更自然的函数式体验。

Haskell 是一种纯函数式语言,它将一切视为表达式,并默认确保函数是纯净的。它通过单子严格管理副作用,这可能会使其学习起来更困难,但它提供了关于代码行为的强大保证。

Scala,就像 C#一样,融合了面向对象和函数式编程。它有一个更先进的类型系统,允许编写更抽象和通用的代码,尽管这可能会增加复杂性。

C#因其实用性而脱颖而出。它允许你根据需要混合函数式和面向对象风格,利用两种方法的优势。虽然它可能不如 Haskell 纯粹,也不如 Scala 在类型系统上先进,但它提供了一个在熟悉环境中的函数式编程温和的入门途径。

通常,开发者的函数式编程之路始于 C#,过渡到 F#,并以纯函数式 Haskell 结束。完成这本书后,您可能会认为函数式编程的第一步已经完成,还有许多新事物等待学习。

进一步学习的资源

您的学习之旅并未结束。总有更多东西等待您去发现和掌握。以下是一些帮助您继续学习的资源。

这里有一些有用的书籍:

  • 《C#编程学习指南:构建 C#语言坚实基础以编写高效程序》,作者:Marius Bancila, Raffaele Rialdi, 和 Ankit Shamra

  • 《使用 C#和函数式编程揭秘:改变你编写应用程序的方式》,作者:Wisnu Anggoro

这些是一些有用的网站和博客:

最后,请查看以下在线课程:

收尾思考

函数式编程改变了我们思考和使用代码的方式。通过使用诸如纯函数、不可变性和高阶函数等概念,我们可以创建更可靠、更易于表达和扩展的软件。感谢您与我们一同踏上这段旅程,愿函数式编程与您同在!

posted @ 2025-10-22 10:35  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报