Golang_02: Go语言 数据类型:基础类型 与 复合类型

原文链接:https://xiets.blog.csdn.net/article/details/130856077

版权声明:原创文章禁止转载

专栏目录:Golang 专栏(总目录)

Go 的数据类型可分为 基础类型、复合类型、引用类型、接口类型 等。

其中 基础类型、复合类型 是常用的 数据结构 类型,可细分为:

  • 基础类型 (不可变类型)
    • 数字 (number)
      • 整数 (int)
      • 浮点数 (float)
      • 复数 (complex)
    • 布尔 (bool)
    • 字符串 (string)
  • 复合类型 (可变类型)
    • 数组 (array)
    • 切片 (slice)
    • 集合 (map)
    • 结构体 (struct)

1. 变量(var) 与 常量(const)

1.1 变量(var)

Go 使用 var 关键字声明 变量,格式:

var name type = expression

其中 类型表达式赋值 可以省略一个,但不能都省略(编译器需要能够推导出变量的类型)。如果省略初始化赋值表达式,则变量的初始值为对应类型的 零值(默认值),Go 不存在未初始的变量。

各类型的 零值:

  • 数字类型 的零值是数值 0
  • 布尔类型 的零值是 false
  • 字符串 的零值是空字符串 ""
  • 结构体 的零值是其内部各成员的零值组成的结构体 (结构体变量没有 nil 状态)
  • 接口引用类型(指针、切片/slice、集合/map、通道/chan、函数/func)的零值是空指针 nil

示例:

var s1 string
fmt.Println(s1)     // ""

var s2 = "hello"
fmt.Println(s2)     // "hello"

var i int
fmt.Println(i)      // 0

可以用 var(...) 声明多少变量:

var (
    a = 0
    b bool
    s string
)
fmt.Println(a, b, s)

可同时声明多个不同类型的变量:

var i, j, k int                     // int, int, int
var b, f, s = true, 3.14, "world"   // bool, float64, string

1.2 短变量声明

在函数中,可以使用 短变量声明 来声明和初始化局部变量,不需要 var 关键字,格式为 name := expression,类型由右边的 expression 表达式决定。

i := 10                             // int
r := rand.Float64() * 3.0           // float64
b, f, s := true, 3.14, "world"      // bool, float64, string

1.3 变量赋值

赋值语句用来更新变量所指的值,通常使用 =、赋值操作符(如+=)、自增(++)、自减(--) 等给变量赋值:

a = 10          // 直接复制
a += 5          // 赋值操作符赋值
a = a + 5       // 等价于 a += 5
*p = 100        // 通过指针间接复制
a++             // 相当于 a += 1
a--             // 相当于 a -= 1

可以一个等号同时赋值多个变量,称为 多重复值

// 多重赋值
b, f, s = true, 3.14, "world"

// 一句代码交换两个变量的值
a[i], a[j] = a[j], a[i]

赋值语句在实际更新变量的值前,会先把等号右边的所有表达式计算出结果,最后再赋值到左边的变量。

1.4 指针

Go 支持 指针类型 的变量,通过取地址符 & 可以对变量取地址赋值给 指针变量。一个类型的指针类型在类型前加 * 号表示。指针变量可以存储一个变量的内存地址,其大小通常与 int 类型相同(32位或64位)。通过指针可以直接访问变量的内存地址,读取或写入变量的值。

声明 整型变量 var x int 和 指针变量 var p *int,再把变量的地址值赋值给指针变量 p = &x,表示说指针 p 指向 x,或者 p 包含了 x 的地址。变量 p 的类型称为整型指针(*int)。表达式 *p 可以获取或更新所指向的变量 x 的值。

指针类型的零值是空指针 nil,可通过 p != nil 来判断指针是否为空指针。两个指针当且仅当指向同一个变量或两者均为 nil 的情况下才相等。不同类型的指针变量不能比较。

x := 10             // 声明 int 类型的变量 x
p := &x             // 声明 *int 指针类型变量 p, 把 x 的地址赋值为 p, 即 p 指向 x
fmt.Println(*p)     // 获取指针指向的变量的值, 结果为 "10"

*p = 20             // 更新指针指向的变量的值
fmt.Println(x)      // 变量 x 存储的值已被更新, 结果为 "20"


s := "Hello"        // 字符串指针操作
ps := &s
fmt.Println(*ps)    // "Hello"
*ps = "World"
fmt.Println(s)      // "World"

1.5 变量的生命周期(作用域)

变量的生命周期指在程序运行过程中,变量从创建到可被回收的时间段(代码段)。包级别的变量为全局变量,其生命周期为整个程序运行过程(即从包初始化到进程退出)。而局部变量(函数内创建的变量)的生命周期是动态的,每次代码执行到声明处创建,到它变的不可访问时将被回收。函数的参数和返回值也在函数内,也属于局部变量。

Go 内存管理是自动化的,不需要显式分配和释放内存。无论是指针操作,还是动态分配内存,只要当变量变的不可访问,它占用的内存空间将被垃圾回收器自动回收。

package main

import "fmt"

func main() {
    fmt.Println(TestFunc("test"))
    fmt.Println(*p)
}

var p *int                      // 包级别变量 (全局变量)

func TestFunc(s string) (ret string) {  // 形参s 和 返回值ret 均为函数内声明的局部变量, 函数外不可访问
    x := 10                     // 局部变量
    y := "Hi"                   // 局部变量
    var ps *int = new(int)      // 动态分配内存, 赋值给一个指针变量ps引用

    if a := 1; a > x {          // a 是 if-else 代码区块内的内的局部变量,
        fmt.Println(a)          // 出了 if-else 代码区块便不可访问 (被回收)
    } else if a < x {
        fmt.Println(a)
    } else {
        fmt.Println(a)
    }

    // fmt.Println(a)           // 不可再访问变量 a

    for a := 0; a < 10; a++ {   // 这里的 a 与前面的 a 不同, 而是是重新声明的 for 代码区块内的局部变量,
        fmt.Println(a)          // 出了 for 代码区块也不可再访问 (被回收)
    }

    // fmt.Println(a)           // 不可再访问变量 a

    fmt.Println(s, x, y, *ps)   // 正常访问函数内的局部变量

    p = &x                      // 全局指针变量 指向 函数内局部变量,
                                // 函数返回后 x 的内存空间还可以被访问, 暂时不回收。
                                // 函数内的一个局部变量, 在函数返回后还可从外部访问,
                                // 这种情况称为 局部变量 从函数中 逃逸。
                                // 变量逃逸会阻止垃圾回收器回收短生命周期的内存空间, 不推荐使用。

    ret = y
    return                      // 这里函数返回后, 除了还被外部引用的 x, 
                                // 其他变量均不可再访问, 它们占用的空间 (包括动态申请的) 都将被释放
}

全局变量的内存分配在堆内存中。函数内的局部变量,如果在函数结束后就不可访问,则分配在栈内存中。因为局部变量可以通过指针的方式返回外部调用者,因此在函数结束后,它仍然可访问,这种情况称为局部变量“逃逸”了,因此这个变量将在堆内存中分配内存。代码中不用关心 Go 内部的内存分配逻辑,编译器会智能判断并自动处理,当任何地方都不可访问到变量时,垃圾回收器会自动取去回收。

1.6 常量(const)

常量 使用 const 关键字定义。常量值可以是一个表达式,常量的值必须在编译阶段能准确计算出来,并在运行时不可改变。因此所有的常量都是 数字、布尔型 或 字符串(不可变类型)。

常量定义的语法和变量类似,但必须显式初始化:

const a = 100               // 定义 int 类型的常量 a
const s = "Hello"           // 定义 string 类型的常量
const x = s + " World"      // 常量值可以是编译时能确定结果的表达式

const i int                 // 错误, 没有显式初始化常量值
const y = math.Pow(2, 10)   // 错误, 调用函数无法在编译时确定唯一结果

可以用 const(...) 定义多个常量:

const (
    e  = 2.71828
    pi = 3.14159
)
fmt.Println(e, pi)

1.7 常量生成器 iota

定义常量可以使用常量生成器 iota。使用 const(...) 定义一个或多个常量,每一个常量初始化时,都有一个隐含的字段 iota,其值从 0 开始递增。即初始化第 1 个常量时 iota = 0,初始化第 2 个常量时 iota = 1,即使在某个常量初始化时不使用 iota,也会递增。iota 的生命周期仅在 const(...) 代码区块内。实际上 iota 表示的是 const(...) 常量声明代码块内所在常量行的 行索引

简单示例:

const (
    A = iota    // 初始化第 1 个常量, iota=0, 即 A = 0
    B = iota    // 初始化第 2 个常量, iota=1, 即 A = 1
    C = iota    // 初始化第 3 个常量, iota=2, 即 A = 2
)
fmt.Println(A, B, C)    // 输出: 0 1 2

const (
    X = 5       // 初始化第 1 个常量, iota=0 (即使没有使用 iota, 也有隐含值)
    Y = 8       // 初始化第 2 个常量, iota=1 (即使没有使用 iota, 也会隐含递增)
    Z = iota    // 初始化第 3 个常量, iota=2, 即 Z = 2
)
fmt.Println(X, Y, Z)    // 输出: 5 8 2

使用 const(...) 定义多个常量,第 1 个需要有明确的声明表达式,之后的常量可以只写一个常量名,表示类型和赋值表达式都与上一个常量保持一致:

const (
    A = iota * iota     // 定义常量 A, 计算结果为 A = 0 * 0 = 0, 整数类型默认为 int
    B                   // 定义常量 B, 相当于 B = iota * iota, 计算结果为 B = 1 * 1 = 1
    C                   // 定义常量 C, 相当于 C = iota * iota, 计算结果为 C = 2 * 2 = 4
)
fmt.Println(A, B, C)    // 输出: 0 1 4

使用 iota 定义星期枚举(enum):

type Weekday int
const (
    Sunday Weekday = iota
    Monday
    Tuesday
    Wednesday
    Thursday
    Friday
    Saturday
)

使用 iota 可以轻松定义使用二进制位来区别的标记常量:

type Flags int
const (
    FlagUp = Flags(1 << iota)
    FlagDown
    FlagLeft
    FlagRight
)

1.8 名称 的 导出 与 私有

Go 程序中,函数、类型、变量、常量、语句标签 和 包 的 名称 都遵循一个简单的规则,即名称的开头是一个字母(Unicode字符即可)或下划线,后面可以跟任意数量的字符、数字 和 下划线,并且区分大小写。

如果一个名称在函数内声明,则它只在函数内部有效,即局部名称(如局部变量)。如果声明在函数外(即包级别的名称),则它将对包里面所有代码可见和可访问。

