Skocz do zawartości

Tablice w Arduino – #6 – argumenty funkcji


Pomocna odpowiedź

W poprzedniej części kursu zapoznaliśmy się z tworzeniem tablic znaków – wiedza ta może przydać się w świadomym gospodarowaniu pamięcią operacyjną. W tej części przyjrzymy się jak przekazywane są tablice do funkcji – dzięki temu będzie można lepiej zorganizować rozbudowane programy.

rysunki_7.thumb.jpg.24ab3b84db1aba0563fa0c432f585a63.jpg

Spis treści

Przeglądając kod źródłowy biblioteki Arduino, z którym dość wybiórczo zapoznaliśmy się w ostatniej części, można było zauważyć, że niektóre funkcje w swoich argumentach przyjmują wskaźnik, a następnie przeprowadzane są iteracje używając zmiennej reprezentującej rozmiar danych.

size_t Print::write(const uint8_t *buffer, size_t size)
{
  size_t n = 0;
  while (size--) {
    if (write(*buffer++)) n++;
    else break;
  }
  return n;
}

Jak dowiedzieliśmy się z 4 części kursu, tablica niesie w sobie informację o adresie pierwszego elementu oraz o rozmiarze obszaru pamięci. Więc wskaźnik przekazany w argumencie funkcji jest jednoznaczny z przekazaniem tablicy. Wiemy jednak, że sam wskaźnik do tablicy nie zawiera informacji o rozmiarze tej tablicy więc należy podać w osobnej zmiennej jej rozmiar.

W skrócie o tym będzie ta część, wszelkie niuanse będą omówione dalej. Najpierw jednak wyjdźmy od prostszego tematu – jak przekazywane są w funkcji zwykłe zmienne.

t10.thumb.png.90de3f9860bcbdb449ef4c830ef7d454.png

Zwykłe zmienne w argumentach funkcji

Zmienne jako argumenty funkcji można przekazać na 3 sposoby:

  • przez wartość,
  • przez referencję,
  • przez wskaźnik.

Domyślnie argumenty przekazywane są przez wartość – na stosie tworzona jest ich kopia, na której możemy działać wewnątrz funkcji. Poniższa funkcja pozornie niszczy pierwotną zawarta w zmiennej "wchodzącej" do funkcji, bo cyklicznie coś od niej odejmujemy. Okazuje się jednak, że opuszczając funkcję (niszcząc dane na stosie) oryginalna zmienna jest nietknięta, dlatego że wewnątrz funkcji działaliśmy jedynie na jej kopii.

void printer(int x) {
  Serial.print(F("Printing values from 0 to ")); Serial.println(x);
  while(x--)
    Serial.println(x);
}

void setup() {
  Serial.begin(9600);
  int n = 3;
  printer(n);
  printer(n);
}

W wyniku otrzymamy:

Printing values from 0 to 3
2
1
0
Printing values from 0 to 3
2
1
0

Jak widać oryginalna zmienna nie jest modyfikowana, choć wewnątrz funkcji jej kopia została zredukowana do 0.

Czy jest różnica w zapisie while(x--) i while(--x)? Sprawdź sam w praktyce.

Co zrobić, aby możliwa była modyfikacja zmiennej? Jednym ze sposobów jest zwrócenie wartości przez funkcję instrukcją return i ponowne przypisanie. Przykładem może być funkcja wyznaczająca wartość bezwzględną:

int absolute(int x) {
  return x < 0 ? -x : x;
}

void setup() {
  int x = -5;
  x = absolute(x);
}

Funkcja ta jest jednak niewydajna. Nie dość, że podając argument musimy skopiować zmienną i odłożyć ją na stosie, to później potrzebna jest tymczasowa zmienna, która zwróci wartość. Ale na tym niedogodności się nie kończą.

Co zrobić, gdybyśmy chcieli zmodyfikować więcej zmiennych? Przykładowo napiszmy funkcję zamieniającą 2 zmienne:

void swap(int a, int b);

Ciężko nawet wymyśleć typ zwracanych danych, jakbyśmy znali struktury to można się o jakąś pokusić. Zróbmy prościej – zmieńmy typ argumentów, tak by przekazane zostały adres do zmiennych.

t11.thumb.png.b0f8481b187cec7954dbc92e2b729d11.png

Przekazywanie przez wskaźnik

