面向-Java-程序员的-Go-教程-一-

130 阅读1小时+

面向 Java 程序员的 Go 教程(一)

原文:Go for Java Programmers

协议:CC BY-NC-SA 4.0

一、简单看一下 Go 和 Java

Java 和 Go 有很多明显和细微的区别。它们可以作为语言和运行时来比较。这项调查主要集中在语言比较上。它旨在提供一个粗略的比较。更深入的比较贯穿全文。

本章中的一些文字可能会被解读为贬低 Go。这不是我们的意图。Go 是一种强大的语言,它可以轻松地与 Java 抗衡。但是 Java 确实有 Go 没有的特性,稍后会对它们进行总结。

请注意,这里的描述可能需要更深入地了解到目前为止已经介绍过的 Go 知识,以便全面理解。在本文后面的内容中,当你对 Go 更加熟悉之后,你可能会想重温这一章。

Go 语言及其相关的运行时与 Java 语言及其相关的 Java 运行时环境 (JRE)既有许多相似之处,也有许多不同之处。本章将试图在较高的层次上对它们进行总结。这些相似点和不同点将在本文后面详细讨论。

Go 和 Java 都是图灵完全 1 环境,这意味着(几乎)任何可能的程序都可以在其中任何一个环境中编写。这只是花费的相对开发努力和产生的程序大小和性能的问题。

应该注意的是,Go 语言和 Go 开发经验更接近于 C 语言,而不是 Java 语言。Go 语言的风格和语义比 Java 更像 C 语言。标准的 Go 库也更类似于 c 语言自带的库。

与 C 语言相比的一个例外是 Go 程序构建经验。在 C 中,这通常由 Make 2 (或一个变体)实用程序驱动。在 Go 中,它由 Go 构建器工具驱动。在作者看来,Go 方法更优越,更容易使用(不需要 make 文件)。

注意一些 Go 开发人员使用 make file-like 方法,特别是在复杂的项目中,这些项目不仅仅将 Go 源文件作为组件,因此还需要构建其他工件。Make 文件通常用于编写超出 Go builder 能力范围的多步骤流程。这类似于在 Java 中使用 Ant 3 或者 Maven 4

Go 是一种编译型语言(而 Java 是解释型语言)

像 C 和 C++一样,Go 程序在执行开始前就已经完全构建好了。所有源代码都被编译成目标计算机体系结构的机器语言。此外,所有代码都被编译到目标操作系统。相比之下,Java 被编译成虚拟机语言(又名字节码),并由 Java 虚拟机(JVM)解释。为了提高性能,字节码通常在运行时动态编译成机器语言。JVM 本身是为特定的操作系统和硬件架构而构建的。

一旦建立起来,Go 程序只需要一个操作系统就能运行。此外,Java 程序需要在运行之前,在计算机上存在一个 JRE(所需版本的)。许多 Java 程序可能还需要额外的第三方代码。

Go 方法通常会导致更快的程序启动和更自包含的程序,这两者都使它更适合于容器化的部署。

Go 和 Java 共享相似的程序结构

两种语言都支持包含方法和字段的数据结构的概念。在 Go 中,它们被称为结构,而在 Java 中,它们被称为。这些结构被收集到称为的分组中。在这两种语言中,包可以分层排列(即,具有嵌套包)。

Java 包只包含类型声明。Go 包可以包含基本声明,如变量、常量、函数以及派生类型声明。

两种语言都通过导入不同包中的代码来访问它们。在 Java 中,可以选择使用非限定的导入类型(String vs. java.lang.String)。在 Go 中,所有导入的名字都必须是限定的。

Go 和 Java 在代码风格上有一些差异,这些差异会影响代码的结构

  • Java 声明把类型放在第一位,而在 Go 中,类型放在最后。例如:

    Java—int x, y z;

    go-var x, y, z int

  • Java 方法只能返回一个值。Go 函数可以返回许多值。

  • Java 方法和字段必须在它们所属的类型中声明。Go 方法是在所属类型之外定义的。Go 支持独立于任何类型的函数和变量。Java 没有真正的静态共享变量;静态字段只是某个类的字段(相对于实例)。Go 支持在可执行映像中分配的真正的静态(全局)变量。

  • Go 有全闭包(可以捕获可变数据),而 Java 只支持部分闭包(只能捕获不可变数据)。这可以让 Go 中的一流功能更加强大。

  • Go 缺少用户定义的泛型类型。一些内置类型(例如切片和贴图)是通用的。Java 支持任何引用类型的泛型类型。

    注意,有一个已获批准的建议,即在将来添加泛型类型。

  • Java 只允许其他类型(类、枚举和接口)的类型扩展,而 Go 可以在任何现有类型的基础上创建新类型,包括基本类型(如整数和浮点)和其他用户定义的类型。Go 可以支持这些自定义类型的方法。

  • Go 和 Java 接口的工作方式非常不同。在 Java 中,一个类(或枚举)必须显式地实现一个接口,如果它是通过那个接口被使用(方法调用)的话。在 Go 中,任何类型都可以简单地通过实现接口的方法来实现该接口;不需要声明实现接口的意图,这只是方法存在的副作用。Java 中的许多标准(继承的)行为(如toString()方法)在 Go 中由实现公共接口的类型提供(相当于Stringer接口的String()方法)。

Go 和 Java 都是过程语言

命令式程序是那些通过随时间显式改变状态并测试该状态来工作的程序。它们直接反映了无处不在的冯·诺依曼计算机架构。过程程序是命令式程序,由过程(也就是 Go 中的函数和 Java 中的方法)组成。每种语言都提供了过程语言的以下主要功能:

  • 可以执行表达式,通常带有变量赋值。

  • 可以执行一系列(0+)语句(通常称为基本块)。

  • 通常,一条语句也可以隐式地充当一个块。

  • 可以在代码流中创建单向(if)、双向(if/else)或 n 向(if/else if/elseswitch)条件分支。

  • 可以循环语句。

  • Java 有whiledofor语句;Go 将它们全部组合成仅仅for

  • 可以定义可以从多个位置调用的可重用代码。

  • Java 有方法;Go 有函数,有些是方法。

所有的 6 程序都可以只用这些结构来编写。

Java 是一种面向对象的语言,而 Go 并不完全是面向对象的

和所有面向对象语言一样,Java 是一种基于类的语言。所有代码(方法)和数据(字段)都封装在某个类实现中。Java 类支持继承,因为它们可以扩展一个超类(从Object开始)。Go 允许组合(一个结构可以嵌入到另一个结构中),这通常可以获得继承的一些代码重用好处,但不是全部。

Java 提供了对方法和字段封装的完全控制(通过可见性:public/protected/package private/private)。Go 不提供所有这些选项。Go 结构在拥有字段和关联方法方面与类相似,但是它们不支持子类化。此外,Go 只支持等同于公共和包私有的可见性。

在 Java 中,类和接口都支持多态方法调度。在 Go 中,只有接口做多态方法调度。Go 没有抽象基类的等价物。同样,合成可以提供这个特性的一个子集。

注:尽管 Java 通常被认为是面向对象的,但它并不是面向对象编程风格的完美例子。例如,它有原始数据类型。但是这篇文章并不是要批评 Java 的设计。

Java 是一种高度函数化的语言,Go 则不然

Java,从版本 8 开始,已经很好的支持函数式编程 (FP)。FP 仅使用具有本地数据的函数进行编程;不存在全局的和可变的状态。Java 支持创建一级函数文字(称为 Lambdas )并将它们传递给其他要调用的代码。Java 还允许外部(或显式)循环(whilefor等)。)将被内部循环(在方法内部)替换。例如,Java 支持提供了这一点。

Go 也有一级函数文字,但是缺少对内部循环的类似支持;循环通常是外部的。一级函数提供类似 lambda 的函数,通常是以一种优越的方式。缺少内部循环被认为是 Go 的一个优点,因为它能产生更明显的代码。

Java FP 支持强烈依赖于泛型类型。目前 Go 缺少这些。

Java 是一种高度声明性的语言,而 Go 则不是

通过注释和流等特性的组合,Java 代码可以用声明式风格编写。这意味着代码说明了要做什么,但没有明确说明如何做。运行库将声明转换为实现预期结果的行为。Go 并不提倡对等的编程风格;必须编写代码来明确说明如何实现一个行为。因此,Go 代码更明显,但有时比典型的 Java 代码更大、更重复。

许多 Java 特性是注释驱动的

很多 Java 库(尤其是那些叫框架的),比如 Spring ,都大量使用了 Java 的注释。注释提供元数据,通常在运行时使用,以修改库提供的行为。Go 没有注释,所以缺少这个功能。因此,Go 代码通常更加明确;这被普遍认为是一种美德。Go 可以使用代码生成来获得与注释类似的结果。Go 有一种简单的注释形式,叫做标签,可以用来定制一些库行为,比如 JSON 或 XML 格式。

注释的使用可以将配置决策绑定到源代码。有时,这是一个缺点,因为决策需要延迟到运行时。在这种情况下,Go 和 Java 通常使用类似的方法(比如命令行或配置文件参数)。

Go 不支持异常

Java 有异常(实际上是抛出的异常或错误),可以被引发来报告异常情况。异常的使用在 Java 中非常普遍,经常被用来报告可预测和不可预测的失败。由于来自方法的值很少,因此返回错误。

Go 对这些角色做了更强的分离。所有失败都由函数返回值报告,调用方必须显式测试这些返回值。这样做很好,因为 Go 函数可以更容易地返回多个值,比如一个结果和一个错误。

Go 有死机,其作用类似于 Java 错误。它们被饲养的频率要低得多。与 Java 不同,紧急值不是类型的层次结构,只是开发者选择的值的包装,但通常是error类型的实例。永远不要声明函数可能引发的异常值的类型(也就是说,没有与 Java 的throws子句等价的语句)。这通常意味着代码不那么冗长。许多 Java 代码遵循这种模式,只抛出不需要声明的RuntimeException实例。

Java 和 Go 都使用托管内存(垃圾收集器)

两种语言都使用堆栈和堆来保存数据。栈主要用于函数局部变量,堆用于其他动态创建的数据。在 Java 中,所有对象都是在堆上分配的。在 Go 中,只有可以在函数的生存期之外使用的数据才被分配到堆上。在 Java 和 Go 中,堆都是垃圾回收的;堆对象由代码显式分配,但总是由垃圾收集器回收。

Java 没有指向对象的指针的概念,只有指向位于堆中的对象的引用。Go 允许访问指向任何数据值的指针(或地址)。大多数情况下,Go 的指针可以像 Java 引用一样使用。

Go 的垃圾回收实现比 Java 简单。与 Java 不同,有几个选项可以对它进行调优,它就是工作。

Go 和 Java 都支持并发,但方式不同

Java 有线程的概念,线程是由库提供的执行路径。Go 有 Goroutines (GRs)的概念,是语言本身提供的执行路径。GRs 可以被视为轻量级线程。Go 运行时可以支持使用比 JRE 所能支持的线程更多的(数千个)gr。

Java 支持语言中的同步控件。Go 有类似的库函数。Go 和 Java 都支持可以跨线程/gr 安全更新的原子值的概念。两者都支持显式锁定库。

Go 提供了通信顺序进程 (CSP)的概念,作为 GRs 在没有显式同步和锁定的情况下进行交互的主要方式。相反,gr 通过通道进行通信,这些通道实际上是与select语句相结合的管道(FIFO 队列)来查询它们。

本文后面将讨论并发方法的其他不同之处。GRs 和线程通常以不同的方式管理,在它们之间传递状态也是如此。

Go 的运行时比 JRE 简单

Go 的运行时比 JRE 提供的要小得多。虽然没有 JVM 的等价物,但是两者都有类似的组件,比如垃圾收集。Go 没有字节码解释器。

Go 有一大套标准库。Go 社区提供了更多。但是 Java 标准和社区库在功能的广度和深度上都远远超过了当前的 Go 库。尽管如此,Go 库足够丰富,可以开发许多有用的应用程序,尤其是应用服务器。

所有使用过的库(仅此而已)都嵌入到 Go 可执行文件中。可执行文件是运行程序所需的一切。Java 库在第一次使用时动态加载。这使得 Go 程序二进制文件(作为文件)通常比 Java 二进制文件(单个“主”类)大,但是当加载 JVM 和所有依赖类时,Java 的总内存占用通常更大。

随着 Java 的解释,动态创建字节码,然后执行它是可能的。这可以通过在运行时编写字节码或动态加载预先编写的字节码(即类)来完成。这带来了极大的灵活性。Go 是预构建的,不能这样做。

Go 程序的构建过程是不同的

Java 程序是在运行时构建的类的组合,通常来自多个来源(供应商)。这使得 Java 程序非常灵活,尤其是通过网络下载时,这是 Java 的一个主要用例。Go 程序是在执行之前静态构建的。启动时,所有代码都在可执行映像中。这以牺牲一些灵活性为代价提供了更大的完整性和可预测性。这使得 Go 更适合容器化部署。

Go 程序通常由“go builder”构建,该工具结合了编译器、依赖性管理器、链接器和可执行构建器工具等。它包含在标准 Go 安装中。Java 类被单独编译(通过 javac 工具,由Java 开发工具包 (JDK)提供),然后通常被组装成保存相关类的档案(JAR/WAR)。程序从这些档案中的一个或多个加载。档案的创建,尤其是包括任何依赖关系,通常是由独立于标准 JRE 的程序(例如, Maven )来完成的。

Go 和 Java 有相似的发布周期

Go 对 1.xx 版本采用了两年一次的发布周期 7 。图 1-1 对此做了最好的总结(来自 Go 网站)。

img/516433_1_En_1_Fig1_HTML.png

图 1-1

两年一次的发布周期

Go 团队支持后两个版本。

Java 为 1.xx 版本采用了类似的两年周期 8 。Java 有一个额外的概念长期支持 (LTS)版本。在提供下一个版本(无论是否是 LTS 版本)之前,将支持非 LTS 版本;至少在下一个 LTS 发布之前,LTS 版本是受支持的。LTS 经常每 18-24 个月来一次。Java 也有实验性特性的概念,这些特性已经发布,但在未来的版本中会有变化(或撤销);它们提供了未来支持的预览。Go 的这种功能较少,但是,例如,类属类型特征可以用类似的方式预览。

Footnotes 1

艾伦·图灵描述了一个通用计算引擎,现在被称为图灵机,可以计算任何可能的计算。任何可以用来创建图灵机的编程语言都被称为“图灵全集”

  2

https://en.wikipedia.org/wiki/Make_(software)

  3

https://en.wikipedia.org/wiki/Apache_Ant

  4

https://en.wikipedia.org/wiki/Apache_Maven

  5

https://en.wikipedia.org/wiki/Von_Neumann_architecture

  6

https://en.wikipedia.org/wiki/Structured_programming#Elements

  7

https://github.com/golang/go/wiki/Go-Release-Cycle

  8

https://dzone.com/articles/welcoming-the-new-era-of-java

 

二、Java 有而 Go 没有的东西

Java 有一些 Go 没有的特性,反之亦然。所以,在我们看一些在 Go 中有相同功能的 Java 特性之前,让我们先简单看一下 Go 没有的特性。并不是每一个 Java 拥有而 Go 没有的特性都可以列出来,但是下面总结了一些关键的特性。

请注意,许多 Go“缺失”的特性被故意省略,以保持语言的简单和高效,而不是因为它们难以提供。这被认为是一种美德。

多重任务

Java 可以在一条语句中将多个变量赋给相同的值。例如:

int x, y, z;
x = y = z = 10;

最接近的 Go 是

var x, y, z int = 10, 10, 10

在 Go 中,分配的类型和值可以不同。

语句和运算符

Go 和 Java 操作符的优先级不同。在作者看来,Go 的优先级更少,也更自然。当有疑问时,使用括号将表达式括起来以确保正确。

一个关键的区别是,在 Go 中x++(表示:x = x + 1)和x--(表示:x = x - 1)是语句,而不是运算符。而且根本没有--x或者++x的表情。

Go 不支持三元表达式。需要使用 if/else 语句。例如,获取较大值的 Java 表达式

var z = x > y ? x : y;

在 Go 中需要像下面这样的东西:

var z = y
if x > y {
   z = x
}

相似但不相同。你也可以这样做:

var z int
if x > y { z = <some expensive int expression> }
else { z = <some other expensive int expression>}

注意前面的if/else必须在一个源代码行中输入。

Go 不支持赋值表达式,只支持赋值语句。

断言语句

Go 没有assert语句。一般来说,Go 有 panics 可以用来实现类似的功能,但是它们不能像断言那样在编译时被禁用。因此,不鼓励这样使用恐慌。

While 和 Do 语句

Java while语句被 Go for语句取代(即for的行为类似于while)。Java do语句没有直接的等价物,但是for语句可以用来代替它。

注意,Java for语句也可以用作while语句。

例如:

var x = 0; for(; x < 10;) { ... ; x++; }

与...相同

var x = 0; while(x < 10) { ... ; x++; }

Throw 语句/Throws 子句

Go 没有throw语句(或throws子句)。Go panic (...)功能的作用与投掷动作类似。

Strictfp、瞬态、易变、同步、抽象、静态

Go 没有这些 Java 修饰符的等价物。大多数是不需要的,因为在 Java 中需要它们的问题在 Go 中以不同的方式解决了。例如,通过将声明的值作为顶级(也称为包)值来实现静态值的等效。

对象和类(OOP)和内部类,Lambdas,this,super,Explicit 构造函数

