文章目录

  • Go语言 · 学习笔记
    • 一、Go包管理
      • 1. 什么是Go语言中的包
      • 2. 包的命名
      • 3. main包
      • 4. 导入包
      • 5. 远程包导入
      • 6. 命名导入
      • 7. 包的init函数
    • 二、Go开发工具
      • 1. Go 开发工具概览
      • 2. go build
      • 3. go clean
      • 4. go run
      • 5. go env
      • 6. go install
      • 7. go get
      • 8. go fmt
      • 9. go vet
      • 10. go test
    • 三、Go Doc 文档
      • 1. 从终端查看文档
      • 2. 在线浏览文档
      • 3. 生成自己的文档
      • 4. 添加文档示例
    • 四、Go 数组
      • 1. 内部实现
      • 2. 声明和初始化
      • 3. 使用数组
      • 4. 函数间传递数组
      • *5. 多维数组
    • 五、Go 切片
      • 1. 内部实现
      • 2. 声明和初始化
      • 3. 使用切片
      • 4. 迭代切片
      • 5. 在函数间传递切片
      • *6. 多维切片
    • 六、Go Map
      • 1. 内部实现
      • 2. 声明和初始化
      • 3. 使用Map
      • 4. 在函数间传递Map
    • 七、Go 类型
      • 1. 基本类型
      • 2. 引用类型
      • 3. 结构类型
      • 4. 自定义类型
    • 八、Go 函数方法
      • 1. 函数
      • 2. 方法
      • 3. 多值返回
      • 4. 可变参数
    • 九、Go 接口(多态)
      • 1. 内部实现
      • 2. 方法集
      • 3. 标准库
    • 十、Go 嵌入类型(继承)
    • 十一、Go 标志符可见性(封装)
    • 十二、Go goroutine(协程)
    • 十三、Go 并发资源竞争
    • 十四、Go 通道
      • 1. 无缓冲的通道
      • 2. 管道
      • 3. 有缓冲的通道
      • 4. 单向通道
    • 十五、Go 并发示例-Runner

Go语言 · 学习笔记

前言:下面的内容都是边看【飞雪无情】大佬的博客,自己边整理,其中部分内容有过删改,推荐大家去看原作者的博客进行学习,本博客内容仅作为自己的学习笔记。在此之前,我跟着b站韩茹老师刷完了Go语言入门教程。

学习链接:https://www.flysnow.org/archives/
参考书籍:《Go语言实战》《Go语言学习笔记》

一、Go包管理

1. 什么是Go语言中的包

我们在使用其他语言,比如Java,是有包的概念的,它是Java语言中组织我们的Java文件的一个概念,比如java.lang这个包,他里面有很多我们常用的类,比如String。在Go语言中,包也是类似的概念,它是把我们的go文件组织起来,可以方便进行归类、复用等目的。 比如Go内置的net包:

net
├── http
├── internal
├── mail
├── rpc
├── smtp
├── testdata
├── textproto
└── url

以上是net包的一个目录结构,net本身是一个包,net目录下的http又是一个包。从这里大家可以看到,go语言的包其实就是我们计算机里的目录,或者叫文件夹,通过它们进行目录结构和文件组织,go只是对目录名字做了一个翻译,叫【包】而已。比如这里的net包其实就是net目录,http包其实就是http目录,这也是go语言中的一个命名习惯,包名和文件所在的目录名是一样的。

2. 包的命名

go语言的包的命名,遵循简洁、全小写、和go文件所在目录同名的原则,这样就便于我们引用,书写以及快速定位查找。

比如go自带的http这个包,它这个http目录下的所有go文件都属于这个http包,所以我们使用http包里的函数、接口的时候,导入这个http包就可以了。

package mainimport "net/http"func main() {http.ListenAndServe("127.0.0.1:8080",handler);
}

从这个例子可以看到,我们导入的是net/http,这在go里叫做全路径,因为http包在net里面,net是最顶级的包,所以必须使用全路径导入,go编译程序才能找到http这个包,和我们文件系统的目录路径是一样的。

因为有了全路径,所以命名的包名可以和其他库的一样,只要它们的全路径不同就可以了,使用全路径的导入,也增加了给包名命名的灵活性。

对于自己或者公司开发的程序而言,我们一般采用域名作为顶级包名的方式,这样就不用担心和其他开发者包名重复的问题了,比如个人域名是www.flysnow.org,那么我自己开发的go程序都以flysnow.org作为全路径中的最顶层部分,比如导入自己开发的一个工具包:

package mainimport "flysnow.org/tools"

如果你没有自己的域名,怎么办呢?这时候可以使用github.com。干研发这一行的,在github都会有个账号,如果没有赶紧申请一个,这时候我们就可以使用github.com/<username>作为你的顶级路径了,别人是不会和你重名的。

package mainimport "github.com/rujews/tools"

这就是换成github.com命名的方式。

3. main包

当把一个go文件的包名声明为main时,就等于告诉go编译程序,我这个是一个可执行的程序,那么go编译程序就会尝试把它编译为一个二进制的可执行文件。

一个main的包,一定会包含一个main()函数,这种我们也不陌生,比如C和Java都有main()函数,它是一个程序的入口,没这个函数,程序就无法执行。

在go语言里,同时要满足main包和包含main()函数,才会被编译成一个可执行文件。

我们看一个Hello World的Go语言版本,来说明main 包。

package mainimport "fmt"func main() {fmt.Println("Hello, Golang")
}

假设该go文件叫hello.go,放在$GOPATH/src/hello目录下(补充:自从有了go module后不必一定在此目录下了),那么我们在这个目录下执行go build命令就会生成二进制的可执行文件,在window系统下生成的是hello.exe,在Unix,MAC和Linux下生成的是hello,我们在CMD或者终端里执行它,就可以看到控制台打印的:

Hello, Golang

二进制可执行文件的名字,就是该main包的go文件所在目录的名字,因为hello.go在hello目录下,所以生成的可执行文件就是hello这个名字。

4. 导入包

要想使用一个包,必须先导入它才可以使用,Go语言提供了import关键字来导入一个包,这个关键字告诉Go编译器到磁盘的哪里去找要想导入的包,所以导入的包必须是一个全路径的包,也就是包所在的位置。

import "fmt"

这就表示我们导入了fmt包,也就等于告诉go编译器,我们要使用这个包下面的代码。如果要导入多个包怎么办呢?Go语言还为我们提供的导入块。

import ("net/http""fmt"
)

使用一对括号包含的导入块,每个包独占一行。

对于多于一个路径的包名,在代码中引用的时候,使用全路径最后一个包名作为引用的包名,比如net/http,我们在代码使用的是http,而不是net

(补充:习惯上将包和目录名保持一致,但这不是强制规定。在代码中引入包成员时,使用包名而非目录名)

现在我导入了包,那么编译的时候,go编译器去什么位置找他们呢?这里就要介绍下Go的环境变量了。Go有两个很重要的环境变量GOROOTGOPATH,这是两个定义路径的环境变量,GOROOT是安装Go的路径,比如/usr/local/goGOPATH是我们自己定义的开发者个人的工作空间,比如/home/flysnow/go(补充,go module是G01.11版本之后官方推出来的版本管理管理,GOPATH的设置就显得没那么重要了,系统会有默认设置)。

编译器会使用我们设置的这两个路径,再加上import导入的相对全路径来查找磁盘上的包,比如我们导入的fmt包,编译器最终找到的是/usr/local/go/fmt这个位置。(补充:标准库中的包会在按照Go的位置找到;Go开发者创建的包会在GOPATH环境变量指定的目录中查找)

值得了解的是:对于包的查找,是有优先级的,编译器会优先在GOROOT里搜索,其次是GOPATH,一旦找到,就会马上停止搜索。如果最终都没找到,就报编译异常了。

5. 远程包导入

互联网的时代,现在大家使用类似于Github共享代码的越来越多,如果有的Go包共享在Github上,我们一样有办法使用他们,这就是远程导入包了,或者是网络导入,Go天生就支持这种情况,所以我们可以很随意的使用Github上的Go库开发程序。

import "github.com/spf13/cobra"

这种导入,前提必须是该包托管在一个分布式的版本控制系统上,比如Github、Bitbucket等,并且是Public的权限,可以让我们直接访问它们。

编译在导入它们的时候,会先在GOPATH下搜索这个包,如果没有找到,就会使用go get工具从版本控制系统(GitHub)获取,并且会把获取到的源代码存储在GOPATH目录下对应URL的目录里,以供编译使用。

go get工具可以递归获取依赖包,如果github.com/spf13/cobra也引用了其他的远程包,该工具可以一并下载下来(补充:这是由于go get的递归特性,该命令会扫描某个包的源码树,获取能找到的所有依赖包)。

6. 命名导入

我们知道,在使用import关键字导入包之后,我们就可以在代码中通过包名使用该包下相应的函数、接口等。如果我们导入的包名正好有重复的怎么办呢?针对这种情况,Go语言可以让我们对导入的包重新命名,这就是命名导入。

package mainimport ("fmt"myfmt "mylib/fmt"
)func main() {fmt.Println()myfmt.Println()
}

如果没有重新命名,那么对于编译器来说,这两个fmt它是区分不清楚的。重命名也很简单,在我们导入的时候,在包名的左侧,起一个新的包名就可以了。

Go语言规定,导入的包必须要使用(补充:非常喜欢Go的这一规则!),否则会报编译错误,这是一个非常好的规则,因为这样可以避免我们引用很多无用的代码而导致的代码臃肿和程序的庞大(补充:比如像C++中导入一大堆没有用的库也不会报错,自己在读源码的时候就很烦),因为很多时候,我们都不知道哪些包是否使用,这在C和Java上会经常遇到,有时候我们不得不借助工具来查找我们没有使用的文件、类型、方法和变量等,把它们清理掉。

但是有时候,我们需要导入一个包,但是又不使用它,按照规则,这是不行的,为此Go语言给我们提供了一个空白标志符_,只需要我们使用_重命名我们导入的包就可以了。

package mainimport (_ "mylib/fmt"  // (补充:导入但不使用---》执行其中的init函数)
)

(补充:归纳起来,对于包有四种不同的导入方式:

import "github.com/qyuhen/test"        // 默认方式:test.A
import X "github.com/qyuhen/test" // 别名方式:X.A
import . "github.com/qyuhen/test" // 简便方式:A
import _ "github.com/qyuhen/test" // 初始化方式:无法引用,仅用来初始化目标包

简便方式常用于单元测试代码中,不推荐在正式项目代码中使用。

初始化方式仅仅是为了让目标包的初始化函数得以执行,而非引用其成员。

7. 包的init函数

每个包都可以有任意多个init函数,这些init函数都会在main函数之前执行。init函数通常用来做初始化变量、设置包或者其他需要在程序执行前的引导工作。比如上面我们讲的需要使用_空标志符来导入一个包的目的,就是想执行这个包里的init函数。

补充:Go 语言中的 init () 执行顺序:

我们以数据库的驱动为例,Go语言为了统一关于数据库的访问,使用databases/sql抽象了一层数据库的操作,可以满足我们操作MySQL、Postgre等数据库,这样不管我们使用这些数据库的哪个驱动,编码操作都是一样的,想换驱动的时候,就可以直接换掉,而不用修改具体的代码。

这些数据库驱动的实现,就是具体的,可以由任何人实现的,它的原理就是定义了init函数,在程序运行之前,把实现好的驱动注册到sql包里,这样我们就使用使用它操作数据库了。

(补充:上面两段内容没能懂)

package mysqlimport ("database/sql"
)func init() {sql.Register("mysql", &MySQLDriver{})
}

因为我们只是想执行这个mysql包的init方法,并不想使用这个包,所以我们在导入这个包的时候,需要使用_重命名包名,避免编译错误。

import "database/sql"
import _ "github.com/go-sql-driver/mysql"db, err := sql.Open("mysql", "user:password@/dbname")

看非常简洁,剩下针对的数据库的操作,都是使用的database/sql标准接口,如果我们想换一个mysql的驱动的话,只需要换个导入就可以了,灵活方便,这也是面向接口编程的便利。

二、Go开发工具

在Go语言中,我们很多操作都是通过go命令进行的,比如我们要执行go文件的编译,就需要使用go build命令,除了build命令之外,还有很多常用的命令,这一次我们就统一进行介绍,对常用命令有一个了解,这样我们就可以更容易的开发我们的Go程序了。

1. Go 开发工具概览

go这个工具,别看名字短小,其实非常强大,是一个强大的开发工具,让我们打开终端,看看这个工具有哪些能力。

➜  ~ go
Go is a tool for managing Go source code.Usage:go command [arguments]The commands are:build       compile packages and dependenciesclean       remove object filesdoc         show documentation for package or symbolenv         print Go environment informationbug         start a bug reportfix         run go tool fix on packagesfmt         run gofmt on package sourcesgenerate    generate Go files by processing sourceget         download and install packages and dependenciesinstall     compile and install packages and dependencieslist        list packagesrun         compile and run Go programtest        test packagestool        run specified go toolversion     print Go versionvet         run go tool vet on packagesUse "go help [command]" for more information about a command.Additional help topics:c           calling between Go and Cbuildmode   description of build modesfiletype    file typesgopath      GOPATH environment variableenvironment environment variablesimportpath  import path syntaxpackages    description of package liststestflag    description of testing flagstestfunc    description of testing functionsUse "go help [topic]" for more information about that topic.

可以发现,go支持的子命令很多,同时还支持查看一些【主题】。我们可以使用go help [command]或者go help [topic]查看一些命令的使用帮助,或者关于某个主题的信息。大部分go的命令,都是接受一个全路径的包名作为参数,比如我们经常用的go build

2. go build

go build,是我们非常常用的命令,它可以启动编译,把我们的包和相关的依赖编译成一个可执行的文件。

usage: go build [-o output] [-i] [build flags] [packages]

go build的使用比较简洁,所有的参数都可以忽略,直到只有go build,这个时候意味着使用当前目录进行编译,下面的几条命令是等价的:

go buildgo build .go build hello.go

以上这三种写法,都是使用当前目录编译的意思。因为我们忽略了packages,所以自然就使用当前目录进行编译了。从这里我们也可以推测出,go build本质上需要的是一个路径,让编译器可以找到哪些需要编译的go文件。packages其实是一个相对路径,是相对于我们定义的GOROOTGOPATH这两个环境变量的,所以有了packages这个参数后,go build就可以知道哪些需要编译的go文件了。

go build flysnow.org/tools

这种方式是指定包的方式,这样会明确的编译我们这个包。当然我们也可以使用通配符。

go build flysnow.org/tools/...

3个点表示匹配所有字符串,这样go build就会编译tools目录下的所有包。

讲到go build编译,不能不提跨平台编译,Go提供了编译链工具,可以让我们在任何一个开发平台上,编译出其他平台的可执行文件。

默认情况下,都是根据我们当前的机器生成的可执行文件,比如你的是Linux 64位,就会生成Linux 64位下的可执行文件,比如Mac,可以使用go env查看编译环境,以下截取重要的部分。

➜  ~ go env
GOARCH="amd64"
GOEXE=""
GOHOSTARCH="amd64"
GOHOSTOS="darwin"
GOOS="darwin"
GOROOT="/usr/local/go"
GOTOOLDIR="/usr/local/go/pkg/tool/darwin_amd64"

注意里面两个重要的环境变量GOOS和GOARCH,其中GOOS指的是目标操作系统,它的可用值为:

  1. darwin
  2. freebsd
  3. linux
  4. windows
  5. android
  6. dragonfly
  7. netbsd
  8. openbsd
  9. plan9
  10. solaris

一共支持10中操作系统。GOARCH指的是目标处理器的架构,目前支持的有:

  1. arm
  2. arm64
  3. 386
  4. amd64
  5. ppc64
  6. ppc64le
  7. mips64
  8. mips64le
  9. s390x

一共支持9中处理器的架构,GOOS和GOARCH组合起来,支持生成的可执行程序种类很多,具体组合参考https://golang.org/doc/install/source#environment。如果我们要生成不同平台架构的可执行程序,只要改变这两个环境变量就可以了,比如要生成linux 64位的程序,命令如下:

GOOS=linux GOARCH=amd64 go build flysnow.org/hello

前面两个赋值,是更改环境变量,这样的好处是只针对本次运行有效,不会更改我们默认的配置。

以上这些用法差不多够我们用的了,更多关于go build的用户可以通过以下命令查看:

go help build

3. go clean

在我们使用go build编译的时候,会产生编译生成的文件,尤其是在我们签入代码的时候,并不想把我们生成的文件也签入到我们的Git代码库中,这时候我们可以手动删除生成的文件,但是有时候会忘记,也很麻烦,不小心还是会提交到Git中。要解决这个问题,我们可以使用go clean,它可以清理我们编译生成的文件,比如生成的可执行文件,生成obj对象等等。

usage: go clean [-i] [-r] [-n] [-x] [build flags] [packages]

用法和go build基本一样,这样不再进行详细举例演示,可以参考go build的使用,更多关于go clean的使用,可以使用如下命令查看:

go help clean

4. go run

go build是先编译,然后我们在执行可以执行文件来运行我们的程序,需要两步。go run这个命令就是可以把这两步合成一步的命令,节省了我们录入的时间,通过go run命令,我们可以直接看到输出的结果。

➜  ~ go help run
usage: go run [build flags] [-exec xprog] gofiles... [arguments...]Run compiles and runs the main package comprising the named Go source files.
A Go source file is defined to be a file ending in a literal ".go" suffix.By default, 'go run' runs the compiled binary directly: 'a.out arguments...'.
If the -exec flag is given, 'go run' invokes the binary using xprog:'xprog a.out arguments...'.
If the -exec flag is not given, GOOS or GOARCH is different from the system
default, and a program named go_$GOOS_$GOARCH_exec can be found
on the current search path, 'go run' invokes the binary using that program,
for example 'go_nacl_386_exec a.out arguments...'. This allows execution of
cross-compiled programs when a simulator or other execution method is
available.For more about build flags, see 'go help build'.

go run命令需要一个go文件作为参数,这个go文件必须包含main包和main函数,这样才可以运行,其他的参数和go build差不多。 在运行go run的时候,如果需要的话,我们可以给我们的程序传递参数,比如:

package mainimport ("fmt""os"
)func main() {fmt.Println("输入的参数为:",os.Args[1])
}

打开终端,输入如下命令执行:

go run main.go 12

这时候我们就可以看到输出:

输入的参数为: 12

5. go env

在前面讲go build的时候,我们使用了go env命令查看了我们当前的go环境信息。

➜  hello go help env
usage: go env [var ...]Env prints Go environment information.By default env prints information as a shell script
(on Windows, a batch file).  If one or more variable
names is given as arguments,  env prints the value of
each named variable on its own line.

使用go env查看我们的go环境信息,便于我们进行调试,排错等,因为有时候我们会遇到一些莫名其妙的问题,比如本来在MAC上开发,怎么编译出一个Linux的可执行文件等,遇到这类问题时,先查看我们的go环境信息,看看有没有哪里配置错了,一步步排错。

6. go install

从其名字上我们不难猜出这个命令是做什么的,它和go build类似,不过它可以在编译后,把生成的可执行文件或者库安装到对应的目录下,以供使用。

➜  hello go help install
usage: go install [build flags] [packages]Install compiles and installs the packages named by the import paths,
along with their dependencies.

它的用法和go build差不多,如果不指定一个包名,就使用当前目录。安装的目录都是约定好的,如果生成的是可执行文件,那么安装在$GOPATH/bin目录下;如果是可引用的库,那么安装在$GOPATH/pkg目录下。

7. go get

go get命令,可以从网上下载更新指定的包以及依赖的包,并对它们进行编译和安装。

go get github.com/spf13/cobra

以上示例,我们就可以从github上直接下载这个go库到我们GOPATH工作空间中,以供我们使用。下载的是整个源代码工程,并且会根据它们编译和安装,和执行go install类似。

go get支持大多数版本控制系统(VCS),比如我们常用的git,通过它和包依赖管理结合,我们可以在代码中直接导入网络上的包以供我们使用。

如果我们需要更新网络上的一个go工程,加-u 标记即可。

go get -u github.com/spf13/cobra

类似的,启用-v标记,可以看到下载的进度以及更多的调试信息。关于go get 命令的更多用法,可以使用如下命令查看:

go help get

8. go fmt

这是go提供的最帅的一个命令了,它可以格式化我们的源代码的布局和Go源代码一样的风格,也就是统一代码风格,这样我们再也不用为大括号要不要放到行尾还是另起一行,缩进是使用空格还是tab而争论不休了,都给我们统一了。

func main() { fmt.Println("输入的参数为:", os.Args[1]) }

比如以上代码,我们执行go fmt 格式化后,会变成如下这样:

func main() {fmt.Println("输入的参数为:", os.Args[1])
}

go fmt也是接受一个包名作为参数,如果不传递,则使用当前目录。go fmt会自动格式化代码文件并保存,它本质上其实是调用的gofmt -l -w这个命令,我们看下gofmt的使用帮助。

➜  hello gofmt -h
usage: gofmt [flags] [path ...]-cpuprofile stringwrite cpu profile to this file-d   display diffs instead of rewriting files-e  report all errors (not just the first 10 on different lines)-l  list files whose formatting differs from gofmt's-r stringrewrite rule (e.g., 'a[b:len(a)] -> a[b:]')-s    simplify code-w write result to (source) file instead of stdout

go fmt 为我们统一了代码风格,这样我们在整个团队协作中发现,所有代码都是统一的,像一个人写的一样。所以我们的代码在提交到git库之前,一定要使用go fmt进行格式化,现在也有很多编辑器也可以在保存的时候,自动帮我们格式化代码。

9. go vet

这个命令不会帮助开发人员写代码,但是它也很有用,因为它会帮助我们检查我们代码中常见的错误。

  1. Printf这类的函数调用时,类型匹配了错误的参数。
  2. 定义常用的方法时,方法签名错误。
  3. 错误的结构标签。
  4. 没有指定字段名的结构字面量。
package mainimport ("fmt"
)func main() {fmt.Printf(" 哈哈",3.14)
}

这个例子是一个明显错误的例子,新手经常会犯,这里我们忘记输入了格式化的指令符,这种编辑器是检查不出来的,但是如果我们使用go vet就可以帮我们检查出这类常见的小错误。

➜  hello go vet
main.go:8: no formatting directive in Printf call

看,提示多明显。其使用方式和go fmt一样,也是接受一个包名作为参数。

usage: go vet [-n] [-x] [build flags] [packages]

养成在代码提交或者测试前,使用go vet检查代码的好习惯,可以避免一些常见问题。

10. go test

该命令用于Go的单元测试,它也是接受一个包名作为参数,如果没有指定,使用当前目录。 go test运行的单元测试必须符合go的测试要求。

  1. 写有单元测试的文件名,必须以_test.go结尾。
  2. 测试文件要包含若干个测试函数。
  3. 这些测试函数要以Test为前缀,还要接收一个*testing.T类型的参数。
package mainimport "testing"func TestAdd(t *testing.T) {if Add(1,2) == 3 {t.Log("1+2=3")}if Add(1,1) == 3 {t.Error("1+1=3")}
}

这是一个单元测试,保存在main_test.go文件中,对main包里的Add(a,b int)函数进行单元测试。 如果要运行这个单元测试,在该文件目录下,执行go test 即可。

➜  hello go test
PASS
ok      flysnow.org/hello   0.006s

以上是打印输出,测试通过。更多关于go test命令的使用,请通过如下命令查看。

go help test

以上这些,主要时介绍的go这个开发工具常用的命令,熟悉了之后可以帮助我们更好的开发编码。

其他关于go工具提供的主题介绍,比如package是什么等等,可以直接使用go help [topic]命令查看。

Additional help topics:c           calling between Go and Cbuildmode   description of build modesfiletype    file typesgopath      GOPATH environment variableenvironment environment variablesimportpath  import path syntaxpackages    description of package liststestflag    description of testing flagstestfunc    description of testing functionsUse "go help [topic]" for more information about that topic.

三、Go Doc 文档

对于协作开发或者代码共享来说,文档是一个可以帮助开发者快速了解以及使用这些代码的一个教程,文档越全面,越详细,入门越快,效率也会更高。

在Go语言中,Go为我们提供了快速生成文档以及查看文档的工具,让我们可以很容易的编写查看文档。

Go提供了两种查看文档的方式,一种是使用go doc命令在终端查看,这种适用于使用vim等工具在终端开发的人员,它们不用离开终端,既可以查看想查看的文档,又可以编码。

第二种方式,是使用浏览器查看的方式,通过godoc命令可以在本机启动一个web服务,我们可以通过打开浏览器,访问这个服务来查看我们的Go文档。

1. 从终端查看文档

这种方式适用于在终端开发的,它们一般不想离开终端,查完即可继续编码,这时候使用go doc命令是很不错的选择。

➜  hello go help doc
usage: go doc [-u] [-c] [package|[package.]symbol[.method]]Doc prints the documentation comments associated with the item identified by its
arguments (a package, const, func, type, var, or method) followed by a one-line
summary of each of the first-level items "under" that item (package-level
declarations for a package, methods for a type, etc.).Flags:-cRespect case when matching symbols.-cmdTreat a command (package main) like a regular package.Otherwise package main's exported symbols are hiddenwhen showing the package's top-level documentation.-uShow documentation for unexported as well as exportedsymbols and methods.

从以上可以看出,go doc的使用比较简单,接收的参数是包名,或者以包里的结构体、方法等。如果我们不输入任何参数,那么显示的是当前目录的文档,下面看个例子。

/*提供的常用库,有一些常用的方法,方便使用*/
package lib// 一个加法实现
// 返回a+b的值
func Add(a,b int) int {return a+b
}
➜  lib go doc
package lib // import "flysnow.org/hello/lib"提供的常用库,有一些常用的方法,方便使用func Add(a, b int) int

在当前目录执行go doc,输出了当前目录下的文档信息。

除此之外,我们还可以指定一个包,就可以列出当前这个包的信息,着包括文档、方法、结构体等。

➜  lib go doc json
package json // import "encoding/json"Package json implements encoding and decoding of JSON as defined in RFC
4627. The mapping between JSON and Go values is described in the
documentation for the Marshal and Unmarshal functions.See "JSON and Go" for an introduction to this package:
https://golang.org/doc/articles/json_and_go.htmlfunc Compact(dst *bytes.Buffer, src []byte) error
func HTMLEscape(dst *bytes.Buffer, src []byte)
func Indent(dst *bytes.Buffer, src []byte, prefix, indent string) error
func Marshal(v interface{}) ([]byte, error)
func MarshalIndent(v interface{}, prefix, indent string) ([]byte, error)
func Unmarshal(data []byte, v interface{}) error
type Decoder struct{ ... }func NewDecoder(r io.Reader) *Decoder
type Delim rune
type Encoder struct{ ... }func NewEncoder(w io.Writer) *Encoder
type InvalidUTF8Error struct{ ... }
type InvalidUnmarshalError struct{ ... }
type Marshaler interface{ ... }
type MarshalerError struct{ ... }
type Number string
type RawMessage []byte
type SyntaxError struct{ ... }
type Token interface{}
type UnmarshalFieldError struct{ ... }
type UnmarshalTypeError struct{ ... }
type Unmarshaler interface{ ... }
type UnsupportedTypeError struct{ ... }
type UnsupportedValueError struct{ ... }

以上是我们以json包为例,查看该包的文档,从中我们可以看到它有一个名为Decoder的结构体,我们进一步查看这个结构体的文档。

➜  lib go doc json.Decoder
package json // import "encoding/json"type Decoder struct {// Has unexported fields.
}A Decoder reads and decodes JSON values from an input stream.func NewDecoder(r io.Reader) *Decoder
func (dec *Decoder) Buffered() io.Reader
func (dec *Decoder) Decode(v interface{}) error
func (dec *Decoder) More() bool
func (dec *Decoder) Token() (Token, error)
func (dec *Decoder) UseNumber()

现在我们看到这个Decoder有很多方法,进一步查看这些方法的文档,比如Decode

➜  lib go doc json.Decoder.Decode
func (dec *Decoder) Decode(v interface{}) errorDecode reads the next JSON-encoded value from its input and stores it in thevalue pointed to by v.See the documentation for Unmarshal for details about the conversion of JSONinto a Go value.

go doc使用就是这样,一步步,缩小范围,查看想看的那些包、结构体、接口或者函数方法的文档。

2. 在线浏览文档

go doc终端查看的方式,虽然也很便捷,不过效率不高,并且没有查看细节以及进行跳转,为此Go为我们提供了基于浏览器使用的网页方式进行浏览API 文档,我们只用点点鼠标,就可以查看了,还可以在方法、包等之间进行跳转,更简洁方便。

要想启动一个Web在线API文档服务很简单,使用godoc就可以了。

➜  lib godoc -http=:6060

后面的http是要指定Web服务监听的IP和Port,运行后,我们就可以打开浏览器,输入http://127.0.0.1:6060进行访问了,你会发现打开的页面,和GoLang的官方网站一样,没错,这个其实就是官网的一个拷贝,但是包的文档http://127.0.0.1:6060/pkg/会和官网不一样,你自己启动的这个服务,是基于你电脑上GOROOTGOPATH这两个路径下的所有包生成的文档,会比官网只是标准库的文档要多。

在线浏览API文档非常方便,只需要鼠标点击就可以了,也可以点击蓝色的超链接在方法、结构、接口以及包等之间跳转,还可以查看对应的源代码,示例代码,很方便,我们经常用的也是这个在线浏览方式。

3. 生成自己的文档

Go文档工具,还有一个亮点,就是可以支持开发人员自己写的代码,只要开发者按照一定的规则,就可以自动生成文档了。

在我们编码中,文档就是注释,Go语言采用了和C、Java差不多的注释风格。一种是双斜线的方式,一种是斜线和星号的方式。

/*提供的常用库,有一些常用的方法,方便使用*/
package lib// 一个加法实现
// 返回a+b的值
func Add(a,b int) int {return a+b
}

这还是我们刚刚那个例子,例子中文档的编写的两种风格。想要为哪些标识符生车文档,就在哪些标识符之前,使用注释的方式,加入到代码中即可。

现在我们不管是用go doc,还是godoc都可以看到我们刚刚注释的文档了。

4. 添加文档示例

我们在看很多官方API文档的时候,可以在文档里看到一些例子,这些例子会告诉我们怎么使用API,以及这个例子打印的输出是什么,我觉得这个非常好,这样看函数文档看不懂的,可以参考这个例子,那么对于我们自己写的API,怎么给API文档添加示例代码呢?

这里我参考了官方的源代码,总结了测试了一下,发现可行,这里分享一下。

  1. 示例代码必须单独存放在一个文件中,文件名字为example_test.go
  2. 在这个go文件里,定义一个名字为Example的函数,参数为空
  3. 示例的输出采用注视的方式,以//Output:开头,另起一行,每行输出占一行。

说了这三个规则,下面通过一个例子更直观的了解。

package libimport "fmt"func Example() {sum:=Add(1,2)fmt.Println("1+2=",sum)//Output://1+2=3
}

这就是为刚刚那个Add函数写的示例代码,我们运行godoc就可以看到结果了。

Go的文档工具非常强大,更多功能,我们可以使用帮助命令查看。这里再推荐一个比较不错的第三方的API文档网站,收录了包括官方在内的很多Go库,可以直接跳转,关联源代码,非常方便。https://gowalker.org/

四、Go 数组

数组,是用来存储集合数据的,这种场景非常多,我们编码的过程中,都少不了要读取或者存储数据(补充:比如使用数据库或者文件,或者访问网络,总需要一种方法来处理接收和发送的数据)。当然除了数组之外,我们还有切片、Map映射等数据结构可以帮我们存储数据,但是数组是它们的基础。

1. 内部实现

要想更清晰的了解数组,我们得了解它的内部实现。数组是长度固定的数据类型,必须存储一段相同类型的元素,而且这些元素是连续的。我们这里强调固定长度,可以说这是和切片最明显的区别。

数组存储的类型可以是内置类型,比如整型或者字符串,也可以是自定义的数据结构。因为是连续的,所以索引比较好计算,所以我们可以很快的索引数组中的任何数据。

这里的索引,一直都是0,1,2,3这样的,因为其元素类型相同,我们也可以使用反射,获取类型占用大小,进行移位,获取相应的元素,这个到反射的时候,我们再讲。

