Linee guida di base per lo standard di codifica Golang

Utilizzare defer per rilasciare risorse

Utilizzare defer per rilasciare risorse come file e lock.

Non raccomandato:

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

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

return newCount

// È facile dimenticare di sbloccare quando ci sono più rami di return

Raccomandato:

p.Lock()
defer p.Unlock()

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

p.count++
return p.count

// Più leggibile

Il costo di defer è estremamente basso, quindi dovrebbe essere evitato solo quando si può dimostrare che il tempo di esecuzione della funzione è a livello di nanosecondi. Utilizzare defer per migliorare la leggibilità merita, in quanto il costo di utilizzo è trascurabile. Questo si applica specialmente a metodi più grandi che coinvolgono più di semplice accesso alla memoria, dove il consumo di risorse di altri calcoli supera di gran lunga quello di defer.

La dimensione del canale dovrebbe essere 1 o non bufferizzata

I canali dovrebbero tipicamente avere una dimensione di 1 o essere non bufferizzati. Per impostazione predefinita, i canali sono non bufferizzati con una dimensione di zero. Qualsiasi altra dimensione deve essere attentamente valutata. È necessario considerare come determinare la dimensione, cosa impedisce al canale di scrivere sotto carichi elevati e quando è bloccato, e cosa cambia nella logica del sistema quando ciò accade.

Non raccomandato:

// Dovrebbe essere sufficiente gestire qualsiasi situazione!
c := make(chan int, 64)

Raccomandato:

// Dimensione: 1
c := make(chan int, 1) // o
// Canale non bufferizzato, dimensione è 0
c := make(chan int)

Gli enum partono da 1

Il metodo standard per introdurre enum in Go è dichiarare un tipo personalizzato e un gruppo const che usa l'iota. Dal momento che il valore predefinito delle variabili è 0, gli enum dovrebbero iniziare tipicamente con un valore diverso da zero.

Non raccomandato:

type Operazione int

const (
  Add Operazione = iota
  Sottrarre
  Moltiplicare
)

// Add=0, Sottrarre=1, Moltiplicare=2

Raccomandato:

type Operazione int

const (
  Add Operazione = iota + 1
  Sottrarre
  Moltiplicare
)

// Add=1, Sottrarre=2, Moltiplicare=3

In alcuni casi, utilizzare il valore zero ha senso (enum che inizia da zero), ad esempio quando il valore zero è il comportamento predefinito ideale.

type OutputLog int

const (
  LogSuStdout OutputLog = iota
  LogSuFile
  LogSuRemoto
)

// LogSuStdout=0, LogSuFile=1, LogSuRemoto=2

Usare atomic

Utilizzare operazioni atomiche dal pacchetto sync/atomic per operare su tipi primitivi (int32, int64, ecc.) perché è facile dimenticare di utilizzare operazioni atomiche per leggere o modificare variabili.

go.uber.org/atomic aggiunge sicurezza di tipo a queste operazioni nascondendo il tipo sottostante. Inoltre, include un comodo tipo atomic.Bool.

Approccio non raccomandato:

type foo struct {
  running int32  // atomic
}

func (f* foo) start() {
  if atomic.SwapInt32(&f.running, 1) == 1 {
     // già in esecuzione…
     return
  }
  // avvia il Foo
}

func (f *foo) isRunning() bool {
  return f.running == 1  // gara!
}

Approccio raccomandato:

type foo struct {
  running atomic.Bool
}

func (f *foo) start() {
  if f.running.Swap(true) {
     // già in esecuzione…
     return
  }
  // avvia il Foo
}

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

Evitare variabili globali mutevoli

Utilizzare l'approccio dell'iniezione delle dipendenze per evitare la modifica delle variabili globali. Questo è applicabile sia ai puntatori alle funzioni che ad altri tipi di valore.

Approccio non raccomandato 1:

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

Approccio raccomandato 1:

// 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)
}

Approccio non raccomandato 2:

// 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))
}

Approccio raccomandato 2:

// sign_test.go
func TestSigner(t *testing.T) {
  s := newSigner()
  s.now = func() time.Time {
    return someFixedTime
  }
  assert.Equal(t, want, s.Sign(give))
}

Evitare l'uso di identificatori predefiniti

La Specificità del Linguaggio Go illustra diversi identificatori predefiniti che non dovrebbero essere utilizzati nei progetti Go. Questi identificatori predefiniti non dovrebbero essere riutilizzati come nomi in contesti diversi, poiché farlo nasconderebbe gli identificatori originali nello scope corrente (o in uno qualsiasi dei sotto-scope), potenzialmente portando a confusione nel codice. Nel migliore dei casi, il compilatore genererà un errore; nel peggiore dei casi, tale codice potrebbe introdurre errori potenzialmente difficili da recuperare.

Pratica non raccomandata 1:

var error string
// `error` nasconde implicitamente l'identificatore integrato

// o

func handleErrorMessage(error string) {
    // `error` nasconde implicitamente l'identificatore integrato
}

Pratica raccomandata 1:

var errorMessage string
// `error` ora fa riferimento all'identificatore integrato non oscurato

