変数への参照と「変数そのもの」

Pythonは最近非常に流行っているので、静的型付け言語が好きな私も無視できないどころか使って便利だと感じることもある。 特に大抵のライブラリがPython APIを提供しているので、ライブラリを組み合わせて何かを組み上げる時は向いているだろう。

そんな感じの認識なので、私はPythonでまとまったコードを書いたことはなく、せいぜいで数百行、関数数個にユーザー定義オブジェクトもあって数個、で事足りるような程度の小物しか書いてこなかった。 なので、私のPythonの知識は入門を終えたところ、という感じだ。そこにC++等他言語の知識・経験を持ってきて必要に応じてやっていっている。

ところで、先ほど以下のようなツイートを見た。

それを見て試して(実際にツイートのようになった)、そのあまりの非直感的な挙動に少し驚いたあと、以前同じくTwitter経由で発見した以下の質問を思い出した。

ja.stackoverflow.com

このそれぞれの予想外の動作は、似た理由に根ざしているのではと思ったのだ。つまり、オブジェクトが基本的に参照で取り回されるという特徴である(後者にはスコープの問題も絡むが)。 何が起きているか説明するために、上記リンク先からコードをいくつか引用しよう。

まずはja.stackoverflowでの質問の内容だ。ひとつ目のサンプルコード。

# sample1.py
fs = [lambda x: i*x for i in range(3)]
for i in range(3):
    print(fs[i](3))

これは何をしているかというと、まず一行目でリスト内包記法によって無名関数(ラムダ)のリストを作っている。意図としては、冗長に書くと

fs = [lambda x: 0*x, lambda x: 1*x, lambda x:2*x]

ということだ。 当然、先のコードの実行結果は

0
3
6

になるだろう。当然だ。for文を手動で展開したら

print((lambda x: 0*x)(3)) # ==> 0*3 ==> 0
print((lambda x: 1*x)(3)) # ==> 1*3 ==> 3
print((lambda x: 2*x)(3)) # ==> 2*3 ==> 6

となるのだから。という至極もっともな予想に反して、この実行結果はPython3では

$ python3 sample1.py
6
6
6

となる。ラムダにキャプチャされているiの中身が全て2になっているのだ! これは変だ。質問者は比較のために以下のようなコードを書いている。

# sample2.py
fs2 = []
for i in range(3):
    fs2.append(lambda x: i*x)
for i in range(3):
    print(fs2[i](3))

見ればわかるが、意味的には全く同じことをしている。しかしこちらは予想通りの挙動をするのだ!

$ python3 sample2.py
0
3
6

この質問は解決済みで、回答を要約すると以下のようになる。

まず、sample1.pyがあのような挙動をしたのは、

  1. Pythonのラムダは実値でなく変数iそのものをキャプチャする。よって全てのラムダが同じ変数iをキャプチャしている。
  2. リスト内包で用いた変数iはリスト内包のスコープでのみ使われ、その後のfor文では参照も変更もされない。

からだ、ということらしい。

# sample1.py
fs = [lambda x: i*x for i in range(3)]
for i in range(3):
    print(fs[i](3))

少し言葉を足してコードとともに説明すると、最初の行でリスト内包のために作ったiは、リストを初期化する際に0, 1, 2と値を変えた後に2で固定される(誰もその後変更しないので)。 この「リスト内包のi」は破棄されずに残り、また「for文のi」とはスコープ的に区別される。 なのでfor文で「for文のi」が0,1,2と変わっている間も、fsのラムダたちが指している「リスト内包のi」は2のままだ。 なので、全てのラムダがlambda x: 2*xになる。

ではsample2.pyが予想通り動くのはなぜか。回答者によると、上記と同様の理由であり、実値をキャプチャするようになったわけではない。

# sample2.py
fs2 = []
for i in range(3):
    fs2.append(lambda x: i*x)
for i in range(3):
    print(fs2[i](3))

