第1章 5个例子
04-13Ctrl+D 收藏本站
本章总共有5个比较小的示例程序。这些示例程序概览了Go语言的一些关键特性和核心包(在其他语言里也叫模块或者库,在Go语言里叫做包(package),这些官方提供的包统称为Go语言标准库),从而让读者对学习Go语言编程有一个初步的认识。如果有些语法或者专业术语没法立即理解,不用担心,本章所有提到的知识点在后面的章节中都有详细的描述。
要使用Go语言写出Go味道的程序需要一定的时间和实践。如果你想将C、C++、Java、Python以及其他语言实现的程序移植到Go语言,花些时间学习Go语言特别是面向对象和并发编程的知识将会让你事半功倍。而如果你想使用 Go语言来从头创建新的应用,那就更要好好掌握 Go语言提供的功能了,所以说前期投入足够的学习时间非常重要,前期付出的越多,后期节省的时间也将越多。
1.1 开始
为了尽可能获得最佳的运行性能,Go语言被设计成一门静态编译型的语言,而不是动态解释型的。Go语言的编译速度非常块,明显要快过其他同类的语言,比如C和C++。
Go语言的官方编译器被称为gc,包括编译工具5g、6g和8g,链接工具5l、6l和8l,以及文档查看工具godoc(在Windows下分别是5g.exe、6l.exe等)。这些古怪的命名习惯源自于Plan 9操作系统,例如用数字来表示处理器的架构(5代表ARM,6代表包括Intel 64位处理器在内的AMD64架构,而8则代表Intel 386)。幸好,我们不必担心如何挑选这些工具,因为Go语言提供了名字为go的高级构建工具,会帮我们处理编译和链接的事情。
Go语言官方文档
Go语言的官方网站是golang.org,包含了最新的Go语言文档。其中Packages链接对 Go 标准库里的包做了详细的介绍,还提供了所有包的源码,在文档不足的情况下是非常有用的。Commands 页面介绍了 Go语言的命令行程序,包括 Go 编译器和构建工具等。Specification链接主要非正式、全面地描述了Go语言的语法规格。最后,Effective Go链接包含了大量Go语言的最佳实践。
Go语言官网还特地为读者准备了一个沙盒,你可以在这个沙盒中在线编写、编译以及运行Go小程序(有一些功能限制)。这个沙盒对于初学者而言非常有用,可以用来熟悉Go语法的某些特殊之处,甚至可以用来学习fmt包中复杂的文本格式化功能或者regexp包中的正则表达式引擎等。官网的搜索功能只搜索官方文档。如果需要更多其他的Go语言资源,你可以访问go-lang.cat-v.org/go-search。
读者也可以在本地直接查看Go语言官方文档。要在本地查看,读者需要运行godoc工具,运行时需要提供一个参数以使godoc运行为Web服务器。下面演示了如何在一个Unix终端(xterm、gnome-terminal、onsole、Terminal.app或者类似的程序)中运行
$ godoc -http=:8000
或者在Windows的终端中(也就是命令提示符或MS-DOS的命令窗口):
C:\>godoc -http=:8000
其中端口号可任意指定,只要不跟已经运行的服务器端口号冲突就行。假设 godoc 命令的执行路径已经包含在你的PATH环境变量中。
运行 godoc 后,你只需用浏览器打开 http://localhost:8000 即可在本地查看 Go语言官方文档。你会发现本地的文档看起来跟golang.org的首页非常相似。Packages链接会显示Go语言的官方标准库和所有安装在GOROOT下的第三方包的文档。如果GOPATH变量已经定义(指向某些本地程序和包的路径),Packages 链接旁边会出现另一个链接。你可以通过这个链接访问相应的文档(环境变量GOROOT和GOPATH将在本章后面小节和第9章中讨论)。
读者也可以在终端中使用godoc命令来查看整个包或者包中某个特定功能的文档。例如,在终端中执行godoc image NewRGBA命令将会输出关于函数image.NewRGBA的文档。执行godoc image/png命令会输出关于整个image/png包的文档。
本书中的所有示例(可以从www.qtrac.eu/gobook.html获得)已经在Linux、Mac OS X和Windows平台上用Go 1中的gc编译器测试通过。Go语言的开发团队会让所有后续的Go 1.x版本都向后兼容Go 1,因此本书所述文字及示例都适用于整个1.x系列的Go。(如果发生不兼容的情况,我们也会及时更新书中的示例以与最新的Go语言发布版兼容。因此,随着时间的推移,网站上的示例程序可能跟本书中所展示的代码不完全相同。)
要下载和安装Go,请访问golang.org/doc/install.html,那里有安装指南和下载链接。在撰写本书时,Go 1已经发布了适用于FreeBSD 7+、Linux 2.6+、Mac OS X(Snow Leopard和Lion)以及Windows 2000+平台的源代码和二进制版本,并且同时支持这些平台的Intel 32位和AMD 64位处理器架构。另外Go 1还在Linux平台上支持ARM架构。预编译的Go安装包已经包含在Ubuntu Linux的发行版中,而在你阅读本书时可能更多的其他Linux发行版也包含Go安装包。如果只为了学习Go语言编程,从Go安装包安装要比从头编译和安装Go环境简单得多。
用gc构建的程序使用一种特定的调用约定。这意味着用gc构建的程序只能链接到使用相同调用约定的外部包,除非出现合适的桥接工具。Go语言支持在程序中以 cgo 工具(golang.org/cmd/cgo)的形式调用外部的C语言代码。而且目前至少在Linux和BSD系统中已经可以通过SWIG工具 (www.swig.org)在Go程序中调用C和C++语言的代码。
除了gc之外还有一个名为gccgo的Go编译器。这是一个针对Go语言的gcc(GNU编译工具集)前端工具。4.6以上版本的gcc都包含这个工具。像gc一样,gccgo也已经在部分Linux发行版中预装。编译和安装gccgo的指南请查看这个网址:golang.org/doc/gccgo_install.html。
1.2 编辑、编译和运行
Go程序使用UTF-8编码[1]的纯Unicode文本编写。大部分现代编辑器都能够自动处理编码,并且某些最流行的编辑器还支持Go语言的语法高亮和自动缩进。如果你用的编辑器不支持Go语言,可以在Go语言官网的搜索框中输入编辑器的名字,看看是否有合适的插件可用。为了编辑方便,所有的Go语言关键字和操作符都使用ASCII编码字符,但是Go语言中的标识符可以是任一Unicode编码字符后跟若干Unicode字符或数字,这样Go语言开发者可以在代码中自由地使用他们的母语。
Go语言版Shebang脚本
因为Go的编译速度非常快,Go程序可以作为类Unix系统上的shebang #! 脚本使用。我们需要安装一个合适的工具来实现脚本效果。在撰写本书的时候已经有两个能提供所需功能的工具:gonow(github.com/kison/gonow)和gorun(wiki.ubuntu.com/gorun)
在安装完gonow或者gorun后,我们就可以通过简单的两个步骤将任意Go程序当做shebang脚本使用。首先,将#!/usr/bin/env gonow或者#!/usr/bin/env gorun添加到包含main函数(在main包里)的.go文件开始处。然后,将文件设置成可执行(如用chmod +x命令)。这些文件只能够用gonow或者 gorun来编译,而不能用普通的编译方式来编译,因为文件中的#!在Go语言中是非法的。
当gonow或者gorun首次执行一个.go文件时,它会编译该文件(当然,非常快),然后运行。在随后的使用过程中,只有当这个.go文件自上次编译后又被修改过后才会被再次编译这使得用Go语言来快速而方便地创建各种实用工具成为可能,比如创建系统管理任务。
为了感受一下如何编辑、编译和运行Go程序,我将从经典的“Hello World”程序开始(虽然我们会将其设计得稍微复杂些)。我们首先讨论编译与运行,然后在下一节中详细解读文件hello/hello.go中的源代码,因为它包含了一些Go语言的基本思想和特性。
我们可以从www.qtrac.eu/gobook.html得到本书中的所有源码,源代码包解压后将是一个goeg文件夹。所以如果我们在$HOME文件夹下解压缩,源文件hello.go的路径将会是$HOME/goeg/src/hello/hello.go。如无特别说明,我们在提到程序的源文件路径时将默认忽略$HOME/goeg/src 部分,比如在这个例子里 hello 程序的源文件路径被描述为hello/hello.go(当然,Windows用户必须将“/”替换成“\”,同时使用它们自己解压的路径,如C:\goeg或者%HOME-PATH%\goeg等)。
如果你直接从预编译Go安装包安装,或从源码编译并以root或Administrator的身份安装,那么你的系统中应该至少有一个环境变量GOROOT,它包含了Go安装目录的路径,同时你系统中的环境变量PATH现在应该已经包含$GOROOT/bin或%GOROOT%\bin。要查看Go是否安装正确,在终端(xterm、gnome-terminal、konsole、Terminal.app或者类似的工具)里键入以下命令即可:
$ go version
或者在Windows系统的MS-DOS命令提示符窗口里键入:
C:\>go version
如果返回的是“command not found”或者“‘go’is not recognized...”这样的错误信息,意味着Go不在环境变量PATH中。如果你用的是类Unix系统(包括Mac OS X),有一个很简单的解决办法,就是将该环境变量加入.bashrc(或者其他shell程序的类似文件)中。例如,作者的.bashrc文件包含这几行:
export GOROOT=$HOME/opt/go
export PATH=$PATH:$GOROOT/bin
通常情况下,你必须调整这些值来匹配你自己的系统(当然这只有在 go version 命令返回失败时才需要这样做)。
如果你用的是Windows系统,可以写一个批处理文件来设置Go语言的环境变量,每次打开命令提示符窗口执行Go命令时先运行这个批处理文件即可。不过最好还是在控制面板里设置Go语言的环境变量,一劳永逸。步骤如下,依次点击“开始菜单”(那个Windows图标)、“控制面板”、“系统和安全”、“系统”、“高级系统设置”,在系统属性对话框中点击“环境变量”按钮,然后点击“新建...”按钮,在其中加入一个以GOROOT命名的变量以及一个适当的值,如C:\Go。在相同的对话框中,编辑PATH环境变量,并在尾部加入文字;C:\Go\bin——文字开头的分号至关重要!在以上两者中,用你系统上实际安装的Go 路径来替代 C:\Go,如果你实际安装的Go 路径不是C:\Go的话。(再次声明,只有在go version命令返回失败时才需要这样做。)
现在我们假设Go在你机器上安装正确,并且Go bin目录包含PATH中所有的Go构建工具。(为了让新设置生效,可能有必要重新打开一个终端或命令行窗口。)
构建Go程序,有两步是必须的:编译和链接。[2]所有这两步都由go构建工具处理。go构建工具不仅可以构建本地程序和本地包,并且可以抓取、构建和安装第三方程序和第三方包。
让 go的构建工具能够构建本地程序和本地包需满足三个条件。首先,Go的bin 目录($GOROOT/bin或者 %GOROOT%\bin)必须在环境变量中。其次,必须有一个包含src目录的目录树,其中包含了本地程序和本地包的源代码。例如,本书的示例代码被解压到goeg/src/hello和goeg/src/bigdigits等目录。最后,src目录的上一级目录必须在环境变量GOPATH中。例如,为了使用go的构建工具构建本书的hello示例程序,我们必须这样做:
$ export GOPATH=$HOME/goeg
$ cd $GOPATH/src/hello
$ go build
相应地,在Windows上也可以这样做:
C:\>set GOPATH=C:\goeg
C:\>cd %gopath%\src\hello
C:\goeg\src\hello>go build
以上两种情况都假设PATH环境变量中已经包含$GOROOT/bin或者%GOROOT%\bin。在go构建工具构建好了程序后,我们就可以尝试运行它。可执行文件的默认文件名跟它所位于的目录名称一致(例如,在类Unix系统中是hello,在Windows系统中是hello.exe),一旦构建完成,我们就可以运行这个程序了。
$./hello
Hello World!
或者
$./hello Go Programmers!
Hello Go Programmers!
在Windows上也类似:
C:\goeg\src\hello>hello Windows Go Programmers!
Hello Windows Go Programmers!
我们用加粗代码字体的形式显示需要你在终端输入的文字,并以罗马字体的形式显示终端的输出。我们也假设命令提示符是$,但其实是什么都没关系(如Windows下的C:\>)。
有一点可以注意到的是,我们无需编译或者显式链接任何其他的包(即使我们将看到hello.go使用了3个标准库中的包)。这是为什么Go程序构建得如此快的原因。
如果我们有好几个 Go 程序,如果它们的可执行程序都可以保存在同一个目录下,由于我们可以一次性将这个目录加入到PATH中,这将会非常的方便。幸运的是,go构建工具可以用以下方式来支持这样的特性:
$ export GOPATH=$HOME/goeg
$ cd $GOPATH/src/hello
$ go install
同样地,我们可以在Windows上这样做:
C:\>set GOPATH=C:\goeg
C:\>cd %gopath%\src\hello
C:\goeg\src\hello>go install
go install 命令跟 go build 所做的工作是一样的,唯一不同的是,它将可执行文件放入一个标准路径中($GOPATH/bin或者 %GOPATH%\bin)。这意味着,只需在PATH中加上一个统一路径($GOPATH/bin 或者 %GOPATH%\bin),我们所安装的所有 Go 程序都会包含在PATH中从而可以在任一路径下直接运行。
除了本书中的示例程序之外,我们可能会想在自己的一个目录下开发自己的Go程序和包。要达到这个目的,我们可以将 GOPATH 环境变量设置成两个或者多个以冒号分隔的路径(在Windows中是以分号分隔)。例如,export GOPATH=$HOME/app/go:$HOME/goeg或者SET GOPATH=C:\app\go;C:\goeg。[3]在这个情况下我们必须将所有的程序和包的源代码都放入$HOME/app/go/src或者C:\app\go\src中。因此,如果我们开发了一个叫myapp的程序,它的.go源文件将位于$HOME/app/go/src/myapp或者C:\app\go\src\myapp。如果我们使用go install在一个GOPATH路径下构建程序,而且GOPATH环境变量包含了两个或者更多个路径,那么可执行文件将被放入相对应源代码目录的bin文件夹中。
通常,每次构建Go程序时export或者设置GOPATH环境变量可能很费劲,因此最好是永久性地设置好这个环境变量。前面我们已经提到过,类Unix系统可修改.bashrc文件(或类似的文件)以设置GOPATH环境变量(参见本书示例中的gopath.sh文件),Windows上可通过编写一个批处理文件(参见本书示例中的gopath.bat文件)或添加GOPATH到系统的环境变量:依次点击“开始菜单”(那个Windows图标)、“控制面板”、“系统和安全”、“系统”、“高级系统设置”,在系统属性对话框中点击“环境变量”按钮,然后点击“新建...”按钮,在其中加入一个以GOPATH命名的变量以及一个适当的值,如C:\goeg或C:\app\go;C:\goeg。
虽然Go语言的推荐构建工具是go命令行工具,我们完全可以使用make或者其他现代构建工具,或者使用别的针对Go语言的构建工具,或者给流行集成开发环境如Eclipse和Visual Studio安装合适的插件来进行Go工程的构建。
1.3 Hello Who?
现在我们已经知道怎么编译一个 hello 程序,让我们看看它的代码。不要担心细节,本章所提及的一切(以及更多的内容)在后面的章节中都有详细描述。下面是完整的hello程序(在文件hello/hello.go中):
// hello.go
package main
import (①
"fmt"
"os"
"strings"
)
func main {
who := "World!" ②
if len(os.Args) > 1 { /* os.Args[0]是"hello"或者"hello.exe" */ ③
who = strings.Join(os.Args[1:], " ") ④
}
fmt.Println("Hello", who) ⑤
}
Go语言使用 C++风格的注释://表示单行注释,到行尾结束,/…/ 表示多行注释。Go语言中的惯例是使用单行注释,而多行注释则往往用于在开发过程中注释掉若干行代码。[4]
所有的Go语言代码都只能放置于一个包中,每一个Go程序都必须包含一个main包以及一个 main函数。main函数作为整个程序的入口,在程序运行时最先被执行。实际上,Go语言中的包还可能包含init函数,它先于main函数被执行,我们将在1.7节了解到,关于init函数的完全介绍在5.6.2节。需要注意的是,包名和函数名之间不会发生命名冲突情况。
Go语言针对的处理单元是包而非文件,这意味着我们可以将包拆分成任意数量的文件。在Go编译器看来,如果所有这些文件的包声明都是一样的,那么它们就同样属于一个包,这跟把所有内容放在一个单一的文件里是一样的。通常,我们也可以根据应用程序的功能将其拆分成尽可能多的包,以保持一切模块化,我们将在第9章看到相关内容。
代码中的import语句(标注为①的地方)导入了3个标准库中的包。fmt包提供来格式化文本和读入格式文本的函数(参见 3.5 节),os 包提供了跨平台的操作系统层面变量及函数,而strings包则提供了处理字符串的函数(参见3.6.1节)。
Go语言的基本类型支持常用的操作符(如+操作符可用于数字加法运算和字符串连接运算),同时Go语言的标准库也提供了拥有各种功能的包来对这些操作进行补充,如这里引入的strings包。你也可以基于这些基本类型创建自己的类型或者为这些类型添加自定义方法(我们将在1.5节提及,并在第6章详细阐述)。
读者可能也已经注意到程序中没有分号,那些 import 语句也不用逗号分隔,if 语句的条件也不用圆括号括起来。在Go语言中,包含函数体以及控制结构体(例如if语句和for循环语句)在内的代码块均使用花括号作为边界符。使用代码缩进仅仅是为了提高代码可读性。从技术层面讲,Go语言的语句是以分号分隔的,但这些是由编译器自动添加的,我们不用手动输入,除非我们需要在同一行中写入多个语句。没有分号及只需要少量的逗号和圆括号,使得Go语言的程序更容易阅读,并且可以大幅降低编写代码时的键盘敲击次数。
Go语言的函数和方法以关键字func定义。但main包里的main函数比较特别,它既没有参数,也没有返回值。当main.main运行完毕,程序会自动终止并向操作系统返回0。通常我们可以随时选择退出程序,并返回一个自己选择的返回值,这点我们随后将详细讲解(参见1.4节)。
main函数中的第一行(标注②)使用了 := 操作符,在Go语言中叫做快速变量声明。这条语句同时声明并初始化了一个变量,也就是说我们不必声明一个具体类型的变量,因为Go语言可以从其初始化值中推导出其类型。所以这里我们相当于声明了一个string类型的变量who,而且由于go是强类型的语言,也就只能将string类型的值赋值给who。
就像大多数语言使用if语句检测一个条件是否成立一样,在这个例子里if语句用来判断命令行中是否输入了一个字符串,如果条件成立就执行相应大括号中的代码块。我们将在本章末尾(参见1.6节)及后面的章节(参见5.2.1节)中看到一些更加复杂的if语句。
代码中的os.Args变量是一个string类型的切片(标注③)。数组、切片和其他容器类型将在第4章中详细阐述(参见4.2节)。现在我们只需要知道可以使用语言内置的len函数来获得切片的长度即可,而切片的元素则可以通过索引操作来获得,其语法是一个 Python 语法子集。具体而言,slice[n]返回切片的第n个元素(从0开始计数),而slice[n:]则返回另一个包含从第n个元素到最后一个元素的切片。在数据集合那一章节,我们将会看到Go语言在这方面的详细语法。对于os.Args,这个切片总是至少包含一个string(程序本身的名字),其在切片中的位置索引为0(Go语言中的所有索引都是从0开始的)。
只要用户输入一个或多个命令行参数,if 语句的条件就成立了,我们将从命令行输入的所有参数连接成一个字符串并赋值给 who 变量(标注④)。在这里我们使用赋值操作符(=),因为如果我们使用快速声明操作符(:=)的话,只能得到另一个生命周期仅限于当前 if代码块的新局部变量who。strings.Join函数的输入参数为以一个string类型的切片和一个分隔符(可以是一个空字符,如"")作为输入,返回一个由分隔符将切片中的所有字符串连接在一起的新字符串。在这个示例里我们用空格作为连接符来连接所有输入的字符串参数。
最后,在最后一个语句(标注⑤)中,我们打印Hello和一个空格,以及who变量中的字符串,并添加一个换行符。fmt 包提供了许多不同的打印函数变体,比如像 fmt.Println会整洁地打印任何输入的内容,而像 fmt.Printf 则使用占位符来提供良好的格式化输出控制能力。打印函数将在第3章(参见3.5节)详细阐述。
本节的hello 程序展示了很多超出这类程序一般所做事情之外的语言特性。接下来的示例也会这样做,在保持程序尽量简短的情况下尽量覆盖更多的高级特性。这样做的主要目的是,通过熟悉简单的语言基础,让读者在构建、运行和体验简单的Go程序的同时体验一下Go语言的强大与独特。当然,本章提及的所有内容都将在后面章节中更详细地阐述。
1.4 大数字——二维切片
示例程序bigdigits(源文件是bigdigits/bigdigits.go)从命令行接收一个数字(作为一个字符串输入),然后用大数字的格式将这个数字输出到命令行窗口。回溯到20世纪,在一些多个用户共用一台高速行式打印机的地方,通常都会习惯性地为每个用户的打印任务添加一个封面页以显示该用户的一些标识信息,比如他们的用户名和打印的文件名等。那时候采取的就是类似于这个例子中演示的大数字技术。
我们将分3部分了解这个示例程序:首先介绍import部分,然后是静态数据,再之后是程序处理过程。为了让大家对整个过程有个大致的印象,我们先来看看程序的运行结果,如下:
$./bigdigits 290175493
222 9999 000 1 77777 55555 4 9999 333
2 2 9 9 0 0 11 7 5 44 9 9 3 3
2 9 9 0 0 1 7 5 4 4 9 9 3
2 9999 0 0 1 7 555 4 4 9999 33
2 9 0 0 1 7 5 444444 9 3
2 9 0 0 1 7 5 5 4 9 3 3
22222 9 000 111 7 555 4 9 333
从这个例子可以看出,每个数字都由一个字符串类型的切片来表示,所有的数字可以用一个二维的字符串类型切片来表示。在查看数据之前,我们先来了解如何声明和初始化一维的字符串类型以及数字类型的切片。
longWeekend := string{"Friday", "Saturday", "Sunday", "Monday"}
var lowPrimes = int{2, 3, 5, 7, 11, 13, 17, 19}
切片的表达方式为Type,如果我们希望同时完成初始化的话,可以在后面直接跟一个花括号,括号内是一个对应类型的元素列表,并在元素之间用逗号分隔。本来对于这两个切片我们可以用同样的变量声明语法,但我们刻意地对 LowPrimes 切片的声明采用了相对较长的声明方式。采取这个方式的原因我们很快会给出说明。因为一个切片的类型本身可以是另一个切片,所以我们可以很容易地创建多维的集合(例如元素类型为切片的切片等)。
bigdigits程序只需要引入四个包:
import (
"fmt"
"log"
"os"
"path/filepath"
)
fmt包提供了格式化文本和读取格式化文本的相关函数(参见3.5节)。log包提供了日志功能。os 包提供的是平台无关的操作系统级别变量和函数,包括用于保存命令行参数的类型为string的os.Args变量(即字符串类型的切片)。而path包中的filepath子包则提供了一系列可跨平台的对文件名和路径操作的函数。需要注意的是,对于位于其他包内的子包,在我们的代码中用到时只需要指定其包名称的最后一部分即可(对于此例而言就是filepath)。
对于bigdigits程序而言,我们需要二维数据(字符串类型的二维切片)。下面我们示范一下如何创建这样的数据,通过将数字0排列好以展示数字对应的字符串如何对应到输出里的行,不过省略了数字3到8的对应字符串。
var bigDigits = string{
{" 000 ",
" 0 0 ",
"0 0",
"0 0",
"0 0",
" 0 0 ",
" 000 "},
{" 1 ", "11 ", " 1 ", " 1 ", " 1 ", " 1 ", "111"},
{" 222 ", "2 2", " 2 ", " 2 ", " 2 ", "2 ", "22222"},
//...3至8...
{" 9999", "9 9", "9 9", " 9999", " 9", " 9", " 9"},
}
虽然在函数和方法之外声明的变量不能使用 := 操作符,但我们可以通过使用关键字var和赋值运算符 =的长声明方式来达到同样的效果,例如本例中我们为 bigDigits 变量所做的。其实之前我们在声明 lowPrimes 变量时已经使用过了。不过我们仍然不需要指定bigDigits的数据类型,因为Go语言能够从赋值动作中推导出相应的类型信息。
我们把计数工作丢给了Go编译器,因此不需要明确指定切片的维度。Go语言的众多便利之一就是支持像大括号这样的复合文面量语法,因此我们不必在一个地方声明这个变量,又在别的地方将相应的值赋值给它,当然,这么做也是可以的。
main函数总共只有20行代码,从命令行读取输入然后生成输出结果。
func main {
if len(os.Args) == 1 { ①
fmt.Printf("usage: %s <whole-number>\n", filepath.Base(os.Args[0]))
os.Exit(1)
}
stringOfDigits := os.Args[1]
for row := range bigDigits[0] { ②
line := ""
for column := range stringOfDigits { ③
digit := stringOfDigits[column] - '0' ④
if 0 <= digit && digit <= 9 { ⑤
line += bigDigits[digit][row] + " " ⑥
} else {
log.Fatal("invalid whole number")
}
}
fmt.Println(line)
}
}
程序先检查启动时是否带有命令行参数。如果没有,则len(os.Args)的值为1(回忆一下,os.Args[0]存放的是程序名字,因此这个切片的长度通常至少为1),然后if条件成立,调用 fmt.Printf函数打印一条用法信息,fmt.Printf接收%占位符,类似于 C/C++中printf函数的支持方式,以及Python的%操作符(更详细的用法可参见3.5节)。
path/filepath包提供了路径操作函数。比如,filepath.Base函数会返回传入路径的基础名(其实就是文件名)。输出消息后,程序通过调用os.Exit函数退出,返回1给操作系统。在类Unix系统中,程序返回0表示成功,非零值表示用法问题或执行失败。
filepath.Base函数的用法演示了 Go语言的一个很酷的功能:在导入一个包时,无论这是一个顶级包还是属于其他包(如path/filepath),我们只需要使用包名里的最后一部分来引用它(如filepath)。而且我们还可以在引入包时给这个包分配一个别名以避免名字冲突。本书第9章会详细介绍相关的用法。
假如用户传入了至少一个命令行参数,我们会将第一个命令行参数复制到stringOfDigits字符串变量中。为了能够将用户输入的数字转换为大数字,我们需要遍历 bigDigits 切片中的每一行,也就是说,先生成每个数字的第一行,然后再生成第二行,等等。我们假设所有的bigDigits 切片都包含了同行的行数,因此我们直接使用了第一个切片的行数。Go语言的for 循环有若干种不同的语法以满足不同的需求;本例标注②和③的地方我们使用了for...range循环来返回切片中每个元素的索引位置。
行列循环部分的代码可以用如下方式实现:
for row := 0; row < len(bigDigits[0]); row++ {
line := ""
for column := 0; column < len(stringOfDigits); column++ {
...
这是C、C++、Java程序员所熟悉的方式,当然Go语言也支持[5]。但是for...range语法可以实现得更短且更方便(我会在5.3节中讨论Go语言中for循环的各种详细用法)。
在每次遍历行之前我们会将行的line变量设置为一个空字符串。然后我们再遍历从用户那里接受到的stringOfDigits字符串中的每一列(其实就是字符)。Go语言中的字符串采用的是UTF-8编码,因此一个字符有可能占用两个或者更多字节。不过这在本例中并不是个问题,因为我们只需要考虑如何处理0到9的数字,而这些数字在UTF-8中都是用一个字节表示。它们的表示方法与7位的ASCII标准完全一致。(之后在第3章中我们将学习如何一个字符一个字符地遍历一个字符串,无论其中的字符是单字节还是多字节。)
当我们按索引位置查询一个字符串的内容时,我们将得到索引位置对应的一个byte类型的值(在Go语言中,byte类型等同于uint8类型)。所以,我们可以对命令行传入的参数按索引位置取相应的byte类型值,然后将该值和数字0对应的byte类型值相减,以得知对应的数字。在UTF-8和ASCII中,字符‘0’对应的是48,字符‘1’对应的是49,以此类推。因此,假如我们得到的是一个字符‘3’(对应数值为51),那么我们可以通过运算‘3’-‘0’(也就是51-48)来获取相应的整型值,也就是一个byte类型的整型数,值为3。
Go语言采用单引号来表达字符,而一个字符其实就是一个与Go语言所有其他整型类型兼容的整型数。Go语言的强类型特征意味着我们不能在不做强制类型转换的前提下将一个int32类型和一个int16类型直接相加,但Go语言的数值类型常量适应到它们的上下文,因此在这个上下文里,‘0’将会被当做是一个byte类型。
假如对应的数字在范围之内,我们可以添加合适的字符串到该行中(在if语句中常量0和9被认为是byte类型,因为digit的类型就是byte,但如果digit是其他的一个类型,比如是int,那么它们也自然会被认为是相应的类型)。虽然Go语言的字符串是不可变的,但 += 这种语法在Go语言里也是支持的,主要是易于使用,实质上是暗地里将原字符串替换掉了,另外 + 连接运算符也是支持的,返回一个将两个字符串连接起来的新字符串(第3章将对字符串进行详细描述)。
为了获得对应的字符串,我们先访问对应于数字的bigDigits切片中的相应行。
如果数字超过了范围(比如包含了非数字的字符),我们调用log.Fatal函数记录一条错误信息,包括日期、时间和错误信息,如果没有显式指定记录到哪里,那么默认是打印到os.Stderr,并调用os.Exit(1)终止程序的执行。另外还有一个log.FatalF函数可以接受%格式的占位符。在第一个if语句里我们没有使用log.Fatal函数,因为我们只需要输出程序的帮助信息,而不需要日期和时间这些通常log.Fatal函数的输出会包含的信息。
当每个数字对应行的字符串准备就绪后,这一行将被打印。在这个例子里,总共有7行被打印,因为每个bigDigits字符串切片中的数字都用七个字符串来表示。
最后一点,通常情况下声明和定义的顺序并不会带来影响。因此在 bigdigits/bigdigits.go文件中,我们可以在main函数前后声明bigDigits变量。在这个例子里,我们将main函数放在前面,因为本书所有的例子我们都趋向于用自上而下的方式来组织内容。
这两个例子中我们已经接触到不少东西,但也仅仅是介绍了 Go语言与其他主流语言类似的一些功能,除了语法上略有区别外。接下来的3个例子将把我们带离舒适地带,开始展示Go语言的一些特有功能,比如特有的Go语言类型,文件处理(包括错误处理)和以值方式传递函数,以及使用goroutine和通道(channel)进行并行编程等。
1.5 栈——自定义类型及其方法
虽然Go语言支持面向对象编程,但它既没有类也没有继承(is-a关系)这样的概念。但是Go语言支持创建自定义类型,而且很容易创建聚合(has-a关系)结构。Go语言也支持将其数据和行为完全分离,同时也支持鸭子类型。鸭子类型是一种强有力的抽象机制,它意味着数据的值(比如传入函数的数据)可以根据该数据提供的方法来被处理,而不管其实际的类型。这个术语是从这条语句演化而来的:“如果它走起来像鸭子,叫起来像鸭子,它就是一只鸭子。”所有这些一起,提供了一种游离于类和继承之外的更加灵活强大的选择。但如果要从 Go语言的面向对象特性中获益,习惯于传统方法的我们必须在概念上做一些重大调整。
Go语言使用内置的基础类型如 bool、int和string 等类型来表示数据,或者使用struct来对基本类型进行聚合。[6]Go语言的自定义类型建立在基本类型、struct或者其他自定义类型之上。(我们会在本章后面看到一些简单的例子,参见1.7节。)
Go语言同时支持命名和匿名的自定义类型。相同结构的匿名类型等价,可以相互替换,但是不能有任何方法(这点我们会在6.4节详细阐述)。任何命名的自定义类型都可以有方法,并且这些方法一起构成该类型的接口。命名的自定义类型即使结构完全相同,也不能相互替换(除特别声明之外,本书所指的“自定义类型”都是指命名的自定义类型)。
接口也是一种类型,可以通过指定一组方法的方式定义。接口是抽象的,因此不可以实例化。如果某个具体类型实现了某个接口所有的方法,那么这个类型就被认为实现了该接口。也就是说,这个具体类型的值既可以当做该接口类型的值来使用,也可以当做该具体类型的值来使用。然而,不需要在接口和实现该接口的具体类型之间建立形式上的联接。一个自定义的类型只要实现了某个接口定义的所有方法就是实现了该接口。当然,一个类型可以实现多个接口,只要这个类型同时实现多个接口所定义的所有方法。
空接口(没有定义方法的接口)用interfae{}来表示。[7]由于空接口没有做任何要求(因为它不需要任何方法),它可以用来表示任意值(效果上相当于一个指向任意类型值的指针),无论这个值是一个内置类型的值还是一个自定义类型的值(Go语言的指针和引用将在4.1节介绍)。顺便提一句,在Go语言中我们只讲类型和值,而非类和对象或者实例(因为Go语言没有类的概念)。
函数和方法的参数类型可以是任意内置类型或者自定义类型,甚至是接口。后一种情况表示,一个函数可能接收这样一个参数,例如“传入一个可以读取数据的值”,而不管该值的实际类型是什么(我们马上会在实践中看到这个,参见1.6节)。
第6章详细阐述了这些,并提供了许多例子来保证读者理解这些想法。现在,就让我们来看一个非常简单的自定义栈类型如何被创建和使用,然后看看该自定义类型是如何实现的。
我们从程序的运行结果分析开始:
$./stacker
81.52
[pin clip needle]
-15
hay
上述结果中的每一项都从该自定义栈中弹出,并各自在单独一行中打印出来。
这个程序的源码是stacker/stacker.go。这里是该程序的包导入语句:
import (
"fmt"
"stacker/stack"
)
fmt包是Go语言标准库的一部分,而stack包则是为我们的stacker程序特意创建的一个本地包。一个Go语言程序或者包的导入语句会首先搜索GOPATH定义的路径,然后再搜索GOROOT所定义的路径。在这个例子中,程序的源代码位于$HOME/goeg/src/stacker/stacker.go中,而 stack 包则位于$HOME/goeg/src/stacker/stack/stack.go 中。只要 GOPATH 是$HOME/goeg或包含了$HOME/goeg这个路径,go构建工具就会将stack和stacker都构建好。
包导入的路径使用Unix风格的“/”来声明,就算在Windows平台上也是这样。每一个本地包都需要保存在一个与包名同名的目录下。本地包可以包含它们自己的子包(如path/filepath),其形式与标准库完全相同(创建和使用自定义包的内容将在第9章中详细阐述)。
下面是打印出输出结果的简单测试程序的main函数:
func main {
var haystack stack.Stack
haystack.Push("hay")
haystack.Push(-15)
haystack.Push(string{"pin", "clip", "needle"})
haystack.Push(81.52)
for {
item, err := haystack.Pop
if err != nil {
break
}
fmt.Println(item)
}
}
函数的开头声明了一个stack.Stack类型的变量haystack。在Go语言中,导入包中的类型、函数、变量以及其他项的惯例是使用pkg.item这样的语法。其中,pkg是包名中的最后一部分(或唯一一项)。这样有助于避免名字冲突。然后,我们往栈中压入一些元素,并将其逐一弹出后再输出,直至栈被清空。
使用自定义栈的一个奇妙之处在于可以自由地将异构(类型不同)的元素混合存储,而不仅仅是存储同构(类型相同)的元素。虽然Go语言是强类型的,但是我们可以通过空接口来实现这一点。我们这个例子里的stack.Stack类型就是这么做的,无需关心它们的实际类型是什么。当然,在实际使用中,这些元素的实际类型我们还是要知道的。不过,在这里我们只使用到了fmt.Println函数,它可以使用Go语言的类型检视功能(在reflect包中)来获得它要打印的元素的类型信息(反射将在后面的9.4.9节中讲到)。
这段代码展示的另一个Go语言的美妙特性就是不带条件的for循环。这是一个无限循环,因此大部分情况下,我们需要提供一种方法来跳出循环,比如这里使用的break语句或者一个return语句。我们会在下一个例子中看到另一种for循环语法(参见1.6节)。for循环的完整语法将在第5章叙述。
Go语言的函数和方法均可返回单一值或者多个值。Go语言中报告错误的惯例是函数或者方法的最后一个返回值是一个错误值(其类型为error)。我们的自定义类型stack.Stack也遵从这样的惯例。
既然我们知道自定义类型stack.Stack是怎么使用的,就让我们再来看看它的具体实现(源码在文件staker/stack/stack.go中)。
package stack
import "errors"
type Stack interface{}
按照惯例,该文件开始处声明其包名,然后导入需要使用的包,在这里只有一个包,即errors。
在 Go语言中定义一个命名的自定义类型时,我们所做的是将一个标识符(类型名称)绑定在一个新类型上,这个新类型与已有的(内置的或者自定义的)类型有相同的底层表示。但Go语言又会认为这两个底层表示有所区别。在这里,Stack类型只是一个空接口类型切片(也就是一个可变长数组的引用)的别名,但它与普通的interface{}类型又有所区别。
由于Go语言的所有类型都实现了空接口,因此任意类型的值都可以存储在Stack中。
内置的数据集合类型(映射和切片)、通信通道(可缓冲)和字符串等都可以使用内置的len函数来获取其长度(或者缓冲大小)。类似地,切片和通道也可以使用内置的cap函数来获取容量(它可能比其使用的长度大)。(Go语言的所有内置函数都以交叉引用的形式列在表5-1中,切片在第4章有详细阐述,参见4.2节。)通常所有的自定义数据集合类型(包括我们自己实现的以及Go语言标准库中的自定义数据集合类型)都应实现Len和Cap方法。
由于 Stack 类型使用切片作为其底层表示,因此我们应为其实现 Stack.Len和Stack.Cap方法。
func (stack Stack) Len int {
return len(stack)
}
函数和方法都使用关键字func定义。但是,定义方法的时候,方法所作用的值的类型需写在 func关键字之后和方法名之前,并用圆括号包围起来。函数或方法名之后,则是小括号包围起来的参数列表(可能为空),每个参数使用逗号分隔(每个参数以variableName type这种形式声明)。参数后面,则是该函数的左大括号(如果它没有返回值的话),或者是一个单一的返回值(例如,Stack.Len方法中的int返回值),也可以是一对圆括号包围起来的返回值列表,后面再紧跟着一个左大括号。
大部分情况下,会为调用该方法的值命名,例如这里我们使用 stack 命名(并且与其包名并不冲突)。调用该方法的值在Go语言中以术语“接收器”来称呼[8]。
本例中,接收器的类型是Stack,因此接收器是按值传递的。这也意味着任何对该接收器的改变都只是作用于其原始值的一份副本,因此会丢失。这对于不需要修改接收器的方法来说是没问题的,例如本例中的Stack.Len方法。
Stack.Cap方法基本上和Stack.Len一样(所以这里没有给出)。唯一的不同是,Stack.Cap方法返回的是栈的cap而非len的值。源代码中还包含一个Stack.IsEmpty方法,但它也跟Stack.Len方法极为相似,只是返回一个bool值以表示栈的len是否等于0,因此也就不再列出。
func (stack *Stack) Push(x interface{}) {
*stack = append(*stack, x)
}
Stack.Push方法在一个指向Stack的指针上被调用(稍后解释),并且接收一个任意类型的值作为参数。内置的append函数可以将一个或多个值追加到一个切片里去,并返回一个切片(可能是新建的),该切片包含原始切片的内容和在尾部追加进去的内容。
如果之前有数据从该栈弹出过,则底层的切片容量可能比切片的实际长度大,因此压栈操作会非常的廉价:只需简单地将x这项保存在len(stack)这个位置,并将栈的长度加1。
Stack.Push函数永远有效(除非计算机的内存耗尽),因此我们没必要返回一个 error值来表示成功或者失败。
如果我们要修改接收器,就必须将接收器设为一个指针。[9]指针是指一个保存了另一个值的内存地址的变量。使用指针的原因之一是为了效率,比如我们有一个很大的值,传入一个指向该值所在内存地址的指针会比传入该值本身更廉价得多。指针的另外一个用处是使一个值可被修改。例如,当一个变量传入到一个函数中,该函数只得到该值的一份副本(例如,传stack给stack.Len函数)。这意味着我们对该值所做的任何改动,对于原始值来说都是无效的。如果我们想修改原始值(就像这里一样我们想往栈中压入数据),我们必须传入一个指向原始值的指针,这样在函数内部我们就可以修改指针所指向的值了。
指针通过在类型名字前面添加一个星号来声明(即星号*)。因此,在Stack.Push方法中,变量stack的类型为 *Stack,也就是说变量stack保存了一个指向Stack类型值的指针,而非一个实际的Stack类型值。我们可以通过解引用操作来获取该指针所指向值的实际Stack值,解引用操作只是简单意味着我们在试图获得该指针所指处的值。解引用操作通过在变量前面加上一个星号来完成。因此,我们写stack时,是指一个指向Stack的指针(也就是一个 *Stack)。写*stack时,是指解引用该指针变量,也就是引用该指针所指之处的实际Stack类型值。
此外星号处于不同的位置所表达的含义也不尽相同。在两个数字或者变量之间时表示乘法,例如x*y,这一点Go和C、C++等是一样的。在类型名称前面时表示指针,例如 *MyType。在变量名称之前时表示解引用,例如 *Z。不过不要太担心这些,我们在第4章中将详细阐述Go语言指针的用法。
需要注意的是,Go语言中的通道(channel)、映射(map)和切片(slice)等数据结构必须通过make函数创建,而且make函数返回的是该类型的一个引用。引用的行为和指针非常类似,当把它们传入函数的时候,函数内对该引用所做的任何改变都会作用到该引用所指向的原始数据。然而,引用不需要被解引用,因此大部分情况下不需要将其与星号一起使用。但是,如果我们要在一个函数或者方法内部使用 append修改一个切片(不同于仅仅修改其中的一个元素内容),必须要么传入指向这个切片的一个指针,要么就返回该切片(也就是将原始切片设置为该函数或者方法返回的值),因为有时候append返回的切片引用与之前所传入的不同。
Stack 类型使用一个切片来表示,因此 Stack 类型的值也可以在操作切片的函数如append和len中使用。然而,Stack类型的值仅仅是该类型的值,与其底层表示的类型值不一样,因此如果我们需要修改它就必须传入指针。
func(stack Stack) Top (interface{}, error) {
if len(stack) == 0 {
return nil, errors.New("can't Top en empty stack")
}
return stack[len(stack)-1], nil
}
Stack.Top方法返回栈中最顶层的元素(最后被添加进去的元素)和一个error类型的错误值,栈不为空时这个错误值为nil,否则不为nil。这个名为stack的接收器之所以被按值传递,是因为栈没有被修改。
error是一个接口类型(参见6.3节),其中包含了一个方法Error string。通常, Go语言的库函数的最后一个返回值为error类型,表示成功(error的值为nil)或者失败。这段代码里我们通过使用errors包中的errors.New函数将Stack类型设计成与标准库中的类型一样工作。
Go语言使用nil来表示空指针(以及空引用),即表示指向为空的指针或者引用值为空的引用。[10]这种指针只在条件判断或者赋值的时候用到,而不应该调用nil值的成员方法。
Go语言中的构造函数从来不会被显式调用。相反地,Go语言会保证当一个值创建时,它会被初始化成相应的空值。例如,数字默认被初始化成0,字符串默认被初始化成空字符串,指针默认被初始化成nil值,而结构体中的各个字段也被初始化成相应的空值。因此,在Go语言中不存在未初始化的数据,这减少了很多在其他语言中导致出错的麻烦。如果默认初始化的空值不合适,我们可以自己写一个创建函数然后显式地调用它,就像在这里创建一个新的error 值一样。也可以防止调用者不通过创建函数而直接构造某个类型的值,我们在第6章将详细阐述如何做到这一点。
如果栈不为空,我们返回其最顶端的值和一个nil错误值。由于Go语言中的索引从0开始,因此切片或者数组的第一个元素的位置为0,最后一个元素的位置为len(sliceOrArray) - 1。
在函数或者方法中返回一个或多个返回值时无需拘泥于形式,只需在所定义函数的函数名后列上返回值类型,并在函数体中保证至少有一个return语句能够返回相应的所有返回值即可。
func (stack *Stack) Pop (interface{}, error) {
theStack := *stack
if len(theStack) == 0 {
return nil, errors.New("Can't pop an empty stack")
}
x := theStack[len(theStack) - 1] ①
*stack = theStack[:len(theStack) - 1] ②
return x, nil
}
Stack.Pop方法用于删除并返回栈中最顶端(最新添加)的元素。像Stack.Top方法一样,它返回该元素和一个nil错误值,或者如果栈为空则返回一个nil元素和一个非nil错误值。
由于该方法需要通过删除元素来修改栈,因此它的接收器必须是一个指针类型的值。为了方便,我们在方法内不使用 *stack(stack变量实际所指向的栈)这样的语法,而是将其赋值给一个临时变量(theStack),然后在代码中使用该临时变量。这样做的性能开销非常小,因为 *stack指向的是一个Stack值,该值使用一个切片来表示,因此这样做的性能开销仅仅比直接使用一个指向切片的引用稍微大一点。
如果栈为空,我们返回一个合适的错误值。否则,我们将该栈最顶端的值保存在一个临时变量x中,然后对原始栈(本身是一个切片)做一次切片操作(新的切片只是少了一个元素),并将切片后的新栈赋值给 stack 指针所指向的原始栈。最后,我们返回弹出的值和一个 nil错误值。Go编译器会重用这个切片,仅仅将其长度减1,并保持其容量不变,而非真地将所有数据拷到另一个新的切片中。
返回的元素通过使用索引操作符和一个索引来得到(标识①)。本例中,该元素索引就是切片最后一个元素的索引。
新的切片通过使用切片操作符和一个索引范围来获得(标识②)。索引范围的形式是first:end。如果first值像这个示例中一样被省略,则其默认值为0,而如果end值被省略,则其默认值为该切片的len值。新获得的切片包含原切片中从第first个元素到第end个元素之间的所有元素,其中包含第first个元素而不包含第end个元素。因此,在本例中,通过将其最后一个元素设置为其原切片的长度减1,我们获得了原切片中除最后一个元素外的所有元素组成的切片,快速有效地删除了切片中的最后一个元素(切片索引将在第4章详细阐述,参见4.2.1节)。
对于本例中那些无需修改 Stack的方法,我们将接收器的类型设置为 Stack 而非指针(即*Stack类型)。对于其底层表示较为轻量(比如只包含少量int类型和string类型的成员)的自定义类型来说,这是非常合理的。但是对于比较复杂的自定义类型,无论该方法是否需要修改值内容,我们最好一直都使用指针类型的接收器,因为传递一个指针的开销远比传递一个大块的值低得多。
关于指针和方法,有个小细节需要注意的是,如果我们在某个值类型上调用其方法,而该方法所需要的又是一个指针参数,那么Go语言会很智能地将该值的地址(假设该值是可寻址的,参见6.2.1节)传递给该方法,而非该值的一份副本。相应地,如果我们在某个值的指针上调用方法,而该方法所需要的是一个值,Go语言也会很智能地将该指针解引用,并将该指针所指的值传递给方法。[11]
正如本例所示,在 Go语言中创建自定义类型通常非常简单明了,无需引入其他语言中的各种笨重的形式。Go语言的面向对象特性将在第6章中详细阐述。
1.6 americanise示例——文件、映射和闭包
为了满足实际需求,一门编程语言必须提供某些方式来读写外部数据。在前面的小节中,我们概览了Go语言标准库里fmt包中强大的打印函数,本节中我们将介绍Go语言中基本的文件处理功能。接下来我们还会介绍一些更高级的Go语言特性,比如将函数或者方法当做第一类值(first-class value)来对待,这样就可以将它们当做参数传递。另外,我们还将用到Go语言的映射(map,也称为数据字典或者散列)类型。
本节尽可能详尽地讲述如何编写一个文本文件读写程序,使得示例和相应的练习都更加生动有趣。第8章将会更详尽地讲述Go语言中的文件处理工具。
大约在20世纪中期,美式英语超越英式英语成为最广泛使用的英语形式。本小节中的示例程序将读取一个文本文件,将文本文件中的英式拼写法替换成相应的美式拼写法(当然,该程序对于语义分析和惯用语分析无能为力),然后将修改结果写入到一个新的文本文件中。这个示例程序的源代码位于americanise/americanise.go中。我们采用自上而下的方式来分析这段程序,先讲解导入包,然后是main函数,再到main函数里面所调用的函数,等等。
import (
"bufio"
"fmt"
"io"
"io/ioutil"
"log"
"os"
"path/filepath"
"regexp"
"strigns"
)
该示例程序所引用的都是 Go 标准库里的包。每个包都可以有任意个子包,就如上面程序中所看到的io包中的ioutil包以及path包中的filepath包一样。
bufio包提供了带缓冲的I/O处理功能,包括从UTF-8编码的文本文件中读写字符串的能力。io包提供了底层的I/O功能,其中包含了我们的americanise程序中所用到的io.Reader和io.Writer接口。io/ioutil包提供了一系列高级文件处理函数。regexp包则提供了强大的正则表达式支持。其他的包(fmt、log、filepath和strings)已在本书之前介绍过。
func main {
inFilename, outFilename, err := filenamesFromCommandLine①
if err != nil {
fmt.Println(err) ②
os.Exit(1)
}
inFile, outFile := os.Stdin, os.Stdout③
if inFilename != "" {
if inFile, err = os.Open(inFilename); err != nil {
log.Faal(err)
}
defer inFile.Close④
}
if outFilename != "" {
if outFile, err = os.Create(outFilename); err != nil {
log.Fatal(err)
}
defer outFile.Close⑤
}
if err = americanize(inFile, outFile); err != nil {
log.Fatal(err)
}
}
这个 main函数从命令行中获取输入和输出的文件名,放到相应的变量中,然后将这些变量传入americanise函数,由该函数做相应的处理。
该函数开始时取得所需输入和输出文件的文件名以及一个 error 值。如果命令行的解析有误,我们将输出相应的错误信息(其中包含程序的使用帮助),然后立即终止程序。如果某些类型包含Error string方法或者String string方法,Go语言的部分打印函数会使用反射功能来调用相应的函数获取打印信息,否则 Go语言也会尽量获取能获取的信息并进行打印。如果我们为自定义类型提供这两个方法中的一个,Go语言的打印函数将会打印该自定义类型的相应信息。我们将在第6章详细阐述相关的做法。
如果err的值为nil,说明变量inFilename和outFilename中包含字符串(可能为空),程序继续。Go语言中的文件类型表示为一个指向 os.File 值的指针,因此我们创建了两个这样的变量并将其初始化为标准输入输出流(这些流的类型都为*os.File)。正如你在以上程序中所看到的,Go语言的函数和方法支持多返回值,也支持多重赋值操作(标识①和③)。
本质上讲,每一个文件名的处理方式都相同。如果文件名为空,则相应的文件句柄已经被设置成os.Stdin或者os.Stdout(它们的类型都为*os.File,即一个指向os.File类型值的指针),但如果文件名不为空,我们就创建一个新的*os.File指针来读写对应的文件。
os.Open函数接受一个文件名字符串,并返回一个 *os.File类型值,该值可以用来从文件中读取数据。相应地,os.Create函数接受一个文件名字符串,返回一个 *os.File值,该值可以用来从文件中读取数据或者将数据写入文件。如果文件名所指向的文件不存在,我们会先创建该文件,若文件已经存在则会将文件的长度截为 0(Go语言也提供了os.OpenFile函数来打开文件,该函数可以让使用者自由地控制文件的打开模式和权限)。
事实上os.Open、os.Create和os.OpenFile这几个函数都有两个返回值:如果文件打开成功,则返回*os.File和nil错误值;如果文件打开失败,则返回一个nil文件句柄和相应非nil的error值。
返回的err值为nil意味着文件已被成功打开,我们在后面紧跟一个defer语句用于关闭文件。任何属于defer语句所对应的语句(参见5.5节)都保证会被执行(因此需要在函数名后面加上括号),但是该函数只会在defer语句所在的函数返回时被调用。因此,defer语句先“记住”该函数,并不马上执行。这也意味着defer 语句本身几乎不用耗时,而执行语句的控制权马上会交给defer语句的下一条语句。因此,被推迟执行的os.File.Close语句实际上不会马上被执行,直到包含它的main函数返回(无论是正常返回还是程序崩溃,稍后我们会讨论)。这样,打开的文件就可以被继续使用,并且保证会在我们使用完后自动关闭,即便是程序崩溃了。
如果我们打开文件失败,则调用 log.Fatal函数并传入相应的错误信息。正如我们在前文中所看的,这个函数会记录日期、时间和相应的错误信息(除非指定了其他输出目标,否则错误记录会默认打印到os.Stderr),并调用os.Exit来终止程序。当os.Exit函数被直接调用或通过 log.Fatal间接调用时,程序会立即终止,任何延迟执行的语句都会被丢失。不过这不是个问题,因为 Go语言的运行时系统会将所有打开的文件关闭,其垃圾回收器会释放程序的内存,而与该程序通信的任何设计良好的数据库或者网络应用都会检测到程序的崩溃,从而从容地应对。正如bigdigits示例程序中那样,我们不在第一个if语句(标识②)中使用log.Fatal,因为err中包含了程序的使用信息,而且我们不需要打印log.Fatal函数通常会输出的日期和时间信息。
在Go语言中,panic是一个运行时错误(很像其他语言中的异常,因此本书将panic直接翻译为“异常”)。我们可以使用内置的panic函数来触发一个异常,还可以使用recover函数(参见5.5节)来在其调用栈上阻止该异常的传播。理论上,Go语言的panic/recover功能可以用于多用途的错误处理机制,但我们并不推荐这么用。更合理的错误处理方式是让函数或者方法返回一个 error值作为其最后或者唯一的返回值(如果没错误发生则返回nil值),并让调用方来检查所收到的错误值。panic/recover机制的目的是用来处理真正的异常(即不可预料的异常)而非常规错误。[12]
两个文件都成功打开后(os.Stdin、os.Stdout和os.Stderr文件是由Go语言的运行时系统自动打开的),我们将要处理的文件传给americanise函数,由该函数对文件进行处理。如果americanse函数返回nil值,main函数将正常终止,所有被延迟的语句(在这里是指关闭inFile和outFile文件,如果它们不是os.Stdin和os.Stdout的话)都将被一一执行。如果err的值不是nil,则错误会被打印出来,程序退出,Go语言的运行时系统会自动将所有打开的文件关闭。
americanise函数的参数是io.Reader和io.Writer接口, 但我们传入的是 *os.File,原因很简单,因为 os.File 类型实现了 io.ReadWriter 结构(而 io.ReadWriter 是io.Reader和io.Writer 接口的组合),也就是说,os.File 类型的值可以用于任何要求io.Reader或者io.Writer接口的地方。这是一个典型的鸭子类型的实例,也就是任何类型只要实现了该接口所定义的方法,它的值都可以用于这个接口。如果americanise函数执行成功,则返回nil值,否则返回相应的error值。
func filenamesFromCommandLine (inFilename, outFilename string,
err error){
if len(os.Args) > 1 && (os.Args[1] == "-h" || os.Args[1] == "--help") {
err = fmt.Errorf("usage: %s [<]infile.txt [>]outfile.txt",
filepath.Base(os.Args[0]))
return "", "", err
}
if len(os.Args) > 1 {
inFilename = os.Args[1]
if len(os.Args) > 2 {
outFilename = os.Args[2]
}
}
if inFilename != "" && inFilename == outFilename {
log.Fatal("won't overwrite the infile")
}
return inFilename, outFilename, nil
}
filenamesFromCommandLine这个函数返回两个字符串和一个错误值。与我们所看到的其他函数不同的是,这里的返回值除了类型外还指定了名字。返回值在函数被执行时先被设置成空值(字符串被设置成空字符串,错误值err被设置成nil),直到函数体内有赋值语句为其赋值时返回值才改变。(下面讨论americanise函数的时候,我们会更加深入这个主题。)
函数先判断用户是否需要打印帮助信息[13]。如果是,就用fmt.Errorf函数来创建一个新的error值,打印合适的用法,并立即返回。与普通的Go语言代码一样,这个函数也要求调用者检查返回的error值,从而做出相应的处理。这也是main函数的做法。fmt.Errorf函数与我们之前所看的fmt.Printf函数类似,不同之处是它返回一个错误值,其中包含由给定的字符串格式和参数生成的字符串,而非将字符串输出到os.Stdout中(errors.New函数使用一个给定的字符串来生成一个错误值)。
如果用户不需要打印帮助信息,我们再检查他是否输入了命令行参数。如果用户输入了参数,我们将其输入的第一个命令行参数存放到inFilename中,将第二个命令行参数存放到outFilename中。当然,用户也可能没有输入命令行参数,这样inFilename和outFilename变量都为空。或者他们也可能只传入了一个参数,其中inFilename有文件名而outFilename为空。
最后,我们再做一些完整性检查,以保证不会用输出文件来覆盖输入文件,并在必要时退出。如果一切都如预期所料,则正常返回。[14]带返回值的函数或方法中必须至少有一个return语句。正如在这个函数中所做的一样,给返回值命名,是为了程序清晰,同时也可以用来生成godoc 文档。在包含变量名和类型作为返回值的函数或者方法中,使用一个不带返回值的return 语句来返回是合法的。在这种情况下,所有返回值变量的值都会被正常返回。本书中我们并不推荐使用不带返回值的return语句,因为这是一种不好的Go语言编程风格。
Go语言使用一种非常一致的方式来读写数据。这让我们可以用统一的方式从文件、内存缓冲(即字节或者字符串类型的切片)、标准输入输出或错误流读写数据,甚至也可以用统一的方式从我们的自定义类型读写数据,只要我们自定义的类型实现了相应的读写接口。
一个可读的值必须满足 io.Reader 接口。该接口只声明了一个方法 Read(byte) (int, error)。Read方法从调用该方法的值中读取数据,并将其放到一个字节类型的切片中。它返回成功读到的字节数和一个错误值。如果没有错误发生,则该错误值为nil。如果没有错误发生但是已读到文件末尾,则返回 io.EOF。如果错误发生,则返回一个非空的错误值。类似的,一个可写的值必须满足 io.Writer 接口。该接口也只声明了一个方法Write(byte) (int, error)。该Write方法将字节类型的切片中的数据写入到调用该方法的值中,然后返回其写入的字节数和一个错误值(如果没有错误发生则其值为nil)。
io包提供了读写模块,但它们都是非缓冲的,并且只在原始的字节层面上操作。bufio包提供了带缓冲的输入输出处理模块,其中的输入模块可作用于任何满足io.Reader接口的值(即实现了相应的Read方法),而输出模块则可作用于任何满足 io.Writer接口的值(即实现了相应的Write方法)。bufio 包的读写模块提供了针对字节或者字符串类型的缓冲机制,因此很适合用于读写UTF-8编码的文本文件。
var britishAmerican = "british-american.txt"
func americanise(inFile io.Reader, outFile io.Writer)(err error) {
reader := bufio.NewReader(inFile)
writer := bufio.NewWriter(outFile)
defer func {
if err == nil {
err = writer.Flush
}
}
var replacer func(string) string①
if replacer, err = makeReplacerFunc(britishAmerican); err != nil {
return err
}
wordRx := regexp.MustCompile("[A-Za-z]+")
eof := false
for !eof {
var line string ②
line, err = reader.ReadString('\n')
if err == io.EOF {
err = nil // 并不是一个真正的
eof = true // 在下一次迭代这会结束该循环
} else if err != nil {
return err // 对于真正的error,会立即结束
}
line = wordRx.ReplaceAllStringFunc(line, replacer)
if _, err = writer.WriteString(line); err != nil { ③
return err
}
}
return nil
}
americanise函数为inFile和outFile分别创建了一个reader和writer,然后从输入文件中逐行读取数据,然后将所有英式英语词汇替换成等价的美式英语词汇,并将处理结果逐行写入到输出文件中。
只需要往bufio.NewReader函数里传入任何一个实现了io.Reader接口的值(即实现了Read方法),就能得到一个带有缓冲的reader,bufio.NewWriter函数也类似。需要注意的是,americanise函数不知道也不用关心它从何处读,写向何处,比如 reader和writer可以是压缩文件、网络连接、字节切片,只要是任何实现io.Reader和io.Writer接口的值即可。这种处理接口的方式非常灵活,并且使得在Go语言编程中非常易于组合功能。
接下来我们创建一个匿名的延迟函数,它会在americanise函数返回并将控制权交给其调用者之前刷新writer的缓冲。这个匿名函数只会在americanise函数正常返回或者异常退出时才执行,由于刷新缓冲区操作也可能会失败,所以我们将 writer.Flush函数的返回值赋值给err。如果想忽略任何在刷新操作之前或者在刷新操作过程中发生的任何错误,可以简单地调用defer writer.Flush,但是这样做的话程序对错误的防御性将较低。
Go语言支持具名返回值,就像我们在之前的filenamesFromCommandLine函数中所做的,在这里我们也充分利用了这个特性(err error)。此外,还有一点需要注意的是,在使用具名返回值时有一个作用域的细节。例如,如果已经存在一个名为value的返回值,我们可以在函数内的任一位置对该返回值进行赋值,但是如果我们在函数内部某个地方使用了if value :=...这样的语句,因为if语句会创建一个新的块,所以这个value是一个新的变量,它会隐藏掉名字同为value的返回值。在americanise函数中,err是一个具名返回值,因此我们必须保证不使用快速变量声明符:=来为其赋值,以避免意外创建出一个影子变量。基于这样的考虑,我们有时必须在赋值时先声明一个变量,如这里的replacer变量(标识①)和我们这里读入的line变量(标识②)。另一种可选的方式是显式地返回所有返回值,就像我们在其他地方所做的那样。
另外一点需要注意的是,我们在这里使用了空标记符_(标识③)。这里的空标记符作为一个占位符放在需要一个变量的地方,并丢弃掉所有赋给它的值。空占位符不是一个新的变量,因此如果我们使用:=,至少需要声明一个其他的新变量。
Go的标准库中包含一个强大的名为regexp的正则表达式包(参见3.6.5节)。这个包可以用来创建一个指向regexp.Regexp值的指针(即regexp.Regexp类型)。这些值提供了许多供查找和替换的方法。这里我们使用 regexp.Regexp.ReplaceAllStringFunc方法。它接受一个字符串变量和一个签名为func(string) string的replacer函数作为输入,每发现一个匹配的值就调用一次 replacer 函数,并将该匹配到的文本内容替换为replacer函数返回的文本内容。
如果我们有一个非常小的replacer 函数,比如只是简单地将匹配的字母转换成大写,我们可以在调用替换函数的时候将其创建为一个匿名函数。例如:
line = wordRx.ReplaceAllStringFunc(line,
func(word string) string {return strings.ToUpper(word)})
然而,americanise 程序的replacer 函数虽然也就是几行代码,但它也需要一些准备工作,因此我们创建了一个独立函数makeReplacerFunction。该函数接受一个包含原始待替换文本的文件名以及用来替换的文字内容,返回一个replacer函数用来执行适当的替换工作。
如果makeReplacerFunction函数返回一个非nil的错误值,函数将直接返回。这种情况下调用者需检查所返回的error内容并做出相应的处理(如上文所做的那样)。
正则表达式可以使用 regexp.Compile函数来编译。该函数执行成功将返回一个*regexp.Regexp值和nil,否则返回一个nil值和相应的error值。这个函数比较适合于正则表达式内容是从外部文件读取或由用户输入的场景,因为需要做一些错误处理。但是这里我们用的是regexp.MustCompile函数,它仅仅返回一个 *regexp.Regexp值,或者在正则表达式非法的情况下执行异常流程。示例中所使用的正则表达式尽可能长地匹配一个或者多个英文字母字符。
有了replacer函数和正则表达式后,我们开始创建一个无限循环语句,每次循环先从reader中读取一行内容。bufio.Reader.ReadString方法将底层reader读取过来的原始字节码按UTF-8编码文本的方式读取(严格地讲应该是解码成UTF-8,对于7位的ASCII编码也有效),它最多只能读取指定长度的字节(也可能已读到文件末尾)。该函数将读取的文本内容以方便使用的string类型返回,同时返回一个error值(不出错误的话为nil)。
如果调用 bufio.Reader.ReadString返回的err 值非空,可能是读到文件末尾或是读取数据过程中遇到了问题。如果是前者,那么err的值应该是io.EOF,这是正常的,我们不应该将它作为一个真正的错误来处理,所以这种情况下我们将err重新设置为nil,并将eof设置为true以退出循环体。遇到io.EOF错误的时候,我们并不立即返回,因为文件的最后一行可能并不是以换行符结尾,在这种情况下我们还需要处理这最后一行文本。
每读到一行,就调用 regexp.Regexp.ReplaceAllStringFunc方法来处理,并传入这行读取到的文本和对应的replacer函数。然后我们调用bufio.Writer.WriteString方法将处理的结果文本行(可能已经被修改)写入到writer中。这个bufio.Writer.WriteString函数接受一个string类型的输入,并以UTF-8编码的字节流写出到相应目的地,返回成功写出的字节数和一个error类型值(如果没有发生问题,这个error类型值将为nil)。这里我们并不关心写入了多少字节,所以用_把第一返回值忽略掉。如果 err 为非空,那么函数将立即返回,调用者会马上接收到相应的错误信息。
正如我们程序中的用法,用bufio来创建reader和writer可以很容易地应用一些字符串处理的高级技巧,完全不用关心原始数据在磁盘上是怎么组织存储的。当然,别忘了我们前面延迟了一个匿名函数,如果没有错误发生所有被缓冲的字节数据都会在americanise函数返回时被写入到writer里。
func makeReplacerFunction(file string) (func(string) string, error) {
rawBytes, err := ioutil.ReadFile(file)
if err != nil {
return nil, err
}
text := string(rawBytes)
usForBritish := make(map[string]string)
lines := strings.Split(text, "\n")
for _, line := range lines {
fields := strings.Fields(line)
if len(fields) == 2 {
usForBritish[fields[0]] = fields[1]
}
}
return func(word string) string{
if usWord, found := usForBritish[word]; found {
return usWord
}
return word
}, nil
}
makeReplacerFunction函数接受包含原始字符串和替换字符串文件的文件名作为输入,并返回一个替换函数和一个错误值,这个被返回的替换函数接受一个原始字符串,返回一个被替换的字符串。该函数假设输入的文件是以UTF-8编码的文本文件,其中的每一行使用空格将原始和要替换的单词分隔开来。
除了bufio包的reader和writer之外,Go的io/ioutil包也提供了一些使用方便的高级函数,比如我们这里用的ioutil.ReadFile。这个函数将一个文件的内容以byte值的方式返回,同时返回一个error类型的错误值。如果读取出错,返回nil和相应的错误,否则,就将它转换成字符串。将UTF-8编码的字节转换成一个字符串是一个非常廉价的操作,因为Go语言中字符串类型的内部表示统一是UTF-8编码的(Go语言的字符串转换内容将在第3章详细阐述)。
由于我们创建的replacer 函数参数和返回值都是一个字符串,所以我们需要的是一种合适的查找表。Go语言的内置集合类型map就非常适合这种情况(参见4.3节)。用map来保存键值对,查找速度是很快的,比如我们这里将英式单词作为键,美式单词作为相应的值。
Go语言中的映射、切片和通道都必须通过make函数来创建,并返回一个指向特定类型的值的引用。该引用可以用于传递(如传入到其他函数),并且在被引用的值上做的任何改变对于任何访问该值的代码而言都是可见的。在这里我们创建了一个名为 usForBritish的空映射,它的键和值都是字符串类型。
在映射创建完成后,我们调用strings.Split函数将文件的内容(就是一个字符串)使用分隔符“\n”切分为若干个文本行。这个函数的输入参数为一个字符串和一个分隔符,会对输入的字符串进行尽可能多次数的切分(如果我们想限制切分的次数,可以使用strings.SplitN函数)。
我们使用一个之前没有接触过的for 循环语法来遍历每一行,这一次我们使用的是一个range语句。这种语法用来遍历映射中的键值对非常方便,可用于读取通道的元素,另外也可用于遍历切片或者数组。当我们使用切片(或数组)时,每次迭代返回的是切片的索引和在该索引上的元素值,其索引从0开始(如果该切片为非空的话)。在本例中,我们使用循环来迭代每一行,但由于我们并不关心每一行的索引,所以用了一个_占位符把它忽略掉。
我们需要将每行切分成两部分: 原始字符串和替换的字符串。 我们可以使用strings.Split函数,但它要求声明一个确定的分隔符,如" ",这在某些手动分隔的文件中可能失败,因为用户可能意外地输入多个空格或者使用制表符来代替空格。幸亏 Go语言标准库提供了另一个strings.Fields函数以空白分隔符来分隔字符串,因此能更恰当地处理用户手动编辑的文本。
如果变量 fields(其类型为string)恰好有两个元素,我们将对应的“键值“对插入映射中。一旦该映射的内容准备好,我们就可以开始创建用来返回给调用者的replacer函数。
我们将 replacer 函数创建为匿名函数,并将其当做一个参数来让 return 语句返回,该return语句同时返回一个空的错误值(当然,我们本来可以更繁琐点,将该匿名函数赋值给一个变量,并将该变量返回)。这个匿名函数的签名与regexp.Regexp.ReplaceAllStringFun方法所期望传入的函数签名必须完全一致。
我们在匿名函数replacer里所做的只是查找一个给定的单词。如果我们在左边通过一个变量来获取一个映射的元素,该元素将被赋值给对应的变量。如果映射中对应的键不存在,那么所获取的值为该类型的空值。如果该映射值类型的空值本身也是一个合法的值,那我们还能如何判断一个给定的值是否在映射中呢?Go语言为此提供了一种语法,即赋值语句的左边同时为两个变量赋值,第一个变量用来接收该值,第二个变量用来接收一个布尔值,表示该键在映射中是否找到。如果我们只是想知道某个特定的值是否在映射中,该方法通常有效。本例中我们在 if 语句中使用第二种形式,其中有一个简单的语句(一个简短的变量声明)和一个条件(那个布尔变量found)。因此,我们得到usWord变量(如果所给出的单词不在映射中,该变量的值为空字符串)和一个布尔类型的found标志。如果英式英语的单词找到了,我们返回相应的美式英语单词;否则,我们简单地将原始单词原封不动地返回。
我们从makeReplacerFunction函数中还可以发现一个有些微妙的地方。在匿名函数内部我们访问了在匿名函数的外层创建的usForBritish 变量(是一个映射)。之所以可以这么做,是因为Go支持闭包(参见5.6.3节)。闭包是一个能够“捕获”一些外部状态的函数,例如可以捕获创建该函数的函数的某些状态,或者闭包所捕获的该状态的任意一部分。因此在这里,在函数makeReplacerFunction内部创建的匿名函数是一个闭包,它捕获了usForBritish变量。
还有一个微妙的地方就是,usForBritish本应该是一个本地变量,然而我们却可以在它被声明的函数之外使用它。在 Go语言中完全可以返回本地变量。即使是引用或者指针,如果还在被使用,Go语言并不会删除它们,只有在它们不再被使用时(也就是当任何保存、引用或者指向它们的变量超出作用域范围时)才用垃圾回收机制将它们回收。
本节给出了一些利用os.Open、os.Create和ioutil.ReadFile函数来处理文件的基础和高级功能。在第8章中我们将介绍更多的文件处理相关内容,包括读写文本文件、二进制文件、JSON文件和XML文件。Go语言的内置集合类型如切片和映射提供了非常良好的性能和极大的便利性,帮助开发者大大降低了创建自定义类型的需求。我们将在第4章详细阐述Go语言的集合类型。Go语言将函数当做一类值来对待并支持闭包,使得开发者在写程序时可以使用一些高级而非常有用的编程技巧。同时,Go语言的defer语句能非常直接简单明了地避免资源泄露。
1.7 从极坐标到笛卡儿坐标——并发
Go语言的一个关键特性在于其充分利用现代计算机的多处理器和多核的功能,且无需给程序员带来太大负担。完全无需任何显式锁就可写出许多并发程序(虽然 Go语言也提供了锁原语以便在底层代码需要用到时使用,我们将在第7章中详细阐述)。
Go语言有两个特性使得用它来做并发编程非常轻松。第一,无需继承什么“线程”(thread)类(这在Go语言中其实也不可能)即可轻易地创建goroutine(实际上是非常轻量级的线程或者协程)。第二,通道(channel)为goroutine之间提供了类型安全的单向或者双向通信,这也可以用来同步goroutine。
Go语言处理并发的方式是传递数据,而非共享数据。这使得与使用传统的线程和锁方式相比,用Go语言来编写并发程序更为简单。由于没有使用共享数据,我们不会进入竞态条件(例如死锁),我们也不必记住何时该加锁和解锁,因为没有共享的数据需要保护。