likes
comments
collection
share

从零开始,一步步构建Go结构体世界

作者站长头像
站长
· 阅读数 8

一、类型定义

1、自定义类型

在Go中,除了基本数据类型整型浮点型布尔型string外,还可以使用type关键字自定义类型。

自定义类型不仅可以基于内置的基本数据类型进行定义,也可以通过struct进行定义。例如:

// 将NewInt定义为int类型
type NewInt int

上述通过type关键字进行NewInt新类型的声明,其底层数据类型时int,具有int的特性。

2、类型别名

在Go1.19中增加了类型别名,类型别名的格式如下:

type NewType = type

例如:

type byte = uint8
type rune = int32

上述类型别名中,byte只是uint8的别名,本质上两个是同一个类型。

3、二者区别

自定义类型与类型别名的对比:

type NewInt int // 自定义类型
type MyInt = int // 类型别名

两者在声明上有一个等号的差异,但类型却不相同。

package main

import "fmt"

type NewInt int
type MyInt = int

func main() {
    var newInt NewInt
    var myInt MyInt

    fmt.Printf("newInt的类型: %T\n", newInt) // newInt的类型: main.NewInt
    fmt.Printf("myInt的类型: %T\n", myInt)   // myInt的类型: int
}

由上述代码的执行结果可知,newInt的类型为main.NewInt,表示main包下定义的NewInt类型。myInt的类型是int,与基本数据类型int相同,myInt类型只会在代码中存在,编译完成时并不会有MyInt类型。

二、结构体

1、结构体介绍

在Go中,可以通过struct关键字来定义自己的类型,叫做结构体

结构体是一种自定义的复合数据类型,用于存储一组相关的数据字段。结构体中的每个字段都可以是不同的数据类型,例如整数、浮点数、字符串、数组、结构体等等。使用结构体可以方便地组织和操作相关的数据。

2、结构体的定义

通过使用typestruct关键字来定义结构体,具体结构如下:

type structName struct {
    field1 type1
    field2 type2
    ...
}

上述定义中:

  • structName:表示定义的结构体类型名,在同一个保内类型名不能重复。
  • field1:表示结构体的字段名。每个结构体中字段名必须唯一。
  • type1:表示结构体中每个字段的具体类型。

例如:

type user struct {
    name  string
    age   int8
    address string
}

通过定义结构体,可以很方便的存储想要的数据集合,结构体是用来描述一组数据,在程序中表示用户的信息。

3、结构体的实例化

当定义好结构体后,需要对结构体进行实例化,分配内存后才能够使用结构体中的字段。结构体可以像变量一样使用var关键字声明结构体类型。

var 结构体实例名 结构体类型

例如:

type user struct {
    name string
    age  int8
    address string
}

func main() {
    var u user
    fmt.Println(u) // { 0 }
}

由上述代码的执行结果可知,在var u user声明结构体后,未初始化的结构体,默认结构体内的字段被赋予零值

(1)基本实例化

type user struct {
    name    string
    age     int8
    address string
}

func main() {
    var u user
    u.name = "serena"
    u.age = 18
    u.address = "广东"
    fmt.Printf("u = %T\n", u)  // u = main.user
    fmt.Printf("u = %v\n", u)  // u = {serena 18 广东}
    fmt.Printf("u = %#v\n", u) // u = main.user{name:"serena", age:18, address:"广东"}
}

上述代码中,在var u user声明结构体后,可以通过使用.的方式来访问结构体中的成员变量,例如u.nameu.age

(2)指针类型结构体

通过使用new关键字,可以对结构体进行实例化,得到结构体的地址。格式如下:

ins := new(T)

T为类型,可以是结构体、整型、字符串等类型。T类型被实例化后保存到ins变量中,ins的类型为 *T,是一个指针。 例如:

func main() {
    var u = new(user)
    u.name = "serena"
    u.age = 18
    u.address = "广东"
    fmt.Printf("u = %T\n", u)  // u = *main.user
    fmt.Printf("u = %v\n", u)  // u = &{serena 18 广东}
    fmt.Printf("u = %#v\n", u) // u = &main.user{name:"serena", age:18, address:"广东"}
}

