结构体与方法:Go 的'面向对象'之路

深入理解 Go 语言的结构体和方法,学会用组合代替继承,写出优雅的面向对象代码

结构体与方法: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 社区的共识是:

使用指针接收者的情况

  1. 方法需要修改接收者
  2. 接收者是一个大的结构体(避免复制开销)
  3. 接收者包含 sync.Mutex 等不能被复制的字段
  4. 不确定用哪种时——用指针

使用值接收者的情况

  1. 接收者是基本类型(int、string 等)
  2. 接收者是很小的结构体,且方法不需要修改它
  3. 接收者是切片、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 选择了组合而不是继承?

传统的类继承有很多问题:

  1. 脆弱的基类:修改父类可能影响所有子类
  2. 菱形继承:多重继承导致的复杂性
  3. 过度耦合:子类和父类紧密绑定
  4. 层次膨胀:继承层次越来越深,越来越难理解

组合的优势在于松耦合。你可以自由组合各种小的结构体来构建复杂的类型,而不用担心继承层次的问题。

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 语言中结构体和方法的核心知识:

  1. 结构体:用 type ... struct 定义,把相关数据打包在一起
  2. 创建实例:字面量初始化最推荐,带字段名更清晰
  3. 匿名字段和嵌入:通过组合实现类似继承的效果
  4. 方法:绑定到类型上的函数,有接收者
  5. 值接收者 vs 指针接收者:需要修改用指针,只读可以用值
  6. 构造函数:Go 没有内置语法,用 New... 函数代替
  7. Stringer:实现 String() 方法自定义打印格式
  8. 标签:用于 JSON 序列化等元数据场景
  9. 组合代替继承:Go 的设计哲学,松耦合优于紧耦合

Go 的面向对象之路和 Java、C++ 截然不同。它没有类、没有继承、没有虚函数、没有抽象类,但它有结构体、方法、接口和组合。这种设计看起来简陋,但用起来你会发现它更加灵活和实用。

练习时间

  1. 学生管理系统:定义一个 Student 结构体(姓名、学号、成绩列表),实现添加成绩、计算平均分、获取最高分的方法
  2. 几何图形:定义 CircleSquare 结构体,各自实现 Area()Perimeter() 方法
  3. 组合练习:定义一个 Vehicle(车辆)结构体,然后定义 CarTruck 嵌入它,添加各自特有的方法
  4. Stringer 练习:给你之前写的结构体都加上 String() 方法
  5. JSON 序列化:给结构体加上标签,用 json.Marshaljson.Unmarshal 做序列化和反序列化

下一篇预告

下一篇文章,我们将学习 Go 语言中最优雅的设计工具——接口(interface)。接口是 Go 语言的灵魂之一,它让代码变得灵活、可测试、易于扩展。我们会讨论:

  • 接口的定义和实现
  • 隐式实现(鸭子类型)
  • 空接口 interface{}
  • 类型断言和 type switch
  • 常见的内置接口

我们下篇见!👋


参考资料:

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页