Python のオブジェクトのコピーとメモリ レイアウトについての深い理解

序文

この記事では、Python のコピー問題を中心に紹介します。早速、コードを直接見てみましょう。次のプログラム部分の出力結果をご存知ですか?

a = [1, 2, 3, 4]
b = a
print(f"{a = } \t|\t {b = }")
a[0] = 100
print(f"{a = } \t|\t {b = }")
复制代码
a = [1, 2, 3, 4]
b = a.copy()
print(f"{a = } \t|\t {b = }")
a[0] = 100
print(f"{a = } \t|\t {b = }")
复制代码
a = [[1, 2, 3], 2, 3, 4]
b = a.copy()
print(f"{a = } \t|\t {b = }")
a[0][0] = 100
print(f"{a = } \t|\t {b = }")
复制代码
a = [[1, 2, 3], 2, 3, 4]
b = copy.copy(a)
print(f"{a = } \t|\t {b = }")
a[0][0] = 100
print(f"{a = } \t|\t {b = }")
复制代码
a = [[1, 2, 3], 2, 3, 4]
b = copy.deepcopy(a)
print(f"{a = } \t|\t {b = }")
a[0][0] = 100
print(f"{a = } \t|\t {b = }")
复制代码

この記事では、上記のプログラムを詳細に分析します。

Python オブジェクトのメモリ レイアウト

まず、メモリ内のデータの論理分散に関する便利な Web サイト、 pythontutor.com/visualize.hを紹介します。

このサイトで最初のコードを実行します。 

上記の出力結果から、a と b は同じメモリ内のデータ オブジェクトを指します。したがって、最初のコードの出力は同じになります。オブジェクトのメモリアドレスはどのように決定すればよいでしょうか? Python では、オブジェクトのメモリ アドレスを取得するための組み込み関数 id() が提供されています。

a = [1, 2, 3, 4]
b = a
print(f"{a = } \t|\t {b = }")
a[0] = 100
print(f"{a = } \t|\t {b = }")
print(f"{id(a) = } \t|\t {id(b) = }")
# 输出结果
# a = [1, 2, 3, 4] 	|	 b = [1, 2, 3, 4]
# a = [100, 2, 3, 4] 	|	 b = [100, 2, 3, 4]
# id(a) = 4393578112 	|	 id(b) = 4393578112
复制代码

実際、上記のオブジェクト メモリ レイアウトにはいくつかの問題があるか、正確さが十分ではありませんが、さまざまなオブジェクト間の関係を示すこともできます。Cpython では、各変数は表現されるデータを指すポインタとみなすことができ、このポインタには Python オブジェクトのメモリ アドレスが格納されます。

Python では、リストには実際のデータではなく、各 Python オブジェクトへのポインターが実際に保持されるため、上記の小さなコードは、次の図を使用してメモリ内のオブジェクトのレイアウトを表すことができます。 

変数 a はメモリ上のリストを指しており [1, 2, 3, 4]、リストにはデータが 4 つあり、この 4 つのデータはポインタであり、この 4 つのポインタはメモリ内の 1、2、3、4 の 4 つのデータを指します。と疑問に思うかもしれませんが、これは問題ではないでしょうか?これらはすべて整数データです。なぜ整数データをリストに直接格納しないのでしょうか。なぜポインターを追加して、このデータをポイントするのでしょうか?

実際、Python では、任意の Python オブジェクトをリストに保存できます。たとえば、次のプログラムは正当です。

data = [1, {1:2, 3:4}, {'a', 1, 2, 25.0}, (1, 2, 3), "hello world"]
复制代码

上図の最初から最後までのデータのデータ型は、整数データ、辞書、セット、タプル、文字列となっていますが、Python のこの機能を実現するには、ポインタの機能が要件を満たしているでしょうか。各ポインタが占有するメモリは同じなので、配列を使用して Python オブジェクトへのポインタを格納し、そのポインタが実際の Python オブジェクトを指すようにすることができます。

小さなテスト

上記の分析の後、次のコードを見て、そのメモリ レイアウトを見てみましょう。

data = [[1, 2, 3], 4, 5, 6]
data_assign = data
data_copy = data.copy()
复制代码

  • data_assign = data, この代入文のメモリレイアウトについては以前にもお話しましたが、そのおさらいもしています この代入文の意味は、data_assignとdataが指すデータが同じデータ、つまり同じリストであるということです。
  • data_copy = data.copy(), この代入ステートメントの意味は、data が指すデータの浅いコピーを作成し、data_copy がコピーされたデータを指すようにすることです。ここでの浅いコピーの意味は、リスト内のポインターではなく、リスト内の各ポインターをコピーすることです。リストのデータがコピーされます。上記のオブジェクトのメモリ レイアウト図から、data_copy が新しいリストを指していることがわかりますが、リスト内のポインタが指すデータは、データ リスト内のポインタが指すデータと同じです。は緑の矢印、データは黒の矢印で表されます。

オブジェクトのメモリアドレスを表示する

前回の記事では、主にオブジェクトのメモリ レイアウトを分析しましたが、このセクションでは、Python を使用してこれを検証するための非常に効果的なツールを提供します。Python では、 id() を使用してオブジェクトのメモリ アドレスを表示でき、 id(a) はオブジェクト a が指すオブジェクトのメモリ アドレスを表示します。

  • 次のプログラムの出力を見てください。
a = [1, 2, 3]
b = a
print(f"{id(a) = } {id(b) = }")
for i in range(len(a)):
    print(f"{i = } {id(a[i]) = } {id(b[i]) = }")
复制代码

以前の分析によると、a と b は同じメモリ ブロックを指します。つまり、2 つの変数は同じ Python オブジェクトを指します。そのため、上記の出力 ID 結果 a と b は同じであり、上記の出力結果は次のようになります。以下に続きます:

id(a) = 4392953984 id(b) = 4392953984
i = 0 id(a[i]) = 4312613104 id(b[i]) = 4312613104
i = 1 id(a[i]) = 4312613136 id(b[i]) = 4312613136
i = 2 id(a[i]) = 4312613168 id(b[i]) = 4312613168
复制代码
  • 浅いコピーのメモリ アドレスを見てください。
a = [[1, 2, 3], 4, 5]
b = a.copy()
print(f"{id(a) = } {id(b) = }")
for i in range(len(a)):
    print(f"{i = } {id(a[i]) = } {id(b[i]) = }")
复制代码

以前の分析によると、リスト自体を呼び出すコピー方法はリストの浅いコピーを作成することであり、リストのポインタ データのみがコピーされ、リスト内のポインタが指す実際のデータはコピーされません。したがって、リスト内のデータを走査して、指定されたオブジェクトのアドレスを取得すると、リスト a とリスト b によって返される結果は同じになりますが、前の例との違いは、リスト a とリスト b によって指定されたリストのアドレスが異なることです。 aとbは異なります(データをコピーしているので、以下の浅いコピーの結果が分かりますを参照してください)。 

次の出力結果を上記のテキストと組み合わせることで理解できます。

id(a) = 4392953984 id(b) = 4393050112 # 两个对象的输出结果不相等
i = 0 id(a[i]) = 4393045632 id(b[i]) = 4393045632 # 指向的是同一个内存对象因此内存地址相等 下同
i = 1 id(a[i]) = 4312613200 id(b[i]) = 4312613200
i = 2 id(a[i]) = 4312613232 id(b[i]) = 4312613232
复制代码

コピーモジュール

