CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Go 语言入门/课时/结构体与接口
RawBlame
课时目录12 节

Go 语言入门

  1. 01Go 是什么
  2. 02基础语法与类型
  3. 03数组、切片与 map
  4. 04结构体与接口
  5. 05模块与包管理
  6. 06并发模型
  7. 07错误处理
  8. 08泛型入门
  9. 09I/O 与 CLI
  10. 10Web 与数据库
  11. 11测试与基准
  12. 12综合练习

结构体与接口

structs、methods、interfaces、embedding、type assertion。

40 分钟

结构体与接口

这一课你将能做到

  1. 定义结构体和方法,说清值接收者与指针接收者的区别与选择标准
  2. 定义并实现接口,理解 Go「隐式实现」与鸭子类型的含义
  3. 用 embedding 完成结构复用,用类型断言 / type switch 还原接口背后的具体类型

为什么要学

Go 没有 class、没有继承、没有构造函数重载,组织代码全靠三样东西:结构体(数据)、方法(行为)、接口(约定)。其中接口是 Go 语言灵魂——标准库 io.Reader、error、http.Handler 全是只有一两个方法的极小接口,正是这种设计让 Go 代码组合起来毫不费力。

这一课学完,你就具备了读懂任何 Go 项目主干结构的能力;后面的错误处理、泛型、Web 框架,全建立在这三个概念上。

先建立图像

Java 的继承是「血缘关系」,Go 的接口是「能力认证」:

Java:  class Dog extends Animal        ← 先有家谱,再有能力
Go:    type Dog struct{...}            ← 先有具体类型
       type Speaker interface{         ← 任何人都可以定义「会说话」认证
           Speak() string
       }
       func (d Dog) Speak() string     ← Dog 实现了 Speak,自动获得认证
                                          不需要 implements 声明

接口变量内部存的是「动态类型 + 动态值」这对组合:

var s Speaker = Dog{Name: "旺财"}
┌─────────────┬──────────────┐
│ 动态类型 Dog │ 动态值 {旺财} │
└─────────────┴──────────────┘

跟着做

步骤 1:定义结构体与创建实例

package main

import "fmt"

type User struct {
	Name string
	Age  int
	Email string // 未导出字段写成 email,本包外不可见
}

func main() {
	// 方式一:字段名齐全(推荐,可读且耐字段增删)
	u1 := User{Name: "张三", Age: 20, Email: "z@x.com"}

	// 方式二:new 得到指针,字段全是零值
	u2 := new(User)
	u2.Name = "李四"

	// 方式三:&User{} 同样得到指针
	u3 := &User{Name: "王五"}

	fmt.Println(u1, u2, u3)
	fmt.Println(u1.Name, u2.Age)

	// 零值结构体声明即可用
	var zero User
	fmt.Printf("%+v\n", zero) // {Name: Age:0 Email:}
}

步骤 2:方法——值接收者 vs 指针接收者

package main

import "fmt"

type Counter struct {
	n int
}

// 值接收者:拿到的是副本,改 n 不影响原对象
func (c Counter) BrokenInc() {
	c.n++
}

// 指针接收者:操作原对象
func (c *Counter) Inc() {
	c.n++
}

func (c Counter) Get() int {
	return c.n
}

func main() {
	var c Counter
	c.BrokenInc()
	c.Inc()
	c.Inc()
	fmt.Println(c.Get()) // 2,BrokenInc 白改了
}

规则:要修改接收者状态 → 指针接收者;只读、且结构体很小 → 值接收者。拿不准就统一用指针接收者,保持一致。

步骤 3:构造函数惯例 NewXxx

Go 没有构造函数,惯例是写一个 NewXxx 函数做初始化和校验:

package main

import (
	"errors"
	"fmt"
)

type Account struct {
	balance int
}

func NewAccount(init int) (*Account, error) {
	if init < 0 {
		return nil, errors.New("初始金额不能为负")
	}
	return &Account{balance: init}, nil
}

func (a *Account) Deposit(v int) { a.balance += v }
func (a Account) Balance() int   { return a.balance }

