📚

Miyazaki.go 勉強会 #3 参加レポート

に公開

はじめに

お久しぶりです。28卒予定で学生をしています。tosshyです。先日、宮崎で開催された、Go言語の勉強会に参加したので、そのレポートでも残しておきます。

https://miyazakigo.connpass.com/event/387152/

背景

2025年12月にMiyazaki.go x Wakayama.go x Biwako.goの合同LT会がありました。そこでMiyazaki.goの主催者と意気投合しました。いつかMiyazaki.goの現地の勉強会に行きたいなとおもいつつも#1、#2と時間は過ぎていきました。

しかし、転機が訪れます。2026年3月に開催されたCyber AgentのGo Collegeというインターンシップの参加です。そこで、福岡在住の学生と親密になりました。

これがきっかけで、Goへのモチベーションも最高潮になり、一緒に宮崎に行くしかないと決意しました。
石川県から宮崎に行くためにありったけのお金を注ぎ込む、、、ことは学生には厳しかったので、身の丈にあった手段で宮崎への旅程を組みました。

取り組んだ内容

Go言語をTDD(テスト駆動開発)で学ぶという、技術を一石二鳥で学べる教材を使用しました。
今回はこの教材の中で「反復、繰り返し」、「配列、スライス」のセクションに取り組みました。
https://andmorefine.gitbook.io/learn-go-with-tests

進め方

参加者の一人がライブコーディングをしながらワイワイ楽しく教材を進めていく形式で、リラックスしながら勉強できました。
挑戦したい方は、ライブコーディング枠に志願できる感じで、シニアエンジニアからのフィードバックも受けられる、貴重な場だったと思います。
教材の内容をそのまま輪読するだけでなく、主催者独自の深掘りが別スライドとして用意されており、勉強会としての満足度は非常に高かったです。

得られたこと

筆者はテーブル駆動テストについて知見が浅い状態だったので、一度自分の手で書くことで、この書き方の良さをみに沁みて学ぶことができました。

慣れないテスト駆動開発ではありましたが、テストを先に書くことで、実装のゴールを設計して、ゴールから逆算する形で実装とリファクタを回すという体験ができました。

教材の実装を満たすだけでなく、Go内部のパッケージを参加者と一緒に深掘りをする時間の余裕もあり、教材以上の学びの機会がありました。

特に印象に残った部分

「反復、繰り返し」のセクションで、ある文字列を特定の回数だけ繰り返して、連結させるという処理のお題がありました。そこでは、入出力の検証を担保するテストだけではなく、ベンチマークテストが実施されていました。その話を抜粋します。

https://andmorefine.gitbook.io/learn-go-with-tests/go-fundamentals/iteration

シンプルな実装

シンプルに実装すると以下のようになると思います。
空文字で初期化した変数に、繰り返しの回数分、文字列を足し合わせていくという素直な実装ですね。

func RepeatBasic(char string, count int) string {
	var repeated string
	for range count {
		repeated += char
	}
	return repeated
}

「"a"を1000回繰り返す文字列を得るシチュエーション」でベンチマークを取りました。

どうやら様子がおかしいです。1byteのものを1000回書き込むと見積もっていたので、たかだか1000byteくらいだろうと思っていたものが、まさかの50倍でした。
内訳としては、1+2+3+4+...+1000byte分書き込まれて、約550000byteになっていると思われます。
メモリ割り当て回数も999回起きており、改善の余地がありそうです。

ベンチマークコード
func BenchmarkRepeat(b *testing.B) {
	benchmarks := []struct {
		name string
		fn   func(string, int) string
	}{
		{"RepeatBasic", RepeatBasic},
	}
	for _, bm := range benchmarks {
		b.Run(bm.name, func(b *testing.B) {
			for b.Loop() {
				bm.fn("a", 1000)
			}
		})
	}
}
$ go test -bench=BenchmarkRepeat -benchmem
goos: darwin
goarch: arm64
pkg: slice-tdd/repeat
cpu: Apple M3
BenchmarkRepeat/RepeatBasic-8              23233             50164 ns/op          530276 B/op    999 allocs/op

strings.Builderを使った実装

勉強会で紹介されたのはstringsパッケージ内のBuilder構造体を使ったものです。

これを使って書き直すと以下のようになります。

func RepeatWithBuilder(char string, count int) string {
	var b strings.Builder
	for range count {
		b.WriteString(char)
	}
	return b.String()
}

