Golang - 更多类型

指针

Go语言沿用了C/C++中关于指针的两个核心操作符 & 和 *,它们的含义是完全一样的。

  • & (取地址操作符 - Address-of Operator)
    • Go&i 会生成一个指向变量 i 的指针。
    • C++&i 同样会生成一个指向变量 i 的指针。
  • * (解引用操作符 - Dereference Operator)
    • Go:
      • 在类型前面,如 *int,表示“一个指向int类型的指针”的类型
      • 在指针变量前面,如 *p,表示获取该指针指向的底层值
    • C++:
      • 在类型前面,如 int* 或 int *,表示“一个指向int类型的指针”的类型
      • 在指针变量前面,如 *p,表示获取该指针指向的底层值
  • 零值 (Zero Value)
    • Go: 指针的零值是 nil。一个 nil 指针不指向任何内存地址。
    • C++: 指针的零值(空指针)是 nullptr (C++11及以后) 或 NULL (旧标准)。

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结构体的XY之间增加了一个新字段Zv2v3的代码完全不需要修改,而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的实际编程中,我们几乎总是使用切片。数组通常只是作为切片的底层存储而存在。

切片

一个切片变量,其内部只是一个包含三个信息的小结构体(称为“切片头”):

  1. 指针 (Pointer):指向底层数组中,该切片第一个元素的位置。
  2. 长度 (Length):该切片包含了多少个元素 (len() 函数获取)。
  3. 容量 (Capacity):从切片的第一个元素开始,到底层数组末尾,总共有多少个元素 (cap() 函数获取)。
  • 长度 (Length):切片当前实际包含的元素个数。
    • 这是你通过 s[i] 能访问的范围(i from 0 to len(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编译器会自动为你做两件事:

  1. 创建一个匿名的、大小合适的底层数组。在这个例子中,它会创建一个 [3]bool 的数组来存储 {true, true, false}
  2. 创建一个指向这个新数组的切片,并返回这个切片。

所以,最终你得到的 sli 是一个 len=3cap=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)
  • 幕后操作:
    1. Go分配一个大小为5的int数组。
    2. 数组中的所有元素都被初始化为int的零值,即0
    3. 创建一个指向这个数组的切片a
  • 结果a是一个内容为{0, 0, 0, 0, 0}的切片。
    • len(a) 等于 5
    • cap(a) 也等于 5
  • 使用场景: 当你需要一个确定大小的缓冲区,或者一个之后会通过索引填充的切片时。例如,从文件中读取固定数量的字节。

2. make([]T, length, capacity) —— 同时指定长度和容量

这种形式提供了更精细的控制,允许你创建一个长度较小(甚至为0)但预留了更大容量的切片。

  • 语法b := make([]int, 0, 5)
  • 幕后操作:
    1. Go分配一个大小为5(即容量capacity)的int数组。
    2. 数组中的元素同样被初始化为零值0
    3. 创建一个指向这个数组的切片b,但这个切片的长度被设置为0(即length)。
  • 结果b是一个空切片,但它拥有一个可以容纳5个元素的底层数组。
    • len(b) 等于 0
    • cap(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 用于切片或数组时,它会在每次迭代中返回两个值:

  1. 第一个值: 当前元素的索引 (index)
  2. 第二个值: 该索引处元素的值 (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这个人。

    1. elem: 键对应的值。如果键不存在,则为该值类型的零值
    2. 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一样。如果你需要按键排序遍历,正确的方法是:

  1. 提取所有的键到一个切片中。
  2. 对切片进行排序。
  3. 遍历排序后的切片,再通过键去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())
	}
}