Efisiensi pengembalian prematur dalam suatu fungsi

97

Ini adalah situasi yang sering saya temui sebagai programmer yang tidak berpengalaman dan saya bertanya-tanya terutama untuk proyek ambisius dan cepat yang saya coba optimalkan. Untuk bahasa C-like mayor (C, objC, C ++, Java, C #, dll) dan compiler biasanya, akankah kedua fungsi ini berjalan dengan efisien? Apakah ada perbedaan dalam kode yang dikompilasi?

void foo1(bool flag)
{
    if (flag)
    {
        //Do stuff
        return;
    }

    //Do different stuff
}

void foo2(bool flag)
{
    if (flag)
    {
        //Do stuff
    }
    else
    {
        //Do different stuff
    }
}

Pada dasarnya, ada yang pernah efisiensi langsung bonus / penalti ketika breaking atau returning awal? Bagaimana stackframe terlibat? Apakah ada kasus khusus yang dioptimalkan? Apakah ada faktor (seperti sebaris atau ukuran "Lakukan sesuatu") yang dapat memengaruhi hal ini secara signifikan?

Saya selalu mendukung peningkatan keterbacaan atas pengoptimalan kecil (saya sering melihat banyak hal dengan validasi parameter), tetapi hal ini sering muncul sehingga saya ingin mengesampingkan semua kekhawatiran sekali dan untuk selamanya.

Dan saya menyadari jebakan pengoptimalan prematur ... ugh, itu adalah kenangan yang menyakitkan.

EDIT: Saya menerima jawaban, tetapi jawaban EJP menjelaskan dengan cukup singkat mengapa penggunaan a returnsecara praktis dapat diabaikan (dalam perakitan, returnmembuat 'cabang' ke akhir fungsi, yang sangat cepat. Cabang mengubah register PC dan juga dapat mempengaruhi cache dan pipeline, yang sangat kecil.) Khusus untuk kasus ini, tidak ada bedanya karena the if/elsedan the returnmembuat cabang yang sama di akhir fungsi.

Philip Guin
sumber
22
Saya tidak berpikir hal semacam itu akan berdampak besar pada kinerja. Tulis saja tes kecil dan lihat diri Anda sendiri. Imo, varian pertama lebih baik karena Anda tidak mendapatkan nesting yang tidak diperlukan yang meningkatkan keterbacaan
SirVaulterScoff
10
@SirVaulterScott, kecuali kedua kasing simetris dalam beberapa hal, dalam hal ini Anda ingin menonjolkan simetri dengan menempatkannya pada tingkat lekukan yang sama.
luqui
3
SirVaulterScoff: +1 untuk mengurangi bersarang yang tidak dibutuhkan
fjdumont
11
Keterbacaan >>> Pengoptimalan mikro. Lakukan cara mana pun yang lebih masuk akal bagi wetware yang akan memelihara ini. Pada level kode mesin, kedua struktur ini identik ketika dimasukkan ke kompiler yang cukup bodoh sekalipun. Kompiler yang mengoptimalkan akan menghapus kemiripan keunggulan kecepatan di antara keduanya.
SplinterReality
12
Jangan optimalkan proyek "intensif kecepatan" Anda dengan mengkhawatirkan hal-hal seperti ini. Buat profil aplikasi Anda untuk mengetahui di mana sebenarnya lambat - jika sebenarnya terlalu lambat setelah Anda selesai membuatnya bekerja. Anda hampir pasti tidak bisa menebak apa yang sebenarnya memperlambatnya.
blueshift

Jawaban:

92

Tidak ada perbedaan sama sekali:

=====> cat test_return.cpp
extern void something();
extern void something2();

void test(bool b)
{
    if(b)
    {
        something();
    }
    else
        something2();
}
=====> cat test_return2.cpp
extern void something();
extern void something2();

void test(bool b)
{
    if(b)
    {
        something();
        return;
    }
    something2();
}
=====> rm -f test_return.s test_return2.s
=====> g++ -S test_return.cpp 
=====> g++ -S test_return2.cpp 
=====> diff test_return.s test_return2.s
=====> rm -f test_return.s test_return2.s
=====> clang++ -S test_return.cpp 
=====> clang++ -S test_return2.cpp 
=====> diff test_return.s test_return2.s
=====> 

Artinya tidak ada perbedaan dalam kode yang dihasilkan bahkan tanpa optimasi pada dua kompiler

Dani
sumber
59
Atau lebih baik: setidaknya ada versi dari kompilator tertentu yang menghasilkan kode yang sama untuk kedua versi tersebut.
UncleZeiv
11
@UncleZeiv - sebagian besar, jika tidak semua, compiler akan menerjemahkan sumber ke model grafik aliran eksekusi. Sulit untuk membayangkan sebuah implementasi waras yang akan memberikan arti grafik aliran yang berbeda bagi mereka dua contoh. Tentang satu-satunya perbedaan yang mungkin Anda lihat adalah bahwa dua hal yang berbeda dapat ditukar - dan bahkan itu mungkin dibatalkan dalam banyak implementasi untuk mengoptimalkan prediksi cabang atau untuk beberapa masalah lain di mana platform menentukan pengurutan yang disukai.
Steve314
6
@ Steve314, tentu, saya baru saja mengomel :)
UncleZeiv
@UncleZeiv: diuji pada clang juga dan hasil yang sama
Dani
Saya tidak mengerti. Tampak jelas bahwa something()akan selalu dieksekusi. Dalam pertanyaan awal, OP memiliki Do stuffdan Do diffferent stuffbergantung pada benderanya. Saya tidak yakin bahwa kode yang dihasilkan akan sama.
Luc M
65

Jawaban singkatnya adalah, tidak ada perbedaan. Bantulah diri Anda sendiri dan berhentilah mengkhawatirkan hal ini. Kompiler pengoptimal hampir selalu lebih pintar dari Anda.

Berkonsentrasi pada keterbacaan dan pemeliharaan.

Jika Anda ingin melihat apa yang terjadi, buat ini dengan optimisasi dan lihat keluaran assembler.

blueshift
sumber
8
@Philip: Dan bantulah semua orang juga dan berhentilah mengkhawatirkan hal ini. Kode yang Anda tulis akan dibaca dan dipelihara oleh orang lain juga (dan bahkan jika Anda menulis yang tidak akan pernah dibaca oleh orang lain, Anda tetap mengembangkan kebiasaan yang akan mempengaruhi kode lain yang Anda tulis yang akan dibaca oleh orang lain). Selalu tulis kode agar mudah dipahami.
hlovdal
8
Pengoptimal tidak lebih pintar dari Anda !!! Mereka hanya lebih cepat dalam memutuskan di mana dampaknya tidak terlalu menjadi masalah. Di mana itu benar-benar penting, Anda pasti akan dengan beberapa pengalaman mengoptimalkan lebih baik daripada kompiler.
johannes
10
@johannes Biarkan saya tidak setuju. Kompiler tidak akan mengubah algoritme Anda menjadi lebih baik, tetapi ia melakukan pekerjaan yang luar biasa dalam menyusun ulang instruksi untuk mencapai efisiensi pipa maksimum dan hal-hal lain yang tidak terlalu sepele untuk loop (fisi, fusi, dll.) Yang bahkan programmer berpengalaman tidak dapat memutuskan apa yang lebih baik apriori kecuali dia memiliki pengetahuan yang mendalam tentang arsitektur CPU.
fortran
3
@Johannes - untuk pertanyaan ini, Anda dapat berasumsi demikian. Selain itu, secara umum, terkadang Anda mungkin dapat mengoptimalkan lebih baik daripada kompiler dalam beberapa kasus khusus, tetapi itu membutuhkan sedikit pengetahuan khusus akhir-akhir ini - kasus normalnya adalah bahwa pengoptimal menerapkan sebagian besar pengoptimalan yang dapat Anda pikirkan dan melakukannya sistematis, tidak hanya dalam beberapa kasus khusus. WRT pertanyaan ini, kompilator mungkin akan membangun grafik aliran eksekusi yang persis sama untuk kedua bentuk. Memilih algoritme yang lebih baik adalah pekerjaan manusia, tetapi pengoptimalan level kode hampir selalu membuang-buang waktu.
Steve314
4
Saya setuju dan tidak setuju dengan ini. Ada beberapa kasus ketika kompilator tidak dapat mengetahui bahwa sesuatu itu setara dengan sesuatu yang lain. Tahukah Anda bahwa ini sering kali lebih cepat dilakukan x = <some number>daripada if(<would've changed>) x = <some number>cabang yang tidak dibutuhkan bisa sangat menyakitkan. Di sisi lain, kecuali ini berada di dalam loop utama dari operasi yang sangat intensif, saya juga tidak akan mengkhawatirkannya.
pengguna606723
28

Jawaban yang menarik: Meskipun saya setuju dengan semuanya (sejauh ini), ada kemungkinan konotasi untuk pertanyaan ini yang sampai sekarang benar-benar diabaikan.

Jika contoh sederhana di atas diperluas dengan alokasi sumber daya, dan kemudian pengecekan kesalahan dengan potensi pembebasan sumber daya, gambar mungkin berubah.

Pertimbangkan pendekatan naif yang mungkin diambil pemula:

int func(..some parameters...) {
  res_a a = allocate_resource_a();
  if (!a) {
    return 1;
  }
  res_b b = allocate_resource_b();
  if (!b) {
    free_resource_a(a);
    return 2;
  }
  res_c c = allocate_resource_c();
  if (!c) {
    free_resource_b(b);
    free_resource_a(a);
    return 3;
  }

  do_work();

  free_resource_c(c);
  free_resource_b(b);
  free_resource_a(a);

  return 0;
}

Di atas akan mewakili versi ekstrim dari gaya kembali sebelum waktunya. Perhatikan bagaimana kode menjadi sangat berulang dan tidak dapat dipelihara seiring waktu ketika kompleksitasnya bertambah. Saat ini orang mungkin menggunakan penanganan pengecualian untuk menangkap ini.

int func(..some parameters...) {
  res_a a;
  res_b b;
  res_c c;

  try {
    a = allocate_resource_a(); # throws ExceptionResA
    b = allocate_resource_b(); # throws ExceptionResB
    c = allocate_resource_c(); # throws ExceptionResC
    do_work();
  }  
  catch (ExceptionBase e) {
    # Could use type of e here to distinguish and
    # use different catch phrases here
    # class ExceptionBase must be base class of ExceptionResA/B/C
    if (c) free_resource_c(c);
    if (b) free_resource_b(b);
    if (a) free_resource_a(a);
    throw e
  }
  return 0;
}

Philip menyarankan, setelah melihat contoh goto di bawah ini, untuk menggunakan sakelar / casing tanpa pemutus di dalam blok penangkap di atas. Seseorang dapat beralih (typeof (e)) dan kemudian jatuh melalui free_resourcex()panggilan tetapi ini tidak sepele dan perlu pertimbangan desain . Dan ingat bahwa sakelar / casing tanpa jeda persis seperti goto dengan label rantai daisy di bawah ini ...

Seperti yang ditunjukkan Mark B, di C ++, gaya yang baik untuk mengikuti Akuisisi Sumber Daya adalah prinsip Inisialisasi , RAII singkatnya . Inti dari konsep ini adalah menggunakan instansiasi objek untuk memperoleh sumber daya. Sumber daya kemudian secara otomatis dibebaskan segera setelah objek keluar dari ruang lingkup dan penghancurnya dipanggil. Untuk sumber daya yang saling bergantung, perhatian khusus harus dilakukan untuk memastikan urutan deallokasi yang benar dan untuk merancang jenis objek sedemikian rupa sehingga data yang diperlukan tersedia untuk semua destruktor.

Atau di hari-hari pra-pengecualian mungkin bisa:

int func(..some parameters...) {
  res_a a = allocate_resource_a();
  res_b b = allocate_resource_b();
  res_c c = allocate_resource_c();
  if (a && b && c) {   
    do_work();
  }  
  if (c) free_resource_c(c);
  if (b) free_resource_b(b);
  if (a) free_resource_a(a);

  return 0;
}

Tetapi contoh yang terlalu disederhanakan ini memiliki beberapa kelemahan: Dapat digunakan hanya jika sumber daya yang dialokasikan tidak bergantung satu sama lain (misalnya tidak dapat digunakan untuk mengalokasikan memori, kemudian membuka filehandle, lalu membaca data dari pegangan ke dalam memori ), dan tidak memberikan kode kesalahan individual yang dapat dibedakan sebagai nilai pengembalian.

Untuk menjaga kode tetap cepat (!), Kompak, dan mudah dibaca dan diperluas, Linus Torvalds menerapkan gaya berbeda untuk kode kernel yang berhubungan dengan sumber daya, bahkan menggunakan goto yang terkenal dengan cara yang benar-benar masuk akal :

int func(..some parameters...) {
  res_a a;
  res_b b;
  res_c c;

  a = allocate_resource_a() || goto error_a;
  b = allocate_resource_b() || goto error_b;
  c = allocate_resource_c() || goto error_c;

  do_work();

error_c:
  free_resource_c(c);
error_b:
  free_resource_b(b);
error_a:
  free_resource_a(a);

  return 0;
}

Inti dari diskusi di milis kernel adalah bahwa sebagian besar fitur bahasa yang "lebih disukai" daripada pernyataan goto adalah goto implisit, seperti if / else besar, penangan pengecualian, pernyataan loop / break / lanjutkan, dll. . Dan goto pada contoh di atas dianggap baik, karena mereka hanya melompat dalam jarak kecil, memiliki label yang jelas, dan membebaskan kode dari kekacauan lain untuk melacak kondisi kesalahan. Pertanyaan ini juga telah dibahas di sini di stackoverflow .

Namun apa yang hilang dalam contoh terakhir adalah cara yang bagus untuk mengembalikan kode kesalahan. Saya berpikir untuk menambahkan result_code++setelah setiap free_resource_x()panggilan, dan mengembalikan kode itu, tetapi ini mengimbangi beberapa peningkatan kecepatan dari gaya pengkodean di atas. Dan sulit untuk mengembalikan 0 jika berhasil. Mungkin saya hanya tidak imajinatif ;-)

Jadi, ya, menurut saya ada perbedaan besar dalam pertanyaan tentang pengkodean pengembalian dini atau tidak. Tetapi saya juga berpikir itu hanya terlihat dalam kode yang lebih rumit yang lebih sulit atau tidak mungkin untuk direstrukturisasi dan dioptimalkan untuk kompiler. Yang biasanya terjadi setelah alokasi sumber daya mulai berlaku.

cfi
sumber
1
Wow, sangat menarik. Saya pasti bisa menghargai ketidaktertahankannya pendekatan naif. Bagaimana penanganan pengecualian memperbaiki kasus tertentu itu? Seperti catchberisi pernyataan break-less switchpada kode kesalahan?
Philip Guin
@Philip Menambahkan contoh penanganan pengecualian dasar. Perhatikan bahwa hanya goto yang memiliki kemungkinan gagal. Sakelar yang Anda usulkan (tipe (e)) akan membantu, tetapi tidak sepele dan perlu pertimbangan desain . Dan ingat bahwa sakelar / casing tanpa jeda persis seperti goto dengan label rantai daisy ;-)
cfi
+1 ini adalah jawaban yang benar untuk C / C ++ (atau bahasa apa pun yang memerlukan pembebasan memori secara manual). Secara pribadi, saya tidak suka versi multi-label. Di perusahaan saya sebelumnya, selalu "goto fin" (itu adalah perusahaan Prancis). Akhirnya kita akan mengalokasikan memori apa pun, dan itulah satu-satunya penggunaan goto yang akan lulus tinjauan kode.
Kip
1
Perhatikan bahwa di C ++ Anda tidak akan melakukan salah satu pendekatan ini, tetapi akan menggunakan RAII untuk memastikan bahwa sumber daya dibersihkan dengan benar.
Markus B
12

