Loading

Go笔记

数组

数组,是用来存储集合数据的,这种场景非常多,我们编码的过程中,都少不了要读取或者存储数据。当然除了数组之外,我们还有切片、Map映射等数据结构可以帮我们存储数据,但是数组是它们的基础。

内部实现

要想更清晰的了解数组,我们得了解它的内部实现。数组是长度固定的数据类型,必须存储一段相同类型的元素,而且这些元素是连续的。我们这里强调固定长度,可以说这是和切片最明显的区别。

数组存储的类型可以是内置类型,比如整型或者字符串,也可以是自定义的数据结构。因为是连续的,所以索引比较好计算,所以我们可以很快的索引数组中的任何数据。

这里的索引,一直都是0,1,2,3这样的,因为其元素类型相同,我们也可以使用反射,获取类型占用大小,进行移位,获取相应的元素,这个反射的时候,我们再讲。

声明和初始化

数组的声明和初始化,和其他类型差不多。声明的原则是:

  1. 指明存储数据的类型。
  2. 存储元素的数量,也就是数组长度。
var array [5]int

以上我们声明了一个数组array,但是我们还没有对他进行初始化,这时候数组array里面的值,是对应元素类型的零值,也就是说,现在这个数组是5个0,这和我们java不一样,java里是null。

数组一旦声明后,其元素类型和大小都不能变了,如果还需要存储更多的元素怎么办?那么只能通过创建一个新的数组,然后把原来数组的数据复制过去。

刚刚声明的数组已经被默认的元素类型零值初始化了,如果我们再次进行初始化怎么做呢,可以采用如下办法:

var array [5]int
array = [5]int{1,2,3,4,5}

这两步比较繁琐,Go为我们提供了:=操作符,可以让我们在创建数组的时候直接初始化。

array:=[5]int{1,2,3,4,5}

这种简短变量声明的方式不仅适用于数组,还适用于任何数据类型,这也是Go语言中常用的方式。

有时候我们更懒,连数组的长度都不想指定,不过没有关系,使用...代替就好了,Go会自动推导出数组的长度。

注意和切片的区别

array:=[...]int{1,2,3,4,5}

假如我们只想给索引为1和3的数组初始化相应的值,其他都为0怎么做呢,直接的办法有:

array:=[5]int{0,1,0,4,0}

还有一种更好的办法,上面讲默认初始化为零值,那么我们就可以利用这个特性,只初始化索引1和3的值:

array:=[5]int{1:1,3:4}

使用数组

数组的访问非常简单,通过索引即可,操作符为[],因为内存是连续的,所以索引访问的效率非常高。

array:=[5]int{1:1,3:4}
fmt.Printf("%d",array[1])

修改数组中的一个元素也很简单:

array:=[5]int{1:1,3:4}
fmt.Printf("%d\n",array[1])
array[1] = 3
fmt.Printf("%d\n",array[1])

如果我们要循环打印数组中的所有值,一个传统的就是常用的for循环:

func main() {
	array := [5]int{1: 1, 3: 4}

	for i := 0; i < 5; i++ {
		fmt.Printf("索引:%d,值:%d\n", i, array[i])
	}
}

不过大部分时候,我们都是使用for rang循环:

func main() {
	array := [5]int{1: 1, 3: 4}

	for i, v := range array {
		fmt.Printf("索引:%d,值:%d\n", i, v)
	}
}

这两段示例代码,输出的结果是一样的。

同样类型的数组是可以相互赋值的,不同类型的不行,会编译错误。那么什么是同样类型的数组呢?Go语言规定,必须是长度一样,并且每个元素的类型也一样的数组,才是同样类型的数组。

array := [5]int{1: 1, 3: 4}
var array1 [5]int = array //success
var array2 [4]int = array1 //error

指针数组和数组本身差不多,只不过元素类型是指针。

array := [5]*int{1: new(int), 3:new(int)}

这样就创建了一个指针数组,并且为索引1和3都创建了内存空间,其他索引是指针的零值nil,这时候我们要修改指针变量的值也很简单,如下即可:

array := [5]*int{1: new(int), 3:new(int)}
*array[1] = 1

以上需要注意的是,只可以给索引1和3赋值,因为只有它们分配了内存,才可以赋值,如果我们给索引0赋值,运行的时候,会提示无效内存或者是一个nil指针引用。

panic: runtime error: invalid memory address or nil pointer dereference

要解决这个问题,我们要先给索引0分配内存,然后再进行赋值修改。

array := [5]*int{1: new(int), 3:new(int)}
array[0] =new(int)
*array[0] = 2
fmt.Println(*array[0])

函数间传递数组

在函数间传递变量时,总是以值的方式,如果变量是个数组,那么就会整个复制,并传递给函数,如果数组非常大,比如长度100多万,那么这对内存是一个很大的开销。

func main() {
	array := [5]int{1: 2, 3:4}
	modify(array)
	fmt.Println(array)
}

func modify(a [5]int){
	a[1] =3
	fmt.Println(a)
}

通过上面的例子,可以看到,数组是复制的,原来的数组没有修改。我们这里是5个长度的数组还好,如果有几百万怎么办,有一种办法是传递数组的指针,这样,复制的大小只是一个数组类型的指针大小。

func main() {
	array := [5]int{1: 2, 3:4}
	modify(&array)
	fmt.Println(array)
}

func modify(a *[5]int){
	a[1] =3
	fmt.Println(*a)
}

这是传递数组的指针的例子,会发现数组被修改了。所以这种情况虽然节省了复制的内存,但是要谨慎使用,因为一不小心,就会修改原数组,导致不必要的问题。

这里注意,数组的指针和指针数组是两个概念,数组的指针是*[5]int,指针数组是[5]*int,注意*的位置。

针对函数间传递数组的问题,比如复制问题,比如大小僵化问题,都有更好的解决办法,这个就是切片,它更灵活。

切片

切片也是一种数据结构,它和数组非常相似,因为他是围绕动态数组的概念设计的,可以按需自动改变大小,使用这种结构,可以更方便的管理和使用数据集合。

内部实现

切片是基于数组实现的,它的底层是数组,它自己本身非常小,可以理解为对底层数组的抽象。因为机遇数组实现,所以它的底层的内存是连续非配的,效率非常高,还可以通过索引获得数据,可以迭代以及垃圾回收优化的好处。

切片对象非常小,是因为它是只有3个字段的数据结构:一个是指向底层数组的指针,一个是切片的长度,一个是切片的容量。这3个字段,就是Go语言操作底层数组的元数据,有了它们,我们就可以任意的操作切片了。

声明和初始化

切片创建的方式有好几种,我们先看下最简洁的make方式。

slice:=make([]int,5)

使用内置的make函数时,需要传入一个参数,指定切片的长度,例子中我们使用的时5,这时候切片的容量也是5。当然我们也可以单独指定切片的容量。

slice:=make([]int,5,10)

这时,我们创建的切片长度是5,容量是10,需要注意的这个容量10其实对应的是切片底层数组的。

因为切片的底层是数组,所以创建切片时,如果不指定字面值的话,默认值就是数组的元素的零值。这里我们所以指定了容量是10,但是我们职能访问5个元素,因为切片的长度是5,剩下的5个元素,需要切片扩充后才可以访问。

容量必须>=长度,我们是不能创建长度大于容量的切片的。

还有一种创建切片的方式,是使用字面量,就是指定初始化的值。

slice:=[]int{1,2,3,4,5}

有没有发现,是创建数组非常像,只不过不用制定[]中的值,这时候切片的长度和容量是相等的,并且会根据我们指定的字面量推导出来。当然我们也可以像数组一样,只初始化某个索引的值:

slice:=[]int{4:1}

这是指定了第5个元素为1,其他元素都是默认值0。这时候切片的长度和容量也是一样的。这里再次强调一下切片和数组的微小差别。

//数组
array:=[5]int{4:1}
//切片
slice:=[]int{4:1}

切片还有nil切片和空切片,它们的长度和容量都是0,但是它们指向底层数组的指针不一样,nil切片意味着指向底层数组的指针为nil,而空切片对应的指针是个地址。

//nil切片
var nilSlice []int
//空切片
slice:=[]int{}

nil切片表示不存在的切片,而空切片表示一个空集合,它们各有用处。

切片另外一个用处比较多的创建是基于现有的数组或者切片创建。

slice := []int{1, 2, 3, 4, 5}
slice1 := slice[:]
slice2 := slice[0:]
slice3 := slice[:5]

fmt.Println(slice1)
fmt.Println(slice2)
fmt.Println(slice3)

基于现有的切片或者数组创建,使用[i:j]这样的操作符即可,她表示以i索引开始,到j索引结束,截取原数组或者切片,创建而成的新切片,新切片的值包含原切片的i索引,但是不包含j索引。对比Java的话,发现和String的subString方法很像。

i如果省略,默认是0;j如果省略默认是原数组或者切片的长度,所以例子中的三个新切片的值是一样的。这里注意的是ij都不能超过原切片或者数组的索引。

slice := []int{1, 2, 3, 4, 5}
newSlice := slice[1:3]

newSlice[0] = 10
	
fmt.Println(slice)
fmt.Println(newSlice)

这个例子证明了,新的切片和原切片共用的是一个底层数组,所以当修改的时候,底层数组的值就会被改变,所以原切片的值也改变了。当然对于基于数组的切片也一样的。

我们基于原数组或者切片创建一个新的切片后,那么新的切片的大小和容量是多少呢?这里有个公式:

对于底层数组容量是k的切片slice[i:j]来说
长度:j-i
容量:k-i

比如我们上面的例子slice[1:3],长度就是3-1=2,容量是5-1=4。不过代码中我们计算的时候不用这么麻烦,因为Go语言为我们提供了内置的lencap函数来计算切片的长度和容量。

slice := []int{1, 2, 3, 4, 5}
newSlice := slice[1:3]

fmt.Printf("newSlice长度:%d,容量:%d",len(newSlice),cap(newSlice))

以上基于一个数组或者切片使用2个索引创建新切片的方法,此外还有一种3个索引的方法,第3个用来限定新切片的容量,其用法为slice[i:j:k]

slice := []int{1, 2, 3, 4, 5}
newSlice := slice[1:2:3]

这样我们就创建了一个长度为2-1=1,容量为3-1=2的新切片,不过第三个索引,不能超过原切片的最大索引值5。

使用切片

使用切片,和使用数组一样,通过索引就可以获取切片对应元素的值,同样也可以修改对应元素的值。

slice := []int{1, 2, 3, 4, 5}
fmt.Println(slice[2]) //获取值
slice[2] = 10 //修改值
fmt.Println(slice[2]) //输出10

切片只能访问到其长度内的元素,访问超过长度外的元素,会导致运行时异常,与切片容量关联的元素只能用于切片增长。

我们前面讲了,切片算是一个动态数组,所以它可以按需增长,我们使用内置append函数即可。append函数可以为一个切片追加一个元素,至于如何增加、返回的是原切片还是一个新切片、长度和容量如何改变这些细节,append函数都会帮我们自动处理。

slice := []int{1, 2, 3, 4, 5}
newSlice := slice[1:3]
	
newSlice=append(newSlice,10)
fmt.Println(newSlice)
fmt.Println(slice)

//Output
[2 3 10]
[1 2 3 10 5]

例子中,通过append函数为新创建的切片newSlice,追加了一个元素10,我们发现打印的输出,原切片slice的第4个值也被改变了,变成了10。引起这种结果的原因是因为newSlice有可用的容量,不会创建新的切片来满足追加,所以直接在newSlice后追加了一个元素10,因为newSliceslice切片共用一个底层数组,所以切片slice的对应的元素值也被改变了。

这里newSlice新追加的第3个元素,其实对应的是slice的第4个元素,所以这里的追加其实是把底层数组的第4个元素修改为10,然后把newSlice长度调整为3。

如果切片的底层数组,没有足够的容量时,就会新建一个底层数组,把原来数组的值复制到新底层数组里,再追加新值,这时候就不会影响原来的底层数组了。

所以一般我们在创建新切片的时候,最好要让新切片的长度和容量一样,这样我们在追加操作的时候就会生成新的底层数组,和原有数组分离,就不会因为共用底层数组而引起奇怪问题,因为共用数组的时候修改内容,会影响多个切片。

append函数会智能的增长底层数组的容量,目前的算法是:容量小于1000个时,总是成倍的增长,一旦容量超过1000个,增长因子设为1.25,也就是说每次会增加25%的容量。

内置的append也是一个可变参数的函数,所以我们可以同时追加好几个值。

newSlice=append(newSlice,10,20,30)

此外,我们还可以通过...操作符,把一个切片追加到另一个切片里。

slice := []int{1, 2, 3, 4, 5}
newSlice := slice[1:2:3]

newSlice=append(newSlice,slice...)
fmt.Println(newSlice)
fmt.Println(slice)
[2 1 2 3 4 5]
[1 2 3 4 5]

迭代切片

切片是一个集合,我们可以使用 for range 循环来迭代它,打印其中的每个元素以及对应的索引。

	slice := []int{1, 2, 3, 4, 5}
	for i,v:=range slice{
		fmt.Printf("索引:%d,值:%d\n",i,v)
	}

如果我们不想要索引,可以使用_来忽略它,这是Go语言的用法,很多不需要的函数等返回值,都可以忽略。

    slice := []int{1, 2, 3, 4, 5}
	for _,v:=range slice{
		fmt.Printf("值:%d\n",v)
	}

这里需要说明的是range返回的是切片元素的复制,而不是元素的引用。

除了for range循环外,我们也可以使用传统的for循环,配合内置的len函数进行迭代。

	slice := []int{1, 2, 3, 4, 5}
	for i := 0; i < len(slice); i++ {
		fmt.Printf("值:%d\n", slice[i])
	}

在函数间传递切片

我们知道切片是3个字段构成的结构类型,所以在函数间以值的方式传递的时候,占用的内存非常小,成本很低。在传递复制切片的时候,其底层数组不会被复制,也不会受影响,复制只是复制的切片本身,不涉及底层数组。

func main() {
	slice := []int{1, 2, 3, 4, 5}
	fmt.Printf("%p\n", &slice)
	modify(slice)
	fmt.Println(slice)
}

func modify(slice []int) {
	fmt.Printf("%p\n", &slice)
	slice[1] = 10
}

打印的输出如下:

0xc420082060
0xc420082080
[1 10 3 4 5]

仔细看,这两个切片的地址不一样,所以可以确认切片在函数间传递是复制的。而我们修改一个索引的值后,发现原切片的值也被修改了,说明它们共用一个底层数组。

在函数间传递切片非常高效,而且不需要传递指针和处理复杂的语法,只需要复制切片,然后根据自己的业务修改,最后传递回一个新的切片副本即可,这也是为什么函数间传递参数,使用切片,而不是数组的原因。

关于多维切片就不介绍了,还有多维数组,一来它和普通的切片数组一样,只不过是多个一维组成的多维;二来我压根不推荐用多维切片和数组,可读性不好,结构不够清晰,容易出问题。

Map

书里把Map翻译为映射,我觉得太硬,所以这篇文章里,我还是用英文Map。

Map是一种数据结构,是一个集合,用于存储一系列无序的键值对。它基于键存储的,键就像一个索引一样,这也是Map强大的地方,可以快速快速检索数据,键指向与该键关联的值。

内部实现

Map是给予散列表来实现,就是我们常说的Hash表,所以我们每次迭代Map的时候,打印的Key和Value是无序的,每次迭代的都不一样,即使我们按照一定的顺序存在也不行。

Map的散列表包含一组桶,每次存储和查找键值对的时候,都要先选择一个桶。如何选择桶呢?就是把指定的键传给散列函数,就可以索引到相应的桶了,进而找到对应的键值。

这种方式的好处在于,存储的数据越多,索引分布越均匀,所以我们访问键值对的速度也就越快,当然存储的细节还有很多,大家可以参考Hash相关的知识,这里我们只要记住Map存储的是无序的键值对集合

声明和初始化

Map的创建有make函数,Map字面量。make函数我们用它创建过切片,除此之外,它还可以涌来创建Map。

dict:=make(map[string]int)

示例中创建了一个键类型为string的,值类型为int的map。现在创建好之后,这个map是空的,里面什么都没有,我们给存储一个键值对。

dict := make(map[string]int)
dict["张三"] = 43

存储了一个Key为张三的,Value为43的键值对数据。

此外还有一种使用map字面量的方式创建和初始化map,对于上面的例子,我们可以同等实现。

dict := map[string]int{"张三":43}

使用一个大括号进行初始化,键值对通过:分开,如果要同时初始化多个键值对,使用逗号分割。

dict := map[string]int{"张三":43,"李四":50}

当然我们可以不指定任何键值对,也就是一个空map。

dict := map[string]int{}

不管怎么样,使用map的字面量创建一定要带上大括号。如果我们要创建一个nil的Map怎么做呢?nil的Map是未初始化的,所以我们可以只声明一个变量,既不能使用map字面量,也不能使用make函数分配内存。

var dict map[string]int

这样就好了,但是这样我们是不能操作存储键值对的,必须要初始化后才可以,比如使用make函数,为其开启一块可以存储数据的内存,也就是初始化。

var dict map[string]int
dict = make(map[string]int)
dict["张三"] = 43
fmt.Println(dict)

Map的键可以是任何值,键的类型可以是内置的类型,也可以是结构类型,但是不管怎么样,这个键可以使用==运算符进行比较,所以像切片、函数以及含有切片的结构类型就不能用于Map的键了,因为他们具有引用的语义,不可比较。

对于Map的值来说,就没有什么限制了,切片这种在键里不能用的,完全可以用在值里。

使用Map

Map的使用很简单,和数组切片差不多,数组切片是使用索引,Map是通过键。

dict := make(map[string]int)
dict["张三"] = 43

以上示例,如果键张三存在,则对其值修改,如果不存在,则新增这个键值对。

获取一个Map键的值也很简单,和存储差不多,还是给予上面的例子。

age := dict["张三"]

在Go Map中,如果我们获取一个不存在的键的值,也是可以的,返回的是值类型的零值,这样就会导致我们不知道是真的存在一个为零值的键值对呢,还是说这个键值对就不存在。对此,Map为我们提供了检测一个键值对是否存在的方法。

age,exists := dict["李四"]

看这个例子,和获取键的值没有太大区别,只是多了一个返回值。第一个返回值是键的值;第二个返回值标记这个键是否存在,这是一个boolean类型的变量,我们判断它就知道该键是否存在了。这也是Go多值返回的好处。

如果我们想删除一个Map中的键值对,可以使用Go内置的delete函数。

delete(dict,"张三")

delete函数接受两个参数,第一个是要操作的Map,第二个是要删除的Map的键。

delete函数删除不存在的键也是可以的,只是没有任何作用。

想要遍历Map的话,可以使用for range风格的循环,和遍历切片一样。

dict := map[string]int{"张三": 43}
for key, value := range dict {
	fmt.Println(key, value)
}

这里的range 返回两个值,第一个是Map的键,第二个是Map的键对应的值。这里再次强调,这种遍历是无序的,也就是键值对不会按既定的数据出现,如果想安顺序遍历,可以先对Map中的键排序,然后遍历排序好的键,把对应的值取出来,下面看个例子就明白了。

func main() {
	dict := map[string]int{"王五": 60, "张三": 43}
	var names []string
	for name := range dict {
		names = append(names, name)
	}
	sort.Strings(names) //排序
	for _, key := range names {
		fmt.Println(key, dict[key])
	}
}

这个例子里有个技巧,range 一个Map的时候,也可以使用一个返回值,这个默认的返回值就是Map的键。

在函数间传递Map

函数间传递Map是不会拷贝一个该Map的副本的,也就是说如果一个Map传递给一个函数,该函数对这个Map做了修改,那么这个Map的所有引用,都会感知到这个修改。

func main() {
	dict := map[string]int{"王五": 60, "张三": 43}
	modify(dict)
	fmt.Println(dict["张三"])
}

func modify(dict map[string]int) {
	dict["张三"] = 10
}

上面这个例子输出的结果是10,也就是说已经被函数给修改了,可以证明传递的并不是一个Map的副本。这个特性和切片是类似的,这样就会更高,因为复制整个Map的代价太大了。

类型

Go 语言是一种静态类型的编程语言,所以在编译器进行编译的时候,就要知道每个值的类型,这样编译器就知道要为这个值分配多少内存,并且知道这段分配的内存表示什么。

提前知道值的类型的好处有很多,比如编译器可以合理的使用这些值,可以进一步优化代码,提高执行的效率,减少bug等等。

基本类型

基本类型是Go语言自带的类型,比如数值类型、浮点类型、字符类型以及布尔类型,他们本质上是原始类型,也就是不可改变的,所以对他们进行操作,一般都会返回一个新创建的值,所以把这些值传递给函数时,其实传递的是一个值的副本。

func main() {
	name:="张三"
	fmt.Println(modify(name))
	fmt.Println(name)
}

func modify(s string) string{
	s=s+s
	return s
}
张三张三
张三

以上是一个操作字符串的例子,通过打印的结果,可以看到,本来name的值并没有被改变,也就是说,我们传递的时一个副本,并且返回一个新创建的字符串。

基本类型因为是拷贝的值,并且在对他进行操作的时候,生成的也是新创建的值,所以这些类型在多线程里是安全的,我们不用担心一个线程的修改影响了另外一个线程的数据。

引用类型

引用类型和原始的基本类型恰恰相反,它的修改可以影响到任何引用到它的变量。在Go语言中,引用类型有切片、map、接口、函数类型以及chan

引用类型之所以可以引用,是因为我们创建引用类型的变量,其实是一个标头值,标头值里包含一个指针,指向底层的数据结构,当我们在函数中传递引用类型时,其实传递的是这个标头值的副本,它所指向的底层结构并没有被复制传递,这也是引用类型传递高效的原因。

本质上,我们可以理解函数的传递都是值传递,只不过引用类型传递的是一个指向底层数据的指针,所以我们在操作的时候,可以修改共享的底层数据的值,进而影响到所有引用到这个共享底层数据的变量。

func main() {
	ages := map[string]int{"张三": 20}
	fmt.Println(ages)
	modify(ages)
	fmt.Println(ages)
}

func modify(m map[string]int) {
	m["张三"] = 10
}

这是一个很明显的修改引用类型的例子,函数modify的修改,会影响到原来变量ages的值。

结构类型

结构类型是用来描述一组值的,比如一个人有身高、体重、名字和年龄等,本质上是一种聚合型的数据类型。

type person struct {
	age int
	name string
}

要定义一个结构体的类型,通过type关键字和类型struct进行声明,以上我们就定义了一个结构体类型person,它有age,name这两个字段数据。

结构体类型定义好之后,就可以进行使用了,我们可以用过var关键字声明一个结构体类型的变量。

var p person

这种声明的方式,会对结构体person里的数据类型默认初始化,也就是使用它们类型的零值,如果要创建一个结构体变量并初始化其为零值时,这种var方式最常用。

如果我们需要指定非零值,就可以使用我们字面量方式了。

jim := person{10,"Jim"}

示例这种我们就为其指定了值,注意这个值的顺序很重要,必须和结构体里声明字段的顺序一致,当然我们也可以不按顺序,但是这时候我们必须为字段指定值。

jim := person{name:"Jim",age:10}

使用冒号:分开字段名和字段值即可,这样我们就不用严格的按照定义的顺序了。

除了基本的原始类型外,结构体内的值也可以是引用类型,或者自己定义的其他类型。具体选择类型,要根据实际情况,比如是否允许修改值本身,如果允许的话,可以选择引用类型,如果不允许的话,则需要使用基本类型。

函数传参是值传递,所以对于结构体来说也不例外,结构体传递的是其本身以及里面的值的拷贝。

func main() {
	jim := person{10,"Jim"}
	fmt.Println(jim)
	modify(jim)
	fmt.Println(jim)
}

func modify(p person) {
	p.age =p.age+10
}

type person struct {
	age int
	name string
}

以上示例的输出是一样的,所以我们可以验证传递的是值的副本。如果上面的例子我们要修改age的值可以通过传递结构体的指针,我们稍微改动下例子

func main() {
	jim := person{10,"Jim"}
	fmt.Println(jim)
	modify(&jim)
	fmt.Println(jim)
}

func modify(p *person) {
	p.age =p.age+10
}

type person struct {
	age int
	name string
}

这个例子的输出是

{10 Jim}
{20 Jim}

非常明显的,age的值已经被改变。如果结构体里有引用类型的值,比如map,那么我们即使传递的是结构体的值副本,如果修改这个map的话,原结构的对应的map值也会被修改,这里不再写例子,大家可以验证下。

自定义类型

Go语言支持我们自定义类型,比如刚刚上面的结构体类型,就是我们自定义的类型,这也是比较常用的自定义类型的方法。

另外一个自定义类型的方法是基于一个已有的类型,就是基于一个现有的类型创造新的类型,这种也是使用type关键字。

type Duration int64

我们在使用time这个包的时候,对于类型time.Duration应该非常熟悉,它其实就是基于int64 这个基本类型创建的新类型,来表示时间的间隔。

但是这里我们注意,虽然Duration是基于int64创建,觉得他们其实一样,比如都可以使用数字赋值。

type Duration int64

var i Duration = 100
var j int64 = 100

但是本质上,他们并不是同一种类型,所以对于Go这种强类型语言,他们是不能相互赋值的。

type Duration int64

var dur Duration
dur=int64(100)
fmt.Println(dur)

上面的例子,在编译的时候,会报类型转换的异常错误。

cannot use int64(100) (type int64) as type Duration in assignment

Go的编译器不会像Java的那样,帮我们做隐式的类型转换。

有时候,大家会迷茫,已经有了int64这些类型了,可以表示,还要基于他们创建新的类型做什么?其实这就是Go灵活的地方,我们可以使用自定义的类型做很多事情,比如添加方法,比如可以更明确的表示业务的含义等等,下一篇方法我们会讲到。

函数

在Go语言中,函数和方法不太一样,有明确的概念区分。其他语言中,比如Java,一般来说,函数就是方法,方法就是函数,但是在Go语言中,函数是指不属于任何结构体、类型的方法,也就是说,函数是没有接收者的;而方法是有接收者的,我们说的方法要么是属于一个结构体的,要么属于一个新定义的类型的。

函数

函数和方法,虽然概念不同,但是定义非常相似。函数的定义声明没有接收者,所以我们直接在go文件里,go包之下定义声明即可。

func main() {
	sum := add(1, 2)
	fmt.Println(sum)
}

func add(a, b int) int {
	return a + b
}

例子中,我们定义了add就是一个函数,它的函数签名是func add(a, b int) int,没有接收者,直接定义在go的一个包之下,可以直接调用,比如例子中的main函数调用了add函数。

例子中的这个函数名称是小写开头的add,所以它的作用域只属于所声明的包内使用,不能被其他包使用,如果我们把函数名以大写字母开头,该函数的作用域就大了,可以被其他包调用。这也是Go语言中大小写的用处,比如Java中,就有专门的关键字来声明作用域privateprotectpublic等。

/*
 提供的常用库,有一些常用的方法,方便使用
*/
package lib

// 一个加法实现
// 返回a+b的值
func Add(a, b int) int {
	return a + b
}

如上例子中定义的Add方法就可以被其他包调用。

方法

方法的声明和函数类似,他们的区别是:方法在定义的时候,会在func和方法名之间增加一个参数,这个参数就是接收者,这样我们定义的这个方法就和接收者绑定在了一起,称之为这个接收者的方法。

type person struct {
	name string
}

func (p person) String() string{
	return "the person name is "+p.name
}

留意例子中,func和方法名之间增加的参数(p person),这个就是接收者。现在我们说,类型person有了一个String方法,现在我们看下如何使用它。

func main() {
	p:=person{name:"张三"}
	fmt.Println(p.String())
}

调用的方法非常简单,使用类型的变量进行调用即可,类型变量和方法之前是一个.操作符,表示要调用这个类型变量的某个方法的意思。

Go语言里有两种类型的接收者:值接收者和指针接收者。我们上面的例子中,就是使用值类型接收者的示例。

使用值类型接收者定义的方法,在调用的时候,使用的其实是值接收者的一个副本,所以对该值的任何操作,不会影响原来的类型变量。

func main() {
	p:=person{name:"张三"}
	p.modify() //值接收者,修改无效
	fmt.Println(p.String())
}

type person struct {
	name string
}

func (p person) String() string{
	return "the person name is "+p.name
}

func (p person) modify(){
	p.name = "李四"
}

以上的例子,打印出来的值还是张三,对其进行的修改无效。如果我们使用一个指针作为接收者,那么就会其作用了,因为指针接收者传递的是一个指向原值指针的副本,指针的副本,指向的还是原来类型的值,所以修改时,同时也会影响原来类型变量的值。

func main() {
	p:=person{name:"张三"}
	p.modify() //指针接收者,修改有效
	fmt.Println(p.String())
}

type person struct {
	name string
}

func (p person) String() string{
	return "the person name is "+p.name
}

func (p *person) modify(){
	p.name = "李四"
}

只需要改动一下,变成指针的接收者,就可以完成了修改。

在调用方法的时候,传递的接收者本质上都是副本,只不过一个是这个值副本,一是指向这个值指针的副本。指针具有指向原有值的特性,所以修改了指针指向的值,也就修改了原有的值。我们可以简单的理解为值接收者使用的是值的副本来调用方法,而指针接收者使用实际的值来调用方法。

在上面的例子中,有没有发现,我们在调用指针接收者方法的时候,使用的也是一个值的变量,并不是一个指针,如果我们使用下面的也是可以的。

p:=person{name:"张三"}
(&p).modify() //指针接收者,修改有效

这样也是可以的。如果我们没有这么强制使用指针进行调用,Go的编译器自动会帮我们取指针,以满足接收者的要求。

同样的,如果是一个值接收者的方法,使用指针也是可以调用的,Go编译器自动会解引用,以满足接收者的要求,比如例子中定义的String()方法,也可以这么调用:

p:=person{name:"张三"}
fmt.Println((&p).String())

总之,方法的调用,既可以使用值,也可以使用指针,我们不必要严格的遵守这些,Go语言编译器会帮我们进行自动转义的,这大大方便了我们开发者。

不管是使用值接收者,还是指针接收者,一定要搞清楚类型的本质:对类型进行操作的时候,是要改变当前值,还是要创建一个新值进行返回?这些就可以决定我们是采用值传递,还是指针传递。

多值返回

Go语言支持函数方法的多值返回,也就说我们定义的函数方法可以返回多个值,比如标准库里的很多方法,都是返回两个值,第一个是函数需要返回的值,第二个是出错时返回的错误信息,这种的好处,我们的出错异常信息再也不用像Java一样使用一个Exception这么重的方式表示了,非常简洁。

func main() {
	file, err := os.Open("/usr/tmp")
	if err != nil {
		log.Fatal(err)
		return
	}
	fmt.Println(file)
}

如果返回的值,我们不想使用,可以使用_进行忽略。

file, _ := os.Open("/usr/tmp")

多个值返回的定义也非常简单,看个例子。

func add(a, b int) (int, error) {
	return a + b, nil
}

函数方法声明定义的时候,采用逗号分割,因为时多个返回,还要用括号括起来。返回的值还是使用return 关键字,以逗号分割,和返回的声明的顺序一致。

可变参数

函数方法的参数,可以是任意多个,这种我们称之为可以变参数,比如我们常用的fmt.Println()这类函数,可以接收一个可变的参数。

func main() {
	fmt.Println("1","2","3")
}

可以变参数,可以是任意多个。我们自己也可以定义可以变参数,可变参数的定义,在类型前加上省略号…即可。

func main() {
	print("1","2","3")
}

func print (a ...interface{}){
	for _,v:=range a{
		fmt.Print(v)
	}
	fmt.Println()
}

例子中我们自己定义了一个接受可变参数的函数,效果和fmt.Println()一样。

可变参数本质上是一个数组,所以我们向使用数组一样使用它,比如例子中的 for range 循环。

函数方法还有其他一些知识点,比如painc异常处理,递归等,这些在《Go语言实战》书里也没有介绍,这些基础知识,可以参考Go语言的那本圣经。

接口

接口是一种约定,它是一个抽象的类型,和我们见到的具体的类型如int、map、slice等不一样。具体的类型,我们可以知道它是什么,并且可以知道可以用它做什么;但是接口不一样,接口是抽象的,它只有一组接口方法,我们并不知道它的内部实现,所以我们不知道接口是什么,但是我们知道可以利用它提供的方法做什么。

抽象就是接口的优势,它不用和具体的实现细节绑定在一起,我们只需定义接口,告诉编码人员它可以做什么,这样我们可以把具体实现分开,这样编码就会更加灵活方面,适应能力也会非常强。

func main() {
	var b bytes.Buffer
	fmt.Fprint(&b,"Hello World")
	fmt.Println(b.String())
}

以上就是一个使用接口的例子,我们先看下fmt.Fprint函数的实现。

func Fprint(w io.Writer, a ...interface{}) (n int, err error) {
	p := newPrinter()
	p.doPrint(a)
	n, err = w.Write(p.buf)
	p.free()
	return
}

从上面的源代码中,我们可以看到,fmt.Fprint函数的第一个参数是io.Writer这个接口,所以只要实现了这个接口的具体类型都可以作为参数传递给fmt.Fprint函数,而bytes.Buffer恰恰实现了io.Writer接口,所以可以作为参数传递给fmt.Fprint函数。

内部实现

我们前面提过接口是用来定义行为的类型,它是抽象的,这些定义的行为不是由接口直接实现,而是通过方法由用户定义的类型实现。如果用户定义的类型,实现了接口类型声明的所有方法,那么这个用户定义的类型就实现了这个接口,所以这个用户定义类型的值就可以赋值给接口类型的值。

func main() {
	var b bytes.Buffer
	fmt.Fprint(&b, "Hello World")
	var w io.Writer
	w = &b
	fmt.Println(w)
}

这里例子中,因为bytes.Buffer实现了接口io.Writer,所以我们可以通过w = &b赋值,这个赋值的操作会把定义类型的值存入接口类型的值。

赋值操作执行后,如果我们对接口方法执行调用,其实是调用存储的用户定义类型的对应方法,这里我们可以把用户定义的类型称之为实体类型

我们可以定义很多类型,让它们实现一个接口,那么这些类型都可以赋值给这个接口,这时候接口方法的调用,其实就是对应实体类型对应方法的调用,这就是多态。

func main() {
	var a animal

	var c cat
	a=c
	a.printInfo()
	//使用另外一个类型赋值
	var d dog
	a=d
	a.printInfo()
}

type animal interface {
	printInfo()
}

type cat int
type dog int

func (c cat) printInfo(){
	fmt.Println("a cat")
}

func (d dog) printInfo(){
	fmt.Println("a dog")
}

以上例子演示了一个多态。我们定义了一个接口animal,然后定义了两种类型catdog实现了接口animal。在使用的时候,分别把类型cat的值c、类型dog的值d赋值给接口animal的值a,然后分别执行aprintInfo方法,可以看到不同的输出。

a cat
a dog

我们看下接口的值被赋值后,接口值内部的布局。接口的值是一个两个字长度的数据结构,第一个字包含一个指向内部表结构的指针,这个内部表里存储的有实体类型的信息以及相关联的方法集;第二个字包含的是一个指向存储的实体类型值的指针。所以接口的值结构其实是两个指针,这也可以说明接口其实一个引用类型。

方法集

我们都知道,如果要实现一个接口,必须实现这个接口提供的所有方法,但是实现方法的时候,我们可以使用指针接收者实现,也可以使用值接收者实现,这两者是有区别的,下面我们就好好分析下这两者的区别。

func main() {
	var c cat
	//值作为参数传递
	invoke(c)
}
//需要一个animal接口作为参数
func invoke(a animal){
	a.printInfo()
}

type animal interface {
	printInfo()
}

type cat int

//值接收者实现animal接口
func (c cat) printInfo(){
	fmt.Println("a cat")
}

还是原来的例子改改,增加一个invoke函数,该函数接收一个animal接口类型的参数,例子中传递参数的时候,也是以类型cat的值c传递的,运行程序可以正常执行。现在我们稍微改造一下,使用类型cat的指针&c作为参数传递。

func main() {
	var c cat
	//指针作为参数传递
	invoke(&c)
}

只修改这一处,其他保持不变,我们运行程序,发现也可以正常执行。通过这个例子我们可以得出结论:实体类型以值接收者实现接口的时候,不管是实体类型的值,还是实体类型值的指针,都实现了该接口

下面我们把接收者改为指针试试。

func main() {
	var c cat
	//值作为参数传递
	invoke(c)
}
//需要一个animal接口作为参数
func invoke(a animal){
	a.printInfo()
}

type animal interface {
	printInfo()
}

type cat int

//指针接收者实现animal接口
func (c *cat) printInfo(){
	fmt.Println("a cat")
}

这个例子中把实现接口的接收者改为指针,但是传递参数的时候,我们还是按值进行传递,点击运行程序,会出现以下异常提示:

./main.go:10: cannot use c (type cat) as type animal in argument to invoke:
	cat does not implement animal (printInfo method has pointer receiver)

提示中已经很明显的告诉我们,说cat没有实现animal接口,因为printInfo方法有一个指针接收者,所以cat类型的值c不能作为接口类型animal传参使用。下面我们再稍微修改下,改为以指针作为参数传递。

func main() {
	var c cat
	//指针作为参数传递
	invoke(&c)
}

其他都不变,只是把以前使用值的参数,改为使用指针作为参数,我们再运行程序,就可以正常运行了。由此可见实体类型以指针接收者实现接口的时候,只有指向这个类型的指针才被认为实现了该接口

现在我们总结下这两种规则,首先以方法接收者是值还是指针的角度看。

Methods Receivers Values
(t T) T and *T
(t *T) *T

上面的表格可以解读为:如果是值接收者,实体类型的值和指针都可以实现对应的接口;如果是指针接收者,那么只有类型的指针能够实现对应的接口。

其次我们我们以实体类型是值还是指针的角度看。

Values Methods Receivers
T (t T)
*T (t T) and (t *T)

上面的表格可以解读为:类型的值只能实现值接收者的接口;指向类型的指针,既可以实现值接收者的接口,也可以实现指针接收者的接口。

posted @ 2022-12-27 13:14  橘崽崽啊  阅读(28)  评论(0编辑  收藏  举报