Капани в C++ - УПП, КН, 2025-2026

warn Този документ ще бъде обновяван до края на курса. Добавки ще бъдат правени!

Тази страница описва кажи-речи всички странности и неприятности в C++, който ще трябва да знаете в този курс.

Постарал съм се да опиша всичко възможно най-подробно и просто. Текста е много, но всичко е написано максимлано просто и детаилно.

Лоши практики

Има някои типове изрази, които са коректен C++ код, но в този курс ще ги зачитаме за нещо некоректно.

Записване/връщане на булеви стойности

Когато запишем булева стойност в променлива, лоша практика е да направим нещо такова:

if (condition)
    myvar = true;
else
    myvar = false;

Стойността на condition е булева сама по себе си, добрата практика е директно да запишем стойността:

myvar = condition;

Като condition е какъвто и да е булев израз.

Еквивалентно имаме случая:

bool f() {
    if (condition)
        return true;
    else
        return false;
}

Коректното е просто да върнем condition, тя е булева:

bool f() {
    return condition;
}

Трябва да е очевидно, но изрази от типа на:

if (condition)
    myvar = false;
else
    myvar = true;
bool f() {
    if (condition)
        return false;
    else
        return true;
}

Спадат в същата категория. Резултата просто трябва да се обърне, булево:

myvar = !condition;
bool f() {
    return !condition;
}

Типове на аритметични операции

При събиране, изваждане, умножение и деление, винаги имплицитно се конвертира към най-големия тип! И върната стойност е от този тип. Например:

int x = 5;
double y = 6;
std::cout << (x * y); // double е по-голямо от int,
                      // x се конверира и резултата е double

Ефекта от това е, че деление на цели числа връща цяло число (което закръгля резултата). Например:

int x = 5, y = 8;
std::cout << (x / y);         // деление на цели числа, резултата се закръгля надолу
std::cout << (double)(x / y); // резултата от деление вече е закръглен,
                              // това преобразуване е твърде късно
std::cout << ((double)x / y); // едното вече е с плаваща запетая,
                              // резултата също става double и няма закръгляне

Undefined behaviour

C++ наследява идеята от C, че някои неща в стандарта на езика не са дефинирани. Тоест, какво ще се случи зависи от имплементацията на компилатора (това което превръща кода в .exe) и често се срещат разлики.

Приоритет на скоби спрямо аритметични оператори

В C++ не е дефиниран приоритета на скобите спрямо аритметични операции. Тоест, ако имаш дълъг аритметичен израз, не се определя дали първо ще се изпълнят всички неща в скобите и след това ще се изпълнят външните аритметични операции или обратно.

Този ефект е значим само при употреба на присвояващи оператори. Например:

int x = 9;
int y = (x += 5) * (x += 1) * (x -= 8);

Има два начина да се изчисли стойността на y:

  1. Първо изчисляваме всички изрази в скобите и след това изпълняваме умноженията.

    Тоест, първо правим x += 5, после x += 1 и след това x -= 8. Това променя стойността на x на 7 и стойността на y се свежда до x * x * x = 343

  2. Първо изчисляваме умноженията. Това означава, че първо "слагаме" скоби около умноженията; израза се изпълнява все едно е:
    ((x += 5) * (x += 1)) * (x -= 8)
    

    (Умножението е ляво асоциативно)

    Тоест, първо правим x += 5 и след това x += 1. Стойността на x е 15 и в големите скоби изчисляваме x * x = 225. Сега израза се свежда до:

    225 * (x -= 8)
    
    Изпълнява се x -= 8, x става 7, и y се свежда до 225 * 7 = 1575.

Първия метод се изпълнява от компилатора на Visual Studio, втория метод от gcc (много популярен C++ компилатор под Linux/MacOS).

Лениво оценяване на булеви изрази

Всички булеви изрази се оценяват (изпълняват) лениво (мързеливо). Това означава, че ако израза е в състояние, където не е нужно да проверим останалите условия, няма да ги проверим.

Двата главни примера се срещат при оператори "и" и "или".

a || b || c || d

Ако a е истина, тогава другите няма да се изпълнят, защото тяхната стойност не е от значение, общия израз ще си остане истина. Аналогично, ако a е лъжа, но b е истина, тогава c и d няма да се изпълнят.

a && b && c && d

Напълно еквивалентно работи и "или". Ако a е лъжа, тогава другите няма да се изпълнят, защото общия израз ще си остане лъжа.

Това е релевантно когато използваме присвояващи оператори в логически условия, например:

if (condition1 && ((myvar += 5) == 8))

и когато използваме функции:

if (condition1 && f())

И в двата случая, ако condition1 е лъжа, тогава присвояването и извикването на функцията няма да се изпълнят.

Дали това е желан ефект или не зависи от кода.

Връщане на масиви/матрици

Всяка променлива, която е декларирана или дефинирана (без помощта на динамича памет) съществува в определен обхват (scope) и всички обхвати в него.

Обхвати се определят с къдрави скоби. Тоест, нивото на къдрави скоби определя къде и кога една променлива е налична.

Например (да, в C++ можем да правим ей така обхвати):

int main() {
    {
        int a = 5;        // a се създава
        std::cout << 5;   // a се изкарва
    } // a се освобождава
    std::cout << a; // грешка, a е унищожено
    {
        std::cout << a; // грешка, a e унищожено
        int b = 6;      // b се създава
        std::cout << b; // b се изкарва
        {
            std::cout << b; // b се изкарва
        }
    } // b се освобождава
}

Като цяло, няма особено значение дали говорим за къдравите скоби на функция, на if, на цикъл, ...

Затова следното е грешно:

int* f() {
    int arr[5] = { 1, 2, 3, 4, 5 };
    return arr;
}

arr е адрес в паметта (индекс, позиция, ..., която посочва клетка в паметта), някакво число.

След като функцията свърши изпълнение и се върне стойността, ще сме "стигнали" къдравата скоба и arr ще бъде освободено. Може да зачитате, че стойностите се унищожават в този момент.

Обаче, адреса сам по себе си е число, което си се връща и ние можем да работим с него както си искаме.

Тоест, C++ няма да ни се скара, че използваме масив, въпреки че той е, на практика, унищожен!

Ако искаме действително да върнем масив от стойности, трябва да го създадем в обхвата в който функцията се извиква и да го подадем като аргумент. Например:

void f(int* arr) {
    /* ... */
    arr[0] = 28;
    /* ... */
}
int main() {
    /* ... */
    int arr[5];
    f(arr);
    /* ... */
}

Всичко това важи и при матрици.

Тип на матрици

int mat[7][3];

Какъв е типа на mat?

От една страна е int[7][3]. Но какъв е типа като указател, тоест когато няма въведени редове и колони?

Отговорите са два: int(*)[3] и int*. Ето защо.

Да си припомним как работят нормалните масиви. Когато декларираме следното:

int arr[14];

Тогава в паметта се използва (заделя) последователност от клетки, която побира 14 int стойности.

Също да си припомним, че arr е указател към първата от тези клетки.

На кратко, това означава че arr е адрес (индекс, позиция, ..., число което посочва в коя клетка на паметта се намираме).

Финално припомняме, че индексирането в масив е същото като отместване на адреса. Т.е. arr[6] е еквивалентно на *(arr + 6).

На кратко, ние преместваме нашия адрес (индекс в паметта) напред с 6 позиции и с * дереферираме (прочитаме стойността на подадения адрес).

Това означава, че всички елементи в масива е нужно да бъдат поставени последователно в паметта.

Двумерните масиви работят по същия начин!

В декларацията на mat, в паметта се заделя последователност от клетки в паметта, равна на размера на матрицата. Тоест 7 * 3 = 21 последователни позиции в паметта.

Тогава, mat[2][1] на какво е еквивалентно?

Отговорът е *(mat + 2 * 7 + 1).

Нашата таблица е разположена като последователност от клетки, където първо имаме клетките от първи ред, след това от втория ред и так. нат.

Например, при следната дефиниция на матрица:

int nums[3][2] = {
    { 8, 92 },
    { 20, 50 },
    { 7, 45 }
};

В паметта имаме разположени така:

8 92 20 50 7 45

Как извеждаме елемента nums[2][1]? Това е последната стойност в матрицата, 45.

Щом искаме трети ред (втори по индекс), значи трябва да пропуснем първите два реда. Това са общо 4 числа, как го сметнахме? Два реда * брой колони на ред = 2 * 2. Сега сме на последния ред, искаме втория елемент (първи по индекс), значи отиваме на следващия елемент.

Така показахме, че nums[2][1] е еквивалентно на *(nums + 2 * 2 + 1).

Това трябва да поражда въпрос: не можем ли да използваме nums като едномерен масив и да напишем nums[2 * 2 + 1]?

Можем! Затова е смислено да преобразуваме int[3][2] към int*.

Единствения проблем, е че употребата става неприятна. Например:

void f(int* matrix) {
    /* ... */
    matrix[2 * 2 + 1];
    /* ... */
}

Забелязваме, че ако C++ знае броя колони, би трябвало да може да направи сам тази сметка с пропускането на редове. От там се поражда int(*)[2] като смислено преобразуване на int[3][2].

Това е най-обикновен указател, обаче му даваме броя колони (и с това, подсказваме че ще работим с матрица). С такъв тип указател можем да използваме синтаксиса с двете квадратни скоби. Например:

void g(int (*matrix)[2]) {
    /* ... */
    matrix[2][1];
    /* ... */
}

Очевиден проблем, е че броя колони трябва да бъде константен (зададен в типа), ако искаме да ползваме двата чифта квадратни скоби.

Няма ли начин да ги използваме с неконстантен брой колони? Отговорът е не!

Изкушаващо е да използваме int**, но този тип обозначава нещо друго: указател към указател.

Ако имаме int** x, то тогава x[2][1] няма да бъде еквивалентно на *(x + 2 * колони + 1)! Ще бъде еквивалентно на *(*(x + 2) + 1)!

Това защо е така ще разберем когато учим динамична памет. Тогава, матриците ни няма да бъдат последователност от елементи, ами ще бъдат последователност от (указатели към) масиви.

Тоест всеки ред в матрицата ще може да се намира на напълно различни позиции в паметта.