Что такое дженерики в программировании
Перейти к содержимому

Что такое дженерики в программировании

  • автор:

Введение в дженерики – обобщенные классы и функции

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

Дженерики Java

Дженерики (или обобщения) — это параметризованные типы.

Параметризованные типы позволяют объявлять классы, интерфейсы и методы, где тип данных, которыми они оперируют, указан в виде параметра. Используя дженерики, можно создать единственный класс, например, который будет автоматически работать с разными типами данных.

Классы, интерфейсы или методы, имеющие дело с параметризованными типами, называются параметризованными или обобщениями, параметризованными (обобщенными) классами или параметризованными (обобщёнными) методами.

Обобщения добавили в язык безопасность типов.

  1. Параметризованные классы
  2. Ограниченные типы
  3. Применение метасимвольных аргументов
  4. Параметризованные методы и конструкторы
  5. Параметризованные интерфейсы
  6. Иерархии параметризованных классов
  7. Использование оператора instanceof c параметризованными классами
  8. Ограничения присущие обобщениям

1. Параметризованные классы

Следующий пример демонстрирует использование параметризованного класса, который описывает матрицу:

public class Matrix  < private T[] array; public Matrix(T[] array) < this.array = array.clone(); >public static void main(String[] args) < MatrixdoubleMatrix = new Matrix<>(new Double[2]); Matrix integerMatrix = new Matrix<>(new Integer[4]); Matrix byteMatrix = new Matrix<>(new Byte[7]); > > 

В объявлении Matrix integerMatrix Integer является аргументом типа.

Java не создает разные версии класса Matrix или любого другого параметризованного класса. Имеется только одна версия класса Matrix , которая существует в прикладной программе.

Дженерики работают только с объектами! Следующий код является неправильным:

Gen strOb = new Gen (53); // Ошибка, нельзя использовать примитивные типы 

Т обозначает имя параметра типа. Это имя используется в качестве заполнителя вместо которого в дальнейшем подставляется имя конкретного типа, передаваемого классу Matrix при создании объекта. Это означает, что обозначение Т применяется в классе Matrix всякий раз, когда требуется параметр типа. Всякий раз, когда объявляется параметр типа, он указывается в угловых скобках.

Обобщенные типы отличаются в зависимости от типов-аргументов. Следующий код не допустим:

doubleMatrix = integerMatrix; // Не верно! 

Несмотря на то, что doubleMatrix и integerMatrix имеют тип Matrix , они являются ссылками на разные типы, потому что типы их параметров отличаются.

Обобщенный класс может быть объявлен с любым количеством параметров типа. Например:

public class TwoGen  < private T obT; private V obV; public TwoGen(T obT, V obV) < this.obT = obT; this.obV = obV; >public void showTypes() < System.out.println("Тип T: " + obT.getClass().getName()); System.out.println("Тип V: " + obV.getClass().getName()); >public T getObT() < return obT; >public V getObV() < return obV; >> public class SimpleGen < public static void main(String[] args) < TwoGentwoGen = new TwoGen<>(88, "Generics"); twoGen.showTypes(); System.out.println("Значение T: " + twoGen.getObT()); System.out.println("Значение V: " + twoGen.getObV()); > > 

2. Ограниченные типы

Указывая параметр типа, можно наложить ограничение сверху в виде верхней границы, где объявляется супер класс, от которого должны быть унаследованы все аргументы типов. С этой целью вместе с параметром указывается ключевое слово extends :

class Gen

Параметр типа Т может быть заменен только указанным супер классом или его подклассами.

Рассмотрим пример использования ограниченного типа:

public class Average  < private T[] array; public Average(T[] array) < this.array = array; >public double average() < double sum = 0.0; for (T value : array) < sum += value.doubleValue(); >return sum / array.length; > > public class AverageDemo < public static void main(String[] args) < Integer[] intArray = ; Average integerAverage = new Average<>(intArray); System.out.println("Среднее значения для Integer " + integerAverage.average()); Double[] doubleArray = ; Average doubleAverage = new Average<>(doubleArray); System.out.println("Среднее значения для Double " + doubleAverage.average()); // Не откомпилируется, // потому что String не является наследником Number /* String[] strArray = ; Average strAverage = new Average<>(strArray); System.out.println("Среднее значения для String " + strAverage.average());*/ > >

В виде ограничения можно накладывать не только тип класса, но и тип интерфейса:

public class MyClass

