Грешки на кои треба да внимавате

Во ова предавање ќе зборуваме за неколку грешки кои програмерите, а особено почетниците, често ги прават за време на пишување на програми во програмскиот јазик C++. Текстот се фокусира на грешки кои се појавуваат при извршување на програмите - а не печатни и/или синтаксни грешки кои се прават за време на пишување на код во некоја развојна околина - таквите грешки се пријавуваат од страна на компајлерот за време на преведување на програмата.

Ова предавање е резултат на мноогу грешки кои авторот "успеал" самиот да ги направи, ги забележал кај студенти кои го изучуваат C++ како дел од своите студии по информатика и/или ги забележал кај ученици кои учествуваат на натпреварите по информатика за основно и средно образование. Прочитајте ги сите точки и обидете се да сфатите каде, како и зошто се направени пропусти - запаметете, секогаш е подобро да се учи од туѓите грешки.

Големина на низа

Една од најчестите грешки кои почетниците ги прават е, секако, креирањето на недоволно голема низа - низа која нема доволно голем простор за чување на сите потребни елементи. Запаметете дека при декларирањето на една низа, по името на променливата, како аргумент се задава "големината на низата", а не индексот на последниот елемент во неа. На пример, со "int arr[10]", се креира низа од 10 елементи - arr[0] до arr[9]. Едноставно, недозволено е да се пристапува до елементот arr[10].

Извадок 21.1

int arr[100];

for (int i=0; i<=100; i++)
{
      arr[i] = i;                 //GRESHKA, nema arr[100]
}

Најдобар начин да ги избегнете овие грешки е да користите вектори (паметни низи) или да креирате низи со големина (малку) поголема од потребното: секогаш ќе има доволно меморија за еден или два дополнителни елементи.

Низи од знаци

Оваа грешка е поврзана со претходната точка - големина на низа. При користењето на низи од знаци во C++, често се смета дека за текст чија големина е најмногу N треба да се декларира низа од знаци со големина N, т.е. char[N] - како што зборувавме во претходната точка. И ова мислење е погрешно - во низата мора да постои знак кој ќе означи каде завршува текстот - т.н. "null character" ('\0'). Конкретно, за текст со големина најмногу N, потребно е да креираме низа од знаци со големина N+1.

Програма 21.1

#include <cstring>
#include <iostream>
using namespace std;

int main()
{
      char s1[6], s2[2];
      strcpy(s2, "0 ");            //s2="0 "
      strcpy(s1, "POENI");         //s1="POENI"
      
      cout << s2 << endl;          // na nekoi kompjuteri ovaa programa
                                   // kje ispechati "0 POENI" namesto "0 "
      return 0;
}

Програмата дадена погоре, барем на некои компјутери (како оној на авторот), ќе испечати "0 POENI", иако се обидуваме да ја испечатиме само вредноста на s2. Тоа е така бидејќи во s2 нема место за чување на "null character" знакот, па програмата не може да одреди каде е крајот на s2. За време на извршување на оваа програма, во меморијата, низата од знаци s1 се наоѓа веднаш по s2, па програмата печати се додека не стигне до s1[5] (null character знакот од низата s1).

Избегнете ги овие грешки така што ќе креирате доволно голема низа или ќе користите string (наместо низа од char елементи).

Неиницијализирани променливи

Сите променливи мора да се иницијализираат пред да бидат користени од страна на програмата. Едноставно, кога креираме една променлива во C++, таа НЕ добива вредност автоматски - доделувањето на вредност мораме самите да го направиме. Доколку одредена променлива нема почетна вредност, а истата се користи во подоцнежни пресметки, непредвидливо е однесувањето на целата програма - извршување на истата програма, со исти влезни податоци, на неколку различни компјутери може да произведе неколку различни резултати.

Програма 21.2

#include <iostream>
using namespace std;

int main()
{
      int x = 10;                       //inicijalizacija - ne zaboravajte!
      
      /*
      int n;                            //dozvoleno - dodeluvanjeto na vrednost
      cin >> n;                         //e vednash vo sledniot red
      */
      
      int arr[10];
      
      for (int i=0; i<10; i++)
            arr[i] = 0;                 //vazhi i za nizi!!!
      
      int sum = 0;                      //inicijalizacija - ne zaboravajte!
      
      for (int i=0; i<10; i++)
            sum += arr[i];              //sum = 0 (sigurno!)
      
      cout << sum << endl;              //pechati '0'
      return 0;
}

Променливите кои се декларирани со некои од основните податочни типови може едноставно да се иницијализираат со доделување на вредност во истиот ред во кој се декларираат. Што се однесува до низите, постојат повеќе начини на кои тие може да се иницијализираат:

  • при самото креирање на низата - на пример, int arr[10] = {0};
  • директно, со еден или повеќе for циклуси (види претходна програма)
  • со алгоритамот fill, од STL - #include <algorithm>
  • со функцијата memset(niza, vrednost, kolkuBajti) - #include <cstring>

Доколку сте во можност (ја знаете вредноста која треба да се додели), најдобар начин да ги иницијализирате елементите на некоја низа е при нејзиното креирање. Но, понекогаш не ја знаеме однапред точната вредност која треба да им се додели на елементите на една низа; или сакаме на елементите повеќе пати да им поставиме вредност (на пример, доколку користиме една низа за повеќе различни пресметки).

Доколку работиме со еднодимензионални низи, најдобар начин да им доделиме вредност за време на извршување на програмата е алгоритамот fill - за кој зборувавме во претходното предавање. Доколку работиме со повеќедимензионални низи, користењето на fill е малку посложено - потребно е да ги знаеме локациите на првиот и последниот елемент во низата. Во тој случај, подобро е да ја иницијализираме повеќедимензионалната низа на друг начин.

Бидејќи досега не зборувавме за memset(niza, vrednost, kolkuBajti), следнава програма го демонстрира начинот на нејзино користење:

Програма 21.3

#include <iostream>
#include <cstring>
using namespace std;

int main()
{
      //ednodimenzionalna niza
      int arr[5];
      memset(arr, 0, sizeof(arr));        //ok, arr={0,0,0,0,0}
      
      int mat[2][2];
      memset(mat, -1, sizeof(mat));       //ok, mat={{-1,-1}, {-1,-1}}
      
      cout << arr[0] << endl;             //pechati '0'
      return 0;
}

Запаметете дека memset(niza, vrednost, kolkuBajti) ја поставува вредноста vrednost на секој бајт меморија во низата (а не на секој елемент!). Користете ја функцијата memset(niza, vrednost, kolkuBajti) единствено за доделување на вредностите 0 и -1. Ништо друго!

Да ги објасниме вредностите 0 и -1. Доколку вредноста е 0 и ја исполниме цела меморија со 0, сите int вредности ќе бидат 0 (нормално, бидејќи сите битови се 0). Слично, можеме да ја поставиме и вредноста -1. Кај модерните компјутери, целите броеви се чуваат во т.н. формат на двоен комплемент. Вредностите на позитивните цели броеви одговараат на нивната вистинска бинарна вредност (нормално, доколку опсегот на податочниот тип е доволно голем). Негативните цели броеви, од друга страна, се складираат на следниот начин - негативниот број -N се чува како негација на сите битови од позитивниот број N-1. На пример, негативниот број -1 се чува како негација од позитивниот број N-1=1-1=0. За 8 битен податок (на пример, char), негација од 0 (00000000) е 11111111. Поради тоа, можеме да ја искористиме функцијата memset(niza, vrednost, kolkuBajti) за, бајт по бајт, да ја поставиме вредноста -1. Ова ќе работи за сите целобројни податочни типови - short, int, long и long long.

Споредба. Доделување на вредност

Многу почетници ги мешаат операторите '=' (доделување на вредност) и '==' (споредба за еднаквост). Бидејќи, во C/C++, можеме да користиме цели броеви како bool изрази (на пример, во управувачките структури if и while), кај повеќето програми компајлерот нема да не предупреди дека сме направиле грешка.

Програма 21.4

#include <iostream>
using namespace std;

int main()
{
      int x = 3;
      
      if (x = 1)                         //sega, x=1 i if uslovot e ispolnet
            cout << "x=1" << endl;       //pechati 'x=1'
      
      return 0;
}

