C++ Kodlama Hataları

C++ Yazılım | | Barış Çelik<bariscelikweb@gmail.com>

Bjarne_Stroustrup1 C++ dilini Bell Laboratuvarları’nda ürettiğinde, günümüzdeki seviyesine gelebileceğini düşünmüş müdür? Bilmiyorum. Ancak, yıllar süren ve özellikle 2011 yılında bir atakla devam eden geliştirmeler sonucunda ortada modern bir programlama dili var.

Genelde programcılar C ya da C++ ile program yazmaya başladıktan sonra öğrenmeye itilir. Bu dilin itici bir gücü vardır. Çünkü hatasız bir program yazmak için birçok konsepti kavramak, muhtemelen fazlaca hata yapmak gerekir.

C++ ile işletim sistemi, gömülü yazılım, masaüstü grafik uygulamaları, oyun geliştirmek sadece bir kısmıdır. Dolayısıyla hem düşük seviye hem de yüksek seviye uygulamalar mümkündür. C++ bu nedenle orta-seviye olarak anılır.

Böyle kapsamlı bir dildeki kodlama hataları da oldukça kapsamlıdır. Bu yazıda belirtilecek performans, stabilite, bellek ve güvenlik kaynaklı hatalara karşı kodlama alışkanlığı geliştirmek için öneriler de olacak.

Verilen örneklerin kaynak kodlarını Github repo‘mda bulabilirsiniz.

Sayılarda Yanlış İşaret Niteleyicisi Kullanımı

Tamsayı tipleri işaretli olabilir. int primitif türü açık hâliyle signed int olarak ifâde edilebilir ve negatif sayıları da tutabilir. unsigned int türü negatif sayıları tutamaz. Bunu unutursanız ya da bilmezseniz, aşağıdakini yapmamanız için bir sebep yoktur.

for (unsigned int i = 5; i >= 0; i--)
    std::cout << i << std::endl;

Burada sonsuza dek i >= 0 şartı sağlandığı için bir sonsuz döngü oluşur. Çünkü unsigned bir sayıya verilen işaret, tipinin alabileceği maksimum sayı değerinden kendi değerinin çıkarılıp 1 eklenmesi anlamına gelir. Örneğin; unsigned i = -1 tanımlamasının yapılması durumunda, int tipinin -varsayılan olarak long inttir- alabileceği en küçük sayı değeri 0, en büyük sayı değeri 4294967295‘tir. unsigned i = -5 yaptığınızda ise sonuç 4294967291 olacaktır.

Kayan Noktalı Sayıların Karşılaştırılması

Bilgisayarların hesaplama sınırlarından biri kayan noktalı sayıların (floating point) yaklaşık değerlerle temsil edilmesidir. Ondalıklı bir sayının noktadan sonraki kısmı sonsuz karakter olamayacağından yuvarlanır.

#include <iostream>
#include <iomanip>

int main()
{
    float num = 5.7f;
    std::cout << std::setprecision(30) << num << std::endl;

    if (num == 5.7)
        std::cout << "is equal to " << 5.7 << std::endl;
    else
        std::cout << "is not equal to " << 5.7 << std::endl;
    

    return 0;
}

Program çıktısı:

5.69999980926513671875
is not equal to 5.70000000000000017763568394003

float tipindeki değerlerin karşılaştırılması için epsilon yöntemi kullanılmalıdır 2. std::numeric_limits<float>::epsilon() ile çalışılan makinenin kayan nokta hassasiyeti tespit edilir, böylece tolere edilebilecek eşitlik seviyesi sağlanır.

#include <iostream>
#include <iomanip>
#include <cmath>
#include <limits>

bool isEqual(float a, float b)
{
    return std::fabs(a - b) <= std::numeric_limits<float>::epsilon();
}

int main()
{
    float num = 5.7f;
    std::cout << std::setprecision(30) << num << std::endl;

    if (isEqual(num, 5.7))
        std::cout << "is equal to " << 5.7 << std::endl;
    else
        std::cout << "is not equal to " << 5.7 << std::endl;

    return 0;
}

Program çıktısı:

5.69999980926513671875
is equal to 5.70000000000000017763568394003

