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

Комментарии (9)


  1. aamonster
    15.09.2022 15:06
    +1

    А почему вы думаете, что "не найдут" информацию о перегрузке те, кто пришёл из мира js? У них такого хватает – к примеру, chrome.tabs.query. И при переходе на ts всё это надо как-то типизировать.

    Скорей проблемы возникнут у плюсовиков: перегрузки функций ведь по факту нет, есть перегрузка только типов функций.

    Ну и понять такую перегрузку (если можешь в типы данных) достаточно просто, достаточно вспомнить про алгебраические типы. Т.е. тип функции (не результата, а самой функции) – это просто тип или из первой декларации, или из второй. Можно этот тип выписать отдельно, через "|". Соответственно, и использовать при желании можно примерно везде...


    1. sanperrier
      15.09.2022 21:08
      +2

      Перегрузки в TS не через union, а через intersection создаются из-за номинальной типизации:

      type ICallString = {
        (arg0: string): string
      } 
      
      type ICallNumber = {
        (arg0: number): number
      }
      
      type F = ICallString & ICallNumber;

      Гуглить по ковариантность и контрвариантность типов в TS


      1. aamonster
        15.09.2022 22:06

        Да, спасибо, вы правы. Не "переменная, в которую можно положить ту или другую функцию", а "функция, которая может использоваться вместо той или другой".


    1. Hrodvitnir
      17.09.2022 04:59
      +1

      А почему вы думаете, что "не найдут" информацию о перегрузке те, кто пришёл из мира js?

      Я узнал о перегрузках только через три года работы. Все как в статье, если ты копаешься только в своих проектах, то перегрузки ты скорее всего нигде не встретишь)


  1. Gigatrop
    15.09.2022 20:02
    +6

    Перегрузка - прекрасный инструмент. В языках, где перегрузка делается манглированием, ситуация получше, там функций несколько с одинаковым именем, то есть несколько реализаций. Это позволяет не выдумывать разные имена при отличии аргументов, что нужно довольно часто. Например метод суммирования логично назвать Sum, а не SumInt, SumFloat, SumDouble, SumShort, SumLong, SumUint, SumUlong, ... А потом DivideInt, DivideFloat, DivideDouble, ... и всё прочее по списку.

    А когда вот так нужно сначала написать сигнатуры методов и потом слепить реализацию в одну на ифах - это какая-то жесть. Тут нет ни выигрыша в понятности кода (сначала сигнатуры потом огород ифов и борьба с аргументами), ни выигрыша в производительности (компиляция большой функции и разбор аргументов ифами, вместо исполнения только нужной отдельной функции), ни простого добавления очередной функции (придётся переписать реализацию). Всё это есть при "нормальной" перегрузке.

    Ещё пример где перегрузка нужна - это конструктор. Например когда какой-то метод создаёт объект, зная только его класс. Такой метод не знает с какими аргументами создать этот объект, поэтому создаёт его дефолтным образом - без аргументов, просто new Object() или CreateInstance( type ). А если требуется создавать такой объект "вручную", то для этого пишется перегрузка с нужными аргументами.

    Для JS и отчасти TS это менее полезно потому, что в них функция принимает на вход что угодно и так. А в статически типизированных языках функция не может принимать что угодно на вход, ей нужно скомпилироваться во что-то конкретное.


    1. Kuch
      16.09.2022 00:21

      Полностью согласен. В java перегрузка прекрасна, но в ts она выглядит слишком монструозно и несуразно, что как будто бы весь смысл использования высосан из пальца и проще просто использовать разные имена


      1. aamonster
        16.09.2022 08:06
        +3

        В ts она абсолютно понятна, если рассматривать его, как обвязку над js, не дающую вам наступать на грабли.

        Дело в том, что уже есть куча функций, возвращающих разный результат в зависимости от аргументов (e.g. chrome.tabs.query), и их надо как-то типизировать, чтобы не было мучительно больно.


  1. amberv
    16.09.2022 09:44

    Может, я чего-то не понял, но дженерик для вашего случая выглядит гораздо более подходящим решением. Особенно если в вашу функцию-обёртку передавать один параметр-объект. В таком случае типизация будет ещё и очень простой.


    1. aamonster
      16.09.2022 17:03

      А что, в дженерики в TypeScript специализацию завезли?