An intro to Go for non-Go developers

Ben Hoyt

Goを使ったことがない開発者のためのGo入門

原文は Ben Hoyt により に公開されました。 このブログを購読する

要約:Goに不慣れな開発者向けにGo入門のトークを何度か行ってきました。この記事は、そのトークを技術記事として文章化したものです。なぜGoを使うべきなのかを考察し、標準ライブラリと言語自体の簡単な概要を紹介します。

数年前、私はサイドプロジェクトであるGifty WeddingsのサーバーをPythonからGoへ移植することでGoを学びました。楽しく言語を学べる方法で、基本的なレベルでGoを習得しコードを移植するのに「バス通勤2週間」ほどかかりました。

それ以来、私はこの言語での開発を本当に楽しんでおり、仕事でもGoAWKzztgoといったサイドプロジェクトでも幅広く使ってきました。現在の職場であるCompass.comでのGoの利用は、私が在籍している間に大きく拡大し、200を超えるサービスのうち約半数がGoで書かれています。

この記事では、私が考えるGoの素晴らしい点をいくつか紹介し、標準ライブラリをごく簡単に概観した後、言語の中核に踏み込んでいきます。ただし、実際のGoコードがどんなものか感触をつかみたいだけなら、HTTPサーバーのサンプルまで飛ばしてください。

なぜGoなのか?

次のGoogle Trendsのグラフが示すように、Goはここ数年で非常に人気が高まっています。その理由は言語自体のシンプルさにもありますが、おそらくより重要なのは優れたツール群にあります。

2010年から2020年までの「golang」のGoogle Trendsデータ

以下は、私がGoでのプログラミングを気に入っている理由です(あなたが気に入るかもしれない理由でもあります):

  • 小さくシンプルなコア言語。 GoはCと同じくらいの規模感で、非常に読みやすい言語仕様はわずか約50ページです(Java仕様の770ページと比べてみてください)。そのため、学ぶのも他人に教えるのも簡単です。
  • 高品質な標準ライブラリ、特にサーバーやネットワーク周りが充実しています。詳細は後述します。
  • 一級の並行処理。スレッドのようなものですがより軽量なgoroutineと、それを起動するgoキーワード、goroutine間の通信のためのチャネル、そしてこれらすべてを調整するスケジューラを持つランタイムを備えています。
  • ネイティブコードにコンパイルされ、主要なプラットフォームすべてでデプロイしやすいバイナリを生成します。
  • 細かい調整を必要としないガベージコレクション(低レイテンシ向けに最適化されています)。
  • 静的型付けですが、型推論があるため「型の繰り返し」を大幅に避けられます。
  • 簡潔で実行可能なサンプルを多数含む優れたドキュメント
  • 優れたツール群。 go buildと打てばプロジェクトをビルドでき、go testでテストの検出と実行ができます。そのほかCPUやメモリのプロファイリング、コードカバレッジ、クロスコンパイルまで、外部ツールを必要とせずに利用できます。
  • 高速なコンパイル。 Goは当初からコンパイルの速さを念頭に設計されています。実際、共同開発者のRob Pikeは「Goは巨大な[C++]のコンパイルを待っている間に思いついた」と冗談を言っています
  • 非常に安定した言語とライブラリ。すべてのGo 1プログラムが将来のGo 1.xでも変更なしに動作することを保証する厳格な互換性の約束があります。
  • 人気がある。 Stack Overflowの2019年調査によると、3番目に使いたいプログラミング言語に選ばれており、Goを使いたい開発者を採用しやすいという利点もあります。
  • クラウドツールで広く使われている。 DockerやKubernetesはGoで書かれており、Dropbox、DigitalOcean、Cloudflareなど多くの企業がGoを大々的に採用しています。

標準ライブラリ

Goの標準ライブラリは充実しており、クロスプラットフォームで、ドキュメントもよく整備されています。Pythonと同様にGoも「batteries included(電池同梱)」であり、サードパーティの依存関係なしですぐに実用的なサーバーやCLIツールを作れます。以下は主なハイライトです(私が使ってきたものに偏っています):

サードパーティのパッケージに関して言えば、Goの典型的な考え方は、npmパッケージを片っ端から取り込むJavaScriptのやり方とはほぼ正反対です。GoogleのGoチームのテックリードであるRuss Coxはソフトウェアの依存関係の問題について語っており、Goの共同開発者であるRob Pikeは「ちょっとしたコピーはちょっとした依存関係よりましだ」とよく言います。そのため、ほとんどのGopherはサードパーティのライブラリの利用にかなり慎重だと言ってよいでしょう。

とはいえ、このトークを最初に書いてから、Goチームは依存関係の管理やバージョン固定をどう行うべきかという公式な答えとしてmodulesを設計・構築しました。私は使ってみて快適だと感じており、通常のgoサブコマンドすべてで問題なく動作します。

言語機能

それでは、Go自体がどのようなものかを見ていき、言語そのものを順にたどってみましょう。

Hello world

GoはCに似た構文を持ち、波括弧は必須で、セミコロンはありません(形式的文法を除いて)。プロジェクトはimportとpackageによって構造化されます。コンパイル単位は、1つ以上の.goファイルを含むディレクトリです。「hello world」は次のようになります:

package main

import "fmt"

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

やや議論を呼ぶ機能

Goには、初めて見たときはとっつきにくく感じられるものの、慣れると実はなかなか良いと分かる点がいくつかあります。

1つ目はコードフォーマットです。go fmtを実行するだけで、波括弧や空白(そしてタブ!)をあるべき場所に整えてくれます。スタイル論争を避け、一貫して整形されたコードで作業を進めるための素晴らしい方法です。

大文字で始まる名前は公開(「exported」)され、小文字で始まる名前はパッケージ内で非公開です。これは最初は非常に奇妙に感じられますが、ルール自体は理解しやすく、Javaのようなpublic static voidといったキーワードの騒がしさを減らしてくれます。publicキーワード自体が不要になります。見た目は次のとおりです:

package people

type Person struct {
    Name      string // fields Name and Age are exported ("public")
    Age       int
    hairColor color  // hairColor is not exported ("private")
}

func New() *Person { ... } // New is exported

func doThing() { ... } // doThing is not exported

一部の開発者が引っかかるもう一つの点は、警告はエラーだということです。Goの組み込みツールにはオプションがほとんどなく、他のコンパイラなら警告になるようなものがGoではエラーになります(言い換えれば、警告は存在しません)。

そのため、未使用のローカル変数や未使用のimportでさえコンパイルエラーになります。これは開発中には少し煩わしく感じることもありますが、コードをクリーンに保ち、どのコンパイラ警告を有効にするかで開発者同士が争うのを防いでくれます。

より議論を呼ぶ機能

さらに議論を呼ぶ機能——というより機能の欠如——が2つあります。すなわち、Goに例外がないことと、ユーザー定義のジェネリクスがないことです。

Goには伝統的な意味での例外がありません。当初からエラーは値であるというのが合言葉で、他の値と同じように明示的に渡したり、返したり、処理したりすべきとされています。そのため、FileNotFound例外を投げる代わりに、エラー値をチェックします:

f, err := os.Open("filename.ext")
if err != nil {
    log.Fatal(err)
}
// do something with the open file f

これによりコードはやや冗長になります(if err != nilはすぐに手が届くところに置いておきましょう)が、各レベルでエラー処理を明示的にする利点があります。コンテキストを追加する、エラーをログに残す、より上位のエラーオブジェクトに変換する、あるいは捨てることさえ選べますが、いずれにせよ明示的に対処しなければなりません。

Goが持っていないことでよく批判される2つ目は、ユーザー定義のジェネリクスです。そのため、独自の型安全なOrderedMap<int>のようなものを定義することはできません。しかし、組み込みのslice型やmap型には静的型付けされたジェネリクスがあるため、痛みを感じることなくかなり遠くまで行けます。

