Go Wiki:GoForCPPProgrammers

Go 是一种系统编程语言,旨在成为一种通用系统语言,如 C++。以下是针对经验丰富的 C++ 程序员的一些有关 Go 的说明。本文档讨论了 Go 和 C++ 之间的差异,并且几乎没有提到相似之处。

需要记住的一个重要要点是,精通这两种语言所需的思维过程存在一些根本差异。最可怕的是,C++ 的对象模型基于类和类层次结构,而 Go 的对象模型基于接口(并且本质上是扁平的)。因此,C++ 设计模式很少能逐字逐句地转换为 Go。要有效地用 Go 编程,必须考虑正在解决的问题,而不是在 C++ 中可能用来解决该问题的机制。

如需了解 Go 的更通用介绍,请参阅 Go Tour如何编写 Go 代码有效的 Go

如需了解 Go 语言的详细说明,请参阅 Go 规范

概念差异

语法

与 C++ 相比,声明语法是相反的。您编写名称后跟类型。与 C++ 不同,类型的语法与变量的使用方式不匹配。类型声明可以轻松地从左到右读取。(var v1 int → “变量 v1 是一个 int。”)

//Go                      C++
var v1 int                // int v1;
var v2 string             // const std::string v2;  (approximately)
var v3 [10]int            // int v3[10];
var v4 []int              // int* v4;  (approximately)
var v5 struct { f int }   // struct { int f; } v5;
var v6 *int               // int* v6;  (but no pointer arithmetic)
var v7 map[string]int     // unordered_map<string, int>* v7;  (approximately)
var v8 func(a int) int    // int (*v8)(int a);

声明通常采用关键字后跟要声明的对象名称的形式。关键字之一是 varfuncconsttype。方法声明是一个小例外,因为接收器出现在要声明的对象的名称之前;请参阅接口讨论

您还可以在括号中使用关键字后跟一系列声明。

var (
    i int
    m float64
)

在声明函数时,您必须为每个参数提供一个名称,或者不为任何参数提供名称。(即,C++ 允许 void f(int i, int);,但 Go 不允许类似的 func f(i int, int)。)但是,为了方便起见,在 Go 中,您可以将几个具有相同类型的名称分组

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

变量可以在声明时初始化。当这样做时,允许指定类型,但不是必需的。当未指定类型时,变量的类型是初始化表达式的类型。

var v = *p

另请参阅以下有关常量的讨论。如果未显式初始化变量,则必须指定类型。在这种情况下,它将隐式初始化为该类型的零值(0nil 等)。Go 中没有未初始化的变量。

在函数中,可以使用带有 := 的简短声明语法。

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

这等同于

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

Go 允许并行执行多个赋值。也就是说,首先计算右侧的所有值,然后将这些值分配给左侧的变量。

i, j = j, i // Swap i and j.

函数可以具有多个返回值,用括号中的列表表示。可以通过将返回值分配给变量列表来存储这些返回值。

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

多个返回值是 Go 处理错误的主要机制

result, ok := g()
if !ok {
  // Something bad happened.
  return nil
}
// Continue as normal.
…

或者更简洁地写为

if result, ok := g(); !ok {
  // Something bad happened.
  return nil
}
// Continue as normal.
…

在实践中,Go 代码很少使用分号。从技术上讲,所有 Go 语句都以分号结尾。但是,Go 将非空行的结尾视为分号,除非该行明显不完整(确切规则在语言规范中)。这导致在某些情况下,Go 不允许你使用换行符。例如,你不能编写

func g()
{                  // INVALID
}

分号将插入在 g() 之后,使其成为函数声明而不是函数定义。类似地,你不能编写

if x {
}
else {             // INVALID
}

分号将插入在 } 之前,导致语法错误。

由于分号确实结束语句,因此你可以继续像在 C++ 中一样使用它们。但是,这不是推荐的风格。惯用的 Go 代码省略不必要的分号,在实践中,它们是除初始 for 循环子句和希望在单行上使用多个短语句的情况之外的所有分号。

当我们讨论这个主题时,我们建议你使用 gofmt 程序设置代码格式,而不是担心分号和大括号的放置。这将生成一种标准的 Go 风格,让你可以专注于代码,而不是格式。虽然这种风格最初看起来可能很奇怪,但它与任何其他风格一样好,而且熟悉它会带来舒适感。

