1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Go言語 データ型・構造体・スコープを深掘りする

1
Posted at

Go言語の変数宣言や基本文法(var:=ifforslicemapstructなど)の続きとして、本記事ではデータ型・構造体・変数のスコープをさらに詳しく扱います。

具体的には、次のテーマを扱います。

  • 整数・浮動小数点・byte・runeなど、型の使い分け
  • ゼロ値という考え方
  • 型変換のルール
  • 定数とiotaによる列挙
  • 変数のスコープとシャドーイング
  • ポインタと値渡し・参照渡し
  • 配列とスライスの違い、スライスが配列を共有する仕組み
  • 構造体のコピーと埋め込み
  • mapのゼロ値と注意点

あわせて、これらのテーマに関連してつまずきやすいポイントを、実際にコンパイルエラーやpanicを発生させながら確認します。

学習を進めるために

なお、「Google ColabでGoのプログラムを動かすなんてできるの?」で引っかかってる読者は、まず下記記事で予習してください。

また、「Go言語の文法を、まずは全体的に俯瞰したい!」という読者は、下記を参考にしてみてください。

すぐに使えるチートシートはこちら Google Colab版

すぐに実行して、試せるコードレシピはこちら。

1. 整数型を使い分ける

Goには複数の整数型があります。

  • int8 / int16 / int32 / int64(符号あり)
  • uint8 / uint16 / uint32 / uint64(符号なし)
  • int / uint(環境依存。64bit環境では通常64bit)

数値の範囲や、符号の有無によって使い分けます。

package main

import "fmt"

func main() {
	var a int8 = 100
	var b uint8 = 200
	var c int64 = 10000000000
	var d float32 = 1.5
	var e float64 = 1.23456789012345

	fmt.Println(a, b, c, d, e)
}
100 200 10000000000 1.5 1.23456789012345

int8-128〜127uint80〜255 の範囲しか表現できません。この範囲を超える値を定数として直接代入しようとすると、コンパイル時にエラーになります。

エラー例1: 定数のオーバーフロー

int8 に、範囲を超える 128 を代入してみます。

package main

import "fmt"

func main() {
	var a int8 = 128
	fmt.Println(a)
}
# command-line-arguments
./main.go:6:15: cannot use 128 (untyped int constant) as int8 value in variable declaration (overflows)

128int8 として扱うには大きすぎる値(オーバーフロー)だと判断され、変数宣言の時点でエラーになっています。実行時ではなく、コンパイルの時点でエラーになる点がポイントです。128 はコード上に直接書かれた定数のため、コンパイラが値を静的に把握でき、事前にチェックできます。修正するには、型を int16int に変えます。

2. byteとruneの正体

Goで文字を1つ扱う型として、byterune があります。

  • byteuint8 のエイリアス(別名)
  • runeint32 のエイリアス(別名)

byte はASCII文字やバイト列を扱う際に、rune はUnicodeの1文字(マルチバイト文字を含む)を扱う際に使います。

package main

import "fmt"

func main() {
	var b byte = 'A'
	var r rune = '雨'

	fmt.Printf("%c %T\n", b, b)
	fmt.Printf("%c %T\n", r, r)
}
A uint8
雨 int32

%T は値の型を表示する書式です。byteuint8runeint32 として表示されており、これらが単なるエイリアスであることがわかります。

3. ゼロ値という考え方

Goでは、変数を宣言しただけで値を指定しない場合、型ごとに決まった初期値(ゼロ値)が入ります。

package main

import "fmt"

type User struct {
	Name string
	Age  int
}

func main() {
	var i int
	var s string
	var b bool
	var f float64
	var u User
	var sl []int
	var m map[string]int
	var p *int

	fmt.Println(i, s, b, f)
	fmt.Println(u)
	fmt.Println(sl, sl == nil)
	fmt.Println(m, m == nil)
	fmt.Println(p)
}
0  false 0
{ 0}
[] true
map[] true
<nil>

型ごとのゼロ値は次のとおりです。

  • 数値型(intfloat64など) → 0
  • string""(空文字列)
  • boolfalse
  • struct → 各フィールドがそれぞれのゼロ値になったもの
  • slicenil(見た目は [] だが、nilと等しい)
  • mapnil
  • ポインタ → nil

