はじめに
Javaをメインに使ってきたエンジニアが、Goを業務で使い始めて気になった点を整理します。
言語仕様の違いから来る戸惑いと、慣れてみると納得できた部分の両方を書きます。
自分の言語経験
大学時代にPythonでデータ分析からプログラミングを始め、業務では次の言語・フレームワークを経験してきました。
新卒入社時:
- 言語:Java、VBA、JavaScript、TypeScript、Python
- フレームワーク:Spring、Angular、Vue、Express
転職後:
- 言語:Java、VB.NET、JavaScript、TypeScript、Go、Python
- フレームワーク:Spring、Java EE、React、Gin、FastAPI
Javaとの比較で感じたGoのシンプルさ
Javaは長い歴史の中で機能が積み重なってきた言語です。良くも悪くも時代の荒波を乗り越えてきたため、同じことを書くのに複数の書き方が存在します。
ループを例にすると、次のとおりです。
// 伝統的なforループ
for (int i = 0; i < list.size(); i++) { ... }
// 拡張for文(Java 5以降)
for (String s : list) { ... }
// Stream API(Java 8以降)
list.stream().filter(...).map(...).collect(...);
どれを使うかはチームや文脈によって変わり、コードベース内で混在することもあります。慣れれば使い分けられますが、初学者には「どれを選べばいいか」が分かりにくいです。
Goのforは文法としては一種類だけですが、用途ごとに書き方を切り替えます。
// インデックスあり
for i, v := range list { ... }
// 値だけ
for _, v := range list { ... }
// 条件だけ(whileに相当)
for i < n { ... }
書き方の選択肢がないぶん、コードを読むときに迷いません。三項演算子やStreamがないのも同じ方向性で、「一通りの書き方に収束させる」という設計思想を感じます。
これはループだけの話ではありません。モダンJavaではRecord型やswitched expressionなど、「こちらの書き方の方が良い」とされる構文が後から追加され続けています。Java 8以前と以降では見ている景色がかなり違い、チームメンバーによって知識のベースラインがずれることもあります。
// Java 16以降のRecord型
record Point(int x, int y) {}
// Java 14以降のswitch式
String result = switch (day) {
case MONDAY -> "月曜";
case FRIDAY -> "金曜";
default -> "その他";
};
Goにも generics(1.18)や slices パッケージ(1.21)など新機能は追加されています。ただし、大きく書き方が置き換わるような変化は少なく、古いコードと新しいコードで極端に見た目が変わりにくいです。これはチームで長く運用するコードベースにとって、地味に効いてくる利点だと感じます。
この違いは、単に言語の新しさだけではなく、設計思想の違いも影響していると感じます。Javaは後方互換性を維持しながら機能追加を続けてきたため、複数の書き方が積み重なっています。一方でGoは後発の言語として、最初から書き方を増やしすぎないよう意図的に制約が設けられています。三項演算子を入れない、Streamのような標準チェーンAPIを持たない、エラーを例外にしない、といった選択はすべて「あえてそうしている」ものです。
Javaの表現力の豊かさは強みでもありますが、Goのシンプルさはチーム開発での読みやすさに直結します。
最初に戸惑ったGoの仕様
三項演算子がない
Goには三項演算子(condition ? a : b)がありません。
最初は不便に感じましたが、他言語でネストした三項演算子に何度か悩まされた経験があるので、今は省かれていて良かったと思っています。if で素直に書いた方が読みやすいです。
JavaのStreamのような標準のチェーン型APIがない
JavaのStream APIのような標準のチェーン型APIはありません。フィルタや変換はループで書くことになります。
処理が長くなるのは事実ですが、中間状態が変数として残るため、デバッグはしやすいです。慣れると「愚直に書かせてくれる言語」という印象になります。
Go 1.21以降は slices / maps パッケージで補助的な関数が増えてきているので、ソートや検索まわりは以前より書きやすくなっています。ただし、JavaのStreamのように変換・フィルタをチェーンする形ではないため、集合演算が必要な場面ではまだループで書くことになります。
日時フォーマットが文字列で定義されている
Goの日時フォーマットは定数名ではなく、基準日時のレイアウト文字列で表します。
t.Format("2006-01-02T15:04:05Z07:00")
Javaでは "yyyy-MM-dd" のように書きますが、Goでは 2006 や 01 という特定の数値がそれぞれ年・月を意味します。覚えるまでは独特に感じますが、慣れると定義済みレイアウトを使い回せるのでそこまで困りません。書式指定子の意味を毎回確認するより迷いが少ない場面もあります。
エラーが例外ではなく戻り値
Javaでは例外を throw しますが、Goではエラーを戻り値として返します。PythonのようにRaiseする仕組みもありません。
result, err := someFunc()
if err != nil {
return err
}
最初は冗長に見えます。ただ、Javaで例外をとりあえず throws Exception で上に投げ続ける設計に悩まされたことがある立場からすると、エラーを値として明示的に扱うこのスタイルは制御の流れが分かりやすいです。
インターフェースの暗黙実装
Goのインターフェースは implements を書かずに満たせます。メソッドシグネチャが一致していれば、その型は自動的にインターフェースを実装しているとみなされます。
type Reader interface {
Read(p []byte) (n int, err error)
}
最初は「本当に実装しているのか分からなくて怖い」と感じました。VSCodeのGo拡張でインターフェースの実装元に飛びにくい場面もあり、一長一短の印象です。ただし、既存の型に後からインターフェースを適用できる柔軟性は大きなメリットです。
匿名構造体でその場に型を定義できる
Goは匿名構造体を使うと、型名をつけずにその場で構造を定義できます。レスポンスを一時的に組み立てるだけのために専用のファイルを作らなくて良いので、小さな処理を書くときの摩擦が少ないです。
// 匿名構造体でその場に定義して使う
res := struct {
ID int
Name string
}{ID: 1, Name: "test"}
名前付きの型が必要なときは通常の type 定義を使い、使い捨ての構造には匿名構造体を使う、という使い分けになります。
ポインタはあるが、使い方はCほど複雑ではない
Goにはポインタがあります。最初は「Cみたいなことをやるのか」と身構えましたが、実際には値の共有や書き換えを明示したい場面で使う程度です。ポインタ演算はなく、メモリ管理もGCが担うので、Cのポインタとは別物と考えてよいです。
nilの扱いに注意が必要
Goのnilは複数の型を持ちます。インターフェース型の変数がnilかどうかを判定するときに、意図しない挙動になるケースがあります。
var err *MyError = nil
var e error = err
// e != nil は true になる
error 型はインターフェースなので、具体的なポインタ型をインターフェース変数に代入するとnilでなくなります。インターフェースは「型情報+値」を持つため、型が入っている時点でnilではなくなるためです。実務でもはまることがある箇所なので、インターフェースとnilを組み合わせるときは意識しておくと良いです。
Go周辺ライブラリ・開発体験で感じたこと
GORMでのトランザクションの書き方
SpringのようにアノテーションでトランザクションをAOPとして切れるわけではなく、明示的にブロックで書く必要があります。
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&user).Error; err != nil {
return err
}
return nil
})
Springの @Transactional に慣れていると最初はやや冗長に感じます。ただし何がトランザクションの範囲かコードを見れば分かるので、チーム開発では読みやすい面もあります。
開発環境まわり
ホットリロードはデフォルトでは使えないため、air などのツールを別途導入する必要があります。Goは標準機能を薄く保つ文化があるため、ホットリロードは外部ツールを組み合わせる前提になりがちです。フルスタック系フレームワークに慣れていると物足りなく感じました。
まとめ
他言語から来ると最初は制約が多く見えますが、Goの設計には「明示的に書かせる」という一貫した方針があります。
- 三項演算子を置かないのも、複雑な式を避けて明示的に書かせる思想の表れに感じます
- StreamのようなチェーンAPIを持たないのも、処理の流れを追いやすくする方向性だと感じます
- エラーを値として扱うのも、制御の流れを明示する思想と一致しています
- トランザクションを明示的に書くのも、スコープを明らかにする同じ方針からきている気がします
慣れてくると、この方針がコードレビューのしやすさに繋がっていると感じます。