分享

Go语言

 菌心说 2021-12-28

0 前言

Go语言通过自定义的方式形成新的类型,结构体是类型中都有成员的复合类型。Go语言使用结构体和结构体成员来描述真实世界的实体和实体对应的各种属性。

结构体成员是由一系列的成员变量构成,这些成员变量也被称为“字段”。字段有以下特性:

  • 字段拥有自己的类型和值。

  • 字段名必须唯一。

  • 字段的类型也可以是结构体,甚至是字段所在结构体的类型。

  • 结构体的存储空间是连续的,字段按照声明时的顺序存放(注意字段之间有字节对齐要求)

<提示> Go语言没有“类”的概念,也不支持“类”的继承等面向对象的概念。Go语言中通过结构体的内嵌再配合接口比面向对象具有更高的扩展性和灵活性。Go语言不仅认为结构体能拥有方法,且每种自定义类型也可以拥有自己的方法。

1 结构体定义

1.1 自定义结构体类型

使用 type 和 struct 关键字来定义结构体,定义格式

type 类型名 struct {
    字段名1 字段1类型
    字段名2 字段2类型
    ...
}
  • 类型名:标识自定义结构体的名称,在同一个包内不能重复。

  • struct { }:表示结构体类型,type 类型名 struct { } 可以理解为将struct {} 结构体定义为类型名的类型。

  • 字段1、字段2.....:表示结构体字段名。结构体中的字段名必须唯一。

  • 字段1类型、字段2类型......:表示结构体字段的数据类型,可以是任意类型。

示例1:定义一个Person结构体,代码如下:

type Person struct {
    name string
    city string
    age  int8
}

// 相同类型的字段可以写在一行,字段之间用逗号分隔
type Person struct {
    name, city string
    age  int8
}

1.2 匿名结构体

 匿名结构体没有类型名称,无须通过type 关键字定义就可以直接使用。

定义匿名结构体类型,定义格式如下:

struct {
    字段1 字段1类型
    字段2 字段2类型
    ...
}

 <说明> 在定义一些临时数据结构等场景下可以使用匿名结构体。

2 实例化结构体 — 为结构体分配内存空间并初始化

结构体的定义只是一种内存布局的描述,只有当结构体实例化后,才会真正地分配内存空间。因此必须在定义结构体并实例化后才能使用结构体的字段。

结构体实例化就是根据结构体定义的格式创建一份与格式一致的内存区域。结构体的实例与实例之间是相互独立的实体。

Go语言可以通过多种方式实例化结构体,可以根据自己的实际需要选择合适的实例化写法。

2.1 基本的实例化形式

 结构体本身是一种类型,可以像整型、字符串等类型一样,以 var 关键字的方式声明结构体即可完成实例化。基本实例化格式如下:

var ins T
  • T:结构体类型。

  • ins:结构体的实例名。

示例1:实例化Person结构体。

type Person struct {
    name string
    city string
    age  int8
}

func main() {
    var p Person         //声明一个Person结构体类型的变量p
    
    //打印结构体实例的地址和各成员变量默认值
    fmt.Printf('&p=%p, p=%v\n', &p, p)
    
    //为结构体成员赋值,结构体实例名通过点号(.)来访问成员
    p.name = 'Godlike'
    p.city = 'Shenzhen'
    p.age = 24
    
    //打印结构体成员信息
    fmt.Printf('p=%v\n', p)  //
    fmt.Printf('p=%#v\n', p) //
}

运行结果:

&p=0xc000056150, p={  0}
p={Godlike Shenzhen 24}
p=main.Person{name:'Godlike', city:'Shenzhen', age:24}

《代码分析》从运行结构可以得知,当结构体变量实例化成功后,其各成员的默认值是各自类型的零值。

2.2 创建指针类型结构体实例

在Go语言中,还可以使用 new 关键字对类型(包括结构体、整型、浮点型、字符串等)进行实例化,结构体在实例化后会返回结构体实例的指针,即结构体的地址。使用new实例化的格式如下:

ins := new(T)
  • T:为类型,可以是 结构体、整型、浮点型、字符串等类型。

  • ins:T类型被实例化后将其首地址保存到ins变量中,ins的类型为 *T,属于指针。

结构体指针变量指向结构体实例后,同样也是使用点号(.)来访问结构体的成员。

示例2:创建指针类型类型结构体实例。

type Person struct {
    name string
    city string
    age  int8
}

func main() {
    var p = new(Person)
    
    //打印变量p的类型
    fmt.Printf('p type: %T\n', p)
    
    //打印结构体实例的地址和各成员变量默认值
    fmt.Printf('p=%p, p=%#v\n', p, p)
    
    //为结构体成员赋值
    p.name = 'Godlike'
    p.city = 'Shenzhen'
    p.age = 24
    
    //打印结构体实例的首地址
    fmt.Printf('&Person.name=%p\n', &p.name)
    
    //打印结构体成员信息
    fmt.Printf('p=%v\n', p)  //%v:值的默认格式
    fmt.Printf('p=%#v\n', p) //%#v:相应值的Go语法表示
}

