😩

[C/C++勢からみるPythonの?なところ](その1) - 多次元配列での隠れた「値渡し」と「参照渡し」

2023/08/21に公開
4

この記事の目的

私の備忘録です。
最近ようやくPythonを触りはじめました。
C/C++ からプログラムをはじめてきた人間として、
 「Pythonのこの書き方や仕様は分かりにくい、気持ち悪い」
と思うところを記録していこうと思っています。
結構数あったりするので。

使いこなすようになれば慣れるところだとは思うので、初心の気持ちを書いておく場所ということで。

今回は第一回。

ポインタ周りに関する例

「ポインタ」はC/C++において「難しい!!」という代表選手みたいに言われるものですが、ものすごく便利なもの、というより、これがないとプログラムが成り立たないものです。
当然、Pythonにも、javascriptにもC#にもその他どのプログラムにもあります。
「ポインタ」という言い方をしていないだけです。

俗にいう
 「値渡しと参照渡し」
です。

「難しいから隠して見えなくしちゃった」
ってことなんでしょうが、逆にそのせいで余計分かりにくくバグが入りやすくなってるんじゃないかと思ってます。

配列値の更新でおかしなことに

問題現象

こんなことを教えてくれているページがありました。

https://sonickun.hatenablog.com/entry/2014/06/13/132821

arr = [[0]*3]*5

arr = [[0 for i in range(3)] for j in range(5)]

では、同じ

[[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]]

という2次元配列が作られます。
しかし、

arr[1][2] = 1

と要素を書き換えてやろうとすると、どちらで作成したかによって結果が変わってしまいます。

arr = [[0]*3]*5
arr = [[0]*3]*5
 # [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]]

 arr[1][2] = 1
 # [[0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 0, 1]]
arr = [[0 for i in range(3)] for j in range(5)]
arr = [[0 for i in range(3)] for j in range(5)]
 # [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]]

 arr[1][2] = 1
 # [[0, 0, 0], [0, 0, 1], [0, 0, 0], [0, 0, 0], [0, 0, 0]]

なんで?

なぜこうなるのかの予想

全然意図した通り、というか直感と違う結果になってしまっていますが、これはよくある「値渡しと参照渡し」が混在することが原因だと思います。
Pythonのパーサーの中身を見たわけではないので正しくは分かりませんが、おそらく以下のようになっているのではないかと予想します。
C++ で再現してみました。

#include <stdio.h>
#include "iostream"

//print(arr)に該当
void show_arr(int s, int t, int** arr){

    std::cout << "[";
    for(int i=0; i<t; i++){
        std::cout << "[";
        for(int j=0;j<s;j++){
            std::cout << arr[i][j];
            if(j<s-1){ std::cout << ", "; }
        }
        std::cout << "]";
        if(i<t-1){ std::cout << ", "; }
    }
    std::cout << "]" << std::endl;

}


int main(){

    int a = 0;  //[0]に該当
    
    //[[0]*3]*5 の中身
    //[0]*3 の部分
    int s = 3;
    int* b;

        // *3
        b = new int[s];
        for(int i=0; i<s; i++){
            b[i] = a;
        }

    //[~]*5 の部分
    int t = 5;
    int** c;

        // *5
        c = new int*[t];
        for(int i=0; i<t; i++){
            c[i] = b;
        }


    //確認1
    show_arr(s, t, c);

    //変更
    c[1][2] = 1;

    //確認2
    show_arr(s, t, c);


    //後処理
    delete[] c;
    delete[] b;

    return 0;
}

これを実行すると、以下の結果が出ます。

[[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]]
[[0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 0, 1]]

Pythonで arr = [[0]*3]*5 とした時と同じ結果です。
これが正しいとすると、つまり、

  1. [0]*3 は、要素数3の動的配列をつくり、「変数 a の値」をそれぞれ b[0], b[1], b[2] に格納する。
    先頭のアドレスは b に格納されている。
  2. [ [0]*3 ]*5 は、要素数5の動的配列をつくり、「ポインタ変数 b の値(つまり、記録された先頭のアドレス値)」をそれぞれ c[0], c[1], ..., c[4] に格納する。
  3. c が arr である。

となっているのではないでしょうか。

「値渡し、参照渡し」的に誤解を恐れず書き直すと、

  1. [0]*3 は、要素数3の動的配列をつくり、「変数 a の値(つまり、[0] の中身 0 )」を "値渡しで" それぞれ b[0], b[1], b[2] に格納する。
  2. [ [0]*3 ]*5 は、要素数5の動的配列をつくり、「ポインタ変数 b の値(つまり、配列 b への参照値)」をそれぞれ c[0], c[1], ..., c[4] に格納する。
    「参照値」を格納しているので、これは配列 b を 参照渡しで c[0], c[1], ..., c[4] に格納している。
  3. c が arr である。

