Go语言编程笔记4:结构体和切片

Go语言编程笔记4:结构体和切片

image-20211108153040805

图源:wallpapercave.com

结构体

Go语言最主要使用的复合结构是结构体,我记得C和C++也是有结构体的,不过在C++中并不常用。事实上从语言继承的关系来说,Go语言与C语言是一脉相承的,所以又被称为类C语言。

定义

定义一个结构体就像是定义一个新的类型,只不过结构体一般会在结构体内部定义一些额外字段:

package main

type Pointer struct {
	x int
	y int
}

这里需要注意的是,虽然Go语言里的结构体可以类比为其它语言中的类,但是Go语言并没有“结构体命名时首字母大写”这样的命名惯例,相反,Go语言中,一个包中的结构体、变量、函数的首字母大写与否是由是否对包外部的调用可见来决定的,和这些命名所代表的类型完全没有关系。

声明一个结构体也很简单:

func main() {
	var p1 Pointer
}

同样的,如果是其它语言转过来的开发者,这里很容易会写为var pointer Pointer,虽然这里这样写并没有错,但是记住我前面说的,Go语言中并没有“结构体首字母一定会大写”这样的规则,所以很容易碰到同一个包内首字母小写的类型,所以别的语言里“临时对象使用首字母小写的类名命名”这样的习惯反而会带来一些麻烦。在Go语言中,一般会使用类型缩写来命名相应的变量,比如这里的var p1 Pointer,如果需要另外一个该类型的变量,可以命名为p2,以此类推。

需要特别说明的是,其它语言转过来的程序员很容易将上面的声明理解为声明了一个结构体Pointer的空指针,即p1 == nil,但这是不对的,因为在Go语言中结构体是一个值类型的变量,而不是引用类型。这点在Go语言编程笔记2:变量我有详细说明。所以这里实质上是会用0值来初始化p1,而结构体的0值实际上是结构体中的字段都用0值初始化后的一个结构体变量:

	var p1 Pointer
	fmt.Println(p1)
	// {0 0}

结构体没有其他语言中类那样的“构造函数”,事实上Go语言也完全没有传统OOP那样的继承关系,所以一个新定义的结构体是没有也不会有从别的地方继承来的默认方法的。

虽然我们可以用工厂方法添加一个结构体的初始化方法:

func newPointer(x, y int) Pointer {
	return Pointer{x: x, y: y}
}

但一般来说这么做意义不大,对于简单的结构体来说只要使用字面量直接初始化就够了:

	p1 = Pointer{x: 1, y: 1}
	fmt.Println(p1)
	// {1 1}

同样的,即使结构体内嵌套复杂的类型,也可以用类似的字面量来初始化:

type Pointers struct {
	pointers []Pointer
}
...省略
	var pts Pointers = Pointers{pointers: []Pointer{p1, {x: 1, y: 2}}}
	fmt.Println(pts)
	// {[{1 1} {1 2}]}

总的来说,相比其他语言中的类,Go语言偏向于使用简洁的方式来定义和初始化结构体。

方法

Go语言中通常只会给结构体添加方法,但实际上Go语言中,是可以给任何自定义类型添加方法的,即使其底层类型是基础类型:

type Celsius float64 //摄氏温度
func (c Celsius) String() string {
	return fmt.Sprintf("%.1fC", c)
}

type Fahrenheit float64 //华氏温度
func (f Fahrenheit) String() string {
	return fmt.Sprintf("%.1fF", f)
}

这里的例子来自Go语言编程笔记2:变量

Go语言的方法其实可以看做是“指定了一个接收者的函数”,而这个接收者就是方法实际调用时绑定的某个命名类型的变量。

这其实是一种函数式编程的风格和思路,熟悉Python的开发者应当不陌生。所以和Python类似,方法也是可以通过所绑定的类型名称来调用的:

	zero := Celsius(0)
	zeroString := Celsius.String(zero)
	fmt.Println(zeroString)
	// 0.0C

虽然Go语言中方法的理念和Python颇为类似,但从命名习惯上讲,Go语言中方法的接收者更习惯以类型缩写来命名,而不是thisself,下面的写法并不符合Go语言的命名习惯(虽然我还是挺喜欢这种风格的):

type Fahrenheit float64 //华氏温度
func (self Fahrenheit) String() string {
	return fmt.Sprintf("%.1fF", self)
}

如果命名类型的方法需要修改类型变量内的值,则方法的接收者需要替换为指针类型:

//重新将温度设置为0摄氏度
func (c *Celsius) Reset() {
	*c = 0
}
...省略
	t1 := Celsius(100)
	fmt.Println(t1)
	// 100.0C
	t1.Reset()
	fmt.Println(t1)
	// 0.0C