运行结果:

p type: *main.Person
p=0xc000056150, p=&main.Person{name:'', city:'', age:0}
&Person.name=0xc000056150
p=&{Godlike Shenzhen 24}
p=&main.Person{name:'Godlike', city:'Shenzhen', age:24}

《代码说明》使用new创建的结构体实例返回的是结构体实例的指针,指针变量p的值是该结构体实例的首地址,亦即指针变量p指向该结构体实例,并通过指针变量p访问结构体实例的成员。

<提示> 在C/C++语言中,使用new实例化类型后,访问其成员变量时必须使用'->'操作符。而在Go语言中,使用结构体指针变量访问结构体实例的成员变量可以继续使用点号(.)。这是因为Go语言为了方便开发者访问结构体成员变量,使用了语法糖(Syntactic sugar)技术,将p.name 形式自动转换为(*p).name。

2.3 取结构体地址的方式实例化

在Go语言中,对结构体进行取地址(&)操作时,视为对该类型进行一次new 的实例化操作。取地址格式如下:

ins := &T{}
  • T:表示结构体类型。

  • ins:表示结构体实例的引用,类型为 *T,即指针类型。

示例3:取地址实例化结构体。

type Person struct {
    name string
    city string
    age  int8
}

func main() {
    var p = &Person{}   //<==> var p = new(Person)
    
    //打印变量p的类型
    fmt.Printf('p type: %T\n', p)
    
    //打印结构体实例的地址和各成员变量默认值
    fmt.Printf('p=%p, p=%#v\n', p, p)
    
    //为结构体成员赋值
    p.name = 'Godlike'
    p.city = 'Shenzhen'
    p.age = 24
    
    //打印结构体实例的首地址
    fmt.Printf('&Person.name=%p\n', &p.name)
    
    //打印结构体成员信息
    fmt.Printf('p=%v\n', p)  //%v:值的默认格式
    fmt.Printf('p=%#v\n', p) //%#v:相应值的Go语法表示
}

运行结果:

p type: *main.Person
p=0xc000056150, p=&main.Person{name:'', city:'', age:0}
&Person.name=0xc000056150
p=&{Godlike Shenzhen 24}
p=&main.Person{name:'Godlike', city:'Shenzhen', age:24}

《代码说明》可以看到,运行结果和上面的示例2的运行结果一样。

<提示> 取地址实例化是最广泛的一种结构体实例化方式。

3 初始化结构体的成员

结构体在实例化时可以直接对成员进行初始化。初始化有两种方式:一种是字段“键值对”形式,另一种是字段值列表的形式。键值对形式的初始化适合选择性填充字段较多的结构体;字段值列表的初始化形式适合填充字段比较少的结构体。

没有初始化的结构体实例,其成员变量都是对应其类型的零值。如,数值类型为0,字符串为空,布尔类型为false,指针为nil等。

3.1 使用键值对初始化结构体

结构体使用“键值对”初始化结构体时,“键”对应的是结构体字段名,键的“值”对应的是字段初始化时的字面值。

键值对填充结构体是可选的,不需要初始化的字段可以不填入初始化列表中。键值对初始化的格式如下:

ins := 结构体类型名 {
    字段1: 字段1的值,
    字段2: 字段2的值,
    字段3: 字段3的值,
    ...
}

《说明》键值之间使用冒号隔开,键值对之间使用逗号隔开。需要注意的是,最后一个键值对后面的逗号不能省略。

示例1:使用键值对填充结构体的例子。

type Person struct {
    name string
    city string
    age  int8
}

p1 := Person{
    name: 'Zhangsan',
    city: 'Shenzhen',
     age:  24,         //最后的逗号不能省略,否则会编译报错
}
fmt.Printf('p1=%#v\n', p1) //p1=main.Person{name:'Zhangsan', city:'Shenzhen', age:24}

//也可以对结构体指针进行键值对初始化
p2 := &Person{
    name: 'Lisi',
    city: 'Beijing',
     age: 18,
}
fmt.Printf('p2=%#v\n', p2)  //p2=&main.Person{name:'Lisi', city:'Beijing', age:18}

//当某些字段不需要初始值的时候,该字段可以不写。此时,没有指定初始值的字段的值就是该字段类型的零值
p3 := &Person{
    city: 'Wuhan',
}
fmt.Printf('p3=%#v\n', p3)  //p3=&main.Person{name:'', city:'Wuhan', age:0}

3.2 使用值的列表初始化结构体

使用值的列表初始化结构体的格式如下:

ins := 结构体类型名{
    字段1的值,
    字段2的值,
    字段3的值,
    ...
}

使用值的列表的形式初始化结构体时,需要注意的事项:

  • 必须初始化结构体的所有字段。

  • 初始值的填充顺序必须与字段在结构体中的声明顺序一致。

  • 键值对和值列表的初始化形式不能混用。

示例2:值列表初始化结构体的例子。

