Golang

Język C XXI wieku

Wstęp

Wciąż często używany język programowania C powstał w końcu lat 70. ubiegłego wieku, gdy mało popularne były pojęcia takie jak internet czy procesory wielordzeniowe (dziś obecne nawet w smartfonach), a o Unicode nikt jeszcze nie słyszał. C++ (1983 r.) rozszerzył C o programowanie obiektowe z zachowaniem kompatybilności wstecznej. Język programowania Go nie jest zgodny z C/C++, ale ma podobny profil zastosowań i dużo bardziej nowoczesną architekturę, a przy tym kompiluje się do kodu maszynowego (Linux/Windows/MacOS/Android).

Tak więc w języku Go mamy automatyczne zarządzanie pamięcią (ang. garbage collector) i pakiety, ale także mechanizmy refleksji i programowania funkcyjnego (funkcja jest bytem pierwszego rzędu), a łańcuchy znaków obsługują Unicode (kodują znaki za pomocą UTF-8). Nawet na starym laptopie można w jednym programie Go uruchomić setki tysięcy wątków poziomu użytkownika (bez ograniczeń rozmiaru stosu) w sposób tak prosty, jak poprzedzenie wywołania dowolnej funkcji słowem kluczowym go. Dostępny jest mechanizm kanałów (implementacja CSP) który może znacząco uprościć komunikację między wątkami w porównaniu z semaforami i zmiennymi warunkowymi.

Twórcami języka Go są m.in. ludzie znani ze świata UNIXa: Ken Thompson i Rob Pike, zaś w propagowanie języka zaangażowany jest Brian Kernighan, autor najbardziej znanego podręcznika do języka C. Go szczególnie dobrze nadaje się do programowania współbieżnych, dobrze skalujących się w pionie usług internetowych i serwerów. Popularność Go ostatnimi czasy gwałtownie rośnie, głównie za sprawą dobrze znanej aplikacji Docker.

Jakich elementów składniowych znanych z innych języków programowania nie ma w Go?

Struktura programu

Przykładowy program:

package main

import "fmt"

func main() {
  fmt.Println("Witaj świecie")
}

Go nie wymaga średników na końcach instrukcji lub deklaracji, z wyjątkiem sytuacji, gdy w tej samej linii pojawia się więcej niż jedna instrukcja lub deklaracja. W efekcie znaki nowej linii następujące po określonych symbolach są przekształcane w średniki, więc miejsce umieszczania znaków nowej linii jest istotne dla właściwego parsowania kodu Go. Przykładowo: otwarcie klamry { funkcji musi się znajdować w tej samej linii co koniec deklaracji func, a nie w osobnej linii, a w wyrażeniu x+y znak nowej linii jest dozwolony po operatorze +, ale nie przed nim.

Pętla for jest jedyną instrukcją pętli w języku Go.

for inicjacja; warunek; publikacja {
}

for warunek {
}

for {
}

Pusty identyfikator (_) może być używany wszędzie tam, gdzie składnia wymaga nazwy zmiennej, ale logika programu nie.

if err := r.ParseForm(); err != nil {
  ...
}

switch warunek {
  case X:
    instrukcje
    fallthrough;
  case Y:
    instrukcje
  default:
    instrukcje
}

Switch nie potrzebuje operandu:

switch {
  case x > 0: return 1
  case x < 0: return -1
  default: return 0
}

Program Go jest przechowywany w plikach o nazwach z rozszerzeniem .go. Każdy plik rozpoczyna się od deklaracji package, wskazującej pakiet, którego częścią jest dany plik. Nazwa każdej encji poziomu pakietu jest widoczna nie tylko w całym pliku źródłowym zawierającym deklarację tej encji, ale również we wszystkich plikach danego pakietu.

Kompilator sortuje pliki .go według nazwy i inicjuje w takiej kolejności (funkcja init).

Jeśli encja jest zadeklarowana w ramach funkcji, jest dla niej lokalna. Jeśli jednak jest zadeklarowana poza funkcją, jest widoczna we wszystkich plikach pakietu, do którego należy. Widoczność encji poza granicami pakietu jest określona przez wielkość pierwszej litery jej nazwy. Jeśli nazwa rozpoczyna się wielką literą, jest eksportowana, czyli jest widoczna i dostępna poza własnym pakietem i mogą się do niej odwoływać inne części programu. Nazwy samych pakietów zaczynają się zawsze małą literą.

Obszerny komentarz dokumentacyjny pakietu jest często umieszczany w osobnym pliku umownie zwanym doc.go.

Stylistycznie programiści Go przy formatowaniu nazw poprzez łączenie słów używają notacji camelCase. Litery akronimów i skrótowców takich jak ASCII oraz HTML są zawsze zapisywane znakami o tej samej wielkości.

Zmienne

var nazwa typ = wyrażenie
var nazwa = wyrażenie
var nazwa typ

Jeśli pominięte zostanie wyrażenie, wartością początkową jest wartość zerowa dla danego typu, którą jest 0 dla liczb, false dla wartości logicznych, "" dla łańcuchów oraz nil dla interfejsów i typów referencyjnych. Wartość zerowa typu złożonego, takiego jak tablica lub struktura, ma wartość zerową wszystkich swoich elementów lub pól.

W języku Go nie ma czegoś takiego jak niezainicjowana zmienna.

var b, f, s = true, 2.3, "cztery"
var f, err = os.Open(name) // funkcja zwraca 2 wartości

t := 0.0 // krótka deklaracja zmiennej
i, j := 0, 1
i, j = j, i // zamiana wartości

Należy pamiętać, że := jest deklaracją, natomiast operator = jest przypisaniem.

Krótkie deklaracje zmiennych są używane do deklarowania i inicjowania większości zmiennych lokalnych. Deklaracja var z reguły jest zarezerwowana dla zmiennych lokalnych wymagających wyraźnego typu, który różni się od typuwyrażenia inicjatora, lub dla przypadku, gdy wartość zmiennej zostanie przypisana później, a jej wartość początkowa jest nieistotna.

i, j = j, i

x := 1
p := &x
*p = 2

func f() *int {
  v := 1
  return &v
}

Nie istnieje arytmetyka wskaźnika.

Funkcja new jest tylko wygodą składniową, a nie fundamentalnym pojęciem: poniższe dwie funkcje newInt mają identyczne zachowania.

return new(int)
v := 1; return &v

Funkcja new jest stosunkowo rzadko stosowana, ponieważ najbardziej typowe zmienne nienazwane są typami struct, dla których bardziej elastyczna jest składnia literału struktury.

Typy

type nazwa typ_bazowy

Typy nazwane pozwalają definiować nowe zachowania (metody) dla wartości danego typu.

Podstawowe typy danych

W języku Go typy dzielą się na cztery kategorie: typy podstawowe (liczby, łańcuchy znaków, logiczne), typy złożone (struktury i tablice), typy referencyjne (kanały, funkcje, wskaźniki, wycinki i mapy) oraz typy interfejsowe.

Liczby

Liczby całkowite

Zachowanie operatora % dla liczb ujemnych różni się w zależności od języka programowania. W języku Go znak reszty jest zawsze taki sam jak znak dzielnej.

Oprócz standardowych operatorów bitowych znanych z C, mamy dodatkowo operator &^ (AND NOT).

int8, int16, int32 (rune), int64
uint8 (byte), uint16, uint32, uint64

Chociaż Go zapewnia liczby bez znaku i arytmetykę, z reguły korzystamy z formy int ze znakiem nawet dla wartości, które nie mogą być ujemne, takie jak długość tablicy, chociaż typ uint mógłby się wydawać bardziej oczywistym wyborem. W rzeczywistości wbudowana funkcja len zwraca int ze znakiem.

Liczby bez znaku są z reguły używane tylko wtedy, gdy wymagane są ich operatory bitowe lub szczególne operatory arytmetyczne, tak jak przy implementowaniu zbioru bitów, parsowaniu plików w formatach binarnych lub do haszowania i kryptografii. Nie sa one zwykle używane dla wartości zaledwie nieujemnych.

Liczby zmiennoprzecinkowe

Typ float32 zapewnia dokładność ok. 6 cyfr dziesiętnych, a float64 ok. 15 cyfr. Typ float64 powinien być preferowany w większości zastosowań, ponieważ obliczenia float32 mogą gwałtownie kumulować błąd, jeśli nie jest się wystarczająco ostrożnym, a najmniejsza dodatnia liczba całkowita, która nie może być dokładnie przedstawiona jako float32, nie jest duża.

var f float32 = 16777216; // 1 << 24
fmt.Println( f == f+1 ); // true

Wszelkie porównania z NaN zawsze dają false.

Liczby zespolone

Go zapewnia dwa rozmiary liczb zespolonych: complex64 i complex128, których komponentami są odpowiednio float32 i float64.

1+2i == complex(1,2)

Wartości logiczne

Operatory && i || są leniwe

Łańcuchy znaków

Łańcuch znaków jest niemutowalną sekwencją bajtów. Łańcuchy znaków mogą zwierać dowolne dane, w tym bajty wartości 0. Tekstowe łańcuchy znaków są umownie interpretowane jako zakodowane w UTF-8 sekwencje punktów kodowych Unicode (w terminologii Go: runy).

Wbudowana funkcja len zwraca liczbę bajtów (nie run) w łańcuchu znaków, a s[i] pobiera i-ty bajt łańcucha s. Nie zawsze i-ty bajt łańcucha jest i-tym znakiem łańcucha, ponieważ kodowanie UTF-8 punktu kodowego spoza ASCII wymaga dwóch lub więcej bajtów.

Łańcuchy znaków mogą być porównywane za pomocą operatorów porównania takich jak == i <. Porównywanie odbywa się bajt po bajcie, więc wynik jest naturalnym uporządkowaniem leksykograficznym (własność UTF-8).

Operacja podłańcucha s[i:j] daje nowy łańcuch składający się z bajtów oryginalnego łańcucha, bez j-tego. Można pominąć jeden z argumentów: i lub j, albo oba. W takim przypadku przyjmowane są odpowiednio domyślne wartości 0 i len(s). Niemutowalność łańcuchów oznacza, że dwie kopie łańcucha znaków mogą bezpiecznie współdzielić tę samą pamięć bazową, dzięki czemu kopiowanie łańcuchów o dowolnej długości wiąże się z niskimi kosztami. Podobnie łańcuch s i podłańcuch taki jak s[7:] mogą bezpiecznie współdzielić te same dane, więc operacja podłańcucha jest również niskokosztowa. W żadnym z tych przypadków nie jest alokowana nowa pamięć.

Operator + konkatenuje łańcuchy.

Surowy literał znaków jest zapisywany w postaci `...` z wykorzystaniem znaków grawis ("odwrotny ciapek") zamiast podwójnych cudzysłowów. W obrębie surowego literału łańcucha znaków nie są przetwarzane żadne sekwencje ucieczek. Cała zawartość jest brana dosłownie, w tym lewe ukośniki i znaki nowej linii, więc surowy literał łańcucha znaków może się rozciągać na kilka linii w źródle programu. Jedynym przetwarzaniem jest usuwanie znaków powrotu karetki, aby wartość łańcucha znaków była taka sama na wszystkich platformach, również na tych, które umownie umieszczają znak powrotu karetki w plikach tekstowych (np. MS Windows).

Naturalnym typem danych do przechowywania pojedynczej runy jest int32 i ten typ wykorzystuje język Go, który dokładnie do tego celu posiada również synonim o nazwie rune.

UTF-8

UTF-8 jest zmiennej długości kodowaniem punktów kodowych Unicode jako bajtów. Kodowanie UTF-8 zostało wynalezione przez Kena Thompsona i Roba Pike'a, zaliczanych do twórców języka Go, a obecnie jest standardem Unicode. Wykorzystuje ono do reprezentowania każdej runy od 1 do 4 bajtów, ale tylko 1 bajt jest przeznaczony dla znaków ASCII, a jedynie 2 lub 3 bajty dla większości run będących w powszechnym użyciu.

Kodowanie o zmiennej długości wyklucza bezpośrednie indeksowanie w celu uzyskiwania dostępu do n-tego znaku łańcucha, ale UTF-8 ma wiele pożądanych właściwości, które to rekompensują. To kodowanie jest zwarte, kompatybilne z ASCII i samosynchronizujące. Można znaleźć początek znaku, cofając się nie więcej niż o 3 bajty. Jest to również kod prefiksowy, a więc może być dekodowany od lewej do prawej bez żadnej dwuznaczności lub wybiegania w przód. Żadne kodowanie runy nie jest podłańcuchem innej runy, ani nawet sekwencją innych run, dzęki czemu można wyszukiwać runę, po prostu szukając jej bajtów, nie przejmując się poprzedzającym kontekstem. Leksykograficzna kolejność bajtów jest taka sama jak porządek punktów kodowych Unicode, więc sortowanie UTF-8 działa w sposób naturalny. Nie ma osadzonych bajtów NUL (zero).

Pliki źródłowe Go są zawsze kodowane w UTF-8, a UTF-8 jest preferowanym kodowaniem tekstowych łańcuchów znaków manipulowanych przez programy Go. Pakiet unicode zapewnia funkcje do pracy z poszczególnymi runami (takie jak odróżnianie liter od liczb lub konwersja wielkich liter na małe), a pakiet unicode/utf8 zapewnia funkcje do kodowania i dekodowania run jako bajtów za pomocą UTF-8.

Pojedynczy bajt w łańcuchu można zakodować jako szesnastkowy znak ucieczki (\xhh, np. \xA8) lub ósemkowy znak ucieczki (\ooo, np. \377). Całe punkty kodowe w łańcuchu można kodować za pomocą \uhhhh (dla wartości 16-bitowych) i \Uhhhhhhhh dla wartości 32-bitowych - potrzeba zastosowania formy 32-bitowej pojawia się bardzo rzadko.

Poniżej mamy kilka alternatywnych sposobów zapisu tego samego łańcucha znaków:

""
"\xe4\xb8\x96\xe7\x95\x8c"
"\u4e16\u754c"
"\U00004e16\U0000754c"
Funkcja utf8.RuneCountInString(s) zwraca liczbę run w łańcuchu. Gdy pętla range języka Go jest zastosowana do łańcucha znaków, wykonuje pośrednie dekodowanie UTF-8. Dla każdej runy niebędącej znakiem ASCII indeks zwiększa się o więcej niż 1:
for i, r := range "Witaj, " {
  fmt.Printf( "%d\t%q\t%d\n", i, r, r )
}

Co się stanie, jeśli wykonamy pętlę range na łańcuchu znaków zawierającym dowolne dane binarne lub dane UTF-8 zawierające błędy? Gdy dekoder UTF-8 konsumuje niespodziewany bajt wejściowy, generuje specjalny znak zastępczy Unicode '\uFFFD', który jest zwykle wyświetlany jako biały znak zapytania wewnątrz czarnego rombu.

Kilka bardziej przydatnych operacji na łańcuchach:

r, size := utf8.DecodeRuneInString(s)
r := []rune(s) // zwraca sekwencję run łańcucha
string(r) // operacja odwrotna
[]byte(s) // konwersja do tablicy bajtów
string(65) // 'A'

W manipulowaniu łańcuchami znaków szczególnie istotne są cztery standardowe pakiety: bytes, strings, strconv i unicode. Pakiet strings zapewnia wiele funkcji do wyszukiwania, podstawiania, porównywania, przycinania, dzielenia i łączenia łańcuchów znaków.

Pakiet bytes zawiera podobne funkcje służące do manipulowania wycinkami bajtów, typu []byte, które mają pewne wspólne właściwości z łańcuchami znaków. Ponieważ łańcuchy są niemutowalne, budowanie łańcuchów inkrementacyjnie może wymagać sporo alokowania i kopiowania. W takich przypadkach bardziej efektywne jest użycie typu bytes.Buffer, który może być wykorzystywany jako zamiennik pliku za każdym razem, gdy funkcja wejścia/wyjścia wymaga ujścia dla bajtów (io.Writer), lub źródła bajtów (io.Reader).

Pakiet strconv zapewnia funkcje do konwersji wartości logicznych, liczb całkowitych i liczb zmiennoprzecinkowych na ich reprezentację łańcuchową i odwrotnie oraz funkcje do cytowania łańcuchów znaków.

Pakiet unicode zapewnia funkcje do klasyfikowania run, takie jak: IsDigit, IsLetter, IsUpper oraz IsLower. Każda funkcja przyjmuje pojedynczy argument runiczny i zwraca wartość logiczną. Funkcje konwersji, takie jak ToLower i ToUpper, konwertują runę na daną wielkość, jeśli jest to litera. Wszystkie te funkcje stosują standardowe kategorie Unicode dla liter, cyfr. itd. Pakiet strings ma podobne funkcje, również zwane ToUpper i ToLower, które zwracają nowy łańcuch z określoną transformacją zastosowaną do każdego znaku oryginalnego łańcucha.

Stałe

const (
  e = 2.71
  pi = 3.14
)

Deklaracja const może wykorzystywać generator stałych iota, który jest używany do tworzenia sekwencji powiązanych wartości bez bezpośredniego precyzowania każdej z nich. W deklaracji const wartość iota zaczyna się od zera i jest zwiększana o jeden dla każdego elementu w sekwencji.

type Weekday int
const (
  Sunday Weekday = iota
  Monday
  Tuesday
  Wednesday
  Thursday
  Friday
  Saturday
)

type Flags uint
const (
  FlagUp Flags = 1 << iota
  FlagBroadcast
  FlagLoopback
  FlagPointToPoint
  FlagMulticast
)

const (
  _ = 1 << (10 * iota)
  KiB
  MiB
  GiB
  TiB
  PiB
)

Typy złożone

Tablice

Z powodu stałej długości (znanej podczas kompilacji) tablice są rzadko stosowane w języku Go bezpośrednio. Oparte na tablicach wycinki, które mogą rozszerzać się i kurczyć, są dużo bardziej przydatne.

var q [3]int = [3]int {1,2,3}
r := [...]int{99: -1}
Tablica nie jest typem referencyjnym - do funkcji jest przekazywana przez wartość, więc warto użyć wskaźnika.

Wycinki

Wycinki reprezentują sekwencje o zmiennej długości, których wszystkie elementy mają ten sam typ. Typ wycinka jest zapisywany jako []T, gdzie elementy mają typ T. Wygląda to jak typ tablicowy bez rozmiaru.

Tablice i wycinki są ze sobą ściśle powiązane. Wycinek jest lekką strukturą danych, dającą dostęp do podsekwencji (albo do wszystkich) elementów tablicy, która jest określana jako bazowa tablica wycinka. Wycinek ma 3 komponenty: wskaźnik, długość i pojemność. Wskaźnik wskazuje pierwszy z elementów tablicy osiągalny poprzez dany wycinek, ale nie jest to konieczne pierwszy w kolejności element tej tablicy. Długość jest liczbą elementów wycinka i nie może przekraczać jego pojemności, która jest zwykle liczbą elementów między początkiem wycinka a końcem bazowej tablicy. Te wartości zwracają wbudowane funkcje len i cap.

Operator wycinka a[i:j], gdzie 0 <= i <= j <= cap(a), tworzy nowy wycinek odwołujący się do elementów od 1 do j-1 sekwencji a, która może być zmienną tablicy, wskaźnikiem do tablicy lub innym wycinkiem. Powstały wycinek ma j-i elementów. Jeśli pominięte zostało i, to i = 0, a jeśli pominięte zostało j, to j = len(a).

Ponieważ wycinek zawiera wskaźnik do elementu tablicy, przekazywanie wycinka do funkcji pozwala jej modyfikować elementy bazowej tablicy.

s := a[2:10]
s := []int {0,1,2,3,4,5}

W przeciwieństwie do tablic wycinki nie są porównywalne, nie możemy więc użyć operatora == do przetestowania, czy dwa wycinki zawierają te same elementy. Standardowa biblioteka zapewnia wysoce zoptymalizowaną funkcję bytes.Equal do prównywania dwóch wycinków bajtów ([]byte).

Wartością zerową typu wycinka jest nil, który nie ma tablicy bazowej. Ma zerową długość i pojemność, ale są również wycinki o zerowej długości i pojemności niebędące wycinkami nil. Jeśli więc potrzebujesz przetestować, czy wycinek jest pusty, użyj len(s) == 0, a nie s == nil.

Wbudowana funkcja append pozwala dodawać więcej niż jeden nowy element lub nawet cały wycinek elementów. Może ona zmienić tablicę bazową, jeśli dotychczasowa miała za mało elementów. Zazwyczaj nie wiemy, czy dane wywołanie funkcji append spowoduje ponowną alokację, więc nie możemy zakładać, że oryginalny wycinek odwołuje się do tej samej tablicy co powstały wycinek ani też, że odwołuje się do innej. Podobnie nie możemy zakładać, że przypisania do elementów starego wycinka zostaną (lub nie) odzwierciedlone w nowym wycinku. W związku z tym zwykle przypisuje się wynik wywołania funkcji append do tej samej zmiennej wycinka, której wartość przekazaliśmy tej funkcji:

runes = append( runes, rune )

Mapy

Mapa jest referencją do tablicy mieszającej, a typ mapy jest zapisywany jako map[K]V, gdzie K i V są typami jej kluczy i wartości. Wszystkie klucze w danej mapie są tego samego typu i wszystkie wartości są tego samego typu, ale klucze nie muszą być tego samego typu co wartości.

ages := make(map[string]int)

ages := map[string]int {
  "alicja": 31,
  "krzysiek": 34,
}

ages["alicja"] = 32

delete(ages, "alicja")

Przeszukiwanie mapy przy użyciu klucza, którego w niej nie ma, zwraca wartość zerową dla jego typu:

ages["robert"] += 1

age, ok := ages["błażej"]

if !ok { /* ... */ }
if age, ok := ages["błażej"]; !ok { /* ... */ }

for name, age := range ages {
  ...
}

Tak jak wycinki, mapy nie mogą być ze sobą porównywane. Jedynym prawidłowym porównaniem jest porównanie z wartością nil.

Język Go nie zapewnia typu set, ale można do tego użyć mapy z wartościami typu bool.

Struktury

Typ struktury może zawierać mieszaninę eksportowanych i nieeksportowanych pól. Wartość zerowa dla struktury składa się z wartości zerowych każdego z jej pól.

type Employee struct {
  ID            int
  Name, Address string
  DoB           time.Time
  Position      string
  Salary        int
  ManagerID     int
}
var dilbert Employee

Zapis kropkowy działa również ze wskaźnikiem do struktury:

var employeeOfTheMonth *Employee = &dilbert
employeeOfTheMonth.Position += " (proaktywny gracz zespołowy)"

type Point struct{ X, Y int }
p := Point{1, 2}
p2 := Point{Y: 2} // X: 0

Jeśli wszystkie pola struktury są porównywalne, sama struktura jest porównywalna.

Go pozwala deklarować pole z typem, ale bez nazwy. Takie pola zwane są polami anonimowymi. Typ takiego pola musi być typem nazwanym lub wskaźnikiem do typu nazwanego.

type Circle struct {
  Point
  Radius int
}

type Wheel struct {
  Circle
  Spokes int `json:"szprychy,omitempty"`
}

var w Wheel
w.X = 8 // w.Circle.Point.X = 8
w = Wheel{8,8,5,20} // błąd kompilacji
w = Wheel{Circle{Point{8,8},5},20}
Zewnętrzny typ struct zyskuje nie tylko pola typu osadzonego, ale także jego metody. Kompozycja ma zasadnicze znaczenie dla programowania obiektowego w języku Go.

Funkcje

func nazwa(lista_parametrów) (lista_wyników) {
  ciało funkcji
}

Jeśli funkcja zwraca jeden nienazwany wynik lub nie zwraca żadnych wyników, nawiasy są opcjonalne i zazwyczaj pomijane. Pominięcie listy wyników całości oznacza zadeklarowanie funkcji, która nie zwraca żadnej wartości i jest wywoływana dla jej efektów.

W języku Go nie funkcjonuje pojęcie domyślnych wartości parametrów i nie istnieje żaden sposób określania argumentów przez nazwę.

Można niekiedy spotkać deklarację funkcji bez ciała, co wskazuje że dana funkcja jest zaimplementowana w języku innym niż Go.

Wiele implementacji języków programowania używa stałych rozmiarów stosu wywołań funkcji. Typowe są rozmiary od 64kB do 2MB. Stosy o stałym rozmiarze nakładają ograniczenia na głębokość rekurencji, należy więc być ostrożnym, aby uniknąć przepełnienia stosu podczas rekurencyjnej trawersacji dużych struktur danych. Stosy o stałych rozmiarach mogą nawet stanowić zagrożenie dla bezpieczeństwa. W przeciwieństwie do tego typowe implementacje Go używają stosów o zmiennym rozmiarze, które na początku są niewielkie i rosną w miarę potrzeb do rozmiarów rzędu gigabajtów. Pozwala to bezpiecznie korzystać z rekurencji, bez obawy o przepełnienie.

return nil, err

W funkcji z wynikami nazwanymi operandy instrukcji return moga być pomijane. Nazywa się to nagim zwracaniem (ang. bare return). Nagie zwracanie może zredukować duplikowanie kodu, ale rzadko ułatwia jego zrozumienie.

Błędy

Funkcja, dla której niepowodzenie jest oczekiwanym zachowaniem, zwraca dodatkowy wynik, umownie ostatni. Jeżeli niepowodzenie ma tylko jedną możliwą przyczynę, wynik jest wartością logiczną, zwykle zwaną ok:

value, ok := cache.lookup(key)
if !ok {
  ...
}

Znacznie częściej, szczególnie w przypadku operacji we/wy, awaria może mieć wiele przyczyn, dla których podmiot wywołujący potrzebuje wyjaśnienia. W takich przypadkach typem dodatkowego wyniku jest error.

doc, err := html.Parse( body )

return nil, fmt.Errorf( "Parsowanie %s jako HTML: %v", url, msg )

Podejście języka Go odróżnia go od wielu innych języków programowania, w których błędy są zgłaszane za pomocą wyjątków, a nie zwykłych wartości. Chociaż Go posiada pewnego rodzaju mechanizm obsługi wyjątków, jest on używany tylko do raportowania naprawdę nieoczekiwanych awarii, które wskazują na usterkę, a nie na rutynowe błędy wykonywania, które powinny być przewidziane podczas budowania solidnego programu.

Wszystkie funkcje log poprzedzają komunikat o błędzie czasem i datą:

if err:= WaitForServer(url); err != nil {
  log.Fatalf("Strona nie działa: %v\n", err)
}

log.SetPrefix("wait: ")

fmt.Fprintf(os.Stderr, "komunikat")

Funkcje anonimowe

strings.Map( func(r rune) rune { return r+1 }, "HAL-9000" )

Funkcje o zmiennej liczbie argumentów

Funkcja wariadyczna (ang. variadic function) to taka, która może być wywoływana z różną liczbą parametrów.

func sum(vals ...int) int { ... }
values := []int{1,2,3,4}
fmt.Println(sum(values...)) // 10
fmt.Println(sum(1,2,3,4)) // 10

Przyrostek f jest szeroko stosowaną konwencją nazewnictwa dla funkcji wariadycznych, które akceptują łańcuch znaków formatowania w stylu Printf.

Składniowo instrukcja defer jest zwykłym wywołaniem funkcji lub metody poprzedzonym słowem kluczowym defer. Wyrażenia funkcji i argumentów są ewaluowane, gdy ta instrukcja jest wykonywana, ale rzeczywiste wywołanie jest odraczane do czasu, aż funkcja zawierająca instrukcję defer zostanie zakończona w sposób normalny (przez wykonanie instrukcji return lub dotarcie do końca) lub anormalny (przez procedurę panic). Odraczać można dowolną liczbę wywołań. Są one wykonywane w kolejności odwrotnej do tej, w jakiej zostały odroczone. Właściwym miejscem dla instrukcji defer zwalniającej zasób jest wstawienie jej bezpośrednio po udanym pobraniu zasobu.

funct title(url string) string {
  resp, err := http.Get( url )
  if err != nil {
    ...
  }
  defer resp.Body.Close()
  ...
}

Funkcje odroczone są uruchamiane po zaktualizowaniu przez instrukcje return zmiennych wynikowych danej funkcji. Ponieważ anonimowa funkcja może uzyskać dostęp do zmiennych (w tym wyników nazwanych) zawierającej ją funkcji, odroczona funkcja anonimowa może obserwować wyniki tej funkcji. Odroczona funkcja anonimowa może nawet zmienić wartości, które zawierająca ją funkcja zwraca swojemu podmiotowi wywołującemu:

func triple(x int) (result int) {
  defer func() { result += x }
  return x*2;
}

Instrukcja defer może być również używana do łączenia w pary akcji "na wejście" i "na wyjście" podczas debugowania funkcji złożonej. Poniższa funkcja bigSlowOperation od razu wywołuje funkcję trace wykonującą akcję "na wejście", a następnie zwracającą wartość funkcji, która gdy zostanie wywołana, wykonuje odpowiadającą akcję "na wyjście". Poprzez odroczenie w ten sposób wywołania funkcji zwracanej możemy instrumentować punkt wejścia i wszystkie punkty wyjścia funkcji w jednej instrukcji, a nawet przekazywać pomiędzy tymi dwiema akcjami wartości takie jak czas start. Nie zapomnij jednak o końcowych nawiasach w instrukcji defer, inaczej akcja "na wejście" wydarzy się na wyjściu, a akcja "na wyjście" nie wydarzy się w ogóle!

func bigSlowOperation() {
  defer trace("bigSlowOperation")()
  // dużo pracy...
}

func trace(msg string) func() {
  start := time.Now()
  log.Printf("punkt wejścia %s", msg)
  return func() { log.Printf("punkt wyjścia %s (%s)", msg, time.Since( start )) }
}

Procedura panic

System typów języka Go wyłapuje wiele błędów podczas kompilacji, ale inne błędy, takie jak próba uzyskania dostępu poza zakresem tablicy lub wyłuskanie wskaźnika nil, wymagają kontroli w czasie wykonania. Gdy środowisko wykonawcze języka Go wykrywa te błędy, uruchamia procedurę panic (panika).

Podczas typowej procedury panic normalne wykonywanie jest zatrzymywane, wykonywane są wszystkie odroczone wywołania funkcji w danej procedurze goroutine, a program ulega awarii i generowany jest komunikat dziennika. Taki komunikat dziennika zawiera: wartość paniki, którą jest zwykle pewnego rodzaju komunikat błędu, oraz dla każdej procedury goroutine ślad stosu, pokazujący stos wywołań funkcji, które były aktywne w momencie paniki. Komunikat dziennika często ma wystarczającą ilość informacji do zdiagnozowania głównej przyczyny problemu bez uruchamiania ponownie programu, więc zawsze powinien być uwzględniony w raporcie o błędach dotyczącym panikującego programu.

Nie wszystkie paniki pochodzą ze środowiska wykonawczego. Wbudowana funkcja panic może być wywoływana bezpośrednio i przyjmuje dowolną wartość jako argument.

Chociaż mechanizm paniki języka Go przypomina wyjątki w innych językach programowania, sytuacje, w których wykorzystywana jest procedura panic, są zupełnie inne. Ponieważ procedura panic powoduje awarię programu, jest zasadniczo wykorzystywana w przypadku poważnych błędów, takich jak logiczne niespójności w programie. Staranni programiści traktują każdą awarię jako dowód na błąd w kodzie. Solidny program powinien elegancko obsługiwać "spodziewane" błędy, takie jak te, które wynikają z nieprawidłowych danych wejściowych, niewłaściwej konfiguracji lub niepowodzenia we/wy. Najlepiej radzić sobie z nimi za pomocą wartości error.

Mechanizm paniki języka Go uruchamia odroczone funkcje, zanim odwinie stos.

W wyniku paniki środowisko wykonawcze kończy program, przekazując do standardowego strumienia błędów komunikat paniki i zrzut stosu.

Dla celów diagnostycznych funkcja runtime.Stack pozwla programiście wykonać zrzut stosu w dowolnym momencie.

Jeśli wbudowana funkcja recover jest wywoływana w ramach funkcji odroczonej, a funkcja zawierająca instrukcję defer panikuje, recover kończy aktualny stan procedury panic i zwraca wartość paniki. Funkcja, która uruchomiła procedurę panic, nie będzie kontynuowana od miejsca przerwania, ale powróci normalnie. Jeśli funkcja recover jest wywoływana w każdym innym czasie, nie wywołuje żadnego efektu i zwraca nil.

func Parse(input string) (s *Syntax, err error) {
  defer func() {
    if p := recover(); p != nil {
      err = fmt.Errorf( "błąd wewnętrzny: %v", p )
    }
  }()
  // parser...
}

Odzyskiwanie sprawności po procedurze panic w ramach tego samego pakietu może uprościć obsługę złożonych lub nieoczekiwanych błędów, ale co do zasady nie należy próbować odzyskiwania sprawności po procedurze panic innego pakietu. Publiczne interfejsy API powinny zgłaszać awarie jako typy error. Nie należy także odzyskiwać sprawności po procedurze panic, która może przejść przez funkcję nieutrzymywaną przez Ciebie, taką jak wywołanie zwrotne dostarczane przez podmiot wywołujący, ponieważ nie możesz zadbać o jej bezpieczeństwo.

Metody

Metoda jest funkcją powiązaną z określonym typem.

func (p Point) Distance(q Point) float64 {
  return math.Hypot( q.X - p.X, q.Y - p.Y );
}

Dodatkowy parametr p nazywa się odbiornikiem metody, co zostało odziedziczone po wczesnych językach obiektowych, które opisują wywołanie metody jako "wysyłanie komunikatu do obiektu".

Metody i pola typu struct zamieszkują tę samą przestrzeń nazw.

W kwestii dopuszczania powiązywania metod z dowolnym typem Go różni się od wielu innych języków obiektowych. Często wygodnie jest definiować dodatkowe zachowania dla prostych typów, takich jak: liczby, łańcuchy znaków, wycinki, mapy, a czasami nawet funkcje. Metody mogą być deklarowane na dowolnym typie nazwanym zdefiniowanym w tym samym pakiecie, pod warunkiem że jego typem bazowym nie jest wskaźnik ani interfejs.

Ponieważ wywołanie funkcji tworzy kopię każdej wartości argumentu, jeśli funkcja musi zaktualizować zmienną lub jeśli argument jest tak duży, że chcemy uniknąć jego kopiowania, musimy przekazać adres zmiennej za pomocą wskaźnika. To samo odnosi się do metod, które potrzebują zaktualizować zmienną odbiornika: doczepiamy je do typu wskaźnika, takiego jak *Point.

func (p *Point) ScaleBy(factor float64) {
  p.X *= factor
  p.Y *= factor
}

W rzeczywistym programie konwencja nakazuje, że jeśli jakakolwiek metoda typu Point posiada odbiornik wskaźnikowy, wszystkie metody typu Point powinny mieć odbiornik wskaźnikowy, nawet te, które nie do końca go potrzebują.

Jeśli odbiornik p jest zmienną typu Point, ale metoda wymaga odbiornika *Point, możemy użyć skrótu p.ScaleBy(2), a kompilator wykona na zmiennej niejawną operację &p.

Metoda jest deklarowana za pomocą pewnego wariantu zwykłej deklaracji funkcji, w której przed nazwą funkcji pojawia się dodatkowy parametr. Ten parametr dołącza daną funkcję do swojego typu.

Jednostką hermetyzacji w Go jest pakiet, a nie typ, jak w wielu innych językach. Pola typu struct są widoczne dla całego kodu w obrębie tego samego pakietu. Nie ma różnicy, czy kod pojawia się w funkcji, czy w metodzie.

Podczas nazywania metody pobierającej wartość zwykle pomijamy prefiks Get. To preferowanie zwięzłości rozciąga się na wszystkie metody, nie tylko na akcesory pól, a także na inne zbędne prefiksy, takie jak Fetch, Find i Lookup.

Interfejsy

Wiele języków obiektowych ma jakąś koncepcję interfejsów, ale interfejsy języka Go wyróżnia to, że ich warunki są spełniane pośrednio. Innymi słowy: nie ma potrzeby deklarowania wszystkich interfejsów, których warunki spełnia konkretny typ. Wystarczy po prostu posiadanie niezbędnych metod. Taka konstrukcja pozwala na tworzenie nowych interfejsów, których warunki są spełniane przez istniejące konkretne typy bez ich zmieniania, co jest szczególnie przydatne dla typów zdefiniowanych w niekontrolowanych przez Ciebie pakietach.

Swoboda zastępowania jednego typu innym, który spełnia warunki tego samego interfejsu, nazywa się podstawialnością, i jest charakterystyczną cechą programowania obiektowego.

Typ io.Writer jest jednym z najszerzej stosowanych interfejsów, ponieważ zapewnia abstrakcję wszystkich typów, do których moga być zapisywane bajty, co obejmuje: pliki, bufory pamięci, połączenia sieciowe, klienty HTTP, programy archiwizujące, programy szyfrujące, itd. Wiele innych uzytecznych interfejsów definiuje pakiet io. Interfejs Reader reprezentuje dowolny ytp, z którego można odczytywać bajty, a Closer jest dowolną wartością, którą można zamknąć, taką jak plik lub połączenie sieciowe.

package io

type Reader interface {
  Read(p []byte) (n int, err error)
}

type Writer interface {
  Write(p []byte) (n int, err error)
}

type Closer interface {
  Close() error
}

type ReadWriteCloser interface {
  Reader
  Writer
  Closer
}

Typ interface{}, zwany pustym typem interfejsowym, jest nieodzowny. Ponieważ nie stawia żadnych wymagań dotyczących typów spełniających jego warunki, możemy do niego przypisać dowolną wartość.

Asercje typów

Asercja typu (ang. type assertion) jest operacją stosowaną do wartości interfejsu. Składniowo wygląda to jak x.(T), gdzie x jest wyrażeniem typu interfejsu, a T jest typem (zwanym typem zakładanym). Asercja typu sprawdza, czy dynamiczny typ jej operandu jest zgodny z typem zakładanym.

if f, ok := w.(*os.File); ok {
  ...
}

Przełączniki typów:

switch x := x.(type) {
  case nil:
    return "NULL"
  case uint, int:
    return fmt.Sprintf("%d", x)
  default:
    panic( fmt.Sprintf("Nieoczekiwany typ %T: %v", x, x) )
}

Funkcje goroutine i kanały

Język Go umożliwia stosowanie dwóch stylów programowania współbieżnego: kanały CSP (ang. communicating sequential processes) i pamięć współdzieloną.

W języku Go każda współbieżnie wykonywana aktywność nazywa się funkcją goroutine.

Po uruchomieniu programu jego jedyną funkcją goroutine jest ta, która wywołuje funkcję main, więc nazywamy ją główną funkcją goroutine. Nowe gorutyny są tworzone przez instrukcję go. Argumenty przekazywane do funkcji uruchamianej instrukcją go są ewaluowane, gdy wykonywana jest sama instrukcja go.

Gdy kończy się funkcja main, wszystkie funkcje goroutine zostają nagle zakończone i program kończy działanie. Poza powróceniem z funkcji main lub wyjściem z programu nie ma żadnego programowego sposobu, aby jedna funkcja goroutine zatrzymała drugą, ale istnieją sposoby skomunikowania się z funkcją goroutine w celu zażądania, żeby się zatrzymała.

Sieci to naturalna dziedzina, w której używa się współbieżności, ponieważ serwery zazwyczaj obsługują jednocześnie wiele połączeń z klientami, a każdy klient jest zasadniczo niezależny od innych.

Kanały

Jeśli funkcje goroutine są aktywnościami współbieżnego programu Go, kanały (ang. channels) są połączeniami między nimi. Kanał jest mechanizmem komunikacji, który umożliwia jednej funkcji goroutine wysyłanie wartości do drugiej funkcji goroutine. Każdy kanał jest przewodem dla wartości określonego typu, nazywanego typem elementów kanału. Typ kanału, którego elementy mają typ int, jest zapisywany jako chan int.

Aby utworzyć kanał, używamy wbudowanej funkcji make:

ch := make(chan int)

Podobnie jak w przypadku map, kanał jest referencją do struktury danych utworzonej przez funkcję make. Kiedy kopiujemy kanał lub przekazujemy go jako argument do funkcji, kopiujemy referencję, więc podmioty wywołujący i wywoływany odwołują się do tej samej struktury danych.

Kanał ma dwie podstawowe operacje: wysyłanie i odbieranie, znane pod wspólną nazwą komunikacji. Instrukcja wysyłania przekazuje wartości z jednej funkcji goroutine poprzez kanał do drugiej funkcji goroutine, wykonującej odpowiednie wyrażenie odbierania. Obie operacje są zapisywane przy użyciu operatora <-. Wyrażenie odbierania, którego wynik nie jest używany, jest prawidłową instrukcją.

ch <- x  // instrukcja wysyłania
x = <-ch // wyrażenie odbierania w instrukcji przypisania
<-ch     // instrukcja odbierania; wynik jest porzucany

Istnieje wariant operacji odbierania, który generuje dwa wyniki: odebrany element kanału oraz wartość logiczną zwyczajowo nazywaną ok, która jest prawdziwa (true) dla udanego odbioru i fałszywa (false) dla odbioru na zamkniętym i osuszonym kanale.

x, ok := <- mychannel

Go pozwala nam używać pętli range również do iteracji przez kanały. Jest to wygodniejsza składnia dla odbierania wszystkich wysłanych przez kanał wartości i zakończenia pętli po ostatniej z nich.

for x := range naturals {
  squares <- x * x
}

Kanały obsługują jeszcze trzecią operację, zamknięcia, która ustawia flagę wskazującą, że przez dany kanał nie będą już nigdy wysyłane żadne wartości. Kolejne próby wysłania wywołają panikę. Operacje odbierania na zamkniętym kanale będą dawać wysłane już wartości, dopóki nie będzie już żadnych więcej wartości. Od tego momentu wszystkie operacje odbierania zostaną natychmiast zakończone i dadzą wartość zerową dla tego typu elementów kanału (można to zinterpretować jako rozgłaszanie (ang. broadcast) zdarzenia).

close(ch)

Całkiem normalne jest, aby kilka funkcji goroutine wysyłało wartości współbieżnie do tego samego kanału, albo otrzymywało z tego samego kanału.

Kanał utworzony za pomocą prostego wywołania make jest nazywany kanałem niebuforowanym, ale make akceptuje opcjonalny drugi argument, czyli liczbę całkowitą zwaną pojemnością kanału. Jeżeli pojemność jest inna niż zero, make tworzy kanał buforowany.

ch = make(chan int)    // kanał niebuforowany
ch = make(chan int, 0) // kanał niebuforowany
ch = make(chan int, 3) // kanał buforowany z pojemnością 3

Kanały niebuforowane

Operacja wysyłania na niebuforowanym kanale blokuje wysyłającą funkcję goroutine, dopóki następna funkcja goroutine nie wykona odpowiedniej operacji odbierania na tym samym kanale. W tym momencie wartość jest przekazywana i obie funkcje goroutine mogą kontynuować swoje wykonywanie. I odwrotnie: jeśli operacja odbierania została podjęta jako pierwsza, odbierająca funkcja goroutine jest blokowana, dopóki inna funkcja goroutine nie wykona operacji wysyłania na tym samym kanale.

Komunikacja przez niebuforowany kanał powoduje synchronizację funkcji goroutine wysyłania i odbierania. Z tego powodu niebuforowane kanały są czasem nazywane kanałami synchronicznymi. Gdy wartość jest wysyłana przez niebuforowany kanał, otrzymanie wartości odbywa się przed przebudzeniem wysyłającej funkcji goroutine.

Komunikaty wysyłane poprzez kanały mają dwa ważne aspekty. Każdy komunikat ma wartość, ale czasem równie ważny jest sam fakt komunikacji i moment, w którym ma ona miejsce. Gdy chcemy podkreślić ten aspekt, nazywamy komunikaty zdarzeniami. Kiedy zdarzenie nie przenosi żadnych dodatkowych informacji, czyli jego jedynym celem jest synchronizacja, będziemy podkreślać to, używając kanału, którego typem elementów jest struct{}, chociaż powszechne jest użycie dla tego samego celu kanału typu bool lub int, poniważ done<-1 jest krótsze niż done<<-struct{}{}.

Kanały mogą być wykorzystywane do łączenia funkcji goroutine w taki sposób, aby dane wyjściowe z jednej funkcji były danymi wejściowymi dla drugiej. Nazywamy to potokiem (ang. pipeline).

Jednokierunkowe typy kanałów

Gdy kanał jest dostarczany jako parametr funkcji, prawie zawsze intencja jest taka, aby był wykorzystywany wyłącznie do wysyłania lub wyłącznie do odbierania.

Aby udokumentować ten zamiar i zapobiec niewłaściwemu wykorzystaniu, system typów języka Go zapewnia jednokierunkowe typy kanałów, które udostępniają tylko jedną lub drugą operację: wysyłanie lub odbieranie. Typ chan<-int, czyli kanał typów int tylko do wysyłania, pozwala na wysyłanie, ale nie na odbieranie. Z kolei typ <-chan int, czyli kanał typów int tylko do odbioru, pozwala na odbieranie, ale nie na wysyłanie. (Pozycja strzałki w stosunku do słowa kluczowego chan jest symboliczna). Naruszenia tego reżimu są wykrywane w czasie kompilacji.

Ponieważ operacja close zakłada, że nie będzie już więcej prób wysyłania na danym kanale, może ją wywołać tylko wysyłająca funkcja goroutine. Dlatego próba zamknięcia kanału tylko do odbioru jest błędem podczas kompilacji.

Konwersje z dwukierunkowych na jednokierunkowe typy kanałów są dozwolone w każdym przypisaniu. Nie ma jednak powrotu: gdy masz już wartość jednokierunkowego typu, nie ma możliwości uzyskania z niej wartości typu chan int, który odwołuje się do tej samej struktury danych kanału.

Kanały buforowane

Jeśli kanał jest pełny, operacja wysyłania blokuje swoją funkcję goroutine, dopóki inna funkcja goroutine odbierania nie zwolni miejsca. Natomiast jeśli kanał jest pusty, operacja odbierania blokuje, dopóki jakaś wartość nie zostanie wysłana przez inną funkcję goroutine.

Gdy zastosujemy do kanału wbudowaną funkcję len, zwróci ona liczbę aktualnie zbuforowanych elementów - może się to przydać przy diagnozowaniu błędów lub optymalizacji wydajności.

len(ch)

Nowicjusze zwabieni przyjemnie prostą składnią buforowanych kanałów ulegają czasami pokusie wykorzystania ich w pojedynczej funkcji goroutine jako kolejki, ale jest to błąd. Kanały są głęboko połączone z harmonogramem funkcji goroutine i bez innej funkcji goroutine odbierającej z danego kanału nadawca (a być może cały program) ryzykuje zablokowanie na zawsze. Jeśli potrzebujesz jedynie prostej kolejki, użyj do tego celu wycinka.

Wyciek funkcji goroutine (ang. goroutine leak) to sytuacja w której gorutyna w nieskończoność zawisa na odczycie lub zapisie, ponieważ partner nigdy się nie pojawi. W odróżnieniu od nieużywanych zmiennych wyciekające funkcje goroutine nie są automatycznie poddawane procesowi odzyskiwania pamięci, więc należy się upewnić, że zakończą one swoje działanie, gdy nie będą już potrzebne.

Multipleksowanie za pomocą instrukcji select

select {
  case <- ch1:
    // ...
  case x := <- ch2:
    // ...
  default:
    ...
}

Każdy przypadek określa komunikację (operację wysyłania lub odbierania na jakimś kanale) i powiązany blok instrukcji. Wyrażenie odbierania może występować samodzielnie, tak jak w pierwszym przypadku, lub w krótkiej deklaracji zmiennych, tak jak w drugim przypadku. Druga forma pozwala odwoływać się do odebranej wartości.

Instrukcja select czeka, aż komunikacja dla jakiegoś przypadku będzie gotowa do procedowania. Następnie przeprowadzana jest ta komunikacja i wykonywane są powiązane instrukcje danego przypadku. Pozostałe komunikacje nie są przeprowadzane. Instrukcja select bez przypadków, zapisywana jako select{}, czeka w nieskończoność. Jeśli gotowych jest kilka przypadków, instrukcja select wybiera losowo jeden z nich, co gwarantuje, że każdy kanał ma równe szanse na wybranie. Przypadek domyślny (default) określa co zrobić, gdy żadna z pozostałych komunikacji nie może być natychmiast wykonana.

W powyższym przykładzie, jeśli zmienna ch2 ma wartość nil, jej przypadek w instrukcji select jest w efekcie wyłączony.

Kanały i typ net.Conn to przykłady typów współbieżnie bezpiecznych. Typ jest współbieżnie bezpieczny, jeśli wszystkie jego dostępne metody i operacje są współbieżnie bezpieczne. Typy współbieżnie bezpieczne są raczej wyjątkiem niż regułą, ale oczekuje się że eksportowane funkcje poziomu pakietu będą współbieżnie bezpieczne.

Współbieżność ze współdzieleniem zmiennych

Nie komunikuj się przez współdzielenie pamięci, zamiast tego współdziel pamięć przez komunikowanie się.

Wyścigiem nazywamy sytuację, w której program nie daje poprawnego wyniku dla niektórych przeplatań operacji wielu funkcji goroutine. Sytuacje wyścigu są szkodliwe, ponieważ mogą pozostać w programie w formie utajonej i pojawiają się rzadko, prawdopodobnie tylko przy dużym obciążeniu lub podczas korzystania z niektórych kompilatorów, platform lub architektur. Z tego powodu trudno je odtworzyć i zdiagnozować. Wyścig danych ma miejsce za każdym razem, gdy dwie funkcje goroutine uzyskują współbieżnie dostęp do tej samej zmiennej i co najmniej jedna z operacji uzyskania dostępu jest zapisem.

Funkcja goroutine, która pośredniczy w uzyskiwaniu dostępu do zamkniętej zmiennej za pomocą żądania wysyłanego przez kanał, jest nazywana monitorującą funkcją goroutine dla tej zmiennej.

Nawet gdy zmienna nie może być zamknięta w pojedynczej funkcji goroutine przez cały swój cykl życia, zamykanie nadal może być rozwiązaniem problemu współbieżnego dostępu. Powszechne jest np. współdzielenie zmiennej między funkcjami goroutine w potoku poprzez przekazywanie jej adresu z jednego etapu do następnego etapu przez kanał. Jeśli każdy etap potoku powstrzymuje się od dostępu do zmiennej po wysłaniu jej do kolejnego etapu, wszystkie próby uzyskania dostępu do tej zmiennej są sekwencyjne. W efekcie zmienna jest zamknięta w jednym etapie potoku, potem jest zamknięta w następnym itd. Ten rygor jest czasami nazywany zamykaniem szeregowym (ang. serial confinement).

Wzajemne wykluczanie

Zgodnie z przyjętą konwencją zmienne strzeżone przez mutex sa deklarowane natychmiast po deklaracji samego muteksu. Jeśli odstąpisz od tej zasady, należy to udokumentować.

var (
  mu sync.Mutex
  balance int
)

func Balance() int {
  mu.Lock()
  defer mu.Unlock()
  return balance
}

W powyższym przykładzie wywołanie Unlock jest wykonywane po tym, jak instrukcja return odczyta wartość zmiennej balance, więc funkcja Balance jest współbieżnie bezpieczna.

Muteksy odczytu/zapisu

Muteks sync.RWMutex pozwala przeprowadzać równolegle operacje tylko odczytu, ale operacjom zapisu daje pełny dostęp na wyłączność.

var mu sync.RWMutex
var balance int

func Balance() int {
  mu.RLock()
  defer mu.RUnlock()
  return balance
}

Funkcja Balance wywołuje teraz metody RLock i RUnlock, aby założyć i zwolnić blokadę odczytów, czyli blokadę współdzieloną. Funkcja Deposit, która nie uległa zmianie, wywołuje metody mu.Lock i mu.Unlock, aby założyć i zwolnić blokadę zapisu, czyli blokadę na wyłączność.

Użycie RWMutex jest korzystne tylko wtedy, kiedy większość funkcji goroutine zakładających blokadę jest funkcjami odczytu, a blokada jest przedmiotem rywalizacji, czyli funkcje goroutine stale muszą czekać aby ją założyć. RWMutex wymaga bardziej złożonej wewnętrznie ewidencji, przez co jest wolniejszy niż standardowy mutex dla blokad niebędących przedmiotem rywalizacji.

Synchronizacja pamięci

W nowoczesnym komputerze mogą być dziesiątki procesorów, każdy z własną lokalna pamięcią podręczną pamięci głównej. Dla efektywności operacje zapisu do pamięci są buforowane w ramach każdego procesora i przenoszone do pamięci głównej tylko wtedy, gdy jest to konieczne. Mogą nawet być umieszczane w pamięci głównej w innej kolejności niż zostały zapisane przez zapisującą funkcję goroutine. Podstawowe elementy synchronizacji, takie jak komunikacje poprzez kanał i operacje muteksu, powodują opróżnianie bufora procesora i zapisywanie w pamięci wszystkich jego nagromadzonych operacji zapisu, aby zagwarantować, żeby efekty wykonywania funkcji do tego punktu były widoczne dla funkcji goroutine uruchomionych na innych procesorach.

Rozważmy możliwe dane wyjściowe z poniższego fragmentu kodu:

var x, y int
go func() {
  x = 1                    // A1
  fmt.Print("y: ", y, " ") // A2
}()
go func() {
  y = 1                     // B2
  fmt.Print("x: ", x, " ") // B2
}

W zależności od kompilatora, procesora i wielu innych czynników możemy niestety otrzymać i takie wyniki:

x:0 y:0
y:0 x:0

W ramach pojedynczej funkcji goroutine gwarantowane jest, że efekty każdej instrukcji będą występować w kolejności wykonywania. Funkcje goroutinesekwencyjnie spójne. Jednak w przypadku braku bezpośredniej synchronizacji za pomocą kanału lub muteksu nie ma żadnej gwarancji, że wydarzenia będą dostrzegane w tej samej kolejności przez wszystkie funkcje goroutine. Chociaż funkcja goroutine A musi zaobserwować efekt zapisu x=1, zanim odczyta wartość y, to nie musi koniecznie dostrzec zapisu w zmiennej y przeprowadzanego przez funkcję goroutine B, więc A może wypisać nieaktualną wartość y.

Ponieważ przypisanie i funkcja Print odwołują się do różnych zmiennych, kompilator może stwierdzić, że kolejność tych dwóch instrukcji nie może wpływać na wynik, i zamienić je. Jeśli dwie funkcje goroutine są wykonywane na różnych procesorach, z których każdy ma własną pamięć podręczną, operacje zapisu przeprowadzane przez jedną funkcję goroutine nie są widoczne dla funkcji Print drugiej goroutine, dopóki pamięci podręczne nie zostaną zsynchronizowane z pamięcią główną.

Wszystkich tych problemów współbieżności można uniknąć poprzez konsekwentne stosowanie prostych, ustalonych wzorców. Tam, gdzie to możliwe, należy zamykać zmienne w pojedynczej funkcji goroutine. Dla wszystkich innych zmiennych należy używać wzajemnego wykluczania.

Detektor wyścigów

Nawet przy największej staranności bardzo łatwo jest popełnić błąd związany ze współbieżnością. Na szczęście środowisko wykonawcze i zestaw narzędzi języka Go są wyposażone w zaawansowane i łatwe w użyciu narzędzie do analizy dynamicznej, czyli detektor wyścigów.

Aby użyć tego narzędzia, po prostu dodaj do polecenia go build, go run lub go test flagę -race. Spowoduje to, że kompilator skompiluje zmodyfikowaną wersję aplikacji czy testu z dodatkową instrumentacją, która skutecznie rejestruje wszystkie próby uzyskania dostępu do współdzielonych zmiennych w trakcie wykonywania, a także rejestruje tożsamości funkcji goroutine odczytujących lub zapisujących zmienną. Ponadto ten zmodyfikowany program rejestruje wszystkie zdarzenia synchronizacji, takie jak: instrukcje go, operacje na kanałach, wywołania (*sync.Mutex).Lock, (*sync.WaitGroup).Wait itd.

Detektor wyścigów analizuje ten strumień zdarzeń, szukając przypadków, gdzie jedna funkcja goroutine odczytuje lub zapisuje współdzieloną zmienną, która została ostatnio zapisana przez inną funkcję goroutine bez pośredniczącej operacji synchronizacji. Wskazuje to na równoczesny dostęp do współdzielonej zmiennej, a więc na wyścig danych. Narzędzie wyświetla raport, który zawiera tożsamość zmiennej oraz stosy aktywnych wywołań funkcji w odczytującej funkcji goroutine i zapisującej funkcji goroutine.

Detektor wyścigów raportuje wszystkie wyścigi danych, które zostały faktycznie wykonane. Jednak może wykryć tylko sytuacje wyścigu, które występują podczas działania programu. Nie można udowodnić, że nigdy nie wystąpią żadne sytuacje wyścigu.

Ze względu na dodatkowe ewidencjonowanie program skompilowany z detektorem wyścigów potrzebuje do wykonania więcej czasu i pamięci, ale ten narzut jest tolerowany nawet dla wielu zadań produkcyjnych. W rzadko występujących sytuacjach wyścigu użycie detektora wyścigów może zaoszczędzić wiele godzin lub dni debugowania.

Funkcje goroutine i wątki

Każdy wątek systemu operacyjnego ma stałego rozmiaru blok pamięci (najczęściej o wielkości 2MB) przeznaczony dla stosu, czyli przestrzeni roboczej, w której zapisywane są lokalne zmienne wywołań funkcji będących w trakcie wykonywania lub czasowo zawieszonych na czas wywołania innej funkcji. Ten stos o stałym rozmiarze to jednocześnie zbyt wiele i zbyt mało. 2 MB stosu byłyby wielkim marnotrawstwem pamięci dla niewielkiej funkcji goroutine. Nie jest rzadkością, że program Go tworzy setki tysięcy funkcji goroutine w tym samym czasie.

Natomiast funkcja goroutine zaczyna życie z małym stosem, zazwyczaj o wielkości ok. 2 KB. Stos funkcji goroutine, podobnie jak stos wątku systemu operacyjnego, przechowuje zmienne lokalne aktywnych i zawieszonych wywołań funkcji, ale, w przeciwieństwie do wątku systemu operacyjnego, nie ma stałego rozmiaru. Rośnie i maleje w miarę potrzeby. Limit rozmiaru stosu funkcji goroutine może wynosić nawet 1GB, więc jest o rzędy wielkości większy niż typowy stos wątkowy o stałym rozmiarze, choć oczywiście niewiele funkcji goroutine używa aż tyle pamięci.

Środowisko wykonawcze języka Go zawiera własnego planistę, który wykorzystuje technikę zwaną planowaniem m:n, ponieważ multipleksuje (lub planuje) m funkcji goroutine na n wątkach systemu operacyjnego. Zadanie planisty Go jest analogiczne do zadania planisty jądra systemu oepracyjnego, ale dotyczy tylko funkcji goroutine pojedynczego programu Go.

W przeciwieństwie do planisty wątków systemu operacyjnego, planista Go nie jest wywoływany cyklicznie przez zegar sprzętowy, ale pośrednio przez określone konstrukcje języka Go. Przykładowo: gdy funkcja goroutine wywołuje time.Sleep albo blokuje w operacji kanału lub muteksu, planista usypia ją i uruchamia kolejną funkcję goroutine do momentu, aż nadejdzie czas wybudzenia tej pierwszej. Ponieważ nie wymaga to przełączenia na kontekst jądra, zmiana rozplanowania funkcji goroutine jest o wiele mniej kosztowna niż zmiana rozplanowania wątku.

Planista Go wykorzystuje parametr zwany GOMAXPROCS do ustalenia, jak wiele wątków systemu operacyjnego może aktywnie wykonywać kod Go jednocześnie. Domyślną wartością tego parametru jest liczba procesorów maszyny.

Pakiety i narzędzie go

Język Go ma ponad 100 standardowych pakietów, które zapewniają fundamenty dla większości aplikacji. Społeczność Go, będąca kwitnącym ekosystemem projektowania, udostępniania, ponownego wykorzystywania i doskonalenia pakietów, opublikowała o wiele więcej. Przeszukiwalny indeks tych pakietów można znaleźć na stronie godoc.org.

Aby uniknąć konfliktów, ścieżki importów wszystkich pakietów innych niż te ze standardowej biblioteki powinny zaczynać się nazwą domeny internetowej organizacji, która jest właścicielem danego pakietu lub go hostuje. Umożliwia to również wyszukiwanie pakietów.

Jeśli musimy do jakiegoś pakietu zaimportować dwa pakiety, których nazwy są takie same, tak jak math/rand i crypto/rand, deklaracja import musi określać alternatywną nazwę dla co najmniej jednego z nich, aby uniknąć konfliktu. Nazywa się to importem ze zmianą nazwy.

import (
  "crypto/rand"
  mrand "math/rand"
}

Każda deklaracja import ustanawia zależność z bieżącego pakietu do importowanego pakietu. Narzędzie go build zgłasza błąd, jeśli te zależności tworzą cykl.

Pakiety i nazewnictwo

Nazwy pakietów zazwyczaj przyjmują formę liczby pojedynczej. Standardowe pakiety bytes, errors i strings używają formy liczby mnogiej, aby uniknąć ukrywania odpowiadających im predeklarowanych typów oraz w przypadku go/types aby uniknąć konfliktu ze słowem kluczowym.

Narzędzie go

Jedyną konfiguracją, jakiej kiedykolwiek będzie potrzebować większość użytkowników, jest ustawienie zmiennej środowiskowej GOPATH, która określa katalog główny obszaru roboczego. Przełączając się do innego obszaru roboczego, użytkownicy aktualizują wartość GOPATH.

GOPATH ma trzy podkatalogi. Podkatalog src zawiera kod źródłowy. Każdy pakiet rezyduje w katalogu, którego nazwa względna w stosunku do $GOPATH/src jest ścieżką importu pakietu, np. code/r01/helloworld. W podkatalogu pkg narzędzia kompilacji przechowują skompilowane pakiety, a podkatalog bin przechowuje wykonywalne programy, takie jak helloworld.

Druga zmienna środowiskowa GOROOT określa katalog główny dystrybucji Go, który zawiera wszystkie pakiety biblioteki standardowej. Struktura katalogów poniżej GOROOT przypomina tę dla GOPATH, więc np. pliki źródłowe pakietu fmt znajdują się w katalogu $GOROOT/src/fmt. Użytkownicy nie muszą nigdy ustawiać zmiennej środowiskowej GOROOT, ponieważ domyślne narzędzie go będzie używać lokalizacji, w której zostało zainstalowane.

Polecenie go env wyświetla faktyczne wartości zmiennych środowiskowych istotnych dla zestawu narzędzi, w tym wartości domyślne dla brakujących zmiennych. Zmienna środowiskowa GOOS określa docelowy system operacyjny (np. android, linux, darwin lub windows), a GOARCH - architekturę docelowego procesora, np. amd64, 386 lub arm.

Pobieranie pakietów

Polecenie go get może pobrać pojedynczy pakiet albo całe poddrzewo lub repozytorium, używając notacji wielokropka (...). Gdy narzędzie go get pobierze pakiety, kompiluje je, a następnie instaluje biblioteki i polecenia.

$ go get github.com/golang/lint/golint
$ $GOPATH/bin/golint code/r02/popcount

Polecenie go get obsługuje popularne strony hostujące kody, takie jak GitHub, Bitbucket i Launchpad, i może wysyłać odpowiednie żądania do ich systemów kontroli wersji. W przypadku mniej znanych stron być może trzeba będzie wskazać w ścieżce importu, który protokół wersji powinien zostać użyty, np. Git lub Mercurial. Aby uzyskać więcej informacji, uruchom polecenie go help importpath.

Katalogi tworzone przez polecenie go get są prawdziwymi klientami zdalnego repozytorium, a nie tylko kopiami plików, więc można korzystać z poleceń systemu kontroli wersji, aby zobaczyć różnice dokonanych lokalnych edycji lub zaktualizować folder do innej wersji.

Jeśli określisz flagę -u, polecenie go get zapewni, że wszystkie odwiedzone pakiety, w tym zależności, zostaną zaktualizowane do ich najnowszej wersji przed skompilowaniem i zainstalowaniem. Bez tej flagi istniejące już lokalne pakiety nie zostaną zaktualizowane.

Kompilowanie pakietów

Polecenie go build kompiluje każdy pakiet podany jako argument. Jeśli pakiet ma nazwę main, polecenie wywołuje konsolidator, aby utworzyć plik wykonywalny w bieżącym katalogu. Nazwa pliku wykonywalnego jest pobierana z ostatniego segmentu ścieżki importu pakietu. Ponieważ każdy katalog zawiera jeden pakiet, każdy program wykonywalny (czyli w terminologii uniksowej polecenie) wymaga własnego katalogu. Pakiety mogą być określane poprzez ich ścieżki importu, lub za pomocą względnej nazwy katalogu, która musi się zaczynać od segmentu . lub .., nawet jeśli normalnie nie jest to wymagane. Jeżeli nie jest dostarczony żaden argument, przyjmowany jest bieżący katalog.

Domyślnie polecenie go build kompiluje żądany pakiet i wszystkie jego zależności, a następnie wyrzuca cały skompilowany kod, z wyjątkiem końcowego pliku wykonywalnego, jeśli taki powstanie.

W przypadku jednorazowych programów często chcemy uruchomić plik wykonywalny od razu po jego skompilowaniu. Te dwa kroki łączy w sobie polecenie go run. Pierwszy argument, który nie kończy się na .go, przyjmuje się za początek listy argumentów dla pliku wykonywalnego Go.

Polecenie go install jest bardzo podobne do go build, z tym wyjątkiem, że zapisuje skompilowany kod każdego pakietu i polecenia, zamiast go wyrzucać. Skompilowane pakiety są zapisywane pod katalogiem $GOPATH/pkg odpowiadającym katalogowi src przechowującemu kod źródłowy, a polecenia wykonywalne są zapisywane w katalogu $GOPATH/bin. (Wielu użytkowników umieszcza $GOPATH/bin w wykonywalnej ścieżce wyszukiwania). Polecenia go build i go install nie uruchamiają potem kompilatora dla tych pakietów i poleceń, jeśli nie zostały one zmienione, co powoduje, że kolejne kompilacje są znacznie szybsze. Dla wygody polecenie go build -i instaluje pakiety, które są zależnościami celu kompilacji.

Ponieważ skompilowane pakiety różnią się w zależności od platformy i architektury, polecenie go install zapisuje je pod katalogiem, którego nazwa zawiera wartości zmiennych środowiskowych GOOS i GOARCH.

Łatwo jest skrośnie skompilować program Go, czyli skompilować plik wykonywalny przeznaczony dla innego systemu operacyjnego lub procesora. Wystarczy ustawić zmienne GOOS lub GOARCH podczas kompilacji.

Niektóre pakiety mogą potrzebować skompilować różne wersje kodu dla określonych platform lub procesorów, aby np. uporać się z niskopoziomowymi problemami przenośności lub dostarczyć zoptymalizowane wersje ważnych procedur. Jeśli nazwa pliku zawiera nazwę systemu operacyjnego lub architektury procesora, tak jak net_linux.go lub asm_amd64.s, wtedy narzędzie go skompiluje ten plik tylko podczas kompilacji dla tego celu. Specjalne komentarze, zwane znacznikami kompilacji (ang. build tags), zapewniają dokładniejszą kontrolę. Jeśli plik zawiera np. ten komentarz:

// +build linux darwin

przed deklaracją pakietu (i jego komentarzem dokumentującym), go build skompiluje go tylko wtedy, gdy będzie przeprowadzać kompilację dla systemów Linux lub MacOS X. Natomiast ten komentarz mówi, aby nigdy nie kompilować pliku:

// +build ignore

Dokumentowanie pakietów

Styl języka Go mocno promuje dobre dokumentowanie interfejsów API pakietów. Każda deklaracja eksportowanego elementu pakietu i sama deklaracja package powinny być bezpośrednio poprzedzone komentarzem wyjaśniającym ich przeznaczenie i sposób użycia.

Komentarze dokumentujące są zawsze pełnymi zdaniami, a pierwsze zdanie jest zwykle podsumowaniem, które rozpoczyna się od deklarowanej nazwy. Parametry funkcji i inne identyfikatory są wymieniane bez cudzysłowu lub znaczników. Dłuższe komentarze pakietów mogą uzasadniać własne pliki. Komentarz pakietu fmt ma ponad 300 linii. Taki plik nazywa się zwykle doc.go.

Narzędzie go doc wyświetla deklarację i komentarz dokumentujący dla encji określonej w wierszu poleceń. Może to być pakiet, element pakietu, metoda. To narzędzie nie wymaga podawania pełnych ścieżek importów ani stosowania właściwej wielkości liter. Poniższe polecenie wyświetla dokumentację funkcji *json.Decoder).Decode z pakietu encoding/json:

go doc json.decode

Drugie narzędzie, o łudząco podobnej nazwie godoc, serwuje zlinkowane strony HTML, które dostarczają te same informacje co go doc i wiele więcej. Można uruchomić instancję godoc w obszarze roboczym, jeśli chcesz przeglądać własne pakiety. W tym celu wpisz w przeglądarce adres http://localhost:8800/pkg po uruchomieniu tego polecenia:

$ godoc -http :8000

Flagi -analysis=type i -analysis=pointer tego polecenia poszerzają dokumentację i kod źródłowy o wyniki zaawansowanej analizy statystycznej.

Pakiety wewnętrzne

Pakiet jest najważniejszym mechanizmem hermetyzacji w programach Go. Nieeksportowane identyfikatory są widoczne tylko w ramach tego samego pakietu, a eksportowane pakiety są widoczne dla świata. Czasem jednak pomocne byłoby jakieś pośrednie rozwiązanie, czyli sposób na definiowanie identyfikatorów, które są widoczne dla niewielkiego zestawu zaufanych pakietów, ale nie dla wszystkich. Kiedy dzielimy np. duży pakiet na łatwiejsze w zarządzaniu części, możemy nie chcieć ujawniać innym pakietom interfejsów pomiędzy tymi częściami. Możemy również chcieć współdzielić funkcje narzędziowe między kilkoma pakietami projektu bez udostępniania ich na szerszą skalę.

Aby zaspokoić te potrzeby, narzędzie go build traktuje pakiet w szczególny sposób, jeśli jego ścieżka importu zawiera segment o nazwie internal. Takie pakiety są nazywane pakietami wewnętrznymi. Pakiet wewnętrzny może być importowany wyłącznie przez inny pakiet, któ©y znajduje się wewnątrz drzewa zakorzenionego w katalogu nadrzędnym w stosunku do katalogu internal. W przypadku poniższych pakietów pakiet net/http/internal/chunked może np. być importowany z net/http/httputil lub net/http, ale nie z net/url.

Odpytywanie pakietów

Narzędzie go list raportuje informacje o dostępnych pakietach. W najprostszej postaci go list sprawdza, czy pakiet jest obecny w obszarze roboczym, a jeśli tak, to wyświetla jego ścieżkę importu:

$ go list github.com/go-sql-driver/mysql

Argument dla polecenia go list może zawierać znak wieloznaczny "...", który dopasowuje dowolny podłańcuch znaków ścieżki importu pakietu. Możemy użyć go do enumeracji wszystkich pakietów w obszarze roboczym Go:

$ go list ...

Lub w obrębie określonego poddrzewa:

$ go list code/r03/...

Polecenie go list uzyskuje pełne metadane dla każdego pakietu, a nie tylko ścieżkę importu, i udostępnia te informacje użytkownikom lub innym narzędziom w różnych formatach. Flaga -json powoduje, że polecenie go list wyświetla cały rekord każdego pakietu w formacie JSON:

$ go list -json hash

Flaga -f pozwala użytkownikom dostosowywać format wyjściowy przy użyciu języka szablonów pakietu text/template. Poniższe polecenie wypisuje przechodnie zależności pakietu strconv oddzielone spacjami:

$ go list -f '{{join .Deps " "}}' strconv

Testowanie

Podejście do testowania w języku Go może się wydawać w porównaniu z innymi językami mało zaawansowane technicznie. Opiera się na jednym poleceniu go test oraz zestawie konwencji dotyczących pisania funkcji testowych, które mogą być uruchamiane za pomocą tego polecenia.

Narzędzie go test

Kluczem do dobrego testu jest rozpoczęcie od implementacji konkretnego wymaganego zachowania i dopiero wtedy użycie funkcji w celu uproszczenia kodu i wyeliminowania powtórzeń. Najlepsze wyniki rzadko uzyskuje się, zaczynając od bibliotek abstrakcyjnych, ogólnych funkcji testujących.

Test, który fałszywie zawodzi, gdy w programie zostanie wprowadzona jakaś rozsądna zmiana, nazywa się kruchym (ang. brittle). Podobnie jak zapluskwiony program frustruje użytkowników, kruchy test drażni jego opiekunów. Najbardziej kruche testy, które zawodzą dla niemal każdej zmiany w kodzie produkcyjnym (dobrej czy złej), są czasami nazywane testami wykrywania zmian lub testami status quo, a poświęcony na nie czas może zniwelować wszelkie korzyści, jakie wydawały się zapewniać.

