09 октября 2023

🔝 Лучшие практики Go: путь к чистому коду

Энтузиаст-разработчик, автор статей по программированию. Сфера интересов - backend, web 3.0, кибербезопасность.
В статье познакомимся с рекомендациями по написанию чистого кода на Go. Разберемся на примерах с особенностями языка и применим на практике основные синтаксические конструкции.
🔝 Лучшие практики Go: путь к чистому коду

Работа с данными

Отличие make и new

Make и new – это встроенные механизмы для выделения памяти. Они используются в разных ситуациях и имеют свои особенности.

  • new инициализирует нулевое значение для данного типа и возвращает указатель на этот тип.
  • make используется исключительно для создания и инициализации срезов, отображений и каналов, возвращает ненулевой экземпляр указанного типа.
  • Основное отличие между ними состоит в том, что make возвращает инициализированный тип, готовый к использованию после создания, а new – указатель на тип с его нулевым значением.
        a := new(chan int)   // a имеет тип *chan int
b := make(chan int)  // b имеет тип chan int

    

Скрытые данные в слайсах

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

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

Рассмотрим эту особенность на конкретных примерах:

        // Плохая практика - непредсказуемое потребление памяти
func cutSlice() []byte {
	slice := make([]byte, 256)
	fmt.Println(len(slice), cap(slice), &slice[0]) // 256 256 <0x...>
	return slice[:10]
}

func main() {
	res := cutSlice()
	fmt.Println(len(res), cap(res), &res[0]) // 10 256 <0x...>
}

    

Для предотвращения возникшей ошибки следует удостовериться, что копирование производится из временного слайса:

        // Хорошая практика - данные скопированы из временного слайса
func cutSlice() []byte {
	slice := make([]byte, 256)
	fmt.Println(len(slice), cap(slice), &slice[0]) // 256 256 <0x...>
	copyOfSlice := make([]byte, 10)
	copy(copyOfSlice, slice[:10])
	return copyOfSlice
}

func main() {
	res := cutSlice()
	fmt.Println(len(res), cap(res), &res[0]) // 10 256 <0x...>
}

    
Библиотека Go разработчика
Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека Go разработчика»
Библиотека Go для собеса
Подтянуть свои знания по Go вы можете на нашем телеграм-канале «Библиотека Go для собеса»
Библиотека задач по Go
Интересные задачи по Go для практики можно найти на нашем телеграм-канале «Библиотека задач по Go»

Функции

Функции с множественным возвратом

Функции в языке Go могут возвращать несколько значений. Это называется «множественным возвратом». Данная особенность языка позволяет возвращать не только результат, но и дополнительные значения, такие как ошибки или другие необходимые данные.

Пример объявления функции с множественным возвратом в Go:

        package main

import "fmt"

func swap(a, b int) (int, int) {
	return b, a
}

func main() {
	x, y := swap(1, 2)
	fmt.Println(x, y) // 2 1

	a, _ := swap(3, 4)
	fmt.Println(a) // 4
}

    

В приведенном примере функция swap принимает два аргумента типа int и возвращает два значения того же типа, меняя местами исходные переменные.

Можно также игнорировать одно или несколько возвращаемых значений, используя пустой идентификатор (_).

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

Приведенная ниже функция openFile возвращает два значения, одно из которых – ошибка или nil в случае ее отсутствия.

        func openFile(name string) (*File, error) {
	file, err := os.Open(name)
	if err != nil {
		return nil, err
	}
	return file, nil
}

    

Интерфейсы

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

Используем интерфейсы правильно

💡Запомните важное правило
Не стоит определять интерфейсы до их использования. Без реального примера сложно понять, действительно ли они необходимы, не говоря уже о методах, которые должны в них содержаться.
        package worker  // worker.go

type Worker interface { Work() bool }

func Foo(w Worker) string { ... }
    
        package worker // worker_test.go

type secondWorker struct{ ... }
func (w secondWorker) Work() bool { ... }
...
if Foo(secondWorker{ ... }) == "value" { ... }
    

Ниже представлен пример неправильного подхода при работе с интерфейсами:

        // Плохая практика
package employer

type Worker interface { Worker() bool }

type defaultWorker struct{ ... }
func (t defaultWorker) Work() bool { ... }

func NewWorker() Worker { return defaultWorker{ ... } }

    

