Instrukcje warunkowe i pętle

W Go mamy kilka instrukcji warunkowych: if, switch, select i tylko jedną pętle - for. Każda z nich ma dodatkowe właściwości, które nie są oczywiste, dla kogoś kto programował już wcześniej. Przyjrzyjmy się im z bliska.

If, składnia

if to instrukcja warunkująca wykonanie bloku kodu. Zwyczaj każe by po słowie kluczowym if pojawiło się wyrażenie zwracające wartość logiczną, a następnie blok kodu, tak też jest i w Go:

if a > b {
  c := a
  a = b
  b = c
}

Powyższy fragment wykona zamianę wartości a i b jeśli a jest większe od b. Użyliśmy tam zmiennej pomocniczej c, która ponieważ została zadeklarowana wewnątrz bloku warunkowego nie będzie widoczna poza nim. Takie zachowanie zasięgów zmiennych może prowadzić do ciężkich do debugowania błędów, szczególnie w sytuacjach gdy będziemy re-deklarować istniejące zmienne:

a := 100
if a > 0 {
    a := 1 //1
    a++
    fmt.Println(a)//2
}
fmt.Println(a)//3

Można by się spodziewać, że gdy program osiągnie linię //3 to wyprowadzi na wyjście liczbę 2, a jednak wyprowadza 100. Spowodowane jest to tym, że w linii //1 zadeklarowaliśmy nową zmienną w nowym zasięgu, która jest nieosiągalna poza blokiem warunkowym. Niestety też kompilator w //1 nie uprzedzi nas o błędzie bo to całkiem legalna i czasem pożądana operacja.

Możemy zadeklarować także zmienną w samej instrukcji warunkowej, jeśli to potrzebne. Między if a warunkiem można dodać dowolną instrukcję, którą należy zakończyć średnikiem:

if v1, ok := execute(cmd); ok {
    fmt.Println(cmd, "successful, returned:", v1)
}

To całkiem typowa dla Go konstrukcja, często używana np. przy asercji typów.

Oczywiście, opcjonalnie możemy dodać klauzule else, i blok kodu który będzie wykonywany tylko w przypadku nie spełnienia warunku w instrucji if.

if a > 0 {
    doSmth()
} else {
    doSmthElse()
}

Za else możemy wstawić kolejny if:

if a % 2 == 0 {
    fmt.Println("a jest parzyste")
} else if a % 5 == 0 {
    fmt.Println("a jest nieparzyste podzielne przez 5")
} else {
    fmt.Println("a jest nie parzyste i nie podzielne przez 5")
}

Rodzaje pętli

W Go zapętlania służy tylko jedną instrukcja: for. Nie mniej przybiera kilka form:

For warunkowy

Po for możemy wstawić dowolne wyrażenie, które zwraca wartość logiczną, np:

for true {
}

lub

for a < b {
}

W pierwszym przykładzie pętla będzie przechodziła dopóki wykonywanie programu się nie zakończy. W drugim, dopóki wartość a będzie mniejsza od b.

For klasyczny

for w postaci klasycznej posiada 3 rozdzielone średnikami instrukcje. Pierwsza to inicjalizacja, która odbywa się tylko raz. Druga instrukcja to wyrażenie zwracające wartość logiczną, pętla będzie wykonywać się tak długo jak owo wyrażenie będzie zwracało true, wyrażenie będzie wywoływane co przejście pętli. Trzecia instrukcja to inkrementacja, odbywa się po każdym przejściu pętli przed sprawdzeniem warunku.

Jak działa for w klasycznej formie polecam sprawdzić samemu kompilując i uruchamiając następujący program:

For iteracyjny

W Go iterowalnymi kolekcjami są wartości typów: slice, array, map, string i chan. Do iterowania po kolekcjach używamy instrukcji range.

Iterowanie po slicesach i arrayach

W przypadku tablic i wycinków iteracja wygląda tak samo.

for i := range(it) {
   fmt.Println(it[i])
}

lub też:

for i, v := range(it) {
   fmt.Print(v)
}

W obu przypadkach zmienna i to numer indeksu tablicy czy wycinka. Iteracja zaczyna się od i == 0 a kończy na len(it). W drugim przypadku dodatkowa zmienna v będzie wartością elementu o indeksie i w skrócie v := it[i].

Iterowanie po elementach mapy

Tak jak dla wycinków i tablic iteracja map może przybrać jedną z dwóch form

for k := range(m) {
   fmt.Println(m[k])
}

oraz

for k, v := range(m) {
   fmt.Println(v)
}

Analogicznie, k to klucz a v to przypisana mu wartość w mapie. Mapy nie gwarantują żadnego porządku w iteracji.

Iterowanie po stringach

Iterowanie po stringach jest oczywiste... dopóki nie próbujemy iterować po łańcuchach encodowanych w UTF-8. Co do zasady w i będzie pozycja bajta rozpoczynającego znak a w c wartość znaku. Niestety pozycja bajta nie zmienia się liniowo, bo w UTFie znaki są zapisane na jednym lub większej ilości bajtów. Przykładowo:

for i, c := range("zażółć") {
   fmt.Println(i, c)
}

Wyprowadzi na ekran:

0 122
1 97
2 380
4 243
6 322
8 263

ostatnie trzy przejścia przez pętle i zwiększało się o 2.

W UTFach, można też zapisać znak niepoprawnie, wtedy range zamiast poprawnej wartości znaku zwróci wartość 0xFFFD i od następnego bajta będzie próbował znaleźć prawidłowy znak.

Iterowanie kanału

Można także iterować po zmiennej reprezentującej kanał, przy czym wyrażenie range() zwraca tylko jedną wartość na raz i jest to wartość przekazana do kanału. Iteracja kończy się gdy kanał zostaje zamknięty.

for v := range(ch) {
    fmt.Println(v)
}

Switch (wyrażeniowy)

Zamiast pisać łańcuchy if-else można użyć instrukcji warunkowej switch. Składa się ona ze słowa kluczowego switch po którym następuje wyrażenie i blok przypadków (case).

Każdy case składa się z listy wyrażeń rozdzielonych przecinkami która kończy się dwukropkiem. Po dwukropku rozpoczyna się blok kodu, który zostanie wykonany gdy wynik wyrażenia z instrukcji switch będzie tożsamy z wynikiem jednego z wylistowywanych wyrażeń. Blok kodu case kończy się wraz z rozpoczęciem definicji kolejnego przypadku.

Wygląda na skomplikowane, więc posłużę się przykładem:

switch a {
    case 1, 2, 3: //1
        fmt.Println("a ma wartość 1, 2 lub 3")
    case 4:       //2
        fmt.Println("a ma wartość 4")
}

W linii //1 porównujemy wartość zmiennej a z jedną z wartości: 1, 2, 3. Jeśli a spełnia ten przypadek to na ekran zostanie wyprowadzony napis "a ma wartość 1, 2 lub 3". W linii //2 mamy tylko jeden element listy wyrażeń przypadku.

Gdyby interesował nas przypadek nie spełniający wszystkich w/w caseów możemy użyć instrukcji default np.:

switch a {
    case 1, 2, 3:
        fmt.Println("a ma wartość 1, 2 lub 3")
    case 4:
        fmt.Println("a ma wartość 4")
    default:
        fmt.Println("a ma inną wartość niż 1,2,3,4")
}

Co ciekawe można pominąć wyrażenie w switch, wtedy wyrażenia w case będą porównywane z prawdą true. Moglibyśmy powyższy przykład przepisać tak:

switch {
    case a == 1, a == 2, a == 3:
        fmt.Println("a ma wartość 1, 2 lub 3")
    case a == 4:
        fmt.Println("a ma wartość 4")
    default:
        fmt.Println("a ma inną wartość niż 1,2,3,4")
}

I podobnie jak w ifach, między switch a wyrażeniem możemy wstawić dodatkową instrukcję, którą należy zakończyć średnikiem:

switch a := produceA(); a {
    case 1, 2, 3:
        fmt.Println("a ma wartość 1, 2 lub 3")
    case 4:
        fmt.Println("a ma wartość 4")
    default:
        fmt.Println("a ma inną wartość niż 1,2,3,4")
}

Zasady zasięgu deklarowanych zmiennych w blokach są takie jak w if. Tu jeszcze raz przypominam: brak wyrażenia po średniku sprawi, że wyrażenia w przypadkach będą przyrównywane do true:

switch a := produceA(); {
    case a == 1, a == 2, a == 3:
        fmt.Println("a ma wartość 1, 2 lub 3")
    case a == 4:
        fmt.Println("a ma wartość 4")
    default:
        fmt.Println("a ma inną wartość niż 1,2,3,4")
}

Switch (typów)

To bardzo użyteczna konstrukcja, szczególnie, gdy w kodzie mocno polegamy na interfejsach, pomaga nam wyciągnąć typ bazowy wartości zmiennej, lub sprawdzić czy spełnia ona jakiś inny interfejs. (To takie połączenie asercji typów ze switchem)

Składniowo przypomina zwykłego switcha, nie mniej zamiast wyrażenia używamy specjalnego operatora .(type), a zamiast wyrażeń w case'ach wylistowujemy typy:

switch a := unknown.(type) {
    case int:
        fmt.Println("unknown daje się przedstawić jako int o wartości", a)
    case string:
        fmt.Println("unknown daje się przedstawić jako string o wartości", a)
    case float, float64:
        fmt.Println("unknown jest jednym z floatów, ale a będzie typu interface{}", a)
    default:
        fmt.Println("unknown nie jest intem, stringiem, floatem a zmienna a jest typu interface{}", a)
}

Jak w przykładzie dałem do zrozumienia, jeśli wyrażenie switcha poprzedzimy przypisaniem do jakiejś zmiennej to ta zmienna w bloku case będzie miała już taki typ jaki przewiduje warunek wejścia do bloku. Jeśli więcej niż jeden typ wpuszcza do bloku zmienna a przyjmuje typ pustego interfejsu.

Select

To specjalny typ przełącznika podobnego w konstrukcji do switch. Służy jednak do wybierania kanału (wartości typów chan) z którego informacja zostanie odebrana lub wysłana i obsłużona. Wybór odbywa się w zależności od dostępności kanałów, a w przypadku gdy więcej niż jedna operacja jest możliwa wybór następuje losowo. Przełącznik select blokuje wykonanie gorutyny do momentu wykonania operacji.

select {
    case e := <-ch :
        fmt.Println("Z kanału ch otrzymaliśmy wartość", e)
    case f := <-ch1:
        fmt.Println("Z kanału ch1 otrzymaliśmy wartość", f)
    case ch <- g:
        fmt.Println("Do kanału ch wysłaliśmy wartość", g)
}

Selecta używamy gdy chcemy by program zaczekał na jedno ze zdarzeń, lub jeśli select będzie w pętli nieskończonej, zarządzał danymi przychodzącymi z gorutyn.