Bagaimana cara kerja penutupan leksikal?

149

Sementara saya sedang menyelidiki masalah yang saya miliki dengan penutupan leksikal dalam kode Javascript, saya menemukan masalah ini dengan Python:

flist = []

for i in xrange(3):
    def func(x): return x * i
    flist.append(func)

for f in flist:
    print f(2)

Perhatikan bahwa contoh ini dengan sadar menghindari lambda. Mencetak "4 4 4", yang mengejutkan. Saya harapkan "0 2 4".

Kode Perl yang setara ini melakukannya dengan benar:

my @flist = ();

foreach my $i (0 .. 2)
{
    push(@flist, sub {$i * $_[0]});
}

foreach my $f (@flist)
{
    print $f->(2), "\n";
}

"0 2 4" dicetak.

Bisakah Anda jelaskan perbedaannya?


Memperbarui:

Masalahnya bukan dengan imenjadi global. Ini menampilkan perilaku yang sama:

flist = []

def outer():
    for i in xrange(3):
        def inner(x): return x * i
        flist.append(inner)

outer()
#~ print i   # commented because it causes an error

for f in flist:
    print f(2)

Seperti yang ditunjukkan oleh baris yang dikomentari, itidak diketahui pada saat itu. Namun, ia mencetak "4 4 4".

Eli Bendersky
sumber
2
Berikut adalah dua pertanyaan terkait yang mungkin membantu: Apa itu Pengikatan Dini dan Terlambat? Mengapa hasil peta () dan pemahaman daftar berbeda?
jfs
3
Berikut ini adalah artikel yang cukup bagus tentang masalah ini. me.veekun.com/blog/2011/04/24/gotcha-python-scoping-closures
updogliu

Jawaban:

151

Python sebenarnya berperilaku seperti yang didefinisikan. Tiga fungsi terpisah dibuat, tetapi masing-masing memiliki penutupan lingkungan yang telah ditentukan - dalam hal ini, lingkungan global (atau lingkungan fungsi luar jika loop ditempatkan di dalam fungsi lain). Ini persis masalah, meskipun - dalam lingkungan ini, saya dimutasi , dan semua penutup mengacu pada i yang sama .

Berikut ini adalah solusi terbaik yang bisa saya buat - buat fungsi creater dan panggil itu . Ini akan memaksa lingkungan yang berbeda untuk setiap fungsi yang dibuat, dengan i berbeda di masing -masing fungsi .

flist = []

for i in xrange(3):
    def funcC(j):
        def func(x): return x * j
        return func
    flist.append(funcC(i))

for f in flist:
    print f(2)

Inilah yang terjadi ketika Anda mencampur efek samping dan pemrograman fungsional.

Claudiu
sumber
5
Solusi Anda juga yang digunakan dalam Javascript.
Eli Bendersky
9
Ini bukan perilaku salah. Itu berperilaku persis seperti yang didefinisikan.
Alex Coventry
6
IMO piro memiliki solusi yang lebih baik stackoverflow.com/questions/233673/...
jfs
2
Saya mungkin akan mengubah 'i' menjadi 'j' terdalam untuk kejelasan.
eggsyntax
7
bagaimana dengan mendefinisikannya seperti ini:def inner(x, i=i): return x * i
dashesy
152

Fungsi-fungsi yang didefinisikan dalam loop tetap mengakses variabel yang sama isaat nilainya berubah. Pada akhir loop, semua fungsi menunjuk ke variabel yang sama, yang memegang nilai terakhir dalam loop: efeknya adalah apa yang dilaporkan dalam contoh.

Untuk mengevaluasi idan menggunakan nilainya, pola umum adalah menetapkannya sebagai parameter default: standar parameter dievaluasi ketika defpernyataan dieksekusi, dan dengan demikian nilai variabel loop dibekukan.

Berikut ini berfungsi seperti yang diharapkan:

flist = []

for i in xrange(3):
    def func(x, i=i): # the *value* of i is copied in func() environment
        return x * i
    flist.append(func)

for f in flist:
    print f(2)
