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

Dünaamiline mäluhaldus II

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

  • teab, mis on magasinmälu (stack memory) ja kuidas seda kasutatakse
  • teab C keele funktsioone malloc, calloc, realloc ja free
  • oskab kasutada operaatoreid new ja delete muutujate, objektide ja massiivide jaoks
  • teab, mis on targad viidad ja oskab neid kasutada
  • teab, millist tarka viita antud olukorras valida
  • oskab kirjeldada, milliseid probleeme targad viidad lahendavad

C++ programmide poolt kasutatav mälu jaguneb neljaks suuremaks osaks

  • mälu, kus paikneb programmitekst (ja kood)
  • mälu, kus paiknevad staatilised ja globaalsed muutujad
  • magasinmälu (stack memory), kuhu pannakse funktsioonide väljakutsetega jms seotud info (lokaalsed muutujad, parameetrid, jne)
  • kuhjamälu (heap memory), kust saab mälu küsida C funktsioonide malloc, calloc, realloc abil, samuti C++ operaatori new abil

Viimast kahte võib nimetada dünaamiliseks mäluks, kuna nende kasutus muutub programmi täitmise jooksul.

Magasin-mälu (stack memory)

Olgu meil programm, milles on peale main funktsiooni veel kaks funktsiooni arvuta ja kuup

#include <iostream>
using namespace std;
int kuup(int x){
    return x*x*x;
}
int arvuta(int a1, int a2){
    int sum = kuup(a1 + a2);
    return sum;
}
int main() {
int a = 3;
int b = 5;
int c = arvuta(a, b);
cout << "(" << a << " + " << b << ")^3 = " << c << '\n'; 
return 0;
}

Vaatame selle programmi täitmist sammhaaval.

1. Programmi töö täitmine algab main funktsioonist. Kogu vajalik info main funktsiooni kohta (lokaalsed muutujad, ...) kopeeritakse magasinmällu. Vastavat mäluosa nimetatakse ka magasinraamiks (stack frame).
2. Funktsioonist main toimub pöördumine funktsiooni arvuta poole. Magasinmällu kopeeritakse kogu vajalik info arvuta funktsiooni jaoks (parameetrid, lokaalsed muutujad, ...).
3. Funktsioonist arvuta toimub pöördumine funktsiooni kuup poole. Magasinmällu kopeeritakse kogu vajalik info kuup funktsiooni jaoks (parameetrid, lokaalsed muutujad, ...).
4. Funktsioonis kuup tehakse arvutused ja toimub tagasipöördumine väljakutsuja, st funktsiooni arvuta poole. Kuna funktsiooni kuup töö on lõppenud, eemaldatakse magasinmälust vastav informatsioon ja funktsiooni kuup lokaalsed muutujad (kui neid on), kaotavad kehtivuse.
5. Funktsioonis arvuta väärtustatakse muutuja sum ja toimub tagasipöördumine väljakutsuja, st funktsiooni main poole. Funktsiooni arvuta info kustutatakse magasinmälust ja tema lokaalsed muutujad ei ole enam kättesaadavad.
6. Funktsioonis main kuvatakse info ekraanile, tagastatakse süsteemile 0 ja main lõpetab töö. Magasinmälust kustutatakse vastav info, vt ka joonis

 

Tegelikult iga kord, kui sisenetakse lokaalsesse skoopi (lisaks funktsiooni väljakutsele võib selleks olla nt lause, kus tegevused { ja } vahel, ... ) võib sellest mõelda, kui skoobi lisamisest magasini. Kui programmi täitmine väljub skoobist, siis skoop eemaldatakse magasinmälust.

Seega, funktsioonist või mõnest muust skoobist lokaalse muutuja aadressi tagastamine viidana või viitena võib lõppeda programmi jaoks ettearvamatu veaga, sest peale skoobist väljumist vabastatakse skoobi lokaalsete muutujate alt mälu ja seda võidakse kasutada süsteemi poolt teistel eesmärkidel.

Operaatorid new ja delete

Programmeerimiskeeles C on mälu hõivamiseks ja vabastamiseks funktsioonid malloc, calloc, realloc, free. Kuidas need funktsioonid täpselt töötavad, uuri Moodles viidatud videost (alates 2:29:14) või https://en.cppreference.com/w/c/memory.

Dünaamilise mälu hõivamine toimub nn kuhjamälust heap memory. Kuna arvuti mälu on piiratud ressurss, siis mälu hõivamisel võib tekkida erind bad_alloc, mida tuleb rohkem mälu nõudvate ülesannete juures kindlasti arvestada.

Keeles C++ on dünaamilise mälu haldamiseks kaks operaatorit - new ja delete. Kuigi need operaatorid töötavad analoogiliselt C++ funktsioonidega, on operaatoritel new ja delete siiski mõned eelised:

  • ei ole vaja kasutada sizeof operaatorit, sest new arvutab suuruse tüübi järgi ise
  • ei ole vaja tüübiteisendust, sest new tagastab nõutud tüüpi viida, mitte void*
  • mõlemat operaatorit on võimalik üle defineerida

Vaatame näidete varal operaatorite new ja delete kasutamist.

1. Mälu hõivamine ja vabastamine ühe täisarvu jaoks koos erindi püüdmisega

#include <iostream>
using namespace std;
int main() {
    int *ptr;
    try {
        ptr = new int; // mälu hõivamine
    } catch (bad_alloc b) { // erindi püüdmine
        cout << "Mälu hõivamise viga!\n";
        return 1; // lõpetame veaga
    }
    *ptr = 200; // väärtuse omistamine
    cout << "Aadressil " << ptr << " "; // aadress
    cout << "on väärtus " << *ptr << '\n'; // väärtus aadressil ptr
    delete ptr; // mälu vabastamine
    return 0;
}
Aadressil 0x1d76add18f0 on väärtus 200

Järgmistes näidetes ei kasutata mälu hõivamisel erindi püüdmist, et hoida programm lühemana.

2. Mälu hõivamine funktsioonis

Järgmise näites hõivatakse mälu funktsioonis, mis tagastab viida ja mälu vabastatakse pöörduja poolt: (vt ka joonis)

#include <iostream>
using namespace std;
int* fun(){
    int *ptr;
    ptr = new int(200); // mälu hõivamine
    cout << "Aadress fun-is: " << ptr << '\n';
    return ptr; // viida tagastamine
}

int main() {
    int* p = fun(); // funktsioon tagastab viida
    cout << "Aadress main-is: " << p << '\n';
    cout << "Väärtus main-is: " << *p << '\n';
    delete p; // mälu vabastamine
    cout << "Väärtus main-is peale vabastamist: " << *p << '\n';
    return 0;
}
Aadress fun-is: 0x153b7a218f0
Aadress main-is: 0x153b7a218f0
Väärtus main-is: 200
Väärtus main-is peale vabastamist: -1214113168

Peale funktsiooni fun töö lõppu tagastatakse täisarvuline viit main funktsiooni muutujale p, mis viitab samale kohale kuhjamälus. Peale info kuvamist ekraanile mälu vabastatakse. Ekraanile kuvatakse viidatud aadressil olev täisarv ka peale mälu vabastamist ja tulemuseks ei ole enam oodatud 200.

NB! Mõnikord võib info ka säilida peale mälu vabastamist, mis on eriti eksitav.

 

3. Mälu hõivamine massiivi jaoks

Mälu hõivamine massiivi jaoks toimub samuti käsu new abil nt int* p1 = new int[5].

#include <iostream>
using namespace std;

int main() {
    int* p, i; // NB! viit on ainult muutuja p; i on tavaline int muutuja
    p = new int [10]; // mälu hõivamine 10 täisarvu jaoks
    for (int i = 0; i < 10; ++i) {
        p[i] = i;
    }
    for(i = 0; i < 10; i++) {
        cout << *(p + i) << " ";
    }
    delete [] p; // mälu vabastamine massiivi alt
    return 0;
}

