3.1 基本词法

在Go语言中,词法指的是代码的构成法则。通俗地讲,词法规定了我们敲入怎样的字符才能够编写出Go语言编译器认可的代码。

Go语言的语言符号又称为词法元素,共包括5类:标识符(identifier)、关键字(keyword)、操作符(operator)、分隔符(delimiter),以及字面量(literal)。它们是组成Go语言代码和程序的最基本单位。一般情况下,空格符、水平制表符、回车符和换行符都会被忽略,除非它们作为多个语言符号之间的分隔符的一部分。另外,在Go语言中我们并不需要显式地插入分号。在必要时,Go语言会自动为代码插入分号以进行语句分隔。

Go语言代码由若干个Unicode字符组成,也正是这些字符代表和形成了各种各样的Go语言符号。我们只要将这些Go语言符号按照Go语言的语法规则进行排列,就能够编写出Go语言编译器认可的程序。因此,我们学习使用Go语言编写程序的第一步,就是了解Go语言的语言符号及其使用方法。

这里简要介绍一下Unicode编码规范。Unicode编码规范是一种在计算机上使用的字符编码方式。它为世界上已存在的各种语言的每个字符都设定了统一且唯一的二进制编码。因此,它能够满足跨语言、跨平台地转换和处理文本的要求。关于Unicode编码的详细说明,请参见相关的文献和资料,或者直接访问其官方网站http://www.unicode.org并查询文档。

在这里,读者只需要记住一条简单的规则:Go语言的所有源代码都必须由Unicode编码规范的UTF-8编码格式进行编码。换句话说,我们编写的Go语言源码文件必须是UTF-8编码格式的。

本节会重点介绍Go语言的标识符、关键字和操作符,而字面量是表示各种数据类型及其值的重要方法,本节会先予以简单介绍,然后在3.2节讲解Go语言复合数据类型时,会对复合字面量进行重点介绍。关于分隔符的知识,我们也会在本章后续部分中予以介绍。

3.1.1 标识符

下面我们来对Go语言中最灵活的语言符号——标识符进行说明。一个标识符可以代表一个变量或一个类型。也就是说,我们可以把标识符看作是变量或类型的代号或名称。Go语言的标识符是由若干字母、下划线“_”和数字组成的字符序列。字符序列的第一个字符必须为字母。这里所说的字母是广义的,只要能够由Unicode编码,就符合要求。

在Go语言代码中,每一个标识符都必须在使用前进行声明。一个声明将一个非空的标识符与一个常量、类型、变量、函数或代码包绑定在一起。在同一个代码块中,不允许重复声明同一个标识符(这有一个例外情况,请参见3.3.1节)。在一个源码文件和一个代码包中的标识符都需要遵循此规则。一个已被声明的标识符的作用域与其直接所属的代码块的范围相同。因此,也可以说,每一个有效的标识符都代表了特定范围内的程序实体。

严格来讲,代码包声明语句并不算是一个声明。因为代码包名称并不会出现在任何一个作用域中。代码包声明语句的目的是为了鉴别若干源码文件是否属于同一个代码包,或者指定导入代码包时的默认代码包引用名称。

很多时候,我们需要访问其他代码包中的变量或类型。这时就需要用到限定标识符。我们可以把限定标识符看作是把代码包名称作为前缀的标识符。代码包名称和标识符本身之间需要用英文句点“.”分隔。例如,当我们需要访问代码包os中名为O_RDONLY的常量时,就需要这样写:os.O_RDONLY

我们刚才提到,一个限定标识符代表了对另一个代码包中的某个标识符的访问。这需要有两个前提条件。第一,这里所说的另一个代码包必须被事先导入。代码包的导入操作需要由Go语言的导入语句来实现。第二,这个在另一个代码包中的标识符必须是可导出的。

一个可导出的标识符也需要满足两个前提条件。第一,标识符名称中的第一个字符必须大写。Go语言是根据标识符名称中的第一个字符的大小写来确定这个标识符的访问权限的。具体的规则是:当标识符名称的第一个字符为大写时,其访问权限为“公开的”,这意味着该标识符可以被任何代码包中的任何代码通过限定标识符访问到;当标识符名称的第一个字符为小写时,其访问权限就是“包级私有的”,也就是说,只有与该标识符同在一个代码包的代码才能够访问到它。第二,标识符必须是被声明在一个代码包中的变量或者类型的名称,或者是属于某个结构体类型的字段名称或方法的名称。

另外,在Go语言中还存在着一类特殊的标识符,叫作预定义标识符。这类标识符随Go语言的源码一同出现。它们是在Go语言源码中被声明的。这类标识符包括以下几种。

  • 所有基本数据类型的名称。

  • 接口类型error

  • 常量truefalseiota

  • 所有内建函数的名称,即appendcapclosecomplexcopydeleteimaglenmakenewpanicprintprintlnrealrecover

这些特殊标识符会在本章陆续出现,我们会逐渐地熟悉它们。

现在我们来稍稍总结一下。在声明一个变量或自定义一个类型的时候,我们会创建标识符以表示变量或类型的名称。如果我们要引用其他代码包中的代码,那么就需要使用限定标识符。我们在声明一个基本数据类型的变量或者使用Go语言内建函数时还会用到预定义标识符。总之,标识符无处不在。它是我们编写Go语言代码时最常用到的语言符号。

顺便提一下,我们在Go语言代码中还会碰到一个叫作空标识符的标识符。它由一个下划线_表示。它一般被用在不需要引入一个新绑定的声明中。举个简单的例子,如果在代码中存在一个变量x,但是却不存在任何对它的使用。这样的代码会使编译器报错。如果我们不想因此引起一个编译错误的话,就需要在变量x的声明代码后的某个位置添加这样一行代码:

  1. _ = x

这个方法可以绕过编译器检查,使它不产生任何编译错误。这是因为这段代码确实使用到了变量x。不过,它没有在变量x上进行任何操作,也没有将它赋值给任何其他变量。更常用的一个例子是,在导入语句(或者称导入声明)中,当我们只想执行一下某个代码包中的初始化函数,而不需要使用这个代码包中的任何程序实体的时候,可以这样编写这个导入语句:

  1. import _ "runtime/cgo"

其中,"runtime/cgo"代表了一个标准库代码包的标识符。这段代码引发了导入这个代码包所需的所有操作(包括执行其中的所有初始化函数),但是却没有真正地把它绑定到一个具体的名称上。也正因为如此,在当前的源码文件中,我们无法对这个代码包中的任何程序实体进行调用(我们无法通过一个标识符访问到它)。以上就是空标识符“_”的作用。它只会导致赋值或导入操作的相关准备工作的进行而已。

在上面的内容中,我们提及了常量、变量、字段和方法等名词,本章的后续部分将详细地进行说明。

3.1.2 关键字

关键字是指被编程语言保留而不让编程人员作为标识符使用的字符序列。因此,关键字也称为保留字。每个编程语言都有自己的关键字。一个编程语言的关键字可以侧面体现出这门语言的简洁程度、功能特性甚至特有哲学。

现在我们来看看Go语言中的关键字。从使用角度看,Go语言的关键字可以分为3类,包括用于程序声明的关键字、用于程序实体声明和定义的关键字,以及用于程序流程控制的关键字,如表3-1所示。

表3-1 Go语言中的关键字

类别关键字
程序声明import, package
程序实体声明和定义chan, const, func, interface, map, struct, type, var
程序流程控制go, select, break, case, continue, default, defer, else, fallthrough, for, goto, if, range, return, switch

可以看到,Go语言的关键字并不多,只有25个。这也体现了Go语言简约的设计风格。

其中,用于程序声明的关键字只有两个——importpackage。这两个关键字我们已经很熟悉了。在本书第2章已经对它们进行过详细的说明。

在Go语言中,程序实体的声明和定义是建立在其数据类型体系之上的。用于各种程序实体声明和定义的关键字并不多,只有8个。其中大多数关键字用来声明和定义Go语言的复合数据类型,包括chanfuncinterfacemapstruct,共5个。它们分别与Go语言的复合数据类型Channel(通道)、Function(函数)、Interface(接口)、Map(字典)和Struct(结构体)相对应。除此之外,关键字type用于自定义数据类型,它与上述5个关键字可以连用。关键字var可以用于声明任何Go语言数据类型的变量,而关键字const则用于声明常量和常量表达式。它们的具体用法都会在后面讲到。

Go语言有许多种程序流程控制方法,我们在使用这些方法时会用到相应的关键字。这些关键字也是在Go语言所有关键字中比重最大的一部分,共有15个,绝大部分在第4章中都会讲到,除了goselect。这两个关键字主要用于Go语言并发编程,因此我们会在本书第三部分中讲到它们。它们恰恰也是Go语言所特有的关键字。尤其是go,将会在本书后续内容中展现其强大的威力。

