このブログのポストとしてはいきなりですが、転職してからは仕事の都合上Goも少し触ることとなりました。以前からGoかRustに触れてみたいなとは思っていたのものの、いざ全く触れてみたことのない言語で書かれてあるアプリを修正するようなことになると少し怖くもなります。なので、少しでもGoのことを知っておくべきではありますね。というわけで、今回はGoに少し触れてみて感じたことを、Javaプログラマーの観点から述べてみたいと思います。
Goの特徴は、色々とあると思いますが、正直GCがあって、VMがない言語だという特徴は、実際はあまり肌で感じる違いではないです。VMがないから、ある言語よりは早いだろうなというとしか言えませんね。
実際その言語を持ってコードを書くという仕事をしている身からしては、そのような特徴よりも、コードを書く場面で気にしなければならないことの方に注目したいものです。まずは自分が今まで触れてきた言語とはどう違うかですね。例えば、ループや条件などを書くときはどうなのか、今までの習慣通りコードを書いても問題ないだろうか、注意すべきところは何かなど。今回はそのような観点から本当の少しだけGoに触れてみた感想を書いていきたいと思います。
考え方を変える必要があるかも
Goに少し触れてみて考えたことは、もっとも基本的な部分でもJavaを書くときとはかなり違うアプローチが必要ではないかということでした。私の場合はJava意外にPython、JavaScript、TypeScript、Kotlinに触れてみたことがあるのですが、JavaScriptやTypeScriptはJavaの書き方とそう変わらない感覚で書くことができて、Kotlinも基本はJavaを簡略化した感覚でコードが書けます。Pythonがかなり違うのですが、どちらかというと書きたいコードを文法の制約なしで書けるという感覚に近いので、文法の差が気にならないものでした。
しかし、Goの場合は少しわけが違います。Javaと比べ、書き方が少し違うだけでなく、機能レベルで違いがあるからです。機能レベルで違うということは、単純にJavaのコードを少し変更したくらいのコードを書くことはあまりよくないということになると思います。なので、そもそもの考え方を変える必要があるのではないかと思いました。そういう観点から感じたGoの印象は、以下の通りです。
似ているようで、似ていない
まず目立つのは、文法です。もちろん、大枠はいわゆるCファミリープログラミング言語とそう変わらないですが、Javaと比較すると文法の構造以外の部分でかなり変わった部分があります。例えば、Walrus Operator
とも呼ばれるPythonの代入式に似たような表現があったり、if文の条件式を括弧なしで書けたり、importを文字列で書いたり、クラスやpublic
・private
のようなキーワードが無かったりの違いがあるので、コードを書くときの感覚が違うだけでなく、パッケージ構造やアプリケーションのアーキテクチャ設計のレベルで今まで自分が経験していたJavaやKotlinとは違うアプローチが必要ではないかと思えるくらいです。
色々と違う点を述べましたが、単純にコードを持って比較してみましょう。例えば、以下のようなコードがあるとしましょう。数字に関する計算を担当するクラスがあって、中には渡された引数が奇数か偶数かを判別して、結果を標準出力する形です。
package simple.math;
public Class Calculator {
public void judge(int number) {
boolean result = number %2 == 0;
if (result) {
System.out.println(number + "は偶数です");
} else {
System.out.println(number + "は奇数です");
}
}
}
これをGoのコードに変えてみます。例えば以下のようになると思います。
package simple.math
import "fmt"
func Judge(number int) {
condition := number % 2 == 0
if condition {
fmt.Println(number, "は偶数です")
} else {
fmt.Println(number,"は奇数です")
}
}
一見あまり変わらないように見えるかもしれませんが、細かいところが違うので注意しなければならないところがあります。いくらIDEの性能がよくなったとはいえ、その言語の仕様と全く違うようなコードを書いてしまっては、正しいコードを提示してくれませんので。例えばインポートは複数になると、以下のようになりますね。
import (
"fmt"
"math"
)
Pythonの場合も一行でimportをまとめたり、from
とas
でAliasを指定するようなことができたりもします。しかし、Goで根本的に違うのは、GoそのものがMavenやGradleのようなパッケージ管理もできるので、インポートにgithubのパッケージを描くこともできるということです。例えば以下のようなコードで、GoのウェブフレームワークであるGinをインポートすることができます。
import "github.com/gin-gonic/gin"
また、変数を:=
を使って宣言する場合は、関数内でのみ可能であるので、パッケージレベルで宣言する場合は普通にvar
を使う必要があるという仕様も理解する必要があったりします。そしてそれに対して、関数の引数としてはvar
宣言がいらなく、型を宣言する必要があります。どんな場合でも(初期化とともにvar
を使うことは最近のJavaでも可能になりましたが)変数の型を書く必要のあるJavaとは大きく違うところですね。こういう細かい違いがあるので、Goの作法に対する理解なしでJavaの感覚のままコードを書くのは大変なことになるかもしれません。
大文字には機能がある
会社ごとにルールは違うかと思いますが、今までの自分が経験では、言語がJavaであれJavaScriptであれ以下のようなルールで書く場合が多かったです。
- クラス、インタフェース名はPascalCase
- フィールド、メソッド、変数、引数はcamelCase
たまにPythonでコードを書く場合はsnake_caseだったり、URLはkebab-caseだったりもしたのですが、プライベートでも多くの場合このルールに従ってコードを書いています。そしてこれはあくまで人間が定めたルールなので、守らなくても
しかし、GoではPascalCaseかcamelCaseかによって意味が変わる部分があります。正確には頭が大文字か小文字かによる違いですね。public
とprivate
の代わりになってくるのが、この仕様です。簡単に説明すると、他のパッケージからも参照できる
のは大文字から始まるフィールドや関数であり、そうでない場合は小文字から始まるものということです。
例えば以下をみてください。A Tour of Goで提示しているコードです。ここではmath
パッケージをインポートして、あらかじめ定義されてあるπ
を標準出力している例です。
package main
import (
"fmt"
"math"
)
func main() {
fmt.Println(math.pi)
}
上記のコードは、なんの問題もないかのように見えますが、実行すると以下のようなエラーメッセージが出力されます。
./prog.go:9:14: cannot refer to unexported name math.pi
これはつまり、外部から参照できないということですね。なので、正しいコードに変えるとmain
関数を以下のように直す必要があります。
func main() {
fmt.Println(math.Pi)
}
先端が大文字であり、外部から参照できるように定義された名前のことをExported Namesというらしいです。Goにはクラスがないので、パッケージをインポートして、そのパッケージ内に存在する.go
ファイルの中にExport Names
で定義された項目のみを参照することになります。なのでクラスを作って、そのクラスのインスタンスを生成し、小文字から始まるフィールドやメソッドを呼ぶというJavaの作法とは感覚が大きく違いますね。
ポインターの存在
プログラマーだと誰もが知っていることだと思いますが、ポインターがあるかどうかの問題は、コードを各感覚にかなりの影響を与えるようなものです。JavaやKotlinなど、ポインターがない言語ではクラスやメソッド間にただ、GoにはGCがあるので、CやC++のようなメモリー問題はないかとも思いますが、とにかく直接使ってみないとわからないところですね。
もちろん、Javaでもpublic static
で宣言したり、SpringだとAutowired
アノテーションをつけることでどこでもアクセスできるオブジェクトを作ることは可能です。Kotlinだとcompanion object
という、クラスに似たようなものをまず定義する必要がありますが、呼び出し元としてはJavaとあまり変わらないコードになりますね。
しかし、こう言ったstatic
なものは、JavaやKotlinだと定数
として使われるのが一般的です。Autowired
でもSingletone
と使い方はあまり変わらず、固定値を格納するか、常に同じ動作(冪等
に違い)をすることを期待するのが一般的ですね。それに比べ、ポインターはやはりその値を直接書き換えたりすることを期待する場合もあるので、やはり違うものです。
まだ私はポインターを本格的に扱う言語に触れてみたことがなく、Goでもポインターを活用するようなコードは書いたことがあまりないので、ここで言えるのは上記で述べた内容だけですが、私と同じくポインターのない言語の経験しかない方にはやはり慣れるまで時間がかかるのではないかと思います。試行錯誤もかなりありそうですね。
例外処理が独特
Goで書かれてあるコードをみて、何が一番目立つかというと、例外処理の部分ではないかと思います。私が経験したことのある言語(Java、Python、JavaScript、TypeScript、Kotlin)では、例外処理のためにtry-catch
ブロックという仕様がありました。言語ごとに少しづつ違うところはありましたが、基本的に例外が発生しうる場所をそのブロックで囲んでから処理する、という発想自体は変わりがありません。例えば以下のようなコードで例外を処理することが一般的でしょう。
public static void main(String[] args) {
int result = 0;
try {
result = divide(1, 0);
} catch (Exception e) {
if (e instanceof ArithmeticException) {
System.out.println("0で分けません");
return;
}
}
System.out.println(result);
}
private static int divide(int numerator, int denominator) {
return numerator / denominator;
}
しかし、Goではそのような機能はありません。代わりに、どんな関数でも戻り値として期待する値
と発生したエラー
を定義し、呼び出し元では関数の実行結果としてエラーが発生したを確認して、エラーが発生していたら(エラーがnil
ではない場合)にそのエラーの対応をする、ということが一般的な作法のようです。言葉で説明すると難しいので、実際のコードをみていきましょう。上記のコードをGoの作法に合わせて書き直すと、以下のようになります。
func main() {
result, err := divide(1, 0)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(result)
}
func divide(numerator int32, denominator int32) (int32, error) {
if (denominator != 0) {
return numerator / denominator, nil
} else {
return 0, errors.New("0で分けません")
}
}
このように、関数ではエラーが発生した場合にそれをそのまま返し(上記のコードでは、あえてエラーを作っていますが)、呼び出し元ではエラーがあったかどうかを確認して分岐します。こうすることで「エラーが発生した場所が明確になる」メリットがあるらしいです。確かに、try-catch
ブロックが囲んでいるコードが多ければ多いほど、例外が発生し得るコードがどれなのかわからなくなる場合もありますね。例外を処理するための機能が、例外を起こさないコードと混り、わけがわからなくなります。そういう観点からすると、Goのアプローチはエラーとロジックを分離できるというメリットがあると言えるでしょう。
ただ、Goの作法では関数を呼び出す度に後続でエラーチェックが入るので、毎回同じ様なコードを書く場合があるのは少し違和感があります。例えば以下のようなコードを見かけるのですが、皆さんはどう思われるのでしょうか。もっとスマートな方法があって、自分が知らないだけなのかもしれませんが…
// いくつかの関数を呼び出して処理をする関数
func doSomething() (string, error) {
// 関数1を呼び出す
result1, err1 := someFunction1()
// 関数1でエラーが発生した場合はエラーを返却する
if err1 != nil {
return "", err
}
// 関数1でエラーが発生していない場合は関数2を呼び出す
result2, err2 := someFunction2(result1)
// 関数2でエラーが発生した場合はエラーを返却する
if err2 != nil {
return "", err
}
// 関数2でエラーが発生していない場合は関数2を呼び出す
result3, err3 := someFunction3(result2)
// 関数3でエラーが発生した場合はエラーを返却する
if err3 != nil {
return "", err
}
// 関数3でエラーが発生していない場合は関数2を呼び出す
result4, err4 := someFunction4(result3)
// 関数4でエラーが発生した場合はエラーを返却する
if err4 != nil {
return "", err
}
// ...続く
}
コンパイラーが厳しい
コンパイルエラーが発生した場合はIDEで知らせてくれるので気にすることはあまりないかと思いがちですが、意外と気になるのはコンパイラーの厳しさです。個人的にはjShell
の様なインタラクティブツールを使ってよくコードの検証をするのですが、GoにはそれがないのでVimで書いたコードをターミナルで動かしてみたり、The Go Playgroundを使ってみています。そしてこういう方法ではIDEの様なサポートをあまり期待できませんので、コンバイルエラーになることが多かったです。
ただ、コンパイルエラーといっても色々な原因がありますが、Goは特に他の言語と比べてもかなり厳しいのではないかと思います。例えば以下の様なコードがあるとします。
package main
import (
"fmt"
)
func main() {
}
ターミナルやThe Go Playgroundでこれを実行すると、以下の様なエラーメッセージが出ます。
./prog.go:4:2: imported and not used: "fmt"
インポートしたパッケージが使われていないというエラーですね。さらに、以下の様なコードを実行したとします。
package main
import (
"fmt"
)
func main() {
var result string
fmt.Println("this is test program")
}
上記のコードを実行した結果は、以下の通りです。
./prog.go:8:9: result declared but not used
今回はresult
という変数が使われてないというエラーです。この様に、Goでは使われてないインポートや変数などがあればエラーとなるので、他の言語と比べ厳しい方と言えますね。なので、プラグインなしのVimを使って修正するなどの場合には十分に気をつける必要がありますね。IDEでも少しはめんどくさいかもしれません。(リンティングと同時に使われてないパッケージや変数を削除してくれるとかの設定をすれば良いかもですが)
最後に
他にも細かい違いはまだまだ山ほどありますが、今の時点で言えるものはこのくらいです。ここで述べた、Javaプログラマーの観点からみたGoの特徴というものは、実は「慣れれば問題ない」だけなのかもしれません。しかし、「慣れる」ということは、「すでに慣れている」ものがあったらまたなかなか難しいものにもなりますね。
例えば、人間の言語でいうと、同じ系列のドイツ語の話者が英語を学ぶのは簡単と言われていますが、それは二つの言語がよく似ているからですね。逆に英語が母国語である人には中国語・日本語のような言語がもっとも難しい言語らしいのですが、これは語彙、文字、文章構造の全てが違うのが原因だそうです。プログラミング言語も本質は人間の言語を模倣しているものなので、新しい言語を学ぶ際には、それが自分の母国語と言える言語と似ていれば似ているほど学びやすく、そうでない場合は難しく感じるものではないかと思います。そういう観点からすると、JavaからGoへの移行は、簡単そうで難しそうな側面があるかなという気がします。
もちろん、世の中には元Javaプログラマーでも、Goの方が簡単だった!という方もたくさんいらっしゃるかと思います。ただ自分がついていけないだけなのかも…ですが。