Go 没有类似 public 和 private 的修饰符,包级别名称实体的第一个字母的大小写决定它是否可跨包访问。如果名称以 大写字母开头,则它是 导出 的,意味着它对包外是可见和可访问的(例如 fmt.Printf)。如果名称以小写字母开头,则它是当前包私有的,只在包内可见和访问。包名本身总是由小写字母组成。

Go 程序名称的命名风格一般使用“驼峰式”风格,即使用大写字母开头而非下划线_区分单词。像 ASCII、TCP、HTML 等首字母缩写的单词通常使用相同的大写或小写,如: htmlEscape、HTMLEscape。

2. 基础类型

2.1 数字 (number)

Go 的数值类型包括 整数(int)浮点数(float)复数(complex),各自又根据有无符号和大小继续细分为多种具体类型:

类型大小描述
int, uint32位 或 64位有符号 和 无符号 的整数
int8, uint88位8位 有符号 和 无符号 的整数
int16, uint1616位16位 有符号 和 无符号 的整数
int32, uint3232位32位 有符号 和 无符号 的整数
int64, uint6464位64位 有符号 和 无符号 的整数
float3232位32位 浮点数
float6464位64位 浮点数
complex6464位64位复数,32位实部(float32) + 32位虚部(float32)
complex128128位128位复数,64位实部(float64) + 64位虚部(float64)

2.1.1 整数 (int)

整数 可分为 有符号(int[n])无符号(uint[n])

有符号的整数以补码表示,保留最高位作为符号位,n 位有符号整数可以表示的值范围为 -2n-1 ~ 2n-1-1 。无符号整数不保留符号位,全部二进制位一起解析为非负值,n 位无符号整数可以表示的值范围为 0 ~ 2n-1 。例如,int8 可取值范围是 -128 ~ 127,uint8 可取值范围是 0 ~ 255。

对于 intuint 类型,在不同的软硬件平台,其大小不同。一般与平台的原生整数大小相同,或等于在该平台上运算效率最高的值(一般 32 位系统为 32 位,64 位系统为 64 位)。

整数类型有两个用于表示 字节字符 的同义词类型(别名):

  • byte 类型是 uint8 类型的同义词 (type byte = uint8),用于表示一个 字节 值。使用上还是 uint8,只是在源码中强调该值是原始数据值,而不是普通数量值。
  • rune 类型是 int32 类型的同义词 (type rune = int32),用于表示一个 Unicode 字符 的码点 (code point)。使用时还是 int32,只是在源码中强调该值是 Unicode 字符,而不是普通数量值。字符在源码中使用单引号 ' 引起来表示。

在源码中,整数的字面量可以用 十进制数、十六进制数(以0x或0X开头)、八进制数(以0开头) 和 二进制数(以0b开头),示例:

// 十进制数
d1 := 123456
d2 := -123456

// 十六进制数
h1 := 0xABCDEF
h2 := 0xabcdef
h3 := -0xABCDEF

// 八进制数
o1 := 0777
o2 := -0666

// 二进制数
b1 := 0b01010101
b2 := -0b01010101

// 源码中 整数字面量(称为无类型整数) 默认被推导为 int 类型, 
// 如果需要声明其他整型, 需要显式转换
x1 := uint8(255)
x2 := uint8(0xFF)

// 还可以使用单个 Unincode 字符来表示整数 (默认被推导为 int32 类型, 也就是 rune 类型)
c1 := 'A'           // 值为 65    (解析为它的 Unicode 码点)
c2 := -'a'          // 值为 -97
c3 := '\xFF'        // 值为 255
c4 := '\uABCD'      // 值为 43981
c5 := '世'          // 值为 19990

// 显式声明一个 Unicode 字符
var r rune = '界'   // 值为 30028

// 声明字节
var b1 byte = 0x7A
b2 := byte(0x5E)

还可以使用科学计数法表示,例如 a := 3e5 相当于 i := 3 * 105

Golang 的源码中无论是多少进制的整数字面量,都是有符号位的,其数学数值是 正负符号(绝对值)数值部分 一起构成:

var b int8  // 声明一个 8 位有符号整型变量, 数值范围是 -128 ~ +127

b =  0x7F   // 数学数值为 +127, 内存中存储的二进制为 "01111111"
b = -0x7F   // 数学数值为 -127, 内存中存储的二进制为 "10000001"

b = -0x80   // 数学数值为 -128, 内存中存储的二进制为 "10000000"
b =  0x80   // 数学数值为 +128, 超出 int8 范围, 编译报错!!! (无法以 int8 二进制编入内存)

b = -1      // 数学数值为   -1, 内存中存储的二进制为 "11111111"
b = 0xFF    // 数学数值为 +255, 超出 int8 范围, 编译报错!!!

b =  0b01111111     // 即  0x7F, 数学数值为 +127, 内存中存储的二进制为 "01111111"
b = -0b01111111     // 即 -0x7F, 数学数值为 -127, 内存中存储的二进制为 "10000001"

b = -0b10000000     // 即 -0x80, 数学数值为 -128, 内存中存储的二进制为 "10000000"
b =  0b10000000     // 即  0x80, 数学数值为 +128, 超出 int8 范围, 编译报错!!!

b = -0b00000001     // 即 -0x01, 数学数值为   -1, 内存中存储的二进制为 "11111111"
b =  0b11111111     // 即  0xFF, 数学数值为 +255, 超出 int8 范围, 编译报错!!!

即使是使用十六进制或二进制表示整数,需要知道 源码中的整数字面量表示的数值内存中实际存储的二进制 是不一样的,前者是有正负符号的人类理解的整数,后者是只有 0 和 1 两种状态的 CPU 理解的二进制位序列。从上面示例可以看出,int8(-1)uint8(255) 编译到内存中存储的二进制均为 "11111111"。已知一个内存中存储的二进制位序列,如果不知道其类型,那么无法知道其表示的数学数值大小。例如,内存中存储的字节二进制位序列为 "11111111",如果以 int8 类型翻译,则其数学数值为 -1;如果以 uint8 类型翻译,则其数学数值就是 255。因此,一个变量在内存中不但存了它的二进制值,还存了它的类型。

打个比方,把 "你好""こんにちは" “编译”为用 ASCII 表示的英文,结果都为 "hello"。但如果要反过来“翻译”回原来的语言,需要知道按什么“类型”翻译。如果以中文翻译,结果是 "你好";按日文翻译,结果则为 "こんにちは"

有符号整数编码到内存中以二进制 补码 的形式存储,保留最高位为符号位(非负数最高位是0,负数最高位是 1)。非负数的补码是其原码本身,负数的补码则是其绝对值正数的原码按位取反后再加 1(计算结果需 舍弃最高位和溢出部分,负数的 最高位固定为 1)。

负数补码计算示例:

int8(-1) 的补码计算过程:

    8 7 6 5 4 3 2 1         // 二进制位数 (高->低)
    ---------------
    0 0 0 0 0 0 0 1         // -1 的绝对值是 1, 计算出 1 的原码
    1 1 1 1 1 1 1 0         // 按位取反
    1 1 1 1 1 1 1 1         // 然后 +1
      1 1 1 1 1 1 1         // 舍去溢出部分 和 保留的符号位(最高位)
    1                       // 负数的符号位固定取 1
    1 1 1 1 1 1 1 1         // 最终 -1 的 8 位补码为 11111111


int8(-128) 的补码计算过程:

    8 7 6 5 4 3 2 1         // 二进制位数 (高->低)
    ---------------
  1 0 0 0 0 0 0 0 0         // -128 的绝对值是 128, 计算出 128 的原码
  0 1 1 1 1 1 1 1 1         // 按位取反
  1 0 0 0 0 0 0 0 0         // 然后 +1 (二进制加法, 满 2 进 1)
      0 0 0 0 0 0 0         // 舍去溢出部分 和 保留的符号位(最高位)
    1                       // 负数的符号位固定为 1
    1 0 0 0 0 0 0 0         // 最终 -128 的 8 位补码为 10000000

CPU 只能做加法运算,计算机以补码的形式存储有符号整数可以更高效的进行计算。在 CPU 的加法运算中,没有「有无符号」或「类型」等特殊处理的概念,通通按二进制位加法傻瓜式简单运算(溢出的高位自动舍弃)。

演示 CPU 中 int8(1) + int8(-1) 的计算:

int8(1) + int8(-1)
    
    8 7 6 5 4 3 2 1         // 二进制位数 (高->低)
    ---------------
    0 0 0 0 0 0 0 0         //  1 的 8 位补码 (正数的补码与原码相同)
                  +
    1 1 1 1 1 1 1 1         // -1 的 8 位补码 (参考上面的计算过程)
    ---------------
  1 0 0 0 0 0 0 0 0         // 二进制加法, 满 2 进 1
    0 0 0 0 0 0 0 0         // 舍弃溢出的高位 (没地方存了, 自动被丢弃), 按 int8 翻译为数学数值结果为 0
                            // 结果: (1) + (-1) == 0

int8 类型可表示的值范围是 -128 ~ 127,那两个 int8 整数 127 + 1 等于多少?会溢出或报错吗?下面演示 CPU 中 int8(127) + int8(1) 的计算:

int8(127) + int8(1)
    
    8 7 6 5 4 3 2 1         // 二进制位数 (高->低)
    ---------------
    0 1 1 1 1 1 1 1         // 127 的 8 位补码
                  +
    0 0 0 0 0 0 0 1         //   1 的 8 位补码
    ---------------
    1 0 0 0 0 0 0 0         // 二进制加法, 满 2 进 1, 
                            // 计算结果为 10000000, 按 int8 翻译为数学数值结果就是 -128
                            // 如果按 uint8 翻译为数学数值结果则是 128
                            // 因此 int8(127) + int8(1) == -128  (可以想象成一个时钟, 23时 + 1个小时, 就回到了原点 0 时)
                            
                            // 但如果是 int8(127) + uint8(1), 二进制计算结果也是 10000000,
                            // 把 uint8 翻译为 uint8 就变成了 128
                            
                            // 同理:
                            //     int8(127)  + int8(3)  == -126
                            //     uint8(127) + uint8(3) == 130
                            //
                            //     int8(-126) 和 uint8(130) 在内存中存储的 8 位二进制是相同的

代码验证:

    func main() {
        var a, b int8
        a = 127
        b = 1
        c := a + b
        fmt.Println(c)      // 结果输出: -128
    }

2.1.2 浮点数 (float)

Go 支持 float32float64 两种浮点数,大小分别为 32 位 和 64 位。浮点类型使用有限小数位的科学计数法表示,它的值范围可以从 极细微 到 超宏大。十进制下,float32 的有效小数位大约是 6 位,float64 的有效小数位大约是 15 位。为了减小浮点运算累计的误差,绝大多数情况下应该优先使用 float64。

  • 常量 math.MaxFloat32 是 float32 的最大值,大约为 3.4e+38
  • 常量 math.MaxFloat64 是 float64 的最大值,大约为 1.8e+308

