嗨玩手游网

Raft

Raft

休闲益智|38.3MB

raft木筏生存是一款以海洋...

下载

高效Go编程指南:1 热身

本章涵盖本书的目标代码设计和可测试性的重要性鸟瞰图围棋简介

本章将向您展示 Go 从鸟瞰的角度提供的内容以及它与其他语言的不同之处。本章将是一个介绍,不会详细介绍所有内容,但它会给你一个概述。

在整本书中,我将帮助您设计和测试Go中的可维护代码。Go 是一种微小而直接的语言。但不要让那愚弄你;你站在你面前巨人的肩膀上。为了实现简单性,在其设计机制中花了很多心思。在幕后,Go 是一种复杂的语言,有许多活动部件,但它故意将这种复杂性隐藏在简单的界面后面。

1.1 本书的目标

我写这本书是给最近学会了如何使用Go编程的中级程序员写的。Go 具有独特的设计习语和机制,如果您像使用其他编程语言一样与该语言作斗争并编写代码,它会咬你。因此,如果没有正确理解 Go 如何进行程序设计,就无法编写设计良好的代码。

假设你是一名Java程序员。您听说 Go 快速、可靠、跨平台,带有出色的工具、开箱即用的库,并且易于学习和使用。因此,您决定将您从 Java 编写的命令行工具之一移植到 Go。正是这本书的正确类型!

在阅读了几个教程和一些艰苦的工作之后,你设法用 Go 编写了程序。您决定向程序再添加一个功能。但这似乎比你想象的更棘手。你偶然发现了一个问题,而且很快又一个。您观察到代码越来越变得不可维护的混乱。

您已经知道如何设计 Java 代码。但是,现在,几乎所有你知道的东西突然停止帮助你。你感到迷茫和沮丧。你的直觉告诉你缺少了一些东西。你开始认为你可能没有设计良好的代码来经受住时间的考验。你意识到你无法弄清楚如何设计一个可维护的程序。你开始阅读书籍和在线文章。经过一番挖掘,你意识到 Go 可能对程序设计有不同的看法。听起来很耳熟?本书就在那里进入场景,并将教你用 Go 编写设计良好、可测试且可维护的代码。

1.1.1 精心设计的代码

让我们谈谈你将在书中学到什么。本书的主要目标是用 Go 编写设计良好且可维护的代码。设计良好的代码很简单,简单也不容易。精心设计的代码易于推理,易于更改,可靠,错误更少,并帮助您避免意外。

由于“好”和“设计”这两个词可能根据上下文表示不同的含义,因此除了指导方针之外,没有单一的真理。有些人可以查看相同的代码并想:“哦,这太糟糕了!”而其他人可能会想:“哇,这太棒了!”。最后,它是关于创建可以通过快速适应不断变化的需求来生存的代码。

只有在完全理解语言机制之后,在 Go 中创建设计良好的代码才会变得更容易。如果没有这些知识,您可能会与该语言作斗争,并从其他编程语言中带来您以前的设计决策。好消息是,在 Go 中通常有一种正确的做事方法,我们称之为惯用 Go。以下是我们希望从良好代码中看到的一些品质:

简单 — 代码简单、易于阅读且易于理解。Go 代码中没有魔力:您可以了解您所做的几乎所有事情的硬件成本。适应性强 — 代码很容易适应不断变化的需求。Go 遵循 Unix 哲学和设计,考虑到可组合性,而不是继承其他类型的行为。可测试 — 代码易于测试。

当然,整本书中还会有很多其他内容。这就是我写这本书的原因!但我认为这些属性可以让你很好地了解我们在 Go 中想要实现的目标。

1.1.2 可测试代码

“除了改变,没有什么能持久。”

- 赫拉克利特

您将在本书中学到的另一件事是编写可测试的代码。幸运的是,测试是 Go 中的一等公民,Go 标准库对此有很好的支持。

软件开发行业最关键的优势之一是软件可以改变。代码应该适应新的要求,并希望通过时间的考验。然而,顽固地改变的软件没有这种优势。通过测试,编写此类代码可能会变得更容易实现。如果没有测试,则无法明智地确定代码在进行更改后是否仍然有效。

早在 90 年代,我就手动测试我的代码。我正在编写一些代码,然后运行它以查看它是否达到了我的预期。但是,尝试以这种方式手动验证软件容易出错且不可扩展。特别是在大型代码库中,手动测试变得不可能。幸运的是,自 2000 年代初以来,我通过手动和自动测试来测试我的代码。但即使使用自动化测试,仍然不可能开发一个完全没有错误的程序。谁有无穷无尽的时间来创建每个测试用例?