由上述代码的打印结果可知,使用new关键字实例化后返回的是一个结构体指针,同样,对结构体指针也支持直接使用.来访问结构体的成员变量。

(3)取地址实例化

使用取地址符&对结构体取地址操作也可以获得结构体指针,相当于对结构体使用了new实例化操作。格式如下:

ins := &T{}

T表示结构体的类型,ins同样也是一个类型为*T的一个指针类型,存储结构体的实例。例如:

func main() {
    var u = &user{}
    u.name = "serena"
    u.age = 18
    u.address = "广东"
    fmt.Printf("u = %T\n", u)  // u = *main.user
    fmt.Printf("u = %v\n", u)  // u = &{serena 18 广东}
    fmt.Printf("u = %#v\n", u) // u = &main.user{name:"serena", age:18, address:"广东"}
}

通过&user{}可以获得一个user结构体指针,同样也支持直接使用.来访问结构体的成员变量,u.id = 1在底层(*u).id = 1,实际是Go的语法糖。

4、结构体初始化

在声明结构体后,如果未对结构体初始化,则结构体内的成员变量的值都会是其对应类型的零值。在声明结构体变量时,可以通过键值对的方式对结构体的成员变量进行初始化。

func main() {
    var u = user{
       name: "serena",
       age:  18,
       address: "广东",
    }
    fmt.Printf("u = %T\n", u)  // u = *main.user
    fmt.Printf("u = %v\n", u)  // u = &{serena 18 广东}
    fmt.Printf("u = %#v\n", u) // u = &main.user{name:"serena", age:18, address:"广东"}
}

同样,结构体指针也可以使用键值对进行初始化。

func main() {
    var u = &user{
       name: "serena",
       age:  18,
       address: "广东",
    }
    fmt.Printf("u = %T\n", u)  // u = *main.user
    fmt.Printf("u = %v\n", u)  // u = &{serena 18 广东}
    fmt.Printf("u = %#v\n", u) // u = &main.user{name:"serena", age:18, address:"广东"}
}

如果某些字段不需要初始化值,则可以不用初始化,此时没有指定初始化的成员变量的值为其对应类型的零值。

func main() {
    var u = &user{
       name: "serena",
    }
    fmt.Printf("u = %T\n", u)  // u = *main.user
    fmt.Printf("u = %v\n", u)  // u = &{serena 0 }
    fmt.Printf("u = %#v\n", u) // u = &main.user{name:"serena", age:0, address:""}
}

5、结构体内存布局

结构式的内存布局占用一块连续的内存来存储结构体。

type store struct {
    a int8
    b int8
    c int8
    d int8
}

func main() {
    n := store{
       1, 2, 3, 4,
    }
    fmt.Printf("n.a %p\n", &n.a)
    fmt.Printf("n.b %p\n", &n.b)
    fmt.Printf("n.c %p\n", &n.c)
    fmt.Printf("n.d %p\n", &n.d)
}
// 执行结果
n.a 0xc0000a6058
n.b 0xc0000a6059
n.c 0xc0000a605a
n.d 0xc0000a605b

其中,空结构体是不占用内存空间的

func main() {
    var s struct{}
    fmt.Println(unsafe.Sizeof(s)) // 0
}

6、构造函数

在Go中,结构体并没有构造函数,但可以自己实现一个构造函数,例如:

func newUser(name string, age int8, address string) *user {
    return &user{
       name: name,
       age: age,
       address: address,
    }
}

调用上述的构造方法后返回一个结构体指针,可以根据自己想要初始化的成员变量值获得对应结构体指针。

type user struct {
    name    string
    age     int8
    address string
}

func newUser(name string, age int8, address string) *user {
    return &user{
       name:    name,
       age:     age,
       address: address,
    }
}

func main() {
    u := newUser("serena", 18, "广东")
    fmt.Printf("%#v\n", u) // &main.user{name:"serena", age:18, address:"广东"}
}

7、方法

(1)方法介绍

在 Go 语言中,方法是一种特殊的函数,它可以与一个特定类型的值关联,称为这个类型的方法,这个特定类型变量为接收者,类似其他语言中的this

