Go学习之旅

Go使用前提

为什么使用Go

  1. 语法简洁
  2. 效率更高
  3. 生态强大
  4. 高安全性
  5. 严格的依赖管理,go mod命令
  6. 拥有强大的编译检查,具有很强的稳定性
  7. 跨平台
  8. 异步编程复杂度低,易于维护
  9. 支持并发,Go关键字(协程)使得Go并发效率提高
  10. 严格的语法规范
  11. “云原生语言”

Go语言的安装

下载

  • 下载:https://go.dev/dl/
  • https://golang.goole.cn/dl
  • https://studygolang.com/dl

配置环境变量

  1. **GOROOT:**Go语言所在的目录,用于全局执行Go相关的命令

  2. **GOPATH:**工作目录,工程代码存放的位置,此目录下,一个文件夹就是一个工程

  3. **GOPROXY:**代理,由于Go需要翻墙使用,需要配置代理

    地址:https://goproxy.io/zh/ 查看文档

  4. go env 检查环境变量是否配置成功

Go入门案例

  1. 创建一个 hello 的文件夹

  2. 在 hello 文件夹中创建一个 main.go 文件(文本编辑器中编写)

    // package 定义包名 main 
    package main
    
    // import 引用库 fmt 
    import "fmt"
    
    // func 定义函数 main 函数名
    func main(){
    	// fmt 包名 . 调用 Print 函数,并且输出定义的字符串
    	fmt.Print("Hello GoLang From Levin.com")
    }
    
  3. 在 cmd 中输入指令 go env 运行

// 编译
go build 文件名
// 运行
.\ 文件名
// 快速运行
go run 文件名

package(创建包)

Go语言以“包”为管理单位,每个Go的源文件都必须声明它所属的包,其声明如下:

// package--关键字,name--包名
package name 

Go包的注意点:

  • 一个目录下的同级文件属于同一个包
  • 包名可以与目录名不同
  • main 包是Go程序的入口包,每个程序有且只能有一个 main 包,如果一个程序中并不存在 main 包,编译时会出错

import(导入包)

两种方式:

// 第一种--单个包
import "name"
// 第二种 多个包 注意:包后无需加逗号
import {
    "name_01"  
    "name_02"
}

main函数(入口函数)

main 函数只能存在于 main 包中,每个 main 包有且只能有一个 main 函数

Go的编译运行

Go语言是一门编译型的静态语言

两种编译方式:

  • go build,该命令可以将Go语言的代码编译成一个可执行文件(二进制) ,使用该命令,我们需要手动开启程序
  • go run ,该命令对代码进行编译并执行可执行文件
go build [fileName_01] [fileName_02]

初始化项目

go mod init 文件夹

go mod 和 go sum

注意跨包使用函数需要大写!!!

开发工具 Golang

上官网下载并安装即可

Go语言基础

1.格式化

gofmt 程序(go fmt)将Go程序按照标准风格缩进、对齐

标准包中的所有Go代码都已经用 gofmt 格式化过

2.命名

获取器

Go语言对 gettersetter 提供自动支持,大写字母可以跨包使用!


接口名

按照约定,只包含一个方法的接口一般在该方法后加上 “er” 来命名

3.变量声明

Go语言是静态类型语言,变量是有明确类型的,编译器也会检查变量类型的正确性。

从计算机系统的角度来讲,变量就是一段或多段内存,用于存储数据

3.1 标准格式
var 变量名 变量类型

变量声明以关键字var开头,变量类型后置,行尾无须分号

// 声明一个名为 age 的变量,类型为int
var age int

变量命名规则准寻驼峰命名法,即首个字母小写

3.2 基本类型

计算机中数据存储的最小单元为bit(位),0或者1

byte:计算机中数据的基本单元,1字节 = 8bit,数据在计算机中存储或计算,至少为1个字节

  • bool
  • string
  • int(根据系统,一般占用四个字节),int8,int16,int32,int64
  • uint(无符号整数),uint8,uint16,uitn32,uint64,uintpr
  • byte(uint8的别名)
  • rune(int32的别名,代表一个 Unicode 编码)
  • float32,float64
  • complex64,complex128

有符号和无符号的区别:int8 范围为:-128~127,uint8 范围为:0-255

当一个变量被声明后,系统自动赋予该类型的零值

所有的内存在Go中都是有经过初始化的

3.3 不指明变量类型
// 设置游戏中角色的初始等级为1
var level = 1

Go语言中,在编译时会自动推到其类型

//var level float32 = 1.0
func main(){
	fmt.Printf("%T", level)
}

/*
输出结果为:
float32
*/
3.4 批量格式
var(
	a int
	b string
	c []float32
)
package main

import "fmt"

var(
	a int
	b float32
	c string
)
func main(){
	// %d 整数占位符, %s 字符串占位符号, %f 浮点数占位符(默认6为精确度)
	fmt.Printf("a=%d,b=%f,c=%s", a, b, c)
}

3.5 简短格式

使用简短格式有以下限制:

  • 定义变量,同时显式初始化
  • 不能提供数据类型
  • 只能使用在函数内部

简短变量声明被广泛使用于大部分的局部变量的声明和初始化,var形式的声明语句往往用于需要显示指定变量类型的地方

4.初始化变量

可以通过 var 或者短变量来初始化赋值变量

在多个短变量声明和赋值中,至少有一个新声明的变量出现在左值中,即便其他变量名可能是重复声明的,编译器也不会报错


标准格式:

// var 变量名 类型 = 表达式
var level int = 10

编译器自动推导类型的格式:

var level = 10 // 10 -- 右值

短变量声明初始化:

level := 10

注意:由于短变量声明初始化中使用 := 而不是 =,因此此种初始化的左值变量必须是没有定义过的变量,若声明过,则编译错误

5.变量交换

var a int = 10
var b int = 20
// 第一种方式
a = a^b
b = b^a
a = a^b
// 第二种方式 -- 多重赋值
a, b = b, a

6.变量类型

根据变量定义位置的不同,可以分为以下三个类型:

  • 函数内定义的变量称为局部变量
  • 函数外定义的变量称为全局变量
  • 函数定义中的变量称为形式参数

匿名变量

没有名称的变量、类型或方法,统称为匿名变量

匿名变量的特点:一个下划线 “_”,下划线本身就是一个特殊的标识符,陈伟空白标识符

func GetValue() (int, int){
    return 100, 200
}

