Mengapa Python hanya membuat salinan dari elemen individual ketika iterasi daftar?

31

Saya baru menyadari bahwa dengan Python, jika ada yang menulis

for i in a:
    i += 1

Elemen-elemen dari daftar asli asebenarnya tidak akan terpengaruh sama sekali, karena variabel iternyata hanya salinan dari elemen asli di a.

Untuk memodifikasi elemen asli,

for index, i in enumerate(a):
    a[index] += 1

akan dibutuhkan.

Saya sangat terkejut dengan perilaku ini. Ini tampaknya sangat berlawanan dengan intuisi, tampaknya berbeda dari bahasa lain dan telah mengakibatkan kesalahan pada kode saya yang harus saya debug sejak lama hari ini.

Saya telah membaca Tutorial Python sebelumnya. Hanya untuk memastikan, saya memeriksa buku itu lagi sekarang, dan bahkan tidak menyebutkan perilaku ini sama sekali.

Apa alasan di balik desain ini? Apakah ini diharapkan menjadi praktik standar dalam banyak bahasa sehingga tutorial percaya bahwa pembaca harus mendapatkannya secara alami? Dalam bahasa lain apa perilaku yang sama pada iterasi hadir, yang harus saya perhatikan di masa depan?

xji
sumber
19
Itu hanya benar jika itidak berubah atau Anda sedang melakukan operasi yang tidak bermutasi. Dengan daftar bersarang for i in a: a.append(1)akan memiliki perilaku yang berbeda; Python tidak menyalin daftar bersarang. Namun integer tidak dapat diubah dan penambahan mengembalikan objek baru, itu tidak mengubah yang lama.
jonrsharpe
10
Tidak mengherankan sama sekali. Saya tidak bisa memikirkan bahasa yang tidak persis sama untuk array tipe dasar seperti integer. Misalnya, coba javascript a=[1,2,3];a.forEach(i => i+=1);alert(a). Sama di C #
edc65
7
Apakah Anda berharap i = i + 1untuk mempengaruhi a?
deltab
7
Perhatikan bahwa perilaku ini tidak berbeda dalam bahasa lain. C, Javascript, Java dll berperilaku seperti ini.
Slebetman
1
@jonrsharpe untuk daftar "+ =" mengubah daftar lama, sementara "+" membuat yang baru
Vasily Alexeev

Jawaban:

68

Saya sudah menjawab pertanyaan yang sama belakangan ini dan sangat penting untuk disadari yang +=dapat memiliki arti yang berbeda:

  • Jika tipe data mengimplementasikan penambahan di tempat (yaitu memiliki __iadd__fungsi yang berfungsi dengan benar ) maka data yang imengacu diperbarui (tidak masalah apakah itu ada dalam daftar atau di tempat lain).

  • Jika tipe data tidak menerapkan __iadd__metode i += xpernyataan hanya untuk gula sintaksis i = i + x, sehingga nilai baru dibuat dan ditugaskan ke nama variabel i.

  • Jika tipe data mengimplementasikan __iadd__tetapi melakukan sesuatu yang aneh. Bisa jadi itu diperbarui ... atau tidak - itu tergantung pada apa yang diterapkan di sana.

Bilangan bulat ular, float, string tidak diimplementasikan __iadd__sehingga ini tidak akan diperbarui di tempat. Namun tipe data lain suka numpy.arrayatau listmenerapkannya dan akan berperilaku seperti yang Anda harapkan. Jadi ini bukan masalah salin atau tidak-salin ketika iterasi (biasanya tidak melakukan salin untuk lists dan tuples - tetapi itu juga tergantung pada implementasi wadah __iter__dan __getitem__metode!) - ini lebih merupakan masalah tipe data Anda telah menyimpannya di a.

MSeifert
sumber
2
Ini adalah penjelasan yang benar untuk perilaku yang dijelaskan dalam pertanyaan.
pabouk
19

Klarifikasi - terminologi

Python tidak membedakan antara konsep referensi dan pointer . Mereka biasanya hanya menggunakan referensi istilah , tetapi jika Anda membandingkan dengan bahasa seperti C ++ yang memang memiliki perbedaan itu - itu jauh lebih dekat dengan pointer .

Karena penanya jelas berasal dari latar belakang C ++, dan karena perbedaan itu - yang diperlukan untuk penjelasan - tidak ada dalam Python, saya telah memilih untuk menggunakan terminologi C ++, yaitu:

  • Nilai : Data aktual yang ada di memori. void foo(int x);adalah tanda tangan dari suatu fungsi yang menerima bilangan bulat berdasarkan nilai .
  • Pointer : Alamat memori yang diperlakukan sebagai nilai. Dapat ditangguhkan untuk mengakses memori yang ditunjuknya. void foo(int* x);adalah tanda tangan dari fungsi yang menerima integer oleh pointer .
  • Referensi : Gula di sekitar petunjuk. Ada penunjuk di belakang layar, tetapi Anda hanya dapat mengakses nilai yang ditangguhkan dan tidak dapat mengubah alamat yang ditunjuknya. void foo(int& x);adalah tanda tangan dari fungsi yang menerima bilangan bulat dengan referensi .

Apa maksud Anda "berbeda dari bahasa lain"? Sebagian besar bahasa yang saya tahu dukungan untuk setiap loop menyalin elemen kecuali jika diperintahkan sebaliknya.

Khusus untuk Python (meskipun banyak dari alasan ini dapat berlaku untuk bahasa lain dengan konsep arsitektur atau filosofi yang serupa):

  1. Perilaku ini dapat menyebabkan bug bagi orang yang tidak menyadarinya, tetapi perilaku alternatif dapat menyebabkan bug bahkan bagi mereka yang menyadarinya . Saat Anda menetapkan variabel ( i) Anda biasanya tidak berhenti dan mempertimbangkan semua variabel lain yang akan diubah karena itu ( a). Membatasi ruang lingkup yang sedang Anda kerjakan adalah faktor utama dalam mencegah kode spageti, dan oleh karena itu iterasi dengan salinan biasanya merupakan standar bahkan dalam bahasa yang mendukung iterasi dengan referensi.

  2. Variabel python selalu berupa pointer tunggal, jadi lebih murah untuk beralih dengan salinan - lebih murah daripada mengulang dengan referensi, yang akan membutuhkan penangguhan tambahan setiap kali Anda mengakses nilai.

  3. Python tidak memiliki konsep variabel referensi seperti - misalnya - C ++. Yaitu, semua variabel dalam Python sebenarnya adalah referensi, tetapi dalam arti bahwa mereka adalah pointer - bukan referensi konstanta di belakang layar seperti type& nameargumen C ++ . Karena konsep ini tidak ada di Python, mengimplementasikan iterasi dengan referensi - apalagi menjadikannya default! - akan membutuhkan menambahkan lebih banyak kompleksitas ke bytecode.

  4. forPernyataan Python tidak hanya bekerja pada array, tetapi pada konsep generator yang lebih umum. Di belakang layar, Python memanggil iterarray Anda untuk mendapatkan objek yang - ketika Anda memanggilnya next- baik mengembalikan elemen berikutnya atau raisesa StopIteration. Ada beberapa cara untuk mengimplementasikan generator di Python, dan akan jauh lebih sulit untuk mengimplementasikannya untuk iterasi-oleh-referensi.

