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