10 Kesalahan C++ Paling Umum yang Dilakukan Pengembang

Diterbitkan: 2022-03-11

Ada banyak jebakan yang mungkin dihadapi pengembang C++. Hal ini dapat membuat pemrograman berkualitas menjadi sangat sulit dan pemeliharaan menjadi sangat mahal. Mempelajari sintaks bahasa dan memiliki keterampilan pemrograman yang baik dalam bahasa serupa, seperti C# dan Java, tidak cukup untuk memanfaatkan potensi penuh C++. Ini membutuhkan pengalaman bertahun-tahun dan disiplin tinggi untuk menghindari kesalahan dalam C++. Pada artikel ini, kita akan melihat beberapa kesalahan umum yang dibuat oleh pengembang dari semua tingkatan jika mereka tidak cukup berhati-hati dengan pengembangan C++.

Kesalahan Umum #1: Salah Menggunakan Pair “baru” dan “hapus”

Tidak peduli seberapa banyak kita mencoba, sangat sulit untuk membebaskan semua memori yang dialokasikan secara dinamis. Bahkan jika kita bisa melakukan itu, seringkali tidak aman dari pengecualian. Mari kita lihat contoh sederhana:

 void SomeMethod() { ClassA *a = new ClassA; SomeOtherMethod(); // it can throw an exception delete a; }

Jika pengecualian dilemparkan, objek "a" tidak pernah dihapus. Contoh berikut menunjukkan cara yang lebih aman dan lebih singkat untuk melakukannya. Ini menggunakan auto_ptr yang tidak digunakan lagi di C++ 11, tetapi standar lama masih banyak digunakan. Itu dapat diganti dengan C++ 11 unique_ptr atau scoped_ptr dari Boost jika memungkinkan.

 void SomeMethod() { std::auto_ptr<ClassA> a(new ClassA); // deprecated, please check the text SomeOtherMethod(); // it can throw an exception }

Apa pun yang terjadi, setelah membuat objek "a", objek itu akan dihapus segera setelah eksekusi program keluar dari ruang lingkup.

Namun, ini hanyalah contoh paling sederhana dari masalah C++ ini. Ada banyak contoh saat penghapusan harus dilakukan di tempat lain, mungkin di fungsi luar atau utas lain. Itulah mengapa penggunaan new/delete berpasangan harus benar-benar dihindari dan pointer pintar yang sesuai harus digunakan sebagai gantinya.

Kesalahan Umum #2: Destructor Virtual yang Terlupakan

Ini adalah salah satu kesalahan paling umum yang menyebabkan kebocoran memori di dalam kelas turunan jika ada memori dinamis yang dialokasikan di dalamnya. Ada beberapa kasus ketika destruktor virtual tidak diinginkan, yaitu ketika kelas tidak dimaksudkan untuk pewarisan dan ukuran serta kinerjanya sangat penting. Penghancur virtual atau fungsi virtual lainnya memperkenalkan data tambahan di dalam struktur kelas, yaitu penunjuk ke tabel virtual yang membuat ukuran setiap instance kelas menjadi lebih besar.

Namun, dalam kebanyakan kasus, kelas dapat diwarisi meskipun awalnya tidak dimaksudkan. Jadi itu adalah praktik yang sangat baik untuk menambahkan destruktor virtual ketika sebuah kelas dideklarasikan. Jika tidak, jika suatu kelas tidak boleh berisi fungsi virtual karena alasan kinerja, merupakan praktik yang baik untuk meletakkan komentar di dalam file deklarasi kelas yang menunjukkan bahwa kelas tersebut tidak boleh diwarisi. Salah satu opsi terbaik untuk menghindari masalah ini adalah dengan menggunakan IDE yang mendukung pembuatan destruktor virtual selama pembuatan kelas.

Satu poin tambahan untuk subjek adalah kelas/templat dari perpustakaan standar. Mereka tidak dimaksudkan untuk warisan dan mereka tidak memiliki destruktor virtual. Jika, misalnya, kami membuat kelas string baru yang disempurnakan yang secara publik mewarisi dari std::string, ada kemungkinan seseorang akan salah menggunakannya dengan pointer atau referensi ke std::string dan menyebabkan kebocoran memori.

 class MyString : public std::string { ~MyString() { // ... } }; int main() { std::string *s = new MyString(); delete s; // May not invoke the destructor defined in MyString }

Untuk menghindari masalah C++ seperti itu, cara yang lebih aman untuk menggunakan kembali kelas/templat dari pustaka standar adalah dengan menggunakan pewarisan atau komposisi pribadi.

Kesalahan Umum #3: Menghapus Array Dengan "hapus" atau Menggunakan Smart Pointer

Kesalahan Umum #3

Membuat array sementara dengan ukuran dinamis seringkali diperlukan. Setelah tidak diperlukan lagi, penting untuk mengosongkan memori yang dialokasikan. Masalah besar di sini adalah bahwa C++ memerlukan operator hapus khusus dengan tanda kurung [], yang sangat mudah dilupakan. Operator delete[] tidak hanya akan menghapus memori yang dialokasikan untuk sebuah array, tetapi pertama-tama akan memanggil destruktor semua objek dari sebuah array. Juga salah menggunakan operator delete tanpa tanda kurung [] untuk tipe primitif, meskipun tidak ada destruktor untuk tipe ini. Tidak ada jaminan untuk setiap kompiler bahwa pointer ke array akan menunjuk ke elemen pertama dari array, jadi menggunakan hapus tanpa tanda kurung [] dapat mengakibatkan perilaku yang tidak terdefinisi juga.

Menggunakan pointer pintar, seperti auto_ptr, unique_ptr<T>, shared_ptr, dengan array juga salah. Ketika penunjuk pintar seperti itu keluar dari ruang lingkup, itu akan memanggil operator hapus tanpa tanda kurung [] yang menghasilkan masalah yang sama seperti yang dijelaskan di atas. Jika penggunaan smart pointer diperlukan untuk array, dimungkinkan untuk menggunakan scoped_array atau shared_array dari Boost atau unique_ptr<T[]> spesialisasi.

Jika fungsionalitas penghitungan referensi tidak diperlukan, yang sebagian besar terjadi pada array, cara yang paling elegan adalah dengan menggunakan vektor STL. Mereka tidak hanya mengurus pelepasan memori, tetapi juga menawarkan fungsionalitas tambahan.

Kesalahan Umum #4: Mengembalikan Objek Lokal dengan Referensi

Ini sebagian besar kesalahan pemula, tetapi perlu disebutkan karena ada banyak kode lama yang mengalami masalah ini. Mari kita lihat kode berikut di mana seorang programmer ingin melakukan semacam optimasi dengan menghindari penyalinan yang tidak perlu:

 Complex& SumComplex(const Complex& a, const Complex& b) { Complex result; ….. return result; } Complex& sum = SumComplex(a, b);

Objek "jumlah" sekarang akan menunjuk ke "hasil" objek lokal. Tapi di mana letak objek "hasil" setelah fungsi SumComplex dijalankan? Tidak ada tempat. Itu terletak di tumpukan, tetapi setelah fungsi dikembalikan, tumpukan dibuka dan semua objek lokal dari fungsi dimusnahkan. Ini pada akhirnya akan menghasilkan perilaku yang tidak terdefinisi, bahkan untuk tipe primitif. Untuk menghindari masalah kinerja, terkadang dimungkinkan untuk menggunakan pengoptimalan nilai pengembalian:

 Complex SumComplex(const Complex& a, const Complex& b) { return Complex(a.real + b.real, a.imaginar + b.imaginar); } Complex sum = SumComplex(a, b);

Untuk sebagian besar kompiler saat ini, jika baris kembali berisi konstruktor objek, kode akan dioptimalkan untuk menghindari semua penyalinan yang tidak perlu - konstruktor akan dieksekusi langsung pada objek "jumlah".

Kesalahan Umum #5: Menggunakan Referensi ke Sumber Daya yang Dihapus

Masalah C++ ini terjadi lebih sering daripada yang Anda kira, dan biasanya terlihat pada aplikasi multithread. Mari kita perhatikan kode berikut:

Utas 1:

 Connection& connection= connections.GetConnection(connectionId); // ...

Utas 2:

 connections.DeleteConnection(connectionId); // …

Utas 1:

 connection.send(data);

Dalam contoh ini, jika kedua utas menggunakan ID koneksi yang sama, ini akan menghasilkan perilaku yang tidak ditentukan. Kesalahan pelanggaran akses seringkali sangat sulit ditemukan.

Dalam kasus ini, ketika lebih dari satu utas mengakses sumber daya yang sama, sangat berisiko untuk menyimpan pointer atau referensi ke sumber daya, karena beberapa utas lain dapat menghapusnya. Jauh lebih aman menggunakan smart pointer dengan penghitungan referensi, misalnya shared_ptr dari Boost. Ini menggunakan operasi atom untuk menambah/mengurangi penghitung referensi, sehingga aman untuk thread.

Kesalahan Umum #6: Membiarkan Pengecualian Meninggalkan Destruktor

Tidak sering diperlukan untuk melempar pengecualian dari destruktor. Bahkan kemudian, ada cara yang lebih baik untuk melakukan itu. Namun, pengecualian sebagian besar tidak dibuang dari destruktor secara eksplisit. Itu bisa terjadi bahwa perintah sederhana untuk mencatat penghancuran suatu objek menyebabkan pelemparan pengecualian. Mari kita pertimbangkan kode berikut:

 class A { public: A(){} ~A() { writeToLog(); // could cause an exception to be thrown } }; // … try { A a1; A a2; } catch (std::exception& e) { std::cout << "exception caught"; }

Dalam kode di atas, jika pengecualian terjadi dua kali, seperti selama penghancuran kedua objek, pernyataan catch tidak pernah dieksekusi. Karena ada dua pengecualian secara paralel, tidak peduli apakah mereka bertipe sama atau berbeda, lingkungan runtime C++ tidak tahu bagaimana menanganinya dan memanggil fungsi penghentian yang mengakibatkan penghentian eksekusi program.

Jadi aturan umumnya adalah: jangan biarkan pengecualian meninggalkan destruktor. Sekalipun jelek, pengecualian potensial harus dilindungi seperti ini:

 try { writeToLog(); // could cause an exception to be thrown } catch (...) {}

Kesalahan Umum #7: Menggunakan “auto_ptr” (Salah)

Template auto_ptr tidak digunakan lagi dari C++ 11 karena sejumlah alasan. Ini masih banyak digunakan, karena sebagian besar proyek masih dikembangkan di C++98. Ini memiliki karakteristik tertentu yang mungkin tidak familiar bagi semua pengembang C++, dan dapat menyebabkan masalah serius bagi seseorang yang tidak berhati-hati. Menyalin objek auto_ptr akan mentransfer kepemilikan dari satu objek ke objek lainnya. Misalnya, kode berikut:

 auto_ptr<ClassA> a(new ClassA); // deprecated, please check the text auto_ptr<ClassA> b = a; a->SomeMethod(); // will result in access violation error

… akan mengakibatkan kesalahan pelanggaran akses. Hanya objek "b" yang akan berisi pointer ke objek Kelas A, sedangkan "a" akan kosong. Mencoba mengakses anggota kelas dari objek "a" akan menghasilkan kesalahan pelanggaran akses. Ada banyak cara salah menggunakan auto_ptr. Empat hal yang sangat penting untuk diingat tentang mereka adalah:

  1. Jangan pernah menggunakan auto_ptr di dalam wadah STL. Menyalin penampung akan meninggalkan penampung sumber dengan data yang tidak valid. Beberapa algoritma STL juga dapat menyebabkan pembatalan "auto_ptr".

  2. Jangan pernah menggunakan auto_ptr sebagai argumen fungsi karena ini akan menyebabkan penyalinan, dan membiarkan nilai yang diteruskan ke argumen tidak valid setelah pemanggilan fungsi.

  3. Jika auto_ptr digunakan untuk anggota data suatu kelas, pastikan untuk membuat salinan yang benar di dalam konstruktor salinan dan operator penugasan, atau larang operasi ini dengan menjadikannya pribadi.

  4. Kapan pun memungkinkan, gunakan beberapa penunjuk pintar modern lainnya alih-alih auto_ptr.

Kesalahan Umum #8: Menggunakan Iterator dan Referensi yang Tidak Valid

Adalah mungkin untuk menulis seluruh buku tentang hal ini. Setiap wadah STL memiliki beberapa kondisi khusus yang membuat iterator dan referensi tidak valid. Penting untuk mengetahui detail ini saat menggunakan operasi apa pun. Sama seperti masalah C++ sebelumnya, masalah ini juga bisa sangat sering terjadi di lingkungan multithreaded, sehingga diperlukan mekanisme sinkronisasi untuk menghindarinya. Mari kita lihat kode sekuensial berikut sebagai contoh:

 vector<string> v; v.push_back(“string1”); string& s1 = v[0]; // assign a reference to the 1st element vector<string>::iterator iter = v.begin(); // assign an iterator to the 1st element v.push_back(“string2”); cout << s1; // access to a reference of the 1st element cout << *iter; // access to an iterator of the 1st element

Dari sudut pandang logis, kode tersebut tampaknya baik-baik saja. Namun, menambahkan elemen kedua ke vektor dapat mengakibatkan realokasi memori vektor yang akan membuat iterator dan referensi menjadi tidak valid dan mengakibatkan kesalahan pelanggaran akses saat mencoba mengaksesnya dalam 2 baris terakhir.

Kesalahan Umum #9: Melewati Objek dengan Nilai

Kesalahan Umum #9

Anda mungkin tahu bahwa melewatkan objek berdasarkan nilai adalah ide yang buruk karena dampak kinerjanya. Banyak yang membiarkannya seperti itu untuk menghindari pengetikan karakter tambahan, atau mungkin berpikir untuk kembali lagi nanti untuk melakukan optimasi. Biasanya tidak pernah selesai, dan akibatnya mengarah ke kode dan kode berkinerja lebih rendah yang rentan terhadap perilaku tak terduga:

 class A { public: virtual std::string GetName() const {return "A";} … }; class B: public A { public: virtual std::string GetName() const {return "B";} ... }; void func1(A a) { std::string name = a.GetName(); ... } B b; func1(b);

Kode ini akan dikompilasi. Pemanggilan fungsi "func1" akan membuat salinan parsial dari objek "b", yaitu hanya akan menyalin bagian kelas "A" dari objek "b" ke objek "a" ("masalah mengiris"). Jadi di dalam fungsi itu juga akan memanggil metode dari kelas "A" alih-alih metode dari kelas "B" yang kemungkinan besar bukan yang diharapkan oleh seseorang yang memanggil fungsi.

Masalah serupa terjadi saat mencoba menangkap pengecualian. Sebagai contoh:

 class ExceptionA: public std::exception; class ExceptionB: public ExceptionA; try { func2(); // can throw an ExceptionB exception } catch (ExceptionA ex) { writeToLog(ex.GetDescription()); throw; }

Ketika eksepsi tipe ExceptionB dilempar dari fungsi “func2” maka akan ditangkap oleh blok catch, tetapi karena masalah slicing hanya sebagian dari kelas ExceptionA yang akan disalin, metode yang salah akan dipanggil dan juga dilempar ulang akan melempar pengecualian yang salah ke blok try-catch luar.

Untuk meringkas, selalu berikan objek dengan referensi, bukan berdasarkan nilai.

Kesalahan Umum #10: Menggunakan Konversi Buatan Pengguna oleh Konstruktor dan Operator Konversi

Bahkan konversi yang ditentukan pengguna terkadang sangat berguna, tetapi mereka dapat menghasilkan konversi yang tidak terduga yang sangat sulit ditemukan. Katakanlah seseorang membuat perpustakaan yang memiliki kelas string:

 class String { public: String(int n); String(const char *s); …. }

Metode pertama dimaksudkan untuk membuat string dengan panjang n, dan yang kedua dimaksudkan untuk membuat string yang berisi karakter yang diberikan. Tetapi masalahnya dimulai segera setelah Anda memiliki sesuatu seperti ini:

 String s1 = 123; String s2 = 'abc';

Pada contoh di atas, s1 akan menjadi string berukuran 123, bukan string yang berisi karakter “123”. Contoh kedua berisi tanda kutip tunggal alih-alih tanda kutip ganda (yang mungkin terjadi secara tidak sengaja) yang juga akan mengakibatkan pemanggilan konstruktor pertama dan membuat string dengan ukuran yang sangat besar. Ini adalah contoh yang sangat sederhana, dan masih banyak lagi kasus rumit yang menyebabkan kebingungan dan konversi tak terduga yang sangat sulit ditemukan. Ada 2 aturan umum tentang cara menghindari masalah seperti itu:

  1. Tetapkan konstruktor dengan kata kunci eksplisit untuk melarang konversi implisit.

  2. Alih-alih menggunakan operator konversi, gunakan metode percakapan eksplisit. Ini membutuhkan sedikit lebih banyak pengetikan, tetapi jauh lebih bersih untuk dibaca dan dapat membantu menghindari hasil yang tidak terduga.

Kesimpulan

C++ adalah bahasa yang kuat. Faktanya, banyak dari aplikasi yang Anda gunakan setiap hari di komputer Anda dan disukai mungkin dibuat menggunakan C++. Sebagai bahasa, C++ memberikan fleksibilitas yang luar biasa kepada pengembang, melalui beberapa fitur paling canggih yang terlihat dalam bahasa pemrograman berorientasi objek. Namun, fitur atau fleksibilitas canggih ini sering kali menjadi penyebab kebingungan dan frustrasi bagi banyak pengembang jika tidak digunakan secara bertanggung jawab. Semoga daftar ini akan membantu Anda memahami bagaimana beberapa kesalahan umum ini memengaruhi apa yang dapat Anda capai dengan C++.


Bacaan Lebih Lanjut di Blog Teknik Toptal:

  • Cara Mempelajari Bahasa C dan C++: Daftar Utama
  • C# vs. C++: Apa Inti?