首页 > 其他分享 >Go进阶概览 -【2.2 结构体与方法集的实现】

Go进阶概览 -【2.2 结构体与方法集的实现】

时间:2024-09-08 21:53:19浏览次数:7  
标签:进阶 占用 字段 内存 Go 2.2 MethodExample 结构 字节

2.2 结构体与方法集的实现

结构体是我们在实际运用中使用比较多的一个概念,Go语言封装的比较简单,我们在使用的时候不需要关注太多的东西。

但是如果对于性能有要求、需要开发框架时,我们还是需要对结构体进行一个深入的了解。

本节我们将针对结构体的内存布局、接口实现及面向对象编程等进行讲解。

本节代码存放目录为 lesson2

结构体的内存布局

在上一章中我们讲过了基础类型的内存表示方式,所有定义最终在内存中都会以二进制的形式存储。

在结构体中,其实也是按照这样的方式,只不过是按照每个字段排列的方式进行。

我们以实际的案例进行讲解,结构体代码如下:

type CacheExample struct {
	a int8
	b int32
	c int16
	d int16
}

在结构体中,每个字段所占用的位数是按照其类型制定的,如下所示:

type CacheExample struct {
	a int8  // 占用8位,1字节
	b int32 // 占用32位,4字节
	c int16 // 占用16位,2字节
	d int16 // 占用16位,2字节
}

从上面的结构体中,我们可以计算出一共占用了9字节,直观的来看在内存中可以理解为就是这样:

| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |

其中0存储了a字段,1、2、3、4存储了b字段,5、6存储了c字段,7、8存储了d字段。

那么现实中是否是这样的呢?Go语言中不是这样的,如果是上面的结构体,在内存中实际是这样的:

| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |

存储字段如下所示:

0 ~ 3 地址:存储 a 字段
4 ~ 7 地址:存储 b 字段
8 ~ 9 地址:存储 c 字段
10 ~ 11 地址:存储 d 字段

那么为什么会是这样呢?这是由于 Go 语言中内存对齐的概念。

内存对齐是指:字段存储的位置应该是类型字节数的倍数。

直观的来说,就是比如b字段int324字节,那么这个字段存储的起始位置就只能是4的倍数,比如说:0、4、8、12...

也就是说b在上面的例子中,由于0已经被a占用,b字段只能从4开始。那么ab之间剩下的空位应该怎么办呢?

这就涉及到了另一个概念,也就是内存填充,也就是说中间空的部分都会填充上默认值。

那么我们在举一个例子:

type CacheExample1 struct {
	a int8  // 占用8位,1字节,内存实际占用4字节
	b int32 // 占用32位,4字节,内存实际占用4字节
	c int8  // 占用8位,1字节,内存实际占用2字节
	d int16 // 占用16位,2字节,内存实际占用2字节
}

在上面的例子中,基于内存对齐与内存填充,最终得出的占用字节数就是:12

a字段:int8,占用 1 字节。由于 b 需要 4 字节对齐,所以 a 后面填充了 3 个字节,使得 b 可以从地址 4 开始。因此,a 实际占用了4字节。

b字段:int32,占用 4 字节,实际也占用 4 字节。

c字段:int8,占用 1 字节。为了对齐下一个 d 字段(int16,2 字节对齐),在 c 之后填充了 1 个字节,使得 d 从一个偶数地址开始。

d字段:int16,占用 2 字节,实际占用 2 字节。

我们可以通过代码直接输出结构体所占用的字节数,代码如下所示:

var (
		example  CacheExample
		example1 CacheExample1
	)
	fmt.Printf("结构体 CacheExample 占用的字节数: %d\n", unsafe.Sizeof(example))
	fmt.Printf("结构体 CacheExample1 占用的字节数: %d\n", unsafe.Sizeof(example1))

结果输出如下所示:

结构体 CacheExample 占用的字节数: 12
结构体 CacheExample1 占用的字节数: 12

那么为什么要这样做呢?我们先假设没有补齐,就是一个字段挨着一个字段布局的,如下所示:

地址01234567
内容abbbb

在上面的示意中,由于b是需要占用4位的,所以占用了:1、2、3、4

这里我们需要补充一个知识,那就是CPU在读写内存数据时,都是按照4位(32位系统)或者8位(64位系统)读写的。

比如CPU读取的是:0 ~ 3、4 ~ 7,那么我们现在再回头看,使用连续存储是不是就并不适用了呢?

也就是说,如果我按照连续存储,CPU首先读取0 ~ 3拿到第1、2、3位置的b字段数据,还要再读取一次4 ~ 7拿到第4位置的b字段数据,之后拼接在一起才会得到实际的b字段数据。