0 1 2 3 4 5 6 7 8 9 

Esimeses for- tsüklis on kasutatud indekseid, teises aga viidete aritmeetikat.

NB! Pöörake tähelepanu mälu vabastamise lausele, kus sulud [] on peale võtmesõna delete.

4. Mälu hõivamine objekti jaoks

Mälu saab hõivata (ja vabaks lasta) ka objekti jaoks, nt

Minuklass* o = new Minuklass{1};
delete o;

Mälu hõivamisel käivitatakse vastav konstruktor ja mälu vabastamisel destruktor. Kui muutuja on viit klassile, siis klassi liikmeid saab välja kutsuda . asemel -> notatsiooniga, nt o->set_i(5).

#include <iostream>
using namespace std;
class Minuklass {
public:
    Minuklass() = default; // vaikekonstruktor
    Minuklass(int i) : m_i{i} { // ühe parameetriga konstruktor
        cout << "Minuklass konstruktoris\n";
    }
    ~Minuklass() { // destruktor
        cout << "Minuklass destruktoris\n";
    }
    void set_i(int i) { // isendimuutuja seadmine
        m_i = i;
    }
    int get_i() { // isendimuutuja võtmine
        return m_i;
    }
private:
    int m_i{};
};
void muuda_objekti(Minuklass* o) { // objekti muutmine
    o->set_i(5);
}
int main() {
    Minuklass* o = new Minuklass{1}; // mälu hõivamine objekti jaoks ja isendi loomine
    muuda_objekti(o); // objekti muutmine
    cout << "i = " << o->get_i() << '\n';
    delete o; // mälu vabastamine 
    cout << "i = " << o->get_i() << '\n'; // NB! objekti alt on mälu vabastatud, tulemus ettearvamatu
    return 0;
}
Minuklass konstruktoris
i = 5
Minuklass destruktoris
i = 1412373104

Objekti loomisel koos mälu hõivamisega käivitatakse konstruktor ja mälu vabastamisel destruktor. Objektimuutuja ise on kättesaadav skoobi lõpuni, st kuni main lõpuni. Seetõttu peale delete käsku on käsk cout << "i = " << o->get_i() << '\n' legaalne, kuid tulemus ei ole see, mis lootsime.

5. Mälu hõivamine objektide massiivi jaoks

Mälu saab hõivata ka objektide massiivi jaoks, kuid ainult siis, kui klassil on olemas vaikekonstruktor. See tuleneb asjaolust, et massiivi jaoks ruumi hõivamisel ei saa kasutada initsialiseerijat. Loome massiivi main funktsioonis (klassil Minuklass on vaikekonstruktor olemas):

int main() {
    Minuklass* p;
    p = new Minuklass[5]; // mälu hõivamine massiivi jaoks, kus on 5 objekti. 
                          // Objektid luuakse vaikekonstruktoriga
    for (int i = 0; i < 5; ++i) {
        muuda_objekti(&p[i]); // massiivi objekti muutmine, argumendiks tuleb anda viit objektile
    }
    for (int i = 0; i < 5; ++i) {
        cout << "p [" << i << "].get_i " << p[i].get_i() << '\n'; //p[i] ei ole viit, seega 
                                                                  // vajalik punktnotatsioon
        // alternatiivselt saab kasutada ka (&p[i])->get_i()
    }
    delete[] p; //mälu vabastamine, massiivi iga liikme jaoks kutsutakse välja destruktor 
    return 0;
}

p [0].get_i 5
p [1].get_i 5
p [2].get_i 5
p [3].get_i 5
p [4].get_i 5
Minuklass destruktoris
Minuklass destruktoris
Minuklass destruktoris
Minuklass destruktoris
Minuklass destruktoris

Targad viidad (smart pointers)

Seni oleme pidanud kuhjamälu korrektseks kasutamiseks meeles pidama võtmesõnaga new loodud viidad ja need ka õigesti kustutama. Lihtsamate programmide puhul ei ole see raske, kuid suuremate programmide puhul võib muutuda see keeruliseks.

Vaatame järgmist funktsiooni:

int jaga(int jagatav, int jagaja) {
   int *jagatavPtr = new int(jagatav); // loome viida jagatavale
   int *jagajaPtr = new int(jagaja);   // loome viida jagajale
   if (*jagatavPtr == 0) { // kui väärtus = 0, siis tagastame 0
       return 0;
   }
   if (*jagajaPtr == 0) { // kui väärtus = 0, siis tagastame 0
       return 0;
   }
   int tulemus = *jagatavPtr / *jagajaPtr; // teostame tehte
   delete jagatavPtr; // vabastame mälu
   delete jagajaPtr;  // vabastame mälu
   return tulemus; 
}

NB! Funktsioonis kasutatud viidad on vaid näiteks, tegelikult on siin viitade kasutamine ebavajalik.

Peale vaadates tundub funktsioon korrektne, kuid nii see ei ole. Juhul kui jagatav või jagaja väärtus on 0, siis toimub funktsioonist väljumine viitasid vabastamata. Sellistel juhtudel tekib mäluleke.

Funktsiooni on võimalik modifitseerida järgnevalt (lisades mälu vabastamise ka erijuhtude juurde):

int jaga(int jagatav, int jagaja) {
   int *jagatavPtr = new int(jagatav);
   int *jagajaPtr = new int(jagaja);
   if (*jagatavPtr == 0) {
       delete jagatavPtr;
       delete jagajaPtr;
       return 0;
   }
   if (*jagajaPtr == 0) {
       delete jagatavPtr;
       delete jagajaPtr;
       return 0;
   }
   int tulemus = *jagatavPtr / *jagajaPtr;
   delete jagatavPtr;
   delete jagajaPtr;
   return tulemus;
}

Nüüd on funktsioon küll mäluleketeta, kuid lisasime dubleerivalt juurde 4 rida koodi.

Unikaalne viit (unique_ptr)

Sellistes olukordades aitab päises <memory> olev andmestruktuur nimega unique_ptr. Antud andmestruktuur haldab new ja delete väljakutseid meie eest. Soovitud tüüpi objekt luuakse automaatselt kuhjamälus unique_ptr objekti loomisel ning kustutatakse, kui unique_ptr objekti eluiga lõpeb. Järgmiste näidete korral on vajalik lause #include <memory>. Antud näite korral funktsioonis loodud unikaalsete viitade eluiga lõpeb funktsioonist väljumisel.

int jaga(int jagatav, int jagaja) {
   unique_ptr<int> jagatavPtr(new int(jagatav)); // luuakse unikaalne viit jagatavale
   unique_ptr<int> jagajaPtr(new int(jagaja)); // luuakse unikaalne viit jagajale
   if (*jagatavPtr == 0) {
       return 0;
   }
   if (*jagajaPtr == 0) {
       return 0;
   }
   int tulemus = *jagatavPtr / *jagajaPtr; // tehakse tehe
   return tulemus;
}

Enam ei ole vaja loodud viitade jaoks võtmesõna delete kasutada ning mälulekkeid funktsioonis ei teki. Ükskõik millisel funktsioonist väljumisel lõpeb loodud viitade eluiga ning kuhjamälus loodud täisarvud kustutatakse.

Paneme tähele, et unikaalse viidaga opereerimine on identne tavalise viidaga opereerimisele. Sealt väärtuse lugemiseks on võimalik kasutada sümbolit *, klassitüüpi viida puhul on olemas ka operaator ->.

Kui unikaalne viit viitab mingile objektile, siis ta on selle objekti omanik ja ükski teine unikaalne viit ei saa sellele viidata, st unikaalset viita ei saa kopeerida ja unikaalset viita ei saa luua koopia omistamise (copy assignment) teel. Küll aga on võimalik unikaalset viita üle anda teisele unikaalsele viidale (siis esimene hävib). Seda saab teha teisaldusomistamise (move assignment) teel. Kui klassis on isendiväli, mis on unikaalne viit (unique pointer), siis kompilaator kustutab automaatselt selle klassi koopiakonstruktori (copy constructor) ja koopiaomistamise (copy assignment).