Go 不像 Java 那样完全支持面向对象编程 (OOP)。因此,它不支持这些 Java 结构。Go 具有本文稍后描述的特性,可以类似于这些 OOP 特性中的大部分来使用。因此,更好的描述是,Go 是一种基于对象的语言*。Go 确实允许一个人实现 OOP 的关键目标,但是以不同于严格的 OOP 语言通常会做的方式。*

Go 不支持真正的(即 Java class声明)。Go 确实支持结构,它们类似于类,但没有继承。Go 确实允许嵌套结构,这有点像内部类。

Go 在类型声明中没有extendsimplements子句。按照这些条款的规定,Go 没有继承。Go 的接口类型确实有一种隐含形式的implements

Go 不支持 Java Lambdas (编译成类实例的函数签名)。相反,Go 支持可以作为参数传递的一级函数(通常是文字)。Go 不支持方法引用(作为参数传递的 lambdas 的简单名称)。

Go 支持接口的方式与 Java 不同。Go 的接口允许鸭子打字。Go 的接口不要求显式实现(Go 中不需要implements子句);任何具有与接口的所有方法相匹配的方法的类型都会隐式实现接口。总的来说,Go 的做法更加灵活。

Java 8 和更高版本允许在接口中实现(具体的)方法。Go 不会。Java 允许在接口中声明常量;Go 不会。Java 允许在接口中声明子类型。Go 不会。

考虑 OOP 的这些租户:

  1. 一个对象有一个标识(它可以与所有其他对象区分开来)。

  2. 一个对象可能(通常确实)有状态(也称为实例数据、属性或字段)。

  3. 一个对象可能(通常确实)有行为(也称为成员函数或方法)。

  4. 一个对象由一个称为类的模板来描述/定义。

  5. 类可以被安排在一个(继承)层次结构中;实例是层次结构中类的组合。

  6. 对象实例被封装;状态通常仅通过方法可见。

  7. 变量可以在类层次结构中的任何级别声明;子类的实例可以分配给这些变量(多态性)。

Java 支持(但不一定强制)所有前面的租户。Go 不会。Go 对这些租户的支持如下:

  1. struct 实例有一个地址,该地址通常可以作为其标识(但可能不总是唯一的);结构实例类似于对象实例,但并不完全相同。

  2. 结构实例可能(通常确实)有状态。

  3. 结构实例可能(通常)有行为。

  4. 像类一样,结构实例由称为结构类型的模板描述/定义。

  5. 不直接支持;结构可以嵌入提供类似组合的其他结构。

  6. 支持但通常不使用(结构字段通常是公共的)。

  7. 不支持。

从历史上看,面向对象程序设计语言源于计算机模拟 1 和改善人机交互的愿望。2OOP 语言被设想用来实现模拟对象之间的消息传递以影响行为。随着 OOP 改进的行为重用可能性(即继承)变得众所周知,它作为一种编程风格越来越受欢迎。大多数现代语言都提供了这种能力。

对许多人来说,Go 缺乏完整的 OOP 可能是它最大的缺点。但是作者希望,一旦你习惯于做习惯性的 Go 编程,你就不会像最初想的那样怀念 OOP 的特性。Go 是一种设计良好、功能丰富的语言,它支持 OOP 的目标,而不包含其他语言(如 Java)复杂的 OOP 特性。

请考虑 OOP 并不是写好程序所必须的。所有现存的 C 程序,一些大而丰富的,如操作系统 3 和网络浏览器,证明并非如此。事实上,有时候 OOP 思维会在程序上强加不适当的结构。再说一遍,Go 是一种类 C 语言。

实现高水平的重用不需要 OOP。函数可以很好地扮演这个角色,尤其是当它们是第一流的时候。

泛型类型和方法

Go 目前不支持泛型类型和任意类型上的方法。这里,泛型意味着能够持有/使用多种类型。在 Java 中,Object类型的变量是泛型的,因为它可以保存任何引用类型的值。在 Go 中,interface{}类型的变量是通用的,因为它可以保存任何类型的值。

Java 5 细化了这个概念,声明的类型(比如容器类)可以被指定为只支持特定的(而不是所有的)类型(比如字符串或数字)作为容器类型的修饰符,例如,List<String>(而不是仅仅List)类型。Go 的内置集合类型(切片、地图和通道)在这方面是通用的。

最初,Java 不支持特定类型的泛型类型。它们是在 Java 5 中引入的,主要是为了缓解该语言中存在的集合的某些可用性问题。由于向后兼容性,Java 的通用设计有一些不理想的特性/妥协。

目前,有一个关于在 Go 中添加泛型的提议得到了批准,其原因与在 Java 中添加泛型的原因大致相同。看起来 Go 将会步 Java 的后尘。

Java(和 Go)定义的泛型类型主要是为了去除重复编码的语法糖。在 Java 中,它们根本不会影响运行时代码(因为运行时类型擦除)。在 Go 中,它们可能会导致可执行文件中存在更多的二进制代码,但不会比手动模拟更多。

广泛的函数式编程能力

Go 支持一级函数,但不支持典型的广义效用函数(map、reduce、select、exclude、forEach、find 等。)最具功能性(强烈支持功能性编程范例)的语言和 Java(通过其 Lambdas 和 Streams 支持)提供。这种省略是 Go 语言设计者故意做出的决定。当包含泛型时,Go 可能会添加一些这样的实用函数。

原始价值观的拳击

Java 集合(数组除外)不能包含原始值,只能包含对象。因此,Java 为每种原始类型提供了包装器类型。为了使集合更容易使用,Java 会自动将一个原语包装(装箱)到一个包装器类型中,以将其插入到一个集合中,并在从集合中取出该值时将其解包(取消装箱)。Go 支持可以保存原语的集合,所以不需要这样的装箱。注意需要使用装箱是 Java 在内存使用方面不如 Go 有效的地方。

源注释

Go 没有注释。Go Struct 字段可以有标签,它提供了一个类似的但是更加有限的角色。

注释,以及函数流和 lambdas,使 Java(至少部分地)成为一种声明性语言。 4 Go 几乎纯粹是一种命令式语言。 5 这是通过选择。这往往会使 Go 代码变得更加明显和冗长。

Note Go 与 Java 编译时注释有类似的概念,源文件可以包含特殊的注释(称为构建约束),构建器解释这些注释以改变代码的处理方式。例如,要为其生成代码的目标操作系统可以在源文件的最开头通过这样的注释来指定:

// +build linux,386

这将导致该文件仅适用于 Linux 操作系统(OS)和基于 386 的体系结构。

有一种替代的(通常是首选的)语法;前面的注释也可以写成

//go:build linux,386

注意,一些约束条件,比如目标操作系统和/或硬件架构,可以嵌入到 Go 文件名中。例如

xxx_windows.go

将只为 Windows 操作系统构建。

多重可见性

Java 支持四种可视性:

  1. private–只有包含类型中的代码可以看到它。

  2. 默认–只有同一个包中的代码可以看到它。

  3. protected–只有同一包或该类型的子类中的代码才能看到它。

  4. public–任何代码都可以看到它。

Go 只支持默认可见性(在 Go 中通常称为 private 或 package)和公共可见性。地鼠通常将公共可见性称为“导出可见性”,将私有可见性称为“未导出可见性”

过载/覆盖的函数

在 Java 中,可以在同一个作用域中定义名称相同但签名不同(参数数量和/或类型不同)的函数。这些被称为(通过一种参数多态性的形式)重载函数。Go 不允许重载。

在 Java 中,具有相同名称和签名的函数可以在继承层次结构中被重新定义。这种重新定义的功能被称为(通过继承多态性)覆盖。因为 Go 不支持继承,所以不允许这样的覆盖。

正式列举

Java 有正式的枚举类型,它们是特殊用途的类类型,具有离散的静态实例,以便于与 sameness ( ==)操作符进行比较。Go 不会。相反,它对整数类型的常量使用了iota运算符。在 Java 中,枚举值可以基于几种类型(但整数是常见的);在 Go 中,只允许整数类型。

注意,Java 枚举是类,可以像任何其他类一样拥有字段和方法。他们也支持继承。Go 枚举没有类似的特性。

内置二进制数据自序列化

Java 提供了以二进制形式序列化(转换为字节序列,在这个用例中通常称为八位字节 6 )数据和对象的能力。Data{Input|Output}Stream和(子类)Object{Input|Output}Stream类型提供 API 来做这件事。序列化数据通常被写入文件或通过网络传输,有时存储在数据库中。序列化可以为原本短暂的对象提供一种持久性形式。序列化也是大多数远程过程调用 7 (RPC)机制的基础。

Java 支持原语值、数组和任何包含原语类型或任何标有Serializable接口的类型以及这些类型的任何集合的数据结构(类实例)的序列化。Java 甚至支持带有引用循环的结构。

Go 没有提供这种完全对象序列化的直接等价物。在 Go 中,人们通常将数据序列化为某种文本格式(比如 JSON 或 XML ),然后保存/发送该格式。使用文本通常不如二进制表示有效(需要更多的字节和时间)。这些文本形式通常不支持数据结构中的引用循环。

Go 提供社区支持,比如针对二进制数据的 Google 协议缓冲区、 8 、??。有了标准的 Go 库,人们可以创建定制的二进制格式,这有点乏味。

并发收款

Java 有许多集合实现,每一个都为不同的用例提供了细微的优化。Go 采用了一种更简单的方法,像 Python 和 JavaScript 等其他语言一样,在所有用例中使用单个集合实现,比如一个列表或地图。这在运行时可能不是最理想的,但是它更容易学习和使用。

除了标准的等价物之外,Java 还有几个并发(在多线程中使用时性能良好(低争用))类型和集合。ConcurrentHashMap大概是最通俗的例子。Go 有一些标准的等价库,比如sync.Map类型。一般来说,这种并发类型在 Go 中的使用频率较低。经常使用替代方法,如通道。

Footnotes 1

https://en.wikipedia.org/wiki/Simula

  2

https://en.wikipedia.org/wiki/Smalltalk;Smalltalk 推出了图形用户界面(GUI)。

  3

www.toptal.com/c/after-all-these-years-the-world-is-still-powered-by-c-programming

  4

https://en.wikipedia.org/wiki/Declarative_programming

  5

https://en.wikipedia.org/wiki/Imperative_programming

  6

https://en.wikipedia.org/wiki/Octet_(computing)

  7

https://en.wikipedia.org/wiki/Remote_procedure_call

  8

https://en.wikipedia.org/wiki/Protocol_Buffers

 

三、Go 和 Java 的深入比较

这一章深入探讨了 Go 与 Java 的早期介绍。它更详细地描述了 Java 和 Go 之间的显著差异。通过比较 Go 和 Java,人们可以更容易地吸收 Go 的特性。

Go 是(在作者看来)比 Java 简单得多的语言;可以说,Go 甚至是比 c 更简单的语言。例如, Java 语言规范目前大约有 800 页长,而 Go 语言规范目前大约有 85 1 页长。显然,Java 比 Go 有更多的语言复杂性。

Go 标准库也是如此。就提供的类型和函数的数量以及纯粹的代码行而言,它们比 Java 标准库小得多。在某些方面,Go 库功能较少,但即使如此,它们的功能一般足以编写许多有用的程序。

与 Java 社区一样,标准库中未包含的功能通常由社区成员提供。在作者看来,Java 库,尤其是社区提供的库,通常比许多相应的 Go 库更成熟。

Java 库通常也更重(做得更多),比相应的 Go 库更难学习和使用。一般来说,对于典型的 Go 用例,Go 库更“大小合适” 2 ,因此,Go 并不缺乏其适用性。考虑到标准 Java 库的大代码库大小迫使 Java 9 将它们分成可选择的模块,这样可以减少 Java 运行时的内存占用。此外,为了进一步减小运行时的大小,许多旧的库已经被弃用(有些现在已经被删除)。

Go 社区大多由 Google 和许多个人或小团队组成。它拥有更少的审查机构,如阿帕奇软件基金会、为 Java 开发关键的第三方库和框架的机构,如阿帕奇软件基金会、阿帕奇软件基金会、阿帕奇软件基金会、阿帕奇软件基金会、阿帕奇软件基金会、阿帕奇软件基金会、阿帕奇软件基金会、阿帕奇软件基金会、阿帕奇软件基金会、阿帕奇软件基金会、阿帕奇软件基金会、阿帕奇软件基金会、阿帕奇软件基金会、阿帕奇软件基金会、阿帕奇软件基金会和阿帕奇软件基金会。

Go 和 Java 5 支持相似但不同的语句和数据类型。它们总结如下。它们将在本文后面更详细地描述。

Go 和 Java 都支持布尔值和字符、整数和浮点数。在 Go 中,一个字符称为一个rune,为 4 个字节;Java 里叫char,2 个字节。两者都使用 Unicode 编码。一般来说,Go 对符文的使用比 Java 的char类型更好,因为任何字符变量都可以表示任何合法的 Unicode 字符。

Java 和 Go 都支持字符串类型,它们实际上是字符数组。在 Go 中,字符串是一种原始类型。Go 在字符串中使用的 Unicode 转换格式 7 (UTF-8)允许许多字符串,特别是对于英文文本,使用比同等的 Java 字符串更少的字节。

在每种语言中,这些类型上的运算符都是相似的。Go 还支持复杂的浮点数,而 Java 不支持。Java 支持大形式的整数和十进制浮点数。Go 支持大形式的整数和二进制浮点数。Go 和 Java 都支持同质值数组。Java 聚合类中的异构值;Go 使用结构。

Java 支持对类实例的引用。Go 使用指针,可以定位任何类型的值。

Java 和 Go 有很多相似的语句。

两者都有赋值语句。两者都增加了(操作员参与的)任务。Go 有多重赋值。

都有ifswitch等条件语句。Go 添加了select。两者都支持循环。Java 有whiledofor语句。Go 只有for

两者都有变量声明语句。Go 为局部变量添加了一个方便的声明和赋值组合。Go 提供了基于任何现有类型的通用类型声明。Java 只能声明类、接口或枚举类型。

Go 和 Java 都有异常能力。Java 可以抛出和捕获Throwable实例;Go 可以从恐慌中崛起和恢复。

在哲学上,Go 与 Java 有一些不同之处:

  • Go 往往遵循“少即是多”的哲学。

  • Java 诞生的最初动机是简化 C++的复杂性。Go 可以从那个角度来看,但是为了简化 C(以及 Java)。例如,在 Go 语言中通常只有一种方法(而 Java 通常有几种方法)来做一些事情。

  • 请注意,Java 的大部分语法都是从 C++语法派生出来的,C++语法是 C 语法的超集,所以 Java 语法也是基于 C 语法的。在较小的程度上,Java 的很多语义都是基于 C++语义的。Go 更面向 C 功能及其支持库。

  • 创建 Go 是为了适应 C 语言这样的利基市场。

  • 与 C++语言相比,Go 与 C 有更多的共同之处(C++是 C 语言的一个大型超集,Java 就是从 C 语言中派生出来的)。它旨在成为一种类似 C 语言的“系统编程”语言,但具有改进的安全性和语义,以满足现代计算机系统的需求,特别是具有改进的多核处理器易用性。Java 就是这样,但它旨在支持更广泛的用例集。

  • Go 在源语法和格式(符号、操作符、标点和空格的使用)上类似于 C(因此也类似于 Java)。因为 Java 也是基于 C 的,所以 Go 和 Java 在这方面也很相似。

  • Go 的语法更简单。

  • 例如,Go 允许分号(“;”的大部分使用)语句结束符在可以隐含时被省略(不存在)。注意,使用省略的语句终止符是惯用的,也是首选的。与 Java 相比,这可以使代码读/写得更干净、更容易。此外,Java 中圆括号((...))的许多用法在 Go 中都被取消了。在关联类型之外定义方法可以使代码更具可读性。

  • Go 与 Java 有不同的优化点/目标。

  • Java 更多的是一种应用(尤其是商业)语言。Go 更面向系统。这些优化点强烈地影响了 Go 语言的设计/本质。像所有的图灵完全语言一样,Java 和 Go 有重叠的适用性领域,在这些领域中任何一个都是合适的选择。

  • Go 通常比 Java 更具命令性和明确性。

  • Java,尤其是如果使用了 Java 8(以及更高版本)的特性,可以比 Go 更具声明性和抽象性。在某些方面,Go 更像 Java 的第一个(1.0)版本,而不是 Java 的当前定义。

  • 在 Go 中,大多数行为都是显式编码的。

  • 行为并不隐藏在 Java Streams 和 Lambdas 所支持的函数式编程特性中。这可能会使 Go 代码在风格上更加重复。错误被显式处理(比如在每次函数返回时),而不是像 Java 那样远程/系统地处理异常。

  • 除了(在功能上受到限制)struct field tags 之外,Go 没有 Java 所具有的注释概念。同样,这是为了让 Go 代码更加透明和明显。注释和任何声明性/后处理(命令性)方法一样,倾向于隐藏或推迟行为。

  • Java 注释驱动方法的一个很好的例子是 Spring MVCJAX-RS 如何在 web 应用服务器中定义 REST API 端点。通常,注释不是在编译时解释,而是在运行时由第三方框架解释。

  • 另一个例子是数据库实体 8 如何被典型地定义为对象关系映射器 9 (ORM)。在这种有限的情况下,Go 通过 struct 标签提供选项,这些标签通常用于通知这些工具。社区提供的GORM10ORM 就是一个例子。内置的 JSON 和 XML 处理器也使用标签。

  • Go 支持(源)生成器的概念。

  • 生成器就是写 Go 代码的 Go 代码。生成器可以由 Go builder 有条件地运行。发电机有许多用例。例如,可以使用生成器来机械地创建集合类型(比如为列表、堆栈、队列、映射等的每个所需 T/K 生成一个类型)。)模仿 Java 泛型类型,但通过预处理器完成。Go 社区提供了这样的选择。

  • Go 支持指针,Java 支持引用

  • 对计算机来说,指针和引用是相似的,但对人类来说,它们是不同的。引用是一个比指针更抽象的概念。指针是保存其他值的机器地址的变量。引用是保存另一个值的定位器(可能是地址或其他东西)的变量。

  • 在 Java 中,引用在使用时总是自动解引用的(除了在赋值中)。指针可以是也可以不是。使用指针,可以获得一些数据的地址,并将其保存在指针变量中,还可以将指针转换为其他类型,比如整数。这对于引用是不可能的。

  • 与 C(或 C++)不同,Java 和 Go 都限制指针/引用来处理特定类型的数据。没有什么比 c 语言的“void”指针更好的了,也没有什么比 c 语言允许的“指针算法”更好的了。因此,Go 和 Java 一样,比 c 语言更安全(不太可能因为寻址错误而失败)

Footnotes 1

在当前 HTML 表单上使用另存为 PDF。

  2

有些人可能会说这些库是精简的,意思是 ??。

  3

www.apache.org/

  4

https://spring.io/

  5

Java 的好总结可以在 www.artima.com/objectsandjava/webuscript/ExpressionsStatements1.html 找到

  6

涉及问题见本文: www.oracle.com/technical-resources/articles/javase/supplementary.html

  7

https://en.wikipedia.org/wiki/UTF-8

  8

https://en.wikipedia.org/wiki/Entity%E2%80%93relationship_model

  9

https://en.wikipedia.org/wiki/Object%E2%80%93relational_mapping

  10

https://gorm.io/index.html

 

四、Go 的关键方面

像在 Java 中一样,在最基本的层次上,Go source 是一个字符流,通常被视为一系列行。Go 文件是用 UTF-8 编码编写的(在 Java 中通常如此)。Go 没有像 Java 一样必须处理 Unicode 转义成原始字符的预处理程序;所有 Unicode 字符都被同等对待,转义只能出现在字符串或字符文本中,而不能出现在标识符或其他地方。

像在 Java 中一样,字符被分组到称为空格的结构中(空格、制表符、换行符等的序列)。)和令牌,Go 编译器解析这些令牌来处理 Go 代码。Go 经常使用空格作为标记分隔符。

除了新行之外,空白序列被视为单个空格字符。在 Go 中,新的一行可以隐式地生成一个分号(“;”)声明安德因此显得有些特别。当遇到行尾时,Go 词法分析器自动添加一个分号,并且在前面的标记后面允许有一个分号。一般来说,在一些大括号({...})或圆括号((...))括起来的列表中,行可以在逗号(“,”)后拆分。

虽然很方便,但这也限制了某些标记相对于彼此必须出现的位置。因此,Go 比 Java 更严格地将源语句排列成行。最重要的是引入块的左大括号(" { ")必须与任何前缀语句位于同一行。你会在这篇课文中看到很多这样的例子。

人们通常认为 Go 程序是一串标记,通常排列在一系列代码行中。标记通常是标识符,其中一些是保留字、分隔符和标点或运算符。

一个简单的 Go 源文件(基于常见的 Hello World 示例)在一个名为main的目录下的一个名为main.go的文件中:

1: package main
2: import "fmt"  // a standard Go library
3: func main() {
4:   fmt.Println ("Hello World!")
5: }

这里,我们在一个源文件中有一个完整的程序。程序入口点被指定在(1) main包中(所有 Go 程序入口点都必须如此)。与 Java 一样,( 3–5)main()函数是必需的入口点。在这里,main就像是 Java 的static方法。该方法使用导入的【??(2,4)】标准库函数来显示消息。像在 Java 中一样,Go 有字符串文字(4)。Go 中的一个区别是字符串是内置的(相对于库java.lang.String)类型。

前面编号的列表形式将不会在本文中进一步使用,因为它会破坏示例。将使用源注释来指出特殊细节。

注意,在 Go 中,与 Java 不同的是,main的命令行参数是通过库函数调用来访问的,而不是作为main函数参数;在本例中不访问它们。

对于 Go 解析器,这个文件看起来像

package main;
import "fmt";
func main() {
      fmt.Println("Hello World!");
}

词法分析器在行尾缺少分号但应该有分号的地方注入分号。这种形式是一种合法的 Go 代码,但不是惯用的。在惯用的 Go 中,以分号结尾的语句通常被省略,除非在一行中输入多个语句,这种情况非常少见。

等价的 Java 程序(可能在Main.java中)是类似的:

public class Main {
  public static main(String[] args) {  // or String... args
    System.out.println("Hello World!");
  }
}

注意,在 Java 中main()是 public,但在 Go 中不是。由于 Go 不像 Java 那样要求函数是某种类型的成员(又名方法)(如系统),所以直接使用打印函数,但不是由所属类限定,而是必须指明所属包(本例中为fmt)。在 Go 中,许多函数的行为类似于 Java 中的static函数(没有关联的实例)。

Java 不要求代码在一个包中(可以使用默认的包),但是强烈推荐一个包,并且通常会提供一个包。所以,Java 的例子通常是这样的

package com.mycompany.hello;
public class Main {
  public static main(String[] args) {
    System.out.println("Hello World!");
  }
}

如果编译器没有自动导入java.lang.*包,这将是

package com.mycompany.hello;
import java.lang.*;
public class Main {
  public static main(String[] args) {
    System.out.println("Hello World!");
  }
}

除了封闭的Main类,这看起来更像 Go 版本。

注意,在 Go 代码中,不需要封闭类(Main)来创建运行程序。这可以减少 basic 程序所需的行数,但也有一个明显的缺点。main函数必须在main包中,一个程序(或源码树)中只能有一个main包。在 Java 中,如果有一个main方法,每个类都可以作为一个不同的程序。

简单的 Go 程序示例

作为一个简单的输出命令行参数的 Go 程序的例子,考虑一下 Java 和 Go 的一组变体。这些例子可以让你更深入地理解 Java 和 Go 编码风格的异同。这些示例使用 Go iffor语句。虽然很像它们的 Java 对等物,但您可能想看看它们的描述。

在 Go 中,第一个程序参数(Args[0])是程序名(总是存在),后面是在命令中输入的任何空格分隔的参数。注意,在 java 中args[0]不是程序名(可能是“Java”或者正在运行的类名),就像在 Go 中一样,但是为了简单起见,在这些例子中我们将假设它是程序名。

注意在下面的 Go 示例中,使用了表达式<variable> := <expression>,通常称为短声明。这是一个简短的形式

var <previously undeclared variable> <type of the expression>
<variable> = <expression>

注意前面的行是两个不同的源代码行,而不是一个单独的换行行。

短声明通常可以嵌入到其他语句中,比如iffor。如果至少有一个变量是新声明的,那么可以声明和赋值多个变量。

首先在 Java 中:

package com.mycompany.args;

class Main {
  public static void main(String[] args) { // or String... args
    var index = 0;
    for (var arg : args) {
      if (index++ == 0) {
        System.out.printf("Program: %s%n", arg);
      } else {
        System.out.printf("Argument %d: %s%n", index, arg);
      }
    }
  }
}

然后在 Go 中:

package main
import "fmt"
import "os"

func main() {
      for index, arg := range os.Args {
            if index == 0 {
                  fmt.Printf("Program: %s\n", arg)
            } else {
                  fmt.Printf("Argument %d: %s\n", index, arg)
            }
      }
}

(在 Microsoft Windows 上)运行方式:...\go_build_main_go.exe 1 2 3

它产生以下输出:

Program: ...\go_build_main_go.exe
Argument 1: 1
Argument 2: 2
Argument 3: 3

注意,可执行文件的名称可以(而且经常)不同;这里它是由使用的 IDE 定义的。

这里导入了多个 Go 包。os.Args顶层变量用于获取命令行参数。

考虑这个稍微不同的替代功能(从现在开始只显示主要功能):

func main() {
      for index, arg := range os.Args {
            if index == 0 {
                  fmt.Printf("Program: %s\n", arg)
                  continue
            }
            fmt.Printf("Argument %d: %s\n", index, arg)
      }
}

这段代码被格式化为更加惯用的 Go 风格,其中很少使用else子句;相反,使用短路动作(如breakcontinuereturn)。Go 风格是尽可能地左对齐代码(或者避免深度代码块嵌套)。

首先在 Java 中,另一个实现是

public static void main(String[] args) {
  System.out.printf("Program: %s%n", args[0]);
  for (var index = 1; index < args.length; index++) {
    System.out.printf("Argument %d: %s%n", index, args[index]);
  }
}

现在在 Go 中:

func main() {
      fmt.Printf("Program: %s\n", os.Args[0])
      for index := 1; index < len(os.Args); index++ {
            fmt.Printf("Argument %d: %s\n", index, os.Args[index])
      }
}

这两种方法(对于循环类型)都是常用的;在作者看来,第一种形式更可取。

另一个替代方法,首先在 Java 中,是

public static void main(String[] args) {
  for (var index = 0; index < args.length; index++) {
   switch (index) {
      case 0:
        System.out.printf("Program: %s%n", args[index]);
        break;
      default:
        System.out.printf("Argument %d: %s%n", index, args[index]);
    }
  }
}

现在在 Go 中:

func main() {
      for index, arg := range os.Args {
            switch {
            case index == 0:
                  fmt.Printf("Program: %s\n", arg)
            default:
                  fmt.Printf("Argument %d: %s\n", index, arg)
            }
      }
}

或者

func main() {
      for index, arg := range os.Args {
            switch index {
            case 0:
                  fmt.Printf("Program: %s\n", arg)
            default:
                  fmt.Printf("Argument %d: %s\n", index, arg)
            }
      }
}

这两种形式都可以被认为是最好的形式。第二种 Go 形式更类似于 Java 代码。

请注意 Java 和 Go 代码之间的高度相似性。最大的区别在于语句语法。Go 通常使用较少的分隔符。注意,Go 的 switch 语句中不需要break

另一个例子是清单 4-1 中所示的一个完整而简单的 web 应用程序。一个等价的 1 Java 例子,尤其是只使用标准的 JSE 库(比如说一个 JAX-RS 框架),会非常大,所以不包括在内。

注意,在 Go 声明中,类型出现在声明的名称之后,而不是之前。通常,变量的类型由初始值的类型暗示,因此被省略。

package main

import (
      "net/http"
      "log"
      "math/rand"
)

var messages = []string{
      "Now is the time for all good Devops to come the aid of their servers.",
      "Alas poor Altair 8800; I knew it well!",
      "In the beginning there was ARPA Net and its domain was limited.",
      // assume many more
      "A blog a day helps keep the hacker away.",
}

func sendRandomMessage(w http.ResponseWriter, req *http.Request) {
      w.Header().Add(http.CanonicalHeaderKey("content-type"),
            "text/plain")
      w.Write([]byte(messages[rand.Intn(len(messages))]))
}

var spec = ":8080"  // means localhost:8080
func main() {
      http.HandleFunc("/message", sendRandomMessage)
      if err := http.ListenAndServe(spec, nil); err != nil {
            log.Fatalf("Failed to start server on %s: %v", spec, err)
      }
}

Listing 4-1Sample Complete but Minimal HTTP Server

这里,我们启动了一个 HTTP 服务器(通过ListenAndServe),它在每个对“/message”路径的请求上返回一个随机消息(对于任何 HTTP 方法,这不是典型的)。服务器运行(ListenAndServe不返回)直到被用户终止。服务器自动返回许多错误(如 404)和成功(如 200)状态。该网站上的浏览器可能会显示您在图 4-1 和 4-2 中看到的内容。

img/516433_1_En_4_Fig1_HTML.jpg

图 4-1

HTTP 获取随机消息服务器 1

img/516433_1_En_4_Fig2_HTML.jpg

图 4-2

HTTP 获取随机消息服务器 2

注意这个例子的简短。服务器函数的核心只需要四行代码。这种简洁性对于大多数 Java 库或框架来说是不可能的。

在所有这些例子中,代码一般都是不言自明的,而对于 Go 代码,即使对 Go 语言没有什么预先了解,也希望你能遵循它。这证明了 Go 语言及其运行时库的简单性和透明性。

Go 包

Go 代码和 Java 代码一样,被组织成。在 Go 中,包不仅仅是类型(类、接口、枚举)的集合,也是变量、常量和函数的集合。Go 包可以是空的。所有 Go 代码必须在某个包中。

Go 包类似于 Java 包:

  • Go 包也代表一个物理结构,通常是一个文件系统目录。

  • 同一个包目录中的所有 Go 源文件用相同的声明包名逻辑地组合成一个包,就像源文件被连接在一起一样。所有这样的源文件都需要在同一个目录中,这个目录通常与包同名(除了一种情况)。

  • 注意软件包目录可能包含非 Go 文件。目录必须至少存在一个.go源文件,才能被视为一个包。

  • Go 包也可以包含子包。Go 使用正斜杠(“/”)来分隔导入路径中的包名,而 Java 使用句点(“.”).Go 引用名称;Java 没有。像在 Java 中一样,每个子包都独立于其父包(即子包没有查看父包内容的特殊能力,反之亦然)。这种安排完全是为了方便。

  • 要被不在包中的代码使用,包需要通过import语句导入。没有像 Java 提供的那样,使用完全限定名(例如java.util.List)而不导入它。Go 没有等同于java.lang.*的 Java 自动导入。

  • 导入通常使导入包的所有公共成员对导入包可见。包的私有成员不能导入到其他包中。

  • Go 不支持导入包的单个标识符;包中的所有公共标识符总是被导入。这不是一个冲突问题,因为要使用这些标识符,包别名必须始终用作限定符。

通常按照导入路径中的姓氏对导入进行排序,但这是可选的。Go 格式化工具(gofmt)和一些 ide 将为您完成这项工作。因此,将重做该顺序,将rand放在最后:

import (
          "net/http"
          "math/rand"
          "log"
)

通常,Go 工具会在处理源代码时对其进行编辑。对于 Java 工具,这通常是不正确的。

Go 不允许同一导入在一个源文件中存在多次。它也不允许导入未在源文件中使用的内容。这可能很烦人。许多 ide 会为您添加任何缺失的导入,并移除未使用的导入。

包声明和任何导入必须位于每个源文件的最前面;其他成员可以以任何顺序出现在同一个包的任何文件中,但是结构声明应该出现在任何关联的(方法)函数定义之前。

在 Java 中,一个包中的类型可以分布在许多源文件中,但是每个类型必须在一个源文件中完成(对于任何类型的声明)。Java 源文件的一般结构是

  • 包装声明

  • 有进口的吗

  • 顶级类型(“类”、“接口”、“枚举”)声明,包括所有成员

Java 源文件由一个或多个带有相关注释的顶级类型定义组成。Java 只允许每个源文件有一个公共顶级类型,但是可以有任意数量的默认可见性顶级类型。大多数源代码只有一个类型声明。生成的类文件将被逻辑地组合成一个名称空间,也称为包。

Go 源文件由一个或多个带有相关注释的顶级(公共或私有)变量、常量、函数/方法或类型定义组成。在 Go 中,包的内容,包括包中定义的类型,可以分布在许多源文件中。Go 源文件的一般结构是

  • 包装声明

  • 有进口的吗

  • 顶级变量(“var”)声明

  • 顶级常量(“const”)声明

  • 顶级函数(“func”)声明

  • 顶级类型(“类型”)声明

请注意,顶级项目可以按任何顺序出现,也可以混合出现。

转到评论

Go 和 Java 一样,允许在源代码中添加注释。Go 注释很像 C 语言,因此也很像 Java 注释。

像 Java 一样,Go 有两种风格的注释:

  • 行(也称为备注)-从“//”开始,直到行尾。

  • block–以“/”开头,以“/”结尾。这种风格的评论可以而且经常跨越行。不允许嵌套块注释。

