In this situation, the conventional solution is to introduce a data type (an “initial” encoding) or a typeclass (a “final” encoding), then you can define the functions and pretty-printed forms as two different interpretations. For example, with a plain data type you can just pattern-match:
data Exp
= Lit Bool
| And Exp Exp
| Or Exp Exp
| Not Exp
| Equ Exp Exp
deriving (Read, Show)
-- Roundtrip to a debugging representation string.
-- (Plus whatever other standard classes you need.)
-- Evaluate an expression to a Boolean.
eval :: Exp -> Bool
eval (Lit b) = b
eval (And e1 e2) = eval e1 && eval e2
eval (Or e1 e2) = eval e1 || eval e2
eval (Not e) = not (eval e)
eval (Equ e1 e2) = eval e1 == eval e2
-- Render an expression to a pretty-printed string.
render :: Exp -> String
render (Lit b) = show b
render (And e1 e2) = concat ["(", render e1, " and ", render e2, ")"]
render (Or e1 e2) = concat ["(", render e1, " or ", render e2, ")"]
render (Not e) = concat ["not ", render e]
render (Equ e1 e2) = concat ["(", render e1, " equivalent to ", render e2, ")"]
With a GADT you can add some more specific static types:
{-# Language GADTs #-}
data Exp t where
Lit :: Bool -> Exp Bool
And, Or, Equ :: Exp (Bool -> Bool -> Bool)
Not :: Exp (Bool -> Bool)
(:$) :: Exp (a -> b) -> Exp a -> Exp b
eval :: Exp t -> t
eval (Lit b) = b
eval And = (&&)
eval Or = (||)
eval Equ = (==)
eval Not = not
eval (f :$ x) = eval f $ eval x
render :: Exp t -> String
render (Lit b) = show b
render And = "and"
render Or = "or"
render Equ = "equivalent to"
render Not = "not"
render (f :$ x :$ y) = concat [render x, " ", render f, " ", render y]
render (f :$ x) = concat [render f, " ", render x]
Or finally with a typeclass the result is similar:
-- The set of types that can be used as
-- /interpretations/ of expressions.
class Exp r where
lit' :: Bool -> r
and', or', equ' :: r -> r -> r
not' :: r -> r
-- Expressions can be interpreted by evaluation.
instance Exp Bool where
lit' = id
and' = (&&)
or' = (||)
equ' = (==)
not' = not
-- A pretty-printed string.
newtype Pretty = Pretty String
-- They can also be interpreted by pretty-printing.
instance Exp Pretty where
lit' b = Pretty $ show b
and' r1 r2 = Pretty $ concat ["(", r1, " and ", r2, ")"]
or' r1 r2 = Pretty $ concat ["(", r1, " or ", r2, ")"]
equ' r1 r2 = Pretty $ concat ["(", r1, " equivalent to ", r2, ")"]
not' r = Pretty $ concat ["not ", r]
This adds flexibility and complexity that you probably don’t need here, but I mention it since this design pattern can be useful for larger problems. (See Tagless-final Style for more.)