Ограничение может включать в себя как тип класса, так и типы одного или нескольких интерфейсов:

class Gen

Тип класса должен быть задан первым. Накладывая на обобщенный тип ограничение, состоящее из класса и одного или нескольких интерфейсов, для их объединения следует воспользоваться логической операцией &: Таким образом, любой тип, передаваемый параметру Т , должен быть подклассом, производным от класса MyClass и реализующим интерфейсы MyInterface1 и MyInterface2 .

3. Применение метасимвольных аргументов

Представьте, что мы хотим добавить метод для сравнения средних значений массивов в класс Average из примера 3. Причем типы массивов могут быть разные:

Integer intArray[] = ; Double doubleArray[] = ; Average iob = new Average<>(intArray); Average dob = new Average<>(doubleArray); if (iob.sameAvg(dob)) < System.out.println("are the same.");>else

Так как Average параметризованный тип, какой тип параметра вы укажете для Average , когда создадите параметр метода типа Average ? Напрашивается следующий вариант:

boolean sameAvg(Average ob)

Но это не сработает, так как в этом случае метод sameAvg будет принимать аргументы только того же типа, что и существующий объект:

if (iob.sameAvg(iob)) < System.out.println("are the same.");>else

Чтобы создать обобщенную версию метода sameAvg() , следует воспользоваться другим средством обобщений Jаvа – метасимвольным аргументом. Метасимвольный аргумент обозначается знаком ? и представляет неизвестный тип.

boolean sameAvg(Average ob)

Мета символ не оказывает никакого влияния на тип создаваемых объектов класса Average . Это определяется оператором extends в объявлении класса Average. Мета символ просто совпадает с любым достоверным объектом класса Average .

Метасимвольные аргументы могут быть ограничены почти таким же образом, как и параметры типов. Ограничивать метасимвольный аргумент особенно важно при создании обобщенного типа, оперирующего иерархией классов. Например:

public class Average2  < private T[] array; public Average2(T[] array) < this.array = array.clone(); >public double average() < double sum = 0.0; for (T value : array) < sum += value.doubleValue(); >return sum / array.length; > boolean sameAvg(Average2 ob) < return average() == ob.average(); >> public class AverageDemo2 < public static void main(String[] args) < Integer[] intArray = ; Average2 iob = new Average2<>(intArray); System.out.println("Среднее значения для Integer " + iob.average()); Double[] doubleArray = ; Average2 dob = new Average2<>(doubleArray); System.out.println("Среднее значения для Double " + dob.average()); Float[] floatArray = ; Average2 fob = new Average2<>(floatArray); System.out.println("Среднее значения для Float " + fob.average()); System.out.print("Средние значения для iob и dob "); if (iob.sameAvg(dob)) < System.out.println("одинаковые."); >else < System.out.println("разные."); >System.out.print("Средние значения для iob и fob "); if (iob.sameAvg(fob)) < System.out.println("одинаковые."); >else < System.out.println("разные."); >> >

4. Параметризованные методы и конструкторы

В методах параметризованного класса можно использовать параметр типа, а следовательно, они становятся параметризованными относительно параметра типа.

Но можно объявить параметризованный метод, в котором непосредственно используется один или несколько параметров типа. Более того, можно объявить параметризованный метод, входящий в не параметризованный класс. Например:

public class GenMethodDemo < /** * Является ли объект x элементом массива array * * @param x * @param array * @param * @param * @return */ public static boolean isIn(T x, V[] array) < for (V element : array) < if (x.equals(element)) < return true; >> return false; > public static void main(String[] args) < Integer[] intArray = ; if (isIn(2, intArray)) < System.out.println("2 входит в массив intArray"); >if (!isIn(7, intArray)) < System.out.println("7 не входит в intArray"); >System.out.println(); String[] strArray = ; if (isIn("two", strArray)) < System.out.println("two входит в массив strArray"); >if (!isIn("seven", strArray)) < System.out.println("seven не входит в массив strArray"); >> >

Конструкторы также могут быть обобщенными, даже если их классы таковыми не являются. Например:

public class GenConstructor < private double value; public GenConstructor(T arg) < value = arg.doubleValue(); >public void showValue() < System.out.println("value: " + value); >> public class GenConstructorDemo < public static void main(String[] args) < GenConstructor genConstructor1 = new GenConstructor(100); GenConstructor genConstructor2 = new GenConstructor(123.5F); genConstructor1.showValue(); genConstructor2.showValue(); >>

5. Параметризованные интерфейсы

В дополнение к обобщенным классам и методам вы можете объявлять параметризованные интерфейсы. Параметризованные интерфейсы специфицируются так же, как и обобщенные классы:

public interface MyInterface  < T someMethod(T t); >public class MyClass implements MyInterface  < @Override public T someMethod(T t) < return t; >public static void main(String[] args) < MyInterfaceobject = new MyClass<>(); String str = object.someMethod("some string"); > >

6. Иерархии параметризованных классов

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

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

public class GenericSuper  < private T ob; public GenericSuper(T ob) < this.ob = ob; >private T getOb() < return ob; >> public class GenericSub extends GenericSuper  < public GenericSub(T ob) < super(ob); >>

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

7. Использование оператора instanceof c параметризованными классами

public class HierarchyDemo < public static void main(String[] args) < GenericSuperobject = new GenericSub<>(88); if (object instanceof GenericSuper) < System.out.println("object is instance of GenericSuper"); >if (object instanceof GenericSub) < System.out.println("object is instance of GenericSub"); >// Ошибка компиляции - информация об обобщенном типе недоступна во время выполнения /* if (object instanceof GenericSub) < System.out.println("object is instance of GenericSub"); >*/ > >

8. Ограничения присущие обобщениям

Обобщениям присущи некоторые ограничения. Рассмотрим их:

1. Нельзя создавать экземпляр по параметру типа. Ни обычный объект, ни массив:

public class GenRestriction  < private T ob; private T[] array; public GenRestriction(T ob, T[] array) < // Недопустимо. //оb = new Т(); //array = new Т[10]; this.ob = ob; this.array = array; >>

2. Нельзя создать массив специфических для типа обобщенных ссылок:

public class GenArrays < public static void main(String[] args) < // Нельзя создать массив специфичных для типа обобщенных ссылок. // GenericSub[] gens = new GenericSub[10]; GenericSub[] gens = new GenericSub[10]; gens[0] = new GenericSub<>(34); > >

3. Нельзя создавать обобщенные статические переменные и методы. Но объявить статические обобщенные методы со своими параметрами типа все же можно:

public class GenericWrongStatic < // Неверно, нельзя создать статические переменные типа Т. //public static Т оb; // Неверно, ни один статический метод не может использовать Т. /* public static T getOb() < return оb; >*/ //Но объявить статические обобщенные методы со своими параметрами типа можно public static void getOb(V v) < System.out.println(v); >>
  • Вложенные классы
  • Задания

Для чего использовать дженерики в 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

«Доктайп» — журнал о фронтенде. Читайте, слушайте и учитесь с нами.

Дженерики в Go — подробности из блога разработчиков

В Go 1.18 добавлена поддержка дженериков. Это самое большое нововведение с момента первого Open Source выпуска Go. Не будем пытаться охватить все детали, затронем всё важное. Подробное описание со множеством примеров смотрите в документе с предложением по улучшению языка. Материалом делимся к старту курса по Backend-разработке на Go.

Введение в дженерики

За основу этого поста взято наше выступление на GopherCon 2021:

Точное описание изменений в Go см. в обновлённой спецификации языка. (Внимание: в фактической реализации 1.18 на то, что разрешено в документе с предложением по улучшению, наложены ограничения. Спецификация должна быть точной. В будущих выпусках некоторые ограничения могут быть сняты.)

Дженерики — это способ написания кода, который не зависит от конкретных применяемых типов. Функции и типы теперь могут быть написаны для любого набора типов.

С дженериками в язык добавляются три важные функциональные возможности:

  1. Типы как параметры для функций и типов.
  2. Определение интерфейсных типов как наборов типов, в том числе типов без методов.
  3. Выведение типа, когда во многих случаях типы аргументов при вызове функции опускаются.

Типы как параметры

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

Чтобы показать принцип работы, начнём с простой функции 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 как аргумента называется инстанцированием. В компиляторе инстанцирование происходит в два этапа:

  1. Замена всех аргументов-типов на соответствующие типам параметры.
  2. Проверка, что каждый тип соответствует своим ограничениям. Подробности позже. Если второй этап не пройден, инстанцирование не происходит и программа не будет работать.

После инстанцирования оставшаяся без параметров функция вызывается так же, как и любая другая. Например, в этом коде:

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++ разработчик
  • Профессия Этичный хакер

А также

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *