Menulis kode yang efisien tanpa mengorbankan keterbacaan

Selesai

Menyeimbangkan efisiensi dan keterbacaan kode adalah keterampilan penting bagi pengembang perangkat lunak. Meskipun performa sangat penting, seharusnya tidak mengorbankan kejelasan kode dan kemudahan pemeliharaan.

Kejelasan terlebih dahulu, optimalkan saat diperlukan

Prinsip panduan dalam rekayasa perangkat lunak adalah terlebih dahulu membuat kode berfungsi dengan benar dan jelas, lalu mengoptimalkan jika dan jika perlu. Jangan mulai mengoptimalkan sebelum Anda tahu apa yang benar-benar membutuhkannya.

Ketika Anda mencoba mengoptimalkan bagian kode tertentu tanpa bukti bahwa kode lambat, Anda berisiko dengan masalah berikut:

  • Membuat kode lebih sulit dipahami dan dipertahankan: Pengoptimalan kompleks (terutama pengoptimalan mikro) dapat memperkenalkan logika berkonvolusi, peretasan yang tidak jelas, atau kode kasus khusus. Penjaga masa depan mungkin kesulitan untuk memahaminya, atau lebih buruk, memperkenalkan bug saat memodifikasinya.

  • Membuang-buang waktu: Anda mungkin menghabiskan berjam-jam mengubah sesuatu yang memiliki efek yang dapat diabaikan pada performa keseluruhan, sambil mengabaikan masalah yang lebih besar di tempat lain.

  • Mengurangi keandalan atau fleksibilitas: Terkadang tweak performa ekstrem menghilangkan lapisan abstraksi atau pemeriksaan kesalahan. Misalnya, menggunakan aritmatika pointer untuk kecepatan dalam C# (kode tidak aman) mungkin mendapatkan sedikit performa tetapi berisiko tinggi terhadap keselamatan dan hilangnya portabilitas. Jenis perubahan ini sulit untuk dijualifikasi dalam aplikasi bisnis.

Mulailah dengan solusi yang jelas. Ingatlah bahwa pengembang membaca kode lebih sering daripada mereka menulisnya. Tekankan penamaan, struktur, dan kesederhanaan yang dapat dibaca. Terlepas dari pilihan algoritma tingkat tinggi, banyak pengoptimalan mikro (seperti cache komputasi trivial atau menghemat beberapa siklus CPU) tidak sebanding dengan pengorbanan kejelasan. Kompiler dan perangkat keras modern baik dalam menjalankan kode langsung secara efisien.

Kapan pengoptimalan masuk akal?

Setelah menulis versi awal kode Anda, identifikasi bagian penting apa pun yang memerlukan pengoptimalan ("kritis 3%"). Berikut adalah tanda dan skenario di mana pengoptimalan (bahkan jika mempersulit kode sedikit) dibenarkan:

  • Hotspot yang Dikonfirmasi: Pembuatan profil menunjukkan metode atau perulangan tertentu mengonsumsi persentase waktu eksekusi yang signifikan. Contoh: Anda membuat profil dan menemukan satu fungsi membutuhkan 60% runtime program. Jika pengoptimalan dapat memotong waktu fungsi tersebut menjadi dua, itu menghasilkan kemenangan keseluruhan yang signifikan.

  • Inefisiensi Algoritma yang Jelas: Terkadang Anda tahu pendekatan yang lebih sederhana secara dramatis kurang efisien dalam istilah big-O. Misalnya, menggunakan perulangan berlapis ganda untuk membandingkan elemen dari dua daftar besar adalah O(n*m); jika Anda menggunakan set hash untuk satu daftar, Anda berpotensi turun ke O(n+m). Jika n dan m bisa besar, perbedaannya sangat besar. Dalam kasus seperti itu, pengembang yang berpengalaman mungkin menerapkan pendekatan yang lebih efisien sejak awal - itu tidak "prematur" jika kebutuhan terbukti. Sangat penting, banyak peningkatan algoritma tidak membuat kode kurang dapat dibaca jika dilakukan dengan baik (menggunakan nama metode deskriptif, komentar, dll.).

  • Operasi Berulang: Jika sepotong kode berjalan sesekali, inefisiensi kecil baik-baik saja. Tetapi jika berjalan ribuan kali sedetik (misalnya, di dalam perulangan yang ketat atau panggilan layanan frekuensi tinggi), Anda akan menganalisisnya lebih dalam. Misalnya, membuat objek baru mungkin baik-baik saja, tetapi melakukannya dalam perulangan 100.000 kali per detik (ketika Anda dapat menggunakan kembali objek) mungkin menjamin perubahan.

  • Domain kritis performa: Dalam domain tertentu (seperti pengembangan game, sistem real-time, atau sistem tersemat), persyaratan performa ketat. Di sini, pengembang sering berpikir tentang efisiensi sejak awal, karena pendekatan naif mungkin tidak memenuhi persyaratan. Meskipun demikian, mereka mengandalkan pola yang sudah dikenal dan praktik terbaik daripada penyesuaian tingkat rendah yang tidak dapat diprediksi.

Tujuannya adalah untuk mengoptimalkan ketika data mendukungnya atau konteks domain menuntutnya, dan bahkan kemudian melakukannya dengan cara yang dapat dipertahankan.

Keterbacaan versus pengoptimalan

Seringkali ada trade-off antara menulis kode yang mudah dibaca dan kode yang sangat dioptimalkan. Namun, banyak pengoptimalan dapat dicapai tanpa mengorbankan kejelasan. Mari kita lihat beberapa contoh yang menggambarkan keseimbangan antara keterbacaan dan efisiensi.

Menggunakan struktur data yang sesuai

Misalkan Anda memiliki koleksi dan Anda perlu berulang kali memeriksa apakah koleksi berisi nilai tertentu. Anda memiliki beberapa opsi:

  • Dapat dibaca tetapi kurang efisien: Iterasi setiap List<T> kali untuk menemukan nilainya. Pendekatan ini memiliki kompleksitas O(n) untuk setiap pemeriksaan, dan kode tetap jelas dan sederhana (baik melalui perulangan dasar atau dengan menggunakan List.Contains, yang melakukan iterasi internal).

  • Efisien dan masih dapat dibaca: Gunakan HashSet<T> atau Dictionary<TKey, TValue> untuk pencarian, memberikan waktu rata-rata O(1) per cek. Terdapat sedikit lebih banyak kode (Anda menambahkan elemen ke dalam HashSet dan menggunakan metodenya Contains), tetapi masih jelas. Bahkan, menggunakan HashSet bahkan mungkin lebih ekspresif: ia memberi tahu pembaca "kita perlu pencarian cepat". Ini adalah kasus di mana solusi yang lebih efisien juga bersih.

  • Terlalu dioptimalkan dan kurang dapat dibaca: Alternatif yang dilanjutkan mungkin melibatkan manipulasi bit tingkat rendah atau algoritma hash kustom yang disesuaikan dengan himpunan data tertentu ini. Pendekatan ini mungkin akan membingungkan pengelola kode dan hanya memberikan keuntungan performa minimal atas standar HashSet (jika ada peningkatan sama sekali). Anda harus menghindari strategi ini kecuali pembuatan profil menunjukkan bahwa struktur data bawaan menciptakan hambatan performa dan implementasi kustom benar-benar penting (yang jarang terjadi).

Ekspansi perulangan atau inlining manual

Terkadang pengembang mencoba "mengoptimalkan" perulangan dengan mengungkapnya atau menginline kode secara manual untuk menghemat biaya overhead loop. Pertimbangkan perulangan yang memproses array:

  • Mudah dibaca: Tulis perulangan yang memproses array 100 elemen. Kodenya ringkas dan jelas. Pengkompilasi dapat mengoptimalkannya dengan baik, dan CPU modern apa pun dapat menangani 100 iterasi dengan mudah.

  • Terlalu dioptimalkan: "Mengurai" perulangan dengan membuat 100 pernyataan yang sama berulang kali untuk menghindari beban berlebih dari perulangan. Pendekatan ini mungkin menghemat beberapa siklus CPU pada pengendalian perulangan, tetapi sekarang kode Anda terdiri dari 100 baris pernyataan yang berulang-ulang - jelas tidak ada gunanya. Masalah besar pemeliharaan kalau Anda mengubahnya menjadi 101 elemen!

  • Ketika itu penting: Untuk perulangan dalam yang sensitif terhadap performa (ditemukan di pustaka komputasi atau algoritma berkinerja tinggi), pengembang terkadang menggunakan pembatalan perulangan parsial untuk pengoptimalan. Namun, kompilator biasanya menangani skenario ini secara otomatis atau melalui mekanisme pengoptimalan lainnya, daripada memerlukan implementasi manual dalam kode aplikasi. Sebagai pengembang aplikasi, andalkan kemampuan pengoptimalan kompilator dan pertahankan kode yang sederhana dan jelas.

Perangkaian string di C#

Perangkaian string adalah skenario umum di mana performa dan keterbacaan dapat bertentangan. Ketika perangkaian string dilakukan berulang kali, pilihan metode dapat berdampak signifikan pada performa.

  • Pendekatan naif: Menggunakan string += string dalam perulangan. Contoh: membangun kueri SQL panjang atau CSV dengan menambahkan baris dalam perulangan. Teknik ini mudah dibaca, tetapi di .NET masing-masing += pada string membuat string baru (karena string tidak dapat diubah). Jika Anda menambahkan 1.000 kali, Anda membuat banyak objek string perantara - kode ini tidak efisien dalam waktu dan memori.

  • Pendekatan yang lebih baik: Gunakan StringBuilder untuk beberapa penggabungan. Kelas ini dirancang untuk skenario tersebut; kelas ini membangun string dalam buffer dan menghasilkan satu string akhir. Kode ini sedikit lebih verbose (Anda harus memanggil Append alih-alih +=), tetapi masih mudah dipahami. Ini dengan jelas menandakan "kami membangun string secara efisien." Bahkan, panduan praktik terbaik untuk .NET merekomendasikan StringBuilder untuk penggabungan string di dalam perulangan. Menggunakan StringBuilder untuk perangkaian string berulang lebih mudah dibaca (untuk pengembang berpengalaman) dan lebih berkinerja.

Contoh ini menunjukkan bahwa terkadang perubahan kecil (menggunakan API yang berbeda) menghasilkan perolehan performa besar dengan efek minimal pada keterbacaan. Pendekatan awal mungkin berfungsi untuk string kecil, tetapi ketika Anda menghadapi input yang besar, perbedaan kinerjanya signifikan.

Hasil cache

Caching adalah teknik pengoptimalan umum yang dapat meningkatkan performa dengan menyimpan hasil panggilan fungsi yang mahal dan menggunakannya kembali ketika input yang sama terjadi lagi.

  • Tanpa caching: Bayangkan sebuah fungsi GetExchangeRate(currency) yang mengambil nilai tukar terkini dengan panggilan HTTP. Jika Anda memanggilnya berulang kali untuk mata uang yang sama, dan tidak disimpan dalam cache, Anda melakukan pekerjaan yang redundan (dan operasi I/O jaringan). Ini mudah tetapi tidak efisien.

  • Dengan caching: Anda menambahkan struktur data kamus untuk menyimpan hasil setelah mengambil, sehingga panggilan berikutnya dikembalikan langsung dari memori. Teknik ini menambahkan beberapa kompleksitas (Anda harus mengelola cache, mungkin tidak validasi jika tarif berubah), tetapi untuk data yang sering diminta, itu dapat secara drastis meningkatkan performa dengan menghindari panggilan yang tidak perlu.

Keputusan untuk cache sering tergantung pada pola penggunaan. Kode menjadi sedikit lebih kompleks (Anda perlu menangani logika cache), dan Anda harus memastikannya tetap benar (data basi, keamanan utas jika diakses dari beberapa utas, dll.). Caching adalah skenario klasik di mana kompleksitas dikorbankan untuk meningkatkan performa. Caching memberikan peningkatan performa yang substansial saat data diakses berulang kali.

Praktik terbaik untuk menyeimbangkan efisiensi dan keterbacaan

Berikut adalah beberapa praktik terbaik untuk membantu Anda menyeimbangkan efisiensi dan keterbacaan dalam kode Anda:

  • Lebih suka kejelasan algoritma: Saat memilih cara mengimplementasikan sesuatu, pikirkan tentang kompleksitas algoritma terlebih dahulu (apakah linier, kuadrat, dll.?). Pilih desain yang memberikan kompleksitas yang baik tanpa mengacaukan kode Anda. Seringkali solusi yang paling elegan secara algoritma juga merupakan kode bersih.

  • Gunakan alat yang tepat untuk pekerjaan: Bahasa dan pustaka tingkat tinggi menyediakan fungsionalitas yang dioptimalkan yang harus Anda gunakan. Misalnya, Language Integrated Query (LINQ) di C# dapat mengekspresikan operasi data tertentu dengan jelas, dan cukup dioptimalkan secara internal. Demikian pula, pustaka pemrosesan paralel (Parallel.ForEach, PLINQ) mengaktifkan eksekusi bersamaan sambil mempertahankan struktur kode yang relatif sederhana. Jangan menciptakan ulang roda kecuali memang diperlukan.

  • Komentari pengoptimalan yang tidak jelas: Jika Anda melakukan sesuatu dengan cara yang tidak intuitif karena alasan performa, tambahkan komentar yang menjelaskan alasannya. Contoh: "Menggunakan pool objek manual di sini untuk mengurangi tekanan pada proses garbage collection, karena metode ini dipanggil dalam perulangan yang ketat dan kami tidak dapat melakukan alokasi yang sering." Menambahkan komentar membantu pembaca di masa mendatang (dan diri Anda sendiri dalam enam bulan) mengingat mengapa kodenya seperti itu.

  • Peningkatan inkremental: Anda sering dapat memulai dengan desain sederhana, lalu secara bertahap meningkatkan bagian-bagian yang membutuhkannya. Selalu bandingkan versi kode yang dioptimalkan dengan yang asli untuk memastikan perilaku kode tidak berubah. Kontrol versi dapat membantu Anda memutar kembali perubahan jika perlu.

  • Jangan membahayakan keselamatan/keamanan untuk kecepatan: Misalnya, melewatkan validasi input atau penanganan kesalahan mungkin membuat kode berjalan sedikit lebih cepat, tetapi hampir tidak pernah sepadan dengan kompromi. Ketahanan lebih penting. Bertujuan untuk pengoptimalan yang tidak merusak kebenaran atau keamanan kode.

Hindari pengoptimalan dini

Bergegas untuk mengoptimalkan kode sebelum Anda tahu di mana hambatan nyata adalah perangkap umum.

Dalam praktiknya:

  • Tulis kode Anda dengan bersih dan rapi dengan struktur yang baik.
  • Identifikasi apakah ada bagian yang menjadi hambatan.
  • Optimalkan bagian tersebut, dengan cara yang dapat dipertahankan, dan verifikasi peningkatannya.

Pendekatan ini memastikan Anda menghabiskan waktu untuk hal yang penting dan menjaga basis kode Anda tetap efisien dan sehat.

Ringkasan

Menulis kode yang efisien tidak harus mengorbankan keterbacaan. Dengan memprioritaskan kejelasan terlebih dahulu dan mengoptimalkan berdasarkan bukti, Anda dapat mencapai keseimbangan yang melayani performa dan pemeliharaan. Gunakan struktur data yang sesuai, terapkan pustaka bawaan, dan terapkan pengoptimalan secara yudisius. Selalu dokumentasikan pilihan yang tidak berbahaya dan hindari pengoptimalan dini. Pendekatan seimbang ini mengarah pada kode yang kuat, efisien, dan dapat dimengerti yang bertahan dalam ujian waktu.