Podpolecenie go test jest sterownikiem testów dla pakietów Go, które są zorganizowane zgodnie z określonymi konwencjami. W katalogu pakietu pliki o nazwach kończących się na _test.go nie są częścią pakietu skompilowanego w zwykły sposób za pomocą go build, ale są jego częścią, gdy pakiet zostanie skompilowany za pomocą go test.

W plikach *_test.go szczególnie traktowane są trzy rodzaje funkcji: testy, benchmarki i przykłady. Funkcja testująca, która jest funkcją o nazwie zaczynającej się od słowa Test, sprawdza logikę programu pod kątem prawidłowego zachowania. Podpolecenie go test wywołuje funkcję testującą i podaje wynik, którym jest PASS (wynik pozytywny) lub FAIL (wynik negatywny). Funkcja benchmarkująca ma nazwę rozpoczynającą się od słowa Benchmark i mierzy wydajność pewnej operacji. Podpolecenie go test raportuje średni czas wykonywania operacji. Natomiast funkcja przykładu, której nazwa rozpoczyna się od słowa Example, zapewnia dokumentację sprawdzoną maszynowo.

Funkcje testujące

Każdy plik testowy musi importować pakiet testing. Funkcje testowe mają następującą sygnaturę:

func TestNazwa( t *testing T) {
  // ...
}