虽然测试可以帮助你找到错误,但这只是故事的一半。测试也是关于创建可测试的代码,这样做本身就是一门艺术。对于每个用例,没有一个单一的真相是一成不变的。创建可测试的代码也可以提高您的软件设计技能。如果制作正确,可测试的代码可以帮助您设计可靠、适应性强且健康的程序。当你编写测试时,你将从测试的眼睛里练习你的代码。这很重要,因为它可以让您直接发现使用代码的难易程度。

让我们停下来,看看测试的一些好处。当你制作足够好的测试时,有很多好处。稍后我将详细解释是什么使测试足够好。以下是其中的一些:

信心 - 您将信任您的代码并毫无畏惧地改进它。模块化设计 - 测试可以帮助您制作具有良好设计特征(如解耦和高内聚)的高质量代码库。更少的错误 - 研究证明,测试可以大大减少并尽早发现错误。调试 - 测试可以帮助您在更改某些内容时自动查找错误,而不是手动查找错误。文档 - Go 对记录代码甚至强制执行代码提供了一流的支持。测试可以成为代码的不断更新的文档。当我试图理解一个软件时,我总是先阅读测试。

当然,没有什么是免费的。以下是测试的一些可能缺点:

更多代码 - 测试是您必须维护的其他代码。更多工作 - 最初,您必须添加测试,而不是客户想要的测试。但随着时间的推移,你实际上可能会变得更快。否则,您将手动测试和调试代码。测试成为主要目标 — 我们的目标应该是设计一个由松散耦合、内聚和可组合部件组成的系统。测试绝对可以帮助您,但它们不是您的最终目标。测试应该是务实的,我们不应该仅仅为了测试而测试。如果你走得太远,你可能会走到测试范围的极端。那里有一条细线。1.2 简介

Go 处于动态类型语言(如 Python)和静态类型语言(如 C)之间的最佳位置。在用 Go 编写代码之前,你可能已经研究过它。在这样做的时候,很容易错过让 Go 大放异彩的关键知识。我们称 Go 开发者为 Gopher,我希望你是一个健全的 Gopher。为此,您需要从鸟瞰的角度了解语言的根源、动机及其显着特征。

图 1.1 Go 处于动态类型和静态类型编程语言之间的最佳位置。

在本节的开头,您将了解导致创建 Go 的动机。我认为如果不了解背景,就很难理解 Go 背后的设计选择。之后,您将探索 Go 语言的差异化和显着特征。你会看到我把它与其他语言进行比较,以及它在它们中的位置。您将了解 Go 的不同之处。这些背景知识将阐明您将在整本书中学到的内容。

1.2.1 动机

花了五年的时间来创建第一个稳定的 Go 版本。早在 2007 年,来自 Google 的三位经验丰富的程序员:Robert Griesemer、Rob Pike 和 Ken Thompson,就厌倦了处理缓慢的编译、复杂的语言功能和难以理解的代码。像C,C++和Java这样的语言通常很快,但它们对开发人员不友好。其他语言,如Python,PHP,Ruby和Javascript对开发人员友好,但它们效率不高。这种动力的丧失促使他们考虑创建一种可以解决所有这些问题的新语言,也许更多。他们问:“现代实用的编程语言应该是什么样子的?

在研究新语言两年后,他们于 2009 年 1 月将其作为开源语言向公众发布。三年来,许多人用他们的想法和代码为语言做出了贡献。最后,他们在 0 年发布了 Go 2012.<>。你可能会问:为什么花了这么长时间?Go 的创造者希望通过精心实验、混合和提炼许多其他语言的最佳想法来创建一种语言。C,Modula,Newsqueak,Oberon,Pascal和Smalltalk对该语言的初始设计产生了重大影响:

类 C 语句和表达式语法。类似帕斯卡的声明语法。类似奥伯龙的包装系统。Go 和 Oberon 不使用公共、私有和受保护的关键字来管理对标识符的访问,而是使用简单的机制从包中导出标识符。Oberon 和 Go 一样,当你导入一个包时,你需要限定包的名称才能访问导出的标识符。当您将第一个字母大写时,Go 导出,而 Oberon 在添加星号时会这样做。类似 Smalltalk 的面向对象编程风格。从其他面向对象编程语言到 Go 的开发人员在看不到任何类时经常会感到惊讶。没有类的概念:数据和行为在 Go 中是两个不同的概念。类似于 Smalltalk 的鸭子类型样式,您可以在其中将值传递给需要一组行为的任何类型。您可以在其他流行语言(如Ruby和Python)中看到相同的功能。但在这种情况下,Go 的不同之处在于 Go 同时提供了类型安全和鸭子类型。类似新闻吱吱声的并发功能。Newsqueak是Rob Pike创造的另一种语言。来自 Modula 的对象文件格式。