Познавајте ја разликата помеѓу операторите '=' и '=='. Операторот '=' служи за доделување на вредност, додека '==' служи за споредба на две вредности.

Опсег

Основните податочни типови (int, long, long long, float, double, ...) немаат доволно голем опсег за да ги претстават сите цели и/или реални броеви. Голем број програми, иако имплементираат точен алгоритам, може да отпечатат погрешен резултат за одредени влезни параметри - поради фактот што користат погрешен податочен тип. Мислењето дека, на пример, со int може да се претстави кој било цел број е погрешно.

Следнава програма нема да го отпечати точниот резултат. Збирот на броевите од 1 до 1 000 000 е 500 000 500 000 - број кој не може да се смести во променлива од тип int (кој има опсег до 2 147 483 647):

Програма 21.5

#include <cstring>
#include <iostream>
using namespace std;

int main()
{
      int sum = 0;
      
      for (int i=1; i<=1000000; i++)
            sum += i;
      
      cout << sum << endl;                 //pechati '1784293664'
      return 0;
}

Бидејќи int овозможува чување и на позитивни и на негативни броеви, одредена комбинација од битови може да предизвика и печатење на негативен број - иако цело време собираме позитивни броеви. Едноставно, надминување на опсегот е сериозен проблем на кој треба сериозно да се внимава.

Познавајте го опсегот на позначајните податочни типови (особено int и long long). Доволно е да се познава бројот на цифри на максималниот број од опсегот и/или бројот на битови потребни за чување на бројот. На тој начин, самите може да го пресметате опсегот. На пример, int зафаќа простор од 32 бита и има опсег од -231 до 231-1 (вкупно 232 вредности), додека long long зафаќа простор од 64 бита и има опсег од -263 до 263-1 (вкупно 264 вредности). Верувајте, поради фактот што денешните архитектури на компјутери се или 32-битни или 64-битни, многу е веројатно дека нема да има никаков позитивен ефект (гледано мемориски или временски) од користењето на помали податочни типови од int.

C/C++ - Основни податочни типови (прв дел)
тип опсег
char -128 (-27) до 127 (27-1), или 0 до 255 (ако е unsigned char)
short -32 768 (-215) до 32 767 (215-1), или 0 до 65535
int -2 147 483 648 (-231) до 2 147 483 647 (231-1), или 0 до 4 294 967 295
long -2 147 483 648 (-231) до 2 147 483 647 (231-1), или 0 до 4 294 967 295
long long -9 223 372 036 854 775 808 (-263) до 9 223 372 036 854 775 807 (263-1)

Пред да започнете да пишувате програма, пресметајте (приближно) до каде може да се движат вредностите кои би ги сместиле во одредена променлива - ова не би требало да ви одземе многу време, а е чекор што го прават сите добри програмери. Потоа, искористете податочен тип кој има доволно голем опсег за да ги претстави тие вредности. На пример, да замислиме дека треба да направиме програма која ќе работи со посетители на некој курс. Очигледно е дека опсегот кој го нуди int е доволен за, на пример, броење на посетителите - курсот сигурно ќе има помалку од 231 = 2 147 483 648 посетители. За другите променливи во програмата ќе направите слична анализа. Со тек на време ќе научите на кои променливи да внимавате и ќе ја правите ваквата анализа исклучително брзо.

Реални броеви

Најчестиот начин на чување на реални броеви (од страна на компјутерите) е преку т.н. метод на подвижна точка (IEEE 754), односно чување на броевите со знак, нормализирана вредност и експонент. На пример, бројот 12345.6789 можеме да го чуваме како 1.23456789 * 104 (ова е поедноставен пример - компјутерите работат со бинарни броеви наместо декадни).

Податочниот тип float користи 32 бита за чување на вредноста, од кои 23 ја даваат прецизноста (нормализираната вредност), 1 бит служи за чување на знакот (позитивен или негативен број), додека останатите 8 бита служат за чување на експонентот (делот 104 во примерот даден погоре). Нормално, со double може да се добие поголема прецизност (double користи 64 бита за чување на вредноста), но, се разбира, и тој има ограничена прецизност.

Ќе цитирам само неколку прашања кои често се среќаваат:

  • Зошто кога внесувам X, програмата чита (X+0.0000000001)?
  • Зошто кога споредувам два броја кои се еднакви, програмата вели дека не се?

Фактот дека е ограничен бројот на битови со кои може да се чуваат вредностите влијае на тоа одредени броеви да не можат да се претстават целосно. На пример, следнава програма ќе работи бесконечно:

Програма 21.6

#include <iostream>
using namespace std;

int main()
{
      double a=0.1, s=0;
      
      while (a != 1.0)
      {
            s += a;
            a += 0.1;
      }
      
      cout << s << endl;       //nikogash nema da se izvrshi
      return 0;
}

Иако, логично, претпоставувате дека програмата ќе престане со извршување кога a ќе добие вредност 1.0 (тогаш нема веќе да е исполнет условот a != 1.0), факт е дека оваа програма нема никогаш да заврши со извршување. Зошто? Бидејќи double има ограничена прецизност и a нема никогаш да биде (точно) еднакво на 1.0.

C/C++ - Основни податочни типови (втор дел)
тип значајни цифри (цифри кои ги сметаме за точни)
float 7-8 (првите 7 цифри од резултатот се значајни)
double 15 до 16 значајни цифри
long double зависно од архитектурата, но најмалку 15-16 цифри

Кога сакате да претворите еден реален број во цел, користете ги функциите round(num) и floor(num+EPS) - и двете се дефинирани во датотеката <cmath> (#include <cmath>). Слично, можете да го искористите изразот (int)(num+EPS). Притоа, EPS е некоја мала вредност (на пример, 0.000001), која ќе ни помогне да ги избегнеме овие т.н. грешки при заокружување.

Второ, бидејќи броевите се чуваат заокружени до одредена децимала, не е можно да провериме дали два реални броеви се еднакви на начин како што можевме кај целите броеви (потсетете се на резултатот од програмата дадена погоре). Имено, може да постојат две променливи за кои знаеме дека ја чуваат истата вредност, а програмата да врати дека тие не се еднакви - поради ограничената прецизност. Поради ова, дали два реални броеви се еднакви или не може да провериме на следниот начин (EPS е некоја мала вредност која ја дава максималната можна разлика за да два реални броеви ги сметаме за еднакви):

Програма 21.7

#include <iostream>
#include <cmath>
using namespace std;

int main()
{
      double EPS = 0.000001;
      
      double A = 0.0;
      for (int i=0; i<10; i++)
            A += 0.1;
      
      double B = 1.0;
      
      if (fabs(A-B) <= EPS)                    //PROVERKA (na vistinski nachin)
            cout << "Ednakvi" << endl;         //pechati 'Ednakvi'
      
      return 0;
}

Во програмата дадена погоре ја искористивме функцијата fabs(X), која е дефинирана во датотеката <cmath> (#include <cmath>) и служи за пресметка на апсолутната вредност на даден реален број X. Многу едноставно е, доколку не ви текнува името на функцијата, да креирате и своја функција за пресметка на апсолутна вредност:

Програма 21.8

#include <iostream>
using namespace std;

double ABS(double x)
{
      if (x < 0)
            return -x;
      
      return x;
}

int main()
{
      double EPS = 0.000001;
      
      double A = 0.0;
      for (int i=0; i<10; i++)
            A += 0.1;
      
      double B = 1.0;
      
      if (ABS(A-B) <= EPS)
            cout << "Ednakvi" << endl;       //pechati 'Ednakvi'
      
      return 0;
}

Во иднина, обидувајте се да ги избегнувате операциите со реални броеви (нормално, доколку е тоа можно) и користете ги целобројните податочни типови - за нив уште велиме дека се егзактни (прецизни). Исто така, во ситуации кога е неопходно да работите со реални броеви, одбегнувајте го податочниот тип float (прецизноста која што ја нуди тој е премала за каква било сериозна пресметка - 7 до 8 точни цифри) и секогаш користете double.

Дозвола за користење: CC BY-NC 2.5 ©