Проблема

В golang нет undefined/none, из-за чего структуры, функции обычные и переменные нельзя использовать гибко - нет синтаксического сахара, как в python. Есть значение nil, но оно тоже не дает понимания, было ли значение передано или нет, так как golang по умолчанию задает значения переменным или полям структуры, например:

дана структура

type Person struct {
Name string
Position string
}

person := Pesron{Name: "Robert"}

при получении поля выдается значение по умолчанию (поле Position)

fmt.Println(person.Name) // Robert
fmt.Println(person.Position) // пустая строка

Детализация проблемы

Задаем два экземпляра структуры

person1 := Pesron{Position: "Junior"}
person2 := Pesron{}

результат

fmt.Println(person1.Position) // пустая строка
fmt.Println(person2.Position) // пустая строка

но поле Position не было ведь задано во втором случае! 

Есть вариант сделать поле Position через pointer

type Person struct {
Name string
Position *string
}

Тогда
position1 := "Junior"
person1 := Pesron{Position: "Junior"}
position2 := ""
person2 := Pesron{Position: ""}
person3 := Pesron{}

fmt.Println(person1.Position) // Junior
fmt.Println(person2.Position) // ""
fmt.Println(person3.Position) // nil

Естественно, в данном случае уже есть различие, но есть ситуации, где nil может быть использован тоже как значение!

Где это критично

Дано:
пакеты pgx, squirell, uuid

Имеется таблица в БД

CREATE TABLE persons(
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
position VARCHAR(255) NULL
);

Имеется dto для получения списка []Person

type GetPersonListDTO struct {
Name string
Position *string
Limit int
Offset int
}

Метод репозитория для работы с БД

func (r *Repository) GetPersonList(ctx context.Context, dto GetPersonListDTO) []Person {
query := sq.Select("id", "name", "position").From("persons").
Where(sq.And{sq.Eq{"name": dto.Name}}).
  Where(sq.And{sq.Eq{"position": pgtype.Text{String: *dto.Position, Valid: true}}).
  Limit(dto.Limit)
  Offset(dto.Offset)
sqlString, , := query.ToSql()

// далее сделать запрос в БД
}

Рассмотрим случаи, где это критично

Случай 1: Передача опциональных параметров в запрос

dto := GetPersonListDTO{Name: "Robert"}

r.GetPersonList(ctx, dto)


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

SELECT id, name, position FROM persons WHERE name = 'Robert' AND position = '' LIMIT 0 OFFSET 0

так как структура GetPersonListDTO имеет значения по умолчанию

dto.Position // пустая строка
dto.Limit // 0
dto.Offset // 0


Но для получения результата был передан всего один аргумент - Name. Как решить эту проблему?

Случай 2: Изменим функцию для обработки переданных аргументов и структуру GetPersonListDTO

 type GetPersonListDTO struct {
Name *string
Position *string
Limit *int
Offset *int
}

func (r Repository) GetPersonList(ctx context.Context, dto GetPersonListDTO) []Person {
query := sq.Select("id", "name", "position").From("persons")
if dto.Name != nil {
query = query.Where(sq.And{sq.Eq{"name": dto.Name}})
}
if dto.Position != nil {
query = query.Where(sq.And{
sq.Eq{
"position": pgtype.Text{String: dto.Position, Valid: true}}})
}
if dto.Limit != nil {
query = query.Limit(*dto.Limit)
}
if dto.Offset != nil {
query = query.Offset(*dto.Offset)
}}
sqlString, , := query.ToSql()

// далее сделать запрос в БД
}

Передача аргументов в функцию

dto := GetPersonListDTO{Name: "Robert"}
r.GetPersonList(ctx, dto)

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

SELECT id, name, position FROM persons WHERE name = 'Robert'

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

Но поле position в БД имеет NULL значение. Вопрос: как отфильтровать по условию WHERE position = null ?
Проверка значения dto.Position != nil уже не работает, так как если передавать в функцию dto := GetPersonListDTO{Name: "Robert"}, то значениеdto.Position будет равно по умолчанию nil.

