Go 语言结构体介绍

1、声明和初始化

Go 不是像 C ++,Java,Ruby和C#一样的面向对象的(OO)语言。它没有对象和继承的概念,也没有很多与面向对象相关的概念,例如多态和重载。

Go所具有的是结构体的概念,可以将一些方法和结构体关联。Go 还支持一种简单但有效的组合形式。 总的来说,它会使代码变的更简单,但在某一些场合,你会错过面向对象提供的一些特性。(值得指出的是,通过组合实现继承是一场古老的战斗呐喊,Go 是我用过的第一种坚定立场的语言,在这个问题上。)

虽然 Go 不会像你以前使用的面向对象语言一样,但是你会注意到结构的定义和类的定义之间有很多相似之处。下面的代码定义了一个简单的 Saiyan 结构体:

type Saiyan struct {
  Name string
  Power int
}

我们将看明白怎么往这个结构体添加一个方法,就像面向对象类,会有方法作为 它的一部分。在这之前,我们先要知道如何声明结构体。

声明和初始化:

当我们第一次看到变量和声明时,我们只看了内置类型,比如整数和字符串。既然现在我们要讨论结构,那么我们需要把讨论范围扩展到指针。

创建结构的值的最简单的方式是:

goku := Saiyan{
  Name: "Goku",
  Power: 9000,
}

注意: 上述结构末尾的逗号 , 是必需的。没有它的话,编译器就会报错。你将会喜欢上这种必需的一致性,尤其当你使用一个与这种风格相反的语言或格式的时候。

我们不必设置所有或哪怕一个字段。下面这些都是有效的:

goku := Saiyan{}

// or

goku := Saiyan{Name: "Goku"}
goku.Power = 9000

就像未赋值的变量其值默认为 0 一样,字段也是如此。

此外,你可以不写字段名,依赖字段顺序去初始化结构体 (但是为了可读性,你应该把字段名写清楚):

goku := Saiyan{"Goku", 9000}

以上所有的示例都是声明变量 goku 并赋值。

许多时候,我们并不想让一个变量直接关联到值,而是让它的值为一个指针,通过指针关联到值。一个指针就是内存中的一个地址;指针的值就是实际值的地址。这是间接地获取值的方式。形象地来说,指针和实际值的关系就相当于房子和指向该房子的方向之间的关系。

为什么我们想要一个指针指向值而不是直接包含该值呢?这归结为 Go 中传递参数到函数的方式:镜像复制。知道了这个,尝试理解一下下面的代码呢?

func main() {
  goku := Saiyan{"Power", 9000}
  Super(goku)
  fmt.Println(goku.Power)
}

func Super(s Saiyan) {
  s.Power += 10000
}

上面程序运行的结果是 9000,而不是 19000,。为什么?因为 Super 修改了原始值 goku 的复制版本,而不是它本身,所以,Super 中的修改并不影响上层调用者。现在为了达到你的期望,我们可以传递一个指针到函数中:

func main() {
  goku := &Saiyan{"Power", 9000}
  Super(goku)
  fmt.Println(goku.Power)
}

func Super(s *Saiyan) {
  s.Power += 10000
}

这一次,我们修改了两处代码。第一个是使用了 & 操作符以获取值的地址(它就是 取地址 操作符)。然后,我们修改了 Super 参数期望的类型。它之前期望一个 Saiyan 类型,但是现在期望一个地址类型 *Saiyan,这里 *X 意思是 指向类型 X 值的指针 。很显然类型 Saiyan*Saiyan 是有关系的,但是他们是不同的类型。

这里注意到我们仍然传递了一个 goku 的值的副本给 Super,但这时 goku 的值其实是一个地址。所以这个副本值也是一个与原值相等的地址,这就是我们间接传值的方式。想象一下,就像复制一个指向饭店的方向牌。你所拥有的是一个方向牌的副本,但是它仍然指向原来的饭店。

我们可以证实一下这是一个地址的副本,通过修改其指向的值(尽管这可能不是你真正想做的事情):

func main() {
  goku := &Saiyan{"Power", 9000}
  Super(goku)
  fmt.Println(goku.Power)
}

func Super(s *Saiyan) {
  s = &Saiyan{"Gohan", 1000}
}

上面的代码,又一次地输出 9000。就像许多语言表现的那样,包括 Ruby,Python, Java 和 C#,Go 以及部分的 C#,只是让这个事实变得更明显一些。

同样很明显的是,复制一个指针比复制一个复杂的结构的消耗小多了。在 64 位的机器上面,一个指针占据 64 bit 的空间。如果我们有一个包含很多字段的结构,创建它的副本将会是一个很昂贵的操作。指针的真正价值在于能够分享它所指向的值。我们是想让 Super 修改 goku 的副本还是修改共享的 goku 值本身呢?

所有这些并不是说你总应该使用指针。这章末尾,在我们见识了结构的更多功能以后,我们将重新检视 指针与值这个问题。