Idan Arye
sumber
Terima kasih atas jawabannya. Tampaknya pemahaman saya tentang iterator masih belum cukup solid. Bukankah iterators dalam referensi C ++ secara default? Jika Anda merujuk iterator, Anda selalu dapat segera mengubah nilai elemen wadah asli?
xji
4
Python tidak mengulangi dengan referensi (baik, dengan nilai, tetapi nilai adalah referensi). Mencoba ini dengan daftar objek yang bisa berubah dengan cepat akan menunjukkan bahwa tidak ada penyalinan yang terjadi.
jonrsharpe
Iterator dalam C ++ sebenarnya adalah objek yang dapat ditangguhkan untuk mengakses nilai dalam array. Untuk memodifikasi elemen asli, Anda menggunakan *it = ...- tetapi sintaks semacam ini sudah menunjukkan Anda memodifikasi sesuatu di tempat lain - yang membuat alasan # 1 kurang dari masalah. Alasan # 2 dan # 3 tidak berlaku juga, karena dalam C ++ penyalinan mahal dan konsep variabel referensi ada. Adapun alasan # 4 - kemampuan untuk mengembalikan referensi memungkinkan implementasi sederhana untuk semua kasus.
Idan Arye
1
@jonrsharpe Ya, itu disebut dengan referensi, tetapi dalam bahasa apa pun yang memiliki perbedaan antara pointer dan referensi, jenis iterasi ini akan menjadi iterasi dengan pointer (dan karena pointer adalah nilai - iterasi dengan nilai). Saya akan menambahkan klarifikasi.
Idan Arye
20
Paragraf pertama Anda menunjukkan bahwa Python, seperti bahasa-bahasa lainnya, menyalin elemen dalam for for. Tidak. Itu tidak membatasi ruang lingkup perubahan yang Anda buat untuk elemen itu. OP hanya melihat perilaku ini karena elemen mereka tidak dapat diubah; bahkan tanpa menyebutkan bahwa perbedaan jawaban Anda paling tidak lengkap dan paling buruk menyesatkan.
jonrsharpe
11

Tidak ada jawaban di sini yang memberi Anda kode untuk dikerjakan untuk benar-benar menggambarkan mengapa ini terjadi di tanah Python. Dan ini menyenangkan untuk dilihat dalam pendekatan yang lebih mendalam jadi begini.

Alasan utama bahwa ini tidak berfungsi seperti yang Anda harapkan adalah karena dalam Python, ketika Anda menulis:

i += 1

itu tidak melakukan apa yang Anda pikir sedang dilakukan. Bilangan bulat tidak berubah. Ini bisa dilihat ketika Anda melihat apa objek sebenarnya dalam Python:

a = 0
print('ID of the first integer:', id(a))
a += 1
print('ID of the first integer +=1:', id(a))

Fungsi id mewakili nilai unik dan konstan untuk objek dalam masa pakai itu. Secara konseptual, ini dipetakan secara longgar ke alamat memori dalam C / C ++. Menjalankan kode di atas:

ID of the first integer: 140444342529056
ID of the first integer +=1: 140444342529088

Ini berarti yang pertama atidak lagi sama dengan yang kedua a, karena id mereka berbeda. Secara efektif mereka berada di lokasi yang berbeda dalam memori.

Namun, dengan suatu benda, berbagai hal bekerja secara berbeda. Saya telah menimpa +=operator di sini:

class CustomInt:
  def __iadd__(self, other):
    # Override += 1 for this class
    self.value = self.value + other.value
    return self

  def __init__(self, v):
    self.value = v

ints = []
for i in range(5):
  int = CustomInt(i)
  print('ID={}, value={}'.format(id(int), i))
  ints.append(int)


for i in ints:
  i += CustomInt(i.value)

print("######")
for i in ints:
  print('ID={}, value={}'.format(id(i), i.value))

Menjalankan ini menghasilkan output berikut:

ID=140444284275400, value=0
ID=140444284275120, value=1
ID=140444284275064, value=2
ID=140444284310752, value=3
ID=140444284310864, value=4
######
ID=140444284275400, value=0
ID=140444284275120, value=2
ID=140444284275064, value=4
ID=140444284310752, value=6
ID=140444284310864, value=8

Perhatikan bahwa atribut id dalam kasus ini sebenarnya sama untuk kedua iterasi, meskipun nilai objek berbeda (Anda juga bisa menemukan idnilai int yang dipegang objek, yang akan berubah saat bermutasi - karena bilangan bulat tidak berubah).

Bandingkan dengan ketika Anda menjalankan latihan yang sama dengan objek yang tidak berubah:

ints_primitives = []
for i in range(5):
  int = i
  ints_primitives.append(int)
  print('ID={}, value={}'.format(id(int), i))

print("######")
for i in ints_primitives:
  i += 1
  print('ID={}, value={}'.format(id(int), i))


print("######")
for i in ints_primitives:
  print('ID={}, value={}'.format(id(i), i))

