我希望能在 C# 7 中写出的优雅代码






4.59/5 (11投票s)
现在开始学习函数式编程范例!
引言
在我的上一篇文章《C# 7 即将迎来更好的函数式编程支持》中,我主张,当你有机会用 C# 7 编写代码时,你应该已经熟悉了函数式编程范式。但由于你目前还不能使用 C# 7,所以你绝对应该考虑用 F# 来学习函数式编程的概念。我希望我们能够像在 F# 中一样,在 C# 中编写优雅的声明式代码。
很难说服别人花时间去尝试新事物而看不到立竿见影的好处。但好处通常以惯用语言特性形式出现,这些特性起初可能令人望而生畏。我希望这篇文章能通过一个真实的F#代码示例,并逐一理解其特性,向您展示函数式编程的立竿见影的好处。我无意介绍F#的所有特性——只介绍足以让您对函数式编程感兴趣的特性。
最近我一直在看 Pluralsight 课程 F# Fundamentals by Liam McLennan,偶然发现了这段 精心设计的代码(快进到 0:46)
// F# Quicksort
let rec quicksort = function
| [] -> []
| x :: xs ->
let smaller = List.filter ((>) x) xs
let larger = List.filter ((<=) x) xs
quicksort smaller @ [x] @ quicksort larger
这6行代码所包含的功能之多,令我惊叹不已!如果你是函数式编程特别是F#的新手,可能很难体会到这段代码的精妙之处,所以让我们逐个特性地回顾一下,是什么让这段代码如此优雅和强大。
如果您好奇 C# 中的快速排序实现是什么样子,请滚动到文章底部。
为了清晰和简单,我将只介绍与上述代码相关的特性,故意省略一些特性。例如,当我说“在 F# 中,值是不可变的”时,我省略了它们默认是不可变的,但你可以使用 **mutable** 修饰符或引用单元格等等。
函数式语言
一种语言要被认为是“函数式的”,通常需要支持几个关键特性:
- 不可变数据
- 函数组合能力
- 函数可以作为数据处理
- 模式匹配
- 惰性求值
在本文中,我们将快速了解前四个特性。
Let 绑定
在F#中没有变量。您定义一个值并同时为其指定一个名称(标识符)。之后,您不能再为该名称赋新值。
要创建一个值,您可以使用 `let` 关键字通过 *let 绑定* 来实现。
// Creating a value via let binding
let x = 1
// F# Interactive output
val x : int = 1
上面那行代码创建了一个不可变的标识符 **x**,其值为 1。此外,F# 的类型推断系统推断出 **x** 的类型为 **int**。
F# 将函数视为数据,其值是函数体。要创建函数,同样使用 *let 绑定*
// Creating a function via let binding
// function declaration
let square x = x * x
// function signature in F# Interactive output
val square : x:int -> int
F# 中没有 **return** 关键字。函数的结果是最后计算的表达式。
注意函数签名中的箭头,它表示“这是一个函数,它接受一个类型为 **int** 的单值 **x** 并返回一个类型为 **int** 的值”。同样,是 F# 类型推断系统推断出函数的输入和输出值都将是 **int** 类型。
如果函数有多个参数,我们用空格将它们隔开
// Creating a function via let binding
// function declaration
let add x y = x + y
// function signature in F# Interactive output
val add : x:int -> y:int -> int
注意函数签名中有两个箭头,表示“这是一个函数,它接受两个值——**x** 和 **y**,它们都是 **int** 类型,并返回一个 **int** 类型的值”。
但更准确地说,它表示“这是一个函数,它接受一个 **int** 类型的 **x** 值,并返回一个函数,该函数接受一个 **int** 类型的 **y** 值,并返回一个 **int** 类型的值”。是不是有点糊涂了?继续阅读!
理解函数签名
当你查看函数的签名(而不是定义!)时,将每个箭头视为一个独立的函数,其输入在左侧,输出在右侧。
所以,当你看到 `typeA` **->** `typeB` 时,把它看作一个函数,它接受 `typeA` 类型的输入值并产生 `typeB` 类型的值。
同样,当你看到 `typeA` **->** `typeB` **->** `typeC` 时,你数一下箭头,然后从右到左在脑海中创建两个函数:`typeB` **->** `typeC` 是一个函数,它接受 `typeB` 类型的输入并产生 `typeC` 类型的值。你可以将这个函数命名为 f2。然后,`typeA` **->** `typeB` 是一个函数,它接受 `typeA` 类型的输入并产生 `typeB` 类型的值。你可以将这个函数命名为 f1。所以,你得到 `f1` -> `f2`。你可以将这两个函数连接起来,因为第一个函数的输出类型与第二个函数的输入类型相同。
函数应用
在 F# 中,您不说“我**调用**一个带参数 x 和 y 的函数 f”,而是说“我**应用**一个函数 f 到参数 x 和 y”。
所以,这里我们将函数 **square** 应用到类型为 **int** 的值 **3**。
// Function application
let squareResult = square 2
// F# Interactive output
val squareResult : int = 4
正如你所看到的,将 square 函数应用到其参数的结果是一个 int 类型的值。
这里我们将函数 **add** 应用到值 **3** 和 **5**,它们都是 **int** 类型。
// Function application
let addResult = add 3 5
// F# Interactive output
val addResult : int = 8
同样,将函数 add 应用于其参数的结果是一个 int 类型的值。
部分函数应用
当你应用函数但没有指定所有参数时会发生什么?换句话说——你将函数应用于部分参数集?在命令式语言中你会得到一个错误,但在 F# 中你会得到一个部分应用的函数。
让我们看看当我们只将函数 add 应用于它的第一个参数时会得到什么。
// Partial function application
let addThreeTo = add 3
// addThreeTo signature in F# Interactive output
val addThreeTo : (int -> int)
你注意到箭头了吗?它是一个函数!它的签名略有不同——周围有括号,但它仍然是一个函数。
因此,**addThreeTo** 是一个部分应用的函数 **add**,其中值 **3** “嵌入”到它的第一个参数中。从签名我们可以看到,它是一个 **函数**,它接受一个 **int** 类型的参数并返回一个 **int** 类型的值。由于它是一个函数——我们可以将其应用于其他参数!
// Applying partially applied function to its arguments
addThreeTo 1
// F# Interactive output
val it : int = 4
您可以清楚地看到,将部分应用的函数应用于其参数的结果只是一个普通的 **int** 类型值。
匿名函数 / Lambda 表达式
之前,当我们定义函数体时,我们将其绑定到一个标识符,以便稍后可以使用它。但如果我们要将代码捆绑在一起仅用于局部应用,并且不打算稍后重用或不想污染命名空间,我们可以使用匿名函数,也称为 lambda 表达式,来内联创建函数。
要创建 lambda 表达式,您使用 **fun** 关键字,后跟函数参数和一个箭头。因此,我们可以使用以下两个 lambda 表达式代替我们的 **square** 和 **add** 函数
// Lambda expression
(fun x -> x * x) 2
// F# Interactive output
val it : int = 4
和
// Lambda expression
(fun x y -> x + y) 3 5
// F# Interactive output
val it : int = 8
高阶函数
因为在F#中,函数只是一个值,所以其他函数,称为高阶函数,可以接受函数作为参数并将其视为返回值。
假设我们想有一个函数,它将不同的算法应用于两个值。我们不知道那个算法会是什么,我们只知道它接受两个值。因此,参数 **f** 将是一个函数(一个算法),我们将它应用于参数 **x** 和 **y**。让我们使用 lambda 表达式来指定不同的算法。
// Higher-order functions
let applyAlgorithm f x y = f x y
applyAlgorithm (fun x y -> x + y) 3 5
// F# Interactive output
val it : int = 8
applyAlgorithm (fun x y -> x * y) 3 5
// F# Interactive output
val it : int = 15
来点乐趣怎么样!如果我们要部分地将算法应用于它的第一个参数呢?令人费解,但在 F# 中却轻而易举!
let multiplyByThree = applyAlgorithm (fun x y -> x * y) 3
multiplyByThree 5
// F# Interactive output
val it : int = 15
multiplyByThree 6
// F# Interactive output
val it : int = 18
multiplyByThree 7
// F# Interactive output
val it : int = 21
F# 语法糖
如果 lambda 表达式只有一个参数,并且它被用作函数体中的最后一个参数,您可以省略 **fun** 关键字、**箭头**、**参数** 和 **参数本身**。
// F# syntactic sugar
let formatOutput f x = f x
formatOutput (fun x -> sprintf "The value is %d" x) 5
formatOutput (sprintf "The value is %d") 5
// F# Interactive output
val it : string = "The value is 5"
符号运算符
当你应用一个函数时,你使用所谓的 **后缀** 表示法,这意味着你应用函数的参数跟在函数名称之后。
// Postfix notation
add 1 2
但是使用后缀表示法表达一些数学公式会相当难以阅读
// Postfix notation
(mult (add 1 2) (add 3 4))
为了这些目的,您可以创建一个符号运算符——一个函数,其名称由以下符号的任意序列组成:**!%&*+-./@^|?**
在函数定义中,您需要将这些符号放在括号中。
// Symbolic operator
let (@%@) x y = x + y
// F# Interactive output
val ( @%@ ) : x:int -> y:int -> int
// yep, it's nothing more than just a function
默认情况下,当您将符号运算符应用于其参数时,您使用 **中缀** 表示法,这意味着您将第一个参数放在符号运算符之前,第二个参数放在符号运算符之后。
// Symbolic operator - infix notation
3 @%@ 4
// F# Interactive output
val it : int = 7
如果你想在应用符号运算符时使用后缀表示法——把它放在括号里。
// Symbolic operator - postfix notation (@%@) 3 4 // F# Interactive output val it : int = 7
当您想要将符号运算符部分应用于参数时,您可以使用后缀表示法。
// Partially applied symbolic operator let partiallyAppliedSymbolicOperator = (@%@) 3 // F# Interactive output val partiallyAppliedSymbolicOperator : (int -> int) partiallyAppliedSymbolicOperator 4 // F# Interactive output val it : int = 7
递归函数
要定义一个可以调用自身的函数,请使用 **rec** 关键字。
// Recursive function
let rec factorial n =
if n < 0 then
failwith "The value n cannot be negative"
elif n <= 1 then
1
else
n * factorial (n - 1)
factorial 5
// F# Interactive output
val it : int = 120
F# 列表
在 F# 中,列表是以分号分隔的不可变值集合,用方括号括起来。值必须是相同类型。
// F# list
let numbers = [1; 2; 3; 4]
// F# Interactive output
val numbers : int list = [1; 2; 3; 4]
要访问列表元素,请使用基于零的点表示法。
// F# list
let secondNumber = numbers.[1]
// F# Interactive output
val secondNumber : int = 2
空列表用空方括号表示。
// F# list
[]
// F# Interactive output
val it : 'a list = []
您只能对列表执行两种操作。第一种是 **cons**(由 cons 运算符 **::** 表示),它将一个元素连接到列表的开头或头部,生成一个新列表。第二种是 **append**(由 **@** 运算符表示),它将两个列表连接在一起,生成一个新列表。
// F# list cons operation
let smallerThanFive = 0 :: numbers
// F# Interactive output
val smallerThanFive : int list = [0; 1; 2; 3; 4]
// F# list append operation
let largerThanFive = [6; 7; 8; 9]
let allNumbers = smallerThanFive @ [5] @ largerThanFive
// F# Interactive output
val allNumbers : int list = [0; 1; 2; 3; 4; 5; 6; 7; 8; 9]
模式匹配
模式匹配是函数式语言中强大而复杂的特性之一。你可以把它看作一个强大的 switch 语句,它可以匹配输入值的类型,提取值,执行一些操作等等。
在以下代码中,**match** 表达式试图将 **number** 参数与常量模式 **1**、**2** 或变量模式 **n** 进行匹配。
// Pattern matching
let describeNumber number =
match number with
| 1 -> printfn "The number is 1"
| 2 -> printfn "The number is 2"
| n -> printfn "The number is %d" n
describeNumber 1
// The number is 1
describeNumber 2
// The number is 2
describeNumber 5
// The number is 5
在以下代码中,**match** 表达式试图将 **list** 参数与列表模式 **[]**、**[_]**、**[_;_]** 或通配符模式 **_** 进行匹配,以获取列表中元素的数量。
// Pattern matching
let describeList list =
match list with
| [] -> printfn "Empty list"
| [_] -> printfn "The list with one element"
| [_;_] -> printfn "The list with two elements"
| _ -> printfn "The list with %d elements" (List.length list)
describeList []
// Empty list
describeList [1]
// The list with one element
describeList [1; 2]
// The list with two elements
describeList [1; 2; 3; 4; 5; 6]
// The list with 6 elements
在以下代码中,**match** 表达式试图将 **list** 参数与列表模式 **[]** 和 cons 模式 **h :: t** 进行匹配,将列表分成头部元素和尾部列表。
// Pattern matching
let splitList list =
match list with
| [] -> printfn "Empty list"
| head :: tail -> printfn "head: %A\ntail: %A" head tail
splitList []
// Empty list
splitList [1]
// head: 1
// tail: []
splitList [1; 2]
// head: 1
// tail: [2]
splitList [1; 2; 3; 4; 5; 6]
// head: 1
// tail: [2; 3; 4; 5; 6]
您还可以使用模式匹配来代替 **if** 语句。我们可以使用带有 **when guard** 的模式匹配重写阶乘算法。
// Pattern matching in recursive function
let rec factorial n =
match n with
| n when n < 0 -> failwith "The value n cannot be negative"
| n when n <= 1 -> 1
| n -> n * factorial (n - 1)
factorial 5
// F# Interactive output
val it : int = 120
带有模式匹配的 `function` 关键字
您可以使用简化的 lambda 语法与模式匹配。请注意,在这种情况下,您没有显式指定函数参数,因为 **function** lambda 只接受一个直接进入模式匹配表达式的参数。
// Pattern matching in recursive function
let rec factorial = function
| n when n < 0 -> failwith "The value n cannot be negative"
| n when n <= 1 -> 1
| n -> n * factorial (n - 1)
factorial 5
// F# Interactive output
val it : int = 120
List 模块函数
在 F# 中,**模块** 是 F# 代码的分组。List 模块包含您可以应用于 F# 列表的函数。让我们来看其中的几个。
List.length – 给定一个列表,返回其长度
List.length;;
// ('a list -> int)
List.length [1; 2; 3];;
// val it : int = 3
List.iter – 给定一个函数和一个列表,遍历列表并将函数应用于每个元素。
List.iter;;
// (('a -> unit) -> 'a list -> unit)
List.iter (fun n -> printfn "Value: %d" n) [1; 2; 3];;
// Value: 1
// Value: 2
// Value: 3
// or you could re-write it as
List.iter (printfn "Value: %d") [1; 2; 3];;
List.filter – 给定一个函数和一个列表,遍历列表并将该函数应用于每个元素,如果函数返回 false,则从结果列表中消除这些元素。
List.filter;;
// (('a -> bool) -> 'a list -> 'a list)
List.filter (fun n -> 3 > n) [1; 2; 3; 4; 5];;
// val it : int list = [1; 2]
// or you could re-write it
// using symbolic operator postfix notation
List.filter ((>) 3) [1; 2; 3; 4; 5]
// val it : int list = [1; 2]
将所有内容整合
此时,您应该已经掌握了理解和欣赏文章开头代码所需的一切。为了方便起见,我将再次在这里重复一遍。
// F# Quicksort
let rec quicksort = function
| [] -> []
| x :: xs ->
let smaller = List.filter ((>) x) xs
let larger = List.filter ((<=) x) xs
quicksort smaller @ [x] @ quicksort larger
let sorted = quicksort [4;5;4;7;9;1;6;1;0;-99;10000;3;2]
// F# Interactive output
// val sorted : int list = [-99; 0; 1; 1; 2; 3; 4; 4; 5; 6; 7; 9; 10000]
通过查看这种声明式代码,很容易看出快速排序是一个应用于列表的递归函数。该函数使用模式匹配将列表分成头部和尾部,然后创建两个未排序的列表,其中包含比头部小和大的数字,将函数本身应用于这些列表,最后一步——将这些现在已排序的列表与头部拼接起来,生成最终的已排序列表。呼……
C# 中快速排序算法的命令式实现
来自 Pluralsight 课程 F# Fundamentals by Liam McLennan 的 C# 快速排序(快进到 3:02)
// C# Quicksort
public static void Quicksort(int[] elements, int left, int right) {
int i = left, j = right; int pivot = elements[(left + right) / 2];
while (i <= j) {
while (elements[i] < pivot) { i++; }
while (elements[j] > pivot) { j--; }
if (i <= j) {
int tmp = elements[i];
elements[i] = elements[j];
elements[j] = tmp;
i++; j--;
}
}
if (left < j) { Quicksort(elements, left, j); }
if (i < right) { Quicksort(elements, i, right); }
}