文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

函数式编程在 Go 泛型下的实用性探索

2024-12-01 17:23

关注

究其原因,大部分的抱怨Golang 函数式编程简述[1] 、GopherCon 2020: Dylan Meeus - Functional Programming with Go[2]集中于 Go 缺乏对泛型的支持,难以写出类型间通用的函数。代码生成只能解决一部分已知类型的处理,且无法应对类型组合导致复杂度(比如实现一个通用的 TypeA → TypeB 的 map 函数)。

有关泛型的提案 spec: add generic programming using type parameters #43651[3] 已经被 Go 团队接受,并计划在 2022 年初发布支持泛型的 Go 1.18,现在 golang/go 仓库的 master 分支已经支持泛型。

基于这个重大特性,我们有理由重新看看,函数式特性在 Go 泛型的加持下,能否变得比以往更加实用。

概述

这篇文章里,我们会尝试用 Go 的泛型循序渐进地实现一些常见的函数式特性,从而探索 Go 泛型的优势和不足。

除非额外说明(例如注释中的 // INVALID CODE!!!),文章里的代码都是可以运行的(为了缩减篇幅,部分删去了 package main 声明和 main 函数,请自行添加)。你可以自行 从源码编译[5] 一个 master 版本的 go 来提前体验 Go 的泛型,或者用 The go2go Playground[6] 提供的在线编译器运行单个文件。

泛型语法

提案的 #Very high level overview[7] 一节中描述了为泛型而添加的新语法,这里简单描述一下阅读本文所需要的语法:

type Integer1 interface {
int
}
type Integer2 interface {
int | int8 | int16 | int32 | int64
}
type Integer3 interface {
~int
}

   「基础类型」在提案中为 “underlying type”,目前尚无权威翻译,在本文中使用仅为方便描述。

高阶函数

在函数式编程语言中,📖 高阶函数[8] (Higher-order function)是一个重要的特性。高阶函数是至少满足下列一个条件的函数:

Golang 支持闭包,所以实现高阶函数毫无问题:

func foo(bar func() string) func() string {
return func() string {
return "foo" + " " + bar()
}
}
func main() {
bar := func() string {
return "bar"
}
foobar := foo(bar)
fmt.Println(foobar())
}
// Output:
// foo bar

filter 操作是高阶函数的经典应用,它接受一个函数 f(func (T) bool)和一个线性表 l([] T),对 l 中的每个元素应用函数 f,如结果为 true,则将该元素加入新的线性表里,否则丢弃该元素,最后返回新的线性表。

根据上面的泛型语法,我们可以很容易地写出一个简单的 filter 函数:

func Filter[T any](f func(T "T any") bool, src []T) []T {
var dst []T
for _, v := range src {
if f(v) {
dst = append(dst, v)
}
}
return dst
}
func main() {
src := []int{-2, -1, -0, 1, 2}
dst := Filter(func(v int) bool { return v >= 0 }, src)
fmt.Println(dst)
}
// Output:
// [0 1 2]

代码生成之困

在 1.17 或者更早前的 Go 版本中,要实现通用的 Filter 函数有两种方式:

  1.  使用 interface{} 配合反射,牺牲一定程度的类型安全和运行效率
  2.  为不同数据类型实现不同的 Filter 变种,例如 FilterInt、FilterString 等,缺点在于冗余度高,维护难度大

方式 2 的缺点可以通过代码生成规避,具体来说就使用相同的一份模版,以数据类型为变量生成不同的实现。我们在 Golang 内部可以看到不少 代码生成的例子[9] 。

那么,有了代码生成,我们是不是就不需要泛型了呢?

答案是否定的:

  1. 代码生成只能针对已知的类型生成代码,明明这份模版对 float64 也有效,但作者只生成了处理 int 的版本,我们作为用户无能为力(用 interface{} 同理,我们能使用什么类型,取决于作者列出了多少个 type switch 的 cases)

     而在泛型里,新的类型约束语法可以统一地处理「基础类型」相同的所有类型:

type signed interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 | ~float32 | ~float64 | ~complex64 | ~complex128
}
func Neg[T signed](n T "T signed") T {
return -n
}
func main() {
type MyInt int
fmt.Println(Neg(1))
fmt.Println(Neg(1.1))
fmt.Println(Neg(MyInt(1)))
}
// Output:
// -1
// -1.1
// -1
  1. 代码生成难以应对需要类型组合的场景,我们来看另一个高阶函数 map:接受一个函数 f(func (T1) T2)和一个线性表 l1([]T1),对 l1 中的每个元素应用函数 f,返回的结果组成新的线性表 l2([]T2)

     如果使用代码生成的话,为了避免命名冲突,我们不得不写出 MapIntInt、MapIntUint、MapIntString 这样的奇怪名字,而且由于类型的组合,代码生成的量将大大膨胀。

     我们可以发现在现有的支持 FP 特性的 Go library 里:

     如果使用泛型的话,只需要定义这样的签名就好了:

   func Map[T1, T2 any](f func(T1 "T1, T2 any") T2, src []T1) []T2

