记一次简单的切片创建引发的源码阅读

引子

先来看看下面的代码,思考一下最后 nums 会打印什么?

package main

import "fmt"

func main() {
	// 声明一个len =0  ,cap =10的切片
	var s1 = make([]int, 0, 10)
	// 先往切片中填充数据
	s1 = append(s1, 1, 2, 3)
	//使用底层数组创建一个新的切片,共享底层数组
	var s2 = s1[1:]
	// 往 s2 append 数据
	s2 = append(s2, 4)
	// 这里打印的结果是什么?
	fmt.Println(s1)
}

答案揭晓

答案是 [1, 2, 3]

思考过程

对于刚接触 Go 的新手来说,这个结果似乎再正常不过了,s1的内容显然是 [1, 2, 3]。但对老手来说,可能会产生疑问:s2s1 共享了底层数组,在不触发扩容的情况下,往 s2 中追加数据,难道不应该影响 s1 吗?按理说,s1 的内容应该是 [1, 2, 3, 4] 才对啊。

源码分析

为了弄清楚这个问题,我们需要深入了解 Go 语言标准库中的 fmt.Println 函数的实现。

以下是fmt.Println的源码片段:

// Println 使用默认格式输出操作数,并写入标准输出。
// 操作数之间会自动加空格,最后追加一个换行符。
// 它返回写入的字节数和可能的错误。
func Println(a ...any) (n int, err error) {
	return Fprintln(os.Stdout, a...)
}

Println 函数只是调用了Fprintln,并将标准输出 os.Stdout 传递给 Fprintln 作为目标输出设备。

接着,我们看看 Fprintln 的实现:

// Fprintln 使用默认格式格式化操作数并写入 w。
// 操作数之间总是添加空格,最后加一个换行符。
// 它返回写入的字节数和可能的写入错误。
func Fprintln(w io.Writer, a ...any) (n int, err error) {
	p := newPrinter()
	p.doPrintln(a)
	n, err = w.Write(p.buf)
	p.free()
	return
}

Fprintln 函数首先创建了一个 printer 对象,然后调用 doPrintln 方法来处理传入的参数 a,并将结果存储在 printer 的缓冲区中。接着,它会在缓冲区末尾添加一个换行符 \n,最后通过 Write 方法将缓冲区的内容写入 w,即传入的 io.Writer 接口(这里是标准输出 os.Stdout)。

深入 doPrintln 函数,我们看到它调用了 printArg 函数来处理每一个参数:

// doPrintln is like doPrint but always adds a space between arguments
// and a newline after the last argument.
func (p *pp) doPrintln(a []any) {
	for argNum, arg := range a {
		if argNum > 0 {
			p.buf.writeByte(' ')
		}
		p.printArg(arg, 'v')
	}
	p.buf.writeByte('\n')
}

关键函数 printArg

func (p *pp) printArg(arg any, verb rune) {
	// Some types can be done without reflection.
	switch f := arg.(type) {
	// 省略了部分代码
	default:
		// If the type is not simple, it might have methods.
		if !p.handleMethods(verb) {
			// Need to use reflection, since the type had no
			// interface methods that could be used for formatting.
			p.printValue(reflect.ValueOf(f), verb, 0)
		}
	}
}

printArg进行断言,发现最后执行了p.printValue函数

在这里插入图片描述

printArg 是一个关键函数,它负责处理每个参数的格式化。对于复杂类型,如切片,最终会调用 p.printValue 来输出切片内容。


// printValue is similar to printArg but starts with a reflect value, not an interface{} value.
// It does not handle 'p' and 'T' verbs because these should have been already handled by printArg.
func (p *pp) printValue(value reflect.Value, verb rune, depth int) {
  // 省略了部分代码
	switch f := value; value.Kind() {
	
	case reflect.Array, reflect.Slice:
		if p.fmt.sharpV {
			p.buf.writeString(f.Type().String())
			if f.Kind() == reflect.Slice && f.IsNil() {
				p.buf.writeString(nilParenString)
				return
			}
			p.buf.writeByte('{')
			for i := 0; i < f.Len(); i++ {
				if i > 0 {
					p.buf.writeString(commaSpaceString)
				}
				p.printValue(f.Index(i), verb, depth+1)
			}
			p.buf.writeByte('}')
		} else {
			p.buf.writeByte('[')
			for i := 0; i < f.Len(); i++ {
				if i > 0 {
					p.buf.writeByte(' ')
				}
				p.printValue(f.Index(i), verb, depth+1)
			}
			p.buf.writeByte(']')
		}
	}
}

在这里插入图片描述
通过断言找到了最终的执行核心代码,并且这儿p.fmt.sharpV=false,最终执行的核心代码如下

p.buf.writeByte('[')
for i := 0; i < f.Len(); i++ {
  if i > 0 {
    p.buf.writeByte(' ')
  }
  p.printValue(f.Index(i), verb, depth+1)
}
p.buf.writeByte(']')

这里,f.Len() 用于获取切片的长度,然后逐个打印切片中的元素。这就解释了为什么 s1 的输出是 [1, 2, 3],因为fmt.Println只遍历了 s1 当前的 len 长度,而没有考虑到底层数组的变化。

在这里插入图片描述

底层原理分析

要理解 s1 没有被修改,必须了解 Go 中切片的底层实现。切片的底层数据结构如下:

	sliceHeader struct {
		Data unsafe.Pointer
		Len  int
		Cap  int
	}

Data 指向底层数组,Len 是切片的长度,而 Cap 是切片的容量。在 s2 追加数据之前,s1 和 s2 共享同一个底层数组,但由于 s2 的 len 较短,fmt.Println 只输出了 s1 的前 Len 个元素。

当我们向 s2 追加数据后,s2 的 len 和 cap 可能会发生变化,但这不会影响到 s1 的 len,因此 s1 的打印结果仍然是 [1, 2, 3]。

我们最后再来看看 s1s2caplen

在修改s2之前,s1和 s2的状态如下

在这里插入图片描述

当向s2 append 数据以后

在这里插入图片描述

最后一张图解释为什么是 [1,2,3]

在这里插入图片描述

总结

这段代码展示了 Go 语言中切片操作的一些微妙之处,提醒我们要小心处理切片的共享问题,尤其是在进行 append 操作时。牢记切片是一个三元素结构(指针、长度、容量),可以帮助我们更好地理解 Go 语言中切片的行为,从而避免类似的困惑。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

疯狂的程需猿

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值