写给Java程序员的Go语言学习指南(一)

Hello我的读者们(假装有)好久不见,作为一个Javaer转Gopher已经有一段时间了,近期打算写一个对Java程序员友好的转型Go语言的系列文章(说不定有些人有这个需求呢),因为没有系统学习过Go语言,所以写这个系列的目的还包括巩固一下自己的Go语言基础,目前我还是一个Go语言新手,所以我的一些理解不一定正确,如果我以后对这个系列中提到的内容有了更深的理解,会回来修订的。

这个系列的目标读者是有一定编程基础的Java码农,因此我在后面讲Go语言基础的时候不会详尽的讲一些所有编程语言中都较普遍的语法,而倾向于讲Go中的特色语法,并将其和Java进行比较,带你快速上手Go。

另外,作为一个已经工作了的社会人儿~以后的文章会偏原创的,尽量写我参阅官方文档和实际工作中学到的内容,避免总是借鉴别人的内容。

那么进入正题,首先,当我们初学一门技术的时候,都得了解清楚这门技术的优缺点,明白我们为什么需要学习这门技术。

所以,同样的,让我们先来解决这个问题:为什么要学习Go语言?

为什么要学习Go语言

Go语言的介绍

首先,Go语言是一门高级语言,并且是一门服务端语言,这是最基础的,可以拿它和Java、Python这样的语言进行比较,但不太适合将其与SHELL、JavaScript、C这类语言进行比较。

Go语言也是一门C-like语言,这是指它属于C-family中的一员,是C语言的衍生语言。但我个人觉得Go语言的语言和C、Java并不是非常相似,特别是当你遇到没有while、变量名在类型前、面向接口编程等语法特性时,可能需要好好适应一会儿。

所以,既然有这么多后端语言了,我们为什么还要学习Go语言呢,下面就来看看Go语言的一些特点。

Go语言的特点

语法简单

Go语言相比起其他面向对象的语言少了很多高级特性,例如不存在泛型、没有继承、没有如Java一般诸多内建的工具类、集合类,也没有如Python一些非常简化的语法如列表生成式、map-reduce。在某些观点中,这些高级特性有时会使代码变得臃肿、可读性降低,而Go的语法非常简单,除了作为一门编程语言最基本的流程控制、变量声明、运算操作之外,其他的功能并没有太多,这使得Go代码的可读性较好,但同样会造成一些不方便。

智者见仁,仁者见智,有些人也觉得Go语言的代码可读性差,关于这点,你可以看下面几篇我的leetcode刷题笔记,比较一下Java、Go、Python的写法各具有什么特点。

轻量的协程支持

Go语言的并发模型也是Go语言引以为傲的特性之一。

关于协程(coroutinue)是什么,可以看我的另一篇笔记深入理解java虚拟机第三版读书笔记12,通俗来说,协程就是由应用程序模拟的线程(用户级线程),操作系统感知不到协程的存在,协程比线程更轻量,由于是应用程序实现的调度模型,因此协程间的调度不需要经过内核,也不需要频繁的两态转换。

在Go语言中,协程是内建支持的,并且创建一个协程的方法也非常简单,仅需一个关键词go

跨平台的编译

Go语言使用了提前编译(Ahead of Time,AOT)的技术,因此Go语言程序在运行时是很快的。但是AOT有个问题就是只能编译成指定平台的机器码,无法“一次编译,到处运行”。

那么这里说的“跨平台”的编译是指什么呢?默认我们使用go build编译出来的可执行文件都是当前操作系统可执行的文件,但假如我想在windows下编译一个linux下可执行文件,可以这样做:

SET CGO_ENABLED=0  // 禁用CGO
SET GOOS=linux  // 目标平台是linux
SET GOARCH=amd64  // 目标处理器架构是amd64

go build

CGO是Go语言提供的一个能够在go代码中直接调用C的库函数的功能,使用了CGO的代码是不支持跨平台编译的

可以看到,只需要指定目标操作系统的平台和处理器架构,即可实现“跨平台”地编译Go语言可执行文件。