接收者的类型可以是几乎任何类型,不单单是结构体类型,任何类型都可以有方法,可以是基本数据类型,函数类型或者是int、bool、string 或数组的别名类型,但接收者不能是一个接口类型,因为接口是一个抽象的定义,而方法是一个具体的实现。

方法的定义格式如下:

func (receiver receiverType) methodName(parameters) (returnList) {
    // 方法的实现代码 
}

上述定义中:

  • receiver:接收者变量。接收者变量在命名时,官方建议使用接收者类型type名称的首字母小写,而非self、this等,例如结构体user,则接收者变量的命名为u
  • receiverType:接收者类型,可以为指针类型或非指针类型。
  • methodName:方法名。
  • parameters:方法的参数列表
  • returnList:方法的返回值列表
type User struct {
    name    string
    age     int8
    address string
}

func NewUser(name string, age int8, address string) *User {
    return &User{
       name:    name,
       age:     age,
       address: address,
    }
}

func (u User) Dream() {
    fmt.Printf("%s的梦想是学好Go语言!\n", u.name)
}

func main() {
    u := NewUser("serena", 18, "广东")
    u.Dream() // serena的梦想是学好Go语言!
}

方法属于特定的类型。而函数不属于任何特定类型。

(2)接收者类型

方法的接收者类型主要有两种,一种是值类型接收者,一种是指针类型接收者。两种接收者类型在使用时会产生不同的效果,被用于不同性能和功能要求的代码中。

指针接收者

指针类型的接收者为一个结构体的指针,由于指针类型为结构体的地址,因此调用方法时可以修改接收者指针的任意成员变量。这种类型的方法类似于其他语言面向对象中的this。例如:

func NewUser(name string, age int8, address string) *User {
    return &User{
       name:    name,
       age:     age,
       address: address,
    }
}

func (u *User) SetAge(newAge int8) {
    u.age = newAge
}

func main() {
    u := NewUser("serena", 18, "广东")
    fmt.Println(u.age) // 18
    u.SetAge(20)
    fmt.Println(u.age) // 20
}

上述代码中,定义了一个接收值为*User指针类型的方法来修改传入的实例的年龄age。由于指针的特性,在调用该方法时,修改接收者指针的任意成员变量,在方法执行结束后,修改仍然生效。

值接收者

当方法的接收者为值接收者时,调用该方法会将接收者的值拷贝一份。在值类型方法内可以获取到接收者的成员变量值,但修改该接收者的成员变量只是修改传入的拷贝,无法改变调用该方法的接收者变量本身。

func (u User) SetAge(newAge int8) {
    u.age = newAge
}

func main() {
    u := NewUser("serena", 18, "广东")
    fmt.Println(u.age) // 18
    u.SetAge(20)
    fmt.Println(u.age) // 18
}

选择指针类型接收者的时机

  • 修改成员变量值:当需要修改接收者中的值时,可以使用指针类型接收者。
  • 避免值拷贝:当接收器类型的值比较大时,使用值类型作为接收器会导致每次方法调用都进行一次值拷贝,影响程序性能。此时,可以使用指针类型作为接收器,避免不必要的值拷贝。
  • 实现接口:通常在实现某个接口时,如果接口方法的签名使用了指针类型作为接收器,那么实现该接口的类型也应该使用指针类型作为接收器。

另外,方法的接收者不单单只能是结构体,接收者的类型可以时任意的类型,任何类型都可以拥有方法。例如:

type NewInt int

func (n NewInt) NewIntMethod() {
    fmt.Println("NewInt类型的方法")
}

func main() {
    var n NewInt
    n.NewIntMethod() // NewInt类型的方法
    n = 100
    fmt.Printf("%#v %T\b", n, n) // 100 main.NewInt
}

上述代码中,通过使用type关键字以及基本数据类型int定义了一个新的自定义类型NewInt,然后为该自定义类型定义了NewIntMethod方法,该方法的接收者类型即为NewInt

警惕引用类型

在Go中,slice、map这两种数据结构在方法或者函数中传递默认是传递指向底层数据的地址,因为slice和map这两种数据类型都包含了指向底层数据的指针,因此在使用时需要注意。例如:

type Student struct {
    name   string
    age    int8
    dreams []string
}

func (p *Student) SetDreams(dreams []string) {
    p.dreams = dreams
}

func main() {
    p := Student{name: "serena", age: 18}
    dreams := []string{"努力", "拼搏", "奋斗"}
    p.SetDreams(dreams)
    fmt.Println(p.dreams) // [努力 拼搏 奋斗]
    dreams[1] = "学好Go"
    fmt.Println(p.dreams) // [努力 学好Go 奋斗]
}

上述代码中,定义了一个Student结构体,其中有一个成员变量为字符串类型的切片,并给这个Student结构体写了一个SetDreams的方法来初始化Student结构体的dreams切片字段。在main()中,初始化了一个dreams的切片并传入SetDreams方法,注意此时传入SetDreams方法的是切片变量,含有指向其底层数组的指针,在SetDreams中初始化结构体的dreams字段后,在第16行修改了切片的某一个元素,此时打印结构体变量p的值时,发现其成员变量dreams切片中的元素也同时被修改。这是由于传入到SetDreamsdreams切片与在main()函数的dreams切片指向同一个底层数组,因此当一方改变,另一方也随之改变,原因在于在方法或者函数中传递默认是传递指向底层数据的地址。

func main() {
    p := Student{name: "serena", age: 18}
    dreams := []string{"努力", "拼搏", "奋斗"}
    p.SetDreams(dreams)
    fmt.Printf("%p\n", dreams)   // 0xc00007e4b0
    fmt.Printf("%p\n", p.dreams) //0xc00007e4b0
}

正确的做法是对传递进来的切片进行拷贝,拷贝后并赋值给结构体的成员变量,因为拷贝后的变量指向的底层数组有所改变。

func (p *Student) SetDreams(dreams []string) {
    p.dreams = make([]string, len(dreams))
    copy(p.dreams, dreams)
}

func main() {
    p := Student{name: "serena", age: 18}
    dreams := []string{"努力", "拼搏", "奋斗"}
    p.SetDreams(dreams)
    fmt.Printf("%p\n", dreams)   // 0xc00007e4b0
    fmt.Printf("%p\n", p.dreams) //0xc00007e4e0
}

8、匿名字段

结构体在定义成员变量时,可以不用定义字段名称只定义类型,这类成员变量称为匿名字段。例如:

type User struct {
    string
    int
}

func main() {
    u := User{
       "serena",
       18,
    }
    fmt.Printf("%#v\n", u) // main.User{string:"serena", int:18}
    fmt.Println(u.string, u.int) // serena 18
}

上述结构体User中,定义了类型为stringint的两个匿名的成员字段,但并非没有字段名,在使用匿名字段时,会默认采用类型对应的类型名作为字段名,即:string stringint int,例如上述代码的第12行,使用u.stringu.int打印结构体的成员字段值。由于结构体中每个字段名必须唯一,因此在一个结构体中同种类型的匿名字段只能有一个。

9、结构体嵌套

在Go的结构体中,可以嵌套另一个结构体的类型或者指针类型,例如:

type User struct {
    Name    string
    Age     int
    Address Address
}

type Address struct {
    Province string
    City     string
}

func main() {
    address := Address{
       Province: "广东省",
       City:     "广州市",
    }
    u := User{
       Name:    "serena",
       Age:     18,
       Address: address,
    }
    fmt.Printf("%#v\n", u) // main.User{Name:"serena", Age:18, Address:main.Address{Province:"广东省", City:"广州市"}}
}

从上述代码中,User结构体中有Address类型的成员变量,而Address类型正是定义的另一个结构体,与User结构体形成了结构体嵌套。

结构体同样也支持匿名结构体类型字段的方式进行嵌套。

type User struct {
    Name string
    Age  int
    Address
}

type Address struct {
    Province string
    City     string
}

func main() {
    var u User
    u.Province = "广东"
    u.City = "广州市"
    fmt.Printf("%#v\n", u) // main.User{Name:"", Age:0, Address:main.Address{Province:"广东", City:"广州市"}}
}

