golang

Go: передача значений VS передача указателей

  • пятница, 12 января 2024 г. в 00:00:18
https://habr.com/ru/companies/it-guide/articles/744046/

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

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

Читаемость

Параметр

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

func foo(ctx context.Context, user *User, order *Order) (*Receipt, error) {
    // ...
}

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

Разумеется, никто не мешает вызывать функцию

func foo(ctx context.Context, user User, order Order) (Receipt, error) {
    // ...
}

как foo(context.TODO(), User{}, Order{}), однако это хотя бы сообщает, что в функцию должны быть переданы non-nil значения, а валидация отдельных полей - уже ответственность самой функции. Однако уже можно быть уверенным, что при доступе к user и order паник не будет, и явные проверки типа if order == nil { return } уже не нужны.

Возвращаемое значение

При возврате функцией значения вместо указателя, написание return Receipt{}, err занимает лишь немногим больше времени, чем return nil, err, однако у вызывающей стороны не будет необходимости проверять значение на nil (кто из нас не делал return nil, nil хотя бы раз в жизни?), не будет необходимости разыменовывать указатель при передаче куда-либо. Преимущества тут уже не настолько видны, т. к. принято возвращать либо значение и ошибку nil, либо значение nil/пустое значение и ошибку.

Производительность

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

Однако на практике данное решение может привести к менее производительному коду. Например, если взять маппинг двух структур:

type User struct {
	ID                              int64
	CreatedAt, UpdatedAt, DeletedAt time.Time

	FirstName, SecondName, Patronymic string
	Birthday                          time.Time
	Nationality                       string
	UserType                          int

	Balance     *big.Rat
	BonusPoints *big.Rat
}

type UserDTO struct {
	ID                              int64
	CreatedAt, UpdatedAt, DeletedAt time.Time

	FirstName, SecondName, Patronymic string
	Birthday                          time.Time
	Nationality                       string
	UserType                          int

	Balance     *big.Rat
	BonusPoints *big.Rat

	FullName               string
	BalanceWithBonusPoints *big.Rat
}

func UserToDTO(u User) UserDTO {
	return UserDTO{
		ID:        u.ID,
		CreatedAt: u.CreatedAt,
		UpdatedAt: u.UpdatedAt,
		DeletedAt: u.DeletedAt,

		FirstName:   u.FirstName,
		SecondName:  u.SecondName,
		Patronymic:  u.Patronymic,
		Birthday:    u.Birthday,
		Nationality: u.Nationality,
		UserType:    u.UserType,

		Balance:     u.Balance,
		BonusPoints: u.BonusPoints,

		FullName:               u.FirstName + " " + u.SecondName + " " + u.Patronymic,
		BalanceWithBonusPoints: new(big.Rat).Add(u.Balance, u.BonusPoints),
	}
}

func DTOToUser(d UserDTO) User {
	return User{
		ID:        d.ID,
		CreatedAt: d.CreatedAt,
		UpdatedAt: d.UpdatedAt,
		DeletedAt: d.DeletedAt,

		FirstName:   d.FirstName,
		SecondName:  d.SecondName,
		Patronymic:  d.Patronymic,
		Birthday:    d.Birthday,
		Nationality: d.Nationality,
		UserType:    d.UserType,

		Balance:     d.Balance,
		BonusPoints: d.BonusPoints,
	}
}

func UserPtrToDTO(u *User) *UserDTO {
	return &UserDTO{
		ID:        u.ID,
		CreatedAt: u.CreatedAt,
		UpdatedAt: u.UpdatedAt,
		DeletedAt: u.DeletedAt,

		FirstName:   u.FirstName,
		SecondName:  u.SecondName,
		Patronymic:  u.Patronymic,
		Birthday:    u.Birthday,
		Nationality: u.Nationality,
		UserType:    u.UserType,

		Balance:     u.Balance,
		BonusPoints: u.BonusPoints,

		FullName:               u.FirstName + " " + u.SecondName + " " + u.Patronymic,
		BalanceWithBonusPoints: new(big.Rat).Add(u.Balance, u.BonusPoints),
	}
}

