AIGC标识 Go 学习笔记:可变参数 & 方法与接口

Go 学习笔记:可变参数 & 方法与接口


今日学习总结

今天完成了 2.4 函数的最后一个知识点——可变参数,并正式进入 2.5 方法与接口。方法与接口是 Go 语言面向对象编程的核心:方法让自定义类型拥有行为,接口用隐式实现的方式定义抽象契约。今天覆盖三个知识点:

  1. 可变参数:函数接受任意数量的参数,底层是切片
  2. 方法声明与接收器:为类型绑定方法,理解值接收器与指针接收器的本质区别
  3. 接口定义与实现:Go 的隐式接口(鸭子类型),接口的满足规则与实际应用

一、可变参数(Variadic Parameters)

1.1 基本概念

可变参数是指一个函数的最后一个参数可以接受零个或多个值。语法上,在参数类型前加上 ... 即可:

func sum(nums ...int) int {
    total := 0
    for _, n := range nums {
        total += n
    }
    return total
}

调用时可以传入任意数量的 int 值:

sum()           // 0 个参数
sum(1)          // 1 个参数
sum(1, 2, 3)    // 3 个参数
sum(1, 2, 3, 4, 5, 6, 7, 8, 9, 10) // 10 个参数

1.2 底层机制:可变参数就是切片

这一点非常关键——在函数内部,nums 的类型并不是什么神秘类型,而是 []int(切片)。Go 编译器在调用端自动把传入的零散值打包成一个切片,再传给函数。

理解这一点后,很多行为就顺理成章了:

func printAll(prefix string, items ...string) {
    // items 的类型是 []string
    fmt.Printf("类型: %T, 长度: %d, 容量: %d\n", items, len(items), cap(items))
    for i, s := range items {
        fmt.Printf("  %s[%d] = %s\n", prefix, i, s)
    }
}

1.3 传切片给可变参数:... 展开运算符

既然可变参数在函数内部就是切片,那么手头已经有一个切片时,怎么传进去?答案是用 ... 展开:

names := []string{"Alice", "Bob", "Charlie"}
printAll("name", names...) // 把切片展开为可变参数

注意两种调用的区别:

printAll("name", "Alice", "Bob")    // 传入 2 个 string 值
printAll("name", []string{"A", "B"}) // ❌ 编译错误!类型不匹配
printAll("name", []string{"A", "B"}...) // ✅ 用 ... 展开切片

1.4 一个重要陷阱:底层数组共享

当用 ... 传递切片时,传入的是原切片的底层数组引用(如果容量足够,不会发生拷贝)。这意味着如果在可变参数函数内部修改了切片元素,外部可能受影响:

func modifyFirst(vals ...int) {
    if len(vals) > 0 {
        vals[0] = 999 // 修改第一个元素
    }
}

func main() {
    nums := []int{1, 2, 3}
    modifyFirst(nums...)
    fmt.Println(nums) // [999 2 3] —— 外部被改了!
}

这个行为源于 Go 的切片机制:... 展开时,如果原切片的底层数组没有发生扩容,函数内的 vals 和外部的 nums 共享同一块内存。所以不要假设可变参数函数内部对参数的修改不会影响调用方

1.5 append 函数就是可变参数的经典案例

我们天天在用的 append 就是可变参数函数,它的签名是:

func append(slice []T, elems ...T) []T

所以这些调用都是合法的:

s := []int{1, 2}
s = append(s, 3)              // 追加一个元素
s = append(s, 4, 5, 6)        // 追加多个元素
s = append(s, []int{7, 8}...) // 追加一个切片

1.6 接口可变参数:fmt.Println 的秘密

fmt.Println 的签名是 func Println(a ...any) (n int, err error)。它用 any(即 interface{})作为可变参数类型,所以能接受任何类型的参数。这是可变参数 + 空接口的强大组合:

fmt.Println(1, "hello", 3.14, []int{1, 2}, true)

1.7 完整练习代码

package main

import "fmt"

// 可变参数求和
func sum(nums ...int) int {
    total := 0
    for _, n := range nums {
        total += n
    }
    return total
}

// 可变参数 + 固定参数混合
func greet(greeting string, names ...string) {
    if len(names) == 0 {
        fmt.Printf("%s, 陌生人!\n", greeting)
        return
    }
    for _, name := range names {
        fmt.Printf("%s, %s!\n", greeting, name)
    }
}

// 可变参数找最大值
func maxOf(nums ...int) int {
    if len(nums) == 0 {
        panic("maxOf: 至少需要一个参数")
    }
    m := nums[0]
    for _, n := range nums[1:] {
        if n > m {
            m = n
        }
    }
    return m
}

// 可变参数拼接字符串(模拟 strings.Join 的简化版)
func join(sep string, parts ...string) string {
    if len(parts) == 0 {
        return ""
    }
    result := parts[0]
    for _, p := range parts[1:] {
        result += sep + p
    }
    return result
}

func main() {
    // 1. 基本调用
    fmt.Println("sum():", sum())
    fmt.Println("sum(1,2,3):", sum(1, 2, 3))
    fmt.Println("sum(1..10):", sum(1, 2, 3, 4, 5, 6, 7, 8, 9, 10))

    // 2. 展开切片
    nums := []int{10, 20, 30, 40}
    fmt.Println("sum(nums...):", sum(nums...))

    // 3. 混合参数
    greet("你好", "张三", "李四", "王五")
    greet("嗨") // 0 个可变参数

    // 4. 找最大值
    fmt.Println("max:", maxOf(3, 7, 2, 9, 1, 8))

    // 5. 字符串拼接
    fmt.Println("join:", join("/", "api", "v1", "users", "list"))
}

二、方法声明与接收器

2.1 什么是方法?

在 Go 中,方法是绑定到特定类型上的函数。和普通函数相比,方法多了一个"接收器"参数,写在 func 关键字和函数名之间:

type Rectangle struct {
    Width, Height float64
}

// 这是 Rectangle 类型的方法
func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

调用方式和大多数语言一样,用点号:

rect := Rectangle{Width: 10, Height: 5}
fmt.Println(rect.Area()) // 50

可以把接收器 r 理解成"方法的第一个参数"——它就代表了"当前正在操作的那个对象"。Go 只是用一种特殊的语法把它单独提到了前面。

2.2 可以为哪些类型定义方法?

Go 的方法比很多语言更灵活——不只是结构体,任何在同一包内定义的命名类型都可以有方法

type Celsius float64    // 自定义数值类型
type MySlice []string   // 基于切片的类型
type Counter int        // 基于整数的计数器

func (c Celsius) String() string {
    return fmt.Sprintf("%.1f°C", float64(c))
}

func (s MySlice) Longest() string {
    longest := ""
    for _, v := range s {
        if len(v) > len(longest) {
            longest = v
        }
    }
    return longest
}

func (c *Counter) Increment() {
    *c++
}

唯一限制:不能给其他包定义的类型加方法(比如不能给 int[]string 直接加方法,必须先用 type 包装一层)。

2.3 值接收器 vs 指针接收器 —— 核心区别

这是 Go 方法体系中最关键的决策点。

值接收器:方法内部拿到的是调用对象的副本,修改不影响原对象。

func (r Rectangle) Scale(factor float64) {
    r.Width *= factor
    r.Height *= factor
    // 改的只是副本,函数结束后原对象不变
}

指针接收器:方法内部拿到的是指向原对象的指针,可以修改原对象。

func (r *Rectangle) Scale(factor float64) {
    r.Width *= factor
    r.Height *= factor
    // 直接修改原对象
}

对比效果:

rect := Rectangle{Width: 10, Height: 5}

rect.Scale(2)                          // 值接收器:原对象不变
fmt.Println(rect)                      // {10 5}

(&rect).Scale(2)                       // 指针接收器:原对象被修改
fmt.Println(rect)                      // {20 10}

2.4 Go 的自动取址/解引用

Go 编译器在方法调用时会自动做取址和解引用的转换,让代码更简洁:

rect := Rectangle{Width: 10, Height: 5}

// rect 是值,但指针接收器的方法也能调用
// 编译器自动转换为 (&rect).Scale(2)
rect.Scale(2) // ✅ 等价于 (&rect).Scale(2)

p := &rect
// p 是指针,但值接收器的方法也能调用
// 编译器自动转换为 (*p).Area()
p.Area() // ✅ 等价于 (*p).Area()

这个特性让代码写起来很自然,但理解底层的转换规则很重要。

2.5 选择值还是指针?三条实用准则

  1. 需要修改接收器状态 → 必须用指针接收器
  2. 接收器是大结构体 → 用指针接收器(避免每次调用都拷贝整个结构体)
  3. 接收器是小而不可变的值(如数值类型、小结构体)→ 用值接收器(更简单,没有指针追踪开销)

一致性原则:一个类型的方法集尽量统一用值或统一用指针,混用容易导致接口实现上的问题(下一节会详细讲)。

2.6 完整练习代码

package main

import "fmt"

// ---- 自定义类型的方法 ----
type Celsius float64

func (c Celsius) String() string {
    return fmt.Sprintf("%.1f°C", float64(c))
}

func (c Celsius) ToFahrenheit() float64 {
    return float64(c)*9/5 + 32
}

// ---- 结构体方法:值接收器 ----
type Point struct {
    X, Y float64
}

func (p Point) DistanceTo(other Point) float64 {
    dx := p.X - other.X
    dy := p.Y - other.Y
    return dx*dx + dy*dy // 返回距离的平方(省去开方)
}

// ---- 结构体方法:指针接收器(可修改) ----
type Counter struct {
    Value int
}

func (c *Counter) Increment() {
    c.Value++
}

func (c *Counter) Reset() {
    c.Value = 0
}

// 值接收器:不修改原对象
func (c Counter) Double() Counter {
    return Counter{Value: c.Value * 2}
}

// ---- 银行账户:指针接收器实战 ----
type Account struct {
    Owner   string
    Balance float64
}

func (a *Account) Deposit(amount float64) {
    a.Balance += amount
    fmt.Printf("  存入 %.2f,余额: %.2f\n", amount, a.Balance)
}

func (a *Account) Withdraw(amount float64) error {
    if amount > a.Balance {
        return fmt.Errorf("余额不足: 需要 %.2f,仅有 %.2f", amount, a.Balance)
    }
    a.Balance -= amount
    fmt.Printf("  取出 %.2f,余额: %.2f\n", amount, a.Balance)
    return nil
}

func (a Account) Info() string {
    return fmt.Sprintf("%s 的账户余额: %.2f 元", a.Owner, a.Balance)
}

func main() {
    // 1. 自定义类型方法
    temp := Celsius(36.5)
    fmt.Println("体温:", temp.String())
    fmt.Println("华氏:", temp.ToFahrenheit())

    // 2. 值接收器:距离计算
    p1 := Point{X: 0, Y: 0}
    p2 := Point{X: 3, Y: 4}
    fmt.Printf("距离平方: %.2f\n", p1.DistanceTo(p2))

    // 3. 指针接收器:可修改
    c := Counter{Value: 0}
    c.Increment()
    c.Increment()
    c.Increment()
    fmt.Println("计数器:", c.Value)

    doubled := c.Double()
    fmt.Println("翻倍后(新对象):", doubled.Value, "原对象不变:", c.Value)

    c.Reset()
    fmt.Println("重置后:", c.Value)

    // 4. 银行账户实战
    acc := &Account{Owner: "张三", Balance: 1000}
    fmt.Println(acc.Info())

    acc.Deposit(500)
    acc.Deposit(200)

    if err := acc.Withdraw(300); err != nil {
        fmt.Println("取款失败:", err)
    }

    if err := acc.Withdraw(99999); err != nil {
        fmt.Println("取款失败:", err)
    }

    fmt.Println(acc.Info())
}

三、接口定义与实现

3.1 接口是什么?

接口是一组方法签名的集合。它定义了"能做什么",但不关心"谁来做"。

Go 的接口最独特的地方在于:实现是隐式的。一个类型只要实现了接口中定义的所有方法,就自动满足了这个接口——不需要 implements 关键字,不需要显式声明。

// 定义接口
type Animal interface {
    Sound() string
}

// Dog 实现了 Sound() 方法,自动满足 Animal 接口
type Dog struct{}

func (d Dog) Sound() string {
    return "汪汪汪"
}

// Cat 也实现了 Sound(),也满足 Animal 接口
type Cat struct{}

func (c Cat) Sound() string {
    return "喵喵喵"
}

这种"如果它走起来像鸭子、叫起来像鸭子,那它就是鸭子"的思路,就是经典的鸭子类型。Go 把它做成了静态类型检查——编译期就能确认接口是否被满足,而不是像 Python 那样到运行时才报错。

3.2 接口的使用

接口变量可以持有任何实现了该接口的值:

func makeSound(a Animal) {
    fmt.Println(a.Sound())
}

func main() {
    makeSound(Dog{}) // 汪汪汪
    makeSound(Cat{}) // 喵喵喵
}

