golang 入门
前言
最近比较空闲,重新回顾了一下 golang 的基础知识,再结合之前的笔记,汇总了下形成了这篇文章。
可以根据目录查看对应的内容,快速回顾一下基础知识。
使用的编辑器:goland
使用的 go 版本:go1.20
简介和入门知识
简介
golang 是由 google 开发的一门开源的编程语言,诞生于 2006年1月2日15点4分5秒(这是重点,要记的),与 2009 年 11 月开源,2012 年发布稳定版本。诞生之初的主要目标是“兼具 Python 等动态语言的开发速度和 C/C++ 等编译语言的性能和安全性”。
这是一门天生支持并发的语言,可以很简单的通过语言特性来实现并发。除此之外,go 还具有以下特点:
- 属于编译型语言,性能优越,支持跨平台(交叉编译)
- 丰富的标准库
- 语言层面定义源代码的格式化(代码风格统一)
- 语法简单,开发效率高
- 自带 GC
go 语言的适用场景:
- 服务端开发
- 分布式系统、微服务
- 网络编程
- 区块链开发
- 云平台
go 文件结构
go 文件使用 .go
作为文件后缀。
所有的 go 文件都必须包含包名,通过 package
关键字指定,且同一个目录下的文件必须属于同一个包。不同包下的变量和函数等都是相互独立的,跨包调用必须通过包名,且只有导出的数据支持通过包名访问。
tip:go 通过首字母的大小写来指定变量/函数是否导出,首字母大写即为导出的数据。
要引用其他包的数据,必须先通过 import
关键字导入该包。
go 程序要想启动,必须包含一个 main 包,且该包下需要有且只有一个 main 函数,main 函数是整个 go 程序的入口。
让我们看看 go 怎么输出 hello world!
1 | package main |
go 的注释
单行注释:// xxx
多行注释: /* xxx */
go 程序的运行
1 | 运行 main.go |
了解完基础,接下来就进行根据各个部分进行介绍了。
变量以及数据类型
变量
首先介绍一下 golang 种定义变量的几种方式:
1 | // 1. 使用 var 关键字 ==> var name type = value |
零值
在上述介绍中出现了零值,我们在此处简单介绍一下零值的概念。golang 中所有类型均有对应的零值,在定义或者初始化的时候,若是没有为变量赋值,则会默认将其赋为对应类型的零值。
以下为 golang 中各种类型的零值表:
类型 | 零值 |
---|---|
int(以及其他所有的无符号或不同长度的整形类型) | 0 |
float32/float64 | 0.0 |
bool | false |
string | “” |
pointer | nil |
map/slice/channel/any | nil |
数据类型
在上述的零值表中出现了各种类型,既然如此,我们就来了解一下 golang 中的数据类型
- 布尔类型(bool),取值为 true 和 false
- 数值型
- 有符号整型(int),会根据操作系统判断,分配为 32 位或者 64 位
- 无符号整型(uint),同上
- 8/16/32/64 位的有符号整型(int8/int16/int32/int64)
- 8/16/32/64 位的无符号整型(uint8/uint16/uint32/uint64)
- 字节(byte),该类型等同于 uint8
- 字符(rune),该类型等同与 int32
- 单精度浮点数(float32)
- 双精度浮点数(float64)
- 32 位实数和虚数(complex64)
- 64 位实数和虚数(complex128)
- 字符串型(string),该类型为多个 byte 的集合,可以理解为字符数组
在有需要的时候,兼容的类型可以进行类型转换,但是要注意精度丢失的问题,语法如下:
1 | // Type(var) |
常量
与变量对应的,就是常量。指的是定义之后不允许改变的值,通常都是作为全局常量,避免运行过程中被意外篡改了。常量的类型只能为基本数据类型,即上述的布尔、数值以及字符串类型。
常量可以通过一下方式定义:
1 | // 1. 使用 const 关键字,const name type = value |
值得一提的是,golang 中并没有枚举值这个概念。但是其提供了 iota 关键字,可以用来动态分配常量的值。在同一个常量组中使用了 iota,其后续的常量不指定值的话,会默认在上一个的基础上自增1。
1 | const ( |
运算符
算数运算符
- 加减乘除 + - * /
- 求余 %
- 自加自减 ++ –
关系运算符
- 大于小于等于不等于 > < = !=
- 大于等于 小于等于 >= <=
逻辑运算符
- 逻辑与 &&
- 逻辑或 ||
- 逻辑非 !
位运算符
- 按位与 &
- 按位或 |
- 异或和按位取反 ^ (二元操作时表示异或,一元操作时表示按位取反,如:a^b 和 ^c)
- 位清空 &^ (对于 a&^b,表示对于 b 上的每一位,如果为 0 则取 a 对应位上的数值,如果为 1 则取 0)
- 左移和右移 << >>
赋值运算符
- 赋值 =
- 相加并赋值 +=
- 相减并赋值 -=
- 相乘并赋值 *=
- 相除并赋值 /=
- 同样规律的还有 %= <<= >>= &= |=
运算符的优先级
优先级 | 运算符 |
---|---|
7 | ~ ! ++ – |
6 | * / % << >> & | &^ |
5 | + - ^ |
4 | == != < <= > >= |
3 | <- |
2 | && |
1 | || |
程序的控制流程
选择结构
if
语法:
1 | if expr{ |
switch
语法:
1 | switch val{ |
每一条 case 分支的值都必须是唯一的,程序会自上而下找到第一个匹配项,然后执行其中的代码。执行完当前 case 默认会退出整个 switch 块,不需要增加 break 关键字。
有提前退出 switch 块的需求时,可以使用 break 关键字,退出当前的 switch 块。
若是在运行完当前 case 后,要继续执行下一个 case 中的代码(这时候不会去判断 case 的值是否符合),可以在当前 case 的末尾增加 fallthrought 关键字,该关键字会在当前 case 执行完成后,继续执行下面的下一个 case ,若是下一个 case 执行完,还要继续执行更下一个,同样需要增加 fallthrought 。
以下为一个 switch 的变种写法:
1 | // 若是 switch 没有表达式,则会匹配 ture |
循环结构
for
语法:
1 | // expr1 : 初始化语句,可以为空 |
for 循环可以多层嵌套,在多层嵌套的时候,单独使用 break 和 continue 只能退出所在层的 for 循环。可以使用标签,来退出指定的 for 循环。
1 | out: |
goto
在上文介绍 for 循环的时候,提到了一个机制:标签。标签不仅可以用来标记循环,也可以用来标记位置。当我们在代码中打了标签后,可以使用 goto 语句来跳到代码指定的位置。
需要注意的是,在 goto 语句之后,不能有新定义的变量。
1 | a := 10 |
复杂类型
数组(array)
定义数组的语法:
1 | // var arr [len]type 或 var arr = [len]type{} |
数组的引用:
1 | fmt.Println(arr1[0]) |
数组的遍历:
1 | // 使用普通 for 循环 |
小 tips :
go 语言支持一条语句给多个变量赋值,使用这种语法的话,可以很轻易的交换数组中两个元素的值
1 | a, b := 10,20 |
切片(slice)
简单来讲,切片其实就是不定长的,支持动态拓展的数组,其存放的是同一类型的多个值。
详细点说,切片本身并不存储数据,它其实就是一个结构体,其中存放了一个指向底层数组的指针、该切片的长度以及容量。
定义的语法:
1 | // var sli []type |
注意,未进行初始化的切片的值为 nil,对其进行操作是会报空指针异常的。
为了避免上述情况,在创建切片的时候,一般建议使用 make 函数
1 | // var sli = make([]type,len,cap) // cap 可省略 |
对切片中元素的引用以及遍历方式和数组一样,这里不再赘述。
以下讲一下切片特有的用法:
一是使用 append 函数向切片中增加元素
1 | sli4 = append(sli4,20) |
二是从数组创建切片
1 | arr2 := [...]int64{1,2,3,4,5,6,7,8,9,10} |
要使用切片的话,了解以上内容就足够了,当然了切片还有许多需要了解的东西,这些内容在后续再开文详细表述。
集合(map)
简单来讲,map 就是一组键值对(key-value)的集合。其中,key 的值是唯一的,可以通过 key 来快速检索数据。
定义的语法:
1 | // var m map[keyType]valType |
引用的语法:
1 | // 取值:map[key] |
遍历的语法:
1 | var m4 = map[int64]string{ |
删除的语法:
1 | // delete(mapName,key) |
获取长度:
1 | fmt.Println(len(m4)) // 长度为当前 map 中 key 的数量 |
注意事项:
- map 是一种无序的数据结构,我们可以通过 for range 来遍历它,但是每次遍历的顺序是无法保证的。
- key 值不可重复
- 不可对未初始化的 map 操作,此时该 map 为 nil
- 对 map 进行并发操作是不安全的
- map 的 key 类型必须是可比较的类型
字符串(string)
字符串虽然是 go 的基本数据类型,但在使用的时候还是有一些小坑的。在这里简单讲一下
- 使用 len(str) 获取的是字符串所占用的字节数,中文字符的话一般占用三字节
- 字符串本质上时 uint8 类型的切片,所以同样可以通过下标来获取对应位置的字符,也可以进行遍历
- 不可通过下标的方式修改字符串指定的字符,字符串是不可修改的
- 可以通过强制转换的方式,将 []uint8 类型转换成字符串
函数
在上文的描述中,我们其实已经使用过 go sdk 中给我们提供的函数了,诸如 make()、append() 等等。
以下我们讲一下 go 中的函数,如何定义以及如何使用。
函数的创建
定义的语法:
1 | /* |
go 的函数支持可变参数,意思是可以接受不定数量的参数,但在使用上是有一定限制的。
先描述一下可变参函数的定义语法:
1 | /* |
从语法中我们可以看出来,可变参数是通过三个点(…)来引入的。
上述定义的 testFunc4
函数,我们在调用的时候,sub 可以传入 0-n 个 string 类型的值。
在函数内部,接收到的 sub 的类型,其实就是 string 类型的切片,我们可以通过下标来取出其中的值。
要注意的是,可变参数在每一个函数中,只允许有一个,且必须在参数列表的最末尾的位置。
函数的使用
直接通过函数名,并传入对应的参数即可调用函数了。
1 | // funName(args) |
注意,函数定义后都是存放在当前文件指定的包中的。若是调用其他包的函数,需要通过包名来调用该包下的函数。
比如说我在 a 包下定义了一个函数 funcA
1 | package a |
我在 b 包下的,若是需要调用到 funcA 的话,需要通过包名 a 进行调用
1 | func funcB(){ |
匿名函数
匿名函数,即不指定函数名的函数,一般只能调用一次。当然,也可以使用一个变量来接收匿名函数,这样也可以做到多次调用。
1 | // 创建并直接执行一个函数 |
为什么要有匿名函数呢?
- 将匿名函数作为一个函数的参数
1 | func oper(a,b int,fun func(int,int)int)int{ |
- 将匿名函数另一个函数的返回值,可以形成闭包结构
一个外层函数中,有内层函数,该内层函数中,会操作外层函数的局部变量(不管是外层函数收到的参数还是外层函数中定义的局部变量),并且该外层函数的返回值就是这个内层函数,这个内层函数和外层函数的局部变量,统称为闭包结构
局部变量的生命周期会发生改变,正常的局部变量随着函数调用而创建,随着函数的结束而销毁。但是闭包结构中的外层函数的局部变量并不会随着外层函数的结束而销毁,因为内层函数还要继续使用
1 | func outFunc() func() int { |
函数的本质
在上文的匿名函数的介绍中,我们可以看到,函数是可以赋值给变量的。所以其实函数的本质也是一个变量,只不过这个变量比较特殊罢了,它存储的是函数体所在的地址。
函数的类型可以描述为 func (参数类型表)(返回值类型表)
,如 func(int,int)(int,string)
因此,当我们通过以下代码生命了 funcA
1 | func funcA(a int, b string) int64 { |
则 funcA 的类型即为 func(int,string)int64
此时我们可以使用一个变量去接受这个函数,并可以通过这个变量来调用函数
1 | var a func(int, string) int64 |
延迟(defer)
defer 也是 go 提供的关键字,作用是延迟一个函数或者方法的执行。有点像其他语言中的 final。
defer 必须在函数中调用,被 defer 的函数将会在主函数结束后执行。
若是有多个 defer 语句,执行顺序为添加顺序的倒序(先进后出,栈结构)
使用语法如下:
1 | func testDefer(){ |
值得一提的是,defer 延迟的是函数的执行,而不是函数的调用。当程序运行到 defer 所在的代码处时,defer 的函数就已经被调用的,只不过会等到时机合适了再执行。所以若是 defer 的函数有参数,传递的参数的值是调用时刻的值。
1 | func function(num int){ |
defer 常常用来做收尾的工作,比如说打开的资源最后都需要关闭,就可以使用 defer,在确定资源被打开后就能使用 defer 进行关闭,这样就不会造成资源泄露问题。
结构体
结构体的使用
结构体是有一系列相同类型或不同类型的数据构成的数据集合。
如何定义一个结构体:
1 | /* |
如何定义一个结构体变量
1 | stu1 := student{ |
对结构体的字段进行引用
1 | fmt.Println(stu1.name) |
定义一个结构体指针
1 | stuP1 := &student{} // 该用法等同于 new(studeng) |
上述例子中出现了 new
函数,new 跟 make 一样,也是 go 语言提供的内建函数。使用 new 可以创建一个类型的指针变量。
new(T) 分配了零值填充的T类型的内存空间,并返回其地址,即一个 *T 类型的值(即指针),因此可以用 new 来创建结构体,返回的是创建的结构体的指针,该结构体的内容为各个成员的零值。
对 new
和 make
进行简单的对比:
new(T) 用于各种类型的内存分配,返回的是所创建类型的指针,其中的数据都为对应类型的零值。
make(T,args) 只能用来创建 slice、map 和 channel,返回的是一个有初始值的 T 类型,而不是一个指针。
slice、map 以及 channel 只能用 make 来创建的原因是指向这三种类型的数据结构的引用在使用前必须被初始化,如 slice 必须包含指向底层数组的指针以及其长度和容量,在这些项目被初始化之前,slice 为nil。 make 初始化了它们内部的数据结构,填充了适当的值。
匿名结构体:没有名字的结构体
有匿名函数,当然也可以有匿名结构体,一般在只使用一次的时候或者作为一个函数的参数时,可以使用匿名结构体。
1 | a := struct{ |
既然结构体都能匿名,那结构体中的字段也匿名不过分吧
1 | b := struct{ |
使用了匿名字段的结构体,默认使用数据类型作为字段名。因此,使用了匿名字段的结构体中的匿名字段的类型不能重复,否则就会冲突。但是可以掺杂非匿名的字段,如:
1 | c := struct{ |
结构体也可以嵌套
1 | type person struct{ |
在 go 中还有一个还可以定义空结构体,空结构体不占用内存,可以用来作为信号使用。
接口
面向对象世界中的接口一般定义是“接口定义对象的行为”,它表示指定对象应该做什么,实现这种行为的方法是针对对象的。
在go语言中,接口是一组方法签名,当类型为(为:四声)接口中的所有方法提供定义时,它被称为实现接口。接口指定了类型应该具有的方法,类型决定了如何实现这些方法。
接口将所有具有共性的方法定义在一起,任何其他类型只要实现了这些方法就是实现了这个接口,如果某个对象实现了某个接口的所有方法,则此对象就实现了该接口。
设计接口的目的在于降低程序的耦合性,即各部分程序之间的关联性尽量降低,这样修改一部分代码时就不会牵扯到另外的代码。go语言中,接口和类型的实现关系是非侵入式的
定义一个接口:
1 | /* |
我们可以注意到,在上述接口的描述中,我们使用的是 method 而不是 function,这里我们就要引申一下方法的概念。
若是一个函数,是独属于指定的类型的,那就可以说这个函数是这个类型的方法。
方法就是一个包含了接收者的函数,接收者可以是命名类型或者结构体类型的值或者指针。
举个例子:
1 | type student struct{ |
上述例子中,我们为 student 类型创建了一个方法 eat(),s 即是接收器。
在上述基础上,我们再为 student 结构体增加一个 drink 方法:
1 | func (s student)drink(){ |
此时,我们可以看到 student 类型实现了 person 接口中的所有方法,此时我们就可以说 student 类型实现了接口 person。
此时,我们可能有个疑问,就是接口可以为空吗?答案是肯定的。这时我们就发现,因为空接口中没有任何方法,所以所有的类型都实现了空接口,所有类型都是空接口的实现。
基于此,我们可以说,空接口可以是任何类型。在 go 1.18 版本之后,还为空接口定义了一个别名,叫做 any
。
只说的话理解起来可能有点抽象,我们通过代码来讲解一下空接口的应用:
1 | // 1. 将函数的参数设置为空接口,以实现接受任何类型的参数 |
同样的,接口也可以支持嵌套
1 | type reader interface{ |
接口断言
在上文接口的描述中,我们发现空接口可以当做任意类型来使用。但我们要知道,go 是一种强类型的语言,那我们通过空接口来接收参数,那我们就只能将参数当做空接口来使用,这种情况下没办法拿到其内部存储的值,也没办法调用其原本类型的方法。
这时候就需要我们的接口断言出场了。
接口断言的作用就是,将一个接口对象,指定为某种特定的类型。指定后我们就可以将其当做我们指定的类型来使用了。
接口断言有两种方式:
1 | /* |
1 | /* |
我们都知道 go 是一门面向过程的语言,那有没有办法用 go 来实现 oop 呢?虽然没办法完全做到,但是还是能够模拟出 oop 的绝大多数功能的,这也是 go 的特色。此处暂时留个坑,后续会专门写一篇文来讲述用 go 如何模拟 oop。==> 跳转阅读详情
type 关键字
我们在上文创建结构体和接口的时候,都用到了 type
关键字。但其实,除了创建结构体和接口,type 关键字还有其他实用的地方。
- 可以用来定义新的类型
1 | // type newTypeName originType |
上述例子中,我们定义了一个 myint 类型,该类型本质上跟 int 类型是一致的,拥有相同的功能。
但他们是两种不同的类型,若是函数参数表的类型是 int,传 myint 类型将会报错,但可以通过类型强转的方式传值,这是允许的。
无法使用int类型给myint类型的变量赋值。
若是为 int 类型创建了方法,myint 类型也是没办法调用的。
- 类型别名
1 | // type otherName = Type |
此时,我们创建的 myint 类型就只是 int 类型的别名,他们两者是完全相同的。
总结
本文汇集了使用 Go 语言进行开发所需的基本知识,主要面向具备 Go 语言基础的开发者,并通过目录形式进行快速的知识点回顾。然而,对于一些较为复杂的主题,以及 Go 语言的重要特性——并发,本文并未进行详细阐述。这些深入的内容将在后续的文章中进行单独的深度解读。因此,本文可以视为你 Go 语言学习之旅的起点或是知识回顾的参考,而后续的文章将带领你进一步探索 Go 语言的强大之处。