В 2019 году была написана потрясающая статья Parse, don’t validate. Я крайне рекомендую изучить её всем программистам (а также недавнее дополнение к ней Names are not type safety). Её основная идея заключается в том, что существует два способа проверки валидности входящих данных функции:
- Валидатор проверяет входящие данные на правильность и в случае их неправильности выдаёт ошибку. Он ничего не возвращает. Например, он может проверять, не пуст ли список.
- Парсер делает то же самое, что и валидатор, но возвращает более конкретное представление входящих данных, обеспечивающее соответствие требуемого свойства. Например, он проверяет, не пуст ли список, и возвращает тип NonEmptyList.
Главное утверждение, сделанное в этой статье — что парсеры предпочтительнее, чем валидаторы. Её основной посыл — нужно сделать недопустимые состояния непредставимыми (unrepresentable). В статье это реализовано с помощью использования системы типов. Я полностью согласен с такой философией, но хотел бы выделить и более подробно обсудить один из ироничных аспектов аргументации:
Инструмент контроля типов является хрестоматийным примером валидатора!
Ведь в конечном итоге инструмент контроля типов получает на входе уже подвергнутое парсингу представление программы и «бракует» его, если не удаётся выполнить контроль типов. Он не возвращает более конкретного представления программы. (Не стоит путать это с выводом типа, который возвращает больше информации, но только касательно типов).
Какой же может быть альтернатива инструменту контроля типов в виде парсера для языка программирования?
Одним из решений может стать создание более конкретной грамматики, устраняющей возможность недопустимых состояний. Например, если определённая функция требует ввода числа в интервале от 1 до 5, то вместо такого обобщённого синтаксиса вызова функций:
fun_call ::= fun_name ‘(‘ integer ‘)’
можно определить для функций конкретные правила грамматики:
one_to_five ::= 1 | 2 | 3 | 4 | 5
fun_call ::= ‘lil_fac(‘ one_to_five ‘)’
| ... other function definitions ...
Придумывание таких конкретных грамматик — один из способов создания предметно-ориентированных языков (domain-specific languages, DSL). И в самом деле, DSL являются отличным способом обеспечения непредставимости недопустимых состояний. Разумеется, это решение не масштабируется, если вы хотите создать язык общего назначения с пользовательскими функциями.
Альтернативой создания очень конкретной грамматики является повышение уровня абстракции для усложнения возникновения недопустимых состояний. Например, частым источником программных ошибок является индексация вне границ массива. Такая ситуация возникает, потому что язык программирования предоставляет разработчику только примитивную операцию индексирования: a[x]
. Здесь x
является integer, но его значение может выйти за границы, что приведёт к исключению или вылету программы (если вам повезёт). Один из способов предотвращения такой ситуации заключается в определении более конкретного типа «числа integer от нуля до 12», чтобы система типов отклоняла все потенциально недопустимые операции индексирования, а затем находила более точный тип для каждого массива — мы снова встретились с валидацией.
Ещё одно решение — можно заметить, что обычно массивы используются лишь ограниченным количеством способов. Например, очень часто производится итерация по массиву с выполнением каких-то вычислений. Вместо того, чтобы заставлять каждого программиста вручную писать для этого циклы for (Массивы в этом языке начинаются с 0 или с 1? Они заканчиваются на array.length or array.length – 1? Индексы массива имеют определённый тип?), мы можем создать общую операцию fold (редукции). Аналогично, вместо того, чтобы заставлять кодеров писать собственные реализации хэш-таблиц, можно предоставить встроенную в язык реализацию. Предоставляя разработчикам более качественные абстракции, вы снижаете вероятность возникновения недопустимых состояний.
Можно пойти ещё дальше и устранить наиболее примитивные операции, обеспечив доступ только к абстракциям более высокого уровня. Надеюсь, что в наше время большинство программистов уже согласно с тем, что устранение оператора goto
в пользу более высокоуровневых структурированных абстракций программирования было большой победой. То же самое может быть истинно и для других низкоуровневых конструкций: null
, обычных указателей памяти и т.д.
В мире компьютерной безопасности у подобных обсуждений есть непосредственный аналог. Модель защиты большинства компьютерных систем позволяет обеспечивать разделение между операциями, которые я могу (пытаюсь) выполнить и операциями, которые мне разрешено выполнять: я могу попытаться удалить чей-то веб-сайт, однако этот запрос будет отклонён как неавторизованный (по крайней мере, на это стоит надеяться). Для сравнения, в системах на основе моделей возможностей объекта допустимость вызова таких операций зависит от того, есть ли у меня возможность (которую невозможно подделать), предоставляющая мне разрешение на их выполнение. В таких системах нельзя даже попробовать выполнить операцию, которую мне не разрешено выполнять. Например, в REST API, использующем мандатные URI я не могу даже отправить запрос DELETE для /users/alice, а должен вместо этого отправлять его на какой-то случайный неподбираемый URI — если у меня нет этого URI, то я даже не могу начать отправлять запрос. Следовательно, цель безопасности на основе возможностей объекта заключается в непредставимости неавторизованных состояний.
Вероятно, наиболее известным воплощением парадигмы возможностей объекта является язык программирования E: объектно-ориентированный язык с динамической типизацией. Хотя он уже почти забыт, это потрясающий язык со множеством отличных идей. Для обеспечения безопасности E использует строгие границы абстракций. Предупреждаю: веб-сайт E — это кроличья нора невероятной глубины!
В молодости я много программировал на Tcl. Большинство читателей согласится, что этот язык максимально далёк от современного языка программирования со статической типизацией (часто об этом говоря с искренним отвращением). Тем не менее, мысль о реализации непредставимости недопустимых состояний была совершенно естественной для меня и других программистов на Tcl. Я часто начинал проект с создания интерпретатора с чистого листа, избавляясь от всех встроенных конструкций языка (циклов, процедур, даже арифметики), а затем добавляя тщательно отобранное множество высокоуровневых предметно-ориентированных примитивов. Этот DSL потом становился конфигурационным файлом приложения, защищённого тем, что недопустимые конфигурации выразить невозможно.
Подведу итог: моё мнение заключается не в том, что системы типов плохи или представленное по ссылке эссе имеет недостатки, я считаю «Parse, don’t validate» превосходным материалом. Но за последние два десятилетия произошёл такой прогресс систем типов и использования полнотиповых шаблонов программирования, что возникла опасность восприятия систем типов как единственного способа достижения корректности при создании ПО. Разумеется, это чрезвычайно мощный инструмент, но столь же мощными могут быть и более простые техники абстрагирования и сокрытия информации. Задача обеспечения непредставимости недопустимых значений должна быть одной из основных целей при проектировании ПО, но для её реализации существует много возможных способов.
На правах рекламы
Эпичные серверы для разработчиков и не только! Дешёвые VDS на базе новейших процессоров AMD EPYC и хранилища на основе NVMe дисков от Intel для размещения проектов любой сложности, от корпоративных сетей и игровых проектов до лендингов и VPN. Вы можете создать собственную конфигурацию сервера в пару кликов!
Автор: Mikhail