首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >Go 语言中的数组、切片、Map和List

Go 语言中的数组、切片、Map和List

原创
作者头像
叫我阿杰好了
发布2025-06-16 16:11:06
发布2025-06-16 16:11:06
4713
举报
文章被收录于专栏:Go入门到入土Go入门到入土

这篇文章已经放到腾讯智能工作台的知识库啦,链接在这里:ima.copilot-Go 入门到入土。要是你有啥不懂的地方,就去知识库找 AI 聊一聊吧。

1、数组的基本用法

在前面的博客中,我们主要介绍了 Go 语言的基础数据类型。然而,在实际的软件开发中,我们通常需要一个“容器”来管理和组织这些基础数据,以便更高效地处理批量数据。Go 语言提供了多种集合类型(或称容器)来满足不同的开发需求。

1、Go 语言的核心集合类型

Go 语言内置了四种主要的集合类型数据结构:

  1. 数组 (Array):存储固定长度、相同类型元素的序列。
  2. 切片 (Slice):一个动态、灵活的数组视图,是 Go 中最常用的集合类型。
  3. Map:键值对的无序集合,类似于其他语言中的哈希表或字典。
  4. 列表 (List):Go 标准库 container/list 提供了双向链表的实现。

在这些类型中,切片 (Slice)Map 的使用频率最高,也是我们后续需要重点掌握的。数组因为其固定长度的特性,使用场景相对有限,而 List 在日常开发中也并不常见。

本节,我们首先深入讲解 Go 语言中的 数组 (Array)

2、 数组的定义与特性

Go 语言中的数组与 C 或 Java 等语言中的数组有显著区别,尤其是在类型系统层面。

2.1 定义语法

数组的定义语法如下:

代码语言:go
复制
var variableName [size]Type
  • var: 声明变量的关键字。
  • variableName: 数组变量的名称。
  • [size]: 数组的长度(元素个数),这是类型的一部分
  • Type: 数组中存储的元素类型。

请注意,数组的长度 [size] 是写在类型 Type 前面的,这与其他许多静态语言的习惯不同。

例如,让我们定义一个包含 3 个字符串元素的数组:

image.png
image.png

2.2 长度是类型的一部分

这是 Go 数组最核心的特性。一个数组的类型由其长度元素类型共同决定。这意味着,[3]string[4]string 是两种完全不同且不兼容的数组类型。

我们可以通过下面的代码来验证这一点:

image.png
image.png

运行结果:

image.png
image.png

从输出中可以清晰地看到,编译器将 [3]string[4]string 识别为不同的类型。因此,不同长度的数组之间不能直接赋值,这大大增强了 Go 语言的类型安全性,但也限制了数组的灵活性。

3、数组的初始化与遍历

3.1 赋值

数组的元素可以通过索引进行访问和赋值,索引从 0 开始。

image.png
image.png

3.2 遍历

遍历数组最常用的方式是使用 for...range 循环。

image.png
image.png

4、数组 vs 切片:一个重要的区分

有同学可能会想,如果定义时不指定长度,是不是就可以创建一种“通用”的数组类型了?比如 var a []string

这是一个非常关键的概念点:当你不指定长度时,你创建的不再是数组,而是一个切片(Slice)

[3]string 是一个数组类型,而 []string 是一个切片类型。它们是两种不同的数据结构,切片在内部依赖于数组,但提供了更为强大的动态能力。我们将在下一节重点讲解切片。

何时使用数组?

尽管数组的长度固定,但在某些特定场景下它依然很有用:

  • 性能: 当元素个数固定时,数组的性能非常高,因为其内存布局是连续且大小已知的。
  • 可预测性: 用于存储固定集合的数据,例如表示一周七天、颜色(RGB)等。

2、数组的初始化和遍历

在 Go 语言中,数组是一种固定长度的、包含相同类型元素的数据结构。正确地初始化和遍历数组是日常开发中的基本功。本文将深入探讨 Go 语言中数组初始化的多种方法以及常用的遍历方式。

1、数组的初始化

Go 语言提供了多种灵活的方式来初始化数组,以适应不同的开发场景。

1.1 基本初始化

在定义数组时,我们可以直接提供一组初始值,从而简化代码。

image.png
image.png

在上面的例子中,:= 是短变量声明操作符,它会根据右侧的值自动推断变量 courses 的类型为 [3]string。这种写法在 Go 中最为常见和推荐。

1.2 指定索引初始化

有时,我们可能只想初始化数组中的某几个特定元素,而让其他元素保持其类型的零值(Zero Value)。对于 string 类型,其零值是空字符串 ""

这种方法通过 索引:值 的形式实现,非常灵活。

image.png
image.png

这种方式特别适用于需要设置稀疏数据或在特定位置插入初始值的场景。

1.3 使用 ... 自动推导长度

在初始化时,如果我们希望数组的长度由初始值的数量来决定,可以使用 ... 省略号。编译器会自动计算元素的数量并设置数组的长度。

image.png
image.png

重点: ... 并非创建了一个动态数组。数组的长度在编译时就已经确定,并成为其类型的一部分。因此,[2]string[3]string 是两种完全不同的类型。

2、数组的遍历

遍历数组是访问其元素的基本操作。Go 语言同样支持多种遍历方式。

标准 for 循环

这是从 C 语言继承而来的传统遍历方式,通过索引来访问数组中的每一个元素。

image.png
image.png

这种方法直观易懂,并且在需要使用元素索引进行计算时非常有用。建议熟练掌握。