func DTOPtrToUser(d *UserDTO) *User {
	return &User{
		ID:        d.ID,
		CreatedAt: d.CreatedAt,
		UpdatedAt: d.UpdatedAt,
		DeletedAt: d.DeletedAt,

		FirstName:   d.FirstName,
		SecondName:  d.SecondName,
		Patronymic:  d.Patronymic,
		Birthday:    d.Birthday,
		Nationality: d.Nationality,
		UserType:    d.UserType,

		Balance:     d.Balance,
		BonusPoints: d.BonusPoints,
	}
}

и такой бенчмарк:

func BenchmarkMapValues(b *testing.B) {
	var (
		user = createUser()
		res  pointers.User
	)

	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		dto := pointers.UserToDTO(user)
		res = pointers.DTOToUser(dto)
	}

	_ = res
}

func BenchmarkMapPointers(b *testing.B) {
	var (
		user = createUser()
		res  *pointers.User
	)

	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		dto := pointers.UserPtrToDTO(&user)
		res = pointers.DTOPtrToUser(dto)
	}

	_ = res
}

func createUser() pointers.User {
	return pointers.User{
		ID:        1,
		CreatedAt: time.Now(),
		UpdatedAt: time.Now(),
		DeletedAt: time.Now(),

		FirstName:   "John",
		SecondName:  "Doe",
		Patronymic:  "Smith",
		Birthday:    time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC),
		Nationality: "Russian",
		UserType:    1,

		Balance:     big.NewRat(1000, 1),
		BonusPoints: big.NewRat(100, 1),
	}
}

и запустить его вот так:

go test -benchmem -bench . ./...

то можно получить вот такой результат:

goos: windows
goarch: amd64
pkg: articles/src/pointers
cpu: AMD Ryzen 7 5800H with Radeon Graphics
BenchmarkMapValues-16            4174956               289.7 ns/op           288 B/op          8 allocs/op
BenchmarkMapPointers-16          3207511               385.7 ns/op           704 B/op         10 allocs/op

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

Уберем из функций операции, приводящие к дополнительному выделению памяти в куче:

Код
type User_NoHeap struct {
	ID                              int64
	CreatedAt, UpdatedAt, DeletedAt time.Time

	FirstName, SecondName, Patronymic string
	Birthday                          time.Time
	Nationality                       string
	UserType                          int

	Balance     *big.Rat
	BonusPoints *big.Rat
}

type UserDTO_NoHeap struct {
	ID                              int64
	CreatedAt, UpdatedAt, DeletedAt time.Time

	FirstName, SecondName, Patronymic string
	Birthday                          time.Time
	Nationality                       string
	UserType                          int

	Balance     *big.Rat
	BonusPoints *big.Rat
}

func UserToDTO_NoHeap(u User_NoHeap) UserDTO_NoHeap {
	return UserDTO_NoHeap{
		ID:        u.ID,
		CreatedAt: u.CreatedAt,
		UpdatedAt: u.UpdatedAt,
		DeletedAt: u.DeletedAt,

		FirstName:   u.FirstName,
		SecondName:  u.SecondName,
		Patronymic:  u.Patronymic,
		Birthday:    u.Birthday,
		Nationality: u.Nationality,
		UserType:    u.UserType,

		Balance:     u.Balance,
		BonusPoints: u.BonusPoints,
	}
}

func DTOToUser_NoHeap(d UserDTO_NoHeap) User_NoHeap {
	return User_NoHeap{
		ID:        d.ID,
		CreatedAt: d.CreatedAt,
		UpdatedAt: d.UpdatedAt,
		DeletedAt: d.DeletedAt,

		FirstName:   d.FirstName,
		SecondName:  d.SecondName,
		Patronymic:  d.Patronymic,
		Birthday:    d.Birthday,
		Nationality: d.Nationality,
		UserType:    d.UserType,

		Balance:     d.Balance,
		BonusPoints: d.BonusPoints,
	}
}

