Rozdział w języku angielskim rozpoczyna się sentencją:
Safe typing is not about preventing heavy-handed use of the keyboard, although it can detect errors made by typos!
Jest to nieprzetłumaczalna gra słów: pisząc typing autor miał na myśli wykorzystywanie systemu typów Ady, a nie przeciwdziałanie mozolnemu wprowadzaniu treści programu przy pomocy klawiatury.
Spis treści:
- Wykorzystanie odrębnych typów
- Typy wyliczeniowe a całkowite
- Zawężenia i podtypy
- Tablice a zawężenia
- Błędy rzeczywiste
Rozdział ten omawia projektowanie struktury typów języka pod kątem unikania błędów natury semantycznej. Zagadnienie to często nazywane jest ścisłą typizacją.
Wczesne języki programowania (Fortran, Algol) traktowały wszystkie dane jako typy numeryczne. Oczywiście, w ostatecznym rozrachunku wszystkie rodzaje danych przybierają w komputerze postać numeryczną, czy to jako liczby całkowite, czy też jako liczby zmiennoprzecinkowe. Języki późniejsze (począwszy od Pascala) wzięły pod uwagę konieczność zwiększenia abstrakcyjności widoku przetwarzanych obiektów. Nawet jeżeli są to naprawdę liczby całkowite, dużo lepiej jest traktować np. kolory jako kolory, a nie - poprzez kodowanie typów wyliczeniowych - jako liczby całkowite (tzw. typy skalarne w Pascalu).
Język Ada rozwija jeszcze bardziej te idee, lecz inne języki ciągle traktują typy skalarne jako typy czysto numeryczne, zarzucając w ten sposób istotną ideę abstrakcji, której celem jest oddzielenie semantyki od reprezentacji maszynowej. Podejście Ady dostarcza więcej okazji do wczesnego wykrywania błędów programowania.
2.1 Wykorzystywanie odrębnych typów
Przypuśćmy, że monitorujemy jakąś produkcję i przy tej okazji kontrolujemy elementy nie spełniające zadanych kryteriów. Możemy osobno zliczać dobre i złe produkty. Chcemy, aby produkcja została wstrzymana po osiągnięciu określonego limitu przez produkty złe, a być może również po osiągnięciu odpowiedniego limitu przez produkty dobre. W C lub C++ możemy zadeklarować zmienne:
int badcount, goodcount; int b_limit, g_limit;
a następnie na przykład:
badcount = badcount + 1; ... if (badcount == b_limit) { ... };
Podobnie w przypadku produktów dobrych. Ponieważ wszystko w rzeczywistości to liczby całkowite, nic nie stoi na przeszkodzie aby popełnić pomyłkę:
if (goodcount == b_limit) { ... };
Oczywiście chodziło nam o napisanie g_limit. Może było to spowodowane skopiowaniem do schowka i wklejeniem bez dokonania wszystkich odpowiednich zmian, a być może po prostu przy wprowadzaniu (znak g znajduje się bezpośrednio nad znakiem b na klawiaturze). W każdym bądź razie, ponieważ mamy do czynienia z liczbami całkowitymi kompilator będzie zadowolony. My natomiast - niezbyt.
Ten sam błąd może powstać i w innych językach programowania. Lecz Ada daje nam możliwość wyrażania się bardziej precyzyjnie. Tak więc w Adzie zapiszmy:
type Goods is new Integer; type Bads is new Integer;
Deklaracje te wprowadzają nowe typy posiadające wszystkie własności predefiniowanego typu Integer (takie jak działania + czy -) i są implementowane dokładnie w ten sam sposób. Niemniej jednak, są to różne typy. Można teraz zapisać:
Good_Count, G_Limit : Goods; Bad_Count, B_Limit : Bads;
Uzyskaliśmy w ten sposób dokładnie różne grupy bytów. Jakiekolwiek pomyłki związane z typami są wykrywalne przez kompilator. Dzięki temu nie ma możliwości, by program w tym zakresie źle działał. Zadowoleni możemy tworzyć dalej:
Bad_Count := Bad_Count + 1; if Bad_Count = B_Limit then
Poniższy fragment natomiast, nigdy nie „przejdzie” przez kompilator:
if Good_Count = B_Limit then -- niedozwolone
ponieważ typy się nie zgadzają.
Jeśli rzeczywiście zajdzie konieczność mieszania typów np. w celu porównania liczby dobrych i złych produktów, możemy skorzystać z konwersji typów (znanej jako rzutowanie w innych językach). Tak wiec piszemy:
if Good_Count = Goods (B_Limit) then
Innym przykładem wymagającym konwersji typów może być obliczenie procentu jaki stanowią złe produkty, w którym to przypadku dokonujemy konwersji na liczbę całkowitą ze znakiem zarówno złe, jak i dobre produkty:
100 * Integer (Bad_Count) / (Integer (Bad_Count) + Integer (Good_Count))
Tę samą technikę wykorzystujemy celem zapobieżenia omyłkowego mieszania typów zmiennoprzecinkowych. Tak więc, gdy zajmujemy się wagą i wzrostem (rozdział Bezpieczna składnia) zamiast:
My_Height, My_Weight : Float;
lepiej zapisać:
type Inches is new Float; type Pounds is new Float; My_Height : Inches := 68.0; My_Weight : Pounds := 168.0;
dzięki czemu ewentualny zamęt wywołany pomiędzy obiema wielkościami zostanie wykryty przez kompilator.
2.2 Typy wyliczeniowe a całkowite
W rozdziale Bezpieczna składnia omawialiśmy przykład skrzyżowania torów kolejowych, w którym zawarty był test:
if (the_signal == clear) { ... };
if The_Signal = Clear then ... end if;
odpowiednio w C i w Adzie. W C zmienna the_signal oraz związana z nią stała taka jak clear mogą być zadeklarowane w poniższy sposób:
enum signal { danger, caution, clear }; enum signal the_signal;
Ta wygodna notacja w rzeczywistości jest tylko skrótem definicji stałych danger, caution i clear jako typu int. Zmienna the_signal również jest typu int.
W konsekwencji nic nas nie ustrzeże przed przypisaniem bezsensownej wartości takiej jak 4 do zmiennej the_signal. W szczególności, takie bezsensowne wartości mogą pochodzić z nie zainicjowanych zmiennych. Dodatkowo, przypuśćmy, że inna część programu zajmuje się obszarem związanym z chemią i wykorzystywane są stany anion oraz cation. W takim wypadku ponownie nic nas nie ustrzeże przed omyłką typu: cation zamiast caution, czy odwrotnie. Również prawdopodobne jest używanie żeńskich imion: betty, clare czy rodzajów broni: dagger, spear. Co nas uchroni przed kombinacją: dagger ↔ danger, albo clare ↔ clear?
W Adzie piszemy:
type Signal is (Danger, Caution, Clear); The_Signal : Signal := Danger;
I brak możliwości popełnienia omyłki, ponieważ typy wyliczeniowe w Adzie są naprawdę różnymi typami, a nie jakimiś tam „skrótami” typu całkowitego. Jeśli napiszemy coś takiego:
type Ions is (Anion, Cation); type Names is (Anne, Betty, Clare, ... ); type Weapons is (Arrow, Bow, Dagger, Spear);
to kompilator ma możliwość zapobieżenia kompilacji programu, który miesza powyższe typy. Dodatkowo kompilator jest w stanie uniemożliwiać przypisywanie do Clear czy Danger z racji, że są to literały i równie „sensownie” byłoby próbować zapisywać działania typu:
5 := 2 + 2;
Na poziomie maszynowym różne typy wyliczeniowe są rzeczywiście kodowane jako liczby całkowite, stąd mamy dostęp do tego kodowania, jeśli tylko zachodzi taka konieczność. Na przykład:
Danger_Code : Integer := Signal’Pos (Danger);
Możliwe jest również narzucenie własnego kodowania. Omówimy to w rozdziale Bezpieczna komunikacja.
Nawiasem mówiąc, bardzo ważnym wbudowanym typem Ady jest typ Boolean, którego formalna deklaracja ma postać:
type Boolean is (False, True);
Rezultatem testu takiego jak The_Signal = Clear jest typ Boolean. Dodatkowo są możliwe pewne operacje na wartościach tego typu: and, or i not. W Adzie nie możliwości traktowania wartości całkowitych jako wartości typu Boolean i odwrotnie. W C — jak sobie przypominamy — testy dają wartości całkowite, przy czym 0 traktowane jest jako fałsz, zaś wartości niezerowe — jako prawda. Zobaczmy jakie niebezpieczeństwo kryje się w kodzie:
if (the_signal == clear) { ... };
Pominięcie jednego znaku równości zmienia test na przypisanie, a — ponieważ C zezwala na traktowanie przypisania jako wyrażenia — składnia jest akceptowalna. Błąd jest dalej potęgowany, gdyż rezultat całkowity przypisania jest w teście traktowany jako Boolean. Suma sumarum, C ma wiele pułapek zilustrowanych w jednym przykładzie:
- wykorzystanie = do przypisania,
- traktowanie przypisania jako wyrażenia,
- traktowanie typu całkowitego jako typu Boolean w wyrażeniach warunkowych.
Większość tych wad została przeniesiona do C++. Natomiast żadna z nich nie dotyczy Ady.
2.3 Zawężenia i podtypy
Wiele jest sytuacji, kiedy wiemy, że wartości jakichś zmiennych zawierają się w określonych przedziałach. Jeśli tak jest, powinniśmy jawnie wykorzystać w programie pewne założenia dotyczące świata realnego. Tak więc wartość My_Weight nigdy nie powinna być ujemna i — oby tak było — nie powinna przekraczać 300 funtów. Zadeklarujmy:
My_Weight : Float range 0.0 .. 300.0;
lub, jeśli jesteśmy metodyczni i wcześniej zadeklarowalismy typ zmiennoprzecinkowy Pounds:
My_Weight : Pounds range 0.0 .. 300.0;
Jeśli przez omyłkę program wygeneruje wartość spoza tego zakresu, a następnie spróbuje przypisać ją do zmiennej My_Weight:
My_Weight := Compute_Weight ( ... );
zostanie zgłoszony (lub „rzucony” — co za język!) wyjatek Constraint_Error w czasie działania programu. Możemy taki wyjątek obsłużyć (lub „złapać”...) w jakiejś innej części programu i podjąć odpowiednie działania. Jeśli tego nie uczynimy, program zatrzyma się, a program wykonawczy spreparuje komunikat błędu, informujący o miejscu wystąpienia wyjątku. To wszystko odbywa się automatycznie — odpowiadający za to kod jest automatycznie wygenerowany przez kompilator.
Idea podzakresów została pierwotnie wprowadzona do Pascala, a rozwinięta w Adzie. Nie jest ona obecna w większości języków, z którego to powodu należy odpowiednie kontrole samemu zaimplementować. Ale bardziej prawdopodobne jest, że nie myśli się o tym, a błędy wynikające z przekroczenia zakresów są bardzo trudne do wykrycia.
Jeśli wiemy, że każda waga obsługiwana przez program będzie mieściła się w założonym zakresie, to zamiast umieszczania zawężenia przy każdej deklaracji zmiennej tego typu, można zawężenie to wstawić do deklaracji typu Pounds:
type Pounds is new Float range 0.0 .. 300.0;
Natomiast jeżeli jakieś wagi w programie są nieograniczone, a znamy ograniczenia wagi tylko odnośnie ludzi, możemy zapisać:
type Pounds is new Float; subtype People_Pounds is Pounds range 0.0 .. 300.0; My_Weight : People_Pounds;
Zawężenia i podtypy można definiować również w przypadku typów całkowitych i typów wyliczeniowych. Tak więc, jeśli w przypadku zliczania dobrych produktów założymy, że ich liczba nie może być ujemna i nie może przekroczyć 1000 sztuk, to zapiszemy to w następujący sposób:
type Goods is new Integer range 0 .. 1000;
Jeśli natomiast jedynym ograniczeniem ma być fakt, że liczba produktów dobrych nie może być ujemna, a górna granica nie jest określona, to zapisujemy to w sposób następujący:
type Goods is new Integer range 0 .. Integer’Last;
gdzie Integer’Last podaje nam maksymalna wartość dla typu Integer. Ograniczenie związane z wartościami dodatnimi czy nieujemnymi jest tak powszechnie stosowane, że język Ada dostarcza następujące typy wbudowane:
subtype Natural is Integer range 0 .. Integer’Last; subtype Positive is Integer range 1 .. Integer’Last;
dzięki czemu typ Goods może być zadeklarowany w taki sposób:
type Goods is new Natural;
co narzuca dolną granicę 0 tak jak tego chcieliśmy.
Przykładem zawężeń typów wyliczeniowych mogą być deklaracje:
type Day is (Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday); subtype Weekday is Day range Monday .. Friday;
dzięki czemu możemy uniknąć omyłkowego przypisania wartości Sunday do zmiennej podtypu Weekday.
Wstawianie zawężeń tak jak w powyzszych przykładach może wydawać się dosyć mozolne, ale czyni to program jaśniejszym. Dodatkowo pozwala to kompilatorowi i systemowi wykonawczemu na stwierdzenie, że założenia wyrażone poprzez zawężenia są rzeczywiście prawidłowe.
2.4 Tablice a zawężenia
Tablica to zaindeksowany zbiór pewnych elementów. Jako prosty przykład rozważmy grę parą kości. Chcemy rejestrować jak wiele rzutów danej wartości (od 2 do 12) uzyskaliśmy. Ponieważ mamy 11 mozliwych wartości w C, zapisujemy:
int counters[11]; int throw;
co w efekcie daje deklaracje 11 zmiennych dostępnych przy pomocy referencji: od counters[0] do counters[10] oraz zmienną throw.
Jeśli chcemy rejestrować wyniki kolejnych rzutów, możemy zapisać:
throw = ...; counters[throw-2] = counters[throw-2] + 1;
Zauważmy potrzebę zmniejszenia wartości throw o 2, ponieważ tablice w C są zawsze indeksowane począwszy od 0. Przypuśćmy teraz, że mechanizm liczący zrobił coś źle (czasami dżoker powoduje uzyskanie 7 oczek lub też generujemy liczby losowe, a generator tychże liczb jest źle zaprogramowany) i wygenerowany zostaje rzut o 13 oczkach. Co się dzieje? Program napisany w C nie wykrywa błędu, lecz po prostu oblicza, gdzie znajduje się counters[11] i dodaje jeden do zawartości. Najprawdopodobniej będzie to pozycja, w której mieści się zmienna throw, ponieważ zmienna ta jest zadeklarowana bezpośrednio za tablicą. Tym sposobem zmienna ta przyjmie wartość 14! I tak otrzymujemy bezużyteczny program.
To przykład osławionego problemu przepełnienia bufora. Jest to źródło wielu poważnych i trudnych do wykrycia problemów. W końcu jest to dziura, która pozwala wirusom na atak systemów takich jak Windows. Omówimy to szerzej w rozdziale 7, Bezpieczne zarządzanie pamięcią.
Teraz rozważmy ten sam problem w Adzie. Zapiszmy:
Counters : array (2 .. 12) of Integer; Throw : Integer;
a następnie:
Throw := ... ; Counters (Throw) := Counters (Throw) + 1;
I teraz, jeżeli Throw przyjmie złą wartość taką jak na przykład 13, to — ponieważ Ada ma wbudowane kontrole w system wykonawczy, zapewniające niemożność odczytu czy zapisu fragmentu tablicy, który nie istnieje — zostaje zgłoszony wyjątek Constraint_Error, a program zostaje uchroniony od działania „na dziko”.
Należy zwrócić uwagę na to, że Ada kontroluje zarówno dolną, jak i górną granicę. Indeksy tablicy nie muszą zaczynać się od 0. Dolne granice w realnych programach zaczynają się dużo częściej od 1. Zadeklarowanie dolnej granicy jako 2 w powyższym przykładzie pozwala na bezpośrednie użycie zmiennej Throw w indeksie tablicy bez niepotrzebnych komplikacji typu odjęcie odpowiedniego przesunięcia jak w wersji C.
Problem z programem do gry w kości nie polega na przekroczeniu górnej granicy tablicy (to raczej symptom), lecz na tym, że wartość zmiennej Throw przyjmuje nieprawidłową wartość. Możemy wyłapać taką pomyłkę wcześniej przez zadeklarowanie zawężenia dla zmiennej Throw:
Throw : Integer range 2 .. 12;
I teraz, gdy nastąpi próba przypisania do tej zmiennej wartości 13, nastąpi zgłoszenie wyjątku Constraint_Error. W konsekwencji kompilator również może być zdolny do „wydedukowania”, że zmienna Throw ma wartość odpowiednią dla zakresu tablicy i nie ma potrzeby kontrolowania tego faktu w momencie dostępu do elementu tablicy przy pomocy zmiennej Throw. W rzeczywistości deklarowanie zawężeń dla zmiennych używanych do indeksowania zazwyczaj redukuje liczbę kontroli w czasie działania programu. Nawiasem mówiąc, możemy zredukować zdublowane zawężenie 2 .. 12 poprzez zapis:
Throw : Integer range 2 .. 12; Counters : array (Throw’Range) of Integer;
lub nawet bardziej czytelnie:
subtype Dice_Range is Integer range 2 .. 12; Throw : Dice_Range; Counters : array (Dice_Range) of Integer;
Zaleta jednokrotnego zapisu zakresu ujawnia się w przypadku konieczności zmiany programu (na przykład dodanie trzeciej śmierci, co skutkuje zakresem 3 .. 18). Zmiany tej dokonujemy tylko w jednym miejscu.
Kontrole zakresów w Adzie są olbrzymią praktyczną korzyścią na etapie testowania. Po stwierdzeniu, że wszystko działa jak należy, można je wyłączyć w produkcyjnej wersji programu. Kompilatory Ady to nie jedyne kompilatory generujące kontrole w czasie działania programów. Kompilator Whetstone Algol 60 datowany na rok 1962 czynił to. Ada (podobnie jak Java) określa kontrole w samej definicji języka.
Być może należałoby również wspomnieć, że można także podać nazwę dla typów tablicowych. Jeśli mamy kilka zestawów liczników wartości, to lepiej byłoby zapisać:
type Counter_Array is array (Dice_Range) of Integer; Counters : Counter_Array; Old_Counters : Counter_Array;
Po czym, jeśli chcemy skopiować wszystkie elementy tablicy Counters do odpowiednich elementów tablicy Old_Counters, po prostu zapisujemy:
Old Counters := Counters;
Nadawanie nazwy typom tablicowym nie jest możliwe w większości języków. Zaletą nazwanych typów jest to, że wprowadzają one jawną abstrakcję, tak jak w przykładzie zliczania dobrych i złych produktów. Mówiąc więcej kompilatorowi o tym co robimy, dostarczamy mu więcej okazji do stwierdzenia, czy program robi to sensownie.
2.5 Błędy rzeczywiste
Tytuł tego podrozdziału jest przykładem tych podstępnych gier słownych, których nienawidził pionier programowania Christopher Strachey. Rzecz jest o dokładnościach w arytmetyce, a konkretnie w arytmetyce liczb rzeczywistych.
W arytmetyce zmiennoprzecinkowej (typy takie jak real w Pascalu, float w C, czy Float w Adzie) obliczenia dokonywane są przez specjalizowane układy zmiennoprzecinkowe. Liczby zmiennoprzecinkowe mają dokładność względną. Słowo 32 bitowe może przechowywać 23 bitowa mantysę, jeden bit znaku oraz 8 bitów wykładnika. To daje nam dokładność 23 cyfr binarnych lub około 7 cyfr dziesiętnych.
Duża wartość, taka jak 123456,7 ma dokładność rzędu 1 cyfry po przecinku, podczas gdy wartość bardzo mała, np. 0,01234567 ma dokładność rzędu 8 cyfr po przecinku. Lecz we wszystkich przypadkach liczba cyfr znaczących to zawsze 7. Tak więc dokładność jest zależna od wielkości liczby.
Dokładność względna sprawdza się dobrze w większości zastosowań. Jednak jak zwykle są wyjątki. Rozważmy reprezentację kąta nachylenia statku lub rakiety. Być może zechcielibyśmy utrzymać dokładność rzędu sekundy łuku. Pamiętamy, że mamy 60 sekund w minucie, 60 minut w stopniu oraz 360 stopni w całym okręgu.
Jeśli będziemy przechowywać kąt jako liczbę zmiennoprzecinkową:
float bearing;
to dokładność w przypadku 360 stopni będzie rzędu około 8 sekund. Dokładność taka będzie niewystarczająca. Natomiast dokładność w przypadku 1 stopnia będzie rzędu 1/45 sekundy. Z kolei aż taka dokładność jest niepotrzebna. Moglibyśmy oczywiście przechowywać wartość kąta jako całkę liczby sekund przy pomocy typu całkowitoliczbowego:
int bearingsecs;
To się sprawdza, ale oznacza też, że musimy pamiętać o przeliczaniu wartości wprowadzanych czy wyświetlanych.
Natomiast prawdziwy kłopot z liczbami zmiennoprzecinkowymi polega na tym, że dokładność działan takich jak dodawanie czy odejmowanie jest dotknięta błędami zaokrągleń. Jeśli odejmiemy dwie prawie równe liczby, uzyskamy błąd unieważnienia. I oczywiście niektóre liczby nie będą przechowywane dokładnie. Jeśli mamy silnik krokowy, pracujący w krokach 1/10 stopnia, to — ponieważ wartość 0.1 nie może być przechowywana dokładnie — wynik dodania 10 kroków nie będzie równy 1 stopniowi. Tak więc, nawet jeśli wymagana dokładność jest bardzo zgrubna, a hipotetyczna dokładność jest bardziej niż wystarczająca, efekt narastania małych błędów obliczeniowych może być niepohamowany.
Sposób polegający na skalowaniu, by móc wykorzystać liczby całkowite, jest do przyjęcia w małych aplikacjach, lecz gdy mamy kilka typów przechowujących skalowane liczby całkowite i musimy na nich wszystkich wykonywać jakieś działania, często napotykamy problemy i musimy dokonywać własnego skalowania (być może nawet przy pomocy rozkazów maszynowych takich jak przesunięcia). To wszystko prowadzi do błędów i komplikacji konserwacji kodu.
Ada jest jednym z kilku języków umożliwiających arytmetykę stałoprzecinkową. Dzięki temu skalowanie jest dokonywane automatycznie. Tak więc, w przypadku silnika krokowego możemy zapisać:
type Angle is delta 0.1 range -360.0 .. 360.0; for Angle’Small use 0.1;
i to rozwiązuje nam problem przechowywania wartości w postaci przeskalowanych liczb całkowitych reprezentujących wielokrotności 0.1. Jednak myśleć powinniśmy w kategoriach abstrakcyjnych, czyli mamy: stopnie oraz dziesiętne stopnia. W ten sposób działania arytmetyczne nie będą cierpiały na błędy zaokrągleń.
Podsumowując, w Adzie mamy dwie postacie arytmetyki rzeczywistej:
- zmiennoprzecinkowa, z dokładnością względną,
- stałoprzecinkowa, z dokładnością absolutną.
Ada udostępnia również bardziej wyspecjalizowaną formę arytmetyki stałoprzecinkowej, przeznaczonej do obliczeń finansowych.
Temat tego podrozdziału jest raczej specjalistyczny, ale ilustruje on rozległość możliwości Ady oraz troskę o zwiększanie bezpieczeństwa w obliczeniach numerycznych.
| Rozdział 1: Bezpieczna składnia | Rozdział 3: Bezpieczne wskaźniki |
| Tekst oryginalny w języku angielskim - |
|