p4 := Person{'Wangwu', 'Shanghai', 20}
fmt.Printf('p4=%#v\n', p4)  //p4=main.Person{name:'Wangwu', city:'Shanghai', age:20}

//也可以对结构体指针进行值列表的初始化
p5 := &Person{'James', 'Los Angeles', 36}
fmt.Printf('p5=%#v\n', p5)  //p5=&main.Person{name:'James', city:'Los Angeles', age:36}

3.3 初始化匿名结构体

匿名结构体的初始化由结构体定义和对初始化初始化两部分组成。结构体定义时没有结构体类型名,只有字段和类型定义。

示例3:匿名结构体初始化的例子。

func main() {
    //匿名结构体实例化并初始化方式1
    p1 := struct {
        name string
        city string
        age  int8
    }{}               //后面的{}不能省略
    //初始化赋值
    p1.name = 'Zhangsan'
    p1.city = 'Shenzhen'
    p1.age = 24
    fmt.Printf('p1=%#v\n', p1)

    //匿名结构体实例化并初始化方式2
    p2 := struct {
        name string
        city string
        age  int8
    }{ //使用键值对初始化
        name: 'Lisi',
        city: 'Beijing',
         age:  18,         //最后的逗号不能省略
    }
    fmt.Printf('p2=%#v\n', p2)

    //当某些字段不需要初始值的时候,该字段可以不写。此时,没有指定初始值的字段的值就是该字段类型的零值
    p3 := struct {
        name string
        city string
        age  int8
    }{
        city: 'Wuhan',
    }
    fmt.Printf('p3=%#v\n', p3)
    
    //匿名结构体实例化并初始化方式3
    p4 := &struct {
        name string
        city string
        age  int8
    }{ //使用值列表初始化
        'Mike',
        'London',
        30,          //最后的逗号不能省略
    }
    fmt.Printf('p4=%#v\n', p4)
}

运行结果:

p1=struct { name string; city string; age int8 }{name:'Zhangsan', city:'Shenzhen', age:24}
p2=struct { name string; city string; age int8 }{name:'Lisi', city:'Beijing', age:18}
p3=struct { name string; city string; age int8 }{name:'', city:'Wuhan', age:0}
p4=&struct { name string; city string; age int8 }{name:'Mike', city:'London', age:30}

《代码分析》从运行结果可以看到,匿名结构体的类型是:struct { name string; city string; age int8 },之所以匿名是因为我们没有使用type关键字给它定义一个自定义类型名而已。

示例4:只实例化匿名结构体,不进行初始化的例子。

func main() {
    //匿名结构体实例化方式1
    p1 := struct {
        name string
        city string
        age  int8
    }{}
    fmt.Printf('p1=%#v\n', p1)
    
    //匿名结构体实例化方式2
    p2 := &struct {
        name string
        city string
        age  int8
    }{}
    fmt.Printf('p2=%#v\n', p2)
}

运行结果:

p1=struct { name string; city string; age int8 }{name:'', city:'', age:0}
p2=&struct { name string; city string; age int8 }{name:'', city:'', age:0}

《代码分析》匿名结构体实例化方式1返回的是一个结构体实例变量;实例化方式2返回的是一个结构体指针变量,这个指针指向结构体实例。

<注意> 实例化匿名结构体时,struct { name string; city string; age int8 } {} 最后的一对花括号不能遗漏,否则会报编译错误。

4 结构体内存布局

结构体占用的是一块连续的内存空间。

示例1

func main() {
    type Test struct {
        a int8
        b int8
        c int8
        d int8
    }
    n := Test{1, 2, 3, 4,}
    
    fmt.Printf('&n=%p\n', &n)
    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=0xc000016090
&n.a=0xc000016090
&n.b=0xc000016091
&n.c=0xc000016092
&n.d=0xc000016093

《代码分析》Test结构体的成员变量a,b,c,d在内存中各占1个字节的内存单元,可以看到它们的内存地址是连续的。结构体实例变量n的地址就是结构体这块连续内存空间的首地址,也是第一个成员变量a的地址。

 示例2:计算一下结构体占用内存空间的大小是多少字节?

type Test struct {
    a bool
    b int32
    c int8
    d int64
    e byte
}

《分析》bool:1字节;int32:4字节;int8:1字节;int64:8字节;byte:1字节。

这么一算,Test结构体占用的内存大小=1+4+1+8+1=15。但是,真实情况是怎样的呢?我们实际执行看看:

package main

import (
    'fmt'
    'unsafe'
)

func main() {
    type Test struct {
        a bool         // 1
        b int32        // 4
        c int8         // 1
        d int64        // 8
        e byte         // 1
    }

    test := Test{}     // 声明一个结构体实例变量test
    fmt.Printf('Test size: %d, align: %d\n', unsafe.Sizeof(test), unsafe.Alignof(test))
}

运行结果:Test size: 32, align: 8

《代码分析》最终的输出结果为32字节,这与我们自己手动计算的结果完全不一样,这说明了前面的计算方式是错误的。这是为什么呢?

这里就涉及到“内存对齐”的概念了,这里就简单说明一下。内存对齐规则主要是一下2点:

(1)每一种基本类型都有一个对齐值(也称为对齐系数),结构体的成员是按字节对齐的方式存储在内存空间当中的,所谓字节对齐就是结构体成员变量的首地址相对于结构体的首地址的偏移量是其对齐值的整数倍。

(2)结构体本身也需要字节对齐,结构体的对齐值取它的成员中对齐系数的最大值。结构体本身对齐的原则是结构体内存空间的下一个字节的地址相对于结构体的首地址的偏移量是其对齐值的整数倍。

根据这两个规则,我们来分析Test结构体的内存布局情况:

成员变量类型对齐系数偏移量自身占用
abool101
字节对齐--13
bint32444
cint8181
字节对齐--97
dint648168
ebyte1241
字节对齐--257
总占用大小-83232

偏移量其实就是成员变量的首地址相对于结构体的首地址的字节长度大小。偏移量必须是成员变量类型的对齐系数的整数倍。需要注意的是,当存放最后一个成员变量e,此时结构体占用的内存空间大小是25,而结构体的对齐系数是8,25不是8的倍数,因此确定偏移量是32。

 Test 内存布局:axxx|bbbb|cxxx|xxxx|dddd|dddd|exxxx|xxxx

如果我们调整一下Test结构体的成员顺序,其占用的存储空间会不会有变化呢?示例3代码如下:

package main

import (
    'fmt'
    'unsafe'
)

func main() {
    type Test struct {
        a bool
        b int32
        c int8
        d int64
        e byte
    }

    type Test2 struct {
        a bool
        c int8
        e byte
        b int32
        d int64
        
    }

    test := Test{}
    fmt.Printf('Test size: %d, align: %d\n', unsafe.Sizeof(test), unsafe.Alignof(test))
    
    test2 := Test2{}
    fmt.Printf('Test2 size: %d, align: %d\n', unsafe.Sizeof(test2), unsafe.Alignof(test2))
}

 运行结果:

Test size: 32, align: 8
Test2 size: 16, align: 8

 《结果分析》可以看到,Test2结构体只是调整了一下Test结构体的成员变量的定义顺序,就改变了结构体占用的内存空间大小。下面我们来分析一下Test2结构体的内存布局。分析流程如下表所示:

成员变量类型对齐系数偏移量自身占用
abool101
cint8111
ebyte121
字节对齐--31
bint32444
dint64888
总占用大小-81616

Test2 内存布局:acex|bbbb|dddd|dddd

总结】通过对比Test 和 Test2 的内存布局:

  • Test:  axxx|bbbb|cxxx|xxxx|dddd|dddd|exxxx|xxxx

  • Test2:acex|bbbb|dddd|dddd

仔细对比可以发现,Test 比 Test2 多了很多的x,这些多出的x,我们称之为填充字节(padding)。这些padding的出现是由于结构体不同成员类型的组合需要进行字节对齐,以保证内存访问边界。同时我们发现,通过合理调整结构体成员变量的字段顺序可以缩写结构体占用的内存空间大小。

<说明> unsafe.Alignof(test) 表示计算结构体类型Test的类型对齐值。

【参考1】在 Go 中恰到好处的内存对齐

【参考2】unsafe 库使用小结

【参考3】Go语言之unsafe包介绍及使用

5 构造函数 — 结构体和类型的一系列初始化操作的函数封装

Go语言的类型或结构体本身没有构造函数,但是我们可以自己使用函数封装实现。

<提示> 其他编程语言构造函数的一些常见功能及特性如下:

  • 每个类可以构造函数,多个构造函数使用函数重载实现。

  • 构造函数一般与类名同名,且没有返回值。

  • 构造函数有一个静态构造函数,一般用这个特性来调用父类的构造函数。

  • 对应C++来说,还有默认构造函数、拷贝构造函数等。

由于Go语言的结构体是值类型,如果结构体比较复杂的话,值拷贝性能开销会比较大,所以构造函数返回的一般是结构体指针类型。

示例1:手动实现结构体的构造函数。

type Person struct {
    name string
    city string
    age  int8
}

func newPerson1(name, city string, age int8) *Person {
    return &Person{
        name: name,
        city: city,
         age: age,
    }
}

func newPerson2(name, city string, age int8) *Person {
    p := new(Person)  //或者 p := &Person{}
    p.name = name
    p.city = city
    p.age  = age
    
    return p
}

func main() {
    p1 := newPerson1('Jack', 'London', 18)
    fmt.Printf('p1=%#v\n', p1)
    
    p2 := newPerson2('Alice', 'Los Angel', 24)
    fmt.Printf('p2=%#v\n', p2)
}

运行结果:

p1=&main.Person{name:'Jack', city:'London', age:18}
p2=&main.Person{name:'Alice', city:'Los Angel', age:24}

6 方法

Go语言的方法(Method)是一种作用于特定类型变量的函数。这种特定类型变量叫做接收器(Receiver)。

如果将特定类型理解为结构体或“类”时,接收器的概念就类似于其他编程语言中的this 或者 self。Go语言的类型方法是一种对类型行为的封装。Go语言的方法非常纯粹,可以看作是特殊类型的函数,其显式地将对象实例或指针作为函数的第一个参数,并且参数名可以自己指定,而不强制要求一定是this 或是 self。这个对象实例或指针称为方法的接收者(Receiver)。

在Go语言中,接收器的类型可以是任何类型,不仅仅是结构体类型,任何类型都可以拥有自己的方法。

<提示> 在面向对象的语言中,类拥有的方法一般被理解为类可以做的事情。在Go语言中的方法的概念与其他编程语言一致,只是Go语言建立的“接收器”强调方法的作用对象是接收器,也就是类型实例,而函数没有作用对象。

##】为命名类型定义方法的语法格式如下:

// 类型方法接收者是值类型
func (t TypeName) MethodName(ParamList) (ReturnList) {
    //method body
}

// 类型方法接收者是指针类型
func (t *TypeName) MethodName(ParamList) (ReturnList) {
    //method body
}

《说明》

  • t:表示接收器变量,接收器参数变量名在命名时,建议使用接收器类型名的第一个小写字母,而不是this、self之类的命名。例如,Socket类型到接收器变量应该命名为s,Connector类型的接收器变量应该命名为c等。如果方法内部并不引用实例变量,可省略参数名,仅保留类型名。

  • TypeName:为接收器参数变量类型。

  • MethodName:为方法名,是一个自定义的标识符。

  • ParamList:形参列表。

  • ReturnList:返回值列表。

Go语言的类型方法本质上就是一个函数,它没有使用隐式的指针this或是self,这是Go的优点,简单明了。我们可以将类型方法改写成常规的函数。示例如下:

// 类型方法接收者是值类型
func TypeName_MethodName(t TypeName, otherParamList) (ReturnList) {
    //function body
}

// 类型方法接收者是指针类型
func TypeName_MethodName(t *TypeName, otherParamList) (ReturnList) {
    //function body
}

6.1 类型方法的特点

1、可以为命名类型添加方法(除了接口类型和指针类型),非命名类型不能自定义方法。

比如说不能为 []int 类型增加方法,因为 []int 是非命名类型。命名接口类型本身就是一个方法的签名集合,所以不能为其增加具体的实现方法。

2、为类型增加方法有一个限制,就是方法的定义必须和类型的定义在同一个包中。

不能再为int、bool 等预声明类型增加方法,虽然它们是命名类型,但是它们是Go语言内置的预声明类型,作用域是全局的,为这些类型新增的方法是在某个包中,这与第2条规则冲突,所以Go编译器会拒绝为 int等内置类型增加方法。

3、方法的命名空间的可见性和变量一样,大写开头的方法可以在包外被访问,否则只能在包内可见。

4、使用 type 关键字自定义的类型是一个新类型,新类型不能继承原有类型的方法,但是底层类型支持的运算可以被新类型继承。

type MyInt int

func main(){
    var a MyInt = 10
    var b MyInt = 20
    
    //int类型支持的加减乘除运算,新类型同样可用
    c := a + b
    d := a * b
    
    fmt.Printf('%d + %d = %d\n', a, b, c)   //10 + 20 = 30
    fmt.Printf('%d * %d = %d\n', a, b, d)   //10 * 20 = 200
}

5、方法同样不支持重载(overload)。

6、方法名不能与结构体类型同名。

##】方法和函数的区别

两者的区别在于函数不属于任何类型,而方法属于特定类型,它是与对象实例绑定的特殊函数。方法是面向对象编程的基本概念,用于维护和展示对象的自身状态。

6.2 为结构体添加方法

接收器根据接收器的类型可以分为非指针接收器和指针接收器。非指针接收器传递的是值类型,而指针接收器传递的是指针值,亦即引用类型。

##】非指针类型接收器

当方法作用于非指针接收器时,Go语言会在代码运行时将接收器的值复制一份。在非接收器的方法中可以获取接收器的成员值,但修改后无效。

示例1:非指针类型接收器使用例子。

// 定义点结构
type Point struct {
    X int
    Y int
}

// 非指针接收器的Add方法
func (p Point) Add(other Point) Point {
    //打印接收器p的地址和值
    fmt.Printf('&p=%p, p=%#v\n', &p, p)

    //成员值与参数相加后返回新的实例
    return Point{p.X + other.X, p.Y + other.Y}
}

func main(){
    //初始化点实例变量
    p1 := Point{1, 1}
    p2 := Point{2, 2}
    
    //打印Point结构体实例变量p1的地址和值
    fmt.Printf('&p1=%p, p1=%#v\n', &p1, p1)
    
    //与另一个点相加
    result := p1.Add(p2)    //调用Point结构体类型的Add()方法
    
    //输出结果
    fmt.Println(result)
}

 运行结果:

&p1=0xc000016090, p1=main.Point{X:1, Y:1}
&p=0xc0000160e0, p=main.Point{X:1, Y:1}
{3 3}

《代码说明》本例中接收器使用的是非指针类型,从运行结果可以看到,实例变量p1和接收器p是两个不同的内存单元,p只是p1的一个副本,因此在Add()函数内部对p的成员的任何修改不会影响到p1。

##】指针类型的接收器

指针类型的接收器由一个结构体类型指针组成,更接近于面向对象中的this 或者 self。由于指针的特性,调用方法时,修改接收器的任意成员变量值,在方法结束后,修改都是有效的。

示例2:指针类型接收器使用例子。

// 定义点结构
type Point struct {
    X int
    Y int
}

//设置点的坐标值
func (p *Point) SetValue(x, y int) {
    fmt.Printf('SetValue: p=%p, p=%#v\n', p, p)
    //修改p的成员变量
    p.X = x
    p.Y = y
}

func main(){
    //声明一个Point实例变量
    var p1 Point
    fmt.Printf('初始化前: &p1=%p, p1=%#v\n', &p1, p1)

    p1.SetValue(100, 200)
    
    //打印Point结构体实例变量p1的地址和值
    fmt.Printf('初始化后: &p1=%p, p1=%#v\n', &p1, p1)
}

运行结果:

初始化前: &p1=0xc000016090, p1=main.Point{X:0, Y:0}
SetValue: p=0xc000016090, p=&main.Point{X:0, Y:0}
初始化后: &p1=0xc000016090, p1=main.Point{X:100, Y:200}

《代码说明》从运行结构可以看到,main()函数中的p1和SetValue()方法中的接收器指针变量p指向的是同一个内存单元,因此使用指针变量p修改结构体成员变量的值,实际上就是修改结构体实例变量p1的成员变量。

提示】指针和非指针类型接收器的使用

在计算机中,小对象由于值复制时的速度比较快(直接寻址方式),所以适合使用非指针接收器。大对象因为复制副本开销比较大,适合使用指针接收器,在接收器和参数间传递的时不进行复制,只是传递指针。

使用指针类型接收器的场景:

  •  需要修改结构体变量的值时要使用指针接收器。

  • 结构体本身比较大,拷贝的内存开销比较大时也要使用指针接收器。

  • 保持一致性:如果有一个类型方法使用了指针接收器,其他的方法为了统一也要使用指针接收器。

7 结构体匿名字段和结构体内嵌

 7.1 结构体匿名字段

结构体允许其成员字段在声明时没有字段名而只有类型,这种没有名字的字段就称为匿名字段。

示例1:结构体匿名字段的写法。

type Data struct {
    int
    float32
    bool
}

ins := &Data{
    int: 10,
    float32: 3.14,
    bool: true,
}

<注意> 这里的匿名字段并不代表没有字段名,而是默认会采用类型名作为字段名,而结构体要求字段名称必须唯一,因此一个结构体中同类型的匿名字段只能有一个。

7.2 结构体内嵌

结构体实例化后,如果匿名字段的类型为结构体,那么可以直接访问匿名结构体里的所有成员,这种方式被称为结构体内嵌。

7.2.1 嵌套结构体

一个结构体中可以嵌套包含另一个结构体或是结构体指针。

示例2:结构体定义体中内嵌结构体类型字段。

//Address 地址结构体
type Address struct {
    Province string
    City     string
}

//User 用户结构体
type User struct {
    Name    string
    Gender  string
    Address Address
}

func main() {
    user1 := User{
          Name: 'Zhangsan',
        Gender: 'man',
        Address: Address{
            Province: 'GuoDong',
                City: 'Shenzhen',
        },
    }
    
    fmt.Printf('user1=%#v\n', user1)
}

运行结果:

user1=main.User{Name:'Zhangsan', Gender:'man', Address:main.Address{Province:'GuoDong', City:'Shenzhen'}}

上面User结构体中嵌套的Address结构体也可以采用匿名字段的方式。修改示例2的代码如下:

//Address 地址结构体
type Address struct {
    Province string
    City     string
}

//User 用户结构体
type User struct {
    Name    string
    Gender  string
    Address          //匿名字段,这种没有结构体字段名而只有结构体类型的写法,就叫做结构体内嵌
}

func main() {
    var user2 User
    user2.Name = 'Zhangsan'
    user2.Gender = 'man'
    user2.Address.Province = 'GuoDong'  //匿名字段默认使用类型名作为字段名
    user2.City = 'Shenzhen'             //匿名字段的默认字段名也可以省略
    
    fmt.Printf('user2=%#v\n', user2)
}

《代码说明》user2.City = 'Shenzhen' 这种写法叫做结构体内嵌写法,当访问结构体成员时,会先在外部结构体中查找字段,找不到再去嵌套的匿名字段中查找。

7.2.2 嵌套结构体的字段名重名冲突

嵌套结构体内部可能存在相同的字段名。在这种情况下为了避免歧义需要通过指定具体的内嵌结构体字段名。

示例3

//Address 地址结构体
type Address struct {
    Province   string
    City       string
    CreateTime string
}

//Email 邮箱结构体
type Email struct {
    Account    string
    CreateTime string
}

//User 用户结构体
type User struct {
    Name   string
    Gender string
    Address
    Email
}

func main() {
    var user3 User
    user3.Name = 'Zhangsan'
    user3.Gender = 'man'
    user3.Province = 'GuoDong'
    user3.City = 'Shenzhen'
    user3.Account = 'Zhangsan@gmail.com'
    
    //user3.CreateTime = '2019'         //编译报错:ambiguous selector user3.CreateTime
    user3.Address.CreateTime = '2020'   //指定Address结构体中的CreateTime
    user3.Email.CreateTime = '2020'     //指定Email结构体中的CreateTime
    
    fmt.Printf('user3=%#v\n', user3)
}

运行结果:

user3=main.User{Name:'Zhangsan', Gender:'man', Address:main.Address{Province:'GuoDong', City:'Shenzhen', CreateTime:'2020'}, Email:main.Email{Account:'Zhangsan@gmail.com', CreateTime:'2020'}}

《代码说明》Address 和 Email 都是User结构体的内嵌结构体,这两个内嵌结构体都有一个CreateTime字段,如果直接使用结构体内嵌写法user3.CreateTime,编译的时候会报:ambiguous selector user3.CreateTime。因此,为了避免歧义,需要通过指定具体的内嵌结构体字段名。

7.3 结构体的“继承” — 组合

7.3.1 使用组合思想描述对象特性

在面向对象编程思想中,实现对象关系需要使用“继承”特性。例如,人类不能飞行,只能行走,而鸟类既可飞行又能行走。人类和鸟类都可以继承行走类,但只有鸟类继承飞行类。

面向对象编程的设计原则中建议对象最好不要使用多重继承,有些面向对象编程语言从语言层面就禁止了多重继承,如C#和Java。鸟类同时继承行走类和飞行类,这显然是存在问题的。在面向对象思想中要正确地实现对象的多重特性,只能使用一些精巧的设计来补救。

使用type 关键字定义的新类型不会继承原有类型的方法,有个特例就是命名结构体类型,命名结构体类型可以嵌套其他的命名类型的字段,外层的结构体是可以嵌入调用字段类型的方法,这种调用既可以是显式的调用,也可以是隐式的调用。这就是Go语言的“继承”,准确地说这就是Go的“组合”。因为Go语言没有继承的语义,结构和字段之间是“has a”的关系,而不是“is a”的关系;没有父子的概念,仅仅是整体和局部的概念,这种嵌套的结构和字段的关系称为组合。

Go语言结构体的内嵌特性就是一种组合特性,使用组合特性可以快速构建对象的不同特性。

示例1:使用Go语言的结构体内嵌实现对象特性组合。

//定义飞行结构体
type Flying struct {}

//定义飞行结构体方法
func (f *Flying) Fly(){
    fmt.Println('--Can fly')
}

//定义行走结构体
type Walkable struct {}

//定义行走结构体方法
func (w *Walkable) Walk(){
    fmt.Println('--Can Walk')
}

// 人类结构体
type Human struct {
    Walkable               //人类能行走
}

// 鸟类结构体
type Bird struct {
    Walkable               //鸟类能行走
    Flying                 //鸟类能飞行
}

func main(){
    //实例化人类
    h := new(Human)
    fmt.Println('Human:')
    h.Walk()

    //实例化鸟类
    b := new(Bird)
    fmt.Println('Bird:')
    b.Walk()
    b.Fly()
}

运行结果:

Human:
--Can Walk
Bird:
--Can Walk
--Can fly

《代码说明》使用Go语言的内嵌结构体实现对象的特性,可以自由地在对象中增删改查各种特性。Go语言编译器会在编译时检查能否使用这些特性。

7.3.2 初始化结构体内嵌

结构体内嵌初始化时,将结构体内嵌的类型名作为字段名像普通结构体一样进行初始化。

示例2:车辆结构的组装和初始化。

//车轮
type Wheel struct{
    Size int
}

//引擎
type Engine struct{
    Power int     //功率
    Type  string  //类型
}

//车
type Car struct{
    Wheel
    Engine
}

func main(){
    c := Car{
        //初始化车轮
        Wheel: Wheel{
            Size: 18,
        },
        
        //初始化引擎
        Engine: Engine{
            Power: 143,
            Type: '2.0T',
        },
    }
    fmt.Printf('%+v\n', c);
}

运行结果:

{Wheel:{Size:18} Engine:{Power:143 Type:2.0T}}

7.3.3 初始化内嵌匿名结构体

在 前面描述车辆和引擎到示例中,有时考虑编写代码的便利性,会将结构体直接定义在嵌入的结构体内部。也就是说,结构体的定义不会被外部引用到。在初始化这个匿名嵌入的结构体时,就需要再次声明结构体才能赋值初始化。

示例3:初始化内嵌匿名结构体的例子。

//车轮
type Wheel struct{
    Size int
}

//车
type Car struct{
    Wheel
    //引擎,匿名结构体,Engine不是类型名而是匿名结构体的字段名
    Engine struct{
        Power int     //功率
        Type  string  //类型
    }
}

func main(){
    c := Car{
        //初始化车轮
        Wheel: Wheel{Size: 18,},
        
        //初始化引擎
        Engine: struct{
            Power int
            Type  string
        }{
            Power: 143,
            Type: '2.0T',
        },
    }
    fmt.Printf('%+v\n', c);  //{Wheel:{Size:18} Engine:{Power:143 Type:2.0T}}
}

《代码说明》本示例中,原来的Engine结构体被直接定义在Car结构体中。这种嵌入的写法就是将原来的结构体类型转换为struct {...}。

当需要对Car的Engine字段进行初始化时,由于Engine字段的类型并没有被单独定义,因此在初始化其字段时需要先写struct {...}声明其类型,然后再使用键值对的方式初始化。

8 结构体字段的可见性

结构体中字段大写开头表示可公开访问,小写表示私有(仅限在定义当前结构体的包中可访问)。

9 结构体与JSON

JSON(JavaScript Object Notation) 是一种轻量级的数据交换格式。易于人阅读和编写。同时也易于机器解析和生成。JSON键值对是用来保存JS对象的一种方式,键/值对组合中的键名写在前面并用双引号''包裹,使用冒号:分隔,然后紧接着值;多个键值之间使用英文逗号(,)分隔。

结构体与JSON数据的关系:

1、序列化:将Go语言中结构体字段 --> json格式的字符串。

2、反序列化:将json格式的字符串 --> Go语言中能够识别的结构体字段。

示例1:结构体与JSON的序列化与反序列化例子。

package main

import (
    'fmt'
    'encoding/json'
)

type Person struct {
    Name string `json:'name' db:'name' ini:'name'`  //反引号的内容是结构体标签
    Age  int    `json:'age'`
}

func main() {
    p1 := Person{
        Name: 'Godlike',
        Age : 18,
    }
    //序列化
    data, err := json.Marshal(p1)
    if err != nil {
        fmt.Printf('marchal failed, err:%v\n', err)
        return
    }
    fmt.Printf('json data: %#v\n', string(data))  //data是[]byte类型,需要转换成字符串格式输出
    
    //反序列化
    str := `{'name': '李想', 'age': 18}`
    var p2 Person
    json.Unmarshal([]byte(str), &p2)  //传指针是为了能在json.Unmarshal()函数内部修改p2的值
    fmt.Printf('p2=%#v\n', p2)
}

运行结果:

json data: '{\'name\':\'Godlike\',\'age\':18}'
p2=main.Person{Name:'李想', Age:18}

《代码说明》上述实例中,使用json.Marshal()对结构体实例变量p1进行序列化,它会将结构体变量p1序列化为 []byte 格式的JSON数据。

使用json.Unmarshal()函数,输入完整的JSON数据,首先需要强制转换为[]byte类型,将JSON数据按Person结构体定义的格式序列化到结构体变量p2中,填充结构体的对应字段。

10 结构体标签(struct Tag) — 对结构体字段额外信息标签

Tag是结构体的元信息,可以在运行的时候通过反射的机制读取出来。 Tag在结构体字段的后方定义,由一对反引号包裹起来,具体的格式如下:

`key1:'value1' key2:'value2'`

结构体tag由一个或多个键值对组成。键与值使用冒号分隔,值用双引号括起来。同一个结构体字段可以设置多个键值对tag,不同的键值对之间使用空格分隔。

<注意> 为结构体编写Tag时,必须严格遵守键值对的规则。结构体标签的解析代码的容错能力很差,一旦格式写错,编译和运行时都不会提示任何错误,通过反射也无法正确取值。例如,不要在key和value之间添加空格。

示例1:为结构体字段定义JSON序列化时使用Tag。

package main

import (
    'fmt'
    'encoding/json'
)

//Student 结构体
type Student struct {
    ID     int    `json:'id'` //通过指定tag实现json序列化该字段时的key
    Gender string             //json序列化是默认使用字段名作为key
    name   string             //私有成员不能被json包访问
}

func main() {
    s1 := Student{
        ID:     10001,
        Gender: 'man',
        name:   'Jack',
    }
    jsonData, err := json.Marshal(s1)    //将结构体变量序列化成json格式数据
    if err != nil {
        fmt.Println('json marshal failed!')
        return
    }
    fmt.Printf('json data:%s\n', string(jsonData)) //json data:{'id':10001,'Gender':'man'}
}

《代码说明》从运行结果可以看到,json格式数据中的第1个字段是标签中定义的“id”,而不是结构体字段名“ID”。

 参考

《Go语言从入门到进阶实战(视频教学版)》

《Go程序设计语言》

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多