Проблематика
В этой статье мы рассмотрим, как работать с категоризируемыми данными. Для
примера возьмем книжный каталог.
Для начала мы определим структуру Book
, которую планируем
классифицировать по жанру Genre
:
package books
type Book struct {
ID int `json:"id"`
Name string `json:"name"`
Genre string `json:"genre"`
}
Теперь, когда мы определились с книгой, давайте определим константы
для жанра:
const (
Adventure = "Adventure"
Comic = "Comic"
Crime = "Crime"
Fiction = "Fiction"
Fantasy = "Fantasy"
Historical = "Historical"
Horror = "Horror"
Magic = "Magic"
Mystery = "Mystery"
Philosophical = "Philosophical"
Political = "Political"
Romance = "Romance"
Science = "Science"
Superhero = "Superhero"
Thriller = "Thriller"
Western = "Western"
)
Пока все выглядит нормально. Однако константы жанра (Genre
) являются
строками. Хотя это
очень «очеловеченный» способ чтения кода, он не очень эффективен для компьютерной программы.
Строки будут занимать больше места в памяти программы (не говоря уже о том, что
если бы мы
хранили миллионы записей в базе данных). Поэтому мы хотим использовать более эффективный
тип
данных для нашего контекста.
В Go одним из способов сделать это является создание констант,
основанных на типе
int
:
const (
Adventure = 1
Comic = 2
Crime = 3
Fiction = 4
Fantasy = 5
Historical = 6
Horror = 7
Magic = 8
Mystery = 9
Philosophical = 10
Political = 11
Romance = 12
Science = 13
Superhero = 14
Thriller = 15
Western = 16
)
Нам также нужно изменить структуру Book
, чтобы теперь
Genre
имел тип int
:
type Book struct {
ID int
Name string
Genre int // Заменили тип string на int для увеличения производительности
}
Хотя теперь у нас есть более эффективная модель памяти для Genre
, она
не так удобна
для человека. Если я выведу значение Book
, то теперь мы получим просто целочисленное
значение.
Чтобы показать это, мы напишем быстрый тест:
package books
import "testing"
func TestGenre(t *testing.T) {
b := Book{
ID: 1,
Name: "Всё про Golang",
Genre: Magic,
}
t.Logf("%+v\n", b)
if got, exp := b.Genre, 8; got != exp {
t.Errorf("unexpected genre. got %d, exp %d", got, exp)
}
}
И вот что он выведет после запуска:
$go test -v ./...
=== RUN TestGenre
book_test.go:12: {ID:1 Name:Всё про Golang Genre:8}
--- PASS: TestGenre (0.00s)
PASS
ok bitbucket.org/ampleevee/examples.git/internal/books 0.273s
-------- Go Version: go 1.22.0
Заметьте, что Genre
просто показывает значение 8
. Каждый
раз, когда мы отлаживаем
код, пишем отчет и т. д., нам нужно выяснить, что на самом деле означает 8
для
человека.
Для этого мы можем написать вспомогательную функцию, которая принимает значение
Genre
и определяет, каким должно быть «человеческое» представление:
func GenreToString(i int) string {
switch i {
case 1:
return "Adventure"
case 2:
return "Comic"
case 3:
return "Crime"
case 4:
return "Fiction"
case 5:
return "Fantasy"
case 6:
return "Historical"
case 7:
return "Horror"
case 8:
return "Magic"
case 9:
return "Mystery"
case 10:
return "Philosophical"
case 11:
return "Political"
case 12:
return "Romance"
case 13:
return "Science"
case 14:
return "Superhero"
case 15:
return "Thriller"
case 16:
return "Western"
default:
return ""
}
}
Более эффективный способ
Хотя весь приведенный выше код работает отлично, в нем не хватает некоторых ключевых
моментов:
- Если в будущем значение
Genre
изменится, нам придется не только изменить
константное
значение, но и обновить функцию GenreToString
. Если этого не сделать, то в коде
возникнет
ошибка.
- Мы не используем систему типов, чтобы инкапсулировать это поведение для
Genre
.
Скоро мы покажем вам, что мы имеем в виду.
Первое, что нам нужно сделать, это написать более устойчивую
функцию GenreToString
.
Под устойчивостью мы понимаем то, что даже если значение константы
Genre
изменится в будущем,
функция GenreToString
не должна будет меняться.
Правильный способ сделать это - не использовать жестко
закодированные значения, а
использовать значения самих констант:
func GenreToString(i int) string {
switch i {
case Adventure:
return "Adventure"
case Comic:
return "Comic"
case Crime:
return "Crime"
case Fiction:
return "Fiction"
case Fantasy:
return "Fantasy"
case Historical:
return "Historical"
case Horror:
return "Horror"
case Magic:
return "Magic"
case Mystery:
return "Mystery"
case Philosophical:
return "Philosophical"
case Political:
return "Political"
case Romance:
return "Romance"
case Science:
return "Science"
case Superhero:
return "Superhero"
case Thriller:
return "Thriller"
case Western:
return "Western"
default:
return ""
}
}
Хорошо, это гораздо чище (и читабельнее), но мы все еще не решили проблему того, что
при выводе на печать отображается значение данных (int
), а не «человеческое»
читаемое
значение.
Собственные типы Go
нам в помощь
Вместо того чтобы использовать стандартный тип int
для
Genre
, мы можем создать свой
собственный тип на основе стандартного. В данном случае мы создадим новый тип
Genre
на
основе типа int
:
type Genre int
type Book struct {
ID int
Name string
Genre Genre // Заменили тип int на собственный Genre без потери производительности
}
Теперь мы определим наши константы как типы Genre
:
const (
Adventure Genre = 1
Comic Genre = 2
Crime Genre = 3
Fiction Genre = 4
Fantasy Genre = 5
Historical Genre = 6
Horror Genre = 7
Magic Genre = 8
Mystery Genre = 9
Philosophical Genre = 10
Political Genre = 11
Romance Genre = 12
Science Genre = 13
Superhero Genre = 14
Thriller Genre = 15
Western Genre = 16
)
Пока что в коде нет никаких изменений. Однако теперь, когда Genre
- это
собственный
тип, мы можем добавить к нему методы. Это позволит инкапсулировать
«человеческое» поведение,
которое мы хотим получить, в тип, а не в общую функцию.
Для этого мы добавим метод String
к типу
Genre
:
func (g Genre) String() string {
switch g {
case Adventure:
return "Adventure"
case Comic:
return "Comic"
case Crime:
return "Crime"
case Fiction:
return "Fiction"
case Fantasy:
return "Fantasy"
case Historical:
return "Historical"
case Horror:
return "Horror"
case Magic:
return "Magic"
case Mystery:
return "Mystery"
case Philosophical:
return "Philosophical"
case Political:
return "Political"
case Romance:
return "Romance"
case Science:
return "Science"
case Superhero:
return "Superhero"
case Thriller:
return "Thriller"
case Western:
return "Western"
default:
return ""
}
}
Теперь мы сможем использовать метод String
, когда захотим узнать, каково
«человеческое» значение у Genre
(данный код уже писался в другом пакете, поэтому
здесь отдельно импортируется пакет books
):
package main
import (
"bitbucket.org/ampleevee/examples.git/internal/books"
"fmt"
)
func main() {
b := books.Book{
ID: 1,
Name: "Всё про Go",
Genre: books.Magic,
}
fmt.Println(b.Genre.String())
}
Вывод:
Magic
Магическое форматирование
В Go
, если вы добавите метод String
к любому типу, пакет
fmt
будет
использовать его при выводе вашего типа автоматически. Благодаря этому
мы увидим, что если мы распечатаем Book
в наших тестах, то получим и
«человекочитаемый»
Genre
:
$go test -v ./...
=== RUN TestGenre
book_test.go:12: {ID:1 Name:Всё про Golang Genre:Magic}
--- PASS: TestGenre (0.00s)
PASS
ok bitbucket.org/ampleevee/examples.git/internal/books 0.273s
-------- Go Version: go 1.22.0
Теперь мы видим, что в распечатанном выводе значение для Genre
- Magic
,
а не 8
.
Важно также отметить, что наш тест фактически не изменился, изменился только
способ, которым мы
использовали наш новый тип для Genre
.
А как же iota
?
Те из вас, кто уже знаком с Go, возможно, посмотрели на эту задачу
и спросили:
«Почему вы просто не использовали iota
?». iota
- это идентификатор,
который вы можете
использовать в Go для создания увеличивающихся числовых
констант. Хотя есть несколько причин, по
которым я не использовал iota
в этой задаче, я посвятил этой теме целую статью.
Читайте об этом
в статье «Где и когда использовать iota
в Golang?».
Резюме
Хотя этот пример был намеренно базовым по своей природе, он
иллюстрирует возможности
определения собственных типов и использования системы типов в Go для создания
более стойкого,
читаемого и многократно используемого кода.