Почему алгебраические типы данных и трейты круче ООП

21 ноября 2014

Проблема с ООП заключается в том, что этим термином сейчас называют все что угодно. Привязал методы к хэшу в Perl — ООП. Наплодил в Erlang процессов, которые обмениваются сообщениями — ООП. Объявил пару-тройку функций для работы с какой-то структурой, и снова ООП. Никто уже толком не понимает, что именно сей термин означает, но все с умным видом его произносят. Стыдно же не знать!

Дополнение: Кстати, если кого-то интересует вопрос «объявления пары-тройки функций для работы с какой-то структурой», есть такая книжка Object-oriented programming With ANSI C [PDF].

Лично я уже перестал понимать, что такое ООП и вообще стараюсь выкинуть этот термин из своего лексикона. Во-первых, потому что я не понимаю, о чем идет речь. Во-вторых, потому что мой собеседник тоже не понимает, и мы оба не понимаем о чем-то своем. Наконец, в-третьих, никому не нужно ООП, что бы оно ни значило. Все, что вам нужно — это алгебраические типы данных (далее АТД) и тайпклассы.

Примечание: АТД также может означать абстрактный тип данных. Это тоже очень важный и полезный АТД, но другой. Постарайтесь не запутаться!

С обоими понятиями мы хорошо знакомы из опыта программирования на Haskell.

Пример АТД на этом замечательном языке:

data Maybe a = Just a | Nothing

То же самое на Scala выражается несколько более многословно:

sealed trait MyMaybe[+A]
case class MyJust[A](a: A) extends MyMaybe[A]
case object MyNothing extends MyMaybe[Nothing]

Тайпклассы, они же классы типов, они же трейты — это в сущности интерфейсы с возможностью задавать дэфолтную реализацию некоторых методов:

trait Animal {
  def word: String
  def talk() { println(this.word) }
}

Тот же код на Haskell:

class Animal a where
  word :: a -> String
 
  talk :: a -> IO ()
  talk = putStrLn . word

Наши АТД могут реализовать эти интерфейсы:

class Cat extends Animal { def word = "Meow!" }

class Dog extends Animal { def word = "Woof!" }

// ...

val dog = new Dog()
dog.talk

То же самое на Haskell:

data Cat = Cat
data Dog = Dog

instance Animal Cat where word _ = "Meow!"
instance Animal Dog where word _ = "Woof!"

Притом один АТД может реализовать один и тот же интерфейс совершенно разными способами:

class JustACat { def meow = "Meow!" }

class HappyCat extends JustACat with Animal {
  def word = meow + " :)"
}

class SadCat extends JustACat with Animal {
  def word = meow + " :("
}

Что на Haskell переводится так:

data JustACat = JustACat

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 могут быть объявлены в двух совершенно разных библиотеках, написанных разными программистами, а потом объединены каким-то третьим программистом, реализовавшим экземпляр класса типов.

Ну хорошо, скажете вы. А как же, например, без ООП сделать исключения? Их тоже легко получить при помощи трейтов:

trait MyException

case class MyRuntimeException(s: String) extends MyException

val err: MyException = MyRuntimeException("Error!")

err match {
  case _ : MyRuntimeException => println("Catched!")
}

В Haskell, по сути, так и делается. И не нужна программистам эта развесистая иерархия исключений в стиле Java. Однако на практике в языке Scala все-таки приходится вписываться в уже существующую иерархию:

case class MyExt(s: String) extends Exception

// ...

try {
  throw MyExt("ololo!")
} catch {
  case err: MyExt => println(err.s)
}

Вот по большому счету и все, что нужно знать об АТД и классах типов. Если вы загляните на Hackage, то обнаружите, что с их помощью на практике можно реализовать что угодно. А зачем нам дополнительная сложность? Фактически, мы можем избавиться от иерархии классов (интерфейсы не считаются). В этом случае нам не нужно никакой там ковариации и контрвариации, восходящего и нисходящего преобразования, абстрактных классов, protected методов и так далее (за редкими исключениями, в основном связанными с наследием Java, см определение MyMaybe выше). В общем, все плоско, как в Haskell.

Недаром даже в книжках по Java в последние годы рекомендуется использовать делегирование вместо наследования, а если вы и вынуждены использовать наследование, то не использовать более двух уровней иерархии. Недаром в современных языках программирования, взять хотя бы Rust и Go, нет никакого наследования. Потому что наследование нужно только для кривой эмуляции АТД и трейтов. Во всех остальных случаях оно создает только лишнюю сложность и приводит к созданию запутанного, очень сложного в поддержке кода. А теперь попробуйте угадать, что мы получим, если оставить в ООП только инкапсуляцию и полиморфизм.

Короче говоря, если в своем коде вы пишите protected или abstract class, скорее всего, вы делаете что-то ну очень не так.

Забудьте об ООП. Используйте АТД и тайпклассы.

Метки: , , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.