Day 5: 関数 - 解説
学習の目的
- 処理を再利用可能にする: 同じコードを何度も書かない(DRY原則)
- コードを整理する: 機能ごとに分割(単一責任の原則)
- 複数の値を返す: Goの特徴的な機能(エラーハンドリングパターン)
- 抽象化を理解する: 実装の詳細を隠蔽し、インターフェースを公開
---
メンタルモデル:関数とは何か
関数は「ブラックボックス」
関数を理解する最も直感的な方法は、それを「ブラックボックス」として考えることです。
入力(引数) → [関数(ブラックボックス)] → 出力(戻り値)
実例で考える:
// 自動販売機のメンタルモデル
// 入力: お金 + 商品選択
// 処理: 内部メカニズム(見えない)
// 出力: 商品 + お釣り
func vendingMachine(money int, itemCode string) (string, int) {
// 内部処理は利用者には見えない
item := getItem(itemCode)
change := money - item.price
return item.name, change
}
この考え方により:
- カプセル化: 内部実装を隠せる
- 再利用: 同じ処理を何度でも呼び出せる
- テスト: 入力と出力だけを検証すればよい
---
詳細なアルゴリズム分析
1. 関数呼び出しのスタックフレーム
関数が呼び出されると、コールスタックに「スタックフレーム」が積まれます。
┌─────────────────┐
│ main() │ ← 最初に呼ばれる
├─────────────────┤
│ greet("太郎") │ ← main()から呼ばれる
├─────────────────┤
│ format(...) │ ← greet()から呼ばれる
└─────────────────┘
コールスタック
実装例:
package main
import "fmt"
// レベル3: 最も深い関数
func formatMessage(name string) string {
return fmt.Sprintf("こんにちは、%sさん!", name)
}
// レベル2: 中間の関数
func greet(name string) {
message := formatMessage(name) // formatMessage()を呼ぶ
fmt.Println(message)
}
// レベル1: エントリーポイント
func main() {
greet("太郎") // greet()を呼ぶ
}
スタックの動き:
1. main()がスタックに積まれる
2. greet("太郎")が呼ばれ、スタックに積まれる
3. formatMessage("太郎")が呼ばれ、スタックに積まれる
4. formatMessage()が完了し、スタックから取り除かれる
5. greet()が完了し、スタックから取り除かれる
6. main()が完了し、プログラム終了
時間計算量: O(1) - 関数呼び出し自体は定数時間 空間計算量: O(n) - 呼び出しの深さに比例(スタックオーバーフローに注意)
---
2. 再帰関数の計算量分析
階乗の計算(ナイーブな再帰):
func factorial(n int) int {
// ベースケース
if n == 0 {
return 1
}
// 再帰ケース
return n * factorial(n-1)
}
実行トレース(n=5の場合):
factorial(5)
= 5 * factorial(4)
= 5 * (4 * factorial(3))
= 5 * (4 * (3 * factorial(2)))
= 5 * (4 * (3 * (2 * factorial(1))))
= 5 * (4 * (3 * (2 * (1 * factorial(0)))))
= 5 * (4 * (3 * (2 * (1 * 1))))
= 5 * (4 * (3 * (2 * 1)))
= 5 * (4 * (3 * 2))
= 5 * (4 * 6)
= 5 * 24
= 120
計算量分析:
- 時間計算量: O(n) - n回の関数呼び出し
- 空間計算量: O(n) - コールスタックの深さ
改善版(末尾再帰):
func factorialTail(n int, acc int) int {
if n == 0 {
return acc
}
// 累積値を渡すことで、戻り時の計算を不要に
return factorialTail(n-1, n*acc)
}
func factorial(n int) int {
return factorialTail(n, 1)
}
改善点:
- Goコンパイラは末尾再帰最適化をしないが、コンセプトとして重要
- 他言語(例:関数型言語)では末尾再帰が自動的にループに最適化される
---
3. フィボナッチ数列:計算量の罠
ナイーブな実装:
func fibonacci(n int) int {
if n <= 1 {
return n
}
return fibonacci(n-1) + fibonacci(n-2)
}
計算木(n=5の場合):
fib(5)
/ \
fib(4) fib(3)
/ \ / \
fib(3) fib(2) fib(2) fib(1)
/ \ / \ / \
fib(2) fib(1)...
/ \
fib(1) fib(0)
問題点:
- 時間計算量: O(2^n) - 指数時間(非常に遅い)
- 空間計算量: O(n) - 最大スタック深度
- 重複計算: fib(3)、fib(2)などが何度も計算される
最適化版(メモ化):
func fibonacciMemo(n int, memo map[int]int) int {
// すでに計算済みならメモから返す
if val, ok := memo[n]; ok {
return val
}
// ベースケース
if n <= 1 {
return n
}
// 計算してメモに保存
memo[n] = fibonacciMemo(n-1, memo) + fibonacciMemo(n-2, memo)
return memo[n]
}
func fibonacci(n int) int {
memo := make(map[int]int)
return fibonacciMemo(n, memo)
}
改善後の計算量:
- 時間計算量: O(n) - 各値を1回だけ計算
- 空間計算量: O(n) - メモ用のマップ + スタック
さらなる最適化(反復版):
func fibonacciIterative(n int) int {
if n <= 1 {
return n
}
prev, curr := 0, 1
for i := 2; i <= n; i++ {
prev, curr = curr, prev+curr
}
return curr
}
最終的な計算量:
- 時間計算量: O(n)
- 空間計算量: O(1) - 定数空間のみ使用
---
設計原則による関数設計
1. SOLID原則の適用
S - 単一責任の原則(Single Responsibility Principle)
悪い例:
// この関数は複数の責任を持つ
func processUser(name string, email string) {
// 1. バリデーション
if len(name) == 0 {
fmt.Println("名前が空です")
return
}
// 2. データ変換
name = strings.TrimSpace(name)
email = strings.ToLower(email)
// 3. データベース保存
db.Save(name, email)
// 4. メール送信
sendWelcomeEmail(email)
// 5. ログ出力
fmt.Printf("ユーザー登録完了: %s\n", name)
}
良い例:
// 各関数が単一の責任を持つ
func validateUser(name, email string) error {
if len(name) == 0 {
return fmt.Errorf("名前が空です")
}
if !strings.Contains(email, "@") {
return fmt.Errorf("メールアドレスが無効です")
}
return nil
}
func normalizeUser(name, email string) (string, string) {
name = strings.TrimSpace(name)
email = strings.ToLower(email)
return name, email
}
func saveUser(name, email string) error {
return db.Save(name, email)
}
func notifyUser(email string) error {
return sendWelcomeEmail(email)
}
// 統合関数
func processUser(name, email string) error {
// バリデーション
if err := validateUser(name, email); err != nil {
return err
}
// 正規化
name, email = normalizeUser(name, email)
// 保存
if err := saveUser(name, email); err != nil {
return err
}
// 通知
if err := notifyUser(email); err != nil {
return err
}
log.Printf("ユーザー登録完了: %s\n", name)
return nil
}
---
2. DRY原則(Don't Repeat Yourself)
悪い例:
func calculateRectangleArea(width, height float64) float64 {
return width * height
}
func calculateSquareArea(side float64) float64 {
return side * side // 重複:長方形の面積計算と同じ
}
func calculateTriangleArea(base, height float64) float64 {
return (base * height) / 2
}
良い例:
// 基本的な面積計算関数
func calculateArea(width, height float64) float64 {
return width * height
}
// 高レベル関数は基本関数を再利用
func calculateRectangleArea(width, height float64) float64 {
return calculateArea(width, height)
}
func calculateSquareArea(side float64) float64 {
return calculateArea(side, side)
}
func calculateTriangleArea(base, height float64) float64 {
return calculateArea(base, height) / 2
}
---
3. KISS原則(Keep It Simple, Stupid)
悪い例(過度に複雑):
func add(a, b int) int {
// 不要な複雑性
result := 0
for i := 0; i < b; i++ {
result = increment(result + a - a)
result = increment(result)
}
return result
}
func increment(n int) int {
return n + 1
}
良い例(シンプル):
func add(a, b int) int {
return a + b
}
---
4. YAGNI原則(You Aren't Gonna Need It)
悪い例(不要な機能):
// 現在は2つの数の足し算しか必要ないのに...
func calculate(operation string, numbers ...float64) (float64, error) {
switch operation {
case "add":
// 足し算の実装
case "subtract":
// 引き算の実装(まだ使われていない)
case "multiply":
// 掛け算の実装(まだ使われていない)
case "divide":
// 割り算の実装(まだ使われていない)
case "power":
// べき乗の実装(まだ使われていない)
default:
return 0, fmt.Errorf("未知の操作")
}
// ...
}
良い例(必要なものだけ):
// 今必要なのは足し算だけ
func add(a, b float64) float64 {
return a + b
}
// 必要になったら他の操作を追加
---
概念の深掘り
値渡しと参照渡し(詳細版)
値渡しの仕組み:
func modifyValue(x int) {
fmt.Printf("関数内のアドレス: %p\n", &x)
x = x * 2
fmt.Printf("関数内の値: %d\n", x)
}
func main() {
n := 5
fmt.Printf("main内のアドレス: %p\n", &n)
fmt.Printf("main内の値(変更前): %d\n", n)
modifyValue(n)
fmt.Printf("main内の値(変更後): %d\n", n)
}
出力:
main内のアドレス: 0xc000014088
main内の値(変更前): 5
関数内のアドレス: 0xc0000140a0 ← 異なるアドレス!
関数内の値: 10
main内の値(変更後): 5 ← 元の値は変わらない
メモリ図:
main()のスタックフレーム:
┌─────────┐
│ n = 5 │ ← アドレス 0xc000014088
└─────────┘
modifyValue()のスタックフレーム:
┌─────────┐
│ x = 5 │ ← アドレス 0xc0000140a0(コピー)
│ ↓ │
│ x = 10 │ ← コピーを変更
└─────────┘
ポインタを使った参照渡し:
func modifyValueByPointer(x *int) {
fmt.Printf("ポインタが指すアドレス: %p\n", x)
*x = *x * 2 // ポインタを経由して元の値を変更
fmt.Printf("変更後の値: %d\n", *x)
}
func main() {
n := 5
fmt.Printf("main内のアドレス: %p\n", &n)
fmt.Printf("main内の値(変更前): %d\n", n)
modifyValueByPointer(&n) // アドレスを渡す
fmt.Printf("main内の値(変更後): %d\n", n) // 変更されている!
}
出力:
main内のアドレス: 0xc000014088
main内の値(変更前): 5
ポインタが指すアドレス: 0xc000014088 ← 同じアドレス!
変更後の値: 10
main内の値(変更後): 10 ← 元の値が変更された
---
スライスと値渡しの特殊性
スライスは「参照型っぽく」振る舞う:
func modifySlice(s []int) {
s[0] = 999 // 元のスライスの要素が変更される!
fmt.Println("関数内:", s)
}
func main() {
numbers := []int{1, 2, 3}
fmt.Println("変更前:", numbers)
modifySlice(numbers)
fmt.Println("変更後:", numbers) // 変更されている!
}
出力:
変更前: [1 2 3]
関数内: [999 2 3]
変更後: [999 2 3]
理由: スライスは内部的に以下の構造を持つ:
type slice struct {
ptr *element // 配列へのポインタ
len int // 長さ
cap int // 容量
}
スライスのコピーでも同じ配列を参照:
main()のスライス:
┌─────────────────┐
│ ptr → [1,2,3] │ ← 配列へのポインタ
│ len = 3 │
│ cap = 3 │
└─────────────────┘
modifySlice()のスライス(コピー):
┌─────────────────┐
│ ptr → [1,2,3] │ ← 同じ配列を指す!
│ len = 3 │
│ cap = 3 │
└─────────────────┘
---
ビジュアル図解
1. 関数呼び出しフロー(ASCII Art)
┌─────────────────────────────────┐
│ プログラム実行開始 │
└────────────┬────────────────────┘
│
▼
┌─────────────────────────────────┐
│ main() 関数実行 │
│ ┌─────────────────────────┐ │
│ │ 変数初期化 │ │
│ │ n := 10 │ │
│ └──────────┬──────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ double(n) を呼び出し │────┼──┐
│ └─────────────────────────┘ │ │
└─────────────────────────────────┘ │
│
│ 制御が移る
│
┌───────────────────────┘
│
▼
┌─────────────────────────────────┐
│ double(x int) int 関数実行 │
│ ┌─────────────────────────┐ │
│ │ 引数受け取り │ │
│ │ x = 10 (コピー) │ │
│ └──────────┬──────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ 計算実行 │ │
│ │ result = x * 2 │ │
│ │ result = 20 │ │
│ └──────────┬──────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ 値を返す │────┼──┐
│ │ return 20 │ │ │
│ └─────────────────────────┘ │ │
└─────────────────────────────────┘ │
│
│ 戻り値と制御が戻る
│
┌───────────────────────┘
│
▼
┌─────────────────────────────────┐
│ main() 関数に戻る │
│ ┌─────────────────────────┐ │
│ │ 戻り値を受け取り │ │
│ │ result = 20 │ │
│ └──────────┬──────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ 結果を出力 │ │
│ │ fmt.Println(result) │ │
│ └─────────────────────────┘ │
└─────────────────────────────────┘
---
2. 再帰関数のスタック展開(Mermaid図)
graph TD
A[factorial 5] -->|5 * factorial 4| B[factorial 4]
B -->|4 * factorial 3| C[factorial 3]
C -->|3 * factorial 2| D[factorial 2]
D -->|2 * factorial 1| E[factorial 1]
E -->|1 * factorial 0| F[factorial 0]
F -->|return 1| E
E -->|return 1| D
D -->|return 2| C
C -->|return 6| B
B -->|return 24| A
A -->|return 120| G[結果: 120]
---
3. 複数戻り値のパターン(図解)
┌─────────────────────────────────────────────────────────┐
│ 関数: divide(a, b int) (int, error) │
└─────────────────────────────────────────────────────────┘
│
│ 入力: a=10, b=2
▼
┌────────────────────────────────┐
│ 条件チェック │
│ b == 0 ? │
└────────┬───────────────┬────────┘
│ │
No │ │ Yes
│ │
▼ ▼
┌────────────────┐ ┌────────────────────┐
│ 正常パス │ │ エラーパス │
│ result = a/b │ │ return 0, error │
│ = 10/2 = 5 │ │ │
└────────┬───────┘ └────────┬───────────┘
│ │
│ │
▼ ▼
┌────────────────┐ ┌────────────────────┐
│ return 5, nil │ │ return 0, error │
└────────┬───────┘ └────────┬───────────┘
│ │
└─────────┬─────────┘
│
▼
┌──────────────────────────────┐
│ 呼び出し元でハンドリング │
│ result, err := divide(...) │
│ if err != nil { ... } │
└──────────────────────────────┘
---
さらなる探求
1. 高階関数(Higher-Order Functions)
関数を引数として受け取ったり、関数を返したりする関数。
例: マップ関数の実装
// transform は各要素にfnを適用する高階関数
func transform(numbers []int, fn func(int) int) []int {
result := make([]int, len(numbers))
for i, n := range numbers {
result[i] = fn(n) // 関数を適用
}
return result
}
func main() {
numbers := []int{1, 2, 3, 4, 5}
// 2倍にする関数
doubled := transform(numbers, func(n int) int {
return n * 2
})
fmt.Println(doubled) // [2 4 6 8 10]
// 2乗する関数
squared := transform(numbers, func(n int) int {
return n * n
})
fmt.Println(squared) // [1 4 9 16 25]
}
---
2. クロージャ(Closures)
外側の関数のスコープにある変数を「閉じ込める」関数。
例: カウンタージェネレーター
func makeCounter() func() int {
count := 0 // この変数はクロージャに「捕捉」される
return func() int {
count++
return count
}
}
func main() {
counter1 := makeCounter()
counter2 := makeCounter()
fmt.Println(counter1()) // 1
fmt.Println(counter1()) // 2
fmt.Println(counter1()) // 3
fmt.Println(counter2()) // 1 (独立したカウンター)
fmt.Println(counter2()) // 2
}
メモリ図:
counter1のクロージャ:
┌──────────────┐
│ count = 3 │ ← 独自のcount変数を保持
└──────────────┘
counter2のクロージャ:
┌──────────────┐
│ count = 2 │ ← 別の独立したcount変数
└──────────────┘
---
3. 可変長引数(Variadic Functions)
基本的な使い方:
func sum(numbers ...int) int {
total := 0
for _, n := range numbers {
total += n
}
return total
}
func main() {
fmt.Println(sum(1, 2, 3)) // 6
fmt.Println(sum(1, 2, 3, 4, 5)) // 15
fmt.Println(sum()) // 0
// スライスを展開して渡す
nums := []int{10, 20, 30}
fmt.Println(sum(nums...)) // 60
}
実用例: ログ関数
func logInfo(format string, args ...interface{}) {
timestamp := time.Now().Format("2006-01-02 15:04:05")
message := fmt.Sprintf(format, args...)
fmt.Printf("[INFO] %s: %s\n", timestamp, message)
}
func main() {
logInfo("サーバー起動")
logInfo("ユーザー接続: %s", "user@example.com")
logInfo("処理完了: %d 件処理しました", 42)
}
---
4. 遅延実行(Defer)
基本的な使い方:
func readFile(filename string) error {
file, err := os.Open(filename)
if err != nil {
return err
}
defer file.Close() // 関数終了時に必ず実行される
// ファイル読み込み処理
// ...
return nil // ここでfile.Close()が自動的に呼ばれる
}
deferのスタック動作:
func example() {
defer fmt.Println("1")
defer fmt.Println("2")
defer fmt.Println("3")
fmt.Println("通常の処理")
}
// 出力:
// 通常の処理
// 3 ← 後から登録されたものが先に実行される(LIFO)
// 2
// 1
実用例: トランザクション管理
func transferMoney(from, to string, amount float64) error {
tx, err := db.Begin()
if err != nil {
return err
}
// 成功/失敗に関わらず、適切に処理
defer func() {
if err != nil {
tx.Rollback() // エラー時はロールバック
} else {
tx.Commit() // 成功時はコミット
}
}()
// 送金処理
err = debit(tx, from, amount)
if err != nil {
return err
}
err = credit(tx, to, amount)
if err != nil {
return err
}
return nil
}
---
セルフチェック質問
理解度を確認するための質問です。答えられるか試してみましょう。
基礎レベル
- 関数とは何ですか?3つの主要な要素を挙げてください。
解答例
関数は処理をまとめたコードブロックです。3つの主要要素: - 引数(パラメータ): 関数に渡す入力値 - 処理(本体): 実行される命令 - 戻り値(返り値): 関数から返される出力値
- 値渡しと参照渡しの違いは何ですか?
解答例
- 値渡し: 値のコピーが渡される。元の変数は変更されない。 - 参照渡し: 変数のアドレス(ポインタ)が渡される。元の変数が変更される可能性がある。
Goはデフォルトで値渡しだが、ポインタを使うことで参照渡しの動作を実現できる。
- なぜ関数を使うのですか?メリットを3つ挙げてください。
解答例
- 再利用性: 同じ処理を何度でも呼び出せる(DRY原則) - 保守性: コードを整理・分割できる(単一責任の原則) - テスト容易性: 独立した単位でテストできる
中級レベル
- 以下のコードの時間計算量と空間計算量は?
func fibonacci(n int) int {
if n <= 1 {
return n
}
return fibonacci(n-1) + fibonacci(n-2)
}
解答例
- 時間計算量: O(2^n) - 指数時間(重複計算が多い) - 空間計算量: O(n) - 再帰の最大深度
メモ化や反復版に書き換えることで、時間計算量をO(n)に改善できる。
- クロージャとは何ですか?どんな場面で有用ですか?
解答例
外側の関数のスコープにある変数を「捕捉」する関数。
有用な場面: - カウンター、ジェネレーター - イベントハンドラー - コールバック関数 - 状態を保持する必要がある場面
上級レベル
- DRY原則とYAGNI原則のバランスをどう取りますか?
解答例
- DRY(Don't Repeat Yourself): 重複を避ける - YAGNI(You Aren't Gonna Need It): 不要な機能を作らない
バランスの取り方: - すでに2回以上使われている処理を関数化(DRY) - 将来使うかもしれない機能は作らない(YAGNI) - リファクタリング時に統合を検討
- 末尾再帰最適化とは何ですか?Goで実現できますか?
解答例
末尾再帰最適化:再帰呼び出しが関数の最後にある場合、コンパイラがループに変換する最適化。
Goでは実現できない: Goコンパイラは末尾再帰最適化を行わない。
対策: - 明示的に反復版を書く - スタックオーバーフローに注意
---
明日への準備
Day 6は「配列とスライス」を学びます。
予習ポイント:
// 配列:固定長
arr := [3]int{1, 2, 3}
// スライス:可変長
slice := []int{1, 2, 3}
slice = append(slice, 4) // 要素を追加できる
関数との関連:
- スライスを関数の引数として渡す方法
- スライスを返す関数の設計
- スライスの内部構造(ポインタ、長さ、容量)
---
まとめ
| 概念 | 説明 | 計算量 |
|---|---|---|
| 関数呼び出し | 処理をまとめたもの | O(1) |
| 引数 | 関数に渡す値(値渡し) | - |
| 戻り値 | 関数から返す値 | - |
| 再帰 | 自分自身を呼び出す | O(n) ~ O(2^n) |
| 高階関数 | 関数を引数/戻り値にする | - |
| クロージャ | 外側の変数を捕捉する | - |
| defer | 遅延実行(クリーンアップ) | - |
重要な設計原則:
- 単一責任の原則: 1つの関数は1つのことだけをする
- DRY原則: 同じコードを繰り返さない
- KISS原則: シンプルに保つ
- YAGNI原則: 不要な機能は作らない
次のステップ:
- 関数を使った実装問題を解く
- 計算量を意識したコーディング
- 設計原則を実践する