Go语言基础(2)——数据、函数与接口

63 阅读18分钟

Go语言基础之数据、函数与接口

数据类型

跟c/c++一样,go也有int、float、char、string、bool、byte等一众基本数据类型,不过由于go主要是应用在服务端开发,因此对数据长度控制更细粒度,每种基本数据类型的长度有更多选择。

类型名宽度取值范围
int与平台相关32位4字节,64位8字节,有符号整型
uint与平台有关32位4字节,64位8字节,无符号整型
int81字节有符号整型[-2^7,2^7-1]
uint81字节无符号整型[0,2^8-1]
int162字节有符号整型[-2^15,2^15-1]
uint162字节无符号整型[0,2^16-1]
int324字节有符号整型[-2^31,2^31-1]
uint324字节无符号整型[0,2^32-1]
int648字节有符号整型[-2^63,2^63-1]
uint648字节无符号整型[0,2^64-1]
uintptr与平台有关32位4字节,64位8字节,无符号整型
float324字节32位符点型
float648字节64位符点型
complex644字节32位实部和虚部
complex1288字节64位实部和虚部
byte1字节同uint8
rune4字节同uint32

除了go自带的基本数据类型,和c/c++一样,go也支持定义结构体来扩充更多的数据类型,并且go的所有结构体都是值类型的(和Java的对象不一样,Java的对象默认都是引用类型的)

type Student struct {
    Name string
    Age uint8
    Man bool
}

这样就可以定义一个Student结构体,go对结构体字段的定义也可以设置访问权限,首字母大写表示所有包都可以访问,而如果首字母是小写,则就只有同包下才可以访问。还有,正如前面提到的,go的结构体是值类型的,因此就算把一个变量的结构体赋值给另一个变量,两个变量也是不会相互影响的,改修一个变量的结构体的某个字段,另一个变量的相同字段的值不会改变,究其原因,是因为这两个变量指向的地址不同。

package main

import (
"fmt"

"github.com/CoderBenson/go_study/basic/stru"
)

func main() {
    stu := stru.Student{}
    stu.Age = 10
    stu1 := stu
    stu1.Age = 20
    fmt.Printf("stu:%v,stu1:%v\n", stu, stu1)
}

这段代码的输出是stu:{10 false 0},stu1:{20 false 0},表明修改stu1的Age的值不会影响到stu的Age的值,那么有没有办法修改stu的值的时候, 能够改变stu1的值呢?办法就是使用指针,go的指针就跟c/c++一样,可以让两个变量指向同一块内存地址。

package main

import (
"fmt"

"github.com/CoderBenson/go_study/basic/stru"
)

func main() {
    stu := stru.Student{}
    stu.Age = 10
    stu1 := stu
    stu1.Age = 20
    fmt.Printf("stu:%v,stu1:%v\n", stu, stu1)

    pStu1 := &stu
    pStu1.Age = 20
    fmt.Printf("stu:%v, pStu1:%v\n", stu, *pStu1)
}

这时候第二个打印输出会变成stu:{20 false 0}, pStu1:{20 false 0},这说明pStu1对Age的修改的同时也修改和stu的Age的值。

在Java中,对于对象还会判断两个对象是否相等,相等的判断结果是两个对象变量指向的对象是否为同一个对象,也可以理解成是否指向同一块内存地址。但基本数据类型和String会判断值是否相等,其实这是因为在Java中,基本数据类型的值被叫作常量,会在jvm的一个常量池中,变量的常量值要是相等,就说明他们指向的常量的地址都是常量池中的同一块地位,因此用==号判断的结果就会是true,而对于String类型,虽然Java是对象类型的,也就是引用类型的,但Java做了特殊处理,jvm也会对String做个常量值,将它可以作为常量。

而对于go来说,==的判断对于基本数据类型,也是判断值是否相等,但对于结构体来说,则是比较结构体的每个字段的值是否相等,如果结构体的某个字段也是结构体呢?那就会用递归,直接内层字段是个基本数据类型为止。如果用两个不同类型的struct用==判断,则直接会报语法错误

package main

import (
"fmt"

"github.com/CoderBenson/go_study/basic/stru"
)

func main() {
stu := stru.Student{}
stu.Age = 10
stu1 := stu
stu1.Age = 20
fmt.Printf("stu:%v,stu1:%v\n", stu, stu1)

pStu1 := &stu
pStu1.Age = 20
fmt.Printf("stu:%v, pStu1:%v\n", stu, *pStu1)

pStu2 := &stu1
fmt.Println(stu == stu1)
fmt.Println(stu == *pStu1)
fmt.Println(pStu1 == pStu2)
}

上面代码最后三行的输出结果是true、true、false,虽然stu和stu1指向的不是同一块地址,但它们是同一种类型的struct,并且每个字段的值都相等,因此==为true。第二个判断stu和pStu1指向的地址相同,那么肯定也是相等的。第三个判断由于pStu1和pStu2是两个指针类型,由于它们的值不同(内存地址不同),因此也不相等。

结构体的继承

既然可以定义结构体了,那么肯定存在一个结构体是另一个结构体的字段的情况,也就是结构体的嵌套。那么如果如果我们定义了一个struct,还需要在不改变这个struct的情况下再复用这个struct,在其基础上再加其他字段,这有点像Java中对象的继承。之前在语言基础一篇中有介绍go其实是不支持继承的,go是用多态的方式来实现继承的,go使用匿名字段这个语法糖来实现继承。

type OtherSutdent struct {
    Student
    SecondName string
}

这里又定义一个OtherStudent类型的struct,有一个SecondName string类型的字段,并且将Student作为其匿名字段,因此OtherStudent就继承了Student的所有字段

func main() {
    stu := stru.Student{Name: "xiaoming"}
    other := stru.OtherSutdent{Student: stu, SecondName: "Jack"}
    fmt.Printf("%+v\n", other)
    fmt.Printf("other.Name=%s\n", other.Name)
}

第一行打印输出结果是{Student:{Age:0 Name:xiaoming Man:false Mark:0} SecondName:Jack}可见OtherStudent还是使用持有的方式储存Student的,而第二行打印输出的结果是other.Name=xiaoming表示可以直接用OtherStudent访问到Student的字段,这样可以从表面上理解成OtherStudent继承了Student。

go在struct的继承方面,虽然用匿名字段语法糖间接实现了,但在结构体变量的初始化时,还是需要一层一层的创建内层结构体的值,再赋值给外层结构体的字段。这时候其实我们可以参考Java这类面向对象语法类的构造器,也就是用一个函数表创建一个对象,在go中我们可以专门用一个函数来创建一个结构体的值

func NewStudent(age uint8, name string, man bool, mark float32) *Student {
    return &Student{
        Age: age,
        Name: name,
        Man: man,
        Mark: mark,
    }
}

func NewOtherStudent(student Student, secondName string) *OtherSutdent {
    return &OtherSutdent{
        Student: student,
        SecondName: secondName,
    }
}

func NewOtherStudentDetail(
age uint8, name string, man bool, mark float32,
secondName string,
) *OtherSutdent {
    return NewOtherStudent(*NewStudent(age, name, man, mark), secondName)
}

这样我们就可以像Java一样使用构造器来创建一个结构体了,并且还可以使用不同的参数构造不同的值,不过go不允许函数重名,因此也就不允许函数重载了,必须给每个函数一个独有的名字。

go的引用类型

前面说到基础数据类型和struct都是值类型的,只有使用指针才能将变量变成引用类型的,其实指针变量也是值类型的,我们可以简单地将指针变量理解成一个储存了内存地址的uint值,那么uint当然也是值类型的,把一个指针变量赋值给另一个指针变量,修改一个指针变量的值,也不会影响另一个指针变量的值。

