F#奇妙游(24):函数式编程与代数数据类型ADT

ADT与表达式

这篇主要是写函数式编程中的元素与ADT的关系,特别的就是关于表达式的讨论。在函数式编程中,表达式也是一个比较重要概念。下图是Scott Wlaschin关于F#核心概念的图。

函数式编程
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和函数的关系,可以把函数式编程中的核心概念串在一起。

表达式
类型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×NN的,也就是说,它接受两个自然数,返回一个自然数。当我们进行领域建模时,如果我们针对的领域就是数学领域,比如我们就是要实现数论的一个库、或者代数领域的一个库,那么这个函数跟领域(数学)的概念是完全对应的。实际上,(+)操作符就是这样的一个函数,它的类型是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#实现了ArrayListSeq等数据结构,并直接提供了针对这些数据机构的操作函数。

> 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用于进行匹配。

结论

  1. ADT是函数式编程的基石;
  2. 理解和实现函数式编程语言,比如模式匹配,必然需要做ADT分析;
  3. ADT可能会是切入函数式语言软件设计的关键点;
  4. 如果联系到领域驱动设计的概念,ADT的作用就更加清晰了。
posted @ 2023-08-28 08:57  大福是小强  阅读(71)  评论(0编辑  收藏  举报  来源