Mengapa program menggunakan tumpukan panggilan, jika panggilan fungsi bersarang dapat diuraikan?

33

Mengapa tidak meminta kompiler mengambil program seperti ini:

function a(b) { return b^2 };
function c(b) { return a(b) + 5 };

dan mengubahnya menjadi program seperti ini:

function c(b) { return b^2 + 5 };

dengan demikian menghilangkan kebutuhan komputer untuk mengingat alamat pengirim c (b)?

Saya kira peningkatan ruang hard disk dan RAM yang diperlukan untuk menyimpan program dan mendukung kompilasi (masing-masing) adalah alasan mengapa kami menggunakan tumpukan panggilan. Apakah itu benar?

moonman239
sumber
30
Lihat apa yang terjadi jika Anda melakukan ini pada program dengan ukuran yang berarti. Secara khusus, fungsi dipanggil dari lebih dari satu tempat.
user253751
10
Juga, terkadang kompiler tidak tahu fungsi mana yang dipanggil! Contoh konyol:window[prompt("Enter function name","")]()
user253751
26
Bagaimana Anda menerapkan function(a)b { if(b>0) return a(b-1); }tanpa tumpukan?
pjc50
8
Di mana hubungannya dengan pemrograman fungsional?
mastov
14
@ pjc50: ini ekor rekursif, jadi kompiler menerjemahkannya ke loop dengan yang bisa diubah b. Tapi poin yang diambil, tidak semua fungsi rekursif dapat menghilangkan rekursi, dan bahkan ketika fungsi pada prinsipnya, kompiler mungkin tidak cukup pintar untuk melakukannya.
Steve Jessop

Jawaban:

75

Ini disebut "inlining" dan banyak kompiler melakukan ini sebagai strategi optimasi dalam kasus di mana masuk akal.

Dalam contoh khusus Anda, optimasi ini akan menghemat ruang dan waktu eksekusi. Tetapi jika fungsi dipanggil di banyak tempat dalam program (tidak jarang!), Itu akan meningkatkan ukuran kode, sehingga strategi menjadi lebih meragukan. (Dan tentu saja jika suatu fungsi menyebut dirinya secara langsung atau tidak langsung akan mustahil untuk sebaris, karena kode akan menjadi tak terbatas dalam ukuran.)

Dan jelas itu hanya mungkin untuk fungsi "pribadi". Fungsi yang diekspos untuk penelepon eksternal tidak dapat dioptimalkan, setidaknya tidak dalam bahasa dengan tautan dinamis.