Go 没有 JavaDoc ("/**...*/)注释。相反,Go 文档工具特别注意在package语句或任何顶级声明之前的注释。由于一个包可以有许多源文件,所以通常创建一个只有文档的源文件(通常称为doc.go),它只有前缀为包注释的package语句。

Go 中的最佳实践是注释任何公共声明。例如:

// PrintAllValues writes the formatted values to STDOUT.
// The default formatting rules per value type are used.
func PrintAllValues(values... interface{}) {
      :
}

or as:
/*
PrintAllValues writes the formatted values to STDOUT.
The default formatting rules per value type are used.
*/
func PrintAllValues(values... interface{}) {
      :
}

注意左边没有星号(“*”),这在 Java 中很常见。

Go“doc”服务器从 Go source 中的这些注释创建 HTML 文档,就像 JavaDoc 工具为 Java 源代码所做的那样。这个注释文本是纯文本(而不是 Java 中的 HTML)。在这个文本中,缩进的文本被原样采用(就像 HTML 中的<pre>...</pre>)。左侧对齐的文本换行。

每个注释的第一句(或唯一一句)很特殊,因为它包含在摘要文档中。这应该足以确定注释项的目的。

请参考 Go 软件包文档,查看 Go 代码文档的一般样式和详细信息的示例。

开始构建/运行流程

Go 开发体验与所有编译(相对于解释)语言非常相似,包括 Java。它通常由以下步骤组成:

  1. 编辑源文件–使用一些编辑器。

  2. 编译源文件–使用 Go builder。

  3. 修复任何编译器错误——使用一些编辑器。

  4. 构建可运行–使用 Go 构建器。

  5. 测试变更–使用 Go builder 和/或第三方测试工具。

  6. 发布代码。

许多内部循环可能以这种顺序出现。整个序列可以重复。假设没有发生编译器错误,第 2、4 和 5 步可以通过一个命令来完成。

注意就构建而言,Java 是一种编译语言。生成的字节码通常在运行时被解释,这在这里并不重要。

有许多工具可以帮助开发人员完成每一步。最基本的方法是在步骤 1-3 中使用文本编辑器和命令行编译器。然后是第四步的程序生成器。步骤 5 可以用调试器和/或测试用例运行器来完成。

通常,这些工具被组合成一个集成开发环境(IDE)。通常,步骤 1–3 由 IDE 代码编辑器假定(即,代码按照键入的方式(交互式)进行编译,并立即显示错误)。

Go 有多种选择。用于 Go 的 IDE 包括 IntelliJ IDEA (或者等效但独立的 IntelliJ Goland IDE)和一些基于 Eclipse 的产品。 S ome 编辑器,比如微软的 Visual Studio Code (VS Code),在某种程度上也可以充当 ide。ide 很方便,因为它们通常将编辑器、编译器、格式化程序、审查工具、构建器、调试器,以及通常的部署工具组合在一起。ide 经常减少使用命令行工具的需要,这通常是有帮助的。

请注意,本文中几乎所有的代码都是使用 IDEA 开发的,而不是通过编辑器和 Go 命令行工具。不使用 IDE,只使用 Go 运行时工具和编辑器,仍然可以成功开发 Go 代码。

因为开发 Go 代码的方法多种多样,所以本书不会在这方面提供太多指导。每个工具通常都为如何设置和使用提供了很好的指导。Go 本身提供了帮助完成第 2、4 和 5 步的工具。

去游乐场

如果你还没有安装 Go 或 Go IDE,作者建议你使用Go Playground(play . golang . org),这是一个交互式网站,可以让你输入并运行大多数 Go 代码。

游乐场这样描述自己(关于按钮):“Go 游乐场是一个运行在 golang.org 服务器上的网络服务。该服务接收 Go 程序,审查、编译、链接并在沙箱中运行该程序,然后返回输出。**

*操场是 Go 对 REPL(读取、评估、打印、循环)过程的逻辑替代,是许多语言的典型,包括 Java。

通常,前面的序列只需要几秒钟。考虑到所涉及的额外网络开销,这证明了 Go 构建和程序启动过程有多快。

图 4-3 显示了刚刚运行显示的代码后的操场截图。

img/516433_1_En_4_Fig3_HTML.jpg

图 4-3

去游乐场你好世界跑

人们可以输入 Go 代码并运行它。这里,我们在底部的输出窗格(白色)中看到了运行示例程序(在黄色/阴影窗格中)的结果。操场限制;不支持某些 Go 库函数。更多信息参见关于按钮文本。

游乐场提供了几个例子/背景。例如,它可以运行 Go 测试用例,如图 4-4 所示。

img/516433_1_En_4_Fig4_HTML.jpg

图 4-4

Go 操场测试用例运行

playground 将允许您运行代码(如果复杂,通常从一些编辑器中复制/粘贴),就像来自多个源文件一样。比如看图 4-5 。

img/516433_1_En_4_Fig5_HTML.jpg

图 4-5

Go Playground 多个源文件示例

注意,这个例子展示了 Go 模块的使用(它有一个go.mod文件)。

操场提供了有限的分担工作的能力。活动代码(最大 64KiB)可以保存在谷歌托管的数据库中,并通过共享按钮共享其 URL。一旦共享,片段 URL 可以被加载到另一个浏览器供其他人查看,如图 4-6 和 4-7 所示。

img/516433_1_En_4_Fig7_HTML.jpg

图 4-7

访问共享代码示例

img/516433_1_En_4_Fig6_HTML.jpg

图 4-6

分享代码示例

Go 集成开发环境

一个 IDE 可以给出更丰富的体验,比如下面的 IntelliJ IDEA 截图。例如,它允许在不同的窗口中同时打开多个源文件。注意此示例显示了顶点计划的一个来源。一般来说,错误报告在 IDE 中更好。

这个 IDEA 截图有许多视图(选项卡),包括一个控制台和导航层次结构。它有一个内置的调试器。它与 Git 2 (可能还有其他)源代码控制系统(SCCS)直接集成。它可以做相当于许多“走……”命令等等。因此,在使用 IDE 时,经常会直接使用“go ...”需要命令。

注意图 4-8 (图 4-9 中放大)右上方的按钮条。有一个绿色箭头(向右的三角形)运行按钮来构建和运行程序。在 Run 按钮旁边有一个绿色的{de} Bug 按钮,用于在调试器中构建和启动程序。两者的行为都很像使用“go run”命令。

img/516433_1_En_4_Fig9_HTML.jpg

图 4-9

主意菜单栏的放大

img/516433_1_En_4_Fig8_HTML.jpg

图 4-8

IntelliJ IDEA Goland 视图

Visual Studio Code (VS Code)是一个可供选择的类似 IDE 的工具,你可以使用。图 4-10 显示了经典 Hello World 示例的一个变体,以及它运行时产生的输出。

img/516433_1_En_4_Fig10_HTML.jpg

图 4-10

Go 示例的 Visual Studio 代码

VS 代码使用 Go 1.16 运行时,因此(默认情况下)需要一个go.mod文件。这个程序在BAFGoPlayground目录下有一个最小的:

module hellogophers
go 1.16

类似的体验,如图 4-11 所示,可供 Java 开发者使用。所以,如果你使用 IntelliJ IDEA,或者 Eclipse,或者任何其他主流 IDE,从 Java 迁移到 Go 应该很简单。

img/516433_1_En_4_Fig11_HTML.jpg

图 4-11

Eclipse IDE Java 视图

有些 ide 会在代码输入时在源代码视图中检测并显示所有编译时错误。如果没有显示错误,代码将启动。其他 ide 只能检测可能错误的子集。只有在代码启动时,才会检测到任何其他错误。

发生这种情况是因为每个 IDE 都有自己的 Go 编译器(或者只是 Go 解析器),它与go命令使用的不同。这些不同的编译器检测错误的方式可能不同。一般来说,运行的是 Go 编译器在启动时生成的代码(不是 IDE 编译器生成的代码,如果有的话)。

深入查看 IDEA 中启动的 Go 程序,如图 4-12 所示,您可以看到 IDE 控制台如何显示 Go 环境、用于构建程序的命令(由 IDE 用 # gosetup 标记,不是实际值的一部分),以及在显示任何程序输出之前构建的程序。这是通过调试按钮启动的:

img/516433_1_En_4_Fig12_HTML.jpg

图 4-12

IntelliJ IDEA 控制台视图

GOROOT=C

:\Users\Administrator\sdk\go1.14.2 #gosetup
GOPATH=C

:\Users\Administrator\go #gosetup
C:\Users\Administrator\sdk\go1.14.2\bin\go.exe build -o C:\Users\Administrator\AppData\Local\Temp\2\___go_build_main_.exe -gcflags "all=-N -l" . #gosetup
C:\Users\Administrator\.IntelliJIdea2019.3\config\plugins\intellij-go\lib\dlv\windows\dlv.exe --listen=localhost:58399 --headless=true --api-version=2 --check-go-version=false --only-same-user=false exec C:\Users\Administrator\AppData\Local\Temp\2\___go_build_main_.exe -- -n tiny1 -u file:/Users/Administrator/Downloads/tiny1.png -time #gosetup
API server listening at: 127.0.0.1:58399
Command arguments: [-n tiny1 -u file:/Users/Administrator/Downloads/tiny1.png -time]
Go version: go1.14.2
:
Starting Server :8080...

前面的粗体行来自程序本身。它们来自这个来源:

fmt.Printf("Command arguments: %v\n", os.Args[1:])
fmt.Printf("Go version: %v\n", runtime.Version())
:
fmt.Printf("Starting Server %v...\n", spec)

运行 Go 程序

Go 编译器(名义上通过“go build”、“go run”或“go test”命令运行)创建可执行二进制文件(EXE 3 )。生成的 EXE 可以从操作系统(OS)命令行运行。与 Java 不同,Java 运行时环境 (JRE)及其 Java 虚拟机 (JVM)并不是运行程序必须具备的先决条件。

Go EXE 是独立的,只需要标准的操作系统功能;所有其他必需的库都嵌入在 EXE 中。EXE 中还嵌入了 Go 运行时 (GRT)。这个 GRT 类似于 JRE,但比它小得多。GRT 没有正式定义,但它至少包括内置的库函数、一个垃圾收集器,以及对 goroutines 的支持(就像轻量级 Java 线程)。

这意味着对于源代码来说,不存在等同于包含字节码*(目标代码)的 Java .class(又名目标)文件。这也意味着没有包含这些类文件集合的 Java 档案 (JAR)文件。*

*假设一个 Go 程序内置在一个 EXE 文件中(在这个例子中是 Windows 的),比如说myprog.exe,要运行它,只需做一件事($>是操作系统的命令提示符):

$>myprog arg1 ... argN

然而,假设该类被编译到当前目录下的MyProg.class中,在 Java 中需要做:

$>java -cp .;<jar directory>... MyProg arg1 ... argN

这里,启动一个 JVM ( java.exe),它必须包含在 OS 路径中,并向它提供任何所需的类目录和/或 JAR 文件的位置。像 Python 和其他解释器一样,JVM 将开发的程序(a .class)作为参数并运行它,而不是操作系统。

构建 Go 程序

一般来说,任何 Go 代码都是从源代码构建的,包括所有需要的源文件(您的代码和任何其他库)。这确保了对任何源文件的所有更改(编辑)都包含在生成的 EXE 文件中。它还允许潜在的跨源文件优化发生。

在 C/C++中,通常需要使用 Make 实用程序来确保所有依赖于其他文件中的更改的代码得到重新编译。Java 编译器有条件地(基于源文件时间戳)重新编译其他类依赖的类。Go 和 Java 都依赖包结构来找到所有引用的源文件。

虽然 Go 方式可能看起来效率较低,并且可能较慢,但 Go 编译器通常非常快,除了大型程序,人们很少注意到每次编译所有源代码所花费的时间。当编译器检测到软件包的源文件自上次编译以来没有发生变化时,它可以创建类似于 Java JAR 文件的预编译软件包档案,以缩短编译时间。

一些开发环境可能包括将一些源代码(尤其是库类型)预编译成“对象”文件的方法,以缩短构建时间,但 Go 应用程序开发人员通常不使用这种方法。这通常是由社区开发者对库进行的。“go install”命令可以做到这一点。它创建存档文件(带有一个“.”。“扩展”)包含预编译代码。通常,这些存档文件放在“pkg”目录中。

就像在 Java 和大多数编译(相对于解释)语言中一样,所有 Go 源代码都由 Go 编译器在以下阶段进行处理:

  1. 词法分析——逐字符读取源代码,并识别标记。

  2. 解析和验证——逐个标记地读取源代码,并构建抽象语法树 (AST)。

  3. 可选的(但是典型的)优化 AST 被重新安排以使结构更好(通常执行起来更快,但是生成的代码可能更少)。

  4. 代码生成–机器代码被创建并保存到目标文件中;在 Java 中,字节码(JVM 的机器码)被写入类文件。

请注意,在第一阶段中,会添加任何缺少的以分号结尾的语句。

Go builder 的行为很像第三方 Java MavenGradle 构建工具,因为它解析依赖库(针对 Go 编译器和代码链接器)并创建完整的可运行代码集(在 Java 中,通常以一个或多个 JAR/WAR 文件的形式;在 Go as EXEs 中)。Go 构建器添加了以下阶段:

  1. 外部引用链接——代码中使用的所有编译过的源代码和外部库都被解析并集成在一起。

  2. 可执行文件构建–构建特定于操作系统的可执行文件。

  3. 可选执行–可执行文件在生产或测试环境中启动。

构建的源代码可以是应用程序代码和/或任何依赖项(或库)。依赖项通常作为手动前置步骤(即使用go getgo install命令)被提取(作为源文件,或者更常见的是,作为档案)到前面的序列。Go 模块可以使依赖版本的选择更加可预测。

Go builder 可以说比 Java 编译器(javac)更完整。Java 编译器假设程序是由 JVM 在运行时实时(??)组装的,所以没有静态链接和程序构建阶段。由于这种运行时链接,Java 可能在编译时和运行时使用不同的库,这可能会有问题。这在 Go 中不可能发生。

Go 允许为许多操作系统(OS)类型构建代码。确切的集合会随着 Go 版本的变化而变化,但这里有一个样本集合:

  • [计]高级交互执行程序(Advanced Interactive Executive)

  • 机器人

  • 达尔文

  • 蜻蜓

  • 操作系统

  • 赫德

  • 插图

  • 射流研究…

  • Linux 操作系统

  • 氯化钠

  • netbsd

  • 从源代码构建

  • 计划 9

  • 操作系统

  • 窗子

  • 现场备用电系统

Go 允许为不同的硬件(HW)架构构建代码。确切的集合会随着 Go 版本的变化而变化,但这里有一个样本集合:

  • Three hundred and eighty-six

  • amd64

  • amd64p32

  • 手臂

  • 安布尔

  • arm64

  • arm64be

  • ppc64

  • ppc64le

  • 分子印迹聚合物

  • 简单的

  • mips64

  • mips64le

  • mips64p32

  • mips64p32le

  • 告别...

  • riscv

  • riscv64

  • s390

  • s390x

  • 平流层过程及其在气候中的作用

  • sparc64

  • 世界睡眠医学协会

标准 Go 安装包中可能不包含某些操作系统和/或体系结构列表成员。

字节码与真实代码

Go 方法与 Java 形成鲜明对比,在 Java 中,编译器生成与操作系统和硬件无关的字节码目标文件。JVM 负责解释字节码或将字节码转换成依赖于 OS 和/或 HW 的代码。这种转换通常由作为 JVM 一部分的 JIT (实时)或 Hotspot (使用优化)字节码编译器在运行时(而不是编译/构建时)完成。

这种差异是 Go 优于 Java 的一个原因。构建 Go 程序时,所有代码都以可运行的形式解析到它的映像中。操作系统只需要将文件读入内存,然后就可以立即开始执行。在 Java 中,代码是在内存中逐步构建的(许多较小的文件读取),而且代码需要在运行时进行 JIT 和链接。这种增量读取和 JIT 行为会显著降低程序的启动速度。但是一旦启动完成,Java 代码就可以像 Go 代码一样快速运行。此外,在 Java 中,一些需要的类文件可能不可用,导致程序突然失败。Go 不能这样。

所以,有人会问:哪个更快?Java 还是 Go?

答案是,生活中的许多事情都是如此:视情况而定!

由于上述原因,Go 程序往往会启动得更快。一旦加载,图片就不那么清晰了。

Go 是静态编译的,也就是说它也是静态优化的。所有的优化都是由 Go 编译器根据源代码本身的信息来完成的。当使用 Java JIT 编译器时,这是类似的,但是优化是在运行时完成的。但是 Java 也可以有一个 Hotspot 编译器,它使用运行时信息来进行改进的优化。随着运行时条件的变化,它甚至可以重新优化代码。因此,从长远来看,人们可以期待 Java 代码得到更好的优化,从而有可能运行得更快。

然而,程序运行时并不总是依赖于它自己的代码。很多时候,第三方服务(比如数据库系统和远程服务器)可以支配程序的执行时间。再多的优化也无法弥补这一点。但是更好地使用并发编程模式可能会。

与 C/C++等以前的语言相比,Java 最初的优势之一是它相对易于使用并且内置了对操作系统线程的支持。Go 及其 goroutines 本质上比 Java 做得更好。因此,在高度并发编程成为可能的情况下,人们应该期望 Go 在一般情况下胜过 Java。

Java 提供了对 Java 编译器(javac)的运行时访问。这允许 Java 代码创建 Java 源代码,然后编译它。因为 Java 可以在运行时加载类,这允许一种自我扩展的 Java 程序。

Go 有一些类似的支持,通过各种go包标准库子包来处理 Go 代码,但是 Go 不能在运行时可靠地扩展程序。

Go 对动态插件的支持是有限的(依赖于操作系统)和不完整的,其中动态代码是可能的。这是否会最终成为完全支持的功能有待确定。Go 代码可以动态编译和构建,然后可以启动生成的可执行文件(作为一个单独的操作系统进程)。这与 Java 方法有些相似,但是插件必须在不同的进程中运行。

Java 的javac编译器还允许在编译期间运行一些外部代码,允许修改抽象语法树 (AST),它是编译器对解析的 Java 类的内部表示。这允许编译时的注释处理。例如, Lombok 4 工具,可以自动化一些常见的 Java 编码动作,就使用了这种能力。

Go 也有类似的支持。例如,它被用于内置的 Go 格式和林挺工具,但是任何开发人员都可以利用它来构建强大的 Go 语言处理工具。

虽然 Go 通常是操作系统(OS)不可知的,但它不一定是基于 OS 类型的无偏见的。像 Java 一样,Go 被设计成在基于 Unix 的系统上运行。Go 支持 Microsoft Windows(和其他操作系统),但不是主要的操作系统类型。这种偏见表现在几个方面,比如命令行处理和文件系统访问。Go 提供对运行时操作系统类型和硬件架构的访问,因此您的代码可以根据需要进行调整。

Java 和 Go 都可以在代码运行时检测(测量/分析)代码。Java 管理扩展 (JMX)通常允许添加静态和动态测量。Go 的选项更加静态(但是可以在运行时启用/禁用)。两者都允许远程访问这些测量。有关此功能的更多详细信息,请参见 Go 文档。第三方提供这种支持。例如,普罗米修斯5(这是用 Go 写的)可以用来仪器化 Go 代码。

转到命令行工具

有点像 Java,Go 可以内置在模块中。Java 模块允许包显式地声明供其他包使用的类型,并显式地控制要导入的可见包。今天开发的大多数 Java 代码并没有显式地使用 Java 模块,但是它隐式地使用了,因为所有的 JRE 库都是模块化的。Java 模块使得像 Go 程序一样以自包含的 EXE 形式生成 Java 程序成为可能,但这种情况并不常见。

注意,从 Go 1.16 开始,使用模块(即go.mod和相关的库解析)已经成为默认(一个小的突破性改变)。为了获得先前的行为,正如本文中通常采用的(因为任何示例都是小型的和自包含的),需要通过将 GO111MODULE 环境值设置为auto来显式禁用模块。未来的 Go 版本可能会完全取消auto模式。

Go 也有一个类似的选项,开发者可以控制导入哪些包以及包的版本。像在 Java 中一样,如果不使用模块,生活会简单一点,但是当包含来自第三方的库时,它们就变得很重要。除非您正在创建这样的库供他人使用,否则您通常可以忽略自己代码中的模块。Still 模块允许您更好地控制您使用的库,并且它们使得以后将您的代码公开为库变得更加容易,所以推荐使用它们。

Go without using modules 假设所有要一起构建的源代码(即生成一个 EXE)都包含在一组源代码树中(由 GOROOT 和 GOPATH 环境值设置)。通过模块,下载的依赖项也可以在 Go builder 维护的本地缓存中找到。

Go 只允许每个 EXE 有一个入口点(main函数),所以每个程序都需要自己的源代码树(或者主包分支,如果你正在构建多个 EXE 的话)。相比之下,Java 允许每个类型(类)都有自己的入口点(main方法),所以每个类型都可以是自己的程序,独立于包结构。在 Go 中,通常会将多个可执行文件放在一个包含多个子目录的cmd(按照惯例,是src的替代)目录中,每个独立的程序通常包含一个main目录和该可执行文件的一个main包。

许多 Go 工具都采用这种结构。Go with modules 允许每个模块有选择地拥有自己独立的源代码树。这可能更容易管理(比如因为源代码在不同的源代码库中)。我们将在本书的后面讨论模块。

Go 命令中捆绑的工具

除了各种“构建”操作之外,“go”命令还有许多选项。这个命令取代了一大堆不同的 Java 构建工具。下面总结了通过“go”命令可以执行的关键操作:

  • bug–在新的 bug 报告中打开浏览器。

  • 构建–编译代码(按包)和任何依赖项,并生成可执行文件。

  • clean–删除所有生成的对象文件(通常自动完成)。

  • doc——像“Javadoc”命令一样,它创建 HTML 形式的包 API 文档。

  • env–显示 Go builder 和其他工具使用的操作系统环境值。

  • 修复–重写 Go source,用任何替换特征替换删除的特征。由于 Go now 承诺完全向后兼容,所以很少需要这个工具。

  • fmt——重写 Go 代码以符合标准(惯用的)Go 源代码格式规则。通常,IDE 会在输入/保存代码时这样做。

  • 生成–通常用于根据 Go source 中的指令(特殊注释)生成新的 Go source。可以用来替换 Java 通过泛型和注释提供的功能。它被用作“开始构建”之前的预备步骤

  • get——从(通常)公共存储库(如 GitHub)获取(下载并安装)一个依赖项(导入的东西),并构建它。

  • 帮助–显示可用操作的帮助。

  • install–与 Get 相关,Install 安装并编译通过导入引用的代码。

  • list–列出已安装的软件包。

  • mod 下载、安装和管理模块。它有几个子动作。

  • 运行–构建并运行 Go 程序(EXE)。

  • 测试–构建并运行 Go 测试。测试就像程序一样,但是在一个源文件中可以定义多个测试;Go 测试很像 Java JUnit 测试。

  • 工具–列出可以运行的工具(操作)。

  • 版本–显示生成的 EXE 的 Go 版本或其自己的版本。

  • vet——在 Go 代码中寻找可能的问题。很像 UnixLint6工具和各种 Java 代码质量检查器,这可以避免运行时的错误。Java 审查工具的例子包括check style7FindBugs8/spot bugs。通常,IDE 会在输入/保存代码时这样做。

对于最新的列表,使用“go”(无参数)命令。

经常使用“构建”、“运行”和“测试”。有关更多详细信息,请参见 Go 文档。

其他工具

还有一些之前没有列出的独立工具。下面列出了几个很有用的例子。此外,前面的许多操作可以作为独立的工具运行。通常,独立的工具具有更广泛的范围,比如一个完整的源代码树与一个单独的 Go 源代码或包。

“cgo”命令在 go 代码和外语代码(通常是 C 语言)之间创建了一个链接。它的用法很像 Java 原生接口 10 (JNI)用于从 Java 代码调用外语(一般是 C)代码。

今天,JNI 式的代码已经很少见了;大多数 Java 功能都是由纯 Java 产品实现的。CGo 代码在 Go 界更为常见,它是通向现有非 Go 产品的桥梁。在笔者看来,随着时间的推移,Go 在这方面会步 Java 后尘,CGo 代码会逐渐淡出。

“cover”命令用于通过分析在“go test -coverprofile”运行期间生成的统计数据来获得代码覆盖率报告。在 Java 中,必须使用第三方(比如 IDE)工具来获得代码覆盖率。

还有其他 Go 工具。有关更多详细信息,请参见 Go 文档。

Go 运行程序而不是类

Go 没有直接等同于 Java 虚拟机 (JVM)或 Java 运行时环境(JRE——一个 JVM 加上标准 Java 类库)。Go 有一个运行时,它提供了支持 Go 语义所需的功能。这包括用于其集合类型和 goroutines 的库。它还包括一个垃圾收集器来管理堆驻留内存分配。这个运行时比 JRE 小得多(通常只有几 MB,而不是几百 MB)。

您的代码、任何库和运行时都构建(链接)到操作系统(OS)运行的单个可执行文件中。这与 Java 程序组装和链接的即时 (JIT)方式形成了对比。Go 在构建时使用早期(静态)链接。Java 在运行时进行后期(动态)链接。

Go 方法类似于 C/C++(以及其他更古老的)语言中使用的方法。它更传统,但不太灵活(特别是,在运行时不容易向 EXE 添加新代码,而 Java 服务器(以及以前的小程序)经常这样做(即,在运行时通过网络下载代码))。

Go 方法会产生一个自包含的可执行文件(不需要安装任何其他的先决条件,比如 JRE)。这可以使部署比典型的 Java 更容易。这是 Go 在容器化(例如 Docker、Kubernetes)环境中如此流行的一个原因。其他用例也可以从这种更加独立的特性中受益。

Go 正朝着更加独立的方向发展。例如,Go 1.16 增加了将文字内容(比如 HTML、CSS 或 JavaScript 文件等文本的目录)嵌入 EXE 主体的能力,而在过去,这需要交付独立的文件。如果充分利用,一个完整的解决方案,如 web 服务器,可以作为一个单独的二进制文件交付。这种嵌入是通过在声明前添加前缀来完成的,如下所示:

//go:embed <path to file>
var text string  // string data

或者

//go:embed <path to file>
var bytes []byte  // binary data

或者

//go:embed <path to directory>
var fs embed.FS  // file system
dirEntries, err := fs.ReadDir("<path to directory>")
:

或者

//go:embed <path to file>
var fs embed.FS  // file system
bytes, err := fs.ReadFile("<path to file>")
:

<path to ...>值可以包含通配符。详见embed包装说明。

这种自包含有额外的好处,因为丢失的库或数据不可能只在运行时才被发现。这确实意味着可执行文件可以比仅作为归档文件(JAR)交付的 Java 程序大得多,并且假设已经存在可用的 JRE。即使是最小的 Go 可执行映像的大小也只有几兆字节。这可以通过在没有调试信息的情况下构建代码来减少,但不建议这样做。

因为 Go 程序是预先组装的,所以它的加载和启动速度通常比典型的 Java 程序要快(通常只需要几秒钟)。这在容器化环境和无服务器云环境中也有帮助。

Go 方法要求为每个目标操作系统构建可执行文件。在 Java 中,类文件可以跨操作系统移植(它们在运行时被 JIT(编译)成本地代码)。这就导致了著名的 Java 写一次,到处跑(wear)Go 所没有的特性。在 Java 中,依赖于操作系统和硬件架构的是 JVM,而不是程序本身;为每个支持的组合构建一个 JVM 版本。

幸运的是,Go 语言本身通常与操作系统和硬件架构无关,它的大多数库也是如此。很少有库是架构相关的。少数依赖于操作系统的标准库是为一组流行的操作系统提供的,如 Linux、iOS 和 Windows。通常依赖于操作系统的第三方库(一小部分)也是如此。因此,大多数 Go 程序可以跨许多操作系统移植,代价是要构建多次,每个操作系统一次。

转到内存管理

Go 可以在几个位置为值分配空间:

  • 代码图像11–用于顶级值

  • 调用堆栈12–用于多个函数或块局部变量

  • 13——用于动态值或可通过闭包访问的值或动态大小/长度的值

在使用动态内存分配的计算机程序中,最大的错误来源之一是不恰当的内存管理。许多故障,如内存泄漏、内存块的不正确重用、过早释放内存等。,经常会导致灾难性的程序失败。像 Java 一样,Go 通过提供自动内存管理来避免这些问题。

与 Java 一样,Go 提供了一个自动(也称为托管或垃圾收集)堆内存管理功能,该功能提供了以下关键功能:

  1. 为对象(Go 中任何数据类型的实例)分配空间

  2. 自动回收任何未引用(通常称为死的或不可访问的)对象的空间

对象被动态地分配在函数调用栈中,或者像 Java 一样,被分配在堆中。与 Java 一样,Go 提供了垃圾收集 14 (GC)堆内存分配/释放。

所有基于堆的对象都被垃圾收集。当所属函数返回或所属块退出时,所有基于堆栈的对象都被释放。对于这两种情况,都没有程序员可以使用的方法来释放它们。像在 Java 中一样,对堆对象的唯一控制是将指向不需要的对象的指针设置为nil

Java GC 实现将对每个即将被回收的对象调用finalize()方法。对于许多类型,这个函数什么也不做,但是它可以进行清理活动。Go 提供了一个类似的功能,但是它并不是通用于所有的分配。任何需要在 GC 时清理的分配对象都必须向 Go 运行时显式注册,这样它才会被清理。为此,我们使用

runtime.SetFinalizer(x *object, fx(x *object))

其中object是任意类型,它接受一个指向x的指针,并在一个 goroutine 中对其运行fxx值被自动取消注册,可以在下一次 GC 时释放。

像在 Java 中一样,堆对象通常使用new函数来分配。通过获取对象文字或变量的地址,也可以将对象放在堆上。

像 Java 一样,当 GC 运行时,让 GC 机制使代码暂停。GC 可能在任何堆分配上发生,并且发生的时间通常是不可预测的。这是使用垃圾收集的主要缺点。

Java 有几个 GC 实现的原因之一是试图将这些暂停调整到自然状态(批处理/命令行、交互式、服务器等)。)的程序。注意 Go 和 Java 一样,有一个 API,runtime.GC (),允许用户强制执行 GC,通常是在可以更好地容忍暂停的时候;这可以创造更多的可预测性。

最简单的 GC 方法被称为标记-清除,Go 实现可能(并且通常会)使用这种方法。它有两个阶段:

  1. 标记–所有对象都标记为不可访问,然后所有可从每个引用根访问的对象都标记为可访问。

    根是具有指针字段的任何顶级指针或对象(结构)以及任何活动调用堆栈中的任何类似指针和结构。完成从每个根开始的参考树遍历。

  2. sweep–释放(或回收)所有仍标记为不可访问的对象。

有关标记清除收集器的更多详细信息,请参见附录 D。

为了防止在 GC 期间这些根发生变化,所有活动的 goroutines 可能都需要暂停。这通常被称为停止世界 (STW)。所以实际上,Go 程序在这段时间内不做任何工作。Go 团队一直在努力减少 STW 暂停持续时间;现在,在现代机器上,大多数都不到一毫秒,因此是普遍可以接受的。

GC 算法的评级依据是

  • 最长停止世界时间–应该尽可能短。

  • GC 消耗的总运行时间的百分比——应该尽可能小。

  • 通常,很难同时优化这两个值。

应该注意的是,Go GC 使用的机制与几个 Java GCs 不同(随时间和运行时上下文而变化)。因为 Go 支持指针(而 Java 支持引用),所以它不容易在堆中移动对象。因此,Go 不使用 Java 中常见的清理(即压缩)收集器。Go 的方法会导致更高的堆碎片,从而降低内存的使用效率。

如前所述,Java 允许在几个 GC 实现中进行选择。Go 不会。随着 JVM 用例的发展,Java GC 选项也随着时间而发展(删除和添加收集器)(表明 JVM 似乎不能提供“一刀切”的选项)。

Go 堆上的对象通常有两个部分:

  • 标题–至少包含标记-扫描命中指示符,通常还包含数据的大小。也可以存在其他值,例如类型和/或调试/分析信息。

  • 数据——实际的数据。

因为头的存在,大多数系统对所有堆对象都有一个最小的大小,通常是 8 或 16 字节,即使数据更小,比如一个布尔值。内存通常以至少这个最小大小的块来分配。因此,为了更好地使用堆,应该避免将许多小值(比如标量值)单独放在堆上(比如作为大数组的一部分)。

在 Java 中,数据的堆栈和堆位置是显而易见的。由new操作符创建的任何东西都在堆上。其他的都在堆栈上。一般来说,这意味着所有原始标量变量都在堆栈中,所有对象都在堆中。

注意,由于装箱的原因,对于集合中的原始类型,Java 的内存效率可能比 Go 低。

在 Go 中,数据的位置并不总是显而易见的。数据可以存在于堆栈或堆中,这取决于它们是如何被引用的以及 Go 运行时是如何工作的。栈对于只在创建它们的函数的生存期内存在的函数局部变量来说是最佳的(即,没有指向它们的外部指针或者不被闭包使用)。其他数据通常需要堆。大数据值也需要堆分配。

Note Go 从堆中分配 goroutine 调用栈。每个 goroutine 都有自己的调用堆栈。这些堆栈开始时很小,然后根据需要增长。在 Java 中,线程调用栈也来自堆,但它们开始时要大得多(通常有几兆字节);相对于 goroutine 调用堆栈的数量,这严重限制了线程调用堆栈的数量。

堆栈和堆分配的混合会影响 Go 程序的性能。Go 提供了分析工具 16 来帮助确定这个比率并指导任何调整。

Go 和 Java 管理内存使用的方式,尤其是在堆中,是完全不同的。由于细节通常依赖于实现并会发生变化,所以它们没有被很好地记录。这些差异意味着类似的 Go 和具有类似数据结构的 Java 程序可以消耗显著不同的运行时内存量。这也意味着内存不足的情况可能会以不同的方式出现。JVM 有比 Go 更多的管理内存使用的选项。Go 更高的内存块碎片也会对此产生影响。

许多对象由 Go new函数分配,该函数分配空间来保存值(通常,但不总是,在堆上,就像在 Java 中一样)并将其初始化为二进制零(根据类型解释为“零”值)。new函数总是返回一个指向分配值的指针(或者在可用内存不足的情况下出现混乱)。

许多标量值(例如,数字和布尔值)和仅标量的结构被分配在堆栈上。大多数集合(比如切片和映射)都是在堆上分配的。

通常,任何地址被占用的值也必须在堆上分配。发生这种情况是因为在声明该值的块返回很久之后,该地址仍可以保存和使用。例如:

func makeInt() *int {
      var i int = 10  // a local, can be on the stack
      return &i       // now can live beyond this function; now on heap
}

或者实际上相当于:

func makeInt() *int {
      var pi = new(int) // on heap
      *pi = 10
      return pi
}

考虑这个结构示例:

type S struct {
      x, y, z int
}

然后:

func makeS() *S {
      return new(S)  // x, y, z = 0
}

或者相当于:

func makeS() *S {
      return &S{} // or &S{1,2,3} if fields initialized
}

Go 还使用make函数创建内置的结构、映射和通道类型。内置的make函数与new的不同之处在于,它们基于参数初始化(有点像 Java 中的构造函数调用)值,并且它返回值本身,而不是指向它的指针。例如,考虑一个类似切片的结构,它可能被定义为(从概念上讲,不是一个真正的 Go 切片;不合法)

type Slice[T any] struct {
      Values *[Cap]T  // actual data; can be shared
      Len, Cap int    // current and maximum lengths
}

where (say) make(new(Slice[int]), 10, 100)创建并返回这个结构和支持数组,并设置所有字段。

Go 标识符

像 Java 一样,Go 使用标识符来标记编程结构。像在 Java 中一样,Go 标识符有一套语法规则。Go 的规则就像 Java 的一样,所以在这里使用你的 Java 经验(任何问题都会被编译器报告)。具体规则见 Go 语言规范

在 Go 中,可以识别的命名结构有

  • 包——所有顶级类型、函数和值都包含在某个包中。

  • 类型——所有变量都有某种类型;所有函数都有某种类型的参数和返回值。

  • 变量–变量是有存储位置的命名值,可以随着时间的推移而改变。

  • 字段–字段是包含在结构(struct)中的变量。

  • 函数(声明的或内置的)–函数是独立的或者是结构或接口(仅原型)成员的代码块;函数可以被其他函数调用。

  • 常量–常量是不能更改的命名值;它们为编译器所知,但通常没有运行时存在。 17

  • 语句关键字——语句或者是声明,或者是嵌套的语句组,或者代表可以用 Go 语言表达的操作;大多数语句(除了赋值语句)都是用一个关键字引入的。

注意在 Go 中,像在 Java 中一样,每个变量都必须有一个声明的静态类型。这意味着该类型在编译时是已知的,不能在代码运行时改变。Go 和 Java 一样,允许一个接口类型的变量的动态(运行时)类型改变为符合(实现)该接口类型的任何类型。Go 没有可以设置为子类实例的类类型变量的等价物。

Java 有一个 Go 仅部分支持的特性,在这个特性中,变量可以被赋予实现该变量类型的任何类型(或子类型)。这通常被称为继承多态性(一个关键的面向对象编程特性)。这适用于类和接口类型。

在 Go 中,这种多态性只适用于接口类型。Go 中没有结构类型继承的概念。因此,如果一个 Go 变量有一个接口类型,那么它只能被赋予一个实现该接口所有方法的任何类型的实例。通常,这比 Java 的多态性更灵活,但却没有那么严格。

Go 范围

Java 和 Go 都是块范围的语言。标识符在声明它们的块中可见,在任何词汇嵌套的块中可见,并且基于它们的可见性,可能在其他块中可见。通常,特别是在 Go 中,封闭块是隐含的,而不是显式编码的。嵌套块可以从包含块中重新声明(从而隐藏)声明。

注意,作用域是一个编译时的概念;生存期(稍后讨论)是一个运行时概念。

块可以充当名称空间,它有时被命名为标识符的集合(通常在一个名称空间中是唯一的,但在不同的名称空间中不一定是唯一的)。虽然块是嵌套的,但是命名空间经常是重叠的。在 Go 中,像在 Java 中一样,名称空间是隐含的。在其他一些语言中(比如 C++),可以显式声明它们。

Java 支持几种标识符范围。通常,标识符在以下范围内声明:

  • 包——类型的名称空间

  • 类型(类、接口、枚举)-嵌套类型、字段或方法的名称空间

  • 方法或块——嵌套(也称为局部)变量的名称空间(方法创建一个块)

Go 支持多种作用域。通常,标识符是在某个范围内声明的:

  • package——全局变量、常量、函数或类型声明的名称空间。又名顶级

  • struct–嵌套字段或方法(与结构相关联的函数)的名称空间

  • 接口——方法原型(也称为签名)的名称空间

  • 函数或块–嵌套变量的名称空间(函数创建块)

一个关键的区别是 Go 允许全局(不像 Java 要求的那样包含在某些类型中)变量声明。Java static字段是全局变量的近似值。同样,在 Go 中,而不是在 Java 中,函数、类型或常量可以被全局声明。

更完整地说,Go 具有以下概念块范围:

  • 一个通用块,包含所有一起编译的 Go 源文件。

  • 每个包创建一个块,包含该包的所有 Go 源文件;这是顶级声明存在的地方。

  • 每个 Go 源文件充当一个包含该文件中所有 Go 源文件文本的文件块。

  • 每个 Go 结构或接口都创建自己的块。

  • 每个 if、else、for、switch 或 select 语句都是它自己的隐式块。

  • 每个 switch 或 select case 或 default 子句都是它自己的隐式块。

内置(或预声明)标识符位于由多个文件块组成的通用块中。包规范(不是声明)是在文件块级别(每个文件块都有自己的一组导入)。包块不会跨越不同的目录。顶层声明在 package 块中。任何局部变量(包括函数接收方、参数和返回名称)都在它的包含块(可以是函数体)中。局部声明从声明点开始,而不是从包含块开始。同一个标识符在同一个块中只能声明一次。

通用块中预先声明的(因此被认为是保留的,尤其是 IDEs 有些可以重新声明,但这是不明智的)标识符是

  • 类型—bool byte complex64 complex128 error float32 float64 int int8 int16 int32 int64 rune string uint uint8 uint16 uint32 uint64 uintptr

  • 常量或零值-false iota nil true

  • 功能-append cap close complex copy delete imag len make new panic print println real recover

Go 支持语句标签在块中自己的命名空间中。它们由breakcontinuegoto语句使用。标签在函数块(而不是嵌套函数)范围内。它们必须是唯一的,并且在该块中使用。不允许跨职能控制流转移。

像 Java 一样,Go 包是声明的名称空间。像 Java 一样,Go 包映射到某个文件系统中的目录。与 Java 不同,包名必须总是用来限定导入的声明。没有什么能等同于

import java.util.*;

在 Go 中,必须做与下面等价的事情(概念上的,Java 中不允许):

import java.util;
:
util.List l = new util.ArrayList();
or:
import java.util as u;
:
u.List l = new u.ArrayList();

Go 有一个类似 Java static字段导入的特性,其中导入的名称被合并到导入的名称空间中。例如:

import . "math"

会将math包中的所有公共名称包含在当前包中,因此它们可以被无限制地使用。不鼓励使用这个特性(这是一种不推荐使用的语言特性)。例如,导入的名称可能会发生冲突。更多细节见 Go 语言规范

在 Java 中,包目录保存类型(类、接口、枚举)源代码,通常(但不要求)每个源文件有一个顶级类型(.java)。包中可以有任意数量的类型。任何这样的类型对同一个包中的其他类型都有特殊的特权可见性(称为默认可见性)。在 Java 中,一个类型的所有方法必须在该类型的定义中(因此在同一个源文件中)。

Go 的私有可见性与 Java 的默认可见性几乎相同。Go 无法使一个类型的成员(比如一个结构)只对该类型私有。

Java 支持嵌套类型声明(例如,定义为一个类的成员并因此由该类限定(区分)的枚举)。这些嵌套类型可以是命名的(具有开发人员分配的名称),或者,如果是类或接口,可以是匿名的(具有编译器生成的名称)。

其他类型可以使用公共嵌套命名类型。这些嵌套类型被编译成单独的类文件(使用编译器构造的名称),并且对于 JVM 是不同的(就像来自不同的源文件一样)。Go 不允许这种嵌套,但是一个 Go 源文件可以定义任意数量的类型。

Go 范围与 Go 源文件

在 Go 中,包目录保存一个或多个 Go 源文件(.go)。每个文件的文本在逻辑上连接在一起(按照源文件名词汇顺序)形成包的内容。包中的声明在源文件中的排列方式有一些限制。这意味着 Go source 比 Java source 组织得更少,但是更灵活。

此外,与 Java 一样,生成的二进制代码(Java 中的.class)通常放在与源代码不同的目录中。在 Java 中,二进制文件是持久化的,并且可以被管理(比如放在一个 JAR 中)。在 Go 中,生成的二进制文件通常是临时的(可能只在内存中),一旦构建了目标 EXE,通常就会被删除。

一个要求是 Go 包中的每个源文件都包含一个package语句作为第一个语句。在 Go 中,没有默认包。该语句声明了包的名称。同一包中的文件应该在同一目录中。通常,目录名与包名相匹配;例如,main包通常位于名为“main”的目录中 Go 允许主包位于一个不同名称的目录中,如果程序是在包含主包的目录中启动的,则可能包含来自不同包的代码,但不建议这样做。

一般来说,每个 Go 程序的源代码都植根于一个目录(称为 GOPATH ),这个目录构成了任何包路径的起点。库包也可以通过 GOROOT 路径找到。GOROOT 可以是一个目录列表(很像 Java 类路径)。包可以驻留在这个根目录的某个路径中。导入本地包时,将使用该根目录的路径。

如果我们看左上角的图 4-13 ,我们会看到这组目录。 LifeServer 项目使用 GOROOT(下面是 Go SDK 目录)来访问 Go 编译器和运行时以及标准库。在这个目录下是所有的 Go 标准包(以源代码的形式,这在调试代码时有帮助)。它使用 GOPATH 来访问任何附加的库(通过“go get ...”来访问))由项目使用。