もう一つ付け加えておくと、ジェネリクスはまさに取り組みが進められているところです。Goチームは、後付けで継ぎ足すのではなく、コストをきちんと考慮したGoらしい形で追加したいと考えています。ドラフト提案実験的な実装があり、Featherweight Goという最近の型理論の論文さえあります。ですから、今後12〜18か月のうちに何らかの形のジェネリクスを備えたGoがリリースされても驚きません。

さて、Goにないものについての話はこのくらいにして、Goが備えている機能(その多くはユニークです)を見ていきましょう。

簡潔な型推論

Goには:=演算子を使った「短い変数宣言」と呼ばれる簡潔な型推論があります。型推論のおかげで、(言うまでもなく)タイプする量が減り、少しスクリプト言語のように感じられます。見た目は次のとおりです:

package main

import "fmt"

// Output: 3 4 hello 5
func main() {
    var i int = 3
    j := 4          // j is an int
    s := "hello"    // s is a string
    a := add(2, 3)  // a is an int
    fmt.Println(i, j, s, a)
}

func add(x, y int) int {
    return x + y
}

一方で、Goは異なるビット幅や符号の有無が違う整数間であっても、自動的な型強制を一切行いません。GoのアプローチをCと比較したFAQの回答を引用します:

Cにおける数値型間の自動変換の利便性は、それが引き起こす混乱によって相殺されてしまいます。式はいつ符号なしになるのか?値の大きさはどれくらいか?オーバーフローするか?結果は実行するマシンに依存せずポータブルなのか?

forループとrange

Goにはループ用のキーワードがforひとつだけあり、whileループ、昔ながらのCスタイルのループ、そしてrangeループ(Goの「for each」)に使われます。sliceやmapをrangeでループする際、Goは1つ目の値としてインデックス(またはmapのキー)を、2つ目の値として値を返します。

いくつかループの例を挙げます。ここまでは特に変わったところはありません:

// C style "for"
for i := 0; i < 10; i++ {
    fmt.Println(i)
}

// Like "while"
for safe.IsLocked() {
    time.Sleep(5 * time.Second)
}

// Loop through elements of array or slice
for index, person := range people {
    fmt.Println(index, person)
}

// If you don't care about the index
for _, person := range people { ... }

// Loop through keys/values of a map
for word, count := range counts { ... }

sliceはとても便利

Goにおいてsliceは配列の一部への参照です。内部表現は非常にシンプルで、データへのポインタ、長さ、そして容量からなります。sliceはジェネリックなので、[]float64と表記されるfloat64のsliceや、[]PersonのようなPerson構造体のsliceを持つことができます。

sliceはPythonのような構文で「スライス」できます。例えばslice[:5]とすれば先頭の5要素を参照する新しいsliceが返ります。新しいsliceも同じ backing array を参照しているため、ポインタを扱うのと同じくらい効率的ですが、メモリセーフです。ランタイムがsliceの終端を超えてしまうような操作や、その他の危険な操作を防いでくれます。

組み込みの汎用関数append()があり、sliceに単一の要素を追加して新しいsliceを返します。backing array(容量)が足りない場合は、サイズが2倍の新しい配列を確保して要素をコピーします。

sliceは次のように使います:

// Create array and slice pointing into it
nums := []int{3, 4, 5, 6}

// Slice the slice
fmt.Println(nums[1:3]) // Output: [4 5]
fmt.Println(nums[:2]) // Output: [3 4]
fmt.Println(nums[2:]) // Output: [5 6]

// Append: may reallocate underlying array
nums = append(nums, 7, 8)
fmt.Println(nums) // Output: [3 4 5 6 7 8]

sliceの機能はかなりミニマルで、(Python出身の)私が恋しく思ったものの一つがリスト内包表記です。リストからいくつかの要素をフィルタリングするだけなのに、なぜforループとif文が必要なのでしょうか。かつてGoチームのメンバーに、なぜそのような機能がないのか尋ねたところ、Goは「システム言語」なのでメモリ割り当てを制御できるようにしたいからだと言われました。例えば、効率化のためにmake()を使ってsliceのbacking arrayを事前に確保することができます。