其他的例子:

  1. Mac 下编译 Linux 和 Windows平台 64位 可执行程序:
// linux
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 
go build
// windows
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 
go build
  1. Linux 下编译 Mac 和 Windows 平台64位可执行程序:
// mac
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 
go build
// windows
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 
go build
  1. Windows下编译Mac平台64位可执行程序:
SET CGO_ENABLED=0
SET GOOS=darwin
SET GOARCH=amd64
go build

安装 & Go指令

安装

安装当然是去官网下载了:https://golang.google.cn/dl/ ,如果这个网站打不开,可以找国内的一些镜像资源站进行下载。

这个系列使用的Go语言版本是1.14.7,希望读者尽量选用比这个版本或更高版本。

作为一个Java码农,安装对你肯定不是问题了,但重要的是在安装完Go之后有几个环境变量需要配置(Go的特色):

  • GoRoot:Go的安装目录
  • GoPath:Go的工作区目录,建议自己建一个

可以参考一下我MacOS上的配置:

GOPATH=/Users/rhett/go_repos
GOROOT=/usr/local/go

Go指令

Go指令当然要看官方的帮助文档了:

# go
Go is a tool for managing Go source code.

Usage:

    go <command> [arguments]

The commands are:

    bug         start a bug report
    build       compile packages and dependencies
    clean       remove object files and cached files
    doc         show documentation for package or symbol
    env         print Go environment information
    fix         update packages to use new APIs
    fmt         gofmt (reformat) package sources
    generate    generate Go files by processing source
    get         download and install packages and dependencies
    install     compile and install packages and dependencies
    list        list packages or modules
    mod         module maintenance
    run         compile and run Go program
    test        test packages
    tool        run specified go tool
    version     print Go version
    vet         report likely mistakes in packages

这里不详细讲各个指令了(说不定之后会单独写一篇文章来讲这些指令),使用go help [command]都可以看到对应的帮助文档,后面我们也会常常用到一些指令。

Go语言的HelloWorld

又到了我最喜欢的入门经典HelloWorld环节,直接上代码:

package main
import "fmt"

func main() {
    fmt.Println("hello world!")
}

其中:

  • package 语句声明当前文件所属的包
  • import 语句用于引入包,这里fmt是一个内建包
  • func 声明函数
  • fmt.Println 是Go语言中的输出语句,借助了内建包fmt的功能。

现在我还没有提到Go语言的IDE,那么在不借助IDE的条件下,我来教你如何运行起这样一个Go代码文件:

假设你已经向一个名为main.go的文件中写入了这段代码,当你的文件不在GoPath目录及其子目录下的时候,可以使用go run [文件名]直接编译+运行这个程序:

# go run main.go
hello world!

或者你可以先编译再运行:

# go build main.go
# ./main
hello world!

你也许已经发现,go build得到的产物就是一个可执行文件。

你也可以使用类似GCC的语法为编译产物取个名字:

# go build -o helloworld main.go
# ./helloworld
hello world!

Go语言常见FAQ(持续更新翻译)

学习任何一门语言时,其官方文档才是最权威的,对于Go语言来说,我觉得学习Go语言必读的是官网的FAQ list,里面阐述了Go语言的设计理念和设计思想,也解决了一些初学者容易产生的误区。

这里列出的FAQ,作为初学者不一定需要立刻看完,尤其是除了设计理念的后面的一些问题,我的建议是学完了Go预言的基本语法之后,可以回来看一看这部分内容。

下面翻译一些我觉得特别重要的Q&A:

Go语言设计的指导原则是什么?

在设计Go语言之初,Java和C++是在Google内部最常用的服务端语言。我们认为这些语言(Java和C++)具有过多需要重复记忆的教条。一些程序员希望转向高效、流畅的语言(如Python),但代价是失去效率和类型安全。我们觉得应该用一种语言就能兼顾效率,安全性和流畅性。

