Go语言设计与实现 学习笔记 第三章 数据结构(2)

server/2024/10/18 2:30:47/

删除

如果想要删除哈希中的元素,就需要使用Go语言中的delete关键字,这个关键字的唯一作用就是将某一个键对应的元素从哈希表中删除,无论该键对应的值是否存在,这个内建的函数不会返回任何结果。
在这里插入图片描述
在编译期间,delete关键字会被转换成操作为ODELETE的节点,而ODELETE会被cmd/compile/internal/gc.walkexpr转换成mapdelete函数簇中的一个,包括mapdeletemapdelete_faststrmapdelete_fast32mapdelete_fast64

func walkexpr(n *Node, init *Nodes) *Node {switch n.Op {// 处理哈希表删除操作的节点case ODELETE:// 将当前节点的初始化代码加入init列表中init.AppendNodes(&n.Ninit)// 获取要操作的哈希表节点map_ := n.List.First()// 获取要删除的键节点key := n.List.Second()// 进一步遍历和转换哈希表节点和键节点,这可能在init中添加更多的初始化语句map_ = walkexpr(map_, init)key = walkexpr(key, init)// 获取哈希表类型t := map_.Type// 通过mapfast函数确定哈希的删除方式fast := mapfast(t)// 如果是慢速删除if fast == mapslow {// 将键节点包装成取地址的节点key = nod(OADDR, key, nil)}// 构建用于删除操作的函数调用节点,将返回的函数调用节点替换n(ODELETE节点)// mapfndel根据删除类型选择删除函数n = mkcall1(mapfndel(mapdelete[fast], t), nil, init, typename(t), map_, key)}
}

这些函数的实现其实差不多,我们来分析其中的runtime.mapdelete函数,哈希表的删除逻辑与写入逻辑非常相似,只是触发哈希的删除需要使用关键字,如果在删除期间遇到了哈希表的扩容,就会对即将操作的桶进行分流,分流结束之后会找到桶中的目标元素完成键值对的删除工作。

func mapdelete(t *maptype, h *hmap, key unsafe.Pointer) {// ...// 如果哈希表正在扩容if h.growing() {// 处理增量扩容工作,确保在删除开始前,处理完必要的扩容工作growWork(t, h, bucket)}// ...
search:// 遍历目标桶b,以及它的所有溢出桶for ; b != nil; b = b.overflow(t) {// 遍历桶中的每个槽位for i := uintptr(0); i < bucketCnt; i++ {// 如果tophash不匹配if b.tophash[i] != top {// 如果后面已经没有槽位了if b.tophash[i] == emptyRest {// 停止搜索break search}// 搜索下一个槽位continue}// 此处tophash已经匹配了,获取完整的key的地址k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))k2 = k// 如果完整的key不相等if !alg.equal(key, k2) {// 处理下一个槽位continue}// 删除key*(*unsafe.Pointer)(k) = nil// 获取指向v的指针v := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))// 删除value*(*unsafe.Pointer)(v) = nil// 将槽位设为emptyOne,表示此槽位曾经被占用,但现在为空b.tophash[i] = emptyOne// ...}}
}

我们其实只需要知道delete关键在在编译期间经过类型检查和中间代码生成阶段被转换成runtime.mapdelete函数簇中的一员就可以,用于处理删除逻辑的函数与哈希表的runtime.mapassign几乎完全相同,不太需要刻意关注。

3.3.5 小结

Go语言使用拉链法来解决哈希碰撞问题实现了哈希表,它的访问、写入和删除等操作都在编译期间转换成了运行时的函数或方法。

哈希在每一个桶中存储键对应哈希的前8位,当对哈希进行操作时,这些tophash就成为了一级缓存帮助哈希快速遍历桶中元素,每一个桶都只能存储8个键值对,一旦当前哈希的某个桶超出8个,新的键值对就会被存储到哈希的溢出桶中。

随着键值对数量的增加,溢出桶的数量和哈希的装载因子也会逐渐升高,超过一定范围就会触发扩容,扩容会将桶的数量翻倍,元素再分配的过程也是在调用写操作时增量进行的,不会造成性能的瞬时巨大抖动。

3.4 字符串

字符串是Go语言中最常用的基础数据类型之一,虽然字符串往往被看做一个整体,但是实际上字符串是一片连续的内存空间,我们也可以将它理解成一个由字符组成的数组,在这一节中就会详细介绍字符串的实现原理、相关转换过程以及常见操作的实现。

字符串虽然在Go语言中是基本类型string,但是它实际上是由字符组成的数组,C语言中的字符串就使用字符数组char[]表示,作为数组会占用一片连续的内存空间,这片内存空间存储了的字节共同组成了字符串,Go语言中的字符串其实是一个只读的字节数组,下图展示了"hello"字符串在内存中的存储方式:
在这里插入图片描述
如果是代码中存在的字符串,会在编译期间被标记成只读数据SRODATA符号(Static Read Only DATA),假设我们有以下的一段代码,其中包含了一个字符串,当我们将这段代码编译成汇编语言时,就能够看到hello字符串有一个SRODATA的标记:

$ cat main.go
package mainfunc main() {str := "hello"println([]byte(str))
}# -S表示将生成的汇编代码输出到控制台
$ GOOS=linux GOARCH=amd64 go tool compile -S main.go
...
# dupok表示如果有多个相同的只读数据段,可以重复使用
go.string."hello" SRODATA dupok size=5# hello的ASCII码表示0x0000 68 65 6c 6c 6f          hello
...

只读意味着字符串会分配到只读的内存空间并且这块内存不会被修改,但是在运行时我们其实还是可以将这段内存拷贝到堆或者栈上,将变量的类型转换成[]byte后就可以进行,修改后通过类型转换就可以变回string,Go语言只是不支持直接修改string类型变量的内存空间。

3.4.1 数据结构

字符串在Go语言中的接口其实非常简单,每一个字符串在运行时都会使用如下的StringHeader结构体表示,在运行时包的内部其实有一个私有的结构stringHeader,它有着完全相同的结构只是用于存储数据的Data字段使用了unsafe.Pointer类型:

type StringHeader struct {Data uintptrLen  int
}

我们会经常说字符串是一个只读的切片类型,这是因为切片在Go语言的运行时表示与字符串高度相似:

type SliceHeader struct {Data uintptrLen  intCap  int
}

与切片的结构体相比,字符串少了一个表示容量的Cap字段,因为字符串作为只读类型,我们并不会直接向字符串直接追加元素改变其本身的内存空间,所有在字符串上执行的写入操作实际都是通过拷贝实现的。

3.4.2 解析过程

字符串的解析一定是解析器在词法分析时就完成的,词法分析阶段会处理源文件中的字符串,将原有无意义的字符流转换成Token序列,在Go语言中,有两种字面量方式可以声明一个字符串,一种是使用双引号,另一种是使用反引号:

str1 := "this is a string"
str2 := `this is another
string`

使用双引号声明的字符串和其他语言中的字符串没有太多的区别,它只能用于单行字符串的初始化,如果字符串内部出现双引号,需要使用\符号避免编译器的解析错误,而反引号声明的字符串就可以摆脱单行的限制,因为双引号不再负责标记字符串的开始和结束,我们可以在字符串内部直接使用",在遇到需要手写JSON或者其他复杂数据格式的场景下非常方便。

json := `{"author": "draven", "tags": ["golang"]}`

两种不同的声明方式也意味着Go语言的编译器需要在解析阶段能够区分并且正确解析这两种不同的字符串格式,解析字符串使用的scanner扫描器,它的功能就是将输入的字符流转换成Token流,cmd/compile/internal/syntax.scanner.stdString方法用来解析使用双引号包裹的标准字符串:

func (s *scanner) stdString() {// 记录字符串字面量的开始位置,从这个位置开始,后面是字符串字面量s.startLit()for {// 获取下一个字符r := s.getr()// 遇到结束引号时,结束字符串扫描if r == '"' {break}// 如果遇到反斜杠,处理转义字符if r == '\\' {s.escape('"')continue}// 遇到换行符,将换行符放回流中,然后报告错误,之后结束字符串扫描if r == '\n' {s.ungetr()s.error("newline in string")break}// 如果遇到文件结束符或读取出错,报告错误并结束字符串扫描if r < 0 {// s.errh相比s.error,可以报告出错位置s.errh(s.line, s.col, "string not terminated")break}}// 标记字符串字面量后面可以是分号s.nlsemi = true// 保存字符串字面量内容s.lit = string(s.stopLit())// 将kind字段设为字符串字面量s.kind = StringLit// 将tok字段设为字面量类型s.tok = _Literal
}

从这个方法的实现我们能分析出Go语言处理标准字符串的逻辑:
1.标准字符串使用双引号表示开头和结尾;

2.标准字符串中需要使用反斜杠\escape双引号;

3.标准字符串中不能出现如下所示的隐式换行符\n'

str := "start
end"

使用反引号声明的原始字符串的解析规则就非常简单了,cmd/compile/internal/syntax.scanner.rawString会将非反引号的所有字符都划分到当前字符串的范围中,所以我们可以使用它来支持复杂的多行字符串:

func (s *scanner) rawString() {// 记录字符串字面量的开始位置,从这个位置开始,后面是字符串字面量s.startLit()for {// 获取下一个字符r := s.getr()// 如果是反引号,表示字符串字面量结束,结束字符串扫描if r == '`' {break}// 如果遇到文件结束符或读取出错,报告错误并结束字符串扫描if r < 0 {s.errh(s.line, s.col, "string not terminated")break}}// 与处理普通字符串字面量时相同s.nlsemi = trues.lit = string(s.stopLit())s.kind = StringLits.tok = _Literal
}

无论是标准字符串还是原始字符串最终都会被标记成StringLit类型的Token并传递到编译的下一个阶段——语法分析,在语法分析阶段,与字符串相关的表达式都会使用如下的方法cmd/compile/internal/gc.noder.basicLit处理:

// basicLit处理基本字面量
func (p *noder) basicLit(lit *syntax.BasicLit) Val {// 将lit.Value赋值给s,然后根据lit.Kind选择caseswitch s := lit.Value; lit.Kind {// 如果是字符串字面量case syntax.StringLit:// 如果字符串字面量非空且是原始字符串字面量if len(s) > 0 && s[0] == '`' {// 将s中的所有"\r"替换为"",这是为了处理Windows换行符\r\ns = strings.Replace(s, "\r", "", -1)}// 去除字符串字面量的引号,将其转换为字符串u, _ := strconv.Unquote(s)// 将解析后的字符串封装到Val结构中并返回return Val{U: u}}
}

无论是import语句中包的路径、结构体中的字段标签还是表达式中的字符串都会使用这个方法将原生字符串中最后的换行符删除并对字符串Token进行Unquote,也就是去掉字符串两边的引号等无关干扰,还原其本来的面目。

strconv.Unquote方法处理了很多边界条件导致整个函数非常复杂,不仅包括各种不同引号的处理,还包括UTF-8等编码的相关问题,所以在这里就不展开介绍了。

3.4.5 拼接

Go语言拼接字符串会使用+符号,编译器会将该符号对应的OADD节点转换成OADDSTR类型的节点,随后在cmd/compile/internal/gc.walkexpr函数中调用cmd/compile/internal/gc.addstr函数生成用于拼接字符串的代码:

func walkexpr(n *Node, init *Nodes) *Node {switch n.Op {// ...case OADDSTR:n = addstr(n, init)}
}

cmd/compile/internal/gc.addstr函数能帮助我们在编译期间选择合适的函数对字符串进行拼接,如果需要拼接的字符串小于等于5个,那么就会直接调用concatstring{2,3,4,5}等一系列函数,如果超过5个就会直接选择runtime.concatstrings传入一个数组切片。

func addstr(n *Node, init *Nodes) *Node {// 获取节点列表的长度c := n.List.Len()// 创建空节点buf := nodnil()// 初始化参数列表args := []*Node{buf} // 将要拼接的字符串节点转换成字符串类型,然后加入参数列表for _, n2 := range n.List.Slice() {args = append(args, conv(n2, types.Types[TSTRING]))}var fn string// 如果要拼接的字符串个数小于等于5if c <= 5 {// 选择相应的函数名concatstring{2,3,4,5}fn = fmt.Sprintf("concatstring%d", c)// 如果要拼接的字符串个数大于5} else {// 使用函数concatstringsfn = "concatstrings"// 创建一个新的字符串slicet := types.NewSlice(types.Types[TSTRING])// 将slice转换成复合字面值(OCOMPLIT)节点slice := nod(OCOMPLIT, nil, typenod(t))// 将节点的元素列表设为要拼接的字符串字面值列表slice.List.Set(args[1:])// 重新设置参数列表args = []*Node{buf, slice}}// 查找要使用的拼接函数并创建函数调用节点cat := syslook(fn)r := nod(OCALL, cat, nil)r.List.Set(args)// ...return r
}

其实无论使用concatstring{2,3,4,5}中的哪一个,最终都会调用runtime.concatstrings,该函数会先对传入的切片参数进行遍历,先过滤空字符串并计算拼接后字符串的长度。

func concatstrings(buf *tmpBuf, a []string) string {// 记录最后一个非空字符串的索引idx := 0// 所有非空字符串的总长度l := 0// 计数非空字符串的数量count := 0for i, x := range a {n := len(x)if n == 0 {continue}l += ncount++idx = i}// 如果所有字符串都为空,返回空字符串if count == 0 {return ""}// 如果只有一个非空字符串且(有缓冲区可用或字符串数据不在栈上)if count == 1 && (buf != nil || !stringDataOnStack(a[idx])) {// 直接那个唯一的返回非空字符串return a[idx]}// 创建一个新字符串s和字节切片b,长度为l,用于存储拼接结果// 如果buf不为nil,则使用buf作为临时缓冲区s, b := rawstringtmp(buf, l)for _, x := range a {// 将当前遍历到的字符串x拷贝到字节切片b中copy(b, x)// 更新切片b的起始位置b = b[len(x):]}return s
}

如果非空字符串的数量为1并且当前的字符串不在栈上就可以直接返回该字符串,不需要进行额外的任何操作。
在这里插入图片描述
3.4.4 类型转换

当我们使用Go语言解析和序列化JSON等数据格式时,经常需要将数据在string[]byte之间来回转换,类型转换的开销并没有想象的那么小,我们经常会看到runtime.slicebytetostring等函数出现在火焰图中,称为程序的性能热点。

从字节数组到字符串的转换就需要使用runtime.slicebytetostring函数,例如:string(bytes),该函数在函数体中会先处理两种比较常见的情况,也就是字节数组的长度为0或者1,这两个情况处理起来都非常简单:

func slicebytetostring(buf *tmpBuf, b []byte) (str string) {// 获取字节切片的长度l := len(b)// 如果字节切片的长度为0if l == 0 {// 返回空字符串return ""}// 如果字节切片的长度为1if l == 1 {// 将string的str设为字节切片中第一个字节的地址stringStructOf(&str).str = unsafe.Pointer(&staticbytes[b[0]])// 将string的长度设为1stringStructOf(&str).len = 1return}var p unsafe.Pointer// 如果调用者提供了缓冲区,且缓冲区能容纳bif buf != nil && len(b) <= len(buf) {// 直接使用调用者提供的缓冲区p = unsafe.Pointer(buf)} else {// 否则自己分配一块内存p = malloc(uintptr(len(b)), nil, false)}// 设置string的实际数据所在地址和长度stringStructOf(&str).str = pstringStructOf(&str).len = len(b)// 将字节切片的内容memmove到p指向的内存memmove(p, (*(*slice)(unsafe.Pointer(&b))).array, uintptr(len(b)))return
}

处理过后会根据传入的缓冲区大小决定是否需要为新的字符串分配一片内存空间,runtime.stringStructOf会将传入的字符串指针转换成stringStruct结构体指针,然后设置结构体持有的字符串指针str和长度len,最后通过memmove将原[]byte中的字节全部复制到新的内存空间中。

当我们想要将字符串转换成[]byte类型时,就需要使用runtime.stringtoslicebyte函数,该函数的实现非常容易理解:

func stringtoslicebyte(buf *tmpBuf, s string) []byte {var b []byte// 如果传入了缓冲区,且缓冲区足够放下字符串sif buf != nil && len(s) <= len(buf) {// 重置缓冲区,确保它为空*buf = tmpBuf{}// 将缓冲区的前len(s)个字节的切片分配给bb = buf[:len(s)]} else {// 否则,分配一块新的长为len(s)的字节sliceb = rawbyteslice(len(s))}// 将字符串s的内容复制到bcopy(b, s)return b
}

如果向该函数传入了缓冲区,那么它会使用传入的缓冲区存储[]byte,没有传入缓冲区时,运行时会调用runtime.rawbyteslice创建一个新的字节切片,copy就会将字符串中的内容拷贝到新的[]byte中。
在这里插入图片描述
字符串和[]byte中的内容虽然一样,但是字符串的内容是只读的,我们不能通过下标或者其他形式改变其中的数据,而[]byte中的内容是可以读写的,无论从哪种类型转换到另一种都需要对其中的内容进行拷贝,而内存拷贝的性能损耗会随着字符串和[]byte长度的增长而增长。

3.4.5 小结

字符串是Go语言中相对来说比较简单的一种数据结构,我们在这一节中详细分析了字符串与[]byte类型的关系,从词法分析阶段理解字符串是如何被解析的,作为只读的数据类型,我们无法改变其本身的结构,但是在做拼接和类型转换等操作时一定要注意性能的损耗,遇到需要极致性能的场景一定要尽量减少类型转换的次数。


http://www.ppmy.cn/server/49006.html

相关文章

C# Winform 用户控件,扩展控件,自定义控件综合实例

Control类是Windows窗体控件的基类&#xff0c;它提供了在 Windows 窗体应用程序中进行可视显示所需的基础结构&#xff0c;可以通过继承来扩展熟悉的用户控件和现有控件的功能。本列介绍三种不同自定义控件以及怎么创建他们。 自定义控件分类 用户控件&#xff1a;基本控件的…

基于51单片机的智能水表

一.硬件方案 本设计主要以51单片机作为主控处理器的智能水表&#xff0c;该水表能够记录总的用水量和单次用水量&#xff0c;当用水量超出设定值时系统发出声光报警提醒&#xff0c;水量报警值能够通过按键进行自行设置&#xff0c;并且存储于AT24C02中&#xff0c;并且可以测…

C#面:详细阐述什么是 DTO

DTO&#xff08;Data Transfer Object&#xff09;是一种设计模式&#xff0c;用于在不同层之间传输数据。它的主要目的是在应用程序的不同部分之间传递数据&#xff0c;而不是直接传递实体对象。DTO通常是一个简单的POCO&#xff08;Plain Old CLR Object&#xff09;&#xf…

干部管理软件有哪些

随着信息技术的飞速发展&#xff0c;干部管理软件在各级党政机关、国企事业单位中扮演着越来越重要的角色。这些软件通过整合干部管理的各项业务流程&#xff0c;实现了干部信息的系统化、规范化和高效化管理。以下是几款主流的干部管理软件及其特点&#xff1a; 一、干部信息…

Linux---系统的初步学习【 项目四 管理Linux 系统用户、组和权限】

项目四 管理Linux 系统用户、组和权限 4.1 项目知识初储备 4.1.1Linux用户和组 1. Linux用户和组 在Linux系统中&#xff0c;用户和组是权限管理的基础。用户&#xff08;User&#xff09;代表系统中的一个账户&#xff0c;可以是个人或服务。组&#xff08;Group&#xff…

Java课程设计:基于swing的学生信息管理系统

文章目录 一、项目介绍二、项目展示三、源码展示四、源码获取 一、项目介绍 这款Java swing实现的学生信息管理系统和jsp版本的功能很相似&#xff0c;简单的实现了班级信息的增删改查&#xff0c;学生信息的增删改查&#xff0c;数据库采用的是mysql&#xff0c;jdk版本不限&…

Python 中国象棋游戏【含Python源码 MX_011期】

简介&#xff1a; 中国象棋是一种古老而深受喜爱的策略棋类游戏&#xff0c;也被称为中国的国粹之一。它在中国有着悠久的历史&#xff0c;起源可以追溯到几个世纪以前。Python 中国象棋游戏是一个用Python编程语言编写的软件程序&#xff0c;旨在模拟和提供中国象棋的游戏体验…

automa学习:写一个取某东图书数据的片断

周五了&#xff0c;实在没事情了。正好上午有个朋友问automa的事&#xff0c;心想再写一个练习一下&#xff0c;毕竟&#xff0c;熟能生巧。 目标某东图书&#xff1a; 分析及介绍如下。 1.新建标签页 1.悬停元素。要注意 县 停 .cate_menu_item:nth-child(14) > .cate_…