我们还可以看到如何轻松访问几个 Go 命令选项。有些,如 Go 编译,是自动启动的,因此没有列出。

img/516433_1_En_4_Fig13_HTML.jpg

图 4-13

显示了选择构建工具的 Goland

可以直接将一个包导入到一个远程存储库中(比如 GitHub )(通过先执行等价的“go get ”;IDEA 有助于实现自动化)。在这种情况下,使用存储库的 URL(减去协议前缀)。例如:

import "github.com/google/uuid"

将带有本地包引用(或别名)uuiduuid 包导入当前文件名称空间。

默认情况下,路径中的最后一个名称用作本地包引用。它可以被覆盖(例如,如果两个不同的导入以相同的名称结束,或者只是通过首选项),如下所示:

import guid "github.com/google/uuid"

通常,我们使用一个本地的,比如 Git,存储库加上一个或多个远程存储库来提供一套完整的可导入代码。

初始化 Go 变量

在 Java 中,变量在声明时被初始化。除了块局部变量之外,所有变量都有一个默认值,如果没有显式提供初始值,则使用该默认值。在第一次读取局部变量之前,必须对其进行显式初始化或赋值(否则会产生编译器错误)。以下字符串(比如一个类字段)的默认值为null:

String name;

大多数变量由作为声明的一部分提供的表达式值初始化。例如:

String name = "John Smith";
String name2 = name;

这里,变量name被设置为引用一个文字字符串值(存储在常量池中)。然后name2被设置为引用相同的字符串。记住,在 Java 中,Object类型(或子类型)的所有变量都持有引用,而不是值。

在 Java 中,类型的字段可以在单独的“初始化器”块中初始化。在一个类型中可以有任意数量的初始化块。这些块对于静态变量可以是static(当类加载时设置)或者对于实例变量可以是非静态的(当通过new创建实例时设置)。实例初始值设定项块是定义构造函数的一种替代方法。例如:

String name;
{
  name = "John Smith";
}

当不能通过简单的表达式初始化变量时,通常使用这些块。在 Java 中,一个变量或字段只能初始化一次。

Go 有类似的行为,除了所有变量(也包括局部变量)总是被初始化。如果省略了某个值,则使用“零”值,很像 Java 的默认值。例如:

var name string

这里,name字符串具有空的(不是nil)字符串值,这是一个字符串的零值。在某些方面,这种类型的初始化使 Go 比 Java 更安全。

顶级值也可以由函数初始化(与 Java 中的块不同)。特殊的无参数空函数init()用于此目的。包内部和包之间可以有任意数量的这些初始化函数。

这些函数仅用于顶级变量。这些函数应该放在它们初始化的变量声明的后面和附近。当不能通过简单的表达式初始化变量时,通常使用这些函数。这些函数在程序启动时由 Go 运行时调用(在调用main函数之前),而不是由开发人员代码调用。每个init()函数只被调用一次。例如:

var name string

func init() {
      name = "John Smith"
}

在 Go 中,init函数可以重置在声明时或在其他init函数中初始化的变量。最后一个叫的赢。这可能会引起一些意外。Go 有一种机制,可以根据代码的依赖性对程序中调用init函数的顺序进行排序。

源文件之间的init()处理排序是基于导入的包。首先初始化没有导入的源文件,然后是直接导入这些源文件中的包的文件,依此类推,直到到达主包。文件(以及init()函数)按照这些依赖关系进行排序。包中的这种排序可以部分地由包中 Go 源文件名的字母排序顺序来控制,以便对源文件的处理进行排序。

这是包导入中没有循环的原因之一(即,A 导入 B and B(直接或间接)导入 A)。Java 没有这样的限制。防止(或消除)导入循环有时是一项挑战。可能需要在包之间移动(即重新打包)代码段或定义的数据类型,以改变所需的导入列表来解决任何循环。通常,Go 编译器会提供信息来帮助定位循环导入模式。

在 Go 中,如果零值不足,实例初始化需要创建一个构造函数(即NewXxx)。

一些地鼠不喜欢使用init()函数,因为它们不能带参数,并且函数运行的时间(或者是否运行)不能被显式控制。为此,您可以选择创建自己的初始化函数,并在需要时显式调用它们。

注意一个包必须由一些代码导入,它的init()函数才能运行。因此,空白的标识符在导入中是允许的。例如,下面的import语句不导入任何符号;它只运行任何可能在包中的init()函数(以及包中包含的过渡包):

import _ "github.com/google/uuid"

像任何函数一样,init()函数中的代码会导致混乱。这就像在 Java 的初始化块中抛出一个异常。由于 point init函数在程序流中运行,这些混乱可能需要以不同于其他地方的混乱的方式来解决。有两种主要的方法来处理它们:

  1. 忽略它们,让程序在main()启动前失败。

  2. init()函数内的defered 函数中捕获它们,并恢复以允许程序继续执行main函数。

Go 标识符的寿命

一个生命周期是一个变量的值保持有效的运行时间。如果变量在范围内,它本身就存在。

Java 变量有以下基本生存期:

静态–如果与这些值相关联的类型(类、接口或枚举)被加载到 JVM 中,则这些值存在。这些值存在于堆中(在一些类型中;记住 Java 中的运行时类型是对象)。

大多数开发人员认为它们在 JVM 的生命周期中是持久的,但这并不总是正确的。类型被延迟加载(在第一次引用时),并且可以在任何时候卸载,因为它们没有剩余的实例,并且堆变得受限。Java 程序员也倾向于认为static值是唯一的,但事实并非总是如此。由不同的类装入器装入的同一个类会有不同的静态值集合。

实例–当与这些值相关联的对象(即实例字段)存在时,这些值会一直存在。这些值存在于堆中(实例内部)。由于 Java 中的对象是垃圾回收的,所以至少在存在一个对实例的引用时是这样。

method/block–只要声明局部值的块在调用堆栈上,局部值就会一直存在。

Go 变量具有相似的生存期:

顶层或包——这些值作为 Go 可执行文件的一部分分配,因此在可执行文件的生命周期内存在。

实例–当与这些值相关联的对象(即实例字段)存在时,这些值会一直存在。这些值存在于(结构内部)堆或调用堆栈中。由于 Go 中的堆对象是垃圾收集的,所以至少在存在一个对实例的引用时是这样。

method/block–只要声明局部值的块在调用堆栈上,局部值就会一直存在。

closure——当在某个闭包(函数字面量)中至少存在一个对局部值的引用时,即使分配块已经结束,块局部值也会持续。这些值通常存在于堆中。Java 没有类似的功能,但是它对只读(final)局部变量有类似的行为;它通过创建变量的副本来实现这一点。

Go 模块摘要

Go 包可以分组到模块中。当提供代码给其他开发人员使用时,模块是重要的结构。它们对于单个应用来说通常不太重要。

来自 Go 网站: 18 “一个模块是一个一起发布、版本化和分发的包的集合。”Go 模块是源代码树中的包的集合。这里没什么新鲜的。但是要成为一个模块,在源码树的根中有一个额外的文件叫做go.mod。这个文件设置了模块路径,它标识了源文件的导入根位置,以及可选的模块版本。

请注意,未来的 Go 版本可能会启用模块,即使go.mod不存在。

通常,模块路径是一个指向托管发布模块的服务器(比如 GitHub)的 URL(减去协议头、地址和端口);比如xyz.com/libraries/library/v2。这是其他代码导入模块的路径。go.mod文件还指出了任何依赖包及其模块路径和所需版本。它还指示模块构建时使用的 Go 版本(或最低要求;Go 构建器并不总是强制这样做)。

请注意,对于标准库,导入 URL 的主机名部分缺失。仅使用库包路径。包通常从 GOROOT 或 GOPATH 中解析。

注意,在 Go 版本 1.16(及更高版本)中,默认使用模块。不再需要一个go.mod文件来启用模块行为。如果需要,可以激活旧版本行为。

一般来说,Go 模块是以源代码的形式导入的(代码从使用的宿主位置复制/下载到您的本地机器上,通常是在第一次引用时自动进行)。然后用你自己的代码编译它们,就像是你自己写的一样。因此,通常没有正式的库代码构建。模块定义也可以驻留在本地(或远程)文件系统中。这在模块发布之前的开发和测试阶段是很典型的。

在 Java 中,这通常是不同的。Java 依赖项很少以源代码的形式提供。相反,提供了编译的 Java 类的 jar。这些 jar 通常由库作者预先构建,并托管在某个存储库中(比如说Maven Central19)。这些 jar 有时是按需下载的。因此,Java 代码只能以二进制形式发布,因而更具保密性。Go 代码一般比较开放。

有了模块,这就扩展了,允许开发者源代码在另一个目录中,称为模块 路径。因此,每个模块可以并且经常被放在不同的源代码树中,其中一些可能是远程的。

Go 模块可以有个语义版本,这些字段有如下含义:

<major>.<minor>.<fix>

其中字段表示

  • major–任何增加都表明与过去的突破性(不向后兼容)改变。

  • 轻微——任何增加都表明与过去相比没有重大变化(通常是增加)。

  • 修复(又名补丁)——任何变更都表示一些小的变更(比如 bug 修复)。

Go builder 可以在找到更新版本时升级相关模块。这有助于保持您的依赖关系是最新的,但它可能会导致意想不到的/不希望的变化。从 Go 1.16 开始,默认情况下这不再是自动化的;依赖模块版本的更改需要通过更新go.mod文件和显式go get(或等效)命令来显式完成。这提供了对所使用的依赖项版本以及何时/是否更新它们的更明确的控制。

随着更多地使用go.mod文件,在依赖项导入路径中显式指定版本的需求(如下所示)已经减少,但是一些包可能仍然使用这种方法。使用模块时,只导入模块路径(没有版本信息);使用的版本来自于go.mod文件。这使得 Go 行为更接近于使用 Maven 或 Gradle 进行 Java 构建的行为。go.mod文件的行为有点像 Maven POM 文件的依赖项部分。

