Перший погляд на Go generics

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Привіт! Дженерики — це найсуперечливіша тема в українській спільноті гоферів GolangUA, дуже просто хайпонути на темі дженериків в Go. Я вирішив написати про дженерики з трьох причин.

Перша — я прочитав статтю Quick Guide: Go 1.18 Features від турка Emre Odabas (оригінал турецькою мовою) й захотів спробувати дженерики для зменшення дублікатів коду.

Друга причина — це бажання поділитись навчальними матеріалами з Go за цей рік, частину взяв з @GolangDigest.

Третя причина — це tada.training.

Стандартна задача з дублюванням коду

Для мене такою задачею є перетворення слайсу в мапу.

package main

import "fmt"

type Company struct {
	ID          int
	Name        string
	Description string
}

type Vacancy struct {
	ID          int
	CompanyID   int
	Title       string
	Description string
}

func CompaniesToMap(items []Company) map[int]Company {
	var result = make(map[int]Company, len(items))

	for _, item := range items {
		result[item.ID] = item
	}

	return result
}

func VacanciesToMap(items []Vacancy) map[int]Vacancy {
	var result = make(map[int]Vacancy, len(items))

	for _, item := range items {
		result[item.ID] = item
	}

	return result
}

func main() {
	var companies = []Company{
		{
			ID:          1,
			Name:        "TADA!",
			Description: "Job interview preparation service",
		},
	}

	var companiesMap = CompaniesToMap(companies)

	fmt.Printf("Map %v, type %T\n", companiesMap, companiesMap)
}
go run ./examples/001_duplication/main.go
Map map[1:{1 TADA! Job interview preparation service}], type map[int]main.Company
Функції CompaniesToMap та VacanciesToMap схожі, спробую їх параметризувати, але почну з простого:
go version
go version go1.18beta1 linux/amd64
package main

import "fmt"

// type Company ...
// type Vacancy ...

type Getter interface {
	Company | Vacancy
}

func main() {
	fmt.Println("Success!")
}
go run ./examples/002_try_define_union/main.go
Success!

Далі вирішив поекспериментувати з типами, а в коментарі вставив помилки від компілятора:

package main

import "fmt"

// type Company ...
// type Vacancy ...

type Getter interface {
	Company | Vacancy | *Vacancy
}

func PrintGetter[T Getter](v T) {
	fmt.Printf("Value %v, type %T\n", v, v)

	// ERROR: invalid operation: cannot use type assertion on type parameter value v (variable of type T constrained by Getter)
	// if company, ok := v.(Company); ok {
	// 	_ = company
	// }
}

func main() {
	PrintGetter(Company{
		ID:          1,
		Name:        "TADA!",
		Description: "Job interview preparation service",
	})

	// ERROR: *Company does not implement Getter
	// PrintGetter(&Company{})

	PrintGetter(Vacancy{
		ID:          1,
		CompanyID:   1,
		Title:       "Go developer",
		Description: "",
	})
	PrintGetter(&Vacancy{})
}
go run ./examples/003_try_print_union/main.go
Value {1 TADA! Job interview preparation service}, type main.Company
Value {1 1 Go developer }, type main.Vacancy
Value &{0 0  }, type *main.Vacancy

Настав час вже написати параметризовану функцію через просту заміну типу:

package main

import "fmt"

// type Company ...
// type Vacancy ...

type Getter interface {
	Company | Vacancy
}

func ToMap[T Getter](items []T) map[int]T {
	var result = make(map[int]T, len(items))

	for _, item := range items {
		result[item.ID] = item
	}

	return result
}

func main() {
	var companies = []Company{
		{
			ID:          1,
			Name:        "TADA!",
			Description: "Job interview preparation service",
		},
	}

	var companiesMap = ToMap(companies)

	fmt.Printf("Map %v, type %T\n", companiesMap, companiesMap)
}
go run ./examples/004_try_map_by_id/main.go
examples/004_try_map_by_id/main.go:26:15: item.ID undefined (interface Getter has no method ID)

На просту заміну типу, яка б спрацювала в TypeScript, компілятор Go видає помилку.

Тоді залишається варіант через додавання методу:

package main

import "fmt"

// type Company ...
// type Vacancy ...

func (c Company) GetID() int {
	return c.ID
}

func (v Vacancy) GetID() int {
	return v.ID
}

type Getter interface {
	GetID() int
}

func ToMap[T Getter](items []T) map[int]T {
	var result = make(map[int]T, len(items))

	for _, item := range items {
		result[item.GetID()] = item
	}

	return result
}

