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 →「變數 v1int。」)

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

分號會插入在 } 之後,在 else 之前,導致語法錯誤。

由於分號會結束陳述式,因此您可以繼續像在 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 來配置 Map 和通道值。宣告為 Map 或通道型別且沒有初始值設定的變數會自動初始化為 nil。呼叫 make(map[int]int) 會傳回一個新配置的型別為 map[int]int 的值。請注意,make 傳回的是值,不是指標。這與 Map 和通道值是透過參照傳遞的事實一致。呼叫具有 Map 型別的 make 會接收一個選用引數,該引數是 Map 的預期容量。呼叫具有通道型別的 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)
}

我們可以透過新增

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

myType 滿足介面

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

現在任何將 myInterface 視為參數的函式都將接受 *myType 類型的變數。

換句話說,如果我們將 myInterface 視為 C++ 純抽象基底類別,為 *myType 定義 SetGet,會讓 *myType 自動從 myInterface 繼承。類型可以滿足多個介面。

type myChildType struct {
    myType
    j int
}

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

匿名欄位可以用於實作類似於 C++ 子類別的東西。

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

這有效地實作 myChildType 作為 myType 的子類別。

Set 方法有效地從 myType 繼承,因為與匿名欄位關聯的方法會被提升為封閉類型的類型。在本例中,由於 myChildType 有一個 myType 類型的匿名欄位,因此 myType 的方法也成為 myChildType 的方法。在此範例中,Get 方法被覆寫,而 Set 方法被繼承。

具有介面類型的變數可以使用稱為類型斷言的特殊結構轉換為具有不同的介面類型。這是在執行階段動態實作的,就像 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
}

請注意,EqualIterator 類型的引數。這不像 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;
}

典型的 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,而不是傳遞一個 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 的一部分。