Saya akrab dengan gagasan penghapusan rekursi ekor dasar , di mana fungsi yang mengembalikan hasil langsung dari panggilan untuk diri mereka sendiri dapat ditulis ulang sebagai loop berulang.
foo(...):
# ...
return foo(...)
Saya juga mengerti bahwa, sebagai kasus khusus, fungsinya masih dapat ditulis ulang jika panggilan rekursif dibungkus dengan panggilan cons
.
foo(...):
# ...
return (..., foo(...))
Properti apa yang cons
memungkinkan ini? Apa fungsi selain yang cons
dapat membungkus panggilan ekor rekursif tanpa menghancurkan kemampuan kita untuk menulis ulang secara iteratif?
GCC (tetapi bukan Dentang) dapat mengoptimalkan contoh " multiplikasi modulo rekursi ekor " ini , tetapi tidak jelas mekanisme apa yang memungkinkannya menemukan ini atau bagaimana ia melakukan transformasi.
pow(x, n):
if n == 0: return 1
else if n == 1: return x
else: return x * pow(x, n-1)
if(n==0) return 0;
(tidak mengembalikan 1 seperti dalam pertanyaan Anda).x^0 = 1
, jadi itu bug. Namun, itu tidak penting untuk sisa pertanyaan; aserative itm memeriksa untuk kasus khusus terlebih dahulu. Tapi anehnya, implementasi berulang memperkenalkan banyak1 * x
yang tidak ada di sumbernya, bahkan jika kita membuatfloat
versi. gcc.godbolt.org/z/eqwine (dan gcc hanya berhasil dengan-ffast-math
.)return 0
telah diperbaiki. Perkalian dengan 1 menarik. Saya tidak yakin apa yang membuatnya.float
tanpa-ffast-math
, meskipun nilainya sama dengan yang dikalikan setiap waktu. (Kecuali untuk 1.0f` yang mungkin menjadi titik puncaknya?)Jawaban:
Meskipun GCC kemungkinan menggunakan aturan ad-hoc, Anda dapat menurunkannya dengan cara berikut. Saya akan gunakan
pow
untuk mengilustrasikan karena Andafoo
didefinisikan secara samar-samar. Juga,foo
mungkin lebih baik dipahami sebagai contoh optimasi panggilan terakhir sehubungan dengan variabel penugasan tunggal sebagaimana bahasa Oz miliki dan sebagaimana dibahas dalam Konsep, Teknik, dan Model Pemrograman Komputer . Manfaat menggunakan variabel penugasan tunggal adalah memungkinkannya tersisa dalam paradigma pemrograman deklaratif. Pada dasarnya, Anda dapat meminta setiap bidangfoo
pengembalian struct diwakili oleh variabel penugasan tunggal yang kemudian diteruskan kefoo
sebagai argumen tambahan.foo
kemudian menjadi ekor-rekursifvoid
fungsi kembali. Tidak ada kepintaran khusus yang diperlukan untuk ini.Kembali ke
pow
, pertama, mentransformasikannya menjadi gaya passing berkelanjutan .pow
menjadi:Semua panggilan adalah panggilan ekor sekarang. Namun, tumpukan kontrol telah dipindahkan ke lingkungan yang ditangkap di penutup yang mewakili kelanjutan.
Selanjutnya, nonaktifkan kontinuitas. Karena hanya ada satu panggilan rekursif, struktur data yang dihasilkan mewakili kelanjutan yang tidak berfungsi adalah daftar. Kita mendapatkan:
Apa yang
applyPow(k, acc)
dilakukan adalah mengambil daftar, yaitu bebas monoid, sukak=Cons(x, Cons(x, Cons(x, Nil)))
dan membuatnya menjadix*(x*(x*acc))
. Tetapi karena*
asosiatif dan umumnya membentuk monoid dengan unit1
, kita dapat menghubungkan kembali hal ini ke dalam((x*x)*x)*acc
, dan, untuk kesederhanaan, memasang1
mulai, memproduksi(((1*x)*x)*x)*acc
. Kuncinya adalah bahwa kita sebenarnya dapat menghitung sebagian hasilnya bahkan sebelum kita memilikinyaacc
. Itu berarti alih-alih menyerahkank
sebagai daftar yang pada dasarnya adalah beberapa "sintaksis" tidak lengkap yang akan kita "interpretasikan" pada akhirnya, kita dapat "menafsirkannya" sambil jalan. Hasilnya adalah bahwa kita dapat menggantiNil
dengan unit monoid,1
dalam hal ini, danCons
dengan operasi monoid*
,, dan sekarangk
mewakili "produk berjalan".applyPow(k, acc)
kemudian menjadi apak*acc
yang bisa kita inline kembalipow2
dan menyederhanakan produksi:Versi gaya ekor-rekursif, akumulator-passing dari aslinya
pow
.Tentu saja, saya tidak mengatakan GCC melakukan semua alasan ini pada waktu kompilasi. Saya tidak tahu logika apa yang digunakan GCC. Maksud saya adalah setelah melakukan alasan ini sekali, relatif mudah untuk mengenali pola dan segera menerjemahkan kode sumber asli ke dalam bentuk akhir ini. Namun, transformasi CPS dan transformasi defungsionalisasi sepenuhnya bersifat umum dan mekanis. Dari sana fusi, penggundulan hutan, atau teknik superkompilasi dapat digunakan untuk mencoba menghilangkan kelanjutan yang telah ditentukan. Transformasi spekulatif dapat dibuang jika tidak mungkin untuk menghilangkan semua alokasi kelanjutan yang ditentukan. Saya menduga, bahwa itu akan terlalu mahal untuk dilakukan sepanjang waktu, secara umum penuh, karenanya pendekatan yang lebih ad-hoc.
Jika Anda ingin menjadi konyol, Anda dapat memeriksa makalah Daur Ulang Daur Ulang yang juga menggunakan CPS dan representasi kelanjutan sebagai data, tetapi melakukan sesuatu yang mirip tetapi berbeda dengan ekor-rekursi-modulo-kontra. Ini menjelaskan bagaimana Anda dapat menghasilkan algoritma pembalikan pointer dengan transformasi.
Pola transformasi dan defungsionalisasi CPS ini adalah alat yang cukup kuat untuk memahami, dan digunakan untuk efek yang baik dalam serangkaian makalah yang saya daftarkan di sini .
sumber
Aku akan bertele-tele sebentar, tapi ada benarnya.
Semigroup
Jawabannya adalah, properti asosiatif dari operasi reduksi biner .
Itu cukup abstrak, tetapi penggandaan adalah contoh yang bagus. Jika x , y dan z adalah beberapa nomor alami (atau bilangan bulat, atau bilangan rasional, atau bilangan real, atau bilangan kompleks, atau N × N matriks, atau salah satu dari sejumlah hal yang lebih), maka x × y adalah jenis yang sama nomor sebagai x dan y . Kami mulai dengan dua angka, jadi ini operasi biner, dan mendapat satu, jadi kami mengurangi jumlah angka yang kami miliki dengan satu, menjadikan ini operasi pengurangan. Dan ( x × y ) × z selalu sama dengan x × ( y ×z ), yang merupakan properti asosiatif.
(Jika Anda sudah mengetahui semua ini, Anda dapat langsung ke bagian selanjutnya.)
Beberapa hal lagi yang sering Anda lihat dalam ilmu komputer yang bekerja dengan cara yang sama:
"a"+"b"+"c"
adalah"abc"
apakah Anda mulai dengan"ab"+"c"
atau"a"+"bc"
)[a]++[b]++[c]
sama[a,b,c]
dari belakang ke depan atau depan ke belakang.cons
pada kepala dan ekor, jika Anda menganggap kepala sebagai daftar tunggal. Itu hanya menyatukan dua daftar.&
,|
dan^
Beberapa hal yang tidak:
int print2(int x, int y) { return printf( "%d %d\n", x, y ); }
, sebagaiprint2( print2(x,y), z );
danprint2( x, print2(y,z) );
memiliki output yang berbeda.Itu adalah konsep yang cukup berguna yang kami beri nama. Satu set dengan operasi yang memiliki properti ini adalah semigroup . Jadi, bilangan real di bawah perkalian adalah semigroup. Dan pertanyaan Anda ternyata menjadi salah satu cara abstraksi semacam ini menjadi berguna di dunia nyata. Operasi semi-grup semua dapat dioptimalkan seperti yang Anda tanyakan.
Coba Ini Di Rumah
Sejauh yang saya tahu, teknik ini pertama kali dijelaskan pada tahun 1974, dalam makalah Daniel Friedman dan David Wise, "Lipat Rekur Bergaya menjadi Iterasi" , meskipun mereka mengasumsikan beberapa properti lebih daripada yang mereka butuhkan.
Haskell adalah bahasa yang bagus untuk menggambarkan hal ini, karena memiliki
Semigroup
typeclass di perpustakaan standarnya. Itu panggilan operasi generikSemigroup
operator<>
. Karena daftar dan string adalah instance dariSemigroup
, instance mereka mendefinisikan<>
sebagai operator gabungan++
, misalnya. Dan dengan impor yang tepat,[a] <> [b]
adalah alias untuk[a] ++ [b]
, yaitu[a,b]
.Tapi, bagaimana dengan angka? Kami hanya melihat bahwa jenis numerik yang semigroups bawah baik Selain atau perkalian! Jadi mana yang akan menjadi
<>
untukDouble
? Yah, salah satunya! Haskell mendefinisikan jenisProduct Double
,where (<>) = (*)
(yaitu definisi yang sebenarnya di Haskell), dan jugaSum Double
,where (<>) = (+)
.Satu kerutan adalah Anda menggunakan fakta bahwa 1 adalah identitas multiplikatif. Semigroup dengan identitas disebut monoid dan didefinisikan dalam paket Haskell
Data.Monoid
, yang memanggil elemen identitas umum dari sebuah typeclassmempty
.Sum
,Product
dan daftar masing-masing memiliki elemen identitas (0, 1 dan[]
, masing-masing), sehingga mereka adalah contohMonoid
dan jugaSemigroup
. (Jangan bingung dengan monad , jadi lupakan saja aku yang membawanya.)Itu informasi yang cukup untuk menerjemahkan algoritme Anda menjadi fungsi Haskell menggunakan monoids:
Yang penting, perhatikan bahwa ini adalah semigroup modulo rekursi ekor: setiap case dapat berupa nilai, panggilan rekursif ekor, atau produk semigroup dari keduanya. Juga, contoh ini kebetulan digunakan
mempty
untuk salah satu kasus, tetapi jika kami tidak membutuhkannya, kami bisa melakukannya dengan typeclass yang lebih umumSemigroup
.Mari kita muat program ini dalam GHCI dan lihat cara kerjanya:
Ingat bagaimana kami menyatakan
pow
untuk obat generikMonoid
, yang jenisnya kami panggila
? Kami memberikan GHCI informasi yang cukup untuk menyimpulkan bahwa jenisa
di sini adalahProduct Integer
, yang merupakaninstance
dariMonoid
yang<>
operasi perkalian bilangan bulat. Jadipow 2 4
mengembang secara rekursif ke2<>2<>2<>2
, yang2*2*2*2
atau16
. Sejauh ini baik.Tetapi fungsi kami hanya menggunakan operasi monoid generik. Sebelumnya, saya mengatakan bahwa ada contoh lain yang
Monoid
dipanggilSum
, yang<>
operasinya+
. Bisakah kita mencobanya?Ekspansi yang sama sekarang memberi kita
2+2+2+2
alih-alih2*2*2*2
. Perkalian adalah untuk menambah karena eksponensial adalah untuk perkalian!Tetapi saya memberikan satu contoh lain dari monoid Haskell: daftar, yang operasinya adalah gabungan.
Menulis
[2]
memberitahu kompiler bahwa ini adalah daftar,<>
pada daftar adalah++
, begitu[2]++[2]++[2]++[2]
juga[2,2,2,2]
.Akhirnya, sebuah Algoritma (Dua, Faktanya)
Dengan hanya menggantinya
x
dengan[x]
, Anda mengonversi algoritma umum yang menggunakan modul rekursi semigroup menjadi semigroup yang membuat daftar. Daftar yang mana? Daftar elemen yang diterapkan algoritma<>
. Karena kami hanya menggunakan operasi semi-grup yang memiliki daftar juga, daftar yang dihasilkan akan isomorfik dengan perhitungan aslinya. Dan karena operasi asli adalah asosiatif, kita dapat mengevaluasi elemen dari belakang ke depan atau dari depan ke belakang.Jika algoritme Anda pernah mencapai casing dasar dan berakhir, daftar tersebut akan kosong. Karena kasing terminal mengembalikan sesuatu, itu akan menjadi elemen terakhir dari daftar, sehingga ia akan memiliki setidaknya satu elemen.
Bagaimana Anda menerapkan operasi reduksi biner ke setiap elemen daftar secara berurutan? Itu benar, lipatan. Jadi Anda dapat mengganti
[x]
untukx
, mendapatkan daftar elemen untuk mengurangi oleh<>
, dan kemudian kanan kali lipat atau kiri-lipat daftar:Versi dengan
foldr1
sebenarnya ada di pustaka standar, sepertisconcat
untukSemigroup
danmconcat
untukMonoid
. Itu melipat kanan malas pada daftar. Artinya, itu diperluas[Product 2,Product 2,Product 2,Product 2]
ke2<>(2<>(2<>(2)))
.Ini tidak efisien dalam hal ini karena Anda tidak dapat melakukan apa pun dengan persyaratan individual hingga Anda menghasilkan semuanya. (Pada satu titik saya memiliki diskusi di sini tentang kapan harus menggunakan lipatan kanan dan kapan harus menggunakan lipatan kiri yang ketat, tetapi terlalu jauh.)
Versi dengan
foldl1'
adalah lipatan kiri yang dievaluasi secara ketat. Artinya, fungsi ekor-rekursif dengan akumulator yang ketat. Ini mengevaluasi(((2)<>2)<>2)<>2
, dihitung segera dan tidak lebih lambat saat dibutuhkan. (Setidaknya, tidak ada penundaan dalam lipatan itu sendiri: daftar yang dilipat dihasilkan di sini oleh fungsi lain yang mungkin berisi evaluasi malas.) Jadi, lipatan menghitung(4<>2)<>2
, lalu segera menghitung8<>2
, lalu16
. Inilah sebabnya kami membutuhkan operasi untuk menjadi asosiatif: kami baru saja mengubah pengelompokan tanda kurung!Lipatan kiri yang ketat sama dengan apa yang dilakukan GCC. Angka paling kiri pada contoh sebelumnya adalah akumulator, dalam hal ini produk yang sedang berjalan. Pada setiap langkah, itu dikalikan dengan angka berikutnya dalam daftar. Cara lain untuk mengekspresikannya adalah: Anda mengulangi nilai yang akan dikalikan, menjaga produk yang berjalan dalam akumulator, dan pada setiap iterasi, Anda mengalikan akumulator dengan nilai berikutnya. Artinya, itu adalah
while
lingkaran yang menyamar.Kadang-kadang dapat dibuat sama efisiennya. Kompiler mungkin dapat mengoptimalkan struktur data daftar dalam memori. Secara teori, ia memiliki informasi yang cukup pada waktu kompilasi untuk mencari tahu itu harus dilakukan di sini:
[x]
adalah singleton, jadi[x]<>xs
sama dengancons x xs
. Setiap iterasi fungsi mungkin dapat menggunakan kembali bingkai tumpukan yang sama dan memperbarui parameter yang ada.Entah lipatan kanan atau lipatan kiri ketat bisa lebih tepat, dalam kasus tertentu, jadi ketahuilah mana yang Anda inginkan. Ada juga beberapa hal yang hanya dapat dilakukan oleh flip kanan (seperti menghasilkan output interaktif tanpa menunggu semua input, dan beroperasi pada daftar yang tak terbatas). Namun, di sini, kami mengurangi urutan operasi ke nilai sederhana, jadi lipatan kiri yang ketat adalah yang kami inginkan.
Jadi, seperti yang Anda lihat, dimungkinkan untuk secara otomatis mengoptimalkan modul rekursi ekor setiap semigroup (salah satu contohnya adalah salah satu dari jenis numerik yang biasa di bawah penggandaan) baik ke lipatan kanan malas atau lipatan kiri ketat, dalam satu baris Haskell.
Generalisasi Lebih Lanjut
Dua argumen operasi biner tidak harus tipe yang sama, asalkan nilai awal adalah tipe yang sama dengan hasil Anda. (Tentu saja Anda selalu dapat membalik argumen agar sesuai dengan urutan jenis lipatan yang Anda lakukan, kiri atau kanan.) Jadi, Anda mungkin berulang kali menambahkan tambalan ke file untuk mendapatkan file yang diperbarui, atau mulai dengan nilai awal dari 1.0, bagi dengan integer untuk mengakumulasikan hasil floating-point. Atau tambahkan elemen ke daftar kosong untuk mendapatkan daftar.
Jenis generalisasi lain adalah menerapkan lipatan bukan untuk daftar tetapi untuk
Foldable
struktur data lainnya . Seringkali, daftar tertaut linear tidak berubah bukan struktur data yang Anda inginkan untuk algoritma yang diberikan. Satu masalah yang tidak saya bahas di atas adalah jauh lebih efisien untuk menambahkan elemen ke depan daftar daripada ke belakang, dan ketika operasi tidak komutatif, menerapkanx
di sebelah kiri dan kanan operasi tidak sama. Jadi Anda perlu menggunakan struktur lain, seperti sepasang daftar atau pohon biner, untuk mewakili suatu algoritma yang dapat diterapkanx
di sebelah kanan<>
maupun ke kiri.Perhatikan juga bahwa properti asosiatif memungkinkan Anda untuk mengelompokkan kembali operasi dengan cara lain yang bermanfaat, seperti membagi-dan-taklukkan:
Atau paralelisme otomatis, di mana setiap utas mengurangi subrange ke nilai yang kemudian dikombinasikan dengan yang lain.
sumber
pow(float x, unsigned n)
versi gcc.godbolt.org/z/eqwine hanya dioptimalkan dengan-ffast-math
, (yang menyiratkan-fassociative-math
. Titik apung yang ketat tentu saja tidak asosiatif karena perbedaan temporer = pembulatan yang berbeda). Memperkenalkan1.0f * x
yang tidak hadir dalam mesin abstrak C (tapi yang akan selalu memberikan hasil yang identik). Maka perkalian n-1 sepertido{res*=x;}while(--n!=1)
sama dengan rekursif, jadi ini adalah optimasi yang tidak terjawab.