Directrices básicas de estándares de codificación Golang

Utilizar defer para liberar recursos

Utilice defer para liberar recursos como archivos y bloqueos.

No recomendado:

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

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

return newCount

// Es fácil olvidar desbloquear cuando hay múltiples ramas de retorno

Recomendado:

p.Lock()
defer p.Unlock()

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

p.count++
return p.count

// Más legible

El costo de utilizar defer es extremadamente bajo, por lo que solo debería evitarse cuando se pueda demostrar que el tiempo de ejecución de la función está en el nivel de nanosegundos. Utilizar defer para mejorar la legibilidad vale la pena porque el costo de usarlo es insignificante. Esto es especialmente aplicable a métodos más grandes que involucran más que simples accesos a memoria, donde el consumo de recursos de otros cálculos supera con creces el de defer.

El tamaño del canal debe ser 1 o sin búfer

Los canales típicamente deben tener un tamaño de 1 o ser sin búfer. Por defecto, los canales son sin búfer con un tamaño de cero. Cualquier otro tamaño debe ser estrictamente revisado. Necesitamos considerar cómo determinar el tamaño, considerar qué evita que el canal escriba bajo cargas altas y cuando está bloqueado, y considerar qué cambios ocurren en la lógica del sistema cuando esto sucede.

No recomendado:

// ¡Debería ser suficiente para manejar cualquier situación!
c := make(chan int, 64)

Recomendado:

// Tamaño: 1
c := make(chan int, 1) // o
// Canal sin búfer, tamaño es 0
c := make(chan int)

Los enums comienzan desde 1

El método estándar de introducir enums en Go es declarar un tipo personalizado y un grupo constante que usa iota. Dado que el valor predeterminado de las variables es 0, los enums típicamente deben comenzar con un valor distinto de cero.

No recomendado:

type Operation int

const (
  Add Operation = iota
  Subtract
  Multiply
)

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

Recomendado:

type Operation int

const (
  Add Operation = iota + 1
  Subtract
  Multiply
)

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

En algunos casos, tiene sentido usar el valor cero (enums que comienzan desde cero), por ejemplo, cuando el valor cero es el comportamiento predeterminado ideal.

type LogOutput int

const (
  LogToStdout LogOutput = iota
  LogToFile
  LogToRemote
)

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

Utilizar atomic

Utilice operaciones atómicas del paquete sync/atomic para operar en tipos primitivos (int32, int64, etc.) porque es fácil olvidar usar operaciones atómicas para leer o modificar variables.

go.uber.org/atomic agrega seguridad de tipos a estas operaciones al ocultar el tipo subyacente. Además, incluye un conveniente tipo atomic.Bool.

Enfoque no recomendado:

type foo struct {
  running int32  // atómico
}

func (f* foo) start() {
  if atomic.SwapInt32(&f.running, 1) == 1 {
     // ¡ya en ejecución!
     return
  }
  // comenzar el Foo
}

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

Enfoque recomendado:

type foo struct {
  running atomic.Bool
}

func (f *foo) start() {
  if f.running.Swap(true) {
     // ¡ya en ejecución!
     return
  }
  // comenzar el Foo
}

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

Evitar variables globales mutables

Utilice el enfoque de inyección de dependencias para evitar cambiar variables globales. Esto es aplicable tanto para punteros a funciones como para otros tipos de valores.

Enfoque no recomendado 1:

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

Enfoque recomendado 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)
}

Enfoque no recomendado 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))
}

Enfoque recomendado 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))
}

Evitar el uso de identificadores predeclarados

La Especificación del lenguaje Go describe varios identificadores predeclarados que no deben usarse en proyectos Go. Estos identificadores predeclarados no deben reutilizarse como nombres en diferentes contextos, ya que hacerlo ocultará los identificadores originales en el ámbito actual (o cualquier ámbito anidado), lo que potencialmente conducirá a la confusión del código. En el mejor de los casos, el compilador generará un error; en el peor de los casos, dicho código puede introducir errores potenciales difíciles de recuperar.