То есть в данном случае нет возможности никак проверить, заполнено поле в dto или нет, даже nil значением, и соответственно, нет возможности получить из БД данные, отфильтрованные по nil.



Из вышесказанного, имеем:
1. Есть заполнение по умолчанию
2. Есть возможность обойтись через nil в некоторых случаях
3. Nil накладывает ограничения на использование опциональности

То есть golang не дает полноценный функционал для опциональной работы с полями из-за особенностей языка.

Решение проблемы

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

type OptionalType[T any] struct {
Value T
Defined bool
Valid bool
}


Из чего состоит этот тип

Value - значение
Valid - флаг, является ли переданное значение nil
Defined - флаг, который показывает, было ли поле определено или нет

Функция, с помощью которой можно создать опциональный тип

func NewOptionalType[T any](v T) OptionalType[T] {
if v == nil {
return OptionalType[*T]{nil, true, false}
}
return OptionalType[*T]{v, true, true}
}

Этот тип можно заставить работать как стандартный тип golang. Для этого мною был написан пакет github.com/denpa16/optional-go-type. Ниже - как работать с ней.

Библиотека optional-go-type

Заполнение структур

Библиотека уже имеет опциональные типы, построенные на встроенных типах golang

например:


import optionalType "github.com/denpa16/optional-go-type"

type Person struct {
Name optionalType.OptionalString
Position optionalType.OptionalString
Age optionalType.OptionalInt
}


создание экземпляра структуры

import optionalType "github.com/denpa16/optional-go-type"
name := "Robert"
position := "Junior"
age := 20

person1 := Person{

Name: optionalType.NewOptionalString(&name)
  Position: optionalType.NewOptionalString(&name)
  Age: optionalType.NewOptionalInt(&age)
}
person2 := Person{
Name: optionalType.NewOptionalString(&name)
  Position: optionalType.NewOptionalString(nil)
  Age: optionalType.NewOptionalInt(nil)
}
person3 := Person{
Name: optionalType.NewOptionalString(&name)
}


Как отрабатывают опциональные поля

В первом случае

fmt.Println(person1.Position.Value) // Junior
fmt.Println(person1.Position.Valid) // true
fmt.Println(person1.Position.Defined) // true

person1.Position.Valid = true, так как значение не равно nil
person1.Position.Defined = true, так как значение было определено

Во втором случае

fmt.Println(person2.Position.Value) // Junior
fmt.Println(person2.Position.Valid) // true
fmt.Println(person2.Position.Defined) // true

person2.Position.Valid = false, так как значение равно nil
person2.Position.Defined = true, так как значение было определено

В третьем случае

fmt.Println(person2.Position.Value) // пустая строка
fmt.Println(person2.Position.Valid) // false
fmt.Println(person2.Position.Defined) // false

person2.Position.Valid = false, так как значение равно nil
person2.Position.Defined = false, так как значение НЕ было определено

Маршаллинг и анмаршаллинг

type Person struct {
Name optionalType.OptionalString `json:"name"`
Position optionalType.OptionalString `json:"position"`
Age optionalType.OptionalInt `json:"age"`
}

json.Unmarshall

jsonData := []byte({"Name": "Robert"})
person := Person{}
_ = json.Unmarshal(jsonData, &person)

person.Name.Value // Robert
person.Name.Defined // true
person.Name.Valid // true

person.Position.Value // nil - так как в json не было передано это поле
person.Position.Defined // false - так как в json не было передано это поле
person.Position.Valid // false - так как в json не было передано это поле


json.Marshall

name := "Robert"
person := Person{
Name: optionalType.NewOptionalString(&name)
}

result, _ := json.Marshal(person)
// Output: {"Name":"Robert"}


Маршаллинг и анмаршаллинг работают как обычно со всеми своими тэгами

Как использовать в критичных местах

Как выглядит метод репозитория (из случая 1 и случая 2 выше) с использованием OptionalType

type GetPersonListDTO struct {
Name OptionalString
Position OptionalString
Limit OptionalInt
Offset OptionalInt
}

