Apa fungsi (lambda) penutupan menangkap?

249

Baru-baru ini saya mulai bermain-main dengan Python dan saya menemukan sesuatu yang aneh dalam cara penutupan. Pertimbangkan kode berikut:

adders=[0,1,2,3]

for i in [0,1,2,3]:
   adders[i]=lambda a: i+a

print adders[1](3)

Itu membangun array sederhana fungsi yang mengambil input tunggal dan mengembalikan input yang ditambahkan oleh angka. Fungsi-fungsi dibangun dalam forloop di mana iterator iberjalan dari 0ke 3. Untuk masing-masing angka-angka ini lambdafungsi dibuat yang menangkap idan menambahkannya ke input fungsi. Baris terakhir memanggil lambdafungsi kedua dengan 3sebagai parameter. Yang mengejutkan saya, hasilnya adalah 6.

Saya mengharapkan sebuah 4. Alasan saya adalah: dalam Python semuanya adalah objek dan karenanya setiap variabel adalah penunjuk yang penting. Saat membuat lambdaclosure for i, saya berharap untuk menyimpan pointer ke objek integer yang saat ini ditunjuk oleh i. Itu berarti bahwa ketika imenetapkan objek integer baru itu seharusnya tidak mempengaruhi penutupan yang dibuat sebelumnya. Sayangnya, memeriksa addersarray di dalam debugger menunjukkan hal itu. Semua lambdafungsi mengacu pada nilai terakhir i, 3yang mengakibatkan adders[1](3)kembali 6.

Yang membuat saya bertanya-tanya tentang hal berikut:

  • Apa tepatnya yang ditangkap penutup?
  • Apa cara paling elegan untuk meyakinkan lambdafungsi untuk menangkap nilai saat ini idengan cara yang tidak akan terpengaruh ketika imengubah nilainya?
Boas
sumber
35
Saya mengalami masalah ini dalam kode UI. Membuatku gila. Kuncinya adalah untuk mengingat bahwa loop tidak membuat ruang lingkup baru.
detly
3
@TimMB Bagaimana cara imeninggalkan namespace?
detly
3
@Detly Yah saya akan mengatakan bahwa print itidak akan berhasil setelah loop. Tapi saya mengujinya sendiri dan sekarang saya mengerti maksud Anda - itu berhasil. Saya tidak tahu bahwa variabel loop tetap setelah loop body dengan python.
Tim MB
1
@TimMB - Ya, itulah yang saya maksud. Sama untuk if, with, trydll
detly
13
Ini ada di FAQ Python resmi, di bawah Mengapa lambdas didefinisikan dalam satu lingkaran dengan nilai yang berbeda semua mengembalikan hasil yang sama? , dengan penjelasan dan solusi biasa.
abarnert

Jawaban:

161

Pertanyaan kedua Anda telah dijawab, tetapi untuk pertanyaan pertama Anda:

apa tepatnya yang ditangkap pada penutupan?

Pelingkupan dalam Python bersifat dinamis dan leksikal. Penutupan akan selalu mengingat nama dan ruang lingkup variabel, bukan objek yang ditunjuknya. Karena semua fungsi dalam contoh Anda dibuat dalam cakupan yang sama dan menggunakan nama variabel yang sama, mereka selalu merujuk ke variabel yang sama.

EDIT: Mengenai pertanyaan Anda yang lain tentang cara mengatasi hal ini, ada dua cara yang muncul di pikiran:

  1. Cara yang paling ringkas, tetapi tidak sepenuhnya setara adalah yang direkomendasikan oleh Adrien Plisson . Buat lambda dengan argumen tambahan, dan setel nilai default argumen tambahan ke objek yang ingin Anda pertahankan.

  2. Sedikit lebih bertele-tele tetapi lebih sedikit peretasan akan menciptakan ruang lingkup baru setiap kali Anda membuat lambda:

    >>> adders = [0,1,2,3]
    >>> for i in [0,1,2,3]:
    ...     adders[i] = (lambda b: lambda a: b + a)(i)
    ...     
    >>> adders[1](3)
    4
    >>> adders[2](3)
    5

    Ruang lingkup di sini dibuat menggunakan fungsi baru (lambda, untuk singkatnya), yang mengikat argumennya, dan meneruskan nilai yang ingin Anda ikat sebagai argumen. Namun, dalam kode nyata, Anda kemungkinan besar akan memiliki fungsi biasa alih-alih lambda untuk membuat lingkup baru:

    def createAdder(x):
        return lambda y: y + x
    adders = [createAdder(i) for i in range(4)]
Max Shawabkeh
sumber
1
Max, jika Anda menambahkan jawaban untuk yang lain (pertanyaan sederhana), saya dapat menandai ini sebagai jawaban yang diterima. Terima kasih!
Boaz
3
Python memiliki pelingkupan statis, bukan pelingkupan dinamis .. hanya saja semua variabel adalah referensi, jadi ketika Anda mengatur variabel ke objek baru, variabel itu sendiri (referensi) memiliki lokasi yang sama, tetapi menunjuk ke sesuatu yang lain. hal yang sama terjadi dalam Skema jika Anda set!. lihat di sini untuk apa sebenarnya ruang lingkup dinamis: voidspace.org.uk/python/articles/code_blocks.shtml .
Claudiu
6
Opsi 2 menyerupai apa bahasa fungsional yang akan disebut "fungsi Curried."
Crashworks
205

Anda dapat memaksa penangkapan variabel menggunakan argumen dengan nilai default:

>>> for i in [0,1,2,3]:
...    adders[i]=lambda a,i=i: i+a  # note the dummy parameter with a default value
...
>>> print( adders[1](3) )
4