つまり、fsに格納されるラムダ達はappendする際に使っている「for文のiそのものをキャプチャするのだが、それは2つめのfor文で変更される。 驚くべきことに、1つめのfor文で使われた変数iは破棄されてはおらず、そのスコープはスクリプト全体に拡散しているのである。 その上、2つめのfor文で使われている変数iは、1つめで作られたものと全く同じものなのだ。

回答者は、説明のためもう一つの例を挙げている。2つめのfor文でカウンタをjにしたものだ。

# sample2.py
fs2 = []
for i in range(3):
    fs2.append(lambda x: i*x)
for j in range(3):
    print(fs2[j](3))

上の説明が正しければ、2つめのfor文で変数iが変更されなくなれば、これは6しか出力しない。実際、これは6しか出力しない。 回答者の説明は十分な予測力を持っており、信憑性が増したというわけだ。

それを念頭に置くと、以下のようなコードは予想通り動くのではないか。

fs = [lambda x: i*x for i in range(3)]
vs = [fs[i](3)      for i in range(3)]
print(vs) # [6,6,6]

と思ったのだが、これは[6,6,6]だった。それぞれのリスト内包のスコープは区別されるのだろう。


質問にあるコードの挙動は、変数の寿命をスコープで管理できる言語を使っていると予想しづらい挙動だ。 もしC++sample2.pyと同様のコードを参照キャプチャで書くと、当然ながら未定義動作になる。

std::vector<std::function<int(int)>> fs;
for(int i=0; i<3; ++i)
    fs.push_back([&](int x){return i * x;});
for(int i=0; i<3; ++i)
    fs[i](3); // Undefined Behavior

C++ではfor文で作られたカウンタはその外へ出ると破棄される。なのでfs[0]の呼び出しを待たずに、変数iの寿命は尽きる。 当然、そこへの参照をキャプチャしているラムダの呼び出しは、既に破壊された領域を間接参照する結果になり、未定義動作を引き起こす。 これは慣れないうちはやりやすいミスで、確か『Effective Modern C++』でも注意するよう言及されていた。

Pythonが(内部で)やっているのを真似ようとすると、以下のようなコードになる。

int i;
std::vector<std::function<int(int)>> fs;
for(i=0; i<3; ++i)
    fs.push_back([&](int x){return i * x;});
for(i=0; i<3; ++i)
    fs[i](3);

for文のスコープはこのブロック全体だ。for文では新しいカウンタを作るのではなく、先に定義してある変数を変更しているだけである。 最初のsample1.pyと同等のコードは、C++では以下のようになる。

int i_init;
int i_for;
std::vector<std::function<int(int)>> fs;
for(i_init=0; i_init<3; ++i_init)
    fs.push_back([&](int x){return i_init * x;});
for(i_for=0; i_for<3; ++i_for)
    fs[i_for](3);

Pythonインタプリタは2つのカウンタiを区別している。2つめのループでは初期化時に使った変数でなくループのための変数が使われている。

ここで、かの質問にはPython2なら直感に応じた挙動をする、との指摘があったことにも触れておこう。 だが、それはPython2が実値をキャプチャしていたから、ではなく、変数iそのものをキャプチャしていたことに変わりはないのだ。 もうおわかりかと思うが、Python2はリスト内包のスコープが外へ漏れ出していたのである。 リスト内包で使われたカウンタiと外のループで使われたカウンタiが同じものだったので、見た目正しく動いていただけだ。 こちらの予想「ラムダはそれぞれが0,1,2を持っているので0,3,6を返している」というのは全くの大外れで、「ラムダはそれぞれが全く同じ変数iを参照しており、それがループ途中で0,1,2と変わっていくので、結果も0,3,6と変わっていく」のである。

そんな内部動作に気づけるだろうか。そんな挙動はかなり熟練のPythonistaにしか気づけないだろうし、それが原因でバグが発生していた場合、修正は非常に困難だろう。

ではデフォルトでコピーキャプチャにすれば、と思わなくもないが、それだと馬鹿でかいオブジェクトが渡された時には非常に大きなオーバーヘッドが発生する。 それに、ユーザーにとって「コピー」よりも「参照を扱う」の方が学習コストが高いと判断したとしても設計者は責められないだろう。C言語のポインタで百万ものプログラマ(になりたかった人々)が死んでいるからだ。

個人的には、非常に面白かったのが「変数そのもの」という言い回しだ。私からすればこの挙動は参照キャプチャであり、私個人はデータの実体はメモリの上にあって参照やポインタは単なるそこへのpath、というイメージで捉えているので、参照は「変数そのもの」とはかなり違ったものと感じている。だが、確かに参照やポインタを陽に扱う必要がなく、デフォルトで参照が取り回されるなら、参照を「変数そのもの」として捉えるモデルは非常に正しい。こういう捉え方にも第一言語(回答者の第一言語が何か知らないが、回答中はPythonistaだっただろう)の差が出てくるのだな、と思うと面白い。


でだ。これだけだと単なるstackoverflowの焼き直しだ。翻訳記事ですらない。 なのでもう一つの、冒頭で紹介したツイートの内容について触れる。多分呟いた本人は理解しているだろうし、届くとも思っていないが、それは目的ではない。

a = [[0]*3]*3
print(a) # [[0, 0, 0], [0, 0, 0], [0, 0, 0]]
a[0][0] = 1
print(a) # [[1, 0, 0], [1, 0, 0], [1, 0, 0]]

このコードだ。Pythonはリストを掛け算で伸ばせる。なので、

b = [0] * 3
print(b) # [0,0,0]

となる。これは非常に基本的な機能だ。だがそれを2重にするとどうなるか、というのが今回の問題だ。

a = [[0] * 3] * 3 # ==> [[0, 0, 0]] * 3
print(a) # [[0, 0, 0], [0, 0, 0], [0, 0, 0]]

ここまではよい。リストが3つ入ったリストを作るのだから、ここまでは完璧だ。問題は次だ。

a[0][0] = 1

予想される結果はなんだろうか。普通は、[[1, 0, 0], [0, 0, 0], [0, 0, 0]]だろう。 1つめのリストの1つめの値に1を代入しているのだ。それ以外のリストは手付かずのはずだ。 だが、以下のようになる。

a[0][0] = 1
print(a) # [[1, 0, 0], [1, 0, 0], [1, 0, 0]]

なんだこれは。触れてすらいないはずの要素が変わっている。

この予測不可能な挙動も、ここまでの話を読んできた読者には理解可能だろう。 ここで変数aは、3つの要素が3つとも同じ配列を指す参照になっているのだ。

C++で同等の状況を書くと、少し変になるが、

std::array<int, 3> zeros{0,0,0};
std::array<std::array<int, 3>*, 3> a{&zeros, &zeros, &zeros};
(*(a[0]))[0] = 1;
for(auto i in a)
{
    for(auto j in *i)
        std::cout << j << ',';
    std::cout << '\n';
}
std::cout << std::flush;

これは

1,0,0,
1,0,0,
1,0,0,

を出力する。

Pythonではデフォルトで参照が用いられる。なので、

a = [[0] * 3] * 3 # [[0, 0, 0], [0, 0, 0], [0, 0, 0]]

は、以下のような意味ではなく……

a = []
a.append([0,0,0])
a.append([0,0,0])
a.append([0,0,0])
a[0][0] = 1
print(a) # [[1, 0, 0], [0, 0, 0], [0, 0, 0]]

……こちらに近いということになる。

a = []
b = [0,0,0]
a.append(b)
a.append(b)
a.append(b)
a[0][0] = 1
print(a) # [[1, 0, 0], [1, 0, 0], [1, 0, 0]]

上のコードでは、それぞれ別個のリストがその場で生成されて入っているのだろう。 しかし、下のコードでは変数aの各要素に同じ変数bへの参照が入っている。 なので、a[i][0]の先にあるものは、i=0,1,2のどれにおいても同じ場所、b[0]だというわけだ。

そうなると以下の挙動も気になる。

a = [0] * 3
a[0] = 1
print(a) # [1,1,1](……ではなく、実際は[1,0,0])

