Алгебраические типы. Часть 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-тип это просто более "специализированная" версия "функционального" варианта.