嗨翻-Go-全-

嗨翻 Go(全)

原文:zh.annas-archive.org/md5/d4b3bcd523a65a8594a1e8a02f7ec08f

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

image

注意

在本节中,我们回答了一个迫切的问题:“那么他们为什么在 Go 书中加入了这个?”

这本书适合谁?

如果你可以对所有这些问题回答“是”:

  1. 你是否有一台带有文本编辑器的计算机?

  2. 你想学习一门使开发快速高效的编程语言吗?

  3. 你喜欢刺激的晚宴谈话胜过枯燥乏味的学术讲座吗?

这本书适合你。

谁应该远离这本书?

如果你可以回答“是”的话,至少有一个

  1. 你完全不懂计算机吗?

    (你不需要是高级用户,但你应该理解文件夹和文件,如何打开终端应用程序,以及如何使用简单的文本编辑器。)

  2. 你是一位忍者摇滚明星开发者,寻找一本参考书籍吗?

  3. 害怕尝试新事物吗?你宁愿拔牙也不愿意将条纹与格子混搭吗?你认为技术书籍如果充满了糟糕的双关语就不可能严肃吗?

本书并不适合你。

image

注意

[市场部的说明:本书适合任何持有有效信用卡的人。]

我们知道你在想什么

怎么可能是一本关于 Go 开发的严肃书籍?”

“这些图形是什么意思?”

“我真的能够这样学习吗?”

我们知道你的大脑在想什么

你的大脑渴望新奇。它总是搜索、扫描,等待一些不寻常的事物。它就是这样构建的,并且帮助你保持生命。

那么你的大脑对你遇到的所有例行、普通、正常事物都做些什么呢?它竭尽所能阻止它们干扰大脑真正的工作——记录重要的事情。它不会费心保存无聊的事物;它们永远不会通过“这显然不重要”的过滤器。

你的大脑如何知道什么是重要的?假设你出去远足一天,一只老虎跳到你面前——你的头脑和身体内会发生什么?

神经元激活。情绪激增。化学物质涌动

这就是你的大脑如何知道……

image

这一定很重要!不要忘记了!

但是想象一下你在家里或图书馆。这是一个安全、温暖、没有老虎的区域。你在学习。准备考试。或者试图学习一些难懂的技术主题,你的老板认为需要一周,最多 10 天。

只有一个问题。你的大脑试图帮你大忙。它试图确保这显然不重要的内容不会占用稀缺的资源。最好的资源应该用来存储那些真正重要的东西。像老虎。像火灾的危险。像你为什么不应该把派对照片发布在 Facebook 页面上。但没有简单的方法告诉你的大脑,“嘿,大脑,非常感谢你,但无论这本书有多无聊,无论我现在在情感里刻度尺上登记得有多少少,我真的让你保留这些东西。”

image

元认知:思考思考的过程

如果你真的想学习,而且想更快更深入地学习,要注意你是如何注意的。思考你的思考。学习你的学习方式。

大多数人在成长过程中并未学习元认知或学习理论课程。我们期望学习,但很少有人我们如何学习。

但我们假设如果你拿着这本书,你真的想学会如何编写 Go 程序。而且你可能不想花很多时间。如果你想运用这本书中的内容,你需要记住你所读的内容。而为了做到这一点,你必须理解它。为了从这本书中或任何一本书或学习经验中获得最大收益,你需要对你的大脑负责。你的大脑对个内容。

诀窍是让你的大脑将你正在学习的新材料视为非常重要的事情。对你的健康至关重要。像老虎一样重要。否则,你将不断与你的大脑进行斗争,因为它竭尽全力防止新内容粘在上面。

image

那么你到底如何让你的大脑把编程看作是一只饥饿的老虎?

有一个缓慢、乏味的方式,还有一个更快、更有效的方式。缓慢的方式是通过纯粹的重复。显然,你知道即使是最枯燥的话题,如果你不断地把同样的东西灌输到你的大脑中,你能够学习和记住。通过足够的重复,你的大脑会说,“对他来说这并重要,但他一直看着同样的东西一次一次一次,所以我想这必须是重要的。”

更快的方法是做任何增加大脑活动的事情,特别是不同类型的大脑活动。前一页的内容是解决方案的重要部分,它们都是已被证明有助于你的大脑运作的事物。例如,研究表明,将单词放在它们描述的图片中(而不是放在页面的其他地方,如标题或正文中)会导致你的大脑尝试理解单词和图片之间的关系,这会导致更多的神经元激活。更多的神经元激活=你的大脑有更多机会理解这是值得关注的事情,并可能记录下来。

对话式风格有助于,因为人们在感知到自己正在进行对话时往往更专注,因为他们期望跟上并保持自己的一部分。令人惊讶的是,你的大脑并不一定在乎“对话”是与你和一本书之间进行的!另一方面,如果写作风格正式而枯燥,你的大脑会把它看作你在一个房间里被动听讲座时的体验。没有必要保持清醒。

但图片和对话风格仅仅是个开始……

这就是我们做的事情

我们使用图片,因为你的大脑对视觉图像更敏感,而不是文本。就你的大脑而言,一张图片确实值千言万语。当文本和图片共同工作时,我们将文本嵌入到图片中,因为当文本在所指的事物内部时,你的大脑处理起来更有效,而不是在说明或正文中的某处。

我们使用冗余性,用不同的方式和不同的媒体类型来表达同样的内容,并涉及多种感官,以增加内容被编码到你大脑的多个区域的机会。

我们以意想不到的方式使用概念和图片,因为你的大脑对新奇事物感兴趣,我们使用带有至少一些 情感 内容的图片和想法,因为你的大脑倾向于关注情绪的生物化学。那些让你感觉到某种情绪的事物更容易记住,即使那种感觉只是一点幽默惊讶兴趣

我们使用了个性化的、对话式的风格,因为当你的大脑认为你正在进行对话而不是 passively listening 时,它会更加关注。即使当你阅读时,你的大脑也会这样做。

我们包含活动,因为当你事情而不是关于事情时,你的大脑更容易学习和记忆。我们制作了具有挑战性但可行的练习,因为这是大多数人喜欢的方式。

我们使用多种学习风格,因为可能更喜欢逐步的步骤,而另一个人则希望先了解大局,还有其他人只想看到一个例子。但无论你自己的学习偏好如何,每个人都会受益于以多种方式呈现相同内容。

我们为你大脑的两侧提供内容,因为你的大脑涉及的越多,你学习和记忆的可能性就越大,你能保持注意力的时间也就越长。因为一侧大脑工作通常意味着给另一侧一个休息的机会,所以你能更长时间地有效学习。

我们包含故事和练习,呈现多个观点,因为当你的大脑被迫进行评估和判断时,它更容易深入学习。

我们包含挑战,包括练习和提出不一定有直接答案的问题,因为你的大脑倾向于在需要付出努力时学习和记忆。想想看——你不可能仅仅通过观察健身房里的人来使你的身体变得健康。但我们尽最大努力确保当你努力工作时,是在正确的事情上。确保你不会浪费额外的神经元来处理难以理解的例子,或是解析难懂、术语繁重或过于简洁的文本。

我们使用了。在故事中,例子,图片等等,因为,呃,是一个人。你的大脑比对事物更关注

这是你可以做的,来驯服你的大脑

所以,我们尽了我们的一份力。剩下的就看你了。这些建议只是一个起点;倾听你的大脑,找出对你有效和无效的方法。尝试新的事物。

image

注意

剪下来,贴在你的冰箱上。

  1. 放慢速度。你理解得越多,需要记忆的就越少。

    不要仅仅阅读。停下来思考。当书问你问题时,不要直接跳到答案。想象有人真的问这个问题。你让大脑深入思考的程度越深,学习和记忆的机会就越大。

  2. 做练习。写下你自己的笔记。

    我们提供这些方法,但如果我们替你做了这些,那就像是让别人帮你做锻炼一样。而且不要仅仅这些练习。用铅笔。有足够的证据表明,在学习过程中进行身体活动可以增加学习效果。

  3. 读“没有愚蠢问题”这本书。

    这意味着所有的内容。它们不是可选的边栏,它们是核心内容的一部分! 不要跳过它们。

  4. 在睡前读这篇文章。或者至少是最后一个具有挑战性的东西。

    学习的一部分(特别是长期记忆的转移)发生在你放下书之后。你的大脑需要独自处理更多时间,进行更多加工。如果你在这个处理时间内加入新的东西,你刚刚学到的一些内容将会丢失。

  5. 大声说出来。

    说话会激活大脑的不同部分。如果你试图理解某事,或者增加记忆它的机会,大声说出来。更好的是,试着向别人大声解释。你会学得更快,而且你可能会发现在阅读时并不知道的想法。

  6. 喝水。大量喝水。

    你的大脑在充足的液体浴中工作效果最好。脱水(即使在你感觉到口渴之前)会降低认知功能。

  7. 听听你的大脑。

    注意你的大脑是否开始过载。如果发现自己开始浅尝辄止或忘记刚刚读过的内容,那么是休息的时候了。一旦超过一定点,通过试图塞更多东西进去来学得更快是不行的,甚至可能会损害学习过程。

  8. 感受一下。

    你的大脑需要知道这很重要。投入到故事中去。为照片编写你自己的标题。对一句糟糕的笑话抱怨仍然比毫无感觉好。

  9. 写很多代码!

    学习开发 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 编译器。然后使用编译器运行以下示例。)

现在让我们试一试吧!

图片

  1. 在你的浏览器中打开play.golang.org。(如果你看到的与截图不太一样,不用担心;这只是说明他们自从印刷本书以来改进了网站!)

  2. 删除编辑区域中的任何代码,并键入以下内容:

    package main
    import "fmt"
    func main() {
         fmt.Println("Hello, Go!")
    }
    
    注意

    别担心,我们将在下一页解释所有这些内容的含义!

  3. 点击格式化按钮,这将根据 Go 约定自动重新格式化你的代码。

  4. 点击运行按钮。

你应该在屏幕底部看到“Hello, Go!”显示。恭喜,你刚刚运行了你的第一个 Go 程序!

翻页,我们将解释刚刚做了什么...

image

这一切都意味着什么?

你刚刚运行了你的第一个 Go 程序!现在让我们来看看这段代码,并弄清楚它实际上意味着什么...

每个 Go 文件都以package子句开头。一个是一组执行类似操作的代码,如格式化字符串或绘制图像。package子句指定了此文件代码将成为其一部分的包的名称。在这种情况下,我们使用特殊的main包,如果要直接运行此代码(通常来自终端),则需要此包。

接下来,几乎每个 Go 文件都有一个或多个import语句。每个文件在其代码可以使用其他包中的代码之前都需要import这些包。一次性加载计算机上所有 Go 代码会导致一个庞大而缓慢的程序,因此你只需指定需要的包来导入它们。

image

每个 Go 文件的最后部分实际上是实际的代码,通常分割成一个或多个函数。一个函数是一组一个或多个代码行,你可以从程序的其他地方调用(运行)。当运行 Go 程序时,它会查找名为main的函数并首先运行它,这就是为什么我们将这个函数命名为main的原因。

典型的 Go 文件布局

你很快会习惯看到这三个部分,按照这个顺序,在你所使用的几乎每个 Go 文件中:

  1. 包子句

  2. 任何import语句

  3. 实际的代码

image

俗话说:“万物有其位,万物有其处。” Go 是一门非常一致的语言。这是一件好事:你经常会发现在项目中找到特定代码的位置,而无需思考!

没有愚蠢的问题

Q: 我的另一种编程语言要求每个语句以分号结束。Go 不需要吗?

A: 在 Go 中,你可以使用分号来分隔语句,但这并非必需(事实上,这通常是不被赞同的)。

Q: 这个格式化按钮是什么?为什么在运行代码之前我们要点击它?

A: Go 编译器配备了一个名为go fmt的标准格式化工具。格式化按钮是go fmt的 Web 版本。

每当你分享你的代码时,其他的 Go 开发者都期望它遵循标准的 Go 格式。这意味着缩进和间距等将以标准的方式格式化,使每个人阅读更加轻松。在其他语言中,这通常通过依赖人们手动根据样式指南重新格式化其代码来实现,但是在 Go 中,你只需运行 go fmt,它就会自动为你修复一切。

我们对为本书创建的每个示例运行了格式化程序,你也应该对你的所有代码运行它!

如果发生了什么错误?

Go 程序必须遵循某些规则,以避免使编译器混淆。如果我们违反其中一个规则,将会收到错误消息。

假设我们忘记在第 6 行对 Println 函数的调用中添加括号。

如果我们尝试运行程序的这个版本,会得到一个错误:

imageimage

Go 告诉我们我们需要转到哪个源代码文件和行号以便我们可以修复问题。(Go Playground 在运行之前会将你的代码保存到一个临时文件中,这就是 prog.go 文件名的来源。)然后它会给出错误的描述。在这种情况下,因为我们删除了括号,Go 无法知道我们正在尝试调用 Println 函数,因此它无法理解为什么我们要在第 6 行的末尾放置 "Hello, Go"

打破东西是教育性的!

image

我们可以通过故意在不同的方式中断我们的程序来了解 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 包的一部分,因此在函数调用前需要包名。

让我们以第一个作为例子...

image

调用函数

我们的示例包含对 fmt 包的 Println 函数的调用。要调用一个函数,输入函数名(本例中为 Println),然后加上一对括号。

imageimage

像许多函数一样,Println可以接受一个或多个参数:你希望函数处理的值。参数出现在函数名称后的括号中。

image

Println可以不带参数调用,也可以提供多个参数。稍后我们会看到,大多数函数需要特定数量的参数。如果提供的参数太少或太多,会出现错误消息,说明期望的参数数量,需要修正代码。

Println函数

当你需要查看程序正在执行的操作时,请使用Println函数。传递给它的任何参数将在您的终端中打印(显示),每个参数由空格分隔。

打印所有参数后,Println将跳到新的终端行。(这就是其名称末尾有“ln”的原因。)

image

使用其他包中的函数

我们第一个程序中的代码都属于main包,但Println函数位于fmt包中。(fmt代表“格式”)为了能够调用Println,我们必须首先导入包含它的包。

image

导入包后,我们可以通过输入包名、一个点和我们想要的函数名称访问它提供的任何函数。

image

这是一个调用其他包中函数的代码示例。因为我们需要导入多个包,我们切换到一种允许在import语句中列出多个包的备用格式,每行一个包名。

image

导入了mathstrings包之后,我们可以使用math.Floor访问math包的Floor函数,使用strings.Title访问strings包的Title函数。

您可能已经注意到,尽管在我们的代码中包含了这两个函数调用,上面的示例却没有显示任何输出。接下来我们将看看如何修复这个问题。

函数返回值

在我们之前的代码示例中,我们尝试调用math.Floorstrings.Title函数,但它们没有产生任何输出:

package main
import (
       "math"
       "strings"
)
func main() {
       math.Floor(2.75)
       strings.Title("head first go")
}
注意

此程序不产生任何输出!

当调用fmt.Println函数时,在此之后我们不需要再与其通信。我们传递一个或多个值给Println打印,相信它会打印出它们。但有时程序需要能够调用函数并从中获取数据返回。因此,大多数编程语言中的函数可以有返回值:函数计算并返回给其调用者的值。

math.Floorstrings.Title函数都是使用返回值的示例函数。math.Floor函数接受一个浮点数,将其向下舍入到最接近的整数,并返回该整数。而strings.Title函数接受一个字符串,将其中每个单词的第一个字母大写(将其转换为“标题格式”),并返回大写的字符串。

要查看这些函数调用的结果,我们需要获取它们的返回值,并将这些返回值传递给fmt.Println

image

一旦这个更改完成,返回值将被打印出来,我们可以看到结果。

池谜题

image

您的任务是从池中获取代码片段,并将它们放入空行中。不要多次使用相同的片段,也不需要使用所有片段。您的目标是编写能够运行并生成所示输出的代码。

imageimage

注意:每个池中的片段只能使用一次!

image 答案在“池谜题解答”中。

一个 Go 程序模板

对于接下来的代码片段,想象将它们插入到这个完整的 Go 程序中:

更好的方法是,尝试在 Go Playground 中键入这个程序,然后逐个插入片段,亲自看看它们的作用!

image

字符串

我们一直将字符串作为Println的参数传递。字符串是一系列字节,通常表示文本字符。您可以在代码中直接定义字符串,使用字符串字面量:双引号之间的文本,Go 将其视为字符串。

image

在字符串中,像换行符、制表符和其他难以包含在程序代码中的字符可以用转义序列来表示:反斜杠后跟表示另一个字符的字符。

image

转义序列
\n 换行符。
\t 制表符。
\" 双引号。
\\ 反斜杠。

符文

虽然字符串通常用于表示一系列文本字符,但 Go 的符文用于表示单个字符。

image

字符串字面量用双引号(")括起来,但符文字面量则用单引号(')括起来。

Go 程序可以使用几乎来自地球上任何语言的任何字符,因为 Go 使用 Unicode 标准来存储符文。符文保存为数值代码,而不是字符本身,如果将符文传递给fmt.Println,您将在输出中看到该数值代码,而不是原始字符。

image

就像字符串字面量一样,符文字面量中也可以使用转义序列来表示在程序代码中难以包含的字符:

image

布尔值

布尔值只能是truefalse两个值中的一个。它们在条件语句中特别有用,条件语句只在条件为真或假时运行代码的某些部分。(我们将在下一章节中讨论条件语句。)

image

数字

你还可以在代码中直接定义数字,这比字符串字面量更简单:只需键入数字。

imageimage

正如我们将很快看到的那样,Go 将整数和浮点数视为不同的类型,因此请记住,小数点可以用来区分整数和浮点数。

数学运算和比较

Go 的基本数学运算符工作方式与大多数其他语言相同。+符号用于加法,-用于减法,*用于乘法,/用于除法。

image

你可以使用<>来比较两个值,看一个是否小于或大于另一个。你可以使用==(这是两个等号)来判断两个值是否相等,使用!=(这是一个感叹号和一个等号,读作“不等于”)来判断两个值是否不相等。<=测试第二个值是否小于或等于第一个值,>=测试第二个值是否大于或等于第一个值。

比较的结果是一个布尔值,要么是true,要么是false

image

类型

在之前的代码示例中,我们看到了math.Floor函数,它将浮点数向下舍入到最接近的整数,并且strings.Title函数,它将字符串转换为标题格式。你传递一个数字作为Floor函数的参数是有道理的,而将一个字符串作为Title函数的参数也是如此。但如果你将一个字符串传递给Floor,并将一个数字传递给Title会发生什么呢?

image

Go 会打印两条错误消息,每个函数调用一条,甚至程序都不会运行!

周围的世界中的事物通常可以根据它们的用途分类为不同的类型。你不会吃汽车或卡车作为早餐(因为它们是车辆),你也不会开着煎蛋卷或碗状谷物去上班(因为它们是早餐食品)。

同样地,Go 中的值都被分类为不同的类型,这些类型指定了值可以用于什么。整数可以用于数学运算,但字符串不能。字符串可以大写,但数字不能。依此类推。

Go 是静态类型的,这意味着它在程序运行之前就知道你的值的类型。函数期望它们的参数具有特定的类型,它们的返回值也有类型(可能与参数类型相同,也可能不同)。如果你在错误的地方意外使用了错误类型的值,Go 会给出错误消息。这是件好事:它能让你在用户之前发现问题!

Go 是静态类型的。如果在错误的地方使用了错误类型的值,Go 会提醒您。

您可以通过将其传递给reflect包的TypeOf函数来查看任何值的类型。让我们看看我们已经见过的一些值的类型:

image

这些类型是用于什么的:

类型 描述
int 整数。保存整数值。
float64 浮点数。保存具有小数部分的数字。(类型名称中的64表示使用了 64 位数据来保存数字。这意味着float64值在被四舍五入之前可以相当精确,但不是无限精确。)
bool 布尔值。只能是truefalse
string 字符串。通常表示文本字符的数据系列。

image 答案在“image 练习解答”中。

声明变量

在 Go 中,变量是包含值的存储空间。您可以使用变量声明为变量命名。只需使用var关键字,后面跟上所需的名称和变量将保存的值的类型。

image

一旦声明了变量,您可以使用=将该类型的任何值赋给它(这是单个等号):

quantity = 2
customerName = "Damon Cole"

您可以在同一语句中为多个变量分配值。只需在=的左侧放置多个变量名,并在右侧用逗号分隔相同数量的值即可。

image

一旦为变量赋值,您可以在任何需要使用原始值的上下文中使用它们:

image

如果您事先知道变量的值,可以在同一行声明变量并为其赋值:

image

您可以为现有变量分配新值,但它们需要是相同类型的值。Go 的静态类型确保您不会意外地将错误类型的值分配给变量。

image

如果在声明变量的同时为其赋值,通常可以省略声明中的变量类型。将赋给变量的值的类型将用作该变量的类型。

image

零值

如果声明一个变量而没有为其赋值,那么该变量将包含其类型的零值。对于数值类型,零值实际上是0

image

但是对于其他类型,值0可能是无效的,因此该类型的零值可能是其他值。例如,string 类型变量的零值是空字符串,bool 类型变量的零值是false

image

代码磁铁

image

一个 Go 程序在冰箱上乱七八糟地摆放着。你能否重构代码片段,使其成为一个能产生指定输出的工作程序?

image

image 答案在 “Code Magnets Solution”。

短变量声明

我们提到你可以在同一行声明变量并给它们赋值:

image

但是,如果你在声明变量的同时知道变量的初始值,使用短变量声明更为典型。而不是显式声明变量类型,然后用 = 赋值,你可以一次完成两者,使用 :=

让我们更新之前的示例以使用短变量声明:

image

不需要显式声明变量的类型;分配给变量的值的类型成为该变量的类型。

因为短变量声明如此方便和简洁,它们比常规声明更常用。尽管如此,你仍会偶尔看到两种形式,所以熟悉这两种形式是很重要的。

Breaking Stuff is Educational!

image

拿我们使用变量的程序,尝试进行以下修改,并运行它。然后撤消你的更改并尝试下一个。看看会发生什么!

image

如果你这样做... ...它会失败,因为...
为相同变量添加第二个声明   quantity := 4 quantity := 4 你只能声明一个变量。(尽管你可以随意给它赋新值。你也可以声明同名的其他变量,只要它们在不同的作用域内。我们将在下一章学习作用域。)
删除短变量声明中的冒号   quantity = 4 如果忘记了冒号,它将被视为赋值而不是声明,而且你不能给一个未声明的变量赋值。
给 int 变量赋一个 string   quantity := 4 quantity = "a" 变量只能被赋予相同类型的值。
变量和值数量不匹配 length, width := 1.2 每个要赋值的变量都必须提供一个值,并且每个值都必须有一个对应的变量。
移除使用变量的代码   fmt.Println(customerName) 所有声明的变量必须在你的程序中使用。如果移除了使用变量的代码,也必须移除该声明。

命名规则

Go 有一套简单的规则适用于变量、函数和类型的名称:

  • 名称必须以字母开头,并且可以有任意数量的额外字母和数字。

  • 如果变量、函数或类型的名称以大写字母开头,它被视为导出的,可以从当前包之外的包访问。(这就是为什么 fmt.Println 中的 P 要大写:这样它可以从 main 包或任何其他包使用。)如果变量/函数/类型名称以小写字母开头,它被视为未导出的,只能在当前包内访问。

image

这些是语言强制执行的唯一规则。但 Go 社区也遵循一些额外的约定:

  • 如果名称由多个单词组成,则从第一个单词开始,每个单词后面的单词应大写,并且它们应该以不加空格连接在一起,如 topPriceRetryConnection 等。(如果要将名称导出到包外,则仅应将名称的第一个字母大写。)这种风格通常称为驼峰式,因为大写字母看起来像骆驼的驼峰。

  • 当名称的含义在上下文中是显而易见的时,Go 社区的惯例是缩写它:使用 i 替代 indexmax 替代 maximum 等。(然而,在 Head First 我们认为,在学习新语言时,没有什么是显而易见的,所以在本书中我们不会遵循这种惯例。)

image

只有以大写字母开头的变量、函数或类型的名称才被视为导出的:可以从当前包之外的包访问。

转换

在 Go 中,数学和比较操作要求包含的值是相同类型的。如果它们不是,则在尝试运行代码时会出错。

image

给变量赋新值也是如此。如果被赋值的值的类型与变量声明的类型不匹配,你将会得到一个错误。

image

解决方法是使用类型转换,它允许您将一个值从一种类型转换为另一种类型。您只需在要转换的值的括号中立即提供要转换为的类型。

image

结果是所需类型的新值。当我们在整数变量中的值上调用 TypeOf,然后在将其转换为 float64 后再次调用时,我们得到的就是这个:

image

让我们更新我们失败的代码示例,在任何数学运算或与其他 float64 值比较之前将 int 值转换为 float64

image

现在数学运算和比较都能正常工作了!

现在让我们尝试在将其分配给 float64 变量之前将 int 转换为 float64

image

再次确认转换完成后,分配就成功了。

在进行转换时,请注意它们可能如何改变结果值。例如,float64变量可以存储分数值,但int变量则不能。当您将float64转换为int时,小数部分将被简单地丢弃!这可能会影响您对结果值进行的任何操作。

image

只要你小心,转换对于使用 Go 是至关重要的。它们允许原本不兼容的类型一起工作。

在您的计算机上安装 Go

Go Playground 是尝试该语言的好方法。但它的实际用途有限。例如,您无法使用它来处理文件。它也没有办法从终端获取用户输入,而这对我们即将开发的程序非常重要。

因此,在结束本章之前,让我们在您的计算机上下载并安装 Go。别担心,Go 团队已经非常简化了这个过程!在大多数操作系统上,您只需运行一个安装程序,就可以完成安装。

image

  1. 在您的 Web 浏览器中访问golang.org

  2. 点击下载链接。

  3. 选择适合您操作系统(OS)的安装包。下载应会自动开始。

  4. 访问您操作系统的安装说明页面(下载开始后可能会自动跳转),按照页面上的指导进行操作。

  5. 打开一个新的终端或命令提示符窗口。

  6. 在提示符处键入**go version**并按下回车键或 Enter 键确认 Go 是否已安装。您应该会看到安装的 Go 版本信息。

编译 Go 代码

我们与 Go Playground 的互动主要是键入代码并神秘地运行它。现在我们实际上在您的计算机上安装了 Go,是时候更仔细地了解它的工作原理了。

计算机实际上不能直接运行 Go 代码。在此之前,我们需要获取源代码文件并编译它:将其转换为 CPU 可以执行的二进制格式。

image

让我们尝试使用我们新的 Go 安装程序编译和运行之前的“Hello, Go!”示例。

imageimage

  1. 使用您喜欢的文本编辑器,将我们之前的“Hello, Go!”代码保存在一个名为hello.go的纯文本文件中。

  2. 打开一个新的终端或命令提示符窗口。

  3. 在终端中,切换到保存hello.go的目录。

  4. 运行**go fmt hello.go**来清理代码格式。(这一步骤不是必需的,但无论如何都是一个好主意。)

  5. 运行**go build hello.go**来编译源代码。这将在当前目录中添加一个可执行文件。在 macOS 或 Linux 上,可执行文件将命名为hello。在 Windows 上,可执行文件将命名为hello.exe

  6. 运行可执行文件。在 macOS 或 Linux 上,输入**./hello**(意思是“在当前目录中运行名为hello的程序”)。在 Windows 上,只需输入**hello.exe**

image

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示例。

image

  1. 打开新的终端或命令提示窗口。

  2. 在终端中,切换到保存hello.go的目录。

  3. 输入**go run hello.go**并按 Enter/Return 键。 (该命令在所有操作系统上都相同。)

image

您将立即看到程序输出。如果对源代码进行更改,您不必进行单独的编译步骤;只需使用go run运行代码,即可立即查看结果。在编写小型程序时,go run是一个很方便的工具!

您的 Go 工具箱

image

至此为止,第一章就讲完了!您已经把函数调用和类型添加到了您的工具箱中。

注意

函数调用

函数是程序中可以从其他位置调用的代码块。

在调用函数时,可以使用参数为函数提供数据。

注意

类型

Go 中的值被分类为不同的类型,这些类型指定了值可以用于什么。

数学操作和不同类型之间的比较不允许,但如果需要,可以将值转换为新类型。

Go 变量只能存储其声明类型的值。

池谜题解决方案

image

代码磁铁解决方案

image

第二章:哪些代码将会接下来运行?:条件语句和循环

image

每个程序都有仅在特定情况下适用的部分。“如果出现错误,则应运行此代码。否则,应运行其他代码。” 几乎每个程序包含应仅在某个条件为真时运行的代码。因此,几乎每种编程语言都提供了条件语句,让您可以确定是否运行代码段。Go 也不例外。

你可能还需要使您的一些代码运行重复。像大多数语言一样,Go 提供了可以多次运行代码段的循环。我们将在本章学习如何同时使用条件语句和循环!

调用方法

在 Go 中,可以定义方法:与给定类型的值相关联的函数。Go 方法有点像您可能在其他语言中附加到“对象”的方法,但它们稍微简单一些。

我们将详细讨论方法如何在第九章中工作。但我们需要使用一些方法来使本章的示例工作,所以让我们现在看一些调用方法的简短示例。

time 包有一个 Time 类型,表示日期(年、月和日)和时间(时、分、秒等)。每个 time.Time 值都有一个 Year 方法,返回年份。下面的代码使用此方法打印当前年份:

image

time.Now 函数返回当前日期和时间的新 Time 值,我们将其存储在 now 变量中。然后,我们在 now 引用的值上调用 Year 方法:

image

Year 方法返回一个整数年份,然后我们将其打印出来。

方法是与特定类型的值相关联的函数

strings 包中有一个 Replacer 类型,它可以搜索字符串中的子字符串,并将每个出现的子字符串替换为另一个字符串。下面的代码用字母 o 替换了字符串中的每个 # 符号:

image

strings.NewReplacer 函数接受两个参数,第一个是要替换的字符串("#"),第二个是要替换为的字符串("o"),并返回一个 strings.Replacer。当我们将字符串传递给 Replacer 值的 Replace 方法时,它将返回一个已进行替换的字符串。

image

点号指示点右侧的东西属于左侧的东西

与我们早些时候看到的函数属于不同,方法属于一个单独的。这个值是出现在点号左侧的内容。

image

取得好成绩

在本章中,我们将探讨 Go 的一些特性,这些特性让您可以根据条件决定是否运行一些代码。让我们看一个可能需要这种能力的情况...

我们需要编写一个程序,允许学生输入他们的百分比成绩,并告诉他们是否通过了。通过或失败遵循一个简单的规则:60%或更高的成绩为通过,低于 60%为失败。因此,如果用户输入的百分比是 60 或更高,我们的程序将需要给出一种响应;否则给出另一种响应。

注释

让我们创建一个新文件,pass_fail.go,来保存我们的程序。我们将处理之前程序中遗漏的一个细节,并在顶部添加一个描述程序功能的描述。

image

大多数 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 程序添加一些实际的代码。它首先需要做的是允许用户输入一个百分比成绩。我们希望他们输入一个数字并按回车,我们将把他们输入的数字存储在一个变量中。让我们添加处理此操作的代码。(注意:此代码将不能按照显示的方式编译,我们稍后会讨论原因!

image

首先,我们需要让用户知道输入内容,因此我们使用fmt.Print函数显示一个提示。 (与Println函数不同,Print在打印消息后不会跳到新的终端行,这使我们可以保持提示和用户输入在同一行上。)

接下来,我们需要一种方法从程序的标准输入中读取(接收和存储)输入,所有键盘输入都将进入这里。行reader := bufio.NewReader(os.Stdin)在变量reader中存储了一个能够执行此操作的bufio.Reader

image

要实际获取用户的输入,我们在Reader上调用ReadString方法。ReadString方法需要一个标志输入结束的符文(字符),我们希望读取用户按下回车键之前的所有内容,因此我们给ReadString传递一个换行符符文。

一旦我们获取了用户的输入,我们就简单地将其打印出来。

那就是计划,但是如果我们试图编译或运行这个程序,我们将会得到一个错误:

image

函数或方法的多返回值

我们试图读取用户的键盘输入,但是出现了错误。编译器在这行代码中报告了一个问题:

image

问题在于 ReadString 方法试图返回两个值,但我们只提供了一个变量来接收值。

在大多数编程语言中,函数和方法只能有一个返回值,但在 Go 语言中,它们可以返回任意数量的值。在 Go 语言中多返回值的最常见用法是返回额外的错误值,可以通过它来查找函数或方法执行过程中是否出现了问题。以下是一些例子:

image

Go 语言不允许我们声明变量而不使用它

Go 语言要求每个被声明的变量在程序中必须被使用。如果我们添加了一个 err 变量但没有检查它,我们的代码就无法编译通过。未使用的变量通常表示程序中可能存在的错误,这是 Go 语言帮助你检测和修复错误的一个例子!

image

选项 1:使用空白标识符忽略错误返回值

ReadString 方法除了返回用户输入的值外,还返回一个第二个值,我们需要对这第二个值做些处理。我们尝试添加第二个变量并忽略它,但我们的代码仍然无法编译通过。

image

当我们有一个通常会被赋值给变量但我们不打算使用的值时,可以使用 Go 语言的空白标识符。将值赋给空白标识符实际上是将其丢弃(同时也向读者明确表明你正在这样做)。要使用空白标识符,在赋值语句中简单地输入一个下划线( _ )字符,替代你通常会输入的变量名。

让我们试着用空白标识符替代我们以前的 err 变量:

image

现在我们来尝试这个变化。在你的终端中,切换到保存了 pass_fail.go 文件的目录,并使用以下命令运行程序:

image

当你在提示符处输入一个成绩(或任何其他字符串)并按 Enter 键时,你输入的内容将被回显给你。我们的程序正在工作!

选项 2:处理错误

image

这是真的。如果真的发生错误,这个程序就不会告诉我们!

如果我们从 ReadString 方法得到一个错误,空白标识符将会忽略这个错误,并且我们的程序会继续运行,可能会带来无效的数据。

image

在这种情况下,如果出现错误,最好通知用户并停止程序。

log 包中有一个 Fatal 函数可以同时执行这两个操作:向终端记录消息并停止程序运行。在这里,Fatal表示报告一个“致命”的错误,即会“杀死”你的程序。

让我们去掉空白标识符,将其替换为 err 变量,这样我们再次记录错误。然后,我们将使用 Fatal 函数记录错误并停止程序。

image

但如果我们尝试运行这个更新后的程序,我们会发现有一个新问题...

条件语句

如果我们的程序在从键盘读取输入时遇到问题,我们已设置程序报告错误并停止运行。但现在,即使一切正常,它也停止运行!

image

ReadString这样的函数和方法返回一个值为nil的错误,这基本上意味着“没有任何内容”。换句话说,如果errnil,那么没有错误。但是我们的程序设置为简单地报告nil错误!我们应该的是只有在err变量的值不是nil时才退出程序。

我们可以通过条件语句来实现这一点:这些语句会导致仅在满足条件时执行一个代码块(一个或多个被{}大括号包围的语句)。

image

表达式被评估,如果结果为true,则执行条件块中的代码。如果结果为false,则跳过条件块。

image

与大多数其他语言一样,Go 支持条件中的多个分支。这些语句采用if...else if...else的形式。

if grade == 100 {
       fmt.Println("Perfect!")
} else if grade >= 60 {
       fmt.Println("You pass.")
} else {
       fmt.Println("You fail!")
}

条件语句依赖于布尔表达式(一个评估为truefalse的表达式),以决定它们包含的代码是否应该被执行。

image

当你需要仅在条件false时执行代码时,可以使用!,布尔取反运算符,它可以将true变为false,或者将false变为true

image

如果你想只在两个条件为真时运行一些代码,你可以使用&&(“和”)运算符。如果你希望在两个条件之一为真时运行,你可以使用||(“或”)运算符。

image

没有愚蠢的问题

Q: 我的另一种编程语言要求**if**语句的条件必须用括号括起来。Go 也是这样吗?

A: 不是,并且事实上,go fmt工具会删除您添加的任何括号,除非您用它们来设置操作顺序。

有条件地记录致命错误

即使我们的成绩评估程序成功从键盘读取输入,也会报告错误并退出。

image

我们知道如果err变量的值为nil,那么从键盘读取是成功的。既然我们知道了if语句,让我们尝试更新我们的代码,只有当err不是nil时才记录错误并退出。

image

如果我们重新运行我们的程序,我们会看到它又开始工作了。现在,如果在读取用户输入时有任何错误,我们也会看到这些错误!

image

代码磁铁

image

冰箱上有一个打印文件大小的 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,调用 TrimSpaceParseFloat

图片

首先,我们向 import 部分添加适当的包。我们添加代码以从 input 字符串中删除换行符。然后将 input 传递给 ParseFloat,并将生成的 float64 值存储在新变量 grade 中。

就像我们在 ReadString 中所做的那样,我们测试 ParseFloat 是否返回错误值。如果返回错误,我们报告错误并停止程序。

最后,我们更新条件语句,以测试 grade 中的数字,而不是 input 中的字符串。这应该修复由于比较字符串与数字而导致的错误。

如果我们尝试运行更新后的程序,就不再会出现类型不匹配的字符串和整数错误。看起来我们已经解决了那个问题。但是还有几个错误需要解决。接下来我们会看看这些。

image

我们已将用户的成绩输入转换为float64值,并将其添加到条件语句中以确定是否通过或失败。但我们又遇到了几个编译错误:

image

正如我们之前看到的,像status这样声明一个变量,而后不使用它是 Go 语言中的一个错误。我们得到错误两次似乎有些奇怪,但现在先不管它。我们将在Println中添加一个调用,打印给定的百分比成绩和status的值。

image

但是现在我们又得到了一个的错误,说在我们的Println语句中尝试使用status变量时未定义!出了什么问题?

Go 代码可以被划分为,代码段。块通常用花括号({})括起来,虽然在源代码文件和包级别也有块。块可以嵌套在彼此内部。

image

函数体和条件语句的主体也都是块。理解这一点将对解决我们在status变量上的问题至关重要…

块和变量作用域

每个你声明的变量都有一个作用域:它在代码中“可见”的一部分。声明的变量可以在其作用域内的任何地方访问,但如果你尝试在该作用域之外访问它,就会收到一个错误。

变量的作用域包括它声明的块及其嵌套在其中的任何块。

image

以上是代码中变量的作用域:

  • packageVar的作用域是整个main包。你可以在包中定义的任何函数内部的任何地方访问packageVar

  • functionVar的作用域是它所声明的整个函数,包括该函数内部嵌套的if块。

  • conditionalVar的作用域仅限于if块。当我们在if块的闭合}后尝试访问conditionalVar时,将会收到一个错误,提示conditionalVar未定义!

现在我们理解了变量作用域,我们可以解释为什么我们的status变量在评分程序中未定义了。我们在条件块中声明了status。(事实上,我们声明了两次,因为有两个单独的块。这就是为什么我们会得到两个status declared and not used错误。)但是后来我们试图在那些块的外部访问status,此时它已经不在作用域内了。

image

解决方案是将status变量的声明移出条件块,并移到函数块顶部。这样一来,status变量将在嵌套的条件块内部和函数块的末尾都可以访问。

image

我们已完成评分程序!

就这样!我们的pass_fail.go程序已经准备就绪!让我们再看一下完整的代码:

image

你可以随意运行完成的程序多次。输入低于 60 的百分比成绩,它将报告不及格状态。输入超过 60 的成绩,它将报告及格状态。看起来一切正常运行!

image

短变量声明中只有一个变量必须是新的

image

的确,当在同一作用域内声明同名变量两次时,我们会得到一个编译错误:

image

但只要短变量声明中至少有一个变量名是新的,就是允许的。新变量名被视为声明,现有变量名被视为赋值。

image

有一个特殊处理的原因:很多 Go 函数返回多个值。如果你因为想重用其中一个变量而不得不单独声明所有变量会很麻烦。

image

相反,Go 允许你为所有东西使用短变量声明,即使对于一个变量,它实际上是一个赋值操作。

image

让我们来制作一个游戏

我们将通过制作一个简单的游戏来结束本章。如果听起来有些艰巨,不用担心;你已经学会了大部分你将需要的技能!在此过程中,我们将学习关于循环的知识,这将允许玩家进行多次回合。

让我们看看我们需要做的所有事情:

image

注意

这个例子首次出现在《Head First Ruby》中。(另一本你也应该买的好书!)它非常成功,所以我们在这里再次使用它。

image

图 2-1. 加里·理查德特 游戏设计师

让我们创建一个名为guess.go的新源文件。

看起来我们的第一个要求是生成一个随机数。让我们开始吧!

包名与导入路径

math/rand包有一个Intn函数可以为我们生成一个随机数,所以我们需要导入math/rand。然后我们将调用rand.Intn来生成随机数。

imageimage

一个是包的导入路径,另一个是包的名称

当我们说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 时循环结束。

image

++-- 语句经常用作循环后置语句。每次评估它们时,++1 添加到变量的值,而 --1 减去。

image

++-- 在循环中使用时,便于计数向上或向下。

image

Go 还包括赋值运算符 +=-=。它们获取变量中的值,添加或减去另一个值,然后将结果重新赋给变量。

image

+=-= 可以在循环中使用,以便按照除 1 外的增量计数。

image

当循环结束时,执行将恢复到循环块后面的语句。但只要条件表达式评估为true,循环将继续进行。这可能被滥用;以下是永远运行的循环示例和根本不会运行的循环示例:

image

初始化语句和后置语句是可选的

如果愿意,您可以从 for 循环中省略初始化和后置语句,仅留下条件表达式(尽管您仍然需要确保条件最终评估为 false,否则您可能会遇到无限循环的问题)。

image

循环和作用域

就像条件语句一样,循环块内声明的任何变量的作用域仅限于该块(尽管初始化语句、条件表达式和后置语句也可视为该作用域的一部分)。

image

就像条件语句一样,在循环的控制语句和块之前声明的任何变量仍然在循环内的作用域内,并且在循环退出后仍然在作用域内。

image

破坏东西是教育性的!

image

这是一个使用循环计数到 3 的程序。尝试进行以下更改并运行它。然后撤消您的更改并尝试下一个。看看会发生什么!

image

如果你这样做... ...它会中断,因为...
for 关键字后加上括号 for (x := 1; x <= 3; x++) 某些其他语言要求在 for 循环的控制语句周围加上括号,但是 Go 不仅不需要它们,而且禁止使用它们。
从初始化语句x = 1中删除: 除非你正在给封闭作用域中已声明的变量赋值(通常情况下不会这样),初始化语句必须是一个声明,而不是一个赋值
从条件表达式x < 3中移除= x达到3时,表达式x < 3变为false(而x <= 3仍然为true)。因此,循环只会计数到2
反转条件表达式x >= 3中的比较 因为条件在循环开始时已经是falsex被初始化为1,比3),所以循环永远不会运行。
将后置语句从x++改为x-- x-- x变量将从1开始递减(10-1-2等),并且由于它永远不会大于3,因此循环永远不会结束。
fmt.Println(x)语句移到循环块之外 在初始化语句或循环块内声明的变量只在循环块内有效。

在我们的猜测游戏中使用循环

我们的游戏仍然只提示用户一次猜测。让我们在提示用户猜测并告知他们是否太低或太高的代码周围添加一个循环,以便用户可以猜测 10 次。

我们将使用一个名为guessesint变量来跟踪玩家已经猜测的次数。在循环的初始化语句中,我们将guesses初始化为0。每次循环迭代时,我们将guesses1,当guesses达到10时,我们将停止循环。

我们还将在循环块的顶部添加一个Println语句,告诉用户剩余的猜测次数。

图片

现在我们的循环已经就位,如果再次运行游戏,我们将被询问 10 次猜测的内容!

图片

由于用于提示猜测并声明是否过高或过低的代码位于循环内部,因此它会被重复运行。经过 10 次猜测后,循环(和游戏)将结束。

但是即使玩家猜对了,循环也总是运行 10 次!修复这个问题将是我们的下一个要求。

使用“continue”和“break”跳过循环的部分

辛苦部分已经完成!我们只剩下几个要求需要完成。

目前,提示用户猜测的循环总是运行 10 次。即使玩家猜对了,我们也不告诉他们,并且我们不停止循环。我们的下一个任务是修复这个问题。

图片

Go 语言提供了两个控制循环流程的关键字。第一个是continue,立即跳到循环的下一次迭代,而不执行循环块中的任何其他代码。

图片

在上述示例中,字符串"after continue"永远不会被打印,因为continue关键字总是在第二次调用Println之前跳回循环顶部。

第二个关键字,break,立即中断循环。循环块内部的代码不再执行,也不再进行进一步的迭代。执行转移到循环后的第一个语句。

image

在循环的第一次迭代中,字符串 "before break" 被打印出来,但是 break 语句立即中断了循环,没有打印 "after break" 字符串,并且不再运行循环(尽管通常还会再运行两次)。执行转而移到循环后的语句。

break 关键字似乎适用于我们当前的问题:当玩家猜测正确时,我们需要中断循环。让我们在游戏中尝试使用它...

退出我们的猜测循环

我们正在使用 if...else if 条件语句来告诉玩家他们猜测的状态。如果玩家猜测的数字太高或太低,我们目前会打印一条消息告诉他们。

如果猜测既不太高不太低,那么它必须是正确的。因此,让我们在条件语句上添加一个 else 分支,在猜测正确的情况下运行。在 else 分支的块内部,我们会告诉玩家他们猜对了,并使用 break 语句来停止猜测循环。

image

现在,当玩家猜测正确时,他们将看到一条祝贺的消息,并且循环将在不再完全重复 10 次的情况下退出。

image

又完成了另一个要求!

揭示目标

image

我们离成功如此近!只剩下一个要求了!

如果玩家猜测了 10 次仍未找到目标数字,则循环将退出。在这种情况下,我们需要打印一条消息告诉他们输了,并告诉他们目标是什么。

但是,如果玩家猜对了,我们会退出循环。我们不希望在玩家已经赢得胜利时说他们输了!

因此,在我们的猜测循环之前,我们将声明一个 success 变量,它保存一个布尔值。(我们需要在循环之前声明它,以便在循环结束后仍然在范围内。)我们将 success 初始化为默认值 false。然后,如果玩家猜对了,我们将 success 设置为 true,表示我们不需要打印失败消息。

image

在循环之后,我们添加了一个 if 块来打印失败消息。但是 if 块只有在条件为 true 时才会执行,我们只想在 successfalse 时打印失败消息。因此,我们添加了布尔取反运算符(!)。正如我们之前看到的,!true 变为 false,将 false 变为 true

结果是,如果 successfalse,则会打印失败消息,但如果 successtrue,则不会打印。

最后的润色

image

恭喜,这是最后一个要求!

让我们处理一些代码的最后问题,然后试试我们的游戏!

首先,正如我们提到的,每个 Go 程序顶部通常添加一条注释来描述其功能。现在让我们添加一条。

image

我们的程序还通过在每次游戏开始时打印目标数字来鼓励作弊者。让我们删除执行此操作的 Println 调用。

image

我们终于准备好尝试运行我们的完整代码了!

首先,我们故意用尽猜测次数以确保目标数字被显示...

image

然后我们将尝试成功猜测。

我们的游戏运行得很顺利!

image

恭喜,您的游戏已完成!

image

使用条件和循环,您已在 Go 中编写了一个完整的游戏!请为自己倒一杯冷饮——您赢得了它!

这是我们完整的 guess.go 源代码!

image

你的 Go 工具箱

image

这就是第二章的全部内容!您已将条件和循环添加到您的工具箱中

image

注意

循环

循环使一段代码重复执行。

一种常见的循环以关键字“for”开头,后跟初始化语句以初始化变量,条件表达式以确定何时退出循环,并且后置语句在每次循环迭代之后运行。

代码磁铁解决方案

image

一个打印文件大小的 Go 程序挂在冰箱上。它调用 os.Stat 函数,该函数返回一个 os.FileInfo 值和可能的错误。然后它调用 FileInfo 值上的 Size 方法来获取文件大小。

最初的程序使用 _ 空标识符来忽略 os.Stat 中的错误值。如果发生错误(例如文件不存在),这会导致程序失败。

你的任务是重建额外的代码片段,使得程序像原始程序一样运行,但还要检查 os.Stat 的错误。如果 os.Stat 的错误不为 nil,则应报告错误并退出程序。

image

第三章:请打电话给我:函数

图片

你一直都在错过。你已经像专业人士一样调用函数了。但是你能调用的函数只有 Go 为你定义的那些。现在轮到你了。我们将向你展示如何创建自己的函数。我们将学习如何声明带有和不带参数的函数。我们将声明返回单个值的函数,并学习如何返回多个值,以便在发生错误时指示。我们还将学习指针,它们允许我们进行更高效的内存管理。

一些重复的代码

假设我们需要计算涂料涂抹若干墙壁所需的量。制造商称每升涂料可以覆盖 10 平方米。因此,我们需要将每堵墙的宽度(以米为单位)乘以其高度,得到其面积,然后除以 10 得到所需的涂料升数。

图片图片

这样做虽然可行,但存在几个问题:

  • 计算结果似乎有微小的误差,并且打印出来的浮点数值显得异常精确。我们实际上只需要几位小数的精度。

  • 即使现在也有相当多的重复代码。随着我们添加更多的墙壁,这个问题会变得更糟。

对于这两个问题都需要一些解释,所以现在先来看看第一个问题...

这些计算略有偏差,因为计算机上的普通浮点数算术略微不精确。(通常是几百万亿分之几。)造成这种情况的原因有点复杂,这里不便深究,但这个问题并非 Go 所独有。

但只要我们在显示之前将数字四舍五入到一个合理的精度,那就没问题。让我们稍作停顿,看看一个可以帮助我们做到这一点的函数。

图片

使用 Printf 和 Sprintf 格式化输出

图片

Go 中的浮点数保持着高度的精确度。当你想要显示它们时,这可能会有些麻烦:

图片

为了处理这类格式化问题,fmt 包提供了 Printf 函数。Printf 代表“打印,带有格式化”。它接受一个字符串,并在其中插入一个或多个值,以特定的方式格式化。然后打印出结果字符串。

图片

Sprintf 函数(也是 fmt 包的一部分)与 Printf 函数的工作方式几乎一样,唯一的区别是它返回格式化后的字符串而不是直接打印出来。

图片

看起来 PrintfSprintf 可以 帮助我们限制所显示的值的正确位数。问题在于,如何?首先,为了能够有效地使用 Printf 函数,我们需要了解它的两个特性:

  • 格式化动词(上述字符串中的 %0.2f 是一个动词)

  • 值的宽度(这是动词中间的 0.2

格式化动词

图片

Printf 的第一个参数是一个字符串,将用于格式化输出。大部分字符串的格式化方式与其显示的方式完全一样。然而,任何百分号(%)都将被视为格式化动词的开始,这部分字符串将被替换为特定格式的值。其余的参数被用作这些动词的值。

image

百分号后面的字母表示要使用的动词。最常见的动词包括:

动词 输出
%f 浮点数
%d 十进制整数
%s 字符串
%t 布尔值(truefalse
%v 任意值(根据提供的值的类型选择适当的格式)
%#v 任意值,格式化为 Go 程序代码中的形式
%T 提供的值的类型(intstring等)
%% 字面上的百分号

image

顺便提一下,我们确保在每个格式化字符串的末尾添加一个换行符 \n 转义序列。这是因为与 Println 不同,Printf 不会为我们自动添加换行符。

image

我们特别想指出 %#v 格式化动词。因为它打印值的方式类似于它们在 Go 代码中的显示方式,而不是它们通常的显示方式,因此 %#v 可以显示出在 %v 中隐藏的一些值。例如,在这段代码中,%#v 显示了一个空字符串、一个制表符和一个换行符,这些在使用 %v 打印时是看不到的。我们将在本书的后续部分更多地使用 %#v

image

格式化值的宽度

因此,%f 格式化动词适用于浮点数。我们可以在我们的程序中使用 %f 格式化所需的油漆量。

image

看起来我们的值被四舍五入到一个合理的数字。但是它仍然显示小数点后的六位,这对于我们当前的目的来说实在太多了。

对于这样的情况,格式化动词允许您指定格式化值的宽度

假设我们想要在一个纯文本表格中格式化一些数据。我们需要确保格式化后的值填充到最小的空格数,以便列对齐。

您可以在百分号后指定格式化动词的最小宽度。如果与该动词匹配的参数比最小宽度短,它将填充空格,直到达到最小宽度。

image

格式化小数位数的宽度

image

现在我们来到了今天任务中重要的部分:您可以使用值的宽度来指定浮点数的精度(显示的数字位数)。这是格式:

image

整个数字的最小宽度包括小数位和小数点。如果包括小数点,则较短的数字将在开始处用空格填充,直到达到这个宽度。如果省略小数点,则永远不会添加空格。

小数点后的宽度是要显示的小数位数。如果给出了更精确的数字,它将四舍五入(向上或向下)以适应给定的小数位数。

这里快速演示了各种宽度值的效果:

image

上述格式"%.2f",可以将任意精度的浮点数四舍五入到两位小数。(它也不会添加任何不必要的填充。)让我们尝试一下,用我们程序中过于精确的值来计算油漆体积。

image

现在更易读了。看起来Printf函数可以为我们格式化数字。让我们回到我们的油漆计算器程序,并应用我们在那里学到的东西。

image

在我们的油漆计算器中使用 Printf

现在我们有了一个Printf动词"%.2f",它将允许我们将浮点数四舍五入到两位小数。让我们更新我们的油漆数量计算程序来使用它。

image

最后,我们得到了合理的输出!浮点运算引入的微小不精确性已被四舍五入消除。

image

好主意。Go 允许我们声明自己的函数,因此也许我们应该将这段代码移到一个函数中

正如我们在第一章开头提到的,函数是一组或多组可以从程序中的其他位置调用的代码行。我们的程序有两组看起来非常相似的行:

image

让我们看看能否将这两个代码部分转换为一个单一的函数。

声明函数

简单的函数声明可能看起来像这样:

image

声明以func关键字开头,后跟您希望函数具有的名称,一对括号(),然后是包含函数代码的块。

一旦声明了函数,你可以在包的其他地方简单地输入其名称,后跟一对括号,即可调用它。这样做时,函数块中的代码将被执行。

image

注意,当我们调用sayHi时,我们不需要输入包名和点号再输入函数名。当调用当前包中定义的函数时,不应指定包名。(键入main.sayHi()将导致编译错误。)

函数名称的规则与变量名称的规则相同:

  • 名称必须以字母开头,后跟任意数量的其他字母和数字。(如果违反此规则,将会得到编译错误。)

  • 函数名以大写字母开头的函数是导出的,可以在当前包之外使用。如果只需在当前包内使用函数,应以小写字母开头命名。

  • 多个单词的名称应使用camelCase

图片

声明函数参数

如果希望调用函数时包含参数,必须声明一个或多个参数。参数是函数内部局部变量,在调用函数时设置其值。

图片

可以在函数声明的括号内部声明一个或多个参数,用逗号分隔。与任何变量一样,需要为每个声明的参数提供一个名称,后跟一个类型(float64bool等)。

参数是函数内部局部变量,在调用函数时设置其值。

如果函数定义了参数,则在调用函数时需要传递匹配的参数集。运行函数时,每个参数将设置为对应参数中值的副本。然后在函数块中使用这些参数值。

图片

在我们的油漆计算器中使用函数

现在我们知道如何声明自己的函数了,让我们看看能否消除油漆计算器中的重复。

图片

我们将代码移到名为paintNeeded的函数中来计算油漆的量。我们将不再使用单独的widthheight变量,而是将它们作为函数参数传入。然后,在我们的main函数中,我们只需为需要涂料的每面墙调用paintNeeded函数。

图片

不再重复的代码,如果我们想要计算额外墙壁所需的涂料,只需添加更多对paintNeeded的调用。这样更加清晰!

函数和变量作用域

我们的paintNeeded函数在其函数块内声明了一个area变量:

图片

与条件和循环块一样,函数块内声明的变量仅在该函数块内可见。因此,如果我们尝试在paintNeeded函数外部访问area变量,将会得到编译错误:

图片

但是,与条件和循环块一样,声明在函数块外部的变量将在该块内可见。这意味着我们可以在包级别声明一个变量,并在该包中的任何函数中访问它。

图片

函数返回值

假设我们想要计算所有需要涂料的墙壁的总量。我们无法使用当前的paintNeeded函数来实现这一点;它只是打印出量然后将其丢弃!

图片

因此,让我们修改paintNeeded函数来返回一个值。然后,调用它的人可以打印这个量,进行额外的计算,或者做其他他们需要做的事情。

函数总是返回特定类型的值(仅限该类型)。要声明函数返回一个值,需在函数声明的参数后面添加返回值类型。然后在函数块中使用return关键字,后跟你想要返回的值。

image

调用函数的人可以将返回值分配给变量,直接传递给另一个函数,或者以其他方式处理它们。

image

return语句执行时,函数立即退出,不再运行其后面的任何代码。你可以结合if语句使用它,在条件下退出函数,避免运行剩余的代码(由于错误或其他条件)。

image

这意味着,如果你包括一个不属于if块的return语句,可能有代码在任何情况下都不会运行。这几乎肯定表明代码中存在错误,因此 Go 通过要求任何声明返回类型的函数必须以return语句结束来帮助你检测这种情况。以其他任何语句结尾都会导致编译错误。

image

如果你的返回值类型与声明的返回类型不匹配,你也会得到编译错误。

image

在我们的涂料计算器中使用返回值

现在我们知道如何使用函数返回值了,让我们看看是否能更新我们的涂料程序,除了每面墙需要的量之外,还打印总共需要的涂料量。

我们将更新paintNeeded函数以返回所需的量。我们将在main函数中使用该返回值,既用于打印当前墙壁的量,又用于添加到total变量,以跟踪所需的总涂料量。

image

它奏效了!返回值使我们的main函数能够决定如何处理计算出的量,而不是依赖于paintNeeded函数来打印它。

破坏事物是教育性的!

image

这是我们更新后的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函数需要错误处理

imageimage

函数paintNeeded似乎不知道传递给它的参数是无效的。它继续在计算中使用了这个无效的参数,并返回了一个无效的结果。这是一个问题——即使你知道一个商店可以购买负数升的油漆,你真的想把它应用到你的房子上吗?我们需要一种方法来检测无效的参数并报告错误。

在第二章中,我们看到了几个不同的函数,除了它们的主要返回值外,还返回一个指示是否存在错误的第二个值。例如,strconv.Atoi函数尝试将字符串转换为整数。如果转换成功,它将返回一个nil的错误值,表示我们的程序可以继续执行。但如果错误值不是nil,则表示字符串无法转换为数字。在这种情况下,我们选择打印错误值并退出程序。

image

如果我们在调用paintNeeded函数时想要做同样的事情,我们将需要两样东西:

  • 创建表示错误的值的能力

  • 返回paintNeeded的额外值的能力

让我们开始弄清楚这个问题吧!

错误值

在我们能够从paintNeeded函数中返回一个错误值之前,我们需要一个错误值来返回。错误值是指具有名为Error并返回字符串的方法的任何值。创建一个最简单的方法是将字符串传递给errors包的New函数,它将返回一个新的错误值。如果你在该错误值上调用Error方法,你将得到你传递给errors.New的字符串。

image

但是,如果你将错误值传递给fmtlog包中的函数,你可能不需要调用它的Error方法。fmtlog中的函数已经被编写成检查传递给它们的值是否具有Error方法,并在需要时打印Error的返回值。

image

如果需要格式化数字或其他值以在错误消息中使用,可以使用fmt.Errorf函数。它将值插入到格式字符串中,类似于fmt.Printffmt.Sprintf,但不是打印或返回一个字符串,而是返回一个错误值。

image

声明多个返回值

现在我们需要一种方式来指定我们的paintNeeded函数将返回一个错误值和所需油漆的量。

要为函数声明多个返回值,请在函数声明中的第二组括号中放置返回值类型(在函数参数的括号之后),用逗号分隔。(如果只有一个返回值,返回值周围的括号是可选的,但如果有多个返回值,则是必需的。)

从那时起,当调用该函数时,你需要考虑额外的返回值,通常是通过将它们赋值给额外的变量来处理。

image

如果为返回值提供名称可以使其更清晰,类似于参数名称。命名返回值的主要目的是作为程序员阅读代码的文档。

image

使用多个返回值与我们的paintNeeded函数

正如我们在上一页看到的那样,可以返回任意类型的多个值。但多返回值的最常见用途是返回主要的返回值,后面跟着一个额外的值,指示函数是否遇到错误。如果没有问题,额外的值通常设为nil,如果发生错误则设为错误值。

我们将遵循paintNeeded函数的这一约定。我们声明它返回两个值,一个float64和一个error。(错误值的类型是error。)在函数块中的第一件事是检查参数是否有效。如果widthheight参数小于0,我们将返回油漆量为0(这是无意义的,但我们必须返回一些东西),并且通过调用fmt.Errorf生成一个错误值。在函数开始时检查错误使我们可以通过调用return轻松地跳过函数代码的其余部分,如果有问题的话。

如果参数没有问题,我们将像以前一样继续计算和返回油漆量。函数代码中的唯一区别是,我们返回第二个值nil与油漆量一起,以表示没有错误。

image

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 要求你使用你声明的每一个变量。这实际上是一个非常有用的特性,特别是在处理错误返回值时,它有助于防止意外忽略错误。

池难题

图片

你的任务是从池中选择代码片段,并将它们放入代码中的空白行中。不要重复使用同一个片段,并且不需要使用所有片段。你的目标是编写能够运行并产生所示输出的代码。

imageimage

注意:每个池中的片段只能使用一次!

image 答案在 “池谜题解答”。

函数参数接收参数的副本

正如我们提到的,当你调用声明了参数的函数时,需要为调用提供参数。每个参数中的值都被复制到对应的参数变量中。(执行此操作的编程语言有时被称为“传值”。)

Go 是一种“传值”语言;函数参数接收函数调用中参数的一个副本。

在大多数情况下这没问题。但是如果你想将变量的值传递给函数,并以某种方式更改该值,你会遇到麻烦。函数只能更改其参数中值的副本,而不能更改原始值。因此,在函数内部进行的任何更改都不会在函数外部可见!

这是我们之前展示的 double 函数的更新版本。它接受一个数字,将其乘以 2,并打印结果。(它使用 *= 运算符,工作方式与 += 相同,但它将变量的值乘以而不是加上。)

image

假设我们想要将打印加倍值的语句从 double 函数移回调用它的函数中。这是行不通的,因为 double 只会修改它的值副本。回到调用函数时,我们将得到原始值,而不是加倍后的值!

image

我们需要一种方法,允许函数改变变量原始值,而不是副本。为了学会如何做到这一点,我们需要再次偏离函数,学习关于指针的内容。

image

指针

image

你可以使用 &(一个&符号)来获取变量的地址,这是 Go 的“地址”运算符。例如,这段代码初始化一个变量,打印其值,然后打印变量的地址...

image

我们可以获取任何类型变量的地址。请注意,每个变量的地址都是不同的。

image

那么这些“地址”究竟是什么?嗯,如果你想在拥挤的城市中找到特定的房子,你会使用它的地址...

image

就像城市一样,计算机为程序设置的内存是一个拥挤的地方。它充满了变量值:布尔值、整数、字符串等。就像房屋的地址一样,如果你有一个变量的地址,你可以用它来找到该变量包含的值。

image

代表变量地址的值被称为指针,因为它们指向变量所在的位置。

图片

指针类型

图片

指针的类型写作*符号,后面跟着指针指向的变量的类型。例如,指向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 工具箱

图片

至此,第三章就结束了!你已经在工具箱中添加了函数声明和指针。

图片

池子难题解决方案

图片

代码磁铁解决方案

图片

第四章:代码包

image

是时候开始组织了。到目前为止,我们一直将所有代码混合放在一个文件中。随着程序变得越来越大和复杂,这很快就会变得一团糟。

在本章中,我们将向你展示如何创建你自己的,以帮助将相关的代码放在一个地方。但包不仅仅是用于组织的好工具。包是在程序之间分享代码的一种简便方式。它们也是向其他开发者分享代码的简便方式。

不同的程序,同一个函数

我们编写了两个程序,每个程序中都有一个相同的函数副本,这让维护变得头疼…

在这一页上,我们有一个来自第二章的新版本的pass_fail.go程序。从键盘读取成绩的代码已经移动到一个新的getFloat函数中。getFloat返回用户键入的浮点数,除非出现错误,否则返回0和一个错误值。如果返回错误,程序会报告并退出;否则,它会像以前一样报告成绩是否及格。

image

在这一页上,我们有一个新的tocelsius.go程序,允许用户输入华氏温度,并将其转换为摄氏温度。

注意,在tocelsius.go中的getFloat函数与pass_fail.go中的getFloat函数是完全相同的。

image

使用包在程序之间共享代码

image

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 扩展名结尾。

image

没有愚蠢的问题

Q: 你说一个包文件夹可以包含多个文件。每个文件应该放什么?

A: 你想要的任何内容!你可以将一个包的所有代码放在一个文件中,或者在多个文件之间进行拆分。无论哪种方式,它们都将成为同一个包的一部分。

创建一个新的包

让我们尝试在工作空间中设置我们自己的包。我们将创建一个简单的包,名为 greeting,用于打印各种语言的问候语。

Go 安装时不会默认创建工作空间目录,因此您需要自己创建。首先进入您的主目录。(在大多数 Windows 系统上,路径为 C:\Users<yourname>,在 Mac 上为 /Users/,在大多数 Linux 系统上为 /home/。)在主目录中,创建一个名为 go 的目录——这将是我们的新工作空间目录。在 go 目录中,创建一个名为 src 的目录。

最后,我们需要一个目录来存放我们的包代码。按照惯例,包的目录应与包的名称相同。因为我们的包将被命名为 greeting,所以您应该为目录使用这个名称。

我们知道,这似乎是很多嵌套的目录(实际上,我们很快将进一步嵌套它们)。但请相信我们,一旦您建立了自己的包集合以及来自他人的包,这种结构将帮助您保持代码的组织性。

image

更重要的是,这种结构有助于 Go 工具找到代码。因为它始终位于 src 目录中,Go 工具确切知道在哪里查找导入包的代码。

您的下一步是在 greeting 目录中创建一个文件,并将其命名为 greeting.go。文件应包含以下代码。稍后我们将详细讨论它,但现在我们想让您注意几点…

就像我们迄今为止的所有 Go 源代码文件一样,此文件以 package 行开头。但不同于其他文件的是,这段代码不属于 main 包;它属于名为 greeting 的包。

image

还要注意两个函数定义。它们与我们迄今为止见过的其他函数没有太大不同。但因为我们希望这些函数可以在 greeting 包外部访问,所以请注意我们将它们的名称首字母大写,以便导出这些函数。

将我们的包导入到程序中

现在让我们尝试在程序中使用我们的新包。

image

在你的工作区目录中,在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 的函数。

imageimage

注意:每个来自池中的片段只能使用一次!

image 在 “池子难题解决方案” 中有答案。

包命名约定

使用包的开发者需要在每次调用来自该包的函数时输入其名称(例如 fmt.Printffmt.Printlnfmt.Print 等)。为了尽可能简化这个过程,包名称应遵循几个规则:

  • 包名应全小写。

  • 如果含义相当明显,名称应缩写(例如 fmt)。

  • 如果可能的话,应该是一个单词。如果需要两个单词,它们不应用下划线分隔,并且第二个单词不应大写。(strconv 包就是一个例子。)

  • 导入的包名称可能与局部变量名称冲突,因此不要使用包用户可能也想使用的名称。(例如,如果 fmt 包被命名为 format,那么任何导入该包的人在命名局部变量 format 时都会面临冲突风险。)

包限定符

当访问来自不同包的导出函数、变量或类似内容时,需要通过输入包名称来限定函数或变量的名称。然而,当访问在当前包中定义的函数或变量时,不应限定包名称。

在我们的 main.go 文件中,因为我们的代码在 main 包中,我们需要指定 HelloHi 函数来自 greeting 包,通过输入 **greeting.Hello****greeting.Hi**

image

假设我们从 greeting 包中的另一个函数中调用了 HelloHi 函数。在那里,我们只需输入 HelloHi(不带包名限定符),因为我们将从定义它们的同一包中调用这些函数。

将我们的共享代码移动到一个包中

现在我们了解了如何向 Go 工作空间添加包,我们终于可以将我们的 getFloat 函数移动到一个包中,这样我们的 pass_fail.gotocelsius.go 程序都可以使用它。

image

让我们命名我们的包为 keyboard,因为它从键盘读取用户输入。我们将在工作空间的 src 目录下创建一个名为 keyboard 的新目录。

接下来,我们将在 keyboard 目录中创建一个源代码文件。我们可以任意命名它,但我们将其命名为包名:keyboard.go

文件顶部,我们需要一个 package 子句,并指定包名称为:keyboard

然后,因为这是一个单独的文件,我们需要一个import语句来引入我们代码中使用的所有包:bufioosstrconvstrings。(我们需要排除fmtlog包,因为它们仅在pass_fail.gotocelsius.go文件中使用。)

image

最后,我们可以直接复制旧的getFloat函数的代码。但我们需要确保将函数重命名为GetFloat,因为除非其名称的第一个字母大写,否则它不会被导出。

现在pass_fail.go程序可以更新以使用我们的新keyboard包。

image

因为我们要移除旧的getFloat函数,所以需要移除未使用的bufioosstrconvstrings导入项。我们将导入新的keyboard包。

在我们的main函数中,我们将不再调用旧的getFloat,而是调用新的keyboard.GetFloat函数。其余代码保持不变。

如果我们运行更新后的程序,我们将看到与之前相同的输出。

image

我们可以对tocelsius.go程序进行相同的更新。

我们更新了导入项,移除了旧的getFloat,并调用keyboard.GetFloat代替。

而且,如果我们运行更新后的程序,将会得到与之前相同的输出。但这次,我们不再依赖于冗余的函数代码,而是在我们的新包中使用共享函数!

常量

许多包会导出常量:这些是从不改变的命名值。

常量声明看起来很像变量声明,有名称、可选类型和常量值。但规则略有不同:

  • 不再使用var关键字,而是使用const关键字。

  • 常量在声明时必须赋值;不能像变量那样稍后再赋值。

  • 变量可以使用:=短变量声明语法,但常量没有类似的语法。

image

就像变量声明一样,您可以省略类型,类型将从被赋值的值中推断出来:

image

变量的值可以变化,但常量的值必须恒定。试图给常量赋予新值将导致编译错误。这是一种安全特性:常量应该用于不应变化的值。

image

如果您的程序包含“硬编码”的文字值,特别是这些值在多个地方使用,您应考虑将它们替换为常量(即使程序没有分成多个包)。这是一个包含两个函数的包,两者都使用整数字面值7表示一周有几天:

image

通过用常量DaysInWeek替换文字值,我们可以说明它们的含义。(其他开发人员看到DaysInWeek这个名称,立即知道我们不是随意选择数字7来在函数中使用。)而且,如果以后添加更多函数,可以通过引用DaysInWeek来避免不一致性。

注意,我们将常量声明在任何函数之外,即在包级别。虽然可以在函数内部声明常量,但那将限制其作用域仅在该函数的块中。更典型的做法是在包级别声明常量,以便所有函数都可以访问它们。

image

像变量和函数一样,以大写字母开头的常量是导出的,我们可以通过限定其名称从其他包中访问它们。在这里,程序使用dates包中的DaysInWeek常量,并将常量名称限定为dates.DaysInWeek

image

嵌套的包目录和导入路径

当您使用 Go 提供的像fmtstrconv这样的包时,包名通常与其导入路径相同(即在import语句中使用的字符串)。但正如我们在第二章中看到的那样,情况并非总是如此……

image

有些包集合通过导入路径前缀进行分组,例如"archive/""math/"。我们说这些前缀可以类比于硬盘上的目录路径……这并非巧合。这些导入路径前缀确实是使用目录创建的!

image

您可以在 Go 工作空间中的一个目录中嵌套类似的包组。

例如,假设我们想要添加其他语言的问候包。如果直接将它们全部放在src目录下,会很快变得混乱。但如果将新包放置在greeting目录下,它们将会整齐地分组在一起。

将包放置在greeting目录下也会影响它们的导入路径。如果dansk包直接存储在src下,其导入路径将是"dansk"。但将其放置在greeting目录下,则其导入路径变为"greeting/dansk"。将deutsch包移动到greeting目录下,其导入路径变为"greeting/deutsch"。原始的greeting包仍然可以通过导入路径"greeting"访问,只要其源代码文件直接存储在greeting目录下(而非子目录)。

假设我们有一个deutsch包嵌套在greeting包目录下,并且其代码看起来像这样:

image

让我们更新我们的hi/main.go代码,以便也使用deutsch包。因为它是嵌套在greeting目录下,我们需要使用引入路径"greeting/deutsch"。但一旦导入了它,我们将只使用包名deutsch来引用它。

image

与以前一样,我们通过使用**cd**命令切换到工作空间目录中的src/hi目录来运行我们的代码。然后,我们使用**go run main.go**来运行程序。我们将在输出中看到对deutsch包函数调用的结果。

image

使用“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目录中查找该目录。

image

当 Go 看到hi目录中的文件包含package main声明时,它会知道这是一个可执行程序的代码。它将编译一个可执行文件,并将其存储在 Go 工作空间中名为bin的目录中。(如果该目录不存在,将自动创建bin目录。)

go build命令不同,后者将可执行文件命名为基于其所依据的.go 文件。go install将可执行文件命名为包含代码的目录名称。由于我们编译了hi目录的内容,可执行文件将命名为hi(或在 Windows 上为hi.exe)。

image

现在,您可以使用**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包。现在,您想运行依赖于greetingmain.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 目录中的包将如下所示:

image

有了保存在 Go 工作空间中的包,它们就可以在程序中使用了。你可以通过像这样的 import 语句来使用 greetingdanskdeutsch 包:

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” 阅读包文档

image

你可以使用 **go doc** 命令来显示任何包或函数的文档。

通过将其导入路径传递给 go doc,你可以获取包的文档。例如,我们可以通过运行 go doc strconv 来获取 strconv 包的信息。

image

输出包括包名和导入路径(在这种情况下它们是一样的),包的整体描述,以及包导出的所有函数列表。

你也可以使用 go doc 通过在包名后面提供函数名来获取特定函数的详细信息。假设我们在 strconv 包的函数列表中看到了 ParseFloat 函数,并且想要了解更多信息。我们可以使用 go doc strconv ParseFloat 来查看它的文档。

你将会得到该函数的描述和它的功能:

image

第一行看起来就像代码中的函数声明。它包括函数名称,后面跟着包含其参数名称和类型(如果有的话)的括号。如果有返回值,那些将出现在参数后面。

然后是详细描述函数做什么以及开发人员在使用它时需要的其他信息。

我们可以通过将其导入路径提供给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包!

image

除了来自 Go 标准库的包之外,godoc工具还为你的 Go 工作区中的任何包构建 HTML 文档。这些可以是你安装的第三方包,也可以是你自己编写的包。

点击keyboard链接,你将进入包的文档页面。文档中将包含我们代码中的任何文档注释!

image

当你准备停止godoc服务器时,回到你的终端窗口,然后按住 Ctrl 键并按 C 键。你会回到系统提示符。

image

Go 使得文档化你的包变得简单,这使得包更容易分享,进而使其他开发者更容易使用。这只是使包成为代码共享的一个更好方式的又一特性!

你的 Go 工具箱

image

这就是第四章的内容!你已经向你的工具箱添加了包。

image

池谜题解答

你的任务是从池中获取代码片段,并将它们放入空白行中。不要重复使用同一个片段,并且你不需要使用所有的片段。你的目标是在 Go 工作区内设置一个calc包,以便在main.go中使用calc的函数。

image

第五章:列表:数组

image

许多程序处理各种列表。地址列表。电话号码列表。产品列表。Go 语言内置了两种存储列表的方法。本章将介绍第一种:数组。您将学习如何创建数组,如何填充数据以及如何再次获取这些数据。然后,您将学习如何处理数组中的所有元素,首先是使用for循环的困难方式,然后是使用for...range循环的简单方式

数组保存值的集合

一位当地餐馆老板面临一个问题。他需要知道未来一周需要订购多少牛肉。如果他订购太多,多余的将会浪费掉。如果他订购不足,他将不得不告诉顾客他无法做出他们最喜欢的菜肴。

他会记录过去三周使用的肉量数据。他需要一个程序来帮助他大致确定需要订购多少肉。

image

这应该足够简单:我们可以通过将三个金额相加并除以 3 来计算平均值。平均值应该能很好地估计需要订购的量。

image

第一个问题将是存储示例值。如果我们想稍后平均更多值,声明三个单独的变量将是一种痛苦。但是,与大多数编程语言一样,Go 提供了一种完美解决这种情况的数据结构...

数组是一组共享相同类型的值。将其视为一个有隔间的药盒 —— 您可以分别存储和检索每个隔间中的药丸,但也很容易将整个容器一起携带。

数组保存的值称为其元素。您可以有一个字符串数组,一个布尔数组,或者任何其他 Go 类型的数组(甚至是数组的数组)。您可以将整个数组存储在单个变量中,然后访问您需要的数组中的任何元素。

image

数组保存特定数量的元素,不能增长或缩小。要声明一个变量来保存数组,您需要在方括号([])中指定它保存的元素数,然后是数组保存的元素类型。

image

要设置数组元素的值或稍后检索值,您需要一种指定您要的元素的方法。数组中的元素从 0 开始编号。元素的编号称为其索引

image

例如,如果您想制作一个音阶上音符名称的数组,第一个音符将分配给索引0,第二个音符将在索引1处,依此类推。索引在方括号中指定。

image

这是一个整数数组:

image

这是一个time.Time值的数组:

image

数组中的零值

和变量一样,当创建数组时,它所包含的所有值都会被初始化为该数组所持有类型的零值。因此,一个包含int值的数组默认填充为零:

图片

然而,字符串的零值是一个空字符串,因此一个包含string值的数组默认填充为空字符串:

图片

即使你没有显式地为其分配一个值,零值也可以确保安全地操作数组元素。例如,在这里我们有一个整数计数器数组。我们可以增加任何一个计数器而无需先显式分配一个值,因为我们知道它们都将从0开始。

图片

当创建数组时,它所包含的所有值都会被初始化为该数组所持有类型的零值。

数组字面值

如果你事先知道数组应该包含哪些值,你可以使用数组字面值来初始化数组。数组字面值的开始方式与数组类型相同,使用方括号表示它将包含的元素数量,然后是其元素的类型。接着是用大括号括起来的初始值列表,每个元素的初始值应该用逗号分隔。

图片

这些示例与我们之前展示的示例非常相似,只是不再逐个为数组元素分配值,而是使用数组字面值初始化整个数组。

图片

使用数组字面值还可以使用:=进行简短的变量声明。

图片

你可以将数组字面值分布在多行,但在你的代码中,在每个换行字符之前必须使用逗号。如果在最后一个条目之后有换行符,则甚至需要在数组字面值的最后一个条目之后使用逗号。(这种风格起初看起来有些尴尬,但它使得以后添加更多元素变得更容易。)

图片

“fmt”包中的函数知道如何处理数组

当你只是尝试调试代码时,你不必逐个将数组元素传递给fmt包中的Println和其他函数。只需传递整个数组。fmt包中有逻辑来为你格式化和打印数组。(fmt包还可以处理我们稍后将看到的切片、映射和其他数据结构。)

图片

你可能还记得PrintfSprintf函数使用的"%#v"动词,它将值格式化为它们在 Go 代码中出现的样子。当用"%#v"格式化时,数组在结果中显示为 Go 数组字面值。

图片

在循环内访问数组元素

在你的代码中,你不必显式地写出你正在访问的数组元素的整数索引。你也可以使用整数变量中的值作为数组索引。

图片

这意味着你可以使用for循环处理数组的元素。你可以循环遍历数组中的索引,并使用循环变量访问当前索引处的元素。

image

在使用变量访问数组元素时,需要小心使用哪些索引值。正如我们所述,数组包含特定数量的元素。尝试访问超出数组的索引将导致panic,这是程序运行时发生的错误(而不是编译时)。

image

通常,panic 会导致程序崩溃并向用户显示错误消息。毫无疑问,应尽量避免 panic。

image

使用“len”函数检查数组长度

编写仅访问有效数组索引的循环可能会有些容易出错。幸运的是,有几种方法可以使这个过程更加简单。

第一种方法是在访问数组之前检查数组中的实际元素数。你可以使用内置的len函数来做到这一点,它返回数组的长度(即它包含的元素数)。

image

当设置循环以处理整个数组时,可以使用len来确定哪些索引是安全访问的。

image

然而,这仍然存在错误的可能性。如果len(notes)返回7,则最高可访问的索引是6(因为数组索引从0开始,而不是1)。如果尝试访问索引7,将会导致 panic。

image

安全地使用“for...range”循环遍历数组

更安全的处理数组每个元素的方式是使用特殊的for...range循环。在range形式中,你提供一个变量来保存每个元素的整数索引,另一个变量来保存元素的值,以及你要遍历的数组。循环将针对数组中的每个元素运行一次,将元素的索引分配给你的第一个变量,将元素的值分配给你的第二个变量。你可以在循环块中添加代码来处理这些值。

image

这种形式的for循环没有杂乱的初始化、条件和后置表达式。因为元素值会自动分配给一个变量,所以不会出现意外访问无效数组索引的风险。由于更安全且易于阅读,因此在处理数组和其他集合时,你经常会看到for循环的range形式被使用。

这里是我们之前的代码,用for...range循环打印我们的音符数组中的每个值:

image

循环运行七次,每次针对notes数组的一个元素。对于每个元素,index变量被设置为元素的索引,note变量被设置为元素的值。然后我们打印索引和值。

使用“for...range”循环结合空白标识符

与往常一样,Go 要求您使用您声明的每个变量。如果我们停止使用来自我们的for...range循环的index变量,我们将会得到一个编译错误:

image

如果我们不使用保存元素值的变量,情况也是如此:

image

记住在第二章中,当我们调用一个带有多个返回值的函数,并且我们想忽略其中一个时?我们将该值分配给空白标识符(_),这会导致 Go 丢弃该值,而不会产生编译器错误...

我们可以对for...range循环中的值做同样的处理。如果我们不需要每个数组元素的索引,我们可以将其分配给空白标识符:

image

如果我们不需要值变量,可以将其分配给空白标识符:

image

获取数组中数字的总和

image

我们终于知道了一切,我们需要创建一个float64值的数组并计算它们的平均值。让我们取过去几周使用的牛肉量,并将它们整合到一个名为average的程序中。

image

我们首先需要做的是设置一个程序文件。在您的 Go 工作空间目录(用户主目录内的go目录,除非您设置了GOPATH环境变量),创建以下嵌套目录(如果它们不存在)。在最内层的average目录中,保存一个名为main.go的文件。

image

现在让我们在main.go文件中编写我们的程序代码。由于这将是一个可执行程序,我们的代码将属于main包,并位于main函数中。

我们首先只计算三个样本值的总和;稍后我们可以返回计算平均值。我们使用数组字面量创建一个包含三个float64值的数组,预先填充了以前几周的样本值。我们声明一个名为sumfloat64变量来保存总和,从0开始。

然后我们使用for...range循环处理每个数字。我们不需要元素索引,因此使用_空白标识符将其丢弃。我们将每个数字添加到sum中。在我们计算出所有值的总和后,我们在退出前打印sum

image

让我们尝试编译和运行我们的程序。我们将使用go install命令创建一个可执行文件。我们将需要向go install提供我们可执行文件的导入路径。如果我们使用这个目录结构...

image

...这意味着我们包的导入路径将是[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)来运行可执行文件。

image

该程序将打印出我们数组中三个值的总和并退出。

获取数组中数字的平均值

我们的average程序已经打印出了数组值的总和,现在让我们更新它以打印实际的平均值。为此,我们将总和除以数组的长度。

将数组传递给len函数返回一个int值,表示数组的长度。但由于sum变量中的总数是float64值,我们也需要将长度转换为float64,这样才能在数学运算中使用。我们将结果存储在sampleCount变量中。完成后,我们只需将sum除以sampleCount,并打印结果即可。

image

一旦代码更新完成,我们可以重复之前的步骤来查看新的结果:运行go install重新编译代码,切换到bin目录,并运行更新后的average可执行文件。现在,我们将看到数组中值的平均数,而不是它们的总和。

image

池谜题

image

你的工作是从池中获取代码片段,并将它们放入这段代码中的空白行中。不要重复使用相同的片段,也不需要使用所有的片段。你的目标是创建一个程序,它将打印数组元素中介于1020之间的索引和值(应与显示的输出匹配)。

imageimage

注意:每个来自池中的片段只能使用一次!

image 答案在“池谜题解答”中。

读取文本文件

imageimage

那是真的 — 用户必须自行编辑和编译源代码的程序并不是很用户友好。

以前,我们使用标准库的osbufio包逐行从键盘读取数据。我们可以使用相同的包来逐行从文本文件中读取数据。让我们稍作偏离,学习如何做到这一点。

然后,我们将回来更新average程序,以从文本文件中读取数值。

在你喜欢的文本编辑器中,创建一个名为data.txt的新文件。现在,将其保存在你的 Go 工作空间目录之外的某个地方。

在文件中,输入我们的三个浮点数样本值,每行一个数字。

imageimage

在我们更新程序以计算文本文件中数字的平均值之前,我们需要能够读取文件的内容。首先,让我们编写一个仅读取文件的程序,然后我们将所学的内容整合到我们的平均值程序中。

image

在与data.txt相同的目录中创建一个名为readfile.go的新程序。我们将只用go run运行readfile.go,所以可以将它保存在 Go 工作区目录之外。将以下代码保存在readfile.go中。(我们将在下一页详细查看这段代码的工作原理。)

image

然后,从您的终端,切换到保存了这两个文件的目录,并运行go run readfile.go。该程序将读取data.txt的内容,并将其打印出来。

imageimage

我们的测试readfile.go程序成功读取了data.txt文件的行并将其打印出来。让我们更仔细地看看程序是如何工作的。

我们首先将要打开的文件名作为字符串传递给os.Open函数。os.Open将返回两个值:指向打开文件的os.File值的指针,和一个error值。与许多其他函数一样,如果error值为nil,表示文件成功打开;否则,表示出现错误(例如文件丢失或不可读)。如果出现错误,我们将记录错误消息并退出程序。

image

然后我们将os.File值传递给bufio.NewScanner函数。这将返回一个从文件中读取的bufio.Scanner值。

image

bufio.Scanner上的Scan方法设计成作为for循环的一部分使用。它将从文件中读取一行文本,如果成功读取数据则返回true,如果没有则返回false。如果在for循环的条件中使用Scan,则循环将继续运行,直到没有更多数据可读取为止。一旦到达文件的末尾(或出现错误),Scan将返回false,循环将退出。

bufio.Scanner上调用Scan方法后,调用Text方法将返回一个包含读取数据的字符串。对于这个程序,我们只需在循环内调用Println来打印每一行。

image

循环退出后,我们已经完成了文件的操作。保持文件打开会消耗操作系统的资源,所以当程序完成文件操作时应该关闭文件。调用os.File上的Close方法可以实现这一点。与Open函数不同,Close方法只返回一个error值,除非发生问题,否则该值为nil。(与Open不同,Close只返回一个值,因为除了错误之外没有其他有用的返回值。)

image

bufio.Scanner在扫描文件时可能会遇到错误。如果遇到错误,调用扫描器的Err方法将返回该错误,我们在退出前将其记录。

imageimage

将文本文件读取到数组中

我们的readfile.go程序运行良好——我们能够将data.txt文件中的行作为字符串读取并打印出来。现在我们需要将这些字符串转换为数字并存储在数组中。让我们创建一个名为datafile的包来为我们完成这个任务。

image

在您的 Go 工作空间目录中,在headfirstgo目录下创建一个datafile目录。在datafile目录中,保存一个名为floats.go的文件。(我们将其命名为floats.go,因为此文件将包含从文件中读取浮点数的代码。)

image

floats.go中,保存以下代码。其中很多内容基于我们测试的readfile.go程序中的代码;我们将代码相同的部分标记为灰色。我们将在下一页详细解释新代码。

image

我们希望能够从除了data.txt之外的文件中读取,因此我们将文件名作为参数接受。我们设置函数返回两个值,一个是float64值的数组,另一个是error值。像大多数返回错误的函数一样,只有当错误值为nil时,才应该考虑使用第一个返回值。

image

接下来,我们声明一个包含三个float64值的数组,用于保存从文件中读取的数字。

image

就像在readfile.go中一样,我们打开文件进行读取。不同之处在于,我们不是使用硬编码的字符串"data.txt",而是打开传递给函数的任何文件名。如果遇到错误,我们需要返回一个数组以及错误值,因此我们只返回numbers数组(即使尚未为其分配任何内容)。

image

我们需要知道将每一行分配给哪个数组元素,因此我们创建一个变量来跟踪当前索引。

image

设置bufio.Scanner并循环遍历文件的行的代码与readfile.go中的代码相同。然而,循环内的代码不同:我们需要对从文件中读取的字符串调用strconv.ParseFloat来将其转换为float64,并将结果分配给数组。如果ParseFloat导致错误,我们需要返回该错误。如果解析成功,我们需要增加i,以便将下一个数字分配给下一个数组元素。

image

我们关闭文件并报告任何错误的代码与readfile.go完全相同,只是我们返回任何错误而不是直接退出程序。 如果没有错误,将到达GetFloats函数的末尾,并将float64值数组与nil错误一起返回。

图像

更新我们的“average”程序以读取文本文件

我们准备好用从data.txt文件中读取的数组替换average程序中的硬编码数组了!

图像

编写我们的datafile包是难点所在。 在主程序中,我们只需要做三件事:

  • 更新我们的import声明以包括datafilelog包。

  • 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 工具箱

image

第五章就到这里!你已经把数组加入了你的工具箱。

image

池谜题解答

image

第六章:追加问题:切片

image

我们已经学到,无法向数组添加更多元素。 这对我们的程序来说是个真正的问题,因为我们事先不知道文件中包含多少数据。但这就是 Go 切片派上用场的地方。切片是一种集合类型,可以动态增加项,正好可以修复我们当前的程序!我们还将看到切片如何为用户提供更简单的方式来提供所有程序所需的数据,并且如何帮助您编写更方便调用的函数。

切片

实际上一种 Go 数据结构,我们可以向其中添加更多值,它被称为切片。与数组一样,切片由多个相同类型的元素组成。不同于数组的是,函数可用于允许我们将额外的元素添加到切片的末尾。

要声明一个变量,其类型为切片,请使用一对空方括号,后跟切片将容纳的元素类型。

image

这与声明数组变量的语法类似,只是不指定大小。

image

与数组变量不同,声明切片变量不会自动创建切片。为此,您可以调用内置的make函数。将切片的类型(应与要分配给它的变量的类型相同)和应创建的切片长度传递给make

image

创建切片后,使用与数组相同的语法分配和检索其元素。

image

您不必分开声明变量并创建切片;使用带有短变量声明的make函数将为您推断变量的类型。

image

内置的len函数与切片一样使用,就像它与数组一样使用。只需将切片传递给len,它的长度将作为整数返回。

image

对于切片,forfor...range循环的工作方式与数组完全相同:

image

切片字面量

就像数组一样,如果您事先知道切片将从哪些值开始,可以使用切片字面量初始化切片。切片字面量看起来很像数组字面量,但数组字面量在方括号中有数组长度,而切片字面量的方括号是空的。然后,空括号后跟切片将容纳的元素类型,并在大括号中列出每个元素的初始值。

您不需要调用make函数;在代码中使用切片字面量将创建切片,并预填充它。

image

这些示例与我们之前展示的示例类似,只是不是逐个为切片元素分配值,而是使用切片字面量初始化整个切片。

图片

池谜题

图片

你的任务是从代码池中获取代码片段,并将它们放入空白行中。不要重复使用相同的片段,你不需要使用所有的片段。你的目标是创建一个能够运行并产生所示输出的程序。

图片图片

注意:每个代码池中的片段只能使用一次!

图片 答案在“池谜题解答”中。

图片

因为切片是建立在数组之上的。如果不了解数组,就不能理解切片的工作原理。在这里,我们将向你展示为什么……

切片操作符

每个切片都建立在一个底层数组之上。实际上,是底层数组保存了切片的数据;切片只是对数组元素的一种(或全部)视图。

当你使用make函数或切片文字创建一个切片时,底层数组会自动为你创建(你无法直接访问它,除非通过切片)。但你也可以自己创建数组,然后基于它使用切片操作符创建一个切片。

图片

切片操作符看起来类似于访问数组单个元素或切片的语法,不同之处在于它有两个索引:切片应该从数组的哪个索引开始,以及切片应该在数组的哪个索引之前停止。

图片

请注意,我们强调第二个索引是切片将在哪里停止的索引。也就是说,切片应该包括元素直到第二个索引,但包括第二个索引。如果你使用underlyingArray[i:j]作为切片操作符,生成的切片实际上将包含元素underlyingArray[i]underlyingArray[j-1]

注意

(我们知道,这有些违反直觉。但类似的表示法在 Python 编程语言中已经使用了 20 多年,而且似乎工作得很好。)

图片

如果你希望切片包含底层数组的最后一个元素,实际上你需要指定第二个索引,该索引比切片操作符中的数组末尾元素索引多一个。

图片

确保不要再往前走,否则会出现错误:

图片

切片操作符对于起始索引和停止索引都有默认值。如果省略起始索引,将使用0(数组的第一个元素)。

图片

如果你省略了停止索引,那么从起始索引到底层数组末尾的所有内容都会包含在生成的切片中。

图片

底层数组

正如我们所提到的,切片本身不保存任何数据;它只是对底层数组元素的一种视图。你可以把切片想象成一种显微镜,聚焦于幻灯片(即底层数组)内容的特定部分。

image

当您获取底层数组的切片时,您只能“看到”通过切片可见的部分数组元素。

image

即使可能存在多个切片指向同一个底层数组的情况。每个切片将是对其自己子集中的数组元素的视图。这些切片甚至可以重叠!

image

更改底层数组,更改切片

现在,这里有一些需要注意的事情:因为切片只是对数组内容的一种视图,如果您更改底层数组,这些更改也将在切片中可见

image

将一个切片元素赋予一个新值将会改变底层数组中对应的元素。

image

如果多个切片指向同一个底层数组,对数组元素的更改将在所有切片中可见。

image

由于这些潜在问题,您可能会发现,通常最好使用make或切片文字创建切片,而不是创建一个数组并在其上使用切片操作符。使用make和切片文字,您永远不必直接操作底层数组。

使用“append”函数添加到切片

image

Go 语言提供了一个内置的append函数,接受一个切片和一个或多个要追加到该切片末尾的值。它返回一个新的、更大的切片,其中包含与原始切片相同的所有元素,以及添加到末尾的新元素。

image

您无需跟踪要分配新值的索引,也无需其他任何操作!只需使用您的切片和要添加到末尾的值(s),调用append,您将得到一个新的、更长的切片。就是这么简单!

好吧,有一个注意事项...

请注意,我们确保将append的返回值分配回相同的切片变量,我们传递给append。这是为了避免从append返回的切片中可能出现的一些不一致的行为。

切片的底层数组大小不能增长。如果数组中没有足够的空间来添加元素,所有元素将被复制到一个新的、更大的数组中,并且切片将被更新以引用这个新数组。但由于所有这些操作都是在append函数的背后进行的,因此很难判断从append返回的切片是否具有与传入的切片相同的底层数组,还是不同的底层数组。如果保留了两个切片,这可能导致一些不可预测的行为。

例如,在下面,我们有四个切片,最后三个通过调用append创建。这里我们没有遵循将append的返回值重新分配给同一变量的约定。当我们将值分配给s4切片的元素时,可以看到在s3中反映出变化,因为s4s3恰好共享相同的底层数组。但是这种变化反映在s2s1中,因为它们有一个不同的底层数组。

图片

因此,在调用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包)并运行它。它应该与以前一样工作。但现在我们的错误处理代码更加清晰了一些。

图片

image 答案在“image 练习解答”

命令行参数

image

还有一种方法——用户可以将数值作为命令行参数传递给程序。

就像你可以通过向许多 Go 函数传递参数来控制它们的行为一样,你也可以向从终端或命令提示符运行的许多程序传递参数。这被称为程序的命令行界面

在这本书中,你已经看到了命令行参数的使用。当我们运行cd(“change directory”)命令时,我们将要切换到的目录名作为参数传递给它。当我们运行go命令时,我们经常传递多个参数:我们想要使用的子命令(runinstall等)以及我们希望子命令处理的文件或包的名称。

image

os.Args切片获取命令行参数

让我们设置一个名为average2的新版本的average程序,它接受要计算平均值的数值作为命令行参数。

os包有一个包变量,os.Args,它被设置为一个字符串切片,表示当前运行的程序执行时带有的命令行参数。我们首先将简单地打印出os.Args切片,以查看它包含了什么内容。

在你的工作空间中average目录旁边创建一个名为average2的新目录,并在其中保存一个main.go文件。

image

然后,在main.go中保存以下代码。它简单地导入了fmtos包,并将os.Args切片传递给fmt.Println

image

让我们试一试。从你的终端或命令提示符中运行以下命令来编译和安装程序:

go install github.com/headfirstgo/average2

这将在你的 Go 工作空间的bin子目录中安装一个名为average2(在 Windows 上为average2.exe)的可执行文件。使用cd命令切换到bin,并输入average2,但还不要立即按 Enter 键。在程序名称后面,输入一个空格,然后输入一个或多个用空格分隔的参数。然后按 Enter 键。程序将运行并打印出os.Args的值。

使用不同的参数重新运行average2,你应该看到不同的输出。

image

切片操作符可以用在其他切片上

这运行得相当顺利,但有一个问题:可执行文件的名称被包括在os.Args的第一个元素中。

image

不过这应该很容易移除。还记得我们如何使用切片操作符获取一个包含数组除第一个元素以外的所有元素的切片吗?

image

切片操作符可以像在数组上一样在切片上使用。如果我们在os.Args上使用切片操作符[1:],它将给我们一个新的切片,省略了第一个元素(索引为0),并包括第二个元素(索引1)到切片的末尾。

image

如果我们重新编译并重新运行average2,这次我们将看到输出只包括实际的命令行参数。

image

更新我们的程序以使用命令行参数

现在我们能够将命令行参数作为字符串切片获取,让我们更新average2程序将参数转换为实际数字,并计算它们的平均值。我们将大部分概念重用到我们原始的average程序和datafile包中学到的概念。

我们在os.Args上使用切片操作符来省略程序名称,并将结果切片赋给一个arguments变量。我们设置一个sum变量,它将保存我们得到的所有数字的总和。然后我们使用for...range循环来处理arguments切片的元素(使用_空白标识符来忽略元素索引)。我们使用strconv.ParseFloat将参数字符串转换为float64。如果出现错误,我们记录并退出,否则我们将当前数字添加到sum中。

当我们循环遍历所有参数时,我们使用len(arguments)来确定我们要计算平均值的数据样本数量。然后我们将sum除以这个样本计数以获得平均值。

image

保存这些更改后,我们可以重新编译并重新运行程序。它将接受您提供的数字作为参数并计算它们的平均值。无论您提供多少参数,它都能正常工作!

image

可变参数函数

现在我们了解了切片,我们可以介绍一下迄今为止我们还没有讨论过的 Go 特性。你是否注意到一些函数调用可以接受所需数量的参数?例如看看fmt.Printlnappend

image

不过,不要尝试对任何函数都这样做!到目前为止,我们定义的所有函数,在函数定义中的参数数量和函数调用中的参数数量之间必须有精确匹配。任何差异都会导致编译错误。

image

那么Printlnappend是如何做到的呢?它们被声明为可变参数函数。可变参数函数是可以使用不同数量的参数调用的函数。要使函数可变参数,可以在函数声明中的最后(或唯一)函数参数的类型之前使用省略号(...)。

image

可变参数函数的最后一个参数接收可变参数作为一个切片,函数可以像处理任何其他切片一样处理它们。

这里是twoInts函数的可变参数版本,它可以很好地处理任意数量的参数:

image

这是一个类似的函数,适用于字符串。请注意,如果我们没有提供变参参数,这并不是错误;函数会接收到一个空切片。

图片

函数可以接受一个或多个非变参参数。虽然函数调用者可以省略变参参数(导致空切片),但非变参参数总是必需的;省略它们会导致编译错误。只有在函数定义中的最后一个参数可以是变参;你不能把它放在必需参数的前面。

图片

使用变参函数

这是一个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函数。但当我们尝试编译程序时,结果出现错误……

image

average函数期望一个或多个float64参数,而不是一组float64值的切片……

那么现在怎么办?我们是被迫在使函数可变参数和能够将切片传递给它们之间做出选择吗?

幸运的是,Go 语言为这种情况提供了特殊的语法。在调用可变参数函数时,只需在要替代可变参数的切片后面添加省略号(...)即可。

image

所以我们只需要在调用average时,在numbers切片后面添加省略号即可。

image

经过这些更改,我们应该能够重新编译并运行我们的程序。它将把我们的命令行参数转换为一个float64值的切片,然后将该切片传递给可变参数average函数。

image

切片拯救了我们!

image

对于任何编程语言来说,处理值列表是至关重要的。通过数组和切片,您可以将数据保留在任何所需大小的集合中。而且,通过像for...range循环这样的特性,Go 语言还可以轻松处理这些集合中的数据!

您的 Go 工具箱

image

这就是关于第六章的全部内容!您已经将切片添加到了您的工具箱中。

image

Pool Puzzle Solution

image

Code Magnets Solution

image

第七章:标记数据:Maps

image

将东西随意堆放是可以的,直到你需要再次找到某样东西。 你已经学会了如何使用 数组切片 创建值的列表。你也学会了如何对数组或切片中的 每个值 应用相同的操作。但是如果你需要处理 特定的 值呢?为了找到它,你必须从数组或切片的开头开始,查找每一个单独的值

如果有一种集合,每个值都带有标签,那该多好啊?你可以快速找到你需要的值!在这一章中,我们将介绍 maps,它们正是做这件事的。

统计选票

今年,Sleepy Creek 县学校董事会的一个席位空缺,民意调查显示选举结果非常接近。现在已经是选举之夜,候选人们正兴奋地观看选票的涌入。

注:

这是另一个在《Head First Ruby》中首次亮相的例子,出现在哈希章节。Ruby 的哈希与 Go 的 maps 非常相似,所以这个例子在这里也非常适用!

image

姓名:安伯·格雷厄姆

职业:经理**

image

姓名:布莱恩·马丁

职业:会计**

投票中有两位候选人,安伯·格雷厄姆和布莱恩·马丁。选民还可以选择“写入”候选人的名字(即输入一个未出现在选票上的名字)。这些情况不会像主要候选人那样常见,但我们预计会有一些这样的名字出现。

今年使用的电子投票机会将选票记录到文本文件中,每行一票。(由于预算紧张,市议会选择了廉价的投票机供应商。)

这是一个关于 A 区所有选票的文件:

image

我们需要处理文件的每一行,并统计每个名字出现的总次数。得票最多的名字将成为我们的获胜者!

从文件中读取名字

我们的第一项工作是读取 votes.txt 文件的内容。前几章中的 datafile 包已经有一个 GetFloats 函数,它可以将文件的每一行读取到一个切片中,但 GetFloats 只能读取 float64 值。我们需要一个单独的函数,能够将文件行作为 string 值的切片返回。

所以让我们首先在 datafile 包目录中与 floats.go 文件并列创建一个 strings.go 文件。在那个文件中,我们将添加一个 GetStrings 函数。GetStrings 中的代码将与 GetFloats 中的代码非常相似(我们已经灰化了相同的代码)。但是,与将每一行转换为 float64 值不同的是,GetStrings 将直接将行添加到我们要返回的切片中,作为 string 值。

image

现在让我们创建实际计数选票的程序。我们将其命名为count。在您的 Go 工作空间中,进入src/github.com/headfirstgo目录并创建一个名为count的新目录。然后在count目录中创建一个名为main.go的文件。

在编写完整程序之前,让我们确认我们的GetStrings函数是否正常工作。在main函数的顶部,我们将调用datafile.GetStrings,将"votes.txt"作为要读取的文件名传递给它。我们将把返回的字符串切片存储在名为lines的新变量中,将任何错误存储在名为err的变量中。通常情况下,如果err不为nil,我们会记录错误并退出。否则,我们将简单地调用fmt.Println来打印出lines切片的内容。

image

就像我们对其他程序所做的那样,您可以通过运行go install并提供包导入路径(在这种情况下是datafile)来编译此程序及其依赖的任何包。如果您使用了上述的目录结构,那么导入路径应该是github.com/headfirstgo/count

image

这将在您的 Go 工作空间的bin子目录中保存一个名为count(或在 Windows 上为count.exe)的可执行文件。

就像前几章的data.txt文件一样,我们需要确保在运行程序时当前目录中保存了votes.txt文件。在您的 Go 工作空间的bin子目录中,保存一个具有右侧显示内容的文件。在终端中,使用**cd**命令切换到相同的子目录。

image

现在您应该能够通过键入**./count**(或在 Windows 上键入**count.exe**)来运行可执行文件。它应该将votes.txt的每一行读入一个字符串切片,然后将该切片打印出来。

image

用切片来进行名字计数的困难方式

从文件中读取一个名字切片并不需要学习任何新东西。但现在来面对挑战:我们如何计算每个名字出现的次数?我们将展示两种方法,首先是使用切片,然后是使用一个新的数据结构——映射

对于我们的第一个解决方案,我们将创建两个切片,每个切片具有相同数量的元素,以特定的顺序。第一个切片将保存我们在文件中找到的名字,每个名字出现一次。我们可以称之为names。第二个切片counts将保存文件中每个名字出现的次数。元素counts[0]将保存names[0]的计数,counts[1]将保存names[1]的计数,依此类推。

image

让我们更新count程序,实际上计算文件中每个名字出现的次数。我们将尝试这个方案,使用一个names切片来保存每个唯一候选人名字,并使用一个对应的counts切片来跟踪每个名字出现的次数。

image

一如既往,我们可以使用go install重新编译程序。如果我们运行生成的可执行文件,它将读取votes.txt文件,并打印出它找到的每个名称,以及该名称出现的次数!

image

让我们更详细地看看它是如何工作的...

我们的count程序使用一个内循环嵌套另一个循环中来统计名称计数。外部循环逐行将文件赋给line变量。

image

内部循环搜索names切片的每个元素,查找与文件当前行相等的名称。

image

假设有人在选票上增加了一个自荐候选人,导致文本文件中的一行加载了字符串"Carlos Diaz"。程序将逐一检查names的元素,以查看是否有任何元素等于"Carlos Diaz"

image

如果没有匹配项,程序将字符串"Carlos Diaz"附加到names切片,并将1对应地添加到counts切片(因为这行代表了对"Carlos Diaz"的第一次投票)。

image

但假设下一行是字符串"Brian Martin"。因为该字符串已经存在于names切片中,程序将找到它,并在counts中相应的值上加1

image

映射

但是将名称存储在切片中存在一个问题:对于文件的每一行,您必须搜索names切片中的许多(如果不是全部)值来进行比较。这在像 Sleepy Creek 县这样的小区域可能还好,但在有大量选票的大区域中,这种方法将会非常慢!

image

把数据放入切片就像把它堆放在一个大堆里;你可以取回特定的项,但必须搜索所有东西才能找到它们。

image

切片

Go 还有另一种存储数据集合的方式:映射映射是一种通过访问每个值的集合。键是从映射中轻松取回数据的一种方式,就像有整齐标签的文件夹而不是一堆乱七八糟的东西。

image

映射

而数组和切片只能使用整数作为索引,映射可以使用任何类型作为键(只要该类型的值可以使用==进行比较)。这包括数字、字符串等。所有值必须是相同类型,所有键必须是相同类型,但键不必与值的类型相同。

要声明一个包含映射的变量,你需要输入map关键字,后面跟着方括号([])包含键类型。然后,在方括号后面,提供值类型。

image

与切片类似,声明映射变量并不会自动创建映射;你需要调用make函数(与用于创建切片的相同函数)。与切片类型不同,你可以将要创建的映射类型传递给make(应与要分配给它的变量类型相同)。

image

或许你会发现仅仅使用短变量声明更容易:

image

分配值给映射并再次获取它们的语法看起来很像为数组或切片分配和获取值的语法。但是,数组和切片只允许使用整数作为元素索引,而你几乎可以选择任何类型来用作映射的键。ranks映射使用string键:

image

数组和切片只能使用整数索引。但你几乎可以选择任何类型来用作映射键。

这是另一个以字符串为键和字符串为值的映射:

image

这是一个以整数为键和布尔值为值的映射:

image

映射字面量

与数组和切片类似,如果你事先知道要在映射中使用的键和值,可以使用映射字面量来创建它。映射字面量以映射类型开头(形式为map[*KeyType*]*ValueType*)。然后是包含你想要映射开始的键/值对的大括号。对于每个键/值对,包括键,冒号,然后是值。多个键/值对用逗号分隔。

image

下面是前面几个映射示例,使用映射字面量重新创建:

image

就像切片字面量一样,如果大括号为空,就会创建一个空的映射。

image

映射内的零值

与数组和切片类似,如果访问尚未分配的映射键,将返回一个零值。

image

根据值类型不同,零值实际上可能不是0。例如,对于值类型为string的映射,零值将是空字符串。

image

与数组和切片类似,即使你尚未显式分配给它,零值也可以确保安全地操作映射值。

image

映射变量的零值是nil

与切片一样,映射变量本身的零值是nil。如果声明了一个映射变量,但没有为它分配值,那么它的值将是nil。这意味着没有映射存在来添加新的键和值。如果尝试这样做,会导致恐慌:

image

在尝试添加键和值之前,使用make或映射字面量创建一个映射,并将其分配给你的映射变量。

image

如何区分零值和分配的值

虽然零值很有用,但有时很难判断给定键是否已分配了零值,或者它从未被分配过。

下面是一个程序示例,其中可能会出现此问题。这段代码错误地报告学生"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 来打印当前候选人的姓名和选票数。

image

通过 go install 进行另一个编译,再运行可执行文件,我们就能看到以新格式输出的结果了。每个候选人的姓名和他们的选票数都在这里,整洁地格式化在各自的行上。

image

投票计数程序完成!

imageimage

我们的投票计数程序已完成!

当我们只能使用数组和切片作为数据集合时,我们需要大量的额外代码和处理时间来查找值。但是使用了映射(map)后,这一过程变得很简单!每当你需要再次查找集合的值时,你都应该考虑使用映射!

代码磁铁

image

一个使用 for...range 循环来打印映射内容的 Go 程序被混在冰箱上。你能重建代码片段,使之成为一个能够产生给定输出的可工作程序吗?(如果程序运行时输出顺序不同也没关系。)

image

image 答案在“代码磁铁解决方案”中。

你的 Go 工具箱

image

这就是第七章的全部内容!你已经把地图添加到了你的工具箱里。

image

代码磁铁解决方案

![image image

第八章:构建存储:结构体

image

有时您需要存储多种类型的数据。

我们学习了关于切片的知识,它们可以存储值列表。然后我们学习了关于映射的知识,它们将键列表映射到值列表。但是这两种数据结构只能保存一种类型的值。有时,您需要将多种类型的值组合在一起。例如邮寄地址,您需要将街道名称(字符串)与邮政编码(整数)混合在一起。或者学生记录,您需要将学生姓名(字符串)与平均绩点(浮点数)混合在一起。您不能在切片或映射中混合值类型。但是如果使用另一种称为结构体的类型,那么可以。我们将在本章中详细了解结构体!

切片和映射只能保存一种类型的值。

Gopher Fancy是一本专门致力于可爱啮齿动物的新杂志。他们目前正在开发一个系统来跟踪他们的订阅者群体。

imageimage

确实如此:数组、切片和映射无法帮助您混合不同类型的值。它们只能设置为保存单一类型的值。但 Go 确实有一种方法来解决这个问题……

结构体由多种类型的值构成

结构体(简称“结构”)是由许多不同类型的其他值构成的值。虽然切片可能只能保存string值,或者映射可能只能保存int值,但是您可以创建一个结构体,其中既可以保存string值,也可以保存int值、float64值、bool值等等——所有这些都放在一个方便的组合中。

image

使用struct关键字声明结构体类型,后跟花括号。在花括号内部,您可以定义一个或多个字段:结构体组合在一起的值。每个字段定义都显示在单独的行上,并包含字段名,后跟该字段将保存的值类型。

image

您可以将结构体类型用作正在声明的变量的类型。此代码声明了一个名为myStruct的变量,该变量保存具有float64字段numberstring字段wordbool字段toggle的结构体:

注意

(通常使用定义的类型来声明结构体变量更常见,但我们将在几页后再详细讨论类型定义,所以现在我们将按照这种方式编写。)

image

当我们在上面使用Printf%#v动词调用时,它将结构体myStruct的值打印为结构体字面量。我们稍后将介绍结构体字面量,但现在您可以看到结构体的number字段已设置为0word字段为空字符串,toggle字段设置为false。每个字段都设置为其类型的零值。

使用点运算符访问结构体字段

现在我们可以定义一个结构体,但实际使用它时,我们需要一种方法来存储结构体字段的新值并再次检索它们。

一直以来,我们一直在使用点运算符来指示“属于”另一个包的函数,或者“属于”一个值的方法:

图片

同样,我们可以使用点运算符来指示“属于”结构体的字段。这对于分配值和检索值都适用。

图片

我们可以使用点运算符为myStruct的所有字段赋值,然后将它们打印出来:

图片

将订阅者数据存储在结构体中

现在我们知道如何声明一个变量来保存结构体并为其字段赋值,我们可以创建一个用于保存杂志订阅者数据的结构体。

首先,我们将定义一个名为subscriber的变量。我们将给subscriber一个结构体类型,其中包含namestring)、ratefloat64)和activebool)字段。

