深入理解与使用go之函数与方法–泛型及堆栈
引子
Go 1.18为语言添加了泛型。简而言之,允许先写代码后确定类型。
- 什么时候应该使用,什么时候不能使用
我们在面试过程中经常被问到函数中的堆与栈,那么
- 什么是堆栈,区别是什么
- 什么时候在堆上,什么时候在栈上
- 我们为什么要了解堆栈
带着这些问题,开始我们今天的讨论
泛型
函数式泛型
示例
假设我们有这样一个场景,我们希望知道slice中某个元素是否存在,可能会有string、int、array等元素,在没有泛型之前
我们可能要向如下方式写三个函数
func InArrayInt(ele int, arr []int) int
func InArrayString(ele string, arr []string) int
func InArrayArr(ele [2]int, arr [][2]int) int
如果使用泛型呢,我们先看看,泛型函数的原型
func funcName[T any,...](a T, ...)
结合上面的需求,我们可以快速实现
// InArray 查找元素在slice中的位置
func InArray[T comparable](ele T, arr []T) int {
for index, val := range arr {
if val == ele {
return index
}
}
return -1
}
func main() {
fmt.Println(InArray(12, []int{1, 3, 4, 5, 6, 7}))
fmt.Println(InArray("lisi", []string{"zhangsan", "lisi", "wangwu"}))
fmt.Println(InArray([2]int{15, 16}, [][2]int{{15, 17}, {15, 18}, {15, 16}}))
}
可能有的同学已经看到了
func InArray[T comparable](ele T, arr []T) int
这里类型 T
为什么不是 interface{}
类型呢
类型说明
首先我们要搞清楚一些概念,从go源码src/builtin/builtin.go
里我们可以看到, 两个我们常看到但不常使用的 类型
// any is an alias for interface{} and is equivalent to interface{} in all ways.
type any = interface{}
// comparable is an interface that is implemented by all comparable types
// (booleans, numbers, strings, pointers, channels, arrays of comparable types,
// structs whose fields are all comparable types).
// The comparable interface may only be used as a type parameter constraint,
// not as the type of a variable.
type comparable interface{ comparable }
any
: 其实就是 interface{}
的别名
comparable
: 实现了所有可比较接口的接口类型
-
bool,/数字/字符串/指针/channel/ 数组
-
所有字段都是可比较的结构体
-
可比较接口只能用作参数的类型约束,不能作为变量类型
func Add(a1, a2 comparable) comparable { return a1 + a2 }
上面这种是错误的,会直接报编译错误
can not use type comparable outside a type constraint
不能在类型约束之外使用类型可比较
然后回到我们的讨论,为什么不能是 any
或者 interface{}
呢,我们函数中有一个操作
if val == ele {
return index
}
对,就是用到了比较,如果我们使用 interface{}
会报错
invalid operation: val == ele (incomparable types in type set)
在类型中并未实现可比较
自定义类型约束
如果我们只想 int
和 string
生效呢, 我们可以将约束定义如下
type IntString interface {
int | string
}
然后使用
func InArray[T IntString](ele T, arr []T) int
新的问题又来了,很多时候,我们为了方便,会将一些基础类型,自定义为自己的类型,比如
type MyInt int
这个时候,我们如果使用
fmt.Println(InArray(12, []MyInt{1, 3, 4, 5, 6, 7}))
会报错
MyInt does not satisfy IntString (possibly missing ~ for int in IntString)
告诉我不满足,我们可以使用它建议的
type IntString interface {
~int | string
}
再执行就正常了, ~int
和 int
的主要区别就是,前者涵盖了所有的int及他的别名,后者必须为 int 本身
结构体式泛型
示例
我们考虑单链表结构,我们期望所有元素类型都支持,同样,没有泛型之前,我们可能这样实现
type Node struct {
Val int
next *Node
}
func (t *Node) Add(node *Node) {
t.next = node
}
type Node struct {
Val string
next *Node
}
func (t *Node) Add(node *Node) {
t.next = node
}
type Node struct {
Val bool
next *Node
}
func (t *Node) Add(node *Node) {
t.next = node
}
可以看到,除了结构体内的一个字段有变化外,其他都没有改变,我们先看看结构体的泛型原型
type structName(T any, B any ...) struct {
Val T
Name B
....
}
那么,除了方法,我们只需要将结构体改造成下面这样
type TNode[T any] struct {
Val T
next *TNode[T]
}
func (t *TNode[T]) Add(node *TNode[T]) {
t.next = node
}
方法可以加类型约束么
如果,我们想在Add 方法上,新增一个可变参数,像下面这样
func (t *TNode[T]) Add[B any](node *TNode[T], b B)
编译会直接报错
syntax error: method must have no type parameters
所以对于类型参数,它们不能与方法参数一起使用,只能与函数参数或方法接收器一起使用。
使用
推荐用
-
数据结构,如链表、二叉树、堆等
-
slice、map或channel使用的
如:提取map的key
func GetMapKeys[T comparable, B any](m map[T]B) []T { if len(m) == 0 { return nil } var ret []T for k := range m { ret = append(ret, k) } return ret } // 测试 fmt.Println(GetMapKeys(map[int]int{1: 1, 2: 2, 3: 3})) // [2 3 1] fmt.Println(GetMapKeys(map[string]int{"ss": 1, "yy": 2, "ll": 3})) // [ss yy ll]
有时候,我们希望对map的key进行排序,然后返回有序的结果
type MapKeySlice[T comparable, B any] struct { M map[T]B S []T Compare func(T, T) bool } func NewMapKeySlice[T comparable, B any](m map[T]B, f func(T, T) bool) MapKeySlice[T, B] { s := MapKeySlice[T, B]{ M: m, Compare: f, } s.S = s.getSortedMapKeys() return s } func (s MapKeySlice[T, B]) getSortedMapKeys() []T { if len(s.M) == 0 { return nil } var ret []T for k := range s.M { ret = append(ret, k) } s.S = ret sort.Sort(s) return s.S } func (s MapKeySlice[T, B]) GetSortedValues() []B { if len(s.S) == 0 { return nil } var ret []B for _, k := range s.S { ret = append(ret, s.M[k]) } return ret } func (s MapKeySlice[T, B]) Len() int { return len(s.S) } func (s MapKeySlice[T, B]) Less(i, j int) bool { return s.Compare(s.S[i], s.S[j]) } func (s MapKeySlice[T, B]) Swap(i, j int) { s.S[i], s.S[j] = s.S[j], s.S[i] } func main() { s1 := NewMapKeySlice(map[int]int{1: 1, 2: 2, 3: 3}, func(i, i2 int) bool { return i < i2 }) fmt.Println(s1.S) fmt.Println(s1.GetSortedValues()) s2 := NewMapKeySlice(map[string]int{"ss": 1, "yy": 2, "ll": 3}, func(i, i2 string) bool { return i < i2 }) fmt.Println(s2.S) fmt.Println(s2.GetSortedValues()) }
-
分解的是行为而不是类型的函数或方法
不推荐用
- 如果一个类型参数是确定的,就不需要变更成泛型
- 如果改成泛型使我们的代码更复杂,性能更差,那就优先不使用
函数的堆与栈
我们先看下堆和栈的内存分布,每个goroutine有各自的栈,而他们共享一个堆
栈
栈是自动管理的,遵循后进先出 (LIFO) 原则。当调用函数时,所有关联的数据都放置在栈顶部,函数完成后,这些数据将被删除。栈高效运行,将内存管理的开销降至最低。在栈上检索和存储数据的过程很快。用于存储特定goroutine的所有局部变量。当goroutine启动时,它会获得2KB的连续内存作为栈空间 (随着时间推移会改变),而且运行时可以根据需要增大或缩小(但他始终在内存中保持连续)
- 用于存储局部变量和函数调用数据,包括函数参数和返回地址
- 分配的变量具有固定大小,并且通常是短暂的,因为当创建它们的函数退出时,它们会自动释放
- Go 自动管理栈内存,您不必担心基于栈的变量的内存释放
- 基本数据类型,例如整数、浮点数和指针,通常存储在栈中
堆
在堆上分配内存的行为通常称为“动态内存分配”,因为在编译和运行时都可以对如何使用该内存以及何时可以清理该内存做出很少的假设。与栈不同的是,您负责根据需要在堆上分配和释放内存,由于堆可以在运行时动态增长,因此它通常用于存储太大而无法放入栈的数据,或者需要在函数调用的生命周期之外保留的数据。但是,由于堆不是自动管理的,因此如果使用不小心,它也更容易出现内存泄漏和其他类型的错误
- 用于动态分配的数据,这些数据在编译时没有固定或已知的大小
- 在堆上分配的变量通常生命周期较长,并且它们的内存必须由程序员显式管理(例如,通过垃圾收集)
- 复杂的数据结构(例如slice、map以及包含slice或map的结构)通常使用堆内存
- Go 中的字符串被实现为只读字节切片(不可变),实际的字符串数据通常存储在堆中
- 当您使用
make
函数创建切片、映射或通道,它们分配在堆上
为什么要关心这些
通常,一个栈是自清洁的,由单个goroutine访问。相反,堆必须由外部系统清理:GC。
堆分配越多,我们对GC的压力就越大。当GC运行时,它可能使用25%或者更多的可用CPU容量,并可能产生几毫秒的“stop-the-world”(STW)延迟(应用程序暂停的阶段),还有栈上分配对于Go运行时来说更快,因为它是微不足道的:相反,在堆上分配需要更多的努力来找到正确的位置,因此需要更多的时间。
内存逃逸
哪些情况下可能出现逃逸
- 发送指针或包含指向通道的指针的值。 在编译时,无法知道哪个 goroutine 将接收通道上的数据。因此编译器无法确定何时不再引用该数据。
- 在切片中存储指针或包含指针的值。 一个例子是像这样的类型
[]*string
。这总是会导致切片的内容逃逸。即使切片的后备数组可能仍在堆栈上,引用的数据也会转义到堆中。 - **支持重新分配的切片数组,因为切片 append 会超出其容量。如果切片的初始大小在编译时已知,它将开始在堆栈上分配。如果这个切片的底层存储必须基于运行时已知的数据进行扩展,那么它将被分配在堆上。
- 调用接口类型上的方法。 对接口类型的方法调用是动态调度——实际使用的具体实现只能在运行时确定。考虑
r
一个接口类型为 的变量io.Reader
。调用r.Read(b)
将导致字节切片的值r
和支持数组都转义b
,因此被分配在堆上
示例分析
我们程序结构如下
├── square
│ ├── square.go
│ └── square_test.go
├── main.go
square.go
func Square(x, y int) int {
z := x * y
return z
}
square_test.go
func BenchmarkSquare(b *testing.B) {
var s int
for i := 0; i < b.N; i++ {
s = Square(456, 547)
}
fmt.Println(s)
}
main.go
func main() {
s := square.Square(4,5)
println(s)
}
我们先看下有没有使用堆(heap)
go run -gcflags="-m=2" main.go
打印
# command-line-arguments
./main.go:5:6: can inline main with cost 19 as: func() { s := square.Square(4, 5); println(s) }
./main.go:6:20: inlining call to square.Square
20
没有来自堆的逃逸,然后我们执行压测
cd square
go test -bench .
结果
1000000000 0.3159 ns/op
接着我们更改返回值为指针,故意构造逃逸场景
square.go
func Square(x, y int) *int {
z := x * y
return &z
}
square_test.go
func BenchmarkSquare(b *testing.B) {
var s *int
...
}
我们再次压测
61264642 17.26 ns/op
性能差了将近60倍
我们分析
go run -gcflags="-m=2" main.go
结果
# command-line-arguments
./main.go:7:6: can inline main with cost 20 as: func() { s := square.Square(4, 5); println(s) }
./main.go:8:20: inlining call to square.Square
0xc00004c768
发现啥也没发生,我们需要理解一句话
如果编译器无法证明函数返回后没有引用变量,则该变量在堆上分配
压测结果为什么会相差那么大,而main函数又没有给出move heap
的证明,我们根据上面这段关键的话来逐一分析
-
压测中,我们定义了一个变量,这个变量在压测周期内没有被销毁,而函数返回了变量引用
编译器无法证明后续的引用,所以压测实际上是在堆上分配的
-
而main函数中,我们唯一的使用就是打印,这之后就没有其他了,编译器直接就默认为变量使用范围明确
所以没有进入堆
heap
我们压测也加入 gcflags 参数进行佐证
go test -gcflags="-m" -bench .
打印
./square.go:3:6: can inline Square
./square_test.go:11:13: inlining call to Square
./square_test.go:13:13: inlining call to fmt.Println
./square.go:4:2: moved to heap: z
./square_test.go:8:22: b does not escape
./square_test.go:11:13: moved to heap: z
./square_test.go:13:13: ... argument does not escape
...
moved to heap: z
这句话已经证明了 z 变量被重新分配给了 堆
如果我们重新更改下 main函数,像这样
func main() {
s := square.Square(4, 5)
go println(s)
time.Sleep(time.Millisecond * 20)
}
再次分析执行
go run -gcflags="-m" main.go
结果
# command-line-arguments
./main.go:9:20: inlining call to square.Square
./main.go:9:20: moved to heap: square.z
0xc00001c0d0
moved to heap: square.z
也证明了 z 变量被重新分配给了 堆
【注意】上面提到了很多 gcflags
标记的使用,其中 m有时候不赋值,有时候等于2,区别在于-m
标记用于打印垃圾回收的详细信息和统计数据,而-m=2
标记在此基础上提供了更详细和更频繁的输出,而且m还可以赋值更大比如3,给出更深层次的解析