新しいことにはウェルカム

技術 | 電子工作 | ガジェット | ゲーム のメモ書き

Go言語でハマったことメモ(slice・map・string)

Golangを始めました。

GolangはC言語のように、シンプルな文法・データ構造でできているのですが、同時に、生産性を高めるための、高度な概念も取り入られています。

そしてそのために、Golangには若干トリッキーな構文がいくつかあります。

しかし、それらを知らずに、他の言語での先入観や勝手な思い込みで判断してしまって、ハマることがちょいちょいありました。

ここでは、Golangを始めてみて、個人的にハマったことや、勘違いしたことを、トピック別に備忘録としてメモしていこうと思います。

ここでは、「スライス(slice)・マップ(map)・ストリング(string)」とは何ぞやについてのメモを記載しました。

はじめに

Golangにはスライス、マップ、ストリングという、他の言語における配列、マップ、文字列のようなものがあります。

他の言語でよく見かけるが故に、よくあるやつでしょと何となくで使っていました。

しかし、Golangにはポインターと実体の概念があります。

値をポインターでやりとりする分には、実体は同じなのであまり気にすることはないのですが、 代入で渡した場合、その後の挙動がどうなるかは、スライスとマップがどういう構造で、どういう仕組みかをある程度知ってないとダメでハマりました。

スライス・マップ・ストリングとは?

スライス・マップ・ストリングとは?ざっくり言うと

Go言語組み込みの、可変長配列・マップ・文字列ライブラリ

です。

Golangのデータ構造を一番小さい単位にまで分解すると、整数や小数といった「基本型」と「配列」「構造体」になり、スライス・マップ・ストリングはそれらと並列にある単独したデータ構造ではなく、それらのデータ構造を組み合わせて表現されています。

そのあたりはC言語に似ていて、例えば、C言語で可変長配列・マップ・文字列が欲しければ、自分で実装するかライブラリを使うことになるのに近しいものがあります。

昨今のプログラミングにおいて、可変長配列・マップ・文字列は頻繁に使うので、C言語のようにライブラリでユーザーが各自で用意する形にすると不便なので、 Golangはそれらを基本型を使って実装しつつ、言語の機能として最初から取り込むことにより、可変長配列・マップ・文字列をGolangの文法レベルに落とし込んでいます。

スライス

スライスは可変長配列で、配列をベースに作られています。

スライスの実体は構造体で、「配列へのポインター」「スライスの要素数」「配列の長さ」のデータを格納している、下記のような構造体です。

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

スライス構造体とは別に長さcapの配列が保存領域として用意されていて、arrayがその配列へのポインターとなります。

そもそもGolangにおける配列は固定長なので、後から長くすることはできません。

固定長のものを可変長のように見せるために、スライスでは、 予め配列を長めに確保しておき、スライスの要素数が増えた時は、スライスの要素数の値を増やして、新しい要素に使わせます。

そして、確保されていた長さより要素数が多くなった場合は、新しくもっと長い配列を用意して、そこに元の配列の値をコピーし、新しい配列を指す、新しいスライス(スライス構造体)を作成します。

スライスの代入

前述のとおり、スライスの実体は構造体です。なので、スライスからスライスへの代入は構造体の代入と同じで、構造体の値がまるっとコピーされます。

スライスが構造体というのは、スライスのメモリサイズを見ても分かります。

配列ポインターの8バイト、配列の長さintの8バイト、スライスの要素数intの8バイトの計24バイトです。

一方配列は、配列全体を指すので、配列のサイズは、配列の容量になります。

a := []int{0}
b := []int{0, 1, 2, 3}
c := [...]int{0}
d := [...]int{0, 1, 2, 3}
fmt.Println(unsafe.Sizeof(a))  // 24
fmt.Println(unsafe.Sizeof(b))  // 24
fmt.Println(unsafe.Sizeof(c))  // 8
fmt.Println(unsafe.Sizeof(d))  // 32

スライスの要素の値の変更がどこまで及ぶか?

スライスの要素の値は、スライスが持つ配列ポインターが指す、配列の中に格納されています。

スライスを他のスライスに代入すると、スライス構造体のコピーが起こるので、配列ポインターの値も同じになり、おなじ配列を指すことになります。

なので、コピー先のスライスの要素の値を変更すると、コピー元のスライスの値も変更されます。

a := []int{0}
b := a
b[0] = 1
fmt.Println(a) // 1
fmt.Println(b) // 1

しかし、スライスに値を追加して配列に入り切らなかった場合など、配列の再割当てが発生した場合に話がややこしくなります。

前述の通り、配列の再割当てが発生した際、元のスライスはそのままで、新しいスライスは新しい配列領域を指すようになります。

a := make([]string, 1, 2) // 配列容量2
a[0] = "A"
b := a
fmt.Println(a) // [A]
fmt.Println(b) // [A]

// a・bは同じ配列を指す
b[0] = "B"
fmt.Println(a) // [B]
fmt.Println(b) // [B]

// bの要素が増えても、配列の再割当てが発生しないなら、a・bは同じ配列を指す
b = append(b, "B")
fmt.Println(a) // [B]
fmt.Println(b) // [B B]

a[0] = "A"
fmt.Println(a) // [A]
fmt.Println(b) // [A B]

// bの要素が増え、配列の再割当てが発生すると、a・bは別の配列を指すようになる
b = append(b, "B")
fmt.Println(a) // [A]
fmt.Println(b) // [A B B]

a[0] = "C"
fmt.Println(a) // [C]
fmt.Println(b) // [A B B]

コピー先の変更がコピー元に及んだり、append()後のスライスの変更がappend()前のスライスに及んだりするので、スライスはポインターかなと勘違いしてしまいがちですが、その時たまたま同じ配列を指していただけです。

マップ

マップはスライスより仕組みは複雑なのですが、マップ同様、実体は下記のような構造体です。

type hmap struct {
    count     int
    flags     uint8
    B         uint8
    noverflow uint16
    hash0     uint32
    buckets    unsafe.Pointer
    oldbuckets unsafe.Pointer
    nevacuate  uintptr
    extra *mapextra
}

ただし、スライスの場合、スライスを作成した時に、ユーザーにはスライス構造体そのものが渡るのですが、マップの場合、マップを作成した時は、まずマップ構造体が作られるのものの、ユーザーにはそのマップ構造体ではなく、マップ構造体へのポインターが渡されます。

マップがポインターというのは、マップのメモリサイズを見ると、ポインターの8バイトとなっていることから分かります。

また、マップのmake()の実装の戻り値が、hmap構造体のポインターになっていることからも分かります。

a := map[int]int{0: 0}  // aはhmap構造体へのポインター
fmt.Println(unsafe.Sizeof(a))  // 8
func makemap(t *maptype, hint int, h *hmap) *hmap 

マップの代入と値の変更がどこまで及ぶか?

マップは内部的にはポインターなので、マップからマップに代入すると、マップポインターがコピーされ、みなが同じマップ構造体を指します。

つまり、マップをコピーしたり関数に渡すと、他の場所でマップの値が変更される可能性はありますが、スライスとは違い、いつも全てのマップが同じ値になることが保証されています。

なので、マップをポインターにして持っておく必要はなく、関数に渡す際もポインターにして渡す必要もなく、マップはマップのまま代入や関数の引数に渡して問題ないのです

func testPointer(v *map[int]int) {
    fmt.Println((*v)[0])
}

func testObject(v map[int]int) {
    fmt.Println(v[0])
}

func main() {
    m := map[int]int{}
    m[0] = 1
    m[1] = 2

    p := &m  // ポインターにしなくてもいい
    fmt.Println((*p)[0])

    o := m
    fmt.Println(o[0])

    testPointer(&m)  // ポインターで渡さなくてもいい
    testObject(m)
}

マップのキーに入れられるものと同じキーの判定条件

マップの面白い機能として、マップのキーには整数や文字列だけでなく、小数・ブール・配列・構造体も使えます。

キーから要素を取り出すのに「==」演算子で同じキーかを判断するので、「==」演算子が定義されている型ならキーに使えます。

それらの仕様から、下記のような特徴があります。

配列をキーにした場合

配列の「==」演算子は、配列の値を比較するので、取り出す時のキーが、キーを設定した時の配列と同じでなくても、中の値が一致すれば同じキーとなります

m := make(map[[2]int]string, 1)
keyA := [...]int{1, 2}
m[keyA] = "TEST"

keyB := [...]int{0, 0}
keyC := [...]int{1, 2}

v, ok := m[keyB]
fmt.Println(ok, v) // false

v, ok = m[keyC]
fmt.Println(ok, v) // true TEST

構造体をキーにした場合

構造体の「==」演算子は、配列同様、値を比較するので、取り出す時のキーが、キーを設定した時の構造体と同じでなくても、構造体の中の値が一致すれば同じキーとなります

type myStruct struct {
    x int
}

m := make(map[myStruct]string, 1)
keyA := myStruct{x: 1}
m[keyA] = "TEST"

keyB := myStruct{x: 0}
keyC := myStruct{x: 1}

v, ok := m[keyB]
fmt.Println(ok, v) // false

v, ok = m[keyC]
fmt.Println(ok, v) // true TEST

ポインターをキーにした場合

ポインターの「==」演算子は、ポインターの値を比較するので、当然と言えば当然なのですが、ポインター値が同じ、つまり、ポインターが同じオブジェクトを指していれば同じキーとなります。