Przekazanie zmiennej przez wskaźnik polega na uzyskaniu adresu zmiennej pod jakim znajduje się w pamięci i wysłaniu do funkcji (utworzeniu na stosie) kopii tego adresu Znowu wiec coś jest kopiowane, ale jak się okaże w przypadku tablic, mechanizm ten pozwala zaoszczędzić sporo pamięci, bo nie kopiujemy całej tablicy tylko adres (1 zmienną).

Czy przesłanie kopii adresu oznacza, że adres (zmienna tego typu) też ma swój adres? Zgadza się!

Najłatwiej zrozumieć przekazanie zmiennej przez wskaźnik na przykładzie. Zmodyfikujmy więc funkcję zamieniającą zmienne:

void swap(int *a, int *b) {  // 2
  int temp = *a;             // 3
  *a = *b;                   // 4
  *b = temp;                 // 5
}

void setup() {
  Serial.begin(9600);
  int a = -5;
  int b = 11;
  swap(&a, &b);              // 1
  Serial.println(a);
  Serial.println(b);
}

W wyniku otrzymamy:

11
-5

Udało się! Zmienne zostały zamienione. Przyda się jednak krótkie wyjaśnienie co tu właściwie się dzieje:

  1. do funkcji przekazujemy adresy zmiennych stosując operator &,
  2. argumenty funkcji są typu int * oznaczającego wskaźnik na zmienną typu int,
  3. tworzymy kopię wartości zmiennej a posługując się operatorem dereferencji *,
  4. przepisujemy wartość zmiennej b (stosując zapis *b) do obszaru wskazywanego przez wskaźnik *a,
  5. do obszaru wskazywanego przez wskaźnik *b przepisujemy tymczasową wartość temp.

Krok 4 może budzić szczególne wątpliwości, ale tak wygląda ta notacja. Jako że samo a jest adresem i *a obszarem pamięci to możliwe jest wpisanie do tego obszaru wartości *b jak do zwykłej zmiennej.

Podobnie możliwe jest wpisanie do elementu tablicy i odczytanie z tablicy posługując się tą samą notacją.

t12.thumb.png.6d085ae7362e3fdf5dc36d32d1a8b1f2.png

Przekazywanie przez referencję

Wskaźniki są metodą znaną już w C, gdzie był to sposób na przekazanie informacji o dużych danych, tworzenie drzew, list, itp.. W C++ wszedł kolejny sposób odnoszenia się do oryginału zmiennej przez tzw. referencję.

Nie należy utożsamiać referencji ze wskaźnikiem, choć określenie referencja w ogólności jest z tym związane.

Referencja służy do stworzenia aliasu (przezwiska) nazwy zmiennej – używając go odnosimy się do oryginalnej zmiennej. Mechanizm ten może służyć, do przesyłania zmiennych do funkcji.

Przykład zamiany wartości zmiennych może wyglądać następująco:

void swap(int &a, int &b) {
  int temp = a;
  a = b;
  b = temp;
}

void setup() {
  Serial.begin(9600);
  int a = -5;
  int b = 11;
  swap(a, b);
  Serial.println(a);
  Serial.println(b);
}

Wygląda na dużo prostszy sposób – brak gwiazdek, ale nie był to cel wprowadzenia nowej składni. Referencje w szczególności sprawdzają się przy przesyłaniu obiektów klas czym w ramach tego kursu się nie zajmiemy.

t13.thumb.png.197242cf498f20c3488d4c9bcccc7b41.png

Kiedy co i gdzie?

Może się nasunąć pytanie, którego zapisu powinno się używać? Wskaźniki przyszły z języka C i są podstawą, np. tablica jest zaprojektowana przy pomocy wskaźnika i tam też będziemy używać zapisu z gwiazdką.

Referencje mają swój specyficzny obszar zastosowań, a w przypadku typów elementarnych można je użyć w sytuacjach, gdy chcemy zabezpieczyć adres przed zmianą. Zwykły wskaźnik może wskazywać na różne obszary pamięci, przykładowo:

int variable_A = 5;
int variable_B = 10;

int *addr = &variable_A;

//zmiana adresu
addr = &variable_B;

Zmienna reprezentująca adres (wskaźnik na typ int) została zmieniona - wskazuje teraz inną zmienną o wartości 10. Stosując mechanizm referencji nie da się tego tak zrobić, ponieważ referencja musi być od razu zadeklarowana:

int variable_A = 5;
int variable_B = 10;

int &alias = variable_A; //przezwisko zmiennej A

alias = variable_B;

W takim zapisie adres zmiennej alias się nie zmieni, zmieni się wartość zarówno przezwiska jak i oryginalnej zmiennej variable_A. Kogoś mogłoby jednak korcić postawienie operatora & po prawej stornie – w takim układzie będzie zgrzyt, bo służy on do uzyskania adresu zmiennej (wskaźnika).

// alias = &variable_B;
// (int&) to nie (int*)

Dla początkujących bardzo trudnym tematem jest rozgraniczenie * i &, wskaźników i referencji. Pół biedy, gdybyśmy pisali w samym C, gdzie * i & to pewnie coś od wskaźników, ale w C++ ktoś wpadł na pomysł, że (w uproszczeniu) to po której stornie od znaku = stoi operator zmienia jego sens... np. operator & raz tyczy się wskaźników, a raz referencji. Jak to rozróżnić?

W uproszczeniu (mocnym uproszczeniu) można założyć, że wskaźnik:

  • kojarzymy z typem danych z gwiazdką, np. int *, float *, void *, itp.,
  • kojarzymy z operatorem * stojącym po prawej stronie (odczytanie wartości wskazywanej przez adres) lub po lewej stornie (odwołanie się do wartości wskazywanej przez adres) od znaku =,
  • kojarzymy z operatorem & stojącym po prawej stornie znaku =, lub w argumentach funkcji ale gdy "wkładamy" do niej coś – służy uzyskaniu adresu czyli zwraca typ danych wskaźnika np. int *, itp.,
  • w argumencie funkcji spotkamy typ danych z *, zaś obiekty podawane w argumentach muszą być adresem (zmienna z operatorem &).

Zaś, referencję:

  • kojarzymy z typem danych z ampersandem, np. int &, float &, itp.,
  • kojarzymy z operatorem & stojącym po lewej stronie znaku = i brakiem dodatkowych operatorów po prawej stornie,
  • w argumencie funkcji spotkamy typ danych z &, zaś obiekty podawane w argumencie "wkładane do funkcji" nie posiadają dodatkowych operatorów.

Warte przypomnienia: &zmienna nie zwróci nam typu danych int &.

Na tym zakończę ten temat, gdyż dla lepszego zrozumienia trzeba by wejść w inne tematy jak np. lwartości, rwartości, a to wcale nie zbliży nas do tablic, bo temat referencji w tym obszarze będzie zbędny.

t14.thumb.png.8c057d2c4452162abe95668f9f5c3dfe.png

Przekazywanie tablicy do funkcji

Tablice na ogół są dość duże stąd nie byłoby sensu przekazywać ich do funkcji przez wartość (wiązałoby się to z koniecznością alokacją nowych obszarów danych dynamicznych co jak się przekonaliśmy może powodować problemy). Dlatego ważna informacja:

Tablice są zawsze przekazywane do argumentów funkcji przez wskaźnik (adres).

Dla ułatwienia można zapamiętać, że domyślnie w funkcji wpisujemy nazwę zmiennej. Nazwa tablicy to inaczej wskaźnik. Więc wpisując do funkcji nazwę tablicy tak naprawdę podajemy wskaźnik. Przykład może wyglądać następująco:

void printer(int tab[], int len) {
  for(int i = 0; i < len; ++i) {
    Serial.print(i); Serial.print(F(": " )); Serial.println(tab[i]); 
  }
}

int some_tab[] = {1, 2, 3, 4, 5, 6};
const int some_tab_len = sizeof(some_tab) / sizeof(some_tab[0]);

void setup() {
  Serial.begin(9600);
  printer(some_tab, some_tab_len);
}

W wyniku otrzymamy wypisaną zawartość tablicy:

0: 1
1: 2
2: 3
3: 4
4: 5
5: 6

Warto zwrócić uwagę na argumenty funkcji:

  • tablica nie ma rozmiaru – jak wspomniałem na początku przy przesyłaniu tablicy do funkcji jest to "goły" wskaźnik bez informacji o rozmiarze, więc w tej notacji z nawiasami klamrowymi nie podajemy rozmiaru tablicy (chyba, że pracujemy z tablicami wielowymiarowymi).
  • rozmiar tablicy trzeba podać w osobnym argumencie.

