Принятие пользовательских типов в Go | Амплеев Евгений - Agile Coach / Full stack web developer. Персональный блог.
Читаете:
Принятие пользовательских типов в Go
Поделиться:

Принятие пользовательских типов в Go

Avatar
Автор статьи: Yevgeniy Ampleev
25 марта 2025 в 10:22
Принятие пользовательских типов в Go

Аннотация

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

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

Целевая аудитория

Эта статья предназначена для тех, кто работал с типизированными языками и практически не имеет опыта работы с Go.


Проблематика

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

Давайте начнем определять необходимый минимум для этого упражнения:


type Product struct {
	ID   int
	Name string
}

type User struct {
	ID       int
	Username string
}
                        

type ProductReview struct {
	ProductID int
	UserID    int
	Review    string
}
                        

Примечание: Для этого примера я написал базу данных in-memory, которая имитирует реальную базу данных. Поскольку на самом деле неважно, какую базу данных мы используем, мы не будем рассматривать этот код. Однако позже мы увидим, как он используется в наших тестах.

Теперь давайте посмотрим, как будет выглядеть метод, который извлекает из базы данных обзор продукта:

func Find(productID, userID int) (*ProductReview, error)

Этот метод принимает идентификатор продукта и идентификатор пользователя и извлекает отзывы этого пользователя для указанного продукта.

Что не так?

С технической точки зрения в коде пока нет ничего страшного. Это правильный Go-код. Он компилируется и запускается.

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


package products

import (
	"github.com/google/go-cmp/cmp"
	"testing"
)

func TestProductReview(t *testing.T) {
	// Создаем инстанс базы данных
	db := NewDatabase()

	// Создаем продукт
	product := Product{Name: "Computer"}
	if err := db.Products.Create(&product); err != nil {
		t.Fatal(err)
	}

	// Создаем пользователя
	user := User{Username: "Gopher"}
	if err := db.Users.Create(&user); err != nil {
		t.Fatal(err)
	}

	// Создаем отзыв о продукте
	exp := &ProductReview{
		UserID:    user.ID,
		ProductID: product.ID,
		Review:    "Комп супер!",
	}
	if err := db.ProductReview.Save(exp); err != nil {
		t.Fatal(err)
	}

	// Получаем отзыв о продукте
	got, err := db.ProductReviews.Find(user.ID, product.ID)
	if err != nil {
		t.Fatal(err)
	}

	if !cmp.Equal(got, exp) {
		t.Fatalf("не ожидаемый отзыв о продукте:\n%s", cmp.Diff(exp, got))
	}
}

Если мы запустим тест, то увидим, что он действительно прошел:


go test -v -run TestProductReview === RUN TestProductReview database_test.go:41: &{ProductID:1 UserID:1 Review:This computer is awesome!}

PASS ok store 0.064s

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

Проблема в том, что мы поменяли местами аргументы метода при вызове метода `Find`. Если мы посмотрим на сигнатуру, то увидим, что она определена как:


```go
func Find(productID, userID int) (*ProductReview, error)

Однако мы вызывали его с заменой идентификатора продукта на идентификатор пользователя (фактически отправляя идентификатор продукта вместо идентификатора пользователя, и наоборот):


db.ProductReviews.Find(user.ID, product.ID)
                        

Итак, мы точно знаем, что написали плохой тест, так почему же он прошел? Причина в том, что, как и во многих интеграционных тестах, мы тестируем из «пустой» базы данных. Поэтому, когда мы создаем первого пользователя, его ID будет 1, а когда мы создаем первый товар, его ID также будет 1. И поскольку оба типа ID - int, мы никогда не увидим, что написали плохой тест.

Вы можете подумать: «Хорошо, мы написали плохой тест, такое бывает. Почему бы нам просто не исправить тест, отправив аргументы метода в правильном порядке?

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

Зачем вы говорите мне это?

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

Типизированные идентификаторы

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


type ProductID int

type Product struct {
	ID   ProductID
	Name string
}

type UserID int

type User struct {
	ID       UserID
	Username string
}

type ProductReview struct {
	ProductID ProductID
	UserID    UserID
	Review    string
}
                        

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

Это означает, что мы также должны обновить метод Find, который теперь будет использовать пользовательские типы для аргументов:

func Find(productID ProductID, userID UserID) (*ProductReview, error)

Теперь при выполнении тестов мы видим следующую ошибку:


$ go test -v -run TestProductReview

# store [store.test]

database_test.go:33:41: cannot use user.ID (type UserID) as type ProductID in argument to db.ProductReviews.Find
database_test.go:33:53: cannot use product.ID (type ProductID) as type UserID in argument to db.ProductReviews.Find

FAIL    store [build failed]
                        

И если мы посмотрим на неудачную строку кода, то увидим, что аргументы действительно поменялись местами:

got, err := db.ProductReviews.Find(user.ID, product.ID)

Именно это я имею в виду, когда говорю об использовании системы типов в Go. Если вы правильно организуете типы данных, компилятор проделает гораздо больше работы, чтобы не допустить ошибок в тестах или рабочем коде.

Если мы не сделаем преобразование, Go применит безопасность типов, и мы получим ошибку времени компиляции.

Хорошо, понял. Всегда использую типизированные идентификаторы

На самом деле... нет. При разработке программного обеспечения всегда нужно смотреть на плюсы и минусы. Хотя использование типизированных идентификаторов позволяет отлавливать ошибки, иногда это становится просто хлопотным делом, потому что приходится постоянно преобразовывать типы из чего-то вроде базового int в ProductID.

Если вы посмотрите на следующий код, это не сработает, поскольку Go обеспечивает безопасность типов, и хотя ProductID основан на int, он НЕ является int и не может быть напрямую присвоен значению int:


package main

import "fmt"

type ProductID int

type Product struct {
	ID   ProductID
	Name string
}

func main() {
	id := 1
	product := Product{
		ID:   id,
		Name: "Computer",
	}
	fmt.Println(product)
}

$ go run invalid.go

# command-line-arguments
invalid.go:16:3: cannot use id (type int) as type ProductID in field value

Чтобы убедиться, что вы следуете правилам безопасности типов, вы должны сообщить Go, что хотите преобразовать значение id, равное int, в тип ProductID, выполнив преобразование типов (type conversion):

ProductID(id)

Вот как теперь будет выглядеть код:


package main

import "fmt"

type ProductID int

type Product struct {
	ID   ProductID
	Name string
}

func main() {
	id := 1
	product := Product{
		ID:   ProductID(id),
		Name: "Computer",
	}
	fmt.Println(product)
}

Как узнать, когда их следует использовать?

Есть несколько признаков, по которым можно определить, что типизированные идентификаторы могут принести пользу вашему коду. Во-первых, если у вас есть функции, которые используют более одного идентификатора из разных типов данных для выполнения операций. В нашем примере это проявилось в том, что ProductReview использовала составной ключ ProductID и UserID. Это может произойти в таких проектах, как RESTful Web API. А может быть, вы потребляете данные из XML или JSON и хотите убедиться, что при обработке данных соблюдаются все типы данных.

Если ваш код не смешивает идентификаторы, то, скорее всего, использование типизированных идентификаторов приведет только к лишнему коду без какой-либо пользы.

Резюме

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

Хотите еще?

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



    Добавить комментарий
    divider graphic

    Возможно, вам будет интересно

    Image
    12 марта 2025 в 18:32
    heart interface icon134

    Протокол HTTP/2 и его использование в Golang

    Данная статья является переводом c оригинала - https://victoriametrics.com/blog/go-http2/. В ней подробно рассказывается о принципах функционирования протокола HTTP/2.

    Image
    Yevgeniy Ampleev
    Image
    24 марта 2025 в 19:34
    heart interface icon128

    Использование собственных типов в Golang

    В данной статье приведены примеры правильного использования собственных типов в Golang

    Image
    Yevgeniy Ampleev
    arrow-up icon