2. 声明和初始化

数组的声明和初始化,和其他类型差不多。声明的原则是:

  1. 指明存储数据的类型。
  2. 存储元素的数量,也就是数组长度。
var array [5]int // (补充:array中默认五个0)

以上我们声明了一个数组array,但是我们还没有对他进行初始化,这时候数组array里面的值,是对应元素类型的零值,也就是说,现在这个数组是5个0,这和我们java不一样,java里是null。

数组一旦声明后,其元素类型和大小都不能变了,如果还需要存储更多的元素怎么办?那么只能通过创建一个新的数组,然后把原来数组的数据复制过去。

刚刚声明的数组已经被默认的元素类型零值初始化了,如果我们再次进行初始化怎么做呢,可以采用如下办法:

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

这两步比较繁琐,Go为我们提供了:= 操作符,可以让我们在创建数组的时候直接初始化。

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

这种简短变量声明的方式不仅适用于数组,还适用于任何数据类型,这也是Go语言中常用的方式(补充:简短变量声明不能用于全局变量/常量)。

有时候我们更懒,连数组的长度都不想指定,不过没有关系,使用...代替就好了,Go会自动推导出数组的长度。

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

假如我们只想给索引为1和3的数组初始化相应的值,其他都为0怎么做呢,直接的办法有:

array := [5]int{0, 1, 0, 4, 0}

还有一种更好的办法,上面讲默认初始化为零值,那么我们就可以利用这个特性,只初始化索引1和3的值:

array := [5]int{1:1, 3:4}

3. 使用数组

数组的访问非常简单,通过索引即可,操作符为[],因为内存是连续的,所以索引访问的效率非常高。

array := [5]int{1:1, 3:4}
fmt.Printf("%d", array[1])

修改数组中的一个元素也很简单:

array := [5]int{1:1, 3:4}
fmt.Printf("%d\n",array[1])
array[1] = 3
fmt.Printf("%d\n",array[1])

如果我们要循环打印数组中的所有值,一个传统的就是常用的for循环:

func main() {array := [5]int{1: 1, 3: 4}for i := 0; i < 5; i++ {fmt.Printf("索引:%d,值:%d\n", i, array[i])}
}

不过大部分时候,我们都是使用for range循环:

func main() {array := [5]int{1: 1, 3: 4}for i, v := range array {fmt.Printf("索引:%d,值:%d\n", i, v)}
}

这两段示例代码,输出的结果是一样的。

同样类型的数组是可以相互赋值的,不同类型的不行,会编译错误。那么什么是同样类型的数组呢?Go语言规定,必须是长度一样,并且每个元素的类型也一样的数组,才是同样类型的数组。

array := [5]int{1: 1, 3: 4}
var array1 [5]int = array //success
var array2 [4]int = array1 //error

指针数组和数组本身差不多,只不过元素类型是指针