要在使用 Go 1.16+时获得之前的行为,您需要使用以下环境选项构建代码:

GO111MODULE=auto

在 Go 1.11 中引入,在 Go 1.16 中该选项的默认设置从auto更改为on。本文中的一些示例需要将该值设置为auto才能正确构建。这代表了 Go 1.16 中一个小的(但合理的)突破性变化。

这些版本用于导入,以控制所使用的版本,如下所示:

import {<alias>} "<path>{.v<version>}"

在哪里

是导入的可选别名。

是包的本地或远程名称。

是要使用的包版本。默认情况下,它表示第一个。

<major>{.<minor>{.<fix>}}

一般形式是

例如:

import xxx "gitworld.com/xxx/somecoolpackage/v2"

导致使用第二个版本。版本上的“v”前缀是识别其为版本规范的惯例。通常,当提供一个新版本(比如 v2)时,任何(或者至少几个修订)旧版本也被保留,以允许人们选择使用哪个版本。这允许跨主要版本的增量升级,尤其是在进行了一些重大更改的情况下。如果没有提供版本指示符,通常会选择 v1 或第一个版本。

也可以通过go get获取包的任何版本,然后在本地使用它,而不需要版本限定符。通过这种方式,开发人员可以完全控制使用哪个版本以及何时进行升级。同样,如果存在一个go.mod文件来显式地声明期望的依赖版本,那么这就不那么频繁了。

每个go.mod文件以如下模块语句开始:

module <module path>

其中路径是模块代码的名称,它不一定是,但通常是,以包含go.mod文件的目录为根的目录树。

该文件通常使用如下go mod命令创建:

go mod init <module path>

该命令在每个模块中使用一次,不管该模块中有多少个包。模块路径通常包括

<source>/<name>

其中<source>通常是一个存储库(或目录)定位器。而<name>是模块名。例如:

mycompany.com/example

go.mod文件中还包含了构建该文件所用的或需要的 Go 版本。

当您从外部(比如远程)存储库导入包(比如mycompany.com/example)时,Go builder 可以解析导入并将其作为依赖项添加到go.mod文件中。在 Go 1.16 及更高版本中,默认情况下这不再是自动化的;需要通过go get进行明确的更新。可以选择导入库的任何可用版本。Go builder 可以(通常会)在本地缓存这个远程模块的内容,以提高构建性能。如果需要,可以添加可传递的依赖关系。

添加依赖项也可以手动完成,这允许用户选择依赖项的不同版本。例如,一旦使用添加的依赖项重新构建了代码,go.mod文件可能看起来像这样

module mycompany.com/example
go 1.16
require xyz.com/utils v1.1.3

通常,会列出多个依赖项。require关键字可以被分解出来,如下所示:

require (
  xyz.com/utils v1.1.3
  abc.com/common v2.2.3
  :
)

“go”命令上的版本向 Go 编译器指示代码的目标语言版本。这可能会导致编译器拒绝使用该版本之后定义的功能的代码。它还可能导致代码编译方式的细微差异。如果任何依赖库需要不同的版本,这可能不会导致错误。请访问 Go 网站了解更多详细信息。

go mod命令提供了管理(通常是升级)下载的依赖项的选项。

您可能会注意到在您的模块根目录中有一个名为go.sum的文件。该文件包含依赖校验和,由 go 工具管理。不要更改或删除它。

本书不会深入探讨模块的使用。有关更多详细信息,请参见 Go 文档。Go 还提供了一种称为“vendoring”的依赖解决方法(制作项目的第三方包的副本的行为依赖于将每个包放在项目内的一个vendor目录中)。有关更多详细信息,请参见 Go 文档。

在 Java 中,文件module-info.java中类似的模块描述如下

module com.mycompany.example {
  requires com.xyz.utils;
  requires com.abc.common;
}

Java 模块还允许开发者通过exports语句来限制 Java 模块公开的包。Go 模块没有类似的特性,但是 Go builder 有一个约定,模块根目录下的任何包都不能被使用库的代码导入。这使得代码实际上是模块的私有代码。当使用模块时,通常按照惯例,公共程序源被放在/pkg目录中(类似于/internal),而不是/src。在这种情况下,pkg的含义与其在模块之前的用法略有不同,如下所述。

Go 不需要前面的结构。例如,本文中列出的一些程序定义在如图 4-14 所示的目录结构中。

img/516433_1_En_4_Fig14_HTML.jpg

图 4-14

目录结构中定义的程序

每个.go文件都包括一个package main语句、任何需要的导入、任何特定于程序的代码和一个main()函数。因此,每个.go文件都是一个可执行程序。

在模块出现之前,大多数开发人员编写的代码都在 GOPATH 中列出的目录中,通常在/src目录下。第三方二进制代码(通常带有.a扩展名)通常安装在/pkg目录下。一些本地构建的包也将放在这里。Go 或其他第三方交付的代码通常放在 GOROOT 集中的一个目录中。典型结构是这样的:

<GOPATH>
      /src
            /main – your application and associated packages
            /xxx – some third-party packages (in source form)
            /yyy – some third-party packages (in source form)
            /zzz – some third-party packages (in source form)
      /pkg
            /ggg – some third-party packages (perhaps binary only)
      /bin – executable results

对于一个 Go 程序来说,下的目录可以看作是一个工作区。您可以更改< GOPATH >(比如通过 CHDIR 和/或 EXPORT 命令)来访问不同的工作区。

这种前模块结构在本书中使用频率最高。通常,显示的代码没有引用它所在的目录。

Go 作业和表达式

计算机最基本的功能是计算(即计算机是一种编程计算器)。在 Java 和 Go 中,计算是通过使用表达式完成的。在许多情况下,表达式的结果通过对变量的赋值存储在一些变量中(以便以后可以访问)。赋值(Go 和 Java 中的“=”操作符)记住变量中的某个值。这是命令式编程的精髓。

表达式可以很简单,比如单个文字的 20 个 值:

x = 1

或者单个变量值(一个术语):

x = y

或者价值交换:

x, y = y, x

或者通过混合文字、术语、运算符和函数调用使它们变得复杂:

c = 1 / (math.Sqrt(a * a + b * b) + base )

注意,只有当abc,base都是float64类型时,前面的表达式在 Go 中才是合法的,比如声明为

var a, b, c, base float64

在 Go 中,像在 Java 中一样,表达式有类型,只能存储在兼容类型的变量中。Go 在这方面比 Java 更严格;类型必须完全匹配(接口类型除外,在接口类型中,值可以是符合接口的任何类型)。

这包括使用数值时。在 Go 中,比如说一个int16不会自动转换成一个int32,一个int32也不会变成一个int64或者float64。任何此类转换都必须由“cast”函数显式完成。这可能不太方便,但是因为类型可以从内置类型派生,所以这是必要的。例如:

var x int16
var y int32
var z float64
z = float64(x) + float64(y)

为了方便起见,Go 会自动调整文字数值的类型(比如 1)以匹配目标(比如 a float64 (1.0)或complex128 (1.0+0.0i))。Go 可以做到这一点,因为文字值是“无类型的”任何类型都是由使用该文本的上下文指定的。数字文字基本上没有大小限制,至少可以和最大的正式数字类型一样大。

注意,Go 编译器通常使用math包中的IntFloat类型来实现数值。

像标识符一样,Go 的数字、字符串和字符文字严格遵循 Java 语法。因此,在这里使用您的 Java 经验(任何问题都会被编译器报告)。具体规则见 Go 语言规范