new, delete Yanlış Kullanımı

new ile türetilen nesneler, nesneye ihtiyaç kalmayınca kaynak kullanımını düşürmek için silinir. Hatta bilindiği üzere JAVA dilinde bu işi Garbage Collector denen yapı otomatik olarak üstlenir.

new ile türetilen nesneler, silineceği yere kadar bir exception ile karşılaşırsa silinmeden, ilgili adreste yaşamlarına devam edebilir, yani çöp olabilirler.

#include <iomanip>
#include <iostream>

class A {
public:
    A()
    {
        std::cout << "ctor" << std::endl;
    }
    ~A()
    {
        std::cout << "dtor" << std::endl;
    }
};

void throwedFunc() noexcept(false)
{
    throw "exception";
}

int main()
{
    A* a = new A();
    throwedFunc();
    delete a;
    return 0;
}

Program çıktısı:

ctor
terminate called after throwing an instance of 'char const*'

Program A sınıfının destructor methoduna ulaşamaz. Bu durum, basit try/catch bloğu kullanarak düzeltilebilir.

int main()
{
    A* a = new A();
    
    try {
        throwedFunc();
    } catch (const char *ex) {
        std::cout << ex << std::endl;
    }
    
    delete a;
    return 0;
}

Program çıktısı:

ctor
exception
dtor

Silinmiş Bir Kaynağın Kullanımı

En yaygın C++ kullanım hatalarından biridir. Özellikle multi-thread uygulamalarda sıklıkla rastlanır. Aynı kaynağa erişen iki thread‘in biri o kaynağı kullanılamaz hâle getirirse, diğer thread bunu bilmediği için ortaya silinmiş bir kaynağa erişmeye çalışan bir kod parçası ve sonucunda undefined behaviour çıkar.

Kaynak, aşağıdaki gibi programın herhangi bir yerinde silinmiş de olabilir.

#include <iomanip>
#include <iostream>

using namespace std;

int main()
{
    int* a = new int(10);
    std::cout << "before delete: " << *a << std::endl;
    delete a;
    std::cout << "after delete: " << *a << std::endl;
    
    return 0;
}

Program çıktısı:

before delete: 10
after delete: 0

Bu sorunun çözümü için kaynağın varlık kontrolü yapılmalıdır. Bu nedenle delete sonrasında kaynak nullptr olarak tanımlanmalı ve devamında nullptr ile karşılaştırılmalıdır.

#include <iomanip>
#include <iostream>

using namespace std;

int main()
{
    int* a = new int(10);
    std::cout << "before delete: " << *a << std::endl;
    delete a;

    a = nullptr;

    if(a != nullptr)
        std::cout << "after delete: " << *a << std::endl;

    return 0;
}

Program çıktısı:

before delete: 10

Referans Olarak Yerel Bir Nesne Döndürmek

Koddaki her değişkenin bir yaşam döngüsü vardır. Bu döngüde nesne doğar, yaşar ve ölür/öldürülür. Değişkenin çalıştığı bu alana, değişkenin kapsamı (scope) denir3.

Yaygın yapılan bu hatanın bir önceki Silinmiş Bir Kaynağın Kullanımı hatasından tek farkı, elle değil otomatik olarak, yaşam döngüsü bittiği için silinen kaynakların çağırılmasıdır.

#include <iomanip>
#include <iostream>

int *getNumber()
{
    int num = 10;
    return &num;
}

int main()
{
    int number = *getNumber();
    return 0;
}

Program çıktısı:

The program has unexpectedly finished.

Stack Overflow

Yığın (stack) değişkenleri sınırlı sayıda adres uzayından oluşur. Boyutlar platformlardan platforma değişse de boyutları aşan stack kullanımı hatalıdır. Stack alanı, bir programın fonksiyon, alt fonksiyonlarında kullanılan geçici değişkenler için uygundur. Aynı zamanda fonksiyonun kendinin işletilmesinde kullanılan işaretçiler de stack üzerinde tutulur. İki hatalı kullanım örneği aşağıdadır.

Büyük stack değişkeni:

void func()
{
    int stackVar[1000000];
}

Sonsuz döngüde özyinelemeli (recursive) fonksiyon:

void func()
{
    func();
}

İki programın çıktısı da şu şekildedir:

The program has unexpectedly finished.

Struct Boyutu Hesaplama

struct kullanımında yaygın hatalardan biri sizeof operatörü kullanırken yapılır. Çünkü; struct üyelerinin sıralaması değiştiğinde boyutları da değişir. Aslında bu bir hata olmayabilir, sizeof operatörünü kullanırken yaşanan bu değişken durumu anlayabilmek için bu sorunun kökenini bilmek gereklidir.

Sorun, hizalamayla ilgili eklenen baytlardan kaynaklanmaktadır 4. Program içerisinde kullanılan veriler bilindiği gibi bellekte saklanır. Derleyiciler ya da mimariler, bellekteki verileri okurken çeşitli performans tabanlı nedenlerden ötürü veriyi hizalı saklamayı sağlarlar. Örneğin; 32 bit bir uygulama için bellekteki paketleme (pack) işlemi 4 bayt sınırında olabilir.

#include <iostream>

typedef struct {
    int16_t num1;
    int16_t num2;
    int16_t num3;
    int16_t num4;
    int32_t num5;
} st1;

typedef struct {
    int16_t num1;
    int16_t num2;
    int16_t num3;
    int32_t num5;
    int16_t num4;
} st2;

int main()
{
    std::cout << "(short int) : " << sizeof(int16_t) << " byte" << std::endl;
    std::cout << "(int) : " << sizeof(int32_t) << " byte" << std::endl;
    std::cout << "(st1) : " << sizeof(st1) << " byte" << std::endl;
    std::cout << "(st2) : " << sizeof(st2) << " byte" << std::endl;

    return 0;
}

Program çıktısı:

(short int) : 2 byte
(int) : 4 byte
(st1) : 12 byte
(st2) : 16 byte

Görüldüğü gibi st1 ve st2‘nin elemanları birbirinin aynısıdır, sadece tanımlama sıralamasında değişiklik yapılmıştır. Sıralama değişikliği sonucunda st1 12 byte, st2 16 byte yer tutmaktadır. Bunun nedeni 32-bit derleyicinin belleği 4 baytlık paketlere bölmesi ve bellek alanını hizalamasıdır.

╔════╤════╤════╤════╗           ╔════╤════╤════╤════╗
║ B1 │ B2 │ B3 │ B4 ║           ║ B1 │ B2 │ B3 │ B4 ║
╠════╧════╪════╧════╣           ╠════╧════╪════╧════╣
║  num1   │  num2   ║           ║  num1   │  num2   ║
╟─────────┼─────────╢           ╟─────────┼─────────╢
║  num3   │  num4   ║           ║  num3   │  ....   ║
╟─────────┴─────────╢           ╟─────────┴─────────╢
║       num5        ║           ║       num5        ║
╚═══════════════════╝           ╟─────────┬─────────╢
         st1                    ║ num4    │ ....    ║
                                ╚═════════╧═════════╝
                                         st2         

Referans yerine değer göndermek

Fonksiyonlara adres olarak geçilmeyen her parametre kopyalanır. Aynı şekilde fonksiyonların dönüşü de kopyalanır. Dolayısıyla bir objede ya da değişkende kalıcı değişiklik yapacak bir fonksiyon yazılmak istenirse, doğru kaynak kullanımı için değişkeni referans göstererek yapılmalıdır.

#include <iostream>

typedef struct
{
    std::string color;
} CAR;

CAR setRed(CAR c)
{
    c.color = "red";
    return c;
}

int main()
{
    CAR myCar = {"blue"};
    myCar = setRed(myCar);
    std::cout << "myCar color : " << myCar.color << std::endl;
    
    return 0;
}

Aynı iş referansla yapıldığında:

#include <iostream>

typedef struct
{
    std::string color;
} CAR;

void setRed(CAR &c)
{
    c.color = "red";
}

int main()
{
    CAR myCar = {"blue"};
    setRed(myCar);
    std::cout << "myCar color : " << myCar.color << std::endl;
    
    return 0;
}