Go语言程序设计

978-7-115-31790-2
作者: 【英】Mark Summerfield
译者: 许式伟吕桂华徐立何李石
编辑: 杨海玲

图书目录:

详情

本书既是一本实用的Go语言教程,又是一本权威的Go语言参考手册。书中从如何获取和安装Go语言环境,以及如何建立和运行Go程序开始,逐步介绍了Go语言的语法、特性以及一些标准库,内置数据类型、语句和控制结构,然后讲解了如何在Go语言中进行面向对象编程,Go语言的并发特性,如何导入和使用标准库包、自定义包及第三方软件包,提供了评价Go语言、以Go语言思考以及用Go语言编写高性能软件所需的所有知识。

图书摘要

版权信息

书名:Go语言程序设计

ISBN:978-7-115-31790-2

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。

• 著    [英] Mark Summerfield

  译    许式伟  吕桂华  徐 立  何李石

  责任编辑 杨海玲

• 人民邮电出版社出版发行  北京市丰台区成寿寺路11号

  邮编 100164  电子邮件 315@ptpress.com.cn

  网址 http://www.ptpress.com.cn

• 读者服务热线:(010)81055410

  反盗版热线:(010)81055315


本书既是一本实用的Go语言教程,又是一本权威的Go语言参考手册。书中从如何获取和安装Go语言环境,以及如何建立和运行Go程序开始,逐步介绍了Go语言的语法、特性以及一些标准库,内置数据类型、语句和控制结构,然后讲解了如何在Go语言中进行面向对象编程,Go语言的并发特性,如何导入和使用标准库包、自定义包及第三方软件包,提供了评价Go语言、以Go语言思考以及用Go语言编写高性能软件所需的所有知识。

本书的目的是通过使用语言本身提供的所有特性以及Go语言标准库中一些最常用的包,向读者介绍如何进行地道的Go语言编程。本书自始至终完全从实践的角度出发,每一章提供多个生动的代码示例和专门设计的动手实验,帮助读者快速掌握开发技能。本书适合对Go语言感兴趣的各个层次的Go语言程序员阅读和参考。


Authorized translation from the English language edition, entitled: Programming in Go, 978-0-321- 77463-7 by Mark Summerfield, published by Pearson Education, Inc., publishing as Addison-Wesley Professional, Copyright © 2012 Pearson Education, Inc.

All rights reserved. No part of this book may be reproduced or transmitted in any form or by any means, electronic or mechanical, including photocopying, recording or by any information storage retrieval system, without permission from Pearson Education, Inc.

CHINESE SIMPLIFIED language edition published by PEARSON EDUCATION ASIA LTD. and POSTS & TELECOM PRESS Copyright © 2013.

本书中文简体字版由Pearson Education Asia Ltd.授权人民邮电出版社独家出版。未经出版者书面许可,不得以任何方式复制或抄袭本书内容。

本书封面贴有Pearson Education(培生教育出版集团)激光防伪标签,无标签者不得销售。

版权所有,侵权必究。


关注过我的人可能都知道,我在新浪微博、《Go语言编程》一书中都非常高调地下了一个论断:Go语言将超过C、Java,成为未来十年最流行的语言。

为什么我可以如此坚定地相信,选择Go语言不会有错,并且相信Go语言会成为未来10年最流行的语言?除了Go语言的并发编程模型深得我心外,Go语言的各种语法特性显得那么深思熟虑、卓绝不凡,其对软件系统架构的领悟,让我深觉无法望其项背,处处带给我惊喜。

Go语言给我的第一个惊喜是大道至简的设计哲学。

Go语言是非常简约的语言。简约的意思是少而精。少就是指数级的多。Go语言极力追求语言特性的最小化,如果某个语法特性只是少写几行代码,但对解决实际问题的难度不会产生本质的影响,那么这样的语法特性就不会被加入。Go语言更关心的是如何解决程序员开发上的心智负担。如何减少代码出错的机会,如何更容易写出高品质的代码,是Go设计时极度关心的问题。

Go语言追求显式表达。任何封装都是有漏洞的,最佳的表达方式就是用最直白的表达方式,所以也有人称Go语言为“所写即所得”的语言。

Go语言也是非常追求自然(nature)的语言。Go不只是提供极少的语言特性,并极力追求语言特性最自然的表达,也就是这些语法特性被设计成恰如多少人期望的那样,尽量避免惊异。事实上,Go语言的语法特性上的争议是非常少的。这些也让Go语言的入门门槛变得非常低。

Go语言给我的第二个惊喜是最对胃口的并行支持。

我对服务端开发的探索,始于Erlang语言,并且认为Erlang风格并发模型的精髓是轻量级进程模型。然而,Erlang除了语言本身不容易被程序员接受外,其基于进程邮箱做消息传递的并发编程模型也小有瑕疵。我曾经在C++中实现了一个名为CERL的网络库,刚开始在C++中完全模仿Erlang风格的并发编程手法,然而在我拿CERL库做云存储服务的实践中,发现了该编程模型的问题所在并做了相应的调整,这就是后来的CERL 2.0版本。有意思的是,CERL 2.0与Go语言的并行编程思路不谋而合。某种程度上来说,这种默契也是我创办七牛时,Go语言甚至语法特性都还没有完全稳定,我们技术选型就坚决地采纳了Go语言的重要原因。

Go语言给我的第三个惊喜是接口。

Go语言的接口,并非是你在Java和C#中看到的接口,尽管看起来有点像。Go语言的接口是非侵入式的接口,具体表现在实现一个接口不需要显式地进行声明。不过,让我意外的不是Go的非侵入式接口。非侵入式接口只是我接受Go语言的基础。在接口(或契约)的表达上,我一直认为Java和C#这些主流的静态类型语言都走错了方向。C++的模板尽管机制复杂,但是走在了正确的方向上。C++0x(后来的C++11)呼声很高的concept提案被否,着实让不少人伤了心。但Go语言的接口远不是非侵入式接口那么简单,它是Go语言类型系统的纲,这表现在以下几个方面。

(1)只要某个类型实现了接口要的方法,那么我们就说该类型实现了此接口。该类型的对象可赋值给该接口。

(2)作为1的推论,任何类型(包括基础类型如bool、int、string等)的对象都可以赋值给空接口interface{}

(3) 支持接口查询。如果你曾经是Windows程序员,你会发现COM思想在Go语言中通过接口优雅呈现。并且Go语言吸收了其中最精华的部分,而COM中对象生命周期管理的负担,却因为Go语言基于gc方式的内存管理而不复存在。

Go语言给我的第四个意外惊喜是极度简化但完备的面向对象编程(OOP)方法。

Go语言废弃大量的OOP特性,如继承、构造/析构函数、虚函数、函数重载、默认参数等;简化的符号访问权限控制,将隐藏的this指针改为显式定义的receiver对象。Go语言让我看到了OOP编程核心价值原来如此简单——只是多数人都无法看透。

Go语言带给我的第五个惊喜是它的错误处理规范。

Go语言引入了内置的错误(error)类型以及defer关键字来编写异常安全代码,让人拍案叫绝。下面这个例子,我在多个场合都提过:

f, err := os.Open(file)
if err != nil {
      ... // 错误处理
      return
}
defer f.Close()
... // 处理文件数据
Go语言带给我的第六个惊喜是它功能的内聚。

一个最典型的案例是Go语言的组合功能。对于多数语言来说,组合只是形成复合类型的基本手段,这一点只要想想C语言的struct就清楚了。但Go语言引入了匿名组合的概念,它让其他语言原本需要引入继承这样的新概念来完成事情,统一到了组合这样的一个基础上。

在C++中,你需要这样定义一个派生类:

class Foo : public Base {
     ...
};

在Go语言中你只要:

type Foo struct {
     Base
     ...
}

更有甚者,Go语言的匿名组合允许组合一个指针:

type Foo struct {
     *Base
     ...
}

这个功能可以实现C++中一个无比晦涩难懂的特性,叫“虚拟继承”。但同样的问题换成从组合角度来表达,直达问题的本质,清晰易懂。

Go语言带给我的第七个惊喜是消除了堆与栈的边界。

在Go语言之前,程序员清楚地知道哪些变量在栈上,哪些变量在堆上。堆与栈是基于现代计算机系统的基础工作模型上形成的概念,Go语言屏蔽了变量定义在堆上还是栈上这样的物理结构,相当于封装了一个新的计算机工作模型。这一点看似与Go语言显式表达的设计哲学不太一致,但我个人认为这是一项了不起的工作,而且与Go语言的显式表达并不矛盾。Go语言强调的是对开发者的程序逻辑(语义)的显式表达,而非对计算机硬件结构的显示表达。对计算机硬件结构的高度抽象,将更有助于Go语言适应未来计算机硬件发展的变化。

Go语言带给我的第八个惊喜是Go语言对C语言的支持。

可以这么说,Go语言是除了Objective-C、C++这两门以兼容C为基础目标的语言外的所有语言中,对C语言支持最友善的一个。什么语言可以直接嵌入C代码?只有Go。什么语言可以无缝调用C函数?只有Go。对C语言的完美支持,是Go快速崛起的关键支撑。还有比C语言更让人觊觎的社区财富吗?那是一个取之不尽的金矿。

总而言之,Go语言是一门非常具有变革性的语言。尽管40年(从1970年C语言诞生开始算起)来出现的语言非常之多,各有各的特色,让人眼花缭乱。但是我个人固执地认为,谈得上突破了C语言思想,将编程理念提高到一个新高度的,仅有Go语言而已。

Go语言很简单,但是具备极强的表现力。从目前的状态来说,Go语言主要关注服务器领域的开发,但这不会是Go语言的完整使命。

我们说Go语言适合服务端开发,仅仅是因为它的标准库支持方面,目前是向服务端开发倾斜:

而作为桌面开发的常规组件:GDI和UI系统与事件处理,基本没有涉及。

尽管Go还很年轻,Go语言1.0版本在2012年3月底发布,到现在才近1年,然而Go语言已经得到了非常普遍的认同。在国外,有人甚至提出“Go语言将制霸云计算领域”。在国内,几乎所有你听到过名字的大公司(腾讯、阿里巴巴、京东、360、网易、新浪、金山、豆瓣等),都有团队对Go语言做服务端开发进行了小范围的实践。这是不能不说是一个奇迹。

Go语言是一门前途非常光明的语言,很少有语言在如此年轻的时候就得到如此热捧。

但因为年轻,导致了Go语言的书籍哪怕在全球都非常稀少。这本书由知名技术作家Mark Summerfield撰写,它会让你了解Go语言,按Go语言的方式思考,以及使用Go语言来编写高性能软件。一直以来,Summerfield的教学方式都是深入实践的。每一章节都提供了多个活生生的代码示例,它们都是经过精心设计的用于鼓励读者动手实验并且能够帮助读者快速掌握如何开发的。

许式伟

2013年6月


我写每一本技术书时都得到过来自他人的帮助与建议,本书也不例外。

我想特别感谢两个之前没有Go语言编程经验的程序员朋友——asmin Blanchette和Trenton Schulz。他们两个曾多年为我的书贡献诸多。他们对本书的反馈也让本书能更符合程序员初学Go语言时的需求。

来自Go语言核心开发者Nigel Tao的反馈也让本书受益良多。虽然我并未完全采纳他的所有建议,但是他的反馈总是能够提点我,进而给代码以及书的内容带来极大的改进。

此外,我得到过其他许多人的帮助,包括Go语言初学者David Boddie。他提供了一些有价值的反馈。同时,Go语言的开发者Ian Lance Taylor特别是Russ Cox为我解决了很多代码以及概念上的问题,他们提供的清晰准确的解释对本书的精确性有极大的贡献。

在撰写本书时,我在golang-nuts这个邮件列表里提了许多问题,每次提问总能从众多回邮件者那里收到深思熟虑且实用的回复。同时,Safari上的本书初稿读者也给了我许多反馈,从而让本书中的一些讲解清晰了很多。

意大利的软件公司www.develer.com以Giovanni Bajo个人的名义,给我提供免费的Mercurial代码库托管服务,让我在写作的漫长过程中能够静心思考。谢谢Lorenzo Mancini为我设置整个环境然后帮我打理它。同时,我也非常感谢Anton Bowers以及Ben Thompson,自2011年初起,我的网站www.qtrac.eu就托管在他们的网络服务器上。

谢谢Russel Winder在他的博客www.russel.org.uk上讨论软件专利的事情,附件B中有许多思想是从他那里来的。

然后,我要一如既往地感谢lout排版系统的作者Jeff Kingston,我所有的书以及许多其他写作项目都是用这个系统排版而成的。

特别感谢我的责任编辑Debra Willians Cauley,是他将本书成功带给出版社,同时也在本书的写作过程中提供了支持与实际帮助。

同时也感谢出版经理Anna Popick,他再次将书的出版过程管理得如此好,也感谢校对人员Audrey Doyle的出色工作。

与以往一样,我还要感谢我的妻子Andrea,谢谢她的爱与支持。


本书介绍如何使用Go语言的语言特性以及标准库中的常用包来进行地道的Go语言编程。同时,本书也设计成在学会Go语言后依然有用的参考资料。为了实现这两个目标,这本书覆盖面非常广,尽量保证每一章只涵盖一个主题,各章之间会进行内容上的交叉引用。

从语言的设计精神来说,Go语言与C语言非常相似,是一门精小而高效的语言,它有便利的底层设施,如指针。不过Go语言还提供了许多只在高级或者非常高级的语言中才有的特性,如Unicode字符串、强大的内置数据结构、鸭子类型、垃圾收集和高层次的并发支持,使用通信而非常规的共享数据和锁方式。另外,Go语言还提供了一个庞大且覆盖面全的标准库。

虽然所有的Go语言特性或者编程范式都会以完整可运行的示例来详细讲解,但是本书还是假设读者有主流编程语言的经验,比如C、C++、Java、Python或其他类似的语言。

要学好任何一门语言,使用它进行编程都是必经之路。为此,本书采用完全面向实战的方式,鼓励读者亲自去练习书中的例子,尝试着去解决练习题中给出的问题,自己去写程序,以获得宝贵的实践经验。正如我以前写的书一样,本书中所引用的代码片段都是“活代码”。也就是说,这些代码自动提取自.go源文件,并直接嵌入到提供给出版商的PDF文件中,故此不会有剪切和粘贴错误,可以直接运行。只要有可能,本书都会提供小而全的程序或者包来作为贴近实际应用场景的例子。本书的例子、练习和解决方案都可以从www.qtrac.eu/gobook.html这个网址获得。

本书的主要目的是传授Go语言本身,虽然我们使用了Go语言标准库中的许多包,但不会试图全都涉及。这并不是问题,因为本书向读者提供了足够的Go语言知识来使用任何标准库中的包或者是任何第三方Go语言的包,当然还能够创建自己的包。

Go语言始于2007年,当时只是Google内部的一个项目,其最初设计者是Robert Griesemer、Unix泰斗Rob Pike和Ken Thompson。2009年11月10日,Go语言以一个自由的开源许可方式公开亮相。Go语言由其原始设计者加上Russ Cox、Andrew Gerrand、Ian Lance Taylor以及其他许多人在内的一个Google团队开发。Go语言采取一种开放的开发模式,吸引了许多来自世界各地的开发者为这门语言的发展贡献力量。其中有些开发者获得了非常好的声望,因此他们也获得了与Google员工一样的代码提交权限。此外,Go Dashboard这个网站(godashboard.appspot.com/project)也提供了许多第三方的Go语言包。

Go语言是近15年来出现的最令人兴奋的新主流语言。它是第一个直接面向21世纪计算机和开发者的语言。

Go语言被设计为可高效地伸缩以便构建非常大的应用,并可在普通计算机上用几秒钟即完成编译。快如闪电的编译速度可能在一定程度上是因为语言的语法很容易解析,但更主要是因为它的依赖管理。如果文件app.go依赖于文件pkg1.go,而pkg1.go又依赖于pkg2.go,在传统的编译型语言中,app.go需要依赖于pkg1.gopkg2.go目标文件。但在Go语言中,一切pkg2.go导出的内容都被缓存在pkg1.go的目标文件中,所以pkg1.go的目标文件足够独立构建app.go。对于只有三个源文件的程序来说,这看不出什么优劣,但对于有着大量依赖关系的大型应用程序来说,这样做可以获得巨大的编译速度提升。

由于Go语言程序的构建是如此之快,因此它也适用一些本来应该使用脚本语言的场景(见 “Go语言版Shebang脚本”,参见1.2节)。此外,Go语言可用于构建基于Google App Engine的Web应用程序。

Go语言使用了一种非常干净且易于理解的语法,避免了像老的语言如C++(发布于1983年)或Java(发布于1995年)一样的复杂和冗长。Go语言是一种强静态类型的语言,这在有些程序员看来是构建大型应用程序的必备特性。然而,使用Go语言进行编程并不需要像使用别的静态语言那样打太多的字,这要归功于Go语言简短的“声明并初始化”的变量声明语法(由于编译器会推断类型,因此并不需要显式地写明),以及它对鸭子类型强大而便捷的支持。

像C和C++这样的语言,当涉及内存管理时需要程序员非常谨慎地面对,特别是对于并发程序,要跟踪它们的内存分配简直犹如噩梦,而这些本来可以交给计算机去做。近年来,C++在这方面用各种“智能”指针进行了很大的改善,但在线程库方面还一直在追赶Java。通过使用垃圾收集器,Java减轻了程序员管理内存的负担。虽然C++语言现在有一个标准的线程库,但是C语言还只能使用第三方线程库。然而,在C、C++或Java中编写并发程序仍然需要相当地谨慎,以确保在恰当的时间正确地锁定和解锁资源。

Go编译器和运行时系统会处理这些繁琐的跟踪问题。对于内存管理而言,Go语言提供了一个垃圾收集器,因此无需使用智能指针或者手动释放内存。Go语言的并发机制基于计算机科学家C. A. R. Hoare提出的CSP(Communicating Sequential Processes)模型构建,这意味着许多并发的Go语言程序不需要加任何锁。此外,Go语言引入goroutine ——一种非常轻量级的进程,可以一次性大量创建,并可跨处理器和处理器核心自动进行负载平衡,以提供比老的基于线程的语言更细粒度的并发。事实上,因为Go语言的并发支持使用起来如此简单和自然,移植单线程程序到Go时经常会发现转为并发模型的机会大增,从而可以更充分地利用计算机资源。

Go语言是一门务实的语言,与语言的纯净度相比,它更关注语言效率以及为程序员带来的便捷性。例如,Go语言的内置类型和用户自定义的类型是不一样的,因为前者可以高度优化,后者却不能。Go语言也提供了两个基本的内置集合类型:切片(slice,它的实际用途是为了提供变长功能的数组)和映射(map,也叫键值字典或散列表)。这些集合类型非常高效,并且在大多数情况下都能非常好地满足需求。当然,Go语言也支持指针(它是一个完全编译型的语言,因此在性能方面没有虚拟机挡路),所以它可以轻松创建复杂的自定义类型,如平衡二叉树。

虽然C语言仅支持过程式编程,而Java则强制要求程序员按照面向对象的方式来编程,但Go语言允许程序员使用最合适的编程范式来解决问题。Go语言可以被用做一个纯粹的过程式编程语言,但对面向对象编程也支持得很好。不过,我们也会在后文看到,Go语言面向对象编程的方式与C++、Java或Python非常不同,它更容易使用且在形式上更加灵活。

就像C语言一样,Go语言也不支持泛型(用C++的话来说就是模板)。然而,Go语言所提供的别的功能特性消除了对泛型支持的依赖。Go并不使用预处理器或者包含文件(这也是为什么它编译得如此之快的另一个原因),因此也无需像C和C++那样复制函数签名。同时,因为没有使用预处理器,程序的语义就无法在Go语言程序员背后悄悄变化,但这种情况在C和C++下使用#define时一不小心就会发生。

可以说,C++、Objective-C和Java都试图成为更好的C语言(后者是间接地成为了更好的C++语言)。尽管Go语言干净而轻盈的语法容易让人联想到Python,Go语言的切片和映射也非常类似于Python的列表和字典,但Go语言也可以被认为试图成为一个更好的C。然而,与任何其他语言相比,Go语言从语言本质上都更接近于C语言,并可以被认为保留了C语言的所有精华的同时试图消除C语言中的缺陷,同时加入了许多强大而有用的独有特性。

Go语言最初被构思为一门可充分利用分布式系统以及多核联网计算机优势且适用于开发大型项目的编译速度很快的系统级语言。现在,Go语言的触角已经远远超出了原定的范畴,它正被用做一个具有高度生产力的通用编程语言。使用Go语言开发和维护系统都让人感觉是一种享受。

第1章开始讲解如何建立和运行Go程序。这一章通过5个简短的示例简要介绍了Go语言的语法与特性,以及一些标准库。每个例子都介绍了一些不同的特性。这一章主要是为了让读者尝试一下Go语言,以此让读者感受一下学习Go语言需要学习的大致内容是什么。(这一章章还讲解了如何获取和安装Go语言环境。)

第2章至第7章更深入地讲解了Go语言的方方面面。其中有三章专门讲解了Go语言的内置数据类型:第2章涵盖了标识符、布尔值和数值类型,第3章涵盖了字符串,第4章涵盖了Go语言内置的集合类型。

第5章描述并讲解了Go语言的语句和控制结构,还解释了如何创建和使用自定义的函数,最后展示了如何使用Go语言创建一个过程式的非并发程序。

第6章展示了如何在Go语言中进行面向对象编程。本章的内容包括可用于聚合和嵌入(委托)其他类型的结构体,可作为一个抽象类型的接口,以及如何在某些情况下产生类似继承的效果。由于Go语言中进行面向对象编程的方式可能与大多数读者的经验不同,这一章会给出几个完整的例子并详细讲解,以确保读者完全理解Go语言的面向对象编程方式。

第7掌讲解了Go语言的并发特性,与面向对象编程一章相比,这一章给出了更多实例,以确保读者对这些新的Go语言特性有透彻的了解。

第8章展示了如何读取和写入自定义的二进制文件、Go二进制(gob)文件、文本、JSON以及XML文件。(读取和写入文本文件的知识在第1章和后续几章中都有所涉及,因为这些知识可以更易于提供一些有价值的示例和练习。)

本书的最后一章是第9章。这一章先展示了如何导入和使用标准库包、自定义包以及第三方软件包。它还展示如何对自定义的包进行文档的自动提取、单元测试和性能基准测试。这一章的最后一节对Go编译器(gc)提供的工具集以及Go语言的标准库做了简要的概述。

Go语言虽然小巧,但它同时也是一门功能丰富和强大表达能力(在语法结构、概念和编程习惯方面)的语言。本书的例子都符合良好的Go语言编程范式。当然,这种做法也意味着有些概念出现时不会被当场解释。但我们希望读者相信,所有的概念都会在本书中进行解释(当然,没有当场解释的内容都会以交叉引用的形式给出相应讲解的位置)。

Go是一门迷人的语言,使用起来感觉非常好。学习Go语法和编程习惯并不会很难,但它的确引入了一些新颖的、对许多读者来说可能不那么熟悉的概念。这本书试图给读者概念上的突破,尤其是在面向对象的Go语言编程和并发Go语言编程方面。如果只阅读那些定义良好却非常简要的文档,读者可能需要花费数周甚至数月的时间才能真正理解相关的知识。

指不需要用户主动加锁,而不是指从内部实现来说没有锁。——译者注

这里有一个例外:前面几章中,即使通道只被当做单向通道使用,我们也总是将通道声明为双向的。从第7章开始,通道只被声明为只有某一特殊的方向,这样,这里所说的Go语言风格用法也就讲得通了。


本章总共有5个比较小的示例程序。这些示例程序概览了Go语言的一些关键特性和核心包(在其他语言里也叫模块或者库,在Go语言里叫做包(package),这些官方提供的包统称为Go语言标准库),从而让读者对学习Go语言编程有一个初步的认识。如果有些语法或者专业术语没法立即理解,不用担心,本章所有提到的知识点在后面的章节中都有详细的描述。

要使用Go语言写出Go味道的程序需要一定的时间和实践。如果你想将C、C++、Java、Python以及其他语言实现的程序移植到Go语言,花些时间学习Go语言特别是面向对象和并发编程的知识将会让你事半功倍。而如果你想使用Go语言来从头创建新的应用,那就更要好好掌握Go语言提供的功能了,所以说前期投入足够的学习时间非常重要,前期付出的越多,后期节省的时间也将越多。

为了尽可能获得最佳的运行性能,Go语言被设计成一门静态编译型的语言,而不是动态解释型的。Go语言的编译速度非常块,明显要快过其他同类的语言,比如C和C++。