func main() {
	var companies = []Company{
		{
			ID:          1,
			Name:        "TADA!",
			Description: "Job interview preparation service",
		},
	}

	var companiesMap = ToMap(companies)

	fmt.Printf("Map %v, type %T\n", companiesMap, companiesMap)
}
go run ./examples/005_map_by_interace_get_id/main.go
Map map[1:{1 TADA! Job interview preparation service}], type map[int]main.Company

Вдалось використати дженерики для зменшення дублювання.

Складніша задача з обгорткою

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

Отже в мене є дві однакові структури, які згенеровані sqlc:

tree ./examples/006_wrap
./examples/006_wrap
├── generated
│   └── models.go
└── main.go

1 directory, 2 files
cat ./examples/006_wrap/generated/models.go
package generated

type SkillsRow struct {
	ID    int32
	Alias string
	Name  string
}

type DirectionsRow struct {
	ID    int32
	Alias string
	Name  string
}

Ці дві структури я хочу привести до однієї:

type Alias struct {
	ID    int32
	Alias string
	Name  string
}

Як в попередньому прикладі, спробую зробити це через дженерики.

package main

import "gitlab.com/go-yp/go-generics-map/examples/006_wrap/generated"

type (
	Skill     generated.SkillsRow
	Direction generated.SkillsRow
)

type Alias struct {
	ID    int32
	Alias string
	Name  string
}

func (s Skill) GetID() int32 {
	return s.ID
}

func (s Skill) ToAlias() Alias {
	return Alias{
		ID:    s.ID,
		Alias: s.Alias,
		Name:  s.Name,
	}
}

func (d Direction) GetID() int32 {
	return d.ID
}

func (d Direction) ToAlias() Alias {
	return Alias{
		ID:    d.ID,
		Alias: d.Alias,
		Name:  d.Name,
	}
}

type AliasGetter interface {
	GetID() int32
	ToAlias() Alias
}

func ToAliasMap[F any, T AliasGetter](values []F) map[int32]Alias {
	var result = make(map[int32]Alias, len(values))

	for _, value := range values {
		var wrap = T(value)

		result[wrap.GetID()] = wrap.ToAlias()
	}

	return result
}

func main() {
	ToAliasMap[generated.SkillsRow, Skill]([]generated.SkillsRow{})

	ToAliasMap[generated.DirectionsRow, Direction]([]generated.DirectionsRow{})
}

Але після запуску компілятор повернув помилку:

go run ./examples/006_wrap/main.go
# command-line-arguments
examples/006_wrap/main.go:49:16: cannot convert value (variable of type F constrained by any) to type T

В цьому разі код став значно складнішим, ніж написати дві функції для кожного типу, хоча рішення є:

package main

import (
	"fmt"
	g "gitlab.com/go-yp/go-generics-map/examples/006_wrap/generated"
)

type Alias struct {
	ID    int32
	Alias string
	Name  string
}

func ToAliasMap[F any](values []F, transform func(F) Alias) map[int32]Alias {
	var result = make(map[int32]Alias, len(values))

	for _, value := range values {
		var alias = transform(value)

		result[alias.ID] = alias
	}

	return result
}

func main() {
	var sourceSkills = []g.SkillsRow{}

	var skills = ToAliasMap[g.SkillsRow](sourceSkills, func(s g.SkillsRow) Alias {
		return Alias{
			ID:    s.ID,
			Alias: s.Alias,
			Name:  s.Name,
		}
	})

	fmt.Printf("Map %v, type %T\n", skills, skills)
}
go run ./examples/006_wrap/main.go
Map map[], type map[int32]main.Alias

Вкладенні типи

В процесі експериментів згадав, що в Go є складнощі з виведенням вкладених типів:

package main

import "fmt"

type NameGetter interface {
	Name() string
}

type User struct {
	name string
}

func (u User) Name() string {
	return u.name
}

func PrintName(lazy func() NameGetter) {
	fmt.Println(lazy().Name())
}

func main() {
	// will work
	// PrintName(func() NameGetter {
	// 	return &User{
	// 		"TADA!",
	// 	}
	// })

	// will error
	PrintName(func() *User {
		return &User{
			"TADA!",
		}
	})
}
go run ./examples/007_nested_before/main.go
cannot use func() *User {…} (value of type func() *User) as type func() NameGetter in argument to PrintName

Чи виправить помилку заміна на дженерик?

package main

import "fmt"

type NameGetter interface {
	Name() string
}

type User struct {
	name string
}

func (u User) Name() string {
	return u.name
}

func PrintName[T NameGetter](lazy func() T) {
	fmt.Println(lazy().Name())
}

func main() {
	// will work
	PrintName(func() NameGetter {
		return &User{
			"TADA!",
		}
	})

	// will work too
	PrintName(func() *User {
		return &User{
			"TADA!",
		}
	})
}
go run ./examples/008_nested_after/main.go
TADA!
TADA!

Дженерик виявився корисним в цій ситуації.

Go Türkiye

Цього року я часто зустрічав турецькі проєкти на GitHub, турецьку статтю навів на початку цієї статті, мав відрядження до Туреччини, а також отримав пропозицію в LinkedIn на турецький проєкт.

Як виявилось, в Туреччині велика спільнота гоферів kommunity.com/goturkiye (4500+ часників), є YouTube-канал Go Türkiye де є записи з GopherCon Turkey 2021 — Turkish Track та GopherCon Turkey 2021 — English Track, #GopherConTR. Й розвивають свій GitHub Go Türkiye.

А ще 12 грудня 2021 року брати Капранови опублікували відео Історія Туреччини.

Це до того, що замість суперечок в коментарях на DOU краще вкладайте час в розвиток нашої Go-спільноти GolangUA, пишіть статті, коментуйте статті на тематику Go або допомагайте колегам перекваліфіковуватись.

TADA! — сервіс для проведення тестових інтерв’ю

Ювженко Денис анонсував сервіс TADA! 30 листопада 2020 року, а зараз шукає інтерв’юера з досвідом в Go. Go активно розвивається в Україні, а ми як спільнота маємо цьому сприяти.

Звісно, інтерв’юер буде отримувати гроші за проведені інтерв’ю.

Навчальні матеріали

Цього року було багато онлайл конференцій з Go, якщо якусь пропустив, то додайте в коментарях.

Статті:

Ще відео:

YouTube-канали:

Roadmaps:

👍ПодобаєтьсяСподобалось10
До обраногоВ обраному7
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Возможности генериков в Go сильно сливают темплейтам в C++. Например, не получится сделать метод структуры с отдельным списком типов, другим чем в объявлении структуры. Но самое главное, невозможно написать имплементации для различных типов. Например было бы очень удобно написать такую конструкцию:

package main

import "fmt"

type P[T int | string] struct {
	data T
}

func (p *P[T int]) String() string {
	return fmt.Sprintf("int %d", v)
}

func (p *P[T string]) String() string {
	return fmt.Sprintf("str %s", v)
}

func main() {
	var p1 P[string]
	fmt.Println(p1.String())
	var p2 P[int]
	fmt.Println(p2.String())
}
Но такой вариант с отдельным инстанцированием в Go не работает, а вместо этого приходится внутри одной функции перебирать все возможные варианты:
func (p *P[T]) String() string {
	switch v := any(p.data).(type) {
	case int:
		return fmt.Sprintf("int %d", v)
	case string:
		return fmt.Sprintf("str %s", v)
	default:
		panic("unreachable condition")
	}
}
А визуально код при использовании генериков чем-то начинает напоминать C++.
package main

import "fmt"
import "strconv"

type Int int

func (i Int) String() string {
	return strconv.Itoa(int(i))
}

type String string

func (s String) String() string {
	return string(s)
}

func main() {
	var p1 Int
	fmt.Println(p1.String())
	var p2 String
	fmt.Println(p2.String())
}

Проблема именно в том, как делать инстанцирование для разных типов в генериках. Тип «T» может быть разным, и в зависимости от типа меняется код. Вот ещё один пример чтения чисел разной размерности из слайса, его через темплейты можно было бы реализовать получше:

func UintRead[T uint8 | uint16 | uint32 | uint64](b []byte) (ret T) {
	switch any(ret).(type) {
	case uint8:
		return T(b[0])
	case uint16:
		return T(binary.LittleEndian.Uint16(b))
	case uint32:
		return T(binary.LittleEndian.Uint32(b))
	case uint64:
		return T(binary.LittleEndian.Uint64(b))
	default:
		panic("unreachable condition")
	}
}
Кстати, простая конструкция switch T { тоже не работает, поэтому надо объявлять переменную ret.

Первый пример отлично показывает, что Go программист сможет написать бойлерплейтный Go код даже с помощью дженериков.

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

Вот ToMap здорового человека, который можно использовать на любых типах.

func ToMap[T any, K comparable](items []T, key_selector func(T)K) map[K]T {
	var result = make(map[K]T, len(items))

	for _, item := range items {
		result[key_selector(item)] = item
	}

	return result
}

...

var companiesMap = ToMap(companies, func(c Company) int { return c.ID })

Как уменьшение кода — да дженерик заебись. Есть тока одна такая неебически большая проблема — всем похер на обьем кода. Сейчас не 1981 год и место много не стоит.
Зато вот читать код обвешений дженериками можно глаза сломать. Что легко видно в коде на С++, и на что все всегда плюются.

При этом в примере два класа почемуто живут в одном файле что по факту — херня.
А вот если разнести то будет ли польза от дженерика? Вам сильно будет мишать один и тот же код в разных файлах?

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

В Go вже були типи схожі на дженерики — це map та slice.

В моєму проєкті є місця, які хочу переписати з дженериками, якщо це ускладнить код то звісно поверну як було.

Что легко видно в коде на С++, и на что все всегда плюются.

В C++ нет нормальных дженериков. Их функцию заменяют темплейты, которые, увы, не эргономичны, отсюда плевание. Посмотри на дженерики здорового человека, например, из C# или Rust.

В C++ нет нормальных дженериков

В C++ ничего нормального нет

В том и суть что Си в свое время раздули до Си++, тем самым дав возможность плодить тонны говна, которую все и начали использовать. Не хотелось бы что бы такая же тема случилась и с Гоу.

Ну разница с С++ только в явном указание типа с которым работаешь.

Я просто очень боюсь что бы довольно простой код гоу не превратили в хрень которую даже под коксом осилит не каждый.

Объём кода имеет значение не потому что он «место на диске занимает», а потому что каждую строчку кода написанную программистом надо поддерживать весь срок жизни проекта. А это уже косты.

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

Дописав рішення:

package main

import (
	"fmt"
	g "gitlab.com/go-yp/go-generics-map/examples/006_wrap/generated"
)

type Alias struct {
	ID    int32
	Alias string
	Name  string
}

func ToAliasMap[F any](values []F, transform func(F) Alias) map[int32]Alias {
	var result = make(map[int32]Alias, len(values))

	for _, value := range values {
		var alias = transform(value)

		result[alias.ID] = alias
	}

	return result
}

func main() {
	var sourceSkills = []g.SkillsRow{}

	var skills = ToAliasMap[g.SkillsRow](sourceSkills, func(s g.SkillsRow) Alias {
		return Alias{
			ID:    s.ID,
			Alias: s.Alias,
			Name:  s.Name,
		}
	})

	fmt.Printf("Map %v, type %T\n", skills, skills)
}
go run ./examples/006_wrap/main.go
Map map[], type map[int32]main.Alias

Навіть такий варіант працює:

func ToMap[S any, K comparable, V any](sources []S, transform func(S) (K, V)) map[K]V {
	var result = make(map[K]V, len(sources))

	for _, source := range sources {
		var key, value = transform(source)

		result[key] = value
	}

	return result
}

tip.golang.org/doc/go1.18 — вот тут описание новых фич в бета-версии, которую обещают зарелизить в феврале.
go.dev/doc/tutorial/generics — а вот тут тюториал по генерикам.
Если коротко один словом, то — гениально! Это всё просто гениально!!!

Зашел, чтобы почитать стенания Валялкина, что generics нинада, боль, ад, etc. И с удивлением их не обнаружил. Що сталося у датському князiвствi?)

В мене це був четвертий прихований мотив, але Олександру, як виявилось, github.com/VictoriaMetrics важливіша

В мене дома відсутні міцні алкогольні напої для таких прогнозів.

Вибачте, я початківець в Go, але в мене чомусь одразу виникла думка, що в першу задачу цілком можливо вирішити взагалі без дженериків. І виявляється що так, навіть досить нескладно. Але в мене питання. Можливо я чогось не второпав? Я маю на увазі, чим код нижче гірший за рішення наведене в статті крім того очевидного факту, що він дозволяє використовувати різні типи всередині одного масиву? Може є ще якісь catches, які я не бачу?

package main

import "fmt"

type Company struct {
	ID          int
	Name        string
	Description string
}

type Vacancy struct {
	ID          int
	CompanyID   int
	Title       string
	Description string
}

func (c Company) GetID() int {
	return c.ID
}

func (v Vacancy) GetID() int {
	return v.ID
}

type Getter interface {
	GetID() int
}

func ToMap(items []Getter) map[int]Getter {
	var result = make(map[int]Getter, len(items))

	for _, item := range items {
		result[item.GetID()] = item
	}

	return result
}