一般来说,结构体中的方法应当统一,比如都使用类型作为接收者或者类型指针作为接收者。当然这并不是硬性规定。

此外,方法的接收者是否为指针类型对接口来说尤为重要,这点在讨论接口时会进行讨论。

结构体的方法无论是否以指针为接收者,都不会影响对内部字段的调用:

func (p Pointer) add(other Pointer) Pointer {
	return Pointer{x: p.x + other.x, y: p.y + other.y}
}

func (p *Pointer) selfAdd(other Pointer) {
	p.x += other.x
	p.y += other.y
}
...省略
	p1 := Pointer{x: 1, y: 1}
	p2 := Pointer{x: 5, y: 5}
	fmt.Println(p1.add(p2))
	// {6 6}
	fmt.Println(p1)
	// {1 1}
	p1.selfAdd(p2)
	fmt.Println(p1)
	// {6 6}

事实上这是因为编译器会自动将指针类型的接收者进行转换,以调用对应变量的字段,比如上边的p.x += other.x,在编译后会以(*p).x += other.x的方式进行调用,但这只是某种语法糖,并不会真正模糊接收者的类型上的区别。

切片

原理

切片可以简单理解为Go语言版本的可变数组,其实现原理是通过存储以下三个值来实现:

  • 指向某个数组的某个元素的指针。
  • 切片长度。
  • 切片容量。

可以用一下图来说明:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-E9AZ9CWT-1636376759626)(C:\Users\70748\AppData\Roaming\Typora\typora-user-images\952033-20190414120007451-1556732330.png)]

图源

创建

创建一段切片可以通过多种途径。

直接声明:

	var numbers = []int{1, 2, 3}
	fmt.Println(numbers)
	// [1 2 3]

需要注意的是,生成指定元素的切片和数组的字面量是有微妙区别的,如果是生成数组,应该:

	var numbers2 = [...]int{1, 2, 3}
	fmt.Println(numbers2)
	// [1 2 3]

虽然两者打印出来的结果一样,但实际类型是不同的。

除了直接使用字面量创建,还可以使用内建的make函数来创建:

	var numbers3 = make([]int, 3, 5)
	fmt.Println(numbers3)
	// [0 0 0]
	numbers3[0] = 1
	numbers3[1] = 2
	numbers3[2] = 3
	fmt.Println(numbers3)
	// [1 2 3]

make函数的第一个参数是类型,第二个参数是切片长度,第三个参数是切片容量。所以make([]int, 3, 5)的实际含义是创建了一个长度为5的底层数组,然后切片的数组指针指向该数组的第一个元素,并且切片的初始长度设置为3。理所当然的,通过这种方式创建时,指定的切片长度是不能超过切片容量的。

通过这种方式创建的切片,其中的元素会被初始化为0值,这实际上是因为切片指向的底层数组在创建后被用0值来初始化。

除此之外,还可以通过已有的切片或数组来创建一个新的切片:

	array1 := [...]int{1, 2, 3, 4, 5}
	slice1 := array1[:]
	fmt.Println(slice1)
	// [1 2 3 4 5]
	slice2 := array1[2:4]
	fmt.Println(slice2)
	// [3 4]
	slice3 := slice1[2:]
	fmt.Println(slice3)
	// [3 4 5]

需要注意的是,通过这种方式创建的新的切片,底层数组和已有的数组或切片的底层数组是共享空间的,所以如果内部元素被修改了,共享底层数组的数据也会有相应改变。

扩容

之前说了,切片可以看做是动态数组,所以是可以进行扩容的,如果切片的底层数组还有剩余空间,也就是说切片长度小于切片容量,扩容是很容易的:

	slice4 := make([]int, 3, 5)
	slice4[0] = 1
	slice4[1] = 2
	slice4[2] = 3
	fmt.Println(len(slice4), cap(slice4))
	// 	3 5
	fmt.Println(slice4)
	// [1 2 3]
	slice4 = slice4[:5]
	slice4[3] = 4
	slice4[4] = 5
	fmt.Println(slice4)
	// [1 2 3 4 5]

使用lencap函数可以获取切片的长度和容量,slice4 = slice4[:5]可以将原来长度3的切片扩展到长度5,因为原始切片的最大容量是5。当然,这种方式扩容时不能超过切片容量。

如果切片长度已经等于切片容量,也就是说底层数组已经全部被切片使用了,则需要使用内建函数append

	numbers3 = append(numbers3, 4)
	numbers3 = append(numbers3, 5)
	numbers3 = append(numbers3, 6)
	fmt.Println(numbers3)
	// [1 2 3 4 5 6]

你可能会觉得奇怪,numbers3所在的底层数组长度不是5吗,为什么在塞进去5个数字后还能追加。此外numbers3 = append(numbers3, 6)这种写法也显得很多余,Python的写法就简单很多:

numbers = [1,2,3]
numbers.append(4)
numbers.append(5)
numbers.append(6)
print(numbers)
# [1, 2, 3, 4, 5, 6]

这是因为append函数的运行机制的原因,要说明这个问题,最好是自己实现一个append函数:

package my_append

func MyAppend(slice []int, newItem int) []int {
	sliceLength := len(slice) //切片长度
	sliceCap := cap(slice)    //切片容量
	if sliceLength >= sliceCap {
		//当前容量不够用,扩容
		var newSliceCap int
		if sliceLength*2 >= sliceLength+1 {
			newSliceCap = sliceLength * 2
		} else {
			newSliceCap = sliceLength + 1
		}
		newSlice := make([]int, sliceLength+1, newSliceCap)
		copy(newSlice, slice)
		slice = newSlice
	} else {
		//容量够,在原基础上增加切片长度
		slice = slice[:sliceLength+1]
	}
	slice[sliceLength] = newItem
	return slice
}

简单起见,这里指定了切片类型为int,如果要任意类型的切片扩容函数,需要使用反射技术,即reflect包,代码会复杂的多,且对于静态语言来说,会遇到一些难以克服的问题,比如动态创建一个运行时才能知道长度的数组。

该函数的思路并不复杂,在底层数组长度够用的情况下,直接扩展切片长度后追加元素,底层数组长度如果不够用,创建一个新的更长的数组,并拷贝原始数据后追加元素。考虑到扩容的成本,为了避免频繁创建数组,这里采用每次扩容都在原数组长度上加倍的方式。

来简单测试一下:

	numbers4 := []int{1, 2, 3}
	numbers4 = my_append.MyAppend(numbers4, 6)
	numbers4 = my_append.MyAppend(numbers4, 7)
	fmt.Println(numbers4)
	// [1 2 3 6 7]

现在应该不难理解为什么要用slice:=append(slice,item)这样的写法了,因为在使用append函数追加元素后,视底层数组的情况,扩容后的切片可能是一个使用了新的底层数组的新切片,所以我们必须将返回值赋给原始切片变量。

事实上内建函数append的扩容方案要复杂的多,编译器会试情况决定是否要创建新数组,以及新数组的长度。

细节

Go语言编程笔记2:变量中我们说过,切片是引用变量,所以可以在函数中修改参数传递过来的切片内容:

package main

import "fmt"

func main() {
	slice := []int{1, 2, 3}
	changeSlice(slice)
	fmt.Println(slice)
	// [99 2 3]
}

func changeSlice(slice []int) {
	slice[0] = 99
}

但是如果这样:

package main

import "fmt"

func main() {
	slice := []int{1, 2, 3}
	changeSlice(slice)
	fmt.Println(slice)
	// [1 2 3]
}

func changeSlice(slice []int) {
	slice = append(slice, 4)
	slice = append(slice, 5)
}

新手或许会疑惑为什么外部的切片不是[1 2 3 4 5],这其实和前边说的切片的原理有关,实际上切片的结构是类似下边的结构体:

type slice struct{
	pointer *int[]
	length int
	cap int
}

其实切片作为参数传递或者赋值时,实际上也是值拷贝,不过拷贝的是底层数组的指针和切片的长度和容量,理解了这一点就可以理解一些奇怪的现象:

package main

import "fmt"

func main() {
	slice := make([]int, 3, 5)
	slice[0] = 1
	slice[1] = 2
	slice[2] = 3
	changeSlice(slice)
	fmt.Println(slice)
	// [1 2 3]
	slice = slice[:5]
	fmt.Println(slice)
	// [1 2 3 4 5]
}

func changeSlice(slice []int) {
	sliceCopy := slice[:5]
	sliceCopy[3] = 4
	sliceCopy[4] = 5
}

changeSlice函数先扩展了原始切片,然后在原切片后追加了两个值,实际上此时底层数组已经变成了[1 2 3 4 5],但外层切片的指针、切片长度、容量都没有发生改变,所以表面上看上去外层切片和之前没有区别。但如果外层切片也进行扩容,就会发现扩容追加的两个元素并非初始化后的0值,而是4和5,这是changeSlice函数修改后的效果。

以上就是本篇笔记的全部内容,谢谢阅读。

往期内容

  • 0
    点赞
  • 1
    收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
©️2022 CSDN 皮肤主题:精致技术 设计师:CSDN官方博客 返回首页
评论 1

打赏作者

魔芋红茶

你的鼓励将是我创作的最大动力

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值