1.函数式编程介绍

如果您之前已经学习过很多编程知识 - 无论是 C#、Visual BASIC、Python 还是其他语言 - 那么您学到的知识很可能是基于目前最主流的编程范式 - 面向对象。

面向对象编码已经存在了很长时间。确切的日期尚有争议,但它很可能是在 50 年代末和 60 年代初发明的。

面向对象编码基于将属性和功能集包装到称为 的逻辑代码块中的想法,这些代码块用作一种模板,我们可以从中实例化 对象。它还有很多内容:继承、多态性、虚拟和抽象函数。诸如此类的东西。

然而,这不是一本面向对象的书。事实上,如果您已经熟悉 OO,那么如果您将已经了解的内容放在一边,您可能会从这本书中获得更多。

我将在本书中描述一种可以替代 OO 的编程风格 - 函数式编程。尽管函数式编程在过去几年中获得了一定的主流认可,但它实际上和 OO 一样古老,甚至更古老。它基于 19 世纪末至 20 世纪 50 年代期间由不同人开发的数学原理,并且自 20 世纪 60 年代以来一直是某些编程语言的一个功能。

在本书中,我将向您展示如何在 C# 中实现它,而无需学习一门全新的编程语言。

在我们开始编写代码之前,我想先谈谈函数式编程本身。它是什么?我们为什么要感兴趣?什么时候最好使用它。这些都是非常重要的问题。

什么是函数式编程?

函数式编程中有几个基本概念,其中许多概念的名称相当晦涩,但其实并不难理解。我会尽量在这里简单地介绍它们。

它是一种语言、API 还是什么?

不,函数式编程不是 Nuget 中的一种语言或第三方插件库,而是一种 范式。我的意思是什么?范式有更正式的定义,但我认为它是一种编程 风格。就像吉他可能被用作完全相同的乐器,但可以演奏许多通常截然不同的音乐风格,因此一些编程语言也提供对不同工作风格的支持。

函数式编程也和面向对象编码一样古老 - 如果不是更古老的话。我稍后会更多地谈论它的起源,但现在只需注意它并不是什么新鲜事,它背后的理论不仅早于 OO,而且在很大程度上也早于计算行业本身。

还值得注意的是,您可以结合范式,例如混合摇滚和爵士乐。它们不仅可以结合,而且有时您可以使用每种的最佳功能来产生更好的最终结果。

编程范式有很多种,但为了简单起见,我仅讨论现代编程中最常见的两种:

  • 命令式

在很长一段时间内,这是唯一的编程范式。过程式和面向对象 (OO) 属于这一类。这些编程风格更直接地指导执行环境需要执行的步骤,即哪个变量包含哪些中间步骤以及如何逐步详细地执行该过程。这就是通常在学校/学院/大学/工作场所教授的编程 [删除适当的内容]。

  • 声明式

在这种编程范式中,我们不太关心如何实现目标的精确细节,代码更接近于对流程结束时所需内容的描述,细节(包括步骤执行顺序等)更多地由执行环境决定。这是函数式编程所属的类别。SQL 也属于此类,因此在某些方面,函数式编程比面向对象更接近 SQL。在编写 SQL 语句时,您不必关心操作顺序(实际上不是 SELECT 然后 WHEN 然后 ORDER BY),您不必关心数据转换的执行细节,只需编写一个有效描述所需输出的脚本即可。这些也是函数式 C# 的一些目标,因此,那些有使用 SQL Server 或其他关系数据库背景的人可能会发现,其中一些想法比没有背景的人更容易理解。

除了这些之外,还有许多更多的范式,但它们远远超出了本书的范围。

函数式编程的属性

在接下来的几节中,我将讨论函数式编程的每个属性,以及它们对开发人员的真正意义。

不变性

如果某物可以改变,那么也可以说它可以变异,就像忍者神龟一样。说某物可以变异的另一种说法是它是可变的。另一方面,如果某物根本无法改变,那么它就是不可变的

在编程中,这指的是变量在定义时设置其值,此后它们可能永远不会再改变。如果需要新值,则应基于旧值创建一个新变量。这就是函数式代码中处理所有变量的方式。

与命令式代码相比,它的工作方式略有不同,但最终会生成更接近数学工作的程序,并鼓励良好的结构和更可预测的 - 因此健壮 - 代码。

DateTimeString 都是 .NET 中的不可变数据结构。您可能 认为 您已经更改了它们,但在后台,每次更改都会在堆栈上创建一个新项。这就是为什么大多数新开发人员都会谈论在 For 循环中连接字符串的原因。

高阶函数

这些是作为变量传递的函数。这可以是局部变量、函数的参数或函数的返回值。Func<T,TResult>Action<T> 委托类型就是完美的例子:

Func<int, int, string> Add = (x, y) => $"{x} + {y} = {x+y}";
Action<string> LogMessage = x => this.Logger.LogInfo($"message received: {x}");

这两种类型都是对存储在变量中的函数的引用,可以将其作为函数调用。Func 和 Action 之间的唯一区别是 Action 没有返回值 - 即它是 void 返回类型。上面的代码完全等同于此:

function string Add(int x, int y)
{
    return $"{x} + {y} = {x + y}";
}

function void LogMessage(string x)
{
    this.Logger.LogInfo($"message received: {x}");
}

使用这些委托类型的一大优势是它们存储在可在代码库中传递的变量中。它们可以作为其他函数的参数或返回类型包含在内。如果使用得当,它们是 C# 最强大的功能之一。

使用函数式编程技术,可以将委托类型组合在一起,从较小的功能构建块创建更大、更复杂的函数。就像将乐高积木放在一起制作模型“千年隼号”或任何你喜欢的名字一样。这就是这种范式被称为函数式编程的真正原因。正如名称所暗示的那样,用其他范式编写的代码并不是完全不起作用。如果它们不起作用,为什么有人会使用它们?

事实上 - 这是一条经验法则。如果有问题,函数式编程的答案几乎肯定会是“函数、函数和更多函数”。

注意

可调用代码模块不仅仅是函数。还有方法。不同之处在于函数总是返回一个值,而方法则不然。在 C# 中,方法是返回类型为“void”的函数。这些几乎不可避免地会产生副作用,因此我们应该避免在代码中使用它们 - 除非不可避免。日志记录可能是方法使用的一个例子,它不仅是不可避免的,而且对于良好的生产代码也是必不可少的。

表达式而不是语句

这里需要几个定义。

表达式是计算结果为值的离散代码单元。我的意思是什么?

最简单的形式是这些表达式:

const exp1 = 6;
const exp2 = 6 * 10;

我们也可以输入值来形成我们的表达式,所以这也是一个:

function int AddTen(int x) => x + 10;

这个也是。它确实执行一个操作 - 即评估一个布尔值,但它最终用于返回一个“bool”,所以它是一个表达式:

function bool IsTen(int x) => x == 10;

如果三元 if 语句纯粹用于确定要返回的值,你也可以将它们视为表达式:

var randomNumber = this._rnd.Generate();
var message = randomNumber == 10
    ? "It was ten"
    : "it wasn't ten";

一个快速的经验法则 - 如果代码中有一个等号,那么它很可能是一个表达式。该规则中有一些灰色区域。对其他函数的调用可能会产生各种无法预料的后果。不过,记住这个规则并不坏。

另一方面,语句计算值的代码片段。它们更多地采用指令的形式。要么是向执行环境发出指令,通过关键字(如“if”、“where”、“for”、“foreach”等)更改执行顺序,要么是调用不返回任何内容的函数 - 而是暗示执行某种操作。像这样:

this._thingDoer.GoDoSomething();

另一个经验法则是,如果没有等号,那么它肯定是一个语句。

基于表达式的编程

如果有帮助,回想一下你上学时的数学课。还记得你在写最终答案时必须写出的那些工作行吗?基于表达式的编程就是这样。在你的工作页面上,你不能回头更改你已经写的内容 - 一旦你把它写在纸上,它就永远在那里了。

以同样的方式思考使用表达式进行编程。每一行要么是要返回的最终计算,要么是过程中的一个步骤,它只能是一个常数值,可能基于先前的步骤。但关键是你不能回头更改你已经完成的事情。

这似乎是不可能的,几乎就像被要求双手被绑在身后编程一样。但这完全是可能的,甚至不一定很难。这些工具在 C# 中已经存在了大约十年,并且有很多更有效的结构。

在有了一些以这种方式工作的经验之后,回到旧的方式实际上会显得很奇怪,甚至有点尴尬和笨拙。

引用透明性

对于一个简单的概念来说,这是一个听起来很可怕的词。函数式编程中有一个“纯函数”的概念。这些函数具有以下属性:

  • 它们不会对函数之外的任何内容进行任何更改。无法更新任何状态,不会存储任何文件等。
  • 给定同一组参数值,它们将始终返回完全相同的结果。不管是什么。无论系统处于什么状态。
  • 它们没有任何意外的副作用。其中包括抛出异常。

这个术语来自这样的想法:给定相同的输入,总是会产生相同的输出,因此在计算中,您可以在给定这些输入的情况下将函数调用与最终值进行交换。在此示例中:

var addTen = x => x + 10;
var twenty = addTen(10);

使用参数 10 调用 addTen 的结果总是 20,没有例外。如此简单的函数也不可能有副作用。因此,原则上可以将对 addTen(10) 的引用替换为常数值 20,而不会产生任何副作用。这就是引用完整性。

以下是一些纯函数:

public int Add(int a, int b) => a + b;

public string SayHello(string name) => "Hello " +
 (string.IsNullOrWhitespace(name)
  ? "Unknown Person"
  : name);

请注意,不会发生任何副作用(我确保字符串中包含空检查),并且函数外部的任何内容都不会改变,只会生成并返回一个新值。

以下是这些相同函数的非纯版本:

public void Add(int a) => this.total += a;

public string SayHello() => "Hello " + this.Name;

在这两种情况下,对当前类的属性的引用都超出了函数本身的范围。 Add 函数甚至修改了该状态属性。 SayHello 函数也没有空检查。 所有这些都不能将这些视为“纯”函数。

这些怎么样?

public string SayHello() => "Hello " + this.GetName();