浮点数声明示例:

f1 := 2.345             // 无类型浮点数默认推导为 float64 类型
f2 := -3.14             // float64 类型的浮点负数
f3 := float32(3.14)     // 声明 float32 类型的浮点数需要显式转换

f4 := 3.14e100          // 科学计数法表示的浮点数
f5 := -3.14e100
f6 := -3.14e-100

浮点数 与 整数 之间的转换:

f1 := 512.99        // 声明 float64 类型的变量
a1 := 100           // 声明 int 类型的变量

a2 := int(f1)       // float64 转 int, 取整数部分 (非四舍五入), 结果为 a2 == 512
f2 := float64(a1)   // int 转 float64

fmt.Printf("%d  %f\n", a2, f2)      // 输出: 512  100.000000

// 转换结果如果超出目标类型的最大/最小值范围, 结果不准确, 但不会报错

2.1.3 复数 (complex)

复数由 实部 和 虚部 两部分构成,Go 支持 complex64complex128 两种复数,大小分别为由两个float32 和 两个float64 两个浮点数构成(实部 和 虚部 分别由一个浮点数表示)。内置函数 real()imag() 可提取出复数的 实部 和 虚部。

声明复数,可以使用 complex(real, imag) 函数 或 使用字母i,复数声明示例:

c1 := complex(1.2, 2.5)             // 实部 和 虚部分别为 1.2 和 2.5 的负数 (默认被推导为 complex128 类型)
c2 := complex64(complex(1.2, 2.5))  // 声明 complex64 类型的复数需要显式转换

c3 := 2.5 + 3.6i                    // 使用字母i表示的复数, 实部为2.5, 虚部为3.6 (默认被推导为 complex128 类型)
c4 := 3.6i                          // 实部为 0 的复数 (纯虚数)
c5 := 3.6                           // 虚部为 0 的复数 (也就是实数)
c6 := complex64(2.5 + 3.6i)         // 声明 complex64 类型的复数需要显式转换

fmt.Println(real(c1), imag(c2))     // 提取复数的 实部 和 虚部

var c complex128                    // 复数支持 + - * / 算数运算 和 == != 比较运算
c = c3 + c4
c = c3 - c4
c = c3 * c4
c = c3 / c4
c = cc + 10
c += c3
c++
c--

2.2 布尔 (bool)

bool 类型用于表示比较和逻辑运算的结果,真(true) 或 假(false),它的值也只有这两种情况。

var b1 bool                     // 声明一个 bool 变量, 它的零值(默认值) 为 false
b2 := true                      // 短变量声明

var c = 'e'
b3 := 'a' <= c && c <= 'z'      // 通过计算布尔表达式结果赋值

2.3 字符串 (string)

2.3.1 字符串字面量

源码中字符串的值可以直接用字符串字面量表示。字符串字面量一般使用双引号"引起来,支持反斜杠\转义字符,可以在其中任意插入 十六进制(\xhh) 或 八进制(\ooo) 的任意字节,还可以插入 Unicode 码点(\uhhhh\Uhhhhhhhh) 表示的字符。

源码中还支持 原生字符串字面量 定义字符串,使用反引号 (`…`) 代替双引号,其中的所有字符不支持转义,编译器直接按字面解析。唯一特殊的是编译器会把回车符 \r 删除(换行符 \n 会保留),这是为了保证源码中的原生字符串字面量在不同操作系统中均保持一致。

Go 的源码总是按 UTF-8 编码,并且 Go 编译器习惯上也会按 UTF-8 编码解读字符串。Go 编译源码时,双引号字符串字面量中的每一个 十六进制(\xhh) 和 八进制(\ooo) 将被解析为单个字节,每一个 Unicode 码点(\uhhhh\Uhhhhhhhh) 表示的字符将以 UTF-8 编码为字符对应字节序列 (1~4个字节)。也就是说,程序运行时,加载到内存中的字符串是按 UTF-8 编码后的字节序列(不是 Unicode,这点和 Java、Python 等大多数采用 Unicode 在内存中存储字符串的编程语言不一样)。因为 UTF-8 是对 Unicode 字符变长编码的 (1~4个字节),因此无法对字符串以下标索引的方式直接访问第 n 个字符。

字符串声明示例:

// "世界" 这两个字符的 Unicode 码点(16进制)值分别为 4e16 和 754c
// "世界" 这两个字符的 UTF-8 编码(16进制)分别为 e4 b8 96 和 e7 95 8c


// 直接使用字符串字面量定义, 按 UTF-8 编码存储, 共占用了 8 个字节
s1 := "Hi世界"

// 将被编译器解析为 "Hi世界", 然后按 UTF-8 编码存储, 共占用了 8 个字节
s2 := "Hi\u4e16\u754c"

// 每一个 \xhh 将被解析为一个字节, 共占用了 8 个字节, 按 UTF-8 解码后生成的字符串为 "Hi世界"
s3 := "Hi\xe4\xb8\x96\xe7\x95\x8c"


fmt.Println(s1)         // "Hi世界"
fmt.Println(s2)         // "Hi世界"
fmt.Println(s3)         // "Hi世界"


// 使用 `...` 定义原生字符
s4 := `D:\abc\def.txt`
s5 :=`
func main() {
    fmt.Println("Hello World")
}
`


// 字符串支持使用 +号, 直接拼接
s6 := s1 + s2 + "World"

// PS: 
//      多个字符串使用+号拼接, 底层实际是把多个字符串打包为 []string, 然后计算拼接后需要的内存大小申请一次内存, 
//      再把逐个字符串复制到到新内存, 具体可以看 runtime/string.go 源码中的 concatstrings() 函数。
//      也就是说多个 string 用+号拼接, 只会申请一次内存, 效率高。
//      除了用+号拼接字符串外, 也可以使用 fmt.Sprint() 或 fmt.Sprintf() 格式化拼接字符串。

字符串长度使用 len() 内置函数计算,返回的是字符串的 UTF-8 字节数,使用下标索引可以访问到具体位置的字节。要计算 UTF-8 编码的 字符数,需要使用 utf8.RuneCountInString(string) 函数,它会按 UTF-8 解码字节序列计算出 Unicode 字符的数量。

字符串/字符长度计算和索引示例:

s := "Hi世界"

// 计算字节数, 结果为 8
fmt.Println(len(s))

// 计算字符数, 结果为 4
fmt.Println(utf8.RuneCountInString(s))


// 通过下标索引访问具体位置的字节, 类型为 byte (索引不能越界, 越界会抛异常)
b := s[0]
fmt.Println(b)      // 结果为 72


// 用 for-range 遍历字符串时, 遍历的是 Unicode 字符 (不是遍历字节序列), 相当边遍历边解码(UTF-8解码为Unicode)
// for-range 自动按 UTF-8 解码字符串遍历出 Unicode 字符 (rune)
for i, r := range s {
    // i 是字节的索引 (注意不是字符的索引), 表示当前字符 r 在 UTF-8 字符串中的字节开始位置 (i 可能不连续取值)
    // r 是从字节索引 i 位置开始 (1~4个字节) 的 Unicode 字符值 (码点)
    fmt.Printf("%d\t%d\t%c\n", i, r, r)
}
// 遍历输出:
// 0       72      H
// 1       105     i
// 2       19990   世
// 5       30028   界

// 也可以用 utf8.DecodeRuneInString() 函数解析出 UTF-8 字符串中的 Unicode 字符
fmt.Printf("index\tcode point\trune\tsize\n")

for i := 0; i < len(s); {
    // 函数返回 UTF-8 字符串的首个 Uniocde 字符
    // r    是 Unicode 字符码点 (rune)
    // size 是这个字符码点在 UTF-8 字符串序列中占用了多少个字节
    r, size := utf8.DecodeRuneInString(s[i:])
    
    fmt.Printf("%d\t%d\t\t%c\t%d\n", i, r, r, size)
 
    // 移动索引到下一个 Unicode 字符码点的开始字节   
    i += size
}
// 遍历输出:
// index   code point      rune    size
// 0       72              H       1
// 1       105             i       1
// 2       19990           世      3
// 5       30028           界      3

字符串内部数据不允许修改:

s := "abcdef"
s[0] = 'A'      // 编译错误, s[0] 无法赋值

字符串不可修改,意味着多个字符串可以安全地共用一段底层内存,使得复制任意长度字符串(变量)、以及任意切片生成子字符串的开销都非常低廉。复制字符串 和 切片生成的子字符串 实际上“都没有”分配新的内存,而是共用了原字符串的底层内存。

实际上,一个 字符串(string)变量 只占用了 2 个 int 大小的空间,而真正的字符串字节序列存储在另一个内存区域。字符串(string)变量内部包含了两个值,一个是 指向字符字节串序列实际存储的位置地址,另一个则是标记此字符串的字节长度,一共占用了 2 个 int 大小的空间(一般 32 位系统为 2x4=8 个字节,64 为系统为 2x8=16 个字节)。

计算变量占用的内存大小,可以使用 unsafe.Sizeof() 函数计算:

// 以 64 位系统为例

var a int
fmt.Println(unsafe.Sizeof(a))       // 输出: 8

b := int8(123)
fmt.Println(unsafe.Sizeof(b))       // 输出: 1

s1 := "12345678901234567890..."
s2 := ""
fmt.Println(unsafe.Sizeof(s1))      // 输出: 16
fmt.Println(unsafe.Sizeof(s2))      // 输出: 16

// 可以看出, 无论是超长的字符串, 还是空字符串, 其变量都只占用了 16 个字节。

字符串复制、切片子串 操作示例:

s1 := "HELLO,WORLD"     // 声明字符串变量 s1, 使用字面量直接赋值
s2 := s1                // 声明字符串变量 s2, 初始值从 s1 复制赋值    (底层没有分配存储字符串序列的新数组)
s3 := s1[2:5]           // 声明字符串变量 s3, 初始值从 s1 切片子串赋值 (底层没有分配存储字符串序列的新数组)

// 后面两条复制和切片字符串的操作, 除了分配存储字符串变量的空间外 (2x4位 或 2x8位), 
// 没有分配新内存来存储变量指向的新字符串序列, 而是底层共用了变量 s1 指向的字符串字节数组。
// 当没有任何字符串变量的地址值指向真正存储字符序列的字节数组空间时, 这片内存将被回收。

// 因此, 对于字符串的任意复制赋值、切片子串、作为参数传递给函数 或 作为函数返回值,
// 这些操作的开销都非常低廉 (仅相当于复制了 2 个 int 值)。