3.1.3 字面量

简单来说,字面量就是表示值的一种标记法。但是,在Go语言中,字面量的含义要更加广泛一些。

我们常常会在Go语言代码中用到的字面量有以下3类。

  • 用于表示基础数据类型值的各种字面量。这是编写Go语言代码时最常用到的一类字面量。例如,表示浮点数类型值的12E-3。我们会在3.2节对它们进行说明。

  • 用于构造各种自定义的复合数据类型的类型字面量。例如,下面的字面量用于表示一个名称为Person的自定义结构体类型:

  1. type Person struct {
  2. Name string
  3. Age uint8
  4. Address string
  5. }

我们在编写Go语言代码的时候经常会根据需要创建出各种各样的自定义数据类型。因此,类型字面量在我们的代码中也会频繁出现。我们会在3.1.4节讲各种复合类型的时候说明相应类型的字面量的编写方法。

  • 用于表示复合数据类型的值的复合字面量。更确切地讲,它会被用来构造类型Struct(结构体)、Array(数组)、Slice(切片)和Map(字典)的值。复合字面量一般由值的字面类型和由花括号“{”和“}”括起来的复合元素的列表组成。这里的字面类型指的是隶属于结构体、数组、切片或字典类型的自定义数据类型的名称。而一个复合元素可以是一个单一表达式,也可以是一个键值对。如果是单一表达式,那么表达式的结果值必须可以被赋给对应的字段、元素或字面类型中的键类型;如果是键值对,那么键就应该是结构体类型字面量中定义的一个字段的名称,或者是数组或切片类型字面量中的一个有效的索引值,再或者是字典类型字面量中定义的键类型的一个值。对于被用来表示字典类型值的复合字面量来说,每一个复合元素都必须是键值对形式的。例如,下面的字面量用于表示上面那个名称为Person的结构体类型的值:
  1. Person{Name: "Robert Hao", Age: 32, Address: "Beijing, China"}

这个复合字面量中的类型名称是Person,意味着它代表了一个Person类型的值。在这个类型名称后面的是用花括号“{”和“}”括起来的3个作为复合元素的键值对。注意,对复合字面量的每次求值都会导致一个新的值被创建。因此,上面示例中的复合字面量每被求值一次就会创建出一个新的Person类型的值。我们还要注意的是,Go语言不允许在一个此类的复合字面量中为多个复合元素指定同一个字段名称或其他常量形式的键。例如,下面这3个复合字面量就都是错误的。

  1. Person{Name: "Robert Hao", Age: 32, Name: "Unknown"} // 表示结构体类型值
  2. map[string]string{"Name": "Robert Hao", "Age": "32", "Age": "32"} // 表示字典类型值
  3. []string{0: "0", 1: "1", 0: "-1"} // 表示切片类型值

这些字面量都无法通过编译,因为在它们的花括号中的键都有重复。

与复合字面量相关的更多编写方法和技巧将会在下一节陆续呈现。在这里,我们只是宏观地对字面量方面的知识进行概述。字面量是非常重要的一类语言符号。除非只是写写打印问候语句的Go语言程序,否则我们基本上都要用到它们。

3.1.4 类型

一个类型确定了一类值的集合,以及可以在这些值上施加的操作。类型可以由类型名称或者类型字面量指定。类型分为基本类型和复合类型,基本类型的名称可以代表其自身。比如,以下代码

  1. var bookName string

就声明了一个类型为string、名称为bookName的变量。类型string是基本类型中的一个。其他基本类型有boolbyteruneint/uintint8/uint8int16/uint16int32/uint32int64/uint64float32float64complex64complex128,共18个。我们在之前讲过,基本类型的名称都属于预定义标识符。因此,基本类型也可以称为预定义类型。除了boolstring之外的其他基本类型也叫作数值类型。对于基本类型而言,我们可以通过由类型名称和圆括号括起来的字面量组成的表达式,把这个字面量转换为该类型的值。比如,表达式uint(123)会将字面量123转换为uint类型的值。我们会在后面经常用到这类表达式。关于数据类型转换的更多内容,我们会在后面陆续展开。

除了基本类型之外,Go语言还有若干复合类型,包括我们已经提到过的Array(数组)、Struct(结构体)、Function(函数)、Interface(接口)、Slice(切片)、Map(字典)和Channel(通道),以及我们还未曾提及的Pointer(指针),共有8个。请读者记住这些官方英文名称和中文译名之间的对应关系,因为我们此后会穿插地使用这些名称。

复合类型一般由若干(也包括零)个其他已被定义的类型组合而成。比如:

  1. type Book struct {
  2. Name string
  3. ISBN string
  4. Press string
  5. PageNumber uint16
  6. }

以上代码是一个复合类型struct的声明,名称是Book。它由3个string类型的字段(NameISBNPress)和一个uint16类型的字段(PageNumber)组合而成。

从另一个角度来讲,Go语言中的类型又可以分为静态类型和动态类型。一个变量的静态类型是指在变量声明中示出的那个类型。绝大多数类型的变量都只拥有静态类型。唯独接口类型的变量例外,它除了拥有静态类型之外,还拥有动态类型。这个动态类型代表了在运行时与该变量绑定在一起的值的实际类型。这个实际类型可以是实现了这个接口类型的任何类型。接口类型的变量的动态类型可以在执行期间变化,因为所有实现了这个接口类型的类型的值都可以被赋给这个变量。但是,这个变量的静态类型永远只能是它声明时被指定的那个类型。也就是说,接口类型的变量的静态类型永远会是这个接口类型。这与Go语言的接口实现方式有很大关系。我们会在讲述接口类型的时候详细说明它。

下面我们再来说说类型的潜在类型。每一个类型都会有一个潜在类型。如果这个类型是一个预定义类型(也就是基本类型),或者是一个由类型字面量构造的复合类型,那么它的潜在类型就是它自身。例如,string类型的潜在类型就是string类型。又例如,在上面的示例中我们自定义的那个名为Book的类型的潜在类型就是Book。如果一个类型并不属于上述情况,那么这个类型的潜在类型就是在类型声明中的那个类型的潜在类型。这句话说起来比较拗口,我们来举几个例子吧。如果我们使用关键字type声明一个自定义类型:

  1. type MyString string

实际上,我们可以把类型MyString看作string类型的一个别名类型,那么MyString类型的潜在类型就是string类型。Go语言基本数据类型中的rune类型也是如此。它可以看作是uint32类型的一个别名类型,其潜在类型就是int32。需要注意的是,类型MyString和类型string是两个不相同的类型。例如,我们不能把属于其中一个类型的值赋给另一个类型的变量。但是,别名类型与它的源类型的不同仅仅体现在名称上,它们的内部结构却是一致的。所以,用于类型转换的表达式MyString("ABC")string(Mystring("ABC"))都是合法的。并且,更重要的是,这种类型转换并不会创建新的值。

一个类型的潜在类型具有可传递性。也就是说,如果存在如下类型声明:

  1. type iString MyString

那么类型iString的潜在类型会与MyString类型的相同,即为string类型。

下面,我们声明这样一个类型:

  1. type MyStrings [3]string

注意,类型MyStrings的潜在类型并不是[3]string。因为[3]string既不是一个预定义类型,也不是一个由类型字面量构造的复合类型,而是一个元素类型为string的数组类型。根据上面的定义,我们应该把[3]string类型的潜在类型作为类型MyStrings的潜在类型,而[3]string类型的潜在类型是string类型。因此,类型MyStrings的潜在类型就是string类型。

我们颇费口舌地阐明了潜在类型的概念。那么在现实场景中它又有什么意义呢?我们还是以数组类型为例。在Go语言中,有如下规则:一个数组类型的变量的声明中的类型决定了在这个变量中可以存放哪一个类型的元素。现在我们使用潜在类型这个概念替换一部分文字,即一个数组类型的潜在类型决定了在该类型的变量中可以存放哪一个类型的元素。这样说是不是更简洁且容易理解呢?潜在类型和类型、变量、函数一样,都是Go编程语言中的基础概念。我们一旦搞明白了它们,在今后提及更复杂的定义和概念的时候,就可以更加轻松地接受和领会了。

在上面的说明中,我们已经展示了几段关于类型声明的代码。Go语言的类型声明方式多种多样,对于复合类型来讲尤为如此。但是,这些类型声明语句的总体结构是基本一致的。一个类型声明总会以关键字type作为开始,并且需要用一个自定义标识符作为这个新类型的名称。最后,它还会包含一个基本类型的名称或者一个复合类型的定义。关于Go语言各个数据类型的详细讲解将在下一节陆续呈现。

