聊聊 Go 中完全木有排面的元组

元组是什么

元组(tuple)和列表等一样,也是一种数据类型。

它和列表不同点在于,列表是元素类型固定,而长度不固定。元组则恰恰相反,长度固定,而元素类型不固定。

对于有 python 或 js 编程经验的人或许对元组比较熟悉,但是对于纯 gophers 来说可能就比较陌生了。与在日常 go 编程中,切片(slice),映射(map)随处可见,但元组却无迹可寻。

其实这也难怪,因为 go 根本没有像内置 slice 和 map 一样内置元组,实在是没有排面。

这篇文章的主旨呢,就是来聊聊这个没排面的元组。

与其他文章不同的是,这篇文章里除了介绍元组的几种实现方式,还会聊聊元组如何工作,可以解决什么问题。在我看来后者是更有意义的。

天生我材必有用

如果有人对你说,公司离了你照样转。也许 ta 说的对,但也不必灰心丧气,有句话说的是天生我材必有用。

只要能在某些领域,某些场景,甚至某些小事上做的更好,那就是价值所在。

不小心扯远了,还是来聊聊元组。

显然,go 离了元组照样好好的转,或许这也是没有内置元组的原因,毕竟大道至简嘛。

但是元组也有其一技之长,在熟悉元组后,某些场景下我们能更好地实现逻辑。

元组的一技之长

以我的理解来说,元组的一技之长就是简化组合。

可以从一个坐标点的实现来看看元组是怎么简化组合的。

一般实现:

type Point struct{ X, Y int }

func PrintPoint1() {
    point := Point{1, 2}

    x, y := point.X, point.Y

    fmt.Printf("point at { x: %d, y: %d }\n", x, y)
}

// output: point at { x: 1, y: 2 }

以上已经是比较简略的实现,但是如果用元组来表达呢

元组伪代码:

func PrintPoint2() {
    point := (1, 2)

    x, y := point

    fmt.Printf("point at { x: %d, y: %d }\n", x, y)
}

最大的不同就是不用再声明 Point 类型,整整少写了一行代码(笑)

So What

如果单单是表达一个 x 和 y 坐标的 point,似乎元组相比结构体并没有多大优势。

但是,如果还有带 z 坐标的 point,还有带名字的 point 呢,还有既带 z 坐标又带名字的 point 呢,还有坐标值类型为浮点数的 point 呢?

当然可以把这些字段都放在一个结构体中,但这对只处理整形数 x, y 坐标值的代码来说数据是冗余的,零值可也占空间。

或者声明不同结构体,但就要费脑筋来取名区分不同结构体。时间一长还可能会产生很多只在某几段代码使用的结构体。

但是从元组角度来看,不是 point 表达了组合元素,而是组合元素表达了 point, 只要元素确定,叫 Point 还是 PointWithXY 并无所谓。

实际场景

突然想起聊聊元组倒也并不是空穴来风,我用 go 实现了一个事件总线处理库eventd,大致使用方法如下:

    bus := new(EventBus[int])

    cancel, err := bus.Subscribe(func(event string, i int) bool {
        // do something
        return true
    }, On("put")) // 订阅 put 事件

    //...

    bus.Emit("put", 0) // 触发事件

可以看到,这里回调方法的签名可以通过事件对象类型推导出来。

但是在使用时有一个问题:

在传递事件消息时,事件对象可能不止一个。举个例子,用户编辑了一篇文章,那么主体就是用户,此外还有客体文章。主体和客体的类型可能会变,但是主体和客体的有序对关系不会变化。

我不想为各种主客体组合声明结构体,也不想实现多个不同数量泛型参数的 event bus。

就在这时,我想起了元组。如果用元组实现会是怎么样呢?

bus := new(EventBus[Tuple[User, Blog]])

这样一来,对于 event bus 来说,永远只有一个事件对象,同时事件对象又可以是不同对象的灵活组合。

但是另一个问题来了,元组从哪里来?

元组的3种实现

函数多返回值

其实 go 并不是全无元组的影子。前面伪代码中 x, y := point,只要在 point 后加个圆括号不就是函数嘛。

按这个思路实现一下伪代码,差不多是下面这样子:

func PrintPoint2() {
    point := func() (int, int) {
        return 1, 2
    }

    x, y := point()

    fmt.Printf("point at { x: %d, y: %d }\n", x, y)
}

好像差点意思,用泛型加强下表达能力。

泛型函数

type Pair[L, R any] func() (L, R)

func TwoTuple[T1, T2 any](v1 T1, v2 T2) Pair[T1, T2] {
    return func() (T1, T2) {
        return v1, v2
    }
}

func PrintPoint2() {
    point := TwoTuple(1, 2)

    x, y := point()

    fmt.Printf("point at { x: %d, y: %d }\n", x, y)
}

还可以为 Pair 实现方法:

// ...

func (p Pair[L, R]) Right() R {
    _, r := p()
    return r
}

// ...

func PrintPoint2() {
    point := TwoTuple(1, 2)

    x, y := point()

    fmt.Printf("point at { x: %d, y: %d }\n", x, y)

    point2 := TwoTuple(1, 2.33)

    fmt.Printf("point at { y: %f }\n", point2.Right())
}

从上面这些代码可以看到,只要声明一次二元组,那么就能表达任意两种元素的组合。

泛型结构体

像上面通过泛型函数来实现元组还是有点花里胡哨了,其实有了泛型之后,直接用结构体表达元组更加简单。

目前比较多 star 的元组实现也这样实现的。

type Tuple2[T1, T2 any] struct {
    V1 T1
    V2 T2
}

这样直接通过访问成员属性就可以读写元素。

与上面泛型函数实现相比,这种实现需要增加一个类似 Values 的方法来一下来实现x,y := ...的效果。但可以避免函数带来的一些副作用,比如说内存逃逸。

以上三种方法都可以模拟元组,但是也有共同的缺点,对于每种长度的元组都需要声明一下。好在一般情况下不会使用 9 个元素以上的元组,这个缺点也算可以接受。

总结

在 go 中没有排面的元组也能在特定场景发光发热,泛型的引入也使得第三方元组实现真正实用起来。

最后为我的 github 项目做个宣传:github.com/symphony09/eventd

本作品采用《CC 协议》,转载必须注明作者和本文链接
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!