CodeGym /Kursy /Go SELF /Powtorka: slices/maps i typowe bledy

Powtorka: slices/maps i typowe bledy

Go SELF
Poziom 20, Lekcja 0
Dostępny

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
var s []T
(nil)
s := []T{}
(empty)
len(s)
0
0
range s
0 iteracji 0 iteracji
append(s, x)
dziala dziala
s == nil
true
false
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.

1
Zadanie
Go SELF, poziom 20, lekcja 0
Niedostępne
Pusta lista
Pusta lista
1
Zadanie
Go SELF, poziom 20, lekcja 0
Niedostępne
Cennik
Cennik
1
Zadanie
Go SELF, poziom 20, lekcja 0
Niedostępne
Słowa czatu
Słowa czatu
1
Zadanie
Go SELF, poziom 20, lekcja 0
Niedostępne
Bezpieczny fragment
Bezpieczny fragment
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION