Лучшие практики для эффективного кода на Golang. Часть 2

Первая часть статьи.

№ 11: обработка паник с помощью «Recover»

Чтобы корректно обрабатывать паники и предотвращать аварийные завершения программ, используйте функцию recover. В Go паники  —  это неожиданные ошибки времени выполнения, чреватые аварийным завершением программы. Однако имеется здесь и механизм корректной обработки паник  —  recover.

Вот простой пример:

package main

import "fmt"

// Функция, которая может «запаниковать»
func riskyOperation() {
defer func() {
if r := recover(); r != nil {
// Восстанавливаемся от паники и корректно ее обрабатываем
fmt.Println("Recovered from panic:", r)
}
}()

// Имитируем ситуацию паники
panic("Oops! Something went wrong.")
}

func main() {
fmt.Println("Start of the program.")

// Вызываем рискованную операцию в функции, которая восстанавливается после паник
riskyOperation()

fmt.Println("End of the program.")
}

№ 12: функции «init»

Избегайте использования функций init без необходимости, с ними усложняются понимание и сопровождение кода.

Переносите логику инициализации в обычные функции, вызываемые явно, как правило, из функции main. Так повышаются контроль и удобство восприятия кода, упрощается тестирование.

Покажем, как избежать функций init, в этой простой программе Go:

package main

import (
"fmt"
)

// Инициализируем конфигурацию с помощью «InitializeConfig».
func InitializeConfig() {
// Параметры конфигурации инициализируются здесь.
fmt.Println("Initializing configuration...")
}

// Инициализируем подключение к базе данных с помощью «InitializeDatabase».
func InitializeDatabase() {
// Подключение к базе данных инициализируется здесь.
fmt.Println("Initializing database...")
}

func main() {
// Вызываем функции инициализации явно.
InitializeConfig()
InitializeDatabase()

// Логика программы «main» находится здесь.
fmt.Println("Main program logic...")
}

№ 13: «defer» для очистки ресурсов

defer позволяет отложить выполнение функции до возвращения внешней функции, применяется обычно для закрытия файлов, разблокировки мьютексов или освобождения других ресурсов.

С defer обеспечивается выполнение действий очистки даже при наличии ошибок.

Создадим простую программу считывания данных из файла и применим defer, обеспечив этим корректное закрытие файла независимо от любых возникающих ошибок:

package main

import (
"fmt"
"os"
)

func main() {
// Открываем файл, меняем «example.txt» на название файла
file, err := os.Open("example.txt")
if err != nil {
fmt.Println("Error opening the file:", err)
return // При ошибке выходим из программы
}
defer file.Close() // Когда функция завершается, файл обязательно закрывается

// Считываем и выводим содержимое файла
data := make([]byte, 100)
n, err := file.Read(data)
if err != nil {
fmt.Println("Error reading the file:", err)
return // При ошибке выходим из программы
}

fmt.Printf("Read %d bytes: %s\n", n, data[:n])
}

Примечание: пример совершенствуется добавлением обработки ошибок в функцию defer при вызове file.Close()  —  имеется возможность возвращения ошибки.

№ 14: составной литерал против функций конструктора

Создавайте экземпляры структур с помощью составных литералов, а не функций конструктора.

Преимущества составных литералов

  1. Лаконичность.
  2. Удобство восприятия.
  3. Гибкость.

Вот простой пример:

package main

import (
"fmt"
)

// Определяем тип структуры, представляющей человека
type Person struct {
FirstName string // Имя
LastName string // Фамилия
Age int // Возраст
}

func main() {
// Создаем экземпляр «Person» с помощью составного литерала
person := Person{
FirstName: "John", // Инициализируем поле «FirstName»
LastName: "Doe", // Инициализируем поле «LastName»
Age: 30, // Инициализируем поле «Age»
}

// Вывод информации о человеке
fmt.Println("Person Details:")
fmt.Println("First Name:", person.FirstName) // Получаем доступ к полю имени и выводим его
fmt.Println("Last Name:", person.LastName) // Получаем доступ к полю фамилии и выводим ее
fmt.Println("Age:", person.Age) // Получаем доступ к полю возраста и выводим его
}

