同学们好!在这节课里,我们将一起学习Go语言的基础语法,也就是与包括面向对象等在内的各种高级特性无关的语言基础部分。


象学习其它计算机编程语言一样,学习Go语言首先需要了解它的基本数据类型,以及通过数据类型定义变量的方法。C/C++语言中的自增减运算和指针常常成为语言初学者的噩梦,即便是经验老道的编程高手也不能保证永远不在这两个地方摔跤。Go语言在这些方面可有什么改进吗?与百无禁忌的C/C++语言不同,Go语言中存在着许多语法禁忌,或者说语法约束,正是这些约束的存在使Go语言变得更加安全。


了解了Go语言的基本数据类型以后,还需要学习它的几种高级数据类型,这里面包括字符串、数组、切片以及映射等等,甚至还有函数,函数在Go语言中也是一种数据类型。


如何在包中导入其它包?如何获取用户输入的命令行参数?如何使用开关分支、标签和枚举?


最后我们将以结构体、init函数和defer语法作为本节课程的结束。


信数据者得永生。整个宇宙是由数据组成的。数据是用类型来描述的。因此基本数据类型乃是人们描述整个宇宙的基本要素。


首先是整型。Go语言的整型分为有符号和无符号两种。无符号整型以“u”开头,取义“unsigned”,即“无符号的”。每种整型又根据字长被分为8位(一字节)、16位(两字节)、32位(四字节)和64位(八字节)等数个版本。当然也可以不显式指定字长,而由编译器自行决定,这就是int和uint类型。其次是浮点型,Go语言的浮点型分为单精度浮点型,32位四个字节,和双精度浮点型,64位八个字节,分别用float32和float64表示。在Go语言中表示非真既假、非此即彼等布尔概念,需要用到布尔类型。


明确了基本数据类型,就可以用它们来定义变量。


Go语言定义变量的基本形式,“var”关键字打头说明这是一个变量,取义“variable”,即“变量”,后面跟变量名,变量名的后面写数据类型。如果需要在定义的同时赋初值,可以在数据类型的后面通过等号指定初值。既然初值的类型是明确的,而变量的类型应该与初值一致,变量名后面的数据类型显得有些多余,不妨省略不写,这时编译器会根据初值的类型自动推导出变量的类型。这种情况下,甚至可以将前面的“var”关键字一并省略掉,不过为了严格区分定义和赋值,变量名和初值之间的等号前面应加上冒号。


下面我们创建一个Var工程,实验一下变量的定义方法:

按下快捷键Ctrl+Shift+F10运行程序并观察输出。变量a采用的是先定义,再通过赋值语句设定初值。变量b在定义的同时指定了初值。变量c省略了数据类型。变量d省略了“var”关键字同时通过“:=”指定初值。通过“:=”定义变量并赋初值的语法不但可用于单个变量,多个变量,甚至不同类型的多个变量也同样适用,比如这里的e和f。最后关于g和h的实验,展示了一种在Go语言中交换两个相同类型变量值的方法。在其它语言中往往不得不借助第三变量达到同样的效果。


具有C/C++背景的开发者,对自增减运算一定感触颇深。尤其是当“++i”、“i++”、“--i”和“i--”出现在同一个表达式中的时候,而这个表达式又恰好出现在您的笔试试卷或者您正在维护的“屎山”之中。沮丧、焦虑、愤怒、怀疑人生······Go语言让这一切烟消云散。


在Go语言中只有后缀自增减,没有前缀自增减,一切类似“++i”或“--i”的写法都会被编译器无情地报错。而且Go语言的自增减是一条语句,而非表达式,讨论“i++”或“i--”的值是没有意义的。它们必须独占一行,不能出现在其它表达式或任何需要值的上下文中。它们的语义非常简单,只是令变量的值增加1或减少1,无它。


下面我们通过一个Inc工程体验一下自增减运算的用法:

变量a的初值为10,自增以后a的值变成11,自减以后a的值又恢复为10。++a、--a、将a++的值作为fmt.Println函数的参数、用a--的值作为变量b的初值,都会令编译器直接报告错误。


学过C/C++语言的开发者都知道,指针是C/C++语言的灵魂,但同时也是代码缺陷的万恶之源。Go语言继承了C/C++语言中关于指针的大部分语法特性,但同时也做了部分扬弃。惩恶扬善的正义之Go。


在C/C++语言中,指针就是变量的地址,指针型变量就是存放变量地址的变量。在这一点上,Go语言和C/C++语言没有区别,同样可以通过取地址运算符“&”获得一个变量的地址,通过解引用运算符“*”获得一个指针的目标。指针型变量也是变量,也有自己的地址,将一个指针型变量的地址存放在另一个指针型变量中是合法的,指向指针的指针即所谓高级指针。这些特性在Go语言中同样适用。但Go语言的指针之间只能通过“==”和“!=”运算验证其是否指向同一个变量,指针之间不能做其它关系运算和算数运算。判断一个指针大于或小于另一个指针,计算两个指针的差值,在Go语言中都是不允许的。


Go语言的指针也不能和整数做加减运算,甚至不能对指针型变量做自增减操作,通过这些操作在内存中移动指针在Go语言中是不允许的。Go语言支持内存的动态分配,但不支持释放,因为无需释放,垃圾回收机制会自动释放不再被使用的内存。在C/C++语言中从一个函数内部返回指向局部变量的指针是十分危险的,因为该指针的目标内存会在函数返回后作为函数栈的一部分,随函数栈一起被销毁,所返回的指针随即成为一个野指针,对野指针的访问将导致不可预期的后果,包括但不限于令进程崩溃。但同样的操作在Go语言中却是安全的,因为编译器会自动将该指针的目标内存放入堆区,在函数返回以后依然有效,这就是所谓的内存逃逸。


在C/C++语言中,野指针一向被视为内存灾难的根源。任何一个训练有素的C/C++程序员都会习惯性地用NULL表示一个什么都不指向的指针,即所谓空指针。NULL在C/C++语言中只是一个宏,其值就是整数0。在Go语言中同样也有空指针的概念,同样表示一个什么都不指向的指针,只是它的值用nil表示。nil并不是宏,在Go语言中也没有宏的概念。Go语言的nil是一个关键字,专门用来表示“空”语义,任何不与实体即有效内存,存在关联的变量,包括指针,都可以用nil表示其值。nil就是空,空就是什么都没有,色即是空,空即是色,与整数0没有任何关系。在C/C++语言中通过结构体变量和结构体指针访问结构体成员是有着严格区分的,前者使用“.”即直接成员访问运算符,而后者使用“->”即间接成员访问运算符。在Go语言中无论是通过结构体变量还是通过结构体指针,访问结构体成员一律使用“.”运算符。事实上,在Go语言中压根就没有“->”运算符。不过在Go语言中确实存在一个很相似的运算符“<-”,只是它与指针和成员访问没什么关系,它是一个专门用于通道读写的运算符,我们在后续课程中会为大家介绍。


下面我们创建一个Pointer工程,对前面讲过的指针语法做一个总结:

在这段代码中,a是一个普通的整型变量,初值为10。b是一个整型指针变量,保存整型变量a的地址,这时我们说b指向a。通过打印b和*b我们看到两者的不同,b代表指针变量本身的值,即a的地址,而*b代表指针变量目标的值,即a的值。通过指针可以修改其目标的值,*b=20是将20赋给了指针b的目标即a,因此我们看到a的值变成了20。c也是一个指针,它保存了指针变量b的地址,即指向指针的指针,也叫二级指针。在后面的打印输出中,我们看到c里面存放的是b的地址。*c表示c的目标,即b的值,也就是a的地址,而**c则表示c的目标的目标,也就是b的目标,即a的值。有关指针运算仅限于等于和不等于比较,其它象大于小于等关系运算,与整数的加减运算,以及对指针的自增减运算等都是非法的。通过new可以实现内存的动态分配,其参数为数据类型,返回值为内存地址,用指针接收,通过该指针可以象访问普通变量一样访问被动态分配的内存中的变量。动态分配的内存无需显式释放,该内存会在失去全部引用后被系统的垃圾回收机制自动释放。foo函数中的g是一个指向局部变量f的指针,该函数将其作为返回值返回给调用代码中的指针e,即e指向f。通过对e和*e的打印输出,我们看到e的目标f虽然是一个局部变量,但并没有随着foo函数返回而被销毁,这就叫内存逃逸。指针可以被置空,成为一个什么都不指向的指针,也可以通过“==”运算符判断一个指针是否为空,空用关键字nil表示。


与C/C++语言相比,Go语言被认为是更加安全的编程语言。它的安全性就体现在留给代码编写者犯错误的机会比较少。这一方面得益于编译器严格的语法检查和强大的自动纠错能力,另一方面则源于Go语言本身摒弃了许多容易导致错误和混淆的语法特性,即所谓语法禁忌。


比如在Go语言中彻底抛弃了为C/C++语言津津乐道的三目运算表达式“?:”,也不允许在条件分支和循环结构中省略花括号,即使被括起的只有一条语句。在Go语言中只有布尔型的值能够参与逻辑判断,非真即假,其它象整数、指针等都没有逻辑语义,不能出现在需要布尔值的上下文中。


字符串string在Go语言中是一种独立的基本数据类型,既不是字符数组比如C,也不是字符容器比如C++。


Go语言的字符串常量有两种表达方式。一种是用双引号引起的字面值,支持转义,也可以内嵌双引号,这一点和C/C++语言中的字符串字面值非常相似,但字符串的内容必须写在同一行中,如需分多行书写,可以借助加号拼接。另一种是用反引号引起的字面值,不支持转义,也不能内嵌反引号,但可以分多行书写。为了获得字符串的长度可以调用自由函数len。但要注意这里所说的字符串长度指的是字符串的字节数而非字符数,二者并不总是相等的,特别是对某些包含东方语言字符的字符串而言。


Go语言的字符串可以通过加号进行拼接,也可以通过全部关系运算符进行相等、不等和大小比较。字符串比较的基本规则,一个是大小写敏感,一个是字典排序,再一个是字符串中的字符个数。真正参与比较的是组成字符串的每个字符的编码。与C/C++语言不同,Go语言字符串中的字符是不能被修改的。令一个不带常限定的字符串变量的值发生变化的唯一方法就是为其赋予一个新的字符串。


下面我们创建一个名为String的项目,体验一下字符串的具体用法:

在这段代码中,我们首先验证了字符串常量的两种表达方式。对于单行不包含转义字符的字符串,双引号和反引号没有区别。而对于包含转义字符的字符串,比如“\n”,用双引号会将“\n”转义为换行符,一个字符,而用反引号则不做转义,“\n”就是“\n”,一个“\”和一个“n”,两个字符。当然用反引号表示的字符串也是可以包含换行符的,比如e,直接换行即可。后面我们又测试了双引号和反引号的内嵌。双引号字符串中可以包含双引号,一个“\"”会被转义为一个“"”。在双引号字符串中包含反引号,和在反引号字符串中包含双引号都是没有问题的,但在反引号字符串中包含反引号是不被允许的,比如i。在有关字符串长度的测试中,我们发现换行是一个特殊的字符,在实现转义的情况下,“\n”被视为一个换行符,因此c和e的长度都是11。唯有d的长度是13,因为这里的“\n”没有被转义,被视为两个字符。字符串j中包含了三个汉字字符“四五六”,它的长度并非大部分中国人所理解的3而是9,因此字符串j的长度就是15而非我们想当然的9。如果我们逐个字节地打印出字符串j的内容,会看到一共15个字节,用十六进制表示依次是31、32、33、e5、9b、9b、e4、ba、94、e5、85、ad、37、38、39。我们知道其中31、32、33、37、38、39分别是字符“1”、“2”、“3”、“7”、“8”、“9”的ASCII编码值。那么e5、9b、9b、e4、ba、94、e5、85、ad这9个字节又是什么呢?熟悉UTF-8编码的同学一定非常清楚,汉字字符“四”的UTF-8编码为三个字节,依次是e5、9b、9b,汉字字符“五”的UTF-8编码也是三个字节,依次为e4、ba、94,汉字字符“六”的UTF-8编码还是三个字节,依次为e5、85、ad。由此我们可以得出结论,Go语言字符串的本质就是构成字符串的每个字符的UTF-8编码的序列,而所谓字符串的长度就是该序列的字节数。有些字符,比如英文字母和阿拉伯数字,其UTF-8编码与ASCII编码相同,只占一个字节,而有些字符,比如中日韩文或其它特殊符号,其UTF-8编码可能不只一个字节,而是二、三、四甚至更多的字节。因此,字符串的长度与字符串的字符数并不总是相等的。后面我们又测试了通过加号拼接多个字符串和对字符串进行比较和判等的方法。最后我们验证了Go语言字符串的不可修改性,除非给字符串变量整体赋新值,当然带有常限定即只读约束的变量除外。


在实际的软件开发中,除了使用单个的数据以外,很多时候我们还需要操作成组的数据,即将多个数据作为一个整体进行读写、传输、运算或存储。这就是数组的概念。


在Go语言中定义数组变量与定义普通变量并没有本质性的区别。其数据类型由被方括号括起来的元素数和元素类型组成,初值由数组的数据类型和被花括号括起来的初值表组成。


在Go语言中遍历数组有两种方法。一种是经典的三表达式for循环。在第一表达式中定义并初始化一个索引变量,第二表达式判断索引变量的值是否小于数组长度,小于则执行循环体,否则退出循环结构,第三表达式对索引变量做自增。在循环体中通过索引和方括号运算符访问数组中的特定元素。这种遍历数组的方法与C/C++语言别无二致。另一种方法是Go语言独有的基于范围的for循环。这种for循环中只有一个表达式,定义并初始化索引和元素两个变量,其值在每次执行循环体前会被更新,更新它们的值来自range关键字,该关键字后面紧跟被遍历的数组名。range负责在每次执行循环体前从数组中次第提取元素,将其索引和值赋予对应的变量,并在循环体中被访问,直到没有元素可被提取了,退出循环。在这种循环结构中,如果只对索引或者元素中的一个感兴趣,甚至都不感兴趣,可将不感兴趣的变量用下划线表示,但中间的逗号不可省略。另外还要注意,循环中得到的只是数组元素的副本而非该元素本身。


下面我们创建一个Array工程,尝试一下数组的用法:

在这段代码中,我们首先尝试了几种定义并初始化数组的方法。注意未被显式初始化的数组元素将被缺省初始化为相应类型的零值。后面我们实验了两种遍历数组的方法,传统的三表达式for循环和基于范围的for循环。在传统的三表达式for循环中,通过索引获得的是数组元素本身,因此数组a最终变成了10、20、30、······。相对应的,基于范围的for循环只能得到数组元素的副本,因此被扩大10倍后的数组b依然是1、2、3、······。当然,即便是基于范围的for循环,也并不是就不能通过索引获得数组元素本身,因此数组b最终还是变成了10、20、30、······。同时我们也看到,在基于范围的for循环中,对于不感兴趣的索引或者元素,可以用下划线占位,既满足对接收变量个数的要求,也不会因为定义了未被使用的变量而导致编译错误。


数组虽然可被作为多个同类型数据的集合参与到某些整体性的操作过程中,但它的大小却是静态的,即在编码阶段就确定好的。如果代码的编写者无法预知一个数组在运行时最多可能包含的元素个数,他将如何定义数组呢?切片在这方面表现出更大的灵活性,甚至可以完全取代数组。


切片的定义和初始化语法与数组十分相似,唯一的差别就是类型中的方括号为空,而不象数组那样必须指定元素数。那么一个切片中究竟包含多少个元素呢?这完全是在运行时而非编译时确定的。切片也被视为动态数组。


