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

Рассмотрим какие проблемы возникают у таких функций и возможные пути их решения.

Configuration

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

type Client interface {
   Do()
}
type client struct{
   address string
}
func(c *client) Do(){}

Теперь нужна функция-конструктор для настройки и создания нашего сервиса:

func NewClient(addr string) Client {
   return &client{address: addr}
}

В общем неплохо получилось.

Проходит время и появляется необходимость добавить дополнительные настройки. Менять функцию NewClient нельзя, так как она является внешним интерфейсом, который уже используют. Придется добавить больше конструкторов. Отсутствия перегрузки функций(function overloading) в Go, еще сильней осложняет ситуацию.

func NewClientWithOptions(addr string, timeout, retries int) Client {
   return &client{
      address: addr,
      retries: retries,
      timeout: timeout,
   }
}

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

This is an image

Часто используют одну структуру в качестве набора опций:

type ClientConfig struct{
   timeout int
   retries int
}

func NewClient(addr string, cfg ClientConfig) Client {
   return &client{
      address: addr,
      retries: cfg.retries,
      timeout: cfg.timeout,
   }
}

Для добавления новых опций, нужно расширить ClientConfig и основную функцию. При этом внешнее API сервиса не меняется.

Получилось хорошее решение, но и в нем есть недостатки.

Часть настроек имеет значения по умолчанию. Но у нас нет возможности отличить нулевые значения от установленных в 0.

NewClient(":", ClientConfig{
   timeout:0,
})
NewClient(":", ClientConfig{})

Еще нужно всегда создавать ClientConfig.

Частично эту проблему можно решить — изменив параметр на указатель и передавать nil.

NewClient(":", nil)

Но тут появляется еще больше неопределенности.

Теперь мы можем менять общие переменные. Какое поведения от этого ожидать, как правило не понятно:

cfg := ClientConfig{
   retries: 1,
}
NewClient(":", &cfg)
cfg.retries=2 // ????

Все еще необходимо передавать два аргумента. Чаще используют именно вариант со стандартными настройками и второй аргумент будет всегда nil.

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

Попробуем решить эти проблемы.

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

А передавать мы будем функции — которые принимают указатель на клиента и устанавливают нужное нам значение:

type Option func(*client)

Сигнатура NewClient измениться:

func NewClient(addr string, opts ...Option) Client

С помощью замыкания сохраняем аргументы:

func WithTimeout(t int) Option {
   return func(c *client) {
      c.timeout = t
   }
}
func WithRetries(r int) Option{
   return func(c *client) {
      c.retries = r
   }
}

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

Вызов функций происходит в конструкторе NewClient:

func NewClient(addr string, opts ...Option) Client {
   c := client{address: addr}

   for _, opt := range opts {
      opt(&c)
   }

   return &c
}

Теперь можно включать любое кол-во опций и передавать их в любом порядке.

NewClient(":")

NewClient(":", WithRetries(5))

NewClient(":", WithTimeout(10), WithRetries(5))

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

Описана базовая вариация, которая подходит в большинстве случаев.

Но возможно различные доработки.

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

type Option func(*client) error
func SetCheatMode(c *client) error {
   return c.CheatMode()
}

а уже в конструкторе будет ее обработчик:

for _, opt := range opts {
   err := opt(&c)
   if err!=nil{
      log.Printf("error NewClient:%s", err)
   }
}

Так же есть варианты, когда опции должны вернуть какое то значение, например, предыдущей вариант. Подробней можно прочитать в статье Роба Пайка Self referential functions and design.

Полный код на github


Источники