Hlavní navigace

Bezpečné programování v C++ III

18. 4. 2009 20:10 (aktualizováno) | inkvizitor

Dnešní článek by měl být v tomto seriálku poslední, který se bude věnovat teorii. Nebo minimálně bych si chtěl s teorií dát chvíli pauzu. Přesto si tento díl neodpustím, protože z diskuse pod předchozími díly vyplynulo, že mnoho čtenářů napadlo moje pozice prostě proto, že jsem vycházel z nevyslovených předpokladů a to je moje chyba, kterou uznávám.

V zásadě platí, že napsat správně fungující program lze v kterémkoli jazyce, počínaje Assemblerem a konče jazyky s vysokou úrovní abstrakce. V praxi jsou ovšem v programech mnohé chyby a pokud se tomu chceme vyvarovat, musíme mít možnost ty chyby nějakým způsobem odhalit. Vzhledem k tomu, že vyšší jazyky nabízejí možnost rozdělit program do jednotlivých stavebních bloků, lze i rozhodování o tom, zda program funguje správně, dělat postupně a k výsledku dojít díky analýze těchto stavebních bloků a vztahů mezi nimi.

Abychom ale mohli zkoumat tyto bloky izolovaně, musíme zajistit, že nejsou na sobě vzájemně závislé a pokud ano, pak jenom v jednom směru. To znamená, že procedura (v C++ říkejme raději funkce), která volá jinou funkci, se musí spolehnout na to, že volaná funkce pracuje správně, ale nemusí se starat o svoje okolí a o to, co dělá funkce nadřazená a co dělají všechny ostatní funkce, které jsou volány v nějakém jiném místě programu.

Čistě funkcionální jazyky (typu Haskell nebo Clean) si zakládají na tom, že dodržují referenční integritu, což znamená, že každá funkce pro daný vstup vrátí vždy tu samou hodnotu na výstupu a to bez ohledu na to, v jakém kontextu je funkce volána a co se děje kolem. V praxi to znamená, že výpočet návratové hodnoty funkce bere v potaz výhradně parametry této funkce a nepoužívá žádné hodnoty, které může vyčíst jinde (globální proměnné, standardní vstup, soubor apod.). V jazycích typu C, C++ ale i v jiných imperativních jazycích toto obecně zaručit nelze, ale je možné ověřit, jestli daná funkce referenční integritu dodržuje.

Další koncept, který pomáhá v rozhodování o tom, zda program pracuje správně, je možnost redukce popisu implementace funkce na vytvoření návratové hodnoty. Pokud fukce nedělá nic jiného, než to, že spočítá výstupní hodnotu a vrátí ji, jedná se o funkci bez vedlejších efektů.

Příkladem funkce zachovávající princip referenční integrity je funkce factorial() (která počítá faktoriál nezáporného celého čísla, n!), která může v C++ vypadat třeba následovně:

unsigned factorial(unsigned input)
{
  unsigned result = 1;

  for (unsigned r = input; r > 1; r--)
  {
    result *= r;
  }

  return result;
}

Jak můžeme snadno zjistit, tato funkce počítá výsledek skutečně pouze na základě hodnoty vstupního parametru a nemá žádné vedlejší efekty. Otázka ale zní: počítá tato funkce výslednou hodnotu vždy správně?

Otázku lze rozdělit do dvou podotázek:

1. Počítá funkce factorial() správně výsledek pro každou hodnotu ze svého definičního oboru?

2. Je zajištěno, že vstupní hodnota funkce factorial() patří do definičního oboru této funkce?

Definiční obor pro faktoriál je obecně množina nezáporných celých čísel a oborem hodnot je podmnožina množiny všech nezáporných celých čísel, takže v prvé řadě bychom se měli ptát, zda algoritmus použitý v naší funkci počítá správně faktoriál za předpokladu, že typ unsigned reprezentuje všechna nezáporná celá čísla. To lze poměrně snadno ověřit.

Typ unsigned zajišťuje, že vstupní hodnota patří do podmnožiny nezáporných celých čísel. Problémem je ale skutečnost, že typ unsigned nereprezentuje množinu všech nezáporných celých čísel. Vzhledem k tomu, že typ unsigned je shora omezený (viz UINT_MAX) že faktoriál je strmě rostoucí funkce, nemůžeme zaručit, že pro všechny možné vstupní hodnoty v rozsahu typu unsigned dokážeme správně vypočítat výstupní hodnotu, protože může dojít k přetečení rozsahu tohoto typu.

Jedno z možných řešení je použít jiný výstupní typ. Existují knihovny pro počítání s (teoreticky) libovolně velkými čísly, které tento problém mohou řešit. Skutečnost je ale taková, že obyčejně nepotřebujeme a ani nechceme, aby naše funkce počítala výsledek pro hodnoty omezené nějakým obecným typem (například unsigned), ale požadujeme, aby funkce počítala správně pro námi určenou množinu vstupních hodnot. Potřebujeme tedy zajistit, že na vstupu dostaneme některou z povolených hodnot. Tím se dostáváme k dalšímu konceptu, který se jmenuje design by contract. S tímto pojmem přišel Bertrand Meyer, autor programovacího jazyka Eiffel. V našem případě chceme:

1. Určit povolený rozsah vstupních hodnot

2. Dát funkci možnost si vstupní hodnotu pohlídat

Pokud nám stačí počítání faktoriálu pro hodnoty menší nebo rovny 10, můžeme zůstat na 32bitových a vyšších platformách u typu unsigned. Nejjednodušší způsob kontroly nám v C++ skýtá makro assert, který způsobí, že v případě nepřípustné hodnoty program skončí s chybou:

unsigned factorial(unsigned input)
{
  assert(input <= 10);

  unsigned result = 1;

  for (unsigned r = input; r > 1; r--)
  {
    result *= r;
  }

  return result;

}

Když se v krátkosti vrátím k funkci filter() z minulých dílů, můžeme o ní říci, že v principu dodržuje principy referenční integrity, nemá vedlejší efekty a povolené vstupní hodnoty jsou poměrně dobře ošetřeny deklarací typů parametrů. Tato tvrzení jsou samozřejmě podmíněná a v průběhu seriálku se k nim ještě vrátím.

V dalších dílech bych chtěl ukázat implementaci principů design by contract pomocí typového systému, nástrojů typu GNU Nana a knihovny Loki, podrobněji pohovořit o referenční integritě a programování bez vedlejších efektů a věnovat se podrobněji možnostem statické analýzy kódu v C++. Nevylučuji, že se časem vynoří další zajímavá témata.