append函数用于在切片中追加元素。追加元素的过程伴随着新内存的分配、原有元素的复制和新元素的追加。append函数以原切片和新元素为参数,同时返回一个新切片,该切片中包含新追加的元素。cap函数返回参数切片的容量,容量即最多可容纳元素的个数,它可能比切片中的实际元素数要大。适度的空间冗余可以减少内存分配的次数。


无论是数组还是切片都支持区间语法,语法的形式是在数组或切片后面的方括号中指定区间的起始和终止位置。区间表达式的值是一个切片,该切片与原数组或原切片共享数据元素,所有对区间切片的修改都会影响原数组或原切片。区间语法中的起始位和终止位都有默认值,默认的起始位在首元素之上,默认的终止位在尾元素之后。字符串在本质上是一个字节数组,其中的每个字节与构成字符串的每个字符的UTF-8编码相对应。对于字符串的内容既可以按字节遍历,也可以按字符遍历,但通过下标得到的只能是字符串中的字节而非字符。针对字符串的区间语法,其起止位置同样指的是字节而非字符。


make函数用于创建切片对象,创建切片时可以指定切片的数据类型、初始长度和容量。当然长度和容量并非一成不变,随着切片元素的增删,其长度和容量会随时发生变化。另外我们必须明确,切片中存放的并非元素本身,而是指向元素的指针,因此当将一个切片赋值给另一个切片时,被复制的只是这些指针,即所谓浅拷贝,两个切片共享同一套数据元素。如果想获得切片的完整副本,即让副本切片拥有一套独立的数据元素,可以借助于copy函数实现,这就是所谓的深拷贝。


下面我们通过一个Slice工程对上述切片特性做一个测试:

在这段代码中,我们首先尝试了几种定义并初始化切片的方法。通过append函数为切片追加元素,参数切片,比如d,在追加前后并不会发生变化,新追加的元素包含在函数返回的切片中,比如e。依次为切片f追加了十个元素,从打印的日志中不难发现,len函数返回的切片长度如实地反映了切片中元素的个数,但从cap函数得到的切片容量与长度并不总是一致的,2、4、8、16,很明显这里采用的是一种空间倍增策略。g是一个rune类型的切片。rune在Go语言中是一种表示字符而非字节的数据类型,一个rune类型的变量或元素可以存储任意字符,无论它的UTF-8编码占几个字节。h和i分别是g的区间切片和区间切片的区间切片。这里我们尝试使用了针对数组和切片的区间语法。在后面我们修改了切片i中的元素,结果发现切片h和切片g中的元素也随之发生了变化,这就验证了区间切片中的数据元素与原数组或原切片是共享的。区间语法中的起始和终止位置都可以使用默认值,甚至可以同时使用默认值,用这种方法我们可以用一个数组来初始化切片。字符串在本质上就是一个包含所有字符UTF-8编码的字节数组,当然也支持区间语法。用这种方法从一个大字符串中截取任意位置任意长度的子字符串会非常方便。make函数可以创建切片,但它并非只能创建切片,因此需要在其参数中指明所要创建切片的数据类型、长度,甚或容量。所创建切片中的元素被初始化为相应类型的零值。最后关于o、p、q的实验验证了深拷贝和浅拷贝的区别。经由赋值操作,使p成为o的浅拷贝,而copy函数则将o深拷贝给了q。其后对o中的元素做了修改,浅拷贝p也随之发生了变化,而深拷贝q则保持着修改前的状态。可见o和p共享同一套数据元素,而q则拥有属于自己的独立空间。


前面我们介绍了数组和切片,体现了一种将多个同类型数据组织为一个整体的思想。但很多时候我们可能需要在不同类型的数据之间建立某种联系。比如整数形式的员工编号与字符串形式的员工姓名,或者字符串形式的商品名称与浮点数形式的销售价格,等等。这种数据与数据之间的联系可以被抽象为一种对应关系,而在这种对应关系中最常见的就是一一对应,即一个数据唯一地关联于另一个数据,根据数据A可以唯一地找到与之对应的数据B。这里的数据A被称为键,而与之对应的数据B则被称为值。一组这样的对应关系被表示为一个键值对,多个这样的键值对所组成的集合就被称为映射。它表示的是一种从键唯一地映射到值的数据关系。


与C++语言标准模板库基于红黑树的映射不同,Go语言的映射是以哈希表的形式存储键值对集合的。每一个键就可以通过哈希算法计算出一个哈希值,用这个哈希值作为下标在数组中存放与该键对应的值。当需要根据键查找值时,只需再次计算键的哈希值,即可以之为下标从数组的特定元素中获取相应的值。相比于对数级时间复杂度的红黑树映射,常数级时间复杂度的哈希表映射查找速度会更快,当然为此付出的代价是需要占用更多的内存空间。在定义映射型变量时,需要使用map关键字并同时给出键和值的数据类型,其中键的类型要放在一对方括号中。与切片不同,调用make函数是获得映射实例的唯一方法。该函数的参数包括映射的类型、长度和容量。


在映射中增加键值对和修改键的值,其语法形式是一样的。在映射后面紧跟被一对方括号括起来的键即可获得对应值的引用,可对其进行赋值。如果键不存在则增加一个新的键值对,如果键已存在则以新值取代旧值。如果我们只是希望读取某个给定键的值,上述语法并不能用于判定该键是否存在。访问不存在的键并不会导致错误,相反会得到一个特定类型的零值。为了明确给定的键是否存在,可在取值的同时接收一个键存在标志,该标志为true表示键存在,否则键不存在。