这种显然是不划算的,所以就出现了补位对齐,那么CPU在读取的时候直接读取4 ~7就一次拿到了b字段的数据。

经过上面的学习,我们现在已经知道了基本的概念,也就是说结构体占用是与字段排序有关系的。我们看下面的例子:

type CacheExample2 struct {
	a int8  // 占用8位,1字节,内存实际占用1字节
	c int8  // 占用8位,1字节,内存实际占用1字节
	d int16 // 占用16位,2字节,内存实际占用2字节
	b int32 // 占用32位,4字节,内存实际占用4字节
}

fmt.Printf("结构体 CacheExample2 占用的字节数: %d\n", unsafe.Sizeof(example2))

结果输出如下所示:

结构体 CacheExample2 占用的字节数: 8

看到上面的输出,我们可能会有点奇怪,为什么d占用不是4呢?

我们来分析一下,首先ac占用了0、1位置,接下来的d字段类型是2字节,d直接存储到2、3

最后的b字段类型需要4字节,而现在刚好排到了4,所以b字段直接从4开始,占用4、5、6、7

综上所述,如果需要优化结构体的内存占用,那么我们只需要:将占用小的字段放在前面。

字段访问及方法调用

字段是如何访问的?

在上面我们讲过了结构体的内存布局,也就是一块连续的存储空间,结构体的字段按照定义时候的顺序排列在内存中。

那么在Go语言中,访问的时候其实也比较简单,就是通过字段偏移量、字段占用位数从内存中取出字段。

比如说:a字段的存储地址偏移量是 0,占用字节数是 4,那么当访问的时候就会从内存的0 ~ 3去取出。

那么这个偏移量又是基于谁的偏移呢?在Go语言中,就是相对于结构体内存地址初始位置的偏移。

那么结构体内存初始位置又是如何确定的呢?这是在初始化时,系统会为该结构体变量分配一个内存地址,这个内存地址的初始位置就是结构体的零点。

我们可以通过代码输出字段的偏移量,如下所示:

type MethodExample struct {
	Score int16
	Age   int16
}

fmt.Println("Offset of age:", unsafe.Offsetof(MethodExample{}.Age))

结果输出如下所示:

Offset of age: 2

也就是说Age字段的偏移量是2,那么我们核实一下。针对上面的结构体,Score占用2字节,那么就是内存中的0、1,同时Score也不需要进行补位,所以Age自然就是从2开始,偏移量也就是2

在上面的示例中,在实际读取Age字段时,也就是从结构体初始位置读取:2 ~ 3的数据。

比如结构体初始位置是30,那么在取Age字段的时候,就是取内存地址的:32 ~ 33


那么Go语言又是怎么知道该访问哪些内存地址的呢

在上面我们讲解了访问计算方法,同时在前面的章节我们讲过机器指令的概念,就是说在编译时会将代码编译为机器能够识别的机器指令。

同样的,对于结构体的访问也是这样的。

在编译的时候,编译器就会将结构体各个字段的偏移量、位数等计算好并且形成机器码,那么在执行的时候就可以直接使用这些机器码进行读取操作。

也就是说其实在编译的时候就会将这些信息计算好形成机器码,并且内嵌到了最终的指令集中。


结构体方法是如何访问的?

结构体方法是我们比较常用的东西,基本上在实际开发中是离不开结构体方法的。

如下代码所示:

type MethodExample struct {
	Score int16
	Age   int16
}

func (m *MethodExample) Print() {
	fmt.Printf("MethodExample score is-> %d\n", m.Score)
}

func (m *MethodExample) Set(score int16) {
	fmt.Printf("MethodExample set, score-> %s\n", score)
	m.Score = score
}
结构体方法与函数有什么区别?

Go语言中,类似于上面代码中的方法,它们有一个接收者,也就是:(m * MethodExample),这种有接收者的就叫做方法,而普通函数是没有接收者的。

在上面的写法中,我们使用的接收者是指针,这意味着方法接收的是一个指针,也就是引用接收者。同时还有另一种写法,如下所示:

type MethodExample1 struct {
	Score int16
	Age   int16
}

func (m MethodExample1) Print() {
	fmt.Printf("MethodExample score is-> %d\n", m.Score)
}

func (m MethodExample1) Set(score int16) {
	fmt.Printf("MethodExample set, score-> %d\n", score)
	m.Score = score
}

我们可以这样调用:

me := &MethodExample{}
me.Set(12)
me.Print()