声明变量及其类型后,我们可以使用点运算符访问结构体的字段。我们为每个字段分配适当类型的值,然后再次将这些值打印出来。

图片

即使我们为订阅者存储的数据使用了各种类型,结构体也让我们能够将它们都放在一个便捷的包中!

定义类型和结构体

图片

在整本书中,您已经使用了多种类型,比如intstringbool,切片,映射,现在又是结构体。但是,您还没有能够创建完全的类型。

类型定义允许您创建自己的类型。它们让您创建一个基于基础类型的新的定义类型

虽然您可以使用任何类型作为基础类型,比如float64string,甚至是切片或映射,但在本章中,我们将专注于使用结构类型作为基础类型。在下一章中,当我们深入研究定义类型时,我们将尝试使用其他基础类型。

要编写类型定义,请使用type关键字,后跟您的新定义类型的名称,然后是要基于的基础类型。如果您使用结构体类型作为基础类型,您将使用struct关键字,后跟花括号中的字段定义列表,就像在声明结构体变量时所做的那样。

图片

就像变量一样,类型定义可以写在函数内部。但这将限制其范围仅在该函数的块内,意味着您不能在该函数外部使用它。因此,类型通常是在包级别的任何函数外定义的。

作为一个快速演示,下面的代码定义了两种类型:partcar。每个定义类型都使用结构体作为其基础类型。

然后,在main函数内部,我们声明了一个porsche变量,类型为car,以及一个bolts变量,类型为part。在声明变量时,无需重新编写冗长的结构体定义;我们只需使用定义类型的名称即可。

图片

声明变量后,我们可以设置其结构体字段的值,并像以前的程序一样获取这些值。

使用定义类型处理杂志订阅者

以前,要创建多个存储杂志订阅者数据的变量,我们必须为每个变量写出完整的结构体类型(包括所有字段)。

image

但现在,我们可以简单地在包级别定义一个subscriber类型。我们只需一次编写结构体类型,作为定义类型的基础类型。当我们准备声明变量时,我们不必再次编写结构体类型;我们只需将subscriber用作它们的类型。不再需要重复整个结构体定义!

image

使用定义类型处理函数

定义类型不仅可以用于变量类型,还可以用于函数参数和返回值。

这里是我们的part类型,还有一个新的showInfo函数,它打印部件的字段。该函数接受一个参数,其类型为part。在showInfo中,我们像处理任何其他结构体变量一样通过参数变量访问字段。

image

这里有一个minimumOrder函数,它创建一个带有指定描述和count字段预定义值的part。我们将minimumOrder的返回类型声明为part,以便它可以返回新的结构体。

image

让我们来看看几个与杂志的subscriber类型一起使用的函数……

printInfo函数接受一个subscriber作为参数,并打印其字段的值。

我们还有一个defaultSubscriber函数,它用一些默认值设置一个新的subscriber结构体。它接受一个名为name的字符串参数,并使用它来设置新的subscriber值的name字段。然后它将rateactive字段设置为默认值。最后,它将完成的subscriber结构体返回给其调用者。

image

在我们的main函数中,我们可以将订阅者名称传递给defaultSubscriber以获取一个新的subscriber结构体。一个订阅者获得了折扣rate,因此我们直接重置该结构体字段。我们可以将填写好的subscriber结构体传递给printInfo以打印它们的内容。

代码磁铁

image

一个 Go 程序被分散在冰箱上。你能重组代码片段,使之成为一个能够产生指定输出的工作程序吗?最终程序将具有名为student的定义结构体类型,以及一个接受student值作为参数的printInfo函数。

image

image 答案在“代码磁铁解决方案”中。

使用函数修改结构体

imageimage

我们在Gopher Fancy的朋友们正试图编写一个函数,该函数接受一个结构体作为参数,并更新该结构体中的一个字段。

还记得很久以前的第三章吗?当时我们试图编写一个double函数,它接受一个数字并使其加倍?在double返回后,数字回到了原始值!

那时我们了解到,Go 语言是一种“传值”语言,这意味着函数参数接收的是调用函数时传入的参数的副本。如果函数修改参数的值,它修改的是这个副本,而不是原始值

图片

对结构也是如此。当我们将subscriber结构体传递给applyDiscount时,函数接收的是结构体的副本。因此,当我们设置结构体的rate字段时,我们修改的是复制的结构体,而不是原始结构体。

图片

回到第三章,我们的解决方案是更新函数参数以接受值的指针,而不是直接接受值。在调用函数时,我们使用取地址操作符(&)传递要更新的值的指针。然后,在函数内部,我们使用*操作符来更新该指针指向的值。

所以,在函数返回后,更新后的值仍然可见。

图片

我们可以使用指针允许函数更新结构体。

这是一个更新后的applyDiscount函数的版本,应该可以正常工作。我们更新s参数以接受指向subscriber结构体的指针,而不是结构体本身。然后我们更新结构体的rate字段的值。

main函数中,我们使用指向subscriber结构体的指针调用applyDiscount。当我们打印结构体中的rate字段时,我们可以看到它已成功更新!

图片图片

事实上,不是这样!点符号访问字段的方法对结构指针以及结构本身都适用。

通过指针访问结构字段

如果尝试打印指针变量,你会看到它指向的内存地址。这通常不是你想要的。

图片

相反,你需要使用*操作符(我们喜欢称之为“值在操作符”)来获取指针指向的值。

图片

因此,你可能认为你需要对结构体指针使用*操作符。但仅仅在结构体指针前加上*并不能起作用:

图片

如果你写*pointer.myField,Go 认为myField必须包含一个指针。但实际上并非如此,这会导致错误。要使其工作,你需要在*pointer周围加上括号。这将导致检索myStruct值,然后可以访问结构字段。

图片

虽然必须经常写(*pointer).myField会很快变得乏味。因此,点操作符允许您通过指向结构体的指针访问字段,就像您可以直接从结构体值访问字段一样。您可以省略括号和*操作符。

image

这对通过指针分配结构体字段同样适用:

image

这就是applyDiscount函数如何能够在不使用*操作符的情况下更新结构体字段的方式。

image

没有愚蠢的问题

Q: 你之前展示了一个defaultSubscriber函数来设置结构体的字段,但它不需要使用任何指针!为什么不需要?

A: defaultSubscriber函数返回了一个结构体值。如果调用者存储了返回的值,那么其字段中的值将会被保留。只有那些修改现有结构体而不返回它们的函数才需要使用指针,以便这些更改能够被保留。

但是如果我们希望的话,defaultSubscriber可以返回一个指向结构体的指针。事实上,在下一部分我们就做了这个改变!

通过指针传递大型结构体

image

是的,会。它必须为原始结构体和副本腾出空间。

函数接收的是调用时传入的参数的副本,即使是像结构体这样的大值。

这就是为什么,除非你的结构体只有一两个小字段,通常最好是传递一个指向结构体的指针,而不是结构体本身。当你传递结构体指针时,内存中只存在原始结构体的一个副本。函数只是接收到单个结构体的内存地址,并可以读取结构体,修改它,或者进行其他任何操作,而无需制作额外的副本。

下面是我们更新过的defaultSubscriber函数,现在返回一个指针,并且我们更新了printInfo函数,使其接收一个指针。像applyDiscount一样,这两个函数都不需要改变现有的结构体。但使用指针确保只需在内存中保留每个结构体的一个副本,同时仍然允许程序正常工作。

image

将我们的结构类型移到不同的包中

image

这应该很容易做到。在你的 Go 工作空间中找到headfirstgo目录,并在其中创建一个新目录来保存名为magazine的包。在magazine中,创建一个名为magazine.go的文件。

image

请确保在magazine.go文件顶部添加package magazine声明。接着,从你现有的代码中复制subscriber结构体定义,并粘贴到magazine.go中。

image

接下来,让我们创建一个程序来尝试新的包。由于我们现在只是做实验,所以暂时不要为这段代码创建一个单独的包目录;我们将使用go run命令来运行它。创建一个名为main.go的文件。你可以将它保存在任何目录中,但要确保它保存在你的 Go 工作空间之外,以免与其他包发生冲突。

image

注意

(如果需要的话,稍后可以将此代码移动到你的 Go 工作空间,只要为其创建一个单独的包目录即可。)

main.go中,保存这段代码,它简单地创建了一个新的subscriber结构体并访问了其中的一个字段。

与先前示例有两个不同之处。首先,我们需要在文件顶部导入magazine包。其次,我们需要使用magazine.subscriber作为类型名称,因为它现在属于另一个包。

image

定义类型的名称必须大写才能被导出

看看我们的实验性代码是否仍然可以访问其新包中的subscriber结构体类型。在终端中,切换到保存main.go的目录,然后输入**go run main.go**

image

我们出现了一些错误,但重要的是:cannot refer to unexported name magazine.subscriber

Go 类型名称遵循与变量和函数名称相同的规则:如果变量、函数或类型的名称以大写字母开头,则被视为导出的,可以从声明它的包外访问。但是我们的subscriber类型名称以小写字母开头。这意味着它只能在magazine包内部使用。

要使类型能够从其定义的包外访问,必须将其导出:其名称必须以大写字母开头。

看起来这是一个简单的修复方法。我们只需打开我们的magazine.go文件,并将定义类型的名称大写化。然后,我们打开main.go并将对该类型的任何引用也大写化。(现在只有一个引用。)

image

如果我们尝试用go run main.go运行更新后的代码,就不再会出现magazine.subscriber类型未导出的错误了。所以这个问题看起来已经解决了。但是,我们却得到了一些新的错误...

image

结构体字段名必须大写才能被导出

Subscriber类型名称大写时,似乎可以从main包中访问它。但是现在我们得到一个错误,说我们不能引用rate字段,因为它是未导出的。

image

即使一个结构体类型从一个包中导出,如果它们的字段名称不以大写字母开头,它们将会是未导出的。让我们尝试在magazine.gomain.go中将Rate大写化...

如果想要从其包中导出结构体字段名称也必须大写。

image

再次运行main.go,你会发现这次一切都正常工作了。现在它们已经被导出,我们可以从main包中访问Subscriber类型以及其Rate字段。

image

注意尽管nameactive字段仍未导出,代码仍然有效。如果需要的话,你可以在单个结构类型中混合使用导出和未导出的字段。

Subscriber类型的情况下可能不明智。能够从其他包中访问订阅率,但不能访问名称或地址是没有意义的。因此,让我们返回magazine.go并将其他字段也导出。只需将它们的名称大写:NameActive

image

结构体字面量

定义一个结构体并逐个为其字段赋值的代码可能有些繁琐:

var subscriber magazine.Subscriber
subscriber.Name = "Aman Singh"
subscriber.Rate = 4.99
subscriber.Active = true

因此,与切片和映射一样,Go 语言提供了结构体字面量,让你可以创建一个结构体并同时设置其字段。

语法看起来类似于映射字面量。首先列出类型,然后是花括号。在花括号内,你可以为一些或所有的结构字段指定值,使用字段名、冒号,然后是值。如果指定多个字段,用逗号分隔。

image

在上面,我们展示了一些创建Subscriber结构体并逐个设置其字段的代码。这段代码使用结构体字面量在一行中完成相同的操作:

image

你可能已经注意到,在大部分章节中,我们不得不对结构体变量使用长形式声明(除非结构体是从函数中返回的)。结构体字面量允许我们对刚刚创建的结构体使用短变量声明。

你可以省略一些甚至所有的字段。省略的字段将会被设置为它们类型的零值。

image

池谜题

image

你的任务是从池中取出代码片段,并将它们放入这段代码的空白行中。不要重复使用相同的片段,你不需要使用所有的片段。你的目标是创建一个能够运行并产生所示输出的程序。

imageimage

注意:每个池中的片段只能使用一次!

image 答案见“Pool Puzzle Solution”。

创建一个 Employee 结构类型

image

添加一个Employee结构类型应该很简单。我们只需将其添加到magazine包中,与Subscriber类型并列。在magazine.go中,定义一个新的Employee类型,其基础类型为struct。为该结构类型添加一个Name字段,类型为string,并添加一个Salary字段,类型为float64。确保将类型名和所有字段都大写,以便从magazine包中导出它们。

我们可以更新main.go中的main函数以尝试新类型。首先,声明一个类型为magazine.Employee的变量。然后为每个字段分配适当类型的值。最后,打印这些值。

图片

如果你从终端执行go run main.go,它应该运行,创建一个新的magazine.Employee结构体,设置其字段值,然后打印这些值。

创建一个 Address 结构体类型

接下来,我们需要为SubscriberEmployee类型跟踪邮寄地址。我们将需要街道地址、城市、州和邮政编码(邮政编码)的字段。

我们可以SubscriberEmployee类型分别添加单独的字段,如下所示:

图片

但无论属于哪种类型,邮寄地址的格式都是相同的。重复多个类型之间所有这些字段是很麻烦的。

结构字段可以容纳任何类型的值,包括其他结构体。因此,我们试试构建一个Address结构体类型,然后在SubscriberEmployee类型上添加一个Address字段。这样现在会为我们节省一些工作量,并且如果需要更改地址格式,稍后可以确保类型之间的一致性。

首先我们将创建Address类型,以确保其正常工作。将其放置在magazine包中,与SubscriberEmployee类型并列。然后,用几行代码替换main.go中的代码,创建一个Address并确保其字段可访问。

图片

在你的终端中键入**go run main.go**,它应该创建一个Address结构体,填充其字段,然后打印整个结构体。

在另一种类型上添加一个结构体作为字段

现在我们确信Address结构体类型可以单独使用,让我们将HomeAddress字段添加到SubscriberEmployee类型中。

添加一个字段,该字段本身是一个结构体类型,与添加任何其他类型的字段没有区别。你为字段提供一个名称,然后是字段的类型(在本例中将是一个结构体类型)。

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类型的零值。

image

如果subscriber是包含Subscriber结构的变量,那么当您键入subscriber.HomeAddress时,即使您尚未明确设置HomeAddress,也将获得一个Address结构。

您可以利用这一事实“链”点运算符在一起,以便访问Address结构的字段。只需键入**subscriber.HomeAddress**来访问Address结构,然后跟随另一个点运算符和您想要在该Address结构上访问的字段名称。

image

这对于给内部结构的字段赋值都适用...

subscriber.HomeAddress.PostalCode = "68111"

...以及稍后检索这些值。

fmt.Println("Postal Code:", subscriber.HomeAddress.PostalCode)

这里是更新的main.go,它使用点运算符链。首先,我们将Subscriber结构存储在subscriber变量中。这将自动在subscriberHomeAddress字段中创建一个Address结构。我们为subscriber.HomeAddress.Streetsubscriber.HomeAddress.City等设置值,然后再次打印这些值。

然后,我们将Employee结构存储在employee变量中,并对其HomeAddress结构执行相同操作。

image

在您的终端中输入**go run main.go**,程序将打印出subscriber.HomeAddressemployee.HomeAddress的完成字段。

匿名结构字段

尽管通过外部结构访问内部结构的字段的代码有点繁琐。每次想要访问它包含的任何字段时,都必须编写内部结构(HomeAddress)的字段名称。

image

Go 允许您定义匿名字段:没有自己名称的结构字段,只有类型。我们可以使用匿名字段使我们的内部结构更容易访问。

这里更新了SubscriberEmployee类型,将它们的HomeAddress字段转换为匿名字段。为此,我们只需删除字段名称,只留下类型。

image

当声明匿名字段时,您可以像使用字段类型名称一样使用它,好像它是字段的名称一样。因此,下面代码中的subscriber.Addressemployee.Address仍然访问Address结构:

image

嵌入结构体

但匿名字段提供的不仅仅是在结构定义中省略字段名称的能力。

一个嵌入在外部结构体中的内部结构体,使用匿名字段存储,被称为嵌入在外部结构体中。嵌入结构体的字段被提升到外部结构体,这意味着你可以像访问外部结构体的字段一样访问它们。

所以现在Address结构类型已经嵌入到SubscriberEmployee结构类型中,你不必写出subscriber.Address.City来获取City字段;你可以直接写subscriber.City。你不需要写employee.Address.State;你可以直接写employee.State

这是main.go的最后一个版本,更新为将Address作为嵌入类型处理。你可以将代码编写得好像根本没有Address类型;就像Address字段属于它们所嵌入的结构体类型一样。

图片

请记住,你并不必须嵌入内部结构体。你根本不需要使用内部结构体。有时,在外部结构体上添加新字段会导致最清晰的代码。考虑你当前的情况,并选择最适合你和你的用户的解决方案。

我们的定义类型已经完成了!

图片

做得好!你已经定义了SubscriberEmployee结构类型,并在每个结构中嵌入了一个Address结构。你找到了一种方式来表示杂志所需的所有数据!

虽然你在定义类型时仍然缺少一个重要的方面。在之前的章节中,你使用了像time.Timestrings.Replacer这样的类型,它们有方法:你可以在它们的值上调用的函数。但是你还没有学会如何为自己的类型定义方法。不用担心;我们将在下一章节详细学习!

你的 Go 工具箱

图片

这就是第八章的全部内容!你已经为你的工具箱添加了结构体和定义类型。

图片

代码磁铁解决方案

图片

池谜题解决方案

图片

第九章:你是我的类型:定义类型

image

还有更多关于定义类型的知识需要学习。 在前一章中,我们向您展示了如何定义一个以结构体为基础类型的类型。但我们没有向您展示您可以使用任何类型作为基础类型。

你还记得方法——那种与特定类型的值相关联的特殊函数吗?我们在整本书中一直在对各种值调用方法,但我们还没有向你展示如何定义自己的方法。在本章中,我们将解决这一切。让我们开始吧!

现实生活中的类型错误

如果你住在美国,你可能习惯了那里使用的古怪计量系统。例如,在加油站,燃料是按加仑出售的,这是大部分世界其余地区使用的升的近四倍体积单位。

Steve 是一个美国人,在另一个国家租车。他驶入加油站加油。他打算购买 10 加仑,以为这将足够到达另一个城市的酒店。

