struct(结构体)也是一种聚合的数据类型,struct可以包含多个任意类型的值,这些值被称为struct的字段。用来演示struct的一个经典案例就是雇员信息,每条雇员信息包含:员工编号,姓名,住址,出生日期,工作岗位,薪资,直属领导等。每个雇员的所有信息都可以存在一个struct中,该struct可以作为变量,或者作为函数的参数、返回值,或者被存到数组、切片中,等等。
下面声明了一个Employee类型的结构体,还声明了一个Employee类型的变量dilbert:
type Employee struct {
ID int
Name string
Address string
DoB time.Time
Position string
Salary int
ManagerID int
}
var dilbert Employee
dilbert的struct字段可以通过点操作符来访问,例如dilbert.Name和dilbert.Dob。同时dilbert的字段是变量,因此可以直接对成员赋值:
dilbert.Salary -= 5000 // demoted, for writing too few lines of code
也可以对成员进行取址,然后通过指针访问:
position := &dilbert.Position
*position = "Senior " + *position // promoted, for outsourcing to Elbonia
点操作符还可以应用在struct指针上:
var employeeOfTheMonth *Employee = &dilbert
employeeOfTheMonth.Position += " (proactive team player)"
上面的第二条语句相当于:
(*employeeOfTheMonth).Position += " (proactive team player)"
EmployeeByID函数根据给定的员工编号返回一个指针指向包含员工信息的struct,可以使用点操作符来访问里面的字段:
func EmployeeByID(id int) *Employee { /* ... */ }
fmt.Println(EmployeeByID(dilbert.ManagerID).Position) // "Pointy-haired boss"
id := dilbert.ID
EmployeeByID(id).Salary = 0 // fired for... no real reason
最后那条语句要注意,它调用EmployeeByID生成了一个*Employee指针,然后直接更新该结构体中的一个字段。如果将EmployeeByID的返回值从*Employee换成Employee类型,那么编译将报错,因为编译器无法对返回的Employee进行寻址(不通过变量直接使用一个值,一般都无法进行寻址)。
struct声明时,通常是一行写一个字段,字段名在类型之前,不过如果两个字段类型相同,那么可以合并到一行,例如Name和Address:
type Employee struct {
ID int
Name, Address string
DoB time.Time
Position string
Salary int
ManagerID int
}
对于struct类型来说,字段的先后顺序是非常关键的。如果两个struct类型包含了完全相同的字段,但是排列顺序不同或者进行了部分合并,那么这两个struct就是不同的类型!
如果struct字段是大写字母开头,那么该字段就是导出的(包外可见),这也符合Go语言的可见性规则。因此一个struct可以同时包含导出和未导出的变量。
我们可以使用匿名struct,特别是在一个struct只是临时使用时,例如:
var user = struct{
id int
name string
} {
id : 1,
name: "corego",
}
但是对于常用的struct类型来说,这样写就会很麻烦,因此应该使用具名struct,例如之前的Employee。一个结构体S不能再包含S类型的字段,因为聚合类型的值不能包含它自身(数组也是一样)。但是S可以包含*S类型的字段,利用这个特性,我们可以创建链表、树这样的递归数据结构。下面的代码使用了二叉树来实现插入排序:
gopl.io/ch4/treesort
type tree struct {
value int
left, right *tree
}
// Sort sorts values in place.
func Sort(values []int) {
var root *tree
for _, v := range values {
root = add(root, v)
}
appendValues(values[:0], root)
}
// appendValues appends the elements of t to values in order
// and returns the resulting slice.
func appendValues(values []int, t *tree) []int {
if t != nil {
values = appendValues(values, t.left)
values = append(values, t.value)
values = appendValues(values, t.right)
}
return values
}
func add(t *tree, value int) *tree {
if t == nil {
// Equivalent to return &tree{value: value}.
t = new(tree)
t.value = value
return t
}
if value < t.value {
t.left = add(t.left, value)
} else {
t.right = add(t.right, value)
}
return t
}
一个struct的零值意味着它的每个成员都是零值,因此struct初始化为零值是最理想的默认情况。例如,对于bytes.Buffer,它的零值就是一个空缓存,sync.Mutex的零值是未使用的互斥锁。零值在大多数情况下是可以直接使用的,但是有些时候需要一些额外的工作。
没有任何字段的struct就是空结构体,写作struct{}, 它不包含任何信息,大小也为0,在有些场景下这种空struct是有价值的。有些Go程序员在用map实现set时,用空struct来代替bool值,因为他们觉得这样可以强调key的重要性。虽然这样可以节约一点空间,但是带来了更高的代码复杂度和更低的可读性,因此我们应该避免这样使用:
seen := make(map[string]struct{}) // set of strings
// ...
if _, ok := seen[s]; !ok {
seen[s] = struct{}{}
// ...first time seeing s...
}
4.4.1. struct字面值
struct的值可以用struct字面值来表示:
type Point struct{ X, Y int }
p := Point{1, 2}
struct字面值有两种形式,上面的代码是第一种写法:按照声明时字段的顺序依次赋值。这样写法要求写代码、读代码时要记住struct的每个字段和顺序,如果后面需要对struct进行改动,那所有的初始化代码都会出错。因此这种写法一般只在临时的或者较小的struct中使用,而且这些struct的字段是很有规律的,例如image.Point{x,y},color.RGBA{red,green,blue,alpha}。
实际实践中,第二种写法会更通用,使用字段名和对应的值来初始化,既可以初始化部分字段也可以初始化全部字段:
anim := gif.GIF{LoopCount: nframes}
这种写法中,如果某个字段被省略,那么该字段会初始化为相应的零值;由于提供了字段名,字段初始化的顺序也不重要。
注意,这两种写法是不能混用的,在使用第一种写法时,试图通过忽略字段名的形式来隐式初始化未导出的字段是不可行的。
package p
type T struct{ a, b int } // a and b are not exported
package q
import "p"
var _ = p.T{a: 1, b: 2} // compile error: can't reference a, b
var _ = p.T{1, 2} // compile error: can't reference a, b
上面报错的代码中,虽然没有显式的使用未导出的字段,但是这样隐式使用的行为也是不允许的。
struct可以作函数的参数和返回值,例如Scale函数将Point进行按比例缩放后再返回:
func Scale(p Point, factor int) Point {
return Point{p.X * factor, p.Y * factor}
}
fmt.Println(Scale(Point{1, 2}, 5)) // "{5 10}"
出于效率方面的考虑,当使用较大的struct时,常常需要使用指针(这里要注意,指针参与gc,值不参与gc且函数调用完就会销毁,因此,对于小的struct,可能使用值会更好):
func Bonus(e *Employee, percent int) int {
return e.Salary * percent / 100
}
如果函数需要修改传入的struct参数,那么就必须使用指针。在Go语言中,所有的函数传参都是通过值拷贝实现的,因此传入的参数不再是之前的变量。
func AwardAnnualRaise(e *Employee) {
e.Salary = e.Salary * 105 / 100
}
通常来说struct都是作为指针来使用,因此可以使用短声明的方法来初始化一个struct并获取它的地址:
pp := &Point{1, 2}
和下面的语句是等价的
pp := new(Point)
*pp = Point{1, 2}
不过&Point{1, 2}可以直接在表达式中使用,例如一个函数调用。
4.4.2. struct的比较
如果struct的所有字段都可以比较,那么该struct也可以通过==或!=进行比较。相等比较时,两个struct的每个字段都会进行比较,因此下面两个表达式是等价的:
type Point struct{ X, Y int }
p := Point{1, 2}
q := Point{2, 1}
fmt.Println(p.X == q.X && p.Y == q.Y) // "false"
fmt.Println(p == q) // "false"
可以比较的struct类型就像其它可比较类型一样,可以作为map的key类型:
type address struct {
hostname string
port int
}
hits := make(map[address]int)
hits[address{"golang.org", 443}]++
4.4.3. 嵌入和匿名字段
下面我们将学习如何使用Go语言的嵌入机制,一个具名struct A是另一个具名structB的匿名字段,那么就说A是B的嵌入字段。这样,就可以通过点操作符x.f来访问匿名字段链中的x.d.e.f。
考虑一个二维绘图程序,提供一个各种图形的库,例如矩形、椭圆形、星形等几何形状:
type Circle struct {
X, Y, Radius int
}
type Wheel struct {
X, Y, Radius, Spokes int
}
Circle代表的是圆形,包含了圆心X、Y坐标及Radius半径。Wheel轮形除了包含Cirecle的所有字段之外还增加了Spokers:
var w Wheel
w.X = 8
w.Y = 8
w.Radius = 5
w.Spokes = 20
随着库中几何图形的增多,我们一定会注意到这些图形间的相似和重复之处,所以可以将相同的属性提取出来:
type Point struct {
X, Y int
}
type Circle struct {
Center Point
Radius int
}
type Wheel struct {
Circle Circle
Spokes int
}
这样改动后库的设计更清晰了,但是会导致更冗余的访问方式:
var w Wheel
w.Circle.Center.X = 8
w.Circle.Center.Y = 8
w.Circle.Radius = 5
w.Spokes = 20
Go语言可以让我们在声明struct时无需指定字段名,只要提供字段类型即可,这种就是匿名字段。匿名字段的数据类型必须是一个具名类型或者指向具名类型的指针。下面代码中,Circle和Wheel各有一个匿名字段,这里Point类型被嵌入到Circle中,同时 Circle被嵌入到Wheel中。
type Circle struct {
Point
Radius int
}
type Wheel struct {
Circle
Spokes int
}
得益于嵌入的特性,我们可以直接访问嵌套树的任意一个叶子节点,而不需要使用完整路径:
var w Wheel
w.X = 8 // equivalent to w.Circle.Point.X = 8
w.Y = 8 // equivalent to w.Circle.Point.Y = 8
w.Radius = 5 // equivalent to w.Circle.Radius = 5
w.Spokes = 20
上面的注释中,给出了显式访问这些叶子的语法,因此匿名字段依然是可以通过类型名来访问的。这里匿名字段Circle和Point都有自己的名字,但是这些名字在点操作符中是可选的。综上所述,在访问叶子字段时,可以忽略任何的匿名字段:w.X,忽略了Cirecle和Point两个匿名字段。
struct字面值初始化时,不能用短声明形式来初始化struct中的匿名字段,因此下面的语句是无法编译通过的:
w = Wheel{8, 8, 5, 20} // compile error: unknown fields
w = Wheel{X: 8, Y: 8, Radius: 5, Spokes: 20} // compile error: unknown fields
可以采用下面两种声明语法,它们是等价的:
gopl.io/ch4/embed
w = Wheel{Circle{Point{8, 8}, 5}, 20}
w = Wheel{
Circle: Circle{
Point: Point{X: 8, Y: 8},
Radius: 5,
},
Spokes: 20, // NOTE: trailing comma necessary here (and at Radius)
}
fmt.Printf("%#v\n", w)
// Output:
// Wheel{Circle:Circle{Point:Point{X:8, Y:8}, Radius:5}, Spokes:20}
w.X = 42
fmt.Printf("%#v\n", w)
// Output:
// Wheel{Circle:Circle{Point:Point{X:42, Y:8}, Radius:5}, Spokes:20}
注意上面Printf函数中的%#v参数,#代表用Go的语法来打印。对于struct类型来说,打印中将包含每个字段的信息(强烈建议读者亲自试验这个#)。
因为匿名字段也有隐式的名字,因此不能同时包含两个相同的匿名字段,会导致名字冲突。由于字段名是隐式的决定的,所以匿名字段的可见性也是隐式决定的。在上面例子中,Point和Circle两个匿名字段都是导出的,然而,即使它们不导出(比如point和circle),我们依然可以访问这两个匿名字段中的字段:
w.X = 8 // equivalent to w.circle.point.X = 8
但是在circle所在的包之外,上面这条语句评论中的长赋值形式是不允许的,因为circle是未导出的!
可以看出点操作符只是一种访问匿名字段的语法糖,在后面我们会看到匿名字段并不仅仅是struct类型,任何具名类型都可以作为匿名字段。但是为什么要嵌入一个非struct(没有任何字段)的匿名类型呢?
答案就是method(方法)。点操作符不仅可以选择匿名字段的子字段,也可以访问它们的方法。实际上最外层的struct不仅仅获得了匿名字段的所有子字段,还获得了它们的全部导出的方法。我们可以利用这个机制实现Go语言中面向对象编程的核心概念:组合,将在第五章中专门讨论。
文章所有权:Golang隐修会 联系人:孙飞,CTO@188.com!