GO语言学习(四)
函数
函数和方法是我们迈向代码复用、多人协作开发的第一步。通过函数,可以把开发任务分解成一个个小的单元,这些小单元可以被其他单元复用,进而提高开发效率、降低代码重合度。再加上现成的函数已经被充分测试和使用过,所以其他函数在使用这个函数时也更安全,比你自己重新写一个相似功能的函数 Bug 率更低
以main函数为例:
- 任何一个函数的定义,都有一个 func 关键字,用于声明一个函数,就像使用 var 关键字声明一个变量一样;
- 然后紧跟的 main 是函数的名字,命名符合 Go 语言的规范即可,比如不能以数字开头;
- main 函数名字后面的一对括号 () 是不能省略的,括号里可以定义函数使用的参数,这里的 main 函数没有参数,所以是空括号 () ;
- 括号 () 后还可以有函数的返回值,因为 main 函数没有返回值,所以这里没有定义;
- 最后就是大括号 {} 函数体了,你可以在函数体里书写代码,写该函数自己的业务逻辑。
函数声明
func funcName(params) result {
body
}
这就是一个函数的签名定义,它包含以下几个部分:
- 关键字 func;
- 函数名字 funcName;
- 函数的参数 params,用来定义形参的变量名和类型,可以有一个参数,也可以有多个,也可以没有;
- result 是返回的函数值,用于定义返回值的类型,如果没有返回值,省略即可,也可以有多个返回值;
- body 就是函数体,可以在这里写函数的代码逻辑。
package main
import "fmt"
func main() {
a := 1
b := 2
i := sum(a, b)
fmt.Println(i)
}
func sum(a int, b int) int {
return a + b
}
这是一个计算两数之和的函数,函数的名字是 sum,它有两个参数 a、b,参数的类型都是 int。sum 函数的返回值也是 int 类型,函数体部分就是把 a 和 b 相加,然后通过 return 关键字返回,如果函数没有返回值,可以不用使用 return 关键字
多值返回
同java不一样,Go 语言的函数可以返回多个值,也就是多值返回
func sum(a, b int) (int, error) {
if a < 0 || b < 0 {
return 0, errors.New("a或者b不能是负数")
}
return a + b, nil
}
提示:这里使用的 error 是 Go 语言内置的一个接口,用于表示程序的错误信息
命名返回参数
不止函数的参数可以有变量名称,函数的返回值也可以,也就是说你可以为每个返回值都起一个名字,这个名字可以像参数一样在函数体内使用
package main
import (
"errors"
"fmt"
)
func main() {
a := -1
b := 2
i, err := sum2(a, b)
fmt.Println(i, err)
}
func sum2(a, b int) (sum int, err error) {
if a < 0 || b < 0 {
return 0, errors.New("a或者b不能是负数")
}
sum = a + b
err = nil
return
}
返回值的命名和参数、变量都是一样的,名称在前,类型在后。以上示例中,命名的两个返回值名称,一个是 sum,一个是 err,这样就可以在函数体中使用它们了
可变参数
可变参数与java中类似
func sum3(params ...int) int {
sum := 0
for _, i := range params {
sum += i
}
return sum
}
这里需要注意,如果你定义的函数中既有普通参数,又有可变参数,那么可变参数一定要放在参数列表的最后一个,比如 sum1(tip string,params …int) ,params 可变参数一定要放在最末尾
包级函数
不管是自定义的函数 sum、sum1,还是我们使用到的函数 Println,都会从属于一个包,也就是 package。sum 函数属于 main 包,Println 函数属于 fmt 包
- 函数名称首字母小写代表私有函数,只有在同一个包中才可以被调用;
- 函数名称首字母大写代表公有函数,不同的包也可以调用;
- 任何一个函数都会从属于一个包。
小提示:Go 语言没有用 public、private 这样的修饰符来修饰函数是公有还是私有,而是通过函数名称的大小写来代表,这样省略了烦琐的修饰符,更简洁。
匿名函数和闭包
顾名思义,匿名函数就是没有名字的函数,这是它和正常函数的主要区别
在下面的示例中,变量 sum2 所对应的值就是一个匿名函数。需要注意的是,这里的 sum2 只是一个函数类型的变量,并不是函数的名字
func main() {
sum2 := func(a, b int) int {
return a + b
}
fmt.Println(sum2(1, 2))
}
通过 sum2,我们可以对匿名函数进行调用,以上示例算出的结果是 3,和使用正常的函数一样。
有了匿名函数,就可以在函数中再定义函数(函数嵌套),定义的这个匿名函数,也可以称为内部函数。更重要的是,在函数内定义的内部函数,可以使用外部函数的变量等,这种方式也称为闭包
func main() {
cl:=colsure()
fmt.Println(cl())
fmt.Println(cl())
fmt.Println(cl())
}
func colsure() func() int {
i:=0
return func() int {
i++
return i
}
}
输出结果:
1
2
3
这都得益于匿名函数闭包的能力,让我们自定义的 colsure 函数,可以返回一个匿名函数,并且持有外部函数 colsure 的变量 i。因而在 main 函数中,每调用一次 cl(),i 的值就会加 1
小提示:在 Go 语言中,函数也是一种类型,它也可以被用来声明函数类型的变量、参数或者作为另一个函数的返回值类型。
方法
不同于函数的方法
在 Go 语言中,方法和函数是两个概念,但又非常相似,不同点在于方法必须要有一个接收者,这个接收者是一个类型,这样方法就和这个类型绑定在一起,称为这个类型的方法
在下面的示例中,type Age uint 表示定义一个新类型 Age,该类型等价于 uint,可以理解为类型 uint 的重命名。其中 type 是 Go 语言关键字,表示定义一个类型,在结构体和接口的课程中我会详细介绍。
func main() {
age := Age(25)
age.String()
}
type Age uint
func (age Age) String() {
fmt.Println("the age is", age)
}
示例中方法 String() 就是类型 Age 的方法,类型 Age 是方法 String() 的接收者。
和函数不同,定义方法时会在关键字 func 和方法名 String 之间加一个接收者 (age Age) ,接收者使用小括号包围。
接收者的定义和普通变量、函数参数等一样,前面是变量名,后面是接收者类型。
现在方法 String() 就和类型 Age 绑定在一起了,String() 是类型 Age 的方法
提示:因为 25 也是 unit 类型,unit 类型等价于我定义的 Age 类型,所以 25 可以强制转换为 Age 类型。
值类型接收者和指针类型接收者
方法的接收者除了可以是值类型(比如上一小节的示例),也可以是指针类型
定义的方法的接收者类型是指针,所以我们对指针的修改是有效的,如果不是指针,修改就没有效果,如下所示:
func (age *Age) Modify(){
*age = Age(30)
}
调用一次 Modify 方法后,再调用 String 方法查看结果,会发现已经变成了 30,说明基于指针的修改有效,如下所示:
age:=Age(25)
age.String()
age.Modify()
age.String()
提示:在调用方法的时候,传递的接收者本质上都是副本,只不过一个是这个值副本,一是指向这个值指针的副本。指针具有指向原有值的特性,所以修改了指针指向的值,也就修改了原有的值。我们可以简单地理解为值接收者使用的是值的副本来调用方法,而指针接收者使用实际的值来调用方法。
示例中调用指针接收者方法的时候,使用的是一个值类型的变量,并不是一个指针类型,其实这里使用指针变量调用也是可以的,如下面的代码所示:
(&age).Modify()
这就是 Go 语言编译器帮我们自动做的事情
- 如果使用一个值类型变量调用指针类型接收者的方法,Go 语言编译器会自动帮我们取指针调用,以满足指针接收者的要求。
- 同样的原理,如果使用一个指针类型变量调用值类型接收者的方法,Go 语言编译器会自动帮我们解引用调用,以满足值类型接收者的要求。
总之,方法的调用者,既可以是值也可以是指针,不用太关注这些,Go 语言会帮我们自动转义,大大提高开发效率,同时避免因不小心造成的 Bug。
不管是使用值类型接收者,还是指针类型接收者,要先确定你的需求:在对类型进行操作的时候是要改变当前接收者的值,还是要创建一个新值进行返回?这些就可以决定使用哪种接收者。
方法是否可以赋值给一个变量?如果可以,要怎么调用它呢?答案是完全可以,方法赋值给变量称为方法表达式,如下面的代码所示
age:=Age(25)
//方法赋值给变量,方法表达式
sm:=Age.String
//通过变量,要传一个接收者进行调用也就是age
sm(age)
结构体
结构体定义
结构体是一种聚合类型,里面可以包含任意类型的值,这些值就是我们定义的结构体的成员,也称为字段。在 Go 语言中,要自定义一个结构体,需要使用 type+struct 关键字组合
在下面的例子中,我自定义了一个结构体类型,名称为 person,表示一个人。这个 person 结构体有两个字段:name 代表这个人的名字,age 代表这个人的年龄。
type person struct {
name string
age uint
}
在定义结构体时,字段的声明方法和平时声明一个变量是一样的,都是变量名在前,类型在后,只不过在结构体中,变量名称为成员名或字段名。
其中:
- type 和 struct 是 Go 语言的关键字,二者组合就代表要定义一个新的结构体类型。
- person 是结构体类型的名字。
- name 是结构体的字段名,而 string 是对应的字段类型。
- 字段可以是零个、一个或者多个。
结构体声明使用
p2 := person{name: "laowan", age: 18} //声明一个person类并赋值
var p person //声明一个空的类型
在 Go 语言中,访问一个结构体的字段和调用一个类型的方法一样,都是使用点操作符“.”
字段结构体
结构体的字段可以是任意类型,也包括自定义的结构体类型,比如下面的代码:
func main() {
p2 := person{name: "laowan", age: 18, address: address{"湖南", "长沙"}}
fmt.Println(p2)
}
type person struct {
name string
age uint
address address
}
type address struct {
province string
city string
}
在这个示例中,我定义了两个结构体:person 表示人,address 表示地址。在结构体 person 中,有一个 address 类型的字段 addr,这就是自定义的结构体
接口
接口的定义
接口是和调用方的一种约定,它是一个高度抽象的类型,不用和具体的实现细节绑定在一起。接口要做的是定义好约定,告诉调用方自己可以做什么,但不用知道它的内部实现,这和我们见到的具体的类型如 int、map、slice 等不一样
type animal interface {
eat() string
sleep() string
}
和java一样,接口只负责出参与入参,置于其中的实现由实现者完成
接口的实现
接口的实现者必须是一个具体的类型,继续以 person 结构体为例,让它来实现 animal 接口,如下代码所示:
func main() {
p := person{"laowan", 18, address{"湖南", "长沙"}}
fmt.Println(p.eat())
}
type animal interface {
eat() string
sleep() string
}
func (p person) eat() string {
return fmt.Sprintf("%s 在 %s省%s市 eat", p.name, p.address.province, p.address.city)
}
type person struct {
name string
age uint
address address
}
type address struct {
province string
city string
}
声明一个接口animal,里面定义了eat和sleep方法,然后使用person类实现此接口,实现其中的业务逻辑
值接收者和指针接收者
我们已经知道,如果要实现一个接口,必须实现这个接口提供的所有方法,而且在上篇文章说方法的时候,我们也知道定义一个方法,有值类型接收者和指针类型接收者两种。二者都可以调用方法,因为 Go 语言编译器自动做了转换,所以值类型接收者和指针类型接收者是等价的。但是在接口的实现中,值类型接收者和指针类型接收者不一样,下面我会详细分析二者的区别
可以这样解读:
- 当值类型作为接收者时,person 类型和*person类型都实现了该接口。
- 当指针类型作为接收者时,只有*person类型实现了该接口。
可以发现,实现接口的类型都有*person,这也表明指针类型比较万能,不管哪一种接收者,它都能实现该接口
工厂函数
工厂函数一般用于创建自定义的结构体,便于使用者调用,我们还是以 person 类型为例,用如下代码进行定义
func NewPerson(name string) *person {
return &person{name:name}
}
我定义了一个工厂函数 NewPerson,它接收一个 string 类型的参数,用于表示这个人的名字,同时返回一个*person。
通过工厂函数创建自定义结构体的方式,可以让调用者不用太关注结构体内部的字段,只需要给工厂函数传参就可以了
用下面的代码,即可创建一个*person 类型的变量 p1
func main() {
p1 := NewPerson("张三")
fmt.Println(p1)
}
func NewPerson(name string) *person {
return &person{name: name}
}
工厂函数也可以用来创建一个接口,它的好处就是可以隐藏内部具体类型的实现,让调用者只需关注接口的使用即可。
继承和组合
在 Go 语言中没有继承的概念,所以结构、接口之间也没有父子关系,Go 语言提倡的是组合,利用组合达到代码复用的目的,这也更灵活
我同样以 Go 语言 io 标准包自带的接口为例,讲解类型的组合(也可以称之为嵌套),如下代码所示
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
//ReadWriter是Reader和Writer的组合
type ReadWriter interface {
Reader
Writer
}
ReadWriter 接口就是 Reader 和 Writer 的组合,组合后,ReadWriter 接口具有 Reader 和 Writer 中的所有方法,这样新接口 ReadWriter 就不用定义自己的方法了,组合 Reader 和 Writer 的就可以了。
类型断言
有了接口和实现接口的类型,就会有类型断言。类型断言用来判断一个接口的值是否是实现该接口的某个具体类型。
func main() {
p := person{"laowan", 18, address{"湖南", "长沙"}}
var a animal
a = &p
p2 := a.(*person)
fmt.Println(p2)
}
type animal interface {
eat() string
sleep() string
}
func (p person) eat() string {
return fmt.Sprintf("%s 在 %s省%s市 eat", p.name, p.address.province, p.address.city)
}
func (p *person) sleep() string {
return fmt.Sprintf("%s 在 %s省%s市 sleep", p.name, p.address.province, p.address.city)
}
type person struct {
name string
age uint
address address
}
type address struct {
province string
city string
}
输出结果:&{laowan 18 {湖南 长沙}}
如上所示,声明了一个animal接口,提供内部方法,person去实现这个接口,然后声明一个person变量,赋值给
animal,然后使用类型断言表达式 a.(*person),尝试返回一个 p2,如果接口的值 a 是一个*person,那么类型断言正确,可以正常返回 p2。如果接口的值 a 不是一个 *person,那么在运行时就会抛出异常,程序终止运行。
小提示:这里返回的 p2 已经是 *person 类型了,也就是在类型断言的时候,同时完成了类型转换。
p := person{"laowan", 18, address{"湖南", "长沙"}}
var a animal
a = &p
p2, ok := a.(*person)
if ok {
fmt.Println(p2)
} else {
fmt.Println("a不是一个person")
}
p3, ok1 := a.(*address)
if ok1 {
fmt.Println(p3)
} else {
fmt.Println("a不是一个address")
}
输出结果:
&{laowan 18 {湖南 长沙}}
a不是一个address