imu-note
いむたろ
いむたろ
@imutaroh
新卒エンジニア / データ基盤 × AI

名前の付け方

変数名:型名はいらない、短いほどいいわけでもない

// 型名が入っている
var userIDList []string

// Go らしい
var userIDs []string
  • 数行で使い終わる変数は短く(ループの i、CSV の1行の line)
  • 宣言から離れた場所で使う変数や、パッケージ全体から見える変数は、意味が分かる名前にする
  • 略語は Id や Url ではなく、ID や URL と書く。だから userId ではなく userID になる。

「静的に決まる」とは

静的とは、プログラムを動かす前(コンパイルのとき)に、すべての変数の(宣言した)型がもう決まっていること。

ids := []string{"u1", "u2"} // この時点で ids は []string に決まる
ids = 42                    // コンパイルエラー。実行する前に止まる

JavaScript のような動的型付けの言語では、型は実行するまで決まらない。だから名前に型を書いておく意味がある。Go では、コンパイラもエディタも型を知っている。名前に List と書くのは、同じ情報を2回書くことになる。

パッケージ名:中身が分からない名前は逆にわかりにくい

util・common・api のような何でも入りそうな名前は避ける。 Goはパッケージ名をコード上で関数の前に使うので、重なる内容は避ける。

http.Server     // Go らしい
http.HTTPServer // http が2回出てくる

ingest というパッケージの中の型なら、IngestJob ではなく Job にする。呼び出す側からは ingest.Job と読める。

インターフェース名とレシーバー名

インターフェースは、メソッドが1つだけなら er を付ける(Read を持つ Reader、Write を持つ Writer)。メソッドが複数あるときは、目的が分かる名前にする(net.Conn、context.Context など)。

レシーバーは、メソッドの func (l *Loader) Load() の l の部分。

定数

定数にできるもの、できないもの

Go の const に置けるのは、コンパイルのときに値が確実に分かっているものだけ。言語のルールとしては、数値・文字列・真偽値の型(と、それを元にして作った型)しか定数にできない。だから、スライスやマップは置けない。長さが固定の配列([2]int)や構造体も置けない。 関数の引数も定数にできない。理由は、引数が実行するときに渡される値であること。関数を呼んだ結果(戻り値)も同じ理由でできない。

const maxRetry = 3                        // OK
const tables = []string{"orders"}         // コンパイルエラー
const ErrEmpty = errors.New("empty file") // コンパイルエラー(関数を呼んだ結果)

func f(n int) {
	const c = n // コンパイルエラー(引数は実行時に決まる)
}

型なし定数と型付き定数

const batch = 500 のように型を書かない定数と、const limit int64 = 500 のように型を書く定数がある。

int64 の変数にfloat64 の変数に
const batch = 500(型なし)入る入る
const limit int64 = 500(型付き)入る入らない(コンパイルエラー)

エラーは定数にできない、じゃあどうするか

errors.New(...) で作るエラーは関数の結果なので、const にできない。 普通は var でパッケージに用意しておく。

var ErrEmptyFile = errors.New("ingest: file is empty")
type ingestError string

func (e ingestError) Error() string { return string(e) }

const ErrEmptyFile ingestError = "ingest: file is empty"

var は書き換えようと思えば書き換えられる。const なら書き換えられない。その代わり、型を1つ作る手間がかかる。

iota

1.3 は iota で列挙型を作る話。今回は飛ばして、2章を読むのに要る最低限だけ押さえた。

type JobStatus int

const (
	Pending JobStatus = iota // 0
	Running                  // 1
	Succeeded                // 2
	Failed                   // 3
)

const ( ) の中で、定数を1つ宣言するごとに 0, 1, 2… と増える数が iota。Go には enum が無いので、型と const と iota を組み合わせて代わりにする。

Goのエラーを扱う

Go には try / catch のような例外の構文がない。エラーはただの値。error は組み込みのインターフェースで、Error() string を持つ型なら何でも error として扱える。エラーが無いことは nil で表す。

// 失敗しうる関数は、戻り値の最後を error にする
func parseRow(line string) (string, int, error) {
	cols := strings.Split(line, ",")
	if len(cols) != 2 {
		return "", 0, fmt.Errorf("want 2 columns, got %d", len(cols))
	}
	qty, err := strconv.Atoi(cols[1])
	if err != nil {
		return "", 0, err // 失敗したら先に抜ける
	}
	return cols[0], qty, nil // ここまで来たら正常
}

