Go语言基础结构 —— Array 数组

文章详细阐述了Go语言中数组的概念、声明方式、类型检查、编译时的数组字面量初始化以及编译时的数组越界检查。在Go中,数组是值类型,其长度在声明时固定,不能动态变化。编译器在处理数组时会进行类型检查,对于数组字面量的初始化,会进行静态和动态的优化。同时,编译器会在编译期间检查数组越界,确保代码的正确性。此外,文章还介绍了编译器如何处理数组的底层实现,包括词法分析、语法分析和类型检查等阶段。
摘要由CSDN通过智能技术生成

数组(Array)

基本概念

数组是由相同类型元素的集合组成的数据结构,计算机会为数组分配一块连续的内存来保存其中的元素,我们可以利用数组中元素的索引快速访问特定元素,常见的数组大多都是一维的线性数组,而多维数组在数值和图形计算领域却有比较常见的应用。

数组有如下特点:

  • 数组是值类型,默认情况下作为参数传递给函数时会拷贝,不影响原数组。
  • 声明时,默认值为零值(0、“”、false等)
  • 声明固定长度,不能动态变化
  • 元素的数据类型要相同
  • 占用内存连续

下面是一些示例,说明在Go语言中如何声明和使用数组:

  1. 声明和初始化一个数组:

    var a [5]int         // 声明一个长度为5的int类型数组
    a = [5]int{1, 2, 3}  // 将前三个元素初始化为1、2、3,后两个元素默认为0
    
    b := [5]int{1, 2, 3, 4, 5}//直接初始化并赋值
    
  2. 访问数组元素:

x := a[0]    // 访问第一个元素
a[2] = 10    // 修改第三个元素
  1. 循环遍历数组:

    var a  = [5]int{1,2,3,4,5}
    
    //for循环遍历
    for i := 0; i < len(a); i++ {
        fmt.Println(a[i])
    }
    
    //for - range遍历
    for index,value := range a{
      fmt.Println(index, value)
    }
    
  2. 数组作为函数参数传递:

    func sum(a [5]int) int {
        s := 0
        for i := 0; i < len(a); i++ {
            s += a[i]
        }
        return s
    }
    
    x := sum([5]int{1, 2, 3, 4, 5})
    
    
  3. 多维数组:

    var a [3][3]int     // 声明一个3x3的二维数组
    a[0][0] = 1         // 访问第一个元素
    

Go语言中,数组还可以与切片、指针和结构体等其他类型进行组合使用,以实现更复杂的数据结构和算法。由于数组在Go中使用频率没有切片那么高,所以一些更灵活的操作将在切片内容部分讲解。

数组底层编译原理

要了解数组的底层的知识点,必须先了解Go的编译流程,Go的编译流程大致分为以下几个步骤:

  1. 词法分析:将源代码转换成一个个的标记(token),包括关键字、标识符、常量、运算符等。
  2. 语法分析:将标记组合成抽象语法树(AST)。AST是一个树形结构,代表了源代码的语法结构。
  3. 类型检查:检查代码中的类型错误,例如类型不匹配、未定义的变量等。
  4. 生成中间代码:将AST转换为中间代码(IR)。Go使用SSAStatic Single Assignment)形式的IR,其每个变量只被赋值一次。
  5. 代码优化:对中间代码进行各种优化,包括常量折叠、死代码删除、内联函数等。
  6. 生成汇编代码:将IR转换为目标机器的汇编代码。
  7. 链接:将生成的目标文件与系统库、其他库链接生成可执行文件。

整个编译流程中,词法分析、语法分析、类型检查、中间代码生成和代码优化都是由编译器完成的,而生成汇编代码和链接则是由链接器完成的。

关于数组底层内容,将选取词法分析、语法分析、类型检查、生成中间代码等阶段的几个重要关键点讲解。

编译时数组类型解析

数组声明解析

Go 语言的数组有两种不同的创建方式,一种是显式的指定数组大小,另一种是使用 [...]T 声明数组:

array1 := [5]int{1,2,3,4,5}
array2 := [...]int{1,2,3,4,5}

在词法及语法解析时,上述几种方式声明的数组会被解析为 ArrayType, 当遇到[...]的声明时,其长度会被标记为nil,将在后续阶段进行自动推断。

ArrayType结构体定义:

// go 1.20.3   path:  src/cmd/compile/internal/syntax/nodes.go
//ArrayType 数组类型节点结构体
type (
  ......
  // [Len]Elem
  ArrayType struct {
    Len  Expr
    Elem Expr
    expr
  }
  ......
)

该结构体有两个字段 LenElem 以及 一个内嵌结构体expr

  • Len 表示数组的长度,类型为 Expr,可以是一个整数常量或者是一个变量或表达式,当数组是一个省略号形式的变长数组时,Lennil
  • Elem 表示数组的元素类型,类型为 Expr,通常是一个基础类型或一个结构体类型等。Elem 字段也可以是一个数组类型,表示多维数组。对于一个空接口类型 interface{},它的 Elem 字段为 nil
  • 内嵌结构体expr ,表示表达式的基础类型,其中包含了表达式的位置信息等

那类型 Expr是什么呢?

Go 语言中,Expr 是一个接口类型,是AST(抽象语法树)节点的一种类型,表示程序中的一个表达式,是语言的基本构建块之一。它们出现在各种语句和声明中,如赋值语句、条件语句、循环语句、函数调用等等,由于表达式是可以求值的,所以它们在程序中经常被用来计算和处理数据。例如,1+2x+3f() 等都是合法的表达式,它们的类型都实现了 Expr 接口。

回到解析上,数组被解析为ArrayType节点类型,使用的是函数 typeOrNil()

该函数是Go语言的解析器(parser)中的一个方法,返回值是一个表达式(Expr),表达式可以是不同类型的节点,例如指针(Indirect)、通道(ChanType)、数组(ArrayType)、映射(MapType)、结构体(StructType)、接口(InterfaceType)等。如果不能解析出一个类型,该函数返回 nil

typeOrNil()函数关于数组部分的源码如下:

// go 1.20.3   path:  src/cmd/compile/internal/syntax/parser.go
func (p *parser) typeOrNil() Expr {
	......
  // 获取当前词法标记的位置信息
	pos := p.pos()
	switch p.tok {
	......
	case _Lbrack:
    // '[' oexpr ']' ntype 或 '[' _DotDotDot ']' ntype,解析数组或切片类型
		p.next() // 向前读取一个词法标记
    // 如果下一个词法标记是右中括号,则表示是切片类型
		if p.got(_Rbrack) {
      // 解析切片类型并返回切片类型节点
			return p.sliceType(pos)
		}
    // 否则解析数组类型并返回数组类型节点
		return p.arrayType(pos, nil)
	......
	return nil
}
  
/**
	// go 1.20.3   path:  src/cmd/compile/internal/syntax/parser.go
	该函数用于解析数组类型,并返回一个Expr表示该类型
	pos表示数组类型的位置,len表示数组的长度
*/
func (p *parser) arrayType(pos Pos, len Expr) Expr {
	......
  // 如果长度为空并且没有出现省略号(_DotDotDot),则解析表达式
	if len == nil && !p.got(_DotDotDot) {
		p.xnest++
		len = p.expr()
		p.xnest--
	}
	......
	// 要求下一个标记为右方括号
	p.want(Rbrack)
	// 创建一个新的数组类型
	t := new(ArrayType)
	t.pos = pos
	t.Len = len
	t.Elem = p.type()
	return t
}

通过上述代码操作,数组的声明被解析为 ArrayType类型的表达式。

types2.Array

Go 语言中,数组类型可以有固定的长度也可以是不定长的(即长度为 nil),但是它们在类型系统中被表示为不同的类型。这是因为在数组类型的定义中,它们的长度是数组类型的一个组成部分,不同长度的数组类型是不兼容的。

当编译器遇到一个长度为 nil 的数组类型时,它会将其表示为 types2.Array 类型。

这是因为在类型检查的过程中,类型检查器需要为这种不定长数组类型分配一个类型,并且需要检查其元素类型是否匹配。这个类型在内部是用 types2.Array 来表示的,因为 types2.Array 类型可以存储一个不确定的长度,并且可以在之后被转换为一个具有确定长度的 types.Array 类型。

这种转换发生在类型检查阶段,因为只有在这个阶段,编译器才有足够的信息来确定数组类型的长度。

在类型检查阶段,编译器对语法树中的表达式节点进行类型检查,根据表达式的上下文推导表达式的类型,并进行类型转换(type conversion)等操作。

检查时,如果是ArrayType类型,且其长度Lennil时,会初始化一个types2.Array并将其长度标记为-1,然后通过check.indexedElts(e.ElemList, utyp.elem, utyp.len)返回数组长度n并赋值给Len,完成自动推断。

types2.Array结构体:

// go 1.20.3   path:  src/cmd/compile/internal/types2/array.go
type Array struct {
	len  int64
	elem Type
}

函数exprInternalGo类型检查器中的核心函数之一,用于对表达式进行类型检查和求值。

来看看该函数对数组类型的节点表达式检查的相关源码:

func (check *Checker) exprInternal(x *operand, e syntax.Expr, hint Type) exprKind {
	......
	//通过类型断言判断表达式 e 的具体类型
	switch e := e.(type) {
	......
	//如果类型是*syntax.CompositeLit类型,即语法树节点类型
	case *syntax.CompositeLit:
		//声明了两个 Type 类型的变量 typ 和 base,用于保存表达式 e 的类型
		var typ, base Type
		switch {
		//如果 e.Type 不为空,则将其转换为Type类型,并将其赋值给变量 typ 和 base
		case e.Type != nil:
      //e.Type为 *syntax.ArrayType 类型且数组长度 Len 为 nil,则将typ设置为&types2.Array,
			if atyp, _ := e.Type.(*syntax.ArrayType); atyp != nil && atyp.Len == nil {
				typ = &Array{len: -1, elem: check.varType(atyp.Elem)}
				base = typ
				break
			}
			typ = check.typ(e.Type)
			base = typ
		......
		}
		//通过 coreType 函数获取 base 的基础类型信息
		switch utyp := coreType(base).(type) {
		......
		//如果基础类型是 *Array 类型,即数组类型
		case *Array:
			//首先检查其元素类型是否为 nil,如果是则报错,表示这是无效的递归类型
			if utyp.elem == nil {
				check.error(e, InvalidTypeCycle, "invalid recursive type")
				goto Error
			}

			//通过 indexedElts 函数计算复合字面量表达式的元素数量
			n := check.indexedElts(e.ElemList, utyp.elem, utyp.len)
			//如果数组类型的长度为负数,则设置其长度为计算得到的元素数量
			if utyp.len < 0 {
				utyp.len = n
        //如果 e.Type 不为空,则记录类型和值信息
				if e.Type != nil {
					check.recordTypeAndValue(e.Type, typexpr, utyp, nil)
				}
			}
		......
		}
		......
	}
	......
}
types.Array 与 types2.Array转换

Go编译器的类型检查过程中,会在 go/types 包中进行类型推断和类型检查。

其中,类型检查器需要使用 types.Array 类型进行数组类型的表示和处理,但在 types2 包中的 Array 类型是用表示不定长的数组的,在设计时可能存在一些历史原因或者设计考虑,导致其在 types 包中无法直接使用。

因此,在 types 包中提供了 types.NewArray() 函数,可以将 types2.Array 转换为 types.Array 类型,以便于后续类型检查器进行处理。通过这种方式,可以将 types2 包中定义的数据类型与 types 包中的类型体系进行衔接,保证类型检查器的正常运行。

//go1.20.3 path:src/cmd/compile/internal/types/type.go
type Array struct {
    Elem  *Type // element type
    Bound int64 // number of elements; <0 if unknown yet
}

func NewArray(elem *Type, bound int64) *Type {
	//如果bound小于0,则抛出一个致命错误。
	if bound < 0 {
		base.Fatalf("NewArray: invalid bound %v", bound)
	}
	//创建一个新的Type结构体,并将其类型设置为TARRAY,表示这是一个数组类型
	t := newType(TARRAY)

	//将t的extra字段设置为一个指向Array结构体的指针,
	//其中Elem字段指向elem所指向的Type结构体,表示该数组的元素类型;
	//Bound字段设置为bound,表示该数组的长度。
	t.extra = &Array{Elem: elem, Bound: bound}
	//如果elem所指向的Type结构体具有类型参数(即泛型类型),则将t的HasTParam字段设置为true
	if elem.HasTParam() {
		t.SetHasTParam(true)
	}
	//如果elem所指向的Type结构体具有形状信息(即元组类型),则将t的HasShape字段设置为true
	if elem.HasShape() {
		t.SetHasShape(true)
	}
	return t
}

而在生成中间结果时,types2.Array最终会通过types.NewArray()转换成types.Array类型:

//go1.20.3 path: src/cmd/compile/internal/noder/types.go
func (g *irgen) typ0(typ types2.Type) *types.Type {
    switch typ := typ.(type) {
    ...
    case *types2.Array:
        return types.NewArray(g.typ1(typ.Elem()), typ.Len())
    ...
}

编译时数组字面量初始化

数组类型解析可以得到数组元素的类型Elem以及数组长度Bound, 而数组字面量的初始化是在编译时类型检查阶段完成的,通过函数tcComplit -> typecheckarraylit循环字面量分别进行赋值。

tcComplit函数的作用是对一个复合字面量(composite literal)进行类型检查,它返回一个经过检查的 ir.Node 节点。

该函数相关数组的源码:

// go 1.20.3   path: /src/cmd/compile/internal/typecheck/expr.go
func tcCompLit(n *ir.CompLitExpr) (res ir.Node) {
    ...
    //获取 CompLitExpr 的类型 t
    t := n.Type()
  
    //通过调用断言函数AssertfAt确保类型t不为 nil。
  	//如果为 nil,则输出错误信息 "missing type in composite literal"。
    base.AssertfAt(t != nil, n.Pos(), "missing type in composite literal")
  
    //根据类型 t 的种类,执行不同的处理
    switch t.Kind() {
    ...
    //如果 t的种类为 TARRAY,即数组
    case types.TARRAY:
        //对数组字面量的元素进行类型检查,且将数组字面量中的每个元素转换为指定类型的值,并确保所有元素都具有相同的类型
        typecheckarraylit(t.Elem(), t.NumElem(), n.List, "array literal")
        //将操作符设置为 OARRAYLIT
        n.SetOp(ir.OARRAYLIT)
    ...
    return n
}

typecheckarraylit函数的作用是将数组字面量中的每个元素转换为指定类型的值,并确保所有元素都具有相同的类型。

它接受四个参数:

  • elemType 表示数组元素的类型
  • bound 表示数组的上限长度
  • elts 是一个 ir.Node 类型的切片,表示数组的元素列表
  • ctx 是一个字符串,用于在编译错误信息中提供上下文信息

该函数源码如下:

// go 1.20.3  path: src/cmd/compile/internal/typecheck/typecheck.go
func typecheckarraylit(elemType *types.Type, bound int64, elts []ir.Node, ctx string) int64 {
    ...
  	//遍历数组字面量 elts 中的每个元素
    for i, elt := range elts {
        //设置元素 elt 的位置信息
        ir.SetPos(elt)
        //将元素 elt 的值赋给变量r
        r := elts[i]
        ......
        //对变量r的值进行表达式转换
        r = Expr(r)
        //将变量r的值转换为指定类型 elemType 的值
        r = AssignConv(r, elemType, ctx)
        ...
}

编译时初始化字面量的优化

在类型检查(Type Checking)阶段,为了减少在程序运行期间的开销,在编译期间优化了复合字面量的初始化过程。

在编译期间,编译器将复合字面量转化为静态或动态的数据结构,并将其放入程序的数据段或堆中。而在运行期间,程序可以直接使用这些静态或动态的数据结构,而不需要再次计算或初始化。

编译器会在负责初始化字面量的 cmd/compile/internal/gc.anylit函数中做两种不同的优化:

  1. 当元素数量小于或者等于 4 个时,会直接将数组中的元素放置在栈上;

    假设代码需要初始化 [5]int{1, 2, 3, 4, 5},那么我们可以将上述过程理解成以下的伪代码:

    var arr [5]int
    statictmp_0[0] = 1
    statictmp_0[1] = 2
    statictmp_0[2] = 3
    statictmp_0[3] = 4
    statictmp_0[4] = 5
    arr = statictmp_0
    
  2. 当元素数量大于 4 个时,会将数组中的元素放置到静态区并在运行时取出;

    假设代码需要初始化 [3]int{1, 2, 3},那么我们可以将上述过程理解成以下的伪代码:

    var arr [3]int
    arr[0] = 1
    arr[1] = 2
    arr[2] = 3
    

来看看anylit关于数组初始化优化相关代码如下:

func anylit(n ir.Node, var_ ir.Node, init *ir.Nodes) {
	t := n.Type()
	switch n.Op() {
	......
	case ir.OSTRUCTLIT, ir.OARRAYLIT:
		n := n.(*ir.CompLitExpr)
		if !t.IsStruct() && !t.IsArray() {
			base.Fatalf("anylit: not struct/array")
		}

		// 针对字面量表达式的静态优化
		if isSimpleName(var_) && len(n.List) > 4 {
			vstat := readonlystaticname(t)
			ctxt := inInitFunction
			if n.Op() == ir.OARRAYLIT {
				ctxt = inNonInitFunction
			}
			// 进行静态优化
			fixedlit(ctxt, initKindStatic, n, vstat, init)
			// 将静态变量赋值给目标变量
			appendWalkStmt(init, ir.NewAssignStmt(base.Pos, var_, vstat))
			// 进行动态优化
			fixedlit(inInitFunction, initKindDynamic, n, var_, init)
			break
		}

		var components int64
		if n.Op() == ir.OARRAYLIT {
			components = t.NumElem()
		} else {
			components = int64(t.NumFields())
		}
		// 如果变量名是简单标识符或者字面量表达式中的元素数量小于目标结构体或数组中的元素数量,则给目标变量赋值为nil
		if isSimpleName(var_) || int64(len(n.List)) < components {
			appendWalkStmt(init, ir.NewAssignStmt(base.Pos, var_, nil))
		}

		// 执行局部代码优化
		fixedlit(inInitFunction, initKindLocalCode, n, var_, init)
	......
	}
}

上述代码中,还有个非常重要的函数:fixedlit,该 函数是 Go 语言内部编译器(compiler)的一部分,用于处理 Go 语言程序中的常量(constant)初始化过程。在 Go 语言中,常量可以通过字面值(literal value)或表达式(expression)进行定义,但它们的值必须在编译时确定。因此,在程序编译时,需要对所有常量进行求值,以生成相应的常量值。这个过程是由编译器内部的常量解析器(constant resolver)完成的,而 fixedlit 函数则是其中的一部分。

fixedlit 函数重点源码如下:

func fixedlit(ctxt initContext, kind initKind, n *ir.CompLitExpr, var_ ir.Node, init *ir.Nodes) {
	// 判断 var_ 是否为空标识符 _
	isBlank := var_ == ir.BlankNode
	var splitnode func(ir.Node) (a ir.Node, value ir.Node)
	switch n.Op() {
	//如果n的操作符是 OARRAYLIT 或者 OSLICELIT,则说明n表示一个数组或者一个切片
	case ir.OARRAYLIT, ir.OSLICELIT:
		var k int64
		// 定义一个匿名函数 splitnode,用于分离数组或切片中的每个元素
		splitnode = func(r ir.Node) (ir.Node, ir.Node) {
			if r.Op() == ir.OKEY {
				// 如果该元素是 key-value 形式,如 a:[2]int{0:1, 1:2},则需要解析key值
				kv := r.(*ir.KeyExpr)
				k = typecheck.IndexConst(kv.Key)
				if k < 0 {
					// key 值必须是非负整数
					base.Fatalf("fixedlit: invalid index %v", kv.Key)
				}
				r = kv.Value
			}
			// 构造IndexExpr节点,表示当前元素的索引, 如 a[0],a[1],a[2]
			a := ir.NewIndexExpr(base.Pos, var_, ir.NewInt(k))
			k++
			if isBlank {
				//如果当前元素没有对应变量,则返回 BlankNode 作为变量占位符
				return ir.BlankNode, r
			}
			// 返回变量和值
			return a, r
		}
	......
	}

	for _, r := range n.List {
		// 分割节点,提取数组/切片/键值对的下标和值
		a, value := splitnode(r)
		// 如果是匿名变量并且右值没有副作用,则继续处理下一个节点
		if a == ir.BlankNode && !staticinit.AnySideEffects(value) {
			continue
		}
		//处理不同类型的右值, 如果右值是一个复合字面量,则递归处理
		switch value.Op() {
		......
		//如果是数组/结构体字面量,递归调用 fixedlit 函数
		case ir.OARRAYLIT, ir.OSTRUCTLIT:
			value := value.(*ir.CompLitExpr)
			fixedlit(ctxt, kind, value, a, init)
			continue
		}

		// 判断右值是否为常量节点
		islit := ir.IsConstNode(value)

		// 如果当前为静态初始化且右值不是常量,则继续处理下一个节点
		// 如果当前为动态初始化且右值是常量,则继续处理下一个节点
		if (kind == initKindStatic && !islit) || (kind == initKindDynamic && islit) {
			continue
		}

		// 构造赋值语句节点
		ir.SetPos(a)
		as := ir.NewAssignStmt(base.Pos, a, value)
		as = typecheck.Stmt(as).(*ir.AssignStmt)

		// 根据不同的初始化类型,生成不同的赋值语句
		switch kind {
		// 静态初始化
		case initKindStatic:
			genAsStatic(as)  // 如果右值是常量,则直接将赋值语句添加到静态初始化列表中
		// 动态初始化
		case initKindDynamic, initKindLocalCode:
			// 如果右值是常量,则直接将赋值语句添加到动态初始化列表中
			appendWalkStmt(init, orderStmtInPlace(as, map[string][]*ir.Name{}))
		default:
			base.Fatalf("fixedlit: bad kind %d", kind)
		}
	}
}

该函数大致流程为:

  1. 根据初始化上下文 ctxt 和初始化类型 kind,分别设置只读变量的标识和初始化标识。

  2. 根据常量表达式的类型 n.Op(),分别处理数组字面值、结构体字面值和切片字面值,以及其它类型的字面值。

  3. 如果常量表达式的类型为数组、结构体或切片字面值,则遍历它们的元素,并对每个元素递归调用 fixedlit 函数。对于结构体字面值,还需要遍历它们的字段并递归调用 fixedlit 函数。

  4. 如果常量表达式的类型为其它类型的字面值,且其值可以通过常量表达式求值,则将求值结果存储到常量节点中。否则,将常量节点设置为 nil

总体来说,函数 fixedlit 的作用是对常量表达式进行求值,并将求得的结果存储到常量节点中。它还会递归处理复合类型的字面值,并对每个元素和字段递归调用 fixedlit 函数。函数中使用的变量和参数都有明确的含义,比如 ctxt 表示初始化上下文,kind 表示初始化类型,n 表示常量表达式节点,var_ 表示常量节点等。

编译时数组索引越界检查

数组访问越界是非常严重的错误,Go在数组越界方面有如何方案:

  • 字面量访问下标:无论是数组的寻址还是赋值,都是在编译阶段完成的

    Go 语言中可以在编译期间的静态类型检查判断数组越界,如果访问越界在编译时就无法通过检查, 此检查通过 typecheck1函数处理,关键源码如下:

    // go 1.20.3  path: src/cmd/compile/internal/typecheck/typecheck.go
    func typecheck1(n ir.Node, top int) ir.Node {
      ......
      switch n.Op() {
      ......
      case ir.OINDEX:
            n := n.(*ir.IndexExpr)
            return tcIndex(n)
      ......
      }
    }
    
    // go 1.20.3  path: src/cmd/compile/internal/typecheck/expr.go
    func tcIndex(n *ir.IndexExpr) ir.Node {
    	//调用Expr 函数对被索引的对象进行语义分析
    	n.X = Expr(n.X)
    	//再调用 DefaultLit 函数对结果进行类型推导
    	n.X = DefaultLit(n.X, nil)
    	//调用 implicitstar 函数对结果进行处理
    	n.X = implicitstar(n.X)
    	l := n.X
    	n.Index = Expr(n.Index)
    	r := n.Index
    	t := l.Type()
    	//获取被索引的对象的类型,并判断该类型是否为 nil,或者索引值的类型是否为 nil,如果是,则返回 nil 并结束函数
    	if t == nil || r.Type() == nil {
    		n.SetType(nil)
    		return n
    	}
    	// 使用 switch 语句块,检查类型 t 的种类
    	switch t.Kind() {
    	......
    	// 如果类型是字符串类型、数组类型或切片类型
    	case types.TSTRING, types.TARRAY, types.TSLICE:
    		// 调用 indexlit 函数对索引表达式中的索引进行处理
    		n.Index = indexlit(n.Index)
    		// 如果类型是字符串类型,将索引表达式的类型设置为 ByteType,否则设置为元素类型
    		if t.IsString() {
    			n.SetType(types.ByteType)
    		} else {
    			n.SetType(t.Elem())
    		}
    		// 创建一个字符串变量 why,设置为 "string"
    		why := "string"
    		// 如果类型是数组类型,将 why 设置为 "array",如果是切片类型,将 why 设置为 "slice"
    		if t.IsArray() {
    			why = "array"
    		} else if t.IsSlice() {
    			why = "slice"
    		}
    		// 如果索引表达式的类型不为空且不是整数类型,输出错误信息
    		if n.Index.Type() != nil && !n.Index.Type().IsInteger() {
    			base.Errorf("non-integer %s index %v", why, n.Index)
    			return n
    		}
    		// 如果索引表达式未绑定且是整数类型的常量
    		if !n.Bounded() && ir.IsConst(n.Index, constant.Int) {
    			x := n.Index.Val()
    			// 如果索引小于 0,输出错误信息
    			if constant.Sign(x) < 0 {
    				base.Errorf("invalid %s index %v (index must be non-negative)", why, n.Index)
    				// 如果类型是数组类型且索引大于等于数组长度,输出错误信息
    			} else if t.IsArray() && constant.Compare(x, token.GEQ, constant.MakeInt64(t.NumElem())) {
    				base.Errorf("invalid array index %v (out of bounds for %d-element array)", n.Index, t.NumElem())
    				// 如果类型是字符串类型且索引大于等于字符串长度,输出错误信息
    			} else if ir.IsConst(n.X, constant.String) && constant.Compare(x, token.GEQ, constant.MakeInt64(int64(len(ir.StringVal(n.X))))) {
    				base.Errorf("invalid string index %v (out of bounds for %d-byte string)", n.Index, len(ir.StringVal(n.X)))
    				// 如果索引溢出,输出错误信息
    			} else if ir.ConstOverflow(x, types.Types[types.TINT]) {
    				base.Errorf("invalid %s index %v (index too large)", why, n.Index)
    			}
    		}
    		......
    	}
    	return n
    }
    
  • 编译器无法提前发现错误,我们需要 Go 语言运行时阻止

    数组和字符串的一些简单越界错误都会在编译期间发现,例如:直接使用整数或者常量访问数组;但是如果使用变量去访问数组或者字符串时,编译器就无法提前发现错误,我们需要 Go 语言运行时阻止不合法的访问。

    Go 语言运行时在发现数组、切片和字符串的越界操作会由运行时的 runtime.panicIndexruntime.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 main
    
    func outOfArray() int {
    	arr := [3]int{1, 2, 3}
    	i := 2
    	elem := arr[i]
    	return elem
    }
    
    func main() {}
    
    $ GOSSAFUNC=outOfRange go build main.go
    dumped SSA to ./ssa.html
    

    start 阶段生成的 SSA 代码就是优化之前的第一版中间代码,下面展示的部分是 elem := arr[i] 对应的中间代码,在这段中间代码中我们发现 Go 语言为数组的访问操作生成了判断数组上限的指令 IsInBounds 以及当条件不满足时触发程序崩溃的 PanicBounds

    • IsInBounds 函数用于检查索引是否在数组或切片的有效范围内。如果索引越界,它将返回 false,否则返回 true
    • PanicBounds 函数用于处理索引越界情况。如果索引越界,它将引发一个运行时 panic。如果索引没有越界,它什么也不做

    SSA代码如下:

    start
    b1:-
    ......
    v20 (6) = LocalAddr <*[3]int> {arr} v2 v19
    v21 (6) = IsInBounds <bool> v13 v10
    If v21 → b2 b3 (likely) (6)
    b2: ← b1-
    v24 (6) = PtrIndex <*int> v20 v13
    v25 (6) = Copy <mem> v19
    v26 (6) = Load <int> v24 v25 (elem[int])
    v27 (7) = MakeResult <int,mem> v26 v25
    Ret v27 (+7)
    b3: ← b1-
    v22 (6) = Copy <mem> v19
    v23 (6) = PanicBounds <mem> [0] v13 v10 v22
    Exit v23 (6)
    name i[int]: v13
    name elem[int]: v26
    

    编译器会将 PanicBounds 指令转换成上面提到的 runtime.panicIndex函数,当数组下标没有越界时,编译器会先获取数组的内存地址和访问的下标、利用 PtrIndex 计算出目标元素的地址,最后使用 Load 操作将指针中的元素加载到内存中。

    当然只有当编译器无法对数组下标是否越界无法做出判断时才会加入 PanicBounds 指令交给运行时进行判断,在使用字面量整数访问数组下标时会生成非常简单的中间代码,当我们将上述代码中的 arr[i] 改成 arr[2] 时,就会得到如下所示的代码:

    start
    b1:-
    ......
    v20 (5) = LocalAddr <*[3]int> {arr} v2 v19
    v21 (+5) = PtrIndex <*int> v20 v13
    v22 (5) = Load <int> v21 v19 (elem[int])
    v23 (+6) = MakeResult <int,mem> v22 v19
    Ret v23 (+6)
    name elem[int]: v22
    

    Go 语言对于数组的访问还是有着比较多的检查的,它不仅会在编译期间提前发现一些简单的越界错误并插入用于检测数组上限的函数调用,还会在运行期间通过插入的函数保证不会发生越界。

总结

  • 数组是go语言中的特殊类型,其与其他语言不太一样。他不可以添加,但是可以获取值,获取长度。
  • 数组的拷贝都是值拷贝,因此不要尽量不要进行大数组的拷贝。
  • 常量的下标以及某一些变量的下标的访问越界问题可以在编译时检测到,但是变量的下标的数组越界问题只会在运行时报错。
  • 数组的声明中,存在一个语法糖。[…]int{2,3,4},但是本质本没有什么差别
  • 在编译期的优化阶段,还会进行重要的优化。当数组的长度小于4时,在运行时会在栈中进行初始化。当数组的长度大于4,会在静态区初始化数组
  • 其实我们在go语言中对于数组用得较少,而是更多的使用切片

参考资料:
Draven https://draveness.me/golang/

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值