他们的辛勤工作得到了回报,Go 通过使开发人员能够编写简单、可靠和高效的软件而变得流行起来。今天,全球数以百万计的开发人员和许多公司正在使用 Go 来构建软件。一些在生产中使用Go的著名公司是亚马逊,苹果,Adobe,AT&T,迪士尼,Docker,Dropbox,谷歌,Microsoft,Lyft等。

迪士尼已经注册了域名“go”。因此,Go 团队需要注册域名 golang,而“golang”这个词一直伴随着语言。直到今天,大多数人称这种语言为“golang”。当然,实际名称是Go,而不是golang。但从实际意义上讲,golang 关键字可以更轻松地在网络上找到与 Go 相关的内容。您可以在此链接中阅读有关 Go 历史的更多信息:https://commandcenterspot/2017/09/go-ten-years-and-climbing.html

1.2.2 你可以用 Go 做什么?

如果您了解 Go,您可能知道可以创建强大的 Web 服务器和跨平台命令行工具。再说一次,列出一些你可以用 Go 成功开发的其他类型的程序并没有什么坏处。以下是其中的一些:

Web 服务 - Go 具有内置的 http 包,用于编写 Web 服务器、Web 客户端、微服务和无服务器应用程序,而无需安装第三方包。它还具有一个名为 database/sql 的数据库抽象包,您可以在 Web 应用程序中使用它。跨平台 CLI 工具 - 正如我上面所说,您可以创建交互式、快速且可靠的命令行工具。最好的部分是你编译和运行你的程序,以便在Linux发行版,Windows,OS X等上本机工作。例如,类似 bash 的 shell、静态网站生成器、编译器、解释器、网络工具等。分布式网络程序 - Go 有一个内置的网络包,用于构建并发服务器和客户端,例如 NATS、raft 等。数据库 - 人们使用Go编写了许多现代数据库软件,包括Cockroach,Influxdb,GoLevelDB和Prometheus。

尽管有可能,但在某些领域 Go 并不适合:

游戏 - Go 没有对游戏开发的内置支持,但许多库可以帮助您构建游戏。例如,Ebiten 和 Pixel。但是,这并不意味着您不能开发大型多人在线游戏服务器!桌面程序 - 与游戏开发一样,Go 没有用于开发桌面应用程序的内置支持。一些跨平台的软件包可以帮助Fyne和Wails。嵌入式程序 - 您可以使用第三方库创建嵌入式程序:Gobot、TinyGo(用于低资源系统的 Go 编译器)和 EMBD。Go 还有一个名为 cgo 的内置包,它允许你在程序中调用 C 代码(或来自 C 的 Go 代码)。1.2.3 Go 成功背后的原因自以为是

在 Go 中做事通常有一种正确的方法。Go 中没有制表符与空格参数。它以标准样式格式化代码。当有未使用的变量和包时拒绝编译。鼓励包成为简单而连贯的单元,模仿 Unix 构建软件的方式。当包之间存在循环依赖关系时拒绝编译。它的类型系统很严格,不允许继承,列表还在继续。

单纯

该语言易于使用,简洁,明确,易于阅读和理解。它最小且易于在一周左右的时间内学习。有一个 50 页长的规范定义了 Go 语言的机制。每当对某些语言功能发生混淆时,您都可以从规范中获得权威答案。Go 的向后兼容性保证了即使 Go 每天都在发展,你十年前编写的代码今天仍然有效。

类型系统和并发性

Go 是一种强静态类型的编程语言,采用面向对象编程的最佳原则,如组合。编译器知道每种值类型,并在您犯错时警告您。Go 是一种内置并发支持的现代语言,它已准备好处理当今的大规模分布式应用程序。

内置软件包和工具

也许新手认为 Go 标准库 - stdlib - 缺乏功能并且依赖于第三方代码。但实际上,Go 附带了一组丰富的软件包。例如,有用于编写命令行工具、http 服务器/客户端、网络程序、JSON 编码器/解码器、文件管理等的包。一旦新手有足够的 Go 经验,大多数人就会摆脱第三方软件包,而更喜欢使用标准库。

安装 Go 时,它附带了许多内置工具,可帮助您有效地开发 Go 程序:编译器、测试器、包管理器、代码格式化程序、静态代码分析器、linter、测试覆盖率、文档、重构、性能优化工具等。

Go 编译器

没有中间执行环境,例如解释器或虚拟机。Go 将代码直接编译为快速本机机器代码。它也是跨平台的:您可以在OS X,Linux,Windows等主要操作系统上编译和运行代码。

Go 从设计之初的设计目标之一就是快速编译。Go 编译得如此之快,以至于你可能认为你甚至没有编译你的代码。感觉就像你在用像Python这样的解释型语言工作。快速编译器通过快速编写和测试代码来提高工作效率。那么它是如何编译得这么快的呢:

语言语法简单易懂。每个源代码文件都告诉编译器代码应该在顶部导入什么。因此,编译器不需要解析文件的其余部分来找出文件正在导入的内容。如果源代码文件不使用导入的包,则编译结束。编译器运行速度更快,因为没有循环依赖项。例如,如果包 A 导入包 B,则包 B 无法导入包 A。在编译期间,编译器记录包的依赖项以及包对目标文件的依赖项。然后,编译器使用对象文件作为缓存机制,并逐步加快后续包的编译速度。图 1.2 Go 运行时层。Go 编译器将 Go 运行时和特定于操作系统的接口嵌入到可执行的 Go 文件中。

在 Go 中,编译后的代码生成一个没有外部依赖的可执行文件;一切都是内置的,如图 1.2 所示。每个 Go 程序都带有一个集成的运行时,其中包括:

在后台运行并自动释放未使用的计算机内存的垃圾回收器。例如,在 C 和 C++ 中,您需要手动管理内存。一个 goroutine 调度程序,用于管理称为 goroutines 的轻量级用户空间线程。垃圾回收器也使用 goroutine 机制。

由于可执行文件没有任何依赖项,因此将代码部署到生产环境变得微不足道。另一方面,如果要在生产环境中运行 Java 代码,则需要安装虚拟机。使用Python,你需要安装一个Python解释器。幸运的是,由于 Go 编译成机器代码,因此无需解释器或虚拟机即可工作。

注意

您可以在链接中阅读有关 Go 编译器背后的设计机制的更多信息:https://talks.golang/2012/splashicle#TOC_7。

1.2.4 类型系统

“如果你能重新做一遍Java,你会改变什么?”“我会不上课,”他回答说。笑声平息后,他解释说,真正的问题不是类本身,而是实现继承。接口继承更可取。应尽可能避免实现继承。

——James Gosling(Java的发明者)

Go 类型系统简单明了,正交。正交是指每个特征都是独立的,可以以创造性的方式自由组合。正交性和简单性使我们能够创造性地使用类型系统,即使是 Go 创建者也可能没有想到。

面向对象但没有类

Go 是一种面向对象的语言,但没有类和继承。每个类型都可以有行为,而不是类。地鼠不是继承,而是使用接口创建多态类型。与其建立大类,不如从小事情中组合更大的东西。如果其中一些事情还不清楚,请不要担心。您将在这里学习其中的一些。

第一个面向对象的编程语言是Simula,它引入了类,对象和继承。然后SmallTalk出现了,它是关于消息传递的。Go 在某种程度上遵循了 SmallTalk 传递消息的传统。

现代面向对象编程语言中最普遍的概念是类,您将行为和数据放在一起。通过数据,我指的是存储数据的变量,而通过行为,我指的是可以将数据从一种形状转换为另一种形状的函数和方法。例如,Java是将类概念作为核心语言功能的语言之一。Java 类结合了数据和行为,不允许定义类之外的任何内容。从这个意义上说,Ruby和Python更加宽松,并且从类定义中定义行为。

在下面的 Java 代码示例中,数据(主机变量)和行为(Start 方法)紧密附加到 Service 类。