Práctica no recomendada 1:

var error string
// `error` sombrea implícitamente el identificador incorporado

// o

func handleErrorMessage(error string) {
    // `error` sombrea implícitamente el identificador incorporado
}

Práctica recomendada 1:

var errorMessage string
// `error` ahora apunta al identificador incorporado no sombreado

// o

func handleErrorMessage(msg string) {
    // `error` ahora apunta al identificador incorporado no sombreado
}

Práctica no recomendada 2:

type Foo struct {
    // Aunque estos campos técnicamente no hacen sombra, redefinir las cadenas `error` o `string` ahora se vuelve ambiguo.
    error  error
    string string
}

func (f Foo) Error() error {
    // `error` y `f.error` son visualmente similares
    return f.error
}

func (f Foo) String() string {
    // `string` y `f.string` son visualmente similares
    return f.string
}

Práctica recomendada 2:

type Foo struct {
    // `error` y `string` ahora son explícitos.
    err error
    str string
}

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

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

Tenga en cuenta que el compilador no generará errores al usar los identificadores predeclarados, pero herramientas como go vet señalarán correctamente estos y otros problemas relacionados implícitamente.

Evite usar init()

Trate de evitar usar init() tanto como sea posible. Cuando init() sea inevitable o preferible, el código debe tratar de:

  1. Asegurar la completitud independientemente del entorno del programa o la llamada.
  2. Evitar depender del orden o efectos secundarios de otras funciones init(). Aunque el orden de init() es explícito, el código puede cambiar, por lo que la relación entre las funciones init() puede hacer que el código sea frágil y propenso a errores.
  3. Evitar acceder o manipular estados globales o ambientales, como información de la máquina, variables de entorno, directorios de trabajo, parámetros/entradas del programa, etc.
  4. Evitar I/O, incluidos sistemas de archivos, redes y llamadas al sistema.

El código que no cumpla con estos requisitos puede pertenecer a parte de la llamada main() (o en otro lugar del ciclo de vida del programa) o puede escribirse como parte de main() en sí. En particular, las bibliotecas destinadas a ser utilizadas por otros programas deben prestar especial atención a la completitud en lugar de realizar "magia de init".

Enfoque no recomendado 1:

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

Enfoque recomendado 1:

var _defaultFoo = Foo{
    // ...
}
// o, para una mejor testabilidad:
var _defaultFoo = defaultFoo()
func defaultFoo() Foo {
    return Foo{
        // ...
    }
}

Enfoque no recomendado 2:

type Config struct {
    // ...
}
var _config Config
func init() {
    // Incorrecto: basado en el directorio actual
    cwd, _ := os.Getwd()
    // Incorrecto: I/O
    raw, _ := os.ReadFile(
        path.Join(cwd, "config", "config.yaml"),
    )
    yaml.Unmarshal(raw, &_config)
}

Enfoque recomendado 2:

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

Dadas las consideraciones anteriores, en algunos casos, init() puede ser más preferible o necesario, incluyendo:

  • No puede ser representado como una sola asignación de una expresión compleja.
  • Hooks insertables, como database/sql, registros de tipos, etc.

Prefiera especificar la capacidad de la lista al agregar

Siempre priorice especificar un valor de capacidad para make() al inicializar una lista que se va a agregar.

Enfoque no recomendado:

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

Enfoque recomendado:

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

Uso de etiquetas de campo en la serialización de estructuras

Cuando se serializa a JSON, YAML, u otro formato que soporta el nombramiento de campos basado en etiquetas, se deben utilizar etiquetas relevantes para la anotación.

No recomendado:

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

Recomendado:

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

En teoría, el formato de serialización de una estructura es un contrato entre diferentes sistemas. Realizar cambios en la forma de serialización de la estructura (incluidos los nombres de los campos) romperá este contrato. Especificar nombres de campos en etiquetas hace que el contrato sea explícito y también ayuda a prevenir violaciones accidentales del contrato a través de refactorizaciones o renombramientos de campos.