Innym sposobem zapisu argumentu jest podanie samego wskaźnika:

void printer(int *tab, int len) {
  for(int i = 0; i < len; ++i) {
    Serial.print(i); Serial.print(F(": " )); Serial.println(*(tab + i)); 
  }
}

Tu również rozmiar tablicy został utracony, a wynik operatora sizeof() jest rozmiarem typu danych wskaźnika.

image.thumb.png.e74ddc8656b049d77cd8675fe022b1c1.png

Modyfikacja tablicy w funkcji

Jak wygląda wpisanie czegoś do tablicy? Rozbudujmy przykład o funkcję wypełniającą tablicę wartościami:

void fibbs(int tab[], int len) {
  if (len < 3)
    return;

  tab[0] = 1;
  tab[1] = 1;
    
  for (int i = 2; i < len; i++)
    tab[i] = tab[i - 1] + tab[i - 2];
}

Jak widać wykonujemy operację na argumencie funkcji. Po jej opuszczeniu i wypisaniu wyników wcześniej zdefiniowaną funkcją printer() zobaczymy, że tablica została nadpisana:

0: 1
1: 1
2: 2
3: 3
4: 5
5: 8
6: 13
7: 21
8: 34
9: 55

W zasadzie na tym się kończy teoria, nie jest to skomplikowane, ważne jest tylko zapamiętanie jak odbywa się przesłanie tablicy.

image.thumb.png.5e7d63bd8b81f95381df07f73e011533.png

Fragment danych

Zdarza się, że potrzebujemy uzyskać dostęp do fragmentu danych tablicy, np. gdy mamy zapisaną długą próbkę dźwięku, a potrzebujemy przeanalizować tylko mały fragment. W takiej sytuacji możemy spróbować wyciąć dane (oczywiście bez kopiowania). To tak jakby podkreślić w książce jakiś akapit, bez wycinania treści ani przepisywania.

Zacznijmy od przygotowania danych – posłuży do tego funkcja wypełniająca dane losowymi wartościami. Warto ustawić stały zarodek generatora liczb losowych (ang. seed) aby wyniki były powtarzalne:

constexpr int SAMPLE_LENGTH = 8;
long sample[SAMPLE_LENGTH];

void prepare_data(long *data, int data_length, long amplitude) {
  while(data_length--)
    *data++ = random(2 * amplitude) - amplitude;
}

void setup() {
  Serial.begin(9600);
  randomSeed(12345LU);
  prepare_data(sample, SAMPLE_LENGTH, 100L);
}

void loop() {}

W funkcji prepare_data() korzystamy z wiedzy zdobytej o przekazywaniu argumentów do funkcji:

  • zmienna data_length jest kopią więc możemy jej użyć do iteracji,
  • operacja postdekrementacji (data_length--) umożliwia przejście po wszystkich zmiennych – zmienna jest porównywana i dopiero później zmniejszana, dzięki czemu licznik przedwcześnie nie osiągnie zera, zaś używając operacji predekrementacji pominęlibyśmy ostatni element (masz już odpowiedź na pytanie z pracy domowej),
  • wskaźnik na tablicę można modyfikować nie naruszając oryginalnej tablicy, bo jest to tylko kopia wskaźnikaktórą podczas wywołania funkcji trzymamy na stosie, więc śmiało można inkrementować adres.

Dzięki temu funkcja jest kompaktowa i elastyczna – można ją zastosować do różnych rozmiarów tablic.

Warte przypomnienia: wskaźnik na tablicę przekazany do funkcji można modyfikować, bo jest to tylko kopia – tablica nie zostanie naruszona.

Aby wypisać wszystkie dane w tablicy można użyć następującej funkcji:

void print_data(const long *data, int data_length) {
  while(data_length--)
    Serial.println(*data++);
}

Jest ona dość podobna, acz kryje się tu ciekawostka. Widzimy wyraźnie specyfikator const ale zmienną (co prawda wskaźnik) data możemy inkrementować... jak to?

W tym przypadku słowo kluczowe const służy jako zabezpieczenie danych. Stąd elementy tablicy wskazywanej przez zmienną data nie da się modyfikować, za to możliwa jest zmiana samego wskaźnika.

Słowo kluczowe const pozwala zabezpieczyć funkcje, które nie powinny modyfikować elementów tablicy, a tylko korzystać z wartości jej elementów.

W wyniku używa tej funkcji uzyskamy podobne wyniki:

-85
-76
-84
-4
31
99
-80
-50

Widzimy, że wszystko działa jak należy. Dość często spotkasz taki zabieg w funkcjach odczytujących ciąg znaków – dane wejściowe nie muszą być typu const char *, to tylko obietnica funkcji, że nic nam w tych danych nie namiesza 🙂 

Zwiększy teraz liczbę danych do np. 250 – zauważymy spory wzrost zużycia pamięci operacyjnej o ponad 50 punktów procentowych:

Szkic używa 11324 bajtów (35%) pamięci programu. Maksimum to 32256 bajtów.
Zmienne globalne używają 1330 bajtów (64%) pamięci dynamicznej, pozostawiając 718 bajtów dla zmiennych lokalnych. Maksimum to 2048 bajtów.

Wypisanie tych danych tekstowo mija się z celem, dlatego dla lepszej wizualizacji można skorzystać z kreślarki:

image.thumb.png.6c67d177862a1cc56dccd62b98284203.png

Co jednak zrobić, aby móc w programie w wygodny sposób odnieść się do wycinka danych? Napiszmy kolejną funkcję, która wypisze tylko wybrany przedział:

void print_data_batch(const long *data, int data_length, int start_index, int count) {
  data += start_index;
  while(count--)
    Serial.println(*data++);
}

Dla początkowego indeksu 0 i liczby elementów 8 funkcja powinna zwrócić identyczny wynik co uzyskaliśmy wcześniej.

Aby zabezpieczyć funkcję przed niewłaściwymi danymi można jeszcze pokusić się o ich uściślenie:

void print_data_batch(const long *data, int data_length, int start_index, int count) {
  start_index = min(start_index, data_length);
  count = min(count, data_length - start_index);
  
  data += start_index;
  while(count--)
    Serial.println(*data++);
}

Dzięki temu nie sięgniemy poza indeks tablicy i nie wypiszemy tyle danych ile się uda:

  print_data_batch(sample, SAMPLE_LENGTH, 247, 8);

Dlatego w tym przypadku uzyskamy:

48
4
-81

Podsumowanie

Jak można zauważyć przekazywanie tablic do funkcji nie jest tak bardzo trudne, a głębsze wniknięcie w temat pozwala pisać całkiem ciekawe kody programów. Funkcje są niezbędnym elementem programowania, gdyż pozwalają wydzielić pewną funkcjonalność do "narzędzi", które można wielokrotnie używać. Udało się nam też nieco pogłębić temat wskaźników i poznać referencje C++.

W kolejnej części przećwiczymy całość zdobytej wiedzy na przykładzie, tak by mając całkiem solidną podstawę móc wejść w głębsze tematy.

 

Edytowano przez Gieneq
  • Lubię! 1
Link to post
Share on other sites
  • Gieneq zmienił tytuł na: Tablice w Arduino – #6 – argumenty funkcji

Planowo część miała być tydzień temu, obiecałem że w poniedziałek, to mamy w piątek (też na literę p). Jeden z ciekawszych odcinków, na pewno bardzo praktyczny. Temat przekazywania tablic jednowymiarowych do funkcji można spokojnie zamknąć w jednym przykładzie, ale warto zapoznać się z dodatkową treścią. 📚

  • Lubię! 1
Link to post
Share on other sites

Dołącz do dyskusji, napisz odpowiedź!

Jeśli masz już konto to zaloguj się teraz, aby opublikować wiadomość jako Ty. Możesz też napisać teraz i zarejestrować się później.
Uwaga: wgrywanie zdjęć i załączników dostępne jest po zalogowaniu!

Anonim
Dołącz do dyskusji! Kliknij i zacznij pisać...

×   Wklejony jako tekst z formatowaniem.   Przywróć formatowanie

  Dozwolonych jest tylko 75 emoji.

×   Twój link będzie automatycznie osadzony.   Wyświetlać jako link

×   Twoja poprzednia zawartość została przywrócona.   Wyczyść edytor

×   Nie możesz wkleić zdjęć bezpośrednio. Prześlij lub wstaw obrazy z adresu URL.

×
×
  • Utwórz nowe...

Ważne informacje

Ta strona używa ciasteczek (cookies), dzięki którym może działać lepiej. Więcej na ten temat znajdziesz w Polityce Prywatności.