public class Service { private String host; // data public void Start() throws IOException { // behavior ... }}

如您所见,Go 混合并提炼了之前许多语言的最佳功能。Go 是一种面向对象的编程语言,但在主流意义上并非如此。你可以在 Go 中编写类似的类型,如下所示:

type Service struct { host string // data}

如您所见,Service 是一个结构体,类似于一个类,但仅包含数据。另一方面,行为与数据完全分开,如下所示:

func Start(s *Service) error { // behavior ...}

没有什么能把你紧紧地束缚在一个类中,在那里你一起定义行为和数据。从这个意义上说,我可以说 Go 比主流的面向对象编程语言更接近过程语言。这种分离允许您将数据与行为混合在一起,而无需从第一天起就考虑设计所谓的类。与大多数其他面向对象的编程语言不同,没有类的层次结构。

每种混凝土类型都可以有行为

Go 方法是简单的函数。例如,如果要将上面的 Start 函数作为方法附加到 Service 类型,则可以轻松地执行以下操作:

func (s *Service) Start() error { // s is a variable in this scope fmt.Println("connecting to", s) return nil // no errors occurred}

与以前使用其他 OOP 语言一样,服务类型现在具有 Start 方法。但是,Go 中没有 “ this ” 关键字。相反,每个方法都有一个接收器变量。在上面的例子中,接收器是变量 s,其类型是 *服务。Start 方法可以使用 s 变量并访问主机字段。

让我们创建一个新的服务值:

svc := &Service{host: "localhost"}As you can see, there are no constructors in Go. The code above declares a variable of type pointer to the Service and sets the host field to localhost. You can now call the method on the new Service value (svc):err := svc.Start()if err != nil { // handle the error: log or fail}

调用 Start 方法后,您将看到以下内容:

Connecting to localhost

让我们再创造一条服务价值:

svc2 := &Service{} // makes an empty *Service valuesvc2 = "google" // assigns "google" to the host fieldsvc2.Start()

这一次,您将看到以下内容:

Connecting to google

如您所见,每个 *Service 值都类似于一个类的实例。

Go 是按值传递的。如果未添加指针接收器 ( *服务 ),则无法更改主机字段。这是因为每次调用该方法时,Go 都会复制接收器变量,并且您将更改另一个 Service 值的主机字段。

在后台,编译器将 Start 方法注册到名为 *Service 类型的方法集的隐藏列表。以便编译器可以按如下方式调用该方法:

err := (*Service).Start(svc)// Connecting to localhost

在上面的示例中,对 svc 值调用 Start 方法。您也可以在另一个值上调用它:

err := (*Service).Start(svc2)// Connecting to google

由于 Start 方法属于 Service 类型,因此编译器需要先遍历该类型并调用该方法。但你不必这样做。最佳方法是对值调用 Start 方法,如下所示:

svc.Start()svc2.Start()

Go 的另一个区别是,您可以将行为附加到您拥有的任何具体类型,无论您是否在很久以前就定义了该类型,只要它位于同一包中。此设计允许您随时使用行为丰富类型:

type number intfunc (n number) square() number { return n * n}

由于行为和数据是分开的,因此如果要向数字类型添加其他方法,则无需更改数字类型的定义。此功能允许您在不更改现有代码的情况下改进代码:

func (n number) cube() number { return n.square() * n}

现在,您可以对数字类型的值调用所有方法:

var n number = 5n = n.square() // same as: square(n) and returns 25.n = n.cube() // returns 15625.接口解锁多态行为

如您所见,Go 提供了一种不同类型的面向对象编程,其中数据和行为是两个不同的东西。虽然类和对象在其他面向对象的编程语言中是必不可少的,但在 Go 中重要的是行为。举个例子,让我们看一下 io 包的 Writer 接口:

type Writer interface { Write(p []byte) (n int, err error)}

编写器接口仅描述没有实现的行为。它有一个方法,仅描述使用 Write 方法写入某些内容。任何具有 写入方法的类型都可以是 编写器 。当一个类型实现一个接口的所有方法时,我们说该类型满足接口。例如,文件类型是编写器,我们说它满足编写器接口。缓冲区类型也是编写器。它们都实现了编写器接口的相同 Write 方法:

type File struct { ... }func (f *File) Write(b []byte) (n int, err error) { ...}type Buffer struct { ... }func (b *Buffer) Write(p []byte) (n int, err error) { ... }

假设您要将文本消息写入文件。为此,您可以使用一个名为 Fprint 的函数来获取 io。编写器接口作为参数,并将消息写入具有 Write 方法的类型的任何给定值:

func Fprint(w io.Writer, ...)

首先,您将使用 stdlib 的操作系统包打开文件,然后将其传递给函数:

f, _ := os.OpenFile("error.log", ...)Fprint(f, "out of memory")

假设,让我们使用 stdlib 的缓冲区类型将其写入内存缓冲区,而不是将错误消息写入文件:

var b bytes.BufferFprint(b, "out of memory")

文件和缓冲区类型没有说明编写器接口。他们所做的只是实现编写器接口的 Write 方法。除此之外,他们对 Writer 界面一无所知。Go 不会将类型耦合到接口,类型隐式满足接口。您甚至不需要 Writer 接口,并且您仍然可以在没有它的情况下描述行为。

与 Go 接口相比,Java 不允许隐式接口和类型耦合到接口。让我们看一下经典的Java接口和实现类似接口的类:

public interface Writer { int Write(p byte[]) throws Exception}// File couples itself to the Writer interfacelic class File implements Writer { ...}

在上面的示例中,文件类型需要表示它实现了编写器接口。通过这样做,它将自身耦合到编写器接口,并且耦合在可维护性方面很糟糕。假设有许多类型实现接口,并且您向接口添加另一个方法。在这种情况下,还需要将新方法添加到实现接口的每个类型中。

如果文件类型未明确说明它实现了编写器接口,则可能会出现另一个问题。假设有一个函数将编写器接口作为参数。即使 File 具有 Write 方法,也无法将其作为 File 对象传递。

在 Go 中,没有这样的问题。接口名称无关紧要。可以将值传递给采用 Writer 接口的函数,只要该值的类型具有具有相同签名(具有相同的输入和结果值)的 Write 方法。

注意

Go 将在编译时抱怨,如果你传递的值不满足接口。

例如,让我们声明一个新接口,如下所示:

type writer interface { Write(p []byte) (n int, err error)}

然后,让我们声明一个采用编写器接口的函数,如下所示:

func write(w writer, ...)write(b, "out of memory")write(f, "out of memory")

可以将 *File 或 *Buffer 值传递给写入函数,因为每个函数都有一个在编写器接口中声明的具有相同签名的 Write 方法。即使您已按如下方式声明该函数,该函数仍然可以采用相同的值:

func write(w io.Writer, ...)write(b, "out of memory")write(f, "out of memory")

正如我所说,接口名称无关紧要。只有方法签名应匹配。

继承与嵌入

“面向对象语言的问题在于,它们拥有所有这些隐含的环境。你想要一根香蕉,但你得到的是一只大猩猩拿着香蕉和整个丛林。

— 乔·阿姆斯特朗

在经典的面向对象编程语言中,子类可以通过继承父类或基类来重用功能和数据。继承的问题在于子类和父类是耦合的。每当其中一个发生变化时,另一个通常会随之而来,并导致可维护性的噩梦。继承还有许多其他问题,但我不会讨论它们,而是专注于 Go 如何处理代码可重用性。

Go 不支持经典意义上的继承,而是支持组合。您可以使用称为结构嵌入的功能重用其他类型的功能和数据。假设有一种类型可以存储有关文件的信息:

type resource struct { path string data []byte}

让我们添加一个拒绝访问资源类型的方法:

// deny denies access to the resourcec (r *resource) deny() { ...}

现在,您可以像这样使用它:

errLog := &resource{path: "error.log"}errLog.deny()

现在,您希望存储有关图像文件的其他信息,但不希望复制资源类型的数据和行为。相反,您可以在映像类型中嵌入资源类型:

type image struct { r resource format imageFormat // png, jpg, ...}

最后,您可以按如下方式使用图像类型:

img := &image{ resource{path: "gopher.png"}, format: PNG,}图 1.3 继承与组合。在左侧,图像类型继承自资源类型并创建类型层次结构。在右侧,图像类型嵌入了资源类型的值。

大多数其他面向对象的语言都将其称为继承,并将路径和数据字段以及 deny 方法复制到图像类型,以下代码将起作用:

img.pathimg.deny()

而 Go 仅将资源值嵌入到图像值中。因此,路径和数据字段以及 deny 方法仍然属于资源类型:

img.format // PNGimg.r.path // "gopher.png"img.r.deny() // calls the deny method of the embedded resource

如您所见,您正在使用嵌入的资源值作为图像值字段。此技术称为合成,其中图像类型具有资源值。图像值将始终具有资源值,您甚至可以在运行程序时即时更改它。虽然 Go 使用嵌入,但你也可以模仿继承,让图像假装继承自资源类型:

type image struct { resource format imageFormat // png, jpg, ...}

您是否注意到嵌入的资源值没有字段名称?所以现在你可以按如下方式使用它:

img.path // same as: img.resource.pathimg.deny() // same as: img.resource.deny()

当您不直接使用嵌入的资源值和类型时:img.path ,在幕后,编译器类型:img.resource.path 。同样,当您对图像值调用 deny 方法时,编译器会将调用转发给嵌入资源的 deny 方法:img.resource.deny()。但是路径字段和 deny 方法仍然属于资源,而不是图像。

在其他面向对象的语言中,父类和子类之间存在 is-a 关系。因此,您可以在需要父类型的地方使用子类型。例如,假设您要拒绝对所有资源的访问。让我们尝试使用公共资源类型将它们放在一个切片中,然后在循环中拒绝对每个资源的访问:

// assume the video type embeds the resource typevid := &video{resource: {...}}resources := []resource{img, vid}for _, r := range resources { r.deny()}

但你不能。图像、视频和资源是不同的类型。如果它们是类似的类型,则可以将它们放在资源切片中。如果要从资源类型继承映像类型,则该映像类型将是资源类型。由于 Go 中没有继承,所以您使用了组合。那么,如何将这些不同类型的值放在同一个切片中呢?您需要从 Go 处理面向对象编程的方式中考虑一个解决方案:它们共享一种称为 deny 的常见行为,您可以通过接口表示该行为:

type denier interface { deny() error}

现在,您可以将不同类型的值放在同一个切片中,只要每个值都实现 deny 方法即可。然后,您可以使用循环一次拒绝对每个资源的访问:

for _, r := range []denier{img, vid} { r.deny()}

上面的代码将起作用。在 Go 中,您可以使用接口多态性实现类型之间的 is-a 关系。与其他面向对象的语言相比,您可以按行为而不是数据对类型进行分组。您可以使用接口进行多态行为和嵌入以实现某种程度的可重用性。这两个特性都允许我们设计松散耦合的组件,而不会创建脆弱的类型层次结构。

1.2.5 并发

在上一节中,您了解了 Go 语言的根源以及您可以使用它构建哪种程序。您还了解了 Go 与其他语言不同的一些东西。在本节中,您将扩大视野并了解 Go 与其他语言区分开来的其他一些显着功能。我想解释的事情很多,但不可能在同一章中涵盖所有内容。

并发性与并行性

Go 是一种并发编程语言,它提供了一种抽象和管理并发工作的方法。新手经常使用并发作为加速程序的一种方式,但这只是故事的一半。并发性的目标不是创建快速执行的程序,但它可能是副产品。相反,它是关于在运行程序之前如何设计程序。并行性与性能有关,这可能仅在运行程序后发生。

让我们想象一个日常生活中的场景。您一边听音乐,一边在自己喜欢的编辑器中编写代码。您正在执行的操作是并行的还是并发的?除了哲学和科学的角度来看,从实际意义上讲,你所做的是平行的,因为你在编码的同时享受音乐。即使你没有活着,你的听觉、触觉和视觉仍然会有一个并行的结构。但是如果你死了,你同时构建的感官将不再能够进行并行处理。

图 1.4 程序员具有并行听音乐和编写代码的并发物理结构。

让我们通过操作系统的镜头看一下情况。假设您的机器上只有一个处理单元,操作系统将快速在媒体播放器和编码编辑器之间共享它,您甚至不会注意到它。它将允许处理器运行媒体播放器一段时间,然后停止播放器并指示处理器运行编码编辑器一段时间。仅当多个处理单元可用于操作系统时,此工作才能并行。

图 1.5 在左侧,单个处理单元 (CPU) 一次运行一个任务。在右侧,多个 CPU 同时(并行)运行媒体播放器和代码编辑器。

并发功能

通过将并发工作划分为独立的处理单元,您可以设计出易于理解且整洁的并发代码,就像编写顺序代码一样。

Tony Hoare在1978年的一篇论文中发明了一种称为通信顺序进程(CSP)的并发模型,其中匿名进程通过相互发送消息来顺序执行语句并进行通信[1]。Go 并发模型的基本原理来自 CSP:

量级用户空间线程,开销最小,您可以像普通函数一样编程。渠道—类型安全值,用于以并发安全的方式在 goroutines 之间进行通信,而无需共享内存、线程和互斥体。Select 语句 - 用于管理多个通道操作的机制。调度程序 - 通过操作系统线程自动管理和多路复用 goroutine。仍有线程,但它们在您的视图中隐藏,默认情况下仅对 Go 调度程序可见。

有时你还需要使用经典的并发特性,比如互斥锁,Go 支持它们,但我们尝试使用通道,而不是使用它们。在本章中,您将只学习 goroutines 和 channels。

Goroutines vs. threads

操作系统进程也分为称为线程的较小单元。例如,媒体播放器程序是一个进程。它可能有几个线程:一个用于从 Internet 流式传输音乐数据,一个用于处理数据,另一个用于将数据发送到计算机上的几个扬声器。

操作系统具有在这些线程之间切换的机制,以便在内核中同时运行它们。由于在进行上下文切换时进行系统调用的开销,在这些线程之间切换可能会产生开销。发生上下文切换时,操作系统会将当前线程状态保存到内存中,然后还原下一个线程的先前状态。这是一个昂贵的操作,这就是为什么我们在使用 Go 编程时使用 goroutines 的原因。

Go 调度器是 Go 管理 goroutines 的方式。由于goroutines很便宜,调度程序可以在一小组内核线程上运行数百万个goroutine。调度程序对于通过通道在 goroutines 之间进行高效通信也至关重要。它还管理其他运行时 goroutine,如垃圾收集器。您将很快了解频道。

注意

您可能想观看此视频,该视频解释了有关 Go 调度程序的更多详细信息:https://www.youtube/watch?v=YHRO5WQGh0k。

Goroutines

每个 Go 程序都有一个称为 main 的函数作为程序的入口点。而且,当你执行一个程序时,操作系统在一个名为主goroutine的goroutine上运行main函数:

func main() { ... the main goroutine runs the code here …}图 1.6 主 Go例程运行主函数代码。箭头描绘了时间的流逝。

图 1.6 显示每个程序至少有一个称为主 goroutine 的 goroutine。主 goroutine 运行 main 函数。当主函数结束时,程序和主 goroutine 一起终止。

当然,Go 程序可以有数百万个 goroutine。假设您正在编写一个创建许多临时文件的数据库服务器。您可以使用简单的 go 语句编写函数,然后在后台将其作为 goroutine运行。代码的其余部分将继续执行,而无需等待 goroutine 完成:

go removeTemps()// ... rest of the code ...图 1.7 两个 Goroutines 同时运行。仅当有多个处理单元时,它们才能并行运行。

在图 1.7 中:

主函数开始在主 goroutine 中运行。然后主函数启动一个名为 删除温度 的新 goroutine。两个 goroutines 同时运行。或者,如果有多个过程单元,则并行。

想想我之前向您展示的媒体播放器示例。您可以使用两个goroutine来设计它:当一个goroutine流式传输歌曲时,另一个将播放它。这两个 goroutines 可以同时工作,而程序中的其他代码继续工作:

go stream()go play()... the main goroutine keeps running the rest of the code …图 1.8 三个 Goroutines 同时运行。

在图 1.8 中:

主 goroutine 通过执行主函数来启动程序。然后主函数启动两个goroutine:流和播放。这三个 goroutines 将继续同时运行,直到程序结束。

有时,您启动了一个 goroutine,但忘记结束它。它被称为goroutine泄漏,它不必要地不断消耗系统资源。这就是为什么你应该在启动goroutines之前计划如何退出它们。

渠道

您需要找到一种方法,从流式处理的goroutine向播放goroutine提供数据以播放歌曲。由于它们同时工作,因此您不能只是在它们之间共享数据。否则,您将不得不处理损坏的数据。幸运的是,您可以使用另一种同步机制:通道。每个通道都有可以传输的数据类型。现在,您可以将通道视为goroutine之间的数据电缆或Unix管道:

cable := make(chan string)

请注意,通道与其他所有值一样是值。您在上面的代码中创建了一个通道变量,并为其分配了一个只能传输字符串数据类型值的通道。在实际程序中,您将使用特定的数据结构。通道值只是指向数据结构的指针,以便您可以将相同的通道值作为参数传递给函数。假设流函数从互联网接收一段数据。因此,您可以使用 send 语句将其发送到您在上面创建的通道:

func stream(out chan string) { for { out <- "...fetched data..." ... }}

流函数接受一个调用的单个输入值,其类型是字符串通道(chan string)。这两个字母符号“<-”被称为接收运算符,或发送语句,具体取决于你将它们放在通道值周围的位置,但我现在将它们称为箭头。如果箭头指向上述通道,则向通道发送值。在另一种情况下,这意味着您从通道接收一个值:

func play(in chan string) { for { data := <-in ... }}

当流 goroutine 向频道发送值时,它将暂时停止工作,直到播放 goroutine 出现并获取该值。另一方面,如果播放 goroutine 尝试从频道接收,也会发生同样的事情。还有一些缓冲通道,这些约束更宽松,但您将在本书后面了解它们。

1.3 小结

到目前为止,您所看到的只是冰山一角。再说一次,我希望你明白 Go 是一种简单但强大的现代语言,它隐藏在易于使用的语言功能背后的复杂性。

在 Go 中设计良好的代码需要不同的思维方式,而设计良好的程序易于维护、可靠且易于测试。测试可以增加您对代码的信心,并可能使其减少错误。Go 感觉像是一种动态语言,但同时又是类型安全的。类型系统获得了面向对象编程的最佳部分,并促进了组合而不是继承。并发性内置于语言中,提供了一种构建并发程序的现代方法。

《木筏生存》新手简易教程攻略

前期

我们的首要任务是解决吃喝的问题,所以简易烤架、初级净化器和杯子就成了前期首要制作品。最快的方法是钩木桶(木桶里面有各种物资和食物),解决吃喝的问题。但别以为喝海水一样可以解渴,这只会让你更加干渴。

木板 :前期无论是蒸馏海水还是烧烤食物,消耗木板非常严重,并且鲨鱼来要你的地基,也需要木板来修补,所以,全程缺木板可以说是每个玩家都苦恼的事情了。

岛屿 :你的木筏可能会撞上岛屿,岛上不仅有树木,还有装有物资的箱子。但要注意的是,当你的木筏随着海水飘远离岛屿一定距离时,岛屿会消失,你也会踩空坠海。

漂流船 :还上随机遇见漂流船,船上有装有物资的宝箱。

研究桌 :放材料进去研究,符合条件即可学习制作新工具

收集网 :自动收集从海面飘过来的物资,并且储存无上限,前期建议在木筏以十字延伸摆放,这样可以使得收集效率最大化。

简易床 :可以让你躺下睡觉直接度过夜晚,使得时间快速流逝。

中期

当你的物资不再紧缺,这时需要做个 船帆 和 船锚 ,接近岛屿的时候抛下船锚,到海底收集海面上打捞不到的矿石资源和沙土

之后你便可以制造 熔炉 了,熔炉可以冶炼金属矿、沙子以及海草,有了这些物品,就可以制造高级烤架和高级净化器(净化器再也不用木板加热了)

后期

很多人以为这款游戏就是简单的海上生存游戏,但并非如此,它也是有主线的

接收器、天线、电池 ,组成完美的雷达,在雷达上便可以看到我们接收到了一个信号,那是生的希望还是空无一物的失望呢?

更多资讯
游戏推荐
更多+