与数组和切片不同,映射中没有下标或索引的概念。对映射的遍历只能借助基于范围的for循环。在其唯一的表达式中,通过range关键字依次提取映射中的每个键值对,并在循环体中使用之,直到没有键值对可被提取了,退出循环。当然对于不感兴趣的键或值,可以用下划线占位,以忽略之。从映射中删除键值对可以借助delete函数,第一个参数为映射,第二个参数为被删除键值对的键,即使该键并不存在,也不会导致异常。另外我们还需要明确,映射中存放的只是哈希表的指针,映射型变量的相互赋值都是浅拷贝,源映射和目标映射共享同一张哈希表,拥有同一套键值对集合。在并发场景下操作同一张哈希表,必须通过加锁予以保护。


在下面的Map工程中,我们来一起实践一下映射的基本用法:

在这段代码中,我们首先尝试了几种定义并初始化映射的方法、通过make函数创建映射实例的方法,以及借助方括号语法为映射增加键值对的方法。后面我们试图获取一个并不存在的键“黄忠”的值,程序并没有报错,相反我们得到了一个0。但我们无法确定是因为不存在“黄忠”这个键而得到0呢?还是真的有这么一个键,其值就是0?为此,我们在接收值的同时也接收一个键存在标志,放在变量ok中。对于不存在的键,比如“黄忠”,该标志为false,而对于存在的键,比如“张飞”,该标志则为true。接着我们尝试了通过基于范围的for循环遍历映射的方法。对于数组和切片,range返回的是索引和元素,而对于映射,range返回的却是键和值。我们尝试删除一个存在的键值对,其键为“赵云”,删除成功,而当我们试图删除一个不存在的键值对,其键为“黄忠”时,删除并没有报错,但也并没有什么效果,相当于一个空操作。借助赋值语法,我们得到了一个映射d的浅拷贝副本映射g。为了验证g和d共享同一张哈希表,我们通过g修改了键“张飞”的值,又通过d增加了一个由“马超”和60组成的键值对。通过后面的打印输出,我们发现,对g的修改影响了d,同样对d的修改也影响了g,g和d拥有的是同一套键值对集合,修改谁都会影响另一个。


与C/C++语言类似,Go语言最基本的执行单元是函数。任何可执行语句都必须位于某个函数的内部,编译器绝对不会容忍游离于所有函数之外的可执行语句。


函数必须以func关键字开头,其后依次是函数名、形参表、返回值类型表和被一对花括号括起的函数体。形参表由被一对圆括号括起的多个形参组成,每个形参包括参数名和参数类型,参数名要写在参数类型前面,多个形参之间以逗号分隔,类型相同的相邻形参可以合并书写。与C/C++语言不同,Go语言的函数可以有多个返回值。多个返回值的类型在返回值类型表中列出,以逗号分隔,被圆括号包裹,可以为返回值指定名字,类型相同的相邻返回值可以合并书写。


在Go语言中,函数也是一种数据类型,可以定义函数类型的变量、数组、函数的参数及返回值。从函数中返回局部变量的指针是安全的,编译器会自动将其目标内存放入堆区。通过以下编译命令:

可以看到编译器对内存逃逸的处理细节。


这里我们创建一个名为Func的工程,体验一下Go语言函数的用法:

在这段代码中,我们除了每个程序都必须有的main函数以外,又定义了f1~f6六个函数,并在main函数中以不同方式进行调用。f1是一个典型的带参具返回值函数,三个形式参数分别为整型的x,布尔型的y和字符串型的z,返回两个值,类型分别为64位浮点型和字符型。该函数首先打印了传入的三个参数x、y和z,然后返回了与返回类型对应匹配的两个返回值,浮点数3.1415926和字符π。在main函数的开始部分调用了f1函数,将整型的10、布尔型的true和字符串型的aaa三个实际参数分别传递给该函数的三个形式参数x、y和z。实参与形参的数据类型严格一致。与此同时,用a和b两个变量接收该函数的返回值并打印输出。对于返回多个值的函数,我们可能只对其部分返回值感兴趣,对于不想接收的返回值可以用下划线占位,表示忽略。比如同样是对f1函数的调用,我们只接收了它的第二个返回值,字符π,而将第一个返回值用下划线忽略掉。函数也可以没有返回值,比如f2,它的两个形参x和y位置相邻且类型相同,这里采用了类型合并的写法。f3与f2貌似一样实则迥异,f3是一个不带参且返回三个值的函数。需要强调的是该函数的三个返回值都带有名字,并采用了类型合并的写法。请注意该函数对返回值的处理。f4接收一个函数类型的参数f1,而f5则返回一个函数类型的值f1。函数类型由形参表和返回值类型表决定。具有相同形参表和返回值类型表的函数属于同一种类型。函数类型的数据本身也是函数,因此无论是f4函数的参数,还是f5函数的返回值,都可以被当作函数一样正常调用。f6函数有意返回了一个指向局部变量的指针,配合特定的编译选项,验证了编译器对内存逃逸的特殊处理。


与C/C++语言不同,Go语言没有头文件、目标模块和库的概念,所有的程序代码都位于扩展名为“.go”的文件中,谓之Go文件。有时候多个Go文件中的代码在逻辑上具有密切的相关性,我们更希望用所谓模块的概念加以抽象。Go语言中的模块是用包来表示的。一个包可以包含一到多个Go文件。不同包中的代码要想互相引用就需要导入,导入了哪个包就可以访问该包中可被访问的部分。


