RevComm Tech Blog

コミュニケーションを再発明し 人が人を想う社会を創る

PythonからGolangへ移動した時の驚いたこと

ハロー、ホセです。今年の9月、PyConJPとGoConJPが同じ日に開催されることになり、どちらに参加するか迷いました。結局PyConJPに参加することにし、「GoConのスライドは後で見よう」と心に決めて広島へ向かいました。しかし、12月になった今でも一つもスライドを読んでいません(涙)。

ということで、Goへのお詫びとして必読書「初めてのGo言語 第2版」を購入し、毎晩Goの勉強を始めました。

本記事では、Python経験者でGoの初心者である私の観点から、Go言語で驚いたポイントを紹介します。Let’s go!

Goはつまらない言語?

「つまらない」というのは「単純」という意味ではありませんし、「不変である」という意味ではありません。

- John Bodner

初めてGoコードを書いたのは2015年のインターンシップでした。2ヶ月間だけだったのであまり学べませんでしたが、当時C++しか知らなかった私にはコードの可読性がすごく印象的でした。その後Pythonを学んだときには、コードの見た目がGolangと似ているなとの感覚が残りました。

そして今年、ちゃんとGolangを真面目に勉強してBodner氏の本を読んだら、「Goはつまらない」という表現を見かけて驚きました。初印象と全然違うけれど、10年ほどの経験を重ねた今の私にはよくわかってきました。

「つまらない」とは、破壊的な変更をしないということ。信頼性があり、長く使え、保守可能なコードを書くということです。それはGoです。そして、その意思は言語のバージョン管理に繋がっています。

Goの柱は互換性と安定性

「つまらない」は良いことだ。「つまらない」であるからこそ、Goバーションの特異な点に気を取られず、本来の仕事に集中できるのだ。

- Russ Cox, How Go programs keep working. GopherCon 2022

安定なプログラムは実装中のコードだけではなく、リリース後も、長年信頼できるコードです。Goでは「互換性の約束」があります。例えば、Go 1.4のプログラムはGo 1.7で実行しても問題なく動きます。それを理想で終わらせないために、Golangの開発者たちは頑張っています。

ただし、将来の変更によってプログラムが破損しないことを保証するのは不可能。あのGoogleでもGoのアップデートでシステムが影響されることがあったらしい。

ところで、Golangはまだ1.x系です。Version 2 が出る時、約束は破られるのか?という質問に対して、Russ Cox(元 GoのTech Lead)はこう回答しました。

Go 2.0はリリースしません。なぜかというと、1.0のプログラムは2.0で実行できませんから。

- Russ Cox, How Go programs keep working. GopherCon 2022

ちゃんと約束を守ってくれると、コードのメンテナとして安心しますね。

Goはコンパイル言語

Goはコンパイル言語ですから、実行可能ファイルに変換してから実行する必要があります。 PythonとJavaScriptなどのインタプリタ言語では、簡単なスクリプトを書いてすぐに実行できますが、Goの場合はそうはいきません。

- 初めてのGo 言語 第2版 第11章

これは驚いたというより、長年PythonとJavaScriptを使ってから、再びコンパイル言語に戻るための慣れ期間が必要でした。実装中は go run で素早く試せます。

Python:

# hello.py
print("Hello, World!")
# 実行: python hello.py

Go

// hello.go
package main

import "fmt"

func main() {
    fmt.Println("Hello, World!")
}

// 実行: go run hello.go
// または
// コンパイル: go build hello.go
// 実行: ./hello

Goは強い型付け言語です

GoではTypeScriptのように型があり、Pythonと違って型が強制されます。Pythonでは型ヒントを書いても実行時にチェックされませんが、Goでは型が一致しないとコンパイルエラーになります。

Python:

# 動的型付け
x = 10       # 整数
x = "hello"  # 文字列に変更可能
y = 5
print(x + str(y))  # 異なる型の結合には明示的な変換が必要

# 型ヒントがあっても強制されない例
def add_numbers(a: int, b: int) -> int:
    return a + b

# 型が違っても実行時エラーにならない
result = add_numbers("hello", "world")  # 実行できてしまう
print(result)  # "helloworld" が出力される

Go:

package main

import "fmt"

func main() {
    var x int = 10
    // x = "hello"  // コンパイルエラー: 型不一致

    y := 5
    fmt.Println(x + y)  // 同じ型のみ演算可能

    // 異なる型の結合
    s := "Result: " + fmt.Sprint(x)
    fmt.Println(s)
}

型についての細かなルールもありますので、A tour of Goと「初めてのGo 第7章」がおすすめです。

Goの配列の型にはサイズが含まれます

配列が直接使われることは多くはない。

- 初めてのGo 言語 第2版 第3章

Goの配列は型に「長さ」も含まれるため、異なるサイズの配列は別型です。また、長さが異なる配列への型変換もできませんし、同じ関数に異なる長さの配列を渡すこともできません。これは「直感的ではない」と感じるかもしれませんが、Goの配列はただのサブキャラ。主人公であるスライスのために存在しています。

var x = [3]int{1,2,3} // type: [3]int
var y = [...]int{1,2,3,4} // type: [4]int

func mutateArray(a [3]int) {
    a[0] = 99 // 値渡しのため呼び出し元の配列は変わらない
}

mutateArray(x) // OK
// mutateArray(y) // コンパイルエラー

Pythonのlistのようなものはslice

一連の値を保持するためのデータ構造が必要ならば、「可変長の配列」とも言えるスライスを使うのが正解です。

- 初めてのGo 言語 第2版 第3章

Goの主人公型の一つはスライスです。Pythonのlistと似ていますが、要素の型宣言が必要です。またスライスには長さとともにキャパシティ(容量)という属性もあります。キャパシティを設定すると想定されているメモリブロックを確保できます。

package main

import "fmt"

func main() {
    // スライスの基本的な宣言と初期化
    // 型の宣言が必要: []int は int 型のスライス
    var numbers []int = []int{1, 2, 3, 4, 5}
    fmt.Println("数値のスライス:", numbers)
    
    // 短縮構文による宣言
    names := []string{"Alice", "Bob", "Charlie"}
    fmt.Println("名前のスライス:", names)
    
    // make関数でスライスを作成
    // make(型, 長さ, キャパシティ)
    // キャパシティはオプション。省略すると長さと同じになる
    scores := make([]int, 3, 5) // 長さ3、キャパシティ5のスライス
    fmt.Println("スコアのスライス:", scores)
    fmt.Println("長さ:", len(scores))
    fmt.Println("キャパシティ:", cap(scores))
    
    // スライスへの要素追加
    scores = append(scores, 100)
    scores = append(scores, 95)
    fmt.Println("要素追加後のスライス:", scores)
    fmt.Println("追加後の長さ:", len(scores))
    fmt.Println("追加後のキャパシティ:", cap(scores))
    
    // キャパシティを超えると自動的に拡張される
    // Go 1.18 の段階でキャパシティが 256 未満の場合は 2 倍になる傾向
    scores = append(scores, 88, 92, 78)
    fmt.Println("キャパシティ超過後のスライス:", scores)
    fmt.Println("新しい長さ:", len(scores))
    fmt.Println("新しいキャパシティ:", cap(scores)) // 通常は2倍に拡張される
    
    // 先頭3つを参照するスライス
    firstThree := scores[:3]
    
    // 元のスライスを変更すると、同じ基底配列を参照するスライスも影響を受ける
    scores[0] = 999
    fmt.Println("scores[0]変更後のfirstThree:", firstThree)
}

キャパシティが超える場合は、スライスの内容が拡張後のメモリにコピーされるため、パフォーマンスに影響します。拡張ルールの話に関して「初めてのGo 6.7、マップとスライスの違い」をご覧ください。

Goの定数はコンパイル時に確定する

Goの定数はリテラルに名前を付与するものです。「変数」がイミュータブルであることを宣言する方法はありません。

- 初めてのGo 言語 第2版 第2章

Goの定数はコンパイル時にのみイミュータブルであることが保証されます。つまり、リテラルに名前を付けているだけの機能です。

package main

func main() {
    const a = 10
    // a = 20 // エラー:cannot assign to a (neither addressable nor a map index expression)
    x := 10
    y := 20
    // const z = x + y // エラー:x + y (value of type int) is not constant
}