ということかと思います。

「なんで 1. のときは値渡しなのに 2. のときは参照渡しするわけ? わけわからん」
となりますよね。
私もなります。
これは「値渡し、参照渡し」となっているプログラム言語の世界で考えても絶対分からないです。
でも、C/C++ の動的配列とポインタのことがわかっているなら何も不思議なことではないです。
両方ともただ値渡ししているだけなので。
動的配列を作ったので、 2. の時に渡す値がアドレスなだけです。

ポインタというかメモリ操作辺りを隠しちゃうからこういうことが起きちゃうんですよね。

ちなみに、上の例は後での追加などもするPythonの使い方を考えて動的配列で用意しましたが、これを静的配列で用意しても動きとしては同じ結果になります。
しかし静的配列の要素数を入力値によって指定することはできないので、プログラム開始時に固定で指定することになります。
(このあたりは C/C++ の仕様)
なので実態としては上の例がより正解かと思います。

感想

これは一つの例ですが、どの時が「値渡し」でどういう時が「参照渡し」なのかパッとは分からないということは結構あります。
パッと分からないことが原因で意図しない動きになってしまい、知らないうちにバグを産んでしまうこともよく聞きます。
「こういう時は注意!」とあらかじめわかっていれば回避もできますが、あらかじめ分かる為には色々なパターンに当たって失敗してバグフィックスして・・・と、場数を踏んでパターンを記憶しなければならないので、なんとも非効率というか、泥臭いというか・・・。
「それがプログラミングのスキルが上がるということだろ!」
と言われる方もいるかと思いますが、そんなところに労力と経験値使わずにもっと違うところに使うべきかと自分は思います。
C/C++ のように。

そもそもコンピュータが実行するときは「参照渡し」なんてものはなくて、全て「値渡し」なので、それをプログラマーが制御できた方がスッキリ分かりやすいと思うのですが・・・。
上の例も配列 b のメモリアドレスを「値渡し」しているだけですし。
(それを「参照渡し」と呼んでいるだけですし)

それを考えると「パッと理解しにくいから」という理由で隠してしまうんじゃなくて、制御できるようにオープンにしておいた方がよっぽど「分かりやすい」と思うんです。
「パッと理解しやすさ」を尊重した挙句、バグを生みやすく、いろいろなパターンを知らなければバグとりもままならないでは本末転倒かと。
最近のプログラム言語はこういうのが多い気がしてます。

スマートを追求して、結局スマートから遠のいている・・・

もっと全体を熟考して仕様考えて欲しいなぁと思います。
(特に数学関連)

つい文句口調になってしまうのはご容赦ください。


追記

Pythonのリストはオブジェクトへの参照を保持しているだけ

というご指摘があり調べてみました。
こちら
https://hibiki-press.tech/python/reference_copy/590
のページによると、

Pythonでは、リスト(に限らずオブジェクト)を変数に代入する処理は、値そのものを変数に代入するのではなく、リストの「参照」を変数に代入します。

とのことです。
id()という便利な関数があることが分かったので調べてみました。

https://docs.python.org/ja/3/library/functions.html?highlight=id#id

この公式にはオブジェクトを渡したときの動作しか書かれていませんが、変数を渡すとその変数に格納されているオブジェクトの識別子(アドレス)を返すようです。
一応実験してみました。

python
print(id(5))
> 140717199319976

five = 5
print(id(five))
> 140717199319976

確かにid()に変数を渡した時とリテラルの5を渡した時共に同じ値を返しています。
変数に格納されているオブジェクトのアドレスを返すで間違いなさそうです。
そして変数に値を代入した時も、値(5)を変数に格納するのではなく、値(5)が存在する場所のアドレスを格納するようです。
(5)自体はメモリの静的領域(プログラム領域)にでも確保するのでしょう。
(文字列リテラルの確保と同じ仕組みに思えます)

そしてPythonでの変数はすべてポインタ変数で、id関数はこういう実装なんでしょうね。
(概念コード)

c++
int id(object* obj){
    return (int)obj
}

このid関数を使って、以下のようなコードを書いて確かめてみました。
arr[i][j]は変数なので、格納されているオブジェクトがどれなのか、これでわかります。
(Pythonはじめてまだ1カ月経ってない初心者なので、コードがきれいでないことはご容赦ください)

python

