Go语言入门(三)
Day 11
Go语言中包的使用
- Go 语言的源码复用建立在包(package)基础之上。包通过 package, import, GOPATH 操作完成。
main包
- Go 语言的入口 main() 函数所在的包(package)叫 main,main 包想要引用别的代码,需要import导入!
package
-
src 目录是以代码包的形式组织并保存 Go 源码文件的。每个代码包都和 src 目录下的文件夹一一对应。每个子目录都是一个代码包。
-
同一个目录下的所有.go文件的第一行添加 包定义,以标记该文件归属的包,演示语法:
1
package 包名
-
包需要满足:
- 一个目录下的同级文件归属一个包。也就是说,在同一个包下面的所有文件的package名,都是一样的。
- 在同一个包下面的文件
package
名都建议设为是该目录名,但也可以不是。也就是说,包名可以与其目录不同名。 - 包名为 main 的包为应用程序的入口包,其他包不能使用。
- 在同一个包下面的文件属于同一个工程文件,不用
import
包,可以直接使用
-
包可以嵌套定义,对应的就是嵌套目录,但包名应该与所在的目录一致,例如:
1 2 3 4 5 6 7 8
// 文件:qf/ruby/tool.go中 package ruby // 可以被导出的函数 func FuncPublic() { } // 不可以被导出的函数 func funcPrivate() { }
-
包中,通过标识符首字母是否大写,来确定是否可以被导出。首字母大写才可以被导出,视为 public 公共的资源。
import
-
要引用其他包,可以使用 import 关键字,可以单个导入或者批量导入,语法演示:
-
通常导入
1 2 3 4 5 6 7
// 单个导入 import "package" // 批量导入 import ( "package1" "package2" )
-
点操作
1 2 3
import( . "fmt" )
-
这个点操作的含义就是这个包导入之后在你调用这个包的函数时,你可以省略前缀的包名,也就是前面你调用的
fmt.Println("hello world")
可以省略的写成Println("hello world")
-
起别名:别名操作顾名思义我们可以把包命名成另一个我们用起来容易记忆的名字。导入时,可以为包定义别名,语法演示:
1 2 3 4 5 6
import ( p1 "package1" p2 "package2" ) // 使用时:别名操作,调用包函数时前缀变成了我们的前缀 p1.Method()
-
_操作:如果仅仅需要导入包时执行初始化操作,并不需要使用包内的其他函数,常量等资源。则可以在导入包时,匿名导入。
-
这个操作经常是让很多人费解的一个操作符,请看下面这个import:
1 2 3 4
import ( "database/sql" _ "github.com/ziutek/mymysql/godrv" )
-
_操作其实是引入该包,而不直接使用包里面的函数,而是调用了该包里面的init函数。也就是说,使用下划线作为包的别名,会仅仅执行init()。
-
导入的包的路径名,可以是相对路径也可以是绝对路径,推荐使用绝对路径(起始于工程根目录)。
-
GOPATH环境变量
- import导入时,会从GO的安装目录(也就是GOROOT环境变量设置的目录)和GOPATH环境变量设置的目录中,检索 src/package 来导入包。如果不存在,则导入失败。
- GOROOT,就是GO内置的包所在的位置。
- GOPATH,就是我们自己定义的包的位置。
- 通常我们在开发Go项目时,调试或者编译构建时,需要设置GOPATH指向我们的项目目录,目录中的src目录中的包就可以被导入了。
init() 包初始化
- init()、main() 是 go 语言中的保留函数。我们可以在源码中,定义 init() 函数。此函数会在包被导入时执行,例如如果是在 main 中导入包,包中存在 init(),那么 init() 中的代码会在 main() 函数执行前执行,用于初始化包所需要的特定资料。
- 相同点:
- 两个函数在定义时不能有任何的参数和返回值。
- 该函数只能由 go 程序自动调用,不可以被引用。
- 不同点:
- init 可以应用于任意包中,且可以重复定义多个。
- main 函数只能用于 main 包中,且只能定义一个。
- 两个函数的执行顺序:
- 在 main 包中的 go 文件默认总是会被执行。
- 对同一个 go 文件的 init( ) 调用顺序是从上到下的。
- 对同一个 package 中的不同文件,将文件名按字符串进行“从小到大”排序,之后顺序调用各文件中的init()函数。
- 对于不同的 package,如果不相互依赖的话,按照 main 包中 import 的顺序调用其包中的 init() 函数。
- 如果 package 存在依赖,调用顺序为最后被依赖的最先被初始化,例如:导入顺序 main –> A –> B –> C,则初始化顺序为 C –> B –> A –> main,一次执行对应的 init 方法。main 包总是被最后一个初始化,因为它总是依赖别的包。
- 避免出现循环 import,例如:A –> B –> C –> A。
- 一个包被其它多个包 import,但只能被初始化一次
管理外部包
- go允许import不同代码库的代码。对于import要导入的外部的包,可以使用 go get 命令取下来放到GOPATH对应的目录中去。
- 对于go语言来讲,其实并不关心你的代码是内部还是外部的,总之都在GOPATH里,任何import包的路径都是从GOPATH开始的;唯一的区别,就是内部依赖的包是开发者自己写的,外部依赖的包是go get下来的。
Day 12
指针
指针的概念
- 指针是存储另一个变量的内存地址的变量。
获取变量的地址
- Go 语言的取地址符是 &,放到一个变量前使用就会返回相应变量的内存地址。
声明指针
-
声明指针*,*T是指针变量的类型,它指向T类型的值。
1
var var_name *var-type
-
var-type 为指针类型,var_name 为指针变量名,* 号用于指定变量是作为一个指针。
1 2
var ip *int /* 指向整型*/ var fp *float32 /* 指向浮点型 */
-
获取指针地址在指针变量前加&的方式。
-
示例代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
package main import "fmt" func main() { var a int= 20 /* 声明实际变量 */ var ip *int /* 声明指针变量 */ ip = &a /* 指针变量的存储地址 */ fmt.Printf("a 变量的地址是: %x\n", &a ) /* 指针变量的存储地址 */ fmt.Printf("ip 变量的存储地址: %x\n", ip ) /* 使用指针访问值 */ fmt.Printf("*ip 变量的值: %d\n", *ip ) }
数组指针
- 数组的指针。
指针数组
- 指针的数组。
- 改值要加*。
|
|
空指针
-
当一个指针被定义后没有分配到任何变量时,它的值为 nil。
-
nil 指针也称为空指针。
-
nil在概念上和其它语言的null、None、nil、NULL一样,都指代零值或空值。
-
一个指针变量通常缩写为 ptr。
-
空指针判断:
1 2
if(ptr != nil) /* ptr 不是空指针 */ if(ptr == nil) /* ptr 是空指针 */
获取指针的值
- 获取一个指针意味着访问指针指向的变量的值。语法是:*a。
指针的指针
- 如果一个指针变量存放的又是另一个指针变量的地址,则称这个指针变量为指向指针的指针变量。
函数指针
- 函数名默认就是指针,不需要*。
指针函数
- 返回值为指针的函数。
指针作为函数参数
- 指针是引用传递,多用于值类型数据。(基本数据类型与数组)
- 使用指针可以节约内存。
- 引用类型指针用处不大。(本身就是引用传递)
Day 13
结构体
什么是结构体
- 结构体是由一系列具有相同类型或不同类型的数据构成的数据集合。
- 结构体是值类型。
结构体的定义和初始化
|
|
- 一旦定义了结构体类型,它就能用于变量的声明
|
|
- 初始化结构体
|
|
结构体的访问
-
访问结构体成员(访问结构的各个字段)
-
通过点.操作符用于访问结构的各个字段。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
package main import "fmt" type Books struct { title string author string subject string book_id int } func main() { var Book1 Books /* 声明 Book1 为 Books 类型 */ var Book2 Books /* 声明 Book2 为 Books 类型 */ /* book 1 描述 */ Book1.title = "Go 语言" Book1.author = "www.runoob.com" Book1.subject = "Go 语言教程" Book1.book_id = 6495407 /* book 2 描述 */ Book2.title = "Python 教程" Book2.author = "www.runoob.com" Book2.subject = "Python 语言教程" Book2.book_id = 6495700 /* 打印 Book1 信息 */ fmt.Printf( "Book 1 title : %s\n", Book1.title) fmt.Printf( "Book 1 author : %s\n", Book1.author) fmt.Printf( "Book 1 subject : %s\n", Book1.subject) fmt.Printf( "Book 1 book_id : %d\n", Book1.book_id) /* 打印 Book2 信息 */ fmt.Printf( "Book 2 title : %s\n", Book2.title) fmt.Printf( "Book 2 author : %s\n", Book2.author) fmt.Printf( "Book 2 subject : %s\n", Book2.subject) fmt.Printf( "Book 2 book_id : %d\n", Book2.book_id) }
结构体指针
|
|
- 以上定义的指针变量可以存储结构体变量的地址。查看结构体变量地址,可以将 & 符号放置于结构体变量前。
|
|
- 使用结构体指针访问结构体成员,使用 “.” 操作符
|
|
- 结构体为值类型,可以用指针作为引用类型操作。
make函数
- make用于内建类型(map、slice 和channel)的内存分配。
- make只能创建slice、map和channel,并且返回一个有初始值(非零)的T类型。
- make返回初始化后的(非零)值。
new函数
- new用于各种类型的内存分配。
- new(T)分配了零值填充的T类型的内存空间,并且返回其地址,即一个*T类型的值。用Go的术语说,它返回了一个指针,指向新分配的类型T的零值。
- new返回指针。
匿名结构体
-
没有名字的结构体,在创建匿名结构体时,同时创建对象。
1 2 3 4 5
变量名 := struct{ 定义字段Field }{ 字段进行赋值 }
1 2 3 4 5 6 7 8
s2 := struct{ name string age int }{ name:"李四", age:19, } fmt.Println(s2.name,s2.age)
结构体的匿名字段
- 结构体的字段只有类型,没有名字。
- 没名字默认使用数据类型的名字当作名字。(不能重复,否则报错)
导出结构体和字段
- 如果结构体类型以大写字母开头,那么它是一个导出类型,可以从其他包访问它。类似地,如果结构体的字段以大写开头,则可以从其他包访问它们。
- 小写相反。
结构体比较
- 结构体是值类型,如果每个字段具有可比性,则是可比较的。如果它们对应的字段相等,则认为两个结构体变量是相等的。
- 如果结构变量包含的字段是不可比较的,那么结构变量是不可比较的。
结构体作为函数的参数
提升字段
-
在结构体中属于匿名结构体的字段称为提升字段,因为它们可以被直接访问,就好像它们属于拥有匿名结构字段的结构一样。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
package main import ( "fmt" ) type Address struct { city, state string } type Person struct { name string age int Address } func main() { var p Person p.name = "Naveen" p.age = 50 p.Address = Address{ city: "Chicago", state: "Illinois", } fmt.Println("Name:", p.name) fmt.Println("Age:", p.age) fmt.Println("City:", p.city) //city is promoted field fmt.Println("State:", p.state) //state is promoted field
结构体嵌套
-
一个结构体可能包含一个字段,而这个字段反过来就是一个结构体。这些结构被称为嵌套结构。
-
默认值传递内容拷贝,可适用结构体指针,变为引用传递。
-
Go语言的结构体嵌套:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
模拟继承性:is - a (可以作为提升字段直接访问) type A struct{ field } type B struct{ A //匿名字段 } 模拟聚合关系:has - a(不可以直接访问,需要使用名字) type C struct{ field } type D struct{ c C //聚合关系 }
Day 14
方法
什么是方法
- 一个方法就是一个包含了接受者的函数,接受者可以是命名类型或者结构体类型的一个值或者是一个指针。
- 方法只是一个函数,它带有一个特殊的接收器类型,它是在func关键字和方法名之间编写的。接收器可以是struct类型或非struct类型。接收方可以在方法内部访问。
- 方法能给用户自定义的类型添加新的行为。它和函数的区别在于方法有一个接收者,给一个函数添加一个接收者,那么它就变成了方法。接收者可以是值接收者,也可以是指针接收者。
- 管方法的接收者是什么类型,该类型的值和指针都可以调用,不必严格符合接收者的类型。
方法的语法
-
定义方法的语法
1 2 3 4 5 6
func (t Type) methodName(parameter list)(return list) { } func funcName(parameter list)(return list){ }
-
实例代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
package main import ( "fmt" ) type Employee struct { name string salary int currency string } /* displaySalary() method has Employee as the receiver type */ func (e Employee) displaySalary() { fmt.Printf("Salary of %s is %s%d", e.name, e.currency, e.salary) } func main() { emp1 := Employee { name: "Sam Adolf", salary: 5000, currency: "$", } emp1.displaySalary() //Calling displaySalary() method of Employee type }
-
可以定义相同的方法名。
-
虽然method的名字一模一样,但是如果接收者不一样,那么method就不一样。
-
method里面可以访问接收者的字段。
-
调用method通过.访问,就像struct里面访问字段一样 。
方法和函数
- method,同函数类似,区别需要有接受者。(也就是调用者)。
- 方法:某个类别的行为功能,需要指定的接受者调用;函数:一段独立功能的代码,可以直接调用。
- 方法:方法名可以相同,只要接受者不同;函数:命名不能冲突。
- Go不是一种纯粹面向对象的编程语言,它不支持类。因此,类型的方法是一种实现类似于类的行为的方法。
- 相同名称的方法可以在不同的类型上定义,而具有相同名称的函数是不允许的。假设我们有一个正方形和圆形的结构。可以在正方形和圆形上定义一个名为Area的方法。这是在下面的程序中完成的。
method继承
- method是可以继承的,如果匿名字段实现了一个method,那么包含这个匿名字段的struct也能调用该method。
method重写
- 方法是可以继承和重写的。
- 存在继承关系时,按照就近原则,进行调用。
接口
什么是接口
- 面向对象世界中的接口的一般定义是“接口定义对象的行为”。它表示让指定对象应该做什么。实现这种行为的方法(实现细节)是针对对象的。
- 在Go中,接口是一组方法签名。当类型为接口中的所有方法提供定义时,它被称为实现接口。它与OOP非常相似。接口指定了类型应该具有的方法,类型决定了如何实现这些方法。
接口的定义语法
-
定义接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
/* 定义接口 */ type interface_name interface { method_name1 [return_type] method_name2 [return_type] method_name3 [return_type] ... method_namen [return_type] } /* 定义结构体 */ type struct_name struct { /* variables */ } /* 实现接口方法 */ func (struct_name_variable struct_name) method_name1() [return_type] { /* 方法实现 */ } ... func (struct_name_variable struct_name) method_namen() [return_type] { /* 方法实现*/ }
-
示例代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
package main import ( "fmt" ) type Phone interface { call() } type NokiaPhone struct { } func (nokiaPhone NokiaPhone) call() { fmt.Println("I am Nokia, I can call you!") } type IPhone struct { } func (iPhone IPhone) call() { fmt.Println("I am iPhone, I can call you!") } func main() { var phone Phone phone = new(NokiaPhone) phone.call() phone = new(IPhone) phone.call() }
-
接口与实现类型的关系是非侵入式。
-
interface可以被任意的对象实现。
-
接口对象不能访问实现类中的属性。
-
一个对象可以实现任意多个interface。
-
任意的类型都实现了空interface(我们这样定义:interface{}),也就是包含0个method的interface。
接口的类型
- Go语言的多态性:
- 多态:一个事物的多种形态
- go语言通过接口模拟多态。
- 一个接口的实现:
- 看成实现本身的类型,能够访问实现类中的属性和方法。
- 看成是对应的接口类型,那就只能够访问接口中的方法。
- 接口的用法:
- 一个函数如果接受接口类型作为参数,那么实际上可以传入该接口的任意实现类型对象作为参数。
- 定义一个类型为接口类型,实际上可以赋值为任意实现类的对象。
空接口
- 不包含任何的方法,正因为如此,所有的类型都实现了空接口,因此空接口可以存储任意类型的数值。
接口的嵌套
- 接口可以多继承。
- 实现能调用哪个接口的方法,取决于这个实现,实现了哪个接口。或者说实现是哪个接口的类型。
接口断言
-
前面说过,因为空接口 interface{}没有定义任何函数,因此 Go 中所有类型都实现了空接口。当一个函数的形参是interface{},那么在函数中,需要对形参进行断言,从而得到它的真实类型。
-
语法格式:
1 2 3 4 5 6 7
// 安全类型断言 <目标类型的值>,<布尔参数> := <表达式>.( 目标类型 ) //非安全类型断言 <目标类型的值> := <表达式>.( 目标类型 )
-
示例代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
package main import "fmt" func main() { var i1 interface{} = new (Student) s := i1.(Student) //不安全,如果断言失败,会直接panic fmt.Println(s) var i2 interface{} = new(Student) s, ok := i2.(Student) //安全,断言失败,也不会panic,只是ok的值为false if ok { fmt.Println(s) } } type Student struct { }
-
断言时,实类型与指针类型不同。
-
断言其实还有另一种形式,就是用在利用 switch语句判断接口的类型。每一个case会被顺序地考虑。当命中一个case 时,就会执行 case 中的语句,因此 case 语句的顺序是很重要的,因为很有可能会有多个 case匹配的情况。
-
示例代码:
1 2 3 4 5 6 7 8
switch ins:=s.(type) { case Triangle: fmt.Println("三角形。。。",ins.a,ins.b,ins.c) case Circle: fmt.Println("圆形。。。。",ins.radius) case int: fmt.Println("整型数据。。") }
type关键字
类型定义
-
定义结构体
1 2 3 4 5 6
//1、定义结构体 //结构体定义 type person struct { name string //注意后面不能有逗号 age int }
-
定义接口
1 2 3 4
type USB interface { start() end() }
-
定义其他的新类型
1
type 类型名 Type
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
package main import "fmt" type myint int type mystr string func main() { var i1 myint var i2 = 100 i1 = 100 fmt.Println(i1) //i1 = i2 //cannot use i2 (type int) as type myint in assignment fmt.Println(i1,i2) var name mystr name = "王二狗" var s1 string s1 = "李小花" fmt.Println(name) fmt.Println(s1) name = s1 //cannot use s1 (type string) as type mystr in assignment }
-
定义函数的类型:Go语言支持函数式编程,可以使用高阶编程语法。一个函数可以作为另一个函数的参数,也可以作为另一个函数的返回值,那么在定义这个高阶函数的时候,如果函数的类型比较复杂,我们可以使用type来定义这个函数的类型。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
package main import ( "fmt" "strconv" ) func main() { res1 := fun1() fmt.Println(res1(10,20)) } type my_fun func (int,int)(string) //fun1()函数的返回值是my_func类型 func fun1 () my_fun{ fun := func(a,b int) string { s := strconv.Itoa(a) + strconv.Itoa(b) return s } return fun }
类型别名
-
类型别名的写法为:
1
type 别名 = Type
-
类型别名规定:TypeAlias 只是 Type 的别名,本质上 TypeAlias 与 Type 是同一个类型。就像一个孩子小时候有小名、乳名,上学后用学名,英语老师又会给他起英文名,但这些名字都指的是他本人。
-
在 Go 1.9 版本之前的内建类型定义的代码是这样写的:
1 2
type byte uint8 type rune int32
而在 Go 1.9 版本之后变为:
1 2
type byte = uint8 type rune = int32
这个修改就是配合类型别名而进行的修改。
非本地类型不能定义方法
-
能够随意地为各种类型起名字,是否意味着可以在自己包里为这些类型任意添加方法?
1 2 3 4 5 6 7 8 9 10 11
package main import ( "time" ) // 定义time.Duration的别名为MyDuration type MyDuration = time.Duration // 为MyDuration添加一个函数 func (m MyDuration) EasySet(a string) { //cannot define new methods on non-local type time.Duration } func main() { }
-
以上代码报错。报错信息:cannot define new methods on non-local type time.Duration
-
编译器提示:不能在一个非本地的类型 time.Duration 上定义新方法。非本地方法指的就是使用 time.Duration 的代码所在的包,也就是 main 包。因为 time.Duration 是在 time 包中定义的,在 main 包中使用。time.Duration 包与 main 包不在同一个包中,因此不能为不在一个包中的类型定义方法。
-
解决这个问题有下面两种方法:
-
将类型别名改为类型定义: type MyDuration time.Duration,也就是将 MyDuration 从别名改为类型。
-
将 MyDuration 的别名定义放在 time 包中。
-
在结构体成员嵌入时使用别名
|
|
在通过s直接访问name的时候,或者s直接调用Show()方法,因为两个类型都有 name字段和Show() 方法,会发生歧义,证明People 的本质确实是Person 类型。
面向对象(OOP)
-
go并不是一个纯面向对象的编程语言。在go中的面向对象,结构体替换了类。
-
Go并没有提供类class,但是它提供了结构体struct,方法method,可以在结构体上添加。提供了捆绑数据和方法的行为,这些数据和方法与类类似。
定义结构体和方法
-
在employee.go文件中保存以下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
package employee import ( "fmt" ) type Employee struct { FirstName string LastName string TotalLeaves int LeavesTaken int } func (e Employee) LeavesRemaining() { fmt.Printf("%s %s has %d leaves remaining", e.FirstName, e.LastName, (e.TotalLeaves - e.LeavesTaken)) }
-
然后在oop目录下,创建文件并命名为main.go,并保存以下内容
1 2 3 4 5 6 7 8 9 10 11 12 13
package main import "oop/employee" func main() { e := employee.Employee { FirstName: "Sam", LastName: "Adolf", TotalLeaves: 30, LeavesTaken: 20, } e.LeavesRemaining() }
-
运行结果:
1
Sam Adolf has 10 leaves remaining
New()函数替代了构造函数
-
我们上面写的程序看起来不错,但是里面有一个微妙的问题。让我们看看当我们用0值定义employee struct时会发生什么。更改main的内容。转到下面的代码
1 2 3 4 5 6 7 8
package main import "oop/employee" func main() { var e employee.Employee e.LeavesRemaining() }
-
运行结果:
|
|
-
通过运行结果可以知道,使用Employee的零值创建的变量是不可用的。它没有有效的名、姓,也没有有效的保留细节。在其他的OOP语言中,比如java,这个问题可以通过使用构造函数来解决。使用参数化构造函数可以创建一个有效的对象。
-
go不支持构造函数。如果某个类型的零值不可用,则程序员的任务是不导出该类型以防止其他包的访问,并提供一个名为NewT(parameters)的函数,该函数初始化类型T和所需的值。在go中,它是一个命名一个函数的约定,它创建了一个T类型的值给NewT(parameters)。这就像一个构造函数。如果包只定义了一个类型,那么它的一个约定就是将这个函数命名为New(parameters)而不是NewT(parameters)。
-
首先修改employee结构体为非导出,并创建一个函数New(),它将创建一个新Employee。代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
package employee import ( "fmt" ) type employee struct { firstName string lastName string totalLeaves int leavesTaken int } func New(firstName string, lastName string, totalLeave int, leavesTaken int) employee { e := employee {firstName, lastName, totalLeave, leavesTaken} return e } func (e employee) LeavesRemaining() { fmt.Printf("%s %s has %d leaves remaining", e.firstName, e.lastName, (e.totalLeaves - e.leavesTaken)) }
-
我们在这里做了一些重要的改变。我们已经将Employee struct的起始字母e设置为小写,即我们已经将类型Employee struct更改为type Employee struct。通过这样做,我们成功地导出了employee结构并阻止了其他包的访问。将未导出的结构的所有字段都导出为未导出的方法是很好的做法,除非有特定的需要导出它们。由于我们不需要在包之外的任何地方使用employee struct的字段,所以我们也没有导出所有字段。
-
由于employee是未导出的,所以不可能从其他包中创建类型employee的值。因此,我们提供了一个输出的新函数。将所需的参数作为输入并返回新创建的employee。
-
这个程序还需要做一些修改,让它能够工作,但是让我们运行这个程序来了解到目前为止变化的效果。如果这个程序运行,它将会失败,有以下编译错误:
1
go/src/constructor/main.go:6: undefined: employee.Employee
-
这是因为我们有未导出的Employee,因此编译器抛出错误,该类型在main中没有定义。完美的。正是我们想要的。现在没有其他的包能够创建一个零值的员工。我们成功地防止了一个无法使用的员工结构价值被创建。现在创建员工的唯一方法是使用新功能。
-
修改main.go代码:
1 2 3 4 5 6 7 8
package main import "oop/employee" func main() { e := employee.New("Sam", "Adolf", 30, 20) e.LeavesRemaining() }
-
运行结果:
1
Sam Adolf has 10 leaves remaining
-
因此,我们可以明白,虽然Go不支持类,但是结构体可以有效地使用,在使用构造函数的位置,使用New(parameters)的方法即可。
组成(Composition )替代了继承(Inheritance)
- Go不支持继承,但它支持组合。组合的一般定义是“放在一起”。构图的一个例子就是汽车。汽车是由轮子、发动机和其他各种部件组成的。
多态性(Polymorphism)
-
Go中的多态性是在接口的帮助下实现的。正如我们已经讨论过的,接口可以在Go中隐式地实现。如果类型为接口中声明的所有方法提供了定义,则实现一个接口。让我们看看在接口的帮助下如何实现多态。
-
任何定义接口所有方法的类型都被称为隐式地实现该接口。
-
类型接口的变量可以保存实现接口的任何值。接口的这个属性用于实现Go中的多态性。
Day 15
错误处理
- 在实际工程项目中,我们希望通过程序的错误信息快速定位问题,但是又不喜欢错误处理代码写的冗余而又啰嗦。
Go
语言没有提供像Java
、C#
语言中的try...catch
异常处理方式,而是通过函数返回值逐层往上抛。这种设计,鼓励工程师在代码中显式的检查错误,而非忽略错误,好处就是避免漏掉本应处理的错误。但是带来一个弊端,让代码啰嗦。
什么是错误
- 错误指的是可能出现问题的地方出现了问题。比如打开一个文件时失败,这种情况在人们的意料之中 。
- 异常指的是不应该出现问题的地方出现了问题。比如引用了空指针,这种情况在人们的意料之外。可见,错误是业务过程的一部分,而异常不是 。
- Go中的错误也是一种类型。错误用内置的
error
类型表示。就像其他类型的,如int,float64,。错误值可以存储在变量中,从函数中返回,等等。
演示错误
-
示例代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
package main import ( "fmt" "os" ) func main() { f, err := os.Open("/test.txt") if err != nil { fmt.Println(err) return } //根据f进行文件的读或写 fmt.Println(f.Name(), "opened successfully") }
-
如果一个函数或方法返回一个错误,那么按照惯例,它必须是函数返回的最后一个值。因此,
Open
函数返回的值是最后一个值。 -
处理错误的惯用方法是将返回的错误与nil进行比较。nil值表示没有发生错误,而非nil值表示出现错误。
错误类型表示
-
Go 语言通过内置的错误接口提供了非常简单的错误处理机制。
1 2 3
type error interface { Error() string }
-
它包含一个带有Error() 字符串的方法。任何实现这个接口的类型都可以作为一个错误使用。这个方法提供了对错误的描述。
-
当打印错误时,fmt.Println函数在内部调用Error() 方法来获取错误的描述。这就是错误描述是如何在一行中打印出来的。
从错误中提取更多信息的不同方法
断言底层结构类型并从结构字段获取更多信息
-
如果仔细阅读打开函数的文档,可以看到它返回的是PathError类型的错误。PathError是一个struct类型,它在标准库中的实现如下:
1 2 3 4 5 6 7
type PathError struct { Op string Path string Err error } func (e *PathError) Error() string { return e.Op + " " + e.Path + ": " + e.Err.Error() }
-
从上面的代码中,您可以理解PathError通过声明
Error()string
方法实现了错误接口。该方法连接操作、路径和实际错误并返回它。这样我们就得到了错误信息:1
open /test.txt: No such file or directory
-
PathError结构的路径字段包含导致错误的文件的路径。让我们修改上面写的程序,并打印出路径。
-
修改代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
package main import ( "fmt" "os" ) func main() { f, err := os.Open("/test.txt") if err, ok := err.(*os.PathError); ok { fmt.Println("File at path", err.Path, "failed to open") return } fmt.Println(f.Name(), "opened successfully") }
-
在上面的程序中,我们使用类型断言获得错误接口的基本值。然后我们用错误来打印路径。这个程序输出:
1
File at path /test.txt failed to open
断言底层结构类型,并使用方法获取更多信息
-
获得更多信息的第二种方法是断言底层类型,并通过调用struct类型的方法获取更多信息。
-
示例代码:
1 2 3 4 5 6 7 8 9 10 11 12 13
type DNSError struct { ... } func (e *DNSError) Error() string { ... } func (e *DNSError) Timeout() bool { ... } func (e *DNSError) Temporary() bool { ... }
-
从上面的代码中可以看到,DNSError struct有两个方法Timeout() bool和Temporary() bool,它们返回一个布尔值,表示错误是由于超时还是临时的。
-
让我们编写一个断言*DNSError类型的程序,并调用这些方法来确定错误是临时的还是超时的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
package main import ( "fmt" "net" ) func main() { addr, err := net.LookupHost("golangbot123.com") if err, ok := err.(*net.DNSError); ok { if err.Timeout() { fmt.Println("operation timed out") } else if err.Temporary() { fmt.Println("temporary error") } else { fmt.Println("generic error: ", err) } return } fmt.Println(addr) }
-
在上面的程序中,我们正在尝试获取一个无效域名的ip地址,这是一个无效的域名。golangbot123.com。我们通过声明它来输入*net.DNSError来获得错误的潜在价值。
-
在我们的例子中,错误既不是暂时的,也不是由于超时,因此程序会打印出来:
1
generic error: lookup golangbot123.com: no such host
-
如果错误是临时的或超时的,那么相应的If语句就会执行,我们可以适当地处理它。
直接比较
-
获得更多关于错误的详细信息的第三种方法是直接与类型错误的变量进行比较。让我们通过一个例子来理解这个问题。
-
filepath包的Glob函数用于返回与模式匹配的所有文件的名称。当模式出现错误时,该函数将返回一个错误ErrBadPattern。
-
在filepath包中定义了ErrBadPattern,如下所述:
1
var ErrBadPattern = errors.New("syntax error in pattern")
-
errors.New()用于创建新的错误。
-
当模式出现错误时,由Glob函数返回ErrBadPattern。
-
让我们写一个小程序来检查这个错误:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
package main import ( "fmt" "path/filepath" ) func main() { files, error := filepath.Glob("[") if error != nil && error == filepath.ErrBadPattern { fmt.Println(error) return } fmt.Println("matched files", files) }
-
运行结果:
1
syntax error in pattern
不要忽略错误
-
永远不要忽略一个错误。忽视错误会招致麻烦。让我重新编写一个示例,该示例列出了与模式匹配的所有文件的名称,而忽略了错误处理代码。
1 2 3 4 5 6 7 8 9 10 11
package main import ( "fmt" "path/filepath" ) func main() { files, _ := filepath.Glob("[") fmt.Println("matched files", files) }
-
我们从前面的例子中已经知道模式是无效的。我忽略了Glob函数返回的错误,方法是使用行号中的空白标识符。
1
matched files []
-
由于我们忽略了这个错误,输出看起来好像没有文件匹配这个模式,但是实际上这个模式本身是畸形的。所以不要忽略错误。
自定义错误
创建自定义错误可以使用errors包下的New()函数,以及fmt包下的:Errorf()函数。
在使用New()函数创建自定义错误之前,让我们了解它是如何实现的。下面提供了错误包中的新功能的实现。
|
|
panic()和recover()
- Golang中引入两个内置函数panic和recover来触发和终止异常处理流程,同时引入关键字defer来延迟执行defer后面的函数。
- 一直等到包含defer语句的函数执行完毕时,延迟函数(defer后的函数)才会被执行,而不管包含defer语句的函数是通过return的正常结束,还是由于panic导致的异常结束。你可以在一个函数中执行多条defer语句,它们的执行顺序与声明顺序相反。
- 当程序运行时,如果遇到引用空指针、下标越界或显式调用panic函数等情况,则先触发panic函数的执行,然后调用延迟函数。调用者继续传递panic,因此该过程一直在调用栈中重复发生:函数停止执行,调用延迟执行函数等。如果一路在延迟函数中没有recover函数的调用,则会到达该协程的起点,该协程结束,然后终止其他所有协程,包括主协程(类似于C语言中的主线程,该协程ID为1)。
panic():
- 内建函数。
- 假如函数F中书写了panic语句,会终止其后要执行的代码,在panic所在函数F内如果存在要执行的defer函数列表,按照defer的逆序执行。
- 返回函数F的调用者G,在G中,调用函数F语句之后的代码不会执行,假如函数G中存在要执行的defer函数列表,按照defer的逆序执行,这里的defer 有点类似 try-catch-finally 中的 finally。
- 直到goroutine整个退出,并报告错误。
recover():
- 内建函数。
- 用来控制一个goroutine的panicking行为,捕获panic,从而影响应用的行为。
- 一般的调用建议:
- 在defer函数中,通过recever来终止一个gojroutine的panicking过程,从而恢复正常代码的执行。
- 可以获取通过panic传递的error
- 简单来讲:go中可以抛出一个panic的异常,然后在defer中通过recover捕获这个异常,然后正常处理。
- 错误和异常从Golang机制上讲,就是error和panic的区别。很多其他语言也一样,比如C++/Java,没有error但有errno,没有panic但有throw。
- Golang错误和异常是可以互相转换的:
- 错误转异常,比如程序逻辑上尝试请求某个URL,最多尝试三次,尝试三次的过程中请求失败是错误,尝试完第三次还不成功的话,失败就被提升为异常了。
- 异常转错误,比如panic触发的异常被recover恢复后,将返回值中error类型的变量进行赋值,以便上层函数继续走错误处理流程。
什么情况下用错误表达,什么情况下用异常表达,就得有一套规则,否则很容易出现一切皆错误或一切皆异常的情况。
以下给出异常处理的作用域(场景):
- 空指针引用
- 下标越界
- 除数为0
- 不应该出现的分支,比如default
- 输入不应该引起函数错误
其他场景我们使用错误处理,这使得我们的函数接口很精炼。对于异常,我们可以选择在一个合适的上游去recover,并打印堆栈信息,使得部署后的程序不会终止。
说明: Golang错误处理方式一直是很多人诟病的地方,有些人吐槽说一半的代码都是"if err != nil { / 打印 && 错误处理 / }",严重影响正常的处理逻辑。当我们区分错误和异常,根据规则设计函数,就会大大提高可读性和可维护性。
错误处理的正确姿势
姿势一:失败的原因只有一个时,不使用error
我们看一个案例:
|
|
我们可以看出,该函数失败的原因只有一个,所以返回值的类型应该为bool,而不是error,重构一下代码:
|
|
说明:大多数情况,导致失败的原因不止一种,尤其是对I/O操作而言,用户需要了解更多的错误信息,这时的返回值类型不再是简单的bool,而是error。
姿势二:没有失败时,不使用error
error在Golang中是如此的流行,以至于很多人设计函数时不管三七二十一都使用error,即使没有一个失败原因。 我们看一下示例代码:
|
|
对于上面的函数设计,就会有下面的调用代码:
|
|
根据我们的正确姿势,重构一下代码:
|
|
于是调用代码变为:
|
|
姿势三:error应放在返回值类型列表的最后
对于返回值类型error,用来传递错误信息,在Golang中通常放在最后一个。
|
|
bool作为返回值类型时也一样。
|
|
姿势四:错误值统一定义,而不是跟着感觉走
很多人写代码时,到处return errors.New(value),而错误value在表达同一个含义时也可能形式不同,比如“记录不存在”的错误value可能为:
- “record is not existed.”
- “record is not exist!”
- “###record is not existed!!!”
- …
这使得相同的错误value撒在一大片代码里,当上层函数要对特定错误value进行统一处理时,需要漫游所有下层代码,以保证错误value统一,不幸的是有时会有漏网之鱼,而且这种方式严重阻碍了错误value的重构。
于是,我们可以参考C/C++的错误码定义文件,在Golang的每个包中增加一个错误对象定义文件,如下所示:
|
|
姿势五:错误逐层传递时,层层都加日志
层层都加日志非常方便故障定位。
说明:至于通过测试来发现故障,而不是日志,目前很多团队还很难做到。如果你或你的团队能做到,那么请忽略这个姿势。
姿势六:错误处理使用defer
我们一般通过判断error的值来处理错误,如果当前操作失败,需要将本函数中已经create的资源destroy掉,示例代码如下:
|
|
当Golang的代码执行时,如果遇到defer的闭包调用,则压入堆栈。当函数返回时,会按照后进先出的顺序调用闭包。 对于闭包的参数是值传递,而对于外部变量却是引用传递,所以闭包中的外部变量err的值就变成外部函数返回时最新的err值。 根据这个结论,我们重构上面的示例代码:
|
|
姿势七:当尝试几次可以避免失败时,不要立即返回错误
如果错误的发生是偶然性的,或由不可预知的问题导致。一个明智的选择是重新尝试失败的操作,有时第二次或第三次尝试时会成功。在重试时,我们需要限制重试的时间间隔或重试的次数,防止无限制的重试。
两个案例:
- 我们平时上网时,尝试请求某个URL,有时第一次没有响应,当我们再次刷新时,就有了惊喜。
- 团队的一个QA曾经建议当Neutron的attach操作失败时,最好尝试三次,这在当时的环境下验证果然是有效的。
姿势八:当上层函数不关心错误时,建议不返回error
对于一些资源清理相关的函数(destroy/delete/clear),如果子函数出错,打印日志即可,而无需将错误进一步反馈到上层函数,因为一般情况下,上层函数是不关心执行结果的,或者即使关心也无能为力,于是我们建议将相关函数设计为不返回error。
姿势九:当发生错误时,不忽略有用的返回值
通常,当函数返回non-nil的error时,其他的返回值是未定义的(undefined),这些未定义的返回值应该被忽略。然而,有少部分函数在发生错误时,仍然会返回一些有用的返回值。比如,当读取文件发生错误时,Read函数会返回可以读取的字节数以及错误信息。对于这种情况,应该将读取到的字符串和错误信息一起打印出来。
说明:对函数的返回值要有清晰的说明,以便于其他人使用。
异常处理的正确姿势
姿势一:在程序开发阶段,坚持速错
速错,简单来讲就是“让它挂”,只有挂了你才会第一时间知道错误。在早期开发以及任何发布阶段之前,最简单的同时也可能是最好的方法是调用panic函数来中断程序的执行以强制发生错误,使得该错误不会被忽略,因而能够被尽快修复。
姿势二:在程序部署后,应恢复异常避免程序终止
在Golang中,某个Goroutine如果panic了,并且没有recover,那么整个Golang进程就会异常退出。所以,一旦Golang程序部署后,在任何情况下发生的异常都不应该导致程序异常退出,我们在上层函数中加一个延迟执行的recover调用来达到这个目的,并且是否进行recover需要根据环境变量或配置文件来定,默认需要recover。 这个姿势类似于C语言中的断言,但还是有区别:一般在Release版本中,断言被定义为空而失效,但需要有if校验存在进行异常保护,尽管契约式设计中不建议这样做。在Golang中,recover完全可以终止异常展开过程,省时省力。
我们在调用recover的延迟函数中以最合理的方式响应该异常:
- 打印堆栈的异常调用信息和关键的业务信息,以便这些问题保留可见;
- 将异常转换为错误,以便调用者让程序恢复到健康状态并继续安全运行。
我们看一个简单的例子:
|
|
我们期望test函数的输出是:
|
|
实际上test函数的输出是:
|
|
原因是panic异常处理机制不会自动将错误信息传递给error,所以要在funcA函数中进行显式的传递,代码如下所示:
|
|
姿势三:对于不应该出现的分支,使用异常处理
当某些不应该发生的场景发生时,我们就应该调用panic函数来触发异常。比如,当程序到达了某条逻辑上不可能到达的路径:
|
|
姿势四:针对入参不应该有问题的函数,使用panic设计
入参不应该有问题一般指的是硬编码,我们先看这两个函数(Compile和MustCompile),其中MustCompile函数是对Compile函数的包装:
|
|
所以,对于同时支持用户输入场景和硬编码场景的情况,一般支持硬编码场景的函数是对支持用户输入场景函数的包装。 对于只支持硬编码单一场景的情况,函数设计时直接使用panic,即返回值类型列表中不会有error,这使得函数的调用处理非常方便(没有了乏味的"if err != nil {/ 打印 && 错误处理 /}“代码块)。