无糖的泛型

Go 的语法在一众的编程语言里绝对算不上简洁优雅。在官网上看到操作 channel 时 <- 的直观便捷让你心下暗喜,而一旦你开始写 real world 的代码,这个语言就处处难掩设计上的简陋。泛型即将到来,而这个语言的其他部分似乎没有做好准备:

闭包语法

在 Haskell 中的匿名函数形式非常简洁:

filter (\x -> x >= 0) [-2, -1, 0, 1, 2]
-- Output:
-- [0,1,2]

而在 Golang 里,函数的类型签名不可省略,无论高阶函数要求何种签名,调用者在构造闭包的时候总是要完完整整地将其照抄一遍Golang 函数式编程简述[13]

func foo(bar func(a int, b float64, c string) string) func() string {
return func() string {
return bar(1, 1.0, "")
}
}
func main() {
foobar := foo(func(_ int, _ float64, c string) string {
return c
})
foobar()
}

这个问题可以归结于 Go 团队为了保持所谓的「大道至简」,而对类型推导这样提升效率降低冗余的特性的忽视(泛型的姗姗来迟又何尝不是如此呢?)。proposal: Go 2: Lightweight anonymous function syntax #21498[14] 提出了一个简化闭包调用语法的提案,但即使该提案被 accept,我们最快也只能在 Go 2 里见到它了。

方法类型参数

链式调用[15] (Method chaining)是一种调用函数的语法,每个调用都会返回一个对象,紧接着又可以调用该对象关联的方法,该方法同样也返回一个对象。链式调用能显著地消除调用的嵌套,可读性好。我们熟悉的 GORM 的 API 里就大量使用了链式调用:

db.Where("name = ?", "jinzhu").Where("age = ?", 18).First(&user)

在函数式编程中,每个高阶函数往往只实现了简单的功能,通过它们的组合实现复杂的数据操纵。

在无法使用链式调用的情况下,高阶函数的互相组合是这样子的(这仅仅是两层的嵌套):

Map(func(v int) int { return v + 1 },
Filter(func(v int) bool { return v >= 0 },
[]int{-2, -1, -0, 1, 2}))

如果用链式调用呢?我们继续沿用前面的 filter ,改成以下形式:

type List[T any] []T
func (l List[T]) Filter(f func(T) bool) List[T] {
var dst []T
for _, v := range l {
if f(v) {
dst = append(dst, v)
}
}
return List[T](dst "T")
}
func main() {
l := List[int]([]int{-2, -1, -0, 1, 2} "int").
Filter(func(v int) bool { return v >= 0 }).
Filter(func(v int) bool { return v < 2 })
fmt.Println(l)
}
// Output:
// [0 1]

看起来很美好,但为什么不用 map 操作举例呢?我们很容易写出这样的方法签名:

// INVALID CODE!!!
func (l List[T1]) Map[T2 any](f func(T1 "T1]) Map[T2 any") T2) List[T2]

很遗憾这样的代码是没法通过编译的,我们会获得以下错误:

提案的 #No parameterized methods[16] 一节明确表示了方法(method,也就是有 recevier 的函数)不支持单独指定类型参数:

这个决定实际上是个不得已的妥协。假设我们实现了上述的方法,就意味对于一个已经实例化了的 List[T] 对象(比如说 List[int]),它的 Map 方法可能有多个版本:Map(func (int) int) List[int] 或者 Map(func (int) string) List[string],当用户的代码调用它们时,它们的代码必然在之前的某个时刻生成了,那么应该在什么时候呢?

  1. 更准确地说,在编译的 link 阶段,这需要 linker 去遍历整个 call graph,确定程序中到底使用了几个版本的 Map。问题在于反射(reflection)的存在:用户可以用 reflect.MethodByName 动态地调用对象的方法,所以即使遍历了整个 call graph,我们也无法确保用户的代码到底调用了几个版本的 Map
  2. 在运行期,在第一次调用方法时 yield 到 runtime 中,生成对应版本的函数后 resume 回去,这要求 runtime 支持 JIT(Just-in-time compilation),而目前 Go 并不支持,即使未来 JIT 的支持提上日程,这也不是一蹴而就的事情

综上,Go 团队选择了不支持给 method 指定类型参数,完美了解决这个问题 🎉。

惰性求值

📖 惰性求值[18] (Lazy Evaluation)是另一个重要的函数式特性,一个不严谨的描述是:在定义运算时候,计算不会发生,直到我们需要这个值的时候才进行。其优点在于能使计算在空间复杂度上得到极大的优化。

下面的代码展示了一个平平无奇的 Add 函数和它的 Lazy 版本,后者在给出加数的时候不会立刻计算,而是返回一个闭包:

func Add(a, b int) int {
return a + b
}
func LazyAdd(a, b int) func() int {
return func () int {
return a + b
}
}

上面这个例子没有体现出惰性求值节省空间的优点。基于我们之前实现的高阶函数,做以下的运算:

l := []int{-2, -1, -0, 1, 2}
l = Filter(func(v int) bool { return v > -2 }, l)
l = Filter(func(v int) bool { return v < 2 }, l)
l = Filter(func(v int) bool { return v != 0 }, l)
fmt.Println(l)

计算过程中会产生 3 个新的长度为 5 的 []int,空间复杂度为 O(3∗N),尽管常数在复杂度分析时经常被省略,但在程序实际运行的时候,这里的 3 就意味着 3 倍的内存占用。

