Имя не гарантирует безопасность. Haskell и типобезопасность

Original author: Alexis King
  • Translation
Разработчики на Haskell много говорят о типобезопасности (type safety). Сообщество Haskell-разработчиков отстаивает идеи «описания инвариант на уровне системы типов» и «исключения недопустимых состояний». Звучит как вдохновляющая цель! Однако не совсем понятно, как ее достичь. Почти год назад я опубликовала статью «Parse, don’t validate» — первый шаг к восполнению этого пробела.

За статьей последовали продуктивные обсуждения, но нам так и не удалось прийти к консенсусу относительно правильного использования конструкции newtype в Haskell. Идея достаточно проста: ключевое слово newtype объявляет wrapper type (тип-обертка), который отличается по имени, но репрезентативно эквивалентен типу, который он обертывает. На первый взгляд, это понятный путь к достижению типобезопасности. Например, рассмотрим, как использовать объявление newtype для определения типа адреса электронной почты:

newtype EmailAddress = EmailAddress Text

Этот прием предоставляет нам определенное значение, а в сочетании с умным конструктором и границей инкапсуляции даже может обеспечить безопасность. Но это совсем другой вид типобезопасности. Он намного слабее и отличается от того, который я выделила год назад. Сам по себе newtype — это просто псевдоним.

Names are not type safety ©

Внутренняя и внешняя безопасность


Чтобы показать разницу между конструктивным data-моделированием (подробнее о нем в предыдущей статье) и обертками newtype, рассмотрим пример. Предположим, нам нужен тип «целое число от 1 до 5 включительно». Естественный подход к конструктивному моделированию — перечисление с пятью случаями:

data OneToFive
  = One
  | Two
  | Three
  | Four
  | Five

Затем мы бы написали несколько функций для конвертации между Int и типом OneToFive:

toOneToFive :: Int -> Maybe OneToFive
toOneToFive 1 = Just One
toOneToFive 2 = Just Two
toOneToFive 3 = Just Three
toOneToFive 4 = Just Four
toOneToFive 5 = Just Five
toOneToFive _ = Nothing

fromOneToFive :: OneToFive -> Int
fromOneToFive One   = 1
fromOneToFive Two   = 2
fromOneToFive Three = 3
fromOneToFive Four  = 4
fromOneToFive Five  = 5

Этого было бы вполне достаточно для достижения заявленной цели, но в реальности с такой технологией работать неудобно. Поскольку мы изобрели совершенно новый тип, мы не можем повторно использовать обычные числовые функции, предоставляемые Haskell. Следовательно, многие разработчики предпочли бы вместо этого использовать обертку newtype (newtype wrapper):

newtype OneToFive = OneToFive Int

Как и в первом случае, мы можем объявить функции toOneToFive и fromOneToFive с идентичными типами:

toOneToFive :: Int -> Maybe OneToFive
toOneToFive n
  | n >= 1 && n <= 5 = Just $ OneToFive n
  | otherwise        = Nothing

fromOneToFive :: OneToFive -> Int
fromOneToFive (OneToFive n) = n

Если мы поместим эти объявления в отдельный модуль и решим не экспортировать конструктор OneToFive, API будут полностью взаимозаменяемы. Кажется, что вариант с newtype более простой и типобезопасный. Однако это не совсем так.

Давайте представим, что мы пишем функцию, которая использует значение OneToFive в качестве аргумента. При конструктивном моделировании, такой функции требуется cопоставление с образцом с каждым из пяти конструкторов. GHC примет определение как достаточное:

ordinal :: OneToFive -> Text
ordinal One   = "first"
ordinal Two   = "second"
ordinal Three = "third"
ordinal Four  = "fourth"
ordinal Five  = "fifth"

С отображением newtype все по-другому. Newtype непрозрачен, поэтому единственный способ наблюдать за ним — это конвертировать обратно в Int. Разумеется, Int может содержать много других значений помимо 1–5, поэтому мы вынуждены добавить образец для остальных возможных значений.

ordinal :: OneToFive -> Text
ordinal n = case fromOneToFive n of
  1 -> "first"
  2 -> "second"
  3 -> "third"
  4 -> "fourth"
  5 -> "fifth"
  _ -> error "impossible: bad OneToFive value"

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

  • Конструктивный тип данных фиксирует свои инварианты таким образом, чтобы они были доступны при дальнейшем взаимодействии. Это освобождает функцию ordinal от обработки недопустимых значений, так как они становятся невыразимы.
  • Обертка newtype предоставляет умный конструктор, который валидирует значение, но логический результат этой проверки используется только для потока управления; он не сохраняется в результате функции. Соответственно мы не можем далее воспользоваться результатом этой проверки и введенными ограничениями, при последующем исполнении мы взаимодействуем с типом Int.

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

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

Это не относится к объявлению newtype, так как у него нет внутренних семантических отличий от Int; его значение указывается внешне через умный конструктор toOneToFive. Любое семантическое различие, предполагаемое newtype, невидимо для системы типов. Разработчик просто держит это в уме.

Повторное посещение непустых списков


Тип данных OneToFive выдуман, но подобные рассуждения применимы и к другим, более реальным сценариям. Рассмотрим NonEmpty, о котором я писал ранее:

data NonEmpty a = a :| [a]

Для наглядности представим версию NonEmpty, объявленную через кnewtype по сравнению с обычными списками. Мы можем использовать обычную стратегию умного конструктора для обеспечения желаемого свойства непустоты значения (non-emptiness):

newtype NonEmpty a = NonEmpty [a]

nonEmpty :: [a] -> Maybe (NonEmpty a)
nonEmpty [] = Nothing
nonEmpty xs = Just $ NonEmpty xs

instance Foldable NonEmpty where
  toList (NonEmpty xs) = xs

Как и в случае с OneToFive, мы быстро обнаружим последствия невозможности сохранить эту информацию в системе типов. Мы хотели использовать NonEmpty для написания безопасной версии head, но для newtype-версии требуется другое утверждение:

head :: NonEmpty a -> a
head xs = case toList xs of
  x:_ -> x
  []  -> error "impossible: empty NonEmpty value"

Это кажется неважным: так мала вероятность, что подобная ситуация может произойти. Но такой довод полностью зависит от веры в правильность модуля, который определяет NonEmpty, в то время как конструктивное определение требует только доверия проверке типов GHC. Поскольку мы по умолчанию считаем, что проверка типов работает правильно, последнее является более убедительным доказательством.

Newtypes как токены


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

Границы абстракции дают newtypes огромное преимущество в обеспечении безопасности. Если конструктор newtype не экспортируется, он становится непрозрачным для других модулей. Модуль, определяющий newtype (то есть «домашний модуль»), может воспользоваться этим, чтобы создать границу доверия (trust boundary), где внутренние инварианты применяются путем ограничения клиентов безопасным API.

Мы можем использовать приведенный выше пример NonEmpty для иллюстрации этой технологии. Пока воздержимся от экспорта конструктора NonEmpty и предоставим операции head и tail. Мы полагаем, что они работают исправно:

module Data.List.NonEmpty.Newtype
  ( NonEmpty
  , cons
  , nonEmpty
  , head
  , tail
  ) where

newtype NonEmpty a = NonEmpty [a]

cons :: a -> [a] -> NonEmpty a
cons x xs = NonEmpty (x:xs)

nonEmpty :: [a] -> Maybe (NonEmpty a)
nonEmpty [] = Nothing
nonEmpty xs = Just $ NonEmpty xs

head :: NonEmpty a -> a
head (NonEmpty (x:_)) = x
head (NonEmpty [])    = error "impossible: empty NonEmpty value"

tail :: NonEmpty a -> [a]
tail (NonEmpty (_:xs)) = xs
tail (NonEmpty [])     = error "impossible: empty NonEmpty value"

Поскольку единственный способ создать или использовать в качестве аргументов значения NonEmpty — это использовать функции в экспортируемом API Data.List.NonEmpty, вышеуказанная реализация не дает клиентам нарушить гарантию непустоты (non-emptiness invariant). Значения непрозрачных newtypes похожи на токены: реализующий модуль выдает токены через свои функции конструктора, и эти токены не имеют внутреннего значения. Единственный способ сделать с ними что-нибудь полезное — это сделать их доступными для функций в использующем их модуле и для получения содержащихся в них значений. В данном случае эти функции: head и tail.

Этот подход менее эффективный, чем использование конструктивного типа данных, так как здесь можно ошибиться и случайно предоставить средство для создания недопустимого значения NonEmpty []. По этой причине newtype-подход к типобезопасности сам по себе не является доказательством того, что желаемый инвариант выполняется.

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

