5.Go编程快速入门学习

2022-09-29 16:22:32 浏览数 (1)

[TOC]

0x01 Go语言基础之错误处理

描述: Go语言中目前(1.16 版本中)是没有异常处理机制(Tips :说是在2.x版本中将会加入异常处理机制),但我们可以使用error接口定义以及panic/recover函数来进行异常错误处理。

1.error 接口定义

描述: 在Golang中利用error类型实现了error接口,并且可以通过errors.New或者fmt.Errorf来快速创建错误实例。

主要应用场景: 在 Go 语言中,错误是可以预期的,并且不是非常严重,不会影响程序的运行。对于这类问题可以用返回错误给调用者的方法,让调用者自己决定如何处理,通常采用 error 接口进行实现。

error接口定义:

代码语言:javascript复制
type error interface {
  Error() string
}

Go语言的标准库代码包errors方法:

代码语言:javascript复制
// 方式1.在errors包中的New方法(Go 1.13 版本)。
package errors
// go提供了errorString结构体,其则实现了error接口
type errorString struct {
  text string
}
func (e *errorString) Error() string {
  return e.text
}

// 在errors包中,还提供了New函数,来实例化errorString,如下:
func New(text string) error {
  return &errorString{text}
}

// 方式2.另一个可以生成error类型值的方法是调用fmt包中的Errorf函数(Go 1.13 版本以后)
package fmt
import "errors"
func Errorf(format string, args ...interface{}) error{
  return errors.New(Sprintf(format,args...))
}

采用 errors 包中装饰一个错误;

代码语言:javascript复制
errors.Unwrap(err error)	//通过 errors.Unwrap 函数得到被嵌套的 error。	
errors.Is(err, target error)	//用来判断两个 error 是否是同一个	
errors.As(err error, target interface{})	//error 断言

实际示例1:

代码语言:javascript复制
package main

import (
  "errors"
  "fmt"
  "math"
)

// 错误处理
// 1.Error
func demo1() {
  // 1.声明并初始化为error类型
  var errNew error = errors.New("# 错误信息来自 errors.New 方法。")
  fmt.Println(errNew)

  // 2.调用标准库中Errorf方法
  errorfFun := fmt.Errorf("- %s", "错误信息来自 fmt.Errorf 方法。")
  fmt.Println(errorfFun)

  // 3.实际案例
  result, err := func(a, b float64) (ret float64, err error) {
    err = nil
    if b == 0 {
      err = errors.New("此处幂指数不能为0值,其结果都为1")
      ret = 1
    } else {
      ret = math.Pow(a, b)
    }
    return
  }(5, 0)

  if err != nil {
    fmt.Println("# 输出错误信息:", err)
    fmt.Printf("5 ^ 0 = %v", result)
  } else {
    fmt.Printf("5 ^ 2 = %v", result)
  }
}

func main() {
  demo1()
}

执行结果:

代码语言:javascript复制
# 错误信息来自 errors.New 方法。
- 错误信息来自 fmt.Errorf 方法。
# 输出错误信息: 此处幂指数不能为0值,其结果都为1
5 ^ 0 = 1

实际示例2:

代码语言:javascript复制
package main

import (
    "fmt"
)

// 定义一个 DivideError 结构 (值得学习)
type DivideError struct {
  dividee int
  divider int
}
// 实现 `error` 接口 (值得学习)
func (de *DivideError) Error() string {
  strFormat := `
  Cannot proceed, the divider is zero.
  dividee: %d
  divider: 0
`
  return fmt.Sprintf(strFormat, de.dividee)
}

// 定义 `int` 类型除法运算的函数
func Divide(varDividee int, varDivider int) (result int, errorMsg string) {
  if varDivider == 0 {
    dData := DivideError{
            dividee: varDividee,
            divider: varDivider,
    }
    errorMsg = dData.Error()
    return
  } else {
    return varDividee / varDivider, ""
  }
}

func main() {
  // 正常情况
  if result, errorMsg := Divide(100, 10); errorMsg == "" {
    fmt.Println("100/10 = ", result)
  }
  // 当除数为零的时候会返回错误信息
  if _, errorMsg := Divide(100, 0); errorMsg != "" {
    fmt.Println("errorMsg is: ", errorMsg)
  }
}

执行结果:

代码语言:javascript复制
100/10 =  10
errorMsg is:  
  Cannot proceed, the divider is zero.
  dividee: 100
  divider: 0

2.panic 函数

描述: 当遇到某种严重的问题时需要直接退出程序时,应该调用panic函数从而引发的panic异常, 所以panic用于不可恢复的错误类似于Java的Error。

具体流程:是当panic异常发生时,程序会中断运行,并立即执行在该goroutine,随后程序崩溃并输出日志信息。日志信息包括panic、以及value的函数调用的堆栈跟踪信息。

panic 函数语法定义:

代码语言:javascript复制
func panic(v interface{})

Tips : panic函数接受任何值作为参数

示例1.数组越界会自动调用panic

代码语言:javascript复制
func TestA() {
  fmt.Println("func TestA{}")	
}

func TestB(x int) {
  var a [10]int
  a[x] = 111
}

func TestC() {
  fmt.Println("func TestC()")
}

func main() {
TestA()
TestB(20) //发生异常,中断程序
TestC()
}

执行结果:

代码语言:javascript复制
>>> func TestA{}
panic: runtime error: index out of rang

示例2.调用panic函数引发的panic异常

代码语言:javascript复制
func A() {
  fmt.Println("我是A函数 - 正常执行")
}

func B() {
  fmt.Println("我是B函数 - 正在执行")
  panic("func B():panic")
  fmt.Println("我是B函数 - 结束执行")
}

func C() {
  fmt.Println("我是c函数 - 正在执行")
}

func demo2() {
  A()
  B() //发生异常,中断程序
  C()
}

执行结果:

代码语言:javascript复制
我是A函数 - 正常执行
我是B函数 - 正在执行
发生异常: panic
"func B():panic"
Stack:
  2  0x00000000004b69a5 in main.B
      at /home/weiyigeek/app/project/go/src/weiyigeek.top/studygo/Day02/05error.go:47
  3  0x00000000004b6a8a in main.demo2
      at /home/weiyigeek/app/project/go/src/weiyigeek.top/studygo/Day02/05error.go:57
  4  0x00000000004b6ac5 in main.main
      at /home/weiyigeek/app/project/go/src/weiyigeek.top/studygo/Day02/05error.go:63

WeiyiGeek.panic异常

Q: 什么时候使用Error,什么时候使用Panic?

  • 对于真正意外的情况,那些表示不可恢复的程序错误,例如索引越界、不可恢复的环境问题、栈溢出、数据库连接后需操作,我们才使用 panic。
  • 对于其他的错误情况,我们应该是期望使用 error 来进行判定。

3.recover 函数

描述: panic异常会导致程序崩溃,而recover函数专门用于“捕获”运行时的panic异常,它可以是当前程序从运行时panic的状态中恢复并重新获得流程控制权。

通常我们会使用 Recover 捕获 Panic 异常,例如Java中利用Catch Throwable来进行捕获异常。

代码语言:javascript复制
// Java
try {
  ...
} catch (Throwable t) {
  ...
}

// C  
try {
  ...
} catch() {

}

panic 函数语法定义:

代码语言:javascript复制
func recover() interface{}

Tips: 在未发生panic时调用recover会返回nil。

流程说明: 如果调用了内置函数recover,并且定义该defer语句的函数发生了panic异常,recover会使程序从panic中恢复,并返回panic value。导致panic异常的函数不会继续运行,但能正常返回。

示例1:panic与recover联合使用,此处采用 panic 演示的代码中的B函数进行继续修改 描述: 在Go语言中可以通过defer定义的函数去执行一些错误恢复的行为