Примечание: есть мнение, что предпочитать составные литералы конструкторам опасно, так как это может привести к некорректным структурам. Вместо экспортирования типа Person следует обеспечить применение корректных значений с помощью экпортированного конструктора. К тому же конструкторы предпочтительны при выполнении проверок, скажем, диапазона значений или наличия чего-либо, а также зависимых операций, например логирования при создании объекта: конструкторы нужны здесь, чтобы убедиться в допустимости состояния объекта.

№ 15: параметры функций

В Go важно писать чистый и эффективный код. Один из способов делать это  —  минимизировать количество параметров функций, благодаря чему код становится удобнее для восприятия и сопровождения.

Вот простой пример:

package main

import "fmt"

// Структура «Option» для хранения параметров конфигурации
type Option struct {
Port int
Timeout int
}

// «ServerConfig» — это функция, которая принимает структуру «Option»
func ServerConfig(opt Option) {
fmt.Printf("Server configuration - Port: %d, Timeout: %d seconds\n", opt.Port, opt.Timeout)
}

func main() {
// Создание структуры «Option» со значениями по умолчанию
defaultConfig := Option{
Port: 8080,
Timeout: 30,
}

// Настройка сервера с параметрами по умолчанию
ServerConfig(defaultConfig)

// Изменение порта «Port» с помощью новой структуры «Option»
customConfig := Option{
Port: 9090,
}

// Настройка сервера с пользовательским значением «Port» и временем ожидания «Timeout» по умолчанию
ServerConfig(customConfig)
}

В этом примере определяется структура Option для параметров конфигурации сервера. Использование одной этой структуры вместо передачи в функцию ServerConfig многочисленных параметров добавляет коду возможностей для сопровождения и расширения.

№ 16: явные возвращаемые значения против именованных

В Go обычно используются именованные возвращаемые значения, но иногда это сказывается на ясности кода, особенно в больших кодовых базах.

Продемонстрируем разницу на простом примере:

package main

import "fmt"

// В «namedReturn» используются именованные возвращаемые значения.
func namedReturn(x, y int) (result int) {
result = x + y
return
}

// А в «explicitReturn» — явные.
func explicitReturn(x, y int) int {
return x + y
}

func main() {
// Именованные возвращаемые значения
sum1 := namedReturn(3, 5)
fmt.Println("Named Return:", sum1)

// Явные возвращаемые значения
sum2 := explicitReturn(3, 5)
fmt.Println("Explicit Return:", sum2)
}

Здесь две функции namedReturn и explicitReturn отличаются следующим:

  • В namedReturn используется именованное возвращаемое значение result. Хотя то, что возвращается функцией, здесь ясно, в функциях посложнее это не так очевидно.
  • В explicitReturn результат возвращается напрямую, так проще и понятнее.

№ 17: сложность функций

Сложностью функции называется степень запутанности, вложенности и разветвленности кода функции. Код с функциями низкой сложности удобнее для восприятия и сопровождения, менее подвержен ошибкам.

Вот простой пример:

package main

import (
"fmt"
)

// В «CalculateSum» возвращается сумма двух чисел.
func CalculateSum(a, b int) int {
return a + b
}

// В «PrintSum» выводится сумма двух чисел.
func PrintSum() {
x := 5
y := 3
sum := CalculateSum(x, y)
fmt.Printf("Sum of %d and %d is %d\n", x, y, sum)
}

func main() {
// Продемонстрируем минимальную сложность функции, вызвав функцию «PrintSum».
PrintSum()
}

В этом примере:

  1. Определяются две функции  —  CalculateSum и PrintSum  —  с конкретными задачами.
  2. CalculateSum  —  простая функция для вычисления суммы двух чисел.
  3. В PrintSum выводится вычисляемая с помощью CalculateSum сумма чисел 5 и 3.
  4. Низкая сложность функций обеспечивается их лаконичностью и акцентированностью на одной задаче, за счет этого повышаются удобство восприятия и сопровождаемость кода.

№ 18: затенение переменных

Затенение переменных происходит, когда в более узкой области объявляется новая переменная с тем же названием, и это чревато неожиданным поведением. Внешняя переменная скрывается за новой, делаясь недоступной в этой области.

Чтобы избежать путаницы, избегайте затенения переменных внутри вложенных областей.

