F# 基础知识

面向 .NET 开发人员的功能性编程简介

Chris Marinos

下载代码示例

到目前为止,您很有可能已经听说过 F#,即 Microsoft Visual Studio 语言系列中新增的一种语言。有很多令人兴奋的理由来学习 F# - 它具有清晰的语法、强大的多线程功能以及与其他 Microsoft .NET Framework 语言之间流畅的互操作性。但是,F# 包括一些重要的新概念,您将需要了解这些概念,然后才能利用上述功能。

要开始学习另一种面向对象的语言(甚至是像 Ruby 或 Python 这样的动态语言),建议您从简要概述着手。这是因为您已经了解了大部分词汇,只需学习新语法即可。然而 F# 却有所不同。F# 是一种功能性编程语言,并且附带了超出您预料之外的新词汇。此外,功能性语言以往用于学术界,因此这些新术语的定义可能难于理解。

幸运的是,F# 并未设计为一种学术语言。它的语法允许您使用功能性技术以崭新而更为出色的方式解决问题,同时仍然支持 .NET 开发人员已经习惯的面向对象的命令式样式。与其他 .NET 语言不同,F# 的多模式结构意味着您可以针对所尝试解决的问题自由选择最佳编程样式。F# 中的功能性编程主要是指编写简洁、强大的代码来解决实际软件问题。它涉及到使用诸如高阶函数和函数组合等技术来创建强大而易于理解的行为。它还涉及到通过消除隐藏的复杂性,使您的代码更易于理解、测试和并行化。

但为了使您利用 F# 的所有这些出色的功能,您需要了解基础知识。在本文中,我将使用 .NET 开发人员已经熟悉的词汇来解释这些概念。我将向您演示一些您可以应用于现有代码的功能性编程技术,以及一些您在进行功能性编程时已经使用的方法。到本文结束时,您将了解到有关功能性编程的足够信息,因此将能够使用 Visual Studio 2010 中的 F# 立即开始工作。

功能性编程基础知识

对于大多数 .NET 开发人员而言,从相反方面来了解功能性编程的概念要更为轻松。命令式编程是一种被视为与功能性编程相对的编程样式。它也是您可能最为熟悉的编程样式,因为大多数主流编程语言都是命令式的。

功能性编程和命令式编程存在根本性的差别,您甚至可以在最简单的代码中看到这一点:

int number = 0;
number++;

此代码很明显会将变量加 1。这并没有什么让人感到十分兴奋的,但您还可以考虑用其他方式来解决问题:

const int number = 0;
const int result = number + 1;

number 仍然会加 1,但并非就地修改的。实际上,结果将存储为另一个常量,因为编译器不允许修改常量的值。您会说常量是固定不变的,因为常量一旦定义,您就无法更改它们的值。与之相反,我的第一个示例中的 number 变量是可变的,因为您可以修改它的值。这两个示例显示了命令式编程和功能性编程之间的其中一个根本差异。命令式编程强调使用可变的变量,而功能性编程则使用不变的值。

大多数 .NET 开发人员会说前面示例中的 number 和 result 是变量,但作为功能性编程人员,您需要更加小心。毕竟,常数变量的概念至多是让人难以理解。实际上,功能性编程人员会说 number 和 result 是值。确保为可变对象保留术语“变量”。请注意,这些术语并不为功能性编程所独有,但在采用功能性样式进行编程时,这些术语要重要得多。

这个差异看起来可能很小,但它却是使功能性编程如此强大的诸多概念的基础。可变变量是导致很多令人生厌的 Bug 的根本原因。正如您在下面将看到的,它们会使代码的不同部分之间产生隐式依赖关系,从而导致许多问题,特别是与并发性相关的问题。相比之下,不可变变量的复杂程度则要低很多。它们带来了将函数用作值以及组合编程这样的功能性技术,稍后我也将详细探讨这些技术。

