- Wstęp
- Wskazówki
- Wskaźniki typów interfejsowych
- Weryfikuj zgodność z interfejsem
- Odbiorniki (Receivers) i Interfejsy
- Poprawność wartości zerowych Mutexów
- Ograniczenia kopiowania wycinków i map
- Stosuj "defer" by opóźnić operacje czyszczenia (clean-up)
- Kanały (channels) powinny mieć rozmiar 1 lub być niebuforowane
- Wyliczanie rozpoczynaj od 1
- Używaj pakietu
"time"
do obsługi czasu - Typy błędów
- Opakowywanie błędów (Error wrapping)
- Obsługa błędów asercji typów
- Nie "panikuj"
- Używaj go.uber.org/atomic
- Unikaj mutowalnych zmiennych globalnych
- Unikaj osadzania typów (type embedding) w strukturach publicznych
- Unikaj używania nazw elementów wbudowanych (built-in names)
- Unikaj
init()
- Wydajność
- Styl
- Spójność ponad wszystko
- Grupuj podobne deklaracje
- Kolejność importowania bibliotek
- Nazwy pakietów
- Nazwy funkcji
- Aliasy importowanych bibliotek
- Grupowanie i porządkowanie funkcji
- Redukuj zagnieżdzenia
- Niepotrzebne instrukcje "else"
- Deklarowanie zmiennych najwyższego poziomu
- Rozpoczynaj nieeksportowane zmienne globalne znakiem _
- Zagnieżdzanie w strukturach (embedding)
- Jawnie podawaj nazwy pól podczas inicjalizacji struktur
- Deklarowanie zmiennych lokalnych
- "nil" to poprawna wartość wycinka
- Redukuj zasięg (scope) zmiennych
- Unikaj przekazywania "surowych" wartości parametrów
- Używaj literałów znakowych (string literals) w celu uniknięcia znaków ucieczki
- Inicjalizowanie referencji do struktur
- Inicjalizowanie map
- Formatuj łańcuchy znakowe poza funkcją "Printf"
- Nazewnictwo funkcji "Printf-style"
- Wzorce
- Lintowanie
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:
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
orazgo 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
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:
- Wskaźnik na pewne informacje specyficzne dla typu. Możesz myśleć o tym jako o "typie".
- 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.
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
Źle | Dobrze |
---|---|
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,
) {
// ...
}
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.
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.
Źle | Dobrze |
---|---|
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. |
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ć.
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] = ... |
Podobnie do powyższego, bądź świadom potencjalnych modyfikacji map lub wycinków udostępniających stan wewnętrzny.
Źle | Dobrze |
---|---|
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() |
Użyj instrukcji "defer" do czyszczenia zasobów takich jak pliki czy blokady (locks).
Źle | Dobrze |
---|---|
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
.
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.
Źle | Dobrze |
---|---|
// 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) |
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.
Źle | Dobrze |
---|---|
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
Czas to skomplikowany temat. Nieprawidłowe założenia często dotyczące „czasu” zakładają że:
- Dzień ma 24 godziny
- Godzina składa się z 60 minut
- Tydzień ma 7 dni
- Rok to 365 dni
- 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żyj time.Time
gdy masz do czynienia z punktami w czasie. Metody time.Time
wykorzystuj do porównywania, dodawania lub odejmowania czasu.
Źle | Dobrze |
---|---|
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)
} |
Use time.Duration
when dealing with periods of time.
Źle | Dobrze |
---|---|
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
i time.Duration
w interakcjach z systemami zewnętrznymi gdy tylko to możliwe.
Na przykład:
- Flagi lini poleceń: pakiet
flag
wspieratime.Duration
poprzeztime.ParseDuration
- JSON:
encoding/json
wspiera kodowanietime.Time
jako RFC 3339 string poprzez metodęUnmarshalJSON
- SQL:
database/sql
wspiera konwersję z kolumnDATETIME
orazTIMESTAMP
wtime.Time
i spowrotem jeśli sterownik to obsługuje. - YAML:
gopkg.in/yaml.v2
wspieratime.Time
jako RFC 3339 string, oraztime.Duration
poprzeztime.ParseDuration
.
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.
Źle | Dobrze |
---|---|
// {"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.
Istnieje kilka sposób deklarowania błędów:
errors.New
dla błędów z prostymi, statycznymi ciągami znakówfmt.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.
Źle | Dobrze |
---|---|
// 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.
Źle | Dobrze |
---|---|
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")
}
}
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.
Źle | Dobrze |
---|---|
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.
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".
Źle | Dobrze |
---|---|
t := i.(string) |
t, ok := i.(string)
if !ok {
// kod obsługi błędu.
} |
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.
Źle | Dobrze |
---|---|
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.
Źle | Dobrze |
---|---|
// 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")
} |
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
.
Źle | Dobrze |
---|---|
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 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.
Źle | Dobrze |
---|---|
// 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))
} |
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) {
// ...
}
Źle | Dobrze |
---|---|
// 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ą.
Źle | Dobrze |
---|---|
// 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.
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.
Źle | Dobrze |
---|---|
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 funkcji init ()
tam, gdzie to możliwe. Gdy wykorzystanie init ()
jest nieuniknione lub wysoce pożądane, twój kod powinien:
- Być całkowicie deterministyczny i niezależny od środowiska czy warunków wywołania programu.
- Unikać zależności od kolejności lub skutków ubocznych (side-effects)
iunnych funkcji
init()
. Podczas gdy porządek kolejnośc wywołania funkcjiinit()
jest dobrze znana, kod może się zmienić a razem z nim zmianie mogą ulec zawarte w nim relacje. - 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.
- 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”.
Źle | Dobrze |
---|---|
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.
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.
Podczas konwersji typów prymitywnych z/do typu string, strconv
jest szybsze od fmt
.
Źle | Dobrze |
---|---|
for i := 0; i < b.N; i++ {
s := fmt.Sprint(rand.Int())
} |
for i := 0; i < b.N; i++ {
s := strconv.Itoa(rand.Int())
} |
|
|
Nie powtarzaj konwersji łańcuchów znakowych na wycinki bajtowe. Zamiast tego, przeprowadź operacje konwersji raz i zachowaj wynik.
Źle | Dobrze |
---|---|
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)
} |
|
|
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.
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.
Źle | Dobrze |
---|---|
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 |
mapa |
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).
Źle | Dobrze |
---|---|
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)
}
} |
|
|
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.
Go wspiera grupowanie podobnych deklaracji.
Źle | Dobrze |
---|---|
import "a"
import "b" |
import (
"a"
"b"
) |
Reguła ta ma również zastosowanie dla stałych, zmiennych i deklaracji typów.
Źle | Dobrze |
---|---|
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.
Źle | Dobrze |
---|---|
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.
Źle | Dobrze |
---|---|
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)
)
...
} |
Instrukcje importowania powinny być podzielone na dwie grupy:
- Biblioteka standardowa
- Cała reszta
Jest to domyślne grupowanie stosowane przez goimports.
Źle | Dobrze |
---|---|
import (
"fmt"
"os"
"go.uber.org/atomic"
"golang.org/x/sync/errgroup"
) |
import (
"fmt"
"os"
"go.uber.org/atomic"
"golang.org/x/sync/errgroup"
) |
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
zamiastnet/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.
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 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.
Źle | Dobrze |
---|---|
import (
"fmt"
"os"
nettrace "golang.net/x/trace"
) |
import (
"fmt"
"os"
"runtime/trace"
nettrace "golang.net/x/trace"
) |
- 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.
Źle | Dobrze |
---|---|
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 {...} |
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.
Źle | Dobrze |
---|---|
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()
} |
Jeśli zmienna jest ustawiona dla obu ścieżek instrukcji if, instrukcja ta może zostać zastąpiona pojedyńczą ścieżką instrukcji if.
Źle | Dobrze |
---|---|
var a int
if b {
a = 100
} else {
a = 10
} |
a := 10
if b {
a = 100
} |
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.
Źle | Dobrze |
---|---|
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.
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.
Źle | Dobrze |
---|---|
// 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"
) |
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.
Źle | Dobrze |
---|---|
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.
Źle | Dobrze |
---|---|
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
} |
Prawie zawsze powinieneś jawnie podawać nazwy pól podczas inicjalizowania struktur.
To jest teraz wymuszane przez polecenie go vet
.
Źle | Dobrze |
---|---|
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"},
}
Skrócone deklaracje zmiennych (:=
) powinny być używane gdy operacja przyporządkowywania wartości do zmiennej jest jawna.
Źle | Dobrze |
---|---|
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.
Źle | Dobrze |
---|---|
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 o zerowej długości. Oznacza to że:
-
Nie powinieneś zwracać wycinków o zerowej długości jawnie. Zamiast tego zwróć
nil
.Źle Dobrze 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
.Źle Dobrze 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 funkcjimake()
.Źle Dobrze 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)
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
Źle | Dobrze |
---|---|
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.
Źle | Dobrze |
---|---|
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 |
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.
Źle | Dobrze |
---|---|
// 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)
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ść.
Źle | Dobrze |
---|---|
wantError := "unknown name:\"test\"" |
wantError := `unknown error:"test"` |
Używaj &T{}
zamiast new(T)
kiedy inicjalizujesz referencje do struktury tak aby było to zgodne z inicjalizacją samych struktur.
Źle | Dobrze |
---|---|
sval := T{Name: "foo"}
// niespójne
sptr := new(T)
sptr.Name = "bar" |
sval := T{Name: "foo"}
sptr := &T{Name: "bar"} |
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.
Źle | Dobrze |
---|---|
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.
Źle | Dobrze |
---|---|
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).
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.
Źle | Dobrze |
---|---|
msg := "unexpected values %v, %v\n"
fmt.Printf(msg, 1, 2) |
const msg = "unexpected values %v, %v\n"
fmt.Printf(msg, 1, 2) |
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.
Stosuj testy oparte o tablice testowe z wykorzystaniem subtests aby uniknąć powielania kodu w przypadku gdy logika testów jest powtarzalna.
Źle | Dobrze |
---|---|
// 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 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.
Źle | Dobrze |
---|---|
// 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ż,
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
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.