在使用指向结构的指针时,你使用 . 而不是 ->。因此,从语法上讲,结构和指向结构的指针以相同的方式使用。

type myStruct struct{ i int }
var v9 myStruct  // v9 has structure type
var p9 *myStruct // p9 is a pointer to a structure
f(v9.i, p9.i)

Go 语言不要求在 if 语句的条件、for 语句的表达式或 switch 语句的值周围加括号。另一方面,它确实要求在 iffor 语句的主体周围加花括号。

if a < b { f() }             // Valid
if (a < b) { f() }           // Valid (condition is a parenthesized expression)
if (a < b) f()               // INVALID
for i = 0; i < 10; i++ {}    // Valid
for (i = 0; i < 10; i++) {}  // INVALID

Go 语言没有 while 语句,也没有 do/while 语句。for 语句可与单个条件一起使用,这使其等同于 while 语句。完全省略条件是一个无限循环。

Go 语言允许 breakcontinue 指定标签。标签必须引用 forswitchselect 语句。

switch 语句中,case 标签不会贯穿。你可以使用 fallthrough 关键字使它们贯穿。这甚至适用于相邻的情况。

switch i {
case 0: // empty case body
case 1:
    f() // f is not called when i == 0!
}

但是一个 case 可以有多个值。

switch i {
case 0, 1:
    f() // f is called if i == 0 || i == 1.
}

case 中的值不必是常量,甚至不必是整数;任何支持相等比较运算符的类型,例如字符串或指针,都可以使用,如果省略 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 will be closed when this function returns.

运算符

++-- 运算符只能在语句中使用,不能在表达式中使用。你不能编写 c = *p++*p++ 被解析为 (*p)++

运算符优先级不同。例如,4 & 3 << 1 在 Go 中计算为 0,在 C++ 中计算为 4

Go operator precedence:
1. *   /   %  <<  >>  &  &^
2. +   -   |  ^
3. ==  !=  <  <=  >   >=
4. &&
5. ||
C++ operator precedence (only relevant operators):
1.  *    /   %
2.  +    -
3.  <<   >>
4.  <    <=  >   >=
5.  ==   !=
6.  &
7.  ^
8.  |
9.  &&
10. ||

常量

在 Go 语言中,常量可以是 无类型的。这甚至适用于使用 const 声明命名的常量,如果声明中未给出类型,并且初始化器表达式仅使用无类型常量。从无类型常量派生的值在需要类型化值的环境中使用时会变成类型化值。这允许相对自由地使用常量,而不需要通用的隐式类型转换。

var a uint
f(a + 1) // untyped numeric constant "1" becomes typed as 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)。Go 语言还提供了本机 stringmapchan(通道)类型,以及一流数组和切片(如下所述)。字符串使用 Unicode 编码,而不是 ASCII。

Go 语言比 C++ 语言的类型化程度要强得多。特别是,Go 语言中没有隐式类型转换,只有显式类型转换。这提供了额外的安全性和避免了一类错误,但代价是需要一些额外的类型化。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 // INVALID: mismatched types position and velocity
// pos += position(vel)  // Valid

即使对于未别名的类型,也是如此:intuint 无法在表达式中组合,除非显式地将一个转换为另一个。

与 C++ 不同,Go 不允许将指针强制转换为整数或从整数强制转换为指针。但是,Go 的 unsafe 包允许在必要时显式绕过此安全机制(例如,用于低级系统代码)。

切片

从概念上讲,切片是一个具有三个字段的结构:指向数组的指针、长度和容量。切片支持 [] 运算符以访问底层数组的元素。内置 len 函数返回切片的长度。内置 cap 函数返回容量。

给定一个数组或另一个切片,可以通过 a[i:j] 创建一个新切片。这将创建一个引用 a 的新切片,从索引 i 开始,在索引 j 之前结束。它的长度为 j-i。如果省略 i,则切片从 0 开始。如果省略 j,则切片在 len(a) 处结束。新切片引用 a 引用的同一数组。此语句的两个含义是 ① 使用新切片进行的更改可以使用 a 查看,② 切片创建(旨在)廉价;无需对底层数组进行复制。新切片的容量只是 a 的容量减去 i。数组的容量是数组的长度。