Output ini:

ID=140023258889248, value=0
ID=140023258889280, value=1
ID=140023258889312, value=2
ID=140023258889344, value=3
ID=140023258889376, value=4
######
ID=140023258889280, value=1
ID=140023258889312, value=2
ID=140023258889344, value=3
ID=140023258889376, value=4
ID=140023258889408, value=5
######
ID=140023258889248, value=0
ID=140023258889280, value=1
ID=140023258889312, value=2
ID=140023258889344, value=3
ID=140023258889376, value=4

Beberapa hal yang perlu diperhatikan di sini. Pertama, di loop dengan +=, Anda tidak lagi menambahkan ke objek asli. Dalam hal ini, karena int adalah salah satu tipe yang tidak dapat diubah dalam Python , python menggunakan id yang berbeda. Juga menarik untuk dicatat bahwa Python menggunakan dasar yang sama iduntuk beberapa variabel dengan nilai tetap yang sama:

a = 1999
b = 1999
c = 1999

print('id a:', id(a))
print('id b:', id(b))
print('id c:', id(c))

id a: 139846953372048
id b: 139846953372048
id c: 139846953372048

tl; dr - Python memiliki beberapa tipe yang tidak dapat diubah, yang menyebabkan perilaku yang Anda lihat. Untuk semua jenis yang bisa berubah, harapan Anda benar.

enderland
sumber
6

@ Idan jawaban melakukan pekerjaan yang baik untuk menjelaskan mengapa Python tidak memperlakukan variabel loop sebagai pointer seperti yang Anda mungkin dalam C, tetapi perlu dijelaskan secara lebih mendalam bagaimana potongan kode membongkar, seperti dalam Python banyak bit yang tampak sederhana kode sebenarnya akan menjadi panggilan untuk metode built in . Untuk mengambil contoh pertama Anda

for i in a:
    i += 1

Ada dua hal yang harus dibongkar: for _ in _:sintaks dan _ += _sintaks. Untuk mengambil loop pertama, seperti bahasa lain, Python memiliki for-eachloop yang pada dasarnya adalah sintaksis gula untuk pola iterator. Dalam Python, sebuah iterator adalah objek yang mendefinisikan .__next__(self)metode yang mengembalikan elemen saat ini dalam urutan, maju ke yang berikutnya dan akan menaikkan StopIterationketika tidak ada lagi item dalam urutan. Sebuah Iterable adalah obyek yang mendefinisikan sebuah .__iter__(self)metode yang kembali iterator.

(NB: an Iteratorjuga merupakan Iterabledan mengembalikan dirinya dari .__iter__(self)metodenya.)

Python biasanya akan memiliki fungsi bawaan yang mendelegasikan ke metode menggarisbawahi kustom ganda. Jadi ada iter(o)yang memutuskan untuk o.__iter__()dan next(o)yang memutuskan untuk o.__next__(). Perhatikan bahwa fungsi bawaan ini sering akan mencoba definisi standar yang masuk akal jika metode yang akan didelegasikan tidak didefinisikan. Misalnya, len(o)biasanya memutuskan untuk o.__len__()tetapi jika metode itu tidak didefinisikan maka akan mencoba iter(o).__len__().

A for loop pada dasarnya didefinisikan dalam hal next(), iter()dan struktur kontrol yang lebih mendasar. Secara umum kode

for i in %EXPR%:
    %LOOP%

akan dibongkar ke sesuatu seperti

_a_iter = iter(%EXPR%)
while True:
    try:
        i = next(_a_iter)
    except StopIteration:
        break
    %LOOP%

Jadi dalam hal ini

for i in a:
    i += 1

akan dibongkar

_a_iter = iter(a) # = a.__iter__()
while True:
    try: 
        i = next(_a_iter) # = _a_iter.__next__()
    except StopIteration:
        break
    i += 1

Setengah lainnya adalah i += 1. Secara umum %ASSIGN% += %EXPR%akan dibongkar %ASSIGN% = %ASSIGN%.__iadd__(%EXPR%). Di sini __iadd__(self, other)melakukan penambahan inplace dan mengembalikan sendiri.

(NB Ini adalah kasus lain di mana Python akan memilih alternatif jika metode utama tidak didefinisikan. Jika objek tidak mengimplementasikannya __iadd__akan jatuh kembali __add__. Ini benar-benar melakukan ini dalam kasus ini karena inttidak mengimplementasikan __iadd__- yang masuk akal karena mereka tidak dapat diubah dan karenanya tidak dapat dimodifikasi di tempat.)

Jadi kode Anda di sini terlihat seperti

_a_iter = iter(a)
while True:
    try:
        i = next(_a_iter)
    except StopIteration:
        break
    i = iadd(i,1)

di mana kita dapat mendefinisikan

def iadd(o, v):
    try:
        return o.__iadd__(v)
    except AttributeError:
        return o.__add__(v)

Ada sedikit lebih banyak hal yang terjadi dalam bit kode kedua Anda. Dua hal baru yang perlu kita ketahui adalah bahwa %ARG%[%KEY%] = %VALUE%akan dibongkar (%ARG%).__setitem__(%KEY%, %VALUE%)dan %ARG%[%KEY%]dibongkar (%ARG%).__getitem__(%KEY%). Dengan menyatukan pengetahuan ini, kami dapat a[ix] += 1membongkar a.__setitem__(ix, a.__getitem__(ix).__add__(1))(lagi: __add__daripada __iadd__karena __iadd__tidak diterapkan oleh ints). Kode akhir kami terlihat seperti:

_a_iter = iter(enumerate(a))
while True:
    try:
        index, i = next(_a_iter)
    except StopIteration:
        break
    a.__setitem__(index, iadd(a.__getitem__(index), 1))

Untuk benar-benar menjawab pertanyaan Anda mengapa yang pertama tidak mengubah daftar sedangkan yang kedua tidak, dalam potongan pertama kami kita mendapatkan idari next(_a_iter), yang berarti iakan menjadi int. Karena inttidak dapat diubah pada tempatnya, i += 1tidak melakukan apa pun pada daftar. Dalam kasus kedua kami, kami sekali lagi tidak mengubah inttetapi memodifikasi daftar dengan memanggil __setitem__.

Alasan untuk seluruh latihan rumit ini adalah karena saya pikir ini mengajarkan pelajaran berikut tentang Python:

  1. Harga keterbacaan Python adalah bahwa ia memanggil metode skor ganda ajaib ini sepanjang waktu.
  2. Oleh karena itu, untuk memiliki kesempatan untuk benar-benar memahami setiap bagian dari kode Python Anda harus memahami apa yang dilakukan terjemahan ini.

Metode garis bawah ganda adalah rintangan ketika memulai, tetapi mereka sangat penting untuk mendukung reputasi "runnable pseudocode" Python. Seorang programmer Python yang baik akan memiliki pemahaman menyeluruh tentang metode ini dan bagaimana mereka dipanggil dan akan mendefinisikan mereka di mana pun masuk akal untuk melakukannya.

Sunting : @deltab mengoreksi penggunaan istilah "koleksi" saya yang ceroboh.

walpen
sumber
2
"iterator juga koleksi" tidak cukup benar: mereka juga dapat dipilih, tetapi koleksi juga memiliki __len__dan__contains__
deltab
2

+=bekerja secara berbeda berdasarkan pada apakah nilai saat ini bisa berubah atau tidak berubah . Ini adalah alasan utama yang terlihat lama untuk diimplementasikan dalam Python, karena pengembang Python takut itu akan membingungkan.

Jika iint, maka itu tidak dapat diubah karena int tidak dapat diubah, dan dengan demikian jika nilai iperubahan maka harus selalu menunjuk ke objek lain:

>>> i=3
>>> id(i)
14336296
>>> i+=1
>>> id(i)
14336272   # Other object

Namun jika sisi kiri bisa berubah , maka + = sebenarnya bisa mengubahnya; seperti jika itu daftar:

>>> i=[]
>>> id(i)
140257231883944
>>> i+=[1]
>>> id(i)
140257231883944  # Still the same object!

