Cvičení 2: Kreslení obrazců

V tomto cvičení již začneme programovat v jazyce C.

Připomenutí z minulého cvičení

V minulém cvičení jsme si zkusili vytvoření prvního programu, který vypisuje řetězec Hello World! na standardní výstup.

#include <stdio.h>

/* Declaration of the main function, returns an integer, doesn't accept any arguments */
int main(void)
{ /* Block begins here */

    printf("Hello World!\n"); /* Call of printf(3) to print formatted string */
    return 0; /* Ends the execution of the function and returns a value */

} /* Block ends here -- definition of the function */

V tomto programu se objevují následující prvky:

  • #include direktiva preprocesoru, která vám zpřístupní funkce definované v hlavičkovém souboru,

  • stdio.h hlavičkový soubor, v němž se nacházejí funkce pro práci se vstupem a výstupem,

  • int datový typ definující celé číslo,

  • main jméno funkce – v případě jazyka C je nezbytné, aby existovala právě jednou pro každý spustitelný program,

  • (void) vstupní argumenty funkce main, v tomto případě žádné nejsou,

  • {} složené závorky definují blok, v tomto případě tělo celé funkce main,

  • printf("Hello World!\n") volání funkce, která vypisuje na standardní výstup předaný formátovací řetězec a další argumenty,

  • return ukončuje průběh funkce a vrátí volajícímu specifikovaný návratový kód, který musí být typu návratové hodnoty funkce. V případě funkce main se návratová hodnota propaguje do volajícího shellu jako návratová hodnota procesu.

Funkce scanf(3)

Před samotnými úkoly si připomeňme minulé cvičení a formátovací značky pro funkci printf(3). Tyto značky jsou univerzální pro celou standardní knihovnu jazyka C a jsou používané i ve funkcích pro načítání vstupu. Jednou z takových funkcí je funkce scanf(3), jejíž prototyp je velice podobný funkci printf(3)