Вот пример программы:

package main

import "fmt"

func main() {
// Объявляем внешнюю переменную «x» и инициализируем значением «10».
x := 10
fmt.Println("Outer x:", x)

// Вводим внутреннюю область с новой переменной «x», затеняющей внешнюю «x».
if true {
x := 5 // Затенение происходит здесь
fmt.Println("Inner x:", x) // Выводим внутреннюю «x» со значением «5».
}

// Внешняя «x» остается неизменной и по-прежнему доступна.
fmt.Println("Outer x after inner scope:", x) // Выводим внешнюю «x» со значением «10».
}

№ 19: интерфейсы для абстракции

Абстракция

Абстракция  —  это фундаментальная концепция Go, позволяющая определять поведение, не указывая детали реализации.

Интерфейсы

В Go интерфейс  —  это набор сигнатур методов.

Любой тип, реализующий все методы интерфейса, неявно соответствует этому интерфейсу.

Это позволяет писать код, который работает с разными типами, пока они придерживаются того же интерфейса.

Вот пример программы на Go, демонстрирующей применение интерфейсов для абстракции:

package main

import (
"fmt"
"math"
)

// Определяем интерфейс «Shape»
type Shape interface {
Area() float64
}

// Структура «Rectangle»
type Rectangle struct {
Width float64
Height float64
}

// Структура «Circle»
type Circle struct {
Radius float64
}

// Реализуем метод «Area» для прямоугольника
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}

// Реализуем метод «Area» для окружности
func (c Circle) Area() float64 {
return math.Pi * c.Radius * c.Radius
}

// Функция для вывода площади любой фигуры
func PrintArea(s Shape) {
fmt.Printf("Area: %.2f\n", s.Area())
}

func main() {
rectangle := Rectangle{Width: 5, Height: 3}
circle := Circle{Radius: 2.5}

// Вызоваем «PrintArea» в прямоугольнике и окружности, ими обоими реализуется интерфейс «Shape»
PrintArea(rectangle) // Выводится площадь прямоугольника
PrintArea(circle) // Выводится площадь окружности
}

В этой одной программе мы определяем интерфейс Shape, создаем две структуры  —  Rectangle и Circle, каждой из которых реализуется метод Area(), и функцией PrintArea выводим площадь любой фигуры, соответствующей интерфейсу Shape.

Примечание: в func PrintArea(s Shape) надо проверить интерфейс, если он nil, это чревато паникой.

Так применяются интерфейсы для абстракции в коде на Go, работающем с различными типами, которыми используется общий интерфейс.

№ 20: библиотечные пакеты и исполняемые файлы

Чтобы сохранять код на Go чистым и сопровождаемым, важно четко разделять пакеты и исполняемые файлы.

Вот пример структуры проекта с разделением библиотеки и исполняемого файла:

myproject/
├── main.go
├── myutils/
└── myutils.go

myutils/myutils.go:

// Объявление пакета — создается отдельный пакет для служебных функций
package myutils

import "fmt"

// Экспортированная функция для вывода сообщения
func PrintMessage(message string) {
fmt.Println("Message from myutils:", message)
}

main.go:

// Основная программа
package main

import (
"fmt"
"myproject/myutils" // Импортируем пользовательский пакет
)

func main() {
message := "Hello, Golang!"

// Из пользовательского пакета вызываем экспортированную функцию
myutils.PrintMessage(message)

// Демонстрируем логику основной программы
fmt.Println("Message from main:", message)
}
  1. В этом примере имеется два отдельных файла: myutils.go и main.go.
  2. В myutils.go определяется пользовательский пакет myutils, содержащий экспортированную функцию PrintMessage, которой выводится сообщение.
  3. main.go  —  исполняемый файл, которым импортируется пользовательский пакет myutils по его относительному пути "myproject/myutils".
  4. Функцией main в main.go из пакета myutils вызывается функция PrintMessage и выводится сообщение. Такое разделение обязанностей сохраняет код организованным и сопровождаемым.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Golang Da: Golang Best Practices (Top 20)

Предыдущая статья8 репозиториев, которые используют продвинутые React-разработчики
Следующая статья10 продвинутых приемов JavaScript для опытных разработчиков