Go语言包的物理形式就是一个位于GOPATH/src目录下包含一到多个Go文件的子目录,该目录也可以包含更多的子目录。这些子目录以及子目录的子目录就构成了完整的包路径。隶属于某个包的Go文件一方面必须位于该包的包路径之下,另一方面应该在文件的最开始部分通过package关键字显式地指定包名。为了访问另一个包中的代码,首先需要在文件的开始部分通过import关键字紧跟包路径的形式导入该包。此后通过类似“包名.函数名”的形式访问该包中的代码。可以在import关键字和包路径之间为被导入的包指定别名,一旦指定了别名,后面所有对该包的访问都要使用此别名。可以用“.”作为包的别名,后续对该包的访问可以省略包名前缀,就象访问本包中的代码一样。


在Go语言中创建包,必须遵循特定的规则。首先,一个包路径下只能有一个包。其次,包路径中的子目录名不一定非要与包名严格一致,但应存在某种常人可以理解的关联性。再次,从包中导出的函数,即可被位于其它包中的代码调用的函数,其函数名的首字母必须大写。最后,所有函数名以小写字母开头的函数都只能在本包,但不一定是本文件中被调用。


在下面的Import工程中,我们将创建一个包,再在main包中导入该包并调用包中的函数。首先我们在工程目录下创建一个包目录,名为Math,在该目录下创建两个Go文件,add.go和sub.go,隶属于math包,各包含一个函数Add和Sub,分别完成对两个整数的加法和减法计算:

而后在main包中的main.go中导入math包,调用其中的Add和Sub函数:

main.go的另一种写法:

这里为包路径Import/Math下的包指定了别名m,并通过该别名调用其中的函数。main.go还可以写成这样:

别名为“.”的包可以在调用其中的函数时省略包名前缀。注意,为了让包中的函数可被包以外的代码调用,即所谓导出函数,该函数的函数名首字母必须为大写,比如这里的Add和Sub。main函数作为进程的入口函数,本质上也是被包外代码调用的,属于导出函数,但它的函数名必须为main,无需将首字母大写。这恐怕是Go语言中唯一一个函数名以小写字母开头的导出函数。


熟悉C/C++语言的开发者对main函数的两个参数一定有着深刻的印象,它们往往被习惯性地命名为argc和argv,用于接收用户从命令行输入的参数。Go语言同样为命令行参数的处理提供了一套解决方案。


与C/C++语言不同,Go语言的main函数既没有参数也没有返回值。任何从命令行传入的信息,都包含在由os包导出的字符串切片型全局变量Args中。该切片中的元素顺序与命令行参数的输入顺序严格一致。


在下面的Args工程中,我们遍历并打印出用户从命令行输入的每一个参数:

我们之前多数都是调用某个包中的导出函数,比如fmt包中Println函数。事实上包除了可以导出函数以外,也可以导出变量,比如os包中的Args。该变量是一个切片,其中的每个元素都是字符串,每个字符串保存一个命令行参数。注意,这里所说的命令行参数,也包括用于启动可执行程序的硬链接名。这与C/C++程序的情况是一样的。


在C/C++语言中,基于switch-case结构的开关分支语句通常被作为条件分支语句if-else if-else结构的有力补充,广泛应用于条件分支相对较多的场合。Go语言同样提供了开关分支语句,但它的用法远比其C/C++版本要灵活得多,适用场景也更加广泛。


与C/C++语言不同,Go语言开关分支语句中的匹配对象不仅限于整型数据,任何支持相等性判断的数据类型都可以作为开关匹配的依据。Go语言开关分支语句的每个分支默认带有break语义,无需显式写明,如果一定需要向下穿透,可以使用fallthrough关键字。


下面的Switch工程演示了开关分支语句的基本用法:

在这段代码中包含一个基于switch-case结构的开关分支语句。根据用户在命令行输入的参数打印不同的编程语言。用户输入“a”或“A”打印“Go”,用户输入“b”或“B”打印“C++”,用户输入“c”或“C”打印“Python”,用户输入其它字符,打印“Invalid option”。如果用户输入的命令行参数不够,则打印正确的命令行用法。分支匹配的依据是命令行参数中的字符串。为了让小写字符与大写字符匹配相同的处理逻辑,这里使用了fallthrough关键字。在每个分支的处理之后不再通过break语句跳出分支结构。


具有C/C++背景的开发者一定对goto语句心有余悸。goto语句曾被认为是不良代码和反结构化设计的标志。不可否认,goto语句的滥用的确令程序的控制流难以跟踪,使程序代码难以理解和维护。它所带来的困难远多于它所解决的问题。但另一方面,过度妖魔化goto语句可能令我们陷入另一个极端。在被严格限定的可控范围内,合理且谨慎地使用goto语句往往能让我们获得事半功倍的效果。Go是一种不走极端且追求效率的语言,面向goto语句的标签语法不但被保留了下来,而且还可以和continue及break语句联用,在特定的场合发挥着重要的作用。


在goto语句中使用标签,可以令控制流跳转到指定的位置。在continue语句中使用标签,可以跳过本轮循环,继续指定的循环,而在break语句中使用标签,则可以直接跳出指定的循环。这在包含多重循环的场景下,显得格外有价值。


