Tydzień z Adą - wszystkie wpisy

Mamy już za sobą wprowadzenie do języka Ada. Pora więc coś w niej napisać. W tym artykule pokażę całą drogę od instalacji toolchaina aż do napisania pierwszego programu – kolejki FIFO opartej na buforze cyklicznym. W trakcie implementacji poznamy kilka podstawowych „ficzerów” Ady

Artykuł powstał w ramach „Tygodnia z Adą” podczas którego od poniedziałku do piątku będą ukazywać się materiały na temat języka Ada. Będzie między innymi dlaczego Ada tak dobrze nadaje się do safety-critical, pokażę swoje pierwsze próby z pisaniem programów w Adzie, a także postaram się uruchomić Adę na STM32.

Toolchain

Zaczynam od ściągnięcia toolchaina GNAT z AdaCore. Wybrałem wersję na Windowsa. Na wersję ARM przyjdzie jeszcze czas w kolejnych wpisach. W skład toolchaina w darmowej wersji Community wchodzą między innymi:

  • Kompilator Ada 2012.
  • IDE GNAT Programming Studio (GPS).
  • GNATmetric do wyliczania metryk takich jak Cyclomatic Complexity.
  • GNATdoc do tworzenia dokumentacji.
  • AUnit – framework do unit testów + GNATtest do generowania testów dla projektu.

Pełną listę tooli wchodzących w skład poszczególnych wersji można znaleźć tutaj. Mnie oczywiście szczególnie ucieszyła obecność frameworka testowego. Chociaż inne bajery również są mile widziane. IDE jest dostępne jedynie w wersji native (czyli w moim wypadku na Windowsa). Oznacza to, że jeśli chcemy używać IDE z toolchainem dla ARMów, powinniśmy wcześniej zainstalować wersję native. Nad samym procesem instalacji nie ma co się rozwodzić.

Opis programu

Pierwszym programem, który napiszę będzie kolejka FIFO oparta na buforze cyklicznym. Będzie więc zawierał tablicę chronioną przed wyjściem poza ostatni indeks. Poza tym chcę sprawdzić kontrolę typów i podział package’a na część public i private. W następnych krokach będę chciał zaimplementować tą kolejkę jako klasę, a na koniec będę chciał zaimplementować ją jako typ generyczny, w którym mogę sobie wybrać typ obsługiwanych elementów i rozmiar tablicy.

Szkielet programu

Na początku tworzę przy pomocy GPS nowy projekt o nazwie fifo. Wygenerował się projekt z pustą funkcją main (widoczny poniżej na obrazku). Następnie dodaje nowy package o nazwie fifo i zaznaczam, żeby utworzył od razu implementation file. Pojawiają się pliki fifo.ads zawierający specyfikację package’a oraz fifo.adb zawierające body, czyli implementację.

Na początek zdefiniujmy sobie funkcje, które będziemy potrzebować w pliku fifo.ads. Tutaj musimy zwrócić uwagę na fakt, iż w Adzie funkcja definiowana z użyciem słowa kluczowego function musi koniecznie zwracać jakąś wartość. Na procedurę, która nic nie zwraca jest oddzielne słowo kluczowe procedure. Na początek więc zdefiniujmy:

  • procedurę Push dodającą element do kolejki.
  • funkcję Pop zwracającą element z kolejki.
  • funkcje IsEmpty i IsFull.
  • procedurę Clear czyszczącą zawartość kolejki.

Po deklaracjach powyższych funkcji i procedur nasz plik fifo.ads będzie wyglądał tak:

package fifo is

   type fifo_elem_t is new Integer;
   
   procedure Push(elem : in fifo_elem_t);
   function Pop return fifo_elem_t;
   function IsEmpty return Boolean;
   function IsFull return Boolean;
   procedure Clear;

end fifo;

Dodałem jeszcze od razu definicję typu dla elementów naszej kolejki. Dzięki temu będzie można go łatwo później zmienić albo przekształcić w generyczny.

W pliku fifo.adb zaimplementuję te funkcje na razie jako puste:

package body fifo is

   procedure Push(elem : in fifo_elem_t) is
   begin
      null;
   end Push;
   
   function Pop return fifo_elem_t is
   begin
      return 0;
   end Pop;
      
   function IsEmpty return Boolean is
   begin
      return False;
   end IsEmpty;
      
   function IsFull return Boolean is
   begin
      return False;
   end IsFull;
   
   procedure Clear is
   begin
      null;
   end Clear;

end fifo;

Dodamy jeszcze do maina informację, żeby korzystał z package fifo:

with fifo;

procedure Main is

begin
   --  Insert code here.
   null;
end Main;

Podstawowa implementacja

Teraz pora na dodanie implementacji w fifo.adb. Najpierw zdefiniuję sobie typy i zmienne. Rozmiar bufora zadeklaruję jako stałą, na razie będzie wynosić 4 elementy. Korzystając z tej stałej zadeklaruję sobie typ buf_range_t na zakres indeksów bufora w zakresie od 1 do 4. Zdefiniuję także typ buffer_t, czyli tablicę przechowującą moje elementy:

   Size : constant Positive := 4;
   type buf_range_t is new Positive range 1 .. Size;
   type buffer_t is array (buf_range_t'Range) of fifo_elem_t;

Zadeklaruję również swoje zmienne – bufor Buf oraz indeksy Head i Tail o początkowych wartościach 1:

   Buf : buffer_t;
   Head : buf_range_t := 1;
   Tail : buf_range_t := 1;

Jak widać, Buffer jest typem tablicy o określonej liczbie i typie elementów. Za pomocą tego typu można deklarować różne zmienne. Uzupełnijmy teraz pierwszą prostą (i błędną!) wersję implementacji:

   procedure Push(elem : in fifo_elem_t) is
   begin
      Buf(Head) := elem;
      Head := Head + 1;
   end Push;
   
   function Pop return fifo_elem_t is
      retval : fifo_t;
   begin
      retval := Buf(Tail);
      Tail := Tail + 1;
      return retval;
   end Pop;
      
   function IsEmpty return Boolean is
   begin
      return Head = Tail;
   end IsEmpty;
      
   function IsFull return Boolean is
   begin
      return Tail = Head + 1;
   end IsFull;
   
   procedure Clear is
   begin
      Head := 1;
      Tail := 1;
   end Clear;

Dodajmy teraz do maina kod testujący naszą kolejkę:

with fifo;
with Ada.Text_IO;

procedure Main is
begin
   Ada.Text_IO.Put_Line("Is queue empty? " & Boolean'Image(fifo.IsEmpty));
   
   fifo.Push(5);
   fifo.Push(6);
   fifo.Push(7);
   
   Ada.Text_IO.Put_Line("First element was " & fifo.fifo_elem_t'Image(fifo.Pop));
   Ada.Text_IO.Put_Line("Second element was " & fifo.fifo_elem_t'Image(fifo.Pop));
   Ada.Text_IO.Put_Line("Third element was " & fifo.fifo_elem_t'Image(fifo.Pop));

end Main;

Wykorzystaliśmy tutaj funkcje biblioteki standardowej do obsługi konsoli Ada.Text_IO. Jak widać stringi są tu dużo przyjemniej obsługiwane, niż na przykład w C. Za pomocą operatora & możemy je łączyć. Dodatkowo wykorzystując atrybut Image. Do atrybutów dostajemy się używając znaku ’. Atrybuty umożliwiają nam uzyskiwać pewne informacje np. o typach oraz dają dostęp do pewnych operacji. W tym przypadku atrybut Image pozwala konwertować wartość do stringa.

Po wykonaniu programu otrzymamy następujący output:

Is queue empty? TRUE
First element was  5
Second element was  6
Third element was  7

Zobaczmy jednak czym skończy się dodanie czwartego elementu:

fifo.Push(8);

Otrzymamy teraz runtime error:

Is queue empty? TRUE
First element was  5
Second element was  6
Third element was  7

raised CONSTRAINT_ERROR : fifo.adb:14 range check failed

Problemem jest procedura Push i próba zwiększenia zmiennej Head powyżej zakresu. Aby temu zapobiec modyfikujemy sposób obliczania Heada wykorzystując operację modulo:

Head := Head mod buf_range_t'Last + 1;

Podobny zabieg należy wykonać dla zmiennej Tail i warunku w funkcji IsFull, dlatego stworzymy sobie pomocniczą funkcję:

   function IncrementIndex(idx : in buf_range_t) return buf_range_t is
   begin
      return idx mod buf_range_t'Last + 1;
   end IncrementIndex;

Tą funkcję możemy zdefiniować w samym body (plik adb), ponieważ nie mamy zamiaru udostępniać jej na zewnątrz. To samo możemy robić dla metod w klasach (do czego jeszcze dojdziemy). Jest to spora zaleta w stosunku do C++, gdzie musimy w headerze deklarować wszystkie metody prywatne mimo, że moduły zewnętrzne nie będą miały do nich dostępu.

Dodajmy jeszcze do procedur Push i Pop zabezpieczenia przed dodaniem do pełnego bufora i zdjęciem z pustego:

   procedure Push(elem : in fifo_elem_t) is
   begin
      if not IsFull then
         Buf(Head) := elem;
         Head := IncrementIndex(Head);
      end if;
   end Push;
   
   function Pop return fifo_elem_t is
      retval : fifo_t;
   begin
      if not IsEmpty then
         retval := Buf(Tail);
         Tail := IncrementIndex(Tail);
      end if;
      return retval;
   end Pop;

Tutaj widzimy konstrukcję warunkową if, która w Adzie wymaga zamykającego bloku end if. Dodatkowo negację wartości boolowskich możemy robić za pomocą słowa kluczowego not. IsFull i IsEmpty to wcześniej zdefiniowane funkcje. W Adzie do funkcji bez argumentów nie używamy pustych nawiasów.

Wykorzystanie klas

W Adzie tworzenie klas jest mniej intuicyjne niż np. w C++. Tutaj klasa to tagged record. Z kolei metody klasy są definiowane oddzielnie z pierwszym argumentem będącym obiektem tej klasy. Zmieniona specyfikacja naszej kolejki fifo będzie wyglądać tak:

package fifo is

   type fifo_elem_t is new Integer;
   type fifo_class is tagged private;
   
   procedure Push(this : in out fifo_class; elem : in fifo_elem_t);
   function Pop(this : in out fifo_class) return fifo_elem_t;
   function IsEmpty(this : in out fifo_class) return Boolean;
   function IsFull(this : in out fifo_class) return Boolean;
   procedure Clear(this : in out fifo_class);
   
private
   
   Size : constant Positive := 4;
   type buf_range_t is new Positive range 1 .. Size;
   type buffer_t is array (buf_range_t'Range) of fifo_elem_t;
   
   type fifo_class is tagged record
      Buf : buffer_t;
      Head : buf_range_t := 1;
      Tail : buf_range_t := 1;
   end record;

end fifo;

Klasa fifo_class została na początku zadeklarowana jako private, a jej pełna deklaracja jest dopiero dalej w sekcji private. Dzięki temu zewnętrzne moduły nie będą miały dostępu do pól klasy, ale będą wiedziały ile na nią zaalokować pamięci. Implementacja metod również uległa niewielkim zmianom. Teraz korzystamy z wewnętrznych pól klasy:

package body fifo is
   
   function IncrementIndex(idx : in buf_range_t) return buf_range_t;
   
   procedure Push(this : in out fifo_class; elem : in fifo_elem_t) is
   begin
      if not this.IsFull then
         this.Buf(this.Head) := elem;
         this.Head := IncrementIndex(this.Head);
      end if;
   end Push;
   
   function Pop(this : in out fifo_class) return fifo_elem_t is
      retval : fifo_elem_t;
   begin
      if not this.IsEmpty then
         retval := this.Buf(this.Tail);
         this.Tail := IncrementIndex(this.Tail);
      end if;
      return retval;
   end Pop;
      
   function IsEmpty(this : in out fifo_class) return Boolean is
   begin
      return this.Head = this.Tail;
   end IsEmpty;
      
   function IsFull(this : in out fifo_class) return Boolean is
   begin
      return this.Tail = IncrementIndex(this.Head);
   end IsFull;
   
   procedure Clear(this : in out fifo_class) is
   begin
      this.Head := 1;
      this.Tail := 1;
   end Clear;
   
   function IncrementIndex(idx : in buf_range_t) return buf_range_t is
   begin
      return idx mod buf_range_t'Last + 1;
   end IncrementIndex;

end fifo;

Z kolei w pliku main wywołujemy metody klasy podobnie jak w C++, czyli używamy składni obiekt.metoda(argumenty):

with fifo;
with Ada.Text_IO;

procedure Main is
   F : fifo.fifo_class;
begin
   Ada.Text_IO.Put_Line("Is queue empty? " & Boolean'Image(F.IsEmpty));
   Ada.Text_IO.Put_Line("Element from empty array is " & fifo.fifo_elem_t'Image(F.Pop));
   
   F.Push(5);
   F.Push(6);
   F.Push(7);
   
   Ada.Text_IO.Put_Line("First element was " & fifo.fifo_elem_t'Image(F.Pop));
   Ada.Text_IO.Put_Line("Second element was " & fifo.fifo_elem_t'Image(F.Pop));
   Ada.Text_IO.Put_Line("Third element was " & fifo.fifo_elem_t'Image(F.Pop));
   
   F.Push(8);
   
   Ada.Text_IO.Put_Line("Fourth element was " & fifo.fifo_elem_t'Image(F.Pop));
   
   Ada.Text_IO.Put_Line("Is queue empty? " & Boolean'Image(F.IsEmpty));
   Ada.Text_IO.Put_Line("Element from empty array is " & fifo.fifo_elem_t'Image(F.Pop));

end Main;

Musieliśmy również zadeklarować obiekt F nowo stworzonej klasy.

Wykorzystanie typów generycznych

Ada wspiera typy generyczne już od wersji 83. Możemy zadeklarować generyczną procedurę albo generyczny package. Aby stworzyć generyczną klasę należy po prostu użyć generycznego package. Nie doszedłem, czy jest jakaś oddzielna składnia na generyczną klasę. Aby nasza klasa była generyczna musimy zmodyfikować plik fifo.ads:

generic
   Size : Positive;
   type fifo_elem_t is private;

package fifo is
   type fifo_class is tagged private;
   
   procedure Push(this : in out fifo_class; elem : in fifo_elem_t);
   function Pop(this : in out fifo_class) return fifo_elem_t;
   function IsEmpty(this : in out fifo_class) return Boolean;
   function IsFull(this : in out fifo_class) return Boolean;
   procedure Clear(this : in out fifo_class);
   
private

   type buf_range_t is new Positive range 1 .. Size;
   type buffer_t is array (buf_range_t'Range) of fifo_elem_t;
   
   type fifo_class is tagged record
      Buf : buffer_t;
      Head : buf_range_t := 1;
      Tail : buf_range_t := 1;
   end record;

end fifo;

Na początku naszego package poinformowaliśmy kompilator, że jest on uzależniony od dwóch parametrów – Size będącym liczbą Positive i fifo_elem_t będącym dowolnym typem. Reszta pliku jest praktycznie bez zmian – po prostu teraz używamy Size i fifo_elem_t z definicji generic zamiast definiować konkretne. Plik fifo.adb natomiast pozostaje bez zmian. Wykorzystuje on już symbole fifo_elem_t oraz Size, więc nie musimy nic edytować.

W mainie natomiast musimy zadeklarować konkretną wersję naszego typu generycznego określając wartości Size i fifo_elem_t. Robimy to w sekcji odpowiedzialnej za deklarację zmiennych i typów:

with fifo;
with Ada.Text_IO;

procedure Main is
   type elem_t is new Positive;
   package my_fifo_t is new fifo(Size => 4, fifo_elem_t => elem_t);
   F : my_fifo_t.fifo_class;
begin
   Ada.Text_IO.Put_Line("Is queue empty? " & Boolean'Image(F.IsEmpty));
   Ada.Text_IO.Put_Line("Element from empty array is " & elem_t'Image(F.Pop));
   
   F.Push(elem_t(5));
   F.Push(elem_t(6));
   F.Push(elem_t(7));
   
   Ada.Text_IO.Put_Line("First element was " & elem_t'Image(F.Pop));
   Ada.Text_IO.Put_Line("Second element was " & elem_t'Image(F.Pop));
   Ada.Text_IO.Put_Line("Third element was " & elem_t'Image(F.Pop));
   
   F.Push(elem_t(8));
   
   Ada.Text_IO.Put_Line("Fourth element was " & elem_t'Image(F.Pop));
   
   Ada.Text_IO.Put_Line("Is queue empty? " & Boolean'Image(F.IsEmpty));
   Ada.Text_IO.Put_Line("Element from empty array is " & elem_t'Image(F.Pop));
end Main;

Jak widać użyłem pomocniczego typu elem_t, żeby można było zmieniać typ w jednym miejscu. Możemy go spokojnie zmienić np. na Float i wszystko dalej będzie działać.

Podsumowanie

Dzięki napisaniu tego prostego programu poznałem kilka podstawowych funkcji języka. Jak można się było spodziewać, do plusów należy zwiększona kontrola błędów zarówno podczas kompilacji, jak i w runtime. Komunikaty o błędach są dużo lepsze niż w C/C++ i pozwalają dużo łatwiej namierzyć przyczynę problemu. W porównaniu z czystym C język jest dużo bardziej rozbudowany. Ma fajną bibliotekę standardową, klasy i typy generyczne. Po opanowaniu składni bardzo możliwe, że będzie się pisało szybciej niż w C. Dostępnymi funkcjami Adzie dużo bliżej do C++.

Jeżeli chodzi o minusy, to trochę ciężko było szukać w necie rozwiązań napotykanych problemów. Community Ady jest jednak dosyć małe i chyba nie pisze tak chętnie na StackOverflow. Poza tym składnia mocno różni się od języków wywodzących się z C i ciężko się przestawić. Aspektów takich jak np. wydajność na razie jeszcze nie sprawdzałem. Nie bawiłem się również jeszcze debuggerem i dodatkowymi narzędziami. W kolejnym odcinku postaram się napisać coś na mikrokontroler.

Tydzień z Adą - Nawigacja