嗨翻-Go-全-
嗨翻 Go(全)
原文:
zh.annas-archive.org/md5/d4b3bcd523a65a8594a1e8a02f7ec08f
译者:飞龙
前言
注意
在本节中,我们回答了一个迫切的问题:“那么他们为什么在 Go 书中加入了这个?”
这本书适合谁?
如果你可以对所有这些问题回答“是”:
-
你是否有一台带有文本编辑器的计算机?
-
你想学习一门使开发快速和高效的编程语言吗?
-
你喜欢刺激的晚宴谈话胜过枯燥乏味的学术讲座吗?
这本书适合你。
谁应该远离这本书?
如果你可以回答“是”的话,至少有一个:
-
你完全不懂计算机吗?
(你不需要是高级用户,但你应该理解文件夹和文件,如何打开终端应用程序,以及如何使用简单的文本编辑器。)
-
你是一位忍者摇滚明星开发者,寻找一本参考书籍吗?
-
你害怕尝试新事物吗?你宁愿拔牙也不愿意将条纹与格子混搭吗?你认为技术书籍如果充满了糟糕的双关语就不可能严肃吗?
本书并不适合你。
注意
[市场部的说明:本书适合任何持有有效信用卡的人。]
我们知道你在想什么
“这怎么可能是一本关于 Go 开发的严肃书籍?”
“这些图形是什么意思?”
“我真的能够这样学习吗?”
我们知道你的大脑在想什么
你的大脑渴望新奇。它总是搜索、扫描,等待一些不寻常的事物。它就是这样构建的,并且帮助你保持生命。
那么你的大脑对你遇到的所有例行、普通、正常事物都做些什么呢?它竭尽所能阻止它们干扰大脑真正的工作——记录重要的事情。它不会费心保存无聊的事物;它们永远不会通过“这显然不重要”的过滤器。
你的大脑如何知道什么是重要的?假设你出去远足一天,一只老虎跳到你面前——你的头脑和身体内会发生什么?
神经元激活。情绪激增。化学物质涌动。
这就是你的大脑如何知道……
这一定很重要!不要忘记了!
但是想象一下你在家里或图书馆。这是一个安全、温暖、没有老虎的区域。你在学习。准备考试。或者试图学习一些难懂的技术主题,你的老板认为需要一周,最多 10 天。
只有一个问题。你的大脑试图帮你大忙。它试图确保这显然不重要的内容不会占用稀缺的资源。最好的资源应该用来存储那些真正重要的东西。像老虎。像火灾的危险。像你为什么不应该把派对照片发布在 Facebook 页面上。但没有简单的方法告诉你的大脑,“嘿,大脑,非常感谢你,但无论这本书有多无聊,无论我现在在情感里刻度尺上登记得有多少少,我真的想让你保留这些东西。”
元认知:思考思考的过程
如果你真的想学习,而且想更快更深入地学习,要注意你是如何注意的。思考你的思考。学习你的学习方式。
大多数人在成长过程中并未学习元认知或学习理论课程。我们期望学习,但很少有人教我们如何学习。
但我们假设如果你拿着这本书,你真的想学会如何编写 Go 程序。而且你可能不想花很多时间。如果你想运用这本书中的内容,你需要记住你所读的内容。而为了做到这一点,你必须理解它。为了从这本书中或任何一本书或学习经验中获得最大收益,你需要对你的大脑负责。你的大脑对这个内容。
诀窍是让你的大脑将你正在学习的新材料视为非常重要的事情。对你的健康至关重要。像老虎一样重要。否则,你将不断与你的大脑进行斗争,因为它竭尽全力防止新内容粘在上面。
那么你到底如何让你的大脑把编程看作是一只饥饿的老虎?
有一个缓慢、乏味的方式,还有一个更快、更有效的方式。缓慢的方式是通过纯粹的重复。显然,你知道即使是最枯燥的话题,如果你不断地把同样的东西灌输到你的大脑中,你能够学习和记住。通过足够的重复,你的大脑会说,“对他来说这并不重要,但他一直看着同样的东西一次又一次又一次,所以我想这必须是重要的。”
更快的方法是做任何增加大脑活动的事情,特别是不同类型的大脑活动。前一页的内容是解决方案的重要部分,它们都是已被证明有助于你的大脑运作的事物。例如,研究表明,将单词放在它们描述的图片中(而不是放在页面的其他地方,如标题或正文中)会导致你的大脑尝试理解单词和图片之间的关系,这会导致更多的神经元激活。更多的神经元激活=你的大脑有更多机会理解这是值得关注的事情,并可能记录下来。
对话式风格有助于,因为人们在感知到自己正在进行对话时往往更专注,因为他们期望跟上并保持自己的一部分。令人惊讶的是,你的大脑并不一定在乎“对话”是与你和一本书之间进行的!另一方面,如果写作风格正式而枯燥,你的大脑会把它看作你在一个房间里被动听讲座时的体验。没有必要保持清醒。
但图片和对话风格仅仅是个开始……
这就是我们做的事情
我们使用图片,因为你的大脑对视觉图像更敏感,而不是文本。就你的大脑而言,一张图片确实值千言万语。当文本和图片共同工作时,我们将文本嵌入到图片中,因为当文本在所指的事物内部时,你的大脑处理起来更有效,而不是在说明或正文中的某处。
我们使用冗余性,用不同的方式和不同的媒体类型来表达同样的内容,并涉及多种感官,以增加内容被编码到你大脑的多个区域的机会。
我们以意想不到的方式使用概念和图片,因为你的大脑对新奇事物感兴趣,我们使用带有至少一些 情感 内容的图片和想法,因为你的大脑倾向于关注情绪的生物化学。那些让你感觉到某种情绪的事物更容易记住,即使那种感觉只是一点幽默、惊讶或兴趣。
我们使用了个性化的、对话式的风格,因为当你的大脑认为你正在进行对话而不是 passively listening 时,它会更加关注。即使当你阅读时,你的大脑也会这样做。
我们包含活动,因为当你做事情而不是读关于事情时,你的大脑更容易学习和记忆。我们制作了具有挑战性但可行的练习,因为这是大多数人喜欢的方式。
我们使用多种学习风格,因为你可能更喜欢逐步的步骤,而另一个人则希望先了解大局,还有其他人只想看到一个例子。但无论你自己的学习偏好如何,每个人都会受益于以多种方式呈现相同内容。
我们为你大脑的两侧提供内容,因为你的大脑涉及的越多,你学习和记忆的可能性就越大,你能保持注意力的时间也就越长。因为一侧大脑工作通常意味着给另一侧一个休息的机会,所以你能更长时间地有效学习。
我们包含故事和练习,呈现多个观点,因为当你的大脑被迫进行评估和判断时,它更容易深入学习。
我们包含挑战,包括练习和提出不一定有直接答案的问题,因为你的大脑倾向于在需要付出努力时学习和记忆。想想看——你不可能仅仅通过观察健身房里的人来使你的身体变得健康。但我们尽最大努力确保当你努力工作时,是在正确的事情上。确保你不会浪费额外的神经元来处理难以理解的例子,或是解析难懂、术语繁重或过于简洁的文本。
我们使用了人。在故事中,例子,图片等等,因为,呃,你是一个人。你的大脑比对事物更关注人。
这是你可以做的,来驯服你的大脑
所以,我们尽了我们的一份力。剩下的就看你了。这些建议只是一个起点;倾听你的大脑,找出对你有效和无效的方法。尝试新的事物。
注意
剪下来,贴在你的冰箱上。
-
放慢速度。你理解得越多,需要记忆的就越少。
不要仅仅阅读。停下来思考。当书问你问题时,不要直接跳到答案。想象有人真的在问这个问题。你让大脑深入思考的程度越深,学习和记忆的机会就越大。
-
做练习。写下你自己的笔记。
我们提供这些方法,但如果我们替你做了这些,那就像是让别人帮你做锻炼一样。而且不要仅仅看这些练习。用铅笔。有足够的证据表明,在学习过程中进行身体活动可以增加学习效果。
-
读“没有愚蠢问题”这本书。
这意味着所有的内容。它们不是可选的边栏,它们是核心内容的一部分! 不要跳过它们。
-
在睡前读这篇文章。或者至少是最后一个具有挑战性的东西。
学习的一部分(特别是长期记忆的转移)发生在你放下书之后。你的大脑需要独自处理更多时间,进行更多加工。如果你在这个处理时间内加入新的东西,你刚刚学到的一些内容将会丢失。
-
大声说出来。
说话会激活大脑的不同部分。如果你试图理解某事,或者增加记忆它的机会,大声说出来。更好的是,试着向别人大声解释。你会学得更快,而且你可能会发现在阅读时并不知道的想法。
-
喝水。大量喝水。
你的大脑在充足的液体浴中工作效果最好。脱水(即使在你感觉到口渴之前)会降低认知功能。
-
听听你的大脑。
注意你的大脑是否开始过载。如果发现自己开始浅尝辄止或忘记刚刚读过的内容,那么是休息的时候了。一旦超过一定点,通过试图塞更多东西进去来学得更快是不行的,甚至可能会损害学习过程。
-
感受一下。
你的大脑需要知道这很重要。投入到故事中去。为照片编写你自己的标题。对一句糟糕的笑话抱怨仍然比毫无感觉好。
-
写很多代码!
学习开发 Go 程序的唯一方法是多写代码。这就是本书贯穿始终的内容。编码是一种技能,唯一精通的方法就是练习。我们将为您提供大量的练习:每章都有一些问题需要您解决。不要只是跳过它们——很多学习过程发生在解决练习时。每个练习都附有解决方案——如果卡住了,不要害怕偷看答案!(有时小问题会让你陷入困境。)但在查看解决方案之前,请尝试自己解决问题。确保在继续阅读书的下一部分之前使其正常运行。
阅读我
这是一个学习经验,不是参考书。我们有意剔除可能妨碍我们在书中当前工作的学习的一切内容。第一次阅读时,你需要从头开始,因为书中假设你已经看过和学过某些内容。
如果您在其他语言中做过一些编程,这将有所帮助。
大多数开发者在学习了其他编程语言之后才接触到 Go。(他们通常是为了从其他语言中寻求避难。)我们简要介绍了基础知识,让完全的新手也能够应对,但我们不会详细讲解变量是什么,或者if
语句如何工作。如果您之前做过一点这方面的工作,学起来会更容易。
我们不会涵盖每一种类型、函数和包。
Go 自带了大量的软件包。当然,它们都很有趣,但即使这本书长度翻倍,我们也无法涵盖所有内容。我们的焦点是对初学者重要的核心类型和函数。我们确保您对它们有深入的理解,并自信地知道如何何时使用它们。无论如何,完成《Head First Go》后,您将能够快速掌握任何参考书中我们未涵盖的软件包。
这些活动是 必须 的。
练习和活动不是额外内容;它们是书籍核心内容的一部分。其中一些有助于记忆,一些用于理解,一些将帮助您应用所学的知识。不要跳过练习。
冗余是有意义且重要的。
Head First 书籍的一个显著区别在于我们希望您真正掌握它。我们希望您完成书籍时记住所学内容。大多数参考书籍的目标不是保持和回忆,但这本书是关于学习的,因此您会看到一些相同的概念出现多次。
示例代码尽可能精简。
要在 200 行代码中寻找你需要理解的两行是很令人沮丧的。本书中的大多数示例都显示在尽可能小的上下文中,这样你要学习的部分就会清晰而简单。所以不要期望代码是强大的,甚至是完整的。这是你完成书后的任务。书中的示例专门用于学习,并不总是完全功能。
我们把所有的示例文件都放在网上供您下载。您可以在headfirstgo.com/
找到它们。
致谢
系列创始人:
感谢Head First的创始人凯西·西埃拉和伯特·贝茨。我十多年前第一次接触到这个系列时就喜欢它,但从未想过我会为它写作。感谢你们创造了这种了不起的教学风格!
在 O'Reilly:
感谢所有使这一切成为可能的 O'Reilly 的人,特别是编辑杰夫·布莱尔,以及克里斯汀·布朗、瑞秋·蒙纳汉和其余的制作团队成员。
技术审阅者:
每个人都会犯错,但幸运的是我有技术审阅者蒂姆·赫克曼、Edward Yue Shung Wong和斯特凡·波克曼来找出我所有的错误。你们永远不会知道他们找出了多少问题,因为我迅速销毁了所有的证据。但他们的帮助和反馈绝对是必要的,我会永远感激!
还有更多的感谢:
感谢Leo Richardson进行额外的校对。
或许最重要的是,感谢克里斯汀、科特尼、布莱恩、莱尼和杰里米,他们的耐心和支持(已经是第二本书了)!
O'Reilly Online Learning
近 40 年来,O'Reilly Media 提供技术和商业培训、知识和洞察,帮助公司取得成功。
我们独特的专家和创新者网络通过书籍、文章、会议以及我们的在线学习平台分享他们的知识和专业知识。O'Reilly 的在线学习平台为您提供按需访问的现场培训课程、深入学习路径、交互式编码环境以及来自 O'Reilly 和其他 200 多个出版商的大量文本和视频内容。更多信息,请访问oreilly.com
。
第一章:让我们开始吧:语法基础
你准备好加速你的软件了吗? 你想要一个简单的编程语言,编译快?运行快?让你轻松分发你的工作给用户?那么你准备好使用 Go 了!
Go 是一种专注于简单和速度的编程语言。它比其他语言更简单,因此学习起来更快。它让你利用当今多核计算机处理器的强大能力,使得你的程序运行更快。本章将向你展示所有能让作为开发者的生活更轻松,让用户更快乐的 Go 特性。
准备好了,开始吧!
2007 年,搜索引擎 Google 遇到了一个问题。他们必须维护数百万行代码的程序。在他们测试新更改之前,他们必须将代码编译成可运行的形式,这个过程当时需要大部分时间。不用说,这对开发者的生产力是不利的。
因此,Google 的工程师 Robert Griesemer、Rob Pike 和 Ken Thompson 为一种新语言设定了一些目标:
-
快速编译
-
更少繁琐的代码
-
未使用的内存会自动释放(垃圾回收)
-
能够同时执行多个操作的易于编写的软件(并发)
-
对多核处理器的良好支持
经过几年的努力,Google 创建了 Go:一种编写代码快速且产生的程序编译和运行速度快的语言。该项目于 2009 年转向开源许可证。现在任何人都可以免费使用。而且你应该使用它!由于其简单性和强大性,Go 正迅速赢得人们的青睐。
如果你正在编写一个命令行工具,Go 可以从同一源代码生成 Windows、macOS 和 Linux 的可执行文件。如果你正在编写一个 Web 服务器,它可以帮助你处理许多用户同时连接。无论你在写什么,它都将帮助你确保你的代码更易于维护和添加。
准备好学习更多了吗?让我们开始吧!
Go Playground
尝试 Go 的最简单方法是在你的 Web 浏览器中访问play.golang.org
。在那里,Go 团队设置了一个简单的编辑器,你可以在其中输入 Go 代码并在他们的服务器上运行。结果会直接显示在你的浏览器中。
(当然,这仅在你有稳定的互联网连接时才有效。如果没有,请参阅“在你的计算机上安装 Go”了解如何直接在你的计算机上下载和运行 Go 编译器。然后使用编译器运行以下示例。)
现在让我们试一试吧!
-
在你的浏览器中打开
play.golang.org
。(如果你看到的与截图不太一样,不用担心;这只是说明他们自从印刷本书以来改进了网站!) -
删除编辑区域中的任何代码,并键入以下内容:
package main import "fmt" func main() { fmt.Println("Hello, Go!") }
注意
别担心,我们将在下一页解释所有这些内容的含义!
-
点击格式化按钮,这将根据 Go 约定自动重新格式化你的代码。
-
点击运行按钮。
你应该在屏幕底部看到“Hello, Go!”显示。恭喜,你刚刚运行了你的第一个 Go 程序!
翻页,我们将解释刚刚做了什么...
这一切都意味着什么?
你刚刚运行了你的第一个 Go 程序!现在让我们来看看这段代码,并弄清楚它实际上意味着什么...
每个 Go 文件都以package
子句开头。一个包是一组执行类似操作的代码,如格式化字符串或绘制图像。package
子句指定了此文件代码将成为其一部分的包的名称。在这种情况下,我们使用特殊的main
包,如果要直接运行此代码(通常来自终端),则需要此包。
接下来,几乎每个 Go 文件都有一个或多个import
语句。每个文件在其代码可以使用其他包中的代码之前都需要import这些包。一次性加载计算机上所有 Go 代码会导致一个庞大而缓慢的程序,因此你只需指定需要的包来导入它们。
每个 Go 文件的最后部分实际上是实际的代码,通常分割成一个或多个函数。一个函数是一组一个或多个代码行,你可以从程序的其他地方调用(运行)。当运行 Go 程序时,它会查找名为main
的函数并首先运行它,这就是为什么我们将这个函数命名为main
的原因。
典型的 Go 文件布局
你很快会习惯看到这三个部分,按照这个顺序,在你所使用的几乎每个 Go 文件中:
-
包子句
-
任何
import
语句 -
实际的代码
俗话说:“万物有其位,万物有其处。” Go 是一门非常一致的语言。这是一件好事:你经常会发现在项目中找到特定代码的位置,而无需思考!
没有愚蠢的问题
Q: 我的另一种编程语言要求每个语句以分号结束。Go 不需要吗?
A: 在 Go 中,你可以使用分号来分隔语句,但这并非必需(事实上,这通常是不被赞同的)。
Q: 这个格式化按钮是什么?为什么在运行代码之前我们要点击它?
A: Go 编译器配备了一个名为go fmt
的标准格式化工具。格式化按钮是go fmt
的 Web 版本。
每当你分享你的代码时,其他的 Go 开发者都期望它遵循标准的 Go 格式。这意味着缩进和间距等将以标准的方式格式化,使每个人阅读更加轻松。在其他语言中,这通常通过依赖人们手动根据样式指南重新格式化其代码来实现,但是在 Go 中,你只需运行 go fmt
,它就会自动为你修复一切。
我们对为本书创建的每个示例运行了格式化程序,你也应该对你的所有代码运行它!
如果发生了什么错误?
Go 程序必须遵循某些规则,以避免使编译器混淆。如果我们违反其中一个规则,将会收到错误消息。
假设我们忘记在第 6 行对 Println
函数的调用中添加括号。
如果我们尝试运行程序的这个版本,会得到一个错误:
Go 告诉我们我们需要转到哪个源代码文件和行号以便我们可以修复问题。(Go Playground 在运行之前会将你的代码保存到一个临时文件中,这就是 prog.go 文件名的来源。)然后它会给出错误的描述。在这种情况下,因为我们删除了括号,Go 无法知道我们正在尝试调用 Println
函数,因此它无法理解为什么我们要在第 6 行的末尾放置 "Hello, Go"
。
打破东西是教育性的!
我们可以通过故意在不同的方式中断我们的程序来了解 Go 程序必须遵循的规则。拿这段代码示例来说,试着做一个以下的改变,并运行它。然后撤销你的改变,再试下一个。看看会发生什么!
package main
import "fmt"
func main() {
fmt.Println("Hello, Go!")}
注意
尝试故意破坏我们的代码示例,并查看发生了什么!
如果你这样做... | ...它会因为...而失败 |
---|---|
删除包声明... package main |
每个 Go 文件必须以包声明开头。 |
删除导入语句... import "fmt" |
每个 Go 文件必须导入其引用的每个包。 |
导入第二个(未使用的)包... import "fmt" import "strings" |
Go 文件必须仅导入其引用的包。(这有助于保持代码的快速编译!) |
重命名 main 函数... func ~~main~~hello |
Go 首先查找名为 main 的函数来运行。 |
将 Println 调用更改为小写... fmt.~~P~~println("Hello, Go!") |
Go 中一切都是大小写敏感的,所以虽然 fmt.Println 是有效的,但 fmt.println 是不存在的。 |
删除 Println 前的包名... ~~fmt~~.Println("Hello, Go!") |
Println 函数不是 main 包的一部分,因此在函数调用前需要包名。 |
让我们以第一个作为例子...
调用函数
我们的示例包含对 fmt
包的 Println
函数的调用。要调用一个函数,输入函数名(本例中为 Println
),然后加上一对括号。
像许多函数一样,Println
可以接受一个或多个参数:你希望函数处理的值。参数出现在函数名称后的括号中。
Println
可以不带参数调用,也可以提供多个参数。稍后我们会看到,大多数函数需要特定数量的参数。如果提供的参数太少或太多,会出现错误消息,说明期望的参数数量,需要修正代码。
Println
函数
当你需要查看程序正在执行的操作时,请使用Println
函数。传递给它的任何参数将在您的终端中打印(显示),每个参数由空格分隔。
打印所有参数后,Println
将跳到新的终端行。(这就是其名称末尾有“ln”的原因。)
使用其他包中的函数
我们第一个程序中的代码都属于main
包,但Println
函数位于fmt
包中。(fmt
代表“格式”)为了能够调用Println
,我们必须首先导入包含它的包。
导入包后,我们可以通过输入包名、一个点和我们想要的函数名称访问它提供的任何函数。
这是一个调用其他包中函数的代码示例。因为我们需要导入多个包,我们切换到一种允许在import
语句中列出多个包的备用格式,每行一个包名。
导入了math
和strings
包之后,我们可以使用math.Floor
访问math
包的Floor
函数,使用strings.Title
访问strings
包的Title
函数。
您可能已经注意到,尽管在我们的代码中包含了这两个函数调用,上面的示例却没有显示任何输出。接下来我们将看看如何修复这个问题。
函数返回值
在我们之前的代码示例中,我们尝试调用math.Floor
和strings.Title
函数,但它们没有产生任何输出:
package main
import (
"math"
"strings"
)
func main() {
math.Floor(2.75)
strings.Title("head first go")
}
注意
此程序不产生任何输出!
当调用fmt.Println
函数时,在此之后我们不需要再与其通信。我们传递一个或多个值给Println
打印,相信它会打印出它们。但有时程序需要能够调用函数并从中获取数据返回。因此,大多数编程语言中的函数可以有返回值:函数计算并返回给其调用者的值。
math.Floor
和strings.Title
函数都是使用返回值的示例函数。math.Floor
函数接受一个浮点数,将其向下舍入到最接近的整数,并返回该整数。而strings.Title
函数接受一个字符串,将其中每个单词的第一个字母大写(将其转换为“标题格式”),并返回大写的字符串。
要查看这些函数调用的结果,我们需要获取它们的返回值,并将这些返回值传递给fmt.Println
:
一旦这个更改完成,返回值将被打印出来,我们可以看到结果。
池谜题
您的任务是从池中获取代码片段,并将它们放入空行中。不要多次使用相同的片段,也不需要使用所有片段。您的目标是编写能够运行并生成所示输出的代码。
注意:每个池中的片段只能使用一次!
答案在“池谜题解答”中。
一个 Go 程序模板
对于接下来的代码片段,想象将它们插入到这个完整的 Go 程序中:
更好的方法是,尝试在 Go Playground 中键入这个程序,然后逐个插入片段,亲自看看它们的作用!
字符串
我们一直将字符串作为Println
的参数传递。字符串是一系列字节,通常表示文本字符。您可以在代码中直接定义字符串,使用字符串字面量:双引号之间的文本,Go 将其视为字符串。
在字符串中,像换行符、制表符和其他难以包含在程序代码中的字符可以用转义序列来表示:反斜杠后跟表示另一个字符的字符。
转义序列 | 值 |
---|---|
\n |
换行符。 |
\t |
制表符。 |
\" |
双引号。 |
\\ |
反斜杠。 |
符文
虽然字符串通常用于表示一系列文本字符,但 Go 的符文用于表示单个字符。
字符串字面量用双引号("
)括起来,但符文字面量则用单引号('
)括起来。
Go 程序可以使用几乎来自地球上任何语言的任何字符,因为 Go 使用 Unicode 标准来存储符文。符文保存为数值代码,而不是字符本身,如果将符文传递给fmt.Println
,您将在输出中看到该数值代码,而不是原始字符。
就像字符串字面量一样,符文字面量中也可以使用转义序列来表示在程序代码中难以包含的字符:
布尔值
布尔值只能是true
或false
两个值中的一个。它们在条件语句中特别有用,条件语句只在条件为真或假时运行代码的某些部分。(我们将在下一章节中讨论条件语句。)
数字
你还可以在代码中直接定义数字,这比字符串字面量更简单:只需键入数字。
正如我们将很快看到的那样,Go 将整数和浮点数视为不同的类型,因此请记住,小数点可以用来区分整数和浮点数。
数学运算和比较
Go 的基本数学运算符工作方式与大多数其他语言相同。+
符号用于加法,-
用于减法,*
用于乘法,/
用于除法。
你可以使用<
和>
来比较两个值,看一个是否小于或大于另一个。你可以使用==
(这是两个等号)来判断两个值是否相等,使用!=
(这是一个感叹号和一个等号,读作“不等于”)来判断两个值是否不相等。<=
测试第二个值是否小于或等于第一个值,>=
测试第二个值是否大于或等于第一个值。
比较的结果是一个布尔值,要么是true
,要么是false
。
类型
在之前的代码示例中,我们看到了math.Floor
函数,它将浮点数向下舍入到最接近的整数,并且strings.Title
函数,它将字符串转换为标题格式。你传递一个数字作为Floor
函数的参数是有道理的,而将一个字符串作为Title
函数的参数也是如此。但如果你将一个字符串传递给Floor
,并将一个数字传递给Title
会发生什么呢?
Go 会打印两条错误消息,每个函数调用一条,甚至程序都不会运行!
周围的世界中的事物通常可以根据它们的用途分类为不同的类型。你不会吃汽车或卡车作为早餐(因为它们是车辆),你也不会开着煎蛋卷或碗状谷物去上班(因为它们是早餐食品)。
同样地,Go 中的值都被分类为不同的类型,这些类型指定了值可以用于什么。整数可以用于数学运算,但字符串不能。字符串可以大写,但数字不能。依此类推。
Go 是静态类型的,这意味着它在程序运行之前就知道你的值的类型。函数期望它们的参数具有特定的类型,它们的返回值也有类型(可能与参数类型相同,也可能不同)。如果你在错误的地方意外使用了错误类型的值,Go 会给出错误消息。这是件好事:它能让你在用户之前发现问题!
Go 是静态类型的。如果在错误的地方使用了错误类型的值,Go 会提醒您。
您可以通过将其传递给reflect
包的TypeOf
函数来查看任何值的类型。让我们看看我们已经见过的一些值的类型:
这些类型是用于什么的:
类型 | 描述 |
---|---|
int |
整数。保存整数值。 |
float64 |
浮点数。保存具有小数部分的数字。(类型名称中的64 表示使用了 64 位数据来保存数字。这意味着float64 值在被四舍五入之前可以相当精确,但不是无限精确。) |
bool |
布尔值。只能是true 或false 。 |
string |
字符串。通常表示文本字符的数据系列。 |
答案在“中。
声明变量
在 Go 中,变量是包含值的存储空间。您可以使用变量声明为变量命名。只需使用var
关键字,后面跟上所需的名称和变量将保存的值的类型。
一旦声明了变量,您可以使用=
将该类型的任何值赋给它(这是单个等号):
quantity = 2
customerName = "Damon Cole"
您可以在同一语句中为多个变量分配值。只需在=
的左侧放置多个变量名,并在右侧用逗号分隔相同数量的值即可。
一旦为变量赋值,您可以在任何需要使用原始值的上下文中使用它们:
如果您事先知道变量的值,可以在同一行声明变量并为其赋值:
您可以为现有变量分配新值,但它们需要是相同类型的值。Go 的静态类型确保您不会意外地将错误类型的值分配给变量。
如果在声明变量的同时为其赋值,通常可以省略声明中的变量类型。将赋给变量的值的类型将用作该变量的类型。
零值
如果声明一个变量而没有为其赋值,那么该变量将包含其类型的零值。对于数值类型,零值实际上是0
:
但是对于其他类型,值0
可能是无效的,因此该类型的零值可能是其他值。例如,string
类型变量的零值是空字符串,bool
类型变量的零值是false
。
代码磁铁
一个 Go 程序在冰箱上乱七八糟地摆放着。你能否重构代码片段,使其成为一个能产生指定输出的工作程序?
答案在 “Code Magnets Solution”。
短变量声明
我们提到你可以在同一行声明变量并给它们赋值:
但是,如果你在声明变量的同时知道变量的初始值,使用短变量声明更为典型。而不是显式声明变量类型,然后用 =
赋值,你可以一次完成两者,使用 :=
。
让我们更新之前的示例以使用短变量声明:
不需要显式声明变量的类型;分配给变量的值的类型成为该变量的类型。
因为短变量声明如此方便和简洁,它们比常规声明更常用。尽管如此,你仍会偶尔看到两种形式,所以熟悉这两种形式是很重要的。
Breaking Stuff is Educational!
拿我们使用变量的程序,尝试进行以下修改,并运行它。然后撤消你的更改并尝试下一个。看看会发生什么!
如果你这样做... | ...它会失败,因为... |
---|---|
为相同变量添加第二个声明 quantity := 4 quantity := 4 |
你只能声明一个变量。(尽管你可以随意给它赋新值。你也可以声明同名的其他变量,只要它们在不同的作用域内。我们将在下一章学习作用域。) |
删除短变量声明中的冒号 quantity = 4 |
如果忘记了冒号,它将被视为赋值而不是声明,而且你不能给一个未声明的变量赋值。 |
给 int 变量赋一个 string quantity := 4 quantity = "a" |
变量只能被赋予相同类型的值。 |
变量和值数量不匹配 length, width := 1.2 |
每个要赋值的变量都必须提供一个值,并且每个值都必须有一个对应的变量。 |
移除使用变量的代码 fmt.Println(customerName) |
所有声明的变量必须在你的程序中使用。如果移除了使用变量的代码,也必须移除该声明。 |
命名规则
Go 有一套简单的规则适用于变量、函数和类型的名称:
-
名称必须以字母开头,并且可以有任意数量的额外字母和数字。
-
如果变量、函数或类型的名称以大写字母开头,它被视为导出的,可以从当前包之外的包访问。(这就是为什么
fmt.Println
中的P
要大写:这样它可以从main
包或任何其他包使用。)如果变量/函数/类型名称以小写字母开头,它被视为未导出的,只能在当前包内访问。
这些是语言强制执行的唯一规则。但 Go 社区也遵循一些额外的约定:
-
如果名称由多个单词组成,则从第一个单词开始,每个单词后面的单词应大写,并且它们应该以不加空格连接在一起,如
topPrice
、RetryConnection
等。(如果要将名称导出到包外,则仅应将名称的第一个字母大写。)这种风格通常称为驼峰式,因为大写字母看起来像骆驼的驼峰。 -
当名称的含义在上下文中是显而易见的时,Go 社区的惯例是缩写它:使用
i
替代index
,max
替代maximum
等。(然而,在 Head First 我们认为,在学习新语言时,没有什么是显而易见的,所以在本书中我们不会遵循这种惯例。)
只有以大写字母开头的变量、函数或类型的名称才被视为导出的:可以从当前包之外的包访问。
转换
在 Go 中,数学和比较操作要求包含的值是相同类型的。如果它们不是,则在尝试运行代码时会出错。
给变量赋新值也是如此。如果被赋值的值的类型与变量声明的类型不匹配,你将会得到一个错误。
解决方法是使用类型转换,它允许您将一个值从一种类型转换为另一种类型。您只需在要转换的值的括号中立即提供要转换为的类型。
结果是所需类型的新值。当我们在整数变量中的值上调用 TypeOf
,然后在将其转换为 float64
后再次调用时,我们得到的就是这个:
让我们更新我们失败的代码示例,在任何数学运算或与其他 float64
值比较之前将 int
值转换为 float64
:
现在数学运算和比较都能正常工作了!
现在让我们尝试在将其分配给 float64
变量之前将 int
转换为 float64
:
再次确认转换完成后,分配就成功了。
在进行转换时,请注意它们可能如何改变结果值。例如,float64
变量可以存储分数值,但int
变量则不能。当您将float64
转换为int
时,小数部分将被简单地丢弃!这可能会影响您对结果值进行的任何操作。
只要你小心,转换对于使用 Go 是至关重要的。它们允许原本不兼容的类型一起工作。
在您的计算机上安装 Go
Go Playground 是尝试该语言的好方法。但它的实际用途有限。例如,您无法使用它来处理文件。它也没有办法从终端获取用户输入,而这对我们即将开发的程序非常重要。
因此,在结束本章之前,让我们在您的计算机上下载并安装 Go。别担心,Go 团队已经非常简化了这个过程!在大多数操作系统上,您只需运行一个安装程序,就可以完成安装。
-
在您的 Web 浏览器中访问
golang.org
。 -
点击下载链接。
-
选择适合您操作系统(OS)的安装包。下载应会自动开始。
-
访问您操作系统的安装说明页面(下载开始后可能会自动跳转),按照页面上的指导进行操作。
-
打开一个新的终端或命令提示符窗口。
-
在提示符处键入
**go version**
并按下回车键或 Enter 键确认 Go 是否已安装。您应该会看到安装的 Go 版本信息。
编译 Go 代码
我们与 Go Playground 的互动主要是键入代码并神秘地运行它。现在我们实际上在您的计算机上安装了 Go,是时候更仔细地了解它的工作原理了。
计算机实际上不能直接运行 Go 代码。在此之前,我们需要获取源代码文件并编译它:将其转换为 CPU 可以执行的二进制格式。
让我们尝试使用我们新的 Go 安装程序编译和运行之前的“Hello, Go!”示例。
-
使用您喜欢的文本编辑器,将我们之前的“Hello, Go!”代码保存在一个名为hello.go的纯文本文件中。
-
打开一个新的终端或命令提示符窗口。
-
在终端中,切换到保存hello.go的目录。
-
运行
**go fmt hello.go**
来清理代码格式。(这一步骤不是必需的,但无论如何都是一个好主意。) -
运行
**go build hello.go**
来编译源代码。这将在当前目录中添加一个可执行文件。在 macOS 或 Linux 上,可执行文件将命名为hello。在 Windows 上,可执行文件将命名为hello.exe。 -
运行可执行文件。在 macOS 或 Linux 上,输入
**./hello**
(意思是“在当前目录中运行名为hello
的程序”)。在 Windows 上,只需输入**hello.exe**
。
Go 工具
安装 Go 后,会在命令提示符中添加一个名为go的可执行文件。go可执行文件为您提供访问各种命令的权限,包括:
命令 | 描述 |
---|---|
go build |
将源代码文件编译成二进制文件。 |
go run |
编译并运行程序,而不保存可执行文件。 |
go fmt |
使用 Go 标准格式重新格式化源文件。 |
go version |
显示当前 Go 版本。 |
我们刚刚尝试了go fmt
命令,该命令会按照标准 Go 格式重新格式化您的代码。它相当于 Go Playground 网站上的格式化按钮。我们建议在创建每个源文件后运行go fmt
。
注意
大多数编辑器可以设置为在每次保存文件时自动运行 go fmt!请参阅blog.golang.org/go-fmt-your-code
。
我们还使用go build
命令将代码编译成可执行文件。像这样的可执行文件可以分发给用户,即使他们没有安装 Go,他们也能运行它们。
但我们还没有尝试过go run
命令。现在让我们来试试吧。
使用“go run”快速试验代码
go run
命令编译并运行源文件,而不会将可执行文件保存到当前目录。这对于快速尝试简单程序非常有用。让我们用它来运行我们的hello.go示例。
-
打开新的终端或命令提示窗口。
-
在终端中,切换到保存hello.go的目录。
-
输入
**go run hello.go**
并按 Enter/Return 键。 (该命令在所有操作系统上都相同。)
您将立即看到程序输出。如果对源代码进行更改,您不必进行单独的编译步骤;只需使用go run
运行代码,即可立即查看结果。在编写小型程序时,go run
是一个很方便的工具!
您的 Go 工具箱
至此为止,第一章就讲完了!您已经把函数调用和类型添加到了您的工具箱中。
注意
函数调用
函数是程序中可以从其他位置调用的代码块。
在调用函数时,可以使用参数为函数提供数据。
注意
类型
Go 中的值被分类为不同的类型,这些类型指定了值可以用于什么。
数学操作和不同类型之间的比较不允许,但如果需要,可以将值转换为新类型。
Go 变量只能存储其声明类型的值。
池谜题解决方案
代码磁铁解决方案
第二章:哪些代码将会接下来运行?:条件语句和循环
每个程序都有仅在特定情况下适用的部分。“如果出现错误,则应运行此代码。否则,应运行其他代码。” 几乎每个程序包含应仅在某个条件为真时运行的代码。因此,几乎每种编程语言都提供了条件语句,让您可以确定是否运行代码段。Go 也不例外。
你可能还需要使您的一些代码运行重复。像大多数语言一样,Go 提供了可以多次运行代码段的循环。我们将在本章学习如何同时使用条件语句和循环!
调用方法
在 Go 中,可以定义方法:与给定类型的值相关联的函数。Go 方法有点像您可能在其他语言中附加到“对象”的方法,但它们稍微简单一些。
我们将详细讨论方法如何在第九章中工作。但我们需要使用一些方法来使本章的示例工作,所以让我们现在看一些调用方法的简短示例。
time
包有一个 Time
类型,表示日期(年、月和日)和时间(时、分、秒等)。每个 time.Time
值都有一个 Year
方法,返回年份。下面的代码使用此方法打印当前年份:
time.Now
函数返回当前日期和时间的新 Time
值,我们将其存储在 now
变量中。然后,我们在 now
引用的值上调用 Year
方法:
Year
方法返回一个整数年份,然后我们将其打印出来。
方法是与特定类型的值相关联的函数。
strings
包中有一个 Replacer
类型,它可以搜索字符串中的子字符串,并将每个出现的子字符串替换为另一个字符串。下面的代码用字母 o
替换了字符串中的每个 #
符号:
strings.NewReplacer
函数接受两个参数,第一个是要替换的字符串("#"
),第二个是要替换为的字符串("o"
),并返回一个 strings.Replacer
。当我们将字符串传递给 Replacer
值的 Replace
方法时,它将返回一个已进行替换的字符串。
点号指示点右侧的东西属于左侧的东西。
与我们早些时候看到的函数属于包不同,方法属于一个单独的值。这个值是出现在点号左侧的内容。
取得好成绩
在本章中,我们将探讨 Go 的一些特性,这些特性让您可以根据条件决定是否运行一些代码。让我们看一个可能需要这种能力的情况...
我们需要编写一个程序,允许学生输入他们的百分比成绩,并告诉他们是否通过了。通过或失败遵循一个简单的规则:60%或更高的成绩为通过,低于 60%为失败。因此,如果用户输入的百分比是 60 或更高,我们的程序将需要给出一种响应;否则给出另一种响应。
注释
让我们创建一个新文件,pass_fail.go,来保存我们的程序。我们将处理之前程序中遗漏的一个细节,并在顶部添加一个描述程序功能的描述。
大多数 Go 程序在其源代码中包含了描述程序功能的注释,这些注释供维护程序的人阅读。编译器会忽略这些注释。
最常见的注释形式是用两个斜杠字符(//
)标记的。从斜杠开始到行末的所有内容都视为注释的一部分。//
注释可以单独出现在一行上,也可以跟随在代码行后面。
// The total number of widgets in the system.
var TotalCount int // Can only be a whole number.
不太常用的注释形式是块注释,跨越多行。块注释以/*
开头,以*/
结束,两者之间的所有内容(包括换行)都是注释的一部分。
/*
Package widget includes all the functions used
for processing widgets.
*/
获取用户的成绩
现在让我们向我们的 pass_fail.go 程序添加一些实际的代码。它首先需要做的是允许用户输入一个百分比成绩。我们希望他们输入一个数字并按回车,我们将把他们输入的数字存储在一个变量中。让我们添加处理此操作的代码。(注意:此代码将不能按照显示的方式编译,我们稍后会讨论原因!)
首先,我们需要让用户知道输入内容,因此我们使用fmt.Print
函数显示一个提示。 (与Println
函数不同,Print
在打印消息后不会跳到新的终端行,这使我们可以保持提示和用户输入在同一行上。)
接下来,我们需要一种方法从程序的标准输入中读取(接收和存储)输入,所有键盘输入都将进入这里。行reader := bufio.NewReader(os.Stdin)
在变量reader
中存储了一个能够执行此操作的bufio.Reader
。
要实际获取用户的输入,我们在Reader
上调用ReadString
方法。ReadString
方法需要一个标志输入结束的符文(字符),我们希望读取用户按下回车键之前的所有内容,因此我们给ReadString
传递一个换行符符文。
一旦我们获取了用户的输入,我们就简单地将其打印出来。
那就是计划,但是如果我们试图编译或运行这个程序,我们将会得到一个错误:
函数或方法的多返回值
我们试图读取用户的键盘输入,但是出现了错误。编译器在这行代码中报告了一个问题:
问题在于 ReadString
方法试图返回两个值,但我们只提供了一个变量来接收值。
在大多数编程语言中,函数和方法只能有一个返回值,但在 Go 语言中,它们可以返回任意数量的值。在 Go 语言中多返回值的最常见用法是返回额外的错误值,可以通过它来查找函数或方法执行过程中是否出现了问题。以下是一些例子:
Go 语言不允许我们声明变量而不使用它。
Go 语言要求每个被声明的变量在程序中必须被使用。如果我们添加了一个 err
变量但没有检查它,我们的代码就无法编译通过。未使用的变量通常表示程序中可能存在的错误,这是 Go 语言帮助你检测和修复错误的一个例子!
选项 1:使用空白标识符忽略错误返回值
ReadString
方法除了返回用户输入的值外,还返回一个第二个值,我们需要对这第二个值做些处理。我们尝试添加第二个变量并忽略它,但我们的代码仍然无法编译通过。
当我们有一个通常会被赋值给变量但我们不打算使用的值时,可以使用 Go 语言的空白标识符。将值赋给空白标识符实际上是将其丢弃(同时也向读者明确表明你正在这样做)。要使用空白标识符,在赋值语句中简单地输入一个下划线( _
)字符,替代你通常会输入的变量名。
让我们试着用空白标识符替代我们以前的 err
变量:
现在我们来尝试这个变化。在你的终端中,切换到保存了 pass_fail.go 文件的目录,并使用以下命令运行程序:
当你在提示符处输入一个成绩(或任何其他字符串)并按 Enter 键时,你输入的内容将被回显给你。我们的程序正在工作!
选项 2:处理错误
这是真的。如果真的发生错误,这个程序就不会告诉我们!
如果我们从 ReadString
方法得到一个错误,空白标识符将会忽略这个错误,并且我们的程序会继续运行,可能会带来无效的数据。
在这种情况下,如果出现错误,最好通知用户并停止程序。
log
包中有一个 Fatal
函数可以同时执行这两个操作:向终端记录消息并停止程序运行。在这里,Fatal
表示报告一个“致命”的错误,即会“杀死”你的程序。
让我们去掉空白标识符,将其替换为 err
变量,这样我们再次记录错误。然后,我们将使用 Fatal
函数记录错误并停止程序。
但如果我们尝试运行这个更新后的程序,我们会发现有一个新问题...
条件语句
如果我们的程序在从键盘读取输入时遇到问题,我们已设置程序报告错误并停止运行。但现在,即使一切正常,它也停止运行!
像ReadString
这样的函数和方法返回一个值为nil的错误,这基本上意味着“没有任何内容”。换句话说,如果err
是nil
,那么没有错误。但是我们的程序设置为简单地报告nil
错误!我们应该的是只有在err
变量的值不是nil
时才退出程序。
我们可以通过条件语句来实现这一点:这些语句会导致仅在满足条件时执行一个代码块(一个或多个被{}
大括号包围的语句)。
表达式被评估,如果结果为true
,则执行条件块中的代码。如果结果为false
,则跳过条件块。
与大多数其他语言一样,Go 支持条件中的多个分支。这些语句采用if
...else if
...else
的形式。
if grade == 100 {
fmt.Println("Perfect!")
} else if grade >= 60 {
fmt.Println("You pass.")
} else {
fmt.Println("You fail!")
}
条件语句依赖于布尔表达式(一个评估为true
或false
的表达式),以决定它们包含的代码是否应该被执行。
当你需要仅在条件false时执行代码时,可以使用!
,布尔取反运算符,它可以将true
变为false
,或者将false
变为true
。
如果你想只在两个条件都为真时运行一些代码,你可以使用&&
(“和”)运算符。如果你希望在两个条件之一为真时运行,你可以使用||
(“或”)运算符。
没有愚蠢的问题
Q: 我的另一种编程语言要求**if**
语句的条件必须用括号括起来。Go 也是这样吗?
A: 不是,并且事实上,go fmt
工具会删除您添加的任何括号,除非您用它们来设置操作顺序。
有条件地记录致命错误
即使我们的成绩评估程序成功从键盘读取输入,也会报告错误并退出。
我们知道如果err
变量的值为nil
,那么从键盘读取是成功的。既然我们知道了if
语句,让我们尝试更新我们的代码,只有当err
不是nil
时才记录错误并退出。
如果我们重新运行我们的程序,我们会看到它又开始工作了。现在,如果在读取用户输入时有任何错误,我们也会看到这些错误!
代码磁铁
冰箱上有一个打印文件大小的 Go 程序。它调用os.Stat
函数,该函数返回一个os.FileInfo
值,可能还有一个错误值。然后它调用FileInfo
值上的Size
方法以获取文件大小。
但原始程序使用_
空白标识符来忽略从os.Stat
返回的错误值。如果发生错误(例如文件不存在),这将导致程序失败。
重构额外的代码片段,使其功能与原始程序完全相同,但还要检查来自os.Stat
的错误。如果os.Stat
返回的错误不是nil
,则应报告错误并退出程序。丢弃带有_
空白标识符的磁铁;它在最终程序中不会被使用。
答案在“代码磁铁解决方案”中。
避免名称遮蔽
fmt.Print("Enter a grade: ")
reader := bufio.NewReader(os.Stdin)
input, err := reader.ReadString('\n')
if err != nil {
log.Fatal(err)
}
命名一个变量为**error**
是一个坏主意,因为它会遮蔽名为**error**
的类型的名称。
当您声明一个变量时,应确保它与任何现有的函数、包、类型或其他变量名称不同。如果在封闭作用域中存在同名内容(我们稍后会讨论作用域),则您的变量将遮蔽它——即优先使用它。而这往往是件坏事。
在这里,我们声明了一个名为int
的变量,它遮蔽了一个类型名称,一个名为append
的变量,它遮蔽了一个内置函数名称(我们将在第六章中看到append
函数),以及一个名为fmt
的变量,它遮蔽了一个导入的包名称。这些名称可能会让人感到尴尬,但它们本身并不会引发任何错误...
...但是,如果我们尝试访问变量遮蔽的类型、函数或包,将会获取变量中的值而不是预期的类型。在这种情况下,会导致编译错误:
为了避免自己和其他开发者的混淆,您应尽可能避免名称遮蔽。在这种情况下,修复问题就像选择与变量名不冲突的名称一样简单:
正如我们将在第三章中看到的那样,Go 语言有一个名为error
的内置类型。因此,当声明用于保存错误的变量时,我们使用err
而不是error
的名称——我们希望避免用变量名遮蔽error
类型的名称。
如果您将变量命名为error
,您的代码可能仍然能够工作。也就是说,直到您忘记error
类型名被遮蔽,并尝试使用该类型时,实际获取的是变量。不要冒这个风险;为您的错误变量使用名称err
!
将字符串转换为数字
条件语句还允许我们评估输入的成绩。让我们添加一个 if
/else
语句来确定成绩是及格还是不及格。如果输入的百分比成绩为 60 或更高,我们将将状态设置为 "passing"
。否则,设置为 "failing"
。
// package and import statements omitted
func main() {
fmt.Print("Enter a grade: ")
reader := bufio.NewReader(os.Stdin)
input, err := reader.ReadString('\n')
if err != nil {
log.Fatal(err)
}
if input >= 60 {
status := "passing"
} else {
status := "failing"
}
}
但是,按其当前形式,这会导致编译错误。
问题在于:键盘输入的 input
被读取为字符串。Go 只能将数字与其他数字进行比较;我们无法将数字与字符串进行比较。而且,从 string
直接转换为数字没有直接的类型转换:
我们需要解决一对问题:
-
input
字符串仍然在末尾具有换行符,因为用户输入时按 Enter 键。我们需要将其去除。 -
字符串的剩余部分需要转换为浮点数。
从 input
字符串末尾去除换行符将很容易。strings
包有一个 TrimSpace
函数,将从字符串开头和末尾去除所有空白字符(换行符、制表符和普通空格)。
因此,我们可以通过将其传递给 TrimSpace
来去除 input
上的换行符,并将返回值分配回 input
变量。
input = strings.TrimSpace(input)
现在 input
字符串中应该只剩下用户输入的数字。我们可以使用 strconv
包的 ParseFloat
函数将其转换为 float64
值。
您将 ParseFloat
传递给要转换为数字的字符串,以及结果应具有的精度位数。因为我们要转换为 float64
值,所以传递数字 64
。(除了 float64
,Go 还提供了不那么精确的 float32
类型,但除非有充分的理由,否则不应使用。)
ParseFloat
将字符串转换为数字,并作为 float64
值返回。像 ReadString
一样,它还有一个第二个返回值,即错误,除非有某些问题转换字符串(例如,无法转换为数字的字符串。我们不知道 "hello"
的数字等价物...)
让我们更新 pass_fail.go,调用 TrimSpace
和 ParseFloat
:
首先,我们向 import
部分添加适当的包。我们添加代码以从 input
字符串中删除换行符。然后将 input
传递给 ParseFloat
,并将生成的 float64
值存储在新变量 grade
中。
就像我们在 ReadString
中所做的那样,我们测试 ParseFloat
是否返回错误值。如果返回错误,我们报告错误并停止程序。
最后,我们更新条件语句,以测试 grade
中的数字,而不是 input
中的字符串。这应该修复由于比较字符串与数字而导致的错误。
如果我们尝试运行更新后的程序,就不再会出现类型不匹配的字符串和整数
错误。看起来我们已经解决了那个问题。但是还有几个错误需要解决。接下来我们会看看这些。
块
我们已将用户的成绩输入转换为float64
值,并将其添加到条件语句中以确定是否通过或失败。但我们又遇到了几个编译错误:
正如我们之前看到的,像status
这样声明一个变量,而后不使用它是 Go 语言中的一个错误。我们得到错误两次似乎有些奇怪,但现在先不管它。我们将在Println
中添加一个调用,打印给定的百分比成绩和status
的值。
但是现在我们又得到了一个新的错误,说在我们的Println
语句中尝试使用status
变量时未定义!出了什么问题?
Go 代码可以被划分为块,代码段。块通常用花括号({}
)括起来,虽然在源代码文件和包级别也有块。块可以嵌套在彼此内部。
函数体和条件语句的主体也都是块。理解这一点将对解决我们在status
变量上的问题至关重要…
块和变量作用域
每个你声明的变量都有一个作用域:它在代码中“可见”的一部分。声明的变量可以在其作用域内的任何地方访问,但如果你尝试在该作用域之外访问它,就会收到一个错误。
变量的作用域包括它声明的块及其嵌套在其中的任何块。
以上是代码中变量的作用域:
-
packageVar
的作用域是整个main
包。你可以在包中定义的任何函数内部的任何地方访问packageVar
。 -
functionVar
的作用域是它所声明的整个函数,包括该函数内部嵌套的if
块。 -
conditionalVar
的作用域仅限于if
块。当我们在if
块的闭合}
后尝试访问conditionalVar
时,将会收到一个错误,提示conditionalVar
未定义!
现在我们理解了变量作用域,我们可以解释为什么我们的status
变量在评分程序中未定义了。我们在条件块中声明了status
。(事实上,我们声明了两次,因为有两个单独的块。这就是为什么我们会得到两个status declared and not used
错误。)但是后来我们试图在那些块的外部访问status
,此时它已经不在作用域内了。
解决方案是将status
变量的声明移出条件块,并移到函数块顶部。这样一来,status
变量将在嵌套的条件块内部和函数块的末尾都可以访问。
我们已完成评分程序!
就这样!我们的pass_fail.go程序已经准备就绪!让我们再看一下完整的代码:
你可以随意运行完成的程序多次。输入低于 60 的百分比成绩,它将报告不及格状态。输入超过 60 的成绩,它将报告及格状态。看起来一切正常运行!
短变量声明中只有一个变量必须是新的
的确,当在同一作用域内声明同名变量两次时,我们会得到一个编译错误:
但只要短变量声明中至少有一个变量名是新的,就是允许的。新变量名被视为声明,现有变量名被视为赋值。
有一个特殊处理的原因:很多 Go 函数返回多个值。如果你因为想重用其中一个变量而不得不单独声明所有变量会很麻烦。
相反,Go 允许你为所有东西使用短变量声明,即使对于一个变量,它实际上是一个赋值操作。
让我们来制作一个游戏
我们将通过制作一个简单的游戏来结束本章。如果听起来有些艰巨,不用担心;你已经学会了大部分你将需要的技能!在此过程中,我们将学习关于循环的知识,这将允许玩家进行多次回合。
让我们看看我们需要做的所有事情:
注意
这个例子首次出现在《Head First Ruby》中。(另一本你也应该买的好书!)它非常成功,所以我们在这里再次使用它。
图 2-1. 加里·理查德特 游戏设计师
让我们创建一个名为guess.go的新源文件。
看起来我们的第一个要求是生成一个随机数。让我们开始吧!
包名与导入路径
math/rand
包有一个Intn
函数可以为我们生成一个随机数,所以我们需要导入math/rand
。然后我们将调用rand.Intn
来生成随机数。
一个是包的导入路径,另一个是包的名称。
当我们说math/rand
时,我们指的是包的导入路径,而不是名称。导入路径只是一个唯一的字符串,用于标识一个包,并在import
语句中使用。导入了包之后,可以用其包名引用它。
到目前为止,我们使用的每个包的导入路径都与包名相同。以下是一些示例:
导入路径 | 包名 |
---|---|
"fmt" |
fmt |
"log" |
log |
"strings" |
strings |
但导入路径和包名不一定相同。许多 Go 包属于类似的类别,如压缩或复杂数学。因此,它们被分组到类似的导入路径前缀下,如 "archive/"
或 "math/"
。(可以把它们想象成硬盘驱动器上目录的路径。)
导入路径 | 包名 |
---|---|
"archive" |
archive |
"archive/tar" |
tar |
"archive/zip" |
zip |
"math" |
math |
"math/cmplx" |
cmplx |
"math/rand" |
rand |
虽然 Go 语言不要求包名与其导入路径有任何关系,但按照惯例,导入路径的最后(或唯一)段也用作包名。因此,如果导入路径是 "archive"
,包名将是 archive
,如果导入路径是 "archive/zip"
,包名将是 zip
。
导入路径 | 包名 |
---|---|
"archive" |
archive |
"archive/tar" |
tar |
"archive/zip" |
zip |
"math" |
math |
"math/cmplx" |
cmplx |
"math/rand" |
rand |
所以,这就是为什么我们的 import
语句使用路径 "math/rand"
,但我们的 main
函数只使用包名 rand
。
生成一个随机数
将一个数字传递给 rand.Intn
,它将返回一个介于 0
和你提供的数字之间的随机整数。换句话说,如果我们传递一个参数 100
,我们将得到一个在 0 到 99 范围内的随机数。由于我们需要的是 1 到 100 范围内的数,我们将随机值加 1
。我们将结果存储在变量 target
中。稍后我们会进一步处理 target
,但现在我们只需打印它。
如果我们现在尝试运行程序,我们将得到一个随机数。但我们每次都只得到相同的随机数!问题在于,计算机生成的随机数并不是真正随机的。但有一种方法可以增加这种随机性……
为了获得不同的随机数,我们需要向 rand.Seed
函数传递一个值。这将“种子”随机数生成器,即给它一个值,它将用来生成其他随机值。但如果我们继续给它相同的种子值,它将一直给我们相同的随机值,我们将回到原点。
我们之前看到 time.Now
函数会给我们一个代表当前日期和时间的 Time
值。我们可以用它来获得每次运行程序时都不同的种子值。
函数 rand.Seed
需要一个整数作为参数,所以我们不能直接传递一个 Time
值。相反,我们在 Time
上调用 Unix
方法,它会将其转换为一个整数(具体来说,它会转换为 Unix 时间格式,这是一个自 1970 年 1 月 1 日以来的秒数整数。但你不必真正记住这些。)我们将这个整数传递给 rand.Seed
。
我们还添加了几个Println
调用,让用户知道我们选择了一个随机数。但除此之外,我们可以保留其余代码,包括对rand.Intn
的调用。播种生成器应该是我们需要做的唯一更改。
现在,每次运行我们的程序,我们都会看到我们的消息,以及一个随机数。看起来我们的更改是成功的!
从键盘获取一个整数
我们的第一个要求已完成!接下来我们需要通过键盘获取用户的猜测。
这应该与我们为分级程序从键盘读取百分比成绩时的方式基本相同。
只有一个区别:我们需要将输入转换为int
(因为我们的猜数字游戏只使用整数)。因此,我们将从键盘读取的字符串传递给strconv
包的Atoi
(字符串转整数)函数,而不是它的ParseFloat
函数。Atoi
将返回一个整数作为其返回值。(就像ParseFloat
一样,如果无法转换字符串,Atoi
也可能会给我们一个错误。如果发生这种情况,我们再次报告错误并退出。)
将猜测与目标进行比较
另一个要求完成了。接下来将很容易...我们只需要将用户的猜测与随机生成的数字进行比较,并告诉他们猜测是高还是低。
如果guess
小于target
,我们需要打印一条消息说猜测偏低。否则,如果guess
大于target
,我们应该打印一条消息说猜测偏高。听起来我们需要一个if
...else if
语句。我们将在main
函数中的其他代码下面添加它。
现在尝试从终端运行我们更新后的程序。它仍然设置为每次运行时打印target
,这对于调试很有用。只需输入一个低于target
的数字,你应该会被告知你的猜测偏低。如果重新运行程序,你将得到一个新的target
值。输入一个高于该值的数字,你将被告知你的猜测偏高。
循环
又一个要求完成了!让我们看看下一个。
目前,玩家只能猜一次,但我们需要允许他们最多猜10
次。
提示玩家猜测的代码已经就位。我们只需要多次运行它。我们可以使用循环来重复执行一段代码。如果你使用过其他编程语言,你可能遇到过循环。当你需要一条或多条语句重复执行时,你将它们放在循环内部。
循环总是以for
关键字开头。在一种常见的循环中,for
后面跟着三个控制循环的代码段:
-
一个初始化语句,通常用于初始化一个变量
-
一个条件表达式,确定何时退出循环
-
后置语句在每次循环迭代后运行
通常,初始化语句用于初始化变量,条件表达式使循环在变量达到特定值之前继续运行,后置语句用于更新该变量的值。例如,在此片段中,t
变量初始化为 3
,条件为 t > 0
时循环继续运行,并且后置语句每次循环时从 t
减去 1
。最终,t
达到 0
时循环结束。
++
和 --
语句经常用作循环后置语句。每次评估它们时,++
将 1
添加到变量的值,而 --
将 1
减去。
++
和 --
在循环中使用时,便于计数向上或向下。
Go 还包括赋值运算符 +=
和 -=
。它们获取变量中的值,添加或减去另一个值,然后将结果重新赋给变量。
+=
和 -=
可以在循环中使用,以便按照除 1
外的增量计数。
当循环结束时,执行将恢复到循环块后面的语句。但只要条件表达式评估为true
,循环将继续进行。这可能被滥用;以下是永远运行的循环示例和根本不会运行的循环示例:
初始化语句和后置语句是可选的
如果愿意,您可以从 for
循环中省略初始化和后置语句,仅留下条件表达式(尽管您仍然需要确保条件最终评估为 false
,否则您可能会遇到无限循环的问题)。
循环和作用域
就像条件语句一样,循环块内声明的任何变量的作用域仅限于该块(尽管初始化语句、条件表达式和后置语句也可视为该作用域的一部分)。
就像条件语句一样,在循环的控制语句和块之前声明的任何变量仍然在循环内的作用域内,并且在循环退出后仍然在作用域内。
破坏东西是教育性的!
这是一个使用循环计数到 3
的程序。尝试进行以下更改并运行它。然后撤消您的更改并尝试下一个。看看会发生什么!
如果你这样做... | ...它会中断,因为... |
---|---|
在 for 关键字后加上括号 for (x := 1; x <= 3; x++) |
某些其他语言要求在 for 循环的控制语句周围加上括号,但是 Go 不仅不需要它们,而且禁止使用它们。 |
从初始化语句x = 1 中删除: |
除非你正在给封闭作用域中已声明的变量赋值(通常情况下不会这样),初始化语句必须是一个声明,而不是一个赋值。 |
从条件表达式x < 3 中移除= |
当x 达到3 时,表达式x < 3 变为false (而x <= 3 仍然为true )。因此,循环只会计数到2 。 |
反转条件表达式x >= 3 中的比较 |
因为条件在循环开始时已经是false (x 被初始化为1 ,比3 小),所以循环永远不会运行。 |
将后置语句从x++ 改为x-- x-- |
x 变量将从1 开始递减(1 、0 、-1 、-2 等),并且由于它永远不会大于3 ,因此循环永远不会结束。 |
将fmt.Println(x) 语句移到循环块之外 |
在初始化语句或循环块内声明的变量只在循环块内有效。 |
在我们的猜测游戏中使用循环
我们的游戏仍然只提示用户一次猜测。让我们在提示用户猜测并告知他们是否太低或太高的代码周围添加一个循环,以便用户可以猜测 10 次。
我们将使用一个名为guesses
的int
变量来跟踪玩家已经猜测的次数。在循环的初始化语句中,我们将guesses
初始化为0
。每次循环迭代时,我们将guesses
加1
,当guesses
达到10
时,我们将停止循环。
我们还将在循环块的顶部添加一个Println
语句,告诉用户剩余的猜测次数。
现在我们的循环已经就位,如果再次运行游戏,我们将被询问 10 次猜测的内容!
由于用于提示猜测并声明是否过高或过低的代码位于循环内部,因此它会被重复运行。经过 10 次猜测后,循环(和游戏)将结束。
但是即使玩家猜对了,循环也总是运行 10 次!修复这个问题将是我们的下一个要求。
使用“continue”和“break”跳过循环的部分
辛苦部分已经完成!我们只剩下几个要求需要完成。
目前,提示用户猜测的循环总是运行 10 次。即使玩家猜对了,我们也不告诉他们,并且我们不停止循环。我们的下一个任务是修复这个问题。
Go 语言提供了两个控制循环流程的关键字。第一个是continue
,立即跳到循环的下一次迭代,而不执行循环块中的任何其他代码。
在上述示例中,字符串"after continue"
永远不会被打印,因为continue
关键字总是在第二次调用Println
之前跳回循环顶部。
第二个关键字,break
,立即中断循环。循环块内部的代码不再执行,也不再进行进一步的迭代。执行转移到循环后的第一个语句。
在循环的第一次迭代中,字符串 "before break"
被打印出来,但是 break
语句立即中断了循环,没有打印 "after break"
字符串,并且不再运行循环(尽管通常还会再运行两次)。执行转而移到循环后的语句。
break
关键字似乎适用于我们当前的问题:当玩家猜测正确时,我们需要中断循环。让我们在游戏中尝试使用它...
退出我们的猜测循环
我们正在使用 if
...else if
条件语句来告诉玩家他们猜测的状态。如果玩家猜测的数字太高或太低,我们目前会打印一条消息告诉他们。
如果猜测既不太高也不太低,那么它必须是正确的。因此,让我们在条件语句上添加一个 else
分支,在猜测正确的情况下运行。在 else
分支的块内部,我们会告诉玩家他们猜对了,并使用 break
语句来停止猜测循环。
现在,当玩家猜测正确时,他们将看到一条祝贺的消息,并且循环将在不再完全重复 10 次的情况下退出。
又完成了另一个要求!
揭示目标
我们离成功如此近!只剩下一个要求了!
如果玩家猜测了 10 次仍未找到目标数字,则循环将退出。在这种情况下,我们需要打印一条消息告诉他们输了,并告诉他们目标是什么。
但是,如果玩家猜对了,我们也会退出循环。我们不希望在玩家已经赢得胜利时说他们输了!
因此,在我们的猜测循环之前,我们将声明一个 success
变量,它保存一个布尔值。(我们需要在循环之前声明它,以便在循环结束后仍然在范围内。)我们将 success
初始化为默认值 false
。然后,如果玩家猜对了,我们将 success
设置为 true
,表示我们不需要打印失败消息。
在循环之后,我们添加了一个 if
块来打印失败消息。但是 if
块只有在条件为 true
时才会执行,我们只想在 success
为 false
时打印失败消息。因此,我们添加了布尔取反运算符(!
)。正如我们之前看到的,!
将 true
变为 false
,将 false
变为 true
。
结果是,如果 success
是 false
,则会打印失败消息,但如果 success
是 true
,则不会打印。
最后的润色
恭喜,这是最后一个要求!
让我们处理一些代码的最后问题,然后试试我们的游戏!
首先,正如我们提到的,每个 Go 程序顶部通常添加一条注释来描述其功能。现在让我们添加一条。
我们的程序还通过在每次游戏开始时打印目标数字来鼓励作弊者。让我们删除执行此操作的 Println
调用。
我们终于准备好尝试运行我们的完整代码了!
首先,我们故意用尽猜测次数以确保目标数字被显示...
然后我们将尝试成功猜测。
我们的游戏运行得很顺利!
恭喜,您的游戏已完成!
使用条件和循环,您已在 Go 中编写了一个完整的游戏!请为自己倒一杯冷饮——您赢得了它!
这是我们完整的 guess.go 源代码!
你的 Go 工具箱
这就是第二章的全部内容!您已将条件和循环添加到您的工具箱中。
注意
循环
循环使一段代码重复执行。
一种常见的循环以关键字“for”开头,后跟初始化语句以初始化变量,条件表达式以确定何时退出循环,并且后置语句在每次循环迭代之后运行。
代码磁铁解决方案
一个打印文件大小的 Go 程序挂在冰箱上。它调用 os.Stat
函数,该函数返回一个 os.FileInfo
值和可能的错误。然后它调用 FileInfo
值上的 Size
方法来获取文件大小。
最初的程序使用 _
空标识符来忽略 os.Stat
中的错误值。如果发生错误(例如文件不存在),这会导致程序失败。
你的任务是重建额外的代码片段,使得程序像原始程序一样运行,但还要检查 os.Stat
的错误。如果 os.Stat
的错误不为 nil
,则应报告错误并退出程序。
第三章:请打电话给我:函数
你一直都在错过。你已经像专业人士一样调用函数了。但是你能调用的函数只有 Go 为你定义的那些。现在轮到你了。我们将向你展示如何创建自己的函数。我们将学习如何声明带有和不带参数的函数。我们将声明返回单个值的函数,并学习如何返回多个值,以便在发生错误时指示。我们还将学习指针,它们允许我们进行更高效的内存管理。
一些重复的代码
假设我们需要计算涂料涂抹若干墙壁所需的量。制造商称每升涂料可以覆盖 10 平方米。因此,我们需要将每堵墙的宽度(以米为单位)乘以其高度,得到其面积,然后除以 10 得到所需的涂料升数。
这样做虽然可行,但存在几个问题:
-
计算结果似乎有微小的误差,并且打印出来的浮点数值显得异常精确。我们实际上只需要几位小数的精度。
-
即使现在也有相当多的重复代码。随着我们添加更多的墙壁,这个问题会变得更糟。
对于这两个问题都需要一些解释,所以现在先来看看第一个问题...
这些计算略有偏差,因为计算机上的普通浮点数算术略微不精确。(通常是几百万亿分之几。)造成这种情况的原因有点复杂,这里不便深究,但这个问题并非 Go 所独有。
但只要我们在显示之前将数字四舍五入到一个合理的精度,那就没问题。让我们稍作停顿,看看一个可以帮助我们做到这一点的函数。
使用 Printf 和 Sprintf 格式化输出
Go 中的浮点数保持着高度的精确度。当你想要显示它们时,这可能会有些麻烦:
为了处理这类格式化问题,fmt
包提供了 Printf
函数。Printf
代表“打印,带有格式化”。它接受一个字符串,并在其中插入一个或多个值,以特定的方式格式化。然后打印出结果字符串。
Sprintf
函数(也是 fmt
包的一部分)与 Printf
函数的工作方式几乎一样,唯一的区别是它返回格式化后的字符串而不是直接打印出来。
看起来 Printf
和 Sprintf
可以 帮助我们限制所显示的值的正确位数。问题在于,如何?首先,为了能够有效地使用 Printf
函数,我们需要了解它的两个特性:
-
格式化动词(上述字符串中的
%0.2f
是一个动词) -
值的宽度(这是动词中间的
0.2
)
格式化动词
Printf
的第一个参数是一个字符串,将用于格式化输出。大部分字符串的格式化方式与其显示的方式完全一样。然而,任何百分号(%
)都将被视为格式化动词的开始,这部分字符串将被替换为特定格式的值。其余的参数被用作这些动词的值。
百分号后面的字母表示要使用的动词。最常见的动词包括:
动词 | 输出 |
---|---|
%f | 浮点数 |
%d | 十进制整数 |
%s | 字符串 |
%t | 布尔值(true 或 false ) |
%v | 任意值(根据提供的值的类型选择适当的格式) |
%#v | 任意值,格式化为 Go 程序代码中的形式 |
%T | 提供的值的类型(int 、string 等) |
%% | 字面上的百分号 |
顺便提一下,我们确保在每个格式化字符串的末尾添加一个换行符 \n
转义序列。这是因为与 Println
不同,Printf
不会为我们自动添加换行符。
我们特别想指出 %#v
格式化动词。因为它打印值的方式类似于它们在 Go 代码中的显示方式,而不是它们通常的显示方式,因此 %#v
可以显示出在 %v
中隐藏的一些值。例如,在这段代码中,%#v
显示了一个空字符串、一个制表符和一个换行符,这些在使用 %v
打印时是看不到的。我们将在本书的后续部分更多地使用 %#v
!
格式化值的宽度
因此,%f
格式化动词适用于浮点数。我们可以在我们的程序中使用 %f
格式化所需的油漆量。
看起来我们的值被四舍五入到一个合理的数字。但是它仍然显示小数点后的六位,这对于我们当前的目的来说实在太多了。
对于这样的情况,格式化动词允许您指定格式化值的宽度。
假设我们想要在一个纯文本表格中格式化一些数据。我们需要确保格式化后的值填充到最小的空格数,以便列对齐。
您可以在百分号后指定格式化动词的最小宽度。如果与该动词匹配的参数比最小宽度短,它将填充空格,直到达到最小宽度。
格式化小数位数的宽度
现在我们来到了今天任务中重要的部分:您可以使用值的宽度来指定浮点数的精度(显示的数字位数)。这是格式:
整个数字的最小宽度包括小数位和小数点。如果包括小数点,则较短的数字将在开始处用空格填充,直到达到这个宽度。如果省略小数点,则永远不会添加空格。
小数点后的宽度是要显示的小数位数。如果给出了更精确的数字,它将四舍五入(向上或向下)以适应给定的小数位数。
这里快速演示了各种宽度值的效果:
上述格式"%.2f"
,可以将任意精度的浮点数四舍五入到两位小数。(它也不会添加任何不必要的填充。)让我们尝试一下,用我们程序中过于精确的值来计算油漆体积。
现在更易读了。看起来Printf
函数可以为我们格式化数字。让我们回到我们的油漆计算器程序,并应用我们在那里学到的东西。
在我们的油漆计算器中使用 Printf
现在我们有了一个Printf
动词"%.2f"
,它将允许我们将浮点数四舍五入到两位小数。让我们更新我们的油漆数量计算程序来使用它。
最后,我们得到了合理的输出!浮点运算引入的微小不精确性已被四舍五入消除。
好主意。Go 允许我们声明自己的函数,因此也许我们应该将这段代码移到一个函数中。
正如我们在第一章开头提到的,函数是一组或多组可以从程序中的其他位置调用的代码行。我们的程序有两组看起来非常相似的行:
让我们看看能否将这两个代码部分转换为一个单一的函数。
声明函数
简单的函数声明可能看起来像这样:
声明以func
关键字开头,后跟您希望函数具有的名称,一对括号()
,然后是包含函数代码的块。
一旦声明了函数,你可以在包的其他地方简单地输入其名称,后跟一对括号,即可调用它。这样做时,函数块中的代码将被执行。
注意,当我们调用sayHi
时,我们不需要输入包名和点号再输入函数名。当调用当前包中定义的函数时,不应指定包名。(键入main.sayHi()
将导致编译错误。)
函数名称的规则与变量名称的规则相同:
-
名称必须以字母开头,后跟任意数量的其他字母和数字。(如果违反此规则,将会得到编译错误。)
-
函数名以大写字母开头的函数是导出的,可以在当前包之外使用。如果只需在当前包内使用函数,应以小写字母开头命名。
-
多个单词的名称应使用
camelCase
。
声明函数参数
如果希望调用函数时包含参数,必须声明一个或多个参数。参数是函数内部局部变量,在调用函数时设置其值。
可以在函数声明的括号内部声明一个或多个参数,用逗号分隔。与任何变量一样,需要为每个声明的参数提供一个名称,后跟一个类型(float64
、bool
等)。
参数是函数内部局部变量,在调用函数时设置其值。
如果函数定义了参数,则在调用函数时需要传递匹配的参数集。运行函数时,每个参数将设置为对应参数中值的副本。然后在函数块中使用这些参数值。
在我们的油漆计算器中使用函数
现在我们知道如何声明自己的函数了,让我们看看能否消除油漆计算器中的重复。
我们将代码移到名为paintNeeded
的函数中来计算油漆的量。我们将不再使用单独的width
和height
变量,而是将它们作为函数参数传入。然后,在我们的main
函数中,我们只需为需要涂料的每面墙调用paintNeeded
函数。
不再重复的代码,如果我们想要计算额外墙壁所需的涂料,只需添加更多对paintNeeded
的调用。这样更加清晰!
函数和变量作用域
我们的paintNeeded
函数在其函数块内声明了一个area
变量:
与条件和循环块一样,函数块内声明的变量仅在该函数块内可见。因此,如果我们尝试在paintNeeded
函数外部访问area
变量,将会得到编译错误:
但是,与条件和循环块一样,声明在函数块外部的变量将在该块内可见。这意味着我们可以在包级别声明一个变量,并在该包中的任何函数中访问它。
函数返回值
假设我们想要计算所有需要涂料的墙壁的总量。我们无法使用当前的paintNeeded
函数来实现这一点;它只是打印出量然后将其丢弃!
因此,让我们修改paintNeeded
函数来返回一个值。然后,调用它的人可以打印这个量,进行额外的计算,或者做其他他们需要做的事情。
函数总是返回特定类型的值(仅限该类型)。要声明函数返回一个值,需在函数声明的参数后面添加返回值类型。然后在函数块中使用return
关键字,后跟你想要返回的值。
调用函数的人可以将返回值分配给变量,直接传递给另一个函数,或者以其他方式处理它们。
当return
语句执行时,函数立即退出,不再运行其后面的任何代码。你可以结合if
语句使用它,在条件下退出函数,避免运行剩余的代码(由于错误或其他条件)。
这意味着,如果你包括一个不属于if
块的return
语句,可能有代码在任何情况下都不会运行。这几乎肯定表明代码中存在错误,因此 Go 通过要求任何声明返回类型的函数必须以return
语句结束来帮助你检测这种情况。以其他任何语句结尾都会导致编译错误。
如果你的返回值类型与声明的返回类型不匹配,你也会得到编译错误。
在我们的涂料计算器中使用返回值
现在我们知道如何使用函数返回值了,让我们看看是否能更新我们的涂料程序,除了每面墙需要的量之外,还打印总共需要的涂料量。
我们将更新paintNeeded
函数以返回所需的量。我们将在main
函数中使用该返回值,既用于打印当前墙壁的量,又用于添加到total
变量,以跟踪所需的总涂料量。
它奏效了!返回值使我们的main
函数能够决定如何处理计算出的量,而不是依赖于paintNeeded
函数来打印它。
破坏事物是教育性的!
这是我们更新后的paintNeeded
函数版本,它返回一个值。尝试进行以下变更之一并尝试编译它。然后撤销您的更改并尝试下一个。看看会发生什么!
func paintNeeded(width float64, height float64) float64 {
area := width * height
return area / 10.0
}
如果你这样做... | ...它会崩溃,因为... |
---|---|
移除return 语句:func paintNeeded(width float64, height float64) float64 { area := width * height ~~return area / 10.0~~ } |
如果函数声明了返回类型,Go 要求它包含一个return 语句。 |
在return 语句之后添加一行:func paintNeeded(width float64, height float64) float64 { area := width * height return area / 10.0 fmt.Println(area / 10.0) } |
如果函数声明了返回类型,Go 要求其最后一个语句必须是一个return 语句。 |
删除返回类型声明:func paintNeeded(width float64, height float64) float64 { area := width * height return area / 10.0 } |
Go 不允许返回未声明的值。 |
更改返回值的类型:func paintNeeded(width float64, height float64) float64 { area := width * height return int(area / 10.0) } |
Go 要求返回值的类型与声明的类型匹配。 |
paintNeeded
函数需要错误处理
函数paintNeeded
似乎不知道传递给它的参数是无效的。它继续在计算中使用了这个无效的参数,并返回了一个无效的结果。这是一个问题——即使你知道一个商店可以购买负数升的油漆,你真的想把它应用到你的房子上吗?我们需要一种方法来检测无效的参数并报告错误。
在第二章中,我们看到了几个不同的函数,除了它们的主要返回值外,还返回一个指示是否存在错误的第二个值。例如,strconv.Atoi
函数尝试将字符串转换为整数。如果转换成功,它将返回一个nil
的错误值,表示我们的程序可以继续执行。但如果错误值不是nil
,则表示字符串无法转换为数字。在这种情况下,我们选择打印错误值并退出程序。
如果我们在调用paintNeeded
函数时想要做同样的事情,我们将需要两样东西:
-
创建表示错误的值的能力
-
返回
paintNeeded
的额外值的能力
让我们开始弄清楚这个问题吧!
错误值
在我们能够从paintNeeded
函数中返回一个错误值之前,我们需要一个错误值来返回。错误值是指具有名为Error
并返回字符串的方法的任何值。创建一个最简单的方法是将字符串传递给errors
包的New
函数,它将返回一个新的错误值。如果你在该错误值上调用Error
方法,你将得到你传递给errors.New
的字符串。
但是,如果你将错误值传递给fmt
或log
包中的函数,你可能不需要调用它的Error
方法。fmt
和log
中的函数已经被编写成检查传递给它们的值是否具有Error
方法,并在需要时打印Error
的返回值。
如果需要格式化数字或其他值以在错误消息中使用,可以使用fmt.Errorf
函数。它将值插入到格式字符串中,类似于fmt.Printf
或fmt.Sprintf
,但不是打印或返回一个字符串,而是返回一个错误值。
声明多个返回值
现在我们需要一种方式来指定我们的paintNeeded
函数将返回一个错误值和所需油漆的量。
要为函数声明多个返回值,请在函数声明中的第二组括号中放置返回值类型(在函数参数的括号之后),用逗号分隔。(如果只有一个返回值,返回值周围的括号是可选的,但如果有多个返回值,则是必需的。)
从那时起,当调用该函数时,你需要考虑额外的返回值,通常是通过将它们赋值给额外的变量来处理。
如果为返回值提供名称可以使其更清晰,类似于参数名称。命名返回值的主要目的是作为程序员阅读代码的文档。
使用多个返回值与我们的paintNeeded
函数
正如我们在上一页看到的那样,可以返回任意类型的多个值。但多返回值的最常见用途是返回主要的返回值,后面跟着一个额外的值,指示函数是否遇到错误。如果没有问题,额外的值通常设为nil
,如果发生错误则设为错误值。
我们将遵循paintNeeded
函数的这一约定。我们声明它返回两个值,一个float64
和一个error
。(错误值的类型是error
。)在函数块中的第一件事是检查参数是否有效。如果width
或height
参数小于0
,我们将返回油漆量为0
(这是无意义的,但我们必须返回一些东西),并且通过调用fmt.Errorf
生成一个错误值。在函数开始时检查错误使我们可以通过调用return
轻松地跳过函数代码的其余部分,如果有问题的话。
如果参数没有问题,我们将像以前一样继续计算和返回油漆量。函数代码中的唯一区别是,我们返回第二个值nil
与油漆量一起,以表示没有错误。
在main
函数中,我们添加第二个变量来记录来自paintNeeded
的错误值。我们打印错误(如果有的话),然后打印油漆量。
如果我们向paintNeeded
传递一个无效的参数,我们将得到一个错误返回值,并打印该错误。但我们还会得到0
作为涂料量。(正如我们所说,当有错误时,这个值是无意义的,但我们必须对第一个返回值使用某些东西。)因此,我们最终打印出了消息“0.00 升液体需要”!我们需要修复这个问题...
总是处理错误!
当我们向paintNeeded
传递无效的参数时,我们会得到一个错误值,我们将其打印供用户查看。但我们还得到了一个(无效的)涂料量,我们也将其打印了出来!
当函数返回一个错误值时,通常也必须返回一个主要的返回值。但是伴随错误值返回的任何其他返回值应被视为不可靠,并被忽略。
当你调用一个返回错误值的函数时,重要的是在继续之前测试该值是否为nil
。如果它不是nil
,意味着有一个必须处理的错误。
如何处理错误取决于具体情况。在我们的paintNeeded
函数的情况下,最好只是跳过当前计算,并继续执行程序的其余部分:
但由于这是一个如此简短的程序,你可以调用log.Fatal
来显示错误消息并退出程序。
重要的是要记住,你应该始终检查返回值,看看是否有错误。在那一点上,你对错误的处理方式由你决定!
破解东西是教育性的!
这是一个计算数字平方根的程序。但是如果将负数传递给squareRoot
函数,它将返回一个错误值。做出以下一种修改并尝试编译它。然后撤销你的修改并尝试下一种。看看会发生什么!
package main
import (
"fmt"
"math"
)
func squareRoot(number float64) (float64, error) {
if number < 0 {
return 0, fmt.Errorf("can't get square root of negative number")
}
return math.Sqrt(number), nil
}
func main() {
root, err := squareRoot(-9.3)
if err != nil {
fmt.Println(err)
} else {
fmt.Printf("%0.3f", root)
}
}
如果你这样做... | ...它会破解因为... |
---|---|
移除一个return 的参数:return math.Sqrt(number)~~, nil~~ |
return 语句中的参数数量必须始终与函数声明中的返回值数量匹配。 |
移除其中一个变量的返回值赋值:root~~, err~~ := squareRoot(-9.3) |
如果你使用了函数的任何返回值,Go 要求你使用所有的返回值。 |
移除使用其中一个返回值的代码:root, err := squareRoot(-9.3) ~~if err != nil {~~ ~~fmt.Println(err)~~ ~~} else {~~ fmt.Printf("%0.3f", root) ~~}~~ |
Go 要求你使用你声明的每一个变量。这实际上是一个非常有用的特性,特别是在处理错误返回值时,它有助于防止意外忽略错误。 |
池难题
你的任务是从池中选择代码片段,并将它们放入代码中的空白行中。不要重复使用同一个片段,并且不需要使用所有片段。你的目标是编写能够运行并产生所示输出的代码。
注意:每个池中的片段只能使用一次!
答案在 “池谜题解答”。
函数参数接收参数的副本
正如我们提到的,当你调用声明了参数的函数时,需要为调用提供参数。每个参数中的值都被复制到对应的参数变量中。(执行此操作的编程语言有时被称为“传值”。)
Go 是一种“传值”语言;函数参数接收函数调用中参数的一个副本。
在大多数情况下这没问题。但是如果你想将变量的值传递给函数,并以某种方式更改该值,你会遇到麻烦。函数只能更改其参数中值的副本,而不能更改原始值。因此,在函数内部进行的任何更改都不会在函数外部可见!
这是我们之前展示的 double
函数的更新版本。它接受一个数字,将其乘以 2,并打印结果。(它使用 *=
运算符,工作方式与 +=
相同,但它将变量的值乘以而不是加上。)
假设我们想要将打印加倍值的语句从 double
函数移回调用它的函数中。这是行不通的,因为 double
只会修改它的值副本。回到调用函数时,我们将得到原始值,而不是加倍后的值!
我们需要一种方法,允许函数改变变量原始值,而不是副本。为了学会如何做到这一点,我们需要再次偏离函数,学习关于指针的内容。
指针
你可以使用 &
(一个&符号)来获取变量的地址,这是 Go 的“地址”运算符。例如,这段代码初始化一个变量,打印其值,然后打印变量的地址...
我们可以获取任何类型变量的地址。请注意,每个变量的地址都是不同的。
那么这些“地址”究竟是什么?嗯,如果你想在拥挤的城市中找到特定的房子,你会使用它的地址...
就像城市一样,计算机为程序设置的内存是一个拥挤的地方。它充满了变量值:布尔值、整数、字符串等。就像房屋的地址一样,如果你有一个变量的地址,你可以用它来找到该变量包含的值。
代表变量地址的值被称为指针,因为它们指向变量所在的位置。
指针类型
指针的类型写作*
符号,后面跟着指针指向的变量的类型。例如,指向int
变量的指针的类型会写作*int
(你可以把它读作“pointer to int
”)。
我们可以使用reflect.TypeOf
函数来显示上一个程序中指针的类型:
我们可以声明变量来持有指针。指针变量只能持有同一类型值的指针,因此一个变量可能只能持有*int
指针,只能持有*float64
指针,等等。
与其他类型一样,如果你将立即给指针变量赋值,可以使用简短的变量声明:
获取或更改指针指向的值
你可以通过在代码中指定*
操作符之前的指针来获取指针引用的变量的值。例如,要获取myIntPointer
的值,你可以输入*myIntPointer
。(关于如何读取*
没有官方共识,但我们喜欢将其发音为“value at”,所以*myIntPointer
就是“value at myIntPointer
”。)
*
操作符也可以用来更新指针指向的值:
在上面的代码中,*myIntPointer = 8
访问了myIntPointer
指向的变量(即myInt
变量),并给它赋了一个新值。因此不仅更新了*myIntPointer
的值,也更新了myInt
的值。
代码磁铁
一个使用指针变量的 Go 程序在冰箱上被打乱了。你能重组代码片段,使之成为一个可以产生给定输出的工作程序吗?
程序应该将myInt
声明为整数变量,将myIntPointer
声明为整数指针变量。然后应该给myInt
赋一个值,并将myInt
的指针赋给myIntPointer
。最后,应该打印myIntPointer
的值。
答案在“代码磁铁解决方案”。
使用指针与函数
可以从函数中返回指针;只需声明函数的返回类型为指针类型即可。
(顺便说一句,与其他一些语言不同,在 Go 中,返回一个指向函数内部局部变量的指针是可以的。尽管该变量不再在作用域内,只要你仍然拥有指针,Go 就会确保你仍然可以访问该值。)
你也可以将指针作为参数传递给函数。只需指定一个或多个参数的类型应为指针类型。
确保只有在函数声明它将接受指针时才使用指针作为参数。如果尝试将一个值直接传递给期望指针的函数,会导致编译错误。
现在你已经了解如何在 Go 语言中使用指针的基础知识。我们已经准备结束我们的迂回,并修复我们的double
函数!
使用指针修复我们的“double”函数
我们有一个double
函数,它接受一个int
值并将其乘以 2。我们希望能够传递一个值并使该值加倍。但是正如我们所学到的,Go 语言是按值传递的,这意味着函数参数接收来自调用者的任何参数的副本。我们的函数将其值加倍并保持原始值不变!
这就是我们为了学习指针而进行的迂回之处。如果我们向函数传递一个指针,然后在该指针处更改值,这些更改仍将在函数外部生效!
我们只需要做一些小改动就能让它正常工作。在double
函数中,我们需要更新number
参数的类型,使其接受*int
而不是int
。然后我们需要修改函数代码以更新number
指针的值,而不是直接更新一个变量。最后,在main
函数中,我们只需要更新对double
的调用,传递一个指针而不是直接值。
当我们运行这个更新后的代码时,将会传递指向amount
变量的指针给double
函数。double
函数将会取该指针处的值并将其加倍,从而改变amount
变量的值。当我们返回main
函数并打印amount
变量时,我们将看到我们加倍后的值!
在本章中,你已经学到了如何编写自己的函数。这些功能的一些好处现在可能还不太明显。别担心——随着我们在后面章节中编写更复杂的程序,你所学到的一切将会派上用场!
你的 Go 工具箱
至此,第三章就结束了!你已经在工具箱中添加了函数声明和指针。
池子难题解决方案
代码磁铁解决方案
第四章:代码包
是时候开始组织了。到目前为止,我们一直将所有代码混合放在一个文件中。随着程序变得越来越大和复杂,这很快就会变得一团糟。
在本章中,我们将向你展示如何创建你自己的包,以帮助将相关的代码放在一个地方。但包不仅仅是用于组织的好工具。包是在程序之间分享代码的一种简便方式。它们也是向其他开发者分享代码的简便方式。
不同的程序,同一个函数
我们编写了两个程序,每个程序中都有一个相同的函数副本,这让维护变得头疼…
在这一页上,我们有一个来自第二章的新版本的pass_fail.go程序。从键盘读取成绩的代码已经移动到一个新的getFloat
函数中。getFloat
返回用户键入的浮点数,除非出现错误,否则返回0
和一个错误值。如果返回错误,程序会报告并退出;否则,它会像以前一样报告成绩是否及格。
在这一页上,我们有一个新的tocelsius.go程序,允许用户输入华氏温度,并将其转换为摄氏温度。
注意,在tocelsius.go中的getFloat
函数与pass_fail.go中的getFloat
函数是完全相同的。
使用包在程序之间共享代码
func getFloat() (float64, error) {
reader := bufio.NewReader(os.Stdin)
input, err := reader.ReadString('\n')
if err != nil {
return 0, err
}
input = strings.TrimSpace(input)
number, err := strconv.ParseFloat(input, 64)
if err != nil {
return 0, err
}
return number, nil
}
实际上,我们可以做一些事情——我们可以将共享的函数移到一个新的包中!
Go 允许我们定义自己的包。正如我们在第一章中讨论的那样,包是一组做类似事情的代码。fmt
包格式化输出,math
包处理数字,strings
包处理字符串,等等。我们已经在多个程序中使用了每个包中的函数。
能够在程序之间使用相同的代码是包存在的主要原因之一。如果你的代码的某些部分被多个程序共享,你应该考虑将它们移到包中。
如果你的代码的某些部分被多个程序共享,你应该考虑将它们移到包中。
Go 工作空间目录保存包代码
Go 工具在计算机上的一个特殊目录(文件夹)中寻找包代码,这个目录称为工作空间。默认情况下,工作空间是当前用户主目录中名为go的目录。
工作空间目录包含三个子目录:
-
bin,它包含编译后的二进制可执行程序。(我们稍后在本章还会更多地讨论bin。)
-
pkg,它包含编译后的二进制包文件。(我们稍后在本章还会更多地讨论pkg。)
-
src,它包含 Go 源代码。
在 src 中,每个包的代码都存在于自己单独的子目录中。按照惯例,子目录的名称应与包名称相同(因此 gizmo
包的代码将放在 gizmo 子目录中)。
每个包目录应该包含一个或多个源代码文件。文件名不重要,但应以 .go 扩展名结尾。
没有愚蠢的问题
Q: 你说一个包文件夹可以包含多个文件。每个文件应该放什么?
A: 你想要的任何内容!你可以将一个包的所有代码放在一个文件中,或者在多个文件之间进行拆分。无论哪种方式,它们都将成为同一个包的一部分。
创建一个新的包
让我们尝试在工作空间中设置我们自己的包。我们将创建一个简单的包,名为 greeting
,用于打印各种语言的问候语。
Go 安装时不会默认创建工作空间目录,因此您需要自己创建。首先进入您的主目录。(在大多数 Windows 系统上,路径为 C:\Users<yourname>,在 Mac 上为 /Users/
最后,我们需要一个目录来存放我们的包代码。按照惯例,包的目录应与包的名称相同。因为我们的包将被命名为 greeting
,所以您应该为目录使用这个名称。
我们知道,这似乎是很多嵌套的目录(实际上,我们很快将进一步嵌套它们)。但请相信我们,一旦您建立了自己的包集合以及来自他人的包,这种结构将帮助您保持代码的组织性。
更重要的是,这种结构有助于 Go 工具找到代码。因为它始终位于 src 目录中,Go 工具确切知道在哪里查找导入包的代码。
您的下一步是在 greeting 目录中创建一个文件,并将其命名为 greeting.go。文件应包含以下代码。稍后我们将详细讨论它,但现在我们想让您注意几点…
就像我们迄今为止的所有 Go 源代码文件一样,此文件以 package
行开头。但不同于其他文件的是,这段代码不属于 main
包;它属于名为 greeting
的包。
还要注意两个函数定义。它们与我们迄今为止见过的其他函数没有太大不同。但因为我们希望这些函数可以在 greeting
包外部访问,所以请注意我们将它们的名称首字母大写,以便导出这些函数。
将我们的包导入到程序中
现在让我们尝试在程序中使用我们的新包。
在你的工作区目录中,在src子目录中,创建另一个名为hi的子目录。(我们不一定要将可执行程序的代码存储在工作区中,但这是一个好主意。)
然后,在你的新hi目录中,我们需要创建另一个源文件。我们可以将文件命名为任何我们想要的名称,只要以.go扩展名结尾,但由于这将是一个可执行命令,我们将其命名为main.go。将下面的代码保存在文件中。
就像每个 Go 源代码文件一样,这段代码以一个package
行开始。但因为我们打算将其作为一个可执行命令,我们需要使用一个main
的包名。通常,包名应该与其所在目录的名称匹配,但main
包是这个规则的一个例外。
接下来,我们需要导入greeting
包,以便我们可以使用它的函数。Go 工具会在工作区的src目录中与import
语句中的名称匹配的文件夹中查找包代码。为了告诉 Go 在工作区内的src/greeting目录中查找代码,我们使用import "greeting"
。
最后,因为这是一个可执行文件的代码,我们需要一个main
函数,当程序运行时将被调用。在main
中,我们调用了greeting
包中定义的两个函数。两个调用都在包名和一个点之前,这样 Go 就知道这些函数属于哪个包。
我们已经准备好了;让我们尝试运行程序。在你的终端或命令提示符窗口中,使用**cd**
命令切换到工作区目录中的src/hi目录。(路径会根据你的主目录位置而变化。)然后,使用**go run main.go**
来运行程序。
当它看到import "greeting"
这一行时,Go 将在你工作区的src目录中的greeting目录中查找包源代码。该代码被编译和导入,我们就能调用greeting
包的函数了!
包使用相同的文件布局
还记得我们在第一章中谈到的几乎每个 Go 源代码文件都有的三个部分吗?
这个规则对我们main.go文件中的main
包当然也适用。在我们的代码中,你可以看到一个package
子句,后面是一个导入部分,然后是我们包的实际代码。
除了main
之外的包遵循相同的格式。你可以看到我们的greeting.go文件也有一个包子句,导入部分,以及最后的实际包代码。
破坏东西是教育性的!
拿出我们的greeting
包的代码,以及导入它的程序的代码。尝试做出以下一项更改并运行它。然后撤销你的更改并尝试下一个。看看会发生什么!
池谜题
你的任务是从池中提取代码片段,并将其放入空白行中。不要重复使用同一段落,也不需要使用所有的段落。你的目标是在 Go 工作空间中设置一个 calc
包,以便在 main.go 中使用 calc
的函数。
注意:每个来自池中的片段只能使用一次!
在 “池子难题解决方案” 中有答案。
包命名约定
使用包的开发者需要在每次调用来自该包的函数时输入其名称(例如 fmt.Printf
、fmt.Println
、fmt.Print
等)。为了尽可能简化这个过程,包名称应遵循几个规则:
-
包名应全小写。
-
如果含义相当明显,名称应缩写(例如
fmt
)。 -
如果可能的话,应该是一个单词。如果需要两个单词,它们不应用下划线分隔,并且第二个单词不应大写。(
strconv
包就是一个例子。) -
导入的包名称可能与局部变量名称冲突,因此不要使用包用户可能也想使用的名称。(例如,如果
fmt
包被命名为format
,那么任何导入该包的人在命名局部变量format
时都会面临冲突风险。)
包限定符
当访问来自不同包的导出函数、变量或类似内容时,需要通过输入包名称来限定函数或变量的名称。然而,当访问在当前包中定义的函数或变量时,不应限定包名称。
在我们的 main.go 文件中,因为我们的代码在 main
包中,我们需要指定 Hello
和 Hi
函数来自 greeting
包,通过输入 **greeting.Hello**
和 **greeting.Hi**
。
假设我们从 greeting
包中的另一个函数中调用了 Hello
和 Hi
函数。在那里,我们只需输入 Hello
和 Hi
(不带包名限定符),因为我们将从定义它们的同一包中调用这些函数。
将我们的共享代码移动到一个包中
现在我们了解了如何向 Go 工作空间添加包,我们终于可以将我们的 getFloat
函数移动到一个包中,这样我们的 pass_fail.go 和 tocelsius.go 程序都可以使用它。
让我们命名我们的包为 keyboard
,因为它从键盘读取用户输入。我们将在工作空间的 src 目录下创建一个名为 keyboard 的新目录。
接下来,我们将在 keyboard 目录中创建一个源代码文件。我们可以任意命名它,但我们将其命名为包名:keyboard.go。
文件顶部,我们需要一个 package
子句,并指定包名称为:keyboard
。
然后,因为这是一个单独的文件,我们需要一个import
语句来引入我们代码中使用的所有包:bufio
、os
、strconv
和strings
。(我们需要排除fmt
和log
包,因为它们仅在pass_fail.go和tocelsius.go文件中使用。)
最后,我们可以直接复制旧的getFloat
函数的代码。但我们需要确保将函数重命名为GetFloat
,因为除非其名称的第一个字母大写,否则它不会被导出。
现在pass_fail.go程序可以更新以使用我们的新keyboard
包。
因为我们要移除旧的getFloat
函数,所以需要移除未使用的bufio
、os
、strconv
和strings
导入项。我们将导入新的keyboard
包。
在我们的main
函数中,我们将不再调用旧的getFloat
,而是调用新的keyboard.GetFloat
函数。其余代码保持不变。
如果我们运行更新后的程序,我们将看到与之前相同的输出。
我们可以对tocelsius.go程序进行相同的更新。
我们更新了导入项,移除了旧的getFloat
,并调用keyboard.GetFloat
代替。
而且,如果我们运行更新后的程序,将会得到与之前相同的输出。但这次,我们不再依赖于冗余的函数代码,而是在我们的新包中使用共享函数!
常量
许多包会导出常量:这些是从不改变的命名值。
常量声明看起来很像变量声明,有名称、可选类型和常量值。但规则略有不同:
-
不再使用
var
关键字,而是使用const
关键字。 -
常量在声明时必须赋值;不能像变量那样稍后再赋值。
-
变量可以使用
:=
短变量声明语法,但常量没有类似的语法。
就像变量声明一样,您可以省略类型,类型将从被赋值的值中推断出来:
变量的值可以变化,但常量的值必须恒定。试图给常量赋予新值将导致编译错误。这是一种安全特性:常量应该用于不应变化的值。
如果您的程序包含“硬编码”的文字值,特别是这些值在多个地方使用,您应考虑将它们替换为常量(即使程序没有分成多个包)。这是一个包含两个函数的包,两者都使用整数字面值7
表示一周有几天:
通过用常量DaysInWeek
替换文字值,我们可以说明它们的含义。(其他开发人员看到DaysInWeek
这个名称,立即知道我们不是随意选择数字7
来在函数中使用。)而且,如果以后添加更多函数,可以通过引用DaysInWeek
来避免不一致性。
注意,我们将常量声明在任何函数之外,即在包级别。虽然可以在函数内部声明常量,但那将限制其作用域仅在该函数的块中。更典型的做法是在包级别声明常量,以便所有函数都可以访问它们。
像变量和函数一样,以大写字母开头的常量是导出的,我们可以通过限定其名称从其他包中访问它们。在这里,程序使用dates
包中的DaysInWeek
常量,并将常量名称限定为dates.DaysInWeek
。
嵌套的包目录和导入路径
当您使用 Go 提供的像fmt
和strconv
这样的包时,包名通常与其导入路径相同(即在import
语句中使用的字符串)。但正如我们在第二章中看到的那样,情况并非总是如此……
有些包集合通过导入路径前缀进行分组,例如"archive/"
和"math/"
。我们说这些前缀可以类比于硬盘上的目录路径……这并非巧合。这些导入路径前缀确实是使用目录创建的!
您可以在 Go 工作空间中的一个目录中嵌套类似的包组。
例如,假设我们想要添加其他语言的问候包。如果直接将它们全部放在src目录下,会很快变得混乱。但如果将新包放置在greeting目录下,它们将会整齐地分组在一起。
将包放置在greeting目录下也会影响它们的导入路径。如果dansk
包直接存储在src下,其导入路径将是"dansk"
。但将其放置在greeting目录下,则其导入路径变为"greeting/dansk"
。将deutsch
包移动到greeting目录下,其导入路径变为"greeting/deutsch"
。原始的greeting包仍然可以通过导入路径"greeting"
访问,只要其源代码文件直接存储在greeting目录下(而非子目录)。
假设我们有一个deutsch
包嵌套在greeting包目录下,并且其代码看起来像这样:
让我们更新我们的hi/main.go代码,以便也使用deutsch
包。因为它是嵌套在greeting目录下,我们需要使用引入路径"greeting/deutsch"
。但一旦导入了它,我们将只使用包名deutsch
来引用它。
与以前一样,我们通过使用**cd**
命令切换到工作空间目录中的src/hi目录来运行我们的代码。然后,我们使用**go run main.go**
来运行程序。我们将在输出中看到对deutsch
包函数调用的结果。
使用“go install”安装程序可执行文件
当我们使用go run
时,Go 必须编译程序及其所有依赖的包,然后才能执行它。当完成后,它会丢弃已编译的代码。
在第一章中,我们向您展示了go build
命令,它会编译并保存一个可执行的二进制文件(即使没有安装 Go 也可以执行的文件)在当前目录。但是过度使用可能会在随机且不方便的位置使您的 Go 工作空间混乱。
go install
命令还会将可执行程序的编译二进制版本保存在一个定义良好且易于访问的地方:Go 工作空间中的bin目录。只需将go install
命令提供的目录名称设置为src中包含可执行程序代码的目录(即以package main
开头的.go 文件)。程序将被编译,并在此标准目录中存储可执行文件。
注意
(确保将“go install”中的目录名称传递给“src”,而不是.go 文件的名称!默认情况下,“go install”未设置为直接处理.go 文件。)
让我们尝试为我们的hi/main.go程序安装一个可执行文件。与以前一样,从终端,我们键入**go install**
,一个空格,然后是src目录中我们的一个文件夹的名称(**hi**
)。无论您从哪个目录执行此操作,go
工具都将在src目录中查找该目录。
当 Go 看到hi目录中的文件包含package main
声明时,它会知道这是一个可执行程序的代码。它将编译一个可执行文件,并将其存储在 Go 工作空间中名为bin的目录中。(如果该目录不存在,将自动创建bin目录。)
与go build
命令不同,后者将可执行文件命名为基于其所依据的.go 文件。go install
将可执行文件命名为包含代码的目录名称。由于我们编译了hi目录的内容,可执行文件将命名为hi
(或在 Windows 上为hi.exe
)。
现在,您可以使用**cd**
命令切换到 Go 工作空间中的bin目录。一旦在bin中,您可以通过键入**./hi**
(或在 Windows 上为**hi.exe**
)来运行可执行文件。
注意
您还可以将工作空间的“bin”目录添加到系统的“PATH”环境变量中。然后,您就可以从系统的任何位置运行“bin”中的可执行文件!最近的 Mac 和 Windows 安装程序会自动为您更新“PATH”。
使用 GOPATH 环境变量更改工作空间
当讨论 Go 工作空间时,您可能会看到各种网站上的开发者谈论“设置您的GOPATH
”。GOPATH
是 Go 工具用来查找工作空间位置的环境变量。大多数 Go 开发人员将所有代码保存在单个工作空间中,并且不更改其默认位置。但是,如果需要,您可以使用GOPATH
将工作空间移动到其他目录。
环境变量允许您存储和检索值,有点像 Go 变量,但由操作系统维护,而不是 Go。通过设置环境变量,您可以配置一些程序,包括 Go 工具。
假设,您不是在您的主目录中,而是在硬盘根目录下的code目录中设置了greeting
包。现在,您想运行依赖于greeting
的main.go文件。
但是,您收到的错误消息显示找不到greeting
包,因为go
工具仍在查找您主目录中的go目录:
设置 GOPATH
如果您的代码存储在默认位置以外的目录中,则需要配置go
工具以查找正确的位置。您可以通过设置GOPATH
环境变量来实现这一点。如何设置取决于您的操作系统。
在 Mac 或 Linux 系统中:
可以使用export
命令设置环境变量。在终端提示符下输入:
export GOPATH="/code"
对于硬盘根目录中名为code的目录,您将需要使用路径“/code
”。如果代码位于其他位置,可以替换不同的路径。
在 Windows 系统中:
您可以使用set
命令设置环境变量。在命令提示符下输入:
set GOPATH="C:\code"
对于硬盘根目录中名为code的目录,您将需要使用路径“C:\code
”。如果代码位于其他位置,可以替换不同的路径。
完成后,go run
应立即开始使用您指定的工作区目录(其他 Go 工具也是如此)。这意味着会找到greeting
库,并且程序会运行!
请注意,上述方法仅为当前终端/命令提示符窗口设置GOPATH
。每次打开新窗口都需要重新设置。但是,如果需要,可以永久设置环境变量。对于每个操作系统,设置方法不同,我们无法在此处详细介绍。如果您在喜欢的搜索引擎中输入“环境变量”后跟上您的操作系统名称,结果应包含有用的说明。
发布包
我们对我们的keyboard
包使用得如此频繁,我们想知道其他人是否也会觉得它有用。
让我们在 GitHub 上创建一个仓库来保存我们的代码,这是一个流行的代码共享网站。这样,其他开发者可以下载并在他们自己的项目中使用它!我们的 GitHub 用户名是headfirstgo
,我们将仓库命名为keyboard,因此它的 URL 将是:
github.com/headfirstgo/keyboard
我们将仅上传keyboard.go文件到仓库,而不将其嵌套在任何目录中。
嗯,这是一个合理的担忧。在 Go 工作空间的src目录中只能有一个名为keyboard的目录,因此看起来我们只能有一个名为keyboard
的包!
让我们试试吧:我们将把我们的包移动到一个代表它托管的 URL 的目录结构中。在我们的src目录中,我们将创建另一个名为github.com的目录。在其中,我们将创建一个名为headfirstgo的目录。然后,我们将从src目录中将我们的keyboard包目录移动到headfirstgo目录中。
尽管将包移动到新的子目录中会更改其导入路径,但不会更改包名称。由于包本身仅包含名称的引用,因此我们不必对包代码进行任何更改!
然而,我们确实需要更新依赖于我们包的程序,因为包导入路径已更改。因为我们将每个子目录命名为包托管 URL 的一部分,所以我们的新导入路径看起来很像那个 URL:
"github.com/headfirstgo/keyboard"
我们只需要更新每个程序中的import
语句。因为包名相同,代码中对包的引用将保持不变。
做出这些更改后,依赖于我们的keyboard
包的所有程序应该正常工作。
顺便说一句,我们希望能够因为使用域名和路径来确保包导入路径的唯一性而得到这个想法的荣誉,但实际上并不是我们想出来的。从一开始,Go 社区就一直在使用这种包命名标准。类似的思想在像 Java 这样的语言中已经使用了几十年了。
使用“go get”下载和安装包
使用包的托管 URL 作为导入路径还有另一个好处。go
工具还有一个名为go get
的子命令,可以自动为您下载和安装包。
我们已经设置了一个包含我们之前在以下 URL 中展示给您的greeting包的 Git 仓库
github.com/headfirstgo/greeting
这意味着只要安装了 Go 的任何计算机,您都可以在终端中输入以下命令:
go get github.com/headfirstgo/greeting
注意
(注意:“go get”安装后仍然可能无法找到 Git。如果出现这种情况,请尝试关闭旧的终端或命令提示符窗口,然后打开一个新的。)
这是 go get
后跟着的存储库 URL,但是省略了 “scheme” 部分(即 “https://”)。 go
工具将连接到 github.com,下载 /headfirstgo/greeting 路径下的 Git 存储库,并将其保存在你的 Go 工作空间的 src 目录中。(注意:如果你的系统没有安装 Git,则在运行 go get
命令时会提示你安装它。只需按照屏幕上的指示操作。 go get
命令还可以与 Subversion、Mercurial 和 Bazaar 存储库一起使用。)
go get
命令将自动创建所需的子目录来设置适当的导入路径(如 github.com 目录,headfirstgo 目录等)。保存在 src 目录中的包将如下所示:
有了保存在 Go 工作空间中的包,它们就可以在程序中使用了。你可以通过像这样的 import
语句来使用 greeting
、dansk
和 deutsch
包:
import (
"github.com/headfirstgo/greeting"
"github.com/headfirstgo/greeting/dansk"
"github.com/headfirstgo/greeting/deutsch")
go get
命令也适用于其他包。如果你之前没有我们展示过的 keyboard
包,这个命令会帮你安装它:
go get github.com/headfirstgo/keyboard
实际上,go get
命令适用于任何已经在托管服务上正确设置的包,无论作者是谁。你只需要运行 go get
并提供包的导入路径即可。该工具将查看路径中对应于主机地址的部分,连接到该主机,并下载由其余导入路径表示的 URL 处的包。这使得使用其他开发者的代码变得非常简单!
使用 “go doc” 阅读包文档
你可以使用 **go doc**
命令来显示任何包或函数的文档。
通过将其导入路径传递给 go doc
,你可以获取包的文档。例如,我们可以通过运行 go doc strconv
来获取 strconv
包的信息。
输出包括包名和导入路径(在这种情况下它们是一样的),包的整体描述,以及包导出的所有函数列表。
你也可以使用 go doc
通过在包名后面提供函数名来获取特定函数的详细信息。假设我们在 strconv
包的函数列表中看到了 ParseFloat
函数,并且想要了解更多信息。我们可以使用 go doc strconv ParseFloat
来查看它的文档。
你将会得到该函数的描述和它的功能:
第一行看起来就像代码中的函数声明。它包括函数名称,后面跟着包含其参数名称和类型(如果有的话)的括号。如果有返回值,那些将出现在参数后面。
然后是详细描述函数做什么以及开发人员在使用它时需要的其他信息。
我们可以通过将其导入路径提供给go doc
,以相同的方式获取我们keyboard
包的文档。让我们看看是否有任何内容能帮助我们潜在的用户。从终端运行:
go doc github.com/headfirstgo/keyboard
go doc
工具能够从代码中推导出基本信息,如包名和导入路径。但是由于没有包描述,因此这并不那么有帮助。
请求GetFloat
函数的信息也得不到描述:
使用文档注释为您的包文档
go doc
工具会根据检查代码添加有用的信息到其输出中。包名和导入路径将被自动添加。函数名称、参数和返回类型也是如此。
但是go doc
并非魔法。如果您希望用户看到包或函数意图的文档,您需要自己添加。
幸运的是,这很容易做到:你只需在你的代码之前立即添加文档注释。出现在包声明或函数声明之前的普通 Go 注释将被视为文档注释,并将显示在go doc
的输出中。
让我们尝试为keyboard
包添加文档注释。在keyboard.go文件的顶部,在package
行之前,我们将添加一个注释描述该包的功能。在GetFloat
声明之前,我们将添加几行注释描述该函数。
下次我们为包运行go doc
时,它将找到package
行前的注释并将其转换为包描述。当我们为GetFloat
函数运行go doc
时,我们将看到基于我们在GetFloat
声明之前添加的注释行的描述。
能够通过go doc
显示文档使安装包的开发人员感到高兴。
文档注释还能让开发人员在处理包代码时感到愉快!它们是普通的注释,所以很容易添加。您可以在修改代码时轻松地参考它们。
添加文档注释时有一些约定要遵循:
-
注释应该是完整的句子。
-
包注释应以“Package”开头,后跟包名称:
// Package mypackage enables widget management.
-
函数注释应以它们描述的函数名称开头:
// MyFunction converts widgets to gizmos.
-
你可以通过将其缩进来包含代码示例在你的评论中。
-
除了代码示例的缩进外,不要添加额外的标点符号用于强调或格式化。文档注释将显示为普通文本,并且应以此方式格式化。
在网页浏览器中查看文档
如果你更喜欢在网页浏览器而不是终端中查看文档,还有其他查看包文档的方法。
最简单的方法是在你喜欢的搜索引擎中键入“golang”后跟你想要的包名称。(“Golang”通常用于搜索有关 Go 语言的内容,因为“go”是一个太普通的词,无法过滤掉无关的结果。)如果我们想要fmt
包的文档,我们可以搜索“golang fmt”:
结果应该包含以 HTML 格式提供 Go 文档的站点。如果你搜索 Go 标准库中的一个包(比如fmt
),顶部结果之一可能来自* golang.org*,这是由 Go 开发团队运行的站点。文档的内容将与go doc
工具的输出基本相同,包括包名称、导入路径和描述。
HTML 文档的一个主要优势是包的函数列表中的每个函数名都是一个方便的可点击链接,可以直接跳转到函数文档。
但内容与在终端中运行go doc
看到的内容完全相同。一切都基于代码中相同简单的文档注释。
使用“godoc”将 HTML 文档提供给自己
golang.org 网站文档部分使用的是相同的软件,实际上也可以在你的计算机上使用。这个工具叫做godoc
(不要与go doc
命令混淆),它会随着 Go 的安装自动安装。godoc
工具基于你的主 Go 安装和工作区中的代码生成 HTML 文档,包含一个可以与浏览器共享生成页面的 Web 服务器。(别担心,默认设置下,godoc
不会接受来自除你自己之外的任何计算机的连接。)
要在 Web 服务器模式下运行godoc
,我们将在终端中输入godoc
命令(再次强调,不要与go doc
混淆),然后加上一个特殊选项:-http=:6060
。
然后,在godoc
运行时,你可以在浏览器中输入 URL:
http://localhost:6060/pkg
...输入到你的网页浏览器地址栏中并按 Enter 键。你的浏览器将连接到你自己的计算机,godoc
服务器将以 HTML 页面响应。你将看到安装在你计算机上的所有包的列表。
列表中的每个包名称都是指向该包文档的链接。点击它,你会看到与在* golang.org* 上看到的相同的包文档。
“godoc”服务器包含你的包!
如果我们继续浏览本地godoc
服务器的包列表,我们会看到一些有趣的内容:我们的keyboard
包!
除了来自 Go 标准库的包之外,godoc
工具还为你的 Go 工作区中的任何包构建 HTML 文档。这些可以是你安装的第三方包,也可以是你自己编写的包。
点击keyboard链接,你将进入包的文档页面。文档中将包含我们代码中的任何文档注释!
当你准备停止godoc
服务器时,回到你的终端窗口,然后按住 Ctrl 键并按 C 键。你会回到系统提示符。
Go 使得文档化你的包变得简单,这使得包更容易分享,进而使其他开发者更容易使用。这只是使包成为代码共享的一个更好方式的又一特性!
你的 Go 工具箱
这就是第四章的内容!你已经向你的工具箱添加了包。
池谜题解答
你的任务是从池中获取代码片段,并将它们放入空白行中。不要重复使用同一个片段,并且你不需要使用所有的片段。你的目标是在 Go 工作区内设置一个calc
包,以便在main.go中使用calc
的函数。
第五章:列表:数组
许多程序处理各种列表。地址列表。电话号码列表。产品列表。Go 语言内置了两种存储列表的方法。本章将介绍第一种:数组。您将学习如何创建数组,如何填充数据以及如何再次获取这些数据。然后,您将学习如何处理数组中的所有元素,首先是使用for
循环的困难方式,然后是使用for
...range
循环的简单方式。
数组保存值的集合
一位当地餐馆老板面临一个问题。他需要知道未来一周需要订购多少牛肉。如果他订购太多,多余的将会浪费掉。如果他订购不足,他将不得不告诉顾客他无法做出他们最喜欢的菜肴。
他会记录过去三周使用的肉量数据。他需要一个程序来帮助他大致确定需要订购多少肉。
这应该足够简单:我们可以通过将三个金额相加并除以 3 来计算平均值。平均值应该能很好地估计需要订购的量。
第一个问题将是存储示例值。如果我们想稍后平均更多值,声明三个单独的变量将是一种痛苦。但是,与大多数编程语言一样,Go 提供了一种完美解决这种情况的数据结构...
数组是一组共享相同类型的值。将其视为一个有隔间的药盒 —— 您可以分别存储和检索每个隔间中的药丸,但也很容易将整个容器一起携带。
数组保存的值称为其元素。您可以有一个字符串数组,一个布尔数组,或者任何其他 Go 类型的数组(甚至是数组的数组)。您可以将整个数组存储在单个变量中,然后访问您需要的数组中的任何元素。
数组保存特定数量的元素,不能增长或缩小。要声明一个变量来保存数组,您需要在方括号([]
)中指定它保存的元素数,然后是数组保存的元素类型。
要设置数组元素的值或稍后检索值,您需要一种指定您要的元素的方法。数组中的元素从 0 开始编号。元素的编号称为其索引。
例如,如果您想制作一个音阶上音符名称的数组,第一个音符将分配给索引0
,第二个音符将在索引1
处,依此类推。索引在方括号中指定。
这是一个整数数组:
这是一个time.Time
值的数组:
数组中的零值
和变量一样,当创建数组时,它所包含的所有值都会被初始化为该数组所持有类型的零值。因此,一个包含int
值的数组默认填充为零:
然而,字符串的零值是一个空字符串,因此一个包含string
值的数组默认填充为空字符串:
即使你没有显式地为其分配一个值,零值也可以确保安全地操作数组元素。例如,在这里我们有一个整数计数器数组。我们可以增加任何一个计数器而无需先显式分配一个值,因为我们知道它们都将从0
开始。
当创建数组时,它所包含的所有值都会被初始化为该数组所持有类型的零值。
数组字面值
如果你事先知道数组应该包含哪些值,你可以使用数组字面值来初始化数组。数组字面值的开始方式与数组类型相同,使用方括号表示它将包含的元素数量,然后是其元素的类型。接着是用大括号括起来的初始值列表,每个元素的初始值应该用逗号分隔。
这些示例与我们之前展示的示例非常相似,只是不再逐个为数组元素分配值,而是使用数组字面值初始化整个数组。
使用数组字面值还可以使用:=
进行简短的变量声明。
你可以将数组字面值分布在多行,但在你的代码中,在每个换行字符之前必须使用逗号。如果在最后一个条目之后有换行符,则甚至需要在数组字面值的最后一个条目之后使用逗号。(这种风格起初看起来有些尴尬,但它使得以后添加更多元素变得更容易。)
“fmt”包中的函数知道如何处理数组
当你只是尝试调试代码时,你不必逐个将数组元素传递给fmt
包中的Println
和其他函数。只需传递整个数组。fmt
包中有逻辑来为你格式化和打印数组。(fmt
包还可以处理我们稍后将看到的切片、映射和其他数据结构。)
你可能还记得Printf
和Sprintf
函数使用的"%#v"
动词,它将值格式化为它们在 Go 代码中出现的样子。当用"%#v"
格式化时,数组在结果中显示为 Go 数组字面值。
在循环内访问数组元素
在你的代码中,你不必显式地写出你正在访问的数组元素的整数索引。你也可以使用整数变量中的值作为数组索引。
这意味着你可以使用for
循环处理数组的元素。你可以循环遍历数组中的索引,并使用循环变量访问当前索引处的元素。
在使用变量访问数组元素时,需要小心使用哪些索引值。正如我们所述,数组包含特定数量的元素。尝试访问超出数组的索引将导致panic,这是程序运行时发生的错误(而不是编译时)。
通常,panic 会导致程序崩溃并向用户显示错误消息。毫无疑问,应尽量避免 panic。
使用“len”函数检查数组长度
编写仅访问有效数组索引的循环可能会有些容易出错。幸运的是,有几种方法可以使这个过程更加简单。
第一种方法是在访问数组之前检查数组中的实际元素数。你可以使用内置的len
函数来做到这一点,它返回数组的长度(即它包含的元素数)。
当设置循环以处理整个数组时,可以使用len
来确定哪些索引是安全访问的。
然而,这仍然存在错误的可能性。如果len(notes)
返回7
,则最高可访问的索引是6
(因为数组索引从0
开始,而不是1
)。如果尝试访问索引7
,将会导致 panic。
安全地使用“for...range”循环遍历数组
更安全的处理数组每个元素的方式是使用特殊的for
...range
循环。在range
形式中,你提供一个变量来保存每个元素的整数索引,另一个变量来保存元素的值,以及你要遍历的数组。循环将针对数组中的每个元素运行一次,将元素的索引分配给你的第一个变量,将元素的值分配给你的第二个变量。你可以在循环块中添加代码来处理这些值。
这种形式的for
循环没有杂乱的初始化、条件和后置表达式。因为元素值会自动分配给一个变量,所以不会出现意外访问无效数组索引的风险。由于更安全且易于阅读,因此在处理数组和其他集合时,你经常会看到for
循环的range
形式被使用。
这里是我们之前的代码,用for
...range
循环打印我们的音符数组中的每个值:
循环运行七次,每次针对notes
数组的一个元素。对于每个元素,index
变量被设置为元素的索引,note
变量被设置为元素的值。然后我们打印索引和值。
使用“for...range”循环结合空白标识符
与往常一样,Go 要求您使用您声明的每个变量。如果我们停止使用来自我们的for
...range
循环的index
变量,我们将会得到一个编译错误:
如果我们不使用保存元素值的变量,情况也是如此:
记住在第二章中,当我们调用一个带有多个返回值的函数,并且我们想忽略其中一个时?我们将该值分配给空白标识符(_
),这会导致 Go 丢弃该值,而不会产生编译器错误...
我们可以对for
...range
循环中的值做同样的处理。如果我们不需要每个数组元素的索引,我们可以将其分配给空白标识符:
如果我们不需要值变量,可以将其分配给空白标识符:
获取数组中数字的总和
我们终于知道了一切,我们需要创建一个float64
值的数组并计算它们的平均值。让我们取过去几周使用的牛肉量,并将它们整合到一个名为average
的程序中。
我们首先需要做的是设置一个程序文件。在您的 Go 工作空间目录(用户主目录内的go目录,除非您设置了GOPATH
环境变量),创建以下嵌套目录(如果它们不存在)。在最内层的average目录中,保存一个名为main.go的文件。
现在让我们在main.go文件中编写我们的程序代码。由于这将是一个可执行程序,我们的代码将属于main
包,并位于main
函数中。
我们首先只计算三个样本值的总和;稍后我们可以返回计算平均值。我们使用数组字面量创建一个包含三个float64
值的数组,预先填充了以前几周的样本值。我们声明一个名为sum
的float64
变量来保存总和,从0
开始。
然后我们使用for
...range
循环处理每个数字。我们不需要元素索引,因此使用_
空白标识符将其丢弃。我们将每个数字添加到sum
中。在我们计算出所有值的总和后,我们在退出前打印sum
。
让我们尝试编译和运行我们的程序。我们将使用go install
命令创建一个可执行文件。我们将需要向go install
提供我们可执行文件的导入路径。如果我们使用这个目录结构...
...这意味着我们包的导入路径将是[github.com/headfirstgo/average](http://github.com/headfirstgo/average)
。因此,从您的终端输入:
go install github.com/headfirstgo/average
你可以从任何目录中执行此操作。go
工具将在你的工作空间的src目录中查找github.com/headfirstgo/average目录,并编译其中包含的所有.go文件。生成的可执行文件将命名为average
,并存储在你的 Go 工作空间的bin目录中。
然后,你可以使用cd
命令切换到你的 Go 工作空间内的bin目录。一旦进入bin目录,你可以通过输入./average
(或在 Windows 上是average.exe
)来运行可执行文件。
该程序将打印出我们数组中三个值的总和并退出。
获取数组中数字的平均值
我们的average
程序已经打印出了数组值的总和,现在让我们更新它以打印实际的平均值。为此,我们将总和除以数组的长度。
将数组传递给len
函数返回一个int
值,表示数组的长度。但由于sum
变量中的总数是float64
值,我们也需要将长度转换为float64
,这样才能在数学运算中使用。我们将结果存储在sampleCount
变量中。完成后,我们只需将sum
除以sampleCount
,并打印结果即可。
一旦代码更新完成,我们可以重复之前的步骤来查看新的结果:运行go install
重新编译代码,切换到bin目录,并运行更新后的average
可执行文件。现在,我们将看到数组中值的平均数,而不是它们的总和。
池谜题
你的工作是从池中获取代码片段,并将它们放入这段代码中的空白行中。不要重复使用相同的片段,也不需要使用所有的片段。你的目标是创建一个程序,它将打印数组元素中介于10
和20
之间的索引和值(应与显示的输出匹配)。
注意:每个来自池中的片段只能使用一次!
答案在“池谜题解答”中。
读取文本文件
那是真的 — 用户必须自行编辑和编译源代码的程序并不是很用户友好。
以前,我们使用标准库的os
和bufio
包逐行从键盘读取数据。我们可以使用相同的包来逐行从文本文件中读取数据。让我们稍作偏离,学习如何做到这一点。
然后,我们将回来更新average
程序,以从文本文件中读取数值。
在你喜欢的文本编辑器中,创建一个名为data.txt的新文件。现在,将其保存在你的 Go 工作空间目录之外的某个地方。
在文件中,输入我们的三个浮点数样本值,每行一个数字。
在我们更新程序以计算文本文件中数字的平均值之前,我们需要能够读取文件的内容。首先,让我们编写一个仅读取文件的程序,然后我们将所学的内容整合到我们的平均值程序中。
在与data.txt相同的目录中创建一个名为readfile.go的新程序。我们将只用go run
运行readfile.go,所以可以将它保存在 Go 工作区目录之外。将以下代码保存在readfile.go中。(我们将在下一页详细查看这段代码的工作原理。)
然后,从您的终端,切换到保存了这两个文件的目录,并运行go run readfile.go
。该程序将读取data.txt的内容,并将其打印出来。
我们的测试readfile.go程序成功读取了data.txt文件的行并将其打印出来。让我们更仔细地看看程序是如何工作的。
我们首先将要打开的文件名作为字符串传递给os.Open
函数。os.Open
将返回两个值:指向打开文件的os.File
值的指针,和一个error
值。与许多其他函数一样,如果error
值为nil
,表示文件成功打开;否则,表示出现错误(例如文件丢失或不可读)。如果出现错误,我们将记录错误消息并退出程序。
然后我们将os.File
值传递给bufio.NewScanner
函数。这将返回一个从文件中读取的bufio.Scanner
值。
bufio.Scanner
上的Scan
方法设计成作为for
循环的一部分使用。它将从文件中读取一行文本,如果成功读取数据则返回true
,如果没有则返回false
。如果在for
循环的条件中使用Scan
,则循环将继续运行,直到没有更多数据可读取为止。一旦到达文件的末尾(或出现错误),Scan
将返回false
,循环将退出。
在bufio.Scanner
上调用Scan
方法后,调用Text
方法将返回一个包含读取数据的字符串。对于这个程序,我们只需在循环内调用Println
来打印每一行。
循环退出后,我们已经完成了文件的操作。保持文件打开会消耗操作系统的资源,所以当程序完成文件操作时应该关闭文件。调用os.File
上的Close
方法可以实现这一点。与Open
函数不同,Close
方法只返回一个error
值,除非发生问题,否则该值为nil
。(与Open
不同,Close
只返回一个值,因为除了错误之外没有其他有用的返回值。)
当bufio.Scanner
在扫描文件时可能会遇到错误。如果遇到错误,调用扫描器的Err
方法将返回该错误,我们在退出前将其记录。
将文本文件读取到数组中
我们的readfile.go程序运行良好——我们能够将data.txt文件中的行作为字符串读取并打印出来。现在我们需要将这些字符串转换为数字并存储在数组中。让我们创建一个名为datafile
的包来为我们完成这个任务。
在您的 Go 工作空间目录中,在headfirstgo目录下创建一个datafile目录。在datafile目录中,保存一个名为floats.go的文件。(我们将其命名为floats.go,因为此文件将包含从文件中读取浮点数的代码。)
在floats.go中,保存以下代码。其中很多内容基于我们测试的readfile.go程序中的代码;我们将代码相同的部分标记为灰色。我们将在下一页详细解释新代码。
我们希望能够从除了data.txt之外的文件中读取,因此我们将文件名作为参数接受。我们设置函数返回两个值,一个是float64
值的数组,另一个是error
值。像大多数返回错误的函数一样,只有当错误值为nil
时,才应该考虑使用第一个返回值。
接下来,我们声明一个包含三个float64
值的数组,用于保存从文件中读取的数字。
就像在readfile.go中一样,我们打开文件进行读取。不同之处在于,我们不是使用硬编码的字符串"data.txt"
,而是打开传递给函数的任何文件名。如果遇到错误,我们需要返回一个数组以及错误值,因此我们只返回numbers
数组(即使尚未为其分配任何内容)。
我们需要知道将每一行分配给哪个数组元素,因此我们创建一个变量来跟踪当前索引。
设置bufio.Scanner
并循环遍历文件的行的代码与readfile.go中的代码相同。然而,循环内的代码不同:我们需要对从文件中读取的字符串调用strconv.ParseFloat
来将其转换为float64
,并将结果分配给数组。如果ParseFloat
导致错误,我们需要返回该错误。如果解析成功,我们需要增加i
,以便将下一个数字分配给下一个数组元素。
我们关闭文件并报告任何错误的代码与readfile.go完全相同,只是我们返回任何错误而不是直接退出程序。 如果没有错误,将到达GetFloats
函数的末尾,并将float64
值数组与nil
错误一起返回。
更新我们的“average”程序以读取文本文件
我们准备好用从data.txt文件中读取的数组替换average
程序中的硬编码数组了!
编写我们的datafile
包是难点所在。 在主程序中,我们只需要做三件事:
-
更新我们的
import
声明以包括datafile
和log
包。 -
用
datafile.GetFloats("data.txt")
替换我们的硬编码数字数组。 -
检查我们是否从
GetFloats
得到了错误,并记录并退出。
所有剩余的代码都完全相同。
我们可以使用与之前相同的终端命令来编译程序:
go install github.com/headfirstgo/average
因为我们的程序导入了datafile
包,所以它也会被自动编译。
我们需要将data.txt文件移动到 Go 工作区的bin子目录中。 这是因为我们将从该目录运行average
可执行文件,并且它将在同一目录中寻找data.txt。 移动data.txt后,切换到该bin子目录。
当我们运行average
可执行文件时,它将从data.txt中加载值到一个数组中,并用它们来计算平均值。
如果我们更改data.txt中的值,平均值也会随之改变。
我们的程序只能处理三个值!
但是有个问题——如果data.txt中有四行或更多行,average
程序将会恐慌并退出!
当一个 Go 程序发生恐慌时,它会输出一个报告,其中包含问题发生的代码行的信息。 在这种情况下,问题似乎出现在floats.go文件的第 20 行。
如果我们查看floats.go的第 20 行,我们会看到那是GetFloats
函数中将文件中的数字添加到数组的部分!
还记得之前代码示例中的错误导致程序尝试访问七元素数组的第八个元素吗?那个程序也会恐慌并退出。
我们的GetFloats
函数中出现了相同的问题。 因为我们声明numbers
数组只能容纳三个元素,所以它只能容纳三个元素。 当达到data.txt文件的第四行时,它尝试为numbers
的第四个元素赋值,结果导致恐慌。
Go 中的数组大小固定;它们无法增长或缩小。但是 data.txt 文件可以添加用户想要添加的任意行数。我们将在下一章节看到解决这一困境的方法!
你的 Go 工具箱
第五章就到这里!你已经把数组加入了你的工具箱。
池谜题解答
第六章:追加问题:切片
我们已经学到,无法向数组添加更多元素。 这对我们的程序来说是个真正的问题,因为我们事先不知道文件中包含多少数据。但这就是 Go 切片派上用场的地方。切片是一种集合类型,可以动态增加项,正好可以修复我们当前的程序!我们还将看到切片如何为用户提供更简单的方式来提供所有程序所需的数据,并且如何帮助您编写更方便调用的函数。
切片
实际上有一种 Go 数据结构,我们可以向其中添加更多值,它被称为切片。与数组一样,切片由多个相同类型的元素组成。不同于数组的是,函数可用于允许我们将额外的元素添加到切片的末尾。
要声明一个变量,其类型为切片,请使用一对空方括号,后跟切片将容纳的元素类型。
这与声明数组变量的语法类似,只是不指定大小。
与数组变量不同,声明切片变量不会自动创建切片。为此,您可以调用内置的make
函数。将切片的类型(应与要分配给它的变量的类型相同)和应创建的切片长度传递给make
。
创建切片后,使用与数组相同的语法分配和检索其元素。
您不必分开声明变量并创建切片;使用带有短变量声明的make
函数将为您推断变量的类型。
内置的len
函数与切片一样使用,就像它与数组一样使用。只需将切片传递给len
,它的长度将作为整数返回。
对于切片,for
和for
...range
循环的工作方式与数组完全相同:
切片字面量
就像数组一样,如果您事先知道切片将从哪些值开始,可以使用切片字面量初始化切片。切片字面量看起来很像数组字面量,但数组字面量在方括号中有数组长度,而切片字面量的方括号是空的。然后,空括号后跟切片将容纳的元素类型,并在大括号中列出每个元素的初始值。
您不需要调用make
函数;在代码中使用切片字面量将创建切片,并预填充它。
这些示例与我们之前展示的示例类似,只是不是逐个为切片元素分配值,而是使用切片字面量初始化整个切片。
池谜题
你的任务是从代码池中获取代码片段,并将它们放入空白行中。不要重复使用相同的片段,你不需要使用所有的片段。你的目标是创建一个能够运行并产生所示输出的程序。
注意:每个代码池中的片段只能使用一次!
答案在“池谜题解答”中。
因为切片是建立在数组之上的。如果不了解数组,就不能理解切片的工作原理。在这里,我们将向你展示为什么……
切片操作符
每个切片都建立在一个底层数组之上。实际上,是底层数组保存了切片的数据;切片只是对数组元素的一种(或全部)视图。
当你使用make
函数或切片文字创建一个切片时,底层数组会自动为你创建(你无法直接访问它,除非通过切片)。但你也可以自己创建数组,然后基于它使用切片操作符创建一个切片。
切片操作符看起来类似于访问数组单个元素或切片的语法,不同之处在于它有两个索引:切片应该从数组的哪个索引开始,以及切片应该在数组的哪个索引之前停止。
请注意,我们强调第二个索引是切片将在哪里停止的索引。也就是说,切片应该包括元素直到第二个索引,但不包括第二个索引。如果你使用underlyingArray[i:j]
作为切片操作符,生成的切片实际上将包含元素underlyingArray[i]
到underlyingArray[j-1]
。
注意
(我们知道,这有些违反直觉。但类似的表示法在 Python 编程语言中已经使用了 20 多年,而且似乎工作得很好。)
如果你希望切片包含底层数组的最后一个元素,实际上你需要指定第二个索引,该索引比切片操作符中的数组末尾元素索引多一个。
确保不要再往前走,否则会出现错误:
切片操作符对于起始索引和停止索引都有默认值。如果省略起始索引,将使用0
(数组的第一个元素)。
如果你省略了停止索引,那么从起始索引到底层数组末尾的所有内容都会包含在生成的切片中。
底层数组
正如我们所提到的,切片本身不保存任何数据;它只是对底层数组元素的一种视图。你可以把切片想象成一种显微镜,聚焦于幻灯片(即底层数组)内容的特定部分。
当您获取底层数组的切片时,您只能“看到”通过切片可见的部分数组元素。
即使可能存在多个切片指向同一个底层数组的情况。每个切片将是对其自己子集中的数组元素的视图。这些切片甚至可以重叠!
更改底层数组,更改切片
现在,这里有一些需要注意的事情:因为切片只是对数组内容的一种视图,如果您更改底层数组,这些更改也将在切片中可见!
将一个切片元素赋予一个新值将会改变底层数组中对应的元素。
如果多个切片指向同一个底层数组,对数组元素的更改将在所有切片中可见。
由于这些潜在问题,您可能会发现,通常最好使用make
或切片文字创建切片,而不是创建一个数组并在其上使用切片操作符。使用make
和切片文字,您永远不必直接操作底层数组。
使用“append”函数添加到切片
Go 语言提供了一个内置的append
函数,接受一个切片和一个或多个要追加到该切片末尾的值。它返回一个新的、更大的切片,其中包含与原始切片相同的所有元素,以及添加到末尾的新元素。
您无需跟踪要分配新值的索引,也无需其他任何操作!只需使用您的切片和要添加到末尾的值(s),调用append
,您将得到一个新的、更长的切片。就是这么简单!
好吧,有一个注意事项...
请注意,我们确保将append
的返回值分配回相同的切片变量,我们传递给append
。这是为了避免从append
返回的切片中可能出现的一些不一致的行为。
切片的底层数组大小不能增长。如果数组中没有足够的空间来添加元素,所有元素将被复制到一个新的、更大的数组中,并且切片将被更新以引用这个新数组。但由于所有这些操作都是在append
函数的背后进行的,因此很难判断从append
返回的切片是否具有与传入的切片相同的底层数组,还是不同的底层数组。如果保留了两个切片,这可能导致一些不可预测的行为。
例如,在下面,我们有四个切片,最后三个通过调用append
创建。这里我们没有遵循将append
的返回值重新分配给同一变量的约定。当我们将值分配给s4
切片的元素时,可以看到在s3
中反映出变化,因为s4
和s3
恰好共享相同的底层数组。但是这种变化不反映在s2
或s1
中,因为它们有一个不同的底层数组。
因此,在调用append
时,通常只需将返回值分配回传递给append
的同一切片变量即可。如果只存储一个切片,就不需要担心两个切片是否有相同的底层数组!
切片和零值
与数组一样,如果访问尚未分配值的切片元素,您将得到该类型的零值返回:
与数组不同,切片变量本身也有一个零值:它是nil
。也就是说,一个尚未分配切片的切片变量将具有值nil
。
在其他语言中,这可能需要在尝试使用之前测试变量是否实际包含切片。但在 Go 语言中,函数被有意地编写为将nil
切片值视为如果它是一个空切片。例如,如果len
函数被传递一个nil
切片,它将返回0
:
append
函数还将nil
切片视为空切片。如果将空切片传递给append
,它将添加你指定的项目到切片,并返回一个包含一个项目的切片。如果将nil
切片传递给append
,你同样会得到一个包含一个项目的切片,尽管从技术上讲没有切片来“追加”项目。append
函数会在幕后创建切片。
这意味着你通常不需要担心你是否有一个空切片或nil
切片。你可以将它们都视为相同,你的代码会“自动工作”!
使用切片和“append”读取额外的文件行
现在我们了解了切片和append
函数,终于可以修复我们的average
程序了!记住,一旦我们在读取data.txt文件时添加了第四行:
我们追溯到了我们的datafile
包的问题,该包将文件行存储在一个不能超过三个元素的数组中:
我们大部分与切片的工作都集中在了理解它们上。现在我们理解了,更新GetFloats
函数以使用切片而不是数组并不需要太多努力。
首先,我们更新函数声明,以返回float64
值的切片而不是数组。之前,我们将数组存储在名为numbers
的变量中;现在我们将使用相同的变量名来保存切片。我们不会给numbers
赋值,所以一开始它将是nil
。
不再将文件读取的值分配给特定的数组索引,而是可以调用append
来扩展切片(如果是nil
则创建一个切片)并添加新值。这意味着我们可以摆脱创建和更新跟踪索引的i
变量的代码。我们将从ParseFloat
返回的float64
值分配给一个新的临时变量,仅用于在检查解析错误时暂时保存它。然后将numbers
切片和文件中的新值传递给append
,确保将返回值重新分配给numbers
变量。
除此之外,GetFloats
中的代码可以保持不变——切片基本上可以无缝替代数组。
尝试我们改进的程序
从GetFloats
函数返回的切片可像主程序中的数组一样直接替换,毫不费力。事实上,我们根本不需要对主程序做任何修改!
因为我们使用了:=
来将GetFloats
的返回值赋给一个变量,numbers
变量自动从推断类型[3]float64
(数组)切换到类型[]float64
(切片)。并且因为for
...range
循环和len
函数在处理切片时与处理数组的方式相同,因此对该代码不需要进行任何更改!
这意味着我们已经准备好尝试这些更改了!确保data.txt文件仍然保存在你的 Go 工作空间的bin子目录中,然后使用与之前相同的命令编译和运行代码。它将读取data.txt的所有行并显示它们的平均值。然后尝试更新data.txt以拥有更多或更少的行;无论如何,它都能正常工作!
在出错时返回一个空切片
让我们对GetFloats
函数做一个小的改进。目前,即使出现错误,我们仍然返回numbers
切片。这意味着我们可能会返回包含无效数据的切片:
调用GetFloats
的代码应该检查返回的错误值,看看它是否不是nil
,并忽略返回切片的内容。但实际上,如果切片包含的数据无效,为什么还要返回切片呢?让我们更新GetFloats
,在出错时返回nil
而不是切片。
让我们重新编译程序(包括更新后的datafile
包)并运行它。它应该与以前一样工作。但现在我们的错误处理代码更加清晰了一些。
答案在“。
命令行参数
还有一种方法——用户可以将数值作为命令行参数传递给程序。
就像你可以通过向许多 Go 函数传递参数来控制它们的行为一样,你也可以向从终端或命令提示符运行的许多程序传递参数。这被称为程序的命令行界面。
在这本书中,你已经看到了命令行参数的使用。当我们运行cd
(“change directory”)命令时,我们将要切换到的目录名作为参数传递给它。当我们运行go
命令时,我们经常传递多个参数:我们想要使用的子命令(run
、install
等)以及我们希望子命令处理的文件或包的名称。
从os.Args
切片获取命令行参数
让我们设置一个名为average2
的新版本的average
程序,它接受要计算平均值的数值作为命令行参数。
os
包有一个包变量,os.Args
,它被设置为一个字符串切片,表示当前运行的程序执行时带有的命令行参数。我们首先将简单地打印出os.Args
切片,以查看它包含了什么内容。
在你的工作空间中average目录旁边创建一个名为average2的新目录,并在其中保存一个main.go文件。
然后,在main.go中保存以下代码。它简单地导入了fmt
和os
包,并将os.Args
切片传递给fmt.Println
。
让我们试一试。从你的终端或命令提示符中运行以下命令来编译和安装程序:
go install github.com/headfirstgo/average2
这将在你的 Go 工作空间的bin子目录中安装一个名为average2(在 Windows 上为average2.exe)的可执行文件。使用cd
命令切换到bin,并输入average2,但还不要立即按 Enter 键。在程序名称后面,输入一个空格,然后输入一个或多个用空格分隔的参数。然后按 Enter 键。程序将运行并打印出os.Args
的值。
使用不同的参数重新运行average2
,你应该看到不同的输出。
切片操作符可以用在其他切片上
这运行得相当顺利,但有一个问题:可执行文件的名称被包括在os.Args
的第一个元素中。
不过这应该很容易移除。还记得我们如何使用切片操作符获取一个包含数组除第一个元素以外的所有元素的切片吗?
切片操作符可以像在数组上一样在切片上使用。如果我们在os.Args
上使用切片操作符[1:]
,它将给我们一个新的切片,省略了第一个元素(索引为0
),并包括第二个元素(索引1
)到切片的末尾。
如果我们重新编译并重新运行average2
,这次我们将看到输出只包括实际的命令行参数。
更新我们的程序以使用命令行参数
现在我们能够将命令行参数作为字符串切片获取,让我们更新average2
程序将参数转换为实际数字,并计算它们的平均值。我们将大部分概念重用到我们原始的average
程序和datafile
包中学到的概念。
我们在os.Args
上使用切片操作符来省略程序名称,并将结果切片赋给一个arguments
变量。我们设置一个sum
变量,它将保存我们得到的所有数字的总和。然后我们使用for
...range
循环来处理arguments
切片的元素(使用_
空白标识符来忽略元素索引)。我们使用strconv.ParseFloat
将参数字符串转换为float64
。如果出现错误,我们记录并退出,否则我们将当前数字添加到sum
中。
当我们循环遍历所有参数时,我们使用len(arguments)
来确定我们要计算平均值的数据样本数量。然后我们将sum
除以这个样本计数以获得平均值。
保存这些更改后,我们可以重新编译并重新运行程序。它将接受您提供的数字作为参数并计算它们的平均值。无论您提供多少参数,它都能正常工作!
可变参数函数
现在我们了解了切片,我们可以介绍一下迄今为止我们还没有讨论过的 Go 特性。你是否注意到一些函数调用可以接受所需数量的参数?例如看看fmt.Println
或append
:
不过,不要尝试对任何函数都这样做!到目前为止,我们定义的所有函数,在函数定义中的参数数量和函数调用中的参数数量之间必须有精确匹配。任何差异都会导致编译错误。
那么Println
和append
是如何做到的呢?它们被声明为可变参数函数。可变参数函数是可以使用不同数量的参数调用的函数。要使函数可变参数,可以在函数声明中的最后(或唯一)函数参数的类型之前使用省略号(...
)。
可变参数函数的最后一个参数接收可变参数作为一个切片,函数可以像处理任何其他切片一样处理它们。
这里是twoInts
函数的可变参数版本,它可以很好地处理任意数量的参数:
这是一个类似的函数,适用于字符串。请注意,如果我们没有提供变参参数,这并不是错误;函数会接收到一个空切片。
函数可以接受一个或多个非变参参数。虽然函数调用者可以省略变参参数(导致空切片),但非变参参数总是必需的;省略它们会导致编译错误。只有在函数定义中的最后一个参数可以是变参;你不能把它放在必需参数的前面。
使用变参函数
这是一个maximum
函数,它接受任意数量的float64
参数,并返回其中最大的值。maximum
的参数存储在numbers
参数的切片中。首先,我们将当前最大值设置为-Inf
,这是一个特殊值,表示负无穷大,通过调用math.Inf
获得。(我们也可以从当前最大值0
开始,但这样maximum
将能处理负数。)然后,我们使用for
...range
处理numbers
切片中的每个参数,将其与当前最大值进行比较,并在其大于当前最大值时将其设置为新的最大值。处理完所有参数后,剩余的最大值即为我们要返回的值。
这是一个inRange
函数,它接受最小值、最大值和任意数量的额外float64
参数。它会丢弃低于给定最小值或高于给定最大值的任何参数,返回仅包含在指定范围内的参数的切片。
代码磁铁
Go 程序定义并使用变参函数时会被打乱。你能重构代码片段以构建一个能够生成指定输出的工作程序吗?
答案在 “代码磁铁解决方案” 中。
使用变参函数计算平均数
让我们创建一个变参的average
函数,它可以接受任意数量的float64
参数并返回它们的平均值。它的逻辑类似于我们的average2
程序。我们将设置一个sum
变量来保存参数值的总和。然后,我们将循环遍历参数的范围,将每个参数添加到sum
中。最后,我们将sum
除以参数数量(转换为float64
)以获得平均值。结果是一个可以计算任意数量(或少量)数字平均值的函数。
将切片传递给变参函数
我们的新average
变参函数效果非常好,我们应该尝试更新我们的average2
程序以利用它。我们可以将average
函数原样粘贴到我们的average2
代码中。
在main
函数中,我们仍然需要将每个命令行参数从string
转换为float64
值。我们将创建一个切片来保存结果值,并将其存储在名为numbers
的变量中。在每个命令行参数被转换后,我们不再直接用它来计算平均值,而是将其追加到numbers
切片中。
我们然后尝试将numbers
切片传递给average
函数。但当我们尝试编译程序时,结果出现错误……
average
函数期望一个或多个float64
参数,而不是一组float64
值的切片……
那么现在怎么办?我们是被迫在使函数可变参数和能够将切片传递给它们之间做出选择吗?
幸运的是,Go 语言为这种情况提供了特殊的语法。在调用可变参数函数时,只需在要替代可变参数的切片后面添加省略号(...
)即可。
所以我们只需要在调用average
时,在numbers
切片后面添加省略号即可。
经过这些更改,我们应该能够重新编译并运行我们的程序。它将把我们的命令行参数转换为一个float64
值的切片,然后将该切片传递给可变参数average
函数。
切片拯救了我们!
对于任何编程语言来说,处理值列表是至关重要的。通过数组和切片,您可以将数据保留在任何所需大小的集合中。而且,通过像for
...range
循环这样的特性,Go 语言还可以轻松处理这些集合中的数据!
您的 Go 工具箱
这就是关于第六章的全部内容!您已经将切片添加到了您的工具箱中。
Pool Puzzle Solution
Code Magnets Solution
第七章:标记数据:Maps
将东西随意堆放是可以的,直到你需要再次找到某样东西。 你已经学会了如何使用 数组 和 切片 创建值的列表。你也学会了如何对数组或切片中的 每个值 应用相同的操作。但是如果你需要处理 特定的 值呢?为了找到它,你必须从数组或切片的开头开始,查找每一个单独的值。
如果有一种集合,每个值都带有标签,那该多好啊?你可以快速找到你需要的值!在这一章中,我们将介绍 maps,它们正是做这件事的。
统计选票
今年,Sleepy Creek 县学校董事会的一个席位空缺,民意调查显示选举结果非常接近。现在已经是选举之夜,候选人们正兴奋地观看选票的涌入。
注:
这是另一个在《Head First Ruby》中首次亮相的例子,出现在哈希章节。Ruby 的哈希与 Go 的 maps 非常相似,所以这个例子在这里也非常适用!
姓名:安伯·格雷厄姆
职业:经理**
姓名:布莱恩·马丁
职业:会计**
投票中有两位候选人,安伯·格雷厄姆和布莱恩·马丁。选民还可以选择“写入”候选人的名字(即输入一个未出现在选票上的名字)。这些情况不会像主要候选人那样常见,但我们预计会有一些这样的名字出现。
今年使用的电子投票机会将选票记录到文本文件中,每行一票。(由于预算紧张,市议会选择了廉价的投票机供应商。)
这是一个关于 A 区所有选票的文件:
我们需要处理文件的每一行,并统计每个名字出现的总次数。得票最多的名字将成为我们的获胜者!
从文件中读取名字
我们的第一项工作是读取 votes.txt 文件的内容。前几章中的 datafile
包已经有一个 GetFloats
函数,它可以将文件的每一行读取到一个切片中,但 GetFloats
只能读取 float64
值。我们需要一个单独的函数,能够将文件行作为 string
值的切片返回。
所以让我们首先在 datafile 包目录中与 floats.go 文件并列创建一个 strings.go 文件。在那个文件中,我们将添加一个 GetStrings
函数。GetStrings
中的代码将与 GetFloats
中的代码非常相似(我们已经灰化了相同的代码)。但是,与将每一行转换为 float64
值不同的是,GetStrings
将直接将行添加到我们要返回的切片中,作为 string
值。
现在让我们创建实际计数选票的程序。我们将其命名为count
。在您的 Go 工作空间中,进入src/github.com/headfirstgo目录并创建一个名为count的新目录。然后在count目录中创建一个名为main.go的文件。
在编写完整程序之前,让我们确认我们的GetStrings
函数是否正常工作。在main
函数的顶部,我们将调用datafile.GetStrings
,将"votes.txt"
作为要读取的文件名传递给它。我们将把返回的字符串切片存储在名为lines
的新变量中,将任何错误存储在名为err
的变量中。通常情况下,如果err
不为nil
,我们会记录错误并退出。否则,我们将简单地调用fmt.Println
来打印出lines
切片的内容。
就像我们对其他程序所做的那样,您可以通过运行go install
并提供包导入路径(在这种情况下是datafile
)来编译此程序及其依赖的任何包。如果您使用了上述的目录结构,那么导入路径应该是github.com/headfirstgo/count
。
这将在您的 Go 工作空间的bin子目录中保存一个名为count(或在 Windows 上为count.exe)的可执行文件。
就像前几章的data.txt文件一样,我们需要确保在运行程序时当前目录中保存了votes.txt文件。在您的 Go 工作空间的bin子目录中,保存一个具有右侧显示内容的文件。在终端中,使用**cd**
命令切换到相同的子目录。
现在您应该能够通过键入**./count**
(或在 Windows 上键入**count.exe**
)来运行可执行文件。它应该将votes.txt的每一行读入一个字符串切片,然后将该切片打印出来。
用切片来进行名字计数的困难方式
从文件中读取一个名字切片并不需要学习任何新东西。但现在来面对挑战:我们如何计算每个名字出现的次数?我们将展示两种方法,首先是使用切片,然后是使用一个新的数据结构——映射。
对于我们的第一个解决方案,我们将创建两个切片,每个切片具有相同数量的元素,以特定的顺序。第一个切片将保存我们在文件中找到的名字,每个名字出现一次。我们可以称之为names
。第二个切片counts
将保存文件中每个名字出现的次数。元素counts[0]
将保存names[0]
的计数,counts[1]
将保存names[1]
的计数,依此类推。
让我们更新count
程序,实际上计算文件中每个名字出现的次数。我们将尝试这个方案,使用一个names
切片来保存每个唯一候选人名字,并使用一个对应的counts
切片来跟踪每个名字出现的次数。
一如既往,我们可以使用go install
重新编译程序。如果我们运行生成的可执行文件,它将读取votes.txt文件,并打印出它找到的每个名称,以及该名称出现的次数!
让我们更详细地看看它是如何工作的...
我们的count
程序使用一个内循环嵌套在另一个循环中来统计名称计数。外部循环逐行将文件赋给line
变量。
内部循环搜索names
切片的每个元素,查找与文件当前行相等的名称。
假设有人在选票上增加了一个自荐候选人,导致文本文件中的一行加载了字符串"Carlos Diaz"
。程序将逐一检查names
的元素,以查看是否有任何元素等于"Carlos Diaz"
。
如果没有匹配项,程序将字符串"Carlos Diaz"
附加到names
切片,并将1
对应地添加到counts
切片(因为这行代表了对"Carlos Diaz"
的第一次投票)。
但假设下一行是字符串"Brian Martin"
。因为该字符串已经存在于names
切片中,程序将找到它,并在counts
中相应的值上加1
。
映射
但是将名称存储在切片中存在一个问题:对于文件的每一行,您必须搜索names
切片中的许多(如果不是全部)值来进行比较。这在像 Sleepy Creek 县这样的小区域可能还好,但在有大量选票的大区域中,这种方法将会非常慢!
把数据放入切片就像把它堆放在一个大堆里;你可以取回特定的项,但必须搜索所有东西才能找到它们。
切片
Go 还有另一种存储数据集合的方式:映射。映射是一种通过键访问每个值的集合。键是从映射中轻松取回数据的一种方式,就像有整齐标签的文件夹而不是一堆乱七八糟的东西。
映射
而数组和切片只能使用整数作为索引,映射可以使用任何类型作为键(只要该类型的值可以使用==
进行比较)。这包括数字、字符串等。所有值必须是相同类型,所有键必须是相同类型,但键不必与值的类型相同。
要声明一个包含映射的变量,你需要输入map
关键字,后面跟着方括号([]
)包含键类型。然后,在方括号后面,提供值类型。
与切片类似,声明映射变量并不会自动创建映射;你需要调用make
函数(与用于创建切片的相同函数)。与切片类型不同,你可以将要创建的映射类型传递给make
(应与要分配给它的变量类型相同)。
或许你会发现仅仅使用短变量声明更容易:
分配值给映射并再次获取它们的语法看起来很像为数组或切片分配和获取值的语法。但是,数组和切片只允许使用整数作为元素索引,而你几乎可以选择任何类型来用作映射的键。ranks
映射使用string
键:
数组和切片只能使用整数索引。但你几乎可以选择任何类型来用作映射键。
这是另一个以字符串为键和字符串为值的映射:
这是一个以整数为键和布尔值为值的映射:
映射字面量
与数组和切片类似,如果你事先知道要在映射中使用的键和值,可以使用映射字面量来创建它。映射字面量以映射类型开头(形式为map[*KeyType*]*ValueType*
)。然后是包含你想要映射开始的键/值对的大括号。对于每个键/值对,包括键,冒号,然后是值。多个键/值对用逗号分隔。
下面是前面几个映射示例,使用映射字面量重新创建:
就像切片字面量一样,如果大括号为空,就会创建一个空的映射。
映射内的零值
与数组和切片类似,如果访问尚未分配的映射键,将返回一个零值。
根据值类型不同,零值实际上可能不是0
。例如,对于值类型为string
的映射,零值将是空字符串。
与数组和切片类似,即使你尚未显式分配给它,零值也可以确保安全地操作映射值。
映射变量的零值是nil
与切片一样,映射变量本身的零值是nil
。如果声明了一个映射变量,但没有为它分配值,那么它的值将是nil
。这意味着没有映射存在来添加新的键和值。如果尝试这样做,会导致恐慌:
在尝试添加键和值之前,使用make
或映射字面量创建一个映射,并将其分配给你的映射变量。
如何区分零值和分配的值
虽然零值很有用,但有时很难判断给定键是否已分配了零值,或者它从未被分配过。
下面是一个程序示例,其中可能会出现此问题。这段代码错误地报告学生"Carl"
不及格,实际上他只是没有记录任何成绩:
为了解决这类情况,访问映射键时可选择返回第二个布尔值。如果返回的值确实已分配给映射,则此值将为true
;如果返回的值只是表示默认的零值,则为false
。大多数 Go 开发者将此布尔值分配给名为ok
的变量(因为名称简短好记)。
注意
Go 语言的维护者们将此称为“comma ok 惯用法”。我们将在第十一章中再次见到它,用于类型断言。
如果您只想测试一个值是否存在,可以通过将其分配给_
空白标识符来忽略值本身。
第二个返回值可用于确定您应该将从映射中获取的值视为已分配值,恰好与该类型的零值匹配,还是作为未分配值。
下面是我们代码的更新版本,测试请求的键是否确实在报告不及格之前已分配了值:
使用“delete”函数删除键/值对
在将值分配给键后的某个时刻,您可能希望从映射中删除它。Go 语言为此提供了内置的delete
函数。只需将delete
函数传递两个参数:要从中删除键的映射,以及要删除的键。该键及其对应的值将从映射中删除。
在下面的代码中,我们在两个不同的映射中分配值给键,然后再次删除它们。之后,当我们尝试访问这些键时,我们得到一个零值(对于ranks
映射是0
,对于isPrime
映射是false
)。第二个布尔值在每种情况下也都是false
,这意味着该键不存在。
更新我们的投票计数程序以使用映射
现在我们对映射有了更深入的了解,让我们看看能否利用所学知识简化我们的投票计数程序。
以前,我们使用了一对切片,一个称为names
,保存候选人的名字,另一个称为counts
,保存每个名字的投票数。对于从文件中读取的每个名字,我们必须逐个搜索names
切片,找到匹配项。然后,在counts
切片的相应元素中递增该名字的投票计数。
使用映射将更加简单。我们可以用一个单独的映射(我们也称之为counts
)替换这两个切片。我们的映射将使用候选人姓名作为键,并使用整数(用于保存该姓名的投票数)作为值。设置好后,我们只需将从文件中读取的每个候选人姓名用作映射键,并增加该键所持有的值。
这里是一些简化的代码,用于创建映射并直接递增一些候选人姓名的值:
我们之前的程序需要单独的逻辑来向两个切片添加新元素,如果找不到姓名的话...
但是我们用映射不需要这样做。如果我们访问的键不存在,我们将得到零值返回(在这种情况下实际上是0
,因为我们的值是整数)。然后我们增加该值,得到1
,并将其分配给映射。当我们再次遇到该姓名时,我们将得到已分配的值,然后可以像往常一样递增。
接下来,让我们尝试将我们的counts
映射整合到实际程序中,以便它可以统计来自实际文件的投票。
坦率地说,经过所有学习映射的工作,最终的代码看起来有点平淡无奇!我们用单个映射声明替换了两个切片声明。接下来是处理来自文件的字符串的循环中的原始代码。我们用一行代码替换了原来的 11 行代码,该代码在映射中递增当前候选人姓名的计数。最后,我们用一行代码替换了打印结果的末尾循环,该行代码打印整个counts
映射。
不过,相信我们,代码看起来只是看似平淡无奇。这里仍然有复杂的操作。但是映射已经为你处理了所有这些,这意味着你不需要写那么多代码!
与之前一样,你可以使用go install
命令重新编译程序。当我们重新运行可执行文件时,将加载并处理votes.txt文件。我们将看到打印的counts
映射,其中显示了文件中每个姓名遇到的次数。
使用for
...range
循环与映射
**姓名:凯文·瓦格纳
职业:选举志愿者**
是的。每行一个名字和一个投票计数的格式可能更好:
要将映射中的每个键和值格式化为单独的行,我们需要循环遍历映射中的每个条目。
我们之前用来处理数组和切片元素的相同for
...range
循环在映射上也适用。不过,与将整数索引分配给提供的第一个变量不同,当前映射键将被分配。
for
...range
循环使得遍历映射键和值变得容易。只需提供一个变量来保存每个键,另一个变量来保存相应的值,它将自动遍历映射中的每个条目。
如果只需要遍历键,可以省略保存值的变量:
如果只需要值,您可以将键分配给 _
空白标识符:
但是这个例子可能存在一个潜在问题…… 如果您将前面的例子保存到文件并使用go run
运行,您会发现映射的键和值以随机顺序打印出来。如果多次运行程序,每次都会得到不同的顺序。
注意
(注意:在在线 Go Playground 站点上运行的代码不适用相同规则。在那里,顺序仍然是随机的,但每次运行时会产生相同的输出。)
for
...range
循环以随机顺序处理映射!
for
...range
循环以随机顺序处理映射的键和值,因为映射是一个无序的键和值的集合。当您在映射上使用for
...range
循环时,您无法预测将以何种顺序获取映射的内容!有时这没关系,但如果需要更一致的顺序,就需要自己编写代码来处理。
这是前一个程序的更新版本,始终按字母顺序打印姓名。它使用两个单独的for
循环。第一个循环遍历映射中的每个键,忽略值,并将它们添加到一个字符串切片中。然后,将该切片传递给sort
包的Strings
函数,按字母顺序对其进行就地排序。
第二个for
循环不是遍历映射,而是遍历排序后的姓名切片。(由于前面的代码,此切片现在包含按字母顺序排列的映射中的每个键。)它打印姓名,然后从映射中获取与该姓名匹配的值。它仍然处理映射中的每个键和值,但是从排序后的切片中获取键,而不是从映射本身获取。
如果我们保存上述代码并运行它,这次将按字母顺序打印学生姓名。无论我们运行程序多少次,这都是真实的。
如果不在乎映射数据的处理顺序,直接在映射上使用for
...range
循环可能适合您。但是如果顺序很重要,您可能需要考虑编写自己的代码来处理处理顺序。
使用for
...range
循环更新我们的投票计数程序
在斯利比克里克县的候选人不多,因此我们不需要按姓名排序输出。我们将使用for
...range
循环直接处理来自映射的键和值。
这是一个相当简单的更改;我们只需用 for
...range
循环替换打印整个映射的行。我们将每个键分配给一个 name
变量,每个值分配给一个 count
变量。然后我们将调用 Printf
来打印当前候选人的姓名和选票数。
通过 go install
进行另一个编译,再运行可执行文件,我们就能看到以新格式输出的结果了。每个候选人的姓名和他们的选票数都在这里,整洁地格式化在各自的行上。
投票计数程序完成!
我们的投票计数程序已完成!
当我们只能使用数组和切片作为数据集合时,我们需要大量的额外代码和处理时间来查找值。但是使用了映射(map)后,这一过程变得很简单!每当你需要再次查找集合的值时,你都应该考虑使用映射!
代码磁铁
一个使用 for
...range
循环来打印映射内容的 Go 程序被混在冰箱上。你能重建代码片段,使之成为一个能够产生给定输出的可工作程序吗?(如果程序运行时输出顺序不同也没关系。)
答案在“代码磁铁解决方案”中。
你的 Go 工具箱
这就是第七章的全部内容!你已经把地图添加到了你的工具箱里。
代码磁铁解决方案
![image
第八章:构建存储:结构体
有时您需要存储多种类型的数据。
我们学习了关于切片的知识,它们可以存储值列表。然后我们学习了关于映射的知识,它们将键列表映射到值列表。但是这两种数据结构只能保存一种类型的值。有时,您需要将多种类型的值组合在一起。例如邮寄地址,您需要将街道名称(字符串)与邮政编码(整数)混合在一起。或者学生记录,您需要将学生姓名(字符串)与平均绩点(浮点数)混合在一起。您不能在切片或映射中混合值类型。但是如果使用另一种称为结构体的类型,那么可以。我们将在本章中详细了解结构体!
切片和映射只能保存一种类型的值。
Gopher Fancy是一本专门致力于可爱啮齿动物的新杂志。他们目前正在开发一个系统来跟踪他们的订阅者群体。
确实如此:数组、切片和映射无法帮助您混合不同类型的值。它们只能设置为保存单一类型的值。但 Go 确实有一种方法来解决这个问题……
结构体由多种类型的值构成
结构体(简称“结构”)是由许多不同类型的其他值构成的值。虽然切片可能只能保存string
值,或者映射可能只能保存int
值,但是您可以创建一个结构体,其中既可以保存string
值,也可以保存int
值、float64
值、bool
值等等——所有这些都放在一个方便的组合中。
使用struct
关键字声明结构体类型,后跟花括号。在花括号内部,您可以定义一个或多个字段:结构体组合在一起的值。每个字段定义都显示在单独的行上,并包含字段名,后跟该字段将保存的值类型。
您可以将结构体类型用作正在声明的变量的类型。此代码声明了一个名为myStruct
的变量,该变量保存具有float64
字段number
、string
字段word
和bool
字段toggle
的结构体:
注意
(通常使用定义的类型来声明结构体变量更常见,但我们将在几页后再详细讨论类型定义,所以现在我们将按照这种方式编写。)
当我们在上面使用Printf
和%#v
动词调用时,它将结构体myStruct
的值打印为结构体字面量。我们稍后将介绍结构体字面量,但现在您可以看到结构体的number
字段已设置为0
,word
字段为空字符串,toggle
字段设置为false
。每个字段都设置为其类型的零值。
使用点运算符访问结构体字段
现在我们可以定义一个结构体,但实际使用它时,我们需要一种方法来存储结构体字段的新值并再次检索它们。
一直以来,我们一直在使用点运算符来指示“属于”另一个包的函数,或者“属于”一个值的方法:
同样,我们可以使用点运算符来指示“属于”结构体的字段。这对于分配值和检索值都适用。
我们可以使用点运算符为myStruct
的所有字段赋值,然后将它们打印出来:
将订阅者数据存储在结构体中
现在我们知道如何声明一个变量来保存结构体并为其字段赋值,我们可以创建一个用于保存杂志订阅者数据的结构体。
首先,我们将定义一个名为subscriber
的变量。我们将给subscriber
一个结构体类型,其中包含name
(string
)、rate
(float64
)和active
(bool
)字段。
声明变量及其类型后,我们可以使用点运算符访问结构体的字段。我们为每个字段分配适当类型的值,然后再次将这些值打印出来。
即使我们为订阅者存储的数据使用了各种类型,结构体也让我们能够将它们都放在一个便捷的包中!
定义类型和结构体
在整本书中,您已经使用了多种类型,比如int
,string
,bool
,切片,映射,现在又是结构体。但是,您还没有能够创建完全新的类型。
类型定义允许您创建自己的类型。它们让您创建一个基于基础类型的新的定义类型。
虽然您可以使用任何类型作为基础类型,比如float64
,string
,甚至是切片或映射,但在本章中,我们将专注于使用结构类型作为基础类型。在下一章中,当我们深入研究定义类型时,我们将尝试使用其他基础类型。
要编写类型定义,请使用type
关键字,后跟您的新定义类型的名称,然后是要基于的基础类型。如果您使用结构体类型作为基础类型,您将使用struct
关键字,后跟花括号中的字段定义列表,就像在声明结构体变量时所做的那样。
就像变量一样,类型定义可以写在函数内部。但这将限制其范围仅在该函数的块内,意味着您不能在该函数外部使用它。因此,类型通常是在包级别的任何函数外定义的。
作为一个快速演示,下面的代码定义了两种类型:part
和car
。每个定义类型都使用结构体作为其基础类型。
然后,在main
函数内部,我们声明了一个porsche
变量,类型为car
,以及一个bolts
变量,类型为part
。在声明变量时,无需重新编写冗长的结构体定义;我们只需使用定义类型的名称即可。
声明变量后,我们可以设置其结构体字段的值,并像以前的程序一样获取这些值。
使用定义类型处理杂志订阅者
以前,要创建多个存储杂志订阅者数据的变量,我们必须为每个变量写出完整的结构体类型(包括所有字段)。
但现在,我们可以简单地在包级别定义一个subscriber
类型。我们只需一次编写结构体类型,作为定义类型的基础类型。当我们准备声明变量时,我们不必再次编写结构体类型;我们只需将subscriber
用作它们的类型。不再需要重复整个结构体定义!
使用定义类型处理函数
定义类型不仅可以用于变量类型,还可以用于函数参数和返回值。
这里是我们的part
类型,还有一个新的showInfo
函数,它打印部件的字段。该函数接受一个参数,其类型为part
。在showInfo
中,我们像处理任何其他结构体变量一样通过参数变量访问字段。
这里有一个minimumOrder
函数,它创建一个带有指定描述和count
字段预定义值的part
。我们将minimumOrder
的返回类型声明为part
,以便它可以返回新的结构体。
让我们来看看几个与杂志的subscriber
类型一起使用的函数……
printInfo
函数接受一个subscriber
作为参数,并打印其字段的值。
我们还有一个defaultSubscriber
函数,它用一些默认值设置一个新的subscriber
结构体。它接受一个名为name
的字符串参数,并使用它来设置新的subscriber
值的name
字段。然后它将rate
和active
字段设置为默认值。最后,它将完成的subscriber
结构体返回给其调用者。
在我们的main
函数中,我们可以将订阅者名称传递给defaultSubscriber
以获取一个新的subscriber
结构体。一个订阅者获得了折扣rate
,因此我们直接重置该结构体字段。我们可以将填写好的subscriber
结构体传递给printInfo
以打印它们的内容。
代码磁铁
一个 Go 程序被分散在冰箱上。你能重组代码片段,使之成为一个能够产生指定输出的工作程序吗?最终程序将具有名为student
的定义结构体类型,以及一个接受student
值作为参数的printInfo
函数。
答案在“代码磁铁解决方案”中。
使用函数修改结构体
我们在Gopher Fancy的朋友们正试图编写一个函数,该函数接受一个结构体作为参数,并更新该结构体中的一个字段。
还记得很久以前的第三章吗?当时我们试图编写一个double
函数,它接受一个数字并使其加倍?在double
返回后,数字回到了原始值!
那时我们了解到,Go 语言是一种“传值”语言,这意味着函数参数接收的是调用函数时传入的参数的副本。如果函数修改参数的值,它修改的是这个副本,而不是原始值。
对结构也是如此。当我们将subscriber
结构体传递给applyDiscount
时,函数接收的是结构体的副本。因此,当我们设置结构体的rate
字段时,我们修改的是复制的结构体,而不是原始结构体。
回到第三章,我们的解决方案是更新函数参数以接受值的指针,而不是直接接受值。在调用函数时,我们使用取地址操作符(&
)传递要更新的值的指针。然后,在函数内部,我们使用*
操作符来更新该指针指向的值。
所以,在函数返回后,更新后的值仍然可见。
我们可以使用指针允许函数更新结构体。
这是一个更新后的applyDiscount
函数的版本,应该可以正常工作。我们更新s
参数以接受指向subscriber
结构体的指针,而不是结构体本身。然后我们更新结构体的rate
字段的值。
在main
函数中,我们使用指向subscriber
结构体的指针调用applyDiscount
。当我们打印结构体中的rate
字段时,我们可以看到它已成功更新!
事实上,不是这样!点符号访问字段的方法对结构指针以及结构本身都适用。
通过指针访问结构字段
如果尝试打印指针变量,你会看到它指向的内存地址。这通常不是你想要的。
相反,你需要使用*
操作符(我们喜欢称之为“值在操作符”)来获取指针指向的值。
因此,你可能认为你需要对结构体指针使用*
操作符。但仅仅在结构体指针前加上*
并不能起作用:
如果你写*pointer.myField
,Go 认为myField
必须包含一个指针。但实际上并非如此,这会导致错误。要使其工作,你需要在*pointer
周围加上括号。这将导致检索myStruct
值,然后可以访问结构字段。
虽然必须经常写(*pointer).myField
会很快变得乏味。因此,点操作符允许您通过指向结构体的指针访问字段,就像您可以直接从结构体值访问字段一样。您可以省略括号和*
操作符。
这对通过指针分配结构体字段同样适用:
这就是applyDiscount
函数如何能够在不使用*
操作符的情况下更新结构体字段的方式。
没有愚蠢的问题
Q: 你之前展示了一个defaultSubscriber
函数来设置结构体的字段,但它不需要使用任何指针!为什么不需要?
A: defaultSubscriber
函数返回了一个结构体值。如果调用者存储了返回的值,那么其字段中的值将会被保留。只有那些修改现有结构体而不返回它们的函数才需要使用指针,以便这些更改能够被保留。
但是如果我们希望的话,defaultSubscriber
可以返回一个指向结构体的指针。事实上,在下一部分我们就做了这个改变!
通过指针传递大型结构体
是的,会。它必须为原始结构体和副本腾出空间。
函数接收的是调用时传入的参数的副本,即使是像结构体这样的大值。
这就是为什么,除非你的结构体只有一两个小字段,通常最好是传递一个指向结构体的指针,而不是结构体本身。当你传递结构体指针时,内存中只存在原始结构体的一个副本。函数只是接收到单个结构体的内存地址,并可以读取结构体,修改它,或者进行其他任何操作,而无需制作额外的副本。
下面是我们更新过的defaultSubscriber
函数,现在返回一个指针,并且我们更新了printInfo
函数,使其接收一个指针。像applyDiscount
一样,这两个函数都不需要改变现有的结构体。但使用指针确保只需在内存中保留每个结构体的一个副本,同时仍然允许程序正常工作。
将我们的结构类型移到不同的包中
这应该很容易做到。在你的 Go 工作空间中找到headfirstgo目录,并在其中创建一个新目录来保存名为magazine
的包。在magazine中,创建一个名为magazine.go的文件。
请确保在magazine.go文件顶部添加package magazine
声明。接着,从你现有的代码中复制subscriber
结构体定义,并粘贴到magazine.go中。
接下来,让我们创建一个程序来尝试新的包。由于我们现在只是做实验,所以暂时不要为这段代码创建一个单独的包目录;我们将使用go run
命令来运行它。创建一个名为main.go的文件。你可以将它保存在任何目录中,但要确保它保存在你的 Go 工作空间之外,以免与其他包发生冲突。
注意
(如果需要的话,稍后可以将此代码移动到你的 Go 工作空间,只要为其创建一个单独的包目录即可。)
在main.go中,保存这段代码,它简单地创建了一个新的subscriber
结构体并访问了其中的一个字段。
与先前示例有两个不同之处。首先,我们需要在文件顶部导入magazine
包。其次,我们需要使用magazine.subscriber
作为类型名称,因为它现在属于另一个包。
定义类型的名称必须大写才能被导出
看看我们的实验性代码是否仍然可以访问其新包中的subscriber
结构体类型。在终端中,切换到保存main.go的目录,然后输入**go run main.go**
。
我们出现了一些错误,但重要的是:cannot refer to unexported name magazine.subscriber
。
Go 类型名称遵循与变量和函数名称相同的规则:如果变量、函数或类型的名称以大写字母开头,则被视为导出的,可以从声明它的包外访问。但是我们的subscriber
类型名称以小写字母开头。这意味着它只能在magazine
包内部使用。
要使类型能够从其定义的包外访问,必须将其导出:其名称必须以大写字母开头。
看起来这是一个简单的修复方法。我们只需打开我们的magazine.go文件,并将定义类型的名称大写化。然后,我们打开main.go并将对该类型的任何引用也大写化。(现在只有一个引用。)
如果我们尝试用go run main.go
运行更新后的代码,就不再会出现magazine.subscriber
类型未导出的错误了。所以这个问题看起来已经解决了。但是,我们却得到了一些新的错误...
结构体字段名必须大写才能被导出
当Subscriber
类型名称大写时,似乎可以从main
包中访问它。但是现在我们得到一个错误,说我们不能引用rate
字段,因为它是未导出的。
即使一个结构体类型从一个包中导出,如果它们的字段名称不以大写字母开头,它们将会是未导出的。让我们尝试在magazine.go和main.go中将Rate
大写化...
如果想要从其包中导出结构体字段名称也必须大写。
再次运行main.go,你会发现这次一切都正常工作了。现在它们已经被导出,我们可以从main
包中访问Subscriber
类型以及其Rate
字段。
注意尽管name
和active
字段仍未导出,代码仍然有效。如果需要的话,你可以在单个结构类型中混合使用导出和未导出的字段。
在Subscriber
类型的情况下可能不明智。能够从其他包中访问订阅率,但不能访问名称或地址是没有意义的。因此,让我们返回magazine.go并将其他字段也导出。只需将它们的名称大写:Name
和Active
。
结构体字面量
定义一个结构体并逐个为其字段赋值的代码可能有些繁琐:
var subscriber magazine.Subscriber
subscriber.Name = "Aman Singh"
subscriber.Rate = 4.99
subscriber.Active = true
因此,与切片和映射一样,Go 语言提供了结构体字面量,让你可以创建一个结构体并同时设置其字段。
语法看起来类似于映射字面量。首先列出类型,然后是花括号。在花括号内,你可以为一些或所有的结构字段指定值,使用字段名、冒号,然后是值。如果指定多个字段,用逗号分隔。
在上面,我们展示了一些创建Subscriber
结构体并逐个设置其字段的代码。这段代码使用结构体字面量在一行中完成相同的操作:
你可能已经注意到,在大部分章节中,我们不得不对结构体变量使用长形式声明(除非结构体是从函数中返回的)。结构体字面量允许我们对刚刚创建的结构体使用短变量声明。
你可以省略一些甚至所有的字段。省略的字段将会被设置为它们类型的零值。
池谜题
你的任务是从池中取出代码片段,并将它们放入这段代码的空白行中。不要重复使用相同的片段,你不需要使用所有的片段。你的目标是创建一个能够运行并产生所示输出的程序。
注意:每个池中的片段只能使用一次!
答案见“Pool Puzzle Solution”。
创建一个 Employee 结构类型
添加一个Employee
结构类型应该很简单。我们只需将其添加到magazine
包中,与Subscriber
类型并列。在magazine.go中,定义一个新的Employee
类型,其基础类型为struct
。为该结构类型添加一个Name
字段,类型为string
,并添加一个Salary
字段,类型为float64
。确保将类型名和所有字段都大写,以便从magazine
包中导出它们。
我们可以更新main.go中的main
函数以尝试新类型。首先,声明一个类型为magazine.Employee
的变量。然后为每个字段分配适当类型的值。最后,打印这些值。
如果你从终端执行go run main.go
,它应该运行,创建一个新的magazine.Employee
结构体,设置其字段值,然后打印这些值。
创建一个 Address 结构体类型
接下来,我们需要为Subscriber
和Employee
类型跟踪邮寄地址。我们将需要街道地址、城市、州和邮政编码(邮政编码)的字段。
我们可以向Subscriber
和Employee
类型分别添加单独的字段,如下所示:
但无论属于哪种类型,邮寄地址的格式都是相同的。重复多个类型之间所有这些字段是很麻烦的。
结构字段可以容纳任何类型的值,包括其他结构体。因此,我们试试构建一个Address
结构体类型,然后在Subscriber
和Employee
类型上添加一个Address
字段。这样现在会为我们节省一些工作量,并且如果需要更改地址格式,稍后可以确保类型之间的一致性。
首先我们将创建Address
类型,以确保其正常工作。将其放置在magazine
包中,与Subscriber
和Employee
类型并列。然后,用几行代码替换main.go中的代码,创建一个Address
并确保其字段可访问。
在你的终端中键入**go run main.go**
,它应该创建一个Address
结构体,填充其字段,然后打印整个结构体。
在另一种类型上添加一个结构体作为字段
现在我们确信Address
结构体类型可以单独使用,让我们将HomeAddress
字段添加到Subscriber
和Employee
类型中。
添加一个字段,该字段本身是一个结构体类型,与添加任何其他类型的字段没有区别。你为字段提供一个名称,然后是字段的类型(在本例中将是一个结构体类型)。
在Subscriber
结构体中添加一个名为HomeAddress
的字段。确保将字段名称大写,这样可以从magazine
包的外部访问它。然后指定字段类型为Address
。
同样,将HomeAddress
字段添加到Employee
类型中。
在另一个结构体内设置结构体
现在让我们看看是否可以在Subscriber
结构体内部填充Address
结构体的字段。有几种方法可以做到这一点。
第一种方法是创建一个完全独立的Address
结构体,然后使用它来设置Subscriber
结构体的整个Address
字段。以下是遵循此方法更新main.go的代码。
在你的终端中键入**go run main.go**
,你会看到订阅者的HomeAddress
字段已经设置为你构建的结构体。
另一种方法是通过外部结构为内部结构的字段赋值。
当创建Subscriber
结构时,其HomeAddress
字段已经设置:它是一个Address
结构,其中所有字段均设置为它们的零值。如果我们使用fmt.Printf
的"%#v"
动词打印HomeAddress
,它将打印出结构体,就像在 Go 代码中看到的一样 — 即,作为结构体文字。我们将看到Address
的每个字段都设置为空字符串,这是string
类型的零值。
如果subscriber
是包含Subscriber
结构的变量,那么当您键入subscriber.HomeAddress
时,即使您尚未明确设置HomeAddress
,也将获得一个Address
结构。
您可以利用这一事实“链”点运算符在一起,以便访问Address
结构的字段。只需键入**subscriber.HomeAddress**
来访问Address
结构,然后再跟随另一个点运算符和您想要在该Address
结构上访问的字段名称。
这对于给内部结构的字段赋值都适用...
subscriber.HomeAddress.PostalCode = "68111"
...以及稍后检索这些值。
fmt.Println("Postal Code:", subscriber.HomeAddress.PostalCode)
这里是更新的main.go,它使用点运算符链。首先,我们将Subscriber
结构存储在subscriber
变量中。这将自动在subscriber
的HomeAddress
字段中创建一个Address
结构。我们为subscriber.HomeAddress.Street
、subscriber.HomeAddress.City
等设置值,然后再次打印这些值。
然后,我们将Employee
结构存储在employee
变量中,并对其HomeAddress
结构执行相同操作。
在您的终端中输入**go run main.go**
,程序将打印出subscriber.HomeAddress
和employee.HomeAddress
的完成字段。
匿名结构字段
尽管通过外部结构访问内部结构的字段的代码有点繁琐。每次想要访问它包含的任何字段时,都必须编写内部结构(HomeAddress
)的字段名称。
Go 允许您定义匿名字段:没有自己名称的结构字段,只有类型。我们可以使用匿名字段使我们的内部结构更容易访问。
这里更新了Subscriber
和Employee
类型,将它们的HomeAddress
字段转换为匿名字段。为此,我们只需删除字段名称,只留下类型。
当声明匿名字段时,您可以像使用字段类型名称一样使用它,好像它是字段的名称一样。因此,下面代码中的subscriber.Address
和employee.Address
仍然访问Address
结构:
嵌入结构体
但匿名字段提供的不仅仅是在结构定义中省略字段名称的能力。
一个嵌入在外部结构体中的内部结构体,使用匿名字段存储,被称为嵌入在外部结构体中。嵌入结构体的字段被提升到外部结构体,这意味着你可以像访问外部结构体的字段一样访问它们。
所以现在Address
结构类型已经嵌入到Subscriber
和Employee
结构类型中,你不必写出subscriber.Address.City
来获取City
字段;你可以直接写subscriber.City
。你不需要写employee.Address.State
;你可以直接写employee.State
。
这是main.go的最后一个版本,更新为将Address
作为嵌入类型处理。你可以将代码编写得好像根本没有Address
类型;就像Address
字段属于它们所嵌入的结构体类型一样。
请记住,你并不必须嵌入内部结构体。你根本不需要使用内部结构体。有时,在外部结构体上添加新字段会导致最清晰的代码。考虑你当前的情况,并选择最适合你和你的用户的解决方案。
我们的定义类型已经完成了!
做得好!你已经定义了Subscriber
和Employee
结构类型,并在每个结构中嵌入了一个Address
结构。你找到了一种方式来表示杂志所需的所有数据!
虽然你在定义类型时仍然缺少一个重要的方面。在之前的章节中,你使用了像time.Time
和strings.Replacer
这样的类型,它们有方法:你可以在它们的值上调用的函数。但是你还没有学会如何为自己的类型定义方法。不用担心;我们将在下一章节详细学习!
你的 Go 工具箱
这就是第八章的全部内容!你已经为你的工具箱添加了结构体和定义类型。
代码磁铁解决方案
池谜题解决方案
第九章:你是我的类型:定义类型
还有更多关于定义类型的知识需要学习。 在前一章中,我们向您展示了如何定义一个以结构体为基础类型的类型。但我们没有向您展示您可以使用任何类型作为基础类型。
你还记得方法——那种与特定类型的值相关联的特殊函数吗?我们在整本书中一直在对各种值调用方法,但我们还没有向你展示如何定义自己的方法。在本章中,我们将解决这一切。让我们开始吧!
现实生活中的类型错误
如果你住在美国,你可能习惯了那里使用的古怪计量系统。例如,在加油站,燃料是按加仑出售的,这是大部分世界其余地区使用的升的近四倍体积单位。
Steve 是一个美国人,在另一个国家租车。他驶入加油站加油。他打算购买 10 加仑,以为这将足够到达另一个城市的酒店。
他重新上路,但在到达目的地的四分之一路程后燃料用尽。
如果 Steve 仔细看了加油站泵上的标签,他会意识到它是用升来测量燃料,而不是加仑,他需要购买 37.85 升才能相当于 10 加仑。
10 加仑
当你有一个数字时,最好确定这个数字测量的是什么。你想知道它是升还是加仑,千克还是磅,美元还是日元。
10 升
以基础基本类型定义的类型
如果你有以下变量:
var fuel float64 = 10
...那表示 10 加仑还是 10 升?写下这个声明的人知道,但其他人不知道,不确定。
你可以使用 Go 的定义类型来明确值的用途。虽然定义类型最常使用结构体作为其基础类型,但它们可以基于int
、float64
、string
、bool
或任何其他类型。
Go 定义类型最常使用结构体作为其基础类型,但也可以基于 int、string、boolean 或任何其他类型。
这是一个定义了两种新类型Liters
和Gallons
的程序,它们都有一个基础类型为float64
。它们在包级别定义,因此它们在当前包的任何函数中都可以使用。
在main
函数中,我们声明了一个类型为Gallons
的变量,另一个为Liters
的变量。我们为每个变量赋值,然后将它们打印出来。
一旦你定义了一个类型,你就可以对该类型的任何基础类型的值进行转换。与其他任何转换一样,你需要在想要转换的类型后面写上要转换的值,用括号括起来。
如果我们愿意,我们可以在上述代码中使用类型转换写出简短的变量声明:
如果你有一个使用定义类型的变量,你不能将不同定义类型的值赋给它,即使另一种类型具有相同的基础类型。这有助于防止开发人员混淆这两种类型。
但是你可以转换具有相同基础类型的类型。因此,Liters
可以转换为Gallons
,反之亦然,因为两者的基础类型都是float64
。但是在进行转换时,Go 语言只考虑基础类型的值;Gallons(Liters(240.0))
和Gallons(240.0)
之间没有区别。简单地从一种类型转换为另一种类型的原始值,会破坏类型应提供的转换错误保护。
相反,你应该执行必要的操作,以将基础类型的值转换为适合转换为的类型的值。
快速的网络搜索显示,1 升大约等于 0.264 加仑,1 加仑大约等于 3.785 升。我们可以通过这些换算率相乘来从Gallons
转换为Liters
,反之亦然。
定义类型和运算符
定义类型支持与其基础类型相同的所有操作。例如,基于float64
的类型支持诸如+
、-
、*
和/
的算术运算符,以及==
、>
和<
等比较运算符。
基于string
的类型将支持+
、==
、>
和<
等操作,但不支持-
,因为-
不是字符串的有效运算符。
定义类型可以与字面值一起使用:
但是定义的类型不能与不同类型的值一起使用,即使另一种类型具有相同的基础类型。这样做是为了防止开发人员意外混合这两种类型。
如果要将Liters
中的值添加到Gallons
中的值,你需要首先将一种类型转换以匹配另一种类型。
池子谜题
你的任务是从池中提取代码片段,并将它们放入此代码中的空白行。不要重复使用相同的片段,而且你不需要使用所有的片段。你的目标是编写一个能运行并产生所示输出的程序。
注意:池中的每个片段只能使用一次!
答案在“池子谜题解答”中。
使用函数进行类型转换
假设我们想要拿一辆以Gallons
为单位测量燃料的汽车,在Liters
单位的加油站加油。或者拿一辆以Liters
为单位测量燃料的公共汽车,在Gallons
单位的加油站加油。为了防止不准确的测量,Go 语言会在我们尝试组合不同类型的值时给出编译错误:
为了处理不同类型的值,我们需要首先进行类型转换以匹配。之前,我们演示了将Liters
值乘以 0.264,并将结果转换为Gallons
。我们还将Gallons
值乘以 3.785,并将结果转换为Liters
。
我们可以创建ToGallons
和ToLiters
函数来执行相同的操作,然后调用它们来进行转换:
汽油不是我们需要测量体积的唯一液体。还有食用油、汽水瓶和果汁等。因此,除了升和加仑之外,还有许多体积单位。在美国有茶匙、杯子、夸脱等等。度量衡系统也有其他单位,但毫升(升的 1/1000)是最常用的单位。
让我们添加一个新类型,Milliliters
。像其他类型一样,它将使用float64
作为基础类型。
我们还需要一种方法来将Milliliters
转换为其他类型。但是,如果我们开始添加一个从Milliliters
到Gallons
的转换函数,我们会遇到一个问题:我们不能在同一个包中拥有两个ToGallons
函数!
我们可以将这两个ToGallons
函数重命名为包含它们所转换的类型的函数:LitersToGallons
和MillilitersToGallons
。但是每次写出这些名称都很麻烦,而且随着我们开始添加其他类型之间的转换函数,很明显这是不可持续的。
没有愚蠢的问题
Q: 我看过其他支持函数重载的语言:它们允许您拥有多个函数具有相同的名称,只要它们的参数类型不同。Go 语言不支持这种功能吗?
A: Go 语言的维护者们也经常遇到这个问题,并在golang.org/doc/faq#overloading
上回答:“通过其他语言的经验,我们知道具有相同名称但不同签名的多种方法有时很有用,但在实践中可能会令人困惑和脆弱。” Go 语言通过不支持函数重载来简化语言,因此不支持它。正如您将在本书后面看到的,Go 团队在语言的其他领域也做出了类似的决策;当他们在简单性和添加更多功能之间需要做出选择时,他们通常选择简单性。但这没关系!很快我们会看到,还有其他方法可以获得相同的好处……
通过方法修复我们的函数名冲突
还记得在第二章中我们向你介绍的方法吗?它们是与给定类型的值相关联的函数?除其他外,我们创建了一个time.Time
值并调用了它的Year
方法,还创建了一个strings.Replacer
值并调用了它的Replace
方法。
我们可以定义自己的方法来帮助解决类型转换问题。
我们不能有多个名为ToGallons
的函数,所以我们不得不编写长而冗长的函数名,其中包含我们正在转换的类型:
LitersToGallons(Liters(2))
MillilitersToGallons(Milliliters(500))
但我们可以有多个名为ToGallons
的方法,只要它们定义在不同的类型上。不必担心名称冲突将使我们的方法名称变得更短。
Liters(2).ToGallons()
Milliliters(500).ToGallons()
但让我们不要过于急躁。在我们做任何其他事情之前,我们需要知道如何定义一个方法……
定义方法
方法定义与函数定义非常相似。事实上,它们只有一个区别:你需要在函数名之前的括号内添加一个额外的参数,即接收者参数。
与任何函数参数一样,你需要为方法定义中的接收者参数提供一个名称,后面跟着一个类型。
要调用你定义的方法,你需要写出你调用方法的值,加一个点,然后是你调用的方法名称,后面跟上括号。你调用方法的值称为方法的接收者。
在方法调用和方法定义之间的相似性可以帮助你记住语法:调用方法时,接收者首先列出,而定义方法时,接收者参数首先列出。
方法定义中接收者参数的名称并不重要,但其类型是重要的;你正在定义的方法将与该类型的所有值相关联。
下面,我们定义了一个名为MyType
的类型,其基础类型为string
。然后,我们定义了一个名为sayHi
的方法。因为sayHi
有一个类型为MyType
的接收者参数,我们将能够在任何MyType
值上调用sayHi
方法。(大多数开发人员会说sayHi
是在MyType
上定义的。)
一旦在类型上定义了方法,就可以在该类型的任何值上调用它。
在这里,我们创建两个不同的MyType
值,并在每个值上调用sayHi
。
接收者参数(基本上)只是另一个参数
接收者参数的类型是该方法关联的类型。但除此之外,接收者参数在 Go 中并没有特殊待遇。你可以像处理任何其他函数参数一样,在方法块内部访问其内容。
下面的代码示例与前一个几乎相同,只是我们更新了它以打印接收者参数的值。你可以在输出结果中看到这些接收者。
Go 允许你随意命名接收者参数,但如果你为一个类型定义的所有方法都使用相同名称的接收者参数,那会更易读。
按照惯例,Go 开发者通常使用由接收者类型名称的第一个字母小写组成的名称。(这就是我们将m
作为MyType
接收者参数名称的原因。)
Go 使用接收者参数而不是其他语言中看到的“self”或“this”值。
没有愚蠢的问题
Q: 我可以为任何类型定义新方法吗?
A: 只能为与方法定义在同一包中的类型定义方法。这意味着不能在你的hacking
包中为别人的security
包中的类型定义方法,也不能在通用类型如int
或string
上定义新方法。
Q: 但我需要能够使用自己的方法来处理别人的类型!
A: 首先你应该考虑是否一个函数就足够了;函数可以接受任何你想要的类型作为参数。但如果你确实需要一个同时具有自己方法和其他包类型方法的值,你可以定义一个结构体类型,匿名地嵌入其他包的类型。我们将在下一章中看看这是如何工作的。
Q: 我在其他语言中看到方法接收者在方法块中作为特殊变量命名为self
或this
。Go 是否也这样做?
A: Go 使用接收者参数而不是self
和this
。主要区别在于,self
和this
是隐式设置的,而你需要显式声明一个接收者参数。除此之外,接收者参数的使用方式相同,Go 不需要将self
或this
保留为关键字!(如果你愿意,甚至可以将接收者参数命名为this
,但不要这样做;惯例是使用接收者类型名称的首字母。)
方法(几乎)与函数完全一样
除了它们在接收者上被调用之外,方法与任何其他函数基本相似。
就像任何其他函数一样,你可以在方法名后的括号中定义额外的参数。这些参数变量可以在方法块中访问,连同接收者参数一起。当你调用方法时,你需要为每个参数提供一个实参。
就像任何其他函数一样,你可以为方法声明一个或多个返回值,在调用方法时返回这些值:
与任何其他函数一样,如果方法名称以大写字母开头,则认为该方法从当前包中导出,如果方法名称以小写字母开头,则认为该方法未导出。如果希望在当前包之外使用你的方法,请确保其名称以大写字母开头。
指针接收器参数
这里有一个可能看起来很熟悉的问题。我们定义了一个新的Number
类型,其基础类型为int
。我们给Number
定义了一个double
方法,该方法应该将其接收器的基础值乘以两倍,然后更新接收器。但从输出中我们可以看到,方法接收器实际上并没有被更新。
回到第三章,我们曾经有一个带有类似问题的double
函数。那时,我们学到函数参数接收的是函数调用时的值的副本,而不是原始值,并且在函数退出时,对副本的任何更新都将丢失。为了使double
函数工作,我们必须传递一个我们想要更新的值的指针,然后在函数内部更新该指针处的值。
我们已经说过,接收器参数与普通参数没有任何区别。与任何其他参数一样,接收器参数接收接收器值的副本。如果在方法内部对接收器进行更改,则更改的是副本,而不是原始值。
与第三章中的double
函数类似,解决方案是更新我们的Double
方法以使用指针作为其接收器参数。这与任何其他参数一样:我们在接收器类型前面加上*
表示它是一个指针类型。我们还需要修改方法块,以便更新指针处的值。完成后,当我们对Number
值调用Double
时,应该会更新Number
。
注意,我们不需要改变方法调用。当您在具有非指针类型的变量上调用需要指针接收器的方法时,Go 会自动将接收器转换为指针。对于具有指针类型的变量也是如此;如果调用需要值接收器的方法,Go 会自动获取指针处的值,并将其传递给方法。
您可以在右侧的代码中看到其工作原理。名为method
的方法采用值接收器,但我们可以使用直接值和指针调用它,因为如果需要,Go 会自动转换。名为pointerMethod
的方法采用指针接收器,但我们可以在直接值和指针上调用它,因为如果需要,Go 会自动转换。
顺便说一句,右侧的代码打破了一个惯例:为了一致性,您类型的所有方法可以接受值接收器,或者可以全部接受指针接收器,但应避免混合使用这两种。我们在这里只是为了演示目的而混合了这两种。
破坏性的事情是有教育意义的!
这里是我们的Number
类型,再次定义了几个方法。进行以下一种更改并尝试编译代码。然后撤消您的更改并尝试下一步。看看会发生什么!
package main
import "fmt"
type Number int
func (n *Number) Display() {
fmt.Println(*n)
}
func (n *Number) Double() {
*n *= 2
}
func main() {
number := Number(4)
number.Double()
number.Display()
}
如果您这样做... | ...代码会因为... |
---|---|
将接收器参数更改为此包中未定义的类型:func (n *~~Number~~int) Double() { *n *= 2 } |
您只能在当前包中声明的类型上定义新方法。在全局定义类型(如int )上定义方法将导致编译错误。 |
将Double 的接收器参数更改为非指针类型:func (n ~~*~~Number) Double() { ~~*~~n *= 2 } |
接收器参数接收调用方法的值的副本。如果Double 函数仅修改副本,则在Double 退出时原始值将保持不变。 |
调用需要指针接收器的方法,但值未存储在变量中:Number(4).Double() |
在调用需要指针接收器的方法时,Go 可以自动将值转换为接收器的指针,如果它存储在变量中。如果没有,您将会收到一个错误。 |
将Display 的接收器参数更改为非指针类型:func (n ~~*~~Number) Display() { fmt.Println(~~*~~n) } |
在做出这个更改后,代码实际上仍然会工作,但它打破了惯例!对于类型的方法,接收器参数可以全部是指针,或者全部是值,但最好避免混合两者。 |
使用方法将升和毫升转换为加仑
当我们在我们定义的体积测量类型中添加了Milliliters
类型时,我们发现无法为Liters
和Milliliters
都创建ToGallons
函数。为了解决这个问题,我们不得不创建具有较长名称的函数:
func LitersToGallons(l Liters) Gallons {
return Gallons(l * 0.264)
}
func MillilitersToGallons(m Milliliters) Gallons {
return Gallons(m * 0.000264)
}
但与函数不同的是,方法名称不必唯一,只要它们定义在不同的类型上。
让我们尝试在Liters
类型上实现一个ToGallons
方法。代码几乎与LitersToGallons
函数相同,但我们将Liters
值作为接收器参数而不是普通参数。然后我们将对Milliliters
类型执行相同操作,将MillilitersToGallons
函数转换为ToGallons
方法。
注意我们并未对接收器参数使用指针类型。我们没有修改接收器,并且这些值不会占用太多内存,因此参数接收副本是可以的。
在我们的main
函数中,我们创建一个Liters
值,然后调用其ToGallons
方法。因为接收器的类型是Liters
,所以调用Liters
类型的ToGallons
方法。同样地,在Milliliters
值上调用ToGallons
会导致调用Milliliters
类型的ToGallons
方法。
将加仑转换为升和毫升使用方法
当将GallonsToLiters
和GallonsToMilliliters
函数转换为方法时,过程类似。我们只是将Gallons
参数移到每个接收器参数中。
您的 Go 工具箱
这就是对第九章的全部内容!您已经向您的工具箱中添加了方法定义。
池子难题解答
第十章:保持低调:封装和嵌入
错误是难免的。 有时,您的程序将从用户输入、正在读取的文件或其他地方接收到无效数据。在本章中,您将学习封装:一种保护结构类型字段免受无效数据影响的方法。这样,您就知道您的字段数据是安全的!
我们还将向您展示如何在您的结构类型中嵌入其他类型。如果您的结构类型需要另一类型上已存在的方法,您无需复制和粘贴方法代码。您可以将其他类型嵌入到您的结构类型中,然后像在自己的类型上定义一样使用嵌入类型的方法!
创建一个Date
结构类型
一个名为 Remind Me 的本地初创公司正在开发一个日历应用,帮助用户记住生日、周年纪念日等。
年、月和日听起来都需要被组合在一起;这些值单独来看可能不是很有用。结构类型可能对于在一个单一的捆绑中保持这些单独的值是有用的。
正如我们所见,定义的类型可以使用任何其他类型作为它们的基础类型,包括结构体。事实上,结构体类型作为我们在第八章中对定义类型的介绍。
让我们创建一个Date
结构类型来保存我们的年、月和日值。我们将在结构中添加Year
、Month
和Day
字段,每个字段的类型都是int
。在我们的main
函数中,我们将使用结构字面量快速测试新类型,使用Println
暂时打印Date
。
如果我们运行完成的程序,我们将看到我们的Date
结构的Year
、Month
和Day
字段。看起来一切正常!
人们将Date
结构字段设置为无效值!
啊,我们可以看到可能发生的情况。只有大于等于1
的年份是有效的,但我们没有任何东西阻止用户意外将Year
字段设置为0
或-999
。只有从1
到12
的月份号码是有效的,但没有任何东西阻止用户将Month
字段设置为0
或13
。Day
字段只有从1
到31
的数字是有效的,但用户可以输入像-2
或50
这样的天数。
我们需要一种方法来确保用户数据在接受之前是有效的。在计算机科学中,这称为数据验证。我们需要测试Year
是否被设置为大于等于1
的值,Month
是否在1
到12
之间,以及Day
是否在1
到31
之间。
注意
(是的,有些月份少于 31 天,但为了保持我们的代码示例长度合理,我们将仅检查它是否介于 1 和 31 之间。)
设置方法
结构体类型只是另一种定义的类型,这意味着你可以像任何其他类型一样在其上定义方法。我们应该能够在Date
类型上创建SetYear
、SetMonth
和SetDay
方法,它们接受一个值,检查其有效性,如果有效则设置相应的结构字段。
这种方法通常称为设置方法。按照惯例,Go 的设置方法通常以Set*X*
的形式命名,其中*X*
是你要设置的内容。
设置方法是用于设置定义类型底层值中字段或其他值的方法。
这是我们第一次尝试的SetYear
方法。接收器参数是你调用方法的Date
结构体。SetYear
作为参数接受你想要设置的年份,并在接收器Date
结构体上设置Year
字段。当前它不验证值的有效性,但稍后我们会添加验证。
在我们的main
方法中,我们创建一个Date
并调用其SetYear
方法。然后我们打印结构体的Year
字段。
尽管我们运行程序,但会发现它并没有完全正确地工作。即使我们创建了一个Date
并用新值调用了SetYear
,Year
字段仍然设置为其零值!
设置方法需要指针接收器。
记得我们之前展示过的Number
类型上的Double
方法吗?最初,我们使用普通值接收器类型Number
编写它。但我们了解到,像任何其他参数一样,接收器参数接收到原始值的副本。Double
方法在更新副本时会丢失。
我们需要更新Double
方法,使其接受指针接收器类型*Number
。当我们在指针上更新值时,在Double
退出后,更改仍然被保留。
对于SetYear
也是如此。Date
接收器得到原始结构的副本。当SetYear
退出时,对副本字段的任何更新都将丢失!
我们可以通过更新为指针接收器(d *Date)
来修复SetYear
。这是唯一必要的更改。我们不必更新SetYear
方法块,因为d.Year
会自动为我们获取指针的值(就像我们输入(*d).Year
一样)。在main
中调用date.SetYear
时也无需更改,因为在传递给方法时,Date
值会自动转换为*Date
。
现在SetYear
采用指针接收器,如果我们重新运行代码,我们将看到Year
字段已经更新。
添加其余的设置方法。
现在应该很容易按照相同的模式定义Date
类型上的SetMonth
和SetDay
方法。我们只需要确保在方法定义中使用指针接收器。当我们调用每个方法时,Go 将接收器转换为指针,并在更新其字段时将指针转换回结构值。
在main
函数中,我们可以创建一个Date
结构值;通过我们的新方法设置其Year
、Month
和Day
字段;然后打印整个结构以查看结果。
现在我们为Date
类型的每个字段都有了设置方法。但即使使用这些方法,用户仍然可能意外地将字段设置为无效值。我们接下来将看看如何防止这种情况。
向设置方法添加验证
向我们的设置方法添加验证将需要一些工作,但我们在第三章中学到了一切我们需要做的事情。
在每个设置方法中,我们将测试值是否在有效范围内。如果无效,我们将返回一个error
值。如果有效,则将Date
结构字段设置为正常状态,并为错误值返回nil
。
让我们首先在SetYear
方法中添加验证。我们添加一个声明,该方法将返回一个error
类型的值。在方法块的开头,我们测试调用者提供的year
参数是否为小于1
的任何数字。如果是,则返回一个带有消息"invalid year"
的error
。如果不是,则设置结构的Year
字段并返回nil
,表示没有错误。
在main
函数中,我们调用SetYear
并将其返回值存储在名为err
的变量中。如果err
不为nil
,这意味着分配的值无效,因此我们记录错误并退出。否则,我们继续打印Date
结构的Year
字段。
向SetYear
传递无效值会导致程序报告错误并退出。但如果我们传递一个有效值,程序将继续打印它。看起来我们的SetYear
方法正常工作!
SetMonth
和SetDay
方法中的验证代码将类似于SetYear
中的代码。
在SetMonth
中,我们测试提供的月份号码是否小于1
或大于12
,如果是则返回错误。否则,我们设置字段并返回nil
。
在SetDay
中,我们测试提供的日期是否小于1
或大于31
。无效值导致返回错误,但有效值导致字段被设置并返回nil
。
// Package, imports, type declaration omitted
func (d *Date) SetYear(year int) error {
if year < 1 {
return errors.New("invalid year")
}
d.Year = year
return nil
}
func (d *Date) SetMonth(month int) error {
if month < 1 || month > 12 {
return errors.New("invalid month")
}
d.Month = month
return nil
}
func (d *Date) SetDay(day int) error {
if day < 1 || day > 31 {
return errors.New("invalid day")
}
d.Day = day
return nil
}
func main() {
// Try the below code snippets here
}
您可以通过将下面的代码片段插入到main
块中来测试设置方法...
将14
传递给SetMonth
会导致错误:
但将5
传递给SetMonth
可以工作:
将50
传递给SetDay
会导致错误:
但将27
传递给SetDay
可以工作:
字段仍然可以设置为无效值!
的确,没有任何阻止任何人直接设置Date
结构字段的东西。如果他们这样做,它将绕过设置方法中的验证代码。他们可以设置任何他们想要的值!
date := Date{}
date.Year = 2019
date.Month = 14
date.Day = 50
fmt.Println(date)
我们需要一种方式来保护这些字段,以便我们的Date
类型的用户只能使用设置方法更新字段。
Go 提供了一种方法来解决这个问题:我们可以将 Date
类型移动到另一个包中,并将其日期字段设为未导出。
到目前为止,未导出的变量、函数等大多数时候都会阻碍我们。最近的例子是在第八章中,当我们发现尽管我们的 Subscriber
结构类型从 magazine
包中导出,但其字段是未导出的,因此在 magazine
包外部无法访问。
但在这种情况下,我们不希望字段可以访问。未导出的结构字段正是我们需要的!
让我们试着将 Date
类型移到另一个包中,并将其字段设为未导出,看看是否可以解决我们的问题。
将 Date
类型移动到另一个包中
在您的 Go 工作区内的 headfirstgo 目录中,创建一个新目录以容纳名为 calendar
的包。在 calendar 内部,创建一个名为 date.go 的文件。(记住,您可以将包目录中的文件命名为任何您喜欢的名称;它们将成为同一个包的一部分。)
在 date.go 中,添加一个 package calendar
声明,并导入 "errors"
包。(这是此文件中的代码将要使用的唯一包。)然后,将 Date
类型的所有旧代码复制并粘贴到这个文件中。
接下来,让我们创建一个程序来试试 calendar
包。由于这只是为了实验,我们将像在第八章中那样,在 Go 工作区之外保存一个文件,以避免与任何其他包发生干扰。(我们将只使用 go run
命令来运行它。)将文件命名为 main.go。
到目前为止,我们添加在 main.go 中的代码仍然可以创建一个无效的 Date
,无论是通过直接设置其字段还是使用结构字面量。
如果我们从终端运行 main.go,我们会看到设置字段的两种方式都起作用,并打印出两个无效的日期。
使 Date
字段变为未导出
现在让我们尝试更新 Date
结构,使其字段变为未导出。只需在类型定义中将字段名更改为小写字母开头,并在其他任何地方也都如此。
Date
类型本身需要保持导出,以及所有的设置方法,因为我们确实需要从 calendar
包外部访问这些方法。
为了测试我们的更改,在 main.go 中更新字段名称,以匹配 date.go 中的字段名称。
通过导出的方法访问未导出的字段
正如你所料,现在我们已经将 Date
的字段转换为未导出后,试图从 main
包中访问它们会导致编译错误。这在直接设置字段值时以及在结构字面量中使用它们时都是如此。
但是我们仍然可以间接访问这些字段。未导出的变量、结构字段、函数、方法等仍然可以被同一包内导出的函数和方法访问。因此,当 main
包中的代码调用 Date
值上的导出 SetYear
方法时,即使 year
字段是未导出的,SetYear
也可以更新 Date
的 year
结构字段。导出的 SetMonth
方法可以更新未导出的 month
字段,依此类推。
如果我们修改 main.go 以使用设置器方法,我们将能够更新 Date
值的字段:
未导出的变量、结构字段、函数和方法仍然可以被同一包内导出的函数和方法访问。
如果我们更新 main.go 以使用无效值调用 SetYear
,则在运行时会出现错误:
现在,Date
值的字段只能通过其设置器方法更新,程序可以避免意外输入无效数据。
噢,没错。我们提供了设置器方法,允许我们设置 Date
字段,尽管这些字段在 calendar
包中是未导出的。但是,我们并未提供任何获取字段值的方法。
我们可以打印整个 Date
结构。但是,如果尝试更新 main.go 以打印单个 Date
字段,则无法访问它!
获取器方法
正如我们所见,其主要目的是设置结构字段或变量值的方法称为设置器方法。而其主要目的是获取结构字段或变量值的方法称为获取器方法。
与设置器方法相比,为 Date
类型添加获取器方法将很容易。它们在被调用时不需要执行任何操作,只需返回字段值即可。
按照惯例,获取器方法的名称应与其访问的字段或变量名称相同。(当然,如果要导出该方法,其名称需要以大写字母开头。)因此,Date
将需要一个 Year
方法来访问 year
字段,一个 Month
方法来访问 month
字段,以及一个 Day
方法来访问 day
字段。
获取器方法根本不需要修改接收器,因此我们可以使用直接的 Date
值作为接收器。但是,如果某个类型的任何方法都使用指针接收器,按照惯例,为了保持一致性,它们全部都应该使用指针接收器。因此,由于我们必须为我们的设置器方法使用指针接收器,我们也为获取器方法使用指针。
完成 date.go 的更改后,我们可以更新 main.go 以设置所有 Date
字段,然后使用获取器方法打印它们。
封装
将程序的一部分中的数据隐藏在另一部分代码中被称为封装,这不仅限于 Go。封装是有价值的,因为它可以用来保护免受无效数据的影响(正如我们所见)。此外,您可以更改程序的封装部分而不必担心破坏访问它的其他代码,因为不允许直接访问。
许多其他编程语言将数据封装在类中。(类是与 Go 类型类似但不完全相同的概念。)在 Go 中,数据通过未导出的变量、结构字段、函数或方法封装在包内。
封装在其他语言中比在 Go 中更频繁地使用。在某些语言中,通常为每个字段定义 getter 和 setter,即使直接访问这些字段也可以很好地工作。在 Go 中,开发者通常只在必要时依赖封装,例如当字段数据需要通过 setter 方法进行验证时。在 Go 中,如果没有看到需要封装字段的必要性,通常可以直接导出它并允许直接访问。
没有愚蠢的问题
问:许多其他语言不允许在类外部访问封装值。在 Go 中,允许同一包中的其他代码访问未导出字段安全吗?
答: 通常,一个包中的所有代码都是单个开发者(或一组开发者)的工作。一个包中的所有代码通常都有相似的目的。同一个包中的代码作者很可能需要访问未公开的数据,并且他们也很可能只在有效的方式下使用这些数据。因此,是的,与包中的其他代码共享未公开数据通常是安全的。
代码在包之外很可能是由其他开发者编写的,但这没关系,因为未导出字段对他们是隐藏的,所以他们无法意外地将其值更改为无效值。
问:我看过其他语言中每个获取器方法的名称都以“Get
”开头,比如GetName
、GetCity
等等。在 Go 语言中我可以这样做吗?
答: Go 语言允许你这样做,但你不应该这样做。Go 社区已经决定不在 getter 方法名称中包含Get
前缀的约定。包含此前缀只会导致其他开发者感到困惑!
就像许多其他语言一样,Go 仍然为 setter 方法使用Set
前缀,因为这是为了区分相同字段的 getter 方法名和 setter 方法名所必需的。
嵌入Event
类型中的Date
类型
那不应该花费太多工作。还记得我们在第八章中是如何将Address
结构类型嵌入另外两个结构类型中的吗?
Address
类型被视为“嵌入”,因为我们在外部结构体中使用了一个匿名字段(只有类型没有名称),这导致Address
的字段被提升到外部结构体,从而允许我们像访问外部结构体字段一样访问内部结构体的字段。
既然这种策略之前如此成功,让我们定义一个Event
类型,其中包含一个匿名字段Date
。
在calendar
包文件夹中创建另一个文件,命名为event.go。(我们可以将其放在现有的date.go文件中,但这样可以更加整洁地组织。)在该文件中,定义一个Event
类型,它有两个字段:一个类型为string
的Title
字段,和一个匿名的Date
字段。
未导出的字段不会被提升
尽管在Event
类型中嵌入Date
不会导致将Date
字段提升到Event
,Date
字段是未导出的,Go 语言不会将未导出字段提升到封闭类型。这是有道理的;我们确保字段被封装,因此只能通过设置器和获取器方法访问它们,并且不希望通过字段提升来规避封装。
在我们的main
包中,如果我们尝试通过其封闭的Event
设置Date
的month
字段,我们会遇到错误:
当然,使用点操作符链式检索Date
字段然后直接访问它的字段也不起作用。当Date
独立存在时,无法访问其未导出字段;当其作为Event
的一部分存在时也不行。
那么这是否意味着我们无法访问Event
类型中嵌入的Date
类型的字段?别担心;还有另一种方法!
导出的方法会像字段一样被提升
如果在结构类型中嵌入具有导出方法的类型,其方法将被提升到外部类型,这意味着您可以像在外部类型上定义的方法一样调用这些方法。(还记得如何在一个结构体类型中嵌入另一个导致内部结构体的字段被提升到外部结构体吗?这与字段相同,但是用方法替代了。)
这是一个定义了两种类型的包。MyType
是一个结构类型,它作为匿名字段嵌入了第二个类型EmbeddedType
。
因为EmbeddedType
定义了一个导出方法(名为ExportedMethod
),该方法会被提升到MyType
,并且可以在MyType
值上调用。
与未导出字段一样,未导出方法也不会被提升。如果尝试调用未导出方法,则会出现错误。
我们的Date
字段没有被提升到Event
类型,因为它们是未导出的。但是Date
上的获取器和设置器方法是导出的,并且会被提升到Event
类型!
这意味着我们可以创建一个Event
值,然后直接在Event
上调用Date
的 getter 和 setter 方法。这正是我们在下面更新的main.go代码中所做的。如常,公开的方法可以为我们访问未导出的Date
字段。
如果您更喜欢在Date
值上直接使用点操作符链式调用方法,您也可以这样做:
封装事件标题字段
因为Event
结构体的Title
字段是公开的,我们仍然可以直接访问它:
这让我们暴露于与Date
字段相同的问题,比如,Title
字符串没有长度限制:
看起来将标题字段封装起来也是个好主意,这样我们就可以验证新的值。下面是更新后的Event
类型,做了这样的操作。我们将字段的名称更改为title
,使其未导出,然后添加了 getter 和 setter 方法。使用unicode/utf8
包中的RuneCountInString
函数来确保字符串中没有太多的符文(字符)。
提升的方法与外部类型的方法并存
现在,我们为title
字段添加了 setter 和 getter 方法,如果使用超过 30 个字符的标题,我们的程序可以报错。尝试设置一个 39 个字符的标题将导致错误返回:
Event
类型的Title
和SetTitle
方法与从嵌入的Date
类型提升的方法并存。导入calendar
包的用户可以将所有方法视为属于Event
类型,而不用担心它们实际上是在哪种类型上定义的。
我们的日历包已经完善了!
方法提升允许您轻松地将一种类型的方法用作另一种类型的方法。您可以使用这个功能来组合多种其他类型的方法。这有助于保持代码的清晰性,而不会牺牲便利性!
您的 Go 工具箱
到此为止,第十章就结束了!您已经将封装和嵌入添加到了您的工具箱中。
注意
嵌入
被存储在结构体类型内部的类型,使用匿名字段被称为被嵌入在结构体中。
嵌入类型的方法被提升到外部类型。它们可以被调用,就好像它们是在外部类型上定义的一样。
第十一章:你能做什么?:接口
有时您不关心值的具体类型。 您不关心它是什么。您只需知道它能做某些事情。您可以在其上调用某些方法。您不在乎是否有一个Pen
还是一个Pencil
,您只需要一个具有Draw
方法的东西。您不在乎是否有一辆Car
或一艘Boat
,您只需要一个具有Steer
方法的东西。
这就是 Go 语言接口的作用。它们允许您定义变量和函数参数,这些变量和函数参数可以持有任何类型,只要该类型定义了某些方法。
拥有相同方法的两种不同类型
还记得磁带录音机吗?(我们假设你们中有些人可能太年轻了。)它们非常棒。你可以轻松地在一盘磁带上录制所有你喜欢的歌曲,即使它们是由不同的艺术家演唱的。当然,这些录音机通常太笨重,无法随身携带。如果你想随身携带磁带,你需要一个单独的电池供电的磁带播放机。通常这种播放机没有录音功能。但是,制作自定义混音磁带并与朋友分享的感觉真是太棒了!
我们对怀旧情怀如此之深,以至于我们创建了一个gadget
包来帮助我们怀旧。它包括一个模拟磁带录音机的类型,以及另一个模拟磁带播放机的类型。
TapePlayer
类型具有Play
方法来模拟播放歌曲,以及一个Stop
方法来停止虚拟播放。
TapeRecorder
类型还有Play
和Stop
方法,以及一个Record
方法。
只能接受一种类型的方法参数
下面是一个使用gadget
包的示例程序。我们定义了一个playList
函数,它接受一个TapePlayer
值和一个要播放的歌曲标题切片。该函数循环遍历切片中的每个标题,并将其传递给TapePlayer
的Play
方法。在播放完列表后,它调用TapePlayer
的Stop
方法。
然后,在main
方法中,我们只需创建TapePlayer
和歌曲标题的切片,然后将它们传递给playList
。
playList
函数与TapePlayer
值配合使用效果很好。您可能希望它也能与TapeRecorder
一起使用。(毕竟,磁带录音机基本上就是带有额外录音功能的磁带播放机。)但是playList
的第一个参数类型是TapePlayer
。尝试传递任何其他类型的参数,您将收到编译错误:
在这种情况下,似乎是 Go 语言的类型安全性在阻碍我们,而不是帮助我们。playList
函数需要的所有方法都由TapeRecorder
类型定义,但我们无法使用它,因为playList
只接受TapePlayer
值。
那我们能做什么?写一个第二个几乎相同的 playListWithRecorder
函数,它接受一个 TapeRecorder
吗?
实际上,Go 还提供了另一种方式...
接口
当你在计算机上安装一个程序时,通常期望该程序提供一种与之交互的方式。你期望一个文字处理器提供一个地方来输入文本。你期望一个备份程序提供一种选择要保存的文件的方式。你期望一个电子表格程序提供一种插入数据列和行的方式。程序提供的用于与之交互的控件集合通常称为其接口。
接口是一组期望某些值具备的方法。
无论你是否真正思考过,你可能期待 Go 的值能够提供一种与它们交互的方式。什么是与 Go 值交互的最常见方式?通过它们的方法。
在 Go 中,接口被定义为一组期望某些值具备的方法。你可以将接口视为你需要某种类型能够执行的一组操作。
你可以使用 interface
关键字和花括号中包含方法名的方式定义接口类型,以及方法期望具备的任何参数或返回值类型。
任何具有接口定义中列出的所有方法的类型都被称为满足该接口。满足接口的类型可以在需要该接口的任何地方使用。
方法名、参数类型(或其缺失)、返回值类型(或其缺失)都需要与接口定义中的匹配。类型可以有接口中未列出的方法,但不能缺少任何方法,否则就不能满足该接口。
一个类型可以满足多个接口,一个接口可以(通常应该)有多个满足它的类型。
定义满足接口的类型
下面的代码设置了一个名为 mypkg
的快速实验性包。它定义了一个名为 MyInterface
的接口类型,具有三个方法。然后它定义了一个类型 MyType
,该类型满足 MyInterface
。
满足 MyInterface
需要三个方法:一个 MethodWithoutParameters
方法,一个接受 float64
参数的 MethodWithParameter
方法,以及一个返回 string
的 MethodWithReturnValue
方法。
然后我们声明另一个类型 MyType
。在这个例子中,MyType
的底层类型并不重要;我们只是用了 int
。我们定义了 MyType
上所有需要满足 MyInterface
的方法,以及一个不属于接口的额外方法。
在许多其他语言中,我们需要明确声明 MyType
满足 MyInterface
。但在 Go 中,这种情况是自动发生的。如果一个类型拥有接口中声明的所有方法,那么它可以在需要该接口的任何地方使用,无需进一步声明。
这里是一个快速的程序,可以让我们尝试一下mypkg
。
声明一个带有接口类型的变量可以保存满足该接口的任何类型的值。这段代码声明了一个value
变量,其类型为MyInterface
,然后创建了一个MyType
值并将其分配给value
。(这是允许的,因为MyType
满足MyInterface
。)然后我们调用该值上的所有属于接口的方法。
具体类型,接口类型
在前几章中,我们定义的所有类型都是具体类型。一个具体类型不仅指定了其值可以做什么(可以在其上调用哪些方法),而且还指定了它们是什么:它们指定了持有该值数据的底层类型。
接口类型并不描述一个值是什么:它们不说它的底层类型是什么,或者它的数据是如何存储的。它们只描述一个值可以做什么:它有哪些方法。
假设您需要写下一个快速的备注。在您的桌子抽屉里,有几种具体类型的值:Pen
、Pencil
和Marker
。每种具体类型都定义了一个Write
方法,所以您不在乎抓取哪种类型。您只想要一个WritingInstrument
:一个由任何具有Write
方法的具体类型满足的接口类型。
分配任何满足接口的类型
当您有一个带有接口类型的变量时,它可以保存满足该接口的任何类型的值。
假设我们有Whistle
和Horn
类型,它们各自都有一个MakeSound
方法。我们可以创建一个NoiseMaker
接口,代表任何具有MakeSound
方法的类型。如果我们用NoiseMaker
类型声明一个toy
变量,我们可以将Whistle
或Horn
值分配给它。(或者任何我们稍后声明的其他具有MakeSound
方法的类型。)
然后,我们可以调用分配给toy
变量的任何值的MakeSound
方法。虽然我们不知道toy
中的具体类型是什么,但我们知道它能做什么:发出声音。如果它的类型没有MakeSound
方法,那么它就不会满足NoiseMaker
接口,我们就不能将其分配给这个变量。
你可以将函数参数声明为接口类型。(毕竟,函数参数本质上也只是变量。)例如,如果我们声明一个play
函数,它接受一个NoiseMaker
,那么我们可以将任何具有MakeSound
方法的类型的值传递给play
:
您只能调用作为接口的一部分定义的方法
一旦您用接口类型的变量(或方法参数)分配了一个值,您就只能调用接口上指定的方法。
假设我们创建了一个Robot
类型,除了一个MakeSound
方法外,还有一个Walk
方法。我们在play
函数中添加一个对Walk
的调用,并将一个新的Robot
值传递给play
。
但是代码无法编译,提示 NoiseMaker
类型没有 Walk
方法。
为什么会这样? Robot
值确实有一个 Walk
方法;定义就在那里!
但这并不是我们传递给 play
函数的 Robot
值;它是一个 NoiseMaker
。如果我们传递一个 Whistle
或 Horn
给 play
,那会怎样?这些没有 Walk
方法!
当我们有一个接口类型的变量时,我们唯一可以确定它具有的方法是接口中定义的方法。因此,这些是 Go 允许你调用的唯一方法。(确实有一种方法可以获取值的具体类型,这样你可以调用更专门化的方法。我们马上来看看。)
注意,将具有其他方法的类型赋给接口类型的变量是完全可以的。只要你不实际调用这些其他方法,一切都会正常运作。
破坏事物是教育性的!
这里有几个具体类型,Fan
和 CoffeePot
。我们还有一个 Appliance
接口,包含一个 TurnOn
方法。Fan
和 CoffeePot
都有 TurnOn
方法,因此它们都满足 Appliance
接口。
这就是为什么在 main
函数中,我们能够定义一个 Appliance
变量,并将 Fan
和 CoffeePot
变量都赋值给它。
做以下变更之一并尝试编译代码。然后撤销您的更改并尝试下一个。看看会发生什么!
type Appliance interface {
TurnOn()
}
type Fan string
func (f Fan) TurnOn() {
fmt.Println("Spinning")
}
type CoffeePot string
func (c CoffeePot) TurnOn() {
fmt.Println("Powering up")
}
func (c CoffeePot) Brew() {
fmt.Println("Heating Up")
}
func main() {
var device Appliance
device = Fan("Windco Breeze")
device.TurnOn()
device = CoffeePot("LuxBrew")
device.TurnOn()
}
如果你这样做... | ...代码会因为... |
---|---|
从接口中未定义的具体类型调用方法:device.Brew() |
当你有一个变量是接口类型时,你只能调用作为该接口一部分定义的方法,而不管具体类型有哪些方法。 |
从类型中移除满足接口的方法:~~func (c CoffeePot) TurnOn() {~~ ~~fmt.Println("Powering up")~~ ~~}~~ |
如果一个类型不满足一个接口,那么你不能将该类型的值赋给使用该接口作为类型的变量。 |
在满足接口的方法上添加新的返回值或参数:func (f Fan) TurnOn() error { fmt.Println("Spinning") return nil } |
如果具体类型的方法定义和接口中方法定义之间的参数和返回值的数量及类型不匹配,那么该具体类型就不满足接口。 |
使用接口修复我们的 playList
函数
我们来看看是否可以使用接口使我们的 playList
函数能够处理 TapePlayer
和 TapeRecorder
上的 Play
和 Stop
方法。
// TapePlayer type definition here
func (t TapePlayer) Play(song string) {
fmt.Println("Playing", song)
}
func (t TapePlayer) Stop() {
fmt.Println("Stopped!")
}
// TapeRecorder type definition here
func (t TapeRecorder) Play(song string) {
fmt.Println("Playing", song)
}
func (t TapeRecorder) Record() {
fmt.Println("Recording")
}
func (t TapeRecorder) Stop() {
fmt.Println("Stopped!")
}
在我们的main
包中,我们声明了一个Player
接口。(我们也可以在gadget
包中定义它,但是在与使用它的相同包中定义接口会给我们更大的灵活性。)我们指定该接口需要具有带有string
参数的Play
方法和没有参数的Stop
方法。这意味着TapePlayer
和TapeRecorder
类型都将满足Player
接口。
我们更新了playList
函数,使其接受任何满足Player
接口而不是特定于TapePlayer
的值。我们还将player
变量的类型从TapePlayer
更改为Player
。这允许我们将TapePlayer
或TapeRecorder
赋给player
。然后我们将这两种类型的值传递给playList
!
没有愚蠢的问题
Q: 接口类型名称应该以大写字母还是小写字母开头?
A: 接口类型名称的规则与任何其他类型的规则相同。如果名称以小写字母开头,则接口类型将是未导出的,在当前包之外将无法访问。有时您不需要从其他包使用您声明的接口,因此将其设置为未导出是可以接受的。但是,如果您确实希望在其他包中使用它,则需要以大写字母开头命名接口类型,以便导出。
类型断言
我们定义了一个新的TryOut
函数,用于测试我们的TapePlayer
和TapeRecorder
类型的各种方法。TryOut
有一个参数,其类型为Player
接口,这样我们可以传递TapePlayer
或TapeRecorder
。
在TryOut
中,我们调用Play
和Stop
方法,这两个方法都属于Player
接口。我们还调用Record
方法,该方法不属于Player
接口,但在TapeRecorder
类型上定义。目前我们只传递了TapeRecorder
值给TryOut
,所以应该没问题,对吗?
不幸的是,不行。我们之前看到,如果将具体类型的值分配给具有接口类型的变量(包括函数参数),则只能调用该接口的方法,而不管具体类型有什么其他方法。在TryOut
函数内部,我们没有TapeRecorder
值(具体类型),而是Player
值(接口类型)。而Player
接口没有Record
方法!
我们需要一种方法来获取具体类型的值(确实有Record
方法)回来。
你的第一直觉可能是尝试使用类型转换将Player
值转换为TapeRecorder
值。但是类型转换不适用于接口类型,因此会生成错误。错误消息建议尝试其他方法:
“类型断言”?那是什么?
当你有一个具体类型的值分配给一个带有接口类型的变量时,类型断言允许你获取具体类型。这有点像类型转换。其语法甚至看起来像是方法调用和类型转换的混合。在接口值之后,你键入一个点,然后跟着具体类型的括号。(或者,更确切地说,你 断言 这个值的具体类型是什么。)
用简单的语言来说,上面的类型断言大致是这样说的:“我知道这个变量使用接口类型NoiseMaker
,但我相当确定 这个 NoiseMaker
实际上是一个Robot
。”
一旦你使用类型断言获取了具体类型的值,你可以在其上调用该类型定义的但不是接口的方法。
这段代码将一个Robot
分配给一个NoiseMaker
接口值。我们能够在NoiseMaker
上调用MakeSound
,因为它是接口的一部分。但是要调用Walk
方法,我们需要使用类型断言来获取一个Robot
值。一旦我们有了Robot
(而不是NoiseMaker
),我们就可以在其上调用Walk
。
类型断言失败
以前,我们的TryOut
函数无法调用Player
值的Record
方法,因为它不是Player
接口的一部分。让我们看看是否可以使用类型断言使其工作。
就像以前一样,我们将TapeRecorder
传递给TryOut
,它被分配给一个使用Player
接口作为其类型的参数。我们能够在Player
值上调用Play
和Stop
方法,因为它们都是Player
接口的一部分。
然后,我们使用类型断言将Player
转换回TapeRecorder
。然后在TapeRecorder
值上调用Record
。
一切看起来都很顺利...使用TapeRecorder
。但是如果我们尝试将TapePlayer
传递给TryOut
会发生什么?考虑到我们有一个类型断言,它说TryOut
的参数实际上是TapeRecorder
,那么这会运行得有多好呢?
一切都成功编译,但当我们尝试运行时,出现了运行时恐慌!正如你所预料的那样,试图断言TapePlayer
实际上是TapeRecorder
并没有顺利进行。(毕竟,这根本不是事实。)
当类型断言失败时避免恐慌
如果在期望只有一个返回值的上下文中使用了类型断言,且原始类型与断言中的类型不匹配,则程序将在运行时(而不是编译时)引发恐慌:
在期望多个返回值的上下文中使用类型断言时,它们有一个第二个可选的返回值,指示断言是否成功。第二个值是一个bool
,如果值的原始类型是断言的类型,则为true
,否则为false
。您可以根据需要处理这个第二个返回值,但按照惯例,通常将其分配给名为ok
的变量。
注意
Go 语言在第七章中首次看到的“逗号 OK 惯用法”,在另一个地方也采用了这种方法。
这是对上述代码的更新,将类型断言的结果分配给具体类型值的变量,以及第二个ok
变量。它在一个if
语句中使用ok
值来确定是否可以安全调用具体值的Record
方法(因为Player
值的原始类型为TapeRecorder
),或者是否应该跳过此操作(因为Player
具有其他具体值)。
在这种情况下,具体类型是TapePlayer
,而不是TapeRecorder
,因此断言不成功,ok
为false
。if
语句的else
子句运行,打印Player was not a TapeRecorder
。运行时恐慌得到了避免。
在使用类型断言时,如果您不确定接口值背后的原始类型是哪个,则应使用可选的ok
值来处理预期以外的类型,并避免运行时恐慌。
使用类型断言测试 TapePlayer 和 TapeRecorder
现在我们看看能否利用所学知识来修复我们的TryOut
函数,适用于TapePlayer
和TapeRecorder
的值。我们不再忽略类型断言的第二个返回值,而是将其赋给一个ok
变量。如果类型断言成功(表明recorder
变量持有准备好我们调用Record
方法的TapeRecorder
值),则ok
变量将为true
,否则为false
(表明不安全调用Record
)。我们将调用Record
方法的语句包装在一个if
语句中,以确保仅在类型断言成功时调用。
与之前一样,在我们的main
函数中,我们首先使用TapeRecorder
值调用TryOut
。TryOut
获取其接收的Player
接口值,并对其调用Play
和Stop
方法。成功断言Player
值的具体类型为TapeRecorder
,并在结果TapeRecorder
值上调用Record
方法。
然后,我们再次使用 TapePlayer
调用 TryOut
。(这是之前导致程序中断的调用,因为类型断言引发了恐慌。)Play
和 Stop
被调用,就像以前一样。类型断言失败,因为 Player
值持有 TapePlayer
而不是 TapeRecorder
。但因为我们在 ok
值中捕获了第二个返回值,类型断言这次不会引发恐慌。它只是将 ok
设置为 false
,这导致我们 if
语句中的代码不运行,因此 Record
不会被调用。(这很好,因为 TapePlayer
值没有 Record
方法。)
多亏了类型断言,我们的 TryOut
函数可以与 TapeRecorder
和 TapePlayer
值一起工作!
池谜题
我们从上一个练习中更新了代码,右侧是它。我们正在创建一个 TryVehicle
方法,该方法调用 Vehicle
接口的所有方法。然后,它应该尝试类型断言以获取具体的 Truck
值。如果成功,应该在 Truck
值上调用 LoadCargo
。
你的 任务 是从池中选取代码片段并将它们放入此代码的空白行中。不要 使用相同的片段超过一次,你不需要使用所有的片段。你的 目标 是创建一个可以运行并生成所示输出的程序。
注意:每个池中的片段只能使用一次!
答案在 “池谜题解答” 中。
“error” 接口
我们希望通过查看一些内置于 Go 中的接口来结束本章。我们尚未明确涵盖这些接口,但你实际上一直在使用它们。
在 第三章 中,我们学习了如何创建我们自己的 error
值。我们说过,“一个 error
值是任何具有名为 Error
的方法且返回字符串的值。”
是的。 error
类型只是一个接口!它看起来像这样:
type error interface {
Error() string
}
将 error
类型声明为接口意味着,如果它有一个返回 string
的 Error
方法,那么它满足 error
接口,并且它是一个 error
值。这意味着你可以定义自己的类型,并在需要 error
值的任何地方使用它!
例如,这里有一个简单定义的类型 ComedyError
。因为它有一个返回 string
的 Error
方法,它满足 error
接口,我们可以将它分配给具有 error
类型的变量。
如果你需要一个 error
值,但也需要跟踪比仅仅错误消息字符串更多的信息,你可以创建一个满足 error
接口且存储你想要的信息的自定义类型。
假设你正在编写一个程序来监控某些设备,以确保它们不会过热。这里有一个可能有用的 OverheatError
类型。它有一个 Error
方法,因此它满足 error
接口。但更有趣的是,它将 float64
作为其底层类型,允许我们跟踪超出容量的度数。
这里有一个 checkTemperature
函数,它使用 OverheatError
。它以系统实际温度和被认为安全的温度作为参数。它指定返回一个 error
类型的值,而不是特定的 OverheatError
,但这没问题,因为 OverheatError
满足 error
接口。如果实际温度超过安全温度,checkTemperature
返回一个记录超出量的新 OverheatError
。
没有蠢问题
Q: 我们如何在所有这些不同的包中使用 error
接口类型,而无需导入它?它的名称以小写字母开头。这不是表示它是未导出的吗,无论它声明在哪个包中?error
到底是在哪个包中声明的?
A: error
类型是一个“预声明的标识符”,就像 int
或 string
一样。因此,像其他预声明的标识符一样,它不属于任何包。它属于“宇宙块”,这意味着它在任何地方都可用,无论你在哪个包里。
还记得有if
和for
块,它们被函数块包围,函数块又被包块包围吗?宇宙块包含所有的包块。这意味着你可以在任何包中使用在宇宙块中定义的任何内容,而无需导入它们。这包括 error
和所有其他预声明的标识符。
Stringer 接口
记得我们在第九章中创建的Gallons
、Liters
和Milliliters
类型吗?它们用于区分不同的体积单位。但我们发现,实际区分它们并不那么容易。12 加仑和 12 升或 12 毫升完全是不同的量,但它们在打印时看起来都一样。如果数值有太多小数位,打印出来也显得很尴尬。
你可以使用 Printf
来四舍五入数字并添加一个表示单位的缩写,但在每个需要使用这些类型的地方这样做会很快变得乏味。
这就是为什么 fmt
包定义了 fmt.Stringer
接口的原因:允许任何类型决定在打印时如何显示。很容易设置任何类型以满足 Stringer
接口;只需定义一个返回 string
的 String()
方法即可。接口定义如下:
例如,在这里我们设置了这个 CoffeePot
类型以满足 Stringer
:
fmt
包中的许多函数检查传递给它们的值是否满足 Stringer
接口,并在是这样的情况下调用它们的 String
方法。这包括 Print
、Println
和 Printf
函数等等。现在 CoffeePot
满足 Stringer
,我们可以直接将 CoffeePot
值传递给这些函数,并且 CoffeePot
的 String
方法的返回值将用于输出中:
现在,让我们来更加严肃地使用这个接口类型。让我们让我们的 Gallons
、Liters
和 Milliliters
类型满足 Stringer
接口。我们将我们的代码移到与每种类型关联的 String
方法中格式化它们的值。我们将调用 Sprintf
函数而不是 Printf
,并返回结果值。
现在,每当我们将 Gallons
、Liters
和 Milliliters
值传递给 Println
(或大多数其他 fmt
函数),它们的 String
方法将被调用,并且返回的值将用于输出。我们为每种类型设置了一个有用的默认格式打印!
空接口
好问题!让我们运行 **go doc**
来查看 fmt.Println
的文档,并看看它的参数声明为什么类型...
正如我们在第六章中看到的那样,...
表示它是一个可变参数函数,这意味着它可以接受任意数量的参数。但是 interface{}
类型又是什么?
记住,接口声明指定了类型必须具有的方法,以满足该接口。例如,我们的 NoiseMaker
接口可以由任何具有 MakeSound
方法的类型满足。
type NoiseMaker interface {
MakeSound()
}
但是如果我们声明一个根本不需要任何方法的接口类型会发生什么?它将被任何类型满足!它将被所有类型满足!
type Anything interface {
}
interface{}
类型被称为空接口,它用于接受任何类型的值。空接口没有任何要求满足它的方法,因此每种类型都满足它。
如果你声明一个接受空接口类型作为其参数类型的函数,那么你可以将任何类型的值作为参数传递给它:
空接口不需要任何方法来满足它,因此所有类型都满足它。
但是不要匆忙开始将空接口用于所有的函数参数!如果你有一个空接口类型的值,你对它的操作就不多了。
fmt
中的大多数函数接受空接口类型的值,所以你可以将它传递给这些函数:
但是不要试图对空接口值调用任何方法!记住,如果你有一个接口类型的值,你只能调用属于该接口的方法。而空接口没有任何方法。这意味着你不能调用空接口类型值上的任何方法!
要在具有空接口类型的值上调用方法,您需要使用类型断言来获取具体类型的值。
而到了那时,您可能最好编写一个仅接受特定具体类型的函数。
因此,在定义自己的函数时,空接口的有用性有所限制。但是,在fmt
包中的函数和其他地方,您将一直使用空接口。下次您在函数文档中看到一个interface{}
参数时,您就会明白它的含义!
当您定义变量或函数参数时,通常您会确切地知道您将要处理的值是什么。您可以使用像Pen
、Car
或Whistle
这样的具体类型。但有时,您只关心值能做什么。在这种情况下,您会想要定义一个接口类型,比如WritingInstrument
、Vehicle
或NoiseMaker
。
您将定义需要调用的方法作为接口类型的一部分。而且,您可以赋值给您的变量或调用您的函数,而不必担心值的具体类型。如果它具有正确的方法,您就可以使用它!
您的 Go 工具箱
这就是 第十一章 的全部内容!您已将接口添加到您的工具箱中。
注意
接口
接口是一组方法的集合,期望具有某些特定值。
拥有接口定义中列出的所有方法的任何类型被认为满足该接口。
满足接口的类型可以赋值给任何使用该接口作为其类型的变量或函数参数。
池难题解决方案
第十二章:重新振作:从失败中恢复
每个程序都会遇到错误。你应该为它们做好准备。
有时处理错误可能就是简单地报告它并退出程序。但其他错误可能需要额外的操作。你可能需要关闭已打开的文件或网络连接,或者以其他方式清理,以防止程序留下混乱。在本章中,我们将向您展示如何推迟清理操作,以便即使出现错误,它们仍然会发生。我们还将向您展示在那些(罕见的)适当情况下如何让程序panic,以及如何recover。
重新审视从文件中读取数字
我们已经讨论了在 Go 语言中处理错误的许多技巧。但到目前为止我们展示的技术并不适用于所有情况。让我们看看一个这样的场景。
我们希望创建一个名为sum.go的程序,从文本文件中读取float64
值,将它们全部加在一起,并打印它们的总和。
在第六章中,我们创建了一个GetFloats
函数,它打开一个文本文件,将文件的每一行转换为float64
值,并将这些值作为一个切片返回。
在这里,我们将GetFloats
移到了main
包中,并更新它以依赖两个新函数,OpenFile
和CloseFile
,来打开和关闭文本文件。
我们想要将要读取的文件名作为命令行参数指定。你可能还记得在第六章中使用过os.Args
切片 —— 它是一个包含程序运行时所有参数的string
值切片。
因此在我们的main
函数中,通过访问os.Args[1]
来获取要打开的文件名作为第一个命令行参数。(请记住,os.Args[0]
元素是正在运行的程序的名称;实际的程序参数出现在os.Args[1]
及以后的元素中。)
然后我们将该文件名传递给GetFloats
以读取文件,并获得一个float64
值切片。
如果在执行过程中遇到任何错误,它们将从GetFloats
函数中返回,并存储在err
变量中。如果err
不为nil
,这意味着发生了错误,我们只需记录它并退出。
否则,这意味着文件已成功读取,因此我们使用for
循环将切片中的每个值相加,并最后打印总和。
让我们把所有这些代码保存在一个名为sum.go的文件中。然后,让我们创建一个填满数字的纯文本文件,每行一个数字。我们将其命名为data.txt,并保存在与sum.go相同的目录中。
我们可以通过go run sum.go data.txt
来运行程序。字符串"data.txt"
将作为sum.go程序的第一个参数,因此它将作为文件名传递给GetFloats
。
我们可以看到 OpenFile
和 CloseFile
函数何时被调用,因为它们都包含对 fmt.Println
的调用。在输出末尾,我们可以看到 data.txt 中所有数字的总和。看起来一切正常!
任何错误都会阻止文件关闭!
但是,如果我们给 sum.go 程序一个格式错误的文件,我们就会遇到问题。例如,文件中有一行无法解析为 float64
值,就会出现错误。
现在,这本身没什么问题;每个程序偶尔都会收到无效数据。但是 GetFloats
函数应该在完成时调用 CloseFile
函数。我们在程序输出中看不到 “Closing file
”,这表明 CloseFile
没有被调用!
问题是当我们用无法转换为 float64
的字符串调用 strconv.ParseFloat
时,它会返回一个错误。我们的代码设置在那一点从 GetFloats
函数返回。
但是那个返回发生在调用 CloseFile
之前,这意味着文件永远不会被关闭!
延迟函数调用
现在,没有关闭文件可能看起来不是什么大问题。对于只打开单个文件的简单程序,这可能没什么问题。但是每个未关闭的文件都会继续消耗操作系统资源。随着时间的推移,多个未关闭的文件会累积,导致程序失败,甚至影响整个系统的性能。养成确保程序完成后关闭文件的习惯非常重要。
但是我们如何实现这一点呢?GetFloats
函数设置为如果在读取文件时遇到错误立即退出,即使还没有调用 CloseFile
!
如果有一个函数调用你希望确保运行,无论如何,你可以使用 defer
语句。你可以在任何普通函数或方法调用之前放置 defer
关键字,Go 将延迟执行该函数调用,直到当前函数退出。
通常,函数调用会在遇到它们时立即执行。在这段代码中,fmt.Println("Goodbye!")
调用在另外两个 fmt.Println
调用之前执行。
但是如果我们在 fmt.Println("Goodbye!")
调用之前添加 defer
关键字,那么该调用将等到 Socialize
函数的所有剩余代码运行完毕并退出后才会执行。
使用延迟函数调用来从错误中恢复
defer
关键字确保即使调用函数提前退出(例如通过使用 return
关键字),也会执行函数调用。
“defer” 关键字确保即使调用函数提前退出,也会执行。
下面,我们更新了Socialize
函数以返回一个error
,因为我们不想说话。Socialize
将在调用fmt.Println("Nice weather, eh?")
之前退出。但因为我们在fmt.Println("Goodbye!")
调用之前包含了defer
关键字,Socialize
总是会礼貌地在结束对话之前打印“Goodbye!
”。
使用延迟函数调用确保文件被关闭
因为defer
关键字可以确保在“任何情况下”都执行函数调用,通常用于需要在发生错误时仍需运行的代码。一个常见的例子是在文件打开后关闭文件。
这正是我们sum.go程序中GetFloats
函数所需要的。在调用OpenFile
函数后,我们需要调用CloseFile
,即使在解析文件内容时出现错误。
通过将CloseFile
的调用移到OpenFile
后面(及其相应的错误处理代码),并在其前面加上defer
关键字,我们可以实现这一点。
使用defer
确保GetFloats
退出时将调用CloseFile
,无论是正常完成还是在解析文件时出错。
现在,即使sum.go收到了错误数据的文件,它仍会在退出前关闭文件!
代码磁铁
该代码设置了一个Refrigerator
类型,模拟冰箱的功能。Refrigerator
使用字符串切片作为其底层类型;这些字符串表示冰箱中包含的食物名称。该类型有一个Open
方法模拟打开冰箱门,以及一个对应的Close
方法来关闭它(毕竟我们不想浪费能量)。FindFood
方法调用Open
打开冰箱门,调用我们编写的find
函数在底层切片中搜索特定食物,然后调用Close
来再次关闭门。
但FindFood
存在问题。如果我们搜索的食物找不到,它设置为返回错误值。但当这种情况发生时,在调用Close
之前它已经返回,导致虚拟冰箱门敞开!
答案在“代码磁铁解决方案”中。
使用下面的磁铁创建FindFood
方法的更新版本。它应该延迟对Close
方法的调用,以便在FindFood
退出时运行(无论是否成功找到食物)。
没有愚蠢的问题
Q: 所以我可以延迟函数和方法调用… 我可以延迟其他语句吗,比如for
循环或变量赋值?
A: 不,只能用于函数和方法调用。您可以编写一个函数或方法来执行您想要的任何操作,然后延迟调用该函数或方法,但defer
关键字本身只能用于函数或方法调用。
列出目录中的文件
Go 还有一些其他功能可帮助您处理错误,我们将展示一个演示这些功能的程序。但是那个程序使用了一些新技巧,我们在深入之前需要向您展示它们。首先,我们需要知道如何读取目录的内容。
尝试创建一个名为 my_directory 的目录,其中包含右侧显示的两个文件和一个子目录。下面的程序将列出 my_directory 的内容,显示每个项目的名称以及它是文件还是子目录。
io/ioutil
包包含一个 ReadDir
函数,它允许我们读取目录的内容。您将目录的名称传递给 ReadDir
,它将返回一个值的切片,每个值代表目录包含的每个文件或子目录(以及遇到的任何错误)。
切片的每个值都满足 FileInfo
接口,该接口包括一个 Name
方法,返回文件的名称,以及一个 IsDir
方法,如果是目录则返回 true
。
因此,我们的程序调用 ReadDir
,并将 my_directory 的名称作为参数传递给它。然后它循环遍历返回的切片中的每个值。如果 IsDir
对于该值返回 true
,则打印 "目录:" + 文件的名称
。否则,打印 "文件:" + 文件的名称
。
将上述代码保存为 files.go,与 my_directory 相同的目录中。在终端中,切换到该父目录,并输入 **go run files.go**
。程序将运行并列出 my_directory 包含的文件和目录。
列出子目录中的文件(会更棘手)
读取单个目录的内容并不太复杂。但是假设我们想要列出更复杂的内容,比如 Go 工作区目录。这将包含一个嵌套在子目录中的整个子目录树,其中有些包含文件,有些则不包含。
通常情况下,这样一个程序会相当复杂。简单来说,其逻辑如下:
相当复杂,对吧?我们宁愿不必编写那样的代码!
但是如果有一种更简单的方法呢?类似于这样的逻辑:
-
获取目录中的文件列表。
-
获取下一个文件。
-
文件是一个目录吗?
-
如果是:从步骤I开始,使用此目录。
-
如果不是:只需打印文件名。
-
-
现在我们不清楚如何处理“使用这个新目录重新启动逻辑”的部分了。为了实现这一点,我们将需要一个新的编程概念...
递归函数调用
这就把我们带到了我们在结束我们的偏离并回到处理错误之前需要向您展示的第二个(也是最后一个)技巧。
Go 是支持递归的众多编程语言之一,允许函数调用自身。
如果你粗心大意地做这个,你最终会陷入一个无限循环,函数一遍又一遍地调用自身:
但是,如果确保递归循环最终停止,递归函数实际上可以很有用。
这是一个递归的 count
函数,从一个起始数计数到一个结束数。(通常循环效率更高,但这是演示递归工作原理的简单方法。)
以下是程序的执行顺序:
-
main
调用count
,起始参数为1
,结束参数为3
-
count
打印start
参数:1
-
start
(1
) 小于end
(3
),所以count
以start
为2
和end
为3
调用自身。 -
这是
count
的第二次调用,打印了它的新start
参数:2
-
start
(2
) 小于end
(3
),所以count
以start
为3
和end
为3
调用自身。 -
count
的第三次调用,打印了它的新start
参数:3
-
start
(3
) 不 小于end
(3
),因此count
不 再次调用自身;它只是返回。 -
前两次调用
count
也返回了,程序结束了。
如果我们添加调用 Printf
来显示每次调用 count
和每次函数退出时,这个顺序将更加明显:
所以这是一个简单的递归函数。让我们尝试将递归应用到我们的 files.go 程序中,看看它是否能帮助我们列出子目录的内容...
递归列出目录内容
我们希望我们的 files.go 程序能够列出 Go 工作空间目录中所有子目录的内容。我们希望使用类似这样的递归逻辑来实现这一点:
-
获取目录中的文件列表。
-
获取下一个文件。
-
文件是否为目录?
-
如果是:从步骤 I 开始处理此目录。
-
如果不是:只需打印文件名。
-
-
我们从main
函数中删除了读取目录内容的代码;现在main
只是调用一个递归的scanDirectory
函数。scanDirectory
函数接受它应该扫描的目录路径作为参数,因此我们将它传递给"go"
子目录的路径。
scanDirectory
的第一件事是打印当前路径,这样我们就知道我们正在哪个目录中。然后它调用 ioutil.ReadDir
来获取目录内容。
它遍历 ReadDir
返回的 FileInfo
值的切片,处理每一个。它调用 filepath.Join
来将当前目录路径和当前文件名用斜杠连接起来(因此 "go"
和 "src"
被连接成 "go/src"
)。
如果当前文件不是目录,scanDirectory
只是打印它的完整路径,并继续处理当前目录中的下一个文件(如果有的话)。
但是,如果当前文件是一个目录,递归就会启动:scanDirectory
会使用子目录的路径调用自身。如果该子目录有任何子目录,scanDirectory
将递归调用每个子目录,依此类推直到整个文件树。
将上述代码保存为 files.go,放在包含您的 Go 工作区的目录中(可能是您的用户主目录)。在终端中,切换到该目录,并使用 **go run files.go**
运行该程序。
当你看到 scanDirectory
函数在运行时,你会看到递归的真正魅力。对于我们的示例目录结构,处理过程大致如下:
-
main
函数使用路径"go"
调用scanDirectory
-
scanDirectory
函数打印传递给它的路径:"go",指示它正在处理的目录 -
它使用路径
"go"
调用ioutil.ReadDir
函数 -
返回的切片中只有一个条目:"src"
-
使用当前目录路径
"go"
和文件名"src"
调用filepath.Join
函数会得到新路径"go/src"
-
src 是一个子目录,因此再次调用
scanDirectory
函数,这次路径是"go/src"
注意
递归!
-
scanDirectory
函数打印新路径:"go/src" -
使用路径
"go/src"
调用ioutil.ReadDir
函数 -
返回的切片中的第一个条目是 "geo"
-
使用当前目录路径
"go/src"
和文件名"geo"
调用filepath.Join
函数会得到新路径"go/src/geo"
-
geo 是一个子目录,因此再次调用
scanDirectory
函数,这次路径是"go/src/geo"
注意
递归!
-
scanDirectory
函数打印新路径:"go/src/geo" -
它使用路径
"go/src/geo"
调用ioutil.ReadDir
函数 -
返回的切片中的第一个条目是 "coordinates.go"
-
coordinates.go 不是一个目录,因此它的名称会简单地被打印出来
-
依此类推...
递归函数可能很难编写,并且通常消耗比非递归解决方案更多的计算资源。但有时,递归函数可以提供解决其他方法难以解决的问题的解决方案。
现在我们的 files.go 程序已经设置好,我们可以结束我们的插曲了。接下来,我们将回到讨论 Go 的错误处理特性。
在递归函数中进行错误处理
如果 scanDirectory
在扫描任何子目录时遇到错误(例如,用户无权访问该目录),它会返回一个错误。这是预期的行为;程序对文件系统没有任何控制权,报告错误是很重要的。
但是,如果我们添加几个 Printf
语句来显示返回的错误,我们会看到这种错误处理方式并不理想:
如果在递归调用 scanDirectory
中的任何一个中发生错误,该错误必须沿着整个链路返回,直到它达到 main
函数!
引发 panic
我们的scanDirectory
函数是一个程序可能在运行时适合使用 panic 的罕见例子。
我们之前遇到过 panic。当访问数组和切片中的无效索引时,我们也见过它们:
当类型断言失败时(如果我们没有使用可选的ok
布尔值),我们也见过它们:
程序 panic 时,当前函数停止运行,并打印日志消息然后崩溃。
您可以通过调用内置的panic
函数自行引发 panic。
panic
函数期望一个满足空接口(即可以是任何类型)的单一参数。该参数会(如果需要)转换为字符串并作为 panic 日志消息的一部分打印出来。
堆栈跟踪
每个被调用的函数都需要返回到调用它的函数。为了实现这一点,像其他编程语言一样,Go 保留了一个调用堆栈,即在任何给定点活动的函数调用列表。
程序 panic 时,会包括堆栈跟踪(stack trace)或调用堆栈列表在 panic 输出中。这在确定程序崩溃原因时非常有用。
在崩溃前完成的延迟调用
程序 panic 时,所有延迟调用的函数仍然会被执行。如果有多个延迟调用,它们将按照被延迟的相反顺序执行。
下面的代码延迟了两次对Println
的调用,然后发生了 panic。程序崩溃前的输出顶部显示了两个调用完成的情况。
使用“panic”与 scanDirectory
右侧的scanDirectory
函数已更新为调用panic
而不是返回错误值。这大大简化了错误处理。
首先,我们从scanDirectory
声明中删除error
返回值。如果从ReadDir
返回error
值,则将其传递给panic
。我们可以从对scanDirectory
的递归调用中删除错误处理代码,并且也可以从main
中删除对scanDirectory
的调用。
当scanDirectory
遇到读取目录错误时,它会直接 panic。所有对scanDirectory
的递归调用都会退出。
何时使用 panic
我们马上会向您展示一种防止程序崩溃的方法。但是调用 panic 确实很少是处理错误的理想方式。
类似于无法访问的文件、网络故障和不良用户输入通常应视为“正常”,并且应通过error
值进行优雅处理。通常,调用panic
应保留用于“不可能”情况:即表明程序中存在错误而不是用户错误的情况。
下面是一个使用panic
来指示 bug 的程序。它给出了隐藏在三扇虚拟门后面的奖品。doorNumber
变量不是由用户输入填充的,而是由rand.Intn
函数选择的随机数。如果doorNumber
包含除1
、2
或3
之外的任何数字,那不是用户错误,而是程序中的 bug。
因此,如果doorNumber
包含无效值,调用panic
是有道理的。这不应该发生,如果发生,我们希望在程序表现出意外行为之前停止程序。
“recover”函数
将我们的scanDirectory
函数更改为使用panic
而不是返回错误,大大简化了错误处理代码。但是,恐慌也导致我们的程序崩溃并显示一个丑陋的堆栈跟踪。我们更愿意只显示用户错误消息。
Go 语言提供了一个内置的recover
函数,可以阻止程序因恐慌而崩溃。我们需要使用它来优雅地退出程序。
当您在正常程序执行期间调用recover
时,它只会返回nil
,不会做其他操作:
如果在程序恐慌时调用recover
,它将停止恐慌。但是当您在函数中调用panic
时,该函数将停止执行。因此,在与panic
相同的函数中调用recover
是没有意义的,因为恐慌仍将继续:
但是在程序恐慌时有一种方法可以调用recover
…… 在恐慌期间,任何延迟调用的函数都会被完成。因此,您可以将调用recover
放在一个单独的函数中,并使用defer
在导致恐慌的代码之前调用该函数。
调用recover
不会 导致执行在恐慌点恢复,至少不完全是这样。发生恐慌的函数将立即返回,该函数块中恐慌后的代码将不会执行。但在发生恐慌的函数返回后,正常执行将恢复。
恐慌值是从recover
函数返回的。
正如我们提到的,当没有恐慌时,调用recover
会返回nil
。
但是当有恐慌发生时,recover
会返回传递给panic
的任何值。这可以用来收集关于恐慌的信息,以帮助恢复或向用户报告错误。
在我们介绍panic
函数时,我们提到它的参数类型是interface{}
,即空接口,因此panic
可以接受任何值。同样,recover
的返回值类型也是interface{}
。您可以将recover
的返回值传递给像Println
(接受interface{}
值)之类的fmt
函数,但不能直接调用它的方法。
下面是一些代码,它将一个 error
值传递给 panic
。但在这样做时,该 error
被转换为一个 interface{}
值。当延迟函数稍后调用 recover
时,将返回该 interface{}
值。因此,即使底层的 error
值具有 Error
方法,尝试在 interface{}
值上调用 Error
将导致编译错误。
要调用方法或使用 panic 值进行其他操作,需要使用类型断言将其转换回其底层类型。
这里更新了上述代码,它获取 recover
的返回值并将其转换回 error
值。完成后,我们可以安全地调用 Error
方法。
在 scanDirectory 中从 panic 中恢复
当我们最后离开 files.go 程序时,在 scanDirectory
函数中添加 panic
调用清理了我们的错误处理代码,但也导致程序崩溃。我们可以利用我们到目前为止学到的关于 defer
、panic
和 recover
的知识来打印错误消息并优雅地退出程序。
我们通过添加一个 reportPanic
函数来实现这一点,在 main
函数中使用 defer
来调用它。我们在调用可能会导致 panic 的 scanDirectory
之前执行这一操作。
在 reportPanic
中,我们调用 recover
并存储它返回的 panic 值。如果程序正在 panic,这将停止 panic。
但当调用 reportPanic
时,我们不知道程序是否真的在 panic。无论 scanDirectory
是否调用了 panic
,延迟调用 reportPanic
都会执行。因此,我们首先测试从 recover
返回的 panic 值是否为 nil
。如果是,这意味着没有 panic,因此我们从 reportPanic
中返回而不执行进一步操作。
但如果 panic 值 不是 nil
,这意味着发生了 panic,我们需要报告它。
因为 scanDirectory
将一个 error
值传递给 panic
,我们使用类型断言将 interface{}
panic 值转换为 error
值。如果转换成功,我们打印 error
值。
有了这些更改,我们的用户将不再看到丑陋的 panic 日志和堆栈跟踪,而只会看到一个错误消息!
恢复 panic
reportPanic
还有一个潜在问题需要解决。现在,它拦截 任何 panic,即使这些 panic 并非源自 scanDirectory
。如果 panic 值无法转换为 error
类型,reportPanic
就不会打印它。
我们可以通过在 main
函数中使用 string
参数再次调用 panic
来测试这一点:
reportPanic
函数从新的 panic 中恢复,但由于 panic 值不是 error
,因此 reportPanic
不会打印它。我们的用户不知道程序为什么失败了!
处理未预期的 panic 的常见策略是简单地重新引发 panic 状态。通常重新引发是合适的,因为毕竟这是一个未预期的情况。
右侧代码更新了reportPanic
以处理意外的 panic。如果类型断言成功将 panic 值转换为error
,我们就像以前一样简单地打印它。但如果失败,我们只需再次调用panic
并传入相同的 panic 值。
再次运行files.go显示修复效果:reportPanic
从我们对panic
的测试调用中恢复,但当error
类型断言失败时,它会再次引发 panic。现在我们可以在main
中移除对panic
的调用,有信心地报告任何其他未预期的 panic!
没有愚蠢的问题
Q: 我看到其他编程语言有“异常”。panic
和recover
函数似乎以类似的方式工作。我能像异常那样使用它们吗?
A: 我们强烈建议不要这样做,Go 语言的维护者们也是这样认为。甚至可以说,根据语言设计本身的原因,使用panic
和recover
是不被鼓励的。在 2012 年的一次会议主题演讲中,Rob Pike(Go 的创始人之一)将panic
和recover
描述为“故意笨拙”。这意味着在设计 Go 语言时,其创作者们并没有试图让panic
和recover
易于使用或愉快,因此它们使用的频率会较低。
这是 Go 设计者们对异常的一个主要弱点做出的回应:异常可能会使程序流程变得更加复杂。相反,Go 开发者被鼓励以与处理程序其他部分相同的方式处理错误:通过if
和return
语句,以及error
值。确实,直接在函数内部处理错误可能会使函数的代码略微变长,但这比根本不处理错误要好。(Go 的创建者发现许多使用异常的开发者会仅仅抛出异常,然后未能正确处理它。)直接处理错误也使得错误处理方式立即显而易见——你无需查看程序的其他部分来查看错误处理代码。
所以不要期待 Go 中与异常等效的功能。这个特性是有意遗漏的。对于习惯于使用异常的开发者来说,可能需要一段时间的适应,但 Go 的维护者们认为这样做最终会带来更好的软件。
注意
你可以在这里查看 Rob Pike 的演讲摘要:
talks.golang.org/2012/splash.article#TOC_16
.
你的 Go 工具箱
这就是第十二章的全部内容!你已经将延迟函数调用和从 panic 中恢复添加到了你的工具箱中。
Code Magnets Solution
func find(item string, slice []string) bool {
for _, sliceItem := range slice {
if item == sliceItem {
return true
}
}
return false
}
type Refrigerator []string
func (r Refrigerator) Open() {
fmt.Println("Opening refrigerator")
}
func (r Refrigerator) Close() {
fmt.Println("Closing refrigerator")
}
func main() {
fridge := Refrigerator{"Milk", "Pizza", "Salsa"}
for _, food := range []string{"Milk", "Bananas"} {
err := fridge.FindFood(food)
if err != nil {
log.Fatal(err)
}
}
}
第十三章:分享工作:Goroutines 和 Channels
一次只做一件事情并不总是完成任务最快的方法。 有些大问题可以分解成更小的任务。Goroutines 允许你的程序同时处理多个不同的任务。你的 goroutines 可以使用channels来协调它们的工作,让它们互相发送数据并同步,以确保一个 goroutine 不会超过另一个。Goroutines 让你充分利用拥有多处理器的计算机,从而使你的程序运行尽可能快!
获取网页
本章将讨论通过同时执行多个任务来更快完成工作。但首先,我们需要一个可以分解成小部分的大任务。所以在接下来的几页中,请耐心等待我们布景...
网页越小,访问者的浏览器加载速度就越快。我们需要一个工具来测量页面的大小,以字节为单位。
多亏了 Go 的标准库,这不应该太难。下面的程序使用net/http
包连接到一个站点,并仅通过几个函数调用获取网页。
我们将想要的站点的 URL 传递给http.Get
函数。它将返回一个http.Response
对象,以及它遇到的任何错误。
http.Response
对象是一个结构体,具有代表页面内容的Body
字段。Body
满足io
包的ReadCloser
接口,意味着它有一个Read
方法(允许我们读取页面数据)和一个Close
方法(在完成读取后释放网络连接)。
我们推迟对Close
的调用,所以在我们读完数据后,连接将被释放。然后我们将响应体传递给ioutil
包的ReadAll
函数,它将读取其所有内容并作为byte
值的切片返回。
我们还没有涵盖byte
类型;它是 Go 的基本类型之一(类似于float64
或bool
),用于保存原始数据,比如从文件或网络连接中读取的数据。如果直接打印byte
值的切片,将不会显示任何有意义的内容,但是如果将byte
值的切片转换为string
类型,就可以得到可读的文本。(假设数据表示可读文本。)因此,我们最后将响应体转换为string
并打印出来。
如果我们将这段代码保存到文件并用go run
运行它,它将获取 example.com
页面的 HTML 内容,并显示出来。
如果您想获取有关此程序中使用的函数和类型的更多信息,可以通过终端上的go doc
命令(我们在第四章中已经了解过)获取。 尝试右侧的命令来查看文档。(或者,如果您愿意,可以使用您喜欢的搜索引擎在浏览器中查找。)
从这里开始,将程序转换为打印多个页面的大小并不是太困难。
我们可以将检索页面的代码移动到单独的responseSize
函数中,并将要检索的 URL 作为参数传递。 我们会打印我们正在检索的 URL,仅用于调试目的。 调用http.Get
、读取响应和释放连接的代码基本上不会改变。 最后,我们不再将响应的字节片段转换为string
,而是直接调用len
获取该片段的长度。 这将给出响应的字节长度,我们将其打印出来。
我们更新我们的main
函数,使用多个不同的 URL 调用responseSize
。 运行程序时,它将打印 URL 和页面大小。
多任务处理
现在我们来到本章的重点:通过同时执行多个任务来加速程序。
我们的程序依次调用responseSize
多次。 每次调用responseSize
都会建立到网站的网络连接,等待网站响应,打印响应大小,然后返回。 只有一个responseSize
调用返回后,下一个才能开始。 如果我们有一个所有代码都重复三次的大型函数,运行时间与我们的三个responseSize
调用相同。
但是,如果有一种方法可以同时运行所有三个responseSize
调用呢? 程序可能只需三分之一的时间就能完成!
使用 goroutines 进行并发处理
当responseSize
调用http.Get
时,您的程序必须在那里等待远程网站响应。 在等待期间,它不执行任何有用的操作。
不同的程序可能需要等待用户输入。 另一个可能在从文件中读取数据时等待。 有许多情况下,程序只是坐在那里等待。
并发允许程序暂停一个任务并处理其他任务。 等待用户输入的程序可能在后台执行其他处理。 程序可能在读取文件时更新进度条。 我们的responseSize
程序在等待第一个请求完成时可能会进行其他网络请求。
如果一个程序被写成支持并发,那么它可能也支持并行处理:同时运行任务。只有一个处理器的计算机一次只能运行一个任务。但是如今大多数计算机都有多个处理器(或者一个拥有多个核心的处理器)。您的计算机可能会将并发任务分配给不同的处理器以同时运行它们。(直接管理这些的情况很少见,通常操作系统会为您处理。)
将大任务分解为可以并发运行的更小子任务,有时可以显著提高程序的运行速度。
在 Go 语言中,并发任务被称为goroutines。其他编程语言有类似的概念称为线程,但是 goroutines 需要比线程更少的计算机内存,而且启动和停止时间也更短,这意味着您可以同时运行更多的 goroutines。
它们也更容易使用。要启动另一个 goroutine,您只需使用go
语句,这只是一个普通的函数或方法调用,在其前面加上go
关键字:
Goroutines 允许并发:暂停一个任务以处理其他任务。在某些情况下,它们还允许并行处理:同时处理多个任务!
注意我们说另一个goroutine。每个 Go 程序的main
函数都是使用一个 goroutine 启动的,所以每个 Go 程序至少运行一个 goroutine。你一直在使用 goroutines,只是不知道而已!
使用 goroutines
这是一个按顺序调用函数的程序。a
函数使用循环 50 次打印字符串"a"
,而b
函数则打印字符串"b"
50 次。main
函数先调用a
,然后是b
,最后在退出时打印一条消息。
这就好像main
函数包含了所有a
函数的代码,然后是所有b
函数的代码,最后是自己的代码:
要在新的 goroutines 中启动a
和b
函数,您只需在函数调用前面加上go
关键字即可:
func main() {
go a()
go b()
fmt.Println("end main()")
}
这会使新的 goroutines 与main
函数并发运行:
但是如果我们现在运行程序,我们只会看到main
函数末尾的Println
调用的输出——我们看不到a
或b
函数的任何输出!
这里有个问题:一旦main
goroutine(调用main
函数的 goroutine)结束,Go 程序就会停止运行,即使其他 goroutine 仍在运行。我们的main
函数在a
和b
函数的代码有机会运行之前就已经完成了。
我们需要保持 main
goroutine 运行,直到 a
和 b
函数的 goroutine 能够完成。为了正确实现这一点,我们将需要 Go 语言的另一个特性——通道,但在本章后面我们会再详细介绍。所以现在,我们只需暂停 main
goroutine 一段时间,以便其他 goroutine 可以运行。
我们将使用 time
包中的一个函数,称为 Sleep
,它会暂停当前 goroutine 给定的时间。在 main
函数中调用 time.Sleep(time.Second)
将导致 main
goroutine 暂停 1 秒。
如果我们重新运行程序,当它们的 goroutine 最终有机会运行时,我们会再次看到来自 a
和 b
函数的输出。这两者的输出会混合在一起,因为程序在两个 goroutine 之间切换。(您得到的模式可能与此处显示的不同。)当 main
goroutine 再次唤醒时,它会进行 fmt.Println
的调用并退出。
main
goroutine 中对 time.Sleep
的调用给了足够的时间让 a
和 b
goroutine 完成运行。
使用 goroutine 和我们的responseSize
函数
适应我们打印网页大小程序使用 goroutine 是非常容易的。我们只需要在每个对responseSize
的调用前添加go
关键字即可。
为了防止 main
goroutine 在 responseSize
goroutine 完成之前退出,我们还需要在 main
函数中添加对 time.Sleep
的调用。
仅休眠 1 秒可能不足以使网络请求完成。调用 time.Sleep(5 * time.Second)
将使 goroutine 休眠 5 秒。(如果您在慢速或无响应的网络上尝试此操作,则可能需要增加该时间。)
func responseSize(url string) {
fmt.Println("Getting", url)
response, err := http.Get(url)
if err != nil {
log.Fatal(err)
}
defer response.Body.Close()
body, err := ioutil.ReadAll(response.Body)
if err != nil {
log.Fatal(err)
}
fmt.Println(len(body))
}
如果我们运行更新后的程序,我们会看到它一次性打印出正在检索的 URL,因为三个 responseSize
goroutine 同时启动。
对 http.Get
的三次调用也是并发进行的;程序在发送下一个请求之前不会等待一个响应返回。因此,使用 goroutine 打印三个响应大小比早期的顺序版本要快得多。然而,程序仍然需要 5 秒才能完成,因为我们等待 main
中的 time.Sleep
调用完成。
我们并没有控制调用 responseSize
的执行顺序,所以如果我们再次运行程序,我们可能会看到请求以不同的顺序发生。
即使所有站点的响应速度比 5 秒更快,程序仍然需要 5 秒才能完成,所以我们从切换到 goroutine 中仍然没有得到很好的速度增益。更糟糕的是,如果站点响应时间长,5 秒可能还不够。有时,您可能会看到程序在所有响应到达之前结束。
显然,time.Sleep
并不是等待其他 goroutine 完成的理想方式。一旦我们在几页中看到通道,我们将有一个更好的替代方法。
我们不能直接控制 goroutine 运行的时间
每次运行程序时,我们可能会看到responseSize
goroutine 以不同的顺序运行:
我们也无法知道前一个程序何时在a
和b
goroutine 之间切换:
在正常情况下,Go 不能保证何时会在 goroutine 之间切换,也不能保证切换的持续时间。这允许 goroutine 以最有效的方式运行。但如果您关心 goroutine 运行的顺序,您将需要使用通道来同步它们(我们很快将看到)。
代码磁铁
使用 goroutines 的程序在冰箱上被打乱了。您能够重构代码片段以使工作程序产生与给定示例类似的输出吗?(无法预测 goroutine 执行的顺序,所以不用担心,您的程序输出不需要完全匹配所示输出。)
答案在“代码磁铁解决方案”中。
go
语句不能与返回值一起使用
转换为 goroutines 引出了另一个问题,我们需要解决:我们不能在go
语句中使用函数返回值。假设我们想要修改responseSize
函数以返回页面大小而不是直接打印它:
我们将会得到编译错误。编译器阻止您尝试从使用go
语句调用的函数中获取返回值。
这实际上是一件好事。当您在go
语句的一部分调用responseSize
时,您的意思是:“Go 在一个单独的 goroutine 中运行responseSize
。我将继续运行此函数中的指令。”responseSize
函数不会立即返回值;它必须等待网站响应。但是您的main
goroutine 中的代码会期望立即返回值,但此时还没有返回值!
这适用于使用go
语句调用的任何函数,而不仅仅是像responseSize
这样的长时间运行函数。您不能依赖于返回值及时准备好,因此 Go 编译器阻止任何尝试使用它们的行为。
Go 不允许您在使用go
语句调用的函数中使用返回值,因为无法保证返回值在使用之前已经准备好:
但是有一种方法可以在 goroutine 之间进行通信:通道(channels)。通道不仅允许您从一个 goroutine 发送值到另一个 goroutine,还确保发送 goroutine 在接收 goroutine 尝试使用该值之前已经发送了它。
使用通道的唯一实际方式是从一个 goroutine 向另一个 goroutine 通信。因此,为了演示通道,我们需要能够做一些事情:
-
创建一个通道。
-
编写一个接收通道作为参数的函数。我们将在单独的 goroutine 中运行此函数,并使用它向通道发送值。
-
在我们的原始 goroutine 中接收发送的值。
每个通道只传递特定类型的值,所以你可能有一个int
值的通道,另一个是结构类型的通道。要声明一个持有通道的变量,你使用chan
关键字,后跟通道将要传递的值的类型。
要实际创建一个通道,你需要调用内置的make
函数(这与创建映射和切片的方式相同)。你将通道类型作为参数传递给make
函数(应该与你想要赋值的变量类型相同)。
大多数情况下,与其单独声明通道变量,不如使用简短的变量声明更容易:
使用通道发送和接收值
要在通道上发送值,你使用<-
操作符(即小于号后跟一个短横线)。它看起来像是一个箭头,从你发送的值指向你发送它的通道。
你也可以使用<-
操作符从通道接收值,但位置不同:将箭头放在你要接收的通道左侧。(看起来像是从通道中拉出一个值。)
这是前一页中的greeting
函数,重写以使用通道。我们已经向greeting
添加了一个myChannel
参数,它接收一个传递string
值的通道。现在greeting
不再返回一个字符串值,而是通过myChannel
发送一个字符串。
在main
函数中,我们使用内置的make
函数创建了要传递给greeting
的通道。然后我们将greeting
作为一个新的 goroutine 调用。使用单独的 goroutine 很重要,因为通道应该只用于在不同的 goroutine 之间通信。(稍后我们会详细讨论原因。)最后,我们从传递给greeting
的通道接收一个值,并打印它返回的字符串。
我们不必直接将从通道接收到的值传递给Println
。你可以在需要值的任何上下文中从通道接收值。(也就是说,在你可能使用变量或函数返回值的任何地方。)例如,我们可以先将接收到的值赋给一个变量:
使用通道同步 goroutine
我们提到通道还确保发送协程在接收通道尝试使用值之前已经发送了该值。通道通过阻塞来实现这一点——暂停当前协程中的所有后续操作。发送操作会阻塞发送协程,直到另一个协程在相同通道上执行接收操作。反之亦然:接收操作会阻塞接收协程,直到另一个协程在相同通道上执行发送操作。这种行为允许协程同步它们的操作,即协调它们的时序。
这是一个创建两个通道并将它们传递给两个新协程函数的程序。然后 main
协程从这些通道接收值并打印它们。与我们的协程程序不同,该程序会重复打印 "a"
或 "b"
,我们可以预测这个程序的输出:它将始终按顺序打印 "a"
,然后是 "d"
,"b"
,"e"
,"c"
和 "f"
。
我们知道顺序是什么,因为 abc
协程每次向通道发送值时都会阻塞,直到 main
协程接收到它的值。def
协程也是如此。main
协程成为 abc
和 def
协程的协调者,只有当它准备好读取它们发送的值时,它们才能继续进行。
观察协程同步
abc
和 def
协程通过它们的通道发送值的速度非常快,以至于很难看清发生了什么。这里有另一个程序可以减慢速度,这样你就可以看到阻塞发生的情况。
我们从一个 reportNap
函数开始,它会导致当前协程休眠指定的秒数。每秒协程休眠时,它将打印一个提示说它仍在休眠。
我们添加了一个 send
函数,它将在一个协程中运行,并向通道发送两个值。但在发送任何东西之前,它首先调用 reportNap
,使其协程睡眠 2 秒。
在 main
协程中,我们创建一个通道并将其传递给 send
。然后我们再次调用 reportNap
,以便这个协程休眠5秒(比 send
协程长 3 秒)。最后,我们在通道上执行两次接收操作。
当我们运行这个程序时,我们会看到两个协程在前两秒内都会休眠。然后 send
协程醒来并发送它的值。但它不会继续做任何事情;发送操作会阻塞 send
协程,直到 main
协程接收到该值。
这并不会立即发生,因为 main
协程仍然需要睡眠另外 3 秒。当它醒来时,它会从通道接收值。只有在此之后,send
协程才会解除阻塞,以便发送它的第二个值。
破坏性的东西是教育性的!
这里是我们最早、最简单的通道演示代码:greeting
函数在一个协程中运行,并向main
协程发送一个字符串值。
做出以下任意一项更改并尝试运行代码。然后撤销您的更改并尝试下一项。看看会发生什么!
func greeting(myChannel chan string) {
myChannel <- "hi"
}
func main() {
myChannel := make(chan string)
go greeting(myChannel)
fmt.Println(<-myChannel)
}
如果你这样做... | ...代码会因为... |
---|---|
从main 函数中向通道发送一个值:myChannel <- "hi from main" |
你会收到一个“所有协程都在睡眠 - 死锁”错误。这是因为main 协程阻塞,等待另一个协程从通道接收。但另一个协程没有进行任何接收操作,所以main 协程保持阻塞状态。 |
在调用greeting 之前移除go 关键字:~~`go`~~ greeting(myChannel) |
这会导致greeting 函数在main 协程内部运行。同样的原因,这也会导致死锁错误:greeting 中的发送操作导致main 协程阻塞,但没有其他协程进行接收操作,所以它保持阻塞状态。 |
删除向通道发送值的那一行:~~myChannel <- "hi"~~ |
这也导致了死锁,但原因不同:main 协程尝试接收一个值,但现在没有发送任何值。 |
删除接收从通道接收值的那一行:~~fmt.Println(<-myChannel)~~ |
greeting 中的发送操作导致该协程阻塞。但由于没有接收操作来使main 协程也阻塞,main 立即完成,程序在不产生任何输出的情况下结束。 |
使用通道来修复我们的网页大小程序
我们的报告网页大小的程序仍然存在两个问题:
-
在
go
语句中,我们不能使用responseSize
函数的返回值。 -
我们的
main
协程在接收到响应大小之前就已经完成了,所以我们增加了一个对time.Sleep
的调用,持续 5 秒钟。但有时 5 秒钟太长,有时又太短。
我们可以使用通道来同时解决这两个问题!
首先,我们从import
语句中移除time
包;我们不再需要time.Sleep
了。然后,我们更新responseSize
以接受一个int
值的通道。不再返回页面大小,而是通过通道发送大小。
在main
函数中,我们调用make
创建了一个int
值的通道。我们更新每个对responseSize
的调用,将通道作为参数添加进去。最后,我们在通道上执行三次接收操作,每次接收responseSize
发送的一个值。
如果我们运行这个程序,我们会看到程序的完成速度和网站的响应速度一样快。这个时间可能有所不同,但在我们的测试中,我们看到完成时间短至 1 秒!
我们还可以做的另一个改进是将我们想要检索的 URL 列表存储在一个切片中,然后使用循环调用responseSize
,并从 channel 接收值。这将使我们的代码更少重复,并且如果以后想要添加更多 URL,则很重要。
我们根本不需要改变responseSize
,只需修改main
函数。我们创建一个包含我们想要的 URL 的string
值切片。然后我们遍历该切片,并调用responseSize
函数,传入当前的 URL 和 channel。最后,我们再进行第二个独立的循环,为切片中的每个 URL 运行一次,接收并打印来自 channel 的值。(在单独的循环中执行这些操作很重要。如果我们在启动responseSize
goroutines 的同一个循环中接收值,main
goroutine 会阻塞,直到接收完成,然后我们又回到了一次请求一个页面的方式。)
使用循环的方式更清晰,但结果仍然相同!
更新我们的 channel 以携带一个结构体
我们仍然需要修复responseSize
函数的一个问题。我们不知道网站将以什么顺序响应。因为我们没有保持页面 URL 与响应大小在一起,所以我们不知道哪个大小属于哪个页面!
不过这并不难修复。Channels 可以像传递基本类型一样轻松地传递复合类型,比如切片、映射和结构体。我们可以创建一个结构体类型来存储页面 URL 以及其大小,这样我们可以一起将它们发送到 channel 中。
我们将声明一个具有底层struct
类型的新Page
类型。Page
将有一个URL
字段来记录页面的 URL,以及一个Size
字段来记录页面的大小。
我们将在responseSize
函数的 channel 参数上更新为保存新的Page
类型,而不仅仅是int
页面大小。我们将让responseSize
创建一个带有当前 URL 和页面大小的新Page
值,并将其发送到 channel 中。
在main
函数中,我们还将更新make
调用中 channel 持有的类型。当我们从 channel 接收一个值时,它将是一个Page
值,因此我们将打印它的URL
和Size
字段。
现在输出将页面大小与它们的 URL 配对。这样就清楚了每个大小属于哪个页面。
以前,我们的程序必须逐个请求页面。Goroutines 允许我们在等待网站响应时开始处理下一个请求。程序完成的时间缩短了三分之一!
你的 Go 工具箱
这就是第十三章的内容!你已经将 goroutines 和 channels 添加到你的工具箱中。
Code Magnets 解决方案
第十四章:代码质量保证:自动化测试
你确定你的软件现在能正常工作吗?真的确定吗? 在将新版本发送给用户之前,你可能试验了新功能以确保它们正常工作。但你是否试验了旧功能以确保没有破坏它们中的任何一个?所有的旧功能?如果这个问题让你担心,你的程序需要自动化测试。自动化测试确保你程序的组件在代码更改后仍能正常工作。Go 语言的testing
包和go test
工具使编写自动化测试变得简单,利用你已经掌握的技能!
自动化测试能在别人之前发现你的 bug
开发者 A 在他们经常去的一家餐馆里碰到了开发者 B……
开发者 A: | 开发者 B: |
---|---|
新工作如何? | 不太好。晚饭后我得回办公室。我们发现一个 bug 导致某些客户被多次计费。 |
哎呀。那个怎么进了你们的计费服务器? | 我们认为可能是几个月前引入的。其中一个开发人员当时对计费代码做了一些更改。 |
哇,那么久以前了…… 你们的测试没发现这个 bug 吗? | 测试? |
你们的自动化测试。在引入 bug 时没有失败? | 嗯,我们没有这方面的测试。 |
什么?! |
你的客户依赖于你的代码。当它出错时,后果可能很严重。你公司的声誉会受损。而你将不得不加班修 bug。
这就是为什么发明了自动化测试。自动化测试是一个单独的程序,执行主程序的组件,并验证它们的行为是否符合预期。
除非你打算测试所有旧功能,确保你的更改没有破坏任何功能。自动化测试比手动测试节省时间,通常也更彻底。
我们应该为其编写自动化测试的一个函数示例
让我们来看一个可以通过自动化测试捕获的 bug 示例。这里有一个简单的包,其中包含一个函数,将几个字符串连接成适合在英语句子中使用的单个字符串。如果有两个项目,它们将用“and”连接(如“苹果和橙子”)。如果有多于两个项目,逗号将根据需要添加(如“苹果、橙子和梨子”)。
注意
最后一个很棒的例子来自《Head First Ruby》(这本书也有一章关于测试)!
代码使用了strings.Join
函数,该函数接受一个字符串切片和一个用于连接它们的字符串。Join
函数返回一个字符串,其中包含来自切片的所有项目,连接字符串分隔每个条目。
在JoinWithCommas
中,我们使用切片操作符来收集切片中除最后一个短语外的所有短语,并将它们传递给strings.Join
来将它们连接成一个字符串,每个短语之间用逗号和空格分隔。然后我们添加词语and(用空格包围),并以最后一个短语结束字符串。
这里有一个快速程序来尝试我们的新功能。我们导入我们的prose
包,并传递一些切片给JoinWithCommas
。
它能工作,但结果有个小问题。也许我们只是不成熟,但我们可以想象这会导致人们开玩笑说父母是一个小丑和一头奖牛。而且用这种方式格式化列表可能会导致其他误解。
为了消除任何混淆,让我们更新我们包的代码,在and之前再加一个额外的逗号(例如“apple, orange, and pear”):
如果我们重新运行程序,我们将在结果字符串的and前看到逗号。现在应该清楚,父母在照片中和小丑和公牛一起。
我们引入了一个 bug!
哦,是的!该函数曾经对这个两项列表返回"my parents and a rodeo clown"
,但这里也包含了一个额外的逗号!我们当时太专注于修复三项列表,结果引入了两项列表的 bug...
如果我们为这个函数编写了自动化测试,这个问题本可以避免。
自动化测试使用特定的输入运行你的代码,并寻找特定的结果。只要你的代码输出与预期值匹配,测试就会“通过”。
但假设你在你的代码中意外引入了一个 bug(就像我们在额外逗号的例子中做的那样)。你的代码输出将不再匹配预期值,测试将“失败”。你会立即知道有 bug。
拥有自动化测试就像在每次修改代码时自动检查代码中的 bug 一样!
写测试
Go 包含一个testing
包,你可以用它为你的代码编写自动化测试,并使用go test
命令来运行这些测试。
让我们先写一个简单的测试。起初我们不会测试任何实际内容,只是展示一下测试的工作原理。然后我们会实际使用测试来帮助我们修复JoinWithCommas
函数。
在你的prose包目录中,紧挨着join.go文件,创建一个join_test.go文件。文件名中的join部分并不重要,但_test.go部分是必须的;go test
工具会寻找以该后缀命名的文件。
测试文件中的代码由普通的 Go 函数组成,但为了能够与go test
工具一起工作,它需要遵循某些约定:
-
您不必将您的测试作为与您正在测试的代码相同的包的一部分,但如果您想要访问包中未导出的类型或函数,您将需要这样做。
-
测试需要使用
testing
包中的类型,因此您将需要在每个测试文件的顶部导入该包。 -
测试函数的名称应以
Test
开头。(其余部分可以任意,但应以大写字母开头。) -
测试函数应接受一个参数:指向
testing.T
值的指针。 -
您可以通过调用
testing.T
值上的方法(如Error
)报告测试失败。大多数方法接受一个带有说明测试失败原因的字符串。
使用“go test”命令运行测试
要运行测试,您可以使用go test
命令。该命令接受一个或多个包的导入路径,就像go install
或go doc
一样。它将查找那些包目录中所有以_test.go结尾的文件,并运行这些文件中每个函数的每个函数,其名称以Test
开头。
让我们运行刚刚添加到我们的prose
包中的测试。在您的终端中运行此命令:
go test github.com/headfirstgo/prose
测试函数将运行并打印它们的结果。
因为两个测试函数都调用了传递给它们的testing.T
值上的Error
方法,所以两个测试都失败了。打印了每个失败测试函数的名称,以及包含Error
调用的行和给定的失败消息。
输出底部是整个prose
包的状态。如果包内的任何测试失败(像我们的那样),则会打印整个包的“FAIL
”状态。
如果我们从测试中删除对Error
方法的调用…
…然后我们将能够重新运行相同的go test
命令,并且测试将通过。由于每个测试都通过,go test
将仅为整个prose
包打印“ok
”状态。
测试我们实际的返回值
我们可以使我们的测试通过,也可以使它们失败。现在让我们尝试编写一些实际帮助我们排查JoinWithCommas
函数的测试。
我们将更新TestTwoElements
以显示在调用具有两个元素切片时从JoinWithCommas
函数期望的返回值。我们将对具有三个元素切片的TestThreeElements
执行相同操作。我们将运行测试,并确认TestTwoElements
当前失败而TestThreeElements
通过。
一旦我们的测试设置为我们想要的方式,我们将修改JoinWithCommas
函数以使所有测试通过。到那时,我们将知道我们的代码已修复!
在TestTwoElements
中,我们将向JoinWithCommas
传递一个包含两个元素的切片,[]string{"apple", "orange"}
。如果结果不等于"apple and orange"
,我们将测试失败。同样,在TestThreeElements
中,我们将传递一个包含三个元素的切片,
[]string{"apple", "orange", "pear"}
。如果结果不等于"apple, orange, and pear"
,我们将测试失败。
如果我们重新运行测试,TestThreeElements
测试将通过,但TestTwoElements
测试将失败。
这是一件好事情;它与我们基于join
程序输出所预期看到的相符。这意味着我们可以依赖我们的测试来判断JoinWithCommas
是否正常工作!
使用“Errorf”方法生成更详细的测试失败消息
目前我们的测试失败信息对于诊断问题并不是很有帮助。我们知道预期有某个值,而且我们知道JoinWithCommas
的返回值与预期不同,但我们不知道这些值是什么。
测试函数的testing.T
参数还有一个可以调用的Errorf
方法。与Error
不同,Errorf
接受带有格式化动词的字符串,就像fmt.Printf
和fmt.Sprintf
函数一样。您可以使用Errorf
在测试失败消息中包含额外的信息,例如您传递给函数的参数、您得到的返回值以及您期望的值。
这里是我们测试的更新,使用Errorf
生成更详细的失败消息。为了避免在每个测试中重复字符串,我们添加了一个want
变量(表示我们期望的值),用于保存我们期望JoinWithCommas
返回的值。我们还添加了一个got
变量(表示我们实际得到的值),用于保存实际的返回值。如果got
不等于want
,我们将调用Errorf
,并让它生成一个错误消息,包括我们传递给JoinWithCommas
的切片(我们使用格式动词%#v
,使切片的打印方式与 Go 代码中的一样),我们得到的返回值,以及我们期望的返回值。
如果我们重新运行测试,我们将看到确切的失败原因。
测试“辅助”函数
您并不局限于在_test.go文件中只有测试函数。通过将重复代码移动到测试文件中的其他“辅助”函数中,您可以减少测试中的重复代码。go test
命令仅使用名称以Test
开头的函数,因此只要您将函数命名为其他名称,就可以正常工作。
在我们的TestTwoElements
和TestThreeElements
函数之间存在相当繁琐的对t.Errorf
的调用(随着我们添加更多测试,可能会出现更多重复)。一种解决方法是将字符串生成移至一个单独的errorString
函数,测试可以调用它。
我们将让errorString
接受传递给JoinWithCommas
的切片,got
值和want
值。然后,不再在testing.T
值上调用Errorf
,而是让errorString
调用fmt.Sprintf
为我们生成一个(相同的)错误字符串来返回。测试本身然后可以使用返回的字符串调用Error
来指示测试失败。这段代码稍微更清晰,但仍然能得到相同的输出。
使测试通过
现在我们的测试设置了有用的失败消息,是时候看看如何用它们来修复我们的主要代码了。
我们有两个关于JoinWithCommas
函数的测试。通过包含三个项的切片的测试通过了,但通过包含两个项的切片的测试失败了。
这是因为JoinWithCommas
当前在返回仅有两个项目的列表时仍然包括一个逗号。
让我们修改JoinWithCommas
来解决这个问题。如果字符串切片中只有两个元素,我们将简单地用" and "
将它们连接在一起,然后返回结果字符串。否则,我们将按照我们一直遵循的逻辑进行操作。
我们已经更新了我们的代码,但它是否工作正常?我们的测试可以立即告诉我们!如果我们现在重新运行我们的测试,TestTwoElements
将通过,意味着所有测试都通过了。
我们可以确信JoinWithCommas
现在能够处理两个字符串的切片,因为相应的单元测试现在通过了。而我们不需要担心它是否仍然正确处理三个字符串的切片;我们有一个单元测试向我们保证这也没问题。
这也反映在我们的join
程序的输出中。如果我们现在重新运行它,我们将看到两个切片都被正确格式化了!
测试驱动开发
一旦你有了一些单元测试的经验,你可能会陷入一种被称为测试驱动开发的循环中:
-
编写测试: 你为你希望的功能编写一个测试,即使它还不存在。然后你运行测试以确保它失败。
-
使其通过: 你在主代码中实现了这个功能。不用担心你写的代码是笨拙还是低效;你的唯一目标是让它工作。然后你运行测试以确保它通过。
-
重构你的代码: 现在,你可以自由地重构代码,随意改进它。你已经看到测试失败,所以你知道如果你的应用代码出错,它将再次失败。你已经看到测试通过,所以只要你的代码工作正常,它将继续通过。
这种自由更改你的代码而不必担心它会出错,这才是你想要单元测试的真正原因。每当你看到一种使你的代码更简短或更易读的方法时,你都会毫不犹豫地去做。当你完成时,只需再次运行你的测试,你就可以确信一切仍然正常运行。
写测试!
让它通过!
重构你的代码!
另一个需要修复的错误
可能 JoinWithCommas
会被调用时传递一个仅包含单个短语的切片。但在这种情况下,它的表现并不是很好,将该项视为列表末尾出现的情况:
在这种情况下,JoinWithCommas
应该返回什么?如果我们有一个只有一个项目的列表,我们实际上不需要逗号、单词and或任何其他东西。我们可以简单地返回一个包含该项目的字符串。
让我们在 join_test.go 中表达这个作为一个新的测试。我们将在现有的 TestTwoElements
和 TestThreeElements
测试旁边添加一个新的测试函数叫做 TestOneElement
。我们的新测试看起来与其他测试类似,但我们将传递一个仅包含一个字符串的切片到 JoinWithCommas
,并期望返回一个包含该字符串的返回值。
正如你所预料的,知道我们的代码中有一个 bug,测试失败了,显示 JoinWithCommas
返回的是 ", and apple"
而不是 "apple"
。
更新 JoinWithCommas
以修复我们的失败测试非常简单。我们检查给定的切片是否只包含一个字符串,如果是,则简单地返回该字符串。
func JoinWithCommas(phrases []string) string {
if len(phrases) == 1 {
return phrases[0]
} else if len(phrases) == 2 {
return phrases[0] + " and " + phrases[1]
} else {
result := strings.Join(phrases[:len(phrases)-1], ", ")
result += ", and "
result += phrases[len(phrases)-1]
return result
}
}
修复了我们的代码后,如果我们重新运行测试,我们将看到一切都通过了。
当我们在代码中使用 JoinWithCommas
时,它将表现得像它应该的那样。
没有蠢问题
Q: 这些测试代码会不会使我的程序变得更大和更慢?
A: 别担心!正如 go test
命令已经被设置为只能处理文件名以 _test.go 结尾的文件一样,go
工具中的其他命令(如 go build
和 go install
)也被设置为忽略以 _test.go 结尾的文件。go
工具可以将您的程序代码编译成可执行文件,但它将忽略您的测试代码,即使它们保存在同一个包目录中。
代码磁铁
糟糕!我们创建了一个 compare
包,其中包含一个 Larger
函数,该函数应返回传入的两个整数中较大的那个。但是我们的比较出错了,Larger
返回的是较小的整数!
我们已经开始编写测试来帮助诊断问题。您能重构代码片段以创建能生成所示输出的工作测试吗?您需要创建一个返回测试失败消息的辅助函数,然后在测试中添加两个对该辅助函数的调用。
答案在 “Code Magnets Solution” 中。
运行特定的测试集
有时候你可能只想运行几个特定的测试,而不是整个集合。go test
命令提供了几个命令行标志来帮助你实现这一点。标志是一个参数,通常是一个连字符(-
)后面跟着一个或多个字母,你可以提供给命令行程序以改变程序的行为。
go test
命令值得记住的第一个标志是 -v
标志,它代表“详细信息”。如果你将其添加到任何 go test
命令中,它将列出每个运行的测试函数的名称和状态。通常,通过的测试会被省略以保持输出“安静”,但在详细模式下,go test
将列出即使是通过的测试。
一旦你获得一个或多个测试的名称(无论是从 go test -v
的输出中还是从测试代码文件中查找),你可以添加 -run
选项来限制要运行的测试集。在 -run
后面,你指定部分或全部函数名,只有名称与你指定的测试函数才会运行。
如果我们在 go run
命令中添加 -run Two
,那么只有函数名中带有 Two
的测试函数会被匹配执行。在我们的情况下,这意味着只会运行 TestTwoElements
。(你可以使用 -run
选项与或不与 -v
标志一起使用,但我们发现添加 -v
可以避免混淆哪些测试正在运行。)
如果我们改为添加 -run Elements
,那么 TestTwoElements
和 TestThreeElements
都会被运行。(但 TestOneElement
不会被运行,因为它的名字结尾没有 s
。)
表驱动测试
在我们的三个测试函数之间存在相当多的重复代码。实际上,测试之间唯一变化的是我们传递给 JoinWithCommas
的切片和我们期望它返回的字符串。
我们可以建立一个“表格”,列出输入数据和我们期望的输出,然后使用一个单独的测试函数来检查表格中的每个条目,而不是维护单独的测试函数。
表格的格式没有标准,但一个常见的解决方案是定义一个新类型,专门用于你的测试,该类型包含每个测试中要传递给 JoinWithCommas
的字符串切片的 list
字段,以及我们期望它返回的相应字符串的 want
字段。这里是我们可能使用的 testData
类型。
我们可以在将要使用的 lists_test.go 文件中直接定义 testData
类型。
我们的三个测试函数可以合并成一个单独的 TestJoinWithCommas
函数。在顶部,我们设置一个 tests
切片,并将旧的 TestOneElement
、TestTwoElements
和 TestThreeElements
中的 list
和 want
变量值移动到 tests
切片中的 testData
值中。
然后,我们循环遍历切片中的每个testData
值。我们将list
切片传递给JoinWithCommas
,并将其返回的字符串存储在got
变量中。如果got
不等于testData
值的want
字段中的字符串,则调用Errorf
并用它来格式化测试失败消息,就像我们在errorString
辅助函数中所做的一样。(既然这使得errorString
函数多余,我们可以将其删除。)
这个更新后的代码更短,重复性更少,但表格中的测试与它们分开时一样通过!
使用测试修复恐慌代码
不过,表驱动测试最好的一点是,需要时很容易添加新的测试。假设我们不确定当JoinWithCommas
接收到空切片时会发生什么。要找出答案,我们只需在tests
切片中添加一个新的testData
结构。我们将指定,如果将空切片传递给JoinWithCommas
,则应返回一个空字符串:
看来我们担心是正确的。如果运行测试,它将以堆栈跟踪恐慌:
显然,某些代码尝试访问一个超出切片范围的索引(它尝试访问一个不存在的元素)。
查看堆栈跟踪,我们看到恐慌发生在lists.go文件的第 11 行,在JoinWithCommas
函数内部:
因此,恐慌发生在lists.go文件的第 11 行…… 那是我们访问切片中除最后一个元素外的所有元素,并用逗号将它们连接在一起的地方。但由于我们传入的phrases
切片为空,根本没有要访问的元素。
如果phrases
片段为空,我们确实不应尝试从中访问任何元素。没有什么可以加入的,因此我们只需返回一个空字符串。让我们在if
语句中添加另一个子句,当len(phrases)
为0
时返回空字符串。
之后,如果再次运行测试,一切都通过了,甚至调用带有空切片的JoinWithCommas
的测试也通过了!
或许您可以想象对JoinWithCommas
想要进行的进一步更改和改进。请继续!您可以毫无顾虑地这样做。如果在每次更改后运行测试,您就可以确切地知道一切是否按预期工作。(如果没有,您将清楚地知道需要修复什么!)
您的 Go 工具箱
这就是第十四章的内容!您已经将测试添加到了您的工具箱中。
代码磁铁解决方案
第十五章:响应请求:Web 应用程序
这是 21 世纪。用户需要 Web 应用程序。 Go 在这方面有所覆盖!Go 标准库包含的包可以帮助您托管自己的 Web 应用程序,并使它们可以从任何 Web 浏览器访问。因此,我们将在本书的最后两章中向您展示如何构建 Web 应用程序。
您的 Web 应用程序需要的第一件事是在浏览器发送请求时能够做出响应。在本章中,我们将学习使用net/http
包来实现这一点。
使用 Go 编写 Web 应用程序
在终端上运行的应用程序对于个人使用非常棒。但普通用户已经被互联网和万维网宠坏了。他们不想为了使用你的应用程序而学习使用终端。他们甚至不想安装你的应用程序。他们希望在他们点击浏览器中的链接时立即可以使用它。
但别担心!Go 也可以帮助您编写 Web 应用程序。
我们不会误导您——编写 Web 应用程序并不是一件小事。这将需要您迄今为止学到的所有技能,再加上一些新技能。但 Go 有一些出色的可用包,将使这个过程更容易!
这包括net/http
包。HTTP 代表“HyperText Transfer Protocol”,它用于 Web 浏览器和 Web 服务器之间的通信。使用net/http
,您将能够使用 Go 创建自己的 Web 应用程序!
浏览器、请求、服务器和响应
当您在浏览器中输入 URL 时,实际上是发送了对 Web 页面的请求。该请求发送到一个服务器。服务器的工作是获取适当的页面并将其发送回浏览器作为响应。
在 Web 的早期阶段,服务器通常会读取服务器硬盘上 HTML 文件的内容,并将该 HTML 返回给浏览器。
但今天,服务器通常与一个程序通信来满足请求,而不是从文件中读取。这个程序可以用几乎任何您想要的语言编写,包括 Go!
一个简单的 Web 应用程序
处理来自浏览器的请求是一项大量的工作。幸运的是,我们不必自己处理所有工作。回到第十三章,我们使用net/http
包向服务器发出请求。net/http
包还包括一个小型的 Web 服务器,因此它也能够响应请求。我们唯一需要做的就是编写代码,填充这些响应数据。
这里有一个使用net/http
来向浏览器提供简单响应的程序。虽然程序很简短,但其中有很多新东西。我们将首先运行程序,然后逐步解释它。
将上述代码保存到任意文件中,并使用go run
命令在终端中运行:
我们正在运行我们自己的 Web 应用程序!现在我们只需要连接一个 Web 浏览器并测试它。打开你的浏览器,将这个 URL 键入地址栏。(如果这个 URL 看起来有点奇怪,不要担心;我们马上解释它的含义。)
http://localhost:8080/hello
浏览器将向应用程序发送请求,应用程序将回复“Hello, web!
”。我们刚刚向浏览器发送了第一个响应!
应用程序会继续监听请求,直到我们停止它。当您完成页面时,在终端中按下 Ctrl-C 以向程序发出退出信号。
您的计算机正在自我通信
当我们启动我们的小型 Web 应用程序时,它会在您的计算机上启动自己的 Web 服务器。
因为该应用程序正在您的计算机上运行(而不是在互联网上的某个地方),所以我们在 URL 中使用特殊的主机名localhost
。这告诉您的浏览器需要从您的计算机建立连接到同一台计算机。
我们还需要在 URL 中指定一个端口。(端口是应用程序可以监听消息的编号网络通信通道。)在我们的代码中,我们指定服务器应在端口 8080 上监听,所以我们在 URL 中包含它,跟在主机名后面。
没有愚蠢的问题
Q: 我收到一个错误,说浏览器无法连接!
A: 你的服务器可能实际上没有运行。在你的终端中查找错误消息。还要检查浏览器中的主机名和端口号,以防输错。
Q: 为什么我在 URL 中必须指定端口号?我在访问其他网站时不必这样做!
A: 大多数 Web 服务器在端口 80 上监听 HTTP 请求,因为这是 Web 浏览器默认发出 HTTP 请求的端口。但是出于安全原因,许多操作系统要求您运行监听端口 80 的服务时需要特殊权限。这就是为什么我们设置我们的服务器监听端口 8080 的原因。
Q: 我的浏览器显示“404 页面未找到”的消息。
A: 这是服务器的响应,这是好事,但也意味着你请求的资源未找到。检查你的 URL 是否以/hello结尾,并确保你在服务器程序代码中没有输错。
Q: 当我尝试运行我的应用程序时,我收到一个错误,说“listen tcp 127.0.0.1:8080: bind: 地址已在使用中”!
A: 你的程序试图监听与另一个程序相同的端口(这是你的操作系统不允许的)。你运行了服务器程序超过一次吗?如果是这样,在终端中完成操作后,确保停止旧的服务器。在运行新的服务器之前一定要停止旧的。
我们的简单 Web 应用程序,解释如下
现在让我们仔细看看我们小型 Web 应用程序的各个部分。
在main
函数中,我们使用字符串"/hello"
和viewHandler
函数调用http.HandleFunc
。(Go 支持一等函数,允许您将函数传递给其他函数。我们稍后会详细讨论这些。)这告诉应用程序在收到以/hello
结尾的 URL 请求时调用viewHandler
。
然后,我们调用http.ListenAndServe
,启动 Web 服务器。我们将字符串"localhost:8080"
传递给它,这将使它仅接受来自本机端口 8080 的请求。(当您准备向其他计算机的请求开放应用程序时,可以改用字符串"0.0.0.0:8080"
。您也可以将端口号更改为其他值。)第二个参数中的nil
值仅表示将使用通过HandleFunc
设置的函数来处理请求。
注意
(稍后,如果您想了解替代处理请求的其他方法,请查看“http”包中“ListenAndServe”函数、“Handler”接口和“ServeMux”类型的文档。)
我们在调用HandleFunc
之后调用ListenAndServe
,因为ListenAndServe
会持续运行,除非遇到错误。如果有错误,它将返回该错误,我们在程序退出之前将其记录。但是,如果没有错误,此程序将继续运行,直到我们在终端中按下 Ctrl-C 来中断它。
与main
相比,在viewHandler
函数中没有什么特别出乎意料的地方。服务器将viewHandler
传递给http.ResponseWriter
,用于向浏览器响应写入数据,并传递给http.Request
值的指针,表示浏览器的请求。(在此程序中,我们不使用Request
值,但处理程序函数仍然必须接受它。)
在viewHandler
内部,我们通过在ResponseWriter
上调用Write
方法向响应添加数据。Write
方法不接受字符串,但它接受byte
值的切片,因此我们将字符串"Hello, web!"
转换为[]byte
,然后传递给Write
方法。
您可能还记得byte
值来自第十三章。当在通过http.Get
函数检索到的响应上调用ioutil.Readall
函数时,该函数返回byte
值的切片。
正如我们在第十三章中看到的,[]byte
可以转换为string
:
正如您刚刚在这个简单的 Web 应用程序中看到的那样,string
可以转换为[]byte
。
ResponseWriter
的Write
方法返回成功写入的字节数以及遇到的任何错误。我们无法使用写入的字节数做任何有用的事情,因此我们忽略它。但是,如果有错误,我们会将其记录并退出程序。
_, err := writer.Write(message)
if err != nil {
log.Fatal(err)
}
资源路径
当我们在浏览器中输入 URL 访问我们的 Web 应用时,我们确保它以/hello结尾。但是为什么我们需要这样做呢?
http://localhost:8080/hello
服务器通常有许多不同的资源可发送到浏览器,包括 HTML 页面、图片等。
URL 中主机地址和端口后面的部分是资源路径。它告诉服务器你要操作它的哪个资源。net/http
服务器从 URL 的末尾提取路径,并在处理请求时使用它。
当我们在我们的 Web 应用程序中调用 http.HandleFunc
时,我们传递了字符串"/hello"
和viewHandler
函数。该字符串用作请求资源路径进行查找。从那时起,每当收到路径为/hello
的请求时,应用程序将调用viewHandler
函数。然后,viewHandler
函数负责生成适合其收到的请求的响应。
在这种情况下,这意味着以文本“Hello, web!”作为响应。
你的应用程序不能仅仅对收到的每个请求响应“Hello, web!”。大多数应用程序将需要以不同方式响应不同的请求路径。
一种实现这一点的方法是为要处理的每个路径调用一次 HandleFunc
,并提供一个不同的函数来处理每个路径。这样,您的应用程序将能够响应任何这些路径的请求。
对不同资源路径的不同响应
这是我们应用程序的更新,提供三种不同语言的问候语。我们调用了三次HandleFunc
。具有"/hello"
路径的请求将调用englishHandler
函数,"/salut"
的请求将由frenchHandler
函数处理,而"/namaste"
的请求将由hindiHandler
处理。每个处理程序函数都将其ResponseWriter
和字符串传递给新的write
函数,该函数将字符串写入响应。
头等函数
当我们用处理函数调用 http.HandleFunc
时,并不是调用处理函数并将其结果传递给 HandleFunc
。我们传递的是函数本身给 HandleFunc
。该函数被存储起来,以便在接收到匹配请求路径时稍后调用。
Go 语言支持头等函数;也就是说,Go 中的函数被视为“头等公民”。
在支持头等函数的编程语言中,函数可以分配给变量,然后从这些变量调用。
下面的代码首先定义了一个 sayHi
函数。在我们的 main
函数中,我们声明了一个类型为 func()
的 myFunction
变量,这意味着该变量可以保存一个函数。
然后我们将 sayHi
函数本身分配给 myFunction
。请注意,我们没有放任何括号 —— 我们不写 sayHi()
—— 因为这样做会 调用 sayHi
。我们仅输入函数名,就像这样:
myFunction = sayHi
这导致 sayHi
函数本身被分配给 myFunction
变量。
但在下一行,我们确实在 myFunction
变量名后面包含括号,像这样:
myFunction()
这导致存储在 myFunction
变量中的函数被调用。
将函数传递给其他函数
具有一等公民函数的编程语言允许您将函数作为参数传递给其他函数。此代码定义了简单的 sayHi
和 sayBye
函数。它还定义了一个 twice
函数,该函数将另一个名为 theFunction
的函数作为参数。然后 twice
函数调用存储在 theFunction
中的任何函数两次。
在 main
中,我们调用 twice
并将 sayHi
函数作为参数传递,导致 sayHi
被运行两次。然后我们用 sayBye
函数再次调用 twice
,导致 sayBye
被运行两次。
函数作为类型
当我们试图将 sayHi
函数作为参数传递给 http.HandleFunc
时,我们会得到编译错误:
函数的参数和返回值是其类型的一部分。持有函数的变量需要指定该函数应具有的参数和返回值。该变量只能持有参数和返回值数量与类型匹配的函数。
此代码定义了一个类型为 func()
的 greeterFunction
变量:它持有一个不接受参数并且不返回值的函数。然后,我们定义了一个类型为 func(int, int) float64
的 mathFunction
变量:它持有一个接受两个整数参数并返回一个 float64
值的函数。
代码还定义了 sayHi
和 divide
函数。如果我们将 sayHi
分配给 greeterFunction
变量,将 divide
分配给 mathFunction
变量,一切都能编译和正常运行:
但是如果我们尝试颠倒两者,将再次得到编译错误:
divide
函数接受两个 int
参数并返回一个 float64
值,因此无法存储在 greeterFunction
变量中(该变量期望不接受参数并且不返回值的函数)。而 sayHi
函数不接受参数并且不返回值,因此无法存储在 mathFunction
变量中(该变量期望接受两个 int
参数并返回一个 float64
值的函数)。
接受函数作为参数的函数也需要指定传入函数应具有的参数和返回类型。
这是一个具有 passedFunction
参数的 doMath
函数。传入的函数需要接受两个 int
参数,并返回一个 float64
值。
我们还定义了divide
和multiply
函数,两者都接受两个int
参数并返回一个float64
。divide
或multiply
都可以成功传递给doMath
。
一个不符合指定类型的函数无法传递给doMath
。
这就是为什么如果我们向http.HandleFunc
传递错误的函数,我们会得到编译错误。HandleFunc
期望传递一个接受ResponseWriter
和Request
指针作为参数的函数。如果传递其他内容,你将得到编译错误。
事实上,这是件好事。一个无法分析请求并写入响应的函数可能无法处理浏览器请求。如果尝试传递类型错误的函数,Go 将在程序编译之前提醒你问题。
池谜题
你的工作是从池中获取代码片段,并将它们放入此代码中的空白行。不要重复使用相同的片段,而且你不需要使用所有的片段。你的目标是创建一个能运行并产生所示输出的程序。
func callFunction(passedFunction ________) {
passedFunction()
}
func callTwice(passedFunction ________) {
passedFunction()
passedFunction()
}
func callWithArguments(passedFunction ________________) {
passedFunction("This sentence is", false)
}
func printReturnValue(passedFunction func() string) {
fmt.Println(____________________)
}
func functionA() {
fmt.Println("function called")
}
func functionB() ________ {
fmt.Println("function called")
return "Returning from function"
}
func functionC(a string, b bool) {
fmt.Println("function called")
fmt.Println(a, b)
}
func main() {
callFunction(___________)
callTwice(___________)
callWithArguments(functionC)
printReturnValue(functionB)
}
注意:每个池中的片段只能使用一次!
答案在“Pool Puzzle Solution”中。
接下来是什么
现在你知道如何从浏览器接收请求并发送响应了。最棘手的部分已经完成!
在最后一章,我们将利用这些知识构建一个更复杂的应用程序。
到目前为止,我们所有的响应都是使用纯文本。我们将学习如何使用 HTML 来为页面提供更多结构。并且我们将学习如何使用html/template
包在将数据插入 HTML 后将其发送回浏览器。到那里见!
你的 Go 工具箱
这就是第十五章的全部内容!你已经向你的工具箱中添加了 HTTP 处理程序函数和一流函数。
池谜题解决方案
第十六章:一个遵循的模式:HTML 模板
您的 Web 应用程序需要用 HTML 来响应,而不是纯文本。 纯文本对于电子邮件和社交媒体帖子来说很好。但您的页面需要格式。它们需要标题和段落。它们需要表单,用户可以向您的应用程序提交数据。为了做到这些,您需要 HTML 代码。
最终,您将需要将数据插入到 HTML 代码中。这就是为什么 Go 提供了html/template
包的原因,这是一种强大的方式,可以将数据包含到您应用程序的 HTML 响应中。模板是构建更大、更好的 Web 应用程序的关键,在这最后一章中,我们将向您展示如何使用它们!
一个留言板应用程序
让我们把我们在第十五章中学到的东西用起来。我们将为网站构建一个简单的留言板应用程序。您的访客将能够在表单中输入消息,并将其保存到文件中。他们还可以查看以前所有签名的列表。
在我们能让这个应用程序正常工作之前,还有很多内容需要覆盖,但不要担心——我们将把这个过程分解成小步骤。让我们看看将涉及哪些内容……
我们需要设置我们的应用程序,并使其响应主留言板页面的请求。这部分不会太难;我们已经在前一章中覆盖了所有需要了解的内容。
然后,我们需要在响应中包含 HTML。我们将创建一个简单的页面,只使用几个 HTML 标签,并将其存储在文件中。然后我们将从文件中加载 HTML 代码,并在我们应用程序的响应中使用它。
我们需要获取访客输入的签名,并将它们合并到 HTML 中。我们将向您展示如何使用html/template
包来完成这一操作。
然后,我们需要创建一个单独的页面,用于添加签名的表单。我们可以使用 HTML 相当容易地完成这个任务。
最后,当用户提交表单时,我们需要将表单内容保存为新的签名。我们将其保存到文本文件中,并与所有其他提交的签名一起加载回来。
处理请求和检查错误的函数
我们的第一个任务将是显示主留言板页面。通过编写示例 Web 应用程序的实践,这应该不会太难。在我们的main
函数中,我们将调用http.HandleFunc
并设置应用程序,以便为路径为"/guestbook"
的任何请求调用名为viewHandler
的函数。然后,我们将调用http.ListenAndServe
来启动服务器。
目前,viewHandler
函数看起来与我们之前示例中的处理程序函数完全相同。它接受一个http.ResponseWriter
和一个http.Request
的指针,就像之前的处理程序一样。我们将一个字符串转换为[]byte
,并使用ResponseWriter
的Write
方法将其添加到响应中。
check
函数是此代码中唯一真正新的部分。在这个 Web 应用程序中,我们可能会有很多潜在的error
返回值,并且我们不想在每个地方重复代码来检查和报告它们。因此,我们将每个错误传递给我们的新check
函数。如果error
为 nil,则check
什么也不做,但否则它会记录错误并退出程序。
在ResponseWriter
上调用Write
可能会返回错误,所以我们将error
返回值传递给check
。注意,我们不会将error
返回值从http.ListenAndServe
传递给check
。这是因为ListenAndServe
总是返回一个错误。(如果没有错误,ListenAndServe
永远不会返回。)由于我们知道这个错误永远不会是nil
,我们只是立即在其上调用log.Fatal
。
设置项目目录并尝试应用程序
对于这个项目,我们将创建几个文件,因此您可能希望花一点时间创建一个新目录来保存它们。(它不必在您的 Go 工作区目录内。)将前述代码保存在此目录中,文件名为guestbook.go。
让我们试着运行它。在您的终端中,切换到保存guestbook.go的目录,并使用go run
运行它。
然后在浏览器中访问此 URL:
*[
localhost:8080/guestbook](http://localhost:8080/guestbook)*
与先前应用程序的 URL 相同,只是在末尾加上了/guestbook路径。您的浏览器将向应用程序发出请求,应用程序将以我们的占位文本作出响应:
我们的应用现在正在响应请求。我们的第一个任务完成了!
虽然我们只是使用纯文本进行响应。接下来,我们将使用 HTML 格式化我们的响应。
在 HTML 中创建签名列表
到目前为止,我们只是向浏览器发送了一些文本片段。我们需要实际的 HTML,以便对页面应用格式。HTML 使用标签对文本应用格式。
如果您以前没有编写 HTML,不用担心;随着我们的进展,我们将涵盖基础知识!
在与guestbook.go相同的目录中,将下面的 HTML 代码保存在名为view.html的文件中。
这个文件中使用的 HTML 元素如下:
-
<h1>
:一级标题。通常显示为大号加粗文本。 -
<div>
:分割元素。单独使用时不直接可见,但用于将页面分割为各个部分。 -
<p>
:段落文本。我们将每个签名视为独立的段落。 -
<a>
:代表“锚点”。创建一个链接。
现在,让我们尝试在浏览器中查看 HTML。启动您喜欢的网络浏览器,从菜单中选择“打开文件…”,并打开您刚刚保存的 HTML 文件。
注意页面上的元素如何与 HTML 代码对应。每个元素都有一个开放标签(<h1>
,<div>
,<p>
等),以及相应的闭合标签(</h1>
,</div>
,</p>
等)。在开放和闭合标签之间的任何文本将用作页面上元素的内容。元素还可以包含其他元素(就像此页面上的<div>
元素一样)。
如果您愿意,可以单击链接,但现在只会产生“页面未找到”错误。在我们修复这个问题之前,我们需要弄清楚如何通过我们的 Web 应用程序提供此 HTML...
使我们的应用程序响应 HTML
当我们直接从view.html文件加载 HTML 到浏览器时,我们的 HTML 可以正常工作,但我们需要通过应用程序提供它。让我们更新我们的guestbook.go代码以响应我们创建的 HTML。
Go 提供了一个包,可以从文件中加载 HTML 并为我们插入签名:html/template
包。现在,我们将加载view.html的内容,插入签名将是我们的下一步。
我们需要更新import
语句以添加html/template
包。我们需要做的唯一其他更改是在viewHandler
函数内部。我们将调用template.ParseFiles
函数并传递要加载的文件名:"view.html"
。这将使用view.html的内容创建一个Template
值。ParseFiles
将返回指向此Template
的指针,可能还会返回一个error
值,我们将其传递给我们的check
函数。
要从Template
值获取输出,我们调用其Execute
方法并传入两个参数... 我们将我们的ResponseWriter
值传递为写入输出的位置。第二个值是我们想要插入模板中的数据,但由于我们现在不插入任何内容,所以我们只传递nil
。
我们很快将学习更多关于html/template
包的知识,但现在让我们看看这是否有效。在终端中运行guestbook.go。(确保在运行此命令时您在项目目录中,否则ParseFiles
函数将无法找到view.html。)
在浏览器中,返回到以下 URL:
您应该看到来自view.html的 HTML,而不是“签名列表在这里”占位符。
“text/template”包
我们的应用程序正在响应我们的 HTML 代码。这是两个任务完成了!
不过,目前我们只显示了一个硬编码的占位符签名列表。我们下一个任务将是使用html/template
包将签名列表插入到 HTML 中,当列表更改时将更新。
html/template
包基于 text/template
包。你几乎完全相同的方式使用这两个包,但 html/template
添加了一些必要的安全功能,用于处理 HTML。让我们先学习如何使用 text/template
包,稍后再将我们所学的应用到 html/template
包上。
下面的程序使用 text/template
来解析并打印一个模板字符串。它将输出打印到终端,因此您不需要使用浏览器来尝试它。
在 main
函数中,我们调用 text/template
包的 New
函数,它返回一个指向新 Template
值的指针。然后我们在 Template
上调用 Parse
方法,并传递字符串 "Here's my template!\n"
。Parse
使用其字符串参数作为模板的文本,不同于 ParseFiles
从文件加载模板文本。Parse
返回模板和一个 error
值。我们将模板存储在 tmpl
变量中,并将 error
传递给一个 check
函数(与 guestbook.go 中的函数相同),以报告任何非 nil
错误。
然后我们在 tmpl
中调用 Template
值的 Execute
方法,就像在 guestbook.go 中一样。不过,这次我们将 os.Stdout
作为输出位置传递。这会导致程序运行时将 "Here's my template!\n"
模板字符串显示为输出。
使用 io.Writer
接口和模板的 Execute
方法
os.Stdout
值是 os
包的一部分。Stdout
代表“标准输出”。它的行为类似于文件,但将写入到它的任何数据都输出到终端,而不是保存到磁盘。(像 fmt.Println
、fmt.Printf
等函数在后台写入数据到 os.Stdout
。)
http.ResponseWriter
和 os.Stdout
如何都能作为 Template.Execute
的有效参数?让我们查看它的文档...
哦,这里说 Execute
的第一个参数应该是一个 io.Writer
。那是什么?让我们查一下 io
包的文档:
看起来 io.Writer
是一个接口!它可以由任何具有接受 byte
值切片并返回写入的字节数和一个 error
值的 Write
方法满足。
ResponseWriters
和 os.Stdout
都满足 io.Writer
接口。
我们已经看到 http.ResponseWriter
值有一个 Write
方法。我们在几个早期的示例中使用了 Write
:
原来 os.Stdout
值也有一个 Write
方法!如果你向它传递一个 byte
值的切片,这些数据将被写入终端:
这意味着 http.ResponseWriter
值和 os.Stdout
都满足 io.Writer
接口,并且可以传递给 Template
值的 Execute
方法。Execute
方法会调用传递给它的值的 Write
方法来输出模板。
如果你传入一个http.ResponseWriter
,意味着模板将被写入到 HTTP 响应中。如果你传入os.Stdout
,意味着模板将被写入到终端的输出中:
使用动作向模板插入数据
Template
值的Execute
方法的第二个参数允许你传入要插入模板的数据。它的类型是空接口,意味着你可以传入任何类型的值。
到目前为止,我们的模板尚未提供任何插入数据的位置,因此我们一直使用nil
作为数据值:
要在模板中插入数据,你需要在模板文本中添加动作。动作用双花括号{{ }}
表示。在双花括号内部,你可以指定要插入的数据或者模板执行的操作。每当模板遇到一个动作时,它会评估其内容,并将结果插入到模板文本中,取代动作本身。
在动作内部,你可以使用一个句点引用传递给Execute
方法的数据值,称为“dot”。
这段代码设置了一个带有单个动作的模板。然后它多次调用模板的Execute
方法,每次使用不同的数据值。Execute
在将结果写入os.Stdout
之前,将动作替换为数据值。
还有许多其他可以使用模板动作实现的功能。让我们设置一个executeTemplate
函数,让我们更轻松地进行实验。它将接受一个模板字符串,我们将其传递给Parse
以创建新模板,并接受一个数据值,我们将其传递给该模板的Execute
方法。与之前一样,每个模板将被写入到os.Stdout
中。
正如前文提到的,你可以使用一个句点来引用“dot”,即模板正在处理的数据中的当前值。虽然句点的值在模板内的不同上下文中可能会改变,但最初它指的是传递给Execute
的值。
使用“if”动作使模板的部分内容成为可选项
在{{if}}
动作及其对应的{{end}}
标记之间的模板部分仅在条件为真时才会包含。在此示例中,我们执行相同的模板文本两次,一次是在dot
为true
时,一次是在dot
为false
时。由于{{if}}
动作的存在,只有在dot
为true
时,“Dot is true!”文本才会包含在输出中。
使用“range”动作重复模板的部分内容
在{{range}}
动作及其对应的{{end}}
标记之间的模板部分将根据数组、切片、映射或通道中收集的每个值重复。该部分内的任何动作也将被重复。
在重复的部分内,dot 的值将设置为集合中的当前元素,允许您将每个元素包含在输出中或对其进行其他处理。
这个模板包括一个{{range}}
动作,将会输出切片中的每个元素。循环之前和之后,dot 的值将是切片本身。但是在循环内部,dot 指的是切片的当前元素。你会在输出中看到这一点。
这个模板处理一个float64
值的切片,它会将其显示为价格列表。
如果提供给{{range}}
动作的值为空或nil
,则循环将不会运行:
使用动作将结构体字段插入模板
当执行模板时,简单类型通常无法保存填充模板所需的各种信息。在这种情况下,使用结构体类型更为常见。
如果 dot 中的值是一个结构体,那么接下来一个带有 dot 和字段名的动作将在模板中插入该字段的值。在这里,我们创建了一个Part
结构体类型,然后设置了一个模板,该模板将输出Part
值的Name
和Count
字段:
最后,在下面我们声明了一个Subscriber
结构体类型和一个打印它们的模板。该模板将无论如何输出Name
字段,但是它使用{{if}}
动作仅在Active
字段设置为true
时才输出Rate
字段。
在这里,你可以使用模板做很多其他事情,我们这里没有足够的空间来覆盖它们所有。要了解更多,请查阅text/template
包的文档:
从文件中读取签名切片
现在我们知道如何将数据插入模板,我们几乎可以将签名插入到访客留言板页面中了。但是首先,我们需要一些可以插入的签名。
在你的项目目录中,保存几行文本到一个名为signatures.txt的纯文本文件中。这些暂时将作为我们的“签名”。
现在我们需要能够将这些签名加载到我们的应用程序中。在guestbook.go中,添加一个新的getStrings
函数。这个函数的工作方式类似于我们在第七章中编写的datafile.GetStrings
函数,它会读取文件并将每一行追加到一个字符串切片中,然后返回这个切片。
但是也有一些区别。首先,新的getStrings
将依赖我们的check
函数来报告错误,而不是直接返回它们。
第二,如果文件不存在,getStrings
将会返回nil
而不是报告错误,而是通过将从os.Open
获取的任何error
值传递给os.IsNotExist
函数来做到这一点,如果错误指示文件不存在,则该函数将返回true
。
我们还将对viewHandler
函数进行小小的更改,添加一个调用getStrings
的调用以及一个临时的fmt.Printf
调用,以显示从文件加载的内容。
让我们试试getStrings
函数。在您的终端中,切换到项目目录,并运行guestbook.go。在浏览器中访问localhost:8080/guestbook
,这样就会调用viewHandler
函数。它将调用getStrings
,后者将加载并返回包含signatures.txt内容的切片。
没有愚蠢的问题
Q:如果signatures.txt
文件不存在,并且getStrings
返回nil
,会导致渲染模板时出现问题吗?
A: 没有必要担心。就像我们已经在append
函数中看到的那样,Go 中的其他函数通常会将nil
切片和映射视为为空。例如,如果传递了nil
切片,len
函数会简单地返回0
:
并且模板操作也将nil
的切片和映射视为为空。正如我们所学的那样,例如,{{range}}
操作如果给定了nil
值,将简单地跳过输出其内容。因此,getStrings
返回一个nil
切片而不是一个切片将是合适的;如果从文件中没有加载任何签名,模板将跳过输出任何签名。
一个结构体来保存签名和签名计数
现在,我们可以将这些签名的切片直接传递给我们的 HTML 模板的Execute
方法,并将签名插入模板中。但是我们还希望我们的主留言簿页面显示接收到的签名的数量,以及签名本身。
我们只能将一个值传递给模板的Execute
方法。因此,我们需要创建一个结构类型,它将包含签名的总数以及签名本身的切片。
在guestbook.go文件的顶部附近,添加一个新的声明,用于新的Guestbook
结构类型。它应该有两个字段:一个SignatureCount
字段来保存签名的数量,和一个Signatures
字段来保存签名本身的切片。
现在,我们需要更新viewHandler
函数,以创建一个新的Guestbook
结构,并将其传递给模板。首先,我们不再需要显示signatures
切片内容的fmt.Printf
调用,所以删除它(您还需要从import
部分删除"fmt"
)。然后,创建一个新的Guestbook
值。将其SignatureCount
字段设置为signatures
切片的长度,并将其Signatures
字段设置为signatures
切片本身。最后,我们需要将数据实际传递给模板。因此,将作为Execute
方法第二个参数传递的数据值从nil
更改为我们的新Guestbook
值。
更新我们的模板以包含我们的签名
现在让我们更新 view.html 中的模板文本以显示签名列表。
我们将 Guestbook
结构体传递给模板的 Execute
方法,因此在模板中,点号代表了 Guestbook
结构体。在第一个 div
元素中,用 X total signatures
中的 X
占位符替换为插入 Guestbook
的 SignatureCount
字段的操作:{{.SignatureCount}}
。
第二个 div
元素包含一系列 p
(段落)元素,每个签名对应一个。使用 range
操作循环遍历 Signatures
切片中的每个签名:{{range .Signatures}}
。(不要忘记在 div
元素结束之前加上对应的 {{end}}
标记。)在 range
操作中,包含一个 p
HTML 元素,并在其中输出点号的嵌套内容:<p>{{.}}</p>
。请记住,点号会依次设置为切片中的每个元素,因此这将导致为切片中的每个签名输出一个 p
元素,其内容设置为该签名的文本。
最后,我们可以使用包含数据的模板进行测试!重新启动 guestbook.go 应用程序,并再次在浏览器中访问 localhost:8080/guestbook
。响应应该显示您的模板。顶部应显示总签名数,并且每个签名应出现在其自己的 <p>
元素中!
没有愚蠢的问题
Q: 你提到 html/template
包有一些“安全功能”。它们是什么?
A: text/template
包将值原样插入模板中,无论其包含什么内容。但这意味着访问者可以添加 HTML 代码作为“签名”,并且它将被视为页面 HTML 的一部分。
您可以自行尝试。在 guestbook.go 中,将 html/template
导入更改为 text/template
。(您不需要更改任何其他代码,因为这两个包中所有函数的名称都相同。)然后,在您的 signatures.txt 文件中添加以下内容作为新行:
<script>alert("hi!");</script>
这是一个包含 JavaScript 代码的 HTML 标签。如果你尝试运行该应用并重新加载签名页面,你会看到一个烦人的警报弹出,因为text/template
包直接将这段代码包含在页面中。
现在回到 guestbook.go,将导入改回html/template
,然后重新启动应用。如果重新加载页面,你将在页面中看到与上述脚本标签完全相同的文本。
但这是因为 html/template
包自动“转义”了 HTML,用导致它被视为 HTML 的字符替换为导致它显示在页面文本中的代码(这样是安全的)。以下是实际插入响应的内容:
<script>alert("hi!");</script>
像这样插入脚本标签只是不良用户可以将恶意代码插入到您的网页中的众多方式之一。html/template
包使得防范这种以及许多其他攻击变得简单!
让用户使用 HTML 表单添加数据
又完成了另一个任务。我们接近尾声:只剩下两个任务!
接下来,我们需要允许访客添加他们自己的签名。我们需要创建一个 HTML form,让他们可以输入签名。表单通常提供一个或多个用户可以输入数据的字段,并提供一个提交按钮,让他们可以将数据发送到服务器。
在项目目录中,创建一个名为 new.html 的文件,并包含以下 HTML 代码。这里有一些我们以前没有见过的标签:
-
<form>
: 此元素包含所有其他表单组件。 -
<input>
的type
属性为"text"
: 用户可以输入字符串的文本字段。它的name
属性将用于标记发送到服务器数据中的字段值(类似于映射键)。 -
<input>
的type
属性为"submit"
: 创建一个用户可以点击以提交表单数据的按钮。
如果我们在浏览器中加载此 HTML,它将如下所示:
响应 HTML 表单
我们在 view.html 中已经有一个指向 /guestbook/new 路径的“添加您的签名”链接。单击此链接将带您到同一服务器上的新路径,所以这就像在输入此 URL 一样:
但是当前访问此路径只会响应错误“404 页面未找到”。我们需要设置应用程序,在用户点击链接时响应 new.html 中的表单。
在 guestbook.go 中,添加一个 newHandler
函数。它将类似于我们的 viewHandler
函数的早期版本。与 viewHandler
一样,newHandler
应该接受一个 http.ResponseWriter
和一个 http.Request
的指针作为参数。它应该对 new.html 文件调用 template.ParseFiles
。然后,它应该调用 Execute
在生成的模板上,以便 new.html 的内容被写入 HTTP 响应。我们不会向此模板插入任何数据,因此将 nil
作为调用 Execute
的数据值传递进去。
然后,我们需要确保在点击“添加您的签名”链接时调用 newHandler
函数。在 main
函数中,添加另一个对 http.HandleFunc
的调用,并将 newHandler
设置为路径为 /guestbook/new 的请求的处理函数。
如果我们保存上述代码并重新启动 guestbook.go,然后点击“添加您的签名”链接,我们将被带到 /guestbook/new 路径。将调用 newHandler
函数,该函数将从 new.html 加载我们的表单 HTML 并包含在响应中。
表单提交请求
我们又完成了另一个任务。就剩一个了!
当有人访问/guestbook/new路径时,无论是直接输入还是点击链接,我们都会显示一个用于输入签名的表单。但如果你填写该表单并点击提交,将不会发生任何有用的事情。
浏览器将会再次请求/guestbook/new路径。"signature"
表单字段的内容将作为一个看起来不好看的参数添加到 URL 的末尾。因为我们的newHandler
函数不知道如何处理表单数据,所以它将被简单丢弃。
我们的应用可以响应请求以显示表单,但没有办法将表单数据提交回应用程序。在我们能保存访客签名之前,我们需要解决这个问题。
表单提交的路径和 HTTP 方法
实际上,提交表单需要向服务器发送两个请求:一个用于获取表单,另一个用于发送用户的输入数据回服务器。让我们更新表单的 HTML 以指定第二个请求应该发送到何处以及如何发送。
编辑new.html,并向form
元素添加两个新的 HTML 属性。第一个属性action
将指定提交请求的路径。我们不会让路径默认回到/guestbook/new,而是指定一个新路径:/guestbook/create。
我们还需要第二个名为method
的属性,其值应为"POST"
。
需要对这个method
属性进行一点解释... HTTP 定义了几种请求可以使用的方法。虽然这些不同于 Go 值上的方法,但意义类似。GET 和 POST 是最常见的方法之一。
-
GET:当您的浏览器需要从服务器获取某些内容时使用,通常是因为您输入了一个 URL 或点击了一个链接。这可以是 HTML 页面、图像或其他资源。
-
POST:当您的浏览器需要向服务器添加一些数据时使用,通常是因为您提交了带有新数据的表单。
我们正在向服务器添加新数据:一个新的访客留言签名。所以看起来我们应该使用 POST 请求提交数据。
尽管如此,默认情况下表单使用 GET 请求提交。这就是为什么我们需要向form
元素添加一个值为"POST"
的method
属性的原因。
现在,如果我们重新加载/guestbook/new页面并重新提交表单,请求将使用路径/guestbook/create。我们会得到一个“404 页面找不到”错误,但这是因为我们还没有为/guestbook/create路径设置处理程序。
表单数据现在不再附加在 URL 的末尾。这是因为表单是通过 POST 请求提交的。
从请求中获取表单字段的值
现在我们正在使用 POST 请求提交表单,表单数据嵌入在请求本身中,而不是作为参数附加到请求路径中。
让我们解决一下当表单数据提交到/guestbook/create路径时出现的“404 页面未找到”错误。在此过程中,我们还将看到如何从 POST 请求中访问表单数据。
像往常一样,我们将通过添加请求处理函数来完成这项工作。在guestbook.go的main
函数中,调用http.HandleFunc
,并将路径为"/guestbook/create"
的请求分配给一个新的createHandler
函数。
然后添加createHandler
函数本身的定义。它应该接受一个http.ResponseWriter
和一个指向http.Request
的指针,就像其他处理函数一样。
与其他处理函数不同,createHandler
旨在处理表单数据。可以通过传递给处理程序函数的http.Request
指针访问该数据。(是的,在忽略了这么长时间的http.Request
值后,我们终于可以使用一个了!)
现在,让我们先查看请求包含的数据。在http.Request
上调用FormValue
方法,并传递字符串"signature"
。这将返回一个包含"signature"
表单字段值的字符串。将其存储在名为signature
的变量中。
让我们将字段值写入响应中,以便在浏览器中查看。在http.ResponseWriter
上调用Write
方法,并将signature
传递给它(但首先要将其转换为字节片)。与往常一样,Write
将返回写入的字节数和一个error
值。我们将通过将其赋值为_
来忽略字节数,并对error
调用check
。
让我们看看我们的表单提交是否成功到达了createHandler
函数。重新启动guestbook.go,访问/guestbook/new页面,然后再次提交表单。
您将被带到/guestbook/create路径,而不是显示“404 页面未找到”错误,应用程序将用您在"signature"
字段中输入的值作出响应!
如果愿意,可以点击浏览器的后退按钮返回/guestbook/new页面,并尝试不同的提交。无论输入什么内容,都将回显到浏览器中。
为 HTML 表单提交设置处理程序是一个重要的步骤。我们正在接近!
保存表单数据
我们的createHandler
函数正在接收包含表单数据的请求,并能从中检索出来宾客签名。现在我们所需要做的就是在createHandler
函数内部将该签名添加到我们的signatures.txt文件中。我们将在createHandler
函数内部处理这个问题。
首先,我们将去掉对ResponseWriter
的Write
方法的调用;我们只需要确认我们可以访问签名表单字段。
现在,让我们添加下面的代码。os.OpenFile
函数以略有不同的方式调用,细节与编写 Web 应用程序无直接关系,因此我们不会在这里完全描述它。(如果您想了解更多信息,请参见 附录 A。)现在,您需要知道的是,此代码执行三个基本操作:
-
它打开了 signatures.txt 文件,如果文件不存在则创建它。
-
它在文件末尾添加一行文本。
-
它关闭文件。
fmt.Fprintln
函数向文件添加一行文本。它接受要写入的文件和要写入的字符串(无需转换为 []byte
)作为参数。就像我们在本章前面看到的 Write
方法一样,Fprintln
返回成功写入文件的字节数(我们忽略),以及遇到的任何错误(我们传递给 check
函数)。
最后,我们在文件上调用 Close
方法。你可能注意到我们没有使用 defer
关键字。这是因为我们正在向文件写入,而不是从中读取。在写入文件后调用 Close
可能会导致错误,我们需要处理这些错误,如果使用 defer
就无法很容易地做到这一点。因此,我们简单地在常规程序流程中调用 Close
,然后将其返回值传递给 check
。
保存前面的代码并重新启动 guestbook.go。在 /guestbook/go 页面上填写并提交表单。
现在你的浏览器会加载 /guestbook/create 路径,这个路径现在显示为完全空白(因为 createHandler
不再向 http.ResponseWriter
写入任何内容)。
但是,如果你查看 signatures.txt 文件的内容,你会看到新的签名保存在末尾!
如果你访问 /guestbook 上的签名列表,你会看到签名数增加了一条,并且新的签名出现在列表中!
HTTP 重定向
我们的 createHandler
函数保存新的签名。还有一件事需要处理。当用户提交表单时,他们的浏览器会加载 /guestbook/create 路径,显示一个空白页面。
在 /guestbook/create 路径上没有有用的内容可供展示;它只是用来接受添加新签名请求的。相反,让我们让浏览器加载 /guestbook 路径,这样用户就可以在访客留言板中看到他们的新签名。
在 createHandler
函数的结尾,我们将添加一个调用 http.Redirect
,它向浏览器发送一个响应,指示其加载与请求的资源不同的资源。Redirect
的前两个参数是 http.ResponseWriter
和 *http.Request
,因此我们将从 createHandler
的 writer
和 request
参数中获取它们的值。然后 Redirect
需要一个字符串,指定将浏览器重定向到的路径;我们将重定向到 "/guestbook"
。
Redirect
的最后一个参数需要是一个状态码,以便向浏览器发送。每个 HTTP 响应都需要包含一个状态码。到目前为止,我们的响应已经自动设置了它们的代码:成功的响应代码为 200(“OK”),对不存在页面的请求代码为 404(“Not found”)。不过,对于Redirect
,我们需要指定一个代码,因此我们将使用常量http.StatusFound
,这将导致重定向响应的状态为 302(“Found”)。
现在我们已经添加了Redirect
的调用,提交签名表单应该像这样工作:
-
浏览器向/guestbook/create路径提交了一个 HTTP POST 请求。
-
应用程序响应并重定向到/guestbook。
-
浏览器发送了一个 GET 请求,用于/guestbook路径。
让我们试试所有功能!
让我们看看重定向是否有效!重新启动guestbook.go,并访问/guestbook/new路径。填写表单并提交。
应用程序将表单内容保存到signatures.txt,然后立即将浏览器重定向到/guestbook路径。当浏览器请求/guestbook时,应用程序将加载更新的signatures.txt文件,并且用户将在列表中看到他们的新签名!
我们的应用程序正在保存从表单提交的签名,并与所有其他签名一起显示。我们的所有功能都已完成。
需要很多组件才能使所有这些工作正常运行,但现在您拥有了一个可用的 Web 应用程序!
我们的完整应用程序代码
我们的应用程序代码已经变得如此之长,我们只能逐步查看它。让我们再花一点时间将所有代码放在一起看看吧!
guestbook.go文件占据了应用程序代码的大部分。(在一个旨在广泛使用的应用程序中,我们可能已将一些此代码拆分为多个包和源文件,位于我们的 Go 工作区目录中,如果您愿意,您也可以这样做。)我们已经浏览并添加了对Guestbook
类型和每个函数的注释。
view.html文件为签名列表提供 HTML 模板。模板操作提供了插入签名数量以及整个签名列表的位置。
new.html文件只是包含用于新签名的 HTML 表单。不会向其中插入任何数据,因此不存在模板操作。
就是这样——一个完整的 Web 应用程序,可以存储用户提交的签名,并稍后再次检索它们!
编写 Web 应用程序可能很复杂,但net/http
和html/template
包利用 Go 的力量使整个过程对您来说更加简单!
您的 Go 工具箱
这就是第十六章的全部内容!您已经将模板添加到了您的工具箱中。
第十七章:恭喜!你成功地到达了结尾。
当然,还有两个附录。
还有索引。
然后还有这个网站...
实际上,没有逃避的余地。
第十八章:这不是告别
**带上你的大脑
前往headfirstgo.com***
附录 A. 理解 os.openfile:打开文件
- 有些程序需要向文件写入数据,而不仅仅是读取数据。 在本书中,当我们想要处理文件时,你必须在文本编辑器中创建它们以供你的程序读取。但有些程序会生成数据,当它们这样做时,它们需要能够写入文件。
本书前面曾使用 os.OpenFile
函数来打开文件进行写入。但当时我们没有足够的空间来详细探讨它的工作原理。在这个附录中,我们将展示你在使用 os.OpenFile
时需要了解的一切!
理解 os.OpenFile
在第十六章中,我们必须使用 os.OpenFile
函数来打开一个文件进行写入,这要求一些看起来相当奇怪的代码:
当时,我们专注于编写 web 应用,所以我们没有太多时间来充分解释 os.OpenFile
。但你在编写 Go 代码时几乎肯定会再次用到这个函数,因此我们添加了这个附录来更仔细地研究它。
当你试图弄清楚一个函数如何工作时,最好从它的文档开始。在你的终端中运行 go doc os OpenFile
(或者在浏览器中搜索 "os"
包的文档)。
它的参数是一个 string
文件名,一个 int
“flag”,以及一个 os.FileMode
“perm”。很明显,文件名只是我们要打开的文件的名称。让我们先弄清楚这个“flag”意味着什么,然后再回头看 os.FileMode
。
为了帮助保持附录中的代码示例简短,假设我们的所有程序都包含一个 check
函数,就像我们在第十六章中展示的那样。它接受一个 error
值,检查是否为 nil
,如果不是,则报告错误并退出程序。
向 os.OpenFile
传递 flag 常量
描述中提到,flag 的一个可能值是 os.O_RDONLY
。让我们查一下它的含义是什么...
根据文档,os.O_RDONLY
是几个 int
常量之一,用于传递给 os.OpenFile
函数,这些常量会改变函数的行为。
让我们尝试使用一些这些常量调用 os.OpenFile
,看看会发生什么。
首先,我们需要一个要处理的文件。创建一个只有一行文本的纯文本文件。你可以将其保存在任何目录下,命名为 aardvark.txt。
然后,在相同的目录中,创建一个包含前一页的check
函数和以下main
函数的 Go 程序。在main
中,我们使用os.O_RDONLY
常量作为第二个参数调用os.OpenFile
。(现在忽略第三个参数;稍后我们会讨论它。)然后我们创建一个bufio.Scanner
并使用它来打印文件的内容。
在你的终端中,切换到保存了aardvark.txt文件和你的程序的目录,并使用go run
来运行程序。它会打开aardvark.txt并输出其内容。
现在让我们尝试写入文件。更新你的main
函数如下所示。(你还需要从import
语句中删除未使用的包。)这次,我们将os.O_WRONLY
常量传递给os.OpenFile
,以便它打开文件进行写入。然后,我们将调用文件的Write
方法,并传入要写入文件的字节片段。
如果运行程序,不会产生任何输出,但会更新aardvark.txt文件。但如果打开aardvark.txt,会发现程序不是在文件末尾追加文本,而是覆盖了文件的一部分!
这不是我们希望程序工作的方式。我们能做些什么?
哦,os
包中还有一些其他可能会有所帮助的常量。这包括一个os.O_APPEND
标志,应该会使程序将数据追加到文件末尾,而不是覆盖它。
但你不能单独将os.O_APPEND
传递给os.OpenFile
;如果尝试,会收到错误。
文档中提到了os.O_APPEND
和os.O_CREATE
“可以进行或操作”。这是指二进制或运算符。我们需要花几页的时间来解释它是如何工作的……
二进制表示法
在最低级别上,计算机必须使用简单的开关来表示信息,这些开关可以是开或关。如果使用一个开关来表示一个数字,你只能表示值0
(开关“关闭”)或1
(开关“打开”)。计算机科学家称之为比特。
如果将多个位组合起来,就能表示更大的数。这就是二进制表示法的思想。在日常生活中,我们最熟悉的是十进制表示法,它使用数字 0 到 9。但是二进制表示法只使用数字 0 和 1 来表示数值。
注意
(如果你想了解更多,请在你喜爱的网络搜索引擎中输入“二进制”。)
你可以使用fmt.Printf
和%b
格式化动词查看各种数值的二进制表示(这些数值由位组成):
位运算符
我们已经看到了+
、-
、*
和/
等运算符,它们允许你对整数进行数学运算。但 Go 语言还有按位运算符,允许你操作一个数字由哪些位组成。其中两个最常见的是按位与运算符&
和按位或运算符|
。
运算符 | 名称 |
---|---|
& |
按位与 |
` | ` |
按位与运算符
我们已经看到了&&
运算符。它是一个布尔运算符,只有在其左边和右边的值都为true
时才返回true
:
然而,&
运算符(只有一个和号)是一个按位运算符。它仅在其左侧值和右侧值的对应位都为1
时才将该位设置为1
。对于只需一个位表示的数字0
和1
来说,这是相当直接的:
然而,对于更大的数字,这可能看起来像是胡言乱语!
只有当你查看单个位的值时,按位操作才有意义。&
运算符仅在左侧数字和右侧数字中相同位置的位都为1
时,结果中的位才设置为1
。
对于任意大小的数字都是如此。用于&
运算符的两个值的位决定了结果值中相同位置的位。
按位或运算符
我们还看到了||
运算符。它是一个布尔运算符,如果其左侧值或右侧值为true
,则返回true
。
|
运算符会在结果中的位上设置为1
,如果其左侧值或右侧值的相应位有一个为1
。
就像按位与运算一样,按位或运算符也会查看它操作的两个值在特定位置的位,以决定结果中相同位置的位的值。
对于任意大小的数字都是如此。用于|
运算符的两个值的位决定了结果值中相同位置的位。
在“os”包常量上使用按位或
我们向你展示所有这些是因为你将需要使用按位或运算符来组合这些常量值!
当文档说os.O_APPEND
和os.O_CREATE
值可以与os.O_RDONLY
、os.O_WRONLY
或os.O_RDWR
值进行“或操作”时,意味着你应该在它们上面使用按位或运算符。
在幕后,这些常量实际上都是int
值:
如果我们看一下这些值的二进制表示,我们会发现每个值只有一个位为1
,而其他位都为0
:
这意味着我们可以使用位或运算符结合这些值,而这些位不会互相干扰:
os.OpenFile
函数可以检查第一位是否为1
,以确定文件是否应为仅写入。如果第七位为1
,OpenFile
将知道在需要时创建文件。如果第十一位为1
,OpenFile
将在文件末尾追加。
使用位或修复我们的 os.OpenFile 选项
之前,当我们只传递os.O_WRONLY
选项给os.OpenFile
时,它覆盖了文件中已有数据的部分。让我们看看是否可以结合选项,使其追加新数据到文件末尾。
首先编辑aardvark.txt文件,使其再次只包含一行。
接下来,更新我们的程序,使用位或运算符将os.O_WRONLY
和os.O_APPEND
常量值组合成一个单独的值。将结果传递给os.OpenFile
。
再次运行程序并查看文件的内容。你应该看到新添加的文本行追加到末尾。
让我们还尝试使用os.O_CREATE
选项,这会导致os.OpenFile
在文件不存在时创建指定的文件。首先删除aardvark.txt文件。
现在更新程序,将os.O_CREATE
添加到传递给os.OpenFile
的选项中。
当我们运行程序时,它将创建一个新的aardvark.txt文件,然后将数据写入其中。
类 Unix 风格的文件权限
我们一直在关注os.OpenFile
的第二个参数,它控制文件的读取、写入、创建和追加。到目前为止,我们忽略了第三个参数,它控制文件的权限:即在程序创建文件后,哪些用户将被允许读取和写入该文件。
当开发人员谈论文件权限时,他们通常指的是类似 macOS 和 Linux 等类 Unix 系统上实现的权限。在 Unix 下,用户对文件可以有三种主要权限:
缩写 | 权限 |
---|---|
r |
用户可以读取文件的内容。 |
w |
用户可以写入文件的内容。 |
x |
用户可以执行文件。 (这仅适用于包含程序代码的文件。) |
如果用户对文件没有读取权限,例如,他们运行试图访问文件内容的任何程序将从操作系统获取错误:
如果用户对文件没有执行权限,他们将无法执行其中包含的任何代码。(不包含可执行代码的文件应不标记为可执行,因为试图运行它们可能会产生不可预测的结果。)
使用os.FileMode
类型表示权限
Go 语言的os
包使用FileMode
类型来表示文件权限。如果文件不存在,你在调用os.OpenFile
时传递给FileMode
的值将决定文件创建时的权限,从而决定用户对文件的访问权限。
FileMode
值有一个String
方法,所以如果你将FileMode
传递给fmt.Println
等fmt
包中的函数,你将得到该值的特殊字符串表示。该字符串显示了FileMode
表示的权限,格式类似于你可能在 Unix 的ls
实用程序中看到的格式。
fmt.Println(os.FileMode(0700))
注意
(如果你想了解更多信息,请在搜索引擎中搜索“Unix 文件权限”)
每个文件有三组权限,分别影响三类不同的用户。第一组权限仅适用于拥有文件的用户。(默认情况下,你创建的任何文件都属于你自己的用户帐户。)第二组权限适用于文件所分配的用户组。第三组权限适用于系统中既不是文件所有者也不是文件分配组的其他用户。
FileMode
的底层类型是uint32
,表示“32 位无符号整数”。这是一个我们之前没有讨论过的基本类型。因为它是无符号的,所以它不能表示任何负数,但是它可以在它的 32 位内存中表示比它能表示的更大的数值。
由于FileMode
基于uint32
,你可以使用类型转换将(几乎)任何非负整数转换为FileMode
值。但是结果可能有点难以理解:
八进制表示法
实际上,指定整数转换为FileMode
值时使用八进制表示法会更容易。我们已经看过十进制表示法,使用了 10 个数字:0 到 9。我们也看过二进制表示法,只使用了两个数字:0 和 1。八进制表示法使用了八个数字:0 到 7。
你可以使用fmt.Printf
和%o
格式化动词查看各种数字的八进制表示:
与二进制表示法不同,Go 语言允许你在程序代码中使用八进制表示法写入数字。任何以0
开头的数字序列都会被视为八进制数。
如果你没有做好准备,这可能会让人感到困惑。十进制的10
和八进制的010
完全不同,十进制的100
和八进制的0100
也完全不同!
在八进制数中只有数字 0 到 7 是有效的。如果包含了 8 或 9,你将会得到编译错误。
将八进制值转换为 FileMode 值
那么为什么要使用这种(可以说有些奇怪的)八进制表示法来表示文件权限呢?因为八进制数的每一位可以用内存中的 3 位来表示:
三个位也正是存储一个用户类别(“用户”、“组”或“其他”)权限所需的确切数据量。您需要的任何用户类别的权限组合都可以用一个八进制数字表示!
注意下面的八进制数的二进制表示与相同数字的FileMode
转换之间的相似性。如果二进制表示中的某位为1
,则对应的权限被启用。
正因如此,Unix 的chmod
实用程序(简称“change mode”)已经几十年来使用八进制数字设置文件权限。
八进制数字 | 权限 |
---|---|
0 |
没有权限 |
1 |
执行 |
2 |
写入 |
3 |
写入、执行 |
4 |
读取 |
5 |
读取、执行 |
6 |
读取、写入 |
7 |
读取、写入、执行 |
Go 对八进制表示法的支持允许您在代码中遵循相同的约定!
对 os.OpenFile 的调用,解释
现在我们理解了位操作符和八进制表示法,我们终于能够理解os.OpenFile
的调用所做的事情!
例如,这段代码将在现有的日志文件中追加新数据。拥有该文件的用户将能够从中读取和写入文件。所有其他用户只能从中读取。
并且这段代码将在文件不存在时创建一个文件,然后将数据追加到文件中。生成的文件将由其所有者可读和可写,但其他用户将无法访问它。
没有蠢问题
Q: 八进制表示法和位操作符太麻烦了!为什么要这样做?
A: 为了节省计算机内存!处理文件的这些约定根源于 Unix,在那时 RAM 和磁盘空间都更小更昂贵。但即使现在,当硬盘可以包含数百万个文件时,将文件权限压缩到几个位而不是几个字节可以节省大量空间(并使您的系统运行更快)。相信我们,这种努力是值得的!
Q: FileMode
字符串开头的那个额外的破折号是什么?
A: 该位置上的破折号表示文件只是普通文件,但它可以显示几个其他值。例如,如果FileMode
值表示一个目录,那么它将是d
。
附录 B. 我们没有涵盖的六件事:剩余内容
我们已经覆盖了很多内容,你离完成这本书只剩一点了。 我们会想念你,但在让你离开去面对这个世界之前,我们觉得还是应该给你一点额外的准备。我们为这个附录留了六个重要的话题。
#1 “if”语句的初始化语句
这里有一个saveString
函数,它返回一个单一的error
值(或者如果没有错误则为nil
)。在我们的main
函数中,我们可能会在处理之前将该返回值存储在一个err
变量中:
现在假设我们在main
函数中添加了另一个对saveString
的调用,也使用了一个err
变量。我们必须记住将第一次对err
的使用作为短变量声明,并将后续的使用改为赋值语句。否则,由于尝试重新声明变量,我们将会得到编译错误。
但实际上,我们只在if
语句及其块内使用err
变量。如果有一种方法可以限制变量的作用域,使得我们可以将每个出现视为一个单独的变量,那该有多好呢?
还记得我们在第二章中首次介绍for
循环时吗?我们说它们可以包含初始化语句,用于初始化变量。那些变量只在for
循环的块内部有效。
类似于for
循环,Go 语言允许在if
语句中的条件之前添加初始化语句。初始化语句通常用于初始化一个或多个变量,以便在if
块中使用。
在初始化语句内声明的变量的作用域仅限于if
语句的条件表达式及其块中。如果我们重新编写之前的示例以使用if
初始化语句,每个err
变量的作用域将限制在if
语句的条件和块内,这意味着我们将有两个完全独立的err
变量。我们不需要担心哪个变量是首次定义的问题。
这种对范围的限制两面性很明显。如果一个函数有多个返回值,并且你需要其中一个在if
语句内部和一个在外部,你可能无法在if
初始化语句中调用它。如果尝试这样做,你会发现你需要的值在if
块外部是不在范围内的。
相反,你需要在if
语句之前像往常一样调用函数,以便它的返回值在if
语句内部和外部都在范围内:
#2 switch
语句
当你根据表达式的值来执行多种操作时,会导致一堆if
语句和else
子句的混乱。switch
语句是表达这些选择的更高效方式。
你写下switch
关键字,后面跟上条件表达式。然后你可以添加几个case
表达式,每个都是条件表达式可能具有的值。第一个与条件表达式匹配的case
被选择,并且运行其包含的代码。其他的case
表达式被忽略。你也可以提供一个default
语句,如果没有case
匹配,则运行该语句。
这是我们在第十二章中使用if
和else
语句编写的代码示例的重新实现。这个版本需要的代码明显更少。对于我们的switch
条件,我们从1
到3
中选择一个随机数。我们为每个值提供了case
表达式,每个表达式打印不同的消息。为了提醒我们理论上不可能的情况,即没有一个case
匹配,我们还提供了一个default
语句,它会导致 panic。
没有愚蠢的问题
Q: 我看过其他语言,那里你必须在每个case
的末尾提供“break”语句,否则会运行下一个case
的代码。Go 语言不需要这样吗?
A: 开发人员在其他语言中经常忘记“break”语句,导致 bug。为了避免这种情况,Go 语言会在case
代码的末尾自动退出switch
语句。
有一个fallthrough
关键字,你可以在一个case
中使用,如果你确实希望继续执行下一个case
的代码。
#3 更多基本类型
Go 语言还有其他基本类型我们没有讨论到。你可能不会在自己的项目中使用这些类型,但在一些库中会遇到,所以最好知道它们的存在。
类型 | 描述 |
---|---|
int8 int16 int32 int64 |
这些类型与int 一样,但它们在内存中有特定的大小(类型名称中的数字指定了位数)。较少的位数消耗更少的 RAM 或其他存储空间;更多的位数意味着可以存储更大的数值。除非有特定原因需要使用其中之一,否则应该使用int ,这样更高效。 |
uint |
这与int 类似,但它只能存储无符号整数;不能存储负数。这意味着你可以在相同内存空间中存储更大的数值,前提是你确保这些值永远不会是负数。 |
uint8 uint16 uint32 uint64 |
这些也是无符号整数,但像int 变体一样,在内存中占用特定位数。 |
float32 |
float64 类型表示浮点数,占用 64 位内存。这是其更小的 32 位版本。(浮点数没有 8 位或 16 位的变体。) |
#4 关于符文的更多信息
我们在第一章中简要介绍了符文,之后就没有再详细讨论了。但是我们不希望结束本书而不多谈一点关于它们的细节...
在现代操作系统出现之前,大多数计算都是使用没有重音的英文字母表完成的,包括其大小写形式在内共 26 个字母。由于数量太少,每个字符可以由单个字节表示(还有 1 位空余)。一个名为 ASCII 的标准被用来确保在不同系统上相同的字节值转换为相同的字母。
当然,英文字母表并不是世界上唯一的书写系统;还有许多其他书写系统,有些拥有数千个不同的字符。Unicode 标准尝试创建一组可以表示每个不同书写系统中的每个字符(以及许多其他字符)的 4 字节 值。
Go 使用 rune
类型的值来表示 Unicode 值。通常,一个 rune
表示一个字符。(当然,也有例外情况,但这超出了本书的范围。)
Go 使用 UTF-8,这是一种标准,用于表示每个 Unicode 字符,每个字符使用 1 至 4 个字节。旧的 ASCII 集中的字符仍然可以使用单个字节表示;其他字符可能需要 2 至 4 个字节。
这里有两个字符串,一个是包含英文字母的,另一个是包含俄语字母的。
通常,您不需要担心字符存储的详细信息。也就是说,直到 您尝试将字符串转换为其组成的字节并返回。例如,如果我们尝试使用 len
函数来调用我们的两个字符串,则会得到非常不同的结果:
当您将字符串传递给 len
函数时,它会返回 字节 长度,而不是 rune 长度。英文字母表字符串适合 5 个字节 - 每个 rune
仅需要 1 个字节,因为它来自旧的 ASCII 字符集。但俄语字母表字符串却需要 10 个字节 - 每个 rune
需要 2 个字节来存储。
如果您想获得字符串的 字符 长度,您应该使用 unicode/utf8
包的 RuneCountInString
函数。此函数将返回正确的字符数,无论用于存储每个字符的字节数。
安全地处理部分字符串意味着将字符串转换为
rune
,而不是字节。
在本书之前,我们不得不将字符串转换为字节切片,以便将它们写入 HTTP 响应或终端。只要确保写入结果切片中的 所有 字节,这种方法就可以正常工作。但如果你试图处理结果切片中的 部分 字节,那就会麻烦不断。
下面是一些试图从前面字符串中剥离前三个字符的代码。我们将每个字符串转换为字节切片,然后使用切片操作符收集从第四个元素到切片末尾的所有内容。然后,我们将部分字节切片再转换回字符串并打印出来。
这在处理英文字母字符时效果很好,每个字符占用 1 字节。但是俄语字符每个占用2 字节。截取该字符串的前 3 个字节只会省略第一个字符和第二个字符的“一半”,导致一个不可打印字符。
Go 支持将字符串转换为rune
值的切片,并从 rune 的切片转换回字符串。要处理部分字符串,应将其转换为rune
值的切片,而不是字节值的切片。这样,您就不会意外地只抓取 rune 的部分字节。
这是一个更新后的代码,将字符串转换为rune
片段而不是字节片段。我们的切片操作现在省略了每个切片的前三个rune,而不是前3 个字节。当我们将部分切片转换为字符串并打印它们时,我们只得到每个切片的最后两个(完整的)字符。
如果尝试使用字节片段处理字符串中的每个字符,会遇到类似的问题。只要您的字符串都是 ASCII 字符集中的字符,逐字节处理就可以。但是一旦出现需要 2 个或更多字节的字符,您将再次发现自己只处理了部分 rune 的字节。
此代码使用for ... range
循环打印英文字母字符,每个字符 1 字节。然后尝试对俄文字母字符执行相同操作,每个字符 1 字节——但失败了,因为每个字符都需要 2 字节。
在 Go 中,您可以在字符串上使用for...range
循环,它将逐个处理rune,而不是每次处理一个字节。这是一种更安全的方法。您提供的第一个变量将被分配为字符串中当前字节的索引(而不是 rune 的索引)。第二个变量将被分配为当前 rune。
这是上述代码的更新版本,使用for...range
循环处理字符串本身,而不是它们的字节表示。您可以从输出中看到,处理英文字符时每次处理 1 字节,但处理俄文字符时每次处理2 字节。
Go 的 rune 使得在处理部分字符串时非常方便,并且无需担心它们是否包含 Unicode 字符。只需记住,每当您想处理字符串的一部分时,请将其转换为 rune,而不是字节!
#5 有缓冲通道
Go 通道有两种类型:无缓冲和有缓冲。
到目前为止,我们向您展示的所有通道都是无缓冲的。当一个 goroutine 在无缓冲通道上发送一个值时,它立即阻塞,直到另一个 goroutine 接收该值。另一方面,有缓冲通道可以在导致发送 goroutine 阻塞之前保存一定数量的值。在适当的情况下,这可以提高程序的性能。
创建通道时,可以通过向make
传递第二个参数来创建带缓冲的通道,该参数指定通道应在其缓冲区中能够容纳的值的数量。
当一个 goroutine 通过通道发送一个值时,该值被添加到缓冲区中。发送 goroutine 不会阻塞,而是继续运行。
发送 goroutine 可以继续向通道发送值,直到缓冲区满为止;只有在额外的发送操作导致 goroutine 阻塞时才会发生。
当另一个 goroutine 从通道接收一个值时,它从缓冲区中提取最早添加的值。
添加额外的接收操作将继续清空缓冲区,而添加额外的发送操作将重新填充缓冲区。
让我们尝试运行一个具有非缓冲通道的程序,然后将其更改为具有缓冲通道,以便您可以看到区别。下面,我们定义一个sendLetters
函数作为一个 goroutine 运行。它向一个通道发送四个值,每个值之间睡眠 1 秒。在main
中,我们创建一个非缓冲通道并将其传递给sendLetters
。然后我们让main
goroutine 睡眠 5 秒。
当main
goroutine 醒来时,它从通道接收四个值。但sendLetters
goroutine 被阻塞,等待main
接收第一个值。因此,main
goroutine 必须在每个剩余值之间等待 1 秒,直到sendLetters
goroutine 赶上。
我们可以通过向通道添加单值缓冲区简单地加快程序运行速度。
当调用make
时,只需添加第二个参数。与通道的交互除此之外完全相同,因此我们不必对代码进行其他更改。
当sendLetters
向通道发送其第一个值时,它不会阻塞,直到main
goroutine 接收它。发送的值放在通道的缓冲区中。只有当发送第二个值时(而尚未接收任何值),通道的缓冲区被填满,sendLetters
goroutine 才会阻塞。向通道添加一个单值缓冲区可以减少程序运行时间 1 秒。
将缓冲区大小增加到3
允许sendLetters
goroutine 在不阻塞的情况下发送三个值。它在最后一个发送时阻塞,但这是在其所有 1 秒的Sleep
调用完成之后。因此,当main
goroutine 在 5 秒后唤醒时,它立即接收缓冲通道中等待的三个值,以及导致sendLetters
阻塞的值。
这使得程序仅需 5 秒完成!
#6 进一步阅读
这是本书的结尾。但这只是你作为 Go 程序员旅程的开端。我们想推荐一些资源,这些资源将帮助你在道路上前行。
Head First Go 网站
这本书的官方网站。在这里,你可以下载我们所有的代码示例,练习额外的练习,并学习关于新主题的知识,所有内容都以同样易读且极具幽默感的文风编写!
Go 语言之旅
这是关于 Go 基本特性的互动教程。它涵盖了与本书大部分相同的内容,但包括一些额外的细节。在 Tour 中的示例可以直接在浏览器中编辑和运行(就像在 Go Playground 中一样)。
Effective Go
golang.org/doc/effective_go.html
由 Go 团队维护的关于如何编写符合社区约定的惯用 Go 代码的指南。
Go 博客
官方的 Go 博客。提供有关使用 Go 的有用文章以及新版本和功能的公告。
包文档
所有标准包的文档。这些文档与go doc
命令提供的相同,但所有库都方便地列在一个列表中供浏览。encoding/json
、image
和 io/ioutil
包可能是开始的有趣地方。
Go 语言编程
这本书是这个页面上唯一不免费的资源,但是它是值得的。它众所周知且广泛使用。
有两种技术书籍:教程类书籍(比如你手上的这本)和参考书籍(像Go 语言编程)。而这是一个很好的参考书籍:它涵盖了我们在这本书中没有空间的所有主题。如果你打算继续使用 Go,这本书是必读的。