func main() {
	a, err := NewAccount(100)
	if err != nil {
		panic(err)
	}
	a.Deposit(50)
	fmt.Println(a.Balance()) // 150

	_, err = NewAccount(-1)
	fmt.Println(err)
}

步骤 4:接口定义与隐式实现

package main

import "fmt"

type Shape interface {
	Area() float64
}

type Rect struct{ W, H float64 }

func (r Rect) Area() float64 { return r.W * r.H }

type Circle struct{ R float64 }

func (c Circle) Area() float64 { return 3.14159 * c.R * c.R }

// 只依赖接口,不关心具体类型——这就是多态
func total(shapes []Shape) float64 {
	var sum float64
	for _, s := range shapes {
		sum += s.Area()
	}
	return sum
}

func main() {
	shapes := []Shape{
		Rect{W: 3, H: 4},
		Circle{R: 1},
	}
	fmt.Println(total(shapes)) // 15.14...

	// 注意:Rect 的零值也是合法的 Shape
	var s Shape = Rect{}
	fmt.Println(s.Area()) // 0
}

Rect 和 Circle 没有写任何 "implements Shape",只要方法集满足接口就算实现——这叫隐式实现(结构化类型/鸭子类型)。

步骤 5:标准库接口初体验——fmt.Stringer

package main

import "fmt"

type Player struct {
	Name string
	Lv   int
}

// 实现 fmt.Stringer 接口,fmt 打印时自动调用
func (p Player) String() string {
	return fmt.Sprintf("%s(Lv.%d)", p.Name, p.Lv)
}

func main() {
	p := Player{Name: "Gopher", Lv: 9}
	fmt.Println(p)         // {Gopher 9} 变成 → Gopher(Lv.9)
	fmt.Printf("%v\n", p)
}

这就是接口的威力:fmt.Println 只认识 Stringer 接口,任何类型实现它都能定制打印。

步骤 6:embedding——组合而非继承

package main

import "fmt"

type Logger struct{}

func (l Logger) Log(msg string) { fmt.Println("[log]", msg) }

type Server struct {
	Logger // 匿名嵌入:Server「获得」了 Log 方法
	Port   int
}

func main() {
	s := Server{Port: 8080}
	s.Log("启动成功") // 方法被「提升」到 Server 上
	fmt.Println(s.Logger.Log) // 不存在——只能调方法,没有 super/继承链
}

embedding 是组合:被嵌入类型的方法、字段被提升到外层,但两者没有父子关系,没有多态传递(Server 不会自动变成 Logger)。

步骤 7:类型断言与 type switch

package main

import "fmt"

func describe(v any) { // any 是 interface{} 的别名
	// 逗号-ok 断言:安全,不会 panic
	if s, ok := v.(string); ok {
		fmt.Println("字符串:", len(s))
		return
	}

	// type switch:一次判断多种类型
	switch x := v.(type) {
	case int:
		fmt.Println("整数,平方:", x*x)
	case bool:
		fmt.Println("布尔:", !x)
	case []int:
		fmt.Println("整数切片,长度:", len(x))
	default:
		fmt.Println("未知类型")
	}
}

func main() {
	describe("hello")
	describe(7)
	describe([]int{1, 2})
	describe(3.14)
}

讲清楚要点

  1. 值接收者拿副本,指针接收者拿原物:修改状态必须用指针;同时一个大结构体用值接收者每次调用都会整体拷贝,浪费性能。
  2. 方法集规则:T 类型的方法集只有值接收者方法;*T 的方法集包含值 + 指针两种。所以 *T 总能满足用值接收者方法定义的接口,反之不行。
  3. 接口是隐式满足的:实现方不需要 import 接口所在包。这让「先有实现、后补接口」和「消费方自定义小接口」都变得自然。
  4. embedding 不是继承:没有方法重写、没有向上转型、没有 super;同名方法外层覆盖内层的「提升」,但这只是名字遮蔽。
  5. 接口断言失败不加 comma-ok 会直接 panic:生产代码一律用 v, ok := x.(T) 形式。

