Go 语言变量声明笔记

在 Go 语言中,变量的声明有多种不同的方式。

⚠️ 核心规则:
在 Go 语言中,声明的变量定义了就必须被使用,否则在编译时会报错(declared and not used)。

1. 标准声明 (指定变量类型)

使用 var 关键字,明确指定变量的类型。先声明,后赋值。

1
2
3
4
5
// 声明一个 int 类型的变量 age
var age int
age = 10
fmt.Println(age)

2. 批量声明多个同类型变量

可以在同一行声明多个相同类型的变量,然后再进行赋值。

1
2
3
4
5
6
7
8
9
10
11
// 声明两个 string 类型的变量
var name1, name2 string
name1 = "mike"
name2 = "fliex"
fmt.Println(name1, name2)

// 也可以在声明时直接赋值
var student1, student2 string
student1, student2 = "fliex", "fliex"
fmt.Println(student1, student2)

3. 类型推断 (省略变量类型)

在声明并赋初值时,可以省略类型。Go 编译器会根据等号右边的值自动推断变量的类型。

1
2
3
4
// 编译器自动推断 age2 为 int 类型
var age2 = 18
fmt.Println(age2)

4. 简短变量声明 ( := 语法)

这是 Go 语言中最常用的局部变量声明方式。省略 var 关键字和类型,直接使用 := 进行声明和初始化。
注意:这种方式只能用在函数内部,不能用于定义全局变量。

1
2
3
4
// 自动推断 name4 为 string 类型
name4 := "fliex"
fmt.Println(name4)

5. 简短声明多个不同类型的变量

使用 := 可以同时声明和初始化多个不同类型的变量,非常方便。

1
2
3
4
// 同时声明并初始化 string, bool, int 类型的变量
name5, name6, age := "fliex", true, 18
fmt.Println(name5, name6, age)

常量

在 Go 语言中,常量是指在程序编译阶段就确定下来,且在程序运行期间其值不能被改变的量。使用 const 关键字进行定义。

1. 常量的基本声明

常量可以通过 const 关键字声明,可以指定类型,也可以让编译器自动推断类型。

1
2
3
4
5
6
// 指定类型
const a int = 10

// 自动推断类型 (无类型常量)
const b = 10

2. 批量声明常量

与变量类似,常量也支持使用圆括号进行批量声明。这在组织一组相关常量时非常清晰,常常用于定义配置项或全局状态。

1
2
3
4
5
6
const (
name = "fliex"
age = 27
sex = "男"
)

也可以在同一行平行声明多个不同类型的常量:

1
2
3
const a, b, c = 1, "like", 3
// a 为 1, b 为 "like", c 为 3

3. 常量的核心特性 ⚠️

  • 不可修改: 常量一旦声明并赋值,在程序运行期间绝对不能被修改。
  • 不可将变量赋值给常量: 常量的值必须在编译时就能确定。你不能将一个运行期间才得出结果的变量赋值给常量。
  • 支持部分内置函数: 如果内置函数的参数在编译时是固定的,那么它的结果就可以用于常量赋值。例如 len() 函数如果测量的是字符串字面量,就可以赋值给常量。
1
2
3
4
5
6
7
// ✅ 正确:字符串 "abc" 的长度在编译时就固定是 3
const a = len("abc")

// ❌ 错误:如果 str 是一个变量,它的长度在运行时才能确定,不能赋值给常量
// var str = "abc"
// const b = len(str)

4. 使用常量模拟枚举 (Enum)

Go 语言没有专门的 enum 关键字,但通常使用 const 块来定义一组相关的枚举值,例如状态码。

1
2
3
4
5
const (
SUCCESS = 0
FAIL = 1
)

Go 语言 iota 计数器

在 Go 语言中,没有专门的 enum 关键字。我们通常通过 const 配合特殊的常量计数器 iota 来优雅地实现枚举的功能。

⚠️ 核心规则:

  • iota 只能在 const 内部使用。
  • 每次遇到 const 关键字时,iota 都会被重置为 0
  • const 块中每新增一行常量声明,iota 的值就会自动加 1

1. 基础用法 (从 0 开始递增)

最直观的写法是每一行都显式地给常量赋值 iota

1
2
3
4
5
6
7
const (
a = iota // a = 0
b = iota // b = 1
c = iota // c = 2
)
fmt.Println(a, b, c) // 输出: 0 1 2

2. 简写模式 (隐式递增)

const 块中,如果后续的常量没有显式赋值,它会默认重复上一行的表达式。因此,实际开发中我们通常只需要在第一行写一次 iota

1
2
3
4
5
6
7
const (
a = iota // a = 0
b // b = 1 (默认重复上一行表达式,即 iota)
c // c = 2
)
fmt.Println(a, b, c) // 输出: 0 1 2

3. 跳过特定的值 (使用空白标识符 _)

如果在枚举的序列中,你希望跳过某个特定的数字,可以使用空白标识符 _ 来占位。此时 iota 依然会随着行号的增加而在后台照常递增。

1
2
3
4
5
6
7
const (
a = iota // a = 0,此时第一行 iota = 0
_ // 占位,此时第二行 iota = 1 (丢弃不用)
b // b = 2,此时第三行 iota = 2
)
fmt.Println(a, b) // 输出: 0 2

