数组实现原理
与大多数语言实现一样,数组是在内存中分配了一段连续的内存空间:
func NewArray(elem *Type, bound int64) *Type {
if bound < 0 {
Fatalf("NewArray: invalid bound %v", bound)
}
t := New(TARRAY)
t.Extra = &Array{Elem: elem, Bound: bound}
t.SetNotInHeap(elem.NotInHeap())
return t
}
编译期间的数组类型是由上述的 cmd/compile/internal/types.NewArray
函数生成的,类型 Array
包含两个字段,一个是元素类型 Elem
,另一个是数组的大小 Bound
,这两个字段共同构成了数组类型,而当前数组是否应该在堆栈中初始化也在编译期就确定了。
数组初始化语法糖
[...]T{1, 2, 3}
形式的数组大家应该都知道,这个其实是一个语法糖,实现方式也较为简单,删减源码如下:
func typecheckcomplit(n *Node) (res *Node) {
...
switch t.Etype {
case TARRAY, TSLICE:
var length, i int64
nl := n.List.Slice()
for i2, l := range nl {
i++
if i > length {
length = i
}
}
if t.IsDDDArray() {
t.SetNumElem(length)
}
}
}
DDDArray
指的就是此类数组(点 == Dot)
数组优化
对于一个由字面量组成的数组,根据数组元素数量的不同,编译器会在负责初始化字面量的 cmd/compile/internal/gc.anylit
函数中做两种不同的优化:
- 当元素数量小于或者等于 4 个时,会直接将数组中的元素放置在栈上;
- 当元素数量大于 4 个时,会将数组中的元素放置到静态区并在运行时取出;
上代码:
func anylit(n *Node, var_ *Node, init *Nodes) {
t := n.Type
switch n.Op {
case OSTRUCTLIT, OARRAYLIT:
if n.List.Len() > 4 {
vstat := staticname(t)
vstat.Name.SetReadonly(true)
fixedlit(inNonInitFunction, initKindStatic, n, vstat, init)
a := nod(OAS, var_, vstat)
a = typecheck(a, ctxStmt)
a = walkexpr(a, init)
init.Append(a)
break
}
fixedlit(inInitFunction, initKindLocalCode, n, var_, init)
...
}
}
func fixedlit(ctxt initContext, kind initKind, n *Node, var_ *Node, init *Nodes) {
var splitnode func(*Node) (a *Node, value *Node)
...
for _, r := range n.List.Slice() {
a, value := splitnode(r)
a = nod(OAS, a, value)
a = typecheck(a, ctxStmt)
switch kind {
case initKindStatic:
genAsStatic(a)
case initKindLocalCode:
a = orderStmtInPlace(a, map[string][]*Node{})
a = walkstmt(a)
init.Append(a)
}
}
}
访问和赋值
Go 语言中对越界的判断是可以在编译期间由静态类型检查完成的,cmd/compile/internal/gc.typecheck1
函数会对访问数组的索引进行验证:
func typecheck1(n *Node, top int) (res *Node) {
switch n.Op {
case OINDEX:
ok |= ctxExpr
l := n.Left // array
r := n.Right // index
switch n.Left.Type.Etype {
case TSTRING, TARRAY, TSLICE:
...
if n.Right.Type != nil && !n.Right.Type.IsInteger() {
yyerror("non-integer array index %v", n.Right)
break
}
if !n.Bounded() && Isconst(n.Right, CTINT) {
x := n.Right.Int64()
if x < 0 {
yyerror("invalid array index %v (index must be non-negative)", n.Right)
} else if n.Left.Type.IsArray() && x >= n.Left.Type.NumElem() {
yyerror("invalid array index %v (out of bounds for %d-element array)", n.Right, n.Left.Type.NumElem())
}
}
}
...
}
}
数组和字符串的一些简单越界错误都会在编译期间发现,比如我们直接使用整数或者常量访问数组,但是如果使用变量去访问数组或者字符串时,编译器就无法发现对应的错误了,这时就需要 Go 语言运行时发挥作用了:
arr[4]: invalid array index 4 (out of bounds for 3-element array)
arr[i]: panic: runtime error: index out of range [4] with length 3
Go 语言运行时在发现数组、切片和字符串的越界操作会由运行时的 panicIndex
和 runtime.goPanicIndex
函数触发程序的运行时错误并导致崩溃退出:
TEXT runtime·panicIndex(SB),NOSPLIT,$0-8
MOVL AX, x+0(FP)
MOVL CX, y+4(FP)
JMP runtime·goPanicIndex(SB)
func goPanicIndex(x int, y int) {
panicCheck1(getcallerpc(), "index out of range")
panic(boundsError{x: int64(x), signed: true, y: y, code: boundsIndex})
}
当数组的访问操作 OINDEX
成功通过编译器的检查之后,会被转换成几个 SSA 指令,假设我们有如下所示的 Go 语言代码,通过如下的方式进行编译会得到 ssa.html
文件:
package check
func outOfRange() int {
arr := [3]int{1, 2, 3}
i := 4
elem := arr[i]
return elem
}
$ GOSSAFUNC=outOfRange go build array.go
dumped SSA to ./ssa.html
start
阶段生成的 SSA 代码就是优化之前的第一版中间代码,下面展示的部分就是 elem := arr[i]
对应的中间代码,在这段中间代码中我们发现 Go 语言为数组的访问操作生成了判断数组上限的指令 IsInBounds
以及当条件不满足时触发程序崩溃的 PanicBounds
指令:
b1:
...
v22 (6) = LocalAddr <*[3]int> {arr} v2 v20
v23 (6) = IsInBounds v21 v11
If v23 → b2 b3 (likely) (6)
b2: ← b1-
v26 (6) = PtrIndex <*int> v22 v21
v27 (6) = Copy v20
v28 (6) = Load v26 v27 (elem[int])
...
Ret v30 (+7)
b3: ← b1-
v24 (6) = Copy v20
v25 (6) = PanicBounds [0] v21 v11 v24
Exit v25 (6)
PanicBounds
指令最终会被转换成上面提到的 panicIndex
函数,当数组下标没有越界时,编译器会先获取数组的内存地址和访问的下标,然后利用 PtrIndex
计算出目标元素的地址,再使用 Load
操作将指针中的元素加载到内存中。
当然只有当编译器无法对数组下标是否越界无法做出判断时才会加入 PanicBounds
指令交给运行时进行判断,在使用字面量整数访问数组下标时就会生成非常简单的中间代码,当我们将上述代码中的 arr[i]
改成 arr[2]
时,就会得到如下所示的代码:
b1:
...
v21 (5) = LocalAddr <*[3]int> {arr} v2 v20
v22 (5) = PtrIndex <*int> v21 v14
v23 (5) = Load v22 v20 (elem[int])
...
Go 语言对于数组的访问还是有着比较多的检查的,它不仅会在编译期间提前发现一些简单的越界错误并插入用于检测数组上限的函数调用,而在运行期间这些插入的函数会负责保证不会发生越界错误。
数组的赋值和更新操作 a[i] = 2
也会生成 SSA 生成期间计算出数组当前元素的内存地址,然后修改当前内存地址的内容,这些赋值语句会被转换成如下所示的 SSA 操作:
b1:
...
v21 (5) = LocalAddr <*[3]int> {arr} v2 v19
v22 (5) = PtrIndex <*int> v21 v13
v23 (5) = Store {int} v22 v20 v19
...
赋值的过程中会先确定目标数组的地址,再通过 PtrIndex
获取目标元素的地址,最后使用 Store
指令将数据存入地址中,从上面的这些 SSA 代码中我们可以看出无论是数组的寻址还是赋值都是在编译阶段完成的,没有运行时的参与。
引用
- Go 语言设计与实现
- 字面量
欢迎大家关注我的公众号