Condividi tramite


Алгебраические типы. Часть 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. Удобно, правда? И к тому же весьма сильно отличается от того, к чему мы привыкли в объектно-ориентированных языках.

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

Comments

  • Anonymous
    May 27, 2011
    >>>Основное из них заключается в том, что алгебраический тип по сути представляет собой закрытую одноуровневую иерархию наследования - вы не можете "дописать" новый конструктор к существующему алгебраическому типу "из вне" - как вы бесспорно можете отнаследоваться от класса, переопределить его методы и при этом не менять уже написанный код, который с этим классом работает. >>>Следствие этой особенности достаточно очевидно. При использовании алгебраических типов конкретный тип всегда известен полностью, компилятор всегда знает, какая именно реализация DoSomething будет вызвана в тот или иной момент. Что то не очевиден мне этот момент. Допустим я определю в  АлгТД Product виртуальный метод GetName() , а в Laptop и Cellphone переопределю этот метод. Как теперь компилер может определить какой конкретно метод вызовется??

  • Anonymous
    May 29, 2011
    Виртуальный метод - это немного из другой оперы. Пожалуй, конечно, стоило об этом явно упомнуть. В функциональном языке - том же Хаскелл - никаких виртуальных методов нет. Более того нет "методов" вообще. Единственное, что вы можете написать - это "свободную" функцию, типом параметра которой явно указан Product. И для нее будет статический диспатч. В гибридных языках, вроде Немерле, это не совсем так. Ну так на то они и гибридные. В Немерле вообще достаточно "широкая" трактовка алгебраического типа.

  • Anonymous
    May 29, 2011
    Давайте я даже так скажу - если вам в компайл-тайм точно известно кол-во наследников Product, то вам не нужен динамический диспатч и виртуальные методы, чтобы добиться нужного вам эффекта. Если в каких-то языках виртуальные таблицы используются совместно с алгебраическими типами - то это лишь артефакты ООП в этих языках, не более.

  • Anonymous
    May 30, 2011
    >> Давайте я даже так скажу Можно просто сказать: нет виртуальных методов - компилятор может сказать какой конкретно метод будет вызван. Хотя в случае хаскеля (и любого другого функ языка) - это тоже не верно. Компилятор хаскела не знает, какой конкретно код будет вызван при вызове fold или любой другой функции высшего порядка. Собственно любой метод объекта с вирт методами и ФВП - это практически одно и тоже. Просто в первом случае указатели на функции приходят неявно, а в случае ФВП в явном виде.

  • Anonymous
    May 30, 2011
    Это вы сейчас начали о структурной типизации, через которую и работают ФВП. Она к данной теме не относится. Компилятор Хаскеля вообще-то знает, какая функция будет вызвана, когда вы вызываете fold, - в противном случае как бы типы выводились и перегрузка через тайп-классы работала - а вот передаваемая в fold функция рассматривается как структурный тип. Что однако никак не отменяет того, что аналог ООП кода на алгебраических типах будет полностью статически типизирован.