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!
Warto dodać, że funkcja „switch” działa inaczej niz „if”, to tablica, którą CPU operuje znacznie szybciej niz warunkami, poniewaz wie do czego sie odnieść od razu. Przez co oszczędza się na cyklach pracy CPU. Im większa tablica „case”, tym ilość pracy CPU mniejsza. Robiono testy i już 5 warunków „if ” zajmuje CPU znacznie więcej pracy niż 1 tablica z 5 „case”. Sprawa może być ważna przy większych warunków i szybkości przetwarzania. MA też swoje wady. Switch obsługuje tylko 1 warunek. A jak mamy dwa do spełnienia, to juz i tak musimy używać „if”.
Kod na switch case wygląda dużo bardziej czytelnie