2、结构体上的函数

我们可以把一个方法关联在一个结构体上:

type Saiyan struct {
  Name string
  Power int
}

func (s *Saiyan) Super() {
  s.Power += 10000
}

在上面的代码中,我们可以这么理解,*Saiyan 类型是 Super 方法的接受者。然后我们可以通过下面的代码去调用 Super 方法:

goku := &Saiyan{"Goku", 9001}
goku.Super()
fmt.Println(goku.Power) // 将会打印出 19001

3、构造器

结构体没有构造器。但是,你可以创建一个返回所期望类型的实例的函数(类似于工厂):

func NewSaiyan(name string, power int) *Saiyan {
  return &Saiyan{
    Name: name,
    Power: power,
  }
}

这种模式以错误的方式惹恼了很多开发人员。一方面,这里有一点轻微的语法变化;另一方面,它确实感觉有点不那么明显。

我们的工厂不必返回一个指针;下面的形式是完全有效的:

func NewSaiyan(name string, power int) Saiyan {
  return Saiyan{
    Name: name,
    Power: power,
  }
}

4、结构体的字段

到目前为止的例子中,Saiyan 有两个字段 NamePower,其类型分别为 stringint。字段可以是任何类型 -- 包括其他结构体类型以及目前我们还没有提及的 array,maps,interfaces 和 functions 等类型。

例如,我们可以扩展 Saiyan 的定义:

type Saiyan struct {
  Name string
  Power int
  Father *Saiyan
}

然后我们通过下面的方式初始化:

gohan := &Saiyan{
  Name: "Gohan",
  Power: 1000,
  Father: &Saiyan {
    Name: "Goku",
    Power: 9001,
    Father: nil,
  },
}

5、New

尽管缺少构造器,Go 语言却有一个内置的 new 函数,使用它来分配类型所需要的内存。 new(X) 的结果与 &X{} 相同。

goku := new(Saiyan)
// same as
goku := &Saiyan{}

如何使用取决于你,但是你会发现大多数人更偏爱后一种写法无论是否有字段需要初始化,因为这看起来更具可读性:

goku := new(Saiyan)
goku.name = "goku"
goku.power = 9001

//vs

goku := &Saiyan {
  Name: "goku",
  Power: 9000,
}

无论你选择哪一种,如果你遵循上述的工厂模式,就可以保护剩余的代码而不必知道或担心内存分配细节

6、组合

Go 支持组合, 这是将一个结构包含进另一个结构的行为。在某些语言中,这种行为叫做 特质 或者 混合。 没有明确的组合机制的语言总是可以做到这一点。在 Java 中, 可以使用 继承 来扩展结构。但是在脚本中并没有这种选项, 混合将会被写成如下形式:

public class Person {
  private String name;

  public String getName() {
    return this.name;
  }
}

public class Saiyan {
  // Saiyan 中包含着 person 对象
  private Person person;

  // 将请求转发到 person 中
  public String getName() {
    return this.person.getName();
  }
  ...
}

这可能会非常繁琐。Person 的每个方法都需要在 Saiyan 中重复。Go 避免了这种复杂性:

type Person struct {
  Name string
}

func (p *Person) Introduce() {
  fmt.Printf("Hi, I'm %s\n", p.Name)
}

type Saiyan struct {
  *Person
  Power int
}

// 使用它
goku := &Saiyan{
  Person: &Person{"Goku"},
  Power: 9001,
}
goku.Introduce()

Saiyan 结构体有一个 Person 类型的字段。由于我们没有显式地给它一个字段名,所以我们可以隐式地访问组合类型的字段和函数。然而,Go 编译器确实给了它一个字段,下面这样完全有效:

goku := &Saiyan{
  Person: &Person{"Goku"},
}
fmt.Println(goku.Name)
fmt.Println(goku.Person.Name)

上面两个都打印 「Goku」。

组合比继承更好吗?许多人认为它是一种更好的组织代码的方式。当使用继承的时候,你的类和超类紧密耦合在一起,你最终专注于结构而不是行为。

7、指针 VS 值

当你写 Go 代码的时候,很自然就会去问自己 应该是值还是指向值的指针呢? 这儿有两个好消息,首先,无论我们讨论下面哪一项,答案都是一样的:

  • 局部变量赋值
  • 结构体指针
  • 函数返回值
  • 函数参数
  • 方法接收器

第二,如果你不确定,那就用指针咯。

正如我们已经看到的,传值是一个使数据不可变的好方法(函数中改变它不会反映到调用代码中)。有时,这是你想要的行为,但是通常情况下,不是这样的。

即使你不打算改变数据,也要考虑创建大型结构体副本的成本。相反,你可能有一些小的结构:

type Point struct {
  X int
  Y int
}

这种情况下,复制结构的成本能够通过直接访问 XY 来抵消,而没有其它任何间接操作。

还有,这些案例都是很微妙的,除非你迭代成千上万个这样的指针,否则你不会注意到差异。