ここで、リストが参照を持つなら[1,1,1]になるのでは、と思うのだ。 0を持つ変数がインタプリタ内部に作られ、そこへの参照がaに入るのが、ここまでの挙動からは自然に思える。 だが、ここでは実値が格納されており、要素一つを変えても他の要素は自分の値を保っている。 つまり、内部変数への参照ではなく、それぞれに変数が新しく作られていることになる。

より驚くべきことに、以下の結果も[1,0,0]になる。

b = 0
a = [b] * 3
a[0] = 1
print(a) # [1,0,0]

即値0を格納するときにわざわざ内部変数が作られてそこへの参照が入らないのはまだわかるが、このコードなら変数aの要素は全て変数bを指していると考えてしまうだろう。 実際にはbの値がコピーされている。あるいは、bと同じ値を持つ別オブジェクトが生成され、それへの参照が格納されている。

組み込み型相当の型に対しては勝手にコピーが働くのかといえば、それが完全に一貫しているわけではない。ラムダは、単なる整数値でも参照キャプチャしていたからだ。

つまりこれはリストのネストレベルに依存する動作なのだろうか。フラットなリストに何かを格納する場合はコピーされ、リストにリストを格納するときはシャローコピーがされるということで良いのだろうか。

ではオブジェクトなら?

class X:
    val1 = 0
    val2 = 0
    val3 = 0
    def __str__(self):
        return '[' + str(self.val1) + ", " + str(self.val2) + ", " + str(self.val3) + ']'
    def __repr__(self):
        return str(self)

b = X()
a = [b] * 3
print(a)      # [[0, 0, 0], [0, 0, 0], [0, 0, 0]]
a[0].val1 = 1
print(a)      # [[1, 0, 0], [1, 0, 0], [1, 0, 0]]

どうやらシャローコピーになるらしい。aの各要素には同じ変数bへの参照が格納されている。

しかしPythonでは全ての値は、それが単なる整数値であったとしてもオブジェクトだったはずでは、と思った私は、以前何かの記事でid()による比較演算の話を読んだことがあったのを思い出した。 確実に処理系依存な挙動だろうが、Pythonは、インタプリタ起動時にいくつかの数の整数値オブジェクトを先に定義しておく。 そして、起動時に作ってある整数値が使われるときはそれが呼ばれ、そうでないものはその場で作られる。なので以下のようなことが起きる。

$ python3
Python 3.5.2 (default, Nov 17 2016, 17:05:23)
[GCC 5.4.0 20160609] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> a = 1
>>> b = 1
>>> id(a) == id(b)
True
>>> a = 10000
>>> b = 10000
>>> id(a) == id(b)
False
>>>

10000は予め作っておいた整数ではないので、その場でオブジェクトが作成され、abのオブジェクトのIDは別のものになる。 これが関係して区別されているのだろうか、と思ったが、スクリプトを渡して実行させるとこれは両方Trueになるので、上の状況とは異なる。

# sample4.py
a = 10000
b = 10000
print(id(a) == id(b)) # True

行き詰まってきた。Pythonでは全ての値はオブジェクトだと思ってきたが、リストにおける扱いを鑑みるにどうやらどこかに境界線があって、変数に格納していたとしてもデフォルトでコピーされるような(参照を取らない)状況が存在するらしい。

もちろん、私も今のPythonの挙動に概ね満足している。何も、以下のように振る舞えと言うつもりはない。

a = [0]*3
print(a) # [0,0,0]
a[0] = 1
print(a) # [1,1,1]

こんなことが起きたら暴動ものだ。 もし一貫性を重視した結果としてこうなったとしても、殆どのユーザーは逃げ出すだろう。 その上で、時には100万要素にもなり得るリストを扱うときに、デフォルト参照渡しにしておいた方が軽快に動くだろうというのもよくわかる。 そう思うと、これはユーザビリティを損ねずに、速度もできれば殺さないようにするため、一貫性を少しだけ壊したということなのだろうか。まあ妥当な落ち着き先のような気もする。 実際にどういう経緯でこうなったのか、また実際の境界線はどこにあるのかは知らないので以上のことは憶測に過ぎないが、もしそこでの議論が記録されていたとしたら、そういうことを調べるのも面白いかも知れない。


余談

ところで、ここまで参照参照と連呼してきたが、私はPythonにどっぷり浸かっているわけではないので、この挙動を言い表す適切な単語がこれでよいかは知らない。 少なくともこの記事では、私は「参照」という言葉を「変数が格納されているメモリ上の領域へのアクセスを提供するもの」という意味で用いている。

こういう注意書きをしている理由として、Javaに関する以下のような記事がある。

もう参照渡しとは言わせない - Qiita

この記事の筆者の主張は、Javaにおいては参照渡しなどというものは存在せず、全ては値渡しであり、「参照渡し」ではなく「参照の値渡し」であるとのことだ。 これだけではよくわからないので、「参照渡し」では説明できないというコードを引用すると、

private void method(ArrayList<String> arg){
    arg = new ArrayList<String>();
    arg.add("PHP");
}

ArrayList<String> list = new ArrayList<String>();
list.add("Java");
method(list);
System.out.println(list);    // [Java]

となる。 このような挙動は(C++における)参照渡しとしては説明できない。 C++では参照は値と同じシンタックスになるので、argの中身が書き換わるだろう。だがこれはJavaだ。

本題から逸れるが一応答えておくと、私はこれに関して、new ArrayList<String>();が新しく作られたオブジェクトへの参照を返しており、argの参照値が関数内一時オブジェクトへ書き換わったものの、元の変数listの参照先は固定されているため(参照値がコピー渡しされているため)結果が[Java]になる、と解釈した。つまりC++で同等のコードを書くと、

void method(std::vector<std::string>* ptr)
{
    ptr = new std::vector<std::string>{"PHP"};
    delete ptr;
}

std::vector<std::string>* list = new std::vector<std::string>{"Java"};
method(list);
for(auto i : *list)
    std::cout << i << std::endl; // "Java"
delete list;

と同等だ。Javaの引数のこの挙動は、値と同じシンタックスを持つC++の参照と同等ではない。 C++では参照への代入は、参照先のオブジェクトへの代入になるからだ。 無論、ポインタは間接参照に関して別のシンタックスを持つのでこれも等価物ではない。 ただ、参照の値がコピーされる、という振る舞いについては、間接参照のシンタックスを無視すればポインタの値渡しに近いだろう。

しかし引用してきた以下のコードが動くように、内部的には値への参照が渡されている。

private void method(ArrayList<String> arg){
    arg.add("PHP");
}

ArrayList<String> list = new ArrayList<String>();
list.add("Java");
method(list);
System.out.println(list);    // [Java, PHP]

如何に互いに影響を与えていようと、JavaC++とは異なる言語なので、C++での参照とJavaの参照値が異なるシンタックスを持つのは当たり前だ。 なので個人的にはこの呼び名が引っかかるというのは、「参照」と言った時に「メモリ上の値へのアクセスを提供するもの」という広い意味でなく、 「C++における参照と同じ戦略のシンタックスを持つ文法」という特定の文脈での意味に取ってしまうからだと思っている。 「参照渡し」に関してのC++の影響力が強すぎたということなのだろうか、それとも「参照渡し」を行う言語の殆どが参照に関してC++と同じシンタックスを持つために誤解を招くということなのだろうか。 誤解を生むというのなら別の言葉、例えば「参照の値渡し」などを作って陽に使い分けられるようにするのは良いことだとは思う。定義をはっきりさせるのは悪いことではない。

何にせよ、この記事では私は「参照」という言葉を、「C++における参照と全く同じシンタックスを持つオブジェクト」ではなく、「メモリ上へのアクセスを提供するもの、メモリアドレス」というより広い意味で用いていることをご理解いただきたい。

Pythonコミュニティでも「参照」という言葉がより広い意味で用いられているなら、こんな注意書きは不要なのだが、実際のところどうなのだろうか。