4. 同一行声明多个常量

极其重要: iota 的递增是严格基于代码行数的,而不是变量的个数。如果在一行内同时定义多个变量,它们引用的 iota 值是相同的。

1
2
3
4
5
6
7
8
9
10
const (
// 第一行,此时 iota = 0
a, b = iota, iota // a = 0, b = 0

// 第二行,此时 iota = 1
c, d // 隐式重复上一行格式,等价于 c, d = iota, iota
// 所以 c = 1, d = 1
)
fmt.Println(a, b, c, d) // 输出: 0 0 1 1

基础数据类型

1. 布尔类型 (Boolean)

布尔类型用于表示逻辑上的真和假,其值只允许是 truefalse

⚠️ 核心规则:极其严谨的布尔值
与 C、C++ 或 Python 等语言不同,在 Go 语言中,**数字 10 绝对不能当做布尔值使用,也不会自动转换为 truefalse**。布尔类型和数字类型是完全隔离的。

1
2
3
4
5
6
7
8
// 1. 显式声明与隐式类型推断
var a bool = true
var b = false // 编译器自动推断为 bool 类型

// 2. 关系表达式的结果自动就是布尔值
c := 4 > 2 // 4大于2成立,因此 c 的值为 true
fmt.Println(a, b, c)

2. 整数类型 (Integer)

Go 语言提供了极其丰富的整数类型,主要分为两大类:平台相关固定大小

  • 平台相关类型 (int / uint): 这也是我们最常用的类型。它的大小会随着您的操作系统架构而变化。在 32 位系统上它是 32 位,在 64 位系统上它是 64 位。
  • 固定大小类型 (int8 / int16 / int32 / int64): 无论代码运行在什么机器上,它们在内存中占用的位数都是固定不变的。这在网络传输或处理二进制文件时非常重要。
  • 无符号整数 (uint):u 前缀的代表 Unsigned(无符号),只能存储 0 和正数,绝对不能存负数。
1
2
3
4
5
var a int = 10      // 大小随操作系统变化 (常用)
var b int32 = 120 // 固定占用 32 位
var c int64 = 120 // 固定占用 64 位
fmt.Println(a, b, c)

3. 浮点数类型 (Float)

Go 语言中没有 double 关键字,小数统称为浮点数,分为单精度和双精度两种。

  • float32 单精度浮点数。
  • float64 双精度浮点数,精度更高。

💡 默认类型提示:
如果您在声明时省略了类型(例如 f := 3.14var f = 3.14),Go 编译器会**默认将其推断为 float64**。在日常开发中,为了避免精度丢失问题,官方也推荐优先使用 float64

1
2
3
4
var a float64 = 3.14 // 双精度 (Go的默认浮点类型)
var b float32 = 3.14 // 单精度
fmt.Println(a, b)

字符与字符串

1. 字符类型 (Characters)

在 Go 语言中,字符不是独立的特殊类型,而是由特定的整数类型来表示的。字符必须使用**单引号 ''** 包裹。

  • byte (等同于 uint8):

  • 主要用于存储 ASCII 字符(英文字母、数字、标点等)。

  • 限制: 无法存储中文字符,因为一个中文字符通常占用 3 个字节,而 byte 只有 1 个字节。

  • rune (等同于 int32):

  • 用于存储 Unicode 字符

  • 优势: 可以完美容纳中文、日文、韩文等各种复杂字符。

  • 格式化输出:

  • 直接使用 fmt.Println() 打印字符变量,输出的是它的数字编码

  • 如果想看到真正的字符长什么样,必须使用 fmt.Printf("%c", 变量名)

1
2
3
4
5
6
7
8
// byte 示例
var b byte = 'a'
fmt.Printf("%c\n", b) // 输出: a

// rune 示例 (支持中文)
var r rune = '中'
fmt.Printf("%c\n", r) // 输出: 中

2. 字符串类型 (Strings)

字符串是由一系列字符连接而成的序列。

  • 普通字符串: 使用**双引号 ""**包裹,支持转义字符(如\n, \t)。
  • 原生字符串: “里面 \ 失去作用,适合 sql, html 等”,在 Go 语言中使用**反引号 ``**` (键盘左上角 Esc 键下方)来表示的,原生字符串会原封不动地保留格式和特殊符号。

⚠️ 核心特性:字符串不可变 (Immutable)

Go 语言中的字符串一旦定义,就绝对不能直接修改其中的某个字符。

1
2
3
s := "hello"
// s[0] = 'H' // ❌ 编译报错:不能直接修改字符串内部的字符

💡 如何强制修改字符串?

如果必须修改,需要进行类型转换:先将字符串转换成 []rune (rune 切片),修改切片里的内容,然后再转回 string

1
2
3
4
5
6
var s = "张三"
b := []rune(s) // 1. 转换为 rune 切片
b[0] = '李' // 2. 修改指定位置的字符
s = string(b) // 3. 将切片转回字符串
fmt.Println(s) // 输出: 李三

3. 字符串拼接的三种常用方式

根据不同的场景,Go 语言提供了多种拼接字符串的方法:

  • 方式一:使用 + 加号(最简单直观)
    适合少量、简单的字符串拼接。
1
2
s3 := "hello" + " " + "world"

  • 方式二:使用 fmt.Sprintf(格式化拼接)
    适合需要将变量和字符串混合排版的场景。
1
2
3
s1, s2 := "hello", "world"
s4 := fmt.Sprintf("%s %s", s1, s2)

  • 方式三:使用 strings.Builder(性能最高 🚀)
    极力推荐: 在需要频繁、大量拼接字符串时(例如在循环内部),使用 strings.Builder 是性能最好、内存消耗最低的方式。
1
2
3
4
5
6
7
8
var builder strings.Builder
builder.WriteString("hello")
builder.WriteString(" ")
builder.WriteString("world")

s := builder.String() // 最后统一生成字符串
fmt.Println(s)

运算符与控制流

1. 运算符的核心特异点 ⚠️

Go 语言的运算符与 C/Java 等语言大致相通,但有几个极其严格的限制:

  • 极其严格的类型限制: 不同的数据类型之间绝对不能直接进行计算,必须先进行强制类型转换。
  • 没有三元运算符: Go 语言不支持 条件 ? 真值 : 假值 这种写法。为了代码的可读性,官方强制要求您老老实实写完整的 if...else
  • 独立的自增/自减语句: * Go 里面**只有 a++a--**,没有前置的 ++a
  • a++a-- 在 Go 中是语句(Statement),而不是表达式(Expression)。这意味着它们不能参与任何复杂的数学运算或赋值操作。
  • ❌ 错误写法:b := a++ + a++ (编译直接报错)。
  • ✅ 正确写法:只能单独写一行 a++

2. if 条件判断

Go 语言的 if 语句更加简洁:

  • 省略小括号: 判断条件不需要被 () 包裹。
  • 支持初始化语句(非常常用): 可以在条件判断之前,先执行一条简短的声明语句(以分号 ; 隔开)。这种方式声明的变量,其作用域仅限于这个 if 块内部,可以有效避免变量污染。
1
2
3
4
5
6
// 先声明变量 a 并赋值为 1,然后判断 a > 0
if a := 1; a > 0 {
fmt.Println("a 大于 0")
}
// 离开 if 块后,变量 a 就不存在了

3. switch 分支结构

Go 语言对 switch 进行了非常强大的升级,修复了其他语言中容易出错的设计:

  • 默认自动 break(无需手写): 只要匹配到一个 case 并执行完毕,就会自动跳出 switch,不会像 C/Java 那样发生意外的“穿透”。
  • case 支持多个值: 多个条件可以合并在同一行,用逗号隔开。
1
2
3
4
5
6
7
8
9
switch day {
case 1:
fmt.Println("星期一")
case 2, 3, 4: // 匹配 2, 3, 4 中的任意一个
fmt.Println("工作日")
default:
fmt.Println("其他")
}

  • 无条件 switch(替代多重 if...else if): switch 后面可以什么都不写,把条件判断直接写在 case 后面。代码看起来会比一连串的 if else 清爽得多。
  • 显式穿透 (fallthrough): 如果你确实需要执行完当前 case 后,继续强行执行紧挨着的下一个 case,需要明确使用 fallthrough 关键字。
1
2
3
4
5
6
7
8
9
score := 90
switch { // 这里不接任何变量
case score >= 80:
fmt.Println("优秀")
fallthrough // 强制穿透到下一个 case
case score >= 60:
fmt.Println("及格") // 因为 fallthrough,这行也会被打印
}

  • Type Switch(类型断言): 这是 Go 语言特有的高级用法。当使用空接口 interface{}(可以接收任何类型的数据)时,可以利用 switch type 来判断里面到底装的是什么类型。
1
2
3
4
5
6
7
8
9
var x interface{} = 3.14 // 空接口可以存任何值

switch v := x.(type) {
case int:
fmt.Println("这是一个 int 类型")
case float64:
fmt.Println("这是一个 float64 类型") // 会匹配到这一分支
}

结构体 (Struct)

1. 定义结构体

使用 typestruct 关键字来定义一个结构体。

1
2
3
4
5
6
type Student struct {
Name string
Age int
Score int
}

⚠️ 核心规则:极其重要的可见性(访问权限)

  • 首字母大写(如 Name, Student): 表示公开(Exported),可以被其他包(package)访问和调用。
  • 首字母小写(如 name, age): 表示私有(Unexported),只能在定义它的包内部使用,外部包无法访问。

2. 结构体的三种初始化方式

Go 语言提供了多种声明和初始化结构体实例的方法,以适应不同的场景:

方式一:先声明,后逐个赋值

最基础的用法。先声明一个结构体变量(此时内部字段会被自动赋予零值,如字符串为空 "",数字为 0),然后再给需要的字段赋值。

1
2
3
4
5
6
var stu Student
stu.Name = "fliex"
stu.Age = 25
stu.Score = 100
fmt.Println(stu) // 输出: {fliex 25 100}

方式二:键值对初始化 (⭐ 极力推荐)

在创建实例时,明确指定字段名和对应的值。

  • 优点: 代码可读性极高,且不需要严格按照结构体定义的顺序来写,即使只初始化部分字段也不会报错(未初始化的字段默认为零值)。
1
2
3
4
5
6
7
var stu2 = Student{
Name: "fliex",
Age: 20,
Score: 100, // 注意:哪怕是最后一行,如果和右大括号 } 不在同一行,也必须加上逗号
}
fmt.Println(stu2)

方式三:按顺序赋值 (省略字段名)

直接传入对应的值来初始化。

  • 缺点: 必须严格按照定义结构体时的字段顺序进行赋值,且必须为所有字段赋值,少一个或者顺序错一个都会导致编译报错。不推荐在拥有大量字段的结构体中使用。
1
2
3
4
5
var stu3 = Student{
"fliex", 18, 110, // 严格对应 Name, Age, Score
}
fmt.Println(stu3.Name) // 通过 点号(.) 访问单个字段

3. 访问结构体字段

无论使用哪种方式初始化,都可以使用 点号 (.) 操作符来访问或修改结构体内部的具体字段。

1
2
3
fmt.Println(stu3.Name) // 访问
stu3.Score = 99 // 修改

数组与切片

1. 数组 (Array)

数组是固定长度的,定义后长度不可改变。注意:[3]int[4]int 是完全不同的类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 1. 标准声明并赋值
var nums1 = [3]int{1, 2, 3}
fmt.Println(nums1)
nums1[0], nums1[1], nums1[2] = 9, 8, 7

// 2. 声明定长数组
var nums2 = [3]int{1, 2, 3}
fmt.Println(nums2)

// 3. 简短声明
nums3 := [3]int{1, 2, 3}
fmt.Println(nums3)

// 4. 指定索引赋值 (很少见)
nums4 := [3]int{1: 1, 2: 2, 0: 0}
fmt.Println(nums4)


2. 切片基础与 make 函数

切片就是定义时不确定长度的数组。

  • 长度(len:决定了“可见范围”。当长度小于容量时,只会显示长度以内的元素。由 make 创建时,剩余未显示的部分默认值是 0。
  • 容量(cap:决定了底层数组的“物理极限”。超出长度的部分是不可见的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 1. 直接定义切片
nums5 := []int{1, 2, 3, 4, 5}
fmt.Println(nums5)

// 2. make([]int, x, y) x长度 y容量
nums6 := make([]int, 3, 5)
nums6 = append(nums6, 1)
nums6 = append(nums6, 2)

// 3. 只指定长度的 make
nums7 := make([]int, 3)
fmt.Println(nums7, len(nums7), cap(nums7))

// 4. 动态追加
nums8 := []int{1}
nums8 = append(nums8, 2)
nums8 = append(nums8, 3)
fmt.Println(nums8, len(nums8), cap(nums8))


3. 切片扩容规则

  • 小容量切片:当原切片容量小于 256 时,新容量直接按 2倍 扩容。
  • 大容量切片:当原切片容量大于等于 256 时,平滑过渡(不再无脑翻倍,避免极大的内存浪费)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 扩容测试 1
nums9 := []int{1, 2, 3}
nums9 = append(nums9, 4)
fmt.Println(nums9, len(nums9), cap(nums9))
nums9 = append(nums9, 5)
fmt.Println(nums9, len(nums9), cap(nums9))
nums9 = append(nums9, 6)
fmt.Println(nums9, len(nums9), cap(nums9))
nums9 = append(nums9, 6)
fmt.Println(nums9, len(nums9), cap(nums9)) // 输出长度 7,容量 12

// 扩容测试 2 (带初始容量)
nums10 := make([]int, 3, 5)
nums10[0] = 1
nums10[1] = 2
nums10[2] = 3
fmt.Println(nums10, len(nums10), cap(nums10))
nums10 = append(nums10, 4)
fmt.Println(nums10, len(nums10), cap(nums10))
nums10 = append(nums10, 5)
fmt.Println(nums10, len(nums10), cap(nums10))
nums10 = append(nums10, 6)
fmt.Println(nums10, len(nums10), cap(nums10))


4. 切片截取与“扩容解绑”机制 (极易踩坑)

切片是引用类型,直接修改它的元素会改变原来的值(共享数据内存)。

  • 截取公式:使用 arr[low:high] 截取切片时(前取后不取):

  • 长度 (len) = high - low

  • 容量 (cap) = 底层数组的原始总容量 - low(起始截取位置)

  • 解绑机制:当 append 触发扩容时,Go 会在底层开辟全新内存。此时,切片与原数组彻底解绑,此后的任何修改互不影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
arr := [3]int{1, 2, 3}
nums11 := arr[0:2] // 截取,长度为 2-0=2,容量为 3-0=3

// 第一次追加:未超出容量,直接修改底层数组 arr
nums11 = append(nums11, 666)
fmt.Println(arr) // arr 会被改变
fmt.Println(nums11, len(nums11), cap(nums11))

// 第二次追加:超出容量 3,触发扩容,重新开辟一块空间!
// 此时 nums11 与 arr 彻底解绑
nums11 = append(nums11, 777)
fmt.Println(arr) // arr 不再受影响
fmt.Println(nums11, len(nums11), cap(nums11))

Map

Map 是一种无序的键值对(Key-Value)数据结构。在 Go 语言中,Map 是引用类型

1. 声明与初始化 ⚠️

Map 声明后默认是 nil必须分配内存(初始化)后才能赋值,否则会引发 panic 报错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ❌ 错误示范:只声明不初始化,直接赋值会报错
// var m map[string]int
// m["a"] = 1

// ✅ 正确方式一:使用 make 初始化 (推荐)
m1 := make(map[string]int)
m1["a"] = 1
m1["b"] = 2

// ✅ 正确方式二:字面量初始化
m2 := map[string]int{
"张三": 18,
"李四": 19,
}

2. 核心操作与 “comma ok” 惯用法

  • 零值特性: 当访问一个不存在的 Key 时,Go 不会报错,而是会返回该值类型的默认零值(例如 int 返回 0string 返回 "")。
  • 探空判断: 为了区分“真值为 0”和“Key 不存在”,必须使用 value, ok := m["key"] 的双变量接收法。
  • 删除操作: 使用内置的 delete 函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
m := map[string]int{"张三": 18}

// 1. 判断 Key 是否存在 (comma ok)
value, ok := m["王五"]
if !ok {
// 如果 ok 为 false,说明 Key 不存在,此时 value 为默认零值 0
fmt.Println("key 不存在, value值为:", value)
}

// 2. 删除 Key
delete(m, "张三")
fmt.Println(m["张三"]) // 张三已被删除,输出默认零值: 0

3. 引用类型特性 (共享内存)

Map 作为引用类型,当把一个 Map 赋值给另一个变量时,它们共享同一块底层内存

1
2
3
4
5
6
m := map[string]int{"张三": 18}
m2 := m // m2 和 m 指向同一个底层哈希表
m2["张三"] = 666 // 通过 m2 修改数据

fmt.Println(m) // 输出也会变成: map[张三:666]

4. 🚫 核心铁律:比较与 Key 的限制

Map 自身的比较限制

两个 Map 之间是绝对不可以相互比较的(不能使用 ==!=)。Map 只能和 nil 进行比较,用来判断是否已经初始化。

1
2
3
4
5
m1 := make(map[int]string)
m2 := make(map[int]string)

// if m1 == m2 { } // ❌ 编译直接报错:Map 之间不能使用 == 比较

Map Key 的严格限制

能够支持使用 ==!= 运算符进行判断的数据类型,才可以作为 Map 的 Key。

  • ✅ 允许作为 Key 的类型: int, float, string, bool, 甚至定长的数组。
  • ❌ 绝对不能作为 Key 的类型 (Go 语言三大不可比较引用类型):
  1. 切片 (slice): 最容易踩坑,切片长度动态变化,底层无法比较。
  2. 字典 (map): Map 本身不能作为另一个 Map 的 Key。
  3. 函数 (func): 函数体无法进行等值比较。

循环控制 (for / for range)

Go 语言在循环结构上做了极简设计:它删除了 whiledo-while整个语言中只有 for 这一种循环关键字

1. for 循环的三种基本形态

通过灵活组合条件,for 可以实现其他语言中所有循环的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 1. 标准经典循环 (初始化; 条件判断; 后置操作)
for i := 0; i < 5; i++ {
fmt.Println(i)
}

// 2. 类似 while 的循环 (只保留条件判断)
i := 0
for i < 5 {
fmt.Println(i)
i++
}

// 3. 无限循环 (配合 break 退出)
i := 1
for {
fmt.Println(i)
i++
if i == 6 {
break // 满足条件时强制跳出循环
}
}

2. for range 遍历与“值拷贝”陷阱 ⚠️

for range 是遍历数组、切片、Map 和字符串的最强工具,但一定要小心它的底层机制。

🛑 核心铁律:
for range 循环中,所有的元素都是值传递(值拷贝)
您在循环里拿到的 value,仅仅是底层元素的一个副本。直接修改 value 绝对不会改变原数据!

错误修改 vs 正确修改

1
2
3
4
5
6
7
8
9
10
11
12
s := []int{1, 2, 3, 4, 5}

// ❌ 错误做法:试图通过修改副本改变原切片
for _, value := range s {
value *= 10 // 这里的 value 只是个临时替身,原切片依然是 1 2 3 4 5
}

// ✅ 正确做法:通过索引 (index) 直接操作原内存地址
for i := range s {
s[i] *= 10 // 原切片成功被修改为 10 20 30 40 50
}

(💡 提示:如果不需要索引或值,可以使用空白标识符 _将其忽略,例如for _, value := range s)

3. 字符串 (String) 遍历的巨大差异

在 Go 中遍历字符串有两种方式,它们在处理包含中文(或特殊字符)的字符串时,表现完全不同:

  • 常规 for 循环(按 byte 字节遍历):
    一个中文字符(UTF-8)通常占 3 个字节。如果用 i < len(str) 去遍历,会把汉字拆碎成一个个不可读的字节,打印出来是乱码。
  • for range 循环(按 rune 字符遍历):
    它会自动识别 Unicode 编码,把一个完整的汉字当做一个独立的字符(rune)来处理,非常智能!
1
2
3
4
5
6
7
8
9
10
11
12
13
str := "hello 世界"

// 推荐:按字符 (rune) 遍历,完美处理中文
for i, v := range str {
fmt.Printf("索引:%d 字符:%c\n", i, v)
// 注意:这里的 i 是字节索引,遇到中文时 i 会跳跃 (例如从 6 直接跳到 9)
}

// 不推荐处理中文:按字节 (byte) 遍历
for i := 0; i < len(str); i++ {
fmt.Printf("%d:%c\n", i, str[i]) // 遇到 "世界" 会打印出乱码碎片
}

4. 循环控制语句

  • break 彻底打断并跳出当前这一层循环。
  • continue 跳过当前这一次循环中剩下的代码,直接进入下一次循环。
1
2
3
4
5
6
7
for i := 0; i < 10; i++ {
if i%2 == 0 {
continue // 遇到偶数直接跳过,所以下面只会打印奇数
}
fmt.Println(i)
}

指针 (Pointer)

Go 语言保留了指针来提高程序的内存操作效率,但为了安全,它对指针进行了极其严格的阉割和限制,去掉了 C/C++ 中容易出错的复杂操作。

1. 指针的两大基础符号

在 Go 语言中,操作指针只需要记住两个基础符号:

  • & (取址符): 放在变量前,用来获取该变量在内存中的真实地址。
  • * (解引用符): 放在指针变量前,用来根据内存地址取回里面存的具体数值。
1
2
3
4
5
6
a := 10
p := &a // p 现在拿到了 a 的内存地址 (比如 0xc00001a070)

fmt.Println(p) // 打印的是一串内存地址
fmt.Println(*p) // 打印的是 10 (顺着地址把值取出来了)

2. ⚠️ 核心考点:Go 指针的三大严苛限制

限制一:极其严格的类型匹配

指针也分类型!指向 int 的指针绝对不能接收 float64 的地址。

1
2
3
4
// var p *int
// var f float64 = 3.14
// p = &f // ❌ 编译直接报错:类型不匹配 (*int 不能存 *float64)

限制二:禁止指针运算 (Pointer Arithmetic)

这是 Go 相比于 C 语言最核心的改动。为了内存安全,Go 语言绝对不允许直接对指针进行加减偏移操作。

1
2
3
4
// a := 10
// p := &a
// p++ // ❌ 编译直接报错:Go 里的普通指针不能像 C 语言那样自增或偏移

限制三:警惕空指针恐慌 (Nil Pointer Dereference)

声明了一个指针但没有给它分配实际的内存地址时,它的默认值是 nil。如果强行对一个 nil 指针使用 * 取值,会导致程序直接崩溃(Panic)。

1
2
3
// var p *int      // 此时 p 是 nil,里面什么地址都没有
// fmt.Println(*p) // ❌ 运行时崩溃 (Panic):无效的内存地址或空指针解引用

3. 为什么切片和 Map 不需要用指针?

这是日常开发中最常见的一个最佳实践。

  • 普通变量是值传递:a := 10; b := a,修改 b 绝对不会影响 a,因为发生了完整拷贝。如果想通过函数修改外部的普通变量,就必须传指针。
  • 引用类型自带“指针基因”: 切片 (slice)、字典 (map) 和通道 (channel) 在底层的结构体中,已经包含了指向实际数据的指针。当您传递或赋值它们时,底层的数据本身就是共享的。
1
2
3
4
5
6
// 切片本身就是引用类型,没必要再写成 *[]int
s := []int{1, 2, 3}
s2 := s // s2 和 s 共享底层数组
s2[0] = 666 // 修改 s2
fmt.Println(s) // s 也会变成 [666 2 3]

函数与参数传递

1. 函数定义与返回值形式

Go 语言的函数非常灵活,支持多返回值和返回值命名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 1. 无返回值
func test() {
fmt.Println("hello world")
}

// 2. 单返回值
func add(a, b int) int {
return a + b
}

// 3. 多返回值:必须用括号包裹类型 (常用于算法或错误处理)
func div(a, b int) (int, int) {
return a / b, a % b
}

// 4. 命名返回值:直接对返回值变量赋值,return 时可省略变量名
func sum(a, b int) (res int) {
res = a + b
return
}

2. 匿名函数 (Function Literals)

函数在 Go 中可以作为变量赋值并直接调用。

1
2
3
4
5
x := func() {
fmt.Println("hello world")
}
x()

3. 核心机制:值传递 (Value Pass) ⚠️

🛑 结论:Go 语言里所有参数的传递【全部都是】值传递。

指针传递 (*int)

虽然是值传递,但拷贝的是地址。函数拿到地址副本后,依然能通过 * 找到原内存并改变其值。

1
2
3
4
func change1(a *int) {
*a = 100 // 修改了原变量的值
}

切片的“引用”本质 (Slice)

疑问: 为什么切片没传指针,原数据 s[0] 还是被修改了?
真相: 切片底层是一个包含三个字段的结构体:指向底层数组的指针、长度 (len) 和容量 (cap)。

  • 当切片作为参数传递时,Go 拷贝了这个结构体副本。
  • 关键点: 结构体里的指针也被原封不动地拷贝了过去。
  • 函数内部通过这个指针副本,操作的是同一个底层数组。
1
2
3
4
5
// 这里也是值传递:s[0] 被修改是因为拷贝了指向底层数组的指针
func change(s []int) {
s[0] = 100
}

4. 运行验证 (您的源码)

1
2
3
4
5
6
7
8
9
10
11
12
func main() {
// 验证指针修改
b := 10
change1(&b)
fmt.Println(b) // 输出: 100

// 验证切片修改 (引用效果)
arr := []int{1, 2, 3}
change(arr)
fmt.Println(arr) // 输出: [100 2 3]
}

语言面向对象:结构体与方法

Go 语言没有 class 关键字,它通过 结构体 (struct) 组织属性,通过 方法 (method) 组织行为。

1. 结构体定义 (属性)

结构体是不同类型数据的集合,类似于其他语言中的类属性。

1
2
3
4
5
type Student struct {
Name string
Age int
}

2. 方法 (行为)

方法就是在函数关键字 func 和函数名之间加上 接收者 (Receiver)

接收者的两种类型

  • 值接收者 (s Student)**:拷贝一份结构体副本。在方法内修改属性,原对象不会**改变。
  • 指针接收者 (s \*Student)**:传递结构体的地址副本。在方法内修改属性,原对象会**同步改变。
1
2
3
4
5
6
7
8
9
// 指针接收者:可以修改原结构体的值
func (s *Student) SetAge(age int) {
s.Age = age
}

func (s *Student) SayHello() {
fmt.Printf("Hello My name is %s, My age is %d\n", s.Name, s.Age)
}

3. 语法糖:自动解引用

在 Go 中,即使方法定义的是指针接收者,您也可以直接用 实例.方法() 调用,Go 会自动帮您处理取地址操作。

1
2
3
4
stu := Student{Name: "zhangsan", Age: 20}
stu.SetAge(30) // ✅ 正常调用
// (&stu).SetAge(30) // 等价于这行,但 Go 提供了语法糖,写上面那种更简洁

4. 为内置类型添加方法

铁律: 不能直接给 intstring 等内置类型定义方法。
解决方案: 使用 type 关键字定义 类型别名

1
2
3
4
5
6
7
8
9
10
11
type MyInt int // 起别名

// 现在可以给 MyInt 绑定方法了
func (m MyInt) Double() MyInt {
return m * 2
}

// 使用:
var m MyInt = 20
m = m.Double() // m 变为 40

5. 核心总结:Go 的 OOP 哲学

特性 实现方式
类 (Class) 结构体 (struct)
封装 (Encapsulation) 首字母大写(公开)/ 小写(私有)
行为 (Behavior) 方法 (method)
继承 (Inheritance) 结构体嵌套 (Composition)

面向对象:组合、嵌套与冲突处理

Go 语言抛弃了传统的 extends 继承机制,转而采用 “组合 (Composition)”。通过在结构体中嵌入另一个结构体(匿名字段),实现属性和方法的“继承”效果。

1. 组合与匿名字段 (Embedding)

当结构体中只写类型而不写字段名时,它被称为 匿名字段

1
2
3
4
5
6
7
8
9
type Animal struct {
Name string
}

type Dog struct {
Name string
Animal // 匿名字段:Dog 组合了 Animal 的所有能力
}

2. 核心特性:自动提升与就近原则

🌟 属性/方法提升 (Promotion)

如果外部结构体(如 Dog)没有同名成员,可以直接通过 dog.Name 访问内部结构体的成员。Go 会自动将其重定向到 dog.Animal.Name

🌟 遮蔽效应 (Shadowing / 重写)

如果内外存在同名成员,Go 遵循 “就近原则”

  • 优先访问外部定义的成员。
  • 若要访问内部被遮蔽的成员,必须显式指定路径。
1
2
3
4
5
6
7
8
dog := Dog{
Name: "旺财", // Dog 自己的 Name
Animal: Animal{Name: "动物"}, // Animal 的 Name
}

fmt.Println(dog.Name) // 输出: 旺财 (外部优先)
fmt.Println(dog.Animal.Name) // 输出: 动物 (显式访问内部)

3. 命名冲突与歧义 (Ambiguity) ⚠️

当一个结构体同时嵌套了两个拥有同名成员的结构体时,Go 无法判断“自动提升”该指向谁。此时,必须显式指定,否则编译报错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type A struct { X int }
type B struct { X int }

type C struct {
A
B
}

func main() {
c := C{}
// c.X = 1 // ❌ 错误:ambiguous selector 'X' (歧义)
c.A.X = 1 // ✅ 正确:显式指明访问 A 的 X
c.B.X = 2 // ✅ 正确:显式指明访问 B 的 X
}

4. 知识点对比总结

概念 Go 的实现方式 其他语言 (Java/C++)
继承 结构体组合 (Composition) extends / public A
子类调用父类 显式指定内嵌结构体名 super / base
方法重写 外部定义同名方法 (遮蔽) @Override
多重继承冲突 强制显式路径指定 虚继承 / 接口实现

接口 (Interface) 与多态

在 Go 语言中,接口是实现多态的基石。它采用“非侵入性”设计:不需要显式声明 implements,只要类型拥有了接口要求的全部方法,就自动实现了该接口。

1. 接口的定义与基本实现

核心规则: 一个类型必须实现接口中定义的全部方法,才算实现了该接口。

1
2
3
4
5
6
7
8
9
10
11
type Animal interface {
Speak() // 方法名(参数列表) 返回值类型
}

type Dog struct { Name string }

// 实现 Animal 接口
func (d Dog) Speak() {
fmt.Printf("Dog %s is speaking...\n", d.Name)
}

2. 多态的本质:看菜吃饭

定义: 只要实现了接口的类型,就可以把该类型的值赋值给接口类型的变量。
行为: 给接口变量传的是哪个结构体,接口就自动调用那个结构体对应实现的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Cat struct { Name string }
func (c Cat) Speak() { fmt.Printf("Cat %s is speaking...\n", c.Name) }

func main() {
var animal Animal // 定义接口变量

// 多态体现:同一个变量,在运行时表现出不同的行为
animal = Dog{Name: "dog1"}
animal.Speak() // 自动调用 Dog 的方法

animal = Cat{Name: "cat1"}
animal.Speak() // 自动调用 Cat 的方法
}

3. 核心考点:接收者类型对接口实现的影响 ⚠️

方法的接收者类型直接决定了哪些值可以赋值给接口:

方法接收者类型 实现接口的类型 赋值给接口的方式
值类型接收者 (d Dog) Dog*Dog animal = danimal = &d
指针类型接收者 (d *Dog) **仅 \*Dog** 必须使用 animal = &d

代码验证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type Dog struct {}

// 情况:指针类型作为接收者
func (d *Dog) Speak() {
fmt.Println("Dog is speaking")
}

func main() {
d := Dog{}
var animal Animal

// animal = d // ❌ 报错:Dog 没有实现 Animal (Speak 方法是指针接收者)
animal = &d // ✅ 成功:只有指针类型 *Dog 实现了这个接口
animal.Speak()
}

4. 一个类型实现多个接口

Go 鼓励小接口组合。一个结构体可以同时满足多个接口,从而具备多种“身份”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type Reader interface { Read() }
type Writer interface { Write() }

type ReadWriter struct {}

// 同时实现 Read 和 Write 方法,即同时实现了两个接口
func (rw ReadWriter) Read() { fmt.Println("Reading...") }
func (rw ReadWriter) Write() { fmt.Println("Writing...") }

func main() {
readwriter := ReadWriter{}

var reader Reader = readwriter // 作为 Reader 使用
var writer Writer = readwriter // 作为 Writer 使用

reader.Read()
writer.Write()
}


5. 核心总结复盘

  • 隐式实现:解耦了接口定义与具体实现。

  • 赋值铁律

  • 如果是值类型作为接收者,那么值类型和指针类型都实现了这个接口。

  • 如果是指针类型作为接收者,那么只有指针类型实现了这个接口。

  • 多态应用:接口变量存储的是“动态类型”和“动态值”,调用时动态绑定。

空接口与类型断言

1. 空接口 (Empty Interface)

在 Go 语言中,interface{} 被称为空接口。由于它没有定义任何方法,因此所有的数据类型都默认实现了空接口

  • 用途: 当你不确定函数会接收什么类型的参数时(类似于 Java 的 Object 或 C 语言的 void*),可以使用空接口。
  • 特性: 它可以存储任何值,包括 intstringbool 甚至是自定义的结构体。
1
2
3
4
5
var x interface{}
x = 100 // OK
x = "Mike" // OK
x = true // OK

2. 类型断言 (Type Assertion)

当你把一个值赋给空接口后,它就丢失了原来的具体类型信息。如果你想把它转回原来的类型,就需要使用类型断言

语法格式:

value, ok := x.(T)

  • x:接口变量。
  • T:你预期的目标类型。
  • value:如果断言成功,返回转换后的值。
  • ok:布尔值,代表断言是否成功(强烈建议使用这种方式,防止程序崩溃)。

3. 代码实例分析

安全类型检查函数

您写的 CheckInt 函数是处理接口数据的标准写法。

1
2
3
4
5
6
7
8
9
10
func CheckInt(x interface{}) bool {
// 尝试将接口 x 断言为 int 类型
_, ok := x.(int)
if ok {
return true // 如果是 int,ok 为 true
} else {
return false // 如果不是 int,ok 为 false
}
}

运行结果

1
2
3
4
5
func main() {
result := CheckInt(true) // 传入的是 bool 类型
fmt.Println(result) // 输出: false
}

4. 关键避坑指南 ⚠️

  1. 直接断言的风险: 如果直接使用 num := x.(int) 而不接收 ok,一旦 x 内部不是 int,程序会直接 panic (崩溃)
  2. 类型分支 (Type Switch): 如果你需要判断多种可能的类型,使用 switch x.(type) 会比一堆 if-else 更优雅。

💡 总结复盘

  • 空接口:是万能容器。
  • 类型断言:是从容器里把东西“辨认”出来的过程。
  • 安全第一:始终使用 v, ok := x.(T) 这种形式来保证代码的健壮性。