Алгебраические типы. Часть 2. ООП как путь к динамической типизации

Возможно, вам доводилось слышать такое утверждение о языке программирования Хаскелл - Хаскелл это полностью статически типизированный язык. Строго говоря, это, конечно же, не совсем так, но сейчас речь не об этом. Согласитесь, что это утверждение в каком-то плане интригует? Нам как бы намекают, что есть простые языки со статической типизацией (вроде C#), а есть такие особенные как Хаскелл, которые "полностью" статически типизированы.

Так что же означает эта полная статическая типизация?

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

 public abstract class Foo
{
  void DoSomething();
}

public void CallFoo(Foo foo)
{
   foo.DoSomething();
}

Что мы можем сказать об этом коде? (Ну кроме того, что он демонстрирует фундаментальный механизм, на основе которого строится полиморфизм в ООП-языках). Вернее, давайте зададим вопрос иначе - может ли данный код быть статически типизирован?

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

И тут-то начинается самое интересное. Ведь стоит немного задуматься, как понимаешь, что мы в действительности понятия не имеем, экземпляр какого конкретного типа может быть передан в Foo. Этот тип может быть реализован вообще спустя несколько лет после того, как вы напишите CallFoo. Все, что нам известно об этом типе, это то, что он должен быть наследником класса Foo - причем необязательно прямым, сойдет и "дальний родственник" - как говорится, седьмая вода на киселе.

Следствием всего этого является один простой факт - компилятор попросту не знает, какой конкретно метод под названием DoSomething будет вызван. Собственно, в нашем примере этот метод вообще не реализован. А определять, какая именно реализация DoSomething должна быть вызвана внутри CallFoo, будет уже среда исполнения. Проще говоря, конкретный тип аргумента foo станет нам известен только в рантайме. Так, секундочку, а чем там статическая типизация отличается от динамической?

Фактически, в объектно-ориентированном языке (таком как C#), при написании кода с использованием полиморфизма классов, компилятору доступа лишь часть информации о типе - известен общий тип, но не известен частный.

Что же должно происходить в языке с "полной" статической типизацией?

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

Следствие этой особенности достаточно очевидно. При использовании алгебраических типов конкретный тип всегда известен полностью, компилятор всегда знает, какая именно реализация DoSomething будет вызвана в тот или иной момент. А отсюда происходит и утверждение о "полной" статической типизации.

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

Алгебраические типы напрямую поддерживаются многими функциональными языками. Помимо Хаскелла, здесь можно упомянуть так же и F#. Вот как наш пример из предыдущей заметки - с ноутбуками и мобильными телефонами - может быть переписан на F#:

 type Product = 
    | Laptop of double 
    | Cellphone of bool
    
let l = Laptop 14.2

let res = match l with
          | Laptop _    -> "We have a laptop"
          | Cellphone _ -> "We have a cellphone"

Как видите, кода нам пришлось написать куда меньше. Да и выглядит он куда проще, чем имитация алгебраического типа на C#, даже если вы не знакомы с ML-подобным синтаксисом.

На примере F# так же очень удобно показать "закрытость" алгебраических типов. Как видите, в примере выше есть код, который проверяет экземпляр алгебраического типа и присваивает переменной res значение, в зависимости от того, с каким экземпляром мы имеем дело. Давайте попробуем добавить новую продукцию к типу Product, при этом код для обработки оставим без изменений:

 type Product = 
    | Laptop of double 
    | Cellphone of bool
    | Monitor of bool

А теперь попробуем скомпилировать нашу программу:

 warning FS0025: Incomplete pattern matches on this 
expression. For example, the value 'Monitor (_)' may 
indicate a case not covered by the pattern(s).

Как видите, заботливый комплиятор F# тут же предупреждает нас, что имеющийся код для обработки неполон и не учитывает наличие продукции Monitor. Удобно, правда? И к тому же весьма сильно отличается от того, к чему мы привыкли в объектно-ориентированных языках.

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