Python には主にオブジェクトのコピーに使用される組み込みパッケージ copy があり、このモジュールには主に copy.copy(x) と copy.deepcopy() の 2 つのメソッドがあります。

  • copy.copy(x) メソッドは主に浅いコピーに使用され、このメソッドの意味はリストに対しては、リスト自体の x.copy() メソッドと同じで、浅いコピーを実行します。このメソッドは、新しい Python オブジェクトを構築し、オブジェクト x 内のすべてのデータ参照 (ポインター) をコピーします。 

  • copy.deepcopy(x) このメソッドは主にオブジェクト x のディープ コピーを作成することです。ここでのディープ コピーの意味は、新しいオブジェクトを構築し、オブジェクト x 内の各オブジェクトを再帰的に表示することです。再帰的に表示されたオブジェクトが不変オブジェクトはコピーされません。表示されたオブジェクトが可変オブジェクトの場合、新しいメモリ空間が再度開かれ、オブジェクト x の元のデータが新しいメモリにコピーされます。(次のセクションで可変オブジェクトと不変オブジェクトを分析します)

  • 上記の分析によると、ディープ コピーのコストは浅いコピーのコストよりも高く、特にオブジェクト内に多くのサブオブジェクトがある場合、多くの時間とメモリ スペースが必要になることがわかります。

  • Python オブジェクトの場合、ディープ コピーとシャロー コピーの違いは主に複合オブジェクトにあります (オブジェクトには、リスト、祖先、クラスのインスタンスなどのサブオブジェクトがあります)。この点は主に、次のセクションの可変オブジェクトと不変オブジェクトに関連します。

可変オブジェクトと不変オブジェクト、およびオブジェクトのコピー

Python のオブジェクトには主に Mutable オブジェクトと Immutable オブジェクトの 2 種類があり、いわゆる Mutable オブジェクトはオブジェクトの内容を変更できることを意味し、immutable オブジェクトはオブジェクトの内容を変更できないことを意味します。

  • 可変オブジェクト: リスト (list)、辞書 (dict)、コレクション (set)、バイト配列 (bytearray)、クラスのインスタンス オブジェクトなど。
  • 不変オブジェクト: 整数 (int)、浮動小数点 (float)、複素数 (complex)、文字列、タプル、不変コレクション (frozenset)、バイト (bytes)。

これを見て、整数や文字列は変更できないのではないかと疑問に思うかもしれません。

a = 10
a = 100
a = "hello"
a = "world"
复制代码

たとえば、次のコードは正しく、エラーは発生しませんが、実際には、 a が指すオブジェクトが変更されています。最初のオブジェクトが整数または文字列を指している場合、新しい別の整数または文字列が再割り当てされる場合、オブジェクトの場合、Python は新しいオブジェクトを作成します。次のコードを使用して確認できます。

a = 10
print(f"{id(a) = }")
a = 100
print(f"{id(a) = }")
a = "hello"
print(f"{id(a) = }")
a = "world"
print(f"{id(a) = }")
复制代码

上記のプログラムの出力は次のとおりです。

id(a) = 4365566480
id(a) = 4365569360
id(a) = 4424109232
id(a) = 4616350128
复制代码

変数が指すメモリ オブジェクトは再代入後に変更されており (メモリ アドレスが変更されたため)、不変オブジェクトであることがわかります。変数は再代入できますが、取得されたオブジェクトは元のオブジェクトに対して変更されていません。 !

次に、可変オブジェクト リストが変更された後にメモリ アドレスがどのように変化するかを見てみましょう。

data = []
print(f"{id(data) = }")
data.append(1)
print(f"{id(data) = }")
data.append(1)
print(f"{id(data) = }")
data.append(1)
print(f"{id(data) = }")
data.append(1)
print(f"{id(data) = }")
复制代码

上記のコードの出力は次のようになります。

id(data) = 4614905664
id(data) = 4614905664
id(data) = 4614905664
id(data) = 4614905664
id(data) = 4614905664
复制代码

上記の出力結果から、リストに新しいデータを追加(リストを変更)しても、リスト自体のアドレスは変化しない、可変オブジェクトであることが分かります。

ディープ コピーとシャロー コピーについては先ほど説明しましたが、次のコードを分析してみましょう。

data = [1, 2, 3]
data_copy = copy.copy(data)
data_deep = copy.deepcopy(data)
print(f"{id(data ) = } | {id(data_copy) = } | {id(data_deep) = }")
print(f"{id(data[0]) = } | {id(data_copy[0]) = } | {id(data_deep[0]) = }")
print(f"{id(data[1]) = } | {id(data_copy[1]) = } | {id(data_deep[1]) = }")
print(f"{id(data[2]) = } | {id(data_copy[2]) = } | {id(data_deep[2]) = }")
复制代码

上記のコードの出力は次のようになります。