代码语言:javascript复制
func recoverB() (err error) {
  fmt.Println("我是recoverB 函数 - 正在执行")
  // 必须是 defer 语句中以及在panic函数前
  defer func() {
    x := recover()
    if x != nil {
      err = fmt.Errorf("# 1.进行 recover(恢复) Panic 导致的程序异常,从此之后将会继续执行后续代码:n%v", x)
    }
  }() // 此处利用匿名函数
  //panic("# 2.recoverB 函数中捕获 Panic")
  panic(errors.New("# 2.recoverB 函数中出现 Panic"))
  fmt.Println("我是recoverB 函数 - 结束执行") // 无法访问的代码
  return
}
func demo3() {
  A()
  err := recoverB()
  if err != nil {
    fmt.Println("#recoverB 输出的信息:", err)
  }
  C()
}

执行结果:

代码语言:javascript复制
我是A函数 - 正常执行
我是recoverB 函数 - 正在执行
# recoverB 输出的信息: # 1.进行 recover(恢复) Panic 导致的程序异常,从此之后将会继续执行后续代码:
# 2.recoverB 函数中出现 Panic
我是c函数 - 正在执行

示例 2.recover捕获异常后的异常,不能再次被recover捕获。

代码语言:javascript复制
func demo4() {
  // 采用匿名函数进行立即执行该函数
  defer func() { //   声明defer,
    fmt.Println("----调用 defer func1 start----")
    err := recover() // 此处输出为 nil ,因为panic只能被 recover 捕获一次
    fmt.Printf("# 第二次 捕获 : %#v n", err)
    if err != nil {
      fmt.Println(err)
    }
    fmt.Println("----调用 defer func1 end----")
  }()

  defer func() { //   声明defer,压栈操作后进先出。
    fmt.Println("----调用 defer func2 start----")
    if err := recover(); err != nil {
      fmt.Println("# 第一次 捕获:", err) // 这里的err其实就是panic传入的内容
    }
    fmt.Println("----调用 defer func2 end----")
  }()

  panic("panic 异常 抛出 测试!")
}

执行结果:

代码语言:javascript复制
----调用 defer func2 start----
# 第一次 捕获: panic 异常 抛出 测试!
----调用 defer func2 end----
----调用 defer func1 start----
# 第二次 捕获 : <nil> 
----调用 defer func1 end----

Q: panic() 与 recover() 位置区别? 答: panic函数可以在任何地方引发(但panic退出前会执行defer指定的内容),但recover函数只有在defer调用的函数中有效并且一定要位于panic语句之前

TIPS : 非常注意下面这种“错误方式”, 他可能会形成僵尸服务进程,导致Health Check失效。

代码语言:javascript复制
defer func() {
  if err := recover(); err != nil {
    Log.Error("Recovered Panic", err)
  }
}()

Q: panic 和 os.Exit 联用时对recover的影响

  • os.Exit 退出时不会调用defer指定的函数.
  • os.Exit 退出时不会输出当前调用栈信息.

4.错误处理最佳实践

  • 1、预定义错误,code里判断
  • 2、及早失败,避免嵌套

0x02 Go语言基础之结构体

描述: Go语言中没有“类”的概念,也不支持“类”的继承等面向对象的概念。但 Go语言中通过结构体的内嵌配合接口比面向对象具有更高的扩展性灵活性

  • Go语言中的基础数据类型可以表示一些事物的基本属性,但是当我们想表达一个事物的全部或部分属性时,这时候再用单一的基本数据类型明显就无法满足需求了(局限性)。
  • Go语言提供了一种自定义数据类型,可以封装多个基本数据类型,这种数据类型叫结构体(英文名称struct), 我们可以通过struct来定义自己的类型了。

简单得说: 结构体时一种数据类型,一种我们自己可以保持多个维度数据的类型。 所以与其他高级编程语言一样,Go语言也可以采用结构体的特性, 并且Go语言通过struct来实现面向对象

1.类型定义

描述: 在Go语言中有一些基本的数据类型,如string、int{}整型、float{}浮点型、boolean布尔等数据类型, Go语言中可以使用type关键字来定义自定义类型(实际上定义了一个全新的类型)。

Tips : 我们可以基于内置的基本类型定义,也可以通过struct定义。

示例演示:

代码语言:javascript复制
//将MyInt定义为int类型
type MyInt int