3、数组比较与多维数组

本节将探讨 Go 语言中数组的比较规则以及多维数组的定义、初始化和遍历方法。这些是掌握 Go 数据结构的重要基石。

1、 数组的比较

在 Go 语言中,数组可以直接使用相等运算符 ==!= 进行比较,这为我们判断两个数组是否相同提供了极大的便利。但是,能够进行比较需要满足两个核心前提。

比较的两个前提条件:

  1. 类型相同:数组的长度是其类型的一部分。因此,只有长度完全相同的数组才能进行比较。例如,一个 [2]string 类型的数组和一个 [3]string 类型的数组是无法进行比较的,这会在编译时就引发错误。
  2. 元素值与顺序完全一致:当两个数组类型相同时,== 会逐一比较其内部的每一个元素。只有当所有对应位置的元素都相等时,两个数组才被认为是相等的。元素的顺序也至关重要。
image.png
image.png

2、多维数组

当一维数组无法满足我们存储更复杂、结构化数据的需求时,多维数组就派上了用场。例如,我们可以用一个二维数组来存储一个班级所有学生各科的成绩,或者存储一系列课程的详细信息。

2.1 声明与初始化

多维数组的定义非常直观,例如 [3][4]string 表示一个3行4列的二维字符串数组。

💡 实用场景:假设我们需要存储三门课程的信息,每门课程都包含四个属性:课程名、时长、讲师和简介。

image.png
image.png

注意:像 courses[0][2] = "NewTeacher" 这样对单个元素赋值也是完全支持的,但通过整行初始化在许多场景下更为清晰。

2.2 遍历多维数组

遍历多维数组通常需要使用嵌套循环。

方法一:标准 for 循环

通过嵌套两层 for 循环,外层循环控制行,内层循环控制列。

image.png
image.png
方法二:使用 for...range (推荐)
image.png
image.png

4、切片的定义和赋值

本节,我们将深入讲解 Go 语言集合类型中一个至关重要的数据结构——切片(Slice)。切片在 Go 项目中应用极为广泛,为了帮助大家在实际开发中避免可能遇到的问题,我们将详细剖析其核心概念与细节。

1、 切片:Go 语言的动态数组

可以将切片理解为一种“动态数组”,它类似于 Python 中的 list 或其他语言中的动态数组(Dynamic Array)。

在许多静态编译型语言中,数组(Array)的长度在定义时就已固定,无法在运行时改变。例如,声明一个长度为 3 的数组后,就不能再向其中添加第四个元素。因此,这类静态数组通常不提供 append(追加)等操作。

与此不同,动态语言中的数组或列表(List)通常是动态的,可以随时方便地向其追加元素。

Go 语言在此问题上采取了一种折中的设计方案。它保留了传统意义上的数组,但其长度是类型的一部分,这使得不同长度的数组成为不同的类型,从而在一定程度上弱化了其灵活性。为了弥补这一不足并提供动态集合的功能,Go 推出了切片(Slice)

切片的本质是对底层数组一个连续片段的引用。可以理解为,Go 在原生数组的基础上构建了功能更丰富的切片,使其具备动态数组的特性,这种设计更符合大多数开发场景的习惯。

2、切片的定义

定义一个切片与定义数组的语法非常相似,关键区别在于定义切片时不需要指定长度

image.png
image.png

这里的 []string 就表示一个元素类型为 string 的切片。切片的元素类型可以是任意的,例如 intstruct,甚至可以是数组或另一个切片,构成多维结构

3、切片的基本操作

3.1 追加元素 (Append)

向切片中添加元素最常用的方法是使用 Go 的内置函数 append

append 函数的用法非常特殊,初学者需要特别注意:它会返回一个包含新增元素的新切片,这个返回值必须重新赋给原来的切片变量。

image.png
image.png

重点解释 append 的工作机制:

初次接触时,slice = append(slice, element) 这种语法可能会令人困惑。为什么不能直接修改原切片,而必须接收返回值?

这是因为切片在容量(Capacity)不足以容纳新元素时,Go 会分配一个全新的、更大的底层数组,并将原有元素和新元素一并拷贝过去。此时,返回的切片将指向这个新的内存地址。如果不接收返回值,原始的切片变量将仍然指向旧的、较小的底层数组,导致添加操作“丢失”。

这个机制是切片实现动态增长的关键,也是一个常见的面试考点。我们将在后续文章中更深入地探讨其内部原理。

3.2 访问元素

访问切片中的单个元素与访问数组元素的方法完全相同,都是通过索引(index)实现,索引从 0 开始。

image.png
image.png

3.3 遍历切片

使用 for range 循环遍历切片的方式也和遍历数组一致,这是最常用和推荐的遍历方式。

image.png
image.png

5、切片的多种初始化方式

在前一节中,我们学习了切片的基本定义和如何通过 append 函数添加元素。本节,我们将重点介绍初始化切片的三种常用方法。

切片的初始化主要有以下三种方式:

  1. 从现有数组或切片创建:通过截取(slicing)操作生成一个新的切片。
  2. 使用切片字面量(Slice Literal):在声明时直接提供初始元素,类似于数组的初始化。
  3. 使用 make 内置函数:预先分配切片的存储空间,适用于已知大概容量的场景。

这三种方法在实际开发中都非常普遍,掌握它们对于高效使用 Go 语言至关重要。

1、 从数组创建切片

可以从一个已存在的数组中“截取”一部分元素来创建一个新的切片。这种操作不会复制元素,而是创建一个指向原数组部分数据的新切片。