Meskipun ini bukan jawaban yang bagus, kompiler produksi akan jauh lebih baik dalam pengoptimalannya daripada Anda. Saya lebih menyukai keterbacaan dan pemeliharaan atas jenis pengoptimalan ini.

Lou
sumber
9

Untuk lebih spesifik tentang ini, returnakan dikompilasi menjadi cabang ke akhir metode, di mana akan ada RETinstruksi atau apapun itu. Jika Anda membiarkannya keluar, ujung blok sebelumnya elseakan dikompilasi menjadi cabang hingga akhir elseblok. Jadi Anda dapat melihat dalam kasus khusus ini tidak ada bedanya sama sekali.

Marquis dari Lorne
sumber
Kena kau. Saya benar-benar berpikir ini menjawab pertanyaan saya dengan cukup ringkas; Saya kira ini benar-benar hanya tambahan register, yang cukup dapat diabaikan (kecuali jika Anda melakukan pemrograman sistem, dan bahkan kemudian ...) Saya akan memberikan ini sebutan yang terhormat.
Philip Guin
@Philip apa penambahan register? Tidak ada instruksi tambahan sama sekali di jalur tersebut.
Marquis dari Lorne
Baik keduanya akan memiliki tambahan register. Itu semua adalah cabang perakitan, bukan? Tambahan penghitung program? Saya bisa saja salah di sini.
Philip Guin
1
@Philip Tidak, cabang perakitan adalah cabang perakitan. Itu memang mempengaruhi PC tentu saja tetapi bisa juga dengan memuat ulang sepenuhnya, dan itu juga memiliki efek samping dalam prosesor, seperti pipa, cache, dll.
Marquis dari Lorne
4

