struct:数据就是数据
struct 是 Go 组织数据的核心,你会天天写。它和 Kotlin 的 data class 第一眼很像——都是一组带名字的字段。但它删掉了 data class 帮你做的一切魔法:没有自动生成的 equals / hashCode / copy / toString,方法不写在 struct 里面,字段可见性靠大小写。这一章讲清 struct 的用法,以及它「只是一块紧凑内存」的本质。
struct:一组字段,仅此而已
type User struct {
ID int
Name string
Email string
admin bool // 小写开头 = 包私有字段
}
这就是一个 struct。它把几个字段打包成一个类型,没有别的 —— 没有方法体、没有构造函数、没有继承、没有属性访问器。它就是「一块贴了字段名的内存」。字段的可见性还是老规矩:大写导出(ID、Name 包外可读写),小写包私有(admin 只有本包能碰)。
创建实例,用复合字面量,两种写法:
// 具名字段(推荐):写你在意的,没写的自动零值
u1 := User{Name: "harry", Email: "h@x.com"}
// u1.ID 是 0、u1.admin 是 false —— 又见零值兜底
// 按顺序(少用):必须列全所有字段,顺序不能错
u2 := User{7, "harry", "h@x.com", false}
// 只要指针,直接 & 一个字面量
u3 := &User{Name: "guest"} // u3 是 *User
几乎永远用具名字段那种。按顺序那种一旦 struct 加个字段就全乱套,而且读的人得记住字段顺序 —— 又是可读性问题。&User{...} 直接得到指针,是 Go 里很常见的写法,比先声明再取地址省事。
方法:写在 struct 外面
这是和 OOP 语言最直观的不同:Go 的方法不写在类型定义里面。你在 struct 外面单独定义函数,用一个「接收者」把它挂到类型上:
type User struct {
Name string
}
// 这是 User 的一个方法。(u User) 是接收者,等于「this」
func (u User) Greet() string {
return "hi, " + u.Name
}
// 调用起来和你熟的一样
u := User{Name: "harry"}
fmt.Println(u.Greet())
那个 (u User) 就是接收者,相当于别的语言的 this(Go 不用 this,你自己给它起名,惯例是类型首字母的小写)。方法和字段在物理上是分开写的 —— struct 定义只管数据,方法散落在(同一个包的)别处。这一点第 8 章会展开讲,它是理解 Go 方法与接口的关键起点。这里你先接受这个形状:struct 是纯数据,方法是外挂。
OOP 的信条是「把数据和操作数据的方法封装在一起」—— class 里既有字段又有方法。Go 故意不这么绑:struct 只有数据,方法定义在外面,靠接收者松散地关联。
这带来一个后果:Go 里没有「一个大而全的类」那种东西。数据是数据,行为是行为,接口(第 9 章)描述行为而不关心数据。这种「解绑」正是 Go 能做到「组合优于继承」的地基 —— 你没法继承一个 struct,但你能把行为自由地组装上去。
data class 帮你做的,Go 都不帮
Kotlin 的 data class User(val name: String) 一行,编译器悄悄帮你生成了 equals()、hashCode()、toString()、copy()、componentN()。这是 Kotlin 的贴心。Go 的 struct 什么都不帮你生成。你想要,自己写或者用别的机制:
| Kotlin data class 自动给 | Go 里怎么办 |
|---|---|
toString() | fmt.Printf("%+v", u) 直接打印字段,或自己写 String() 方法 |
equals() | 可比较的 struct 直接用 ==(见下),否则手写 |
copy() | 直接赋值就是复制(上一章的值语义):u2 := u1 |
hashCode() | 可比较的 struct 能直接当 map 的 key |
有意思的是,Go 的值语义反而让其中两样变得免费:因为 struct 是值,u2 := u1 天然就是一份完整复制(Kotlin 的 copy());而只要字段都是可比较的类型,两个 struct 可以直接 == 逐字段比(Kotlin 的 equals()):
a := User{Name: "harry"}
b := User{Name: "harry"}
fmt.Println(a == b) // true!逐字段相等
c := a // 一份独立复制
c.Name = "改了"
fmt.Println(a.Name) // 还是 "harry"
== 只对「可比较」的 struct 有效 —— 字段全是数字、字符串、bool、指针、数组、或其它可比较 struct 时。一旦 struct 里有切片、map 或函数字段,它就不可比较了,写 a == b 直接编译错误。这时要比较得用 reflect.DeepEqual(a, b)(慢,多见于测试),或者自己手写比较逻辑。这个限制的根源,正是切片和 map 的「引用」性质 —— 下一章会讲清楚。
tag:挂在字段上的元数据
字段后面可以跟一串反引号包起来的标签(tag),最常见的用途是告诉 JSON 库怎么序列化:
type User struct {
Name string `json:"name"`
Email string `json:"email,omitempty"`
admin bool `json:"-"` // - 表示不参与 JSON
}
这些 tag 在你把 struct 编码成 JSON 时被 encoding/json 读取:Name 序列化成 "name",Email 为空时省略,admin 干脆不导出。它有点像 Java 的注解,但简陋得多 —— tag 就是一段字符串,靠反射在运行时读,没有类型检查。这是 Go 处理「结构化元数据」的方式:一个字符串约定,而不是一套注解处理系统(对照 Kotlin 的 @SerializedName + KSP,第 1 章账本里「KSP 换成代码生成」说的就是这类事)。
struct 在内存里是紧凑的一块
最后一个心智模型,很重要:一个 struct 值在内存里就是它的字段挨个排在一起的一整块连续内存(可能有对齐填充)。User{ID, Name, Email} 就是三个字段紧挨着。没有对象头、没有额外的间接层。
这就是为什么「struct 是值、复制是复制整块」那么自然 —— 它本来就是一块内存,复制它就是把这块内存拷一份。也是为什么 Go 程序常常比 JVM 省内存、对缓存友好:一个 []User 切片是一整排 User 挨着放的连续内存,而 Java 的 List<User> 是一排指向散落各处对象的指针。Go 让你能贴着内存去想问题,这在你写高性能代码时会变成实打实的优势。
Kotlin 对照
Kotlin 的 data class 是「加法」的典范:一个关键字,换来一大堆自动生成的方法,写着极爽。代价藏在看不见的地方 —— 那些 equals / hashCode 是编译器生成的,你得知道它们存在、知道它们怎么处理 var 字段和继承,出问题时得掀开生成的字节码看。
Go 的 struct 是「减法」的典范:它就是一组字段,没有任何隐藏行为。想要 toString 就自己写个 String(),想比较就看看能不能 ==。啰嗦,但没有任何东西是编译器背着你干的 —— 你看到的就是全部。
迁移时的两个惊喜:一是 copy() 免费了(值语义),二是简单 struct 的 == 和当 map key 也免费了。一个失落:再没有一行 data class 打天下的爽快,序列化那套要靠 tag + 标准库,比 Kotlin 啰嗦。总体上,你用「多写几行样板」换来了「没有任何隐藏魔法」——又是那笔一以贯之的交易。
这一章的一句话
Go 的 struct 是纯数据:没有 data class 的自动生成、方法定义在类型之外、可见性靠大小写、元数据靠 tag 字符串;但值语义让「复制」和简单 struct 的「== 比较、当 map key」变成了免费午餐,而它在内存里就是紧凑的一整块。
这一章两次提到「含切片 / map 的 struct 不一样」。下一章我们就把切片和 map 这两个 Go 最常用、也最容易埋坑的类型彻底拆开 —— 包括那个几乎人人踩过一次的 append 别名坑。