如果您此时对功能性编程持有怀疑态度,不要担心。这很自然。大多数命令式编程人员受到的培训是您不能用不可变值执行任何有用的操作。但是,请看看以下示例:

string stringValue = "world!";
string result = stringValue.Insert(0, "hello ");

Insert 函数生成“hello world!”字符串,但您知道 Insert 不会修改源字符串的值。这是因为字符串在 .NET 中是不可变的。.NET Framework 的设计人员使用了功能性方法,因为这样可以更轻松地用字符串编写更出色的代码。由于字符串是 .NET Framework 中使用最广泛的数据类型之一(除此之外还有其他基类型,比如整数、日期时间等),因此很有可能您完成了比您认识到的更有用的功能性编程。

开始使用 F#

F# 随 Visual Studio 2010 一起提供,并且您可在 msdn.microsoft.com/vstudio 上找到最新版本。如果您使用 Visual Studio 2008,您可以从 F# 开发人员中心 msdn.microsoft.com/fsharp 下载 F# 加载项,在其中您还可以找到 Mono 的安装说明。

F# 向 Visual Studio 中增加了一个名为“F# Interactive”的新窗口,该窗口毫无疑问允许您以交互方式执行 F# 代码。您可以将其视为功能更强大的“即时窗口”,甚至当您不在调试模式下时,也能访问该窗口。如果您熟悉 Ruby 或 Python,您将认识到 F# Interactive 是一个读取-求值-打印循环 (REPL),它是学习 F# 和快速体验代码的非常有用的工具。

我将在本文中使用 F# Interactive 向您演示当编译和运行示例代码时将发生什么情况。如果您在 Visual Studio 中突出显示代码并按 Alt+Enter,则会将代码发送到 F# Interactive。若要了解这一点,请看下面这个 F# 中的简单加法示例:

let number = 0
let result = number + 1

当您在 F# Interactive 中运行此代码时,将会获得以下结果:

val number : int = 0
val result : int = 1

您或许能够通过 val 一词猜到,number 和 result 都是不可变值,而不是可变变量。可通过使用 F# 赋值运算符 <- 看到这一点:

> number <- 15;;

  number <- 15;;
  ^^^^^^^^^^^^

stdin(3,1): error FS0027: This value is not mutable
>

由于您知道功能性编程基于不变性,因此这一错误应该讲得通。let 关键字用于在名称和值之间创建不可变绑定。就 C# 而言,所有内容在 F# 中默认情况下都是常量。您可以在需要时建立可变变量,但必须要明确指明。默认情形与您熟悉的命令式语言中的情况恰好相反。

let mutable myVariable = 0
myVariable <- 15

类型推理和空格敏感性

F# 允许您声明变量和值而不指定其类型,因此您可能会猜想 F# 是一种动态语言,但事实并非如此。就像 C# 或 C++ 一样,F# 是一种静态语言,了解这一点非常重要。不过,F# 有强大的类型推理系统,利用该系统,您可以避免在多处指定对象的类型。这样就得到了一种简单且简洁的语法,同时仍然具备静态语言的类型安全性。

尽管在命令式语言中实际上找不到这样的类型推理系统,但类型推理并不直接与功能性编程相关。但是,如果您希望学习 F#,则类型推理是要理解的一个重要概念。幸运的是,如果您是 C# 开发人员,那么因为存在 var 关键字,您可能已经熟悉了基本类型推理。

// Here, the type is explictily given
Dictionary<string, string> dictionary = 
  new Dictionary<string, string>();

// but here, the type is inferred
var dictionary = new Dictionary<string, string>();

这两行 C# 代码都会创建以静态方式类型化为 Dictionary<string, string> 的新变量,但 var 关键字将指示编译器为您推断变量的类型。F# 将此概念提升到了一个新的高度。例如,下面是 F# 中的一个 add 函数:

let add x y =
    x + y
    
let four = add 2 2

上面的代码中没有单独的类型注释,但 F# Interactive 显示了静态类型化:

val add : int -> int -> int
val four : int = 4

我将在后面对箭头进行详细说明,但现在您可将此代码的含义解释为:add 定义为采用两个 int 参数,并且 four 是 int 值。F# 编译器能够基于 add 和 four 的定义方式来推断这一点。编译器用于进行此推断的规则超出了本文的讨论范围,但如果感兴趣,您可以在 F# 开发人员中心了解有关这些规则的详细信息。

类型推理是 F# 减少代码中的干扰信息的一种方式,但请注意,并没有用于指示 add 函数的主体或返回值的大括号或关键字。这是因为 F# 默认情况下是一种对空格敏感的语言。在 F# 中,您通过缩进来指示函数的主体,并通过确保值是函数中的最后一行来返回值。如同类型推理一样,空格敏感性与功能性编程并没有直接关系,但您需要熟悉该概念才能使用 F#。

副作用

现在,您已了解到,功能性编程与命令式编程之所以不同,原因在于它依赖于不可变值而不是可变变量,但该事实本身并不非常有用。下一步是了解副作用。

在命令式编程中,函数的输出取决于其输入参数和程序的当前状态。在功能性编程中,函数只取决于其输入参数。换言之,如果使用同一输入值调用某个函数多次,您始终会得到同一输出值。之所以在命令式编程中不是这样,原因在于副作用,如图 1 中所示。

图 1 可变变量的副作用

public MemoryStream GetStream() {
  var stream = new MemoryStream();
  var writer = new StreamWriter(stream);
  writer.WriteLine("line one");
  writer.WriteLine("line two");
  writer.WriteLine("line three");
  writer.Flush();
  stream.Position = 0;
  return stream;
}

[TestMethod]
public void CausingASideEffect() {
  using (var reader = new StreamReader(GetStream())) {
    var line1 = reader.ReadLine();
    var line2 = reader.ReadLine();

    Assert.AreNotEqual(line1, line2);
  }
}

第一次调用 ReadLine 时,将在遇到新行之前读取流。然后,ReadLine 返回直到新行为止的所有文本。在这些步骤之间,表示流位置的可变变量将会更新。这就是副作用。在第二次调用 ReadLine 时,可变位置变量的值发生了变化,因此 ReadLine 将返回不同的值。

现在,让我们看看使用副作用的最显著影响之一。首先,假设有一个简单的 PiggyBank 类,并且某些方法使用该类(请参见图 2)。

图 2 可变的 PiggyBank

public class PiggyBank{
  public PiggyBank(int coins){
    Coins = coins;
  }

  public int Coins { get; set; }
}

private void DepositCoins(PiggyBank piggyBank){
  piggyBank.Coins += 10;
}

private void BuyCandy(PiggyBank piggyBank){
  if (piggyBank.Coins < 7)
    throw new ArgumentException(
      "Not enough money for candy!", "piggyBank");

  piggyBank.Coins -= 7;
}

如果您有一个内含 5 枚硬币的存钱罐,您可以在 BuyCandy 之前调用 DepositCoins,但顺序反过来则会引发异常:

// this works fine
var piggyBank = new PiggyBank(5);

DepositCoins(piggyBank);
BuyCandy(piggyBank);

// but this raises an ArgumentException
var piggyBank = new PiggyBank(5);

BuyCandy(piggyBank);
DepositCoins(piggyBank);

BuyCandy 函数和 DepositCoins 函数都通过使用副作用更新存钱罐的状态。因此,每个函数的行为都取决于存钱罐的状态。由于硬币的数量是可变的,因此这些函数的执行顺序很重要。换言之,这两个方法之间存在隐式的时间安排依赖关系。

现在,让我们将硬币数设为只读来模拟不可变数据结构。图 3 显示 BuyCandy 和 DepositCoins 现在返回新的 PiggyBank 对象,而不是更新现有 PiggyBank。

图 3 不可变 PiggyBank

public class PiggyBank{
  public PiggyBank(int coins){
    Coins = coins;
  }

