本章将概述 Go 语言及其基本功能。我们将对该语言及其特征进行简短解释,我们将在以下章节中详细阐述。这将有助于我们更好地理解 Go,同时使用它的所有功能和应用程序。
本章将介绍以下主题:
- 语言特点
- 包装和进口
- 基本类型、接口和用户定义类型
- 变量和函数
- 流量控制
- 内置函数
- 并发模型
- 内存管理
从本章开始,您需要在机器上安装 Go。请按照以下步骤执行此操作:
-
用
tar -C /usr/local -xzf go$VERSION.$OS-$ARCH.tar.gz
提取。 -
用
export PATH=$PATH:/usr/local/go/bin
将其添加到PATH
中。 -
确保 Go 与
go version
一起安装。 -
在
.profile
中添加 export 语句,自动添加。 -
如果要为代码使用不同的目录,也可以更改
GOPATH
变量(默认值为~/go
)。
我还建议安装 Visual Studio 代码(https://code.visualstudio.com/ 及其 vscode go(https://github.com/Microsoft/vscode-go )扩展,其中包含一个助手,该助手将安装改善 Go 开发体验所需的所有工具。
Go 是一种现代服务器语言,具有强大的并发原语,并且是一个大部分自动化的内存系统。它被一些人认为是 C 的继承者,在许多情况下它都能做到这一点,因为它的性能很好,有一个广泛的标准库,并且有一个伟大的社区,提供了许多第三方库,这些库涵盖、扩展和改进了它的功能。
Go 创建于 2007 年,旨在尝试解决谷歌的工程问题,并于 2009 年公开发布,2012 年达到 1.0 版。主版本仍然是相同的(版本 1),而次版本(版本 1.1、1.2 等)与其功能一起增长。这样做是为了遵守 Go 对所有主要版本兼容性的承诺。2018 年提出了两个新特性(泛型和错误处理)的草案,该草案可能会包含在 2.0 版中。
Go 背后的思想是:
- Robert Griesemer:谷歌研究员,曾参与过许多项目,包括 V8 JavaScript 引擎和设计,以及 Sawzall 的实现。
- Rob Pike:Unix 团队、Plan 9 和 Inferno OS 开发团队以及 Limbo 编程语言设计团队的成员。
- 肯·汤普森:计算机科学的先驱,最初 Unix 的设计者,B(C 的直接前身)的发明者。Ken 也是 Plan 9 操作系统的创建者和早期开发者之一。
Go 是一种非常固执己见的语言;有些人喜欢它,有些人讨厌它,主要是因为它的一些设计选择。以下是一些未被广泛接受的功能:
- 详细错误处理
- 缺乏泛型
- 缺少依赖项和版本管理
前两个问题将在下一个主要版本中解决,而后一个问题则由社区首先(godep、glide 和 Govendo)解决,由谷歌自己与 dep 一起解决依赖关系,并由 gopkg.in(解决 http://labix.org/gopkg.in) 在版本管理方面。
该语言的优点数不胜数:
- 它是一种静态类型语言,具有它带来的所有优点,比如静态类型检查。
- 它不需要集成开发环境(IDE),即使它支持许多集成开发环境。
- 标准库确实令人印象深刻,它可能是许多项目的唯一依赖项。
- 它有并发原语(通道和 goroutine),这隐藏了编写高效和安全的异步代码的最困难部分。
- 它附带了一个格式化工具,
gofmt
,它统一了 Go 代码的格式,使其他人的代码看起来非常熟悉。 - 它生成二进制文件,没有依赖关系,使部署快速而简单。
- 它是最简单的,有几个关键字,代码很容易阅读和理解。
- 它是 duck 类型的,具有隐式接口定义(如果它走路像鸭子,游泳像鸭子,嘎嘎叫像鸭子,那么它可能是鸭子。这在测试系统的特定功能时非常方便,因为它可以被模拟。
- 它是跨平台的,这意味着它能够为架构和操作系统生成不同于宿主的二进制文件。
- 有大量的第三方软件包,因此它在功能上几乎没有留下什么。托管在公共存储库中的每个包都可以编制索引并进行搜索。
现在,让我们看看 Go 代码是如何组织的。GOPATH
环境变量确定代码驻留的位置。此目录中有三个子目录:
src
包含所有源代码。pkg
包含编译包,分为架构/操作系统。bin
包含已编译的二进制文件。
源文件夹下的路径对应于包的名称($GOPATH/src/my/package/name
应该是my/package/name
。
go get
命令使使用它获取和编译包成为可能。获取对http://package_name?go-get=1
的调用,如果它找到go-import
元标记,它将使用该标记获取包。标签应该包含包名、使用的 VCS 和存储库 URL,所有内容都用空格分隔。让我们来看一个例子:
<meta name="go-import" content="package-name vcs repository-url">
go get
下载一个包后,它会尝试对其他包执行相同的操作,直到所有必要的源代码都可用,否则它无法递归解析。
每个文件都以一个包定义开始,即package package_name
,该定义对于目录中的所有文件都必须相同。如果包生成二进制文件,则包为main
。
包声明之后是一系列的import
语句,用于指定所需的包。
导入未使用的包(除非忽略)是一个编译错误,这就是 Go 格式化工具gofmt
删除未使用包的原因。有实验或社区工具,如 goimports(https://godoc.org/golang.org/x/tools/cmd/goimports 或 goreturns(https://github.com/sqs/goreturns ),也将丢失的导入添加到 Go 文件。避免循环依赖是很重要的,因为它们不会编译。
由于不允许循环依赖,因此包的设计需要与其他语言不同。为了打破循环依赖关系,最好从包中导出功能或用接口替换依赖关系。
Go 降低了二进制模型的所有符号可见性(导出和未导出),这与许多其他具有中间级别的语言不同。对于每个包,以大写字母开头的所有符号都将导出,而其他所有符号仅在包内使用。导出的值也可以由其他包使用,而未导出的值只能在包本身中使用。
如果其中一个包路径元素是内部的(例如,my/package/internal/pdf
),则会发生异常。这限制了自身及其子包只能由附近的包导入(例如,my/package
。如果有很多未报告的符号,并且您希望将它们分解为子包,同时防止其他包使用它,从而生成基本上私有的子包,那么这非常有用。查看以下内部软件包列表:
my/package/internal
my/package/internal/numbers
my/package/internal/strings
这些只能由my/package
使用,不能由任何其他包导入,包括my
。
导入可以有不同的形式。标准进口表格是完整的包装名称:
import "math/rand"
...
rand.Intn
命名导入将使用自定义名称替换包名称,在引用包时必须使用自定义名称:
import r "math/rand"
...
r.Intn
相同的包导入使符号在没有命名空间的情况下可用:
import . "math/rand"
...
Intn
忽略的导入用于导入包,而无需使用它们。这样就可以执行包的init
功能,而无需在代码中引用包:
import _ math/rand
// still executes the rand.init function
Go type 系统定义了一系列基本类型,包括字节、字符串和缓冲区、复合类型(如切片或映射)以及应用程序定义的自定义类型。
以下是 Go 的基本类型:
| 类别 | 类型 |
| 一串 | string
|
| 布尔值 | bool
|
| 整数 | int
、int8
、int16
、int32
、int64
|
| 无符号整数 | uint
、uint8
、uint16
、uint32
、uint64
|
| 指针整数 | uinptr
|
| 浮动指针 | float32
和float64
|
| 复数 | complex64
和complex128
|
int
、uint
和uiptr
的位数取决于体系结构(例如,x86 为 32 位,x86 为 64 位)。
除了基本类型之外,还有其他类型,称为复合类型。详情如下:
| 型 | 说明 | 示例 |
| 指针 | 变量在存储器中的地址 | *int
|
| 大堆 | 具有固定长度的相同类型元素的容器 | [2]int
|
| 片 | 数组的连续段 | []int
|
| 地图 | 字典或关联数组 | map[int]int
|
| 结构 | 可以具有不同类型的字段的集合 | struct{ value int }
|
| 作用 | 具有相同参数和输出的一组函数 | func(int, int) int
|
| 通道 | 类型管道,用于相同类型元素的通信 | chan int
|
| 界面 | 方法的特定集合,具有支持这些方法的基础值 | interface{}
|
空接口interface{}
是一个泛型类型,可以包含任何值。由于此接口没有任何要求(方法),因此任何值都可以满足它。
接口、指针、切片、函数、通道和映射可以有一个 void 值,该值在 Go bynil
中表示:
- 指针是不言自明的;它们不是指任何可变地址。
- 接口的基础值可以为空。
- 其他指针类型,如片或通道,可以为空。
包可以使用type defined definition
表达式定义自己的类型,其中定义是共享已定义内存表示的类型。自定义类型可以由基本类型定义:
type Message string // custom string
type Counter int // custom integer
type Number float32 // custom float
type Success bool // custom boolean
它们也可以由复合类型(如切片、贴图或指针)定义:
type StringDuo [2]string // custom array
type News chan string // custom channel
type Score map[string]int // custom map
type IntPtr *int // custom pointer
type Transform func(string) string // custom function
type Result struct { // custom struct
A, B int
}
它们还可以与其他自定义类型结合使用:
type Broadcast Message // custom Message
type Timer Counter // custom Counter
type News chan Message // custom channel of custom type Message
自定义类型的主要用途是定义方法并使类型特定于某个范围,例如定义名为Message
的string
类型。
接口定义以不同的方式工作。可以通过指定一系列不同的方法来定义它们,例如:
type Reader interface {
Read(p []byte) (n int, err error)
}
它们也可以是其他接口的组合:
type ReadCloser interface {
Reader
Closer
}
或者,它们可以是两个接口的组合:
type ReadCloser interface {
Reader // composition
Close() error // method
}
现在我们已经了解了类型,我们将了解如何在语言中实例化不同的类型。我们将首先了解变量和常量是如何工作的,然后我们将讨论函数和方法。
变量表示映射到一部分连续内存的内容。它们有一个定义内存扩展量的类型和一个指定内存中内容的值。类型可以是基本类型、复合类型或自定义类型,其值可以通过声明用其零值初始化,也可以通过赋值用另一个值初始化。
变量的零值声明使用var
关键字并通过指定名称和类型来完成;例如,var a int
。对于来自另一种语言(如 Java)的人来说,这可能是违反直觉的,在 Java 中,类型和名称顺序是颠倒的,但它实际上更易于人类阅读。
var a int
示例描述了一个名为a
的变量(var
,即整数(int
。此类表达式为所选类型创建一个零值的新变量:
| 型 | 零值 |
| 数字类型(int
、uint
和float
类型) | 0
|
| 字符串(string
类型) | ""
|
| 布尔值 | false
|
| 指针、接口、切片、映射和通道 | nil
|
启动变量的另一种方式是赋值,它可以具有推断的或特定的类型。通过以下操作可实现推断类型:
- 变量名,后跟
:=
运算符和值(例如a := 1
,也称为短声明。 var
关键字,后跟名称、=
运算符和值(例如,var a = 1
。
请注意,这两种方法几乎是等效的,并且是冗余的,但是 Go 团队决定保留这两种方法,以保证 Go 1 的兼容性。简短声明的主要区别在于不能指定类型,而是由值推断。
具有特定类型的赋值使用声明进行,后跟等号和值。如果声明的类型是接口,或者推断的类型不正确,这非常有用。以下是一些例子:
var a = 1 // this will be an int
b := 1 // this is equivalent
var c int64 = 1 // this will be an int64
var d interface{} = 1 // this is declared as an interface{}
某些类型需要使用内置函数才能正确初始化:
new
可以在为基础变量分配一些空间的同时创建特定类型的指针。make
初始化切片、贴图和通道:- 切片的大小需要一个额外的参数,底层数组的容量需要一个可选参数。
- 贴图的初始容量可以有一个参数。
- 通道的容量也可以有一个参数。它们不同于地图,地图无法改变。
使用内置函数初始化类型如下所示:
a := new(int) // pointer of a new in variable
sliceEmpty := make([]int, 0) // slice of int of size 0, and a capacity of 0
sliceCap := make([]int, 0, 10) // slice of int of size 0, and a capacity of 10
map1 = make(map[string]int) // map with default capacity
map2 = make(map[string]int, 10) // map with a capacity of 10
ch1 = make(chan int) // channel with no capacity (unbuffered)
ch2 = make(chan int, 10) // channel with capacity of 10 (buffered)
我们已经看到赋值操作,它使用=
运算符为变量提供一个新值。让我们来看看更多的运营商:
- 有比较运算符
==
和!=
,它们比较两个值并返回布尔值。 - 有一些数学运算可以在相同类型的所有数值变量上执行,即,
+
、-
、*
和/
。和运算也用于连接字符串。++
和--
是一个数字加一或减一的缩写。+=
、-=
、*=
、/=
在运算符前后等号之前执行操作,并将其分配给左边的变量。这四个操作产生相同类型的变量值;还有其他特定于数字的比较运算符:<
、<=
、>
和>=
。 - 有些操作是对整数的独占操作,并生成其他整数:
%,
、&
、|
、^
、&^
、<<
和>>
。 - 其他的只针对布尔型,并生成另一个布尔型:
&&
、||
和!
。 - 一个操作符仅为通道
<-
,用于从通道接收值或将值发送到通道。 - 对于所有非指针变量,也可以使用参考运算符
&
来获取可分配给指针变量的变量地址。*
运算符可以对指针执行解引用操作,并获得指针指示的变量值:
| 操作员 | 名称 | 说明 | 示例 |
| =
| 分配 | 将值指定给变量 | a = 10
|
| :=
| 声明和转让 | 声明变量并为其赋值 | a := 0
|
| ==
| 等于 | 比较两个变量,如果它们相同,则返回布尔值 | a == b
|
| !=
| 不平等 | 比较两个变量,如果它们不同,则返回布尔值 | a != b
|
| +
| 加 | 同一数值类型之间的和 | a + b
|
| -
| 减 | 同一数值类型之间的差异 | a - b
|
| *
| 时代 | 同一数字类型之间的乘法 | a * b
|
| /
| 被分割的 | 同一数字类型之间的划分 | a / b
|
| %
| 模 | 同一数字类型除法后的余数 | a % b
|
| &
| 和 | 点点滴滴 | a & b
|
| &^
| 一清二楚 | 一清二楚 | a &^ b
|
| <<
| 左移 | 位向左移动 | a << b
|
| >>
| 右移 | 位向右移位 | a >> b
|
| &&
| 和 | 布尔与 | a && b
|
| ||
| 或 | 布尔或 | a || b
|
| !
| 不 | 布尔不 | !a
|
| <-
| 接收 | 从通道接收 | <-a
|
| ->
| 邮寄 | 发送到通道 | a <- b
|
| &
| 参考 | 返回指向变量的指针 | &a
|
| *
| 解引用 | 返回指针的内容 | *a
|
将一个类型转换为另一个类型是一个名为casting的操作,它对接口和具体类型的工作方式略有不同:
- 接口可以转换为实现它的具体类型。此转换可以返回第二个值(布尔值),并显示转换是否成功。如果省略布尔变量,则应用程序将在失败的强制转换时死机。
- 对于具体类型,可以在具有相同内存结构的类型之间进行转换,也可以在数字类型之间进行转换:
type N [2]int // User defined type
var n = N{1,2}
var m [2]int = [2]int(N) // since N is a [2]int this casting is possible
var a = 3.14 // this is a float64
var b int = int(a) // numerical types can be casted, in this case a will be rounded to 3
var i interface{} = "hello" // a new empty interface that contains a string
x, ok := i.(int) // ok will be false
y := i.(int) // this will panic
z, ok := i.(string) // ok will be true
有一种特殊类型的条件运算符用于强制转换,称为类型开关,它允许应用程序一次尝试多个强制转换。以下是使用interface{}
检查基础值的示例:
func main() {
var a interface{} = 10
switch a.(type) {
case int:
fmt.Println("a is an int")
case string:
fmt.Println("a is a string")
}
}
变量的作用域或可见性也与其生存期有关。这可能是以下情况之一:
- 包:变量在所有包中都可见;如果导出该变量,则该变量也可以从其他包中看到。
- 函数:变量在声明它的函数中可见。
- 控件:变量在其定义的块内可见。
可见性下降,从一个包到另一个块。因为块可以嵌套,所以外部块对内部块的变量没有可见性。
同一作用域中的两个变量不能具有相同的名称,但内部作用域的变量可以重用标识符。发生这种情况时,外部变量在内部范围内不可见–这称为阴影,需要记住,以避免难以识别的问题,例如:
// this exists in the outside block
var err error
// this exists only in this block, shadows the outer err
if err := errors.New("Doh!"); err !=
fmt.Println(err) // this not is changing the outer err
}
fmt.Println(err) // outer err has not been changed
Go 的变量没有不变性,但定义了另一种称为常量的不变性值。这是由const
关键字定义的(而不是var
),它们是不能更改的值。这些值可以是基本类型和自定义类型,如下所示:
- 数字(整数,
float
- 复杂的
- 一串
- 布尔值
指定的值在分配给变量时没有类型。可以自动转换数字类型和基于字符串的类型,如下代码所示:
const PiApprox = 3.14
var PiInt int = PiApprox // 3, converted to integer
var Pi float64 = PiApprox // is a float
type MyString string
const Greeting = "Hello!"
var s1 string = Greeting // is a string
var s2 MyString = Greeting // string is converted to MyString
数值常量对于数学运算非常有用,因为它们只是正则数,所以可以与任何数值变量类型一起使用。
Go 中的函数由func
关键字标识,后跟标识符、最终参数和返回值。Go 中的函数一次可以返回多个值。参数和返回类型的组合称为签名,如下代码所示:
func simpleFunc()
func funcReturn() (a, b int)
func funcArgs(a, b int)
func funcArgsReturns(a, b int) error
括号之间的部分是函数体,return
语句可以在函数体内部使用,以提前中断函数。如果函数返回值,则 return 语句必须返回相同类型的值。
return
值可以在签名中命名;它们是零值变量,如果return
语句没有指定其他值,则返回这些值:
func foo(a int) int { // no variable for returned type
if a > 100 {
return 100
}
return a
}
func bar(a int) (b int) { // variable for returned type
if a > 100 {
b = 100
return // same as return b
}
return a
}
函数是 Go 中的一类类型,它们也可以分配给变量,每个签名代表不同的类型。他们也可以是匿名的;在这种情况下,它们被称为闭包。一旦一个变量用一个函数初始化,同一个变量可以用另一个具有相同签名的函数重新赋值。下面是一个为变量指定闭包的示例:
var a = func(item string) error {
if item != "elixir" {
return errors.New("Gimme elixir!")
}
return nil
}
接口声明的函数称为方法,它们可以通过自定义类型实现。方法实现看起来像一个函数,唯一的例外是名称前面有一个实现类型的参数。这只是语法上的甜点——方法定义在引擎盖下创建了一个函数,它接受一个额外的参数,即实现方法的类型。
这种语法可以为不同的类型定义相同的方法,每个类型将充当函数声明的名称空间。通过这种方式,可以用两种不同的方式调用方法,如下代码所示:
type A int
func (a A) Foo() {}
func main() {
A{}.Foo() // Call the method on an instance of the type
A.Foo(A{}) // Call the method on the type and passing an instance as argument
}
需要注意的是,类型及其指针共享同一名称空间,因此可以只为其中一个实现相同的方法。不能为类型及其指针定义相同的方法,因为两次声明该方法(对于类型及其指针)将产生编译错误(方法重新声明)。不能为接口定义方法,只能为具体类型定义方法,但接口可以在复合类型中使用,包括函数参数和返回值,如以下示例所示:
// use error interface with chan
type ErrChan chan error
// use error interface in a map
type Result map[string]error
type Printer interface{
Print()
}
// use the interface as argument
func CallPrint(p Printer) {
p.Print()
}
内置软件包已经定义了一个在标准库和所有在线可用软件包中使用的接口,error
接口:
type error interface {
Error() string
}
这意味着任何具有Error() string
方法的类型都可以用作错误,每个包都可以根据需要定义其错误类型。这可用于简洁地携带有关错误的信息。在本例中,我们定义了ErrKey
,它指定找不到string
键。除了键之外,我们不需要其他任何东西来表示错误,如下代码所示:
type ErrKey string
func (e Errkey) Error() string {
returm fmt.Errorf("key %q not found", e)
}
在 Go 中,所有内容都通过一个值传递,因此当调用函数或方法时,会在堆栈中创建变量的副本。这意味着对值所做的更改不会反映在被调用函数之外。甚至切片、映射和其他引用类型都是按值传递的,但由于它们的内部结构包含指针,所以它们的行为就像是按引用传递一样。如果为类型定义了方法,则不能为其指针定义方法,反之亦然。以下示例已用于检查值是否仅在方法内部更新,以及更改是否未反映main
功能:
package main
import (
"fmt"
)
type A int
func (a A) Foo() {
a++
fmt.Println("foo", a)
}
func main() {
var a A
fmt.Println("before", a) // 0
a.Foo() // 1
fmt.Println("after", a) // 0
}
要更改原始变量,参数必须是指向变量本身的指针–指针将被复制,但它将引用相同的内存区域,从而可以更改其值。请注意,指定另一个值指针(而不是其内容)不会更改原始指针引用的内容,因为它是副本。
如果我们对类型使用一个方法而不是它的指针,我们将看不到在方法之外传播的更改。
在下面的示例中,我们使用的是值接收器。这使得Birthday
方法中的User
值成为main
中User
值的副本:
type User struct {
Name string
Age int
}
func (u User) Birthday() {
u.Age++
fmt.Println(u.Name, "turns", u.Age)
}
func main() {
u := User{Name: "Pietro", Age: 30}
fmt.Println(u.Name, "is now", u.Age)
u.Birthday()
fmt.Println(u.Name, "is now", u.Age)
}
完整示例见https://play.golang.org/p/hnUldHLkFJY 。
因为更改应用于副本,所以原始值保持不变,正如我们从第二条 print 语句中看到的那样。如果我们想更改原始对象中的值,我们必须使用指针接收器,以便复制的对象将成为指针,并对基础值进行更改:
func (u *User) Birthday() {
u.Age++
fmt.Println(u.Name, "turns", u.Age)
}
完整示例见https://play.golang.org/p/JvnaQL9R7U5 。
我们可以看到,使用指针接收器可以更改基础值,我们可以更改struct
的一个字段或替换整个struct
本身,如下代码所示:
func (u *User) Birthday() {
*u = User{Name: u.Name, Age: u.Age + 1}
fmt.Println(u.Name, "turns", u.Age)
}
完整示例见https://play.golang.org/p/3ugBEZqAood 。
如果我们试图更改指针的值而不是基础指针的值,我们将编辑一个与在main
中创建的对象无关的新对象,并且这些更改不会传播:
func (u *User) Birthday() {
u = &User{Name: u.Name, Age: u.Age + 1}
fmt.Println(u.Name, "turns", u.Age)
}
完整示例见https://play.golang.org/p/m8u2clKTqEU 。
Go 中的某些类型通过引用自动传递。这是因为这些类型在内部定义为包含指针的结构。这将创建一个类型列表及其内部定义:
| 类型 | 内部定义 |
| map
|
struct {
m *internalHashtable
}
|
| slice
|
struct {
array *internalArray
len int
cap int
}
|
| channel
|
struct {
c *internalChannel
}
|
为了控制应用程序的流程,Go 提供了不同的工具–一些语句,如if
/else
、switch
和for
用于顺序场景,而其他语句,如go
和select
用于并发场景。
if
语句验证二进制条件,并在条件为true
时执行if
块内的代码。当else
块存在时,在条件为false
时执行。此语句还允许在条件之前进行简短声明,并用;
分隔。此条件可以用else if
语句链接,如下代码所示:
if r := a%10; r != 0 { // if with short declaration
if r > 5 { // if without declaration
a -= r
} else if r < 5 { // else if statement
a += 10 - r
}
} else { // else statement
a /= 10
}
另一个条件声明为switch
。这允许一个简短的声明,比如if
,后面跟着一个表达式。这样一个表达式的值可以是任何类型的(不仅仅是布尔值),它根据一系列的case
语句进行计算,每个语句后面都有一块代码。如果switch
和case
条件相等,则匹配表达式的第一条语句将执行其块。
如果在块执行被中断的情况下存在一个break
语句,但存在一个fallthrough
,则执行下面的case
块中的代码。如果不满足任何情况,可以使用名为default
的特殊情况来执行其代码,如下代码所示:
switch tier { // switch statement
case 1: // case statement
fmt.Println("T-shirt")
if age < 18{
break // exits the switch block
}
fallthrough // executes the next case
case 2:
fmt.Println("Mug")
fallthrough // executes the next case
case 3:
fmt.Println("Sticker pack")
default: // executed if no case is satisfied
fmt.Println("no reward")
}
for
语句是 Go 中唯一的循环语句。这要求您指定三个表达式,用;
分隔:
- 对现有变量的简短声明或赋值
- 每次迭代前要验证的条件
- 在迭代结束时执行的操作
所有这些语句都可以是可选的,没有条件意味着它总是true
。break
语句中断循环的执行,continue
跳过当前迭代并继续下一个迭代:
for { // infinite loop
if condition {
break // exit the loop
}
}
for i < 0 { // loop with condition
if condition {
continue // skip current iteration and execute next
}
}
for i:=0; i < 10; i++ { // loop with declaration, condition and operation
}
当switch
和for
组合嵌套时,continue
和break
语句引用内部流量控制语句。
外部循环或条件可以使用name:
表达式进行标记,而名称是其标识符,loop
和continue
后面都可以跟有名称,以指定干预的位置,如以下代码所示:
label:
for i := a; i<a+2; i++ {
switch i%3 {
case 0:
fmt.Println("divisible by 3")
break label // this break the outer for loop
default:
fmt.Println("not divisible by 3")
}
}
我们已经列出了一些用于初始化某些变量的内置函数,即make
和new
。现在,让我们回顾一下每个函数,看看它们的作用:
func append(slice []Type, elems ...Type) []Type
:此函数将元素附加到切片的末尾。如果基础数组已满,它会在追加之前将内容重新分配到更大的片。func cap(v Type) int
:返回数组的数字元素,如果参数是切片,则返回基础数组的数字元素。func close(c chan<- Type)
:关闭一个通道。func complex(r, i FloatType) ComplexType
:给定两个浮点数,返回一个复数。func copy(dst, src []Type) int
:将元素从一个切片复制到另一个切片。func delete(m map[Type]Type1, key Type)
:从地图中删除条目。func imag(c ComplexType) FloatType
:返回复数的虚部。func len(v Type) int
:返回数组、切片、映射、字符串或通道的长度。func make(t Type, size ...IntegerType) Type
:创建新的切片、贴图或通道。func new(Type) *Type
:返回指向指定类型的变量的指针,并用零值初始化。func panic(v interface{})
:停止当前 goroutine 的执行,如果未被拦截,则停止程序的执行。func print(args ...Type)
:将参数写入标准错误。func println(args ...Type)
:将参数写入标准错误,并在末尾添加新行。func real(c ComplexType) FloatType
:返回复数的实部。func recover() interface{}
:停止恐慌序列并捕获恐慌值。
一个非常重要的关键字是defer
,它隐藏了很多复杂性,但可以轻松执行许多操作。这将应用于函数、方法或闭包执行,并使其前面的函数在函数返回之前执行。一个常见且非常有用的用法是关闭资源。成功打开资源后,延迟关闭语句将确保它独立于退出点执行,如下代码所示:
f, err := os.Open("config.txt")
if err != nil {
return err
}
defer f.Close() // it will be closed anyways
// do operation on f
在函数的生命周期内,所有延迟语句都被添加到一个列表中,在退出之前,它们以相反的顺序执行,从最后一个到第一个defer
。
这些语句即使在出现死机时也会执行,这就是为什么可以使用带有recover
调用的延迟函数来拦截相应 goroutine 中的死机,并避免否则会杀死应用程序的死机。除了手动调用panic
功能外,还有一组操作会导致死机,包括以下操作:
- 访问负的或不存在的数组/片索引(索引超出范围)
- 将整数除以
0
- 发送到封闭通道
- 在
nil
指针(nil
指针上解引用 ** 使用填充堆栈的递归函数调用(堆栈溢出)*
*恐慌应该用于不可恢复的错误,这就是为什么错误只是 Go 中的值。恢复死机应该只是在退出应用程序之前尝试处理该错误。如果出现意外问题,那是因为没有正确处理或缺少一些检查。这是一个需要处理的严重问题,程序需要更改,这就是为什么它应该被拦截和撤销。
并发是非常重要的,它的两个基本工具就是关键字—chan
和go
。这是一种非常聪明的方法,可以隐藏设计良好、实现良好、易于使用和理解的并发模型的复杂性。
通道是用来交流的,这就是为什么 Go 的咒语如下:
不要通过共享内存进行通信,而是通过通信共享内存
通道用于共享数据,它通常连接应用程序中的两个或多个执行线程,这样就可以发送和接收数据,而不必担心数据安全。Go 有一个线程的轻量级实现,该线程由运行时而不是操作系统管理,让它们通信的最佳方式是使用通道。
创建一个新的 goroutine 非常简单–您只需要使用go
操作符,然后执行函数。这包括方法调用和闭包。如果函数有任何参数,将在例程启动之前对其进行求值。一旦启动,如果不使用通道,则无法保证外部作用域对变量的更改将同步:
a := myType{}
go doSomething(a) // function call
go func() { // closure call
// ...
}() // note that the closure is executed
go a.someMethod() // method call
我们已经看到了如何创建具有make
功能的新通道。如果一个通道是无缓冲的(0
容量),则发送到该通道是一个阻塞操作,它将等待另一个 goroutine 从同一通道接收数据以解锁该通道。容量显示在发送下一条消息成为阻塞操作之前,通道能够容纳多少消息:
unbuf := make(chan int) // unbuffered channel
buf := make(chan int, 3) // channel with size 3
为了发送到通道,我们可以使用<-
操作符。如果通道位于操作员左侧,则为发送操作;如果通道位于右侧,则为接收操作。从通道接收的值可以分配给变量,如下所示:
var ch = make(chan int)
go func() {
b := <-ch // receive and assign
fmt.Println(b)
}()
ch <- 10 // send to channel
可通过close()
功能关闭通道。此操作意味着无法向通道发送更多值。这就是为什么它通常是发送者的责任。发送到一个关闭的通道无论如何都会导致panic
,这就是为什么它应该由接收器来完成。此外,当从通道接收时,可以在赋值中指定第二个布尔变量。如果通道仍然打开,以便接收器知道通道何时关闭,则这将是正确的:
var ch = make(chan int)
go func() {
b, ok := <-ch // channel open, ok is true
b, ok = <-ch // channel closed, ok is false
b <- ch // channel close, b will be a zero value
}()
ch <- 10 // send to channel
close(ch) // close the channel
有一个名为select
的特殊控制语句,其工作原理与switch
完全相同,但仅对通道进行操作:
var ch1 = make(chan int)
var ch2 = make(chan int)
go func() { ch1 <- 10 }
go func() { <-ch2 }
switch { // the first operation that completes is selected
case a := <-ch1:
fmt.Println(a)
case ch2 <- 20:
fmt.Println(b)
}
Go 是垃圾收集;它以计算成本管理自己的内存。编写高效的应用程序需要了解其内存模型和内部结构,以减少垃圾收集器的工作并提高总体性能。
内存分为两个主要区域—堆栈和堆。应用程序入口点函数(main
)有一个堆栈,每个 goroutine 都会创建额外的堆栈,这些堆栈存储在堆中。正如其名称所示,堆栈是一个内存部分,它随着每次函数调用而增长,当函数返回时会收缩。堆由一系列动态分配的内存区域组成,它们的生存期没有预先定义为堆栈中的项;可以随时分配和释放堆空间。
所有在定义它们的函数之外存在的变量都存储在堆中,例如返回的指针。编译器使用一个名为转义分析的过程来检查堆上的变量。这可以通过go tool compile -m
命令进行验证。
堆栈中的变量随函数的执行而变化。让我们来看一下堆栈如何工作的一个实际例子:
func main() {
var a, b = 0, 1
f1(a,b)
f2(a)
}
func f1(a, b int) {
c := a + b
f2(c)
}
func f2(c int) {
print(c)
}
我们有main
函数调用一个名为f1
的函数,该函数调用另一个名为f2
的函数。然后,main
直接调用相同的函数。
当main
函数启动时,堆栈会随着所使用的变量而增长。在内存中,这类似于下表,其中每列表示堆栈的伪状态,它表示堆栈从左到右的时间变化方式:
| main
调用 | f1
调用 | f2
调用 | f2
返回 | f1
返回 | f2
调用 | f2
返回 | main
返回 |
| main()
| main()
| main()
| main()
| main()
| main()
| main()
| //空的 |
| a = 0
| a = 0
| a = 0
| a = 0
| a = 0
| a = 0
| a = 0
| |
| b = 1
| b = 1
| b = 1
| b = 1
| b = 1
| b = 1
| b = 1
| |
| | f1()
| f1()
| f1()
| | f2()
| | |
| | a = 0
| a = 0
| a = 0
| | c = 0
| | |
| | b = 1
| b = 1
| b = 1
| | | | |
| | c = 1
| c = 1
| c = 1
| | | | |
| | | f2()
| | | | | |
| | | c = 1
| | | | | |
调用f1
时,通过复制新部分中的a
和b
变量并添加新变量c
,堆栈再次增长。f2
也是如此。当f2
返回时,堆栈通过删除函数及其变量而收缩,这是f1
完成时发生的情况。当直接调用f2
时,它会通过循环使用f1
使用的相同内存部分再次增长。
垃圾收集器负责清理堆中未引用的值,因此避免在堆中存储数据是降低垃圾收集器(GC的工作的好方法,这会导致 GC 运行时应用程序的性能略有下降。
GC 负责释放堆中未在任何堆栈中引用的区域。这原本是用 C 写的,有一种停止世界的行为。程序停止了一小部分时间,释放了内存,然后继续运行。
Go 1.4 启动了将运行时(包括垃圾收集器)转换为 Go 的过程。在 Go 中翻译这些部分为更简单的优化奠定了基础,而优化已经从 1.5 版开始,在 1.5 版中 GC 变得更快,可以与其他 Goroutine 并行运行。
从那时起,对该过程进行了大量优化和改进,将 GC 时间缩短了几个数量级。
现在,我们已经对所有语言特性和功能进行了快速概述,我们可以重点讨论如何运行和构建我们的应用程序。
在 Go 中,有不同的命令来构建包和应用程序。第一个是go install
,后跟路径或包名,在$GOPATH
内的pkg
目录中创建包的编译版本。
所有编译后的包都按照操作系统和体系结构进行组织,存储在$GOOS
和$GOARCH
环境变量中。使用go env
命令以及其他信息(如编译标志)可以看到这些设置:
$ go env
GOARCH="amd64"
...
GOOS="linux"
GOPATH="/home/user/go"
...
GOROOT="/usr/lib/go-1.12"
...
对于当前架构和操作系统,所有编译后的包都将放在$GOOS_$GOARCH
子目录中:
$ ls /home/user/go/pkg/
linux_amd64
如果包名为main
且包含main
函数,则该命令将生成一个可执行的二进制文件,该文件将存储在$GOPATH/bin
中。如果包已经安装,并且源文件没有更改,则不会再次编译,这将在第一次编译后显著加快构建时间。
也可以使用go build
命令在特定位置构建二进制文件。可以使用-o
标志定义特定的输出文件,否则将使用包名作为二进制名称在工作目录中构建:
# building the current package in the working directory
$ go build .
# building the current package in a specific location
$ go build . -o "/usr/bin/mybinary"
执行go build
命令时,参数可以是以下参数之一:
- 作为相对路径的包(如当前包的
go build .
或go build ../name
) - 将在
$GOPATH
中查找的作为绝对路径(go build some/package
的包 - 特定的 Go 源文件(
go build main.go
)
后一种情况允许您构建一个在$GOPATH
之外的文件,并将忽略同一目录中的任何其他源文件。
第三个命令与 build 类似,但也运行二进制代码。它使用build
命令创建二进制文件,使用临时目录作为输出,并动态执行二进制文件:
$ go run main.go
main output line 1
main output line 2
$
在对源代码进行更改时,可以在生成或安装时使用 Run。如果代码是相同的,那么最好构建一次并执行多次。
在本章中,我们回顾了 Go 的一些历史及其当前的优缺点。在通过查看包系统和导入如何工作来理解名称空间之后,我们使用基本、复合和用户定义的类型探索了它的类型系统。
我们通过查看变量如何被声明和初始化,类型之间允许哪些操作,如何将变量转换为其他类型,以及如何查看接口的底层类型来关注变量。我们看到了作用域和阴影是如何工作的,以及常量和变量之间的差异。在此之后,我们跳转到函数,一类类型,以及每个签名如何表示不同的类型。然后我们了解了方法基本上是如何伪装成函数并附加到允许自定义类型满足接口的类型上的。
此外,我们还学习了如何使用诸如if
、for
和switch
之类的语句来控制应用程序流。我们分析了各种控制语句和循环语句之间的差异,并查看了每个内置函数的功能。然后,我们了解了基本并发如何与通道和 goroutine 一起工作。最后,我们了解了 Go 内部内存分配的工作原理,垃圾收集器的历史和性能,以及如何构建、安装和运行 Go 二进制文件。
在下一章中,我们将看到如何通过与文件系统交互来实现这些功能。
- 导出的符号和未导出的符号有什么区别?
- 为什么自定义类型很重要?
- 简短声明的主要限制是什么?
- 什么是范围,它如何影响变量阴影?
- 如何访问方法?
- 解释一系列的
if
/else
和switch
之间的区别。 - 在典型用例中,谁通常负责关闭通道?
- 什么是逃避分析?*