golang系列(3)-自定义类型, 结构体,方法和接收者, 接口, 包, 文件

marvin

go 中没有类的概念, 也不支持类的继承等面向对象的东西, go 中通过结构体的内嵌再配合接口比面向对象具有更高的扩展性和灵活性
类型别名和自定义类型
go 中可以使用 type 关键字定义自定义类型. 自定义类型是定义了一个全新的类型, 我们可以基于内置的基本类型定义, 也可以通过 struct 定义, 比如:
type MyInt int
这里通过 type 关键字将 MyInt 定义为一种新的类型, 它具有 int 的特性
类型别名和自定义类型的区别是, 编译之后类型别名就不存在了, 而自定义类型在编译之后还是存在
结构体
go 中的数据类型可以表示一些事物的基本属性, 但是当我们想要表达一个事物的全部或部分属性时, 这时候再用单一个的基本数据类型明显就无法满足需求了, go 语言提供了一种自定义数据类型, 可以封装多个基本数据类型, 这种数据类型叫结构体, 用 struct 表示, 因此 go 语言中通过 struct 来实现面向对象, 下面是结构体的定义:
type 类型名称 struct {
字段名1 字段类型
字段名2 字段类型
}
这里的类型名就是结构体的名称, 在同一个包中结构体的名称不能重复, 字段名表示结构体中的某一个数据项, 字段名在同一个结构体中不能重复, 每一个数据项都有自己的类型
为了说明下面举一个使用结构体的例子:
type person struct {
name string
age int
gender string
hobby []string
}
func main() {
var p person
p.name = "bob"
p.age = 90000
p.gender = "male"
p.hobby = []string{"ping pang", "lisp", "c"}
fmt.Println(p)
}
这里通过 person 这个结构体描述了一个人的多维度信息, 使用的时候直接进行赋值即可, 从这个例子也可以看出, 结构体是值类型的
匿名结构体
我们可以在声明一个变量的时候直接指定它是一个结构体类型,同时这个结构体可以没有名字, 这种结构体就叫做匿名结构体, 例如:
var s struct {
name string
age int
}
s.name = "heiehi"
s.age = 0
这里的 s 就是一个匿名结构体, 当一个结构体只使用一次就不再使用, 这个时候匿名结构体是最适合的场景
指针类型结构体
我们还可以通过使用 new 关键字对结构体进行实例化, 得到的是结构体的地址. 格式如下:
var p2 = new(person)
fmt.Printf("%T\n", p2)
fmt.Printf("p2=%#v\n", p2)
通过输出可以看出 p2 是一个结构体指针, 我们可以通过 . 来访问结构体的成员, 例如:
var p2 = new(person)
p2.name ="heha"
fmt.Printf("p2=%#v\n", p2)
因为结构体传递的时候传的是值, 想要修改原始的结构体只能通过内存地址指针进行操作, 例如下面的函数:
func f2(x *person) {
(*x).gender = "female"
}
这里的 person 是结构体, 在函数内部根据指针地址找到原来的那个变量, 修改变量的值. 其中 (*x).gender 等价于 x.gender
结构体的初始化
结构体初始化有3种方式:
- 声明后赋值
- 声明时使用 key, value 初始化
- 声明时按顺序初始化
为了说明, 来看一个初始化的例子:
type person struct {
name string
gender string
}
var p1 = person{
name: "hello",
gender: "male",
}
var p2 = person{
"hello",
"male",
}
这里的 p1 使用 key, value 的形式进行初始化, p2 则按顺序初始化, 需要注意的是在按顺序初始化的时候需要保证值的顺序和定义时字段的顺序一致
构造函数
构造函数就是一个返回一个结构体变量的函数, go 提倡的是面向接口编程, 这是与 java 等传统面向对象语言不同的地方, 下面是一个构造函数的例子:
func newPerson(name string, age int) *person {
return &person{
name,
age
}
}
我们这里返回了结构体指针, 也可以直接返回结构体, 但为了降低拷贝负担一般返回地址
方法和接收者
go 语言中 方法(method) 是一种作用于特定类型变量的函数, 这种特定类型变量叫做接收者(Receiver), 接收者的概念就类似其他语言中的 this 或者 self.
方法的定义如下:
func (接收者变量 接收者类型) 方法名(参数列表) (返回参数) {
函数体
}
这里有以下几个要点:
- 接收者变量: 接收者中的参数变量名在命名的时候, 官方建议使用接收者类型名的第一个小写字母, 而不是 self, this 之类的命名. 例如 Person 类型的接收者变量应该命名为 p, Connector 类型的接收者变量应该命名为 c等
- 接收者类型: 接收者类型和参数类似, 可以是指针类型和非指针类型
为了说明, 来看一个使用方法的例子:
type dog struct {
name string
}
func newDog(name string) dog {
return dog{
name: name,
}
}
func (d dog) speak() {
fmt.Printf("%s: wang", d.name)
}
func main() {
d1 := newDog("n1")
d1.speak()
}
这里定义了一个 dog 结构体, newDog 是一个构造函数, 返回 dog 结构体, 其表示一只狗, speak 是一个作用于 dog 类型的方法, 使用的时候可以直接通过 dog 结构体进行调用
值接收者和指针接收者的区别
通过值接收者不能修改原始数据, 指针接收者可以修改原始数据, 例如下面的例子:
func (p person) plusAge() {
p.age++
}
func (p *person) plusAge1() {
p.age++
}
func main() {
p := newPerson("bob", 18)
p.plusAge()
p.plusAge1()
fmt.Println(p.age)
}
这里的 plusAge 里的 p 是一个值接收者, plusAge1 里的 p 是一个指针接收者, 在调用者两个方法的时候, 只有 plusAge1 执行结束后原始数据 p 的 age 才被修改为 19. 由于 plusAge 操作的只是副本,因此不会生效.
当需要修改接收者中的值或接收者是拷贝代价比较大的对象时,就应该使用指针接收者.
给任意类型添加方法
在 go 语言中, 接收者的类型可以是任何类型, 不仅仅是结构体, 任意类型都可以拥有方法, 举个例子, 我们基于内置的 int 类型使用 type 关键字可以定义新的自定义类型, 然后我们可以为这个自定义类型添加方法:
type MyInt int
func (m MyInt) SayHello() {
fmt.Println("Hello, 我是一个 int")
}
func main() {
var m MyInt
m.SayHello()
}
这里我们将 MyInt 作为接收者, 输出了正确的结果. 需要注意的是, 非本地类型不能定义方法, 只能给当前包的类型定义方法
匿名字段
在给结构体进行定义的时候, 可以不用指定字段的名字, 这就叫匿名字段. 如果要拿到对应的数据, 需要通过类型来表示名字, 下面是一个例子:
type person struct {
string
int
}
func main() {
p1 := person{
"bob"
100
}
fmt.Println(p1)
fmt.Println(p1.string)
}
这里通过 p1.string 访问的就是 person 这个结构体的第一个字段, 这里表示的含义是名字为 bob. 匿名字段的缺点很明显,相同字段不能重复, 因为类型不能重复, 因此匿名字段适合字段比较少的场景
结构体嵌套
一个结构体中可以嵌套包行另一个结构体或结构体指针, 为了说明, 这里给出了一个例子:
type Address struct {
Provice string
City string
}
type User struct {
Name string
Gender string
Address Address
}
func main() {
user1 := User{
Name: "bob",
Gender: "male",
Address: Address{
Provice: "Shanghai",
City: "Shanghai",
}
}
fmt.Println(user1.Address.City)
}
这里分别定义了一个表示地址的结构体和用户结构体, 用户包含了地址, 在定义结构体的时候内部嵌套的结构体像普通变量一样定义就好. 但是会有一个问题, 要获取 City 的时候必须通过 Address 字段去取, 那有没有办法通过 user1 自身拿到呢, 这就引出了匿名嵌套结构体, 在定义的时候只需指定类型, 下面是改造后的结果:
type Address struct {
Provice string
City string
}
type User struct {
Name string
Gender string
Address
}
func main() {
user1 := User{
Name: "bob",
Gender: "male",
Address: Address{
Provice: "Shanghai",
City: "Shanghai",
}
}
fmt.Println(user1.City)
}
这里之所以能直接找到 City 是由于匿名嵌套的特性, 在寻找字段的时候首先会在当前结构体上寻找, 如果找不到就去匿名嵌套结构体中寻找. 需要注意的是如果有多个匿名嵌套结构体拥有一样的字段, 这个时候就会产生冲突, 这时候只能指定字段路径进行访问
结构体的继承
go 语言中使用结构体也可以实现其他编程语言中所谓的继承, 下面是一个继承的例子:
// 动物
type Animal struct {
name string
}
// 动物移动
func (a *Animal) move() {
fmt.Println("%s正在移动!", a.name)
}
// 狗
type Dog struct {
Feet int8
*Animal // 通过匿名嵌套结构体实习继承
}
// 狗叫
func (d *Dog) wang() {
fmt.Printf("%s正在叫...", d.name)
}
func main() {
d1 := &Dog{
Feet: 4,
Animal: &Animal{
name: "bob"
}
}
d1.move()
}
这里通过匿名嵌套结构体的特性实现了继承的模拟.
结构体与JSON
在 web 开发中后端给前端的数据一般都是 JSON 格式的, 这个时候需要将结构体表示的数据转换为 JSON 字符串, go 提供了 json 包来处理这个问题, 下面是一个 JSON 处理的例子:
import "encoding/json"
func main() {
p1 := person{
name: "bob",
age: 12,
}
b, err := json.Marshal(p1)
if err != nil {
fmt.Printf("序列化失败, err: %v", err)
}
fmt.Printf("typeof b: %T, %#v\n", b, string(b))
这里通过 json 包的序列化函数 Marshal 来处理我们的结构体, 返回的是一个 uint8 类型的切片, 我们通过 string 可以将切片转换成字符串, 输出结果是空的 json 字符串: "{}".
之所以输出为空, 是因为 person 的字段是小写的, 小写的字段在其他包中是不可见的, 因此需要修改 person 的定义:
type person struct {
Name string
Age int
}
这样在转换为 json 的过程中就不会有问题了, 如果我们想要生成的 json 名称是小写的, 可以在字段后面加一个 tag:
type person struct {
Name string `json:"name"`
Age int `json:"age"`
}
这里的tag告诉外部包 json 在处理的时候将名字转换为对应的小写形式, 需要注意的是冒号前后都不要添加空格, 这里实际上和反射有关系,后面会详细说明这块.
接口
很多时候我们并不关心一个变量是什么类型的, 而只关心它有哪些方法可以调用, 这个时候就需要用到接口, 接口也是一种类型, 它不定义任何数据, 只规定变量拥有哪些方法, 下面是一个使用接口的例子:
package main
type cat struct {}
type dog struct {}
type speakable interface {
speak()
}
func (c cat) speak() {
fmt.Println("喵喵喵~")
}
func (d dog) speak() {
fmt.Println("旺旺旺~")
}
func touch(speaker speakable) {
speaker.speak()
}
func main() {
var c1 cat
var d1 dog
touch(c1)
touch(d1)
}
这里定义了一个 speakable 接口, 然后定义了一个 touch 方法表示摸的动作, 凡是满足 speakable 接口的类型都可以被当做 touch 的参数, 而 cat 和 dog 都拥有自己的 speak 方法,因此都可以被当做参数传递进去运行.
接口的定义如下:
type 接口名 interface {
方法名(参数1, 参数2, ...)(返回值1, 返回值2, ...)
}
一个变量必须要实现接口中定义的全部方法, 才能说这个变量实现了这个接口.
值接收者和指针接收者实现接口的区别
使用值接收者实现接口和使用指针接收者实现接口有什么区别呢? 下面有一个例子:
type Mover interface {
move()
eat(string)
}
type dog struct {
name string
feet int8
}
func (c dog) move() {
fmt.Println("moving...")
}
func (c dog) eat(food string) {
fmt.Println(food)
}
func main() {
var m Mover
m1 := dog{"tom", 4}
m2 := &dog{"tim", 4}
m = m1
m = m2
fmt.Println(m)
}
这里使用值接收者 d 实现了接口 Mover 里面的方法, 此时不论是对应的 dog 还是 dog 的指针都可以传进去. 下面我们使用指针接收者实现接口的方法, 看看有什么不一样的:
// ...
func (c *dog) move() {
fmt.Println("moving...")
}
func (c *dog) eat(food string) {
fmt.Println(food)
}
// ...
这时候由于实现 Mover 接口的是 dog 的指针类型, 因此能传给 Mover 类型变量m的只能是 dog 的地址, 修改如下:
m = &m1
实现多个接口和接口嵌套
接口和类型的关系是, 一个类型能够实现多个接口, 一个接口能够被多个类型实现. 下面是一个类型实现多个接口的例子:
package main
import "fmt"
type mover interface {
move()
}
type eater interface {
eat()
}
type animal interface {
mover
eater
}
type cat struct {
name string
feet int8
}
func (c *cat) move() {
fmt.Println("cat moving")
}
func (c *cat) eat() {
fmt.Println("cat eating")
}
func main() {
var c = cat{
name: "haha",
feet: 4
}
c.move()
c.eat()
}
这里定义了一个嵌套接口, 通过嵌套接口的复合我们定义了一个新的接口, 这个新的接口具备 mover 和 eater 接口的全部特性. cat 实现了 move 和 eat 接口, 故 cat 同时实现了 mover 接口与 eater 接口.
空接口
空接口指没有定义任何方法的接口, 因此任意类型都实现了空接口. 空接口类型的变量可以存储任意类型的变量, 以下是空接口的表示:
interface{} // 空接口
类型断言
对于空接口我们并不知道它具体的类型是什么, 通过类型断言可以判断空接口的值, 它的语法格式如下:
x.(T)
这里的 x 表示类型为 interface{} 的变量, T 表示断言 x 可能是的类型. 该语法返回两个参数, 第一个参数是 x 转化为 T 类型后的变量, 第二个值是一个布尔值, 若为 true 则表示断言成功, 否则断言失败, 如下是一个类型断言的例子:
func main() {
var x interface{}
x = "hello"
v, ok := x.(string)
if ok {
fmt.Println(v)
} else {
fmt.Println("类型断言失败")
}
}
这里的 x 是 string 类型, 故会直接输出字符串值
包
包是多个 go 源码的集合, 是一种高级的代码复用方案, go 语言为我们提供了很多内置包, 如 fmt, os, io 等. 我们可以更具自己的需要创建自己的包, 一个包可以简单的理解为存放 .go 文件的文件夹. 该文件夹下所有的 go 文件都要在代码的第一行添加如下代码, 声明该文件所属的包:
package 包名
这里通过 package 关键字声明了一个包, 需要注意以下3点:
- 一个文件夹下面只能有一个包, 同一个包的文件不能在多个文件夹下
- 包名可以和文件夹的名字不一样, 包名不能包含-符号
- 包名为 main 的包为应用程序的入口包, 编译时不包含 main 包的源代码时不会得到可执行文件
包的可见性
如果想在一个包中引用另外一个包里的标识符, 该标识符必须时对外可见的, 在 go 语言中只需要将标识符的首字母大写就可以让标识符对外可见了. 举个例子, 我们定义一个包名为calc的包, 代码如下:
// calc.go
package calc
func Add(x, y int) int {
return x + y
}
这里的 Add 就可以被外部的包使用了, 如果是在 go mod 模式下, 只需要通过模块名后跟calc即可导入.
对于包的导入, 有以下几个需要注意的地方:
- 包名在非 mod 模式下是从 $GOPATH/src 后开始的路径, 而在 mode 模式下是从 mod 开始的路径
- go 语言中禁止循环导入包
自定义包名
在导入包名的时候, 我们还可以为导入的包设置别名, 具体语法格式如下:
import 别名 "包的路径"
这里的别名在代码中可以直接使用, 例如我们导入 fmt 给了一个别名 fmt1, 则我们可以直接使用 fmt1
导入匿名包
如果只希望导入包, 而不需要用到包内部的数据的时候, 我们可以使用匿名包导入. 语法如下:
import _ "包的路径"
匿名导入的包与其他方式导入的包一样都会被编译到可执行文件中
init 初始化函数
在 go 语言程序执行时, import 语句会自动触发被导入的包内部的 init 函数, 这是一个钩子函数, 需要注意的是 init 函数没有参数也没有返回值, 只能被自动执行而不能主动调用. 包初始化执行的顺序如下:
全局声明 -> init 执行 -> main 函数执行
go 会从 main 包开始检查导入的所有包, 而每个包可能导入其他包, 编译器会根据这些信息构建一个引用关系图, 根据引用顺序决定编译顺序, 依次编译这些包的代码. 在运行时, 最后被导入的包会最先初始化并调用其 init 函数.
文件
计算机中的文件时存储在外部介质上的数据集合, 文件分为文本文件和二进制文件. 文件操作一般时读取和写入, 下面来看看如何打开和关闭文件
打开和关闭文件
os.Open 函数能够打开一个文件, 返回一个 *File 和一个 err. 对得到的文件实例调用 close 方法能够关闭这个文件, 这里的 File 是一个文件指针, 下面是一个打开和关闭文件的例子:
package main
import (
"fmt"
"os"
)
func main() {
// 只读方式打开当前目录下的 main.go 文件
file, err := os.Open("./main.go")
if err != nil {
fmt.Println("open file failed!, err", err)
return
}
// 关闭文件
file.Close()
}
问了防止关闭文件, 我们通常使用 defer 注册文件关闭句柄
读取文件
file.Read 用于读取一个文件, 其函数签名如下:
func (f *File) Read(b []byte) (n int, err error)
它接收一个字节切片, 返回读取的字节数和可能的具体错误, 督导文件末尾时会返回 0 和 io.EOF. 下面是一个例子:
func main() {
// 只读方式打开当前目录下的 main.go 文件
file, err := os.Open("./main.go")
if err != nil {
fmt.Println("Open file failed!, err", err)
return
}
defer file.Close()
// 使用 Read 方法读取数据
var tmp = make([]byte, 128)
n, err := file.Read(tmp[:])
if err == io.EOF {
fmt.Println("文件读完了")
return
}
if err != nil {
fmt.Println("read file failed, err: ", err)
return
}
fmt.Printf("读取了%d字节数据", n)
}
这里从 main.go 文件中读取了 128 个字节. 如果我们想读取整个文件的内容, 则需要循环读取, 但 go 提供了一个方便的方法去读取文件, 这就是 bufio, 它时在 file 的基础上封装了一层 api, 支持了更多的功能, 下面是使用 bufio 的例子:
package main
import (
"bufio"
"fmt"
"io"
"os"
)
func main() {
file, err := os.Open("./main.go")
if err != nil {
fmt.Println("open file failed, err: ", err)
return
}
defer file.Close()
reader := bufio.NewReader(file)
for {
line, err := reader.ReadString('\n')
if err == io.EOF {
fmt.Println("文件读完了")
break
}
if err != nil {
fmt.Println("read file failed, err: ", err)
return
}
fmt.Print(line)
}
}
这里通过缓冲区每次读取一行直到文件结束.
实际上还有更加简洁的方法, 通过 io/ioutil 包直接读取文件完整内容, 下面是一个例子:
content, err := ioutil.ReadFile("./main.go")
if err != nil {
fmt.Println("read file failed, err: ", err)
return
}
fmt.Println(string(content))
这里的 content 是字节切片, 需要转换到字符串. 通常如果文件比较小可以直接使用 ReadFile 方法读取整个文件的内容.
文件写入
os.OpenFile 函数能够以指定模式打开文件, 从而实现文件写入相关功能, 其定义如下:
func OpenFile(name string, flat int, perm FileMode) (*File, error)
这里的 name 表示要打开的文件路径, flag 表示打开文件的模式, 模式有以下几种
- O_WRONLY 只写
- O_CREATE 创建文件
- O_RDONLY 只读
- O_RDWR 读写
- O_TRUNC 清空
- O_APPEND 追加
perm: 文件权限, 一个八进制数, r(读) 04, w (写) 02, x (执行) 01. 下面是一个写文件的例子:
func main() {
file, err := os.OpenFile("./test.txt", os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644)
if err != nil {
fmt.Printf("Open file failed, err: %v", err)
return
}
defer file.Close()
file.Write([]byte("helloworld"))
file.WriteString("what")
}
这里指定了只写, 追加以及创建模式, test.txt 文件不存在的时候会自动创建.
同样这里也可以使用 bufio 去写文件:
func main() {
file, err := os.OpenFile("test.txt", os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0666)
if err != nil {
fmt.Println("open file failed, err: ", err)
return
}
defer file.Close()
writer := bufio.NewWriter(file)
for i := 0; i < 10; i++ {
writer.WriteString("hello")
}
writer.Flush() // 将缓冲区中的内容写入文件
}
这里通过实例化一个写入器, 循环写入内容, 最后将缓冲区的内容清空.
用 ioutil 写入文件更简单了, 连 flag 都不用指定:
func main() {
str := "hello"
err := ioutil.WriteFile("test.txt", []byte(str), 0666)
if err != nil {
fmt.Println("heloo")
return
}
}
拷贝文件
通过 io.Copy 方法能够对文件进行拷贝, 下面是一个拷贝文件的例子:
func CopyFile(dstName, srcName string) (written int64, err error) {
src, err := os.Open(srcName)
if err != nil {
fmt.Printf("open %s failed, err: %v.\n", srcName, err)
return
}
defer src.Close()
dst, err := os.OpenFile(dstName, os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
fmt.Printf("open %s failed, err: %v.\n", dstName, err)
return
}
defer dst.Close()
return io.Copy(dst, src)
}
func main() {
_, err := CopyFile("dst.txt", "src.txt")
if err != nil {
fmt.Println("copy file failed, err: ", err)
return
}
fmt.Println("copy done")
}
这里首先以读的方式打开源文件, 然后以写的方式打开了目标文件, 最后通过 io.Copy 方法将源文件拷贝到了目标文件.
从bufio读取用户输入
fmt 中读取用户输入有一个问题, 遇到空白符会被当成分割符, bufio 将标准输入视为文件,可以直接读取用户输入, 下面是一个使用 bufio 读取用户输入的例子:
var s string
reader := bufio.NewReader(os.Stdin)
reader.ReadString('\n')
s, _ reader.ReadString('\n')
fmt.Printf("您输入的内容是: %s\n", s)
这里通过 reader 接收用户输入, 直到遇到换行符.
在文件中间插入内容
我们可以通过移动光标到特定的位置插入我们想要插入的内容, 下面是一个在文件中间插入内容的例子:
func f() {
fileObj, err := os.OpenFile("./hello.txt", os.O_RDWR, 0644)
if err != nil {
fmt.Printf("open file failed, err: %v", err)
return
}
// 创建临时文件
tempFile, err := os.OpenFile("./tmp.txt", os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
if err != nil {
fmt.Println("create tmp file failed, err: %v\n", err)
return
}
// 读取前面的内容
var ret [1]byte
n, err := fileObj.Read(ret[:])
if err != nil {
fmt.Printf("read from file failed, err: %v\n", err)
return
}
// 写入临时文件
tempFile.Write(ret[:n])
// 写入要插入的内容
s := []byte{'c'}
tempFile.Write(s)
// 把剩下的内容写入临时文件
var x [1024]byte
for n, err:= fileObj.Read(x[:]); n > 0 && err != io.EOF; {
if err != nil {
fmt.Printf("read from file failed, err: %v\n", err)
return
}
tempFile.Write(x[:n])
}
fileObj.Close()
tempFile.Close()
os.Rename("./tmp.txt", "./hello.txt")
}
这里首先打开文件, 读取要插入的位置前面的内容, 写入到临时文件中, 然后向临时文件中写入需要插入的内容, 然后将光标后面的内容写入临时文件, 最后通过 os.Rename 将tmp.txt重命名为hello.txt,从而覆盖原先的hello.txt实现了在文件中间插入内容的需求