  public int Coins { get; private set; }
}

private PiggyBank DepositCoins(PiggyBank piggyBank){
  return new PiggyBank(piggyBank.Coins + 10);
}

private PiggyBank BuyCandy(PiggyBank piggyBank){
  if (piggyBank.Coins < 7)
    throw new ArgumentException(
      "Not enough money for candy!", "piggyBank");

  return new PiggyBank(piggyBank.Coins - 7);
}

与先前一样,如果您尝试在 DepositCoins 之前调用 BuyCandy,将会出现参数异常:

// still raises an ArgumentException
var piggyBank = new PiggyBank(5);

BuyCandy(piggyBank);
DepositCoins(piggyBank);

但现在,即使您颠倒顺序,也会得到同样的结果:

// now this raises an ArgumentException,  too!
var piggyBank = new PiggyBank(5);

DepositCoins(piggyBank);
BuyCandy(piggyBank);

此处 BuyCandy 和 DepositCoins 仅取决于其输入参数,原因是硬币数不可变。可以按任意顺序执行函数,结果都相同。不再有隐式时间依赖关系。但是,由于您可能希望 BuyCandy 成功,因此需要使 BuyCandy 的结果取决于 DepositCoins 的输出。您需要显式确定依赖关系:

var piggyBank = new PiggyBank(5);
BuyCandy(DepositCoins(piggyBank));

这是有深远影响的一个细微差别。共享的可变状态和隐式依赖关系是命令式代码中某些最令人生厌的 Bug 的根源,并且也是多线程处理在命令式语言中如此困难的原因所在。如果您不得不考虑函数的执行顺序,您需要依赖于繁琐的锁定机制来加以区分。纯粹的功能性程序没有副作用和隐式时间依赖关系,因此函数的执行顺序无关紧要。这意味着您不必考虑锁定机制和其他容易出错的多线程技术。

更简单的多线程处理是功能性编程近来得到关注的一个主要原因,但采用功能性方式进行编程还有许多其他优点。没有副作用的函数更易于测试,原因是每个函数都只依赖于其输入参数。由于这些函数不隐式依赖于其他设置函数中的逻辑,因此它们更易于维护。此外,没有副作用的函数通常更小且更易于合并。我将立即详细论述最后这一点。

在 F# 中,您侧重于针对函数的结果值(而不是其副作用)来计算函数。在命令式语言中,通常调用函数来执行操作;在功能性语言中,调用函数的目的是为了产生结果。可通过查看 if 语句在 F# 中看到这一点:

let isEven x =
    if x % 2 = 0 then
        "yes"
    else
        "no"

您知道,在 F# 中,函数的最后一行是其返回值,但在本例中,函数的最后一行是 if 语句。这不是一个编译器技巧。在 F# 中,即使是 if 语句也设计为返回值:

let isEven2 x =
    let result = 
        if x % 2 = 0 then
            "yes"
        else
            "no"
    result

结果值的类型为字符串,并且被直接赋给 if 语句。这种方式与 C# 中条件运算符的工作方式类似:

string result = x % 2 == 0 ? "yes" : "no";

条件运算符强调返回值,而不是引起副作用。它是一种功能性更高的方法。相比之下,C# 的 if 语句的命令性更高,因为它不返回结果。它所能做的就是引起副作用。

编写函数

既然您已了解了没有副作用的函数的优点,下面即可在 F# 中充分发挥函数的潜力。首先,让我们从某个对 0 到 10 的数字求平方值的 C# 代码开始:

IList<int> values = 0.Through(10).ToList();

IList<int> squaredValues = new List<int>();

for (int i = 0; i < values.Count; i++) {
  squaredValues.Add(Square(values[i]));
}

除了 Through 和 Square 帮助器方法外,此代码完全是标准的 C#。合格的 C# 开发人员可能会对我使用 for 循环(而不是 foreach 循环)大为光火,也难怪他们如此。类似 C# 这样的新式语言提供 foreach 循环作为抽象概念,因为不需要显式索引器,所以可以更为轻松地遍历枚举。它们成功实现了此目标,但请看图 4 中的代码。

图 4 使用 foreach 循环

IList<int> values = 0.Through(10).ToList();

// square a list
IList<int> squaredValues = new List<int>();

foreach (int value in values) {
  squaredValues.Add(Square(value));
}

// filter out the even values in a list
IList<int> evens = new List<int>();

foreach(int value in values) {
  if (IsEven(value)) {
    evens.Add(value);
  }
}

// take the square of the even values
IList<int> results = new List<int>();

foreach (int value in values) {
  if (IsEven(value)) {
    results.Add(Square(value));
  }
}

本例中的各个 foreach 循环是类似的,但每个循环体所执行的操作略有不同。命令式编程人员在传统上不会反对这种代码重复,因为它被视为是惯用代码。

功能性编程人员则采用不同的方法。他们使用没有副作用的函数,而不是创建像 foreach 循环这样的抽象概念来帮助遍历列表:

let numbers = {0..10}
let squaredValues = Seq.map Square numbers

此 F# 代码也计算一系列数字的平方值,但它使用高阶函数来完成操作。高阶函数只是一些接受另一个函数作为输入参数的函数。在本例中,函数 Seq.map 接受 Square 函数作为参数。它将此函数应用于数字序列中的每个数字,并返回平方数的序列。高阶函数就是为什么许多人说功能性编程将函数用作数据的原因。这只是意味着可将函数用作参数,或就像整数或字符串一样赋给值或变量。就 C# 而言,它非常类似于委托和 lambda 表达式的概念。

高阶函数是使功能性编程如此强大的技术之一。您可以使用高阶函数来隔离 foreach 循环中的重复代码,并将其封装到没有副作用的独立函数中。这些函数各自执行一个 foreach 循环内的代码本来处理的小操作。由于这些函数没有副作用,因此您可以将它们合并,以创建更可靠、更易于维护的代码,这些代码实现与 foreach 循环相同的功能:

let squareOfEvens = 
    numbers
    |> Seq.filter IsEven
    |> Seq.map Square

此代码唯一令人费解的部分可能是 |> 运算符。此运算符用于提高代码的可读性,它允许您对函数的参数进行重新排序,以便最后一个参数是您读到的第一项。它的定义非常简单:

let (|>) x f = f x

如果没有 |> 运算符,squareOfEvens 代码将如下所示:

let squareOfEvens2 = 
  Seq.map Square (Seq.filter IsEven numbers)

如果您使用 LINQ,则通过这种方式使用高阶函数看起来应非常熟悉。这是因为 LINQ 深深扎根于功能性编程中。事实上,您可以使用 LINQ 中的方法轻松地将偶数平方问题转换为 C#:

var squareOfEvens =
  numbers
  .Where(IsEven)
  .Select(Square);

这会转换为以下 LINQ 查询语法:

var squareOfEvens = from number in numbers
  where IsEven(number)
  select Square(number);

通过在 C# 或 Visual Basic 代码中使用 LINQ,您能够每天都利用到功能性编程的一些强大功能。这是学习功能性编程技术的一种很好方法。

当您开始定期使用高阶函数时,最终将会遇到希望创建很小、非常特定的函数来传入高阶函数的情况。功能性编程人员使用 lambda 函数来解决此问题。Lambda 函数只是您在定义时未指定名称的函数。它们通常很小,并具有非常特定的用途。例如,下面是您可使用 lambda 计算偶数的平方值的另一种方式:

let withLambdas =
    numbers
    |> Seq.filter (fun x -> x % 2 = 0)
    |> Seq.map (fun x -> x * x)

