原文:《GoForCPPProgrammers》(2018年8月29日版本)

Go希望自己能成为一门像C++那样通用的系统编程语言,而这里有一些面向C++老鸟的笔记。这篇文章主要讨论了Go与C++之间的差异,以及两者间的一点点相似性。

想要精通这两门语言,一定要记住:他们拥有完全不同的思维方式。最重要的是:C++的对象模型基于类以及类的层次结构,而Go的对象模型基于(基本平坦的)接口。因此,C++的设计模式很少会被直接翻译为Go。为了高效地使用Go编程,程序员需要重新考虑如何解决面临的问题,而不是照搬过去在C++中使用的解决方案。

关于Go的更通用的介绍,请看Go TourHow to Write Go Code以及Effective Go

关于Go的详细介绍,请看Go spec

概念的区别

  • Go没有类(class),也没有其附带的构造函数、析构函数。Go没有类方法,没有类继承、虚函数。取而代之,Go提供了接口(下文会详细讨论)。接口也能替代C++的模板。
  • Go提供全自动的内存垃圾回收。使用Go时不必要(也不可以)手动释放内存,也无需困扰内存开辟于堆还是栈,无需纠结使用new还是mallocdelete还是delete[]还是free,也不用分开管理std::unique_ptrstd::shared_ptrstd::weak_ptrstd::auto_ptr和不够智能的普通指针。Go的运行时会处理这些程序员容易弄错的工作。
  • Go虽然拥有指针,但不支持指针运算,Go的指针更接近C++的引用。举个例子:我们不能使用Go指针来遍历一个字符串的所有字节。当我们编码时需要指针运算这个特性,通常可以使用slice(后文会详细讨论)来替代。
  • Go默认是“安全”的。指针并不能随意指向任意内存地址——因为缓冲区溢出常常会导致崩溃或安全漏洞。如果一定需要手动操作指针,我们可以使用unsafe包来绕过Go的安全保护措施。
  • 数组(Array)是Go的一类公民。当数组被作为参数传递给函数,函数将会收到该数组的拷贝,而不是指针。不过,在实践中通常使用slice替代数组来扮演参数;slice包含了指向底层数组的指针。(slice会在后文进行进一步讨论)
  • 字符串(String)是被Go原生支持的。一旦字符串被创建,就不能被改变。
  • Go语言提供哈稀表。它们叫map。
  • Go提供独立的代码执行流(goroutine),以及在它们间充当沟通作用的信道(channel)。这些会在后文进一步讨论。
  • 某些类型(map、channel——这些会在后文讨论)被以传引用的方式传递,而不是以传值的方式。也就是说,将map传递给某个函数并不会拷贝这个map,当函数改变了该map,这些改变对调用者也是可见的。按照C++的习惯,我们可以把它们看作引用类型。(译者注:此处的意思仅仅是这些类型“看起来”像引用类型,其本质仍然是指针)
  • Go不使用头文件。在Go中,每个文件都是某个包的一部分。当一个包定义了一个以首字母大写格式命名的对象(类型、常量、变量或者函数),该对象对所有引用该包的外部文件是可见的。
  • Go不支持隐式类型转换。混合了不同类型的操作需要手动进行类型转换(Type Conversions)。即使是相同类型的不同别名之间也是如此。
  • Go不支持函数重载和运算符重载。
  • Go不支持const和volatile限定符。
  • Go使用nil表示非法指针,就像C++使用NULL0(在C++11中,使用nullptr)。
  • Go习惯于使用多返回值来传递错误——一个或多个返回结果加上一个error——用来取代错误标记值(例如-1)或者结构化的异常捕获机制(C++的trycatchthrow,或者Go的panicrecover)。

语法

Go的声明语法与C++相反,你需要紧跟着变量名写变量类型。不同于C++,类型语法和变量使用方法不匹配。Go的类型声明更易于从左往右阅读。(var v1 int → “变量v1是一个int“。)

