Vítejte v předmětu PB111, kde se budeme zabývat modelem výpočtu,
který popisuje fungování běžných počítačů. Tato skripta podávají
teoretický pohled na věc a slouží jako předloha pro přednášky.
Tento kurz předpokládá jen minimum znalostí – je ale velmi důležité,
abyste měli zvládnutou látku předmětu IB111. Znalosti z předmětů
PB150 nebo PB151 o základech fungování počítačů budou jistě výhodou.
Překvapivě důležitým požadavkem tohoto předmětu je schopnost
soustředit se na psaný text (to platí jak pro teoretickou, tak
praktickou část předmětu).
Studijní materiály tohoto předmětu jsou rozděleny do dvou částí –
tyto poznámky jsou ta více teoretická, která se váže k přednášce.
Praktická část předmětu pak používá sbírku příkladů, která obsahuje
krom samotných úloh také řadu prakticky zaměřených ukázek a další
materiál spíše referenčního charakteru.
Tento předmět je ukončen zkouškou. Je-li pro Vás předmět
nepovinný, můžete si jej zapsat také na zápočet – v takovém případě
budete hodnoceni pouze ze semestrální práce. Přesný popis hodnocení
předmětu a zejména bodování naleznete v kapitole A sbírky.
Kurz je rozdělen na 3 velké tematické celky. První měsíc se budeme
zabývat převážně tím, jak probíhá výpočet na úrovni procesoru –
popíšeme si jaké prostředky a operace nám procesory nabízí a jak nám
tento velmi jednoduchý model umožňuje spouštět libovolně složité
programy. Naučíme se zejména jak se na úrovni stroje realizují
standardní prvky jazyků vyšší úrovně.1
Druhý blok se bude zabývat organizací paměti. V kapitolách 5–8
probereme základní stavební prvky datových struktur – pole a
ukazatele – a základy dynamické alokace paměti.
V posledním bloku se pak budeme blíže zabývat konkrétními datovými
strukturami: dynamické pole, binární halda, hašovací tabulka a
vyhledávací strom.2 Zejména se zaměříme na jejich nízkoúrovňovou
realizaci za pomoci znalostí a dovedností nabytých v prvních dvou
blocích.
Nebudeme se zde ovšem zabývat interakcí s vnějším světem ani souběžností – tato témata spadají do kurzu PB152 Operační systémy. Pro účely tohoto kurzu pracujeme s izolovaným sekvenčním počítačem který má pouze výpočetní jednotku (procesor) a paměť.
Tato kapitola předchází prvnímu bloku a zavádí pojmy a koncepty,
které budeme potřebovat během celého semestru. Bude-li některý hrát
v pozdější přednášce obzvláště důležitou roli, jeho definici si
připomeneme. Jinak ale budeme v dalším předpokládat, že tyto pojmy
znáte.
Počítač je složitý elektronický stroj – vnitřní detaily jeho
fungování jsou mimo náš současný obzor a zejména bychom se jimi
radši nezabývali. Díky abstrakci se ale můžeme na počítač dívat
jako na relativně jednoduché výpočetní zařízení, které má jednotné
vnější chování – bez ohledu na to, kdo vyrobil paměťové moduly
nebo procesor, jaké tento obsahuje tranzistory, jaké je chemické
složení polovodičů v těchto tranzistorech, atd.
Obecně používanou abstrakcí pro počítač je tzv. von Neumanova
architektura3, která má několik modulů, které lze rozdělit do dvou
kategorií. Tou první (a jedinou, kterou se budeme v tomto předmětu
zabývat) jsou základní výpočetní zdroje, totiž:
výpočetní jednotka (procesor, CPU, procesorové jádro, atp.) je
zařízení, které vykonává instrukce (elementární příkazy, pomocí
kterých počítač programujeme),
operační paměť (obvykle jednoduše „paměť“, případně RAM,
pracovní paměť) je zařízení, které si pamatuje data (čísla), se
kterými program při svém výpočtu pracuje, a které je schopno tato
data předávat výpočetní jednotce a na její pokyn je měnit.
Instrukce, které výpočetní jednotka vykonává, jsou uloženy, podobně
jako pracovní data programu, v operační paměti. Operační paměť je
adresovaná: to znamená, že je složena z očíslovaných buněk, kde
každá buňka si pamatuje jeden bajt (číslo v rozsahu 0 až 255).
Buňky jsou očíslovány po sobě jdoucími celými čísly, obvykle
počítáno od nuly. Instrukce mohou vyžádat načtení čísla z libovolně
vypočtené adresy4 – tím se paměť liší od registrů, které mají
pevná jména (program nemůže „spočítat“ které dva registry má
sečíst). Jedná se o podobný rozdíl (opět nikoliv náhodou), jako mezi
celočíselnou proměnnou a seznamem.5
Je také obvyklé, že přístup do registrů je mnohem rychlejší než do paměti. To je pro nás v tuto chvíli celkem nepodstatné – budeme tedy pracovat s abstrakcí, kde každá instrukce trvá stejně dlouho, bez ohledu na to, co počítá, nebo jestli přistupuje do paměti.
Možná z architektury x86 znáte např. registry al, ah, ax, eax, rax, které se „překrývají“. To lze ale chápat tak, že skutečný registr je pouze rax (resp. eax na 32b procesorech, ax na 16b procesorech) a ty ostatní jsou virtuální: „syntaktické zkratky“ pro extrakci příslušných bitů skutečného registru. Vztah mezi „pojmenovaným“ (virtuálním) registrem a fyzickým registrem (klopnými obvody) je ostatně ve skutečnosti dost komplikovaný. Zde si vystačíme s abstrakcí, kde se registry nepřekrývají a nebudeme virtuální a fyzické registry rozlišovat.
V tomto předmětu vůbec neuvažujeme virtualizaci, nebudeme tedy ani rozlišovat virtuální a fyzické adresy. Z pohledu běžného (ne systémového) programu existuje jediný typ adres i v reálném světě.
Podobně jako paměť, registry slouží k ukládáni čísel – existují dva
klíčové rozdíly mezi registry a pamětí:6
pojmenování registru je pevnou součástí instrukce, kdežto
paměťovou adresu lze vypočítat (paměť lze indexovat, registry
nikoliv),
reprezentace čísla v registru je monolitická – registry
nejsou složené ze slabik, daný registr obsahuje celé slovo
(částečně důsledek předchozího bodu: registr lze pojmenovat
pouze jako celek).7
Příklad: Budeme-li potřebovat mluvit konkrétně, budeme používat
výpočetní stroj tiny, který má 16 aritmetických registrů rv,
l1 … l7, t1 … t6, bp a sp. Jak velká čísla lze do
registrů ukládat je opět vlastností konkrétního procesoru – aby se
nám dobře psala a četla, budeme používat 16b registry. Moderní
počítače mají často registry o šířce 64 bitů (někdy též 32, méně
jen u velmi malých počítačů).
Vyhrazený registr (programový čítač, angl. program counter, někdy
také instruction pointer, budeme jej označovat pc) pak obsahuje
adresu právě vykonávané instrukce.³ Tento registr rozhoduje o tom,
která instrukce se má vykonat, není do něj ale obvykle možné
zapisovat běžnými (aritmetickými, atp.) instrukcemi. K tomu jsou
určeny instrukce řízení toku, kterých hlavním efektem je právě změna
hodnoty programového čítače.
Z jednotlivých příkazů jazyka symbolických adres je nutné konkrétní instrukce nejprve vypočítat. Blíže je tento proces popsaný v sekci B.3 sbírky. Na rozdílu mezi instrukcí a příkazem jazyka symbolických adres sice nebudeme bazírovat, ale přesto je dobré mít ho na paměti.
Operace je obvykle určena operačním kódem: autoři instrukčních sad často volí nějakou pravidelnou strukturu čísel, které jednotlivé instrukce kódují – v takovém případě je operační kód ta část, která určuje operaci. Jiné části obvykle určují operandy (registry, se kterými se bude pracovat). Pozor: kódování instrukcí žádnou takovou strukturu dodržovat nemusí. A aby nebylo zmatku málo, někdy se operačním kódem myslí i kódování instrukce jako celku.
je elementární příkaz strojového kódu; to znamená:
elementární – je to nejmenší jednotka činnosti, kterou lze
procesoru zadat,
příkaz – instrukce řídí činnost procesoru, „přikazují“ mu
provedení nějaké akce.
Instrukcí budeme nazývat pouze celek, který obsahuje veškeré
informace potřebné k provedení konkrétních akcí (zejména udává
operaci, která se má provést, a konkrétní registry, se kterými
se bude pracovat a také konkrétní přímé operandy).
Instrukcí je pouze konečně mnoho, je tedy zejména možné je
očíslovat (nebo jinak konečně kódovat, např. do sekvencí bajtů).
Každé takové číslo (kódování) popisuje konkrétní akci, kterou může
procesor provést.
Příklad: Instrukce z pohledu člověka vypadají například takto:
add l1 l2 → l1; jiná (i když podobná) instrukce je
add l1 l2 → rv; ještě jiná je
add l1 l3 → l1; atd.
Takto instrukce zapisujeme pro vlastní potřebu – tomuto zápisu
bychom mohli říkat mnemonický zápis strojového kódu, ale v dalším
nebudeme striktně zápis a podstatu strojového kódu odlišovat –
mezi číselným a mnemonickým zápisem je podobný vztah jako mezi
„7“, „VII“ nebo „sedm“. V počítači jsou instrukce reprezentovány
čísly (zakódovanými v paměti jako posloupnost bajtů):
add l1 l2 → l1 může odpovídat např. 0x31112000,
add l1 l2 → rv pak 0x31012000,
add l1 l3 → l1 zase 0x31113000, atp.
Pozor, mnemonický zápis není totéž co jazyk symbolických adres
(angl. assembly language). Používají sice velmi podobnou
syntaxi, ale v jazyce symbolických adres nezapisujeme nutně přímo
instrukce.8 To, co jsme zde nazvali mnemonickým zápisem, je
podmnožinou jazyka symbolických adres.
Striktně vzato tedy nemůžeme obecně mluvit o „instrukci add“ –
nejedná se o jedinou instrukci, ale o celou rodinu instrukcí.
Samozřejmě má ale smysl mluvit o takto příbuzných instrukcích nějak
souhrnně. Takovou rodinu instrukcí můžeme popsat pomocí společné
operace² (např. sčítání), která typicky určuje také počet a typ
operandů (registrů).
Procesor vykonává instrukce, čím realizuje výpočet.
Nejjednodušší třídou instrukcí jsou tzv. aritmetické a logické
instrukce (tedy ty, které provádí ALU – aritmeticko-logická
jednotka). Tím se myslí zejména:
aritmetika: sčítání, odečítání, násobení a dělení,
bitové operace: and, or, xor po bitech, bitové posuvy,
srovnání dvou hodnot (rovnost, nerovnost) – výsledek se uloží do
běžného registru nebo do stavového příznaku procesoru.
Další třídy instrukcí provádí složitější akce, které mění stav
paměti, nebo řídí průběh výpočtu:
již zmiňovaný přístup do paměti (ld, st),
řízení toku – rozhodnutí o tom, kterou instrukcí bude výpočet
pokračovat (zejména podmíněný skok, případně nepřímý skok9),
instrukce pro realizaci podprogramů zahrnují jak přístupy do
paměti, tak řízení toku: patří sem zejména
práce s hardwarovým zásobníkem (přesuny hodnot mezi zásobníkem
a registry),
instrukce pro aktivaci podprogramu (přímo nebo nepřímo),
instrukce pro návrat z podprogramu.
Instrukční sadu výpočetního stroje tiny, kterou budeme v tomto
předmětu používat, si popíšeme postupně, podle toho jak budeme nové
operace potřebovat při výkladu.
Programem budeme v tomto kurzu nazývat předpis, kterým je určen
nějaký výpočet. Tento předpis je možné zadat různým způsobem, my
budeme využívat dva „textové“ (určené pro zápis a čtení člověkem):
zápis v jazyce symbolických adres, nebo
zápis v jazyce C.
V obou případech se jedná o text, který je utvořen podle
specifických pravidel (dodržuje syntaxi daného jazyka) a kterého
význam je přesně určen sémantikou tohoto jazyka.
Sémantika zároveň určuje, co považujeme pro daný program za vstup
a výstup (výsledek). Pro jednoduchost (a trochu překvapivě i bez
újmy na obecnosti) se můžeme omezit na programy, které nemají žádný
vstup a jejich výstup je pouze „skončil bez chyby“ a „jiný výsledek“
(což může být „nikdy neskončí“ nebo „skončil chybou“).¹
Takto omezeným programům se často říká „uzavřené“ – všechny hodnoty,
které vstupují do výpočtu, jsou součástí programu a každý
(deterministický) program tak může mít jeden jediný výsledek.10
Tento typ programů velmi dobře znáte – tuto formu totiž měla každá příprava v předmětu IB111, a proto by Vás ani nemělo příliš překvapit, že i takto omezené programy mohou mít velmi bohaté vnitřní chování (průběh výpočtu).
Zároveň budeme za program považovat předpis zadaný jako strojový
kód – tento nebudeme nikdy přímo zapisovat, ale vznikne
automatickým překladem z některé výše uvedené formy. Klíčovou
vlastností překladu je, že zachovává sémantiku – původní a
přeložený program vypočtou pro pro stejné vstupy ten stejný
výsledek.
Abychom se vyhnuli komplikacím s ekvivalencí vstupů (vstupy jsou
v různých jazycích zadávány různě), zaměříme se na již zmiňované
„uzavřené“ programy.11
Překladem z jazyka A do jazyka B nazveme funkci (v matematickém
smyslu), která pro každý platný program v jazyce A určí
ekvivalentní program v jazyce B, přitom programy považujeme za
ekvivalentní, mají-li tentýž výsledek (který je jednoznačně dán
programem).12 Překladač je pak program, který tuto funkci realizuje.
Situace v „reálném světě“ je přirozeně mnohem komplikovanější, ale většina této komplikovanosti souvisí s definicí vstupu a výstupu, zejména v případech, kdy nedokážeme vstup a/nebo výstup jednoduše popsat. Za tento problém pak vděčíme souběžnosti (a s tím související komunikaci). V tomto předmětu se souběžností vůbec zabývat nebudeme. Na teoretické úrovni se ale souběžností zabývá kurz PB152 Operační systémy.
Jednoznačnou výhodou tohoto přístupu je, že nám v sémantice stačí určit, co pro daný program znamená „skončil bez chyby“. Zobecnění definice překladu na složitější formy programů navíc není nijak obtížné (máme-li k dispozici uspokojivou definici příslušného typu programu).
V této kapitole si představíme základní výpočetní model, který pak
budeme používat celý semestr. Realizovat jej bude výpočetní stroj
tiny, který je sice velmi jednoduchý (minimální implementace
v jazyce Python má přibližně 200 řádků kódu), ale umožní nám
spouštět libovolné programy napsané v jazyce C.13
Přesto, že v tomto předmětu budeme používat pouze podmnožinu jazyka C, není to na úkor obecnosti: tato podmnožina je dostatečně silná na to, aby do ní bylo možné přepsat libovolný program napsaný v plném ISO C99, a to včetně standardní knihovny. Také by bylo možné přímo jazyk ISO C99 překládat do strojového kódu stroje tiny.
Abychom se mohli o výpočtech přesně vyjadřovat (a také přesně
uvažovat), musíme si zavést vhodný model výpočtu – efektivně
matematický aparát, který nám umožní výpočet uchopit jako objekt.
Standardní metodou, jak výpočet popsat, je jako sled stavů
(konfigurací) nějakého abstraktního stroje a přechodů – akcí –
mezi nimi.
Protože tento předmět je zaměřený spíše prakticky (a pragmaticky),
chceme aby náš abstraktní výpočetní stroj co nejpřesněji odpovídal
skutečným počítačům. Zároveň se ale nechceme takříkajíc „utopit
v detailech“ – proto bude náš konkrétní výpočetní model určitým
kompromisem.
Většinu modelu a terminologie si půjčíme ze světa skutečných
počítačů, v několika ohledech si ale situaci zjednodušíme:
nebudeme se zabývat virtualizací a souběžností – náš „počítač“
bude najednou vykonávat jediný sekvenční program,
omezíme se na výpočty se 16bitovými hodnotami a na 16bitovou
adresaci – to nám umožní mít lepší přehled o obsahu paměti, a
zároveň nám velmi usnadní čtení adres a čísel obecně,
instrukční sada tiny je oproti těm reálným velmi malá
(obsahuje mnohem méně operací), jednodušší (jednotlivé operace
toho dělají méně najednou) a pravidelnější.
Jak jsme zmínili výše, důležitým aspektem výpočtu jsou stavy
výpočetního stroje. Stav stroje tiny má tři složky:14
obecné registry,
programový čítač,
paměť.
Přesněji řečeno, stavem myslíme konkrétní hodnoty „uložené“ v těchto
složkách. Pro ilustraci si na chvíli stroj ještě zmenšíme (na
velikost, která už není pro výpočty prakticky použitelná, ale která
se nám vejde na stránku). Přisoudíme mu pouze 2 obecné registry
(prozatím je nazveme r₁ a r₂), programový čítač pc a 12 bajtů
(slabik) paměti. Stavy takovéhoto vskutku mikroskopického
výpočetního stroje vypadají jako řádky této tabulky:
pc
r₁
r₂
paměť
0000
0000
0000
00 00 00 00 00 00 00 00 00 00 00 00
0004
0007
03ff
de ad de ad de ad de ad de ad de ad
0000
1007
7fff
00 00 00 00 00 00 00 de ad 00 co de
Samozřejmě zatím nevíme, jaký mají takovéto stavy význam – tím se
budeme zabývat za chvíli. Můžeme udělat ale jiné zajímavé pozorování
– totiž kolik různých stavů takový výpočetní stroj má. Protože máme
k dispozici pevný počet bitů, dva stavy se od sebe budou lišit
v případě, že se liší alespoň v jednom bitu. To zejména znamená, že
různých stavů bude konečný počet; velmi jednoduchá kombinatorická
úvaha nás pak dovede k výsledku, že různých stavů je – i pro
takto malinký stroj se jedná o velmi úctyhodný počet.
„Skutečný“ stroj tiny (ten, který budeme používat, resp.
programovat) má 16bitový programový čítač, 16 16bitových registrů a
64 KiB paměti, tzn. jeho stav obsahuje 65570 bajtů resp. 524560 bitů
informace. Různých stavů tedy existuje což je přibližně
. Pro srovnání, celkový počet baryonů (hlavně protonů a
neutronů) ve vesmíru se odhaduje na .
Velmi důležitou vlastností takto definovaného stroje je, že
libovolný stav přesně určuje celý následující výpočet – jinými
slovy, náš výpočetní model je deterministický. Chování tohoto typu
stroje je velmi jednoduché – je řízen sadou pravidel, podle které
určíme jak z daného stavu odvodíme ten následovný.
Výpočet budeme obvykle začínat ve stavu, kdy jsou všechny registry
nulové, ale obsah paměti nikoliv – počáteční obsah paměti budeme
nazývat programem. S každým programem se tak váže právě jeden
výpočet.15
To může vypadat na pohled velmi nerealisticky – všichni zřejmě máme zkušenost, kdy spuštění téhož programu (v neformálním smyslu) vede k různým výsledkům. To je způsobeno jednak tím, že reálné počítače nejsou deterministické (reagují na vnější události) a také tím, že v reálných situacích často nemáme počáteční stav zcela pod kontrolou a to co se nám jeví jako tentýž stav je ve skutečnosti mnoho různých, ale těžko odlišitelných, stavů.
konečný výpočet, tvořen konečnou posloupností po dvou různých
stavů, je obvyklá a žádoucí situace; může mít dvě varianty:
úspěšný výpočet který byl ukončen instrukcí halt, přitom
výsledek výpočtu jsme obvykle schopni odečíst z posledního
stavu,
neúspěšný výpočet, který byl ukončen chybou (viz také níže),
nekonečný výpočet, ve kterém se nějaká posloupnost stavů
nekonečněkrát opakuje – je složen z konečného prefixu (kde se
stavy neopakují) a nekonečné smyčky, celkově má tvar tzv. lasa.
Chyby, které mohou výpočet ukončit mohou být různých typů:
nepodařilo se dekódovat instrukci, tzn. na adrese uložené
v registru pc nezačíná platné kódování žádné instrukce,
při vykonání instrukce došlo k fatální chybě (typickým příkladem
je zde dělení nulou, případně neplatný přístup do paměti),
selhalo tvrzení (angl. assertion) realizované instrukcí
asrt – došlo k porušení programátorem určené vstupní nebo
výstupní podmínky nebo invariantu,
byla detekována sémanticky chybná operace, obvykle podle
anotací vložených překladačem – z pohledu stroje by výpočet mohl
bez problémů pokračovat, ale pravděpodobně by vedl k nesprávnému
výsledku – situace, která se podobá na selhané tvrzení
z předchozího bodu.
V dalším se budeme zabývat akcemi, které má stroj k dispozici.
Abychom mohli popsat jejich efekt, musíme si ale nejprve upřesnit
jak vypadají jednotlivé složky stavu a jaký mají význam.
Nyní se na jednotlivé složky stavu podíváme blíže. Stroj tiny má
16 „obecných“ a jeden „speciální“ registr.
Obecné registry mají mnemonické názvy, které ale na výpočet nemají
žádný vliv. Naznačují pouze typické použití (programy, které
vzniknou překladem z jazyka C se budou tohoto konvenčního použití
držet):
rv od return value je registr určený pro předávání návratové
hodnoty z podprogramu (více si o podprogramech povíme ve třetí
kapitole),
l1 až l7 jsou registry pro lokální proměnné a pro předávání
parametrů podprogramům (jak později uvidíme, mezi formálním
parametrem a lokální proměnnou není v jazyce C příliš velký
rozdíl),
t1 až t6 slouží pro dočasné hodnot – mezivýsledky, např. při
výpočtu složených výrazů – při výpočtu a + b + c budeme
potřebovat dočasně někam uložit výsledek a + b,
bp a sp opět souvisí s podprogramy, konkrétněji se správou
zásobníku.
Krom těchto 16 obecných registrů, které mohou být operandy libovolné
aritmetické instrukce (a zároveň také jejich cílovým registrem), má
stroj tiny ještě speciální registr
pc (program counter, programový čítač), který obsahuje adresu
ze které bude načtena, dekódována a provedena další instrukce.
Stroj tiny disponuje 64 KiB paměti. Tím se myslí, že tato paměť je
složená z buněk, přitom každá buňka je schopna uchovávat číslo
od 0 do 255. Tyto buňky jsou navíc očíslované (mají adresy).
S paměťovými buňkami lze pracovat použitím instrukcí ld a st
(více o nich ve čtvrté kapitole). Se kterou buňkou si přejeme
pracovat určuje hodnota uložená v registru, tzn. adresa zejména
může být výsledkem nějakého výpočtu. Všimněte si, že se jedná
o zcela zásadní rozdíl oproti registrům – se kterými registry daná
instrukce pracuje je její pevnou součástí.
Krom syntaxe – toho, jak instrukce zapisujeme, potažmo kódujeme,
nás bude zajímat jejich sémantika – význam. Co instrukce znamená
budeme zejména popisovat tím, jaký má efekt na stav.
Stav stroje jednoznačně určuje, jaká instrukce bude spuštěna – je to
ta, které kódování je uloženo na adrese pc (každá instrukce je
kódovaná do 4 bajtů, tzn. ve skutečnosti je uložena na adresách
pc, pc + 1, pc + 2 a pc + 3).
Řada instrukcí bude mít podobné efekty. Zejména každá instrukce,
která není instrukcí řízení toku, zvýší registr pc o 4, čím
způsobí, že jako další bude spuštěna instrukce na nejbližší vyšší
adrese.
Podobně obsahují téměř všechny instrukce nějaké operandy, které
určují, s jakými daty bude operace pracovat. Typicky budou operandy
identifikátory registrů. Počet a forma operandů se pro různé
operace bude lišit, ale ve všech případech spadají do těchto mezí:
součástí instrukce můžou být až 2 vstupní registry – operandy,
které určí, ze kterých registrů se mají načíst vstupní hodnoty,
instrukce může mít nejvýše jeden výstupní registr – operand,
který určí, do kterého registru bude zapsán výsledek,
instrukce může obsahovat nejvýše jeden přímý operand – 16bitové
slovo, které je přímo součástí instrukce, a které se použije jako
jedna ze vstupních hodnot (která to bude závisí od operace).
Instrukce stroje tiny mají velice jednoduché a pravidelné kódování
– je to zejména proto, abychom byli v případě potřeby schopni
instrukce dekódovat (alespoň přibližně) i „ručně“, z číselného
výpisu obsahu paměti. Také se tím značně zjednodušuje implementace
dekodéru (který máte k dispozici jako ukázku ve sbírce).
Instrukce jsou kódovány do dvou 16bitových slov, přitom:
horní slovo obsahuje:
8bitový kód operace v horní slabice,
dva registrové operandy ve spodní slabice – výstupní registr
v horní půlslabice a první vstupní registr v té spodní,
spodní slovo kóduje zbývající vstupní operand:
druhý vstupní registr (v nejvyšší půlslabice), nebo
přímý operand (využije celé spodní slovo).
Jak se dekódují operandy je určeno kódem operace, který je proto
potřeba dekódovat jako první.
Nejzákladnější operací, kterou můžeme v programu potřebovat, je
nastavení registru, a to buď na předem známou konstantu, nebo na
hodnotu aktuálně uloženou v některém jiném registru. K tomuto účelu
můžeme použít operace copy (kopie mezi registry) a put
(nastavení registru na konstantu).
Binárních operací je k dispozici celá řada – odpovídají běžným
aritmetickým a bitovým operacím, které z velké části již znáte.
Patří sem:16
aritmetika: sčítání add, odečítání sub, násobení mul,
dělení (sdiv, udiv, smod, umod – rozdíly mezi verzemi
s___ a u___ vysvětlíme později),
srovnání – výsledkem je hodnota 0 nebo 1: rovnost eq, nerovnost
ne, znaménkové porovnání slt, sgt, sle, sge,
neznaménkové porovnání ult, ugt, ule, uge,
bitové operace: logické and, or, xor a posuvy shl, shr,
sar (aritmetický).
Do registru pc není možné běžnými instrukcemi přímo zapisovat ani
z něj číst; efekt jmp addr je velmi podobný hypotetické instrukci
put addr → pc, ale protože je tento efekt velmi odlišný od všech
ostatních použití operace put, má svůj vlastní zápis.17
Naproti tomu operace jz a jnz se na žádnou další známou
instrukci nepodobají – jsou totiž podmíněné – jejich efekt se bude
lišit podle hodnoty operandu. Uvažme instrukci jz reg, addr – tato
instrukce se bude chovat jako jmp addr v případě, že je v registru
reg uložena nula. V případě opačném bude výpočet pokračovat
následující instrukcí. Jinými slovy:
jz reg, addr nastaví pc na hodnotu přímého operandu addr,
je-li v registru reg uložena nula,
jinak zvýší hodnotu pc o 4.
Operace jnz je pak analogická, liší se pouze inverzí podmínky
(skok se provede je-li registr nenulový).
Jak jsme již zmiňovali dříve, za výstup programu budeme brát pouze
formu jeho ukončení:
výpočet může být úspěšně dokončen, což program signalizuje
provedením instrukce halt, která nemá žádné operandy,
výpočet může skončit chybou – tuto lze signalizovat instrukcí
asrt reg, přitom chyba nastane pouze v případě, že v registru
reg je uložena nula (v opačném případě výpočet pokračuje další
instrukcí, tzn. registr pc se zvýší o 4) – jedná se o analogii
tvrzení assert jak ho znáte z jazyka Python,
výpočet může skončit jinou chybou (špatná instrukce, atp.) nebo
se může „zacyklit“ – neskončí nikdy (v praxi budeme vždy délku
výpočtu nějak uměle omezovat,18 „nikdy“ je totiž velmi dlouhá
doba).
Jak již bylo zmíněno dříve, stroj tiny může v principu zacyklení spolehlivě detekovat (je to díky tomu, že konfigurace stroje má pevnou velikost – existuje tak pouze konečně mnoho různých konfigurací). V praxi může být takový cyklus velmi dlouhý a tedy těžce odhalitelný.
V poslední části první kapitoly si ukážeme, jak ve strojovém kódu
zapsat standardní konstrukce řízení toku z vyšších programovacích
jazyků – podmíněný příkaz a cyklus. Protože jediný vyšší
programovací jazyk, který v tuto chvíli známe, je Python, budeme jej
prozatím využívat jako pseudokód. V pozdějších kapitolách pak budeme
používat jazyk C.
Jak již bylo zmíněno v kapitole B, překladem rozumíme zobrazení,
kterého vstupem je nějaký program , zatímco výstupem je nový
program (typicky v jiném jazyce), přitom ale platí, že výpočet
končí úspěchem právě když výpočet končí úspěchem.
Zobrazení je přitom definováno pouze pro platné vstupní programy.
Nyní jsme připraveni provést první náčrt toho, jak toto zobrazení
spočítáme (jinými slovy, jak funguje překladač). Z předchozího
studia19 víte, že výrazy (zejména aritmetické, ale i libovolné
jiné) můžeme reprezentovat pomocí stromů (ve smyslu datové
struktury). Stejný přístup lze použít i pro příkazy a další prvky
programovacích jazyků.
Jinak řečeno, zdrojové jazyky překladu mají typicky rekurzivní
strukturu, kterou lze zachytit (abstraktním) syntaktickým
stromem. Tento strom (angl. AST, z abstract syntax tree) je tak
přesnou reprezentací vstupního programu a budeme jej považovat za
startovní bod překladu.20
Jednoduchý překladač sestavuje strojový kód rekurzivně, podle
struktury vstupního stromu. To zejména znamená, že překlad nějakého
uzlu obdržíme vhodnou kombinací překladů jeho potomků.
V této kapitole si ukážeme pouze překlad uzlů, které odpovídají
příkazům řízení toku – if a while – abychom získali představu,
jaký je vztah mezi vstupním programem (tím, který typicky píšeme) a
strukturou výstupního strojového kódu. Kompletní algoritmus překladu
jazyka C21 vybudujeme postupně (naleznete jej vždy ve sbírce, ve
skriptech a přednášce vyzvedneme jen ty nejzajímavější části).
Skutečné překladače tento strom konstruují ze vstupního textu. Touto částí překladu – syntaktickou analýzou – se zde zabývat nebudeme. Předpokládejme, že naším vstupem je již hotový abstraktní syntaktický strom.
Tato kapitola se bude zabývat základními stavebními kameny jazyka C
– hodnotami, proměnnými, výrazy a příkazy. U každé výpočetní
konstrukce si zejména ukážeme, jak se abstraktní zápis na úrovni
jazyka C realizuje sekvencemi instrukcí konkrétního výpočetního
stroje.
V této části se budeme zabývat základními datovými prvky jazyka C
– připomeneme si pojmy jako hodnota, objekt, proměnná nebo typ,
které již znáte z jazyka Python, a zasadíme je do nového kontextu.
Stejně jako v jazyce Python, fundamentálním předmětem výpočtu je
v jazyce C hodnota – pro tuto chvíli se bude jednat o celé číslo
v nějakém pevném rozsahu, nicméně v pozdějších kapitolách se setkáme
i se složitějšími hodnotami.
Pozor, hodnota je abstraktní – hodnota není totéž co
reprezentace. Zejména nesmíme hodnotám přisuzovat vlastnosti
použité reprezentace. Zápisy 0x10, 16, šestnáct, XVI všechny
reprezentují XXX tutéž XXX hodnotu.
V standardní implementaci jazyka Python je každý objekt identifikovatelný adresou, na které je v paměti uložen. V jazyce C to ale neplatí, protože objekt nemusí mít adresu žádnou, nebo se jeho adresa může během výpočtu měnit.
Konceptuálně není výpočet ničím jiným, než mechanickou manipulací
hodnot použitím kompatibilních operací. Klasickým příkladem
operace je např. sčítání – vstupem jsou dvě celočíselné hodnoty a
výstupem je nová hodnota. Aby bylo možné výpočet provést, je nutné
si potřebné hodnoty pamatovat – při výpočtech ve středoškolské
matematice k tomu používáme typicky papír a tužku. Počítač k tomu
bude samozřejmě využívat nějaký typ elektronické paměti.
Přímá práce s pamětí a registry je pro větší programy značně
nepohodlná – musíme při programování neustále pamatovat, kde máme
uloženy které hodnoty (ve kterém registru nebo na jaké adrese),
navíc jména registrů nejsou příliš popisná a je jich omezený počet.
Z jazyka Python jsme zvyklí hodnoty uchovávat v proměnných. Vazba
mezi proměnnou a hodnotou ale není přímá – ani v Pythonu, ani v C.
Hodnoty jsou totiž invariantní, anonymní entity – nemají identitu,
ani schopnost se vnitřně měnit. Abychom obdrželi sémantiku, na
kterou jsme při programování intuitivně zvyklí, musí do hry vstoupit
ještě jeden prvek – objekt.
Hlavními vlastnostmi objektu jsou:
schopnost uchovávat, číst a měnit hodnotu (abstraktní entitu
programovacího jazyka),
identita:
můžeme od sebe rozlišit různé objekty i v případě, že zrovna
obsahují stejnou hodnotu,
jsme schopni určit, že se jedná o tentýž objekt, i když se
hodnota v něm uložená během výpočtu změnila.
Objekt je tak zobecněním paměťové buňky – má operace „přečti“ a
„ulož“ a jeho identita je obdobou adresy.
Objekty mají sice identitu, ale nemají jména – jejich identita je
více nebo méně abstraktní.¹ Abychom tedy mohli s objektem pracovat,
potřebujeme mu přiřadit jméno – a to je přesně úloha proměnné.
Proměnná je (v jazyce C) pojmenovaný objekt, přičemž její jméno
(identifikátor) má syntakticky omezený rozsah platnosti23 – přímo
ze zdrojového kódu umíme lehce identifikovat, ve kterých příkazech a
výrazech je použití tohoto jména přípustné, a případně ke které
deklaraci se váže. Vazba mezi jménem (proměnnou) a objektem je
pevná – vznikne při deklaraci proměnné a až do jejího zániku tuto
vazbu není lze měnit.24
Zde se objevuje důležitý rozdíl mezi C a Pythonem. Přiřazení v C, jak za chvíli uvidíme, značí zápis do objektu, kdežto v jazyce Python značí změnu vazby na jiný objekt.
Různé hodnoty mají různé vlastnosti a různé operace. Uvažme 16bitové
hodnoty bez znaménka a podobné (ale ne tytéž!)
16bitové hodnoty se znaménkem . Zřejmě:
, podobně ,
ale .
Je tedy potřeba podobné hodnoty rozlišovat – k tomu slouží typy.
Typy mají v programovacím jazyce dvě funkce:
určí, jaké operace jsou pro dané hodnoty přípustné,
je-li operace přípustná, typy mohou ovlivnit její význam –
např. existují dvě různé operace odečítání (viz výše) pro
16bitová čísla: znaménkové a neznaménkové.
Typ je vlastnost hodnoty, ale to neznamená, že je ke každé hodnotě
„fyzicky“ připojen její typ – řada programovacích jazyků, a C mezi
nimi, typovou informaci uchovává pouze v době překladu – ve
strojovém kódu bychom informaci o typech hledali marně.25 To
samozřejmě neznamená, že typy nemají pro výsledný strojový kód
důsledky – bude na nich třeba záviset, jestli se pro srovnání
použije operace slt nebo ult, atp.
Typy můžeme přisuzovat krom hodnot také objektům a skrze objekty
také proměnným. Je-li objekt nějakého typu, znamená to, že je
schopen uchovávat hodnoty pouze tohoto typu. Je-li proměnná nějakého
typu, znamená to, že je svázána s objektem tohoto typu.26
Tomuto konceptu se říká „vymazání typů“, angl. „type erasure“ – udržování informací o typech za běhu programu předstauje dodatečnou režii a je-li to možné, překladače se tomu vyhýbají.
Polymorfismus – schopnost objektu uchovávat různé typy hodnot – lze chápat např. tak, že takovému objektu přisoudíme součtový typ. V jazycích, kde je možné měnit vazbu mezi proměnnou a objektem může polymorfismus existovat jak na úrovni objektu (lze uložit různé typy hodnot) tak na úrovni proměnné (k jednomu jménu lze vázat různé typy objektů). To se ale nacházíme už mimo hranice tohoto předmětu.
Pozor – číselný literál musí být platnou hodnotou příslušného typu:
je-li typ int 16bitový, literál 0xffff je chybný (tak velké
číslo není možné reprezentovat). Naproti tomu, protože 0xffffu je
typu unsigned, je zde vše v pořádku.
Aritmetické, bitové, atp. operace vyžadují, aby byly operandy
stejného typu. Jazyk C zároveň nepodporuje operace na typech menších
než int resp. unsigned (pro nás to znamená, že „jednobajtová“
aritmetika neexistuje – všimněte si, že podobně neexistuje ani ve
výpočetním stroji).
Povýšení: typy s rozsahem, který je menší než rozsah typu int, se
nejprve zvětší na int.27 Po povýšení se pak najde společný typ –
je-li to možné, preferuje se znaménkový typ.28
Pozor: povýšení se dotkne i unárních operátorů. Máme-li signed
char x = 5;, bude hodnota výrazu -x typu int, nikoliv typu
signed char!
V naší omezené verzi jazyka to dopadne takto (všechny případy jsou
symetrické):
operand
operand
společný typ
signed char
signed char
int
unsigned char
int
int
int
unsigned
unsigned !
unsigned char
unsigned char
int
int
int
unsigned
unsigned
int
int
int
unsigned
unsigned
unsigned
unsigned
unsigned
Pozor: na řádcích označených ! dochází ke konverzi operandu
s menším rozsahem ve dvou krocích. Nejprve je rozšířen na typ int
a poté až na společný unsigned. Zejména to znamená, že
jednobajtové hodnoty jsou převedeny znaménkovým rozšířením. Např.:
signed char x = -3; /* 0xfd */
unsigned y = 1; /* 0x0001 */
assert( x + y == 65534u ); /* 0xfffe */
Součet je 65534 (0xfffe), nikoliv 254 (0x00fe) jak by mohl někdo
čekat.
Rozsahy všech jednobajtových typů (char, signed char, unsigned char) jsou menší než rozsah typu int. Rozsah typu int není větší než rozsah typu unsigned, protože např. 40000 není přípustná hodnota typu int ale je to přípustná hodnota typu unsigned.
V našem jazyce žádné znaménkové typy větší než int nejsou, proto se toto pravidlo neuplatní – společný typ je unsigned je-li alespoň jeden operand unsigned, jinak je to vždy int.
Prozatím budeme uvažovat pouze přiřazení tvaru var = e₁ – levá
strana může být tedy pouze název proměnné. Pozor, vyhodnocení se
bude pro složitější formy lišit!
Vyhodnocení tohoto typu přiřazení probíhá takto:
vyhodnotí se pravá strana,
výsledek se převede (konvertuje) na typ levé strany,
má-li typ levé strany větší nebo stejný rozsah, probíhá stejně
jako konverze operandů aritmetických operátorů,
je-li levý operand menší a je neznaménkového typu, vyšší bity
se implicitně oříznou,
je-li levý operand menší a je znaménkového typu, výsledek
závisí na implementaci – program může spadnout, ale nemá
nedefinované chování,
převedená hodnota se zapíše do objektu určeného levou stranou,
výsledná hodnota (přiřazení je výraz) je ta, která byla zapsaná
(tzn. hodnota po konverzi z druhého bodu).
Výrazy nám poskytují mocný výpočetní aparát, ale díky své
deklarativní struktuře nejsou ideální pro popis sledu výpočetních
kroků. Je-li potřeba provést nějakou sekvenci operací v daném
pořadí, budou se mnohem lépe k zápisu hodit příkazy.
Nejprve se budeme zabývat podprogramem na abstraktní úrovni – jejich
významem (sémantikou). To, jak se výpočet podprogramu realizuje na
výpočetním stroji, si pak přiblížíme v další sekci.
Podprogram je funkční celek, který popisuje nějaký výpočet – podobně
jako samotný program. Tento výpočet je obvykle parametrizován – je
možné podprogram používat (vykonávat, spouštět) opakovaně pro různé
hodnoty parametrů a výsledek se může v závislosti na těchto
parametrech měnit.
Výsledkem podprogramu jsou pak opět nějaké hodnoty –
v nejjednodušším případě je pouze jediná a říkáme jí návratová
hodnota. Díky této hodnotě je typicky použití (volání, spuštění,
vykonání) podprogramu na syntaktické úrovni výrazem. Později (ve
čtvrté kapitole) si ukážeme i jiné možnosti, zejména výstupní
parametry.
Hlavní charakteristikou podprogramu je možnost jej používat
opakovaně, a to zejména „staticky“ – stejný podprogram je možné
použít v mnoha různých (nezávislých) výrazech.29
Toto se může jevit jako naprostá samozřejmost, ale uvážíme-li, jak pracuje výpočetní stroj – zejména instrukce řízení toku – není na první pohled jasné, jak něčeho takového dosáhnout a zároveň mít strojový kód podprogramu v jediné kopii.
Abychom mohli o podprogramech mluvit, musíme být nejprve schopni je
zapsat, a také zapsat jejich použití. Definice podprogramu (v jazyce
C) sestává z:
hlavičky, která deklaruje
typ návratové hodnoty,
jméno podprogramu (musí být v celém programu30 unikátní),
typy a jména formálních parametrů,
těla složeného z příkazů, které realizují výpočet podprogramu.
Chceme-li již definovaný podprogram použít (zavolat, vykonat),
použijeme k tomu jeho jméno následované seznamem skutečných
parametrů uzavřeným v kulatých závorkách.
V programech složených z více překladových jednotek umožňuje jazyk C pojmenovávat podprogramy buď na úrovni programu (implicitně), nebo překladové jednotky (klíčovým slovem static). Ty druhé pak ale není možné přímo používat mimo tuto překladovou jednotku. V tomto předmětu nic z toho ale potřebovat nebudeme.
Nyní nastala ta chvíle, kdy budeme nuceni konfrontovat otázku vstupů
a výstupů. Protože podprogramy zavádíme na úrovni jazyka C, budeme
se pro tuto chvíli bavit o vstupech pouze na této úrovni. Protože
náš jazyk je v tuto chvíli velice omezený, vstupy a výstupy budou
mít velmi jednoduchou formu:
parametry podprogramu jsou hodnoty, které v místě použití
podprogramu explicitně uvádíme,
návratová hodnota je výsledek, který se při vyhodnocení výrazu
„dosadí“ místo (již ukončeného) provedení podprogramu.
Krom explicitně uvedených parametrů je ale podprogram stále
„uzavřený“ v tom smyslu, že jeho výsledek (návratová hodnota) nebude
záviset na ničem dalším. Tento pohled se nám ovšem značně zamotá již
příští týden.
Z výše uvedeného můžeme udělat možná poněkud překvapivý závěr – náš
jazyk prozatím neumožňuje zapsat jiné podprogramy, než čisté
funkce. K otázce vstupů, výstupů a čistoty funkcí se ale vrátíme
hned v další kapitole.
Omezíme-li se na čisté funkce, sémantika volání podprogramu je velmi
jednoduchá – stačí ve výrazu celý podvýraz volání nahradit jeho
výsledkem. V očekávání nových prvků jazyka ale takovýto denotační
pohled není příliš uspokojivý a zvážíme tedy i jiný (operační),
který blíže odpovídá tomu, jak se skutečně podprogramy chovají.
Předpokládejme (bez újmy na obecnosti), že program je zadán jako
sbírka vzájemně se volajících podprogramů a jeden z nich je vyznačen
jako „hlavní“ – jeho výpočet odpovídá výpočtu celého programu.31 Pak
můžeme „výpočet programu“ stotožnit s „výpočtem hlavního
podprogramu“, od této ekvivalence se odrazit, a zavést pojem
„výpočet podprogramu“ (od výpočtu programu se liší pouze tím, že se
musíme vypořádat s parametry – vstupem).
Nyní již můžeme přistoupit k popisu sémantiky volání (použití)
podprogramu. Je-li během výpočtu potřeba získat výsledek takového
volání:
hlavní výpočet v tomto místě pozastavíme,
poznačíme si hodnoty skutečných parametrů (které v tomto bodě
již musí být známy),
pokračujeme v hlavním výpočtu, přitom jako výsledek volání
použijeme hodnotu z bodu 4.
Velice jednoduchým (i když ne zcela praktickým) způsobem, jak tohoto
dosáhnout, je bod 3 realizovat na zcela novém výpočetním stroji
(kterému jako program zadáme volaný podprogram). Jak později
uvidíme, skutečné volání podprogramu se ale tomuto přístupu až
podezřele podobá.
Tento podrogram také jistě dobře znáte ze sbírky cvičení. Jmenuje se main. V našem zjednodušeném světě tento podprogram nebude mít žádné parametry – to odpovídá uzavřenosti programu jako celku.
Tato kapitola přináší první prostředek, který nám umožní s objekty
pracovat nepřímo – rozhodnutí o tom, se kterým objektem budeme
pracovat, provedeme výpočtem (za běhu programu). Zejména tedy
konkrétní použitý objekt v pevně zvoleném příkazu může záviset na
tom, s jakými vstupními daty byl program spuštěn.
Pojem ukazatel je těsně svázán s objekty a jejich identitou.
Proto se nejprve vrátíme k těmto pojmům – bez nich není možné
ukaztele správně zadefinovat.32
Ukazuje se, že použití ukazatelů činí mnoha studentům značné problémy. Domníváme se, že je tomu zejména proto, že mají chybnou představu o tom, co ukazatel je. V obecném povědomí žel koluje mnoho mýtů a polopravd, a je pravděpodobné, že jste se s nimi už setkali. Zkuste prosím pro tuto chvíli zapomenout, že jste někdy něco o ukazatelích slyšeli, nebo je nějak používali. Ukazatele zejména nejsou nijak magické ani záhadné, jedná se o velice přímočarý koncept (i přesto, že jejich správné použití nemusí být vždy jednoduché).
Ukazatele jsou zejmena novým typem hodnoty – proto je potřeba mít
na paměti, co je hodnota – prozatím jsme se v tomto kurzu setkali
pouze s tzv. celočíselnými typy a jejich hodnotami. Může být tedy
těžké oddělit koncept hodnoty od konceptu čísla, ale je to krok
bezpodmínečně nutný pro hlubší pochopení výpočtu (a tedy i
programování jako takového).
Jak jsme již zmiňovali ve druhé kapitole, výpočet je posloupnost
operací (které s hodnotami pracují) nad sbírkou objektů (které
hodnoty uchovávají). Abychom o něčem mohli říct, že je to hodnota,
musí tedy vykazovat právě tyto dvě vlastnosti:
musí existovat nějaké operace, pro které jsou tyto hodnoty
vstupy (operandy) a/nebo výstupy (výsledky),
hodnoty musí být možné ukládat (pamatovat si).
Naopak, není nutné, aby bylo možné libovolnou hodnotu přímo zapsat
do programu (jako literál/konstantu), aby ji bylo možné „vypsat na
obrazovku“ (vytvořit řetězec, který tuto hodnotu nějak reprezentuje)
ani není určena žádná operace, která musí vždy existovat (některé
hodnoty nemusí být možné sčítat, některé hodnoty nemusí být možné
srovnávat, atp.).
Hodnoty také nemají identitu – není například možné říct, je-li
kopie hodnoty (která vznikne načtením hodnoty z objektu a následným
uložením do nového objektu) nějaká „nová hodnota“ nebo „tatáž
hodnota“ – tuto otázku nedává smysl pokládat33 – vztah „být tentýž“
na hodnotách vůbec není definovaný.34
Pozor! Jiná je rovnost hodnot – v tomto případě se jedná o operaci na hodnotách a neurčuje, zda se jedná o „tutéž“ hodnotu. Nakonec při vyhodnocení výrazu a == a dojde na levé straně k načtení hodnoty z objektu určeného proměnnou a a totéž se stane na pravé straně – jakákoliv intuice typu „je to přece tatáž hodnota, protože je uložena v tom stejném objektu“ ve skutečnosti také nefunguje.
Základním prvkem paměti programu v jazyce C (ale i mnoha jiných,
včetně jazyka Python) je objekt. Pro pochopení ukazatelů není
vůbec důležité, jak je takový objekt realizován – jestli a jak je
reprezentován v paměti, jestli má nebo nemá nějakou adresu, atp.
Podobně jako je nutné vyčlenit pojem hodnoty, je také nutné
rozlišovat mezi hodnotami a objekty a pochopit vztah mezi nimi.
Pro práci s objekty jsou důležité jen dvě vlastnosti – jeho obsah
a jeho identita – vše ostatní je „implementační detail“ – podobně,
jako při běžném programování nemusíte uvažovat o tom, jaké instrukce
se použijí pro překlad kterého výrazu, nemusíme (a ani nechceme)
uvažovat o tom, jak bude (nebo nebude) který objekt uložen v paměti.
Do této chvíle jsme s objekty pracovali pouze skrze proměnné – každá
proměnná pevně identifikuje právě jeden objekt.35 Typ tohoto objektu
je stejný, jako typ proměnné, a je do něj možné uložit pouze hodnoty
tohoto typu.
Zejména klíčový je pojem identity objektu.36 Není důležité, jak
taková identita vnitřně vypadá (podobně jako není důležité, jak jsou
vnitřně reprezentovaná celá čísla), důležité ale je, že se jedná
o hodnotu.
Základní vlastností hodnoty je, že je možné ji ukládat (do objektů,
a tedy i do proměnných) a že jsou na ní definované nějaké operace.
Základní operace s identitami jsou dvě:
máme-li nějaký objekt, můžeme získat jeho identitu,
máme-li identitu, můžeme s její pomocí získat příslušný
objekt,
máme-li dvě identity, můžeme zjistit, jestli jsou stejné nebo
různé (na identitách je definovaná rovnost).
Protože různé objekty mají různé identity, a každý objekt má právě
jednu identitu, rovnost identit přesně rozhoduje, identifikují-li obě
tentýž objekt.
Tato kapitola se bude zabývat indexovanými kolekcemi objektů –
koncept, který je pro výpočetní systémy (a tedy i jejich
programování) zcela centrální. Indexace nám zejména umožňuje vybrat
konkrétní objekt výpočtem.
Tato kapitola zejména uvede implementaci zřetězeného seznamu –
datové struktury složené z buněk (uzlů) provázaných odkazy. K tomu
budeme potřebovat složené hodnoty – uzel musí obsahovat jak
hodnotu tak odkaz na další prvek a musí být tedy složeného typu.
Složené typy v jazyce C zavádíme pomocí tzv. struktur. Konečně
jednotlivé buňky budou uložené v poli a odkazy mezi nimi tak můžeme
realizovat dvěma způsoby – indexy nebo ukazateli.
Struktura je jazyková konstrukce, jenž nám umožní zavést do programu
nový složený typ. Protože typ zavádíme – není součástí jazyka –
říká se mu někdy i uživatelský typ.
Než budeme diskutovat složené typy, připomeneme si pojem složené
hodnoty. Jedná se o hodnotu, kterou lze chápat jako kombinaci
několika jednodušších hodnot, a kterou můžeme smysluplně z takových
jednodušších hodnot složit a opět je na ně rozložit.
Typickým příkladem složené hodnoty je uspořádaná dvojice (nebo
obecněji -tice), která vzniká kartézským součinem. Dvojici můžeme
přímočaře vytvořit (konstruovat) ze dvou jednodušších hodnot a díky
projekcím ji můžeme také jednoduše rozložit na dvě jednodušší
hodnoty.
Typům, které zastřešují hodnoty tohoto charakteru, proto říkáme
součinové typy. Součinové typy mají charakteristické operace:
podobně, jako hodnoty aritmetických typů můžeme sčítat nebo násobit,
hodnoty součinových typů můžeme:
skládat (konstruovat) z jejich jednotlivých složek,
vytvářet nové hodnoty nahrazením některé složky za novou,
Abychom mohli s hodnotami v programech pracovat, musíme být schopni
nějak si je pamatovat – a u složených hodnot tomu není jinak. Pro
uložení složené hodnoty se v jazyce C použije složený objekt, a
to takový, že každé složce hodnoty odpovídá podobjekt
příslušného typu (vzpomeňte si, že typ objektu a typ v něm uložené
hodnoty se musí shodovat, a že podobjekt je také sám o sobě
plnohodnotným objektem).
Díky tomuto uspořádání můžeme složené hodnoty přímo upravovat po
složkách, pomocí přiřazení do podobjektu (to, co budeme v jazyce C
zapisovat přibližně jako X.i = N). Protože podhodnoty a podobjekty
se na sebe mapují 1:1, je zaručeno, že výsledek přiřazení do
podobjektu je tentýž, jako „kanonická“ posloupnost operací:
načti složenou hodnotu z objektu ,
vytvoř novou složenou hodnotu výměnou -té složky ve ,
tzn. ,
Záznamový typ – struktura – je zřejmě nejběžnějším prostředkem
k zavedení součinových typů do programovacího jazyka. Vytvoříme-li
příslušnými jazykovými prostředky nový součinový typ, automaticky
tím získáme:
možnost konstruovat hodnoty tohoto typu tak, že dodáme hodnotu
pro každou složku,
možnost vytvářet nové hodnoty z již existujících tak, že změníme
hodnotu některé složky,
možnost získat hodnotu libovolné složky.
Je zároveň relativně běžné (i mimo jazyk C), že bod 2 je realizován
skrze přiřazení do podobjektu, jak bylo diskutováno výše. Z hlediska
výrazových možností jazyka jsou obě možnosti ekvivalentní.
Nyní si stručně popíšeme syntaxi záznamových typů v jazyce C. Nový
záznamový typ zavedeme klíčovým slovem struct například takto:
struct pair
{
int a;
int b;
};
Hodnoty uvedeného typu budou mít dvě složky, obě typu int, tzn.
budou to celá čísla se znaménkem. Identifikátor pair není sám
o sobě názvem typu, musíme ho nadále uvádět uvozený klíčovým slovem
struct. Deklarace proměnné typu struct pair tedy vypadá
například takto:
Podobně jako u jednoduchých objektů, není-li hodnota objektu
inicializovaná, není dovoleno z objektu číst.37 Inicializaci
záznamové proměnné zapisujeme složenými závorkami. Jména složek jsou
při inicializaci nepovinná:
Nejjednodušší dynamická datová struktura – taková, která umožňuje
uložit a později zpracovávat předem neznámý počtem prvků – je
zřetězený seznam. Klíčovou vlastností datových struktur obecně je,
že existují na úrovni objektů (nikoliv hodnot). Zřetězený seznam
(a později i další datové struktury) tedy budeme zejména chápat jako
vzájemně provázaný soubor objektů.
Tato kapitola se bude zabývat správou buněk – navážeme na zřetězené
seznamy, o kterých byla řeč minule, a zaměříme se na to, jak
udržovat informaci o tom, které buňky jsou používané – alokované
(patří nějaké datové struktuře) a které jsou naopak volné – je
možné je využít při stavbě nové, nebo rozšíření některé existující
struktury.
program potřebuje během výpočtu postupně vytvářet nové objekty
(aby do nich mohl ukládat nějaké hodnoty),
celkový počet objektů může být velmi velký,
jednotlivé objekty jsou ale používány pouze po omezenou dobu.
Typicky bude zejména nastávat situace, kdy výpočet potřebuje jen
relativně malý počet objektů najednou a bylo by tedy výhodné
objekty využívat opakovaně – zejména tedy budeme hledat způsob, jak
již nepotřebné objekty využít pro nové hodnoty.
Pro tuto chvíli se navíc omezíme na situace, kdy potřebujeme
dynamicky pracovat s objekty jediného typu a tyto budou všechny
uloženy v jediném poli. Předpokladem úspěšného výpočtu přirozeně
bude, že toto pole má alespoň tolik položek, kolik hodnot potřebuje
výpočet uložit najednou.