Jika Anda benar-benar ingin tahu apakah ada perbedaan dalam kode yang dikompilasi untuk kompilator dan sistem Anda, Anda harus mengkompilasi dan melihat sendiri rakitannya.

Namun dalam skema besar, hampir pasti bahwa compiler dapat mengoptimalkan lebih baik daripada fine tuning Anda, dan bahkan jika tidak bisa, sangat kecil kemungkinannya untuk benar-benar penting bagi kinerja program Anda.

Sebaliknya, tulis kode dengan cara yang paling jelas untuk dibaca dan dipelihara oleh manusia, dan biarkan kompilator melakukan yang terbaik: Hasilkan perakitan terbaik yang dapat dilakukan dari sumber Anda.

Mark B
sumber
4

Dalam contoh Anda, pengembaliannya terlihat. Apa yang terjadi pada orang yang men-debug ketika kembaliannya adalah satu atau dua halaman di atas / di bawah tempat // terjadi hal-hal yang berbeda? Jauh lebih sulit untuk ditemukan / dilihat ketika ada lebih banyak kode.

void foo1(bool flag)
{
    if (flag)
    {
        //Do stuff
        return;
    }

    //Do different stuff
}

void foo2(bool flag)
{
    if (flag)
    {
        //Do stuff
    }
    else
    {
        //Do different stuff
    }
}
PCPGMR
sumber
Tentu saja, sebuah fungsi tidak boleh lebih dari satu (atau bahkan dua) halaman. Tetapi aspek debugging belum tercakup dalam jawaban lainnya. Poin diambil!
cfi
3

Saya sangat setuju dengan blueshift: keterbacaan dan pemeliharaan terlebih dahulu !. Tetapi jika Anda benar-benar khawatir (atau hanya ingin mempelajari apa yang dilakukan kompiler Anda, yang tentunya merupakan ide yang bagus dalam jangka panjang), Anda harus mencarinya sendiri.

