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

от автора

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


ссылка на оригинал статьи https://habr.com/ru/articles/688270/


Комментарии

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

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