F#奇妙游(24):函数式编程与代数数据类型ADT
ADT与表达式
这篇主要是写函数式编程中的元素与ADT的关系,特别的就是关于表达式的讨论。在函数式编程中,表达式也是一个比较重要概念。下图是Scott Wlaschin关于F#核心概念的图。
区别于面向过程的程序主要由语句(Statement)构成,函数式程序的主体是表达式(Expression)。语句和表达式的主要区别是,表达式是一个值,在函数式程序中,把一个表达式替代为它的值是没有问题的,因此值和表达式通常也会交叉使用。而语句则是依靠其副作用存在,例如,在一个语句中,把一个表达式或者值绑定到一个变量名称;或者把一个值打印在屏幕上;或者给一个对象发送一个消息,对象根据消息改变其内部状态。语句在某些程序设计语言中可能会返回一个值,但是语句的返回值通常是没有意义的。
对于一个值(表达式)而言,它会有一个固定的类型。编译期或者解释器可以根据这个类型来检查程序的正确性。
在F#中,let x = 1 + 2
就是一个语句,其中等号的右边是一个表达式,它的类型是int
,而这个语句的作用是把一个值1+2
绑定到变量名x
上。这里把1+2
替换成它的值3
是没有问题的,因此let x = 3
跟上面那个语句是等价的。
我们在上一篇中讨论了值/表达式的类型在函数式编程中抽象为ADT来分析,并且对函数将一个值映射为另一个值的内涵也进行了讨论。并且还明确了函数同样是一个ADT。那么F#中函数的定义就更好的体现了这一点。
let add x y = x + y
这里的函数定义中,x + y
同样是一个表达式,等号的左边就是函数的名称。
通过表达式、类型、ADT和函数的关系,可以把函数式编程中的核心概念串在一起。
函数与函数式
如果我们在dotnet fsi
中输入上面的add
函数的定义,会得到如下的结果。
> let add x y = x + y;;
val add : x:int -> y:int -> int
val
在F#中,在签名中用于表示值,或在类型中用于声明成员。这里的add
是一个值,它的类型是x:int -> y:int -> int
。
->
是函数类型的符号,代表映射的意思,它的右边是函数的返回值类型,而左边是函数的参数类型。这里的add
函数的类型是int -> int -> int
,它的意思是,这个函数接受两个int
类型的参数,返回一个int
类型的值。这里的->
是右结合的,因此int -> int -> int
等价于int -> (int -> int)
,也就是说,add
函数接受一个int
类型的参数,返回一个函数,这个函数接受一个int
类型的参数,返回一个int
类型的值。
如果在数学的意义上,这个函数是
N
×
N
→
N
\N \times \N \to \N
N×N→N的,也就是说,它接受两个自然数,返回一个自然数。当我们进行领域建模时,如果我们针对的领域就是数学领域,比如我们就是要实现数论的一个库、或者代数领域的一个库,那么这个函数跟领域(数学)的概念是完全对应的。实际上,(+)
操作符就是这样的一个函数,它的类型是int -> int -> int
。
> (+);;
val it: (int -> int -> int) = <fun:it@2>
我们可以直接把表达式(值)(+)
绑定到一个变量上,这个变量的类型就是int -> int -> int
,也就是说,这个变量是一个函数。
> let add = (+);;
val add : (int -> int -> int)
所以说,函数式一等公民的意思就是,函数可以像值一样被绑定到一个变量上,也可以作为参数传递给另一个函数,或者作为另一个函数的返回值。要在程序设计语言中实现这样的语法,其核心的概念就是函数作为一个值(表达式),它的类型也被作为一个ADT来定义。
我们回头再来考虑C语言,C语言程序是一些列语句(Statement)的顺序集合。但是C语言中,也存在表达式的概念,还能够定义函数,也能利用指针来传递函数,或者把函数指针作为参数传递给另外一个函数。
为什么C语言是面向过程的呢?而F#中可以进行函数式编程呢?
我们可以利用ADT的一套理论来分析C语言中的表达式、变量、函数;也能把函数指针与函数在语义上等同起来。那么C语言可能进行函数式编程吗?
这个问题的答案毫无疑问是肯定的。可以用C语言进行函数式编程,进一步限制C语言中的灵活性,不去进行显式的内存操作,主要使用没有副作用的函数,肯定是没问题的。在进行程序设计时,使用ADT来分析领域的数据,把领域中的数据转换同样采用ADT来设计,也能用C语言编出函数式的代码。
所以实际上,函数式编程和过程式编程的区别在于抽象的层次。只要把C语言中跟计算机体系结构紧密相关的概念屏蔽掉,就能应用函数式编程的范式进行编程。
只不过,C语言在函数式编程范式的语法糖上面没有提供很好的支持,C语言的编译器也没有利用ADT来进行类型自动匹配和类型检查。
而F#这类的编程语言,整个语法结构和编译器都是围绕函数式编程的核心概念来设计的,因此,F#这类的编程语言在进行函数式编程时,能够提供更好的支持。
回到表达式
接着上面的讨论,F#中对构造表达式提供了更多的支持。
例如分支表达式,最简单的if
表达式。
> if 1 = 2 then 1 else 2;;
val it : int = 2
在C语言中同样有一个三元表达式。
int ret = 1 == 2 ? 1 : 2
当然,C语言中的三元表达式也可以用if
语句来替代,但是这里的if
语句是一个语句,它的返回值是没有意义的。
除了简单的双分支表达式,F#中还提供了多分支表达式match
。对这个表达式的讨论,我已经写了好几个帖子,这里不再赘述。
另外一个重要的语法构造,循环。在F#和C中,用for
实现的循环都是语句。
> for i in 1..10 do printfn "%d" i;;
for (int i = 1; i <= 10; i++) {
printf("%d\n", i);
}
只是,结合yield
关键字,F#中的for
语句可以用来构造序列。
> seq { for i in 1..10 do yield i };;
val it : seq<int> = seq [1; 2; 3; 4; 5; 6; 7; 8; 9; 10]
这里的seq
是一个序列,它的类型是seq<int>
,它的值是seq [1; 2; 3; 4; 5; 6; 7; 8; 9; 10]
。这里的语法特性是F#中另外一个主题,下次写到了再说。
另外,结合表达序列的ADT,F#实现了Array
,List
,Seq
等数据结构,并直接提供了针对这些数据机构的操作函数。
> Array.sum (seq { for i in 1..10 do yield i });;
val it : int = 55
这就是典型的函数式集合类型数据的表达式。当然利用C语言,也是可以实现类似的数据结构和操作的。
对于另外一个重要的数据类型,字符串,F#中也提供了表达式的支持。
> "Hello World!";;
val it : string = "Hello World!"
> String.length "Hello World!";;
val it : int = 12
> String.concat " " ["Hello"; "World!"];;
val it : string = "Hello World!"
F#的语法中,一切要素都是围绕表达式来设计的,这也是函数式编程的核心概念。表达式代表了一个值,而值的类型是一个ADT。
而函数表达式,也有一个特殊的语法,匿名函数。
> (fun x y -> x + y) 1 2;;
val it : int = 3
> let f = fun x y -> x + y;;
val f : (int -> int -> int)
表达式(值)和配套的ADT系统就是F#中的核心概念。
回到模式匹配
我感觉自己又到了函数式编程学习的一个关口,可能就是要筑基(上次感受到函数是编程就是值的编程,那肯定就是感气)。
类似于F#中这种功能强大的模式匹配,必然依赖于ADT的支持。特别是关于ADT分析中的组合数的部分,是模式匹配的数学基础,判定完整性和互斥性。
在F#中,因为函数也是一个ADT,因此模式匹配也可以用来匹配函数。
let apply (func: obj) (args: int list) =
match func with
| :? (int -> int) as f -> f args[0]
| :? (string -> int) as f -> f $"{args[0]}"
| :? (int -> int -> int) as f -> f args[0] args[1]
| _ -> failwith "unknown function type"
apply (fun x -> x + 1) [ 1 ]
apply (fun (s: string) -> s.Length) [ 123; 4567 ]
apply (fun x y -> x + y) [ 1; 2 ]
其他各种类型的模式匹配,都可以采用ADT进行分析和考虑。一旦采用这个视角,各类模式匹配的模式就清楚多了,因为可以计算组合数,并且将各个分支的组合数与被匹配对象的组合数进行对照分析。
特别的,对主动模式,前面提到,主动模式实际上是一个类似于函数的存在,并且可以作为函数进行直接调用,在fsi
中可以看到主动模式的类型就是ADT -> ADT
。这就是主动模式的内在本质,对被匹配的对象(ADT)调用主动模式函数,转化为另一个ADT,这个ADT用于进行匹配。
结论
- ADT是函数式编程的基石;
- 理解和实现函数式编程语言,比如模式匹配,必然需要做ADT分析;
- ADT可能会是切入函数式语言软件设计的关键点;
- 如果联系到领域驱动设计的概念,ADT的作用就更加清晰了。