func UserPtrToDTO_NoHeap(u *User_NoHeap) *UserDTO_NoHeap {
	return &UserDTO_NoHeap{
		ID:        u.ID,
		CreatedAt: u.CreatedAt,
		UpdatedAt: u.UpdatedAt,
		DeletedAt: u.DeletedAt,

		FirstName:   u.FirstName,
		SecondName:  u.SecondName,
		Patronymic:  u.Patronymic,
		Birthday:    u.Birthday,
		Nationality: u.Nationality,
		UserType:    u.UserType,

		Balance:     u.Balance,
		BonusPoints: u.BonusPoints,
	}
}

func DTOPtrToUser_NoHeap(d *UserDTO_NoHeap) *User_NoHeap {
	return &User_NoHeap{
		ID:        d.ID,
		CreatedAt: d.CreatedAt,
		UpdatedAt: d.UpdatedAt,
		DeletedAt: d.DeletedAt,

		FirstName:   d.FirstName,
		SecondName:  d.SecondName,
		Patronymic:  d.Patronymic,
		Birthday:    d.Birthday,
		Nationality: d.Nationality,
		UserType:    d.UserType,

		Balance:     d.Balance,
		BonusPoints: d.BonusPoints,
	}
}

Также возьмем структуры размером 2 КБ, 8 КБ и 1 МБ (функции также без дополнительного выделения памяти в куче):

type User2KB struct {
	Data [2048]byte
}

type UserDTO2KB struct {
	Data [2048]byte
}

func UserToDTO2KB(u User2KB) UserDTO2KB {
	return UserDTO2KB{
		Data: u.Data,
	}
}

func DTOToUser2KB(d UserDTO2KB) User2KB {
	return User2KB{
		Data: d.Data,
	}
}

func UserPtrToDTO2KB(u *User2KB) *UserDTO2KB {
	return &UserDTO2KB{
		Data: u.Data,
	}
}

func DTOPtrToUser2KB(d *UserDTO2KB) *User2KB {
	return &User2KB{
		Data: d.Data,
	}
}

Результат при этом получается вот таким:

goos: windows
goarch: amd64
pkg: articles/src/pointers
cpu: AMD Ryzen 7 5800H with Radeon Graphics
BenchmarkMapValues1MB-16                    9324            123896 ns/op               0 B/op          0 allocs/op
BenchmarkMapPointers1MB-16                  5163            218242 ns/op         2097156 B/op          2 allocs/op
BenchmarkMapValues2KB-16                 6429981               186.2 ns/op             0 B/op          0 allocs/op
BenchmarkMapPointers2KB-16               2866360               416.5 ns/op          2048 B/op          1 allocs/op
BenchmarkMapValues8KB-16                 2202045               544.4 ns/op             0 B/op          0 allocs/op
BenchmarkMapPointers8KB-16                773160              1548 ns/op            8192 B/op          1 allocs/op
BenchmarkMapValuesNoHeap-16             41105602                28.50 ns/op            0 B/op          0 allocs/op
BenchmarkMapPointersNoHeap-16           18602142                58.65 ns/op          192 B/op          1 allocs/op
BenchmarkMapValues-16                    3729990               330.7 ns/op           288 B/op          8 allocs/op
BenchmarkMapPointers-16                  2999234               396.9 ns/op           704 B/op         10 allocs/op

Также я взял большую структуру из кода на работе (которую не покажу), которая по размеру немного меньше 1 КБ. Результаты замены единственной строки (func convert(x *X) *Y -> func convert(x X) Y) таковы (третий бенчмарк - это передача *&X{}, а в остальном он аналогичен первому):

goos: windows
goarch: amd64
pkg: supercompany/code
cpu: AMD Ryzen 7 5800H with Radeon Graphics
BenchmarkValue-16                                2789080               411.7 ns/op           432 B/op          7 allocs/op
BenchmarkPointer-16                              1767438               673.8 ns/op          1136 B/op          8 allocs/op
BenchmarkDereferencePointerToValue-16            2450964               422.3 ns/op           432 B/op          7 allocs/op

