– Tom Ellis, February 2025
In “OOP is not that bad, actually”, Ömer Sinan Ağacan describes a task that he says “mainstream statically-typed OOP languages do well”. He defines OOP [object oriented programming] as statically-typed programming with classes, inheritance, subtyping and virtual calls.
Ultimately I interpret the article not as advocating for OOP, but rather as advocating for programming against well-defined interfaces that can be instantiated with a variety of implementations. I’m strongly in support. However, I think the task is better solved by Haskell, a statically-typed functional language, than by an OOP language (as Ömer defines it). In particular, I don’t see inheritance and subtyping as particularly valuable for this task.
Let’s look at Ömer’s example in more detail, in a Haskell context.
We start with a basic logger interface. In Haskell an interface is a
type, here Logger
. This is like the Logger
type that Ömer
defined, except using the
Eff
type from my effect system Bluefin instead of IO
. (Most of what I
say in this article will apply equally well to IO
or Eff
and I’ll
explain at the end why I think Eff
is better.)
type Severity = Int
newtype Logger e =
-- Log a message with a severity
MkLogger {logImpl :: String -> Severity -> Eff e ()}
Then we need a bit of ceremony to define a Handle
instance and the
log
function that we define in terms of the Handle
implementation.
(This is in principle derivable using Template Haskell or Generics but
I haven’t implemented that in Bluefin yet. Mea culpa. I’m including
this boilerplate in the article to be honest and explicit.)
instance Handle Logger where
=
mapHandle logger MkLogger
= (fmap . fmap) useImpl (logImpl logger)
{ logImpl
}
log :: (e :> es) => Logger e -> String -> Severity -> Eff es ()
log = operationFrom logImpl
Then immediately we can define a function, exampleWithLogger
, which
uses the Logger
interface. It prints some messages to stdout (using
putStrLn
) and logs some messages to the Logger
(using log
).
exampleWithLogger ::
:> es, e2 :> es) =>
(e1 IOE e1 ->
Logger e2 ->
Eff es ()
= do
exampleWithLogger io logger putStrLn "Started Logger example")
effIO io (log logger "Mild Logger message" 0
log logger "Severe Logger message" 10
putStrLn "Ended Logger example") effIO io (
Having such a function is not useful until we have a way to
instantiate the Logger
interface. Here’s one: it prints log
messages to stdout.
withStdoutLogger ::
:> es) =>
(e1 IOE e1 ->
forall e. Logger e -> Eff (e :& es) r) ->
(Eff es r
=
withStdoutLogger io k
useImplIn
kMkLogger
=
{ logImpl ->
\msg sev -- Print log message to stdout
putStrLn (mkMsg msg sev))
effIO io (
}where
=
mkMsg msg sev "Logger message: " ++ show sev ++ ": " ++ msg
(useImplIn
and effIO io
are Bluefin incantations. The same code using IO
instead of Eff
wouldn’t have them.) I can instantiate the Logger
interface and use
it to run the example, like this:
runExampleWithLogger :: IO ()
= runEff $ \io -> do
runExampleWithLogger -- "Instantiate" the Logger interface
-- with a stdout logger
$ \logger -> do
withStdoutLogger io -- Use the Logger "instance"
exampleWithLogger io logger
and the result is as expected:
Started Logger example
Log msg: 0: Mild Logger message
Log msg: 10: Severe Logger message
Ended Logger example
Another example that Ömer uses is a logger which only logs above a
certain severity. Bluefin can do that too! Ömer suggested that the
minimum severity logger should be created afresh, but I actually think
it’s better to take an existing logger and wrap it into a minimum
severity logger. That sounds more useful, more object-oriented, and
in any case more interesting, so let’s do it:
withLogAboveSeverityLogger
creates a logger that logs to an existing
Logger
(passed in as an argument), but only when the severity is
above some minimum severity.
withLogAboveSeverityLogger ::
:> es) =>
(e1 Severity ->
Logger e1 ->
forall e. Logger e -> Eff (e :& es) r) ->
(Eff es r
= do
withLogAboveSeverityLogger minSev logger k
useImplIn
kMkLogger
= \msg sev -> do
{ logImpl >= minSev) $ do
when (sev log logger msg sev
}
Then we can make a stdout logger and restrict it so it only logs messages of severity 5 and above:
runExampleWithLogAboveSeverityLogger :: IO ()
= runEff $ \io -> do
runExampleWithLogAboveSeverityLogger -- Make the stdout logger
$ \logger -> do
withStdoutLogger io -- Only log messages of severity 5 and above
5 logger $ \severeLogger -> do
withLogAboveSeverityLogger -- Run the example with the restricted logger
exampleWithLogger io severeLogger
The output is the same except the mild (severity 0) log message is suppressed.
Started Logger example
Log msg: 10: Severe Logger message
Ended Logger example
Finally, Ömer defines a file logger. Following the recipe seen above,
we first define an interface for this type of logger. It contains a
Logger
whose log operation will write to a file, and an effectful
operation which flushes writes to the open file.
data FileLogger e = MkFileLogger
fileLoggerLogger :: Logger e,
{ flushImpl :: Eff e ()
}
This is “composition, not inheritance”, a famous design principle of OOP! Again we have some ceremony. Sorry.
instance Handle FileLogger where
=
mapHandle fileLogger MkFileLogger
=
{ fileLoggerLogger
mapHandle (fileLoggerLogger fileLogger),=
flushImpl
useImpl (flushImpl fileLogger)
}
flush :: (e :> es) => FileLogger e -> Eff es ()
= operationFrom flushImpl flush
To create a FileLogger
we take a file name, get access to a
writeable file handle using a Bluefin withFile
block, and use the
file handle to define the Logger
and the flush operation. Bluefin’s
withFile
is bracketed, so the file is closed automatically when
leaving withFileLogger
(even if an exception is thrown).
withFileLogger ::
:> es) =>
(e1 FilePath ->
IOE e1 ->
forall e. FileLogger e -> Eff (e :& es) r) ->
(Eff es r
=
withFileLogger fp io k -- Open a file for writing
WriteMode $ \handle -> do
withFile io fp -- Create the FileLogger
useImplIn
kMkFileLogger
=
{ fileLoggerLogger MkLogger
=
{ logImpl ->
\msg sev -- Log to the open file
hPutStrLn handle (mkMsg msg sev)
},= do
flushImpl -- Diagnostic message for the sake of
-- the example
putStrLn "Flushing FileLogger")
effIO io (-- Flush writes to the file
hFlush handle
}where
=
mkMsg msg sev "FileLogger message: " ++ show sev ++ ": " ++ msg
We want to be able to use functions that accept Logger
with our
FileLogger
. But how can we? Haskell doesn’t have subtyping!
That’s OK: Haskell has functions. We just apply the function
fileLoggerLogger
. This achieves the same end as subtyping would,
but with an explicit use of function application rather than an
implicit use of the type system.
Then we can use a FileLogger
with our function exampleWithLogger
,
which expected a Logger
exampleWithFileLogger ::
:> es, e2 :> es) =>
(e1 IOE e1 ->
FileLogger e2 ->
Eff es ()
= do
exampleWithFileLogger io fileLogger -- Create a Logger from the FileLogger
let logger = fileLoggerLogger fileLogger
putStrLn "Started FileLogger example")
effIO io (-- Log to the FileLogger
log logger "Mild FileLogger message" 0
5 logger $ \severeLogger -> do
withLogAboveSeverityLogger
exampleWithLogger io severeLogger
-- Flush the FileLogger
flush fileLogger
log logger "Severe FileLogger message" 10
putStrLn "Ended Logger example") effIO io (
and run it (using the “file” /dev/stdout
, so that all messages
appear directly on the console for the benefit of exposition).
runExampleWithFileLogger :: IO ()
= runEff $ \io -> do
runExampleWithFileLogger -- Create the FileLogger
"/dev/stdout" io $ \fileLogger ->
withFileLogger -- Use the FileLogger
exampleWithFileLogger io fileLogger
We can see that the FileLogger
is used for all messages, it is
flushed at the expected point, and the mild (severity 0) message
arising from exampleWithLogger
is suppressed (as it was above in
runExampleWithLogAboveSeverityLogger
).
Opening file
Started FileLogger example
File logger message: 0: Mild FileLogger message
Started Logger example
File logger message: 10: Severe Logger message
Ended Logger example
Flushing FileLogger
File logger message: 10: Severe FileLogger message
Ended Logger example
Closing file
Before getting to the stage that we have now reached, Ömer suggested that our approach is unworkable:
unlike our OOP example, existing code that uses the
Logger
type andlog
function cannot work with this new [FileLogger
] type. There needs to be some refactoring, and how the user code will need to be refactored depends on how we want to expose this new type to the users.
I don’t understand why. The approach taken in this article seems perfectly workable to me, even very natural, and doesn’t require any refactoring of existing code. It uses explicit function application instead of implicit subtyping.
The one element that one might think OOP languages do better is avoiding the explicit function application. But we don’t need subtyping, even for that! All we need is a way for a value to implicitly conform to some interface, in Haskell a type class. For example:
class IsLogger h where
isLogger :: h e -> Logger e
instance IsLogger FileLogger where
= fileLoggerLogger isLogger
And we could define functions to take an instance of IsLogger
rather
than a concrete Logger
, for example change
exampleWithLogger ::
:> es, e2 :> es) =>
(e1 IOE e1 ->
Logger e2 ->
Eff es ()
to
exampleWithLogger ::
:> es, e2 :> es, IsLogger logger) =>
(e1 IOE e1 ->
->
logger e2 Eff es ()
That would involve some refactoring, if we’ve already defined a
suite of functions that accept Logger
, but it’s not required, even
if potentially convenient. I’m inclined to say that being explicit is
clearer anyway, but then I like neither the implicit subtyping
approach of OO style, nor the implicit type class approach of many
Haskell libraries, to begin with.
Even if we like this style of “programming against well-defined interfaces that can be instantiated with a variety of implementations”, are we really getting the benefits from Bluefin, or just Haskell? Well, a significant part comes from Haskell, but there are a couple of notable additional benefits of Bluefin: effect tracking and resource safety.
Firstly, Bluefin enforces strict controls, through the type system,
over what functions can and cannot do. For example, we can add a
logger that yields log messages to a Bluefin Stream
:
streamLogger ::
:> es) =>
(e1 Stream String e1 ->
forall e. Logger e -> Eff (e :& es) r) ->
(Eff es r
=
streamLogger stream k
useImplIn
kMkLogger
= \msg sev ->
{ logImpl
yield stream (mkMsg msg sev)
}where
=
mkMsg msg sev "streamLogger message: " ++ show sev ++ ": " ++ msg
Later we can choose what to do with the elements from the stream. Here we print them.
runExampleStreamLogger :: IO ()
= runEff $ \io -> do
runExampleStreamLogger
forEach-> streamLogger stream $ \logger -> do
( \stream
exampleWithLogger io logger
)-> do
( \logMsg putStrLn logMsg)
effIO io ( )
The end product is the same as runExampleWithLogger
above.
Started Logger example
streamLogger message: 0: Mild Logger message
streamLogger message: 10: Severe Logger message
Ended Logger example
But the way we have factored the program is different. From the
program structure it is clear that using streamLogger
does not
perform any IO
(there is no IOE
argument to streamLogger
) even
though other parts of the program do perform IO
. This fine-grained
tracking of effects is not possible if you use the raw IO
monad
(anything that runs in IO
can perform any possible effect) nor is
such tracking possible in any “mainstream, statically-typed OOP
language”.
Secondly, the file handle supplied by
withFile
(used in the definition of withFileLogger
) is guaranteed not to
escape its scope, that is, it’s not possible to use it after
withFileLogger
has returned. That guarantee is not provided by
Haskell’s standard
System.IO.withFile
.
In what ways is Bluefin worse than an OOP language, or just pure Haskell?
Well, we’ve seen that there is ceremony and boilerplate involved in
defining and using Bluefin effects. Much of this can in principle be
addressed by generating the boilerplate using Template Haskell or
Generics, but tracking fine-grained effects at the type system is
always going to be less convenient than using plain IO
(in Haskell),
or than using nothing (in every other language).
Setting up the “subtyping” relationship using IsLogger
also requires
some boilerplate. In OO languages it comes for free, syntactically
free anyway.
I think these costs are worth paying, but I won’t be surprised if others have different preferences.
For my purposes, I’m happy to say that “Bluefin is better than OOP, actually”.
The code in this article uses the following convenience function, which may or may not be added to Bluefin at a later date.
operationFrom ::
Handle h, e :> es) =>
(-> t) ->
(h es ->
h e
t= f . mapHandle operationFrom f
Ömer, for writing the original article
Enis Bayramoğlu, for suggesting the approach of this article on Haskell Reddit