通过type关键字的定义,MyInt就是一种新的类型,它具有int的特性。

2.类型别名

描述: 类型别名从字面意义上都很好理解,即类型别名本章上与原类型一样, 就比如像一个孩子小时候有小名、乳名,上学后用学名,英语老师又会给他起英文名,但这些名字都指的是他本人。

示例演示:

代码语言:javascript复制
// TypeAlias只是Type的别名,本质上TypeAlias与Type是同一个类型
type TypeAlias = Type

我们之前见过的runebyte就是类型别名,他们的定义如下:

代码语言:javascript复制
type byte = uint8
type rune = int32

Tips: 采用int32别名创建一个变量的几种方式。

代码语言:javascript复制
type MyInt32 = int32
// 方式1
var i MyInt32
i = 1024
// 方式2
var j MyInt32 = 1024
// 方式3
var k  = MyInt32(1024)
// 方式4
l := MyInt32(1024)  // 此处并非是函数,而是一个强制类型转换而已

Q: 类型定义和类型别名有何区别?

答: 类型别名与类型定义表面上看只有一个等号的差异,我们通过下面的这段代码来理解它们之间的区别。

示例演示1:

代码语言:javascript复制
//1.类型定义
type NewInt int

//2.类型别名
type MyInt = int

// 类型定义 与 类型别名 区别演示
func demo1() {
  // 类型定义的使用
  var i NewInt
  i = 1024
  fmt.Printf("Type of i: %T, Value:%v n", i, i)

  // 类型别名的使用
  var j MyInt
  j = 2048
  fmt.Printf("Type of j: %T, Value:%v n", j, j)

  // rune 也是类型别名底层还是int32类型
  var k rune
  k = '中'
  fmt.Printf("Type of j: %T, Value:%c n", k, k)
}

执行结果:

代码语言:javascript复制
Type of i: main.NewInt, Value:1024 
Type of j: int, Value:2048 
Type of j: int32, Value:中

结果显示说明:

  • i 变量的类型是main.NewInt,表示main包下定义的NewInt类型。
  • j 变量的类型是int,因MyInt类型只会在代码中存在,编译完成时并不会有MyInt类型。

3.结构体的定义

描述: 语言内置的基础数据类型是用来描述一个值的,而结构体是用来描述一组值的。比如一个人有名字、年龄和居住城市等,本质上是一种聚合型的数据类型

使用typestruct关键字来定义结构体,具体代码格式如下:

代码语言:javascript复制
type 类型名 struct {
  字段名 字段类型
  字段名 字段类型
  …
}

其中:

  • 类型名:标识自定义结构体的名称,在同一个包内不能重复。
  • 字段名:表示结构体字段名。结构体中的字段名必须唯一。
  • 字段类型:表示结构体字段的具体类型。

举例说明: 以定义一个Person(人)结构体为例:

代码语言:javascript复制
// 方式(0)
var v struct{}

// 方式(1)
type person struct {
  name string
  city string
  age  int8
}

// 方式(2): 同样类型的字段也可以写在一行
type person1 struct {
  name, city string
  age   int8
}

Tips : 上面创建了结构体一个person的自定义类型,它有name、city、age三个字段,分别表示姓名、城市和年龄。这样我们使用这个person结构体就能够很方便的在程序中表示和存储人信息了。

4.结构体实例化

描述: 只有当结构体实例化时,才会真正地分配内存。也就是必须实例化后才能使用结构体的字段。

Tips :结构体本身也是一种类型,我们可以像声明内置类型一样使用var关键字声明结构体类型。例如:var 结构体实例 结构体类型

描述: 结构体初始化是非常必要,因为没有初始化的结构体,其成员变量都是对应其类型的零值。

结构体示例化的三种语法格式:

代码语言:javascript复制
type demo struct {
  username string
  city string
}

// 1.方式1.利用`.`进行调用指定属性
var m1 demo
demo.username = "WeiyiGeek"

