Go语言类型的本质

在声明一个新类型之后,声明一个该类型的方法之前,需要先回答一个问题:这个类型的本质是什么。如果给这个类型增加或者删除某个值,是要创建一个新值,还是要更改当前的值?如果是要创建一个新值,该类型的方法就使用值接收者。如果是要修改当前值,就使用指针接收者。

这个答案也会影响程序内部传递这个类型的值的方式:是按值做传递,还是按指针做传递。保持传递的一致性很重要。这个背后的原则是,不要只关注某个方法是如何处理这个值,而是要关注这个值的本质是什么。

内置类型

内置类型是由语言提供的一组类型。我们已经见过这些类型,分别是数值类型、字符串类型和布尔类型。这些类型本质上是原始的类型。因此,当对这些值进行增加或者删除的时候,会创建一个新值。

基于这个结论,当把这些类型的值传递给方法或者函数时,应该传递一个对应值的副本。让我们看一下标准库里使用这些内置类型的值的函数,如下面代码所示。

func Trim(s string, cutset string) string {
    if s == "" || cutset == "" {
        return s
    }
    return TrimFunc(s, makeCutsetFunc(cutset))
}

通过上面的代码可以看到标准库里 strings 包的 Trim 函数。Trim 函数传入一个 string 类型的值作操作,再传入一个 string 类型的值用于查找。之后函数会返回一个新的 string 值作为操作结果。这个函数对调用者原始的 string 值的一个副本做操作,并返回一个新的 string 值的副本。

字符串(string)就像整数、浮点数和布尔值一样,本质上是一种很原始的数据值,所以在函数或方法内外传递时,要传递字符串的一份副本。

让我们看一下体现内置类型具有的原始本质的第二个例子,如下面代码所示。

func isShellSpecialVar(c uint8) bool {
    switch c {
    case '*', '#', '$', '@', '!', '?', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
        return true
    }
    return false
}

上面的代码展示了 env 包里的 isShellSpecialVar 函数。这个函数传入了一个 int8 类型的值,并返回一个 bool 类型的值。注意,这里的参数没有使用指针来共享参数的值或者返回值。调用者传入了一个 uint8 值的副本,并接受一个返回值 true 或者 false。

引用类型

Go语言里的引用类型有如下几个:切片、映射、通道、接口和函数类型。当声明上述类型的变量时,创建的变量被称作标头(header)值。从技术细节上说,字符串也是一种引用类型。

每个引用类型创建的标头值是包含一个指向底层数据结构的指针。每个引用类型还包含一组独特的字段,用于管理底层数据结构。因为标头值是为复制而设计的,所以永远不需要共享一个引用类型的值。标头值里包含一个指针,因此通过复制来传递一个引用类型的值的副本,本质上就是在共享底层数据结构。

让我们看一下 net 包里的类型,代码如下所示。

type IP []byte

上面代码展示了一个名为 IP 的类型,这个类型被声明为字节切片。当要围绕相关的内置类型或者引用类型来声明用户定义的行为时,直接基于已有类型来声明用户定义的类型会很好用。编译器只允许为命名的用户定义的类型声明方法,代码如下所示。

func (ip IP) MarshalText() ([]byte, error) {
    if len(ip) == 0 {
        return []byte(""), nil
    }
    if len(ip) != IPv4len && len(ip) != IPv6len {
        return nil, errors.New("invalid IP address")
    }
    return []byte(ip.String()), nil
}

上述代码里定义的 MarshalText 方法是用 IP 类型的值接收者声明的。一个值接收者,正像预期的那样通过复制来传递引用,从而不需要通过指针来共享引用类型的值。这种传递方法也可以应用到函数或者方法的参数传递,代码如下所示。

// ipEmptyString 像ip.String 一样,
// 只不过在没有设置ip 时会返回一个空字符串
func ipEmptyString(ip IP) string {
    if len(ip) == 0 {
    return ""
    }
    return ip.String()
}

在上述代码中,有一个ipEmptyString 函数。这个函数需要传入一个IP 类型的值。再一次,你可以看到调用者传入的是这个引用类型的值,而不是通过引用共享给这个函数。调用者将引用类型的值的副本传入这个函数。这种方法也适用于函数的返回值。最后要说的是,引用类型的值在其他方面像原始的数据类型的值一样对待。

结构类型

结构类型可以用来描述一组数据值,这组值的本质即可以是原始的,也可以是非原始的。如果决定在某些东西需要删除或者添加某个结构类型的值时该结构类型的值不应该被更改,那么需要遵守之前提到的内置类型和引用类型的规范。让我们从标准库里的一个原始本质的类型的结构实现开始,代码如下所示。