上述代码的第14、15行中,Go语言中的结构体嵌套支持直接使用嵌套结构体中的成员变量,例如u.Province,而无需通过u.Address.Province的方式使用嵌套结构体中的字段,访问结构体成员时会先在结构体中查找该字段,若查找不到则会去嵌套的匿名结构体字段中查找。

在内嵌多个结构体类型字段时,可能会出现多个结构体内拥有相同成员字段名的情况,这种情况下为了避免歧义,需要通过指定特定的内嵌结构体名来获取对应的结构体字段。例如:

type User struct {
    Name string
    Age  int
    Address
    Email
}

type Address struct {
    Province   string
    City       string
    CreateTime string
}

type Email struct {
    Email      string
    CreateTime string
}

func main() {
    var u User
    // 编译器报错 Ambiguous reference 'CreateTime'
    // u.CreateTime
    u.Address.CreateTime = "2023"
    u.Email.CreateTime = "2013"
    fmt.Printf("%#v\n", u) // main.User{Name:"", Age:0, Address:main.Address{Province:"", City:"", CreateTime:"2023"}, Email:main.Email{Email:"", CreateTime:"2013"}}
}

// 执行结果
main.User{Name:"", Age:0, Address:main.Address{Province:"", City:"", CreateTime:"2023"}, Email:main.Email{Email:"", CreateTime:"2013"}}

10、Go中的“面向对象”

Go语言的面向对象思想主要是通过结构体与方法来实现。

封装

封装是指将数据和方法组合成一个整体,并对外部隐藏内部实现细节

Go语言的封装非常的简单便捷,例如结构体的成员变量的可见性是有结构体定义时,字段名称的首字母大小写来决定,结构体中字段大写开头表示可公开访问,小写表示私有(仅在定义当前结构体的包中可访问)来限制对数据的直接访问,只能通过方法来进行间接访问和修改。

  • 包内可见:通过标识符首字母小写,只在它所在的包内可见;
  • 包外可见:通过标识符首字母大写,对所在包以外也可见;

继承

Go语言中继承并没有类似extends显式的声明继承,通过使用结构体嵌套的方式,也能够达到类似继承的效果,通过在一个结构体中嵌入另一个结构体,被嵌入结构体的字段和方法会被自动继承到外层结构体中,从而实现代码的重用。例如:

type Animal struct {
    name string
}

func (a *Animal) printName() {
    fmt.Printf("动物的名字为:%s\n", a.name)
}

type Cat struct {
    *Animal
    color string
}

func (c *Cat) speak() {
    fmt.Printf("%s的叫声:%s\n", c.name, "喵~")
}

func main() {
    cat := Cat{
       Animal: &Animal{
          name: "猫",
       },
       color: "black",
    }
    cat.printName() // 动物的名字为:猫
    cat.speak()     // 猫的叫声:喵~
}

上述代码中,Cat结构体内嵌了匿名变量AnimalAnimal结构体的字段以及方法被自动继承到了Cat结构体中,在第25行代码中使用Cat类型声明的变量调用Animal结构体的方法printName

多态

多态是指同一种操作对于不同的对象可以有不同的行为

在Go语言中,多态是通过接口和类型断言实现的。不同的结构体类型可以通过实现接口中定义的所有方法来实现不同的行为,接口类型的变量调用方法时,会调用其具体类型对应实现的方法,而无需关心具体的实现类型。举个简单的例子:

type Animal interface {
    Sound()
}

type Cat struct{}
type Dog struct{}

func (c *Cat) Sound() {
    fmt.Println("猫的叫声:喵~")
}

func (d *Dog) Sound() {
    fmt.Println("狗的叫声:汪!")
}

func main() {
    var animal Animal = &Cat{}
    animal.Sound() // 猫的叫声:喵~
    animal = &Dog{}
    animal.Sound() // 狗的叫声:汪!
}

上述代码中,结构体Cat与结构体Dog分别实现了接口Animal的方法Sound(),在main()中声明animal接口变量,并以此初始化结构体Cat类型与结构体Dog类型,接口变量分别调用Sound(),产生的结果也会不同,体现了多态性。