Skip to content
This repository has been archived by the owner on Mar 29, 2024. It is now read-only.

Latest commit

 

History

History
3071 lines (2297 loc) · 74.5 KB

style.md

File metadata and controls

3071 lines (2297 loc) · 74.5 KB

Uber Go Style Guide

Spis treści

Wstęp

Style to konwencje zarządzania naszym kodem. Termin "styl" jest tutaj nieco mylący, ponieważ opisane tu konwencje obejmują znacznie większy obszar niżeli tylko formatowanie plików źródłowych, które zresztą robi za nas gofmt.

Celem niniejszego poradnika jest ustrukturyzowanie tych kowencji poprzez szczegółowe opisanie rekomendacji i przeciwwskazań (Dos and Don'ts) stosowanych przy pisaniu kodu w Go w firmie Uber. Zasady te istnieją w celu zachowania sprawnego zarządzania bazą kodu przy jednoczesnym umożliwieniu inżynierom produktywnego korzystania z cech oraz funkcjonalności języka Go.

Poradnik został pierwotnie napisany przez Prashanta Varanasi oraz Simona Newtona jako sposób na wprowadzenie współpracowników w język Go. Przez lata był on stale modyfikowany i dopracowywany bazując na otrzymywanych informacjach zwrotnych.

Dokumentacja ta zawiera idiomatyczne konwencje dla kodu Go, przestrzegane w firmie Uber. Wiele z nich to oficjalne wytyczne dla Go podczas, gdy inne pochodzą z zewnętrznych źródeł takich jak:

  1. Efektywny Go (Effective Go)
  2. Przewodnik po typowych błędach Go (The Go common mistakes guide)

Cały zawarty kod powinien być wolny od błędów zgłaszanych przez polecenia golint oraz go vet. Zalecamy skonfigurowanie edytora tak by:

  • Uruchamiać goimports po każdym zapisie pliku
  • Uruchamiać golint oraz go vet w celu wykrycia potencjalnych błędów

Informacje na temat edytorów i ich wsparcia dla języka Go dostępne są pod linkiem: https://github.com/golang/go/wiki/IDEsAndTextEditorPlugins

Wskazówki

Wskaźniki typów interfejsowych

Praktycznie nigdy nie ma potrzeby używania wskaźników do interfejsów. Powinieneś przekazywać interfejs poprzez wartość, ponieważ w dalszym ciągu możliwe jest aby przekazywany obiekt był wskaźnikiem na dane.

Interfejs składa się z dwóch pól:

  1. Wskaźnik na pewne informacje specyficzne dla typu. Możesz myśleć o tym jako o "typie".
  2. Wskaźnik na dane. Jeśli wartością jest wskaźnik, jest on przechowywany bezpośrednio. W innym przypadku, gdy wartością są dane, przechowywany zostaje wskaźnik na te dane.

Jeśli więc chcesz, aby metody typu interfejsowego modyfikowały jego dane, musisz w nich używać wskaźnika.

Weryfikuj zgodność z interfejsem

W razie potrzeby weryfikuj zgodność z interfejsem w czasie kompilacji. Np. dla:

  • Wyeksportowanych typów, które są wymagane do wdrożenia określonych interfejsów w ramach kontraktu API
  • Wyeksportowanych jak i nieeksportowanych typów będących częscią grupy typów implementujących ten sam interfejs
  • Innych przypadków, w których naruszenie interfejsu spowodowałoby problemy po stronie użytkowników
ŹleDobrze
type Handler struct {
  // ...
}
func (h *Handler) ServeHTTP(
  w http.ResponseWriter,
  r *http.Request,
) {
  ...
}
type Handler struct {
  // ...
}
var _ http.Handler = (*Handler)(nil)
func (h *Handler) ServeHTTP(
  w http.ResponseWriter,
  r *http.Request,
) {
  // ...
}

Instrukcja var _ http.Handler = (*Handler)(nil) spowoduje błąd kompilacji jeżeli *Handler kiedykolwiek przestanie spełniać interfejs http.Handler.

Prawa strona przypisania powinna być wartością zerową testowanego (asserted) typu. Jest to nil dla wskaźników do typów (jak *Handler), wycinków oraz map oraz pusta instancja dla struktur.

type LogHandler struct {
  h   http.Handler
  log *zap.Logger
}
var _ http.Handler = LogHandler{}
func (h LogHandler) ServeHTTP(
  w http.ResponseWriter,
  r *http.Request,
) {
  // ...
}

Odbiorniki (Receivers) i Interfejsy

Metody przynależące do wartości odbiornika mogą być wywoływane zarówno na jego wskaźnikach jak i wartościach. Metody przynależące do wskaźnika odbiornika (pointer receiver) mogą być wywoływane jedynie na jego wskaźnika lub wartościach adresowalnych.

Na przykład:

type S struct {
  data string
}

func (s S) Read() string {
  return s.data
}

func (s *S) Write(str string) {
  s.data = str
}

sVals := map[int]S{1: {"A"}}

// Możesz wywoływać "Read" jedynie poprzez wartość
sVals[1].Read()

// Kompilacja tego kodu się nie powiedzie:
//  sVals[1].Write("test")

sPtrs := map[int]*S{1: {"A"}}

// Możesz wywoływać zarówno "Read" jak i "Write" jeśli użyjesz wskaźnika
sPtrs[1].Read()
sPtrs[1].Write("test")

Podobnie, interfejs może zostać spełniony poprzez wskaźnik, nawet jeśli odbiornikiem zadeklarowanej metody jest wartość, nie wskaźnik.

type F interface {
  f()
}
Typy błędów
func (s *S2) f() {}

s1Val := S1{}
s1Ptr := &S1{}
s2Val := S2{}
s2Ptr := &S2{}

var i F
i = s1Val
i = s1Ptr
i = s2Ptr

// Kompilacja poniższego kodu się nie powiedzie, ponieważ s2Val przechowyje wartość, a odbiornikiem dla f jest wskaźnik.
//   i = s2Val

Efektywny Go dobrze opisuje ten przypadek w rozdziale Pointers vs. Values.

Poprawność wartości zerowych Mutexów

Wartości zerowe dla typów sync.Mutex oraz sync.RWMutex są jak najbardziej poprawne, prawie nigdy nie ma potrzeby na używanie wskaźników na mutex.

ŹleDobrze
mu := new(sync.Mutex)
mu.Lock()
var mu sync.Mutex
mu.Lock()

Jeśli używasz struktury przez wskaźnik, zawarty w niej mutex nie musi być wskaźnikiem.

Struktury nieeksportowane wykorzystujące mutex do ochrony pól, mogą go zawierać (osadzać - embed).

type smap struct {
  sync.Mutex // tylko dla nieeksportowanych typów

  data map[string]string
}

func newSMap() *smap {
  return &smap{
    data: make(map[string]string),
  }
}

func (m *smap) Get(k string) string {
  m.Lock()
  defer m.Unlock()

  return m.data[k]
}
type SMap struct {
  mu sync.Mutex

  data map[string]string
}

func NewSMap() *SMap {
  return &SMap{
    data: make(map[string]string),
  }
}

func (m *SMap) Get(k string) string {
  m.mu.Lock()
  defer m.mu.Unlock()

  return m.data[k]
}
Osadź w prywatnych typach lub typach, które muszą implementować interfejs Mutex. Dla eksportowanych typów, użyj pól prywatnych.

Ograniczenia kopiowania wycinków i map

Ze względu na to że wycinki i mapy zawierają wskaźniki na przechowywane dane, należy uważać na scenariusze kiedy trzeba je skopiować.

Odbieranie wycinków i map

Pamiętaj, że użytkownicy mogą modyfikować zawartość map lub wycinków które uzyskałeś jako argument, gdy przechowywujesz do nich referencje.

Źle Dobrze
func (d *Driver) SetTrips(trips []Trip) {
  d.trips = trips
}

trips := ...
d1.SetTrips(trips)

// Czy chciałeś zmodyfikować pole d1.trips?
trips[0] = ...
func (d *Driver) SetTrips(trips []Trip) {
  d.trips = make([]Trip, len(trips))
  copy(d.trips, trips)
}

trips := ...
d1.SetTrips(trips)

// Możemy teraz modyfikować wartość trips[0] bez wpływania na d1.trips.
trips[0] = ...

Zwracanie wycinków i map

Podobnie do powyższego, bądź świadom potencjalnych modyfikacji map lub wycinków udostępniających stan wewnętrzny.

ŹleDobrze
type Stats struct {
  mu sync.Mutex
  counters map[string]int
}

// Snapshot zwraca aktualny status
func (s *Stats) Snapshot() map[string]int {
  s.mu.Lock()
  defer s.mu.Unlock()

  return s.counters
}

// snapshot nie jest już dłużej chroniony przez mutex,
// więc dowolna próba dostępu może stać się częścią wyścigu (data race).
snapshot := stats.Snapshot()
type Stats struct {
  mu sync.Mutex
  counters map[string]int
}

func (s *Stats) Snapshot() map[string]int {
  s.mu.Lock()
  defer s.mu.Unlock()

  result := make(map[string]int, len(s.counters))
  for k, v := range s.counters {
    result[k] = v
  }
  return result
}

// Snapshot to teraz kopia.
snapshot := stats.Snapshot()

Stosuj "defer" by opóźnić operacje czyszczenia (clean-up)

Użyj instrukcji "defer" do czyszczenia zasobów takich jak pliki czy blokady (locks).

ŹleDobrze
p.Lock()
if p.count < 10 {
  p.Unlock()
  return p.count
}

p.count++
newCount := p.count
p.Unlock()

return newCount

// łatwo o pominięcie odblokowywania
/// ze wzgledu na wiele wystąpień instrukcji "return"
p.Lock()
defer p.Unlock()

if p.count < 10 {
  return p.count
}

p.count++
return p.count

// bardziej czytelny

Defer ma niezwykle mały narzut i należy go unikać jedynie wtedy, gdy można udowodnić, że czas wykonania funkcji jest rzędu nanosekund. Korzyści w czytelności kodu wynikające z instrukcji defer są warte minimalnego kosztu za jej wykorzystanie. Powyższe stwierdzenie jest szczególnie prawdziwe dla większych metod operujących nie tylko na pamięci, w przypadku których koszt innych operacji znacząco przewyższa koszt instrukcji defer.

Kanały (channels) powinny mieć rozmiar 1 lub być niebuforowane

Domyślnie kanały są niebuforowane a ich rozmiar wynosi 0. Każdy inny rozmiar powinien podlegać ścisłej kontroli. Zastanów się, co wpływa na rozmiar kanału, co zapobiegnie jego zapełnianiu się pod wpływem obciążenia oraz blokowaniu zapisu i co jeśli taki scenariusz się wydarzy.

ŹleDobrze
// Naaaapewno dla wszystkich wystarczy!
c := make(chan int, 64)
// Rozmiar równy 1
c := make(chan int, 1) // lub
// Niebuforowany, o rozmiarze 0
c := make(chan int)

Wyliczanie rozpoczynaj od 1

Standardowym sposobem wprowadzenia wyliczeń (enums) w Go jest zadeklarowanie własnego typu oraz zgrupowanie const z użyciem iota. Ponieważ wartością domyślną zmiennych jest 0, dobrą praktyką jest rozpoczynianie wyliczania od wartości niezerowej.

ŹleDobrze
type Operation int

const (
  Add Operation = iota
  Subtract
  Multiply
)

// Add=0, Subtract=1, Multiply=2
type Operation int

const (
  Add Operation = iota + 1
  Subtract
  Multiply
)

// Add=1, Subtract=2, Multiply=3

Są oczywiście przypadki gdzie rozpoczynanie od zera ma sens, na przykład gdy wartość zerowa opisuje zachowanie domyślne.

type LogOutput int

const (
  LogToStdout LogOutput = iota
  LogToFile
  LogToRemote
)

// LogToStdout=0, LogToFile=1, LogToRemote=2

Używaj pakietu "time" do obsługi czasu

Czas to skomplikowany temat. Nieprawidłowe założenia często dotyczące „czasu” zakładają że:

  1. Dzień ma 24 godziny
  2. Godzina składa się z 60 minut
  3. Tydzień ma 7 dni
  4. Rok to 365 dni
  5. Oraz wiele wiele innych

Dla przykładu przypadek 1 pokazuje, że dodanie 24 godzin do pewnego punktu w czasie nie zagwarantuje otrzymania kolejnego dnia kalendarzowego.

Używaj time.Time dla punktów w czasie

Użyj time.Time gdy masz do czynienia z punktami w czasie. Metody time.Time wykorzystuj do porównywania, dodawania lub odejmowania czasu.

ŹleDobrze
func isActive(now, start, stop int) bool {
  return start <= now && now < stop
}
func isActive(now, start, stop time.Time) bool {
  return (start.Before(now) || start.Equal(now)) && now.Before(stop)
}

Używaj time.Duration dla przedziałów czasowych

Use time.Duration when dealing with periods of time.

ŹleDobrze
func poll(delay int) {
  for {
    // ...
    time.Sleep(time.Duration(delay) * time.Millisecond)
  }
}
poll(10) // czy to były sekundy czy milisekundy?
func poll(delay time.Duration) {
  for {
    // ...
    time.Sleep(delay)
  }
}
poll(10*time.Second)

Wracając do przykładu z dodawaniem 24 godzin to punktu w czasie, metoda której użyjemy do dodawania zależy od intencji. Jeżeli chcemy uzyskać tą samą porę, lecz następnego dnia, powinniśmy użyć Time.AddDate. Jednakże, jeżeli chcemy gwarancji że uzyskamy punkt w czasie przesunięty o 24 od podanego powinniśmy posłużyć się Time.Add.

newDay := t.AddDate(0 /* lata */, 0 /* miesiące */, 1 /* dni */)
maybeNewDay := t.Add(24 * time.Hour)

Używaj time.Time oraz time.Duration z systemami zewnętrznymi

Używaj time.Time i time.Duration w interakcjach z systemami zewnętrznymi gdy tylko to możliwe.

Na przykład:

Jeżeli nie jest możliwe aby we wspomnianych interakcjach użyć time.Duration, użyj int albo float64 oraz dołącz jednostkę do nazwy pola.

Na przykładkl, ponieważ encoding/json nie wpisra time.Duration, nazwa jednostki została zawarta w nazwie pola.

ŹleDobrze
// {"interval": 2}
type Config struct {
  Interval int `json:"interval"`
}
// {"intervalMillis": 2000}
type Config struct {
  IntervalMillis int `json:"intervalMillis"`
}

Jeżeli nie jest możliwe aby we wspomnianych interakcjach użyć time.Time, chyba że uzgodniono inaczej, użyj string i formatuj znaczniki czasu (timestamps) zgodnie z definicją zawartą w RFC 3339.

Format ten jest używany domyślnie przez Time.UnmarshalText i jest dostępny do użytku w Time.Format oraz time.Parse poprzez time.RFC3339.

Chociaż w praktyce nie stanowi to problemu, należy pamiętać, że Pakiet "time" nie obsługuje parsowania znaczników czasu (timestamps) z sekundami przestępnymi (8728), ani nie uwzględnia sekund przestępnych w obliczeniach (15190). Jeśli porównasz dwa punkty w czasie, różnica nie obejmie sekund przestępnych, które mogły wystąpić między tymi dwoma momentami.

Typy błędów

Istnieje kilka sposób deklarowania błędów:

  • errors.New dla błędów z prostymi, statycznymi ciągami znaków
  • fmt.Errorf dla błędów opartych o formatowalne łańcuchy znaków
  • Typy niestandardowe, które implementują metodę Error()
  • Błędy opakowane z użyciem "pkg/errors".Wrap

Podczas zwracania błędów, zadaj sobie poniższe pytania w celu wybrania opcji, która najlepiej odpowiada twoim potrzebom:

  • Czy chce zwrócić prosty błąd bez żadnych dodatkowych informacji? Jeśli tak, errors.New powinno być wystarczające.
  • Czy klient (twojej funkcji) powinien móc rozpoznać oraz obsłużyć ten błąd? Jeśli tak, powinieneś zastosować własny typ i zaimplementować w nim metodę Error()
  • Czy jedynie propagujesz błąd zwracany przez inną funkcje? Jeśli tak, zobacz sekcję na temat opakowywania błędów.
  • W pozostałych przypadkach dobrym pomysłem jest użycie fmt.Errorf.

Jeśli klient (twojej funkcji) potrzebuje możliwości wykrycia błędu, ale stworzyłeś błąd przy użyciu errors.New, wyeksportuj błąd do oddzielnej zmiennej.

ŹleDobrze
// pakiet foo

func Open() error {
  return errors.New("could not open")
}

// pakiet bar

func use() {
  if err := foo.Open(); err != nil {
    if err.Error() == "could not open" {
      // kod obsługi błędu
    } else {
      panic("unknown error")
    }
  }
}
// pakiet foo

var ErrCouldNotOpen = errors.New("could not open")

func Open() error {
  return ErrCouldNotOpen
}

// pakiet bar

if err := foo.Open(); err != nil {
  if err == foo.ErrCouldNotOpen {
    // kod obsługi błędu
  } else {
    panic("unknown error")
  }
}

Jeśli chcesz by twój błąd był wykrywalny przez klienta i chciałbyś dodać do niego więcej szczegółów (nie tylko pojedynczy łańcuch znaków), powinieneś utworzyć własny typ błędu.

ŹleDobrze
func open(file string) error {
  return fmt.Errorf("file %q not found", file)
}

func use() {
  if err := open("testfile.txt"); err != nil {
    if strings.Contains(err.Error(), "not found") {
      // kod obsługi błędu
    } else {
      panic("unknown error")
    }
  }
}
type errNotFound struct {
  file string
}

func (e errNotFound) Error() string {
  return fmt.Sprintf("file %q not found", e.file)
}

func open(file string) error {
  return errNotFound{file: file}
}

func use() {
  if err := open("testfile.txt"); err != nil {
    if _, ok := err.(errNotFound); ok {
      // kod obsługi błędu
    } else {
      panic("unknown error")
    }
  }
}

Uważaj z bezpośrednim eksportowaniem własnych typów błędów ponieważ stają sie one częścią publicznego API twojego pakietu. Zdecydowanie bardziej zalecane jest wystawienie funkcji które pozwolą na wykrywanie tego typu błędów (matcher functions).

// pakiet foo

type errNotFound struct {
  file string
}

func (e errNotFound) Error() string {
  return fmt.Sprintf("file %q not found", e.file)
}

func IsNotFoundError(err error) bool {
  _, ok := err.(errNotFound)
  return ok
}

func Open(file string) error {
  return errNotFound{file: file}
}

// pakiet bar

if err := foo.Open("foo"); err != nil {
  if foo.IsNotFoundError(err) {
    // kod obsługi błędu
  } else {
    panic("unknown error")
  }
}

Opakowywanie błędów (Error wrapping)

Istnieją trzy główne sposoby propagacji błędów w przypadku błędu wywołania funkcji:

  • Zwróć oryginalny błąd jeżeli nie ma potrzeby na dodawanie żadnych dodatkowych informacji o kontekście lub gdy zależy ci na zachowaniu oryginalnego typu błędu.
  • Dodawaj kontekst przy użyciu "pkg/errors".Wrap dzięki czemu komunikat błędu będzie zawierać więcej przydatnych informacji oraz "pkg/errors".Cause aby wyekstrahować oryginalną część zapakowanego błędu.
  • Używaj fmt.Errorf w przypadkach, w których wiesz że dla kodu wywołującego (caller) twój kod nie zaistnieje potrzeba wykrywania oraz obsługi tego konkretnego przypadku (w przypadkach gdy wystarczy informacja że coś poszło nie tak).

Zalecane jest by dodawać kontekst tam gdzie to tylko możliwe aby zamiast niejasnych komunikatów błędów takich jak "connection refused", móc dostać przydatne informacje o kontekście wywołania takie jak w przypadku "call service foo: connection refused".

Dodając kontekst do zwracanych błędów, utrzymuj zwięzły zestaw informacji o kontekście poprzez unikanie zwrotów takich jak "failed to", które opisują rzeczy oczywiste i jedynie gromadzą się na kolejnych poziomach propagacji błędu zwiększając tym samym obiętość wynikowego komunikatu.

ŹleDobrze
s, err := store.New()
if err != nil {
    return fmt.Errorf(
        "failed to create new store: %s", err)
}
s, err := store.New()
if err != nil {
    return fmt.Errorf(
        "new store: %s", err)
}
failed to x: failed to y: failed to create new store: the error
x: y: new store: the error

Jednakże, w przypadkach gdy błąd ma trafić do innego systemu, informacja o tym że komunikat dotyczy błędu powinna byc zawarta w wiadomości (np. poprzez tag err lub prefix "Failed" w logach).

Zobacz również Don't just check errors, handle them gracefully.

Obsługa błędów asercji typów

Forma oparta na pojedynczej wartości zwracanej z asercji typu wywoła panikę przy podaniu nieprawidłowego typu. Dlatego staraj się wykorzystywać formę zawierającą 2 wartości zwracane z użyciem idiomu "comma ok".

ŹleDobrze
t := i.(string)
t, ok := i.(string)
if !ok {
  // kod obsługi błędu.
}

Nie "panikuj"

Kod działający produkcyjnie musi unikać paniki. Instrukcje "panic" są głównym źródłem tzw. cascading failures "awarii kaskadowych". Jeśli błąd wystąpi, funkcja powinna go zwróćić i tym samym umożliwić wywołującemu zdecydowanie o ścieżce jego dalszej obsługi.

ŹleDobrze
func run(args []string) {
  if len(args) == 0 {
    panic("an argument is required")
  }
  // ...
}

func main() {
  run(os.Args[1:])
}
func run(args []string) error {
  if len(args) == 0 {
    return errors.New("an argument is required")
  }
  // ...
  return nil
}

func main() {
  if err := run(os.Args[1:]); err != nil {
    fmt.Fprintln(os.Stderr, err)
    os.Exit(1)
  }
}

Panic/recover to nie strategią obsługi błędów. Program panikuje dopiero wtedy gdy błąd wynika z sytuacji nie do naprawienia takiej jak np. dereferencja wartości nil. Wyjatek od reguły stanowi tutaj etap inicjalizacji programu: błędy w procesie uruchamiania programu, które powinny przerwać program, powinny wywołać panikę.

var _statusTemplate = template.Must(template.New("name").Parse("_statusHTML"))

Nawet w testach, preferuj t.Fatal lub t.FailNow zamiast "panic" by upewnić się że test zostanie oznaczony jako nieudany.

ŹleDobrze
// func TestFoo(t *testing.T)

f, err := ioutil.TempFile("", "test")
if err != nil {
  panic("failed to set up test")
}
// func TestFoo(t *testing.T)

f, err := ioutil.TempFile("", "test")
if err != nil {
  t.Fatal("failed to set up test")
}

Używaj go.uber.org/atomic

Operacje atomowe przeprowadzane z użyciem pakietu sync/atomic operują na surowych typach danych (int32, int64, itp.) a więc łatwo jest zapomnieć o użyciu operacji atomowej do odczytu lub modyfikacji zmiennych.

go.uber.org/atomic zwiększa bezpieczeństwo tych operacji, ukrywając typ podstawowy. Dodatkowo zawiera wygodny typ atom.Bool.

ŹleDobrze
type foo struct {
  running int32  // typ atomowy
}

func (f* foo) start() {
  if atomic.SwapInt32(&f.running, 1) == 1 {
     // juz działa...
     return
  }
  // rozpocznij foo
}

func (f *foo) isRunning() bool {
  return f.running == 1  // wyścig!
}
type foo struct {
  running atomic.Bool
}

func (f *foo) start() {
  if f.running.Swap(true) {
     // już działa…
     return
  }
  // rozpocznij foo
}

func (f *foo) isRunning() bool {
  return f.running.Load()
}

Unikaj mutowalnych zmiennych globalnych

Unikaj mutowania zmiennych globalnych, zamiast tego stosuj wstrzykiwanie zależności (dependency injection). Dotyczy to zarówno wskaźników funkcji jak i innych rodzajów wartości.

ŹleDobrze
// sign.go

var _timeNow = time.Now

func sign(msg string) string {
  now := _timeNow()
  return signWithTime(msg, now)
}
// sign.go

type signer struct {
  now func() time.Time
}

func newSigner() *signer {
  return &signer{
    now: time.Now,
  }
}

func (s *signer) Sign(msg string) string {
  now := s.now()
  return signWithTime(msg, now)
}
// sign_test.go

func TestSign(t *testing.T) {
  oldTimeNow := _timeNow
  _timeNow = func() time.Time {
    return someFixedTime
  }
  defer func() { _timeNow = oldTimeNow }()

  assert.Equal(t, want, sign(give))
}
// sign_test.go

func TestSigner(t *testing.T) {
  s := newSigner()
  s.now = func() time.Time {
    return someFixedTime
  }

  assert.Equal(t, want, s.Sign(give))
}

Unikaj osadzania typów (type embedding) w strukturach publicznych

Typy osadzane w strukturach publicznych powodują wyciekanie szczegółów o implementacji, ograniczają ewolucję typów oraz negatywnie wpływają na czytelność dokumentacji (zaciemniają ją).

Zakładając, że zaimplementowałeś różne typy list przy użyciu współdzielonego AbstractList, unikaj osadzania AbstractList w twoich konretnych implementacjach list. Zamiast tego dodaj do swojej konkretnej listy metody oddelegowujące zadanie do metod abstrakcyjnego typu listy AbstractList.

type AbstractList struct {}
// Add dodaje element do listy.
func (l *AbstractList) Add(e Entity) {
  // ...
}
// Remove usuwa element z listy.
func (l *AbstractList) Remove(e Entity) {
  // ...
}
ŹleDobrze
// ConcreteList stanowi listę elementów.
type ConcreteList struct {
  *AbstractList
}
// ConcreteList stanowi listę elementów.
type ConcreteList struct {
  list *AbstractList
}
// Add dodaje element do listy.
func (l *ConcreteList) Add(e Entity) {
  l.list.Add(e)
}
// Remove usuwa element z listy.
func (l *ConcreteList) Remove(e Entity) {
  l.list.Remove(e)
}

Go pozwala na osadzanie typów (type embedding) w ramach kompromisu między dziedziczeniem a kompozycją. Typ zewnętrzny otrzymuje niejawne kopie metod typu osadzonego. Metody te domyślnie oddelegowują zadanie do metod osadzonej instacji.

W procesie osadzania struktura zyskuje pole o tej samej nazwie co osadzony w niej typ. Zatem jeśli typ osadzony jest publiczny, pole również będzie publiczne. W celu zachowania zgodności wstecznej, każda przyszła wersja typu zewnętrznego będzie musiała zachować typ osadzony.

Osadzony typ jest rzadko potrzebny. To głównie wygodny sposób na uniknięcie żmudnego pisania metod oddelegowujących.

Nawet osadzenie kompatybilnego interfejsu AbstractList zamiast struktury, zapewniłby deweloperowi większą elastyczność w zakresie zmian w przyszłości, nadal jednak powodowałby wyciek informacji o tym że konkretna implementacja listy wykorzystuje implementację abstrakcyjną.

ŹleDobrze
// AbstractList stanowi ogólną implementację
// dla kilku typów list.
type AbstractList interface {
  Add(Entity)
  Remove(Entity)
}
// ConcreteList stanowi listę elementów.
type ConcreteList struct {
  AbstractList
}
// ConcreteList stanowi listę elementów.
type ConcreteList struct {
  list AbstractList
}
// Add dodaje element do listy.
func (l *ConcreteList) Add(e Entity) {
  l.list.Add(e)
}
// Remove usuwa element z listy.
func (l *ConcreteList) Remove(e Entity) {
  l.list.Remove(e)
}

Zarówno w przypadku osadzania struktur jak i interfejsów, osadzanie typów ogranicza ewolucję typu.

  • Dodawanie metod do interfejsu osadzonego łamie kompatybilność.
  • Usuwanie metod z osadzanych struktur łamie kompatybilność.
  • Usuwanie typu osadzonego łamie kompatybilność.
  • Wymiana typu osadzonego, nawet jeżeli zamiennik spełnia ten same interfejs, łamie kompatybilność.

[przyp. tłum. Wspomniane wyżej "łamanie kompatybilności" odnosi się do tzw. "breaking change"]

Chociaż pisanie tych dodatkowych metod delegujących jest uciążliwe, dodatkowy wysiłek pozwala na ukrycie szczegółów implementacyjnych, pozostawia więcej miejsca na zmiany, a takze eliminuje pośrednie ujawnianie pełnego interfejsu listy w dokumentacji.

Unikaj używania nazw elementów wbudowanych (built-in names)

Specyfikacja języka Go określa zestaw wbudowanych, predefiniowanych identyfikatorów, których nie należy używać jako nazw w programach Go.

W zależności od kontekstu, ponowne użycie tych identyfikatorów jako nazw spowoduje zaciemnienie oryginału wewnątrz bieżącego zakresu leksyklanego (i wszystkich zagnieżdzonych w nim zakresów), lub sprawi że kod zacznie wprowadzać czytelnika w błąd. W najlepszym przypadku można spodziewać sie ostrzeżeń ze strony kompilatora, w najgorszym, taki kod może wprowadzać ukryte, trudne do wyłuskania błędy.

ŹleDobrze
var error string
// zmienna `error` zaciemnia typ wbudowany
// lub
func handleErrorMessage(error string) {
    // parametr `error` zaciemnia typ wbudowany
}
var errorMessage string
// `error` dalej odwołuje się do typu wbudowanego
// lub
func handleErrorMessage(msg string) {
    // `error` dalej odwołuje się do typu wbudowanego
}
type Foo struct {
    // Mimo że te pola technicznie nie
    // niczego nie zaciemniającego,
    // wyszukiwanie w tekście pod kątem
    // fraz takich jak `error` lub `string`
    // daje niejednoznaczne wyniki.
    error  error
    string string
}
func (f Foo) Error() error {
    // `error` oraz `f.error` 
    // są wizualnie pobodne
    return f.error
}
func (f Foo) String() string {
    // `string` oraz `f.string`
    // są wizualnie podobne
    return f.string
}
type Foo struct {
    // frazy `error` oraz `string`
    // są teraz jednoznaczne.
    err error
    str string
}
func (f Foo) Error() error {
    return f.err
}
func (f Foo) String() string {
    return f.str
}

Warto pamiętać że kompilator nie będzie generował błędów podczas korzystania z wcześniej zadeklarowanych identyfikatorów, ale narzędzia takie jak go vet powinny poprawnie wskazywać takie jak i inne przypadki zaciemniania nazw.

Unikaj init()

Unikaj funkcji init () tam, gdzie to możliwe. Gdy wykorzystanie init () jest nieuniknione lub wysoce pożądane, twój kod powinien:

  1. Być całkowicie deterministyczny i niezależny od środowiska czy warunków wywołania programu.
  2. Unikać zależności od kolejności lub skutków ubocznych (side-effects) iunnych funkcji init(). Podczas gdy porządek kolejnośc wywołania funkcji init() jest dobrze znana, kod może się zmienić a razem z nim zmianie mogą ulec zawarte w nim relacje.
  3. Unikaj manipulowania lub uzyskiwania dostępu do stanu globalnego czy stanu środowiska w tym informacji takich jak informacje o maszynie, zmienne środowiskowe, ścieżka do katalogu roboczego (working directory), argumenty wywoływania czy dane wejściowe programu itp.
  4. Unikaj operacji wejścia/wyjścia (I/O - input/output) włączając w to operacje na systemie plików, sieci oraz wywołaniach systemowych (system calls)

Kod, który nie jest w stanie spełnić powyższych wymagań jest, z dużym prawdopodobieństwem, kodem pomocniczym, który powinien zostać wywołany jako krok funkcji main() (lub też w innej części cyklu życia programu), lub powinien zostać zaimplementowany jako część samej funkcji main(). W szczególności biblioteki, tworzone z myślą o wykorzystaniu przez inne programy powinny zachować szczególną ostrożność, aby być całkowicie deterministyczny i nie wykonujący "magii podczas inicjalizacji”.

ŹleDobrze
type Foo struct {
    // ...
}
var _defaultFoo Foo
func init() {
    _defaultFoo = Foo{
        // ...
    }
}
var _defaultFoo = Foo{
    // ...
}
// lub lepiej, dla testowalności:
var _defaultFoo = defaultFoo()
func defaultFoo() Foo {
    return Foo{
        // ...
    }
}
type Config struct {
    // ...
}
var _config Config
func init() {
    // Źle: bazuje na ścieżce katalogu roboczego
    cwd, _ := os.Getwd()
    // Źle: operacje wyjścia/wejścia (I/O)
    raw, _ := ioutil.ReadFile(
        path.Join(cwd, "config", "config.yaml"),
    )
    yaml.Unmarshal(raw, &_config)
}
type Config struct {
    // ...
}
func loadConfig() Config {
    cwd, err := os.Getwd()
    // obsługa err
    raw, err := ioutil.ReadFile(
        path.Join(cwd, "config", "config.yaml"),
    )
    // obsługa err
    var config Config
    yaml.Unmarshal(raw, &config)
    return config
}

Biorąc pod uwagę powyższe, niektóre sytuacje, w których init () może być preferowane lub niezbędne mogą obejmować:

  • Złożone wyrażenia, których nie można przedstawić jako pojedyncze przypisania.
  • "Pluggable hooks", takie jak dialekty database/sql, rejestry typów kodowania (encoding type registries) itp.
  • Optymalizacje do Google Cloud Functions i innych form deterministycznych obliczeń wstępnych.

Wydajność

Wytyczne dotyczące wydajności odnoszą się tylko to tzw. "hot paths" - ścieżek wykonywania kodu, w których spędzana jest większość czasu wykonywania i które mogą być wykonywane bardzo często.

Preferuj strconv ponad fmt

Podczas konwersji typów prymitywnych z/do typu string, strconv jest szybsze od fmt.

ŹleDobrze
for i := 0; i < b.N; i++ {
  s := fmt.Sprint(rand.Int())
}
for i := 0; i < b.N; i++ {
  s := strconv.Itoa(rand.Int())
}
BenchmarkFmtSprint-4    143 ns/op    2 allocs/op
BenchmarkStrconv-4    64.2 ns/op    1 allocs/op

Unikaj konwersji string-to-byte

Nie powtarzaj konwersji łańcuchów znakowych na wycinki bajtowe. Zamiast tego, przeprowadź operacje konwersji raz i zachowaj wynik.

ŹleDobrze
for i := 0; i < b.N; i++ {
  w.Write([]byte("Hello world"))
}
data := []byte("Hello world")
for i := 0; i < b.N; i++ {
  w.Write(data)
}
BenchmarkBad-4   50000000   22.2 ns/op
BenchmarkGood-4  500000000   3.25 ns/op

Preferuj określenie pojemności kontenerów

W miarę możliwości określaj pojemność kontenera aby z góry przydzielić odpowiednią ilość pamięci. Operacja ta pomoże zminimalizować kolejne przydziały pamięci (spowodowane przez kopiowanie oraz zwiększanie kontenera) w miarę dodawania nowych elementów.

Określaj wskazówki dotyczące pojemności map

Gdy to możliwe, umieszczaj wskazówki (hints) dotyczące pojemności mapy inicjalizowanej za pomocą funkcji make().

make(map[T1]T2, hint)

Zapewnienie informacji o pojemności funkcji make() sprawia że funkcja spróbuje dopasować rozmiar mapy w czasie jej inicjalizacji, co skutkuje redukcją czasu potrzebnego na zwiększenie mapy i alokacje pamięci podczas dodawania nowych elementów.

Pamiętaj jednak że w przeciwieństwie do wycinków, mapy nie gwarantują obsługi wskazanej pojemności, która służy jedynie do przybliżenia ilości potrzebnych "wiaderek" (hashmap buckets). Tak więc dodawanie kolejnych elementów może nadal wymagać alokacji pamięci nawet w przypadku podania wskazówki na temat pojemności mapy.

ŹleDobrze
m := make(map[string]os.FileInfo)

files, _ := ioutil.ReadDir("./files")
for _, f := range files {
    m[f.Name()] = f
}
files, _ := ioutil.ReadDir("./files")

m := make(map[string]os.FileInfo, len(files))
for _, f := range files {
    m[f.Name()] = f
}

mapa m stworzona bez wskazania pojemności; Może wystąpić więcej alokacji podczas operacji przypisania.

mapa m stworzona ze wskazaniem pojemności; Może wystąpić mniej alokacji podczas operacji przypisywania.

Określaj pojemność wycinków

Tam, gdzie to możliwe, podaj wskazówki dotyczące pojemności podczas inicjowania wycinków za pomocą funkcji make(), szczególnie przy okazji operacji dodawania elementów (appending).

make([]T, length, capacity)

Inaczej niż w przypadku map, pojemność wycinków nie stanowi jedynie wskazówki: kompilator przydzieli wycinkowi taką ilość pamięci, która będzie odpowiadała pojemności (capacity) jaka zostanie dostarczona funkcji make(), co oznacza że kolejne operacje append() nie będą pociągały za sobą operacji alokacji pamięci (do momentu gdy długość wycinka zrówna się z jego pojemnością, co spowoduje że kolejna operacja dodania będzie wymagała zmiany rozmiaru wycinka w celu przechowywania nowych elementów).

ŹleDobrze
for n := 0; n < b.N; n++ {
  data := make([]int, 0)
  for k := 0; k < size; k++{
    data = append(data, k)
  }
}
for n := 0; n < b.N; n++ {
  data := make([]int, 0, size)
  for k := 0; k < size; k++{
    data = append(data, k)
  }
}
BenchmarkBad-4    100000000    2.48s
BenchmarkGood-4   100000000    0.21s

Styl

Spójność ponad wszystko

Niektóre wytyczne przedstawione w tym dokumencie można ocenić obiektywnie; inne zaś są sytuacyjne, kontekstowe lub subiektywne.

Ponad wszystko inne, zachowaj spójność.

Spójny kod łatwiej się utrzymuje oraz racjonalizuje. Stawia on mniejszą barierę poznawczą oraz pozwala na prostrze migrowanie czy aktualizowanie wraz z pojawianiem się nowych konwencji lub w celu przeprowadzenia operacji naprawczych.

Z drugiej strony, baza kodu zawierająca wiele różnych, potencjalnie sprzecznych styli powoduje dysonans poznawczy, zwiększa narzut związany z utrzymaniem oraz przyczynia się do wzrostu ogólnej niepewności w projekcie. Wszystko to może bezpośrednio przyczynić się do zmniejszenia prędkości pracy zespołu (velocity), bolesnego procesu review kodu oraz powstawania błędów.

Podczas stosowania tych wytycznych w swojej bazie kodu zaleca się wprowadzanie zmian na poziomie całego pakietu (lub większym): stosowanie ich na poziomie pojedynczego "sup-package" narusza wyżej wymienione obawy poprzez wprowadzenie wielu różnych styli w tą samą bazę kodu.

Grupuj podobne deklaracje

Go wspiera grupowanie podobnych deklaracji.

ŹleDobrze
import "a"
import "b"
import (
  "a"
  "b"
)

Reguła ta ma również zastosowanie dla stałych, zmiennych i deklaracji typów.

ŹleDobrze
const a = 1
const b = 2



var a = 1
var b = 2



type Area float64
type Volume float64
const (
  a = 1
  b = 2
)

var (
  a = 1
  b = 2
)

type (
  Area float64
  Volume float64
)

Grupuj jedynie związane ze sobą deklaracje. Nie grupuj deklaracji które nie mają nic wspólnego.

ŹleDobrze
type Operation int

const (
  Add Operation = iota + 1
  Subtract
  Multiply
  ENV_VAR = "MY_ENV"
)
type Operation int

const (
  Add Operation = iota + 1
  Subtract
  Multiply
)

const ENV_VAR = "MY_ENV"

Grupy nie posiadają ograniczeń pod kątem miejsca użycia. Możesz użyć ich naprzykład wewnątrz ciała funkcji.

ŹleDobrze
func f() string {
  var red = color.New(0xff0000)
  var green = color.New(0x00ff00)
  var blue = color.New(0x0000ff)

  ...
}
func f() string {
  var (
    red   = color.New(0xff0000)
    green = color.New(0x00ff00)
    blue  = color.New(0x0000ff)
  )

  ...
}

Kolejność importowania bibliotek

Instrukcje importowania powinny być podzielone na dwie grupy:

  • Biblioteka standardowa
  • Cała reszta

Jest to domyślne grupowanie stosowane przez goimports.

ŹleDobrze
import (
  "fmt"
  "os"
  "go.uber.org/atomic"
  "golang.org/x/sync/errgroup"
)
import (
  "fmt"
  "os"

  "go.uber.org/atomic"
  "golang.org/x/sync/errgroup"
)

Nazwy pakietów

Podczas nazywania pakietów, wybiera nazwy które:

  • Posiadają jedynie małe litery. Bez wielkich liter czy podkreśleń (_).
  • Nie wymagają zmiany nazwy poprzez "named imports" w większości miejsc wykorzystania.
  • Są krótkie i zwięzłe. Pamiętaj że każde miejsce wywołania będzie musiało korzystać z pełnej nazwy pakietu.
  • Nie zwierają liczby mnogiej. Na przykład net/url zamiast net/urls.
  • Nie należą do nazw jak "common", "util", "shared" czy "lib". Są to złe, nieinformacyjne nazwy.

Zobacz również Package Names oraz Style guideline for Go packages.

Nazwy funkcji

Przestrzegamy konwencji społeczności Go używającej MixedCaps for function names. Wyjątkiem są funkcje testowe, które mogą zawierać podkreślenia w celu grupowania powiązanych przypadków testowych np. TestMyFunction_WhatIsBeingTested.

Aliasy importowanych bibliotek

Aliasy dla importowanych bibliotek muszą być stosowane w przypadku gdy nazwa pakietu nie pasuje do ostatniego elemetu ścieżki importu.

import (
  "net/http"

  client "example.com/client-go"
  trace "example.com/trace/v2"
)

We wszystkich innych przypadkach, import aliasing powinien być unikany chyba że występuje konflikt w nazwach importowanych pakietów.

ŹleDobrze
import (
  "fmt"
  "os"


  nettrace "golang.net/x/trace"
)
import (
  "fmt"
  "os"
  "runtime/trace"

  nettrace "golang.net/x/trace"
)

Grupowanie i porządkowanie funkcji

  • Funkcje powinny być sortowane w przybliżonej kolejności wykonywania.
  • Funkcje wewnątrz tego samego pliku powinny być grupowane zgodnie z ich odbiorcą (receiver).

Dlatego najpierw w pliku powinny pojawić się funkcje eksportowane, a następnie definicje takie jak struct,const, var.

Funkcje newXYZ()/NewXYZ() mogą pojawić się po definicji typu XYZ, jednak powinny znajdować się przed resztą metod których odbiorcą jest ten typ.

Ponieważ funkcje są pogrupowane według odbiorcy, funkcje narzędziowe ogólnego przeznaczenia powinny znajdować się na końcu pliku.

ŹleDobrze
func (s *something) Cost() {
  return calcCost(s.weights)
}

type something struct{ ... }

func calcCost(n []int) int {...}

func (s *something) Stop() {...}

func newSomething() *something {
    return &something{}
}
type something struct{ ... }

func newSomething() *something {
    return &something{}
}

func (s *something) Cost() {
  return calcCost(s.weights)
}

func (s *something) Stop() {...}

func calcCost(n []int) int {...}

Redukuj zagnieżdzenia

Kod powinien redukować zagnieżdzenia gdzie to tylko możliwe poprzez obsługę błędów oraz przypadków specialnych najpierw oraz wczesne zwrócenie lub kontynuacje pętli. Redukuj ilość kodu o wielu poziomach zagnieżdzenia.

ŹleDobrze
for _, v := range data {
  if v.F1 == 1 {
    v = process(v)
    if err := v.Call(); err == nil {
      v.Send()
    } else {
      return err
    }
  } else {
    log.Printf("Invalid v: %v", v)
  }
}
for _, v := range data {
  if v.F1 != 1 {
    log.Printf("Invalid v: %v", v)
    continue
  }

  v = process(v)
  if err := v.Call(); err != nil {
    return err
  }
  v.Send()
}

Niepotrzebne instrukcje "else"

Jeśli zmienna jest ustawiona dla obu ścieżek instrukcji if, instrukcja ta może zostać zastąpiona pojedyńczą ścieżką instrukcji if.

ŹleDobrze
var a int
if b {
  a = 100
} else {
  a = 10
}
a := 10
if b {
  a = 100
}

Deklarowanie zmiennych najwyższego poziomu

Na najwyższym poziomie (poziomie pliku), używaj standardowego słowa kluczowego var. Nie określaj typu, chyba że jest on inny niżeli typ przypisywanego wyrażenia.

ŹleDobrze
var _s string = F()

func F() string { return "A" }
var _s = F()
// Ponieważ F już informuje, że ​​zwraca łancuch znaków,
// nie musimy ponownie określać typu.

func F() string { return "A" }

Określ typ, jeśli typ wyrażenia nie odzwierciedla w pełni porządanego typu.

type myError struct{}

func (myError) Error() string { return "error" }

func F() myError { return myError{} }

var _e error = F()
// F zwraca obiekt o typie myError jednak zależy nam na typie error.

Rozpoczynaj nieeksportowane zmienne globalne znakiem _

Stosuj prefix _ dla nieeksportowanych var (zmiennych) oraz const (stałych) najwyższego poziomu żeby w przypadku użycia jasno poinformować że są symbolami globalnymi.

Wyjątek: Nieeksportowane wartości błędów, które powinny posiadać prefix err.

Uzasadnienie wyjątku: Zmienne i stałe najwyższego poziomu mają zakres pakietu. Używanie generycznych nazw może ułatwić popełnianie błędów polegających na przypadkowym użyciu niewłaściwej wartości w innym pliku tego samego pakietu.

ŹleDobrze
// foo.go

const (
  defaultPort = 8080
  defaultUser = "user"
)

// bar.go

func Bar() {
  defaultPort := 9090
  ...
  fmt.Println("Default port", defaultPort)

  // Nie zobaczymy błędu kompilacji po usunięciu pierwszej lini w funkcji Bar.
  // Zamiast tego zostanie użyta globalna stała o tej samej nazwie.
}
// foo.go

const (
  _defaultPort = 8080
  _defaultUser = "user"
)

Zagnieżdzanie w strukturach (embedding)

Typy zagnieżdzone (takie jak mutexy) powinny występować na samej górze listy pól struktury w której są zagnieżdzane. Powinny być również oddzielone pojedynczą pustą linią od zwykłych pól.

ŹleDobrze
type Client struct {
  version int
  http.Client
}
type Client struct {
  http.Client

  version int
}

Zagnieżdzanie typów powinno zapewniać wymierne korzyści takie jak dodawanie lub roszerzanie funkcjonalności w sposób zgodny semantycznie. Korzyści te nie powinny nieść ze sobą żadnych negatywnych skutków ubocznych dla użytkownika. (patrz Unikaj osadzania typów (type embedding) w strukturach publicznych)

Zagnieżdzanie nie powinno:

  • Być zabiegiem czysto kosmetycznym lub motywowanym jedynie wygodą.
  • Utrudniać budowania oraz użytkowania typu zewnętrznego (zawierającego osadzany typ).
  • Wpływać na wartości zerowe struktury zewnętrznego. Jeśli typ zewnętrzny ma użyteczną wartość zerową, osadzenie typu wewnętrznego nie powinno tego zmieniać.
  • Upubliczniać w efekcie ubocznym funkcji lub pól typu wewnętrznego, które nie są w żaden sposób powiązane z typem zewnętrznym.
  • Eksponować typów nieeksportowanych.
  • Wpływać na semantykę kopiowania typu zewnętrznego.
  • Zmieniać API lub semantykę typu zewnetrznego.
  • Zagnieżdzać w sobie niekanoniczną formę typu wewnętrznego.
  • Upubliczniać szczegóły implementacyjne typu zewnętrznego.
  • Pozwolić użytkownikowi na obserwowanie lub kontrolę elementów wewnętrznych.
  • Zmieniać ogólne zachowanie funkcji wewnętrznych poprzez zawijanie (wrapping) w sposób, którego nie spodziewają się użytkownicy.

Mówiąc krótko, zagnieżdzaj świadomie oraz z pełną celowością swojego działania. Dobrym testem "papierka lakmusowego" jest zadanie sobie pytania "czy wszystkie te wyeksportowane wewnętrzne metody / pola możnaby dodać bezpośrednio do typu zewnętrznego?". Jeśli odpowiedź brzmi "niektóre" lub "nie", nie osadzaj typu wewnętrznego, zamiast tego utwórz pole.

ŹleDobrze
type A struct {
    // Źle: A.Lock() oraz A.Unlock()
    //      są teraz dostępne do użytku mimo że
    //      nie zapewniają żadnych korzyści
    //      oraz pozwalają użytkownikom na
    //      kontrolę elementów wewnętrznych typu A
    sync.Mutex
}
type countingWriteCloser struct {
    // Dobrze: Write() jest dostępne w
    //         warstwie zewnętrznej z
    //         w bardzo konkretnynm celu
    //         a także oddelegowuje wykonanie zadania
    //         do metody Write() typu wewnętrznego.
    io.WriteCloser
    count int
}
func (w *countingWriteCloser) Write(bs []byte) (int, error) {
    w.count += len(bs)
    return w.WriteCloser.Write(bs)
}
type Book struct {
    // Źle: wskaźnik zmienia użyteczność wartości zerowej
    io.ReadWriter
    // inne pola
}
// dalej
var b Book
b.Read(...)  // panic: nil pointer
b.String()   // panic: nil pointer
b.Write(...) // panic: nil pointer
type Book struct {
    // Dobrze: posiada użyteczną wartość zerową
    bytes.Buffer
    // inne pola
}
// later
var b Book
b.Read(...)  // ok
b.String()   // ok
b.Write(...) // ok
type Client struct {
    sync.Mutex
    sync.WaitGroup
    bytes.Buffer
    url.URL
}
type Client struct {
    mtx sync.Mutex
    wg  sync.WaitGroup
    buf bytes.Buffer
    url url.URL
}

Jawnie podawaj nazwy pól podczas inicjalizacji struktur

Prawie zawsze powinieneś jawnie podawać nazwy pól podczas inicjalizowania struktur. To jest teraz wymuszane przez polecenie go vet.

ŹleDobrze
k := User{"John", "Doe", true}
k := User{
    FirstName: "John",
    LastName: "Doe",
    Admin: true,
}

Wyjątek: Nazwy pól mogą w ostateczności być pomijane w przypadku tablic testowych składających się z 3 lub mniejszej ilości pól.

tests := []struct{
  op Operation
  want string
}{
  {Add, "add"},
  {Subtract, "subtract"},
}

Deklarowanie zmiennych lokalnych

Skrócone deklaracje zmiennych (:=) powinny być używane gdy operacja przyporządkowywania wartości do zmiennej jest jawna.

ŹleDobrze
var s = "foo"
s := "foo"

Jednakże, są przypadki w których domyślna wartość zmiennej staje się bardziej oczywista gdy zastosuje się słowo kluczowe var. Jak w przypadku deklarowania pustych wycinków.

ŹleDobrze
func f(list []int) {
  filtered := []int{}
  for _, v := range list {
    if v > 10 {
      filtered = append(filtered, v)
    }
  }
}
func f(list []int) {
  var filtered []int
  for _, v := range list {
    if v > 10 {
      filtered = append(filtered, v)
    }
  }
}

"nil" to poprawna wartość wycinka

nil to poprawna wartość wycinka o zerowej długości. Oznacza to że:

  • Nie powinieneś zwracać wycinków o zerowej długości jawnie. Zamiast tego zwróć nil.

    ŹleDobrze
    if x == "" {
      return []int{}
    }
    if x == "" {
      return nil
    }
  • Aby sprawdzić czy wycinek jest pusty, zawsze stosuj wyrażenie len(s) == 0. Nie porównuj go z wartością nil.

    ŹleDobrze
    func isEmpty(s []string) bool {
      return s == nil
    }
    func isEmpty(s []string) bool {
      return len(s) == 0
    }
  • Wartość zerowa (wycinek zadeklarowany przy użyciu var) nadaje się do użytku od razu, bez konieczności stosowania funkcji make().

    ŹleDobrze
    nums := []int{}
    // lub, nums := make([]int)
    
    if add1 {
      nums = append(nums, 1)
    }
    
    if add2 {
      nums = append(nums, 2)
    }
    var nums []int
    
    if add1 {
      nums = append(nums, 1)
    }
    
    if add2 {
      nums = append(nums, 2)
    }

Pamiętaj, że mimo tego że nil jest jak najbardziej prawidłową wartością wycinka, nie jest tym samym co wycinek, który został zaalokowany z zerową długością - pierwszy stanowi pełnoprawną wartość nil a drugi nie - przez co w niektórych sytuacjach mogą być traktowane w zupełnie inny sposób (przykładem takie sytuacji jest serializacja do formatu JSON)

Redukuj zasięg (scope) zmiennych

Jeśli to tylko możliwe, staraj się redukować zakres (scope) zmiennych. Nie redukuj zakresu jeśli stoi to w sprzeczności z zasadami opisanymi w Redukuj zganieżdzenia

ŹleDobrze
err := ioutil.WriteFile(name, data, 0644)
if err != nil {
 return err
}
if err := ioutil.WriteFile(name, data, 0644); err != nil {
 return err
}

Jeżeli potrzebujesz wyniku wywołania funkcji poza instrukcją if, nie powinieneś starać się redukować zasięgu.

ŹleDobrze
if data, err := ioutil.ReadFile(name); err == nil {
  err = cfg.Decode(data)
  if err != nil {
    return err
  }

  fmt.Println(cfg)
  return nil
} else {
  return err
}
data, err := ioutil.ReadFile(name)
if err != nil {
   return err
}

if err := cfg.Decode(data); err != nil {
  return err
}

fmt.Println(cfg)
return nil

Unikaj przekazywania "surowych" wartości parametrów

Surowe wartości parametrów przekazywane podczas wywoływania funkcji mogą zaszkodzić czytelności. Dodawaj komentarze w stylu języka C (/* ... */) z nazwami parametrów jeśli ich znaczenie nie jest oczywiste.

ŹleDobrze
// func printInfo(name string, isLocal, done bool)

printInfo("foo", true, true)
// func printInfo(name string, isLocal, done bool)

printInfo("foo", true /* isLocal */, true /* done */)

Jeszcze lepiej będzie gdy zastąpisz "surową" wartość boolowską własnym typem w celu poprawienia czytelności oraz kodu bezpiecznego pod kątem typowania. Zachowanie takie zezwala na potencjalne przekazywanie przez ten parametr więcej niż dwóch stanów jeśli w przyszłości zajdzie taka potrzeba.

type Region int

const (
  UnknownRegion Region = iota
  Local
)

type Status int

const (
  StatusReady Status = iota + 1
  StatusDone
  // Może w przyszłości dodamy kolejny możliwy status - StatusInProgress.
)

func printInfo(name string, region Region, status Status)

Używaj literałów znakowych (string literals) w celu uniknięcia znaków ucieczki

Go wspiera surowe literały znakowe (raw string literals), które mogą obejmować wiele wierszy i zawierać znaki cudzysłowu. Używaj ich by uniknąć ręcznego dodawania znaków ucieczki (escapingu), co zdecydowanie zmniejsza czytelność.

ŹleDobrze
wantError := "unknown name:\"test\""
wantError := `unknown error:"test"`

Inicjalizowanie referencji do struktur

Używaj &T{} zamiast new(T) kiedy inicjalizujesz referencje do struktury tak aby było to zgodne z inicjalizacją samych struktur.

ŹleDobrze
sval := T{Name: "foo"}

// niespójne
sptr := new(T)
sptr.Name = "bar"
sval := T{Name: "foo"}

sptr := &T{Name: "bar"}

Inicjalizowanie map

Preferuj make(..) dla pustych map oraz map zapełnianych w czasie działania programu. To sprawia, że inicjalizacja mapy różni się wizualnie od jej deklaracji i ułatwia późniejsze dodawanie wskazówek dotyczących jej rozmiaru, jeśli to możliwe.

ŹleDobrze
var (
  // m1 jest bezpieczne dla operacji odczytu oraz zapisu
  // m2 wywoła panikę przy próbie zapisu
  m1 = map[T1]T2{}
  m2 map[T1]T2
)
var (
  // m1 jest bezpieczne dla operacji odczytu oraz zapisu
  // m2 wywoła panikę przy próbie zapisu
  m1 = make(map[T1]T2)
  m2 map[T1]T2
)

Deklaracja oraz inicjalizacja są podobne wizualnie.

Deklaracja oraz inicjalizacja są odróżnialne wizualnie.

Tam, gdzie to możliwe, stosuj wskazówki dotyczące pojemności podczas inicjalizacji z użyciem funkcji make(). Więcej szczegółów znajdziesz w rozdziale Określaj wskazówki dotyczące pojemności map.

Z drugiej strony, jeśli mapa zawiera z góry ustalone elementy, do jej inicjalizacji użyj literału.

ŹleDobrze
m := make(map[T1]T2, 3)
m[k1] = v1
m[k2] = v2
m[k3] = v3
m := map[T1]T2{
  k1: v1,
  k2: v2,
  k3: v3,
}

Podstawową zasadą jest używanie literałów mapy podczas dodawania stałego zestawu elementów w czasie inicjalizacji, w przeciwnym wypadku użyj funkcji make (i podawaj wskazówki dotyczące rozmiaru jeżeli to możliwe).

Formatuj łańcuchy znakowe poza funkcją "Printf"

Jeśli deklarujesz formatowalne łańcuchy znakowe w stylu funkcji Printf twórz z nich stałe przy użyciu const.

Pomoże to poleceniu go vet w wykonaniu statycznej analizy formatowania wewnątrz tego łańcucha.

ŹleDobrze
msg := "unexpected values %v, %v\n"
fmt.Printf(msg, 1, 2)
const msg = "unexpected values %v, %v\n"
fmt.Printf(msg, 1, 2)

Nazewnictwo funkcji "Printf-style"

Kiedy deklarujesz funkcje w stylu funkcji Printf, upewnij się że go vet jest w stanie ją wykryć oraz sprawdzić wewnętrzny łańcuch znakowy zawierający formatowanie.

Oznacza to że powinieneś używać predefiniowanych nazw w stylu Printf jeżeli to tylko możliwe. go vet domyślnie sprawdza takie funkcje. W celu poznania szczegółów zobacz Printf family.

Jeżeli używanie predefiniowanych nazw nie jest możliwe, zakońćż wybraną nazwę literą "f" nazywają funkcję Wrapf (nie Wrap). go vet posiada opcje pozwalające na wyspecyfiukowanie funkcji w stylu Printf jednak ich nazwy muszą kończyć się literą "f".

$ go vet -printfuncs=wrapf,statusf

Zobacz również artykuł go vet: Printf family check.

Wzorce

Tablice testowe

Stosuj testy oparte o tablice testowe z wykorzystaniem subtests aby uniknąć powielania kodu w przypadku gdy logika testów jest powtarzalna.

ŹleDobrze
// func TestSplitHostPort(t *testing.T)

host, port, err := net.SplitHostPort("192.0.2.0:8000")
require.NoError(t, err)
assert.Equal(t, "192.0.2.0", host)
assert.Equal(t, "8000", port)

host, port, err = net.SplitHostPort("192.0.2.0:http")
require.NoError(t, err)
assert.Equal(t, "192.0.2.0", host)
assert.Equal(t, "http", port)

host, port, err = net.SplitHostPort(":8000")
require.NoError(t, err)
assert.Equal(t, "", host)
assert.Equal(t, "8000", port)

host, port, err = net.SplitHostPort("1:8")
require.NoError(t, err)
assert.Equal(t, "1", host)
assert.Equal(t, "8", port)
// func TestSplitHostPort(t *testing.T)

tests := []struct{
  give     string
  wantHost string
  wantPort string
}{
  {
    give:     "192.0.2.0:8000",
    wantHost: "192.0.2.0",
    wantPort: "8000",
  },
  {
    give:     "192.0.2.0:http",
    wantHost: "192.0.2.0",
    wantPort: "http",
  },
  {
    give:     ":8000",
    wantHost: "",
    wantPort: "8000",
  },
  {
    give:     "1:8",
    wantHost: "1",
    wantPort: "8",
  },
}

for _, tt := range tests {
  t.Run(tt.give, func(t *testing.T) {
    host, port, err := net.SplitHostPort(tt.give)
    require.NoError(t, err)
    assert.Equal(t, tt.wantHost, host)
    assert.Equal(t, tt.wantPort, port)
  })
}

Tablice testowe ułatwiają dodawanie kontekstu do komunikatów błędów, redukują duplikacje w logice oraz ułatwiają dodawanie nowych scenariuszy testowych.

Postępujemy zgodnie z konwencją mówiącą że wycinki struktur testowych należy nazwać tests a każdy scenariusz testowy tt. Ponadto zachęcamy do jawnego oznaczania danych wejściowych oraz wyjściowych dla każdego scenariusza testowego prefiksami give oraz want.

tests := []struct{
  give     string
  wantHost string
  wantPort string
}{
  // ...
}

for _, tt := range tests {
  // ...
}

Opcje funkcjonalne

Opcje funkcjonalne to wzorzec, w którym deklarujesz przykrywający typ Option, który rejestruje informacje w pewnej nieeksportowanej (wewnętrznej, prywatnej) strukturze. Akceptujesz dowolną ilość takich opcji i działasz na podstawie pełnej informacji zawartej przez opcje w tej strukturze.

Użyj tego wzorca dla opcjonalnych argumentów w konstruktorach i innych publicznych interfejsach API, szczególnie gdy przewidujesz późniejszą potrzebę ich rozszerzania, zwłaszcza jeśli masz już funkcje z trzema lub większą ilością argumentów.

ŹleDobrze
// pakiet db

func Open(
  addr string,
  cache bool,
  logger *zap.Logger
) (*Connection, error) {
  // ...
}
// pakiet db

type Option interface {
  // ...
}

func WithCache(c bool) Option {
  // ...
}

func WithLogger(log *zap.Logger) Option {
  // ...
}

// Open otwiera połączenie.
func Open(
  addr string,
  opts ...Option,
) (*Connection, error) {
  // ...
}

"cache" oraz "logger" są zawsze wymagane nawet jeżeli użytkownik chce użyć ich wartości domyślnych.

db.Open(addr, db.DefaultCache, zap.NewNop())
db.Open(addr, db.DefaultCache, log)
db.Open(addr, false /* cache */, zap.NewNop())
db.Open(addr, false /* cache */, log)

Opcje można podać tylko w razie potrzeby.

db.Open(addr)
db.Open(addr, db.WithLogger(log))
db.Open(addr, db.WithCache(false))
db.Open(
  addr,
  db.WithCache(false),
  db.WithLogger(log),
)

Sugerowany przez nas sposób implementacji tego wzorca opiera się o interfejs Option zawierjaący nieeksportowaną metodę rejestrującą opcje w nieeksportowanej strukturze options.

type options struct {
  cache  bool
  logger *zap.Logger
}

type Option interface {
  apply(*options)
}

type cacheOption bool

func (c cacheOption) apply(opts *options) {
  opts.cache = bool(c)
}

func WithCache(c bool) Option {
  return cacheOption(c)
}

type loggerOption struct {
  Log *zap.Logger
}

func (l loggerOption) apply(opts *options) {
  opts.logger = l.Log
}

func WithLogger(log *zap.Logger) Option {
  return loggerOption{Log: log}
}

// Open otwiera połączenie.
func Open(
  addr string,
  opts ...Option,
) (*Connection, error) {
  options := options{
    cache:  defaultCache,
    logger: zap.NewNop(),
  }

  for _, o := range opts {
    o.apply(&options)
  }

  // ...
}

Warto zaznaczyć że istnieje sposób implementacji tego wzorca oparty na domnięciach jednak jesteśmy zdania że powyższy wzorzec zapewnia autorowi większą elastyczność i jest łatwiejszy w debugowaniu i testowaniu przez użytkowników. Sposób ten pozwala w szczególności na porównywanie opcji między sobą w testach oraz imitacjach (mock'ach) co w przypadku domknięć staje się niemożliwe. Ponad to pozwala opcjom na implementowanie innych interfejsów takich jak fmt.Stringer, który umożliwia dodanie opcjom reprezentacji czytelnej dla użytkownika.

Zobacz również,

Lintowanie

Ważniejsza od jakichkolwiek "błogosławionych" zestawów linterów jest konsekwencja w lintowaniu swojej bazy kodu.

Zalecamy stosowanie przynajmniej wymienionych linterów, ponieważ uważamy, że pomogą one wyłapać najczęstsze problemy oraz ustanowić wysoki próg jakościowy dla kodu bez niepotrzebnego narzucania reguł:

  • errcheck to ensure that errors are handled
  • goimports to format code and manage imports
  • golint to point out common style mistakes
  • govet to analyze code for common mistakes
  • staticcheck to do various static analysis checks

Runnery linterów

Zalecamy stosowanie golangci-lint jako głównego runnera na potrzeb lintowania kodu w języku Go, przede wszystkim z powodu jego wydajności dla dużych baz kodu oraz możliwości konfiguracji i używania wielu standardowych linterów jednocześnie. To repozytorium posiada przykład pliku konfiguracyjnego .golangci.yml zawierającego rekomendowane lintery oraz ustawienia.

golangci-lint umożliwia użycie wielu linterów. Wyżej wymienione lintery są rekomendowane jako podstaswowy zbiór, jednak zachęcamy również zespoły developerskie do dodania dodatkowych linterów mających sens w kontekście ich projektów.