购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

2.2 高级语言元素

现在让我们讨论Go的一些更高级的功能。与上一节中提到的基础知识类似,在讨论效率改进之前,概述核心语言功能尤为重要。

2.2.1 做好代码文档

每个项目,在某些时候,都需要可靠的API文档。对于库类型项目,编程API是主要切入点。具有良好描述的强大接口允许开发人员隐藏复杂性,带来价值,并避免意外。代码接口概述对于应用程序也是必不可少的,它可以让任何人快速理解代码库。在其他项目中复用应用程序的Go包也很常见。

Go项目从一开始就开发了一个名为godoc的工具( https://oreil.ly/TQXxv ),而不是依赖社区来创建许多分散和不兼容的解决方案。它类似于Python的Docstring( https://oreil.ly/UdkzS )和Java的Javadoc( https://oreil.ly/wlWGT )。godoc直接从代码及其注释生成一致的文档HTML页面。奇妙之处在于它没有许多会直接使得源代码中的代码注释可读性降低的特殊约定。要有效地使用这个工具,你需要记住五件事。我们可以使用代码示例2-9和代码示例2-10来完成它们。调用godoc( https://oreil.ly/EYJlx )时生成的HTML页面如图2-1所示。

代码示例2-9:具有godoc兼容文档的 block.go 文件的示例片段

图2-1:代码示例2-9和代码示例2-10的godoc输出

❶ 规则1:可选的包级描述必须放在package条目的顶部,中间没有空白行,并以package<name>前缀开头。如果任何源文件都有这些条目,godoc会将它们全部收集起来。如果你有很多文件,惯例是让 doc.go 文件只包含包级文档、包声明,而没有其他代码。

❷ 规则2:任何公共构造都应该有一个完整的句子注释,以构造的名称开头(这很重要!),然后写出构造的定义。

❸ 规则3:可以使用//BUG(who)语句提及已知错误。

❹ 私有构造可以有注释,但鉴于其私有性质,它们不应暴露在文档中。保持一致性并以结构名称开头,以提高可读性。

代码示例2-10:带有godoc兼容文档的 block_test.go 文件示例片段

❶ 规则4:如果你在测试文件中编写一个名为Example<ConstructName>的函数,例如block test.go文件,godoc将生成一个带有所需示例的交互式代码块。请注意,包名也必须有一个 _test 后缀,代表一个本地测试包,该包在不访问私有字段的情况下测试该包。由于示例是单元测试的一部分,它们将被主动运行和编译。

❷ 规则5:如果示例的最后一条注释以//Output:开头,则该注释后面的字符串将在示例之后以标准输出方式被断言,从而保持示例的可靠性。

我强烈建议遵守这五个简单的规则。不仅因为你可以手动运行godoc并生成文档网页,这些规则还能使你的Go代码注释结构化且一致。

笔者建议在所有注释中使用完整的英文句子,即使注释内容不会出现在godoc中。它能确保代码注释清晰明了。毕竟评论是供人类阅读的。

此外,Go团队还维护着一个公共文档网站( https://pkg.go.dev ),该网站可以免费抓取所有请求的公共存储库。因此,如果你的公共代码库与godoc兼容,它将得到正确呈现。用户还可以阅读每个模块或包版本自动生成的文档。

2.2.2 向后兼容性和可移植性

Go对向后兼容性保证有很强的把握。这意味着核心API、库和语言规范永远不会破坏为Go 1.0( https://oreil.ly/YOKfu )创建的旧代码。很多人放心地将Go升级到最新的次要版本或补丁版本。在大多数情况下,升级是顺利的,没有重大错误和意外。

至于效率兼容性,则很难实现任何保证。我们通常不能保证目前仅进行两次内存分配的函数在Go项目和任何库的下一个版本中不会变成需要分配数百次。在效率和速度特性方面,不同版本之间曾发生过显著变动。社区正在努力改进编译和语言运行时(更多内容参见2.2.3节和第4章)。由于硬件和操作系统也在被开发,Go团队正在尝试不同的优化和功能,让每个人都能更高效地执行。当然,我们在这里不讨论主要的性能回归,因为这通常会在发布候选阶段被注意到并修复。然而,如果我们希望我们的软件快速高效,就需要更加谨慎地应对Go引入的变化。

源代码被编译成针对每个平台的二进制代码。然而,Go工具允许跨平台编译,因此你可以为几乎所有体系结构和操作系统构建二进制文件。

当你执行为不同操作系统(OS)或架构编译的Go二进制文件时,它可能会返回一些晦涩的错误消息。例如,当你尝试在Linux上运行为Darwin(macOS)编译的二进制文件时,一个常见错误便是Exec格式错误。如果你看到这个错误,就必须为正确的体系结构和操作系统重新编译代码源。

关于可移植性,我们不能不提Go运行时及其特性。

2.2.3 Go运行时

许多语言决定通过使用虚拟机来解决跨不同硬件和操作系统的可移植性问题。典型的例子是,用于Java字节码兼容语言(如Java或Scala)的Java虚拟机(JVM)( https://oreil.ly/fhOmL ),和用于.NET代码的公共语言运行时(CLR)( https://oreil.ly/StGbU ),例如C#。这样的虚拟机允许构建语言,而无须担心复杂的内存管理逻辑(分配和释放)、硬件和操作系统之间的差异等。JVM或CLR解释中间字节码并将程序指令传输到主机。不幸的是,虽然它们使创建编程语言变得更容易,但它们也引入了一些开销和许多未知因素 [13] 。为了减轻开销,虚拟机通常使用复杂的优化,比如准时制(just-in-time,JIT)编译( https://oreil.ly/XXARz ),来动态地将特定虚拟机字节码块处理为机器码。

Go不需要任何“虚拟机”。我们的代码和使用的库在编译时会被完全编译为机器代码。由于大型操作系统和硬件的标准库支持,我们的代码如果是针对特定架构编译的,将可以在那里毫无问题地运行。

然而,当我们的程序启动时,后台(同时)也在运行着一些进程。Go运行时( https://oreil.ly/mywcZ )的逻辑是,除了Go的其他一些小功能外,还负责内存和并发管理。

2.2.4 面向对象编程

毫无疑问,面向对象编程(OOP)在过去几十年中赢得了大量的关注度。它由Alan Kay于1967年左右发明,至今仍是编程中最流行的范例 [14] 。OOP使我们能够利用封装、抽象、多态性和继承等高级概念( https://oreil.ly/8hA0u )。原则上,它允许我们将代码视为一些具有属性(在Go字段中)和行为(方法)的对象,这些对象会告诉彼此要做什么。例如公开Walk()方法的动物类或允许使用Ride()方法的汽车类,但在实践中,对象通常不那么抽象,但仍然有用、被封装,并由类描述。Go中没有类,但有等效的struct类型。代码示例2-11显示了我们如何在Go中编写OOP代码以将多个块对象压缩为一个。

代码示例2-11:Go with Group中的OOP示例,其行为类似于Block

❶ 在Go中,结构体和类之间没有分离,就像在C++中一样。在Go中,除了integer、string等基本类型之外,还有一种struct类型可以具有方法(行为)和字段(属性)。我们可以使用结构体作为类,将更复杂的逻辑封装在更简洁的接口下。例如,Block上的Duration()方法告诉我们该块所覆盖的时间范围的持续时间。

❷ 如果我们将一些结构体(例如Block)添加到另一个结构体(例如Group)中,但没有任何名称,则这样的Block结构体被认为是嵌入的,而不是一个字段。嵌入允许Go开发人员获得继承中最有价值的一部分,借用嵌入的结构字段和方法。在这种情况下,Group将具有Block的字段和Duration方法。这样,我们就可以在生产代码库中复用大量代码。

❸ 你可以在Go中定义两种类型的方法:使用“值接收器”(例如,在Duration()方法中)或使用“指针接收器”(带“*”)。所谓接收器就是func后面的变量,代表我们要添加方法的类型,在我们的例子中是Group。我们将在5.5.1节中提到这一点,但关于使用其中哪种类型的规则很简单:

· 如果你的方法不修改Group的状态,请使用值接收器[例如,func(g Group)SomeMethod()]。对于值接收者,每次调用它时,g都会创建Group对象的本地副本。它等同于func SomeMethod(g Group)。

· 如果你的方法是为了修改本地接收器状态,或者任何其他方法都这样做,则使用指针接收器[例如,func(g*Group)SomeMethod()]。它等同于func SomeMethod(g*Group)。在我们的例子中,如果Group.Merge()方法是一个值接收器,我们就不会保留g.children更改,也不会潜在地注入g.start和g.end值。此外,为了保持一致性,如果至少有一个方法需要指针,则始终建议使用具有所有指针接收器方法的类型

❹ 为了将多个block压缩在一起,我们的算法需要一个已排序了的block列表。我们可以使用标准库sort.Sort( https://oreil.ly/N6ZWS ),它需要sort.Interface接口。[]Block切片不实现这个接口,因此我们将它转换为临时sortable类型,如示例2-13中所述。

❺ 这是真正继承中唯一缺失的元素。Go不允许将特定类型转换为另一种类型,除非它是别名或严格的单结构嵌入(如代码示例2-13所示)。之后,你只能将接口转换为某种类型。这就是为什么我们需要明确指定嵌入式struct和block。因此,Go通常被认为是一种不支持完全继承的语言。

示例2-11给了我们什么启发呢?首先,Group类型可以重用Block功能,如果操作正确,我们可以像使用任何其他Block一样使用Group。

嵌入多种类型

你可以在一个struct中嵌入任意数量的独特结构。

这里不存在优先级——如果编译器无法判断使用哪个方法,编译将失败,因为两个嵌入类型具有相同的SomeMethod()方法。在这种情况下,应使用类型名称明确告诉编译器应该使用什么方法。

如代码示例2-11中所述,Go还允许定义接口来告知struct必须实现哪些方法才能匹配它。请注意,无须像在Java等其他语言中那样显式标记实现特定接口的特定struct。只需实现所需的方法就足够了。让我们看一下代码示例2-12中标准库公开的排序接口示例。

代码示例2-12:标准排序Go库中的排序接口

要在sort.Sort函数中使用我们的类型,它必须实现所有sort.Interface方法。代码示例2-13展示了sortable类型是如何做的。

代码示例2-13:可以使用sortable排序的类型示例

❶ 我们可以将另一种类型(例如,Block元素的一个切片)作为sortable结构体中的唯一内容。这允许在[]Block和sortable之间进行简单(但显式)转换,正如我们在示例2-11中的Compact方法中使用的那样。

❷ 我们可以通过使用time.Time.Before(...)方法( https://oreil.ly/GQ2Ru ),对start字段升序排序。

❸ 我们可以使用这个单行语句断言我们的sortable类型实现了sort.Interface,否则编译失败。笔者建议,只要你想确保你的类型在未来与特定接口保持兼容,就使用这样的语句!

总而言之,struct的方法、字段和接口是编写过程可组合代码和面向对象代码的一种优秀而简单的方法。根据笔者的经验,最终它可以满足我们软件开发过程中的低级和高级编程需求。虽然Go不支持所有的继承方面(类型到类型转换),但它足以满足几乎所有OOP情况。

2.2.5 泛型

从1.18版本开始,Go支持泛型( https://oreil.ly/qYyuQ ),泛型也是社区最需要的功能之一。泛型,也称为参数多态性( https://oreil.ly/UIUAg ),它允许以类型安全的方式实现我们希望跨不同类型重用的功能。

Go语言中对泛型的需求在Go团队和社区中引起了很大的讨论,主要有两个问题:

做同样事情的两种方式

从一开始,Go就已经通过接口,支持类型安全的可重复用代码。你可以在前面的OOP示例中看到这一点——sort.Sort( https://oreil.ly/X2NxR )可被所有实现sort.Interface的类型复用,如代码示例2-12所示。我们可以通过实现代码示例2-13中的方法来对自定义块类型进行排序。添加泛型意味着在许多情况下,我们有两种方法来做一件事( https://oreil.ly/dL8uE )。

不过,接口可能会给我们代码的用户带来更多麻烦,而且由于一些运行时开销,有时会比较慢( https://oreil.ly/8tSVf )。

开销

实现泛型会给语言带来许多负面影响。根据不同的实现方式,会对不同的事情产生影响。举例来说:

· 我们可以像在C语言中一样直接跳过实现泛型,这会减慢程序员的工作速度。

· 我们可以使用单态化( https://oreil.ly/B062N ),这本质上是为将要使用的每种类型复制代码。这会影响编译时间和二进制大小。

· 我们可以像在Java中一样使用装箱,这与Go的接口实现非常相似。在这种情况下,我们会影响执行时间或内存使用。

一般性的难题是:你想要慢速程序员、慢速编译器和臃肿的二进制文件,还是慢速的执行时间?

——Russ Cox,“The Generic Dilemma”( https://oreil.ly/WjjV4

经过多次提议和辩论,最终的设计(极其详细!)( https://oreil.ly/k9cCR )通过了。起初,笔者对此持怀疑态度,但事实证明,这种用法是明确而合理的。到目前为止,社区也没有像人们担心的那样滥用这些机制。我们看到泛型很少被使用,它只有在需要的时候才会被用到,因为泛型会使代码的维护变得更加复杂。

例如,我们可以为所有基本类型,如int、float64甚至string,编写通用排序,如代码示例2-14所示。

代码示例2-14:基本类型泛型排序的示例实现

❶ 由于有了泛型(也称为类型参数),我们可以为所有基本类型实现一个可以实现sort.Interface(见代码示例2-13)的单一类型。我们可以提供看起来很像接口的自定义约束,以限制可用作类型参数的类型。在这里,我们使用了一种表示Integer|Float|~string约束的类型,因此任何类型都支持比较运算符。我们还可以使用任何其他接口,如any来匹配所有类型。我们还可以使用一个特殊的comparable关键字,它允许我们使用T comparable的对象作为map的key。

❷ 现在,s切片中的任何元素都被认为是带有Ordered约束的T类型,因此编译器允许我们对它们进行比较,以获得Less功能。

❸ 我们现在可以为任何基本类型实现一个排序函数,该函数将利用sort.Sort实现。

❹ 我们不需要实现如sort.Ints等特定类型的函数。我们可以使用genericSortBasic [<type>]([]<type>),只要切片属于可以排序的类型即可!

这样很好,但它只适用于基本类型。不幸的是,目前在Go中我们还不能覆盖“<”这样的操作符,因此要为更复杂的类型实现泛型排序,我们还必须做更多的工作。例如,我们可以在设计排序时期望每个类型都实现func <typeA> Compare(<typeA>)int方法 [15] 。如果我们将此方法添加到代码示例2-11的Block中,就可以轻松地对其进行排序,如代码示例2-15所示。

代码示例2-15:某些类型对象的通用排序的示例实现

❶ 让我们设计一下约束。我们希望每个类型都有一个接受相同类型的Compare方法。因为约束和接口也可以有类型参数,所以我们可以实现这样的需求。

❷ 我们现在可以为这类对象提供一个实现sort.Interface接口的类型。请注意Comparable[T]中的嵌套T,因为我们的接口也是通用的!

❸ 现在我们可以为Block类型实现Compare函数。

❹ 有了这个功能,我们就不需要为每个要排序的自定义类型实现sortable类型。只要该类型具有Compare方法,我们就可以使用genericSort!

在单独使用用户界面就会很麻烦的情况下,公认的设计显示出了优势。但是,泛型困境问题又该如何处理呢?这种设计允许任何实现( https://oreil.ly/rZBtz ),那么最终是如何取舍的呢?我们不会在本书中详述,但Go使用了介于单态化和装箱之间的字典和模板( https://oreil.ly/poLls )算法 [16]

泛化代码会更快吗?

Go中泛型的具体实现(会随着时间的推移而改变)意味着,理论上泛型的实现应该比接口更快,但比手工实现特定类型的某些功能要慢。但实际上,在大多数情况下,潜在的差异可以忽略不计,因此请首先选用可读性最高、最易维护的选项。

根据笔者的经验,这种差异在效率关键型代码中可能很重要,但结果并不总是符合理论。例如,有时泛型实现更快( https://oreil.ly/9cEIb ),有时使用接口可能更高效( https://oreil.ly/tiOhS )。结论是什么?一定要进行基准测试(见第8章)以确保万无一失!

总而言之,根据笔者使用Go语言的经验,这些事实是笔者在教别人用Go编程时发现的关键要点。此外,在本书后面深入探讨Go的运行时性能时,这些事实也会有所帮助。

但是,如果你以前从未使用过Go编程,那么在跳转到本书后续章节之前,不妨先浏览一下其他资料,如Go之旅( https://oreil.ly/J3HE3 )。先尝试编写一个Go程序,编写单元测试(unit test),并使用loop语句、switch语句以及并发机制(如通道和协程)。学习常用类型和标准库抽象。作为一个刚接触新语言的人,你需要编写一个能返回有效结果的程序,然后确保它能快速高效地执行。

我们已经了解了Go语言的一些基本特性和高级特性,现在是时候来了解一下Go语言的效率方面了。用Go语言编写足够好的或高性能的代码有多容易? Ws7IPT/gR1HLYrlLXEJ54KTS1V6ogeFjAvrKMWPeXrOvTGkAOd1zYTd+SiNTHC6H

点击中间区域
呼出菜单
上一章
目录
下一章
×