😺

誰でも書けるスクリプト言語は、immutable な配列を提供すればよかった

に公開4

突然ですが、

Python あるある〜。

a = [1, 2, 3]
b = a # コピー?
c = a # コピー?

b.append(4) # 追加
c.append(5) # 追加

print(b)
# [1, 2, 3, 4, 5]
# c に追加したはずの 5 が
# b にも追加されていて、ビビる

print(c)
# [1, 2, 3, 4, 5]
# b に追加したはずの 4 が
# c にも追加されていて、ビビる
JavaScript 版もあります
const a = [1, 2, 3]
const b = a // コピー?
const c = a // コピー?

b.push(4) // 追加
c.push(5) // 追加

console.log(b)
/* [ 1, 2, 3, 4, 5 ]
c に追加したはずの 5 が
b にも追加されていて、ビビる */

console.log(c)
/* [ 1, 2, 3, 4, 5 ]
b に追加したはずの 4 が
c にも追加されていて、ビビる */

その日人類は困りました。

C++ はどうか

ビビりません。

#include <iostream>
#include <vector>

int main() {
    std::vector a { 1, 2, 3 };
    auto b = a, c = a; // コピー
    
    b.push_back(4); // 追加
    c.push_back(5); // 追加
    
    for (int &i : b) std::cout << i << std::endl;
    // 1
    // 2
    // 3
    // 4
    std::cout << std::endl;

    for (int &i : c) std::cout << i << std::endl;
    // 1
    // 2
    // 3
    // 5
}

これは std::vector が deep copy だからです。

ならば軽率にコピーしまくろう

いいえ。Deep copy にかかる時間は要素数に比例するため、予期せぬ計算量の悪化を招くことがあります。たとえば、

int binary_search(std::vector<int> vec, int target) {
    // 対数時間で高速!
    // ……ではない
}

この関数は、長さ nstd::vector を受け取った時点で時間計算量 \Theta(n) です。
二分探索の計算量 \Theta(\log n) にしたければ、こう:

int binary_search(const std::vector<int> &vec, int target)

Python や JavaScript でビビるのを防ごうとしたら、今度は & の有無を意識する必要が生じてしまいました!

C++ を書く人なら大体パフォーマンスのことも考えられるでしょう。でも「誰でも書けるスクリプト言語」にこの手間があると嫌に思う人もいそうです。

Rust はいいぞおじさん、現る

上の話をすると必ず現れるおじさんがいて、こんなコードを見せてくれます。

let a = vec![1, 2, 3];
let b = a;
let c = a; // エラー

3 行目から早くもエラー!?ただしヘルプが出てきます:

help: consider cloning the value if the performance cost is acceptable
  |
3 |     let b = a.clone();
  |              ++++++++

パフォーマンスの悪化が許容できるなら、clone はいかがですか?

なるほど、Python や JavaScript のような苦しみは避けつつ、計算量の悪化に気付けるんですね。

でも、Rust はこれが至るところで起こります。

let b = a; // 代入すれば a を奪われる……
foo(a); // 関数に渡せば a を奪われる……
dbg!(a); // デバッグすれば a を奪われる……
a; // 何なら書くだけで奪われる……

(念のため付け加えておきますが、私は好きですよ。Rust)
(以前、RustCoder というのを書いていました)
(Rust のやさしい解説です)
(Rust 好きです。信じて下さい)

どうやら、Rust を書くには、コンパイラとゆっくり話し合う時間が必要そうです。でも私は今すぐ動くコードが必要なんです!

もし 1970 年代の AT&T ベル研究所で Rust が覇権を取っていれば、「苦しんで覚える C 言語」は「苦しんで覚える Rust」になり、ムーブを理解することが全プログラマーにとっての第一歩になっていたかもしれません。しかし残念ながら今はそうじゃありません。Rust の挙動は、他の多くのプログラミング言語と異なります。

OS が作りたい? Rust は良い選択肢でしょう。
言語処理系が書きたい? Rust は良い選択肢でしょう。
私は応用数学が専門で、プログラミングはからっきしなのだが、さっきシャワーと一緒に頭に降ってきたアイディアを今すぐ試したい?うーん……

「誰でも書けるスクリプト言語」にしては考えることが多そうです。

ここで Scala を見てみましょう

val a = Vector(1, 2, 3)

val b = a :+ 4
val c = a :+ 5

println(b)
// Vector(1, 2, 3, 4)
// ビビらない!

println(c)
// Vector(1, 2, 3, 5)
// ビビらない!

Scala の Vector は immutable です。つまり、a に直接要素を追加はできません。

代わりに、:+ 演算子で要素を追加した新しい Vector を返します。

これを標準で備えていれば、Python や JavaScript のような混乱は起きません!

「誰でも書けるスクリプト言語」は、この immutable な配列を用意すればよかったんですね。

でもランダムアクセスが O(1) じゃないって?

Effectively constant time です。

事実上の O(1) であり、Python のような「誰でも書けるスクリプト言語」に求められる速度としては十分では?

結論

Immutable な配列を標準装備すればよかった。

Discussion

YuneKichiYuneKichi

immutableではないですが、PHPの配列はどうでしょうか。
実装上Copy on Writeですし、書かれている内容からするとだいぶ理想的な動作なのでは。

とがとが

ありがとうございます。この記事を書いてから、PHP や R 言語など、存じ上げなかった他言語の情報をいくつかいただいております。調べて追記したいと思います

Kohei WatanabeKohei Watanabe

まさにタプルが存在する根本的な理由ですね

a = (1, 2)
b = a
b += (3,)
print(a)  # (1, 2)
print(b)  # (1, 2, 3)

余談: かつてJavaScriptでもタプルが提案されていました(残念ながらこの提案は取り入れられませんでした)
https://github.com/tc39/proposal-record-tuple

とがとが
b += (3,)

のような末尾への追加は \Theta(n) かかり、C++ と同様に予期せぬ計算量の悪化に繋がるのではないでしょうか。