// 2.方式2.使用键值对初始化
m2 := demo {username: "WeiyiGeek",city:"重庆",}
m2 := &demo {username: "WeiyiGeek",city:"重庆",} // ==> new(demo) 此种方式会在结构体指针里面实践。

// 3.方式3.使用值的列表初始化
m3 := demo {
  "WeiyiGeek",
  "重庆"
}
m3 := &demo {
  "WeiyiGeek",
  "重庆"
}

Tips : 特别注意在使用值的列表初始化这种格式初始化时, (1)必须初始化结构体的所有字段,(2)初始值的填充顺序必须与字段在结构体中的声明顺序一致,(3) 该方式不能和键值初始化方式混用。

示例演示: 下述演示三种基础方式进行结构体的实例化。

代码语言:javascript复制
// 1.结构体初识还是老示例采用结构体描述人员信息并进行赋值使用
type Person struct {
  name  string
  age   uint8
  sex   bool
  hobby []string
}

func demo1() {
  // 方式1.声明一个Persin类型的变量x
  var x Person
  // 通过结构体中的属性进行赋值
  x.name = "WeiyiGeek"
  x.age = 20
  x.sex = true // {Boy,Girl)
  x.hobby = []string{"Basketball", "乒乓球", "羽毛球"}
  // 输出变量x的类型以及其字段的值
  fmt.Printf("Type of x : %T, Value : %v n", x, x)
  x.name = "WeiyiGeeker"
  // 我们通过.来访问结构体的字段(成员变量), 例如x.name和x.age等。
  fmt.Printf("My Name is %v n", x.name)

  // 方式2.在声明是进行赋值(key:value,或者 value)的值格式
  // 使用键值对初始化
  var y = Person{
    name:  "Go",
    age:   16,
    sex:   false,
    hobby: []string{"Computer", "ProgramDevelopment"},
  }
  fmt.Printf("Type of y : %T, Value : %v n", y, y)
  // 非常注意此种方式是按照结构体中属性顺序进行赋值,同样未赋值的为该类型的零值
  // 使用值的列表初始化
  z := Person{
    "WeiyiGeek",
    10,
    true,
    []string{},
  }
  fmt.Printf("Type of z : %T, Value : %v n", z, z)
}

执行结果:

代码语言:javascript复制
Type of x : main.Person, Value : {WeiyiGeek 20 true [Basketball 乒乓球 羽毛球]} 
My Name is WeiyiGeeker 
Type of y : main.Person, Value : {Go 16 false [Computer ProgramDevelopment]} 
Type of z : main.Person, Value : {WeiyiGeek 10 true []}

Tips : 如果没有给结构体中的属性赋值,则默认采用该类型的零值。

5.结构体内存布局

描述: 结构体占用一块连续的内存,但是需要注意空结构体是不占用空间的。

连续内存空间

示例演示:

代码语言:javascript复制
// 示例1.空结构体是不占用空间的
var v struct{}
fmt.Println(unsafe.Sizeof(v))  // 0


// 示例2.结构体占用一块连续的内存
type test struct {
  a int8
  b int8
  c int8
  d int8
}
n := test{
  1, 2, 3, 4,
}
fmt.Printf("n.a %p, int8 size: %dn", &n.a, unsafe.Sizeof(bool(true)))
fmt.Printf("n.b %pn", &n.b)
fmt.Printf("n.c %pn", &n.c)
fmt.Printf("n.d %pn", &n.d)

// 执行结果:
n.a 0xc0000a0060
n.b 0xc0000a0061
n.c 0xc0000a0062
n.d 0xc0000a0063
内存对齐分析

[进阶知识点] 关于在 Go 语言中恰到好处的内存对齐 描述: 在讲解前内存对齐前, 我们先丢出两个struct结构体引发思考:

示例1. 注意两个结构体中声明不同元素类型的顺序。

代码语言:javascript复制
type Part1 struct {
  a bool
  b int32
  c int8
  d int64
  e byte
}

type Part2 struct {
  e byte
  c int8
  a bool
  b int32
  d int64
}

在开始之前,希望你计算一下 Part1 与 Part2 两个结构体分别占用的大小是多少呢?

