これは何?
を読んで、いろいろ調べた結果を記す。
いろいろな処理系でどうなるか調べた
Go
Go の スライスの気持ちは正直良くわからない。
スライスはどこかのメモリを参照している。そのメモリは make
で作ったかもしれないし、配列かもしれない。
a := make([]int, 0, 10)
for i := 0; i < 10; i++ {
a[0:10][i] = i // [A]
}
b := a[1:5] // [B]
fmt.Println(b, len(b)) //=> [1 2 3 4] 4
c := b[2:6] // [C]
fmt.Println(c, len(c)) //=> [3 4 5 6] 4
func() {
defer func() {
fmt.Println("panic! ", recover())
}()
ix := -1
fmt.Println(c[ix:4]) //[D] => [略] slice bounds out of range [-1:]
}()
a[0:10][3] = 333 // [E]
fmt.Println(b, c) //=> [1 2 333 4] [333 4 5 6]
defer func() {
fmt.Println("panic! ", recover())
}()
d := c[5:10] // [F] => [略] slice bounds out of range [:10] with capacity 7
fmt.Println(d, len(c))
初期化のために仕方なく [A] のような汚いことをした。これが合法であるのが Go のスライスの特徴のひとつだと思う。
[A] が可能なので [B] [C] が可能なのは当たり前と思う。
[B] [C] 直後の Println
の結果、[A] で埋めた値が確認できるので、[B] や [C] のタイミングでゼロ値で埋めているわけではないことがわかる。
len(スライス)
を超えてスライスを切り出せるんだから負のインデックスも行けるだろうと思って [F] を試すと panic。一貫性がない印象。
a
への書き込みが b
c
に影響を与えているので、同じメモリを参照していることがわかる([E])。
[F] で、capacity を超えるとパニックになることがわかる。
Python
Python には、list と tuple がある。さらに numpy.array
もある。
それぞれどうなるか見てみる。
以下、登場する p
という関数は、print(repr(x))
のような内容になっている。
list
a = [0,1,2,3,4,5,6,7,8,9]
b = a[1:5]
p(b) #=> [1, 2, 3, 4]
c = b[2:6]
p(c) #=> [3, 4]
a[3] = 333
p(b,c) #=> ([1, 2, 3, 4], [3, 4])
a[1:5]
のような式で取り出されたリストは、元のリストとメモリを共有していない。
キャパシティのような値はユーザーから全く見ることができない(と思ってるんだけど、どう?>識者)。
len(リスト)
を超えて b[2:6]
のようなことをすると、要求より短いリストが手に入る。
[][100]
が例外なのに [][100:101]
が例外にならないのは一貫性が少なめな印象だけどどうなんだろう。
tuple
a = (0,1,2,3,4,5,6,7,8,9)
b = a[1:5]
p(b) #=> (1, 2, 3, 4)
c = b[2:6]
p(c) #=> (3, 4)
タプルはリストと違って要素を変更できない。
a[1:5]
のような式で取り出されたタプルが、元のタプルとメモリを共有しているかどうかは知る方法がないし、実装の詳細なのでどうでもいい。
リストと同様、len(タプル)
を超えて b[2:6]
のようなことをすると要求より短いリストが手に入る。
numpy.array
a = np.array([0,1,2,3,4,5,6,7,8,9])
b = a[1:5]
p(b) #=> array([1, 2, 3, 4])
c = b[2:6]
p(c) #=> array([3, 4])
a[3] = 333
p(b,c) #=> (array([1, 2, 333, 4]), array([333, 4]))
b = np.append(b,555)
p(a) #=> array([0, 1, 2, 333, 4, 5, 6, 7, 8, 9])
p(b,c) #=> (array([1, 2, 333, 4, 555]), array([333, 4]))
numpy.array
は、list
と異なり、 a[1:5]
のような方法で切り出した部分は、もとの numpy.array
と同じメモリを参照する。
そこは Go の Slice と似ているが、len(numpy の array)
を超えて b[2:6]
のようなことをした場合の動作は Python のリストやタプルと同じように、要求より短いリストを返すということになっている。
Go との違いはもうひとつ。
Go はappend
すると、もとのメモリが使えればそこを使うけど、numpy.array
はコピーを行うので元のメモリに影響を与えない。
C++20
C++20 にはコンテナの一部を別のコンテナとみなす、という趣旨の機能が入っているので、初めて使ってみた。あんまりわかっていないのだけれど。
python と同じく、p
という関数が別途あって、中身を適当に出力する。
using namespace std::ranges;
std::vector<int> a{0,1,2,3,4,5,6,7,8,9};
// a | views::subrange(1,4) みたいに書けないの?
auto b = a | views::drop(1) | views::take(4);
p(b); //=> 1 2 3 4
auto c = b | views::drop(2) | views::take(4);
p(c); //=> 3 4
a[3]=333;
p(b,c); //=> 1 2 333 4, 333 4
return;
C++ も Python と同じく、take(4)
としても要素が 4 に満たなければある文だけ返すということになっている。
上記の b
c
は a
のメモリを使っているので a
を書き換えると b
c
が書き換わる。
Zig
例によって、 p
は中身を適当に出力する関数。
var a = [_]i32{ 0,1,2,3,4,5,6,7,8,9 };
var b = a[1..5];
try p(b);
a[3]=333;
try p(b);
var c = b[2..6]; // error: end index 6 out of bounds for array of length 4
zig の場合、 b[2..6]
が範囲外を参照していることがコンパイラにバレて、コンパイルエラーになる。
ならばとこうすると
fn sliceTest(n0 :usize, n1:usize) !void {
var a = [_]i32{ 0,1,2,3,4,5,6,7,8,9 };
var b = a[1..5];
try p(b); // 1 2 3 4
a[3]=333;
try p(b); // 1 2 333 4
var c = b[n0..n1]; // panic: index out of bounds: index 6, len 4
try p(c);
}
pub fn main() !void {
try sliceTest(2,6);
}
コンパイルは通り、実行時エラーになる。
この panic というものを catch というか rescue というか recover というかできるかどうかを知らない。
追加はできないよなと思っていたんだけど、コンパイル時ならできる。
var a = [_]i32{ 0,1,2,3,4,5,6,7,8,9 };
var b = a[1..5];
var c = b ++ [_]i32{11,22,33};
a[3]=333;
try p(b); // 1 2 333 4
try p(c); // 1 2 3 4 11 22 33
Go とちがって、 ++
はもとの配列に影響を与えない。
C#
C# もあんまり書かないからよく知らないんだけど、こんな感じかな。
※ str
は、空白で Join した結果を返すメソッド。
int[] arr = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
var a = new ArraySegment<int>(arr, 1, 5);
Console.WriteLine(str(a)); //=> 1 2 3 4 5
var b = a.Slice(2,2);
Console.WriteLine(str(b)); //=> 3 4
arr[3]=333;
Console.WriteLine(str(a)+ ", "+ str(b)); //=> 1 2 333 4 5, 333 4
var c = a.Slice(2,6); //=> (略) System.ArgumentOutOfRangeException: Index was out of range. (略)
ArraySegment
というものの存在を初めて知ったんだけど、配列の一部の view を作る、みたいなやつ。もとの配列とメモリを共有する。
a.Slice(2,6)
は、Go のスライスと同じロジックなら困らずに作れるはずだけど a
の範囲からは逸脱しているということで例外。
まとめ
スライス(のようなもの) b
からスライス(のようなもの) c
を切り出す際。
「メモリ共有」は、b
の要素と c
の要素が同じメモリを使うかどうか。
「範囲外スライス」は、例えば b
の長さが 4 なのに「2番目から始まる 4個くれ」とお願いしたらどうなるか。
「追加」は Go の append
に相当する処理。
道具 | メモリ 共有 |
範囲外スライス | 追加 |
---|---|---|---|
Go のスライス | 共有 | 非負のインデックス なら スライス長を超えて capacity までは行ける。 負のインデックスはパニック |
上書き |
Python の list
|
非共有 | 要求より短いものを返す | ふつう |
Python の tuple
|
どうでもいい | 要求より短いものを返す | 不可能 |
Python の numpy.array
|
共有 | 要求より短いものを返す | コピー |
C++ の std::ranges::views
|
共有 | 要求より短いものを返す | 未調査 |
Zig のスライス | 共有 | コンパイルエラー または panic (リカバー不可能?) | コピー |
C# の ArraySegment
|
共有 | 例外 | 不可能 |
こうしてみると、capacity を見に行っているのは Go だけ。
その点も含めて Go は 異端だなと思う。
「追加」の欄をちょっと説明しておくと。
Go の append
は、キャパシティに余裕があればもとのスライスと同じメモリを使うので、b
から切り出したスライス c
に append
すると、b
の内容が書き換わる。
Python の list
は、別の list
と共有していないのでこの欄で書くべきことがない。
Python の tuple
は「自分に追加」がそもそも不可能。追加操作をすると別の tuple
になる。
C++ の std::ranges::views
は、追加できないことを確認できていない。少なくとも push_back
は生えていない。
Zig の追加操作はコンパイル時のみ可能。 ++
で連結できる。連結の結果はもとのスライスとはメモリを共有しない。
C# の ArraySegment
は Add
メソッドが生えているものの、呼ぶと必ず例外。
おもうところ
Python の list
は、list
の何番目に何があるかを覚えるためのメモリの管理も list
が担っている。なので、list
のメモリを他の list
と共有するなんてありえない。C++ の std::vector
や ruby の Array
なんかもそう。
C++ の std::ranges::views
は、メモリの持ち主は他人。なので push_back
できない。C# の ArraySegment
で Add
できないのもそういうこと。
ちょっときもちわるい(個人の感想です)のは numpy.array
。b=a[1:5]
のようにすると、 何番目に何があるかを覚えるためのメモリは a
と b
で共有する。そうすると便利ということなのか、速度のための選択なのかわからないけど、同じ Python の list
と異なる挙動なので罠として機能すると思う。
メモリ共有している状態で numpy.append
numpy.delete
などのサイズが変わる演算をするとコピーが発生する。
サイズが変わらない演算(そういう reshape
とか)だとコピーが発生せず、メモリ画は共有されたままになる。むずかしい。
かなりきもちわるい(個人の感想です)のは Go。
numpy.array
と同様、メモリの持ち主が誰なのかが曖昧。
numpy.array
はそれでも「僕は行列です」という気分を表現していると思うけど、Go のスライスは「僕は値の列だよ」という振る舞いと「僕は値の列のビューだよ」という振る舞いが混ざっているように思う。
値の列なら末尾を超えて切り出せるべきではないし、値の列のビューなら append の挙動は受け入れがたい。