public string SayHello2(Customer c)
{
 c.SaidHelloTo = true;
 return "Hello " + (c?.Name ?? "Unknown Person);
}

public string SayHello3(string name) =>
 DateTime.Now().ToString() + " - Hello " + (name ?? "Unknown Person);

这些都不是纯粹的。

SayHello 依赖于自身之外的函数。如果必须使用函数来检索名称,我会用 Func<T,TResult> 委托重写它,以便将功能安全地注入我们的 SayHello 函数。

SayHello2 修改传入的对象 - 使用此函数的明显副作用。通过引用传递对象并像这样修改它们在面向对象代码中并不是一种不寻常的做法,但在函数式编程中绝对不会这样做。我可能会通过将对象属性的更新和说 hello 的处理分离到单独的函数中来使其变得纯粹。

SayHello3 使用 DateTime.Now(),每次使用时都会返回不同的值。纯函数的绝对对立面。我会通过向函数添加 DateTime 参数并传入值来解决这个问题。

引用透明性是大幅提高函数代码可测试性的功能之一。这意味着必须使用其他技术来跟踪状态,稍后我会介绍这一点。

我们的应用程序的“纯度”也是有限制的,尤其是当我们必须与外界、用户或一些不遵循函数范式的第三方库进行交互时。在 C# 中,我们总是需要在这里或那里做出妥协。

我通常喜欢在这一点上提出一个比喻。阴影有两个部分:本影和半影。本影是阴影的固体暗部,实际上是大部分阴影。半影是外部的灰色模糊圆圈,阴影和非阴影相遇并逐渐消失的部分。在 C# 应用程序中,我认为代码库的纯区域是 Umbra,而妥协区域是 Penumbra。我的任务是最大化 Pure 区域,并尽可能地最小化非 Pure 区域。

递归

如果您不理解这一点,请参阅:递归否则,请参阅:认真的递归

认真的递归

递归几乎和编程一样存在了很长时间。它是一个调用自身的函数,以实现无限(但希望不是无限)循环。任何曾经编写过代码来遍历文件夹结构或编写过高效排序算法的人都应该熟悉这一点。

递归函数通常分为两个部分:

  • 条件,用于确定是否应再次调用该函数,或者是否已达到最终状态(例如,我们尝试计算的值已找到,没有子文件夹可供探索等)
  • 返回语句,根据最终状态条件的结果,返回最终值或再次引用同一函数。

这是一个非常简单的递归添加:

function int AddUntil(int startValue, int endValue)
{
    if(startValue == endValue)
        return startValue;
    else
        AddUntil(startValue + 1, endValue);
}

虽然上面的例子很愚蠢,但请注意,我从未改变过任何一个整数的值。对递归函数的每次调用都包含原始参数的副本,有时可能是修改后的版本。这是另一个 不变性 的例子 - 我没有改变变量中的值,而是使用原始值的修改版本调用函数。

递归是函数式编程用作 While 和 ForEach 等语句的替代方法之一。但是,C# 中存在一些性能问题。稍后会有整整一章来讨论递归、尾部递归和尾部调用优化递归,但现在请谨慎使用,并坚持我的观点。一切都会变得清晰……

模式匹配

在 C# 中,这基本上是带有“加速”条纹的 Switch 语句。不过,F# 将这个概念进一步发展。现在,我们在 C# 中已经有几个版本使用了它。 C# 8 中引入的 Switch 表达式引入了我们自己对这个概念的原生实现,微软团队一直在定期对其进行增强。

这是一种切换,您可以根据所检查对象的类型及其属性更改执行路径。它可用于将大量复杂的嵌套 if 语句简化为几行相当简洁的语句。这是一个令人难以置信的强大功能,也是我最喜欢的功能之一。

接下来的几章中将出现许多此类示例,因此如果您有兴趣了解更多有关这方面的内容,请跳过。

此外,对于那些仍在使用旧版本 C# 的人来说,有多种实现方法,稍后我将展示一些技巧。

无状态

面向对象代码通常具有一组状态对象,它们表示一个过程 - 无论是真实的还是虚拟的。这些状态对象会定期更新,以与它们所代表的内容保持同步。例如:

public class DoctorWho
{
  public int NumberOfStories { get; set; }
  public int CurrentDoctor { get; set; }
  public string CurrentDoctorActor { get; set; }
  public int SeasonNumber { get; set; }
}

public class DoctorWhoRepository : IDwRepo
{
  private DoctorWho State;

  public DoctorWhoRepository(DoctorWho initialState)
  {
    this.State = initialState;
  }

  public void AddNewSeason(int storiesInSeason)
  {
    this.state.NumberOfStories += storiesInSeason;
    this.SeasonNumber++;
  }

  public void RegenerateDoctor(string newActorName)
  {
    this.state.CurrentDoctor++;
    this.state.CurrentDoctorActor = newActorName;
  }
}

好吧,如果你想进行函数式编程,就别再这么做了。没有中央状态对象的概念,也没有修改其属性的概念,就像上面的代码示例一样。

真的吗?感觉就像最纯粹的疯狂,不是吗?严格来说,存在一个状态,但它更像是系统的一个新兴属性。

任何曾经使用过 React-Redux 的人都已经接触过函数式状态方法(这反过来又受到了函数式编程语言 Elm 的启发)。在 redux 中,应用程序状态是一个不可变的对象,它不会更新,而是由开发人员定义一个函数,该函数采用旧状态、命令和任何所需参数,然后根据旧状态返回一个新的、更新的状态对象。随着 C# 10 中记录类型的引入,这个过程在 C# 中变得无限简单。我稍后会详细讨论。不过现在,一个存储库函数如何重构以在功能上工作的简单版本可能如下所示:

  public DoctorWho RegenerateDoctor(DoctorWho oldState, string newActorName)
  {
    return new DoctorWho
    {
      NumberOfStories = oldState.NumberOfStories,
      CurrentDoctor = oldState + 1,
      CurrentDoctorActor = newActorName,
      SeasonNumber = oldState.SeasonNumber
    };
  }

显然,在存储库之外,其使用方式也会有所不同。事实上,现在称其为存储库可能有点错误。稍后我将更多地讨论无状态运行所需的策略。希望这足以让您了解函数式代码的工作原理。

烘焙蛋糕

如果您想要对这些范例之间的差异进行更高级别的描述。以下是它们制作纸杯蛋糕的方式:

命令式蛋糕

这不是真正的 C# 代码,只是一种 .NET 主题的伪代码,给人一种对这个假想问题的命令式解决方案的印象。

Oven.SetTemperatureInCentigrade(180);
for(int i ==0; i < 3; i++)
{
    bool isEggBeaten = false;
    while(!isEggBeaten)
    {
        Bowl.BeatContents();
        isEggBeaten = Bowl.IsStirred();
    }
}
for(int i == 0; i < 12; i++)
{
    OvenTray.Add(paperCase[i]);
    OvenTray.AddToCase(bowl.ExtractInG(55));
}
Oven.Add(OvenTray);
Thread.PauseMinutes(25);
Oven.ExtractAll();

对我来说,这代表了典型的复杂命令式代码。大量短暂的小变量被编造出来以跟踪状态。它还非常关注事物的精确顺序。它更像是给一个完全没有智能的机器人的指令,需要为它们拼写出一切。

声明式蛋糕

以下是一段完全虚构的声明式代码,用于解决相同问题:

Oven.SetTemperatureInCentigrade(180);
var cakeBatter = EggBox.Take(3)
    .Each(e => Bowl.Add(e)
          .Then(b =>
                b.While(x => !x.IsStirred, x.BeatContents())
               )
         )
    .DivideInto(12)
    .Each(cb =>
          OvenTray.Add(PaperCaseBox.Take(1).Add(cb))
         );

如果您不熟悉函数式编程,那么现在这可能看起来很奇怪和不寻常,但在本书中,我将解释这一切是如何工作的,有什么好处,以及如何在 C# 中自己实现所有这些。

但值得注意的是,没有状态跟踪变量,没有 IfWhile 语句。我甚至不确定操作的顺序一定是怎样的,但这无关紧要,因为系统会工作,以便在需要时完成任何必要的步骤。

这更像是对稍微智能一点的机器人的指令。一个可以自己思考的机器人,至少就可能听起来像“这样做直到存在这样或那样的状态”的指令而言,在程序代码中,它将通过结合 While 循环和一些状态跟踪代码行而存在。

函数式编程从何而来?

我想说的第一件事是,尽管有些人可能认为函数式编程很古老,但它已经过时了。 真的很老了——至少按照计算标准来说。我的观点是——它不像今年流行的 JavaScript 框架,明年又会有很多旧闻。它比所有现代编程语言都早,甚至在某种程度上比计算本身都早。函数式语言存在的时间比我们任何人都长,而且在我们退休后,它很可能还会继续存在。我的观点略显冗长,但值得投入时间和精力去学习和理解它。即使有一天你发现自己不再使用 C# 工作,大多数其他语言或多或少地支持函数式概念(JavaScript 的支持程度是大多数语言梦寐以求的),因此这些技能将在你的整个职业生涯中保持相关性。

在继续这一部分之前,我要先说明一下——我不是数学家。我喜欢数学,它是我在学校、学院和大学最喜欢的科目之一,但最终到了更高层次的理论数学,甚至让我也感到眼花缭乱,头疼不已。话虽如此,我会尽力简要介绍一下函数式编程的起源。事实上,函数式编程就是理论数学的世界。

大多数人能说出的函数式编程历史上的第一位人物通常是 Haskell Brooks Curry (1900-1982),他是一位美国数学家,现在有不少于三种以他的名字命名的编程语言,以及“柯里化”的函数式概念(稍后会详细介绍)。他的工作是研究所谓的“组合逻辑”——一种数学概念,涉及以 lambda(或箭头)表达式的形式写出函数,然后将它们组合起来以创建更复杂的逻辑。这是函数式编程的基本基础。不过,Curry 并不是第一个研究这个问题的人,他继承了数学前辈的论文和书籍,这些人包括:

  • Alonzo Church (1903-1955,美国人) - Church 创造了“Lambda 表达式”这个术语,我们至今仍在 C# 和其他语言中使用这个术语。
  • Moses Schönfinkel (1888-1942,俄罗斯人) - Schönfinkel 撰写了关于组合逻辑的论文,这是 Haskell Curry 工作的基础之一
  • Friedrich Frege (1848-1925,德国人) - 可以说是第一个描述我们现在所知的 Currying 这一概念的人。虽然将发现归功于正确的人很重要,但 Freging 并不那么出名。

第一批函数式编程语言是:

  • IPL(信息处理语言),由 Allen Newell(1927-1992,美国人)、Cliff Shaw(1922-1991,美国人)和 Herbert Simon(1916-2001,美国人)于 1956 年开发
  • LISP(LISt 处理器),由 John McCarthy(1927-2011,美国人)于 1958 年开发。我听说 LISP 至今仍有粉丝,并且仍在一些企业的生产中使用。然而,我自己从未见过任何直接证据。

有趣的是,这两种语言都不是所谓的“纯”函数式语言。与 C#、Java 和许多其他语言一样,它们采用了某种混合方法,与 Haskell 和 Elm 等现代“纯”函数式语言不同。

我不想在函数式编程(无可否认,令人着迷的)历史上花太多时间,但希望从我所展示的内容中可以明显看出,它有着悠久而辉煌的血统。

  • 还有谁在做函数式编程?

    正如我已经说过的,函数式编程已经存在了一段时间,而且不仅仅是 .NET 开发人员对此感兴趣。恰恰相反,许多其他语言提供函数式范式支持的时间比 .NET 要长得多。

    我所说的支持是什么意思?我的意思是它提供了在函数式范式中实现代码的能力。这大致有两种形式:

    • 纯函数式语言

    旨在让开发人员专门编写函数式代码。所有变量都是不可变的,提供开箱即用的柯里化、高阶函数等。这些语言中可能存在一些面向对象的特性,但对于它们背后的团队来说,这在很大程度上是次要的关注点。

    • 混合或多范式语言

    这两个术语可以完全互换使用。它们描述了提供允许以两种或多种范式编写代码的功能的编程语言。通常同时使用两种或多种。支持的范式通常是函数式和面向对象的。任何支持的范式可能都没有完美的实现。对象导向得到完全支持并不罕见,但并非所有函数式功能都可以使用。

    纯函数式语言

    还有十几种纯函数式语言,下面简要介绍一下目前使用最广泛的三种语言:

    • Haskell

    Haskell 在银行业得到广泛使用。它通常被推荐为任何想要真正掌握函数式编程的人的一个很好的起点。情况可能确实如此,但老实说,我没有时间或空闲时间去学习一门我从未打算在日常工作中使用的整个编程语言。

    如果您真的有兴趣在使用 C# 之前成为函数式范式专家,那么请务必继续寻找 Haskell 内容。经常有人推荐 Miran Lipovača 的《学会 Haskell 大有裨益》。我自己从未读过这本书,但我的朋友读过,并说这本书很棒。

    • Elm

    Elm 似乎最近越来越受欢迎,原因很简单,Elm 用于执行 UI 更新的系统已被采纳并应用于其他很多项目,包括 ReactJS。这个“Elm 架构”我想留到后面的章节再讲。

    • Elixir

    一种通用编程语言,基于与 Erlang 相同的虚拟机。它在业界非常受欢迎,甚至每年都有自己的会议。

    • PureScript

    PureScript 编译为 JavaScript,因此可用于创建功能性前端代码,以及等距编程环境中的服务器端代码和桌面应用程序 - 即像 Node.JS 这样的环境,允许在客户端和服务器端使用相同的语言。

是否值得先学习纯函数式语言?

至少就目前而言,面向对象是软件开发领域绝大多数的主导范式,而函数式范式则是之后必须学习的东西。我不排除未来会发生变化的可能性,但至少就目前而言,我们处于这样的境地。

我曾听人争论说,从面向对象开始,最好先学习纯函数式编程,然后再回来将所学应用于 C#。

如果这就是你做的,那就去做吧。玩得开心。我毫不怀疑这是值得的。

对我来说,这种观点让我想起了我们以前在英国的那些老师,他们坚持认为孩子们应该学习拉丁语,因为拉丁语是许多欧洲语言的根源,对拉丁语的知识可以很容易地转移到法语、意大利语、西班牙语等。

我有点不同意这种观点。与拉丁语不同,纯函数式语言并不一定,尽管它们与面向对象开发非常不同。那些职业生涯中大量参与面向对象开发的人可能会发现更难适应。

拉丁语和纯函数式语言的相似之处在于它们代表了一种更纯粹的祖先形式。除了少数专业兴趣之外,它们都只有有限的价值。

除非您对法律、古典文学、古代历史等感兴趣,否则学习拉丁语也几乎完全无用。学习现代法语或意大利语更有用。它们是迄今为止最容易学习的语言,您可以现在使用它们参观美丽的地方并与居住在那里的善良人交谈。比利时也有一些很棒的法语漫画。看看吧。我会等的。

同样,很少有地方会在生产中真正使用纯函数式语言。您将花费大量时间彻底改变您的工作方式,最终学习一种您可能永远不会在自己的业余代码之外使用的语言。我从事这项工作已有很长时间,但我从未遇到过一家公司在实际生产中使用比 C# 更先进的语言。

C# 的妙处在于它几乎平等地支持 OO 和函数式,因此您可以随意在它们之间切换。您可以随意使用一种或另一种范式中的任意多种功能,而不会受到任何影响。这些范式可以相当舒适地并排存在于同一个代码库中,因此这是一个以适合您的速度从纯 OO 过渡到函数式或反之亦然的简单环境。

这在纯函数式语言中是不可能的,即使有很多函数式功能在 C# 中不可能实现。

那么 F# 呢?我应该学习 F# 吗?

这可能是我被问到最多的问题。那么 F# 呢?它不是纯函数式语言,但比 C# 更接近于该范式的正确实现。它有各种开箱即用的函数式技巧和玩具,为什么不使用它们呢?

在回答这个问题之前,我总是喜欢检查房间里可用的出口。F# 拥有热情的用户群,他们可能都比我聪明得多。但是……

……不,我认为在尝试函数式 C# 之前不值得学习 F#。这并不是因为 F# 不棒。我不了解 F#,但我见过的一切都很棒,我很想玩一玩。

这并不是因为 F# 不容易学。从我所见,它是容易学的,而且如果你是编程新手,它很可能比 C# 更容易学。

这并不是说 F# 不会带来商业利益,因为我真诚地相信它会。

这并不是说 F# 不能做任何其他语言能做的一切。它肯定可以。我见过一些关于如何制作全栈 F# Web 应用程序的令人印象深刻的演讲。

这是一个专业的决定。找到 C# 开发人员并不难,至少在我去过的任何国家都是如此。如果我把大型开发者会议的每位与会者的名字放进一顶帽子里,然后随机抽取一个,很有可能这个人是能够专业编写 C# 的人。如果一个团队决定投资 C# 代码库,那么让团队中充满能够保持代码良好维护和业务相对满意的工程师并不是什么难事。

另一方面,了解 F# 的开发人员相对较少。我认识的不多。通过将 F# 添加到代码库中,您可能会依赖团队来确保您始终有足够的人了解它,否则您将面临代码某些部分难以维护的风险,因为很少有人知道如何维护。

我应该指出,风险并不像引入一种全新的技术(例如 Node.JS)那么高。F# 仍然是一种 .NET 语言,并编译为相同的中间语言。然而,对于大多数 .NET 开发人员来说,它仍然是一种完全陌生的语法。

我坚决希望这种情况会随着时间的推移而改变。我非常喜欢我所看到的 F#,我很乐意做更多这样的事情。如果我的老板告诉我已经做出了采用 F# 的业务决定,我会第一个欢呼。

但事实是,目前这种情况不太可能发生。谁知道未来会带来什么。也许这本书的未来版本需要大量重写才能满足突然涌现的对 F# 的热爱,但目前我看不出这在近期内会发生。

我的建议是先尝试一下这本书。如果你喜欢它,也许 F# 可能是你踏上函数式之旅的下一个目的地。

多范式语言

可以说,除了纯函数式语言之外,所有语言都是某种形式的混合。换句话说,至少可以实现函数式范式的某些方面。这可能是真的,但我将简要介绍几个可以完全或大部分实现函数式的语言,以及由其背后的团队明确提供的功能。

  • JavaScript

JavaScript 当然是编程语言中的狂野西部,几乎任何事情都可以用它完成,而且它在函数式方面做得非常非常好。可以说比面向对象更好。如果您想了解如何以函数式和正确的方式执行 JS,请查看 Douglas Crockford 的 Javascript:优点 和他的一些在线讲座(例如 https://www.youtube.com/watch?v=_DKkVvOt6dk) 。

  • Python

近几年来,Python 迅速成为开源社区最喜爱的编程语言。我很惊讶地发现它自 80 年代末就已存在!Python 支持高阶函数,并且有几个可用的库:itertoolsfunctools,允许实现更多函数特性。

  • Java

Java 平台对函数特性的支持程度与 .NET 相同。此外,还有 Scala、Clojure 和 Kotlin 等衍生项目,它们提供的函数特性远多于 Java 语言本身。

  • F#

我已经在上一节中详细讨论过这个问题,所以现在就不多说了。这是 .NET 更纯粹的函数式语言。C# 和 F# 库之间也可以互操作,因此您可以拥有利用两者所有最佳特性的项目。

  • C#

微软从一开始就一直在慢慢增加对函数式编程的支持。可以说,早在 2005 年,C# 2.0 中引入的委托协变和匿名方法就被认为是支持函数式范式的最早项目。直到第二年,事情才真正开始顺利进行,当时 C# 3.0 引入了我认为是有史以来添加到 C# 中的最具变革性的功能之一 - LINQ。

我稍后会详细讨论它,但 LINQ 深深植根于函数式范式,是我们开始用 C# 编写函数式代码的最佳工具之一。事实上,C# 团队的既定目标是,每个发布的 C# 版本都应该比之前的版本包含对函数式编程的进一步支持。推动这一决定的因素有很多,但其中之一就是 F#,它经常向 .NET 运行时人员请求新的函数式特性,而 C# 最终也从中受益。

函数式编程的好处

我希望你选择这本书是因为你已经对函数式编程很感兴趣,并想立即开始。本节可能对团队讨论是否在工作中使用它很有用。

简洁

虽然这不是函数式编程的一个特点,但与面向对象或命令式代码相比,我最喜欢的优点就是它看起来简洁而优雅。

其他风格的代码更关注如何做某事的底层细节,以至于有时需要花很多时间研究代码才能弄清楚那件事到底是什么。函数式编程更倾向于描述需要什么。为了实现这一目标,哪些变量需要更新、如何更新以及何时更新的细节并不是我们关心的。

我曾与一些开发人员讨论过这个问题,他们不喜欢减少参与较低级别的数据处理,但我个人更愿意让执行环境来处理这个问题,这样我就不用太担心了。

这感觉像是一件小事,但我真的很喜欢函数式代码与命令式替代方案相比的简洁性。开发人员的工作很辛苦,我们经常会继承需要快速掌握的复杂代码库。弄清楚函数实际在做什么的时间越长、难度越大,企业损失的钱就越多。函数式代码通常以一种接近自然语言的方式来描述正在完成的工作。这也使得查找错误变得更容易,这又为企业节省了时间和金钱。

可测试

很多人将函数式编程最喜欢的一个特性描述为它非常易于测试。它确实如此。如果您的代码库无法接近 100% 地进行测试,那么您可能没有正确遵循范例。

测试驱动开发 (TDD) 和行为驱动开发 (BDD) 现在是重要的专业实践。这些编程技术涉及首先为生产代码编写自动化单元测试,然后编写允许测试通过所需的实际代码。它往往会产生设计更好、更强大的代码。函数式编程巧妙地实现了这些实践。这反过来又会产生更好的代码库和更少的生产错误。

强大

不仅仅是可测试性导致了更强大的代码库。函数式编程具有主动防止错误发生的结构,或者如果发生错误,则防止任何意外行为,并使准确报告问题变得更容易。函数式编程中没有 NULL 概念。仅凭这一点就可以避免大量可能的错误。

可预测

函数式代码从代码块的开头开始,一直到结尾。完全按顺序执行。这是程序式代码无法做到的,因为程序式代码有循环和分支 If 语句。只有单一、易于遵循的代码流。

如果操作正确,甚至不会出现 Try/Catch 块,而我发现,对于操作顺序不可预测的代码,Try/Catch 块是最严重的问题。如果 Try 的范围不小,并且与 Catch 紧密耦合,那么有时它就相当于盲目地将一块石头扔向空中。谁知道它会落在哪里,谁或什么会接住它。谁能说清程序流程的这种中断可能会引发什么意外行为。

在我的职业生涯中,我观察到许多生产中意外行为的背后都是设计不当的 Try/Catch 块,而这个问题在函数式范式中根本不存在。

在函数式代码中仍然可能出现不正确的错误处理,但函数式编程的本质并不鼓励这样做。

更好地支持并发性

软件开发领域最近出现了两项发展,在过去几年中变得非常重要:

  • 容器化

Docker 和 Kubernetes 等产品提供了容器化。容器化是指应用程序不再运行在传统服务器上,而是运行在由脚本在部署时生成的微型虚拟机 (VM) 之类的东西上。两者并不完全相同,没有硬件模拟,但从用户的角度来看,结果大致相同。它解决了“在我的计算机上运行正常”的问题,遗憾的是,许多开发人员对此非常熟悉。许多公司都有软件基础设施,涉及将同一应用程序的许多实例堆叠在容器阵列中,所有实例都处理相同的输入源。无论是队列、用户请求还是其他什么。托管它们的环境甚至可以配置为根据需求增加或减少活动容器的数量。

  • 无服务器

.NET 开发人员可能对 Azure Functions 或 AWS Lambdas 很熟悉。这种代码不会部署到传统的 Web 服务器(例如 IIS),而是作为独立存在于云托管环境中的单个函数。这既允许与容器相同的自动扩展,也允许微观层面的优化,可以将更多资金花在更关键的功能上,而将更少的资金花在输出可能需要更长时间才能完成的功能上。

在这两种技术中,并发处理的利用率很高;即同一功能的多个实例在同一输入源上同时工作。它类似于 .NET 的异步功能,但应用范围要大得多。

任何类型的异步操作的问题往往发生在共享资源上,无论是内存状态还是字面共享的物理或基于软件的外部资源。

函数式编程在没有状态的情况下运行,因此线程、容器或无服务器函数之间不能有共享状态。

如果正确实施,遵循函数式范式可以更轻松地实现这些需求量很大的技术功能,但不会在生产中引起任何意外行为。

减少代码噪音

在音频处理中,他们有一个称为“信噪比”的概念。这是衡量录音清晰度的标准,基于信号(您想要听到的东西)的音量与噪音(嘶嘶声、噼啪声、隆隆声或背景中的其他声音)的比率。

在编码中,“信号”是代码块的业务逻辑 - 它实际上试图完成的事情。代码的“内容”。

“噪音”是必须编写的所有样板代码,以便实现目标。For 循环定义、If 语句,诸如此类。

与程序代码相比,简洁明了的函数式编程样板代码明显更少,因此信噪比要好得多。

这不仅仅是对开发人员的好处。健壮、更易于维护的代码库意味着企业需要在维护和增强上花费更少的钱。

使用函数式编程的最佳场合

函数式编程绝对可以做任何其他范式可以做的事情,但有些领域是它最强大和最有益的 - 而其他领域则可能需要妥协并纳入一些面向对象特性,或稍微改变函数式范式的规则。至少在 .NET 中,必须做出妥协,因为任何基类或附加库都倾向于按照面向对象范式编写。这不适用于纯函数式语言。

函数式编程在可预测性较高的地方很好。例如,数据处理模块 - 将数据从一种形式转换为另一种形式的函数。业务逻辑类处理来自用户或数据库的数据,然后将其传递到其他地方进行渲染。诸如此类。

函数式编程的无状态性质使其成为并发系统的绝佳推动者 - 例如高度异步的代码库,或多个处理器同时监听同一输入队列的地方。当没有共享状态时,几乎不可能出现资源争用问题。

如果您的团队正在考虑使用无服务器应用程序(例如 Azure Functions),那么出于大多数相同的原因,函数式编程可以很好地实现这一点。

对于高度业务关键的系统,值得考虑使用函数式编程,因为该范式倾向于生成比使用面向对象范式编码的应用程序更稳定、更强大的代码。 如果系统保持运行非常重要,并且在发生未处理的异常或无效输入时不会崩溃(即意外终止),那么函数式编程可能是最佳选择。

您应该在哪里考虑使用其他范式?

当然,您不必做任何这样的事情。 函数式可以做任何事情,但在某些领域可能值得寻找其他范式 - 纯粹在 C# 环境中。 值得再次提及的是,C# 是一种混合语言,因此许多范式可以非常愉快地并排放置,具体取决于开发人员的需求。我当然知道我更喜欢哪个!

与外部实体的交互是一个需要考虑的领域。 I/O、用户输入、第三方应用程序、Web API 等等。没有办法制作那些纯函数(即没有副作用),所以妥协是必要的。从 Nuget 包导入的第三方模块也是如此。甚至有一些较旧的 Microsoft 库根本无法使用函数式。这在 .NET Core 中仍然如此。如果您想看一个具体的例子,请查看 C# 的 SMTP 功能和 MainMessage 类。

在 C# 世界中,如果性能是您唯一的项目,压倒性的关注,胜过所有其他方面,甚至可读性和模块化,那么遵循函数式范式可能不是最好的主意。函数式 C# 代码的性能并不一定天生就差,但它也不一定是性能最强的解决方案。

我认为函数式编程的好处远远超过任何轻微的性能损失,而且如今,大多数时候,在应用程序上添加更多硬件(虚拟或物理,视情况而定)非常容易 - 这可能比开发、测试、调试和维护以命令式代码编写的代码库所需的额外开发人员时间成本要便宜一个数量级。例如,如果您正在编写要放置在某种移动设备上的代码,这种情况就会发生变化,因为内存有限且无法更新,因此性能至关重要。

我们能走多远?

不幸的是,在 C# 中实现整个函数式范式是不可能的。原因有很多,包括语言需要向后兼容以及对仍然是强类型语言的限制。

本书的目的不是向您展示如何完成所有这些,而是展示可能和不可能之间的界限。我还将研究实用性,尤其是针对那些维护生产代码库的人。这最终是一份实用的函数式编码风格指南。

Monads – 其实现在还不必担心

Monads 常常被认为是函数式恐怖故事。在 Wikipedia 上查找定义,你会看到一堆奇怪的字母,其中包含 F、G、箭头和比你在当地图书馆的书架下找到的还要多的括号。我发现正式定义 - 即使是现在 - 也完全难以辨认。归根结底,我是一名工程师,而不是数学家。

Douglas Crockford 曾经说过,Monad 的诅咒是,一旦你获得了理解它的能力,你就失去了解释它的能力。所以我不会。然而,它们可能会在这本书的某个地方出现。特别是在意想不到的时候。

别担心,一切都会好起来的。我们会一起努力解决这一切。相信我……

总结

使用 C# 进行函数式编程的这一激动人心的第一部分中,我们强大而令人敬畏的英雄——你——勇敢地了解了函数式编程到底是什么,以及为什么值得学习。

对函数式范式的重要特征进行了初步的简要介绍:

  • 不变性
  • 高阶函数
  • 表达式优于语句
  • 引用透明性
  • 递归
  • 模式识别
  • 无状态

讨论了函数式编程最适合使用的领域,以及可能需要讨论是否以纯粹的形式使用它。

我们还研究了使用函数式范式编写应用程序的诸多好处。

在下一个激动人心的章节中,我们将开始研究您现在就可以在 C# 中做什么。无需新的第三方库或 Visual Studio 扩展。只是一些真正开箱即用的 C# 和一点独创性。

只需返回页面即可了解所有内容。同样的 .NET 时间。同样的 .NET 频道。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

0neKing2017

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值