func (r Repository) GetPersonList(ctx context.Context, dto GetPersonListDTO) []Person {
query := sq.Select("id", "name", "position").From("persons")
if dto.Name.Defined != nil {
query = query.Where(sq.And{sq.Eq{"name": dto.Name.Value}})
}
if dto.Position.Defined != nil {
query = query.Where(sq.And{
sq.Eq{
"position": pgtype.Text{String: dto.Position.Value, Valid: true}}})
}
if dto.Limit.Defined != nil {
query = query.Limit(*dto.Limit.Value)
}
if dto.Offset.Defined != nil {
query = query.Offset(*dto.Offset.Value)
}}
sqlString, , := query.ToSql()

// далее сделать запрос в БД
}

Передача аргументов в функцию

1. Передача части параметров

dto := GetPersonListDTO{Name: "Robert"}
r.GetPersonList(ctx, dto)

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

SELECT id, name, position FROM persons WHERE name = 'Robert'

2. Передача части параметров с nil для фильтрации по NULL

name := "Robert"
dto := GetPersonListDTO{Name: &name, Position: nil}
r.GetPersonList(ctx, dto)

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

SELECT id, name, position FROM persons WHERE name = 'Robert' AND position = NULL

3. Передача части Nullable параметров со значением

name := "Robert"
position := "Junior"
dto := GetPersonListDTO{Name: &name, Position: &position}
r.GetPersonList(ctx, dto)

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

SELECT id, name, position FROM persons WHERE name = 'Robert' AND position = 'Junior'

Таким образом, есть возможность:

1. Не фильтровать вообще, если параметр не был передан для формирования запроса
2. Формировать запрос с NULL значением
3. Формировать запрос с переданным значением в NULLABLE поле

OptionalType позволяет гибко работать со структурами и функциями, где нельзя обойтись использованием только nil и значением по умолчанию

Исходный код пакета

Как добавить пакет в свой go проект

go get github.com/denpa16/optional-go-type