idenya adalah mendeklarasikan parameter (dinamai secara cerdik i) dan memberikannya nilai default dari variabel yang ingin Anda tangkap (nilai i)

Adrien Plisson
sumber
7
+1 untuk menggunakan nilai default. Dievaluasi ketika lambda didefinisikan membuatnya sempurna untuk penggunaan ini.
quornian
21
+1 juga karena ini adalah solusi yang didukung oleh FAQ resmi .
abarnert
23
Ini luar biasa. Namun perilaku Python default tidak.
Cecil Curry
1
Ini sepertinya bukan solusi yang bagus ... Anda sebenarnya mengubah tanda tangan fungsi hanya untuk mengambil salinan variabel. Dan juga mereka yang memanggil fungsi dapat mengacaukan dengan variabel i, kan?
David Callanan
@ Davidvid, kita berbicara tentang lambda: jenis fungsi ad-hoc yang biasanya Anda tetapkan dalam kode Anda sendiri untuk memasukkan lubang, bukan sesuatu yang Anda bagikan melalui seluruh SDK. jika Anda membutuhkan tanda tangan yang lebih kuat, Anda harus menggunakan fungsi nyata.
Adrien Plisson
33

Untuk melengkapi jawaban lain untuk pertanyaan kedua Anda: Anda dapat menggunakan sebagian dalam modul functools .

Dengan mengimpor add dari operator ketika Chris Lutz mengusulkan contohnya menjadi:

from functools import partial
from operator import add   # add(a, b) -- Same as a + b.

adders = [0,1,2,3]
for i in [0,1,2,3]:
   # store callable object with first argument given as (current) i
   adders[i] = partial(add, i) 

print adders[1](3)
Joma
sumber
24

Pertimbangkan kode berikut:

x = "foo"

def print_x():
    print x

x = "bar"

print_x() # Outputs "bar"

Saya pikir kebanyakan orang tidak akan menganggap ini membingungkan sama sekali. Itu adalah perilaku yang diharapkan.

Jadi, mengapa orang berpikir itu akan berbeda ketika dilakukan dalam satu lingkaran? Saya tahu saya melakukan kesalahan itu sendiri, tetapi saya tidak tahu mengapa. Itu adalah loop? Atau mungkin lambda?

Lagipula, loop hanyalah versi pendek dari:

adders= [0,1,2,3]
i = 0
adders[i] = lambda a: i+a
i = 1
adders[i] = lambda a: i+a
i = 2
adders[i] = lambda a: i+a
i = 3
adders[i] = lambda a: i+a
truppo
sumber
11
Ini adalah pengulangan, karena dalam banyak bahasa lain pengulangan dapat membuat cakupan baru.
detly
1
Jawaban ini bagus karena menjelaskan mengapa ivariabel yang sama sedang diakses untuk setiap fungsi lambda.
David Callanan
3

Untuk menjawab pertanyaan kedua Anda, cara paling elegan untuk melakukan ini adalah dengan menggunakan fungsi yang mengambil dua parameter, bukan array:

add = lambda a, b: a + b
add(1, 3)

Namun, menggunakan lambda di sini agak konyol. Python memberi kita operatormodul, yang menyediakan antarmuka fungsional untuk operator dasar. Lambda di atas memiliki overhead yang tidak perlu hanya untuk memanggil operator tambahan:

from operator import add
add(1, 3)

Saya mengerti bahwa Anda bermain-main, mencoba menjelajahi bahasa, tapi saya tidak bisa membayangkan situasi saya akan menggunakan berbagai fungsi di mana keanehan pelingkupan Python akan menghalangi.

Jika Anda mau, Anda bisa menulis kelas kecil yang menggunakan sintaks pengindeksan array:

class Adders(object):
    def __getitem__(self, item):
        return lambda a: a + item

adders = Adders()
adders[1](3)
Chris Lutz
sumber
2
Chris, tentu saja kode di atas tidak ada hubungannya dengan masalah awal saya. Ini dibangun untuk mengilustrasikan poin saya dengan cara yang sederhana. Ini tentu saja tidak ada gunanya dan konyol.
Boaz
3

Berikut adalah contoh baru yang menyoroti struktur data dan konten penutupan, untuk membantu mengklarifikasi ketika konteks terlampir "disimpan."

def make_funcs():
    i = 42
    my_str = "hi"

    f_one = lambda: i

    i += 1
    f_two = lambda: i+1

    f_three = lambda: my_str
    return f_one, f_two, f_three

f_1, f_2, f_3 = make_funcs()

Apa yang ada dalam penutupan?

>>> print f_1.func_closure, f_1.func_closure[0].cell_contents
(<cell at 0x106a99a28: int object at 0x7fbb20c11170>,) 43 

Khususnya, my_str tidak ada dalam penutupan f1.

Apa yang ada di penutupan f2?

>>> print f_2.func_closure, f_2.func_closure[0].cell_contents
(<cell at 0x106a99a28: int object at 0x7fbb20c11170>,) 43

Perhatikan (dari alamat memori) bahwa kedua penutupan mengandung objek yang sama. Jadi, Anda dapat mulai memikirkan fungsi lambda sebagai referensi untuk ruang lingkup. Namun, my_str tidak dalam penutupan untuk f_1 atau f_2, dan saya tidak dalam penutupan untuk f_3 (tidak ditampilkan), yang menunjukkan objek penutupan itu sendiri adalah objek yang berbeda.

Apakah objek penutupan itu sendiri adalah objek yang sama?

>>> print f_1.func_closure is f_2.func_closure
False
Jeff
sumber
NB Keluaran int object at [address X]>membuat saya berpikir penutupan menyimpan [alamat X] AKA referensi. Namun, [alamat X] akan berubah jika variabel dipindahkan setelah pernyataan lambda.
Jeff