指针
Go语言沿用了C/C++中关于指针的两个核心操作符 & 和 *,它们的含义是完全一样的。
&(取地址操作符 - Address-of Operator)- Go:
&i会生成一个指向变量i的指针。 - C++:
&i同样会生成一个指向变量i的指针。
- Go:
*(解引用操作符 - Dereference Operator)- Go:
- 在类型前面,如
*int,表示“一个指向int类型的指针”的类型。 - 在指针变量前面,如
*p,表示获取该指针指向的底层值。
- 在类型前面,如
- C++:
- 在类型前面,如
int*或int *,表示“一个指向int类型的指针”的类型。 - 在指针变量前面,如
*p,表示获取该指针指向的底层值。
- 在类型前面,如
- Go:
- 零值 (Zero Value)
- Go: 指针的零值是
nil。一个nil指针不指向任何内存地址。 - C++: 指针的零值(空指针)是
nullptr(C++11及以后) 或NULL(旧标准)。
- Go: 指针的零值是
Go代码示例:
package main
import "fmt"
func main() {
i := 42
// p 是一个指向 int 类型的指针
// Go的习惯写法是 var p *int
var p *int
// &i 获取变量 i 的内存地址,并将其赋给 p
p = &i
// *p 解引用,读取指针 p 指向的底层值 (也就是 i 的值)
fmt.Println("Value via pointer:", *p) // 输出: 42
// 通过指针修改底层值
*p = 21
fmt.Println("New value of i:", i) // 输出: 21
}
Go中一个很大的不同点就是Go没有指针运算。Go希望你通过更安全的方式来操作数据集合,比如使用切片和索引 arr[i]。
结构体
1. 定义与字段访问 (. 操作符)
这部分和C++几乎完全一样。struct是一个字段的集合,通过 . 来访问。
C++ 语言:
struct Vertex {
int X;
int Y;
};
int main() {
Vertex v = {1, 2};
v.X = 100; // 同样通过点号访问
std::cout << v.X; // 输出: 100
}
Go 语言:
// 定义一个结构体类型
type Vertex struct {
X int
Y int
}
func main() {
v := Vertex{1, 2}
v.X = 100 // 通过点号访问并修改字段
fmt.Println(v.X) // 输出: 100
}
2. 通过指针访问 (Go语法的便利之处)
这是Go语言一个非常棒的语法糖,它统一了值和指针的字段访问方式。
Go 的统一与简化: 在Go中,无论你有一个结构体值还是一个指向结构体的指针,你总是使用 . 来访问字段。Go编译器会自动帮你处理解引用的操作。
v := Vertex{3, 4}
p := &v
v.X = 10 // 通过 . 访问
p.Y = 20 // 同样通过 . 访问,无需 ->
// (*p).X = 30 这种写法在 Go 中也是合法的,但没人这么用,
// 因为 p.X 更简单、更地道。
结论:Go语言用 . 统一了 . 和 -> 的功能,这是一个非常受欢迎的便利性改进,可以减少心智负担。
C++ 的区别: 在C++中,你必须严格区分对象和指向对象的指针。访问对象成员用 .,访问指针指向的对象的成员用 ->。
Vertex v = {3, 4};
Vertex* p = &v;
v.X = 10; // 通过 . 访问
p->Y = 20; // 必须通过 -> 访问
(*p).X = 30; // 也可以这样写,但很繁琐
3. 结构体字面量 (Struct Literals)
这是Go中初始化struct实例的方式,非常灵活。
创建指向结构体的指针 (& 前缀): 如果你想直接创建一个指向新分配的结构体实例的指针,可以在结构体字面量前加上 &。
// p 是一个 *Vertex 类型的指针
p := &Vertex{X: 1, Y: 2}
fmt.Println(p.X) // 直接使用 . 访问
这只是下面两行代码的一个快捷写法:
// 完整写法
temp_v := Vertex{X: 1, Y: 2}
p := &temp_v
C++类比: 这有点像 new 关键字 auto p = new Vertex{1, 2};,但有一个本质区别:在Go中你不需要关心内存是在堆上还是栈上分配,也不需要手动delete。Go的编译器和垃圾回收器会自动处理这一切,大大简化了内存管理。
通过 Name: Value 语法: 这种方式更常用,也更健壮。
// 字段顺序可以任意,也可以只初始化部分字段
v2 := Vertex{X: 1} // Y 会被自动初始化为零值 0
v3 := Vertex{Y: 2, X: 1} // 顺序无关
优点: 如果未来你在Vertex结构体的X和Y之间增加了一个新字段Z,v2和v3的代码完全不需要修改,而v1的写法就会导致编译错误。 C++类比: 这种写法非常类似于 C++20 引入的指定初始化器 (designated initializers): Vertex v = {.Y = 2, .X = 1};。这说明现代编程语言在“如何让初始化更清晰、更健壮”这个问题上,思路是趋同的。
按顺序提供字段值:
// 必须提供所有字段的值,且顺序必须和定义时一致
v1 := Vertex{1, 2}
C++类比: 类似于C++的聚合初始化 Vertex v1 = {1, 2};。
数组和切片
数组
类型 [n]T 表示一个数组,它拥有 n 个类型为 T 的值。
表达式
var a [10]int
会将变量 a 声明为拥有 10 个整数的数组。因为Go的数组是一个固定大小的、存放特定类型元素的容器,而长度是类型的一部分。这意味着 [5]int 和 [10]int 是两种完全不同、互不兼容的类型。你不能将一个[5]int类型的变量赋值给一个[10]int类型的变量。
正因为其死板的固定长度,数组在函数参数传递等场景下非常不灵活。因此,在Go的实际编程中,我们几乎总是使用切片。数组通常只是作为切片的底层存储而存在。
切片
一个切片变量,其内部只是一个包含三个信息的小结构体(称为“切片头”):
- 指针 (Pointer):指向底层数组中,该切片第一个元素的位置。
- 长度 (Length):该切片包含了多少个元素 (
len()函数获取)。 - 容量 (Capacity):从切片的第一个元素开始,到底层数组末尾,总共有多少个元素 (
cap()函数获取)。
- 长度 (Length):切片当前实际包含的元素个数。
- 这是你通过
s[i]能访问的范围(ifrom0tolen(s)-1)。 for...range循环遍历的就是这个长度。- C++类比:完全等同于
std::vector的size()方法。
- 这是你通过
- 容量 (Capacity):从切片的起始元素开始,到底层数组的末尾,总共可以容纳的元素个数。
- 它代表了切片在不重新分配内存的情况下,可以“增长”的最大潜力。
- C++类比:完全等同于
std::vector的capacity()方法。
切片操作 a[low : high]
这个操作会从一个已有的数组或切片中,创建一个新的切片,这个新切片将共享同一个底层数组。
low是起始索引(包含)。high是结束索引(不包含)。- 新切片的长度是
high - low。 - 新切片的容量是从
low索引到底层数组的末尾。
切片字面量
切片字面量是一种直接在代码中声明并初始化一个新切片的语法。
切片字面量: [] 中不指定长度。
// 这创建了一个 []bool 类型的切片
sli := []bool{true, true, false}
数组字面量: 必须在 [] 中指定一个固定长度。
// 这创建了一个 [3]bool 类型的数组
arr := [3]bool{true, true, false}
当你使用切片字面量 []bool{true, true, false} 时,Go编译器会自动为你做两件事:
- 创建一个匿名的、大小合适的底层数组。在这个例子中,它会创建一个
[3]bool的数组来存储{true, true, false}。 - 创建一个指向这个新数组的切片,并返回这个切片。
所以,最终你得到的 sli 是一个 len=3,cap=3 的切片,它指向一个刚刚为它创建的、大小为3的数组。
切片时的默认值
这是切片操作 a[low:high] 的一种语法糖,让你在取切片的开头或结尾部分时,可以省略索引。
我们先定义一个用于演示的切片:
planets := []string{"Mercury", "Venus", "Earth", "Mars", "Jupiter", "Saturn"}
// 索引: 0 1 2 3 4 5
a. 忽略下界 (Default low is 0)
如果你省略冒号 : 前面的 low 索引,它会默认从 0 开始。
- 显式写法:
planets[0:3]结果是{"Mercury", "Venus", "Earth"} - 默认值写法:
planets[:3]结果完全相同。
这对于获取一个切片的“前缀”非常方便。
b. 忽略上界 (Default high is len(slice))
如果你省略冒号 : 后面的 high 索引,它会默认一直取到切片的末尾(即 len(planets) 的位置)。
- 显式写法:
planets[4:6]结果是{"Jupiter", "Saturn"} - 默认值写法:
planets[4:]结果完全相同。
这对于获取一个切片的“后缀”非常方便。
c. 同时忽略上下界
如果你同时省略 low 和 high,那么就会创建一个引用整个原始切片的新切片。
- 显式写法:
planets[0:6] - 默认值写法:
planets[:]
两者都创建了一个与 planets 指向相同底层数组、且长度和容量都相同的新切片。
nil切片
切片的零值是 nil。
nil 切片的长度和容量为 0 且没有底层数组。
make 创建切片
make在用于切片时,主要有两种形式:
1. make([]T, length) —— 只指定长度
这种形式会创建一个包含 length 个元素的切片,并且每个元素都会被初始化为其类型的零值。
- 语法:
a := make([]int, 5) - 幕后操作:
- Go分配一个大小为5的
int数组。 - 数组中的所有元素都被初始化为
int的零值,即0。 - 创建一个指向这个数组的切片
a。
- Go分配一个大小为5的
- 结果:
a是一个内容为{0, 0, 0, 0, 0}的切片。len(a)等于 5cap(a)也等于 5
- 使用场景: 当你需要一个确定大小的缓冲区,或者一个之后会通过索引填充的切片时。例如,从文件中读取固定数量的字节。
2. make([]T, length, capacity) —— 同时指定长度和容量
这种形式提供了更精细的控制,允许你创建一个长度较小(甚至为0)但预留了更大容量的切片。
- 语法:
b := make([]int, 0, 5) - 幕后操作:
- Go分配一个大小为5(即容量
capacity)的int数组。 - 数组中的元素同样被初始化为零值
0。 - 创建一个指向这个数组的切片
b,但这个切片的长度被设置为0(即length)。
- Go分配一个大小为5(即容量
- 结果:
b是一个空切片,但它拥有一个可以容纳5个元素的底层数组。len(b)等于 0cap(b)等于 5
- 使用场景 (性能优化): 这是
make最重要的用途。当你需要构建一个切片,并且预知它最终会包含大约N个元素时,使用make([]T, 0, N)初始化。然后,在循环中使用append向其添加元素。这样做的好处是:前N次append操作都不会触发内存重新分配和数据复制,因为容量是足够的。这会比从一个零容量的切片开始append要高效得多。
切片可以包含任何类型,当然也包括其他切片。
package main
import (
"fmt"
"strings"
)
func main() {
// 创建一个 3x3 的棋盘
// board 是一个 [][]string 类型的切片
// 它的外层切片有3个元素,每个元素本身又是一个 []string 类型的切片
board := [][]string{
[]string{"_", "_", "_"},
[]string{"_", "_", "_"},
[]string{"_", "_", "_"},
}
// 让我们下几步棋
board[0][0] = "X" // 第一行第一列
board[2][2] = "O" // 第三行第三列
board[1][2] = "X"
board[1][0] = "O"
board[0][2] = "X"
// 打印棋盘
for i := 0; i < len(board); i++ {
// strings.Join 可以用空格把一个字符串切片连接起来
fmt.Printf("%s\n", strings.Join(board[i], " "))
}
}
需要注意的是,Go的 [][]int:它在内存中不是一块连续的空间。它是一个“交错数组”或“锯齿数组”。外层的切片包含了N个元素,而每个元素都是一个独立的切片头(指针、长度、容量)。这些独立的切片头可以指向内存中完全不相关的底层数组。这种结构最大的好处是灵活性:每一行的长度都可以不同。
append 追加元素
append的功能是在切片的末尾添加一个或多个新元素。
var s []int
s = append(s, 1) // 追加一个元素
s = append(s, 2, 3, 4) // 同时追加多个元素
fmt.Println(s) // 输出: [1 2 3 4]
append的行为取决于切片的容量 (capacity)。append 的结果是一个包含原切片所有元素加上新添加元素的切片。当 s 的底层数组太小,不足以容纳所有给定的值时,它就会分配一个更大的数组。 返回的切片会指向这个新分配的数组。
append必须有返回值,因为Go的append函数返回一个新的切片,你必须用这个返回值来更新你的切片变量。因此永远要把 append 的结果赋值给你原来的切片变量。
如果想把一个切片的所有元素都追加到另一个切片后面,需要使用 ... 语法。
s1 := []int{1, 2}
s2 := []int{3, 4}
// 使用 s2... 将 s2 的所有元素“打散”作为 append 的参数
s1 = append(s1, s2...)
fmt.Println(s1) // 输出: [1 2 3 4]
range遍历
for 循环的 range 形式可遍历切片或映射。
当使用 for 循环遍历切片时,每次迭代都会返回两个值。 第一个值为当前元素的下标,第二个值为该下标所对应元素的一份副本。
当 range 用于切片或数组时,它会在每次迭代中返回两个值:
- 第一个值: 当前元素的索引 (index)。
- 第二个值: 该索引处元素的值 (value)。
package main
import "fmt"
func main() {
pow := []int{1, 2, 4, 8, 16, 32}
// i 是索引, v 是该索引处的值
for i, v := range pow {
fmt.Printf("2**%d = %d\n", i, v)
}
}
如果不需要索引或值,可以使用Go的空白标识符 _ (下划线)来忽略它。注意,如果只提供一个变量,它接收的是索引。
需要注意的是,第二个返回值是“该下标所对应元素的一份副本”。这意味着在循环中,你拿到的变量 v 只是切片中元素的一个拷贝,而不是元素本身。直接修改 v 不会影响原始切片。
如果想修改原始切片中的元素,必须使用索引来操作。Go语言选择不提供“引用式”的遍历变量,而是鼓励你通过明确的索引 s[i] 来进行修改。这使得代码的意图(读取 vs 修改)更加清晰。
map 映射
1. 创建映射 (Creating a Map)
Go提供了多种创建map的方式,你需要根据不同场景选择。
a. 零值 nil 映射
- 重要: 你可以安全地从
nil映射中读取数据,它会返回该值类型的零值。fmt.Println(m["one"]) // 输出: 0
Go 语言:
var m map[string]int
fmt.Println(m == nil) // 输出: true
一个nil映射不能用于存储键值对。如果你尝试向nil映射写入数据,程序会崩溃 (panic)。 m["one"] = 1 // 会导致 panic: assignment to entry in nil map
b. 使用 make 函数
这是创建可用的、非nil的空映射的标准方式。
C++ 类比: 这完全等同于创建一个空的 std::unordered_map。
std::unordered_map<std::string, int> m;
m["one"] = 1; // 合法
Go 语言:
// 创建一个键为 string,值为 int 的空映射
m := make(map[string]int)
m["one"] = 1 // 合法
c. 映射字面量 (Map Literals)
如果你在创建时就知道一些初始的键值对,可以使用字面量。
C++ 类比: 这类似于C++11的初始化列表语法。
std::unordered_map<std::string, int> m = {
{"one", 1},
{"two", 2}
};
Go 语言:
// 语法和结构体字面量很像,但必须有键
m := map[string]int{
"one": 1,
"two": 2, // 注意:结尾的逗号是必需的
}
2. 修改映射 (CRUD 操作)
Go对map的操作非常简洁直观。
| 操作 | Go 语法 | C++ std::unordered_map 等价操作 |
|---|---|---|
| 插入 / 更新 | m["key"] = value |
m["key"] = value; 或 m.insert_or_assign("key", value); |
| 读取 | elem := m["key"] |
auto elem = m["key"]; |
| 删除 | delete(m, "key") |
m.erase("key"); |
一个细微但重要的区别:在C++中,如果键"key"不存在,访问m["key"]会自动插入一个默认值的元素。但在Go中,访问m["key"]不会修改map,它只会返回value类型的零值。
3. “Comma OK” 断言:检查键是否存在
这是Go语言一个非常著名且地道的用法(idiom),用于解决一个经典问题:如何区分“一个不存在的键”和“一个值为零值的键”?
例如,m["bob_score"] 返回了 0,我们无法知道是因为Bob的分数真的是0,还是因为map里根本没有Bob这个人。
elem: 键对应的值。如果键不存在,则为该值类型的零值。ok: 一个bool值。如果键存在,则为true;如果不存在,则为false。
Go 的 “Comma OK” 方案: Go的map在通过键来索引时,可以同时返回两个值:
scores := map[string]int{
"alice": 100,
"bob": 0,
}
// 场景1:键存在,值为非零值
score, ok := scores["alice"]
fmt.Println(score, ok) // 输出: 100 true
// 场景2:键存在,值为零值
score, ok = scores["bob"]
fmt.Println(score, ok) // 输出: 0 true
// 场景3:键不存在
score, ok = scores["charlie"]
fmt.Println(score, ok) // 输出: 0 false
通过检查ok的值,你就能准确地判断键是否存在。最常见的用法是结合if的短变量声明:
if score, ok := scores["charlie"]; ok {
fmt.Println("Charlie's score is:", score)
} else {
fmt.Println("Charlie is not in the map.")
}
这种写法非常简洁、高效且没有歧义,是Go语言的一大亮点。
C++ 的解决方案: 通常需要先用 .find() 或 .count() 来检查。
if (m.count("bob_score") > 0) {
// Key exists
}
这种方式比较繁琐。
4. 遍历 Map
for...range 循环同样适用于map,它会返回键和值。
for key, value := range scores {
fmt.Printf("Key: %s, Value: %d\n", key, value)
}
重要: Go map的遍历顺序是随机的、不保证的。每次遍历的输出顺序都可能不同。这一点和C++的std::unordered_map一样。如果你需要按键排序遍历,正确的方法是:
- 提取所有的键到一个切片中。
- 对切片进行排序。
- 遍历排序后的切片,再通过键去
map中取值。
函数
1. 函数类型 (Function Types)
首先,要理解函数是一种值,就要知道函数本身也有类型。函数类型由它的参数和返回值共同决定。
- Go 语言:
func(int, int) string就是一个函数类型,它代表了“任何接受两个int参数并返回一个string的函数”。 - C++ 类比: 这非常类似于
std::function<std::string(int, int)>。两者都定义了一个可以持有特定签名函数”的类型。
2. 将函数赋值给变量
既然函数有类型,我们就可以把它赋值给一个变量。
package main
import "fmt"
// 定义一个普通的具名函数
func compute(fn func(float64, float64) float64) float64 {
return fn(3, 4)
}
func main() {
// 声明一个 hypot 变量,它的类型是 func(float64, float64) float64
// 并将一个“匿名函数”(Anonymous Function)赋值给它
hypot := func(x, y float64) float64 {
return x*x + y*y // 故意写错,应该是math.Sqrt(x*x + y*y)
}
// hypot 变量现在持有了上面的函数值,可以像普通函数一样调用
fmt.Println(hypot(5, 12)) // 输出: 169 (5*5 + 12*12)
// 将 hypot 函数值作为参数传递给 compute 函数
fmt.Println(compute(hypot)) // 输出: 25 (3*3 + 4*4)
}
- 匿名函数: 上例中
func(x, y float64) float64 { ... }就是一个匿名函数,因为它没有名字。它在定义的同时被赋值给了hypot变量。
C++ 类比: 这与C++的Lambda表达式几乎完全等价。
// C++ Lambda
auto hypot = [](double x, double y) {
return x*x + y*y;
};
std::cout << hypot(5, 12); // 输出: 169
3. 函数作为参数 (Higher-Order Functions)
这是函数值最常见的用途:将一个函数作为参数传递给另一个函数。这可以让你编写出非常灵活和通用的代码,常用于回调、策略模式等。
Go 语言示例:
package main
import (
"fmt"
"math"
)
// `compute` 函数接受一个函数 `fn` 作为它的第二个参数。
// `fn` 的类型必须是 func(float64, float64) float64
func compute(a, b float64, fn func(float64, float64) float64) float64 {
return fn(a, b)
}
func main() {
// 1. 定义一个普通的具名函数
hypot := func(x, y float64) float64 {
return math.Sqrt(x*x + y*y)
}
// 把 hypot 函数“本身”作为值传递给 compute
fmt.Println("hypot:", compute(3, 4, hypot)) // hypot: 5
// 2. math.Pow 也是一个符合签名的函数,所以也可以直接传递
fmt.Println("pow:", compute(3, 4, math.Pow)) // pow: 81 (3的4次方)
// 3. 直接在调用处传递一个匿名函数
add := compute(3, 4, func(x, y float64) float64 {
return x + y
})
fmt.Println("add:", add) // add: 7
}
Go标准库中有很多这样的例子,比如 http.HandleFunc 就接受一个用于处理HTTP请求的函数作为参数。
4. 函数作为返回值 (Function Factories)
函数甚至可以作为另一个函数的返回值。这可以用来创建“函数工厂”,即一个函数专门用来“生产”其他函数。
Go 语言示例: 这个例子中,adder() 函数返回一个函数。返回的这个函数就是一个闭包 (Closure),因为它“记住”了它被创建时的环境(即sum变量)。
package main
import "fmt"
// `adder` 函数返回一个类型为 `func(int) int` 的函数
func adder() func(int) int {
sum := 0 // `sum` 变量被下面的匿名函数“捕获”
return func(x int) int {
sum += x
return sum
}
}
func main() {
// pos 和 neg 都是函数,但它们各自拥有独立的 sum 变量
pos, neg := adder(), adder()
for i := 0; i < 5; i++ {
fmt.Printf("Pos sum: %d, Neg sum: %d\n", pos(i), neg(-2*i))
}
}
输出:
Pos sum: 0, Neg sum: 0
Pos sum: 1, Neg sum: -2
Pos sum: 3, Neg sum: -6
Pos sum: 6, Neg sum: -12
Pos sum: 10, Neg sum: -20
- 在Go中,函数是一种可以被存入变量、作为参数传递、作为返回值返回的值。
- 函数类型: 由函数的参数和返回值类型共同定义。
- 匿名函数: 可以在任何需要函数值的地方动态定义一个函数,这在Go中非常常见,功能上等同于C++的Lambda。
- 闭包 (Closure): 当一个函数捕获了其外部作用域的变量时,就形成了一个闭包。这在函数作为返回值时特别强大。
例子:斐波纳契闭包
实现一个 fibonacci 函数,它返回一个函数(闭包),该闭包返回一个斐波纳契数列 (0, 1, 1, 2, 3, 5, ...)。
package main
import "fmt"
// fibonacci 函数返回一个“生成器”函数,该函数每次被调用都返回下一个斐波那契数
func fibonacci() func() int {
// a 和 b 就是被闭包“捕获”的状态变量。
// 它们只在 fibonacci 函数被调用时初始化一次。
a, b := 0, 1
// 这里返回的是一个匿名函数,即闭包
return func() int {
// 保存当前要返回的斐波那契数
current := a
// 更新状态,为下一次调用做准备
// Go 的多重赋值非常适合这个场景
a, b = b, a+b
// 返回当前这个数
return current
}
}
func main() {
// f 是一个闭包函数,它内部维持着 a 和 b 的状态
f := fibonacci()
// 循环调用 f,每次都会得到序列中的下一个数
for i := 0; i < 10; i++ {
fmt.Println(f())
}
}