Всем привет! Команда TestMace публикует очередной перевод статьи из мира web-разработки. На этот раз для новичков! Приятного чтения.
Развеем пелену таинственности и недопонимания над синтаксисом <T>
и наконец подружимся с ним
Наверное, только матёрые разработчики Java или других строго типизированных языков не хлопают глазами, увидев дженерик в TypeScript. Его синтаксис коренным образом отличается от всего того, что мы привыкли видеть в JavaScript, поэтому так непросто сходу догадаться, что он вообще делает.
Я бы хотел показать вам, что на самом деле всё гораздо проще, чем кажется. Я докажу, что если вы способны реализовать на JavaScript функцию с аргументами, то вы сможете использовать дженерики без лишних усилий. Поехали!
Дженерики в TypeScript
В документации TypeScript приводится следующее определение: "дженерики — это возможность создавать компоненты, работающие не только с одним, а с несколькими типами данных".
Здорово! Значит, основная идея состоит в том, что дженерики позволяют нам создавать некие повторно используемые компоненты, работающие с различными типами передаваемых им данных. Но как это возможно? Вот что я думаю.
Дженерики и типы соотносятся друг с другом, как значения и аргументы функции. Это такой способ сообщить компонентам (функциям, классам или интерфейсам), какой тип необходимо использовать при их вызове так же, как во время вызова мы сообщаем функции, какие значения использовать в качестве аргументов.
Лучше всего разобрать это на примере дженерика тождественной функции. Тождественная функция — это функция, возвращающая значение переданного в неё аргумента. В JavaScript она будет выглядеть следующим образом:
function identity (value) {
return value;
}
console.log(identity(1)) // 1
Сделаем так, чтобы она работала с числами:
function identity (value: Number) : Number {
return value;
}
console.log(identity(1)) // 1
Отлично, мы добавили в определение тождественной функции тип, но хотелось бы, чтобы она была более гибкой и срабатывала для значений любого типа, а не только для чисел. Именно для этого и нужны дженерики. Они позволяют функции принимать значения любого типа данных на входе и, в зависимости от них, преобразовывать саму функцию.
function identity <T>(value: T) : T {
return value;
}
console.log(identity<Number>(1)) // 1
Ох уж этот странный синтаксис <T>
! Отставить панику. Мы всего лишь передаём тип, который хотим использовать для конкретного вызова функции.
Посмотрите на картинку выше. Когда вы вызываете identity<Number>(1)
, тип Number
— это такой же аргумент, как и 1. Он подставляется везде вместо T
. Функция может принимать несколько типов аналогично тому, как она принимает несколько аргументов.
Посмотрите на вызов функции. Теперь-то синтаксис дженериков не должен вас пугать. T
и U
— это просто имена переменных, которые вы назначаете сами. При вызове функции вместо них указываются типы, с которыми будет работать данная функция.
Альтернативная версия понимания концепции дженериков состоит в том, что они преобразуют функцию в зависимости от указанного типа данных. На анимации ниже показано, как меняется запись функции и возвращаемый результат при изменении типа.
Как можно видеть, функция принимает любой тип, что позволяет создавать повторно используемые компоненты различных типов, как и было обещано в документации.
Обратите особое внимание на второй вызов console.log на анимации выше — в него не передаётся тип. В этом случае TypeScript попытается вычислить тип по переданным данным.
Обобщённые классы и интерфейсы
Вам уже известно, что дженерики — это всего лишь способ передать типы в компонент. Только что вы видели, как они работают с функциями, и у меня хорошие новости: с классами и интерфейсами они работают точно таким же образом. В этом случае указание типов следует после имени интерфейса или класса.
Посмотрите на пример и попробуйте разобраться сами. Надеюсь, у вас получилось.
interface GenericInterface<U> {
value: U
getIdentity: () => U
}
class IdentityClass<T> implements GenericInterface<T> {
value: T
constructor(value: T) {
this.value = value
}
getIdentity () : T {
return this.value
}
}
const myNumberClass = new IdentityClass<Number>(1)
console.log(myNumberClass.getIdentity()) // 1
const myStringClass = new IdentityClass<string>("Hello!")
console.log(myStringClass.getIdentity()) // Hello!
Если код сразу не понятен, попробуйте отследить значения type
сверху вниз вплоть до вызовов функции. Порядок действий следующий:
- Создаётся новый экземпляр класса
IdentityClass
, и в него передаются типNumber
и значение1
. - В классе значению
T
присваивается типNumber
. IdentityClass
реализуетGenericInterface<T>
, и нам известно, чтоT
— этоNumber
, а такая запись эквивалентна записиGenericInterface<Number>
.- В
GenericInterface
дженерикU
становитсяNumber
. В данном примере я намеренно использовал разные имена переменных, чтобы показать, что значение типа переходит вверх по цепочке, а имя переменной не имеет никакого значения.
Реальные случаи использования: выходим за рамки примитивных типов
Во всех приведённых выше вставках кода были использованы примитивные типы вроде Number
и string
. Для примеров самое то, но на практике вы вряд ли станете использовать дженерики для примитивных типов. Дженерики будут по-настоящему полезны при работе с произвольными типами или классами, формирующими дерево наследования.
Рассмотрим классический пример наследования. Допустим, у нас есть класс Car
, являющийся основой классов Truck
и Vespa
. Пропишем служебную функцию washCar
, принимающую обобщённый экземпляр Car
и возвращающую его же.
class Car {
label: string = 'Generic Car'
numWheels: Number = 4
horn() {
return "beep beep!"
}
}
class Truck extends Car {
label = 'Truck'
numWheels = 18
}
class Vespa extends Car {
label = 'Vespa'
numWheels = 2
}
function washCar <T extends Car> (car: T) : T {
console.log(`Received a ${car.label} in the car wash.`)
console.log(`Cleaning all ${car.numWheels} tires.`)
console.log('Beeping horn -', car.horn())
console.log('Returning your car now')
return car
}
const myVespa = new Vespa()
washCar<Vespa>(myVespa)
const myTruck = new Truck()
washCar<Truck>(myTruck)
Сообщая функции washCar
, что T extends Car
, мы обозначаем, какие функции и свойства можем использовать внутри этой функции. Дженерик также позволяет возвращать данные указанного типа вместо обычного Car
.
Результатом выполнения данного кода будет:
Received a Vespa in the car wash.
Cleaning all 2 tires.
Beeping horn - beep beep!
Returning your car now
Received a Truck in the car wash.
Cleaning all 18 tires.
Beeping horn - beep beep!
Returning your car now
Подведем итоги
Надеюсь, я помог вам разобраться с дженериками. Запомните, всё, что вам нужно сделать, — это всего лишь передать значение type
в функцию :)
Если хотите ещё почитать про дженерики, я прикрепил далее пару ссылок.
Что почитать:
- TypeScript Generics Documentation - документация дженериков
- TypeScript Generics Explained - более глубокое погружение в тему дженериков