同じようにベンチマークテストを実行します。
わかりやすいように、先ほど実装したRepeatBasicの結果も一緒に見てみましょう。
メモリ割り当て回数、書き込みバイト数、実行時間ともに大きく改善されています!

ベンチマークコード
func BenchmarkRepeat(b *testing.B) {
	benchmarks := []struct {
		name string
		fn   func(string, int) string
	}{
		{"RepeatBasic", RepeatBasic},
        {"RepeatWithBuilder", RepeatWithBuilder},
	}
	for _, bm := range benchmarks {
		b.Run(bm.name, func(b *testing.B) {
			for b.Loop() {
				bm.fn("a", 1000)
			}
		})
	}
}
$ go test -bench=BenchmarkRepeat -benchmem
goos: darwin
goarch: arm64
pkg: slice-tdd/repeat
cpu: Apple M3
BenchmarkRepeat/RepeatBasic-8              23726             49641 ns/op          530276 B/op    999 allocs/op
BenchmarkRepeat/RepeatWithBuilder-8       317864              3665 ns/op            3320 B/op      9 allocs/op

メモリ割り当て回数が改善した理由

なぜここまで改善されたのでしょうか?
ここでは主にメモリ割り当て回数について見てみましょう。
RepeatBasicの場合はN回の繰り返しに対してO(N)の割り当て回数になっていそうです。
それがRepeatWithBuilderではN回の繰り返しに対してO(log(N))の割り当て回数に改善されていると仮説を立てましょう。

この仮説をもとにBuilder.WriteStringメソッドの中身をのぞいてみます。

func (b *Builder) WriteString(s string) (int, error) {
	b.copyCheck()
	b.buf = append(b.buf, s...)
	return len(s), nil
}

文字列をb.bufというスライスに追加しているようです。
そしてBuilder構造体のbuf変数の定義はみると、

type Builder struct {
	addr *Builder
	buf []byte
}

バイトのスライスになっていますね。以上を踏まえてもう一度RepeatWithBuilderを見ましょう。

func RepeatWithBuilder(char string, count int) string {
	var b strings.Builder
    // b.bufはnil, len(b.buf) = 0, cap(b.buf) = 0
	for range count {
		b.WriteString(char) // b.bufに対してappendする
	}
	return b.String()
}

加えて、スライスには以下の仕様があります。

capを超えてスライスにappendするとき
既存のスライスの2倍の容量を持った新しいスライスが作られて
既存のデータは新しいスライスにコピーされる

// (cap(s)+1)*2は、cap(s)=0のときだけ
// 他のケースはcap(s)*2
t := make([]byte, len(s), (cap(s)+1)*2)
copy(t, s)
s = t

Go Slices: usage and internals; Growing slices (the copy and append functions)

仕様を参考に長さが0、容量が0のスライスに対してNappendするときのメモリ割り当て回数を見積もることができます。
スライスのメモリ再割り当てのタイミングは、長さと容量が共に0, 2, 4, 8, 16, 32, ... 512 ...のとき。
これを一般化すると、Nappendするときのメモリ割り当て回数はlog_2 N回になりますね。オーダー記法にすると、O(log(N))です。
これで1000回の文字列の繰り返しの時、RepeatWithBuilderの実装だとメモリ割り当て回数が10回程度になるカラクリが解けました!

まとめ

RepeatBasicからRepeatWithBuilderへのリファクタで高速化した要因をまとめます。

  • スライスを用いることでメモリ割り当ての回数が減った
  • メモリ割り当ての回数が減ったことでコピー回数が減った
  • コピー回数が減ったことで書き込む総バイト数が減った
  • 書き込む総バイト数が減ったことで高速に動作した

実はまだ2段階高速化の余地があるので、これはまた今度の話にしておきます。
ベンチマークを使ったパフォーマンスチューニングは楽しいですね!

おわりに

2026年から本格化すると伝えられていたMiyazaki.goが4月で#3なので、これからもっと盛り上がっていくことが期待されます。
5月も開催されるようなので、九州在住のGoに興味がある方、学生はぜひ参加することをお勧めします!
筆者もまた参加したいと思っていますので、これを機にお会いできたら声をかけてください!

参考

Miyazaki.go x Wakayama.go x Biwako.go 合同LT会
https://biwakogo.connpass.com/event/375433/

Cyber Agent Go College
https://www.cyberagent.co.jp/careers/students/event/detail/id=32665

Discussion