Day 8: 構造体 - 解説
学習の目的
構造体(struct)は、Go言語における最も重要な概念の一つです。この章では以下を学びます:
- 独自のデータ型を定義する - 関連するデータをまとめて管理
- メソッドの概念を理解する - データと振る舞いを結びつける
- ポインタレシーバを使い分ける - 値の変更が必要な場合の処理
- 実践的な設計パターン - プロダクション品質のコードを書く
---
Mental Model: 構造体の直感的理解
構造体とは何か
構造体は「設計図」のようなものです。例えば、以下のように考えることができます:
┌─────────────────────────┐
│ Person(人)の設計図 │
├─────────────────────────┤
│ Name: 名前を入れる箱 │
│ Age: 年齢を入れる箱 │
└─────────────────────────┘
この設計図から、実際の「インスタンス」(実体)を作ることができます:
// 設計図の定義
type Person struct {
Name string
Age int
}
// 設計図を使って実体を作る
taro := Person{Name: "太郎", Age: 25}
hanako := Person{Name: "花子", Age: 22}
現実世界との対応
例1: 図書館の本
type Book struct {
Title string // タイトル
Author string // 著者
ISBN string // ISBN番号
Available bool // 貸出可能か
}
// 実際の本
book1 := Book{
Title: "Go言語入門",
Author: "山田太郎",
ISBN: "978-4-xxxx-xxxx-x",
Available: true,
}
例2: ショッピングカート
type CartItem struct {
ProductName string
Price int
Quantity int
}
type ShoppingCart struct {
Items []CartItem
Total int
}
---
アルゴリズム分析
時間計算量
構造体の操作にかかる時間を理解することは重要です。
フィールドへのアクセス
type Person struct {
Name string
Age int
}
p := Person{Name: "太郎", Age: 25}
name := p.Name // O(1) - 定数時間
age := p.Age // O(1) - 定数時間
時間計算量: O(1)
- フィールドへのアクセスは常に一定時間
- メモリ上の位置が決まっているため
スライスでの検索
type Student struct {
Name string
Score int
}
students := []Student{
{Name: "太郎", Score: 80},
{Name: "花子", Score: 90},
{Name: "次郎", Score: 75},
}
// 線形検索
func findByName(students []Student, name string) *Student {
for i := range students {
if students[i].Name == name { // 最悪の場合、全要素を確認
return &students[i]
}
}
return nil
}
時間計算量: O(n)
- n は要素数
- 最悪の場合、全ての要素を確認する必要がある
マップを使った高速化
type StudentDB struct {
byName map[string]*Student
}
func (db *StudentDB) FindByName(name string) *Student {
return db.byName[name] // ハッシュテーブルによる検索
}
時間計算量: O(1)(平均)
- ハッシュテーブルによる高速検索
- 衝突が少ない場合は定数時間
空間計算量
構造体が使用するメモリ量を理解することも重要です。
type Person struct {
Name string // 16バイト(ポインタ8 + 長さ8)
Age int // 8バイト(64ビット環境)
}
// 合計: 24バイト
// スライスの場合
people := make([]Person, 100)
// 100 * 24 = 2,400バイト = 約2.4KB
---
設計原則の適用
SOLID原則
1. Single Responsibility Principle(単一責任の原則)
一つの構造体は一つの責任だけを持つべきです。
// ❌ 悪い例: 複数の責任を持つ
type User struct {
ID int
Name string
Email string
Password string
// データベース関連
DBConnection *sql.DB
// メール送信関連
SMTPConfig SMTPConfig
}
// ✅ 良い例: 責任を分離
type User struct {
ID int
Name string
Email string
Password string
}
type UserRepository struct {
db *sql.DB
}
type EmailService struct {
smtpConfig SMTPConfig
}
2. Open/Closed Principle(開放/閉鎖の原則)
拡張に対しては開いているが、修正に対しては閉じている。
// 基本構造体
type Person struct {
Name string
Age int
}
// 埋め込みによる拡張
type Employee struct {
Person // Personを拡張
EmployeeID string
Department string
}
// さらに拡張
type Manager struct {
Employee // Employeeを拡張
TeamSize int
}
DRY原則(Don't Repeat Yourself)
同じコードを繰り返さず、共通部分を抽出します。
// ❌ 悪い例: 繰り返し
type Teacher struct {
Name string
Age int
Address string
}
type Student struct {
Name string
Age int
Address string
}
// ✅ 良い例: 共通部分を抽出
type Person struct {
Name string
Age int
Address string
}
type Teacher struct {
Person
Subject string
}
type Student struct {
Person
Grade int
}
KISS原則(Keep It Simple, Stupid)
シンプルに保つことが最も重要です。
// ❌ 複雑すぎる
type ComplexUser struct {
data map[string]interface{}
}
func (u *ComplexUser) GetName() string {
if v, ok := u.data["name"]; ok {
if name, ok := v.(string); ok {
return name
}
}
return ""
}
// ✅ シンプル
type User struct {
Name string
}
func (u *User) GetName() string {
return u.Name
}
YAGNI原則(You Aren't Gonna Need It)
必要になるまで実装しない。
// ❌ 使わない機能を先に実装
type User struct {
ID int
Name string
Email string
Phone string
Address string
Birthday time.Time
FavoriteColor string // 本当に必要?
ShoeSize int // 本当に必要?
PetNames []string // 本当に必要?
}
// ✅ 必要な機能だけ
type User struct {
ID int
Name string
Email string
}
---
メモリレイアウトの詳細
構造体のメモリ配置
Goの構造体は、メモリ上に連続して配置されます。
type Example struct {
A bool // 1バイト
B int64 // 8バイト
C bool // 1バイト
}
実際のメモリレイアウト:
┌──┬─────────┬──────────┬──┬─────────┐
│A │パディング│ B │C │パディング│
│1B│ 7B │ 8B │1B│ 7B │
└──┴─────────┴──────────┴──┴─────────┘
合計: 24バイト
最適化されたレイアウト:
type Optimized struct {
B int64 // 8バイト
A bool // 1バイト
C bool // 1バイト
}
┌──────────┬──┬──┬─────────┐
│ B │A │C │パディング│
│ 8B │1B│1B│ 6B │
└──────────┴──┴──┴─────────┘
合計: 16バイト(33%削減!)
パディングの理由
CPUは、データが特定のアドレス境界に配置されていると効率よく読み書きできます。
アライメント要件(64ビット環境):
- bool, int8: 1バイト境界
- int16: 2バイト境界
- int32, float32: 4バイト境界
- int64, float64, ポインタ: 8バイト境界
実測例
package main
import (
"fmt"
"unsafe"
)
type Bad struct {
A bool // 1 + 7パディング
B int64 // 8
C bool // 1 + 7パディング
}
type Good struct {
B int64 // 8
A bool // 1
C bool // 1 + 6パディング
}
func main() {
fmt.Printf("Bad: %d bytes\n", unsafe.Sizeof(Bad{})) // 24
fmt.Printf("Good: %d bytes\n", unsafe.Sizeof(Good{})) // 16
}
---
値レシーバとポインタレシーバの深い理解
メモリの動き
値レシーバの場合
type Person struct {
Name string
Age int
}
func (p Person) Greet() { // コピーが渡される
fmt.Printf("Hello, %s\n", p.Name)
}
func main() {
taro := Person{Name: "太郎", Age: 25}
taro.Greet()
}
メモリの動き:
1. taro が作成される(24バイト)
┌────────────┐
│ taro │
│ Name: 太郎 │
│ Age: 25 │
└────────────┘
2. Greet()呼び出し時、コピーが作られる(さらに24バイト)
┌────────────┐ ┌────────────┐
│ taro │ │ p (コピー) │
│ Name: 太郎 │ → │ Name: 太郎 │
│ Age: 25 │ │ Age: 25 │
└────────────┘ └────────────┘
3. Greet()終了後、コピーは破棄される
ポインタレシーバの場合
func (p *Person) Birthday() { // ポインタが渡される
p.Age++
}
func main() {
taro := Person{Name: "太郎", Age: 25}
taro.Birthday() // Goが自動的に&taroに変換
}
メモリの動き:
1. taro が作成される(24バイト)
┌────────────┐
│ taro │
│ Name: 太郎 │
│ Age: 25 │
└────────────┘
2. Birthday()呼び出し時、ポインタだけが渡される(8バイト)
┌────────────┐
│ taro │ ← p が指す
│ Name: 太郎 │
│ Age: 25 │
└────────────┘
3. 元のtaroが直接変更される
┌────────────┐
│ taro │
│ Name: 太郎 │
│ Age: 26 │ ← 変更された!
└────────────┘
パフォーマンス比較
type LargeStruct struct {
Data [1000]int // 8000バイト
}
// 値レシーバ: 毎回8000バイトコピー
func (l LargeStruct) ProcessValue() {
// 処理
}
// ポインタレシーバ: 8バイトのポインタだけ
func (l *LargeStruct) ProcessPointer() {
// 処理
}
ベンチマーク結果:
BenchmarkValueReceiver-8 100000 125000 ns/op
BenchmarkPointerReceiver-8 10000000 150 ns/op
ポインタレシーバは約833倍高速!
---
視覚的な図解
構造体の埋め込み
type Address struct {
City string
Country string
}
type Person struct {
Name string
Address // 埋め込み
}
p := Person{
Name: "太郎",
Address: Address{
City: "東京",
Country: "日本",
},
}
メモリ構造:
┌──────────────────────────┐
│ Person p │
├──────────────────────────┤
│ Name: "太郎" │
├──────────────────────────┤
│ Address (埋め込み) │
│ ├─ City: "東京" │
│ └─ Country: "日本" │
└──────────────────────────┘
アクセス方法:
p.Name → "太郎"
p.City → "東京"(直接アクセス可能)
p.Address.City → "東京"(明示的アクセスも可能)
スライスと構造体
type Student struct {
Name string
Score int
}
students := []Student{
{Name: "太郎", Score: 80},
{Name: "花子", Score: 90},
}
メモリ構造:
students スライス:
┌─────────┬──────┬─────────┐
│ ptr │ len │ cap │
│ (8B) │ (8B) │ (8B) │
└───┬─────┴──────┴─────────┘
│
↓ 指している配列
┌────────────────┬────────────────┐
│ Student[0] │ Student[1] │
├────────────────┼────────────────┤
│ Name: "太郎" │ Name: "花子" │
│ Score: 80 │ Score: 90 │
└────────────────┴────────────────┘
---
Further Exploration: Experiencedコースへの橋渡し
インターフェースとの組み合わせ
構造体の真の力は、インターフェースと組み合わせたときに発揮されます。
// インターフェース(Experiencedで学習)
type Greeter interface {
Greet() string
}
// 構造体1
type Person struct {
Name string
}
func (p Person) Greet() string {
return fmt.Sprintf("私は%sです", p.Name)
}
// 構造体2
type Robot struct {
Model string
}
func (r Robot) Greet() string {
return fmt.Sprintf("ロボット%sです", r.Model)
}
// どちらも Greeter インターフェースを満たす
func SayHello(g Greeter) {
fmt.Println(g.Greet())
}
func main() {
p := Person{Name: "太郎"}
r := Robot{Model: "R2-D2"}
SayHello(p) // 私は太郎です
SayHello(r) // ロボットR2-D2です
}
並行処理での構造体
// Experiencedで学習する内容のプレビュー
type Counter struct {
mu sync.Mutex
value int
}
func (c *Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}
func main() {
counter := &Counter{}
// 複数のgoroutineから安全に操作
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter.Increment()
}()
}
wg.Wait()
fmt.Println(counter.value) // 1000
}
---
Self-Check Questions: 理解度確認
レベル1: 基礎
Q1: 構造体とは何ですか?
答えを見る
複数のフィールドをまとめて一つの新しい型を定義するデータ構造です。異なる型のデータを論理的にグループ化できます。
Q2: 値レシーバとポインタレシーバの違いは?
答えを見る
- 値レシーバ: コピーに対して操作、元の値は変わらない
- ポインタレシーバ: 元の値を直接変更できる
レベル2: 応用
Q3: なぜメモリレイアウトの最適化が重要なのですか?
答えを見る
- メモリ使用量の削減
- CPUキャッシュの効率向上
- パフォーマンスの改善
大きな構造体や大量のインスタンスを扱う場合、パディングを減らすことで大幅な改善が可能です。
Q4: 構造体の埋め込みの利点は?
答えを見る
- コードの再利用
- 継承のような機能を実現(ただしGoには継承はない)
- 埋め込まれた型のメソッドも使える
- 明示的な委譲が不要
レベル3: 実践
Q5: 以下のコードの出力は?
type Counter struct {
value int
}
func (c Counter) Increment() {
c.value++
}
func main() {
c := Counter{value: 0}
c.Increment()
fmt.Println(c.value)
}
答えを見る
出力: 0
理由: 値レシーバを使っているため、Increment()はコピーに対して操作しています。元のcの値は変わりません。
正しくは:
func (c *Counter) Increment() { // ポインタレシーバ
c.value++
}
Q6: このコードの問題点は?
type User struct {
ID int
Name string
Email string
Password string
}
func (u *User) ToJSON() string {
data, _ := json.Marshal(u)
return string(data)
}
答えを見る
問題点:
- パスワードがJSON化されてしまう(セキュリティリスク)
- エラー処理を無視している(
_)
改善版:
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"-"` // JSON化しない
}
func (u *User) ToJSON() (string, error) {
data, err := json.Marshal(u)
if err != nil {
return "", err
}
return string(data), nil
}
---
実践演習
演習1: 銀行口座システム
以下の要件を満たすBankAccount構造体を実装してください:
要件:
- 口座番号、名義人、残高を管理
- 入金メソッド
- 出金メソッド(残高不足時はエラー)
- 残高照会メソッド
解答例を見る
type BankAccount struct {
accountNumber string
holderName string
balance int
}
func NewBankAccount(number, name string) *BankAccount {
return &BankAccount{
accountNumber: number,
holderName: name,
balance: 0,
}
}
func (ba *BankAccount) Deposit(amount int) error {
if amount <= 0 {
return errors.New("金額は正の値である必要があります")
}
ba.balance += amount
return nil
}
func (ba *BankAccount) Withdraw(amount int) error {
if amount <= 0 {
return errors.New("金額は正の値である必要があります")
}
if amount > ba.balance {
return errors.New("残高不足です")
}
ba.balance -= amount
return nil
}
func (ba *BankAccount) GetBalance() int {
return ba.balance
}
func (ba *BankAccount) String() string {
return fmt.Sprintf("口座番号: %s, 名義: %s, 残高: %d円",
ba.accountNumber, ba.holderName, ba.balance)
}
演習2: ToDoリスト
以下の機能を持つToDoリストを実装してください:
要件:
- タスクの追加
- タスクの完了
- 未完了タスクの一覧表示
- 完了タスクの一覧表示
解答例を見る
type Task struct {
ID int
Title string
Description string
Completed bool
CreatedAt time.Time
}
type TodoList struct {
tasks []Task
nextID int
}
func NewTodoList() *TodoList {
return &TodoList{
tasks: make([]Task, 0),
nextID: 1,
}
}
func (tl *TodoList) AddTask(title, description string) {
task := Task{
ID: tl.nextID,
Title: title,
Description: description,
Completed: false,
CreatedAt: time.Now(),
}
tl.tasks = append(tl.tasks, task)
tl.nextID++
}
func (tl *TodoList) CompleteTask(id int) error {
for i := range tl.tasks {
if tl.tasks[i].ID == id {
tl.tasks[i].Completed = true
return nil
}
}
return fmt.Errorf("タスクID %dが見つかりません", id)
}
func (tl *TodoList) GetPendingTasks() []Task {
pending := []Task{}
for _, task := range tl.tasks {
if !task.Completed {
pending = append(pending, task)
}
}
return pending
}
func (tl *TodoList) GetCompletedTasks() []Task {
completed := []Task{}
for _, task := range tl.tasks {
if task.Completed {
completed = append(completed, task)
}
}
return completed
}
---
まとめ
重要なポイント
1. 構造体の基本
- 複数のフィールドをまとめた型
typeキーワードで定義- リテラルで初期化
2. メソッド
- 構造体に関連付けた関数
- 値レシーバとポインタレシーバ
- 値を変更する場合はポインタレシーバ
3. メモリ最適化
- フィールドの順序が重要
- パディングを意識する
- 大きいフィールドを先に配置
4. 設計原則
- 単一責任の原則
- DRY(繰り返しを避ける)
- KISS(シンプルに保つ)
- YAGNI(必要になるまで実装しない)
次のステップ
Experienced コースで学ぶこと:
- インターフェース - ポリモーフィズムの実現
- 並行処理 - Goroutineとチャネル
- エラーハンドリング - 堅牢なコード
- テスト - 品質保証
- パッケージ設計 - 大規模システム
学習リソース
公式ドキュメント:
推奨書籍:
- "The Go Programming Language" by Alan Donovan & Brian Kernighan
- "Learning Go" by Jon Bodner
- "Go in Practice" by Matt Butcher & Matt Farina
オンラインリソース:
---
最後に
構造体は、Go言語における最も基本的で、最も強力なデータ構造です。この章で学んだ概念は、今後のすべてのGoプログラミングの基礎となります。
8日間の学習、お疲れさまでした!
あなたは今、Go言語の基礎を完全にマスターしました。ここから先は、より高度な概念を学び、実践的なアプリケーションを構築していく段階です。
次のステップへ進みましょう!
Experienced コースでは、インターフェース、並行処理、エラーハンドリングなど、プロダクションレベルのGoアプリケーション開発に必要なスキルを身につけます。
この8日間で学んだことを忘れずに、引き続き学習を進めてください。プログラミングは、実践してこそ身につくスキルです。たくさんコードを書いて、たくさん間違えて、そして成長していきましょう。
Happy coding!