// 所谓的字符串变量复制赋值, 只是将 变量存储的2个int值 直接复制给 另一个字符串变量。

上面的字符串 存储、复制赋值 和 切片子串赋值 演示:

addr     0xA0  0xA1  0xA2  0xA3  0xA4  0xA5  0xA6  0xA7  0xA8  0xA9  0xAA
        +-----------------------------------------------------------------+
        |  H  |  E  |  L  |  L  |  O  |  ,  |  W  |  O  |  R  |  L  |  D  |
        +-----------------------------------------------------------------+

string  s1(addr=0xA0, len=11)    ->    "HELLO,WORLD"
        s2(addr=0xA0, len=11)    ->    "HELLO,WORLD"
        s3(addr=0xA2, len=3)     ->    "LLO"

对于字符串 复制赋值、切片子串 的操作,底层共用了同一个字符串字节数组,不会真正产生新的字符串,也就不会分配新的内存区域去存储字符串。但对于字符串的拼接,则会为拼接后的新字符串分配新的底层字节数组去存储。如果频繁拼接字符串,可以使用 缓冲区(bytes.Buffer) 来做优化。

2.3.2 Unicode 与 UTF-8

Unicode 编码称为统一码,也叫万国码,它是国际组织制定的涵盖了世界上所有语言的文字和符号的字符编码方案,并且兼容 ASCII 码。Unicode 为每一个字符都设定了统一且唯一的二进制编码,称为 码点(Code Point)。统一的编码可以很方便地处理 跨语言、跨平台 的任意文本转换。

Unicode 码点的编码长度通常使用 2 个字节,这已足以容纳世界上所有语言的大部分文字和符号,简称为 UCS-2,即 2 字节编码。为了防止以后 2 个字节不够用,Unicode 还扩展了 4 字节编码,简称为 UCS-4。Unicode 字符的码点都是标准的数字,在 Go 中用 rune 类型(int32 的别名)来表示一个 Unicode 字符。

Unicode 字符最大是 4 个字节(32位),将每一个 Unicode 字符都使用固定大小的 4 个字节来表示,这种方式称作是 UTF-32 或 UCS-4。这种统一长度的方式在内存中可以很方便地处理数据,但存储到磁盘中就会变的很占用空间,因为世界上所有语言的大部分文字和符号的 Unicode 码点只需要 1 ~ 2 个字节就足以表示。为此,UTF-8 编码就是对 UTF-32 的改进。

UTF-8 以字节为单位对 Unicode 字符码点做可变长度编码。每一个字符用 1 ~ 4 个字节来表示,绝大多数字符只需要用到 3 个以内的字节,而且 ASCII 字符只需要一个字节。一个字符编码的首字节高位指定了后面还有多少个字节属于这个字符,首字节后面的字节以 10 开头。首字节最高位为 0,表示当前字符由 1 个字节构成,也就是兼容的 ASCII;首字节最高位为 110,表示当前字符由 2 个字节构成;首字节最高位为 1110,表示当前字符由 3 个字节构成;首字节最高位为 11110,表示当前字符由 4 个字节构成。

UTF-8 编码示例:

UTF-8 单个字符的编码字节序列               表示的 Unicode 码点范围
------------------------------------------------------------
0xxxxxxx                                0 ~ 127      (ASCII)
110xxxxx 10xxxxxx                       128 ~ 2047
1110xxxx 10xxxxxx 10xxxxxx              2048 ~ 65535
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx     65536 ~ 0x10FFFF

前缀固定,把剩余的二进制位连在一起存储 Unicode 码点值。UTF-8 是前缀编码,从左往右解码时不会产生歧义,也无需超前预读。UTF-8 的前缀特性,最多只要追溯 3 个字节,就能定位一个字符的编码字节起始位置。

Unicode 和 UTF-8 的关系:Unicode 是一个字符集,把世界上所有语言的文字和符号对应一个不重复的唯一整数。UTF-8 是对 Unicode 的一种编码格式,主要目的是为了减小字符的存储空间和节约传输流量。

2.3.3 字符串操作包

对字符串操作的标准包:strings, bytes, strconv, unicode, utf8

  • strings 包提供了 搜索、替换、比较、修正、切分 和 连接字符串 等操作。
  • bytes 包的也提供了 strings 包的类似函数,只不过 bytes 包处理的对象是 []byte 切片类型。
  • strconv 包提供了 字符串 与 基本数据类型 的相互转换。
  • unicode 包提供数据和函数来测试 Unicode 字符的某些属性,如 IsUpper() 和 IsLower(),每个函数都把一个单字符 (rune) 作为参数。
  • utf8 包提供了函数和常量以支持以 UTF-8 编码的文本,它包括在 Unicode 字符 和 UTF-8 字节序列之间进行转换的函数。

Unicode 字符 和 UTF-8 字节序列可以相互转换。把 Unicode 字符码点转换为 UTF-8 字节序列,称为 编码(Encode)。把 UTF-8 字节序列转换为 Unicode 字符码点,称为 解码(Decode)

UTF-8 编码和解码相关函数:

// 编码: Unicode -> UTF-8
func EncodeRune(p []byte, r rune)       int

// 解码: UTF-8 -> Unicode
func DecodeRune(p []byte)               (r rune, size int)
func DecodeRuneInString(s string)       (r rune, size int)
func DecodeLastRune(p []byte)           (r rune, size int)
func DecodeLastRuneInString(s string)   (r rune, size int)

Unicode 转 UTF-8(编码 / Encode):

// 创建一个长度为 3 的 byte 数组, 用于存储编码后的 UTF-8 字节序列
var buf [3]byte

// 一个 Unicode 字符的码点
var r rune = 0x4E16

// 编码字符r, 结果保存到 buf字节数组, 返回此字符的 UTF-8 编码所占字节数量
size := utf8.EncodeRune(buf[:], r)

// utf8Bytes 字节切片 就是字符编码后的 UTF-8 字节序列
utf8Bytes := buf[:size]

// 转为字符串
s := string(utf8Bytes)


// 如果将一个整数转换为字符串, 则其值按 Unicode 字符码点(rune) 解读, 并产生代表该字符的 UTF-8 字节序列。
// 如果 rune 码点值是非法的, 将使用专门的替换字符('\uFFFD')取代, 
fmt.Println(string(65))             // "A", 而非 "65"
fmt.Println(string(12345678))       // "�", 也就是 "\uFFFD"

UTF-8 转 Unicode(解码 / Decode):

// 把 UTF-8 字符串解码为 Unicode字符
var s = "你好,世界"

// 解码字符串(只解码第一个), 返回两个参数, 
// r    是解码后的 Unicode 字符码点, 
// size 是这个字符码点在 UTF-8 字符串序列中占用了多少个字节
r, size := utf8.DecodeRuneInString(s)

// 输出 字符 和 占用的字节数
fmt.Printf("%c\t%d\n", r, size)
// Output: 你      3

----------------------------------------------------

// 把 UTF-8 字符串的字节数组(byte切片) 解码为 Unicode字符
var bs = []byte("你好,世界")

// 解码字节切片(只解码第一个), 返回两个参数, 
// r    是解码后的 Unicode 字符码点, 
// size 是这个字符码点在 UTF-8 字符串序列中占用了多少个字节
r, size := utf8.DecodeRune(bs)

// 输出 字符 和 占用的字节数
fmt.Printf("%c\t%d\n", r, size)
// Output: 你      3

2.3.4 字符串与基本类型的转换(strconv)

字符串与基本类型之间的转换,可以使用 strconv 包。

字符串字节序列([]byte) 相互转换:

str := "Hello世界"

// string -> []byte
slice := []byte(str)

// []byte -> string
str = string(slice)

// 字符串本身就是字节序列, 通过索引下标访问的元素值本身就是字节值, 字符串和[]byte之间可以直接转换。

字符串基本类型 相互转换函数:

// 解析字符串: 字符串 -> 基本类型
strconv.ParseInt(s string, base int, bitSize int)       (i int64, err error)
strconv.ParseUint(s string, base int, bitSize int)      (uint64, error)
strconv.ParseFloat(s string, bitSize int)               (float64, error)
strconv.ParseBool(str string)                           (bool, error)
strconv.ParseComplex(s string, bitSize int)             (complex128, error)


// 格式化基本类型: 基本类型 -> 字符串
strconv.FormatInt(i int64, base int)                                string
strconv.FormatUint(i uint64, base int)                              string
strconv.FormatFloat(f float64, fmt byte, prec, bitSize int)         string
strconv.FormatBool(b bool)                                          string
strconv.FormatComplex(c complex128, fmt byte, prec, bitSize int)    string

// 格式化基本类型: 基本类型 -> []byte
// 与上面的 FormatXXX 函数功能相同, 只不过是把结果输出到 []byte, 并返回扩展后的 []byte
strconv.AppendInt(dst []byte, i int64, base int)                        []byte
strconv.AppendUint(dst []byte, i uint64, base int)                      []byte
strconv.AppendFloat(dst []byte, f float64, fmt byte, prec, bitSize int) []byte
strconv.AppendBool(dst []byte, b bool)                                  []byte


// ASCII字符串 转 int值, 等价于 ParseInt(s, 10, 0)
strconv.Atoi(s string) (int, error)
// int值 转 ASCII字符串, 等价于 FormatInt(int64(i), 10)
strconv.Itoa(i int)    string


// 基本类型 转 字符串, 可以直接使用 fmt 包中的(格式化)打印函数, 下面三个函数均返回 string
fmt.Sprintf(...)
fmt.Sprint(...)
fmt.Sprintln(...)

判断一个 Unicode 字符码点 (rune) 是否被 Go 定义为“可打印”:

// 如 'a'、'A' 为可打印, '\n'、'\t' 为不可打印
// 与 unicode.IsPrint() 的定义相同
strconv.IsPrint(r rune) bool

字符串/字符的引号(Quote)形式的处理函数:

// 把字符串两边加上双引号输出(把 内存中的字符串 转换为 源码中的字符串字面量形式)
strconv.Quote(s string)             string              // `Hi"世界"` -> `"Hi\"世界\""`
strconv.QuoteToASCII(s string)      string              // `Hi"世界"` -> `"Hi\"\u4e16\u754c\""`
strconv.QuoteToGraphic(s string)    string              // `Hi"世界"` -> `"Hi\"世界\""`
strconv.QuotedPrefix(s string)      (string, error)     // `"世界"Hi` -> `"世界"`


// 把字符两边加上单引号输出 把 内存中的字符 转换为 源码中的字符字面量形式)
strconv.QuoteRune(r rune)           string              // '世' -> `'世'`
strconv.QuoteRuneToASCII(r rune)    string              // '界' -> `'\u754c'`
strconv.QuoteRuneToGraphic(r rune)  string              // '\n' -> `'\n'`


