Обобщения
Важная часть разработки программного обеспечения — создание компонентов, которые не только имеют хорошо определенные и согласованные API, но и являются многократно используемыми. Компоненты, способные работать с данными сегодняшнего дня и завтрашнего, обеспечат максимальную гибкость при создании больших программных систем.
В языках программирования, таких как C# и Java, одним из основных инструментов для создания многократно используемых компонентов являются обобщения, то есть возможность создания компонента, который может работать с различными типами, а не только с одним. Это позволяет пользователям использовать эти компоненты и применять свои собственные типы.
Привет, мир обобщений
Для начала давайте рассмотрим «привет, мир» для обобщений: функцию тождества. Функция тождества — это функция, которая возвращает то, что ей передано.
Без обобщений нам пришлось бы предоставить функции тождества определенный тип:
function identity(arg: number): number {
return arg;
} Или мы могли бы описать функцию тождества с помощью any типа:
function identity(arg: any): any {
return arg;
} Хотя использование any определенно обобщенно, так как это заставит функцию принимать любые типы для типа arg, мы фактически теряем информацию о том, каким был этот тип, когда функция возвращает значение. Если мы передали число, единственная информация, которой мы располагаем, — это то, что может быть возвращен любой тип.
Вместо этого нам нужен способ захвата типа аргумента таким образом, чтобы мы также могли использовать его для обозначения возвращаемого значения. Здесь мы будем использовать переменную типа, особый вид переменной, которая работает с типами, а не со значениями.
function identity<Type>(arg: Type): Type {
return arg;
} Мы добавили переменную типа Type к функции тождества. Это Type позволяет нам захватить тип, предоставленный пользователем (например, number), чтобы мы могли использовать эту информацию позже. Здесь мы снова используем Type в качестве типа возвращаемого значения. При осмотре мы видим, что один и тот же тип используется для аргумента и типа возвращаемого значения. Это позволяет нам передавать эту информацию типа с одной стороны функции на другую.
Мы говорим, что этот вариант функции identity является обобщенным, так как он работает с множеством типов. В отличие от использования any, он также столь же точен (то есть не теряет никакой информации), как и первая функция identity, которая использовала числа для аргумента и типа возвращаемого значения.
После того как мы написали обобщенную функцию тождества, мы можем вызвать её двумя способами. Первый способ — передать все аргументы, включая аргумент типа, в функцию:
let output = identity<string>("myString"); Здесь мы явно задаем Type как string в качестве одного из аргументов вызова функции, используя <> вокруг аргументов, а не ().
Второй способ, возможно, самый распространенный. Здесь мы используем вывод типов аргументов — то есть хотим, чтобы компилятор автоматически задавал значение Type на основе типа передаваемого аргумента:
let output = identity("myString"); Обратите внимание, что нам не нужно было явно передавать тип в угловых скобках (<>); компилятор просто посмотрел на значение "myString", и задал Type его типу. Хотя вывод типов аргументов может быть полезным инструментом для сокращения и повышения читабельности кода, вам может потребоваться явно передавать аргументы типа, как мы сделали в предыдущем примере, когда компилятор не может вывести тип, как может произойти в более сложных примерах.
Работа с переменными обобщенного типа
Когда вы начинаете использовать обобщения, вы заметите, что при создании обобщенных функций, таких как identity, компилятор будет требовать, чтобы вы правильно использовали параметры обобщенного типа в теле функции. То есть, что вы фактически рассматриваете эти параметры как любые и все типы.
Давайте возьмем нашу функцию identity из предыдущего примера:
function identity<Type>(arg: Type): Type {
return arg;
} Что если мы хотим также вывести длину аргумента arg в консоль при каждом вызове? Мы можем попробовать написать это:
function loggingIdentity<Type>(arg: Type): Type {
console.log(arg.length);
return arg;
} При этом компилятор выдаст ошибку, что мы используем член .length объекта arg, но нигде не указали, что у arg есть этот член. Помните, что мы ранее говорили, что эти переменные типа подразумевают любые и все типы, поэтому кто-то, использующий эту функцию, мог передать массив number, у которого нет члена .length.
Предположим, что эта функция на самом деле должна работать с массивами Type вместо Type напрямую. Поскольку мы работаем с массивами, член .length должен быть доступен. Мы можем описать это так же, как мы создаем массивы других типов:
function loggingIdentity<Type>(arg: Type[]): Type[] {
console.log(arg.length);
return arg;
} Можно прочитать тип loggingIdentity как «обобщенная функция loggingIdentity принимает параметр типа Type, и аргумент arg, который представляет собой массив Type и возвращает массив Type». Если мы передадим массив чисел, мы получим массив чисел, так как Type будет привязано к number. Это позволяет нам использовать нашу обобщенную переменную типа Type как часть типов, с которыми мы работаем, а не весь тип, что обеспечивает большую гибкость.
Мы можем альтернативно записать пример так:
function loggingIdentity<Type>(arg: Array<Type>): Array<Type> {
console.log(arg.length); // Array has a .length, so no more error
return arg;
} Вы, возможно, уже знакомы с этим стилем типов из других языков. В следующем разделе мы рассмотрим, как создать собственные обобщенные типы, такие как Array<Type>.
Обобщенные типы
В предыдущих разделах мы создавали обобщенные функции тождества, которые работали с множеством типов. В этом разделе мы рассмотрим типы функций самих по себе и способы создания обобщенных интерфейсов.
Тип обобщенных функций такой же, как у необобщенных функций, с параметрами типа, перечисленными первыми, аналогично объявлениям функций:
function identity<Type>(arg: Type): Type {
return arg;
}
let myIdentity: <Type>(arg: Type) => Type = identity; Мы также могли использовать другое имя для параметра типа обобщения в типе, при условии, что количество переменных типа и то, как используются переменные типа, соответствуют друг другу.
function identity<Input>(arg: Input): Input {
return arg;
}
let myIdentity: <Input>(arg: Input) => Input = identity; Мы также можем записать обобщенный тип как сигнатуру вызова типа объекта:
function identity<Type>(arg: Type): Type {
return arg;
}
let myIdentity: { <Type>(arg: Type): Type } = identity; Это приводит нас к написанию нашего первого обобщенного интерфейса. Возьмем объект-литерал из предыдущего примера и переместим его в интерфейс:
interface GenericIdentityFn {
<Type>(arg: Type): Type;
}
function identity<Type>(arg: Type): Type {
return arg;
}
let myIdentity: GenericIdentityFn = identity; В аналогичном примере мы можем захотеть переместить параметр обобщения в качестве параметра всего интерфейса. Это позволяет увидеть, над какими типами мы используем обобщение (например, Dictionary<string> вместо только Dictionary). Это делает параметр типа видимым для всех других членов интерфейса.
interface GenericIdentityFn<Type> {
(arg: Type): Type;
}
function identity<Type>(arg: Type): Type {
return arg;
}
let myIdentity: GenericIdentityFn<number> = identity; Обратите внимание, что наш пример немного изменился. Вместо описания обобщенной функции у нас теперь есть необобщенная сигнатура функции, которая является частью обобщенного типа. При использовании GenericIdentityFn, нам теперь также необходимо указать соответствующий аргумент типа (здесь: number). Это фактически фиксирует используемую базовую сигнатуру вызова. Понимание того, когда следует поместить параметр типа непосредственно в сигнатуру вызова и когда поместить его в сам интерфейс, будет полезно для описания аспектов типа, которые являются обобщенными.
Помимо обобщенных интерфейсов, мы также можем создавать обобщенные классы. Обратите внимание, что создать обобщенные перечисления и пространства имен невозможно.
Обобщенные классы
Обобщенный класс имеет сходную структуру с обобщенным интерфейсом. Обобщенные классы имеют список параметров обобщенного типа в угловых скобках (<> ) после имени класса.
class GenericNumber<NumType> {
zeroValue: NumType;
add: (x: NumType, y: NumType) => NumType;
}
let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function (x, y) {
return x + y;
}; Это довольно буквальное использование класса GenericNumber, но вы могли заметить, что ничего не ограничивает его использованием только типа number. Мы могли бы вместо этого использовать string или даже более сложные объекты.
let stringNumeric = new GenericNumber<string>();
stringNumeric.zeroValue = "";
stringNumeric.add = function (x, y) {
return x + y;
};
console.log(stringNumeric.add(stringNumeric.zeroValue, "test")); Так же, как и в интерфейсах, размещение параметра типа в самом классе позволяет нам убедиться, что все свойства класса работают с одним и тем же типом.
Как мы обсуждаем в разделе о классах, класс имеет две стороны своего типа: статическую и экземпляровую. Обобщенные классы обобщены только по своей экземпляровной стороне, а не по статической, поэтому при работе с классами статические члены не могут использовать параметр типа класса.
Обобщенные ограничения
Если вы помните из предыдущего примера, иногда вы можете захотеть написать обобщенную функцию, которая работает с набором типов, о которых у вас есть некоторое представление о возможностях этого набора типов. В нашем примере loggingIdentity мы хотели получить доступ к свойству .length объекта arg, но компилятор не может доказать, что каждый тип имеет свойство .length, поэтому предупреждает нас, что мы не можем сделать это предположение.
function loggingIdentity<Type>(arg: Type): Type {
console.log(arg.length);
return arg;
} Вместо работы с любыми и всеми типами, мы хотели бы ограничить эту функцию работой с любыми и всеми типами, у которых также есть свойство .length. Если тип имеет этот член, мы разрешим его, но он обязательно должен иметь как минимум этот член. Для этого нам нужно указать наше требование как ограничение для Type.
Для этого мы создадим интерфейс, описывающий наше ограничение. Здесь мы создадим интерфейс с единственным свойством .length и затем будем использовать этот интерфейс и ключевое слово extends для обозначения нашего ограничения:
interface Lengthwise {
length: number;
}
function loggingIdentity<Type extends Lengthwise>(arg: Type): Type {
console.log(arg.length); // Now we know it has a .length property, so no more error
return arg;
} Поскольку обобщенная функция теперь ограничена, она больше не будет работать с любыми и всеми типами:
loggingIdentity(3);
Вместо этого нам нужно передать значения, тип которых имеет все необходимые свойства:
loggingIdentity({ length: 10, value: 3 }); Использование параметров типов в обобщенных ограничениях
Вы можете объявить параметр типа, ограниченный другим параметром типа. Например, здесь мы хотим получить свойство объекта по его имени. Мы хотим убедиться, что мы случайно не получаем свойство, которое не существует у obj, поэтому установим ограничение между двумя типами:
function getProperty<Type, Key extends keyof Type>(obj: Type, key: Key) {
return obj[key];
}
let x = { a: 1, b: 2, c: 3, d: 4 };
getProperty(x, "a");
getProperty(x, "m"); Использование типов классов в обобщениях
При создании фабрик в TypeScript с использованием обобщений необходимо ссылаться на типы классов по их конструкторам. Например,
function create<Type>(c: { new (): Type }): Type {
return new c();
} Более продвинутый пример использует свойство prototype для вывода и ограничения взаимосвязей между конструктором и экземпляровной стороной типов классов.
class BeeKeeper {
hasMask: boolean = true;
}
class ZooKeeper {
nametag: string = "Mikle";
}
class Animal {
numLegs: number = 4;
}
class Bee extends Animal {
numLegs = 6;
keeper: BeeKeeper = new BeeKeeper();
}
class Lion extends Animal {
keeper: ZooKeeper = new ZooKeeper();
}
function createInstance<A extends Animal>(c: new () => A): A {
return new c();
}
createInstance(Lion).keeper.nametag;
createInstance(Bee).keeper.hasMask; Эта схема используется для реализации паттерна проектирования миксов.
Значения по умолчанию для универсальных параметров
Рассмотрим функцию, которая создает новый HTMLElement. Вызов функции без аргументов генерирует Div; вызов с элементом в качестве первого аргумента генерирует элемент типа аргумента. Также можно необязательно передать список дочерних элементов. Ранее нужно было определять это так:
declare function create(): Container<HTMLDivElement, HTMLDivElement[]>; declare function create<T extends HTMLElement>(element: T): Container<T, T[]>; declare function create<T extends HTMLElement, U extends HTMLElement>( element: T, children: U[] ): Container<T, U[]>;
С значениями по умолчанию для универсальных параметров мы можем сократить до:
declare function create<T extends HTMLElement = HTMLDivElement, U = T[]>( element?: T, children?: U ): Container<T, U>;
Значение по умолчанию для универсального параметра подчиняется следующим правилам:
- Параметр типа считается необязательным, если у него есть значение по умолчанию.
- Обязательные параметры типа не должны следовать за необязательными параметрами типа.
- Типы по умолчанию для параметра типа должны удовлетворять ограничению для параметра типа, если оно существует.
- При указании аргументов типа, вам нужно указывать аргументы типа только для обязательных параметров типа. Неуказанные параметры типа будут разрешены до своих типов по умолчанию.
- Если тип по умолчанию указан, и вывод не может выбрать кандидата, то тип по умолчанию выводится.
- Объявление класса или интерфейса, которое объединяется с существующим классом или интерфейсом, может ввести значение по умолчанию для существующего параметра типа.
- Объявление класса или интерфейса, которое объединяется с существующим классом или интерфейсом, может ввести новый параметр типа, при условии, что он указывает значение по умолчанию.
© 2012-2023 Microsoft
Licensed under the Apache License, Version 2.0.
https://www.typescriptlang.org/docs/handbook/2/generics.html