var (
	me1 MethodExample1
)
me1.Set(12)
me1.Print()

执行代码输出如下:

MethodExample set, score-> 12
MethodExample score is-> 12
MethodExample1 set, score-> 12
MethodExample1 score is-> 0

那么为什么第二个输出的Score会是0呢?

这就涉及到了Go语言方法的底层概念:指针接收者传递的是结构体的指针地址,而值接收者传递的是结构体的一个副本。

也就是说,当我们的接收者为指针时,由于指针指向的是内存地址,所以在调用Set的时候,操作的就是me本身。

当我们的接收者为普通值引用时,在调用Set的时候,其实是复制了一份me1传递过去,那么这时候操作字段赋值,自然对me1是不生效的,因为操作的就不是me1

所以我们可以进一步的发散。方法是不是本身与函数就是一样的,在调用的时候只不过是把前面的接收者(结构体)作为了一个参数传递过去呢?

答案是肯定的,在底层其实就是这么操作的,本身方法与函数其实是一样的,只不过方法会将接收者作为参数隐式的给传递过去。


结构体方法是怎么被调用到的?

在上面我们讲解过了字段是怎么被访问的,那么我们在调用方法时,也是使用了符号.就进行了直接调用,那么它的底层又是怎么样的呢?

这里涉及到一个概念,叫做:方法表。在Go语言中,会为我们的结构体类型创建一个方法表,当我们在调用时,就会去方法表里面找到我们的方法信息,之后再去调用实际的函数。

结构示意如下所示:

+----------------------+
|  MethodExample       |  <--- 定义的结构体类型
+----------------------+
|  Method Table (MT)    |  <--- 类型T的方法表(与类型关联,不与实例关联)
+----------------------+
|  Method 1:            |
|  - Name: "Print"    |  --- 方法名称
|  - Signature: ()      |  --- 方法签名(参数和返回值类型)
|  - Function Ptr:      |  --- 指向方法实现的函数指针
|                      |
|  Method 2:            |
|  - Name: "Set"    |
|  - Signature: (int16)   |
|  - Function Ptr:      |
|                      |
|  ...                  |
+----------------------+

在实际的应用中,当我们创建了一个结构体后,就会形成一个方法表,这个方法表是与类型关联的,比如与MethodExample这个类型关联。

在这个方法表中,标识了对应的结构体类型、方法的列表,在方法信息中包含了方法名称、方法签名以及指向实际函数的指针。

总之可以大概理解为,当我们定义结构体类型时,这个类型就会附带上一个表,这个表里面存储了这个结构体所有的方法信息。

需要注意是的,这个方法表是与类型本身MethodExample关联的,而不是与创建的实例me关联的。

在上面我们讲到了结构体类型与方法表的关系及结构,那么在我们实际使用的时候,又是怎么去调用到的呢?

我们可以通过下面的结构来探索:

+----------------------+
|  me of MethodExample |  <--- 结构体实例 me
+----------------------+
|  score: int16        |  <--- 结构体字段 score
|  age: int6           |  <--- 结构体字段 age
|  ...                 |
+----------------------+

调用 `me.Print()` 时:

1. Go 运行时系统知道 me 是 MethodExample 类型。

2. Go 通过 MethodExample 的类型信息找到 MethodExample 的方法表(MT)。

3. 在方法表中找到 Print 的函数指针。

4. 使用该函数指针调用 Print 的实现。

从上面的示意我们可以看出,在执行调用时,运行时系统会通过me找到他的实际类型,也就是MethodExample,之后再次通过类型找到了所关联的方法表MT,下一步就是使用指针直接去调用实际的 Print 函数。

需要注意的是:方法表与前面所讲到的一样,都是由编译器在编译时就已经生成的了,在使用的时候直接进行查找即可。

小结

本节我们讲解了结构体的内存布局、字段访问、方法与函数的区别以及方法调用。

关于本节总结如下:

  • 结构体字段按照定义的顺序排列存储

  • 字段存储的位置应该是类型字节数的倍数

  • 字段内存排列时需要补位对齐

  • 将占用位数小的字段放在前面可以减少内存占用

  • 结构体在初始化时得到了初始内存地址

  • 通过字段偏移量、字节数访问内存地址得到字段

  • 结构体方法本质上与函数是一样的

  • 结构体类型关联一个方法表,方法表记录了结构体实现的所有方法信息

  • 调用时通过类型关联查找得到最终的函数进行调用

我的GitHub:https://github.com/swxctx

书籍地址:https://d.golang.website/