piro
sumber
7
s / pada waktu kompilasi / pada saat defpernyataan dieksekusi /
jfs
23
Ini adalah solusi yang cerdik, yang membuatnya mengerikan.
Stavros Korokithakis
Ada satu masalah dengan solusi ini: func kini memiliki dua parameter. Itu berarti tidak berfungsi dengan jumlah parameter yang bervariasi. Lebih buruk lagi, jika Anda memanggil func dengan parameter kedua ini akan menimpa yang asli idari definisi. :-(
Pascal
34

Inilah cara Anda melakukannya menggunakan functoolsperpustakaan (yang saya tidak yakin tersedia pada saat pertanyaan diajukan).

from functools import partial

flist = []

def func(i, x): return x * i

for i in xrange(3):
    flist.append(partial(func, i))

for f in flist:
    print f(2)

Output 0 2 4, seperti yang diharapkan.

Luca Invernizzi
sumber
Saya benar-benar ingin menggunakan ini, tetapi fungsi saya adalah metode kelas dan nilai pertama yang dilewati adalah self. Apakah ada masalah di sekitar ini?
Michael David Watson
1
Benar. Misalkan Anda memiliki kelas Matematika dengan metode add (self, a, b), dan Anda ingin mengatur a = 1 untuk membuat metode 'increment'. Kemudian, buat instance dari kelas Anda 'my_math', dan metode kenaikan Anda akan menjadi 'increment = partial (my_math.add, 1)'.
Luca Invernizzi
2
Untuk menerapkan teknik ini ke metode, Anda juga bisa menggunakan functools.partialmethod()python 3.4
Matt Eding
13

Lihat ini:

for f in flist:
    print f.func_closure


(<cell at 0x00C980B0: int object at 0x009864B4>,)
(<cell at 0x00C980B0: int object at 0x009864B4>,)
(<cell at 0x00C980B0: int object at 0x009864B4>,)

Ini berarti mereka semua menunjuk ke instance variabel i yang sama, yang akan memiliki nilai 2 setelah loop selesai.

Solusi yang mudah dibaca:

for i in xrange(3):
        def ffunc(i):
            def func(x): return x * i
            return func
        flist.append(ffunc(i))
Null303
sumber
1
Pertanyaan saya lebih "umum". Mengapa Python memiliki kelemahan ini? Saya berharap bahasa yang mendukung penutupan leksikal (seperti Perl dan seluruh dinasti Lisp) bekerja dengan benar.
Eli Bendersky
2
Bertanya mengapa sesuatu memiliki cacat berarti menganggap itu bukan cacat.
Null303
7

Apa yang terjadi adalah bahwa variabel i ditangkap, dan fungsinya mengembalikan nilai yang terikat pada saat ia dipanggil. Dalam bahasa fungsional situasi seperti ini tidak pernah muncul, karena saya tidak akan pulih. Namun dengan python, dan juga seperti yang Anda lihat dengan lisp, ini tidak lagi benar.

Perbedaannya dengan contoh skema Anda adalah hubungannya dengan semantik loop do. Skema secara efektif membuat variabel i baru setiap kali melalui loop, daripada menggunakan kembali saya yang mengikat seperti dengan bahasa lain. Jika Anda menggunakan variabel berbeda yang dibuat di luar untuk loop dan mengubahnya, Anda akan melihat perilaku yang sama dalam skema. Coba ganti loop Anda dengan:

(let ((ii 1)) (
  (do ((i 1 (+ 1 i)))
      ((>= i 4))
    (set! flist 
      (cons (lambda (x) (* ii x)) flist))
    (set! ii i))
))

Lihatlah di sini untuk diskusi lebih lanjut tentang ini.

[Sunting] Mungkin cara yang lebih baik untuk menggambarkannya adalah dengan menganggap do loop sebagai makro yang melakukan langkah-langkah berikut:

  1. Tentukan lambda mengambil parameter tunggal (i), dengan tubuh yang ditentukan oleh tubuh loop,
  2. Panggilan langsung dari lambda itu dengan nilai i yang sesuai sebagai parameternya.

yaitu. setara dengan python di bawah ini:

flist = []

def loop_body(i):      # extract body of the for loop to function
    def func(x): return x*i
    flist.append(func)

map(loop_body, xrange(3))  # for i in xrange(3): body

I bukan lagi yang berasal dari lingkup induk tetapi variabel baru dalam cakupannya sendiri (mis. Parameter ke lambda) dan jadi Anda mendapatkan perilaku yang Anda amati. Python tidak memiliki lingkup baru implisit ini, jadi badan for for hanya membagikan variabel i.

Brian
sumber
Menarik. Saya tidak menyadari perbedaan dalam semantik loop do. Terima kasih
Eli Bendersky
4

Saya masih belum sepenuhnya yakin mengapa dalam beberapa bahasa ini bekerja satu arah, dan dalam beberapa cara lain. Dalam Common Lisp itu seperti Python:

(defvar *flist* '())

(dotimes (i 3 t)
  (setf *flist* 
    (cons (lambda (x) (* x i)) *flist*)))

(dolist (f *flist*)  
  (format t "~a~%" (funcall f 2)))

Mencetak "6 6 6" (perhatikan bahwa di sini daftarnya adalah dari 1 hingga 3, dan dibangun secara terbalik "). Sementara di Skema berfungsi seperti di Perl:

(define flist '())

(do ((i 1 (+ 1 i)))
    ((>= i 4))
  (set! flist 
    (cons (lambda (x) (* i x)) flist)))

(map 
  (lambda (f)
    (printf "~a~%" (f 2)))
  flist)

Cetakan "6 4 2"

Dan seperti yang telah saya sebutkan, Javascript ada di Python / CL camp. Tampaknya ada keputusan implementasi di sini, yang pendekatan bahasa berbeda dengan cara yang berbeda. Saya ingin sekali memahami apa keputusannya.

Eli Bendersky
sumber
8
Perbedaannya terletak pada (jangan ...) daripada aturan pelingkupan. Dalam skema do menciptakan variabel baru setiap melewati loop, sementara bahasa lain menggunakan kembali ikatan yang ada. Lihat jawaban saya untuk detail lebih lanjut dan contoh versi skema dengan perilaku yang mirip dengan lisp / python.
Brian
2

Masalahnya adalah bahwa semua fungsi lokal mengikat ke lingkungan yang sama dan dengan demikian ke ivariabel yang sama . Solusinya (solusinya) adalah membuat lingkungan yang terpisah (stack frames) untuk setiap fungsi (atau lambda):

t = [ (lambda x: lambda y : x*y)(x) for x in range(5)]

>>> t[1](2)
2
>>> t[2](2)
4
Rafał Dowgird
sumber
1

Variabelnya iadalah global, yang nilainya 2 pada setiap kali fungsi fdipanggil.

Saya akan cenderung menerapkan perilaku yang Anda cari sebagai berikut:

>>> class f:
...  def __init__(self, multiplier): self.multiplier = multiplier
...  def __call__(self, multiplicand): return self.multiplier*multiplicand
... 
>>> flist = [f(i) for i in range(3)]
>>> [g(2) for g in flist]
[0, 2, 4]

Respons terhadap pembaruan Anda : Bukan globalitas i per se yang menyebabkan perilaku ini, melainkan fakta bahwa itu adalah variabel dari cakupan terlampir yang memiliki nilai tetap selama waktu ketika f dipanggil. Dalam contoh kedua Anda, nilai idiambil dari ruang lingkup kkkfungsi, dan tidak ada yang berubah ketika Anda memanggil fungsi flist.

Alex Coventry
sumber
0

Alasan di balik perilaku telah dijelaskan, dan beberapa solusi telah diposting, tetapi saya pikir ini adalah yang paling pythonic (ingat, segala sesuatu di Python adalah objek!):

flist = []

for i in xrange(3):
    def func(x): return x * func.i
    func.i=i
    flist.append(func)

for f in flist:
    print f(2)

Jawaban Claudiu cukup bagus, menggunakan generator fungsi, tetapi jawaban piro adalah hack, jujur, karena itu membuat saya menjadi argumen "tersembunyi" dengan nilai default (itu akan berfungsi dengan baik, tetapi itu bukan "pythonic") .

darkfeline
sumber
Saya pikir itu tergantung pada versi python Anda. Sekarang saya lebih berpengalaman dan saya tidak lagi menyarankan cara melakukannya. Claudiu adalah cara yang tepat untuk membuat penutupan dengan Python.
darkfeline
1
Ini tidak akan berfungsi pada Python 2 atau 3 (keduanya menghasilkan "4 4 4"). The funcdalam x * func.iakan selalu mengacu pada fungsi terakhir yang ditetapkan. Jadi, meskipun masing-masing fungsi secara individual memiliki angka yang benar, masing-masing akhirnya membaca dari yang terakhir.
Lambda Fairy