Ini berarti menggunakan decompiler atau melihat output compiler level rendah (misalnya bahasa assembly). Dalam C #, atau bahasa .Net apa pun, alat didokumentasikan di sini akan memberi Anda apa yang Anda butuhkan.

Tetapi seperti yang telah Anda amati sendiri, ini mungkin pengoptimalan yang prematur.

Tuan Putty
sumber
1

Dari Kode Bersih: Buku Pegangan Pengerjaan Perangkat Lunak Tangkas

Argumen bendera itu jelek. Meneruskan boolean menjadi suatu fungsi adalah praktik yang sangat buruk. Ini segera memperumit tanda tangan metode, dengan lantang menyatakan bahwa fungsi ini melakukan lebih dari satu hal. Ia melakukan satu hal jika benderanya benar dan yang lainnya jika benderanya salah!

foo(true);

dalam kode hanya akan membuat pembaca menavigasi ke fungsi dan membuang waktu membaca foo (bendera boolean)

Basis kode terstruktur yang lebih baik akan memberi Anda kesempatan yang lebih baik untuk mengoptimalkan kode.

Yuan
sumber
Saya hanya menggunakan ini sebagai contoh. Apa yang diteruskan ke fungsi bisa menjadi int, double, kelas, sebut saja, itu bukan inti masalahnya.
Philip Guin
Pertanyaan yang Anda ajukan adalah tentang melakukan sakelar di dalam fungsi Anda, sebagian besar, ini adalah bau kode. Ini dapat dicapai dengan banyak cara dan pembaca tidak harus membaca seluruh fungsi itu, katakan apa arti foo (28)?
Yuan
0

Satu aliran pemikiran (tidak dapat mengingat orang pintar yang mengusulkannya saat ini) adalah bahwa semua fungsi hanya boleh memiliki satu titik balik dari sudut pandang struktural untuk membuat kode lebih mudah dibaca dan di-debug. Itu, saya kira, lebih untuk memprogram debat agama.

Salah satu alasan teknis Anda mungkin ingin mengontrol kapan dan bagaimana fungsi keluar yang melanggar aturan ini adalah saat Anda mengkodekan aplikasi waktu nyata dan Anda ingin memastikan bahwa semua jalur kontrol melalui fungsi tersebut mengambil jumlah siklus jam yang sama untuk diselesaikan.

MartyTPS
sumber
Eh, saya pikir itu ada hubungannya dengan pembersihan (terutama saat coding dalam C).
Thomas Eding
tidak, tidak peduli di mana Anda meninggalkan metode selama Anda mengembalikan tumpukan itu terbentur kembali (hanya itu yang "dibersihkan").
MartyTPS
-4

Saya senang Anda mengajukan pertanyaan ini. Anda harus selalu menggunakan cabang untuk pengembalian awal. Kenapa berhenti disana? Gabungkan semua fungsi Anda menjadi satu jika Anda bisa (setidaknya sebanyak yang Anda bisa). Ini bisa dilakukan jika tidak ada rekursi. Pada akhirnya, Anda akan memiliki satu fungsi utama yang sangat besar, tetapi itulah yang Anda butuhkan / inginkan untuk hal semacam ini. Setelah itu, ganti nama pengenal Anda menjadi sesingkat mungkin. Dengan begitu, saat kode Anda dieksekusi, lebih sedikit waktu yang dihabiskan untuk membaca nama. Selanjutnya lakukan ...

Thomas Eding
sumber
3
Saya tahu Anda sedang bercanda, tetapi hal yang menakutkan adalah bahwa beberapa orang mungkin menganggap serius nasihat Anda!
Daniel Pryden
Setuju dengan Daniel. Sebanyak saya suka sinisme - itu tidak boleh digunakan dalam dokumentasi teknis, whitepaper, dan situs Tanya Jawab seperti SO.
cfi
1
-1 untuk jawaban yang sinis, belum tentu dapat dikenali oleh pemula.
Johan Bezem