Arvutiteaduse instituut
  1. Kursused
  2. 2022/23 kevad
  3. Programmeerimine keeles C++ (LTAT.03.025)
EN
Logi sisse

Programmeerimine keeles C++ 2022/23 kevad

  • Pealeht
  • 1. Muutujad ja andmetüübid
  • 2. Keele põhikonstruktsioonid I
  • 3. Keele põhikonstruktsioonid II
  • 4. Funktsioonimallid, failitöötlus
  • 5. OOP I Klassid
  • 6. OOP II Pärilus ja polümorfism
  • 7. Kontrolltöö 1?

Seitsmendal nädalal toimub 1. kontrolltöö

7.1 1. kontrolltöö näide?
  • 9. Dünaamiline mäluhaldus II
  • 10. Klassimallid
  • 11. STL andmestruktuurid I
  • 12. STL andmestruktuurid II
  • 13. Erindite töötlemine
  • 14. Täiendavad teemad
  • 15. Kontrolltöö 2?

Viieteistkümnendal nädalal toimub 2. kontrolltöö

15.1 2. kontrolltöö näide?
  • 16. Projekti esitlus?
  • Viiteid
  • Vanad materjalid
  • Praktikumid
  • Juhendid
  • Viited

Funktsioonimallid ja töö tekstifailidega

Pärast selle praktikumi läbimist üliõpilane

  • teab, mis on funktsioonimallid
  • teab, mis on tavalise funktsiooni ja funktsioonimalli vahe
  • oskab funktsioonimalle luua ja kasutada nii ühe kui mitme parameetriga
  • oskab analüüsida mallide kasutamisel tekkivaid tüüpidega seotud veateateid
  • oskab tekstifailist andmeid lugeda ja andmeid tekstifaili kirjutada

Funktsioonimallid on, nagu nimi ütleb, funktsioonide mallid. Kui senini oleme pidanud sama sisuga, kuid erinevat tüüpi parameetrite korral funktsioonid üle defineerima, siis funktsioonimalli abil on võimalik kõikide tüüpide jaoks kirjutada üks funktsioonimall.

Olgu meil näiteks kaks funktsiooni:

void printNum(int num) {
    cout << num << '\n';
}
void printNum(double num) {
    cout << num << '\n';
}

Funktsioonimalli abil on võimalik kirjutada need funktsioonid ühe mallina järgmiselt:

template<typename T>
void printNum(T num) {
    cout << num << '\n';
}

Funktsioonimalli päises on template<typename T>, kus T tähistab üldistatud tüüpi. Tavaliselt tähistatakse üldistatud tüüpe suurte tähtedega. Selle malli alusel oskab kompilaator luua vastavad funktsioonid tüüpidele, millega seda funktsiooni kutsutakse.

Mallide kasutamine

Mallide kasutamine on sarnane tavalise funktsioonide väljakutsumisega. Erinev on see, et nüüd tuleb kompilaatorile teada anda mallis kasutatav andmetüüp. Eelnevalt loodud malli saame kasutada järgmiselt:

int main() {
    printNum<double>(2.0); // 2.0
    printNum<int>(1);      // 1
    return 0;
}

Funktsiooni nime järel noolsulgudes on tüüp, mis annab kompilaatorile teada, mis tüüpi funktsioon oleks mallist vaja luua.

Kui aga kompilaator suudab tüübid ise välja mõelda (type inference), ei pea kasutaja tüüpi määrama. Sellist väljakutset nimetatakse ilmutatamata (implicit) väljakutseks. Etteantud tüübiga väljakutseid nimetatakse ilmutatud (explicit) väljakutseteks.

Näited ilmutamata väljakutsete kohta:

int main() {
    printNum(2.0); // tüübiks double
    printNum(1);   // tüübiks int
    printNum<>(0); // noolsulud on lubatud ka tühjaks jätta, tüübiks int
    return 0;
}

Selguse mõttes on soovitatav siiski pöördumisel tüüp ilmutatud kujul ette anda. Muutuja tüüpi saab kindlaks teha funktsiooniga typeid, mille väljundile rakendada funktsiooni name(). Esitame siin kogu programmi. Väljundis vastab tüübile double nimi d, tüübile int nimi i.

#include <iostream>
using namespace std;

template<typename T>
void printNum(T num) {
    cout << num << ' ' << typeid(num).name() << '\n';
}
int main() {
    printNum<double>(2.0);
    printNum<int>(1);
    printNum<int>(0);
    return 0;
}
2 d
1 i
0 i

Loodud funktsioonimalli on võimalik kasutada ka näiteks sõne (string) korral:

int main() {
    printNum<string>("Tere, maailm!"); // Tere, maailm!
    return 0;
}

Tegelikult antud mallis parameetriteks sobivad praegu kõik tüübid, mida on võimalik saata väljundvoogu (suur osa praeguseni kasutatud tüüpe).

NB! Kui kasutada päisefaili (.h), siis peab mallifunktsioon tervikuna asuma päisefailis.

C++20 päis <concepts>

Alates C++20 versioonist on keele standardteegis olemas päis <concepts>, kus on defineeritud mõned piirangud, mida saab funktsioonimallide parameetritele seada. Piirame malli parameetreid selliselt, et funktsiooni saab välja kutsuda vaid täisarvu (int) või ujukomaarvu (double) korral. Lisame malli päisesse requires integral<T> || floating_point<T>

template<typename T>
requires integral<T> || floating_point<T> // piirab võimalikud tüübid (täisarv või ujukomaarv)
void printNum(T num) {
    cout << num << '\n';
}

Selle malli kasutamisel string tüübiga saame kompileerimisvea.

int main() {
    printNum<double>(2.0);
    printNum<int>(1);
    //printNum<string>("Tere, maailm!"); //viga kompileerimisel
    return 0;
}

Paneme tähele, et nüüd ei ole võimalik funktsiooni kutsuda sõnega.

CLion annab näiteks sellise teavituse:

 

Teiste tööriistade ja kompilaatorite veateated võivad olla täiesti erinevad.

Mitu tüübiparameetrit

Mallidele on võimalik ette anda ka mitu erinevat tüübiparameetrit. Järgmises näites on tüübiargumente kolm. Selles näites on oluline, et parameetrite tüübid sobiksid funktsiooni to_string argumentideks.

template<typename T, typename U, typename V>
requires integral<T> || floating_point<T>
string sonena(T t, U u, V v){
    return to_string(t) + "; " + to_string(u) + "; " + to_string(v);
}

Funktsiooninmalli kasutamisel võib argumentide tüüpe kasutada suvalises järjekorras.

cout << sonena<double, int, int>(25.1, 12, 34) << '\n';
cout << sonena<int, double, double>(25, 12.34, 34.5) << '\n';
25.100000; 12; 34
25; 12.340000; 34.500000

Funktsiooni to_string väljund erineb ujukomaarvude korral sellest, mida me näeme väljundvoos cout.

Fikseeritud tüübiparameetrid

Oleme siiani T ette kirjutanud võtmesõna typename (typename asemel võib kirjutada ka class, aga soovitatav on siiski typename). See ütleb lihtsalt, et tegemist on mingi tüübiga. Antud võtmesõna asemele on võimalik kirjutada ka fikseeritud tüübi, mis tüüpi antud tüübiargument olema peaks.

Vaatame näidet:

template<typename T, int N>
void erinevadTyybid(T t) {
    cout << "Sain väärtuse: "        << t << '\n';
    cout << "Lisaks sain täisarvu: " << N << '\n';
}

Antud mallil on kaks tüübiparameetrit: T ja N. Parameeter T võib olla suvalist tüüpi ja N peab olema täisarv. Paneme tähele, et N ei ole funktsiooni parameeter.

Malli saame kasutada järgmiselt. Noolsulgude vahel tuleb määrata nii tüüp kui ka konkreetset tüüpi argument.

int main() {
    erinevadTyybid<string, 10>("Tere!");
    erinevadTyybid<double, 100>(0.5);
    return 0;
}
Sain väärtuse: Tere!
Lisaks sain täisarvu: 10
Sain väärtuse: 0.5
Lisaks sain täisarvu: 100

Tüüpide vaikeväärtused

Funktsioonimalli parameetritele on võimalik anda ka vaikimisi väärtusi. Vaikeväärtused tulevad kasutusele siis, kui funktsiooni poole pöördumisel noolsulgude vahel tüüpi ei määrata või (mingitel tingimustel) tüüpi pole võimalik tuletada. Kõige kasulikum on vaikimisi väärtuseid kasutada kindlat tüüpi argumentide korral (mitte typename korral).

Vaatame funktsioonimalli, kus konkreetse tüübi int korral on ette antud vaikeväärtus

template<int N = 1>
void printN() {
    std::cout << N << '\n';
}

Malli saame kasutada järgmiselt:

int main() {
    printN(); //tüübiargument puudub, kasutatakse vaikeväärtust
    printN<10>();
    return 0;
}
1
10

