Ecosyste.ms: Awesome

An open API service indexing awesome lists of open source software.

Awesome Lists | Featured Topics | Projects

https://github.com/picalines/ts-digital-root-presentation

Презентация про TypeScript на Motion Canvas
https://github.com/picalines/ts-digital-root-presentation

Last synced: 1 day ago
JSON representation

Презентация про TypeScript на Motion Canvas

Awesome Lists containing this project

README

        

# Что это?

Это исходный код презентации про [цифровой корень](https://en.wikipedia.org/wiki/Digital_root) на TypeScript

Работает на [Motion Canvas](https://motioncanvas.io/), для просмотра введите `npm start`. Ниже приведён примерный сценарий для выступающего

# Введение

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

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

Из определения несложно догадаться, что цифровой корень для числа от 0 до 9 равен этому же числу. Давайте запишем функцию цифрового корня как `dr`, где за `ds` я обозначил сумму цифр

```math
dr(n) = \begin{cases}n, 0 \le n \le 9\\dr(ds(n)), n \gt 9\end{cases}
\\
ds(n) = \begin{cases}n, 0 \le n \le 9\\n \bmod 10 + ds(\lfloor n \div 10 \rfloor)\end{cases}
```

Сюжетный поворот того контеста был в том, что эту функцию надо вычислить *на типах TS во время компиляции*. Крутой повод погрузиться в `infer` и прочую магию TS!

```tsx
type DigitalRoot = ...
```

# Арифметика в типах?

Чтобы осознать масштаб проблемы, заметим, что в TS нет *никаких* механизмов для работы с числами. Условное `123` не несёт в себе какого-то математического смысла - это просто какой-то тип, который наследует `number`, и который отличен от `122`, `124`, `199.5` и остальных

```tsx
type Sum = 123 + 456 // ?
```

Для построения логики у нас есть всего несколько инструментов - это условные типы с `extends`, рекурсия, заветный `infer` и немножко хитрости

Давайте сначала закрепим нашу предметную область - объявим тип для одной цифры `Digit`. Это будет простое перечисление от 0 до 9

```tsx
type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
```

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

# Только не точь в точь!

Предлагаю поломать голову: попробуйте вспомнить, как вы учились складывать в столбик. У каждого человека будет немного разная картинка в голове, но в рамках программы нам подойдёт и обычное заучивание! Мы можем заранее записать результаты сложения двух цифр, а потом просто подсматривать в шпаргалку

В обычном TS коде мы часто достаём типы отдельных полей из объектов, чтобы прокинуть только часть нужных параметров. Таким же образом можно доставать элементы из массива по их индексу:

```tsx
type CheatSheet = [0, -2, 9, 4, 3]
type Solution = CheatSheet[2]
// ^? 9
```

А теперь хитрость - давайте используем `Digit` как индекс нашей шпаргалки. Чтобы подсматривать было удобнее, сделаем двумерный массив - таблицу сложения двух цифр, где индекс строки это первая цифра, а индекс столбца - вторая. И завернём эти индексы в отдельный generic тип, чтобы все думали, что внутри написан реально сложный алгоритм:

```tsx
type AddTable = [
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
[2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
[3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
[4, 5, 6, 7, 8, 9, 10, 11, 12, 13],
[5, 6, 7, 8, 9, 10, 11, 12, 13, 14],
[6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
[7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
[8, 9, 10, 11, 12, 13, 14, 15, 16, 17],
[9, 10, 11, 12, 13, 14, 15, 16, 17, 18],
]

type AddDigits = AddTable[A][B]
```

Заметим, что в результате сложения, *кто бы мог подумать*, иногда получаются двузначные числа, с которыми мы ещё не умеем работать. Давайте сделаем отступление и ненадолго вернёмся к математике - обещаю, у этого есть смысл!

# Считаем цифровой корень… без суммы?

Предлагаю немного порассуждать про цифровой корень. Мы уже знаем, что числа от 0 до 9 равны своим же цифровым корням. Дальше идёт 10, и его цифровой корень равен 1. Потом будет 2, 3, 4… А на 19 снова будет 1. Нетрудно заметить, что цифровой корень зацикливается

Цифровой корень ведёт себя почти так же, как остаток от деления на 9. Отличаются они только на 9, 18, 27 и т.д. - там остаток равен нулю, а цифровой корень - девяти, т.е.

```math
dr(n) = \begin{cases}n \bmod 9, n \bmod 9 \ne 0\\
9, n \bmod 9 = 0\end{cases}
```

Если немного поиграться в голове, то такую запись можно сократить до:

```math
dr(n) = 1 + (n - 1) \bmod 9
```

А у остатка от деления, *делаю вид что всегда это знал*, есть дистрибутивное свойство:

```math
(a + b) \bmod n = [a \bmod n + b \bmod n] \bmod n
```

Давайте на секунду поиграем в математиков, и запишем такое же равенство, только заменим mod n на dr

```math
dr(a + b) = dr(dr(a) + dr(b))
```

И, пользуясь определением $dr$ через $mod$ попробуем это доказать

```math
\begin{aligned}
dr(a + b) = 1 + ([1 + (a - 1) \bmod 9] + [1 + (b - 1) \bmod 9] - 1) \bmod 9\\
dr(a + b) = 1 + [1 + (a - 1) \bmod 9 + (b - 1) \bmod 9] \bmod 9\\
dr(a + b) = 1 + [1 \bmod 9 + (a - 1) \bmod 9 + (b - 1) \bmod 9] \bmod 9\\
dr(a + b) = 1 + (a + b - 1) \bmod 9\\
dr(a + b) = dr(a + b)
\end{aligned}
```

*Минуту молчания всем, кто пришёл послушать про TypeScript*. Потерпите ещё пару слайдов, у этого точно есть смысл! Это свойство помогает заметить другую штуку: если dr можно получить разбив число на сумму, то можно поиграть вот так:

```math
dr(1234) = dr(1200 + 34)
```

А здесь можно догадаться до другой фигни: если дописывать к числу нули, то его цифровой корень не изменится. Получается, что мы можем “разрывать” число на отдельные цифры вот так:

```math
dr(1234) = dr(12 + 34) = dr(dr(1 + 2) + dr(3 + 4))
```

Или, что уже более полезно, вот так:

```math
dr(1234) = dr(1 + 234) = dr(1 + dr(2 + 34)) = dr(1 + dr(2 + dr(3 + dr(4))))
```

А полезно это тем, что в такой форме нет ни одного двузначного числа! dr всегда даёт значение от 0 до 9, и складываем мы его тоже всегда с однозначным. Самое страшное, что может случиться, это если сумма двух цифр будет двузначной

С этим уже можно работать! Получается, что нам не нужно складывать *любые* два натуральных числа, чтобы формировать сумму всех цифр. Достаточно только уметь складывать две цифры, а потом считать их цифровой корень. Самая большая сумма от двух цифр - это 18

Не будем долго думать над алгоритмом в TS - тут подойдёт и простая шпаргалка. Давайте обзовём эту операцию малым цифровым корнем, а потом используем его в реализации обычного. У ~~этой функции~~ этого типа нет конкретного ограничения на вход, но возвращает он всегда `Digit`

```tsx
type SmallDigitalRootTable = [
// 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 1, 2, 3, 4, 5, 6, 7, 8, 9
]

type SmallDigitalRoot =
SmallDigitalRootTable[N] extends Digit
? SmallDigitalRootTable[N]
: never
```

> [!NOTE]
> Интересный факт, здесь **нужна** заглушка на `never`. Немного позже объясню почему

Наш алгоритм будет брать две цифры из числа, брать их “малый цифровой корень”, а потом подставлять его в ту же сумму, пока не останется одна цифра. Несложно заметить, что порядок цифр тут неважен, поскольку в конечном счёте всё сводится к обычной сумме

# *Подскажите*, я плохо вижу!

На первый взгляд кажется, что мы уже можем написать `DigitalRoot`, но так мы быстро споткнёмся при реализации: в рекурсии надо заменять две цифры на их “малый цифровой корень”, т.е. разделять и склеивать массивы цифр - давайте пока придумаем алгоритм для `Digit[]` c подписью `Impl`, а потом научимся читать их из `number`

Есть простой базовый случай, когда на вход приходит массив из одной цифры. Его можно написать так:

```tsx
type DigitalRootImpl =
DS extends [Digit]
? DS[0]
: ...
```

…но я сегодня вредный и хочу рассказать про `infer`

Иногда в TypeScript’е нам нужно “достать составную часть” из какого-то типа. Мы уже умеем брать элементы таплов и поля объектов, поэтому давайте придумаем другой кейс. Самый простой на свете пример: у нас есть функциональный React компонент, и мы хотим получить его пропсы. Человек экспортировал только функцию, авторы React удалили `ComponentProps`, а линтер в транке вечером пятницы почему-то запрещает использовать встроенный `Parameters` - детали неважны, мы просто хотим достать пропсы без подглядывания в чужой код

```tsx
export const ShinyButton: FC<{
sparkle: number,
onClick: () => void,
...
}> = ({ sparkle, ...props }) => {
...
}

// other module

type ShinyButtonProps = ???
```

Получается, что мы знаем как в общих чертах выглядит наш тип, но не знаем его деталей. `extends` и `infer` помогут нам решить эту проблему!

`infer` позволяют в интуитивной форме написать, что, е*сли какая-то штука имеет такую-то форму, то из неё нужно достать эти детали*. `infer` по сути объявляет новый generic параметр внутри вашего типа, который можно использовать для какой-то логики

```tsx
type InnerPart = T extends { some: { shape: infer I } } ? I : never
type Example = InnerPart<{ some: { shape: 'It works!' } }>
// ^? 'It works!'
```

Вернёмся к примеру с React. Форма generic параметра - это *какая-то* функция с одним параметром, тип которого мы хотим получить, и которая возвращает ReactNode. В зависимости от решаемой проблемы в другой ветке условия можно тоже что-то вернуть, но там уже не будет доступен тип объявленный в `infer`.

```tsx
type ComponentProps = C extends (props: infer P) => ReactNode ? P : never
```

Если подумать, `infer` в TS типах идейно схож с синтаксисом *деструктурированного* присваивания - оно тоже проверяет значение на соответствие какой-то абстрактной форме, и достаёт найденные значения. Это сходство фичей ничего не даёт, мне просто нравится системно думать про языки программирования. Обычно такую фичу называют “сопоставлением шаблонов”, и она позволяет писать более декларативный код

```cs
Operation[] operations = [ /* ... */ ];

// Императивно:
if (operations.Length == 1 && operations[0] is DeleteOperation) {
var deleteOperation = (DeleteOperation)operations[0];
if (deleteOperation.ItemCount > 0) {
// ...
}
}

// Декларативно:
if (operations is [DeleteOperation { ItemCount: > 0 }]) {
// ...
}
```

# Теперь вижу получше!

Вернёмся к цифровому корню. Теперь мы можем написать базовый случай через `infer`:

```tsx
type DigitalRootImpl =
DS extends [infer D1 extends Digit]
? D1
: ...
```

…и это ничего не меняет, я просто вредина. Заметьте как после `infer` можно добавить вложенный `extends`: он уточняет условие, если вложенный тип тоже тоже должен иметь какую-то форму. Иногда это сокращает число веток в условиях, хотя здесь это, наверное, даже усложняет код

Напомню: мы написали случай для одной цифры в массиве, остался кейс с рекурсивным шагом. Здесь `infer` помогает описать уже более сложную форму: массив цифр `DS` должен иметь две цифры в начале, а потом заканчиваться на неизвестное число цифр

```tsx
type DigitalRootImpl =
DS extends [infer D1 extends Digit]
? D1
: DS extends [
infer D1 extends Digit,
infer D2 extends Digit,
...infer Rest extends Digit[]
]
? DigitalRootImpl<[SmallDigitalRoot>, ...Rest]>
: never
```

Эта конструкция может казаться сложной в первый раз, но идейно - мы просто достали из массива первые два элемента, назвали `D1` и `D2`, а остаток сложили в новый массив `Rest`. В положительной ветке реализуется алгоритм задачи: складываются две цифры, берётся их цифровой корень, а потом формируется новый массив цифр, от которого рекурсивно берётся новый цифровой корень

Мы написали основной алгоритм, но нам ещё нужно что-то придумать с `Digit[]` на входе. Давайте сделаем ~~функцию~~ тип `Digits`, который должен принимать `number` и отдавать `Digit[]`. У него есть такой же базовый случай с одной цифрой, когда мы можем сразу вернуть массив с одним элементом

```tsx
type Digits =
N extends Digit
? [N]
: ...
```

Но дальше опять возникают трудности. Как мы можем поделить number на отдельные цифры? Думаю много кто представит императивный алгоритм, в котором мы сначала преобразуем число в строку, возьмём её первый символ и преобразуем обратно в число. Здесь нам поможет относительно новая фича TS, с которой можно использовать `infer` *внутри шаблонных строк*.

```tsx
type Digits =
N extends Digit
? [N]
: `${N}` extends `${infer D1 extends Digit}${infer R extends number}`
? [D1, ...Digits]
: never
```

Идейно это тот же алгоритм, только написан декларативно. Мы смотрим на `N` в виде строки, а потом вычленяем из неё первую цифру `D1` и оставшееся число `R` справа. TS, *кто бы мог подумать*, упрощает жизнь и даёт преобразовать часть строки в число. Полученные части строки мы преобразуем в массив, где делается рекурсивный вызов. Работу `Digits` можно расписать вот таким образом:

```tsx
type X = Digits<123>
// ^? [1, ...[2, ...[3]]] => [1, 2, 3]
```

# Складываем пазл

Поздравляю, теперь у нас есть все инструменты для решения задачи!

```tsx
type DigitalRoot = DigitalRootImpl>

type Solution = DigitalRoot;
```

Думаю легко было заметить, что TS с этой точки зрения является уже не просто каким-то линтером с дополнительным синтаксисом, а настоящим функциональным языком программирования. *Желаю удачи как-то жить с этой информацией дальше!*