Комментарии (17)


  1. 40kTons
    05.08.2025 10:23

    Переизобрели шарповский Nullable на гошный манер?


    1. denpavlov16 Автор
      05.08.2025 10:23

      Может быть, сам не силен в шарпах
      Библиотека создавалась из потребности решить свои проблемы неопределенности, а также узнать как эту проблему обходят другие
      Постарался сделать удобно и в общем виде, может кто еще будет пользоваться


      1. wmns
        05.08.2025 10:23

        Эту проблему другие не обходят, потому что это не проблема.


        1. denpavlov16 Автор
          05.08.2025 10:23

          Может быть у других просто нет таких кейсов
          Либо они делают костыли
          Либо у них есть команда инфраструктуры, которая делает свои либы

          как и писал выше, хотел узнать как делают другие


  1. ertaquo
    05.08.2025 10:23

    https://github.com/samber/mo вам в помощь. Буквально то же самое - опции, результаты и действия над ними. Заодно и https://github.com/samber/lo - кучка удобных функций :)


    1. denpavlov16 Автор
      05.08.2025 10:23

      Спасибо за наводку!
      Будем исследовать и изучать как можно внедрить в свои проекты!


  1. uvelichitel
    05.08.2025 10:23

    Можно использовать конструкторы
    func NewPerson() *Person
    и точно знать во что поля установлены по дефолту.
    В вашем подходе можно поэкономить память. Тип bool в Go это alias int8. То есть вам не нужно две переменных bool для флагов. Одной int8 хватит на восемь бинарных флагов с применением битовых масок. Так сделано например в стандартном пакете os для масок атрибутов файла https://pkg.go.dev/os#pkg-constants Идея проста
    const Defined int8 = 1
    const Valid int8 = 2
    var Mark int8
    Mark += Valid
    if Mark&Defined != 0 {//false}
    if Mark&Valid != 0 {//true}


    1. denpavlov16 Автор
      05.08.2025 10:23

      Спасибо за комментарий!
      Да, это красиво
      Но писать конструктор для каждой структуры не всегда хочется
      + при конструкторе nil всегда будет
      а как понять, nil был прокинут ранее или по дефолту задан?
      Хочется просто работать как с обычными типами, у которых просто внутри заложен этот механизм

      Насчет оптимизации памяти - постараюсь доработать в следующих версиях


  1. evgeniy_kudinov
    05.08.2025 10:23

    Просьба, оформите код в статье с подсветкой синтаксиса.


  1. vvardenfell
    05.08.2025 10:23

    А почему здесь "пустая строка" в person1.Position? Вы же инициализировали "Junior". Ну и опечатки

    Здесь код не скомпилируется даже, переменные position1 и position2 не используются:


  1. qrKot
    05.08.2025 10:23

    type Person struct { 
       Name string       
       Position *string   
    }
    
    position1 :=  "Junior"
    person1 := Pesron{Position: "Junior"} // ТУТ НЕ СКОМПИЛИРУЕТСЯ
    position2 :=  ""
    person2 := Pesron{Position: ""}  // И ТУТ ТОЖЕ
    person3 := Pesron{}

    Ну блин, ну код-то свой запустить можно попробовать разок?

    Ну и "библиотеку" посмотрел:

    OptionalInt   = OptionalType[*int]
    OptionalBool = OptionalType[*bool]

    Кхм... bool-значение, занимающее 16 байт в стеке и еще 8 в куче. А может, ну его, этот ваш паттерн Optional (ну либо уже просто возьмите существующие реализации, и не парьте мозг). Ну и да, раздельные поля defined/valid нафиг не нужны (ну либо вы мне сейчас расскажете, зачем инициализировать переменную невалидным значением).


    1. denpavlov16 Автор
      05.08.2025 10:23

      Спасибо за комментарий! Ошибки есть, не спорю

      Но Вы поругаться пришли?) Или так, свое мнение оставить? Я Вас не заставляю качать библиотеку и использовать
      Вроде как Хабр свободная площадка

      Лучше бы предложили свое решение проблемы касаемо запросов в БД - как делать универсальную функцию, которая будет в зависимости от заполненности/незаполненности параметров формировать sql запрос

      Или как Вы узнаете, поле было заполнено nil-ом или нет?
      Ощущение, что Вы на go пишете просто посредственные вещи без каких-то паттернов, DRY, SOLID и прочее


      1. qrKot
        05.08.2025 10:23

        Лучше бы предложили свое решение проблемы касаемо запросов в БД

        sqlc

        Или как Вы узнаете, поле было заполнено nil-ом или нет?

        Зачем?

        Ощущение, что Вы на go пишете просто посредственные вещи без каких-то паттернов, DRY, SOLID и прочее

        Ну вот про SOLID: буковка S говорит о том, что в реализации паттерна Optional одно из полей valid/defined - лишнее.

        type Optional[T any] struct {
        	Value T
        	IsSet bool
        }

        Все, готовый Optional. Всяческие OptionalBool и OptionalInt - не нужны, тем более в том виде, который вы использовали.

        OptionalInt = OptionalType[*int]

        Вот это вы зачем написали? Зачем тут указатель на int? Из принципа "что-то у меня оперативки многовато" или "а давайте посмотрим, насколько говенный у нас сборщик мусора"?

        Просто вопрос: сколько памяти и где занимает ваш OptionalInt?


        1. denpavlov16 Автор
          05.08.2025 10:23

          Вы не углубились в суть проблемы, раз пишете об этом


    1. denpavlov16 Автор
      05.08.2025 10:23

      Готовые решения - знал бы я о них раньше - не писал бы свою либу

      "ну либо вы мне сейчас расскажете, зачем инициализировать переменную невалидным значением" - ощущение, что Вы не пишете продакшн код


      1. qrKot
        05.08.2025 10:23

        отовые решения - знал бы я о них раньше - не писал бы свою либу

        И эти люди...

        "ну либо вы мне сейчас расскажете, зачем инициализировать переменную невалидным значением" - ощущение, что Вы не пишете продакшн код

        Ну т.е. не расскажете...


        1. denpavlov16 Автор
          05.08.2025 10:23

          Вы не углубились в суть проблемы, раз пишете об этом