Magicloud
Magicloud

Reputation: 857

How to convert my thoughts in OOP to Haskell?

For example, I have a container type to hold elements with common character. And I also provide some types to be the element. And I also want this function to be easily extended (others could make their own element type and be hold by my container).

So I do:

class ElementClass
data E1 = E1 String
instance ElementClass E1
data E2 = E2 Int
instance ElementClass E2
data Element = forall e. (ElementClass e) => Element e
data Container = Container [Element]

This is fine until I need to deal with the element individually. Due to forall, function "f :: Element -> IO ()" has no way to know what element it is exactly.

What is the proper way to do this in Haskell style?

Upvotes: 3

Views: 462

Answers (3)

Luis Casillas
Luis Casillas

Reputation: 30237

First of all, make sure you read and understand "Haskell Antipattern: Existential Typeclass". Your example code is more complex than it needs to be.

Basically, you're asking how to perform the equivalent of a downcast in Haskell—cast a value from a supertype to a subtype. This sort of operation can intrinsically fail, so the type is something like Element -> Maybe E1.

The first question to ask here is: do you really need to? There are two complementary alternatives to this. First: you can formulate your "supertype" in such a way that it only ever has a finite, fixed number of "subtypes." Then you implement your type just as a union:

data Element = E1 String | E2 Int

And every time you want to use an Element you pattern match and presto, you have the case-specific data:

processElement :: Element -> whatever
processElement (E1 str) = ...
processElement (E2 i) = ...

The downsides to this approach are that:

  1. Your union type can only have a fixed set of subcases.
  2. Every time you add a subcase you will have to modify all the existing operations to add an extra matching case for it.

The upsides are:

  1. By enumerating all the subcases in your type, you can use the compiler to tell you when you've missed one.
  2. Adding a new operation is easy, and doesn't require you to modify any existing code.

The second way you can go is to reformulate the type as an "interface". By this I mean your type is now going to be modeled as a record type, each of whose fields constitutes a "method":

data Element = Element { say :: String }

-- A "constructor" for your first subcase
makeE1 :: String -> Element
makeE1 str = Element str

-- A "constructor" for your second subcase
makeE2 :: Int -> Element
makeE2 i = Element (show i)

This has the upside that you now can have as many subcases as you want, and you can easily add them without modifying existing operations. It has these two downsides:

  1. If you need to add new operations, you will have to add a "method" (field) to the Element type, and modify every existing function that constructs an Element.
  2. Consumers of the Element type can never tell which subcase they're dealing with, or get information specific to this subcase. E.g., a consumer can't tell a particular Element started was constructed with makeE2, much less extract the Int that such an Element encapsulates.

(Note that your example with existentials is equivalent to this "interface" approach, and shares the same advantages and limitations. It's just needlessly verbose.)

But if you really insist on having the equivalent of a downcast, there is a third alternative: use the Data.Dynamic module. A Dynamic value is an immutable container that holds a single value of any type that instantiates the Typeable class (which GHC can derive for you). Example:

data E1 = E1 String deriving Typeable
data E2 = E2 Int deriving Typeable

newtype Element = Element Dynamic

makeE1 :: String -> Element
makeE1 str = Element (toDyn (E1 str))

makeE2 :: Int -> Element
makeE2 i = Element (toDyn (E2 i))

-- Cast an Element to E1
toE1 :: Element -> Maybe E1
toE1 (Element dyn) = fromDynamic dyn

-- Cast an Element to E2
toE2 :: Element -> Maybe E2
toE2 (Element dyn) = fromDynamic dyn

-- Cast an Element to whichever type the context expects
fromElement :: Typeable a => Element -> Maybe a
fromElement (Element dyn) = fromDynamic dyn

This is the closest solution to the OOP downcasting operation. The downside to this is that downcasts are inherently not type safe. Let's go back to the case where, some months later, you need to add an E3 subcase to your code. Well, the problem now is you have a lot of functions sprinkled throughout the code that are testing whether an Element is an E1 or an E2, which were written before E3 ever existed. How many of these functions will break when you add this third subcase? Good luck, because the compiler has no way of helping you!

Note that this three-alternative scenario I've described also exists in OOP, with these three alternatives:

  1. The OOP counterpart to union type is the Visitor pattern, which is meant to make it easy to add new operations to a type without having to modify its subclasses. (Well, relatively easy. The Visitor pattern is hella verbose.)
  2. The OOP counterpart to the "interface" solution is to code 100% to an interface (or abstract class). This means not only that you use an interface—it also means that your client code never "peeks under the interface" to see what the actual implementation classes are; it relies entirely on the interface methods and their contracts.
  3. The OOP counterpart to the Dynamic solution is to use downcasting. It has the same downsides as I explained above—somebody can come in and add a new subclass, and code that "peeks" at the runtime subtype may not be ready to handle this.

So to the broader question of how to change from OOP thinking to Haskell thinking, I think this comparison provides a good starting point. OOP and Haskell provide all three alternatives. OOP makes #3 very easy, but that basically gives you rope to hang yourself with; #2 is what many OOP gurus would recommend you do, and it can be achieved if you are disciplined; but #1 in OOP gets very verbose. Haskell makes #1 easiest; #2 is not much harder to implement, but requires more careful forethought ("am I providing the correct operations for all users of this type?"); #3 is the one that's a bit verbose and against the grain of the language.

Upvotes: 5

Random Dev
Random Dev

Reputation: 52290

Ok I'll try to help a bit.

First: I assume you have these data types:

data E1 = E1 String
data E2 = E2 Int

And you have a sensible operation on both that I'll call say:

say1 :: E1 -> String -> String
say1 (E1 s) msg = msg ++ s

say2 :: E2 -> String -> String
say2 (E2 i) msg = msg ++ show i

So what you can do without any type-classes or stuff is this:

type Messanger = String -> String

and instead of having a container with lot's of E1 and E2, instead use a container with Messagners:

sayHello :: [Messanger] -> String
sayHello = map ($ "Hello, ")

sayHello [say1 (E1 "World"), say2 (E2 42)]
> ["Hello, World","Hello, 42"]

I hope this helps you a bit - the thing is just going away from the object and looking at the operations instead.

So instead of pushing the objects/data to a function that should work with the objects data and behaviour just use a common "interface" to do your stuff.

If you give me some better example of classes and methods (for example two types that might indeed share some traits or behaviour - String and Int are really lacking on this) I will update my answer.

Upvotes: 6

leftaroundabout
leftaroundabout

Reputation: 120731

to know what element it is exactly

To know that, you should of course use a simple ADT

data Element' = E1Element E1
              | E2Element E2
              | ...

this way, you can pattern-match on which one it is in your container.

Now, that clashes with

others could make their own element type and be hold by my container

and it must clash! When other people are allowed to add new types to the list of elements, there's no way to safely match all possible cases. So if you want to match, the only correct thing is to have a closed set of possibilities, as an ADT gives you.

OTOH, with an existential like you originally had in mind, the class of allowed types is open. That's ok, but only because the exact type isn't in fact accessible but only the common interface defined by forall e. ElementClass e.

Existentials are indeed a bit frowned-upon in Haskell, because they are so OO-ish. But sometimes this is quite the right thing to do, your application might be a good case.

Upvotes: 7

Related Questions