Использование генериков в Go

Generics — это часть большинства основных языков программирования, позволяющая использовать типы в качестве параметров функций, классов, структур и так далее. Исторически GoLang был известен как популярный и более новый язык программирования, без дополнительной сложности генериков, что в некоторых кругах считалось бонусом. Тем не менее, все изменилось, когда вышла версия 1.18 с новой блестящей функцией: дженериками. Они настолько новые, что даже стандартные библиотеки еще не полностью перешли на дженерики. Я решил опробовать дженерики и решить проблему, которую я еще не видел: загрузка переменных окружения и их красноречивая обработка до нужных типов. Оговорка: это был мой первый эксперимент с дженериками в Go, так что воспринимайте его с долей соли. Поехали.

Синтаксис

Прежде всего, такие языки, как Java, имеют в своей ДНК аргументы generic-типа, настолько, что практически все можно расширить с помощью аргументов generic-типа, однако в Go подход несколько иной. Первое ограничение заключается в том, что дженерики не могут быть использованы в методах, только в функциях. Некоторые могут счесть это неважным, учитывая, что методы — это просто синтаксический сахар для определения одной и той же функции с «экземпляром» в качестве первого аргумента и последующего прямого вызова с экземпляром, переданным в качестве параметра. Это немного более многословный процесс, но зато позволяет выполнить работу. Однако сегодня мы этого делать не будем. Вы можете добавлять параметры общих типов в функции и делать типы общими. Эта система немного ограничена, но она полностью соответствует философии Go: чем меньше вещей вы можете делать, тем меньше способов решения проблем вы можете придумать, и, следовательно, ваш код будет ближе к чужому. В сегодняшней заметке я исследую добавление аргументов общих типов в функции.

Для этого я могу использовать следующий синтаксис:

// here I pass in T as a generic argument
func At[T any](arg []T, i int) T {
  return arg[i]
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Следует отметить пару моментов. Каждый раз, когда вы передаете параметр типа, вы также должны определить, чему он соответствует. Чтобы сделать это немного проще, команда GoLang сопоставила наш любимый тип interface{} с ключевым словом any, которое будет соответствовать всему, что вы ему передадите. Вы можете использовать этот механизм, чтобы убедиться, что переданный аргумент соответствует определенному интерфейсу, например, comparable, если вы хотите сортировать, упорядочивать или фильтровать вещи.

Вы также можете определить тип входных данных и передать их в общие типы или превратить их в срезы.

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

Вы также можете указать функции, что возвращаемый тип имеет какое-то отношение к родовому аргументу.

Ленивый Env

Моя библиотека будет называться lazyenv. Это небольшая библиотека с единственной целью: позволить пользователю получать переменные окружения ленивым способом — кэшируя их при первом обращении к переменной, а также преобразовывая их в нужный тип красноречивым способом.

Давайте рассмотрим пример того, чего я пытаюсь достичь. Скажем, у нас есть переменная среды BUSLINES=7,8,108,133,907,908,932,956,973,990 и в моей программе я хочу иметь список этих автобусных линий. Теперь я знаю, что все шины имеют положительное число между 0-999, что означает, что мы можем использовать беззнаковое целое число. 8 бит не покроют весь наш диапазон, но 16 бит покроют. В псевдокоде я хочу, чтобы интерфейс моей библиотеки выглядел следующим образом:

buslines = get "BUSLINES" as uint16
Вход в полноэкранный режим Выход из полноэкранного режима

Проектирование интерфейса

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

buslines := lazyenv.Get("BUSLINES").Uint16Slice()
Вход в полноэкранный режим Выход из полноэкранного режима

Чтобы реализовать нечто подобное, мы должны сделать следующее

package lazyenv

import (
    "os"
    "strconv"
    "strings"
)

type env struct {
    value string
}

func Get(key string) env {
    value := os.Getenv(key)
    return env{value}
}

func (e env) Uint16Slice() []uint16 {
    vals := strings.Split(e.value, ",")
    var ret []uint16
    for _, v := range vals {
        i, _ := strconv.ParseUint(v, 10, 16)
        ret = append(ret, uint16(i))
    }
    return ret
}


var buslines = Get("BUS_LINES").Uint16Slice()
Войти в полноэкранный режим Выйти из полноэкранного режима

Но подождите, а где же дженерики?

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

Следующее, что я хочу добавить, это возможность определить значение по умолчанию, которое будет возвращаться, если переменная окружения пуста.

Шаблон конструктора

Шаблон, который мы здесь используем, основан на шаблоне строителя. В паттерне builder мы используем базовый объект, класс или структуру для накопления информации, которую мы создаем. Мы используем методы экземпляра построителя для «создания» значений. Когда мы закончили построение, мы завершаем результат, вызывая определенный метод или в некоторых случаях несколько методов могут дать результат, как в моем примере, где мы можем завершить построение до .String(), .Uint16Slice() или .Interface() среди прочих.


type env struct {
    value string
    defaultValue interface{}
}

func (e env) Default(value interface{}) env {
    e.defaultValue = value
    return e
}
Вход в полноэкранный режим Выход из полноэкранного режима

Давайте добавим некоторые общие характеристики:

type env[T any] struct {
    value string
    defaultValue T
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Теперь структура env поддерживает общий аргумент. Это осветит наш код, как рождественскую елку. Чтобы удовлетворить компилятор, нам нужно везде указывать этот общий аргумент.

func Get[T any](key string) env[T] {
    value := os.Getenv(key)
    var defaultValue T
    return env[T]{value, defaultValue}
}
Вход в полноэкранный режим Выход из полноэкранного режима

Наши методы строителей теперь выглядят следующим образом:

func (e env[T]) Default(value T) env[T] {
    e.defaultValue = value
    return e
}

func (e env[T]) Uint16Slice() []uint16 {
    vals := strings.Split(e.value, ",")
    var ret []uint16
    for _, v := range vals {
        i, _ := strconv.ParseUint(v, 10, 16)
        ret = append(ret, uint16(i))
    }
    return ret
}
Войти в полноэкранный режим Выйти из полноэкранного режима

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

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

Вот как теперь выглядит наш код для получения этой переменной:

var buslines = Get[[]uint16]("BUS_LINES").Default([]uint16{}).Uint16Slice()
Войти в полноэкранный режим Выйти из полноэкранного режима

Что странно в этом коде, так это то, что мы должны объявить наш тип дважды.

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

На самом деле ничто не мешает вам предоставить []uint16 в качестве значения по умолчанию, но попросить вернуть .String(). Наше значение по умолчанию и struct уже типизированы в этой точке, но, к сожалению, у нас нет способа взять инициализатор для этого типа, потому что информация о типе не является динамической во время выполнения, это — опять же, просто синтаксический сахар, который позволяет вам написать функцию с той же сигнатурой один раз, но в действительности будет работать так же, как если бы вы ввели эту функцию на месте. Другими словами, следующее просто невозможно:

func AccessType[T any](t T) {
    switch T {
    case uint16:
        fmt.Println("uint16")
    case string:
        fmt.Println("string")
    }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Это подводит нас к рассматриваемой проблеме:

Другая моя претензия ко всему этому подходу — расширяемость. В Swift можно расширить любой интерфейс, и все это будет просто волшебным образом соединено вместе. Однако Swift — гораздо более сложный язык, а Go не позволяет сделать подобное, поэтому использование паттерна builder будет означать, что в конечном итоге эта библиотека будет ограничена теми связками, которые я добавлю в нее в качестве методов finaliser. Нельзя прийти и предоставить сторонние отображения для различных типов.

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

Композиция функций

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

  1. Это делает программу более ленивой — это означает, что если нам в конечном итоге не понадобится значение по умолчанию, то программе не придется его инициализировать. Компромисс заключается в том, что если наше значение по умолчанию будет просто примитивным значением или чем-то простым и не ресурсоемким, то в итоге мы получим больше накладных расходов, однако если, например, мы хотим получить значения из API, если среда не предоставляет их, мы можем сделать это красноречивым способом, не жертвуя производительностью.
  2. Это позволяет использовать композицию функций для управления поведением логики возврата. Например: это позволяет нам сделать так, чтобы значение по умолчанию не возвращалось, но вместо этого программа возвращала ошибку или мы даже могли сразу запаниковать.

Так почему бы просто не использовать параметры для точной настройки поведения?

Если вы уже поверили в композицию функций, можете пропустить этот параграф, но для скептиков представляю вашему вниманию:

// a simple interface
type get [T any]func (key string) (T, error)
Вход в полноэкранный режим Выход из полноэкранного режима

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

type get [T any]func (key string, returnError bool, panicOnMissing bool) T
Войти в полноэкранный режим Выйти из полноэкранного режима

Куда девается значение по умолчанию?

type get [T any]func (key string, returnError bool, panicOnMissing bool, defaultValue T) T
Вход в полноэкранный режим Выйти из полноэкранного режима

Теперь наш вызов выглядит следующим образом:

Get[string]("test", true, false, "")
Ввести полноэкранный режим Выход из полноэкранного режима

Наша реализация, вероятно, также беспорядочна, и нет никаких ограничений, мешающих нам передать true для обоих вариантов поведения, а также добавить значение по умолчанию, и в этом случае, какой из них имеет приоритет?

var val string
if returnError && panicOnMissing {
    panic(errors.New("returnError and panicOnMissing cannot both be true"))
}
if returnError {
    return val, errors.New("")
}
if panicOnMissing {
    panic("")
}
return val, nil
Войти в полноэкранный режим Выйти из полноэкранного режима

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

type get [T any]func (key string, onMissing func(string) (T, error)) (T, error)
Войти в полноэкранный режим Выход из полноэкранного режима

Давайте попробуем реализовать функцию panic on missing value:

func OrPanic[T any](key string) (T, error) {
    panic(fmt.Errorf("%s is not set", key))
}
Вход в полноэкранный режим Выход из полноэкранного режима

Чтобы использовать его, можно передать его в наш геттер, например, так:

Get("EXAMPLE", OrPanic[string])
Enter fullscreen mode Выйти из полноэкранного режима

Обратите внимание, здесь мы инстанцируем OrPanic с типом string, что также позволяет Get вывести тип или T. Это обязательно, иначе Go не знал бы, какой тип ему нужно применить во время сборки.

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

func OrError[T any](key string) (T, error) {
    var value T
    return value, fmt.Errorf("failed to get %s", key)
}
Вход в полноэкранный режим Выйти из полноэкранного режима

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

Get("EXAMPLE", OrError[string])
Войти в полноэкранный режим Выйти из полноэкранного режима

Наконец, мы можем предоставить значение по умолчанию, например, так:

func OrReturn[T any] (defaultValue T) func(key string) (T, error) {
    return func(key string) (T, error) {
        return defaultValue, nil
    }
}

Ввести полноэкранный режим Выйти из полноэкранного режима

Обратите внимание, как мы используем фабрику для создания функции, которая соответствует сигнатуре, необходимой нам для параметра, который мы передаем в Get. Вот как выглядит использование:

Get("BUSLINES", OrReturn([]uint16{}))
Вход в полноэкранный режим Выход из полноэкранного режима

Здесь следует отметить одну важную вещь: поскольку мы передаем параметр, который мы аннотировали аргументом типа T, нам не нужно также инстанцировать параметр типа, благодаря инференции.

Давайте сопоставим эти первоначальные значения string с чем-то более полезным. Сначала попробуем использовать int. Те из вас, кто знаком с библиотекой strconv, заметят, что она поставляется с функцией, которую мы можем использовать как есть, потому что она соответствует нашей сигнатуре маппера. Я позволю вам угадать, что это такое. Оставьте комментарий ниже, если вы угадали правильно!

type Mapper[T any] func(value string) (T, error)
Вход в полноэкранный режим Выход из полноэкранного режима

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

func Get[T any](key string, getDefaultValue GetDefaultValue[T], mapper Mapper[T]) (T, error)
Войти в полноэкранный режим Выйти из полноэкранного режима

Мы можем использовать ее в таком виде:

func String(value string) (string, error) {
    return value, nil
}

var s, err = Get("EXAMPLE", Optional[string], String)
Войти в полноэкранный режим Выйти из полноэкранного режима

strconv.Atoi также придерживается этого интерфейса, так что можно сразу отобразить на int:

var i, err = Get("NUMBER", Optional[int], strconv.Atoi)
Войти в полноэкранный режим Выйти из полноэкранного режима

Но мы можем сделать это более гибко и просто держать под рукой функцию для этой конкретной задачи:

func Int(value string) (int, error) {
    return strconv.Atoi(value)
}
Enter fullscreen mode Выйти из полноэкранного режима

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

Я собираюсь разработать функцию, которая принимает Mapper в качестве аргумента и возвращает срез типа того, к чему привязан Mapper.

func SliceOf[T any](mapper Mapper[T]) Mapper[[]T]
Вход в полноэкранный режим Выход из полноэкранного режима

Затем я могу взять эту функцию и передать ее в мой Get, чтобы сохранить красноречие (это вообще слово?) этой библиотеки, добавив при этом новую функциональность. Имейте в виду, что сейчас я просто добавляю поверх уже существующей «платформы», которая является расширяемым API, разработанным мной ранее. Теоретически можно реализовать любой маппер или фабрику мапперов, исходя из своих конкретных потребностей.

Последний пример для моего случая использования:

buslines := Get("BUSLINES", Required[[]int], SliceOf(Int))
Вход в полноэкранный режим Выход из полноэкранного режима

Двусмысленность

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


type GetDefaultValueParams struct {
    Key string
}

type GetDefaultValue[T any] func(params GetDefaultValueParams) (T, error)
Вход в полноэкранный режим Выход из полноэкранного режима

Замечание о тестах

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

Заключение

Я лично являюсь большим поклонником дженериков. Система дженериков в Go является спорной, потому что она отвлекает от простоты языка, для которого она была разработана. Кроме того, она не такая гибкая и «умная», как другие. Система generics в Java более надежна, а система generics в TypeScript гораздо более активна в выводах, что позволяет набирать меньше кода. Долгосрочный эффект от наличия дженериков как части языка будет положительным, особенно после того, как стандартная библиотека будет переработана для использования дженериков.

Если вы хотите посмотреть реализацию этой библиотеки или просто хотите использовать ее в своем проекте, я разместил весь исходный код на GitHub.

Оцените статью
devanswers.ru
Добавить комментарий