结构体与方法:Go 的"面向对象"之路
到目前为止,我们已经学了 Go 的基本数据类型、切片、map、指针。但如果你要描述一个更复杂的事物——比如一个"用户",他有姓名、年龄、邮箱、注册时间等多个属性——用基本类型就不够用了。
你当然可以用多个独立的变量来描述:
name := "张三"
age := 25
email := "zhangsan@example.com"
但这很松散。这些变量之间没有什么关联,别人看了也不知道它们属于同一个"用户"。你需要的是一种方式,把这些相关的数据打包在一起,形成一个有机的整体。
这就是结构体(struct)的用途。
在 Go 语言中,结构体是面向对象编程的基础。但 Go 的面向对象和 Java、C++ 那种传统面向对象很不一样——Go 没有类(class)和继承(inheritance)。取而代之的是结构体(struct)+ 方法(method)+ 接口(interface)的组合。
这种设计看似简陋,实际上非常精妙。今天我们就来一探究竟。
结构体的定义
基本语法
结构体用 type 关键字定义:
type User struct {
Name string
Age int
Email string
CreatedAt time.Time
}
这定义了一个名为 User 的结构体类型,包含 4 个字段。每个字段都有名字和类型。
注意字段名的首字母大写——这意味着它们是导出的(exported),可以被其他包访问。如果首字母小写,就是未导出的(unexported),只能在定义它的包内访问。
创建结构体实例
有好几种方式可以创建结构体:
// 方式 1:声明后逐字段赋值
var u1 User
u1.Name = "张三"
u1.Age = 25
u1.Email = "zhangsan@example.com"
u1.CreatedAt = time.Now()
// 方式 2:字面量(推荐)
u2 := User{
Name: "李四",
Age: 30,
Email: "lisi@example.com",
CreatedAt: time.Now(),
}
// 方式 3:部分字段初始化(未指定的字段使用零值)
u3 := User{
Name: "王五",
Age: 28,
}
// 方式 4:按位置初始化(不推荐,容易出错)
u4 := User{"赵六", 22, "zhaoliu@example.com", time.Now()}
// 方式 5:创建指针
u5 := &User{
Name: "孙七",
Age: 35,
}
💡 小贴士:推荐使用方式 2(带字段名的字面量),因为它最清晰,而且即使以后结构体增加了新字段,代码也不需要修改。方式 4 按位置初始化很脆弱——一旦字段顺序变了,你的代码就错了。
访问和修改字段
用 . 操作符访问和修改结构体的字段:
u := User{Name: "张三", Age: 25}
// 读取
fmt.Println(u.Name) // 张三
fmt.Println(u.Age) // 25
// 修改
u.Age = 26
u.Email = "new@example.com"
如果结构体是指针,Go 会自动解引用,你不需要显式地写 *:
u := &User{Name: "张三", Age: 25}
// 这两种写法等价
fmt.Println((*u).Name) // 显式解引用
fmt.Println(u.Name) // 自动解引用(推荐)
匿名字段和嵌入
Go 支持一种有趣的特性——匿名字段(anonymous fields),也叫嵌入(embedding)。你可以在一个结构体中嵌入另一个结构体,不需要给字段起名字:
type Address struct {
City string
Province string
ZipCode string
}
type User struct {
Name string
Age int
Address // 嵌入 Address 结构体(匿名字段)
}
嵌入后,内部结构体的字段可以直接通过外部结构体访问,就像它们是外部结构体自己的字段一样:
u := User{
Name: "张三",
Age: 25,
Address: Address{
City: "北京",
Province: "北京市",
ZipCode: "100000",
},
}
// 直接访问嵌入字段
fmt.Println(u.City) // 北京
fmt.Println(u.Province) // 北京市
// 也可以通过字段名访问
fmt.Println(u.Address.City) // 北京
这就是 Go 的"继承"——组合(composition)。User “拥有”(has-a)一个 Address,而不是"是"(is-a)一个 Address。
方法提升
嵌入不仅提升字段,还提升方法。如果 Address 有方法,User 也可以直接调用:
func (a Address) FullAddress() string {
return a.Province + a.City + " " + a.ZipCode
}
// User 可以直接调用
fmt.Println(u.FullAddress()) // 北京市北京 100000
字段冲突
如果外部结构体和嵌入的结构体有同名字段,外部的会"遮蔽"(shadow)内部的:
type Base struct {
Name string
}
type Child struct {
Base
Name string // 和 Base.Name 同名
}
c := Child{
Base: Base{Name: "Base名字"},
Name: "Child名字",
}
fmt.Println(c.Name) // Child名字(外部的优先)
fmt.Println(c.Base.Name) // Base名字(显式访问内部的)
方法(Method)
什么是方法?
方法就是"绑定"到某个类型上的函数。它和普通函数的区别在于,方法有一个接收者(receiver),声明在 func 和函数名之间:
func (接收者) 方法名(参数) 返回值 {
// 方法体
}
来看一个例子:
type Rectangle struct {
Width float64
Height float64
}
// Area 计算矩形面积(值接收者)
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
// Perimeter 计算矩形周长
func (r Rectangle) Perimeter() float64 {
return 2 * (r.Width + r.Height)
}
func main() {
rect := Rectangle{Width: 10, Height: 5}
fmt.Println("面积:", rect.Area()) // 50
fmt.Println("周长:", rect.Perimeter()) // 30
}
这里 (r Rectangle) 就是接收者。你可以把它理解为方法的"主人"——Area() 是 Rectangle 的方法,r 代表调用方法的那个矩形。
值接收者 vs 指针接收者
这是 Go 方法中一个非常重要的概念。接收者可以是值类型,也可以是指针类型:
值接收者:方法操作的是接收者的副本
func (r Rectangle) Scale(factor float64) Rectangle {
return Rectangle{
Width: r.Width * factor,
Height: r.Height * factor,
}
}
指针接收者:方法操作的是接收者本身
func (r *Rectangle) ScaleInPlace(factor float64) {
r.Width *= factor
r.Height *= factor
}
区别:
rect := Rectangle{Width: 10, Height: 5}
// 值接收者:不修改原对象
newRect := rect.Scale(2)
fmt.Println(rect) // {10 5}(没变)
fmt.Println(newRect) // {20 10}
// 指针接收者:修改原对象
rect.ScaleInPlace(2)
fmt.Println(rect) // {20 10}(被修改了!)
什么时候用哪种接收者?
Go 社区的共识是:
使用指针接收者的情况:
- 方法需要修改接收者
- 接收者是一个大的结构体(避免复制开销)
- 接收者包含
sync.Mutex等不能被复制的字段 - 不确定用哪种时——用指针
使用值接收者的情况:
- 接收者是基本类型(int、string 等)
- 接收者是很小的结构体,且方法不需要修改它
- 接收者是切片、map、channel(它们本身就是引用类型)
type Counter struct {
count int
}
// 必须用指针接收者,否则修改不生效
func (c *Counter) Increment() {
c.count++
}
func (c *Counter) Count() int {
return c.count // 只读,但因为 Counter 可能变大,建议也用指针
}
⚠️ 最佳实践:如果一个类型有至少一个方法使用了指针接收者,那么所有方法都应该使用指针接收者,保持一致性。
Go 的自动解引用和取址
Go 很贴心地帮你处理了值和指针之间的转换:
rect := Rectangle{Width: 10, Height: 5}
rectPtr := &rect
// 值接收者的方法,指针也能调用(Go 自动解引用)
fmt.Println(rectPtr.Area())
// 指针接收者的方法,值也能调用(Go 自动取地址)
rect.ScaleInPlace(2)
但有一个例外:接口类型不能自动取地址(后面学接口时会讲到)。
构造函数
Go 没有内置的构造函数语法。社区约定俗成的做法是用 New... 开头的函数来创建对象:
type User struct {
Name string
Age int
Email string
CreatedAt time.Time
}
// NewUser 是 User 的"构造函数"
func NewUser(name string, age int, email string) *User {
return &User{
Name: name,
Age: age,
Email: email,
CreatedAt: time.Now(),
}
}
func main() {
user := NewUser("张三", 25, "zhangsan@example.com")
fmt.Println(user.Name, user.Age)
}
New 前缀让代码的意图很明确——“这是一个创建对象的函数”。返回指针还是值,取决于你的需求。大多数情况下返回指针更常见。
Stringer 接口
还记得 fmt.Println 怎么打印结构体吗?默认会打印出所有字段:
u := User{Name: "张三", Age: 25}
fmt.Println(u) // {张三 25 zhangsan@example.com ...}
如果你想自定义打印格式,可以实现 String() 方法:
func (u User) String() string {
return fmt.Sprintf("User{Name: %s, Age: %d}", u.Name, u.Age)
}
func main() {
u := User{Name: "张三", Age: 25}
fmt.Println(u) // User{Name: 张三, Age: 25}
}
这和 Java 的 toString()、Python 的 __str__() 是一个概念。任何实现了 String() string 方法的类型,在用 fmt.Println 等函数打印时都会自动调用这个方法。
方法集
一个类型的方法集(method set)是属于该类型的所有方法的集合。方法集决定了一个类型是否实现了某个接口。
规则:
- 类型
T的方法集:只包含值接收者的方法 - 类型
*T的方法集:包含值接收者和指针接收者的方法
type Widget struct {
Name string
}
func (w Widget) Info() string { // 值接收者
return w.Name
}
func (w *Widget) SetName(name string) { // 指针接收者
w.Name = name
}
// Widget 的方法集:{Info}
// *Widget 的方法集:{Info, SetName}
这个区别在你后面学接口时会很重要。如果你用值类型的变量去实现一个需要指针接收者方法的接口,是行不通的。
标签(Tag)
结构体字段后面可以加标签(tag),用来给字段附加元数据。标签最常用于 JSON 序列化:
type User struct {
Name string `json:"name"`
Age int `json:"age"`
Email string `json:"email,omitempty"`
phone string `json:"-"` // "-" 表示序列化时忽略
}
用 json.Marshal 序列化:
u := User{
Name: "张三",
Age: 25,
Email: "zhangsan@example.com",
phone: "13800138000",
}
data, _ := json.Marshal(u)
fmt.Println(string(data))
// {"name":"张三","age":25,"email":"zhangsan@example.com"}
注意:
json:"name"表示 JSON 字段名是name(小写)json:",omitempty"表示如果字段是零值就不序列化json:"-"表示完全忽略这个字段
组合代替继承
Go 没有继承,但有组合。通过嵌入结构体,你可以实现类似继承的效果,同时避免继承带来的复杂性。
来看一个实际的例子:
package main
import "fmt"
// Animal 基础结构体
type Animal struct {
Name string
Age int
}
func (a Animal) String() string {
return fmt.Sprintf("%s (age: %d)", a.Name, a.Age)
}
func (a Animal) Eat(food string) {
fmt.Printf("%s is eating %s\n", a.Name, food)
}
// Dog 嵌入 Animal
type Dog struct {
Animal
Breed string
}
func (d Dog) Bark() {
fmt.Printf("%s says: Woof!\n", d.Name)
}
// Cat 嵌入 Animal
type Cat struct {
Animal
Indoor bool
}
func (c Cat) Meow() {
fmt.Printf("%s says: Meow!\n", c.Name)
}
func main() {
dog := Dog{
Animal: Animal{Name: "旺财", Age: 3},
Breed: "金毛",
}
cat := Cat{
Animal: Animal{Name: "咪咪", Age: 2},
Indoor: true,
}
// Dog 可以直接使用 Animal 的方法
dog.Eat("骨头") // 旺财 is eating 骨头
dog.Bark() // 旺财 says: Woof!
fmt.Println(dog) // 旺财 (age: 3)
// Cat 也可以使用 Animal 的方法
cat.Eat("鱼") // 咪咪 is eating 鱼
cat.Meow() // 咪咪 says: Meow!
fmt.Println(cat) // 咪咪 (age: 2)
}
为什么 Go 选择了组合而不是继承?
传统的类继承有很多问题:
- 脆弱的基类:修改父类可能影响所有子类
- 菱形继承:多重继承导致的复杂性
- 过度耦合:子类和父类紧密绑定
- 层次膨胀:继承层次越来越深,越来越难理解
组合的优势在于松耦合。你可以自由组合各种小的结构体来构建复杂的类型,而不用担心继承层次的问题。
Go 社区有一句名言:“Favor composition over inheritance”(偏好组合而非继承)。其实这句话在很多语言中都适用,只是 Go 把它贯彻得更彻底——直接在语言层面不支持继承。
实战:银行账户系统
让我们用结构体和方法来实现一个简单的银行账户系统:
package main
import (
"errors"
"fmt"
"time"
)
// Transaction 交易记录
type Transaction struct {
Type string // "deposit" 或 "withdraw"
Amount float64
Timestamp time.Time
Balance float64
}
func (t Transaction) String() string {
return fmt.Sprintf("[%s] %s ¥%.2f (余额: ¥%.2f)",
t.Timestamp.Format("2006-01-02 15:04"),
t.Type, t.Amount, t.Balance)
}
// Account 银行账户
type Account struct {
Owner string
balance float64
transactions []Transaction
}
// NewAccount 创建新账户
func NewAccount(owner string, initialDeposit float64) (*Account, error) {
if initialDeposit < 0 {
return nil, errors.New("初始存款不能为负数")
}
acc := &Account{
Owner: owner,
balance: initialDeposit,
transactions: make([]Transaction, 0),
}
if initialDeposit > 0 {
acc.transactions = append(acc.transactions, Transaction{
Type: "deposit",
Amount: initialDeposit,
Timestamp: time.Now(),
Balance: initialDeposit,
})
}
return acc, nil
}
// Deposit 存款
func (a *Account) Deposit(amount float64) error {
if amount <= 0 {
return errors.New("存款金额必须大于 0")
}
a.balance += amount
a.transactions = append(a.transactions, Transaction{
Type: "deposit",
Amount: amount,
Timestamp: time.Now(),
Balance: a.balance,
})
return nil
}
// Withdraw 取款
func (a *Account) Withdraw(amount float64) error {
if amount <= 0 {
return errors.New("取款金额必须大于 0")
}
if amount > a.balance {
return fmt.Errorf("余额不足:当前余额 ¥%.2f,尝试取款 ¥%.2f", a.balance, amount)
}
a.balance -= amount
a.transactions = append(a.transactions, Transaction{
Type: "withdraw",
Amount: amount,
Timestamp: time.Now(),
Balance: a.balance,
})
return nil
}
// Balance 查询余额
func (a *Account) Balance() float64 {
return a.balance
}
// PrintStatement 打印账单
func (a *Account) PrintStatement() {
fmt.Printf("\n=== %s 的账单 ===\n", a.Owner)
for _, t := range a.transactions {
fmt.Println(t)
}
fmt.Printf("当前余额: ¥%.2f\n", a.balance)
}
func main() {
acc, err := NewAccount("张三", 1000)
if err != nil {
fmt.Println("创建账户失败:", err)
return
}
acc.Deposit(500)
acc.Withdraw(200)
acc.Deposit(1000)
// 尝试超额取款
if err := acc.Withdraw(5000); err != nil {
fmt.Println("\n取款失败:", err)
}
acc.PrintStatement()
}
小结
今天我们学习了 Go 语言中结构体和方法的核心知识:
- 结构体:用
type ... struct定义,把相关数据打包在一起 - 创建实例:字面量初始化最推荐,带字段名更清晰
- 匿名字段和嵌入:通过组合实现类似继承的效果
- 方法:绑定到类型上的函数,有接收者
- 值接收者 vs 指针接收者:需要修改用指针,只读可以用值
- 构造函数:Go 没有内置语法,用
New...函数代替 - Stringer:实现
String()方法自定义打印格式 - 标签:用于 JSON 序列化等元数据场景
- 组合代替继承:Go 的设计哲学,松耦合优于紧耦合
Go 的面向对象之路和 Java、C++ 截然不同。它没有类、没有继承、没有虚函数、没有抽象类,但它有结构体、方法、接口和组合。这种设计看起来简陋,但用起来你会发现它更加灵活和实用。
练习时间
- 学生管理系统:定义一个
Student结构体(姓名、学号、成绩列表),实现添加成绩、计算平均分、获取最高分的方法 - 几何图形:定义
Circle和Square结构体,各自实现Area()和Perimeter()方法 - 组合练习:定义一个
Vehicle(车辆)结构体,然后定义Car和Truck嵌入它,添加各自特有的方法 - Stringer 练习:给你之前写的结构体都加上
String()方法 - JSON 序列化:给结构体加上标签,用
json.Marshal和json.Unmarshal做序列化和反序列化
下一篇预告
下一篇文章,我们将学习 Go 语言中最优雅的设计工具——接口(interface)。接口是 Go 语言的灵魂之一,它让代码变得灵活、可测试、易于扩展。我们会讨论:
- 接口的定义和实现
- 隐式实现(鸭子类型)
- 空接口
interface{} - 类型断言和 type switch
- 常见的内置接口
我们下篇见!👋
参考资料:
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。