Võtmesõnaga typename määratud tüübile saame samuti anda vaikimisi väärtuse:

template<typename T = const char*>
T printAndReturn(T t) {
    std::cout << t << '\n';
    return t;
}
int main() {
    auto tulemus = printAndReturn<string>("olen std::string");
    auto tulemus2 = printAndReturn("olen char");
    return 0;
}
olen std::string
olen char

Märkus: Tüüp char* on C keele ajast sõnede defineerimiseks.

Programmeerimiskeskkonnad näitavad funktsiooni poole pöördumisel argumentide tüübid ette, nt CLion korral näeme pöördumisi järgmiselt:

 

Funktsiooni poole pöördumistes on näha, et ilmutatult argumendi määramisel valitakse see tüübiks. Argumenti määramata võeti kasutusele vaikimisi määratud tüüp const char* (rohkem juttu tulevastes praktikumides). Märkus: C++ oskab automaatselt char* muuta tüübiks string. Seetõttu saame anda funktsiooni argumendiks jutumärkide vahel oleva sõne.

Varieeruva parameetrite arvuga (variadic arguments) mallid

Malle on võimalik luua ka selliseid, kus ei ole vaja täpselt kirja panna, mitu tüüpi (ja ka mitu parameetrit) funktsioonile ette antakse.

Loome sellise malli

template<typename ...T>
void mulOnSuvalineArvArgumente(T ...argumendid) {
    for (auto argument : {argumendid...}) {
        std::cout << argument << '\n';
    }
}

Siin on kasutatud forEach tsüklit kõikide argumentide läbimiseks.

Malli kasutamine:

int main() {
    mulOnSuvalineArvArgumente<string, string>("tere", "maailm");
    mulOnSuvalineArvArgumente<int, int, int>(1, 2, 3);
    return 0;
}
tere
maailm
1
2
3

Oluline on, et ühel pöördumisel kõik argumendid oleksid sama tüüpi. Näiteks annab pöördumine mulOnSuvalineArvArgumente<int, double>(1, 2.0) veateate forEach tsüklis. Seda küll nüüd selle pärast, et seal üritame koostada standardteegis olevat listi. Listidega tegeleme juba järgnevates praktikumides.

Täpsemalt kutsutakse siin parameetreid parameetrite pakiks: https://en.cppreference.com/w/cpp/language/parameter_pack.

Lisaks forEach tsüklile on võimalik kasutada ka varieeruvate argumentide jaoks loodud päist <cstdarg>. Uuri lähemalt: https://en.cppreference.com/w/cpp/utility/variadic. NB! Siin peab olema tüüpidega hästi ettevaatlik!

Töö tekstifailidega

Failid on C++ kontekstis vood (stream), nagu ka std::cout, std::cin ja ka näiteks std::stringstream. Failide kasutamiseks on päis <fstream>.

Märkus: Failide päis sisaldab kolme tüüpi faile: ainult sisendiks (ifstream), ainult väljundiks (ofstream) ja nii sisendiks kui väljundiks (fstream) mõeldud faile. Kasutame materjalis vaid fstream tüüpi failiobjekte.

Faili avamine

Faili avamiseks piisab uue failiobjekti loomisest:

int main() {
    std::fstream fail("fail.txt");
}

Näites oleme loonud failiobjekti nimega fail, mis avab faili nimega fail.txt.

Kui avatav fail ei ole määratud absoluutse asukohana, üritatakse faili avada relatiivselt programmi töökataloogist.

CLioni korral on töökataloogiks cmake-build-debug. Soovi korral on võimalik see jooksutuskonfiguratiooni sätetest ära muuta (vt Working Directory). VSCode'i puhul on töökataloog avatud kaust ning käsurealt jooksutades kaust, millest programm jooksutati.

Paneme tähele, et kui antud faili ei leidu, siis programm faili avamisel sellest veaga teada ei anna. Samamoodi ei anna veateadet (ega ka tulemust) failist lugemine. Seetõttu on vajalik enne failist lugemist kontrollida, kas fail on avatud. Faili avamise kontroll:

int main() {
    fstream fail("fail.txt");
    if (fail.is_open()) {
        cout << "Fail 'fail.txt' leiti!\n";
    } else {
        cout << "Faili 'fail.txt' ei leitud!\n";
        cout << "Error: " << strerror(errno) << '\n';
    }
    return 0;
}
Faili 'fail.txt' ei leitud!
Error: No such file or directory

