LoginSignup
17
4

More than 1 year has passed since last update.

Tour of Goのスライスの挙動で引っかかってたら、色々分かった話

Posted at

目次

はじめに

この記事は カオナビ Advent Calendar 2021 5日目です。

初めまして!カオナビに新卒で入って現在2年目の@summer_shineです。

最近、自分が新しく所属することになったチームで、初めてGoを触ることになりました。
その際に、A Tour of Go (Goのチュートリアルができる場所)のスライスの項目でそんな挙動するの!?ってなり、引っかかった箇所があったので、それについて調べてみました。

自分と同じように、つまづいた人がいれば参考にして頂けると嬉しいです。
というわけで、5日目はGoのスライスの話をしようと思います!

対象の読者

  • Goの初学者
  • スライスについて学び始めた人
  • Tour of GoのSlice length and capacityの挙動が良く分からなかった人 ←ピンポイント

この記事で伝えたいこと

  • 重要:スライスの代入について
  • 重要;スライスのインデックスの仕様について
  • スライスについて
  • スライスのスライシングについて

スライスとは

スライスについての記事は調べると沢山出てきますが、自分が引っかかったコードについて理解してもらう為にも、最低限必要なスライスの知識については触れておきます。

スライスとはGoにおける可変長の配列のようなものです。

スライスは配列をラップ(配列の機能を含みつつ、別のものとして提供)してできています。

Goの配列は固定長です。
普段コードを書く際には利便性が高いという理由で、スライスの方が使われることが多いです。(後からスライスに要素を追加したりできるので)

スライスの宣言の仕方は簡単で、配列宣言時の要素数を省略することで、使うことができます。(make関数を使うことでも作成可能)

// 配列
favoritePokemon := [3]string{"カイオーガ", "ウルガモス", "シャンデラ"}

// スライス
favoritePokemon := []string{"カイオーガ", "ウルガモス", "シャンデラ"}

// make([]T, len, cap)を使ったスライスの宣言。 T、len、capにはそれぞれ型、長さ、容量を指定
favoritePokemon := make([]string, 3, 3)

スライスはポインタ(ptr)と長さと(len)容量(cap)で構成されています。
slice-struct (1).png

  • ptr : 元となる配列のアドレスを持つ
  • len : スライスが参照している配列の要素の数
  • cap : 元になる配列内の要素の数。ptrによって参照される要素から数えた数。

スライスのスライシング

スライスはスライシングをすることでスライスの値を切り出して渡すことができます。

スライスシングの指定の仕方はs[low:high]でスライスに対してインデックスを指定することで取得できます。

// スライス 
favoritePokemon := []string{"カイオーガ", "ウルガモス", "シャンデラ"}

// 炎タイプだけスライシング  
fireType := favoritePokemon[1:3]// ["ウルガモス", "シャンデラ"]

どこで引っかかったか

次に、まず自分がTour of Goのスライスの項目の挙動で引っかかった箇所を見てみたいと思います。
上記の項目のスライスの宣言とスライシングが分かっていれば、該当の箇所は読めると思います。(コードはTour of Goのままですが、コメントを一部編集しています)

package main

import "fmt"

func main() {
    // スライスを代入
    s := []int{2, 3, 5, 7, 11, 13} 
    printSlice(s) //   [2 3 5 7 11 13]

    // スライスの[low:high]を指定してスライシング
    // この場合は要素数0から0、つまり空のスライスをsに再代入
    s = s[:0]
    printSlice(s) // []

    // 問題の箇所
    // 先ほど、空を代入したスライスの0番目から4番目までを代入!?
    s = s[:4]
    printSlice(s) // [2 3 5 7] ← !?!?!?!?

    // 再代入したものをスライシング
    s = s[2:]
    printSlice(s)
}

func printSlice(s []int) {
    // len()とcap()はスライスを受け取り、len()はスライスの長さ、cap()は容量を返す
    fmt.Printf("len=%d cap=%d %v\n", len(s), cap(s), s)
}   

実行結果

len=6 cap=6 [2 3 5 7 11 13]
len=0 cap=6 []
len=4 cap=6 [2 3 5 7]
len=2 cap=4 [5 7]

実行結果では確かに空が出力されていたのに、そのスライスに対して[:4]とスライシングすると最初に代入していた値が取れています。

自分は最初、何が起こったんだ・・・となってましたが、調べたら謎が解けたので次以降の項目で、この現象がなぜ起こったのかを順を追って話したいと思います。

スライスの代入は元の配列のポインタを渡す

スライスを変数に代入するときに、その変数に渡されるスライスの値(例:[2, 3, 5, 7])は値のコピーを渡すのではなく、元の配列のポインタを渡しています。

