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 fail
on 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