func main() {
	for _, line := range []string{"A-001,3", "A-002,x", "A-003"} {
		sku, qty, err := parseRow(line)
		if err != nil {
			fmt.Println("skip:", err)
			continue
		}
		fmt.Println(sku, qty)
	}
}

変数は短縮形式:=とvarのどちらを使うべきか

変数の宣言には var と :=(短縮記法)の2つがある。var rows int のように値を書かないと、その型のゼロ値が入る。ゼロ値は、数値なら 0、文字列なら ""、スライスやポインターなら nil。page := 1 は宣言と代入を同時にして、型は右辺から決まる。

使い分けの基本は「値を入れて始めるなら :=」。var limit int = 500 は動くが、batch := 500 で足りる。

func main() {
	// Goらしくない:動くが、型も値も書いていて長い
	var limit int = 500

	// Goらしい:右辺から int に決まる
	batch := 500

	fmt.Println(limit, batch) // 500 500
}

反対に、ゼロ値から数え始めるカウンターや、あとで append していくスライスは、var で宣言すると「ここではまだ空」という意図が伝わる。関数の外(パッケージ変数)では := が使えないので var を使う。

// 関数の外は var(:= は使えない)
var endpoint = "https://api.example.com/orders"

func main() {
	page := 1           // 値を入れて始めるなら :=
	var rows int        // ゼロ値(0)から数え始めるなら var
	var failed []string // まだ空(nil)。あとで append する

	failed = append(failed, "A-002")
	rows++
	fmt.Println(endpoint, page, rows, failed) // https://api.example.com/orders 1 1 [A-002]
}

右辺と違う型にしたいときは、var limit int32 = 500 でも limit := int32(500) でも書ける。

メモリ起因のパフォーマンス低下を解消する

スライスは、固定長の配列。len は「いま入っている要素数」、cap(容量)は「裏の配列に入る最大数」。append で容量を使い切ると、ランタイムはもっと大きな配列を確保し直し、中身をコピーして移る。

CSV の行数のように件数が先に分かっているなら、make([]Record, 0, n) で容量だけ先に確保する。

recs := make([]Record, 0, len(lines)) // 長さ0・容量は行数ぶん

for _, l := range lines {
	id, amt, _ := strings.Cut(l, ",")
	recs = append(recs, Record{UserID: id, Amount: amt})
}
fmt.Println(len(recs), cap(recs)) // 10000 10000

wrong := make([]Record, len(lines)) // 長さも10000になる
wrong = append(wrong, Record{UserID: "u0"})
fmt.Println(len(wrong)) // 10001(先頭に空の Record が1万個)

defer は、関数を抜けるときに後ろの処理を実行する予約。途中で return しても走る(os.Exit や log.Fatal で終わるときは走らない)ので、ファイルを開いた直後に defer f.Close() と書けば閉じ忘れが減る。落とし穴は2つある。

1つ目はループの中の defer。予約は「ループの1周」ではなく「関数」が終わるまで溜まる。1万ファイルを回せば、1万個が開きっぱなしになる。1周分を別の関数に切り出し、その中で defer すれば、1周ごとに閉じられる。

2つ目は Close のエラー。書き込み用のファイルは、Close のときに初めて失敗が分かることがある。defer f.Close() だけだと、そのエラーは捨てられる。拾うには戻り値に名前を付け(func f() (err error))、無名関数の defer の中で代入する。ここにさらに罠がある。err = w.Close() と素直に代入すると、本体で入っていたエラーが Close の nil で上書きされて消える。両方を残すには、Go 1.20 で入った errors.Join を使う(全部 nil なら nil を返す)。

// Goらしくない:Close の nil が本体のエラーを上書きする
func exportBad(w io.WriteCloser) (err error) {
	defer func() {
		err = w.Close()
	}()
	_, err = io.WriteString(w, "user_id,amount\n")
	return err
}

// Goらしい:本体のエラーと Close のエラーを両方残す
func export(w io.WriteCloser) (err error) {
	defer func() {
		err = errors.Join(err, w.Close())
	}()
	_, err = io.WriteString(w, "user_id,amount\n")
	return err
}

// 書き込みは失敗し、Close は成功する出力先に渡すと
// exportBad → <nil>(失敗が消える)
// export    → disk full

文字列の結合方法

Go の文字列は不変。作ったあとで中身を書き換えられない。だから q += cは、「q と c をつないだ新しい文字列」を毎回作り直している。ループで何百回も繰り返すと、途中の文字列が毎回メモリに確保されて遅くなる。

cols := []string{"user_id", "order_id", "amount", "ingested_at"}

// Goらしくない:+= のたびに新しい文字列を作り直す
q := "SELECT "
for i, c := range cols {
	if i > 0 {
		q += ", "
	}
	q += c
}
q += " FROM raw.orders"

// Goらしい:Builder に書き足して、最後に1回だけ文字列にする
var b strings.Builder
b.Grow(80) // 仕上がりの大きさの見込み
b.WriteString("SELECT ")
for i, c := range cols {
	if i > 0 {
		b.WriteString(", ")
	}
	b.WriteString(c)
}
b.WriteString(" FROM raw.orders")
fmt.Println(b.String())
// SELECT user_id, order_id, amount, ingested_at FROM raw.orders

// 数個をつなぐだけなら1つの式で + でよい
table := "raw." + "orders" + "_" + "20261022"

日時の取り扱い

Go は日付も時刻もタイムゾーンも、time.Time という1つの型で扱う。現在時刻は time.Now()、指定した日時は time.Date(年, 月, 日, 時, 分, 秒, ナノ秒, タイムゾーン) で作る。タイムゾーンは time.UTC・time.Local のほか、time.LoadLocation("Asia/Tokyo") で OS のタイムゾーンデータベースから引くか、time.FixedZone で時差を直接指定する。OS にデータベースが無いコンテナ向けには、time/tzdata を import してバイナリに同梱する方法もある。

文字列との変換は Format(Time → 文字列)と time.Parse(文字列 → Time)。書式に YYYY や %Y は使わない。「2006年1月2日 15時04分05秒」という基準の日時を、出したい形に並べて書く。月=1、日=2、時=3(15 なら24時間表記)、分=4、秒=5、年=6 と覚える。よく使う形は定数になっていて、Go 1.20 からは time.DateTime(2006-01-02 15:04:05)や time.DateOnly もある。CSV の時刻のように文字列にタイムゾーンが書かれていないとき、time.Parse は UTC として読む。JST のつもりなら time.ParseInLocation でタイムゾーンを渡す。

時間の長さは time.Duration で表す。int64 を元にした型で、1 が1ナノ秒。5 * time.Minute のように定数と掛けて作る。設定ファイルから読んだ int の秒数は型が違うので、そのままでは掛けられない。time.Duration(n) * time.Second と変換してから掛ける。過去の時刻は、マイナスの Duration を Add に渡して出す。

jst := time.FixedZone("JST", 9*60*60)
t := time.Date(2026, 10, 22, 14, 5, 9, 0, jst)
fmt.Println(t.Format("2006-01-02 15:04:05")) // 2026-10-22 14:05:09
fmt.Println(t.Format("20060102"))            // 20261022(テーブルの日付サフィックス)

s := "2026-10-22 14:05:09" // CSV の値。タイムゾーンが書かれていない
u, _ := time.Parse(time.DateTime, s)
fmt.Println(u.Location()) // UTC(JST のつもりでも UTC として読まれる)
j, _ := time.ParseInLocation(time.DateTime, s, jst)
fmt.Println(j.Location()) // JST

timeoutSec := 90 // ジョブ設定から読んだ int
timeout := time.Duration(timeoutSec) * time.Second
fmt.Println(timeout)                               // 1m30s
fmt.Println(t.Add(-timeout).Format(time.TimeOnly)) // 14:03:39(90秒前)

最後に月の計算。AddDate(年, 月, 日) で月を足せるが、存在しない日付は自動で繰り上がる(正規化)。1月31日に1か月足すと「2月31日」になり、それが3月3日に直される。月次バッチで気づきにくいバグになる。

closing := time.Date(2026, 1, 31, 0, 0, 0, 0, jst)

// Goらしくない:2月を飛び越える
fmt.Println(closing.AddDate(0, 1, 0).Format(time.DateOnly)) // 2026-03-03

// Goらしい:正規化を逆手に取る。「翌々月の0日」=翌月の末日
y, m, _ := closing.Date()
end := time.Date(y, m+2, 0, 0, 0, 0, 0, jst)
fmt.Println(end.Format(time.DateOnly)) // 2026-02-28

この書き方が返すのは、元の日にかかわらず常に翌月の末日。1月15日から始めても2026-02-28 になるので、月末締めの日付から次の締め日を出すときにだけ使える。

関連