(补充:

array := [5]*int{1: new(int), 3: new(int)}  // (补充:注意,这是指针数组,从后往前读)

这样就创建了一个指针数组,并且为索引1和3都创建了内存空间,其他索引是指针的零值nil,这时候我们要修改指针变量的值也很简单,如下即可:

array := [5]*int{1: new(int), 3:new(int)}
*array[1] = 1  // (补充:只能给索引1和3赋值)

以上需要注意的是,只可以给索引1和3赋值,因为只有它们分配了内存,才可以赋值,如果我们给索引0赋值,运行的时候,会提示无效内存或者是一个nil指针引用。

panic: runtime error: invalid memory address or nil pointer dereference

要解决这个问题,我们要先给索引0分配内存,然后再进行赋值修改。

array := [5]*int{1: new(int), 3:new(int)}
array[0] =new(int) // (补充:给索引0分配内存)
*array[0] = 2
fmt.Println(*array[0])

4. 函数间传递数组

在函数间传递变量时,总是以值的方式,如果变量是个数组,那么就会整个复制,并传递给函数,如果数组非常大,比如长度100多万,那么这对内存是一个很大的开销。

func main() {array := [5]int{1: 2, 3: 4}modify(array)fmt.Println(array)
}func modify(a [5]int){a[1] =3fmt.Println(a)
}

通过上面的例子,可以看到,数组是复制的,原来的数组没有修改。我们这里是5个长度的数组还好,如果有几百万怎么办,有一种办法是传递数组的指针,这样,复制的大小只是一个数组类型的指针大小。

func main() {array := [5]int{1: 2, 3: 4}modify(&array)fmt.Println(array)
}func modify(a *[5]int){a[1] =3fmt.Println(*a)
}

这是传递数组的指针的例子,会发现数组被修改了。所以这种情况虽然节省了复制的内存,但是要谨慎使用,因为一不小心,就会修改原数组,导致不必要的问题。

这里注意,数组指针和指针数组是两个概念,数组指针是*[5]int,指针数组是[5]*int,注意*的位置。

(补充:指针数组还是个数组,只是里面存储的是指针;而数组的指针是一个指向数组的指针,要获取数组变量的地址)

针对函数间传递数组的问题,比如复制问题,比如大小僵化问题,都有更好的解决办法,这个就是切片,它更灵活,下一篇将详细介绍。

(补充:

*5. 多维数组

数组本身只有一个维度,不过可以组合多个数组创建多维数组。多维数组很容易管理具有父子关系的数据或者与坐标系相关联的数据。

直接看示例:

声明二维数组:

// 声明一个二维整型数组,两个维度分别存储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}}

访问二维数组的元素:

var array1 [2][2]intarray1[0][0] = 10
array1[0][1] = 20
array1[1][0] = 30
array1[1][1] = 40

使用索引为多维数组赋值:

// 将array1的索引为1的维度复制到一个同类型的新数组里
var array3 [2]int = array1[1]// 将外层数组的索引为1、内层数组的索引为0的整型值复制到新的整型变量里
var value int = array1[1][0]

在定义多维数组时,仅第一维度允许使用 “...”

内置类型 len 和 cap 都返回第一维度长度

如果元素类型支持 “==”、“!=” 等操作符,那么数组也支持此操作.

func main() {var a, b [2]intfmt.Println(a == b)c := [2]int{1, 2}d := [2]int{0, 1}fmt.Println(c != d)var e, f [2]map[string]intfmt.Println(e == f)     // 无效操作:[2]map[string]int cannot be compared
}

五、Go 切片

切片也是一种数据结构,它和数组非常相似,因为他是围绕动态数组的概念设计的,可以按需自动改变大小,使用这种结构,可以更方便的管理和使用数据集合。

1. 内部实现

切片是基于数组实现的,它的底层是数组,它自己本身非常小,可以理解为对底层数组的抽象。因为基于数组实现,所以它的底层的内存是连续分配的,效率非常高,还可以通过索引获得数据,可以迭代以及垃圾回收优化的好处。

切片对象非常小,是因为它是只有3个字段的数据结构:一个是指向底层数组的指针,一个是切片的长度,一个是切片的容量。这3个字段,就是Go语言操作底层数组的元数据,有了它们,我们就可以任意的操作切片了。

(补充:

2. 声明和初始化

切片创建的方式有好几种,我们先看下最简洁的make方式。

slice := make([]int, 5)

使用内置的make函数时,需要传入一个参数,指定切片的长度,例子中我们使用的时5,这时候切片的容量也是5。当然我们也可以单独指定切片的容量。

slice := make([]int, 5, 10)     // (补充:5 ~ 切片长度,10 ~ 切片容量)

这时,我们创建的切片长度是5,容量是10,需要注意的这个容量10其实对应的是切片底层数组的。

因为切片的底层是数组,所以创建切片时,如果不指定字面值的话,默认值就是数组的元素的零值。这里我们虽然指定了容量是10,但是我们只能访问5个元素,因为切片的长度是5,剩下的5个元素,需要切片扩充后才可以访问。

容量必须 >= 长度,我们是不能创建长度大于容量的切片的。

还有一种创建切片的方式,是使用字面量,就是指定初始化的值。

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

有没有发现,是创建数组非常像,只不过不用指定[]中的值,这时候切片的长度和容量是相等的,并且会根据我们指定的字面量推导出来。当然我们也可以像数组一样,只初始化某个索引的值:

slice := []int{4: 1}

这是指定了第5个元素为1,其他元素都是默认值0。这时候切片的长度和容量也是一样的。这里再次强调一下切片和数组的微小差别。

//数组
array := [5]int{4: 1}
//切片
slice := []int{4: 1}

切片还有nil切片和空切片,它们的长度和容量都是0,但是它们指向底层数组的指针不一样,nil切片意味着指向底层数组的指针为nil,而空切片对应的指针是个地址。

//nil切片
var nilSlice []int//空切片
slice := make([]int, 0)
slice := []int{}

nil切片表示不存在的切片,而空切片表示一个空集合,它们各有用处。

(补充:

在Go语言中,nil切片是很常见的创建切片的方法。nil切片可以用于很多标准库和内置函数。在需要描述一个不存在的切片时,nil切片会很好用。例如,函数要求返回一个切片但是发生异常的时候:

空切片在底层数组包含0个元素,也没有分配任何存储空间。想表示空集合是空切片很有用,例如,数据库查询返回0个查询结果时:

切片另外一个用处比较多的创建是基于现有的数组或者切片创建。

slice := []int{1, 2, 3, 4, 5}
slice1 := slice[:]
slice2 := slice[0:]
slice3 := slice[:5]fmt.Println(slice1)
fmt.Println(slice2)
fmt.Println(slice3)

基于现有的切片或者数组创建,使用[i:j]这样的操作符即可,它表示以i索引开始,到j索引结束,截取原数组或者切片,创建而成的新切片,新切片的值包含原切片的i索引,但是不包含j索引。对比Java的话,发现和String的subString方法很像。

i如果省略,默认是0;j如果省略默认是原数组或者切片的长度,所以例子中的三个新切片的值是一样的。这里注意的是ij都不能超过原切片或者数组的索引。

slice := []int{10, 20, 30, 40, 50}
newSlice := slice[1:3]newSlice[0] = 1fmt.Println(slice)
fmt.Println(newSlice)

这个例子证明了,新的切片和原切片共用的是一个底层数组,所以当修改的时候,底层数组的值就会被改变,所以原切片的值也改变了。当然对于基于数组的切片也一样的。

(补充:执行完上面那段代码后,我们有了两个切片,它们共享同一段底层数组,但是通过不同的切片会看到底层数组的不同部分

我们基于原数组或者切片创建一个新的切片后,那么新的切片的大小和容量是多少呢?这里有个公式:

对于底层数组容量是k的切片slice[i:j]来说
长度:j-i
容量: k-i

比如我们上面的例子slice[1:3],长度就是3-1=2,容量是5-1=4。不过代码中我们计算的时候不用这么麻烦,因为Go语言为我们提供了内置的lencap函数来计算切片的长度和容量。

slice := []int{1, 2, 3, 4, 5}
newSlice := slice[1:3]fmt.Printf("newSlice长度: %d, 容量: %d", len(newSlice), cap(newSlice))

以上基于一个数组或者切片使用2个索引创建新切片的方法,此外还有一种3个索引的方法,第3个用来限定新切片的容量,其用法为slice[i:j:k]

slice := []int{1, 2, 3, 4, 5}
newSlice := slice[1:2:3]

这样我们就创建了一个长度为2-1=1,容量为3-1=2的新切片,不过第三个索引,不能超过原切片的最大索引值5。

3. 使用切片

使用切片,和使用数组一样,通过索引就可以获取切片对应元素的值,同样也可以修改对应元素的值。

slice := []int{1, 2, 3, 4, 5}
fmt.Println(slice[2]) //获取值
slice[2] = 10 //修改值
fmt.Println(slice[2]) //输出10

切片只能访问到其长度内的元素,访问超过长度外的元素,会导致运行时异常,与切片容量关联的元素只能用于切片增长。

我们前面讲了,切片算是一个动态数组,所以它可以按需增长,我们使用内置append函数即可。append函数可以为一个切片追加一个元素,至于如何增加、返回的是原切片还是一个新切片、长度和容量如何改变这些细节,append函数都会帮我们自动处理。

slice := []int{1, 2, 3, 4, 5}
newSlice := slice[1:3]newSlice=append(newSlice, 10)   // (补充:append---追加元素,下面有讲解)
fmt.Println(newSlice)
fmt.Println(slice)//Output
[2 3 10]
[1 2 3 10 5]

例子中,通过append函数为新创建的切片newSlice,追加了一个元素10,我们发现打印的输出,原切片slice的第4个值也被改变了,变成了10。引起这种结果的原因是因为newSlice有可用的容量,不会创建新的切片来满足追加,所以直接在newSlice后追加了一个元素10,因为newSliceslice切片共用一个底层数组,所以切片slice的对应的元素值也被改变了。

这里newSlice新追加的第3个元素,其实对应的是slice的第4个元素,所以这里的追加其实是把底层数组的第4个元素修改为10,然后把newSlice长度调整为3。

如果切片的底层数组,没有足够的容量时,就会新建一个底层数组,把原来数组的值复制到新底层数组里,再追加新值,这时候就不会影响原来的底层数组了。

(补充:

slice := []int{10, 20, 30, 40}  // 长度和容量都是4的整型切片
newSlice := append(slice, 50)  // 向切片追加一个新元素,将新元素赋值为50

当这个append操作完成后,newSlice拥有一个全新的底层数组,这个数组的容量是原来的两倍:

所以一般我们在创建新切片的时候,最好要让新切片的长度和容量一样,这样我们在追加操作的时候就会生成新的底层数组,和原有数组分离,就不会因为共用底层数组而引起奇怪问题,因为共用数组的时候修改内容,会影响多个切片。

append函数会智能的增长底层数组的容量,目前的算法是:容量小于1000个时,总是成倍的增长,一旦容量超过1000个,增长因子设为1.25,也就是说每次会增加25%的容量。随着语言的演化,这种增长算法可能会有所改变(补充:为了节约空间)

内置的append也是一个可变参数的函数,所以我们可以同时追加好几个值。

newSlice = append(newSlice, 10, 20, 30)

此外,我们还可以通过...操作符,把一个切片追加到另一个切片里。

slice := []int{1, 2, 3, 4, 5}
newSlice := slice[1:2:3]newSlice = append(newSlice, slice...)
fmt.Println(newSlice)
fmt.Println(slice)

4. 迭代切片

切片是一个集合,我们可以使用 for range 循环来迭代它,打印其中的每个元素以及对应的索引。

slice := []int{1, 2, 3, 4, 5}
for i, v := range slice{fmt.Printf("索引:%d,值:%d\n",i,v)
}

如果我们不想要索引,可以使用_来忽略它,这是Go语言的用法,很多不需要的函数等返回值,都可以忽略。

slice := []int{1, 2, 3, 4, 5}
for _, v := range slice{fmt.Printf("值:%d\n", v)
}

这里需要说明的是range返回的是切片元素的复制,而不是元素的引用。

(补充:关键字range返回的两个值。第一个值是当前迭代到的索引位置,第二个值是该位置对应元素值的一份副本:

除了for range循环外,我们也可以使用传统的for循环,配合内置的len函数进行迭代。

slice := []int{1, 2, 3, 4, 5}
for i := 0; i < len(slice); i++ {fmt.Printf("值:%d\n", slice[i])
}

5. 在函数间传递切片

我们知道切片是3个字段构成的结构类型,所以在函数间以值的方式传递的时候,占用的内存非常小,成本很低。在传递复制切片的时候,其底层数组不会被复制,也不会受影响,复制只是复制的切片本身,不涉及底层数组。

func main() {slice := []int{1, 2, 3, 4, 5}  // (补充:slice变量中存储的是底层数组的地址)fmt.Printf("%p\n", &slice)    modify(slice)       // (补充:相当于把底层数组的指针传给了modify函数)fmt.Println(slice)
}func modify(slice []int) {fmt.Printf("%p\n", &slice)slice[1] = 10
}

打印的输出如下:

0xc420082060
0xc420082080
[1 10 3 4 5]

仔细看,这两个切片的地址不一样,所以可以确认切片在函数间传递是复制的。而我们修改一个索引的值后,发现原切片的值也被修改了,说明它们共用一个底层数组。

在函数间传递切片非常高效,而且不需要传递指针和处理复杂的语法,只需要复制切片,然后根据自己的业务修改,最后传递回一个新的切片副本即可,这也是为什么函数间传递参数,使用切片,而不是数组的原因。

关于多维切片不推荐使用,还有多维数组,一来它和普通的切片数组一样,只不过是多个一维组成的多维;二来不推荐用多维切片和数组,可读性不好,结构不够清晰,容易出问题。

(补充:

*6. 多维切片

// 创建一个整型切片的切片
slice := [][]int{{10}, {100, 200}}

我们有了一个包含两个元素的外层切片,每个元素包含一个内层的整型切片:

这种组合可以让用户创建非常复杂且强大的数据结构。

// 为第一个切片追加值为20的元素
slice[0] = append(slice[0], 20)

Go语言中使用append函数处理追加的方式很简明:**先增长切片,再将新的整型切片赋值给外层切片的第一个元素。**上面这段代码,会为新的整型切片分配新的底层数组,然后将切片复制到外层切片的索引为0的元素:

即便是这么简单的多维切片,操作时也会涉及众多布局和值。

六、Go Map

书里把Map翻译为映射,显得太生硬,所以这篇文章里,还是用英文Map。

Map是一种数据结构,是一个集合,用于存储一系列无序的键值对。它基于键存储的,键就像一个索引一样,这也是Map强大的地方,可以快速检索数据,键指向与该键关联的值。

1. 内部实现

Map是基于散列表来实现,就是我们常说的Hash表,所以我们每次迭代Map的时候,打印的Key和Value是无序的,每次迭代的都不一样,即使我们按照一定的顺序存在也不行。(补充:就是说每次for range打印出来的map顺序都不一样)

Map的散列表包含一组桶,每次存储和查找键值对的时候,都要先选择一个桶。如何选择桶呢?就是把指定的键传给散列函数,就可以索引到相应的桶了,进而找到对应的键值。

这种方式的好处在于,存储的数据越多,索引分布越均匀,所以我们访问键值对的速度也就越快,当然存储的细节还有很多,大家可以参考Hash相关的知识,这里我们只要记住Map存储的是无序的键值对集合

2. 声明和初始化

Map的创建有make函数,Map字面量。make函数我们用它创建过切片,除此之外,它还可以用来创建Map。

dict := make(map[string]int)

示例中创建了一个键类型为string的,值类型为int的map。现在创建好之后,这个map是空的,里面什么都没有,我们给存储一个键值对。

dict := make(map[string]int)
dict["张三"] = 43

存储了一个Key为张三的,Value为43的键值对数据。

此外还有一种使用map字面量的方式创建和初始化map,对于上面的例子,我们可以同等实现。

dict := map[string]int{"张三": 43}

使用一个大括号进行初始化,键值对通过:分开,如果要同时初始化多个键值对,使用逗号分割。

dict := map[string]int{"张三": 43, "李四": 50}

当然我们可以不指定任何键值对,也就是一个空map。

dict := map[string]int{}

不管怎么样,使用map的字面量创建一定要带上大括号。如果我们要创建一个nil的Map怎么做呢?nil的Map是未初始化的,所以我们可以只声明一个变量,既不能使用map字面量,也不能使用make函数分配内存。

var dict map[string]int

这样就好了,但是这样我们是不能操作存储键值对的,必须要初始化后才可以,比如使用make函数,为其开启一块可以存储数据的内存,也就是初始化。

var dict map[string]int      // (补充:必须初始化后才能使用)
dict = make(map[string]int)
dict["张三"] = 43
fmt.Println(dict)

Map的键可以是任何值,键的类型可以是内置的类型,也可以是结构类型,只要这个键可以使用==运算符进行比较。 所以像切片、函数以及含有切片的结构类型就不能用于Map的键了,因为他们具有引用的语义,不可比较。

(补充:下面会报错

dict := map[[]string]int{}  // 创建一个使用字符串切片作为键的映射
compiler Exception:
invalid map key type []string

对于Map的值来说,就没有什么限制了,切片这种在键里不能用的,完全可以用在值里。

(补充:

dict := map[int][]string{}  // 创建一个使用字符串切片作为值的映射

3. 使用Map

Map的使用很简单,和数组切片差不多,数组切片是使用索引,Map是通过键。

dict := make(map[string]int)
dict["张三"] = 43

以上示例,如果键张三存在,则对其值修改,如果不存在,则新增这个键值对。

(补充:

可以通过声明一个未初始化的映射来创建一个值为nil的映射(称为nil映射)。nil映射不能用于存储键值对,否则,会产生一个语言运行时错误:

var colors map[string]string // 通过声明映射创建一个nil映射
colors["李四"] = "qwqw"  Runtime Error:
panic: runtime error: assignment to entry in nil map

获取一个Map键的值也很简单,和存储差不多,还是基于上面的例子。

age := dict["张三"]

在Go Map中,如果我们获取一个不存在的键的值,也是可以的,返回的是值类型的零值,这样就会导致我们不知道是真的存在一个为零值的键值对呢,还是说这个键值对就不存在。对此,Map为我们提供了检测一个键值对是否存在的方法。

age, exists := dict["李四"]
if exists {     // 这个键存在吗?fmt.Println(age)
}

看这个例子,和获取键的值没有太大区别,只是多了一个返回值。第一个返回值是键的值;第二个返回值标记这个键是否存在,这是一个boolean类型的变量,我们判断它就知道该键是否存在了。这也是Go多值返回的好处。

如果我们想删除一个Map中的键值对,可以使用Go内置的delete函数。

delete(dict, "张三")

delete函数接受两个参数,第一个是要操作的Map,第二个是要删除的Map的键。

delete函数删除不存在的键也是可以的,只是没有任何作用。

想要遍历Map的话,可以使用for range风格的循环,和遍历切片一样。

dict := map[string]int{"张三": 43}
for key, value := range dict {fmt.Println(key, value)
}

这里的range 返回两个值,第一个是Map的键,第二个是Map的键对应的值。这里再次强调,这种遍历是无序的,也就是键值对不会按既定的数据出现,如果想按顺序遍历,可以先对Map中的键排序,然后遍历排序好的键,把对应的值取出来,下面看个例子就明白了。

func main() {dict := map[string]int{"王五": 60, "张三": 43}var names []stringfor name := range dict {names = append(names, name)}sort.Strings(names) //排序for _, key := range names {fmt.Println(key, dict[key])}
}

这个例子里有个技巧,range 一个Map的时候,也可以使用一个返回值,这个默认的返回值就是Map的键。

4. 在函数间传递Map

函数间传递Map是不会拷贝一个该Map的副本的,也就是说如果一个Map传递给一个函数,该函数对这个Map做了修改,那么这个Map的所有引用,都会感知到这个修改。

func main() {dict := map[string]int{"王五": 60, "张三": 43}modify(dict)fmt.Println(dict["张三"])
}func modify(dict map[string]int) {dict["张三"] = 10
}

上面这个例子输出的结果是10,也就是说已经被函数给修改了,可以证明传递的并不是一个Map的副本。这个特性和切片是类似的,这样就会更高,因为复制整个Map的代价太大了。

七、Go 类型

Go 语言是一种静态类型的编程语言,所以在编译器进行编译的时候,需要知道每个值的类型,这样编译器就知道要为这个值分配多少内存,并且知道这段分配的内存表示什么。

(补充:

如指定int64类型的值,则需要8字节(64位),表示一个整数值;float32类型的值需要4字节,表示一个IEEE-754定义的二进制浮点数类型;bool类型的值需要1字节,表示布尔值true和false。

有些类型的内部表示与编译代码的机器的体系结构有关。例如,根据编译所在的机器的体系结构,一个int值的大小可能是8字节(64位),也可能是4字节(32位)。还有一些与体系结构相关的类型,如Go语言里的所有引用类型。如果编译器不知道这些信息,就无法阻止用户做一些导致程序受损甚至机器故障的事情。

提前知道值的类型的好处有很多,比如编译器可以合理的使用这些值,可以进一步优化代码,提高执行的效率,减少bug等等。

1. 基本类型

基本类型是Go语言自带的类型,比如数值类型、浮点类型、字符类型以及布尔类型,他们本质上是原始类型,也就是不可改变的,所以对他们进行操作,一般都会返回一个新创建的值,所以把这些值传递给函数时,其实传递的是一个值的副本。

func main() {name := "张三"fmt.Println(modify(name))fmt.Println(name)
}func modify(s string) string{s = s + sreturn s
}// 输出:
张三张三
张三

以上是一个操作字符串的例子,通过打印的结果,可以看到,本来name的值并没有被改变,也就是说,我们传递的时一个副本,并且返回一个新创建的字符串。

基本类型因为是拷贝的值,并且在对它进行操作的时候,生成的也是新创建的值,所以这些类型在多线程里是安全的,我们不用担心一个线程的修改影响了另外一个线程的数据。

2. 引用类型

引用类型和原始的基本类型恰恰相反,它的修改可以影响到任何引用到它的变量。在Go语言中,引用类型有切片、map、接口、函数类型以及chan

引用类型之所以可以引用,是因为我们创建引用类型的变量,其实是一个标头值,标头值里包含一个指针,指向底层的数据结构,当我们在函数中传递引用类型时,其实传递的是这个标头值的副本,它所指向的底层结构并没有被复制传递,这也是引用类型传递高效的原因。

本质上,我们可以理解函数的传递都是值传递,只不过引用类型传递的是一个指向底层数据的指针,所以我们在操作的时候,可以修改共享的底层数据的值,进而影响到所有引用到这个共享底层数据的变量。

func main() {ages := map[string]int{"张三": 20}fmt.Println(ages)modify(ages)fmt.Println(ages)
}func modify(m map[string]int) {m["张三"] = 10
}

这是一个很明显的修改引用类型的例子,函数modify的修改,会影响到原来变量ages的值。

3. 结构类型

结构类型是用来描述一组值的,比如一个人有身高、体重、名字和年龄等,本质上是一种聚合型的数据类型。

type person struct {age intname string
}

要定义一个结构体的类型,通过type关键字和类型struct进行声明,以上我们就定义了一个结构体类型person,它有age,name这两个字段数据。

结构体类型定义好之后,就可以进行使用了,我们可以用过var关键字声明一个结构体类型的变量。

var p person // (创建一个结构体变量并初始化为零值时,常用这种方式)

这种声明的方式,会对结构体person里的数据类型默认初始化,也就是使用它们类型的零值,如果要创建一个结构体变量并初始化其为零值时,这种var方式最常用。

如果我们需要指定非零值,就可以使用我们字面量方式了。

jim := person{10, "Jim"}

示例这种我们就为其指定了值,注意这个值的顺序很重要,必须和结构体里声明字段的顺序一致,当然我们也可以不按顺序,但是这时候我们必须为字段指定值。

jim := person{name: "Jim", age: 10,       // (补充:这里有 “,”,如果写成一行则结尾需要“,”)
}

使用冒号:分开字段名和字段值即可,这样我们就不用严格的按照定义的顺序了。

除了基本的原始类型外,结构体内的值也可以是引用类型,或者自己定义的其他类型。具体选择类型,要根据实际情况,比如是否允许修改值本身,如果允许的话,可以选择引用类型,如果不允许的话,则需要使用基本类型。

函数传参是值传递,所以对于结构体来说也不例外,结构体传递的是其本身以及里面的值的拷贝。

func main() {jim := person{10, "Jim"}fmt.Println(jim)modify(jim)fmt.Println(jim)
}func modify(p person) {p.age = p.age + 10
}type person struct {age intname string
}

以上示例的输出是一样的,所以我们可以验证传递的是值的副本。如果上面的例子我们要修改age的值可以通过传递结构体的指针,我们稍微改动下例子

func main() {jim := person{10, "Jim"}fmt.Println(jim)modify(&jim)fmt.Println(jim)
}func modify(p *person) {p.age = p.age + 10
}type person struct {age intname string
}// 输出:
{10 Jim}
{20 Jim}

非常明显的,age的值已经被改变。如果结构体里有引用类型的值,比如map,那么我们即使传递的是结构体的值副本,如果修改这个map的话,原结构的对应的map值也会被修改,这里不再写例子,大家可以验证下。

4. 自定义类型

Go语言支持我们自定义类型,比如刚刚上面的结构体类型,就是我们自定义的类型,这也是比较常用的自定义类型的方法。

另外一个自定义类型的方法是基于一个已有的类型,就是基于一个现有的类型创造新的类型,这种也是使用type关键字。

type Duration int64

我们在使用time这个包的时候,对于类型time.Duration应该非常熟悉,它其实就是基于int64 这个基本类型创建的新类型,来表示时间的间隔。

但是这里我们注意,虽然Duration是基于int64创建,觉得他们其实一样,比如都可以使用数字赋值。

type Duration int64var i Duration = 100
var j int64 = 100

但是本质上,他们并不是同一种类型,所以对于Go这种强类型语言,他们是不能相互赋值的。

type Duration int64var dur Duration
dur = int64(100)   // (补充:这里会报错!!类型不一样!!)
fmt.Println(dur)

上面的例子,在编译的时候,会报类型转换的异常错误。

cannot use int64(100) (type int64) as type Duration in assignment

Go的编译器不会像Java的那样,帮我们做隐式的类型转换。

有时候,大家会迷茫,已经有了int64这些类型了,可以表示,还要基于他们创建新的类型做什么?其实这就是Go灵活的地方,我们可以使用自定义的类型做很多事情,比如添加方法,比如可以更明确的表示业务的含义等等,下一篇方法会讲到。

八、Go 函数方法

在Go语言中,函数和方法不太一样,有明确的概念区分。其他语言中,比如Java,一般来说,函数就是方法,方法就是函数,但是在Go语言中,函数是指不属于任何结构体、类型的方法,也就是说,函数是没有接收者的;而方法是有接收者的,我们说的方法要么是属于一个结构体的,要么属于一个新定义的类型的。

1. 函数

函数和方法,虽然概念不同,但是定义非常相似。函数的定义声明没有接收者,所以我们直接在go文件里,go包之下定义声明即可。

func main() {sum := add(1, 2)fmt.Println(sum)
}func add(a, b int) int {return a + b
}

例子中,我们定义了add就是一个函数,它的函数签名是func add(a, b int) int,没有接收者,直接定义在go的一个包之下,可以直接调用,比如例子中的main函数调用了add函数。

例子中的这个函数名称是小写开头的add,所以它的作用域只属于所声明的包内使用,不能被其他包使用,如果我们把函数名以大写字母开头,该函数的作用域就大了,可以被其他包调用。这也是Go语言中大小写的用处,比如Java中,就有专门的关键字来声明作用域privateprotectpublic等。

/*提供的常用库,有一些常用的方法,方便使用
*/
package lib// 一个加法实现
// 返回a+b的值
func Add(a, b int) int {return a + b
}

如上例子中定义的Add方法就可以被其他包调用。(补充:可以联系一下我们经常用到的fmt包中Println()…,这不,首字母都是大写)

2. 方法

方法的声明和函数类似,他们的区别是:方法在定义的时候,会在func和方法名之间增加一个参数,这个参数就是接收者,这样我们定义的这个方法就和接收者绑定在了一起,称之为这个接收者的方法。

type person struct {name string
}func (p person) String() string{   // (补充:person结构类型的接收者)return "the person name is " + p.name
}

留意例子中,func和方法名之间增加的参数(p person),这个就是接收者。现在我们说,类型person有了一个String方法,现在我们看下如何使用它。

func main() {p := person{name: "张三"}fmt.Println(p.String())
}

调用的方法非常简单,使用类型的变量进行调用即可,类型变量和方法之前是一个.操作符,表示要调用这个类型变量的某个方法的意思。

Go语言里有两种类型的接收者:值接收者和指针接收者。我们上面的例子中,就是使用值类型接收者的示例。

使用值类型接收者定义的方法,在调用的时候,使用的其实是值接收者的一个副本,所以对该值的任何操作,不会影响原来的类型变量。

func main() {p := person{name: "张三"}p.modify()        //值接收者,修改无效fmt.Println(p.String())
}type person struct {name string
}func (p person) String() string{return "the person name is "+p.name
}func (p person) modify(){p.name = "李四"
}

以上的例子,打印出来的值还是张三,对其进行的修改无效。如果我们使用一个指针作为接收者,那么就会其作用了,因为指针接收者传递的是一个指向原值指针的副本,指针的副本,指向的还是原来类型的值,所以修改时,同时也会影响原来类型变量的值。

func main() {p := person{name: "张三"}p.modify()        //指针接收者,修改有效fmt.Println(p.String())
}type person struct {name string
}func (p person) String() string{return "the person name is " + p.name
}func (p *person) modify(){p.name = "李四"
}

只需要改动一下,变成指针的接收者,就可以完成了修改。

在调用方法的时候,传递的接收者本质上都是副本,只不过一个是这个值副本,一是指向这个值指针的副本。指针具有指向原有值的特性,所以修改了指针指向的值,也就修改了原有的值。我们可以简单的理解为值接收者使用的是值的副本来调用方法,而指针接收者使用实际的值来调用方法。

在上面的例子中,有没有发现,我们在调用指针接收者方法的时候,使用的也是一个值的变量,并不是一个指针,如果我们使用下面的也是可以的。

p := person{name: "张三"}
(&p).modify() //指针接收者,修改有效

这样也是可以的。如果我们没有这么强制使用指针进行调用,Go的编译器自动会帮我们取指针,以满足接收者的要求。(补充:好叭,我个人不喜欢go中的这一点,虽然编译器自动识别,但是造成了开发人员对其理解的深度不够)

同样的,如果是一个值接收者的方法,使用指针也是可以调用的,Go编译器自动会解引用,以满足接收者的要求,比如例子中定义的String()方法,也可以这么调用:

p := person{name: "张三"}
fmt.Println((&p).String())

总之,方法的调用,既可以使用值,也可以使用指针,我们不必要严格的遵守这些,Go语言编译器会帮我们进行自动转义的,这大大方便了我们开发者。

不管是使用值接收者,还是指针接收者,一定要搞清楚类型的本质:对类型进行操作的时候,是要改变当前值,还是要创建一个新值进行返回?这些就可以决定我们是采用值传递,还是指针传递。

3. 多值返回

Go语言支持函数方法的多值返回,也就说我们定义的函数方法可以返回多个值,比如标准库里的很多方法,都是返回两个值,第一个是函数需要返回的值,第二个是出错时返回的错误信息,这种的好处,我们的出错异常信息再也不用像Java一样使用一个Exception这么重的方式表示了,非常简洁。

func main() {file, err := os.Open("/usr/tmp")if err != nil {     // (补充:成功-->err == nil,失败-->就会包含错误信息)log.Fatal(err)return}fmt.Println(file)
}

如果返回的值,我们不想使用,可以使用_进行忽略。

file, _ := os.Open("/usr/tmp")

多个值返回的定义也非常简单,看个例子。

func add(a, b int) (int, error) {return a + b, nil
}

函数方法声明定义的时候,采用逗号分割,因为是多个返回,还要用括号括起来。返回的值还是使用return 关键字,以逗号分割,和返回的声明的顺序一致。

4. 可变参数

函数方法的参数,可以是任意多个,这种我们称之为可变参数,比如我们常用的fmt.Println()这类函数,可以接收一个可变的参数。

func main() {fmt.Println("1","2","3")
}

可以变参数,可以是任意多个。我们自己也可以定义可变参数,可变参数的定义,在类型前加上省略号…即可。

func main() {print("1","2","3")
}func print (a ...interface{}){ // (补充:a是一个可变参数)for _, v := range a{fmt.Print(v)}fmt.Println()
}

例子中我们自己定义了一个接受可变参数的函数,效果和fmt.Println()一样。

可变参数本质上是一个数组,所以我们向使用数组一样使用它,比如例子中的 for range 循环。

函数方法还有其他一些知识点,比如painc异常处理,递归等,这些在《Go语言实战》书里也没有介绍,这些基础知识,可以参考Go语言的那本圣经。

(补充:

看一段完整的代码段,这个示例程序展示如何声明并使用方法:

package mainimport ("fmt"
)// usr在程序里定义了一个用户类型
type user struct {name stringemail string
}// notify使用值接收者实现一个方法
func (u user) notify() {fmt.Printf("sending user email to %s<%s>\n", u.name, u.email)
}// changeEmail使用一个指针接收者实现一个方法
func (u *user) changeEmail(email string) {u.email = email
}// main是应用程序的入口
func main() {// user类型的值可以用来调用// 使用值接收者声明的方法bill := user{"bill", "bill@qq.com"}bill.notify()// 指向user类型值的指针也可以用来调用// 使用值接收者声明的方法lisa := &user{"lisa", "lisa@qq.com"}lisa.notify()       // user类型的值可以用来调用// 使用指针接收者声明的方法bill.changeEmail("bill@newqq.com")bill.notify()// 指向user类型值得指针可以用来调用// 使用指针接收者声明的方法lisa.changeEmail("lisa@newqq.com")lisa.notify()
}

输出:

sending user email to bill<bill@qq.com>
sending user email to lisa<lisa@qq.com>
sending user email to bill<bill@newqq.com>
sending user email to lisa<lisa@newqq.com>

分析部分代码:

lisa.notify()

Go在代码背后的执行动作:(*lisa).notify(),指针被解引用为值,直接操作lisa指向的那块内存,这样就符合了值接收者的要求。再次强调,notify操作的是一个副本,只不过这次操作的是从lisa指针指向的值的副本。

bill.changeEmail("bill@newqq.com")

Go在代码背后的执行动作:(&bill).changeEmail("bill@newqq.com"),首先引用bill值得到一个指针,这样这个指针就能匹配方法的接收者类型,再进行调用,直接操作名字为bill的内存空间。

九、Go 接口(多态)

接口是一种约定,它是一个抽象的类型,和我们见到的具体的类型如int、map、slice等不一样。具体的类型,我们可以知道它是什么,并且可以知道可以用它做什么;但是接口不一样,接口是抽象的,它只有一组接口方法,我们并不知道它的内部实现,所以我们不知道接口是什么,但是我们知道可以利用它提供的方法做什么。

抽象就是接口的优势,它不用和具体的实现细节绑定在一起,我们只需定义接口,告诉编码人员它可以做什么,这样我们可以把具体实现分开,这样编码就会更加灵活方面,适应能力也会非常强。

(补充:这个例子可以跳过,直接看下面的一个)

func main() {var b bytes.Bufferfmt.Fprint(&b, "Hello World")fmt.Println(b.String())
}

以上就是一个使用接口的例子,我们先看下fmt.Fprint函数的实现。

func Fprint(w io.Writer, a ...interface{}) (n int, err error) {p := newPrinter()p.doPrint(a)n, err = w.Write(p.buf)p.free()return
}

从上面的源代码中,我们可以看到,fmt.Fprint函数的第一个参数是io.Writer这个接口,所以只要实现了这个接口的具体类型都可以作为参数传递给fmt.Fprint函数,而bytes.Buffer恰恰实现了io.Writer接口,所以可以作为参数传递给fmt.Fprint函数。

1. 内部实现

我们前面提过接口是用来定义行为的类型,它是抽象的,这些定义的行为不由接口直接实现,而是通过方法由用户定义的类型实现。如果用户定义的类型实现了某个接口类型声明的一组方法,那么这个用户定义的类型的值就可以赋给这个接口类型的值。这个赋值会把用户定义的类型的值存入接口类型的值。(补充:这部分对原内容有过删改)

(补充:对接口值方法的调用会执行接口值里存储的用户定义的类型的值对应的方法。因为任何用户定义的类型都可以实现任何接口,所以对接口值方法的调用就用一种多态。)

func main() {var b bytes.Bufferfmt.Fprint(&b, "Hello World")var w io.Writerw = &bfmt.Println(w)
}

这里例子中,因为bytes.Buffer实现了接口io.Writer,所以我们可以通过w = &b赋值,这个赋值的操作会把定义类型的值存入接口类型的值。

赋值操作执行后,如果我们对接口方法执行调用,其实是调用存储的用户定义类型的对应方法,这里我们可以把用户定义的类型称之为实体类型

我们可以定义很多类型,让它们实现一个接口,那么这些类型都可以赋值给这个接口,这时候接口方法的调用,其实就是对应实体类型对应方法的调用,这就是多态。

func main() {var a animal// 用cat类型赋值var c cata = ca.printInfo()//使用另外一个类型赋值var d doga = da.printInfo()
}type animal interface {        // (补充:定义一个接口animal)printInfo()
}type cat int
type dog intfunc (c cat) printInfo(){   fmt.Println("a cat")
}func (d dog) printInfo(){fmt.Println("a dog")
}

以上例子演示了一个多态。我们定义了一个接口animal,然后定义了两种类型catdog实现了接口animal。在使用的时候,分别把类型cat的值c、类型dog的值d赋值给接口animal的值a,然后分别执行aprintInfo方法,可以看到不同的输出。

a cat
a dog

我们看下接口的值被赋值后,接口值内部的布局。接口的值是一个两个字长度的数据结构,第一个字包含一个指向内部表结构的指针,这个内部表里存储的有实体类型的信息以及相关联的方法集;第二个字包含的是一个指向存储的实体类型值的指针。所以接口的值结构其实是两个指针,这也可以说明接口其实一个引用类型。

(补充:

2. 方法集

我们都知道,如果要实现一个接口,必须实现这个接口提供的所有方法,但是实现方法的时候,我们可以使用指针接收者实现,也可以使用值接收者实现,这两者是有区别的,下面我们就好好分析下这两者的区别。

(补充:以下可以运行的代码段的输出结果均为:a cat

func main() {var c cat//值作为参数传递invoke(c)
}
//需要一个animal接口作为参数
func invoke(a animal){a.printInfo()
}type animal interface {printInfo()
}type cat int//值接收者实现animal接口
func (c cat) printInfo(){fmt.Println("a cat")
}

还是原来的例子改改,增加一个invoke函数,该函数接收一个animal接口类型的参数,例子中传递参数的时候,也是以类型cat的值c传递的,运行程序可以正常执行。现在我们稍微改造一下,使用类型cat的指针&c作为参数传递。

func main() {var c cat//指针作为参数传递invoke(&c)
}

只修改这一处,其他保持不变,我们运行程序,发现也可以正常执行。通过这个例子我们可以得出结论:实体类型以值接收者实现接口的时候,不管是实体类型的值,还是实体类型值的指针,都实现了该接口。

下面我们把接收者改为指针试试。

func main() {var c cat//值作为参数传递invoke(c)
}
//需要一个animal接口作为参数
func invoke(a animal){a.printInfo()
}type animal interface {printInfo()
}type cat int//指针接收者实现animal接口
func (c *cat) printInfo(){fmt.Println("a cat")
}

这个例子中把实现接口的接收者改为指针,但是传递参数的时候,我们还是按值进行传递,点击运行程序,会出现以下异常提示:

./main.go:10: cannot use c (type cat) as type animal in argument to invoke:cat does not implement animal (printInfo method has pointer receiver)

提示中已经很明显的告诉我们,说cat没有实现animal接口,因为printInfo方法有一个指针接收者,所以cat类型的值c不能作为接口类型animal传参使用。下面我们再稍微修改下,改为以指针作为参数传递。

func main() {var c cat//指针作为参数传递invoke(&c)
}

其他都不变,只是把以前使用值的参数,改为使用指针作为参数,我们再运行程序,就可以正常运行了。由此可见实体类型以指针接收者实现接口的时候,只有指向这个类型的指针才被认为实现了该接口

现在我们总结下这两种规则,首先以方法接收者是值还是指针的角度看

Methods Receivers Values
(t T) T and *T
(t *T) *T

上面的表格可以解读为:如果是值接收者,实体类型的值和指针都可以实现对应的接口;如果是指针接收者,那么只有类型的指针能够实现对应的接口。

其次我们我们以实体类型是值还是指针的角度看

Values Methods Receivers
T (t T)
*T (t T) and (t *T)

上面的表格可以解读为:类型的值只能实现值接收者的接口;指向类型的指针,既可以实现值接收者的接口,也可以实现指针接收者的接口。

3. 标准库

(暂跳)

十、Go 嵌入类型(继承)

(补充:Go语言允许用户扩展或修改已有类型的行为。这个功能对代码复用很重要,在修改已有类型以符合新类型的时候也很重要。这个功能是通过嵌入类型完成的。)

嵌入类型,或者嵌套类型,这是一种可以把已有的类型声明在新的类型里的一种方式,这种功能对代码复用非常重要。

在其他语言中,有继承可以做同样的事情,但是在Go语言中,没有继承的概念,Go提倡的代码复用的方式是组合,所以这也是嵌入类型的意义所在,组合而不是继承,所以Go才会更灵活。

type Reader interface {Read(p []byte) (n int, err error)
}type Writer interface {Write(p []byte) (n int, err error)
}type Closer interface {Close() error
}type ReadWriter interface {ReaderWriter
}type ReadCloser interface {ReaderCloser
}type WriteCloser interface {WriterCloser
}

以上是标准库io包里,我们常用的接口,可以看到ReadWriter接口是嵌入ReaderReader接口而组合成的新接口,这样我们就不用重复的定义被嵌入接口里的方法,直接通过嵌入就可以了。嵌入类型同样适用于结构体类型,我们再来看个例子:

type user struct {   // (补充:内部类型)name stringemail string
}type admin struct {    // (补充:外部类型)userlevel string
}

嵌入后,被嵌入的类型称之为内部类型、新定义的类型称之为外部类型,这里user就是内部类型,而admin是外部类型。

通过嵌入类型,与内部类型相关联的所有字段、方法、标志符等等所有,都会被外包类型所拥有,就像外部类型自己的一样,这就达到了代码快捷复用组合的目的,而且定义非常简单,只需声明这个类型的名字就可以了。

同时,外部类型还可以添加自己的方法、字段属性等,可以很方便的扩展外部类型的功能。

func main() {ad := admin{user{"张三", "zhangsan@flysnow.org"}, "管理员",}fmt.Println("可以直接调用,名字为:", ad.name)fmt.Println("也可以通过内部类型调用,名字为:", ad.user.name)fmt.Println("但是新增加的属性只能直接调用,级别为:", ad.level)
}

以上是嵌入类型的使用,可以看到,我们在初始化的时候,采用的是字面值的方式,所以要按其定义的结构进行初始化,先初始化user这个内部类型的,再初始化新增的level 属性。

对于内部类型的属性和方法访问上,我们可以用外部类型直接访问,也可以通过内部类型进行访问;但是我们为外部类型新增的方法属性字段,只能使用外部类型访问,因为内部类型没有这些。

当然,外部类型也可以声明同名的字段或者方法,来覆盖内部类型的,这种情况方法比较多,我们以方法为例

func main() {ad := admin{user{"张三", "zhangsan@flysnow.org"},   // (补充:注意这里,末尾有分号)"管理员",}ad.user.sayHello()ad.sayHello()
}type user struct {name stringemail string}type admin struct {userlevel string
}func (u user) sayHello(){fmt.Println("Hello,i am a user")
}func (a admin) sayHello(){fmt.Println("Hello,i am a admin")
}

内部类型user有一个sayHello方法,外部类型对其进行了覆盖,同名重写sayHello,然后我们在main方法里分别访问这两个类型的方法,打印输出:

Hello,i am a user
Hello,i am a admin

从输出中看,方法sayHello被成功覆盖了。

嵌入类型的强大,还体现在:如果内部类型实现了某个接口,那么外部类型也被认为实现了这个接口。我们稍微改造下例子看下。

func main() {ad := admin{user{"张三", "zhangsan@flysnow.org"}, "管理员",}sayHello(ad.user)    // 使用user作为参数sayHello(ad)       // 使用admin作为参数
}type Hello interface {     // (补充:定义接口)hello()
}func (u user) hello() {fmt.Println("Hello,i am a user")
}func sayHello(h Hello) {   // (补充:参数类型为接口)h.hello()
}

这个例子原来的结构体类型useradmin的定义不变,新增了一个接口Hello,然后让user类型实现这个接口,最后我们定义了一个sayHello方法,它接受一个Hello接口类型的参数,最终我们在main函数演示的时候,发现不管是user类型,还是admin类型作为参数传递给sayHello方法的时候,都可以正常调用。

这里就可以说明admin实现了接口Hello,但是我们又没有显示的声明类型admin实现,所以这个实现是通过内部类型user实现的,因为admin包含了user所有的方法函数,所以也就实现了接口Hello

当然外部类型也可以重新实现,只需要像上面例子一样覆盖同名的方法即可。这里要说明的是,不管我们如何同名覆盖,都不会影响内部类型,我们还可以通过访问内部类型来访问它的方法、属性字段等。

嵌入类型的定义,是Go为了方便我们扩展或者修改已有类型的行为,是为了宣传组合这个概念而设计的,所以我们经常使用组合,灵活运用组合,扩展出更多的我们需要的类型结构。

(补充:再来看两个例子

1. 内部类型实现的接口被自动提升到外部类型

package mainimport "fmt"// notifier是一个定义了通知类行为的接口
type notifier interface {notify()
}// user在程序里定义了一个用户类型
type user struct {name  stringemail string
}// 通过user类型值的指针调用的方法
func (u *user) notify() {fmt.Printf("sending user email to %s<%s>\n", u.name, u.email)
}// admin代表一个拥有权限的管理员用户
type admin struct {userlevel string
}// main是应用程序的入口
func main() {// 创建一个admin用户ad := admin{user: user{name:  "john smith",email: "john@qq.com",},level: "super",}// 给admin用户发送一个通知// 接口的嵌入的内部类型实现,被提升到外部类型sendNotification(&ad)
}// sendNotification接受一个实现了notifier接口的值并发送通知
func sendNotification(n notifier) {n.notify()
}

运行结果:

sending user email to john smith<john@qq.com>

2. 外部类型不需要内部类型的实现,内部类型没有被自动提升

package mainimport "fmt"// notifier是一个定义了通知类行为的接口
type notifier interface {notify()
}// user在程序里定义了一个用户类型
type user struct {name  stringemail string
}// 通过user类型值的指针调用的方法
func (u *user) notify() {fmt.Printf("sending user email to %s<%s>\n", u.name, u.email)
}// admin代表一个拥有权限的管理员用户
type admin struct {userlevel string
}// 通过admin类型值的指针调用的方法
func (a *admin) notify() {fmt.Printf("sending admin email to %s<%s>\n", a.name, a.email)
}// main是应用程序的入口
func main() {// 创建一个admin用户ad := admin{user: user{name:  "john smith",email: "john@qq.com",},level: "super",}// 给admin用户发送一个通知// 接口的嵌入的内部类型实现,并没有提升到外部类型sendNotification(&ad)// 我们可以直接访问内部类型的方法ad.user.notify()// 内部类型的方法没有被提升ad.notify()
}// sendNotification接受一个实现了notifier接口的值并发送通知
func sendNotification(n notifier) {n.notify()
}

运行结果:

sending admin email to john smith<john@qq.com>
sending user email to john smith<john@qq.com>
sending admin email to john smith<john@qq.com>

十一、Go 标志符可见性(封装)

Go的标志符,这个翻译怪怪的,可以理解为Go的变量、类型、字段等。这里的可见性,也就是说那些方法、函数、类型或者变量字段的可见性,比如哪些方法不想让另外一个包访问,我们就可以把它们声明为非公开的;如果需要被另外一个包访问,就可以声明为公开的,和Java语言里的作用域类似。

在Go语言中,没有特别的关键字来声明一个方法、函数或者类型是否为公开的,Go语言提供的是以大小写的方式进行区分的,如果一个类型的名字是以大写开头,那么其他包就可以访问;如果以小写开头,其他包就不能访问。

package commontype count int
package mainimport ("flysnow.org/hello/common""fmt"
)func main() {c := common.count(10)    // (补充:会报错)fmt.Println(c)
}

这是一个定义在common包里的类型count,因为它的名字以小写开头,所以我们不能在其他包里使用它,否则就会报编译错误。

./main.go:9: cannot refer to unexported name common.count

因为这个类型没有被导出,如果我们改为大写,就可以正常编译运行了,大家可以自己试试。

现在这个类型没有导出,不能使用,现在我们修改下例子,增加一个函数,看看是否可行。

package commontype count intfunc New(v int) count {return count(v)
}
func main() {c := common.New(100)fmt.Println(c)
}

这里我们在common包里定义了一个导出的函数New ,该函数返回一个count类型的值。New函数可以在其他包访问,但是count类型不可以,现在我们在main包里调用这个New函数,会发现是可以正常调用并且运行的,但是有个前提,必须使用:=这样的操作符才可以,因为它可以推断变量的类型。

这是一种非常好的能力,试想,我们在和其他人进行函数方法通信的时候,只需约定好接口,就可以了,至于内部实现,使用方是看不到的,隐藏了实现。

package commonimport "fmt"func NewLoginer() Loginer{   // (补充:返回Loginer接口的名为NewLoginer的函数)return defaultLogin(0)
}type Loginer interface {   // (补充:定义Loginer接口)Login()
}type defaultLogin intfunc (d defaultLogin) Login(){fmt.Println("login in...")
}
func main() {l := common.NewLoginer()   // (补充:这里l被初始化为Loginer接口类型)l.Login()
}

以上例子,我们对于函数间的通信,通过Loginer接口即可,在main函数中,使用者只需要返回一个Loginer接口,至于这个接口的实现,使用者是不关心的,所以接口的设计者可以把defaultLogin类型设计为不可见,并让它实现接口Loginer,这样我们就隐藏了具体的实现。如果以后重构这个defaultLogin类型的具体实现时,也不会影响外部的使用者,极为方便,这也就是面向接口的编程。

假如一个导出的结构体类型里,有 一个未导出的字段,会出现怎样的问题。

type User struct {Name stringemail string    // (补充:在其他包声明和初始化User时,无法初始化email)
}

当我们在其他包声明和初始化User的时候,字段email是无法初始化的,因为它没有导出,无法访问。此外,一个导出的类型,包含了一个未导出的方法也一样,也是无法访问的。

我们再扩展,导出和未导出的类型相互嵌入,会有什么什么样的发现?

type user struct {Name string
}type Admin struct {user
}

被嵌入的user是未导出的,但是它的外部类型Admin是导出的,所以外部可以声明初始化Admin

func main() {var ad common.Adminad.Name = "张三"fmt.Println(ad)
}

这里因为user是未导出的(补充:这里的user首字母是小写),所以我们不能再使用字面值直接初始化user了,所以只能先定义一个Admin类型的变量,再对Name字段初始化。这里Name可以访问是因为它是导出的,在user嵌入到Admin中时,它已经被提升为Admin的字段,所以它可以被访问。(补充:注意字段的自动提升,应该有更进一步的理解)

如果我们还想使用:=操作符怎么做呢?

ad := common.Admin{}

字面值初始化的时候什么都不做就好了,因为user未导出,所以我们不能直接使用字面值初始化Name字段。

(补充:说来说去,只要外部Admin是导出的,内部user | User 不管是不是导出的,内部的首字母大写的字段如Name,外部都是可以访问的,但是不能直接初始化Name字段。)

(补充:如果Admin中有导出的字段,那么在common.Admin{}中进行初始化,而Name字段放在外面访问,ad.Name = "xxx"

还有要注意的是,因为user未导出,所以我们不能通过外部类型访问内部类型了,也就是说ad.user这样的操作,都会编译不通过。

最后,我们做个总结,导出还是未导出,是通过名称首字母的大小写决定的,它们决定了是否可以访问,也就是标志符的可见性。

对于.操作符的调用,比如调用类型的方法,包的函数,类型的字段,外部类型访问内部类型等等,我们要记住:.操作符前面的部分导出了,.操作符后面的部分才有可能被访问;如果.前面的部分都没有导出,那么即使.后面的部分是导出的,也无法访问。

例子 可否访问
Admin.User.Name
Admin.User.name
Admin.user.Name
Admin.user.name

以上表格中Admin 为外部类型,User(user)为内部类型,Name(name)为字段,以此来更好的理解最后的总结,当然方法也适用这个表格。

十二、Go goroutine(协程)

在谈goroutine之前,我们先谈谈并发和并行。

一般的程序,如果没有特别的要求的话,是顺序执行的,这样的程序也容易编写维护。但是随着科技的发展、业务的演进,我们不得不编写可以并行的程序,因为这样有很多好处。

(补充:例如,Web服务需要在各自独立的套接字(socket)上同时接受多个数据请求。每个套接字请求都是独立的,可以完全独立于其他套接字进行处理。具有并行执行多个请求的能力可以显著提高这类系统的性能。考虑到这一点,Go语言的语法和运行时直接内置了对并发的支持。)

比如你在看文章的时候,还可以听着音乐,这就是系统的并行,同时可以做多件事情,充分的利用计算机的多核,提升的软件运行的性能。

在操作系统中,有两个重要的概念:一个是进程、一个是线程。当我们运行一个程序的时候,比如你的IDE或者QQ等,操作系统会为这个程序创建一个进程,这个进程包含了运行这个程序所需的各种资源,可以说它是一个容器,是属于这个程序的工作空间,比如它里面有内存空间、文件句柄、设备和线程等等。

那么线程是什么呢?一个线程是一个执行空间, 比如要下载一个文件,访问一次网络等等。线程会被操作系统调度来在不同的处理器上运行编写的代码任务,这个处理器不一定是该程序进程所在的处理。操作系统的调度是操作系统负责的,不同的操作系统可能会不一样,但是对于我们程序编写者来说,不用关心,因为对我们都是透明的(补充:计算机中常说的 “透明” 指的是 看不见)。

一个进程在启动的时候,会创建一个主线程,这个主线程结束的时候,程序进程也就终止了,所以一个进程至少有一个线程,这也是我们在main函数里,使用goroutine的时候要让主线程等待的原因,因为主线程结束了,程序就终止了,那么就有可能会看不到goroutine的输出。

go语言中并发指的是让某个函数独立于其他函数运行的能力,一个goroutine就是一个独立的工作单元,Go的runtime(运行时)会在逻辑处理器上调度这些goroutine来运行,一个逻辑处理器绑定一个操作系统线程,所以说goroutine不是线程,它是一个协程,也是这个原因,它是由Go语言运行时本身的算法实现的。

这里我们总结下几个概念:

概念 说明
进程 一个程序对应一个独立程序空间
线程 一个执行空间,一个进程可以有多个线程
逻辑处理器 执行创建的goroutine,绑定一个线程
调度器 Go运行时中的,分配goroutine给不同的逻辑处理器
全局运行队列 所有刚创建的goroutine都会放到这里
本地运行队列 逻辑处理器的goroutine队列

当我们创建一个goroutine后,会先存放在全局运行队列中,等待Go运行时的调度器进行调度,之后调度器把他们分配给其中的一个逻辑处理器,并放到这个逻辑处理器对应的本地运行队列中,最终等着被逻辑处理器执行即可。

(补充:

有时,正在运行的goroutine需要执行一个阻塞的系统调用,如打开一个文件。当这类调用发生时,线程和goroutine会从逻辑处理器上分离,该线程会继续阻塞,等待系统调用的返回。与此同时,这个逻辑处理器就失去了用来运行的线程。所以,调度器会创建一个新线程,并将其绑定到该逻辑处理器上。之后,调度器会从本地运行队列里选择另一个goroutine来运行。一旦被阻塞的系统调用执行完成并返回,对应的goroutine会放回到本地运行队列,而之前的线程会保存好,以便之后可以继续使用。

如果一个goroutine需要做一个网络I/O 调用,流程上会有些不一样。在这种情况下,goroutine会和逻辑处理器分离,并移到集成了网络轮询器的运行时。一旦该轮询器指示某个网络读或者写操作已经就绪,对应的goroutine就会重新分配到逻辑处理器上来完成操作。调度器对可以创建的逻辑处理器的数量没有限制,但语言运行时默认限制每个程序最多创建10000个线程。这个限制器可以通过调用 runtime/debug包的setMaxThreads方法来更改。如果程序试图使用更多的线程,就会崩溃。

这一套管理、调度、执行goroutine的方式称之为Go的并发。并发可以同时做很多事情,比如有个goroutine执行了一半,就被暂停执行其他goroutine去了,这是Go控制管理的。所以并发的概念和并行不一样,并行指的是在不同的物理处理器上同时执行不同的代码片段,并行可以同时做很多事情,而并发是同时管理很多事情(补充:比如有两件事,并行就是两个人一人一个任务同时去执行并完成;而并发就是一个人去不断交替完成两个任务),因为操作系统和硬件的总资源比较少,所以并发的效果要比并行好的多,使用较少的资源做更多的事情,也是Go语言提倡的。

Go的并发原理我们刚刚讲了,那么Go的并行是怎样的呢?其实答案非常简单,多创建一个逻辑处理器就好了,这样调度器就可以同时分配全局运行队列中的goroutine到不同的逻辑处理器上并行执行。(补充:如果希望让goroutine并行,必须使用多于一个逻辑处理器。)

// (补充:并发程序示例,由于电脑是4核的,所以这个示例中也有并行的调度)
func main() {var wg sync.WaitGroup  // (补充:一个计数的信号量)wg.Add(2)            // (补充:等待2个goroutine执行完后再结束程序)go func(){     // (补充:这里是匿名函数)defer wg.Done()for i := 1; i < 100; i++ {fmt.Println("A:", i)}}()go func(){defer wg.Done()for i := 1; i < 100; i++ {fmt.Println("B:", i)}}()wg.Wait()
}
// 补充:
// 运行结果:A和B会交叉出现

这是一个简单的并发程序。创建一个goroutine是通过go关键字的,其后跟一个函数或者方法即可。

这里的sync.WaitGroup其实是一个计数的信号量,使用它的目的是要main函数等待两个goroutine执行完成后再结束,不然这两个goroutine还在运行的时候,程序就结束了,看不到想要的结果。

sync.WaitGroup的使用也非常简单,先是使用Add 方法设置计算器为2,每一个goroutine的函数执行完之后,就调用Done方法减1。Wait方法的意思是如果计数器大于0,就会阻塞,所以main 函数会一直等待2个goroutine完成后,再结束。

我们运行这个程序,会发现A和B前缀会交叉出现,并且每次运行的结果可能不一样,这就是Go调度器调度的结果。

(补充:

默认情况下,Go默认是给每个可用的物理处理器都分配一个逻辑处理器,因为我的电脑是4核的,所以上面的例子默认创建了4个逻辑处理器,所以这个例子中同时也有并行的调度,如果我们强制只使用一个逻辑处理器,我们再看看结果。

func main() {runtime.GOMAXPROCS(1)   // (补充:这一行与上图---比对,用来设置逻辑处理器的个数)var wg sync.WaitGroupwg.Add(2)go func(){defer wg.Done()for i := 1; i < 100; i++ {fmt.Println("A:", i)}}()go func(){defer wg.Done()for i := 1;i < 100; i++ {fmt.Println("B:", i)}}()wg.Wait()
}
// 补充:
// 运行结果:先打印A,在打印B
// 原因:这里goroutine执行的时间太短暂,不要误以为是顺序执行!!!

设置逻辑处理器个数也非常简单,在程序开头使用runtime.GOMAXPROCS(1)即可,这里设置的数量是1。我们这时候再运行,会发现先打印A,再打印B。

这里我们不要误认为是顺序执行,这里之所以顺序输出的原因,是因为我们的goroutine执行时间太短暂了,还没来得及切换到第2个goroutine,第1个goroutine就完成了。这里我们可以把每个goroutine的执行时间拉长一些,就可以看到并发的效果了,这里不再示例了,大家自己试试。

对于逻辑处理器的个数,不是越多越好,要根据电脑的实际物理核数,如果不是多核的,设置再多的逻辑处理器个数也没用,如果需要设置的话,一般我们采用如下代码设置。

runtime.GOMAXPROCS(runtime.NumCPU())

所以对于并发来说,就是Go语言本身自己实现的调度,对于并行来说,是和运行的电脑的物理处理器的核数有关的,多核就可以并行并发,单核只能并发了。

十三、Go 并发资源竞争

有并发,就有资源竞争,如果两个或者多个goroutine在没有相互同步的情况下,访问某个共享的资源,比如同时对该资源进行读写时,就会处于相互竞争的状态,这就是并发中的资源竞争。

并发本身并不复杂,但是因为有了资源竞争的问题,就使得我们开发出好的并发程序变得复杂起来,因为会引起很多莫名其妙的问题。

package mainimport ("fmt""runtime""sync"
)var (count int32           // count是所有goroutine都要增加其值的变量wg    sync.WaitGroup   // wg用来等待程序结束
)func main() {wg.Add(2)     // 计数加2,表示要等待两个goroutine// 创建2个goroutinego incCount()    go incCount()wg.Wait()      // 等待goroutine结束fmt.Println(count)
}// inCount增加包里的count变量的值
func incCount() {defer wg.Done()        // 在函数退出时调用Done来通知main函数工作已经完成for i := 0; i < 2; i++ {value := count     // 捕获count的值runtime.Gosched()   // 当前goroutine从线程退出,并放回到队列value++          // 增加本地value变量的值count = value  // 将该值保存回counter}
}

(补充:

)

这是一个资源竞争的例子,我们可以多运行几次这个程序,会发现结果可能是2,也可以是3,也可能是4。因为共享资源count变量没有任何同步保护,所以两个goroutine都会对其进行读写,会导致对已经计算好的结果覆盖,以至于产生错误结果,这里我们演示一种可能,两个goroutine我们暂时称之为g1和g2。

  1. g1读取到count为0。
  2. 然后g1暂停了,切换到g2运行,g2读取到count也为0。
  3. g2暂停,切换到g1,g1对count+1,count变为1。
  4. g1暂停,切换到g2,g2刚刚已经获取到值0,对其+1,最后赋值给count还是1
  5. 有没有注意到,刚刚g1对count+1的结果被g2给覆盖了,两个goroutine都+1还是1

不再继续演示下去了,到这里结果已经错了,两个goroutine相互覆盖结果。我们这里的runtime.Gosched()是让当前goroutine暂停的意思,退回执行队列,让其他等待的goroutine运行,目的是让我们演示资源竞争的结果更明显。注意,这里还会牵涉到CPU问题,多核会并行,那么资源竞争的效果更明显。

所以我们对于同一个资源的读写必须是原子化的,也就是说,同一时间只能有一个goroutine对共享资源进行读写操作

共享资源竞争的问题,非常复杂,并且难以察觉,好在Go为我们提供了一个工具帮助我们检查,这个就是go build -race命令。我们在当前项目目录下执行这个命令,生成一个可以执行文件,然后再运行这个可执行文件,就可以看到打印出的检测信息。

go build -race

多加了一个-race标志,这样生成的可执行程序就自带了检测资源竞争的功能,下面我们运行,也是在终端运行。

./hello

我这里示例生成的可执行文件名是hello,所以是这么运行的,这时候,我们看终端输出的检测结果。

➜  hello ./hello
==================
WARNING: DATA RACE
Read at 0x0000011a5118 by goroutine 7:main.incCount()/Users/xxx/code/go/src/flysnow.org/hello/main.go:25 +0x76Previous write at 0x0000011a5118 by goroutine 6:main.incCount()/Users/xxx/code/go/src/flysnow.org/hello/main.go:28 +0x9aGoroutine 7 (running) created at:main.main()/Users/xxx/code/go/src/flysnow.org/hello/main.go:17 +0x77Goroutine 6 (finished) created at:main.main()/Users/xxx/code/go/src/flysnow.org/hello/main.go:16 +0x5f
==================
4
Found 1 data race(s)

看,找到一个资源竞争,连在那一行代码出了问题,都标示出来了。goroutine 7在代码25行读取共享资源value := count,而这时goroutine 6正在代码28行修改共享资源count = value,而这两个goroutine都是从main函数启动的,在16、17行,通过go关键字。

既然我们已经知道共享资源竞争的问题,是因为同时有两个或者多个goroutine对其进行了读写,那么我们只要保证,同时只有一个goroutine读写不就可以了,现在我们就看下传统解决资源竞争的办法——对资源加锁。

Go语言提供了atomic包和sync包里的一些函数对共享资源同步加锁,我们先看下atomic包。

package mainimport ("fmt""runtime""sync""sync/atomic"
)var (count int32wg    sync.WaitGroup
)func main() {wg.Add(2)go incCount()go incCount()wg.Wait()fmt.Println(count)
}func incCount() {defer wg.Done()for i := 0; i < 2; i++ {value := atomic.LoadInt32(&count)   // (补充:读取int32类型变量的值)runtime.Gosched()value++atomic.StoreInt32(&count, value)    // (补充:修改int32类型变量的值)}
}

留意这里atomic.LoadInt32atomic.StoreInt32两个函数,一个读取int32类型变量的值,一个是修改int32类型变量的值,这两个都是原子性的操作,Go已经帮助我们在底层使用加锁机制,保证了共享资源的同步和安全,所以我们可以得到正确的结果,这时候我们再使用资源竞争检测工具go build -race检查,也不会提示有问题了。

atomic包里还有很多原子化的函数可以保证并发下资源同步访问修改的问题,比如函数atomic.AddInt32可以直接对一个int32类型的变量进行修改,在原值的基础上再增加多少的功能,也是原子性的,这里不再举例,大家自己可以试试。

atomic虽然可以解决资源竞争问题,但是比较都是比较简单的,支持的数据类型也有限,所以Go语言还提供了一个sync包,这个sync包里提供了一种互斥型的锁,可以让我们自己灵活的控制哪些代码,同时只能有一个goroutine访问,被sync互斥锁控制的这段代码范围,被称之为临界区,临界区的代码,同一时间,只能又一个goroutine访问。刚刚那个例子,我们还可以这么改造。

package mainimport ("fmt""runtime""sync"
)var (count int32wg    sync.WaitGroupmutex sync.Mutex
)func main() {wg.Add(2)go incCount()go incCount()wg.Wait()fmt.Println(count)
}func incCount() {defer wg.Done()for i := 0; i < 2; i++ {// 同一时刻只允许一个goroutine进入这个临界区mutex.Lock(){value := count     // 捕获count的值runtime.Gosched()   // 当前goroutine从线程退出,并放回到队列value++          // 增加本地value变量的值count = value  // 将该值保存回count}// 释放锁,允许其他正在等待的goroutine进入临界区mutex.Unlock()}
}

实例中,新声明了一个互斥锁mutex sync.Mutex,这个互斥锁有两个方法,一个是mutex.Lock(),一个是mutex.Unlock(),这两个之间的区域就是临界区,临界区的代码是安全的。

示例中我们先调用mutex.Lock()对有竞争资源的代码加锁,这样当一个goroutine进入这个区域的时候,其他goroutine就进不来了,只能等待,一直到调用mutex.Unlock() 释放这个锁为止。

这种方式比较灵活,可以让代码编写者任意定义需要保护的代码范围,也就是临界区。除了原子函数和互斥锁,Go还为我们提供了更容易在多个goroutine同步的功能,这就是通道chan,我们下篇讲。

十四、Go 通道

(补充:Go语言的并发同步模型来自一个叫做通信顺序进程(Communicating Sequential Processes, CSP)的范型(paradigm)。**CSP是一种消息传递模型,通过在goroutine之间传递数据来传递消息,而不是对数据进行加锁来实现同步访问。**用于在goroutine之间同步和传递数据的关键数据类型叫做通道(channel)。对于没有使用过通道写并发程序的程序员来说,通道会让他们感觉神奇而兴奋。使用通道可以使编写并发程序更容易,也能让并发程序出错更少。)

上一篇我们讲的原子函数和互斥锁,都可以保证共享数据的读写,但是呢,它们还是有点复杂,而且影响性能,对此,Go又为我们提供了一种工具,这就是通道。

所以在多个goroutine并发中,我们不仅可以通过原子函数和互斥锁保证对共享资源的安全访问,消除竞争的状态,还可以通过使用通道,在多个goroutine发送和接受共享的数据,达到数据同步的目的。

通道,他有点像在两个routine之间架设的管道,一个goroutine可以往这个管道里塞数据,另外一个可以从这个管道里取数据,有点类似于我们说的队列。

声明一个通道很简单,我们使用chan关键字即可,除此之外,还要指定通道中发送和接收数据的类型,这样我们才能知道,要发送什么类型的数据给通道,也知道从这个通道里可以接收到什么类型的数据。

ch := make(chan int)

通道类型和Map这些类型一样,可以使用内置的make函数声明初始化,这里我们初始化了一个chan int类型的通道,所以我们只能往这个通道里发送int类型的数据,当然接收也只能是int类型的数据。

我们知道,通道是用于在goroutine之间通信的,它具有发送和接收两个操作,而且这两个操作的运算符都是<-

ch <- 2   //发送数值2给这个通道
x := <-ch   //从通道里读取值,并把读取的值赋值给x变量
<-ch         //从通道里读取值,然后忽略

看例子,慢慢理解发送和接收的用法。发送操作<-在通道的后面,看箭头方向,表示把数值2发送到通道ch里;接收操作<-在通道的前面,而且是一个一元操作符,看箭头方向,表示从通道ch里读取数据。读取的数据可以赋值给一个变量,也可以忽略。

通道我们还可以使用内置的close函数关闭。

close(ch)

如果一个通道被关闭了,我们就不能往这个通道里发送数据了,如果发送的话,会引起painc异常。但是,我们还可以接收通道里的数据,如果通道里没有数据的话,接收的数据是零值

刚刚我们使用make函数初始化的时候,只有一个参数,其实make还可以有第二个参数,用于指定通道的大小。默认没有第二个参数的时候,通道的大小为0,这种通道也被成为无缓冲通道

ch := make(chan int)
ch := make(chan int, 0)
ch := make(chan int, 2)    // (补充:第二个参数指定通道的大小,默认为0)

看例子,其中第一个和第二个初始化是等价的。第三个初始化创建了一个大小为2的通道,这种称为有缓冲通道

1. 无缓冲的通道

无缓冲的通道指的是通道的大小为0,也就是说,这种类型的通道在接收前没有能力保存任何值,它要求发送goroutine和接收goroutine同时准备好,才可以完成发送和接收操作。

从上面无缓冲的通道定义来看,发送goroutine和接收goroutine必须是同步的,同时准备后,如果没有同时准备好的话,先执行的操作就会阻塞等待,直到另一个相对应的操作准备好为止。这种无缓冲的通道我们也称之为同步通道

func main() {ch := make(chan int)go func() {var sum int = 0for i := 0; i < 10; i++ {sum += i}ch <- sum}()fmt.Println(<-ch)
}

在前面的例子中,我们为了演示goroutine,防止程序提前终止,都是使用sync.WaitGroup进行等待,现在的这个例子就不用了,我们使用同步通道来等待。

在计算sum和的goroutine没有执行完,把值赋给ch通道之前,fmt.Println(<-ch)会一直等待,所以main主goroutine就不会终止,只有当计算和的goroutine完成后,并且发送到ch通道的操作准备好后,同时<-ch就会接收计算好的值,然后打印出来。

2. 管道

我们在使用Bash的时候,有个管道操作|,它的意思是把上一个操作的输出,当成下一个操作的输入,连起来,做一连串的处理操作。

➜  ~ ls |grep 'D'
Desktop
Documents
Downloads

比如上面这个例子的意思是,先使用ls命令,把当前目录下的目录和文件列出来,作为下一个grep命令的输入,然后通过grep命令,匹配我们需要显示的目录和文件,这里匹配以D开头的文件名或者目录名。

其实我们使用通道也可以做到管道的效果,我们只需要把一个通道的输出,当成下一个通道的输入即可。

func main() {one := make(chan int)two := make(chan int)go func() {one <- 100}()go func() {v := <-onetwo <- v}()fmt.Println(<-two)
}

这里例子中我们定义两个通道onetwo,然后按照顺序,先把100发送给通道one,然后用另外一个goroutine从one接收值,再发送给通道two,最终在主goroutine里等着接收打印two通道里的值,这就类似于一个管道的操作,把通道one的输出,当成通道two的输入,类似于接力赛一样。

3. 有缓冲的通道

有缓冲通道,其实是一个队列,这个队列的最大容量就是我们使用make函数创建通道时,通过第二个参数指定的。

ch := make(chan int, 3)

这里创建容量为3的,有缓冲的通道。对于有缓冲的通道,向其发送操作就是向队列的尾部插入元素,接收操作则是从队列的头部删除元素,并返回这个刚刚删除的元素。

当队列满的时候,发送操作会阻塞;当队列空的时候,接受操作会阻塞。有缓冲的通道,不要求发送和接收操作时同步的,相反可以解耦发送和接收操作。

(补充:梳理一下,有缓冲的通道和无缓冲的通道之间有一个很大的不同:无缓冲的通道保证进行发送和接收的goroutine会在同一时间进行数据交换;有缓冲的通道没有这种保证。

)

想知道通道的容量以及里面有几个元素数据怎么办?其实和map一样,使用caplen函数就可以了。

cap(ch)
len(ch)

cap函数返回通道的最大容量,len函数返回现在通道里有几个元素。

func mirroredQuery() string {responses := make(chan string, 3)go func() { responses <- request("asia.gopl.io") }()go func() { responses <- request("europe.gopl.io") }()go func() { responses <- request("americas.gopl.io") }()return <-responses // return the quickest response
}
func request(hostname string) (response string) { /* ... */ }

这是Go语言圣经里比较有意义的一个例子,例子是想获取服务端的一个数据,不过这个数据在三个镜像站点上都存在,这三个镜像分散在不同的地理位置,而我们的目的又是想最快的获取到数据。

所以这里,我们定义了一个容量为3的通道responses,然后同时发起3个并发goroutine向这三个镜像获取数据,获取到的数据发送到通道responses中,最后我们使用return <-responses返回获取到的第一个数据,也就是最快返回的那个镜像的数据。

(补充:再来看一个示例

// 这个示例程序展示如何使用有缓冲的通道和固定数目的goroutine来处理一堆工作
package mainimport ("fmt""math/rand""sync""time"
)const (numberGoroutines = 4       // 要使用的goroutine的数量taskLoad     = 10   // 要处理的工作的数量
)var wg sync.WaitGroup      // wg用来等待程序完成// init初始化包,Go语言运行时会在其他代码执行之前,优先执行这个函数
func init() {// 初始化随机数种子rand.Seed(time.Now().Unix())
}// main是所有Go程序的入口
func main() {// 创建一个有缓冲的通道来管理工作tasks := make(chan string, taskLoad)// 启动goroutine来处理工作wg.Add(numberGoroutines)for gr := 1; gr <= numberGoroutines; gr++ {go worker(tasks, gr)}// 增加一组要完成的工作for post := 1; post <= taskLoad; post++ {tasks <- fmt.Sprintf("Task: %d", post)}// 当所有工作都处理完时关闭通道,以便所有goroutine退出close(tasks)// 等待所有工作完成wg.Wait()
}// worker作为goroutine启动来处理从有缓冲通道传入的工作
func worker(tasks chan string, worker int) {// 通知函数已经返回defer wg.Done()for {// 等待分配工作task, ok := <-tasksif !ok {// 这意味着通道已经空了,并且已被关闭fmt.Printf("Worker: %d : Shutting Down\n", worker)return}// 显示我们开始工作了fmt.Printf("Worker: %d : Started %s\n", worker, task)// 随机等一段时间来模拟工作sleep := rand.Int63n(100)time.Sleep(time.Duration(sleep) * time.Millisecond)// 显示我们完成了工作fmt.Printf("Worker: %d : Completed %s\n", worker, task)}
}
// 运行结果:
Worker: 4 : Started Task: 3
Worker: 2 : Started Task: 1
Worker: 1 : Started Task: 2
Worker: 3 : Started Task: 4
Worker: 2 : Completed Task: 1
Worker: 2 : Started Task: 5
Worker: 3 : Completed Task: 4
Worker: 3 : Started Task: 6
Worker: 4 : Completed Task: 3
Worker: 4 : Started Task: 7

4. 单向通道

有时候,我们有一些特殊场景,比如限制一个通道只可以接收,但是不能发送;有时候限制一个通道只能发送,但是不能接收,这种通道我们称为单向通道。

定义单向通道也很简单,只需要在定义的时候,带上<-即可。

var send chan<- int //只能发送
var receive <-chan int //只能接收

注意<-操作符的位置,在后面是只能发送,对应发送操作;在前面是只能接收,对应接收操作。

单向通道应用于函数或者方法的参数比较多,比如

func counter(out chan<- int) {}

例子这样的,只能进行发送操作,防止误操作,使用了接收操作,如果使用了接收操作,在编译的时候就会报错的。

使用通道可以很简单的在goroutine之间共享数据,下一篇会具体介绍一些例子,以便更好的理解并发。

十五、Go 并发示例-Runner

这篇通过一个例子,演示使用通道来监控程序的执行时间,生命周期,甚至终止程序等。 我们这个程序叫runner,我们可以称之为执行者,它可以在后台执行任何任务,而且我们还可以控制这个执行者,比如强制终止它等。

现在开始吧,运用前面十几篇连载的知识,来构建我们的Runner,使用一个结构体类型就可以。

//一个执行者,可以执行任何任务,但是这些任务是限制完成的,
//该执行者可以通过发送终止信号终止它
type Runner struct {tasks []func(int)       //要执行的任务complete chan error     //用于通知任务全部完成timeout <-chan time.Time //这些任务在多久内完成interrupt chan os.Signal //可以控制强制终止的信号
}

示例中,我们定义了一个结构体类型Runner,这个Runner包含了要执行哪些任务tasks,然后使用complete通知任务是否全部完成,不过这个执行者是有时间限制的,这就是timeout,如果在限定的时间内没有完成,就会接收到超时的通知,如果完成了就会接收到完成的通知。注意这里的timeout是单向通道,只能接收。

complete定义为error类型的通道,是为了当执行任务出现问题时返回错误的原因,如果没有出现错误,返回的是nil

此外,我们还定义了一个中断的信号,让我们可以随时的终止执行者。

有了结构体,我们接着再定义一个工厂函数New,用于返回我们需要的Runner

func New(tm time.Duration) *Runner {return &Runner{complete:  make(chan error),timeout:   time.After(tm),interrupt: make(chan os.Signal, 1),}
}

这个New函数非常简洁,可以帮我们很快的初始化一个Runnner,它只有一个参数,用来设置这个执行者的超时时间。这个超时区间被我们传递给了time.After函数,这个函数可以在tm时间后,会同伙一个time.Time类型的只能接收的单向通道,来告诉我们已经到时间了。

complete是一个无缓冲通道,也就是同步通道,因为我们要使用它来控制我们整个程序是否终止,所以它必须是同步通道,要让main routine等待,一致要任务完成或者被强制终止。

interrupt是一个有缓冲的通道,这样做是因为,我们可以至少接收到一个操作系统的中断信息,这样Go runtime在发送这个信号的时候不会被阻塞,如果是无缓冲的通道就会阻塞了。

系统信号是什么意思呢,比如我们在程序执行的时候按下Ctrl + C,这就是一个中断的信号,告诉程序可以强制终止了。

我们这里初始化了结构体的三个字段,而执行的任务tasks没有初始化,默认就是零值nil,因为它是一个切片。但是我们的执行者Runner不能没有任务啊,既然初始化Runner的时候没有,那我们就定义一个方法,通过方法给执行者添加需要执行的任务。

//将需要执行的任务,添加到Runner里
func (r *Runner) Add(tasks ...func(int)){r.tasks = append(r.tasks, tasks...)
}

这个没有太多可以说明的,r.tasks就是一个切片,来存储需要执行的任务。通过内置的append函数就可以追加任务了。这里使用了可变参数,可以灵活的添加一个,甚至同时多个任务,比较方便。

到了这里我们需要的执行者Runner,如何添加任务,如何获取一个执行者,都有了,下面就开始执行者如何运行任务?如何在运行的时候强制中断任务?在这些处理之前,我们先来定义两个我们的两个错误变量,以便在接下来的代码实例中使用。

var ErrTimeOut = errors.New("执行者执行超时")
var ErrInterrupt = errors.New("执行者被中断")

两种错误类型,一个表示因为超时错误,一个表示因为被中断错误。下面我们就看看如何执行一个个任务。

//执行任务,执行的过程中接收到中断信号时,返回中断错误
//如果任务全部执行完,还没有接收到中断信号,则返回nil
func (r *Runner) run() error {for id, task := range r.tasks {if r.isInterrupt() {return ErrInterrupt}task(id)}return nil
}//检查是否接收到了中断信号
func (r *Runner) isInterrupt() bool {select {       // (补充:下面有讲解select,跟switch很像)case <-r.interrupt:signal.Stop(r.interrupt)return truedefault:return false}
}

新增的run方法也很简单,会使用for循环,不停的运行任务,在运行的每个任务之前,都会检测是否收到了中断信号,如果没有收到,则继续执行,一直到执行完毕,返回nil;如果收到了中断信号,则直接返回中断错误类型,任务执行终止。

这里注意isInterrupt函数,它在实现的时候,使用了基于select的多路复用,selectswitch很像,只不过它的每个case都是一个通信操作。那么到底选择哪个case块执行呢?原则就是哪个case的通信操作可以执行就执行哪个,如果同时有多个可以执行的case,那么就随机选择一个执行。

针对我们方法中,如果r.interrupt中接受不到值,就会执行default语句块,返回false,一旦r.interrupt中可以接收值,就会通知Go Runtime停止接收中断信号,然后返回true

这里如果没有default的话,select是会阻塞的,直到r.interrupt可以接收值为止,因为我们例子中的逻辑要求不能阻塞,所以我们使用了default

好了,基础工作都做好了,现在开始执行我们所有的任务,并且时刻监视着任务的完成,执行事件的超时。

//开始执行所有任务,并且监视通道事件
func (r *Runner) Start() error {//希望接收哪些系统信号signal.Notify(r.interrupt, os.Interrupt)go func() {r.complete <- r.run()}()select {case err := <-r.complete:return errcase <-r.timeout:return ErrTimeOut}
}

signal.Notify(r.interrupt, os.Interrupt),这个是表示如果有系统中断的信号,发给r.interrupt即可。

任务的执行,这里开启了一个groutine,然后调用run方法,结果发送给通道r.complete。最后就是使用一个select多路复用,哪个通道可以操作,就返回哪个。

到了这时候,只有两种情况了,要么任务完成;要么到时间了,任务执行超时。从我们前面的代码看,任务完成又分两种情况,一种是没有执行完,但是收到了中断信号,中断了,这时返回中断错误;一种是顺利执行完成,这时返回nil。

现在把这些代码汇总一下,容易统一理解一下,所有代码如下

package commonimport ("errors""os""os/signal""time"
)
// (补充:ErrTimeOut会在任务执行超时时返回)
var ErrTimeOut = errors.New("执行者执行超时")
// (补充:ErrInterrupt会在接收到操作系统的事件时返回)
var ErrInterrupt = errors.New("执行者被中断")//一个执行者,可以执行任何任务,但是这些任务是限制完成的,
//该执行者可以通过发送终止信号终止它
// (补充:Runner在给定的超时时间内执行一组任务,并且在操作系统发送中断信号时结束这些任务)
type Runner struct {tasks     []func(int)      //要执行的任务,(补充:tasks持有一组以索引顺序依次执行的函数)complete  chan error       //用于通知任务全部完成timeout   <-chan time.Time //这些任务在多久内完成,(补充:timeout报告处理任务已经超时)interrupt chan os.Signal   //可以控制强制终止的信号,(补充:interrupt通道报告从操作系统发送的信号)}// (补充:New返回一个新的准备使用的Runner)
func New(tm time.Duration) *Runner {return &Runner{complete:  make(chan error),timeout:   time.After(tm),interrupt: make(chan os.Signal, 1),}
}//将需要执行的任务,添加到Runner里,(补充:这个任务是一个接收一个int类型的ID作为参数的函数)
func (r *Runner) Add(tasks ...func(int)) {r.tasks = append(r.tasks, tasks...)
}//执行任务,执行的过程中接收到中断信号时,返回中断错误
//如果任务全部执行完,还没有接收到中断信号,则返回nil
// (补充:run执行每一个已注册的任务)
func (r *Runner) run() error {for id, task := range r.tasks {// (补充:检测操作系统的中断信号)if r.isInterrupt() {return ErrInterrupt}// (补充:执行已注册的任务)task(id)}return nil
}//检查是否接收到了中断信号
func (r *Runner) isInterrupt() bool {select {// 当中断事件被触发时发出的信号case <-r.interrupt:signal.Stop(r.interrupt)    // 停止接收后续的任何信号return true// 继续正常运行default:return false}
}//开始执行所有任务,并且监视通道事件
func (r *Runner) Start() error {//希望接收哪些系统信号signal.Notify(r.interrupt, os.Interrupt)//用不同的goroutine执行不同的任务go func() {r.complete <- r.run()}()select {// 当任务处理完成时发出的信号case err := <-r.complete:return err// 当任务处理程序运行超时时发出的信号case <-r.timeout:return ErrTimeOut}
}

(补充:

上面代码段展示了依据调度运行的无人值守的面向任务的程序,以及所使用的并发模式。在设计上,可支持以下终止点:

  • 程序可以在分配的时间内完成工作,正常终止;
  • 程序没有及时完成工作,“自杀”;
  • 接收到操作系统发送的中断事件,程序立刻试图请理状态并停止工作

这个common包里的Runner我们已经开发完了,现在我们写个例子试试它。

package mainimport ("GoActualCombat/common""log""time""os"
)func main() {log.Println("...开始执行任务...")timeout := 3 * time.Second  // timeout规定了必须在多少秒内处理完成r := common.New(timeout)   // 为本次执行分配超时时间// 加入要执行的任务r.Add(createTask(), createTask(), createTask())// 执行任务并处理结果if err := r.Start(); err != nil {switch err {case common.ErrTimeOut:log.Println(err)os.Exit(1)case common.ErrInterrupt:log.Println(err)os.Exit(2)}}log.Println("...任务执行结束...")
}// createTask返回一个根据id休眠指定秒数的示例任务
func createTask() func(int) {return func(id int) {log.Printf("正在执行任务%d", id)time.Sleep(time.Duration(id)* time.Second)}
}

例子非常简单,定义任务超时时间为3秒,添加3个生成的任务,每个任务都是打印一个正在执行哪个任务,然后休眠一段时间。

调用r.Start()开始执行任务,如果一切都正常的话,返回nil,然后打印出...任务执行结束...,不过我们例子中,因为超时时间和任务的设定,结果是执行超时的。

2021/11/20 00:06:34 ...开始执行任务...
2021/11/20 00:06:34 正在执行任务0
2021/11/20 00:06:34 正在执行任务1
2021/11/20 00:06:35 正在执行任务2
2021/11/20 00:06:37 执行者执行超时

如果我们把超时时间改为4秒或者更多,就会打印...任务执行结束...。这里我们还可以测试另外一种系统中断情况,在终端里运行程序后,快速不停的按Ctrl + C,就可以看到执行者被中断的打印输出信息了。

到这里,这篇文章已经要收尾了,这个例子中,我们演示使用通道通信、同步等待,监控程序等。

此外这个执行者也是一个很不错的模式,比如我们写好之后,交给定时任务去执行即可,比如cron,这个模式我们还可以扩展,更高效率的并发,更多灵活的控制程序的生命周期,更高效的监控等,这个大家自己可以试试,基于自己的需求修改就可以了。

【Go语言 · 学习笔记】相关推荐

  1. 梓益C语言学习笔记之链表&动态内存&文件

    梓益C语言学习笔记之链表&动态内存&文件 一.定义: 链表是一种物理存储上非连续,通过指针链接次序,实现的一种线性存储结构. 二.特点: 链表由一系列节点(链表中每一个元素称为节点)组 ...

  2. 6.方法(go语言学习笔记)

    6.方法(go语言学习笔记) 目录 定义 匿名字段 方法集 表达式 1. 定义 方法是与对象实例绑定的特殊函数. 方法是面向对象编程的基本概念,用于维护和展示对象的自身状态.对象是内敛的,每个实例对象 ...

  3. 梓益C语言学习笔记之指针

    梓益C语言学习笔记之指针 一.32位平台下,地址是32位,所以指针变量占32位,共4个字节 二.内存单元的地址即为指针,存放指针的变量称为指针变量,故:"指针"是指地址,是常量,& ...

  4. c语言中void arrout,c语言学习笔记(数组、函数

    <c语言学习笔记(数组.函数>由会员分享,可在线阅读,更多相关<c语言学习笔记(数组.函数(53页珍藏版)>请在人人文库网上搜索. 1.数组2010-3-29 22:40一维数 ...

  5. c语言float二进制输出代码_C语言学习笔记——学前知识概述

    将我大一学习C语言时做的笔记拿来与大家分享,内容比较浅显,比较适合初学者,如有错误还请见谅,提出改正,谢谢! 前言:此C语言笔记是本人在自学时记录的一些重点或初学者常犯的错误,希望我的这本笔记能够对大 ...

  6. c语言学习笔记【结构体02】结构体指针变量与结构体变量的函数参数,C语言学习笔记结构体02结构体指针变量与结构体变量的函数参数.docx...

    C 语言学习笔记[结构体02]结构体指针变量与结构体变量 的函数参数 C 语言学习笔记之结构体指针变量一提指针,那可 是 C 语言的核心了,有多少学子曾拜倒在指针的脚下.单纯的说指针,其实并不难,但是 ...

  7. C语言学习笔记---001C语言的介绍,编译过程原理,工具等

    C语言学习笔记---001C语言的介绍,编译过程原理,工具等 创梦技术交流平台:资源下载,技术交流,网络赚钱: 交流qq群:1群:248318056 2群:251572072 技术交流平台:cre.i ...

  8. go get 拉取指定版本_go语言学习笔记-基础知识-3

    相关文档 go语言学习笔记-目录 1.简介 1.1 什么是GO Go 是一个开源的编程语言,它能让构造简单.可靠且高效的软件变得容易.Go是从2007年末由Robert Griesemer, Rob ...

  9. 安装成功配置环境变量_go语言学习笔记-Windows10开发环境安装和环境变量配置

    相关文档 go语言学习笔记-目录 1.安装 1.1.访问 https://golang.google.cn/dl/ 或 https://golang.org/dl/ 下载官方安装包 1.2.选择Win ...

  10. C语言学习笔记—code:blocks工具debug调试异常

    C语言学习笔记-code:blocks工具debug调试异常 参考文章: (1)C语言学习笔记-code:blocks工具debug调试异常 (2)https://www.cnblogs.com/do ...

最新文章

  1. 从windows server的文件服务到分布式文件服务(一)
  2. python byte和string转换
  3. i2c的时钟延展问题(转)
  4. Spring Remoting: Remote Method Invocation (RMI)--转
  5. 产品迭代的道与术,爆款产品的打造密码 | 周四话产品
  6. VTK:简单操作之RandomSequence
  7. 闭包、装饰器与递归_月隐学python第12课
  8. DOM-5【兼容】滚动距离与高度、兼容模式、可视尺寸
  9. python怎么让输出居中_python格式化输出字符串居中
  10. php框架加滑动条,IOS_iOS实现双向滑动条效果,最近做项目,碰到一种双向滑 - phpStudy...
  11. 2.6.24内核中对S3C2440的引导启动分析
  12. linux导出Mysql数据sql脚本
  13. memcached 远程拒绝服务漏洞
  14. 嵌入式视频采集编程思路(Video 4 Linux)-转
  15. PHP IE下载时提示”无法复制 无法读取源文件或磁盘”的解决办法
  16. 【黄啊码】软件测试之Loadrunner教程
  17. 天猫电视盒子服务器响应错误,电视盒子错误的使用方法,你中招了几条?
  18. 计算机固态硬盘与机械硬盘的区别是什么,电脑固态硬盘和机械硬盘有什么区别-电脑固态硬盘和机械硬盘区别介绍 - 系统家园...
  19. AI公司盈利难?MSN聊天机器人起家的小i是如何做到的
  20. Field属性(域)

热门文章

  1. android dataBinding 与ObservableField实现双向绑定
  2. 人工智能期末复习:聚类(详细笔记)
  3. checkbox 选中触发js事件
  4. 【后端检测-绕过】文件头检测绕过、二次渲染绕过
  5. win7桌面右下角有计算机图标,win7系统电脑任务栏右下角图标空白的解决方法
  6. 使用css渐变色。实现动态进度条效果
  7. 和平精英灵敏度分享码服务器没有响应,和平精英灵敏度分享码
  8. oracle当天八点,oracle实现动态查询前一天早八点到当天早八点的数据功能示例
  9. webrtc streamer前端页面js播放摄像头rtsp流
  10. mapbox 接入高德矢量地图实战