ペンギン村 Tech Blog

技術をこよなく愛するエンジニア集団が在住するペンギン村から、世界へ役立つ(かもしれない)技術情報を発信する技術系ブログです。某アラレちゃんが済む村とは一切関係ありません。んちゃ!

Swift プログラマが Go言語を学び始めた話(後編)

以下の記事の続きです。

blog.penginmura.tech

blog.penginmura.tech

最後となるこの記事では、ここまで紹介してこなかった細かい機能や、Go言語における並列処理を実現するゴルーチン・チャネルについて触れ、最後に Go 言語に対する総括的な感想を述べて終わりにしたいと思います。

defer

Swift でも用意されている defer は Go言語で初めて採用された仕組みらしい。どちらもリソースの後片付けなどに利用できる。

概略

次の例では Mutex ロックを確保した直後に defer でロック開放のコードを書くことで、関数を抜けたタイミングで確実にロック解放がされるようにしている。

func increment() {
    mu.Lock()
    defer mu.Unlock()
    count++
}

Swift で同等のAPI が用意されていれば次のようになるだろう。

func increment() {
    mu.Lock()
    defer { mu.Unlock() }
    count += 1
}

関数 vs スコープ

Swift と Go言語の大きな違いとして、Swift がスコープ単位であるのに対して、Go言語では関数単位で機能する点が大きく異なる。

func foo() {
    fmt.Println("Enter")
    for _, x := range []int{1, 2, 3} {
        defer fmt.Println(x)
    }
    fmt.Println("Leave")
}
// Enter
// Leave
// 3
// 2
// 1

Swift で同じようなコードを書くと、deferfor のスコープで機能しているのが分かる。

func foo() {
    print("Enter")
    for i in 1...3 {
        defer { print(i) }
    }
    print("Leave")
}
// Enter
// 1
// 2
// 3
// Leave

コード例は割愛するが、どちらも LIFO で処理される点は同じである。

Go言語特有の機能

なお、Go言語における defer の面白い機能として、関数に入った時と出た時の両方を処理することもできる。

func hello() {
    defer trace("hello")()
    fmt.Println("こんにちは!")
}

func trace(name string) func() {
    fmt.Printf("Enter: %v()\n", name)
    return func() { fmt.Printf("Leave: %v()\n", name) }
}

hello()
// Enter: hello()
// こんにちは!
// Leave: hello()

このコードは初見では読みづらいが、最初に defer の行が処理されたときには trace("hello()") が評価され、関数脱出時には trace() が返した関数(クロージャ)が評価されるという仕組みとなっている。

なお、Go言語では defer はかなりの頻出パターンとなっている。Go言語ではデストラクタ(Swift では deinit)が存在しないため、RAII によるリソース解放ができないという理由が大きいのかもしれない。

panic / recover

panic

Swift における fatalError() と似たものとして、Go言語でも回復不能なエラーを発生させる panic() が存在する。どちらもコールスタックを遡り、最終的にプログラムをクラッシュさせるという点で同様である。

n, err := Div(6, 2)
if err != nil {
    panic("error") // プログラムをクラッシュさせる
}

Go言語において、呼び出し元でエラーハンドリングが可能な場合は error を利用するが、メモリ確保に失敗した場合など呼び出し元がどうすることもできないものについては panic() を利用するという使い分けになっている。

recover

Swift との大きな違いとして、Go言語の panic()recover() を利用して、例外におけるキャッチのようにハンドリングすることが可能な点がある。

func fatal() (err error) {
    defer func() {
        if p := recover(); p != nil { // panic をハンドリングし、エラーに変換
            err = fmt.Errorf("error")
        }
    }()
    panic("fatal error")
}

これは例外と同じような使い方ができることになるが、recover() はプログラムを強制終了する前の後片付けとして利用するのが一般的なようなので、多用すべきではないようだ。

構造体埋め込み

Go言語における少し変わった機能として、構造体埋め込みと言われるものがある。中編で登場させたRectangle 構造体に、座標を表す Point 構造体を追加する場合、通常は次のように記述する。

type Point struct {
    X, Y int
}

type Rectangle struct {
    Point         Point
    Width, Height int
}

これは次のように利用することになる。

r := Rectangle{
    Point:  Point{X: 1, Y: 2},
    Width:  5,
    Height: 4,
}
fmt.Printf("(%v, %v)\n", r.Point.X, r.Point.Y)

Swift と同じように r.Point.X といったように段階的にアクセスしているが、構造体埋め込みという機能を利用すると r.X という風にアクセスできるようになる。

type Rectangle struct {
    Point // フィールド名を省略し、型だけ宣言
    Width, Height int
}

r := Rectangle{
    Point:  Point{X: 1, Y: 2},
    Width:  5,
    Height: 4,
}

fmt.Printf("(%v, %v)\n", r.X, r.Y)

