Алгебраические типы. Часть 3. Полиморфные варианты
Главный недостаток алгебраических типов явным образом следует из их главного преимущества перед классами в ООП. В прошлой заметке мы обсуждали тот факт, что алгебраические типы являются "закрытыми", и вы не можете добавить новый конструктор, не изменив само объявление алгебраического типа, что позволяет еще на этапе компиляции получить полную информацию о том, какие конструкторы включает наш алгебраический тип. Это позволяет нам избавиться от лишней косвенности в виде виртуальных функций, и в целом делает наш код более предсказуемым, жестко просчитываемым еще на этапе компиляции. Но и недостатки подобного подхода очевидны.
Помните наш пример с компанией, которая умеет продавать только мобильные телефоны и ноутбуки? Ну ради бога, разве у реальной компании будут такие нелепые ограничения? Скорее наоборот - сегодня мы продаем телефоны и ноутбуки, а завтра - предметы женского гардероба. Очевидно, что алгебраические типы в таком случае окажутся не слишком полезны.
На самом деле проблема невозможности расширения алгебраических типов не дает покоя лучшим умам уже много лет. Ей даже придумали название - expression problem. Ну раз у проблемы даже есть название, то должно быть и какое-нибудь решение - ведь не может быть такого, что "лучшие умы" за столько лет так ничего и не придумали? И решение действительно есть.
Начнем как обычно издалека.
Есть такой язык программирования OCaml, дальний (впрочем, не такой уж и дальний) родственник F#. (Скажу по секрету - F# первоначально фактически и представлял собой версию OCaml под платформу .NET, но впоследствии их пути немного разошлись). OCaml, как и подобает функциональному языку из семейства ML, поддерживает "классические" алгебраические типы. Но в какой-то момент в OCaml появился и другой тип данных - с интригующим названием полиморфный вариант.
Что же это такое? Давайте посмотрим на примере. Попробуем переписать код из предыдущей заметки на OCaml с использованием этих самых полиморфных вариантов:
let l = `Laptop 14.2;;
let res = match l with
| `Laptop _ -> "We have a laptop"
| `Cellphone _ -> "We have a cellphone";;
На первый взгляд все очень похоже на F#, если не считать дополнительных "знаков препинания". Но погодите, а мы точно ничего не забыли? Где же объявление нашего алгебраического типа? (Ну или, как его, полиморфного варианта?). А в том-то и дело, что никакого объявления нет.
Отвечая на вопрос "каким образом сделать алгебраические типы расширяемыми", OCaml приходит к довольно-таки неожиданному решению. А давайте представим, что во всей нашей программе - да что там "программе", во всем мире! - есть лишь один-единственный алгебраический тип. Этот тип включает все возможные конструкторы - даже те, которые вам только предстоит придумать. По этой причине нет никакой необходимости заранее декларировать алгебраический тип - с полиморфными вариантами он как бы объявляется на ходу.
Возвращаясь к нашему примеру - что теперь нужно сделать, если мы захотим добавить к числу реализуемых товаров нашей фирмы еще и мониторы? Да практически ничего. Просто считайте, что мониторы у нас уже есть:
let res = match l with
| `Laptop _ -> "We have a laptop"
| `Cellphone _ -> "We have a cellphone"
| `Monitor _ -> "We have a monitor";;
Однако в вышеприведенном коде есть одна серьезная проблема. В случае с закрытыми алгебраическими типами нам всегда точно известно, какие конструкторы включает в себя тот или иной алгебраический тип. Соответственно, предыдущая версия кода по анализу продукта (написанная на F#) была полностью корректна и безопасна - ведь нам же известно, что компания не продает ничего, кроме ноутбуков и мобильных телефонов, более того, сам компилятор гарантирует нам это!
Здесь же ситуация в корне изменяется. Перечислить все конструкторы мы попросту не можем, а соответственно, код, приведенный выше, уже не так безопасен как раньше. Что будет если кто-нибудь вызовет его с вариантом `Tablet? Будет ошибка времени исполнения. Чтобы избежать этого нам придется переделать этот код так:
let res = match l with
| `Laptop _ -> "We have a laptop"
| `Cellphone _ -> "We have a cellphone"
| `Monitor _ -> "We have a monitor"
| _ -> "We have an unknown product";;
Думаю, что смысл изменений должен быть понятен, даже если вы не знакомы с ML-подобным синтаксисом. Фактически мы просто добавили специальный паттерн, который будет срабатывать во всех случаях, когда вместо монитора, ноутбука или телефона нам передают что-либо другое. Проблема в том, что теперь нам придется писать так всегда - ну или попросту смириться с тем, что неосторожное обращение с вариантами может привести к неожиданным ошибкам во время исполнения.
Вторая проблема явно проистекает из того факта, что варианты не требуют предварительного объявления. А раз объявление необязательно, то компилятор никак не сможет сам разобраться, "вводите" ли вы новый полиморфный вариант или же обращаетесь к уже "объявленному" ранее. А это приводит к таким вот ошибкам (совсем не свойственным статически-типизированным языкам):
let l = `Monitor 24.0
let res = match l with
| `Laptop _ -> "We have a laptop"
| `Cellphone _ -> "We have a cellphone"
| `Monitol _ -> "We have a monitor"
| _ -> "We have an unknown product";;
Как видите, при написании конструкции match я допустил опечатку в слове Monitor, и компилятор никак не сможет мне тут помочь. Код будет скомпилирован успешно и приведет к ошибочному поведению в ран-тайме.
Очевидно, что полиморфные варианты так же не кажутся панацеей. Если проблема классических алгебраических типов заключается в невозможности расширения, то полиморфные варианты как раз напротив - чрезмерно расширяемые, без возможности как-то проконтролировать и ограничить эту их расширяемость.
Есть такое мнение, что язык с динамической типизацией - это на самом деле язык со статической типизацией, в котором есть всего лишь один тип. Надо сказать, что данная позиция не лишена основания. И что мы имеем с полиморфными вариантами? Фактически и получается, что везде, где мы их используем, мы работаем с одним и тем же типом, что сводит все преимущества статической типизации на нет. Получается весьма резкий переход от алгебраических типов, которые в известном смысле куда более статическим типизированы, чем классы в ООП, к "без пяти минут динамике" под видом полиморфных вариантов. При этом стоит заметить, что OCaml, язык, в котором дебютировала концепция полиморфных вариантов, - это очень строгий, если можно так выразиться, статически-типизированный язык, в котором даже используются разные арифметические операторы для целых и вещественных чисел. Очевидно, что, хотя полиморфные варианты и решают вышеозначенную проблему "закрытости" алгебраических типов, в такой язык как OCaml они не очень хорошо вписываются.
А что если бы у нас был динамически-типизированный язык?
Ela, о которой я уже упоминал ранее, так же поддерживает концепцию вариантов и при этом является динамически-типизированным языком. Вышеприведенный код выглядел бы на Ela следующим образом:
let l = Monitor 24.0
let res = match l with
Laptop = "We have a laptop"
Cellphone = "We have a cellphone"
Monitor = "We have a monitor"
_ = "We have an unknown product"
Весьма похоже, правда? Но есть и пара отличий. Например, в Ela не используется апостроф перед названием конструктора полиморфного варианта - в нем просто нет нужды, так как нет необходимости отличать конструкторы алгебраических типов от конструкторов полиморфных вариантов. Классические алгебраические типы Ela не поддерживает - да они и невозможны в рамках динамической типизации. Поэтому в Ela используется весьма простое, традиционное для функциональных языков соглашение - любой идентификатор, начинающийся с заглавной буквы, считается вариантом.
Фактически вариант в Ela - это очень простая концепция. У вас попросту есть возможность прикрепить произвольную метку к любому значению (или даже создать одиночную метку, без связанного значения). Данная метка впоследствии может быть проанализирована с помощью паттерн-матчинга. Так как Ela динамически-типизированный язык, и у нас и так в каком-то смысле есть лишь один-единственный тип, концепция полиморфных вариантов не приносит в язык "лишней" динамики.
Простейший пример использования вариантов мог бы выглядеть так:
let getOdd x | r > 0 = Odd r
| else = Even
where r = x % 2
Данная функция проверяет, является ли переданное в нее число четным и возвращает результат, используя вариант. Здесь несложно усмотреть некоторые параллели с nullable-типами в C#. И действительно - nullable-тип это просто более "специализированная" версия "функционального" варианта.