int scanf(char *fmt_str, ...);
  • fmt_str je formátovací řetězec (prozatím budeme řetězec uvažovat jako sekvenci znaků uzavřených v dvojitých uvozovkách "), který říká, jaké elementy mají být načteny ze standardního vstupu,

  • …​ je výpustka (ellipsis), která značí libovolný počet dalších argumentů libovolného typu (typ se odvodí z formátovacího řetězce),

  • návratová hodnota je počet úspěšných konverzí, tedy počet úspěšně zpracovaných formátovacích značek z řetězce fmt_str.

Funkce scanf(3) se následně požívá stejně jako funkce printf(3), tedy jako první argument jí je předán formátovací řetězec a následně další proměnné, které budou naplněny hodnotami načtenými ze standardního vstupu. Pokud například budeme chtít načíst číslo, bude volání vypadat následovně

int x = 0;
scanf("%d", &x);

Po tomto volání bude v proměnné x uloženo číslo přečtené ze standardního vstupu, které bylo zadáno v dekadickém formátu. Nicméně, toto volání předpokládá, že funkce scanf(3) neselže. Abychom ověřili, že se čtení podařilo, musíme ověřit návratovou hodnotu funkce scanf(3).

int x = 0;
if (scanf("%d", &x) != 1) {
    printf("Read failed\n");
}

Pro toto cvičení budeme uvažovat pouze dvě formátovací značky:

  • %d načtení celého čísla v dekadickém zápisu,

  • %c načtení právě jednoho znaku.

Pro načtení znaku je potřeba ve formátovacím řetězci správně použít znak mezery před specifikací formátovací značky. Pokud bychom zadali formátovací řetězec "%c", tak funkce scanf(3) načte první nepřečtený znak na standardním vstupu, což ovšem bude většinou nějaký bílý znak (mezera, tabulátor, zalomení řádku).

Abychom tomuto chování předešli, budeme při načítání znaku zadávat formátovací řetězec jako " %c", které je funkcí scanf(3) interpretováno jako, přeskoč všechny bílé znaky a přečti další znak (tedy nutně nebílý).

Všimněte si znaku ampersand & před proměnnou, do které chceme načtenou hodnotu uložit. Abychom nemuseli složitě zabrušovat do tajů jazyka C, nebudeme prozatím tento koncept vysvětlovat, ale upozorníme, že tento operátor je potřeba přidávat před proměnnou pouze v případě funkce scanf(3), pokud byste použili & při volání funkce printf(3), bude váš program vypisovat nesmysly. Zároveň, pokud zapomenete použít & při volání scanf(3), může nastat jedna nebo více z následujících možností

  • Kompilátor toto chování odhalí a vypíše vám warning.

  • Ukládání hodnoty selže a vy skončíte s původní hodnotou v proměnné (která může být nedefinovaná).

  • Váš program rovnou v místě volání zkolabuje na základě přijetí signálu (proces bude zabit).

Pokud vám ale ampersand stále leží v hlavě, můžete si zabrousit na konec tohoto cvičení, kde je tento operátor rámcově vysvětlen. Nicméně vám doporučujeme vyhnout se cestování v čase a počkat dva týdny, kdy bude tento koncept řádně vysvětlen na přednášce.

Úkol 1. Vykreslení čáry

Od tohoto úkolu dále budeme pracovat v souboru drawer.c, pokud zadání neřekne jinak.

Vaším úkolem bude napsat funkci

void draw_line(int length);

která na standardní výstup vypíše length znaků #.

  • Pomocí cyklu while vypisujte, dokud length != 0, a v každé iteraci snižte length.

  • Vypsat znak můžete například pomocí funkce putchar(3), která přijímá právě jeden znak typu char.

  • Po vypsání křížků vypíše znak '\n', tedy znak konce řádku.

Tuto funkci zkuste zavolat z funkce main() několikrát s různými argumenty. Jak se bude funkce chovat, pokud jí předáte záporné číslo?

V případě zacyklení programu je možné v linuxovém terminálu program ukončit pomocí kombinace Ctrl + C.

Úkol 2. Testování

Nedílnou součástí programování je také testování. Umožňuje vám specifikovat očekávané chování vašeho programu a taky zachycovat chyby, které můžete prostou nepozorností při úpravách kódu vytvořit.

V předmětu PB071 budeme používat knihovnu CUT. Začneme jednoduchými testy, které budou kontrolovat standardní výstup vašeho programu. Pro tento účel budeme používat makro ASSERT_FILE(file, expected), které zkontroluje zdali soubor file obsahuje expected. V našem případě budeme kontrolovat obsah „speciálního“ souboru stdout, tedy standardní výstup programu. Příklad použití:

ASSERT_FILE(stdout, "Hello World!\n");

Jestli váš program (nebo volání funkcí v testu) vypíše na výstup Hello World! následován znakem nového řádku, tak test projde, jinak selže.

Pro testování funkce, která čte vstup, se může hodit taky makro INPUT_STRING(string), které simuluje vaše psaní do terminálu. Pro testování funkce main() neboli celého programu však doporučujeme sáhnout pro jiném nástroji. Jeden z takových nástrojů, CLI, budete mít k dispozici i v domácích úkolech.

Několik jednoduchých testů můžete vidět v připravené kostře cvičení v souboru tests.c. Vaším úkolem je otestovat další případ chování funkce draw_line(), který je poněkud zajímavější, a to zápornou vstupní délku. Jak by se funkce v takovém případě měla chovat? Jestli váš test neprošel, je nutné funkci opravit. Jak na to se podíváme v dalším úkolu.

Úkol 3. První vstup

Pokud již máte napsanou funkci draw_line(), můžeme nyní náš program nechat spolupracovat s uživatelem. Vaším úkolem bude upravit funkci main() tak, aby načetla celé číslo ze vstupu a následně toto číslo použila jako argument funkce draw_line(). Protože se jedná o uživatelský vstup, je potřeba ošetřit jeho validitu, aby určité vstupy nemohly způsobit například pád programu nebo nevalidní výsledek:

  • Použijte funkci scanf(3) a načtené číslo předejte funkci draw_line().

  • Rozšiřte funkci draw_line() o kontrolu validity vstupu.

  • Použijte konstrukci if (condition) { commands; } pro ověření validity.

  • Vstup je nevalidní, pokud je menší než 0, řádek nulové délky validní je.

Výstup vašeho programu by měl vypadat takto:

$ ./drawer
10
##########

kde první řádek specifikuje spuštění programu, druhý načtení čísla a třetí výstup funkce draw_line().

Úkol 4. Bitové operace

Součástí nízkoúrovňového programování jsou taky bitové operace, které vám mohou některé činnosti ulehčit (třeba i implementovat jednoduchou množinu bez potřeby pokročilejších datových struktur).

V jazyku C máte k dispozici následující bitové operace (příklady používají x = 3 (BIN: 11) a y = 10 (BIN: 1010); zároveň předpokládají, že x a y jsou stejného celočíselného typu o velikosti 4B):

Table 1. Přehled bitových operací
Operace Operátor Příklad

bitový součin (AND)

&

(x & y) == 2 // BIN: (11 & 1010) = 10

bitový součet (OR)

|

(x | y) == 11 // BIN: (11 | 1010) = 1011

XOR

^

(x ^ y) == 9 // BIN: (11 ^ 1010) = 1001

posun doprava

>>

(y >> 1) == 5 // BIN: (1010 >> 1) = 101

posun doleva

<<

(x << 1) == 6 // BIN: (11 << 1) = 110

inverze

~

(~x) == 0xFFFFFFFC // BIN: (~11) = 1111…1100

Určitě jste již někdy viděli binární hodiny, vaším úkolem bude napsat funkci

void draw_time(int hours, int minutes);

která přijme čas (v hodinách a minutách) a vypíše jej ve formě binárních hodin. Formát je ponechán na vás. Ukázka níže vypisuje hodiny a minuty v celku, v odkazu výše si můžete všimnout i hodin, které vypisují jednotky a desítky v hodinách (resp. minutách) odděleně.

Výstup vaší funkce může vypadat třeba nasledovně (pro účely testování vám dáváme k disposici více časů):

06:32   ..##.   #.....
07:30   ..###   .####.
13:37   .##.#   #..#.#
17:15   #...#   ..####
20:16   #.#..   .#....
23:57   #.###   ###..#
03:14   ...##   ..###.

Při kontrole validity argumentů předaných do funkce můžete být v pokušení psát kód, který vypadá třeba takto:

if (hours >= 0) {
    if (hours < 24) {
        if (minutes >= 0) {
            if (minutes < 60) {
                // happy path when all arguments are correct
            }
        }
    }
}

Kdyby jste navíc chtěli ošetřovat jednotlivé chyby, tak budete muset přidat spoustu else větví. Předchozí ukázku vylepšíme nasledujícím způsobem:

  • hodiny a minuty zkontrolujeme odděleně a obě hranice intervalů zároveň

  • u každé kontroly použijeme early return

Takto dostaneme kód, který vypadá následovně:

if (hours < 0 || hours > 23) {
    // TODO: complain to the user
    // ---
    // At this point we know that we got an invalid argument and it doesn't make
    // sense to do anything else, so we can just `return` and “call it a day”
    return;
}

if (minutes < 0 || minutes > 59) {
    // TODO: complain to the user
    return;
}

// happy path when all arguments are correct

Můžete vidět, že kód je více čitelný a zbavili jsme se zbytečného odsazení.

Úkol 5. Výprava do nové dimenze

Vaším úkolem bude vytvořit funkci:

void draw_square(int size);

Tato funkce vykreslí čtverec o velikosti strany size.

  • Kvůli velikosti fontu v terminálech, budeme jedno políčko čtverce počítat jako dva znaky. Pokud bychom použili jeden, bude náš čtverec spíše obdélníkového tvaru. Tedy výpis jednoho políčka provedeme jako putchar('#'); putchar('#');.

  • Výsledný obrazec tedy bude mít na každém řádku 2 * size křížků # a size řádků.

  • Stále platí, že čtverec se zápornou velikostí strany nelze vykreslit.

  • Ve funkci main() upravte volání z draw_line() na draw_square().

  • Pro vykreslení čtverce bude vhodné použít cyklus for.

Program bude po implementaci vypisovat:

$ ./drawer
5
##########
##########
##########
##########
##########

Úkol 6. Obdélník

Nyní svoji implementaci zobecníme a rozšíříme na kreslení obdélníků.

void draw_rectangle(int a, int b, char fill);
  • Vykreslí obdélník o délkách stran a a b.

  • a značí délku strany odpovídající řádku.

  • b značí délku strany odpovídající sloupci.

  • fill je znak, kterým bude obdélník nakreslený.

  • Upravte funkci main(), tak aby nejdříve načítala dvě čísla a následně načetla jeden znak.

  • Při načítání znaku si dejte pozor na přidání mezery do formátovacího řetězce.

  • Ve funkci main() upravte volání z draw_square() na draw_rectangle().

Program bude po implementaci vypisovat:

$ ./drawer
5
3
$
$$$$$
$$$$$
$$$$$

Bonusový úkol 1. — Kruh

Jako první bonusový úkol implementujeme vykreslení kruhu do terminálu.

void draw_circle(int radius, char fill, char space);

Protože kruh vám nevyplní místa v rozích, bude potřeba přidat další znak, který označí, co je na výsledném obrázku prázdným místem.

  • radius značí poloměr kruhu.

  • Obrázek tedy bude vysoký 2 * radius řádek.

  • Šířka obrázku bude 4 * radius znaků.

  • Stejně jako u čtverce použijeme jako jedno políčko dva znaky.

  • Při vykreslování musíte vypočítat, zda na dané souřadnici [x, y] bude kruh, nebo prázdné místo.

  • Pro připomenutí analytické geometrie: středová rovnice kružnice má tvar $(x-x_0)^2 + (y - y_0)^2 = r^2$, kde $x_0$ a $y_0$ jsou souřadnicemi středu kružnice.

  • Kruh je množina bodů, jejichž vzdálenost je od obepínající kružnice menší nebo rovna poloměru.

  • Upravte main(), tak aby načítal nejdříve jedno číslo a potom dva znaky.

  • I zde bude potřeba dát pozor na správné použití mezery před formátovací značkou

  • Ve funkci main() upravte volání z draw_rectangle() na draw_circle().

  • Protože to, co funkce vykreslí, se může lišit na základě podmínek, zkuste postupně nahrazovat porovnání < a <= a nalezněte nejlepší kombinaci, aby váš výtvor vypadal co nejpodobněji kruhu.

Váš program by měl vypsat:

$ ./drawer
10
#
^
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
^^^^^^^^^^^^################^^^^^^^^^^^^
^^^^^^^^########################^^^^^^^^
^^^^^^############################^^^^^^
^^^^################################^^^^
^^^^################################^^^^
^^####################################^^
^^####################################^^
^^####################################^^
^^####################################^^
^^####################################^^
^^####################################^^
^^####################################^^
^^####################################^^
^^^^################################^^^^
^^^^################################^^^^
^^^^^^############################^^^^^^
^^^^^^^^########################^^^^^^^^
^^^^^^^^^^^^################^^^^^^^^^^^^
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Není od vás vyžadováno, aby jste zreplikovali daný výstup 1:1 vůči vzorovému výstupu, přibližné řešení, z kterého je na pohled možné říct, že se jedná o kruh je zcela postačující.

Bonusový úkol 2. — Elipsa

Jako druhý bonusový úkol vytvořte funkci

void draw_ellipse(int a_axis, int b_axis, char fill, char space);

která vykreslí na standardní výstup elipsu, kde

  • a_axis určuje šířku poloosy na ose x.

  • b_axis určuje šířku poloosy na ose y.

  • fill je znak, kterým bude elipsa vyplněna.

  • space je znak použitý pro prázdné místo.

  • Stejně jako v případě kruhu, nelze vykreslit elipsu se zápornými poloosami.

  • Pokud je vstup nevalidní, funkce vypíše chybovou hlášku a ukončí se.

  • Podobně jako u kruhu, připomeňme si středovou rovnici elipsy $\left(\frac{x-x_0}{a}\right)^2 + \left(\frac{y - y_0}{b}\right)^2 = 1$ , kde

    • $x$ a $y$ jsou souřadnice bodu na elipse,

    • $x_0$ a $y_0$ jsou souřadnice středu elipsy,

    • $a$ je délka poloosy na ose x,

    • $b$ je délka poloosy na ose y.

Po implementaci funkce draw_ellipse() upravte funkci main() tak, aby akceptovala na vstupu

  • celé číslo a_axis,

  • celé číslo b_axis,

  • znak pro výplň,

  • znak pro mezeru.

Výstup by měl vypadat následovně:

$ ./drawer
15
10
#
^
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
^^^^^^^^^^^^^^^^^^##########################^^^^^^^^^^^^^^^^^^
^^^^^^^^^^^^^^##################################^^^^^^^^^^^^^^
^^^^^^^^^^##########################################^^^^^^^^^^
^^^^^^^^##############################################^^^^^^^^
^^^^^^##################################################^^^^^^
^^^^######################################################^^^^
^^##########################################################^^
^^##########################################################^^
^^##########################################################^^
^^##########################################################^^
^^##########################################################^^
^^##########################################################^^
^^##########################################################^^
^^^^######################################################^^^^
^^^^^^##################################################^^^^^^
^^^^^^^^##############################################^^^^^^^^
^^^^^^^^^^##########################################^^^^^^^^^^
^^^^^^^^^^^^^^##################################^^^^^^^^^^^^^^
^^^^^^^^^^^^^^^^^^##########################^^^^^^^^^^^^^^^^^^
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Ačkoliv vzorec pro výpočet elipsy obsahuje podíl, zkuste se zamyslet, zda by se tento výpočet nedal přepsat do celočíselné aritmetiky.

Doplňkové informace: Aneb cesta do budoucnosti a zase zpátky

Nechci se do toho zabrušovat, abych se pak z toho nemusel nějak vybrušovat a neřekl něco špatně.

— Miloslav Rozner
Poslanec parlamentu České Republiky

Proč píšeme ve funkci scanf(3) ampersand před proměnnou?

Při volání funkce zadáváme seznam argumentů, které jsou následně funkcí využity. Nicméně v jazyce C se předání argumentu provede kopií, tedy pokud například zavoláme funkci draw_line() následujícím způsobem

int k = 10;
draw_line(k);

tak je hodnota proměnné k překopírována do proměnné size (vstupní argumenty jsou proměnné) existující pouze po dobu běhu funkce draw_line(). Z toho můžete odvodit, že pokud bychom funkci scanf(3) předali proměnnou jako takovou, potom bychom ji vlastně předali hodnotu, která je zcela irelevantní.

Operátor & v tomto kontextu vrací adresu, na které je v paměti proměnná uložena. Pokud tedy zapíšeme

int k = 10;
scanf("%d", &k);

tak je funkci scanf(3) předána adresa místo hodnoty, což je funkcí očekáváno, a scanf(3) po přečtení hodnoty ze standardního vstupu zapíše tuto hodnotu přímo na předanou adresu. Proto, pokud při volání scanf(3) zapomenete před proměnnou použít &, vyhodnotí váš program zadaný argument jako adresu v paměti a pokusí se na ní zapsat, což může způsobit i pád programu.