Go语言尝试减少关键字的数量和含义。在整个设计过程中,我们试过减少复杂性:没有前向声明,也没有头文件;一切都只被声明一次。初始化语句的表达力强,且易于使用。关键字的语法简洁明了。复杂的变量声明例如foo.Foo* myFoo = new(foo.Foo)通过使用:=这个声明并初始化的操作符被简化。并且或许最根本是,Go语言中没有类型层次(继承):一个类型只能是它自己,而不必去声明它和其他类型的关系。通过这些简化,Go可以在不牺牲设计的精巧性的前提下变得具有表现力并易于理解。

另一个重要的设计原则是保持类型概念的正交【博主注:正交性,由Orthogonality翻译而来,这里可以理解为类型之间的独立性,降低耦合度】。可以为任何类型实现接口方法;结构体代表数据,而接口代表抽象,等等。正交性使人们更容易理解当一些类型组合时代表了什么。

Unicode标识符是怎么回事?

在设计Go语言时,我们要确保它不是以ASCII码为中心的,这意味着要扩展一些语言标识符限于7位ASCII范围的空间。目前Go语言的规则(标识符字符必须是Unicode定义的字符或数字)易于理解和实现,但有一定的限制。例如,组合字符不被允许,并且排除了某些语言,例如梵文。

这条规则还有另一个问题。由于在Go语言中exported(包外可见)的标识符必须以大写字母开头,因此,不能使某些语言的字符组成的标识符exported。目前唯一的解决方案是使用X日本語这样的形式,这显然不能令人满意。

自从Go语言的最早版本发布以来,我们就一直在思考如何最好地扩展标识符空间以兼容不同地区的程序员可以使用他们自己的语言。确切地说,这个问题如何解决仍然是讨论的活跃话题,并且Go语言的未来版本在标识符定义方面可能会更加宽松。例如,它可能会采用Unicode组织关于标识符的建议中的一些想法。但是无论发生什么情况,都必须在兼容(或扩展)字母大小写确定标识符可见性的方式下完成,这仍然是Go最受欢迎的功能之一。

目前,我们有一个简单的规则,可以在以后扩展而又不会破坏旧代码的兼容性,该规则修复了因允许歧义标识符的规则而引起的bug。


理解:Go语言的标识符不受限于ascii字符,可以使用很多语言的字符,但没有必要。

为什么Go语言没有泛型

在某个时间点我们可能会添加泛型的支持。即使有些程序员觉得这是一件紧急的事,但我们并不觉得。

Go语言的目标是编写出长时间易维护的服务端程序。其设计集中在伸缩性,可读性和并发性等方面。当时,多态编程对于实现该语言的目标似乎并不重要,因此为了简单起见而被略去。

Go语言现在已经更加成熟,可以考虑实现某种形式的泛型编程。但是,仍然存在一些警告。

泛型很方便,但是它以类型系统和runtime的复杂性为代价。我们一直在思考但始终尚未找到一种能平衡其带来的价值与复杂度之间的方案。同时,Go语言内置的map和slice以及其能够使用interface{}构造容器的能力(带有显式拆箱),意味着一些情况下它能写出和泛型能实现的功能相似的代码(可能不是那么平滑)。

这个话题保持open状态。要查看之前Go语言尝试过的设计泛型编程的失败案例,请参阅此提案

为什么Go语言没有异常?

我们认为如同try-catch-finally这种习惯用法将异常耦合到逻辑控制结构会导致代码混乱。它还有鼓励程序员将许多常见的错误(例如无法打开文件)标记为异常的倾向。

Go语言采用了不同的错误处理方法。对于简单的错误处理,Go的多返回值会使报告错误变得容易,而不需要返回值重载。规范的error类型,与Go语言的其他一些特性相结合,能使错误处理令人愉悦,同时与其他语言完全不同。

Go还具有一些内建函数,可以发出信号并从真正“异常”的状况中恢复(recover)。恢复(recover)机制仅在发生错误后被破坏的一部分函数状态中执行,该机制足以处理灾难,但不需要任何额外的控制结构,如果使用得当,可以生成整洁干净的错误处理代码。

相关详细信息,请参见Defer, Panic, and Recover。另外,Errors are values这篇博客文章描述了Go语言中错误即为值对象从而可以在错误处理中发挥的巨大作用。

为什么基于CSP的思想设计Go语言中的并发?

并发和多线程编程长时间因其难以学习而闻名,我们认为其中一部分原因是pthreads这样复杂的设计造成的,另一部分原因则是过于强调互斥锁、条件变量、内存屏障这样的底层细节。即使底层有这些互斥锁,更高层次的语言接口应该能写出更简单的代码。

Hoare设计的CSP(Communicating Sequential Processes)模型是为高级语言提供并发支持的最成功模型之一。Occam和Erlang是基于CSP思想设计的两种最知名的语言。Go的并发原语来自贡献出将channel作为最高等对象的重要思想的家族树的不同分支(不知道咋翻译,原句:Go's concurrency primitives derive from a different part of the family tree whose main contribution is the powerful notion of channels as first class objects.)。几种早期语言的经验表明,CSP模型非常适合过程语言框架。

为什么用goroutine代替线程?

Goroutines是为了让并发编程更易于使用做出的努力之一。有一个存在了一段时间的想法是,将一个独立执行的函数协程服用到一组线程上去。当一个协程阻塞时, 例如通过一个产生阻塞的系统调用, runtime会自动将同一线程中其他协程转移到另一个runnable的线程中,这样它们就不会被阻塞,而程序员看不到这些底层实现。这样会使我们使用goroutine的成本很低:它们在栈上的开销很小,通常只有几kb。

为了使栈空间使用更少, Go语言的runtime使用可扩容的、与goroutine绑定的私有栈。 新创建的goroutine只会被分配几个kb, 而这通常已足够使用。当这些空间不够时, runtime会自动扩容(缩容)内存以容纳栈空间,这容许许多goroutine共存于现代容量巨大的内存中。每个函数调用的CPU开销平均只有三条简单的指令,在同一片内存空间创建成百上千的goroutine是十分有用的。如果不用goroutine而是使用线程, 系统资源会被更容易消耗殆尽。

Go是一门面向对象的语言吗?

可以说是或者不是。尽管Go语言的类型和方法允许面向对象风格的编程, 但没有类型层级(继承)。Go语言中“接口”的概念提供了一种我们认为更易使用并在某种程度上更通用的使用方法。Go语言有一些方法可以将类型嵌入到其他类型中,以实现类似(但不完全相同)其他语言中继承的功能。还有, Go语言的方法比C++或Java中的更通用:它们可以为任何数据类型定义, 例如“拆箱”的整数这样的内置的类型。方法的实现并不限定在结构体(类)中。

并且,Go语言中缺少类型继承导致Go中的”对象“比C++、Java中的更轻量。

怎样能够动态分派方法呢?

Go语言中只有一种途径可以动态分派方法:通过接口。结构体或其他确定的类型的方法总是静态解析的。

为什么Go语言不支持重载方法或操作符?

如果不去做类型匹配,方法分派会非常简单。其他语言的经验告诉我们大量相同名称但不同方法签名的函数有时会很有用,但在实践中可能会造成混淆。仅通过名称匹配并要求类型一致是一个主要使得Go类型系统简化的决定。

至于操作符重载, 貌似其必要性不超过其便利性。没有它,语言能更加简化。

可以将[]T转换为[]interface{}吗?

不能直接转换,没有相同内存指向的类型直接转换是不符合语言规范的。单有必要单独地将原切片中的元素逐一地复制到目标切片中去。例如下面这段代码将int切片复制到interface{}切片中去:

t := []int{1, 2, 3, 4}
s := make([]interface{}, len(t))
for i, v := range t {
    s[i] = v
}

如果T1和T2所指同一个类型,可以将[]T1转换为[]T2吗?

下面这段例子中最后一行代码是不能编译的:

type T1 int
type T2 int
var t1 T1
var x = T2(t1) // OK
var st1 []T1
var sx = ([]T2)(st1) // NOT OK

Go语言中,类型是紧密绑定到方法的, 每个命名类型都有属于自己的一个方法集合(可能是空的)。 大体上的规则是,你可以改变转换后的类型名称(同时也会改变它的方法集合),但你不能改变一个集合类型中元素的类型名和方法集合. 并且Go语言要求显式的类型转换。

为什么error的nil值不等于nil?

在底层, 接口被实现为两个部分, 一个类型T和值V。V是一个确定的值例如整数、结构体或指针, 但不会是接口本身, 并且有一个类型T。举个例子,如果我们在接口中存储一个整数3, 最终的接口值为 T=int, V=3。值V也被称为接口的动态值,鉴于在程序执行过程中,一个给定的接口变量在不同时间可能会有不同的值V。

仅当V和T都没有设定时,一个接口值才是nil(T=nil, V is not set),特别的,一个nil接口总是有一个nil类型。如果我们存储一个*int类型的nil指针在接口中, 内部类型仍会为*int而不管值V是什么(T=*int, V=nil)。因为这样一个接口即使其值V为nil,它也可以不是一个nil。

这样的情形可能会变得令人疑惑,特别是一个nil存储在接口例如error中进行返回:

func returnsError() error {
    var p *MyError = nil
    if bad() {
        p = ErrBad
    }
    return p // Will always return a non-nil error.
}

如果一切顺利,这个函数会返回一个nil p,于是这个返回值是容纳在一个error接口中的 (T=*MyError, V=nil)。这意味着如果调用者将这个返回的error与nil比较,看起来似乎总会是有错误发生,即使实际一切正常。为了返回一个正确的nil error给调用者,这个函数必须返回一个显式的nil:

func returnsError() error {
    if bad() {
        return ErrBad
    }
    return nil
}

在函数中返回错误,为了保证错误被正确创建,在方法签名中使用error类型代替一个精确的错误类型(例如*MyError)总是一个好的实践(就像我们上面做的那样)。举个例子, os.Open 始终返回一个error,如果不是nil,则必是一个特定类型*os.PathError

类似这样的情况在使用接口时都会出现。记住只要接口中存储了任何具体值,它就不会为nil。想了解更多信息,请参阅The Laws of Reflection


代码示例:

Go语言中的常量是什么样的?

尽管Go语言对数字类型的变量之间的类型转换要求很严格, 常量却灵活多了。字面常量例如23,3.14159和math.Pi可以占用理想的内存空间、任意的精度,且不会上溢或下溢。例如math.Pi的值在源码中限定在63位,但涉及这个数的常量表达式却可以超过float64的计算精度。仅当这个常量或常量表达式被赋值给一个内存中的变量时,它才成为了一个“电子化”的数字并按照浮点数的标准舍去精度。

另外,因为他们只是数字,没有指定类型,所以Go语言中的常量比变量更容易使用,避免了类型转换问题的尴尬。你可以写这样的表达式:

sqrt2 := math.Sqrt(2)

编译器不会报错,因为在调用math.Sqrt函数时可以把参数2安全地将类型、精度转换为float64.

Constants 这篇博客详细地探讨了该主题。

64位机器上的int占用多大空间?

intuint的大小是独立实现的,但在一个给定的平台上占用的空间总是相同的,在32位机器上编译器使用32位整数,而64位机器上使用64位整数(尽管从历史上看,这不总是正确的)。为了实现可移植性,需要特定大小的值应该使用带有显式大小的类型,例如int64。。

另外,出于编程人员在使用浮点数时必须注意精度带来的影响的考虑,浮点数和复数的大小总是限定的(没有floatcomplex这两种基本类型)。浮点数字面量默认使用的类型是float64,例如foo := 3.0声明了一个float64的变量。如果要使用字面常量初始化一个float32的变量,必须通过下面的变量声明显式指定类型:

var foo float32 = 3.0

或者,你可以通过类型转换做到同样的效果:foo := float32(3.0)

如何知道变量是在堆上还是在栈上分配?

从程序正确性的角度来看,你不需要关心。只要保留有引用,Go语言中的每个变量都会持续存在。至于实际选择的存储位置不会影响编程时的语义。

然而了解存储位置确实对于编写高效的程序有帮助。Go编译器会尽可能在函数的私有堆栈帧中分配该函数的本地变量。但是,如果编译器无法证明函数返回后该变量就不会被引用,则编译器必须在堆上分配该变量,以避免悬空指针错误。同样,如果局部变量很大,则将其分配在堆而不是栈上可能更为合理。

在当前的编译器中,如果使用了变量的地址,则该变量大概率是在堆上分配。但是,基本的逃逸分析可以识别某些变量不会逃出函数作用范围的情况,此时变量可以分配在栈上。

哪些操作是原子的?互斥量又是什么?

Go语言并发操作相关的描述可以参阅文档Go Memory Model document

低级的同步和并发原语可以在syncsync/atomic中找到。这两个包适用于一些简单的任务例如增加引用计数或保证小规模的互斥。

为了支持更高级别的操作,例如分布式服务器之间的协作,使用更高层次的技术可以方便写出更好的程序, Go语言通过gorountine和channel提供了对于这些的支持。 例如,你可以将你的程序构建成同一时间只有一个goroutine负责一块特定的数据处理。这种方法在Go proverb中有所介绍。

不要通过共享内存来通信,你应该使用通过通信来共享内存。

参阅Share Memory By Communicating代码部分,还有它的关联文章是对于这一概念详细的讨论。

大型并发程序很可能会同时借助这两个工具包的功能。

为什么goroutine没有ID?

Goroutine没有名称,他们是无名工作者. 它们不向编程人员暴露出唯一标识符、名称或数据结构。有些人会对此感到惊奇,期望着之后会出现能返回一些能用来访问和控制goroutine的Go语句。

Goroutine是匿名的最根本的原因是,只有这样,在编写并发代码时,所有的Go语句都能被使用。否则在使用命名线程和goroutine的时候会限制于使用它们的库中。

这是一个命名goroutine造成复杂度的例证:一旦某个人对goroutine进行命名并围绕它创建了模型,它就变得非常特别,并且人们会倾向于将所有的计算任务与这个goroutine关联起来,而忽视了使用多个可能共享的goroutine进行处理的可能性。倘若 net/http 包为每一个request状态关联一个goroutine,客户端在处理一个请求时将不能使用更多的goroutine。

另外,在一些要求在主线程上处理所有计算的图形系统的库的经验表明,当在一个并发语言中部署时,这种情况的尴尬和局限性。特殊线程和goroutine的存在迫使编程人员对程序进行不必要的操作以避免操作了错误的线程而导致的崩溃和其他问题。

对于那些特定的goroutine确实尤为特别的情况,Go语言提供了一些诸如channel的功能,通过灵活使用它来与特定goroutine交互。

为什么++和--是语句而不是表达式?为什么使用后缀而不是前缀?

如果没有指针运算,则前缀和后缀增量运算符的便利性将会下降。将它们从表达式中彻底删除,可以简化表达式语法,并且还消除了围绕++和--的求值顺序的混乱问题(考虑f(i++)p[i] = q[++i])。简化的意义重大。而至于后缀和前缀,两者都可以正常工作,但后缀版本更传统。STL坚持使用前缀增量运算符,STL是一种语言的标准库,而具有讽刺意味的是,这种语言的名称包含后缀增量运算符。

博主注:解释一下,这里是一个冷笑话,因STL指的是C++的Standard Template Library(标准模板库),而C++的名称你懂的。

原创文章,作者:彭晨涛,如若转载,请注明出处:https://www.codetool.top/article/%e5%86%99%e7%bb%99java%e7%a8%8b%e5%ba%8f%e5%91%98%e7%9a%84go%e8%af%ad%e8%a8%80%e5%ad%a6%e4%b9%a0%e6%8c%87%e5%8d%97%ef%bc%88%e4%b8%80%ef%bc%89/

发表评论

电子邮件地址不会被公开。