В этом посте я собираюсь объяснить, как расширенные типы, такие как Record, Union Types и Variadic Tuple Types, помогают вам писать выразительный и читаемый код.

Пост основан на Дне 12 AdventOfCode. Если вы не знаете об этом удивительном адвент-календаре с небольшими программными головоломками, проверьте.

Прежде чем продолжить, прочтите Описание головоломки 12-го дня. Проверьте мое полное решение здесь.

Мой подход к этим головоломкам следующий

  • Будьте выразительны с типами
  • Декларативный над императивным
  • Функциональное над процедурным/ООП
  • Читаемые имена для функций и переменных

Определим некоторые типы. Я использую кортежи вместо интерфейса или классов, я нахожу их более читабельными и менее многословными. TypeScript 4.0 позволяет помечать элементы кортежа.

Direction, Turn и ActionType являются типами объединения, а Navigation представляет собой кортеж из ActionType и числа.

type Direction = 'N' | 'S' | 'E' | 'W';
type Turn = 'L' | 'R';
type ActionType = Direction | Turn | 'F';
type Navigation = [action: ActionType, value: number];

Эти специфичные для предметной области типы улучшают читаемость, а также проверку типов.

const t: Turn = 'A'; // Invalid as value A is not a valid Turn
const t: Turn = 'L'; // Valid

Ввод головоломки представляет собой набор инструкций по навигации, каждая строка может быть сопоставлена ​​с типом навигации, как показано в коде ниже. Знак `+` преобразует строку в число. При таком разборе мы получаем массив навигационных инструкций.

/*
F10  ==> [F, 10]
N3   ==> [N,  3]
F7   ==> [F,  7]
R90  ==> [R, 90]
F11  ==> [F, 11]
*/
const lineToNav = (l: string) => [l[0], +l.substr(1)] as Navigation;

Наш корабль имеет начальное местоположение восток 0, север 0 и обращен на ВОСТОК. Каждое действие навигации изменяет это состояние в той или иной форме.

Переход состояния с приведенным выше вводом выглядит следующим образом

// State Transitions
START  east  0, north 0, facing east
1. F10 east 10, north 0, facing east
2. N3  east 10, north 3, facing east
3. F7  east 17, north 3, facing east
4. R90 east 17, north 3, facing south
5. F11 east 17, south 8, facing south

Следующий график показывает этот переход

Местоположение на графике может быть представлено двумя числами, x и y.

east  0, north 0 ==> x :  0, y :  0
east 10, north 0 ==> x : 10, y :  0
east 10, north 3 ==> x : 10, y :  3
east 17, north 3 ==> x : 17, y :  3
east 17, north 3 ==> x : 17, y :  3
east 17, south 8 ==> x : 17, y : -8

Вы также можете использовать комплексное число для представления двухмерного местоположения, в Python есть отличная поддержка для этого, и поток 12-дневных решений на Reddit демонстрирует множество таких примеров.

Итак, пришло время определить тип корабля для сохранения состояния.

type Ship = [x: number, y: number, facing: Direction];
const ship : Ship = [0, 0, E];

Каждый тип действия (N, S, E, W, L, R, F) указывает действие, которое изменяет состояние корабля. Определим тип Action. Это будет делегат, который принимает два параметра, корабль и значение и возвращает обновленное состояние.

type Action = (s: Ship, v: number) => Ship

Теперь начинается настоящая работа по написанию реализации для каждого типа действия. Чтобы сделать его типобезопасным, я собираюсь объявить дополнительный тип для хранения этого сопоставления.

type ActionMap = Record<ActionType, Action>;

Теперь вам должно быть интересно, что здесь Record. Запись Создает тип с набором свойств Keys типа Type. Таким образом, переменная/объект с типом ActionMap должна иметь все ключи ActionType (N, S, …) типа Action.

const shipNav: ActionMap = {
    // N10 means ActionType is N, and value (v) is 10
    // y indicates NORTH/SOUTH
    // Moving north means y will increase, x will stay as is
    N: ([x, y, f], v) => [x, y + v, f],
    S: ([x, y, f], v) => [x, y - v, f],
    E: ([x, y, f], v) => [x + v, y, f],
    W: ([x, y, f], v) => [x - v, y, f],
    L: ([x, y, f], v) => [x, y, takeTurn(f, v, L)],
    R: ([x, y, f], v) => [x, y, takeTurn(f, v, R)],
    F: ([x, y, f], v) => [
        f === E || f === W ? x + v * factor[f] : x,
        f === N || f === S ? y + v * factor[f] : y,
        f,
    ],
};

В приведенном выше коде N, S, E и W говорят сами за себя. L и R не влияют на x и y, но влияют на направление движения корабля.