你可能会懵的地方

  • 值类型实例也能调用指针接收者的方法吗? 可以,前提是变量可寻址:u.Inc() 会被自动改写成 (&u).Inc()。但存在 map 里的值、函数返回的临时值不可寻址,会报错,这是编译器最常拦下的场景之一。

  • 接口变量里到底存了什么? 一对(动态类型, 动态值)。赋什么类型它就记什么类型;var s Shape 未赋值时两个都是 nil。判断「接口是否为空」用 s == nil,但注意:把一个值为 nil 的具体指针赋给接口后,接口本身不是 nil(类型部分非 nil),这是著名陷阱,第 7 课再遇。

  • Go 为什么放弃了继承? 继承造成强耦合的深层层次(脆弱基类问题)。Go 用组合 + 小接口达到同样的复用与多态,代价是少写 extends,收益是每个类型都能独立理解。

  • 什么时候用接口,什么时候用结构体? 「需要多态、需要 mock、需要约定」就用接口;「描述一份数据」就用结构体。接口尽量小——一个方法最好,消费者按需定义。

  • 空接口 any 是什么? interface{} 的类型别名,任何类型都满足(因为没有任何方法要求)。它是泛型出现之前处理「任意类型」的唯一手段,现在只在 JSON 解析等真正动态的场景使用。

错法对照

错法问题正解
值接收者里修改字段改的是副本,原对象不变指针接收者 func (c *T) ...
type Dog struct { Animal } 后期望重写父类行为被多态调用embedding 无多态把公共行为抽成接口
x.(string) 不接 ok,类型不对直接 panics, ok := x.(string)
断言写反方向 Dog.(Speaker)语法/逻辑混乱断言是从接口到具体类型:v.(Dog)
实现接口时方法名小写(如 area())方法未导出,包外视为未实现方法名首字母大写 Area()
把 embedding 当继承用 s.super.Log()Go 没有 super直接 s.Logger.Log(...)

卡点急救

  1. cannot use x (type T) as type I:T 没有完整实现接口——检查方法名拼写、大小写、签名、接收者类型(值/指针)。
  2. cannot call pointer method on ...:目标不可寻址(map 元素、临时值)。先取出到变量再调用。
  3. 改字段不生效:接收者类型写错了,加 *。
  4. type Switch 里拿到的变量是 any 类型运算报错:v.(type) 的绑定变量已是各 case 的具体类型,不需要再断言。

面试常问

  • 值接收者 vs 指针接收者怎么选? 需要修改接收者、结构体较大、或类型中含 sync 锁等不可拷贝字段 → 指针接收者;小且不可变的值类型(time.Duration 这类)可用值接收者。同一类型的接收者类型应保持一致,且接口实现一致性受方法集约束。

  • Go 接口隐式实现有什么好处? 解耦:接口可以由消费方事后定义,实现方零感知;标准库因此能定义大量单方法小接口(Reader/Writer/Handler)而不制造依赖地狱。

  • 接口的 nil 陷阱是什么? 接口 == nil 要求类型与值都为 nil。把类型为 *T、值为 nil 的变量赋给接口后,接口 != nil。防御做法:函数返回错误类型时不要返回具体指针类型的 nil。

  • Go 如何实现多态?和 Java 有何不同? 通过接口的方法集动态派发(接口变量持有 itab/动态类型信息)。区别在于无需显式声明 implements,且提倡小接口组合,而非继承体系。

自测三问

  1. 一个结构体嵌入了 Logger,能否把 Server 当作 Logger 传给 func use(l Logger)?为什么?
  2. func (c *Counter) Inc() 和 func (c Counter) Inc() 同时存在合法吗?map 里的 Counter 能调用指针方法吗?
  3. 写出把 any 安全断言为 []string 的代码,并解释不加 comma-ok 的风险。

小结一句话

Go 的面向对象 = 结构体管数据 + 方法管行为(指针/值接收者二选一并保持一致)+ 接口管约定(隐式实现、小即是美)+ 组合替代继承。

延伸

  • Go 官方博客:Go 语言接口内部实现(英文进阶)
  • Effective Go:方法与接口
  • 下一课:模块与包管理——从单文件走向多文件、多依赖的工程项目。
加载本节练习…
← 上一课数组、切片与 map下一课 →模块与包管理