イメージ的には Point 構造体のフィールド XY をインライン化している埋め込んでいる感じだろうか。ただし、上記のコードを見て分かるとおり、初期化コードは Point を明示的に指定する必要があるため、説明としては厳密ではない。

ここではフィールド(XY)が昇格される例を挙げたが、もし Point がメソッドを持っていれば、それも Rectangle に昇格される。

func (p Point) dump() {
    fmt.Printf("(%v, %v)\n", p.X, p.Y)
}

r := Rectangle{
    Point:  Point{X: 1, Y: 2},
    Width:  5,
    Height: 4,
}
r.dump() // => (1, 2)

この昇格においては名前が衝突する可能性があるが、上位レベルから優先して探索され、同階層のレベルで衝突した場合はコンパイルエラーとなる。

例えば、Rectangle 構造体にも dump() というメソッドが宣言されていた場合、Point#dump() ではなく Rectangle#dump() が解決されるという意味だ。

func (r Rectangle) dump() {
    fmt.Printf("(%v, %v)\n", r.Width, r.Height)
}

func (p Point) dump() {
    fmt.Printf("(%v, %v)\n", p.X, p.Y)
}

r := Rectangle{
    Point:  Point{X: 1, Y: 2},
    Width:  5,
    Height: 4,
}
r.dump() // => (5, 4) - 上位の`Rectangle#dump()`が優先される

これは Swift に無い Go言語特有の機能と言えるが、 Swift における Protocol のデフォルト実装が、準拠したクラスや構造体に対して機能を追加するのと同様に、一種の Mix-in としても捉えることもできるかもしれない。

ゴルーチン / チャネル

Go言語における並行処理

Swift において GCD(Grand Central Dispatch)がスレッドに代わって高レベルの平行処理API を提供するのと同じように、Go 言語では「ゴルーチン」と「チャネル」が OSスレッドに代わる高レベルのAPIを提供する。

ただし、GCD がタスクキュー的な平行処理を提供するのに対して、ゴルーチンはスレッドに似たようなものであり、チャネルは Actor モデルのようなゴルーチン間の通信の役割を果たす。チャネルにおいてはキューという概念が出てくるが、データ構造としてキューが採用されている点が同じだけで、GCD のキューと比較できるものではない。

そういった意味で、Go言語の並行処理を学ぶ際は GCD と比較するよりは、新たな並行処理の概念としてゴルーチン・チャネルを学ぶという姿勢をとったほうが、学習効率が良いのだろうと私は感じた。

ゴルーチン・チャネルは以下のような1冊の本になるほどの深いテーマであるため、ここではイメージがつかめるレベルの内容に留めたいと思う(現状では、私が深く理解していないという理由もある)。

Go言語による並行処理

Go言語による並行処理

  • 作者: Katherine Cox-Buday,山口能迪
  • 出版社/メーカー: オライリージャパン
  • 発売日: 2018/10/26
  • メディア: 単行本(ソフトカバー)
  • この商品を含むブログを見る

ゴルーチン

ゴルーチンはOSスレッドのようなもので、1つの独立した処理を表している。ただし、OSスレッドと違ってとても軽量で、何百万という単位のゴルーチンを起動することも現実的らしい。

他の言語においてプログラム実行時にメインスレッドから起動され、必要に応じてバックグラウンドスレッドが起動されるのと同じように、メインゴルーチンから始まり、必要に応じて別のゴルーチン(サブゴルーチンとでも呼べばいいのだろうか?)が起動される。

新たなゴルーチンを起動したい場合は、関数呼び出しの前にキーワード go をつけるだけである。

func calc() {
    // 重い処理
}

func main() {
    go calc()
    fmt.Println("calculating...") // メインゴルーチンはそのまま継続される
}

goを付けて呼び出した calc() は新たなゴルーチンによりバックグラウンドで実行されることになる。メインゴルーチンはそのまま継続して実行されるため calc() の処理に10秒かかったとしても calculating… はすぐに出力される。

なお関数に切り出さず、関数リテラルを使って即時実行することも可能である。

go func() {
    // 重い処理
}()

このようにキーワード go をつけるだけで挙動を変更できるので、他の言語に比べると並行処理についてのサポートがかなり手厚くなっていることが分かる(これはチャネルについても同様だ)。

チャネル概略

チャネルはゴルーチン間の通信に利用されるもので、特定の型のデータが流れるストリーム(パイプ)と考えることができる。

そういった意味では、Rx における Observable と近いものがあるかもしれない。チャネルには Error こそ流れないもの「閉じる」という概念があり、これは Rx における Completed と似たものと考えることもできる。

スレッドによる並行処理プログラミングにおいては、複数スレッドでメモリを共有し、そのメモリにどうやって安全にアクセスさせるか(競合状態にさせないか)という考え方が基本となる。一方で、Go言語ではメモリは共有せずに、チャネルを使って必要なデータをやり取りするという考え方をする。