slicemap はどちらも nil になりますが、扱い方には違いがあります。これは後述します。

4. 型変換は明示的に行う

Goでは、異なる型どうしの演算を暗黙に変換してくれません。intfloat64 を直接足すこともできません。

エラー例2: 型の不一致

int 型の変数と float64 型の変数を、そのまま足してみます。

package main

import "fmt"

func main() {
	var a int = 10
	var b float64 = 3.5

	result := a + b
	fmt.Println(result)
}
# command-line-arguments
./main.go:9:12: invalid operation: a + b (mismatched types int and float64)

Goでは、型が違う値どうしの演算はコンパイルエラーになります。演算する前に、型を明示的にそろえる必要があります。

package main

import "fmt"

func main() {
	var a int = 10
	var b float64 = 3.5

	result := float64(a) + b
	fmt.Println(result)
}
13.5

float64(a) のように、型名(値) の形で明示的に変換します。この明示性が、Pythonなど動的型付け言語との大きな違いです。

5. 定数とiotaで列挙を表現する

Goには列挙型(enum)専用の構文はありませんが、constiota を組み合わせて、連番の定数を表現できます。

package main

import "fmt"

type Weekday int

const (
	Sunday Weekday = iota
	Monday
	Tuesday
	Wednesday
	Thursday
	Friday
	Saturday
)

func main() {
	fmt.Println(Sunday, Monday, Tuesday)
	fmt.Println(Wednesday)
}
0 1 2
3

iota は、const のブロック内で 0 から始まり、1行ごとに1ずつ増える値です。曜日のような「順序のある固定の選択肢」を表すのに使われます。

6. 変数のスコープ

Goの変数は、{ } で囲まれたブロックの中で有効です。iffor・関数の中括弧はもちろん、単独の { } もスコープを作ります。

package main

import "fmt"

func main() {
	x := 1
	{
		y := 2
		fmt.Println(x, y)
	}
	fmt.Println(x)
}
1 2
1

y は内側の { } の中でしか使えません。もし { } の外で y を参照すると、コンパイルエラーになります(undefined: y)。if 文や for 文の条件部分で宣言した変数(if err := f(); err != nil { ... }err など)も、同様にそのブロック内でのみ有効です。

7. シャドーイングに注意する

:= は、同じ名前の変数がすでに外側のスコープにあっても、内側のブロックで新しい変数を作ります。これをシャドーイング(変数の隠蔽)と呼びます。

package main

import "fmt"

func main() {
	x := 10
	if x > 5 {
		x := 100
		fmt.Println("inside:", x)
	}
	fmt.Println("outside:", x)
}
inside: 100
outside: 10

if ブロックの中の x := 100 は、外側の x とは別の変数です。ブロックを抜けると、外側の x10 のままです。

シャドーイングは、err のような使い回しの多い変数名で、意図せず発生することがあります。

package main

import (
	"fmt"
	"strconv"
)

func main() {
	n, err := strconv.Atoi("10")
	if err != nil {
		fmt.Println(err)
	}

	if true {
		n, err := strconv.Atoi("20") // ここで新しいn, errが作られている
		fmt.Println("inner n:", n, err)
	}

	fmt.Println("outer n:", n)
}
inner n: 20 <nil>
outer n: 10

内側の if ブロックで n, err := strconv.Atoi("20") と書くと、外側の n とは別の変数が新しく作られます。そのため、外側の n10 のまま変わりません。コンパイルエラーにはならず、実行結果を見て初めて気づくタイプの間違いです。意図せぬ再宣言を避けるには、新しい変数を作る := と、既存の変数に代入する = を、意識して使い分ける必要があります。

エラー例3: 宣言したのに使っていない変数

Goでは、ローカル変数を宣言しても一度も使わないと、コンパイルエラーになります。

package main

import "fmt"

func main() {
	x := 10
	y := 20
	fmt.Println(x)
}
# command-line-arguments
./main.go:7:2: y declared but not used

y を宣言したものの、一度も使っていないためエラーになっています。同様に、使っていない import があった場合もコンパイルエラーになります。デバッグ用に一時的に変数を増やしたまま消し忘れると、このエラーに遭遇しやすいです。なお、Go 1.20以降ではこのメッセージが declared and not used: y という表記に変わっています。

8. ポインタで値を書き換える

Goの関数は、基本的に値のコピーを受け取ります(値渡し)。関数の中で引数を書き換えても、呼び出し元の変数には影響しません。呼び出し元の値を書き換えたい場合は、ポインタ(*T&x)を使います。

package main

import "fmt"

func addTen(n int) {
	n = n + 10
}

func addTenPtr(n *int) {
	*n = *n + 10
}

func main() {
	x := 1

	addTen(x)
	fmt.Println("after addTen:", x)

	addTenPtr(&x)
	fmt.Println("after addTenPtr:", x)
}
after addTen: 1
after addTenPtr: 11

addTenx のコピーを受け取って書き換えているだけなので、呼び出し元の x は変わりません。addTenPtr&xx のアドレス)を受け取り、*n(アドレス先の値)を直接書き換えているため、呼び出し元の x11 になります。

9. 配列とスライスの違い

Goには、長さが固定の「配列」と、長さが可変の「スライス」があります。

package main

import "fmt"

func main() {
	var arr [3]int
	arr[0] = 1
	arr[1] = 2
	arr[2] = 3
	fmt.Println(arr, len(arr))

	sl := []int{1, 2, 3}
	sl = append(sl, 4)
	fmt.Println(sl, len(sl))
}
[1 2 3] 3
[1 2 3 4] 4

[3]int のように [ ] の中に数値を書くと配列(長さ固定)、[]int のように空にするとスライス(長さ可変)になります。配列は長さも型の一部なので、[3]int[4]int は別の型として扱われます。実務では、ほとんどの場合スライスを使います。

10. スライスは配列を共有している

スライスは、内部的には配列の一部を指す「窓」のようなものです。スライスから別のスライスを切り出すと、同じ配列を共有します。

package main

import "fmt"

func main() {
	a := []int{1, 2, 3, 4, 5}
	b := a[1:3]

	b[0] = 999

	fmt.Println("a:", a)
	fmt.Println("b:", b)
}
a: [1 999 3 4 5]
b: [999 3]

b := a[1:3] は、a と同じ配列を参照するスライスを作ります。b[0] = 999 で書き換えると、共有している配列そのものが変わるため、a[1]999 に変わります。関数に slice を渡すときも同様で、コピーされるのは「配列へのポインタ・長さ・容量」を持つスライスヘッダのみで、中身の配列は共有されます。意図せず元のスライスまで書き換えてしまう典型的な間違いです。

一方で、append によって容量(capacity)を超えると、新しい配列が確保され、共有関係が切れます。

package main

import "fmt"

func main() {
	a := make([]int, 3, 3) // 長さ3、容量3
	a[0], a[1], a[2] = 1, 2, 3

	b := a
	b = append(b, 4) // 容量を超えるため、新しい配列が確保される
	b[0] = 999

	fmt.Println("a:", a)
	fmt.Println("b:", b)
}
a: [1 2 3]
b: [999 2 3 4]

a の容量はちょうど 3 なので、append で4個目の要素を追加すると新しい配列が確保されます。その結果、ba とは別の配列を参照するようになり、b[0] = 999a に影響しません。「共有されるかどうかは容量次第」という点が、スライス特有の注意点です。

エラー例4: スライスの範囲外アクセス

スライスの長さを超えたインデックスにアクセスすると、実行時にpanicになります。

package main

import "fmt"

func main() {
	nums := []int{1, 2, 3}
	fmt.Println(nums[5])
}
panic: runtime error: index out of range [5] with length 3

goroutine 1 [running]:
main.main()
	/content/main.go:7 +0x1d
exit status 2

インデックスが定数の場合でも、コンパイルエラーにはならず、実行時のpanicになります(配列と違い、スライスの長さは実行時にしかわからないためです)。goroutine以下のスタックトレースの行番号やアドレスは、環境によって変わります。

11. mapのゼロ値はnil

map のゼロ値は nil です。nil のmapは、読み取りはできますが、書き込みはできません

エラー例5: nilマップへの書き込み

package main

import "fmt"

func main() {
	var m map[string]int

	fmt.Println(m["apple"]) // 読み取りはOK(ゼロ値の0が返る)
	m["apple"] = 100        // 書き込みはpanic
}
0
panic: assignment to entry in nil map