Map

Goのmapは、キーを値にマッピングする順序なしのハッシュテーブルです。sliceと同様にジェネリックかつ型安全なので、例えばmap[string]intのように「文字列キーを整数値にマッピングするmap」を持つことができます。

map型は取得、設定、削除、存在確認、反復処理を提供します。sliceと同様に、「サイズヒント」を使ってmake()でメモリ割り当てを制御できます。

mapについてはもっと語るべきことがあり、実装について読むこともできますが、ここではコードで少しだけ味見してみましょう:

phrase := "the foo foo bar the foo"

counts := make(map[string]int)
for _, word := range strings.Fields(phrase) {
    counts[word]++
}

fmt.Println(counts)
// Output: map[foo:3 bar:1 the:2]

// map literal
maths := map[string]float64{
    "pi":  3.14,
    "tau": 6.28,
}

ポインタ、しかし安全

Goにはポインタがありますが、CやC++とは異なり安全です。存在しないメモリを指すことはできず、ランタイムがnilポインタのデリファレンスを防いでくれます。実際、ポインタ演算は一切ありません。何かにインデックスでアクセスしたい場合は、安全なsliceを使うか、低レベルのunsafeパッケージに頼る必要があります(私は使ったことがありません)。

ポインタはCと同様に*&の構文を使い、*はポインタのアドレスにある値を取り出し、&は変数のアドレスを取得します。

うれしい構文上の特徴の一つは、Cスタイルの->演算子がないことです。構造体ポインタをデリファレンスしてフィールドを取得するのにも.を使います。例をいくつか挙げます:

p := new(Person) // p is a "pointer to Person"
p.Name = "Joe Bloggs"
p.Age = 42
pers := *p // dereference p back to Person

// More succinct alternatives
p = &Person{"Joe Bloggs", 42}
p = &Person{Name: "Joe Bloggs", Age: 42}
pers = Person{"Joe Bloggs", 42}
p = &pers

Defer

Goにはdeferというユニークなキーワードがあり、現在の関数がreturnする(あるいはランタイムの「panic」によって終了する)直前に、指定された関数呼び出しを実行します。deferが複数回呼ばれた場合、関数は最後にdeferされたものから順に呼び出されます。これはC++のRAIIやPythonのwith文の代わりに、リソースのクリーンアップに使われます。

私の知る限り、deferはGo独自の制御フロー文であり、エラーハンドリングを明示的に行うというGoのアプローチによく合っています。詳細はこちらで読めますが、ここではよくある作業であるファイルのオープンとクローズの非常にシンプルな例を示します:

f, err := os.Open("file")
if err != nil {
    log.Fatal(err)
}
defer f.Close()
// read from f

Goroutine

GoroutineはGoの並行処理機構です。スレッドのようなものですが、はるかに軽量で、10万、あるいは100万ものgoroutineを同時に生かしておくことも容易です。Goランタイムがgoroutineをスケジューリングし、必要に応じて(例えばI/Oの準備ができたときなど)起こしてOSのスレッド上で実行します。

Goの並行処理モデルの素晴らしい点の一つは、標準ライブラリのすべての関数がシンプルな同期APIを持っていることです。並行処理が必要な場合は、明示的にgoroutineを使います。これにより、「色付き関数」の問題——一部の言語が持つ、非同期用と同期用の2種類のAPI——を回避できます。

goroutineを開始するには、単にgo backgroundFunc()と書くだけで、Goランタイムが新しいgoroutineでbackgroundFuncを起動します。以下は、ユーザーのサインアップを記録し、バックグラウンドでメールを送信するハンドラ関数のシンプルな例です(これは私がサイドプロジェクトで実際に使っているコードに似ています):

func ProcessSignup(u *User) {
    u.SignedUpAt = time.Now()
    u.Save(db)
    go SendEmail(u.email, "Thanks for signing up!", "signup.html")
}

チャネル

goroutineを開始してもpromiseやgoroutine IDは返りません。goroutine間で通信したり作業の完了を通知したりしたい場合は、明示的にチャネルを使う必要があります。チャネルはGoの主要なgoroutine間通信機構であり、Go Proverbにあるように、「メモリを共有することで通信するな、通信することでメモリを共有せよ」という考え方です。

チャネルは型安全でスレッドセーフなキューであり、データをやり取りできるだけでなく、同期も行えます。チャネルから読み取ろうとするgoroutineは、別のgoroutineが書き込むまで待機します。

ここでは、単純な「配列の合計」タスクを並列化する例を示します。この例は実際にはgoroutineを使うことで恩恵を受けるとはほぼ考えられませんが、考え方は伝わるでしょう:

func main() {
    s := []int{7, 2, 8, -9, 4, 0}

    c := make(chan int)
    go sum(s[:len(s)/2], c) // first half
    go sum(s[len(s)/2:], c) // second half

    // Receive both results from channel
    x, y := <-c, <-c

    fmt.Println(x, y, x+y)
}

func sum(s []int, c chan int) {
    sum := 0
    for _, v := range s {
        sum += v
    }
    c <- sum // Send sum back to main
}

チャネルは強力な仕組みであり、語るべきことはたくさんあります(バッファあり/なし、クローズされたチャネルなど)が、それについてはEffective Goに譲ります。

型とメソッド

Goはユーザー定義型をサポートし、型はメソッドを持つことができますが、クラスはありません(Goはclassyな言語ではないと言う人もいます)。そしてstructやインターフェース(後述)はありますが、継承はありません。OOPの良い部分はすべてコンポジションで行われますが、埋め込みのような別のアプローチを提供する仕組みもあります。

型に定義されたメソッドは「レシーバ」引数を取ります。これはPythonのselfや他の言語のthisに似ています。ただし、いくつかユニークな性質があります(例えば、レシーバはポインタにも値にもなれます)。レシーバの名前は自由に付けられますが、通常は対象の型の頭文字が使われます。

以下は、2つのフィールドを持つシンプルなstructとStringメソッドの例です:

type Person struct {
    Name string
    Age  int
}

func (p *Person) String() string {
    return fmt.Sprintf("%s (%d years)", p.Name, p.Age)
}

// Output: Bob (42 years)
func main() {
    p := &Person{"Bob", 42}
    fmt.Println(p.String()) // but .String() is optional; see below
}

インターフェース

インターフェースは、class MyThing implements ThatInterfaceと明示的に宣言しなければならないJavaなどの言語とは少し異なります。Goでは、ある型にインターフェースのすべてのメソッドを定義すれば、その型は暗黙的にそのインターフェースを実装したことになり、インターフェースが求められる場所ならどこでも使えます。implementsキーワードはどこにも現れません。

Goのアプローチはしばしば「静的なダックタイピング」と呼ばれ、構造的型付けの一種です(TypeScriptも構造的型付けを採用している人気の言語です)。

インターフェースは標準ライブラリやGoのコードのあらゆる場所で使われています。最も一般的な2つの例は、Printfなどが値の文字列表現を生成できるようにするStringerインターフェースと、ファイル、HTTPサーバー、gzip圧縮されたファイル、文字列バッファなどを読み取り/書き込みストリームとして扱えるようにするio.Readerio.Writerインターフェースです。

以下はStringerWriterインターフェースの定義です。どちらも非常にシンプルな単一メソッドのインターフェースです(Goでは小さなインターフェースが非常に一般的です)。実際にこれらを自分で定義する必要はありませんが、このコードで構文を示します:

// Defined in package "fmt"
type Stringer interface {
    String() string
}

// Defined in package "io"
type Writer interface {
    Write(p []byte) (n int, err error)
}

// ...

func main() {
    p := &Person{"Bob", 42}
    fmt.Println(p.String())
    // Equivalent (Person implements Stringer, which Println looks for)
    fmt.Println(p)
}

Goにおけるインターフェースの重要性は、強調してもしすぎることはありません。アルゴリズムを汎用的にしたり、関数をテスト可能にしたりするために使われます。Effective Goでさらに詳しく読んでみてください。

HTTPサーバーのサンプル

最後に、GoでHTTPサーバーを書くのがいかに簡単かを示す小さなプログラムを2つ紹介します。これらは単なるおもちゃではありません。Goのnet/httpパッケージは本番利用に対応しています(他の多くの言語に付属する組み込みWebサーバーが、常に「本番では使わないでください」と断り書きをしなければならないのとは対照的です)。

こちらは、userクエリ文字列パラメータをエコーする単一のルートを持つ、非常に基本的なHTTPサーバーです。http.ResponseWriterio.Writerとしてfmt.Fprintfに渡されている点に注目してください:

package main

import (
    "fmt"
    "net/http"
)

func main() {
    http.HandleFunc("/", handler)
    fmt.Println("listening on port 8080")
    http.ListenAndServe(":8080", nil)
}

func handler(w http.ResponseWriter, r *http.Request) {
    user := r.URL.Query().Get("user")
    if user == "" {
        user = "world"
    }
    fmt.Fprintf(w, "Hello, %s", user)
}

少しだけ高度な例として、ここでは数行のコードでカスタムの正規表現ベースのルーターを持つHTTPサーバーを構築します。

追記: このようなカスタムルーティングのコードはもう必要ありません。Go 1.22では、/user/{userId}のようなパスに直接マッチできるhttp.ServeMuxの拡張が追加されました。

// NOTE: use the new http.ServeMux routing in Go 1.22!

package main

import (
    "fmt"
    "net/http"
    "regexp"
)

type route struct {
    pattern *regexp.Regexp
    handler func(w http.ResponseWriter, r *http.Request, matches []string)
}

func home(w http.ResponseWriter, r *http.Request, matches []string) {
    fmt.Fprintf(w, "Home")
}

func user(w http.ResponseWriter, r *http.Request, matches []string) {
    user := matches[1]
    fmt.Fprintf(w, "User ID: %s", user)
}

func main() {
    routes := []route{
        {regexp.MustCompile(`^/$`), home},
        {regexp.MustCompile(`^/user/(\w+)$`), user},
    }
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        for _, route := range routes {
            matches := route.pattern.FindStringSubmatch(r.URL.Path)
            if len(matches) >= 1 {
                route.handler(w, r, matches)
                return
            }
        }
        http.NotFound(w, r)
    })
    fmt.Println("listening on port 8080")
    http.ListenAndServe(":8080", nil)
}

goツール

最近、誰かに一番好きな開発ツールは何かと聞かれました。最初は「Sublime Textかな?」と答えましたが、考え直しました。今一番好きな開発ツールはgoコマンドだと思います。Makefileなしで、以下のすべてを高速にこなすことができます:

go build          # build your project, produce a static executable
go run            # quick way to build and run, for development
go fmt            # format your .go files in the standard way
go test           # find and run your tests
go test -bench=.  # run all your benchmarks too

go mod init                # initialize a "Go modules" project
go get github.com/foo/bar  # fetch and install the "bar" package

他にもたくさんのコマンドがあります。完全なドキュメントを読んでみてください。

しかし私にとって何より驚くべきことは、2つの環境変数GOOSGOARCHを設定してからgo buildを実行するだけで、Goが指定されたOSとアーキテクチャ向けにプロジェクトをクロスコンパイルしてくれることです。macOSやWindowsマシン上でデプロイ可能なLinuxバイナリを作成するワンライナーは次のとおりです:

GOOS=linux GOARCH=amd64 go build

かっこよくないですか? Turbo Pascal以来、開発がこれほど簡単になったことはありません……

まとめ

Goとそのエコシステムについてはまだまだ語るべきことがたくさんありますが、他の言語から来た方にとってこの記事が役立つ入門になれば幸いです。始めるには、公式のGo Tourを強くお勧めします。さらに深く学ぶには、Effective Goを読み、その後優れた書籍The Go Programming Languageを読んでみてください。

そして、Goで書こう!

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。

コメント