Funktsioonimall make_unique

Alternatiivselt on võimalik unikaalset viita luua kasutades funktsioonimalli make_unique:

int main() {
   auto ptr = make_unique<int>(10); // loome unikaalse viida täisarvu 10 jaoks
   cout << "ptr = " << *ptr << '\n'; // ekraanile sisu
   return 0;
}
ptr = 10

Paneme tähele, et nüüd on võimalik kasutada võtmesõna auto ning võtmesõna new ei ole enam tarvis kirjutada. Mall make_unique käitub talle parameetrina antud argumendi tüübi konstruktorina.

See tähendab, et saame klassi

class Klass {
   int m_A{0};
   int m_B{0};
public:
   Klass(int a, int b) : m_A{a}, m_B{b} {} // konstruktor
};

korral klassi objektile unikaalse viida loomiseks kasutada järgmist koodilõiku:

auto klassiObjekt = make_unique<Klass>(1, 2);

NB! Konstruktori ja funktsioonimalli parameetrite arv ja tüübid peavad kokku langema.

Antud funktsioonimalli on mõistlik eelistada konstruktoriga viida loomisele kahel põhjusel:

  • kasutades malli, on võimalik kasutada võtmesõna auto, elimineerides sellega palju tüüpide jmt ümberkirjutamist.
  • mall on loodud paremini erindeid haldama – kui klassi konstruktor peaks viskama mingit sorti erindi, annab funktsioonimalli kasutamine parema võimaluse erindiga tegeleda. Erindeid vaatame tulevastes praktikumides.

Täpsemalt saab lugeda järgneva StackOverflow postituse vastusest ja viidatud linkidest: https://stackoverflow.com/a/37514601.

Unikaalset viita ei tohiks kunagi jagada mingi teise funktsiooni, objekti ega skoobiga. Olgugi, et mõnel korral võib programm töötada ootuspäraselt, siis ei ole see hea tava ning võib tekkida erinevaid algselt nähtamatuid probleeme.

Proovi unikaalsest viidast luua koopia. Mis kompilaator selle peale ütleb? Nt

auto klassiObjekt = make_unique<Klass>(1, 2);
//Klass* p = klassiObjekt; // viga, unikaalset viita ei saa kopeerida

Unikaalsete viitade kohta saab täpsemalt uurida aadressil https://en.cppreference.com/w/cpp/memory/unique_ptr

Jagatud viit (shared_ptr)

Kui on vaja loodud viita jagada teiste funktsioonidega, objektidega, skoopidega jne, oleks mõistlik kasutada andmestruktuuri jagatud viit ehk shared_ptr. Jagatud viit tagab selle, et objekt kustutatakse alles siis, kui mitte ükski ressurss (funktsioon, objekt jmt) seda enam ei kasuta. Selle tagamiseks kasutatakse prügikorje tehnikat nimega viidete loendamine (vt täpsemalt https://en.wikipedia.org/wiki/Reference_counting).

Jagatud viida loomine on analoogne unikaalse viida loomisele. Selleks on samuti olemas konstruktor ning funktsioonimall make_shared, mis töötab analoogselt mallile make_unique.

Paneme tähele, et jagatud viita on võimalik kopeerida ja liigutada. Seega on järgnev koodilõik täiesti korrektne C++ programm:

class Klass {
    int m_A{0};
    int m_B{0};
public:
    Klass(int a, int b) : m_A{a}, m_B{b} {} // konstruktor

    void set_A(int a){ // isendimuutuja seadmine
        m_A = a;
    }
    void valjasta() { // ekraanile
        cout << "Klass: a = " << m_A << ", b = " << m_B << '\n';
    }
};
void tootleAndmeid(shared_ptr<Klass> klass) { // viit kopeeritakse
    klass->valjasta(); // liikmefunktsiooni kutsumiseks tuleb kasutada -> operaatorit
}
int main() {
    auto klass = make_shared<Klass>(1, 2); // jagatud viida tegemine
    tootleAndmeid(klass);
    klass->valjasta();
    shared_ptr<Klass> klass1 = klass; // teine muutuja hakkab viitama samale objektile
    klass1->set_A(5); // isendimuutuja seadmine
    klass1->valjasta(); // isendiväljad uue viidaga
    klass->valjasta(); // isendiväljad vana viidaga
    return 0;
}
Klass: a = 1, b = 2
Klass: a = 1, b = 2
Klass: a = 5, b = 2
Klass: a = 5, b = 2

Märkus: Kompilaator hoiatab funktsiooni tootleAndmeid argumendi puhul, et objekti kopeerimise vältimiseks peaks see olema viit. Kuigi antud näites see rolli ei mängi, on alati hea kõiki tarku viitasid (v.a unikaalset viita) argumendiks anda viidana.

Jagatud viitade kohta saab täpsemalt uurida aadressil https://en.cppreference.com/w/cpp/memory/shared_ptr

Nõrk viit (weak_ptr)

Viimase targa viidana vaatame nõrka viita ehk andmestruktuuri weak_ptr. Nõrk viit on käitumiselt sarnane jagatud viidale: seda võib jagada, kopeerida jne. Küll aga ei halda see objektide loomist ega kustutamist. Nõrga viida saab luua ainult jagatud viidast (või teisest nõrgast viidast).

Kuna nõrk viit ise mälu haldamist ei tee, tuleb selles oleva väärtuse kasutamiseks kasutada liikmefunktsioone expired ja lock. Funktsioon expired tagastab tõeväärtuse, kas objekt on kustutatud ja lock tagastab jagatud viida objektile (juhul kui see eksisteerib).

Vaatame järgmist näidet:

int main() {
   weak_ptr<Klass> norkKlass; // nõrga viida loomine
    {
        auto klass = make_shared<Klass>(1, 2);
        norkKlass = klass; // hakkab viitama jagatud viidale
        norkKlass.lock()->valjasta();
        cout << "kas eksisteerib veel? " << boolalpha << !norkKlass.expired() << '\n';
    }
    cout << "kas eksisteerib veel? " << boolalpha << !norkKlass.expired() << '\n';
    //norkKlass.lock()->valjasta(); // programm või lõpetada veaga
    return 0;
}
Klass: a = 1, b = 2
kas eksisteerib veel? true
kas eksisteerib veel? false

Siin jagatud viit kustutas loodud klassi objekti peale esimest sulgevat loogelist sulgu. Seega antud objekt edaspidi enam mälus ei eksisteeri. Näeme seda ka funktsiooni expired kasutades. Teise funktsiooni valjasta kutse enam midagi ei väljasta või tekitab programmi töö lõppemise veaga (uuri põhjust funktsiooni lock dokumentatsioonist).

Nõrkade viitade kohta saab täpsemalt uurida aadressil https://en.cppreference.com/w/cpp/memory/weak_ptr

Siinkohal toome tsitaadi raamatust: Ivor Horton, Peter Van Veert, Beginning C++20 From Novice to Professional, Sixth Edition, Apress, 2020. Lk. 220

"Never use the operators new, new[], delete, and delete[] directly in day-to-day coding. These operators have no place in modern C++ code. Always use either the std::vector<> container (to replace dynamic arrays) or a smart pointer (to dynamically allocate individual objects and manage their lifetimes). These high-level alternatives are much, much safer than the low-level memory management primitives and will help you tremendously by eradicating dangling pointers, multiple deallocations, allocation/deallocation mismatches, and memory leaks from your programs."

NB! Tööta kindlasti läbi eelmise nädala video, et kõik viitade, viidete ja mäluhaldusega seonduv saaks selgeks!

  • 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