语法array[low:high]

这种语法创建一个包含从索引 lowhigh-1 元素的切片。这是一个左闭右开区间,即包含 low 索引处的元素,但不包含 high 索引处的元素。新切片的长度为 high - low

示例: 假设我们有一个包含五门课程的数组,现在希望从中提取前两门课程创建一个切片。

image.png
image.png

注意:Go 的切片操作语法借鉴自 Python,非常灵活。例如,allCourses[:] 可以将整个数组转换为一个切片。

2、 使用切片字面量 (Slice Literal)

这是最直接的初始化方式,可以在声明时直接填充元素。其语法与数组字面量非常相似,但不需要在 [] 中指定长度

image.png
image.png

这种方式同样可以用于从一个现有切片创建新切片。

3、使用 make 内置函数

当需要在创建切片时预留存储空间,以提高性能(特别是在能预估最终元素数量时),可以使用 make 函数。这样做可以避免后续 append 操作可能引发的频繁内存重新分配和数据拷贝。

make 函数可以接受两个或三个参数:

  • make([]T, length):创建一个类型为 []T,长度(length)为 length 的切片。其容量(capacity)也等于 length
  • make([]T, length, capacity):创建一个类型为 []T,长度为 length,容量为 capacity 的切片。
image.png
image.png

重要对比

  • 使用 make:切片被创建时,其底层数组已经被分配,并且长度被设定。因此,你可以直接通过索引对 0length-1 范围内的元素进行赋值。
  • 仅声明 var slice []T:此时切片的值为 nil,其长度和容量都为 0。你不能通过索引直接赋值,因为底层数组不存在。这种情况下,必须使用 append 来添加第一个元素。
image.png
image.png

掌握这三种初始化方法至关重要:

  • 当需要从现有数组或切片中获取子集时,使用切片表达式 ([low:high])。
  • 当在编码时已知所有初始元素时,使用切片字面量 ([]T{...}) 最为简洁。
  • 当可以预估切片所需存储的元素数量时,使用 make 函数预分配容量,可以获得最佳性能。

由于切片在 Go 编程中的核心地位,请务必熟练掌握并理解这三种基本用法的适用场景。

6、切片的数据访问

本节,我们将探讨如何访问 Go 语言切片中的元素。这些是切片非常常用的操作,需要熟练掌握。访问切片元素主要分为两种情况:访问单个元素和访问多个元素(即创建子切片)。

1、访问单个元素

访问切片中的单个元素与访问数组的方法完全相同,都是通过索引(index)来完成。

语法slice[index]

需要注意的是,索引必须在有效范围内,即 0 <= index < len(slice)。尝试访问超出此范围的索引将导致程序运行时发生 panic

image.png
image.png

2、访问多个元素(创建子切片)

在更多场景下,我们常常需要获取切片中一个连续的子集。这可以通过切片表达式 Slicing Expression来实现,其语法非常灵活。

基本语法slice[start:end]

  • start:起始索引(包含该索引对应的元素)。
  • end:结束索引(不包含该索引对应的元素)。

这是一个左闭右开的区间。startend 索引都是可选的。下面我们详细说明其四种主要用法。

假设我们有以下基础切片用于演示:

image.png
image.png

2.1 指定起始和结束索引 slice[start:end]

截取一个明确范围的子集。

image.png
image.png

2.2 省略结束索引 slice[start:]

表示从 start 索引开始,一直截取到切片的末尾。

image.png
image.png

2.3 省略起始索引 slice[:end]

表示从切片的开头(索引 0)开始,一直截取到 end 索引之前。

image.png
image.png

2.4 同时省略起始和结束索引 slice[:]

这会创建一个包含原切片所有元素的新切片。需要注意的是,这是一种“浅拷贝”:新切片和原切片共享同一个底层数组,但它们是两个独立的切片头结构。

image.png
image.png

语法借鉴:Go 的切片语法很大程度上借鉴了 Python,但功能上是其子集,相对更为简洁,易于学习和使用。

7、切片(Slice)的数据添加与合并

在前几节中,我们已经接触了用于向切片添加元素的 append 内置函数。本节,我们将深入探讨 append 的更多高级用法和细节。

回顾一下 append 的两个基本要点:

  1. 它是一个内置的全局函数。
  2. 它返回一个新的切片,必须用原切片变量接收其返回值,否则添加操作将无效。

1、 一次性追加多个元素

append 函数不仅可以一次添加一个元素,还可以通过可变参数(variadic parameter)的形式,一次性添加多个元素。

示例: 假设我们有一个初始切片,现在需要同时向其中添加 "Gin"、"MySQL" 和 "Elasticsearch"。

image.png
image.png

源码解读append 函数的定义类似 func append(slice []T, elements ...T) []T

参数 elements ...T 表示可以接收零个或多个 T 类型的参数。这就是为什么我们可以传入任意数量的元素。

2、 合并两个切片

当需要将一个切片的所有元素添加到另一个切片末尾时,最直观的想法可能是使用 for 循环遍历并逐个 append

传统方法:使用 for 循环

image.png
image.png

虽然这种方法可行,但 Go 提供了更为简洁和高效的语法。

推荐方法:使用 ... 语法

我们可以利用 append 的可变参数特性,将一个切片“打散”成独立的元素序列,然后一次性追加。这通过在要添加的切片名称后加上 ... 实现。

image.png
image.png

这种写法在功能上等同于 for 循环,但代码更简洁,可读性更强,是 Go 语言中合并切片的标准做法。

注意:你不能直接将一个切片 append 到另一个切片中,因为它们的类型不匹配。append(courseSlice1, courseSlice2) 会导致编译错误,因为它试图将 []string 类型的 courseSlice2 作为一个单一元素添加到只能存放 stringcourseSlice1 中。

3、追加另一个切片的子集

... 语法同样可以与切片表达式结合使用,从而实现只追加另一个切片的部分元素。

示例: 假设 courseSlice2 中包含 {"MySQL", "Elasticsearch", "Gin"},我们只想将 "Elasticsearch" 和 "Gin" 追加到 courseSlice1

image.png
image.png

这种组合用法极大地增强了 append 操作的灵活性。

本节我们深入学习了 append 函数的高级用法。关键点在于,它不仅可以一次性追加多个独立的元素,更重要的是,可以通过 ... 语法高效地将一个切片(或其子切片)的所有元素合并到另一个切片中。这是 Go 开发中非常常用且重要的技巧。

8、切片(Slice)的删除与复制操作

本节,我们将学习如何在 Go 语言的切片中删除和复制元素。与取值操作的直接性不同,删除操作的实现方式需要特别注意。

1、 从切片中删除元素

Go 没有提供一个直接的内置函数来删除切片中的元素(如 delete(slice, index)),这一操作需要通过切片本身的拼接能力来实现。

1.1 删除中间的元素

要删除切片中间的一个或多个元素,核心思路是:将需要删除的元素“左边”的部分和“右边”的部分拼接在一起,构成一个新的切片。

实现方法:

  1. 使用切片表达式 slice[:index] 获取要删除元素之前的所有元素。
  2. 使用切片表达式 slice[index+1:] 获取要删除元素之后的所有元素。
  3. 使用 append 函数将这两部分合并。

示例:从以下切片中删除 "MySQL"(索引为 2)。

image.png
image.png

这种用法虽然看起来有些绕,但它是 Go 中实现“删除”操作的标准模式。

1.2 删除末尾的元素

如果想删除从某个索引开始到末尾的所有元素,操作就简单得多,只需重新切片,保留所需的部分即可。

示例:删除 "MySQL" 及之后的所有元素。

image.png
image.png

性能说明:频繁的切片操作看似会影响性能,但由于切片主要操作的是指向底层数组的指针和元数据(长度、容量),而不是大规模复制数据本身,因此在大多数情况下性能是可以接受的。

2、复制切片

复制切片时,必须区分浅拷贝(Shallow Copy)深拷贝(Deep Copy)

  • 浅拷贝:新旧两个切片共享同一个底层数组。修改其中一个切片会影响到另一个。
  • 深拷贝:为新切片创建一个全新的底层数组,并复制所有元素。新旧切片完全独立。

2.1 浅拷贝(赋值与切片表达式)

直接赋值 (newSlice := oldSlice) 或使用完整的切片表达式 (newSlice := oldSlice[:]) 都只会创建一个新的切片头,它与原切片指向同一个底层数组。

示例

image.png
image.png

可见,修改 originalSlice 会直接影响 shallowCopy

2.2 深拷贝(使用 copy 内置函数)

要实现真正的深拷贝,应使用 Go 的内置 copy 函数。它将元素从源切片复制到目标切片。

关键点copy 函数不会为目标切片自动扩容。你必须确保目标切片有足够的长度来接收被复制的元素。通常,我们会使用 make 函数来创建一个长度与源切片相同的目标切片。

语法copy(destination, source)

示例

image.png
image.png

可以看到,deepCopy 是一个完全独立的副本,不受对原始切片修改的影响。

理解这些操作的底层机制至关重要。如果不清楚浅拷贝和深拷贝的区别,很可能会在程序中引入难以察觉的 bug。

9、切片 (Slice) 的底层实现原理:值传递与引用传递之谜

在 Go 语言中,如果不了解切片(Slice)的底层实现原理,开发过程中极易遇到难以排查的问题。因此,深入理解其工作机制,尤其是在函数调用中的表现,至关重要。这也是一个常见的面试热点。

本节,我们将通过实验现象,引出一个核心问题:

Go 的切片在作为函数参数传递时,是值传递(Pass-by-Value)还是引用传递(Pass-by-Reference)?

严格来说,Go 语言中所有函数参数传递都是值传递。然而,切片的操作效果却常常呈现出引用传递的特征,但又不完全是。这种模棱两可的表现是初学者最主要的困惑来源。

为了揭示这一现象,我们来看两个截然相反的实验。

1、场景一:在函数内修改切片元素

我们定义一个函数,它接收一个切片作为参数,并尝试修改该切片的某个元素。

image.png
image.png

现象分析: 函数 modifySlice 内部对 data 第一个元素的修改,成功地反映到了 main 函数的原始变量 courses 上。从结果来看,这完全符合引用传递的特征。

2、场景二:在函数内追加切片元素

现在,我们换一个操作。我们定义另一个函数,尝试向传入的切片中追加(append)新元素。

image.png
image.png

现象分析: 函数 appendToSlice 内部成功地向 data 追加了新元素 "MySQL",其长度和容量都发生了变化。然而,当函数返回后,main 函数中的原始变量 courses 没有任何改变。这个结果又完全符合值传递的特征。

我们观察到了两种完全矛盾的现象:

  1. 修改切片内部的元素,会影响到外部的原始切片(类似引用传递)。
  2. 在函数内部对切片进行 append 操作,却不会影响到外部的原始切片(类似值传递)。

这种“怪异”行为的根源在于切片自身的内部结构。切片本身是一个小的数据结构(或称“描述符”、“头信息”),它包含了指向底层数组的指针、切片的长度和容量。

当我们以值传递的方式传递切片时,实际上传递的是这个描述符结构体的一份拷贝

  • 场景一:拷贝的描述符和原始的描述符都指向同一个底层数组。因此,通过拷贝的描述符修改底层数组的元素,原始描述符自然能“看到”这个变化。
  • 场景二:当 append 操作超出了底层数组的容量时,Go 会分配一个新的、更大的数组,并将原数据拷贝过去。函数内的那份描述符拷贝会更新其指针指向这个新数组,但 main 函数中的原始描述符对此一无所知,它仍然指向旧的数组。

10、深入 Go Slice 的底层原理

要真正掌握 Go 语言的切片(Slice),就必须理解其底层的实现原理。Slice 的设计是 Go 语言的一大特色,但其独特的机制也要求开发者对其内部工作方式有所了解,否则在开发中容易遇到难以排查的“坑”。

Slice 的核心可以理解为一个结构体(struct)。如果你不熟悉结构体,可以暂时将其看作是其他语言中的类(class)或对象(object)。

image.png
image.png

这个结构体包含三个关键部分:

  1. 指针 (Data):指向一个连续的内存空间,也就是切片的底层数组。所有的数据都存储在这里。
  2. 长度 (Len):切片中当前包含的元素个数。len() 函数返回的就是这个值。
  3. 容量 (Cap):从切片的起始元素到底层数组的末尾,总共可以容纳的元素个数。cap() 函数返回此值。

1、为什么 Slice 兼具“值传递”与“引用传递”的特征?

这是关于 Slice 最核心、也最容易混淆的问题。

答案是:Go 的 Slice 本身是“值传递”的,但它的效果常常表现为“引用传递”。

初始状态:创建一个 Slice

首先,我们创建一个 Slice。这个 Slice s1 包含三个元素 [A, B, C]。在内存中,这表现为一个 sliceHeader 结构体,它包含一个指向底层数组的指针 (ptr),以及长度 (len)容量 (cap)

image.png
image.png

说明:

  • s1sliceHeader 包含了指向底层数组的指针。
  • 底层数组是实际存储数据 [A, B, C, ...] 的地方。
  • len3,表示 Slice 当前包含的元素数量。
  • cap5,表示底层数组从指针开始位置到其末尾的总容量。

函数调用:将 Slice 作为参数传递

现在,我们将 s1 传递给一个函数 foo(s2)。Go 会复制 s1sliceHeader,创建一个新的 sliceHeader s2。关键在于,这个新的 s2 内部的指针,和 s1 的指针指向的是同一个底层数组

image.png
image.png

说明:

  1. 值传递 (Pass-by-Value): s1sliceHeader 被完整地复制给了 s2s1s2 是两个独立的结构体变量,它们位于不同的内存区域(例如,在栈上的不同帧中)。
  2. 引用效果 (Reference-like Effect): 尽管 sliceHeader 是复制的,但它们内部的指针 ptr 具有相同的值。这意味着它们都指向了同一个存储实际数据的底层数组。

结论

正是因为这种机制——“Header 本身是值传递,但内部指针共享同一个底层数组”——导致了 Slice 的独特行为:

  • 表现为“引用传递”:当你在函数内部通过 s2 修改底层数组的元素时(例如 s2[0] = 'X'),这个修改会通过共享的底层数组反映到 s1 上。
  • 表现为“值传递”:如果你在函数内部使用 append 操作,导致底层数组因为容量不足而重新分配了内存,那么 s2 的指针会指向一个新的底层数组。这时,s2 的后续修改将不再影响原始的 s1,因为它俩的指针已经指向了不同的地方。这暴露了其“值传递”的本质。
image.png
image.png

结果分析

  • 步骤 3:修改 s2[0] 将底层数组中原来的 5 改成了 99。因为 s1 的最后一个元素也指向这里,所以 s1 也受到了影响。
  • 步骤 4s2 发生了扩容,它指向了一个全新的底层数组。
  • 步骤 5:再次修改 s2 时,它操作的是新数组,与 s1 指向的旧数组已经毫无关系。

2、扩容策略

Go 的 Slice 扩容机制旨在平衡内存使用和分配次数:

  • 当所需容量小于 1024 个元素时,会翻倍扩容 (newCap = oldCap * 2)。
  • 当所需容量超过 1024 个元素时,会以 1.25 倍的速度缓慢增长,避免因单次扩容造成巨大的内存浪费。
image.png
image.png

观察输出,你会清晰地看到容量从 1, 2, 4, 8... 一直翻倍增长,直到超过 1024 后增长速度放缓。

3、核心总结

  • Slice 是一个包含指针、长度和容量的结构体。
  • 函数传递 Slice 时,传递的是这个结构体的副本(值传递)。
  • 在不发生扩容时,对 Slice 元素的操作会通过指针影响共享的底层数组,表现出“引用传递”的效果。
  • 一旦 append 操作触发扩容,Slice 的指针会指向新分配的内存,与原来的底层数组“脱钩”,表现出“值传递”的效果。
  • 这也解释了为什么 append 函数必须有返回值并重新赋值给原 Slice (slice = append(slice, ...)), 因为扩容后返回的是一个指向全新内存的、完全不同的 Slice 结构体。

11、Map 使用详解

在 Go 语言中,slicemap 是使用频率最高、也最为核心的两种集合类数据结构。本节将详细讲解 map 的定义、特性及其使用方法。

1、什么是 Map?

Map 是一种基于 键值对(key-value) 的、无序的集合。

  • Key (键):作为索引,每个键都是唯一的。
  • Value (值):与键相关联的数据。

Map 最主要的优势在于其高效的查询性能。与需要通过遍历来查找元素的数组或切片不同,map 可以通过键直接定位到值,其查询操作的时间复杂度为 O(1)。这使得 map 在需要快速存取的场景中表现极为出色。

2、Map 的定义与初始化

基本定义

定义 map 时,需要指定键(key)和值(value)的数据类型。

代码语言:go
复制
// 定义一个键为 string 类型、值为 string 类型的 map
var courseMap map[string]string

在上述代码中,string 是键的类型,必须放在中括号 [] 内;第二个 string 是值的类型。

初始化

Map 在使用前 必须进行初始化,否则会导致运行时错误(runtime panic)。对一个未初始化的 nil map 进行写操作是 Go 语言中的常见错误。

错误示例:向未初始化的 map 中添加值会引发 panic。

image.png
image.png

以下是两种推荐的初始化方式:

方式一:使用字面量初始化

在声明的同时,可以通过字面量直接为其赋初值。Go 会自动推断其类型。

image.png
image.png

注意:根据 Go 的语法规定,在使用多行字面量初始化时,最后一个元素后面也必须跟一个逗号。

如果想初始化一个空 map,可以这样做:

image.png
image.png

方式二:使用 make 函数

make 是 Go 语言的内置函数,专门用于初始化 slicemapchannel 这三种引用类型。这是最常用的初始化方式。

image.png
image.png

总结:无论采用哪种方式,关键在于确保在向 map 中存入值之前,它已经被成功初始化。

3、 Map 的基本操作

3.1 添加与修改值

Map 的赋值操作语法非常简洁。如果键已存在,则会更新其对应的值;如果不存在,则会创建新的键值对。

image.png
image.png

3.2 获取值

image.png
image.png

4、 nil 在 Map 和 Slice 中的差异

一个值得注意的细节是 nilmapslice 的影响不同。

  • nil map:一个未初始化的 map,其值为 nil。你不能向一个 nil map 添加任何元素。
  • nil slice:一个未初始化的 slice,其值也为 nil。但特殊的是,你可以对一个 nil slice 安全地使用内置的 append 函数来添加元素。
image.png
image.png

这个差异是 Go 语言设计中的一个重要细节,在面试和实际开发中都可能遇到。

12、Map 的遍历与特性

在 Go 语言中,我们经常需要遍历 map 来处理其中的每一个键值对。本节将深入探讨 map 的遍历方法、键的类型约束以及其固有的无序性。

1、使用 for...range 遍历 Map

for...range 结构是遍历 map 的标准且最便捷的方式。

同时获取键和值

在遍历时,for...range 会返回两个值:键(key)和与该键对应的值(value)。

image.png
image.png
只获取值

如果只关心 map 中的值而不需要键,可以使用空白标识符 _ 来忽略键。

image.png
image.png
只获取键

for...range 还支持一种单返回值形式。当只提供一个变量时,该变量接收到的是 map

image.png
image.png

实现原理: Go 编译器会根据 for...range 接收变量的数量(一个或两个)来决定底层的实现方式,因此这两种写法都是有效的。尽管两种方式都能实现完整遍历,但直接使用 for key, value := range ... 的形式通常更清晰、更高效。

2、Map 的键类型约束

并非所有类型都能作为 map 的键。

  • Value(值):可以是任意 Go 类型。
  • Key(键):键的类型必须是可比较的(Comparable)。这意味着该类型的值可以用 ==!= 运算符进行比较。

常见的可比较类型包括:

  • bool
  • 数值类型(int, float64, 等)
  • string
  • 指针
  • channel
  • interface
  • 数组(Array)

而不可比较的类型,因此不能作为 map 的键,主要包括:

  • 切片(Slice)
  • Map
  • 函数(Function)
image.png
image.png

在编写代码时,现代的 IDE 通常会自动检测并提示无效的键类型。

3、Map 的无序性

这是 map 最重要的特性之一:map 是无序的

当遍历一个 map 时,元素的返回顺序是不固定的。Go 语言在设计上特意打乱了遍历的起始点,以防止开发者依赖于某个特定的迭代顺序。

image.png
image.png
image.png
image.png

核心结论

  1. Map 的遍历顺序是随机的。
  2. 绝不能依赖 map 的遍历顺序来实现业务逻辑。

如果你的应用场景要求数据容器保持有序,那么 map 本身无法满足需求。在这种情况下,通常需要将 mapslice 结合使用:例如,将键存储在一个 slice 中并对其进行排序,然后根据排好序的 slice 来从 map 中取值。

13、判断Map 中是否存在元素和删除元素

1、获取与判断 Map 中的元素

1.1 直接取值的潜在问题

直接通过键(key)从 Map 中获取值是一种常见操作。

image.png
image.png

然而,这种方式存在一个重要的模糊性:当指定的键不存在时,Go 会返回该值类型的零值。例如,如果值的类型是 string,那么在键不存在时会返回一个空字符串 ""

image.png
image.png

这会导致一个问题:我们无法区分 “键存在,但其对应的值恰好是零值(如空字符串)”“键根本不存在” 这两种情况。仅通过判断返回值是否为零值来确定键是否存在,可能会导致程序逻辑错误(bug)。

1.2 安全的键存在性判断

为了准确地判断一个键是否存在于 Map 中,应该使用支持两个返回值的访问方式。

image.png
image.png

这种用法会返回两个值:

  • value:与键对应的值。如果键不存在,value 会是该类型的零值。
  • ok:一个布尔值。如果键存在,oktrue;如果键不存在,okfalse

因此,检查 ok 的值是判断键是否存在的标准方法

image.png
image.png
结合 if 的简洁语法

Go 语言允许在 if 语句的条件判断前执行一个简短的初始化语句。这种特性与 Map 的键值判断完美结合,可以写出更紧凑、可读性更高的代码。

通过这种方式,从 Map 中获取的 valueok 变量的作用域被限制在 if-else 代码块内部,增强了代码的封装性。

image.png
image.png

如果只关心键是否存在而不关心其具体值,可以使用空标识符 _ 来忽略第一个返回值。

image.png
image.png

2、删除 Map 中的元素

使用 Go 的内置函数 delete() 可以方便地从 Map 中移除一个键值对。

语法delete(map_variable, key_to_delete)

image.png
image.png

一个重要的特性是,尝试删除一个不存在的键不会导致程序错误(panic)。如果 delete() 函数的目标键不存在,该操作不会产生任何效果,程序会继续正常执行。因此,可以放心使用 delete() 而无需预先检查键是否存在。

3、重要提示:Map 的线程安全

Go 语言内置的 map 类型不是线程安全的

如果在多个协程(goroutine)中并发地对同一个 Map 进行读写操作,将会导致数据竞争,引发不可预测的程序行为甚至运行时崩溃。

对于需要在并发环境中使用的 Map,必须采用同步措施。Go 标准库为此提供了 sync.Map 类型,它专门为并发访问场景设计,并内置了必要的锁定机制。在并发编程中,应优先使用 sync.Map 来保证数据安全。

14、list:链表数据结构

本节我们探讨 Go 语言容器中的 list,它是一个基于链表实现的数据结构。

1、listslice 的核心差异

list 的设计初衷是为了解决 slice 在特定场景下的局限性。slice 的本质是动态数组,其核心特点和潜在问题如下:

  • 连续内存空间slice 要求其底层数据存储在一块连续的内存中。这意味着如果内存中没有足够大的连续空间,就无法分配相应大小的 slice
  • 扩容成本:当向 slice 中持续添加数据直至其容量用尽时,会触发扩容机制。扩容会分配一块更大的新内存空间,并将所有旧数据完整地拷贝到新空间,在高频添加场景下会带来性能开销。

为了应对这些问题,list(链表)采用了完全不同的存储模型。

  • 非连续内存:链表中的每个元素(节点)都在内存中独立分配。它们通过指针nextprev,因为 Go 的 list 是双向链表)连接起来,而无需在物理上连续存储。
  • 空间开销:这种结构的代价是每个元素都需要额外的空间来存储指向其他元素的指针,因此存在一定的空间浪费。

2、性能特点对比

由于底层原理的巨大差异,slicelist 在不同操作上表现出截然不同的性能。

操作 (Operation)

slice (切片)

list (链表)

备注 (Remarks)

查询/访问 (Query/Access)

O(1)

O(n)

slice 通过索引可直接计算内存地址,访问速度极快。list 必须从头节点开始逐个遍历才能找到目标元素。

中间插入/删除 (Mid-list Insertion/Deletion)

O(n)

O(1)

slice 在中间插入或删除元素需要移动该位置之后的所有元素。list 仅需修改相邻节点的指针即可,操作非常高效(前提是已持有目标位置的指针)。

例如,要在 [1, 2, 3, 4, 5, 6]34 之间插入 7

  • 对于 slice:需要重新分配一个更大的连续空间,然后将 1, 2, 3 拷贝过去,放入 7,再将 4, 5, 6 拷贝到后面。
  • 对于 list:只需新分配一个存放 7 的节点,然后将 3next 指针指向 77prev 指针指向 37next 指针指向 44prev 指针指向 7 即可。无需移动任何现有元素。

3、应用场景与总结

尽管 list 在插入和删除操作上具有理论优势,但在 Go 的实际开发中,其使用频率远低于 slicemap

主要原因在于

  1. 查询性能差O(n) 的查询复杂度在绝大多数场景下是不可接受的。
  2. 缓存不友好slice 的连续内存布局具有极佳的缓存局部性,CPU 可以高效预取数据,使其遍历性能在实践中远超 list

因此,slicemap 凭借其出色的综合性能,满足了超过 90% 的编程需求。只有在极少数需要海量、高频地在集合中间进行插入和删除,且对查询性能要求不高的特定场景下,list 才可能成为一个考虑选项。

总而言之,理解 list 的原理有助于我们全面地认识 Go 的数据结构,但在日常编码中,应优先选择 slicemap

15、list 的基本使用

本节将演示如何使用 Go 语言标准库中的 container/list 包,它提供了一个双向链表的实现。

slicemap 不同,list 并非 Go 的内置关键字,而是通过导入包来使用。

代码语言:go
复制
import (
	"container/list"
	"fmt"
)

1、导入与初始化

初始化一个 list 有两种主要方式:

image.png
image.png

2、添加元素

list 提供了在链表头部和尾部添加元素的方法。

  • PushBack(v interface{}) *Element:在链表尾部添加元素。
  • PushFront(v interface{}) *Element:在链表头部添加元素。
image.png
image.png

3、 遍历元素

list 的遍历不能使用 for...range 循环,而需要通过从头(Front)或尾(Back)节点开始,借助节点的 Next()Prev() 方法进行移动。

正序遍历 (从头到尾)

image.png
image.png

逆序遍历 (从尾到头)

image.png
image.png

4、插入与删除

list 最核心的优势是在指定元素前后进行高效的插入和删除操作。这些操作需要一个指向具体元素(*list.Element)的指针作为锚点。

4.1 在指定元素前插入

InsertBefore(v interface{}, mark *Element) 方法可以在 mark 元素之前插入一个新值 v

首先,我们需要遍历链表以找到这个 mark 元素。

image.png
image.png

4.2 删除指定元素

Remove(e *Element) 方法可以从链表中删除一个元素。同样,需要先找到该元素。

image.png
image.png

5、总结:四种集合类型的回顾

Go 语言中主要的四种集合类型各有其特点和适用场景:

  1. 数组 (Array)
    • 特点: 定长,长度是类型的一部分。
    • 用途: 使用较少,通常用于需要精确控制内存布局的底层场景。
  2. 切片 (Slice)
    • 特点: 动态数组,使用方便,性能高(缓存友好)。
    • 用途: 最常用的数据结构,几乎是所有序列数据的首选。
  3. Map
    • 特点: 键值对存储,查找效率高。
    • 用途: 非常常用的数据结构,用于实现关联数组、哈希表等。它在功能上没有直接的竞争者。
  4. 列表 (List)
    • 特点: 双向链表,中间插入/删除效率高,但查询慢。
    • 用途: 使用较少。它的竞争对手是 slice。虽然在理论上 list 能完成的 slice 也能完成,反之亦然,但由于性能和缓存等因素,slice 在绝大多数场景下是更优的选择。

核心建议: 熟练掌握 slicemap,因为它们构成了 Go 日常开发的基础。仅在确认业务场景高度契合链表特性(如高频的非首尾插入/删除)时,才考虑使用 list。若想了解更多方法,可直接查阅 container/list 包的官方文档。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1、数组的基本用法
    • 1、Go 语言的核心集合类型
    • 2、 数组的定义与特性
      • 2.1 定义语法
      • 2.2 长度是类型的一部分
    • 3、数组的初始化与遍历
      • 3.1 赋值
      • 3.2 遍历
    • 4、数组 vs 切片:一个重要的区分
  • 2、数组的初始化和遍历
    • 1、数组的初始化
      • 1.1 基本初始化
      • 1.2 指定索引初始化
      • 1.3 使用 ... 自动推导长度
    • 2、数组的遍历
      • 标准 for 循环
  • 3、数组比较与多维数组
    • 1、 数组的比较
    • 2、多维数组
      • 2.1 声明与初始化
      • 2.2 遍历多维数组
  • 4、切片的定义和赋值
    • 1、 切片:Go 语言的动态数组
    • 2、切片的定义
    • 3、切片的基本操作
      • 3.1 追加元素 (Append)
      • 3.2 访问元素
      • 3.3 遍历切片
  • 5、切片的多种初始化方式
    • 1、 从数组创建切片
    • 2、 使用切片字面量 (Slice Literal)
    • 3、使用 make 内置函数
  • 6、切片的数据访问
    • 1、访问单个元素
    • 2、访问多个元素(创建子切片)
      • 2.1 指定起始和结束索引 slice[start:end]
      • 2.2 省略结束索引 slice[start:]
      • 2.3 省略起始索引 slice[:end]
      • 2.4 同时省略起始和结束索引 slice[:]
  • 7、切片(Slice)的数据添加与合并
    • 1、 一次性追加多个元素
    • 2、 合并两个切片
    • 3、追加另一个切片的子集
  • 8、切片(Slice)的删除与复制操作
    • 1、 从切片中删除元素
      • 1.1 删除中间的元素
      • 1.2 删除末尾的元素
    • 2、复制切片
      • 2.1 浅拷贝(赋值与切片表达式)
      • 2.2 深拷贝(使用 copy 内置函数)
  • 9、切片 (Slice) 的底层实现原理:值传递与引用传递之谜
    • 1、场景一:在函数内修改切片元素
    • 2、场景二:在函数内追加切片元素
  • 10、深入 Go Slice 的底层原理
    • 1、为什么 Slice 兼具“值传递”与“引用传递”的特征?
    • 2、扩容策略
    • 3、核心总结
  • 11、Map 使用详解
    • 1、什么是 Map?
    • 2、Map 的定义与初始化
      • 方式一:使用字面量初始化
      • 方式二:使用 make 函数
    • 3、 Map 的基本操作
      • 3.1 添加与修改值
      • 3.2 获取值
    • 4、 nil 在 Map 和 Slice 中的差异
  • 12、Map 的遍历与特性
    • 1、使用 for...range 遍历 Map
    • 2、Map 的键类型约束
    • 3、Map 的无序性
  • 13、判断Map 中是否存在元素和删除元素
    • 1、获取与判断 Map 中的元素
    • 2、删除 Map 中的元素
    • 3、重要提示:Map 的线程安全
  • 14、list:链表数据结构
    • 1、list 与 slice 的核心差异
    • 2、性能特点对比
    • 3、应用场景与总结
  • 15、list 的基本使用
    • 1、导入与初始化
    • 2、添加元素
    • 3、 遍历元素
    • 4、插入与删除
      • 4.1 在指定元素前插入
      • 4.2 删除指定元素
    • 5、总结:四种集合类型的回顾
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档