第4章: 型システム基礎 - メモリレベルからの理解
学習目標
この章を終えると、以下ができるようになります:
- Goの型システムの設計思想を理解する
- 各データ型のメモリレイアウトを説明できる
- 型変換と型アサーションの内部動作を理解する
- 名前付き型とエイリアスの違いを使い分けられる
- ゼロ値の設計思想とその重要性を説明できる
- スライスとマップの内部実装を理解し、効率的に使える
---
1. Goの型システム設計思想
1.1 静的型付けの意味
🔑 Goは静的型付け言語 - すべての変数の型がコンパイル時に決定されます。
静的型付けのメリット:
┌─────────────────────────────────────────┐
│ コンパイル時 │
│ ┌────────┐ ┌──────────────┐ │
│ │ソース │ --> │型チェッカー │ │
│ │コード │ │(エラー検出)│ │
│ └────────┘ └──────────────┘ │
│ ↓ │
│ 型エラーは │
│ ここで全て検出! │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 実行時 │
│ 型エラーは発生しない │
│ → パフォーマンス向上 │
│ → 安全性の保証 │
└─────────────────────────────────────────┘
静的型付けの実例:
// コンパイル時にエラーを検出
var x int = 42
var y string = "hello"
// ❌ エラー: 型が一致しない(コンパイル時に検出)
// x = y // cannot use y (type string) as type int in assignment
// ✅ 明示的な変換が必要
x = len(y) // OK(len関数はintを返す)
💡 型安全性の保証:
1.2 型システムの構造
Goの型は以下のように分類されます:
型の階層
├── 基本型(Basic types)
│ ├── bool
│ ├── 数値型
│ │ ├── 整数型(int, int8, int16, int32, int64)
│ │ ├── 符号なし整数(uint, uint8, uint16, uint32, uint64)
│ │ ├── 浮動小数点(float32, float64)
│ │ └── 複素数(complex64, complex128)
│ └── string
│
├── 複合型(Composite types)
│ ├── 配列(Array)
│ ├── スライス(Slice)
│ ├── マップ(Map)
│ ├── 構造体(Struct)
│ └── ポインタ(Pointer)
│
└── インターフェース型(Interface types)
└── interface
---
2. 基本型のメモリレイアウト
2.1 整数型のサイズとメモリ表現
🔑 整数型は固定サイズのビット列として格納されます。
整数型のメモリサイズ:
┌──────────┬─────────┬──────────────────────┬────────────────────────┐
│ 型 │サイズ │ 範囲(符号付き) │ 範囲(符号なし) │
├──────────┼─────────┼──────────────────────┼────────────────────────┤
│ int8 │ 1バイト │ -128 ~ 127 │ │
│ uint8 │ 1バイト │ │ 0 ~ 255 │
│ int16 │ 2バイト │ -32768 ~ 32767 │ │
│ uint16 │ 2バイト │ │ 0 ~ 65535 │
│ int32 │ 4バイト │ -2^31 ~ 2^31-1 │ │
│ uint32 │ 4バイト │ │ 0 ~ 2^32-1 │
│ int64 │ 8バイト │ -2^63 ~ 2^63-1 │ │
│ uint64 │ 8バイト │ │ 0 ~ 2^64-1 │
│ int │ 可変 │ プラットフォーム依存 │ │
│ uint │ 可変 │ │ プラットフォーム依存 │
│ uintptr │ 可変 │ ポインタサイズに一致 │ │
└──────────┴─────────┴──────────────────────┴────────────────────────┘
メモリ表現の例(int8):
var x int8 = 42
var y int8 = -42
メモリ上の表現(2の補数表現):
x = 42(正の数)
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ 0 │ 0 │ 1 │ 0 │ 1 │ 0 │ 1 │ 0 │ = 0x2A = 42
└───┴───┴───┴───┴───┴───┴───┴───┘
↑
符号ビット(0=正)
y = -42(負の数)
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ 1 │ 1 │ 0 │ 1 │ 0 │ 1 │ 1 │ 0 │ = 0xD6 = -42
└───┴───┴───┴───┴───┴───┴───┴───┘
↑
符号ビット(1=負)
計算方法:
-42 = ~42 + 1 = ~(00101010) + 1 = 11010101 + 1 = 11010110
💡 intとint64の違い:
import "unsafe"
func main() {
var i int
var i64 int64
fmt.Printf("intのサイズ: %d バイト\n", unsafe.Sizeof(i))
fmt.Printf("int64のサイズ: %d バイト\n", unsafe.Sizeof(i64))
// 32bitシステム: intのサイズ: 4 バイト
// 64bitシステム: intのサイズ: 8 バイト
// すべてのシステム: int64のサイズ: 8 バイト
}
オーバーフローの挙動:
var x int8 = 127
x = x + 1 // -128(オーバーフロー、ラップアラウンド)
// ビットレベルで見ると:
// 127: 01111111
// +1: + 00000001
// --- ---------
// -128: 10000000
⚠️ 注意:オーバーフローは検出されません!
var x uint8 = 255
x = x + 1 // 0(警告なし)
// 実務ではmath.MaxInt8などの定数を使う
import "math"
if x > math.MaxInt8 - 1 {
// オーバーフローの危険性
}
2.2 浮動小数点のIEEE 754形式
🔑 Goの浮動小数点はIEEE 754標準に従います。
float64のメモリレイアウト(64ビット):
┌──────┬────────────┬────────────────────────────────────────────┐
│ 符号 │ 指数部 │ 仮数部 │
│ 1bit│ 11bits │ 52bits │
└──────┴────────────┴────────────────────────────────────────────┘
63 62 52 51 0
値 = (-1)^符号 × 2^(指数-1023) × (1.仮数部)
float32のメモリレイアウト(32ビット):
┌──────┬────────────┬────────────────────┐
│ 符号 │ 指数部 │ 仮数部 │
│ 1bit│ 8bits │ 23bits │
└──────┴────────────┴────────────────────┘
31 30 23 22 0
値 = (-1)^符号 × 2^(指数-127) × (1.仮数部)
具体例:3.14をfloat64で表現
var pi float64 = 3.14
3.14の内部表現:
1. 二進数に変換
3.14 (10進) = 11.001000111101... (2進)
2. 正規化
11.001000111101... = 1.1001000111101... × 2^1
3. IEEE 754形式で格納
┌─┬───────────┬──────────────────────────────────────────────┐
│0│10000000000│1001000111101011100001010001111010111000010100│
└─┴───────────┴──────────────────────────────────────────────┘
符号 指数 仮数
(正) (1+1023) (1.仮数部)
💡 浮動小数点の精度問題:
// 有名な浮動小数点誤差
a := 0.1 + 0.2
fmt.Println(a) // 0.30000000000000004
fmt.Println(a == 0.3) // false
// 理由:0.1と0.2は二進数で正確に表現できない
// 0.1 (10進) = 0.0001100110011... (2進、無限小数)
// 0.2 (10進) = 0.0011001100110... (2進、無限小数)
安全な浮動小数点比較:
import "math"
func almostEqual(a, b, epsilon float64) bool {
return math.Abs(a-b) < epsilon
}
a := 0.1 + 0.2
b := 0.3
fmt.Println(almostEqual(a, b, 0.00001)) // true
⚠️ 浮動小数点の特殊値:
import "math"
// 無限大
positiveInf := math.Inf(1) // +∞
negativeInf := math.Inf(-1) // -∞
// 非数(Not a Number)
nan := math.NaN()
// 判定
math.IsInf(positiveInf, 1) // true
math.IsNaN(nan) // true
// NaNの特殊な性質
fmt.Println(nan == nan) // false(NaNは自分自身とも等しくない)
2.3 complex128の内部構造
🔑 複素数は実部と虚部の2つの浮動小数点数のペアです。
complex128のメモリレイアウト:
┌────────────────────────────────┬────────────────────────────────┐
│ 実部(float64) │ 虚部(float64) │
│ 64 bits │ 64 bits │
└────────────────────────────────┴────────────────────────────────┘
0 63 64 127
合計:128ビット = 16バイト
複素数の使用例:
// 複素数の作成
var c1 complex128 = 1 + 2i
c2 := complex(3.0, 4.0) // 3 + 4i
// 実部と虚部の取得
r := real(c1) // 1.0
i := imag(c1) // 2.0
// メモリサイズの確認
import "unsafe"
fmt.Println(unsafe.Sizeof(c1)) // 16 (bytes)
// 複素数の演算
c3 := c1 + c2 // (1+2i) + (3+4i) = 4+6i
c4 := c1 * c2 // (1+2i) * (3+4i) = (3-8) + (4+6)i = -5+10i
内部的な演算:
複素数の乗算:(a+bi) × (c+di) = (ac-bd) + (ad+bc)i
メモリレベルでは:
1. 実部同士の乗算:a × c
2. 虚部同士の乗算:b × d
3. 実部の計算:(a×c) - (b×d)
4. 虚部の計算:(a×d) + (b×c)
5. 結果を新しいcomplex128に格納
---
3. 文字列型のメモリ構造
3.1 文字列の内部表現
🔑 Goの文字列は不変(immutable)なバイトスライスへのポインタです。
文字列のメモリレイアウト:
string構造体(runtime.stringStruct)
┌──────────────────┬─────────┐
│ pointer (8byte) │ len (8) │
│ データへのポインタ│ 長さ │
└────────┬─────────┴─────────┘
│
└─────> [actual byte data] ← 不変領域
['H']['e']['l']['l']['o']
実例:
s := "Hello"
// 内部構造
// pointer: バイト配列の先頭アドレス(例:0x1234)
// len: 5
import "unsafe"
import "reflect"
// 文字列構造体のサイズ
fmt.Println(unsafe.Sizeof(s)) // 16 bytes(64bitシステム)
// pointer(8) + len(8)
// 内部構造を見る
sh := (*reflect.StringHeader)(unsafe.Pointer(&s))
fmt.Printf("Data: %x, Len: %d\n", sh.Data, sh.Len)
// Data: 10c3a90, Len: 5
3.2 UTF-8エンコーディング
🔑 Goの文字列はUTF-8でエンコードされたバイト列です。
UTF-8の可変長エンコーディング:
┌─────────────────┬───────────┬─────────────────────────┐
│ 文字範囲 │ バイト数 │ エンコード形式 │
├─────────────────┼───────────┼─────────────────────────┤
│ U+0000..U+007F │ 1 byte │ 0xxxxxxx │
│ U+0080..U+07FF │ 2 bytes │ 110xxxxx 10xxxxxx │
│ U+0800..U+FFFF │ 3 bytes │ 1110xxxx 10xxxxxx ... │
│ U+10000..U+10FFFF│ 4 bytes │ 11110xxx 10xxxxxx ... │
└─────────────────┴───────────┴─────────────────────────┘
具体例:
s := "Hello世界"
メモリ上のバイト配列:
'H' 'e' 'l' 'l' 'o' '世' '界'
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│48 │65 │6C │6C │6F │E4 │B8 │96 │E7 │95 │8C │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
1 2 3 4 5 6 7 8 9 10 11
len(s) = 11 (バイト数)
len([]rune(s)) = 7 (文字数)
ASCIIは1バイト:'H' = 0x48
日本語は3バイト:'世' = 0xE4B896
💡 文字列のイテレーション:
s := "Hello世界"
// バイト単位のループ(非推奨)
for i := 0; i < len(s); i++ {
fmt.Printf("%x ", s[i])
}
// 出力: 48 65 6c 6c 6f e4 b8 96 e7 95 8c
// rune単位のループ(推奨)
for i, r := range s {
fmt.Printf("index: %d, rune: %c (U+%04X)\n", i, r, r)
}
// 出力:
// index: 0, rune: H (U+0048)
// index: 1, rune: e (U+0065)
// index: 2, rune: l (U+006C)
// index: 3, rune: l (U+006C)
// index: 4, rune: o (U+006F)
// index: 5, rune: 世 (U+4E16) ← インデックス5から開始
// index: 8, rune: 界 (U+754C) ← インデックス8から開始
3.3 文字列の不変性
🔑 文字列は不変なので、安全に共有できます。
s1 := "Hello"
s2 := s1 // ポインタのコピー(データはコピーされない)
// s1[0] = 'h' // ❌ コンパイルエラー
メモリ図:
s1 ┌──────────┬────┐
│ pointer │ 5 │
└────┬─────┴────┘
│
└──> ['H']['e']['l']['l']['o'] ← 読み取り専用
↑
│
s2 ┌───┴──────┬────┐
│ pointer │ 5 │
└──────────┴────┘
両方の文字列が同じメモリを参照
→ コピーコストなし
→ 安全(変更不可)
文字列を「変更」する場合:
s := "Hello"
s = "h" + s[1:] // 新しい文字列を作成
// 内部的には:
// 1. "h"という新しい文字列を作成
// 2. s[1:]で"ello"という部分文字列を作成(ポインタ移動)
// 3. "h"と"ello"を結合して新しい文字列を作成
// 4. sを新しい文字列を参照するように更新
---
4. 配列のメモリレイアウト
4.1 配列は値型
🔑 配列は連続したメモリ領域に格納される値型です。
var arr [5]int = [5]int{1, 2, 3, 4, 5}
メモリレイアウト(連続配置):
arr(アドレス: 0x1000)
┌───────┬───────┬───────┬───────┬───────┐
│ 1 │ 2 │ 3 │ 4 │ 5 │
└───────┴───────┴───────┴───────┴───────┘
0x1000 0x1008 0x1010 0x1018 0x1020
各要素:8バイト(int64の場合)
合計サイズ:40バイト
配列のコピー:
a := [3]int{1, 2, 3}
b := a // 全体がコピーされる
b[0] = 100
fmt.Println(a) // [1, 2, 3](変更されない)
fmt.Println(b) // [100, 2, 3]
メモリ図:
a ┌───────┬───────┬───────┐
│ 1 │ 2 │ 3 │
└───────┴───────┴───────┘
0x1000 0x1008 0x1010
コピー操作(memcpy)
↓ ↓ ↓
b ┌───────┬───────┬───────┐
│ 1 │ 2 │ 3 │
└───────┴───────┴───────┘
0x2000 0x2008 0x2010
b[0]を変更
↓
b ┌───────┬───────┬───────┐
│ 100 │ 2 │ 3 │
└───────┴───────┴───────┘
0x2000 0x2008 0x2010
aは変更されない
⚠️ 大きな配列のコピーは高コスト:
type LargeArray [1000000]int
func processArray(arr LargeArray) { // ❌ 8MB のコピー
// ...
}
// 推奨:ポインタを渡す
func processArray(arr *LargeArray) { // ✅ 8バイトのポインタ
// ...
}
// または:スライスを使う
func processSlice(arr []int) { // ✅ 24バイトのスライス構造体
// ...
}
---
5. スライスの内部実装
5.1 スライスの構造体
🔑 スライスは配列への参照、長さ、容量の3要素を持つ構造体です。
スライスの内部構造(runtime.slice):
type slice struct {
array unsafe.Pointer // 配列へのポインタ(8バイト)
len int // 長さ(8バイト)
cap int // 容量(8バイト)
}
// 合計:24バイト(64bitシステム)
メモリ図:
s := make([]int, 3, 5)
s[0] = 10
s[1] = 20
s[2] = 30
スライス構造体 s
┌──────────────┬─────┬─────┐
│ array ptr │ len │ cap │
│ 0x1000 │ 3 │ 5 │
└──────┬───────┴─────┴─────┘
│
└──> 内部配列(容量5)
┌────┬────┬────┬────┬────┐
│ 10 │ 20 │ 30 │ 0 │ 0 │
└────┴────┴────┴────┴────┘
0x1000 使用中 未使用
←───── len=3 ────→
←─────── cap=5 ──────────→
5.2 スライシング操作
🔑 スライシングは新しいスライス構造体を作るが、内部配列は共有します。
original := []int{1, 2, 3, 4, 5}
slice1 := original[1:4] // [2, 3, 4]
slice2 := original[2:] // [3, 4, 5]
メモリ図:
original
┌──────────┬─────┬─────┐
│ array │ 5 │ 5 │
└────┬─────┴─────┴─────┘
│
└──> ┌───┬───┬───┬───┬───┐
│ 1 │ 2 │ 3 │ 4 │ 5 │
└───┴───┴───┴───┴───┘
0x1000
slice1
┌──────────┬─────┬─────┐
│ array │ 3 │ 4 │ len=3(要素2,3,4)
└────┬─────┴─────┴─────┘ cap=4(残り容量)
│
└──> (0x1000 + 8)
┌───┬───┬───┬───┐
│ 2 │ 3 │ 4 │ 5 │
└───┴───┴───┴───┘
slice2
┌──────────┬─────┬─────┐
│ array │ 3 │ 3 │ len=3(要素3,4,5)
└────┬─────┴─────┴─────┘ cap=3(残り容量)
│
└──> (0x1000 + 16)
┌───┬───┬───┐
│ 3 │ 4 │ 5 │
└───┴───┴───┘
⚠️ スライスは内部配列を共有する:
original := []int{1, 2, 3, 4, 5}
slice1 := original[1:4] // [2, 3, 4]
slice1[0] = 100 // 内部配列を変更
fmt.Println(original) // [1, 100, 3, 4, 5] 変更される!
fmt.Println(slice1) // [100, 3, 4]
5.3 appendの内部動作
🔑 appendは容量が足りない場合、新しい配列を確保します。
ケース1:容量が十分な場合
s := make([]int, 3, 5) // len=3, cap=5
s = append(s, 40) // len=4, cap=5
append前:
s ┌──────────┬─────┬─────┐
│ array │ 3 │ 5 │
└────┬─────┴─────┴─────┘
│
└──> ┌────┬────┬────┬────┬────┐
│ 10 │ 20 │ 30 │ 0 │ 0 │
└────┴────┴────┴────┴────┘
append後:
s ┌──────────┬─────┬─────┐
│ array │ 4 │ 5 │ lenが増加
└────┬─────┴─────┴─────┘
│
└──> ┌────┬────┬────┬────┬────┐
│ 10 │ 20 │ 30 │ 40 │ 0 │ 40を追加
└────┴────┴────┴────┴────┘
ケース2:容量が不足する場合
s := []int{1, 2, 3} // len=3, cap=3
s = append(s, 4) // len=4, cap=6(拡張)
append前:
s ┌──────────┬─────┬─────┐
│ 0x1000 │ 3 │ 3 │
└────┬─────┴─────┴─────┘
│
└──> ┌───┬───┬───┐
│ 1 │ 2 │ 3 │
└───┴───┴───┘
0x1000(古い配列)
容量不足を検出
↓
新しい配列を確保(通常2倍)
┌───┬───┬───┬───┬───┬───┐
│ 1 │ 2 │ 3 │ 4 │ 0 │ 0 │
└───┴───┴───┴───┴───┴───┘
0x2000(新しい配列、cap=6)
↓
古いデータをコピー + 新要素を追加
append後:
s ┌──────────┬─────┬─────┐
│ 0x2000 │ 4 │ 6 │ 新しい配列を参照
└────┬─────┴─────┴─────┘
│
└──> ┌───┬───┬───┬───┬───┬───┐
│ 1 │ 2 │ 3 │ 4 │ 0 │ 0 │
└───┴───┴───┴───┴───┴───┘
0x2000
容量拡張のアルゴリズム:
// Go 1.18以降の拡張戦略(簡略版)
func growslice(oldCap, neededCap int) int {
newCap := oldCap
doubleCap := newCap + newCap
if neededCap > doubleCap {
newCap = neededCap
} else {
if oldCap < 256 {
newCap = doubleCap // 小さいスライスは2倍
} else {
// 大きいスライスは1.25倍に近い成長率
for newCap < neededCap {
newCap += (newCap + 3*256) / 4
}
}
}
return newCap
}
💡 パフォーマンス最適化:容量を事前確保
// ❌ 非効率:何度も再割り当て
var s []int
for i := 0; i < 1000; i++ {
s = append(s, i)
}
// 再割り当て回数:約10回
// ✅ 効率的:容量を事前確保
s := make([]int, 0, 1000)
for i := 0; i < 1000; i++ {
s = append(s, i)
}
// 再割り当て回数:0回
5.4 スライスのコピー
🔑 copyは要素を深くコピーします。
src := []int{1, 2, 3, 4, 5}
dst := make([]int, 3)
n := copy(dst, src) // 3要素をコピー
fmt.Println(dst) // [1, 2, 3]
fmt.Println(n) // 3(コピーした要素数)
メモリ図:
src
┌──────────┬─────┬─────┐
│ 0x1000 │ 5 │ 5 │
└────┬─────┴─────┴─────┘
│
└──> ┌───┬───┬───┬───┬───┐
│ 1 │ 2 │ 3 │ 4 │ 5 │
└───┴───┴───┴───┴───┘
0x1000
copy操作(memcpy)
↓ ↓ ↓
dst
┌──────────┬─────┬─────┐
│ 0x2000 │ 3 │ 3 │
└────┬─────┴─────┴─────┘
│
└──> ┌───┬───┬───┐
│ 1 │ 2 │ 3 │ 独立したメモリ
└───┴───┴───┘
0x2000
dst[0]を変更してもsrcは影響を受けない
---
6. マップの内部実装
6.1 マップの構造
🔑 マップはハッシュテーブルで実装されています。
マップの内部構造(簡略版):
runtime.hmap(マップのヘッダ)
┌─────────────┬─────────┬──────────┬────────┐
│ count │ buckets │ oldbuckets│ hash0 │
│ (要素数) │(配列) │(拡張用) │(seed)│
└─────────────┴────┬────┴──────────┴────────┘
│
└──> バケット配列
┌────────┬────────┬────────┐
│bucket 0│bucket 1│bucket 2│...
└────────┴────────┴────────┘
│
└──> バケット構造
┌──────────────────┐
│ tophash[8] │
├──────────────────┤
│ keys[8] │
├──────────────────┤
│ values[8] │
├──────────────────┤
│ overflow pointer │
└──────────────────┘
ハッシュ関数の流れ:
m := make(map[string]int)
m["hello"] = 42
1. キーをハッシュ化
"hello" → hash("hello") → 0x7A8B9C1D2E3F4567
2. バケット番号を計算(下位ビット使用)
hash & (bucketCount - 1) → bucket 7
3. tophashを計算(上位ビット使用)
hash >> 56 → 0x7A
4. バケットに格納
bucket[7]
┌──────────┐
│ tophash │ [0x7A, ...]
├──────────┤
│ keys │ ["hello", ...]
├──────────┤
│ values │ [42, ...]
└──────────┘
6.2 マップの検索
🔑 マップの検索はO(1)の平均時間計算量です。
value, ok := m["hello"]
検索プロセス:
1. キーのハッシュを計算
"hello" → 0x7A8B9C1D2E3F4567
2. バケット番号を計算
hash & (bucketCount - 1) → bucket 7
3. tophashを計算
hash >> 56 → 0x7A
4. バケット内を線形探索
for i := 0; i < 8; i++ {
if tophash[i] == 0x7A {
if keys[i] == "hello" {
return values[i], true
}
}
}
5. オーバーフローバケットをチェック
if overflow != nil {
// オーバーフローバケットも探索
}
6. 見つからない場合
return zeroValue, false
6.3 マップの拡張
🔑 マップは負荷率が高くなると自動的に拡張されます。
拡張のトリガー:
負荷率(load factor)= 要素数 / (バケット数 × 8)
拡張条件:
1. 負荷率 > 6.5 → 2倍に拡張
2. オーバーフローバケットが多すぎる → 同じサイズで再編成
拡張プロセス:
拡張前(4バケット):
┌────┬────┬────┬────┐
│ b0 │ b1 │ b2 │ b3 │ 各バケットに6-8要素
└────┴────┴────┴────┘ 負荷率 > 6.5
拡張開始:
┌────┬────┬────┬────┐
│ b0 │ b1 │ b2 │ b3 │ oldbuckets
└────┴────┴────┴────┘
↓ 段階的に移行(incremental)
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ b0 │ b1 │ b2 │ b3 │ b4 │ b5 │ b6 │ b7 │ 新しいbuckets
└────┴────┴────┴────┴────┴────┴────┴────┘
拡張完了:
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ b0 │ b1 │ b2 │ b3 │ b4 │ b5 │ b6 │ b7 │ 8バケット
└────┴────┴────┴────┴────┴────┴────┴────┘
💡 段階的拡張(Incremental Resizing):
Goのマップは一度に全て移行せず、アクセスごとに少しずつ移行:
map操作ごとに:
1. 要求された操作を実行
2. 最大2つのバケットを旧配列から新配列に移行
3. すべて移行完了するまで繰り返す
メリット:
- 大きな遅延を回避
- 一時的なメモリ使用量を削減
6.4 マップの削除
delete(m, "hello")
削除プロセス:
1. キーを検索(検索と同じプロセス)
2. 見つかった場合:
- tophash[i] を空のマーカーに設定
- keys[i] をゼロ値にクリア
- values[i] をゼロ値にクリア
3. 見つからない場合:
- 何もしない(エラーにならない)
メモリ図:
削除前:
┌─────────┐
│ tophash │ [0x7A, 0x3B, 0x9F, ...]
├─────────┤
│ keys │ ["hello", "world", "foo", ...]
├─────────┤
│ values │ [42, 100, 200, ...]
└─────────┘
delete(m, "hello")後:
┌─────────┐
│ tophash │ [0x00, 0x3B, 0x9F, ...] 空マーカー
├─────────┤
│ keys │ ["", "world", "foo", ...] ゼロ値
├─────────┤
│ values │ [0, 100, 200, ...] ゼロ値
└─────────┘
---
7. 型変換と型アサーション
7.1 暗黙的変換がない理由
🔑 Goは意図しない型変換によるバグを防ぐため、暗黙的変換を禁止しています。
var i int = 42
var f float64 = i // ❌ エラー!
// 明示的変換が必要
var f float64 = float64(i) // ✅ OK
暗黙的変換の危険性(C言語の例):
// C言語では暗黙的変換が許可される
int x = 300;
char c = x; // 暗黙的に変換(データ損失)
printf("%d\n", c); // 44(300 & 0xFF = 44)
💡 Goの設計思想:明示性優先
// 各変換は意図を明示
var i int64 = 42
var i32 int32 = int32(i) // 明示的に縮小
var f float64 = float64(i) // 明示的に型変更
// コンパイラが意図を確認できる
var x int16 = 32767
var y int16 = 1
// var z int16 = x + y // オーバーフローの可能性を認識
7.2 型変換の内部動作
数値型の変換:
var i int64 = 1000
var i32 int32 = int32(i)
メモリレベル:
i(int64):
┌────────────────────────────────────────────────────────────────┐
│ 0x00000000000003E8 │
└────────────────────────────────────────────────────────────────┘
64 bits
int32(i)の変換:
下位32ビットを切り出す
┌────────────────────────────────────┐
│ 0x000003E8 │
└────────────────────────────────────┘
32 bits
i32(int32):
┌────────────────────────────────────┐
│ 0x000003E8 = 1000 │
└────────────────────────────────────┘
⚠️ オーバーフローの危険性:
var i int64 = 0x1FFFFFFFF // 8,589,934,591
var i32 int32 = int32(i)
fmt.Printf("i64: %d (0x%X)\n", i, i)
fmt.Printf("i32: %d (0x%X)\n", i32, i32)
// 出力:
// i64: 8589934591 (0x1FFFFFFFF)
// i32: -1 (0xFFFFFFFF) ← 上位ビットが切り捨てられる
浮動小数点への変換:
var i int = 42
var f float64 = float64(i)
変換プロセス:
1. 整数値(42)をIEEE 754形式に変換
42 (10進) = 101010 (2進) = 1.0101 × 2^5
2. IEEE 754形式で表現
符号:0(正)
指数:5 + 1023 = 1028 = 10000000100 (2進)
仮数:0101000...(52ビット)
3. float64メモリに格納
┌─┬───────────┬────────────────────────────────────────────┐
│0│10000000100│0101000000000000000000000000000000000000000│
└─┴───────────┴────────────────────────────────────────────┘
7.3 型アサーションの内部動作
🔑 型アサーションはインターフェース値の動的型をチェックします。
var i interface{} = 42
// 型アサーション
value, ok := i.(int)
インターフェース値の構造:
interface{}の内部表現(runtime.eface)
┌──────────────┬─────────────────┐
│ _type │ data │
│ (型情報) │ (値へのポインタ)│
└──────┬───────┴────────┬────────┘
│ │
└──> *_type └──> 実際の値
┌────────┐ ┌────┐
│ int型 │ │ 42 │
└────────┘ └────┘
型アサーションのプロセス:
value, ok := i.(int)
1. インターフェース値の型情報を取得
_type → *int型
2. 要求された型と比較
要求: int型
実際: int型
→ 一致!
3. データポインタから値をコピー
value = *data // 42
4. 成功フラグを設定
ok = true
メモリ操作:
┌─────────┬────────┐
│ _type │ data │ interface{}
└────┬────┴───┬────┘
│ │
↓ ↓
確認:int? 値をコピー
YES! value = 42
ok = true
型アサーション失敗の場合:
var i interface{} = "hello"
value, ok := i.(int)
1. 型情報を比較
要求: int型
実際: string型
→ 不一致!
2. ゼロ値を返す
value = 0 // intのゼロ値
3. 失敗フラグを設定
ok = false
7.4 型スイッチの実装
func describe(i interface{}) {
switch v := i.(type) {
case int:
fmt.Printf("整数: %d\n", v)
case string:
fmt.Printf("文字列: %s\n", v)
default:
fmt.Printf("不明な型: %T\n", v)
}
}
型スイッチの内部動作:
コンパイル後の疑似コード:
func describe(i interface{}) {
// インターフェースの型情報を取得
_type := i._type
// 各caseで型を比較
if _type == typeof(int) {
v := *(*int)(i.data)
fmt.Printf("整数: %d\n", v)
return
}
if _type == typeof(string) {
v := *(*string)(i.data)
fmt.Printf("文字列: %s\n", v)
return
}
// default
fmt.Printf("不明な型: %T\n", i)
}
---
8. 名前付き型とエイリアス
8.1 type定義の違い
🔑 Goには2種類の型定義があります。
名前付き型(Named Type):
type Celsius float64 // 新しい型を定義
type Fahrenheit float64
メモリ上は同じfloat64だが、型システムでは別物:
Celsius
┌─────────────────────────────────────────────────────────────┐
│ underlying type: float64 │
│ methods: 独自のメソッドを持てる │
└─────────────────────────────────────────────────────────────┘
Fahrenheit
┌─────────────────────────────────────────────────────────────┐
│ underlying type: float64 │
│ methods: Celsiusとは別のメソッド │
└─────────────────────────────────────────────────────────────┘
float64
┌─────────────────────────────────────────────────────────────┐
│ 組み込み型 │
└─────────────────────────────────────────────────────────────┘
互換性なし:
var c Celsius = 100.0
var f Fahrenheit = c // ❌ エラー!型が異なる
型エイリアス(Type Alias):
type MyInt = int // エイリアス(完全に同じ型)
MyIntとintは完全に同一:
MyInt = int
┌─────────────────────────────────────────────────────────────┐
│ 同じ型(エイリアスは名前だけ異なる) │
│ methods: intのメソッドをすべて共有 │
└─────────────────────────────────────────────────────────────┘
互換性あり:
var x MyInt = 42
var y int = x // ✅ OK(同じ型)
8.2 underlying typeの概念
🔑 すべての名前付き型は「underlying type」を持ちます。
type Celsius float64
func main() {
var c Celsius = 100.0
// underlying typeへの変換
var f float64 = float64(c) // OK
// underlying typeからの変換
c = Celsius(98.6) // OK
}
underlying typeの連鎖:
type A int
type B A
type C B
// underlying typeの連鎖
// C → B → A → int
// すべてのunderlying typeは「int」
型の階層:
C (型)
└─> underlying type: B
└─> underlying type: A
└─> underlying type: int (基本型)
変換の必要性:
var a A = 10
var b B = a // ❌ エラー(AとBは異なる型)
var b B = B(a) // ✅ OK(明示的変換)
var c C = C(b) // ✅ OK
// intへの変換
var i int = int(c) // ✅ OK(underlying typeへの変換)
8.3 メソッドの継承
🔑 名前付き型は独自のメソッドを持てますが、継承はありません。
type Celsius float64
func (c Celsius) ToFahrenheit() Fahrenheit {
return Fahrenheit(c*9/5 + 32)
}
func main() {
c := Celsius(100)
f := c.ToFahrenheit() // Celsiusのメソッド
// float64には変換できるが、メソッドは失われる
var x float64 = float64(c)
// x.ToFahrenheit() // ❌ エラー!メソッドなし
}
メソッドの添付:
Celsius型
┌─────────────────────────────────────────┐
│ underlying type: float64 │
│ │
│ methods: │
│ - ToFahrenheit() Fahrenheit │
│ - String() string │
└─────────────────────────────────────────┘
float64への変換
↓
┌─────────────────────────────────────────┐
│ float64 │
│ │
│ methods: │
│ (メソッドなし) │
└─────────────────────────────────────────┘
メソッドは型に紐づく!
---
9. ゼロ値の設計思想
9.1 ゼロ値とは
🔑 Goのすべての型には「ゼロ値」があります。
各型のゼロ値一覧:
┌──────────────────┬─────────────────────────┐
│ 型 │ ゼロ値 │
├──────────────────┼─────────────────────────┤
│ bool │ false │
│ 数値型 │ 0 │
│ string │ ""(空文字列) │
│ pointer │ nil │
│ slice │ nil │
│ map │ nil │
│ channel │ nil │
│ interface │ nil │
│ function │ nil │
│ struct │ 各フィールドのゼロ値 │
│ array │ 各要素のゼロ値 │
└──────────────────┴─────────────────────────┘
ゼロ値の初期化:
var i int // 0
var f float64 // 0.0
var s string // ""
var b bool // false
var p *int // nil
var sl []int // nil
var m map[string]int // nil
// 構造体のゼロ値
type Person struct {
Name string
Age int
}
var person Person // Person{Name: "", Age: 0}
9.2 ゼロ値が有用な理由
💡 ゼロ値はそのまま使える値として設計されています。
例1:sync.Mutex
type SafeCounter struct {
mu sync.Mutex // ゼロ値で使える!
count int
}
func (c *SafeCounter) Inc() {
c.mu.Lock() // 初期化不要
c.count++
c.mu.Unlock()
}
func main() {
var counter SafeCounter // ゼロ値で初期化
counter.Inc() // そのまま使える
}
例2:bytes.Buffer
var buf bytes.Buffer // ゼロ値で使える
buf.WriteString("Hello") // 初期化不要
buf.WriteString(" World")
fmt.Println(buf.String()) // "Hello World"
例3:スライスのnil
var s []int // nil
// nilスライスはappendできる
s = append(s, 1, 2, 3) // OK
// nilスライスのlenとcapは0
fmt.Println(len(s)) // 0 → 3(append後)
fmt.Println(cap(s)) // 0 → 4(append後)
9.3 ゼロ値のメモリ表現
基本型のゼロ値:
int(ゼロ値: 0)
┌────────────────────────────────────────────────────────────────┐
│ 0x0000000000000000 │
└────────────────────────────────────────────────────────────────┘
64 bits すべて0
bool(ゼロ値: false)
┌────┐
│ 0 │
└────┘
8 bits
string(ゼロ値: "")
┌──────────────────┬─────────┐
│ pointer = nil │ len = 0 │
│ 0x0000000000 │ 0 │
└──────────────────┴─────────┘
slice(ゼロ値: nil)
┌──────────────────┬─────────┬─────────┐
│ array = nil │ len = 0 │ cap = 0 │
│ 0x0000000000 │ 0 │ 0 │
└──────────────────┴─────────┴─────────┘
構造体のゼロ値:
type Point struct {
X int
Y int
}
var p Point // ゼロ値
メモリレイアウト:
┌─────────────┬─────────────┐
│ X = 0 │ Y = 0 │
│ 8 bytes │ 8 bytes │
└─────────────┴─────────────┘
すべてのフィールドがゼロ値で初期化される
→ メモリはゼロクリアされる
9.4 nilの特殊性
🔑 nilはポインタ型、スライス、マップ、チャネル、インターフェース、関数のゼロ値です。
nilの挙動の違い:
// スライス:nilでもappend可能
var s []int // nil
s = append(s, 1) // ✅ OK
// マップ:nilに書き込み不可
var m map[string]int // nil
// m["key"] = 1 // ❌ panic: assignment to entry in nil map
m = make(map[string]int)
m["key"] = 1 // ✅ OK
// ポインタ:nilをデリファレンス不可
var p *int // nil
// x := *p // ❌ panic: nil pointer dereference
nilインターフェースの罠:
func returnsError() error {
var p *MyError = nil
return p // 非nilインターフェース!
}
func main() {
err := returnsError()
fmt.Println(err == nil) // false(予想外!)
}
なぜfalseなのか?
interface{}の内部構造:
┌──────────────┬─────────────────┐
│ _type │ data │
└──────────────┴─────────────────┘
returnsError()が返す値:
┌──────────────┬─────────────────┐
│ *MyError型 │ nil │
└──────────────┴─────────────────┘
↑
型情報あり!→ nilではない
真のnilインターフェース:
┌──────────────┬─────────────────┐
│ nil │ nil │
└──────────────┴─────────────────┘
正しい書き方:
func returnsError() error {
var p *MyError = nil
if p != nil {
return p
}
return nil // ✅ 真のnilを返す
}
---
10. 実践的な型の使い方
10.1 型の選択基準
整数型の選択:
// ✅ 推奨:通常の整数
age := 25 // int(デフォルト)
// ✅ 推奨:バイトデータ
data := []byte{0x48, 0x65, 0x6C, 0x6C, 0x6F}
// ✅ 推奨:Unicode文字
char := 'A' // rune(int32)
// ❌ 避ける:過剰な最適化
var age int8 = 25 // intで十分
浮動小数点の選択:
// ✅ 推奨:科学計算、グラフィックス
var distance float64 = 384400.0 // km
// ❌ 避ける:金額計算
var price float64 = 19.99 // 誤差の危険性
// ✅ 推奨:金額は整数(セント単位)
var priceInCents int = 1999 // 19.99ドル
文字列 vs バイトスライス:
// ✅ 文字列:テキストデータ、不変
name := "Alice"
// ✅ バイトスライス:バイナリデータ、可変
data := []byte{0xFF, 0xFE, 0xFD}
// 変換
s := "Hello"
b := []byte(s) // stringからbyte sliceへ(コピー)
s = string(b) // byte sliceからstringへ(コピー)
10.2 パフォーマンスの考慮
スライスの事前確保:
// ❌ 非効率
var result []int
for i := 0; i < 1000; i++ {
result = append(result, i)
}
// 再割り当て回数:約10回(0→1→2→4→8→16→...→1024)
// ✅ 効率的
result := make([]int, 0, 1000)
for i := 0; i < 1000; i++ {
result = append(result, i)
}
// 再割り当て回数:0回
マップの事前確保:
// ❌ 非効率
m := make(map[string]int)
// ✅ 効率的(要素数がわかっている場合)
m := make(map[string]int, 1000)
文字列の結合:
// ❌ 非効率(多数の結合)
var result string
for i := 0; i < 1000; i++ {
result += fmt.Sprintf("%d,", i) // 毎回新しい文字列を作成
}
// ✅ 効率的
var builder strings.Builder
builder.Grow(5000) // 容量を事前確保
for i := 0; i < 1000; i++ {
builder.WriteString(fmt.Sprintf("%d,", i))
}
result := builder.String()
10.3 型安全性の活用
名前付き型で意図を明示:
type UserID int
type ProductID int
func getUser(id UserID) User { /* ... */ }
func getProduct(id ProductID) Product { /* ... */ }
func main() {
userID := UserID(123)
productID := ProductID(456)
user := getUser(userID) // ✅ OK
// user := getUser(productID) // ❌ コンパイルエラー!
}
カスタム型でバリデーション:
type Email string
func NewEmail(s string) (Email, error) {
if !strings.Contains(s, "@") {
return "", errors.New("invalid email")
}
return Email(s), nil
}
func sendEmail(to Email) error {
// toは必ず有効なメール形式
// ...
}
---
11. 自己チェック問題
問題1:型システムの基礎
🔑 Q: Goが静的型付け言語であることのメリットを3つ挙げてください。
解答を見る
A:
- 型エラーをコンパイル時に検出:実行前にバグを発見できる
- パフォーマンスの向上:実行時の型チェックが不要
- コードの可読性向上:型がドキュメントとして機能
問題2:整数のオーバーフロー
🔑 Q: 以下のコードの出力を予測してください。
var x int8 = 127
x = x + 1
fmt.Println(x)
解答を見る
A: -128
理由:
- int8の範囲:-128~127
- 127 + 1 = 128はオーバーフローし、-128にラップアラウンド
- ビットレベル:01111111 + 1 = 10000000(2の補数表現で-128)
問題3:浮動小数点の精度
🔑 Q: なぜ 0.1 + 0.2 == 0.3 は false になるのですか?
解答を見る
A: 0.1と0.2は二進数で正確に表現できないため。
詳細:
- 0.1 (10進) = 0.0001100110011...(2進、無限小数)
- IEEE 754形式で丸め誤差が発生
- 結果:0.30000000000000004
解決策:
epsilon := 0.00001
if math.Abs((0.1 + 0.2) - 0.3) < epsilon {
// ほぼ等しい
}
問題4:文字列の長さ
🔑 Q: 以下のコードの出力を予測してください。
s := "こんにちは"
fmt.Println(len(s))
fmt.Println(len([]rune(s)))
解答を見る
A:
15
5
理由:
len(s)はバイト数を返す(UTF-8で日本語は3バイト × 5文字 = 15)len([]rune(s))は文字数を返す(5文字)
問題5:配列とスライスの違い
🔑 Q: 以下のコードの出力を予測してください。
a := [3]int{1, 2, 3}
b := a
b[0] = 100
fmt.Println(a[0])
fmt.Println(b[0])
解答を見る
A:
1
100
理由:
- 配列は値型なので、
b := aで全体がコピーされる - bを変更してもaは影響を受けない
問題6:スライスの容量
🔑 Q: 以下のコードで、スライス s の長さと容量を答えてください。
s := make([]int, 3, 5)
s = append(s, 10, 20)
解答を見る
A:
- 長さ(len):5
- 容量(cap):5
理由:
- 初期:len=3, cap=5
- append後:len=5(3+2要素), cap=5(拡張なし)
問題7:スライスの共有
🔑 Q: 以下のコードの出力を予測してください。
original := []int{1, 2, 3, 4, 5}
slice1 := original[1:4]
slice1[0] = 100
fmt.Println(original)
fmt.Println(slice1)
解答を見る
A:
[1 100 3 4 5]
[100 3 4]
理由:
- スライシングは内部配列を共有する
slice1[0]はoriginal[1]と同じメモリを参照- 片方を変更すると、もう片方も変更される
問題8:マップのゼロ値
🔑 Q: 以下のコードは正しく動作しますか?エラーになる場合、理由を説明してください。
var m map[string]int
value := m["key"]
fmt.Println(value)
解答を見る
A: 正しく動作します。出力は 0。
理由:
- nilマップからの読み取りは許可されている
- 存在しないキーのゼロ値(intの場合は0)を返す
- 書き込みはpanic(
m["key"] = 1は不可)
問題9:型アサーション
🔑 Q: 以下のコードの出力を予測してください。
var i interface{} = "hello"
value, ok := i.(int)
fmt.Println(value, ok)
解答を見る
A:
0 false
理由:
iは実際にはstring型- int型への型アサーションは失敗
- ゼロ値(0)とfalseが返される
問題10:nilインターフェース
🔑 Q: 以下のコードの出力を予測してください。
func getError() error {
var err *os.PathError = nil
return err
}
func main() {
err := getError()
fmt.Println(err == nil)
}
解答を見る
A: false
理由:
- インターフェースは(型情報、値)のペア
- 返されるerrorは(*os.PathError型、nil値)
- 型情報があるため、nilインターフェースではない
- 真のnilは(nil型、nil値)
問題11:appendの再割り当て
🔑 Q: 以下のコードで、新しい配列が確保されるのは何回目のappendですか?
s := make([]int, 0, 4)
s = append(s, 1) // 1回目
s = append(s, 2) // 2回目
s = append(s, 3) // 3回目
s = append(s, 4) // 4回目
s = append(s, 5) // 5回目
解答を見る
A: 5回目
理由:
- 初期容量:4
- 1~4回目:容量内なので再割り当てなし
- 5回目:容量不足(len=4, cap=4)→新しい配列確保(cap=8に拡張)
問題12:型の互換性
🔑 Q: 以下のコードはコンパイルできますか?
type Celsius float64
type Fahrenheit float64
var c Celsius = 100.0
var f Fahrenheit = c
解答を見る
A: コンパイルエラー
理由:
- CelsiusとFahrenheitは異なる名前付き型
- underlying typeは同じ(float64)だが、型は互換性がない
- 明示的変換が必要:
var f Fahrenheit = Fahrenheit(c)
問題13:型エイリアス
🔑 Q: 以下の2つの型定義の違いを説明してください。
type MyInt1 int // (1)
type MyInt2 = int // (2)
解答を見る
A:
- (1) 名前付き型:intとは異なる新しい型。独自のメソッドを持てる。
- (2) 型エイリアス:intの別名。完全に同じ型として扱われる。
互換性:
var i1 MyInt1 = 10
var i2 MyInt2 = 20
var i int = 30
// i = i1 // ❌ エラー(異なる型)
i = i2 // ✅ OK(同じ型)
問題14:ゼロ値の有用性
🔑 Q: 以下のコードは正しく動作しますか?理由を説明してください。
var mu sync.Mutex
mu.Lock()
// クリティカルセクション
mu.Unlock()
解答を見る
A: 正しく動作します。
理由:
sync.Mutexのゼロ値はそのまま使える(Unlocked状態)- 明示的な初期化が不要
- Goの「ゼロ値は有用」という設計思想の好例
問題15:パフォーマンス最適化
🔑 Q: 10,000要素をappendする場合、以下のどちらが効率的ですか?
// A
var s []int
for i := 0; i < 10000; i++ {
s = append(s, i)
}
// B
s := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
s = append(s, i)
}
解答を見る
A: Bが効率的
理由:
- A: 容量不足のたびに再割り当て(約14回)、毎回コピーが発生
- B: 容量を事前確保、再割り当てなし、コピーなし
パフォーマンス差:
- A: O(n log n) の時間(再割り当てとコピー)
- B: O(n) の時間(追加のみ)
---
まとめ
この章では、Goの型システムをメモリレベルから深く理解しました。
重要ポイント
🔑 型システムの設計:
- 静的型付け:コンパイル時にすべての型をチェック
- 型安全性:意図しない型変換を防ぐ
- 明示的変換:暗黙的変換は禁止
🔑 メモリレイアウト:
- 整数:固定サイズ、2の補数表現
- 浮動小数点:IEEE 754形式、精度に注意
- 文字列:不変、UTF-8、ポインタ+長さ
- スライス:ポインタ+長さ+容量の構造体
- マップ:ハッシュテーブル、段階的拡張
🔑 型変換:
- 明示的変換:
type(value)形式 - 型アサーション:インターフェースの動的型チェック
- 名前付き型:underlying typeは共有、メソッドは独立
🔑 ゼロ値:
- すべての型にゼロ値:未初期化でも安全
- ゼロ値は有用:そのまま使える設計
- nil:ポインタ型、スライス、マップ、インターフェースのゼロ値
実践での使い分け
✅ 推奨パターン:
- 整数:デフォルトは
int、用途に応じてbyte、rune - 浮動小数点:科学計算は
float64、金額は整数 - 文字列:テキストは
string、バイナリは[]byte - コレクション:可変長は
slice、キー検索はmap - 型安全性:名前付き型で意図を明示
⚠️ 避けるべきパターン:
- 過剰な最適化(
int8の乱用) - 浮動小数点での金額計算
- 容量を確保しない大量の
append - nilマップへの書き込み
次のステップ
次の章では、制御構造(if、for、switch)を学びます。型システムの知識を活かして、より安全で効率的なコードを書けるようになりましょう。
💡 継続学習のヒント:
- 各型のメモリサイズを
unsafe.Sizeof()で確認 - スライスの容量変化を観察
- 型アサーションと型スイッチを使い分ける
- カスタム型を活用して型安全性を高める