Drabiny i wyliczanki na Arduino, czyli nie tylko if

Przed chwilą skończyliśmy budować prosty odbiornik kodów z pilota IR na Arduino. Była to bardzo prosta (jednak użyteczna konstrukcja). By rozwinąć ten projekt, zwiększając nieco jego skomplikowanie (głównie od strony programu) użyjemy kilku bardziej złożonych konstrukcji języka programowania używanego w Arduino.

Dla wyjaśnienia – jeśli jesteś już programistą, to użyte konstrukcje nie będą dla Ciebie czymś nowym. Jednak Arduino dla wielu osób, które dotąd nie miały nic wspólnego z programowaniem czy elektroniką jest impulsem który powoduje że wkroczyły na te obszary. Głównie dla takich osób są pisane artykuły dla Nettigo StarterKit, staramy się w przystępny sposób tłumaczyć absolutne podstawy. Jednak wierzymy też (ba mamy pewność!), że nieco bardziej złożone konstrukcje programistyczne są również „do ogarnięcia” dla każdego.

Jeśli spojrzysz w kod programu z poprzedniej części, pętla loop składa się z dwóch warunków if. W obu przypadkach sprawdzamy odczytany kod z tej samej zmiennej. Jeśli będziemy chcieli wprowadzić obsługę większej ilości klawiszy w pilocie, liczba warunków if będzie rosnąć. Pogarszać będzie się czytelność programu, co może doprowadzić do błędów w kodzie.

Rozgałęziacz zamiast drabiny czyli switch zamiast if

Sytuację ulepszyć może skorzystanie z innej instrukcji warunkowej. Zamiast if użyjemy switch. Struktura switch wygląda następująco:

switch (zmienna) {
    case 1:
      //jeśli równe 1
      break;
    case 2:
      //jeśli równe 2
      break;
    default:
      // wszystkie pozostałe przypadki
      break;
}

W nawiasie po switch wstawiamy nazwę zmiennej którą chcemy sprawdzić, w przypadku kodu naszego pilota to będzie switch (code). Następnie wstawiamy dyrektywy case podając wartość która nas interesuje. Z dyrektywą default jest drobny problem by ją wytłumaczyć prosto i dokładnie. W powyższym przykładzie kod w default wykona się gdy zmienna będzie inna od 1 i 2. Każda inna wartość spowoduje wykonanie kodu po dyrektywie default. Jednak komenda switch swoje niuansy, które mogą wpłynąć na działanie – ale o tym za chwilę.

Czyli z

 if(code == 0xFFA25D) { // CH_MINUS
    digitalWrite(RELAY, LOW);
  }
  if(code == 0xFFE21D) { // CH_PLUS
    digitalWrite(RELAY, HIGH);
  }

robi się

 switch(code) { 
   case 0xFFA25D: // CH_MINUS
     digitalWrite(RELAY, LOW);
     break;
   case 0xFFE21D: // CH_PLUS
    digitalWrite(RELAY, HIGH);
    break;
  }

Jak widać, nie ma tutaj dużej zmiany w stosunku do poprzedniego kodu. Jednak gdy chcemy obsłużyć więcej klawiszy, drabinka ifów będzie się znacznie zwiększać i switch pokaże swoją przewagę. Wytłumaczyć trzeba jeszcze jedną sprawę. Komenda break powoduje że obsługa danego przypadku zostaje zakończona. O co chodzi? Wgraj na Arduino taki kod (zwróć uwagę na brak breaków) :

void setup() {
  // put your setup code here, to run once:
  Serial.begin(115200);
  int i = 0;

  switch (i) {
    case 0:
      Serial.println("ZERO");
    case 1:
      Serial.println("JEDEN");
    case 2:
      Serial.println("DWA");
    default:
      Serial.println("OLABOGA");
  }

}

void loop() {
  // put your main code here, to run repeatedly:

}

Wgraj na Arduino i otwórz monitor portu szeregowego (pamiętaj by ustawić go na prędkość 115200). Rezultat:

ZERO
JEDEN
DWA
OLABOGA

Zmień int i = 0 na int i = 2 i wgraj ponownie:

DWA
OLABOGA

Dodaj teraz po DWA dyrektywę break:

    case 2:
      Serial.println("DWA");
      break;

Rezultat:

DWA

Możesz poeksperymentować i pozmieniać kolejność dyrektyw wewnątrz instrukcji switch, dodać polecenia break na koniec każdego case, zobacz jak się będzie zmieniać rezultat działania w zależności od wartości i

Czego się  dowiedzieliśmy o switch/case?

  • warunki case są sprawdzane wg kolejności wpisania w kod programu, do natrafienia pierwszego który będzie zwracał wartość logiczną prawda, wtedy wykonywany jest kod w tym warunku i następnych aż do końca komendy switch lub napotkania komendy break
  • brak komendy break na końcu kodu obsługujący dany case powoduje przejście do kodu obsługi następnego case, bez sprawdzania warunku (dlatego bez break’ów przy i=0 nasz przykładowy kod wypisał wszystkie wartości od zera do domyślnej).
    W większości przypadków, pisząc kod każdego case na koniec powinieneś wrzucić break
  • za każdym razem gdy kod dojdzie do dyrektywy default wykona jej treść, dlatego dajemy ją na koniec całego switch

Wydaje się to trochę skomplikowane, ale pozwala to czasem robić rzeczy niestandardowe, pozwalające oszczędzić czas programisty, pozostając czytelne.

Wyliczanki, czyli enum

