Golang中零大小类型指针的隐患与解决方案

Golang中零大小类型指针的隐患与解决方案 大家好,

假设你正在编写一个将内部错误转换为公共API错误的翻译函数。在第一个版本中,当无需翻译时,你返回 nil。你做了一个简单的改动——返回原始错误而非 nil——然后你的程序突然表现不同了:

translate1: unsupported operation
translate2: internal not implemented

这两个几乎完全相同的函数产生了不同的结果(Go Playground)。你猜猜是为什么?

type NotImplementedError struct{}

func (*NotImplementedError) Error() string {
	return "internal not implemented"
}

func Translate1(err error) error {
	if (err == &NotImplementedError{}) {
		return errors.ErrUnsupported
	}

	return nil
}

func Translate2(err error) error {
	if (err == &NotImplementedError{}) {
		return nil
	}

	return err
}

func main() {
	fmt.Printf("translate1: %v\n", Translate1(DoWork()))
	fmt.Printf("translate2: %v\n", Translate2(DoWork()))
}

func DoWork() error {
	return &NotImplementedError{}
}

我想分享一篇深度解析的博客文章,《零大小类型之地的指针陷阱》,以及一个配套的新静态分析工具 zerolint

博客:《零大小类型之地的指针陷阱》

仓库:fillmore-labs.com/zerolint

我在这里看到过帖子 零宽度类型的指针相等性行为趣谈指向空结构体的指针的不一致行为,但它们已经关闭了。

你在自己的项目中遇到过这个问题吗?也许你认为这是一个特性,而不是一个漏洞?


更多关于Golang中零大小类型指针的隐患与解决方案的实战教程也可以访问 https://www.itying.com/category-94-b0.html

4 回复

你好。我认为即使是在你的博客中,你也遗漏了一个重要的细节。在这种情况下,不应该使用 errors.Is,为此目的应该使用 errors.As

更多关于Golang中零大小类型指针的隐患与解决方案的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


我们生活在一个一切皆有可能、并且很可能在某个时刻被滥用的世界。Go语言从来就不是为了解决内存哲学问题而设计的。对于稍后会被垃圾回收的东西来说,8个字节仍然微不足道。

errors.As 是一种在不得不使用指针时的变通方案,但你仍然会为了极少的信息浪费8个字节(在64位机器上)。

此外,你还面临一种风险,即有人可能误用你的API,仍然使用 errors.Is

			conn.SendDisconnectedTelemetry()
			if err != nil {
				if exitErr := (&gossh.ExitError{}); errors.As(err, &exitErr) {
					// 清除错误,因为除了报告状态外它没有其他用处。
					return ExitError(exitErr.ExitStatus(), nil)
				}
				// 如果连接意外断开,我们会得到一个 ExitMissingError 但没有其他错误详情,所以至少尝试给用户一个更好的消息
				if errors.Is(err, &gossh.ExitMissingError{}) {
					return ExitError(255, xerrors.New("SSH 连接意外结束"))
				}
				return xerrors.Errorf("会话结束: %w", err)
			}

			return nil
		},
	}
	waitOption := serpent.Option{
		Flag:        "wait",

这是一个典型的零大小类型指针相等性问题。问题出现在 err == &NotImplementedError{} 这个比较上。

问题分析:

NotImplementedError 是空结构体,大小为0。在Go中,零大小类型的所有实例在内存中可能共享相同的地址。当你使用 &NotImplementedError{} 创建新实例时,每次调用都可能返回相同的地址。

示例演示:

package main

import (
	"fmt"
)

type NotImplementedError struct{}

func main() {
	// 演示零大小类型的指针行为
	p1 := &NotImplementedError{}
	p2 := &NotImplementedError{}
	p3 := &NotImplementedError{}
	
	fmt.Printf("p1: %p\n", p1)
	fmt.Printf("p2: %p\n", p2)
	fmt.Printf("p3: %p\n", p3)
	fmt.Printf("p1 == p2: %v\n", p1 == p2)
	fmt.Printf("p1 == p3: %v\n", p1 == p3)
	
	// 正确的比较方式
	var err error = &NotImplementedError{}
	
	// 方法1:类型断言
	if _, ok := err.(*NotImplementedError); ok {
		fmt.Println("Type assertion: true")
	}
	
	// 方法2:使用errors.Is(需要实现Is方法)
	if err == (*NotImplementedError)(nil) {
		fmt.Println("Direct comparison with nil conversion")
	}
}

解决方案:

// 方案1:使用类型断言
func Translate1(err error) error {
	if _, ok := err.(*NotImplementedError); ok {
		return errors.ErrUnsupported
	}
	return nil
}

// 方案2:实现Is方法,使用errors.Is
type NotImplementedError struct{}

func (e *NotImplementedError) Error() string {
	return "internal not implemented"
}

func (e *NotImplementedError) Is(target error) bool {
	_, ok := target.(*NotImplementedError)
	return ok
}

func Translate2(err error) error {
	if errors.Is(err, &NotImplementedError{}) {
		return nil
	}
	return err
}

// 方案3:使用哨兵错误
var ErrNotImplemented = &NotImplementedError{}

func DoWork() error {
	return ErrNotImplemented
}

func Translate3(err error) error {
	if err == ErrNotImplemented {
		return errors.ErrUnsupported
	}
	return nil
}

根本原因:

Go编译器对零大小类型有特殊优化。根据Go语言规范,零大小类型的值可能共享相同的内存地址。这意味着 &NotImplementedError{} 每次可能返回相同的指针值,但这并不是保证的行为,依赖于具体实现。

实际影响:

// 这个比较是不可靠的
if err == &NotImplementedError{} {
	// 可能为true,也可能为false
}

// 应该使用
if _, ok := err.(*NotImplementedError); ok {
	// 可靠的类型检查
}

这个问题在涉及错误处理、接口比较和零大小类型时特别容易出现。使用类型断言或实现 Is() 方法配合 errors.Is() 是标准的解决方案。

回到顶部