// 功能与上面的函数相同, 只不过是把结果保存到 []byte, 并返回扩展后的 []byte
strconv.AppendQuote(dst []byte, s string)               []byte
strconv.AppendQuoteToASCII(dst []byte, s string)        []byte
strconv.AppendQuoteToGraphic(dst []byte, s string)      []byte

strconv.AppendQuoteRune(dst []byte, r rune)             []byte
strconv.AppendQuoteRuneToASCII(dst []byte, r rune)      []byte
strconv.AppendQuoteRuneToGraphic(dst []byte, r rune)    []byte


// 移除字符串/字符两边的引号 (上面加引号函数的反操作)
strconv.Unquote(s string)                   (string, error)
strconv.UnquoteChar(s string, quote byte)   (value rune, multibyte bool, tail string, err error)

3. 复合类型

Go 程序中有四种主要的复合数据类型,分别为 数组(array)切片(slice)集合(map)结构体(struct)。其中 数组(array) 和 结构体(struct) 拥有固定长度,切片(slice) 和 集合(map) 的长度是可以动态增长或缩短的动态数据结构。

复合数据类型 比较运算 的支持:

  • 数组(array)、切片(slice)、集合(map)、结构体 均不支持大小比较
  • 如果数组(array)元素类型可比较,且长度和元素类型均相同,那么这个数组类型就是可比较的。数组的比较是按索引顺序比较 (==!=) 两个数组的元素。
  • 如果结构体(struct)的所有成员都可比较,那么这个结构体就是可比较的。结构体比较运算是按顺序比较 (==!=) 两个结构体的成员变量。
  • 切片(slice)、集合(map) 属于引用类型,仅支持与其零值 nil 比较 (==!=),两个 (slice/map) 变量之间不支持比较。

3.1 数组 (array)

数组(array) 是有固定长度,且拥有零个或多个相同数据类型元素的序列。数组中的元素通过下标索引访问,数组索引是从 0 开始递增的整数值。不能索引越界访问数组元素,会导致程序崩溃。数组元素的零值,是其元素类型的零值。内置函数 len() 可以计算数组的长度(元素个数)。

数组类型长度元素类型 一起组成,表示为 [LEN]TYPE,其中 LEN 表示数组的固定长度,TYPE 表示数组的元素类型。两个数组的元素类型即使相同,如果长度不同,其类型就不同。长度必须是常量表达式,也就是在编译时就可以完全唯一确定它的长度值。

数组变量分配的是一片连续的内存,一个数组变量占用的内存大小为其元素类型大小乘以长度,即 unsafe.Sizeof(array[0]) * LEN。数组不是引用类型,赋值操作(包括函数 传递参数 和 接收返回值)都是拷贝所有元素值的副本,所以数组的传递通常以指针形式传递。

3.1.1 数组的定义

声明一个数组,默认使用元素类型的零值来初始化各元素值:

var a [3]int

fmt.Println(a[0])               // 输出数组的第一个元素, 结果为: 0
fmt.Println(len(a))             // 输出数组的长度, 结果为: 3
fmt.Println(a[len(a)-1])        // 输出数组的第一个元素, 结果为: 0

a[1] = 10                       // 给指定索引位置的数组元素赋值
fmt.Println(a[0])               // 结果为: 10

a[1]++                          // 数组元素变量可以当做普通变量做递增赋值等赋值运算
a[1] += 1  
fmt.Println(a[1])               // 结果为: 12

声明数组,同时初始化个元素值:

var a [3]int = [3]int{1, 2, 3}
b := [5]int{1, 2, 3, 4, 5}

声明数组,元素可以只初始化部分,其他未指定部分初始值为元素类型的零值:

a := [5]int{1, 2, 3}
fmt.Println(a[0], a[4])         // 输出: 1 和 0

可以通过索引来初始化对应位置的元素值:

a := [5]int{0: 10, 3: 30}
fmt.Println(a[0], a[1], a[3])   // 输出: 10、0 和 30

普通初始化 和 索引初始化 可以混用:

a := [5]int{1, 2, 4: 123}
fmt.Println(a[0], a[4])         // 输出: 1 和 123

可以用 ... 来表示数组长度,表示不指定长度,在编译时由初始化表达式来推导:

a := [...]int{1, 2, 3}          // 长度被推导为 3
b := [...]int{1, 2, 3, 9: 123}  // 长度被推导为 10
fmt.Printf("%T", b)             // 输出数组类型为: [10]int

可以使用整数的同义类型来当做长度和索引(会隐式转换为整数值):

const WeekDays = 7
type Week int
const (
    Sunday Week = iota
    Monday
    Tuesday
)
w := [WeekDays]string{Sunday: "星期日", Monday: "星期一", Tuesday: "星期二"}
fmt.Println(w[0])   // 输出: "星期日"

数组的元素类型也可以是一个数组类型,也就是 多维数组

// 数组a 的 长度为 2, 类型为 [3]int
a := [2][3]int{{1, 2, 3}, {4, 5, 6}}

fmt.Println(len(a))     // 数组a 的长度时 2, 输出: 2
fmt.Println(len(a[0]))  // a[0] 的值的类型是 [3]int, 也是一个数组, 其长度是3, 输出: 3

fmt.Println(a[0])       // 输出: [1 2 3]
fmt.Println(a)          // 输出: [[10 11 12] [10 11 12]]

fmt.Println(a[0][0])    // 访问二维数组的元素, 输出: 1

Go 数组不是引用类型,赋值操作(包括函数 传递参数 和 接收返回值)都是拷贝所有元素值的副本:

func main() {
    a := [3]int{10, 11, 12}
    
    // 这里是重新声明了一个 数组b, 并把 数组a 的元素按位置逐个复制给 数组b,
    // 必须类型相同 (长度和元素类型相同) 的数组才能相互赋值 (包括函数 参数传递 和 接收返回)
    b := a
    
    b[0] = 123
    fmt.Println(a)      // 输出: [10 11 12]
    
    f(a)
    fmt.Println(a)      // 输出: [10 11 12]
}

func f(a [3]int) {
    a[0] = 789
    a[1] = 456
    a[2] = 123
}

3.1.2 数组的指针访问

数组变量 和 数组元素变量 均可使用 & 取地址赋值给对应的指针变量:

a := [...]int{10, 11, 12}

var p *[3]int = &a      // 对数组变量取地址, 赋值给数组类型的指针类型
var p1 *int = &a[1]     // 对数组元素变量取地址, 赋值给数组元素类型的指针类型

fmt.Println(*p)         // 输出整个数组: [10 11 12]
fmt.Println(*p1)        // 输出指针 p1 指向的变量值: 11


(*p)[0] = 100           // 通过数组指针给指定索引位置的元素做赋值运算
(*p)[0]++
(*p)[0] += 1
fmt.Println(a[0])       // 输出: 102


fmt.Println(p[0])       // p[0] 相当于 (*p)[0], 可以直接通过数组指针+下标访问数组元素, 自动隐式转换


*p1 = 200               // 通过数组元素指针给数组元素做赋值运算
*p1++
*p1 += 1
fmt.Println(a[1])       // 输出: 102

// 指针变量不能递增其指向, 比如 *(p + 1) 这样的访问是 错误的。

通过数组指针传递数组给函数的参数:

func main() {
    a := [3]int{10, 11, 12}
    f(&a)
    fmt.Println(a)  // 输出: [789 456 123]
}

func f(a *[3]int) {
    a[0] = 789      // 隐式转换, a[0] 相当于 (*a)[0]
    a[1] = 456
    a[2] = 123
}

3.1.3 数组的比较

如果一个数组类型的元素类型是可比较的,那么这个数组可以做 ==!= 的比较运算(不能做大小比较)。两个数组可比较的条件:数组元素可比较,并且 数组类型 相同(长度和元素类型均相同)。两个数组比较时,当且仅当两个数组的元素值按索引顺序相等时,两个数组相等。

数组比较:

a := [3]int{1, 2, 3}
b := [...]int{1, 2, 3}
c := [2]int{1, 2}
d := [2]string{"1", "2"}

fmt.Println(a == b, a != b)     // 可比较, 结果为: true false

fmt.Println(b == c)             // 编译出错, [3]int 和 [2]int 不是同类型数组
fmt.Println(c == d)             // 编译出错, [2]int 和 [2]string 不是同类型数组

3.1.4 数组元素遍历

内置函数 len() 可以返回数组的长度,通过下标索引遍历数组元素:

a := [...]int{10, 11, 12, 13, 14, 15}

for i := 0; i < len(a); i++ {
    fmt.Printf("%d  %d\n", i, a[i])
}

// 输出:
// 0  10
// 1  11
// 2  12

使用 for range 循环变量数组元素:

a := [...]int{10, 11, 12}

for i, e := range a {
    // i 是数组的索引
    // e 是当前索引位置的元素值 e == a[i]
    fmt.Printf("%d  %d\n", i, e)
}

// 输出:
// 0  10
// 1  11
// 2  12

3.1.5 空标识符 _

Go 程序中声明的变量如果没有使用会报错,如果一个表达式返回的值不需要,可以使用一个 空标识符 来接收,空标识符字面量表示为下划线 (_)。用 _ 接收的值,表示忽略此值:

a := [...]int{10, 11, 12}

for _, e := range a {
    // _ 用来接收数组的索引值, 接收到后自动忽略
    // 注意: _ 不能取值访问, 如 fmt.Println(_) 会报错
    // e 是当前索引位置的元素值
    fmt.Printf("%d\n", e)
}

// 输出:
// 10
// 11
// 12

3.2 切片 (slice)

切片(slice) 是一个有相同类型元素的可变长度的序列,其类型表示为 []TYPE,TYPE 表示类型,没有固定长度。切片可以看做是没有固定长度的数组类型,其底层对应的也是一个普通数组。

切片有三个属性:

  • 指针:指向一个 底层数组,是切片真正存储数据的地方。
  • 长度:表示切片当前可访问的序列长度(越界会导致程序崩溃)。
  • 容量:表示在不需要重新分配内存的情况下切片可以存储的最大元素数量,容量的大小通常是从切片指针指向的底层数组元素位置(切片的起始元素位置)到底层数组的最后一个元素之间的元素个数。

一个底层数组,可以同时被多个切片引用,并且可以引用数组的任何位置。切片的 长度 和 容量,可以分别使用内置函数 len()cap() 计算。

切片属于引用类型,其零值为 nil。两个切片之间不能比较,但切片可以和 nil==!= 比较,用于判断切片变量是否为 nil。如果想判断一个 slice 是否为空切片(没有引用任何元素),需要使用 len(slice) == 0,不能使用 slice == nil,因为 slice 不是 nil 时,它的长度也可能是 0。值为 nil 的切片,也可以使用 len()cap() 计算长度和容量,结果均为 0。