Go言語のFAQでは次のような文があり、その考え方を簡潔に示している。

共有メモリを使って通信しないでください。それとは逆に、通信によってメモリを共有してください。

ここにおける 通信 を実現するものが チャネル ということになる。Go言語でも共有メモリを利用する API は提供されているが、可能であればチャネルを使ってデータをやり取りするのが基本スタンスとなる。

チャネルの利用例

ここでは1つだけ例を見てみる。

// 重い計算処理のイメージ
func calc(out chan<- int) {
    time.Sleep(3 * time.Second)
    out <- 42 // チャネルに結果を送信
}

out := make(chan int) // チャネルを作成

go calc(out) // 新たなゴルーチンで実行
fmt.Println("calculating...")

n := <-out // チャネルから結果を受信(受け取れるまでWait)

fmt.Println(n) // => 42

ここでは make(chan int) を使って int 型の値が流れるチャネルを作成している。Rx なら Observable<Int> みたいなものだ。

calc() の引数にチャネルを渡し、新たなゴルーチンで実行しているが、その直後に n := <-out でチャネルから結果を受信する処理を書いている。この <-ch という記法がチャネル(ch)から結果を受信(<-)するという意味を持つ。

チャネルから値を取り出しようにも、何も入っていない場合は実行がブロックされる。ここではcalc()関数内で3秒経ってから、out <- 42 とチャネルに値を送信しているため、n := <-out の実行はそれが完了するまで停止される。スレッドプログラミングをやったことがある人であれば、join() と似ていると感じるだろう。

説明が前後してしまったが、チャネルに値を送信する場合は ch <- value という記法を使用し、チャネル(ch)に対して値(value)を送信(<-)するという意味になる。慣れないうちは混乱するかもしれないが、<- がデータの流れを示していると考えるとわりと直感的なシンタックスになっていると私は感じた。

ここで紹介したチャネルは「バッファなしチャネル」と呼ばれるものであり、チャネルを理解するには「バッファありチャネル」も知る必要があるのだが、これを書き始めると長文になりそうなので機会があれば別記事として書きたいと思う。

おわりに

ここまで Swift と比較しながら Go 言語の機能について紹介してきた。

Go言語はそこまで特殊な機能がなく、学ぶのが比較的かんたんな言語だという言葉もたまに耳にする。しかし、それはある意味で正しく、ある意味で間違っている、と今回学んでみて感じた。

たしかに Rust や Haskell、あるいは Scala といった言語に比べれば、Go言語はとてもコンパクトに作られていて、学習コストは低いと言えるだろう。振り返ってみても、Go言語において特有といえる機能はゴルーチン・チャネルくらいのものであると感じる。

しかし、Go言語の独特なシンタックスは慣れないうちは難しく感じるだろうし、他の強いプログラミング言語に比べて機能が少ないGo言語で、Go言語らしくプログラムを書くのにはそれなりの学習と慣れが必要だと感じた。

私は Rust や Scala,それに Swift といった、いわゆる近代的なプログラミング言語が好きであり、50年間のプログラミング研究を捨てたとも揶揄される Go言語を学ぶのにはかなり抵抗があった。前編の冒頭でも述べたが、学んでいく際にも機能に不足を感じたり、安全ではないと感じる仕様も多かった。しかし、全体を通してみるとそこにはたしかにシンプルさという哲学を感じられ、それが Go言語を Go言語たらしめているのだと思った。

私は新しいプログラミング言語を学ぶ際には、すでに習得しているプログラミング言語の機能と比較しながら学ぶと効率的だと感じている。この連載記事が Swift あるいは Kotlin といった近代的なプログラミング言語の経験者が、Go言語を学ぶ際の一助になればと思う。

P.S.

この連載記事を書くにあたっては、あえて説明しすぎないことを意識した。

Go言語がどういったものであるかの雰囲気をつかみ、Go言語に対する抵抗感を少しでも減らせることができる記事にしたいという思いがあったからだ。

newmakeといった基本的なキーワードにすら触れていないし、リフレクションやunsafeなAPI、それに特徴的なユニットテストについてもあえて触れなかった。そして基本的には Swift との比較に留めることにして、Rust では〜といった他のプログラミング言語の例も削ることにした。(もちろん私が学習途中であり、すべてを語ることができないという現実的な理由もある)

Go言語のバイブル的と言われる書籍「プログラミング言語Go」は400ページ以上あり、読むのにはそこそこのエネルギーが必要となるが、言語仕様について丁寧に解説されている良書だと感じた。Go言語についてより深く学びたい場合はこの書籍を読んでみても良いかもしれない。

プログラミング言語Go (ADDISON-WESLEY PROFESSIONAL COMPUTING SERIES)

プログラミング言語Go (ADDISON-WESLEY PROFESSIONAL COMPUTING SERIES)