go:数组与切片

0 阅读10分钟

切片

定义

概念

一片连续的内存区域,不能进行扩容,在复制和传递时为值传递。

声明方式

在声明过程中,可以同时对数组进行赋值操作。初始化数组时,必须明确指定其长度;若未指定长度,则必须在后续为数组赋予具体值,此时编译器将自动推断数组的长度。

var arr [cap]int
var arr [cap]int{1,2,3,4}
arr :=[...]int{2,3,4}

不同类型的数组之间不能进行比较。

数组长度可以内置len函数获取,数组中的元素可以通过下标获取,只能访问数组中已有的元素,如果数组访问越界,则在编译时会报错。

数组值复制

数组在赋值和函数调用时形参都是值复制。

a:=[3]int{2,5,8}
b=a
func chaneg(c [3]int){}

在赋值给b,还是函数调用c都是值赋值,不管修改b还是c。对a没有影响。

原理

内存优化

当数组的长度小于4时,在运行时数组会被放置在栈中,当数组的长度大于4时,数组会被放到内存的静态只读区。

这是关于Go语言中数组内存分配的一个重要优化策略:

  1. 当数组长度小于4时(数组较小):
    • 数组被分配在栈(Stack)上
    • 这样做的好处是分配和释放都很快
    • 由于数据在栈上,可以减少垃圾回收的压力
    • 适合临时性的小数组使用
  2. 当数组长度大于4时(数组较大):
    • 数组被分配在静态只读区
    • 这样可以避免大数组频繁在栈上分配释放
    • 静态只读区的数据可以被多个goroutine共享
    • 适合存储较大的、不会频繁变化的数据

索引与访问越界原理

数组越界访问是一个严重的编程错误,其检测在Go语言中部分在编译期间通过类型检查阶段完成。具体而言,typecheck1函数被用于对数组索引进行验证,确保访问操作的安全性。

  1. 编译期检查:利用typecheck1函数,对数组索引的常量值进行静态分析
  2. 运行时检查:对于非常量索引,编译器会插入相应的边界检查代码,以防止越界错误的发生
// typecheck1 处理特定类型的节点并返回类型检查后的节点
func typecheck1(n *Node, top int) (res *Node) {
    // ... 其他类型处理代码 ...
    
    case OINDEX: // 处理索引操作 a[i]
        // 检查左操作数是否为数组、切片或字符串
        a := n.Left
        i := n.Right
        
        // 类型检查和转换
        a = typecheck(a, Erv)
        i = typecheck(i, Ev)
        
        // 检查索引类型是否为整数
        if !i.Type.IsInteger() {
            yyerror("non-integer index %v", i)
            n.Type = nil
            return n
        }
        
        // 处理数组索引的特殊情况
        if a.Type.IsArray() {
            // 检查索引是否为常量
            if i.Op == OLITERAL {
                // 常量索引:在编译期直接检查越界
                x := i.Int64()
                if x < 0 || uint64(x) >= uint64(a.Type.NumElem()) {
                    yyerror("constant index %d out of bounds [0:%d)", x, a.Type.NumElem())
                    n.Type = nil
                    return n
                }
            } else {
                // 非常量索引:插入运行时边界检查
                if !n.Bounded() {
                    n = mkcall1(chanop("runtime.panicIndex"), nil, typename("index out of range"), a, i)
                    n.SetType(types.Types[TUNSAFEPTR])
                    return n
                }
            }
        }
        
        // ... 其他索引操作处理代码 ...
    }
    
    // ... 其他类型处理代码 ...
}

编译时检查的关键逻辑

上述代码中,编译时检查的核心逻辑是:

// 检查数组索引是否为字面常量
if i.Op == OLITERAL { 
    // 获取索引的整数值
    x := i.Int64() 
    // 检查索引是否越界:
    // 1. x < 0:检查是否为负数
    // 2. uint64(x) >= uint64(a.Type.NumElem()):检查是否超过数组长度
    if x < 0 || uint64(x) >= uint64(a.Type.NumElem()) { 
        // 如果索引越界,输出错误信息
        // 错误格式:constant index {索引值} out of bounds [0:{数组长度})
        yyerror("constant index %d out of bounds [0:%d)", x, a.Type.NumElem()) 
        // 将节点类型设置为nil
        n.Type = nil 
        // 返回错误节点
        return n 
    } 
} 

这里会检查:

  1. 索引是否为常量表达式(i.Op == OLITERAL
  2. 常量值是否在合法范围内(0 ≤ x < len(array)

如果检测到越界,会直接报编译错误,阻止生成有问题的代码。

运行时检查的插入

对于非常量索引,Go 编译器会插入运行时检查代码:

// 检查索引是否在边界范围内
if !n.Bounded() { 
    // 如果索引越界:
    // 1. 调用runtime.panicIndex函数创建一个运行时恐慌
    // 2. 传入数组a和索引i作为参数
    // 3. 错误信息为"index out of range"
    n = mkcall1(chanop("runtime.panicIndex"), nil, typename("index out of range"), a, i) 
    // 将节点类型设置为unsafe.Pointer
    n.SetType(types.Types[TUNSAFEPTR]) 
    // 返回包含恐慌调用的节点
    return n 
} 

这会在运行时调用 runtime.panicIndex 函数进行边界检查,如果越界会触发 panic。

总结

typecheck1 函数通过以下机制确保数组访问安全:

  1. 静态分析:对常量索引直接在编译期检查,提前发现越界错误
  2. 动态防护:对变量索引插入运行时检查,防止运行时越界
  3. 类型安全:强制要求索引类型为整数,避免非整数索引

这种双检查机制有效减少了数组越界的风险,但需要注意:如果通过 unsafe 包绕过类型系统,这些检查会失效。

切片

定义

一种轻量级的数据结构,切片是对底层数组的一个动态视图,可以动态增长和缩减。切片本身并不存储数据,而是描述底层数组的一段区间。可以动态管理一组数据。

切片的数据结构由指针,长度,容量组成

  • 指针:指向底层数组的起始元素。
  • 长度:切片当前包含的元素个数。
  • 容量:从切片起始位置到底层数组末尾的元素个数。
// runtime/slice.go
// 切片的底层结构体定义
 type slice struct {
     array unsafe.Pointer // 指向底层数组的指针
     len   int           // 切片长度
     cap   int           // 切片容量
 }

底层数据结构

初始化

在声明设为不初始值的情况,切片的值nil。切片初始化需要使用内置 的make函数

var slice []int 
var slice []int=make([]int,5)
var slcie []int=make([]int,5,6)

切片的初始化可以通过截取数组的一部分来实现,截取后,切片依然保持对原数组的引用。因此,对切片所做的任何修改都会直接反映在原数组上。

空切片与nil切片

var num []int这种方式声明出的切片为nil切片,切片的指针,长度和容量的零值分别为nil、0和0

num:=make([]int,0)这种方式声明的时空切片,切片的指针,长度分别为空结构体、0和0,底层数组为空,但底层数值的指针非空。

make函数进行初始化切片源码

// makeslice 用于创建切片,分配底层数组内存
func makeslice(et *_type, len, cap int) unsafe.Pointer {
    // 计算需要分配的总内存大小:元素类型大小 × 容量
    mem, overflow := math.MulUintptr(et.size, uintptr(cap))
    // 检查溢出、超出最大分配限制、长度或容量非法
    if overflow || mem > maxAlloc || len < 0 || len > cap {
        // 若容量非法,进一步检查长度是否合法
        mem, overflow := math.MulUintptr(et.size, uintptr(len))
        if overflow || mem > maxAlloc || len < 0 {
            panicmakeslicelen() // 长度非法时触发panic
        }
        panicmakeslicecap() // 容量非法时触发panic
    }
    // 分配内存,返回切片底层数组的指针
    return mallocgc(mem, et, true)
}

// MulUintptr 用于无符号整数乘法并检测溢出
func MulUintptr(a, b uintptr) (uintptr, bool) {
    // 计算乘积
    c := a * b
    // 检查是否发生溢出(乘法结果除以任一因子不等于另一因子则溢出)
    if a != 0 && c/a != b {
        return 0, true // 溢出
    }
    return c, false // 未溢出
}
  • makeslice首先用MulUintptr计算切片底层数组所需内存,防止溢出和非法参数。
  • 若容量或长度非法,分别触发panic。
  • math.MulUintptr用于安全地计算内存大小,避免溢出。

切片值复制与数据引用

切片的复制本质上是值复制,然而,在运行时,此过程涉及对SliceHeader结构的复制。尽管底层指针依然指向同一数组地址,实现了引用传递,这种机制在处理大量数据时,能够以较低的成本高效完成复制操作。

// reflect.SliceHeader 的定义
// Data字段为底层数组的指针,Len为切片长度,Cap为切片容量
 type SliceHeader struct {
    Data uintptr
    Len  int
     Cap  int
 }

切片的复制本质上是SliceHeader结构体的值复制(浅拷贝),即复制Data、Len、Cap三个字段。这样复制后的切片与原切片共享同一底层数组,但它们的SliceHeader是独立的。

扩容

使用内置函数append向切片中追加元素,返回与原切片完全相同尾部追加新元素的更大新切片,如果追加新元素后的新切片的容量大于底层数组的容量,就会发生扩容,创建一个新的底层数组,将现有的值复制到新数组中,然后再添加新值。

扩容规则

// growslice 根据所需容量扩容切片
func growslice(et *_type, old slice, cap int) slice {
    // ... 前置检查代码 ...
    
    // 计算新容量
    newcap := old.cap
    doublecap := newcap + newcap
    if cap > doublecap {
        // 需求容量超过当前容量的2倍,直接使用需求容量
        newcap = cap
    } else {
        // 否则根据切片大小选择扩容策略
        const threshold = 256
        if old.cap < threshold {
            // 小切片(容量少于256)按2倍扩容
            newcap = doublecap
        } else {
            // 大切片按约1.25倍扩容(添加0.75*threshold的增长因子)
            for 0 < newcap && newcap < cap {
                newcap += (newcap + 3*threshold) / 4
            }
            // 确保新容量满足最小需求
            if newcap <= 0 {
                newcap = cap
            }
        }
    }
    
    // ... 内存对齐和溢出检查代码 ...
    
    // 分配新的底层数组
    var overflow bool
    var lenmem, newlenmem, capmem uintptr
    // ... 计算内存大小代码 ...
    
    var p unsafe.Pointer
    if et.ptrdata == 0 {
        // 元素类型不含指针,直接分配内存
        p = mallocgc(capmem, nil, false)
        // 用零值填充新分配的内存
        memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
    } else {
        // 元素类型包含指针,需要更复杂的内存分配和GC处理
        p = mallocgc(capmem, et, true)
        if lenmem > 0 && writeBarrier.enabled {
            // 启用写屏障的情况下需要特殊处理
            bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(old.array), lenmem)
        }
    }
    
    // 将旧数据复制到新数组
    memmove(p, old.array, lenmem)
    
    // 返回新切片
    return slice{p, old.len, newcap}
}

新的长度newLen=oldLen+num

  1. 若追加后所需容量(cap)大于原容量的两倍,则新容量直接为cap。

  2. 若原容量小于阈值(如256),则新容量为原容量的2倍。

  3. 若原容量大于等于阈值,则新容量每次增加原容量的1.25倍(即newcap += (newcap + 3*threshold)/4),直到满足所需容量。

  4. 计算完newcap后,还会通过roundupsize函数进行内存对齐,保证分配效率和内存利用率。

  5. 扩容时会分配新的底层数组,并将原有数据拷贝到新数组。

这种策略兼顾了小切片的快速扩容和大切片的内存利用率,且内存分配有进一步优化。

为什么采用这种策略?

这种扩容策略的设计权衡了内存使用效率和性能:

  1. 小切片快速增长:小切片按 2 倍扩容可以减少频繁扩容的开销
  2. 大切片渐进增长:大切片按较小比例扩容可以避免浪费过多内存
  3. 内存对齐优化:确保内存块大小符合系统分配规则,提高内存利用率