Di loop for Anda, imerujuk ke setiap elemen apada gilirannya. Jika itu adalah bilangan bulat, maka kasus pertama berlaku, dan hasilnya i += 1harus mengacu pada objek bilangan bulat lainnya. Daftar atentu saja masih memiliki elemen yang sama seperti sebelumnya.

RemcoGerlich
sumber
Saya tidak mengerti perbedaan antara objek yang dapat berubah dan tidak dapat diubah: jika i = 1diatur ike objek integer yang tidak dapat diubah, maka i = []harus ditetapkan ike objek daftar yang tidak dapat diubah. Dengan kata lain, mengapa objek integer tidak dapat diubah dan daftar objek bisa berubah? Saya tidak melihat logika di balik ini.
Giorgio
@Iorgio: objek berasal dari kelas yang berbeda, listmengimplementasikan metode yang mengubah isinya, inttidak. [] adalah objek daftar yang dapat diubah, dan i = []mari imerujuk ke objek itu.
RemcoGerlich
@Iorgio tidak ada yang namanya daftar abadi dengan Python. Daftar bisa berubah. Bilangan bulat tidak. Jika Anda menginginkan sesuatu seperti daftar tetapi tidak berubah, pertimbangkan tupel. Mengenai alasannya, tidak jelas level apa yang Anda inginkan dijawab.
jonrsharpe
@RemcoGerlich: Saya mengerti bahwa kelas yang berbeda berperilaku berbeda, saya tidak mengerti mengapa mereka diimplementasikan dengan cara ini, yaitu saya tidak mengerti logika di balik pilihan ini. Saya akan menerapkan +=operator / metode untuk berperilaku serupa (prinsip kejutan paling sedikit) untuk kedua jenis: mengubah objek asli atau mengembalikan salinan yang dimodifikasi untuk bilangan bulat dan daftar.
Giorgio
1
@ Giorgio: benar-benar benar bahwa +=mengejutkan dengan Python, tetapi dirasakan bahwa opsi lain yang Anda sebutkan juga akan mengejutkan, atau setidaknya kurang praktis (mengubah objek asli tidak dapat dilakukan dengan jenis nilai yang paling umum Anda menggunakan + = with, ints. Dan menyalin seluruh daftar jauh lebih mahal daripada memutasinya, Python tidak menyalin hal-hal seperti daftar dan kamus kecuali disuruh secara eksplisit). Itu adalah perdebatan besar saat itu.
RemcoGerlich
1

Loop di sini agak tidak relevan. Sama seperti parameter fungsi atau argumen, mengatur loop untuk seperti itu pada dasarnya hanya tugas yang tampak mewah.

Bilangan bulat tidak berubah. Satu-satunya cara untuk memodifikasinya adalah dengan membuat integer baru, dan menugaskannya dengan nama yang sama dengan aslinya.

Semantik Python untuk penugasan peta langsung ke C (diberikan pointer PyObject * CPython yang diberikan), dengan satu-satunya peringatan adalah bahwa semuanya adalah pointer, dan Anda tidak diizinkan memiliki pointer ganda. Pertimbangkan kode berikut:

a = 1
b = a
b += 1
print(a)

Apa yang terjadi? Mencetak 1. Mengapa? Ini sebenarnya kira-kira setara dengan kode C berikut:

i64* a = malloc(sizeof(i64));
*a = 1;
i64* b = a;
i64* tmp = malloc(sizeof(i64));
tmp = *b + 1;
b = tmp;
printf("%d\n", *a);

Dalam kode C, jelas bahwa nilai asepenuhnya tidak terpengaruh.

Adapun mengapa daftar tampaknya berfungsi, jawabannya pada dasarnya hanya bahwa Anda menugaskan untuk nama yang sama. Daftar bisa berubah. Identitas objek yang dinamai a[0]akan berubah, tetapi a[0]masih merupakan nama yang valid. Anda dapat memeriksa ini dengan kode berikut:

x = 1
a = [x]
print(a[0] is x)
a[0] += 1
print(a[0] is x)

Tapi, ini tidak spesial untuk daftar. Ganti a[0]kode itu dengan ydan Anda mendapatkan hasil yang sama persis.

Kevin
sumber