Go语言的官方编译器被称为gc,包括编译工具5g6g8g,链接工具5l6l8l,以及文档查看工具godoc(在Windows下分别是5g.exe6l.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终端(xtermgnome-terminalonsoleTerminal.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链接旁边会出现另一个链接。你可以通过这个链接访问相应的文档(环境变量GOROOTGOPATH将在本章后面小节和第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

Go程序使用UTF-8编码的纯Unicode文本编写。大部分现代编辑器都能够自动处理编码,并且某些最流行的编辑器还支持Go语言的语法高亮和自动缩进。如果你用的编辑器不支持Go语言,可以在Go语言官网的搜索框中输入编辑器的名字,看看是否有合适的插件可用。为了编辑方便,所有的Go语言关键字和操作符都使用ASCII编码字符,但是Go语言中的标识符可以是任一Unicode编码字符后跟若干Unicode字符或数字,这样Go语言开发者可以在代码中自由地使用他们的母语。

Go语言版Shebang脚本

因为Go的编译速度非常快,Go程序可以作为类Unix系统上的shebang #! 脚本使用。我们需要安装一个合适的工具来实现脚本效果。在撰写本书的时候已经有两个能提供所需功能的工具:gonowgithub.com/kison/gonow)和gorunwiki.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是否安装正确,在终端(xtermgnome-terminalkonsoleTerminal.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程序,有两步是必须的:编译和链接。所有这两步都由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在这个情况下我们必须将所有的程序和包的源代码都放入$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:\goegC:\app\go;C:\goeg

虽然Go语言的推荐构建工具是go命令行工具,我们完全可以使用make或者其他现代构建工具,或者使用别的针对Go语言的构建工具,或者给流行集成开发环境如Eclipse和Visual Studio安装合适的插件来进行Go工程的构建。

现在我们已经知道怎么编译一个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语言中的惯例是使用单行注释,而多行注释则往往用于在开发过程中注释掉若干行代码。

所有的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代码块的新局部变量whostrings.Join()函数的输入参数为以一个string类型的切片和一个分隔符(可以是一个空字符,如"")作为输入,返回一个由分隔符将切片中的所有字符串连接在一起的新字符串。在这个示例里我们用空格作为连接符来连接所有输入的字符串参数。

最后,在最后一个语句(标注❺)中,我们打印Hello和一个空格,以及who变量中的字符串,并添加一个换行符。fmt包提供了许多不同的打印函数变体,比如像fmt.Println()会整洁地打印任何输入的内容,而像fmt.Printf()则使用占位符来提供良好的格式化输出控制能力。打印函数将在第3章(参见3.5节)详细阐述。

本节的hello程序展示了很多超出这类程序一般所做事情之外的语言特性。接下来的示例也会这样做,在保持程序尽量简短的情况下尽量覆盖更多的高级特性。这样做的主要目的是,通过熟悉简单的语言基础,让读者在构建、运行和体验简单的Go程序的同时体验一下Go语言的强大与独特。当然,本章提及的所有内容都将在后面章节中更详细地阐述。

示例程序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        4 4   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包提供的是平台无关的操作系统级别变量和函数,包括用于保存命令行参数的类型为[]stringos.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语言也支持。但是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)进行并行编程等。

虽然Go语言支持面向对象编程,但它既没有类也没有继承(is-a关系)这样的概念。但是Go语言支持创建自定义类型,而且很容易创建聚合(has-a关系)结构。Go语言也支持将其数据和行为完全分离,同时也支持鸭子类型。鸭子类型是一种强有力的抽象机制,它意味着数据的值(比如传入函数的数据)可以根据该数据提供的方法来被处理,而不管其实际的类型。这个术语是从这条语句演化而来的:“如果它走起来像鸭子,叫起来像鸭子,它就是一只鸭子。”所有这些一起,提供了一种游离于类和继承之外的更加灵活强大的选择。但如果要从Go语言的面向对象特性中获益,习惯于传统方法的我们必须在概念上做一些重大调整。

Go语言使用内置的基础类型如boolintstring等类型来表示数据,或者使用struct来对基本类型进行聚合。Go语言的自定义类型建立在基本类型、struct或者其他自定义类型之上。(我们会在本章后面看到一些简单的例子,参见1.7节。)

Go语言同时支持命名和匿名的自定义类型。相同结构的匿名类型等价,可以相互替换,但是不能有任何方法(这点我们会在6.4节详细阐述)。任何命名的自定义类型都可以有方法,并且这些方法一起构成该类型的接口。命名的自定义类型即使结构完全相同,也不能相互替换(除特别声明之外,本书所指的“自定义类型”都是指命名的自定义类型)。

接口也是一种类型,可以通过指定一组方法的方式定义。接口是抽象的,因此不可以实例化。如果某个具体类型实现了某个接口所有的方法,那么这个类型就被认为实现了该接口。也就是说,这个具体类型的值既可以当做该接口类型的值来使用,也可以当做该具体类型的值来使用。然而,不需要在接口和实现该接口的具体类型之间建立形式上的联接。一个自定义的类型只要实现了某个接口定义的所有方法就是实现了该接口。当然,一个类型可以实现多个接口,只要这个类型同时实现多个接口所定义的所有方法。

空接口(没有定义方法的接口)用interfae{}来表示。由于空接口没有做任何要求(因为它不需要任何方法),它可以用来表示任意值(效果上相当于一个指向任意类型值的指针),无论这个值是一个内置类型的值还是一个自定义类型的值(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构建工具就会将stackstacker都构建好。

包导入的路径使用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语言中以术语“接收器”来称呼

本例中,接收器的类型是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值来表示成功或者失败。

如果我们要修改接收器,就必须将接收器设为一个指针。指针是指一个保存了另一个值的内存地址的变量。使用指针的原因之一是为了效率,比如我们有一个很大的值,传入一个指向该值所在内存地址的指针会比传入该值本身更廉价得多。指针的另外一个用处是使一个值可被修改。例如,当一个变量传入到一个函数中,该函数只得到该值的一份副本(例如,传stackstack.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来表示空指针(以及空引用),即表示指向为空的指针或者引用值为空的引用。这种指针只在条件判断或者赋值的时候用到,而不应该调用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错误值。

由于该方法需要通过删除元素来修改栈,因此它的接收器必须是一个指针类型的值。为了方便,我们在方法内不使用 *stackstack变量实际所指向的栈)这样的语法,而是将其赋值给一个临时变量(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语言也会很智能地将该指针解引用,并将该指针所指的值传递给方法。

正如本例所示,在Go语言中创建自定义类型通常非常简单明了,无需引入其他语言中的各种笨重的形式。Go语言的面向对象特性将在第6章中详细阐述。

为了满足实际需求,一门编程语言必须提供某些方式来读写外部数据。在前面的小节中,我们概览了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.Readerio.Writer接口。io/ioutil包提供了一系列高级文件处理函数。regexp包则提供了强大的正则表达式支持。其他的包(fmtlogfilepathstrings)已在本书之前介绍过。

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,说明变量inFilenameoutFilename中包含字符串(可能为空),程序继续。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.Filenil错误值;如果文件打开失败,则返回一个nil文件句柄和相应非nilerror值。

返回的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机制的目的是用来处理真正的异常(即不可预料的异常)而非常规错误。

两个文件都成功打开后(os.Stdinos.Stdoutos.Stderr文件是由Go语言的运行时系统自动打开的),我们将要处理的文件传给americanise()函数,由该函数对文件进行处理。如果americanse()函数返回nil值,main()函数将正常终止,所有被延迟的语句(在这里是指关闭inFileoutFile文件,如果它们不是os.Stdinos.Stdout的话)都将被一一执行。如果err的值不是nil,则错误会被打印出来,程序退出,Go语言的运行时系统会自动将所有打开的文件关闭。

americanise()函数的参数是io.Readerio.Writer接口, 但我们传入的是 *os.File,原因很简单,因为os.File类型实现了io.ReadWriter结构(而io.ReadWriterio.Readerio.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()函数的时候,我们会更加深入这个主题。)

函数先判断用户是否需要打印帮助信息。如果是,就用fmt.Errorf()函数来创建一个新的error值,打印合适的用法,并立即返回。与普通的Go语言代码一样,这个函数也要求调用者检查返回的error值,从而做出相应的处理。这也是main()函数的做法。fmt.Errorf()函数与我们之前所看的fmt.Printf()函数类似,不同之处是它返回一个错误值,其中包含由给定的字符串格式和参数生成的字符串,而非将字符串输出到os.Stdout中(errors.New函数使用一个给定的字符串来生成一个错误值)。

如果用户不需要打印帮助信息,我们再检查他是否输入了命令行参数。如果用户输入了参数,我们将其输入的第一个命令行参数存放到inFilename中,将第二个命令行参数存放到outFilename中。当然,用户也可能没有输入命令行参数,这样inFilenameoutFilename变量都为空。或者他们也可能只传入了一个参数,其中inFilename有文件名而outFilename为空。

最后,我们再做一些完整性检查,以保证不会用输出文件来覆盖输入文件,并在必要时退出。如果一切都如预期所料,则正常返回。带返回值的函数或方法中必须至少有一个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()函数为inFileoutFile分别创建了一个reader和writer,然后从输入文件中逐行读取数据,然后将所有英式英语词汇替换成等价的美式英语词汇,并将处理结果逐行写入到输出文件中。

只需要往bufio.NewReader()函数里传入任何一个实现了io.Reader接口的值(即实现了Read()方法),就能得到一个带有缓冲的reader,bufio.NewWriter()函数也类似。需要注意的是,americanise()函数不知道也不用关心它从何处读,写向何处,比如reader和writer可以是压缩文件、网络连接、字节切片,只要是任何实现io.Readerio.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) stringreplacer函数作为输入,每发现一个匹配的值就调用一次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语句能非常直接简单明了地避免资源泄露。

Go语言的一个关键特性在于其充分利用现代计算机的多处理器和多核的功能,且无需给程序员带来太大负担。完全无需任何显式锁就可写出许多并发程序(虽然Go语言也提供了锁原语以便在底层代码需要用到时使用,我们将在第7章中详细阐述)。

Go语言有两个特性使得用它来做并发编程非常轻松。第一,无需继承什么“线程”(thread)类(这在Go语言中其实也不可能)即可轻易地创建goroutine(实际上是非常轻量级的线程或者协程)。第二,通道(channel)为goroutine之间提供了类型安全的单向或者双向通信,这也可以用来同步goroutine。

Go语言处理并发的方式是传递数据,而非共享数据。这使得与使用传统的线程和锁方式相比,用Go语言来编写并发程序更为简单。由于没有使用共享数据,我们不会进入竞态条件(例如死锁),我们也不必记住何时该加锁和解锁,因为没有共享的数据需要保护。

本节中,我们会看看本章中的第五个也是最后一个“概览”示例。这节的例子使用两个通信通道,并且在一个独立的goroutine中处理数据。对于这样一种小巧的程序而言,这显然是大材小用,但这样做的目的是为了以尽量简洁的方式来讲解这些Go语言功能的基本使用方式。我们将在第7章展示一个更加实用的并发示例,它会给出许多一起使用通道和goroutine的不同做法。

我们将要讲解的这个程序叫做polar2cartesian。这是一个交互型的命令行程序,首先提示用户输入两个由空格分隔的数字:一个半径和一个角度,然后该程序使用它们来计算相应的笛卡儿坐标。除了会介绍一种并发编程的特定实现方式,这个示例也展示一些简单的结构体(struct)类型,以及如何确定程序是运行在一个类Unix系统上还是运行在Windows系统上,因为两个系统的不同点值得关注。这里有一个在Linux的终端下运行的示例程序:

$ ./polar2cartesian
Enter a radius and an angle (in degrees), e.g., 12.5 90, or Ctrl+D to quit.
Radius and angle: 5 30.5
Polar radius=5.00 θ=30.50° →Cartesian x=4.31 y=2.54
Radius and angle: 5 -30.25
Polar radius=5.00 θ=-30.25° →Cartesian x=4.32 y=-2.52
Radius and angle: 1.0 90
Polar radius=1.00 θ=90.00° →Cartesian x=-0.00 y=1.00
Radius and angle: ^D
$

这个程序的源文件位于polar2cartesian/polar2cartesian.go,我们将自上而下地解读它,先是导入包,我们用到结构体(struct),接着是init()函数、main()函数,然后是被main()函数调用的函数等。

import (
     "bufio"
     "fmt"
     "math"
     "os"
     "runtime"
)

这是polar2cartesian程序导入的几个包,其中有些在前面几节中提到过,因此我们只在这里提提新引入的包。math包提供了操作浮点数的数学函数(参见2.3.2节),而runtime包提供了一些运行时控制,例如可以知道该程序运行在哪个平台上。

type polar struct {
     radius  float64
     θ         float64
}

type cartesian struct {
     x    float64
     y    float64
}

Go语言的结构体是一种能够用来保存(聚合或者嵌入)一个或者多个数据字段的类型。这些字段可以是像本例所采用的内置类型(float64)、结构体、接口,或者所有这些类型的组合。(一个接口类型的数据字段其实只是一个指向任意类型值的指针,该类型实现了这个接口,也就是实现了该接口所声明的所有方法。)

我们很自然地使用了小写的希腊字母θ来表示极坐标的角度,这在Go语言中很容易做到,因为Go语言支持UTF-8编码的字符。Go语言允许我们使用任何Unicode字符作为我们的标识符,而不限于英文字母。

虽然这两个结构体恰好包含了完全相同的字段类型,但它们仍属不同类型,两者之间也不能自动地相互转换。这也可以认为是防御性编程,毕竟用一个极坐标来代替一个笛卡儿坐标也不合理。在有些情况下这种转换是有意义的,这样我们可以轻易地创建一个转换方法(也就是该类型的某个方法可以返回另一个类型),它能够充分利用Go语言的组合特性来从一个源类型创建另一个目标类型(数值数据类型的转换将在第2章中详述。字符串类型的转换将在第3章中详述)。

var prompt = "Enter a radius and an angle (in degrees), e.g., 12.5 90, " + "or %s to quit."

func init() {
     if runtime.GOOS == "windows" {
          prompt  = fmt.Sprintf(prompt, "Ctrl+Z, Enter")
     } else { //类Unix
          prompt = fmt.Sprintf(prompt, "Ctrl+D")
     }
}

如果一个包里包含了一个或多个init()函数,那么它们会在main()函数之前被自动执行,而且init()函数不能被显式调用。因此当我们的polar2cartesian程序启动时,这个init()函数会首先被调用。这里我们使用不同的init()函数来为不同的平台设置不同的提示信息,因为不同的平台文件结束的标志是不同的,例如在Windows平台上是使用Ctrl+Z然后按回车键来结束文件。runtime包提供了一个字符串类型的常量GOOS来标示程序所运行的操作系统,其常用值为darwin(Mac OS X)freebsdlinux以及windows

在深入剖析main()函数以及剩下的程序之前,让我们先简单地介绍一下通道,并在使用它之前看一些好玩的小示例。

通道是基于Unix上管道的思想而被设计出来的,它提供了双向(或者如我们这里用到的单向)数据通信。通道的行为跟FIFO(先进先出)队列一样,因此它们会保留发送给它们的数据的先后顺序。通道中的数据不能被删除,但我们可以随便忽略任何或者所有接收到的数据。让我们看一个非常简单的例子,首先我们创建一个通道:

messages := make(chan string, 10)

我们使用make()函数来创建一个通道,其声明的语法为chan Type。这里我们创建了一个名为messages的通道,用来发送和接收字符串消息。make()函数的第二个参数是通道缓冲区的大小(其默认值为0)。这里我们将其设置得足够大,以便能够容纳10个字符串。如果通道的缓冲区满了,就会发生阻塞,直到其中的至少一个项被接收。这也意味着可以向一个通道传入任意数量的项,因为其中的数据会不断地被取回,而给后面的数据腾出足够的空间。如果另一端在等待接收一个数据,那么一个缓冲大小为0的缓冲只可以发送一个数据(也可以使用Go语言的select语句来得到非阻塞通道的效果,我们将在第7章阐述)。

现在,让我们来发送一些字符串到通道里:

messages <- "Leader"
messages <- "Follower"

当<-通信操作符用做二元操作符时,它的左操作数必须是一个通道,右操作数必须是发往该通道的数据,其类型为通道声明时所能接收的类型。这里,我们先将字符串Leader发往通道,然后再将字符串Follower发往通道。

message1 := <-messages
message2 := <-messages

当<-通信操作符用做一元操作符时只有一个左操作数(必须是一个通道),它是一个接收器,一直阻塞直到获得一个可以返回的数据。这里,我们从该messages通道中取回两条消息。字符串Leader被赋值给变量message1,字符串Follower被赋值给变量message2,这两个变量都是字符串类型的。

通常情况下通道用于goroutine之间的通信。通道在发送和接收数据时无需加锁,而其阻塞的特性可以用于达到线程同步的效果。

我们已经了解了一些关于通道的基本知识,现在让我们在实际代码中看看通道和goroutine的使用。

func main() {
     questions := make(chan polar)
     defer close(questions)
     answers := createSolver(questions)
     defer close(answers)
     interact(questions, answers)
}

一旦有任一init()函数返回,Go语言的运行时系统就会调用main包的main()函数。

在这个示例里,main()函数先创建了一个用来传输polar结构体信息的通道(通道类型为chan polar),然后将其赋给questions变量。一旦通道创建好之后,我们使用defer语句调用内置的close()函数来保证在该通道被使用完毕之后能被正常关闭。接下来我们调用createSolver()函数,将questions传递给它,返回一个名为answers的通道用于接收消息。我们使用另一个defer语句来保证answers在使用完后能够被正常关闭。最后我们将这两个通道传递给interact()函数,接下来的工作就交给用户交互了。

func createSolver(questions chan polar) chan cartesian {
     answers := make(chan cartesian)
     go func() {
        for {
               polarCoord := <-questions❶
               θ := polarCoord.θ * math.Pi / 180.0 // 度变弧度
               x := polarCoord.radius * math.Cos(θ) 
               y := polarCoord.radius * math.Sin(θ)
               answers <- cartesian{x, y} ❷
          }
     }()
     return answers
}

createSolver()函数首先创建了一个名为answers的通道,然后往里面发送接收到的问题(极坐标)的答案(笛卡儿坐标)。

在通道创建后,该函数然后调用了一个go语句。go语句接受一个函数调用(这种语法类似于defer语句),这会创建一个独立的异步goroutine来执行这个函数。这也意味着当前函数的控制流程会继续向下执行,比如我们这里go语句之后就是一个return语句,它将answers返回给调用者。前面我们已经知道,在Go语言里返回本地变量是非常安全的,因为Go语言会为我们打理一切内存管理的杂事。

在这个go语句里我们创建了一个匿名函数,该函数有一个无限循环体处于阻塞等待状态(但不会阻塞其他goroutine,也不会阻塞创建该goroutine的函数),直到它接收到一个问题(本例中是一个定义在polar结构体上的questions通道)。当收到一个极坐标时,该匿名函数通过一定的数学计算(使用标准库中的math包)得出相应的笛卡儿坐标,然后使用Go语言的组合语法将其结果创建成为一个cartesian结构体发送给answers

在语句❶中,<-操作符作为一元操作符使用,它从questions通道中获取一个极坐标。而语句❷则作为二元运算符使用,它的左操作数是用于接收数据的answers通道,右操作数则是用于发送数据的cartesian结构体。

一旦对函数createSolver()的调用完成,我们就有了两个通信通道,还有一个独立的goroutine用于等待极坐标发送到questions上,而其他包括执行main()函数在内的goroutine则不会阻塞。

const result = "Polar radius=%.02f θ=%.02f° →Cartesian x=%.02f y=%.02f\n"

func interact(questions chan polar, answers chan cartesian) {
     reader := bufio.NewReader(os.Stdin)
     fmt.Println(prompt)
     for {
          fmt.Println("Radius and angle: ")
          line, err := reader.ReadString('\n')
          if err != nil {
               break
          }
          var radius, θfloat64
          if _, err := fmt.Sscan(line, "%f %f", &radius, &θ); err != nil {
              fmt.Println(os.Stderr, "invalid input")
              continue
          }
          questions <- polar{radius, θ}
          coord := <-answers
          fmt.Printf(result, radius, θ, coord.x, coord.y)
     }
     fmt.Println()
}

调用这个函数时需传入两个通道作为参数。由于我们需要在控制台上跟用户交互,因此该函数开始处为os.Stdin创建了一个带缓冲的reader。然后打印提示符告诉用户输入什么,怎样输入,以及怎样退出。如果用户只按了一个回车键(没有输入任何数字),那么我们就直接退出程序,而不是还让用户输入文件的结束符。然而,通过要求用户输入文件结束符,我们可以使得polar2cartesian程序更加灵活,因为这样就可以从任意的外部文件中获得输入了(假设输入的文件每行只有两个由空格分隔的数字)。

随后函数就进入了无限循环,提示用户输入极坐标(一个半径和一个角)。要求用户输入数据后,函数会等待用户输入某些文字然后再按回车键,或者按Ctrl+D键(在Windows上是按Ctrl+Z和回车键)来表示用户输入结束。我们并没有检查返回的错误值,如果它不为nil,我们就退出循环并返回到调用者main()函数,然后main()函数会随后退出(同时调用它的延迟执行语句来关闭通道)。

我们创建了两个float64类型的变量来保存用户输入,然后使用fmt.Sscanf()函数来解析每一行。该函数接受一个字符串作为待解析的输入字符串、字符串的解析格式(在本例中是两个由空格分隔的浮点数),后面紧跟的是一个或者多个用来填充的参数(地址操作符&amp;用于得到指向一个变量的指针。参见4.1节)。该函数返回其成功解析的元素数量和一个error值(或者为nil)。万一发生错误,我们将错误信息打印到os.Stderr,这样可以使得即使将程序的os.Stdout重定向到一个文件,其错误信息在控制台也能够看到。Go语言的这些强大而又灵活的扫描函数将在第8章中详细阐述(参见8.1.3.2节)以及表8-2。

如果用户输入了合法的数字并已经以polar结构体的格式发送到questions通道,那么就会阻塞主goroutine,等待answers通道的响应。createSolver()函数额外创建的一个goroutine会阻塞等待questions通道接收到一个polar类型的数据,因此当我们发送polar数据后,这个goroutine将执行计算,并将计算结果cartesian发送回answers通道,然后等待另一个问题的输入(阻塞其自身)。一旦interact()函数在answers通道上接收到cartesianinteract()就不再阻塞。这样,我们就使用fmt.Printf()函数打印结果信息,并将极坐标和笛卡儿坐标的值输入作为结果字符串的%占位符。这些goroutine和这些通道的关系可以通过图1-1来解释。

图1-1 两个相互通信的goroutine

intaract()函数中的for循环是一个无限循环,在打印一个结果后又让用户输入下一个半径和角度值,直到用户输入了一个文件结束符。这个输入可能来自于用户的交互输入,也可能是因为到达了一个重定向输入文件的末尾。

程序polar2cartesian中的计算非常轻量,因此没必要在另一个独立的goroutine中执行。然而如果一个程序需要做多个相互独立的大规模计算以作为一个输入的结果,使用本文所用到的方法可能会更好,比如为每一个计算都创建一个独立的goroutine。我们会在第7章看到关于通道和goroutine的更加实用的示例。

通过讲解本章中所给出的5个小示例程序,我们完成了对Go语言特性的概述。当然,我们将在本书的后面章节看到,Go语言所提供的远远不止这一章所能写下的。接下来的每一章将专注于Go语言的某一特定主题,以及与该主题相关的标准库。这章的结尾处有个小习题,其量虽小,但是需要些思考和细心才能完成。

bigdigits文件夹复制为另一个文件夹,比如命名为my_bigdigits,修改my_bigdigits/big-digits.go的内容以使得新版本的bigdigits程序可以可选地输出由一条“*”组成的上横线和下横线,并且还带有改进过的命令行参数处理能力。

如果运行程序时没有输入数字作为命令行参数,原来版本的程序会输出其使用信息。请更改该程序,使得用户使用-h或者--help参数的时候程序也能输出使用信息。例如:

$ ./bigdigits --help
usage: bigdigits [-b|--bar] <whole-number>
-b --bar draw an underbar and an overbar

如果运行时没有提供 --bar(或者-b)选项,那么程序的功能应该与原来的版本一样。下面是在给出该参数的情况下程序的预期输出:

$ ./bigdigits --bar 8467243
*****************************************************
 888      4     666   77777   222      4     333 
8   8    44    6          7   2   2    44    3   3 
8   8   4 4    6         7       2    4 4        3 
 888   4  4    6666     7      2    4   4      33 
8   8  444444  6   6   7      2     444444      3 
8   8     4    6   6  7      2          4    3   3
 888      4     666   7      22222     4     333 
*****************************************************

虽然与之前的版本相比,只需更改几行代码就可以在第一行之前输出上横线以及在最后一行后面输出下横行,但该方案需要更加精细的,命令行处理。总而言之,该方案大概需要20行的额外代码,其main()函数的长度需要增加到大概原先的两倍(大约40行),其中大部分代码与处理命令行有关。文件bigdigits_ans/bigdigits.go提供了一个参考答案。

提示:为了防止输出的横线过长,该解决方案与之前输出每行数字的方式稍微有点不同。同时,该解答中需要导入strings包并使用其中的strings.Repeat(string, int) 函数。该函数返回一个字符串,该字符串是由该函数的第一个参数重复第二个参数所指定的次数生成的。何不在本地(参见1.1节中的“Go语言官方文档”)或者到golang.org/pkg/strings查找一下这个函数,开始熟悉下Go语言标准库文档呢?

使用专为处理命令行参数设置的包可以带来很大的便利性。Go语言标准库中既包含一个相对基本的命令行解析包flag以支持X11风格的选项(也就是 -option这样的选项)。另外,godashboard.appspot.com/project上也有许多可选的支持GNU风格的短选项或长选项(也就是 -o--option这样的形式)的命令行参数解析器。

有些Windows编辑器(如记事本)不符合Unicode编码标准,会在UTF-8编码的文件开始处插入0xEF0xBB0xBF字节。本书的示例假设所有的UTF-8文件中不包含这些字节。

由于本书假设使用gc编译器,使用gccgo的读者需要按照链接golang.org/doc/gccgo_install.html中描述的编译和链接过程来进行。类似地,使用其他编译器的读者也需要按照相应的编译器指南来进行编译和链接。

从现在开始我们只给出类Unix系统的命令行,并且假设Windows程序员能够自行转换过来。

为了在文中更好的引用,我们在程序中使用一些简单的语法高亮,有时会高亮一些代码行或者以数字(❶,❷,…)来对代码行进行标注。这些都不是Go语言的组成部分。

与C、C++以及Java语言不同的是,Go语言中的++和−−操作符只可用于语句而非表达式中。更进一步讲,它们可能只可用做后缀操作符而非前缀操作符。这意味着,某些特定顺序的求值问题不会在Go语言中出现——因为,谢天谢地,Go语言中不会写出f(i++)以及A[i]=b[++i]这样的表达式来。

不同于C++,Go语言的struct(结构体)不是伪类。例如,Go语言的struct结构体支持组合与委派,但不支持继承。

Go语言的空接口扮演的是Java中的Object或者C/C++中的void*类型一样的角色。

在其他语言中,接收器一般被称为this或self,使用这种称谓在Go语言中也没问题,但被认为是不太好的Go语言风格。

Go语言中的指针除了不支持指针运算之外(其实也不需要),其他的与C和C++里的是一样的。

Go语言中的nil与C、C++中的NULL或0,或者Java中的null以及Objective-C中的nil是等价的。

这是Go语言不需要类似于C、C++语言中的 -> 操作符的原因。

Go语言的错误处理与C++、Java和Python非常不同,因为这几门语言经常将异常处理机制同时用于处理错误和异常。关于Go语言panic/recover机制的讨论和原理阐述请参见https://groups.google.com/group/golang-nuts/browse_thread/thread/1ce5cd050bb973e4?pli=1

Go的标准库包含有一个flag包用于命令行参数处理。GNU格式命令行参数处理的相关第三方包可以从godashboard.appspot.com/project获取(使用第三方包的内容将在第9章中阐述)。

实际上用户还是可以使用重定向来覆写输入文件的,例如$./americanise infile > infile,但是我们至少防止了很明显的意外。


这是关于过程式编程的四章内容中的第一章,它构成了Go语言编程的基础——无论是过程式编程、面向对象编程、并发编程,还是这些编程方式的任何组合。

本章涵盖了Go语言内置的布尔类型和数值类型,同时简要介绍了一下Go标准库中的数值类型。本章将介绍,除了各种数值类型之间需要进行显式类型转换以及内置了复数类型外,从C、C++以及Java等语言转过来的程序员还会有更多惊喜。

本章第一小节讲解了Go语言的基础,比如如何写注释,Go语言的关键字和操作符,一个合法标识符的构成,等等。一旦这些基础性的东西讲解完后,接下来的小节将讲解布尔类型、整型以及浮点型,之后也对复数进行了介绍。

Go语言支持两种类型的注释,都是从C++借鉴而来的。行注释以//开始,直到出现换行符时结束。行注释被编译器简单当做一个换行符。块注释以/*开头,以*/结尾,可能包含多个行。如果块注释只占用了一行(即/* inline comment*/),编译器把它当做一个空格,但是如果该块注释占用了多行,编译器就把它当做一个换行符。(我们将在第5章看到,换行符在Go语言中非常重要。)

Go标识符是一个非空的字母或数字串,其中第一个字符必须是字母,该标识符也不能是关键字的名字。字母可以是一个下划线_,或者Unicode编码分类中的任何字符,如大写字母“Lu”(letter,uppercase)、小写字母“Ll”(letter,lowercase)、首字母大写“Lt”(letter,titlecase)、修饰符字母“Lm”(letter, modifier)或者其他字母,“Lo”(letter,other)。这些字符包含所有的英文字母(A~Z以及a~z)。数字则是Unicode编码"Nd"分类(number, decimal digit)中的任何字符,这些字符包括阿拉伯数字0~9。编译器不允许使用与某个关键字(见表2-1)一样的名字作为标识符。

表2-1 Go语言的关键字

break

default

func

interface

select

case

defer

go

map

struct

chan

else

goto

package

switch

const

fallthrough

if

range

type

contiue

for

import

return

var

Go语言预先定义了许多标识符(见表2-2),虽然可以定义与这些预定义的标识符名字一样的标识符,但是这样做通常很不明智。

表2-2 Go语言预定义的标识符

append

copy

int8

nil

true

bool

delete

int16

Panic

uint

byte

error

int32

print

uint8

cap

false

int64

println

uint16

close

float32

iota

real

uint32

complex

float64

len

recover

uint64

complex64

imag

make

rune

uintptr

complex128

int

new

string

标识符都是区分大小写的,因此LINECOUNTLinecountLineCountlineCountlinecount是5个不一样的标识符。以大写字母开头的标识符,即Unicode分类中属于“Lu”的字母(包含A~Z),是公开的——以Go语言的术语来说就是导出的,而任何其他的标识符都是私有的——用Go语言的术语来说就是未导出的。(这项规则不适用于包的名字,包名约定为全小写。)第6章讨论面向对象编程以及第9章讨论包时,我们会在实际的代码中看到这两者的区别。

空标识符“_”是一个占位符,它用于在赋值操作的时候将某个值赋值给空标识符,从而达到丢弃该值的目的。空标识符不是一个新的变量,因此将它用于:=操作符的时候,必须同时为至少另一个值赋值。通过将函数的某个甚至是所有返回值赋值给空标识符的形式将其丢弃是合法的。然而,如果不需要得到函数的任何返回值,更为方便的做法是简单地忽略它。这里有些例子:

count, err = fmt.Println(x)      // 获取打印的字节数以及相应的error值
count, _ = fmt.Println(x)         // 获取打印的字节数,丢弃error值
_, err = fmt.Println(x)           // 丢弃所打印的字节数,并返回error值
fmt.Println(x)                    // 忽略所有返回值

打印到终端的时候忽略返回值很常见,但是使用fmt.Fprint()以及类似函数打印到文件和网络连接等情况时,则应该检查返回的错误值。(Go语言的打印函数将在3.5节详细介绍。)

常量使用关键字const声明;变量可以使用关键字var声明,也可以使用快捷变量声明语法。Go语言可以自动推断出所声明变量的类型,但是如果需要,显式指定其类型也是合法的,比如声明一种与Go语言的常规推断不同的类型。下面是一些声明的例子:

const limit = 512               // 常量,其类型兼容任何数字
const top uint16 = 1421         // 常量,类型:uint16
start := -19                    // 变量,推断类型:int
end := int64(9876543210)        // 变量,类型:int64
var i int                       // 变量,值为0,类型:int
var debug = false               // 变量,推断类型:bool
checkResults := true            // 变量,推断类型:bool
stepSize := 1.5                 // 变量,推断类型:float64
acronym := "FOSS"               // 变量,推断类型:string

对于整型字面量Go语言推断其类型为int,对于浮点型字面量Go语言推断其类型为float64,对于复数字面量Go语言推断其类型为complex128(名字上的数字代表它们所占的位数)。通常的做法是不去显式地声明其类型,除非我们需要使用一个Go语言无法推断的特殊类型。这点我们会在2.3节中讨论。指定类型的数值常量(即这里的top)只可用于别的数值类型相同的表达式中(除非经过转换)。未指定类型的数值常量可用于别的数值类型为任何内置类型的表达式中(例如,常量limit可以用于包含整型或者浮点型数值的表达式中)。

变量i并没有显式的初始化。这在Go语言中非常安全,因为如果没有显式初始化,Go语言总是会将零值赋值给该变量。这意味着每一个数值变量的默认值都保证为0,而每个字符串都默认为空。这可以保证Go程序避免遭受其他语言中的未初始化的垃圾值之灾。

枚举

需要设置多个常量的时候,我们不必重复使用const关键字,只需使用const关键字一次就可以将所有常量声明组合在一起。(第1章中我们导入包的时候使用了相同的语法。该语法也可以用于使用var关键字来声明一组变量。)如果我们只希望所声明的常量值不同,并不关心其值是多少,那么可以使用Go语言中相对比较简陋的枚举语法。

const Cyan = 0
const Magenta = 1
const Yellow = 2


const (
    Cyan = 0
    Magenta = 1
    CYellow = 2
)
const (
    Cyan = iota //0
    Magenta //1
    Yellow //2
)

这3个代码片段的作用完全一样。声明一组常量的方式是,如果第一个常量的值没有被显式设置(设为一个值或者是iota),则它的值为零值,第二个以及随后的常量值则设为前面一个常量的值,或者如果前面常量的值为iota,则将其后续值也设为iota。后续的每一个iota值都比前面的iota值大1。

更正式的,使用iota预定义的标识符表示连续的无类型整数常量。每次关键字const出现时,它的值重设为零值(因此,每次都会定义一组新的常量),而每个常量的声明的增量为1。因此在最右边的代码片段中,所有常量(指MagentaYellow)都被设为iota值。由于Cyan紧跟着一个const关键字,其iota值重设为0,即Cyan的值。Magenta的值也设为iota,但是这里iota的值为1。类似地,Yellow的值也是iota,它的值为2。而且,如果我们在其末尾再添加一个Black(在const组内部),它的值就被隐式地设为iota,这时它的值就是3。

另一方面,如果最右边的代码片段中没有iota标识符,Cyan就会被设为0,而Magenta的值则会设为Cyan的值,Yellow的值则被设为Magenta的值,因此最后它们都被设为零值。类似的,如果Cyan被设为9,那么随后的值也会被设为9。或者,如果Magenta的值设为5,Cyan的值就被设为0(因为是组中的第一个值,并且没有被设为一个显式的值或者iota),Magenta的值就是5(显式地设置),而Yellow的值也是5(前一个常量的值)。

也可以将iota与浮点数、表达式以及自定义类型一起使用。

type BitFlag int
const (
     Active BitFlag = 1 << iota                   // 1 << 0 == 1
     Send     // 隐式地设置成BitFlag = 1 << iota    // 1 << 1 == 2
     Receive //隐式地设置成BitFlag = 1 << iota      // 1 << 2 == 4
)
flag := Active | Send

在这个代码片段中,我们创建了3个自定义类型BitFlag的位标识,并将变量flag(其类型为BitFlag)的值设为其中两个值的按位或(因此flag的值为3,Go语言的按位操作符已在表2-6中给出)。我们可以略去自定义类型,这样Go语言就会认为定义的常量是无类型整数,并将flag的类型推断成整型。BitFlag类型的变量可以保存任何整型值,然而由于BitFlag是一个不同的类型,因此只有将其转换成int型后才能将其与int型数据一起操作(或者将int型数据转换成BitFlag类型数据)。

正如这里所表示的,BitFlag类型非常有用,但是用来调试不太方便。如果我们打印flag的值,那么得到的只是一个3,没有任何标记表示这是什么意思。Go语言很容易控制自定义类型的值如何打印,因为如果某个类型定义了String()方法,那么fmt包中的打印函数就会使用它来进行打印。因此,为了让BitFlag类型可以打印出更多的信息,我们可以给该类型添加一个简单的String()方法。(自定义类型和方法的内容将在第6章详细阐述。)

func (flag BitFlag) String() string {
     var flags []string
     if flag & Active == Active {
          flags = append(flags, "Active")
     }
     if flag & Send == Send {
          flags = append(flags, "Send")
     }
     if flag & Receive == Receive {
          flags = append(flags, "Receive")
     }
     if len(flags) > 0 { // 在这里,int(flag)用于防止无限循环,至关重要!
          return fmt.Sprintf("%d(%s)", int(flag), strings.Join(flags, "|"))
     }
     return "0()"
}

对于已设置好值的位域,该方法构建了一个(可能为空的)字符串切片,并将其以十进制整型表示的位域的值以及表示该值的字符串打印出来。(通过将%d标识符设为%b,我们可以轻易地将该值以二进制整数打印出来。)正如其中的注释所说,当将flag传递给fmt.Sprintf()函数的时候,将其类型转换成底层的int类型至关重要,否则BitFlag.String()方法会在flag上递归地调用,这样就会导致无限的递归调用。(内置的append()函数将在4.2.3节中讲解。fmt.Sprintf()strings.Join()函数将在第3章讲解。)

Println(BitFlag(0), Active, Send, flag, Receive, flag|Receive)
0() 1(Active) 2(Send) 3(Active|Send) 4(Receive) 7(Active|Send|Receive)

上面的代码片段给出了带String()方法的BitFlag类型的打印结果。很明显,与打印纯整数相比,这样的打印结果对于调试代码更有用。

当然,也可以创建表示某个特定范围内的整数的自定义类型,以便创建一个更加精细的自定义枚举类型,我们会在第6章详细阐述自定义类型的内容。Go语言中关于枚举的极简方式是Go哲学的典型:Go语言的目标是为程序员提供他们所需要的一切,包括许多强大而方便的特性,同时又让该语言尽可能地保持简小、连贯而且快速编译和运行。

Go语言提供了内置的布尔值truefalse。Go语言支持标准的逻辑和比较操作,这些操作的结果都是布尔值,如表2-3所示。

表2-3 布尔值和比较操作符

语法

描述/结果

! b

逻辑非操作符,如果表达式b的值为true,则操作结果为false

ab

短路逻辑或操作符,只要布尔表达式a或者b中的任何一个表达式为true,表达式的结果都为true

a&&b

短路逻辑与操作符,如果两个布尔表达式ab都为true,则整个表达式的值为true

x<y

如果表达式x的值小于表达式y的值,则表达式的结果为true

x<=y

如果表达式x的值小于或者等于表达式y的值,则表达式的结果为true

x==y

如果表达式x的值等于表达式y的值,则返回true

x !=y

如果表达式x的值不等于表达式y的值,则返回true

x<=y

如果表达式x的值大于等于表达式y的值,则返回true

x<y

如果表达式x的值大于表达式y的值,则返回true

布尔值和表达式可以用于if语句中,也可以用于for语句的条件中,以及switch语句的case子句的条件判断中,这些都将在第5章讲述。

二元逻辑操作符(||&&)使用短路逻辑。这意味着如果我们的表达式是b1||b2,并且表达式b1的值为true,那么无论b2的值为什么,表达式的结果都为true,因此b2的值不会再计算而直接返回true。类似地,如果我们的表达式为b1&&b2,而表达式b1的计算结果为false,那么无论表达式b2的值是什么,都不会再计算它的值,而直接返回false

Go语言会严格筛选用于使用比较操作符(<<===!=>=、>)进行比较的值。这两个值必须是相同类型的,或者如果它们是接口,就必须实现了相同的接口类型。如果有一个值是常量,那么它的类型必须与另一个类型相兼容。这意味着一个无类型的数值常量可以跟另一个任意数值类型的值进行比较,但是不同类型且非常量的数值不能直接比较,除非其中一个被显式的转换成与另一个相同类型的值。(数字之间转换的内容已在2.3节讨论过。)

==!=操作符可以用于任何可比较的类型,包括数组和结构体,只要它们的元素和成员变量与==!=操作符相兼容。这些操作符不能用于比较切片,尽管这种比较可以通过Go标准库中的reflect.DeepEqual()函数来完成。==!=操作符可以用于比较两个指针和接口,或者将指针、接口或者引用(比如指向通道、映射或切片)与nil比较。别的比较操作符(<<=>=>)只适用于数字和字符串。(由于Go也跟C和Java一样,不支持操作符重载,对于我们自定义的类型,如果需要,可以实现自己的比较方法或者函数,如Less()或者Equal(),详见第6章。)

Go语言提供了大量内置的数值类型,标准库也提供了big.Int类型的整数和big.Rat类型的有理数,这些都是大小不限的(只限于机器的内存)。每一个数值类型都不同,这意味着我们不能在不同的类型(例如,类型int32和类型int)之间进行二进制数值运算或者比较操作(如+或者<)。无类型的数值常量可以兼容表达式中任何(内置的)类型的数值,因此我们可以直接将一个无类型的数值常量与另一个数值做加法,或者将一个无类型的常量与另一个数值进行比较,无论另一个数值是什么类型(但必须为内置类型)。

如果我们需要在不同的数值类型之间进行数值运算或者比较操作,就必须进行类型转换,通常是将类型转换成最大的类型以防止精度丢失。类型转换采用type(value)的形式,只要合法,就总能转换成功——即使会导致数据丢失。请看下面的例子。

const factor = 3         // factor与任何数值类型兼容
i := 20000               // 通过推断得出i的类型为int
i *= factor
j := int16(20)           // j的类型为int16,与这样定义效果一样:var j int16 = 20
i += int(j)              // 类型必须匹配,因此需要转换
k := uint8(0)            // 效果与这样定义一样:var k uint8
k = uint8(i)             // 转换成功,但是k的值被截为8位
fmt.Println(i, j, k)     // 打印:60020 20 16

为了执行缩小尺寸的类型转换,我们可以创建合适的函数。例如:

func Uint8FromInt(x int) (uint8, error) {
     if 0 <= x && x <= math.MaxUint8 {
         return uint8(x), nil
     }
     return 0, fmt.Errorf("%d is out of the uint8 range", x)
}

该函数接受一个int型参数,如果给定的int值在给定的范围内,则返回一个uint8nil,否则返回0和相应的错误值。math.MaxUint8常量来自于math包,该包中也有一些类似的Go语言中其他内置类型的常量。(当然,无符号的类型没有最小值常量,因为它们的最小值都为0。)fmt.Errorf()函数返回一个基于给定的格式化字符串和值创建的错误值。(字符串格式化的内容将在3.5节讨论。)

相同类型的数值可以使用比较操作符进行比较(参见表2-3)。类似地,Go语言的算术操作符可以用于数值。表2-4给出的算术运算操作符可用于任何内置的数值,而表2-6给出的算术运算操作符适用于任何整型值。

表2-4 可用于任何内置的数值的算术运算操作符

语法

描述/结果

+ x

x

- x

x 的负值

x ++

x加上一个无类型的常量1

x --

x减去一个无类型的常量1

x += y

x加上y

x -= y

x减去y

x *= y

x乘以y

x /= y

x除以y,如果这些数字都是整数那么任何余数都被丢弃,除以0会导致运行时异常

x + y

xy的和

x - y

x 减去y的结果

x * y

x 乘以y的结果

x / y

x 除以y的结果,如果这些数字都是整数那么任何余数都被丢弃,除以0会导致运行时异常

常量表达式的值在编译时计算,它们可能使用任何算术、布尔以及比较操作符。例如:

const (
     efri  int64 = 10000000000 // 类型:int64
     hlutföllum = 16.0 / 9.0  // 类型:float64
     mælikvarða = complex(-2, 3.5) * hlutföllum // 类型:complex128
     erGjaldgengur = 0.0 <= hlutföllum && hlutföllum < 2.0 // 类型: bool
)

该例子使用冰岛语标识符表示Go语言完全支持本土语言的标识符。(我们马上会讨论complex(),参见2.3.2节。)

虽然Go语言的优先级规则比较合理(即不像C和C++那样),我们还是推荐使用括号来保证清晰的含义。强烈推荐使用多种语言进行编程的程序员使用括号,以避免犯一些难以发现的错误。

Go语言提供了11种整型,包括5种有符号的和5种无符号的,再加上1种用于存储指针的整型类型。它们的名字和值在表2-5中给出。另外,Go语言允许使用byte来作为无符号uint8类型的同义词,并且使用单个字符(即Unicode码点)的时候提倡使用rune来代替int32。大多数情况下,我们只需要一种整型,即int。它可以用于循环计数器、数组和切片索引,以及任何通用目的的整型运算符。通常,该类型的处理速度也是最快的。本书撰写时,int类型表示成一个有符号的32位整型(即使在64位平台上也是这样的),但在Go语言的新版本中可能会改成64位的。

表2-5 Go语言的整数类型及其范围

类型

取值范围

byte

等同于uint8

int

依赖不同平台下的实现,可以是int32或者int64

int8

[−128, 127]

int16

[−32 768, 32 767]

int32

[−2 147 483 648, 2 147 483 647]

int64

[−9 223 372 036 854 775 808, 9 223 372 036 854 775 807]

rune

等同于uint32

uint

依赖不同平台下的实现,可以是uint32或者uint64

uint8

[0, 255]

uint16

[0, 65 535]

uint32

[0, 4 294 967 295]

uint64

[0, 18 446 744 073 709 551 615]

uintptr

一个可以恰好容纳指针值的无符号整数类型(对32位平台是uint32,对64位平台是uint64

从外部程序(如从文件或者网络连接)读写整数时,可能需要别的整数类型。这种情况下需要确切地知道需要读写多少位,以便处理该整数时不会发生错乱。

常用的做法是将一个整数以int类型存储在内存中,然后在读写该整数的时候将该值显式地转换为有符号的固定尺寸的整数类型。byte(uint8)类型用于读或者写原始的字节。例如,用于处理UTF-8编码的文本。在前一章的americanise示例中,我们讨论了读写UTF-8编码的文本的基本方式,第8章中我们会继续讲解如何读写内置以及自定义的数据类型。

Go语言的整型支持表2-4中所列的所有算术运算,同时它们也支持表2-6中所列出的算术和位运算。所有这些操作的行为都是可预期的,特别是本书给出了很多示例,因此无需更深入讨论。

表2-6 只适用于内置的整数类型的算术运算操作符

语法

含义/结果

^ x

按位取反

x %= y

x的值设为x除以y的余数;除0会导致一个运行时异常

x & = y

x的值设为xy按位与(AND)的结果

x|= y

x的值设为xy按位或(OR)的结果

x ^= y

x的值设为xy按位异或(XOR)的结果

x & ^= y

x的值设为xy按位与非(ANDNOT)的结果

x >>= u

x的值设为x右移u个位的结果

x <<= u

x的值设为x左移u个位的结果

x % y

结果为x除以y的余数

x & y

结果为xy按位与(AND)

x| y

结果为xy按位或(OR)

x ^ y

结果为xy按位异或(XOR)

x & ^ y

结果为xy按位与非(ANDNOT)

x << u

结果为x左移u个位

x >> u

结果为x右移u个位

将一个更小类型的整数转换成一个更大类型的整数总是安全的(例如,从int16转换成int32),但是如果向下转换一个太大的整数到一个目标类型或者将一个负整数转换成一个无符号整数,则会产生无声的截断或者一个不可预期的值。这种情况下最好使用一个自定义的向下转换函数,如前文给出的那个。当然,当试图向下转换一个字面量时(如int8(200)),编译器会检测到问题,并报告异常错误。也可以使用标准Go语法将整数转换成浮点型数字(如float64(integer))。

有些情况下,Go语言对64位整数的支持让使用大规格的整数来进行高精度计算成为可能。例如,在商业上计算财务时使用int64类型的整数来表示百万分之一美分,可以使得在数十亿美元之内计算还保持着足够高的精度,这样做有很多用途,特别是当我们很关心除法操作的时候。如果计算财务时需要完美的精度,并且需要避免余数错误,我们可以使用big.Rat类型。

大整数

有时我们需要使用甚至超过int64位和uint64位的数字进行完美的计算。这种情况下,我们就不能使用浮点数了,因为它们表示的是近似值。幸运的是,Go语言的标准库提供了两个无限精度的整数类型:用于整数的big.Int型以及用于有理数的big.Rat型(即包括可以表示成分数的数字如和1.1496,但不包括无理数如e或者π)。这些整数类型可以保存任意数量的数字——只要机器内存足够大,但是其处理速度远比内置的整型慢。

Go语言也像C和Java一样不支持操作符重载,提供给big.Intbig.Rat类型的方法有它自己的名字,如Add()Mul()。在大多数情况下,方法会修改它们的接收器(即调用它们的大整数),同时会返回该接收器来支持链式操作。我们并没有列出math/big包中提供的所有函数和方法,它们都可以在文档上查到,并且也可能在本书出版之后又添加了新内容。但是,我们会给出一个具有代表性的例子来看看big.Int是如何使用的。

使用Go语言内置的float64类型,我们可以很精确地计算包含大约15位小数的情况,这在大多数情况下足够了。但是,如果我们想要计算包含更多位小数,即数十个甚至上百个小数时,例如计算π的时候,那么就没有内置的类型可以满足了。

1706年,约翰·梅钦(John Machin)发明了一个计算任意精度π值的公式(见图2-1),我们可以将该公式与Go标准库中的big.Int结合起来计算π,以得到任意位数的值。在图2-1中给出了该公式以及它依赖的arccot()函数。(理解这里介绍的big.Int包的使用无需理解梅钦的公式。)我们实现的arccot()函数接受一个额外的参数来限制计算结果的精度,以防止超出所需的小数位数。

图2-1 Machin的公式

整个程序在文件pi_by_digits/pi_by_digits.go中,不到80行。下面是它的main()函数

func main() {
     places := handleCommandLine(1000)
     scaledPi := fmt.Sprint((places))
     fmt.Printf("3.%s\n", scaledPi[1:])
}

该程序假设默认的小数位数为1 000,但是用户可以在命令行中指定任意的小数位数。handleCommandLine()函数(这里没有给出)返回传递给它的值,或者是用户从命令行输入的数字(如果有并且是合法的话)。π()函数将π以big.Int型返回,它的值为314159…。我们将该值打印到一个字符串,然后将字符串以适当的格式打印到终端,以便看起来像3.1415926535897 9323846264338327950288419716939937510这样(这里我们打印了将近50位)。

func π(places int) *big.Int {
     digits := big.NewInt(int64(places))
     unity := big.NewInt(0)
     ten := big.NewInt(10)
     exponent := big.NewInt(0)
     unity.Exp(ten, exponent.Add(digits, ten), nil)  ❶
     pi := big.NewInt(4)
     left := arccot(big.NewInt(5), unity)
     left.Mul(left, big.NewInt(4))❷
     right := arccot(big.NewInt(239), unity)
     left.Sub(left, right)
     pi.Mul(pi, left)❸
     return pi.Div(pi, big.NewInt(0).Exp(ten, ten, nil))  ❹
}

π()函数开始时计算unity变量的值(10digits+10),我们将其当做一个放大因子来使用,以便计算的时候可以使用整数。为了防止余数错误,使用+10操作为用户添加额外10个数字。然后,我们使用了梅钦公式,以及我们修改过的接受unity变量作为其第二个参数的arccot()函数(没有给出)。最后,我们返回除以1010的结果,以还原放大因子unity的效果。

为了让unity变量保存正确的值,我们开始创建4个变量,它们的类型都是*big.Int(即指向big.Int的指针,参见4.1节)。unityexponent变量都被初始化成0,变量ten初始化成10,digits被初始化成用户请求的数字的位数。unity值的计算一行就完成了(❶)。big.Int.Add()方法往变量digits中添加了10。然后big.Int.Exp()方法用于将10增大到它的第二个参数(digits+10)的幂。如果第三个参数像这里一样是nilbig.Int.Exp(x, y, nil)进行xy计算。如果3个参数都是非空的,big.Int.Exp(x, y, z)执行(xyz)。值得注意的是,我们无需将结果赋给unity变量,这是因为大部分big.Int方法返回的同时会修改它的接收器,因此在这里unity被修改成包含结果值。

接下来的计算模式类似。我们为pi设置一个初始值4,然后返回梅钦公式内部的左半部分。创建完成之后,我们无需将left的值赋回去(❷),因为big.Int.Mul()方法会在返回时将结果(我们可以安全地忽略它)保存回其接收器中(在本例中即保存回left变量中)。接下来,我们计算公式内部右半部分的值,并从left中减去right的值(将其结果保存在left)中。现在我们用pi(其值为4)乘以left(它保存了梅钦公式的结果)。这样就得到了结果,只是被放大了unity倍。因此,在最后一行中(❹),我们将其值除以(1010)以还原其结果。

使用big.Int类型需小心,因为它的大多数方法都会修改它的接收器(这样做是为了节省创建大量临时big.Int值的开销)。与执行pi×left计算并将计算结果保存在pi中的那一行(❸)相比,我们计算pi÷1010并将结果立即返回(❹),而无需关心pi的值最后已经被修改。

无论什么时候,最好只使用int类型,如果int型不能满足则使用int64型,或者如果不是特别关心它们的近似值,则可以使用float32或者float64类型。然而,如果计算需要完美的精度,并且我们愿意付出使用内存和处理器的代价,那么就使用big.Int或者big.Rat类型。后者在处理财务计算时特别有用。进行浮点计算时,如果需要可以像这里所做的那样对数值进行放大。

Go语言提供了两种类型的浮点类型和两种类型的复数类型,它们的名字及相应的范围在表2-7中给出。浮点型数字在Go语言中以广泛使用的IEEE-754格式表示(http://en.wikipedia.org/wiki/IEEE_754-2008)。该格式也是很多处理器以及浮点数单元所使用的原生格式,因此大多数情况下Go语言能够充分利用硬件对浮点数的支持。

表2-7 Go语言的浮点类型

类型

范围

float32

±3.402 823 466 385 288 598 117 041 834 845 169 254 40×1038尾数部分计算精度大概是7个十进制数

float64

±1.797 693 134 862 315 708 145 274 237 317 043 567 981×10308尾数部分计算精度大概是15个十进制数

complex64

实部和虚部都是一个float32

complex128

实部和虚部都是一个float64

Go语言的浮点数支持表2-4中所有的算术运算。math包中的大多数常量以及所有函数都在表2-8和表2-10中列出。

表2-8 math包中的常量与函数 #1

除非特殊说明,math包中的所有函数都接受并且返回float64数据。所有给出的常量都截成小数点后面包含15位,以更好地适应表。

语法

含义/结果

math.Abs( x )

x|,即x的绝对值

math.Acos( x )

以弧度为单位的x的反余弦值

math.Acosh( x )

以弧度为单位的x的反双曲余弦值

math.Asin( x )

以弧度为单位的x的反正弦值

math.Asinh( x )

以弧度为单位的x的反双曲正弦值

math.Atan( x )

以弧度为单位的x的反正切值

math.Atan2( y, x)

坐标系x正方向与射线 (x,y) 构成的角度的反正切值

math.Atanh( x )

以弧度为单位的x的反双曲正切值

math.Cbrt( x )

,即x的开立方根

math.Ceil( x )

[x],即≥x的最小整数值;例如math.Ceil(5.4) == 6.0

math.Copysign( x , y )

得到一个值,其绝对值与x相同,但符号位与y相同

math.Cos( x )

以弧度为单位的x的余弦值

math.Cosh( x )

以弧度为单位的x的双曲余弦值

math.Dim( x , y)

效果上,等价于math.Max(x - y,0.0)

math.E

自然数e;值大约是2.718 281 828 459 045

math.Erf( x )

erf(x),即x的高斯误差函数

math.Erfc( x )

erfc(x),即x的互补高斯误差函数

math.Exp( x )

ex

math.Exp2( x )

即2x

math.Expm1( x )

即ex−1;但当x接近于0时,其结果的精度远好于用math.Exp(x) - 1

math.Float32bits( f )

依据IEEE-754标准表示的float32值,并将其视为int32整数

math.Float32frombits( u )

是上面math.Float32bits(f) 的反操作,将一个int32整数视作符合IEEE-754标准表示的float32

math.Float64bits( x )

依据IEEE-754标准表示的float64值,并将其视为uint64整数

math.Float64frombits( u )

是上面math.Float64bits(x)的反操作,将一个uint64整数视作符合IEEE-754标准表示的float64

表2-9 math包中的常量与函数 #2

语法

含义/结果

math.Floor( x )

[x],即≤x的最大整数值;例如math.Floor(5.4) == 5.0

math.Frexp( x )

结果是(frac float64, exp int),使得x = frac * 2 exp;是math.Ldexp(frac, exp) 的反函数

math.Gamma( x )

Γ(x),即 (x−1)!

math. Hypot( x ,y)

math,Sqrtx * x , y * y

math.Ilogb( x )

取的整数部分;参见math.Logb()

math.Inf( n )

如果n ≥ 0,则返回float64类型的+∞值;否则返回−∞

math.IsInf( x , n )

如果n > 0且xfloat64类型的+∞值,或者x < 0且xfloat64类型的−∞值,或者x == 0xfloat64类型的+∞或−∞值,则返回true;否则返回false

math.IsNaN( x )

如果x是IEEE-754中的NaN(not a number),返回true;否则返回false

math._J_ 0( x )

(x), 第一类贝塞尔函数

math._J_ 1( x )

(x), 第一类贝塞尔函数

math.Jn( n , x )

(x), 第一种贝塞尔函数

math.Ldexp( x , n )

结果为x 2 n,是math.Frexp的反函数

math.Lgamma( x )

结果是(lgamma float64, sign int),使得lgamma =ln(Γ( x )),sign = Γ(x)的符合位(小于0时取−1,否则取+1)

math.Ln2

常数ln 2,近似等于0.693 147 180 559 945

math.Ln10

常数ln 10,近似等于2.302 585 092 994 045

math.Log(x)

ln x

math.Log2E

常数,近似等于1.442 695 021 629 333

math.Log10(x)

math.Log10E

常数,近似等于0.434 294 492 006 301

math.Log1p(x)

ln (1 + x),但当x接近于0时,其结果的精度远好于用math.Log(1 + x)

math.Log2(x)

x

math.Logb(x)

x 的整数部分;参见math.Ilogb()

math.Max( x , y )

xy中的大者

math.Min( x , y )

xy中的小者

math.Mod( x , y )

x除以y的余数;参见math.Remainder()

表2-10 math包中的常量与函数 #3

语法

含义/结果

math.Modf( x )

结果是(whole float64, frac float64),其中whole = x的整数部分,而frac是分数部分

math.NaN( x )

返回IEEE-754中的NaN值

math.Nextafter( x , y )

返回xy的下一个可表达的值(译者注:此函数可用于实现for x != y { ...; x = math.Nextafter(x,y ) } 这样的循环)

math.Pi

常数π,近似等于3.141 592 653 589 793

math.Phi

常数φ,近似等于1.618 033 988 749 984

math.Pow( x , y )

xy

math.Pow10( n )

10n

math.Remainder( x , y )

与IEEE-754兼容的x除以y的余数;参见math.Mod()

math.Signbit( x )

如果x < 0则返回true

math.Sin( x )

以弧度为单位的x的正弦值

math.SinCos( x )

这个函数主要同时返回sin(x)和cos(x)

math.Sinh( x )

以弧度为单位的x的双曲正弦值

math.Sqrt( x )

math.Sqrt2

常数,近似等于1.414213562373095

math.SqrtE

常数;近似等于1.648 721 270 700 128

math.SqrtPi

常数;近似等于1.772 453 850 905 516

math.SqrtPhi

常数;近似等于1.272 019 649 514 068

math.Tan( x )

以弧度为单位的x的正切值

math.Tanh( x )

以弧度为单位的x的双曲正切值

math.Trunc( x )

x的分数部分设为0

math.Y0( x )

(x),第二类贝塞尔函数

math.Y1( x )

(x),第二类贝塞尔函数

math.Yn( n , x )

(x),第二类贝塞尔函数

浮点型数据使用小数点的形式或者指数符号来表示,例如0.0、3.、8.2、−7.4、−6e4、.1以及5.9E-3等。计算机通常使用二进制表示浮点数,这意味着有些小数可以精确地表示(如0.5),但是其他的浮点数就只能近似表示(如0.1和0.2)。另外,这种表示使用固定长度的位,因此它所能表示的数字的位数有限。这不是Go语言特有的问题,而是困扰所有主流语言的浮点数问题。然而,这种不精确性并不是总都这么明显,因为Go语言使用了智能算法来输出浮点数,这些浮点数在保证精确性的前提下使用尽可能少的数字。

表2-3中所列出的所有比较操作都可以用于浮点数。不幸的是,由于浮点数是以近似值表示的,用它们来做相等或者不相等比较时并不总能得到预期的结果。

x, y := 0.0, 0.0
for i := 0; i < 10; i++ {
     x += 0.1
     if i%2 == 0{
         y += 0.2
     } else {
          fmt.Printf("%-5t %-5t %-5t %-5t", x == y, EqualFloat(x, y, -1), 
               EqualFloat(x, y, 0.000000000001), EqualFloatPrec(x, y, 6))
          fmt.Println(x, y)
     } 
}

true true true true 0.2 0.2
true true true true 0.4 0.4
false false true true 0.6 0.6000000000000001
false false true true 0.7999999999999999 0.8
false false true true 0.9999999999999999 1

这里开始时我们定义了两个float64型的浮点数,其初始值都为0。我们往第一个值中加上10个0.1,往第二个值中加上5个0.2,因此结果都为1。然而,正如代码片段下面所给出的输出所示,有些浮点数并不能得到完美的结果。这样看来,计算使用==以及!= 对浮点数进行比较时,我们必须非常小心。当然,有些情况下可以使用内置的操作符来比较浮点数的相等或者不相等性。例如,为了避免除数为0,可以这样做if y != 0.0 { return x / y}

格式"%-5"以一个向左对齐的5个字符宽的区域打印一个布尔值。字符串格式化的内容将在下一章讲解,参见3.5节。

func EqualFloat(x, y, limit float64) bool {
     if limit <= 0.0 {
          limit = math.SmallestNonzeroFloat64
     }
     return math.Abs(x-y) <= (limit * math.Min(math.Abs(x), math.Abs(y)))
}

EqualFloat()函数用于在给定精度范围内比较两个float64型数,如果给定的精度范围为负数(如−1),则将该精度设为机器所能达到的最大精度。它还依赖于标准库math包中的一个函数(以及一个常量)。

一个可替代(也更慢)的方式是以字符串的形式比较两个数字。

func EqualFloatPrec(x, y float64, decimals int) bool {
     a := fmt.Sprintf("%.*f", decimals, x)
     b := fmt.Sprintf("%.*f", decimals, y)
     return len(a) == len(b) && a == b
}

对于该函数,其精度以小数点后面数字的位数声明。fmt.Sprintf()函数的%格式化参数能够接受一个*占位符,用于输入一个数字,因此这里我们基于给定的float64创建了两个字符串,每个字符串都以给定位数的尾数进行格式化。如果浮点数中数字的多少不一样,那么字符串ab的长度也不一样(例如,12.32和592.85),这样就能给我们一个快速的短路测试。(字符串格式化的内容将在3.5节讲解。)

大多数情况下如果需要浮点数,float64类型是最好的选择,一个特别原因是math包中的所有函数都使用float64类型。然而,Go语言也支持float32类型,这在内存比较宝贵并且无需使用math包,或者愿意处理在与float64类型之间进行来回转换的不便时非常有用。由于Go语言的浮点类型是固定长度的,因此从外部文件或者网络连接中读写时非常安全。

使用标准的Go语法(例如int(float))可以将浮点型数字转换成整数,这种情况下小数部分会被丢弃。当然,如果浮点数的值超出了目标整型的范围,那么得到的结果值将是不可预期的。我们可以使用一个安全的转换函数来解决该问题。例如:

func IntFromFloat64(x float64) int {
     if math.MinInt32 <= x && x <= math.MaxInt32 {
          whole, fraction := math.Modf(x)
          if fraction >= 0.5 {
               whole++
          }
         return int(whole)
     }
     panic(fmt.Sprintf("%g is out of the int32 range", x))
}

Go语言规范(golang.org/doc/go_spec.html)中说明了int型所占的位数与uint相同,并且uint总是32位或者64位的。这意味着一个int型值至少是32位的,我们可以安全地使用math.MinInt32math.MaxInt32常量来作为int的范围。

我们使用math.Modf()函数来分离给定数字(都是float64型数字)的整数以及分数部分,而非简单地返回整数部分(即截断),如果小数部分大于或者等于0.5,则向上取整。

与我们的自定义Uint8FromInt()函数不同的是,我们不是返回一个错误值,而是将值越界当做一个需要停止程序运行的重要问题,因此我们使用了内置的panic()函数,它会产生一个运行时异常,并停止程序继续运行,直到该异常被一个recover()调用恢复(参见5.5节)。这意味着如果程序运行成功,我们就知道转换过程没有发生值越界。(值得注意的是,该函数并没有以一个return语句结束,Go编译器足够智能,能够意识到panic()调用意味那里不会出现正常的返回值。)

复数类型

Go语言支持的两种复数类型已在表2-7中给出。复数可以使用内置的complex()函数或者包含虚部数值的常量来创建。复数的各部分可以使用内置的real()imag()函数来获得,这两个函数返回的都是float64型数(或者对于complex64类型的复数,返回一个float32型数)。

复数支持表2-4中所有的算术操作符。唯一可用于复数的比较操作符是==和!=(参见表2-3),但也会遇到与浮点数比较相同的问题。标准库中有一个复数包math/cmplx``,表2-11给出了它的函数。

表2-11 Complex数学包中的函数

导入 "math/cmplx" 包。除非特别说明,否则所有的函数都接收和返回complex128值。

语法

含义/结果

cmplx.Abs( x )

x|,即作为float64值的x的绝对值

cmplx.Acos( x )

x 的反余弦,单位为弧度

cmplx.Acosh( x )

x 的反双曲余弦,单位为弧度

cmplx.Asin( x )

x 的反正弦,单位为弧度

cmplx.Asinh( x )

x 的反双曲正弦,单位为弧度

cmplx.Atan( x )

x 的反正切,单位为弧度

cmplx.Atanh( x )

x 的反双曲正切,单位为弧度

cmplx.Conj( x )

x 的复共轭

cmplx.Cos( x )

x 的余弦,单位为弧度

cmplx.Cosh( x )

x 的双曲余弦,单位为弧度

cmplx.Cot( x )

x 的余切,单位为弧度

cmplx.Exp( x )

e x

cmplx.Inf()

复数complex(math.Inf(1), math.Inf(1))

cmplx.IsInf( x )

如果real(x)或者imag(x)的结果为±∞,则为true;否则为false

cmplx.IsNaN( x )

如果real(x)或者imag(x)都为“非数字”并且都不是±∞,则为true;否则为false

cmplx.Log( x )

ln x

cmplx.Log10( x )

lg x

cmplx.NaN()

复数“非数字”的值

cmplx.Phase( x )

float64型数字x在范围[−π, +π]内的相

cmplx.Polar( x )

求满足以下等式的rθ值,均为float64,其中相r的范围为[−π, +π]

cmplx.Pow( x , y )

xy

cmplx.Rect( r , θ )

坐标为r,相为θ构成的complex128复数

cmplx.Sin( x )

x 的正弦值,单位为弧度

cmplx.Sinh( x )

x 的双曲正弦值,单位为弧度

cmplx.Sqrt( x )

cmplx.Tan( x )

x 的正切,单位为弧度

cmplx.Tanh( x )

x 的双曲正切,单位为弧度

这里有些简单的例子:

f := 3.2e5                               // 类型:float64
x := -7.3 - 8.9i                         // 类型:complex128(字面量)
y := complex64(-18.3 + 8.9i)             // 类型:complex64(转换)❶
z := complex(f, 13.2)                    // 类型:complex128(构造)❷
fmt.Println(x, real(y), imag(z))         // 打印:(-7.3-8.9i) -18.3 13.2

正如数学中所表示的那样,Go语言使用后缀i表示虚数。这里,数xz都是complex128类型的,因此它们的实部和虚部都是float64类型的。ycomplex64类型的,因此它的各部分都是float32类型的。需要注意的一点小细节是,使用complex64类型的名字(或者是任何其他内置的类型名)来作为函数会进行类型转换。因此这里(❶)复数 -18.3+8.9i(从复数字面量推断出来的复数类型为complex128)被转换成一个complex64类型的复数。然而,complex()是一个函数,它接受两个浮点数输入,返回对应的complex128(❷)。

另一个细节点是fmt.Println()函数可以统一打印复数。(就像将在第6章看到的那样,我们可以创建自己的无缝兼容Go语言的打印函数的类型,只需为它们简单地添加一个String()方法即可实现。)

一般而言,最适合使用的复数类型是complex128,因为math/cmplx包中的所有函数都工作于complex128类型。Go语言也支持complex64类型,这在内存非常紧缺的情况下是非常有用的。Go语言的复数类型是定长的,因此从外部文件或网络连接中读写复数总是安 全的。

本章中我们讲解了Go语言的布尔类型以及数值类型,同时在表格中给出了可以查询和操作它们的操作符和函数。下一章将讲解Go语言的字符串类型,包括对Go语言的格式化打印功能(参见3.5节)的全面讲解,当然其中也包括我们需要的格式化打印布尔值和数字的内容。第8章中我们会看看如何对文件进行数据类型的读写,包括布尔型和数值类型,在本章结束之前,我们会讲解一个短小但是完全能够工作的示例程序。

这个例子的目的是为了提高大家对Go编程的理解并提供实践机会。就如同第一章,这个例子使用了一些还没有完整讲解的Go语言特性。这应该不是大问题,因为我们提供了相应的简单解释和交叉引用。这个例子还很简单的使用了Go语言官方网络库net/http包。使用net/http包我们可以非常容易地创建一个简单的HTTP服务器。最后,为了不脱离本章的主题,这节的例子和练习都是数值类型的。

statistics程序(源码在statistics/statistics.go文件里)是一个Web应用,先让用户输入一串数字,然后做一些非常简单的统计计算,如图2-2所示。我们分两部分来讲解这个例子,先介绍如何实现程序中相关的数学功能,然后再讲解如何使用net/http包来创建一个Web应用程序。由于篇幅有限,而且书中的源码均可从网上下载,所以有侧重地只显示部分代码(对于import部分和一些常量等可能会被忽略掉),当然,为了让大家能更好地理解我们会尽可能讲解得全面些。

图2-2 Linux和Windows上的Statistics示例程序

我们定义了一个聚合类型的结构体,包含用户输入的数据以及我们准备计算的两种统计:

type statistics struct{
     numbers []float64
     mean     float64
     mdian    float64
}

Go语言里的结构体类似于C里的结构体或者Java里只有public数据成员的类(不能有方法),但是不同于C++的结构体,因为它并不是一个类。我们在6.4节将会看到,Go语言里的结构体对聚合和嵌入的支持是非常完美的,是Go语言面向对象编程的核心(主要介绍在第6章)。

func getStats(numbers []float64) (stats statistics) {
     stats.numbers = numbers
     sort.Float64s(stats.numbers)
     stats.mean = sum(numbers) / float64(len(numbers))
     stats.median = median(numbers)
     return stats
}

getStats函数的作用就是对传入的[]float64切片(这些数据都在processRequest()里得到)进行统计,然后将相应的结果保存到stats结果变量中。其中计算中位数使用了sort包里的Float64s()函数对原数组进行升序排列(原地排序),也就是说getStats()函数修改了它的参数,这种情况在传切片、引用或者函数指针到函数时是很常见的。如果需要保留原始切片,可以使用Go语言内置的copy()函数(参见4.2.3节)将它赋值到一个临时变量,使用临时变量来工作。

结构体中的mean(通常也叫平均数)是对一连串的数进行求和然后除以总个数得到的结果。这里我们使用一个辅助函数sum()求和,使用内置的len()取得切片的大小(总个数)并将其强制转换成float64类型的变量(因为sum()函数返回一个float64的值)。这样我们也就确保了这是一个浮点除法运算,避免了使用整数类型可能带来的精度损失问题。median是用来保存中位数的,我们使用median()函数来单独计算它。

我们没有检查除数为0的情况,因为在我们的程序逻辑里,getStats()函数只有在至少有1个数据的时候才会被调用,否则程序会退出并产生一个运行时异常(runtime panic)。对于一个关键性应用当发生一个异常时程序是不应该被结束的,我们可以使用recover()来捕获这个异常,将程序恢复到一个正常的状态,让程序继续运行(5.5节)。

func sum(numbers []float64) (total float64) {
     for_, x := range numbers {
          total += x
     }
     returntotal
}

这个函数使用一个for…range循环遍历一个切片并将所有的数据相加计算出它们的和。Go语言总是将所有变量初始化为0,包括已经命名了的返回变量,例如total,这是一个相当有益的设计。

func median(numbers []float64) float64 {
     middle := len(numbers) / 2
     result := numbers[middle]
     if len(numbers)%2 == 0 {
          result = (result + numbers[middle-1]) / 2
     }
     return result
}

这个函数必须传入一个已经排序好了的切片,它一开始将切片里最中间的那个数保存到result变量中,但是如果总个数是偶数,就会产生两个中间数,我们取这两个中间数的平均值作为中位数返回。

在这一小部分里我们讲解了这个统计程序最主要的几个处理过程,在下一部分我们来看看一个只有简单页面的Web程序的基本实现。(读者如果对Web编程不感兴趣的话可以略过本节直接跳到练习或者跳到下一章。)

这个statistics程序在本机上提供了一个简单网页,它的主函数如下:

func main() {
     http.HandleFunc("/", homePage)
     if err := http.ListenAndServe(":9001", nil); err != nil {
          log.Fatal("failed to start server", err)
     }
}

http.HandleFunc()函数有两个参数:一个路径,一个当这个路径被请求时会被执行的函数的引用。这个函数的签名必须是func(http.ResponseWriter, *http.Request)我们可以注册多个“路径-函数”对,这里我们只注册了“/”(通常是网页程序的主页) 和一个自定义的homePage()函数。

http.ListenAndServe()函数使用给定的TCP地址启动一个Web服务器。这里我们使用localhost和端口9001。如果只指定了端口号而没有指定网络地址,默认情况下网络地址是localhost。当然也可以这样写“localhost:9001”或者“127.0.0.1``:9001”。端口的选择是任意的,如果和现有的服务器有冲突的话,比如端口已经被其他进程占用了等,修改代码中的端口为其他端口号即可。http.ListenAndServe()的第二个参数支持自定义的服务器,为空的话(传一个nil参数)表示使用默认的类型。

这个程序使用了一些字符串常量,但是这里我们只展示其中的一个。

form  =  '<form action="/" method="POST">
<label for="numbers">Numbers (comma or space-separated):</label><br />
<input type="text" name="numbers" size="30"><br />
<input type="submit" value="Calculate">
</form>'

字符串常量form包含一个HTML的表单元素,包含一些文本和一个提交按钮。

func homePage(writer http.ResponseWriter, request *http.Request) {
     err := request.ParseForm() // 必须在写响应内容之前调用
     fmt.Fprint(writer, pageTop, form)
     if err != nil {
          fmt.Fprintf(writer, anError, err)
     } else {
          if numbers, message, ok := processRequest(request); ok {
               stats := getStats(numbers)
               fmt.Fprint(writer, formatStats(stats))
          } else if message != "" {
               fmt.Fprintf(writer, anError, message)
          }
     }
     fmt.Fprint(writer, pageBottom)
}

当统计网站被访问的时候会调用这个函数,request参数包含了请求的详细信息,我们可以往writer里写入一些响应信息(HTML格式)。

我们从分析这个表单开始吧。这个表单一开始只有一个空的文本输入框(text),我们将这个文本输入框标识为“numbers”,这样当后面我们处理这个表单的时候就能找到它。表单的action设置为"/",当用户点击Calculate按钮的时候这个页面被重新请求了一次。这也就是说不管什么情况这个homePage()函数总是会被调用的,所以它必须处理几个情况:没有数据输入、有数据输入或者发生错误了。实际上,所有的工作都是由一个叫processRequest()的自定义函数来完成的,它对每一种情况都做了相应的处理。

分析完表单之后,我们将pageTop(源码可见)和form这两个字符串常量写到writer里去(返回数据给客户端),如果分析表单失败我们写入一个错误信息:anError是一个格式化字符串,err是即将被格式化的error值(格式化字符串3.5节会提到)。

anError = '<p class="error">%s</p>'

如果分析成功了,我们调用自定义函数processRequest()处理用户键入的数据。如果这些数据都是有效的,我们调用之前提到过的getStats()函数来计算统计结果,然后将格式化后的结果返回给客户端,如果接受到的数据无效,且我们得到了错误信息,则返回这个错误信息(当这个表单第一次显示的时候是没有数据的,也没有错误发生,这种情况下ok变量的值是false,而且message为空)。最后我们打印出pageBottom字符串常量(源码可见),用来关闭<body><html>标签。

func processRequest(request *http.Request) ([]float64, string, bool) {
     var numbers []float64
     if slice, found := request.Form["numbers"]; found && len(slice) > 0 {
          text := strings.Replace(slice[0], ",", " ", -1)
          for _, field := range strings.Fields(text) {
               if x, err := strconv.ParseFloat(field, 64); err != nil {
                    return numbers, "'" + field + "' is invalid", false
               } else {
                    numbers = append(numbers, x)
               }
          }
     }
     if len(numbers) == 0 {
          return numbers, "", false // 第一次没有数据被显示
     }
     return numbers, "", true
}

这个函数从request里读取表单的数据。如果这是用户首次请求的话,表单是空的,“numbers”输入框里没有数据,不过这并不是一个错误,所以我们返回一个空的切片、一个空的错误信息和一个false布尔型的值,表明从表单里没有读取到任何数据。这些结果将会以空的表单形式被展示出来。如果用户有输入数据的话我们返回一个[]float64类型的切片、一个空的错误信息以及true;如果存在非法数据,则返回一个可能为空的切片、一个错误消息和false

request结构里有一个map[string][]string类型的Form成员(参见4.3节),它的键是一个字符串,值是一个字符串切片,所以一个键可能有任意多个字符串在它的值里。例如:如果用户键入“5 8.2 7 13 6”,那么这个Form里有一个叫“numbers”的键,它的值是[]string{"5 8.2 7 13 6"},也就是说它的值是一个只有一个字符串的字符串切片(作为对比,这里有一个包含两个字符串的字符串切片:[]string{"1 2 3","a b c"})。我们检查这个“numbers”键是否存在(应该存在),如果存在,而且它的值至少有一个字符串,那么我们有数据可以读了。

我们使用strings.Replace()函数(第三个参数指明要执行多少次替换,−1表示替换所有)将用户输入中的所有逗号转换为空格,得到一个新的字符串。新字符串里所有数据都是由空格分隔开的,再使用strings.Fields()函数根据空白处将字符串切分成一个字符串切片,这样我们就可以直接使用for...range循环来遍历它了(strings这个包的函数参见3.6节,for...range循环请参见5.3节)。对于每一个字符串,例如“5”、“8.2”等,用strconv.ParseFloat()函数将它转换成float64类型,这个函数需要传入一个字符串和一个位大小如32或者64(参见3.6节)。如果转换失败我们立即返回现有已经转好了的数据切片、一个非空的错误信息和false。如果转换成功我们将转换的结果float64类型的数据追加到numbers切片里去,内置的函数append()可以将一个或多个值和原有切片合并返回一个新的切片,如果原来的切片的容量比长度大的话,这个函数执行的过程是非常快的,效率很高(关于append()参见4.2.3节)。

假如程序没有因为错误退出(存在非法数据),将返回数值和一个空的错误信息以及true。没有数据需要处理(如这个表单第一次被访问的时候)的情况下返回false

func formatStats(stats statistics) string {
return fmt.Sprintf('< table border="1">
< tr>< th colspan="2">Results< /th>< /tr>
< tr>< td>Numbers< /td>< td>%v< /td>< /tr>
< tr>< td>Count< /td>< td>%d< /td>< /tr>
< tr>< td>Mean< /td>< td>%f< /td>< /tr>
< tr>< td>Median< /td>< td>%f< /td>< /tr>
< /table>', stats.numbers, len(stats.numbers), stats.mean, stats.median)
}

一旦计算完毕我们必须将结果返回给用户。因为程序是一个Web应用,所以我们需要生成HTML。(Go语言的标准库提供了用于创建数据驱动文本和HTML的text/templatehtml/template包,但是我们这里的需求比较简单,所以我们选择自己手动写HTML。9.4.2节有一个简单的使用text/template包的例子。)

fmt.Sprintf()是一个字符串格式化函数,需要一个格式化字符串和一个或多个值,将这一个或多个值按照格式中指定的动作(如%v%d%f等)进行转换,返回一个新的格式化后的字符串(格式化字符串在3.5节里有非常详细的描述)。我们不需要做任何的HTML转义,因为我们所有的值都是数字。(如果需要的话我们可以使用template.HTMLEscape()或者html.EscapeString()函数。)

从这个例子可以了解,假如我们了解基本的HTML语法,使用Go语言来创建一个简单的Web应用是非常容易的。Go语言标准库提供的htmlnet/httphtml/templatetext/template等包让整个事情就变得更加简单。

本章有两道数值相关的练习题。第一题需要修改我们之前的statistics程序。第二题就是动手创建一个Web应用,实现一些简单的数学计算。

(1)复制statistics目录为比如my_statistics,然后修改my_statistics/statistics.go代码,实现估算众数和标准差的功能,当用户点击页面上的Calculate按钮时能产生类似图2-3所示的结果。

图2-3 Mac OS X上的statistics示例程序

这需要在statistics结构体里增加一些成员并实现两个新函数去执行计算。可以参考statistics_ans/statistics.go文件里的答案。这大概增加了40行代码和使用了Go语言内置的append()函数将数字追加到切片里面。

写一个计算标准差的函数也很容易,只需要使用math包里面的函数,不到10行代码就可以完成。我们使用公式来计算,其中x表示每一个数字,表示数学平均数,n是数字的个数。

众数是指出现最多次的数,可能不止一个,例如有两个或者多个数的出现次数相等。但是,如果所有数的出现次数都是一样的话,我们就认为众数是不存在的。计算众数要比标准差难,大概需要20行左右的代码。

(2)创建一个Web应用,使用公式来求二次方程的解。要用复数,这样即使判别式b2-4ac部分为负能计算出方程的解。刚开始的时候可以先让程序能够工作起来,如图2-4左图所示,然后再修改你的代码让它输出得更美观一些,如图2-4右图所示。

图2-4 Linux上的二次方程求解

最简单的做法就是直接使用statistics程序的main()函数、homePage()函数以及processRequest()函数,然后修改homePage()让它调用我们自定义的3个函数:formatQuestion()solve()formatSolutions(),还有processRequest()函数要用来读取那3个浮点数,这个改动的代码多一点。

第一个参考答案在quadratic_ans1/quadratic.go里,约120行代码,只实现了基本的功能,使用EqualFloat()函数来判断方程的两个解是否是约等的,如果约等,只返回一个解。(EqualFloat()函数在之前有讨论过。)

第二个参考答案在quadratic_ans2/quadratic.go里,约160行代码,相比第一个主要是优化了输出的结果。例如,它将“+ -”替换成“-”,将“1x”替换成“x”,去掉系数为0的项(例如“0x”等),使用math/cmplx包里的cmplx.IsNaN()函数将一个虚数部分近似0的解转换成浮点数,等等。此外,还用了一些高级的字符串格式化技巧(主要在3.5节介绍)。

异常,即panic,见1.6节和5.5节。

这里的实现基于http://en.literateprograms.org/Pi\_with\_Machin's\_formula\_(Python)

相比之下,在工程上以及Python语言中,虚数用_j_来表示。


相关图书

Rust游戏开发实战
Rust游戏开发实战
JUnit实战(第3版)
JUnit实战(第3版)
Kafka实战
Kafka实战
Rust实战
Rust实战
PyQt编程快速上手
PyQt编程快速上手
Elasticsearch数据搜索与分析实战
Elasticsearch数据搜索与分析实战

相关文章

相关课程