image

他重新上路,但在到达目的地的四分之一路程后燃料用尽。

如果 Steve 仔细看了加油站泵上的标签,他会意识到它是用升来测量燃料,而不是加仑,他需要购买 37.85 升才能相当于 10 加仑。

image

10 加仑

当你有一个数字时,最好确定这个数字测量的是什么。你想知道它是升还是加仑,千克还是磅,美元还是日元。

image

10 升

以基础基本类型定义的类型

如果你有以下变量:

var fuel float64 = 10

...那表示 10 加仑还是 10 升?写下这个声明的人知道,但其他人不知道,不确定。

你可以使用 Go 的定义类型来明确值的用途。虽然定义类型最常使用结构体作为其基础类型,但它们可以基于intfloat64stringbool或任何其他类型。

Go 定义类型最常使用结构体作为其基础类型,但也可以基于 int、string、boolean 或任何其他类型。

这是一个定义了两种新类型LitersGallons的程序,它们都有一个基础类型为float64。它们在包级别定义,因此它们在当前包的任何函数中都可以使用。

main函数中,我们声明了一个类型为Gallons的变量,另一个为Liters的变量。我们为每个变量赋值,然后将它们打印出来。

image

一旦你定义了一个类型,你就可以对该类型的任何基础类型的值进行转换。与其他任何转换一样,你需要在想要转换的类型后面写上要转换的值,用括号括起来。

如果我们愿意,我们可以在上述代码中使用类型转换写出简短的变量声明:

image

如果你有一个使用定义类型的变量,你不能将不同定义类型的值赋给它,即使另一种类型具有相同的基础类型。这有助于防止开发人员混淆这两种类型。

image

但是你可以转换具有相同基础类型的类型。因此,Liters可以转换为Gallons,反之亦然,因为两者的基础类型都是float64。但是在进行转换时,Go 语言只考虑基础类型的值;Gallons(Liters(240.0))Gallons(240.0)之间没有区别。简单地从一种类型转换为另一种类型的原始值,会破坏类型应提供的转换错误保护。

image

相反,你应该执行必要的操作,以将基础类型的值转换为适合转换为的类型的值。

快速的网络搜索显示,1 升大约等于 0.264 加仑,1 加仑大约等于 3.785 升。我们可以通过这些换算率相乘来从Gallons转换为Liters,反之亦然。

image

定义类型和运算符

定义类型支持与其基础类型相同的所有操作。例如,基于float64的类型支持诸如+-*/的算术运算符,以及==><等比较运算符。

image

基于string的类型将支持+==><等操作,但不支持-,因为-不是字符串的有效运算符。

image

定义类型可以与字面值一起使用:

image

但是定义的类型不能与不同类型的值一起使用,即使另一种类型具有相同的基础类型。这样做是为了防止开发人员意外混合这两种类型。

image

如果要将Liters中的值添加到Gallons中的值,你需要首先将一种类型转换以匹配另一种类型。

池子谜题

image

你的任务是从池中提取代码片段,并将它们放入此代码中的空白行。不要重复使用相同的片段,而且你不需要使用所有的片段。你的目标是编写一个能运行并产生所示输出的程序。

imageimage

注意:池中的每个片段只能使用一次!

image 答案在“池子谜题解答”中。

使用函数进行类型转换

假设我们想要拿一辆以Gallons为单位测量燃料的汽车,在Liters单位的加油站加油。或者拿一辆以Liters为单位测量燃料的公共汽车,在Gallons单位的加油站加油。为了防止不准确的测量,Go 语言会在我们尝试组合不同类型的值时给出编译错误:

image

为了处理不同类型的值,我们需要首先进行类型转换以匹配。之前,我们演示了将Liters值乘以 0.264,并将结果转换为Gallons。我们还将Gallons值乘以 3.785,并将结果转换为Liters

image

我们可以创建ToGallonsToLiters函数来执行相同的操作,然后调用它们来进行转换:

汽油不是我们需要测量体积的唯一液体。还有食用油、汽水瓶和果汁等。因此,除了升和加仑之外,还有许多体积单位。在美国有茶匙、杯子、夸脱等等。度量衡系统也有其他单位,但毫升(升的 1/1000)是最常用的单位。

让我们添加一个新类型,Milliliters。像其他类型一样,它将使用float64作为基础类型。

image

我们还需要一种方法来将Milliliters转换为其他类型。但是,如果我们开始添加一个从MillilitersGallons的转换函数,我们会遇到一个问题:我们不能在同一个包中拥有两个ToGallons函数!

image

我们可以将这两个ToGallons函数重命名为包含它们所转换的类型的函数:LitersToGallonsMillilitersToGallons。但是每次写出这些名称都很麻烦,而且随着我们开始添加其他类型之间的转换函数,很明显这是不可持续的。

image

没有愚蠢的问题

Q: 我看过其他支持函数重载的语言:它们允许您拥有多个函数具有相同的名称,只要它们的参数类型不同。Go 语言不支持这种功能吗?

A: Go 语言的维护者们也经常遇到这个问题,并在golang.org/doc/faq#overloading上回答:“通过其他语言的经验,我们知道具有相同名称但不同签名的多种方法有时很有用,但在实践中可能会令人困惑和脆弱。” Go 语言通过支持函数重载来简化语言,因此不支持它。正如您将在本书后面看到的,Go 团队在语言的其他领域也做出了类似的决策;当他们在简单性和添加更多功能之间需要做出选择时,他们通常选择简单性。但这没关系!很快我们会看到,还有其他方法可以获得相同的好处……

image

通过方法修复我们的函数名冲突

还记得在第二章中我们向你介绍的方法吗?它们是与给定类型的值相关联的函数?除其他外,我们创建了一个time.Time值并调用了它的Year方法,还创建了一个strings.Replacer值并调用了它的Replace方法。

image

我们可以定义自己的方法来帮助解决类型转换问题。

我们不能有多个名为ToGallons的函数,所以我们不得不编写长而冗长的函数名,其中包含我们正在转换的类型:

LitersToGallons(Liters(2))
MillilitersToGallons(Milliliters(500))

但我们可以有多个名为ToGallons方法,只要它们定义在不同的类型上。不必担心名称冲突将使我们的方法名称变得更短。

Liters(2).ToGallons()
Milliliters(500).ToGallons()

但让我们不要过于急躁。在我们做任何其他事情之前,我们需要知道如何定义一个方法……

定义方法

方法定义与函数定义非常相似。事实上,它们只有一个区别:你需要在函数名之前的括号内添加一个额外的参数,即接收者参数

与任何函数参数一样,你需要为方法定义中的接收者参数提供一个名称,后面跟着一个类型。

image

要调用你定义的方法,你需要写出你调用方法的值,加一个点,然后是你调用的方法名称,后面跟上括号。你调用方法的值称为方法的接收者

在方法调用和方法定义之间的相似性可以帮助你记住语法:调用方法时,接收者首先列出,而定义方法时,接收者参数首先列出。

image

方法定义中接收者参数的名称并不重要,但其类型是重要的;你正在定义的方法将与该类型的所有值相关联。

下面,我们定义了一个名为MyType的类型,其基础类型为string。然后,我们定义了一个名为sayHi的方法。因为sayHi有一个类型为MyType的接收者参数,我们将能够在任何MyType值上调用sayHi方法。(大多数开发人员会说sayHi是在MyType上定义的。)

image

一旦在类型上定义了方法,就可以在该类型的任何值上调用它。

在这里,我们创建两个不同的MyType值,并在每个值上调用sayHi

接收者参数(基本上)只是另一个参数

接收者参数的类型是该方法关联的类型。但除此之外,接收者参数在 Go 中并没有特殊待遇。你可以像处理任何其他函数参数一样,在方法块内部访问其内容。

下面的代码示例与前一个几乎相同,只是我们更新了它以打印接收者参数的值。你可以在输出结果中看到这些接收者。

image

Go 允许你随意命名接收者参数,但如果你为一个类型定义的所有方法都使用相同名称的接收者参数,那会更易读。

按照惯例,Go 开发者通常使用由接收者类型名称的第一个字母小写组成的名称。(这就是我们将m作为MyType接收者参数名称的原因。)

Go 使用接收者参数而不是其他语言中看到的“self”或“this”值。

没有愚蠢的问题

Q: 我可以为任何类型定义新方法吗?

A: 只能为与方法定义在同一包中的类型定义方法。这意味着不能在你的hacking包中为别人的security包中的类型定义方法,也不能在通用类型如intstring上定义新方法。

Q: 但我需要能够使用自己的方法来处理别人的类型!

A: 首先你应该考虑是否一个函数就足够了;函数可以接受任何你想要的类型作为参数。但如果你确实需要一个同时具有自己方法和其他包类型方法的值,你可以定义一个结构体类型,匿名地嵌入其他包的类型。我们将在下一章中看看这是如何工作的。

Q: 我在其他语言中看到方法接收者在方法块中作为特殊变量命名为selfthis。Go 是否也这样做?

A: Go 使用接收者参数而不是selfthis。主要区别在于,selfthis隐式设置的,而你需要显式声明一个接收者参数。除此之外,接收者参数的使用方式相同,Go 不需要将selfthis保留为关键字!(如果你愿意,甚至可以将接收者参数命名为this,但不要这样做;惯例是使用接收者类型名称的首字母。)

方法(几乎)与函数完全一样

除了它们在接收者上被调用之外,方法与任何其他函数基本相似。

就像任何其他函数一样,你可以在方法名后的括号中定义额外的参数。这些参数变量可以在方法块中访问,连同接收者参数一起。当你调用方法时,你需要为每个参数提供一个实参。

image

就像任何其他函数一样,你可以为方法声明一个或多个返回值,在调用方法时返回这些值:

image

与任何其他函数一样,如果方法名称以大写字母开头,则认为该方法从当前包中导出,如果方法名称以小写字母开头,则认为该方法未导出。如果希望在当前包之外使用你的方法,请确保其名称以大写字母开头。

图片

指针接收器参数

这里有一个可能看起来很熟悉的问题。我们定义了一个新的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类型时,我们发现无法为LitersMilliliters都创建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方法。

将加仑转换为升和毫升使用方法

当将GallonsToLitersGallonsToMilliliters函数转换为方法时,过程类似。我们只是将Gallons参数移到每个接收器参数中。

image

您的 Go 工具箱

image

这就是对第九章的全部内容!您已经向您的工具箱中添加了方法定义。

image

池子难题解答

image

第十章:保持低调:封装和嵌入

image

错误是难免的。 有时,您的程序将从用户输入、正在读取的文件或其他地方接收到无效数据。在本章中,您将学习封装:一种保护结构类型字段免受无效数据影响的方法。这样,您就知道您的字段数据是安全的!

我们还将向您展示如何在您的结构类型中嵌入其他类型。如果您的结构类型需要另一类型上已存在的方法,您无需复制和粘贴方法代码。您可以将其他类型嵌入到您的结构类型中,然后像在自己的类型上定义一样使用嵌入类型的方法!

创建一个Date结构类型

一个名为 Remind Me 的本地初创公司正在开发一个日历应用,帮助用户记住生日、周年纪念日等。

image

年、月和日听起来都需要被组合在一起;这些值单独来看可能不是很有用。结构类型可能对于在一个单一的捆绑中保持这些单独的值是有用的。

正如我们所见,定义的类型可以使用任何其他类型作为它们的基础类型,包括结构体。事实上,结构体类型作为我们在第八章中对定义类型的介绍。

让我们创建一个Date结构类型来保存我们的年、月和日值。我们将在结构中添加YearMonthDay字段,每个字段的类型都是int。在我们的main函数中,我们将使用结构字面量快速测试新类型,使用Println暂时打印Date

image

如果我们运行完成的程序,我们将看到我们的Date结构的YearMonthDay字段。看起来一切正常!

人们将Date结构字段设置为无效值!

image

啊,我们可以看到可能发生的情况。只有大于等于1的年份是有效的,但我们没有任何东西阻止用户意外将Year字段设置为0-999。只有从112的月份号码是有效的,但没有任何东西阻止用户将Month字段设置为013Day字段只有从131的数字是有效的,但用户可以输入像-250这样的天数。

image

我们需要一种方法来确保用户数据在接受之前是有效的。在计算机科学中,这称为数据验证。我们需要测试Year是否被设置为大于等于1的值,Month是否在112之间,以及Day是否在131之间。

注意

(是的,有些月份少于 31 天,但为了保持我们的代码示例长度合理,我们将仅检查它是否介于 1 和 31 之间。)

设置方法

结构体类型只是另一种定义的类型,这意味着你可以像任何其他类型一样在其上定义方法。我们应该能够在Date类型上创建SetYearSetMonthSetDay方法,它们接受一个值,检查其有效性,如果有效则设置相应的结构字段。

这种方法通常称为设置方法。按照惯例,Go 的设置方法通常以Set*X*的形式命名,其中*X*是你要设置的内容。

设置方法是用于设置定义类型底层值中字段或其他值的方法。

这是我们第一次尝试的SetYear方法。接收器参数是你调用方法的Date结构体。SetYear作为参数接受你想要设置的年份,并在接收器Date结构体上设置Year字段。当前它不验证值的有效性,但稍后我们会添加验证。

在我们的main方法中,我们创建一个Date并调用其SetYear方法。然后我们打印结构体的Year字段。

image

尽管我们运行程序,但会发现它并没有完全正确地工作。即使我们创建了一个Date并用新值调用了SetYearYear字段仍然设置为其零值!

设置方法需要指针接收器。

记得我们之前展示过的Number类型上的Double方法吗?最初,我们使用普通值接收器类型Number编写它。但我们了解到,像任何其他参数一样,接收器参数接收到原始值的副本Double方法在更新副本时会丢失。

image

我们需要更新Double方法,使其接受指针接收器类型*Number。当我们在指针上更新值时,在Double退出后,更改仍然被保留。

对于SetYear也是如此。Date接收器得到原始结构的副本。当SetYear退出时,对副本字段的任何更新都将丢失!

image

我们可以通过更新为指针接收器(d *Date)来修复SetYear。这是唯一必要的更改。我们不必更新SetYear方法块,因为d.Year会自动为我们获取指针的值(就像我们输入(*d).Year一样)。在main中调用date.SetYear时也无需更改,因为在传递给方法时,Date值会自动转换为*Date

image

现在SetYear采用指针接收器,如果我们重新运行代码,我们将看到Year字段已经更新。

添加其余的设置方法。

现在应该很容易按照相同的模式定义Date类型上的SetMonthSetDay方法。我们只需要确保在方法定义中使用指针接收器。当我们调用每个方法时,Go 将接收器转换为指针,并在更新其字段时将指针转换回结构值。

image

main函数中,我们可以创建一个Date结构值;通过我们的新方法设置其YearMonthDay字段;然后打印整个结构以查看结果。

现在我们为Date类型的每个字段都有了设置方法。但即使使用这些方法,用户仍然可能意外地将字段设置为无效值。我们接下来将看看如何防止这种情况。

图片

向设置方法添加验证

向我们的设置方法添加验证将需要一些工作,但我们在第三章中学到了一切我们需要做的事情。

在每个设置方法中,我们将测试值是否在有效范围内。如果无效,我们将返回一个error值。如果有效,则将Date结构字段设置为正常状态,并为错误值返回nil

让我们首先在SetYear方法中添加验证。我们添加一个声明,该方法将返回一个error类型的值。在方法块的开头,我们测试调用者提供的year参数是否为小于1的任何数字。如果是,则返回一个带有消息"invalid year"error。如果不是,则设置结构的Year字段并返回nil,表示没有错误。

main函数中,我们调用SetYear并将其返回值存储在名为err的变量中。如果err不为nil,这意味着分配的值无效,因此我们记录错误并退出。否则,我们继续打印Date结构的Year字段。

图片

SetYear传递无效值会导致程序报告错误并退出。但如果我们传递一个有效值,程序将继续打印它。看起来我们的SetYear方法正常工作!

SetMonthSetDay方法中的验证代码将类似于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 也可以更新 Dateyear 结构字段。导出的 SetMonth 方法可以更新未导出的 month 字段,依此类推。

如果我们修改 main.go 以使用设置器方法,我们将能够更新 Date 值的字段:

image

未导出的变量、结构字段、函数和方法仍然可以被同一包内导出的函数和方法访问。

如果我们更新 main.go 以使用无效值调用 SetYear,则在运行时会出现错误:

image

现在,Date 值的字段只能通过其设置器方法更新,程序可以避免意外输入无效数据。

image

噢,没错。我们提供了设置器方法,允许我们设置 Date 字段,尽管这些字段在 calendar 包中是未导出的。但是,我们并未提供任何获取字段值的方法。

我们可以打印整个 Date 结构。但是,如果尝试更新 main.go 以打印单个 Date 字段,则无法访问它!

image

获取器方法

正如我们所见,其主要目的是设置结构字段或变量值的方法称为设置器方法。而其主要目的是获取结构字段或变量值的方法称为获取器方法

与设置器方法相比,为 Date 类型添加获取器方法将很容易。它们在被调用时不需要执行任何操作,只需返回字段值即可。

image

按照惯例,获取器方法的名称应与其访问的字段或变量名称相同。(当然,如果要导出该方法,其名称需要以大写字母开头。)因此,Date 将需要一个 Year 方法来访问 year 字段,一个 Month 方法来访问 month 字段,以及一个 Day 方法来访问 day 字段。

获取器方法根本不需要修改接收器,因此我们可以使用直接的 Date 值作为接收器。但是,如果某个类型的任何方法都使用指针接收器,按照惯例,为了保持一致性,它们全部都应该使用指针接收器。因此,由于我们必须为我们的设置器方法使用指针接收器,我们也为获取器方法使用指针。

完成 date.go 的更改后,我们可以更新 main.go 以设置所有 Date 字段,然后使用获取器方法打印它们。

image

封装

将程序的一部分中的数据隐藏在另一部分代码中被称为封装,这不仅限于 Go。封装是有价值的,因为它可以用来保护免受无效数据的影响(正如我们所见)。此外,您可以更改程序的封装部分而不必担心破坏访问它的其他代码,因为不允许直接访问。

许多其他编程语言将数据封装在类中。(类是与 Go 类型类似但不完全相同的概念。)在 Go 中,数据通过未导出的变量、结构字段、函数或方法封装在包内。

封装在其他语言中比在 Go 中更频繁地使用。在某些语言中,通常为每个字段定义 getter 和 setter,即使直接访问这些字段也可以很好地工作。在 Go 中,开发者通常只在必要时依赖封装,例如当字段数据需要通过 setter 方法进行验证时。在 Go 中,如果没有看到需要封装字段的必要性,通常可以直接导出它并允许直接访问。

没有愚蠢的问题

问:许多其他语言不允许在类外部访问封装值。在 Go 中,允许同一包中的其他代码访问未导出字段安全吗?

答: 通常,一个包中的所有代码都是单个开发者(或一组开发者)的工作。一个包中的所有代码通常都有相似的目的。同一个包中的代码作者很可能需要访问未公开的数据,并且他们也很可能只在有效的方式下使用这些数据。因此,是的,与包中的其他代码共享未公开数据通常是安全的。

代码包之外很可能是由其他开发者编写的,但这没关系,因为未导出字段对他们是隐藏的,所以他们无法意外地将其值更改为无效值。

问:我看过其他语言中每个获取器方法的名称都以“Get”开头,比如GetNameGetCity等等。在 Go 语言中我可以这样做吗?

答: Go 语言允许你这样做,但你不应该这样做。Go 社区已经决定不在 getter 方法名称中包含Get前缀的约定。包含此前缀只会导致其他开发者感到困惑!

就像许多其他语言一样,Go 仍然为 setter 方法使用Set前缀,因为这是为了区分相同字段的 getter 方法名和 setter 方法名所必需的。

嵌入Event类型中的Date类型

image

那不应该花费太多工作。还记得我们在第八章中是如何将Address结构类型嵌入另外两个结构类型中的吗?

image

Address类型被视为“嵌入”,因为我们在外部结构体中使用了一个匿名字段(只有类型没有名称),这导致Address的字段被提升到外部结构体,从而允许我们像访问外部结构体字段一样访问内部结构体的字段。

image

既然这种策略之前如此成功,让我们定义一个Event类型,其中包含一个匿名字段Date

calendar包文件夹中创建另一个文件,命名为event.go。(我们可以将其放在现有的date.go文件中,但这样可以更加整洁地组织。)在该文件中,定义一个Event类型,它有两个字段:一个类型为stringTitle字段,和一个匿名的Date字段。

未导出的字段不会被提升

尽管在Event类型中嵌入Date不会导致将Date字段提升到EventDate字段是未导出的,Go 语言不会将未导出字段提升到封闭类型。这是有道理的;我们确保字段被封装,因此只能通过设置器和获取器方法访问它们,并且不希望通过字段提升来规避封装。

image

在我们的main包中,如果我们尝试通过其封闭的Event设置Datemonth字段,我们会遇到错误:

image

当然,使用点操作符链式检索Date字段然后直接访问它的字段也不起作用。当Date独立存在时,无法访问其未导出字段;当其作为Event的一部分存在时也不行。

image

那么这是否意味着我们无法访问Event类型中嵌入的Date类型的字段?别担心;还有另一种方法!

导出的方法会像字段一样被提升

如果在结构类型中嵌入具有导出方法的类型,其方法将被提升到外部类型,这意味着您可以像在外部类型上定义的方法一样调用这些方法。(还记得如何在一个结构体类型中嵌入另一个导致内部结构体的字段被提升到外部结构体吗?这与字段相同,但是用方法替代了。)

这是一个定义了两种类型的包。MyType是一个结构类型,它作为匿名字段嵌入了第二个类型EmbeddedType

image

因为EmbeddedType定义了一个导出方法(名为ExportedMethod),该方法会被提升到MyType,并且可以在MyType值上调用。

image

与未导出字段一样,未导出方法也不会被提升。如果尝试调用未导出方法,则会出现错误。

image

我们的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类型的TitleSetTitle方法与从嵌入的Date类型提升的方法并存。导入calendar包的用户可以将所有方法视为属于Event类型,而不用担心它们实际上是在哪种类型上定义的。

图像

我们的日历包已经完善了!

图像

方法提升允许您轻松地将一种类型的方法用作另一种类型的方法。您可以使用这个功能来组合多种其他类型的方法。这有助于保持代码的清晰性,而不会牺牲便利性!

您的 Go 工具箱

图像

到此为止,第十章就结束了!您已经将封装和嵌入添加到了您的工具箱中。

图像

注意

嵌入

被存储在结构体类型内部的类型,使用匿名字段被称为被嵌入在结构体中。

嵌入类型的方法被提升到外部类型。它们可以被调用,就好像它们是在外部类型上定义的一样。

第十一章:你能做什么?:接口

image

有时您不关心值的具体类型。 您不关心它是什么。您只需知道它能某些事情。您可以在其上调用某些方法。您不在乎是否有一个Pen还是一个Pencil,您只需要一个具有Draw方法的东西。您不在乎是否有一辆Car或一艘Boat,您只需要一个具有Steer方法的东西。

这就是 Go 语言接口的作用。它们允许您定义变量和函数参数,这些变量和函数参数可以持有任何类型,只要该类型定义了某些方法。

拥有相同方法的两种不同类型

还记得磁带录音机吗?(我们假设你们中有些人可能太年轻了。)它们非常棒。你可以轻松地在一盘磁带上录制所有你喜欢的歌曲,即使它们是由不同的艺术家演唱的。当然,这些录音机通常太笨重,无法随身携带。如果你想随身携带磁带,你需要一个单独的电池供电的磁带播放机。通常这种播放机没有录音功能。但是,制作自定义混音磁带并与朋友分享的感觉真是太棒了!

image

我们对怀旧情怀如此之深,以至于我们创建了一个gadget包来帮助我们怀旧。它包括一个模拟磁带录音机的类型,以及另一个模拟磁带播放机的类型。

image

TapePlayer类型具有Play方法来模拟播放歌曲,以及一个Stop方法来停止虚拟播放。

image

TapeRecorder类型还有PlayStop方法,以及一个Record方法。

只能接受一种类型的方法参数

下面是一个使用gadget包的示例程序。我们定义了一个playList函数,它接受一个TapePlayer值和一个要播放的歌曲标题切片。该函数循环遍历切片中的每个标题,并将其传递给TapePlayerPlay方法。在播放完列表后,它调用TapePlayerStop方法。

然后,在main方法中,我们只需创建TapePlayer和歌曲标题的切片,然后将它们传递给playList

image

playList函数与TapePlayer值配合使用效果很好。您可能希望它也能与TapeRecorder一起使用。(毕竟,磁带录音机基本上就是带有额外录音功能的磁带播放机。)但是playList的第一个参数类型是TapePlayer。尝试传递任何其他类型的参数,您将收到编译错误:

imageimage

在这种情况下,似乎是 Go 语言的类型安全性在阻碍我们,而不是帮助我们。playList函数需要的所有方法都由TapeRecorder类型定义,但我们无法使用它,因为playList只接受TapePlayer值。

那我们能做什么?写一个第二个几乎相同的 playListWithRecorder 函数,它接受一个 TapeRecorder 吗?

实际上,Go 还提供了另一种方式...

接口

当你在计算机上安装一个程序时,通常期望该程序提供一种与之交互的方式。你期望一个文字处理器提供一个地方来输入文本。你期望一个备份程序提供一种选择要保存的文件的方式。你期望一个电子表格程序提供一种插入数据列和行的方式。程序提供的用于与之交互的控件集合通常称为其接口

接口是一组期望某些值具备的方法。

无论你是否真正思考过,你可能期待 Go 的值能够提供一种与它们交互的方式。什么是与 Go 值交互的最常见方式?通过它们的方法。

在 Go 中,接口被定义为一组期望某些值具备的方法。你可以将接口视为你需要某种类型能够执行的一组操作。

你可以使用 interface 关键字和花括号中包含方法名的方式定义接口类型,以及方法期望具备的任何参数或返回值类型。

image

任何具有接口定义中列出的所有方法的类型都被称为满足该接口。满足接口的类型可以在需要该接口的任何地方使用。

方法名、参数类型(或其缺失)、返回值类型(或其缺失)都需要与接口定义中的匹配。类型可以有接口中未列出的方法,但不能缺少任何方法,否则就不能满足该接口。

image

一个类型可以满足多个接口,一个接口可以(通常应该)有多个满足它的类型。

定义满足接口的类型

下面的代码设置了一个名为 mypkg 的快速实验性包。它定义了一个名为 MyInterface 的接口类型,具有三个方法。然后它定义了一个类型 MyType,该类型满足 MyInterface

满足 MyInterface 需要三个方法:一个 MethodWithoutParameters 方法,一个接受 float64 参数的 MethodWithParameter 方法,以及一个返回 stringMethodWithReturnValue 方法。

然后我们声明另一个类型 MyType。在这个例子中,MyType 的底层类型并不重要;我们只是用了 int。我们定义了 MyType 上所有需要满足 MyInterface 的方法,以及一个不属于接口的额外方法。

image

在许多其他语言中,我们需要明确声明 MyType 满足 MyInterface。但在 Go 中,这种情况是自动发生的。如果一个类型拥有接口中声明的所有方法,那么它可以在需要该接口的任何地方使用,无需进一步声明。

这里是一个快速的程序,可以让我们尝试一下mypkg

声明一个带有接口类型的变量可以保存满足该接口的任何类型的值。这段代码声明了一个value变量,其类型为MyInterface,然后创建了一个MyType值并将其分配给value。(这是允许的,因为MyType满足MyInterface。)然后我们调用该值上的所有属于接口的方法。

image

具体类型,接口类型

在前几章中,我们定义的所有类型都是具体类型。一个具体类型不仅指定了其值可以什么(可以在其上调用哪些方法),而且还指定了它们什么:它们指定了持有该值数据的底层类型。

接口类型并不描述一个值什么:它们不说它的底层类型是什么,或者它的数据是如何存储的。它们只描述一个值可以什么:它有哪些方法。

假设您需要写下一个快速的备注。在您的桌子抽屉里,有几种具体类型的值:PenPencilMarker。每种具体类型都定义了一个Write方法,所以您不在乎抓取哪种类型。您只想要一个WritingInstrument:一个由任何具有Write方法的具体类型满足的接口类型。

image

分配任何满足接口的类型

当您有一个带有接口类型的变量时,它可以保存满足该接口的任何类型的值。

假设我们有WhistleHorn类型,它们各自都有一个MakeSound方法。我们可以创建一个NoiseMaker接口,代表任何具有MakeSound方法的类型。如果我们用NoiseMaker类型声明一个toy变量,我们可以将WhistleHorn值分配给它。(或者任何我们稍后声明的其他具有MakeSound方法的类型。)

然后,我们可以调用分配给toy变量的任何值的MakeSound方法。虽然我们不知道toy中的具体类型是什么,但我们知道它能做什么:发出声音。如果它的类型没有MakeSound方法,那么它就不会满足NoiseMaker接口,我们就不能将其分配给这个变量。

image

你可以将函数参数声明为接口类型。(毕竟,函数参数本质上也只是变量。)例如,如果我们声明一个play函数,它接受一个NoiseMaker,那么我们可以将任何具有MakeSound方法的类型的值传递给play

image

您只能调用作为接口的一部分定义的方法

一旦您用接口类型的变量(或方法参数)分配了一个值,您就只能调用接口上指定的方法。

假设我们创建了一个Robot类型,除了一个MakeSound方法外,还有一个Walk方法。我们在play函数中添加一个对Walk的调用,并将一个新的Robot值传递给play

但是代码无法编译,提示 NoiseMaker 类型没有 Walk 方法。

为什么会这样? Robot 值确实有一个 Walk 方法;定义就在那里!

但这并不是我们传递给 play 函数的 Robot 值;它是一个 NoiseMaker。如果我们传递一个 WhistleHornplay,那会怎样?这些没有 Walk 方法!

当我们有一个接口类型的变量时,我们唯一可以确定它具有的方法是接口中定义的方法。因此,这些是 Go 允许你调用的唯一方法。(确实有一种方法可以获取值的具体类型,这样你可以调用更专门化的方法。我们马上来看看。)

image

注意,将具有其他方法的类型赋给接口类型的变量是完全可以的。只要你不实际调用这些其他方法,一切都会正常运作。

image

破坏事物是教育性的!

image

这里有几个具体类型,FanCoffeePot。我们还有一个 Appliance 接口,包含一个 TurnOn 方法。FanCoffeePot 都有 TurnOn 方法,因此它们都满足 Appliance 接口。

这就是为什么在 main 函数中,我们能够定义一个 Appliance 变量,并将 FanCoffeePot 变量都赋值给它。

做以下变更之一并尝试编译代码。然后撤销您的更改并尝试下一个。看看会发生什么!

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 函数能够处理 TapePlayerTapeRecorder 上的 PlayStop 方法。

// 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方法。这意味着TapePlayerTapeRecorder类型都将满足Player接口。

我们更新了playList函数,使其接受任何满足Player接口而不是特定于TapePlayer的值。我们还将player变量的类型从TapePlayer更改为Player。这允许我们将TapePlayerTapeRecorder赋给player。然后我们将这两种类型的值传递给playList

image

没有愚蠢的问题

Q: 接口类型名称应该以大写字母还是小写字母开头?

A: 接口类型名称的规则与任何其他类型的规则相同。如果名称以小写字母开头,则接口类型将是未导出的,在当前包之外将无法访问。有时您不需要从其他包使用您声明的接口,因此将其设置为未导出是可以接受的。但是,如果您确实希望在其他包中使用它,则需要以大写字母开头命名接口类型,以便导出。

类型断言

我们定义了一个新的TryOut函数,用于测试我们的TapePlayerTapeRecorder类型的各种方法。TryOut有一个参数,其类型为Player接口,这样我们可以传递TapePlayerTapeRecorder

TryOut中,我们调用PlayStop方法,这两个方法都属于Player接口。我们还调用Record方法,该方法属于Player接口,但TapeRecorder类型上定义。目前我们只传递了TapeRecorder值给TryOut,所以应该没问题,对吗?

不幸的是,不行。我们之前看到,如果将具体类型的值分配给具有接口类型的变量(包括函数参数),则只能调用该接口的方法,而不管具体类型有什么其他方法。在TryOut函数内部,我们没有TapeRecorder值(具体类型),而是Player值(接口类型)。而Player接口没有Record方法!

image

我们需要一种方法来获取具体类型的值(确实Record方法)回来。

你的第一直觉可能是尝试使用类型转换将Player值转换为TapeRecorder值。但是类型转换不适用于接口类型,因此会生成错误。错误消息建议尝试其他方法:

image

“类型断言”?那是什么?

当你有一个具体类型的值分配给一个带有接口类型的变量时,类型断言允许你获取具体类型。这有点像类型转换。其语法甚至看起来像是方法调用和类型转换的混合。在接口值之后,你键入一个点,然后跟着具体类型的括号。(或者,更确切地说,你 断言 这个值的具体类型是什么。)

image

用简单的语言来说,上面的类型断言大致是这样说的:“我知道这个变量使用接口类型NoiseMaker,但我相当确定 这个 NoiseMaker实际上是一个Robot。”

