Здравствуй, Хабр. Недавно я проходил собеседование в одну солидную айтишную контору. Когда мы разобрались с формальностями, начался технический этап, на котором мне поручили написать fizzbuzz. По не вполне понятным мне причинам обсуждение решения этой задачи растянулось на довольно большой срок, после которого время на интервью уже вышло. Мы расстались на хорошей ноте, и мне пообещали перезвонить. Пока я жду оффер, я решил поделиться своим опытом прохождения интервью с широкой публикой, равно как и своим решением, ибо они показались мне заслуживающим внимания. Интервьюер был настроен доброжелательно. — Напишите программу, которая бы вывела числа от одного до ста включительно по следующим правилам: если число делится на 3, то выведите "fizz", если число делится на 5, то выведите "buzz", если число делится и на 3, и на 5, то выведите "fizzbuzz", а если ни одно из этих условий не выполняется, то выведите число как обычно. — Хорошо. Как отделять числа друг от друга? По одному на строчку? — Как вам удобно. — Ясно. Какой язык программирования я могу использовать? — Любой, с которым вам удобно. Впрочем, крайне желательно, чтобы это не был какой-то эзотерический язык программирования. А то у нас как-то один кандидат — доброжелательный интервьюер на секунду поёжился — как-то развернул список на чём-то вроде Haskell и даже тестов не написал. — Спасибо, я буду использовать Rust. — Это тот новый хайповый язык программирования? — Да. Он похож по синтаксису на C, так что проблем с пониманием возникнуть не должно. В этот момент интервьюер облегчённо выдохнул. — Хорошо. Можете начинать. Я открыл Rust playground и начал писать код: Интервьюер слегка нахмурился. — Не могли бы вы пояснить, что вы делаете? — Конечно. Я делаю свои собственные числа согласно аксиоматике Пеано. Как видите, эти два определения напрямую отражают аксиомы: натуральное число — это либо ноль, либо некоторое иное натуральное число, увеличенное на единицу. — А зачем вам эти определения? Разве вы не можете обойтись встроенными числовыми типами? — Разумеется, могу. Однако в той задаче, которой вы мне дали, отсутствуют входные данные, весь вывод программы можно подсчитать заранее, поэтому я бы хотел избежать ненужных вычислений в рантайме. С другой стороны, все преобразования над типами гарантированно выполняются на этапе компиляции — всё-таки Rust статически типизированный язык — поэтому вынесение чисел на уровень типов позволит делать меньше в рантайме. — А, ну если вы так считаете, — сказал интервьюер после некоторой паузы — хорошо, продолжайте. Я где-то читал, что на собеседовании хорошим тоном является проговаривать свои мысли вслух. Поэтому, а также из-за вопросов интервьюера я решил самостоятельно комментировать то, что пишу: — Конечно, мы можем составить из этих S нужную нам сотню, но это несколько утомительно и вдобавок увеличивает шанс внести ошибку из-за опечатки, поэтому я реализую способ складывать подобные числа: — Для того, чтобы составить сумму из чисел, записанных в типах, нам потребуются трейты (некоторые также называют их "типажами", но мне этот термин не очень нравится). Функций на уровне типов в Rust, увы, нет, но трейты с ассоциированными типами позволяют до некоторой степени их эмулировать. Сейчас я написал новый трейт, который будет сопоставлять Self (типу, для которого определяется трейт) и Rhs их сумму через ассоциированный тип Sum. Так как развёрнутый синтаксис, используемый для сопоставления типу его ассоциированного типа определённого трейта, несколько шумный, для удобства я определил псевдоним, который скрывает эту громоздкую конструкцию. Я сделал небольшую паузу, чтобы дать интервьюеру переварить услышанное, и продолжил: — В силу того, что определение чисел у меня индуктивное, определение суммы также должно быть индуктивным. Базой индукции, очевидно, является случай, когда одно из слагаемых равно нулю: в этом случае сумма равна другому слагаемому. Но что делать в шаге индукции, когда мы складываем два ненулевых числа? В таком случае мы можем одно слагаемое — скажем, первое — уменьшить на единицу, а другое — увеличить на единицу, и для вновь образованных слагаемых подсчитать сумму. Сумма от этого, разумеется, не изменится, но мы свели задачу к меньшей. Рано или поздно это уменьшение приведёт к тому, что одно из слагаемых станет нулевым, и мы сможем воспользоваться базой индукции. Выразим это в коде: — Имея на руках сложение, уже несложно выписать нужные нам константы: три, пять и количество требуемых элементов: сто. — Окей, с числами мы разобрались. Теперь нам нужно каким-то образом отсчитать от единицы до сотни. Судя по всему, нам понадобится список из них. Списки также несложно определить индуктивно: это или пустой список, или пара из первого элемента списка и всех остальных элементов списка: — Кстати, как нетрудно видеть, определённые мною числа изоморфны спискам из элементов unit-типов, то есть я вполне мог бы вместо S<N> использовать Cons<(), N>. Но я решил этого не делать, чтобы сохранить ясность кода. Интервьюер медленно кивнул. Убедившись, что меня понимают, я продолжил: — Теперь встаёт вопрос, а как нам составить список из чисел от единицы до сотни включительно. К сожалению, попытка сделать это напрямую вынудит нас задать на числах отношение порядка, и в силу того, что требования к типам в Rust вычисляются не лениво, подход в лоб приведёт нас к рекурсии при вычислении ограничений на типы. Поэтому поступим по-другому: сделаем сначала список от сотни до единицы, а потом обратим его. Нам опять потребуется функция на уровне типов, которую мы выразим как трейт с ассоциированным типом: — Кстати, кое-что с этой функцией не так. Вам очевидно, что именно? Интервьюер немного помолчал, а потом ответил: — Наверное… Но не могли вы сами сказать? Сообразив, что меня проверяют, я продолжил со своим ответом: — Эта функция делает список, который кончается нулём, а не единицей. К счастью, отбросить потом голову списку — плёвое дело, а в некоторых аспектах это даже окажется удобным, — тут я виновато улыбнулся — я уже прикидывал, как такое делать. Интервьюер тоже улыбнулся, но, как мне показалось, несколько натянуто. Я перешёл к следующей части решения: — Обращение списка, как известно, является банальной левой свёрткой по списку с операций cons с аргументами в обратном порядке и пустым списком в качестве начального значения. К сожалению, система типов Rust недостаточно продвинута, чтобы выразить на уровне типов функции высшего порядка, поэтому свёртку придётся писать руками. Впрочем, она тут достаточно простая: для пустого списка нам достаточно вернуть аккумулятор, а для cons нам надо отделить голову, приставить её к аккумулятору и продолжить обращение с хвостом списка. Всё это достаточно коротко и ясно выражается в коде: — Теперь перейдём к сути задания. От нас требуется классифицировать числа по их остаткам от деления на три и на пять. В принципе, мы можем реализовать деление с остатком чисел Пеано, но это заняло бы довольно много нудного кода. Вместо это попробуем прикинуть, как добиться нужного результата меньшими усилиями. Я на время откинулся от клавиатуры. — Что вообще означает, что остаток числа от деления на, скажем, три, равен нулю? По другому это можно сформулировать так, что если мы начнём считать от нуля, то каждое третье число будет, очевидно, делиться на три без остатка. Мы можем пройтись по всему списку, вручную отмечая каждый третий элемент. Конечно, мы не хотим повторять себя и хотим использовать схожий подход для пятёрки. Мы хотим абстрагироваться от конкретного числа, используя счётчик, который будет инкрементироваться для каждого элемента списка по некоторому модулю. Таким образом, нам нужно определить операцию инкрементирования по модулю некоего числа n, а потом прикрепить к этому счётчик и поднять эту операцию на уровень списков. — Или же нет? — прервал я сам себя — при таком подходе мы неизбежно столкнёмся с теми же проблемами, что и при наивной реализации RangeTo: бесконечной рекурсии требований к типам. С другой стороны, реализовать декремент по некоторому модулю мы спокойно можем. При этом позиции, где счётчик принимает значение ноль, останутся теми же самыми — а именно они нам и нужны. Снова определим результат индуктивно: ноль, уменьшенный на единицу по некоторому модулю, равен числу, на единицу меньшему модулю, а число, большее единицы, декрементированное по тому же модулю, ну, то же самое число, только без единицы. В коде это выглядит менее неловко, чем мои слова, — я снова принялся печатать: Теперь нам нужно прикрепить счётчик к списку. Конечно, можно сказать, что это какая-то правая свёртка, но тут это ясности не прибавляет, проще написать код: Код становится немного угрожающим, но более сложных наворотов на уровне типов нам уже не потребуется. Введём алиасы для нужных нам операций: Осталось только отобразить значение со счётчиками на нужное значение на уровне типов, что фактически сводится к сопоставлению с образцом: Если честно, моего внутреннего пуриста немного коробит, что одно и то же определение используется и для отдельных элементов, и для списка целиком, но вреда тут это не несёт, а введение отдельного трейта для операций на списках не дало бы никаких преимуществ. Я сделал небольшую паузу, чтобы выдохнуть, и продолжил: — Что ж, у нас почти всё готово, осталось лишь собрать всё это воедино — и учесть тот факт, что список в итоге начинается с нуля и потому нуждается в срезании головы: Фактически, нам сейчас даже не нужно писать никакого рантайм-кода: мы можем просто написать что-то с использованием List, что не будет тайпчекаться, и компилятор выведет нам тип сам, — я с энтузиазмом набросал код: Затем я нажал Ctrl+Enter и через пару секунд с довольным видом показал интервьюеру ошибку компиляции: Интервьюер посмотрел на экран, прокрутил его вплоть до конца длинной ошибки, немного помолчал со сосредоточенным видом, а потом спросил меня: — Извините, а что в итоге сделала ваша программа? — Моя? — я развёл руками — моя программа не сделала ничего. В конце-концов, она не скомпилировалась. Интервьюер вздохнул. — Это не то, что от вас требовалось. От вас требовалось написать программу, которая будет, гм, запускаться, и выдавать нужный результат. Вдобавок, этот вывод, возможно, и правильный, но я не могу его прочитать, так что я не смогу сказать, правильный он или нет. Я хлопнул себя по лбу. — Ну конечно! Я совсем забыл про начальную задачу. Что ж, технически нам достаточно наваять скрипт на баше, который будет вызывать rustc на этом файле, но я согласен, что вывод не особо читаемый. Выходит, нам требуется сопоставить каждому элементу списка строковое значение. Для структур Fizz, Buzz и FizzBuzz это делается тривиально через трейт с ассоциированной константой строкового типа, а вот для чисел Пеано это несколько сложнее. Создание на этапе компиляции строки из числа в Rust является почти невыполнимой задачей, так что мы поступим иначе: будем использовать пару из массива байт и длины итоговой строки. Благодаря системе типов Rust мы легко можем объединить обе возможности в одном типе: Как вы могли заметить, этот тип параметризован максимальным размером байтового представления строки. В каком-то смысле это перебор, поскольку мы могли бы указать, скажем, массив из трёх байт, которых бы хватило для представления всех чисел до сотни включительно, но мне не хотелось бы затачиваться на конкретную верхнюю границу, тем более что требуемую длину мы всегда можем подсчитать. Что ж, займёмся переводом в строку и начнём с жужжащих типов: Перейдём к численным типам. Вот тут уже посложнее: для перевода числа в строку нам нужно его численное значение. Значение это, впрочем, получается элементарно: После того, как мы получили на руки численное значение, вывести его строковое представление уже не так сложно: достаточно последовательно брать остатки от деления на 10 и прибавлять их к ASCII-символу нуля — тут мы эксплуатируем тот факт, что в кодировке ASCII коды для цифр идут по порядку. Главное тут — не забыть, что мы таким образом получаем цифры в обратном порядке, поэтому после окончания процесса надо обернуть порядок символов: Отлично. Теперь, получив нужное разложение, мы можем написать реализации AsStr одним махом для всех чисел на уровне типов: И теперь уже несложно отобразить список типов на список строковых значений: А теперь подумаем о том, как получить из Str нативную растовую строку. Для варианта Str::Plain это тривиально, мы просто достаём значение. А что делать с Str::Decomposed? Тоже ничего сложного: обрезаем массив до указанной длины и вызываем std::str::from_utf8. Тут, однако, есть парочка неприятных вещей. Первое: std::str::from_utf8 возвращает Result, с которым надо что-то делать. Второе: и конвертация из байтов в строку, и нарезка слайса делают в рантайме проверки – вообще говоря, ненужные. Мы кладём в массив только ASCII-коды, так что строка заведомо корректна для UTF-8, а возвращаемая длина никогда не превысит длину массива: если это вдруг произойдёт, функция to_str или не скомпилируется, или запаникует, в зависимости от того, была она вызвана в const-контексте или нет. Так что мы можем использовать unsafe для того, чтобы избежать этих проверок… Но поля перечисления Str — публичные, туда можно положить всё, что угодно, в том числе и данные, для которых эти предположения неверны. Чтобы обойти эту проблему, мы сделаем новый тип с приватным полем, для которого оставим два публичных конструктора: обычный для создания из литерала и unsafe для создания из массива байт и длины. Пользователь небезопасного конструктора должен обещать, что предоставляемая длина не превышает длины массива, а сами данные в массиве до указанной длины корректно закодированы в UTF-8. В таком случае мы можем спокойно вызывать небезопасные функции внутри метода, который будет конструировать строку из этого нового типа. Этот новый тип я, пожалуй, назову Str, а старый Str назову StrInner. Вынесем всё это в отдельный модуль, так как границы видимости в Rust проходят именно по ним, — я начал печатать, вынося код в отдельный модуль: Пару минут интервьюер ждал, пока я адаптировал код к новым изменениям. — Ага, уже почти готово. Из крупного нам осталось реализовать обход списка, который у нас в итоге получится. К счастью, тут нет ничего сложного, всё опять определяется индуктивно: И ещё нам всё ещё нужно определить необходимое количество памяти для Str. Ну, тут всё элементарно: Я немного потянулся, разминая затёкшую спину. — Итак, все необходимые части в сборе. Осталось только собрать их вместе: Набрав этот код, я с некоторым колебанием нажал Ctrl+Enter, и через несколько секунд отобразился результат запуска программы: Интервьюер несколько раз перевёл взгляд с экрана на меня и обратно, не проронив ни слова. Я терпеливо молчал. Через несколько секунд он спросил меня с видом, как будто только что что-то вспомнил: — Скажите, а как вы собираетесь всё это тестировать? Я ожидал это вопроса, так что мой ответ не заставил себя ждать: — Что ж, в этом проекте можно выделить две части, которые можно тестировать независимо друг от друга. Одна из них — это перевод числа в декомпозированную строку. К счастью, эту часть можно элементарно покрыть тестами на основе свойств. А вот что касается построения списка, тот тут можно писать тесты этапы компиляции, которые будут проверять, что создаваемый тип равен ожидаемому. Конечно, выписывать вручную все эти типы будет несколько утомительно, но если сюда прикрутить макросы — можно процедурные для красивого синтаксиса, но, в принципе, можно обойтись и декларативными… Интервьюер перебил меня, замахав руками: — Хорошо, хорошо, мы вам верим. Он вышел из кабинета, сославшись на то, что ему нужно переговорить с коллегами, а через пару минут вернулся и сказал мне, что на сегодня интервью окончено и что я могу идти домой. — Мы вам позвоним, — сказал он мне на прощание, пожимая руку. Правда, этого звонка я почему-то жду до сих пор. UPD: полный код для статьи Типострадалец