July 7

Обзор реализаций конструкторов объектов в Go

Functional Options

Источники:
1. https://dave.cheney.net/2014/10/17/functional-options-for-friendly-apis
2. https://rednafi.com/go/configure_options/

Начнём с наиболее распространённого в Go подхода — Functional Options.

type User struct {
	// Обязательный параметр
	Email string

	// Опциональные параметры
	Name   string
	Public bool
}

type UserOption func(*User) error

func WithName(name string) UserOption {
	return func(u *User) error {
		u.Name = name
		return nil
	}
}

func WithPublicFlag(u *User) error {
	u.Public = true
	return nil
}

func NewUser(email string, options ...UserOption) (*User, error) {
	// Инициализируем объект с обязательными полями.
	// Здесь же можно задать дефолтные значения для опциональных полей.
	u := &User{
		Email: email,
	}

	for _, option := range options {
		if err := option(u); err != nil {
			return nil, err
		}
	}

	return u, nil
}

func main() {
	user, _ := NewUser("ivan@example.com")
	fmt.Printf("%+v\n", user)
	// &{Email:ivan@example.com Name: Public:false}

	user, _ = NewUser("ivan@example.com", WithName("Ivan Ivanich"), WithPublicFlag)
	fmt.Printf("%+v\n", user)
	// &{Email:ivan@example.com Name:Ivan Ivanich Public:true}
}

Преимущества:

  • Валидация ввода (поддержка возврата ошибки)

Недостатки:

  • IDE не показывает доступные опции конструктора, нужно явно просматривать структуру UserOption
  • На практике, With-опций вызываются с префиксом названия пакета, что может выглядеть чуждо

Configurable Object

Источники:
1. https://rednafi.com/go/dysfunctional_options_pattern/

Реализация с отдельной структурой для хранения опциональных параметров:

type User struct {
	// Обязательный параметр
	Email string

	UserOptions
}

// Опциональные параметры
type UserOptions struct {
	Name   string
	Public bool
}

func (userOption UserOptions) WithName(name string) UserOptions {
	userOption.Name = name
	return userOption
}

func (userOption UserOptions) WithPublic() UserOptions {
	userOption.Public = true
	return userOption
}

func NewUser(email string, options UserOptions) *User {
	// Описываем объект с обязательными полями.
	// Здесь же можно задать дефолтные значения для опциональных полей.
	u := &User{
		Email:       email,
		UserOptions: options,
	}

	return u
}

func main() {
	user := NewUser("ivan@example.com", UserOptions{})
	fmt.Printf("%+v\n", user)
	// &{Email:ivan@example.com UserOptions:{Name: Public:false}}

	user = NewUser("ivan@example.com", UserOptions{}.WithName("Ivan Ivanich").WithPublic())
	fmt.Printf("%+v\n", user)
	// &{Email:ivan@example.com UserOptions:{Name:Ivan Ivanich Public:true}}
}

Выявляется менее наглядная работа с ошибками, валидацию в With-опции встроить не получится, только описать на уровне NewUser().

Также сигнатура конструктора обязывает передавать options, даже в неиспользуемом случае. Здесь можно упомянуть альтернативу — Builder pattern, реализующую цепочку методов, мутирующих основную структуру:

type User struct {
	// Обязательный параметр
	Email string

	// Опциональные параметры
	Name   string
	Public bool
}

func (u *User) WithName(name string) *User {
	u.Name = name
	return u
}

func (u *User) WithPublicFlag() *User {
	u.Public = true
	return u
}

func NewUser(email string) *User {
	return &User{
		Email: email,
	}
}

func main() {
	user := NewUser("ivan@example.com")
	fmt.Printf("%+v\n", user)
	// &{Email:ivan@example.com Name: Public:false}

	user = NewUser("ivan@example.com").WithName("Ivan Ivanich").WithPublicFlag()
	fmt.Printf("%+v\n", user)
	// &{Email:ivan@example.com Name:Ivan Ivanich Public:true}
}

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

Также важным недостатком, объединяющим оба примера, является смешивание методов конструктора и основной структуры в автодополнении IDE.

Структура Options

Источники:
1. https://asankov.dev/post/different-ways-to-initialize-go-structs

Вместо дополнительного кода ради Configurable Object, напрашивается использование простой структуры:

type User struct {
	// Обязательный параметр
	Email string

	UserOptions
}

// Опциональные параметры
type UserOptions struct {
	Name   string
	Public bool
}

func NewUser(email string, options UserOptions) *User {
	u := &User{
		Email:       email,
		UserOptions: options,
	}

	return u
}

func main() {
	user := NewUser("ivan@example.com", UserOptions{})
	fmt.Printf("%+v\n", user)
	// &{Email:ivan@example.com UserOptions:{Name: Public:false}}

	user = NewUser("ivan@example.com", UserOptions{
		Name:   "Ivan Ivanich",
		Public: true,
	})
	fmt.Printf("%+v\n", user)
	// &{Email:ivan@example.com UserOptions:{Name:Ivan Ivanich Public:true}}
}

Если мы имеем дело с приватными полями, поможет прокси-структура:

type User struct {
	email, name string
	public      bool
}

// Опциональные параметры
type UserOptions struct {
	Name   string
	Public bool
}

func NewUser(email string, options UserOptions) *User {
	u := &User{
		email: email,
	}

	// Такой метод сравнения работает если структура состоит только из comparable-полей
	//   - https://stackoverflow.com/questions/28447297/how-to-check-for-an-empty-struct
	if options == (UserOptions{}) {
		return u
	}

	if options.Name != "" {
		u.name = options.Name
	}

	if options.Public != false {
		u.public = options.Public
	}

	return u
}

func main() {
	user := NewUser("ivan@example.com", UserOptions{})
	fmt.Printf("%+v\n", user)
	// &{Email:ivan@example.com UserOptions:{Name: Public:false}}

	user = NewUser("ivan@example.com", UserOptions{
		Name:   "Ivan Ivanich",
		Public: true,
	})
	fmt.Printf("%+v\n", user)
	// &{Email:ivan@example.com UserOptions:{Name:Ivan Ivanich Public:true}}
}

Из недостатков, сохраняется обязательность аргумента options в конструкторе.