为了比较在goto、continue和break语句中标签的使用,我们创建了名为Label的工程:

程序伊始便毫无悬念地首先打印了“A”,但在本该打印“B”之前遇到了goto语句,于是流程跳转到标签lb1所在的位置,并从那里继续执行其后的语句,打印“C”。因此在最后的输出中,我们只能看到“A”和“C”,“B”是看不到的,因为打印“B”的操作被goto语句跳过了。这就叫无条件跳转,即在没有任何附加条件的情况下,完全按照标签的指示,跳转到指定的位置并继续执行。当然这里所说的“无条件”是指goto语句本身不包含任何为执行跳转而预先设定的条件,但并不排除人为为goto语句的执行设定条件的可能。比如在下面的二重循环中,外层循环循环变量i的值变成3,即是执行goto语句的条件。事实上这段代码的执行永远不会终止。因为goto语句的存在,i的值一旦累加到3即重新进入循环,外层循环的退出条件i>4永远不可能满足。下面我们把二重循环中的goto换成continue,看看程序的运行会有什么不同:

continue语句的作用是结束本轮循环并进入下一轮循环。下一轮循环默认情况下仅限于continue语句所在的循环,而在其后使用标签则意在强调下一轮循环究竟是哪个循环。比如这里的continue语句,在结束本轮内层循环的同时直接进入下一轮外层循环,而这正是由标签lb2指定的。break语句的情况与此类似,不带标签的break语句只能跳出该语句所在的循环,加上标签则可人为指定跳出哪个循环:

内层循环中的break语句,在i值为3时直接跳出了外层循环,标签lb2的作用恰在于此。这比C/C++语言中的break语句只能一层循环一层循环地逐层跳出确实便捷了许多。


与C/C++语言不同,Go语言并没有提供枚举类型,也没有提供专门针对枚举的关键字,但我们可以借助一些其它手段,间接获得类似枚举的效果。


在Go语言中无论变量还是常量,除了单个定义以外也可以成组定义。语法形式是在var或const关键字后面紧跟一对圆括号,圆括号中列出每个变量或常量的名字、类型和用等号指示的初值。其中按组定义的常量,如果与iota关键字结合,就可以获得类似枚举的效果。


iota本是希腊字母表中的第九个字母“I/ι”,表示极微小量。在Go语言中它被作为一个关键字,表示一个常量组计数器。该计数器从0开始,逐行递增。缺省不写,取上一行的值加一。同一行的多个iota值都相同。不同组的多个iota彼此独立。可以用下划线跳过某些值,也可令iota参与算数运算,已获得不连续效果。


在下面的Iota工程中,我们一起体验一下Go语言关于“枚举”的近似替代方案:

在这段代码中,我们先后按组定义了三个变量和七个常量。这七个常量分别表示一星期中的七天,星期日、星期一、星期二、······,一直到星期六。在代码中通过名字引用常量,如“SUN”、“MON”等,肯定比直接使用字面值常量,如“0”、“1”等,具有更好的可读性。这也正是枚举存在的意义。枚举中的常量通常都是类似这样连续的整数。每一个常量都显式地用字面值初始化的确略显繁琐。这时iota关键字的价值就凸显出来了:

从这段代码不难看出,无论是变量组还是常量组,在有初值的情况下,数据类型都可省略,这与定义单个变量或常量的情况是一样的。在为常量组中的常量指定初值时,这里没有显式使用字面值常量,而是使用了计数器关键字iota。它的初值是0,每一行比上一行增加1,比如SUN和MON。即使iota被省略不写也是如此,比如TUE和SAT。位于同一行中的多个iota数值相同,比如WED和WEDNESDAY。这种情况下的iota同样可以省略不写,比如THU和THURSDAY。借助iota定义枚举的更实用的写法应该是这样的:

这里我们定义了表示一年里十二个月的枚举JAN~DEC,其值为0~11。也许有人认为它们要是能和人类的自然认知保持一致,取值1~12会更好些,为此:

这里用下划线占位有意忽略掉了0,下面的常量取值从1开始。或者直接使用算数表达式也可以:

下划线和算数表达式在很多对枚举值有非连续要求的场景下用起来会非常方便。


前面介绍过的数组和切片是对相同类型多个数据的封装。被映射封装的多个数据元素,可以包含不同类型的键和值,但每个键值对的类型仍然必须是相同的。那么如果我们所要封装的数据集合中包含多个类型可能完全不同的元素又当如何呢?针对这个问题,结构体为我们提供了近乎完美的解决之道。它甚至可被视为面向对象编程,即所谓OOP的基石。


在Go语言中可以通过type关键字为一个已定义的数据类型定义别名。它的形式是在type关键字的后面依次列出类型的别名与原名。经过别名定义的数据类型,别名与原名完全等价。别名即原名,原名即别名。基于type关键字的类型别名语法对于结构体的定义是不可或缺的。这里的类型别名就是结构体名,而类型原名则是由struct关键字引导的,被一对花括号括起来的,由一到多个成员名及成员类型组成的成员列表。结构体变量的初始化可以直接使用花括号语法。每个初始化项由成员名、冒号和成员初值组成。多个初始化项之间以逗号分隔。全初始化成员名可以省略,部分初始化成员名不可省略。如果结尾花括号单独占一行,最后一个初始化项必须以逗号结尾。与C/C++语言不同,Go语言不区分直接成员访问和间接成员访问。无论是通过结构体变量还是通过结构体指针,访问结构体成员一律使用“.”运算符。Go语言没有“->”运算符。


