此系列文章主要用来记录自己学习 Go 语言的点点滴滴,在这个系列中,我将介绍和 Go 语言相关的基础知识,语言特性,性能优化以及最佳实践。
在这里,希望有在学习或者使用 Go 语言的小伙伴,也能够参与到我的学习旅程中来,希望每一位小伙伴不仅仅只是作为阅读者,还可以提问、分享你的见解,对我的内容进行反馈。因为我深信,真正的学习是相互的,让我们携手走进 Go 语言的世界,一起学习,一起成长,期待我们在 Go 语言的道路上共同前行。
Go 语言中没有“类”的概念,也不支持“类”的继承等面向对象的概念。在 Go 语言中,通过结构体的内嵌再配合接口,能够有比面向对象更高的扩展性和灵活性。
本文开始将会介绍 Go 语言里面的结构体相关内容,主要包含以下内容:
-
类型别名和自定义类型
- 类型别名
- 自定义类型
- 类型别名和自定义类型的区别
-
结构体
- 结构体基础语法
- 结构体和指针
- 结构体内存布局
类型别名和自定义类型
在正式介绍结构体之前,我们先介绍一下类型名别和自定义类型这两个概念。
类型别名
所谓类型别名,你可以看作是给一种已经存在的类型再取一个小名,就像一个孩子小时候有小名、乳名,上学后用学名,英语老师又会给他起英文名,但这些名字其实都指的是他同一个人。
Go 语言从 1.9 版本开始新增了类型别名的功能,语法如下:
type TypeAlias = Type
第一个 type 是关键字,表示这里要声明一个类型,TypeAlias 则是对应类型别名,等号后面的 Type 则是对应的具体类型。
在 Go 语言中,内置的数据类型 rune 和 byte 其实就是 uint8 和 int32 的类型别名,他们的定义如下:
type byte = uint8
type rune = int32
自定义类型
所谓自定义类型,则是我们自己定义了一个全新的类型,其语法如下:
// 将 MyInt 定义为 int 类型
type MyInt int
在上面的代码中,我们定义了一个名为 MyInt 的类型,这是一个全新的类型,它具有 int 的特性。
可能有的同学会觉得这里很奇怪,直接用 int 不就好了么?为啥要将 int 定义为一种新的类型 MyInt ?
这里其实是给后面的结构体做铺垫,下面是一个结构体的简单示例:
type person struct {
name string
city string
age int8
}
先不管后面的 struct 以及后面的大括号那一块,这里我们能清楚的看到,定义结构体时,其实就是定义了一个自定义的类型,例如这里就是定义了一个名为 person 的类型,只不过这个类型是一个结构体。
类型别名和自定义类型的区别
接下来我们来看一看类型别名和自定义类型之间的区别。
有的人觉得就是一个有等号,一个没有等号的区别,但是这种理解是比较肤浅的,那仅仅只是语法上面表现形式的区别。
我们通过下面的这段代码来解释两者之间的区别:
package main
import (
"fmt"
)
// 自定义类型
type NewInt int
// 类型别名
type MyInt = int
func main() {
var a NewInt
var b MyInt
fmt.Printf("type of a:%T\n", a) //type of a:main.NewInt
fmt.Printf("type of b:%T\n", b) //type of b:int
}
可以看到,结果显示 a 的类型是 main.NewInt,表示 main 包下定义的 NewInt 类型。b 的类型是 int。
MyInt 这种类型别名只会在代码中存在,编译完成后实际上并不会有 MyInt 类型。而 NewInt 则是一种实实在在的新类型,所以编译之后也是会存在的。
结构体
接下来我们来看一下 Go 语言里面的结构体。
在 Go 语言中,基础数据类型可以表示一些事物的基本属性,但是当我们想表达一个事物的全部或部分属性时,这时候再用单一的基本数据类型明显就无法满足需求了。Go 语言提供了结构体的方式,可以封装多个基本数据类型。
结构体基础语法
定义结构体的语法如下:
type 类型名 struct {
字段名 字段类型
字段名 字段类型
...
}
其中:
- 类型名:标识自定义结构体的名称,在同一个包内不能重复。
- 字段名:表示结构体字段名。结构体中的字段名必须唯一。
- 字段类型:表示结构体字段的具体类型。
例如,我们想要定义一个 student 的结构体:
type student struct {
name string
age int
gender string
score int
}
类型相同的字段,也可以写在一行,不过可读性稍微差那么一些:
type student struct {
name, gender string
age, score int
}
通过这种方式,我们就不再局限于基础数据类型只能描述单一的值,而是通过定义一个复合数据类型来描述一组值。
需要注意,上面的语法仅仅是创建了一种新的复合的数据类型而已,如果声明了类型没有使用的话是没有任何意义的。要使用结构体也非常简单,因为是一种新的类型,直接像内置类型一样使用即可:
var 变量名 结构体类型
例如:
package main
import (
"fmt"
)
type student struct {
name string
age int
gender string
score int
}
func main() {
var stu student
stu.name = "张三"
stu.age = 18
stu.gender = "男"
stu.score = 100
fmt.Println(stu) // {张三 18 男 100}
fmt.Printf("%v \n", stu) // {张三 18 男 100}
fmt.Printf("%+v \n", stu) // {name:张三 age:18 gender:男 score:100}
fmt.Printf("%#v \n", stu) // main.student{name:"张三", age:18, gender:"男", score:100}
}
在上面的代码中,我们声明了一个名为 student 的结构体,里面有 name、age、gender、score 等字段,之后在 main 中就像使用普通内置类型一样使用了 student 类型,通过 . 的方式来访问结构体内部的成员。
另外这里顺便显示了 %v、%+v、%#v 这几种格式化输出的方式:
- %v 按默认格式输出
- %+v 在%v的基础上额外输出字段名
- %#v 在%+v的基础上额外输出类型名
在某些定义临时复合数据的场景下,可以定义匿名的结构体,例如:
package main
import (
"fmt"
)
func main() {
var stu struct {
name string
age int
gender string
score int
}
stu.name = "张三"
stu.age = 18
stu.gender = "男"
stu.score = 100
fmt.Println(stu) // {张三 18 男 100}
fmt.Printf("%v \n", stu) // {张三 18 男 100}
fmt.Printf("%+v \n", stu) // {name:张三 age:18 gender:男 score:100}
fmt.Printf("%#v \n", stu) // main.student{name:"张三", age:18, gender:"男", score:100}
}
在上面的代码中,我们定义了一个匿名的结构体类型,然后直接用在了 stu 变量上面,由于是匿名的方式,所以该结构体是一次性的,仅仅用于指定 stu 的类型是什么样的。
除了结构体能够匿名,结构体里面的字段也可以匿名,例如:
package main
import (
"fmt"
)
type student struct {
string
int
}
func main() {
var stu = &student{
"张三",
18,
}
fmt.Println(stu) // &{张三 18}
fmt.Println(stu.string) // 张三
fmt.Println(stu.int) // 18
}
在上面的代码中,我们声明了一个名为 student 的结构,但是结构体里面字段采用的是匿名字段,但是你仔细观察后面访问 stu.string 和 stu.int 就会发现,其实并非是字段没有了,而是因为字段名和字段类型相同,所以可以进行简写。
结构体和指针
前面我们有介绍过 Go 语言里面的 指针,在使用结构体时,经常会涉及到和指针相关的操作。
先看下面的代码:
package main
import (
"fmt"
)
type student struct {
name string
age int
gender string
score int
}
func main() {
var stu1 student
var stu2 = new(student)
fmt.Println(stu1) // { 0 0}
fmt.Printf("%+v \n", stu1) // {name: age:0 gender: score:0}
fmt.Println(stu2) // &{ 0 0}
fmt.Printf("%+v \n", stu2) // &{name: age:0 gender: score:0}
}
在上面的代码中,我们声明了一个 student 的结构体类型,然后声明了两个变量 stu1 和 stu2,不同于 stu1,stu2 在声明的时候,使用到了 new 关键字,最终通过 new 关键字所声明的 stu2,背后存储的结构体的地址,也就是说,stu2 是一个指针变量。
因为在 Go 语言中虽然支持指针,但是仅仅支持指针的访问,并不支持指针的操作,因此我们可以直接对结构体指针使用 . 的形式来访问结构体里面的成员,例如:
package main
import (
"fmt"
)
type student struct {
name string
age int
gender string
score int
}
func main() {
var stu = new(student)
stu.name = "张三"
stu.age = 18
stu.gender = "男"
stu.age = 100
fmt.Println(stu) // &{张三 100 男 0}
}
另外可以使用 & 对结构体做取地址操作,这种操作等价于上面所介绍的 new 操作:
package main
import (
"fmt"
)
type student struct {
name string
age int
gender string
score int
}
func main() {
var stu1 = new(student)
var stu2 = &student{}
fmt.Println(stu1) // &{ 0 0}
fmt.Println(stu2) // &{ 0 0}
}
可以看到,上面我们分别使用 new 和 & 符号创建了两个变量,这两个变量都是指针变量,由于没有赋值,所以里面都是对应类型的零值。
相比 new 的方式,使用 & 符号时我们可以在声明变量的同时指定初始值,这样的操作会更方便一些:
package main
import (
"fmt"
)
type student struct {
name string
age int
gender string
score int
}
func main() {
var stu = &student{
name: "Alice",
age: 20,
gender: "Female",
score: 90,
}
fmt.Println(stu) // &{Alice 20 Female 90}
}
当某些字段没有初始值的时候,该字段可以不写。此时,没有指定初始值的字段的值就是该字段类型的零值。例如:
package main
import (
"fmt"
)
type student struct {
name string
age int
gender string
score int
}
func main() {
var stu = &student{
name: "Alice",
gender: "Female",
}
fmt.Println(stu) // &{Alice 0 Female 0}
}
指定初始值的时候还可以不写键,直接写值:
package main
import (
"fmt"
)
type student struct {
name string
age int
gender string
score int
}
func main() {
var stu = &student{
"Alice",
20,
"Female",
90,
}
fmt.Println(stu) // &{Alice 20 Female 90}
}
使用这种格式初始化时,需要注意:
- 必须初始化结构体的所有字段
- 初始值的填充顺序必须与字段在结构体中的声明顺序一致
- 该方式不能和键值初始化方式混用
仔细观察下面的初始化代码:
package main
import (
"fmt"
)
type student struct {
name string
age int
gender string
score int
}
func main() {
stu1 := student{
name: "Bill",
age: 20,
gender: "Male",
score: 100,
}
stu2 := &student{
name: "Alice",
age: 20,
gender: "Female",
score: 90,
}
fmt.Println(stu1) // {Bill 20 Male 100}
fmt.Println(stu2) // &{Alice 20 Female 90}
}
在上面的代码中,我们在声明 stu1 和 stu2 时,都在声明的同时对变量进行了初始化值的操作,但是区别在于一个用了 & 符号,另一个没有,两者之间是有区别的:
- stu1 是 student 类型的一个实例,也就是说它的值是直接存储在变量 stu1 中的。
- stu2 是一个指向 student 类型实例的指针,它的值是存储在内存的某个位置,而 stu2 存储的是这个位置的地址。
在 Go 语言中,结构体类型的变量默认是值类型,也就是说当你把 stu1 赋值给另一个变量时,会创建一个新的、与 stu1 有着相同值的 student 实例。任何对新变量的改变都不会影响到原变量 stu1。
例如:
stu3 := stu1
stu3.score = 80
fmt.Println(stu1.score) // 输出 100
fmt.Println(stu3.score) // 输出 80
然而,stu2 是指针类型,当你把 stu2 赋值给另一个变量时,会创建一个新的指针,这个新指针和 stu2 指向同一个 student 实例。因此,任何对新变量的改变都会影响到原变量 stu2。例如:
stu4 := stu2
stu4.score = 80
fmt.Println(stu2.score) // 输出 80
fmt.Println(stu4.score) // 输出 80
结构体内存布局
当我们创建了一个结构体,然后某个变量使用该结构体进行初始化之后,该变量所占用的内存是一块连续的内存。
下面的代码可以证明:
package main
import (
"fmt"
)
type test struct {
a int8
b int8
c int8
d int8
}
func main() {
n := test{
1, 2, 3, 4,
}
fmt.Printf("n.a %p\n", &n.a)
fmt.Printf("n.b %p\n", &n.b)
fmt.Printf("n.c %p\n", &n.c)
fmt.Printf("n.d %p\n", &n.d)
// n.a 0x140000a8014
// n.b 0x140000a8015
// n.c 0x140000a8016
// n.d 0x140000a8017
}
在上面的代码中,我们创建了一个名为 test 类型的结构,然后声明了一个 n 变量为 test 类型,然后分别打印了它的四个字段的内存地址。由于 int8 类型占用一个字节的内存,所以每个字段的地址都比前一个字段的地址大 1,这证明了结构体中的字段是在连续的内存地址中存储的。
然而,需要注意的是,虽然在这个特定的例子中,结构体的字段在内存中是连续的,但这并不总是成立。
Go 的编译器可能会对结构体进行内存对齐优化,这可能导致结构体的字段之间存在内存空隙。具体的内存布局取决于各个字段的类型和它们在结构体中的顺序。例如,在以下的代码中,test 结构体中的字段并不是在连续的内存地址中存储的:
package main
import (
"fmt"
)
type test struct {
a int8
b int32
c int8
d int32
}
func main() {
n := test{
1, 2, 3, 4,
}
fmt.Printf("n.a %p\n", &n.a)
fmt.Printf("n.b %p\n", &n.b)
fmt.Printf("n.c %p\n", &n.c)
fmt.Printf("n.d %p\n", &n.d)
// n.a 0x140000140c0
// n.b 0x140000140c4
// n.c 0x140000140c8
// n.d 0x140000140cc
}
关于 Go 语言中内存对齐的相关内容,可以参阅 这里。
另外,空结构体是不占用空间的,例如:
package main
import (
"fmt"
"unsafe"
)
type test struct {
a int8
b int8
c int8
d int8
}
func main() {
var a = test{
1, 2, 3, 4,
}
fmt.Println(unsafe.Sizeof(a)) // 4
var b test
fmt.Println(unsafe.Sizeof(b)) // 4
var c struct{}
fmt.Println(unsafe.Sizeof(c)) // 0
}
在上面的代码中,a 就不用说了,肯定是会占用内存空间的,而 b 之所以也会占用内存空间是因为当 b 声明为 test 类型后,会初始化为相应的零值。c 的类型为 struct{ },Go 语言中的空结构体 struct{ } 是特殊的一种结构体,它不包含任何字段,因此不占用任何内存。
-EOF-