一个切片变量(slice),维护了 指针、长度、容量 三个属性,因此存储一个切片变量所占用的大小为 3 个 int 类型的大小,在 64 为系统中也就是 3x8=24 个字节,即 unsafe.Sizeof(slice) == 24

3.2.1 切片的创建

内置函数 make() 可以创建一个具有指定类型、长度 和 容量 的切片,语法格式:make([]TYPE, len[, cap]),其中容量cap可以省略,默认与长度len相等,函数返回[]TYPE(切片元素的零值为 TYPE 类型的零值)。

声明切片变量,以及使用 make() 函数创建切片:

var s1 []int                                // 声明一个切片变量, 默认值为零值 nil
fmt.Printf("%T\n", s1)                      // 输出类型为: []int
fmt.Println(s1)                             // 长度为 0 的切片, 输出: []
fmt.Println(s1 == nil, len(s1), cap(s1))    // 值为 nil 的切片的长度和容量均为 0, 输出: true 0 0


s2 := []int{1, 2, 3}                        // 声明一个切片变量, 并手动初始化值
fmt.Printf("%T\n", s2)                      // 输出类型为: []int
fmt.Println(s2)                             // 输出: [1 2 3]
fmt.Println(len(s2), cap(s2))               // 输出: 3 3


s3 := make([]int, 5, 10)                    // 创建切片, 返回 []int
fmt.Printf("%T\n", s3)                      // 输出类型为: []int
fmt.Println(s3)                             // 输出: [0 0 0 0 0]
fmt.Println(len(s3), cap(s3))               // 输出: 5 10

切片元素的访问:

s := make([]int, 5, 10)                 // 创建切片, 返回 []int

fmt.Println(s[0])                       // 根据索引访问切片的元素, 输出: 0
s[0] = 10                               // 切片元素可以像普通变量一样赋值运算
s[0]++
s[0] += 1
fmt.Println(s[0])                       // 输出: 12

fmt.Println(s[6])                       // 程序崩溃: 索引越界 (超出了切片长度可访问范围)

切片变量也可以取地址创建对应的指针变量,但切片本身是引用类型,不需要再用指针引用就可以高效地做任何赋值操作,比如接传递给函数的形参或作为函数返回值接收,仅仅是复制了它的 指针、长度、容量 这 3 个与 int 类型相同大小的值。

3.2.2 数组的切片引用

切片底层引用的是一个数组,一个数组也可以直接创建切片来访问。假如一个数组 var arr []TYPE,创建切片格式为 arr[start:end],返回切片([]TYPE)。start 表示切片引用的起始位置(包括),如果省略,默认为数组的起始位置 0。end 表示切片引用的结束位置(不包括),如果省略,默认为数组的长度 len(arr)。start 和 end 的值必须满足 0 <= start <= end <= len(arr),否则将导致编译错误或程序崩溃。

// 创建一个 [5]int 类型的数组
var a [5]int = [5]int{10, 11, 12, 13, 14}

// 创建数组的切片引用, 返回类型为 []int,
// 一共引用了数组的 2 个元素, 所以切片 s1 的长度为 2
var s1 []int = a[0:2]

// 切片长度为 2,
// 容量是从切片首个元素位置到数组的最后一个元素 (也就从切片指向的首个元素开始往后一共允许使用底层数组元素的个数),
// 因此容量为 5, 输出: 2 5
fmt.Println(len(s1), cap(s1))

// 输出切片的值, 输出的是已使用长度的值(非整体容量), 输出: [10 11]
fmt.Println(s1)

// 超出切片可访问的长度, 抛出 索引越界 的错误。
// fmt.Println(s1[2])


s2 := a[1:]                         // 创建数组的切片引用, 引用数组 a[1] 位置开始到末尾
fmt.Println(len(s2), cap(s2), s2)   // 输出: 4 4 [11 12 13 14]


s3 := a[:3]                         // 从数组元素开始位置引用到 a[3] 位置
fmt.Println(len(s3), cap(s3), s3)   // 输出: 3 5 [10 11 12]


s4 := a[:]                          // 引用数组的所有位置 (从开头到结尾)
fmt.Println(len(s4), cap(s4), s4)   // 输出: 5 5 [10 11 12 13 14]


s5 := a[1:1]                        // 切片指针指向 a[1], 切片长度为 0
fmt.Println(len(s5), cap(s5), s5)   // 输出: 0 4 []

s6 := a[0:2:3]                      // 使用2个冒号引用的扩展表达式, 其中前面2个索引与1个冒号时相同, 第3个索引表示需要索引的容量位置(不包括)
fmt.Println(len(s6), cap(s6), s6)   // 输出: 2 3 [10 11]

通过切片引用,修改底层数组的值:

a := [5]int{10, 11, 12, 13, 14}     // 创建一个 [5]int 类型的数组
s := a[1:]                          // 重建数组的切片引用
s[0] = 123                          // 修改切片引用的首个元素的值
fmt.Println(a)                      // 输出原数组值: [10 123 12 13 14]

切片的切片引用,一个切片可以继续创建子切片引用,格式与数组的切片引用相同 slice[start:end]

a := [5]int{1, 2, 3, 4, 5}          // 创建一个 [5]int 类型的数组

s1 := a[:4]                         // 创建数组的切片引用
fmt.Println(len(s1), cap(s1), s1)   // 输出: 4 5 [1 2 3 4]

s2 := s1[2:]                        // 通过切片创建子切片 (底层引用的还是同一个数组)
fmt.Println(len(s2), cap(s2), s2)   // 输出: 2 3 [3 4]

3.2.3 切片添加元素 append()

切片是可变长度的,可以通过内置函数 fun append(slice []Type, elems ...Type) []Type 添加元素到切片末尾,返回新的切片引用(通常使用原切片变量来接收它)。添加的元素,如果切片还有容量存储,则直接存储到切片尾部,长度 len() 递增;如果容量已满 len() == cap(),则会新创建一个长度更长的数组作为新的底层数组(自动扩容),然后把切片引用的旧数组数据复制到新数组并创建就新的切片引用,然后再把新元素添加到新切片的尾部,最后返回新切片。

append() 函数的使用示例一:

s := []int{1, 2, 3}                 // 声明一个切片
fmt.Println(len(s), len(s), s)      // 输出: 3 3 [1 2 3]

s = append(s, 4)                    // 添加元素到切片尾部, 返回新的切片引用 (一般使用原切片变量来接收它)
                                    // 扩容会创建新的底层数组, 因此扩容后, 新旧切片引用的就不是同一个底层数组了
fmt.Println(len(s), len(s), s)      // 输出: 4 4 [1 2 3 4]

s = append(s, 5, 6)                 // 可添加多个元素
fmt.Println(len(s), len(s), s)      // 输出: 6 6 [1 2 3 4 5 6]

var s2 = []int{10, 20}
s = append(s, s2...)                // 添加一个切片内的所有元素
fmt.Println(len(s), len(s), s)      // 输出: 8 8 [1 2 3 4 5 6 10 20]

append() 函数的使用示例二:

s := make([]int, 3, 5)              // 创建一个切片, 长度为3, 容量为5
s[0] = 1
s[1] = 2
s[2] = 3

fmt.Println(len(s), cap(s), s)      // 输出: 3 5 [1 2 3]

s = append(s, 4, 5)                 // 添加元素, 返回新的切片引用 (有容量存储新元素, 不扩容)
fmt.Println(len(s), cap(s), s)      // 输出: 5 5 [1 2 3 4 5]

值为零值 nil 的切片也可以调用 append() 函数添加元素:

var s []int
s = append(s, 123)
fmt.Println(len(s), cap(s), s)  // 输出: 1 1 [123]

// 声明长度为 0 的切片时, 推荐使用 var 变量声明的方式, 因为零值切片不需要分配内存。

3.2.4 切片元素的复制 copy()

切片元素的复制,可以使用内置函数 func copy(dst, src []Type) int,两个切片的长度可以不一致,返回实际复制的元素数量。

切片复制示例:

s1 := []int{1, 2}       // 创建一个长度为 2 的切片并初始化值
s2 := make([]int, 3)    // 创建一个长度为 3 的切片, 元素值默认为零值
n := copy(s2, s1)       // 切片元素复制: s2 <- s1
fmt.Println(n)          // 复制了 2 个, 输出: 2
fmt.Println(s2)         // 输出: [1 2 0]

s3 := []int{1, 2, 3}
s4 := make([]int, 2)
n = copy(s4, s3)        // 切片元素复制: s4 <- s3
fmt.Println(n)          // 复制了 2 个, 输出: 2
fmt.Println(s4)         // 输出: [1 2]

使用 copy() 函数复制数组:

a1 := [3]int{1, 2, 3}
var a2 [3]int
copy(a2[:], a1[:])      // 函数接收的是切片类型, 需要创建数组的切片引用传递给函数
fmt.Println(a2)         // 输出:[1 2 3]

3.2.5 切片元素的遍历

遍历切片元素和遍历数组一样:

s := []int{100, 200, 300}

// 通过索引下标遍历切片
for i := 0; i < len(s); i++ {
    fmt.Printf("%d  %d\n", i, s[i])
}
// 输出:
// 0  100
// 1  200
// 2  300

// 使用 for range 循环遍历
for i, e := range s {
// i 是切片的索引, 从 0 到 len(s)-1
// e 是当前索引位置的元素值, 相当于 s[i]
    fmt.Printf("%d  %d\n", i, e)
}
// 输出:
// 0  100
// 1  200
// 2  300

3.3 集合 (map)

map 是一个拥有键值对元素的无序集合,也称为散列表或字典。在 map 中,键是唯一的,每一个键对应一个值,通过键可以获取、更新 或 移除键对应的值。map 内部通过散列算法实现键的存储和查找,无论 map 集合多大,对 map 的增删改查操作都可以通过常量时间的键比较就可以完成。

map 是引用类型(零值为 nil),在程序中表示为 map[K]V,其中 K 和 V 分别为 map 的 键 和 值 的数据类型。在一个 map 实例中,所有的键都拥有相同的数据类型,所有的值的类型也是相同的。map 中键的类型 K 必须是可以通过操作符 == 来比较的数据类型。虽然浮点类型也可以比较,但浮点类型的相等比较会因为精度问题而不准确,所以不能使用浮点类型作为 map 的键类型。map 中的值类型 V 可以是任意数据类型。

map 的大小使用内置函数 len() 计算。和切片(slice)一样,两个 map 之间不能进行比较运算,map 只能和它的零值 nil==!= 比较运算。