Go 有一个字符串文字的扩展语法。如果使用反斜杠(`)而不是引号(")字符作为分隔符,则字符串可以跨行。这些字符串被称为“原始字符串”。“字符串中的任何字符都被视为文字值,因此不需要(或识别)转义。任何回车符都从原始文本中删除。带引号的字符串被称为“解释字符串”。两个字符串都被编码成 UTF 8 字符。

注意 Java 15 提供了多行字符串,当用三重引号("""...""")分隔时允许转义。

像在 Java 中一样,Go 解释的字符串支持转义值:

八进制(\###)–转换为一个字节(#是一个八进制数字–01234567)

十六进制(\x##)–转换为字节(#是十六进制数字–0123456789 ABCDEF | ABCDEF)

Unicode ( \u####\U########)–转换为 16 位或 32 位值(#是十六进制数字)

ASCII (\a,\b,\f,\n,\r,\v,\,\ ',\ ")-就像 Java 转义一样

使用数字转义时必须小心,因为它们必须表示 UTF-8 编码的字符。

Go 有一个名为rune的字符类型,长度为 32 位(而 Java 是 16 位的char)。符文文字类似于字符串文字,但只允许一个字符。像在 Java 中一样,rune 文字用撇号(')括起来。符文文字用 32 位 Unicode 编码。

Go 中的文本格式

能够计算值并不有趣,除非结果可以呈现给用户。通常,这意味着向用户显示或格式化以打印或写入某个永久存储器。大多数操作系统有两种途径向用户显示纯文本(通过控制台):

  • 标准输出(STDOUT)–正常输出

  • 标准错误(STDERR)-错误输出

在 Java 中,这些被提供为 PrintStreams :

  • System.out

  • System.err

Java 允许使用默认格式(使用print()println()方法)或开发人员指定的格式(使用printf()方法)将值写入这些流。你也可以通过使用String.format()方法(?? 在幕后使用)格式化成一个字符串。

Go 也做类似的事情。缺省情况下,Go 支持打印到标准输出,打印到任何写入器(包括标准输出、标准错误、文件等)。),并通过 format(“fmt”)包中提供的函数转换为字符串。有关更多详细信息,请参见“去库调查”部分。

以下是一些示例:

fmt.Print(1, 2, 3)      // like System.out.print(1 + " "  + 2 + "  " + 3)
fmt.Fprintf(os.Stdout, 1, 2, 3)   // like above explicitly to standard out
fmt.Print(1)            // like above, but just 1 value
fmt.Fprintf(os.Stderr, 1)   // like above but to standard error
fmt.Println(1)          // same as fmt.Print(1); fmt.Print("\n")
fmt.Printf("%v\n", 1)   // similar to above

哪个输出(假设标准输出和错误都是到控制台的)

1 2 31 2 3111
1

格式化的表单(名称以“f”结尾)接受一个格式字符串和零个或多个要格式化的值(格式字符串中每个“%”一个值)。文件格式(名称以“F”开头)以一个io.Writer作为第一个参数。结果被写入编写器,编写器可以是一个打开的文件。字符串形式(名称以“S”开头)返回带有格式化文本的字符串。fmt.Sprintf()函数经常用于格式化值。

由于 Go 允许开发人员定制类型,所以最好为这些类型提供一个定制的字符串格式化程序(比如 Java toString()方法)。这是由fmt.Stringer接口完成的。许多 Go 库类型都这样做。

给定一个自定义类型,可以这样做:

type MyIntType int

func (mt MyIntType) String() string {  // conforms to Stringer interface
      return fmt.Sprintf("MyType %d", mt)
}

可以按如下方式使用:

var mt MyIntType = 1
formatted := fmt.Sprintf("%s", mt)  // could use "%v" too
fmt.Println(formatted)

哪些输出:MyType 1

注意使用"%s" (vs. "%v")来确保使用 Stringer 接口。

泛型(%v)说明符提供的格式根据被格式化的实际数据类型而变化。其他说明符应该与值的实际类型相匹配。表 4-1 中列出了标量数据类型的有效格式。

表 4-1

基本类型的格式选项

|

类型

|

有效格式

| | --- | --- | | Bool | %t | | int types | %d | | uint types | %d, %#x when formatted via %#v | | float and complex types | %g | | String | %s | | Chan | %p | | &above (pointer) | %p |

对于复合数据类型,使用表 4-2 中列出的规则对元素进行格式化,可能是递归的。

表 4-2

复杂类型的默认格式选项

|

类型

|

有效展示

| | --- | --- | | struct types | {field0 field1 ...} | | array, slice types | [elem0 elem1 ...] | | map types | map[key0:value0 key1:value1 ...] | | &above (pointer) | &{}, &[], &map[] |

注在 Java 中,逗号(",")而不是空格(" ")通常用于分隔元素。此外,Java 格式经常用数据的类型名作为非基本数据的前缀。

如前所示,Go fmt (format)包有很大的效用。它是将 Go 值格式化成字符串的主要方法,通常是将它们打印出来,并将来自用户、文件或字符串的文本输入转换成值。

基本的格式化是通过Print系列函数完成的。可以对用户、文件或字符串进行打印。一般形式是

func Printf(format string, args ...interface{}) (n int, err error)

这将导致参数与格式字符串中嵌入的格式说明符(%x)一一匹配,并返回格式化计数或某些错误。通常,调用方不会检查返回的计数和错误值。这是经常违反“总是检查错误”规则的一个地方。因此,这样的输出可能会丢失。这可能只有在输出指向文件或通过网络时才有意义。参见顶点工程utility.go文件,了解可用于克服这一问题的函数。

可以输出多个值,每个值具有不同的格式规格:

fmt.Printf("Value 1: %d, value 2: %s, value 3: %q\n", 1, "2", "3")

要接受输入,使用Scan系列函数之一。一般形式是

func Scanf(format string, args ...interface{}) (n int, err error)

与 Printf 类似,它使来自输入源的文本与格式字符串匹配,并与扫描的计数一一对应地放入args值中,否则会返回一些错误。args值必须是指向要设置的正确类型变量的指针。

可以输入多个值,每个值都有不同的格式规范:

var one int
var two, three float64
fmt.Scanf("%d %e %v\n", &one, &two, &three)

格式字符串是嵌入了格式规范的任何字符串。扫描时,规范中除格式控制以外的文本必须完全匹配。在打印时,这样的文本按原样输出。与 Java 一样,任何这样的字符串都在运行时被解释,而不是被编译。这意味着故障可能在运行时发生。与 Java 相比,Go 通常对这些类型的错误更宽容(没有引起恐慌)。这些规范非常丰富,这里进行了总结。与 Java 一样,规范以百分号(" % ")开始,以区分大小写的格式代码字母结束。修饰符和宽度可以进行格式编码。一般格式是

%{[<index>]}{<modifier>}{<width>}{.<precession>}<code>

代码列于表 4-3 中。

表 4-3

fmt 包格式代码

|

密码

|

使用

|

适用类型

| | --- | --- | --- | | % | %字符 |   | | v | 一般价值 | 任何(如 Java 中的%s) | | b, t | 布尔代数学体系的 | 布尔 or(如果整数基数为 2) | | s | 线 | 线 | | d | 小数 | 以 10 为基数的整数 | | f | 浮点十进制 | 数字 | | g, G | 浮动通用 | 数字 | | e, E | 浮动科学 | 数字 | | o, O | 八进制的 | 基数为 8 的整数 | | x, X | 十六进制的 | 基数为 16 的整数 | | u, U | Unicode 转义 | 符文或字符串 | | q | 引用和转义字符串 | 线 | | c | 性格;角色;字母 | 古代北欧文字 | | p | 指针 | 任何指针类型 | | T | 价值类型 | 任何的 |

允许使用表 4-4 中列出的修饰符(因代码而异)。

表 4-4

fmt 包格式代码修饰符

|

修饰语

|

使用

|

注意

| | --- | --- | --- | | + | 始终添加一个标志 |   | | - | 右侧(相对于左侧)的焊盘宽度 |   | | # | 使用更详细的格式 | 在整数上添加基本指示符;结构上的字段名 | | | 在正值上添加前导空格 |   | | Zero | 用零填充左边的宽度 |   |

<width>值设置最小值宽度。<precision>值设置显示在任何小数点右侧的位数或显示的最小字符数。

如果存在,<index>是从 1 开始的自变量位置。这允许格式重用参数或重新排序参数。

戈里普斯

Java 最重要的特性之一是支持内置于语言和 via 类型中的相对简单的多线程(相对于 C 和 C++),例如标准库中提供的线程,以及语言特性,例如同步方法/块。Go 基于对 Goroutines 的使用提供了类似的特性,Goroutines 是一种轻量级的类似线程的方式来运行与通道相结合的代码(将在本文后面讨论)。

并发性问题

在我们讨论在 Go 中进行并发编程的机制之前,让我们看一下并发编程可能导致的一个问题。Java 和 Go 都使用共享内存模型(所有线程都可以访问相同的内存位置),因此临界区 (CS),即访问变量时会受到并行访问影响的代码区域,是很常见的。Java 语言有synchronized块来帮助控制对 CS 的访问。Java 允许任何对象成为这样一个 CS 上的门(又名条件)。

考虑一个例子:

public class Main {
  public static void main(String[] args) {
    int N = 10;

    var sum = new int[1];
    var rand = new Random();
    var threads = new ArrayList<Thread>();
    for (var i = 0; i < N; i++) {
      var t = new Thread(() -> {
        try {
          Thread.sleep(rand.nextInt(10));
        } catch (InterruptedException e) {
          // ...
        }
        sum[0] += 100;
      });
      threads.add(t);
      t.start();
    }
    try {
      for (var t : threads) {
        t.join();
      }
      System.out.printf("Sum result: %d%n", sum[0]);
    } catch (InterruptedException e) {
      // ...
    }
  }
}

注意sum是一个由一个int组成的数组,所以它在线程体中是可写的。这是必要的,因为 Java 没有闭包。

这里,期望的结果是sum等于N * 100。有时(可能大多数时候)会是这个值,但也可以更小。例如,当 N = 10 时:

Sum result: 900

这是因为声明

sum[0] += 100; // same as sum[0] = sum[0] + 100

是一个(隐藏的)临界区,因为+=操作不是线程间的原子操作,因此在提取sum并向其添加 100 以及设置新的sum值之间可能会发生线程切换。任何这样的读-修改-写序列如果不是自动完成的,就会创建一个 CS。

这可以通过如下更改分配来解决:

synchronized (threads) {
   sum[0] += 100;
}

这确保一次只有一个线程执行该语句。在这种情况下,可以使用除threads之外的其他值,也可能使用this。更简单的方法是使用原子值:

var sum = new AtomicInteger(0);
:
sum.addAndGet(100); // replaces synchronized block

像 Java 一样,Go 也有内存访问顺序的特性。Java 用(有点复杂)发生-之前 (HB)关系来解释这一点。 21 Go 也有 HB 关系 22 用于内存访问。必须小心,尤其是当涉及多个 goroutines 时,以确保代码尊重所有 HB 关系。可以使用 Go 通道、原子访问和锁定功能来实现这一点。

走向并行

Go 通过一个名为 Goroutines 的特性支持并发编程,这使得异步或并行处理变得相对(相对于 Java)容易。Java 中最相似的概念是前面所示的线程。Goroutines 可以引入相同的临界区问题。我们将在后面讨论如何处理 Go 中的关键部分。

注意并行并发不是一回事。并发意味着能够并行运行。这并不意味着代码总是并行运行的。并发通常意味着代码的行为是可预测的,与它是否并行运行无关。通常,这是代码设计的一个功能。

在多处理器(或内核)系统上,代码可以真正并行(同时)运行,但前提是其设计支持并发性。有时,可以通过在单个处理器上多路复用不同的代码执行线程来模拟并行行为(通常称为多任务或分时)。

注大多数现代计算机至少包含两个内核,因此并行处理是可能的。服务器级机器通常包含几十个(可能有 100 个)内核。

goroutine 只是一个普通的 Go 函数。创建一个 goroutine,并以go语句开始。go 语句立即返回,goroutine 函数与调用者异步运行,并且可能与调用者并行运行。

Goroutines 与通道(稍后讨论)相结合,提供了通信顺序流程 23 (CSP)的实现。CSP 的基本概念是独立的执行路径(在 Java 中称之为线程,在 Go 中称之为 goroutines)可以通过在它们之间以受控的方式传递数据来进行交互(通常像通道一样先进先出)。这通常比管理 CS 更容易和安全,并且是 Java 同步方法的一种替代方案。

使用 CSP,每个线程不同时共享数据(注意 Go 中没有任何东西阻止这一点,但通常不需要),而是使用一种消息传递的形式;数据由源 goroutine“发送”(传输),由目标/处理器 goroutine“接收”。这防止了临界区的可能性。通过缓冲这样的消息,发送方和接收方可以异步工作。

CSP 就像演员 24 系统。参与者系统也在参与者之间发送消息。参与者通常是具有特殊方法的对象,该方法在指定的线程上运行并接收任何消息;actor 本身通常不是线程,而是由某个 actor 运行时管理的共享线程。这为 actor 系统提供了更好的实例规模。参与者运行时负责将消息路由/传递给参与者。在 Go 中,通道承担了这个角色。

Java 社区提供了几个很好的 actor 库/框架,例如, Akka25 再来一次,Go 默认提供了这个能力;在 Java 中,它是一个附加组件。

CSP 和 Actors 都通过使消息的处理顺序化(不间断)来简化编程。处理器在准备好接收新消息之前不会收到新消息。它们还一次只允许一个线程访问任何数据。

与 Java 线程相比,goroutine 是轻量级的(使用较少的资源)。Goroutines 就像 Java 中通常所说的绿色 26 线程(这是由运行时而不是操作系统创建的类似线程的函数,通常比原生操作系统线程更轻,并且提供更快的上下文切换)。

通常,每个操作系统线程可能有许多绿色线程。goroutines 也是如此。实际上可以使用的 Java 线程的最大数量一般在几千个左右,而通常可以使用数万个(在大型系统中,达到数百万个)goroutines。

goroutines 如何实现的细节在不同的 Go 版本中会有所不同,因此在本文中不做深入的解释。值得注意的一点是,每个 goroutine 都有自己的调用堆栈(这占了 goroutine 消耗的大部分资源)。

与 Java 线程不同,Java 线程的堆栈通常只会增长,而且通常有几兆字节,而 goroutine 堆栈可以根据需要随时间增长和收缩。因此,一个 goroutine 消耗的堆栈正好是它所需要的,仅此而已。这是 goroutines 轻量级的原因之一,尤其是相对于 Java 线程而言。

另一个方面是,在 Go 操作系统中,线程是按需创建和结束的,并保存在池中以供重用,因此通常只需要支持活动的 goroutines。

考虑一下,如果所有的 goroutines 都是 CPU 受限的(也就是说,它们不做太多的 I/O),那么线程的数量只需要和处理器内核的数量一样多(其他没有采用多任务处理的线程必然是空闲的)。由于完全受 CPU 限制的代码(至少在很长一段时间内)很少,因此需要额外的线程来支持并发的 CPU 和 I/O 密集型 goroutines。

在 Go 中,goroutine 调度程序通常维护一个线程池,如图 4-15 所示。它将 goroutines 分配给池中的非活动线程。这种关联不是静态的,而是会随着时间的推移而变化。它会根据需要添加新的 I/O 线程,但通常会根据机器中处理器(核心)的实际数量来限制 CPU 线程。一般来说,goroutines 与 threads 的比率可以很大(比如> > 100)。

img/516433_1_En_4_Fig15_HTML.png

图 4-15

Goroutine 处理概述

如果 goroutine 做了一些事情来阻止它继续执行(或者主动放弃它的线程),那么它的线程就会被分离并交给另一个 goroutine。如果 goroutine 发出阻塞操作系统调用(比如执行文件或套接字 I/O 操作),Go 调度程序也可以分离线程。因此,调度器可能有两个线程池:一个用于 CPU 绑定的 goroutines,另一个用于 I/O 绑定的 goroutines。

Go 提供了有限的方法来控制 Go 用来执行 goroutines 的操作系统线程。可以使用 GOMAXPROCS 环境值和等效的runtime.GOMAXPROCS(n int)函数来设置运行 goroutines 的最大 CPU(或内核)数量。

Go 提供了通过使用上下文以编程方式取消或超时长时间运行的异步进程的能力,比如 goroutines 和网络或数据库请求中的循环。 28 上下文还提供了一个通道来通知侦听器这样一个长时间运行的进程已经完成(正常情况下或通过超时)。上下文将在本文后面详细讨论。

runtime.Goexit()函数在运行完所有延迟的函数后杀死调用的 goroutine。注意,main函数运行在一个 goroutine 上。当main的 goroutine 退出(返回)时,EXE 结束。这就像当所有非守护线程结束时,JVM 也结束了。

runtime.Gosched()函数使当前的 goroutine 自愿放弃(让出)它的线程,但保持可运行。这就像在 Java 中使用Thread.yield()一样。在长时间运行的代码段(如循环)中,让步是很好的做法。

由于 goroutines 比 Java 线程更轻量级,所以对池化和重用它们作为 Java 支持的支持更少;新的 goroutines 通常是根据需要创建的。Goroutines 不像线程那样提供身份和类似的管理身份的方法。通道通常取代了 Java 中对线程本地的需求。

Goroutines 示例

与 Java 一样,没有语言手段来测试 goroutine 的完成情况,但是标准库函数确实存在。Java 使用Thread.join()方法来实现这一点。在 Go 中,一种常见的方式是通过 WaitGroups (WG)。WG 实际上是一个递增/递减计数器,客户可以在这里等待它倒数到零。完成此操作的常用方法如下:

var wg sync.WaitGroup
:
wg.Add(1)
go func() {
      defer wg.Done() // idiomatic. Done() is equivalent to Add(-1)
      :
}()
:
wg.Add(1)
go func() {
      defer wg.Done()
      :
}()
:
wg.Wait()

在每个 goroutine 启动之前,WG 都会递增。此增量必须在 goroutine 主体之外才能正常工作。在每个 goroutine 中,当 goroutine 结束时,WG 递减Done。然后,启动的 goroutine 等待(暂停)所有(可以有任意数量的 go routine)启动的 go routine 结束。

下面是一个类似的 Java 解决方案:

var threads = new ArrayList<Thread>();
var t1 = new Thread(() -> {
  :
});
t1.start();
threads.add(t1);
:
var t2 = new Thread(() -> {
  :
});
t2.start();
threads.add(t2);
:
for (var t : threads)
  try {
    t.join();
  } catch (InterruptedException e) {
    // ...
  }
}

Go 频道也可以做类似的事情:

var count int
// support up to 100 completed before any blocked
var done = make(chan bool, 100)
:
count++
go func() {
      defer sayDone(done)  // must be a function call
      :
}()
:
count++
go func() {
      defer sayDone(done)  // must be a function call
      :
}()
:
waitUntilAllDone(done, count)

func sayDone(done chan bool) {
      done <- true
}
func waitUntilAllDone(done chan bool, count int) {
      for count > 0 {
            if <- done {
                  count--
            }
      }
}

清单 4-2 显示了作为完全可运行示例的前述方法的一个稍微不同的表达式。

package main

import (
      "fmt"
)

var count int
var done = make(chan bool, 100)

func sayDone(index int) {
      done <- true
      fmt.Printf("go %d done\n", index)
}

func waitUntilAllDone(done chan bool, count int) {
      for count > 0 {
            if <-done {
                  count--
            }
      }
}

func main() {
      fmt.Println("Started")
      for i := 0; i < 5; i++ {
            count++
            go func(index int) {
                  defer sayDone(index)
                  fmt.Printf("go %d running\n", index)
            }(i)
      }

      waitUntilAllDone(done, count)
      fmt.Println("Done")
}

Listing 4-2Complete Example of the Use of Channels

它产生以下输出:

Started
go 4 running
go 1 running
go 1 done
go 0 running
go 0 done
go 4 done
go 3 running
go 3 done
go 2 running
go 2 done
Done

如果您将done通道的大小减少到 1,您会得到如下输出:

Started
go 4 running
go 4 done
go 3 running
go 3 done
go 1 running
go 1 done
go 0 running
go 0 done
go 2 running
go 2 done
Done

请注意,交叉工作较少。通道的容量会强烈影响使用它的 goroutines 的并行性。还要注意,如果在sayDone(...)中将"fmt.Printf(...)"放在"done <- true"之前,输出模式可能会不同。

Go 与前面在包sync/atomic中讨论的 Java 中的原子值等价:

var sum int32
:
atomic.AddInt32(&sum, 100)

Go 例程的行为可能不可预测,尤其是当多个 Go routines 同时运行时。考虑清单 4-3 中所示的简单例子。

package main

import (
      "fmt"
      "time"
)

func printNum(id string, count int) {
      for i := 0; i < count; i++ {
            fmt.Printf("%s: %d\n", id, i)
            delay := time.Duration(rand.Intn(10)) * time.Millisecond
            time.Sleep(delay)  // delay a bit
      }
}

func main() {
      printNum("one", 5)
      printNum("two", 5)
      printNum("main", 5)
}

Listing 4-3Complete Serial Printing Example

能出来什么?具体如下:

one: 0
:
one: 4
two: 0
:
two: 4
main: 0
:
main: 4

但是有了这个小小的改变:

func main() {
      go printNum("one", 5) // now a goroutine
      go printNum("two", 5) // now a goroutine
      printNum("main", 5)
}

能出来什么?可能是与之前相同的行,但是以任何可能的顺序(其中前面的顺序是不可能的)。但是可能只有一些one和/或two线出来。这是因为 Go 调度程序只能运行准备好的 goroutines(而Sleep让它们不准备好),但是可以按任何顺序运行,并且可以随时在它们之间切换。同样,main函数也运行在一个 goroutine 中,它可能会在其他 go routine 完成之前结束,导致程序结束。因此,goroutines 通常表现得像 Java 中的守护进程线程一样。

下面是一个输出示例:

main: 0
one: 0
two: 0
main: 1
two: 1
one: 1
two: 2
main: 2
main: 3
two: 3
two: 4
one: 2
main: 4
one: 3

作为使用 goroutines 的最后一个例子,让我们提供一个程序,它可以并行压缩所有命名为命令行参数的文件。它使用“去库调查”部分中定义的CompressFileToNewGZIPFile函数。它有这样的签名:

func CompressFileToNewGZIPFile(path string) (err error)

我们的main是这样定义的(用一个虚拟版本的CompressFileToNewGZIPFile只是为了演示并发性)。

package main

import (
      "fmt"
      "log"
      "math/rand"
      "os"
      "sync"
      "time"
)

func CompressFileToNewGZIPFile(path string) (err error) {
      // dummy compression code
      fmt.Printf("Starting compression of %s...\n", path)
      start := time.Now()
      time.Sleep(time.Duration(rand.Intn(5) + 1) * time.Second)
      end := time.Now()
      fmt.Printf("Compression of %s complete in %d seconds\n", path,
      end.Sub(start) / time.Second)
      return
}

func main() {
      var wg sync.WaitGroup
      for _, arg := range os.Args[1:] { // Args[0] is program name
            wg.Add(1)
            go func(path string) {
                  defer wg.Done()
                  err := CompressFileToNewGZIPFile(path)
                  if err != nil {
                        log.Printf("File %s received error: %v\n", path, err)
                        os.Exit(1)
                  }
            }(arg)  // prevents duplication of arg in all goroutines
      }
      wg.Wait()
}

Listing 4-4Parallel File Compression Example

这会产生以下命令行输出,包括

file1.txt file2.txt file3.txt file4.txt file5.txt

Starting compression of file5.txt...
Starting compression of file1.txt...
Starting compression of file3.txt...
Starting compression of file2.txt...
Starting compression of file4.txt...
Compression of file4.txt complete in 2 seconds
Compression of file5.txt complete in 2 seconds
Compression of file1.txt complete in 3 seconds
Compression of file3.txt complete in 3 seconds
Compression of file2.txt complete in 5 seconds

请注意,goroutines 以不可预测的顺序开始。

值得注意的是,goroutine 不能向其调用者返回结果,因此 goroutine 中出现的结果(或错误)必须以其他方式报告。在这个例子中,它们被记录(并且程序被终止),但是通常有一个通道,在这个通道上这样的错误(或结果)被报告给通道监听器。

回到关键部分。与 Java 不同,Go 中没有同步的语句或块。经常使用的是锁柜接口。本质上是

type Locker interface {
      Lock()    // better named: WaitUntilAvailableAndLock()
      Unlock()  // better named: UnlockAndThusMakeAvailable()
}

sync.Mutex类型实现了这个接口。它可用于控制对关键部分的访问。它的基本用法如下:

var mx sync.Mutex
:
func SomeAction() {
      mx.Lock()
      defer mx.Unlock()
      : do something that is a critical section

}

注意锁不允许同一个 goroutine 重入,就像synchronized使用 Java 线程一样。所以,小心使用它们以防止自我死锁。

通道可以做类似的事情:

var ch = make(chan bool, 1)  // allow only one received message at a time
:
func SomeAction() {
      ch <- true
      defer func() {
            <- ch   // discards the value
      }()
      : do something that is a critical section
}

如果没有空间接受该值,则顶部的 send 会阻塞。因为通道只能容纳一个值,所以只能允许一个用户。底部的 receive 删除该值。通过增加通道的大小,我们可以允许有限数量的 goroutines 并发地进入操作。

Go 有一个额外的选项来避免锁定关键部分。通过以其他方式使用通道,正如本文后面所讨论的,锁通常可以被消除。相反,数据是通过通道在消费者之间传输的,所以关键部分根本不存在。这通常是首选。

Footnotes 1

由于 JSE JRE 不提供对 HTTP 服务器的标准支持,所以没有直接的 Java 等价物。需要像 Spring 或 JAX-RS 服务器这样的第三方代码。

  2

https://en.wikipedia.org/wiki/Git

  3

EXE 是可执行文件的 Microsoft Windows 名称(基于使用的文件扩展名)。其他操作系统使用不同的术语。

  4

https://projectlombok.org/

  5

https://en.wikipedia.org/wiki/Prometheus_(software)https://prometheus.io/

  6

https://en.wikipedia.org/wiki/Lint_(software)

  7

https://en.wikipedia.org/wiki/Checkstyle

  8

https://en.wikipedia.org/wiki/FindBugs

  9

https://github.com/spotbugs/spotbugs

  10

https://en.wikipedia.org/wiki/Java_Native_Interface

  11

https://en.wikipedia.org/wiki/Executable

  12

https://en.wikipedia.org/wiki/Call_stack

  13

https://en.wikipedia.org/wiki/Memory_management

  14

https://en.wikipedia.org/wiki/Garbage_collection_(computer_science)

  15

https://dougrichardson.us/2016/01/23/go-memory-allocations.html

  16

https://blog.golang.org/pprof

  17

一些 Go 实现可能会将常量转换成只读变量的等价物。

  18

https://golang.org/ref/mod

  19

https://search.maven.org/

  20

在形式语言理论中,文字也是术语。

  21

https://golang.org/ref/mem

  22

https://golang.org/doc/go1compat

  23

https://en.wikipedia.org/wiki/Communicating_sequential_processes

  24

https://en.wikipedia.org/wiki/Actor_model

  25

https://en.wikipedia.org/wiki/Akka_(toolkit)

  26

当操作系统线程不可用时,在早期的 Java 实现中使用。Java 的早期版本被命名为 Oak,然后是 Green。所以选择了绿色。 https://en.wikipedia.org/wiki/Green_threads

  27

一个 Java 线程通常需要几兆的内存来支持它的状态。相比之下,goroutine 通常只需要几千字节的内存。这是三个数量级的差异。

  28

https://golang.org/pkg/context/

 

**