JacquesB
sumber
7
@ Bllfl: Kompiler modern sebenarnya tidak membutuhkan definisi di header lagi; mereka dapat berbaris di seluruh Unit Terjemahan. Ini memang membutuhkan penghubung yang layak. Definisi dalam file header adalah solusi untuk tautan bodoh.
MSalters
3
"Fungsi yang diekspos untuk penelepon eksternal tidak dapat dioptimalkan jauh" - fungsi harus ada, tetapi setiap situs panggilan yang diberikan kepadanya (baik dalam kode Anda sendiri, atau jika mereka memiliki sumber, penelepon eksternal ') dapat digarisbawahi.
Random832
14
Wow, 28 memilih untuk jawaban yang bahkan tidak menyebutkan alasan mengapa menyatukan semuanya tidak mungkin: Rekursi.
mastov
3
@R ..: LTO adalah Optimasi waktu LINK, bukan LOAD Optimasi Waktu.
MSalters
2
@immibis: Tetapi jika tumpukan eksplisit itu diperkenalkan oleh kompiler, maka tumpukan itu adalah tumpukan panggilan.
user2357112 mendukung Monica
51

Ada dua bagian untuk pertanyaan Anda: Mengapa memiliki beberapa fungsi sama sekali (alih-alih mengganti panggilan fungsi dengan definisi mereka) dan mengapa mengimplementasikan fungsi-fungsi itu dengan tumpukan panggilan alih-alih secara statis mengalokasikan data mereka di tempat lain?

Alasan pertama adalah rekursi. Bukan hanya jenis "oh, mari kita membuat panggilan fungsi baru untuk setiap item dalam daftar ini", juga jenis sederhana di mana Anda memiliki dua panggilan fungsi yang aktif secara bersamaan, dengan banyak fungsi lain di antaranya. Anda perlu meletakkan variabel lokal di tumpukan untuk mendukung ini, dan Anda tidak bisa sebaris fungsi rekursif secara umum.

Lalu ada masalah untuk pustaka: Anda tidak tahu fungsi mana yang akan dipanggil dari mana dan seberapa sering, jadi "pustaka" tidak pernah benar-benar dapat dikompilasi, hanya dikirimkan ke semua klien dalam beberapa format tingkat tinggi yang nyaman yang kemudian menjadi dimasukkan ke dalam aplikasi. Selain masalah lain dengan ini, Anda benar-benar kehilangan tautan dinamis dengan semua kelebihannya.

Selain itu, ada banyak alasan untuk tidak menjalankan fungsi bahkan ketika Anda bisa:

  1. Ini belum tentu lebih cepat. Menyiapkan frame stack dan merobohkannya mungkin adalah selusin instruksi satu siklus, untuk banyak fungsi perulangan atau besar yang bahkan tidak 0,1% dari waktu eksekusi.
  2. Mungkin lebih lambat. Duplikasi kode memiliki biaya, misalnya, akan lebih menekan cache instruksi.
  3. Beberapa fungsi sangat besar dan dipanggil dari banyak tempat, membariskannya di mana-mana meningkatkan biner jauh melampaui apa yang masuk akal.
  4. Kompiler sering mengalami kesulitan dengan fungsi yang sangat besar. Semuanya sama, fungsi ukuran 2 * N membutuhkan waktu lebih dari 2 * T di mana fungsi ukuran N membutuhkan waktu T.

sumber
1
Saya terkejut dengan poin 4. Apa alasannya?
JacquesB
12
@ JacquesB Banyak algoritma optimisasi yang kuadratik, kubik, atau bahkan secara teknis NP-lengkap. Contoh kanonik adalah alokasi register, yang NP-lengkap dengan analogi dengan pewarnaan grafik. (Biasanya kompiler tidak mencoba solusi yang tepat, tetapi hanya beberapa heuristik yang sangat buruk berjalan dalam waktu linier.) Banyak optimasi sederhana, satu-pass perlu melewati analisis superlinear terlebih dahulu, seperti segala sesuatu yang bergantung pada dominasi dalam aliran kontrol (umumnya n log n waktu dengan n blok dasar).
2
"Kamu benar-benar punya dua pertanyaan di sini" Tidak, aku tidak. Hanya satu - mengapa tidak memperlakukan pemanggilan fungsi hanya sebagai pengganti yang kompiler mungkin, katakanlah, ganti dengan kode fungsi yang dipanggil?
moonman239
4
@ moonman239 Lalu kata-kata Anda mengusir saya. Namun, pertanyaan Anda dapat diuraikan seperti yang saya lakukan dalam jawaban saya dan saya pikir itu adalah perspektif yang berguna.
16

Tumpukan memungkinkan kita untuk memotong batas yang diberlakukan oleh jumlah register yang terbatas secara elegan.

Bayangkan memiliki 26 global "register az" (atau bahkan hanya memiliki register berukuran 7 byte dari chip 8080) Dan setiap fungsi yang Anda tulis dalam aplikasi ini berbagi daftar datar ini.

Awal yang naif adalah mengalokasikan beberapa register pertama ke fungsi pertama, dan mengetahui bahwa hanya butuh 3, mulai dengan "d" untuk fungsi kedua ... Anda kehabisan dengan cepat.

Sebaliknya, jika Anda memiliki pita metaforis, seperti mesin turing, Anda dapat meminta setiap fungsi memulai "panggil fungsi lain" dengan menyimpan semua variabel yang digunakan dan meneruskan () rekaman itu, dan kemudian fungsi callee dapat mengacaukan sebanyak mungkin mendaftar sesuai keinginan. Ketika callee selesai, ia mengembalikan kontrol ke fungsi induknya, yang tahu di mana harus mengambil output callee sesuai kebutuhan, dan kemudian memutar kaset mundur untuk mengembalikan kondisinya.

Frame panggilan dasar Anda adalah hanya itu, dan dibuat dan dihapus oleh urutan kode mesin standar yang dimasukkan oleh kompiler di sekitar transisi dari satu fungsi ke fungsi lainnya. (Sudah lama sejak saya harus mengingat frame stack C saya, tetapi Anda dapat membaca tentang berbagai cara tugas siapa yang menjatuhkan apa yang ada di X86_calling_conventions .)

(rekursi itu luar biasa, tetapi jika Anda harus menyulap register tanpa tumpukan, maka Anda akan sangat menghargai tumpukan.)


Saya kira peningkatan ruang hard disk dan RAM yang diperlukan untuk menyimpan program dan mendukung kompilasi (masing-masing) adalah alasan mengapa kami menggunakan tumpukan panggilan. Apakah itu benar?

Meskipun kita dapat menyejajarkan lebih banyak hari-hari ini, ("lebih banyak kecepatan" selalu baik; "lebih sedikit kb rakitan" berarti sangat sedikit dalam dunia aliran video) Batasan utama adalah kemampuan kompiler untuk meratakan jenis pola kode tertentu.

Misalnya, objek polimorfik - jika Anda tidak tahu satu-satunya jenis objek yang akan Anda berikan, Anda tidak dapat meratakannya; Anda harus melihat vtable objek dari fitur dan memanggil melalui pointer itu ... sepele untuk dilakukan saat runtime, tidak mungkin untuk inline pada waktu kompilasi.

Toolchain modern dapat dengan senang hati menggarisbawahi fungsi yang terdefinisi secara polimorfik ketika ia telah meratakan cukup banyak pemanggil untuk mengetahui dengan pasti aroma obj yang mana:

class Base {
    public: void act() = 0;
};
class Child1: public Base {
    public: void act() {};
};
void ActOn(Base* something) {
    something->act();
}
void InlineMe() {
    Child1 thingamabob;
    ActOn(&thingamabob);
}

di atas, kompiler dapat memilih untuk tetap menggunakan inline statis, dari InlineMe melalui tindakan apa pun yang ada di dalam (), atau kebutuhan untuk menyentuh vtable apa pun saat runtime.

Tetapi setiap ketidakpastian dalam apa rasa objek akan meninggalkan sebagai panggilan ke fungsi diskrit, bahkan jika beberapa doa lain dari fungsi yang sama yang inline.

xander
sumber
11

Kasus-kasus yang tidak dapat ditangani oleh pendekatan itu:

function fib(a) { if(a>2) return fib(a-1)+fib(a-2); else return 1; }

function many(a) { for(i = 1 to a) { b(i); };}

Ada yang bahasa dan platform dengan tumpukan terbatas atau tidak ada panggilan. Mikroprosesor PIC memiliki tumpukan perangkat keras yang dibatasi antara 2 dan 32 entri . Ini menciptakan kendala desain.

COBOL melarang rekursi: https://stackoverflow.com/questions/27806812/in-cobol-is-it-possible-to-recursively-call-a-paragraph

Memberlakukan larangan rekursi berarti Anda dapat mewakili seluruh kaligraf program secara statis sebagai DAG. Kompiler Anda kemudian dapat memancarkan satu salinan fungsi untuk setiap tempat dari mana ia dipanggil dengan lompatan tetap alih-alih pengembalian. Tidak diperlukan tumpukan, hanya lebih banyak ruang program, berpotensi cukup banyak untuk sistem yang kompleks. Tetapi untuk sistem tertanam kecil ini berarti Anda dapat menjamin untuk tidak memiliki stack overflow saat runtime, yang akan menjadi berita buruk bagi reaktor nuklir / turbin jet / kontrol throttle mobil Anda, dll.

pjc50
sumber
12
Contoh pertama Anda adalah rekursi dasar, dan Anda benar di sana. Tetapi contoh kedua Anda tampaknya untuk loop memanggil fungsi lain. Fungsi in-lining berbeda dari membuka gulungan; fungsi tersebut dapat di-in-line tanpa membuka gulungannya. Atau apakah saya melewatkan beberapa detail halus?
jpmc26
1
Jika contoh pertama Anda dimaksudkan untuk menentukan seri Fibonacci, itu salah. (Tidak ada fibpanggilan.)
Paŭlo Ebermann
1
Sementara melarang rekursi berarti bahwa keseluruhan grafik panggilan dapat direpresentasikan sebagai DAG, itu tidak berarti bahwa seseorang dapat mendaftar daftar lengkap urutan panggilan bersarang dalam jumlah ruang yang masuk akal. Pada satu proyek saya untuk mikrokontroler dengan 128KB ruang kode, saya membuat kesalahan dengan meminta grafik panggilan yang mencakup semua fungsi yang dapat mempengaruhi persyaratan parameter-RAM maksimum dan grafik panggilan itu melebihi manggung. Grafik panggilan lengkap akan lebih lama, dan itu untuk sebuah program yang sesuai dengan 128K ruang kode.
supercat
8

Anda ingin fungsi inlining , dan kebanyakan kompiler ( mengoptimalkan ) melakukan itu.

Perhatikan bahwa inlining membutuhkan fungsi yang dipanggil untuk diketahui (dan efektif hanya jika fungsi yang dipanggil tidak terlalu besar), karena secara konseptual ia mengganti panggilan dengan menulis ulang fungsi yang disebut. Jadi, Anda biasanya tidak dapat menyejajarkan fungsi yang tidak diketahui (mis., Penunjuk fungsi -dan yang menyertakan fungsi dari pustaka bersama yang terhubung secara dinamis -, yang mungkin terlihat sebagai metode virtual dalam beberapa vtable ; tetapi beberapa kompiler kadang-kadang dapat mengoptimalkan melalui teknik devirtualisasi ). Tentu saja tidak selalu mungkin untuk inline fungsi rekursif (beberapa kompiler pintar mungkin menggunakan evaluasi parsial dan dalam beberapa kasus dapat inline fungsi rekursif).

Perhatikan juga inlining, meskipun mudah, tidak selalu efektif: Anda (sebenarnya kompiler Anda) dapat meningkatkan ukuran kode sedemikian rupa sehingga cache CPU (atau prediktor cabang ) bekerja kurang efisien, dan itu akan membuat program Anda berjalan lebih lambat.

Saya sedikit fokus pada gaya pemrograman fungsional , karena Anda menandai qestion Anda seperti itu.

Perhatikan bahwa Anda tidak perlu memiliki tumpukan panggilan (setidaknya dalam arti mesin dari ekspresi "tumpukan panggilan"). Anda hanya bisa menggunakan heap.

Jadi, lihat kelanjutan dan baca lebih lanjut tentang kelanjutan gaya lewat (CPS) dan transformasi CPS (secara intuitif, Anda bisa menggunakan penutupan kelanjutan sebagai "bingkai panggilan" yang dialokasikan dialokasikan di tumpukan, dan mereka semacam meniru tumpukan panggilan; maka Anda membutuhkan pengumpul sampah yang efisien ).

Andrew Appel menulis buku Compiling with Continuations dan pengumpulan sampah kertas lama bisa lebih cepat daripada alokasi tumpukan . Lihat juga makalah A.Kennedy (ICFP2007) yang Mengompilasi dengan Lanjutan, Lanjutan

Saya juga merekomendasikan membaca buku Lisp In Small Pieces dari Queinnec , yang memiliki beberapa bab terkait dengan kelanjutan & kompilasi.

Perhatikan juga bahwa beberapa bahasa (mis. Brainfuck ) atau mesin abstrak (mis. OISC , RAM ) tidak memiliki fasilitas panggilan tetapi masih Turing-lengkap , jadi Anda tidak (secara teori) bahkan memerlukan mekanisme fungsi panggilan, bahkan jika ini sangat nyaman. BTW, beberapa arsitektur kumpulan instruksi lama (misalnya IBM / 370 ) bahkan tidak memiliki tumpukan panggilan perangkat keras, atau instruksi mesin panggilan yang mendorong (IBM / 370 hanya memiliki instruksi mesin Cabang dan Tautan )

Akhirnya, jika seluruh program Anda (termasuk semua perpustakaan yang dibutuhkan) tidak memiliki rekursi, Anda dapat menyimpan alamat pengirim (dan variabel "lokal", yang sebenarnya menjadi statis) dari setiap fungsi di lokasi statis. Kompiler Fortran77 lama melakukannya pada awal 1980-an (sehingga program yang dikompilasi tidak menggunakan tumpukan panggilan pada saat itu).

Basile Starynkevitch
sumber
2
Ini sangat bisa diperdebatkan adalah CPS tidak memiliki "panggilan stack". Ini bukan pada stack , wilayah mistis dari RAM biasa yang memiliki sedikit dukungan perangkat keras %esp, dll., Tetapi masih menyimpan pembukuan yang setara pada tumpukan spaghetti yang tepat di wilayah lain RAM. Alamat pengirim, khususnya, pada dasarnya dikodekan dalam kelanjutan. Dan tentu saja kelanjutannya tidak lebih cepat (dan menurut saya inilah yang didapat OP) daripada membuat panggilan sama sekali melalui inlining.
Koran lama Appel mengklaim (dan menunjukkan dengan benchmarking) bahwa CPS bisa secepat memiliki tumpukan panggilan.
Basile Starynkevitch
Saya skeptis akan hal itu tetapi apa pun itu bukan yang saya klaim.
1
Sebenarnya, ini adalah pada stasiun kerja MIPS era 1980-an. Mungkin, hierarki cache pada PC saat ini akan membuat kinerjanya sedikit berbeda. Ada beberapa makalah yang menganalisis klaim Appel (dan memang, pada mesin saat ini, alokasi tumpukan mungkin sedikit lebih cepat - dengan beberapa persen - daripada pengumpulan sampah yang dibuat dengan hati-hati)
Basile Starynkevitch
1
@Gilles: Banyak core ARM yang lebih baru seperti Cortex M0 dan M3 (dan mungkin yang lain seperti M4) memiliki dukungan stack perangkat keras untuk hal-hal seperti penanganan interupsi. Lebih lanjut, set instruksi Thumb mencakup subset terbatas dari instruksi STRM / STRM yang mencakup STRMDB R13 dengan kombinasi R0-R7 dengan / tanpa LR, dan LDRMIA R13 dari setiap kombinasi R0-R7 dengan / tanpa PC, yang secara efektif memperlakukan R13 sebagai stack pointer.
supercat
8

Inlining (mengganti panggilan fungsi dengan fungsi yang setara) berfungsi dengan baik sebagai strategi optimasi untuk fungsi-fungsi sederhana yang kecil. Overhead panggilan fungsi dapat secara efektif diperdagangkan dengan penalti kecil dalam ukuran program tambahan (atau dalam beberapa kasus, tidak ada penalti sama sekali).

Namun, fungsi-fungsi besar yang pada gilirannya memanggil fungsi-fungsi lain dapat menyebabkan ledakan besar dalam ukuran program jika semuanya diuraikan.

Inti dari fungsi yang dapat dipanggil adalah untuk memfasilitasi penggunaan kembali secara efisien, tidak hanya oleh programmer, tetapi oleh mesin itu sendiri, dan itu termasuk properti seperti memori yang masuk akal atau jejak pada disk.

Untuk apa nilainya: Anda dapat memiliki fungsi yang dapat dipanggil tanpa tumpukan panggilan. Sebagai contoh: IBM System / 360. Ketika pemrograman dalam bahasa seperti FORTRAN pada perangkat keras itu, penghitung program (alamat pengirim) akan disimpan ke dalam bagian kecil dari memori yang disediakan tepat di depan titik masuk fungsi. Hal ini memungkinkan untuk fungsi yang dapat digunakan kembali, tetapi tidak memungkinkan untuk rekursi atau kode multi-threaded (upaya pada panggilan rekursif atau masuk kembali akan menghasilkan alamat kembali yang disimpan sebelumnya ditimpa ditimpa).

Sebagaimana dijelaskan oleh jawaban lain, tumpukan adalah hal yang baik. Mereka memfasilitasi rekursi dan panggilan multi-utas. Sementara setiap algoritma yang dikodekan untuk menggunakan rekursi dapat dikodekan tanpa mengandalkan rekursi, hasilnya mungkin lebih kompleks, lebih sulit untuk dipelihara, dan mungkin kurang efisien. Saya tidak yakin arsitektur stack-less dapat mendukung multi-threading sama sekali.

Zenilogix
sumber