Go vs .NET 技術比較
.NET (C#) の経験がある開発者向けに、Goの設計思想、ランタイム、ビルドモデル、エコシステムを .NET と対比しながら整理します。このカテゴリ全体の入口となるドキュメントです。
1. Goの設計思想
C# は言語機能を継続的に追加して表現力を高めてきた言語です。一方Goは、言語仕様を意図的に小さく保つことを重視しています。この違いは偶然ではなく、両言語が解こうとした問題の違いから生まれています。
1.1 誕生の背景
Goは2007年に Google の Robert Griesemer、Rob Pike、Ken Thompson が設計を始め、2009年に公開、2012年に Go 1.0 がリリースされました。当時の Google が抱えていた課題が、そのまま設計の出発点です。
| 当時の課題 | Goでの対応 |
|---|---|
| C++ などの巨大コードベースでビルドが数十分かかる | 依存関係を単純化し、未使用 import をエラーにして高速コンパイルを実現 |
| マルチコアCPUの普及に対し、スレッドとロックによる並行処理は難しい | goroutine と channel を言語機能として組み込む |
| 数千人規模のエンジニアが同じコードを読み書きする | 機能を絞り、gofmt で書式を統一して読みやすさを優先 |
| 言語機能が増えすぎて、チームごとに書き方が分かれる | 「やり方は基本的に1つ」になるよう言語を設計 |
| 動的言語の生産性と、静的型付けコンパイル言語の性能・安全性を両立したい | 静的型付け + ガベージコレクション + 型推論 + ネイティブコンパイル |
つまりGoは、個人の表現力の最大化ではなく、大規模なソフトウェア開発をチームで長期間続けること (software engineering) を最適化した言語です。Rob Pike の講演 "Go at Google: Language Design in the Service of Software Engineering" がこの考え方をよく表しています。
1.2 中核となる設計原則
| 方針 | 内容 |
|---|---|
| シンプルさ | キーワードは25個。継承・例外・演算子オーバーロード・属性(Attribute)ベースの魔法はない |
| 明示性 | エラーは戻り値で返す。暗黙の型変換を持たず、「裏で動く仕組み」を極力避ける |
| 組み合わせ | 継承ではなく、struct の埋め込みと小さなインターフェースで構成する |
| 並行処理の組み込み | CSP (Communicating Sequential Processes) に基づく goroutine と channel を言語の一部にする |
| 標準化 | gofmt によるフォーマット統一。スタイルの議論をツールで終わらせる |
| 高速なビルド | 大規模コードベースでも数秒〜数十秒でビルドが完了する |
| 互換性 | Go 1 の互換性保証により、Go 1.x で書いたコードは以降のバージョンでも動き続ける |
結果として「誰が書いても似たコードになり、他人のコードを読みやすい」ことが、Goの最大の強みの一つです。
1.3 原則の詳細
シンプルさ: 機能を足さないという選択
- 言語仕様は短く、1人の開発者が全体を読んで把握できる分量に収まっています。
- ループは
forのみ (while/do-while/foreachはすべてforの形違い)。三項演算子、メソッドのオーバーロード、デフォルト引数、enumキーワード、try/catchもありません。 - 新機能の追加には非常に慎重です。たとえばジェネリクスは、公開から約10年の議論を経て Go 1.18 (2022年) で導入されました。「便利そうだから足す」のではなく、「既存の機能の組み合わせでは本当に解決できない問題か」が問われます。
- 一方で、コード量は増えがちです。Goは「少し冗長でも、誰が読んでも分かる」ことを選びます。
Go Proverbs (Rob Pike) には、この姿勢を表す次の言葉があります。
- Clear is better than clever. (賢いコードより明快なコード)
- A little copying is better than a little dependency. (小さな依存を増やすより、少しコピーするほうがよい)
明示性: 隠れた制御フローを作らない
Goでは、コードを上から読めば何が起きるかが分かることを重視します。
var i int32 = 10
var j int64 = int64(i) // 暗黙の拡張変換はなく、明示的な変換が必要
f, err := os.Open("data.txt") // 失敗しうる処理は error を返す
if err != nil {
return fmt.Errorf("open data: %w", err) // 呼び出し側が必ず向き合う
}
defer f.Close() // 後始末も関数内に明示する
- エラーは値: 例外は「どこから飛んでくるか分からない非局所的なジャンプ」です。Goは
errorを通常の戻り値として扱い、if err != nilで都度処理させます (Errors are values.)。panicは本当に回復不能な状況 (プログラムのバグなど) に限定されます。 - 暗黙の型変換なし:
int32とint64の演算にも明示的なキャストが必要です。 - 未使用の変数・import はコンパイルエラー: コードベースにノイズが蓄積するのを防ぎ、ビルド時間も短く保ちます。
- 属性やリフレクション主導の魔法を避ける: ルーティング、DI、バリデーションなどを、アノテーションではなく通常のコードで書きます。
組み合わせ: 継承ではなく合成
- クラスと継承階層を持たず、データは
struct、振る舞いはメソッドで表現します。 - 再利用は struct の埋め込み (embedding) で行います。これは継承ではなく、フィールドとメソッドを委譲する仕組みです。
- インターフェースは構造的部分型 (structural typing) です。メソッドの集合が一致すれば、宣言なしに満たします。そのため、抽象化は「実装する側」ではなく「使う側」が必要になった時点で後付けできます。
- インターフェースは小さく保つのが慣習です (The bigger the interface, the weaker the abstraction.)。標準ライブラリの
io.Reader/io.Writerは、メソッドが1つだけであることで、ほぼあらゆる場所で再利用されています。 - 「インターフェースを受け取り、具象型 (struct) を返す」(Accept interfaces, return structs) が基本指針です。
// メソッド1つだけの小さなインターフェース。宣言なしに *os.File も *bytes.Buffer も満たす
type Reader interface {
Read(p []byte) (n int, err error)
}
// 継承ではなく埋め込みで機能を再利用する
type Logger struct{ prefix string }
func (l Logger) Log(msg string) { fmt.Println(l.prefix + msg) }
type Server struct {
Logger // Server は Log メソッドを使える (is-a ではなく has-a の委譲)
addr string
}
なぜ継承を持たないのか
Goには class も extends も virtual / override も abstract もなく、型の階層 (type hierarchy) を作る仕組みそのものがありません。これは機能の欠落ではなく、意図的な設計判断です。Go FAQ でも、オブジェクト指向の主要言語は型同士の関係の議論に時間を使いすぎており、その関係の多くは自動的に導けるはずだ、という趣旨が述べられています。
継承には次のような問題があると考えられています。
| 継承の問題 | 内容 |
|---|---|
| 脆い基底クラス問題 | 基底クラスの内部実装を変えると、派生クラスが壊れる。基底と派生が実装レベルで密結合になる |
| 階層の事前設計 | 最初に「is-a」の分類を決める必要があり、後から要件が変わると階層全体の見直しになる |
| 制御フローの追いにくさ | 仮想メソッドとオーバーライドにより、呼び出し先が実行時の型でしか決まらず、コードを読むだけでは追えない |
| 深い階層と巨大な基底クラス | 機能が基底クラスに集まり、「神クラス」や抽象クラスの連鎖が生まれやすい |
| 多重継承の曖昧さ | C++ ではダイヤモンド継承。C# は単一継承としつつ、interface の既定実装で似た問題が再び出る |
Goはこれらを避けるため、「振る舞い (interface) とデータ・実装の再利用 (embedding / 関数) を分離する」方針を取っています。
- 振る舞いの多態性は interface が担う: 暗黙的に満たされるため、型同士に継承関係を宣言する必要がありません。
- 実装の再利用は合成が担う: 埋め込みは「フィールドとメソッドの委譲」であり、サブタイプ関係を作りません。
ServerがLoggerを埋め込んでも、ServerをLoggerとして渡すことはできません。 - 仮想ディスパッチ (オーバーライド) が働かない: 埋め込まれた型のメソッドは、常に埋め込まれた側のレシーバで動きます。外側の型がメソッドを「上書き」しても、内側のメソッドからはそれが見えません。
この最後の点は C# 開発者が最も戸惑う部分なので、比較します。
// C#: 継承。Describe 内の Speak() は実行時の型 (Dog) のオーバーライドが呼ばれる
public class Animal
{
public virtual string Speak() => "...";
public string Describe() => $"I am {Speak()}";
}
public class Dog : Animal
{
public override string Speak() => "Woof";
}
// new Dog().Describe() == "I am Woof"
// Go: 埋め込み。Describe 内の a.Speak() は常に Animal.Speak
type Animal struct{}
func (a Animal) Speak() string { return "..." }
func (a Animal) Describe() string { return "I am " + a.Speak() }
type Dog struct{ Animal }
func (d Dog) Speak() string { return "Woof" } // Animal.Speak を隠すだけで、置き換えるわけではない
// Dog{}.Describe() == "I am ..." (Dog.Speak は呼ばれない)
// Dog{}.Speak() == "Woof" (Dog に直接呼んだ場合のみ)
Go では「基底の処理から派生の挙動を呼ぶ」ことはできません。同じことをしたい場合は、差し替えたい振る舞いを interface や関数として明示的に渡します。
type Speaker interface{ Speak() string }
// 差し替えたい部分を interface として受け取る (継承の Template Method の代わり)
func Describe(s Speaker) string { return "I am " + s.Speak() }
type Dog struct{}
func (Dog) Speak() string { return "Woof" }
// Describe(Dog{}) == "I am Woof"
継承で書いていたパターンは、おおむね次のように置き換えます。
| C# (継承ベース) | Go |
|---|---|
| 抽象基底クラス + 派生クラス | 小さな interface + それを満たす複数の struct |
| 共通処理の再利用 | 共通処理を struct にして埋め込む、または普通の関数にする |
| Template Method パターン | 変動部分を interface や関数引数で受け取る |
virtual / override による差し替え | interface の実装を差し替える (コンストラクタで注入) |
protected メンバー | なし。公開範囲はパッケージ単位 (大文字/小文字) |
| 基底型で受け取って多態的に扱う | interface 型で受け取る |
is / as による型判定 | 型アサーション / 型スイッチ (必要最小限に使う) |
この方針の代償も明確です。委譲メソッドを手で書く定型コードが増える、フレームワークが提供する基底クラス (ControllerBase など) を継承して拡張する流儀が使えない、といった点です。一方で、型同士の結合が弱くなり、テスト時の差し替えや後からの抽象化が容易になる利点があります。
並行処理: 言語に組み込まれた第一級の概念
- 並行処理を「ライブラリ」ではなく「言語機能」として提供します。
go f()で goroutine を起動し、channel で値を受け渡します。 - goroutine はOSスレッドではなく、Goランタイムが管理する軽量な実行単位です (初期スタックは数KB程度)。数万〜数十万個を起動しても現実的に動きます。
- 指針は Don't communicate by sharing memory; share memory by communicating. です。共有メモリをロックで守るより、channel でデータの所有権を受け渡す設計を第一選択にします。
- 関数に「async かどうか」という色を付けません。ブロッキングI/Oは同期的に書き、ランタイムが内部で効率的にスケジューリングします。
ゼロ値と「退屈な」設計
- すべての型にゼロ値があり (
0,"",false,nil)、Make the zero value useful. (ゼロ値がそのまま使える型を作る) が設計の指針になっています。sync.Mutexやbytes.Bufferは初期化なしで使えます。 - 初期化されていない状態を表現するための
null周りの仕組みや、複雑なコンストラクタ規約を減らせます。 - 「退屈 (boring) であること」を美徳とします。新しい書き方を覚えるより、どこでも同じ書き方で読めることに価値を置きます。
標準化とツールチェーン
gofmtはフォーマットを一意に決めます。Gofmt's style is no one's favorite, yet gofmt is everyone's favorite. という言葉通り、好みではなく統一を取ります。go build/go test/go vet/go doc/go modが標準ツールチェーンとして最初から揃っており、ビルド定義ファイルや外部のテストフレームワークを選ぶ必要がほぼありません。- Go 1 互換性保証: Go 1 で書かれたプログラムは、以降の Go 1.x でもコンパイル・動作し続けることが約束されています。言語を頻繁に変えないことが、長期運用コストの低さにつながります。
1.4 .NET (C#) の設計思想との比較
.NET / C# も互換性や長期運用を重視しますが、重心が異なります。C# は 「表現力」と「統合された開発体験」 を重視し、時代の要請に合わせて LINQ、async / await、パターンマッチング、レコード、nullable 参照型、ソースジェネレーターなど、機能を積極的に取り込んできました。Visual Studio / Rider といった強力なIDE、巨大な公式フレームワーク群と一体で、フルスタックな生産性を提供する思想です。
| 観点 | Go | .NET (C#) |
|---|---|---|
| 最適化の対象 | チームでの大規模・長期開発の読みやすさと保守性 | 表現力と、IDE・フレームワークを含む開発体験の統合 |
| 言語機能の進化 | 非常に保守的。足さないことを重視 | 積極的。毎年新バージョンで機能を追加 |
| 同じことをする方法 | 基本的に1つ (やり方を絞る) | 複数ある (foreach / LINQ / for、クラス / レコード / struct など) |
| 抽象化の手段 | struct、小さな interface、関数、埋め込み | クラス継承、interface、abstract、ジェネリクス、デリゲート、属性など |
| 継承 | なし。型の階層を作らず、埋め込み (委譲) と interface で構成する | 単一のクラス継承 + 複数の interface。virtual / override / abstract |
| コードの再利用 | 合成 (埋め込み、関数) を第一選択とする | 継承が一般的な選択肢の一つ (合成も推奨される) |
| 型の関係付け | 暗黙のインターフェース充足 (構造的) | 明示的な宣言 (: IFoo) (名前的) |
| エラー処理 | error 戻り値。制御フローが明示的 | 例外。正常系が簡潔に書ける |
| 並行処理 | goroutine / channel。同期的な見た目で書く | Task / async / await。非同期を型とキーワードで表現 |
| メタプログラミング | ほぼ無し (リフレクションは最小限、go generate で生成) | 属性、リフレクション、Source Generator、式ツリー |
| 抽象度の高いデータ表現 | 直和型・enum・パターンマッチングは未サポート。定数と interface で代用 | レコード、判別共用体的な書き方、switch 式、パターンマッチング |
| フレームワーク | 標準ライブラリ中心。小さなライブラリを組み合わせる | ASP.NET Core、EF Core など公式の統合フレームワーク |
| 配布 | 単一のネイティブバイナリ | ランタイム依存 (自己完結型 / Native AOT も可) |
| 書式・規約 | gofmt が言語標準として強制 | .editorconfig やアナライザーで設定・拡張 |
| ガバナンス | Googleが主導するオープンソース。変更は提案 (proposal) プロセスで慎重に判断 | Microsoft と .NET Foundation。機能追加が活発 |
| 互換性 | Go 1 互換性保証 | 高い後方互換性。ただしメジャー版で破壊的変更が入ることはある |
同じ問題への取り組み方の違い
たとえば「偶数の2乗を集める」という処理は、設計思想の違いがそのまま表れます。
// C#: 宣言的で簡潔。表現の選択肢が多い
var result = numbers.Where(n => n % 2 == 0).Select(n => n * n).ToList();
// Go: 手続き的で冗長だが、何が起きるかが一目で分かる
result := make([]int, 0, len(numbers))
for _, n := range numbers {
if n%2 == 0 {
result = append(result, n*n)
}
}
C# は「宣言的に、短く書ける」ことに価値を置き、Goは「少し長くても、制御フローとメモリの動きが明示される」ことに価値を置きます。
1.5 トレードオフ: 何を得て、何を手放したか
| Goが得たもの | 手放したもの |
|---|---|
| 学習コストの低さ。チームに新しく加わった人がすぐ読める | 高度な抽象化や DSL を作る表現力 |
| コードの均質性。レビューが「書き方」でなく「設計」に集中できる | 個人の好みや流儀を反映する自由 |
| 型同士の結合の弱さ。継承階層に起因する脆さや複雑さがない | 継承によるコード再利用、オーバーライドによる拡張点、基底クラス型のフレームワーク流儀 |
| 高速なビルドと単一バイナリによる配布のしやすさ | リッチなランタイム機能 (動的ロード、豊富なリフレクション、実行時のコード生成など) |
| 制御フローの明示 | エラー処理の簡潔さ (if err != nil の繰り返し) |
| 長期的な互換性と安定 | 新しい言語機能の素早い導入 |
| 並行処理の書きやすさ | 細かな非同期制御の型表現 (Task の合成など) |
「Goのほうが優れている」のではなく、「大規模チームで、ネットワークサービスやツールを長く保守する」という目的に最適化した結果がGoの設計です。.NET は逆に、表現力と統合された開発体験を最大化することで、業務アプリケーションやGUI、Windows 統合まで広い領域をカバーしています。
Goのコードを書くときは、C# の流儀を持ち込まず、「この言語が何を避けようとしているか」 を意識すると、設計判断の理由が見えやすくなります。
2. ランタイムとビルドモデルの違い
実行モデル
- .NET: ビルド成果物は IL (中間言語) で、実行時に CLR が JIT コンパイルします。実行環境にランタイムが必要です (自己完結型発行や Native AOT で回避も可能)。
- Go: ビルド成果物は OS/CPU ごとのネイティブバイナリです。GCやスケジューラを含む小さなランタイムがバイナリに組み込まれるため、ランタイムのインストールが不要です。
対応表
| .NET (C#) | Go |
|---|---|
| CLR + JIT / AOT | ネイティブバイナリ (AOT) + 軽量ランタイム |
| GC (世代別) | GC (並行・低レイテンシ重視、世代別ではない) |
| 豊富な言語機能 | 意図的にシンプルな言語仕様 |
dotnet publish -r linux-x64 | GOOS=linux GOARCH=amd64 go build (クロスコンパイルが標準機能) |
NuGet + .csproj | Go Modules + go.mod |
| ASP.NET Core (フレームワーク) | net/http (標準ライブラリで足りることが多い) |
Task / async / await | goroutine / channel |
例外 (try / catch) | error 戻り値 |
| クラス継承 | struct 埋め込み + interface |
| ジェネリクス (フル機能) | ジェネリクス (Go 1.18〜、機能は限定的) |
| LINQ | for ループ + slices / maps パッケージ |
null / Nullable 参照型 | nil (ポインタ・スライス・マップ・interface など) |
GC とメモリ
- Goの GC は並行マーク&スイープで、一時停止時間の短さを優先しています。世代別GCではないため、スループットは .NET の Server GC に劣る場面があります。
- Go は値型 (struct) を中心に使い、コンパイラのエスケープ解析でスタック/ヒープのどちらに置くかを自動で決めます。C# の
struct/classのような使い分けは不要です。 - 起動が速く、メモリフットプリントも小さいため、コンテナや CLI、サーバーレスと相性が良いです。
3. 向いている領域・向いていない領域
| 領域 | Go | .NET |
|---|---|---|
| CLI ツール / DevOps ツール | 非常に向く (単一バイナリ) | 向く (AOT なら可) |
| マイクロサービス / API サーバー | 向く | 非常に向く |
| クラウドネイティブ基盤 (Kubernetes, Docker, Terraform など) | 事実上の標準 | 少ない |
| 大量の同時接続 / ネットワークサーバー | 非常に向く | 向く |
| エンタープライズ業務アプリ (複雑なドメイン、ORM 活用) | 可能だが道具は少なめ | 非常に向く |
| デスクトップ / モバイル UI | 弱い | WPF, MAUI, Blazor など充実 |
| Windows 統合 (AD, COM, Office) | 弱い | 非常に向く |
「Go が .NET より優れている」わけではなく、Go は小さく速く配布しやすいサービスやツール、.NET は表現力と統合された開発体験が必要なアプリケーションに強みがあります。
4. C# 開発者がつまずきやすいポイント
- 例外がない: 失敗する可能性のある関数は
errorを返し、呼び出し側が毎回if err != nilで処理します。冗長に見えますが、制御フローが目で追えるのが利点です (詳細はエラーハンドリング)。 - クラスがない:
classの代わりにstructとメソッドを使います。継承もありません (詳細は型と構造体、メソッドとインターフェース)。 - インターフェースは暗黙的に満たされる:
: IFooのような宣言は不要です。メソッドが揃っていれば自動的に実装したことになります。しかも、インターフェースは「使う側」のパッケージで定義するのが慣習です。 - 大文字・小文字がアクセス修飾子:
public/privateキーワードの代わりに、識別子の先頭が大文字なら公開 (exported)、小文字ならパッケージ内に限定されます。 - ゼロ値が有効: 変数は必ずゼロ値 (
0,"",false,nil) で初期化されます。コンストラクタがなくても使える型を設計するのがGo流です。 - async/await がない: 関数に色 (async か否か) はなく、同期的に書いて goroutine で並行実行します (詳細はgoroutineとチャネル)。
- DI コンテナ・属性・リフレクション主導の設計は一般的でない: コンストラクタ関数で依存を明示的に渡す形が主流です。
- フレームワークより標準ライブラリ:
net/http、encoding/json、database/sql、log/slog、testingなど標準で多くが揃います。 nilの落とし穴: nil スライスは安全に使えますが、nil マップへの書き込みや nil ポインタ参照は panic になります。interface が nil かどうかの判定にも注意が必要です。- 使われない変数・import はコンパイルエラー: 警告ではなくエラーです。最初は驚きますが、すぐ慣れます。
5. コード例
同じ処理 (ユーザーを取得して名前を返す) を C# と Go で比較します。
C#
public interface IUserRepository
{
Task<User?> FindAsync(int id, CancellationToken ct);
}
public class UserService(IUserRepository repo)
{
public async Task<string> GetNameAsync(int id, CancellationToken ct)
{
var user = await repo.FindAsync(id, ct)
?? throw new KeyNotFoundException($"user {id} not found");
return user.Name;
}
}
Go
package user
import (
"context"
"errors"
"fmt"
)
type User struct {
ID int
Name string
}
var ErrNotFound = errors.New("user not found")
// インターフェースは「使う側」で必要最小限だけ定義する
type Repository interface {
Find(ctx context.Context, id int) (*User, error)
}
type Service struct {
repo Repository
}
// コンストラクタ関数で依存を明示的に渡す (DI コンテナは不要)
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) GetName(ctx context.Context, id int) (string, error) {
u, err := s.repo.Find(ctx, id)
if err != nil {
return "", fmt.Errorf("get name (id=%d): %w", id, err)
}
if u == nil {
return "", ErrNotFound
}
return u.Name, nil
}
| 観点 | C# | Go |
|---|---|---|
| 非同期 | async Task<T> + await | 通常の関数 (呼び出し側が goroutine で並行化) |
| キャンセル | CancellationToken | context.Context (第1引数が慣例) |
| 失敗の表現 | 例外 | (T, error) の多値戻り値 |
| 依存性注入 | DI コンテナ | コンストラクタ関数 (NewXxx) |
| 実装宣言 | : IUserRepository が必要 | 不要 (メソッドが揃えば満たす) |
6. 学習ロードマップ
このカテゴリは以下の順序で読み進めることを想定しています。
- 手を動かして最初に慣れるならGo入門から。
- 最も考え方が変わるのはエラーハンドリングと並行処理です。ここを重点的に読むと C# との違いを掴みやすくなります。
7. まとめ
- Goは「小さな言語仕様」「明示性」「単一バイナリ」「並行処理の組み込み」を軸にした言語で、クラウドネイティブなサービスやツールに強い。
- .NET の経験は無駄にならない。ただし、例外・継承・async/await・DI コンテナといった慣れた道具は、Goでは別の形 (
error戻り値・埋め込みと interface・goroutine・コンストラクタ関数) に置き換わる。 - 最初は冗長に感じても、Goらしい書き方 (idiomatic Go) に寄せるほうが、標準ライブラリやエコシステムとの相性が良く、読みやすいコードになる。