O ile polecenie switch/case jest w sumie dość często stosowaną konstrukcją, to drugi temat który poruszę jest z obszarów o wiele rzadziej „odwiedzanych” w programach dla Arduino. A chodzi o definiowanie własnych typów danych.

Pierwsza kwestia – typ wyliczeniowy enum. Pozwala on stworzyć własny typ wyliczeniowy danych. Co to znaczy? Że zmienna takiego typu przyjmuje kolejne wartości liczbowe (całkowite). O co chodzi. Weźmy pierwszy przykład który używaliśmy dziś do objaśniania działania switch. Uzupełnijmy go o linię definicji typu:

enum rezultat { ZERO, JEDEN, DWA };

Widzicie dokąd to prowadzi? Kompilator stworzył typ danych (jak int, byte). Zmienna takiego typu może przyjąć trzy wartości, które są ukryte pod etykietami ZERO, JEDEN i DWA, które teraz możemy używać w programie. Jakie są te trzy wartości? Pierwsza (ZERO) domyślnie jest przyjmowana jako 0. Każda kolejna wartość jest o jeden większa. Czyli JEDEN odpowiada 1, itd. Jeśli dopiszemy do definicji słowo TSZY :) to TSZY będzie równe trzy :)

Po co wyliczanki, czyli jakich błędów uniknąć możemy

Zyskujemy w ten sposób cały zbiór etykiet do stosowania w programie (ZERO, JEDEN i DWA), które używane  w kodzie utrudniają popełnienie błędu. Na przykład, jeżeli rezultat byłby „zwykłą”  liczbą całkowitą (int) to moglibyśmy zrobić błąd przypisując wartość – np zmienna = 3 zamiast wartości 2. Zdarza się taka omyłka. Natomiast jeśli korzystamy z typu zdefiniowanego przez enum, używamy etykiety DWA którą kompilator jest w stanie sprawdzić, że wcześniej została zdefiniowana.

Druga kategoria pomyłek, którą nam taki typ pozwala ominąć to błędy, które powstają przy zmianach w programie. Robiąc jakiś projekt na Arduino, tylko w bardzo banalnych i prostych przypadkach nasz program piszemy raz i od razu działa. Zwykle tryb pracy wygląda tak, że piszemy jakiś kawałek realizujący fragment zadania, następnie w kolejnych powtórzeniach dodajemy kolejny kawałek kodu, realizujący kolejne zadania. Zmieniając w trakcie pisania zakres zadań jaki ma projekt realizować można wpaść na problemy ze znaczeniem niektórych zmiennych. Na przykład na początku projektu wartość zmiennej 2 miała znaczyć że wszystkie diody mają świecić, a w kolejnej iteracji okazuje się że na 2 mają świecić tylko niektóre, a na 3 wszystkie. Gdy program ma korzystać z tej zmiennej w różnych miejscach kodu, używanie bezpośredni liczb rodzić może błędy. W jednym miejscu, gdzie kod miał zapalać wszystkie diody zapomnisz poprawić  2 na 3 i już działa źle. Takie pomyłki zwykle trudno znaleźć. Jeśli zamiast tego korzystasz typu enum np : enum stany_diod { ZGASZONE, JEDNA, WSZYSTKIE }; to potem w kodzie definiujesz zmienną np: stany_diod wyswietlacz; i przypisujesz stan np tak wyswietlacz = WSZYSTKIE; a warunek do wyświetlania będzie wyglądał np tak: if (WSZYSTKIE == wyswietlacz) {/* kod */}

Dodanie nowego stanu, przez zmianę definicji na enum stany_diod {ZGASZONE, JEDNA, POLOWA, WSZYSTKIE }; nie powoduje konieczności żadnych zmian w kodzie, bo wyswietlacz = WSZYSTKIE; jest tak samo poprawną znaczeniowo konstrukcją, mimo że liczba jaką stan WSZYSTKIE jest reprezentowana uległa zmianie. Podobnie if działa poprawnie mimo wprowadzenia nowego stanu.

Podobny efekt można osiągnąć przez #define:

#define ZGASZONE 0
#define JEDNA 1
#defina WSZYSTKIE 2

Jednak, w tym wypadku przy dodawaniu nowej etykiety sami musimy pilnować by nie nastąpiło powtórzenie wartość (np by JEDEN i POLOWA nie miały przypisanej tej samej wartości). Przy małej liczbie stanów jest to proste, jednak przy większej liczbie może to sprawiać już kłopot.

OK, przykład: kod po wprowadzeniu tego typu może wyglądać tak:

enum rezultat { ZERO, JEDEN, DWA };
void setup() {
  // put your setup code here, to run once:
  Serial.begin(115200);
  rezultat i = ZERO;
  switch (i) {
    case ZERO:
      Serial.println("ZERO");
    case JEDEN:
      Serial.println("JEDEN");
    case DWA:
      Serial.println("DWA");
    default:
      Serial.println("OLABOGA");
  }

}

void loop() {
  // put your main code here, to run repeatedly:

}

Czy jest jasne już jak można używać enum? Ma on swoje przewagi, pewnie większość podobnych efektów można osiągnąć korzystając z serii dyrektyw #define, ale enum ma swoje przewagi – trudniej o błąd, nie da się powtórzyć wartości jak może mieć miejsce przy #define.

Co dalej?

Uzbrojeni w tą nową wiedzę, możemy ruszyć w stronę następnego przykładu, który nam pokaże jak reagować w różny sposób na naciśnięcia wielu przycisków na pilocie. Już za kilka dni!