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