探索 Go 语言的无类设计:从 Struct 到组合的优雅之道
在众多编程语言中,Go 以其简洁和高性能著称,但它却刻意摒弃了传统的面向对象特性——class。与 C++、Java 等语言的继承体系不同,Go 选择了一条别样的道路:通过 struct、方法关联以及组合(composition)来实现数据与行为的组织。这种设计不仅简化了代码结构,还赋予了开发者更大的灵活性。本文将深入剖析 Go 如何在无 class 的世界中实现面向对象的目标,从 struct 的基本用法到组合与转发的强大特性,带你领略 Go 的独特哲学。
Go 语言摒弃了传统的 class 和继承机制,转而使用 struct 结合方法关联来定义数据和行为。通过 struct 的复合字面值和构造函数,开发者可以灵活初始化复杂数据结构;而嵌入(embedding)特性则通过组合与方法转发,实现了类似继承的功能,但更为简洁和灵活。本文从 struct 的基本使用入手,展示了如何将方法绑定到类型上,并通过构造函数和组合替代 class 的功能。代码示例进一步阐释了这些概念的实践应用,包括距离计算和温度转换等场景。文章还探讨了组合优于继承的设计哲学,帮助读者理解 Go 的无类之道。
Go语言没有class
Go语言里没有class
- Go和其它经典语言不同,它没有class,没有对象,也没有继承。
- 但是Go提供了struct和方法。
将方法关联到struct
- 方法可以被关联到你声明的类型上
package main
import "fmt"
// coordinate in degrees, minutes, seconds in a N/S/E/W hemisphere.
type coordinate struct {
d, m, s float64
h rune
}
// decimal converts a d/m/s coordinate to decimal degrees.
func (c coordinate) decimal() float64 {
sign := 1.0
switch c.h {
case 'S', 'W', 's', 'w':
sign = -1
}
return sign * (c.d + c.m/60 + c.s/3600)
}
func main() {
// Bradbury Landing: 4°35'22.2" S, 137°26‘30.1“ E
lat := coordinate{4, 35, 22.2, 'S'}
long := coordinate{137, 26, 30.12, 'E'}
fmt.Println(lat.decimal(), long.decimal())
}
小测试
- 上例中,decimal方法的接收者是谁?
- 答案:coordinate 类型,c 是接收者的变量名。
构造函数
- 可以使用struct复合字面值来初始化你所要的数据。
- 但如果struct初始化的时候还要做很多事情,那就可以考虑写一个构造用的函数。
package main
import "fmt"
// coordinate in degrees, minutes, seconds in a N/S/E/W hemisphere.
type coordinate struct {
d, m, s float64
h rune
}
// decimal converts a d/m/s coordinate to decimal degrees.
func (c coordinate) decimal() float64 {
sign := 1.0
switch c.h {
case 'S', 'W', 's', 'w':
sign = -1
}
return sign * (c.d + c.m/60 + c.s/3600)
}
type location struct {
lat, long float64
}
// newLocation from latitude, longitude d/m/s coordinates.
func newLocation(lat, long, coordinate) location {
return location{lat.decimal(), long.decimal()}
}
func main() {
// Bradbury Landing: 4°35'22.2" S, 137°26‘30.1“ E
lat := coordinate{4, 35, 22.2, 'S'}
long := coordinate{137, 26, 30.12, 'E'}
fmt.Println(lat.decimal(), long.decimal())
// curiosity := location{lat.decimal(), long.decimal()}
curiosity := newLocation(lat, long)
fmt.Println(curiosity)
}
- Go语言没有专用的构造函数,但以new或者New开头的函数,通常是用来构造数据的。例如newPerson(),NewPerson()
New函数
- 有一些用于构造的函数的名称就是New(例如errors包里面的New函数)。
- 这是因为函数调用时使用包名.函数名的形式。
- 如果该函数叫NewError,那么调用的时候就是errors.NewError(),这就不如errors.New()简洁
小测试
- 如果你想构建一个Universe类型的变量,那么你如何为该函数命名?
- 答案:newUniverse 或 NewUniverse,推荐 NewUniverse(Go 惯例)。
class的替代方案
- Go语言没有class,但使用struct并配备几个方法也可以达到同样的效果。
package main
import (
"fmt"
"math"
)
type location struct {
lat, long float64
}
type world struct {
radius float64
}
// distance calculation using the Spherical Law of Cosines.
func (w world) distance(p1, p2 location) float64 {
s1, c1 := math.Sincos(rad(p1.lat))
s2, c2 := math.Sincos(rad(p2.lat))
clong := math.Cos(rad(p1.long - p2.long))
return w.radius * math.Acos(s1*s2+c1*c2*clong)
}
// rad converts degrees to radians.
func rad(deg float64) float64 {
return deg * math.Pi / 180
}
func main() {
var mars = world{radius: 3389.5}
spirit := location{-14.5684, 175.472636}
opportunity := location{-1.9462, 354.4734}
dist := mars.distance(spirit, opportunity)
fmt.Printf("%.2f km\n", dist)
}
小测试
- 与不采用面向对象的方式相比,在world类型上声明一个distance方法的好处是什么?
- 答案:封装性更好,world 的 radius 与 distance 逻辑绑定,便于扩展和维护。
作业题
-
使用例子中的代码,编写一个程序。并为下表中每个 位置都声明一个location,以十进制度数打印出每个位置。
-
使用例子中的distance方法,编写一个程序,来判定上题表中每对着陆点之间的距离。并回答:
- 哪两个着陆点之间最近?
- 哪两个着陆点之间最远?
- 计算伦敦到巴黎之间的距离(51°30’N 0°08’W),(48°51’N 2°21’E),地球半径为6371公里。
- 计算你的城市到北京距离
- 计算火星上Mount Sharp (5°4’ 48”S, 137°51’E)到Olympus Mons (18°39’N,226°12’E)之间的距离。火星的半径是3389.5公里。
组合与转发
Composition and forwarding
组合
- 在面向对象的世界中,对象由更小的对象组合而成。
- 术语:对象组合或组合
- Go通过结构体实现组合(composition)。
- Go提供了“嵌入”(embedding)特性,它可以实现方法的转发(forwarding)
- 组合是一种更简单、灵活的方式。
组合结构体
例子一
package main
type report struct {
sol int
high, low float64
lat, hong float64
}
func main() {
}
例子二
package main
type report struct {
sol int
temperature temperature
location location
}
type temperature struct {
hign, low celsius
}
type location struct {
lat, long float64
}
type celsius float64
func (t temperature) average() celsius {
return (t.high + t.low) / 2
}
func (r report) average() celsius {
return r.temperature.average()
}
func main() {
bradbury := location{-4.5895, 137.4417}
t := temperature{high: -1.0, low: -78.0}
fmt.Println(t.average())
report := report {
sol: 15,
temperature: t,
location: bradbury
}
fmt.Println(report.temperature.average())
fmt.Println(report.average())
fmt.Printf("%+v\n", report)
fmt.Printf("a balmy %v° C\n", report.temperature.high)
}
小测试
- 比较例子1和2的代码,你更喜欢哪一种?原因是什么?
- 答案:例子 2(组合)更好,结构清晰,易于扩展和复用。
转发方法
- Go可以通过struct嵌入 来实现方法的转发。
- 在struct中只给定字段类型,不给定字段名即可。
package main
import "fmt"
//type report struct {
// sol int
// temperature temperature
// location location
//}
type report struct {
sol int
temperature
location
}
type temperature struct {
high, low celsius
}
type location struct {
lat, long float64
}
type celsius float64
func (t temperature) average() celsius {
return (t.high + t.low) / 2
}
func main() {
bradbury := location{-4.5895, 137.4417}
t := temperature{high: -1.0, low: -78.0}
fmt.Println(t.average())
report := report {
sol: 15,
temperature: t,
location: bradbury
}
fmt.Println(report.average())
fmt.Println(report.high)
fmt.Printf("%+v\n", report)
fmt.Printf("a balmy %v° C\n", report.temperature.high)
}
- 在struct中,可以转发任意类型。
package main
import "fmt"
//type report struct {
// sol int
// temperature temperature
// location location
//}
type sol int
type report struct {
sol
temperature
location
}
type temperature struct {
high, low celsius
}
type location struct {
lat, long float64
}
type celsius float64
func (t temperature) average() celsius {
return (t.high + t.low) / 2
}
func (s sol) days(s2 sol) int {
days := int(s2 - s)
if days < 0 {
days = -days
}
return days
}
func main() {
report := report {
sol: 15,
}
fmt.Println(report.sol.days(1446))
fmt.Println(report.days(1446))
}
小测试
- 结构体可以嵌入什么类型?
- 访问report.lat字段是否合法?如果合法,那么上面例子中它指向哪个字段?
命名冲突
package main
import "fmt"
//type report struct {
// sol int
// temperature temperature
// location location
//}
type sol int
type report struct {
sol
temperature
location
}
type temperature struct {
high, low celsius
}
type location struct {
lat, long float64
}
type celsius float64
func (t temperature) average() celsius {
return (t.high + t.low) / 2
}
func (s sol) days(s2 sol) int {
days := int(s2 - s)
if days < 0 {
days = -days
}
return days
}
func (l location) days(l2 location) int {
// To-do: complicated distance calculation
return 5
}
func (r report) days(s2 sol) int {
return r.sol.days(s2)
}
func main() {
report := report {
sol: 15,
}
fmt.Println(report.sol.days(1446))
//fmt.Println(report.days(1446)) // 报错
fmt.Println(report.days(1446))
}
继承 还是 组合?
- Favor object composition over class inheritance.
- 优先使用对象组合而不是类的继承。
- Use of classical inheritance is always optional; every problem thatit solves can be solved another way.
- 对传统的继承不是必需的;所有使用继承解决的问题都可以通过其它方法解决。
小测试
- 如果多个嵌入的类型都实现了同名的方法,那么Go编译器会报错吗?
- 答案:Go 编译器在多个嵌入类型有同名方法时不会直接报错,但要求显式指定调用哪个类型的方法,否则报“歧义”错误。 这种设计避免了继承中复杂的多重继承冲突,保持了简洁性。也就是说,在 Go 中,当一个结构体通过嵌入(embedding)包含多个类型,并且这些类型实现了同名方法时,编译器不会直接报错,而是要求开发者在调用时明确指定使用哪个嵌入类型的方法。如果不指定,编译器会报错提示“ambiguous selector”(选择器歧义)。
总结
Go 语言的无类设计并不是对面向对象的否定,而是对其的一种重新定义。通过 struct 和方法关联,Go 提供了简洁的数据与行为绑定方式;通过组合与转发,它在避免继承复杂性的同时,保留了灵活性和复用性。从构造函数的优雅实现到嵌入带来的方法共享,Go 用更少的概念解决了 class 所能解决的问题。正如“一切有为法,如梦幻泡影”,Go 的设计哲学提醒我们:技术不必拘泥于传统,简单的组合往往胜过繁琐的继承。无论你是面向对象编程的拥趸,还是初探 Go 的新手,这种无类之道都值得一试。