func main(){
    a, _ := GetValue()
    _, b := GetValue()
    fmt.Println(a, b)
}

匿名变量不占用内存空间,不会分配内存,匿名变量与匿名变量之间也不会因为多次声明而无法使用。


局部变量

局部变量不是一直存在的,它只在定义它的函数被调用后存在,函数调用结束后这个局部变量就会被销毁。


全局变量

全局变量声明必须以 var 关键字开头,如果想要在外部包中使用全局变量的首字母必须大写。


形式参数

形式参数会作为函数的局部变量来使用。

7.数据类型

整数类型

有符号整型:

int8、int16、int32 和 int64

无符号整型:

uint8、uint16、uint32 和 uint64

小数类型

浮点数取值范围的极限值可以在 math 包中找到:

  • 常量 math.MaxFloat32 表示 float32 能取到的最大数值,大约是 3.4e38;
  • 常量 math.MaxFloat64 表示 float64 能取到的最大数值,大约是 1.8e308;
  • float32 和 float64 能表示的最小值分别为 1.4e-45 和 4.9e-324。

浮点数在声明的时候可以只写整数部分或者小数部分,如:

var a = .1234 //0.1234
var b = 1. // 1
复数

Go语言中复数的类型有两种,分别是 complex128(64 位实数和虚数)和 complex64(32 位实数和虚数),其中 complex128 为复数的默认类型。

复数的声明如下:

var value complex128 = complex(a, b) // a和b为float64类型数值

// 获取实部
real(value)
// 获取虚部
imag(value)
布尔类型

布尔类型的值:truefalse

非0即 true

字符串类型

一个字符串是一个不可改变的字节序列,字符串可以包含任意的数据。

字符串是 UTF-8 字符的一个序列(当字符为 ASCII 码表上的字符时则占用 1 个字节,其它字符根据需要占用 2-4 个字节)。

字符串是一种值类型,且值不可变,即创建某个文本后将无法再次修改这个文本的内容,可以说,字符串是字节的定长数组

一般的比较运算符(==、!=、<、<=、>=、>)是通过在内存中按字节比较来实现字符串比较的,因此比较的结果是字符串自然编码的顺序。字符串所占的字节长度可以通过函数 len() 来获取,例如 len(str)

字符串的内容(纯字节)可以通过标准索引法来获取,在方括号[ ]内写入索引,索引从 0 开始计数:

  • 字符串 str 的第 1 个字节:str[0]
  • 第 i 个字节:str[i - 1]
  • 最后 1 个字节:str[len(str)-1]

需要注意的是,这种转换方案只对纯 ASCII 码的字符串有效(因为 ASCII 码的字符只占用一个字节)。


字符串的定义

使用双引号 "" 来定义字符串

package main

import "fmt"

func main(){
    var str = "Hello World!"
    fmt.Println(str)    
}

字符串的拼接

两个字符串拼接可以通过 + 拼接在一起,如:

var str_01 = "Hello "
var str_02 = "World!"
str := str_01+str_02

也可以采用 += 的形式:

str := ""
str00 := "yeah!"
str += str00

字符串的实现

Go中的字符串内部实现是使用 UTF-8 编码,通过 rune 类型(rune ---- uint8),对每个 UTF-8 字符进行访问


多行字符串

字符串字面量:双引号书写字符串,不能跨行

多行字符串需要采用 \ ,如下:

str := `The first line
The Second Line
The Last Line!
\n
`

在多行字符串的方式下,所有的转义字符均无效,字符串会正常输出

字符类型

字符串是由字符组成的,Go语言中的字符有两种:

  • uint8(byte) 类型,表示一个 ASCII码表中的字符
  • rune 类型,代表一个 UTF-8 字符,处理中文等复合字符时,需要 rune 类型,即 uint32 类型

除了 UTF-8 编码外,Go也同样支持 Unicode 编码,在内存中用 int 表示,在文档中,一般以 U+hhhh 的格式表示,h 为一个十六进制数

注意:%c 输出字符,%v%d 输出该字符对应的整数,%U 输出 U+hhhh

Unicode 包中内置了与字符相关的函数,可以查询相关文档使用


UTF-8 为编码方式

Unicode 和 ASCII 都是字符集

UTF-8 的编码方式下,拉丁文的字符编码一般每个字符占用一个字节,中文字符占用3个字节

8.数据类型的转换

Go语言不存在隐式的类型转换

类型转换声明:

// value_01与value_02不同类型
// 类型1的值 = 类型1(类型2的值)
value_01 = type(value_02)

// 实例
value_t1 := 5.0
value_t2 := int(a)

注意:类型转换只能在正确的情况下转换成功,精度高的数值转换成精度低的数值,精度会损失

9.指针

Go语言提供了控制数据结构指针的能力,但不能进行指针运算,Go语言允许你控制特定集合的数据结构、分配的数量以及内存访问模式

指针的两个核心概念

  • 类型指针,允许对这个指针类型的数据进行修改,传递数据可以直接使用指针,无需拷贝数据,类型指针不能进行偏移和运算
  • 切片,由原始指针(指向第一个元素的指针)、元素数量和容量(最大数量)组成

指针地址和指针类型

一个指针变量可以指向任何一个值的内存地址,它所指向的值的内存地址在 32 和 64 位机器上分别占用 4 或 8 个字节,占用字节的大小与所指向的值的大小无关。当一个指针被定义后没有分配到任何变量时,它的默认值为 nil

v := 10
ptr := &v

此处 ptr 的类型为 *int,称为 int 的指针类型

地址的打印通过 %p 表示

var value = 10
ptr := &value
fmt.Printf("%p %p", &value, ptr)

获取指针指向的值

通过 * 来获取指针的值

var str = "Hello World!"

ptr := &str

fmt.Prinln("%s", *ptr)

指针修改值

var a int = 10
var b int = 20
temp := &a
*a = *b
*b = *temp

new()函数可以创建指针

strPtr := new(string)

10.常量

常量的定义使用关键字 const 定义,编译时即被创建

常量的定义:

// const name [type] = vlaue
const name = "String"

iota常量生成器

常量声明可以使用 iota 常量生成器初始化,它用于生成一组以相似规则初始化的常量,但不用每行都写一遍初始化表达式。

在一个 const 声明语句中,在第一个声明常量所在行,iota将会被置为 0,然后在每一个有常量声明所在行加一。


无类型常量

编译器为这些没有明确基础类型的数字常量提供比基础类型更高精度的算术运算,可以认为至少有 256bit 的运算精度。

有六种未明确类型的常量类型,分别是无类型的布尔型、无类型的整数、无类型的字符、无类型的浮点数、无类型的复数、无类型的字符串。

// 无类型常量
var x float32 = math.Pi
var y float64 = math.Pi
var z complex128 = math.Pi
fmt.Printf("x = %v, y = %v, z = %v\n", x, y, z)

const Pi64 float64 = math.Pi
var x2 float32 = float32(Pi64)
var y2 float64 = Pi64
var z2 complex128 = complex128(Pi64)
fmt.Printf("x2 = %v, y2 = %v, z2 = %v\n", x2, y2, z2)

11.类型别名

Go1.9版本前:

type abc uint8

Go1.9版本后:

type abc = uint8

类型定义与类型别名

类型定义:

type MyTime time.Duration

类型别名:

type MyTime = time.Duration

非本地类型不能定义方法,即非本包定义的数据类型

12. 关键字与标识符

Go中的关键字一共有25个

关键字不能作为标识符使用


标识符的命名需要遵守以下规则:

  • 由 26 个英文字母、0~9、_组成;
  • 不能以数字开头,例如 var 1num int 是错误的;
  • Go语言中严格区分大小写;
  • 标识符不能包含空格;
  • 不能以系统保留关键字作为标识符,比如 breakif 等等。

命名标识符时还需要注意以下几点:

  • 标识符的命名要尽量采取简短且有意义;
  • 不能和标准库中的包名重复;
  • 为变量、函数、常量命名时采用驼峰命名法,例如 stuNamegetVal

当然Go语言中的变量、函数、常量名称的首字母也可以大写,如果首字母大写,则表示它可以被其它的包访问(类似于 Java 中的 public);如果首字母小写,则表示它只能在本包中使用 (类似于 Java 中 private)。

13.运算符

优先级分类运算符结合性
1逗号运算符,从左到右
2赋值运算符=、+=、-=、*=、/=、 %=、 >=、 <<=、&=、^=、|=从右到左
3逻辑或||从左到右
4逻辑与&&从左到右
5按位或|从左到右
6按位异或^从左到右
7按位与&从左到右
8相等/不等==、!=从左到右
9关系运算符<、<=、>、>=从左到右
10位移运算符<<、>>从左到右
11加法/减法+、-从左到右
12乘法/除法/取余*(乘号)、/、%从左到右
13单目运算符!、*(指针)、& 、++、–、+(正号)、-(负号)从右到左
14后缀运算符( )、[ ]、->从左到右

Go容器

数组

介绍

数组属于值类型

数组是一个由固定长度的特定类型元素组成的序列,一个数组中的元素可能为0或多个


声明

// var 数组变量名 [元素数量]Type
var data [13]int

默认情况下,数组的每个元素都会被初始化为元素类型对应的零值,对于数字类型来说就是 0,同时也可以使用数组字面值语法,用一组值来初始化数组:

var data [2]int = [2]int{1, 2}
var data2 [3]int = 3[int]{1, 2}

在数组的定义中,如果在数组长度的位置出现“…”省略号,则表示数组的长度是根据初始化值的个数来计算,如下:

data := [...]int{1, 2, 3, 4}

注意:数组的长度需要在编译阶段请确定,长度必须是常量表达式


数组的比较

只有两个数组的类型相同,才可以进行比较[类型指的是元素类型及数组长度]

可以通过 ==!= 判断两个数组是否相对


多维数组

声明如下:

// var 数组名称 [size_01][size_02]...[size_n]Type
var data [4][2]int

data = [4][2]int{{1, 2}, {2, 3}, {3, 4}, {4, 5}}

切片

介绍

切片是对数组的一个连续片段的引用,属于引用类型

切片的内部结构包含:地址、大小、容量


切片的获取

// slice [start:end]
a := [4]int{1, 2, 3, 4}
a[0:2]

生成切片的特性

  • 取出的元素数量为:结束位置 - 开始位置;
  • 取出元素不包含结束位置对应的索引,切片最后一个元素使用 slice[len(slice)] 获取;
  • 当缺省开始位置时,表示从连续区域开头到结束位置;
  • 当缺省结束位置时,表示从开始位置到整个连续区域末尾;
  • 两者同时缺省时,与切片本身等效;
  • 两者同时为 0 时,等效于空切片,一般用于切片复位。

切片的声明

// var name []Type
var str []string

切片的创建----make

通过 make() 可以动态地创建一个切片

// make([]Type, size, cap)
s := make([]int, 10, 12)

切片添加元素----append

可以通过 append() 为切片动态添加元素,如下:

var array []int
array = append(array, 1)
array = append(array, 1, 2, 3, 4)
array = append(array, []int{1, 2, 3}...)

切片复制

内置函数 copy() 可以实现切片的复制

copy() 的使用方法如下:

copy(dest [] Type, src []Type)int

注意:切片复制不是引用,改变副本值不会改变原有切片的值


切片的删除

切片的删除可以通过移动数据指针或者覆盖数据来实现

即通过改变的起始引用位置来实现

注意:切片的删除效率 较低,当业务需要大量的删除操作,一般不使用切片


range关键字

通过 range 来配合 for 实现迭代,直接上例子:

s := []int{1, 2, 3}
for i, v in range s{
    fmt.Println(v)
}

for _, v in range s{
    fmt.Println(v)
}

多维切片

声明:

// var slice [][]...[]Type
slice := [][]int{{10}, {10, 20}}
slice[0] = append(slice[0], 20)

映射

map 是引用类型

map 是一种元素对的无序集合,一个元素由一个 key 和一个 value 组成


声明和创建

声明,无需指定长度,map 类型是可以自动增长

var name map[keyType]valueType
// 键值的类型之间允许有空格
var m1 map[string] string

m1 = map[string]string{"Name": "Levin", "Age": "21"}

创建的方法

m := map[string] int{}
m := make(map[string] int)

容量

map 可以动态的伸缩,不存在固定长度或者最大限制,但可以设置 map 的初始容量 capacity

// make(map[keyType] valueType, capacity)
m := make(map[string] string, 10)

一对多情况

用切片作为 map 的值

当一个应用需要一个 key 对应多个 value 时,可以将 value 设置为数组类型

m1 := make(map[string] []int)
m2 := make(map[string] *[]int)

map 的遍历

直接上例子

m := map[string] int{"First": 1, "Second": 2, "Third": 3}
for k, v := range m{
    fmt.Println(k, v)
}

map 的删除清空

内置函数 delete() 可以用于删除容器的元素

// delete(map, key)

m := map[string] int{"abc": 123, "i": 1}
delete(m, "i")

清空 map 的唯一方法就是 make 一个新的 map

m := map[string] int{"abc": 123, "i": 1}

m = make(map[string] int)

sync.Map

在并发情况下,map 在只读条件下是线程安全的,在读写状态下是不安全的

sync.Map 有以下特性:

  • 无须初始化,直接声明即可
  • sync.Map 不能使用 map 的方式进行取值和设置等操作,而是使用 sync.Map 的方法进行调用,Store 表示存储,Load 表示获取,Delete 表示删除
  • 使用 Range 配合一个回调函数进行遍历操作,通过回调函数返回内部遍历出来的值,Range 参数中回调函数的返回值在需要继续迭代遍历时,返回 true,终止迭代遍历时,返回 false

上例子:

var sMap sync.Map

// 保存 Store
sMap.Store("Green", 1)
sMap.Store("Red", 2)
sMap.Store("Yellow", 3)

// 获取 Load
sMap.Load("Green")

// 删除 Delete
sMap.Delete("Green")

// 遍历 匿名函数--回调函数,参数类型为 interface{}
sMap.Range(func(k, v interface{})) bool{
    fmt.Prinln(k, v)
    return true
})

列表

列表是一种非连续的存储容器,由多个节点组成,节点通过一些变量记录彼此之间的关系,列表有多种实现方法,如单链表、双链表等。


初始化列表

list 的初始化有两种方法:

  • 使用 New() 函数
  • 使用 var 关键字
// 第一种
name := list.New()
// 第二种
var name list.List

插入元素

双链表支持队列前后方插入元素,方法为:PushFront()PushBack()

这两个方法都会返回 list.Element 结构

l := list.New()

l.PushFront("Hello ")
l.PushBack("World!")
方 法功 能
InsertAfter(v interface {}, mark * Element) * Element在 mark 点之后插入元素,mark 点由其他插入函数提供
InsertBefore(v interface {}, mark * Element) *Element在 mark 点之前插入元素,mark 点由其他插入函数提供
PushBackList(other *List)添加 other 列表元素到尾部
PushFrontList(other *List)添加 other 列表元素到头部

删除元素

直接上示例

l := list.New()

l.PushBack("Green")
e := l.PushBack("Hello")

l.InsertBefore("Green", e)

// 删除
l.Remove(e)

列表的遍历

l := list.New()

l.PushBack("World!")

l.PushFront("Hello ")

// Front获取队列首个元素
for i := l.Front(); i != nil; i = i.Next(){
    fmt.Println(i.value)
}

空值和零值

nil 是Go语言中的一个预定义的表示夫,它与其他语言的 null 有些许区别

nil 的特点:

  • nil 标识符不能比较
  • nil 不是关键字或保留字
  • nil 没有默认类型
  • 不同类型的 nil 是不一样的
  • 不同类型的 nil 占用的内存大小不同

Go函数

函数声明

函数的基本组成:关键字 func,函数名,参数列表,返回值,函数体,返回语句

Go语言中的三种类型的函数:

  • 普通函数
  • 匿名函数/lambda函数
  • 方法

普通函数

fun 函数名(形参列表)(返回值列表){
    函数体
}

例子:

func testFunc(a, b int, c, d string){
    fmt.Prinln(a, b, c, d)
}

func testFunc2() (int int){
    return 1, 2
}

func testFunc3() (a, b int){
    a = 1
    b = 2
    return 
}

函数变量

在Go中,函数也是一种类型,可以保存在变量中

func getF(){
    fmt.Prinln("You Guess!")
}

func main(){
    var f func()
    
    f = getF()
    
    f()
}

匿名函数

定义

func (参数列表) (返回参数列表){
    函数体
}

匿名函数的定义使用:

  • 定义时调用
  • 将匿名函数赋值给变量

匿名函数作回调函数

func testFunc(a []int, f func(int)){
    for _, v := range a{
        f(v)
    }
}

func main(){
    testFunc([]int{1, 2, 3}, func(a int){
        fmt.Prinln(a)
    })
}

匿名函数实现封装

map 封装函数


函数与接口

函数的声明不能直接实现接口,需要将函数定义为类型后,使用类型设计结构体

闭包

Go语言中的闭包 = 函数+引用环境

函数参数

可变参数

可变参数:函数传入的参数个数可变

func testFunc(args ...int){
	for _, arg := range args{
		fmt.Println(arg)
	}
}

// 函数调用
func(1, 2, 3)
func(10, 20)

任意类型的可变参数

想要将函数的参数改为任意类型,只需要将类型指定为 interface{}

func testFunc(args ...interface{}){
    
}

defer 语句

defer 语句即将它所跟随的语句进行延迟处理,先被 defer 的语句最后被执行,最后被 defer 的语句最早执行

fmt.Println("Hello World!")

defer fmt.Prinln("C/C++")

defer fmt.Prinln("Python")

defer fmt.Prinln("GoLang")

defer fmt.Prinln("Java")

/*
执行顺序如下:
Java
GoLang
Python
C/C++
*/

使用 defer 在函数退出时释放资源

  • 使用延迟并发解锁

  • 使用延迟释放文件

异常/错误

Go语言的错误处理思想及设计包含以下特征:

  • 一个可能造成错误的函数,需要返回值中返回一个错误接口(error),如果调用是成功的,错误接口将返回 nil,否则返回错误。
  • 在函数调用后需要检查错误,如果发生错误,则进行必要的错误处理。

错误接口的定义

error 是Go声明的接口类型

type error interface{
    Error() string
}

自定义错误

在Go语言中,使用 errors 包进行错误的定义,方式如下:

var err = errors.New("This is an error of program!") // 参数为报错信息

宕机(panic)

一般而言,当宕机发生时,程序会中断运行,并立即执行在该 goroutine(可以先理解成线程)中被延迟的函数(defer 机制)。随后,程序崩溃并输出日志信息,日志信息包括 panic value 和函数调用的堆栈跟踪信息,panic value 通常是某种错误信息。


