Go 里的 struct:没有 class,也能写出优雅的对象世界 如果你从 Java、C、Python 或 JavaScript 走进 Go很容易在第一个struct面前停下来Go 没有 class那它怎么表达“对象”方法写在哪里构造函数在哪里继承、封装、多态又去了哪里这篇文章想慢慢回答这些问题。Go 的答案并不是“换个名字继续写 class”而是换了一种更轻、更直接的组织方式用struct表达数据用方法表达行为用接口表达能力用组合代替继承。Go 并不反对面向对象。它只是把“对象”从一套庞大的语法体系里拿出来拆成几块朴素的积木让你按需要组装。1. struct 是什么一组有名字的字段最简单的struct就是把一组相关数据放在一起。package main import fmt type User struct { Name string Age int } func main() { u : User{ Name: Alice, Age: 18, } fmt.Println(u.Name) fmt.Println(u.Age) }这里的User不是 class。它没有构造器语法没有this没有继承列表也没有访问修饰符关键字。它只是在说一个用户由Name和Age两个字段组成。这种朴素感是 Go 的风格。它倾向于让代码看起来接近数据本身而不是把数据藏在很多仪式后面。2. struct 的零值Go 很重视“拿来就能用”Go 的每个类型都有零值。struct的零值就是它每个字段各自的零值。type User struct { Name string Age int } func main() { var u User fmt.Printf(%q\n, u.Name) // fmt.Println(u.Age) // 0 }这意味着你不一定非要调用构造函数才能得到一个可用对象。当然并不是所有类型的零值都代表业务上“完整可用”。例如数据库连接、带配置的客户端、必须校验的订单对象通常仍然需要初始化函数。但 Go 鼓励你设计类型时考虑一个问题这个类型的零值能不能自然地工作比如标准库里的sync.Mutex零值就能直接使用var mu sync.Mutex mu.Lock() mu.Unlock()这种设计让很多 Go 代码少了初始化负担也少了“忘记 new 导致崩掉”的心智成本。3. 创建 struct字面量、指针与字段名Go 里常见的创建方式有几种。使用字段名u : User{ Name: Alice, Age: 18, }这是最推荐的写法。字段名清楚顺序不敏感将来给User增加字段时也更安全。按字段顺序u : User{Alice, 18}这种写法更短但不够稳定。字段一多可读性会下降字段顺序变动时也容易出错。除非是很小、很局部的结构体否则建议少用。创建指针u : User{ Name: Alice, Age: 18, }这会得到*User也就是指向User的指针。在 Go 里很多时候你会用结构体指针因为它避免拷贝并且允许方法修改原对象。4. 方法行为不写在 struct 里面在许多语言里类的字段和方法都写在 class 代码块里。Go 不这么做。Go 的struct只声明字段方法写在外面通过 receiver 和某个类型绑定。type User struct { Name string Age int } func (u User) SayHello() { fmt.Println(Hello, I am, u.Name) }func (u User) SayHello()里的(u User)叫 receiver。它表示这是一个属于User类型的方法。调用时和其他语言里的对象方法类似u : User{Name: Alice, Age: 18} u.SayHello()虽然方法写在外面但调用体验仍然是对象.方法()。这也是 Go 的一个关键审美组织方式可以简单使用方式可以自然。5. 值接收者与指针接收者Go 的方法 receiver 有两种常见写法func (u User) SayHello() { fmt.Println(u.Name) } func (u *User) GrowUp() { u.Age }第一种是值接收者第二种是指针接收者。值接收者拿到的是一份拷贝func (u User) Rename(name string) { u.Name name } func main() { u : User{Name: Alice} u.Rename(Bob) fmt.Println(u.Name) // Alice }Rename里修改的是副本所以原来的u.Name不变。指针接收者可以修改原对象func (u *User) Rename(name string) { u.Name name } func main() { u : User{Name: Alice} u.Rename(Bob) fmt.Println(u.Name) // Bob }这次 receiver 是*User方法拿到的是原对象地址所以可以修改它。怎么选择常用经验是方法需要修改对象时用指针接收者。结构体较大不想频繁拷贝时用指针接收者。类型内部包含sync.Mutex等不应该被复制的字段时用指针接收者。小而不可变的值类型可以用值接收者。例如坐标点适合值接收者type Point struct { X int Y int } func (p Point) DistanceFromOrigin() int { return p.X*p.X p.Y*p.Y }而用户资料、订单、缓存、连接对象通常更适合指针接收者。6. Go 没有构造函数但常用 New 函数Go 没有constructor关键字。你可以直接用字面量创建对象也可以写一个普通函数来负责初始化。type User struct { Name string Age int } func NewUser(name string, age int) *User { return User{ Name: name, Age: age, } }调用u : NewUser(Alice, 18)这只是普通函数不是语言特权。它的好处是可以封装校验和默认值。func NewUser(name string, age int) (*User, error) { if name { return nil, errors.New(name cannot be empty) } if age 0 { return nil, errors.New(age cannot be negative) } return User{ Name: name, Age: age, }, nil }在 Go 里构造并不是必须套进特殊语法的动作。它只是一个函数输入参数返回值可能返回错误。这种做法非常 Go。7. 封装靠大小写而不是 private/public 关键字Go 的可见性规则很简单首字母大写包外可见。首字母小写仅包内可见。type User struct { Name string // 包外可访问 age int // 仅当前包内可访问 }如果一个字段不希望外部直接修改可以小写然后通过方法暴露读取或受控修改。type Account struct { owner string balance int } func NewAccount(owner string) *Account { return Account{owner: owner} } func (a *Account) Owner() string { return a.owner } func (a *Account) Balance() int { return a.balance } func (a *Account) Deposit(amount int) error { if amount 0 { return errors.New(amount must be positive) } a.balance amount return nil }外部代码不能直接写account.balance -100它必须通过Deposit这类方法进入对象内部。于是规则被放在方法里数据被保护在包边界内。Go 的封装不是“把一切都藏起来”而是让包成为天然的边界。8. 组合Go 用 embedding 取代继承Go 没有类继承。它推荐组合。最普通的组合是把一个结构体作为另一个结构体的字段type Address struct { City string Street string } type User struct { Name string Address Address }使用u : User{ Name: Alice, Address: Address{ City: Shanghai, Street: West Road, }, } fmt.Println(u.Address.City)Go 还有一种特殊组合方式叫嵌入字段。type User struct { Name string Address }这时可以直接访问被嵌入结构体的字段fmt.Println(u.City)注意这不是继承。User并没有“继承”Address。更准确地说User拥有一个匿名的Address字段而 Go 帮你把字段和方法提升到了外层。这种设计让代码复用更透明你看得见对象由哪些部分组成。9. 方法也会被嵌入嵌入字段不仅能提升字段也能提升方法。type Logger struct{} func (Logger) Log(message string) { fmt.Println([log], message) } type Service struct { Logger Name string } func main() { s : Service{Name: payment} s.Log(service started) }Service里嵌入了Logger所以可以直接调用s.Log()。这很像“继承了方法”但本质仍然是组合。你应该把它理解成Service 里面有一个 Logger所以 Service 可以复用 Logger 的能力。这个区别很重要。继承强调“我是某种东西”组合强调“我拥有某种能力”。Go 更偏爱后者。10. 接口Go 的多态来自“能力”如果说struct负责保存数据方法负责定义行为那么接口就负责描述能力。type Speaker interface { Speak() string }任何类型只要有Speak() string方法就自动实现了Speaker。type User struct { Name string } func (u User) Speak() string { return I am u.Name } type Robot struct { ID string } func (r Robot) Speak() string { return Robot r.ID } func SaySomething(s Speaker) { fmt.Println(s.Speak()) }调用func main() { SaySomething(User{Name: Alice}) SaySomething(Robot{ID: R2}) }Go 不要求你写type User struct implements Speaker也不要求你声明继承关系。只要方法集匹配就算实现。这叫隐式接口实现。它带来一种很轻的多态我不关心你是什么类型我只关心你能不能做这件事。这也是 Go 里非常核心的设计味道。11. struct 与 interface 搭配小接口大自由Go 社区喜欢小接口。一个接口通常只描述一个很小的能力。比如标准库里的io.Readertype Reader interface { Read(p []byte) (n int, err error) }只要一个类型有这个方法它就是 reader。我们可以自己写一个字符串读取器的包装器给内容加上前缀type PrefixReader struct { Prefix string Reader io.Reader done bool } func (p *PrefixReader) Read(buf []byte) (int, error) { if !p.done { p.done true return strings.NewReader(p.Prefix).Read(buf) } return p.Reader.Read(buf) }这个例子并不完美因为如果buf很小前缀可能读不完整。真正生产代码需要保存内部状态。但它说明了一个重要思想struct 保存状态Read 方法定义行为io.Reader 接口让它进入整个 Go I/O 生态。你之前做的rot13Reader也是同一个模式type rot13Reader struct { r io.Reader } func (rr rot13Reader) Read(p []byte) (n int, err error) { n, err rr.r.Read(p) for i : 0; i n; i { switch { case A p[i] p[i] Z: p[i] A (p[i]-A13)%26 case a p[i] p[i] z: p[i] a (p[i]-a13)%26 } } return n, err }它没有继承io.Reader也没有显式声明实现io.Reader。它只是拥有一个Read方法于是它就是一个io.Reader。这就是 Go 的简洁之美。12. 一个完整例子用 struct 设计订单下面用一个稍完整的例子把字段、方法、构造函数、封装和接口放在一起。package main import ( errors fmt ) type OrderStatus string const ( OrderPending OrderStatus pending OrderPaid OrderStatus paid ) type Order struct { id string items []OrderItem status OrderStatus } type OrderItem struct { Name string Price int Count int } func NewOrder(id string) (*Order, error) { if id { return nil, errors.New(id cannot be empty) } return Order{ id: id, status: OrderPending, }, nil } func (o *Order) ID() string { return o.id } func (o *Order) Status() OrderStatus { return o.status } func (o *Order) AddItem(item OrderItem) error { if o.status ! OrderPending { return errors.New(cannot add item to a paid order) } if item.Name { return errors.New(item name cannot be empty) } if item.Price 0 || item.Count 0 { return errors.New(price and count must be positive) } o.items append(o.items, item) return nil } func (o *Order) Total() int { total : 0 for _, item : range o.items { total item.Price * item.Count } return total } func (o *Order) Pay() error { if len(o.items) 0 { return errors.New(cannot pay an empty order) } if o.status ! OrderPending { return errors.New(order is not pending) } o.status OrderPaid return nil } type Printable interface { Print() string } func (o *Order) Print() string { return fmt.Sprintf(Order(id%s, status%s, total%d), o.id, o.status, o.Total()) } func PrintReceipt(p Printable) { fmt.Println(p.Print()) } func main() { order, err : NewOrder(order-001) if err ! nil { panic(err) } err order.AddItem(OrderItem{Name: Book, Price: 80, Count: 2}) if err ! nil { panic(err) } err order.AddItem(OrderItem{Name: Pen, Price: 10, Count: 3}) if err ! nil { panic(err) } fmt.Println(order.Total()) // 190 err order.Pay() if err ! nil { panic(err) } PrintReceipt(order) }这个例子里有几件事值得看Order的字段是小写的外部不能随意修改订单状态。NewOrder负责创建合法订单。AddItem和Pay把业务规则收在方法里。Total是根据内部数据计算出来的行为。Print让Order自动满足Printable接口。这不是传统 class 体系但它依然表达了完整的对象建模。更准确地说它表达得更克制数据在哪里行为在哪里规则在哪里都很清楚。13. struct 不是 class 的残缺版很多初学者会问Go 的 struct 是不是少了很多 class 的功能换个角度看Go 其实是主动舍弃了一部分复杂度。它没有继承树于是少了“父类改动影响一片子类”的问题。它没有构造器重载于是初始化逻辑更像普通函数。它没有显式 implements于是接口可以在调用方一侧自然长出来。它没有把所有东西都塞进 class于是包、函数、结构体、接口各司其职。Go 的设计不是让你不能抽象而是提醒你抽象应该在需要的时候出现而不是在每个文件开头先摆好架子。14. 常见误区误区一所有字段都要私有再写 getter 和 setter这是一种从 Java 带来的习惯但在 Go 里不一定合适。如果一个类型只是简单的数据载体字段可以直接导出type Point struct { X int Y int }没必要写func (p Point) GetX() int { return p.X }Go 更喜欢直白。如果字段没有复杂规则直接访问就是最清楚的表达。误区二把所有方法都写成指针接收者指针接收者很常用但不是永远必要。对于小值类型值接收者更自然。type Celsius float64 func (c Celsius) Fahrenheit() float64 { return float64(c)*9/5 32 }Celsius很小也不需要被修改用值接收者就很好。误区三用嵌入模拟复杂继承嵌入字段不是为了让你重建一棵继承树。如果你发现自己写出了很多层嵌入并且开始依赖复杂的方法提升和重名规则那往往说明设计已经变得不透明。此时更好的方式可能是显式字段名、接口拆分或者重新整理职责。误区四先定义大接口Go 的接口最好从使用方长出来。不要一开始就写type UserRepository interface { Create(user User) error Update(user User) error Delete(id string) error FindByID(id string) (User, error) FindAll() ([]User, error) }如果某个函数只需要查询就让它依赖更小的接口type UserFinder interface { FindByID(id string) (User, error) }小接口更容易测试也更不容易把调用方绑死在庞大的实现上。15. 一句话对比Go struct 与传统 class主题传统 class 常见做法Go 的做法数据字段写在 class 内字段写在 struct 内行为方法写在 class 内方法通过 receiver 绑定到类型构造constructor普通NewXxx函数或字面量封装public/private/protected按首字母大小写控制包外可见性继承class extends class组合与嵌入多态显式实现接口或继承父类方法集隐式满足接口设计倾向类型层级能力组合这张表不表示谁更高级。它只说明 Go 选择了一条不同的路少一点层级多一点组合少一点声明多一点行为匹配。16. 写 Go struct 时的几个建议第一先写清楚数据。不要急着抽象。先问这个概念有哪些稳定字段哪些字段应该暴露哪些字段必须被保护第二让方法表达真实行为。方法不只是“把字段拿出来再放回去”。好的方法应该承载一点业务含义比如Pay、Deposit、AddItem、Validate、Read。第三让构造函数负责不变量。如果对象必须满足某些条件才能成立就把这些条件放进NewXxx或相关方法里。第四优先组合。当你想复用能力时先考虑“这个类型是否拥有另一个组件”而不是“它是否应该继承另一个类型”。第五接口从需求中长出来。不要为了“看起来面向对象”而提前设计一堆接口。Go 里的接口最有力量的时候往往是在调用方发现自己只需要一小块能力时。结语Go 的对象观Go 里的struct像一只安静的容器。它不急着把自己装扮成庞大的类体系也不试图用语法告诉你所有设计答案。它只是说先把数据放清楚。如果有行为就给它方法。如果要复用就用组合。如果要多态就描述能力。这套方式初看朴素写久了会觉得清爽。因为它让对象回到了对象本身不是继承树上的一个节点不是设计模式里的一个角色而是一组数据和围绕这组数据展开的行为。Go 没有 class但它并不贫乏。它只是把对象世界里的许多装饰拿掉留下更直接的结构、更清楚的边界以及更容易维护的代码