// Go C++
var v1 int // int v1;
var v2 string // const std::string v2; (近似地)
var v3 [10]int // int v3[10];
var v4 []int // int* v4; (近似地)
var v5 struct { f int } // struct { int f; } v5;
var v6 *int // int* v6; (但Go没有指针运算)
var v7 map[string]int // unordered_map<string int="int">* v7; (近似地)
var v8 func(a int) int  // int (*v8)(int a);
```</string>

声明通常采用“关键字后紧跟对象名”的格式。关键字可以是`var`、`func`、`const`或者`type`。方法声明是一个例外,接收者(receiver)会出现在方法名的前面。参考:[discussion of interfaces](https://github.com/golang/go/wiki/GoForCPPProgrammers#interfaces)。

你也可以在括号里使用一个关键字紧跟多个声明。

```go
var (
i int
m float64
)

当声明一个函数的时候,你要么为每个参数提供名字,要么不为任何参数提供名字。(C++允许void f(int i, int);,但Go不允许。)不过为了方便,在Go中你可以使用一个类型一次性标记一组名字:

func f(i, j, k int, s, t string)

一个变量在声明的时候可以被初始化。初始化时同样可以手动指定类型,当然这不是必须的。当类型未被指定,变量的类型取决于初始化表达式。

var v = *p

也可以参考discussion of constants, below。如果一个变量未被手动初始化,变量类型一定要被指定。此时变量会被隐式初始化为该类型的零值(0nil等)。在Go中不存在未被初始化的变量。

在函数中,:=是一种简略的声明语法。

v1 := v2 // C++: auto v1 = v2;

其等效于

var v1 = v2 // C++: auto v1 = v2;

Go允许并行的多个赋值。首先右边的值会被计算,然后它们会被赋给左边的变量。

i, j = j, i // 交换 i 和 j

函数可以拥有多个返回值,声明时需要放在括号里面。可以通过赋值给多个变量的方式存储多个返回值。

func f()(i int, j int) { ... }
v1, v2 = f()

多返回值是Go语言错误处理机制采用的主要手段:

result, ok := g()
if !ok {
// 有不好的事请发生
return nil
}
// 继续运行
...

也有更简洁的方式:

if result, ok := g(); !ok {
// 有不好的事发生
return nil
}
// 继续运行

...

在实践中Go代码很少会用到分号。从技术上讲,所有的Go语句都以分号(;)结束。不过Go将所有非空行的结束都视作分号——除非该行明确未结束(准确的规则说明在the language specification)。以至于在某些情况下,Go不允许你换行。例如,你不能这样写:

func g()
{ // 非法
}

一个分号会被插入到g()的后面,导致它成为一个函数声明(而不是函数定义)。近似地,你也不能这样写:

if x {
}
else { // 非法
}

