详解Go语言如何实现类似Python中的with上下文管理器

 更新时间:2023年07月03日 10:01:45   作者:江湖十年  
熟悉 Python 的同学应该知道 Python 中的上下文管理器非常好用,那么在 Go 中是否也能实现上下文管理器呢,下面小编就来和大家仔细讲讲吧

熟悉 Python 的同学应该知道 Python 中的上下文管理器非常好用,在对数据库进行读写、访问文件等操作时,上下文管理器能够确保资源在使用后得到释放。在 Go 中是否也能实现上下文管理器呢?这便是本文所要探讨的话题。

Python 上下文管理器

以操作文件为例,为了保证操作文件完成后资源能被正确关闭,在 Python 中我们可以编写出如下代码:

try:
    f = open('foo.txt', 'r')
    print(f.readlines())
finally:
    f.close()

不过这种写法显然不够 Pythonic,Python 在语法层面提供了 with 语句实现上下文管理,用法如下:

with open('foo.txt', 'r') as f:
    print(f.readlines())

这段使用 with 语句实现的代码,才更符合 Python 哲学。

如果你对 Python with 语法不熟悉,可以参阅我的文章《Python 上下文管理器实现》

Go 中资源释放问题

我们知道,在 Go 语言中访问数据库、文件等资源时,可以使用 defer 语句完成资源释放操作。

如下定义一个 ReadFile 函数用来读取文件:

func ReadFile(paths []string) error {
	for _, path := range paths {
		file, err := os.Open(path)
		if err != nil {
			return err
		}
		defer file.Close()
		content, err := io.ReadAll(file)
		if err != nil {
			return err
		}
		fmt.Printf("%s content: %s\n", file.Name(), content)
	}
	return nil
}

这个函数使用循环遍历传进来的文件路径列表,依次打开文件并输出文件内容。

为了保证即使在遇到错误时,资源也能够被释放,我们往往会使用 defer file.Close() 来关闭文件。

不过,这段代码其实是存在问题的,我们知道 defer 的调用实际上并不会立即执行,而是等到函数退出时才会执行。

所以,代码中的 defer 调用并不会在本轮循环中处理完当前文件时被执行,而是直到所有循环执行完成,函数退出时才会执行。

我们可以对以上示例稍作修改,来验证下这个问题:

func ReadFile(paths []string) error {
	for _, path := range paths {
		file, err := os.Open(path)
		if err != nil {
			return err
		}
		defer func() {
			file.Close()
			fmt.Printf("close %s\n", file.Name())
		}()
		content, err := io.ReadAll(file)
		if err != nil {
			return err
		}
		fmt.Printf("%s content: %s\n", file.Name(), content)
	}
	return nil
}

我们将原来的 defer 语句改成:

defer func() {
    file.Close()
    fmt.Printf("close %s\n", file.Name())
}()

以此来显示 defer 调用时机。

针对以上示例,我们使用如下代码来调用:

func main() {
	err := ReadFile([]string{"foo.txt", "bar.txt"})
	fmt.Printf("ReadFile err: %v\n", err)
}

注意:foo.txtbar.txt 两个文件我已经提前准备好了,foo.txt 文件内容为 foobar.txt 文件内容为 bar

执行以上示例,得到如下输出:

$ go run main.go
foo.txt content: foo
bar.txt content: bar
close bar.txt
close foo.txt
ReadFile err: <nil>

根据输出内容可以验证,defer 语句的调用,的确在 for 循环退出以后才开始执行。

如果打开资源过多,而没有及时关闭,势必会造成资源的浪费,甚至因此而意外终止程序。

所以切记,不要在循环中使用 defer

我们可以使用匿名函数来解决这个问题:

func ReadFile(paths []string) error {
	for _, path := range paths {
		file, err := os.Open(path)
		if err != nil {
			return err
		}
		err = func() error {
			defer func() {
				file.Close()
				fmt.Printf("close %s\n", file.Name())
			}()
			content, err := io.ReadAll(file)
			if err != nil {
				return err
			}
			fmt.Printf("%s content: %s\n", file.Name(), content)
			return nil
		}()
		if err != nil {
			return err
		}
	}
	return nil
}

现在,将 defer 语句放入到一个立即执行的匿名函数中,就可以解决问题了。

执行以上示例,得到如下输出:

$ go run main.go
foo.txt content: foo
close foo.txt
bar.txt content: bar
close bar.txt
ReadFile err: <nil>