Go は値渡しだけど参照もできる?

Goでは関数に渡された引数は関数内で新たなコピーになります。これによりイミュータビリティが確保され、データフローが分かりやすくなります。

一方で mutable なオブジェクトを渡したい場合は、ポインタを使えます。ちなみにPythonではクラスは裏側で参照(ポインタ)として扱われます。ただ、Pythonと違ってGoではポインタを使うかどうかを開発者が選べます。

選べるからといって、ポインタを多用するとデータフローがぐちゃぐちゃになりやすいので、JSONオブジェクトなどを除き慎重に使うのがよさそうです。

package main

import "fmt"

func modifySlice(s []int) {
    s = append(s, 4)  // スライスは参照的に振る舞うため呼び出し元に反映されやすい
}

func modifyInt(x int) {
    x = 100  // 値渡しのため呼び出し元には反映されない
}

func modifyIntPointer(x *int) {
    *x = 100  // ポインタを使うと呼び出し元の値を変更できる
}

func main() {
    mySlice := []int{1, 2, 3}
    modifySlice(mySlice)
    fmt.Println(mySlice)  // [1 2 3 4]

    myInt := 10
    modifyInt(myInt)
    fmt.Println(myInt)    // 10(変更されない)

    modifyIntPointer(&myInt)
    fmt.Println(myInt)    // 100(変更される)
}

ポインタの場合は参照渡しに似ていますが、本質的には値渡しです。関数に渡す際に、ポインタのアドレスがコピーされているだけです。

Goの文字列はバイト列?

Go言語での文字列は、Pythonのそれとは根本的に異なります。Goの文字列は実際にはバイト列であり、UTF-8エンコードを基本としています。これに対してPythonの文字列はUnicodeコードポイントのシーケンスです。マルチバイトを扱う場合はruneを使いましょう。

// 文字列はバイト列
s := "Hello, 世界"

// len(s)はバイト数を返す(UnicodeコードポイントではなくUTF-8バイト数)
fmt.Println(len(s)) // 13(UTF-8では「世界」が6バイトを占める)

// 文字列をバイト単位で処理
for i := 0; i < len(s); i++ {
    fmt.Printf("%d: %c (%x)\n", i, s[i], s[i])
    // 注意:マルチバイト文字は正しく表示されない
}

// 文字列をruneに変換して処理(runeはUnicodeコードポイント)
for i, r := range s {
    fmt.Printf("%d: %c (%U)\n", i, r, r)
    // iはバイトオフセット、rはUnicodeコードポイント
}

Pythonだともっと簡単ですね。

# Pythonでの文字列はUnicodeコードポイントのシーケンス
s = "Hello, 世界"

# len(s)は文字数(コードポイント数)を返す
print(len(s)) # 9

# 文字列の各文字(コードポイント)を処理
for i, c in enumerate(s):
    print(f"{i}: {c} ({ord(c):x})")

Goでは try/except がない

Python の try/except に相当する構文は Go にはありません。理由はコードのネストは読みづらいからです。Go では「エラーは例外ではなく値」として扱い、関数が「値, error」を多値で返すのが基本です。呼び出し側はエラーをその場で判定し、必要なら早期 return します。これにより制御フローが明示的で読みやすくなります。

v, err := Do()
if err != nil {
    return fmt.Errorf("do failed: %w", err)
}
// v を安全に使える

多値が前提なので、関数設計も自然と次のようになります。

// 値と error を返す関数の例
func LoadUser(id string) (User, error) {
    if id == "" {
        return User{}, fmt.Errorf("id must not be empty")
    }
    // ... 実処理 ...
    return user, nil
}

u, err := LoadUser(id)
if err != nil {
    return fmt.Errorf("load user %s: %w", id, err)
}

おまけ:マスコットはGopherと呼ぶ

ご存知の方も多いかもしれませんが、Goのマスコットは Gopher です。ストーリーが面白いのでぜひこのQiitaの記事を読んでてください。

まとめ

今回はGoの訓練の中で一番驚いたことを書いてきました。Golangについてはまだまだ学ぶことがあるので、進捗は改めて報告させてください!

Happy New Year!

参照