type Time struct {
    // sec 给出自公元 1 年1 月1 日00:00:00
    // 开始的秒数
    sec int64

    // nsec 指定了一秒内的纳秒偏移,
    // 这个值是非零值,
    // 必须在[0, 999999999]范围内
    nsec int32

    // loc 指定了一个Location,
    // 用于决定该时间对应的当地的分、小时、
    // 天和年的值
    // 只有Time 的零值,其loc 的值是nil
    // 这种情况下,认为处于UTC 时区
    loc *Location
}

上述代码中的 Time 结构选自 time 包。当思考时间的值时,应该意识到给定的一个时间点的时间是不能修改的。所以标准库里也是这样实现 Time 类型的。让我们看一下 Now 函数是如何创建 Time 类型的值的,代码如下所示。

func Now() Time {
    sec, nsec := now()
    return Time{sec + unixToInternal, nsec, Local}
}

上述代码中的代码展示了 Now 函数的实现。这个函数创建了一个 Time 类型的值,并给调用者返回了 Time 值的副本。这个函数没有使用指针来共享 Time 值。之后,让我们来看一个 Time 类型的方法,代码如下所示。

func (t Time) Add(d Duration) Time {
    t.sec += int64(d / 1e9)
    nsec := int32(t.nsec) + int32(d%1e9)
    if nsec >= 1e9 {
        t.sec++
        nsec -= 1e9
    } else if nsec < 0 {
        t.sec--
        nsec += 1e9
    }
    t.nsec = nsec
    return t
}

上面代码中的 Add 方法是展示标准库如何将 Time 类型作为本质是原始的类型的绝佳例子。这个方法使用值接收者,并返回了一个新的 Time 值。该方法操作的是调用者传入的 Time 值的副本,并且给调用者返回了一个方法内的 Time 值的副本。

至于是使用返回的值替换原来的 Time 值,还是创建一个新的 Time 变量来保存结果,是由调用者决定的事情。

大多数情况下,结构类型的本质并不是原始的,而是非原始的。这种情况下,对这个类型的值做增加或者删除的操作应该更改值本身。当需要修改值本身时,在程序中其他地方,需要使用指针来共享这个值。让我们看一个由标准库中实现的具有非原始本质的结构类型的例子,代码如下所示。

// File 表示一个打开的文件描述符
type File struct {
    *file
}

// file 是*File 的实际表示
// 额外的一层结构保证没有哪个os 的客户端
// 能够覆盖这些数据。如果覆盖这些数据,
// 可能在变量终结时关闭错误的文件描述符
type file struct {
    fd int
    name string
    dirinfo *dirInfo // 除了目录结构,此字段为nil
    nepipe int32     // Write 操作时遇到连续EPIPE 的次数
}

可以从上面的代码里看到标准库中声明的 File 类型。这个类型的本质是非原始的。这个类型的值实际上不能安全复制。对内部未公开的类型的注释,解释了不安全的原因。因为没有方法阻止程序员进行复制,所以 File 类型的实现使用了一个嵌入的指针,指向一个未公开的类型。

正是这层额外的内嵌类型阻止了复制。不是所有的结构类型都需要或者应该实现类似的额外保护。程序员需要能识别出每个类型的本质,并使用这个本质来决定如何组织类型。

让我们看一下 Open 函数的实现,代码如下所示。

func Open(name string) (file *File, err error) {
    return OpenFile(name, O_RDONLY, 0)
}

上面的代码展示了 Open 函数的实现,调用者得到的是一个指向 File 类型值的指针。Open 创建了 File 类型的值,并返回指向这个值的指针。如果一个创建用的工厂函数返回了一个指针,就表示这个被返回的值的本质是非原始的。

即便函数或者方法没有直接改变非原始的值的状态,依旧应该使用共享的方式传递,代码如下所示。

func (f *File) Chdir() error {
    if f == nil {
        return ErrInvalid
    }
    if e := syscall.Fchdir(f.fd); e != nil {
        return &PathError{"chdir", f.name, e}
    }
    return nil
}

上述代码中的 Chdir 方法展示了,即使没有修改接收者的值,依然是用指针接收者来声明的。因为 File 类型的值具备非原始的本质,所以总是应该被共享,而不是被复制。

是使用值接收者还是指针接收者,不应该由该方法是否修改了接收到的值来决定。这个决策应该基于该类型的本质。这条规则的一个例外是,需要让类型值符合某个接口的时候,即便类型的本质是非原始本质的,也可以选择使用值接收者声明方法。这样做完全符合接口值调用方法的机制。

编程帮,一个分享编程知识的公众号。跟着站长一起学习,每天都有进步。

通俗易懂,深入浅出,一篇文章只讲一个知识点。

文章不深奥,不需要钻研,在公交、在地铁、在厕所都可以阅读,随时随地涨姿势。

文章不涉及代码,不烧脑细胞,人人都可以学习。

当你决定关注「编程帮」,你已然超越了90%的程序员!

编程帮二维码
微信扫描二维码关注