一个分号会被添加到else前的{后面,导致了语法错误。

由于分号可以用来结束语句,你可以延续在C++中使用分号的习惯。但是,那并不是被推荐的代码风格。Go代码一般会省略不必要的分号——除了你想使用for循环或想在短短一行里塞入几条语句时。

虽然我们在讨论格式的问题,但我们建议您不用去纠结分号和括号的位置。不如用gofmt去自动格式化你的代码,它会帮你修正标准的Go代码风格,把心思花在你的代码本身吧。Go的代码风格刚开始看起来可能有点怪,但是没关系,看多了就顺眼了。

当使用指向结构体的指针时,你需要使用.来代替-&gt;。从语法上讲,结构体和结构体指针的使用方式是相同的。

type myStruct struct{ i int }
var v9 myStruct // v9是一个结构体变量
var p9 *myStruct // p9是一个结构体指针
f(v9.i, p9.i)

Go里的if语句、for语句或者switch语句都不需要括号(()),但仍然需要花括号({})。

if a < b { f() } // 合法
if (a < b) { f() } // 合法(带括号的表达式)
if (a < b) f() // 非法
for i = 0; i < 10; i++ {} // 合法
for (i = 0; i < 10; i++) {} // 非法

Go没有while语句和do/while语句。但Go的for语句可以当作C++的while那样去使用(只带一个条件语句),当这个条件语句被省略,它就是一个死循环。

Go允许breakcontinue被指定一个标记。这个标记必须被放在forswitchselect语句块的前面。

// 译者注
myLabel:
for {
for {
break myLabel // 该break会跳出两层死循环
}
}

switch语句中,代码不会顺延执行下面的case。你可以使用fallthrough关键词使它强制顺延。

switch i {
case 0: // 空case
case 1:
f() // 当 i == 0,f不会被调用
}

case可以有多个匹配值。

switch i {
case 0,1:
f() // 当 i == 0 || i == 1,f会被调用
}

每个case的所有匹配值都不需要是常量——甚至不需要是整型;任何支持比较运算符的类型(例如string或指针)都可以被使用——当switch的判断值为空,默认置为true

switch {
case i < 0:
f1()
case i == 0:
f2()
case i > 0:
f3()
}

在某函数中使用defer语句时,被defer标明的另一函数会在该函数返回前被调用。defer经常被用来取代C++的析构函数,但其与调用代码而不是某个类或对象相关联。

fd := open("filename")
defer close(fd) // fd会在函数返回时被关闭

运算符

++--只被允许在语句中使用,不再允许在表达式中使用。你不能写类似c=*p++的代码。*p++会被解析为(*p)++

运算符优先级也是有区别的。例如4 &amp; 3 &lt;&lt; 1在Go中等于0,而在C++中等于4

Go的运算符优先级:
1. * / % << >> & &^
2. + – | ^
3. == != < <= > >=
4. &&
5. ||

C++的运算符优先级:
1. * / %
2. + –
3. << >>
4. < <= > >=
5. == !=
6. &
7. ^
8. |
9. &&
10. ||

常量

在Go中,即便某个常量被使用const关键字进行声明,只要它没有被指定类型,初始化也只使用了无类型常量,这个常量就可以是无类型的。当常量被使用时,如果上下文需要某个确定的类型,该常量的值会被赋予类型。这样的机制允许相对灵活地使用常量,从而避免了隐式类型转换。

var a uint
f(a + 1) // 无类型的数字常量“1”被赋予类型uint

这门语言不给常量或常量表达式强加任何位宽限制。位宽限制只有需要类型时才会存在。

const huge = 1 << 100
f(huge >> 98)

Go不支持枚举类型。为了取代枚举类型,你可以在const声明中使用iota以获得一系列递增的值。当const的初始化表达式被省略,它会重用前面的表达式。

const(
red = iota // red == 0
blue // blue == 1
green // green == 2
)

类型

C++和Go提供相似但并不完全相同的内建类型:不同位宽的有符号整型与无符号整型、32位与64位的浮点数字(包括实数和复数)、struct(结构体)、指针、等等。在Go中,uint8int64和其它类似命名的整型都属于语言的一部分,而并非构建于位宽依赖于具体实现的整型之上(例如long long,译者注:C++中long long并未被严格定义长度,其位宽至少是——但不一定是——64位)。Go额外提供了原生的stringmapchannel类型,以及“一类公民”数组和切片(slice,会在后文解释)。字符串按照Unicode而不是ASCII编码。

Go比C++更加注重类型。特别是Go没有隐式强制类型转换,只允许手动进行类型转换。虽然这带来一些编码时的麻烦,但提高了安全性,减少了可能的Bug产生。Go中也没有union类型(联合体),因为那会使类型系统变得混乱,不过Go的interface{}(下文会讲)对其提供了类型安全的替代。

C++和Go都支持类型别名(C++中是typedef,Go中是type)。不过与C++不同的是,Go将它们视作不同的类型,以下的写法在C++中是合法的:

// C++
typedef double position;
typedef double velocity;

position pos = 218.0;
velocity vel = -9.8;

pos += vel

但如果手动进行类型转换,类似写法在Go中是非法的:

type position float64
type velocity float64

var pos position = 218.0
var vel velocity = -9.8

pos += vel // 非法: position和velocity的类型不匹配
// pos += position(vel) // 合法

甚至是不会混淆的类型之间也是如此:intuint不能在结合在同一表达式中,除非手动进行类型转换。

和C++中不同,Go不允许将指针转换成整型,也不允许将整型转换为指针。不过如果必要的话,Go的unsafe包允许我们手动明确绕过此安全机制(例如在编写底层系统的代码时)。

Slices(切片)

从概念上讲切片(slice)是拥有三个成员变量的结构体:指向某数组的指针、长度、容量。切片支持使用[]运算符来访问底层数组的元素。内建的len函数可返回slice的长度。内建的cap函数可返回内建的容量。

给定一个数组或切片,可通过a[i:j]这样的格式创建一个新的切片,新创建的切片与原a相关联,它们使用共同的底层数组。新切片从旧切片索引i开始,在索引j之前结束,它的长度是j-i,如果i被忽略,默认视为0。如果j被忽略,那么就结束于len(a)。这给我带来两点启示:①对新切片所做的更改对旧的a也是可见的;②切片的创建是轻量的,因为并没有底层数组的数据拷贝。新切片的容量就是a的容量减去i。数组的容量就是数组的长度。

这意味着在Go中对切片的使用,一定程度上类似于在C++中对指针的使用。如果你创建一个类型为[100]byte的值(100个byte的数组,这可能被用作buffer),而你想在不进行拷贝的情况下将其传递给函数,你应该将函数的参数声明为[]byte,然后传递一个该数组的切片(a[:]会传递整个数组)。和在C++中不同的是,我们不需要传递buffer的长度;使用len会更有效率。

切片的语法也可以给字符串(string)使用。它会返回一个原字符串的子串。因为字符串是不可变的,因此字符串切片不需要耗费新的内存。

创建值

Go拥有内建函数new,它接受一个类型并在堆上开辟新的空间,新分配的内存会被以这个类型初始化为0。new(int)在堆上分配一个int,将其初始化为0,并返回它的地址,地址类型为*int。与C++中不同的是,new是一个函数而不是一个运算符;new int这样的语法是错的。

可能令人惊奇的是,Go中并不会常用new。在Go中获取变量的指针是绝对安全的,不会产生悬浮指针。当一个程序获取了某变量的指针,
但有必要时,这个变量会被分配在堆上。所以这些函数是等效的:

type S { I int }

func f1() *S {
return new(S)
}

func f2() *S {
var s S
return &s
}

func f3() *S {
// 更惯用的复合写法
return &S{}
}

与之相对的,在C++中返回本地变量的指针是不安全的:

// C++
S* f2() {
S s;
return &s; // 非法——s的值在任何时候都可能被覆盖
}

map和channel必须使用内建函数make进行创建分配。未进行初始化的map或channel变量会被自动初始化为nil。调用make(map[int]int)会返回新分配的类型为map[int]int的值。注意make返回的是值而不是指针。但这并不影响map或channel的值是以引用的方式被传递。调用map类型的make可接受一个可选参数用来表明容量。调用channel类型的make可接受一个可选参数用来设置channel的缓冲容量——这个容量默认为0(即无缓冲)。

make函数也可用来创建切片,它会分配底层数组的内存并返回与该数组相关联的切片。此时需要一个必填的参数,它表示切片的元素个数。还有另一个可选参数,用来表示切片的容量。例如make([]int, 10, 20),相当于new([20]int)[0:10]。因为Go自带垃圾回收,如果一段时间以后不再有对此切片的引用存在,新分配的数组将会被丢弃。

接口

为了取代C++的类、子类和模板,Go提供了接口(interface)。Go的接口和C++的纯抽象类(没有成员变量,只有纯虚方法的类)很类似。不过在Go中,任何提供了某接口的所有方法的数据结构,都可被视作该接口的实现,并不需要手动进行继承声明。接口的实现与接口本身是完全分离的。

方法看起来和函数很类似,除了它有一个额外的接收者(receiver)。接收者这个概念类似于C++方法的this指针。

type myType struct { i int }
func (p *myType) Get() int { return p.i }

这样就为myType类型声明了一个Get方法,在函数体中接收者被命名为p

方法被定义在有名字的类型上。如果你将其值转换为不同的类型,新值将拥有新类型的方法,失去旧类型的方法。

Go将类型别名与原类型视作不同的类型,因此你可以为别名定义方法。

type myInteger int

func (p myInteger) Get() int { return int(p) } // 需要类型转换
func f(i int) {}

var v myInteger

// f(v)是非法的
// f(int(v))是合法的,int(v)没有方法

给定这个接口

type myInterface interface {
Get() int
Set(i int)
}

我们可以通过添加以下方法使myType满足该接口

func (p *myType) Set(i int) { p.i = i }

现在任何接受myInterface作为参数的函数都可接受*myType

func GetAndSet(x myInterface) {}
func f1() {
var p myType
GetAndSet(&p)
}

换句话说,如果我把myInterface视作C++的纯抽象基类,为*myType定义SetGet方法后,*myType便自动继承myInterface。单个类型可以支持多个接口。

匿名字段可以用来实现一些类似C++子类的东西。

type myChildType struct {
myType
j int
}

func (p *myChildType) Get() int { p.j++; return p.myType.Get() }

这样可以让myChildType实现为myType的孩子。

func f2() {
var p myChildType
GetAndSet(&p)
}

Set方法从myType有效继承,这是因为分配给匿名字段的方法会被升格为内嵌类型的方法。因为myChildType有一个类型为myType的匿名字段,myType的方法也会变成myChildType的方法。在这个例子中,Get方法被覆盖,Set方法被继承。

这与C++中的子类并不完全相同。当调用匿名字段的方法时,它的接收者(receiver)是这个字段,而非外围的结构体。换句话说,匿名字段的方法并不是虚函数。如果你想要虚函数的等效替代,使用接口。

通过被叫做类型断言的特殊构造,拥有接口的变量可以被转换以拥有不同的接口。这是在运行时中动态实现的,就像C++的dynamic_cast。不过和dynamic_cast不同,两个接口间不需要被声明任何关系。

type myPrintInterface interface {
Print()
}

func f3(x myInterface) {
x.(myPrintInterface).Print() // 对myPrintInterface的类型断言
}

myPrintInterface的转换是完全动态的,这可以被用来实现通用程序,类似C++的模板——通过操作最小的接口的值。

type Any interface{}

容器可以使用Any来编写,但调用者拆箱的时候必须用类型断言来将值还原被装箱的类型。由于这个类型的转换是动态的而不是静态的,所以没办法像C++那样进行内联。运行时中会进行完整的类型检查,但所有操作都涉及函数调用。

type Iterator interface {
Get() Any
Set(v Any)
Increment()
Equal(arg Iterator) bool
}

注意Equal有一个Iterator类型的参数,这和C++模板的表现是不同的。详见 the
FAQ

函数闭包

在C++11之前,创建隐藏状态函数的一般方法是使用仿函数(functor)——重载operator()可使类的实例看起来像函数。例如,以下代码定义的my_transform函数(STL中std::transform的简化版),为数组in里的每个元素使用给定运算符op,然后将结果存放在数组out中。为了实现前缀的和(例如{x[0], x[0]+x[1], x[0]+x[1]+x[2], …}),代码创建了一个functor(MyFunctor),追踪运行中的总值(total),将实例传递给functor(my_transform)。

// C++
#include <iostream>
#include <cstddef></cstddef></iostream>

template <class unaryoperator="unaryoperator">
void my_transform (size_t n_elts, int* in, int* out, UnaryOperator op)
{
size_t i;</class>

for (i = 0; i < n_elts; i++)
out[i] = op(in[i]);
}

class MyFunctor {
public:
int total;
int operator()(int v) {
total += v;
return total;
}
MyFunctor() : total(0) {}
};

int main (void)
{
int data[7] = {8, 6, 7, 5, 3, 0, 9};
int result[7];
MyFunctor accumulate;
my_transform(7, data, result, accumulate);

std::cout << "Result is [ ";
for (size_t i = 0; i < 7; i++)
std::cout << result[i] << ' ';
std::cout << "]\n";
return 0;
}

C++增加了匿名函数(lambda表达式),它可以被存放在变量里并被传递给函数。lambda表达式可被用来实现闭包,这意味着他们能引用来自父作用域的状态。这个特性极大简化了my_transform

// C++11
#include <iostream>
#include <cstddef>
#include <functional></functional></cstddef></iostream>

void my_transform (size_t n_elts, int* in, int* out, std::function<intint> op)
{
size_t i;</intint>

for (i = 0; i < n_elts; i++)
out[i] = op(in[i]);
}

int main (void)
{
int data[7] = {8, 6, 7, 5, 3, 0, 9};
int result[7];
int total = 0;
my_transform(7, data, result, [&total] (int v) {
total += v;
return total;
});

std::cout << "Result is [ ";
for (size_t i = 0; i < 7; i++)
std::cout << result[i] << ' ';
std::cout << "]\n";
return 0;
}

Go版本的my_transform和C++11版本看起来很像。

package main

import "fmt"

func my_transform(in []int, xform func(int) int) (out []int) {
out = make([]int, len(in))
for idx, val := range in {
out[idx] = xform(val)
}
return
}

func main() {
data := []int{8, 6, 7, 5, 3, 0, 9}
total := 0
fmt.Printf("Result is %v\n", my_transform(data, func(v int) int {
total += v
return total
}))
}

(注意我们选择从my_transform返回一个新的out,而不是传入一个供写入的入参。这是出于美学上的考虑;不然看起来太像C++版本了。)

在Go中,函数总是完全闭包的,等同于C++11的[&amp;]。不过有个很重要的区别:在C++中,引用了超出范围的变量的闭包是非法的(可能导致upward问题——当一个函数返回引用了本地变量的lambda)(译者注:闭包引用了即将被释放的变量,很可能造成类似悬浮指针的问题)。而在Go中这是完全合法的。

并发

就像C++的std::thread,Go允许在共享地址空间中并发地执行工作流。这样的机制被叫做 goroutine ,我们可以通过go语句来使用它。std::thread的典型实现会启动很重的操作系统线程,而goroutine被实现为轻量的用户态线程,所有goroutine会复用几个操作系统线程。所以goroutine是低开销的,可以在程序中尽情使用。

func server(i int) {
for {
fmt.Print(i)
time.Sleep(10 * time.Second)
}
}
go server(1)
go server(2)

(注意server函数中的for块,相当于C++中的while(true)循环。)

函数关键字(Go将其实现为闭包)配合go语句非常有用。

var g int
go func(i int) {
s := 0
for j := 0; j < i; j++ {
s += j
}
g = s
}(1000) // 将1000作为参数传递给该闭包函数

就像C++11那样,Go为内存的非同步访问定义了一个内存模型。尽管Go在sync包中提供了std::mutex的替代物,但这并不是Go实现线程间通信和同步的标准途径。与锁或屏障(barrier)从根本上就不同,Go线程通过消息的传递通信。Go有一句口头禅,

不要靠共享内存来通信;而要靠通信来共享内存。

这句话说的是,goroutine利用 信道(channel) 来通信。任何类型的值(包括其它信道!)可以通过信道来发送。在构造信道时,可以通过传入一个数字,来为其指定一个固定长度的缓冲区,当然也可以不指定。

信道是一类公民;它可以被存放在变量中,并且可以在函数间传递——就像别的普通值一样。(当被提供给函数使用时,信道以引用的方式被传递。)信道也是有类型的:chan intchan string是不同的类型。

因为信道在Go中广为使用,信道(被设计得)高效率和低开销。要利用信道发送一个值,在信道右边使用&lt;-运算符。要从信道接收一个值,在信道左边使用&lt;-运算符。信道可在多个发送者和接收者间共享,要保证每个被发送的值至少有一个接收者。

这里有一个例子,使用一个管理函数来控制对单个值的访问。

type Cmd struct { Get bool; Val int }
func Manager(ch chan Cmd) {
val := 0
for {
c := <-ch
if c.Get { c.Val = val; ch <- c }
else { val = c.Val }
}
}

此例中,同一个信道被用来输入和输出。如果同时有多个goroutine通过这个manager交流,是不准确的:一个从manager等待反馈的goroutine可能会收到来自另一个goroutine的请求。一个解决方案是传入另一个信道。

type Cmd2 struct { Get bool; Val int; Ch chan<- int }
func Manager2(ch <-chan Cmd2) {
val := 0
for {
c := <-ch
if c.Get { c.Ch <- val }
else { val = c.Val }
}
}

使用Manager2时需要给它一个信道:

func f4(ch chan<- Cmd2) int {
myCh := make(chan int)
c := Cmd2{true, 0, myCh} // Composite literal syntax.
ch <- c
return <-myCh
}