← На главную

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

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

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