手动触发宕机

Go程序在宕机时,会将堆栈和 goroutine 信息输出到控制台

func main(){
    panic("宕机")
}

/*
panic: 宕机
goroutine 1 [running]:
main.main()
*/

func panic(v interface{}}) // 参数可以是任意类型

宕机时出发延迟执行语句

程序 panic() 触发宕机发生时,panic() 后的代码不会被运行

宕机恢复(recover)

recover 是Go的一个内置函数,可以让 goroutine 从宕机状态中恢复过来,recover 只在延迟函数 defer 中有效,在一般情况下,调用 recover 返回 nil


panicrecover 的关系

  • panicrecover,程序宕机。
  • panic 也有 recover,程序不会宕机,执行完对应的 defer 后,从宕机点退出当前函数后继续执行。

函数执行时间

函数的运行时间是一个函数性能的指标,在Go语言中,可以使用 time 中的 Since() 函数来获取函数的运行时间

func Since(t Time) Duration
// time.Now().Sub(t)

func testFunc(){
    begin := time.Now()
    
    fmt.Println("Hello World!")
    
    cost := time.Since(begin)
    // cost := time.Now().Sub(begin)
    fmt.Println(cost)
}

Test功能

Go语言中提供 testing 包实现单元测试功能

测试需要:

  • 命名文件需要以 _test.go 结尾

  • 单个测试源文件可以有多个函数,每个测试函数需要以 Test 为前缀

编写测试用例的注意点:

  • 测试用例文件不会参与正常源码的编译,不会被包含到可执行文件中;
  • 测试用例的文件名必须以 _test.go 结尾;
  • 需要导入 testing 包;
  • 测试函数的名称要以 TestBenchmark 开头,后面可以跟任意字母组成的字符串,但第一个字母必须大写,例如 TestFunc007(),一个测试用例文件中可以包含多个测试函数;
  • 单元测试则以 (t *testing.T) 作为参数,性能测试以 (t *testing.B) 做为参数;
  • 测试用例文件使用 go test 命令来执行,源码中不需要 main() 函数作为入口,所有以 _test.go 结尾的源码文件内以 Test 开头的函数都会自动执行。

单元测试

// 
package test

func Get(v int) int {
	return v
}

//
package test

import "testing"

func TestGet(t *testing.T) {
	v := Get(1)

	if v != 2 {
		t.Error("测试failed")
	}
}

//
package test

import "testing"

func TestGet(t *testing.T) {
	v := Get(1)

	if v != 1 {
		t.Error("测试failed")
	}
}

// 命令行输入 go test -v

性能测试

//
func BenchmarkGet(b *testing.B) {
	for i := 0; i < b.N; i++ {
		Get(i)
	}
}

// 命令函输入 go test -bench="."


覆盖率测试

覆盖率测试能知道测试程序总共覆盖了多少业务代码

# 命令行参数
go test -cover

Go结构体

结构体定义

结构体是一种自定义的新类型

结构体可以通过使用 new 来创建实例 [指针]

结构体的特性:

  • 每个字段有类型和值
  • 字段名必须唯一
  • 字段类型可以是基本数据类型,也可以是结构体类型
type 结构体名称 struct{
    字段名称 字段类型
    字段名称 字段类型
    ...
}

// 
type Person struct{
    Name string
    Sex string
}

// 
type Color struct{
    Red, Green byte
}

结构体实例化

结构体的定义是一种内存布局的描述,当结构体实例化时,才会分配内存


实例化格式

结构体时一种类型,通过 var 的方式声明结构体即可创建实例

// T--结构体,t--结构体实例
var t T
type Person struct{
    Name string
    Sex string
}

var p Person
p.Name = "Levin"
p.Sex = "male"

创建指针类型的结构体

Go语言中,可以使用 new 关键字对类型进行实例化,实例化后会生成指针类型的结构体

t := new(Person)

通过 new 创建的结构体实例,虽然类型为指针类型,但访问依旧可以通过 t.XXX 访问,Go采用了语法糖技术,将 t.xxx 的形式 (*t).xxx


取地址实例化

t := &T{}

type Person struct{
    Name string
    Sex string
}

p := &Person{}
p.Name = "Levin"
p.Sex = "male"

初始化成员变量

初始化有两种形式

  • 字段 “键值对” 形式
  • 多个值的列表形式

键值对初始化

//
t := 结构体名{
    字段一: 字段一的值, // 注意此处有逗号
    字段二: 字段二的值,
	...
}

多个值的列表初始化

// 
t := 结构体名{
    字段一的值,  // 注意此处有逗号
    字段二的值,
    ...
}

此种初始化方式的注意点:

  • 必须初始化所有字段

  • 每个初始值的填充顺序不得改变

  • 键值对与此处初始化方式不能混用


匿名结构体

type 结构体名 struct{
    xxx xxxType
    xxx xxxType
    xxx xxxType
    ...
}{
    // 初始化
    xxx: xxxValue,
    xxx: xxxValue,
    xxx: xxxValue,
    ...
}

类型内嵌/结构体内嵌

结构体可以包含一个或多个匿名(即内嵌)字段,此时类型就是该字段的名字

type Point struct{
    X int
    Y int
}

type Line struct{
    Point
}

l := new(Line){
    Point{1, 1}
}

/*
l := new(Line)
l.Point.X = 1
l.Point.Y = 1
*/

结构体内嵌的特性:

  • 内嵌的结构体可以直接访问成员变量
  • 内嵌结构体的字段名是它的类型名

初始化内嵌匿名结构体

type Point struct{
    X, Y int
}

type Line struct{
    Point
    Point2 struct{
        X, Y int
    }
}

l := new(Line){
    Point{1, 1},
    struct{
        X, Y int
    }{
        2,
        2,
    }
}

内嵌结构体成员名字冲突

当名字发生冲突时,编译器会编译失败,zer`

Go语言自带垃圾回收机制(GC)。

GC 通过独立的进程执行,它会搜索不再使用的变量,并将其释放。需要注意的是,GC 在运行时会占用机器资源。

GC 是自动进行的,如果要手动进行 GC,可以使用 runtime.GC() 函数,显式的执行 GC。显式的进行 GC 只在某些特殊的情况下才有用,比如当内存资源不足时调用 runtime.GC() ,这样会立即释放一大片内存,但是会造成程序短时间的性能下降。

finalizer(终止器)是与对象关联的一个函数,通过 runtime.SetFinalizer 来设置,如果某个对象定义了 finalizer,当它被 GC 时候,这个 finalizer 就会被调用,以完成一些特定的任务,例如发信号或者写日志等。

注意:终止器只会在对象被 GC 时执行

设置终止器的函数:

func SetFinalizer(x, f interface{})
/*
x -- 通过new申请的对象指针/通过取址得到的指针
f -- 函数,接收x类型的参数
*/

runtime.SetFinalizer 函数只会在 x 不能使用的任意时间被调用执行

Go接口

声明和定义

接口类型是对其它类型行为的抽象和概括

接口类型不会和特定的实现细节绑定在一起,通过这种抽象的方式我们可以让我们的函数更加灵活和更具有适应能力

接口是双方约定的一种合作协议,接口实现者不需要关心接口会被怎样使用,调用者也不需要关心接口的实现细节。

接口是一种类型,也是一种抽象结构,不会暴露所含数据的格式、类型及结构。


声明

type 接口名 interface{
    方法名(参数列表) 返回值列表
    方法名(参数列表) 返回值列表
    方法名(参数列表) 返回值列表
    ...
}

// 
type Writer interface{
    Write(p []byte) (n int, err error)
}

// 类比 toString()
type Stringer interface {
    String() string
}

接口的实现

Go语言中没有 implements 关键字,编译器在需要时候会自动检查两个类型的实现关系

接口被实现的条件:

  • 接口的方法与实现接口的类型方法格式一致
  • 接口中所有方法均被实现

类型与接口的关系

在Go语言中类型和接口之间有一对多和多对一的关系

一个类型可以实现多个接口

一个类型可以同时实现多个接口,而接口间彼此独立,不知道对方的实现。

多个类型可以实现相同的接口

断言

类型断言是使用在接口值上的操作,用于检查接口类型变量的值是否实现了具体的类型

value, ok := i.(T) 
/*
i -- 接口
T -- 具体类型
返回值为i和布尔值
*/

接口与类型的转换

Go语言可以使用接口断言实现将一个接口转为另一个接口或类型

空接口类型

空接口是接口类型的特殊形式,空接口没有任何方法,因此任何类型都无须实现空接口。从实现的角度看,任何值都满足这个接口的需求。因此空接口类型可以保存任何值,也可以从空接口中取出原值。


将值保存到空接口中

var a int = 10

var ii interface{}

ii = a

从空接口获取值

var a int = 10
var ii interface{} = a
// var b int = ii 编译错误,不能直接赋值
var b int = ii.(int) // 需要使用断言

空接口值的比较

  • 空接口比较直接使用 == 即可比较

  • 类型不同的空接口的值比较结果不同

  • 不能比较空接口中的动态值 [map、切片]

类型分支

type-switch 流程控制

switch 接口变量.(type){
    case 类型:
    	// code
    case 类型:
    	// code
    case 类型:
    	// code
    ...
    default:
    	// code
}

error接口

error 接口中有一个方法 Error() string,所有实现该接口的类型都可以作为一个错误类型

创建一个 error 实例,通过调用 errors.New() 函数,创建一个实例


自定义错误类型

通过调用 Error() string 方法自定义错误信息

Go包

基本概念

Go语言是使用包来组织源代码的,包(package)是多个 Go 源码的集合,是一种高级的代码复用方案。Go语言中为我们提供了很多内置包,如 fmtosio 等。

任何源代码文件必须属于某个包,同时源码文件的第一行有效代码必须是package pacakgeName 语句,通过该语句声明自己所在的包。

一般情况下,包名即源文件所在目录名,包的用法如下:

  • 一般情况下,包名是小写的

  • 一般情况下,包名与所在目录同名

  • 包名不能包含 - 等特殊符号

  • 包一般使用域名作为目录名称 [同Java]

  • 包名为 main 的包为程序入口包,编译时若不包含 main 包的源文件不会得到可执行文件

  • 同一个目录下的源文件同属一个包


包的导入

// 单行导入
import "fmt"

// 多行导入
import(
	"fmt"
    "errors"
)

// 全路径导入
// 相对路径导入

包的引用

import "fmt"

fmt.Println("Hello World!")

// 自定义别名引用格式
import F "fmt"
F.Println("Hello World!")

// 省略引用格式
import . "fmt"
Println("Hello World!")

// 匿名引用格式
// 引用某个包只是为了执行包初始化的init函数,可以采取使用匿名引用格式
import _ "fmt"
  • 一个包可以有多个 init 函数,包加载时会执行所有的 init 函数
  • 执行 init 函数时不保证执行顺序
  • 不能存在循环引用包的情况
  • 运行包的重复引用,且编译器会保证重复引用的包的 init 函数只执行一次

包加载

Go语言包的初始化有如下特点:

  • 包初始化程序从 main 函数引用的包开始,逐级查找包的引用,直到找到没有引用其他包的包,最终生成一个包引用的有向无环图。
  • Go 编译器会将有向无环图转换为一棵树,然后从树的叶子节点开始逐层向上对包进行初始化。
  • 单个包的初始化过程先初始化常量,然后是全局变量,最后执行包的 init 函数。

包的封装

在Go语言中的封装也与其他语言的封装类似,只是实现方法不同,其他语言有关键字来声明属性的访问权限,Go语言通过字段名称的首字母来实现

实现:

  • 将结构体及保护的属性的名称首字母小写(类似私有)
  • 提供一个工厂模式的函数创建结构体实例(类似构造)
  • 设置首字母大写的 Set 方法
  • 设置首字母大写的 Get 方法

GOPATH

GOPATH 是 Go语言中使用的一个环境变量,它使用绝对路径提供项目的工作目录。

GOPATH 处理大量Go源码、多个包组合而成的复杂工程


GOPATH的工程结构

在GOPATH指定的工作目录下,代码总会保存在 GOPATH/src 的目录下,在编译后将二进制可执行文件放在 GOPATH/bin 的目录下,生成的缓存文件保存在 GOPATH/pkg 下。

包的导入

使用 import 将包导入


程序启动前包初始化入口:init

init() 函数的特性如下:

  • 每个源码可以使用 1 个 init() 函数。
  • init() 函数会在程序执行前(main() 函数执行前)被自动调用。
  • 调用顺序为 main() 中引用的包,以深度优先顺序初始化。

go mod包依赖管理工具

go module 是Go语言从 1.11 版本之后官方推出的版本管理工具,并且从 Go1.13 版本开始,go module 成为了Go语言默认的依赖管理工具。


Moudules

Modules是Go包的集合,是源代码交换和版本控制的单元

使用:设置 GO111MODULE

# 禁用go module,编译时会从 GOPATH 和 vendor 文件夹中查找包;
GO111MODULE=off
# 启用go module,编译时会忽略 GOPATH 和 vendor 文件夹,只根据 go.mod下载依赖
GO111MODULE=off
# 当项目在 GOPATH/src 目录之外,并且项目根目录有 go.mod 文件时,开启 go module
GO111MODULE=auto #(默认值)

# Windows
set GO111MODULE=on
set GO111MODULE=auto
# Linux
export GO111MODULE=on 
export GO111MODULE=auto

go mod 常用命令:

  • go mod download:下载依赖包到本地
  • go mod edit:编辑 go.mod 文件
  • go mod init:初始化当前文件夹,创建 go.mod 文件
  • go mod tidy:增加缺少的包,删除无用包

GOPROXY

代理服务器,由于服务器在国外,无法直接通过 go get 方法,故需要设置代理,两个常用代理服务器:

  • goproxy.io
  • goproxy.cn

使用go get命令下载指定版本的依赖包

执行go get 命令,在下载依赖包的同时还可以指定依赖包的版本。

  • 运行go get -u命令会将项目中的包升级到最新的次要版本或者修订版本;
  • 运行go get -u=patch命令会将项目中的包升级到最新的修订版本;
  • 运行go get [包名]@[版本号]命令会下载对应包的指定版本或者将对应包升级到指定的版本。

Go并发

基本概念

并发/并行

多线程程序在单核心的 cpu 上运行,称为并发;多线程程序在多核心的 cpu 上运行,称为并行。

协程/线程

协程:独立的栈空间,共享堆空间,调度由用户自己控制,本质上有点类似于用户级线程,这些用户级线程的调度也是自己实现的。

线程:一个线程上可以跑多个协程,协程是轻量级的线程


Goroutine

Goroutine是一种轻量级的线程实现,是Go语言设计的核心

Go语言内部实现了 Goroutine 之间的内存共享

使用 go 关键字就可以创建 Goroutine,将 go 声明放到一个需调用的函数之前,在相同地址空间调用运行这个函数,这样该函数执行时便会作为一个独立的并发线程,这种线程在Go语言中则被称为 Goroutine。


并发通信

在工程中,两种最常用的并发通信模型:共享数据和消息(队列)

Go语言中提供的是通信模型,以消息机制作为通信方式

消息机制认为每个并发单元是自包含的、独立的个体,并且都有自己的变量,在不同并发单元间变量不共享。

Go中的消息通信机制被称为 channel

竞争状态

在并发的程序中,存在资源竞争,如果两个或多个 goroutine 在没有互相同步的情况下,访问某个共享的资源,如并发中最常见的读写问题,就会处于竞争的状态,这就是资源竞争。

// 上锁
var (
	count int
	lock  sync.Mutex
)

func PrimeNumber(n int) {
	for i := 2; i < n; i++ {
		if n%i == 0 {
			return
		}
	}
	fmt.Printf("%v\t", n)
	// 写操作上锁
	lock.Lock()
	count++
	// 写操作完成后解锁
	lock.Unlock()
}
func Goroutine() {
	for i := 2; i < 100001; i++ {
		// PrimeNumber(i)
		// 开启协程
		go PrimeNumber(i)
	}
	var key string
	fmt.Scanln(&key)
	fmt.Printf("一共有%v个素数", count)
}

注:此处已经用锁 mutex 实现了同步,若不加锁,会导致读写操作的结果被覆盖

对于同一个资源的读写必须是原子化的,即同一时刻只运行有一个 goroutine 对共享资源进行访问


同步机制

Go语言中的 atomicsync 包中的函数可以对共享资源加锁


原子函数

Go语言中存在原子函数,能够以低层的加锁机制来同步访问整型变量和指针(类比Java中的集合)

import "sync/atomic"

var c = 0;
// 安全对变量c进行操作,不会发生覆盖的情况
atomic.AddInt64(&c, 1)

// 
atomic.LoadInt64(&c)
// 
atomic.StoreInt64(&c, 10)

互斥锁

除了使用原子函数外,还可以使用互斥锁对资源进行同步

// 上锁
var (
	count int
	lock  sync.Mutex
)

func PrimeNumber(n int) {
	for i := 2; i < n; i++ {
		if n%i == 0 {
			return
		}
	}
	fmt.Printf("%v\t", n)
	// 写操作上锁
	lock.Lock()
	count++
	// 写操作完成后解锁
	lock.Unlock()
}
func Goroutine() {
	for i := 2; i < 100001; i++ {
		// PrimeNumber(i)
		// 开启协程
		go PrimeNumber(i)
	}
	var key string
	fmt.Scanln(&key)
	fmt.Printf("一共有%v个素数", count)
}

并发的运行性能

Go程序运行时实现了一个小型的任务调度器,可以通过 runtime.GOMAXPROCS() 函数维护线程池中的线程与CPU核心数量的关系:

// 
runtime.GOMAXPROCS(逻辑CPU数量)

/*
<1:不改变任何数值
==1:单核执行
>1:多核并发执行
*/

// 
runtime.GOMAXPROCS(runtime.NumCPU())
// runtime.NumCPU()获取CPU数量

并发与并行

  • 并发(concurrency):把任务在不同的时间点交给处理器进行处理。在同一时间点,任务并不会同时运行。
  • 并行(parallelism):把每一个任务分配给每一个处理器独立完成。在同一时间点,任务一定是同时运行。

Go语言在 GOMAXPROCS 数量与任务数量相等时,可以做到并行执行,但一般情况下都是并发执行。

sync包与锁

Go语言中的 sync 包提供了互斥锁 Mutex 和读写锁 RWMutex

锁是 sync 中的核心,其主要实现依托两个方法:加锁(Lock)、解锁(Unlock


Mutex锁

// 加锁
func (m *Mutex)Lock()
// 解锁
func (m *Mutex)Unlock()

RWMutex锁

经典的单写多读模型

// 写操作锁
func (*RWMutex)Lock
func (*RWMutex)Unlock
// 读操作锁
func (*RWMutex)RLock
func (*RWMutex)RUnlock

两者的区别:

  • 当一个 Goroutine 获得写锁,其他 Goroutine 的上锁操作(写锁、读锁)都会被阻塞
  • 当一个 Goroutine 获得读锁,其他 Goroutine 可以获得读锁

即:

  • 只能有一个 Goroutine 同时获得写锁;
  • 允许同时有多个 Goroutine 获得读锁

通道channel

channel 是一个通信机制,可以让一个 goroutine 向另一个 goroutine 发送值信息,channel 的类型是不确定的,根据需要传递的值的类型来设置其数据类型,如:chan int


通道的特性

Go中的 channel 是一种特殊的类型,同锁一样,每时刻只能有一个 Goroutine 访问通道进行数据的发送或获取

通道遵循先入先出的原则,保证消息的顺序


通道的声明

var 通道变量名称 chan 通道类型
// chan的空值为nil,需要配合make使用

通道的创建

通道实例名称 := make(chan 数据类型)

//
c1 := make(chan int)
c2 := make(chan interface{}) // 由通道数据类型是空接口,故可以存放任意的数据类型

发送数据

channel 发送信息通过操作符 <-,如下:

通道变量名称 <-//
c := make(chan int)
c <- 1
c_new := make(chan interface{})
c <- "Hello World!"

注:当数据往 channel 发送时,一直没有接收,此时发送操作会阻塞


接收数据

channel 接收信息同样通过操作符 <-,如下:

//
c := make(chan int)
c <- 0
a := <- c

// TODO 此种方式会造成很高的CPU占用,故一般不采用此种方法,而采用另一种方法 selet channel
c := make(chan int)
c <- 10
data, ok := <- c
if ok{
    fmt.Println(data)
}else{
    fmt.Println("Error!")
}

//
c := make(chan int)
c <- 10
<- c

注:

  • 通道的发送和接收在两个不同的 Goroutine 实现
  • 如果一直没有对 channel 发送信息,接收端持续阻塞
  • 每次只接收一个元素

循环接收

通道的数据可以通过 for...range 语句实现多个元素的接收:

for data := range ch{
    fmt.Println(data)
}

单向通道

Go语言中提供了单方向的 channel 类型,单向 channel 只能用于写入或读取一个功能


单向通道的声明

当一个函数只对 channel 进行读取或者只进行写入时,可以将该通道参数设置为单向通道类型,从而限制该函数对该 channel 的操作

// 只能写入
var 通道变量名 chan <- 数据类型

// 只能读取
var 通道变量名 <- chan 数据类型

// 赋值初始化
c := make(chan int)
var chSendOnly chan <- int = c
var chRecvOnly <- chan int = c

// 也可以创建只读或只写的通道,但实际开发这样子的做法毫无作用

管道的关闭

close(ch) // ch为channel实例

// 判断是否关闭
v, ok := <- ch

无缓冲的通道

无缓冲的通道,顾名思义即没有缓存池,需要接收方和发送方都做好准备

ch := make(chan int) // 即创建一个无缓冲的通道

有缓冲的通道

Go语言中的缓存通道能够在接受前存储一个或多个值,该种通道只有在缓存区存放数据的空间接收方才会阻塞

有缓冲和无缓冲通道最大的区别为:

  • 无缓冲通道保证发送和接收的 Goroutine 会在同一时间进行消息传递/数据传输
  • 有缓冲通道无法保证

带缓冲通道的创建

//
通道变量名 := make(chan 数据类型, 缓冲大小)

//
ch := make(chan int, 10)
fmt.Println(len(ch)) // 通过内置函数查看当前通道的大小
ch <- 1
ch <- 5
ch <- 10
fmt.Println(len(ch)) 
/*
0
3
*/

带缓冲通道的阻塞

带缓冲通道在以下情况下会发生阻塞:

  • 缓冲区被填满,发送方再次发送数据时会发生阻塞
  • 缓冲区为空,接收端尝试拿取数据时发生阻塞

select语句

channel 机制

Go语言不提供直接对超时的处理机制,但能够通过 select 语句来设置超时


select 语句

select 语句和 switch 语句的语法相似,但 select 语句中最大的区别是每个 case 语句里必须是一个IO操作,即每个 case 都必须是一个通信

select{
    case <- chan_01: //if get the data from chan_01, then go to code1
    	// code1
    case chan_02 <- 10: //if write the data to chan_01, then go to code2
    	// code2
    default:
    	//
}

select 语句中,Go语言会从第一个 case 语句执行到最后一个

如果存在多个 case 语句都满足,那么从这些可执行的语句中任选一条进行使用

如果不存在任意一个 case 语句满足,即有以下情况:

  • 设置了 default 语句,此时会执行 default 语句
  • 没有设置 default 语句,此时 select 语句被阻塞,等待有一个通道不再被阻塞

等待组

Go语言除了使用 channel 和互斥锁来实现两个并发进程的同步外,还提供了等待组(WaitGroup),存在于 sync 包中,每个等待组内部维护一个计数器,该计数器的默认值为0

WaitGroup 中的常用方法:

(wg *WaitGroup)Add(delta int) // 计数器+1
(wg *WaitGroup)Done() // 计数器-1
(wg *WaitGroup)Wait() // 当计数器不为0时,发起阻塞,直到计数器为0

对于一个 WaitGroup 的实例 wg

  • 可以通过 wg.Add(delta) 来改变 wg 中计数器的值
  • wg.Add(-1)wg.Done() 等价
  • 如果调用方法使得计数器的值小于0,会产生一个恐慌
  • 当一个 Goroutine 调用 wg.Wait(),会出现两种情况:
    • 如果此时计数器的计数值为0,此操作相当于空操作(noop)
    • 如果此时计数器的计数值不为0,该 Goroutine 进入阻塞状态,当其他 Goroutine 通过方法的调用使得计数为0时,该 Goroutine 重新进入运行状态

死锁/活锁

死锁

死锁是指两个或两个以上的进程(或线程)在执行过程中,因争夺资源而造成互相等待


活锁

活锁是另一种形式的活跃性问题,该问题尽管不会阻塞线程,但也不能继续执行,因为线程将不断重复同样的操作,而且总会失败。

附:
该笔记由作者本人通过C语言中文网和观看B站视频提炼总结

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

programming_rooike

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

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

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

打赏作者

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

抵扣说明:

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

余额充值