Лучший опыт

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

Первая часть статьи. № 11: обработка паник с помощью «Recover» Чтобы корректно обрабатывать паники и предотвращать аварийные завершения программ, используйте функцию recover. В Go паники  —  это неожиданные ошибки времени выполнения, чреватые аварийным завершением программы. Однако имеется здесь и механизм корректной обработки паник  —  recover. Вот простой пример: package main import "fmt" // Функция, которая может «запаниковать» func riskyOperation() { defer func
Лучшие практики для эффективного кода на 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 и выводится сообщение. Такое разделение обязанностей сохраняет код организованным и сопровождаемым.