makeSound 函数不关心传入的是 Dog 还是 Cat,它只要求参数满足 Animal 接口。这就是面向接口编程的核心。

3.3 接口组合

Go 接口可以组合其他接口,形成更大的接口。标准库中 io.ReadWriter 就是 io.Readerio.Writer 的组合:

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

// 组合接口
type ReadWriter interface {
    Reader
    Writer
}

一个类型要满足 ReadWriter,必须同时实现 ReadWrite 两个方法。

3.4 空接口 interface{} / any

没有任何方法的接口叫做空接口,Go 1.18 之后可以用 any 作为 interface{} 的别名。因为任何类型都"实现了零个方法",所以空接口可以持有任何值

func printAny(v any) {
    fmt.Println(v)
}

printAny(42)
printAny("hello")
printAny([]int{1, 2, 3})
printAny(struct{ X int }{X: 10})

空接口是 Go 泛型出现之前处理"任意类型"的主要手段。fmt.Printlnjson.Marshal 等标准库函数大量使用空接口。

3.5 类型断言与类型分支

从接口值中取出底层的具体类型,需要用类型断言:

var a Animal = Dog{}

dog, ok := a.(Dog) // 带检查的类型断言
if ok {
    fmt.Println("这是一只狗")
}

cat, ok := a.(Cat) // ok 为 false
if !ok {
    fmt.Println("这不是猫")
}

当需要针对多种类型做不同处理时,用类型分支(type switch):

func describe(a Animal) {
    switch v := a.(type) {
    case Dog:
        fmt.Println("狗说:", v.Sound())
    case Cat:
        fmt.Println("猫说:", v.Sound())
    default:
        fmt.Println("未知动物:", v.Sound())
    }
}

3.6 接口与指针接收器的方法集规则

这是一个经常踩坑的点:

  • 值类型的方法集 = 该类型用值接收器定义的所有方法
  • 指针类型的方法集 = 该类型用值接收器和指针接收器定义的所有方法

换句话说:

type Speaker interface {
    Speak()
}

type Person struct{}

func (p Person) Speak()    {} // 值接收器
func (p *Person) Shout()   {} // 指针接收器

var s Speaker

p := Person{}
s = p  // ✅ Person 值有 Speak(),满足 Speaker

pp := &Person{}
s = pp // ✅ *Person 也有 Speak()(自动解引用),满足 Speaker

但如果接口要求的方法只有指针接收器版本:

type Yeller interface {
    Shout()
}

var y Yeller
y = Person{}  // ❌ Person 值没有 Shout(),不满足 Yeller
y = &Person{} // ✅ *Person 有 Shout()

口诀:指针类型能访问值接收器和指针接收器的方法;值类型只能访问值接收器的方法。

3.7 接口的实际价值

接口最大的价值不在于"定义抽象",而在于解耦。看一个经典例子——日志系统:

// 接口:定义日志写入的契约
type Logger interface {
    Log(msg string)
}

// 实现1:控制台日志
type ConsoleLogger struct{}

func (c ConsoleLogger) Log(msg string) {
    fmt.Println("[CONSOLE]", msg)
}

// 实现2:带前缀的文件日志(模拟)
type FileLogger struct {
    Prefix string
}

func (f FileLogger) Log(msg string) {
    fmt.Printf("[FILE:%s] %s\n", f.Prefix, msg)
}

// 业务代码只依赖接口,不依赖具体实现
type UserService struct {
    logger Logger
}

func (s *UserService) CreateUser(name string) {
    s.logger.Log("创建用户: " + name)
    // ... 创建用户的业务逻辑 ...
}

UserService 不关心日志是写到控制台还是文件,它只依赖 Logger 接口。这种设计让代码可测试(测试时传入 mock logger)、可扩展(新增日志实现不需要改业务代码)。

3.8 完整练习代码

package main

import "fmt"

// ---- 接口定义 ----
type Shape interface {
    Area() float64
    Perimeter() float64
}

// ---- 实现1:矩形 ----
type Rectangle struct {
    Width, Height float64
}

func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

func (r Rectangle) Perimeter() float64 {
    return 2 * (r.Width + r.Height)
}

// ---- 实现2:圆形 ----
type Circle struct {
    Radius float64
}

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

func (c Circle) Perimeter() float64 {
    return 2 * 3.14159 * c.Radius
}

// ---- 实现3:三角形 ----
type Triangle struct {
    A, B, C float64 // 三条边
}