Верное решение с точки зрения Go — вернуть конкретный тип и позволить Worker имитировать реализацию employer:

        // Хорошая практика
package employer

type Worker struct { ... }
func (w Worker) Work() bool { ... }

func NewWorker() Worker {
	return Worker{
		...
	}
}
    

Конкурентность и параллелизм

Отслеживание горутин

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

💡 Запомните важное правило
Каждый раз, когда вы используете ключевое слово go в своей программе для запуска горутины, вы должны знать, как и когда она завершится.

Если вы не знаете ответа на два приведённых вопроса, это может привести к возникновению утечек памяти.

Обратимся к примеру для иллюстрации данной ошибки:

        func leakGoroutine() {
	ch := make(chan int)
	go func() {
		received := <- ch
		fmt.Println("Полученное значение:", received)
	}
}

    

Здесь функция leakGoroutine запускает горутину, которая блокирует чтение из канала ch . В результате в него ничего не отправится, и сам он никогда не закроется. Горутина будет заблокирована навсегда, вызов функции fmt.Println никогда не произойдет.

Обнаружение утечек

Инженеры из Uber, которые принимают активное участие в развитии Go, создали детектор утечек горутин – пакет goleak, нацеленный на интеграцию с модульными тестами. Рассмотрим пример работы с этим инструментом на практике.

Пусть есть некая функция leakGoroutin с утечкой горутины:

        func leakGoroutine() {
	go func() {
		time.Sleep(time.Minute)
	}()

	return nil
}

    

И тест этой функции:

        func TestLeakGoroutine(t *Testing.T) {
	defer goleak.VerifyNone(t)

	if err := leak(); err != nil {
		t.Fatal("Fatal message")
	}
}

    

При запуске тестов появляется сообщение об ошибке found enexpected goroutines , где указывается вершина стека с проблемной горутиной, ее состояние и идентификатор.

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

Обработка ошибок и восстановление

Ошибки в Go представлены интерфейсом error, который определяет метод Error() string. Любой тип, реализующий этот метод, может быть использован как ошибка.

        type error interface {
    Error() string
}

    
  • Чтобы больше узнать об ошибках в Go, рекомендуется прочитать статью «Исключения в Go – это легко?». Из нее вы узнаете о том, как эффективно решать проблемные ситуации в программах.

Обрабатываем ошибки правильно

Игнорирование ошибок может привести к неопределенному поведению и усложнить отладку кода. Рассмотрим правильный способ обработки ошибок на примере работы с файлом:

        // плохо
file, err := os.Open("filename.txt")
if err == nil {
    // операции с файлом
}

    
        // хорошо
file, err := os.Open("filename.txt")
if err != nil {
	log.Fatal(err) // обработка ошибки
}
defer f.Close() // отложенный вызов функции для закрытия файла

    

Без паники, но с восстановлением

Классический способ сообщить об ошибке – вернуть тип error. Но что делать в тех случаях, когда её нельзя быстро восстановить? Тогда на помощь приходит встроенная функция panic (часто её называют просто «паника»), которая завершает программу и выводит настраиваемое сообщение об ошибке.

Ниже представлен пример простой функции с паникой:

        package main

import "fmt"

func examplePanic() {
  panic("Паника - программа завершена")
  fmt.Println("Функция examplePanic успешно завершилась")  
}

func main() {
  examplePanic()
  fmt.Println("Функция main успешно завершилась")
}

    

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

Чтобы продемонстрировать работу данного механизма, обратимся к примеру:

        package main

import "fmt"

func Recovery() {
	if recoveryResult := recover(); recoveryResult != nil {
		fmt.Println(recoveryResult)
	}
	fmt.Println("Восстановление...")
}

func Panic() {
	defer Recovery()
	panic("Паника")
	fmt.Println("Функция Panic успешно завершилась")
}

func main() {
	Panic()
	fmt.Println("Функция main успешно завершилась")
}

    

В результате выполнения кода мы получим следующий вывод:

        Паника
Восстановление...
Функция main успешно завершилась

    

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

Заключение

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

Хочется верить, что статья вдохновит читателей применять описанные практики в разработке на Go и создавать программы, в которых будет нетрудно разобраться даже новичку. И помните, чистый код – это путь к успешному проекту!

Материалы по теме

Комментарии

ВАКАНСИИ

Добавить вакансию
AppSec Business Partner
по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