一旦你使用类型断言获取了具体类型的值,你可以在其上调用该类型定义的但不是接口的方法。

这段代码将一个Robot分配给一个NoiseMaker接口值。我们能够在NoiseMaker上调用MakeSound,因为它是接口的一部分。但是要调用Walk方法,我们需要使用类型断言来获取一个Robot值。一旦我们有了Robot(而不是NoiseMaker),我们就可以在其上调用Walk

image

类型断言失败

以前,我们的TryOut函数无法调用Player值的Record方法,因为它不是Player接口的一部分。让我们看看是否可以使用类型断言使其工作。

就像以前一样,我们将TapeRecorder传递给TryOut,它被分配给一个使用Player接口作为其类型的参数。我们能够在Player值上调用PlayStop方法,因为它们都是Player接口的一部分。

然后,我们使用类型断言将Player转换回TapeRecorder。然后在TapeRecorder值上调用Record

image

一切看起来都很顺利...使用TapeRecorder。但是如果我们尝试将TapePlayer传递给TryOut会发生什么?考虑到我们有一个类型断言,它说TryOut的参数实际上是TapeRecorder,那么这会运行得有多好呢?

image

一切都成功编译,但当我们尝试运行时,出现了运行时恐慌!正如你所预料的那样,试图断言TapePlayer实际上是TapeRecorder并没有顺利进行。(毕竟,这根本不是事实。)

image

当类型断言失败时避免恐慌

如果在期望只有一个返回值的上下文中使用了类型断言,且原始类型与断言中的类型不匹配,则程序将在运行时(而不是编译时)引发恐慌:

image

在期望多个返回值的上下文中使用类型断言时,它们有一个第二个可选的返回值,指示断言是否成功。第二个值是一个bool,如果值的原始类型是断言的类型,则为true,否则为false。您可以根据需要处理这个第二个返回值,但按照惯例,通常将其分配给名为ok的变量。

注意

Go 语言在第七章中首次看到的“逗号 OK 惯用法”,在另一个地方也采用了这种方法。

这是对上述代码的更新,将类型断言的结果分配给具体类型值的变量,以及第二个ok变量。它在一个if语句中使用ok值来确定是否可以安全调用具体值的Record方法(因为Player值的原始类型为TapeRecorder),或者是否应该跳过此操作(因为Player具有其他具体值)。

image

在这种情况下,具体类型是TapePlayer,而不是TapeRecorder,因此断言不成功,okfalseif语句的else子句运行,打印Player was not a TapeRecorder。运行时恐慌得到了避免。

在使用类型断言时,如果您不确定接口值背后的原始类型是哪个,则应使用可选的ok值来处理预期以外的类型,并避免运行时恐慌。

使用类型断言测试 TapePlayer 和 TapeRecorder

现在我们看看能否利用所学知识来修复我们的TryOut函数,适用于TapePlayerTapeRecorder的值。我们不再忽略类型断言的第二个返回值,而是将其赋给一个ok变量。如果类型断言成功(表明recorder变量持有准备好我们调用Record方法的TapeRecorder值),则ok变量将为true,否则为false(表明安全调用Record)。我们将调用Record方法的语句包装在一个if语句中,以确保仅在类型断言成功时调用。

image

与之前一样,在我们的main函数中,我们首先使用TapeRecorder值调用TryOutTryOut获取其接收的Player接口值,并对其调用PlayStop方法。成功断言Player值的具体类型为TapeRecorder,并在结果TapeRecorder值上调用Record方法。

然后,我们再次使用 TapePlayer 调用 TryOut。(这是之前导致程序中断的调用,因为类型断言引发了恐慌。)PlayStop 被调用,就像以前一样。类型断言失败,因为 Player 值持有 TapePlayer 而不是 TapeRecorder。但因为我们在 ok 值中捕获了第二个返回值,类型断言这次不会引发恐慌。它只是将 ok 设置为 false,这导致我们 if 语句中的代码不运行,因此 Record 不会被调用。(这很好,因为 TapePlayer 值没有 Record 方法。)

多亏了类型断言,我们的 TryOut 函数可以与 TapeRecorderTapePlayer 值一起工作!

池谜题

image

我们从上一个练习中更新了代码,右侧是它。我们正在创建一个 TryVehicle 方法,该方法调用 Vehicle 接口的所有方法。然后,它应该尝试类型断言以获取具体的 Truck 值。如果成功,应该在 Truck 值上调用 LoadCargo

你的 任务 是从池中选取代码片段并将它们放入此代码的空白行中。不要 使用相同的片段超过一次,你不需要使用所有的片段。你的 目标 是创建一个可以运行并生成所示输出的程序。

image

注意:每个池中的片段只能使用一次!

image

image 答案在 “池谜题解答” 中。

“error” 接口

我们希望通过查看一些内置于 Go 中的接口来结束本章。我们尚未明确涵盖这些接口,但你实际上一直在使用它们。

在 第三章 中,我们学习了如何创建我们自己的 error 值。我们说过,“一个 error 值是任何具有名为 Error 的方法且返回字符串的值。”

image

是的。 error 类型只是一个接口!它看起来像这样:

type error interface {
       Error() string
}

error 类型声明为接口意味着,如果它有一个返回 stringError 方法,那么它满足 error 接口,并且它是一个 error 值。这意味着你可以定义自己的类型,并在需要 error 值的任何地方使用它!

image

例如,这里有一个简单定义的类型 ComedyError。因为它有一个返回 stringError 方法,它满足 error 接口,我们可以将它分配给具有 error 类型的变量。

image

如果你需要一个 error 值,但也需要跟踪比仅仅错误消息字符串更多的信息,你可以创建一个满足 error 接口且存储你想要的信息的自定义类型。

假设你正在编写一个程序来监控某些设备,以确保它们不会过热。这里有一个可能有用的 OverheatError 类型。它有一个 Error 方法,因此它满足 error 接口。但更有趣的是,它将 float64 作为其底层类型,允许我们跟踪超出容量的度数。

image

这里有一个 checkTemperature 函数,它使用 OverheatError。它以系统实际温度和被认为安全的温度作为参数。它指定返回一个 error 类型的值,而不是特定的 OverheatError,但这没问题,因为 OverheatError 满足 error 接口。如果实际温度超过安全温度,checkTemperature 返回一个记录超出量的新 OverheatError

image

没有蠢问题

Q: 我们如何在所有这些不同的包中使用 error 接口类型,而无需导入它?它的名称以小写字母开头。这不是表示它是未导出的吗,无论它声明在哪个包中?error 到底是在哪个包中声明的?

A: error 类型是一个“预声明的标识符”,就像 intstring 一样。因此,像其他预声明的标识符一样,它不属于任何包。它属于“宇宙块”,这意味着它在任何地方都可用,无论你在哪个包里。

还记得有iffor块,它们被函数块包围,函数块又被包块包围吗?宇宙块包含所有的包块。这意味着你可以在任何包中使用在宇宙块中定义的任何内容,而无需导入它们。这包括 error 和所有其他预声明的标识符。

Stringer 接口

记得我们在第九章中创建的GallonsLitersMilliliters类型吗?它们用于区分不同的体积单位。但我们发现,实际区分它们并不那么容易。12 加仑和 12 升或 12 毫升完全是不同的量,但它们在打印时看起来都一样。如果数值有太多小数位,打印出来也显得很尴尬。

image

你可以使用 Printf 来四舍五入数字并添加一个表示单位的缩写,但在每个需要使用这些类型的地方这样做会很快变得乏味。

image

这就是为什么 fmt 包定义了 fmt.Stringer 接口的原因:允许任何类型决定在打印时如何显示。很容易设置任何类型以满足 Stringer 接口;只需定义一个返回 stringString() 方法即可。接口定义如下:

image

例如,在这里我们设置了这个 CoffeePot 类型以满足 Stringer

image

fmt 包中的许多函数检查传递给它们的值是否满足 Stringer 接口,并在是这样的情况下调用它们的 String 方法。这包括 PrintPrintlnPrintf 函数等等。现在 CoffeePot 满足 Stringer,我们可以直接将 CoffeePot 值传递给这些函数,并且 CoffeePotString 方法的返回值将用于输出中:

image

现在,让我们来更加严肃地使用这个接口类型。让我们让我们的 GallonsLitersMilliliters 类型满足 Stringer 接口。我们将我们的代码移到与每种类型关联的 String 方法中格式化它们的值。我们将调用 Sprintf 函数而不是 Printf,并返回结果值。

image

现在,每当我们将 GallonsLitersMilliliters 值传递给 Println(或大多数其他 fmt 函数),它们的 String 方法将被调用,并且返回的值将用于输出。我们为每种类型设置了一个有用的默认格式打印!

空接口

image

好问题!让我们运行 **go doc** 来查看 fmt.Println 的文档,并看看它的参数声明为什么类型...

image

正如我们在第六章中看到的那样,... 表示它是一个可变参数函数,这意味着它可以接受任意数量的参数。但是 interface{} 类型又是什么?

记住,接口声明指定了类型必须具有的方法,以满足该接口。例如,我们的 NoiseMaker 接口可以由任何具有 MakeSound 方法的类型满足。

type NoiseMaker interface {
       MakeSound()
}

但是如果我们声明一个根本不需要任何方法的接口类型会发生什么?它将被任何类型满足!它将被所有类型满足!

type Anything interface {
}

interface{} 类型被称为空接口,它用于接受任何类型的值。空接口没有任何要求满足它的方法,因此种类型都满足它。

如果你声明一个接受空接口类型作为其参数类型的函数,那么你可以将任何类型的值作为参数传递给它:

image

空接口不需要任何方法来满足它,因此所有类型都满足它。

但是不要匆忙开始将空接口用于所有的函数参数!如果你有一个空接口类型的值,你它的操作就不多了。

fmt 中的大多数函数接受空接口类型的值,所以你可以将它传递给这些函数:

image

但是不要试图对空接口值调用任何方法!记住,如果你有一个接口类型的值,你只能调用属于该接口的方法。而空接口没有任何方法。这意味着你不能调用空接口类型值上的任何方法!

image

要在具有空接口类型的值上调用方法,您需要使用类型断言来获取具体类型的值。

image

而到了那时,您可能最好编写一个仅接受特定具体类型的函数。

image

因此,在定义自己的函数时,空接口的有用性有所限制。但是,在fmt包中的函数和其他地方,您将一直使用空接口。下次您在函数文档中看到一个interface{}参数时,您就会明白它的含义!

当您定义变量或函数参数时,通常您会确切地知道您将要处理的值什么。您可以使用像PenCarWhistle这样的具体类型。但有时,您只关心值能什么。在这种情况下,您会想要定义一个接口类型,比如WritingInstrumentVehicleNoiseMaker

您将定义需要调用的方法作为接口类型的一部分。而且,您可以赋值给您的变量或调用您的函数,而不必担心值的具体类型。如果它具有正确的方法,您就可以使用它!

您的 Go 工具箱

image

这就是 第十一章 的全部内容!您已将接口添加到您的工具箱中。

注意

接口

接口是一组方法的集合,期望具有某些特定值。

拥有接口定义中列出的所有方法的任何类型被认为满足该接口。

满足接口的类型可以赋值给任何使用该接口作为其类型的变量或函数参数。

池难题解决方案

image

第十二章:重新振作:从失败中恢复

image

每个程序都会遇到错误。你应该为它们做好准备。

有时处理错误可能就是简单地报告它并退出程序。但其他错误可能需要额外的操作。你可能需要关闭已打开的文件或网络连接,或者以其他方式清理,以防止程序留下混乱。在本章中,我们将向您展示如何推迟清理操作,以便即使出现错误,它们仍然会发生。我们还将向您展示在那些(罕见的)适当情况下如何让程序panic,以及如何recover

重新审视从文件中读取数字

我们已经讨论了在 Go 语言中处理错误的许多技巧。但到目前为止我们展示的技术并不适用于所有情况。让我们看看一个这样的场景。

image

我们希望创建一个名为sum.go的程序,从文本文件中读取float64值,将它们全部加在一起,并打印它们的总和。

在第六章中,我们创建了一个GetFloats函数,它打开一个文本文件,将文件的每一行转换为float64值,并将这些值作为一个切片返回。

在这里,我们将GetFloats移到了main包中,并更新它以依赖两个新函数,OpenFileCloseFile,来打开和关闭文本文件。

image

我们想要将要读取的文件名作为命令行参数指定。你可能还记得在第六章中使用过os.Args切片 —— 它是一个包含程序运行时所有参数的string值切片。

因此在我们的main函数中,通过访问os.Args[1]来获取要打开的文件名作为第一个命令行参数。(请记住,os.Args[0]元素是正在运行的程序的名称;实际的程序参数出现在os.Args[1]及以后的元素中。)

然后我们将该文件名传递给GetFloats以读取文件,并获得一个float64值切片。

如果在执行过程中遇到任何错误,它们将从GetFloats函数中返回,并存储在err变量中。如果err不为nil,这意味着发生了错误,我们只需记录它并退出。

否则,这意味着文件已成功读取,因此我们使用for循环将切片中的每个值相加,并最后打印总和。

image

让我们把所有这些代码保存在一个名为sum.go的文件中。然后,让我们创建一个填满数字的纯文本文件,每行一个数字。我们将其命名为data.txt,并保存在与sum.go相同的目录中。

image

我们可以通过go run sum.go data.txt来运行程序。字符串"data.txt"将作为sum.go程序的第一个参数,因此它将作为文件名传递给GetFloats

我们可以看到 OpenFileCloseFile 函数何时被调用,因为它们都包含对 fmt.Println 的调用。在输出末尾,我们可以看到 data.txt 中所有数字的总和。看起来一切正常!

image

任何错误都会阻止文件关闭!

但是,如果我们给 sum.go 程序一个格式错误的文件,我们就会遇到问题。例如,文件中有一行无法解析为 float64 值,就会出现错误。

image

现在,这本身没什么问题;每个程序偶尔都会收到无效数据。但是 GetFloats 函数应该在完成时调用 CloseFile 函数。我们在程序输出中看不到 “Closing file”,这表明 CloseFile 没有被调用!

问题是当我们用无法转换为 float64 的字符串调用 strconv.ParseFloat 时,它会返回一个错误。我们的代码设置在那一点从 GetFloats 函数返回。

但是那个返回发生在调用 CloseFile 之前,这意味着文件永远不会被关闭!

image

延迟函数调用

现在,没有关闭文件可能看起来不是什么大问题。对于只打开单个文件的简单程序,这可能没什么问题。但是每个未关闭的文件都会继续消耗操作系统资源。随着时间的推移,多个未关闭的文件会累积,导致程序失败,甚至影响整个系统的性能。养成确保程序完成后关闭文件的习惯非常重要。

但是我们如何实现这一点呢?GetFloats 函数设置为如果在读取文件时遇到错误立即退出,即使还没有调用 CloseFile

如果有一个函数调用你希望确保运行,无论如何,你可以使用 defer 语句。你可以在任何普通函数或方法调用之前放置 defer 关键字,Go 将延迟执行该函数调用,直到当前函数退出。

通常,函数调用会在遇到它们时立即执行。在这段代码中,fmt.Println("Goodbye!") 调用在另外两个 fmt.Println 调用之前执行。

image

但是如果我们在 fmt.Println("Goodbye!") 调用之前添加 defer 关键字,那么该调用将等到 Socialize 函数的所有剩余代码运行完毕并退出后才会执行。

image

使用延迟函数调用来从错误中恢复

image

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关键字本身只能用于函数或方法调用。

列出目录中的文件

image

Go 还有一些其他功能可帮助您处理错误,我们将展示一个演示这些功能的程序。但是那个程序使用了一些新技巧,我们在深入之前需要向您展示它们。首先,我们需要知道如何读取目录的内容。

尝试创建一个名为 my_directory 的目录,其中包含右侧显示的两个文件和一个子目录。下面的程序将列出 my_directory 的内容,显示每个项目的名称以及它是文件还是子目录。

image

io/ioutil 包包含一个 ReadDir 函数,它允许我们读取目录的内容。您将目录的名称传递给 ReadDir,它将返回一个值的切片,每个值代表目录包含的每个文件或子目录(以及遇到的任何错误)。

切片的每个值都满足 FileInfo 接口,该接口包括一个 Name 方法,返回文件的名称,以及一个 IsDir 方法,如果是目录则返回 true

因此,我们的程序调用 ReadDir,并将 my_directory 的名称作为参数传递给它。然后它循环遍历返回的切片中的每个值。如果 IsDir 对于该值返回 true,则打印 "目录:" + 文件的名称。否则,打印 "文件:" + 文件的名称

image

将上述代码保存为 files.go,与 my_directory 相同的目录中。在终端中,切换到该父目录,并输入 **go run files.go**。程序将运行并列出 my_directory 包含的文件和目录。

image

列出子目录中的文件(会更棘手)

imageimage

读取单个目录的内容并不太复杂。但是假设我们想要列出更复杂的内容,比如 Go 工作区目录。这将包含一个嵌套在子目录中的整个子目录树,其中有些包含文件,有些则不包含。

通常情况下,这样一个程序会相当复杂。简单来说,其逻辑如下:

image

相当复杂,对吧?我们宁愿不必编写那样的代码!

但是如果有一种更简单的方法呢?类似于这样的逻辑:

  1. 获取目录中的文件列表。

    1. 获取下一个文件。

    2. 文件是一个目录吗?

      1. 如果是:从步骤I开始,使用此目录。

      2. 如果不是:只需打印文件名。

现在我们不清楚如何处理“使用这个新目录重新启动逻辑”的部分了。为了实现这一点,我们将需要一个新的编程概念...

image

递归函数调用

image

这就把我们带到了我们在结束我们的偏离并回到处理错误之前需要向您展示的第二个(也是最后一个)技巧。

Go 是支持递归的众多编程语言之一,允许函数调用自身。

如果你粗心大意地做这个,你最终会陷入一个无限循环,函数一遍又一遍地调用自身:

图片

但是,如果确保递归循环最终停止,递归函数实际上可以很有用。

这是一个递归的 count 函数,从一个起始数计数到一个结束数。(通常循环效率更高,但这是演示递归工作原理的简单方法。)

图片图片

以下是程序的执行顺序:

  1. main 调用 count,起始参数为 1,结束参数为 3

  2. count打印start参数:1

  3. start (1) 小于 end (3),所以 countstart2end3 调用自身。

  4. 这是 count 的第二次调用,打印了它的新 start 参数:2

  5. start (2) 小于 end (3),所以 countstart3end3 调用自身。

  6. count 的第三次调用,打印了它的新 start 参数:3

  7. start (3) 小于 end (3),因此 count 再次调用自身;它只是返回。

  8. 前两次调用 count 也返回了,程序结束了。

如果我们添加调用 Printf 来显示每次调用 count 和每次函数退出时,这个顺序将更加明显:

图片

所以这是一个简单的递归函数。让我们尝试将递归应用到我们的 files.go 程序中,看看它是否能帮助我们列出子目录的内容...

递归列出目录内容

图片

我们希望我们的 files.go 程序能够列出 Go 工作空间目录中所有子目录的内容。我们希望使用类似这样的递归逻辑来实现这一点:

  1. 获取目录中的文件列表。

    1. 获取下一个文件。

    2. 文件是否为目录?

      1. 如果是:从步骤 I 开始处理此目录。

      2. 如果不是:只需打印文件名。

图片

我们从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 函数在运行时,你会看到递归的真正魅力。对于我们的示例目录结构,处理过程大致如下:

  1. main 函数使用路径 "go" 调用 scanDirectory

  2. scanDirectory 函数打印传递给它的路径:"go",指示它正在处理的目录

  3. 它使用路径 "go" 调用 ioutil.ReadDir 函数

  4. 返回的切片中只有一个条目:"src"

  5. 使用当前目录路径 "go" 和文件名 "src" 调用 filepath.Join 函数会得到新路径 "go/src"

  6. src 是一个子目录,因此再次调用 scanDirectory 函数,这次路径是 "go/src"

    注意

    递归!

  7. scanDirectory 函数打印新路径:"go/src"

  8. 使用路径 "go/src" 调用 ioutil.ReadDir 函数

  9. 返回的切片中的第一个条目是 "geo"

  10. 使用当前目录路径 "go/src" 和文件名 "geo" 调用 filepath.Join 函数会得到新路径 "go/src/geo"

  11. geo 是一个子目录,因此再次调用 scanDirectory 函数,这次路径是 "go/src/geo"

    注意

    递归!

  12. scanDirectory 函数打印新路径:"go/src/geo"

  13. 它使用路径 "go/src/geo" 调用 ioutil.ReadDir 函数

  14. 返回的切片中的第一个条目是 "coordinates.go"

  15. coordinates.go 不是一个目录,因此它的名称会简单地被打印出来

  16. 依此类推...

递归函数可能很难编写,并且通常消耗比非递归解决方案更多的计算资源。但有时,递归函数可以提供解决其他方法难以解决的问题的解决方案。

现在我们的 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包含除123之外的任何数字,那不是用户错误,而是程序中的 bug。

因此,如果doorNumber包含无效值,调用panic是有道理的。这不应该发生,如果发生,我们希望在程序表现出意外行为之前停止程序。

image

“recover”函数

将我们的scanDirectory函数更改为使用panic而不是返回错误,大大简化了错误处理代码。但是,恐慌也导致我们的程序崩溃并显示一个丑陋的堆栈跟踪。我们更愿意只显示用户错误消息。

Go 语言提供了一个内置的recover函数,可以阻止程序因恐慌而崩溃。我们需要使用它来优雅地退出程序。

当您在正常程序执行期间调用recover时,它只会返回nil,不会做其他操作:

image

如果在程序恐慌时调用recover,它将停止恐慌。但是当您在函数中调用panic时,该函数将停止执行。因此,在与panic相同的函数中调用recover是没有意义的,因为恐慌仍将继续:

image

但是在程序恐慌时有一种方法可以调用recover…… 在恐慌期间,任何延迟调用的函数都会被完成。因此,您可以将调用recover放在一个单独的函数中,并使用defer在导致恐慌的代码之前调用该函数。

image

调用recover 不会 导致执行在恐慌点恢复,至少不完全是这样。发生恐慌的函数将立即返回,该函数块中恐慌后的代码将不会执行。但在发生恐慌的函数返回后,正常执行将恢复。

image

恐慌值是从recover函数返回的。

正如我们提到的,当没有恐慌时,调用recover会返回nil

image

但是当有恐慌发生时,recover会返回传递给panic的任何值。这可以用来收集关于恐慌的信息,以帮助恢复或向用户报告错误。

image

在我们介绍panic函数时,我们提到它的参数类型是interface{},即空接口,因此panic可以接受任何值。同样,recover的返回值类型也是interface{}。您可以将recover的返回值传递给像Println(接受interface{}值)之类的fmt函数,但不能直接调用它的方法。

下面是一些代码,它将一个 error 值传递给 panic。但在这样做时,该 error 被转换为一个 interface{} 值。当延迟函数稍后调用 recover 时,将返回该 interface{} 值。因此,即使底层的 error 值具有 Error 方法,尝试在 interface{} 值上调用 Error 将导致编译错误。

image

要调用方法或使用 panic 值进行其他操作,需要使用类型断言将其转换回其底层类型。

这里更新了上述代码,它获取 recover 的返回值并将其转换回 error 值。完成后,我们可以安全地调用 Error 方法。

image

在 scanDirectory 中从 panic 中恢复

当我们最后离开 files.go 程序时,在 scanDirectory 函数中添加 panic 调用清理了我们的错误处理代码,但也导致程序崩溃。我们可以利用我们到目前为止学到的关于 deferpanicrecover 的知识来打印错误消息并优雅地退出程序。

我们通过添加一个 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 日志和堆栈跟踪,而只会看到一个错误消息!

imageimage

恢复 panic

reportPanic 还有一个潜在问题需要解决。现在,它拦截 任何 panic,即使这些 panic 并非源自 scanDirectory。如果 panic 值无法转换为 error 类型,reportPanic 就不会打印它。

我们可以通过在 main 函数中使用 string 参数再次调用 panic 来测试这一点:

image

reportPanic 函数从新的 panic 中恢复,但由于 panic 值不是 error,因此 reportPanic 不会打印它。我们的用户不知道程序为什么失败了!

处理未预期的 panic 的常见策略是简单地重新引发 panic 状态。通常重新引发是合适的,因为毕竟这是一个未预期的情况。

右侧代码更新了reportPanic以处理意外的 panic。如果类型断言成功将 panic 值转换为error,我们就像以前一样简单地打印它。但如果失败,我们只需再次调用panic并传入相同的 panic 值。

image

再次运行files.go显示修复效果:reportPanic从我们对panic的测试调用中恢复,但当error类型断言失败时,它会再次引发 panic。现在我们可以在main中移除对panic的调用,有信心地报告任何其他未预期的 panic!

image

没有愚蠢的问题

Q: 我看到其他编程语言有“异常”。panicrecover函数似乎以类似的方式工作。我能像异常那样使用它们吗?

A: 我们强烈建议不要这样做,Go 语言的维护者们也是这样认为。甚至可以说,根据语言设计本身的原因,使用panicrecover是不被鼓励的。在 2012 年的一次会议主题演讲中,Rob Pike(Go 的创始人之一)将panicrecover描述为“故意笨拙”。这意味着在设计 Go 语言时,其创作者们并没有试图让panicrecover易于使用或愉快,因此它们使用的频率会较低

这是 Go 设计者们对异常的一个主要弱点做出的回应:异常可能会使程序流程变得更加复杂。相反,Go 开发者被鼓励以与处理程序其他部分相同的方式处理错误:通过ifreturn语句,以及error值。确实,直接在函数内部处理错误可能会使函数的代码略微变长,但这比根本不处理错误要好。(Go 的创建者发现许多使用异常的开发者会仅仅抛出异常,然后未能正确处理它。)直接处理错误也使得错误处理方式立即显而易见——你无需查看程序的其他部分来查看错误处理代码。

所以不要期待 Go 中与异常等效的功能。这个特性是有意遗漏的。对于习惯于使用异常的开发者来说,可能需要一段时间的适应,但 Go 的维护者们认为这样做最终会带来更好的软件。

注意

你可以在这里查看 Rob Pike 的演讲摘要:

talks.golang.org/2012/splash.article#TOC_16.

你的 Go 工具箱

这就是第十二章的全部内容!你已经将延迟函数调用和从 panic 中恢复添加到了你的工具箱中。

imageimage

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)
                  }
            }
}

image

第十三章:分享工作:Goroutines 和 Channels

image

一次只做一件事情并不总是完成任务最快的方法。 有些大问题可以分解成更小的任务。Goroutines 允许你的程序同时处理多个不同的任务。你的 goroutines 可以使用channels来协调它们的工作,让它们互相发送数据并同步,以确保一个 goroutine 不会超过另一个。Goroutines 让你充分利用拥有多处理器的计算机,从而使你的程序运行尽可能快!

获取网页

image

本章将讨论通过同时执行多个任务来更快完成工作。但首先,我们需要一个可以分解成小部分的大任务。所以在接下来的几页中,请耐心等待我们布景...

网页越小,访问者的浏览器加载速度就越快。我们需要一个工具来测量页面的大小,以字节为单位。

多亏了 Go 的标准库,这不应该太难。下面的程序使用net/http包连接到一个站点,并仅通过几个函数调用获取网页。

我们将想要的站点的 URL 传递给http.Get函数。它将返回一个http.Response对象,以及它遇到的任何错误。

http.Response对象是一个结构体,具有代表页面内容的Body字段。Body满足io包的ReadCloser接口,意味着它有一个Read方法(允许我们读取页面数据)和一个Close方法(在完成读取后释放网络连接)。

我们推迟对Close的调用,所以在我们读完数据后,连接将被释放。然后我们将响应体传递给ioutil包的ReadAll函数,它将读取其所有内容并作为byte值的切片返回。

image

我们还没有涵盖byte类型;它是 Go 的基本类型之一(类似于float64bool),用于保存原始数据,比如从文件或网络连接中读取的数据。如果直接打印byte值的切片,将不会显示任何有意义的内容,但是如果将byte值的切片转换为string类型,就可以得到可读的文本。(假设数据表示可读文本。)因此,我们最后将响应体转换为string并打印出来。

image

如果我们将这段代码保存到文件并用go run运行它,它将获取 example.com 页面的 HTML 内容,并显示出来。

image

如果您想获取有关此程序中使用的函数和类型的更多信息,可以通过终端上的go doc命令(我们在第四章中已经了解过)获取。 尝试右侧的命令来查看文档。(或者,如果您愿意,可以使用您喜欢的搜索引擎在浏览器中查找。)

image

从这里开始,将程序转换为打印多个页面的大小并不是太困难。

我们可以将检索页面的代码移动到单独的responseSize函数中,并将要检索的 URL 作为参数传递。 我们会打印我们正在检索的 URL,仅用于调试目的。 调用http.Get、读取响应和释放连接的代码基本上不会改变。 最后,我们不再将响应的字节片段转换为string,而是直接调用len获取该片段的长度。 这将给出响应的字节长度,我们将其打印出来。

我们更新我们的main函数,使用多个不同的 URL 调用responseSize。 运行程序时,它将打印 URL 和页面大小。

imageimage

多任务处理

现在我们来到本章的重点:通过同时执行多个任务来加速程序。

我们的程序依次调用responseSize多次。 每次调用responseSize都会建立到网站的网络连接,等待网站响应,打印响应大小,然后返回。 只有一个responseSize调用返回后,下一个才能开始。 如果我们有一个所有代码都重复三次的大型函数,运行时间与我们的三个responseSize调用相同。

image

但是,如果有一种方法可以同时运行所有三个responseSize调用呢? 程序可能只需三分之一的时间就能完成!

image

使用 goroutines 进行并发处理

responseSize调用http.Get时,您的程序必须在那里等待远程网站响应。 在等待期间,它不执行任何有用的操作。

不同的程序可能需要等待用户输入。 另一个可能在从文件中读取数据时等待。 有许多情况下,程序只是坐在那里等待。

并发允许程序暂停一个任务并处理其他任务。 等待用户输入的程序可能在后台执行其他处理。 程序可能在读取文件时更新进度条。 我们的responseSize程序在等待第一个请求完成时可能会进行其他网络请求。

如果一个程序被写成支持并发,那么它可能也支持并行处理:同时运行任务。只有一个处理器的计算机一次只能运行一个任务。但是如今大多数计算机都有多个处理器(或者一个拥有多个核心的处理器)。您的计算机可能会将并发任务分配给不同的处理器以同时运行它们。(直接管理这些的情况很少见,通常操作系统会为您处理。)

将大任务分解为可以并发运行的更小子任务,有时可以显著提高程序的运行速度。

在 Go 语言中,并发任务被称为goroutines。其他编程语言有类似的概念称为线程,但是 goroutines 需要比线程更少的计算机内存,而且启动和停止时间也更短,这意味着您可以同时运行更多的 goroutines。

它们也更容易使用。要启动另一个 goroutine,您只需使用go语句,这只是一个普通的函数或方法调用,在其前面加上go关键字:

Goroutines 允许并发:暂停一个任务以处理其他任务。在某些情况下,它们还允许并行处理:同时处理多个任务!

image

注意我们说另一个goroutine。每个 Go 程序的main函数都是使用一个 goroutine 启动的,所以每个 Go 程序至少运行一个 goroutine。你一直在使用 goroutines,只是不知道而已!

使用 goroutines

这是一个按顺序调用函数的程序。a函数使用循环 50 次打印字符串"a",而b函数则打印字符串"b" 50 次。main函数先调用a,然后是b,最后在退出时打印一条消息。

image

这就好像main函数包含了所有a函数的代码,然后是所有b函数的代码,最后是自己的代码:

image

要在新的 goroutines 中启动ab函数,您只需在函数调用前面加上go关键字即可:

func main() {
       go a()
       go b()
       fmt.Println("end main()")
}

这会使新的 goroutines 与main函数并发运行:

image

但是如果我们现在运行程序,我们只会看到main函数末尾的Println调用的输出——我们看不到ab函数的任何输出!

image

这里有个问题:一旦main goroutine(调用main函数的 goroutine)结束,Go 程序就会停止运行,即使其他 goroutine 仍在运行。我们的main函数在ab函数的代码有机会运行之前就已经完成了。

image

我们需要保持 main goroutine 运行,直到 ab 函数的 goroutine 能够完成。为了正确实现这一点,我们将需要 Go 语言的另一个特性——通道,但在本章后面我们会再详细介绍。所以现在,我们只需暂停 main goroutine 一段时间,以便其他 goroutine 可以运行。

我们将使用 time 包中的一个函数,称为 Sleep,它会暂停当前 goroutine 给定的时间。在 main 函数中调用 time.Sleep(time.Second) 将导致 main goroutine 暂停 1 秒。

image

如果我们重新运行程序,当它们的 goroutine 最终有机会运行时,我们会再次看到来自 ab 函数的输出。这两者的输出会混合在一起,因为程序在两个 goroutine 之间切换。(您得到的模式可能与此处显示的不同。)当 main goroutine 再次唤醒时,它会进行 fmt.Println 的调用并退出。

main goroutine 中对 time.Sleep 的调用给了足够的时间让 ab goroutine 完成运行。

image

使用 goroutine 和我们的responseSize函数

适应我们打印网页大小程序使用 goroutine 是非常容易的。我们只需要在每个对responseSize的调用前添加go关键字即可。

为了防止 main goroutine 在 responseSize goroutine 完成之前退出,我们还需要在 main 函数中添加对 time.Sleep 的调用。

image

仅休眠 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 调用完成。

image

我们并没有控制调用 responseSize 的执行顺序,所以如果我们再次运行程序,我们可能会看到请求以不同的顺序发生。

image

即使所有站点的响应速度比 5 秒更快,程序仍然需要 5 秒才能完成,所以我们从切换到 goroutine 中仍然没有得到很好的速度增益。更糟糕的是,如果站点响应时间长,5 秒可能还不够。有时,您可能会看到程序在所有响应到达之前结束。

image

显然,time.Sleep并不是等待其他 goroutine 完成的理想方式。一旦我们在几页中看到通道,我们将有一个更好的替代方法。

我们不能直接控制 goroutine 运行的时间

每次运行程序时,我们可能会看到responseSize goroutine 以不同的顺序运行:

image

我们也无法知道前一个程序何时在ab goroutine 之间切换:

image

在正常情况下,Go 不能保证何时会在 goroutine 之间切换,也不能保证切换的持续时间。这允许 goroutine 以最有效的方式运行。但如果您关心 goroutine 运行的顺序,您将需要使用通道来同步它们(我们很快将看到)。

代码磁铁

image

使用 goroutines 的程序在冰箱上被打乱了。您能够重构代码片段以使工作程序产生与给定示例类似的输出吗?(无法预测 goroutine 执行的顺序,所以不用担心,您的程序输出不需要完全匹配所示输出。)

image

image 答案在“代码磁铁解决方案”中。

go语句不能与返回值一起使用

转换为 goroutines 引出了另一个问题,我们需要解决:我们不能在go语句中使用函数返回值。假设我们想要修改responseSize函数以返回页面大小而不是直接打印它:

image

我们将会得到编译错误。编译器阻止您尝试从使用go语句调用的函数中获取返回值。

这实际上是一件好事。当您在go语句的一部分调用responseSize时,您的意思是:“Go 在一个单独的 goroutine 中运行responseSize。我将继续运行此函数中的指令。”responseSize函数不会立即返回值;它必须等待网站响应。但是您的main goroutine 中的代码会期望立即返回值,但此时还没有返回值!

image

这适用于使用go语句调用的任何函数,而不仅仅是像responseSize这样的长时间运行函数。您不能依赖于返回值及时准备好,因此 Go 编译器阻止任何尝试使用它们的行为。

Go 不允许您在使用go语句调用的函数中使用返回值,因为无法保证返回值在使用之前已经准备好:

image

但是有一种方法可以在 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 协程成为 abcdef 协程的协调者,只有当它准备好读取它们发送的值时,它们才能继续进行。

图片

观察协程同步

abcdef 协程通过它们的通道发送值的速度非常快,以至于很难看清发生了什么。这里有另一个程序可以减慢速度,这样你就可以看到阻塞发生的情况。

我们从一个 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 秒钟太长,有时又太短。

image

我们可以使用通道来同时解决这两个问题!

首先,我们从import语句中移除time包;我们不再需要time.Sleep了。然后,我们更新responseSize以接受一个int值的通道。不再返回页面大小,而是通过通道发送大小。

image

main函数中,我们调用make创建了一个int值的通道。我们更新每个对responseSize的调用,将通道作为参数添加进去。最后,我们在通道上执行三次接收操作,每次接收responseSize发送的一个值。

image

如果我们运行这个程序,我们会看到程序的完成速度和网站的响应速度一样快。这个时间可能有所不同,但在我们的测试中,我们看到完成时间短至 1 秒!

我们还可以做的另一个改进是将我们想要检索的 URL 列表存储在一个切片中,然后使用循环调用responseSize,并从 channel 接收值。这将使我们的代码更少重复,并且如果以后想要添加更多 URL,则很重要。

我们根本不需要改变responseSize,只需修改main函数。我们创建一个包含我们想要的 URL 的string值切片。然后我们遍历该切片,并调用responseSize函数,传入当前的 URL 和 channel。最后,我们再进行第二个独立的循环,为切片中的每个 URL 运行一次,接收并打印来自 channel 的值。(在单独的循环中执行这些操作很重要。如果我们在启动responseSize goroutines 的同一个循环中接收值,main goroutine 会阻塞,直到接收完成,然后我们又回到了一次请求一个页面的方式。)

image

使用循环的方式更清晰,但结果仍然相同!

更新我们的 channel 以携带一个结构体

我们仍然需要修复responseSize函数的一个问题。我们不知道网站将以什么顺序响应。因为我们没有保持页面 URL 与响应大小在一起,所以我们不知道哪个大小属于哪个页面!

image

不过这并不难修复。Channels 可以像传递基本类型一样轻松地传递复合类型,比如切片、映射和结构体。我们可以创建一个结构体类型来存储页面 URL 以及其大小,这样我们可以一起将它们发送到 channel 中。

我们将声明一个具有底层struct类型的新Page类型。Page将有一个URL字段来记录页面的 URL,以及一个Size字段来记录页面的大小。

我们将在responseSize函数的 channel 参数上更新为保存新的Page类型,而不仅仅是int页面大小。我们将让responseSize创建一个带有当前 URL 和页面大小的新Page值,并将其发送到 channel 中。

main函数中,我们还将更新make调用中 channel 持有的类型。当我们从 channel 接收一个值时,它将是一个Page值,因此我们将打印它的URLSize字段。

image

现在输出将页面大小与它们的 URL 配对。这样就清楚了每个大小属于哪个页面。

以前,我们的程序必须逐个请求页面。Goroutines 允许我们在等待网站响应时开始处理下一个请求。程序完成的时间缩短了三分之一!

你的 Go 工具箱

image

这就是第十三章的内容!你已经将 goroutines 和 channels 添加到你的工具箱中。

image

Code Magnets 解决方案

image

第十四章:代码质量保证:自动化测试

image

你确定你的软件现在能正常工作吗?真的确定吗? 在将新版本发送给用户之前,你可能试验了新功能以确保它们正常工作。但你是否试验了功能以确保没有破坏它们中的任何一个?所有的旧功能?如果这个问题让你担心,你的程序需要自动化测试。自动化测试确保你程序的组件在代码更改后仍能正常工作。Go 语言的testing包和go test工具使编写自动化测试变得简单,利用你已经掌握的技能!

自动化测试能在别人之前发现你的 bug

开发者 A 在他们经常去的一家餐馆里碰到了开发者 B……

开发者 A: 开发者 B:
新工作如何? 不太好。晚饭后我得回办公室。我们发现一个 bug 导致某些客户被多次计费。
哎呀。那个怎么进了你们的计费服务器? 我们认为可能是几个月前引入的。其中一个开发人员当时对计费代码做了一些更改。
哇,那么久以前了…… 你们的测试没发现这个 bug 吗? 测试?
你们的自动化测试。在引入 bug 时没有失败? 嗯,我们没有这方面的测试。
什么?!

你的客户依赖于你的代码。当它出错时,后果可能很严重。你公司的声誉会受损。而将不得不加班修 bug。

这就是为什么发明了自动化测试。自动化测试是一个单独的程序,执行主程序的组件,并验证它们的行为是否符合预期。

image

除非你打算测试所有旧功能,确保你的更改没有破坏任何功能。自动化测试比手动测试节省时间,通常也更彻底。

我们应该为其编写自动化测试的一个函数示例

让我们来看一个可以通过自动化测试捕获的 bug 示例。这里有一个简单的包,其中包含一个函数,将几个字符串连接成适合在英语句子中使用的单个字符串。如果有两个项目,它们将用“and”连接(如“苹果和橙子”)。如果有多于两个项目,逗号将根据需要添加(如“苹果、橙子和梨子”)。

注意

最后一个很棒的例子来自《Head First Ruby》(这本书也有一章关于测试)!

image

代码使用了strings.Join函数,该函数接受一个字符串切片和一个用于连接它们的字符串。Join函数返回一个字符串,其中包含来自切片的所有项目,连接字符串分隔每个条目。

image

JoinWithCommas中,我们使用切片操作符来收集切片中除最后一个短语外的所有短语,并将它们传递给strings.Join来将它们连接成一个字符串,每个短语之间用逗号和空格分隔。然后我们添加词语and(用空格包围),并以最后一个短语结束字符串。

image

这里有一个快速程序来尝试我们的新功能。我们导入我们的prose包,并传递一些切片给JoinWithCommas

image

它能工作,但结果有个小问题。也许我们只是不成熟,但我们可以想象这会导致人们开玩笑说父母一个小丑和一头奖牛。而且用这种方式格式化列表可能会导致其他误解。

为了消除任何混淆,让我们更新我们包的代码,在and之前再加一个额外的逗号(例如“apple, orange, and pear”):

image

如果我们重新运行程序,我们将在结果字符串的and前看到逗号。现在应该清楚,父母在照片中小丑和公牛一起。

image

我们引入了一个 bug!

image

哦,是的!该函数曾经对这个两项列表返回"my parents and a rodeo clown",但这里也包含了一个额外的逗号!我们当时太专注于修复项列表,结果引入了项列表的 bug...

image

如果我们为这个函数编写了自动化测试,这个问题本可以避免。

自动化测试使用特定的输入运行你的代码,并寻找特定的结果。只要你的代码输出与预期值匹配,测试就会“通过”。

但假设你在你的代码中意外引入了一个 bug(就像我们在额外逗号的例子中做的那样)。你的代码输出将不再匹配预期值,测试将“失败”。你会立即知道有 bug。

imageimage

拥有自动化测试就像在每次修改代码时自动检查代码中的 bug 一样!

写测试

Go 包含一个testing包,你可以用它为你的代码编写自动化测试,并使用go test命令来运行这些测试。

让我们先写一个简单的测试。起初我们不会测试任何实际内容,只是展示一下测试的工作原理。然后我们会实际使用测试来帮助我们修复JoinWithCommas函数。

在你的prose包目录中,紧挨着join.go文件,创建一个join_test.go文件。文件名中的join部分并不重要,但_test.go部分是必须的;go test工具会寻找以该后缀命名的文件。

image

测试文件中的代码由普通的 Go 函数组成,但为了能够与go test工具一起工作,它需要遵循某些约定:

  • 您不必将您的测试作为与您正在测试的代码相同的包的一部分,但如果您想要访问包中未导出的类型或函数,您将需要这样做。

  • 测试需要使用testing包中的类型,因此您将需要在每个测试文件的顶部导入该包。

  • 测试函数的名称应以Test开头。(其余部分可以任意,但应以大写字母开头。)

  • 测试函数应接受一个参数:指向testing.T值的指针。

  • 您可以通过调用testing.T值上的方法(如Error)报告测试失败。大多数方法接受一个带有说明测试失败原因的字符串。

使用“go test”命令运行测试

要运行测试,您可以使用go test命令。该命令接受一个或多个包的导入路径,就像go installgo 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",我们将测试失败。

image

如果我们重新运行测试,TestThreeElements测试将通过,但TestTwoElements测试将失败。

image

这是一件事情;它与我们基于join程序输出所预期看到的相符。这意味着我们可以依赖我们的测试来判断JoinWithCommas是否正常工作!

imageimage

使用“Errorf”方法生成更详细的测试失败消息

目前我们的测试失败信息对于诊断问题并不是很有帮助。我们知道预期有某个值,而且我们知道JoinWithCommas的返回值与预期不同,但我们不知道这些值是什么。

image

测试函数的testing.T参数还有一个可以调用的Errorf方法。与Error不同,Errorf接受带有格式化动词的字符串,就像fmt.Printffmt.Sprintf函数一样。您可以使用Errorf在测试失败消息中包含额外的信息,例如您传递给函数的参数、您得到的返回值以及您期望的值。

这里是我们测试的更新,使用Errorf生成更详细的失败消息。为了避免在每个测试中重复字符串,我们添加了一个want变量(表示我们期望的值),用于保存我们期望JoinWithCommas返回的值。我们还添加了一个got变量(表示我们实际得到的值),用于保存实际的返回值。如果got不等于want,我们将调用Errorf,并让它生成一个错误消息,包括我们传递给JoinWithCommas的切片(我们使用格式动词%#v,使切片的打印方式与 Go 代码中的一样),我们得到的返回值,以及我们期望的返回值。

image

如果我们重新运行测试,我们将看到确切的失败原因。

测试“辅助”函数

您并不局限于在_test.go文件中只有测试函数。通过将重复代码移动到测试文件中的其他“辅助”函数中,您可以减少测试中的重复代码。go test命令仅使用名称以Test开头的函数,因此只要您将函数命名为其他名称,就可以正常工作。

在我们的TestTwoElementsTestThreeElements函数之间存在相当繁琐的对t.Errorf的调用(随着我们添加更多测试,可能会出现更多重复)。一种解决方法是将字符串生成移至一个单独的errorString函数,测试可以调用它。

我们将让errorString接受传递给JoinWithCommas的切片,got值和want值。然后,不再在testing.T值上调用Errorf,而是让errorString调用fmt.Sprintf为我们生成一个(相同的)错误字符串来返回。测试本身然后可以使用返回的字符串调用Error来指示测试失败。这段代码稍微更清晰,但仍然能得到相同的输出。

image

使测试通过

现在我们的测试设置了有用的失败消息,是时候看看如何用它们来修复我们的主要代码了。

我们有两个关于JoinWithCommas函数的测试。通过包含三个项的切片的测试通过了,但通过包含两个项的切片的测试失败了。

这是因为JoinWithCommas当前在返回仅有两个项目的列表时仍然包括一个逗号。

image

让我们修改JoinWithCommas来解决这个问题。如果字符串切片中只有两个元素,我们将简单地用" and "将它们连接在一起,然后返回结果字符串。否则,我们将按照我们一直遵循的逻辑进行操作。

image

我们已经更新了我们的代码,但它是否工作正常?我们的测试可以立即告诉我们!如果我们现在重新运行我们的测试,TestTwoElements将通过,意味着所有测试都通过了。

image

我们可以确信JoinWithCommas现在能够处理两个字符串的切片,因为相应的单元测试现在通过了。而我们不需要担心它是否仍然正确处理三个字符串的切片;我们有一个单元测试向我们保证这也没问题。

这也反映在我们的join程序的输出中。如果我们现在重新运行它,我们将看到两个切片都被正确格式化了!

image

测试驱动开发

一旦你有了一些单元测试的经验,你可能会陷入一种被称为测试驱动开发的循环中:

  1. 编写测试: 你为你希望的功能编写一个测试,即使它还不存在。然后你运行测试以确保它失败

  2. 使其通过: 你在主代码中实现了这个功能。不用担心你写的代码是笨拙还是低效;你的唯一目标是让它工作。然后你运行测试以确保它通过

  3. 重构你的代码: 现在,你可以自由地重构代码,随意改进它。你已经看到测试失败,所以你知道如果你的应用代码出错,它将再次失败。你已经看到测试通过,所以只要你的代码工作正常,它将继续通过。

这种自由更改你的代码而不必担心它会出错,这才是你想要单元测试的真正原因。每当你看到一种使你的代码更简短或更易读的方法时,你都会毫不犹豫地去做。当你完成时,只需再次运行你的测试,你就可以确信一切仍然正常运行。

image 写测试!

image 让它通过!

image 重构你的代码!

另一个需要修复的错误

可能 JoinWithCommas 会被调用时传递一个仅包含单个短语的切片。但在这种情况下,它的表现并不是很好,将该项视为列表末尾出现的情况:

image

在这种情况下,JoinWithCommas 应该返回什么?如果我们有一个只有一个项目的列表,我们实际上不需要逗号、单词and或任何其他东西。我们可以简单地返回一个包含该项目的字符串。

image

让我们在 join_test.go 中表达这个作为一个新的测试。我们将在现有的 TestTwoElementsTestThreeElements 测试旁边添加一个新的测试函数叫做 TestOneElement。我们的新测试看起来与其他测试类似,但我们将传递一个仅包含一个字符串的切片到 JoinWithCommas,并期望返回一个包含该字符串的返回值。

image

正如你所预料的,知道我们的代码中有一个 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
       }
}

修复了我们的代码后,如果我们重新运行测试,我们将看到一切都通过了。

image

当我们在代码中使用 JoinWithCommas 时,它将表现得像它应该的那样。

image

没有蠢问题

Q: 这些测试代码会不会使我的程序变得更大和更慢?

A: 别担心!正如 go test 命令已经被设置为只能处理文件名以 _test.go 结尾的文件一样,go 工具中的其他命令(如 go buildgo install)也被设置为忽略_test.go 结尾的文件。go 工具可以将您的程序代码编译成可执行文件,但它将忽略您的测试代码,即使它们保存在同一个包目录中。

代码磁铁

糟糕!我们创建了一个 compare 包,其中包含一个 Larger 函数,该函数应返回传入的两个整数中较大的那个。但是我们的比较出错了,Larger 返回的是较小的整数!

image

我们已经开始编写测试来帮助诊断问题。您能重构代码片段以创建能生成所示输出的工作测试吗?您需要创建一个返回测试失败消息的辅助函数,然后在测试中添加两个对该辅助函数的调用。

image

image 答案在 “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,那么 TestTwoElementsTestThreeElements 都会被运行。(但 TestOneElement 不会被运行,因为它的名字结尾没有 s。)

图片

表驱动测试

在我们的三个测试函数之间存在相当多的重复代码。实际上,测试之间唯一变化的是我们传递给 JoinWithCommas 的切片和我们期望它返回的字符串。

图片

我们可以建立一个“表格”,列出输入数据和我们期望的输出,然后使用一个单独的测试函数来检查表格中的每个条目,而不是维护单独的测试函数。

表格的格式没有标准,但一个常见的解决方案是定义一个新类型,专门用于你的测试,该类型包含每个测试中要传递给 JoinWithCommas 的字符串切片的 list 字段,以及我们期望它返回的相应字符串的 want 字段。这里是我们可能使用的 testData 类型。

图片

我们可以在将要使用的 lists_test.go 文件中直接定义 testData 类型。

我们的三个测试函数可以合并成一个单独的 TestJoinWithCommas 函数。在顶部,我们设置一个 tests 切片,并将旧的 TestOneElementTestTwoElementsTestThreeElements 中的 listwant 变量值移动到 tests 切片中的 testData 值中。

然后,我们循环遍历切片中的每个testData值。我们将list切片传递给JoinWithCommas,并将其返回的字符串存储在got变量中。如果got不等于testData值的want字段中的字符串,则调用Errorf并用它来格式化测试失败消息,就像我们在errorString辅助函数中所做的一样。(既然这使得errorString函数多余,我们可以将其删除。)

image

这个更新后的代码更短,重复性更少,但表格中的测试与它们分开时一样通过!

image

使用测试修复恐慌代码

不过,表驱动测试最好的一点是,需要时很容易添加新的测试。假设我们不确定当JoinWithCommas接收到空切片时会发生什么。要找出答案,我们只需在tests切片中添加一个新的testData结构。我们将指定,如果将空切片传递给JoinWithCommas,则应返回一个空字符串:

image

看来我们担心是正确的。如果运行测试,它将以堆栈跟踪恐慌:

image

显然,某些代码尝试访问一个超出切片范围的索引(它尝试访问一个不存在的元素)。

image

查看堆栈跟踪,我们看到恐慌发生在lists.go文件的第 11 行,在JoinWithCommas函数内部:

image

因此,恐慌发生在lists.go文件的第 11 行…… 那是我们访问切片中除最后一个元素外的所有元素,并用逗号将它们连接在一起的地方。但由于我们传入的phrases切片为空,根本没有要访问的元素。

image

如果phrases片段为空,我们确实不应尝试从中访问任何元素。没有什么可以加入的,因此我们只需返回一个空字符串。让我们在if语句中添加另一个子句,当len(phrases)0时返回空字符串。

image

之后,如果再次运行测试,一切都通过了,甚至调用带有空切片的JoinWithCommas的测试也通过了!

image

或许您可以想象对JoinWithCommas想要进行的进一步更改和改进。请继续!您可以毫无顾虑地这样做。如果在每次更改后运行测试,您就可以确切地知道一切是否按预期工作。(如果没有,您将清楚地知道需要修复什么!)

您的 Go 工具箱

image

这就是第十四章的内容!您已经将测试添加到了您的工具箱中。

image

代码磁铁解决方案

image

第十五章:响应请求:Web 应用程序

image

这是 21 世纪。用户需要 Web 应用程序。 Go 在这方面有所覆盖!Go 标准库包含的包可以帮助您托管自己的 Web 应用程序,并使它们可以从任何 Web 浏览器访问。因此,我们将在本书的最后两章中向您展示如何构建 Web 应用程序。

您的 Web 应用程序需要的第一件事是在浏览器发送请求时能够做出响应。在本章中,我们将学习使用net/http包来实现这一点。

使用 Go 编写 Web 应用程序

在终端上运行的应用程序对于个人使用非常棒。但普通用户已经被互联网和万维网宠坏了。他们不想为了使用你的应用程序而学习使用终端。他们甚至不想安装你的应用程序。他们希望在他们点击浏览器中的链接时立即可以使用它。

但别担心!Go 也可以帮助您编写 Web 应用程序。

image

我们不会误导您——编写 Web 应用程序并不是一件小事。这将需要您迄今为止学到的所有技能,再加上一些新技能。但 Go 有一些出色的可用包,将使这个过程更容易!

这包括net/http包。HTTP 代表“HyperText Transfer Protocol”,它用于 Web 浏览器和 Web 服务器之间的通信。使用net/http,您将能够使用 Go 创建自己的 Web 应用程序!

浏览器、请求、服务器和响应

当您在浏览器中输入 URL 时,实际上是发送了对 Web 页面的请求。该请求发送到一个服务器。服务器的工作是获取适当的页面并将其发送回浏览器作为响应

在 Web 的早期阶段,服务器通常会读取服务器硬盘上 HTML 文件的内容,并将该 HTML 返回给浏览器。

image

但今天,服务器通常与一个程序通信来满足请求,而不是从文件中读取。这个程序可以用几乎任何您想要的语言编写,包括 Go!

image

一个简单的 Web 应用程序

处理来自浏览器的请求是一项大量的工作。幸运的是,我们不必自己处理所有工作。回到第十三章,我们使用net/http包向服务器发出请求。net/http包还包括一个小型的 Web 服务器,因此它也能够响应请求。我们唯一需要做的就是编写代码,填充这些响应数据。

这里有一个使用net/http来向浏览器提供简单响应的程序。虽然程序很简短,但其中有很多新东西。我们将首先运行程序,然后逐步解释它。

image

将上述代码保存到任意文件中,并使用go run命令在终端中运行:

image

我们正在运行我们自己的 Web 应用程序!现在我们只需要连接一个 Web 浏览器并测试它。打开你的浏览器,将这个 URL 键入地址栏。(如果这个 URL 看起来有点奇怪,不要担心;我们马上解释它的含义。)

http://localhost:8080/hello

浏览器将向应用程序发送请求,应用程序将回复“Hello, web!”。我们刚刚向浏览器发送了第一个响应!

应用程序会继续监听请求,直到我们停止它。当您完成页面时,在终端中按下 Ctrl-C 以向程序发出退出信号。

image

您的计算机正在自我通信

当我们启动我们的小型 Web 应用程序时,它会在您的计算机上启动自己的 Web 服务器。

image

因为该应用程序正在您的计算机上运行(而不是在互联网上的某个地方),所以我们在 URL 中使用特殊的主机名localhost。这告诉您的浏览器需要从您的计算机建立连接同一台计算机。

image

我们还需要在 URL 中指定一个端口。(端口是应用程序可以监听消息的编号网络通信通道。)在我们的代码中,我们指定服务器应在端口 8080 上监听,所以我们在 URL 中包含它,跟在主机名后面。

image

没有愚蠢的问题

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

image

然后,我们调用http.ListenAndServe,启动 Web 服务器。我们将字符串"localhost:8080"传递给它,这将使它仅接受来自本机端口 8080 的请求。(当您准备向其他计算机的请求开放应用程序时,可以改用字符串"0.0.0.0:8080"。您也可以将端口号更改为其他值。)第二个参数中的nil值仅表示将使用通过HandleFunc设置的函数来处理请求。

注意

(稍后,如果您想了解替代处理请求的其他方法,请查看“http”包中“ListenAndServe”函数、“Handler”接口和“ServeMux”类型的文档。)

我们在调用HandleFunc之后调用ListenAndServe,因为ListenAndServe会持续运行,除非遇到错误。如果有错误,它将返回该错误,我们在程序退出之前将其记录。但是,如果没有错误,此程序将继续运行,直到我们在终端中按下 Ctrl-C 来中断它。

image

main相比,在viewHandler函数中没有什么特别出乎意料的地方。服务器将viewHandler传递给http.ResponseWriter,用于向浏览器响应写入数据,并传递给http.Request值的指针,表示浏览器的请求。(在此程序中,我们不使用Request值,但处理程序函数仍然必须接受它。)

image

viewHandler内部,我们通过在ResponseWriter上调用Write方法向响应添加数据。Write方法不接受字符串,但它接受byte值的切片,因此我们将字符串"Hello, web!"转换为[]byte,然后传递给Write方法。

image

您可能还记得byte值来自第十三章。当在通过http.Get函数检索到的响应上调用ioutil.Readall函数时,该函数返回byte值的切片。

image

正如我们在第十三章中看到的,[]byte可以转换为string

image

正如您刚刚在这个简单的 Web 应用程序中看到的那样,string可以转换为[]byte

image

ResponseWriterWrite方法返回成功写入的字节数以及遇到的任何错误。我们无法使用写入的字节数做任何有用的事情,因此我们忽略它。但是,如果有错误,我们会将其记录并退出程序。

_, 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 变量中的函数被调用。

image

将函数传递给其他函数

具有一等公民函数的编程语言允许您将函数作为参数传递给其他函数。此代码定义了简单的 sayHisayBye 函数。它还定义了一个 twice 函数,该函数将另一个名为 theFunction 的函数作为参数。然后 twice 函数调用存储在 theFunction 中的任何函数两次。

main 中,我们调用 twice 并将 sayHi 函数作为参数传递,导致 sayHi 被运行两次。然后我们用 sayBye 函数再次调用 twice,导致 sayBye 被运行两次。

image

函数作为类型

当我们试图将 sayHi 函数作为参数传递给 http.HandleFunc 时,我们会得到编译错误:

image

函数的参数和返回值是其类型的一部分。持有函数的变量需要指定该函数应具有的参数和返回值。该变量只能持有参数和返回值数量与类型匹配的函数。

此代码定义了一个类型为 func()greeterFunction 变量:它持有一个不接受参数并且不返回值的函数。然后,我们定义了一个类型为 func(int, int) float64mathFunction 变量:它持有一个接受两个整数参数并返回一个 float64 值的函数。

代码还定义了 sayHidivide 函数。如果我们将 sayHi 分配给 greeterFunction 变量,将 divide 分配给 mathFunction 变量,一切都能编译和正常运行:

image

但是如果我们尝试颠倒两者,将再次得到编译错误:

image

divide 函数接受两个 int 参数并返回一个 float64 值,因此无法存储在 greeterFunction 变量中(该变量期望不接受参数并且不返回值的函数)。而 sayHi 函数不接受参数并且不返回值,因此无法存储在 mathFunction 变量中(该变量期望接受两个 int 参数并返回一个 float64 值的函数)。

接受函数作为参数的函数也需要指定传入函数应具有的参数和返回类型。

这是一个具有 passedFunction 参数的 doMath 函数。传入的函数需要接受两个 int 参数,并返回一个 float64 值。

我们还定义了dividemultiply函数,两者都接受两个int参数并返回一个float64dividemultiply都可以成功传递给doMath

image

一个不符合指定类型的函数无法传递给doMath

image

这就是为什么如果我们向http.HandleFunc传递错误的函数,我们会得到编译错误。HandleFunc期望传递一个接受ResponseWriterRequest指针作为参数的函数。如果传递其他内容,你将得到编译错误。

事实上,这是件好事。一个无法分析请求并写入响应的函数可能无法处理浏览器请求。如果尝试传递类型错误的函数,Go 将在程序编译之前提醒你问题。

image

池谜题

image

你的工作是从池中获取代码片段,并将它们放入此代码中的空白行。不要重复使用相同的片段,而且你不需要使用所有的片段。你的目标是创建一个能运行并产生所示输出的程序。

image

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)
}

注意:每个池中的片段只能使用一次!

image

image 答案在“Pool Puzzle Solution”中。

接下来是什么

现在你知道如何从浏览器接收请求并发送响应了。最棘手的部分已经完成!

image

在最后一章,我们将利用这些知识构建一个更复杂的应用程序。

到目前为止,我们所有的响应都是使用纯文本。我们将学习如何使用 HTML 来为页面提供更多结构。并且我们将学习如何使用html/template包在将数据插入 HTML 后将其发送回浏览器。到那里见!

你的 Go 工具箱

image

这就是第十五章的全部内容!你已经向你的工具箱中添加了 HTTP 处理程序函数和一流函数。

image

池谜题解决方案

image

第十六章:一个遵循的模式:HTML 模板

image

您的 Web 应用程序需要用 HTML 来响应,而不是纯文本。 纯文本对于电子邮件和社交媒体帖子来说很好。但您的页面需要格式。它们需要标题和段落。它们需要表单,用户可以向您的应用程序提交数据。为了做到这些,您需要 HTML 代码。

最终,您将需要将数据插入到 HTML 代码中。这就是为什么 Go 提供了html/template包的原因,这是一种强大的方式,可以将数据包含到您应用程序的 HTML 响应中。模板是构建更大、更好的 Web 应用程序的关键,在这最后一章中,我们将向您展示如何使用它们!

一个留言板应用程序

让我们把我们在第十五章中学到的东西用起来。我们将为网站构建一个简单的留言板应用程序。您的访客将能够在表单中输入消息,并将其保存到文件中。他们还可以查看以前所有签名的列表。

image

在我们能让这个应用程序正常工作之前,还有很多内容需要覆盖,但不要担心——我们将把这个过程分解成小步骤。让我们看看将涉及哪些内容……

我们需要设置我们的应用程序,并使其响应主留言板页面的请求。这部分不会太难;我们已经在前一章中覆盖了所有需要了解的内容。

然后,我们需要在响应中包含 HTML。我们将创建一个简单的页面,只使用几个 HTML 标签,并将其存储在文件中。然后我们将从文件中加载 HTML 代码,并在我们应用程序的响应中使用它。

我们需要获取访客输入的签名,并将它们合并到 HTML 中。我们将向您展示如何使用html/template包来完成这一操作。

然后,我们需要创建一个单独的页面,用于添加签名的表单。我们可以使用 HTML 相当容易地完成这个任务。

最后,当用户提交表单时,我们需要将表单内容保存为新的签名。我们将其保存到文本文件中,并与所有其他提交的签名一起加载回来。

image

处理请求和检查错误的函数

我们的第一个任务将是显示主留言板页面。通过编写示例 Web 应用程序的实践,这应该不会太难。在我们的main函数中,我们将调用http.HandleFunc并设置应用程序,以便为路径为"/guestbook"的任何请求调用名为viewHandler的函数。然后,我们将调用http.ListenAndServe来启动服务器。

目前,viewHandler函数看起来与我们之前示例中的处理程序函数完全相同。它接受一个http.ResponseWriter和一个http.Request的指针,就像之前的处理程序一样。我们将一个字符串转换为[]byte,并使用ResponseWriterWrite方法将其添加到响应中。

check函数是此代码中唯一真正新的部分。在这个 Web 应用程序中,我们可能会有很多潜在的error返回值,并且我们不想在每个地方重复代码来检查和报告它们。因此,我们将每个错误传递给我们的新check函数。如果error为 nil,则check什么也不做,但否则它会记录错误并退出程序。

image

ResponseWriter上调用Write可能会返回错误,所以我们将error返回值传递给check。注意,我们不会error返回值从http.ListenAndServe传递给check。这是因为ListenAndServe总是返回一个错误。(如果没有错误,ListenAndServe永远不会返回。)由于我们知道这个错误永远不会是nil,我们只是立即在其上调用log.Fatal

设置项目目录并尝试应用程序

对于这个项目,我们将创建几个文件,因此您可能希望花一点时间创建一个新目录来保存它们。(它不必在您的 Go 工作区目录内。)将前述代码保存在此目录中,文件名为guestbook.go

image

让我们试着运行它。在您的终端中,切换到保存guestbook.go的目录,并使用go run运行它。

image

然后在浏览器中访问此 URL:

*[localhost:8080/guestbook](http://localhost:8080/guestbook)*

与先前应用程序的 URL 相同,只是在末尾加上了/guestbook路径。您的浏览器将向应用程序发出请求,应用程序将以我们的占位文本作出响应:

image

我们的应用现在正在响应请求。我们的第一个任务完成了!

image

虽然我们只是使用纯文本进行响应。接下来,我们将使用 HTML 格式化我们的响应。

在 HTML 中创建签名列表

到目前为止,我们只是向浏览器发送了一些文本片段。我们需要实际的 HTML,以便对页面应用格式。HTML 使用标签对文本应用格式。

如果您以前没有编写 HTML,不用担心;随着我们的进展,我们将涵盖基础知识!

在与guestbook.go相同的目录中,将下面的 HTML 代码保存在名为view.html的文件中。

这个文件中使用的 HTML 元素如下:

  • <h1>:一级标题。通常显示为大号加粗文本。

  • <div>:分割元素。单独使用时不直接可见,但用于将页面分割为各个部分。

  • <p>:段落文本。我们将每个签名视为独立的段落。

  • <a>:代表“锚点”。创建一个链接。

image

现在,让我们尝试在浏览器中查看 HTML。启动您喜欢的网络浏览器,从菜单中选择“打开文件…”,并打开您刚刚保存的 HTML 文件。

image

注意页面上的元素如何与 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:

localhost:8080/guestbook

您应该看到来自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" 模板字符串显示为输出。

image

使用 io.Writer 接口和模板的 Execute 方法

image

os.Stdout 值是 os 包的一部分。Stdout 代表“标准输出”。它的行为类似于文件,但将写入到它的任何数据都输出到终端,而不是保存到磁盘。(像 fmt.Printlnfmt.Printf 等函数在后台写入数据到 os.Stdout。)

http.ResponseWriteros.Stdout 如何都能作为 Template.Execute 的有效参数?让我们查看它的文档...

image

哦,这里说 Execute 的第一个参数应该是一个 io.Writer。那是什么?让我们查一下 io 包的文档:

image

看起来 io.Writer 是一个接口!它可以由任何具有接受 byte 值切片并返回写入的字节数和一个 error 值的 Write 方法满足。

ResponseWritersos.Stdout 都满足 io.Writer 接口。

我们已经看到 http.ResponseWriter 值有一个 Write 方法。我们在几个早期的示例中使用了 Write

image

原来 os.Stdout 值也有一个 Write 方法!如果你向它传递一个 byte 值的切片,这些数据将被写入终端:

image

这意味着 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}}标记之间的模板部分仅在条件为真时才会包含。在此示例中,我们执行相同的模板文本两次,一次是在dottrue时,一次是在dotfalse时。由于{{if}}动作的存在,只有在dottrue时,“Dot is true!”文本才会包含在输出中。

图片

使用“range”动作重复模板的部分内容

{{range}}动作及其对应的{{end}}标记之间的模板部分将根据数组、切片、映射或通道中收集的每个值重复。该部分内的任何动作也将被重复。

在重复的部分内,dot 的值将设置为集合中的当前元素,允许您将每个元素包含在输出中或对其进行其他处理。

这个模板包括一个{{range}}动作,将会输出切片中的每个元素。循环之前和之后,dot 的值将是切片本身。但是在循环内部,dot 指的是切片的当前元素。你会在输出中看到这一点。

image

这个模板处理一个float64值的切片,它会将其显示为价格列表。

image

如果提供给{{range}}动作的值为空或nil,则循环将不会运行:

image

使用动作将结构体字段插入模板

当执行模板时,简单类型通常无法保存填充模板所需的各种信息。在这种情况下,使用结构体类型更为常见。

如果 dot 中的值是一个结构体,那么接下来一个带有 dot 和字段名的动作将在模板中插入该字段的值。在这里,我们创建了一个Part结构体类型,然后设置了一个模板,该模板将输出Part值的NameCount字段:

image

最后,在下面我们声明了一个Subscriber结构体类型和一个打印它们的模板。该模板将无论如何输出Name字段,但是它使用{{if}}动作仅在Active字段设置为true时才输出Rate字段。

image

在这里,你可以使用模板做很多其他事情,我们这里没有足够的空间来覆盖它们所有。要了解更多,请查阅text/template包的文档:

image

从文件中读取签名切片

现在我们知道如何将数据插入模板,我们几乎可以将签名插入到访客留言板页面中了。但是首先,我们需要一些可以插入的签名。

在你的项目目录中,保存几行文本到一个名为signatures.txt的纯文本文件中。这些暂时将作为我们的“签名”。

现在我们需要能够将这些签名加载到我们的应用程序中。在guestbook.go中,添加一个新的getStrings函数。这个函数的工作方式类似于我们在第七章中编写的datafile.GetStrings函数,它会读取文件并将每一行追加到一个字符串切片中,然后返回这个切片。

image

但是也有一些区别。首先,新的getStrings将依赖我们的check函数来报告错误,而不是直接返回它们。

第二,如果文件不存在,getStrings将会返回nil而不是报告错误,而是通过将从os.Open获取的任何error值传递给os.IsNotExist函数来做到这一点,如果错误指示文件不存在,则该函数将返回true

image

我们还将对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 占位符替换为插入 GuestbookSignatureCount 字段的操作:{{.SignatureCount}}

第二个 div 元素包含一系列 p(段落)元素,每个签名对应一个。使用 range 操作循环遍历 Signatures 切片中的每个签名:{{range .Signatures}}。(不要忘记在 div 元素结束之前加上对应的 {{end}} 标记。)在 range 操作中,包含一个 p HTML 元素,并在其中输出点号的嵌套内容:<p>{{.}}</p>。请记住,点号会依次设置为切片中的每个元素,因此这将导致为切片中的每个签名输出一个 p 元素,其内容设置为该签名的文本。

image

最后,我们可以使用包含数据的模板进行测试!重新启动 guestbook.go 应用程序,并再次在浏览器中访问 localhost:8080/guestbook。响应应该显示您的模板。顶部应显示总签名数,并且每个签名应出现在其自己的 <p> 元素中!

image

没有愚蠢的问题

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 的字符替换为导致它显示在页面文本中的代码(这样是安全的)。以下是实际插入响应的内容:

&lt;script&gt;alert(&#34;hi!&#34;);&lt;/script&gt;

像这样插入脚本标签只是不良用户可以将恶意代码插入到您的网页中的众多方式之一。html/template 包使得防范这种以及许多其他攻击变得简单!

让用户使用 HTML 表单添加数据

又完成了另一个任务。我们接近尾声:只剩下两个任务!

接下来,我们需要允许访客添加他们自己的签名。我们需要创建一个 HTML form,让他们可以输入签名。表单通常提供一个或多个用户可以输入数据的字段,并提供一个提交按钮,让他们可以将数据发送到服务器。

image

在项目目录中,创建一个名为 new.html 的文件,并包含以下 HTML 代码。这里有一些我们以前没有见过的标签:

  • <form>: 此元素包含所有其他表单组件。

  • <input>type 属性为 "text": 用户可以输入字符串的文本字段。它的 name 属性将用于标记发送到服务器数据中的字段值(类似于映射键)。

  • <input>type 属性为 "submit": 创建一个用户可以点击以提交表单数据的按钮。

image

如果我们在浏览器中加载此 HTML,它将如下所示:

image

响应 HTML 表单

我们在 view.html 中已经有一个指向 /guestbook/new 路径的“添加您的签名”链接。单击此链接将带您到同一服务器上的新路径,所以这就像在输入此 URL 一样:

localhost:8080/guestbook/new

image

但是当前访问此路径只会响应错误“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 的请求的处理函数。

image

如果我们保存上述代码并重新启动 guestbook.go,然后点击“添加您的签名”链接,我们将被带到 /guestbook/new 路径。将调用 newHandler 函数,该函数将从 new.html 加载我们的表单 HTML 并包含在响应中。

image

表单提交请求

我们又完成了另一个任务。就剩一个了!

image

当有人访问/guestbook/new路径时,无论是直接输入还是点击链接,我们都会显示一个用于输入签名的表单。但如果你填写该表单并点击提交,将不会发生任何有用的事情。

image

浏览器将会再次请求/guestbook/new路径。"signature"表单字段的内容将作为一个看起来不好看的参数添加到 URL 的末尾。因为我们的newHandler函数不知道如何处理表单数据,所以它将被简单丢弃。

image

我们的应用可以响应请求以显示表单,但没有办法将表单数据提交回应用程序。在我们能保存访客签名之前,我们需要解决这个问题。

表单提交的路径和 HTTP 方法

实际上,提交表单需要向服务器发送两个请求:一个用于获取表单,另一个用于发送用户的输入数据回服务器。让我们更新表单的 HTML 以指定第二个请求应该发送到何处以及如何发送。

编辑new.html,并向form元素添加两个新的 HTML 属性。第一个属性action将指定提交请求的路径。我们不会让路径默认回到/guestbook/new,而是指定一个新路径:/guestbook/create

我们还需要第二个名为method的属性,其值应为"POST"

image

需要对这个method属性进行一点解释... HTTP 定义了几种请求可以使用的方法。虽然这些不同于 Go 值上的方法,但意义类似。GET 和 POST 是最常见的方法之一。

  • GET:当您的浏览器需要从服务器获取某些内容时使用,通常是因为您输入了一个 URL 或点击了一个链接。这可以是 HTML 页面、图像或其他资源。

  • POST:当您的浏览器需要向服务器添加一些数据时使用,通常是因为您提交了带有新数据的表单。

我们正在向服务器添加新数据:一个新的访客留言签名。所以看起来我们应该使用 POST 请求提交数据。

尽管如此,默认情况下表单使用 GET 请求提交。这就是为什么我们需要向form元素添加一个值为"POST"method属性的原因。

现在,如果我们重新加载/guestbook/new页面并重新提交表单,请求将使用路径/guestbook/create。我们会得到一个“404 页面找不到”错误,但这是因为我们还没有为/guestbook/create路径设置处理程序。

image

表单数据现在不再附加在 URL 的末尾。这是因为表单是通过 POST 请求提交的。

image

从请求中获取表单字段的值

现在我们正在使用 POST 请求提交表单,表单数据嵌入在请求本身中,而不是作为参数附加到请求路径中。

让我们解决一下当表单数据提交到/guestbook/create路径时出现的“404 页面未找到”错误。在此过程中,我们还将看到如何从 POST 请求中访问表单数据。

像往常一样,我们将通过添加请求处理函数来完成这项工作。在guestbook.gomain函数中,调用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函数内部处理这个问题。

首先,我们将去掉对ResponseWriterWrite方法的调用;我们只需要确认我们可以访问签名表单字段。

现在,让我们添加下面的代码。os.OpenFile 函数以略有不同的方式调用,细节与编写 Web 应用程序无直接关系,因此我们不会在这里完全描述它。(如果您想了解更多信息,请参见 附录 A。)现在,您需要知道的是,此代码执行三个基本操作:

  1. 它打开了 signatures.txt 文件,如果文件不存在则创建它。

  2. 它在文件末尾添加一行文本。

  3. 它关闭文件。

image

fmt.Fprintln 函数向文件添加一行文本。它接受要写入的文件和要写入的字符串(无需转换为 []byte)作为参数。就像我们在本章前面看到的 Write 方法一样,Fprintln 返回成功写入文件的字节数(我们忽略),以及遇到的任何错误(我们传递给 check 函数)。

最后,我们在文件上调用 Close 方法。你可能注意到我们没有使用 defer 关键字。这是因为我们正在向文件写入,而不是从中读取。在写入文件后调用 Close 可能会导致错误,我们需要处理这些错误,如果使用 defer 就无法很容易地做到这一点。因此,我们简单地在常规程序流程中调用 Close,然后将其返回值传递给 check

保存前面的代码并重新启动 guestbook.go。在 /guestbook/go 页面上填写并提交表单。

image

现在你的浏览器会加载 /guestbook/create 路径,这个路径现在显示为完全空白(因为 createHandler 不再向 http.ResponseWriter 写入任何内容)。

image

但是,如果你查看 signatures.txt 文件的内容,你会看到新的签名保存在末尾!

image

如果你访问 /guestbook 上的签名列表,你会看到签名数增加了一条,并且新的签名出现在列表中!

image

HTTP 重定向

我们的 createHandler 函数保存新的签名。还有一件事需要处理。当用户提交表单时,他们的浏览器会加载 /guestbook/create 路径,显示一个空白页面。

image

/guestbook/create 路径上没有有用的内容可供展示;它只是用来接受添加新签名请求的。相反,让我们让浏览器加载 /guestbook 路径,这样用户就可以在访客留言板中看到他们的新签名。

createHandler 函数的结尾,我们将添加一个调用 http.Redirect,它向浏览器发送一个响应,指示其加载与请求的资源不同的资源。Redirect 的前两个参数是 http.ResponseWriter*http.Request,因此我们将从 createHandlerwriterrequest 参数中获取它们的值。然后 Redirect 需要一个字符串,指定将浏览器重定向到的路径;我们将重定向到 "/guestbook"

Redirect的最后一个参数需要是一个状态码,以便向浏览器发送。每个 HTTP 响应都需要包含一个状态码。到目前为止,我们的响应已经自动设置了它们的代码:成功的响应代码为 200(“OK”),对不存在页面的请求代码为 404(“Not found”)。不过,对于Redirect,我们需要指定一个代码,因此我们将使用常量http.StatusFound,这将导致重定向响应的状态为 302(“Found”)。

image

现在我们已经添加了Redirect的调用,提交签名表单应该像这样工作:

  1. 浏览器向/guestbook/create路径提交了一个 HTTP POST 请求。

  2. 应用程序响应并重定向到/guestbook

  3. 浏览器发送了一个 GET 请求,用于/guestbook路径。

让我们试试所有功能!

让我们看看重定向是否有效!重新启动guestbook.go,并访问/guestbook/new路径。填写表单并提交。

image

应用程序将表单内容保存到signatures.txt,然后立即将浏览器重定向到/guestbook路径。当浏览器请求/guestbook时,应用程序将加载更新的signatures.txt文件,并且用户将在列表中看到他们的新签名!

image

我们的应用程序正在保存从表单提交的签名,并与所有其他签名一起显示。我们的所有功能都已完成。

需要很多组件才能使所有这些工作正常运行,但现在您拥有了一个可用的 Web 应用程序!

image

我们的完整应用程序代码

我们的应用程序代码已经变得如此之长,我们只能逐步查看它。让我们再花一点时间将所有代码放在一起看看吧!

guestbook.go文件占据了应用程序代码的大部分。(在一个旨在广泛使用的应用程序中,我们可能已将一些此代码拆分为多个包和源文件,位于我们的 Go 工作区目录中,如果您愿意,您也可以这样做。)我们已经浏览并添加了对Guestbook类型和每个函数的注释。

imageimage

view.html文件为签名列表提供 HTML 模板。模板操作提供了插入签名数量以及整个签名列表的位置。

image

new.html文件只是包含用于新签名的 HTML 表单。不会向其中插入任何数据,因此不存在模板操作。

image

就是这样——一个完整的 Web 应用程序,可以存储用户提交的签名,并稍后再次检索它们!

编写 Web 应用程序可能很复杂,但net/httphtml/template包利用 Go 的力量使整个过程对您来说更加简单!

image

您的 Go 工具箱

image

这就是第十六章的全部内容!您已经将模板添加到了您的工具箱中。

image

第十七章:恭喜!你成功地到达了结尾。

image

当然,还有两个附录。

还有索引。

然后还有这个网站...

实际上,没有逃避的余地。

第十八章:这不是告别

**带上你的大脑

前往headfirstgo.com***

image

附录 A. 理解 os.openfile:打开文件

image

  • 有些程序需要向文件写入数据,而不仅仅是读取数据。 在本书中,当我们想要处理文件时,你必须在文本编辑器中创建它们以供你的程序读取。但有些程序会生成数据,当它们这样做时,它们需要能够写入文件。

本书前面曾使用 os.OpenFile 函数来打开文件进行写入。但当时我们没有足够的空间来详细探讨它的工作原理。在这个附录中,我们将展示你在使用 os.OpenFile 时需要了解的一切!

理解 os.OpenFile

在第十六章中,我们必须使用 os.OpenFile 函数来打开一个文件进行写入,这要求一些看起来相当奇怪的代码:

image

当时,我们专注于编写 web 应用,所以我们没有太多时间来充分解释 os.OpenFile。但你在编写 Go 代码时几乎肯定会再次用到这个函数,因此我们添加了这个附录来更仔细地研究它。

当你试图弄清楚一个函数如何工作时,最好从它的文档开始。在你的终端中运行 go doc os OpenFile(或者在浏览器中搜索 "os" 包的文档)。

image

它的参数是一个 string 文件名,一个 int “flag”,以及一个 os.FileMode “perm”。很明显,文件名只是我们要打开的文件的名称。让我们先弄清楚这个“flag”意味着什么,然后再回头看 os.FileMode

为了帮助保持附录中的代码示例简短,假设我们的所有程序都包含一个 check 函数,就像我们在第十六章中展示的那样。它接受一个 error 值,检查是否为 nil,如果不是,则报告错误并退出程序。

image

os.OpenFile 传递 flag 常量

描述中提到,flag 的一个可能值是 os.O_RDONLY。让我们查一下它的含义是什么...

image

根据文档,os.O_RDONLY 是几个 int 常量之一,用于传递给 os.OpenFile 函数,这些常量会改变函数的行为。

让我们尝试使用一些这些常量调用 os.OpenFile,看看会发生什么。

首先,我们需要一个要处理的文件。创建一个只有一行文本的纯文本文件。你可以将其保存在任何目录下,命名为 aardvark.txt

image

然后,在相同的目录中,创建一个包含前一页的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_APPENDos.O_CREATE“可以进行或操作”。这是指二进制或运算符。我们需要花几页的时间来解释它是如何工作的……

二进制表示法

在最低级别上,计算机必须使用简单的开关来表示信息,这些开关可以是开或关。如果使用一个开关来表示一个数字,你只能表示值0(开关“关闭”)或1(开关“打开”)。计算机科学家称之为比特

如果将多个位组合起来,就能表示更大的数。这就是二进制表示法的思想。在日常生活中,我们最熟悉的是十进制表示法,它使用数字 0 到 9。但是二进制表示法只使用数字 0 和 1 来表示数值。

注意

(如果你想了解更多,请在你喜爱的网络搜索引擎中输入“二进制”。)

你可以使用fmt.Printf%b格式化动词查看各种数值的二进制表示(这些数值由位组成):

图片

位运算符

我们已经看到了+-*/等运算符,它们允许你对整数进行数学运算。但 Go 语言还有按位运算符,允许你操作一个数字由哪些位组成。其中两个最常见的是按位与运算符&和按位或运算符|

运算符 名称
& 按位与
` `

按位与运算符

我们已经看到了&&运算符。它是一个布尔运算符,只有在其左边和右边的值都为true时才返回true

image

然而,&运算符(只有一个和号)是一个按位运算符。它仅在其左侧值和右侧值的对应位都为1时才将该位设置为1。对于只需一个位表示的数字01来说,这是相当直接的:

image

然而,对于更大的数字,这可能看起来像是胡言乱语!

image

只有当你查看单个位的值时,按位操作才有意义。&运算符仅在左侧数字和右侧数字中相同位置的位都为1时,结果中的位才设置为1

image

对于任意大小的数字都是如此。用于&运算符的两个值的位决定了结果值中相同位置的位。

image

按位或运算符

我们还看到了||运算符。它是一个布尔运算符,如果其左侧值或右侧值为true,则返回true

image

|运算符会在结果中的位上设置为1,如果其左侧值或右侧值的相应位有一个为1

image

就像按位与运算一样,按位或运算符也会查看它操作的两个值在特定位置的位,以决定结果中相同位置的位的值。

image

对于任意大小的数字都是如此。用于|运算符的两个值的位决定了结果值中相同位置的位。

image

在“os”包常量上使用按位或

image

我们向你展示所有这些是因为你将需要使用按位或运算符来组合这些常量值!

当文档说os.O_APPENDos.O_CREATE值可以与os.O_RDONLYos.O_WRONLYos.O_RDWR值进行“或操作”时,意味着你应该在它们上面使用按位或运算符。

在幕后,这些常量实际上都是int值:

image

如果我们看一下这些值的二进制表示,我们会发现每个值只有一个位为1,而其他位都为0

image

这意味着我们可以使用位或运算符结合这些值,而这些位不会互相干扰:

image

os.OpenFile函数可以检查第一位是否为1,以确定文件是否应为仅写入。如果第七位为1OpenFile将知道在需要时创建文件。如果第十一位为1OpenFile将在文件末尾追加。

使用位或修复我们的 os.OpenFile 选项

之前,当我们只传递os.O_WRONLY选项给os.OpenFile时,它覆盖了文件中已有数据的部分。让我们看看是否可以结合选项,使其追加新数据到文件末尾。

首先编辑aardvark.txt文件,使其再次只包含一行。

image

接下来,更新我们的程序,使用位或运算符将os.O_WRONLYos.O_APPEND常量值组合成一个单独的值。将结果传递给os.OpenFile

image

再次运行程序并查看文件的内容。你应该看到新添加的文本行追加到末尾。

image

让我们还尝试使用os.O_CREATE选项,这会导致os.OpenFile在文件不存在时创建指定的文件。首先删除aardvark.txt文件。

image

现在更新程序,将os.O_CREATE添加到传递给os.OpenFile的选项中。

image

当我们运行程序时,它将创建一个新的aardvark.txt文件,然后将数据写入其中。

image

类 Unix 风格的文件权限

我们一直在关注os.OpenFile的第二个参数,它控制文件的读取、写入、创建和追加。到目前为止,我们忽略了第三个参数,它控制文件的权限:即在程序创建文件后,哪些用户将被允许读取和写入该文件。

image

当开发人员谈论文件权限时,他们通常指的是类似 macOS 和 Linux 等类 Unix 系统上实现的权限。在 Unix 下,用户对文件可以有三种主要权限:

缩写 权限
r 用户可以读取文件的内容。
w 用户可以写入文件的内容。
x 用户可以执行文件。 (这仅适用于包含程序代码的文件。)

如果用户对文件没有读取权限,例如,他们运行试图访问文件内容的任何程序将从操作系统获取错误:

image

如果用户对文件没有执行权限,他们将无法执行其中包含的任何代码。(不包含可执行代码的文件应标记为可执行,因为试图运行它们可能会产生不可预测的结果。)

image

使用os.FileMode类型表示权限

Go 语言的os包使用FileMode类型来表示文件权限。如果文件不存在,你在调用os.OpenFile时传递给FileMode的值将决定文件创建时的权限,从而决定用户对文件的访问权限。

FileMode值有一个String方法,所以如果你将FileMode传递给fmt.Printlnfmt包中的函数,你将得到该值的特殊字符串表示。该字符串显示了FileMode表示的权限,格式类似于你可能在 Unix 的ls实用程序中看到的格式。

image

fmt.Println(os.FileMode(0700))
注意

(如果你想了解更多信息,请在搜索引擎中搜索“Unix 文件权限”)

每个文件有三组权限,分别影响三类不同的用户。第一组权限仅适用于拥有文件的用户。(默认情况下,你创建的任何文件都属于你自己的用户帐户。)第二组权限适用于文件所分配的用户组。第三组权限适用于系统中既不是文件所有者也不是文件分配组的其他用户。

image

FileMode的底层类型是uint32,表示“32 位无符号整数”。这是一个我们之前没有讨论过的基本类型。因为它是无符号的,所以它不能表示任何负数,但是它可以在它的 32 位内存中表示比它能表示的更大的数值。

由于FileMode基于uint32,你可以使用类型转换将(几乎)任何非负整数转换为FileMode值。但是结果可能有点难以理解:

image

八进制表示法

实际上,指定整数转换为FileMode值时使用八进制表示法会更容易。我们已经看过十进制表示法,使用了 10 个数字:0 到 9。我们也看过二进制表示法,只使用了两个数字:0 和 1。八进制表示法使用了八个数字:0 到 7。

你可以使用fmt.Printf%o格式化动词查看各种数字的八进制表示:

image

与二进制表示法不同,Go 语言允许你在程序代码中使用八进制表示法写入数字。任何以0开头的数字序列都会被视为八进制数。

如果你没有做好准备,这可能会让人感到困惑。十进制的10和八进制的010完全不同,十进制的100和八进制的0100也完全不同!

image

在八进制数中只有数字 0 到 7 是有效的。如果包含了 8 或 9,你将会得到编译错误。

image

将八进制值转换为 FileMode 值

那么为什么要使用这种(可以说有些奇怪的)八进制表示法来表示文件权限呢?因为八进制数的每一位可以用内存中的 3 位来表示:

image

三个位也正是存储一个用户类别(“用户”、“组”或“其他”)权限所需的确切数据量。您需要的任何用户类别的权限组合都可以用一个八进制数字表示!

image

注意下面的八进制数的二进制表示与相同数字的FileMode转换之间的相似性。如果二进制表示中的某位为1,则对应的权限被启用。

image

正因如此,Unix 的chmod实用程序(简称“change mode”)已经几十年来使用八进制数字设置文件权限。

image

八进制数字 权限
0 没有权限
1 执行
2 写入
3 写入、执行
4 读取
5 读取、执行
6 读取、写入
7 读取、写入、执行

Go 对八进制表示法的支持允许您在代码中遵循相同的约定!

对 os.OpenFile 的调用,解释

现在我们理解了位操作符和八进制表示法,我们终于能够理解os.OpenFile的调用所做的事情!

例如,这段代码将在现有的日志文件中追加新数据。拥有该文件的用户将能够从中读取和写入文件。所有其他用户只能从中读取。

image

并且这段代码将在文件不存在时创建一个文件,然后将数据追加到文件中。生成的文件将由其所有者可读和可写,但其他用户将无法访问它。

image

没有蠢问题

Q: 八进制表示法和位操作符太麻烦了!为什么要这样做?

A: 为了节省计算机内存!处理文件的这些约定根源于 Unix,在那时 RAM 和磁盘空间都更小更昂贵。但即使现在,当硬盘可以包含数百万个文件时,将文件权限压缩到几个位而不是几个字节可以节省大量空间(并使您的系统运行更快)。相信我们,这种努力是值得的!

Q: FileMode字符串开头的那个额外的破折号是什么?

A: 该位置上的破折号表示文件只是普通文件,但它可以显示几个其他值。例如,如果FileMode值表示一个目录,那么它将是d

image

附录 B. 我们没有涵盖的六件事:剩余内容

image

我们已经覆盖了很多内容,你离完成这本书只剩一点了。 我们会想念你,但在让你离开去面对这个世界之前,我们觉得还是应该给你一点额外的准备。我们为这个附录留了六个重要的话题。

#1 “if”语句的初始化语句

这里有一个saveString函数,它返回一个单一的error值(或者如果没有错误则为nil)。在我们的main函数中,我们可能会在处理之前将该返回值存储在一个err变量中:

image

现在假设我们在main函数中添加了另一个对saveString的调用,也使用了一个err变量。我们必须记住将第一次对err的使用作为短变量声明,并将后续的使用改为赋值语句。否则,由于尝试重新声明变量,我们将会得到编译错误。

image

但实际上,我们只在if语句及其块内使用err变量。如果有一种方法可以限制变量的作用域,使得我们可以将每个出现视为一个单独的变量,那该有多好呢?

还记得我们在第二章中首次介绍for循环时吗?我们说它们可以包含初始化语句,用于初始化变量。那些变量只在for循环的块内部有效。

image

类似于for循环,Go 语言允许在if语句中的条件之前添加初始化语句。初始化语句通常用于初始化一个或多个变量,以便在if块中使用。

image

在初始化语句内声明的变量的作用域仅限于if语句的条件表达式及其块中。如果我们重新编写之前的示例以使用if初始化语句,每个err变量的作用域将限制在if语句的条件和块内,这意味着我们将有两个完全独立的err变量。我们不需要担心哪个变量是首次定义的问题。

image

这种对范围的限制两面性很明显。如果一个函数有多个返回值,并且你需要其中一个if语句内部和一个外部,你可能无法在if初始化语句中调用它。如果尝试这样做,你会发现你需要的值在if块外部是不在范围内的。

image

相反,你需要在if语句之前像往常一样调用函数,以便它的返回值在if语句内部外部都在范围内:

image

#2 switch语句

当你根据表达式的值来执行多种操作时,会导致一堆if语句和else子句的混乱。switch语句是表达这些选择的更高效方式。

你写下switch关键字,后面跟上条件表达式。然后你可以添加几个case表达式,每个都是条件表达式可能具有的值。第一个与条件表达式匹配的case被选择,并且运行其包含的代码。其他的case表达式被忽略。你也可以提供一个default语句,如果没有case匹配,则运行该语句。

这是我们在第十二章中使用ifelse语句编写的代码示例的重新实现。这个版本需要的代码明显更少。对于我们的switch条件,我们从13中选择一个随机数。我们为每个值提供了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 个字节。

这里有两个字符串,一个是包含英文字母的,另一个是包含俄语字母的。

image

通常,您不需要担心字符存储的详细信息。也就是说,直到 您尝试将字符串转换为其组成的字节并返回。例如,如果我们尝试使用 len 函数来调用我们的两个字符串,则会得到非常不同的结果:

image

当您将字符串传递给 len 函数时,它会返回 字节 长度,而不是 rune 长度。英文字母表字符串适合 5 个字节 - 每个 rune 仅需要 1 个字节,因为它来自旧的 ASCII 字符集。但俄语字母表字符串却需要 10 个字节 - 每个 rune 需要 2 个字节来存储。

如果您想获得字符串的 字符 长度,您应该使用 unicode/utf8 包的 RuneCountInString 函数。此函数将返回正确的字符数,无论用于存储每个字符的字节数。

image

安全地处理部分字符串意味着将字符串转换为 rune,而不是字节。

在本书之前,我们不得不将字符串转换为字节切片,以便将它们写入 HTTP 响应或终端。只要确保写入结果切片中的 所有 字节,这种方法就可以正常工作。但如果你试图处理结果切片中的 部分 字节,那就会麻烦不断。

下面是一些试图从前面字符串中剥离前三个字符的代码。我们将每个字符串转换为字节切片,然后使用切片操作符收集从第四个元素到切片末尾的所有内容。然后,我们将部分字节切片再转换回字符串并打印出来。

image

这在处理英文字母字符时效果很好,每个字符占用 1 字节。但是俄语字符每个占用2 字节。截取该字符串的前 3 个字节只会省略第一个字符和第二个字符的“一半”,导致一个不可打印字符。

Go 支持将字符串转换为rune值的切片,并从 rune 的切片转换回字符串。要处理部分字符串,应将其转换为rune值的切片,而不是字节值的切片。这样,您就不会意外地只抓取 rune 的部分字节。

这是一个更新后的代码,将字符串转换为rune片段而不是字节片段。我们的切片操作现在省略了每个切片的前三个rune,而不是前3 个字节。当我们将部分切片转换为字符串并打印它们时,我们只得到每个切片的最后两个(完整的)字符。

image

如果尝试使用字节片段处理字符串中的每个字符,会遇到类似的问题。只要您的字符串都是 ASCII 字符集中的字符,逐字节处理就可以。但是一旦出现需要 2 个或更多字节的字符,您将再次发现自己只处理了部分 rune 的字节。

此代码使用for ... range循环打印英文字母字符,每个字符 1 字节。然后尝试对俄文字母字符执行相同操作,每个字符 1 字节——但失败了,因为每个字符都需要 2 字节。

image

在 Go 中,您可以在字符串上使用for...range循环,它将逐个处理rune,而不是每次处理一个字节。这是一种更安全的方法。您提供的第一个变量将被分配为字符串中当前字节的索引(而不是 rune 的索引)。第二个变量将被分配为当前 rune。

这是上述代码的更新版本,使用for...range循环处理字符串本身,而不是它们的字节表示。您可以从输出中看到,处理英文字符时每次处理 1 字节,但处理俄文字符时每次处理2 字节

image

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 网站

headfirstgo.com/

这本书的官方网站。在这里,你可以下载我们所有的代码示例,练习额外的练习,并学习关于新主题的知识,所有内容都以同样易读且极具幽默感的文风编写!

Go 语言之旅

tour.golang.org

这是关于 Go 基本特性的互动教程。它涵盖了与本书大部分相同的内容,但包括一些额外的细节。在 Tour 中的示例可以直接在浏览器中编辑和运行(就像在 Go Playground 中一样)。

Effective Go

golang.org/doc/effective_go.html

由 Go 团队维护的关于如何编写符合社区约定的惯用 Go 代码的指南。

Go 博客

blog.golang.org

官方的 Go 博客。提供有关使用 Go 的有用文章以及新版本和功能的公告。

包文档

golang.org/pkg/

所有标准包的文档。这些文档与go doc命令提供的相同,但所有库都方便地列在一个列表中供浏览。encoding/jsonimageio/ioutil 包可能是开始的有趣地方。

Go 语言编程

www.gopl.io/

这本书是这个页面上唯一不免费的资源,但是它是值得的。它众所周知且广泛使用。

有两种技术书籍:教程类书籍(比如你手上的这本)和参考书籍(像Go 语言编程)。而这是一个很好的参考书籍:它涵盖了我们在这本书中没有空间的所有主题。如果你打算继续使用 Go,这本书是必读的。

posted @ 2024-06-18 18:05  绝不原创的飞龙  阅读(9)  评论(0编辑  收藏  举报