3.1.5 操作符

操作符就是用于执行特定算术或逻辑操作的符号。在一些编程语言中,这些符号称为操作符。操作符总是会与其操作对象结合起来以表达既定的语义。这些操作对象统称为操作数。

在Go语言中,操作数代表了一个表达式中的基本值。Go语言的操作数可以是字面量,也可以是用于代表常量、变量或函数名称的限定或非限定标识符,还可以是方法(Method,函数的一种)表达式或者带圆括号的表达式。我们在下一小节中会讲到方法表达式。在这里,我们先来了解一下其他形式的操作数。在读完本节之后,相信读者对上述大部分操作数都会有足够的认知。

为了容易理解,本小节只使用表示基本数据类型值的字面量(操作数的有效形式之一)来辅助说明各种操作符的用法。另外,本小节还会多次提到表达式这个名词。表达式是下一小节的主题,我们在这里可以暂时把表达式理解为若干操作符和操作数的组合。

现在,让我们重新关注操作符。Go语言中的操作符并不多。从操作符与操作数的结合方式来看,Go语言的操作符可以被分为一元操作符和二元操作符。所谓一元操作符就是仅需要一个操作数的操作符,包括+-!^*&<-。而二元操作符就是需要两个操作数的操作符。在Go语言中没有三元操作符,所以除了一元操作符以外的操作符都必定是二元操作符。此外,除了!<-之外的一元操作符也可以作为二元操作符来使用。下面我们通过表3-2来看看Go语言的操作符都有哪些。

表3-2 Go语言中的操作符

符号说明示例
||表示逻辑或操作。它是二元操作符,同时也属于逻辑操作符true || false //表达式的结果是true
&&表示逻辑与操作。它是二元操作符,同时也属于逻辑操作符true && false //表达式的结果是false
==表示相等判断操作。它是二元操作符,同时也属于比较操作符"abc" == "abc" //表达式的结果是true
!=表示不等判断操作。它是二元操作符,同时也属于比较操作符"abc" != "Abc" //表达式的结果是true
<表示小于判断操作。它是二元操作符,同时也属于比较操作符1 < 2 //表达式的结果是true
<=表示小于或等于判断操作。它是二元操作符,同时也属于比较操作符1 < =2 //表达式的结果是true
>表示大于判断操作。它是二元操作符,同时也属于比较操作符3 > 2 //表达式的结果是true
>=表示大于或等于判断操作。它是二元操作符,同时也属于比较操作符3 >= 2 //表达式的结果是true
+表示求和操作。它既是一元操作符又是二元操作符,同时也属于算术操作符。若作为一元操作符,此操作符不会对原值产生任何影响+1 //表达式的结果是11 + 2 //表达式的结果是3
-表示求差操作。它既是一元操作符又是二元操作符。若作为一元操作符,则表示求反操作。同时,它也属于算术操作符-1 //表达式的结果为-1(1的相反数)1 - 3 //表达式的结果是-2
|表示按位或操作。它是二元操作符,同时也属于算术操作符5 | 11 //表达式的结果是15
^表示按位异或操作。它既是一元操作符又是二元操作符。若作为一元操作符,则表示按位补码操作。同时,它也属于算术操作符5 ^ 11 //表达式的结果是14^5 //表达式的结果是-6
表示求乘积操作。它既是一元操作符又是二元操作符。同时,它也属于算术操作符和地址操作符。若作为地址操作符,则表示取值操作p //若p为指向整数类型值2的指针类型值,则表达式的结果即为22 * 5 //表达式的结果是10
/表示求商操作。它是二元操作符,同时也属于算术操作符10 / 5 //表达式的结果是2
%表示求余数操作。它是二元操作符,同时也属于算术操作符12 % 5 //表达式的结果是2
<<表示按位左移操作。它是二元操作符,同时也属于算术操作符4 << 2 //表达式的结果是16
>>表示按位右移操作。它是二元操作符,同时也属于算术操作符4 >> 2 //表达式的结果是1
&表示按位与操作。它既是一元操作符又是二元操作符。同时,它也属于算术操作符和地址操作符。若作为地址操作符,则表示取址操作&v //表达式的结果为标识符v所代表的值在内存中的地址5 & 11 //表达式的结果是1
&^表示按位清除操作。它是二元操作符,同时也属于算术操作符5 &^ 11 //表达式的结果是4
!表示逻辑非操作。它是一元操作符,同时也属于逻辑操作符!b //若b的值为false,则表达式的结果为true
<-表示接收操作。它是一元操作符,同时也属于接收操作符<- ch //若ch代表了元素类型为byte的通道类型值,则此表达式从ch中接收byte类型值的操作

如表3-2所示,Go语言的操作符一共有21个。在该表的说明中,我们提到了一些与操作符有关的名词,包括算术操作符、比较操作符、逻辑操作符、地址操作符和接收操作符。实际上,这5个名词就是根据操作符的功能对Go语言操作符进行分类的结果。为了便于记忆和使用,我们下面就以功能分类来对Go语言操作符进行详细的讲解。

1. 算术操作符

算术操作符是用于操作数值类型值的符号。算术操作符对一个或两个操作数进行操作,并产生与第一个操作数具有相同类型的结果。其中的4个标准算术操作符可以作用于整数、浮点数和复数类型的操作数。它们是+-*/。其中,标准操作符+还可以用于操作字符串类型的值,作为字符串连接符来使用。比如:

  1. "Hello, " + "Golang" + "!" //表达式的结果是"Hello, Golang!"

注意,这样的字符串连接操作只会创建并使用一个新的字符串值来保存操作结果,而不会改变任何操作数的值。

除了标准算术操作符之外,其余的Go语言算术操作符都只能用于操作整数类型,包括%&|^&^<<>>,共7个。

需要说明的是,算术操作符/%共同实现了舍尾除法(truncated division)。简单来说,舍尾除法将两个数值相除之后的结果分解为了商数和余数。有下面的公式:

被除数=除数×商数+余数

余数的绝对值一定会小于除数的绝对值。实际上,对两个数值进行舍尾除法后得到的商数总是通过将除法结果向零取整而得出的。下面举几个例子:

  • 设被除数为5、除数为2,则除法结果为2.5。从结果2.5中可以分解出的商数2和余数为1。因为5=2×2+1。

  • 设被除数为5、除数为-2,则除法结果为-2.5。从结果-2.5中可以分解出的商数-2和余数1,因为5=-2×-2+1。

  • 设被除数为-5、除数为2,则除法结果为-2.5。从结果-2.5中可以分解出商数-2和余数-1,因为-5=2×-2-1。

  • 设被除数为-5、除数为-2,则除法结果为2.5。从结果-2.5中可以分解出商数2和余数-1,因为-5=-2×2-1。

由此,我们可以总结出这样一个规律:对两个数值进行舍尾除法后得到的余数的正负符号,总是会与被除数的正负符号相同。标准算术操作符%也会遵循此规则。除此之外,我们还需要注意,除数不能为零!否则程序会引发一个运行时的恐慌(panic)。不过,如果被除数是浮点数类型或复数类型,那么恐慌也不一定会发生,这取决于具体的实现方式。

除了/%,其他非标准算术操作符都用于二进制位运算。我们下面对它们进行解释。

算术操作符&|分别实现了按位与和按位或的操作。按位与的含义是,在同位上的两个二进制数只要有一个为0,则此位的结果就为0,否则此位的结果为1。而按位或的含义是,在同位上的两个二进制数只要有一个为1,则此位的结果就为1,否则此位的结果为1。例如:

  1. 7 & 13 = 5 // 十进制数的按位与操作
  2. 00000111 & 00001101 = 00000101 // 对应的二进制数运算示意

再如:

  1. 7 | 13 = 15 // 十进制数的按位或操作
  2. 00000111 | 00001101 = 00001111 // 对应的二进制数运算示意

为了简便和清晰,我们下面会以仅由8位二进制位就可以表示的数值(即byte类型的数值)作为示例数据。

算术操作符^实现了按位异或操作。按位异或的含义是,若在同位上的两个二进制数的值相同,则此位的结果就为0,否则,此位的结果为1。例如:

  1. 7 ^ 13 = 10 // 十进制数的按位异或操作
  2. 00000111 ^ 00001101 = 00001010 // 对应的二进制数运算示意

算术操作符&^实现了按位清除操作。按位清除的含义是,根据第二个操作数的二进制值对第一个操作数的二进制值进行相应的清零操作。具体来讲,如果第二个操作数的某个二进制位上的数值为1,那么就把第一个操作的对应二进制位上的数值设置为0。否则,第一个操作数的对应二进制位上的数值不变。当然,这不会改变第一个操作数的原值,只会根据两个操作数的二进制值计算出结果值。我们来看下面这个例子:

  1. 7 &^ 13 = 2 // 十进制数的按位清除操作
  2. 00000111 &^ 00001101 = 00000010 // 对应的二进制数运算示意

如果我们把两个操作数互换,那么:

  1. 13 &^ 7 = 8 // 十进制数的按位清除操作
  2. 00001101 &^ 00000111 = 00001000 // 对应的二进制数运算示意

显然,按位清除是一种不具交换性的二元运算。也就是说,如果将参与按位清除运算的两个操作数互换位置,那么运算结果将会不同。作为特殊情况,如果两个操作数的值相同,那么其按位清除的运算结果就会为0。

算术操作符<<实现了按位左移的操作。我们先来看最简单的情况。以按位左移一位为例,它是指把二进制值上的每一位数值都用右边相邻位上的数值代替,最右边的二进制位(也称为最低比特位)上的数值会被设置为0。例如,2(二进制值为00000010)在按位左移一位之后得到4(二进制位00000100)。从表面上看,这就像将所有二进制位向左移动了一位。这也是“左移”一词的由来。算术操作符<<是二元操作符,它的第一个操作数就是需要被“左移”的数值,而第二个操作数就是“左移”操作的次数。正因为此,第二个操作必须是一个正整数。例如:

  1. 8 << 3 = 64 // 十进制数的按位左移操作
  2. 00001000 << 3 = 01000000 // 对应的二进制数运算示意

读者可能已经意识到了,每左移一位就相当于在当前操作数的基础上乘以2。这是因为我们是在二进制位上进行操作的。

如果读者理解了<<操作符的含义,那么>>操作符代表的操作就很容易理解了。算术操作符>>实现了按位右移的操作。没错,它代表了一个与<<操作符完全相反的运算,即“右移”N次且“最高比特位”补零。其中,N即是第二个操作数的值。示例如下:

  1. 8 >> 3 = 1 // 十进制数的按位右移操作
  2. 00001000 >> 3 = 00000001 // 对应的二进制数运算示意

类似地,每右移一位就相当于在当前操作数的基础上除以2,移位后得到的值即是其商数。

在Go语言的算术操作符中,有一些操作符可以作为一元操作符来使用,包括+-^*&。其中,需要特别说明的是操作符^

如果与一元操作^联结的唯一操作数的类型是无符号的整数类型,这一操作就相当于对这个操作数和其整数类型的最大值进行二元的按位异或操作。例如:

  1. ^uint8(1) = 254 // 无符号整数的一元按位异或操作
  2. 00000001 ^ 11111111 = 11111110 // 对应的二进制数运算示意

其中,内置函数uint8会将一个整数字面量转换为一个uint8类型的值。这确保了一元操作符^的唯一操作数一定是一个无符号整数类型的值。

如果与一元操作^联结的唯一操作数的类型是有符号的整数类型,那么这一操作就相当于对这个操作数和-1进行二元的按位异或操作。例如:

  1. ^1 = -2 // 有符号整数的一元按位异或操作
  2. 00000001 ^ 11111111 = 11111110 // 对应的二进制数运算示意

注意,以上示例中的操作数的二进制值都是以补码形式表示的。并且,在上例中,我们并没有把操作数1的类型显式地转换为代表一个字节宽度的有符号整数数据类型int8(通过调用内置函数int8可以做到这一点)。这是因为在默认情况下整数字面量是有符号的。

除此之外,操作符*&仅在被当作二元操作符使用时才属于算术操作符。如果我们把它们作为一元操作符使用,那么它们就属于地址操作符。关于地址操作符我们稍后再作介绍。

最后,需要特别注意的是,除了<<>>的其他算术操作符只能对两个类型完全相同的数值类型的操作数进行操作。但是,只要有一个操作数是字面量就不存在这种约束。例如,表达式uint(2) + int(3)不会通过编译,而表达式uint(2) + 3却是合法的。后者中的3会被隐式地进行类型转换。具体细节请参看3.3.4节。

2. 比较操作符

Go语言的比较操作符用于比较两个操作数并得出无类型的布尔值。它们是==!=<<=>>=,共6个。其中,操作符==!=又称为相等操作符,而其余的4个操作符也称为排序操作符。这些操作符所实现的操作非常好理解。不过需要注意的是,参与操作的两个操作数所属的数据类型必须是互相兼容的。

关于针对各个数据类型及其值的具体比较规则,请读者查阅3.3.3节。

3. 逻辑操作符

Go语言的逻辑操作符包括||&&!,分别代表逻辑或、逻辑与和逻辑非操作。与它们联结的操作数只能是布尔值或者产出布尔值的表达式。逻辑操作符与操作数组合而成的表达式的结果也是布尔值。其中,操作符||&&是二元操作符,也就是说它们都需要两个操作数。以操作符||为例:

  1. b1 || b2

标识符b1b2都是布尔值或者产出布尔值的表达式。只要它们中有一个为true,那么上述示例中的表达式的结果就为true。也正因为如此,如果b1被求值(或者说其计算结果)为true,那么Go语言会忽略对b2的求值(也可以说不会计算b2)。再来看操作符&&

  1. b3 && b4

同样,标识符b3b4也都是布尔值或产出布尔值的表达式。与操作符||相反,只要它们中有一个为false,那么上述示例中的表达式的结果就为false。并且,只要b3被求值为false,那么Go语言就会忽略对b4的求值。

逻辑操作符!为一元操作符。如果其唯一操作数被求值为false,那么它与这个操作数组成的表达式的结果就是true,反之亦然。

我们可以把任意多个逻辑操作符和操作符进行串联并组成更复杂的表达式:

  1. b1 || b2 || !b3 || b4

请记住,在一般情况下,当多个逻辑操作符和操作数进行串联时,Go语言总是从左到右依次对它们进行求值。也就是说,上述示例中的表达式相当于:

  1. (((b1 || b2) || !b3) || b4)

Go语言会先对表达式b1 || b2进行求值,然后把这个求值结果与表达式!b3的求值结果做逻辑或运算,最后再把这第三个求值结果与b4做逻辑与运算。当然,我们也可以使用圆括号显式地改变求值的顺序。比如:

  1. b1 || (b2 || !b3) || b4

这种情况下,Go语言会把b1与表达式b2 || !b3的求值结果做逻辑或运算,最后将这第3个结果与b4做逻辑与运算。显然,以不同的顺序运算多个相同的子表达式很可能会得到不同的最终结果。

读者很可能已经注意到了表达式!b3。它总是会被最先运算,除非我们显式地使用圆括号改变其运算顺序,比如!(b3 && b4)。这种可以被优先运算的特权与操作符的优先级有关,我们稍后再作解释。

4. 地址操作符

我们之前已经提到,操作符&在作为一元操作符时即属于地址操作符。这时,操作符又被称为取值操作符,而操作符&又被称为取址操作符。

对于某个类型的操作数v,我们可以使用表达式&v来得到指向存放此操作数的内存地址的指针类型值。显然,这要求操作数v必须是可寻址的。也就是说,Go语言必须为了存储这个操作数专门开辟一段内存空间。为了满足这个要求,操作数v必须是下列几个种情况之一。

  • v是一个变量的名称。这时,如果我们要对变量v进行取址操作应该这样编写表达式:&v

  • v是一个取值操作符和操作数组成的表达式。这种情况我们稍后再讨论。

  • v是一个可寻址的数组类型值的索引表达式。对于至少包含了一个元素的数组类型值a,其有效的索引操作就是a[0]。这时,如果我们要对a的第一个元素进行取址操作,就应该这样编写表达式:&a[0]

  • v是一个切片类型值的索引表达式。对于至少包含了一个元素的切片类型值s,其有效的索引操作就是s[0]。这时,如果我们要对s的第一个元素进行取址操作就应该这样编写表达式:&s[0]

  • v是一个可寻址的结构体类型值中的一个字段选择器。对于包含了名称为F的字段的结构体类型值s,我们可以这样访问这个字段s.Fs.F即为字段选择器的表现形式——选择表达式。这时,如果我们要对结构体类型值s的字段F进行取址操作,就应该这样编写表达式:&s.F

  • v可以是一个复合字面量。这种情况是作为可寻址能力要求的一个特例存在的。复合字面量代表了一个自定义复合类型的值,而这个值在被赋给一个变量之前,是没有任何指针类型值与它相对应的。因此,它是不可能被寻址的。但是,一个复合字面量仍然可以被取址。这是因为,我们在使用取址操作符&对一个复合字面量进行取址操作的时候,Go语言会为这个复合字面量所代表的值专门生成一个指针类型值。

前面的第2种情况提到的取值操作符即是。作为一元操作符的只能与指针类型的操作数进行联结。具体来说,对于代表了某个指针类型值的标识符p,表达式p表示了这个指针类型值指向的那个值。注意,如果值p为空值(即nil),那么表达式p在被求值时就会引发一个运行时的恐慌。

读者可能已经意识到,一元操作符&之间存在着一定的对立关系。对于表达式p,我们可以再使用操作符&取到这个表达式所表示的那个被指向值的指针类型值。也就是说,表达式&(p)的结果与指针类型值p相同。反过来讲也是一样的,对于可寻址的操作数v,我们同样可以使用表达式(&v)来表示它。

关于指针的更多知识,请见3.2.8节。

5. 接收操作符

Go语言的接收操作符只有一个,即<-,只作用于通道类型的值。对于通道类型的值ch,表达式<-ch的含义是从此通道中接收一个值。前提是通道的方向必须允许接收操作,并且该操作的结果的类型必须与通道元素的类型之间存在可赋予的关系。这个表达式会被阻塞直到通道中有一个值可用。至于什么时候会有一个值可用,我们会在第7章说明。使用接收操作符时,以下两点需要特别注意。

  • 从一个通道类型的空值(即nil)接收值的表达式将会永远被阻塞。

  • 从一个已被关闭的通道类型值接收值会永远成功并立即返回一个其元素类型的零值。

一个由接收操作符和通道类型的操作数所组成的表达式可以直接被用于变量赋值或初始化,例如:

  1. v1 := <-ch
  2. v2 = <-ch

再如:

  1. v, ok = <-ch
  2. v, ok := <-ch

上面例子中的特殊标记=用于将一个值赋给一个已被声明的变量或常量。而特殊标记:=则用于在声明一个变量的同时对这个变量进行赋值,且只能在函数体内使用。我们在3.3.1节讲赋值语句的时候会对它们进行更详细的说明。

当我们同时对两个变量进行赋值或初始化时,第二个变量将会是一个布尔类型的值。这个值代表了接收操作的成功与否。这样我们就可以通过它来判断一个通道是否已被关闭了。如果此值为false,那么就说明这个通道已经被关闭。

我们在第7章讲解通道类型的时候会再次遇到接收操作符。

6. 操作符优先级

当一个表达式中存在多个操作符时,就涉及操作先后顺序的问题。在Go语言中,一元操作符拥有最高的优先级,而二元操作符的优先级共有5个,如表3-3所示。

表3-3 Go语言的二元操作符的优先级

优先级操作符
5* / % << >> & &^
4+ - | ^
3== != < <= > >=
2&&
1||

在此表中,以数字来表示操作符的优先级,数字越大就意味着优先级越高。如果在一个表达式中出现了处于相同优先级的多个操作符,且这些操作符之间仅存在操作数,那么就会按照从左到右的顺序进行操作。比如,表达式a << 4 b & c等同于(((a << 4) b) & c)。当然,我们可以使用使用圆括号显式地改变原有的操作顺序,例如表达式a << (4 b) & c等同于((a << (4 b)) & c),即子表达式4 * b会先被求值。

最后需要注意的是,++是语句而不是表达式,因而它们不存在于任何操作符优先级层次之内。例如,表达式p—等同于(p)—

操作符是组成表达式的元素之一,我们需要熟知它们的用法。有时对操作符使用不当会造成不可预知的程序错误,并且不容易排查。因此,请读者对本小节所讲的内容进行重点记忆。

3.1.6 表达式

表达式代表了把操作符和函数作用于操作数的计算方法。在Go语言中,表达式是构成具有词法意义的代码的最基本元素。Go语言的表达式有很多种,我们在之前讲到的限定标识符、复合字面量、操作符和操作数都包含在内。在本小节中,我们会对Go语言中最常用和最重要的表达式进行说明。认识和理解这些表达式会对我们编写合格和优秀的Go语言代码有非常大的帮助。

1. 基本表达式

基本表达式一般作为复杂的表达式的一部分。它也是高级表达式的操作对象。基本表达式有如下几种形成方式。

  • 使用操作数来表示基本表达式。例如,我们把用于表示切片类型值的复合字面量[]int{1, 2, 3, 4, 5}当作操作数,并使用这个操作数作为一个基本表达式。我们可以在这个基本表达式的基础上编写一个更复杂一些的表达式[]int{1, 2, 3, 4, 5}[2]。这个表达式用于取出数组中索引为2的元素。所以,此表达式的结果值即为3。顺便提一句,这种“更复杂一些”的表达式称为索引表达式。

  • 使用类型转换作为基本表达式。例如,有一个类型为uint8的变量v1,我们想把它与一个int类型的变量v2相加。此时,我们需要这样编写表达式:int(v1) + v2。我们在这里使用了表达式int(v1),而并没有直接使用变量名称v1。这是因为操作符+只能作用于两个类型完全相同的值。对于表达式int(v1) + v2来说,int(v1)就是它内部的一个基本表达式。

  • 使用内建函数调用作为基本表达式。例如,有一个数组类型的变量v3,我们想知道它包含的元素的个数。这时,我们就需要使用Go语言的内建函数len,并编写调用这个内建函数的代码len(v3)以完成需求。这个内建函数调用代码len(v3)就是一个基本表达式。

  • 一个基本表达式和一个选择符号可以组成另外一个基本表达式。选择符号是由英文句号“.”和一个标识符组合而成的符号。例如,如果在一个结构体类型中存在字段f,那么我们就可以在这个结构体类型的变量x上应用一个选择符号来访问这个字段f。这个表达式为x.f。其中,.f就是一个选择符号。注意,前提是这个变量x的值不能是nil。在Go语言中,nil用来表示空值。

  • 一个基本表达式和一个索引符号可以组成另外一类基本表达式——索引表达式。索引符号由狭义的表达式和外层的方括号“[”和“]”组成。这里所说的狭义的表达式是指仅由操作符和操作数组成的表达式。我们在前面讲操作符的时候提到过这种表达式。我们在前面提到过的表达式[]int{1, 2, 3, 4, 5}[2]就是索引表达式。更复杂一点的例子如[]int{1, 2, 3, 4, 5}[1+2]。

  • 一个基本表达式和一个切片符号可以组成一个基本表达式。切片符号由2个或3个狭义的表达式和外层的方括号组成,这些表达式之间由冒号“:”分隔。切片符号的作用与索引符号类似,但不同的是,索引符号只能用于取出数组或切片中的一个元素或者字符串中的一个字节,而切片符号则可以取出其中的一个或多个元素或字节。我们也可以这样理解:索引符号针对的是一个点,切片符号针对的是一个范围。举个简单的例子,如果我们想要取出一个切片[]int{1, 2, 3, 4, 5}的第二个到第四个元素,那么可以使用切片符号编写出这样的基本表达式:[]int{1, 2, 3, 4, 5}[1:4]。

  • 一个基本表达式和一个类型断言符号可以组成一个基本表达式。类型断言符号以一个英文句号“.”为前缀,并后跟一个被圆括号括起来的类型名称或类型字面量。类型断言符号用于判断一个变量或常量是否为一个预期的类型,并根据判断结果采取不同的响应。例如,如果我们要判断一个int8类型的变量num是否是int类型,可以这样编写表达式:interface{}(num).(int)。这也许与我们想象的代码编写方式不太一样。实际上,这一表达式的结果值也可能会让初识Go语言的读者疑惑。当然,这涉及一个特定的用法,我们稍后会对它进行详细说明。

  • 一个基本表达式可以和一个调用符号组成另外一个基本表达式。调用符号只针对于函数或者方法。因此,与调用符号组合的基本表达式一般不是一个代表代码包名称(或者其别名)的标识符就是一个代表结构体类型的方法的名称的标识符。调用符号由一个英文句号“.”为前缀和一个被圆括号括起来的参数列表组成,多个参数之间用逗号“,”分隔。例如,基本表达式os.Open("/etc/profile")表示对代码包os中的函数Open的调用。

至此,我们列出了Go语言中所有的基本表达式的基础概念和组成形式。下面我们将会对上述的几种基本表达式进行有针对性的说明。

2. 选择符号和选择表达式

首先需要记住的是,选择符号的应用对象不能是一个代表代码包名称(或者其别名)的标识符。例如,之前提到过的os.O_RDONLY是一个限定标识符,而不是一个包含选择符号的表达式。这正是因为os代表的是一个代码包。换个角度说,只有当一个基本表达式x不代表一个代码包的时候,我们才能在它之上应用一个选择符号,就像我们前面提到的基本表达式x.f。这类表达式又被称为选择表达式。可以看到,选择表达式与限定标识符在表现形式上非常相近,它们唯一的区别就在于应用对象。

基本表达式x.f中的f既可以是一个字段的名称,也可以是一个方法的名称。它被我们使用选择符.f调用了。也正因为此,x必须代表一个至少拥有字段或方法的某个类型的值。在Go语言中,符合此要求的类型有结构体类型和接口类型。另外,f不能是空标识符。还记得空标识符吗?空标识符用“_”来表示。

选择符号可以用于调用任意深度的类型值的字段或者方法。最简单的情况就是,如果x代表了某个结构体类型的变量,那么选择表达式x.f就表示了深度为0的字段或者方法。如果fx的一个字段的同时也代表了某个结构体类型的值,并且在这个类型上有一个字段f2,那么我们可以通过选择表达式x.f.f2访问到这个深度为1的某个结构体类型的值(由x的字段f代表)的字段f2。以此类推。也就是说,表达式最左边的那个值的深度为0,而每个选择符前缀(表现为英文句号“.”)右边的那个标识符代表的值,都比其左边的标识符代表的那个值的深度更深一层。

现在,我们对选择表达式中值的深度的含义已经有所了解了。下面让我们来看看一些与选择表达式有关的规则。

  • 对于一个类型T或者对应的指针类型*T的值x,选择表达式x.f表示类型T的最浅深度(即深度为0)的字段或者方法。这里有两个前提条件: T不能是接口类型;类型T必须要有名称为f的字段或者方法。如果不满足这两个前提条件,那么选择表达式x.f就是非法的。

  • 对于一个接口类型I的变量x,选择表达式x.f表示赋给x的那个值(实现了接口类型I的那个类型的值)的方法f。如果接口类型I的方法集合中不包含名称为f的方法,那么选择表达式x.f就是非法的。

请注意,除了上述两种情况之外的任何选择表达式都是非法的!

  • 如果x是一个与某个结构体类型对应的指针类型的变量,并且它的值为nil,那么针对表达式x.f的赋值和求值都会引起一个运行时恐慌。不论f代表的是那个结构体类型的一个字段,还是它拥有的某个方法,都会是这样。

  • 如果x是一个接口类型的变量且它的值为nil,那么针对表达式x.f的调用和求值都会引起一个运行时恐慌。这里的前提条件是,f必须代表的是该接口类型的一个方法。

上面提到的运行时恐慌属于Go语言异常处理机制的一部分。我们会在下一章讲到它。

在上面的规则中,我们提到了在与某个结构体类型对应的指针类型的变量上也能够找到在那个结构体类型中声明的字段。实际上,这涉及了选择符的一个特性。我们可以把这个特性叫作自动解引用。具体来说,如果x是与一个结构体类型对应的指针类型的值,那么表达式x.f就是表达式(x).f的一个速记法。不论f代表的是一个字段还是一个方法。注意,这里的操作符是一个取值操作符,且表达式x的结果值是指针类型值x指向的那个结构体类型值。如果f代表了一个字段且也是一个与结构体类型对应的指针类型值,那么表达式x.f.f2就是表达式((x).f).f2的一个速记法,不论f2代表的是一个字段还是一个方法。以此类推。如果x包含了一个类型为A的匿名字段,并且A是一个拥有字段或方法f的结构体类型,那么x.f就是选择表达式(x.A).f的一个速记法。关于结构体类型的匿名字段的说明,请参看下一节。从表面上看,这里的表达式x.f代表的是一个深度为0的字段或方法,但是实质上它却代表了一个深度为1的字段或方法。这是因为,虽然类型为A的匿名字段在其所属的结构体类型的声明中并没有一个显式的名称,但是它却具有与x的其他字段相同的深度。这从它代表的表达式(*x.A).f上就有所体现。

3. 索引符号和索引表达式

一个索引表达式由一个基本表达式和一个索引符号组成,形如a[x]。索引表达式a[x]会被求值为由x索引的a中的那个元素。被索引符号操作的基本表达式a代表的可以是数组、切片、字符串或字典类型的值。对于字典类型的值,标识符x则代表了字典的键。对于其他类型的值,x则代表了索引值。

与索引表达式有关的具体规则如下。

  • 如果a不是一个字典类型的值。索引值x必须是一个int类型的值,或者是无类型的整数字面量。同时,x必须大于等于0且小于a的长度。否则针对该表达式的赋值和求值都会引起一个与越界有关的运行时恐慌。顺便提一句,a的长度的范围就是int类型可以表示的非负的取值范围。

  • 如果A是一个数组类型,那么对于A及其对应的指针类型A则有:作为索引值的常量必须在上一条规则描述的范围内。如果A类型的变量a的值为nil(注意,数组类型的值不可能为nil)或者索引值x超出了规定的范围,就会引起一个运行时恐慌。索引表达式a[x]代表了数组类型的变量a的值中与索引值x对应的那个元素类型值,且a[x]的类型与数组类型A的元素类型相同。

  • 如果S是一个切片类型,那么对于与其类型值绑定的变量a则有:如果切片类型的值为nil或者索引值x超出了范围,那么就会引起一个运行时恐慌。索引表达式a[x]代表了切片类型的变量a的值中与索引值x对应的那个元素类型值,且a[x]的类型与切片类型S的元素类型相同。

  • 如果T是一个字符串类型,那么对于与其类型值绑定的变量a则有:作为常量的索引值必须在同是常量的字符串类型值的有效的长度范围内。如果索引值x超出了有效范围,那么就会引起一个运行时恐慌。索引表达式a[x]代表了字符串类型的变量a的值中与索引值x对应的那个字节类型(即byte)值,且a[x]的类型即是字节类型。注意,不能对a[x]进行赋值操作!因为字符串类型值是不能改变的。

  • 如果M是一个字典类型,那么对于与其类型值绑定的变量a则有:键x的类型必须是可以赋值给字典类型M的键的类型的。也就是说,键x的类型的值永远可以通过类型推断符号判定为字典类型M的键的类型。如果a中包含了一个以x为键的键值对,那么表达式a[x]就代表了a中的、与键x对应的那个值,且a[x]的类型与字典类型M的元素的类型相同。如果字典类型的变量a的值为nil,或者其中不包含以x为键的键值对,那么表达式a[x]的求值结果就会是字典类型M的元素的类型的零值。

注意,除上述规则中描述的情况之外的任何表达式都是非法的!

关于上面所说的“字典类型的变量a的值为nil,或者其中不包含以x为键的键值对”的情况,还存在一个有歧义的地方。具体来说,如果表达式a[x]的求值结果是该字典类型的元素类型的零值,那么我们无法辨别得到这个零值的真实原因是由于该字典类型值内没有与键x对应的键值对,还是由于其中的与键x对应的值本身就是这个零值。对于此,Go语言还允许这样的赋值语句:

  1. v, ok := a[x]

请注意,上面的索引表达式的结果是一对值,而不是单一值。第一个值的类型就是该字典类型的元素类型,而第二个值则是布尔类型的。在这个示例中,与变量ok绑定的布尔值代表了在字典类型的a中是否包含了以x为键的键值对。如果在a中包含这样的键值对,那么赋给变量ok的值就是true,否则就为false。这样,我们就可以很容易地消除上述歧义了。

最后,需要特别注意一点,虽然当字典类型的变量a的值为nil时,求值表达式a[x]并不会发生任何错误,但是在这种情况下对a[x]进行赋值却会引起一个运行时恐慌。

4. 切片符号和切片表达式

切片符号可以操作字符串、数组、数组的指针以及切片类型的值。对于这样一个类型的值a,切片表达式形如a[x:y:z]a是切片符号[x:y]的操作对象。其中,x代表了切片的元素下界索引,y代表了切片的元素上界索引,而z则代表了切片的容量上界索引。对于它们有以下约束:

  1. 0 <= 元素下界索引 <= 元素上界索引 <= 容量上界索引 <= 操作对象的容量

如果元素下界索引、元素上界索引或容量上界索引不满足此约束,那么切片表达式在被求值的时候,就会造成一个越界错误并引发一个运行时恐慌。

切片类型值的长度和容量是不同的。作为切片表达式的求值结果的那个切片类型值(以下简称新切片值)的长度一定等于元素上界索引 - 元素下界索引。我们可以直接编写针对新切片值的索引表达式以获取对应位置上的元素值,只要满足

  1. 0 <= 索引值 < (元素上界索引 - 元素下界索引)