Этот компромисс может быть крайне полезен. Достаточно сложно гарантировать инварианты с помощью конструктивного моделирования данных, поэтому это не всегда практично. Однако нам нужно быть осторожными, чтобы случайно не предоставить механизм, позволяющий нарушить инвариант. Например, разработчик может воспользоваться удобным классом типов GHC, производным от класса типов Generic для NonEmpty:

{-# LANGUAGE DeriveGeneric #-}

import GHC.Generics (Generic)

newtype NonEmpty a = NonEmpty [a]
  deriving (Generic)

Всего лишь одна строка обеспечивает простой механизм для обхода границы абстракции:

ghci> GHC.Generics.to @(NonEmpty ()) (M1 $ M1 $ M1 $ K1 [])
NonEmpty []

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

ghci> read @(NonEmpty ()) "NonEmpty []"
NonEmpty []

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

  • Все инварианты должны быть понятны мейнтейнерам доверенного модуля. Для простых типов, таких как NonEmpty, инвариант очевиден, но для более сложных типов нужны комментарии.
  • Каждое изменение доверенного модуля необходимо проверять, так как оно может ослабить желаемые инварианты.
  • Необходимо воздержаться от добавления небезопасных лазеек, которые могут скомпрометировать инварианты при неправильном использовании.
  • Может потребоваться периодический рефакторинг, чтобы доверенная область оставалась небольшой. Иначе со временем резко увеличится вероятность взаимодействия, которое вызывает нарушение инварианта.

В то же время, типы данных, корректные по своей конструкции, не имеют ни одной из перечисленных проблем. Инвариант не может быть нарушен без изменения определения типа данных, это оказывает влияние на остальную часть программы. Усилия со стороны разработчика не требуется, поскольку проверка типов автоматически применяет инварианты. Для таких типов данных не существует «доверенного кода» (trusted code), поскольку все части программы в равной степени подчиняются ограничениям, установленным типом данных.

В библиотеках целесообразно использовать новую концепцию безопасности (благодаря newtype) через инкапсуляцию, поскольку библиотеки часто предоставляют строительные блоки, используемые для создания более сложных структур данных. Такие библиотеки обычно подвергаются большему изучению и вниманию, чем код приложения, особенно с учетом того, что они меняются гораздо реже.

В коде приложения эти методы по-прежнему полезны, но изменения производственной кодовой базы со временем ослабляют границы инкапсуляции, поэтому, когда это возможно, следует отдавать предпочтение правильности конструкции.

Другие способы использования newtype, злоупотребления и неправильное использование


В предыдущем разделе описаны основные способы использования newtype. Однако на практике newtypes обычно используются по-другому, не так, как мы описали выше. Некоторые из таких применений обоснованы, например:

  • В Haskell идея согласованности классов типов (typeclass) ограничивает каждый тип одним экземпляром любого класса. Для типов, которые допускают более одного полезного экземпляра (instance), newtypes являются традиционным решением и могут успешно использоваться. Например, newtypes Sum и Product из Data.Monoid предоставляют полезные экземпляры Monoid для числовых типов.
  • Аналогичным образом newtypes могут применяться для введения или изменения параметров типа. Newtype Flip из Data.Bifunctor.Flip — простой пример, меняющий аргументы Bifunctor местами, чтобы экземпляр Functor мог работать с обратным порядком аргументов:

newtype Flip p a b = Flip { runFlip :: p b a }

Newtypes необходимы для такого рода манипуляций, поскольку Haskell пока не поддерживает лямбда-выражения на уровне типов.

  • Прозрачные newtypes могут использоваться для предотвращения злоупотребления, когда значение необходимо передать между удаленными частями программы, а у промежуточного кода нет никаких причин проверять значение. Например, строка ByteString, содержащая секретный ключ, может быть обернута в newtype (с исключенным экземпляром Show), чтобы предотвратить код от случайного логирования или иного раскрытия.

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

Слишком часто такая иллюзия безопасности приводит к откровенному злоупотреблению newtype. Например, вот определение из кодовой базы, с которой я лично работаю:

newtype ArgumentName = ArgumentName { unArgumentName :: GraphQL.Name }
  deriving ( Show, Eq, FromJSON, ToJSON, FromJSONKey, ToJSONKey
           , Hashable, ToTxt, Lift, Generic, NFData, Cacheable )

В этом случае newtype — бессмысленный шаг. Функционально он полностью взаимозаменяем с типом Name, настолько, что производит с десяток классов типов! В каждом месте, где используется newtype, он немедленно разворачивается, как только извлекается из закрывающей записи. Так что никакой пользы для типобезопасности в этом случае нет. Более того, непонятно, зачем обозначать newtype как ArgumentName, если имя поля и так проясняет его роль.

Мне кажется, подобное применение newtypes возникает из-за желания использовать систему типов в качестве способа таксономии (классификации) мира. «Argument name» — это более конкретное понятие, чем общее «name», поэтому, конечно, оно должно иметь свой собственный тип. Это утверждение имеет смысл, но, скорее, оно ошибочно: таксономия полезна для документирования интересующей области, но не обязательно полезна для ее моделирования. При программировании мы используем типы для разных целей:

  • В первую очередь, типы выделяют функциональные различия между значениями. Значение типа NonEmpty a функционально отличается от значения типа [a], поскольку оно фундаментально отличается по структуре и допускает дополнительные операции. В этом смысле типы структурны; они описывают, какие значения находятся внутри языка программирования.
  • Во-вторых, иногда мы используем типы, чтобы избежать логических ошибок. Можно использовать отдельные типы Distance и Duration, чтобы случайно не сделать что-то бессмысленное, например сложить их вместе, даже если они оба представлены действительными числами.

Обратите внимание, что обе эти цели прагматичны; они понимают систему типов как инструмент. Это довольно естественное отношение, поскольку система статических типов является инструментом в прямом смысле. Тем не менее такая точка зрения кажется нам необычной, даже несмотря на то, что использование типов для классификации мира обычно создает бесполезный шум вроде ArgumentName.

Вероятно, не очень целесообразно, когда newtype полностью прозрачен, и в него обертывают и развертывают обратно по желанию. В этом конкретном случае я бы полностью исключила различие и использовала Name, но в ситуациях, когда различные метки вносят ясность, всегда можно использовать тип alias:

type ArgumentName = GraphQL.Name

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

Заключение и рекомендованная литература


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

Newtypes — это один из механизмов определения типов-оберток (wrapper types). Такая концепция существует практически в любом языке, даже в тех, которые используют динамическую типизацию. Если вы не пишете Haskell, многое в этой статье, вероятно, актуально и для выбранного вами языка. Можно сказать, что это продолжение одной идеи, которую я пыталась передать по-разному в течение последнего года: системы типов — это инструменты. Нам следует более сознательно и целенаправленно относиться к тому, что на самом деле дают типы и как их эффективно использовать.

Поводом для написания этой статьи послужила недавно опубликованная статья Tagged is not a Newtype. Это отличный пост, и я полностью разделяю его основную идею. Но я подумала, автор упустил возможность озвучить более серьезную мысль. На самом деле, Tagged — это newtype по определению, поэтому заголовок статьи ведет нас по ложному следу. Настоящая проблема лежит немного глубже.

Newtypes приносят пользу при внимательном применении, но безопасность не является свойством, присущим им по умолчанию. Мы же не считаем, что пластик, из которого сделан дорожный конус, обеспечивает безопасность на дороге сам по себе. Важно поместить конус в правильный контекст! Без того же условия newtypes — это просто средство маркировки, способ дать имя.

А имя не гарантирует типобезопасность!
Timeweb
VDS и инфраструктура. По коду Habr10 — 10% скидка

Comments 2

    0
    Разве инкапсуляция в ООП появилась не из тех же самых соображений? Скрываем данные (читай значения небезопасного типа), выставляем наружу интерфейс, в классах контролируем инварианты.
      +1
      Да, все верно! ООП здесь ничему не противоречит.

      Мы так же в классе можем добавить дополнительные проверки для значения и ограничить число возможных инвариант предоставляемых типом. Но есть одно но!

      Если мы произвели некоторую проверку внутри класса и возвращаем тип с большим числом инвариант, то у нас возникает та же самая проблема, что и в Haskell: мы не сохранили информацию об этих проверках в системе типов, поэтому при последующем использовании значения и добавлении новых инвариант, система типов нам ничем не поможет и не сообщит о возможной ошибке.

    Only users with full accounts can post comments. Log in, please.