Перегрузка функций — это та область TS, которая находится в невидимой зоне для разработчиков, которые изучали JS, а потом на работе «на ходу» начали осваивать TS. Особенно, если изучение JS не было связано с университетом или любым другим фундаментальным образованием. Если вы изучали JS на курсах, то вы никогда не услышите там про перегрузку функций, просто потому что в JS этого функционала нет. А когда вы сами начнете изучать TS, то вы не наткнетесь на перегрузку функций, просто потому что даже не подозреваете о ней. Если, прочитав вступление, вы задались вопросом «Что за перегрузка такая?», то эта статья для вас.
Общее определение
Начнем с определения и общего понимания, что такое перегрузка, как концепция, не зацикливаясь на TS. Возьмем определение из википедии: «Перегрузка процедур и функций — возможность использования одноименных подпрограмм: процедур или функций в языках программирования». Что это значит на практике? Обратимся за примером к официальной документации TypeScript:
function makeDate(timestamp: number): Date;
function makeDate(m: number, d: number, y: number): Date;
function makeDate(mOrTimestamp: number, d?: number, y?: number): Date {
if (d !== undefined && y !== undefined) {
return new Date(y, mOrTimestamp, d);
} else {
return new Date(mOrTimestamp);
}
}
const d1 = makeDate(12345678);
const d2 = makeDate(5, 5, 5);
const d3 = makeDate(1, 3);
// No overload expects 2 arguments, but overloads do exist that expect either 1 or 3 arguments.
Уверен, что те, кто впервые столкнулся с этим синтаксисом, очень удивлены. Но давайте вначале разберемся, в чем смысл функции, а потом разберем синтаксис перегрузки. Итак, дана функция, которая создает объект даты и умеет принимать либо один параметр — тайм-штамп, либо три параметра — месяц, день, год. В TS есть возможность сделать не две отдельные функции для этого, а одну. Более того, можно описать для нее параметры достаточно точно, чтобы при компиляции TS проверял их количество и тип. Вызов функции из примера с двумя параметрами приведет к ошибке, что можно увидеть в последних строках кода выше.
Перегрузка функций — это очень холиварная тема, и у этого приема есть как сторонники, так и противники (кажется, что противников все-таки больше), но цель этой статьи — просто описать сам инструмент. Хотя свое личное мнение я все же выскажу в самом конце.
Синтаксис перегрузки
Если еще раз глянуть на кусок кода выше, то можно увидеть, как устроен синтаксис перегрузки. В начале описывается тип функции с одним параметром:
function makeDate(timestamp: number): Date;
Потом описывается тип функции с тремя параметрами:
function makeDate(m: number, d: number, y: number): Date;
После этого идет реализация функции, которая объединяет два типа, описанных выше:
function makeDate(mOrTimestamp: number, d?: number, y?: number): Date {
if (d !== undefined && y !== undefined) {
return new Date(y, mOrTimestamp, d);
} else {
return new Date(mOrTimestamp);
}
}
Согласно официальной документации, первые два участка с описанием типов функций называются «Overload Signatures», на Хабре есть перевод официальной документации TS, где это понятие перевели как «сигнатуры перегрузки», будем использовать его. А код с реализацией функции называется «Implementation Signature» или «сигнатура реализации».
Назначение сигнатур
Сигнатуры перегрузки не выглядят необычно, это просто типы двух функций, а вот сигнатура реализации нуждается в нескольких комментариях. Во-первых, она объединяет типы сигнатур перегрузки. Первый параметр в примере может быть или месяцем, или тайм-штампом, и он обязательный, а вот дата и год необязательные. Во-вторых, в теле функции происходит проверка, которая помогает определить, какой из двух сценариев использования функции сейчас выполняется, с одним параметром или с тремя.
У читателя может возникнуть логичное замечание, что можно убрать сигнатуры перегрузки, оставить только сигнатуру реализации, и все будет работать. И это действительно так, все будет работать, но давайте попробуем понять, в чем преимущество наличия сигнатур перегрузки. Если вы увидите в коде только сигнатуру реализации, то, посмотрев только описание типа функции, вы не сможете составить представление о том, как она устроена, придется читать тело функции, чтобы понять как она работает, это во-первых. А во-вторых, отсутствие сигнатур перегрузки позволит вам использовать функцию, передавая туда два аргумента, что приведет к заведомо неверному результату работы функции.
То есть, назначение сигнатур перегрузки в том, что с ними можно четко разделить все случаи использования перегруженной функции. В функции из примера понятно, что можно получить объект даты либо из тайм-штампа, либо из дня, месяца и года. Также функционал TS позволяет избежать ошибочного использования перегруженной функции за пределами описанных сигнатур перегрузки. Если описаны варианты — либо один параметр, либо три — то передача туда двух аргументов приведет к ошибке компиляции.
На этом этапе может возникнуть вопрос: «А зачем мне вообще городить такие сложности, я просто создам две отдельные функции — получение даты из тайм-штампа и получение даты из дня, месяца и года». Абсолютно справедливый вопрос. Попробуем о нем порассуждать в конце статьи, а пока обсудим разные сценарии применения перегрузки.
Применение перегрузки
Как видно из примера выше, перегрузку можно применять к функциям в TS объявленными подходом function declaration, а вот можно ли использовать перегрузку для функций объявленных подходом function expression или для стрелочных функций? Можно ли перегружать методы классов? Я не нашел этой информации в официальной документации TS, поэтому просто на деле попробовал все эти варианты. Получил следующие результаты:
Перегрузку можно использовать для функций объявленных function expression, но для этого необходимо использовать немного другой подход, нужно описать отдельный тип для сигнатур перегрузки. После объявления функции и указания для нее описанного типа с сигнатурами перегрузки описать сигнатуру реализации.
// сигнатуры перегрузки type MakeDate = { (timestamp: number): Date; (m: number, d: number, y: number): Date; }; // сигнатура реализации const makeDate:MakeDate = function (mOrTimestamp: number, d?: number, y?: number): Date { if (d !== undefined && y !== undefined) { return new Date(y, mOrTimestamp, d); } else { return new Date(mOrTimestamp); } };
Перегрузку можно использовать для стрелочных функций, используя подход, описанный выше:
// сигнатуры перегрузки type MakeDate = { (timestamp: number): Date; (m: number, d: number, y: number): Date; }; // сигнатура реализации const makeDate:MakeDate = (mOrTimestamp: number, d?: number, y?: number): Date => { if (d !== undefined && y !== undefined) { return new Date(y, mOrTimestamp, d); } else { return new Date(mOrTimestamp); } };
Перегрузку можно использовать для методов класса, объявленных подходом function declaration:
class DateCreator { makeDate(timestamp: number): Date; makeDate(m: number, d: number, y: number): Date; makeDate(mOrTimestamp: number, d?: number, y?: number): Date { if (d !== undefined && y !== undefined) { return new Date(y, mOrTimestamp, d); } else { return new Date(mOrTimestamp); } } }
Перегрузку можно использовать для методов класса, объявленных подходом function expression:
// сигнатуры перегрузки type MakeDate = { (timestamp: number): Date; (m: number, d: number, y: number): Date; }; class DateCreator { // сигнатура реализации makeDate:MakeDate = function (mOrTimestamp: number, d?: number, y?: number): Date { if (d !== undefined && y !== undefined) { return new Date(y, mOrTimestamp, d); } else { return new Date(mOrTimestamp); } }; }
Перегрузку можно использовать для методов класса, объявленных стрелочными функциями:
// сигнатуры перегрузки type MakeDate = { (timestamp: number): Date; (m: number, d: number, y: number): Date; }; class DateCreator { // сигнатура реализации makeDate:MakeDate = (mOrTimestamp: number, d?: number, y?: number): Date => { if (d !== undefined && y !== undefined) { return new Date(y, mOrTimestamp, d); } else { return new Date(mOrTimestamp); } }; }
И еще одна небольшая деталь: если ваши сигнатуры перегрузки возвращают разные типы, то в сигнатуре реализации нужно использовать не логическое «или», а логическое «и»:
// сигнатуры перегрузки
type MakeDate = {
(timestamp: number): string;
(m: number, d: number, y: number): Date;
};
class DateCreator {
// сигнатура реализации
makeDate:MakeDate = (mOrTimestamp: number, d?: number, y?: number): Date & string => {
if (d !== undefined && y !== undefined) {
return new Date(y, mOrTimestamp, d) as (Date & string);
} else {
return new Date(mOrTimestamp).toDateString() as (Date & string);
}
};
}
Выглядит грязно из-за использования переприсваивания типа, но на данный момент это не считается ошибкой, судя по обсуждению тут. Если вы понимаете, как записать это чище, пожалуйста, расскажите в комментариях.
Личные рассуждения о перегрузке функций
Теперь я хочу немного порассуждать о том, можно ли использовать перегрузку и если да, то в каких случаях. Я неоднократно слышал от коллег, что перегрузка — плохой инструмент. Она путает разработчиков, перегружает код, и, в конце концов, скорее вредит разработке, чем помогает. Но я расскажу о случае из своей работы, в котором я использовал перегрузку, и считаю это оправданным решением.
Я писал функцию-обертку, которая вызывала исходную функцию и отправляла статистику успеха или фейла операции на сервер. Логика была примерно такой:
const claim = async (params) => {
try {
res = await this.__claim(params);
}
BackendLogger.success(CLAIM_SUCCESS);
return res;
} catch (err) {
BackendLogger.error(FAILED_CLAIM);
throw err;
}
};
И так оказалось, что у нас есть две одноименные функции в коде в разных местах, которые назывались «claim», и у которых были абсолютно разные интерфейсы:
Интерфейс функции №1:
async claim(address: string, privateKey: string, contractAddress?: string): Promise<void>;
Интерфейс функции №2:
async claim(params: ClaimParam): Promise<HashOrError>;
На тот момент я не мог переименовать эти функции и написать две отдельные обертки, давайте примем это как условие — ресурса и возможности поправить эту ошибку не было. Первое, что я сделал — это расширил интерфейс функции-обертки так, чтобы она могла работать с двумя одноименными функциями:
claim = async (addressOrParams: string | ClaimParam, privateKey?: string, contractAddress?: string): Promise<void | HashOrError> => {
try {
let res;
if (typeof addressOrParams === 'string'){
res = await this.__claim(addressOrParams, privateKey, contractAddress);
} else {
res = await this.__claim(addressOrParams);
}
BackendLogger.success(CLAIM_SUCCESS);
return res;
} catch (err) {
BackendLogger.error(FAILED_CLAIM);
throw err;
}
};
Получившийся результат меня не устраивал. Он был неоднозначным и сложным для восприятия. Было не ясно, какие аргументы обязательные, а какие нет, в какой комбинации их нужно передавать и что возвращает эта функция. Тогда я посоветовался с более опытным коллегой, который заметил, что это выглядит как идеальный случай для использования перегрузки. Вот, что получилось в результате:
type Claim = {
(params: ClaimParam): Promise<HashOrError>;
(address: string, privateKey?: string, contractAddress?: string): Promise<void>;
};
claim: Claim = async (addressOrParams: string | ClaimParam, privateKey?: WalletPrivateKey, contractAddress?: string): Promise<void & HashOrError> => {
try {
let res;
if (typeof addressOrParams === 'string'){
res = await this.__claim(addressOrParams, privateKey, contractAddress);
} else {
res = await this.__claim(addressOrParams);
}
BackendLogger.success(CLAIM_SUCCESS);
return res;
} catch (err) {
BackendLogger.error(FAILED_CLAIM);
throw err;
}
};
В условиях, когда ты не можешь исправить ошибку с одноименными функциями, и при этом ты не хочешь оставлять для себя завтрашнего непонятный код, перегрузка была хорошим решением.
Буквально через неделю я столкнулся с другой проблемой — старая ошибка из тех.долга, которую решить «здесь и сейчас» не было возможности. Тогда перегрузка помогла избежать ts-ignore и типа any.
Таким образом, у меня сложилось мнение, что перегрузка может быть полезным инструментом. В ситуации хаоса с ее помощью можно навести небольшой порядок и дать больше определенности. При этом мне кажется, что использовать перегрузку как основной инструмент работы с функциями — плохая практика, потому что перегрузка противоречит принципу единственной ответственности.
Итоги
Перегрузка — неоднозначный инструмент, вокруг которого много разговоров, но знать о том, что он есть и как он работает, будет полезно для каждого разработчика. Вы не будете применять его часто, но, возможно, однажды он спасет вас от многочасового переписывания тех.долга, как меня.