假设这些高阶函数的求值是惰性的,则计算只会在对 fmt.Println 对参数求值的时候发生,元素从原始的 l 中被取出,判断 if v > -2、if v < 2,最后执行 v + 1,放入新的 []int 中,空间复杂度依然是 O(N),但毫无疑问地我们只使用了一个 `[]int``。

泛型的引入对惰性求值的好处有限,大致和前文所述一致,但至少我们可以定义类型通用的 接口了:

// 一个适用于线性结构的迭代器接口
type Iter[T any] interface{ Next() (T, bool) }
// 用于将任意 slice 包装成 Iter[T]
type SliceIter[T any] struct {
i int
s []T
}
func IterOfSlice[T any](s []T "T any") Iter[T] {
return &SliceIter[T]{s: s}
}
func (i *SliceIter[T]) Next() (v T, ok bool) {
if ok = i.i < len(i.s); ok {
v = i.s[i.i]
i.i++
}
return
}

接着实现惰性版本的 filter:

type filterIter[T any] struct {
f func(T) bool
src Iter[T]
}
func (i *filterIter[T]) Next() (v T, ok bool) {
for {
v, ok = i.src.Next()
if !ok || i.f(v) {
return
}
}
}
func Filter[T any](f func(T "T any") bool, src Iter[T]) Iter[T] {
return &filterIter[T]{f: f, src: src}
}

可以看到这个版本的 filter 仅仅返回了一个 Iter[T](*filterIter[T]),实际的运算在 *filterIter[T].Next() 中进行。

我们还需要一个将 Iter[T] 转回 []T 的函数:

func List[T any](src Iter[T] "T any") (dst []T) {
for {
v, ok := src.Next()
if !ok {
return
}
dst = append(dst, v)
}
}

最后实现一个和上面等价的运算,但实际的计算工作是在 List(i) 的调用中发生的:

i := IterOfSlice([]int{-2, -1, -0, 1, 2})
i = Filter(func(v int) bool { return v > -2 }, i)
i = Filter(func(v int) bool { return v < 2 }, i)
i = Filter(func(v int) bool { return v != 0 }, i)
fmt.Println(List(i))

Map 的迭代器

Golang 中的 Hashmap map[K]V 和 Slice []T 一样是常用的数据结构,如果我们能将 map 转化为上述的 Iter[T],那么 map 就能直接使用已经实现的各种高阶函数。

map[K]V 的迭代只能通过 for ... range 进行,我们无法通过常规的手段获得一个 iterator。反射当然可以做到,但 reflect.MapIter 太重了。modern-go/reflect2[19] 提供了一个 更快的实现[20] ,但已经超出了本文的讨论范围,此处不展开,有兴趣的朋友可以自行研究。

局部应用

局部应用[21] (Partial Application)是一种固定多参函数的部分参数,并返回一个可以接受剩余部分参数的函数的操作。

备注

局部应用不同于 📖 柯里化[22] (Currying) Partial Function Application is not Currying[23],柯里化是一种用多个单参函数来表示多参函数的技术,在 Go 已经支持多参函数的情况下,本文暂时不讨论 Currying 的实现。

我们定义一个有返回值的接收单个参数的函数类型:

type FuncWith1Args[A, R any] func(A) R
对一个只接受一个参数的函数进行一次 partial application,其实就相当于求值:
func (f FuncWith1Args[A, R]) Partial(a A) R {
return f(a)
}

接受两个参数的函数被 partial application 后,一个参数被固定,自然返回一个上述的 FuncWith1Args:

type FuncWith2Args[A1, A2, R any] func(A1, A2) R
func (f FuncWith2Args[A1, A2, R]) Partial(a1 A1) FuncWith1Args[A2, R] {
return func(a2 A2) R {
return f(a1, a2)
}
}

我们来试用一下,将我们之前实现的 filter 包装成一个 FuncWith2Args,从左到右固定两个参数,最后得到结果:

f2 := FuncWith2Args[func(int) bool, Iter[int], Iter[int]](Filter[int] "func(int) bool, Iter[int], Iter[int]")
f1 := f2.Partial(func(v int) bool { return v > -2 })
r := f1.Partial(IterOfSlice([]int{-2, -1, -0, 1, 2}))
fmt.Println(List(r))
// Output:
// [-1 0 1 2]

类型参数推导

我们勉强实现了 partial application,可是把 Filter 转换为 FuncWith2Args 的过程太过繁琐,在上面的例子中,我们把类型参数完整地指定了一遍,是不是重新感受到了 闭包语法[24] 带给你的无奈?

这一次我们并非无能为力,提案中的 #Type inference[25] 一节描述了对类型参数推导的支持情况。上例的转换毫无歧义,那我们把类型参数去掉:

// INVALID CODE!!!
f2 := FuncWith2Args(Filter[int])

编译器如是抱怨:

提案里的类型参数推导仅针对函数调用,FuncWith2Args(XXX) 虽然看起来像是函数调用语法,但其实是一个类型的实例化,针对类型实例化的参数类型推导( #Type inference for composite literals[26] )还是一个待定的 feature。

如果我们写一个函数来实例化这个对象呢?很遗憾,做不到:我们用什么表示入参呢?只能写出这样「听君一席话,如听一席话」的函数:

func Cast[A1, A2, R any](f FuncWith2Args[A1, A2, R] "A1, A2, R any") FuncWith2Args[A1, A2, R] {
return f
}

但是它能工作!当我们直接传入 Filter 的时候,编译器会帮我们隐式地转换成一个 FuncWith2Args[func(int) bool, Iter[int], Iter[int]]!同时因为函数类型参数推导的存在,我们不需要指定任何的类型参数了:

f2 := Cast(Filter[int])
f1 := f2.Partial(func(v int) bool { return v > -2 })
r := f1.Partial(IterOfSlice([]int{-2, -1, -0, 1, 2}))
fmt.Println(List(r))
// Output:
// [-1 0 1 2]

可变类型参数

FuncWith1Args 、FuncWith2Args 这些名字让我们有些恍惚,仿佛回到了代码生成的时代。为了处理更多的参数,我们还得写 FuncWith3Args、FuncWith4Args… 吗?

是的, #Omissions[27] 一节提到:Go 的泛型不支持可变数目的类型参数:

对应到函数签名,我们也没有语法来声明拥有不同类型的可变参数。

类型系统

众多函数式特性的实现依赖于一个强大类型系统,Go 的类型系统显然不足以胜任,作者不是专业人士,这里我们不讨论其他语言里让人羡慕的类型类(Type Class)、代数数据类型(Algebraic Data Type),只讨论在 Go 语言中引入泛型之后,我们的类型系统有哪些水土不服的地方。

编译期类型判断

当我们在写一段泛型代码里的时候,有时候会需要根据 T 实际上的类型决定接下来的流程,可 Go 的完全没有提供在编译期操作类型的能力。运行期的 workaround 当然有,怎么做呢:将 T 转化为 interface{},然后做一次 type assertion:

func Foo[T any](n T "T any") {
if _, ok := (interface{})(n).(int); ok {
// do sth...
}
}

无法辨认「基础类型」

我们在 代码生成之困[28] 提到过,在类型约束中可以用 ~T 的语法约束所有 基础类型为 T 的类型,这是 Go 在语法层面上首次暴露出「基础类型」的概念,在之前我们只能通过 reflect.(Value).Kind 获取。而在 type assertion 和 type switch 里并没有对应的语法处理「基础类型」:

type Int interface {
~int | ~uint
}
func IsSigned[T Int](n T "T Int") {
switch (interface{})(n).(type) {
case int:
fmt.Println("signed")
default:
fmt.Println("unsigned")
}
}
func main() {
type MyInt int
IsSigned(1)
IsSigned(MyInt(1))
}
// Output:
// signed
// unsigned

乍一看很合理,MyInt 确实不是 int。那我们要如何在函数不了解 MyInt 的情况下把它当 int 处理呢?答案是还不能:#Identifying the matched predeclared type[29] 表示这是个未决的问题,需要在后续的版本中讨论新语法。总之,在 1.18 中,我们是见不到它了。

类型约束不可用于 type assertion

一个直观的想法是单独定义一个 Signed 约束,然后判断 T 是否满足 Signed:

type Signed interface {
~int
}
func IsSigned[T Int](n T "T Int") {
if _, ok := (interface{})(n).(Signed); ok {
fmt.Println("signed")
} else {
fmt.Println("unsigned")
}
}

但很可惜,类型约束不能用于 type assertion/switch,编译器报错如下:

尽管让类型约束用于 type assertion 可能会引入额外的问题,但牺牲这个支持让 Go 的类型表达能力大大地打了折扣。

总结

函数式编程的特性不止于此,代数数据类型、引用透明(Referential Transparency)等在本文中都未能覆盖到。总得来说,Go 泛型的引入:

  1.  使的部分 函数式特性能以更通用的方式被实现
  2.  灵活度比代码生成更高 ,用法更自然,但细节上的小问题很多
  3.  1.18 的泛型在引入 type paramters 语法之外并没有其他大刀阔斧的改变,导致泛型和这个语言的其他部分显得有些格格不入,也使得泛型的能力受限。至少在 1.18 里,我们要忍受泛型中存在的种种不一致
  4. 受制于 Go 类型系统的表达能力,我们无法表示复杂的类型约束,自然也 无法实现完备的函数式特性
来源:马哥Linux运维内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     813人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     354人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     318人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     435人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-后端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