func (t Triangle) Area() float64 {
    // 海伦公式
    s := (t.A + t.B + t.C) / 2
    return sqrt(s * (s - t.A) * (s - t.B) * (s - t.C))
}

func (t Triangle) Perimeter() float64 {
    return t.A + t.B + t.C
}

// 手写平方根(不依赖 math 包,纯练习)
func sqrt(x float64) float64 {
    if x <= 0 {
        return 0
    }
    z := x / 2
    for i := 0; i < 20; i++ {
        z = z - (z*z-x)/(2*z)
    }
    return z
}

// ---- 面向接口编程 ----
func printShapeInfo(name string, s Shape) {
    fmt.Printf("%s -> 面积: %.2f, 周长: %.2f\n", name, s.Area(), s.Perimeter())
}

func totalArea(shapes ...Shape) float64 {
    total := 0.0
    for _, s := range shapes {
        total += s.Area()
    }
    return total
}

// ---- 接口组合 ----
type Describer interface {
    Describe() string
}

// ShapeDescriber 组合了 Shape 和 Describer
type ShapeDescriber interface {
    Shape
    Describer
}

// Rectangle 实现 Describe
func (r Rectangle) Describe() string {
    return fmt.Sprintf("矩形(宽=%.1f, 高=%.1f)", r.Width, r.Height)
}

// ---- 类型断言与 type switch ----
func classifyShape(s Shape) string {
    switch v := s.(type) {
    case Rectangle:
        return "这是一个矩形: " + v.Describe()
    case Circle:
        return fmt.Sprintf("这是一个圆形(半径=%.1f)", v.Radius)
    case Triangle:
        return fmt.Sprintf("这是一个三角形(边=%.1f,%.1f,%.1f)", v.A, v.B, v.C)
    default:
        return "未知形状"
    }
}

func main() {
    // 1. 多态:不同形状统一处理
    rect := Rectangle{Width: 10, Height: 5}
    circle := Circle{Radius: 3}
    triangle := Triangle{A: 3, B: 4, C: 5}

    printShapeInfo("矩形", rect)
    printShapeInfo("圆形", circle)
    printShapeInfo("三角形", triangle)

    // 2. 接口切片 + 可变参数
    fmt.Printf("总面积: %.2f\n", totalArea(rect, circle, triangle))

    // 3. 接口组合
    var sd ShapeDescriber = rect
    fmt.Println("描述:", sd.Describe())
    fmt.Println("面积:", sd.Area())

    // 4. 类型断言
    var s Shape = circle
    if c, ok := s.(Circle); ok {
        fmt.Printf("断言成功,半径=%.1f\n", c.Radius)
    }

    // 5. type switch
    shapes := []Shape{rect, circle, triangle}
    for _, sh := range shapes {
        fmt.Println(classifyShape(sh))
    }
}

四、知识要点速查表

知识点 核心要点 易错点
可变参数 ...T 底层是切片 []T 传切片要用 slice... 展开
... 展开 把切片拆成可变参数 展开后底层数组可能共享
方法接收器 func (r T) Method() / func (r *T) Method() 值接收器拿到副本,改不了原对象
值 vs 指针接收器 需修改/大对象→指针;小不可变→值 一个类型的方法集尽量统一
自动取址/解引用 rect.Scale()(&rect).Scale() 等价 指针接收器的方法集更"大"
接口隐式实现 实现所有方法即满足接口 implements 关键字
方法集规则 值类型只有值接收器方法;指针类型两者都有 值类型可能不满足需要指针方法的接口
空接口 any 可持有任何值 取出具体值要类型断言
类型断言 v, ok := i.(T) 不带 ok 的断言失败会 panic
type switch switch v := i.(type) 每个分支的 v 是不同类型

五、学习心得

  1. 可变参数的本质是语法糖:理解了它底层就是切片,所有行为都可以用切片知识推导。append 本身就是最好的可变参数范例。

  2. 方法接收器的选择是设计决策:不是技术问题,而是"这个方法是否应该修改对象"的设计问题。一旦想清楚这个问题,值还是指针的选择就自然了。

  3. Go 的接口哲学是"消费者定义":很多语言是"生产者声明我实现了什么接口",Go 则是"消费者需要什么接口就定义什么,生产者不用管"。这意味着你可以为已有类型定义新接口,完全不需要修改原类型代码——这就是 Go 接口的强大之处。


posted @ 2026-07-24 09:30  FfHUCisI  阅读(2)  评论(0)    收藏  举报