书籍代码:https://github.com/YouCanGolang/GoDeeperCode

标签:进阶,占用,字段,内存,Go,2.2,MethodExample,结构,字节
From: https://blog.csdn.net/qq_28796345/article/details/141951893

相关文章

  • Go进阶概览 -【2.4 切片的结构与内存管理】
    2.4切片的结构与内存管理切片是我们日常使用比较多的一个结构,深入的了解它的结构对于我们提高程序性能也有比较大的帮助。本节我们将针对切片底层结构、扩容机制、底层数组进行讲解。本节代码存放目录为lesson4切片底层结构我们在使用的时候发现切片与数组很相似,这是......
  • Linux目录结构进阶和过滤命令(三)
    1.日志查询四剑客注意:查看日志的时候不要用cat或者vim命令,在工作中日志的内容很多,用cat会刷屏,用vim又特别的占用内存,所以我们引出了四条有关查看日志的相关命令1.1四剑客之headhead#显示文件的头几行,默认显示十行head-nnum#显示头num行实例一:显示/etc/passwd的......
  • 非官方python二进制包 https://www.lfd.uci.edu/~gohlke/pythonlibs/ 替代
    前两年的时候,由于偶尔会使用LFD中的二进制python包,但是下载地址都是加密的,不能直接给pip使用,因此为了方便自己把地址解密后做了一个目录页,并自动更新。今天看了一下页面发现包的更新时间都是前两年的,以为是自动更新程序出问题了,一番求证后发现原来是LFD的服务关闭了,幸好只关闭了......
  • 【Python使用】嘿马python高级进阶全体系教程第9篇:HTTP 协议,1. HTTP 协议的介绍【附
    本教程的知识点为:操作系统1.常见的操作系统4.小结ls命令选项2.小结mkdir和rm命令选项1.mkdir命令选项压缩和解压缩命令1.压缩格式的介绍2.tar命令及选项的使用3.zip和unzip命令及选项的使用4.小结编辑器vim1.vim的介绍2.vim的工作模式3.vim的末行模......
  • Python毕业设计基于Django的川剧戏剧京剧戏曲科普平台 含选座功能
    文末获取资源,收藏关注不迷路文章目录一、项目介绍1管理员功能模块前台系统功能模块二、主要使用技术三、研究内容四、核心代码五、文章目录一、项目介绍随着我国经济的高速发展与人们生活水平的日益提高,人们对生活质量的追求也多种多样。尤其在人们生活节奏不断加......
  • 【Django开发】django美多商城项目完整开发4.0第10篇:收货地址,数据库建表【附代码文档
    本教程的知识点为:项目准备项目准备配置1.修改settings/dev.py文件中的路径信息2.INSTALLED_APPS3.数据库用户部分图片1.后端接口设计:视图原型2.具体视图实现用户部分使用Celery完成发送判断帐号是否存在1.判断用户名是否存在后端接口设计:用户部分JWT什......
  • Go语言中的RPC协议原理解析
    Go语言中的RPC协议原理解析在分布式系统中,不同的服务或组件通常运行在不同的计算机或进程上。为了实现这些服务之间的通信,我们可以使用RPC(RemoteProcedureCall,远程过程调用)协议。RPC允许我们像调用本地函数一样调用远程服务,从而简化了分布式系统中的通信复杂性。本文将详......
  • 南沙信C++陈老师解一本通题:1310:【例2.2】车厢重组
    ​【题目描述】在一个旧式的火车站旁边有一座桥,其桥面可以绕河中心的桥墩水平旋转。一个车站的职工发现桥的长度最多能容纳两节车厢,如果将桥旋转180度,则可以把相邻两节车厢的位置交换,用这种方法可以重新排列车厢的顺序。于是他就负责用这座桥将进站的车厢按车厢号从小到大排列......
  • 一个小例子,给你讲透典型的 Go 并发操作
    一个小例子,给你讲透典型的Go并发操作原创 訢亮 程序员新亮  2024年09月08日16:57 天津 听全文程序员新亮GitHub9K+Star|技术交流分享206篇原创内容公众号如果你有一个任务可以分解成多个子任务进行处理,同时每个子任务没有先后执行顺序的限制......
  • 利用Django框架快速构建Web应用:从零到上线
    随着互联网的发展,Web应用的需求日益增长,而Django作为一个高级的PythonWeb框架,以其强大的功能和灵活的架构,成为了众多开发者的选择。本文将指导你如何从零开始使用Django框架构建一个简单的Web应用,并将其部署到线上,让世界看到你的作品。Django简介Django是由AdrianHolov......