Как стать автором
Обновить
1463.13
OTUS
Цифровые навыки от ведущих экспертов

Перегрузка функций в TypeScript

Время на прочтение8 мин
Количество просмотров34K

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

  1. Перегрузку можно использовать для функций объявленных 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);
     }
    };
    
  2. Перегрузку можно использовать для стрелочных функций, используя подход, описанный выше:

    // сигнатуры перегрузки
    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);
     }
    };
    
  3. Перегрузку можно использовать для методов класса, объявленных подходом 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);
       }
     }
    }
    
  4. Перегрузку можно использовать для методов класса, объявленных подходом 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);
       }
     };
    }
    
  5. Перегрузку можно использовать для методов класса, объявленных стрелочными функциями:

    // сигнатуры перегрузки
    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. Интерфейс функции №1:

    async claim(address: string, privateKey: string, contractAddress?: string): Promise<void>;
  2. Интерфейс функции №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. 

Таким образом, у меня сложилось мнение, что перегрузка может быть полезным инструментом. В ситуации хаоса с ее помощью можно навести небольшой порядок и дать больше определенности. При этом мне кажется, что использовать перегрузку как основной инструмент работы с функциями — плохая практика, потому что перегрузка противоречит принципу единственной ответственности.

Итоги

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

Полезные ссылки:

  1. Официальная документация TS

  2. Перевод этой документации

  3. Обсуждение типов, которые возвращает перегруженная функция

Бесплатные вебинары от OTUS

  1. Кодогенерация в TypeScript

  2. Парсинг строк и интерпретация древовидных структур данных

Теги:
Хабы:
Всего голосов 18: ↑18 и ↓0+18
Комментарии18

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS