『GoLang』结构体与方法

结构体

结构体类型

Go 通过结构体的形式支持用户自定义类型,或者叫定制类型。

Go 语言结构体是实现自定义类型的一种重要数据类型。

结构体是复合类型(composite types),它由一系列属性组成,每个属性都有自己的类型和值的,结构体通过属性把数据聚集在一起。

结构体类型和字段的命名遵循可见性规则

方法(Method)可以访问这些数据,就好像它们是这个独立实体的一部分

结构体是值类型,因此可以通过 new 函数来创建

结构体是由一系列称为字段(fields)的命名元素组成,每个元素都有一个名称和一个类型。 字段名称可以显式指定或隐式指定,没有显式字段名称的字段称为匿名(内嵌)字段。在结构体中,非空字段名称必须是唯一的

结构体定义的一般方式如下:

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

结构体里的字段一般都有名字,像 field1field2 等,如果字段在代码中从来也不会被用到,那么可以命名它为 _

空结构体如下所示:

struct {}

具有6个字段的结构体:

struct {
    x, y int
    u float32
    _ float32  // 填充
    A *[]int
    F func()
}

对于匿名字段,必须将匿名字段指定为类型名称T或指向非接口类型名称*T的指针,并且T本身可能不是指针类型

type innerS struct {
    in1 int
    in2 int
}

type outerS struct {
    b int
    c float32
    int  // anonymous field
    innerS //anonymous field
}

调用方式:

outer := new(outerS)
outer.b = 6
outer.c = 7.5
outer.int = 60
outer.in1 = 5  // outer.innerS.in1这样用也行,效果一样
outer.in2 = 10

使用 new 函数给一个新的结构体变量分配内存,它返回指向已分配内存的指针:

type S struct { a int; b float64 }
new(S)

new(S)S类型的变量分配内存,并初始化a = 0,b = 0.0,返回包含该位置地址的类型*S的值。

我们一般的惯用方法是:t := new(T),变量 t 是一个指向 T的指针,此时结构体字段的值是它们所属类型的零值。

也可以这样写:var t T ,也会给t分配内存,并零值化内存,但是这个时候 t 是类型T

在这两种方式中,t 通常被称做类型 T 的一个实例(instance)或对象(object)。

使用点号符.可以获取结构体字段的值structname.fieldname无论变量是一个结构体类型还是一个结构体类型指针,都使用同样的表示法来引用结构体的字段。例如:

package main

import "fmt"

func main() {
	type myStruct struct{ i int }
	var v myStruct                  // v是结构体类型变量
	var p *myStruct = new(myStruct) // p是指向一个结构体类型变量的指针
	v.i = 1
	p.i = 2
	fmt.Println(v) // {1}
	fmt.Println(p) // &{2}
}

结构体变量有下面几种初始化方式,前面一种按照字段顺序,后面两种则对应字段名来初始化赋值:

type Interval struct {
    start  int
    end   int
}

intr := Interval{0, 3}            // (A)
intr := Interval{end:5, start:1}  // (B)
intr := Interval{end:5}           // (C)

在(A)中,值必须以字段在结构体定义时的顺序给出,(B)显示了另一种方式,字段名加一个冒号放在值的前面,这种情况下值的顺序不必一致,并且某些字段还可以被忽略掉,就像(C)中那样。

结构体类型和字段的命名遵循可见性规则,一个导出的结构体类型中有些字段是导出的,也即首字母大写字段会导出;另一些不可见,也即首字母小写为未导出,对外不可见

结构体特性

结构体的内存布局

Go 语言中,结构体和它所包含的数据在内存中是以连续块的形式存在的,即使结构体中嵌套有其他的结构体,这在性能上带来了很大的优势。

递归结构体

递归结构体类型可以通过引用自身指针来定义。这在定义链表或二叉树的节点时特别有用,此时节点包含指向临近节点的链接。例如:

type Element struct {
    next, prev *Element
    Value interface{}
}

可见性

通过参考应用可见性规则,如果结构体名不能导出,可使用 new 函数使用工厂方法的方法达到同样的目的。例如:

type bitmap struct {
    Size int
    data []byte
}

func NewBitmap(size int) *bitmap {
    div, mod := size/8, size%8
    if mod > 0 {
        div++
    }
    return &bitmap{size, make([]byte, div)}
}

在包外,只有通过NewBitmap函数才可以初始bitmap结构体。同理,在bitmap结构体中,由于其字段data是小写字母开头即并未导出,bitmap结构体的变量不能直接通过选择器读取data字段的数据。

带标签的结构体

结构体中的字段除了有名字和类型外,还可以有一个可选的标签(tag)。它是一个附属于字段的字符串,可以是文档或其他的重要标记。标签的内容不可以在一般的编程中使用,只有 reflect 包能获取它。

reflect包可以在运行时反射得到类型属性方法。如变量是结构体类型,可以通过 Field() 方法来索引结构体的字段,然后就可以得到Tag 属性。例如:

package main

import (
    "fmt"
    "reflect"
)

type Student struct {
    name string "学生名字"     // 结构体标签
    Age int "学生年龄"         // 结构体标签
    Room int `json:"Roomid"`  // 结构体标签
}

func main() {
    st := Student{"Titan", 14, 102}
    fmt.Println(reflect.TypeOf(st).Field(0).Tag)
    fmt.Println(reflect.TypeOf(st).Field(1).Tag)
    fmt.Println(reflect.TypeOf(st).Field(2).Tag)
}

输出:

学生名字
学生年龄
json:"Roomid"

匿名成员

Go语言结构体中可以包含一个或多个匿名(内嵌)字段,即这些字段没有显式的名字,只有字段的类型是必须的,此时类型就是字段的名字(这一特征决定了在一个结构体中,每种数据类型只能有一个匿名字段)。

匿名(内嵌)字段本身也可以是一个结构体类型,即结构体可以包含内嵌结构体

type Human struct {
    name string
}

type Student struct { // 含内嵌结构体Human
    Human // 匿名(内嵌)字段
    int   // 匿名(内嵌)字段
}

Go语言结构体中这种含匿名(内嵌)字段和内嵌结构体的结构,可近似地理解为面向对象语言中的继承概念。

Go 语言中的继承是通过内嵌或者说组合来实现的,所以可以说,在 Go 语言中,相比较于继承,组合更受青睐。

嵌入与聚合

结构体中包含匿名(内嵌)字段叫嵌入或者内嵌;而如果结构体中字段包含了类型名,还有字段名,则是聚合。聚合的在JAVA和C++都是常见的方式,而内嵌则是 Go 的特有方式。

type Human struct {
    name string
}

type Person1 struct {           // 内嵌
    Human
}

type Person2 struct {           // 内嵌, 这种内嵌与上面内嵌有差异
    *Human
}

type Person3 struct{             // 聚合
    human Human
}

使用工厂方法创建结构体实例

Go 语言不支持面向对象编程语言中那样的构造子方法,但是可以很容易的在 Go 中实现 “构造子工厂”方法。为了方便通常会为类型定义一个工厂,按惯例,工厂的名字以 newNew 开头。假设定义了如下的 File 结构体类型:

type File struct {
    fd int  // 文件描述符
    name string  // 文件名
}

下面是这个结构体类型对应的工厂方法,它返回一个指向结构体实例的指针:

func NewFile(fd int, name string) *File {
    if fd < 0 {
        return nil
    }
    return &File{fd, name}
}

然后这样调用它:

f := NewFile(10, "./test.txt")

在 Go 语言中常常像上面这样在工厂方法里使用初始化来简便的实现构造函数。

如果 File 是一个结构体类型,那么表达式 new(File)&File{} 是等价的

如何强制使用工厂方法

type matrix struct {
    ...
}

func NewMatrix(params) *matrix {
    m := new(matrix) // 初始化 m
    return m
}

在其他包里使用工厂方法:

package main
import "matrix"
//...
wrong := new(matrix.matrix)     // 编译失败(matrix 是私有的)
right := matrix.NewMatrix(...)  // 实例化 matrix 的唯一方式

这里利用了可见性规则

这里再强调一句,make()用于引用变量分配空间,new()适用于值对象创建并初始化;结构体是值对象!

方法

方法的定义

在 Go 语言中,结构体就像是类的一种简化形式,那么面向对象程序员可能会问:类的方法在哪里呢?在 Go 语言中有一个概念,它和方法有着同样的名字,并且大体上意思相近。

Go 语言中方法和函数在形式上很像,它是作用在接收器(receiver)上的一个函数,接收器是某种类型的变量。因此方法是一种特殊类型的函数,方法只是比函数多了一个接收器(receiver),当然在接口中定义的函数我们也称为方法(因为最终还是要通过绑定到类型来实现)。

正是因为有了接收器,方法才可以作用于接收器的类型(变量)上,类似于面向对象中类的方法可以作用于类属性上。

接收者类型可以是(几乎)任何类型,不仅仅是结构体类型:任何类型都可以有方法,甚至可以是函数类型,可以是 intboolstring 数组的别名类型。但是接收者不能是一个接口类型,因为接口是一个抽象定义,但是方法却是具体实现;如果这样做会引发一个编译错误:invalid receiver type…

定义方法的一般格式如下:

func (recv receiver_type) methodName(parameter_list) (return_value_list) { 
    ... 
}

在方法名之前,func 关键字之后的括号中指定接收器 receiver

type A struct {
    Face int
}

func (a A) f() {
    fmt.Println("hi ", a.Face)
}

上面代码中,我们定义了结构体 A ,注意f()就是 A 的方法,(a A)表示接收器。a A的实例,f()是它的方法名,方法调用遵循传统的 object.name 即选择器符号:a.f()

接收器(receiver)

  • 接收器类型除了不能是指针类型或接口类型外,可以是其他任何类型,不仅仅是结构体类型,也可以是函数类型,还可以是 int、bool、string 等等为基础的自定义类型
package main

import (
    "fmt"
)

type Human struct {
    name string    // 姓名
    Gender string  // 性别
    Age int        // 年龄
    string         // 匿名字段
}

func (h Human) print() { // 值方法
    fmt.Println("Human:", h)
}

type MyInt int

func (m MyInt) print() { // 值方法
    fmt.Println("MyInt:", m)
}

func main() {
    //使用new方式
    hu := new(Human)
    hu.name = "Titan"
    hu.Gender = "男"
    hu.Age = 14
    hu.string = "Student"
    hu.print()

    // 指针变量
    mi := new(MyInt)
    mi.print()

    // 使用结构体字面量赋值
    hum := Human{"Hawking", "男", 14, "Monitor"}
    hum.print()

    // 值变量
    myi := MyInt(99)
    myi.print()
}

程序输出:

Human: {Titan 男 14 Student}
MyInt: 0
Human: {Hawking 男 14 Monitor}
MyInt: 99
  • 接收器不能是一个接口类型,因为接口是一个抽象定义,但是方法却是具体实现;如果这样做会引发一个编译错误:invalid receiver type…
package main

import (
    "fmt"
)

type printer interface {
    print()
}

func (p printer) print() { //  invalid receiver type printer (printer is an interface type)
    fmt.Println("printer:", p)
}
func main() {}
  • 接收器不能是一个指针类型,但是它可以是任何其他允许类型的指针。
package main

import (
    "fmt"
)

type MyInt int

type Q *MyInt

func (q Q) print() { // invalid receiver type Q (Q is a pointer type)
    fmt.Println("Q:", q)
}

func main() {}

从这可以看出来,你如果Q是指针类型,接收器再 q Q 是会报错的,如果Q不是指针类型,接收器再 q *Q 是没问题的

如果有类型T,方法的接收器为(t T)时我们称为值接收器,该方法称为值方法;方法的接收器为(t *T)时我们称为指针接收器,该方法称为指针方法

类型 T(或 *T)上的所有方法的集合叫做类型 T(或 *T)的方法集。

package main

import (
	"fmt"
)

type MyInt struct{
	n int
}

func (mi *MyInt) print() { // 指针接收器,指针方法
	fmt.Println("MyInt:", *mi)
}
func (mi MyInt) echo() { // 值接收器,值方法
	fmt.Println("MyInt:", mi)
}
func main() {
	i := new(MyInt)
	i.print() // MyInt: {0}
	i.echo()  // MyInt: {0}

	var j MyInt = MyInt{6}
	j.print()  // MyInt: {6}
	j.echo()  // MyInt: {6}
}

从这段代码可以看出,i是指针类型,但是可以调用MyInt的值方法集合指针方法集,同样,j是值类型,也是两种方法集都能调用。

关于接收器的命名

社区约定的接收器命名是类型的一个或两个字母的缩写(像 c 或者 cl 对于 Client)。不要使用泛指的名字像是 me,this 或者 self,也不要使用过度描述的名字,简短即可。

方法表达式与方法值

在Go语言中,方法调用的方式如下:如有类型X的变量xm()是其方法,则方法有效调用方式是x.m(),如果x是指针变量,则x.m()实际上是(&x).m()的简写。所以我们看到指针方法的调用写成x.m(),这其实是一种语法糖。

这里我们了解下Go语言的选择器(selector),如:

x.f

上面代码表示如果x不是包名,则表示是x(或*x)的f(字段或方法)。标识符f(字段或方法)称为选择器(selector),选择器不能是空白标识符。选择器表达式的类型是f的类型。

选择器f可以表示类型T的字段或方法,或者指嵌入字段T的字段或方法f。遍历到f的嵌入字段的层数被称为其在T中的深度。在T中声明的字段或方法f的深度为零。在T中的嵌入字段A中声明的字段或方法f的深度是A中的f的深度加1

在Go语言中,我们认为方法的显式接收器(explicit receiver)x是方法x.m()的等效函数X.m()的第一个参数,所以x.m()X.m(x)是等价的,下面我们看看具体例子:

package main

import (
   "fmt"
)

type T struct {
   a int
}

func (tv T) Mv(a int) int {
   fmt.Printf("Mv的值是: %d\n", a)
   return a
} // 值方法

t.Mv(1)T.Mv(t, 1)效果是一致的,这里显式接收器t可以当做为等效函数T.Mv()的第一个参数。而在Go语言中,我们可以利用选择器,将方法值(Method Value)取到,并可以将其赋值给其它变量。使用 t.Mv,就可以得到 Mv 方法的方法值,而且这个方法值绑定到了显式接收器(实参)t

f0 := t.Mv // 通过选择器将方法值t.Mv赋值给一个变量 f0

除了使用选择器取到方法值外,还可以使用方法表达式(Method Expression) 取到函数值(Function Value)。方法表达式(Method Expression)产生的是一个函数值(Function Value)而不是方法值(Method Value)。

f1 := T.Mv // 利用方法表达式(Method Expression) T.Mv 取到函数值
f1(t, 5)
f2 := (T).Mv // 利用方法表达式(Method Expression) T.Mv 取到函数值
f2(t, 6)

这个函数值的第一个参数必须是一个接收器:

f1(t, 5)
f2(t, 6)

在Go语言中不允许方法重载,因为方法是函数,所以对于一个类型只能有唯一一个特定名称的方法。但是如果基于接收器类型,我们可以通过一种变通的方法,达到这个目的:具有同样名字的方法可以在 2 个或多个不同的接收器类型上存在,比如在同一个包里这么做是允许的:

type MyInt1 int
type MyInt2 int

func (a *MyInt1) Add(b int) int { return 0 }
func (a *MyInt2) Add(b int) int { return 0 }

函数和方法的区别

方法相对于函数多了接收器,这是他们之间最大的区别。

函数是直接调用,而方法是作用在接收器上,方法需要类型的实例来调用。方法接收器必须有一个显式的名字,这个名字必须在方法中被使用。

在接收器是指针时,方法可以改变接收器的值(或状态),这点函数也可以做到(当参数作为指针传递,即通过引用调用时,函数也可以改变参数的状态)。

在 Go 语言中,(接收器)类型关联的方法不写在类型结构里面,就像类那样;耦合更加宽松;类型和方法之间的关联由接收器来建立。

方法没有和定义的数据类型(结构体)混在一起,方法和数据是正交,而且数据和行为(方法)是相对独立的。

匿名类型的方法提升

下面的代码可以看出来,什么时候可以省略匿名嵌入成员

type People struct {
    Age    int
    gender string
    Name   string
}

type OtherPeople struct {
    People
}

func (p People) PeInfo() {
    fmt.Println("People ", p.Name, ": ", p.Age, "岁, 性别:", p.gender)
}

func main() {
    p := People{Age: 18, gender: "男", Name: "芜情"}
    op := OtherPeople{People:p}
    p.PeInfo()  // People  芜情 :  18 岁, 性别: 男
    op.PeInfo()  // People  芜情 :  18 岁, 性别: 男
	
    OtherPeople.PeInfo(op)  // People  芜情 :  18 岁, 性别: 男
    People.PeInfo(p)  // People  芜情 :  18 岁, 性别: 男
}

规则一:如果S包含嵌入字段T,则S*S的方法集都包括具有接收器T的提升方法。*S的方法集还包括具有接收器*T的提升方法。

规则二:如果S包含嵌入字段*T,则S*S的方法集都包括具有接收器T*T的提升方法。

注意:以上规则在调用值方法 t.M() 时会被自动转换为 (&t).M() ,由于这个语法糖,导致我们很容易误解上面的规则不起作用,而实际上规则是有效的,在实际应用中我们可以留意这个问题。

Tips

  • 特别说一下方法的语法糖,go编译器会自动适配值方法和指针方法,比如x.f(),找不到会自动找(&x).f()或者(*x).f(),如果转换后存在了就调用这个方法。

    这个在匿名方法提升时,我只确定会自动找(&x).f()

  • 结构体类型要想把不可见的对象向外部展示可以采取gettersetter方法。

类型的 String() 方法和格式化描述符

当定义了一个有很多方法的类型时,十之八九你会使用 String() 方法来定制类型的字符串形式的输出,换句话说:一种可阅读性和打印性的输出。如果类型定义了 String() 方法,它会被用在fmt.Printf() 中生成默认的输出:等同于使用格式化描述符 %v 产生的输出。还有fmt.Print()fmt.Println() 也会自动使用 String() 方法。

不要在 String() 方法里面调用涉及 String() 方法的方法,它会导致意料之外的错误,比如下面的例子,它导致了一个无限递归调用(TT.String() 调用 fmt.Sprintf,而 fmt.Sprintf 又会反过来调用 TT.String()...),很快就会导致内存溢出:

type TT float64

func (t TT) String() string {
    return fmt.Sprintf("%v", t)
}
t.String()
posted @ 2020-04-11 17:02  芜情  阅读(4373)  评论(0编辑  收藏  举报