type myStruct struct {
    x int
}

m := make(map[*myStruct]string, 1)
keyA := myStruct{x: 1}
m[&keyA] = "TEST"

keyB := myStruct{x: 0}
keyC := myStruct{x: 1}

v, ok := m[&keyB]
fmt.Println(ok, v) // false

v, ok = m[&keyC]
fmt.Println(ok, v) // false

copyKeyA := keyA
v, ok = m[&copyKeyA]
fmt.Println(ok, v) // false

pointerKeyA := &keyA
v, ok = m[pointerKeyA]
fmt.Println(ok, v) // true TEST

スライス・マップをキーにした場合

スライスの「==」演算子は、定義されていないので、スライスをキーにすることはできません

同様に、マップの「==」演算子は、定義されていないので、マップをキーにすることはできません

スライス・マップのnilとmake()について

スライス・マップを変数宣言すると、要素数0で構造体は作成されるのですが、値を格納する配列領域は作成されません。

その変数をnilと比較するとtrueとなります。

一方、要素数0の空のスライス・マップや、make()でスライス・マップを作成すると、値を格納する配列領域を作成した上で、要素数0の構造体が作成されます。

その変数をnilと比較するとfalseとなります。

スライス・マップの変数にnilを代入すると、変数を宣言した時と同じく、値を格納する配列領域は作成されない、新しい構造体が作成されるので、既存のスライス・マップの領域開放に使えます。

// slice
var s[]int
fmt.Println(len(s))  // 0
fmt.Println(s == nil)  // true

s = []int{}
fmt.Println(len(s))  // 0
fmt.Println(s == nil)  // false

s = nil
fmt.Println(len(s))  // 0
fmt.Println(s == nil)  // true

// map
var m map[int]int
fmt.Println(len(m))  // 0
fmt.Println(m == nil)  // true

m = map[int]int{}
fmt.Println(len(m))  // 0
fmt.Println(m == nil)  // false

m = nil
fmt.Println(len(m))  // 0
fmt.Println(m == nil)  // true

文字列(string)

文字列はスライスと似ていて、配列へのポインターと配列の長さを格納した、下記のような構造体です

type stringStruct struct {
    str unsafe.Pointer
    len int
}

ただし、Go言語の仕様で、後から値の変更や文字列の追加はできないようになっています。

文字列を後から変更できないので、構造体が持っているのは配列のポインターと配列の長さの情報だけで、スライスのように要素数の情報は必要ないのでありません。

文字列は構造体なので、文字列の長さに関わらず、文字列変数のサイズは、配列ポインターの8バイト、配列の長さintの8バイトの計16バイトになります。

a := "T"
b := "TEST"
fmt.Println(unsafe.Sizeof(a))  // 16
fmt.Println(unsafe.Sizeof(b))  // 16

文字列とスライスの違い

文字列はスライスに似ているのですが、前述のとおり、言語仕様で文字列の中身を変更できないのと、「==」演算子が定義されていて、値の比較ができ、マップのキーに使えるところが大きく違います。

文字列の代入と比較

文字列の代入は、スライスと同様、文字列構造体のコピーです。

なので、どんなに長い文字列を他の変数に代入しても、文字列構造体の16バイトのコピーだけで済みます。

そして、文字列をどんどん他の変数に代入していっても、全ての文字列変数の内容は、最初の文字列の生成時に確保された配列を指していて同じです。

例えば、下記のように、強引に元の文字列「a」の内容を変更すると、代入先「b」も変更されることから、aとbは同じ領域を指しているのが分かります。

a := fmt.Sprint("abc") // 値を変更できるように、aを動的に"abc"に設定
fmt.Println(a) // "abc"

b := a
fmt.Println(b) // "abc"

// aを"ABC"にする
data := (*[3]byte)(unsafe.Pointer((*reflect.StringHeader)(unsafe.Pointer(&a)).Data))
data[0] = byte(rune('A'))
data[1] = byte(rune('B'))
data[2] = byte(rune('C'))

fmt.Println(a) // "ABC"
fmt.Println(b) // "ABC"

つまり、文字列は他の変数に代入しても新たな文字列領域は生成せず、最初に設定された値も変更されないので、stringはポインターにする必要はなく、stringのまま、代入や関数の引数間で引き回して問題ないのです。

ただし、文字列どうしの演算は、新しい文字列を生成するので、新しい文字列領域が発生します。

a := "test"
b := a  // aとbは同じ配列を指す
c := a + b // cはa・bとは別の新しい配列を指す

感想など

GolangはC言語のように、シンプルな文法・データ構造でできているのですが、スライス・マップ・文字列などの凝った機能も、それらを使って実用的な手段で融合されている、モダンで面白い言語ですね。

関連カテゴリー記事

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com