此代码与前面用于计算偶数平方值的代码的唯一不同之处在于 Square 和 IsEven 定义为 lambda。在 F# 中,您使用 fun 关键字声明 lambda 函数。您只应使用 lambda 来声明单用途函数,因为 lambda 在其定义范围之外使用起来并不方便。因此,Square 和 IsEven 对于 lambda 函数而言并不是好的选择,因为它们在很多情况下都有用。

科里化和分部应用程序

您现在已经了解了几乎所有着手使用 F# 所需的基础知识,但还有一个概念您应要熟悉。在前面的示例中,来自 F# Interactive 的类型签名中的 |> 运算符和箭头都与一个称为“科里化”的概念相关。

“科里化”的意思是将具有多个参数的函数划分为一系列函数,其中每个函数采用一个参数并最终生成与原始函数相同的结果。对于 .NET 开发人员而言,“科里化”可能是本文中最复杂的主题,特别是因为它通常会与分部应用程序混淆起来。您可以在此示例中看到两者均在发挥作用:

let multiply x y =
    x * y
    
let double = multiply 2
let ten = double 5

马上您应会看到与大多数命令式语言不同的行为。第二个语句通过将一个参数传递到采用两个参数的函数,从而创建一个名为 double 的新函数。结果将生成一个函数,该函数接受一个 int 参数并产生相同的输出,就好像您在 x 等于 2 并且 y 等于该参数的情况下调用 multiply 一样。就行为而言,它与此代码相同:

let double2 z = multiply 2 z

通常,人们错误地说 multiply 被科里化而形成 double。但这种说法只在一定程度上正确。multiply 函数是被科里化,但却是在定义时进行的,因为 F# 中的函数默认情况下已科里化。在创建 double 函数时,更准确的说法是部分应用了 multiply 函数。

让我们详细重温一下这些步骤。“科里化”将具有多个参数的函数划分为一系列函数,其中每个函数采用一个参数并最终生成与原始函数相同的结果。依据 F# Interactive,multiply 函数具有以下类型签名:

val multiply : int -> int -> int

到目前为止,您将此代码的含义解释为:multiply 是接受两个 int 参数并返回 int 结果的函数。现在我将解释实际发生的情况。multiply 函数实际上是一系列二元函数。第一个函数接受一个 int 参数并返回另一个函数,实际上将 x 绑定到特定值。此函数还接受一个 int 参数,您可将该参数看作是要绑定到 y 的值。调用此第二个函数后,x 和 y 均已绑定,因此结果是 double 的主体中定义的 x 和 y 的积。

为了创建 double,将对一系列 multiply 函数中的第一个函数进行求值,以部分应用 multiply。将为生成的函数指定名称 double。在对 double 进行求值时,该函数使用其参数以及部分应用的值来创建结果。

使用 F# 和功能性编程

既然您已了解了足够的词汇来开始使用 F# 和功能性编程,那么,对于下一步要进行的操作,您有很多选择。

利用 F# Interactive,您可以浏览 F# 代码并快速构建 F# 脚本。对于验证有关 .NET 库函数行为的日常问题,它也十分有用,而无需求助于帮助文件或 Web 搜索。

F# 擅长于表述复杂的算法,因此您能够将应用程序这些部分封装到可从其他 .NET 语言调用的 F# 库中。在工程应用程序中或多线程情况下,这一点特别有用。

最终,您甚至能够不编写 F# 代码而在日常 .NET 开发中使用功能性编程技术。使用 LINQ,而不是 for 或 foreach 循环。尝试使用委托来创建高阶函数。限制命令式编程中可变性和副作用的使用。一旦您开始以功能性风格编写代码,将很快会发现自己希望编写更多 F# 代码。                     

Chris Marinos 是密歇根州 Ann Arbor 市 SRT Solutions 公司的软件顾问。您可以在 Ann Arbor 地区举行的活动上或他的博客(srtsolutions.com/blogs/chrismarinos)上听到他讨论 F#、功能性编程以及其他令人兴奋的主题。

衷心感谢以下技术专家,感谢他们审阅了本文:Luke Hoban