func main() {
	var companies = []Getter{
		Company{
			ID:          1,
			Name:        "TADA!",
			Description: "Job interview preparation service",
		},
	}

	var vacancies = []Getter{
		Vacancy{
			ID:          1,
			Title:       "Software Developer",
			Description: "Guy who knows everything about every single technology",
		},
	}

	var companiesMap = ToMap(companies)
	var vacanciesMap = ToMap(vacancies)

	fmt.Printf("Map %v, type %T\n", companiesMap, companiesMap)
	fmt.Printf("Map %v, type %T\n", vacanciesMap, vacanciesMap)
}

Будь ласка оберніть код для форматування в HTML:

<div class="hl-wrap golang"><pre>
</pre></div>

Так я хотів перетворити слайс в мапу, а у вас інший результат

div насправді не потрібен.Тільки вбиває підсвічування синтаксису. Достатньо pre. Дякую. Виправив.

Підсвічування зникає після редагування коментаря, бо JavaScript, який включає підсвічування, запускається при старті сторінки. Відповідно щоб включити підсвічування то треба оновити сторінку через F5.

div потрібний бо тоді цільове підсвічування мови Go.
Без div блок коду має підсвічування від Scala.

а у вас інший результат
C:\Projects\Learning\Go\GenericsTry>go run main.go
Map map[1:{1 TADA! Job interview preparation service}], type map[int]main.Getter
Map map[1:{1 0 Software Developer Guy who knows everything about every single technology}], type map[int]main.Getter
Ніби такий самий. За виключенням того, що замість конкретного типу використовується Getter.

А вам буде далі комфортно використовувати цей Getter?

Запитання було не про це. Я досить добре розумію, чим типізований масив відрізняється від нетипізованого (вже років 30 пишу код). Я питав про те, чи є якісь інші недоліки. :) Просто перевіряв себе. Звісно конкретний тип в більшості випадків використовувати комфортніше.

Звісно конкретний тип в більшості випадків використовувати комфортніше.

То про це і річ, що функція має перетворювати список конкретного типу в мапу конкретного типу, яку далі буде комфортно використовувати.

А якщо type map[int]main.Getter то для використання потрібно буде додатково приводити через value.(Company), а це джерело помилок.

Насправді рік тому, коли я почав цікавитися Go, найбільш мене цікавило, як ґофери пораються на великих проектах без дженериків. Тепер це більш-меньш зрозуміло. Дякую за статтю. Корисний суто технічний матеріал на Dou не так вже й часто зустрінеш...

Ще є кодогенерації які спрощують розробку гоферам: gRPC, sqlc.

Про gRPC, тільки читав, ще не торкався. А що до GORM, не погоджусь. Нещодавно трошки погрався з нею, на перший погляд виглядає досить зручною. Нагадує Entity Framework, яку я вважаю однією з найкращих.

тепер в генерики можна пихати і типи, з одними інтерфейсами тепер не цікаво =/

Помню, пару лет назад, когда спрашивал у гоферов про дженерики, они говорили, что дженерики концептуально не нужны и даже вредны. Концепция поменялась?

Я був обережних у висловлюваннях стосовно дженериків:

В попередньому прикладі ми працювали з конкретним типом, але для кожного типу події потрібно буде написати свій Buffer, Storage та Worker, що буде дублюванням коду, бо поки в Go відсутні дженерики.

Спасибо большое за материал !

func ToAliasMap[F any, T AliasGetter](values []F) map[int32]Alias {

смотришь на такие записи, страшновато становится

Любой язык со временем протухает до уровня modern C++, а modern C++ асимптотично стремится в перл.

Зато в самом начале можно рассказывать какой простой язык, не то что эти ваши!!!11
Джава-путь, чо уж там

В TypeScript ще не таке робиться .....

type Getter interface {
Company | Vacancy
}
На просту заміну типу, яка б спрацювала в TypeScript, компілятор Go видає помилку.

Код з перших прикладів просто пахне TypeScript :)
Хоча потім всеодно звичайний інтерфейс використали:

type Getter interface {
GetID() int
}

В Kotlin теж вже давно точаться дискусії на тему union-типів youtrack.jetbrains.com/issue/KT-13108, але це одна з речей, яка мене найбільше бісить в TypeScript :)

Раніше вже писали, що мій Go код схожий на PHP, зараз ви пишете що схожий на TypeScript. Що краще?

Що краще?

Треба ще приклад фортрану на го, тоді можна оцінити всю картину

Вже краще Prolog бо парадигма нова для мене.

Ще встигнемо покритикувати коли в Україні буде популяризація Rust-у

Підписатись на коментарі