Geliştiricilerin Yaptığı En Yaygın 10 C++ Hatası
Yayınlanan: 2022-03-11Bir C++ geliştiricisinin karşılaşabileceği birçok tuzak vardır. Bu, kaliteli programlamayı çok zor ve bakımı çok pahalı hale getirebilir. C# ve Java gibi benzer dillerde dil sözdizimini öğrenmek ve iyi programlama becerilerine sahip olmak, C++'ın tüm potansiyelini kullanmak için yeterli değildir. C++'da hatalardan kaçınmak için yılların deneyimi ve büyük bir disiplin gerekir. Bu yazıda, C++ geliştirme konusunda yeterince dikkatli olmayan her seviyedeki geliştiriciler tarafından yapılan bazı yaygın hatalara göz atacağız.
Yaygın Hata 1: “Yeni” ve “Sil” Çiftlerini Yanlış Kullanma
Ne kadar denersek deneyelim, dinamik olarak ayrılmış tüm belleği boşaltmak çok zordur. Bunu yapabilsek bile, genellikle istisnalardan güvenli değildir. Basit bir örneğe bakalım:
void SomeMethod() { ClassA *a = new ClassA; SomeOtherMethod(); // it can throw an exception delete a; }
Bir istisna atılırsa, “a” nesnesi asla silinmez. Aşağıdaki örnek, bunu yapmanın daha güvenli ve daha kısa bir yolunu göstermektedir. C++ 11'de kullanımdan kaldırılan auto_ptr'yi kullanır, ancak eski standart hala yaygın olarak kullanılmaktadır. Mümkünse Boost'tan C++11 unique_ptr veyascoped_ptr ile değiştirilebilir.
void SomeMethod() { std::auto_ptr<ClassA> a(new ClassA); // deprecated, please check the text SomeOtherMethod(); // it can throw an exception }
Ne olursa olsun, “a” nesnesi oluşturulduktan sonra program yürütme kapsamdan çıkar çıkmaz silinecektir.
Ancak bu, bu C++ sorununun yalnızca en basit örneğiydi. Silmenin başka bir yerde, belki bir dış fonksiyonda veya başka bir iş parçacığında yapılması gerektiğine dair birçok örnek vardır. Bu nedenle çiftler halinde yeni/sil kullanımından tamamen kaçınılmalı ve bunun yerine uygun akıllı işaretçiler kullanılmalıdır.
Yaygın Hata #2: Unutulmuş Sanal Yıkıcı
Bu, içlerinde dinamik bellek ayrılmışsa, türetilmiş sınıfların içinde bellek sızıntılarına yol açan en yaygın hatalardan biridir. Sanal yıkıcının istenmediği, yani bir sınıfın kalıtım için tasarlanmadığı ve boyutu ile performansının çok önemli olduğu bazı durumlar vardır. Sanal yıkıcı veya diğer herhangi bir sanal işlev, bir sınıf yapısı içinde ek veriler sağlar, yani sınıfın herhangi bir örneğinin boyutunu büyüten sanal bir tabloya bir işaretçi.
Bununla birlikte, çoğu durumda sınıflar, başlangıçta amaçlanmasa bile miras alınabilir. Bu nedenle, bir sınıf bildirildiğinde sanal bir yıkıcı eklemek çok iyi bir uygulamadır. Aksi takdirde, performans nedeniyle bir sınıfın sanal işlevler içermemesi gerekiyorsa, bir sınıf bildirim dosyasına sınıfın miras alınmaması gerektiğini belirten bir açıklama koymak iyi bir uygulamadır. Bu sorunu önlemek için en iyi seçeneklerden biri, sınıf oluşturma sırasında sanal yıkıcı oluşturmayı destekleyen bir IDE kullanmaktır.
Konuya ek bir nokta, standart kütüphanedeki sınıflar/şablonlardır. Miras için tasarlanmamışlardır ve sanal bir yıkıcıları yoktur. Örneğin, std::string'den genel olarak miras alan yeni bir geliştirilmiş string sınıfı yaratırsak, birinin onu bir işaretçi veya std::string referansıyla yanlış kullanma ve bir bellek sızıntısına neden olma olasılığı vardır.
class MyString : public std::string { ~MyString() { // ... } }; int main() { std::string *s = new MyString(); delete s; // May not invoke the destructor defined in MyString }
Bu tür C++ sorunlarından kaçınmak için, standart kitaplıktan bir sınıfı/şablonu yeniden kullanmanın daha güvenli bir yolu, özel miras veya oluşturma kullanmaktır.
Yaygın Hata #3: Bir Diziyi “delete” İle Silme veya Akıllı İşaretçi Kullanma
Dinamik boyutta geçici diziler oluşturmak genellikle gereklidir. Artık gerekli olmadıklarında, ayrılan belleği boşaltmak önemlidir. Buradaki en büyük sorun, C++'ın çok kolay unutulan [] parantezli özel silme operatörü gerektirmesidir. delete[] operatörü sadece bir dizi için ayrılan belleği silmekle kalmaz, aynı zamanda bir dizideki tüm nesnelerin yıkıcılarını da çağırır. Ayrıca, bu türler için yıkıcı olmamasına rağmen, ilkel türler için [] parantezleri olmadan silme operatörünü kullanmak yanlıştır. Her derleyici için bir dizi işaretçisinin dizinin ilk öğesini işaret edeceğinin garantisi yoktur, bu nedenle [] parantezleri olmadan silme kullanmak tanımsız davranışa da neden olabilir.
auto_ptr, unique_ptr<T>, shared_ptr gibi akıllı işaretçileri dizilerle kullanmak da yanlıştır. Böyle bir akıllı işaretçi bir kapsamdan çıktığında, [] köşeli parantezleri olmayan bir silme operatörünü çağırır ve bu da yukarıda açıklanan aynı sorunlarla sonuçlanır. Bir dizi için bir akıllı işaretçi kullanılması gerekiyorsa, Boost'tan ya da bir unique_ptr<T[]> uzmanlığından kapsam_array veya paylaşılan_array kullanmak mümkündür.
Çoğunlukla diziler için geçerli olan referans sayımının işlevselliği gerekli değilse, bunun yerine STL vektörlerini kullanmak en şık yoldur. Yalnızca belleğin serbest bırakılmasıyla ilgilenmezler, aynı zamanda ek işlevler de sunarlar.
Yaygın Hata #4: Yerel Bir Nesneyi Referansa Göre Döndürme
Bu çoğunlukla yeni başlayanlar için bir hatadır, ancak bu sorundan muzdarip çok sayıda eski kod olduğundan bahsetmeye değer. Bir programcının gereksiz kopyalamadan kaçınarak bir çeşit optimizasyon yapmak istediği aşağıdaki koda bakalım:
Complex& SumComplex(const Complex& a, const Complex& b) { Complex result; ….. return result; } Complex& sum = SumComplex(a, b);
“Sum” nesnesi şimdi yerel “sonucu” nesnesine işaret edecektir. Ancak, SumComplex işlevi yürütüldükten sonra “sonuç” nesnesi nerede bulunur? Hiçbir yerde. Yığın üzerinde bulunuyordu, ancak işlev döndürüldükten sonra yığının paketi açıldı ve işlevdeki tüm yerel nesneler yok edildi. Bu, sonunda ilkel türler için bile tanımsız bir davranışla sonuçlanacaktır. Performans sorunlarından kaçınmak için bazen dönüş değeri optimizasyonunu kullanmak mümkündür:
Complex SumComplex(const Complex& a, const Complex& b) { return Complex(a.real + b.real, a.imaginar + b.imaginar); } Complex sum = SumComplex(a, b);
Günümüz derleyicilerinin çoğu için, eğer bir dönüş satırı bir nesnenin yapıcısını içeriyorsa, gereksiz tüm kopyalamaları önlemek için kod optimize edilecektir - yapıcı doğrudan "toplam" nesnesi üzerinde yürütülecektir.
Yaygın Hata #5: Silinmiş Bir Kaynağa Referans Kullanmak
Bu C++ sorunları düşündüğünüzden daha sık meydana gelir ve genellikle çok iş parçacıklı uygulamalarda görülür. Aşağıdaki kodu ele alalım:
Konu 1:
Connection& connection= connections.GetConnection(connectionId); // ...
Konu 2:
connections.DeleteConnection(connectionId); // …
Konu 1:
connection.send(data);
Bu örnekte, her iki iş parçacığı aynı bağlantı kimliğini kullanırsa, bu tanımsız davranışa neden olur. Erişim ihlali hatalarını bulmak genellikle çok zordur.
Bu durumlarda, aynı kaynağa birden fazla iş parçacığı eriştiğinde, başka bir iş parçacığı onu silebilir, çünkü kaynaklara işaretçileri veya referansları tutmak çok risklidir. Referans sayımı ile akıllı işaretçiler kullanmak çok daha güvenlidir, örneğin Boost'tan Shared_ptr. Bir referans sayacını artırmak/azaltmak için atomik işlemleri kullanır, bu nedenle iş parçacığı güvenlidir.
Yaygın Hata #6: İstisnaların Yıkıcılardan Ayrılmasına İzin Vermek
Bir yıkıcıdan bir istisna atmak sıklıkla gerekli değildir. O zaman bile, bunu yapmanın daha iyi bir yolu var. Ancak, istisnalar çoğunlukla yıkıcılardan açıkça atılmaz. Bir nesnenin imhasını günlüğe kaydetmek için basit bir komut, bir istisna atılmasına neden olabilir. Aşağıdaki kodu ele alalım:
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"; }
Yukarıdaki kodda, örneğin her iki nesnenin de yok edilmesi sırasında olduğu gibi iki kez istisna oluşursa, catch ifadesi hiçbir zaman yürütülmez. Paralel olarak iki istisna olduğu için, aynı türden veya farklı türden olmalarına bakılmaksızın, C++ çalışma zamanı ortamı bununla nasıl başa çıkacağını bilmez ve bir programın yürütülmesinin sonlandırılmasına neden olan bir sonlandırma işlevi çağırır.

Dolayısıyla genel kural şudur: istisnaların yıkıcıları terk etmesine asla izin vermeyin. Çirkin olsa bile, potansiyel istisna şu şekilde korunmalıdır:
try { writeToLog(); // could cause an exception to be thrown } catch (...) {}
Yaygın Hata #7: “auto_ptr” Kullanımı (Yanlış)
auto_ptr şablonu, birkaç nedenden dolayı C++11'den kaldırılmıştır. Çoğu proje hala C++98'de geliştirildiği için hala yaygın olarak kullanılmaktadır. Muhtemelen tüm C++ geliştiricilerinin aşina olmadığı ve dikkatli olmayan biri için ciddi sorunlara neden olabilecek belirli bir özelliği vardır. auto_ptr nesnesinin kopyalanması, sahipliği bir nesneden diğerine aktaracaktır. Örneğin, aşağıdaki kod:
auto_ptr<ClassA> a(new ClassA); // deprecated, please check the text auto_ptr<ClassA> b = a; a->SomeMethod(); // will result in access violation error
… erişim ihlali hatasına neden olur. Yalnızca "b" nesnesi, A Sınıfı nesnesine bir işaretçi içerecek, "a" ise boş olacaktır. “a” nesnesinin bir sınıf üyesine erişmeye çalışmak, erişim ihlali hatasıyla sonuçlanacaktır. auto_ptr'yi yanlış kullanmanın birçok yolu vardır. Onlar hakkında hatırlanması gereken çok kritik dört şey:
STL kapsayıcılarında asla auto_ptr kullanmayın. Kapsayıcıların kopyalanması, kaynak kapsayıcıların geçersiz verilerle kalmasına neden olur. Bazı STL algoritmaları da “auto_ptr”lerin geçersiz kılınmasına neden olabilir.
auto_ptr'yi asla bir işlev argümanı olarak kullanmayın, çünkü bu kopyalamaya yol açar ve argümana iletilen değeri işlev çağrısından sonra geçersiz bırakır.
Bir sınıfın veri üyeleri için auto_ptr kullanılıyorsa, bir kopya oluşturucu ve bir atama operatörü içinde uygun bir kopya oluşturduğunuzdan emin olun veya bunları özel yaparak bu işlemlere izin vermeyin.
Mümkün olduğunda auto_ptr yerine başka bir modern akıllı işaretçi kullanın.
Yaygın Hata #8: Geçersizleştirilmiş Yineleyicileri ve Referansları Kullanma
Bu konuda koca bir kitap yazmak mümkün olabilir. Her STL kapsayıcısının, yineleyicileri ve referansları geçersiz kıldığı bazı özel koşulları vardır. Herhangi bir işlemi kullanırken bu detayların farkında olmak önemlidir. Önceki C++ probleminde olduğu gibi, bu problem de çok iş parçacıklı ortamlarda çok sık meydana gelebilir, bu yüzden bunu önlemek için senkronizasyon mekanizmalarını kullanmak gerekir. Örnek olarak aşağıdaki sıralı kodu görelim:
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
Mantıksal bir bakış açısından kod tamamen iyi görünüyor. Bununla birlikte, vektöre ikinci öğenin eklenmesi, vektörün belleğinin yeniden tahsis edilmesine neden olabilir, bu da hem yineleyiciyi hem de referansı geçersiz kılacak ve son 2 satırda bunlara erişmeye çalışırken erişim ihlali hatasıyla sonuçlanacaktır.
Yaygın Hata #9: Bir Nesneyi Değerine Göre İletmek
Performans etkisi nedeniyle nesneleri değerlerine göre aktarmanın kötü bir fikir olduğunu muhtemelen biliyorsunuzdur. Birçoğu, fazladan karakter yazmaktan kaçınmak için böyle bırakır veya muhtemelen daha sonra optimizasyonu yapmak için geri dönmeyi düşünür. Genellikle hiçbir zaman yapılmaz ve sonuç olarak daha düşük performanslı kodlara ve beklenmeyen davranışlara eğilimli kodlara yol açar:
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);
Bu kod derlenecek. “func1” fonksiyonunun çağrılması “b” nesnesinin kısmi bir kopyasını yaratacaktır, yani “b” nesnesinin sadece “A” sınıfının bir kısmını “a” nesnesine (“dilimleme problemi”) kopyalayacaktır. Bu nedenle, işlevin içinde, büyük olasılıkla işlevi çağıran birinin beklediği gibi olmayan “B” sınıfından bir yöntem yerine “A” sınıfından bir yöntem çağıracaktır.
İstisnaları yakalamaya çalışırken benzer sorunlar ortaya çıkar. Örneğin:
class ExceptionA: public std::exception; class ExceptionB: public ExceptionA; try { func2(); // can throw an ExceptionB exception } catch (ExceptionA ex) { writeToLog(ex.GetDescription()); throw; }
“Func2” işlevinden ExceptionB türünde bir istisna atıldığında, bu, catch bloğu tarafından yakalanacaktır, ancak dilimleme sorunu nedeniyle, ExceptionA sınıfının yalnızca bir kısmı kopyalanacak, yanlış yöntem çağrılacak ve ayrıca yeniden atılacak. bir dış try-catch bloğuna yanlış bir istisna atar.
Özetlemek gerekirse, nesneleri her zaman değere göre değil, referansa göre iletin.
Yaygın Hata #10: Yapıcı ve Dönüştürme Operatörleri Tarafından Kullanıcı Tanımlı Dönüşümlerin Kullanılması
Kullanıcı tanımlı dönüşümler bile bazen çok faydalıdır, ancak bulunması çok zor olan öngörülemeyen dönüşümlere yol açabilirler. Diyelim ki birisi string sınıfına sahip bir kitaplık yarattı:
class String { public: String(int n); String(const char *s); …. }
İlk yöntem, n uzunluğunda bir dize oluşturmaya yöneliktir ve ikincisi, verilen karakterleri içeren bir dize oluşturmaya yöneliktir. Ancak, böyle bir şeye sahip olduğunuz anda sorun başlar:
String s1 = 123; String s2 = 'abc';
Yukarıdaki örnekte, s1, “123” karakterlerini içeren bir dize değil, 123 boyutunda bir dize olacaktır. İkinci örnek, çift tırnak yerine tek tırnak içerir (ki bu tesadüfen olabilir), bu da ilk kurucunun çağrılmasına ve çok büyük boyutlu bir dize oluşturulmasına neden olur. Bunlar gerçekten basit örnekler ve bulması çok zor olan karışıklığa ve öngörülemeyen dönüşümlere yol açan daha birçok karmaşık durum var. Bu tür sorunlardan nasıl kaçınılacağına dair 2 genel kural vardır:
Örtülü dönüşümlere izin vermemek için açık anahtar kelimeye sahip bir oluşturucu tanımlayın.
Dönüştürme operatörlerini kullanmak yerine açık konuşma yöntemlerini kullanın. Biraz daha fazla yazma gerektiriyor, ancak okuması çok daha temiz ve öngörülemeyen sonuçların önlenmesine yardımcı olabilir.
Çözüm
C++ güçlü bir dildir. Aslında, bilgisayarınızda her gün kullandığınız ve sevdiğiniz uygulamaların çoğu muhtemelen C++ kullanılarak oluşturulmuştur. Bir dil olarak C++, nesne yönelimli programlama dillerinde görülen en karmaşık özelliklerden bazıları aracılığıyla geliştiriciye muazzam miktarda esneklik sağlar. Bununla birlikte, bu karmaşık özellikler veya esneklikler, sorumlu bir şekilde kullanılmazsa çoğu geliştirici için kafa karışıklığına ve hayal kırıklığına neden olabilir. Umarım bu liste, bu yaygın hatalardan bazılarının C++ ile elde edebileceklerinizi nasıl etkilediğini anlamanıza yardımcı olur.
Toptal Mühendislik Blogunda Daha Fazla Okuma:
- C ve C++ Dilleri Nasıl Öğrenilir: Nihai Liste
- C# ve C++: Çekirdekte Neler Var?