Go笔记
数组
数组,是用来存储集合数据的,这种场景非常多,我们编码的过程中,都少不了要读取或者存储数据。当然除了数组之外,我们还有切片、Map映射等数据结构可以帮我们存储数据,但是数组是它们的基础。
内部实现
要想更清晰的了解数组,我们得了解它的内部实现。数组是长度固定的数据类型,必须存储一段相同类型的元素,而且这些元素是连续的。我们这里强调固定长度,可以说这是和切片最明显的区别。
数组存储的类型可以是内置类型,比如整型或者字符串,也可以是自定义的数据结构。因为是连续的,所以索引比较好计算,所以我们可以很快的索引数组中的任何数据。
这里的索引,一直都是0,1,2,3这样的,因为其元素类型相同,我们也可以使用反射,获取类型占用大小,进行移位,获取相应的元素,这个反射的时候,我们再讲。
声明和初始化
数组的声明和初始化,和其他类型差不多。声明的原则是:
- 指明存储数据的类型。
- 存储元素的数量,也就是数组长度。
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
如果省略默认是原数组或者切片的长度,所以例子中的三个新切片的值是一样的。这里注意的是i
和j
都不能超过原切片或者数组的索引。
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语言为我们提供了内置的len
和cap
函数来计算切片的长度和容量。
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,因为newSlice
和slice
切片共用一个底层数组,所以切片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中,就有专门的关键字来声明作用域private
、protect
、public
等。
/*
提供的常用库,有一些常用的方法,方便使用
*/
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
,然后定义了两种类型cat
和dog
实现了接口animal
。在使用的时候,分别把类型cat
的值c
、类型dog
的值d
赋值给接口animal
的值a
,然后分别执行a
的printInfo
方法,可以看到不同的输出。
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) |
上面的表格可以解读为:类型的值只能实现值接收者的接口;指向类型的指针,既可以实现值接收者的接口,也可以实现指针接收者的接口。