map 是引用类型,一个 map 类型变量的大小为一个 int 类型的大小(64位系统中为 8 个字节),也就是只存储了实例的地址。任何对 map 的赋值操作、传递给函数形参、接收函数的返回,拷贝的是 map 实例的引用(地址),因此没必要对 map 进行取地址传递指针。

3.3.1 集合(map)的创建

内置函数 make() 可以创建一个 map 实例,语法格式: make(map[K]V),返回一个 map[K]V 类型的实例。

集合(map)的创建:

var m1 map[string]int               // 声明 map, 默认值为零值 nil
m2 := make(map[string]string)       // 使用 make() 函数创建 map

fmt.Printf("%T\n", m1)              // 输出: map[string]int
fmt.Printf("%T\n", m2)              // 输出: map[string]string

fmt.Println(m1 == nil, m2 == nil)   // 输出: true false

fmt.Println(m1, len(m1))            // 输出: map[] 0
fmt.Println(m2, len(m2))            // 输出: map[] 0

创建集合(map)并初始化:

m := map[string]int{
    "one":   1,
    "two":   2,
    "three": 3,
}
fmt.Println(len(m), m)  // 输出: 3 map[one:1 three:3 two:2]
fmt.Println(m["one"])   // 输出: 1

上面代码等价于:

m := make(map[string]int)
m["one"] = 1
m["two"] = 2
m["three"] = 3
fmt.Println(len(m), m)  // 输出: 3 map[one:1 three:3 two:2]
fmt.Println(m["one"])   // 输出: 1

map 的值类型可以是任意数据类型,其中也包含 map,值类型为 map 的复合 map:

m := make(map[string]map[string]string)
m["hello"] = map[string]string{"aa": "AA"}
m["world"] = map[string]string{"bb": "BB"}

fmt.Println(m["hello"])     // 输出: map[aa:AA]
fmt.Println(m)              // 输出: map[hello:map[aa:AA] world:map[bb:BB]]

判断一个 map 是否为空集合,需要使用 len(map) == 0 来判断,不能通过 map == nil 来判断。比如 map[string]int{} 是一个空集合,但值不为 nil

3.3.2 集合(map)的增删改查

map 通过键索引来访问值,通过内置函数 delete(m map[Type]Type1, key Type) 删除元素。

创建一个 map 集合,并添加/更新元素:

m := make(map[string]int)
m["one"] = 1
m["two"] = 2
m["hi"] = 100

获取键对应的值:

fmt.Println(m["one"])       // 输出: 1
fmt.Println(m["two"])       // 输出: 2
fmt.Println(m["three"])     // 如果map中没有对应的键, 则返回值类型的零值, 输出: 0


m["hi"] += 10               // 可以对 map 元素值做赋值运算
m["hi"]++


// 注意: map 元素不是一个变量, 无法对其取地址, 这是因为 map 元素的存储位置不是固定的, 
// 随着 map 的增长, 可能会导致已有元素被重新散列到新的存储位置, 这样之前取的地址就无效了。
p := &m["one"]              // 编译报错, 无法对 map 元素取地址

判断 map 中是否有指定键的元素:

// 取值时返回两个参数, 
// 第一个是键对应的值(如果没有, 则是值类型的零值),
// 第二个是一个布尔类型的值, true 表示有这个键, false 表示没有这个键
v, ok := m["three"]


// 可以写成 if 语句块模式
if v, ok := m["three"]; !ok {
    fmt.Println("three not in map, v == 0")
}

// 注意: 不能通过元素值是否为零值来判断是否有该元素, 因为零值也是元素的合法值。

删除 map 中的元素 delete()

// 删除 map 中键为 "one" 的元素, 如果 map 为 nil 或 key 不存在时也不会崩溃, 而是空操作
delete(m, "one")

向值为 nilmap 添加元素会导致崩溃,值为 nilmaplen() 与空 map 一致。

map 不是并发安全的,如果并发读写 map 可能导致崩溃(并发读取可以)。如果需要并发读写 map,可以额外加上互斥锁,或者使用标准库中的 sync.Map

3.3.3 集合(map)的遍历

使用 for range 循环遍历 map 集合:

m := make(map[string]string)
m["aa"] = "AA"
m["bb"] = "BB"
m["cc"] = "CC"

for k, v := range m {
    // k 是 map 的键
    // v 是键对应的值, v == m[k]
    fmt.Printf("%s: %s\n", k, v)
}
// 输出:
// cc: CC
// aa: AA
// bb: BB

3.3.4 map 实现 set集合

Go (目前)没有类似其他语言的 set 集合,可以通过 map 来实现一个 set 集合(使用 map 的键来存储 set 集合的元素):

// 使用 map[string]bool 类型中的键来表示 set 集合的元素,
// 可以看做是 set[string]
set := make(map[string]bool)

// 添加元素到 set 集合
set["aa"] = true
set["bb"] = true
set["cc"] = true

// 从 set 集合中删除元素
delete(set, "cc")

// 判断 set 集合中是否有指定元素,
// 因为 map 的值是 bool 类型, 而值只使用了 true 表示已添加此值,
// 所有可用直接判断指定键的值是否为 true 来判断 set 集合中是否有此元素
if set["dd"] {
    fmt.Println("dd in set")
} else {
    fmt.Println("dd not in set")
}

// 遍历 set 集合的元素 (只取 map 中的键)
for e, _ := range set {
    fmt.Println(e)
}

3.4 结构体 (struct)

结构体(struct) 是将零个或多个任意类型的变量组合在一起的复合数据类型,其中每一个变量都称作 结构体的成员。结构体成员变量通过 结构体变量.成员变量 的形式访问。

结构体变量的大小是其所有成员变量类型的大小之和。结构体的零值是由其所有成员的零值组成,结构体不是引用类型,赋值操作(包括函数 传递参数 和 接收返回值)是把他的所有成员逐个对应赋值,可以通过指针的形式传递结构体变量。

3.4.1 结构体的定义与创建

使用 type NAME struct { } 定义结构体类型:

// 定义一个结构体类型
type Employee struct {
    Name       string
    Age        int
    Salary     float64
    Addr, Desc string       // 相同类型的属性可以用逗号分隔写在一行内
}

创建结构体变量:

// 声明一个结构体变量, 成员变量值默认为其零值
var e1 Employee
fmt.Printf("%#v\n", e1)
// 输出: main.Employee{Name:"", Age:0, Salary:0, Addr:"", Desc:""}


// 声明结构体变量, 并按成员顺序逐个赋值初始化
e2 := Employee{"Tom", 25, 12345.123, "Guangzhou", "World"}
fmt.Printf("%#v\n", e2)
// 输出: main.Employee{Name:"Tom", Age:25, Salary:12345.123, Addr:"Guangzhou", Desc:"World"}


// 声明结构体变量, 初始化部分成员 (没有初始化值的变量默认为其零值)
e3 := Employee{
    Name: "Cat",
    Age:  30,
    Addr: "Shenzhen",
}
fmt.Printf("%#v\n", e3)
// 输出: main.Employee{Name:"Cat", Age:30, Salary:0, Addr:"Shenzhen", Desc:""}

3.4.2 结构体成员的访问

通过 结构体变量.成员变量 的形式访问结构体变量:

// 定义结构体类型
type Employee struct {
    Name   string
    Age    int
    Salary float64
}


// 创建结构体变量
e := Employee{"Hello", 20, 5000.00}
fmt.Printf("%+v\n", e)                      // 输出: {Name:Hello Age:20 Salary:5000}


// 访问结构体成员
fmt.Printf("%s, %d, %.2f\n", e.Name, e.Age, e.Salary)   // 输出: Hello, 20, 5000.00


// 结构体成员变量和普通变量一样, 可以做赋值运算、取地址 等操作
e.Name = "World"
e.Age += 5
e.Salary += 3000.00
fmt.Printf("%+v\n", e)                      // 输出: {Name:World Age:25 Salary:8000}

p := &e.Age
*p = 30
fmt.Printf("%+v\n", e)                      // 输出: {Name:World Age:30 Salary:8000}

3.4.3 结构体的指针访问

结构体指针变量访问成员变量,可以直接通过 结构体指针.成员变量 的形式访问,编译器自动解引用,结构体指针.成员变量 相当于 (*结构体指针).成员变量

// 定义结构体类型
type Employee struct {
    Name   string
    Age    int
    Salary float64
}

// 创建结构体变量
e := Employee{"Hello", 20, 5000.00}

// 创建结构体指针变量
p := &e
fmt.Printf("%T\n", p) // 输出: *main.Employee

// 结构体指针可以直接访问成员, 会自动隐式解引用, p.Name 相当于 (*p).Name
fmt.Printf("%s, %d, %.2f\n", p.Name, p.Age, (*p).Salary)        // 输出: Hello, 20, 5000.00

p.Name = "World"
p.Age += 10
fmt.Printf("%s, %d, %.2f\n", (*p).Name, (*p).Age, p.Salary)     // 输出: World, 30, 5000.00

3.4.4 结构体的嵌套(类型为结构体的成员变量)

结构体是一种数据类型,也可以作为结构体的成员的类型:

// 定义一个点坐标的结构体
type Point struct {
    X, Y int
}

// 定义一个圆的结构体
type Circle struct {
    Center Point        // 圆心坐标, 数据类型为一个点的结构体
    Radius int          // 半径长度
}


// 声明一个圆的结构体变量, 成员变量默认为其零值
var c1 Circle
fmt.Printf("%#v\n", c1)
// 输出: main.Circle{Center:main.Point{X:0, Y:0}, Radius:0}


// 创建一个圆结构体变量, 并初始化值
c2 := Circle{Point{2, 3}, 5}
fmt.Printf("%#v\n", c2)
// 输出: main.Circle{Center:main.Point{X:2, Y:3}, Radius:5}


// 访问嵌套成员
fmt.Printf("(%d, %d)  %d\n", c2.Center.X, c2.Center.Y, c2.Radius)
// 输出: (2, 3)  5

3.4.5 结构体的匿名成员(结构体成员继承)

Go 允许定义不带变量名称的结构体成员,只要指定数据类型即可,这种结构体成员称为 匿名成员。匿名成员的数据类型必须是一个命名类型或者指向命名类型的指针类型。

// 定义一个点坐标的结构体
type Point struct {
    X, Y int
}

// 定义一个圆的结构体
type Circle struct {
    Point               // 圆心坐标, 只指定类型, 没有变量名称
    Radius int          // 半径长度
}

// 创建一个圆的结构体变量
c := Circle{Point{2, 3}, 5}

// 访问匿名成员的成员 (把类型名称当做成员变量名称访问)
fmt.Println(c.Point.X, c.Point.Y)   // 输出: 2 3

// 访问匿名成员的成员可以省略类型名称, 上句代码相当于
fmt.Println(c.X, c.Y)               // 输出: 2 3

