はじめに
前回は、Java経験者としてGoを学び始めたときに驚いた文法のポイントをまとめました。
参照:Java経験者がGoを学んで文法のポイントをまとめてみた
今回はその中でも、特に実務でどう使うのか気になった点 を少し深掘りしてみます。
対象は以下の6つです。
- スライスと配列
- ポインタとレシーバ
- メソッドと関数
- インタフェース
- Error
- 空とnil、ゼロ値
1. スライスと配列
配列とスライスの違い
Goでは配列とスライスは別物です。
- 配列:要素数が型に含まれる固定長のデータ
- スライス:配列の一部を参照する仕組み
arr := [3]int{1, 2, 3}
s := arr[0:2]
arr は長さ3の配列です。
一方 s はスライスで、配列 arr の一部を参照しています。
Pythonのスライスと似た見た目ですが、Goのスライスは元の配列を参照しているという点が特徴的です。
実務では、DBから値を取得する際など、サイズ固定の配列よりもスライスを使う場面の方が多いようです。
capは何のためにあるのか
s := make([]int, 0, 10)
fmt.Println(len(s)) // 0
fmt.Println(cap(s)) // 10
-
len:現在の要素数 -
cap:追加可能な余裕
スライスに要素を追加する際は append を使用しますが、append を繰り返すと内部で再確保(メモリ再割り当て)が発生します。
そのため、あらかじめ必要なサイズが分かっている場合は cap を確保しておくことで、無駄な再確保を減らすことができます。
nilスライスと空スライス
var s1 []int // nil
s2 := []int{} // 空スライス
どちらも len は 0 ですが、nil かどうかが異なります。
見た目は似ていますが、JSONエンコード時に挙動が変わるため注意が必要です。
import (
"encoding/json"
"fmt"
)
var s1 []int // nil
s2 := []int{} // 空スライス
// スライスを JSON に変換する
b1, _ := json.Marshal(s1)
b2, _ := json.Marshal(s2)
fmt.Println(string(b1)) // null
fmt.Println(string(b2)) // []
APIのレスポンスで null を返すか [] を返すかが変わってくるため、実務では意識する必要があります。
2. ポインタとレシーバ
値レシーバ vs ポインタレシーバ
値レシーバはレシーバのコピーに対して処理を行うため、元の値は変更されません。
一方、ポインタレシーバは元の値そのものを参照するため、メソッド内の変更が呼び出し元にも反映されます。
type Point struct {
X, Y float64
}
func (p Point) ScaleByValue(f float64) {
p.X *= f
p.Y *= f
}
func (p *Point) ScaleByPointer(f float64) {
p.X *= f
p.Y *= f
}
p1 := Point{3, 4}
p2 := Point{3, 4}
p1.ScaleByValue(2)
p2.ScaleByPointer(2)
fmt.Println("値レシーバ:", p1) // {3 4}
fmt.Println("ポインタレシーバ:", p2) // {6 8}
実務では、以下のケースでポインタレシーバが使われることが多いです。
- 値を変更したいとき
- 大きな構造体のコピーを避けたいとき
{} と & の違い
p := Point{X: 3, Y: 4}
q := &p
-
{}:値を作る -
&:アドレスを取得する
役割が異なるため、混同しないように注意が必要です。
fmt.Printlnでポインタ表示が違う理由
i := 42
p := &i
fmt.Println(p)
// 2章で定義したPointを使用
t := &Point{1, 2}
fmt.Println(t)
実行結果:
0xc0000120a0
&{1 2}
どちらもポインタですが、fmt.Println の表示ルールが異なるため表示が変わります。
| 型 | 表示形式 |
|---|---|
*int など |
アドレス表示 |
*struct |
&{中身} 表示 |
構造体ポインタは、中身が分かりやすいように表示されます。
アドレスを明示的に表示したい場合は以下のように記述します。
fmt.Printf("%p\n", t)
自分はポインタを扱う言語は初めてだったので、なぜ表示形式が違うのか最初は混乱しました。
3. メソッドと関数
前回の記事でも触れましたが、今回はメソッド呼び出し時の自動変換の挙動に絞って深掘りします。
メソッドの特徴
まず、ポインタレシーバと値レシーバのメソッドをそれぞれ定義します。
type Shape struct {
X, Y int
}
// ポインタレシーバのメソッド:Xを2倍にする
func (s *Shape) Double() {
s.X *= 2
}
// 値レシーバのメソッド:Xを表示する
func (s Shape) PrintX() {
fmt.Println(s.X)
}
どちらも、値・ポインタのどちらからでも呼び出せます。
v := Shape{3, 4}
p := &v
// ポインタレシーバ → 値から呼び出せる
v.Double() // OK(Goが自動で &v に変換)
(&v).Double() // 上と同じ意味
// 値レシーバ → ポインタから呼び出せる
p.PrintX() // OK(Goが自動で *p に変換)
(*p).PrintX() // 上と同じ意味
このようにメソッド呼び出し時は、Goが「ポインタの参照外し(*p)」と「アドレス取得(&v)」を自動で行ってくれます。
関数は自動変換されない
同じ処理を関数として定義した場合、挙動が変わります。
// 関数として定義:ポインタを受け取る
func DoubleX(s *Shape) {
s.X *= 2
}
v := Shape{3, 4}
DoubleX(v) // エラー(値は渡せない)
DoubleX(&v) // OK(明示的にアドレスを渡す)
関数では自動変換されないため、明示的にアドレスを渡す必要があります。
4. インタフェース
Goでは、必要なメソッドを持っていれば自動的にインタフェースを満たします。
implements を書く必要はありません。
type Greeter interface {
Greet() string
}
type User struct {
name string
}
func (u User) Greet() string {
return "こんにちは、" + u.name + "さん"
}
実務イメージ:実装の差し替え
インタフェースを使うことで、“何をするか”と“どうやるか”を分離できます。
type Mailer interface {
Send(to, body string) error
}
// 本番実装
type SMTPMailer struct{}
func (m SMTPMailer) Send(to, body string) error {
// 実際にメール送信する処理
fmt.Printf("メール送信: %s\n", to)
return nil
}
// モック実装(テスト用)
type MockMailer struct{}
func (m MockMailer) Send(to, body string) error {
fmt.Printf("[Mock] メール送信: %s\n", to)
return nil
}
// インタフェースを受け取る関数
func Notify(m Mailer, to string) {
m.Send(to, "お知らせです")
}
Notify(SMTPMailer{}, "user@example.com") // 本番
Notify(MockMailer{}, "user@example.com") // テスト
呼び出し側は具体的な実装(SMTPやMock)を意識せずに利用できるため、テストや将来の変更(実装の差し替え)が容易になります。
【ハマりポイント】メソッドは呼べるのに代入できない
3章では、メソッド呼び出し時はGoが自動変換してくれると説明しました。
しかし、インタフェースへの代入では自動変換が行われません。
まず、インタフェースと構造体を定義します。
type Shaper interface {
Area() int
}
type Shape struct {
X, Y int
}
// ポインタレシーバでメソッドを定義
func (s *Shape) Area() int {
return s.X * s.Y
}
メソッド呼び出しは3章と同様、値からでも呼び出せます。
v := Shape{3, 4}
v.Area() // OK(3章と同様、Goが自動で &v に変換してくれる)
しかし、インタフェースへの代入は厳密にチェックされます。
var a Shaper
a = &v // OK(*Shape は Shaper を満たす)
a = v // NG!(Shape は Shaper を満たさない)
Area() は *Shape に定義されているため、Shape(値)はインタフェースを満たさず、*Shape(ポインタ)のみ満たします。
| 操作 | 自動変換 | 結果 |
|---|---|---|
v.Area() |
あり | OK |
a = &v |
なし(不要) | OK |
a = v |
なし | NG |
メソッド呼び出しは優しいが、インタフェース代入は厳しい、と覚えておくと良いです。
5. Error
error は組み込みのインタフェースです。内部的には以下のように定義されています。
参照:error | builtin - Go Packages
type error interface {
Error() string
}
つまり Error() string メソッドを持つ型であれば、何でも error として扱えます。
4章で学んだインタフェースの仕組みがここでも活きています。
func divide(a, b int) (int, error) {
if b == 0 {
return 0, fmt.Errorf("0で割ることができません")
}
return a / b, nil
}
res, err := divide(10, 0)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(res)
エラーは必ず呼び出し側でチェックします。
errors.Is(補足)
エラーに文脈情報を付けて返したい場合、fmt.Errorf の %w を使ってエラーを「ラップ(包む)」できます。
var ErrNotFound = errors.New("not found")
func findUser(id int) error {
// %w でErrNotFoundをラップして返す
// → 「findUser:」という文脈情報を付けつつ、元のエラーも保持できる
return fmt.Errorf("findUser: %w", ErrNotFound)
}
ただし、ラップすると == での比較では一致しなくなります。
err := findUser(1)
fmt.Println(err == ErrNotFound) // false(ラップされているので一致しない)
fmt.Println(errors.Is(err, ErrNotFound)) // true(中身まで確認してくれる)
errors.Is を使うことで、ラップされていても元のエラーかどうかを正しく判定できます。
6. 空とnil、ゼロ値
個人的な話ですが、nilや空文字("")、NULLといった「空っぽを表す値」の扱いは、どの言語でもDBでも苦手意識があり、混乱が起きやすいポイントだと感じています
Goではこれらが明確に区別されているため、最初に整理しておくと後々楽になります。
| 種類 | 意味 |
|---|---|
ゼロ値(0, "", false) |
変数が宣言されたが値が未設定 |
| nil | ポインタ・スライス・mapなどが「存在しない」状態 |
空スライス([]int{}) |
存在はするが要素が0件 |
空文字("") |
存在はするが文字が0文字 |
特にDBと組み合わせるときは、DBの NULL をGoの nil にマッピングするか、ゼロ値にするかを意識する必要があります。
たとえばdatabase/sql というGo標準ライブラリのパッケージに含まれるsql.NullString のような型を使うと、DB側の NULL と空文字を区別して扱えます。
参照:database/sql - NullString | Go Packages
import "database/sql"
var name sql.NullString
if name.Valid {
fmt.Println(name.String) // DBの値が入っている
} else {
fmt.Println("NULL") // DBはNULL
}
ゼロ値
var i int // 0
var s string // ""
var b bool // false
スライス・mapの挙動の違い(重要)
ここがハマりやすいポイントですが、nilのときに追加操作ができるかどうかがスライスとmapで異なります。
var s []int // nilスライス
var m map[string]int // nil map
mapはpanicになる
nilのmapに対して直接キーを指定して代入しようとすると、panic(プログラムが続行不可能な異常状態に陥ったときに発生する実行時エラー)になります。
そのため、make で初期化するか、事前にnilチェックが必要です。
// m["a"] = 1 // これはpanic!
if m == nil {
m = make(map[string]int)
}
m["a"] = 1 // OK
スライスはappendできる
一方で、スライスはnilの状態からでも append が可能です。
Goが内部で自動的にメモリを割り当ててくれるため、事前に make しなくても安全に使い始めることができます。
// nilスライスにそのまま追加できる
s = append(s, 1) // OK(panicにならない)
実務では、mapは初期化を意識し、スライスは必要になるまで var 宣言のみで済ませる、といった使い分けがあるようです。
まとめ
Goはシンプルな文法に見えますが、
- スライス
- レシーバ
- インタフェース
などは、実務視点で理解するとより分かりやすくなります。
今後も実務で得た知識を整理していきたいと思います。
参考文献
- Go Packages
- A Tour of Go
- たった1日で基本が身に付く! Go言語 超入門