Складываем числа в системе типов Typescript
Недавно я участвовал в соревновании Yandex Cup, где одним из заданий в треке фронтенд-разработки было решить некую задачку полностью в рамках системы типов Typescript. Я очень люблю тайпскрипт в целом и его систему типов в частности, поэтому на это задание я потратил значительную часть всего времени. Что там было — совсем другая история, но именно тогда мне пришла идея написать свой сумматор на типах.
В процессе решения задания я немного поискал в интернете и наткнулся на проект calc-ts, который реализует основные арифметические операции и парсинг выражений. Но у него обнаружился один фатальный недостаток. В силу особенностей реализации, в которые я не буду вдаваться, он перестаёт работать на числах больше нескольких тысяч. Там используется довольно наивный подход "давайте увеличивать одно число на единичку, а второе уменьшать, пока оно не станет равно нулю", и тайпскрипт захлёбывается на таком количестве итераций.
Вместо этого я решил взять за основу десятичную систему и алгоритм сложения столбиком. Что ж, начнём!
Нам нужно реализовать тип, который принимает на вход два числа, а на выход выдаёт ещё одно число — сумму.
Сначала нужно превратить числа в строки, а строки разбить на отдельные цифры. Это, пожалуй, единственный способ удобной работы с числами в TS, ведь никаких арифметических операций в системе типов нет.
type StringToChars<T extends string> = T extends `${infer C}${infer Rest}` ? [C, ...StringToChars<Rest>] : []; type NumToChars<T extends number> = StringToChars<`${T}`>;
Для преобразования числа в строку мы используем подстановку в template literal. А вот для разбиения строки на символы пригодится рекурсия и немного infer-магии. Простыми словами можно описать так:
- на каждом шаге мы проверяем, представляет ли строка из себя нечто формата
`${первый символ}${всё остальное}`
(причём всё остальное может быть пустой строкой) - если да, то возвращаем первый символ и результат разбиения оставшейся строки (которая возвращает второй символ и результат разбиения оставшейся строки и так далее)
- если нет, то возвращаем пустой массив (это нужно для завершения рекурсии, когда строка пустая)
Теперь из числа 12345
мы можем получить кортеж цифр ["1", "2", "3", "4", "5"]
. Дальше вспоминаем алгоритм сложения столбиком: числа нужно записать так, чтобы одинаковые разряды оказались друг над другом. В нашем случае — чтобы одинаковые разряды была на одинаковых позициях в кортеже. Как бы сделать так, чтобы цифра 5 в числе 12345 была на том же месте, что и 8 в 678? Развернуть массив!
Для этого опять используем infer и рекурсию:
type Reverse<T extends any[]> = T extends [infer V, ...infer Rest] ? [...Reverse<Rest>, V] : [];
Reverse<NumToChars<12345>>
даст нам ["5", "4", "3", "2", "1"]
, а Reverse<NumToChars<678>>
— ["8", "7", "6"]
. Как видите, разряды действительно совпадают.
Теперь нужно научиться складывать две цифры. Для этого я написал маленький скриптик, который генерирует look-up table:
for (let i = 0; i <= 9; ++i) { console.log(`A extends '${i}' ? (`) for (let j = 0; j <= 9; ++j) { console.log(`B extends '${j}' ? [${i + j >= 10 ? `'${(i+j) % 10}', true`: `'${i+j}', false`}] :`) } console.log('never) :') } console.log('never')
Результат выглядит примерно так:
type AddDigitsCore<A extends string, B extends string> = A extends "0" ? ( B extends "0" ? ["0", false] : B extends "1" ? ["1", false] : B extends "2" ? ["2", false] // тут ещё 100 строчек…
Тип принимает на вход два символа и на выход отдаёт символ, который нужно записать в ответ, а также нужно ли добавить единичку к следующему разряду.
Как добавлять единичку? Тут мне пришлось написать маленький костыль, потому что переделывать по-нормальному мне было лень.
type AddOne<T extends [string, boolean]> = [ AddDigitsCore<T[0], "1">[0], AddDigitsCore<T[0], "1">[1] extends true ? true : T[1], ];
Тип принимает на вход кортеж из символа и индикатора переполнения и отдаёт такой же кортеж на выход. Символ — это результат прибавления единички к входному символу. А вот для индикатора переполнения написано условие. Если он равен true, то возвращаем true. Иначе возвращаем то, что было передано на вход. Выглядит не очень красиво, но работает, и вот почему:
- Если на вход передаётся число с переполнением (например 12, то есть
["2", true]
), то на выходе переполнение остаётся (получается 13, то есть["3", true]
) - Переполнения переполнения случиться не может, потому что максимальное число на входе — это 9+9, то есть 18
- Если на вход передаётся число от 0 до 8, то переполнения не появляется
- Единственный случай, когда появляется переполнение — когда передаётся 9 (
["9", false]
=>["0", true]
)
Теперь напишем тип для сложения двух цифр, но с учётом возможной «единички»:
type AddDigits< A extends string, B extends string, Leftover extends boolean = false, > = Leftover extends true ? AddOne<AddDigitsCore<A, B>> : AddDigitsCore<A, B>;
Тут всё просто: если Leftover = true, то складываем цифры и добавляем единичку; если нет, то просто складываем цифры.
Дальше напишем ещё несколько типов, которые пригодятся нам позже.
Первый — это тип Shift, который удаляет первый элемент из кортежа.
type Shift<T extends any[]> = ((...v: T) => void) extends ((v: any, ...rest: infer V) => void) ? V : [];
Я не очень понимаю, зачем тут используются сигнатуры функций, но без них что-то ломается. В общем, тип «проглатывает» первый элемент, а все остальные возвращает обратно. Если массив и так пустой, то просто возвращает пустой массив.
Второй — это тип FallbackToZero, тут всё очевидно.
type FallbackToZero<T extends string | undefined> = T extends undefined ? "0" : T;
Теперь подумаем о том, как бы нам реализовать алгоритм сложения. Шаги должны быть такие:
- Сложить два числа в текущем разряде (не забыть про возможную «единичку» сверху)
- Записать последнюю цифру результата в ответ
- Запомнить «единичку» (если она есть) для следующего разряда
- Перейти к следующему разряду
В качестве решения напрашивается рекурсия, её и будем использовать. Получился тип
type AddCore< A extends string[], B extends string[], Leftover extends boolean = false, > = true extends ( & (A[0] extends undefined ? true : false) & (B[0] extends undefined ? true : false) ) ? Leftover extends true ? "1" : "" : `${AddCore< Shift<A>, Shift<B>, AddDigits< FallbackToZero<A[0]>, FallbackToZero<B[0]>, Leftover >[1] >}${AddDigits< FallbackToZero<A[0]>, FallbackToZero<B[0]>, Leftover >[0]}`;
Давайте разбираться, что тут происходит. На вход приходит два числа (в виде массива символов в обратном порядке) и индикатор «единички». Для первого разряда он всегда false, но для следующих итераций пригодится.
AddDigits< FallbackToZero<A[0]>, FallbackToZero<B[0]>, Leftover >[0]
Тут мы получаем цифру, которая идёт в ответ.
AddCore< Shift<A>, Shift<B>, AddDigits< FallbackToZero<A[0]>, FallbackToZero<B[0]>, Leftover >[1] >
Тут мы запускаем рекурсию для следующего разряда. В Leftover
передаётся индикатор переполнения из результата сложения текущего разряда. Из чисел убирается по одной цифре с помощью метода Shift
. С каждой итерацией остаётся всё меньше и меньше цифр. Если одно число короче другого, то в один момент у него закончатся цифры, и A[0]
(или B[0]
) вернёт undefined
. Тут пригождается FallbackToZero
, который заменяет undefined
на "0"
.
true extends ( & (A[0] extends undefined ? true : false) & (B[0] extends undefined ? true : false) ) ? Leftover extends true ? "1" : ""
Тут мы проверяем, не закончились ли у нас разряды у обоих чисел сразу. Если так, то мы завершаем рекурсию и возвращаем 1, если у нас осталась «единичка», либо пустую строку.
В результате у нас получается сумма чисел в виде строки. Никаких дополнительных манипуляций с ней производить не нужно, она уже записана в правильном порядке (старший разряд слева). Можно было попытаться возвращать результат в виде числа, но я не смог придумать оптимальный способ конвертации символов в число.
Ну и наконец делаем юзер-френдли тип, который принимает на вход два числа, конвертирует их в обратный массив символов и вызывает AddCore
type Add<A extends number, B extends number> = AddCore< Reverse<NumToChars<A>>, Reverse<NumToChars<B>> >;
Готово! Потыкать можно в песочнице посмотреть код — на гитхабе.
Подписывайтесь на мой телеграм-канал, ставьте лайки, до связи!