这意味着 Go 在 C++ 使用指针的一些情况下使用切片。如果你创建一个类型为 [100]byte 的值(一个 100 字节的数组,可能是一个缓冲区),并且想在不复制它的情况下将其传递给一个函数,你应该声明函数参数的类型为 []byte,并传递数组的一个切片(a[:] 将传递整个数组)。与 C++ 不同,无需传递缓冲区的长度;可以通过 len 有效地访问它。

切片语法也可以与字符串一起使用。它返回一个新字符串,其值是原始字符串的子字符串。由于字符串是不可变的,因此可以在不为切片的内容分配新存储空间的情况下实现字符串切片。

制作值

Go 有一个内置函数 new,它采用一个类型并在堆上分配空间。分配的空间将针对该类型进行零初始化。例如,new(int) 在堆上分配一个新的 int,用值 0 对其进行初始化,并返回其地址,该地址的类型为 *int。与 C++ 不同,new 是一个函数,而不是一个运算符;new int 是一个语法错误。

也许令人惊讶的是,new 在 Go 程序中并不常用。在 Go 中,获取变量的地址始终是安全的,并且永远不会产生悬空指针。如果程序获取变量的地址,则在必要时它将在堆上分配。因此,这些函数是等效的

type S struct { I int }

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

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

func f3() *S {
    // More idiomatic: use composite literal syntax.
    return &S{}
}

相比之下,在 C++ 中将指针返回给局部变量是不安全的

// C++
S* f2() {
  S s;
  return &s;   // INVALID -- contents can be overwritten at any time
}

必须使用内置函数 make 分配映射和通道值。未初始化的映射或通道类型声明的变量将自动初始化为 nil。调用 make(map[int]int) 将返回类型为 map[int]int 的新分配值。请注意,make 返回一个值,而不是一个指针。这与映射和通道值通过引用传递的事实是一致的。使用映射类型调用 make 时,需要一个可选参数,该参数是映射的预期容量。使用通道类型调用 make 时,需要一个可选参数,该参数设置通道的缓冲容量;默认值为 0(无缓冲)。

make 函数还可以用于分配切片。在这种情况下,它将为基础数组分配内存并返回引用该数组的切片。有一个必需的参数,即切片中的元素数量。第二个可选参数是切片的容量。例如,make([]int, 10, 20)。这与 new([20]int)[0:10] 相同。由于 Go 使用垃圾回收,因此在没有对返回切片的引用后,新分配的数组将被丢弃。

接口

C++ 提供类、子类和模板,而 Go 提供接口。Go 接口类似于 C++ 纯抽象类:没有数据成员的类,其方法都是纯虚方法。但是,在 Go 中,提供接口中命名的任何类型的方法都可以被视为该接口的实现。不需要明确声明的继承。接口的实现与接口本身完全分离。

方法看起来像一个普通的函数定义,只是它有一个接收器。接收器类似于 C++ 类方法中的 this 指针。

type myType struct{ i int }

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

这声明了一个与 myType 关联的方法 Get。接收器在函数体中命名为 p

方法在命名类型上定义。如果您将值转换为不同类型,则新值将具有新类型的方法,而不是旧类型的方法。

您可以通过声明一个从内置类型派生的新命名类型来定义内置类型的方法。新类型与内置类型不同。

type myInteger int

func (p myInteger) Get() int { return int(p) } // Conversion required.
func f(i int)                {}

var v myInteger

// f(v) is invalid.
// f(int(v)) is valid; int(v) has no defined methods.

给定此接口

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++ 中的子类并不完全相同。当调用匿名字段的方法时,其接收者是字段,而不是周围的结构。换句话说,匿名字段上的方法不是虚函数。当您想要虚拟函数的等效项时,请使用接口。

具有接口类型的变量可以使用称为类型断言的特殊构造转换为具有不同接口类型。这在运行时动态实现,就像 C++ dynamic_cast 一样。与 dynamic_cast 不同,这两个接口之间不需要任何声明的关系。

type myPrintInterface interface {
    Print()
}

