image

Недавно я закончил читать книгу Тейвы Харсаньи "100 ошибок и как их избежать", и вместо того, чтобы писать рецензию (всем, кто работает с Go, стоит ее прочитать), я решил поделиться четырьмя ошибками, которые показались мне интересными и о которых я раньше не знал.

#13. Создание пакетов утилит


Поэтому я упорно придерживаюсь принципа: в большинстве своих проектов я пишу пакеты утилит в тот момент, когда какой-либо фрагмент кода используется более одного раза. При этом я обычно называл пакет utils, если он выполнял несколько общих (в контексте API) операций, таких как форматирование, валидация и т.д. Из этой книги и советов других разработчиков я понял, что понятие util не имеет смысла. Его можно назвать common, shared или base, но это все равно останется бессмысленным названием, которое не описывает ничего, связанного с тем, что предоставляет API. Например,

package util

func NewStringSet(...string) map[string]struct{} { ... }
func SortStringSet(map[string]struct{}) []string { ... }

// client.go
set := util.NewStringSet("A", "B", "C")
fmt.Println(util.SortStringSet(set))

Вместо этого вспомогательный пакет следует разбить на части (если есть несколько конкурирующих обязанностей) и переименовать во что-то более выразительное и понятное, например, в stringset в данном случае. В дальнейшем, возможно, чисто из вредности, я предпочитаю структурировать свои пакеты по принципу «родитель (интерфейс | контекст) -> ребенок (реализация)», поэтому stringset можно найти по следующему пути: your_project/pkg/utils/stringset. Возможно, в следующей статье «Еще больше ошибок в Go» будет доказано, что это тоже плохой проект; а пока!

package stringset

func New(...string) map[string]struct{} { ... }
func Sort(map[string]struct{}) []string { ... }

#26. Утечка мощности слайса


Несмотря на то, что я работаю с Go в профессиональном контексте уже более двух лет, я до сих пор не удосужился изучить различия между слайсами и массивами. Я с уверенностью могу сказать, что в той или иной функции или блоке кода я не знаю, какие из них используются и какие лучше использовать. Поэтому, дойдя до этого раздела книги, где подробно описываются различные типы данных и связанные с ними типичные ошибки, мне пришлось провести небольшое исследование на тему "«Слайсы» и «Массивы»". Эндрю Герранд в книге "Go Slices: usage and internals" описывает слайсы и массивы следующим образом:

Тип «слайс» — это абстракция, построенная поверх типа массива в Go, поэтому для понимания слайсов необходимо сначала разобраться с массивами. В определении типа массива указывается длина и тип элемента.

Например, тип [4]int представляет собой массив из четырех целых чисел. Размер массива фиксирован, а длина является частью его типа ([4]int и [5]int — разные, несовместимые типы). Массивы можно индексировать обычным способом, поэтому выражение s[n] открывает доступ к n-му элементу, начиная с нуля. Массивы имеют свое место, но они несколько негибки, поэтому в коде на Go они встречаются нечасто. А вот срезы встречаются повсеместно. Они строятся на основе массивов и обеспечивают большую мощность и удобство. Спецификация типа для слайса — []T, где T — тип элементов слайса. В отличие от типа массива, тип слайса не имеет определенной длины.

Итак, немного во всём этом разобравшись, давайте уточним, как это относится к leak capacity?
Ссылаясь на исходный текст из репозитория "Github: teivah/100-go-mistakes", Тейва объясняет,

Операция слайсинга над msg с использованием msg[:5] создает слайс длиной пять. Однако его емкость остается такой же, как и у исходного фрагмента. Оставшиеся элементы все равно выделяются в памяти, даже если в конечном итоге на msg не ссылаются

func consumeMessages() {
 for {
  msg := receiveMessage()
  // Что-то делаем с сообщением
  storeMessageType(getMessageType(msg))
 }
}

func getMessageType(msg []byte) []byte {
    return msg[:5]
}

func receiveMessage() []byte {
    return make([]byte, 1_000_000)
}

func storeMessageType([]byte) {}

func printAlloc() {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    fmt.Printf("%d KB\n", m.Alloc/1024)
}

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

Рассмотрим пример с сообщением большой длины — 1 млн. байт. После операции слайсинга базовый массив все еще содержит 1 млн. байт. Следовательно, если мы храним в памяти 1000 сообщений, то вместо 5 КБ мы храним около 1 ГБ.

Рекомендуемый подход для решения этой проблемы? Используйте копирование слайсов!

func getMessageTypeWithCopy(msg []byte) []byte {
    msgType := make([]byte, 5)
    copy(msgType, msg)
    return msgType
}

#46. Использование имени файла в качестве ввода функции


Даже в облачно-ориентированной среде Kubernetes я сталкиваюсь с написанием функций, очень похожих на пример Теивы. Простая функция, которая считывает содержимое файла с любым именем, указанным в качестве аргумента. На самом деле, я собирался объяснить некоторые из своих разочарований по поводу проекта, но автор так хорошо объяснил один из самых больших подводных камней в этом отрывке,

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

func countEmptyLinesInFile(filename string) (int, error) {
    file, err := os.Open(filename)
    if err != nil {
        return 0, err
    }
    // Обрабатываем замыкание файла

    scanner := bufio.NewScanner(file)
    for scanner.Scan() {
    // ...
    }

    return 0, nil
}

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

Итак, какой проект лучше для таких API? Сделать так, чтобы функция принимала аргумент io.Reader! Прочитав это объяснение, я на самом деле почувствовал облегчение от того, насколько это лучший проект!

func countEmptyLines(reader io.Reader) (int, error) {     
    scanner := bufio.NewScanner(reader)
    for scanner.Scan() {
          // ...
    }
}

#54. Отсутствие обработки ошибок отсрочки


defer — это часто используемое ключевое слово в Go, однако я никогда не слышал об ошибках с отложенным типом. На самом деле, если бы вы спросили меня до прочтения этой главы, я бы не поверил, что можно использовать тип ошибки с defer. Это распространенная ошибка, поскольку в десятках и десятках кодовых баз я видел паттерны, которые не справляются с ошибками defer. Итак, как мы можем улучшить ситуацию? Используя приведенный пример,

func getBalance(db *sql.DB, clientID string) (
    float32, error) {
    rows, err := db.Query(query, clientID)
    if err != nil {
        return 0, err
    }
    defer rows.Close()
 
    // Используем строки
}

Интерфейс Closer, реализованный в примере типом *sql.Rows, включает функцию closer.Closer, которая возвращает ошибку. При этом, как видно из приведенного примера, никакой обработки возвращаемого значения ошибки не происходит. Однако если вызов функции defer подразумевает набор логических действий, которые должны произойти до возврата из функции, как обработать ошибку, не потеряв контекст и не потеряв уже существующую ошибку?

func getBalance3(db *sql.DB, clientID string) (balance float32, err error) {
    rows, err := db.Query(query, clientID)
    if err != nil {
        return 0, err
    }
    
    defer func() {
        closeErr := rows.Close()
        if err != nil {
            if closeErr != nil {
                log.Printf("failed to close rows: %v", err)
            }
            return
        }
        err = closeErr
    }()

    // Используем строки
    return 0, nil
}  

Ошибка rows.Close присваивается другой переменной: closeErr. Прежде чем присвоить ее переменной err, мы проверяем, отличается ли err от nil. Если это так, то ошибка уже была возвращена функцией getBalance, поэтому мы решаем записать err в лог и вернуть существующую ошибку.

Мне показалось интересным объяснение проекта, поскольку он включает в себя не очень распространенную особенность языка go, называемую возвратами. Я слышал, что эта особенность является антипаттерном для простого дизайна, или запахом кода, которого следует избегать, но у меня самого нет реального мнения, хотя у меня никогда не было необходимости использовать ее, так что здесь я могу признать свою неопытность. Вместо этого я сошлюсь на "GeeksforGeeks: Именованные параметры возврата в Golang".

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

Таким образом, вместо того чтобы возвращать nil в самом конце функции, она будет возвращать значение err после выполнения функции defer func(). При этом значение err будет обновлено статусом этой операции, если не существует другого значения err.

P.S.
Также обращаем ваше внимание на то, что у нас на сайте проходит распродажа.

Комментарии (10)


  1. noRoman
    27.09.2023 13:45
    +2

    Ошибка rows.Close присваивается другой переменной: closeErr. Прежде чем присвоить ее переменной err, мы проверяем, отличается ли err от nil. Если это так, то ошибка уже была возвращена функцией getBalance, поэтому мы решаем записать err в лог и вернуть существующую ошибку.

    Странный момент. Как по мне лучше вернуть и closeErr, если таковая имеется.
    err = errors.Join(err, closeErr)
    И уже вызывающая сторона решит как реагировать на ошибку.

    Можно создать для этого ошибку
    var cErr = errors.New("Close error")
    Узнать была ли она if errors.In(err, cErr) ...

    или через type CloseError struct ... если нужны подробности ошибки
    Тогда проверка if _, ok := err.(*CloseError); ok ...

    Лень расписывать, кто пишет на golang, тот поймет.

    Короче пример очень плохой обработки ошибки (


    1. qrKot
      27.09.2023 13:45

      а почему не errors.As/Is?


  1. tumbler
    27.09.2023 13:45
    +1

    С утечкой capacity тоже фигня какая-то... Обещали копию всего слайса, а по-факту, копируется только структура с указателем, длиной и размером.

    https://go.dev/play/p/MDLJQ6whbwR

    Или я что-то не так понял?


    1. Demacr
      27.09.2023 13:45

      Тут скорее вот это https://go.dev/play/p/z1pFsj7eUwN имеется в виду


      1. tumbler
        27.09.2023 13:45
        +1

        Тогда причем там аллокация гигабайтов памяти? В общем, складывается впечатление, что автор оригинала не совсем понимает то, что пишет.


        1. Demacr
          27.09.2023 13:45

          А ведь, кстати, да. Объект создаётся новый, но ссылается на тот же массив. Т.е. при изменении b, изменится и a. https://go.dev/play/p/K-DVpiwX1n2


    1. ollegio
      27.09.2023 13:45
      +2

      Я думаю что там подразумевается что storeMessageType складывает полученный слайс куда-то ещё в память, и GC не придёт за изначальным мегабайтным слайсом данных, т.к. на него ещё висит ссылка в виде 5-элементного слайса, который где-то висит в памяти


    1. dim0xff
      27.09.2023 13:45
      +2

      Как я понял там речь не про копию слайса, а о том, что если вы решите слайс из 5 элементов (который образован из слайса на 1мб через [:5]) где-то сохранить на длительное время и остальная часть от 1мб вам никогда не понадобится, то gc память не очистит.

      При этом, если создадите новый слайс и скопируете 5 элементов, то с условием выше (остальная часть от 1мб вам никогда не понадобится) gc очистит лишнюю память.

      Проверить варианты (в 12 строке true и !true):

      https://go.dev/play/p/hxh4kWBhPaO


      1. tumbler
        27.09.2023 13:45

        Пока что эта гипотеза наиболее правдоподобна, спасибо!


  1. mrobespierre
    27.09.2023 13:45

    func getMessageType(msg []byte) []byte {
    return msg[:5]
    }

    func receiveMessage() []byte {
    return make([]byte, 1_000_000)
    }

    func storeMessageType([]byte) {}

    Понимаю, что пример синтетический, но нельзя такое в книгах писать: новички видят, потом используют. Во-первых слайс байт, который мы передаём, он же откуда-то взялся. Скорее всего из ридера. "Принимай интерфейсы, отдавай структуры". Автор другим советует принимать интерфейс (вместо файла), а себе не смог. Во-вторых в Go не принято принуждать к аллокациям. А если я а хочу 10 тыс раз сделать receiveMessage()? А если я точно знаю, что буду делать их по очереди и хотел бы переиспользовать слайс вместо его многократного выделения?