Для чего использовать дженерики в TypeScript
Дженерики (generic) помогают писать универсальный, переиспользуемый код, а также в некоторых случаях позволяют отказаться от any . Главная задача дженериков — помочь разработчику писать код, который одинаково будет работать со значениями разных типов.
Посмотрим на примере из реального мира.
Представьте завод по изготовлению автомобилей. Старый завод, который проектировался для сборки автомобиля определённой модели. На нём могут собирать только такую модель автомобиля, а если потребуется выпустить машину с немного другим кузовом, то придётся строить новый завод. Это неоптимальное решение. Если разные машины собираются одинаково, то лучше научиться собирать разные машины на одном заводе.
Узнайте больше о дженериках, научитесь на практике использовать аннотацию типов и обобщённое программирование на профессиональном курсе по TypeScript.
Суть дженериков
С дженериками тоже примерно так. Если мы напишем функцию и жёстко зададим тип, то она сможет работать только со значениями этого типа. Значения других типов передать не получится. Есть два способа это поправить.
Первый способ. Написать несколько одинаковых функций, которые работают с разными типами. Например, такая функция проверяет, есть ли в массиве конкретный элемент.
function includeStr(array: string[], query: string): boolean < // на входе массив и строка для поиска for (const value of array) < // перебираем массив if (value === query) < // если в массиве есть элемент — возвращаем true return true; >> // если ничего не нашлось, возвращаем false return false; >
Функция будет отлично работать на массивах из строк. Но для поиска в массиве из чисел придётся дублировать функцию, менять типы, но сам код функции останется неизменным. Например:
function includeNumber(array: number[], query: number): boolean < // всё то же самое, только на входе числа for (const value of array) < if (value === query) < return true; >> return false; >
Вот тут на помощь и приходят дженерики. Они помогают написать код, который одинаково работает с данными разных типов.
❌ Пишем много функций для разных типов
✅ Объявляем в функции параметр типа, а потом передаём через него нужный тип
Вместо конкретного типа, мы как будто объявляем «переменную», а затем передаём в неё нужный тип. Таким образом, получается код, который может работать с разными типами:
function include < T >(array: T[], query: T): boolean < for (const value of array) < if (value === query) < return true; >> return false; >
Код функции не поменялся, но теперь мы не указываем конкретный тип. Мы заводим переменную T и говорим, что тип параметра array — это тип, который будет передан в переменную T . А тип параметра query — это тип, который будет передан через переменную T .
Когда мы захотим воспользоваться этой функцией, то помимо данных для параметров array и query мы ещё должны передать информацию о типах (для переменной T ). В первом примере мы передаём тип string , а во втором — number .
// передаём string в качестве типа include < string >(['igor', 'sasha', 'ira'], 'ira'); // true // передаём number в качестве типа include < number >([1, 3, 5], 7); // false
Получается, что с помощью дженериков мы смогли написать код, который работает с разными типами значений. То есть, если коротко.
Дженерики — переменные, через которые мы можем передавать тип.
Ещё о JavaScript
- Type predicates в TypeScript на примере
- Типы данных в JavaScript. Инструкция для начинающих
- Живые и неживые коллекции в JavaScript
«Доктайп» — журнал о фронтенде. Читайте, слушайте и учитесь с нами.
Введение в дженерики – обобщенные классы и функции
В программировании иногда приходится обрабатывать разные типы данных похожим образом. С другой стороны, в языках со статической типизацией, к которым относится Kotlin, параметры функции типизированы. Это значит, что если функция принимает целое число, ей нельзя передать вещественное.
Частично проблему решает наследование, ведь мы можем присваивать переменным родительских типов объекты дочерних:
fun main() val a: Int = 10 val b: Double = 1.5 fraction(a) // 2.0 fraction(b) // 0.3 >
fun fraction(n: Number) println(n.toDouble() / 5) >
В данном случае Number – это класс Kotlin, который является родительским для числовых типов данных. Однако подобное не всегда подходит. Например, мы хотели бы возвращать из функции данные заранее точно неизвестного типа.
Некоторые языки программирования позволяют создавать так называемые дженерики (generics) – обобщенные функции и классы. Рассмотрим пример определения и вызова обобщенной функции на языке Kotlin.
fun main() val a: Int = 10 val b: String = "Hello" val c: ListInt> = listOf(1, 5) val aa: ListInt> = doTwo(a) val bb: ListString> = doTwo(b) val cc: ListListInt>> = doTwo(c) println(aa) // [10, 10] println(bb) // [Hello, Hello] println(cc) // [[1, 5], [1, 5]] >
fun T> doTwo(obj: T): ListT> val list: ListT> = listOf(obj, obj) return list >
Функция doTwo() не только способна принимать разный тип данных, но и возвращает разное.
T – это неизвестный на момент определения функции тип параметра. О том, что функция параметризована таким образом, что она обобщенная, относится к дженерикам, сообщается угловыми скобками перед ее именем. Буква T – это просто соглашение, обозначить неизвестный тип можно любым идентификатором.
Другими словами, записывая перед именем функции , мы говорим, что везде где в функции будет встречаться идентификатор T , его нужно будет заменить на тип, который будет известен в момент вызова функции. Когда функция doTwo() вызывается с аргументом-целым числом, то T становится Int , когда со списком – T становится List . Когда мы вызываем функцию, передавая ей строку, то тип параметра obj – это String , а возвращаемого из функции значения – List .
Не обязательно, чтобы все параметры функции-дженерика были параметризованы. Так ниже, у функции parePrint неизвестный тип имеет только один параметр, у второго тип определен – Char .
fun main() val a: Int = 10 val b: String = "Hello" val c: ListInt> = listOf(10, 16, 3) parePrint(a, ') // parePrint(b, '[') // [Hello] parePrint(c, '"') // "[10, 16, 3]" >
fun T> parePrint(obj: T, p: Char) when(p) '(', ')' -> println("($obj)") '[', ']' -> println("[$obj]") ', '>' -> println("") else -> println("$p$obj$p") > >
У обобщенного параметра может быть ограничение, которое записывается после двоеточия в его объявлении. Например, чтобы ограничить обобщенный параметр только числовыми типами, его следует объявить как :
fun main() val a: Int = 10 val b: Double = 1.5 println(fraction(a, 5)) // 2.0 println(fraction(b, 3)) // 0.5 >
fun T: Number> fraction(n: T, f: Int): Double return n.toDouble() / f >
В отличие от приведенного в начале урока примера обычной функции, в которой параметр n имеет тип Number , здесь n в момент вызова функции принимает более конкретный тип. Например, Int .
Обобщенными могут быть не только функции, но и классы. Хотя обобщены не они сами, а описанные в них свойства и методы. В случае класса обобщенный параметр указывается после имени класса.
class SomethingT> val prop: T constructor(p: T) prop = p > >
fun main() val a: SomethingInt> = Something(10) val b: SomethingString> = Something("Hello") println(a.prop) // 10 println(b.prop) // Hello >
Такой класс называют параметризованным, так как на его основе создаются объекты по сути разных типов. В примере мы не можем объект типа Something присвоить переменной, объявленной как Something .
Класс выше описан через вторичный конструктор для наглядности. Обычно используется первичный конструктор. Класс будет выглядеть так:
class SomethingT>(p: T) val prop: T = p >
class SomethingT>(val prop: T)
С подобным мы уже сталкивались, используя стандартную библиотеку Kotlin. Так массивы, списки и словари – это параметризованные классы.
fun main() { val a: ListInt> = listOf(4, 5) val b: MapChar, Int> = mapOf('a' to 2, 'b' to 10) }
В случае со словарями у класса не один, а два обобщенных параметра. В объявлении класса это выглядит примерно так:
class SomethingT, V>(p: T, q: V) val prop: T = p val qty: V = q >
Какими типами окажутся поля prop и qty определится только при создании объекта.
fun main() val a: SomethingString, Int> a = Something("Hello", 5) >
Generics
Дженерики (generics) в языке программирования Java — это сущности, которые могут хранить в себе данные только определенного типа. Например, список элементов, в котором могут быть одни числа. Но не только: дженерик — обобщенный термин для разных структур.
Освойте профессию «Java-разработчик»
Можно представить дженерик как папку для бумаг, куда нельзя положить ничего, кроме документов определенного формата. Это удобно: помогает разделить разные данные и не допустить ситуаций, когда в сущность передается что-то не то.
Дженерик-сущности еще иногда называют параметризованными, общими или обобщенными. Такая сущность создается со специальным параметром. Параметр позволяет указать, с каким типом данных она будет работать. Отсюда и название.
В разных источниках можно услышать про «тип-дженерик», «класс-дженерик» или «метод-дженерик». Это нормально, ведь обобщение и параметризация касаются всех этих сущностей, а generics — общий термин.
Для чего нужны дженерики
С дженериками работают программисты на Java. Без этой возможности писать код, который работает только с определенным видом данных, было сложнее. Существовало два способа, и оба неоптимальные:
- указывать проверку типа вкоде. Например, получать данные — и сразу проверять, а если они не те, выдавать ошибку. Это помогло бы отсеять ненужные элементы. Но если бы класс понадобилось сделать более гибким, например, создать его вариацию для другого типа, его пришлось бы переписывать или копировать. Не получилось бы просто передать другой специальный параметр, чтобы тот же класс смог работать еще с каким-то типом;
- полагаться на разработчиков. Например, оставлять в коде комментарий «Этот класс работает только с числами». Слишком велик риск, что кто-то не заметит комментарий и передаст в объект класса не те данные. И хорошо, если ошибка будет заметна сразу, а не уже на этапе тестирования.
Поэтому появились дженерики: они решают эту проблему, делают написание кода проще, а защиту от ошибок надежнее.
Профессия / 14 месяцев
Java-разработчик
Освойте востребованный язык
Как работают дженерики
Чтобы вернее понять принцип работы, нужно представлять, как устроены сущности в Java. Есть классы — это как бы «чертежи» будущих сущностей, описывающие, что они делают. И есть объекты — экземпляры классов, непосредственно существующие и работающие. Класс — как схема машины, объект — как машина.
Когда разработчик создает дженерик-класс, он приписывает к нему параметр в треугольных скобках — метку. К примеру, так:
Теперь при создании объекта этого класса нужно будет указать на месте T название типа, с которым будет работать объект. Например, myClass для целых чисел или myClass для строк. Сам класс остается универсальным, то есть общим. А вот каждый его объект специфичен для своего типа.
С помощью дженериков можно создать один класс, а потом на основе него — несколько объектов этого класса для разных типов. Не понадобится дублировать код и усложнять программу. Поэтому дженерики лучше и удобнее, чем проверка типа прямо в коде — тогда для каждого типа данных понадобился бы свой класс.
Что такое raw types
В Java есть понятие raw types. Так называют дженерик-классы, из которых удалили параметр. То есть изначально класс описали как дженерик, но при создании объекта этого класса тип ему не передали. То есть что-то вроде myClass<> — тип не указан.
Дословно это название переводится как «сырые типы». Пользоваться ими сейчас в коммерческой разработке — чаще всего плохая практика. Но в мире все еще много старого кода, который написали до появления дженериков. Если такой код еще не успели переписать, в нем может быть очень много «сырых типов». Это надо учитывать, чтобы не возникало проблем с совместимостью.
Дженерики-классы и дженерики-методы
Выше мы говорили, что дженериками могут быть разные сущности. Разберемся подробнее:
- дженерик-классы (generic classes)— это классы, «схемы» объектов с параметром. При создании объекта ему передается тип, с которым он будет работать;
- дженерик-методы (generics methods)— это методы, работающие по такому же принципу. Метод — это функция внутри объекта, то, что он может делать. Методу тип передается при вызове, сразу перед аргументами. Так можно создавать более универсальные функции и применять одну и ту же логику к данным разного типа.
Кстати, дженериками могут быть и встроенные классы или методы, и те, которые разработчик пишет самостоятельно. Например, встроенный ArrayList — список-массив — работает как дженерик.
Станьте Java-разработчиком
и создавайте сложные сервисы
на востребованном языке
Что будет, если передать дженерику не тот тип
Если объекту класса-дженерика передать не тот тип, который указали при его объявлении, он выдаст ошибку. Например, если в ходе работы экземпляра myClass в нем попытаются сохранить дробное число или даже строку, программа не скомпилируется. Вместо этого разработчик увидит ошибку: неверный тип.
Эта ошибка отличается от тех, которые возникнут, если не пользоваться дженериками. По ней сразу ясно, из-за чего она возникла и как можно ее исправить. Кроме того, она появляется сразу. Поэтому код становится легче отлаживать.
А если отправить «не тот» тип объекту без дженерика, действия с ним выполнятся с ошибкой. Но по этой ошибке не всегда очевидно, чем она вызвана. Худший вариант — код успешно запустится, но сработает неправильно: так ошибку будет найти еще сложнее.
Особенности дженериков
У дженериков есть несколько особенностей, о которых стоит знать при работе с ними. Если не учитывать эти детали, программировать будет как минимум менее удобно. А как максимум можно допустить ошибку и не понять, куда она закралась.
Выведение типа. Эта особенность касается объявления экземпляра класса, то есть создания объекта. Полная запись создания будет выглядеть так:
myClass objectForIntegers = new myClass();
objectForIntegers — это название объекта, оно может быть любым. То, что находится после знака «равно», — непосредственно команда «создать новый экземпляр класса».
Но полная запись очень громоздкая. Поэтому современные компиляторы Java способны на выведение типа — автоматическую его подстановку в записи после первого упоминания. То есть конструкцию myClass понадобится написать только один раз.
Запись, в которой программист пользуется возможностью выведения типа, будет выглядеть так:
myClass objectForIntegers = new myClass<>();
Повторное упоминание типа опускается. Запись становится короче. Кажется, что это мелочь, но таких конструкций в коде могут быть десятки и писать полную запись всегда было бы не очень удобно.
Стирание типов. Важная деталь, которая касается работы дженериков, — они существуют только на этапе компиляции. В этом их суть: «не пропускать» данные ненужного типа в объект, а такие вещи определяет компилятор.
После компиляции код на Java превращается в байт-код. И на этом уровне никаких дженериков нет. myClass и myClass в байт-коде будут идентичны, просто с разными данными внутри.
Это называется стиранием типов. Суть в том, что внутри дженерик-класса нет информации о его параметре и после компиляции эти сведения просто исчезают. Так сделали, потому что дженерики появились в Java не сразу. Если бы информацию о параметре добавили в байт-код, это сломало бы совместимость с более старыми версиями.
О стирании типов важно помнить. Для запущенной программы в байт-коде дженериков не существует, и это может вызвать ошибки. Например, при сравнении myClass и myClass программа скажет, что они одинаковые. А иногда в объект в запущенном коде и вовсе получается передать данные другого типа.
«Дикие карты». Еще одна интересная и полезная особенность дженериков — так называемые wildcards, или «дикие карты». Это термин из спорта, означающий особое приглашение спортсмена на соревнование в обход правил. А в карточных играх так называют карты, которые можно играть вместо других, например джокера.
В основе wildcards в Java лежит такая же идея: изменить предустановленное поведение и сделать что-то в обход установленных рамок. Когда объявляется «дикая карта», в треугольных скобках вместо названия типа ставится вопросительный знак. Это означает, что сюда можно подставить любой тип.
Подставить wildcard можно не везде. Например, при создании класса это сделать не получится, а при объявлении объекта этого класса — получится. Чаще всего «дикую карту» используют при работе с переменными и с коллекциями.
Ограниченные «дикие карты». Кроме стандартной wildcard, существует еще несколько типов — ограниченные «дикие карты». С их помощью можно передать в объект данные не только конкретного типа, но и унаследованных от него — «потомков». Или же «предков» — типов, от которых был унаследован упомянутый.
Ограниченный wildcard описывается как вопросительный знак, за которым следует правило.
Есть два вида ограничений:
- upper bounding — ограничение сверху. За вопросительным знаком следует слово extends и название типа. В такой дженерик можно передавать названный тип и его потомков;
- lower bounding — ограничение снизу. Ситуация наоборот: за вопросительным знаком слово super и тип, а подставлять можно элементы этого типа и его предков.
Скорее всего, впервые столкнуться с дженериками придется еще в начале изучения Java, просто новичку не сразу понятно, что это такое. Со временем появляется понимание, как работает эта конструкция, и становится легче решать более сложные задачи.
Java-разработчик
Java уже 20 лет в мировом топе языков программирования. На нем создают сложные финансовые сервисы, стриминги и маркетплейсы. Освойте технологии, которые нужны для backend-разработки, за 14 месяцев.
Статьи по теме:
Дженерики в Go — подробности из блога разработчиков
В Go 1.18 добавлена поддержка дженериков. Это самое большое нововведение с момента первого Open Source выпуска Go. Не будем пытаться охватить все детали, затронем всё важное. Подробное описание со множеством примеров смотрите в документе с предложением по улучшению языка. Материалом делимся к старту курса по Backend-разработке на Go.
Введение в дженерики
За основу этого поста взято наше выступление на GopherCon 2021:
Точное описание изменений в Go см. в обновлённой спецификации языка. (Внимание: в фактической реализации 1.18 на то, что разрешено в документе с предложением по улучшению, наложены ограничения. Спецификация должна быть точной. В будущих выпусках некоторые ограничения могут быть сняты.)
Дженерики — это способ написания кода, который не зависит от конкретных применяемых типов. Функции и типы теперь могут быть написаны для любого набора типов.
С дженериками в язык добавляются три важные функциональные возможности:
- Типы как параметры для функций и типов.
- Определение интерфейсных типов как наборов типов, в том числе типов без методов.
- Выведение типа, когда во многих случаях типы аргументов при вызове функции опускаются.
Типы как параметры
В функциях и типах теперь параметром может быть тип. Список таких параметров выглядит как список обычных параметров, но вместо круглых скобок используются квадратные.
Чтобы показать принцип работы, начнём с простой функции Min без параметров, для значений с плавающей точкой:
func Min(x, y float64) float64 < if x < y < return x >return y >
Параметризуем эту функцию для работы с разными типами, вместо типа float64 добавив список с одним параметром типа T:
import "golang.org/x/exp/constraints" func GMin[T constraints.Ordered](x, y T) T < if x < y < return x >return y >
И вызовем её с типом в качестве аргумента:
x := GMin[int](2, 3)
Указание в GMin типа int как аргумента называется инстанцированием. В компиляторе инстанцирование происходит в два этапа:
- Замена всех аргументов-типов на соответствующие типам параметры.
- Проверка, что каждый тип соответствует своим ограничениям. Подробности позже. Если второй этап не пройден, инстанцирование не происходит и программа не будет работать.
После инстанцирования оставшаяся без параметров функция вызывается так же, как и любая другая. Например, в этом коде:
fmin := GMin[float64] m := fmin(2.71, 3.14)
при инстанцировании GMin[float64] фактически получается исходная функция Min для значений с плавающей точкой. Эту функцию можно вызывать.
У типов теперь тоже могут быть параметры:
type Tree[T interface<>] struct < left, right *Tree[T] value T >func (t *Tree[T]) Lookup(x T) *Tree[T] < . >var stringTree Tree[string]
Здесь в дженерик-типе Tree хранятся значения параметра типа T. В дженерик-типах могут быть и методы, такие как Lookup выше. Чтобы использовать дженерик-тип, его нужно инстанцировать. Tree[string] — пример инстанцирования Tree с типом-аргументом string.
Наборы типов
Рассмотрим подробнее аргументы-типы, применяемые для инстанцирования типа как параметра.
У обычной функции для каждого значения параметра есть тип, определяющий возможный набор значений. Так, в нашей функции Min с типом float64 для аргумента допустим набор значений с плавающей точкой, которые могут быть представлены этим типом.
Аналогично, в списках типов как параметров тип есть у каждого параметра. Но тип-параметр — сам по себе тип, а значит, типы-параметры определяют наборы типов. Такой набор (метатип) также называется ограничением типа.
В параметризованной функции GMin ограничение типа импортируется из пакета constrains. В ограничении Ordered описывается набор всех типов со значениями, которые можно упорядочить или, другими словами, сравнить через операторы < (или и т. д.).
Это ограничение гарантирует передачу в GMin только типов с упорядоченными значениями. Кроме того, значения параметра этого типа могут использоваться в теле функции GMin с оператором сравнения
В Go ограничения типа должны быть интерфейсами, поэтому интерфейсный тип может быть типом для значения и метатипом. Интерфейсы определяют методы. Поэтому очевидно, что мы можем выразить ограничения типа, требующие наличия определённых методов.
До недавнего времени в спецификации Go было заявлено, что интерфейс определяет набор методов, примерно соответствующий перечисленному в интерфейсе набору. Любой тип, реализующий все методы набора, реализует соответствующий интерфейс:
Но можно сказать, что интерфейс определяет набор типов, реализующих методы набора. С этой точки зрения любой тип, который является элементом набора типов интерфейса, реализует интерфейс.
Два этих подхода приводят к одному результату: для каждого набора методов можно представить соответствующий набор типов, реализующих эти методы, и это набор определяемых интерфейсом типов.
Но для наших целей подход с набором типов предпочтительнее: можно явно добавлять типы в набор и, таким образом, по-новому управлять набором типов. Для этого мы расширили синтаксис интерфейсных типов. Например, interface < int|string|bool >определяет набор типов int, string и bool:
По новому подходу этому интерфейсу соответствуют только int, string или bool.
Рассмотрим фактическое определение contraints.Ordered:
type Ordered interface
Здесь интерфейс Ordered — это набор всех целочисленных типов, типов числа с плавающей точкой и строковых типов. Вертикальная полоса обозначает объединение типов (или наборов типов в данном случае).
Integer и Float — это интерфейсные типы, аналогично определённых в пакете constraints. Обратите внимание: нет методов, определяемых интерфейсом Ordered.
Что касается ограничений типа, конкретный тип (например, string) нас обычно не так интересует, как все строковые типы. Вот для чего нужен токен ~: выражение ~string означает набор всех типов с базовым типом string. Это сам тип string и все типы, объявленные с такими определениями, как type MyString string.
Конечно, методы всё равно нужно указывать в интерфейсах и с сохранением обратной совместимости. В Go 1.18, как и прежде, в интерфейсе могут иметь место методы и встроенные интерфейсы, а ещё — встроенные неинтерфейсные типы, объединения и наборы базовых типов.
Набор определяемых интерфейсом типов, когда интерфейс используется в качестве ограничения типа, точно указывает типы, разрешённые как типы-аргументы для соответствующего параметра типа.
В теле параметризованной функции, тип операнда которой — тип-параметр P с ограничением C, операции разрешены, если они разрешены всеми типами в наборе типов C. Сейчас здесь есть ряд ограничений реализации, но в обычном коде встреча с ними маловероятна.
Используемым как ограничения интерфейсам можно присваивать имена (например, Ordered). Или они могут быть литеральными интерфейсами, встроенными в список типов-параметров. Например:
[S interface<~[]E>, E interface<>]
Здесь S — это тип среза, тип конкретного элемента среза может быть любым.
Это типичный случай, поэтому внешний interface<> для интерфейсов в позиции ограничения можно опустить и просто написать:
[S ~[]E, E interface<>]
Пустой интерфейс часто встречается в списках типов как параметров, да и в обычном коде на Go тоже. Поэтому в качестве псевдонима для пустого интерфейсного типа в Go 1.18 появился новый предварительно объявляемый идентификатор any. С ним получаем идиоматический код:
[S ~[]E, E any]
Интерфейсы как наборы типов — это новый мощный механизм и ключевой фактор для работы ограничений типа на Go. Пока интерфейсы с новыми синтаксическими формами могут использоваться только в качестве ограничений, но нетрудно представить, насколько в целом могут быть полезны ограничивающие тип интерфейсы с явным определением типов.
Выведение типов
Новая значительная возможность языка — выведение типов. Это самое сложное, но и важное нововведение, допускающее при написании кода применение естественного стиля, где вызываются параметризованные функции.
Выведение типа-аргумента функции
С типами-параметрами связана необходимость передачи типов как аргументов, которая может привести к перегруженности кода.
Вернёмся к параметризованной функции GMin:
func GMin[T constraints.Ordered](x, y T) T
Тип-параметр T нужен, чтобы не указывать обычные типы x и y. Как мы видели ранее, эта функция может вызываться с помощью аргумента с явно заданным типом:
var a, b, m float64 m = GMin[float64](a, b) // explicit type argument
В компиляторе во многих случаях тип-аргумент для T может выводиться из обычных аргументов. Результат — столь же чёткий код, но короче:
var a, b, m float64 m = GMin(a, b) // no type argument
Эффект достигается сопоставлением типов аргументов a и b с типами параметров x и y.
Такое выведение аргументов-типов из типов аргументов в функцию называется выведением типа аргумента функции. Оно происходит только для параметров типа, которые используются в параметрах функции, а не исключительно в результатах функции или в её теле.
Например, выведение типа не применяется к таким функциям, как MakeT[T any]() T, в которых T используется только для результата.
Выведение типа ограничения
Язык поддерживает выведение типа ограничения. Чтобы описать его, начнём с этого примера масштабирования среза целых чисел:
// Scale returns a copy of s with each element multiplied by c. // This implementation has a problem, as we will see. func Scale[E constraints.Integer](s []E, c E) []E < r := make([]E, len(s)) for i, v := range s < r[i] = v * c >return r >
Это параметризованная функция, она работает для среза любого целочисленного типа.
Рассмотрим многомерный тип Point, где каждый Point — это список целых чисел, определяющих координаты точки. Конечно, у этого типа есть методы:
type Point []int32 func (p Point) String() string < // Details not important. >
Чтобы масштабировать Point, пригодится функция Scale:
// ScaleAndPrint doubles a Point and prints it. func ScaleAndPrint(p Point) < r := Scale(p, 2) fmt.Println(r.String()) // DOES NOT COMPILE >
Но она не компилируется и завершается ошибкой r.String undefined (type []int32 has no field or method String) .
Проблема заключается в том, что функция Scale возвращает значение типа []E, где E — это тип элемента среза аргумента. Когда мы вызываем Scale со значением типа Point, базовый тип которого — []int32, то получаем значение типа []int32, а не Point. Это обусловлено самим способом написания кода (дженериком). Но это не то, что нам здесь нужно.
Решим проблему, изменив функцию Scale (для типа среза используем тип-параметр:
// Scale returns a copy of s with each element multiplied by c. func Scale[S ~[]E, E constraints.Integer](s S, c E) S < r := make(S, len(s)) for i, v := range s < r[i] = v * c >return r >
Мы ввели новый тип-параметр среза S и ограничили его так, чтобы базовым типом стал S, а не []E, и типом результата также был S. Но E может быть только целым числом, поэтому эффект тот же, что и раньше: первый аргумент должен быть срезом целочисленного типа. Единственное изменение в теле функции: когда мы вызываем make — передаём S, а не []E.
Поведение новой функции — такое же, как и у прежней, если вызывать её с помощью обычного среза. Если же использовать тип Point, то получим значение типа Point. Это то, что нам нужно. В этой версии Scale более ранняя функция ScaleAndPrint будет компилироваться и запускаться, как мы ожидаем.
Но почему можно писать вызов к Scale без передачи аргументов с явно заданным типом? То есть почему, вместо того чтобы писать Scale[Point, int32](p, 2), мы можем написать Scale(p, 2) без типов-аргументов?
В новой функции Scale теперь два типа-параметра: S и E. Поскольку при вызове к Scale никаких типов-аргументов не передаётся, то описанный выше механизм выведения типа-аргумента функции позволяет компилятору в качестве типа-аргумента для S вывести Point.
Но у функции есть ещё тип-параметр E, — это тип множителя с . Соответствующий аргумент функции равен 2, а поскольку 2 — это нетипизированная константа, вывод типа аргумента функции не может вывести правильный тип для E: в лучшем случае он может вывести тип по умолчанию для 2, — int, что неверно.
Вместо этого происходит процесс, с помощью которого в компиляторе выводится, что тип-аргумент для E — это тип элемента среза. Этот процесс называется выведением типа ограничения. Типы-аргументы выводятся из ограничений параметров типа.
Выведение типа ограничения применяется, когда у одного типа-параметра есть ограничение, определяемое как другой тип-параметр. Когда тип-параметр одного из таких парметров известен, ограничение используется для выведения типа-аргумента другого типа-аргумента.
Обычный случай применения такого вывода — когда в одном ограничении используется форма ~type для типа, в свою очередь записываемого с помощью других типов-параметров. Мы видим это в примере со Scale.
Здесь S — это ~[]E, то есть ~, за которым идёт тип []E, написанный как другой тип-параметр. Если мы знаем тип-аргумент для S, то можем вывести и тип-аргумент для E. S — это тип среза, а E — тип элемента этого среза.
Мы рассмотрели лишь основы выведения типа ограничения. Подробности смотрите в документе с предложением или в спецификации языка.
Выведение типа на практике
Механизм выведения типа сложен, но применять его просто: выведение типа либо происходит, либо нет. Если тип выводится, типы-аргументы можно опустить — тогда вызов параметризованных функций ничем не отличается от вызова обычных функций. Если выведение типа не происходит, в компиляторе выдаётся сообщение об ошибке — тогда мы можем просто указать необходимые типы-аргументы.
Добавляя в язык выведение типа, мы стремились к оптимальному сочетанию возможностей и сложности — чтобы выводимые в компиляторе типы никогда не вызывали удивления. Мы старались исключить возможность выведения неверного типа, то лучше он не будет выведен.
Возможно, у нас это не совсем получилось, и в будущих выпусках работа в этом смысле будет продолжена. В итоге можно будет написать больше программ без аргументов с явно заданным типом. Программам, которым типы в качестве аргументов не нужны сегодня, такие аргументы не понадобятся и завтра.
Заключение
Дженерики — самое большое нововведение в Go 1.18. Языковым изменениям потребовалось много нового кода, который не проходил серьёзного тестирования в производственных условиях. Оно будет пройдено, только когда больше людей воспользуются дженериками.
Мы считаем, что их функционал хорошо реализован. Но это, в отличие от большинства аспектов Go, нельзя подкрепить реальным опытом. Поэтому, хотя мы и приветствуем использование дженериков там, где это имеет смысл, призываем быть осторожными при развёртывании такого кода на продакшене. Тем не менее надеемся, что с появлением дженериков программисты Go станут продуктивнее.
А мы поможем вам прокачать навыки или освоить профессию в IT, востребованную в любое время:
- Профессия Backend-разработчик на Go
- Профессия Fullstack-разработчик на Python
Краткий каталог профессий и курсов
Data Science и Machine Learning
- Профессия Data Scientist
- Профессия Data Analyst
- Курс «Математика для Data Science»
- Курс «Математика и Machine Learning для Data Science»
- Курс по Data Engineering
- Курс «Machine Learning и Deep Learning»
- Курс по Machine Learning
Python, веб-разработка
- Профессия Fullstack-разработчик на Python
- Курс «Python для веб-разработки»
- Профессия Frontend-разработчик
- Профессия Веб-разработчик
Мобильная разработка
- Профессия iOS-разработчик
- Профессия Android-разработчик
Java и C#
- Профессия Java-разработчик
- Профессия QA-инженер на JAVA
- Профессия C#-разработчик
- Профессия Разработчик игр на Unity
От основ — в глубину
- Курс «Алгоритмы и структуры данных»
- Профессия C++ разработчик
- Профессия Этичный хакер
А также