func f3(x myInterface) {
    x.(myPrintInterface).Print() // type assertion to myPrintInterface
}

转换为 myPrintInterface 完全是动态的。只要 x 的动态类型定义了 Print 方法,它就会起作用。

由于转换是动态的,因此可用于实现类似于 C++ 中模板的泛型编程。这是通过操作最小接口的值来完成的。

type Any interface{}

容器可以用 Any 来编写,但调用者必须使用类型断言进行拆箱以恢复所包含类型的的值。由于类型是动态的而不是静态的,因此没有等效于 C++ 模板内联相关操作的方式。这些操作在运行时经过完全类型检查,但所有操作都将涉及函数调用。

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

请注意,Equal 具有类型为 Iterator 的参数。这与 C++ 模板的行为不同。请参阅 常见问题解答

函数闭包

在早于 C++11 的 C++ 版本中,创建具有隐藏状态的函数最常见的方法是使用“仿函数”——一个重载 operator() 以使实例看起来像函数的类。例如,以下代码定义了一个 my_transform 函数(STL 的 std::transform 的简化版本),它将给定的单目运算符 (op) 应用于数组 (in) 的每个元素,并将结果存储在另一个数组 (out) 中。要实现前缀和(即,{x[0]x[0]+x[1]x[0]+x[1]+x[2]、…}),代码创建一个仿函数 (MyFunctor) 来跟踪运行总计 (total),并将此仿函数的实例传递给 my_transform

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

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

  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++11 添加了匿名(“lambda”)函数,可以将其存储在变量中并传递给函数。它们可以作为闭包,这意味着它们可以引用父作用域的状态。此功能极大地简化了 my_transform

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

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

  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;
}

my_transform 的典型 Go 版本看起来很像 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,而不是传递一个 out 来写入。这是一个美学决策;代码可以更像 C++ 版本那样编写。)

在 Go 中,函数始终是完全闭包,相当于 C++11 中的 [&]。一个重要的区别是,在 C++11 中,闭包引用作用域已消失的变量是无效的(这可能是由 向上 funarg 引起的——返回引用局部变量的 lambda 的函数)。在 Go 中,这是完全有效的。

并发

与 C++11 的 std::thread 类似,Go 允许启动在共享地址空间中并发运行的新执行线程。这些称为goroutine,并使用 go 语句生成。虽然典型的 std::thread 实现启动重量级操作系统线程,但 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) // Passes argument 1000 to the function literal.

与 C++11 类似,但与 C++ 的早期版本不同,Go 为对内存的非同步访问定义了一个内存模型。尽管 Go 在其 sync 包中提供了 std::mutex 的类似物,但这并不是在 Go 程序中实现线程间通信和同步的常规方式。相反,Go 线程通常通过消息传递进行通信,这与锁和屏障有着根本不同的方法。Go 对此主题的口号是:

不要通过共享内存进行通信;而是通过通信共享内存。

也就是说,通道用于在 goroutine 之间进行通信。任何类型的(包括其他通道!)的值都可以通过通道发送。通道可以是无缓冲的或缓冲的(使用在通道构建时指定的缓冲区长度)。

通道是一等值;它们可以存储在变量中,并像任何其他值一样传递到函数和从函数中传递。 (当提供给函数时,通道通过引用传递。)通道也是类型化的:chan intchan string 不同。

由于它们在 Go 程序中被广泛使用,因此通道(旨在)高效且廉价。要在通道上发送值,请使用 <- 作为二元运算符。要在通道上接收值,请使用 <- 作为一元运算符。通道可以在多个发送者和多个接收者之间共享,并保证发送的每个值最多由一个接收者接收。

以下是如何使用管理函数来控制对单个值的访问的示例。

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 同时与管理器通信,这是不正确的:等待管理器响应的 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 getFromManagedChannel(ch chan<- Cmd2) int {
    myCh := make(chan int)
    c := Cmd2{true, 0, myCh} // Composite literal syntax.
    ch <- c
    return <-myCh
}

func main() {
    ch := make(chan Cmd2)
    go Manager2(ch)
    // ... some code ...
    currentValue := getFromManagedChannel(ch)
    // ... some more code...
}

此内容是Go Wiki的一部分。