代码语言:javascript复制
func typeSize() {
  fmt.Printf("bool size: %dn", unsafe.Sizeof(bool(true)))
  fmt.Printf("int32 size: %dn", unsafe.Sizeof(int32(0)))
  fmt.Printf("int8 size: %dn", unsafe.Sizeof(int8(0)))
  fmt.Printf("int64 size: %dn", unsafe.Sizeof(int64(0)))
  fmt.Printf("byte size: %dn", unsafe.Sizeof(byte(0)))
  fmt.Printf("string size: %dn", unsafe.Sizeof("WeiyiGeek"))  // 注意上面声明的结构体中没有该类型。
}

// 输出结果
bool size: 1
int32 size: 4
int8 size: 1
int64 size: 8
byte size: 1
string size: 16

这么一算 Part1/Part2 结构体的占用内存大小为 1 4 1 8 1 = 15 个字节。相信有的小伙伴是这么算的,看上去也没什么毛病

真实情况是怎么样的呢?我们实际调用看看,如下:

代码语言:javascript复制
func main() {
  part1 := Part1{}
  fmt.Printf("part1 size: %d, align: %dn", unsafe.Sizeof(part1), unsafe.Alignof(part1))
  fmt.Println()
  part2 := Part2{}
  fmt.Printf("part2 size: %d, align: %dn", unsafe.Sizeof(part2), unsafe.Alignof(part2))
}

执行结果:

代码语言:javascript复制
part1 size: 32, align: 8
part2 size: 16, align: 8

Tips : `unsafe.Sizeof` 来返回相应类型的空间占用大小
Tips : `unsafe.Alignof` 来返回相应类型的对齐系数

从上述结果中可以看见 part1 占用32个字节而 part2 占用16字节,此时 part1 比我们上面计算结构体占用字节数多了16 Byte, 并且相同的元素类型但顺序不同的 part2 是正确的只占用了 16 Byte, 那为什么会出现这样的情况呢?同时这充分地说明了先前的计算方式是错误的。

在这里要提到 “内存对齐” 这一概念,才能够用正确的姿势去计算,接下来我们详细的讲讲它是什么

Q: What 什么是内存对齐? 答:有的小伙伴可能会认为内存读取,就是一个简单的字节数组摆放(例图1) 表示一个坑一个萝卜的内存读取方式。但实际上 CPU 并不会以一个一个字节去读取和写入内存, 相反 CPU 读取内存是一块一块读取的,块的大小可以为 2、4、6、8、16 字节等大小, 块大小我们称其为内存访问粒度(例图2):

WeiyiGeek.内存对齐

在样例中,假设访问粒度为 4。 CPU 是以每 4 个字节大小的访问粒度去读取和写入内存的。这才是正确的姿势

Q: Why 为什么要关心对齐?

  • 你正在编写的代码在性能(CPU、Memory)方面有一定的要求
  • 你正在处理向量方面的指令
  • 某些硬件平台(ARM)体系不支持未对齐的内存访问

Q: Why 为什么要做对齐?

  • 平台(移植性)原因:不是所有的硬件平台都能够访问任意地址上的任意数据。例如:特定的硬件平台只允许在特定地址获取特定类型的数据,否则会导致异常情况
  • 性能原因:若访问未对齐的内存,将会导致 CPU 进行两次内存访问,并且要花费额外的时钟周期来处理对齐及运算。而本身就对齐的内存仅需要一次访问就可以完成读取动作

WeiyiGeek.内存申请

在上图中,假设从 Index 1 开始读取,将会出现很崩溃的问题, 因为它的内存访问边界是不对齐的。因此 CPU 会做一些额外的处理工作。如下:

  • 1.CPU 首次读取未对齐地址的第一个内存块,读取 0-3 字节。并移除不需要的字节 0
  • 2.CPU 再次读取未对齐地址的第二个内存块,读取 4-7 字节。并移除不需要的字节 5、6、7 字节
  • 3.合并 1-4 字节的数据
  • 4.合并后放入寄存器