goroutine 1 [running]:
main.main()
	/content/main.go:9 +0x90
exit status 2

存在しないキーを読み取ると、値の型のゼロ値(int なら 0)が返るだけで、エラーにはなりません。一方、nil のmapに書き込もうとすると、実行時panicになります。書き込みが必要なmapは、make(map[string]int) または map[string]int{} で初期化してから使います。

12. 構造体は値としてコピーされる

struct は、変数への代入や関数への引数渡しの際、値としてコピーされます。スライスやmapとは異なる挙動です。

package main

import "fmt"

type Point struct {
	X, Y int
}

func main() {
	p1 := Point{X: 1, Y: 2}
	p2 := p1

	p2.X = 100

	fmt.Println("p1:", p1)
	fmt.Println("p2:", p2)
}
p1: {1 2}
p2: {100 2}

p2 := p1 の時点で、p1 の内容がまるごとコピーされます。p2 を書き換えても p1 には影響しません。これはメソッドのレシーバでも同様です。

package main

import "fmt"

type Point struct {
	X, Y int
}

func (p Point) MoveByValue(dx, dy int) {
	p.X += dx
	p.Y += dy
}

func (p *Point) MoveByPointer(dx, dy int) {
	p.X += dx
	p.Y += dy
}

func main() {
	p := Point{X: 1, Y: 1}

	p.MoveByValue(10, 10)
	fmt.Println("after MoveByValue:", p)

	p.MoveByPointer(10, 10)
	fmt.Println("after MoveByPointer:", p)
}
after MoveByValue: {1 1}
after MoveByPointer: {11 11}

func (p Point) MoveByValue(...) は値レシーバなので、p はコピーです。メソッド内で書き換えても、呼び出し元の p は変わりません。func (p *Point) MoveByPointer(...) はポインタレシーバなので、呼び出し元の p を直接書き換えられます。構造体の値を変更するメソッドを定義する場合は、ポインタレシーバを使うのが基本です。

13. 構造体の埋め込みでデータをまとめる

Goには継承(inheritance)はありませんが、構造体の中に別の構造体を埋め込むことで、フィールドやメソッドを引き継いだような書き方ができます。

package main

import "fmt"

type Animal struct {
	Name string
}

func (a Animal) Speak() {
	fmt.Println(a.Name, "makes a sound")
}

type Dog struct {
	Animal
	Breed string
}

func main() {
	d := Dog{
		Animal: Animal{Name: "Pochi"},
		Breed:  "Shiba",
	}

	d.Speak()
	fmt.Println(d.Name, d.Breed)
}
Pochi makes a sound
Pochi Shiba

DogAnimal を型名だけで埋め込んでいます(Animal フィールドという名前を持ちますが、フィールド名は型名と同じ Animal になります)。これにより、d.Named.Speak() のように、Animal が持つフィールドやメソッドを、あたかも Dog 自身が持っているかのように呼び出せます。継承ではなく、あくまで「構造体を内部に持つ」コンポジション(合成)である点が、クラスベースの言語との違いです。

まとめ

本記事で扱ったエラー・panicの一覧です。

# 内容 発生タイミング メッセージ(要約)
1 定数のオーバーフロー コンパイル時 cannot use 128 ... as int8 value ... (overflows)
2 型の不一致 コンパイル時 invalid operation: ... (mismatched types int and float64)
3 未使用変数 コンパイル時 y declared but not used
4 スライスの範囲外アクセス 実行時(panic) index out of range [5] with length 3
5 nilマップへの書き込み 実行時(panic) assignment to entry in nil map

あわせて、コンパイルエラーにはならないものの意図しない結果になりやすい例として、:= によるシャドーイングと、スライスが配列を共有することによる書き換えの伝播も確認しました。

弊社について

本記事を書いている 合同会社インクルーシブソリューションズ は、データ基盤構築・分析基盤設計・システム改善支援を中心に活動している小規模IT法人です。

主な領域は、

  • データマート設計・データパイプライン構築
  • SQL / Python を用いたデータ処理設計
  • BI導入支援・分析基盤の整備
  • 既存システムの運用改善・可視化支援

といった、「データを使える状態にする」ための活動です。

弊社の企業活動に興味がある方は、ぜひ公式サイトも覗いてみてください。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?