// o

func handleErrorMessage(msg string) {
    // `error` ora fa riferimento all'identificatore integrato non oscurato
}

Pratica non raccomandata 2:

type Foo struct {
    // Anche se questi campi non nascondono tecnicamente, ridefinire le stringhe `error` o `string` ora diventa ambiguo.
    error  error
    string string
}

func (f Foo) Error() error {
    // `error` e `f.error` appaiono visivamente simili
    return f.error
}

func (f Foo) String() string {
    // `string` e `f.string` appaiono visivamente simili
    return f.string
}

Pratica raccomandata 2:

type Foo struct {
    // `error` e `string` sono ora espliciti.
    err error
    str string
}

func (f Foo) Error() error {
    return f.err
}

func (f Foo) String() string {
    return f.str
}

Si noti che il compilatore non genererà errori nell'uso degli identificatori predefiniti, ma strumenti come go vet indicheranno correttamente questi e altri problemi impliciti correlati.

Evita di utilizzare init()

Cerca di evitare quanto più possibile di utilizzare init(). Quando init() è inevitabile o preferibile, il codice dovrebbe cercare di:

  1. Garantire completezza indipendentemente dall'ambiente del programma o dalla chiamata.
  2. Evitare di fare affidamento sull'ordine o sugli effetti collaterali di altre funzioni init(). Anche se l'ordine di init() è esplicito, il codice può cambiare, quindi la relazione tra le funzioni init() potrebbe rendere il codice fragile e soggetto a errori.
  3. Evitare di accedere o manipolare stati globali o ambientali, come informazioni sulla macchina, variabili d'ambiente, directory di lavoro, parametri/ingressi del programma, ecc.
  4. Evitare I/O, inclusi file system, networking e chiamate di sistema.

Il codice che non soddisfa questi requisiti potrebbe appartenere come parte della chiamata main() (o altrove nel ciclo di vita del programma) o essere scritto come parte di main() stesso. In particolare, le librerie destinate ad essere utilizzate da altri programmi dovrebbero prestare particolare attenzione alla completezza piuttosto che eseguire "magia di inizializzazione".

Approccio non consigliato 1:

type Foo struct {
    // ...
}
var _defaultFoo Foo
func init() {
    _defaultFoo = Foo{
        // ...
    }
}

Approccio consigliato 1:

var _defaultFoo = Foo{
    // ...
}
// oppure, per una migliore testabilità:
var _defaultFoo = defaultFoo()
func defaultFoo() Foo {
    return Foo{
        // ...
    }
}

Approccio non consigliato 2:

type Config struct {
    // ...
}
var _config Config
func init() {
    // Male: basato sulla directory corrente
    cwd, _ := os.Getwd()
    // Male: I/O
    raw, _ := os.ReadFile(
        path.Join(cwd, "config", "config.yaml"),
    )
    yaml.Unmarshal(raw, &_config)
}

Approccio consigliato 2:

type Config struct {
    // ...
}
func loadConfig() Config {
    cwd, err := os.Getwd()
    // gestire l'errore
    raw, err := os.ReadFile(
        path.Join(cwd, "config", "config.yaml"),
    )
    // gestire l'errore
    var config Config
    yaml.Unmarshal(raw, &config)
    return config
}

Date le considerazioni precedenti, in alcuni casi, init() potrebbe essere più preferibile o necessario, incluso:

  • Non può essere rappresentato come un'unica assegnazione di un'espressione complessa.
  • Hooks inseribili, come database/sql, registri di tipi, ecc.

Preferire la specifica della capacità della slice durante l'append

Dà sempre la priorità alla specifica di un valore di capacità per make() durante l'inizializzazione di una slice da appendere.

Approccio non consigliato:

for n := 0; n < b.N; n++ {
  data := make([]int, 0)
  for k := 0; k < size; k++{
    data = append(data, k)
  }
}

Approccio consigliato:

for n := 0; n < b.N; n++ {
  data := make([]int, 0, size)
  for k := 0; k < size; k++{
    data = append(data, k)
  }
}

Utilizzo dei tag di campo nella serializzazione di strutture

Quando si serializza in JSON, YAML o in qualsiasi altro formato che supporta la denominazione dei campi in base ai tag, dovrebbero essere utilizzati tag pertinenti per l'annotazione.

Non consigliato:

type Stock struct {
  Price int
  Name  string
}
bytes, err := json.Marshal(Stock{
  Price: 137,
  Name:  "UBER",
})

Consigliato:

type Stock struct {
  Price int    `json:"price"`
  Name  string `json:"name"`
  // Sicuro rinominare Name in Symbol.
}
bytes, err := json.Marshal(Stock{
  Price: 137,
  Name:  "UBER",
})

In teoria, il formato di serializzazione di una struttura è un contratto tra sistemi diversi. Apportare modifiche al formato di serializzazione della struttura (compresi i nomi dei campi) romperà questo contratto. Specificare i nomi dei campi nei tag rende il contratto esplicito e aiuta a prevenire violazioni accidentali del contratto attraverso il refactoring o la rinomina dei campi.