从上述流程可得出,不做 “内存对齐” 是一件有点 “麻烦” 的事。因为它会增加许多耗费时间的动作, 而假设做了内存对齐,从 Index 0 开始读取 4 个字节,只需要读取一次,也不需要额外的运算。这显然高效很多,是标准的空间换时间做法

默认系数 描述: 在不同平台上的编译器都有自己默认的 “对齐系数”,可通过预编译命令 #pragma pack(n) 进行变更,n 就是代指 “对齐系数”。一般来讲,我们常用的平台的系数如下:32 位:4, 64 位:8, 例如, 前面示例中的对齐系数是8验证了我们系统是64位的。

另外要注意不同硬件平台占用的大小和对齐值都可能是不一样的。因此本文的值不是唯一的,调试的时候需按本机的实际情况考虑

不同数据类型的对齐系数

代码语言:javascript复制
func main() {
  fmt.Printf("bool align: %dn", unsafe.Alignof(bool(true)))
  fmt.Printf("byte align: %dn", unsafe.Alignof(byte(0)))
  fmt.Printf("int8 align: %dn", unsafe.Alignof(int8(0)))
  fmt.Printf("int32 align: %dn", unsafe.Alignof(int32(0)))
  fmt.Printf("int64 align: %dn", unsafe.Alignof(int64(0)))
  fmt.Printf("string align: %dn", unsafe.Alignof("WeiyiGeek"))
  fmt.Printf("map align: %dn", unsafe.Alignof(map[string]string{}))
}

执行结果:

代码语言:javascript复制
bool align: 1
byte align: 1
int8 align: 1
int32 align: 4
int64 align: 8
string align: 8
map align: 8

通过观察输出结果,可得知基本都是 2^n,最大也不会超过 8。这是因为我手提(64 位)编译器默认对齐系数是 8,因此最大值不会超过这个数。

Tips: 在上小节中提到了结构体中的成员变量要做字节对齐。那么想当然身为最终结果的结构体,也是需要做字节对齐的