可以发现,现在 defer 语句不再是等到 for 循环退出才会执行,而是在匿名函数退出时即可执行。

这样,就达到了在本轮循环中尽早释放不再使用的文件资源的目的。

此外,为了代码的可读性,我们可以将匿名函数提取出来,单独封装一个函数:

func ReadFile(paths []string) error {
	for _, path := range paths {
		file, err := os.Open(path)
		if err != nil {
			return err
		}
		err = processFile(file)
		if err != nil {
			return err
		}
	}
	return nil
}
func processFile(file *os.File) error {
	defer func() {
		file.Close()
		fmt.Printf("close %s\n", file.Name())
	}()
	content, err := io.ReadAll(file)
	if err != nil {
		return err
	}
	fmt.Printf("%s content: %s\n", file.Name(), content)
	return nil
}

processFile 函数专门用来处理打开的文件,ReadFile 函数可读性也得到了提高。

执行以上示例,得到如下输出:

go run main.go
foo.txt content: foo
close foo.txt
bar.txt content: bar
close bar.txt
ReadFile err: <nil>

这个输出符合预期。

以上我们介绍了两种方式,能够解决 defer 语句延迟调用的问题。

在 Go 中实现上下文管理器

最近为了写《Go 语言中 database/sql 是如何设计的》一文,我阅读了下 database/sql 的源码。在这个过程中,*sql.DB.queryDC 方法中一小段代码激起了我的兴趣:

func (db *DB) queryDC(ctx, txctx context.Context, dc *driverConn, releaseConn func(error), query string, args []any) (*Rows, error) {
	...
	if ok {
		var nvdargs []driver.NamedValue
		var rowsi driver.Rows
		var err error
		withLock(dc, func() {
			nvdargs, err = driverArgsConnLocked(dc.ci, nil, args)
			if err != nil {
				return
			}
			rowsi, err = ctxDriverQuery(ctx, queryerCtx, queryer, query, nvdargs)
		})
		...
	}
	...
}

*sql.DB.queryDC 方法中有一个 withLock 函数的调用,withLock 函数定义如下:

func withLock(lk sync.Locker, fn func()) {
	lk.Lock()
	defer lk.Unlock()
	fn()
}

当看到 withLock 函数定义时,我瞬间就想到了 Python 中的 with 上下文管理器。

withLock 接收一个 sync.Locker 接口,定义如下:

type Locker interface {
	Lock()
	Unlock()
}

它只有两个方法,加锁和释放锁。

withLock 能够用于所有实现 sync.Locker 接口的对象,在执行 fn() 前加锁,执行之后释放锁。

这与 Python 的上下文管理器功能如出一辙,就是这么一个只有三行的小函数,实现却相当精妙,真可谓短小精悍。

于是,参考 withLock 函数实现,解决 for 循环中defer 语句延迟调用的问题,就有了第三种解法。

我们可以模仿 withLock 实现一个 WithClose 函数:

func WithClose(closer io.Closer, fn func()) {
	defer func() {
		closer.Close()
		fmt.Printf("close %s\n", closer.(*os.File).Name())
	}()
	fn()
}

WithClose 接收一个 io.Closer 接口,定义如下:

type Closer interface {
	Close() error
}

我们可以在执行 fn() 函数之前,使用 defer 语句来调用 io.CloserClose 方法释放资源。

现在,我们可以在 ReadFile 函数中使用这个小函数了:

func ReadFile(paths []string) error {
	for _, path := range paths {
		file, err := os.Open(path)
		if err != nil {
			return err
		}
		WithClose(file, func() {
			var content []byte
			content, err = io.ReadAll(file)
			if err != nil {
				return
			}
			fmt.Printf("%s content: %s\n", file.Name(), content)
		})
		if err != nil {
			return err
		}
	}
	return nil
}

这个用法同 *sql.DB.queryDC 中调用 withLock 函数一样,并且因为闭包的存在,我们可以拿到 WithClose 内部执行的 fn() 函数所产生的错误对象。

执行以上示例,得到如下输出:

$ go run main.go
foo.txt content: foo
close foo.txt
bar.txt content: bar
close bar.txt
ReadFile err: <nil>

这个输出依然符合预期。

我们可以测试下遇到错误的情况,修改 main 函数,调用 ReadFile 时最后传入一个不存在的文件 baz.txt

func main() {
	err := ReadFile([]string{"foo.txt", "bar.txt", "baz.txt"})
	fmt.Printf("ReadFile err: %v\n", err)
}

执行以上示例,得到如下输出:

$ go run main.go
foo.txt content: foo
close foo.txt
bar.txt content: bar
close bar.txt
ReadFile err: open baz.txt: no such file or directory

遇到错误能够被正常捕获。

现在,我们就在 Go 中实现类了似 Python 中的 with 上下文管理器,为解决 for 循环中defer 语句延迟调用的问题提供了新思路。

总结

本文灵感来自于 database/sql 源码中的一小段代码,为大家讲解了如何在 Go 中实现类似 Python 中的 with 上下文管理器。

切记,不要在循环中使用 defer。为了解决这个问题,我们可以使用匿名函数、函数封装以及 WithClose 三种方案。

希望此文能对你有所帮助。

P.S.

database/sql 源码中的这一小段代码,找回了我在开始用 Go 作为主力语言后,很久没有在编程语言语法层面上体会过快感。相较于我最近写的几篇长篇大论型文章,本文显得微不足道,但我还是很乐于为这一小段代码写一篇文章分享出来,毕竟这久违的感觉又回来了。

从把 Go 作为主力编程语言开始,写代码的思路都是“平铺直叙”,很少思考怎么写出更加优雅且有趣的代码。尽管我也分享过几篇 Go 编程模式的文章,但相较于用 Python 作为主力编程语言时,还是少了很多“花哨”的小技巧在里面,更多的是遵循套路的样板代码。

尽管 Go 语言的哲学更适合工程化,但 Go 代码写多了,有时不免会略感乏味,怀念 Python 的灵活。我无意于讨论哪种编程语言的好坏,只是,愿在编程的道路上,你我都能找到属于自己的乐趣所在。

以上就是详解Go语言如何实现类似Python中的with上下文管理器的详细内容,更多关于Go语言上下文管理器的资料请关注脚本之家其它相关文章!

相关文章

  • Golang配置管理库 Viper的教程详解

    Golang配置管理库 Viper的教程详解

    这篇文章主要介绍了Golang 配置管理库 Viper,使用 viper 能够很好的去管理你的配置文件信息,比如数据库的账号密码,服务器监听的端口,你可以通过更改配置文件去更改这些内容,而不用定位到那一段代码上去,提高了开发效率,需要的朋友可以参考下
    2022-05-05
  • Golang 限流器的使用和实现示例

    Golang 限流器的使用和实现示例

    这篇文章主要介绍了Golang 限流器的使用和实现示例,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2020-06-06
  • golang读写分离sync.Map的使用

    golang读写分离sync.Map的使用

    sync.Map是Go语言的并发安全映射,通过读写分离优化读性能,本文主要介绍了golang读写分离sync.Map的使用,具有一定的参考价值,感兴趣的可以了解一下
    2025-05-05
  • 浅谈go-restful框架的使用和实现

    浅谈go-restful框架的使用和实现

    这篇文章主要介绍了浅谈go-restful框架的使用和实现,小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2018-03-03
  • golang 交叉编译C++ dll配置文件的实现

    golang 交叉编译C++ dll配置文件的实现

    本文探讨了在64位环境下调用32位C++ DLL的的实现方法,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2025-07-07
  • golang之判断元素是否在数组内问题

    golang之判断元素是否在数组内问题

    这篇文章主要介绍了golang之判断元素是否在数组内问题,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教
    2020-12-12
  • 一文秒懂Go 编写命令行工具的代码

    一文秒懂Go 编写命令行工具的代码

    这篇文章主要介绍了一文秒懂Go 编写命令行工具的代码,本文给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下
    2021-01-01
  • Go JSON中序列化大整数精度丢失的问题分析

    Go JSON中序列化大整数精度丢失的问题分析

    当存储或传输 大整数(int64) 时,往往会出现精度丢失的问题,本文通过一个示例来详细分析原因,并给出解决方案,文中的示例代码讲解详细,感兴趣的小伙伴可以了解下
    2026-01-01
  • go 异常处理panic和recover的简单实践

    go 异常处理panic和recover的简单实践

    在Go语言中,异常处理主要通过panic和recover这两个内建函数来实现,本文主要介绍了go异常处理panic和recover的简单实践,具有一定的参考价值,感兴趣的可以了解一下
    2025-04-04
  • Go语言题解LeetCode下一个更大元素示例详解

    Go语言题解LeetCode下一个更大元素示例详解

    这篇文章主要为大家介绍了Go语言题解LeetCode下一个更大元素示例详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-12-12

最新评论