F# 编程/基本概念
F#:基本概念 |
现在我们已经拥有了 F# 的工作安装环境,我们可以探索 F# 的语法和函数式编程的基础知识。我们将从 F# 交互式环境开始,因为它提供了一些非常有价值的类型信息,这有助于我们理解 F# 中实际发生了什么。从开始菜单打开 F# 交互式环境,或打开命令行提示符并键入 fsi
。
在计算机编程中,每一段数据都有一个类型,它描述了程序员正在使用的数据的类型。在 F# 中,基本数据类型是
F# 类型 | .NET 类型 | 大小 | 范围 | 示例 | 表示 |
---|---|---|---|---|---|
整数类型 | |||||
sbyte
|
System.SByte
|
1 字节 | -128 到 127 | 42y -11y
|
8 位有符号整数 |
byte
|
System.Byte
|
1 字节 | 0 到 255 | 42uy 200uy
|
8 位无符号整数 |
int16
|
System.Int16
|
2 字节 | -32768 到 32767 | 42s -11s
|
16 位有符号整数 |
uint16
|
System.UInt16
|
2 字节 | 0 到 65,535 | 42us 200us
|
16 位无符号整数 |
int /int32 |
System.Int32
|
4 字节 | -2,147,483,648 到 2,147,483,647 | 42 -11
|
32 位有符号整数 |
uint32
|
System.UInt32
|
4 字节 | 0 到 4,294,967,295 | 42u 200u
|
32 位无符号整数 |
int64
|
System.Int64
|
8 字节 | -9,223,372,036,854,775,808 到 9,223,372,036,854,775,807 | 42L -11L
|
64 位有符号整数 |
uint64
|
System.UInt64
|
8 字节 | 0 到 18,446,744,073,709,551,615 | 42UL 200UL
|
64 位无符号整数 |
bigint
|
System.Numerics.BigInteger
|
至少 4 字节 | 任何整数 | 42I 14999999999999999999999999999999I
|
任意精度整数 |
浮点类型 | |||||
float32
|
System.Single
|
4 字节 | ±1.5e-45 到 ±3.4e38 | 42.0F -11.0F
|
32 位有符号浮点数(7 位有效数字) |
float
|
System.Double
|
8 字节 | ±5.0e-324 到 ±1.7e308 | 42.0 -11.0
|
64 位有符号浮点数(15-16 位有效数字) |
decimal
|
System.Decimal
|
16 字节 | ±1.0e-28 到 ±7.9e28 | 42.0M -11.0M
|
128 位有符号浮点数(28-29 位有效数字) |
BigRational
|
Microsoft.FSharp.Math.BigRational
|
至少 4 字节 | 任何有理数。 | 42N -11N
|
任意精度有理数。使用此类型需要引用 FSharp.PowerPack.dll。 |
文本类型 | |||||
char
|
System.Char
|
2 字节 | U+0000 到 U+ffff | 'x' '\t'
|
单个 Unicode 字符 |
string
|
System.String
|
20 + (2 * 字符串长度) 字节 | 0 到大约 20 亿个字符 | "Hello" "World"
|
Unicode 文本 |
其他类型 | |||||
bool
|
System.Boolean
|
1 字节 | 只有两个可能的值,true 或 false |
true false
|
存储布尔值 |
F# 是一种完全面向对象的语言,使用基于 .NET 通用语言基础结构 (CLI) 的对象模型。因此,它具有单继承、多接口对象模型,并允许程序员声明类、接口和抽象类。值得注意的是,它完全支持泛型类、接口和函数定义;但是,它缺少其他语言中发现的一些 OO 特性,例如 mixin 和多重继承。
F# 还提供了一系列独特的数据结构,这些数据结构直接构建到语言的语法中,包括
- Unit,只包含一个值的类型,等同于 C 语言中
void
。 - 元组类型,这是程序员可以用来将相关值组合到单个对象中的临时数据结构。
- 记录类型,它们类似于元组,但提供命名字段来访问记录对象保存的数据。
- 区分联合,用于创建定义明确的类型层次结构和层次数据结构。
- 列表、映射 和 集合,它们分别表示堆栈、哈希表和集合数据结构的不可变版本。
- 序列,它们表示按需计算的项目的延迟列表。
- 计算表达式,它们在 Haskell 中与 monad 有相同的用途,允许程序员以命令式风格编写延续式代码。
所有这些特性将在本书的后续章节中进一步列举和解释。
F# 是一种静态类型语言,这意味着编译器在编译时知道变量和函数的数据类型。F# 也是强类型语言,这意味着绑定到 int
的变量不能在以后的某个时刻重新绑定到 string
;一个 int
变量永远与 int
数据绑定。
与 C# 和 VB.Net 不同,F# 不执行隐式转换,即使是安全转换(例如将 int
转换为 int64
)。F# 需要显式转换才能在数据类型之间进行转换,例如
> let x = 5;;
val x : int = 5
> let y = 6L;;
val y : int64 = 6L
> let z = x + y;;
let z = x + y;;
------------^
stdin(5,13): error FS0001: The type 'int64' does not match the type 'int'
> let z = (int64 x) + y;;
val z : int64 = 11L
数学运算符 +, -, /, *,
和 %
被重载以处理不同的数据类型,但它们要求运算符两侧的参数具有相同的数据类型。我们尝试将 int
添加到 int64
时会遇到错误,因此我们必须在程序成功编译之前将上面一个变量中的一个强制转换为另一个变量的数据类型。
与许多其他强类型语言不同,F# 通常不需要程序员在声明函数和变量时使用类型标注。相反,F# 会尝试根据变量在代码中的使用方式自行推断出类型。
例如,让我们来看这个函数
let average a b = (a + b) / 2.0
我们没有使用任何类型标注:也就是说,我们没有明确地告诉编译器 a
和 b
的数据类型,也没有指示函数返回值的类型。如果 F# 是一种强类型、静态类型语言,编译器如何在事先不知道任何数据类型的情况下知道任何事物的数据类型?这很简单,它使用简单的推断
+
和/
运算符被重载以处理不同的数据类型,但在没有额外信息的情况下,它默认为整数加法和整数除法。(a + b) / 2.0
,粗体值类型为float
。由于 F# 不执行隐式转换,并且它要求运算符两侧的参数具有相同的数据类型,因此值(a + b)
也必须返回float
。+
运算符只有在运算符两侧的参数都是float
时才会返回float
,因此a
和b
也必须是float
。- 最后,由于
float / float
的返回值是float
,因此average
函数必须返回一个float
。
这个过程称为类型推断。在大多数情况下,F# 能够自行推断出数据类型,而无需程序员明确写出类型标注。这对小型程序和大型程序一样有效,并且可以节省大量时间。
在 F# 无法正确推断出类型的情况下,程序员可以提供显式标注来指导 F# 朝正确的方向前进。例如,如上所述,数学运算符默认为对整数的运算
> let add x y = x + y;;
val add : int -> int -> int
在没有其他信息的情况下,F# 确定 add
接受两个整数并返回另一个整数。如果我们想使用 float
,我们会写
> let add (x : float) (y : float) = x + y;;
val add : float -> float -> float
F# 的模式匹配类似于其他语言中的 if... then
或 switch
结构,但功能更加强大。模式匹配允许程序员将数据结构分解为其组成部分。它根据数据结构的形状匹配值,例如
type Proposition = // type with possible expressions ... note recursion for all expressions except True
| True // essentially this is defining boolean logic
| Not of Proposition
| And of Proposition * Proposition
| Or of Proposition * Proposition
let rec eval x =
match x with
| True -> true // syntax: Pattern-to-match -> Result
| Not(prop) -> not (eval prop)
| And(prop1, prop2) -> (eval prop1) && (eval prop2)
| Or(prop1, prop2) -> (eval prop1) || (eval prop2)
let shouldBeFalse = And(Not True, Not True)
let shouldBeTrue = Or(True, Not True)
let complexLogic =
And(And(True,Or(Not(True),True)),
Or(And(True, Not(True)), Not(True)) )
printfn "shouldBeFalse: %b" (eval shouldBeFalse) // prints False
printfn "shouldBeTrue: %b" (eval shouldBeTrue) // prints True
printfn "complexLogic: %b" (eval complexLogic) // prints False
eval
方法使用模式匹配递归地遍历和评估抽象语法树。rec
关键字将函数标记为递归函数。模式匹配将在本书的后续章节中详细解释。
函数式编程与命令式编程对比
[edit | edit source]F# 是一种混合范式语言:它支持命令式、面向对象和函数式编程风格,其中对函数式编程风格的重视程度最高。
不可变值与变量
[edit | edit source]初学者接触函数式编程时常犯的第一个错误,就是认为 let 语句等同于赋值。请考虑以下代码
let a = 1
(* a is now 1 *)
let a = a + 1
(* in F# this throws an error: Duplicate definition of value 'a' *)
从表面上看,这与我们熟悉的命令式伪代码完全一样
a = 1 // a is 1 a = a + 1 // a is 2
然而,F# 代码的本质却截然不同。每个 let 语句都会引入一个新的作用域,并在该作用域内将符号绑定到值。如果执行超出此引入的作用域,则符号将恢复其原始含义。这显然与使用赋值进行变量状态修改不同。
为了澄清,让我们将 F# 代码进行反糖化
let a = 1 in
((* a stands for 1 here *);
(let a = (* a still stands for 1 here *) a + 1 in (* a stands for 2 here *));
(* a stands for 1 here, again *))
实际上,代码
let a = 1 in
(printfn "%i" a;
(let a = a + 1 in printfn "%i" a);
printfn "%i" a)
会输出
1 2 1
一旦符号绑定到值,就不能再为其分配新值。改变绑定符号含义的唯一方法是通过引入该符号的新绑定(例如,使用 let 语句,如 let a = a + 1
)来对其进行 遮蔽,但这只会产生局部效果:它只会影响新引入的作用域。F# 使用所谓的 "词法作用域",这意味着只需查看代码即可确定绑定的作用域。因此,(let a = a + 1 in ..)
中 let a = a + 1
绑定的作用域仅限于括号。使用词法作用域,代码片段无法更改其外部绑定的符号的值,例如在调用它的代码中。
不可变性是一个很棒的概念。不可变性允许程序员将值传递给函数,而不必担心函数会以不可预知的方式改变值的状态。此外,由于值不能被修改,程序员可以处理多个线程共享的数据,而不必担心数据会被其他进程修改;因此,程序员可以编写没有锁的多线程代码,可以消除与竞态条件和死锁相关的整类错误。
函数式程序员通常通过向函数传递额外的参数来模拟状态;对象通过创建具有所需更改的完全新实例来“修改”,并让垃圾收集器丢弃不再需要的旧实例。这种风格带来的资源开销可以通过结构共享来处理。例如,更改包含 1000 个整数的单链表的头部,只需分配一个新的整数,并重复使用原始链表的尾部(长度为 999)。
对于真正需要修改的罕见情况(例如,在性能瓶颈的数字运算代码中),F# 提供了引用类型和 .NET 可变集合(如数组)。
递归还是循环?
[edit | edit source]命令式编程语言倾向于使用循环来迭代集合
void ProcessItems(Item[] items)
{
for(int i = 0; i < items.Length; i++)
{
Item myItem = items[i];
proc(myItem); // process myItem
}
}
这可以直接转换为 F#(由于 F# 可以推断出 i
和 item
的类型注释,因此省略了这些注释)
let processItems (items : Item []) =
for i in 0 .. items.Length - 1 do
let item = items.[i] in
proc item
然而,上面的代码显然不是用函数式风格编写的。它的一个问题是它遍历了一个项目数组。对于包括枚举在内的许多目的,函数式程序员会使用不同的数据结构,即单链表。以下是用模式匹配迭代此数据结构的示例
let rec processItems = function
| [] -> () // empty list: end recursion
| head :: tail -> // split list in first item (head) and rest (tail)
proc head;
processItems tail // recursively enumerate list
需要注意的是,由于对 processItems
的递归调用作为函数中的最后一个表达式出现,这是一个所谓的 尾递归 示例。F# 编译器会识别出这种模式,并将 processItems
编译为一个循环。因此,processItems
函数以恒定空间运行,不会导致栈溢出。
F# 程序员依赖尾递归来构建他们的程序,只要这种技术有助于代码清晰度。
细心的读者会注意到,在上面的例子中,proc
函数来自环境。通过将此函数参数化(使 proc
成为一个参数),可以改进代码并使其更通用
let rec processItems proc = function
| [] -> ()
| hd :: tl ->
proc hd;
processItems proc tl // recursively enumerate list
这个 processItems
函数确实非常有用,它以 List.iter
的名称被纳入标准库。
为了完整起见,必须提到 F# 包含 List.iter
的通用版本,名为 Seq.iter
(其他 List.* 函数通常也有 Seq.* 对应项),它适用于列表、数组和所有其他集合。F# 还包含一个适用于所有实现 System.Collections.Generic.IEnumerable
的集合的循环构造
for item in collection do
process item
函数组合而不是继承
[edit | edit source]传统的 OO 广泛使用 实现继承;换句话说,程序员创建具有部分实现的基类,然后从基类构建对象层次结构,并在需要时覆盖成员。这种风格从 1990 年代初开始就证明非常有效,但这种风格与函数式编程并不一致。
函数式编程旨在构建简单、可组合的抽象。由于传统的 OO 只能使对象的接口变得更复杂,而不能使其更简单,因此继承在 F# 中很少使用。因此,F# 库往往类更少,对象层次结构更“扁平”,与在等效的 Java 或 C# 应用程序中发现的非常深且复杂的层次结构形成对比。
F# 倾向于更多地依赖对象组合和委托,而不是继承来跨模块共享实现片段。
函数作为一等类型
[edit | edit source]F# 是一种函数式编程语言,这意味着函数是一等数据类型:它们可以像任何其他变量一样声明和使用。
在像 Visual Basic 这样的命令式语言中,变量和函数之间传统上存在根本区别。
Function MyFunc(param As Integer)
MyFunc = (param * 2) + 7
End Function
' The program entry point; all statements must exist in a Sub or Function block.
Sub Main()
Dim myVal As Integer
' Also statically typed as Integer, as the compiler (for newer versions of VB.NET) performs local type inference.
Dim myParam = 2
myVal = MyFunc(myParam)
End Sub
请注意定义和评估函数与定义和赋值变量之间的语法差异。在前面的 Visual Basic 代码中,我们可以对变量执行许多不同的操作,我们可以
- 创建一个令牌(变量名)并将其与一个类型相关联
- 为它分配一个值
- 查询它的值
- 将其传递给函数或子例程(不返回值的函数)
- 从函数中返回它
函数式编程不区分值和函数,因此我们可以认为函数与所有其他数据类型相同。这意味着我们可以
- 创建一个令牌(函数变量名)并将其与一个类型相关联
- 为它分配一个值(实际计算)
- 查询它的值(执行计算)
- 将函数作为另一个函数或子例程的参数传递
- 返回函数作为另一个函数的结果
F# 程序的结构
[edit | edit source]一个简单的、非平凡的 F# 程序包含以下部分
open System
(* This is a
multi-line comment *)
// This is a single-line comment
let rec fib = function
| 0 -> 0
| 1 -> 1
| n -> fib (n - 1) + fib (n - 2)
[<EntryPoint>]
let main argv =
printfn "fib 5: %i" (fib 5)
0
大多数 F# 代码文件以一些 open
语句开头,这些语句用于导入命名空间,使程序员能够引用命名空间中的类,而无需编写完全限定的类型声明。这个关键字在功能上等同于 C# 中的 using
指令和 VB.Net 中的 Imports
指令。例如,Console
类位于 System
命名空间下;如果不导入命名空间,程序员将需要通过其完全限定名 System.Console
来访问 Console
类。
F# 文件的主体通常包含用于实现应用程序中业务逻辑的函数。
最后,许多 F# 应用程序表现出这种模式
[<EntryPoint>]
let main argv =
// Code to be executed
0
F# 程序的入口点由 [<EntryPoint>] 属性标记,其后必须是一个函数,该函数接受字符串数组作为输入,并返回一个整数(默认情况下为 0)。