W dzisiejszym wpisie przyjrzymy się dwóm reprezentantom struktur danych, które zwyczajowo określa się mianem typów złożonych, czyli swoistym agregatom danych tworzonym przez łączenie innych typów podstawowych.
W przypadku języka GO mamy do czynienia 4 wbudowanymi typami danych tego rodzaju. Są to:
- tablice (arrays),
- wycinki (slices)
- mapy (maps),
- struktury (structs).
Dzisiaj zajmiemy się dwoma pierwszymi z wyżej wymienionych, ponieważ nie sposób omawiać ich osobno, o czym będzie okazja się przekonać nieco dalej. Natomiast pierwotnie planowałem włączyć do niniejszego tekstu również mapy, które podobnie jak tablice i wycinki są agregatami jednorodnymi (pozwalają na przechowywanie danych wyłącznie tego samego typu), ale wówczas wpis prawdopodobnie osiągnąłby rozmiary absolutnie dla mnie nieakceptowalne i byłby jeszcze bardziej nieuporządkowany. Dlatego też mapom poświęcę osobny wpis. Zresztą podobnie stanie się w przypadku struktur, choć zapewne nie nastąpi to tak prędko, ponieważ zamierzam pochylić się nad tym zagadnieniem nieco później, tj. dopiero po uprzednim omówieniu metod oraz funkcji. Wprawdzie nie chcę w tym miejscu uprzedzać faktów, ale struktury to wyjątkowy w przypadku GO typ danych i to nie tylko z powodu tego, że jest to konstrukt heterogeniczny w odróżnieniu od pozostałej trójki z wymienionej wyżej listy. Do pewnego stopnia jest to swoisty sposób realizacji w GO elementów programowania obiektowego, a do tego trzeba chociaż trochę liznąć zagadnienia tworzenia i używania metod, stąd taka a nie inna kolejność.
Tablice
Tablice są złożonymi strukturami danych ze ściśle określoną liczbą elementów. Ponieważ rozmiar tablicy jest statyczny, struktura danych musi alokować pamięć tylko raz, w kontrze do tych struktury danych, gdzie liczebność zbioru może się zmieniać – w efekcie czego pamięć musi być alokowana dynamicznie, by umożliwić w przyszłości potencjalną modyfikacje liczby elementów danej kolekcji. Chociaż stała długość tablic może sprawić, że praca z nimi będzie nieco utrudniona, jednorazowa alokacja pamięci może zwiększyć szybkość i wydajność programu. Z tego powodu programiści zazwyczaj używają tablic podczas optymalizacji programów w przypadkach, w których struktura danych nigdy nie będzie wymagała modyfikowania liczebności elementów.
W przeciwieństwie do tablic wycinki reprezentują sekwencje o zmiennej długości, choć podobnie jak w tablicach wszystkie elementy mają dokładnie ten sam typ. Ponieważ rozmiar wycinków jest zmienny, korzystanie z nich zapewnia znacznie większą elastyczność. Podczas pracy z kolekcjami danych, które mogą w przyszłości wymagać zmiany wielkości zbioru elementów, użycie wycinka gwarantuje, że kod nie napotka błędów podczas próby manipulowania długością danej kolekcji. Z tego powodu wydają się dużo bardziej przydatne do hobbystycznych – czyli nas interesujących – zastosowań, gdzie optymalizacja działania aplikacji nie ma aż tak fundamentalnego znaczenia. Tym niemniej aby zrozumieć wycinki, musimy najpierw zrozumieć tablice.
Definiowanie tablicy zaczyna się od zadeklarowanie jej rozmiaru (liczby przechowywanych elementów w danej kolekcji), którą to wartość zapisujemy w przy pomocy nawiasów kwadratowych [ ]. Następnie wskazywany być musi typ danych, które mogą być do tablicy przekazana (przypominam, że tablica w GO musi mieć wszystkie elementy tego samego typu). Dopiero po tym wszystkim można od razu zadeklarować poszczególne wartości dla kolejnych elementów tablicy, co sprowadza się do podania odpowiedniej listy w ramach klamrowych { }. Tym niemniej jeśli nie zadeklarujemy od razu wartości jako elementów naszej tablicy, poszczególne pozycje przyjmą wartość zerową dla danego typu (np. dla liczb całkowitych jest to reprezentowane przez zero, a dla wartości logicznych będzie to false – zagadnienie to było omawiane przy okazji wprowadzenia do zmiennych).
Niżej widać przykład zmiennej tablicowej o nazwie number, która zawiera trzy elementy będące liczbami całkowitymi (integer), które nie mają jeszcze przypisanych wartości (de facto kompilator podstawi pod nie 3 zera):
var numbers [3]int
fmt.println(numbers) /// na wyjściu zobaczymy następujący wynik [0 0 0]
W kolejnym przykładzie od razy deklarujemy zmienną tablicową z od razu przypisanymi do niej wartościami:
systemy := [5]string{“linux”, “windows”, “bsd”, “darwin”, “android”}
W tym ostatnim przykładzie można byłoby zrezygnować ze wskazywania wprost długości ustanowionej właśnie kolekcji, ponieważ w tym samym momencie nastąpiło również podanie listy wartości, z których ta tablica będzie się składać. W takim wypadku – jeśli nie chce nam się liczyć bądź gdy wiemy, że w tym miejscu przekazana zostanie lista o skończonej długości, której z jakiś powodów jeszcze nie znamy, bo na przykład będzie pobrana z zewnątrz – możemy w między nawiasami kwadratowymi wpisać trzy kropki […], które w tym wypadku “nakażą” kompilatorowi długość tablicy samodzielnie porachować. Tym samym nasza deklaracja mogłaby wyglądać równie dobrze w ten sposób:
systemy := […]string{“linux”, “windows”, “bsd”, “darwin”, “android”}
Skoro już potrafimy utworzyć na kilka sposobów tablice, to jeszcze słowo o sposobie wyświetlania ich zawartości. Najwygodniejszym czy właściwie najbardziej czytelnym sposobem prezentacji jest prawdopodobnie użycie funkcji fmt.Printfm (patrz przykład poniżej), która pozwala odpowiednio sformatować zawartość tablicy przed jej wyświetleniem.
fmt.Printf(“%q\n”, systemy)
Popularna funkcja fmt.Println niestety w tym wypadku nie zawsze się sprawdza najlepiej (zwłaszcza w przypadku łańcuch znaków), ponieważ nie separuje odpowiednio poszczególnych elementów tablicy.
Indeksowanie tablic (oraz wycinków)
Jak już mamy naszą tablice gotową, to możemy się odwołać do każdego jej elementu dzięki mechanizmowi indeksowania tych struktur. Otóż każdy element tablicy/wycinka odpowiada numerowi indeksu, który jest wartością liczbą całkowitą. Pierwszemu elementowi w naszej tablicy/wycinku odpowiada zero, a później wartości idą i każdy kolejny indeks ma wartość o jeden większą.
Chcąc wyświetlić trzeci element z wcześniej utworzonej tablicy “systemy” (będzie to wartość “bsd”) wystarczy wskazać na indeks o numerze 2 (patrz przykład poniżej):
fmt.Println(systemy[2])
Warto pamiętać, że w odróżnieniu od niektórych popularnych języków, w przypadku GO w takiej notacji nie możemy wychodzić poza zakres indeksu (poza jednym wyjątkiem) czy używać ujemnych wartości indeksu. Jest to dość popularny i nierzadko przydatny mechanizmy w Pythonie. Tutaj on się nie sprawdzić, no chyba, że zależy nam na wygenerowaniu błędu kompilatora.
Indeksowania możemy użyć do tego, aby zmienić wybrane elementy w tablicy lub wycinku, ustawiając dla elementu o wskazanym numerze indeksu inną wartości. Dla przykładu, gdybyśmy w naszej tablicy “systemy” chcieli zmienić wartość “windows” (odpowiada jej indeks 1) na bardziej swojską nazwę “okna” wystarczy użyć następującego kodu:
systemy[1] = “okna”
Na zakończenie tej części warto wspomnieć o wbudowanej funkcji GO o nazwie len(), która oblicza długość tablicy lub wycinka. Tym samym jeśli użyjemy tej funkcji dla naszej kolekcji “systemy” (tak jak widać to niżej) na naszym monitorze wyświetli się liczba 5, ponieważ z tylu właśnie elementów składa się ta konkretna tablica.
fmt.Println(len(systemy))
Wycinki
Definiowanie wycinków wygląda bliźniaczo podobnie do tego, co można zobaczyć w przypadku tablic. Oczywiście przy tworzeniu (deklarowaniu) wycinków siłą rzeczy nie wskazujemy z góry określonej długości dla inicjowanej właśnie kolekcji. Dlatego też między nawiasami klamrowymi (“[]”) nie wstawiamy żadnej wartości. Całość może wówczas wyglądać tak:
systemy := []string{“linux”, “windows”, “bsd”, “darwin”, “android”}
Gdybyśmy z jakiś powodów chcieli utworzyć wycinek o określonej długości bez jednoczesnego inicjowania konkretnych wartości dla poszczególnych elementów tej kolekcji, można użyć do tego celu wbudowanej funkcji make():
systemy := make([]string, 5)
W efekcie powstanie wycinek, który zawiera 5 elementów, dla których na dzień dobry zostanie przypisana wartość składająca się z pustego łańcucha znaków (“”).
Tworzenia wycinków z istniejących tablic
Używając numerów indeksu do wyznaczania interesującego nas zakresu, czyli poprzez określenie początku i punktu końcowego, możemy wykroić jakąś podsekcję wartości z tablicy, tworząc w ten sposób wycinek. Taki zakres tworzy się poprzez podanie wspomnianego początku i końca wyznaczonego przez numer indeksu, które to wartości oddzielone są znakiem dwukropka.
Załóżmy, że chcemy po prostu wyświetlić środkowe elementy tablicy “systemy”, czyli bez pierwszego i ostatniego elementu tej kolekcji. Możesz to zrobić, tworząc wycinek rozpoczynający się od indeksu 1 i kończący się tuż przed indeksem 4:
package main
import "fmt"
func main() {
systemy := [5]string{"linux", "windows", "bsd", "darwin", "android"}
os := systemy[1:4]
fmt.Printf("%q\n", os)
}
Warto zwrócić uwagę, że w takim wypadku podaliśmy jako indeks końcowy liczbę 4, która odpowiada wartości “android” z naszej tablicy a nie “darwin” (odpowida temu elementowi indeks z numerem 3), ponieważ przy tej notacji ostatni element zakresu nie jest brany pod uwagę (stanowi granicę, do której sięga nasz zakres).
Natomiast jeśli chcemy od początku do jakiegoś punktu lub od jakiegoś punktu do końca, wystarczy wówczas pominąć podawanie numeru indeksu przed lub po dwukropku. Na przykład, jeśli chcesz wydrukować pierwsze trzy elementy z naszej tablicy “systemy” możemy to zrobić za pomocą poniższej notacji:
fmt.Println(systemyl[:3])
W efekcie zobaczymy 3 pierwsze elementy, ponieważ 4 z nich oznaczony indeksem 3, nie wchodzi w zakres (o czym była mowa wcześniej).
W przypadku “odwrotnej” notacji, czyli takiej gdzie wskazujemy punkt początkowy, ale chcemy zatrzymać się dopiero na samym końcu tablicy (patrz przykład niżej), jest nieco inaczej ponieważ indeks początkowy jest włączany do wycinka.
fmt.Println(systemyl[1:])
Gdybyśmy pierwotnie utworzyliśmy tablicę i po jakimś czasie stwierdzili, że jednak potrzebujemy bardziej dynamicznej kolekcji (o zmiennej długość), możemy w dość prosty sposób przekonwertować ją na wycinek właśnie. Należy jednak przy tym pamiętać, że nie jest to konwersja wprost, czyli tak naprawdę mając jakąś zmienną typu tablicowego musimy skopiować jej zawartość do nowej zmiennej będącej wycinkiem.
W tym celu możemy użyć wprowadzonego przed momentem mechanizmu krojenia tablic. Wystarczy wówczas wybrać cały zakres tablicy, czyli w praktyce opuścić wyznaczenia miejsca startu i końca interesującego nas wycinka:
os := systemy[:]
Dodawanie i usuwanie elementów wycinka
W przypadku wycinków nie jesteśmy rzecz jasna ograniczeni do stałej/niezmiennej liczby elementów, w związku z tym możemy dodawać dowolną liczbę nowych pozycji lub usuwać te, które stają się finalnie niepotrzebnie. Do tego pierwszego celu wykorzystywana jest wbudowana w GO funkcja append(), która przyjmuje jakiś wycinek jako pierwszy argument oraz oczekuje przekazania w drugim argumencie wartości dla nowo dodawanego elementu. Ten ostatni umieszczany jest wówczas na końcu modyfikowanej właśnie kolekcji. Tym samym funkcja append() zwraca de facto nowy, większy wycinek, który zawiera te same elementy co pierwotna kolekcja uzupełnione o dołączone na końcu nowe elementy.
systemy := [5]string{“linux”, “windows”, “bsd”, “darwin”, “android”}
systemy = append(systemy, “haiku”)
Oczywiście jeśli chcemy dodać więcej niż jeden nowy element nie musimy wywoływać tej funkcji kilka razy, ponieważ funkcja append() jest tzw. funkcją wariadyczną (jak chociażby tak Println), a więc w jednym wywołaniu możesz przekazać jej wiele elementów, które ma dołączyć, tak jak to widać w przykładzie poniżej:
systemy := [5]string{“linux”, “windows”, “bsd”, “darwin”, “android”}
systemy = append(systemy, “haiku”, “reactOS”)
Stety lub nie w języku GO na próżno szukać – jak to ma miejsce w innych językach – wbudowanych funkcji pozwalających do usuwania elementu z zakresu wycinka. Dlatego do tego celu używany do tego jest znany już nam mechanizm operatora wycinków. Tym samym, aby usunąć konkretny element musimy po prostu wyciąć wszystkie elementy występujące przed tym niechcianym intruzem oraz pozostałe, który po nim występują, a następnie je połączyć za pomocą funkcji append(). Dajmy na to, że chcemy z naszej kolekcji “systemy” usunąć wartość “windows”, który to element przypisany jest do indeksu o numerze 1. Całość wyglądałaby tak:
package main
import "fmt"
func main() {
systemy := []string{"linux", "windows", "bsd", "darwin", "android"}
fmt.Println(systemy)
systemy = append(systemy[:1], systemy[2:]...)
fmt.Println(systemy)
}
Zwracam uwagę na konieczność umieszczenia trój kropka na końcu funkcji append.
Konstruowanie wielowymiarowych wycinków
Możesz także zdefiniować wycinki oraz tablice składające się z innych wycinków/tablic jako elementów tej kolekcji. Wówczas każdą taką listę/element umieszcza się w nawiasach klamrowych wewnątrz nadrzędnego wycinka. Następnie poszczególne wartości tak spreparowanej kolekcji można traktować jako odpowiadające współrzędnym wielowymiarowym. Brzmi to szalenie niezrozumiale więc najlepiej zademonstrować to na przykładzie. Najlepiej zaprezentować to na przykładzie wycinka o “wymiarach” 2 w poziomie i 3 w pionie (czyli kolekcję, która ma dwa elementy, z których każdy jest wycinkiem trójelementowym. Zbiór z naszego przykładu będzie reprezentował dwa “osobne” zestawienie systemów operacyjnych, tj. dla komputerowych oraz urządzeń mobilnych.
package main
import "fmt"
func main() {
systemy := [][]string{{"linux", "windows", "darwin"}, {"android", "ios", "symbian"}}
fmt.Println(systemy)
fmt.Println(systemy[0][1])
fmt.Println(systemy[1][0])
}
Jeśli uruchomimy kod wyżej, wówczas na naszym monitorze zobaczymy najpierw oba rozdzielne zbiory w ramach jednego wycinka, a następnie informację o 2 elemencie pierwszego zbioru (windows) oraz 1 elemencie drugiego (“android”).
Na zakończenie
Omówione dzisiaj kwestie nie wyczerpują oczywiście tematu tablic i wycinków, ale opanowanie tego zakresu materiału stanowi solidne podłoże do dalszej eksploracji, a zapewne dla celów hobbystycznych zazwyczaj w zupełności wystarczy. Natomiast jeśli w przyszłości uznam, że warto jakieś zagadnienie dodatkowo poruszyć, to nie omieszkam zmodyfikować niniejszego tekstu. Na ten moment to jednak wszystko, zaś kolejny wpis będzie dotyczył innego reprezentanta jednorodnych kolekcji, czyli omówione zostaną wówczas mapy.