Воздействие F зависит от направления, например, если судно смотрит на запад и указана команда F20, значение x должно уменьшиться на 20. Переменная factor — это просто карта воздействия (+ve или -ve) для каждого направления. TypeScript здесь очень умен, он понимает, что f имеет тип Direction, а factor имеет значения каждого направления, поэтому он хорошо работает без каких-либо дополнительных аннотаций типа для переменной «factor».

const factor = {E: 1, W: -1, S: -1, N: 1};

Имея все это в руках, остается написать функцию сокращения для перехода из одного состояния в другое. Нашей актуальной задачей является вычисление манхэттенского расстояния между начальной точкой (0, 0) и последней точкой.

function calculate(instructions: Navigation[]){
    const [x, y] = instructions.reduce(
        (state, [action, value]) => shipNav[action](state, value),
        [0, 0, E] as Ship
    );
    return Math.abs(x) + Math.abs(y);
};

Редукторы берут [0, 0, E] в качестве базы и сохраняют состояние преобразования. Используя функцию разрушения TypeScript, мы считываем конечное положение корабля и получаем сумму его абсолютных значений.

Теперь идет часть 2 с новой концепцией, называемой путевой точкой. Правила для действий также немного отличаются. Направление движения корабля теперь не имеет значения. Я собираюсь определить один тип для представления этих изменений.

type Snwp = [x: number, y: number, wx: number, wy: number];

Я знаю, что нет такого слова, как Snwp, здесь оно означает Корабль и Путевую точку. x и y для позиции корабля, а wx и wy для позиции путевой точки.

Теперь у нас есть два типа состояний, давайте воспользуемся универсальным для уточнения некоторых из предыдущих типов.

type Action<T> = (s: T, v: number) => T;
type ActionMap<T> = Record<ActionType, Action<T>>;

Действие принимает общий тип и возвращает обновленное значение того же типа, в то время как ActionMap также корректируется соответствующим образом.

Давайте поработаем над правилами навигации для Snwp.

const snwpNav: ActionMap<Snwp> = {
    N: ([x, y, wx, wy], v) => [x, y, wx, wy + v],
    S: ([x, y, wx, wy], v) => [x, y, wx, wy - v],
    E: ([x, y, wx, wy], v) => [x, y, wx + v, wy],
    W: ([x, y, wx, wy], v) => [x, y, wx - v, wy],
    L: ([x, y, wx, wy], v) => [x, y, ...rotate(wx, wy, v, L)],
    R: ([x, y, wx, wy], v) => [x, y, ...rotate(wx, wy, v, R)],
    F: ([x, y, wx, wy], v) => [x + wx * v, y + wy * v, wx, wy],
};

Реализация вращения немного сложна, нам нужно снова посетить график. Также обратите внимание, что rotate только возвращает обновленные wx и wy, но с (оператор расширения) он становится частью кортежа состояния.

R — вращение по часовой стрелке, а L — вращение путевой точки против часовой стрелки. R90 и L270 эквивалентны, они меняют значения координат x и y и меняют знак оси y. R180/L180 переключает знаки x и y.

const rotate = (
    wx: number,
    wy: number,
    value: number,
    turn: Turn
): [number, number] => {
    if (value == 180) {
        return [wx * -1, wy * -1];
    }
    if ((value === 90 && turn === R) 
         || 
        (value === 270 && turn == L)) {
        return [wy, -wx];
    }
    return [-wy, wx];
};

Время заканчивать часть 2, обратите внимание, что начальная позиция путевой точки 10, 1.

function calculate2(instructions: Navigation[]){
    const [x, y] = instructions.reduce(
        (state, [action, value]) => snwpNav[action](state, value),
        [0, 0, 10, 1] as Snwp
    );
    return Math.abs(x) + Math.abs(y);
};

Все хорошо, время для окончательного рефакторинга. Если вы видите, что функции calculate и calculate2 структурно идентичны.

С помощью дженериков его можно реорганизовать следующим образом.

Но TypeScript не устраивает наше разрушение общего T на x и y. Мы можем обмануть и преобразовать результат из reduce в any , но тогда это сделает Андерса Хейлсберга (мозг автора TypeScript) недовольным.

Давайте скажем TypeScript, что T — это кортеж, первые два элемента которого — число, и он позаботится об уничтожении x и y. К счастью для нас, и Ship, и Snwp хранят состояние корабля в первых двух элементах.

Примечание. Variadic Tuple Types — это новая функция TypeScript 4.0.

function calculate<T extends [number, number, ...any]>(

Вот последний фрагмент

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

mapLine — это вспомогательная функция для чтения ввода.

Спасибо за чтение.