即可。

切片类型值的容量体现了该值的最大有效索引范围。如果我们不在切片表达式中显式地指定容量值,那么新切片值的容量就一定等于

  1. 操作对象的容量 - 元素下界索引

的求值结果,否则其容量就一定等于

  1. 容量上界索引 - 元素下界索引

的求值结果。

对于切片表达式中的那3个索引值:如果它是一个常量,那么它在字面上就必须是一个int类型的非负值。如果索引值是由一个表达式代表的,那么就不存在这个限制,此表达式的求值结果只要是一个整数值就可以通过编译。

如果元素下界索引、元素上界索引和容量上界索引都是常量,那么它们就必须在字面上满足前面所说的那个约束条件。

对于元素下界索引和元素上界索引来说,容量上界索引显得并不那么重要,且总是可以被省略。但是,它也有特殊用途。在3.2.3节中,我们再对切片类型值的容量和容量上界索引的用法进行详细讲解。下面我们只关注切片表达式中的元素下界索引和元素上界索引。

对于字符串类型的值来说,切片表达式的作用是截取子字符串,并且它的求值结果也会是一个字符串类型的值。对于数组、数组的指针和切片类型的值来说,切片表达式的作用是截取一段切片。这种情况下,切片表达式的求值结果会是一个切片类型的值,并且这个切片类型值的元素类型会与切片符号的操作对象的元素类型一致。注意,如果切片符号的操作对象是数组类型的值,那么这个值必须是可寻址的。另外,当在一个空值(nil)上应用切片符号就会引发一个运行时恐慌。指向数组的指针类型和切片类型的值都可能为nil

下面,我们通过一系列示例来增强对以上描述的理解。

我们以元素类型为int的切片类型值[]int{1, 2, 3, 4, 5}为例。切片表达式[]int{1, 2, 3, 4, 5}[1:3]的求值结果是由操作对象[]int{1, 2, 3, 4, 5}中的第二个元素到第三个元素组成的元素类型为int的切片类型值,即[]int{2, 3}。眼尖的读者可能会有个疑问:在这个例子中,元素上界索引为3。它对应于操作对象中的第四个元素。但是,切片表达式截取出的切片类型值中却不包含第四个元素。这是为什么呢?

要解释这个原因,需要从索引的真正含义开始讲起。对于字符串类型值中的每一个字节以及数组和切片类型值中的每一个元素来说,都有一个索引值相对应。那么,元素与索引值之间到底是怎样对应起来的呢?我们先来看图3-1。

{%}

图 3-1 切片与索引值

图中示意了元素类型为int的切片类型值[]int{1, 2, 3, 4, 5}中的元素与索引值的对应关系。可以看到,索引出现在了切片类型值的第一个元素的左边、最后一个元素的右边,以及每对相邻元素的中间位置上。最左边的索引的值为0,并与第一个元素相对应。相应地,紧挨在每个索引右边的元素就是与该索引对应的那个元素,除了最后一个索引。实际上,没有与最后一个索引对应的元素。但是,这个索引的值恰恰是该切片类型值的长度。因此,索引表达式a[3]的求值结果为该切片类型值的第四个元素4。然而,切片表达式中的索引值的作用略有不同。切片表达式中的元素下界索引和元素上界索引精确地限定了切片操作的范围。具体来说,切片表达式a[1:3]意味着索引1到索引3之间的所有元素都会被截取出来并构成一个新的切片类型值,即[]int{2, 3}。因此,与元素下界索引对应的(或者说紧挨在元素下界索引右边的)那个元素永远不会被包含在这个新生成的切片类型值中。这也意味着,对于这个作为切片表达式求值结果的值来说,其长度永远等于元素上界索引与元素下界索引的差。

对于一个字符串类型的值来说,上述规则也同样适用,如图3-2所示。

{%}

图 3-2 字符串与索引值

对于值为"golang"的字符串类型的变量a来说,索引表达式a[3]的求值结果为字节类型值97。由于字符串类型值"golang"中的每个字符都是单字节字符,因此这个字节类型值也就是"golang"中的第四个字符“a”的UTF-8编码值的十进制表示形式。此外,针对一个字符串类型值的切片表达式的求值结果也会是一个字符串类型的值。具体地说,切片表达式a[1:3]的求值结果是字符串类型值"ol"。当然,只有当在元素上界索引左边的字符全部为单字节字符时,切片表达式的结果才能够与相应位置的字符对应起来。举个例子,对于值为"Go并发编程实战"的字符串类型的变量a来说,切片表达式a[1:3]的求值结果就是字符串类型值"o3.1 基本词法 - 图3"。这显然与我们直觉上的结果不太一致。这是因为,UTF-8编码格式会以3个字节来表示一个中文字符,而切片操作是针对字节进行的。切片表达式a[1:3]意味着截取了字符串类型值"Go并发编程实战"中的第二个字节和第三个字节。其中,第二个字节即代表了单字节字符“o”,而第三个字节则是三字节字符“并”中的第一个字节。切片操作将截取出的多个字节类型值转换为一个字符串类型值,并将其作为最终的结果值。由于被截取出的第二个字节属于非法的UTF-8编码值,所以被转换成了字符“3.1 基本词法 - 图4”。根据上面的描述,如果我们这样编写切片表达式:a[1:5],那么得到的求值结果中就会包含一个完整的中文字符。切片表达式a[1:5]的求值结果是字符串类型值"o并"

好了,我们已经了解了关于切片表达式的很多细节。下面介绍与切片符号有关的两个语法糖。同样以元素类型为int的切片类型的变量a为例,设a的值为[]int{1, 2, 3, 4, 5}。切片表达式a[:3]的含义是截取在索引3之前的(左边的)所有元素、构成新的元素类型为int的切片类型值,并将其作为求值结果。这等同于切片表达式a[0:3]。这是因为切片符号中的元素下界索引的默认值为0。当我们未在元素下界索引的位置上插入任何值的时候,Go语言会使用0作为元素下界索引。对应地,元素上界索引的默认值为操作对象的长度值或容量值。因此,当我们未在元素上界索引的位置上插入任何值的时候,Go语言会使用操作对象的长度值作为元素上界索引。例如,切片表达式a[3:]的含义是截取在索引3之后的(右边的)所有元素、构成新的元素类型为int的切片类型值并将其作为求值结果。对于切片符号的操作对象[]int{1, 2, 3, 4, 5}来说,这等同于切片表达式a[3:5]

最后,如果a代表的是一个切片类型的值,那么切片表达式a[:]就等同于复制a所代表的值并将这个复制品作为此表达式的求值结果。否则,切片表达式a[:]就意味着将会有一个包含了指向a的第一个元素的指针的切片类型值被创建。关于这一区别,我们会在3.2.3节讲切片类型的时候详细说明。

5. 类型断言

对于一个求值结果为接口类型值的表达式x和一个类型T,对应的类型断言为:

  1. x.(T)

这也属于表达式的一种。其作用是判断“x不为nil且存储在其中的值是T类型的”这一假设是否成立。

如果T不是一个接口类型,那么x.(T)将会判断x的动态类型是否与类型T一致。也就是说,这是一个关于“类型T是否为x的动态类型”的判断。我们在本节讲类型的时候已经对动态类型有过说明。简单来说,一个变量的动态类型就是在运行期间存储在其中的值的实际类型。这个实际类型必须是该变量所声明的那个类型的一个实现类型,否则就根本不可能在该变量中存储这一类型的值。因此,在我们当前的上下文中,类型T必须是x的类型的一个实现类型。又由于在Go语言中只有接口类型可以被其他类型实现,所以x的求值结果必须是一个接口类型的值。举个例子,表达式int(123).(int)会引发一个编译错误,相应的提示信息是

  1. invalid type assertion: 123.(int) (non-interface type int on left)

其大意是:表达式int(123)的求值结果是int类型的,而int类型并不是一个接口类型。现在,我们把这个表达式改写为interface{}(123).(int)。这个表达式会顺利通过编译,因为表达式interface{}(123)的含义是将字面量123转换为interface{}类型的值,它的结果值是接口类型的。这正好符合上面的要求。实际上,interface{}是一个特殊的接口类型,代表空接口。所有类型都是它的实现类型。我们在3.2.6节讲接口类型的时候还会接触到它。

如果T不是一个接口类型且类型T不是x的类型的一个实现,那么类型断言x.(T)就是失败的,或者说它所判断的假设就是不成立的。这会引发一个运行时恐慌。例如,表达式interface{} (uint(123)).(int)在被求值时就会引发一个运行时恐慌,且相应的错误提示信息是

  1. interface conversion: interface is uint, not int

这是因为123在被转换为接口类型之前是uint类型的,而不是int类型的。

对于另一种情况,即T是一个接口类型,那么表达式x.(T)将会判断x的动态类型是否实现了接口类型T。这一判断内容几乎与上一种情况相反,且更容易被理解。在这种情况下,x的类型是否为一个接口类型并不重要,重要的是x的类型是否完全定义或实现了接口类型T中所定义的方法集合。关于接口类型、实现和方法集合方面的知识,请参看3.2.6节。

无论属于哪种情况,如果断言成功就说明x的结果值(对于一个变量x来说,其结果值就是存储在其中的那个值)的类型就是T或者它的实现类型。否则,在这个类型断言表达式被求值时就会引发一个运行时恐慌。注意,只有在程序运行期间,x的动态类型才能够被获知,而在编译期间能够确定的只有T所代表的类型。这也是在程序运行期间才能够确定类型断言是否成功的原因。

类型断言可以被用在很多地方。在对变量的赋值或初始化的时候,我们也可以使用类型断言,例如:

  1. v, ok := x.(T)

在这条赋值语句中使用了一种特殊用法。当使用类型断言表达式同时对两个变量进行赋值时,如果类型断言成功,那么赋给第一个变量的将会是已经被转换为T类型的表达式x的求值结果,否则赋给第一个变量的就是类型T的零值。但是,更关键的地方在于布尔类型的第二个结果。在这里,它会被赋给变量okok的值体现了类型断言的成功(此时值为true)与否(此时值为false)。注意,在这种使用场景下,即使类型断言失败也不会引发运行时恐慌。

6. 调用

如果有函数类型F的值f,那么表达式f(a1, a2, a3)就表示了对函数f的调用,同时会以a1a2a3作为参数传递给函数f。这其中的参数的数量、排列顺序和各自的类型都由函数类型F的声明来决定。在调用时,每个参数都可以用一个表达式来代表,但前提条件是该表达式的求值结果只有一个,并且其类型即是对应的参数的类型,或是该类型的实现类型。表达式f(a1, a2, a3)的类型即是F定义中的结果(确切地讲,是返回参数)的类型。与此对应,我们之前常说的一个表达式的类型实际上就是该表达式的求值结果的类型。

方法可以说是函数的一种。简单地说,方法比函数多了一个接收者。这个接收者可以是该方法所属的结构体类型的值,或者与该结构体类型对应的那个指针类型的值。在这种情况下,我们在调用一个结构体的方法的时候,必须先要声明一个该结构体类型的变量,或者初始化一个该结构体类型的值,然后再在这个变量或者值之上,使用前面讲过的选择符号来调用其拥有的方法。例如,有一个无参数的方法m,它所属的结构体的类型是S,并且有该结构体类型的变量s,那么表达式s.m()就是对方法m的调用。注意,s.m()有效的前提条件是,在类型S的方法集合中必须包含方法m,并且参数(如果有的话)的数量、排列顺序和各自的类型也需要完全对应。如果x是可寻址的,并且与类型S对应的那个指针类型的方法集合中包含了方法m(名称和参数的列表都要完全对应),那么调用表达式s.m()也是有效的。在这种情况下,s.m()即是调用表达式(&s).m()的速记法。

在函数或方法调用表达式中,函数值和参数是以通常的顺序被求值的。在这里,我们先重点解释一下“通常的顺序”这个词。

“通常的顺序”意味着在求值一个表达式、赋值语句或者返回语句中包含的操作数的时候,所有的函数调用、方法调用和通信操作(由接收操作符及其操作数组成)都会按照词法上的从左到右的顺序被求值。例如:

  1. y[f()], ok = g(h(), i()+x[j()], <-c), k()

在这一赋值语句中,函数调用和通信操作的求值顺序如下:f()h()i()j()<-cg()k()。注意,Go语言并不会对它们(函数调用、方法调用和通信操作)与其他表达式之间以及其他表达式之间的求值顺序作出任何保证。例如,在上面的示例中,我们无从知晓针对yx的索引表达式会在什么时候被求值。又例如:

  1. a := 10
  2. f := func() int { a = a * 2; return 5 }
  3. x := []int{a, f()}

在上面的示例中,我们将整数字面量赋给了变量a,然后将一个匿名函数(由函数字面量表示)赋给了变量f。最后,将一个元素类型为int的切片类型值赋给了变量x。注意,这个切片类型值中包含了两个元素,即是在前面被声明和初始化的变量af。根据上面所说的“通常的顺序”规则,变量x的值可能是[]int[10, 5],也可能是[]int[20, 5]。这是因为af()之间的求值顺序并不固定。

此外,在默认情况下,在单一表达式中的浮点数操作的求值顺序仅依赖于操作符的优先级顺序。不过,我们可以显式地使用圆括号来改变默认的求值顺序。

当函数或方法调用表达式中所有作为参数的标识符或表达式都被求值之后,它们的值会被传递给函数。而后,被调用的那个函数开始执行。当函数执行完毕并返回时,其返回参数(即结果)的值就会被传给调用该函数的代码。

最后,值得一提的是,作为函数调用表达式的一个特例,我们可以把一个函数或者方法的结果直接作为参数传递给另一个函数或者方法。例如:

  1. f(g(a1, a2, a3))

此表达式表明,在调用函数g之后,把它的所有结果都作为参数传递给了函数f。为了让上面的表达式合法,需要满足下面几个条件。

  • 函数g的结果与函数f的参数在数量上必须相同。并且按照从左到右的顺序,函数g的结果必须能够分别赋值给函数f的参数。也就是说,在对应的位置上,函数g的结果的类型需要与函数f的参数的类型一致,或者为函数f的参数的类型的一个实现。

  • 对函数f的调用表达式中除了函数g的调用表达式之外不能再出现其他参数,即f(a0, g(a1, a2, a3))和f(g(a1, a2, a3), a4)都是不合法的。

  • 函数g必须至少有一个结果。

  • 如果函数在参数列表的最后是一个可变长参数,那么Go语言会在把函数g的结果作为函数f的普通参数传递给f之后,再尝试将剩余的结果(如果有的话)传递给函数f的可变长参数。

最后,在Go语言中没有单独的方法类型和方法字面量,因此方法调用表达式除了需要满足上述的规则和约束之外再无其他。

7. 可变长参数

如果函数f可以接受的参数的数量是不固定的,那么函数f就是一个能够接受可变长参数的函数,简称为可变参函数。在Go语言中,在可变参函数的参数列表的最后总会出现一个可变长参数,这个可变长参数的类型声明形如…T。可变长参数可以用于接受数量不定但类型均为T或其实现类型的参数值。它等同于一个元素类型为T的切片类型的参数。对于函数f的每一次调用,被传递给可变长参数的值实际上都是包含了实际参数、元素类型为T的切片类型值。Go语言会在每次调用函数f的时候创建一个这样的切片类型值,并用它来存放这些实际参数。这个切片类型值的长度就是在当前调用表达式中与可变长参数绑定的实际参数的数量。下面举个例子,如果可变参函数appendIfAbsent的声明如下(由于篇幅原因,在这里省略了它的函数体):

  1. func appendIfAbsent(s []string, t ...string) []string

那么就可以这样编写针对它的调用表达式:

  1. appendIfAbsent([]string{"A", "B", "C"}, "C", "B", "E")

其中,与可变长参数t绑定的切片类型值为[]string{"C", "B", "E"}。其中包含了实际参数"C""B""E"

我们也可以直接把一个元素类型为T的切片类型值赋给…T类型的可变长参数。为此,我们需要在欲赋予可变长参数的那个切片类型值(或者代表这个值的变量)的后面追加一个特殊符号。例如,我们可以把上面的调用表达式改为:

  1. appendIfAbsent([]string{"A", "B", "C"}, []string{"C", "B", "E"}...)

或者,如果有一个元素类型为string的切片类型的变量s的话,那么就是这样:

  1. appendIfAbsent([]string{"A", "B", "C"}, s...)

对于将切片类型的变量赋给可变长参数的情况,Go语言不会专门创建一个切片类型值来存储其中的实际参数。因为,这样的切片类型值已经存在了,正如我们刚刚看到的那样,可变长参数t的值就是变量s的值。

至此,我们已经对Go语言中的绝大多数表达式进行了介绍和说明。我们在编写Go语言代码的时候离不开表达式。读者在本书的后续部分也会经常看到它们的身影。它们一般会作为各种Go语言语句的组成部分。另外,我们在后面讲复合数据类型的时候,还会对可作为表达式的一些类型字面量和复合字面量进行一番探讨。