Vigade otsimine
Mida teha, kui esitad võistlusel lahenduse ja server annab vastuseks "vale vastus" või "programmi täitmine ebaõnnestus (nullist erinev veakood)"? Esimese asjana võib muidugi kontrollida, ega koodis mingit ilmset viga ei ole. Aga pärast seda? Paljud võistlejad jäävad suhteliselt kiiresti hätta ja ei oska teha muud, kui viimases hädas suvalisi "kahtlaseid" asju muuta. Kogemus näitab, et sarnaseid probleeme on isegi kogenud võistlejatel.
Vigade otsimine on tegelikult protsess, mida saab teha väga süstemaatiliselt ja mis on suhteliselt kergesti õpitav. Kindlasti on see protsess palju lihtsam ja kiirem lahenduste välja mõtlemisest. Olukorrad, kus programmis vea leidmine võtab rohkem kui pool tundi, peaksid olema erandlikud.
Esimene reegel: ükskõik mis töökeskonda (Emacs, vi, PyCharm, VS Code) kasutades tuleks olla tuttav selle tähtsamate funktsionaalsustega. Kindlasti on kohustuslik teada:
- kuidas programmi käivitada;
- kuidas vajadusel muuta programmi käivitamise käsurida;
- kuidas debugger ehk silur tööle panna;
- (C++) mis vahe on kompileerimisel ja käivitamisel;
- (C++) kuidas programmi kompileerida;
- (C++) kuidas kompileerimise käsurida muuta.
Need ei peaks olema asjad, mida alles võistluse jooksul uurima hakata!
Tõsiselt soovituslik on ka käsurea mingil määral tundmine. Käsurea tundmine tekitab ka IDE-s samade asjade tegemisest parema arusaamise (vastupidine ei kehti). Sealhulgas on soovituslik ka käsureal debuggeri kasutamise oskus: graafiline debugger on üks nendest asjadest, mis võistluskeskkondades katki kipub olema.
Enamus kaasaegsetes IDE-des on mingisugune käsurida sisse ehitatud:
Vali ülaribalt Terminal → New Terminal (võib väiksemal ekraanil olla "..." taga).
Avanenud käsurida näeb välja nii:
Vali paremalt alt nurgast märgitud ikoon.
Avanenud käsurida näeb välja nii:
M-x ansi-term
Võib kasutada ka eraldiseisvat terminalprogrammi. Linuxi tarnetes on pea alati mingisugune terminal sisse ehitatud (otsi menüüst "Terminal"). Windowsis on kaks terminali: vanem Command Prompt ja uuem PowerShell. Nendest võib kasutada emba-kumba, aga süntaks ja paljud käsud on erinevad.
Käsurea kiirkursus
Teeme läbi lihtsa harjutuse: programmi käsurealt kompileerimise ja käivitamise.
Käsureal on alati mingi failisüsteemi kaust lahti. Selles mõttes sarnaneb käsurida natuke operatsioonisüsteemi sisseehitatud failihalduriga.
Et teada saada milline, käivita käsk pwd
(cd
). Siin ja igal pool järgnevas
on sulgudes olev käsk Command Prompti oma. Kaustas olevate failide loetelu
trükib ekraanile ls
(dir
).
Selleks, et teisse kausta navigeerida, saab kasutada käsku cd
. Näiteks
kausta code
navigeerimiseks kasutage cd code
. Käsk cd ..
liigub ühe
kausta võrra üles. Neid käske kasutades liigume kausta, kus programm asub.
Selleks, et ühest failist koosnevat programmi program.cpp
kompileerida, kasutame
käsku
g++ program.cpp -o program
Selle tulemusena tekib kausta uus fail program
(Windowsis program.exe
), mille
käivitamiseks tuleb teha ./program
(Command Promptis program.exe
või .\program
).
Kompilaatorile g++
saab erinevaid täiendavaid argumente ette anda. Näiteks käsk
g++ program.cpp -O2 -Wall -g -o program
lülitab sisse optimeerimise (-O2
), näitab kõiki hoiatusi (-Wall
) ja lisab
kompileeritud faili debugimisinfo (-g
).
Pythonis kirjutatud programmi käivitamine käib nii:
python program.py
Sõltuvalt installeeritud interpretaatorist võib olla vaja kirjutada python
asemel
python3
, pypy
või pypy3
.
Muid kasulikke käske:
cat file.txt
(type file.txt
): trükib failifile.txt
sisu ekraanile;less file.txt
võimore file.txt
: pikema faili mugav vaatlemine;diff file1.txt file2.txt
(fc file1.txt file2.txt
): võrdleb failidefile1.txt
jafile2.txt
sisu;time ./program
(PowerShellisMeasure-Command { ./program }
).
1. Sisendi-väljundi suunamine
Enamus võistlustel, sealhulgas EIO-l, antakse sisend nn standardsisendist ja nõutakse, et väljund trükitaks standardväljundisse. Teisisõnu, sisend on see, "mida klaviatuurilt sisestatakse" ja väljund see, "mida ekraanile trükitakse". See aga ei tähenda, et seda ka ise testides peaks käsitsi sisse tippima: see on väga tüütu ja veaohtlik. Natuke parem variant on selle copy-pastemine, kuid ka see on näiteks suurte sisenditega testimise korral täiesti kasutu.
On ka hoopis lihtsam moodus: salvestada sisend faili ja käivitada programm nii,
et faili sisu oleks "justkui klaviatuurilt sisestatud". Oletame näiteks, et
programmi nimi on program
ja sisendfaili nimi input.txt
. Siis käib see nii:
./program < input.txt
program.exe < input.txt
Get-Content input.txt | ./program
Samamoodi saab väljundi suunata eraldi faili output.txt
. Neid kahte lähenemist
saab isegi kombineerida:
./program > output.txt
./program < input.txt > output.txt
program.exe > output.txt
program.exe < input.txt > output.txt
./program > output.txt
Get-Content input.txt | ./program > output.txt
Pythonis kirjutatud programmide puhul näevad need käsud välja analoogilised, täpsemalt:
python program.py < input.txt
python program.py > output.txt
python program.py < input.txt > output.txt
python program.py < input.txt
python program.py > output.txt
python program.py < input.txt > output.txt
Get-Content input.txt | python program.py
program.py > output.txt
Get-Content input.txt | python program.py > output.txt
Sõltuvalt installeeritud interpretaatorist võib olla vaja python
asemel kirjutada
python3
, pypy
, pypy3
vms.
On ka teine populaarne meetod: programmis endas failist lugemine ja faili kirjutamine, aga ainult juhul, kui programmi käivitatakse lokaalselt.
CMS kompileerib kõiki programme lipuga -DEVAL
. See tähendab, et kui C++ programmi
tekstis kirjutada
#ifdef EVAL
// siia kirjuta midagi
#endif
siis selle ploki sisu CMSis käivitatakse, aga mujal mitte, kui just ise sedasama
lippu ei kasuta. Võib ka vastupidi: CMSis lippu (näiteks) -DLOCAL
ei kasutata,
aga oma arvutis testides võib seda teha.
See tähendab, et võime programmi lisada koodi, mis suunab faili ümber standardsisendisse nii, et see kood CMSis ei jookse:
#include <cstdio>
using namespace std;
// ...
int main () {
#ifndef EVAL
freopen("input.txt", "r", stdin);
freopen("output.txt", "w", stdout);
#endif
// programm ise
}
Nüüd viitab lokaalsel käivitamisel cin
failile input.txt
ja cout
failile
output.txt
, CMSis aga kasutatakse standardsisendit ja standardväljundit nagu
ikka. Mitte kõik platvormid ei kasuta lippu -DEVAL
(levinud on ka näiteks
hoopis -DONLINE_JUDGE
), seega vast isegi kindlam on kontrollida lokaalse lipu
puudumist:
#include <cstdio>
using namespace std;
// ...
int main () {
#ifdef LOCAL
freopen("input.txt", "r", stdin);
freopen("output.txt", "w", stdout);
#endif
// programm ise
}
Selle toimimiseks tuleb kompileerimise käsureal kasutada lippu -DLOCAL
, näiteks
g++ a.cpp -DLOCAL -o a
.
2. fsanitize
See osa on relevantne ainult C++ kasutajatele.
Pythonis ja paljudes teistes keeltes annab programm vigade korral enam-vähem mõistlikke veateateid.
arr = [0, 1, 2]
print(arr[4])
Traceback (most recent call last):
File "/test/broken.py", line 2, in <module>
print(arr[4])
~~~^^^
IndexError: list index out of range
C++ on teistsugune. Vead, nagu massiivi piiride ületamine, ei pruugi mingisugust veateadet anda. Näiteks ülaloleva programmi C++ ekvivalent:
#include <iostream>
#include <vector>
int main () {
std::vector<int> arr = {0, 1, 2};
std::cout << arr[4] << '\n';
}
ei andnud mul mingisugust veateadet:
0
See aga ei tähenda, et teistsugune programm, mis massiivi piire ületab, samamoodi toimib. Veelgi enam, isegi sama programm ei pruugi teises arvutis (või teise kompilaatoriga, või mõnikord isegi samades tingimustes) samamoodi toimida. See tähendab, et kui programmis mõni selline viga on, ei pruugi see tingimata lokaalse testimise ja/või väikeste testidega testimise korral välja tulla.
Niisugune massiivi piiride ületamine on näide määramatusest (undefined behavior, UB). Kui programmi koodis midagi sellist on, siis võib programm ilma igasuguste hoiatusteta teha üks kõik mida. Suur osa kompilaatori tööst on optimeerimine, ja optimeerijad on kirjutatud eeldusel, et programmid ei sisalda määramatust. See tähendab, et määramatust sisaldava koodi käitumine võib olla väga ebaintuitiivne.
Mõned näited:
- massiivi piiride ületamine, nagu ülal;
- algväärtustamata lokaalsed skalaarid (nt.
int x; cout << x << '\n';
); - märgiga täisarvude ületäitumine (signed integer overflow);
- nullpointeri dereferentseerimine ehk tühiviida järgmine.
Võistlustel on kõige sagedasem massiivi piiride ületamine. Kuna koodi testimine ei pruugi viga tuvastada, siis kuidas niisugust viga üles leida?
Selleks, et massiivi piiride ületamist ja muid sarnaseid vigu leida, kompileeri oma
kood lipuga -fsanitize=address
:
g++ -fsanitize=address broken.cpp -o broken
Kui ülaltoodud programmi nüüd uuesti jooksutada, on väljund üsna pikk ja hirmutav:
=================================================================
==770335==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x502000000020 at pc 0x57177d6d661b bp 0x7fffe1b25ef0 sp 0x7fffe1b25ee0
READ of size 4 at 0x502000000020 thread T0
#0 0x57177d6d661a in main /test/broken.cpp:6
#1 0x7a8b79c2a1c9 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
#2 0x7a8b79c2a28a in __libc_start_main_impl ../csu/libc-start.c:360
#3 0x57177d6d6324 in _start (/test/broken+0x2324) (BuildId: 556f7a1cf83216fedd2e49e69184e258cf832d91)
0x502000000020 is located 4 bytes after 12-byte region [0x502000000010,0x50200000001c)
allocated by thread T0 here:
#0 0x7a8b7a4fe548 in operator new(unsigned long) ../../../../src/libsanitizer/asan/asan_new_delete.cpp:95
#1 0x57177d6d7581 in std::__new_allocator<int>::allocate(unsigned long, void const*) /usr/include/c++/13/bits/new_allocator.h:151
#2 0x57177d6d713e in std::allocator_traits<std::allocator<int> >::allocate(std::allocator<int>&, unsigned long) /usr/include/c++/13/bits/alloc_traits.h:482
#3 0x57177d6d713e in std::_Vector_base<int, std::allocator<int> >::_M_allocate(unsigned long) /usr/include/c++/13/bits/stl_vector.h:381
#4 0x57177d6d6d0f in void std::vector<int, std::allocator<int> >::_M_range_initialize<int const*>(int const*, int const*, std::forward_iterator_tag) /usr/include/c++/13/bits/stl_vector.h:1692
#5 0x57177d6d68a4 in std::vector<int, std::allocator<int> >::vector(std::initializer_list<int>, std::allocator<int> const&) /usr/include/c++/13/bits/stl_vector.h:682
#6 0x57177d6d6598 in main /test/broken.cpp:5
#7 0x7a8b79c2a1c9 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
#8 0x7a8b79c2a28a in __libc_start_main_impl ../csu/libc-start.c:360
#9 0x57177d6d6324 in _start (/test/broken+0x2324) (BuildId: 556f7a1cf83216fedd2e49e69184e258cf832d91)
SUMMARY: AddressSanitizer: heap-buffer-overflow /test/broken.cpp:6 in main
Shadow bytes around the buggy address:
0x501ffffffd80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x501ffffffe00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x501ffffffe80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x501fffffff00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x501fffffff80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x502000000000: fa fa 00 04[fa]fa fa fa fa fa fa fa fa fa fa fa
0x502000000080: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000000100: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000000180: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000000200: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000000280: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack mid redzone: f2
Stack right redzone: f3
Stack after return: f5
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
Container overflow: fc
Array cookie: ac
Intra object redzone: bb
ASan internal: fe
Left alloca redzone: ca
Right alloca redzone: cb
==770335==ABORTING
Siin on igasugust huvitavat infot, ja ainuüksi selle väljundi interpreteerimise kohta võiks pidada mitu loengut. Oluline osa on aga kohe alguses:
==770335==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x502000000020 at pc 0x57177d6d661b bp 0x7fffe1b25ef0 sp 0x7fffe1b25ee0
READ of size 4 at 0x502000000020 thread T0
#0 0x57177d6d661a in main /test/broken.cpp:6
#1 0x7a8b79c2a1c9 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
#2 0x7a8b79c2a28a in __libc_start_main_impl ../csu/libc-start.c:360
#3 0x57177d6d6324 in _start (/test/broken+0x2324) (BuildId: 556f7a1cf83216fedd2e49e69184e258cf832d91)
See ütleb: real 6 on midagi valesti. Tõepoolest: rida 6 on see, kus loeti vektori piiridest väljas olevat neljandat elementi.
2.1. Vektorite saneerimine
Vektoritega tuleb aga olla ettevaatlik. Vaatleme järgmist programmi:
#include <iostream>
#include <vector>
using namespace std;
int main () {
vector<int> arr;
arr.push_back(0);
arr.push_back(1);
arr.push_back(2);
cout << arr[3] << '\n';
}
Kompileerime selle programmi lipuga -fsanitize=address
ja käivitame.
Võiks arvata, et juhtub midagi sarnast, nagu eelmine kord. Aga ei juhtu mitte
midagi: programm trükib ekraanile mingi arvu ja lõpetab õnnelikult. Miks?
Selleks tuleb aru saada, kuidas std::vector
töötab. Kui vektor initsialiseeritakse,
on temas ruumi ainult mõnele elemendile. Kui ruum otsa saab, kopeeritakse kogu
vektori sisu ümber uude kohta, kus on ruumi (üldjuhul) kaks korda rohkematele
elementidele. Nii võtab iga .push_back()
keskmiselt kokku ikka \(O(1)\) aega.
See tähendab, et enamasti on vektoris ruumi rohkematele elementidele, kui seal
tegelikult elemente on.
Pärast esimest .push_back()
on vektoris arr
ruumi ühele elemendile,
pärast teist kahele ja pärast kolmandat neljale elemendile, kuigi vektoris on
tegelikult ainult kolm elementi. Koht, kuhu neljas element minema peaks, on seega
juba vektorile reserveeritud. AddressSanitizer näeb ka, et aadress on õigele
asjale reserveeritud, ja seega ei pahanda, kui sinna ligi pääsetakse.
AddressSanitizer "ei tea", et seda aadressi ei ole tegelikult vektori poolt
päris lõpuni kasutusele võetud.
GCC-s saab niisuguse saneerimise sisse lülitada lipuga -D_GLIBCXX_SANITIZE_VECTOR
:
g++ broken.cpp -D_GLIBCXX_SANITIZE_VECTOR -fsanitize=address -o broken
2.2. Keerulisem näide
Vaatleme üht salakavalamat näidet:
#include <vector>
struct Trie;
std::vector<Trie> heap;
struct Trie {
int child_index = -1;
void add (int depth) {
if (depth == 0)
return;
if (child_index == -1) {
child_index = heap.size();
heap.push_back(Trie());
}
heap[child_index].add(depth-1);
}
};
int main () {
Trie root;
root.add(2);
}
Ka selles programmis on viga, mis ei pruugi testimisel alati välja tulla.
Kui seda programmi niisama jooksutada, ei teki ekraanile mingit veateadet.
Samas on tegu lihtsustatud versiooniga programmist, mis võistluse ajal ettearvamatult
käitus. Kompileerides lipuga -fsanitize=address
saame väljundi:
=================================================================
==781694==ERROR: AddressSanitizer: heap-use-after-free on address 0x502000000010 at pc 0x60df17855830 bp 0x7ffe7849ea30 sp 0x7ffe7849ea20
READ of size 4 at 0x502000000010 thread T0
#0 0x60df1785582f in Trie::add(int) /test/trie.cpp:18
#1 0x60df17855862 in Trie::add(int) /test/trie.cpp:18
#2 0x60df17855474 in main /test/trie.cpp:24
#3 0x727ea9c2a1c9 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
#4 0x727ea9c2a28a in __libc_start_main_impl ../csu/libc-start.c:360
#5 0x60df178552e4 in _start (/test/a.out+0x12e4) (BuildId: fabdea7c9974b4a315046e22a8903d6fa39528bb)
0x502000000010 is located 0 bytes inside of 4-byte region [0x502000000010,0x502000000014)
freed by thread T0 here:
#0 0x727eaa4ff5e8 in operator delete(void*, unsigned long) ../../../../src/libsanitizer/asan/asan_new_delete.cpp:164
#1 0x60df178565ca in std::__new_allocator<Trie>::deallocate(Trie*, unsigned long) /usr/include/c++/13/bits/new_allocator.h:172
#2 0x60df17855b77 in std::allocator_traits<std::allocator<Trie> >::deallocate(std::allocator<Trie>&, Trie*, unsigned long) /usr/include/c++/13/bits/alloc_traits.h:517
#3 0x60df17855b77 in std::_Vector_base<Trie, std::allocator<Trie> >::_M_deallocate(Trie*, unsigned long) /usr/include/c++/13/bits/stl_vector.h:390
#4 0x60df178562de in void std::vector<Trie, std::allocator<Trie> >::_M_realloc_insert<Trie>(__gnu_cxx::__normal_iterator<Trie*, std::vector<Trie, std::allocator<Trie> > >, Trie&&) /usr/include/c++/13/bits/vector.tcc:519
#5 0x60df17855d70 in Trie& std::vector<Trie, std::allocator<Trie> >::emplace_back<Trie>(Trie&&) /usr/include/c++/13/bits/vector.tcc:123
#6 0x60df17855a29 in std::vector<Trie, std::allocator<Trie> >::push_back(Trie&&) /usr/include/c++/13/bits/stl_vector.h:1299
#7 0x60df178557e3 in Trie::add(int) /test/trie.cpp:15
#8 0x60df17855862 in Trie::add(int) /test/trie.cpp:18
#9 0x60df17855474 in main /test/trie.cpp:24
#10 0x727ea9c2a1c9 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
#11 0x727ea9c2a28a in __libc_start_main_impl ../csu/libc-start.c:360
#12 0x60df178552e4 in _start (/test/a.out+0x12e4) (BuildId: fabdea7c9974b4a315046e22a8903d6fa39528bb)
previously allocated by thread T0 here:
#0 0x727eaa4fe548 in operator new(unsigned long) ../../../../src/libsanitizer/asan/asan_new_delete.cpp:95
#1 0x60df17857071 in std::__new_allocator<Trie>::allocate(unsigned long, void const*) /usr/include/c++/13/bits/new_allocator.h:151
#2 0x60df17856a94 in std::allocator_traits<std::allocator<Trie> >::allocate(std::allocator<Trie>&, unsigned long) /usr/include/c++/13/bits/alloc_traits.h:482
#3 0x60df17856a94 in std::_Vector_base<Trie, std::allocator<Trie> >::_M_allocate(unsigned long) /usr/include/c++/13/bits/stl_vector.h:381
#4 0x60df17856087 in void std::vector<Trie, std::allocator<Trie> >::_M_realloc_insert<Trie>(__gnu_cxx::__normal_iterator<Trie*, std::vector<Trie, std::allocator<Trie> > >, Trie&&) /usr/include/c++/13/bits/vector.tcc:459
#5 0x60df17855d70 in Trie& std::vector<Trie, std::allocator<Trie> >::emplace_back<Trie>(Trie&&) /usr/include/c++/13/bits/vector.tcc:123
#6 0x60df17855a29 in std::vector<Trie, std::allocator<Trie> >::push_back(Trie&&) /usr/include/c++/13/bits/stl_vector.h:1299
#7 0x60df178557e3 in Trie::add(int) /test/trie.cpp:15
#8 0x60df17855474 in main /test/trie.cpp:24
#9 0x727ea9c2a1c9 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
#10 0x727ea9c2a28a in __libc_start_main_impl ../csu/libc-start.c:360
#11 0x60df178552e4 in _start (/test/a.out+0x12e4) (BuildId: fabdea7c9974b4a315046e22a8903d6fa39528bb)
SUMMARY: AddressSanitizer: heap-use-after-free /test/trie.cpp:18 in Trie::add(int)
Shadow bytes around the buggy address:
0x501ffffffd80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x501ffffffe00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x501ffffffe80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x501fffffff00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x501fffffff80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x502000000000: fa fa[fd]fa fa fa 00 fa fa fa fa fa fa fa fa fa
0x502000000080: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000000100: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000000180: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000000200: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000000280: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack mid redzone: f2
Stack right redzone: f3
Stack after return: f5
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
Container overflow: fc
Array cookie: ac
Intra object redzone: bb
ASan internal: fe
Left alloca redzone: ca
Right alloca redzone: cb
==781694==ABORTING
Real 18 loetakse midagi, mis on varasemalt real 15 .push_back()
tegemise
tagajärjel vabastatud. Mis toimub?
Lahendus
Esiteks meenutame, et vektorile .push_back()
tegemine põhjustab aeg-ajalt
kõikide elementide ümber kolimise.
Programmiga on samaväärne järgmine programm:
#include <vector>
struct Trie {
int child_index = -1;
};
std::vector<Trie> heap;
void add (Trie *node, int depth) {
if (depth == 0)
return;
if (node->child_index == -1) {
node->child_index = heap.size();
heap.push_back(Trie());
}
add(&heap[node->child_index], depth-1);
}
int main () {
Trie root;
add(&root, 2);
}
Real 14 tehakse heap.push_back(Trie())
. Kui vektori heap
maht on täis, siis
kopeeritakse selle sisu üle uute kohta ja vanad aadressid vabastatakse. Pointer
node
ise viitab ühele heap
elemendile, mille push_back
nüüd invalideerib.
Seega real 18 node->child_index
välja kutsumine on nüüd määramatus.
Analoogiliselt lipule -fsanitize=address
saab kasutada lippu
-fsanitize=undefined
, millega saab avastada muid määramatusi, näiteks
märgiga täisarvude ületäitumist.
Mõlemad lipud teevad aga programmi aeglasemaks, seega tasub neid kasutada ainult
vigade otsimise ajal.
Windowsis need meetodid MinGW või CygWin peal jooksva g++
peal ei toimi.
Kui on WSL, siis pole probleemi.
3. Stress-testimine
Programmis tekkinud vea parandamine on kõvasti lihtsam, kui õnnestub see viga kõigepealt reprodutseerida. See tähendab: leida mõni test, kus programmi väljund ei ole see, mis ta olema peaks. Ei ole oluline, kas programm sai vale vastuse või töö katkestati veateatega. Samuti ei ole oluline, kas programmis on ideeline viga või kõigest implementatsioon katki.
Testi leidmiseks on üldine retsept:
- Kirjuta hästi lihtne ja naiivne programm, mis ülesande kindlasti korrektselt ära lahendab. See võib olla ülimalt aeglane — me jooksutame koodi ainult väikestel testidel.
- Kirjuta teine programm, mis genereerib ülesande kirjeldusele vastavaid teste.
- Võrdle oma programmi ja lihtsa programmi väljundeid tuhandetel väikestel testidel.
Programmi tuleks testida just nimelt tuhandetel väikestel, mitte mõnel suurel. Praktika on näidanud, et enamuse valelahenduste vead tulevad välja juba väikeste testide juures (kui piisavalt palju proovida). Kuna lihtne programm võib olla väga aeglane, ei ole meil paljudel suurtel testidel jooksutamiseks aega. Lisaks tuleb meil pärast testi leidmist ka viga ise üles otsida. Suure testiga vea otsimine on üsna lootusetu.
Teeme läbi näite.
Ülesanne. On \(n\) eset, mis tuleb karpidesse pakkida. Igasse karpi mahub ülimalt kaks eset, ja ühegi karbi kaal ei tohi ületada \(x\) grammi. Kõikide esemete kaalud on teada. Mis on minimaalne võimalik karpide arv?
Sisendi esimesel real on kaks täisarvu \(n\) ja \(x\). Teisel real on \(n\) täisarvu \(p_1, p_2, \ldots, p_k\): esemete kaalud.
\(1 \le n \le 2 \cdot 10^5\), \(1 \le x \le 10^9\), \(1 \le p_i \le x\). [Allikas: CSES 1090]
Võibolla mõnele tuleb järgmine idee:
Sorteerime esemed kaalu järgi kasvavalt. Kui ese ei mahu "praegusesse" karpi, asetame karbi kõrvale ja võtame uue karbi. Vastasel korral paneme eseme praegusesse karpi.
See idee on implementeeritud allpool.
boxes.cpp
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
int main () {
int n, x;
cin >> n >> x;
vector<int> weights (n);
for (int i = 0; i < n; i++)
cin >> weights[i];
sort(weights.begin(), weights.end());
int box_count = 1; // kasutatud karpide arv
int cur_weight = 0; // praeguse karbi kogukaal
int cur_count = 0; // praegusesse karpi pandud asjade arv
for (int weight : weights) {
if (cur_weight + weight > x || cur_count == 2) {
// ese ei mahu praegusesse karpi
// alustame uut karpi
box_count++;
cur_weight = weight;
cur_count = 1;
} else {
cur_weight += weight;
cur_count++;
}
}
cout << box_count << '\n';
}
Tuleb aga välja, et see lahendus ei ole korrektne ja annab valesid vastuseid. Me ei tea, kas implementatsioonis on mingi viga või on kogu idee vale. Et veast paremini aru saada, kasutame ülaltoodud protsessi, et leida mõni test, kus see programm vale vastuse annab.
3.1. Lihtne ja naiivne programm
Esimene samm protsessis oli:
- Kirjuta hästi lihtne ja naiivne programm, mis ülesande kindlasti korrektselt ära lahendab. See võib olla ülimalt aeglane — me jooksutame koodi ainult väikestel testidel.
Üks võimalus on kasutada rekursiivset variantide läbivaatust. Kirjutame rekursiivse funktsiooni, mis proovib järjest vastusesse kõikvõimalikke paare või üksikuid esemeid lisada.
boxes_naive.cpp
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
void rec (const vector<int> &weights, vector<int> &used, int x, int cur_boxes, int &best_boxes) {
if (*min_element(used.begin(), used.end()) == 1) {
// base case - kõik esemed kasutatud
best_boxes = min(cur_boxes, best_boxes);
}
int n = weights.size();
// proovime läbi kõik karpidesse panemata esemete paarid
for (int i = 0; i < n; i++) {
if (used[i])
continue;
for (int j = i + 1; j < n; j++) {
if (used[j])
continue;
if (weights[i] + weights[j] <= x) {
used[i] = 1;
used[j] = 1;
rec(weights, used, x, cur_boxes + 1, best_boxes);
used[i] = 0;
used[j] = 0;
}
}
}
// ...ja üksikud esemed
for (int i = 0; i < n; i++) {
if (used[i])
continue;
used[i] = 1;
rec(weights, used, x, cur_boxes + 1, best_boxes);
used[i] = 0;
}
}
int main () {
int n, x;
cin >> n >> x;
vector<int> weights (n);
for (int i = 0; i < n; i++)
cin >> weights[i];
vector<int> used (n, 0);
int best_boxes = n;
rec(weights, used, x, 0, best_boxes);
cout << best_boxes << '\n';
}
See programm tuli küll pikem kui algne boxes.cpp
, aga see polegi nii oluline.
Oluline on see, et:
- programm on "kontseptuaalselt" lihtne (selliseid rekursiivseid lahendusi on palju ja nende kirjutamine on ära õpitav);
- programm ei tee mitte mingisuguseid eelduseid: proovib absoluutselt kõik variandid läbi.
3.2. Testigeneraator
Teine samm meie protsessis on:
- Kirjuta teine programm, mis genereerib ülesande kirjeldusele vastavaid teste.
Selleks võiks kirjutada programmi, mis genereerib juhuslikke teste. Ei ole vaja muretseda selle pärast, et kas juhuslikkus on ikka päris ühtlane vmt. Oluline on see, et see programm suudaks teoorias genereerida mistahes korrektse testi.
Hea on, kui sellele programmile saab ette anda mingid parameetrid, et vajaduse
korral saaks proovida erinevate suurustega teste. Neid parameetreid võib anda
edasi erinevatel viisidel, aga väga mugav on anda neid käsurea kaudu. Selleks
tuleb C++-s main
-funktsioonile lisada parameetrid (int argc, char **argv)
.
Pythoni vaste sellele on kasutada sys.argv
. Antud juhul on loogilised
parameetrid ülesande tekstis olevad \(n\) ja \(x\).
boxes_gen.cpp
#include <iostream>
using namespace std;
int main (int, char **argv) {
// esimene parameeter: juhuslikkuse seed
// seda on mugav parameetrina ette anda, et vajaduse korral sama testi taastoota
srand(atoi(argv[1]));
// teine parameeter: ülesande teksti n
int n = atoi(argv[2]);
// kolmas parameeter: ülesande teksti x
int x = atoi(argv[3]);
cout << n << " " << x << '\n';
for (int i = 0; i < n; i++) {
// iga eseme kaal on arv 1...x
// genereerime selle juhuslikult
// rand on üldiselt problemaatiline, aga antud juhul see ei loe
// meid ei huvita, kas juhuslikkus on ühtlane, eesmärk on lihtsalt
// proovida paljusid erinevaid teste.
// kui see ikkagi kripeldama jääb, võib kasutada mt19937
cout << 1 + rand() % x << " ";
}
cout << '\n';
}
Kui see kood kompileerida binaariks boxes_gen
, siis väljastab näiteks
./boxes_gen 123 4 5
testi, kus \(n = 4\), \(x = 5\) ja juhuslikkuse seed
on 123
.
3.3. Võrdlemine
Viimaks:
- Võrdle oma programmi ja lihtsa programmi väljundeid tuhandetel väikestel testidel.
Tuhandetel väikestel testidel jooksutamine ei ole muidugi asi, mida käsitsi teha saab. Selle programmi erinevatel testidel käivitamine on asi, mida saab suhteliselt lihtsate vahenditega automatiseerida.
Salvesta faili script.sh
kood
for i in `seq 1 100`; do
# prindib praeguse teksti indeksi
# mulle meeldib nii teha -- saab progressi näha
echo $i
./boxes_gen $i 5 4 > input.txt
./boxes < input.txt > output.txt
./boxes_naive < input.txt > answer.txt
diff output.txt answer.txt || break
done
Jooksuta käsureal chmod +x script.sh
, et anda endale õigused skripti käivitamiseks.
Seejärel käivita skript käsuga ./script.sh
.
Salvesta faili script.bat
kood
@echo off
for /l %%i in (1, 1, 100) do (
echo %%i
boxes_gen.exe %%i 5 4 > input.txt
boxes.exe < input.txt > output.txt
boxes_naive.exe < input.txt > answer.txt
fc output.txt answer.txt || goto :out
)
:out
Käivita skript käsuga script.bat
.
Salvesta faili script.ps1
kood
foreach ($i in 1..100) {
echo $i;
./boxes_gen $i 6 4 > input.txt;
Get-Content input.txt | ./boxes > output.txt;
Get-Content input.txt | ./boxes_naive > answer.txt;
fc.exe output.txt answer.txt;
if ($LASTEXITCODE) {
break;
}
}
Käivita skript käsuga ./script.ps1
.
Skriptide käivitamine võib olla välja lülitatud. Kui on, siis saab seda sisse lülitada käsuga (vaja administraatori õiguseid)
Set-ExecutionPolicy -ExecutionPolicy Unrestricted
Süntaks võib näida veider, aga pisut edasi vaadates näeme, et tegu on tavalise
for
-tsükliga. Tsükkel käib läbi kõik täisarvud \(1 \ldots 100\). Iga i
kohta
käivitatakse programm boxes_gen
argumentidega i 5 4
, väljund suunatakse faili
input.txt
. Seejärel käivitatakse mõlemad lahendusprogrammid sisendiga
input.txt
; väljund suunatakse vastavalt failidesse output.txt
ja answer.txt
.
Viimane käsk (diff
või fc
) võrdleb omavahel faile output.txt
ja answer.txt
.
Kui need on erinevad, siis käivitub ||
-le järgnev break
või goto :out
,
mis väljuvad tsüklist.
Mul leidis skript erinevuse viiendal katsel. Test on järgmine:
5 4
4 2 3 1 1
See on tõepoolest kontratest — ahne lahendus boxes.cpp
leiab selles tekstis
tükelduse [[1, 1], [2], [3], [4]]
, seevastu üks võimalik optimaalne paigutus
oleks [[1, 2], [1, 3], [4]]
.
Seda meetodit võib kasutada erinevatel loomingulistel viisidel. Näiteks kui
kahtlustad, et programmis on määramatus, võib seda kombineerida ülaltoodud
-fsanitize=address
lipuga. Sel juhul pole naiivset lahendust vaja: skript
vaatab läbi erinevaid teste ja kontrollib, ega ühegi jooksutamisel määramatust
ei teki.
g++ program.cpp -fsanitize=address -o program
for i in `seq 1 100`; do
echo $i
./program < input.txt > output.txt || break
done
Määramatuse esinemise korral väljub programm veaga, seetõttu käivitub ||
järel
olev break
või goto :out
.
4. Tekstipõhine debugger gdb
Programm program
debugimiseks käivita käsk gdb program
. Üldpõhimõtted on samad,
mis IDE-s olevas graafilises debuggeris, aga kõik on tekstipõhine.
Programmi käivitamiseks on käsk run
ehk r
. Seda saab kombineerida käsureaargumentide
ja isegi sisendi suunamisega, näiteks r 12 34 5 < in.txt
.
Breakpoint ehk katkepunkti lisamiseks on käsk break <rea number>
ehk
b <rea number>
. Mitmest failist koosneva programmi korral kasuta
break failinimi:<rea number>
. Katkepunkti eemaldamiseks on käsk
delete <katkepunkti number>
ehk d <katkepunkti number>
.
=
Käsk backtrace
ehk bt
trükib ekraanile praeguse stack trace ehk pinujälje.
Mööda pinujälge üles ja alla liikumiseks on frame <frame number>
ehk f
.
=
Käsk print <muutuja nimi>
ehk p <muutuja nimi>
trükib ekraanile vastava muutuja
väärtuse.
=
Käsk next
ehk n
liigub programmis ühe sammu võrra edasi (ilma funktsioonidesse
sisse laskuma). Laskumisega versioon sellest on step
ehk s
.
continue
ehk c
jätkab programmi tööd järgmise katkepunktini.
=
=
=
Debuggerist väljumiseks on käsk quit
ehk q
.