如果本结构体的成员变量名称与匿名成员(结构体类型)的成员变量名称有相同的,优先访问本结构体的成员:

type Point struct {
    X, Y int
}

type DoublePoint struct {
    Point
    X, Y int
}

p := DoublePoint{Point{2, 3}, 5, 6}

// DoublePoint 和 Point 都有成员变量 X, p.X 优先访问当前结构体(DoublePoint)的变量
fmt.Println(p.X)            // 输出: 5

// 如果要访问 Point 中的 X, 需要显式访问
fmt.Println(p.Point.X)      // 输出: 2

如果有多个匿名成员(结构体类型)的成员变量名称有相同的,不能省略类型名称访问,需要显式访问:

type Point1 struct {
    X, Y int
}

type Point2 struct {
    X, Y int
}

type DoublePoint struct {
    Point1
    Point2
}

p := DoublePoint{Point1{2, 3}, Point2{5, 6}}

// 编译报错: 直接 p.X 访问成员 X, 无法确定访问的是 Point1.X 还是 Point2.X
// fmt.Println(p.X)

// 必须显式指定要访问的是哪个匿名成员的变量
fmt.Println(p.Point1.X, p.Point2.X)         // 输出: 2 5

结构体的匿名成员也可以是指针类型:

type Point struct {
    X, Y int
}

type Circle struct {
    *Point              // 匿名成员也可以是指针类型
    Radius int
}

c := Circle{&Point{2, 3}, 5}

// 结构体指针访问成员会隐式解引用,
// c.X 相当于 c.Point.X, 隐式解引用也就是相当于 (*(c.Point)).X
fmt.Println(c.X)        // 输出: 2

结构体匿名成员,实际上就是 Go 面向对象的继承实现方式。同一个结构体内不能有两个类型相同(包括对应的指针类型)的匿名成员,如果需要两个以上的情况,则有必要加上变量名称说明清楚每一个的含义。

3.5 new() 和 make() 函数

内置函数 new()make() 用于创建类型的实例。

  • new() 可以为任何类型预分配内存并初始化为对应类型的零值,返回 指针类型
  • make() (目前)只能创建 切片(slice)集合(map)通道(chan),并且可以为相应类型预分配 长度(len) 和 容量(cap),返回类型的一个实例对象 (非指针)。

两个函数的原型:

  • func new(Type) *Type
  • func make(t Type, size ...IntegerType) Type

new() 函数示例:

type Point struct {
    X, Y int
}
// 动态分配内存创建一个 Point 对象 (初始值默认为零值), 返回 Point 类型的指针
p := new(Point)
fmt.Printf("%T\n", p)       // 输出: *main.Point
fmt.Printf("%#v\n", *p)     // 输出: main.Point{X:0, Y:0}

// 为基本类型预分配内存, 返回类型对应的指针类型
pi := new(int)
*pi += 10
fmt.Printf("%T\n", pi)      // 输出: *int
fmt.Println(*pi)            // 输出: 10

pf := new(float64)
fmt.Printf("%T\n", pf)      // 输出: *float64
fmt.Printf("%.2f\n", *pf)   // 输出: 0.00

// 为数组预分配内存, 返回数组类型的指针
pa := new([5]int)
pa[0] = 123
(*pa)[1] = 456
fmt.Printf("%T\n", pa)      // 输出: *[5]int
fmt.Printf("%v\n", *pa)     // 输出: [123 456 0 0 0]

make() 函数示例:

// 创建一个 map
m := make(map[string]string)
m["aa"] = "AA"
m["bb"] = "BB"
fmt.Printf("%T\n", m)       // 输出: map[string]string
fmt.Println(len(m), m)      // 输出: 2 map[aa:AA bb:BB]

// 创建一个 int 切片, 指定 len=5, cap=10
arr := make([]int, 5, 10)
fmt.Printf("%T\n", arr)                 // 输出: []int
fmt.Println(len(arr), cap(arr), arr)    // 输出: 5 10 [0 0 0 0 0]

// 创建一个 int 类型的通道, 容量(cap)=10
ch := make(chan int, 10)
fmt.Printf("%T\n", ch)      // 输出: chan int
fmt.Println(cap(ch))        // 输入通道容量: 10
ch <- 123                   // 发送数据到通道
fmt.Println(<-ch)           // 从通道接收数据 (应该在另一个 goroutine 中接收), 输出: 123

4. 类型声明

可以使用 type 以已有的数据类型为基础声明新的类型,用于更清晰地描述事物。

4.1 声明 类型别名 和 新类型

type 声明类型分为 声明类型的别名声明新类型。类型的别名在使用时与原类型完全相同,仅在语法上体现为别名形式,比如内置类型 byte 实际是 uint8 的别名。声明的新类型,与原类型的相关的运算操作需要通过转换为相同类型才能操作。

  • 声明 类型别名 格式:type TYPE_ALIAS = TYPE
  • 声明 新类型 的格式:type NEW_TYPE TYPE

声明类型别名:

// 声明一个类型 char 作为 int32 类型的别名, 用于表示一个 Unicode 字符
type char = int32

// 声明一个 char 类型变量, 编译时等号右边的数字隐式转换为对应从 char 类型
var c char = 65
fmt.Printf("%T\n", c)       // 类型别名的类型在运行时还是原类型, 输出: int32
fmt.Println(c)              // 直接输出表现为原类型, 输出: 65
fmt.Printf("%c\n", c)       // 解析为 Unicode 字符, 输出: A

// 编译时等号右边的数字隐式转换为对应从 char 类型
c = '世'
fmt.Printf("%c\n", c)       // 输出: 世

// 显式将数字转换为 char 类型
c = char(97)
fmt.Printf("%c\n", c)       // 输出: a

var a int32 = 3
fmt.Println(a + c)  // 与原类型可以直接运算(因为内存中属于同一种类型), 不需要转换, 输出: 100

声明新类型:

// 定义一个 摄氏温度 的新类型, 底层类型为 float64
type Celsius float64

// 声明一个 Celsius 变量, 默认值是其底层类型的零值
var c1 Celsius
fmt.Printf("%T\n", c1)              // 输出: main.Celsius
fmt.Println(c1)                     // 输出: 0

// 把底层类型显式转换为 Celsius 类型
c2 := Celsius(20.0)
fmt.Printf("%T\n", c2)              // 输出: main.Celsius
fmt.Println(c2)                     // 输出: 20

// 最右边的 5 将被隐式转换为 Celsius 类型后再相加,
// 新类型的数学运算按其底层类型操作, 运算结果转换为 Celsius 类型
c3 := c1 + c2 + 5
fmt.Printf("%T\n", c3)              // 输出: main.Celsius
fmt.Println(c3)                     // 输出: 25

var f1 float64 = 5.0

// 即使 Celsius 的底层类型是 float64 也不能直接做数学运算, 需要显式转换为相同类型 (如果是数字字面量, 会隐式转换)
c4 := c3 + Celsius(f1)
// 也可以将 Celsius 类型转换回其底层类型
f2 := float64(c3) + f1

fmt.Printf("%T\n", c4)              // 输出: main.Celsius
fmt.Printf("%T\n", f2)              // 输出: float64
fmt.Println(c4, f2)                 // 输出: 30 30

相比直接使用底层类型 float64 声明一个摄氏温度变量,定义新类型 Celsius 来表示摄氏温度明显更准确清晰,而且不需要注释说明就能明确知道 Celsius 类型的变量表示的就是一个摄氏温度。而且还可以为新类型添加方法,以实现面向对象操作:

package main

import "fmt"

// Celsius 定义一个 摄氏温度 的新类型, 底层类型为 float64
type Celsius float64

// String 改变 %v 和 print 函数输出变量值时的默认输出形式
func (c Celsius) String() string {
    return fmt.Sprintf("%.2f°C", c)
}

// ToFahrenheit 为 Celsius 类型添加一个方法, 转换为 华氏温度
func (c Celsius) ToFahrenheit() float64 {
    // 华氏度 = 32°F+ 摄氏度 × 1.8
    return 32.0 + float64(c)*1.8
}

func main() {
    c := Celsius(30)
    fmt.Println(c)                          // 输出: 30.00°C

    f := c.ToFahrenheit()
    fmt.Printf("%.2f°C == %.2f°F\n", c, f)  // 输出: 30.00°C == 86.00°F
}

4.2 声明的新类型 与 底层类型 的转换

声明的新类型 底层包装了它对应的 底层类型,两者之间可以相互转换,直接使用 NEW_TYPE(TYPE)TYPE(NEW_TYPE) 的形式互相转换。新类型在进行相关数学运算时与其底层类型相同,但需要转换为相同的类型(显式或隐式转换)。

// 声明新类型
type Celsius float64

// 分别声明新类型和底层类型的两个变量
var c Celsius
var f float64

f = 18.99
c = Celsius(f)      // 新类型 -> 底层类型

f = float64(c)      // 底层类型 -> 新类型

i := int(c)         // 新类型也可以直接转换为底层类型支持的转换类型: 新类型 -> 底层类型 -> int
fmt.Println(i)      // 输出: 18
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
引用\[1\]提供了关于Golang的四大数据类型及基本类型的介绍。其中,基本类型包括数字类型、布尔型和字符串类型。数字类型包括整型和浮点型,还有其他数字类型。布尔型的值只能是常量true或false。字符串类型分为解释字符串和非解释字符串。引用\[2\]进一步解释了Golang的基本类型复合类型、引用类型和接口类型。基本类型包括数字、字符串和布尔型。复合类型包括数组和结构体,用于表示更复杂的数据结构。引用类型包括指针、切片、映射、通道、接口和函数类型。引用类型的变量被称为标头值。字符串也是一种引用类型。引用\[3\]提到了Golang常量的特点。虽然常量可以有确定的基本类型,但许多常量没有明确的基本类型。编译器为这些常量提供了比基本类型更高精度的算术运算。无类型的常量包括无类型的布尔型、无类型的整数、无类型的字符、无类型的浮点数、无类型的复数和无类型的字符串。无类型的常量不仅提供更高的运算精度,还可以直接用于更多的表达式而不需要显式的类型转换。 所以,Golang的基本数据类型包括数字类型、布尔型和字符串类型。同时,Golang还有复合类型、引用类型和接口类型。 #### 引用[.reference_title] - *1* *2* [Golang数据类型](https://blog.csdn.net/MatChen/article/details/114639964)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^insert_down28v1,239^v3^insert_chatgpt"}} ] [.reference_item] - *3* [Golang(六)[基础数据类型]](https://blog.csdn.net/weixin_42366378/article/details/105003056)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^insert_down28v1,239^v3^insert_chatgpt"}} ] [.reference_item] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

谢TS

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值