Священные войны в интернете о системах типов по-прежнему страдают от распространенного мифа о том, что динамические системы типов по своей природе лучше подходят для моделирования предметных областей «открытого мира». Обычно аргумент звучит так: цель статической типизации состоит в том, чтобы как можно более точно зафиксировать все сущности, однако в реальном мире это просто неудобно. Реальные системы должны быть слабо связаны и должны как можно меньше быть завязаны на представление данных, поэтому динамическая типизация приводит к более устойчивой системе в целом.
Эта история звучит убедительно, однако это неправда. Ошибка в таких рассуждениях заключается в том, что статические типы не предназначены для «классификации мира» или определения структуры каждого значения в системе. Реальность такова, что статические системы типов позволяют точно указать, сколько именно компонент должен знать о структуре его входных данных, и, наоборот, сколько он не знает. На практике статические системы типов превосходно обрабатывают данные с частично известной структурой, равно как и имеют возможность сделать так, чтобы логика приложения случайно не предполагала слишком многого о данных.
Две лжи о типах
Я давно уже хотел написать статью на эту тему в блоге, но последним толчком для этого решения были дезинформирующие комментарии в ответ на мою предыдущую статью. В частности, два комментария особенно привлекли мое внимание, первый из которых был опубликован на /r/programming:
Решительно не согласен с постом […], который продвигает совершенно запутанный и статичный взгляд на мир. Предполагается, что мы можем или должны теоретически установить, что именно является «допустимым» вводом на границе между программой и миром, таким образом привнося ощущение сильной связности на всю систему, и тогда несоответствие какой-либо схеме автоматически приводит к сбою в работе программы.
Здесь это рекламируется как полезное свойство, но представьте, если бы интернет работал бы таким образом. Сервер поменял свой JSON ответ, и нам нужно теперь перекомпилировать и перепрограммировать весь интернет. Это статическое представление, которое продвигается как полезная вещь. […] «Менталитет парсинга» является принципиально жестким и глобальным, в то время как отказоустойчивая система должна проектироваться как децентрализованная и предоставлять интерпретацию данных получателю.
Учитывая аргумент, приведенный в той статье о том, что вы должны использовать по возможности более точные типы, можно увидеть, откуда исходит эта ошибочная интерпретация. Дескать, как прокси-сервер может быть написан в таком стиле, что он не может предвидеть структуру данных, проходящих через него? Вывод комментатора заключается в том, что строгая статическая типизация противоречит программам, которые заранее не знают структуру своих входных данных.
Второй комментарий был оставлен на Hacker News и он значительно короче первого:
Какой будет сигнатура, скажем, питоновского
pickle.load()
?
Это другой тип аргумента, основанный на том факте, что типы операций рефлексии могут зависеть от значений во время выполнения, что затрудняет их описание статическими типами. Этот аргумент предполагает, что статические типы ограничивают выразительность, потому что они прямо запрещают такие операции.
Оба эти аргумента ошибочны, но чтобы показать почему, мы должны сделать явным одно неявное утверждение. В двух комментариях основное внимание уделяется иллюстрации того, как статические системы типов не могут описывать данные неизвестной формы, но они одновременно выдвигают следующую неявное предположение: языки с динамической типизацией могут обрабатывать данные неизвестной формы. Как мы увидим, это предположение ошибочно; программы не способны обрабатывать данные действительно неизвестной формы независимо от типизации, а статические системы типов только делают уже существующие предположения явными.
Вы не можете обработать то, что вы не знаете
Утверждение простое: в статической системе типов вы должны заранее объявить схему данных, но в динамической системе типов такой тип может быть, ну, в-общем, динамическим! Это звучит как само собой разумеющееся настолько, что Рич Хикки практически построил свою карьеру оратора на эмоциональной привлекательности данного тезиса. Единственная проблема в том, что он не верен.
Гипотетический сценарий обычно выглядит следующим образом. Допустим, у вас есть распределенная система и сервисы в системе генерируют события, которые могут использоваться любыми другими сервисами. Каждое событие сопровождается полезной нагрузкой (payload), которую слушающие сервисы могут использовать для дальнейших действий. Сама эта полезная нагрузка представляет собой минимально структурированные данные без схемы, закодированные с помощью какого-либо достаточно общего формата данных, например JSON или EDN.
В качестве простого примера, сервис входа в систему может генерировать примерно такое событие всякий раз, когда регистрируется новый пользователь:
{
"event_type": "signup",
"timestamp": "2020-01-19T05:37:09Z",
"data": {
"user": {
"id": 42,
"name": "Alyssa",
"email": "alyssa@example.com"
}
}
}
Некоторые зависимые сервисы могут прослушивать такие signup
-события и предпринимать дальнейшие действия при получении событий. Например, почтовый сервис может отправлять приветственное письмо при регистрации нового пользователя. Если бы сервис был написан на JavaScript, обработчик события мог бы выглядеть примерно так:
const handleEvent = ({ event_type, data }) => {
switch (event_type) {
case 'login':
/* ... */
break
case 'signup':
sendEmail(data.user.email, `Welcome to Blockchain Emporium, ${data.user.name}!`)
break
}
}
Но что, если этот сервис будет написан на Haskell? Будучи прилежными, ожидающими недоброе от реального мира программистами на Haskell, которые парсят, а не валидируют, мы можем написать такой код:
data Event = Login LoginPayload | Signup SignupPayload
data LoginPayload = LoginPayload { userId :: Int }
data SignupPayload = SignupPayload
{ userId :: Int
, userName :: Text
, userEmail :: Text
}
instance FromJSON Event where
parseJSON = withObject "Event" obj -> do
eventType <- obj .: "event_type"
case eventType of
"login" -> Login <$> (obj .: "data")
"signup" -> Signup <$> (obj .: "signup")
_ -> fail $ "unknown event_type: " <> eventType
instance FromJSON LoginPayload where { ... }
instance FromJSON SignupPayload where { ... }
handleEvent :: JSON.Value -> IO ()
handleEvent payload = case fromJSON payload of
Success (Login LoginPayload { userId }) -> {- ... -}
Success (Signup SignupPayload { userName, userEmail }) ->
sendEmail userEmail $ "Welcome to Blockchain Emporium, " <> userName <> "!"
Error message -> fail $ "could not parse event: " <> message
Этот фрагмент несомненно более многословен, хотя ожидать некоторых дополнительных определений типов вполне естественно (и, да, они выглядят сильно преувеличенно в таких крошечных примерах). Однако обсуждаемые нами аргументы в любом случае не относятся к размеру кода. Настоящая проблема с этой версией кода, согласно предыдущему комментарию на Reddit, заключается в том, что код на Haskell должен быть обновлён всякий раз, когда сервис входа добавляет новый тип события! Новый конструктор типа должен быть добавлен к типу данных Event, и для него должна быть определена новая логика парсинга. А что будет, когда новые поля будут добавлены в полезную нагрузку? Какой кошмар для поддержки.
Для сравнения, код JavaScript гораздо более разрешающий. Если добавлен новый тип события, он просто провалится через switch
и ничего не сделает. Если к полезной нагрузке добавляются дополнительные поля, код JavaScript будет просто игнорировать их. Похоже, выигрыш для динамической типизации налицо.
За исключением того, что нет, это не так. Если мы не обновляем тип Event
, то единственная причина сбоя статически типизированной программы заключается в том, что мы именно так и написали функцию handleEvent
. Мы могли бы просто сделать то же самое в коде JavaScript, добавив случай по умолчанию, который отвергает неизвестные типы событий:
const handleEvent = ({ event_type, data }) => {
switch (event_type) {
/* ... */
default:
throw new Error(`unknown event_type: ${event_type}`)
}
}
Мы этого не делали, так как в этом случае это было бы явно глупо. Если сервис получает событие, о котором он не знает, он должен просто игнорировать его. Это тот случай, когда допустимость является скорее всего правильным поведением, мы можем легко реализовать это и в коде на Haskell:
handleEvent :: JSON.Value -> IO ()
handleEvent payload = case fromJSON payload of
{- ... -}
Error _ -> pure ()
Этот подход по-прежнему соответствует духу «парсить, не валидировать», потому что мы парсим интересующие нас данные и стараемся это делать как можно раньше, дабы не попасть в ловушку двойной валидации. Ни в каком из путей исполнения мы не зависим от правильности значения без того, чтобы сначала убедиться (с помощью системы типов) что оно на самом деле правильное. Мы не вынуждены реагировать на неправильные данные, выдавая ошибку! Мы просто должны явно сказать о том, что плохие данные мы игнорируем.
Данный пример иллюстрирует важный момент: тип Event
в этом коде на Haskell не описывает «все возможные события», он описывает такие события, о которых заботится приложение. Аналогично код, который анализирует полезную нагрузку этих событий, беспокоится только о полях нужных приложению и игнорирует ненужные. Статические типы не требуют, чтобы вы охотно писали схему для всей Вселенной, они только требуют от вас заблаговременно определить то, что вам нужно.
Получается, что такое требование дает много приятных преимуществ, не смотря на то, что знания о входных данных ограничены:
-
Легко обнаружить предположения в программе на Haskell, просто взглянув на определения типов. Мы знаем, например, что это приложение не заботится о поле
timestamp
, так как оно никогда не появляется ни в одном из типов для полезной нагрузки. В программе с динамической типизацией нам нужно было бы проверять каждый путь исполнения, чтобы увидеть, использует ли код это поле, а это, в свою очередь, такая работа в которой весьма легко ошибиться! -
Более того, оказывается, что код Haskell на самом деле не использует поле
userId
в типеSignupPayload
, поэтому этот тип слишком консервативен. Если мы хотим убедиться, что оно действительно не нужно (поскольку, возможно, мы постепенно избавляемся от появленияuserId
в этой полезной нагрузке), нам нужно только удалить это поле записи; если проверка типов проходит, ура, мы можем быть уверены, что код действительно не зависит от этого поля. -
Наконец, мы аккуратно избегаем всех ошибок, связанных с парсингом наугад (shotgun parsing), упомянутых в предыдущей статье блога, поскольку мы до сих пор не поступились ни одним из описанных в той статье принципов.
Итак, мы уже свели на нет первую половину заявления, что, дескать, языки со статической типизацией не могут работать с данными, структура которых не полностью известна. Давайте теперь посмотрим на другую половину, в которой говорится, что динамически типизированные языки могут обрабатывать данные с вообще неизвестной структурой. Возможно, это ещё звучит убедительно, но если вы на секунду остановитесь и тщательно подумаете об этом, вы обнаружите, что это, мягко говоря, не совсем так.
Приведенный выше код JavaScript делает все те же предположения, что и наш код на Haskell: он предполагает, что полезные нагрузки событий являются объектами JSON с полем event_type
и для событий «типа» signup
включают в себя поля data.user.name
и data.user.email
. Он не может сделать ничего полезного с действительно неизвестными входными данными! Если добавляется новый тип события, наш код JavaScript не может магически адаптироваться к нему лишь потому, что он динамически типизирован. Динамическая типизация просто означает, что типы значений переносятся вместе с ними на время выполнения и проверяются по мере выполнения программы; типы все еще там, и эта программа все еще неявно полагается на то, что обрабатываемые значения являются какими-то конкретными.
Сохранение непрозрачности данных
В предыдущем разделе мы развенчивали миф о том, что статически типизированные системы не могут обрабатывать частично известные данные, но пристально взглянув, вы могли заметить, что первоначальное утверждение опровергнуто не полностью.
Несмотря на возможность обрабатывать неизвестные данные, мы всегда просто отбрасывали их, что не сработало бы, если бы мы попытались реализовать какую-то прокси-функцию. Предположим, например, что у нас есть сервис пересылки. Он транслирует события через общедоступную сеть, прикрепляя цифровую подпись к полезной нагрузке чтобы избежать подмены данных. Мы могли бы реализовать это в JavaScript следующим образом:
const handleEvent = (payload) => {
const signedPayload = { ...payload, signature: signature(payload) }
retransmitEvent(signedPayload)
}
В этом случае мы вообще не заботимся о конкретной структуре полезной нагрузки (функция signature работает с любым допустимым объектом JSON), однако нам нужно сохранить эту структуру, добавив только поле. Как мы могли бы сделать это на языке со статической типизацией, ведь там требуется точно описать тип для полезной нагрузки?
Опять же, ответ отвергает эту предпосылку: нет никакой необходимости указывать тип данных более детальный, чем требуется приложению. Та же самая логика может быть непосредственно написана на Haskell:
handleEvent :: JSON.Value -> IO ()
handleEvent (Object payload) = do
let signedPayload = Map.insert "signature" (signature payload) payload
retransmitEvent signedPayload
handleEvent payload = fail $ "event payload was not an object " <> show payload
В данном случае, поскольку нас не волнует структура полезной нагрузки, мы напрямую манипулируем значением типа JSON.Value
. Этот тип является существенно менее точным по сравнению с нашим типом Event
представленным ранее — он может содержать любое допустимое значение JSON любой формы, но в этом случае мы хотим, чтобы он был неточным.
Благодаря этой неточности система типов помогла нам и здесь: она уловила факт предположения о том, что полезная нагрузка является JSON-объектом (то есть набором пар ключ-значение), а не каким-либо другим вариантом значения JSON, и заставила нас явно обрабатывать не-объектные случаи. В данном случае мы решили выдать ошибку, но, как и прежде, вы можете выбрать другую форму реакции на этот случай, если хотите. Вы просто должны явно сообщить об этом.
Еще раз отметим, что то предположение, которое мы были вынуждены сделать явным в коде на Haskell, также сделано и кодом на JavaScript! Если бы наша функция JavaScript handleEvent
была вызвана со строкой (а она тоже является JSON значением), а не с объектом, весьма маловероятно, что поведение будет желательным, поскольку построение объекта по строке через spread-оператор …
приводит к следующему сюрпризу:
> { ..."payload", signature: "sig" }
{0: "p", 1: "a", 2: "y", 3: "l", 4: "o", 5: "a", 6: "d", signature: "sig"}
Ой, явно не то. Еще раз, стиль обработки данных через парсинг сильно помог нам. Если бы мы не «парсили» значение JSON в объект путем явного сопоставления с образцом Object
, наш код бы не скомпилировался. А если бы мы не обработали случай не-объекта, мы бы получили предупреждение о том, что сопоставление с образцом неисчерпывающее.
Давайте рассмотрим еще один пример подобного механизма, прежде чем двигаться дальше. Предположим, мы используем API, который возвращает идентификаторы пользователей, и предположим, что эти идентификаторы являются UUID
. Прямая интерпретация «парсить, не валидировать» может указывать на то, что мы представляем идентификаторы пользователей в нашем клиенте API Haskell с использованием типа UUID
:
type UserId = UUID
Тем не менее, наш комментатор Reddit, вероятно, будет гнобить нас за это! Если в спецификации на API явно не указано, что все идентификаторы пользователей будут UUID, то это делая такое предположение, мы выходим за определённые границы. Хотя идентификаторы пользователей могут быть UUID сегодня, возможно, они не будут таковыми завтра, и тогда наш код сломается без причины! Это же вина статической типизации, не так ли?
Опять же, ответ — нет. Это случай неправильного моделирования данных, но статическая система типов не виновата — её просто неправильно использовали. На самом деле, наиболее подходящий способ представления UserId
в данном случае — это определить новый непрозрачный тип:
newtype UserId = UserId Text
deriving (Eq, FromJSON, ToJSON)
В отличие от определённого выше псевдонима типа, который просто создаёт новое имя для существующего типа UUID
, новое объявление создает совершенно новый тип UserId
, который отличается от всех других типов, включая Text
. Если мы оставим конструктор типа данных закрытым (то есть не будем экспортировать модуля, который определяет этот тип), то единственный способ создать UserId
— это распарсить его с помощью FromJSON
. Продолжая дальше, единственное, что вы можете сделать с UserId
— это сравнить его с другими UserId
для равенства или сериализовать его с помощью ToJSON
. Больше ничего не разрешено: система типов не позволит вам зависеть от внутреннего представления идентификаторов пользователей в чужом сервисе.
Это иллюстрирует другой способ, которым системы статического типа могут обеспечить надежные, полезные гарантии при манипулировании полностью непрозрачными данными. Для среды исполнения UserId
является просто строкой, но система типов не позволит вам случайно использовать такие значения как строку и не позволяет подделать новый UserId
из произвольной строки.
Технически, вы можете злоупотребить классом типов FromJSON
для преобразования произвольной строки в UserId
, но это будет не так прямолинейно, как кажется, так как fromJSON
может отвергнуть входные данные. Это означает, что вам каким-то образом придётся справиться с этим случаем отказа. Поэтому данный трюк вряд ли продвинет вас слишком далеко, если вы уже не находитесь в том контексте, где вы выполняете парсинг ввода… но в таком случае было бы легче просто выполнить преобразование уже разобранного ввода в UserId
. Так что система типов не мешает вам делать все возможное, чтобы выстрелить себе в ногу, но она направляет вас к правильному решению (и да, не существует никакой технологии, которая могла бы гарантированно защитить программистов от превращения их собственной жизни в кошмар, если они намерены это сделать).
Система типов — это не кандалы, заставляющие вас описывать представление каждого входящего и выходящего из вашей программы значения в самых подробных деталях. Скорее, это инструмент, силу которого вы можете регулировать так, чтобы он наилучшим образом соответствовал вашим потребностям.
Рефлексия не является чем-то особенным
Итак, мы полностью опровергли утверждение, сделанное первым комментатором, но вопрос, заданный вторым комментатором, может всё ещё казаться лазейкой в нашей логике. Каков тип pickle.load()
из библиотеки языка Python? Для тех, кто не знаком, эта библиотека с любопытным названием позволяет сериализовать и десериализовать целые графы объектов Python. Любой объект может быть сериализован и сохранен в файле с помощью pickle.dump()
, а затем загружен из файла и десериализован с помощью pickle.load()
.
Что делает сложным для нашей статической системы типов, так это тип значения, создаваемого pickle.load()
, трудно предсказать — он полностью зависит от того, что было записано в этот файл с помощью pickle.dump()
. Кажется, что это в самой своей сути динамический тип, поскольку мы не можем знать тип этого значения на момент компиляции. На первый взгляд, это как раз то, что динамическая типизация может осуществить, а вот статическая принципиально не может.
Однако оказывается, что эта ситуация фактически идентична предыдущим примерам с использованием JSON, и тот факт, что библиотека pickle из Python сериализует нативные объекты Python напрямую, ничего не меняет. Почему? Давайте рассмотрим, что происходит после того, как программа вызывает pickle.load()
. Допустим, вы пишете следующую функцию:
def load_value(f):
val = pickle.load(f)
# сделать что-нибудь с `val`
Проблема в том, что теперь val
может быть любого типа, и так же, как вы не можете делать ничего полезного с действительно неизвестным, неструктурированным вводом, вы не можете ничего сделать со значением, если не знаете хотя бы что-нибудь о нём. Если вы вызываете какой-либо метод или обращаетесь к какому-либо полю в результате, то вы уже сделали предположение о том, что же на самом деле pickle.load(f)
вернёт и, оказывается, что эти предположения относятся к типу val
!
Например, представьте, что единственное, что вы делаете с val
это вызываете val.foo()
и возвращаете результат вызова, который ожидается будет строкой. Если бы мы писали Java, то ожидаемый тип val
был бы довольно простым — мы бы ожидали, что он будет экземпляром следующего интерфейса:
interface Foo extends Serializable {
String foo();
}
И действительно оказывается, что функции pickle.load()
можно дать совершенно разумный тип в Java:
static <T extends Serializable> Optional<T> load(InputStream in, Class<? extends T> cls);
Зануды конечно будут придираться к тому, что это не то же самое, что pickle.load()
, так как вы должны передать токен Class<T>
чтобы заблаговременно выбрать тип. Однако ничто не мешает вам передавать Serializable.class
и преобразовывать к нужному типу позже, после загрузки объекта. И это ключевой момент: в тот момент, когда вы делаете с объектом хоть что-то, вы должны знать что-то о его типе, даже на динамически типизированном языке! Язык со статической типизацией просто заставляет вас быть более явным, как это делалось, когда мы говорили о полезных нагрузках в JSON-сообщениях.
Можем ли мы сделать подобную типизацию и в Haskell? Абсолютно точно — мы можем использовать библиотеку serialise, которая имеет API, аналогичный такому, как в Java, который упомянут выше. Этот API имеет интерфейс, очень похожий на библиотеку Haskell для работы с JSON, aeson, поскольку, как оказывается, проблема работы с неизвестными данными JSON не сильно отличается от работы с любым неизвестным значением в Haskell — в какой-то момент вы должны выполнить какой-то разбор, чтобы сделать с полученным значением что-нибудь.
Тем не менее, если действительно хотите, вы можете эмулировать динамическую типизацию pickle.load()
, откладывая проверку типов до последнего возможного момента. Хотя реальность такова, что это практически никогда не бывает полезным. В какой-то момент вы вынуждены сделать предположения о структуре значения, чтобы использовать его, и вы знаете, что это за предположения, потому что вы написали этот код. Хотя есть крайне редкие исключения из этого, которые требуют истинной динамической загрузки кода (например, реализации REPL для вашего языка программирования), они не встречаются в повседневном программировании, и программисты на статически типизированных языках могут совершенно свободно описывать такие предположения в типах.
Это одно из фундаментальных разногласий между лагерем статической типизации и лагерем динамической типизации. Программисты, работающие на статически типизированных языках, недоумевают, когда некоторые программисты утверждают, что мол они могут сделать что-то на динамически типизированном языке, что «принципиально» запрещает статически типизированный язык, поскольку программист на статически типизированном языке может ответить, что значению просто не был дан достаточно точный тип. С точки зрения программиста, работающего на языке с динамической типизацией, система типов ограничивает пространство допустимого поведения, но с точки зрения программиста, работающего на языке со статической типизацией, набор допустимых поведений является типом значения.
На самом деле ни одна из этих точек зрения не является до конца точной. Системы статических типов действительно накладывают ограничения на структуру программы, так как невозможно отрицать все плохие программы на языке, полном по Тьюрингу, не отвергая при этом и некоторые хорошие (это теорема Райса). Но одновременно верно и то, что невозможность решения общей проблемы не препятствует решению несколько более ограниченной версии проблемы, и многие из так называемых «фундаментальных» ограничений статических систем типов совсем не фундаментальны.
Приложение: реальность, скрытая за мифами
Ключевой тезис этой статьи уже сделан: статические системы типов не являются принципиально хуже динамических систем типов при обработке данных с открытой или частично известной структурой. Виды утверждений, высказанных в комментариях, приведенных в начале этой статьи, недостаточно точно отражают понимание того, как конструируются статически типизированные программы, они не до конца понимают ограничения статической типизации и в то же время преувеличивая возможности динамической типизации.
Однако, хотя они сильно преувеличены, эти мифы действительно имеют основание в реальности. По-видимому, они частично возникли из-за недопонимания различий между структурной и номинативной типизацией (nominal typing). Эта разница, к сожалению, слишком велика, чтобы ее можно было обсудить в этой статье, поскольку она сама по себе тянет на несколько статей. Около полугода назад я попытался написать статью на эту тему, но потом я нашёл её доводы не очень убедительными и выбросил её. Надеюсь, однажды я найду лучший способ донести эти идеи в будущем.
Хотя я не могу дать полную трактовку, которой этот вопрос заслуживает сейчас, я все же хотел бы кратко остановиться на нём, чтобы заинтересованные читатели могли поискать другие ресурсы по этой теме, если захотят. Основная идея заключается в том, что во многих динамически типизированных языках считается идиоматичным повторно использовать простые структуры данных, такие как хэш-таблицы, для представления того, что в языках со статической типизацией часто представлено пользовательскими типами данных (обычно определяемыми как классы или структуры).
Эти два стиля способствуют очень различным стилям программирования. Программа на JavaScript или Clojure может представлять запись в виде хэш-таблицы из строковых или символьных ключей в значения, написанных с использованием объектных или хэш-литералов и управляемых с помощью обычных функций из стандартной библиотеки, которые обрабатывают ключи и значения универсальным образом. Это позволяет легко взять две записи и объединить их поля или сделать произвольный (или даже динамический) выбор полей из существующей записи.
Напротив, большинство статических систем типов не допускают такого манипулирования записями в произвольной форме, поскольку записи не являются хэш-таблицами вообще, а являются уникальными типами, отличными от всех других типов. Эти типы однозначно идентифицируются по их (полностью определенному) имени, отсюда и термин номинативная типизация (nominal typing). Если вы хотите выбрать часть полей структуры, вы должны определить совершенно новую структуру; это часто создает взрыв неуклюжего рутинного кода (boilerplate).
Это одна из главных мыслей, обсуждаемых Ричем Хикки во многих своих выступлениях, в которых критикуется статическая типизация. Он выдвинул идею, что эта способность играючи объединять, разделять и преобразовывать записи делает динамическую типизацию особенно подходящей для области распределенных, открытых систем. К сожалению, эта риторика имеет два существенных пробела:
-
Он слишком близок к тому, чтобы называть это фундаментальным ограничением систем типов, предполагая, что не просто неудобно, но и якобы невозможно моделировать такие системы в номинативной статической системе типов. Это не только не соответствует действительности (как продемонстрировано в данной статье), но и дезориентирует людей с точки зрения действительно ценного качества: практического, прагматического преимущества более структурированного подхода к моделированию данных.
-
Он путает отличие между структурным и номинативным с отличием между динамическим и статическим, создавая ошибочное представление того, что лёгкое объединение и разделение записей представленных в виде пар ключ-значение возможно только в динамически типизированном языке. Собственно, является фактом не только то, что языки со статической типизацией поддерживают структурную типизацию, но и многие языки с динамической типизацией также поддерживают номинативную типизацию. Исторически эти оси имеют некоторую корреляцию, но теоретически они ортогональны.
Для контрпримера к этим утверждениям рассмотрим классы Python, которые являются вполне номинативными, несмотря на то, что они динамические, и интерфейсы TypeScript, которые являются структурными, хотя и определяются статически. Действительно, современные статически типизированные языки все чаще получают встроенную поддержку структурно типизированных записей. В этих системах типы записей работают подобно хэш-таблицам в Clojure — они не являются отдельными именованными типами, а представляют собой анонимные коллекции пар ключ-значение — и они поддерживают многие из тех же выразительных манипуляций, что и хэш-таблицы Clojure, и всё в статически-типизированных рамках.
Если вы заинтересованы в изучении систем статических типов с сильной поддержкой структурной типизации, я бы порекомендовал взглянуть на любой из следующих языков: TypeScript, Flow, PureScript, Elm, OCaml или Reason, каждый из которых имеет некоторую поддержку структурно типизированных записей. Чего бы я не рекомендовал для этой цели — так это Haskell, который имеет ужасную поддержку структурной типизации; Haskell (по разным причинам, выходящим за рамки этой статьи) агрессивно номинативен.
Я считаю, что это самый существенный недостаток Haskell на момент написания этой статьи.
Означает ли это, что Haskell плох, или что его практически невозможно использовать для решения подобных проблем? Нет, конечно нет; есть много способов смоделировать эти решения в Haskell, и работают они достаточно хорошо, хотя некоторые из них страдают от значительного количества рутинного кода. Основной тезис этой статьи относится как к Haskell, так и к любому из других языков, упомянутых выше. Тем не менее, было бы упущением не упоминать об этой особенности Haskell, поскольку это может дать приверженцам динамической типизации, которые исторически находили статически типизированные языки гораздо более раздражающими, более ясное понимание настоящей причины таких ощущений. (В принципе, все основные статически типизированные ООП языки ещё более номинативны, чем Haskell!)
В качестве заключительной мысли: эта статья не предназначена для того, чтобы начать священную войну, равно как и не предназначена, чтобы атаковать динамическую типизацию. Существует много решений в динамически типизированных языках, которые действительно трудно перевести в статически типизированный контекст, и я думаю, что как раз обсуждение этих решений может быть продуктивным. Цель этой статьи — объяснить, почему одно конкретное рассуждение является тупиковым, поэтому, пожалуйста, прекратите приводить эти аргументы. Есть гораздо более плодотворные способы вести диалог о типизации.
Автор: Nick Linker