欢迎,来自IP地址为:216.73.216.157 的朋友


如果 有过 JavaScript、Python 或者 Rust 的编程经验,那么对于闭包这个概念并不会陌生。虽然这个概念对于每种编程语言来说有些细微差异,但是核心理念是一致的:闭包是一个能访问其定义时所在的作用域中的变量的函数。这使得函数能够”记住”它被创建的环境,即使它在该环境之外执行,这对如何编写和构建代码具有重要的意义。

在本文中,我们将探讨 Go 语言(一种以简洁高效著称的静态类型语言)中闭包的工作原理。我们将了解如何创建闭包、闭包如何捕获变量以及一些实际用例。

1. Go 中的闭包到底是什么

简单来说,Go 中的闭包是一个引用在其外部定义的变量的函数。这听起来可能很抽象,那么让我们从一个可以立即运行的示例开始:

package main

import "fmt"

func counter() func() int {
	n := 0
	return func() int {
		n++
		return n
	}
}

func main() {
	next := counter()
	fmt.Println(next()) // 1
	fmt.Println(next()) // 2
	fmt.Println(next()) // 3
}

当程序调用 counter() 函数时,会返回另一个函数,但这个函数会保持对于变量 n 的访问。即使是 counter() 函数执行完毕,变量 n 仍然不会消失。每次调用根据 counter() 函数生成的 next() 函数时,都会访问同一个变量 n,也就是初次调用时生成的那个。

这正是闭包的定义属性:

闭包”封闭”其环境,只要闭包本身存在,它所需的变量就会一直保持活动状态。

2. Go 如何实现闭包

通常,Go 中的局部变量存储于栈中,函数返回时栈会被清除。

但是,如果嵌套函数需要继续使用其中一个变量,Go 的编译器会执行所谓的逃逸分析:它会发现该变量的存活时间比函数调用的时间长,因此会将该变量移至堆,只要有引用它(在本例中是闭包),它就可以一直存活。

我们可以让编译器演示这个过程:

go build -gcflags="-m" main.go

示例输出如下:

从图中可以看出,Go 将变量 n 从栈移至堆中,稍后的闭包就可以安全的使用它。

多个独立闭包

每次调用返回闭包的函数都会创建一个新的、独立的环境:

a := counter()
b := counter()
fmt.Println(a()) // 1
fmt.Println(a()) // 2
fmt.Println(b()) // 1

这里,a 和 b 是两个独立的闭包,每个闭包都有各自的 n。调用 a() 会递增其自身的 n,而调用 b() 则会从其各自的 n 开始。

3. Go 创建闭包

在 Go 中创建闭包的方法有很多种。让我们来探讨一些常见的模式。

从函数返回闭包

最常见的模式是让函数返回一个闭包,该闭包维护自身的状态:

package main

import "fmt"

func makeCounter() func() int {
	n := 0
	return func() int {
		n++
		return n
	}
}

func main() {
	c1 := makeCounter()
	fmt.Println(c1()) // 1
	fmt.Println(c1()) // 2
}

每次调用 makeCounter() 函数都会创建一个具有自己 n 的新闭包,正如我们之前看到的那样。

命名内部函数

为了提高代码可读性或便于调试,还可以给函数字面量命名:

func makeCounter() func() int {
    n := 0
    next := func incr() int {
        n++
        return n
    }
    return next
}

这种方法与匿名函数的工作方式相同,但它会给内部函数赋予一个名称(incr),这在堆栈跟踪中非常有用。除此之外,它的行为与匿名函数完全相同。

循环或 goroutine 中的内联闭包

闭包通常以内联方式定义,尤其是在循环或 goroutine 中:

for i := 0; i < 3; i++ {
    go func(x int) {
        fmt.Println(x)
    }(i)
}

在这里,我们将 i 作为参数传递给闭包,确保每个 goroutine 都获得自己的值副本,避免循环变量陷阱。

带参数的闭包

闭包可以接受自己的参数:

func adder(base int) func(int) int {
    return func(x int) int {
        return base + x
    }
}

