Почему алгебраические типы данных и трейты круче ООП
21 ноября 2014
Проблема с ООП заключается в том, что этим термином сейчас называют все что угодно. Привязал методы к хэшу в Perl — ООП. Наплодил в Erlang процессов, которые обмениваются сообщениями — ООП. Объявил пару-тройку функций для работы с какой-то структурой, и снова ООП. Никто уже толком не понимает, что именно сей термин означает, но все с умным видом его произносят. Стыдно же не знать!
Дополнение: Кстати, если кого-то интересует вопрос «объявления пары-тройки функций для работы с какой-то структурой», есть такая книжка Object-oriented programming With ANSI C [PDF].
Лично я уже перестал понимать, что такое ООП и вообще стараюсь выкинуть этот термин из своего лексикона. Во-первых, потому что я не понимаю, о чем идет речь. Во-вторых, потому что мой собеседник тоже не понимает, и мы оба не понимаем о чем-то своем. Наконец, в-третьих, никому не нужно ООП, что бы оно ни значило. Все, что вам нужно — это алгебраические типы данных (далее АТД) и тайпклассы.
Примечание: АТД также может означать абстрактный тип данных. Это тоже очень важный и полезный АТД, но другой. Постарайтесь не запутаться!
С обоими понятиями мы хорошо знакомы из опыта программирования на Haskell.
Пример АТД на этом замечательном языке:
То же самое на Scala выражается несколько более многословно:
case class MyJust[A](a: A) extends MyMaybe[A]
case object MyNothing extends MyMaybe[Nothing]
Тайпклассы, они же классы типов, они же трейты — это в сущности интерфейсы с возможностью задавать дэфолтную реализацию некоторых методов:
def word: String
def talk() { println(this.word) }
}
Тот же код на Haskell:
word :: a -> String
talk :: a -> IO ()
talk = putStrLn . word
Наши АТД могут реализовать эти интерфейсы:
class Dog extends Animal { def word = "Woof!" }
// ...
val dog = new Dog()
dog.talk
То же самое на Haskell:
data Dog = Dog
instance Animal Cat where word _ = "Meow!"
instance Animal Dog where word _ = "Woof!"
Притом один АТД может реализовать один и тот же интерфейс совершенно разными способами:
class HappyCat extends JustACat with Animal {
def word = meow + " :)"
}
class SadCat extends JustACat with Animal {
def word = meow + " :("
}
Что на Haskell переводится так:
meow JustACat = "Meow!"
newtype HappyCat = HappyCat JustACat
newtype SadCat = SadCat JustACat
instance Animal HappyCat where word (HappyCat x) = meow x ++ " :)"
instance Animal SadCat where word (SadCat x) = meow x ++ " :("
Что интересно, тип JustACat и интерфейс Animal могут быть объявлены в двух совершенно разных библиотеках, написанных разными программистами, а потом объединены каким-то третьим программистом, реализовавшим экземпляр класса типов.
Ну хорошо, скажете вы. А как же, например, без ООП сделать исключения? Их тоже легко получить при помощи трейтов:
case class MyRuntimeException(s: String) extends MyException
val err: MyException = MyRuntimeException("Error!")
err match {
case _ : MyRuntimeException => println("Catched!")
}
В Haskell, по сути, так и делается. И не нужна программистам эта развесистая иерархия исключений в стиле Java. Однако на практике в языке Scala все-таки приходится вписываться в уже существующую иерархию:
// ...
try {
throw MyExt("ololo!")
} catch {
case err: MyExt => println(err.s)
}
Вот по большому счету и все, что нужно знать об АТД и классах типов. Если вы загляните на Hackage, то обнаружите, что с их помощью на практике можно реализовать что угодно. А зачем нам дополнительная сложность? Фактически, мы можем избавиться от иерархии классов (интерфейсы не считаются). В этом случае нам не нужно никакой там ковариации и контрвариации, восходящего и нисходящего преобразования, абстрактных классов, protected методов и так далее (за редкими исключениями, в основном связанными с наследием Java, см определение MyMaybe выше). В общем, все плоско, как в Haskell.
Недаром даже в книжках по Java в последние годы рекомендуется использовать делегирование вместо наследования, а если вы и вынуждены использовать наследование, то не использовать более двух уровней иерархии. Недаром в современных языках программирования, взять хотя бы Rust и Go, нет никакого наследования. Потому что наследование нужно только для кривой эмуляции АТД и трейтов. Во всех остальных случаях оно создает только лишнюю сложность и приводит к созданию запутанного, очень сложного в поддержке кода. А теперь попробуйте угадать, что мы получим, если оставить в ООП только инкапсуляцию и полиморфизм.
Короче говоря, если в своем коде вы пишите protected
или abstract class
, скорее всего, вы делаете что-то ну очень не так.
Забудьте об ООП. Используйте АТД и тайпклассы.
Метки: Haskell, Scala, Функциональное программирование.
Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.