Вычислите число Фибоначчи, используя свой собственный тип
В этой статье мы приводим пример реализации ALU. Некоторые из операций, которые мы будем реализовывать, — это bitwise
операции, такие как shifts
и arithmetic
операции, такие как addition
и subtraction
.
В конце мы увидим, как мы можем использовать большинство этих операций для реализации типа, который может вычислять число fibonacci
.
Все конструкции, которые мы используем, являются типами TS, и все вычисления выполняются во время сборки. Наши типы не работают на JS. Мы полагаемся на систему типизации для выполнения вычислений.
Имея это в виду, давайте начнем.
Базовые типы
Нам нужен способ представить бит. Для этого мы начнем с представления двух состояний, которые может иметь бит (0 или 1).
Следующим шагом является определение бита как типа объединения двух состояний.
После этого мы можем перейти к определению байта. Для этого мы будем использовать кортежи.
type Byte = [Bit, Bit, Bit, Bit, Bit, Bit, Bit, Bit];
Это очень простое представление байта в системе типов TS, но его достаточно, чтобы начать работать над нашими операциями ALU.
Теперь мы можем определить несколько вспомогательных типов, которые мы можем использовать в дальнейшем.
type ZERO_BYTE = [Zero, Zero, Zero, Zero, Zero, Zero, Zero, Zero] type ONE_BYTE = [Zero, Zero, Zero, Zero, Zero, Zero, Zero, One]
Побитовые операции
Имея тип для бита, давайте посмотрим, как мы можем реализовать not
, and
, or
и xor
для битов.
Для этого будут усиленно использоваться ограниченные дженерики и условные типы.
Давайте немного рассмотрим детали реализации:
Битовое отрицание является самым простым. Мы ограничиваем T
значением Bit
, а затем проверяем, соответствует ли оно Zero
. Если это так, мы определяем тип как One
. В противном случае мы определяем тип как Zero
.
Это означает, что теперь мы можем использовать BitNot
вот так.
type OneNegated = BitNot<One>
И этот тип на самом деле будет Zero
.
Мы делаем что-то похожее на BitAnd
, но на этот раз нам нужно определить его как имеющий два общих типа: LHS
и RHS
. Мы используем условные типы, чтобы реализовать ветвление внутри системы типизации. Самый простой способ определить AND
— проверить, расширяют ли они оба One
и возвращают ли One
в этом случае.
Это очень похоже на следующий код:
if (LSH === One) { if (RHS === One) { return One } else { return Zero } } else { return Zero }
Из-за природы условных типов все ветви должны разрешаться в тип. По этой причине Zero
появляется два раза. Это делает эту реализацию более подробной.
Мы делаем что-то подобное для BitOr
.
BitXor
немного более подробен, потому что нет способа сократить вычисление, поэтому мы должны рассмотреть все четыре возможности.
Побайтовые операции
Теперь у нас есть все, что нужно для реализации побайтовых операций.
С этого момента мы будем в значительной степени полагаться на вывод внутри типов условий. Нам нужен способ обойти кортеж и применить операции к каждому компоненту. Этого можно добиться, используя универсальный тип с условными типами и выводя только часть кортежа. Мы можем сделать что-то похожее на это:
type Walkable<T extends Byte> = T extends [infer U extends Bit, ...infer R extends Bit[]] ? U : []
Это много, чтобы понять за один присест, поэтому давайте немного рассмотрим это.
Уточняем, что T extends Byte
. Это означает, что это кортеж, состоящий из 8 Bit
.
Следующим шагом является использование условия с extends
для разделения кортежа и возможности ссылаться на заголовок кортежа. В этом контексте the head of the tuple
означает первый Bit
в Byte
.
Мы можем сослаться на этот тип U
в истинной ветви условного оператора. То же самое относится к R
, который является типом кортежа со всеми остальными Bits
.
Самые простые операции, которые мы можем реализовать сейчас, это ShiftLeft
и ShiftRight
.
Давайте внимательно посмотрим, что происходит с ShiftLeft
. Мы выводим тип для самого левого Bit
, а затем создаем новый тип, который его игнорирует, распаковывает R
и добавляет Zero
. Это по существу перемещает наименее старшие 7 битов влево.
Теперь мы можем использовать его так:
ShiftLeft<[Zero, Zero, Zero, One, Zero, Zero, Zero, One]>
или используя помощники, которые мы определили ранее:
ShiftLeft<ONE_BYTE>
и в итоге получится:
[Zero, Zero, One, Zero, Zero, Zero, One, Zero]
Что-то очень похожее делается для ShiftRight
.
Байт-нет и Xor
Реализация not
и xor
для байтов использует аналогичную технику, но немного сложнее.
Причина, по которой not
и xor
более задействованы, связана с тем фактом, что после разделения head
и tail
байта нам нужно снова применить то же правило для tail
. Проблема в том, что tail
больше не Byte
, а tuple
только с 7
элементами. Выход из этого состоит в том, чтобы использовать другое определение type
, которое допускает любое Bit[]
.
Вот небольшая реализация для Not
:
Здесь происходит то, что мы используем BitNot
, чтобы перевернуть головной бит. Затем мы снова применяем тип, но на этот раз к оставшимся битам в байте. На самом деле это форма хвостовой рекурсии, и мы будем использовать ее чаще в будущих операциях.
Мы можем использовать это так:
type Flipped = ByteNote<[Zero, One, One, Zero, One, One, One, Zero]>
И Flipped
на самом деле будет:
[One, Zero, Zero, One, Zero, Zero, Zero, One]
XOR немного сложнее, так как нам нужно 2 Byte
для создания операции. Но мы можем использовать те же понятия, что и раньше:
- ограниченные дженерики
- условные типы
- используя вывод и хвостовую рекурсию для обхода байта
Поскольку у нас есть два оператора и тот факт, что нам нужны условные типы для вывода head
и tail
из Byte
, код труднее читать. Но используемые техники те же.
Одна вещь, которую следует отметить в реализации, это то, что мы останавливаем рекурсию, возвращая пустой массив []
. Причина, по которой это работает, заключается в том, что если TS
больше не может сделать вывод в предложениях extends
, он выберет альтернативный маршрут. В этом случае, используя empty tuple
в качестве типа.
Арифметическая операция
Теперь у нас есть все необходимое для реализации арифметических операций. Наша цель — иметь возможность использовать add
и subtract
байт.
Увеличение
Первое, что мы можем создать, это способность increment
. Это означает, что мы хотим добавить 1
к Byte
.
Это то, что вы много раз увидите при реализации этих операций:
T extends [...infer U extends Bit[], infer R extends Bit]
На этот раз нам нужно начать обработку Byte
с хвоста, поэтому мы будем использовать ту же технику infer
, но вместо захвата первого Bit
мы будем захватывать последний, а затем использовать рекурсию для оставшихся битов.
Теперь нам нужно проверить, имеет ли R
значение One
или нет.
Почему это важно?
Если R
это Zero
, то мы меняем его на One
, распаковываем U
и готово. С другой стороны, если R
равно One
, то нам нужно изменить его на Zero
, а затем continue
добавить One
к следующему Bit
. Это часть рекурсии.
T extends [...infer U extends Bit[], infer R extends Bit] ? R extends One ? [...PartialAddOne<U>, Zero] : [...U, One] : []
Полная реализация такова:
Теперь мы можем использовать его так:
type ONE = AddOne<[Zero, Zero, Zero, Zero, Zero, Zero, Zero, Zero]>
Результат будет:
[Zero, Zero, Zero, Zero, Zero, Zero, Zero, One]
Обратите внимание, что число, которое мы получаем в итоге, является его представлением Byte
, а не числом в том смысле, к которому мы привыкли.
Теперь мы можем сгенерировать положительное число (на самом деле мы можем сгенерировать любое представление Byte
, так как то, положительно оно или нет, является семантической проблемой).
Все это действительные операции сейчас:
type ZERO = [Zero, Zero, Zero, Zero, Zero, Zero, Zero, Zero] type ONE = AddOne<ZERO> type TWO = AddOne<ONE> type THREE = AddOne<AddOne type FOUR = AddOne<THREE>
Добавить с переносом
Прежде чем мы сможем перейти к реализации полного add
между 2 Bytes
, нам нужно сохранить добавление carry
между bits
.
ALU обычно должны иметь флаг (CF
), который можно использовать для этого. Но с этой точки зрения TS
не имеет глобального состояния. Поэтому мы не можем просто хранить это где-то и использовать позже. Мы всегда должны быть в состоянии передать его.
Для этого создадим несколько типов, которые нам помогут:
type CarryFlag = Bit; type BitWithCarry = { cf: CarryFlag value: Bit }
Теперь мы можем обращаться к флагу BitWithCarry
, когда это необходимо.
Точно так же, как мы сделали с Bit
логическими операциями, чтобы мы могли использовать их на Byte
позже, используя tail recursion
, мы сделаем то же самое сейчас.
Нам нужно реализовать возможность складывать 2 бита вместе, хранить значение полученного Bit
, а также carry value
.
Мы можем просмотреть таблицу истинности этой операции, которая поможет нам в реализации:
+------+------+------+------+------+
| Bit1 | Bit2 | CF | BitR | CR |
+------+------+------+------+------+
| 1 | 1 | 1 | 1 | 1 |
+------+------+------+------+------+
| 1 | 1 | 0 | 0 | 1 |
+------+------+------+------+------+
| 1 | 0 | 1 | 0 | 1 |
+------+------+------+------+------+
| 1 | 0 | 0 | 1 | 0 |
+------+------+------+------+------+
| 0 | 1 | 1 | 0 | 1 |
+------+------+------+------+------+
| 0 | 1 | 0 | 1 | 0 |
+------+------+------+------+------+
| 0 | 0 | 1 | 1 | 0 |
+------+------+------+------+------+
| 0 | 0 | 0 | 0 | 0 |
+------+------+------+------+------+
Теперь нам нужно реализовать это в TS
, используя условные типы. Это сложная задача, но в итоге мы получим что-то похожее на это:
Имея это в руках, мы можем приступить к реализации операции add
для bytes
.
Мы будем использовать те же методы, что и раньше:
- вывести последний бит
- используйте
AddBitsWithCarry
в последних битах - используйте вспомогательный тип для головных битов
- передать значение
cf
вспомогательному типу, так как это необходимо для остальных битов
У нас получается что-то похожее на это:
Несколько вещей, на которые стоит обратить внимание:
- Здесь мы использовали значение по умолчанию для общего параметра
CH extends CarryFlag = Zero
. В нашем случае это обязательно, но делаетPartialAdd
более гибким. - При рекурсивной ссылке на тип мы дважды вызываем
AddBitsWithCarry
. Сначала получитьvalue
, а затемcf
, даже если мы передаем одни и те же параметры. Опять же, потому что в системе типовTS
нет глобального состояния.
Вычитание и умножение
Теперь, когда у нас есть основа, реализовать другие операции намного проще.
Для вычитания мы будем использовать дополнение до 2. Короче говоря, нам нужно инвертировать Byte
, а затем add
один к нему. Это очень легко сделать со всеми типами, которые у нас есть:
type Byte2Complement<T extends Byte> = AddOne<ByteNot<T>>
Вычитание теперь становится очень простым. Нам нужно вычислить дополнение до 2 для RHS
, а затем добавить его к LHS
:
type Subtract<LHS extends Byte, RHS extends Byte> = Add<LHS, Byte2Complement<RHS>>
Для удобства мы можем реализовать decrement
:
type SubtractOne<LHS extends Byte> = Subtract<LHS, ONE_BYTE>
Теперь мы находимся в том месте, где мы можем заняться реализацией multiply
. Нам нужно думать об умножении как о повторяющемся addition
. Мы снова сделаем recursion
и будем уменьшать LHS
, пока оно не достигнет One
.
LHS
достижение One
— это условие остановки этой рекурсии.
Нам также нужно учитывать случай, когда LHS
на самом деле является Zero
, поэтому для этого у нас есть специальное условие. В итоге получаем вот это:
type Mul< LHS extends Byte, RHS extends Byte, Result extends Byte = RHS > = ONE_BYTE extends LHS ? Result : ZERO_BYTE extends LHS ? ZERO_BYTE : Mul<SubtractOne<LHS>, RHS, Add<Result, RHS>>;
Обратите внимание, что Result
по умолчанию равно RHS
. Это значит, что мы покрываем как случай, когда мы останавливаемся из-за рекурсии, так и когда LHS
на самом деле One
.
Теперь мы можем использовать его так:
Mul<ONE_BYTE, ZERO_BYTE> Mul<[Zero, Zero, Zero, Zero, Zero, Zero, One, Zero], [Zero, Zero, Zero, Zero, Zero, Zero, One, Zero]>
Что во втором случае получится:
[Zero, Zero, Zero, Zero, Zero, One, Zero, Zero]
Реализация сравнения
Наше АЛУ было бы неполным без некоторых реализаций сравнения (на самом деле, мы могли бы также использовать некоторое деление, но мы сделаем это позже).
Примечание: все операции сравнения выполняются без знака.
EQ
Первое сравнение, которое мы можем реализовать, и самое простое, это EQ
. Мы хотим увидеть, равны ли 2 Bytes
. Мы можем легко сделать это, вычитая одно Byte
из другого и проверяя, равно ли оно нулю. На нашем языке это можно записать так:
type EQ<LHS extends Byte, RHS extends Byte> = ZERO_BYTE extends ByteXOR<LHS, RHS> ? true : false
ЛТ (меньше чем)
Для реализации LT
нам сначала понадобится небольшая помощь. Основная идея реализации этого состоит в том, чтобы пройтись по байтам слева направо (от наиболее значимого к наименее значимому) и выбрать другой первый бит.
Пока биты одинаковы, текущая операция undecided
. Это означает, что нам нужно перейти к следующей паре.
Чтобы помочь с этим шагом, создадим вспомогательный тип, который будет возвращать true
, false
или undecided
. Это очень поможет при наборе текста в дороге. А пока давайте посмотрим на это в действии:
type BitLTB<LHS extends Bit, RHS extends Bit> = Zero extends LHS ? One extends RHS ? true : "undecided" : One extends RHS ? "undecided" : false;
Теперь мы можем перейти к полной реализации. Идея та же, что и раньше:
- вывести голову и решку из обоих операндов
- используйте
BitLTB
, чтобы увидеть, можем ли мы разделить, если мы можем вернуть это значение - если значение равно
undecided
, то перейти к следующему биту
Как и раньше, на реализацию немного сложно смотреть:
Еще раз, поскольку у нас нет глобального состояния, нам нужно вызывать BitLTB
несколько раз. Вероятно, мы могли бы сделать это немного короче, если бы проверили boolean extends BitLTB
.
Теперь мы можем использовать его так:
LT<ZERO_BYTE, ONE_BYTE> // will result in true LT<ONE_BYTE, ZERO_BYTE> // will result in false
С реализованным LT
остальные операции сравнения довольно легко реализовать:
LTE
Ничего больше, чем LTE
или EQ
type LTE<LHS extends Byte, RHS extends Byte> = true extends LT<LHS, RHS> ? true : EQ<LHS, RHS>
GT
Это просто LT
, но с переставленными операндами.
type GT<LHS extends Byte, RHS extends Byte> = LT<RHS, LHS>
ГТД
То же самое, но с помощью LTE
.
type GTE<LHS extends Byte, RHS extends Byte> = LTE<RHS, LHS>
Разделение
Последняя операция, которую мы реализуем, — это деление с остатком.
Для этого мы будем использовать LT
и Subtraction
. Мы вернем тип, который имеет две величины:
- частное
- остаток
Нам нужно принять во внимание division by zero
. Итак, мы будем использовать внутренний тип для последующих subtraction
и использовать основной тип только для проверки zero
. У нас получается что-то вроде этого:
type DivInternal<LHS extends Byte, RHS extends Byte, Q extends Byte = ZERO_BYTE> = true extends LT<LHS, RHS> ? { q: Q, r: LHS } : DivInternal<Subtract<LHS, RHS>, RHS, AddOne<Q>> type Div<LHS extends Byte, RHS extends Byte> = ZERO_BYTE extends RHS ? "Division by 0" : DivInternal<LHS, RHS>
Основной тип Div
проверяет, является ли RHS
Zero
, и возвращает строку "Division by 0"
.
В случае, когда RHS
не является 0
, мы откладываем до DivInternal
. Он вычитает RHS
из LHS
до тех пор, пока LHS
не станет меньше RHS
. Мы знаем, что это можно сделать, поскольку RHS
не равно нулю. Теперь мы можем использовать его так:
Div<TWO, ONE> // we get {q: TWO, r: ZERO_BYTE} Div<ONE, ZERO_BYTE> // we get "Division by 0" Div<[Zero, Zero, Zero, Zero, One, Zero, One, Zero], TWO> // we get {q:[Zero, Zero, Zero, Zero, Zero, One, Zero, One], r: ZERO_BYTE}
Реализация Фибоначчи
Все это привело нас к моменту, когда мы можем реализовать числа fibonacci
.
Идея этой реализации строится на тех же принципах, что и раньше:
- общие ограниченные типы
- условные типы
Мы определяем тип FIBONACCI
с тремя общими аргументами.
C
текущий индекс в вычисленииA
текущий номер в последовательностиB
предыдущее число в последовательности
Затем мы просто decrement C
вычисляем новый A
как сумму A
и B
, заменяем новый B
старым A
. Нам также нужно иметь условие остановки. Это представлено, если C
равно 1 или 2. Давайте посмотрим на это в действии:
Со всем этим мы можем вычислить fibonacci
порядковых номеров, передав двоичное представление числа.
FIBONACCI<[Zero, Zero, Zero, Zero, Zero, One, One, One]>
Он вычислит 7-е значение в последовательности Фибоначчи, а именно:
[Zero, Zero, Zero, Zero, One, One, Zero, One] // 13
Заключительные мысли
Мы показали способ реализации простого АЛУ с использованием системы типов TypeScript.
Основные приемы, которые мы использовали:
- общие ограниченные типы
- условные дженерики для достижения ветвления
- условные дженерики, которые ссылаются на себя для создания циклов (рекурсия)
Полную реализацию этого можно найти в этом репозитории GitHub. В репозитории также есть набор тестов, используемых для проверки операций.
Надеюсь, вам понравилось это читать. Увидимся в следующей статье.