id(data ) = 4620333952 | id(data_copy) = 4619860736 | id(data_deep) = 4621137024
id(data[0]) = 4365566192 | id(data_copy[0]) = 4365566192 | id(data_deep[0]) = 4365566192
id(data[1]) = 4365566224 | id(data_copy[1]) = 4365566224 | id(data_deep[1]) = 4365566224
id(data[2]) = 4365566256 | id(data_copy[2]) = 4365566256 | id(data_deep[2]) = 4365566256
复制代码

これを見ると、あなたは間違いなく非常に混乱するでしょう。なぜ深いコピーと浅いコピーが指すメモリオブジェクトは同じなのでしょうか? 前のセクションでは、浅いコピーは参照をコピーするので、それらが指すオブジェクトは同じであることが理解できましたが、なぜ深いコピー後に指すメモリオブジェクトは浅いコピーと同じなのでしょうか? これは、リスト内のデータが不変オブジェクトである整数データであるためです。data または data_copy が指すオブジェクトが変更された場合、そのオブジェクトは新しいオブジェクトを指し、元のオブジェクトを直接変更することはありません。実際、不変オブジェクトは、このメモリ内のオブジェクトが変更されないため、再割り当てのために新しいメモリ空間を開く必要はありません。

コピー可能なオブジェクトを見てみましょう。

data = [[1], [2], [3]]
data_copy = copy.copy(data)
data_deep = copy.deepcopy(data)
print(f"{id(data ) = } | {id(data_copy) = } | {id(data_deep) = }")
print(f"{id(data[0]) = } | {id(data_copy[0]) = } | {id(data_deep[0]) = }")
print(f"{id(data[1]) = } | {id(data_copy[1]) = } | {id(data_deep[1]) = }")
print(f"{id(data[2]) = } | {id(data_copy[2]) = } | {id(data_deep[2]) = }")
复制代码

上記のコードの出力は次のようになります。

id(data ) = 4619403712 | id(data_copy) = 4617239424 | id(data_deep) = 4620032640
id(data[0]) = 4620112640 | id(data_copy[0]) = 4620112640 | id(data_deep[0]) = 4620333952
id(data[1]) = 4619848128 | id(data_copy[1]) = 4619848128 | id(data_deep[1]) = 4621272448
id(data[2]) = 4620473280 | id(data_copy[2]) = 4620473280 | id(data_deep[2]) = 4621275840
复制代码

上記のプログラムの出力から、変更可能なオブジェクトがリストに格納されている場合、ディープ コピーを実行すると、まったく新しいオブジェクトが作成されることがわかります (ディープ コピーのオブジェクト メモリ アドレスは、浅いコピー)。

コードフラグメント分析

上記の学習を終えると、この記事の冒頭で提起した疑問は非常に単純なものになるはずです。ここで、これらのコード スニペットを分析してみましょう。

a = [1, 2, 3, 4]
b = a
print(f"{a = } \t|\t {b = }")
a[0] = 100
print(f"{a = } \t|\t {b = }")
复制代码

これは非常に単純です。a と b の異なる変数は同じリストを指します。a のデータが変更されると、b のデータも変更されます。出力は次のようになります。

a = [1, 2, 3, 4] 	|	 b = [1, 2, 3, 4]
a = [100, 2, 3, 4] 	|	 b = [100, 2, 3, 4]
id(a) = 4614458816 	|	 id(b) = 4614458816
复制代码

2 番目のコード スニペットを見てみましょう

a = [1, 2, 3, 4]
b = a.copy()
print(f"{a = } \t|\t {b = }")
a[0] = 100
print(f"{a = } \t|\t {b = }")
复制代码

b は a の浅いコピーであるため、a と b は異なるリストを指しますが、リスト内のデータは同じを指します。ただし、整数データは不変であるため、a[0] が変更されても、元のデータは変更されません。ただし、新しい整数データが​​メモリ内に作成されるため、リスト b の内容は変更されません。したがって、上記のコードの出力は次のようになります。

a = [1, 2, 3, 4] 	|	 b = [1, 2, 3, 4]
a = [100, 2, 3, 4] 	|	 b = [1, 2, 3, 4]
复制代码

3 番目のフラグメントを見てみましょう。

a = [[1, 2, 3], 2, 3, 4]
b = a.copy()
print(f"{a = } \t|\t {b = }")
a[0][0] = 100
print(f"{a = } \t|\t {b = }")
复制代码

これは 2 番目のフラグメントの分析と似ていますが、a[0] は変数オブジェクトなので、データが変更されても a[0] の点は変化しないため、a の変更内容は b に影響します。

a = [[1, 2, 3], 2, 3, 4] 	|	 b = [[1, 2, 3], 2, 3, 4]
a = [[100, 2, 3], 2, 3, 4] 	|	 b = [[100, 2, 3], 2, 3, 4]
复制代码

最後のスニペット:

a = [[1, 2, 3], 2, 3, 4]
b = copy.deepcopy(a)
print(f"{a = } \t|\t {b = }")
a[0][0] = 100
print(f"{a = } \t|\t {b = }")
复制代码

ディープ コピーでは、a[0] と同一のオブジェクトがメモリ内に再作成され、b[0] がこのオブジェクトを指すようにします。そのため、a[0] を変更しても b[0] には影響しないため、出力は次のようになります。

a = [[1, 2, 3], 2, 3, 4] 	|	 b = [[1, 2, 3], 2, 3, 4]
a = [[100, 2, 3], 2, 3, 4] 	|	 b = [[1, 2, 3], 2, 3, 4]
复制代码

Python オブジェクトをわかりやすく理解する

Cpython がリスト データ構造を実装する方法と、リストに何が定義されているかを簡単に見てみましょう。

typedef struct {
    PyObject_VAR_HEAD
    /* Vector of pointers to list elements.  list[0] is ob_item[0], etc. */
    PyObject **ob_item;

    /* ob_item contains space for 'allocated' elements.  The number
     * currently in use is ob_size.
     * Invariants:
     *     0 <= ob_size <= allocated
     *     len(list) == ob_size
     *     ob_item == NULL implies ob_size == allocated == 0
     * list.sort() temporarily sets allocated to -1 to detect mutations.
     *
     * Items must normally not be NULL, except during construction when
     * the list is not yet visible outside the function that builds it.
     */
    Py_ssize_t allocated;
} PyListObject;
复制代码

上記で定義された構造の中で、次のものが挙げられます。

  • 「allocated」は、割り当てられたメモリ空間の量、つまり格納できるポインタの数を示し、すべての空間を使い果たした場合は、再度メモリ空間を割り当てる必要があります。
  • ob_item は、メモリ内の Python オブジェクトへのポインタを実際に格納する配列を指します。たとえば、リストの最初のオブジェクトへのポインタを取得したい場合は、list->ob_item[0] です。実際のデータ、それは *(list->ob_item[ 0]) です。
  • PyObject_VAR_HEAD は構造体の部分構造を定義するマクロであり、この部分構造の定義は次のとおりです。
typedef struct {
    PyObject ob_base;
    Py_ssize_t ob_size; /* Number of items in variable part */
} PyVarObject;
复制代码
  • ここではオブジェクト PyObject については説明しませんが, 主に ob_size について説明します, これはリストに格納されているデータの数を示します. これは割り当てられたとは異なります. 割り当てられたは ob_item が指す配列にどれだけのスペースがあるかを示し, ob_size はその方法を示します多くの項目が配列に格納されています。データ ob_size <= が割り当てられています。

リストの構造を理解した後は、以前のメモリ レイアウトを理解できるはずです。すべてのリストは実際のデータを格納するのではなく、これらのデータへのポインタを格納します。

要約する

この記事では主に、Python におけるオブジェクトのコピーとメモリ レイアウト、オブジェクト メモリ アドレスの検証について紹介し、最後にリスト オブジェクトのメモリ レイアウトを理解するのに役立つ cpython の内部リスト実装の構造を紹介します。


以上がこの記事の内容です、私はいじめっ子です、また次号でお会いしましょう!

おすすめ

転載: blog.csdn.net/weixin_73136678/article/details/128571590