Капани в C++ - УПП, КН, 2025-2026
Contents
Тази страница описва кажи-речи всички странности и неприятности в 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:
- Първо изчисляваме всички изрази в скобите и след това изпълняваме умноженията.
Тоест, първо правим
x += 5, послеx += 1и след товаx -= 8. Това променя стойността наxна 7 и стойността наyсе свежда доx * x * x = 343 - Първо изчисляваме умноженията. Това означава, че първо "слагаме" скоби около умноженията; израза се изпълнява все едно е:
((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)!
Това защо е така ще разберем когато учим динамична памет. Тогава, матриците ни няма да бъдат последователност от елементи, ами ще бъдат последователност от (указатели към) масиви.
Тоест всеки ред в матрицата ще може да се намира на напълно различни позиции в паметта.
Този документ ще бъде обновяван до края на курса.
Добавки ще бъдат правени!