Объектно-ориентированное программирование на Хаскеле
Я пытаюсь получить представление об объектно-ориентированном программировании в стиле Haskell, зная, что все будет немного по-другому из-за отсутствия изменчивости. Я играл с типами классов, но мое понимание их ограничено ими как интерфейсами. Итак, я написал пример C++, который представляет собой стандартный бриллиант с чистой основой и виртуальным наследованием. Bat
наследуется Flying
а также Mammal
и оба Flying
а также Mammal
унаследовать Animal
,
#include <iostream>
class Animal
{
public:
virtual std::string transport() const = 0;
virtual std::string type() const = 0;
std::string describe() const;
};
std::string Animal::describe() const
{ return "I am a " + this->transport() + " " + this->type(); }
class Flying : virtual public Animal
{
public:
virtual std::string transport() const;
};
std::string Flying::transport() const { return "Flying"; }
class Mammal : virtual public Animal
{
public:
virtual std::string type() const;
};
std::string Mammal::type() const { return "Mammal"; }
class Bat : public Flying, public Mammal {};
int main() {
Bat b;
std::cout << b.describe() << std::endl;
return 0;
}
В основном меня интересует, как перевести такую структуру на Haskell, в основном это позволило бы мне иметь список Animal
с, как я мог бы иметь массив (умных) указателей на Animal
в C++.
4 ответа
Вы просто не хотите этого делать, даже не начинайте. ОО, безусловно, имеет свои достоинства, но "классические примеры", такие как ваш C++, почти всегда являются надуманными структурами, предназначенными для того, чтобы вбить парадигму в мозг студентов, чтобы они не начали жаловаться на то, насколько глупы языки, которые они должны использовать †,
Похоже, идея в основном моделирует "объекты реального мира" объектами на вашем языке программирования. Это может быть хорошим подходом для реальных задач программирования, но это имеет смысл, только если вы можете провести аналогию между тем, как вы используете объект реального мира, и тем, как объекты OO обрабатываются внутри программы.
Что просто смешно для таких животных примеров. Во всяком случае, методы должны быть такими, как "корм", "молоко", "убой"... но "транспорт" - это неправильное название, я бы взял это на самом деле, чтобы переместить животное, что скорее было бы методом окружающей среды, в которой живет животное, и в основном имеет смысл только как часть модели посетителя.
describe
, type
и что ты называешь transport
с другой стороны, намного проще. В основном это константы, зависящие от типа, или простые чистые функции. Только OO Паранойя ратифицирует сделать их классовыми методами.
Любая вещь в духе этого животного, где в основном есть только данные, становится намного проще, если вы не пытаетесь принудительно заставить это сделать что-то похожее на OO, а просто остаетесь с (полезно типизированными) данными в Haskell.
Так как этот пример, очевидно, не продвигает нас дальше, давайте рассмотрим кое-что, где ООП имеет смысл. Наборы виджетов приходят на ум. Что-то вроде
class Widget;
class Container : public Widget {
std::vector<std::unique_ptr<Widget>> children;
public:
// getters ...
};
class Paned : public Container { public:
Rectangle childBoundaries(int) const;
};
class ReEquipable : public Container { public:
void pushNewChild(std::unique_ptr<Widget>&&);
void popChild(int);
};
class HJuxtaposition: public Paned, public ReEquipable { ... };
Почему ОО имеет здесь смысл? Во-первых, это позволяет нам легко хранить гетерогенную коллекцию виджетов. На самом деле это не так просто сделать в Haskell, но перед тем, как попробовать, вы можете спросить себя, действительно ли вам это нужно. Для некоторых контейнеров, возможно, не очень желательно разрешать это, в конце концов. В Haskell параметрический полиморфизм очень хорош в использовании. Для любого данного типа виджета мы наблюдаем функциональность Container
в значительной степени сводится к простому списку. Так почему бы просто не использовать список там, где вам требуется Container
?
Конечно, в этом примере вы, вероятно, обнаружите, что вам нужны гетерогенные контейнеры; самый прямой способ получить их {-# LANGUAGE ExistentialQuantification #-}
:
data GenericWidget = GenericWidget { forall w . Widget w => getGenericWidget :: w }
В этом случае Widget
будет классом типа (может быть довольно буквальный перевод абстрактного класса Widget
). В Хаскеле это скорее последнее прибежище, но может быть и здесь.
Paned
это больше интерфейс. Мы могли бы использовать другой класс типов здесь, в основном транслитерируя класс C++:
class Paned c where
childBoundaries :: c -> Int -> Maybe Rectangle
ReEquipable
сложнее, потому что его методы на самом деле мутируют контейнер. Это очевидно проблематично в Haskell. Но, опять же, вы можете обнаружить, что в этом нет необходимости: если вы подставили Container
Класс за обычными списками, вы можете сделать обновления как чисто функциональные обновления.
Хотя, вероятно, это было бы слишком неэффективно для поставленной задачи. Полное обсуждение способов эффективного выполнения изменяемых обновлений было бы слишком много для целей этого ответа, но такие способы существуют, например, использование lenses
,
Резюме
ОО не слишком хорошо переводится на Хаскелл. Нет простого родового изоморфизма, только несколько приближений, среди которых выбор требует опыта. Как можно чаще вам следует избегать подхода к проблеме с точки зрения ОО в целом и вместо этого думать о данных, функциях, слоях монад. Оказывается, это очень далеко в Хаскеле. Только в нескольких приложениях ОО настолько естественен, что стоит вдавить его в язык.
† Извините, эта тема всегда сводит меня в режим сильного мнения...
Эти паранойи частично мотивированы проблемами изменчивости, которые не возникают в Хаскеле.
В Хаскеле нет хорошего метода для создания "деревьев" наследования. Вместо этого мы обычно делаем что-то вроде
data Animal = Animal ...
data Mammal = Mammal Animal ...
data Bat = Bat Mammal ...
Таким образом, мы инкапсулируем общую информацию. Что не так уж редко встречается в ООП, "отдавай предпочтение композиции, а не наследованию". Далее мы создаем эти интерфейсы, называемые классами типов
class Named a where
name :: a -> String
Тогда мы бы сделали Animal
, Mammal
, а также Bat
случаи Named
однако это имело смысл для каждого из них.
С тех пор мы просто пишем функции в соответствующую комбинацию классов типов, нас это не волнует Bat
имеет Animal
похоронен внутри с именем. Мы просто говорим
prettyPrint :: Named a => a -> String
prettyPrint a = "I love " ++ name a ++ "!"
и пусть базовые классы типов беспокоятся о том, как выяснить, как обращаться с конкретными данными. Это позволит нам писать более безопасный код, например,
foo :: Top -> Top
bar :: Topped a => a -> a
С foo
Понятия не имеем, какой подтип Top
возвращается, мы должны сделать уродливое приведение на основе времени выполнения, чтобы выяснить это. С bar
Мы статически гарантируем, что мы придерживаемся нашего интерфейса, но базовая реализация согласована для всей функции. Это значительно облегчает безопасное создание функций, которые работают на разных интерфейсах для одного и того же типа.
TLDR; В Haskell мы сочетаем обработку данных более композиционно, а затем полагаемся на ограниченный параметрический полиморфизм, чтобы обеспечить безопасную абстракцию между конкретными типами, не жертвуя информацией о типах.
Многие другие ответы уже намекают на то, как классы шрифтов могут быть вам интересны. Тем не менее, я хочу отметить, что по моему опыту, много раз, когда вы думаете, что класс типов является решением проблемы, на самом деле это не так. Я считаю, что это особенно верно для людей с опытом работы в ООП.
На самом деле есть очень популярная статья в блоге, Haskell Antipattern: Existential Typeclass, вам она может понравиться!
Более простой подход к вашей проблеме может состоять в том, чтобы смоделировать интерфейс как простой алгебраический тип данных, например
data Animal = Animal {
animalTransport :: String,
animalType :: String
}
Такой, что ваш bat
становится простым значением:
flyingTransport :: String
flyingTransport = "Flying"
mammalType :: String
mammalType = "Mammal"
bat :: Animal
bat = Animal flyingTransport mammalType
Имея это под рукой, вы можете определить программу, которая описывает любое животное, так же, как ваша программа:
describe :: Animal -> String
describe a = "I am a " ++ animalTransport a ++ " " ++ animalType a
main :: IO ()
main = putStrLn (describe bat)
Это позволяет легко иметь список Animal
значения и, например, печать описания каждого животного.
Есть много способов успешно реализовать это в Haskell, но немногие из них будут "чувствовать" себя как Java. Вот один пример: мы будем моделировать каждый тип независимо, но предоставим операции "приведения", которые позволяют нам обрабатывать подтипы Animal
как Animal
data Animal = Animal String String String
data Flying = Flying String String
data Mammal = Mammal String String
castMA :: Mammal -> Animal
castMA (Mammal transport description) = Animal transport "Mammal" description
castFA :: Flying -> Animal
castFA (Flying type description) = Animal "Flying" type description
Вы можете, очевидно, составить список Animal
без проблем. Иногда людям нравится реализовывать это через ExistentialTypes
и классы типов
class IsAnimal a where
transport :: a -> String
type :: a -> String
description :: a -> String
instance IsAnimal Animal where
transport (Animal tr _ _) = tr
type (Animal _ t _) = t
description (Animal _ _ d) = d
instance IsAnimal Flying where ...
instance IsAnimal Mammal where ...
data AnyAnimal = forall t. IsAnimal t => AnyAnimal t
что позволяет нам вводить Flying
а также Mammal
прямо в список вместе
animals :: [AnyAnimal]
animals = [AnyAnimal flyingType, AnyAnimal mammalType]
но на самом деле это не намного лучше, чем в исходном примере, так как мы выбросили всю информацию о каждом элементе в списке, за исключением того, что он имеет IsAnimal
экземпляр, который, если посмотреть внимательно, полностью эквивалентен тому, чтобы сказать, что это просто Animal
,
projectAnimal :: IsAnimal a => a -> Animal
projectAnimal a = Animal (transport a) (type a) (description a)
Таким образом, мы могли бы просто пойти с первым решением.