Siin kontrollime, kas fail failon avatud. Kui fail pole avatud, saame tegeliku vea (faili ei leidu, on lukus vmt) saamiseks kasutada funktsiooni strerror(errno), mis annab viimase juhtunud vea kohta sõnalise veateate. Funktsiooni strerror(errno) kasutamiseks on vajalik päise <cstring> olemasolu.

Paneme tähele, et näites ei ole faili suletud. Nimelt, C++ failid suletakse automaatselt skoobi lõppedes. See tähendab seda, et kui jõuame main funktsiooni lõpus olevale loogelisele sulule, lõpeb antud skoop ning fail suletakse. Küll aga on hea tava failid käsitsi sulgeda, kui nendega enam midagi ei tehta. Selleks saab kasutada failiobjektil funktsiooni close.

Failist lugemine

Primitiivseid tüüpe (nt int, double) ja muid sisseehitatud tüüpe (nt string) on võimalik üsna mugavalt failist sisse lugeda. Küll aga eeldab see kindlat teadmist, mis struktuuriga antud fail on.

Vaatame näidet:

Olgu faili fail.txt sisu järgmine:

100 2.0 tere päevast!
siin on ka teine rida
ja kolmas!

ja tühi rida ka

Järgmises näites loeme failist andmeid muutujatesse. Failist saame muutujaid sisse lugeda sarnaselt standardsisendist lugemisele operaatoriga >>. Programmis on käsk return EXIT_FAILURE, mis vea korral tagastab nullist erineva arvu, täpsemalt arvu 1.

int main() {
    fstream fail("fail.txt");
    if (fail.is_open()) {
        cout << "Fail 'fail.txt' leiti!\n";
    } else {
        cout << "Faili 'fail.txt' ei leitud!\n";
        cout << "Error: " << strerror(errno) << '\n';
        return EXIT_FAILURE;
    }
    int x;
    double y;
    string z;
    fail >> x >> y >> z;
    cout << "Sain failist:\n";
    cout << "\ttäisarvu " << x << '\n';
    cout << "\tujukomaarvu " << y << '\n';
    cout << "\tsõne " << z << '\n';
    fail.close();
    return 0;
}
Fail 'fail.txt' leiti!
Sain failist:
	täisarvu 100
	ujukomaarvu 2
	sõne tere

Sisse loetud tekstis on ainult sõne "tere". Seda seetõttu, et >> operaatoriga lugemine loeb ainult järgmise tühikuni.

Failist reakaupa lugemine

Mingites olukordades on parem lugeda failist reakaupa. Vaatame näidet:

    string rida;
    while (getline(fail, rida)) {
        cout << "Lugesin failist rea: '" << rida << "'\n";
    }
Lugesin failist rea: '100 2.0 tere päevast!'
Lugesin failist rea: 'siin on ka teine rida'
Lugesin failist rea: 'ja kolmas!'
Lugesin failist rea: ''
Lugesin failist rea: 'ja tühi rida ka'

Näites tühi rida on loetud eraldi ning sõned ei sisalda reavahetusi (\n).

Faili sisu lugemine ühekorraga

Seni toodud näidetes lugesime failist nn "jooksvalt". Failist on võimalik lugeda ka kogu sisu korraga.

Märkus: Suurte failide puhul ei ole see kõige parem, kuna kogu fail loetakse korraga mällu (reakaupa lugemise näite korral loetakse vaid üks rida korraga).

Kogu faili lugemiseks peame esmalt teada saama faili suuruse. NB! Suurus on baitides! Saame seda teha järgmiselt:

 

Siin on soovitatav kasutada võtmesõna auto, et lasta kompilaatoril muutujate algus ja lõpp tüüp automaatselt määrata.

Märkus: Nüüd failist uuesti lugemise jaoks on vaja faili algusesse tagasi sõita. Uuri, kuidas kasutada funktsiooni seekg, et minna tagasi faili algusesse. Mis saab siis, kui faili algusesse tagasi ei minda ja üritatakse failist lugeda (nt üritame lugeda ühe täisarvu)?

Selle koodi jooksutamisel saame väljundisse kirje, et faili sisu suurus on 75 baiti.

Märkus: Kogu faili lugemiseks kasutame veel õppimata kontseptsiooni: võtmesõnad new ja delete. Nende teemadeni jõuame tulevikus, aga lühidalt teeme allolevas näites märgimassiivi suurusega failiSuurus, loeme kogu faili sisu massiivi ja pärast kustutame loodud massiivi ära. NB! Võtmesõna delete ei saa kasutada varasemalt õpitud massiivide kasutamisel.

int main{
    fstream fail("fail.txt");
    if (fail.is_open()) {
        cout << "Fail 'fail.txt' leiti!\n";
    } else {
        cout << "Faili 'fail.txt' ei leitud!\n";
        cout << "Error: " << strerror(errno) << '\n';
        return EXIT_FAILURE;
    }

    auto algus = fail.tellg(); 
    fail.seekg(0, ios::end);
    auto lopp = fail.tellg();

    auto failiSuurus = lopp - algus; //baitide arv
    cout << "Faili suurus: " << failiSuurus << '\n';
    fail.seekg(0); // tagasi faili algusse
    char *failiSisu = new char[failiSuurus];
    fail.read(failiSisu, failiSuurus);
    cout << "Faili kogu sisu on '" << failiSisu << "'\n";
    delete[]failiSisu;
    return 0;
}
Fail 'fail.txt' leiti!
Faili suurus: 75
Faili kogu sisu on '100 2.0 tere paevast!
siin on ka teine rida
ja kolmas!

ja tuhi rida ka'

Nüüd on kõik reavahetused säilinud.

NB! Probleeme võivad tekitada täpitähed. Antud näites on täpitähed failis asendatud tavaliste märkidega.

Faili salvestamine

Faili salvestamine töötab analoogselt standardäljundisse (std::cout) saatmisele. Järgmises näites on vajalik päis <fstream> ja fail uusfail.txt peab olema olemas.

int main() {
    fstream fail("uusfail.txt");
    if (fail.is_open()) {
        string s{"Selle sisu paneme\nfaili uusfail.txt."};
        fail << s;
        fail.close();
    }
    else{
        cout << "Probleem faili avamisega\n";
    }    
    return 0;
}

Juhul, kui fail uusfail.txt oli enne programmi täitmist tühi, siis faili sisu peale programmi täitmist on järgmine:

Selle sisu paneme
faili uusfail.txt.

Antud programmiga kirjutatakse fail algusest alates üle. Eelnev info failis, mida üle ei kirjutatud, säilib.

Täpsemalt saab uurida aadressil https://en.cppreference.com/w/cpp/io/basic_fstream

Faili loomine

Uue faili loomiseks piisab vaid väljundfaili objekti loomisest:

int main() {
    ofstream fail("test.txt"); // kindlasti ofstream
    fail << "olen uus fail\n";
    return 0;
}

Kindlasti oleks vaja kontrollida, kas antud fail sai loodud. Kasuta selleks failiobjektil funktsiooni is_open.

Faili ülekirjutamine

Faili üle kirjutamiseks tuleb väljundfail avada kindlas režiimis.

int main() {
    ofstream fail("uusfail.txt", ios::out | ios::trunc);
    fail << "olen uus fail\n";
}

Paneme tähele, et teise argumendi konstrueerimisel on kasutatud üht püstjoont. Tegemist on bitioperatsiooniga või. Uuri lähemalt, mida antud režiimid ios::out ja ios::trunc tähendavad.

Päisefail <filesystem>

Failihaldusega käib käsikäes päisefail <filesystem>, kus on mitmeid failidega töötamise tüüpe ja funktsioone.

Vaatame näitena funktsiooni file_size:

int main() {
    auto suurus = filesystem::file_size("fail.txt");
    cout << "Faili 'fail.txt' suurus on " << suurus << " baiti.\n";
    return  0;
}

Antud programmi väljund annab meile samuti tulemuseks 75 baiti. Nüüd saime faili suuruse teada ilma faili avamata.

Täpsemalt saab <filesystem> võimalusi uurida aadressil

https://en.cppreference.com/w/cpp/filesystem

  • Arvutiteaduse instituut
  • Loodus- ja täppisteaduste valdkond
  • Tartu Ülikool
Tehniliste probleemide või küsimuste korral kirjuta:

Kursuse sisu ja korralduslike küsimustega pöörduge kursuse korraldajate poole.
Õppematerjalide varalised autoriõigused kuuluvad Tartu Ülikoolile. Õppematerjalide kasutamine on lubatud autoriõiguse seaduses ettenähtud teose vaba kasutamise eesmärkidel ja tingimustel. Õppematerjalide kasutamisel on kasutaja kohustatud viitama õppematerjalide autorile.
Õppematerjalide kasutamine muudel eesmärkidel on lubatud ainult Tartu Ülikooli eelneval kirjalikul nõusolekul.
Courses’i keskkonna kasutustingimused