#---表示関数定義---
def show_arr_id(arr):
    
    print("---arr[i][j]---")
    print("arr", id(arr), sep=":")
    for i in range(0,5):
        print("arr[%d]" % i, id(arr[i]), sep=":")
        for j in range(0,3):
           print("arr[%d][%d]" %(i, j), id(arr[i][j]), sep=":")
    
    return 0

#---代入する値、0と1のid(アドレス)値表示---

print("id(0) : %d" % id(0))
print("id(1) : %d" % id(1))

print("\n")

#---[[0]*3]*5の場合---
print("===== [[0]*3]*5 =====")
arr1 = [[0]*3]*5

print(arr1)
show_arr_id(arr1)

print("\n", "--- do arr[1][2] = 1 ---", "\n")
arr1[1][2] = 1

print(arr1)
show_arr_id(arr1)

print("\n")

#--- [[0 for i in range(3)] for j in range(5)]の場合---
print("===== [[0 for i in range(3)] for j in range(5)] =====")
arr2 = [[0 for i in range(3)] for j in range(5)]

print(arr2)
show_arr_id(arr2)

print("\n", "--- do arr[1][2] = 1 ---", "\n")
arr2[1][2] = 1


print(arr2)
show_arr_id(arr2)

結果は以下になりました。

結果
id(0) : 4308867696
id(1) : 4308867728


===== [[0]*3]*5 =====
[[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]]
---arr[i][j]---
arr:4302190016
arr[0]:4302189056
arr[0][0]:4308867696
arr[0][1]:4308867696
arr[0][2]:4308867696
arr[1]:4302189056
arr[1][0]:4308867696
arr[1][1]:4308867696
arr[1][2]:4308867696
arr[2]:4302189056
arr[2][0]:4308867696
arr[2][1]:4308867696
arr[2][2]:4308867696
arr[3]:4302189056
arr[3][0]:4308867696
arr[3][1]:4308867696
arr[3][2]:4308867696
arr[4]:4302189056
arr[4][0]:4308867696
arr[4][1]:4308867696
arr[4][2]:4308867696

 --- do arr[1][2] = 1 --- 

[[0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 0, 1]]
---arr[i][j]---
arr:4302190016
arr[0]:4302189056
arr[0][0]:4308867696
arr[0][1]:4308867696
arr[0][2]:4308867728
arr[1]:4302189056
arr[1][0]:4308867696
arr[1][1]:4308867696
arr[1][2]:4308867728
arr[2]:4302189056
arr[2][0]:4308867696
arr[2][1]:4308867696
arr[2][2]:4308867728
arr[3]:4302189056
arr[3][0]:4308867696
arr[3][1]:4308867696
arr[3][2]:4308867728
arr[4]:4302189056
arr[4][0]:4308867696
arr[4][1]:4308867696
arr[4][2]:4308867728


===== [[0 for i in range(3)] for j in range(5)] =====
[[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]]
---arr[i][j]---
arr:4302189568
arr[0]:4301017088
arr[0][0]:4308867696
arr[0][1]:4308867696
arr[0][2]:4308867696
arr[1]:4302184832
arr[1][0]:4308867696
arr[1][1]:4308867696
arr[1][2]:4308867696
arr[2]:4302227968
arr[2][0]:4308867696
arr[2][1]:4308867696
arr[2][2]:4308867696
arr[3]:4302189824
arr[3][0]:4308867696
arr[3][1]:4308867696
arr[3][2]:4308867696
arr[4]:4301571328
arr[4][0]:4308867696
arr[4][1]:4308867696
arr[4][2]:4308867696

 --- do arr[1][2] = 1 --- 

[[0, 0, 0], [0, 0, 1], [0, 0, 0], [0, 0, 0], [0, 0, 0]]
---arr[i][j]---
arr:4302189568
arr[0]:4301017088
arr[0][0]:4308867696
arr[0][1]:4308867696
arr[0][2]:4308867696
arr[1]:4302184832
arr[1][0]:4308867696
arr[1][1]:4308867696
arr[1][2]:4308867728
arr[2]:4302227968
arr[2][0]:4308867696
arr[2][1]:4308867696
arr[2][2]:4308867696
arr[3]:4302189824
arr[3][0]:4308867696
arr[3][1]:4308867696
arr[3][2]:4308867696
arr[4]:4301571328
arr[4][0]:4308867696
arr[4][1]:4308867696
arr[4][2]:4308867696

この結果から、確かに値自体を保持していないことが分かりました。
従って、Pythonでは代入はすべて「参照渡し」であるようです。

上で予想したC/C++コードでは変数 a に値 0 自体を格納させているので、正しくなかったようです。
この結果をもとに修正した同じ動きをするC/C++のコードが以下です。
const修飾子がついた変数は、静的領域(プログラム領域)に確保される想定です(実装によるようです)。

C++
#include <stdio.h>
#include "iostream"

//print(arr)に該当
void show_arr(int s, int t, void** arr){

    std::cout << "[";
    for(int i=0; i<t; i++){
        std::cout << "[";
        for(int j=0;j<s;j++){
            std::cout << *(int*)((void**)(arr[i]))[j];
            if(j<s-1){ std::cout << ", "; }
        }
        std::cout << "]";
        if(i<t-1){ std::cout << ", "; }
    }
    std::cout << "]" << std::endl;


}


int main(){

    const int zero = 0;
    const int one = 1;

    void* a = (void*)&zero;  //[0]に該当
    

    //[[0]*3]*5 の中身

    //[0]*3 の部分
    int s = 3;
    void** b;

        // *3
        b = new void*[s];
        for(int i=0; i<s; i++){
            b[i] = (void*)a;
        }

    //[~]*5 の部分
    int t = 5;
    void** c;

        // *5
        c = new void*[t];
        for(int i=0; i<t; i++){
            c[i] = (void*)b;
        }


    //確認1
    show_arr(s, t, c);

    //変更
    ((void**)(c[1]))[2] = (void*)&one;

    //確認2
    show_arr(s, t, c);


    //後処理
    delete[] c;
    delete[] b;

    return 0;
}

変数はすべてポインタ変数に変わりました。
(s,tはPythonの方では直接値を指定するので例外とします)
またintのポインタのままにすると、bはint** b;、cはint*** c;というように「*」が増えて処理が同一にならない為、void*を使って処理が同一になるようにしています。
変数自体がポインタ変数なので、配列はポインタを保持するポインタ配列、ということです。
よりPythonでの実行結果に近づけるには、void* a = (void*)&zero;ではなくuintptr_t a = (uintptr_t)((void*)(&zero));としてやって、アドレス値を整数として処理してやった方が良かったかもしれません。
(実際の実装はそうなっていたりするのかな?)

Pythonでの代入はすべて参照渡し

これを前提に考えてプログラムすれば、いまよりバグが入りにくくプログラムできるかもしれません。
ただ、
  ・意図した(直観的な)動きと違う動きをされる
  ・いつオブジェクト自体が作られて確保されるのかがわかりにくい
というのが現時点での感想です。

C/C++などがすべて「値渡し」のプログラムなので、すべて「参照渡し」のプログラムスタイルに慣れる必要がある、ということなのかもしれません。


ご指摘頂きありがとうございました。
勉強になりました。

Discussion

laniuslanius

同じ値で2次元配列作る場合であれば、下記の方法が良いかもしれません。

N, M = 3, 5
arr = [[0] * N for _ in range(M)]

arr[1][2] = 1

print(arr)
# [[0, 0, 0], [0, 0, 1], [0, 0, 0], [0, 0, 0], [0, 0, 0]]
K.HidakaK.Hidaka

なるほど!
[0]*Nの部分は値配列(ポインタ配列ではなく)になるので、[0 for i in range(N)]と書いても[0]*Nと書いても同じになるわけですね。
そうであればこちらの書き方のほうがスマートでスッキリしています。
ありがとうございます!
勉強になります!

nkaynkay

[0]*3 は、要素数3の動的配列をつくり、「変数 a の値(つまり、[0] の中身 0 )」を "値渡しで" それぞれ b[0], b[1], b[2] に格納する。

というのはどういうことなんでしょうか…?

pythonのリストは変数a(の参照オブジェクト)への参照を持つだけだと思うのですが、いかがでしょうか。

K.HidakaK.Hidaka

ご指摘ありがとうございます。
ご指摘を受けてちょっと調べてみました。
こちら
のページの内容に従うと、代入はすべてアドレスを渡しているので「参照渡し」のようです。

C/C++では int a=0; とすると変数aとして確保されたメモリには0のバイト列 0x00000000 (つまり値0自体)が直接入りますが、これによるとPythonでは変数 a として確保されたメモリには、メモリ内のプログラム領域(静的領域)に確保された0(0x00000000 という値)へのアドレス値が入るようです。

「Pythonではすべては参照渡し」ということなのですね。

上の説明は、C/C++ で予想した上のプログラムを説明していました。
変数 a には 0x00000000 という「値0そのもの」が入っているので、b[0],b[1],b[2]に値0そのものを渡している、だから「値渡し」と書いていました。
しかしどうやらこのC/C++プログラムの予想は違っていたようです。

確認と実験しなおした結果を追記いたしました。
また間違い等ありましたらご指摘いただければ幸いです。

ご指摘ありがとうございます。
大変勉強になりました。