Reputation: 586
I have this recurrent problem when programming in Haskell. At some point, I try to simulate an OOP approach. Here I was writing some sort of AI for a flash game I found, and I'd like to describe the various pieces and the level as a list of pieces.
module Main where
type Dimension = (Int, Int)
type Position = (Int, Int)
data Orientation = OrienLeft | OrienRight
data Pipe = Vertical | Horizontal | UpLeft | UpRight | DownLeft | DownRight
data Tank = Tank Dimension Orientation
data Bowl = Bowl Dimension
data Cross = Cross
data Source = Source Dimension
-- desired
-- data Piece = Pipe | Tank | Bowl | Cross | Source
-- So that I can put them in a list, and define
-- data Level = [Piece]
I know I should abstract the functionalities and put Them in a list, but I often feel blocked in the process of writing code. What is the general mindset I should have in these situations?
Upvotes: 13
Views: 737
Reputation: 5251
I had similar problems 10 years ago, when I learned haskell. Let me answer this in a more general way than your example above.
TL;DR:
Think in terms of OOP-like objects, that represent value-objects and function-objects, with heavy use of c++Templates/javaGenerics. Your program design should be based on data-flow through functions instead of references-to-mutable-memory that store intermediate object states. Those function-objects are composable at runtime.
Your statement "At some point, I try to simulate an OOP approach." describes your experienced way of designing programs. As you might have heard somewhere, functional programming is considered harder to learn for OOP programmers than for newbies, because "wrong concepts" need to be unlearned first. That is not completely true, if you know how to use that to your advantage:
The trick is to use your OOP experience or more likely your imagination on "objects" in the opposite direction: How would you design haskell functions and haskell values with objects? How would you restructure your program to design a sane data flow using only these new concepts? In functional programming, every "value" is an object with only final/const properties -- or with a getter that allows lazy initialization (and with that it allows to implement seemingly infinite single linked lists). "Functions" are such "values", too. If you think about your programm using these concepts, you will easily master this without OOP design patterns but with "objects" that are actually functions and values.
If OOP is about managing memory cells that store mutable states/values, then functions are about calculating the next state/value from the previous. At some point you will see that you only think about combining functions for a sane dataflow and not about managing memory-locations-that-store-values.
The next step would be to see instances of type-classes as some kind of globally instanciated dictionary-objects that are automagically passed as invisible parameters to functions. The OOP classes of these dictionary-objects would use c++Templates/javaGenerics as type parameters, and their methods relate to their params and/or return value instead of any "this" reference. As soon as you understand when and how to use type-classes, they will become properties/roles/flavours of your types instead of imaginary objects.
IMHO, type-classes are one of the best/sanest OOP-design-patterns, but they are only known to very very few programmers and are not easily reinvented without functional thinking. (Every experienced haskell programmer that I know personally has reinvented type-classes as design-pattern in c++Templates/objC/js...)
The general way to learn to think in haskell or any other new strange language is to ask the question: "What can I do with this language easily and how?" And not the question: "How can I write this specific design that actually fits to a completely different language that I'm already very experienced in?" (This sounds obvious, but we tend to forget that. Again and again.)
If your program really needs objects more in the OOP sense, then you might be looking for haskells records (if you are a beginner). If those objects represent databases or similar things, you might be looking for lens libraries; but lenses might be (or might not be) very advanced/irritating for beginners. Lenses are composable getterAndSetter "objects"(or values or function like things) that are used like "obj.lens1.lens2.modify()" in OOP -- which are in OOP stackable but neither composable nor objects themself.
Upvotes: 1
Reputation: 74364
You're on your way to some great code. Let me push it a few more steps toward a Haskell-like solution.
You've successfully modeled each Piece
as an independent entity. This looks completely fine as is, but you want to be able to work with collections of pieces. The most immediate way to do this is to describe a type which can be any of the pieces desired.
data Piece = PipePiece Pipe
| TankPiece Tank
| BowlPiece Bowl
| CrossPiece Cross
| SourcePiece Source
which would let you write a list of pieces like
type Kit = [Piece]
but requires that when you consume your Kit
that you pattern match on the different kinds of Piece
s
instance Show Piece where
show (PipePiece Pipe) = "Pipe"
show (TankPiece Tank) = "Tank"
show (BowlPiece Bowl) = "Bowl"
show (CrossPiece Cross) = "Cross"
show (SourcePiece Source) = "Source"
showKit :: Kit -> String
showKit = concat . map show
There's also a strong argument for reducing the complexity of the Piece
type by "flattening" out some redundant information
type Dimension = (Int, Int)
type Position = (Int, Int)
data Orientation = OrienLeft | OrienRight
data Direction = Vertical | Horizontal | UpLeft | UpRight | DownLeft | DownRight
data Piece = Pipe Direction
| Tank Dimension Orientation
| Bowl Dimension
| Cross
| Source Dimension
which eliminates many redundant type constructors at the expense of no longer being able to reflect what kind of piece you have in the type of a function—no longer can we write
rotateBowl :: Bowl -> Bowl
rotateBowl (Bowl orientation) = Bowl (rotate orientation)
but instead
rotateBowl :: Piece -> Piece
rotateBowl (Bowl orientation) = Bowl (rotate orientation)
rotateBowl somethingElse = somethingElse
which is pretty annoying.
Hopefully that highlights some of the tradeoffs between those two models. There's at least one "more exotic" solution which uses type classes and ExistentialQuantification
to "forget" about everything besides an interface. This is worth exploring as it's pretty tempting to do but is considered to be a Haskell anti-pattern. I'll describe it first then talk about the better solution.
To use ExistentialQuantification
we remove the sum type Piece
and create a type class for pieces.
{-# LANGUAGE ExistentialQuantification #-}
class Piece p where
melt :: p -> ScrapMetal
instance Piece Pipe
instance Piece Bowl
instance ...
data SomePiece = forall p . Piece p => SomePiece p
instance Piece SomePiece where
melt (SomePiece p) = melt p
forgetPiece :: Piece p => p -> SomePiece
forgetPiece = SomePiece
type Kit = [SomePiece]
meltKit :: Kit -> SomePiece
meltKit = combineScraps . map melt
This is an antipattern because ExistentialQuantification
leads to more complex type errors and the erasure of lots of interesting information. The usual argument goes that if you're going to erase all information besides the ability to melt
the Piece
, you ought to have just melted it to begin with.
myScrapMetal :: [ScrapMetal]
myScrapMetal = [melt Cross, melt Source Vertical]
And if your typeclass has multiple functions then perhaps your real functionality is stored in that class. For instance, let's say we can melt
a piece
and also sell
it, perhaps the better abstraction would be the following
data Piece = { melt :: ScrapMetal
, sell :: Int
}
pipe :: Direction -> Piece
pipe _ = Piece someScrap 2.50
myKit :: [Piece]
myKit = [pipe UpLeft, pipe UpRight]
In all honesty, this is almost exactly what you're getting via the ExistentialQuantification
method, but much more directly. When you erase the type information via forgetPiece
you leave only the typeclass dictionary for class Piece
---this is exactly a product of the functions in the typeclass which is what we're explicitly modeling with the data Piece
type just described.
The one reason I can think of to use ExistentialQuantification
is best exemplified by Haskell's Exception
system—if you're interested, take a look at how it's implemented. The short of it being that Exception
had to be designed such that anyone could add a new Exception
in any code and have it be routable through the shared Control.Exception
machinery while maintaining enough identity for the user to catch it as well. This required the Typeable
machinery as well... but it's almost certainly overkill.
The takeaway should be that the model you use will depend a lot on how you end up consuming your data type. Initial encodings where you represent everything as an abstract ADT like the data Piece
solution are nice in that they throw away little information... but can also be both unwieldy and slow. Final encodings like the melt
/sell
dictionary are often more efficient, but require deeper knowledge about what a Piece
"means" and how it will be used.
Upvotes: 15
Reputation: 532
In my opinion, there is no problem in the way you are thinking: it is fairly abstract and that is good.
As suggested by Sassa NF, you can use type classes and that would be very elegant. But in your example, I would expand it in a "simpler way", using abstract data type, since it seems the "natural way" that your thinking.
In this sense, your example would be something like, for example:
data Piece = Vertical
| Horizontal
| UpLeft
| UpRight
| DownLeft
| DownRight
| Cross
| Bowl Dimension
| Source Dimension
| Tank Dimension Orientation
And repeating it: I do not see problems in your way of modeling the problem since it seems abstract enough to me.
Upvotes: 1
Reputation: 5406
You are thinking of polymorphism. There is place for that in Haskell, too, only it is done differently.
For example, it seems you want to process Pieces in a Level in a generic fashion. What is that processing? If you can define those functions, you will find that's like defining a Piece interface. In Haskell that will be a typeclass (defined as class Piece a
with a list of functions the "implementations" should take care of).
Then you would need to define what those functions do for particular data types, for example instance Piece Pipe
and add the definitions of those functions. Once you've done that for all data types, you can add them to the list of Pieces.
Upvotes: 0