Note
Access to this page requires authorization. You can try signing in or changing directories.
Access to this page requires authorization. You can try changing directories.
Главный недостаток алгебраических типов явным образом следует из их главного преимущества перед классами в ООП. В прошлой заметке мы обсуждали тот факт, что алгебраические типы являются "закрытыми", и вы не можете добавить новый конструктор, не изменив само объявление алгебраического типа, что позволяет еще на этапе компиляции получить полную информацию о том, какие конструкторы включает наш алгебраический тип. Это позволяет нам избавиться от лишней косвенности в виде виртуальных функций, и в целом делает наш код более предсказуемым, жестко просчитываемым еще на этапе компиляции. Но и недостатки подобного подхода очевидны.
Помните наш пример с компанией, которая умеет продавать только мобильные телефоны и ноутбуки? Ну ради бога, разве у реальной компании будут такие нелепые ограничения? Скорее наоборот - сегодня мы продаем телефоны и ноутбуки, а завтра - предметы женского гардероба. Очевидно, что алгебраические типы в таком случае окажутся не слишком полезны.
На самом деле проблема невозможности расширения алгебраических типов не дает покоя лучшим умам уже много лет. Ей даже придумали название - 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-тип это просто более "специализированная" версия "функционального" варианта.