在前面的章节里,我们已经学习了 Go 的变量、流程控制、函数、数组、切片、映射等基础内容。从这一节开始,我们正式进入 Go 中非常重要的一块内容:结构体(struct)。
如果你有 Java、C++、Python 等语言的经验,可能会下意识把结构体理解为“类”的替代品。这个理解不能说完全错,但也不够准确。Go 的设计思路与传统面向对象语言不同:它没有经典意义上的 class、没有继承体系,却依然能够写出清晰、可维护、可扩展的程序。
这正是 Go 的一个重要理念:用组合代替继承(Composition over Inheritance)。
这一节我们会围绕以下几个核心知识点展开:
- 结构体定义与初始化
- 结构体嵌套与组合
- 构造函数模式
- 标签(Tag)与序列化基础
学完这一节后,你不仅会写结构体,还会理解 Go 在“面向对象”这件事上的设计哲学。
一、为什么 Go 需要结构体
在真实开发中,很多数据并不是单个值,而是一组彼此相关的数据。
例如,一个学生通常有:
- 姓名
- 年龄
- 班级
- 成绩
如果只用普通变量来表示,会变成这样:
var studentName string = "小明"
var studentAge int = 18
var studentClass string = "高三(1)班"
var studentScore float64 = 92.5
这样写在数据很少时还可以接受,但一旦数据增多,就会很混乱:
- 同一对象的数据分散在多个变量中,不容易管理
- 很难批量传递给函数
- 代码可读性和可维护性较差
这时就需要把这些相关字段组织在一起,而 Go 提供的方式就是 结构体。
二、结构体定义与初始化
1. 什么是结构体
结构体是一种用户自定义类型,它可以把多个不同类型的字段组合成一个整体。
语法如下:
type 结构体名 struct {
字段名 字段类型
字段名 字段类型
}
比如定义一个学生结构体:
type Student struct {
Name string
Age int
Class string
Score float64
}
这里的 Student 就是一个新的类型,它包含了 4 个字段。
2. 结构体的基本初始化方式
Go 中常见的结构体初始化方式有三种:
- 先声明再赋值
- 按字段名初始化
- 按顺序初始化
下面通过一个完整示例来看。
package main
import "fmt"
type Student struct {
Name string
Age int
Class string
Score float64
}
func main() {
// 方式1:先声明,再逐个赋值
var s1 Student
s1.Name = "张三"
s1.Age = 20
s1.Class = "Go入门班"
s1.Score = 95.5
// 方式2:按字段名初始化,推荐使用
s2 := Student{
Name: "李四",
Age: 21,
Class: "Go进阶班",
Score: 88.0,
}
// 方式3:按字段顺序初始化
s3 := Student{"王五", 19, "Go项目班", 91.2}
fmt.Println("s1:", s1)
fmt.Println("s2:", s2)
fmt.Println("s3:", s3)
}
运行结果类似如下:
s1: {张三 20 Go入门班 95.5}
s2: {李四 21 Go进阶班 88}
s3: {王五 19 Go项目班 91.2}
3. 推荐优先使用“按字段名初始化”
虽然按顺序初始化写起来更短,但不够直观,而且字段顺序一旦调整,代码就容易出错。
来看一个对比:
| 初始化方式 | 可读性 | 是否依赖字段顺序 | 是否推荐 |
|---|---|---|---|
| 先声明后赋值 | 较高 | 否 | 适合少量修改 |
| 按字段名初始化 | 很高 | 否 | 推荐 |
| 按顺序初始化 | 一般 | 是 | 不推荐大型项目使用 |
因此,在工程开发中,更推荐这样写:
user := Student{
Name: "赵六",
Age: 22,
Class: "Go实战班",
Score: 93.8,
}
4. 访问和修改结构体字段
结构体字段通过 . 来访问。
package main
import "fmt"
type Book struct {
Title string
Author string
Price float64
}
func main() {
book := Book{
Title: "Go语言实战",
Author: "某作者",
Price: 59.9,
}
fmt.Println("书名:", book.Title)
fmt.Println("作者:", book.Author)
fmt.Println("价格:", book.Price)
book.Price = 49.9
fmt.Println("打折后价格:", book.Price)
}
5. 结构体是值类型
Go 中结构体默认是值类型。这意味着:
- 把一个结构体赋值给另一个变量时,会复制一份数据
- 修改副本不会影响原对象
package main
import "fmt"
type User struct {
Name string
Age int
}
func main() {
u1 := User{Name: "小王", Age: 25}
u2 := u1
u2.Name = "小李"
fmt.Println("u1:", u1)
fmt.Println("u2:", u2)
}
输出结果:
u1: {小王 25}
u2: {小李 25}
这说明 u2 修改后,u1 并没有变化。
如果你希望多个地方操作同一份数据,就需要使用指针,这部分我们会在后续章节继续深入。
三、Go 的“面向对象”到底是什么
很多初学者第一次接触 Go 时会问:
没有 class,Go 还能算面向对象吗?
答案是:可以,但方式不同。
传统面向对象语言通常依赖三大特性:
- 封装
- 继承
- 多态
Go 对这些能力并不是完全不要,而是采用了更轻量、更直接的实现方式:
- 用结构体 + 方法表达对象
- 用组合代替继承
- 用接口实现多态
本节重点先理解前两点。
换句话说,Go 不是“没有面向对象”,而是不鼓励复杂的面向对象层级设计。
它更关注:
- 代码是否简单
- 依赖关系是否清晰
- 组合是否灵活
- 修改是否低成本
这也是 Go 非常适合工程开发的重要原因。
四、结构体嵌套与组合(Composition over Inheritance)
1. 什么是结构体嵌套
结构体的字段本身也可以是另一个结构体,这就叫结构体嵌套。
例如,一个人有地址信息,地址本身也可以定义成结构体:
package main
import "fmt"
type Address struct {
City string
District string
}
type Person struct {
Name string
Age int
Address Address
}
func main() {
p := Person{
Name: "小张",
Age: 28,
Address: Address{
City: "北京",
District: "海淀区",
},
}
fmt.Println("姓名:", p.Name)
fmt.Println("年龄:", p.Age)
fmt.Println("城市:", p.Address.City)
fmt.Println("区:", p.Address.District)
}
结构体嵌套的好处是:
- 数据层次更清晰
- 模块边界更明确
- 可以复用已有结构体定义
2. 匿名嵌套与字段提升
Go 还支持把一个结构体类型直接嵌入另一个结构体中,而不写字段名。这叫匿名字段或嵌入字段。
package main
import "fmt"
type Address struct {
City string
District string
}
type Employee struct {
Name string
Address
}
func main() {
e := Employee{
Name: "小刘",
Address: Address{
City: "上海",
District: "浦东新区",
},
}
fmt.Println("姓名:", e.Name)
fmt.Println("城市:", e.City)
fmt.Println("区:", e.District)
}
这里 Employee 中嵌入了 Address,因此 e.City 实际上等价于 e.Address.City。
这被称为字段提升。它让组合后的使用体验更加自然。
3. 为什么 Go 推崇组合而不是继承
在很多传统面向对象语言里,我们经常会这样设计:
- 动物是父类
- 狗继承动物
- 导盲犬继承狗
- 某种特殊导盲犬再继续继承……
看起来层次清晰,但实际项目里很容易出现问题:
- 继承层级越来越深,理解成本越来越高
- 父类变动影响面很大,修改风险高
- 子类会继承很多自己并不真正需要的能力
- 关系一旦设计错了,后续很难调整
Go 选择了另一条路线:
不强调“是什么”的继承关系,而强调“由哪些能力组合而成”。
例如,与其说“汽车继承交通工具”,不如说:
- 汽车有引擎
- 汽车有轮子
- 汽车有行驶能力
这样的设计更贴近现实,也更灵活。
4. 用组合表达能力
下面我们用一个完整例子来体会 Go 的组合思想。
package main
import "fmt"
type Engine struct {
Brand string
Power int
}
func (e Engine) Start() {
fmt.Printf("%s 引擎启动,马力:%d\n", e.Brand, e.Power)
}
type Car struct {
Model string
Engine
}
func main() {
car := Car{
Model: "家用轿车",
Engine: Engine{
Brand: "BYD",
Power: 150,
},
}
fmt.Println("车型:", car.Model)
car.Start()
}
这个例子里:
Car没有继承EngineCar只是组合了一个 Engine- 但由于嵌入字段的存在,
car.Start()可以直接调用
这就是 Go 很典型的风格:通过组合复用能力,而不是通过继承绑定层级关系。
5. 组合的优势总结
| 对比项 | 继承 | 组合 |
|---|---|---|
| 关系表达 | 强耦合,层级固定 | 灵活组合,按需拼装 |
| 复用方式 | 继承父类全部能力 | 只组合需要的能力 |
| 修改影响 | 父类修改可能波及全部子类 | 局部改动影响更可控 |
| 代码理解 | 层级深时复杂 | 结构通常更直观 |
| 工程适应性 | 容易过度设计 | 更适合演进式开发 |
对于 Go 来说,组合并不是“退而求其次”的方案,而是一种有明确价值取向的设计哲学。
它的优势主要体现在三点:
- 低耦合:各部分职责清晰,不容易互相牵连
- 高灵活:不同结构可以组合出不同能力
- 易维护:后期重构成本更低,更适合真实项目迭代
这也是为什么 Go 社区经常强调一句话:
Composition over Inheritance。
五、方法:让结构体拥有行为
仅有数据还不够。一个“对象”通常既有数据,也有行为。
在 Go 中,结构体可以绑定方法。方法本质上就是带接收者的函数。
package main
import "fmt"
type Rectangle struct {
Width float64
Height float64
}
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
func (r Rectangle) Perimeter() float64 {
return 2 * (r.Width + r.Height)
}
func main() {
rect := Rectangle{
Width: 5,
Height: 3,
}
fmt.Println("面积:", rect.Area())
fmt.Println("周长:", rect.Perimeter())
}
这里:
Area()是Rectangle的方法Perimeter()也是Rectangle的方法
这样写以后,结构体就不再只是“数据袋子”,而是同时具备了与数据相关的操作。
这就是 Go 组织“对象行为”的方式。
六、构造函数模式
1. Go 没有构造函数语法
在 Java、C++ 中,类通常有专门的构造函数语法。但 Go 没有内建的 constructor 关键字。
不过在实际开发中,Go 有一套非常常见的替代方案:
- 定义一个普通函数
- 函数名通常以
New开头 - 在函数中完成对象初始化
- 返回结构体或结构体指针
这就叫 构造函数模式。
2. 为什么需要构造函数模式
看起来直接写结构体字面量就能创建对象,那为什么还需要 NewXXX 呢?
因为构造函数模式可以帮助我们:
- 统一初始化逻辑
- 隐藏内部实现细节
- 保证对象创建时处于合法状态
- 便于未来扩展
例如,某个用户对象要求年龄不能小于 0,注册时间要自动赋值,这种逻辑就适合放到构造函数里。
3. 基础示例:New 函数
package main
import "fmt"
type User struct {
Name string
Age int
}
func NewUser(name string, age int) User {
if age < 0 {
age = 0
}
return User{
Name: name,
Age: age,
}
}
func main() {
user := NewUser("小陈", -5)
fmt.Println(user)
}
输出结果:
{小陈 0}
这里 NewUser 的作用就是保证 User 在创建时符合基本规则。
4. 返回值还是返回指针?
Go 中构造函数既可以返回值,也可以返回指针。两者都合法,但使用场景略有区别。
| 返回方式 | 示例 | 适用场景 |
|---|---|---|
| 返回值 | func NewUser(...) User |
结构体较小、拷贝成本低、语义简单 |
| 返回指针 | func NewUser(...) *User |
结构体较大、需要共享修改、常用于工程代码 |
在实际项目中,很多 NewXXX 都会返回指针,因为这样可以:
- 避免不必要的拷贝
- 更方便后续修改字段
- 与方法的指针接收者配合更自然
5. 返回指针的完整示例
package main
import "fmt"
type Counter struct {
Value int
}
func NewCounter() *Counter {
return &Counter{Value: 0}
}
func (c *Counter) Add() {
c.Value++
}
func main() {
counter := NewCounter()
counter.Add()
counter.Add()
fmt.Println("当前计数:", counter.Value)
}
这里如果不用指针,而是使用值传递,修改可能只发生在副本上。返回指针后,就能更明确地表示:
这是一个会被持续修改的对象。
6. 构造函数模式的工程意义
对于初学者来说,NewXXX 似乎只是写法上的习惯;但在项目中,它其实非常重要。
例如下面这些事情,都适合放在构造函数中:
- 设置默认值
- 检查参数是否合法
- 初始化内部依赖
- 创建 map、slice、channel 等字段
- 保证对象处于可用状态
看一个更完整的例子:
package main
import "fmt"
type Config struct {
Host string
Port int
}
func NewConfig(host string, port int) *Config {
if host == "" {
host = "127.0.0.1"
}
if port <= 0 {
port = 8080
}
return &Config{
Host: host,
Port: port,
}
}
func main() {
config := NewConfig("", 0)
fmt.Printf("服务启动地址:http://%s:%d\n", config.Host, config.Port)
}
这类模式能让对象在“出生时”就更可靠,调用者也不必每次手动设置默认值。
七、标签(Tag)与序列化基础
1. 什么是结构体标签
Go 的结构体字段后面可以写一段反引号包裹的元信息,这就是标签(Tag)。
例如:
type User struct {
Name string `json:"name"`
Age int `json:"age"`
}
标签本身不会直接影响结构体字段的存储,但很多标准库和第三方库会读取这些标签,用来完成:
- JSON 序列化与反序列化
- 数据库字段映射
- 表单参数绑定
- 校验规则定义
也就是说,Tag 是结构体和外部世界沟通的一种“说明书”。
2. 为什么序列化需要 Tag
假设我们要把结构体转换成 JSON,如果没有标签,默认会使用字段名作为键。
但 Go 字段名通常是大写开头的导出字段,例如 Name、Age,而 JSON 更常见的风格往往是小写:name、age。
这时 Tag 就能发挥作用。
3. JSON 序列化示例
下面是一个完整可运行示例,演示如何把结构体转换成 JSON。
package main
import (
"encoding/json"
"fmt"
)
type User struct {
Name string `json:"name"`
Age int `json:"age"`
Email string `json:"email"`
}
func main() {
user := User{
Name: "小林",
Age: 24,
Email: "xiaolin@example.com",
}
data, err := json.Marshal(user)
if err != nil {
fmt.Println("序列化失败:", err)
return
}
fmt.Println(string(data))
}
输出结果:
{"name":"小林","age":24,"email":"xiaolin@example.com"}
这里有两个关键点:
- 字段必须是可导出的,也就是首字母大写
json:"name"指定了 JSON 中对应的键名
如果字段首字母小写,即使写了 Tag,JSON 包通常也无法访问到它。
4. JSON 反序列化示例
序列化是把结构体转成 JSON,反序列化则是把 JSON 解析回结构体。
package main
import (
"encoding/json"
"fmt"
)
type Product struct {
ID int `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
}
func main() {
jsonStr := `{"id":101,"name":"机械键盘","price":299.5}`
var product Product
err := json.Unmarshal([]byte(jsonStr), &product)
if err != nil {
fmt.Println("反序列化失败:", err)
return
}
fmt.Printf("商品编号:%d\n", product.ID)
fmt.Printf("商品名称:%s\n", product.Name)
fmt.Printf("商品价格:%.2f\n", product.Price)
}
这里要注意:
json.Unmarshal需要传入结构体指针- 否则无法把解析结果写回变量
5. 常见 Tag 用法
JSON Tag 不只是改字段名,还可以控制一些序列化行为。
| Tag 写法 | 含义 |
|---|---|
json:"name" |
指定 JSON 字段名为 name |
json:"name,omitempty" |
当字段为零值时忽略该字段 |
json:"-" |
该字段不参与 JSON 序列化 |
下面看一个完整示例:
package main
import (
"encoding/json"
"fmt"
)
type Account struct {
Username string `json:"username"`
Password string `json:"-"`
Nickname string `json:"nickname,omitempty"`
}
func main() {
account := Account{
Username: "admin",
Password: "123456",
Nickname: "",
}
data, err := json.Marshal(account)
if err != nil {
fmt.Println("序列化失败:", err)
return
}
fmt.Println(string(data))
}
输出结果:
{"username":"admin"}
解释如下:
Password被json:"-"忽略了Nickname因为空字符串,属于零值,因此被omitempty忽略
这在接口开发中非常常见。
6. Tag 的本质理解
初学者容易把 Tag 理解成“语法装饰”,其实它的价值远不止如此。
你可以把它理解为:
给结构体字段附加额外描述信息,让其他工具或库知道该如何处理这个字段。
也正因为如此,结构体 + Tag 是 Go 中数据建模的基础组合之一。
八、一个综合示例:结构体、组合、构造函数、Tag 一起使用
为了把今天的内容串起来,我们看一个稍微完整一点的例子。
这个例子模拟“用户资料”场景,包含:
- 结构体定义
- 嵌套组合
- 构造函数模式
- JSON Tag
package main
import (
"encoding/json"
"fmt"
)
type Profile struct {
City string `json:"city"`
Hobby string `json:"hobby"`
}
type User struct {
Name string `json:"name"`
Age int `json:"age"`
Email string `json:"email"`
Profile Profile `json:"profile"`
}
func NewUser(name string, age int, email string, city string, hobby string) *User {
if age < 0 {
age = 0
}
return &User{
Name: name,
Age: age,
Email: email,
Profile: Profile{
City: city,
Hobby: hobby,
},
}
}
func main() {
user := NewUser("小周", 26, "xiaozhou@example.com", "杭州", "摄影")
data, err := json.MarshalIndent(user, "", " ")
if err != nil {
fmt.Println("序列化失败:", err)
return
}
fmt.Println(string(data))
}
输出结果类似如下:
{
"name": "小周",
"age": 26,
"email": "xiaozhou@example.com",
"profile": {
"city": "杭州",
"hobby": "摄影"
}
}
这个例子已经很接近真实项目中的数据模型写法了。
九、本节总结
这一节我们正式进入了 Go 中最重要的数据组织方式:结构体。
请你重点掌握以下几点:
- 结构体用于组织相关数据,是 Go 中自定义类型的重要方式
- 结构体可以初始化、复制、传值和修改字段
- Go 没有传统的类继承体系,但依然支持“面向对象式”的组织方式
- 组合优于继承 是 Go 的核心设计哲学之一
NewXXX是 Go 中非常常见的构造函数模式- Tag 是结构体与 JSON 等外部格式协作的重要机制
尤其要理解的是:
Go 并不是做不到继承,而是有意减少复杂层级带来的设计负担。它鼓励开发者把关注点放在“能力组合”和“职责拆分”上,而不是构建庞大的继承树。
这种设计的实际优势在工程里非常明显:
- 更容易维护
- 更容易扩展
- 更容易重构
- 更不容易被历史设计绑死
对于刚学 Go 的同学来说,一开始可能会觉得“怎么和我以前学的面向对象不一样”。但当你写的代码越来越多,就会逐渐体会到这种风格的简洁与实用。
下一步,你可以继续思考这样一个问题:
结构体有了数据和方法之后,多个不同类型如果想表现出同一种行为,该怎么做?
这就会自然引出 Go 中另一项非常重要的能力:接口(interface)。
📝 版权声明:本文为原创技术博客,转载请注明出处。
如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!