#scala #traits #дизайн_языка #теория_типов
Много раз натыкался на термин «фантомный тип», особенно в контексте обсуждения traits
в языке Scala.
Что это такое? При чём здесь traits?
Ответы
Ответ 1
Заранее прошу извинить за длину ответа, просто иначе понять, что такое фантомные
типы и как они используются, будет трудно, а тема и правда интересная.
Предположим, "Роскосмос" попросил написать нас систему управления запуском ракет.
"Ракета должна быть заправлена топливом и кислородом, и только после этого можно запускать",
- лаконично констатирует техническое задание. Не долго думая, мы пишем следующий код
на языке Scala (в функциональном стиле, не в объектно-ориентированном, почему - будет
видно впоследствии):
object UnsafeRocketModule {
case class Rocket private[UnsafeRocketModule] (hasFuel: Boolean, hasO2: Boolean)
def createRocket() = Rocket(false, false)
def addFuel(r: Rocket) = Rocket(true, r.hasO2)
def addO2(r: Rocket) = Rocket(r.hasFuel, true)
def launch(r: Rocket) =
if (!r.hasFuel || !r.hasO2)
throw new Exception("Попытка запустить неподготовленную ракету!")
else
println("3-2-1... Пуск!")
}
Модуль UnsafeRocketModule определяет тип Rocket, функцию-конструктор ракет (т.к.
конструктор типа Rocket не экспортируется модулем в целях поддержания инкапсуляции),
а также функции заправки ракеты топливом, кислородом, и функцию запуска ракеты. Мы
также отслеживаем состояние ракеты (заправленность топливом и кислородом) и генерируем
исключение при попытке запустить неподготовленную ракету. Загрузим этот модуль в Scala
REPL, а затем попробуем ввести определение функции, которая подготавливает ракету (заправляет
топливом и кислородом), и запускает ее:
scala> def prepareAndLaunchRocket() {
| import UnsafeRocketModule._
| launch(addFuel(createRocket()))
| }
prepareAndLaunchRocket: ()Unit
Все скомпилировалось без ошибок, можно работать:
scala> prepareAndLaunchRocket()
java.lang.Exception: Попытка запустить неподготовленную ракету!
at UnsafeRocketModule$.launch(:13)
at $anonfun$prepareAndLaunchRocket$2.apply(:8)
at $anonfun$prepareAndLaunchRocket$2.apply(:8)
... пропущено ...
Вот незадача: в тексте функции prepareAndLaunchRocket мы заправили ракету топливом,
но забыли заправить кислородом! Эта типичная для программ ошибка: попытка выполнить
операцию над объектом, который находится в неподходящем для этого состоянии. Этот класс
ошибок выявляется во время выполнения программы (run time), и очень часто, уже в процессе
эксплуатации. Но мы ведь не хотим, чтобы из-за нашей программы взрывались ракеты, только
потому, что в коде мы забыли подготовить ее должным образом, а из-за нехватки времени
и внимания написали тесты, не покрывающие этот случай, что не позволило выявить проблему
до сдачи в эксплуатацию. Поэтому, исправив функцию prepareAndLaunchRocket, мы понимаем,
что пришло время что-то кардинально менять. А именно: мы должны придумать способ гарантировать,
что перед запуском (то есть, вызовом функции launch()) ракета будет подготовлена должным
образом, то есть будут вызваны обе функции addFuel И addO2. Такая гарантия означает,
что попытка запуска неподготовленной ракеты должна теперь выявляться во время компиляции
(compile time) программы! Иными словами, компилятор не должен нам позволить скомпилировать
ошибочное определение prepareAndLaunchRocket, приведенное выше. А это означает, что
это определение не должно пройти проверку типов. То есть, мы должны расширить систему
типов, принятую в нашем языке программирования. С этого момента начинается магия.
Первое, что мы делаем, это переписываем определение типа Rocket следующим образом:
object SafeRocketModule {
case class Rocket[Fuel, O2] private[SafeRocketModule] ()
// ...
}
Что мы поменяли? Тип Rocket приобрел два параметра типа (Fuel и O2), а атрибуты hasFuel
и hasO2 были изъяты. Мы перенесли отслеживание состояния заправленности ракеты топливом
и кислородом из системы времени выполнения в систему типов, то есть в систему времени
компиляции. То, что отслеживалось атрибутами класса отныне отслеживается параметрами
этого типа. Теперь добавим следующие вспомогательные типы:
sealed trait NoFuel
sealed trait HasFuel
sealed trait NoO2
sealed trait HasO2
Эти типы будут использоваться нами как значения времени компиляции вместо значений
времени выполнения (true и false для атрибутов hasFuel и hasO2) для индикации состояния
заправленности ракеты. Это типы-маркеры, существующие только для подстройки системы
типов, они не имеют атрибутов, а их значения (new NoFuel { ... }) нами никогда не будут
использованы. Такие типы называются фантомными. А traits являются удобным механизмом
их определения. С их помощью мы можем переписать оставшиеся функции модуля:
def createRocket() = Rocket[NoFuel, NoO2]()
def addFuel[O2](r: Rocket[NoFuel,O2]) = Rocket[HasFuel,O2]()
def addO2[Fuel](r: Rocket[Fuel,NoO2]) = Rocket[Fuel,HasO2]()
def launch(r: Rocket[HasFuel,HasO2]) = println("3-2-1... Пуск!")
Обратим внимание: потребности в проверке заправленности ракеты функцией launch()
больше нет. Система типов гарантирует, что launch() будет вызвана только для корректно
подготовленной ракеты. Кроме того, становится понятно, почему нам следует использовать
функциональный стиль, а не объектно-ориентированный. В последнем случае объект Rocket
был бы создан единственный раз и функции addFuel, addO2 и launch вызывались бы впоследствии
как его методы. Нам же необходимо менять тип ракеты при вызове соответствующих операций,
и создавать значения этих типов с нуля, что и предполагает функциональный стиль. Загрузим
модуль SafeRocketModule в Scala REPL, а затем снова попробуем ввести ошибочное определение
функции prepareAndLaunchRocket:
scala> def prepareAndLaunchRocket() {
| import SafeRocketModule._
| launch(addFuel(createRocket()))
| }
:8: error: type mismatch;
found : SafeRocketModule.Rocket[SafeRocketModule.NoFuel,SafeRocketModule.NoO2]
required: SafeRocketModule.Rocket[SafeRocketModule.NoFuel,SafeRocketModule.HasO2]
launch(addFuel(createRocket()))
^
Компилятор не дал ошибочному определению попасть в код программы из-за ошибки во
время проверки типов. Исправим определение функции:
scala> def prepareAndLaunchRocket() {
| import SafeRocketModule._
| launch(addFuel(addO2(createRocket())))
| }
prepareAndLaunchRocket: ()Unit
scala> prepareAndLaunchRocket()
3-2-1... Пуск!
Компиляция прошла успешно, пробный запуск осуществлен без ошибок. Теперь в эксплуатацию
попадет гарантированно корректная версия этой функции, даже при отсутствии тестов -
верификацию выполнила за нас система типов. Наша новая реализация имеет также и другие
преимущества, по сравнению со старой: расширенная нами система типов гарантирует, что
ракета будет заправлена топливом и кислородом только один раз, не допуская повторных
вызовов функций заправки для ракеты, которая уже была заправлена:
scala> addFuel(addFuel(createRocket))
:10: error: type mismatch;
found : SafeRocketModule.Rocket[SafeRocketModule.HasFuel,SafeRocketModule.NoO2]
required: SafeRocketModule.Rocket[SafeRocketModule.NoFuel,?]
addFuel(addFuel(createRocket))
^
scala> addO2(addO2(createRocket))
:10: error: type mismatch;
found : SafeRocketModule.Rocket[SafeRocketModule.NoFuel,SafeRocketModule.HasO2]
required: SafeRocketModule.Rocket[?,SafeRocketModule.NoO2]
addO2(addO2(createRocket))
^
Рассмотренный способ применения фантомных типов часто используется при проектировании
публичных интерфейсов (API) в таких языках, как Haskell, особенно для сложных интерфейсов.
В других языках, с менее развитой системой типов, эти приемы не используются вовсе.
К примеру, в Java, насколько мне известно, все это не работает "из коробки", и надо
делать уродливую "мумбу-юмбу", чтобы получить похожий результат. Также следует заметить,
что это не единственная область применения фантомных типов. Более подробно о последних
и о возможных областях применения можно почитать здесь (все источники англоязычные):
Phantom Types In Haskell and Scala. Пример с ракетами был адаптирован из этой статьи,
где параллельно приводится версия на Haskell.
Phantom type (HaskellWiki)
A Foundation for Embedded Languages by Morten Rhiger - основательная академическая
публикация о фантомных типах и их применениях в Haskell