Wszystko o slicesach

Wycinki to typ, który wart jest głębszego omówienia, bo jest szalenie wygodny i użyteczny, im wcześniej się z nim zaprzyjaźnisz tym lepiej.

Czym są slicesy

Slice to przykrywka na tablice, które są toporne w użyciu. Dla każdej zmiennej tablicowej trzeba przewidzieć rozmiar (bo przecież [2]int to nie to samo co [1]int), trzeba pamiętać, by w wywołaniach funkcji przekazywać wskaźniki do tablic, a nie wartości tablic (bo wartości argumentów funkcji są kopiowane). Generalnie operowanie na zwykłych tablicach nastręcza trudności.

Dlatego twórcy Go zaimplementowali dużo lżejszy w użyciu i elastyczniejszy typ indeksowanej kolekcji, który w podstawowym działaniu przypomina tablice, to jest:

  • jego elementy mogą być tylko jednego określonego typu
  • dostęp do poszczególnych elementów odbywa się tak jak w przypadku tablic za pomocą operatora []
  • iterowalny
  • o wyliczalnej ilości elementów (wbudowana funkcja len())

Dodali mu jednak kilka innych uelastyczniających cech:

  • nie posiada z góry określonej ilości elementów - jest zmienna
  • jest typem referencyjnym - wartości tego typu są referencjami, zatem nie trzeba się martwić o kopiowanie dużych ilości danych przy wywołaniach funkcji
  • można w łatwy sposób zwiększać pojemność takiej wartości (append)

Są trzy sposoby tworzenia wycinków:

  • make([]T,len,cap), alokacja nowego wycinka o zadanych parametrach (szczegóły w artykule o wartościach)
  • literał (szczegóły w artykule o literałach)
  • za pomocą operatora wycinania z tablic lub innych wartości slice

Operator wcinania

Jedną z podstawowych sytuacji w których powinniśmy użyć slicesa zachodzi, gdy chcemy przekazać część tablicy (lub stringa czy innego slice) np. w wywołaniu funkcji, bez kopiowania jej. Wtedy możemy uciec się do operacji wycinania za pomocą operatora [od : do], gdzie od to indeks od którego zaczynamy wycinanie a do to indeks końca wycinania (element o tym indeksie nie wchodzi do wycinka).

Powyższy fragment kodu tworzy zmienną sliceInt, która będzie wskazywała na wartości 2 i 3. Indeksowanie slicesów rozpoczyna się od zera więc sliceInt[0] będzie wskazywało 2 a sliceInt[1] będzie wskazywało 3.

Indeksy od i do są opcjonalne, brak indeksu od oznacza "od początku" a brak indeksu do oznacza "do końca".

s1 := arr[:] //wycinek całości sekwencji <=> arr[0:len(arr)]
s2 := arr[:4]//wycinek z pierwszych 4 elementów <=> arr[0:4]
s3 := arr[4:]//wycinek od 4 indeksu do końca <=> arr[4:len(arr)]

Funkcje pomocnicze

Mamy wbudowane cztery funkcje, które pomogą nam przeprowadzać operacje na wycinkach.

len

Funkcja len zwróci liczbę jak wiele elementów zawiera wycinek

cap

Funkcja cap zwraca ilość elementów, które wycinek może zawierać bez tworzenia nowej tablicy bazowej (więcej o tym w ostatniej sekcji tego artykułu).

append

Powiększanie wycinka o dodatkowe elementy możemy zrealizować dzięki funkcji append, jako argumenty przyjmuje ona rozszerzany slice i dodatkowe elementy (nieokreśloną ilość) o typie zgodnym z elementami wycinka i zwraca nowego slice.

Ale uwaga, gdy wycinek uzyskany jest z tablicy to mogą dziać się dziwne rzeczy np.:

Niejawnie tablica s zostanie zmodyfikowana: na pozycjach 3 i 4 wartości zastąpią zera, podczas gdy q := append(p, 0, 0, 0, 0), nie zmodyfikuje tablicy, wynika to z konstrukcji slicesów, co omówię później w tym artykule. Aby uniknąć takich wpadek polecam dla pewności zrobić copy na slicesach niewiadomego pochodzenia, przed dodawaniem mu elementów.

copy

Kopiowanie przeprowadzamy za pomocą funkcji wbudowanej copy, która przyjmuje jako argumenty cel i źródło a zwraca ilość skopiowanych elementów. Oba argumenty muszą mieć elementy tego samego typu. Co do zasady, cel i źródło, to muszą być slicesy, choć zrobiono wyjątek dla źródła będącego typu string i celem będącego []byte.

Jeśli wielkość wycinków nie jest równa to zostanie skopiowana mniejsza ilość elementów:

Slicesy od podszewki

We wstępie napisałem, że slice to abstrakcja na tablice i tak rzeczywiście jest. Każdy slice ma odpowiadającą mu tablicę zapisaną gdzieś tam w pamięci, a on sam przechowuje jedynie referencję do niej. Dzięki temu cokolwiek zmienimy w wycinku, zostanie zmienione w owej tablicy.

Oprócz referencji do tablicy wycinki trzymają informację o ilości elementów tablicy bazowej które udostępniają (nazywamy to długością len(s)), oraz o ilości elementów, które wycinek może jeszcze reprezentować, bez alokowania (i kopiowania danych do) nowej tablicy (nazywamy to pojemnością cap(s)).

Dla przykładu:

To oznacza, że możemy do wycinka s dodać cap(s) - len(s) (5) elementów bez kosztowo. Gdybyśmy jednak chcieli dodać do s więcej niż 5 elementów zostałaby utworzona nowa tablica, ze wszystkimi elementami które slice udostępniał powiększona o bliżej nieokreśloną ilość elementów (choć odnoszę wrażenie, że nowe tablice są zawsze dokładnie dwa razy większe).

Aby była jasność: dodanie elementu do wycinka to modyfikacja tablicy bazowej. Jeśli w tablicy bazowej nie ma miejsca na nowe elementy, to się jej pozbywamy i tworzymy większą.

PROTIP: Jeśli używasz wycinków aby trzymać jakieś iterowalne dane i wiesz, że będziesz do nich dodawał wiele elementów stwórz odrazu większy wycinek. Zyskasz na wydajności, bo nie trzeba będzie wielokrotnie kopiować tablic bazowych.

myGrowingSlice := make([]int, 0, 1200)

Zagrożenia

Należy pamiętać, że Go jest językiem z garbage collectorem, który głównie zajmuje się liczeniem referencji do wartości. Jeśli stworzymy ogromną tablicę, do której utworzymy slice, to tablica będzie tak długo rezydowała w pamięci aż pozbędziemy się tego wycinka (czyli referencji do niej).