Как и многие разработчики, я проснулся 1 декабря, как будто это было рождественское утро, и мне не терпелось броситься к своему ноутбуку и посмотреть, что S̶a̶n̶t̶a̶ Eric Wastl принес мне в этом году. Правильно, пришло время для Пришествия кода в этом году!

Если вы никогда не слышали об этом, Advent of Code — это ежегодный адвент-календарь задач по программированию в виде истории, разбитой на 25 двухэтапных частей. Этот год особенно пронзительный, так как он проходит в отпуске на тропическом острове (хороший Covid), однако предыдущие выпуски перенесли нас в открытый космос и через поток времени. Каждое испытание раскрывается в полночь по восточному поясному времени и состоит из части истории этого дня и двух задач, вторая разблокируется после завершения первой. Вы можете писать свои решения на любом языке, я настоятельно рекомендую проверить субреддит AoC, чтобы увидеть некоторые умопомрачительные вещи, которые делают люди (визуализация Unity — особая изюминка для меня).

В течение последних нескольких лет я пытался следовать за мной, хотя время/праздничные обязательства/общее выгорание из-за рождественских хлопот всегда приводили к тому, что я сдавался. В этом году, учитывая дополнительное время, предоставленное моей новой удаленной работой, и явное отсутствие социальных обязательств (опять же, хороший Covid), я полон решимости пройти через это. Я пишу свои решения на Typescript, а также следую некоторым принципам TDD, поскольку я всегда стремлюсь улучшить свои навыки в этих областях.

Мой первоначальный подход к задачам AoC заключался в том, чтобы решать каждую из них по отдельности, однако в прошлом году я заметил, что несколько дней повторно использовались концепции из предыдущих задач, в частности дьявольская машина Intcode, которая продолжала возникать. В результате в этом году я позабочусь о том, чтобы везде, где это возможно, я реорганизовал свои решения в легко повторно используемые классы/модули, на всякий случай!

Пока этого не произошло, однако, если это произойдет, я буду готов!

Возьмем День 4 в качестве примера моего процесса…

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

const passports = parseInput('input.txt');
const requiredFields = [...];
const validCount = passports.reduce((count, passport) => {
   // Check passport fields and increment count if all valid
}, 0);
console.log(`Valid passports: ${validCount}`);

Это совершенно верно и дало мне правильный ответ, однако часть 2 поставила гораздо более строгий набор требований для каждого поля. Очевидно, я мог бы просто справиться с моим первоначальным подходом, но мой инстинкт программиста подсказывал мне, что проверка входных данных на соответствие набору требований — это то, что может снова появиться в будущем. Время для рефакторинга!

Создание валидатора

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

type Rule<T> = (input: T) => boolean;
interface iValidator<T> {
   rules: Rule<T>[];
   validate: (input: T) => boolean;
}

Обратите внимание, что мы определяем общий тип T, что означает, что наш валидатор не привязан к какому-либо конкретному типу данных.

Теперь давайте будем хорошими разработчиками и напишем тесты для ожидаемого нами поведения. Мне нравится использовать Jest в качестве средства запуска тестов, но есть много других альтернатив. Обратите внимание, что во время тестирования мы указываем проверку строки, хотя теоретически, если наши тесты проходят для одного типа, они должны пройти для всех (благодаря магии Typescript!)

//Validator.spec.ts
describe('Validator', () => {
   const minLengthRule: Rule<string> = input => input.length > 5;
   const maxLengthRule: Rule<string> = input => input.length < 10;
   
   const validator = new Validator<string>([
      minLengthRule,
      maxLengthRule,
   ]);
   describe('validate', () => {
      it('returns true when all rules pass', () => {
         expect(validator.validate('abcdef')).toBeTruthy();
      });
      it('returns false when any rule is broken', () => {
         expect(validator.validate('abc')).toBeFalsy();
         expect(validator.validate('abcdefghijk')).toBeFalsy();
      });
   }) 
});

Очевидно, что на данный момент наши тесты проваливаются, потому что мы не написали никакого кода! Давайте исправим это.

class Validator<T> implements iValidator<T> {
   constructor(public rules: Rule<T>[]){}
   validate(input: T): boolean {
      return this.rules.every(rule => rule(input));
   }
}

И вуаля, все наши тесты должны быть зелеными. Теперь мы можем написать столько правил, сколько захотим, передать их валидатору и проверить, проходят ли наши входные данные. Для этой конкретной задачи я написал класс Passport для представления отдельных входных данных (см. Мой репозиторий Github для справки).

const passports: Passport[] = parseInput();
const requiredKeys: Rule<Passport> = (input) => {...}
const ruleX: Rule<Passport> = (input) => {...}
const ruleY: Rule<Passport> = (input) => {...}
//...Other rules
const validator1 = new Validator<Passport>([requiredKeys]);
const validator2 = new Validator<Passport>([
   requiredKeys,
   ruleX,
   ruleY,
   ...
]);
const part1 = passports.reduce((count, passport) =>{
   if(validator1.validate(passport)) {
      count += 1;
   }
   return count;
}, 0);
const part2 = passports.reduce((count, passport) =>{
   if(validator2.validate(passport)) {
      count += 1;
   }
   return count;
}, 0);

Мы могли бы оставить это здесь, наш класс Validator дает правильные ответы, у нас есть 2 золотые звезды, и мы можем перейти к следующему дню… но мое паучье чутье все еще покалывает. Эти 2 варианта использования Array.reduce() меня беспокоят, они выглядят очень похоже, и получение массива допустимых входных данных кажется довольно полезным. Давайте напишем несколько тестов, чтобы определить ожидаемое поведение.

//Validator.spec.ts
describe('Validator', () => {
   const minLengthRule: Rule<string> = input => input.length > 5;
   const maxLengthRule: Rule<string> = input => input.length < 10;
   const validator = new Validator<string>([
      minLengthRule,
      maxLengthRule,
   ]);
...
   describe('Filtering', () => {
      it('filters out invalid inputs', () => {
         expect(validator.filterInvalid([
            'abc',
            'abcdef',
            'abcdefghijk',
         ])).toEqual(['abcdef']);
      })
   });
});

А теперь пусть это пройдет…

class Validator<T> implements iValidator<T> {
   constructor(public rules: Rule<T>[]){}
   validate(input: T): boolean {
      return this.rules.every(rule => rule(input));
   }
   filterInvalid(input: T[]): T[] {
      return input.filter(el => this.validate(el));
   }
}

Сладкий, все наши тесты зеленые, и наш код решения теперь выглядит так

...
const part1 = validator1.filterInvalid(passports).length;
const part2 = validator2.filterInvalid(passports).length;

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

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

Я надеюсь, что вы нашли это полезным независимо от того, делаете ли вы AoC или нет, но если вам повезло!