func main() {
    stu := *stru.NewStudent(20, "xiaoming", true, 20)
    pStu1 := &stu
    pStu2 := pStu1
    fmt.Println(pStu1 == pStu2)

    pStu1 = stru.NewStudent(20, "xiaohong", false, 30)
    fmt.Println(pStu1 == pStu2)
}

上面的代码,第一个打印输出是true,而第二个打印输出是false,说明就算是指针类型的变量,其实也是值类型的,只有我们用指针变量修改内存中的值时,多个指向同一个内存的指针的变量的值才会改变。

除了使用指针,go其他一些内置的数据结构也是引用类型的,比如map、接口和切片。接口是引用类型很好理解,因为接口变量必须使用一个指针来赋值。对于新手来说,特别容易犯的错误就是不理解go只有值传递,对于map和切片这种引用类型来说,在值传递时无法判断变量修改的影响。

func testSlice(s []string) {
   s = append(s, "err")
}

func main() {
   s := make([]string, 0)
   testSlice(s)
   fmt.Println(s)
}

上面的代码testSlice函数,以为s是切片类型是传的引用,但其实上s仍然是传值的,对s变量重新赋值不会对main函数中的s变量产生任何影响

go的函数与方法

go和c/c++一样,允许函数单独存在,同时也能够为结构体定义函数,类似于为类添加方法一样,对于结构体上的函数,也可以称之为结构体的方法,结构体被称为函数的receiver。同时,receiver有值类型和指针类型两种,区别就是值类型的函数不会修改结构体变量的值,而指针类型的函数可以修改。我们可以简单地将结构体的函数当作一个普通函数,只不会结构体作为了一个额外的形参。对于值类型的receiver,形参是值类型的,而指针类型的receiver作为形参也是指针类型的,因此修改变量的字段后会产生影响。更为奇特的设定是,指针类型的方法,用nil甚至都可以调用,只不过在函数内部如果直接访问了receiver,就产生空指针panic

type Student struct {
	Name string
}

func (s Student) ChangeName1() {
	s.Name = "changed1"
}

func (s *Student) ChangeName2() {
	s.Name = "changed2"
}

func main() {
	s := Student{Name: "xiaoming"}
	s.ChangeName1()
	fmt.Println(s)
	s.ChangeName2()
	fmt.Println(s)
}

上面的代码第一次是用值类型的方法,而第二次调用的是指针类型的方法,打印输出结果是

{xiaoming}
{changed2}

可以看到只有指针类型的方法可以真正修改变量的值,值类型的方法只是修改了函数作用域内的变量的值

func ChangeName3(s Student) {
	s.Name = "change3"
}

func ChangeName4(s *Student) {
	s.Name = "change4"
}

其实ChangeName1和ChangeName3是完全等价的,而ChangeName2和ChangeName4也完全等价

go中的接口

软件设计原则中有一条介绍是我们应该依赖于抽象而不是依赖实现(依赖倒置原则),接口是一种抽象的实现方案,但抽象不仅仅只有接口。Java中的接口是一系列函数定义的集合,一个类要实现一个接口就必须实现这个接口定义的所有方法;在Go中也一样,接口也是一系列函数的集合,并且结构体要实现一个接口也必须实现这个接口的所有函数。这样看来,go的接口跟Java的接口就没啥区别了。那我们就来看看一个struct具体实现一个接口的案例

type Eatable interface {
	Eat(food string)
}

func (s Student) Eat(food string) {
	fmt.Printf("%s eat %s\n", s.Name, food)
}

func main() {
	s := Student{Name: "xiaoming"}

	var eatable Eatable
	eatable = &s
	eatable.Eat("apple")
}

这样Student这个struct就实现了Eatable这个接口,但我们可以看到,Student和Eat方法都没有对Eatable这个接口有任何代码上的依赖,因此就算我们把Eatable放到Student访问不到的module也不会有问题,这里就要涉及到go关于接口的特点了。

前面说到,一个struct是否实现了一个interface要取决于struct是否实现了interface定义的所有函数,描述到这里,其实这时候struct并不依赖于interface本身。因此,go在接口设计上相对于c/c++和Java显得更激进一些,只要struct实现了interface签名的所有函数,就算实现了interface,那么interface类型的变量就可以指向struct类型的指针(也即内存)。其实这在编译原理层面来看也是行得通的,并没有使用什么黑科技,我们之所以使用接口作为变量的类型,其实是希望只依赖函数的签名而不需要依赖其实现,以此来达到依赖抽象而不是依赖实现的原则。编译运行时,所有的函数其实最终会变成一个代码段,那么不管是interface还是struct的方法,都只会是函数表中的一行记录而已。从调用安全考虑(也即不允许访问不存在的代码段),因为strcut实现了相关签名的函数,那么调用接口的函数就是安全的。

那么go接口的这一特点有何优势呢?优势在于对于接口的实现不再依赖于接口的定义了,准确来说是不依赖于接口名的定义,其实go在实现接口时,还是依赖于interface中定义的函数签名的,我们如果把函数签名当作接口描述,那么其实对接口的实现还是依赖于接口的定义。其次,我们应用go接口的这一特点,还可以实现更细粒度的接口复用。例如我们已有一个sturct函数的实现,并且已经封装到一个库中了,这个库已经非常稳定了,这时候我们就只需要根据struct这个函数的签名来定义接口,这个新接口就马上能应用到原有的struct上了,这意味着我们可以先有实现后有接口,同时这样将完全不需要修改struct所在的库。

最后,go的接口还有一个特点,那就是如果interface只定义了一个函数A,并且一个函数类型的类型B实现了这个接口的函数,那么我们就可以将与B相同签名的函数直接转成interface类型的变量,说起来有点绕,下面用代码演示一下

type Eatable interface {
	Eat(food string)
}

type EatFunc func(string)

func (f EatFunc) Eat(food string) {
	f(food)
}

func Eat(food string) {
	fmt.Printf("smart eat %s\n", food)
}

func main() {
   s := Student{Name: "xiaoming"}

   eat := Eatable(EatFunc(Eat))
   eat.Eat("apple")
   eat = Eatable(EatFunc(s.Eat))
   eat.Eat("apple")
}

上面的main函数中我们将Eat函数和Student的Eat方法都通过EatFunc这个函数的类型转成了Eatable接口,最终两个打印输出分别是smart eat applechanged2 eat apple,可以看到都得到了理想的结果。但这样写不显示多此一举吗?No~ No~ No~,当然不会多此一举。下面一一来说明:

函数转成接口

首先,第一个用法将Eat函数转成Eatable接口,如果我们直接调用了Eat函数,那么不就是依赖于实现了嘛,咱们提到多次的依赖抽象而不是依赖实现的原则呢?因此,我们可以用interface来进行抽象,在定义struct的字段或函数的形参时,我们可以使用Eatable这样的interface,最后赋值或传实参时,就可以将Eat函数转成Eatable来使用了。 辩论鬼才小A有话说

  • 小A:稍等一下下......我们也可以像Student这样定义一个struct来实现Eatable接口呀,可以达到一样的效果!!!你这一层套一层的,有过度设计的嫌疑哦,而且容易成屎山
  • 聪明的小B:当然可以,但没必要。看Eat函数的实现,并没有使用任何struct的字段,要是我们像Student这样来实现,不就冗余了嘛,定义的这个struct完全就是个空壳。
  • 小A:请你再稍等一下,你不也定义了一个EatFunc函数类型嘛,不也是冗余?不也是空壳?
  • 小B:上面的代码看起来EatFunc确实是空壳一层,但既然前面说到依赖抽象而不是依赖实现,目的是为啥?是为了能够隔绝实现,方便替换嘛(此处召唤里氏替换原则)。既然我们设计了Eatable接口,当然是想其有很多种实现,当我们有特别多种Eat函数时,EatFunc这个函数类型还是可以使用同一个。如果使用struct来实现interace,那么每一种Eat的实现都需要一个struct。
  • 小A:啊,是是是!嗯,对对对

struct的方法转成接口

  • 鬼才小A:好,就算第一点被你狡辩对了,那这第二点你怎么说?
  • 聪明的小B:既然我能提出来,那么我肯定是有话说滴。看看第二种的打印出来,change2 eat app,这说明s.Eat其实是带了Student的Name字段的值的,第一种用法只能针对于一个单纯的函数,而第二种可以应用于一个struct的方法。在面向对象编程设计中,我们通常都是使用struct+方法这种组合,将数据和操作进行封装,以此来方便地实现数据结构与算法。
  • 小A:请正面回答我的问题,既然你都用Student实现了Eatable接口的Eat函数了,那么Student的变量就可以赋值给Eatable类型的变量,也就可以实现你第一种所说的依赖抽象而不是依赖实现,为啥还要搞EatFunc这一层?
  • 小B:是的,你确实可以直接用Eatable类型的变量来指向Student,但要是Student的Eat方法的名字不叫Eat呢?记得最开始提到的,go的接口实现依赖于函数签名,既然函数名都不一样了,那还能说Student实现了Eatable接口吗?因此,这第二种用法实际上最根本的作用是进行接口适配,也就是一种简易的Adapter模式。
  • 小A:既然你说到Adapter模式,那我就又有话说了。通常情况下,Adapter模式是为了解决接口不一致的问题,接口不一致不只函数名不一致,更常见的是连函数的形参列表和返回值都不一致,这样的场景你这第二种用法都不管用了吧。
  • 小B:是的,第二种用法只能解决函数名不一致的接口适配问题,但咱们活人不能让尿憋死,既然形参列表和返回值可能也不一致,那咱们就再写一个函数或struct的方法,把形参和返回值给适配成一致的呗。我只是说第二种用法是一种简易的Adapter模式,可以方便地解决函数名不一致导致的接口兼容问题。还有一种方法不能重新定义函数或struct,那就是为每种不一致的适配定义一个EatFunc函数类型,在EatFunc的Eat函数实现中做接口适配,但这样就舍弃了EatFunc这个类型的通用性了。不论如何,使用Adapter模式来解决接口适配问题,都需要为每种适配场景单独写一套适配逻辑
  • 小A:不听不听,王八念经。

函数转接口和方法转接口是同一个原理

var ff EatFunc
ff = s.Eat
ff("apple")

上面的代码一样会输出changed2 eat apple,说明s.Eat虽然带了Student的Age字段,但它依然可以是EatFunc类型的变量,相对于go的interface,这种函数类型则更是体现了抽象其实是函数的签名(形参和返回值等)而不是函数名,从编译原理和运行时来看,函数名可以理解成代码段的地址,而函数签名才是真正的代码记录,因为只要函数签名正确,就一定能保证函数的调用是安全的。

在go源码中也有一些场景使用了这两种用法,比如http.HandleFunc这个函数类型就是和http.Handler拥有相同的函数签名,并且这个函数类型还实现了http.HandlerServeHTTP(w ResponseWriter, r *Request)函数,因此只要和http.HandlerFunc函数签名相同的函数,都可以通过HandleFunc转成Handler接口。我们在实现一个http server的时候,比如ServeMux,每个path都需要一个Handler,我们就可以使用上述的两种用法在不定义struct的情况下轻松实现想要的功能。

// HandleFunc registers the handler function for the given pattern.
func (mux *ServeMux) HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
	if handler == nil {
		panic("http: nil handler")
	}
	mux.Handle(pattern, HandlerFunc(handler))
}

上面的代码取自go源码的net/http/server.go中的2473-2479行,可见go源码也会实现这种用法来使用接口简化