(Go语言)Go基础的进阶知识!带你认识迭代器与类型以及声明并使用接口与泛型!
1. 接口
1.1 概念
当一个类型位于一个接口的类型集内,且该类型的值可以由该接口类型的变量存储,那么称该类型实现了该接口。并且还给出了如下的额外定义。
当如下情况时,可以称类型T实现了接口I
- T不是一个接口,并且是接口I类型集中的一个元素
- T是一个接口,并且T的类型集是接口I类型集的一个子集
如果T实现了一个接口,那么T的值也实现了该接口。
Go在1.18最大的变化就是加入了泛型,新接口定义就是为了泛型而服务的,不过一点也不影响之前接口的使用,同时接口也分为了两类,
- 基本接口(
Basic Interface
):只包含方法集的接口就是基本接口 - 通用接口(
General Interface
):只要包含类型集的接口就是通用接口
什么是方法集,方法集就是一组方法的集合,同样的,类型集就是一组类型的集合。
1.2 基本接口
type Person interface {
Say(string) string
Walk(int)
}
这是一个Person
接口,有两个对外暴露的方法Walk
和Say
,在接口里,函数的参数名变得不再重要,当然如果想加上参数名和返回值名也是允许的
1.3 初始化
接口只是一组规范,但是它并没有具体的实现,不过可以被声明
func main() {
var person Person
fmt.Println(person) // nil
}
1.4 面向接口编程
- 接口:一种规范
- 实现:根据接口规范来实现某种功能
- 功能不变实现变(好处):因某种需要,需要更改某个功能的实现原理,但是总的功能还是没有改变的。
- 面向接口编程:根据结构的规范来使用功能,屏蔽了其内部的实现
type Con interface {
fly()
}
type conContent struct {
con Con
}
type bodyA struct {
name string
}
func (b bodyA) move() {
fmt.Println("走走走走")
}
func (b bodyA) fly() {
b.move()
fmt.Println("容器A的fly方法调用")
}
func (c *conContent) Build() {
c.con.fly()
}
func main() {
a := conContent{bodyA{}}
a.Build()
}
上面例子中,可以观察到接口的实现是隐式的,也对应了官方对于基本接口实现的定义:方法集是接口方法集的超集
在Go中,实现一个接口不需要implements
关键字显式的去指定要实现哪一个接口,只要是实现了一个接口的全部方法,那就是实现了该接口。
有了实现之后,就可以初始化接口了,建筑公司结构体内部声明了一个Con
类型的成员变量,可以保存所有实现了Con
接口的值,由于是Con
类型的变量,所以能够访问到的方法只有JackUp
和Hoist
,内部的其他方法例如move
和都无法访问。
实际例子:
package main
import "fmt"
// =================== 接口定义 - 开始
// 模拟用户表类型
type User interface {
get(id int) UserBase
add(name string, age, id int) bool
}
// =================== 接口定义 - 结束
// =================== 接口集合声明 - 开始
type UserInter struct {
user User
}
// =================== 接口集合声明 - 结束
// =================== UserBase结构体声明 - 开始
type UserBase struct {
name string
age int
}
// =================== UserBase结构体声明 - 结束
// =================== UserBase结构类型方法实现 - 开始
// 获得用户信息,并返回
func (u *UserBase) get(id int) UserBase {
stu, _ := student[id]
return stu
}
func (u *UserBase) add(name string, age, id int) bool {
student[id] = UserBase{name, age}
return true
}
// =================== UserBase结构类型方法实现 - 结束
// 存储变量
var student = make(map[int]UserBase)
func main() {
a := UserInter{&UserBase{}}
a.user.add("张三", 20, 1)
stu := a.user.get(1)
fmt.Println(stu)
}
2. 泛型
2.1 介绍
最开始 Go语言并没有泛型,而在1.18版本中,go加入了对泛型的支持。
2.2 泛型的好处
先来看一个小例子:
func Sum(a, b int) int {
return a + b
}
在这个例子中,传入两个int类型值即可以相加。但若是传入两个float64类型的值进去就会报错了。
解决办法自然是把类型接收的变量类型改为float64,或者新建个方法用于接收float64类型。
那么如果又传入了其他数据类型怎么办?难不成重新编写另外的函数吗?虽然可以用any类型来接收,但是里面也需要做判断,但是这样写会显得十分复杂,而且性能低下
Sum
函数的逻辑都是一模一样的,都只不过是将两个数相加而已,这时候就需要用到了泛型,所以为什么需要泛型,泛型是为了解决执行逻辑与类型无关的问题,这类问题不关心给出的类型是什么,只需要完成对应的操作就足够。
写法如下
func Sum[T int | float64](a, b T) T {
return a + b
}
- 类型传参:
T
就是一个类型形参,形参具体是什么类型取决于传进来什么类型 - 类型约束:
int | float64
构成了一个类型约束,这个类型约束内规定了哪些类型是允许的,约束了类型形参的类型范围 - 类型实参:
Sum[T int|float64]
,手动指定了int
类型,int
就是类型实参
2.3 泛型结构
泛型切片: type GenericSlice[T int | int32 | int64] []T
type GenSlice[T int | int32 | int64] []T
func main() {
a := GenSlice[int64]{3, 21, 3}
fmt.Println(a) // [3 21 3]
}
这是一个泛型哈希表:type GenericMap[K comparable, V int | string | byte] map[K]V
- 键的类型必须是可比较的,所以使用
comparable
接口 - 值的类型约束为
V int | string | byte
2.3.1 更推荐:泛型结构体
type Company[T int | string, S int | string] struct {
Name string
Id T
Stuff []S
}
/*
更推荐的写法
*/
d := GenStruct[int]{
Id: 1,
Name: "好男人",
}
d.Id = 1
d.Name = "hello"
d.Id = 2
fmt.Println(d)
泛型接口
type SayAble[T int | string] interface {
Say() T
}
type Person[T int | string] struct {
msg T
}
func (p Person[T]) Say() T {
return p.msg
}
func main() {
var s SayAble[string]
s = Person[string]{"hello world"}
fmt.Println(s.Say())
}
2.3.2 泛型结构注意点
- 以下写法是错误的,泛型形参T是不能作为基础类型的
type GenericType[T int | int32 | int64] T
- 虽然下列的写法是允许的,不过毫无意义而且可能会造成数值溢出的问题,虽然并不推荐
type GenericType[T int | int32 | int64] int
- 泛型类型无法使用类型断言
对泛型类型使用类型断言将会无法通过编译,泛型要解决的问题是类型无关的,如果一个问题需要根据不同类型做出不同的逻辑,那么就根本不应该使用泛型,应该使用interface{}
或者any
。
func Sum[T int | float64](a, b T) T {
ints,ok := a.(int) // 不被允许
switch a.(type) { // 不被允许
case int:
case bool:
...
}
return a + b
}
泛型使用的场景和any使用的场景区分:
- 泛型:当在一个功能下,想要不同的类型都走一套逻辑,那么可以使用泛型
- any:在一个功能中,想要不同的类型进行区分走不同的逻辑,使用any类型或者interface使用实现方法
- 匿名结构不支持泛型
匿名结构体是不支持泛型的,如下的代码将无法通过编译
testStruct := struct[T int | string] {
Name string
Id T
}[int]{
Name: "jack",
Id: 1
}
2.4 使用
2.4.1 队列
下面用泛型实现一个简单的队列,首先声明队列类型,队列中的元素类型可以是任意的,所以类型约束为any
type Queue[T any] []T
总共只有四个方法Pop
,Peek
,Push
,Size
,代码如下。
type Queue[T any] []T
func (q *Queue[T]) Push(e T) {
*q = append(*q, e)
}
func (q *Queue[T]) Pop(e T) (_ T) {
if q.Size() > 0 {
res := q.Peek()
*q = (*q)[1:]
return res
}
return
}
func (q *Queue[T]) Peek() (_ T) {
if q.Size() > 0 {
return (*q)[0]
}
return
}
func (q *Queue[T]) Size() int {
return len(*q)
}
在Pop
和Peek
方法中,可以看到返回值是_ T
,这是具名返回值的使用方式,但是又采用了下划线_
表示这是匿名的,这并非多此一举,而是为了表示泛型零值。由于采用了泛型,当队列为空时,需要返回零值,但由于类型未知,不可能返回具体的类型,借由上面的那种方式就可以返回泛型零值。也可以声明泛型变量的方式来解决零值问题,对于一个泛型变量,其默认的值就是该类型的零值,如下
func (q *Queue[T]) Pop(e T) T {
var res T
if q.Size() > 0 {
res = q.Peek()
*q = (*q)[1:]
return res
}
return res
}
2.4.2堆
上面队列的例子,由于对元素没有任何的要求,所以类型约束为any
。但堆就不一样了,堆是一种特殊的数据结构,它可以在O(1)的时间内判断最大或最小值,所以它对元素有一个要求,那就是必须是可以排序的类型,但内置的可排序类型只有数字和字符串,并且go的泛型约束不允许存在带方法的接口,所以在堆的初始化时,需要传入一个自定义的比较器,比较器由使用者提供,比较器也必须使用泛型,如下
type Comparator[T any] func(a, b T) int
下面是一个简单的二项最小堆的实现,先声明泛型结构体,依旧采用any
进行约束,这样可以存放任意类型
type Comparator[T any] func(a, b T) int
type BinaryHeap[T any] struct {
s []T
c Comparator[T]
}
几个方法实现
func (heap *BinaryHeap[T]) Peek() (_ T) {
if heap.Size() > 0 {
return heap.s[0]
}
return
}
func (heap *BinaryHeap[T]) Pop() (_ T) {
size := heap.Size()
if size > 0 {
res := heap.s[0]
// 交换位置
heap.s[0], heap.s[size-1] = heap.s[size-1], heap.s[0]
heap.s = heap.s[:size-1]
heap.down(0)
return res
}
return
}
func (heap *BinaryHeap[T]) Push(e T) {
heap.s = append(heap.s, e)
heap.up(heap.Size() - 1)
}
func (heap *BinaryHeap[T]) up(i int) {
if heap.Size() == 0 || i < 0 || i >= heap.Size() {
return
}
for parentIndex := i>>1 - 1; parentIndex >= 0; parentIndex = i>>1 - 1 {
// greater than or equal to
if heap.compare(heap.s[i], heap.s[parentIndex]) >= 0 {
break
}
heap.s[i], heap.s[parentIndex] = heap.s[parentIndex], heap.s[i]
i = parentIndex
}
}
func (heap *BinaryHeap[T]) down(i int) {
if heap.Size() == 0 || i < 0 || i >= heap.Size() {
return
}
size := heap.Size()
for lsonIndex := i<<1 + 1; lsonIndex < size; lsonIndex = i<<1 + 1 {
rsonIndex := lsonIndex + 1
if rsonIndex < size && heap.compare(heap.s[rsonIndex], heap.s[lsonIndex]) < 0 {
lsonIndex = rsonIndex
}
// less than or equal to
if heap.compare(heap.s[i], heap.s[lsonIndex]) <= 0 {
break
}
heap.s[i], heap.s[lsonIndex] = heap.s[lsonIndex], heap.s[i]
i = lsonIndex
}
}
func (heap *BinaryHeap[T]) Size() int {
return len(heap.s)
}
使用起来如下
type Person struct {
Age int
Name string
}
func main() {
heap := NewHeap[Person](10, func(a, b Person) int {
return cmp.Compare(a.Age, b.Age)
})
heap.Push(Person{Age: 10, Name: "John"})
heap.Push(Person{Age: 18, Name: "mike"})
heap.Push(Person{Age: 9, Name: "lili"})
heap.Push(Person{Age: 32, Name: "miki"})
fmt.Println(heap.Peek())// {9 lili}
fmt.Println(heap.Pop())// {9 lili}
fmt.Println(heap.Peek())// {10 John}
}
有泛型的加持,原本不可排序的类型传入比较器后也可以使用堆了,这样做肯定比以前使用interface{}
来进行类型转换和断言要优雅和方便很多。
2.5 为什么Go在最开始不加入泛型?
go的一大特点就是编译速度非常快,编译快是因为编译期做的优化少,泛型的加入会导致编译器的工作量增加,工作更加复杂,这必然会导致编译速度变慢
事实上当初go1.18刚推出泛型的时候确实导致编译更慢了,go团队既想加入泛型又不想太拖累编译速度,开发者用的顺手,编译器就难受,反过来编译器轻松了,开发者就难受了,现如今的泛型就是这两者之间妥协后的产物。
3. 类型:
Go是一个典型的静态类型语言,所有变量的类型都会在编译器确定好,并且在整个程序的生命周期都不会再改变。
3.1 静态强类型
静态指的是Go所有变量的类型早在编译期间就已经确定了,再程序的生命周期都不会再发生改变
尽管Go中的短变量声明有点类似**动态语言(JS、Python…)**的写法,但其变量类型是由编译器自行推断的,最根本的区别在于它的类型一旦推断出来后不会再发生变化,动态语言则完全相反
var a int = 64
a = "64" // 不能通过编译,因为已经推断出来是int类型
强类型指的是再程序中会执行严格的类型检查,如果出现类型不匹配情况,会立即出现提示。
3.2 类型后置
Go为什么要把类型声明放在后面而不是前面?
这是段C语言代码
int (*(*fp)(int (*)(int, int), int))(int, int)
它在Go语言中的写法类似于如下:
func(func(int,int) int, int) func(int, int) int
Go的声明方式始终遵循名字在前面,类型在后面的原则,从左往右读,大概第一眼就可以知道这是一个函数,且返回值为func(int,int) int
。
当类型变得越来越复杂时,类型后置在可读性上要好得多,Go在许多层面的设计都是为了可读性而服务的
尽管我个人认为,这样返回还是非常的难看…可读性还是一坨…
3.3 类型声明
在Go中类型声明,可以声明一个自定义名称的新类型,例如:
type i int64
在Go中,每一个新声明的类型都必须有一个与之对应的基础类型,且类型名称不建议与已有的内置标识符重复。
而通过类型声明的类型都是新类型,不同的类型无法进行运算,即便基础类型是相同的,例如:
type MyInt int64
var i1 MyInt = 1
var i2 int64 = 2
fmt.Println(i1 + i2)
// 报错
// invalid operation: i1 + i2 (mismatched types MyInt and int64)
3.4 类型别名
类型别名与类型声明则不同,类型别名仅仅只是一个别名。并没有创建一个新的类型
// 类型别名
type MyInt1 = int
// 类型声明
type MyInt int64
请注意类名别名和类型声明的区别!!
类型声明是没有=
符号的!!它会声明一个相似类型出来,这个声明出来的类型无法做相同基础类型的运算
而类型别名,它是可以做运算的。
type MyInt = int
var i MyInt = 1
var j int = 2
fmt.Println(i + j) // 3
使用场景:当声明变量过多的时候,可以根据自己的规范来区分其他类型
内置类型
any
就是interface{}
的类型别名,两者完全等价,仅仅叫法不一样。
3.5 类型转换
在Go中,只存在显式的类型转换,不存在隐式类型转换,因此不同类型的变量无法进行运算,无法作为参数传递。类型转换适用的前提是知晓被转换变量的类型和要转换成的目标类型,例子如下:
type MyFloat64 float64
var f1 MyFloat64
var f float64
f1 = 0.2
f = 0.1
fmt.Println(float64(f1) + f) // 0.30000000000000004
这里通过显示转换,蒋MyFloat64 类型转换为float64类型,才能进行加法运算。
而类型转换的前提是:被转换类型必须是可以被目标类型代表的
- 例如:int 可以被 int64 代表,同时 float64 也可以代表。
同时,类型转换也只是推荐小转大,不推荐大转小,这会导致数值溢出问题。
Go官方文档对于代表的诠释:
可表示性¶
常量可由类型为 的值表示 , 其中 不是类型参数, 如果满足以下条件之一:
xTT
x
位于 由 确定的值集中。T
T
是浮点类型,可以四舍五入为 的 精度,而不会溢出。舍入使用 IEEE 754 舍入到偶数规则,但使用 IEEE 负零进一步简化为无符号零。请注意,常量值永远不会产生 以 IEEE 负零、NaN 或无穷大表示。xT
T
是复杂类型,而 的组件 和 可由 的组件类型 ( 或 ) 的值表示如果是类型参数,则可由类型 if is representable 的值表示 按 的 type set 中每种类型的值。
TxTxT
3.6 类型断言
断言的格式: 变量.(类型)
var b int = 1
var a interface{} = b // 空接口类型可以代表所有的类型
if intVal, ok := a.(int); ok {
fmt.Println(intVal)
} else {
fmt.Println("error type")
}
由于interface{}
是空接口类型,空接口类型可以代表所有的类型。
但是int
类型无法代表interface{}
类型,所以无法使用类型转换。
而类型断言就可以判断其底层类型是否为想要的类型:
- 类型断言语句有两个返回值,
- 一个是类型转换过后的值
- 另一个是转换结果的布尔值
3.7 类型判断
在Go中,switch
语句还支持一种特殊的写法,通过这种写法可以根据不同的case
做出不同的逻辑处理,使用的前提是入参必须是接口类型
var a interface{} = 2
// 可以直接放断言进去,匹配类型
switch a.(type) {
case int: fmt.Println("int")
case float64: fmt.Println("float")
case string: fmt.Println("string")
}
4. 迭代器
4.1 推送式迭代器
Go的迭代器是range over func
风格,我们可以直接用for range
关键字来进行使用,使用起来也要比原来更方便
func main() {
n := 8
for f := range Fibonacci(n) {
fmt.Println(f)
}
}
func Fibonacci(n int) func(yield func(int) bool) {
a, b, c := 0, 1, 1
return func(yield func(int) bool) {
for range n {
if !yield(a) {
return
}
a, b = b, c
c = a + b
}
}
}
如上所示,迭代器就是一个闭包函数,它接受一个回调函数作为参数,你甚至可以在里面看到yield
这种字眼
Go的迭代器并没有新增任何关键字,语法特性,在上述示例中yield
也只是一个回调函数,它并非关键字,官方取这个名字是为了方便理解。
而推送式迭代器的重要依据就是:通过调用 yield
函数逐步推出一系列值,yield
函数返回 bool
,决定是否继续执行推出操作。
4.2 推送式迭代器代码分析
package main
import "fmt"
func sumPlus(a int) func(plus func(int) bool) {
n := 1
// 第二步
// // 定义匿名函数 func(plus func(int) bool)
return func(plus func(int) bool) {
// 第三步(进入迭代循环) 6 9
// 这个匿名函数的参数值是:func(int) bool 函数,形参名为plus
for range a {
// 第四步 7
// 使用形参函数 - 这里迭代器判断判断是否还能迭代,如果可以则返回true继续进行,不行则返回false
// 这里进入plus方法会到外层方法输出打印一次n的值,而plus方法中传入的值即时我们需要计算的值
if !plus(n) {
return
}
n *= 2 // 第一次操作是在第一次输出后
}
}
}
func main() {
n := 10
// 第一步
for f := range sumPlus(n) {
// 第五步 8 10...
fmt.Println(f)
}
}
/*
输出如下:
1
2
4
8
16
32
64
128
256
512
*/
- 进入for range循环,进入sumPlus(n)方法
- 执行n:=1代码
- 执行return返回匿名函数
- 进入匿名函数执行for range a开始迭代
- 执行if !plus(n)方法:一路往上走;推出 n变量,最后到达第一步将n变量赋值给 f;f=n(n=1)
- 到了最外层执行fmt.Println(f)代码;f=1
- 结束if !plus(n)方法,系统给出true(判定可继续执行)
- 往下执行 n*=2 代码;n=2
- 结束for range a的第一次迭代;开始第二次 for range a迭代
- 然后重复执行 5-9 步,直到 for range a迭代的a已经达到指定次数或者plus(n)方法返回false不可执行才会结束迭代
4.3 迭代器的格式与分类
在 Go 1.23
中,迭代器 实际上是指符合以下三种函数签名之一的函数:
func(yield func() bool)
func(yield func(V) bool)
func(yield func(K, V) bool)
所以不要去修改它的一个返回类型或者其他形参,规定是这么规定的。
4.4 拉取式迭代器
推送式迭代器(pushing iterator)是由迭代器来控制迭代的逻辑,用户被动获取元素,相反的拉取式迭代器(pulling iterator)就是由用户来控制迭代逻辑,主动的去获取序列元素。
一般而言,拉取式迭代器都会有特定的函数如next()
,stop()
来控制迭代的开始或结束,它可以是一个闭包或者结构体。
package main
import (
"fmt"
"iter"
)
func sumSub(a int) func(plus func(int) bool) {
n := 1
return func(plus func(int) bool) {
for range a {
n *= 2
if !plus(n) {
return
}
}
}
}
/*
拉取式迭代器
*/
func main() {
n := 10
next, stop := iter.Pull(sumSub(n))
for {
v, ok := next()
if !ok {
break
}
fmt.Println(v)
stop() // stop函数也会导致迭代器终止
}
}
个人认为拉取式迭代器更加灵活,用户可以进行更加细致的判断来决定要不要停止迭代。
4.5 关于迭代器的标准库
5.1 slices.All
func All[Slice ~[]E, E any](s Slice) iter.Seq2[int, E]
slices.All
会将切片转换成一个切片迭代器
func main() {
s := []int{1, 2, 3, 4, 5}
// i:索引;n:值
for i, n := range slices.All(s) {
/*
0 1
1 2
2 3
3 4
4 5
*/
fmt.Println(i, n)
}
}
5.2 slices.Values
func Values[Slice ~[]E, E any](s Slice) iter.Seq[E]
slices.Values
会将切片转换成一个切片迭代器,但是不带索引
func main() {
s := []int{1, 2, 3, 4, 5}
for n := range slices.Values(s) {
/*
1
2
3
4
5
*/
fmt.Println(n)
}
}
5.3 slices.Chunk
func Chunk[Slice ~[]E, E any](s Slice, n int) iter.Seq[Slice]
slices.Chunk
函数会返回一个迭代器,该迭代器会以n个元素为切片推送给调用者
func main() {
s := []int{1, 2, 3, 4, 5}
// .Chunk(s, n):s代表是切片;n代表是以n个元素为切割
for chunk := range slices.Chunk(s, 2) {
/*
[1 2]
[3 4]
[5]
*/
fmt.Println(chunk)
}
}
5.4 slices.Collect
func Collect[E any](seq iter.Seq[E]) []E
slices.Collect
函数会将切片迭代器收集成一个切片
func main() {
s := []int{1, 2, 3, 4, 5}
// slices.Values(s) 以迭代器形式返回切片的值(不含索引)
s2 := slices.Collect(slices.Values(s))
fmt.Println(s2)// [1 2 3 4 5]
}
5.5 maps.Keys
func Keys[Map ~map[K]V, K comparable, V any](m Map) iter.Seq[K]
maps.Keys
会返回一个迭代map所有键的迭代器,配合slices.Collect
可以直接收集成一个切片。
func main() {
m := map[string]int{"one": 1, "two": 2, "three": 3}
keys := slices.Collect(maps.Keys(m))
fmt.Println(keys)// [three one two]
}
5.6 maps.Values
func Values[Map ~map[K]V, K comparable, V any](m Map) iter.Seq[V]
maps.Values
会返回一个迭代map所有值的迭代器,配合slices.Collect
可以直接收集成一个切片。
func main() {
m := map[string]int{"one": 1, "two": 2, "three": 3}
keys := slices.Collect(maps.Values(m))
fmt.Println(keys) // [3 1 2]
}
5.7 maps.All
func All[Map ~map[K]V, K comparable, V any](m Map) iter.Seq2[K, V]
maps.All
可以将一个map转换为成一个map迭代器
func main() {
m := map[string]int{"one": 1, "two": 2, "three": 3}
for k, v := range maps.All(m) {
fmt.Println(k, v)
}
}
一般不会这么直接用,都是拿来配合其他数据流处理函数的。
5.8 maps.Collect
func Collect[K comparable, V any](seq iter.Seq2[K, V]) map[K]V
maps.Collect
可以将一个map迭代器收集成一个map
func main() {
m := map[string]int{"one": 1, "two": 2, "three": 3}
m2 := maps.Collect(maps.All(m))
fmt.Println(m2)
}
collect函数一般作为数据流处理的终结函数来使用。
4.6 理性看待
理性的看待Go迭代器,它确实使得编写代码更加方便,尤其是在处理切片类型的时候,但同时也会引入了些许复杂度,迭代器部分的代码可读性会降低。
它换在java中就差不多是lambda表达式
5. 😍 前篇知识回顾
- Go的环境安装与开发工具配置
- Go的运行流程步骤与包的概念
- (Go)变量与常量?字面量与变量的较量!
- 初上手Go?本篇文章帮拿捏Go的数据类型!
- (Go语言)条件判断与循环?切片和数组的关系?映射表与Map?三组关系傻傻分不清?本文带你了解基本的复杂类型与执行判断语句
- (Go语言)Go里面的指针如何?函数与方法怎么不一样?带你了解Go不同于其他高级语言的语法
6. 💕👉 其他好文推荐
- 还不了解Git分布式版本控制器?本文将带你全面了解并掌握
- 带你认识Maven的依赖、继承和聚合都是什么!有什么用?
- 2-3树思想与红黑树的实现与基本原理
- !全网最全! ElasticSearch8.7 搭配 SpringDataElasticSearch5.1 的使用
- 全面深入Java GC!!带你完全了解 GC垃圾回收机制!!
- 全面了解Java的内存模型(JMM)!详细清晰!
- 在JVM中,类是如何被加载的呢?本篇文章就带你认识类加载的一套流程!
全文资料学习全部参考于:Golang中文学习文档