1. Przypominamy slices: jak sa zbudowane i gdzie sie psuja
Kiedy dopiero zaczynacie, latwo wpaść w pulapke: „no przeciez zwrocilo poprawna odpowiedz na dwoch testach — czyli wszystko jest OK”. Slices i mapy sa szczegolnie podstepne, bo ich bledy czesto nie ujawniaja sie od razu: bug moze pojawic sie dopiero na innych danych, w innej galezi kodu albo po drobnym refaktoringu. Powtorzenie jest tutaj potrzebne nie po to, by meczyc sucha teoria, ale po to, by nauczyc sie przewidywac zachowanie programu.
Uzyteczna mysl dnia: slices i mapy to nie tylko „kontenery”, ale kontenery z zasadami. Jesli trzymacie te zasady w glowie, piszecie kod szybciej i debugujecie go spokojniej. Jesli nie — zaczynaja sie szamanskie tance: „czemu zmienil mi sie oryginalny slice?”, „czemu output mapy za kazdym razem jest inny?”, „czemu append czasem psuje dane?”.
Model slice'a: „okno” w tablicy
Slice w Go warto traktowac nie jako „liste”, ale jako okno, przez ktore patrzymy na kawalek tablicy. To okno opisujace sa trzema liczbami: gdzie zaczynaja sie dane (wskaznik), ile elementow uznajemy za „widoczne” (len) i o ile mozemy je „dorzucic” bez przeprowadzki (cap). Wlasnie przez ten model append czasem jest bezpieczny, a czasem — jak remont w mieszkaniu: niby „troche dokleje”, a konczy sie przeprowadzka.
Slices moga dzielic jedna tablice, nakladac sie na siebie i wplywac jeden na drugi. Wewnetrzna budowa slice'a jako „pointer/len/cap” to klucz do zrozumienia prawie wszystkich dziwnych przypadkow z append, pod-slices i kopiowaniem. To wlasnie ten moment, kiedy jeden prosty rysunek oszczedza wam godziny debugowania.
Można to przedstawic tak:
flowchart LR
A[Underlying array] --> B[Slice header]
B --> P[pointer -> start index]
B --> L[len]
B --> C[cap]
nil-slice i pusty slice: takie same po dlugosci, rozne znaczeniowo
Temat nil vs empty wydaje sie drobnostka, dopoki nie zaczynacie pisac dokladnych sprawdzen i nie traficie na „czemu == nil daje false?”. W Go dopuszczalne sa oba warianty: var s []int (to nil) oraz s := []int{} (to pusty, ale nie nil). W wiekszosci zadan praktycznych zachowuja sie tak samo: len bedzie 0, range nic nie zrobi, append zadziala. Ale czasem roznica jest wazna jako sygnal znaczenia: „wartosc nie jest ustawiona” kontra „wartosc jest, ale pusta”.
Sprawdzmy maly przyklad i przy okazji utrwalmy, ze append potrafi pracowac z nil-slice'em (i to jest normalne):
package main
import "fmt"
func main() {
var a []int // nil
b := []int{} // empty
fmt.Println(a == nil, len(a), cap(a)) // true 0 0
fmt.Println(b == nil, len(b), cap(b)) // false 0 0
a = append(a, 10)
b = append(b, 10)
fmt.Println(a) // [10]
fmt.Println(b) // [10]
}
Dla utrwalenia — krotka tabelka:
| Wlasciwosc | (nil) |
(empty) |
|---|---|---|
|
|
|
|
0 iteracji | 0 iteracji |
|
dziala | dziala |
|
|
|
| Znaczenie | „nie ustawione” | „ustawione, ale puste” |
append: dlaczego wynik trzeba zapisac
Gdyby wybierac „najdrozszy blad poczatkujacego” wedlug stosunku „prostota → liczba straconych nerwow”, to byloby ignorowanie wyniku append. append zwraca nowy slice, bo moze zmienic dlugosc, a czasem — takze bufor danych (przydzielic nowa tablice i skopiowac elementy). Dlatego poprawna forma prawie zawsze wyglada tak: s = append(s, x).
Nie musicie zapamietywac wewnetrznych algorytmow wzrostu, ale musicie zapamietac kontrakt zachowania: „po append stary slice moze przestac byc tym, czym byl”. Czasem bedzie wskazywal na ta sama tablice, czasem — na nowa. I wlasnie dlatego kod, ktory „jakby dzialal”, zaczyna sie sypac po malej zmianie, na przyklad po dodaniu jeszcze jednego append obok.
Mini-przyklad na uwaznosc:
package main
import "fmt"
func main() {
s := make([]int, 0, 2)
s = append(s, 1)
s = append(s, 2)
t := append(s, 3) // wazny moment: zapisujemy do t
fmt.Println("s:", s) // s: [1 2]
fmt.Println("t:", t) // t: [1 2 3]
}
Jesli napisac po prostu append(s, 3), to slice s moglby zostac dlugosci 2, a moglby sie „jakos” zmienic — i dostalibyscie trudny do wyjasnienia obraz.
Pod-slices i aliasing: „czemu zmienil sie oryginal?!”
Pod-slices (na przyklad sub := base[a:b]) — to wygodna rzecz… dopoki nie zaczynacie ich modyfikowac. Glowna idea: pod-slice prawie zawsze dzieli underlying array z bazowym slice'em. To oznacza, ze zapis do elementow pod-slice'a zmienia oryginalna tablice. Jest to spodziewane, gdy robicie sub[0] = 99, ale duzo mniej spodziewane, gdy robicie append(sub, ...) i nagle zmienia sie „ogon” base.
Oto przyklad, ktory warto przejrzec wzrokiem wiele razy:
package main
import "fmt"
func main() {
base := []int{1, 2, 3, 4}
sub := base[1:3] // [2 3]
sub = append(sub, 99) // moze wplynac na base
fmt.Println("base:", base)
fmt.Println("sub :", sub)
}
Dlaczego „moze”? Bo wszystko rozbija sie o cap(sub). Jesli cap pozwala „dopisywac” do tej samej tablicy, Go dopisze tam — i nadpisze elementy tablicy bazowej za oknem sub. To zachowanie jest w pelni logiczne, jesli pamietac o header z cap.
Full slice expression s[a:b:c]: oficjalny sposob na postawienie plotu
Full slice expression wyglada jak magiczny trik, ale sens jest prosty: to dokladniejszy opis okna — podajecie nie tylko len, ale tez maksymalna pojemnosc (cap) dla otrzymanego pod-slice'a. Pomysl jest prosty: „oto widoczna czesc a:b, i prosze, nie wychodzcie append-em poza c w oryginalna tablice”.
Z praktycznego punktu widzenia to dobry sposob ochrony przed przypadkowym aliasingiem: jesli zamierzacie robic append do pod-slice'a i nie chcecie zmieniac oryginalu, ograniczcie cap, zeby append byl zmuszony przydzielic nowa tablice.
package main
import "fmt"
func main() {
base := []int{1, 2, 3, 4}
sub := base[1:3:3] // len=2, cap=2
sub = append(sub, 99) // cap nie wystarczy -> nowa tablica
fmt.Println("base:", base) // base: [1 2 3 4]
fmt.Println("sub :", sub) // sub : [2 3 99]
}
copy jako granica wlasnosci: „teraz to moje”
Kiedy rozumiecie juz, ze slices moga dzielic dane, nastepny logiczny krok to nauczyc sie robic jawna kopie, gdy jest potrzebna. W Go kopia nie powstaje przez przypisanie (ono kopiuje tylko header), lecz przez utworzenie nowego slice'a i copy. Jest to przydatne, gdy chcecie zachowac „snapshot” danych, ktory sie nie zmieni, nawet jesli oryginalny slice pozniej bedzie modyfikowany.
Klasyczna forma:
package main
import "fmt"
func main() {
orig := []string{"a", "b", "c"}
clone := make([]string, len(orig))
copy(clone, orig)
orig[0] = "X"
fmt.Println(orig) // [X b c]
fmt.Println(clone) // [a b c]
}
W tym miejscu wielu osob zadaje pytanie: „a dlaczego nie mozna po prostu clone := orig?”. Poniewaz bylby to drugi header na ta sama tablice, czyli znowu aliasing, tylko bardziej ukryty.
Usuwanie z slice'a i „ogon”: dlaczego czasem warto czyscic
Usuwanie elementow ze srodka slice'a to temat, w ktorym latwo pomylic sie na granicach, a jeszcze latwiej — zapomniec, ze underlying array moze nadal trzymac referencje do „usunietych” elementow poza len. Na poziomie „modelu w glowie” warto pamietac: dlugosc sie zmniejszyla, ale pamiec tablicy moze przechowywac stare wartosci, dopoki ich nie wyzerujecie (wazne dla slice'ow wskaznikow, stringow, innych slices itd.).
W nowoczesnych wersjach Go standardowy pakiet slices przejmuje czesc tej roboty: niektore operacje (na przyklad slices.Delete) czyszcza „ogon”, aby przypadkiem nie utrzymywac pamieci.
Ale podstawowa umiejetnosc pozostaje ta sama: jesli funkcja zwraca nowy slice — musicie go uzyc.
package main
import (
"fmt"
"slices"
)
func main() {
s := []int{10, 20, 30, 40}
s = slices.Delete(s, 1, 3) // usunieto elementy o indeksach 1..2
fmt.Println(s) // [10 40]
}
Jesli napisac slices.Delete(s, 1, 3) i nie przypisac wyniku, dostaniecie „dziwny” slice: stara dlugosc, czesciowo przesunieta zawartosc — i dalej zaczyna sie klasyczny monolog „no Go na pewno sobie kpi”.
3. Mapy: model i praktyczne zasady
Co jest gwarantowane, a co nie
Mapa w Go to struktura „klucz → wartosc” i swietnie nadaje sie do szybkich sprawdzen obecnosci, zliczen, patternow set i innych praktycznych rzeczy. Ale mapa ma dwie zasady, ktore trzeba miec stale w glowie: po pierwsze, nil-mapy nie wolno modyfikowac (zapis spowoduje panic), a po drugie, kolejnosc przejscia range po map nie jest kontraktem.
To nie jest „cecha konkretnej implementacji”, tylko swiadoma zasada: kod nie powinien opierac sie na kolejnosci, bo inaczej dostaniecie niestabilne zachowanie. W prawdziwym rozwoju niestabilny output to bol: testy zaczynaja flapkowac, diffy w logach staja sie bezsensowne, a uzytkownicy pytaja: „dlaczego lista skacze miejscami?”.
nil-mapa: czytac mozna, pisac nie mozna
nil-mapa jest jak pusta zeszytowa kartka, na ktora patrzycie przez witryne: naglowki mozna odczytac (i tak ich nie ma), ale pisac nie wolno. Za to range po nil-mapie jest bezpieczny — po prostu nie bedzie iteracji.
package main
import "fmt"
func main() {
var m map[string]int // nil map
fmt.Println(m["a"]) // 0 (zero value), ale klucza nie ma
// m["a"] = 1 // panic: assignment to entry in nil map
m = make(map[string]int)
m["a"] = 1
fmt.Println(m["a"]) // 1
}
Tutaj szczegolnie wazne jest, by nie mieszac „zero value z mapy” i „klucz nie istnieje”. Dlatego nastepna czesc jest o idiomie ok.
v, ok := m[k]: jak odroznic „brak klucza” od zero value
Jesli wartosc typu V ma sensowne zero value (na przyklad 0 dla int), to zwykle odczytanie m[k] nie pozwala stwierdzic: czy tam naprawde bylo 0, czy klucza nie bylo. Wlasnie dlatego idiom v, ok := m[k] to nie tylko „ficzer jezyka”, ale realna ochrona przed bledami logiki.
package main
import "fmt"
func main() {
m := map[string]int{"a": 0}
v1, ok1 := m["a"]
v2, ok2 := m["b"]
fmt.Println(v1, ok1) // 0 true
fmt.Println(v2, ok2) // 0 false
}
W kodzie aplikacyjnym ratuje to przed nieprzyjemnymi bugami, kiedy program „jakby widzial wartosc”, ale tak naprawde klucza nie bylo, a wy po prostu dostaliscie zero value.
Kolejnosc range po map i stabilny output: sortujemy klucze
Kiedy wypisujecie dane z mapy (do konsoli, raportu, testu), prawie zawsze chcecie, aby kolejnosc byla taka sama. Go tego nie gwarantuje, wiec stosujemy prosty protokol: zebrac klucze do slice'a → posortowac → wypisac po kolei. W Go sa wygodne narzedzia do sortowania slices; w standardowej bibliotece jest pakiet slices z sortowaniem i narzedziami pomocniczymi.
Mini-przyklad:
package main
import (
"fmt"
"slices"
)
func main() {
m := map[string]int{"b": 2, "a": 1, "c": 3}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
slices.Sort(keys)
for _, k := range keys {
fmt.Println(k, m[k])
}
}
Ten pattern warto traktowac jako „norme”, a nie jako „kiedys potem zoptymalizujemy”. Optymalizacja tutaj prawie nigdy nie jest potrzebna: sortowanie kluczy zwykle kosztuje mniej niz wasz czas na rozbieranie niestabilnych wynikow.
4. Mini-aplikacja „Lista zakupow”
Zlozmy maly przyklad, podobny do prawdziwych dydaktycznych programow konsolowych: jest lista stringow (slice), jest zliczanie powtorzen (mapa) i jest kilka miejsc, gdzie latwo popelnic typowe bledy. Celowo nie uzywamy struktur — beda pozniej; na razie pracujemy narzedziami, ktore juz znamy.
Zaczniemy od dodawania zakupow do slice'a i rownoczesnie bedziemy liczyc, ile razy pojawia sie kazde slowo (na przyklad po to, by zobaczyc „co kupuje sie najczesciej”).
package main
import (
"fmt"
"strings"
)
func addItem(items []string, counts map[string]int, raw string) []string {
name := strings.ToLower(strings.TrimSpace(raw))
if name == "" {
return items
}
items = append(items, name)
counts[name]++
return items
}
func main() {
items := make([]string, 0, 4)
counts := make(map[string]int)
items = addItem(items, counts, " Milk ")
items = addItem(items, counts, "bread")
items = addItem(items, counts, "milk")
fmt.Println(items) // [milk bread milk]
fmt.Println(counts) // map[bread:1 milk:2] (kolejnosc moze byc rozna)
}
W tym kodzie juz sa ukryte dwa „wazne powtorzenia”: append zwraca nowy slice, wiec items = addItem(...) to poprawna forma; a wypisanie mapy jest „jak wyjdzie”, wiec dla ladnego i stabilnego outputu potrzebne jest sortowanie kluczy.
Stabilne wypisywanie licznika:
package main
import (
"fmt"
"slices"
)
func printCounts(counts map[string]int) {
keys := make([]string, 0, len(counts))
for k := range counts {
keys = append(keys, k)
}
slices.Sort(keys)
for _, k := range keys {
fmt.Println(k, counts[k])
}
}
Zwracajcie uwage, ze keys to slice, wiec tutaj znow cwiczycie nawyk „append zawsze zapisujemy”.
5. Typowe bledy
Blad nr 1: ignorowac wynik append.
Wyglada to niewinnie: „przeciez tylko dodalem element”. Ale append zwraca nowy slice i jesli go nie zapiszecie, bedziecie dalej pracowac na starym header: ze stara dlugoscia i potencjalnie juz zmienionymi danymi. Nawyk wyrabia sie prawie mechanicznie: widzicie append — pytacie siebie „gdzie zapisuje wynik?”.
Blad nr 2: myslec, ze sub := base[a:b] to kopia.
Pod-slice to prawie zawsze „widok” na te same dane. Problem pojawia sie nie wtedy, gdy czytacie sub, ale gdy piszecie w sub albo robicie append(sub, ...). W efekcie moze zmienic sie base i bedziecie szukac „kto popsul dane”, chociaz popsuliscie je sami — tylko przez wspolny underlying array. Jesli trzeba sie odseparowac — uzyjcie copy albo ograniczcie cap przez base[a:b:c].
Blad nr 3: pisac do nil-mapy.
var m map[K]V tworzy nil-mape. Czytac mozna, range robic mozna, ale zapis prowadzi do panic. To nie dlatego, ze Go jest zlosliwe, tylko dlatego, ze „nie ma przydzielonej pamieci pod tablice”. Leczy sie to w jedna linijke: m = make(map[K]V) przed pierwszym zapisem.
Blad nr 4: polegac na kolejnosci range po map.
Czasem chce sie powiedziec: „no przeciez mam trzy klucze i zawsze wyswietlaja sie tak samo... jak dotad”. To zludne wrazenie. Kolejnosc nie jest gwarantowana i nie wolno robic z niej czesci logiki. Jesli potrzebujecie stabilnej kolejnosci dla wyswietlania lub testow, uzyjcie protokolu „klucze do slice'a → sortowanie → output”.
Blad nr 5: mylic „brak klucza” z „wartosc rowna zero value”.
Jesli piszecie v := m[k] i na tej podstawie wyciagacie wnioski, to dla int nie odroznicie „klucz nie istnieje” od „klucz istnieje i ma 0”. To klasyka bugow w licznikach, flagach, statusach. W Go jest na to standardowa odpowiedz: v, ok := m[k]. Jesli ok == false, to znaczy, ze klucza nie bylo.
GO TO FULL VERSION