下面我们通过一个Struct工程体验一下结构体的基本用法:

在这段代码中我们首先尝试了通过type关键字为string类型定义了一个别名str,而后用该别名定义了一个变量a,该变量的本质就是一个字符串。同样利用type关键字,我们又定义了一个结构体类型Student代表学生,该结构体包含四个成员,分别是字符串型的name表示学生的姓名、整型的age表示学生的年龄、字符串型的gender表示学生的性别、浮点型的score表示学生的学分。用Student结构体类型定义了一个变量b,同时在花括号中为它的每个成员指定初值,这里省略了成员名,初值与成员之间按顺序对应。在后面打印结构体成员的过程中使用了“.”运算符。另一个Student结构体类型的变量c,在它的初始化语法中使用了成员名,这时顺序已经不再重要。右花括号独占一行,因此最后一个初始化项以逗号结尾。没有被显式指定初值的成员,默认被初始化为相应类型的零值。结构体指针类型的变量d是一个指向结构体变量c的指针。通过d访问其目标结构体的成员,与通过*d或c一样,都使用“.”运算符。


一个包中封装了与特定功能密切相关的一组数据和函数,在这些数据和函数被外界访问之前,往往还有一些准备工作需要提前完成。为此Go语言提供了所谓init函数,专门用于包的初始化操作。


每个包都可以包含0~N个init函数。这些init函数既不带参数也没有返回值。init函数中可以包含任何代码,但通常只完成与初始化包有关的操作。init函数由代码编写者定义,但不由代码编写者的代码调用。它是在其所在包被import关键字导入的过程中由系统自动调用的。如果一个包中包含多个init函数,其被调用的顺序是不确定的。我们不能假定谁一定先于谁执行,因此不要在多个init函数中编写存在依赖关系的代码。有时我们只是希望某个包中的init函数被执行,但并不打算访问该包中的任何代码。如果直接通过import关键字导入该包将导致编译错误,为此可在包路径前加一个下划线。


在下面的Init工程中,我们将创建一个包,再在main包中导入该包并调用包中的函数。首先我们在工程目录下创建一个包目录,名为Math,在该目录下创建两个Go文件,add.go和sub.go,隶属于math包,各包含一个函数Add和Sub,分别完成对两个整数的加法和减法计算,同时各定义两个init函数,打印调试日志以验证其是否被调用:

而后在main包的main.go中导入math包,调用其中的Add和Sub函数:

从这段代码的运行日志可以清楚地看到,定义在math包中的四个init函数无一例外地都被调用了。一个包可以包含多个init函数,它们可以位于同一个Go文件中,也可以位于不同的Go文件中。作为代码编写者的我们只负责定义init函数,不负责调用init函数。在包含init函数的包被导入时,系统会自动调用其中的init函数。任何试图手动调用init函数的代码,例如:

都将引发编译错误:

如果我们并不想在main函数中调用math包中的方法:

这里是不能导入math包的。如果强行导入会导致编译失败。这样一来,math包中的init函数也就不会被执行。但假设我们导入的其它包需要在math包被成功初始化的前提下使用其中的数据或方法。这就要求我们既不使用math包又要导入math包,以保证其中的init函数被执行。为此可在包路径前加一个下划线:

这样math包就能在不被使用的前提下被导入,包中的init函数被执行,包被正确地初始化。


在一个函数中,往往有一些操作需要在函数返回前被执行,如关闭文件、断开数据库连接等。无论函数是最终成功了还是中途失败了,这些操作在函数返回前总是要被执行的。为此我们的代码可能不得不写成这样:

毫无疑问,这样的代码不仅丑陋臃肿,而且难以维护。每增加一个可能出现的失败返回,就要把“返回前的操作”再写一遍。“返回前的操作”一旦需要修改,所有涉及该操作的代码都要修改。Go语言引入了defer语句,专门针对这个问题提供了绝佳的解决方案。


defer是一个关键字,被defer修饰的语句并不在执行defer语句时执行,而是在defer语句所在函数的栈被销毁前执行。一个函数中可以包含多个defer语句,越靠后的越先执行。被defer修饰的语句通常都与资源清理等善后事宜有关。一个defer关键字只能修饰一条语句。如果需要在一个defer语句中包含由多条语句组成的复杂操作,可以将这些操作封装成一个匿名函数。被defer关键字修饰的单条语句即是对该匿名函数的调用。


在下面的Defer工程中,我们将亲手实践一下defer语句的用法,并观察它的语法特性:

代码运行伊始,首先打印出来的是A、B、C,1、2、3并不在此时打印,它们要在main函数即将返回时才被打印,且打印顺序与出现顺序相反,是3、2、1。这正是defer关键字所带来的效果。foo函数展示了defer语句的常规用法。这里首先打开了一个文件,而只要文件打开成功,无论后面发生什么,在foo函数返回前总要关闭该文件。因此这里使用了defer语句。语句中包含对匿名函数的调用,匿名函数打印日志并关闭之前打开的文件。注意此时该匿名函数并不会执行,它要等到foo函数即将返回时才会执行。此后又读取并打印了文件的内容,失败返回-1,成功返回0,而无论成功还是失败,函数返回前都会执行先前defer语句中的匿名函数,完成关闭文件的操作。


谢谢大家,我们下节课再见!