add5 := adder(5)
fmt.Println(add5(10)) // 15

在这里,adder 返回一个闭包,该闭包会将一个固定的基值加到它接收到的任何参数上。

捕获多个变量

闭包可以捕获多个外部变量:

func multiplier(factor int) func(int) int {
    offset := 2
    return func(x int) int {
        return x*factor + offset
    }
}

m := multiplier(3)
fmt.Println(m(4)) // 14

在这个例子中,闭包从其周围范围捕获了 factor 和 offset — factor 是一个参数,而 offset 是一个局部变量。

结构体中的闭包

闭包也可以像其他函数值一样存储在结构体中。当需要具有动态或有状态行为的对象时,这是一种非常有用的模式。

type Counter struct {
    Next func() int
}

func NewCounter() Counter {
    n := 0
    return Counter{
        Next: func() int {
            n++
            return n
        },
    }
}

func main() {
    c := NewCounter()
    fmt.Println(c.Next()) // 1
    fmt.Println(c.Next()) // 2
}

这里,”Next”字段包含一个闭包,用于捕获变量 “n”。”Counter”的每个实例都有其独立的状态,无需单独的类型或互斥锁。

这种模式展示了闭包如何作为轻量级对象:将行为和状态捆绑在一起。

关于方法接收器的说明

Go 语言中的闭包不会像某些语言那样隐式地捕获方法接收器。如果希望闭包在方法内部使用接收器,通常需要将其赋值给一个局部变量:

type Counter struct {
    n int
}

func (c *Counter) MakeIncrementer() func() int {
    r := c // capture receiver explicitly
    return func() int {
        r.n++
        return r.n
    }
}

这样可以确保闭包引用的是预期的接收者,而不是引入意外行为。

与 JavaScript 或 Python 不同,Go 闭包捕获的是词法变量,而不是隐式的 “this” 或 “self”。

总结来说:

  • 闭包可以从函数返回,可以命名、内联,甚至可以存储在结构体中
  • 它们捕获外部变量,而不是其值的副本
  • 这样使用时,闭包可以替代小型类型或接口,实现轻量级封装

4. 闭包与并发

闭包在 Go 语言中功能强大,但当它们与并发结合使用时,如果不小心,它们捕获的变量可能会出现意想不到的行为。

跨 goroutine 的独立状态

每个闭包都会保持其捕获的变量处于活动状态,即使在并发 goroutine 中使用也是如此:

package main

import "fmt"

func makeWorker(start int) func() int {
	counter := start
	return func() int {
		counter++
		return counter
	}
}

func main() {
	worker1 := makeWorker(0)
	worker2 := makeWorker(100)

	go func() { fmt.Println(worker1()) }() // prints 1
	go func() { fmt.Println(worker2()) }() // prints 101
}

这里,worker1 和 worker2 拥有独立的计数器变量,因此它们互不干扰。每个闭包都维护着独立的状态,即使在不同的 goroutine 中也是如此。

安全地捕获共享变量

当多个闭包共享一个变量时,必须协调访问权限。例如:

package main

import "fmt"

func main() {
	counter := 0
	ch := make(chan int)

	for i := 0; i < 3; i++ {
		go func() {
			// increments a shared variable
			ch <- 1
		}()
	}

	// aggregate safely
	for i := 0; i < 3; i++ {
		counter += <-ch
	}
	fmt.Println(counter) // 3
}

闭包捕获了外部变量 ch(一个通道),这是安全的,因为通道会串行化访问。在这里使用缓冲通道不会改变闭包的行为:它仍然会捕获自身的 n 并将值独立地发送到通道。

闭包本身并不同步共享状态,但仍然需要通道或互斥锁。

Go 语言中的闭包允许函数携带状态、封装行为,并安全地与并发模式交互,同时保持代码的简洁性和表达力。通过理解闭包如何捕获变量、它们在循环和 goroutine 中的行为以及它们的内存影响,就可以自信地使用它们来编写更符合 Go 语言习惯且更易于维护的代码。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注