对齐规则

  • 1.结构体的成员变量,第一个成员变量的偏移量为 0。往后的每个成员变量的对齐值必须为编译器默认对齐长度(#pragma pack(n))或当前成员变量类型的长度(unsafe.Sizeof),取最小值作为当前类型的对齐值。其偏移量必须为对齐值的整数倍
  • 2.结构体本身,对齐值必须为编译器默认对齐长度(#pragma pack(n))或结构体的所有成员变量类型中的最大长度,取最大数的最小整数倍作为对齐值
  • 3.结合以上两点,可得知若编译器默认对齐长度(#pragma pack(n))超过结构体内成员变量的类型最大长度时,默认对齐长度是没有任何意义的

分析流程

Step 1.首先我们先来分析 part1 结构体 到底经历了些什么,影响了 “预期” 结果

成员变量

类型

偏移量

自身占用

a

bool

0

1

字节对齐

1

3

b

int32

4

4

c

int8

8

1

字节对齐

9

7

d

int64

16

8

e

byte

24

1

字节对齐

25

7

总占用大小

-

-

32

成员对齐步骤

  • 第一个成员 a
    • 类型为 bool
    • 大小/对齐值为 1 字节
    • 初始地址,偏移量为 0。占用了第 1 位
  • 第二个成员 b
    • 类型为 int32
    • 大小/对齐值为 4 字节
    • 根据规则 1,其偏移量必须为 4 的整数倍。确定偏移量为 4,因此 2-4 位为 Padding(理解点)。而当前数值从第 5 位开始填充,到第 8 位。如下:axxx|bbbb
  • 第三个成员 c
    • 类型为 int8
    • 大小/对齐值为 1 字节
    • 根据规则1,其偏移量必须为 1 的整数倍。当前偏移量为 8。不需要额外对齐,填充 1 个字节到第 9 位。如下:axxx|bbbb|c…
  • 第四个成员 d
    • 类型为 int64
    • 大小/对齐值为 8 字节
    • 根据规则 1,其偏移量必须为 8 的整数倍。确定偏移量为 16,因此 9-16 位为 Padding。而当前数值从第 17 位开始写入,到第 24 位。如下:axxx|bbbb|cxxx|xxxx|dddd|dddd
  • 第五个成员 e
    • 类型为 byte
    • 大小/对齐值为 1 字节
    • 根据规则 1,其偏移量必须为 1 的整数倍。当前偏移量为 24。不需要额外对齐,填充 1 个字节到第 25 位。如下:axxx|bbbb|cxxx|xxxx|dddd|dddd|e…

整体对齐步骤

  • 在每个成员变量进行对齐后,根据规则 2,整个结构体本身也要进行字节对齐,因为可发现它可能并不是 2^n,不是偶数倍。显然不符合对齐的规则
  • 根据规则 2,可得出对齐值为 8。现在的偏移量为 25,不是 8 的整倍数。因此确定偏移量为 32。对结构体进行对齐

结果说明:

最终 Part1 内存布局 axxx|bbbb|cxxx|xxxx|dddd|dddd|exxx|xxxx

通过本节的分析,可得知先前的 “推算” 为什么错误? 是因为实际内存管理并非 “一个萝卜一个坑” 的思想。而是一块一块。通过空间换时间(效率)的思想来完成这块读取、写入。另外也需要兼顾不同平台的内存操作情况

Step 2.通过上述我们可知根据成员变量的类型不同,其结构体的内存会产生对齐等动作。而像 part2 结构体一样,按照变量类型对齐值从小到大,进行依次排序进行占用内存空间的结果分析。

通过开头的示例我们可知,只是 “简单” 对成员变量的字段顺序(类型占用字节数从小到大排序)进行改变,就改变了结构体占用大小。

成员变量

类型

偏移量

自身占用

e

byte

0

1

c

int8

1

1

a

bool

2

1

字节对齐

3

1

b

int32

4

4

d

int64

8

8

总占用大小

-

-

16

成员对齐

  • 第一个成员 e
    • 类型为 byte
    • 大小/对齐值为 1 字节
    • 初始地址,偏移量为 0。占用了第 1 位
  • 第二个成员 c
    • 类型为 int8
    • 大小/对齐值为 1 字节
    • 根据规则1,其偏移量必须为 1 的整数倍。当前偏移量为 2。不需要额外对齐
  • 第三个成员 a
    • 类型为 bool
    • 大小/对齐值为 1 字节
    • 根据规则1,其偏移量必须为 1 的整数倍。当前偏移量为 3。不需要额外对齐
  • 第四个成员 b
    • 类型为 int32
    • 大小/对齐值为 4 字节
    • 根据规则1,其偏移量必须为 4 的整数倍。确定偏移量为 4,因此第 3 位为 Padding(理解点)。而当前数值从第 4 位开始填充,到第 8 位。如下:ecax|bbbb
  • 第五个成员 d
    • 类型为 int64
    • 大小/对齐值为 8 字节
    • 根据规则1,其偏移量必须为 8 的整数倍。当前偏移量为 8。不需要额外对齐,从 9-16 位填充 8 个字节。如下:ecax|bbbb|dddd|dddd

整体对齐: 由于符合规则 2,则不需要额外对齐。

结果说明:

Part2 内存布局:ecax|bbbb|dddd|dddd

总结

通过对比 Part1Part2 的内存布局,你会发现两者有很大的不同。如下:

  • Part1:axxx|bbbb|cxxx|xxxx|dddd|dddd|exxx|xxxx
  • Part2:ecax|bbbb|dddd|dddd

仔细一看,Part1 存在许多 Padding。显然它占据了不少空间,那么 Padding 是怎么出现的呢?

通过本文的介绍,可得知是由于不同类型导致需要进行字节对齐,以此保证内存的访问边界

那么也不难理解,为什么调整结构体内成员变量的字段顺序就能达到缩小结构体占用大小的疑问了,是因为巧妙地减少了 Padding 的存在。让它们更 “紧凑” 了。这一点对于加深 Go 的内存布局印象和大对象的优化非常有帮

当然了,没什么特殊问题,你可以不关注这一块。但你要知道这块知识点

0 人点赞