1. 写在前面
公司的新业务开发需要用到go语言,虽然之前没接触过这门语言,但在大模型的帮助下,边看项目边写代码也能进行go的项目开发,不过,写了一段时间代码之后,总感觉对go语言本身,我的知识体系里面并没有一个比较完整的架子,学习到的知识零零散散,不成体系,虽然能完成工作,但心里比较虚,没有沉淀下知识。所以想借着这个机会,用两周的时间系统的学习下go语言, 在知识体系里面搭一个属于go语言的知识框架,把知识拎起来, 也便于后续知识的扩充与回看。
此次学习,依然是业余时间看文档的方式搭建知识框架(工作之后发现看视频比较慢,没时间看), 参看的文档是C语言中文网go教程, 上面内容整理的很详细,非常适合初学者搭建知识体系。只不过内容比较多, 这次还是和之前一样, 整体过一遍教程, 把我觉得现阶段比较关键的知识梳理出来,过于简单的知识作整合,对重点知识,用其他一些资料补充,再用一些实验作为辅助理解,先跳过一些demo示例实践, 基础知识作整理,关键知识作扩展搭建基础框架,后面再从项目中提炼新知识作补充完善框架。
PS: 由于我有C/C++、Java、Python等语言基础,所以针对教程的前面常识部分作了整合和删减,小白的话建议去原网站学习哈。
Go语言系统部分打算用3篇文章搭建知识框架,基础篇、进阶篇和杂项篇,每一篇里面的内容各个模块划分的比较清晰,这样后面针对新知识方便补充。这篇是go语言的基础部分,主要是从灵魂三问(WhatWhyHow)方面介绍下go语言,然后再整理基本语法,容器,流程控制,函数以及结构体部分的内容。
大纲如下:
- go语言初识(what, why)
- 基础语法(变量,数据类型,容器,流程控制)
- 函数与结构体
OK, let’s go!
2 初识
2.1 What?
- go语言是谷歌2009年发布的第二款开源编程语言(系统开发语言),是基于编译、垃圾回收和并发的编程语言
- Go语言有时候被描述为“C 类似语言”,从C语言继承了相似的表达式语法、控制流结构、基础数据类型、调用参数传值、指针等很多思想。
- Go 是编译型语言,使用编译器来编译代码。编译器将源代码编译成二进制(或字节码)格式;在编译代码时,编译器检查错误、优化性能并输出可在不同平台上运行的二进制文件。
特性:
- 语法简单
- Go语言的语法规则严谨,没有歧义,更没什么黑魔法变异用法
- 将“++”、“–”从运算符降级为语句,保留指针,但默认阻止指针运算,带来的好处是显而易见的。还有,将切片和字典作为内置类型,从运行时的层面进行优化,这也算是一种“简单”。
- 并发模型
- Goroutine 是 Go 最显著的特征。它用类协程的方式来处理并发单元,却又在运行时层面做了更深度的优化处理。这使得语法上的并发编程变得极为容易,无须处理回调,无须关注线程切换
- 搭配 channel,实现 CSP 模型。将并发单元间的数据耦合拆解开来,各司其职,这对所有纠结于内存共享、锁粒度的开发人员都是一个可期盼的解脱。
- 内存分配
- Go 选择了 tcmalloc,它本就是为并发而设计的高性能内存分配组件。
- 垃圾回收
- 标准库: 有很多好用丰富的标准库,比如net/http
- 工具链
- 完整的工具链对于日常开发极为重要。Go 在此做得相当不错,无论是编译、格式化、错误检查、帮助文档,还是第三方包下载、更新都有对应的工具。其功能未必完善,但起码算得上简单易用。
- 内置完整测试框架,其中包括单元测试、性能测试、代码覆盖率、数据竞争,以及用来调优的 pprof,这些都是保障代码能正确而稳定运行的必备利器。
2.2 Why?
- go之前,无论是汇编还是脚本语言,执行效率和开发效率不能兼得
- 兼顾python等动态语言的开发速度和和 C/C++等编译型语言的性能与安全性
- Go程序有着近C的执行性能和近解释型语言的开发效率,近乎于完美的编译速度,更加安全,支持并行进程。
- 设计哲学: 简单、实用体现得淋漓尽致
优点:
Go语言是集多编程范式之大成者,体现了优秀的软件工程思想和原则,其特性可以使开发者快速地开发、测试和部署程序,大大提高了生产效率。
- 相对 C/C++ ,Go语言拥有清晰的依赖管理和全自动的垃圾回收机制,代码量降低,开发效率提高。
- 相对 Java ,Go语言拥有简明的类型系统、函数式编程范式和先进的并发编程模型。代码块更小更简洁、可重用性更高,并可在多核计算环境下更快地运行。
- 对于 PHP ,Go语言更具通用性和规范性。更适合构建大型的软件,并能够更好地将各个模块组织在一起。在性能方面,PHP 不可与 Go 同日而语。
- 对于 Python/Ruby 来讲,Go 的优势在于其简洁的语法、非侵入式和扁平化的类型系统和浑然天成的多范式编程模型。与 PHP 一样,Python 和 Ruby 也是动态类型的解释型语言,这就意味着它们的运行速度会比静态类型的编译型语言慢很多。
Go语言对于当前大多数主流语言来讲,最大的优势在于具有较高的生产效率、先进的依赖管理和类型系统,以及原生的并发计算支持。
缺点:
- 语法方面不够简洁
- 并发方面可能会有门槛
- 垃圾回收角度看,Go语言的垃圾回收采用的是并发的标记清除算法(Concurrent Mark and Sweep,CMS),有延迟
- 第三方库不够多
使用go语言的项目:
- Docker:操作系统层面的虚拟化技术,可以在操作系统和应用程序之间进行隔离,也可以称之为容器。Docker 可以在一台物理服务器上快速运行一个或多个实例
- Kubernetes:Google 公司开发的构建于 Docker 之上的容器调度服务,用户可以通过 Kubernetes 集群进行云端容器集群管理。系统会自动选取合适的工作节点来执行具体的容器集群调度处理工作。其核心概念是 Container Pod(容器仓)
- etcd: 一款分布式、可靠的 KV 存储系统,可以快速进行云配置
使用场景:
- 服务器编程: 处理日志、数据打包、虚拟机、文件系统,分布式系统,数据库代理,中间件
- 网络编程: Web应用,API应用,下载应用等
- 云平台开发、区块链、嵌入式开发
下面列举了一些基于Go语言开发的优秀开源项目:
- 云计算基础设施领域,代表项目:docker、kubernetes、etcd、consul、cloudflare CDN、七牛云存储等。
- 基础软件,代表项目:tidb、influxdb、cockroachdb 等。
- 微服务,代表项目:go-kit、micro、monzo bank 的 typhon、bilibili 等。
- 互联网基础设施,代表项目:以太坊、hyperledger 等。
3 安装
这里只整理windows的安装方式:
- https://studygolang.com/dl里面下载安装包
- 选择位置安装,D:\Environment\Go, 无脑安装完成, go version查看
- 配置环境变量, Go需要一个安装目录,需要一个工作目录:
- GOROOT: 安装目录
- GOPATH: 工作目录D:\Environment\GoWorks,存放一些包的代码等, 这个如果不设置,系统会在c盘自己弄一个,这里是为了代码好管理,所以明确指定出来,里面需要有scr,pkg,bin三个目录
- 命令行输入: go env会看到安装的配置
golang的开发工具: GoLand
3.1 GOPATH细节
Go语言对工程(项目)的目录结果有明确规定,go项目的构建主要是通过GOPATH来实现的,如果想要构建一个项目,就需要将这个项目的目录添加到 GOPATH 中,多个项目之间可以使用;
分隔。
不配置 GOPATH,即使处于同一目录,代码之间也无法通过绝对路径相互调用。
一个Go语言项目的目录一般包含以下三个子目录:
- src目录:放项目和库的源文件,以包(package)的形式组织,这里的包与 src 下的每个子目录一一对应。例如,若一个源文件被声明属于 log 包,那么它就应当保存在 src/log 目录中。并不是说 src 目录下不能存放 Go 源文件,一般在测试或演示的时候也可以把 Go 源文件直接放在 src 目录下,但是这么做的话就只能声明该源文件属于 main 包了。正常开发建议大家把 Go 源文件放入l特定的目录中。包是Go语言管理代码的重要机制,其作用类似于java中的package和c/c++的头文件。 go源文件第一段有效代码必须是
package <包名>
- pkg目录: 用于存放通过
go install
命令安装某个包后的归档文件。归档文件是指那些名称以“.a
”结尾的文件。编译和安装项目代码的过程一般会以代码包为单位进行,比如 log 包被编译安装后,将生成一个名为log.a
的归档文件,并存放在当前项目的 pkg 目录下 - bin目录: 与 pkg 目录类似,在通过
go install
命令完成安装后,保存由 Go 命令源文件生成的可执行文件。在类 Unix 操作系统下,这个可执行文件的名称与命令源文件的文件名相同。而在 Windows 操作系统下,这个可执行文件的名称则是命令源文件的文件名加 .exe 后缀
命令源文件 VS 库的源文件:
- 命令源文件:如果一个 Go 源文件被声明属于 main 包,并且该文件中包含 main 函数,则它就是命令源码文件。命令源文件属于程序的入口,可以通过Go语言的
go run
命令运行或者通过go build
命令生成可执行文件。 - 库源文件:库源文件则是指存在于某个包中的普通源文件,并且库源文件中不包含 main 函数。
- 不管是命令源文件还是库源文件,在同一个目录下的所有源文件,其所属包的名称必须一致的
建议大家无论是使用命令行或者使用集成开发环境编译 Go 源码时,GOPATH 跟随项目设定。在 Jetbrains 公司的 GoLand 集成开发环境(IDE)中的 GOPATH 设置分为全局 GOPATH 和项目 GOPATH,如下图所示
图中的 Global GOPATH 代表全局 GOPATH,一般来源于系统环境变量中的 GOPATH;Project GOPATH 代表项目所使用的 GOPATH,该设置会被保存在工作目录的 .idea 目录下,不会被设置到环境变量的 GOPATH 中,但会在编译时使用到这个目录。建议在开发时只填写项目 GOPATH,每一个项目尽量只设置一个 GOPATH,不使用多个 GOPATH 和全局的 GOPATH
3.2 Hello World
安装完go之后,我们先来经典的hello world练练手,新建hello.go
package main // 未来程序编译成可执行文件要使用的 编译的时候会找这个main包
// 导入一个系统包fmt用来输出 fmt 包是Go语言标准库为我们提供的,用于格式化输入输出的内容(类似于C语言中的 stdio.h 头文件)
import "fmt"
// main 函数只能声明在 main 包中,不能声明在其他包中,并且,一个 main 包中也必须有且仅有一个 main 函数
func main() {
fmt.Println("hello world")
}
关于包
// Go语言以“包”作为管理单位,每个 Go 源文件必须先声明它所属的包
// Go语言的包与文件夹是一一对应的,它具有以下几点特性:
// 一个目录下的同级文件属于同一个包。
//包名可以与其目录名不同。
//main 包是Go语言程序的入口包,一个Go语言程序必须有且仅有一个 main 包。如果一个程序没有 main 包,那么编译时将会出错,无法生成可执行文件
关于导包
// 导包细节注意: 导入的包中不能含有代码中没有使用到的包,否则Go编译器会报编译错误,例如 imported and not used: "xxx","xxx" 表示包名
// 也可以使用一个 import 关键字导入多个包,此时需要用括号( )将包的名字包围起来,并且每个包名占用一行
import (
"fmt"
"xxx"
)
关于函数
// go语言中,所以函数都是以func开头
func 函数名 (参数列表) (返回值列表){
函数体
}
// 函数名:由字母、数字、下画线_组成,其中,函数名的第一个字母不能为数字,并且,在同一个包内,函数名称不能重名
// 函数列表:一个参数由参数变量和参数类型组成
// 返回值列表:可以是返回值类型列表,也可以是参数列表那样变量名与类型的组合,函数有返回值时,必须在函数体中使用 return 语句返回
// 函数体:能够被重复调用的代码片段
==注意:Go语言函数的左大括号{必须和函数名称在同一行,否则会报错==
运行上面的hello.go, 两种方法:
go build hello.go:
将Go语言程序代码编译成二进制的可执行文件,此时会同级目录下出来一个二进制hello.exe, 手动运行这个二进制文件 ./hello.exego run hello.go
: 会在编译后直接运行Go语言程序,编译过程中会产生一个临时文件,但不会生成可执行文件,这个特点很适合用来调试程序
如果在GoLand里面跑不起来,设置一个go的环境变量:
go env -w GO111MODULE=off // 禁用 go module,编译时会从 GOPATH 和 vendor 文件夹中查找包
Go语言里, 命名为main的包有特殊含义,Go语言的编译程序会试图把这种名字的包编译为二进制可执行文件。 所有用Go语言编译的可执行程序都必须有一个名叫main的包。 一个可执行程序有且仅有一个main包。
编译器发现某个包名字叫main时, 它会去找main()的函数, 如果没有也不会创建可执行文件。 main()函数是程序入口。 程序编译时, 会使用声明main包的代码所在的目录的目录名作为二进制可执行文件的文件名。
4 基础语法
4.1 变量与常量
4.1.1 注释
注释:
单行注释 //
多行注释 /* */
4.1.2 变量
变量: go是一种静态类型语言,所以go里面的变量是有明确类型的,编译器也会检查变量的正确性。
在数学概念中,变量表示没有固定值且可改变的数。但从计算机系统实现角度来看,变量是一段或多段用来存储数据的内存。
# var关键字声明, 格式:
var 变量名 变量类型 # var name string = "zhangsan"
# 变量建议命名: 驼峰命名
# 批量定义变量
# 当一个变量被声明之后,系统自动赋予它该类型的零值:int 为 0,float 为 0.0,bool 为 false,string 为空字符串,指针为 nil 等。
# **所有的内存在 Go 中都是经过初始化的**
var (
a int # 默认值 0
b string # 默认值""
c []float32
d func() bool
e struct {
x int
}
)
a = 20
b = "zhongguo"
# go的变量初始化可以用:= 这个是可以自动的推导类型,就不用写类型了, 这个就类似于与python
# 这个叫作短变量声明并初始化
name := "wuzhongqiang"
age := 20
# 也可以写一块 a,s:=1, "abc" 注意:在多个短变量声明和赋值中,至少有一个新声明的变量出现在左值中,即便其他变量名可能是重复声明的,编译器也不会报错
conn, err := net.Dial("tcp", "127.0.0.1:8080")
conn2, err := net.Dial("tcp", "127.0.0.1:8080")
# go语言的推导声明写法, 编译器会自动根据右值类型推导出左值对应类型
# 使用限制: 定义变量同时初始化, 不能提供数据类型,只能用在函数内部,不能到处定义
# **必须是新的变量才能这么写,如果变量已经被声明过了,就不能这么写了**
简短变量声明被广泛用于大部分的局部变量的声明和初始化。var 形式的声明语句往往是用于需要显式指定变量类型地方,或者因为变量稍后会被重新赋值而初始值无关紧要的地方
# go语言中的变量交换
var a = 100
var b = 200
b, a = a, b
# 匿名变量 _, 空白标识符,任何赋值给这个标识符的值都会被抛弃, 这些值在后续代码中不能使用
# 匿名变量不会占用内存空间,不会分配内存
func test() (int, int){
return 10, 20
}
func main() {
a, _ := test()
fmt.Println(_) # 这个会报错 cannot use _ as value, 所以go和python不一样, python _接收的变量可以用
}
# **匿名变量不占用内存空间,不会分配内存。匿名变量与匿名变量之间也不会因为多次声明而无法使用**
go语言中如果定义变量不去使用会报错
Go的设计者故意让这成为一种错误,是为了鼓励编写简洁的代码并避免不必要的代码膨胀。这一规则帮助开发者及早发现可能的错误,
例如:
- 避免了拼写错误导致的问题,比如定义了新的变量却错误地使用了另一个同名的存在变量。
- 提前发现和修复没有使用的变量,这些变量可能是由于早期的重构遗留下来,或者是未完成的代码。
- 减少内存占用,未使用的变量如果堆积在代码中会造成不必要的内存分配
go语言里面变量类型放到后面, 一般编译语言都是变量类型放到前面,go这样做的好处就是可以避免像C语言中那样含糊不清的声明形式,例如:int* a, b;
。其中只有 a 是指针而 b 不是。如果你想要这两个变量都是指针,则需要将它们分开书写
// 在 Go 中,则可以和轻松地将它们都声明为指针类型:
var a, b *int
Go语言的基本类型有:
bool
string
int、int8、int16、int32、int64
uint、uint8、uint16、uint32、uint64、uintptr
byte // uint8 的别名
rune // int32 的别名 代表一个 Unicode 码
float32、float64
complex64、complex128
内存地址:
# 获取变量的地址 & 取地址符
var num int
num = 100
fmt.Printf("num: %d, addr: %p", num, &num)
变量的作用域: 一个变量(常量、类型或函数)在程序中都有一定的作用范围,称之为作用域,Go语言会在编译时检查每个变量是否使用过,一旦出现未使用的变量,就会报编译错误
// 局部变量:函数体内声明的变量,作用域只在函数体内, 函数的参数和返回值都属于局部变量
// 局部变量不是一直存在的,它只在定义它的函数被调用后存在,函数调用结束后这个局部变量就会被销毁
// 全局变量: 函数体外声明的变量,只需要在一个源文件中定义,就可以使用,不包含这个全局变量的源文件需要用import引入全局变量所在的源文件
// 全局变量必须var开头,如果想要在外部中使用全局变量的首字母必须大写
package main
import (
"fmt"
)
var d int
func main() {
//声明局部变量 a 和 b 并赋值
var a int = 3
var b int = 4
//声明局部变量 c 并计算 a 和 b 的和
c := a + b
d = c * 2
fmt.Printf("a = %d, b = %d, c = %d, d = %d\n", a, b, c, d)
}
// 全局变量和局部变量名字可以相同,优先使用局部变量
// 形式参数
// 在定义函数时函数名后面括号中的变量叫做形式参数(简称形参)。
// 形式参数只在函数调用时才会生效,函数调用结束后就会被销毁,在函数未被调用时,函数的形参并不占用实际的存储单元,也没有实际值。
// 形式参数会作为函数的局部变量来使用。
变量逃逸
// 栈(Stack)是一种拥有特殊规则的线性表数据结构, 只允许从线性表的同一端放入或取出数据, 先进后出原则
// 变量和栈的关系: 栈可用于内存分配,栈的分配和回收速度非常快。
func calc(a, b int) int {
var c int
c = a * b
var x int
x = c * 10
return x
}
// Go语言默认情况下会将 c 和 x 分配在栈上,这两个变量在 calc() 函数退出时就不再使用,函数结束时,保存 c 和 x 的栈内存再出栈释放内存,整个分配内存的过程通过栈的分配和回收都会非常迅速
// 堆
// 堆在内存分配中类似于往一个房间里摆放各种家具,家具的尺寸有大有小,分配内存时,需要找一块足够装下家具的空间再摆放家具。经过反复摆放和腾空家具后,房间里的空间会变得乱七八糟,此时再往这个空间里摆放家具会发现虽然有足够的空间,但各个空间分布在不同的区域,没有一段连续的空间来摆放家具
// 堆分配内存和栈分配内存相比,堆适合不可预知大小的内存分配。但是为此付出的代价是分配速度较慢,而且会形成内存碎片
// 变量逃逸: 自动决定变量分配方式,提高运行效率
// Go语言通过编译器分析代码的特征和代码的生命周期,决定应该使用堆还是栈来进行内存分配
// 分析变量逃逸
func dummy(b int) int {
// 声明一个变量c并赋值
var c int
c = b
return c
// c是在栈上分配的内存, 但由于return的存在,使得其值通过 dummy() 的返回值“逃出”了 dummy() 函数。
// 变量 c 的值被复制并作为 dummy() 函数的返回值返回,即使变量 c 在 dummy() 函数中分配的内存被释放,也不会影响 main() 中使用 dummy() 返回的值
}
func main() {
fmt.Println(dummy(0))
}
// 分析下面结构体的内存分配
// 声明空结构体测试结构体逃逸情况
type Data struct {
}
func dummy() *Data {
// 实例化c为Data类型
var c Data
//返回函数局部变量地址
return &c
// 这里返回了一个取地址,Go 编译器觉得如果将变量 c 分配在栈上是无法保证程序最终结果的,如果这样做,dummy() 函数的返回值将是一个不可预知的内存地址
// Go语言最终选择将 c 的 Data 结构分配在堆上。然后由垃圾回收器去回收 c 的内存
}
func main() {
fmt.Println(dummy())
}
// 编译器觉得变量应该分配在堆和栈上的原则是:
// 变量是否被取地址;
// 变量是否发生逃逸。
变量生命周期: 指的是在程序运行期间变量有效存在的时间间隔
变量的生命周期与变量的作用域有着不可分割的联系:
- 全局变量:它的生命周期和整个程序的运行周期是一致的;
- 局部变量:它的生命周期则是动态的,从创建这个变量的声明语句开始,到这个变量不再被引用为止;
- 形式参数和函数返回值:它们都属于局部变量,在函数被调用的时候创建,函数调用结束后被销毁。
// 栈和堆的区别在于:
// 堆(heap):堆是用于存放进程执行中被动态分配的内存段。它的大小并不固定,可动态扩张或缩减。当进程调用 malloc 等函数分配内存时,新分配的内存就被动态加入到堆上(堆被扩张)。当利用 free 等函数释放内存时,被释放的内存从堆中被剔除(堆被缩减);
// 栈(stack):栈又称堆栈, 用来存放程序暂时创建的局部变量,也就是我们函数的大括号{ }中定义的局部变量。
// 程序的编译阶段,编译器会根据实际情况自动选择在栈或者堆上分配局部变量的存储空间,不论使用 var 还是 new 关键字声明变量都不会影响编译器的选择
// 我记得java不是来,java new出来的好像是放到堆里面来, 而go是编译器会根据是否会逃逸以及是否有取地址等操作自行决定放到哪里
var global *int
func f() {
var x int
x = 1 // 函数 f 里的变量 x 必须在堆上分配,因为它在函数退出后依然可以通过包一级的 global 变量找到
global = &x // 变量x在f函数中逃逸了
}
func g() {
y := new(int)
*y = 1
// 函数 g 返回时,变量 *y 不再被使用,也就是说可以马上被回收的。
// 因此,*y 并没有从函数 g 中逃逸,编译器可以选择在栈上分配 *y 的存储空间,也可以选择在堆上分配,然后由Go语言的 GC(垃圾回收机制)回收这个变量的内存空间
// 可见go里面变量的地址分配跟new 不 new无关
}
// 结论
// 在实际的开发中,并不需要刻意的实现变量的逃逸行为,因为逃逸的变量需要额外分配内存,同时对性能的优化可能会产生细微的影响
// 为了能够开发出高性能的应用我们任然需要了解变量的声明周期。例如,如果将局部变量赋值给全局变量,将会阻止 GC 对这个局部变量的回收,导致不必要的内存占用,从而影响程序的性能
4.1.3 常量
常量: 用来存储不会改变的数据,常量是在编译时被创建的,即使定义在函数内部也是如此,并且只能是布尔型、数字型(整数型、浮点型和复数)和字符串型。由于编译时的限制,定义常量的表达式必须为能被编译器求值的常量表达式
const 变量名 [type] = value
显式类型定义: const b string = "abc"
隐式类型定义: const b = "abc"
# 多个相同类型的声明
const c_name1, c_name2 = value1, value2
# 特殊常量 iota, 可以认为是一个可以被编译器修改的常量,是go语言的常量计数器
# iota在const关键字出现时将被重置为0, const中每新增一行常量声明将使iota计数一次(可以理解为const语句块的行索引)
# iota可以被用作枚举值
const (
a = iota // 0
b = iota // 1
c = iota // 2
)
# 一组常量中,如果某个常量没有初始值, 默认和上一行一致
const (
j = iota // 0 iota又从0开始计数
i // 1 原因是没有初始值, 和上一行一致,拿到iota,此时iota计数为1了
k // 2
)
# demo
package main
import "fmt"
func main() {
const (
a = iota // 0
b // 1
c // 2
d = "haha" // haha iota 3
e // haha 和上面的变量一致 iota 4
f = 100 // 100 iota 5
g // 100 和上一行一样 iota 5
h = iota // 7
i // 8
)
const (
j = iota // 0
k //
)
fmt.Println(a, b, c, d, e, f, g, h, i, j, k) # 0 1 2 haha haha 100 100 7 8 0 1
}
# Go语言现阶段没有枚举类型, 可以用上面的iota模拟枚举
type Weapon int
const (
Arrow Weapon = iota # 开始生成枚举值, 默认为0
Shuriken # 1
SniperRifle # 2
Rifle # 3
Blower # 4
)
# 输出所有枚举值
fmt.Println(Arrow, Shuriken, SniperRifle, Rifle, Blower) # 0, 1, 2, 3, 4
# 使用枚举类型并赋初值
var weapon Weapon = Blower
fmt.Println(weapon) # 4
4.2 数据类型
4.2.1 基本数据类型
# bool 类型, true false, 默认是false 布尔值并不会隐式转换为数字值 0 或 1,反之亦然,必须使用 if 语句显式的进行转换
# 布尔型无法参与数值运算,也无法与其他类型进行转换
var isFlag bool = true
# 数值型
# 整型 int 默认int64 别名 byte 表示uint8
# 浮点型 float 默认float64 默认保留6位小数 尽量使用float64定义浮点类型的数据, 精度不易丢失
# 复数 这个不重要,实际用到的不多,暂时不整理
# 字符串型 string
# 字符串是一种值类型,且值不可变,即创建某个文本后将无法再次修改这个文本的内容,更深入地讲,字符串是字节的定长数组
# 字符串是 UTF-8 字符的一个序列(当字符为 ASCII 码表上的字符时则占用 1 个字节,其它字符根据需要占用 2-4 个字节)
func main() {
var str string
str = "zhongqiang"
fmt.Printf("%T, %s\n", str, str) // %T 打印类型 string, zhongqiang
}
# 一般的比较运算符(==、!=、<、<=、>=、>)是通过在内存中按字节比较来实现字符串比较的,因此比较的结果是字符串自然编码的顺序。
# 字符串所占的字节长度可以通过函数 len() 来获取,例如 len(str)。
# 字符串的内容(纯字节)可以通过标准索引法来获取,在方括号[]内写入索引,索引从 0 开始计数, 最后一个字节str[len(str)-1]
# 注意:获取字符串中某个字节的地址属于非法行为,例如 &str[i]。
# 单引号 字符, 整型-ASCII字符码
v1 := 'A'
v2 := "A"
fmt.Printf("%T, %d\n", v1, v1) // int32, 65
fmt.Printf("%T, %s\n", v2, v2) // string, A
# 所有的中国字在GBK编码表里面
# ASCII字符码
# 全世界的编码表: Unicode编码表
# Unicode与UTF-8的区别: Unicode 与 ASCII 类似,都是一种字符集。
# 字符集为每个字符分配一个唯一的 ID,我们使用到的所有字符在 Unicode 字符集中都有一个唯一的 ID,例如 a 在 Unicode 与 ASCII 中的编码都是 97。汉字“你”在 Unicode 中的编码为 20320
# 在不同国家的字符集中,字符所对应的 ID 也会不同。而无论任何情况下,Unicode 中的字符的 ID 都是不会变化的。
# UTF-8 是编码规则,将 Unicode 中字符的 ID 以某种方式进行编码,UTF-8 的是一种变长编码规则,从 1 到 4 个字节不等
# 0xxxxxx 表示文字符号 0~127,兼容 ASCII 字符集
# 从 128 到 0x10ffff 表示其他字符
# 广义的 Unicode 指的是一个标准,它定义了字符集及编码规则,即 Unicode 字符集和 UTF-8、UTF-16 编码
# 字符串连接 + 两个字符串 s1 和 s2 可以通过 s := s1 + s2 拼接在一起。将 s2 追加到 s1 尾部并生成一个新的字符串 s
s := "Beginning of the string " + # 因为编译器会在行尾自动补全分号,所以拼接字符串用的加号“+”必须放在第一行末尾
"second part of the string"
# 多行字符串
const s = `第一行
第二行
第三行
\r\n
`
# 字符串中的每一个元素叫做“字符”,在遍历或者单个获取字符串元素时可以获得字符。
# 数据类型转换
# go语言不存在隐式的数据类型转换 所有的类型转换都必须显式的声明 valueOfTypeB = typeB(valueOfTypeA)
# 字符串与整型的数据类型转换
# go里面的strconv 包可以帮助完成一些整型转字符串的工作
# 常用的函数包括 Atoi()、Itia()、parse 系列函数、format 系列函数、append 系列函数
func main() {
num := 100
# int转成string func Itoa(i int) string
str := strconv.Itoa(num)
fmt.Printf("type:%T value:%#v\n", str, str)
# string to int func Atoi(s string) (i int, err error)
str1 := "110"
str2 := "s100"
num1, err := strconv.Atoi(str1)
if err != nil {
fmt.Printf("%v 转换失败!", str1)
} else {
fmt.Printf("type:%T value:%#v\n", num1, num1)
}
num2, err := strconv.Atoi(str2)
if err != nil {
fmt.Printf("%v 转换失败!", str2)
} else {
fmt.Printf("type:%T value:%#v\n", num2, num2)
}
}
# parse系列函数: Parse 系列函数用于将字符串转换为指定类型的值,其中包括 ParseBool()、ParseFloat()、ParseInt()、ParseUint()
# ParseBool(str string) (value bool, err error): 用于将字符串转换为 bool 类型的值,它只能接受 1、0、t、f、T、F、true、false、True、False、TRUE、FALSE,其它的值均返回错误
# ParseInt(s string, base int, bitSize int) (i int64, err error) 函数用于返回字符串表示的整数值(可以包含正负号), 这里的base是进制
# ParseUint(s string, base int, bitSize int) (n uint64, err error): 和上面类似,不接受正负号
# ParseFloat(s string, bitSize int) (f float64, err error): 将一个表示浮点数的字符串转换为 float 类型, bitSize是32或者64 返回值的类型是float32或者float64
# format系列函数:实现了将给定类型数据格式化为字符串类型的功能,其中包括 FormatBool()、FormatInt()、FormatUint()、FormatFloat()
# FormatBool(b bool) string: 一个 bool 类型的值转换为对应的字符串类型
# FormatInt(i int64, base int) string: 用于将整型数据转换成指定进制并以字符串的形式返回
# FormatUint(i uint64, base int) string: 和上面类似, 无符号整型
# FormatFloat(f float64, fmt byte, prec, bitSize int) string: float转字符串
4.2.2 派生数据类型
# go语言指针
# 指针(pointer)在Go语言中可以被拆分为两个核心概念:
# 类型指针,允许对这个指针类型的数据进行修改,传递数据可以直接使用指针,而无须拷贝数据,类型指针不能进行偏移和运算。
# 切片,由指向起始元素的原始指针、元素数量和容量组成
# 关于指针的几个概念: 指针类型,指针取值和指针地址
# 指针地址: 一个指针变量可以指向任何一个值的内存地址,它所指向的值的内存地址在 32 和 64 位机器上分别占用 4 或 8 个字节, 当一个指针被定义后没有分配到任何变量时,它的默认值为 nil。指针变量通常缩写为 ptr
# 每个变量在运行时都拥有一个地址,这个地址代表变量在内存中的位置。Go语言中使用在变量名前面添加&操作符(前缀)来获取变量的内存地址 ptr := &t
package main
import (
"fmt"
)
func main() {
//声明局部变量 a 和 b 并赋值
var a int = 3
var b string = "banana"
fmt.Printf("%p %p", &a, &b) # 0xc00007c020 0xc00005c1e0 打印两个变量的地址
}
# 提示:变量、指针和地址三者的关系是,每个变量都拥有地址,指针的值就是地址
# 获取指针指向的值 *指针
package main
import (
"fmt"
)
func main() {
// 准备一个字符串类型
var name = "wuzhongqiang"
ptr := &name // 对字符串取地址, ptr类型为*string
fmt.Printf("ptr type: %T\n", ptr) // 打印ptr的类型 *string
fmt.Printf("address: %p\n", ptr) // 打印ptr的指针地址 0xc00005c1e0
value := *ptr // 对指针进行取值操作
fmt.Printf("value type: %T\n", value) // 取值后的类型 string
fmt.Printf("value: %s\n", value) // 指针取值后就是指向变量的值 wuzhongqiang
}
# 取地址操作符&和取值操作符*是一对互补操作符,&取出地址,*根据地址取出地址指向的值
# 变量、指针地址、指针变量、取地址、取值的相互关系和特性如下:
# 对变量进行取地址操作使用&操作符,可以获得这个变量的指针变量。
# 指针变量的值是指针地址。
# 对指针变量进行取值操作使用*操作符,可以获得指针变量指向的原变量的值
# 指针修改值 交换两个变量的值,可以通过指针操作
func swap(p, q *int){
t := *p
*p = *q
*q = t
}
func main(){
var a int = 2
var b int = 3
swap(&a, &b)
fmt.Printf("a=%d, b=%d", a, b)
}
# 用指针获取命令行的输入信息
# Go语言内置的 flag 包实现了对命令行参数的解析,flag 包使得开发命令行工具更为简单 类似于python的argparse
# 下面写一个main.go
package main
import (
"flag"
"fmt"
)
// 定义命令行参数
var mode = flag.String("mode", "", "process mode")
func main() {
// 解析命令行参数
flag.Parse()
// 输出命令行参数
fmt.Println(*mode)
}
# 运行 go run main.go --mode=fast
4.3 容器
4.3.1 数组
数组: 数组是一个由固定长度的特定类型元素组成的序列, 由于长度是固定的,go里面很少使用,简单理解, go里面一般使用slice, 可以动态的伸缩扩充,类似python中的列表
// 声明方式
var 数组变量名 [元素数量]Type
var a[3] int
// 访问某个值
fmt.Println(a[2])
a[2] = 1
for idx, v := range a{ // 类似于python for idx, v in enumerate(a)
fmt.Println(idx, v) // 默认v 0
}
// 声明的时候初始化
var q [3]int = [3]int{1, 2, 3}
// 如果声明数组的长度时出现省略号,则表示数组的长度是根据初始化值的个数来计算
q := [...]int{1, 2, 3}
// 数组的长度是数组类型的一个组成部分,因此 [3]int 和 [4]int 是两种不同的数组类型,数组的长度必须是常量表达式,因为数组的长度需要在编译阶段确定
q := [3]int{1, 2, 3}
q = [4]int{1, 2, 3, 4} // 编译错误:无法将 [4]int 赋给 [3]int
// 如果两个数组类型相同(包括数组的长度,数组中元素的类型)的情况下,我们可以直接通过较运算符(==和!=)来判断两个数组是否相等
// 只有当两个数组的所有元素都是相等的时候数组才是相等的,不能比较两个类型不同的数组,否则程序将无法完成编译。
a := [2]int{1, 2}
b := [...]int{1, 2}
c := [2]int{1, 3}
fmt.Println(a == b, a == c, b == c) // "true false false"
d := [3]int{1, 2}
fmt.Println(a == d) // 编译错误:无法比较 [2]int == [3]int
// Go语言中允许使用多维数组,因为数组属于值类型,所以多维数组的所有维度都会在创建时自动初始化零值
// 声明一个二维整型数组,两个维度的长度分别是 4 和 2
var array [4][2]int
// 使用数组字面量来声明并初始化一个二维整型数组
array = [4][2]int{{10, 11}, {20, 21}, {30, 31}, {40, 41}}
// 声明并初始化数组中索引为 1 和 3 的元素
array = [4][2]int{1: {20, 21}, 3: {40, 41}}
// 声明并初始化数组中指定的元素
array = [4][2]int{1: {0: 20}, 3: {1: 41}}
// 访问
array[0][1]
4.3.2 切片
切片:切片(slice)是对数组的一个连续片段的引用,所以切片是一个引用类型(类似 Python 中的 list 类型),这个片段可以是整个数组,也可以是由起始和终止索引标识的一些项的子集,需要注意的是,终止索引标识的项不包括在切片内
// slice [开始位置 : 结束位置]
// 数组中生成切片
var a = [3]int{1, 2, 3}
fmt.Println(a, a[1:2]) // [1 2 3] [2]左闭右开
fmt.Println(a[:]) // [1 2 3]
// 直接声明新切片
// 声明字符串切片
var strList []string
// 声明整型切片
var numList []int
// 声明一个空切片
var numListEmpty = []int{}
// make函数构造切片 动态地创建一个切片,可以使用 make() 内建函数
// make( []Type, size, cap ) size 指的是为这个类型分配多少个元素,cap 为预分配的元素数量,这个值设定后不影响 size,只是能提前分配空间,降低多次分配空间造成的性能问题。
a := make([]int, 2)
b := make([]int, 2, 10)
// 输出3个切片
fmt.Println(strList, numList, numListEmpty) // [] [] []
// 输出3个切片大小
fmt.Println(len(strList), len(numList), len(numListEmpty)) // 0 0 0
// 切片判定空的结果
fmt.Println(strList == nil) // true
fmt.Println(numList == nil) // true
fmt.Println(numListEmpty == nil) // false 本来会在{}中填充切片的初始化元素,这里没有填充,所以切片是空的,但是此时的 numListEmpty 已经被分配了内存,只是还没有元素
// 注意:切片是动态结构,只能与 nil 判定相等,不能互相判定相等
var strList []string
var strList1 []string
fmt.Println(strList1==strList) // invalid operation: strList1 == strList (slice can only be compared to nil)
// append append() 可以为切片动态添加元素
// append会产生一个新切片出来,这个和python里面不一样,python里面从原列表上变动
var a []int
a = append(a, 1) // 追加1个元素
a = append(a, 1, 2, 3) // 追加多个元素, 手写解包方式
a = append(a, []int{1,2,3}...) // 追加一个切片, 切片会解包
fmt.Println(a) // [1 1 2 3 1 2 3]
// 开头添加元素
// 在切片开头添加元素一般都会导致内存的重新分配,而且会导致已有元素全部被复制 1 次,因此,从切片的开头添加元素的性能要比从尾部追加元素的性能差很多。
a = append([]int{0}, a...) // 在开头添加1个元素
a = append([]int{-3,-2,-1}, a...) // 在开头添加1个切片
// a = append(0, a...) // first argument to append must be slice; have untyped number
// 切片中间插入元素
a = append(a[:i], append([]int{x}, a[i:]...)...) // 在第i个位置插入x
a = append(a[:i], append([]int{1,2,3}, a[i:]...)...) // 在第i个位置插入切片
// 每个添加操作中的第二个 append 调用都会创建一个临时切片,并将 a[i:] 的内容复制到新创建的切片中,然后将临时创建的切片再追加到 a[:i] 中
// 切片复制 copy( destSlice, srcSlice []T) int
// 目标切片必须分配过空间且足够承载复制的元素个数,并且来源和目标的类型必须一致,copy() 函数的返回值表示实际发生复制的元素个数
// 如果两个切片不一样大, 就会按照其中较小的那个元素的个数进行复制
slice1 := []int{1, 2, 3, 4, 5}
slice2 := []int{5, 4, 3}
slice3 := []int{7, 8, 9, 10}
copy(slice2, slice1) // 只会复制slice1的前3个元素到slice2中
copy(slice1, slice3) // 只会复制slice3的3个元素到slice1的前3个位置
fmt.Println(slice1) // [7 8 9 10 5]
fmt.Println(slice2) // [1 2 3]
// 元素删除
// 中间和尾部删除, 直接用切片操作
a = []int{1, 2, 3}
a = a[N:] // 删除开头N个元素
a = a[:len(a)-N] // 删除尾部N个元素
// 删除中间元素,可以用append操作
a = append(a[:i], a[i+N:]...) // 删除中间N个元素
// range迭代切片
// 当迭代切片时,关键字 range 会返回两个值,第一个值是当前迭代到的索引位置,第二个值是该位置对应元素值的一份副本(注意这里是副本,而不是引用)
for idx, value := range a{
fmt.Println(idx, value)
}
// 如果是从中间进行迭代
for idx:=2; idx<len(a); idx++{
fmt.Println(idx, a[idx])
}
// 多维切片
// 声明一个二维整型切片并赋值
slice := [][]int{{10}, {100, 200}}
// 为第一个切片追加值为 20 的元素
slice[0] = append(slice[0], 20) // append() 函数处理追加的方式很简明,先增长切片,再将20赋值放到切片loc1位置,再将整个切片复制到外层切片的索引为 0 的元素
4.3.3 字典
字典: 一种元素对(pair)的无序集合,pair 对应一个 key(索引)和一个 value(值)
// 创建 var mapname map[keytype]valuetype
// 在声明的时候不需要知道map的长度,map可以动态增长,未初始化的map的值是 nil,使用函数len()可以获取map中pair的数目
mapLit := map[string]int{} // 也可以make定义make(map[string]int) 可以事先加容量make(map[string]int, cap), 这样编译器就可以事先预分配一块cap大小的内存空间,如果超了这块空间再加,这样就避免了每次添加都要分配内存的性能损耗。
mapLit["one"] = 1
mapLit["two"] = 2
// 定义并且初始化
mapLit := map[string]int{"one": 1, "two": 2} // 这种mapLit :=的写法, 变量在前面不能定义过
// 定义后, 进行赋值, 注意上面这3种写法
var mapLit map[string]int
mapLit = map[string]int{"one": 1, "two": 2}
// 如果值为切片
make(map[string][]int)
// 遍历map 注 遍历输出元素的顺序与填充顺序无关, map的存储是无序的
for k, v := range scene {
fmt.Println(k, v)
}
// 删除与清空 delete(map, 键)
****scene := make(map[string]int)
// 准备map数据
scene["route"] = 66
scene["brazil"] = 4
scene["china"] = 960
delete(scene, "brazil")
for k, v := range scene {
fmt.Println(k, v)
} // 只有route和china了
// 清空就是重新map一个新的map, go没有专门的函数清空map
4.3.4 列表
列表:列表是一种非连续的存储容器,由多个节点组成,节点通过一些变量记录彼此之间的关系,列表有多种实现方法,如单链表、双链表等。 注意这里的列表和python里面的不是一个概念,这里感觉像是链表。 python里面的列表更像是go里面的切片。
链表在插入和删除操作上会更加有优势。
// list 的初始化有两种方法:分别是使用 New() 函数和 var 关键字声明
import "container/list"
varName := list.New()
var varName list.List
// 列表与切片和 map 不同的是,列表并没有具体元素类型的限制
// 元素插入: 支持双端插入
l := list.New()
l.PushBack("wuzhongqiang")
l.PushFront(29)
fmt.Println(l) // 直接打印会打印出地址来,没法打印具体元素 &{{0xc00005c1b0 0xc00005c180 } 2}
// 中间插入, 这里需要拿到一个句柄
// 尾部添加后保存元素句柄
// 根据句柄在中间添加元素
element := l.PushBack("xxx")
fmt.Println(element) // &{0xc00005c150 0xc00005c180 0xc00005c150 xxx}
l.InsertAfter("high", element)
l.InsertBefore("noon", element)
// 删除元素 需要句柄
l.Remove(element)
// 用下面的方式遍历
for i := l.Front(); i != nil; i = i.Next() {
fmt.Println(i.Value)
}
4.3.5 nil
nil 是Go语言中一个预定义好的标识符,指针、切片、映射、通道、函数和接口的零值是 nil, 有些伙伴会把 nil 看作其他语言中的 null(NULL),其实这并不是完全正确的,因为Go语言中的 nil 和其他语言中的 null 有很多不同点。
-
nil标识符是不能比较的
nil==nil
error, python里面的None==None
是可以的 -
nil不是关键字或者保留字,可以声明变量名叫nil,但不建议这么做
-
nil没有默认类型
fmt.Printf(”%T”, nil)
error -
不同类型的nil指针是一样的, 但不同类型的nil是不能比较的, 相同类型的也不能比较
var arr []int var num *int fmt.Printf("%p\n", arr) // 0x0 fmt.Printf("%p", num) // 0x0 fmt.Println(arr==num) // ./main.go:10:20: invalid operation: arr == num (mismatched types []int and *int)
4.3.6 make和new
Go语言中 new 和 make 是两个内置函数,主要用来创建并分配类型的内存。在我们定义变量的时候,可能会觉得有点迷惑,不知道应该使用哪个函数来声明变量,其实他们的规则很简单,new 只分配内存,而 make 只能用于 slice、map 和 channel 的初始化
new函数
// The new built-in function allocates memory. The first argument is a type,
// not a value, and the value returned is a pointer to a newly
// allocated zero value of that type.
func new(Type) *Type
// new 函数只接受一个参数,这个参数是一个类型,并且返回一个指向该类型内存地址的指针。同时 new 函数会把分配的内存置为零,也就是类型的零值。
var sum *int
sum = new(int) //分配空间 没有这句话就会报错空指针异常 panic: runtime error: invalid memory address or nil pointer dereference
// 指针定义完了之后,得明确空间指向
*sum = 98
fmt.Println(*sum) // 98, 如果没有上面的赋值,这里会是0
// new 函数不仅仅能够为系统默认的数据类型,分配空间,自定义类型也可以使用 new 函数来分配空间
type Student struct {
name string
age int
}
var s *Student
s = new(Student) //分配空间
s.name ="zhongqiang"
fmt.Println(s) // &{zhongqiang 0}
make: 也是用于内存分配的,但是和 new 不同,它只用于 chan、map 以及 slice 的内存创建,而且它返回的类型就是这三个类型本身,而不是他们的指针类型,因为这三种类型就是引用类型,所以就没有必要返回他们的指针。
// func make(t Type, size ...IntegerType) Type
// make 函数的 t 参数必须是 chan(通道)、map(字典)、slice(切片)中的一个,并且返回值也是类型本身
// make 函数只用于 map,slice 和 channel,并且不返回指针。如果想要获得一个显式的指针,可以使用 new 函数进行分配,或者显式地使用一个变量的地址
Go语言中的 new 和 make 主要区别如下:
- make 只能用来分配及初始化类型为 slice、map、chan 的数据。new 可以分配任意类型的数据;
- new 分配返回的是指针,即类型 *Type。make 返回引用,即 Type;
- new 分配的空间被清零。make 分配空间后,会进行初始化。
make 关键字的主要作用是创建 slice、map 和 Channel 等内置的数据结构,而 new 的主要作用是为类型申请一片内存空间,并返回指向这片内存的指针
4.4 流程控制
Go 语言的常用流程控制有 if 和 for,而 switch 和 goto 主要是为了简化代码、降低重复代码而生的结构,属于扩展类的流程控制
4.4.1 分支结构
// if语句
// 这个和python差不多
if condition1 {
// do something
} else if condition2 {
// do something else
}else {
// catch-all or default
}
// 格式上注意: 编译器强制规定
// 关键字if和else之后的左大括号{必须和关键字在同一行
// 使用else if结构,则前段代码块的右大括号}必须和else if关键字在同一行。
// if 还有一种特殊的写法,可以在 if 表达式之前添加一个执行语句,再根据变量值进行判断
if err := Connect(); err != nil {
fmt.Println(err)
return
}
// Connect 是一个带有返回值的函数,err:=Connect() 是一个语句,执行 Connect 后,将错误保存到 err 变量中。
//err != nil 才是 if 的判断表达式,当 err 不为空时,打印错误并返回。
// 这种写法可以将返回值与判断放在一行进行处理,而且返回值的作用范围被限制在 if、else 语句组合中
// switch case语句
// 比C语言的更加通用,表达式不需要为常量,甚至不需要为整数,case 按照从上到下的顺序进行求值,直到找到匹配的项
// Go语言改进了 switch 的语法设计,case 与 case 之间是独立的代码块,不需要通过 break 语句跳出当前 case 代码块以避免执行到下一行
var a = "hello"
switch a {
case "hello":
fmt.Println(1)
case "world": // 一个分支也可以多个值,case "mum", "daddy":
fmt.Println(2)
default: // 一个switch只能有一个default
fmt.Println(0)
}
//case 后不仅仅只是常量,还可以和 if 一样添加表达式 注意,这种情况的 switch 后面不再需要跟判断变量
var r int = 11
switch {
case r > 10 && r < 20:
fmt.Println(r)
}
在编程中,变量的作用范围越小,所造成的问题可能性越小,每一个变量代表一个状态,有状态的地方,状态就会被修改,函数的局部变量只会影响一个函数的执行,但全局变量可能会影响所有代码的执行状态,因此限制变量的作用范围对代码的稳定性有很大的帮助。
4.4.2 循环结构
Go语言中的循环语句只支持 for 关键字,而不支持 while 和 do-while 结构, for结构和c或者c++非常接近
// 比较正规的
sum := 0
for i := 0; i < 10; i++ { // 初始语句可以被忽略,但分号得保留, 条件表达式也可以被忽略,此时是无限循环
sum += i
}
// 左花括号{必须与 for 处于同一行
// Go语言中的 for 循环与C语言一样,都允许在循环条件中定义和初始化变量,唯一的区别是,Go语言不支持以逗号为间隔的多个赋值语句,必须使用平行赋值的方式来初始化多个变量
// Go语言的 for 循环同样支持 continue 和 break 来控制循环,但是它提供了一个更高级的 break,可以选择中断哪一个循环
for j := 0; j < 5; j++ {
for i := 0; i < 10; i++ {
if i > 5 {
break JLoop // break 语句终止的是 JLoop 标签处的外层循环
}
fmt.Println(i)
}
}
JLoop:
// for range **Go语言特有的一种的迭代结构, 类似于其他语言的foreach**
// for range 可以遍历数组、切片、字符串、map 及通道(channel)
// 遍历切片
for key, value := range []int{1, 2, 3, 4} {
fmt.Printf("key:%d value:%d\n", key, value)
}
// 遍历字符串
var str = "hello"
for key, value := range str {
fmt.Printf("key:%d value:%c\n", key, value) // h e l l o
}
// 遍历map
m := map[string]int{
"hello": 100,
"world": 200,
}
for key, value := range m {
fmt.Println(key, value)
}
// 遍历channel
c := make(chan int)
go func() {
c <- 1 // 往通道里面添加元素
c <- 2
c <- 3
close(c)
}()
for v := range c {
fmt.Println(v)
}
4.4.3 goto跳转
Go语言中 goto 语句通过标签进行代码间的无条件跳转,同时 goto 语句在快速跳出循环、避免重复退出上也有一定的帮助
func main() {
for x := 0; x < 10; x++ {
for y := 0; y < 10; y++ {
if y == 2 {
// 跳转到标签
goto breakHere
}
}
}
// 手动返回, 避免执行进入标签
return
// 标签
breakHere:
fmt.Println("done")
}
// 集中处理错误
err := firstCheckError()
if err != nil {
goto onExit
}
err = secondCheckError()
if err != nil {
goto onExit
}
fmt.Println("done")
return
onExit:
fmt.Println(err)
exitProcess()
// goto语法跳来跳去, 会影响程序的可读性, 尽量少用, 或者针对特定的功能只用
5 函数和结构体
5.1 函数
5.1.1 基本介绍
Go语言中,函数的基本组成为:关键字 func、函数名、参数列表、返回值、函数体和返回语句
Go语言里面的函数:
- 普通的带有名字的函数
- 匿名函数
// 普通函数
func 函数名(形式参数列表)(返回值列表){
函数体
}
// 如果一组形参或返回值有相同的类型,我们不必为每个形参都写出参数类型
func hypot(x, y float64) float64 { // 等价于 func hypot(x float64, y float64)
return math.Sqrt(x*x + y*y)
}
// 在函数中,实参通过值传递的方式进行传递,因此函数的形参是实参的拷贝,对形参进行修改不会影响实参
// 但是,如果实参包括引用类型,如指针、slice(切片)、map、function、channel 等类型,实参可能会由于函数的间接引用被修改
// go语言支持多返回值,类似于python
// Go语言经常使用多返回值中的最后一个返回参数返回函数执行中可能发生的错误
// conn, err := connectToNetwork()
func typedTwoValues() (int, int) { // 这种不带变量名的写法也不太好,容易造成混乱
return 1, 2
}
func namedRetValues() (a, b int) { // 带有变量名的返回值
a = 1
b = 2
return a, b // 这里可写成return, 但感觉不太好理解,最好还是写上
}
func main() {
a, b := typedTwoValues()
fmt.Println(a, b)
// 在Go语言中,函数也是一种类型,可以和其他类型一样保存在变量中
var tt func()(a, b int)
tt = namedRetValues
c, d := tt()
fmt.Println(c, d)
}
Go语言中传入与返回参数在调用和返回时都使用值传递,这里需要注意的是指针、切片和 map 等引用型对象在参数传递中不会发生复制,而是将指针进行复制,类似于创建一次引用
// 匿名函数: 不需要定义函数名的一种函数实现方式,由一个不带函数名的函数声明和函数体组成
func(参数列表)(返回参数列表){
函数体
}
// 1. 定义时调用匿名函数
func(data int) {
fmt.Println("hello", data)
}(100)
// 2. 将匿名函数赋值给变量,通过变量去调用
f := func(data int) {
fmt.Println("hello", data)
}
// 使用f()调用
f(100)
// 匿名函数的用途非常广泛,它本身就是一种值,可以方便地保存在各种容器中实现回调函数和操作封装
// 遍历切片的每个元素, 通过给定函数进行元素访问
func visit(list []int, f func(int)) {
for _, v := range list {
f(v)
}
}
func main() {
// 使用匿名函数打印切片内容
visit([]int{1, 2, 3, 4}, **func(v int) {
fmt.Println(v)
}**)
}
5.1.2 可变参数
可变参数:指函数传入的参数个数是可变的
func myfunc(args ...int) { // 接收的参数是可变的,且都是int 类似于Python的def myfunc(*args)
for _, arg := range args {
fmt.Println(arg)
}
}
// ...type格式的类型只能作为函数的参数类型存在,并且必须是最后一个参数
// 从内部实现机理上来说,类型...type本质上是一个数组切片,也就是[]type,这也是为什么上面的参数 args 可以用 for 循环来获得每个传入的参数。
// 任意类型的可变参数
func MyPrintf(args ...interface{}) {
for _, arg := range args {
// 接收过来之后,可以用下面这种方法判断类型,针对不同类型做操作
switch arg.(type) {
case int:
fmt.Println(arg, "is an int value.")
case string:
fmt.Println(arg, "is a string value.")
case int64:
fmt.Println(arg, "is an int64 value.")
default:
fmt.Println(arg, "is an unknown type.")
}
}
}
5.1.3 defer
go语言中的defer(延迟执行语句)
// Go语言的 defer 语句会将其后面跟随的语句进行延迟处理
// 在 defer 归属的函数即将返回时,将延迟处理的语句按 defer 的逆序进行执行,也就是说,先被 defer 的语句最后被执行,最后被 defer 的语句,最先被执行。
// 关键字 defer 的用法类似于面向对象编程语言 Java 和 C# 的 finally 语句块,它一般用于释放某些已分配的资源,典型的例子就是对一个互斥解锁,或者关闭一个文件。
func main() {
fmt.Println("defer begin")
// 将defer放入延迟调用栈
defer fmt.Println(1)
defer fmt.Println(2)
// 最后一个放入, 位于栈顶, 最先调用
defer fmt.Println(3)
fmt.Println("defer end")
}
// 最后的执行结果 defer首先是延迟到最后执行,其次在多个defer里面, 先defer的后执行
defer begin
defer end
3
2
1
// 使用延迟语句在函数退出时释放资源
// 处理业务或逻辑中涉及成对的操作是一件比较烦琐的事情,比如打开和关闭文件、接收请求和回复请求、加锁和解锁等。在这些操作中,最容易忽略的就是在每个函数退出处正确地释放和关闭资源。
// defer 语句正好是在函数退出时执行的语句,所以使用 defer 能非常方便地处理资源释放问题。
// demo1 使用延迟并发解锁
var (
// 一个演示用的映射, map 默认不是并发安全的,准备一个 sync.Mutex 互斥量保护 map 的访问
valueByKey = make(map[string]int)
// 保证使用映射时的并发安全的互斥锁, 假设会并发使用上面的map
valueByKeyGuard sync.Mutex
)
// readValue() 函数给定一个键,从 map 中获得值后返回,该函数会在并发环境中使用,需要保证并发安全
func readValue(key string) int {
// 对共享资源加锁
valueByKeyGuard.Lock()
// 取值
v := valueByKey[key]
// 对共享资源解锁
valueByKeyGuard.Unlock()
// 返回值
return v
}
// 上面这个readValue函数,可以用defer实现的更加优雅
func readValue(key string) int {
valueByKeyGuard.Lock()
// defer后面的语句不会马上调用, 而是延迟到readValue函数结束时调用
defer valueByKeyGuard.Unlock()
return valueByKey[key]
}
// demo2 关闭文件资源
func fileSize(filename string) int64 {
f, err := os.Open(filename)
if err != nil {
return 0
}
// 延迟调用Close, 此时Close不会被调用
defer f.Close()
info, err := f.Stat()
if err != nil {
// defer机制触发, 调用Close关闭文件
return 0
}
size := info.Size()
// defer机制触发, 调用Close关闭文件
return size
}
5.1.4 执行时间
go语言中计算函数执行时间
// func Since(t Time) Duration
// Since() 函数返回从 t 到现在经过的时间,等价于time.Now().Sub(t)
import "fmt"
import "time"
func main() {
start := time.Now() // 获取当前时间
sum := 0
for i := 0; i < 100000000; i++ {
sum++
}
elapsed := time.Since(start) // 或者 elapsed := time.Now().Sub(start)
fmt.Println("该函数执行完成耗时:", elapsed) // 该函数执行完成耗时: 60.67188ms
}
5.2 结构体
5.2.1 基本介绍
结构体就是把多个任意类型的变量组合到一块形成新的实体,是一种复合类型。 其中每个字段叫作结构体的成员,有以下特性:
- 字段拥有自己的类型和值;
- 字段名必须唯一;
- 字段的类型也可以是结构体,甚至是字段所在结构体的类型。
type 类型名 struct {
字段1 字段1类型
字段2 字段2类型
…
}
// example
// 结构体的定义只是一种内存布局的描述,只有当结构体实例化时,才会真正地分配内存
// 实例化就是根据结构体定义的格式创建一份与格式一致的内存区域,结构体实例与实例间的内存是完全独立的
type People struct{
name string
age int
}
// 基本实例化
var p people
p.name = "zhongqiang"
p.age = 29
fmt.Printf("%T\n", p) // main.People
fmt.Println(p) // {zhongqiang 29}
// Go语言中,还可以使用 new 关键字对类型(包括结构体、整型、浮点数、字符串等)进行实例化,结构体在实例化后会形成指针类型的结构体
p2 := new(People)
p2.name = "zhangsan" // 成员变量的实例化和上面的基本实例化一致,原因是go背后做了一步操作,将p2.name转成(*p2).name了, 与c/c++不一样, 后者是p2->name去赋值成员变量
p2.age = 30
fmt.Printf("%T\n", p2) // *mian.People %T打印类型
fmt.Println(p2, p2.name, p2.age) // &{zhangsan 30} zhangsan 30
// 取结构体地址进行实例化,这也是结构体实例化比较常用的一种方式
p3 := &People{}
p3.name = "lisi"
p3.age = 30
fmt.Printf("%T\n", p3) // *mian.People 指针类型
fmt.Println(p3, p3.name, p3.age)
// 初始化成员变量
type People struct{
name string
age int
child *People
}
func main() {
// 键值对的方式初始化, 也可以不加&,直接初始化结构体, 加&是通过结构体地址去实例化结构体,都可以,访问成员变量和方法上在go里面没区别
p := &People{
name: "zhongqiang",
age: 30,
child: &People{
name: "son",
age: 3,
},
}
fmt.Println(p)
// 多个值列表方式
// 必须初始化结构体的所有字段, 每一个初始值的填充顺序必须与字段在结构体中的声明顺序一致,键值对与值列表的初始化形式不能混用
p2 := &People{
"zhongqiang",
30,
&People{
"son",
3,
nil,
},
}
fmt.Println(p2)
}
5.2.2 接收器与方法
go语言中, 没有明确声明类的操作, 而是使用结构体作为了简化。那么类的方法是什么呢? Go语言中有一个概念,它和方法有着同样的名字,并且大体上意思相同,Go 方法是作用在接收器(receiver)上的一个函数,接收器是某种类型的变量,因此方法是一种特殊类型的函数。
接收器类型可以是(几乎)任何类型,不仅仅是结构体类型,任何类型都可以有方法,甚至可以是函数类型,可以是 int、bool、string 或数组的别名类型,但是接收器不能是一个接口类型,因为接口是一个抽象定义,而方法却是具体实现
一个类型加上它的方法等价于面向对象中的一个类,一个重要的区别是,在Go语言中,类型的代码和绑定在它上面的方法的代码可以不放置在一起,它们可以存在不同的源文件中,唯一的要求是它们必须是同一个包的
在面向对象的语言中,类拥有的方法一般被理解为类可以做的事情。在Go语言中“方法”的概念与其他语言一致,只是Go语言建立的“接收器”强调方法的作用对象是接收器,也就是类实例,而函数没有作用的对象。
上面的意思,也就是说, go语言和java或者python在面向对象方面编程方法还是不一样的,在java这种强面向对象里面,可以声明一个class, 里面有属于该class的属性以及操作属性的方法,并且建立出来的class还可以继承, 有多态等。 **面向对象是一种人类很容易理解的设计思想。**go里面是没法显式定义class这个东西的,但可以借助结构体和作用在接收器(结构体的实例)来实现类似于class的成员方法, 比如可以在struct里面定义各种”类”的各种属性, 然后定义一系列作用在该”类”上的方法进行操作, 把该类的实例作为接收器传进去即可。对比下就明白了:
// java面向对象
public class People {
String name; // 成员变量
int age;
// 无参构造函数
public People() {
this(null, 0);
}
// 全参构造函数
public People(String name, int age) {
this.name = name;
this.age = age;
}
// 成员方法
public void Eat() {
System.out.println("zhongqiang吃饭");
}
}
// go 面向对象
// ---Go 语言的 “类”---
type People struct {
name string // 成员变量
age int
}
// --- Go 中的 “构造方法”
// NewDefaultPeople 可以看作是无参构造方法
func NewDefaultPeople() *People {
return NewPeople("", 0)
}
// NewPeople 可以看作是全参构造函数
func NewPeople(name string, age int) *People {
return &People{
name: name,
age: age,
}
}
// ---Go 语言的 “成员方法”---
func (p *People) Eat() {
fmt.Println("zhongqiang吃饭")
}
所以通过上面这个例子对比,可以看出go所有的方法都是单独存在于外界的,但每一个结构体都可以通过一些标识符(接收器),明确说明哪些方法属于自己。另外go也没有构造函数这些概念,但是它也可以写一些函数,当作是构造函数来使用。可以用它们,快速的创建对象。所以, 关于对象编程,对于go,先了解下面几个点:
- Go 语言中,没有对象(但是开发时通常叫做对象),没有类,也没有继承,也不能直接使用多态。
- 它通过组合匿名字段的手段,来达到类似继承的效果。
- 它只能通过接口,来实现的多态,使一个类可以有不同的实现。
- 将一类事物,抽象成struct的过程,这才是封装的主要部分
- Go语言 sturct 方法的写法,和其他面向对象语言中 this 的本质很像
明白了这一点之后, 再对比下go语言里面带接收器的方法, 和普通方法的不同之处,还是拿上面的例子:
package main
import "fmt"
type People struct{
name string
age int
}
// 面向过程的普通方法
// Eat()函数将*People参数放在第一位,强调Eat()会操作*People结构体,但实际使用中,并不是每个人都会习惯将操作对象放在首位,一定程度上让代码失去一些范式和描述性。
// 同时,Eat()函数也与People没有任何归属概念,随着类似Eat的函数越来越多,面向过程的代码描述对象方法概念会越来越麻烦和难以理解
func Eat(p *People) {
fmt.Printf("%s %d 在吃饭", p.name, p.age)
}
// 带接收器的方法
// Eat方法归于People, 可以通过People的实例直接访问
// (p*People)表示接收器,即Eat作用的对象实例
func (p *People) Eat(){
fmt.Printf("%s %d 在吃饭", p.name, p.age)
}
func main() {
p := &People{
name: "zhongqiang",
age: 30,
}
Eat(p)
// 这个可以像面向对象的方式去调用了
p.Eat()
}
好,下面给出接收器方法的格式:
func (接收器变量 接收器类型) 方法名(参数列表) (返回参数) {
函数体
}
// 接收器变量:接收器中的参数变量名在命名时,官方建议使用接收器类型名的第一个小写字母,而不是 self、this 之类的命名
// 接收器类型:接收器类型和参数类似,可以是指针类型和非指针类型
// 方法名、参数列表、返回参数:格式与函数定义一致
// 接收器根据接收器的类型可以分为指针接收器、非指针接收器,两种接收器在使用时会产生不同的效果,根据效果的不同,两种接收器会被用于不同性能和功能要求的代码中
// 指针类型的接收器
// 由一个结构体的指针组成,更接近于面向对象中的 this 或者 self
// 由于指针的特性,调用方法时,修改接收器指针的任意成员变量,在方法结束后,修改都是有效的
func (p *People) setName(name string){
p.name = name
}
// 写一个people的构造函数
func NewPeople(name string, age int) *People{
return &People{
name: name,
age: age,
}
}
func main() {
p := &People{
name: "zhongqiang",
age: 30,
}
p.setName("wuzhongqiang")
fmt.Println(p) // &{wuzhongqiang 30}
p2 := NewPeople("zhangsan", 30)
fmt.Println(p2) // &{zhangsan 30}
}
// 非指针类型的接收器
//当方法作用于非指针接收器时,Go语言会在代码运行时将接收器的值复制一份,在非指针接收器的方法中可以获取接收器的成员值,但修改后无效
// 定义点结构
type Point struct {
X int
Y int
}
// 非指针接收器的加方法
func (p Point) Add(other Point) Point {
// 成员值与参数相加后返回新的结构
return Point{p.X + other.X, p.Y + other.Y}
}
func main() {
// 初始化点
p1 := Point{1, 1}
p2 := Point{2, 2}
// 与另外一个点相加
result := p1.Add(p2)
// 输出结果
fmt.Println(result)
}
// 由于例子中使用了非指针接收器,Add() 方法变得类似于只读的方法,Add() 方法内部不会对成员进行任何修改
// 在计算机中,小对象由于值复制时的速度较快,所以适合使用非指针接收器,大对象因为复制性能较低,适合使用指针接收器,在接收器和参数间传递时不进行复制,只是传递指针
5.2.3 结构体内嵌
结构体的内嵌来模拟类的继承
在面向对象思想中,实现对象关系需要使用“继承”特性,比如猫和狗都是动物, 都有动物的一些基本方法(睡觉)等,但又各自有自己的一些方法,比如不同的叫声等。go语言可以通过结构体的内嵌来实现类的这种”继承”
// 下面实现个例子
package main
import "fmt"
type Animal struct{
name string
age int
color string
}
func (a *Animal) sleep(){
fmt.Println(a.name, a.age, a.color, "在睡觉")
}
type Dog struct{
// 结构体内嵌,内嵌完了可以直接通过Dog对象.name, .age, .color访问Animal的成员
Animal
}
func (d *Dog) jiao(){
fmt.Println(d.name, d.age, d.color, "wangwang")
}
type Cat struct{
// 结构体内嵌
Animal
}
func (c *Cat) jiao(){
fmt.Println(c.name, c.age, c.color, "miaomiao")
}
func main() {
d1 := Dog{
// 内嵌结构体初始化,可以和普通结构体初始化一样
Animal{"xiaogou", 3, "yellow"},
}
c1 := Cat{
Animal{"xiaomao", 2, "blue"},
}
d1.jiao()
c1.jiao()
}
// 内嵌结构体的特性
// 内嵌的结构体可以直接访问其成员变量: 嵌入结构体的成员,可以通过外部结构体的实例直接访问。如果结构体有多层嵌入结构体,结构体实例访问任意一级的嵌入结构体成员时都只用给出字段名
// 内嵌结构体的字段名是它的类型名,内嵌结构体字段仍然可以使用详细的字段进行一层层访问,内嵌结构体的字段名就是它的类型名 比如d1.Animal.name, 一个结构体只能嵌入一个同类型的成员
6. 小总
基础篇的文章整理到这里啦,这篇文章内容比较多,但偏基础,比较简单,算是复习了一遍编程语言的基础部分,变量常量,数据类型,容器,流程控制等,在变量常量,数据类型,流程控制,函数等和C或者C++等都差不多,可能是写法上会有一些区别,但整体上比较好理解,而容器部分呢, 和python的列表,集合等,或者C++里面的STL有些像, 结构体部分和C的结构体很像,和C++或者python的类有些像, 但和类也有一些区别,写法和设计理念上不太一样,所以采用这种对比学习的方式,方便我们更好的理解这些编程语言之间的异同, 学习go的同时,也顺便复习复习其他的编程语言吧 😉
后面一篇文章作为进阶,主要介绍go里面的接口,并发和反射相关的内容,Rush!