その為、スライスを代入した状態でそれぞれの値が指し示すアドレスを表示してみると、同じものが出力されます。
同じポインタを持っていると言うことはs2のを値を変えるとs1の値も変わります。

// 配列へptr、len:6、cap:6をもつスライスを代入
s1 := []int{2, 3, 5, 7, 11, 13}  // [2 3 5 7 11 13]

// スライシングして再代入
s2 := s1[:1]  // [2]

// アドレスを表示すると同じ場所を指している
fmt.Printf("%p \n", s1) // 例: 0xc0000b6030
fmt.Printf("%p \n", s2) // 例: 0xc0000b6030

つまり、自分が引っかかった場所である以下のGo tourのコードは、値としては空の配列であるが、ポインタが指す先は元の配列のアドレスを持っていた、という事になります。

s := []int{2, 3, 5, 7, 11, 13} // スライスを代入
printSlice(s) //   [2 3 5 7 11 13]

// スライシングしつつ、元の配列のポインタも渡している
s = s[:0]
printSlice(s) // []

再代入されたsが、ポインタとしては元の配列を指していることは分かったと思うのですが、空のスライスに対してs = s[:4]とスライシングが出来てしまう事には、まだ疑問を持っている人もいるかもしれません。
(少なくとも自分は、空からなんで値取れるんだ・・・ってなってました)

ちなみに、スライスの複製をしたい場合はcopy()を使って作ることがきます。
copy()だとそれぞれのスライスが別々のポインタを持つので、片方を変更しても、もう片方が変わるという事にはなりません。

// スライス
s1 := []int{1, 2, 3, 4, 5, 6}

// 複製したいスライスの代入先の初期化
s2 := make([]int, len(s1))

//  copy(複製の代入先、複製したいスライス)
copy(s2, s1)

スライシングの仕様

スライシング(s[low:high])でスライスの値を切り出せると言う話をしたと思いますが、その際に指定できるインデックス(low:highの数値 )の範囲は、要素の数len(s)ではありません。

スライスの場合、指定できるインデックスの上限は容量cap(s) 、つまり元になる配列のptrによって参照される要素から数えた値によります。

For slices, the upper index bound is the slice capacity cap(a) rather than the length.
(和訳)スライスの場合、インデックスの上限は、長さではなくスライスの容量cap(a)となります。
The Go Programming Language Specification - go.dev より

  • スライスの代入は元の配列のポインタを渡すこと
  • スライシングのインデックスの範囲は容量cap(s) によること

以上の2つを踏まえてもう一度go tourのコードを見ていきましょう。(一部省略)

// スライスを代入
s := []int{2, 3, 5, 7, 11, 13} // len=6 cap=6 [2 3 5 7 11 13]

// 空のスライスをsに再代入
s = s[:0]// len=0 cap=6 []

// 問題の箇所
s = s[:4]

5行目でs = s[:0]の再代入により、sは基の配列のptr(指し示す先は[])、len ([ ]なので0)、cap (ptrからみた元の配列の要素数なので6)を持っている状態です。

8行目のs = s[:4] は、再代入されたslen は0であるが、スライスのインデックスの範囲はlenではなくcapを見るので、ポインタが指し示す基の配列に対して[:4] というスライシングを行ったという事になります。

その為、空の配列から値を取っているように見えた。という事になります。

これでスライスの挙動で引っかかっていたものが解消されました(チョースッキリ!!)

ビミョーに話がズレますが、スライシングで追加で気をつけて欲しいことは、インデックスを省略した場合です。

// スライスを代入
s := []int{2, 3, 5, 7, 11, 13} // len=6 cap=6 [2 3 5 7 11 13]

// 空のスライスをsに再代入
s = s[:0] // len=0 cap=6 []

fmt.Println(s4[:cap(s)])// [2 3 5 7 11 13]
fmt.Println(s4[:])//  []       [2 3 5 7 11 13]とはならない

[:]は全要素を取り出したい時に使ったりしますが、この時に省略したlowhighはそれぞれlow = 0high = len(s) として扱われます。その為、5行目で空を再代入したslenが0な為、[:]で取り出しても空のままとなります。

まとめ

  • スライスの代入は配列のポインタを渡す!
  • スライシングのインデックスの上限はlenではなくcap!
  • スライスの再代入の挙動には気を付けよう!

今回初めて記事を書く事になって色々と大変でしたが、学びがありました。
気になった点などあれば、コメントよろしくお願いします!

参考

https://go.dev/blog/slices-intro
https://go.dev/ref/spec#

17
4
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
17
4