Це блог-версія моїх виступів на Google Open Source Live:
https://youtu.be/nr8EpUO9jhw?si=jlWTapr6NM6isLgt&embedable=true
та GopherCon 2021:
https://youtu.be/Pae9EeCdy8?si=M-87Eisb2nU1qmJ&embedable=true
\ Випуск Go 1.18 додає важливу нову функцію мови: підтримку узагальненого програмування. У цій статті я не буду описувати, що таке узагальнення і як їх використовувати. Ця стаття про те, коли використовувати узагальнення в коді Go, а коли ні.
\ Для ясності, я надам загальні рекомендації, а не жорсткі правила. Використовуйте власне судження. Але якщо ви не впевнені, я рекомендую використовувати рекомендації, обговорені тут.
Почнемо із загальної рекомендації для програмування на Go: пишіть програми Go, пишучи код, а не визначаючи типи. Коли справа доходить до узагальнень, якщо ви починаєте писати свою програму з визначення обмежень параметрів типу, ви, ймовірно, на неправильному шляху. Почніть з написання функцій. Легко додати параметри типу пізніше, коли стане зрозуміло, що вони будуть корисними.
Отже, розглянемо випадки, коли параметри типу можуть бути корисними.
Один випадок - це коли пишуться функції, які працюють з особливими типами контейнерів, визначеними мовою: зрізи, карти та канали. Якщо функція має параметри з цими типами, і код функції не робить особливих припущень щодо типів елементів, то може бути корисно використовувати параметр типу.
\ Наприклад, ось функція, яка повертає зріз усіх ключів у карті будь-якого типу:
// MapKeys returns a slice of all the keys in m. // The keys are not returned in any particular order. func MapKeys[Key comparable, Val any](m map[Key]Val) []Key { s := make([]Key, 0, len(m)) for k := range m { s = append(s, k) } return s }
\ Цей код не робить жодних припущень щодо типу ключа карти і взагалі не використовує тип значення карти. Він працює для будь-якого типу карти. Це робить його хорошим кандидатом для використання параметрів типу.
\ Альтернативою параметрам типу для такого роду функцій зазвичай є використання рефлексії, але це більш незручна модель програмування, яка не перевіряється статично під час збірки і часто повільніша під час виконання.
Інший випадок, коли параметри типу можуть бути корисними, - це структури даних загального призначення. Структура даних загального призначення - це щось на зразок зрізу або карти, але те, що не вбудовано в мову, наприклад, зв'язаний список або бінарне дерево.
\ Сьогодні програми, які потребують таких структур даних, зазвичай роблять одне з двох: пишуть їх з конкретним типом елементів або використовують інтерфейсний тип. Заміна конкретного типу елементів на параметр типу може створити більш загальну структуру даних, яку можна використовувати в інших частинах програми або в інших програмах. Заміна інтерфейсного типу на параметр типу може дозволити зберігати дані більш ефективно, економлячи ресурси пам'яті; це також може дозволити коду уникнути приведення типів і бути повністю перевіреним на типи під час збірки.
\ Наприклад, ось частина того, як може виглядати структура даних бінарного дерева з використанням параметрів типу:
// Tree is a binary tree. type Tree[T any] struct { cmp func(T, T) int root *node[T] } // A node in a Tree. type node[T any] struct { left, right *node[T] val T } // find returns a pointer to the node containing val, // or, if val is not present, a pointer to where it // would be placed if added. func (bt *Tree[T]) find(val T) **node[T] { pl := &bt.root for *pl != nil { switch cmp := bt.cmp(val, (*pl).val); { case cmp < 0: pl = &(*pl).left case cmp > 0: pl = &(*pl).right default: return pl } } return pl } // Insert inserts val into bt if not already there, // and reports whether it was inserted. func (bt *Tree[T]) Insert(val T) bool { pl := bt.find(val) if *pl != nil { return false } *pl = &node[T]{val: val} return true }
\ Кожен вузол у дереві містить значення параметра типу T. Коли дерево інстанціюється з конкретним аргументом типу, значення цього типу будуть зберігатися безпосередньо у вузлах. Вони не будуть зберігатися як інтерфейсні типи.
\ Це розумне використання параметрів типу, оскільки структура даних Tree, включаючи код у методах, значною мірою не залежить від типу елемента T.
\ Структурі даних Tree потрібно знати, як порівнювати значення типу елемента T; для цього вона використовує передану функцію порівняння. Ви можете побачити це в четвертому рядку методу find, у виклику bt.cmp. Окрім цього, параметр типу взагалі не має значення.
Приклад Tree ілюструє ще одну загальну рекомендацію: коли вам потрібно щось на зразок функції порівняння, віддавайте перевагу функції, а не методу.
\ Ми могли б визначити тип Tree так, щоб тип елемента повинен був мати метод Compare або Less. Це можна зробити, написавши обмеження, яке вимагає метод, що означає, що будь-який аргумент типу, використаний для інстанціювання типу Tree, повинен мати цей метод.
\ Наслідком було б те, що будь-хто, хто хоче використовувати Tree з простим типом даних, як-от int, повинен був би визначити власний цілочисельний тип і написати власний метод порівняння. Якщо ми визначимо Tree так, щоб він приймав функцію порівняння, як у коді, показаному вище, то легко передати бажану функцію. Написати цю функцію порівняння так само легко, як і написати метод.
\ Якщо тип елемента Tree вже має метод Compare, то ми можемо просто використовувати вираз методу, як-от ElementType.Compare, як функцію порівняння.
\ Іншими словами, набагато простіше перетворити метод на функцію, ніж додати метод до типу. Тому для типів даних загального призначення віддавайте перевагу функції, а не написанню обмеження, яке вимагає метод.
Інший випадок, коли параметри типу можуть бути корисними, - це коли різні типи повинні реалізовувати деякий загальний метод, і реалізації для різних типів виглядають однаково.
\ Наприклад, розглянемо sort.Interface зі стандартної бібліотеки. Він вимагає, щоб тип реалізував три методи: Len, Swap і Less.
\ Ось приклад узагальненого типу SliceFn, який реалізує sort.Interface для будь-якого типу зрізу:
// SliceFn implements sort.Interface for a slice of T. type SliceFn[T any] struct { s []T less func(T, T) bool } func (s SliceFn[T]) Len() int { return len(s.s) } func (s SliceFn[T]) Swap(i, j int) { s.s[i], s.s[j] = s.s[j], s.s[i] } func (s SliceFn[T]) Less(i, j int) bool { return s.less(s.s[i], s.s[j]) }
\ Для будь-якого типу зрізу методи Len і Swap абсолютно однакові. Метод Less вимагає порівняння, що є частиною Fn у назві SliceFn. Як і в попередньому прикладі Tree, ми передамо функцію при створенні SliceFn.
\ Ось як використовувати SliceFn для сортування будь-якого зрізу за допомогою функції порівняння:
// SortFn sorts s in place using a comparison function. func SortFn[T any](s []T, less func(T, T) bool) { sort.Sort(SliceFn[T]{s, less}) }
\ Це схоже на функцію стандартної бібліотеки sort.Slice, але функція порівняння написана з використанням значень, а не індексів зрізу.
\ Використання параметрів типу для такого коду доречне, оскільки методи виглядають абсолютно однаково для всіх типів зрізів.
\ (Я повинен зазначити, що Go 1.19, а не 1.18, швидше за все, включатиме узагальнену функцію для сортування зрізу з використанням функції порівняння, і ця узагальнена функція, швидше за все, не використовуватиме sort.Interface. Див. пропозицію #47619. Але загальний момент все ще залишається вірним, навіть якщо цей конкретний приклад, швидше за все, не буде корисним: розумно використовувати параметри типу, коли вам потрібно реалізувати методи, які виглядають однаково для всіх відповідних типів.)
Тепер поговоримо про іншу сторону питання: коли не використовувати параметри типу.
Як ми всі знаємо, Go має інтерфейсні типи. Інтерфейсні типи дозволяють певний вид узагальненого програмування.
\ Наприклад, широко використовуваний інтерфейс io.Reader надає узагальнений механізм для читання даних з будь-якого значення, яке містить інформацію (наприклад, файл) або яке виробляє інформацію (наприклад, генератор випадкових чисел). Якщо все, що вам потрібно зробити зі значенням певного типу, - це викликати метод для цього значення, використовуйте інтерфейсний тип, а не параметр типу. io.Reader легко читається, ефективний і дієвий. Немає потреби використовувати параметр типу для читання даних зі значення, викликаючи метод Read.
\ Наприклад, може виникнути спокуса змінити перший підпис функції тут, який використовує лише інтерфейсний тип, на другу версію, яка використовує параметр типу.
func ReadSome(r io.Reader) ([]byte, error)