Parametr t dostarcza metody do raportowania niepowodzeń testów i rejestrowania dodatkowych informacji.

Narzędzie go test skanuje pliki *_test.go pod kątem tych specjalnych funkcji, generuje tymczasowy pakiet main, który wywołuje je wszystkie w odpowiedni sposób, kompiluje i uruchamia go, podaje wyniki, a następnie czyści. Wywołane bez podania pakietów jako argumentów operuje na pakiecie znajdującym się w bieżącym katalogu. Flaga -v wyświetla nazwę i czas wykonywania każdego testu w zestawie. Natomiast flaga -run, której argumentem jest wyrażenie regularne, powoduje, że go test uruchamia tylko te testy, których nazwa funkcji odpowiada danemu wzorcowi:

$ go test -v -run="French|Canal"

W razie niepowodzenia wołamy t.Error lub t.Errorf. Gdy naprawdę musimy zatrzymać funkcję testującą (np. z powodu niepowodzenia jakiegoś kodu inicjującego albo żeby zapobiec wywołaniu przez już zgłoszone niepowodzenie mylącej kaskady innych niepowodzeń), używamy t.Fatal lub t.Fatalf. Te funkcje muszą być wywołane z tej samej goroutine co funkcja Test, a nie z innej utworzonej podczas testu.

Komunikaty o negatywnych wynikach testów mają zwykle postać "f(x) = y, oczekiwane z", gdzie f(x) opisuje operację, której próba wykonania jest podejmowana, oraz jej dane wejściowe, y jest rzeczywistym wynikiem, a z jest wynikiem oczekiwanym.

Zewnętrzne pakiety testowe

Czasami test z pakietu niższego poziomu importuje pakiet wyższego poziomu. Powoduje to utworzenie cyklu w grafie zależności między pakietami i błąd kompilacji. Rozwiązujemy ten problem, deklarując funkcję testującą w zewnętrznym pakiecie testowym, czyli w umieszczonym w katalogu net/url pliku, którego deklaracją pakietu jest package name_test. Przyrostek _test jest sygnałem dla polecenia go test, że powinno skompilować dodatkowy pakiet zawierający tylko te pliki i uruchomić jego testy.

Dzięki unikaniu cykli importów zewnętrzne pakiety testowe pozwalają testom, a zwłaszcza testom integracyjnym (które testują interakcję kilku komponentów), swobodnie importować inne pakiety, dokładnie tak, jak zrobiłaby aplikacja.

Czasami zewnętrzny pakiet testowy może potrzebować uprzywilejowanego dostępu do wewnętrznych funkcjonalności testowanego pakietu, jeśli np. test strukturalny musi się mieścić w osobnym pakiecie, aby uniknąć cykli importów. W takich przypadkach używamy pewnej sztuczki: dodajemy deklaracje do należącego do pakietu pliku _test.go, aby udostępnić wewnętrzne funkcjonalności zewnętrznemu testowi. Ten plik oferuje zatem testowi "tylne drzwi" do pakietu. Jeśli plik źródłowy istnieje tylko w tym celu i sam nie zawiera żadnych testów, często jest nazywany export_test.go.

Pokrycie

Uruchomienie testu z flagą -coverprofile umożliwia gromadzenie danych pokrycia instrukcji poprzez instrumentację kodu produkcyjnego, tzn. modyfikację kopii kodu źródłowego w taki sposób, że przed wykonaniem każdego bloku instrukcji ustawiana jest zmienna logiczna (jedna zmienna na blok). Tuż przed zakończeniem zmodyfikowanego programu wartość każdej zmiennej jest zapisywana w określonym pliku dziennika c.out i wyświetlane jest podsumowanie procentowego udziału instrukcji, które zostały wykonane. (Jeśli potrzebujesz tylko tego podsumowania, użyj polecenia go test -cover).

Jeśli zostanie uruchomione polecenie go test z flagą -covermode=count, instrumentacja będzie dla każdego bloku inkrementować licznik, zamiast ustawiać wartość logiczną. Powstały dziennik liczb wykonań każdego bloku umożliwia dokonywanie ilościowych porównań pomiędzy blokami "gorętszymi" (które są częściej wykonywane) a "chłodniejszymi".

Po zebraniu danych możemy uruchomić narzędzie cover, które przetwarza dziennik, generuje raport HTML i otwiera go w nowym oknie przeglądarki:

$ go tool cover -html=c.out

Testowanie jest zasadniczo dążeniem pragmatycznym, kompromisem pomiędzy kosztami pisania testów a kosztami awarii, którym można by zapobiec poprzez testy. Narzędzia pokrycia mogą pomóc określić najsłabsze punkty, ale opracowanie dobrych przypadków testowych wymaga tak samo rygorystycznego sposobu myślenia jak programowanie w ogóle.

Funkcje benchmarkujące

Benchmarkowanie jest praktyką mierzenia wydajności programu dla ustalonego obciążenia roboczego. W języku Go funkcja benchmarkująca wygląda jak funkcja testująca, ale z prefiksem Benchmark i parametrem *testing.B, który zapewnia większość tych samych metod co *testing.T plus kilka dodatkowych związanych z pomiarem wydajności. Udostępnia również pole N wartości całkowitej, która określa liczbę powtórzeń dla wykonywania mierzonej operacji.

Oto benchmark dla funkcji IsPalindrome, który wywołuje ją N razy w pętli:

import "testing"

func BenchmarkIsPalindrome(b *testing.B) {
  for i := 0; i < b.N; i++ {
    IsPalindrome( "A man, a plan, a canal: Panama" )
  }              
}

Argument flagi -bench określa, które benchmarki uruchomić (domyślnie nie są uruchamiane żadne benchmarki). Wzorzec "." powoduje dopasowanie wszystkich benchmarków w pakiecie word, ale ponieważ jest tylko jeden, równoważne byłoby wyrażenie -bench=IsPalindrome.

Ponieważ narzędzie uruchamiające benchmark nie ma pojęcia, jak długo trwa dana operacja, wykonuje wstępne pomiary za pomocą małych wartości N, a następnie ekstrapoluje do wartości wystarczająco dużej, aby dokonać stabilnego pomiaru czasu.

Najszybszy program to często ten, który wykonuje najmniej alokacji pamięci.

Profilowanie

Profil CPU identyfikuje funkcje, których wykonywanie zabiera najwięcej czasu procesora. Wątki uruchomione równolegle na każdym procesorze są cyklicznie przerywane przez system operacyjny co kilka milisekund, a każde przerwanie rejestruje jedno zdarzenie profilowe przed wznowieniem normalnego wykonywania.

Profil sterty identyfikuje instrukcje odpowiedzialne za alokowanie największej ilości pamięci. Biblioteka profilująca próbkuje wywołania wewnętrznych procedur alokacji pamięci w taki sposób, że średnio jedno zdarzenie profilowe jest rejestrowane dla każdych 512 kB przydzielonej pamięci.

Profil blokowania identyfikuje operacje odpowiedzialne za najdłuższe blokowanie funkcji goroutine, takie jak wywołania systemowe, wysyłanie i odbieranie poprzez kanał oraz zakładanie blokad. Biblioteka profilująca rejestruje zdarzenie za każdym razem, gdy jakaś funkcja goroutine zostaje zablokowana przez jedną z tych operacji.

Gromadzenie profilu dla testowanego kodu jest tak proste jak włączenie jednej z poniższych flag. Bądź jednak ostrożny, gdy będziesz używał więcej niż jednej flagi na raz: mechanizm odpowiedzialny za gromadzenie jednego profilu może wypaczyć wyniki innych.

$ go test -cpuprofile=cpu.out
$ go test -memprofile=mem.out
$ go test -blockprofile=block.out

Funkcje profilujące środowiska wykonawczego Go można włączyć pod kontrolą programisty za pomocą interfejsu API pakietu runtime.

Gdy już zgromadzimy profil, musimy przeanalizować go przy użyciu narzędzia prof. Jest ono standardową częścią dystrybucji Go, ale ponieważ nie jest codziennym narzędziem, dostęp do niego uzyskuje się pośrednio za pomocą polecenia go tool pprof. Jest ono standardową częścią dystrybucji Go, ale ponieważ nie jest codziennym narzędziem, dostęp do niego uzyskuje się pośrednio za pomocą polecenia go tool pprof. Posiada wiele funkcji i opcji, ale podstawowe zastosowanie wymaga tylko dwóch argumentów: pliku wykonywalnego, który wygenerował profil, oraz dziennika profilu.

Aby uczynić profilowanie efektywnym i zaoszczędzić przestrzeń, dziennik nie zawiera nazw funkcji. Zamiast tego funkcje są identyfikowane poprzez ich adresy. Oznacza to, że pprof potrzebuje pliku wykonywalnego, aby zrozumieć plik dziennika. Chociaż go test zwykle porzuca plik wykonywalny po zakończeniu testu, gdy profilowanie jest włączone, zapisuje plik wykonywalny jako foo.test, gdzie foo jest nazwą testowanego pakietu.

$ go tool pprof -text -nodecount=10 ./http.test cpu.log

Flaga -text określa format wyjściowy, w tym przypadku tabelę tekstową z jednym rzędem na każdą funkcję, posortowaną w kolejności od "najgorętszych" (zużywających najwięcej cykli CPU) do "najchłodniejszych". Flaga -nodecount=10 ogranicza wynik do 10 wierszy.

W przypadku bardziej subtelnych problemów lepsze może być użycie jednego z graficznych wyświetlaczy narzędzia pprof. Wymagają one oprogramowania Graphviz. Wtedy flaga -web renderuje skierowany graf funkcji programu, opatrzony adnotacjami wskazań profilu CPU i pokolorowany, aby wskazać najgorętsze funkcje.

Funkcje przykładów

Trzecim rodzajem funkcji traktowanym wyjątkowo przez narzędzie go test jest funkcja przykładu, której nazwa rozpoczyna się od słowa Example.

Funkcje przykładów służą trzem celom. Podstawowym z nich jest dokumentacja: dobry przykład może być bardziej zwięzłym lub intuicyjnym sposobem oddania zachowania funkcji biblioteki niż jej opisanie, zwłaszcza gdy zostanie użyty jako przypomnienie lub skrócona instrukcja. Przykład może również demonstrować interakcje pomiędzy kilkoma typami i funkcjami należącymi do jednego interfejsu API, podczas gdy dokumentacja musi być zawsze dołączona do jednego miejsca, takiego jak deklaracja typu funkcji albo pakietu jako całości. I, w przeciwieństwie do przykładów w komentarzach, funkcje przykładów są rzeczywistym kodem Go, podlegającym kontroli w czasie kompilacji, więc nie stają się nieaktualne wraz z ewoluowaniem kodu.

Na podstawie przyrostka funkcji Example serwer dokumentacji WWW godoc kojarzy funkcje przykładów z ilustrowaną funkcją lub ilustrowanym pakietem, więc funkcja ExampleIsPalindrome byłaby pokazana wraz z dokumentacją dla funkcji IsPalindrome, a funkcja przykładu, zwana po prostu Example, byłaby powiązana z całym pakietem.

Drugim celem jest to, że przykłady są wykonywalnymi testami uruchamianymi za pomocą polecenia go test. Jeśli funkcja przykładu zawiera końcowy komentarz //Output, sterownik testów wykona funkcję i sprawdzi czy to, co wypisała do standardowego strumienia wyjściowego, odpowiada tekstowi w komentarzu.

Refleksja

Refleksja pozwala traktować same typy jako typy pierwszoklasowe. Refleksja jest zapewniona przez pakiet reflect. Definiuje on dwa ważne typy: Type i Value. Type reprezentuje typ Go. Jest to interfejs z wieloma metodami do rozróżniania typów oraz sprawdzania ich komponentów, takich jak pola struktury lub parametry funkcji. Typ reflect.Value może przechowywać wartość dowolnego typu. Funkcja reflect.ValueOf przyjmuje dowolny interface{} i zwraca reflect.Value zawierający wartość dynamiczną tego interfejsu. Wywołanie metody Type na Value zwraca jego typ jako reflect.Type.

Dla refleksji widoczne są nawet nieeksportowane pola, ale nie pozwala ona na ich modyfikację.

Funkcje oparte na refleksji mogą być o rząd lub dwa rzędy wielkości wolniejsze niż kod wyspecjalizowany dla określonego typu. Testowanie szczególnie dobrze nadaje się do refleksji, ponieważ większość testów używa małych zbiorów danych. Jednak w przypadku funkcji na kluczowej ścieżce najlepiej jest refleksji unikać.

Programowanie niskiego poziomu

Na ogół wiele szczegółów implementacji jest niedostępnych dla programów Go, np. układ pamięci typu złożonego, takiego jak struktura, kod maszynowy dla funkcji lub tożsamość wątku systemu operacyjnego, w którym uruchomiona jest bieżąca funkcja goroutine. W rzeczywistości planista Go swobodnie przesuwa funkcje goroutine z jednego wątku do drugiego. Wskaźnik identyfikuje zmienną bez ujawniania adresu numerycznego tej zmiennej. Adresy mogą ulec zmianie wraz z przenoszeniem zmiennych przez mechanizm odzyskiwania pamięci. Wskaźniki są przejrzyście aktualizowane. Żadna z bieżących implementacji języka Go nie wykorzystuje przenoszącego mechanizmu odzyskiwania pamięci (choć przyszłe implementacje mogą to robić), ale to nie powód do samozadowolenia: aktualne wersje Go przenoszą w pamięci niektóre zmienne. Stosy funkcji goroutine rosną w miarę potrzeb. Gdy tak się dzieje, wszystkie zmienne ze starego stosu mogą zostać przeniesione do nowego, większego stosu, więc nie można polegać na tym, że wartość numeryczna adresu zmiennej będzie pozostawała niezmieniona przez cały czas jej życia.

Wszystkie te cechy sprawiają, że programy Go, szczególnie te wadliwe, stają się bardziej przewidywalne i mniej tajemnicze niż programy napisane w C, czyli podstawowym języku niskiego poziomu. Poprzez ukrywanie bazowych szczegółów czynią również programy Go wysoce przenośnymi, ponieważ semantyka języka jest w znacznym stopniu niezależna od jakiegokolwiek konkretnego kompilatora, systemu operacyjnego czy od jakiejkolwiek architektury procesora.

Od czasu do czasu możemy zrezygnować z niektórych z tych pomocnych gwarancji, aby osiągnąć najwyższą możliwą wydajność, zapewnić współdziałanie z bibliotekami napisanymi w innych językach lub zaimplementować funkcję, która nie może być wyrażona w czystym Go.

Pakiet unsafe pozwala nam wyjść poza zwyczajowe reguły, a narzędzie cgo tworzyć powiązania Go dla bibliotek C i wywołań systemu operacyjnego. Można skompilować program Go jako statyczne archiwum, które można zlinkować z programem C, lub jako współdzieloną bibliotekę, która może być dynamicznie ładowana przez program C.

Pakiet unsafe unieważnia gwarancję języka Go dotyczącą kompatybilności z przyszłymi wersjami, ponieważ (w sposób zamierzony lub nie) łatwo jest uzależnić się od niesprecyzowanych szczegółów implementacji, które mogą się nieoczekiwanie zmienić. Pakiet unsafe jest szeroko stosowany w ramach niskopoziomowych pakietów, takich jak: runtime, os, syscall i net, które współdziałają z systemem operacyjnym, ale prawie nigdy nie jest wymagany przez zwykłe programy.

Komputery najbardziej efektywnie ładują i przechowują wartości z pamięci, gdy te wartości są prawidłowo wyrównane. Z tego powodu rozmiar wartości typu złożonego (struktury lub tablicy) jest co najmniej sumą rozmiarów jego pól lub elementów, ale może być większy z powodu obecności "dziur". Dziury są niewykorzystywanymi spacjami dodawanymi przez kompilator, aby zapewnić, że następujące po nich pole lub element będą prawidłowo wyrównane względem początku struktury lub tablicy.

Literatura

Język Go. Poznaj i programuj, A.Donovan, B.Kernighan, Helion 2016