Fizzbuzz

Как можно написать серьезную статью без как минимум одного сложного и научного алгоритма? Реализуем fizzbuzz!

Сделаем две реализации: одна будет по возможности использовать передачу структур по значению (копирование), а вторая - передачу по указателю, а затем сравним их производительность.

Реализация
type ControllerReq struct {
	From, To int
}

type ControllerResp struct {
	Values map[int]string
}

type logicReq struct {
	value int
}

type logicResp struct {
	value string
}

func ValueController(ctx context.Context, req *ControllerReq) (*ControllerResp, error) {
	res := make(map[int]string, req.To-req.From)
	for i := req.From; i < req.To; i++ {
		x, err := valueLogic(ctx, logicReq{i})
		if err != nil {
			return nil, err
		}

		res[i] = x.value
	}

	return &ControllerResp{res}, nil
}

func valueLogic(ctx context.Context, req logicReq) (logicResp, error) {
	var (
		divisibleBy3 = req.value%3 == 0
		divisibleBy5 = req.value%5 == 0
	)
	switch {
	case divisibleBy3 && divisibleBy5:
		return logicResp{"fizzbuzz"}, nil
	case divisibleBy3:
		return logicResp{"fizz"}, nil
	case divisibleBy5:
		return logicResp{"buzz"}, nil
	default:
		return logicResp{strconv.FormatInt(int64(req.value), 10)}, nil
	}
}

func PtrController(ctx context.Context, req *ControllerReq) (*ControllerResp, error) {
	res := make(map[int]string, req.To-req.From)
	for i := req.From; i < req.To; i++ {
		x, err := ptrLogic(ctx, &logicReq{i})
		if err != nil {
			return nil, err
		}

		res[i] = x.value
	}

	return &ControllerResp{res}, nil
}

func ptrLogic(ctx context.Context, req *logicReq) (*logicResp, error) {
	var (
		divisibleBy3 = req.value%3 == 0
		divisibleBy5 = req.value%5 == 0
	)
	switch {
	case divisibleBy3 && divisibleBy5:
		return &logicResp{"fizzbuzz"}, nil
	case divisibleBy3:
		return &logicResp{"fizz"}, nil
	case divisibleBy5:
		return &logicResp{"buzz"}, nil
	default:
		return &logicResp{strconv.FormatInt(int64(req.value), 10)}, nil
	}
}
Бенчмарк
func BenchmarkValue(b *testing.B) {
	var (
		req  = &fizzbuzz.ControllerReq{From: 1, To: 100}
		resp *fizzbuzz.ControllerResp
		err  error
	)

	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		resp, err = fizzbuzz.ValueController(context.TODO(), req)
	}

	_, _ = resp, err
}

func BenchmarkPointer(b *testing.B) {
	var (
		req  = &fizzbuzz.ControllerReq{From: 1, To: 100}
		resp *fizzbuzz.ControllerResp
		err  error
	)

	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		resp, err = fizzbuzz.PtrController(context.TODO(), req)
	}

	_, _ = resp, err
}

Результат:

goos: windows
goarch: amd64
pkg: articles/src/fizzbuzz
cpu: AMD Ryzen 7 5800H with Radeon Graphics
BenchmarkValue-16         323662              3313 ns/op            4227 B/op          4 allocs/op
BenchmarkPointer-16       204608              5862 ns/op            5811 B/op        103 allocs/op

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

Расширяемость

Под расширяемостью я понимаю способность кода не требовать модификаций при изменении требований или связанного с ним кода.

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

Консистентность

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

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

Потенциальные ошибки

Повсеместное использование указателей в качестве параметров приводит к выбору: необходимо либо проверять каждый параметр на равенство nil, либо допускать, что произойдёт паника при попытке разыменования указателя nil.

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