My latest article, in response to “OOP is not that bad, actually”, by Ömer Sinan Ağacan (@osa1) last year.
Hi Tom, can you explain how you read the term useImplIn
? To me it reads like “Use the implementation in …” But then it has two arguments, and I don’t know how to complete the sentence with those clues.
If I go read the types, I think I understand what it’s doing. I’m less certain about useImpl
. Combining three of these terms in one line certainly makes the boilerplate hard to follow.
The are good questions.
Firstly, I wouldn’t suggest trying to read the boilerplate. It’s debatable whether I should have included it. I only did so to keep myself honest (about eventually adding some TH/Generics to generate it) and to be explicit for people who really want that. Maybe I’ll move it to an appendix.
Well, in this example
useImplIn
k
MkLogger
{ logImpl = \msg sev -> do
when (sev >= minSev) $ do
log logger msg sev
}
useImplIn
means something like “use this implementation of MkLogger
in the call to the continuation k
”.
I’m not sure what you mean by that. Where are you seeing three of something combined in one line?
Regarding useImpl
specifically, it is
useImpl :: e :> es => Eff e r -> Eff es r
so when you call useImpl m
you’re saying “m
works when the effects e
are in scope, so it certainly also works when a bigger set of effects, es
, are in scope”. (I’m not convinced “useImpl
” is a good name for this!)
Thanks for the post and the ping!
I think we are mostly in agreement, but for people reading this post but not mine:
My point in my original post isn’t whether you can have the same in Haskell that’s is extensible while being backwards compatible, but rather, with OOP you have one way to do it, everyone knows how to do it, it’s extensible in a backwards compatible way, and it can be used in any code base with no effort (no adapters etc.).
Whereas in Haskell we can come up with a dozen ways to do this, with different tradeoffs, and only some of them would be extensible in a backwards compatible way. If the logger library doesn’t use my favorite effect library then I’ll need adapters.
Both IsLogger
and Logger
in your examples require that you design with extensibility in mind or update use sites when you decide to pass around diffrent types of loggers, which isn’t required in the OOP implementation. This is the main point.
I think you also allude to some of this in the “Worse?” section towards the end.
One thing that I’d respond to or elaborate is
In particular, I don’t see inheritance and subtyping as particularly valuable for this task.
Subtyping is essential for the OOP code in my blog post, because without it you wouldn’t be able to pass the different Logger
implementations as Logger
with no changes in the use site.
You never really need it, but without it you have to call isLogger
before you use the logger, and have the IsLogger
constraint in your type signatures. You can do similar things in OOP and you also wouldn’t need subtyping, but the point is that you extend the type without changes in the use sites, which don’t make any assumptions about the extensibility of the types used and don’t plan for it ahead of time. This is only possible with subtyping.
But you are right that inheritance is not important. Inheritance is an orthogonal concept that made one with subtyping by the language designers, which I see as a mistake.
This is not what I took away from your post! I’m sorry if I misinterpreted your post, but that’s not what came across during my readings of it. I agree that having “one default way” of doing things can be beneficial. But with regard to Haskell specifically, I hope that it never closes doors so that we are always free to experiment. After all, Bluefin would never have come into existence if MTL, or Polysemy, or effectful, or whatever, were built in to the language.
True, but I don’t think this is a big deal with IO-based effect systems. They’re trivially interconvertible (or if not, it’s just because no one’s written the adaptors yet).
I don’t understand this. Logger
itself is an interface. You can swap out its implementation without changing use sites, so in what sense is “subtyping” required?
I probably didn’t express it clear enough.
(The problem is writing a blog post takes a long time, so after a while I stop editing and improving and publish, otherwise I won’t be able to publish anything!)
It’s good that languages like Haskell exist, but if I’m in the industry trying to get things done and I have all kinds of constraints, this advantage of OOP can be a big deal.
Aren’t there just two of these, effectful and Bluefin?
Logger
is not an interface initially! It starts as a concrete class, then becomes an interface and with subtypes (implementations), but the users don’t even notice it and don’t need to update the use sites as you change it from a class to an interface and add subtypes. That’s the beauty of the OOP approach.
Logger is not an interface initially! It starts as a concrete class, then becomes an interface and with subtypes (implementations), but the users don’t even notice it and don’t need to update the use sites as you change it from a class to an interface and add subtypes.
I’m confused about this, as well.
The Haskell version of Logger
starts as:
data Logger = MkLogger
{ _log :: Message -> Severity -> IO ()
}
Then we have an “extension” that is
data FileLogger = MkFileLogger
{ _logger :: Logger
, _flush :: IO ()
}
If we want supply FileLogger
to clients that expect a plain Logger
, that’s no problem. We unwrap FileLogger
to get the Logger
and pass that. No need to recompile the clients (assuming that the definitions for Logger
and FileLogger
live in different compilation units, and that the definition of Logger
hasn’t changed).
Even in a language like C# (and, IIRC, Java as well) turning a concrete class into an abstract class, or an abstract class into an interface, will always require recompiling the clients as well. Even if the public method signatures stay the same.
For passing around a file logger where you previously passed a stdio logger this works fine (though other caveats apply, e.g. there are many ways to do this, some incompatible with others).
With the OOP approach, I can also make the Logger
constructor return FileLogger
instead (maybe log to a standard file) and no use site would have to be updated. I show an example of this in my blog post when I update the factory method factory Logger() => ...
. Initially it returns Logger
, but I update it to return SimpleLogger
. I could also make it return a FileLogger
or any other Logger
subtype.
This is useful when you don’t pass things around as arguments (explicitly, or implicitly via typeclasses) but rather use sites “construct” the objects themselves.
I hadn’t even considered recompilation when in my blog post, I’m more concerned with updating the use sites (code).
You can do exactly the same with the Haskell version : just call , _logger . MkFileLogger
… and pass the new logger
around.
No, you can’t. Your example updates the use site from MkFileLogger
to _logger . MkFileLogger
.
As I explain above, with the OOP approach you make all these changes without updating the use sites. That’s the whole point, and the beauty of the OOP approach.
I am not updating anything. I am WRITING _logger . MkFileLogger
instead of MkFileLogger
(wherever I need to replace the “construction” of an old logger by a file logger).
You can come up with various alternatives but you just can’t have the same thing as subtyping, without subtyping.
MkFileLogger
is not realistic for a logger, because it’s a constructor rather than factory as I show in my blog post. A constructor can’t read env variables or open files. With a factory I have a constructor function that can open a file or read an env variable and return one thing today, return another tomorrow. It still works.
If you have a function similar to the factory than its return type will change when you change the returned logger.
I think we covered a lot of alternatives in the previous comments and in the blog posts. You can pick one that works best for you. I’m not claiming that you should use OOP and it’s the best.
My impression is that in “modern” OOP, the factory pattern is somewhat discouraged and dependency injection is used instead.
That means components don’t “ask” for dependencies. Instead, they receive their dependencies during construction. And there is a point in the code, sometimes called the “composition root” where all the dependencies of all components are wired together. This wiring can be done by just passing arguments around, but you can add some degreee of magic to automate things somewhat.
So, in the case of Logger
vs. FileLogger
, the change from MyFileLogger
to _logger . MkFileLogger
would be in the composition root, not on any component that uses Logger
. The composition root would have to be recompiled, but not the clients of Logger
(which could live in a separate compilation unit).
I don’t see any problems with doing the same in Haskell. In your blog post you propose different implementations and argue that you need to change the call sites, but why not use classical abstract data types?
module Logger
( Logger -- No implementation is exposed, you can do
-- anything you want inside the Logger.
-- The "interface" will not change
, log
) where
Then you can add any implementation inside. I guess
data Logger = Stdout | File Handle
would work perfectly fine for the first 15 years (like the logger I hacked in one evening – 14 years in production and counting).
I would think twice even about a simple record of functions (plain data types are easier to observe than opaque functions). Other approaches are even more dubious:
- Type classes are for ad-hoc polymorphism, not for structuring the code. I often see that people who are used to designing everything with classes in OOP are trying to use type classes in Haskell in the same way. 99% of the time ADT + pattern matching will work better, in the remaining 1% you can pass a function as an argument.
- Existentials – if you don’t overuse type classes you might not need them. They frequently smell of OOP (though they do have their uses in specific code like compilers).
- Effect systems – that’s another OOP in Haskell. And they have exactly the problem you describe – all code has to use them.
Right, this is what I was thinking too. I feel there must be something that @osa1 is trying to communicate that I’ve missed.
Well, ReaderT IO
/rio
(in a sense a “non effect-system” effect system) is also one of these, as is cleff
, and I expect all future effect systems that see practical adoption in Haskell to also be one of these.
But I take your point: interconversion is a cost to be paid, even if it’s low. In a language where the object system is built in, everything works to the same “meta interface”, so is compatible by default. There are downsides: in order to take a Foo
argument and know you really do have a Foo
, not a subclass, you have to play some kind of trick, such as final
in Java, which itself complicates matters.
![](https://discourse.haskell.org/user_avatar/discourse.haskell.org/osa1/48/4710_2.png)
With a factory I have a constructor function that can open a file or read an env variable and return one thing today, return another tomorrow. It still works.
I think I must be missing something important. It seems to me that withMyLogger
(below) is a factory. It returns a Logger
because I don’t want to commit to it being a FileLogger
, so that I can swap out its implementation later.
withMyLogger ::
(e1 :> es) =>
IOE e1 ->
(forall e. Logger e -> Eff (e :& es) r) ->
Eff es r
withMyLogger io k = do
withFileLogger "/home/me/mylogfile" io $ \fileLogger -> do
k (fileLoggerLogger fileLogger)
(where withFileLogger
is from the article.) For example, I could change it to read an env var:
withMyLogger ::
(e1 :> es) =>
IOE e1 ->
(forall e. Logger e -> Eff (e :& es) r) ->
Eff es r
withMyLogger io k = do
filename <- effIO io (getEnvVar "LOGFILE")
withFileLogger filename io $ \fileLogger -> do
k (fileLoggerLogger fileLogger)
or I could stop it being a FileLogger
at all, and just print to the terminal, say
withMyLogger
(e1 :> es) =>
IOE e1 ->
(forall e. Logger e -> Eff (e :& es) r) ->
Eff es r
withMyLogger = withStdoutLogger
(where withStdoutLogger
is from the article).
In which regard does an OO language do better than this?
Sorry, I see that in Option 2 you actually propose to hide the “concrete logger class” (maybe just “hide the implementation”?).
Existentials are not that hard if you mix them with GADTs
data AnyLogger where
AnyLogger :: Logger a => a -> AnyLogger
-- Then you just pass AnyLogger as a plain data type.
-- Pattern matching will bring the type class into scope
log (AnyLogger l) = doLog l
And it’s private to your module, no need to change call sites.
And to expand on the record of funtions
data Logger
= Logger
{ log :: Message -> Severity -> IO ()
}
data FileLogger
= FileLogger
{ logger :: Logger
, flush :: IO ()
}
-- | A "factory" to create the file logger.
newFileLogger :: FilePath -> IO FileLogger
...
-- | Conversion function.
-- Manual yes, but not too hard to write.
fileLoggerToLogger FileLogger{logger} = Logger{logger}
![](https://discourse.haskell.org/user_avatar/discourse.haskell.org/vshabanov/48/2080_2.png)
Then you can add any implementation inside. I guess
data Logger = Stdout | File Handle
would work perfectly fine for the first 15 years
If you interpret @osa1’s original article to be about logging then sure, it is over engineering. But it’s not about logging. It’s about the general concern of defining interfaces and writing implementations of those interfaces, without being in control of the interface definition. With your Logger
definition, no one except you can add a new logging backend.
![](https://discourse.haskell.org/user_avatar/discourse.haskell.org/vshabanov/48/2080_2.png)
Effect systems – that’s another OOP in Haskell. And they have exactly the problem you describe – all code has to use them.
I know you don’t believe me (and I certainly haven’t demonstrated this yet) but I really don’t think it does (for IO
-based effect systems)! Adapting Bluefin code to work with effectful and vice versa ought to be really, really cheap (performance wise and syntactically).
![](https://discourse.haskell.org/user_avatar/discourse.haskell.org/tomjaguarpaw/48/1230_2.png)
it’s not about logging. It’s about the general concern
Unfortunately, people read these examples and think that this is the way to develop even the simplest code.
I have a general concern too – a lot of code could be written much simpler.
![](https://discourse.haskell.org/user_avatar/discourse.haskell.org/tomjaguarpaw/48/1230_2.png)
without being in control of the interface definition
I think closed source development is long gone. If people can’t change interfaces when they need to, it’s a sure way to code rot.
![](https://discourse.haskell.org/user_avatar/discourse.haskell.org/tomjaguarpaw/48/1230_2.png)
With your
Logger
definition, no one except you can add a new logging backend.
Anyone with access to the source code should be able to do it. If people are prohibited from changing the code, then that’s the problem to solve first.
![](https://discourse.haskell.org/user_avatar/discourse.haskell.org/vshabanov/48/2080_2.png)
Unfortunately, people read these examples and think that this is the way to develop even the simplest code.
Unfortunately indeed.
![](https://discourse.haskell.org/user_avatar/discourse.haskell.org/vshabanov/48/2080_2.png)
Anyone with access to the source code should be able to do it. If people are prohibited from changing the code, then that’s the problem to solve first.
So do you mean that if I am depending on some logging package aloggingpackage
that provides data Logger = Stdout | File Handle
, and I want to add a logging backend that logs to stderr
then I have to fork the package or vendor it into my codebase, add the Stderr
constructor, and change all pattern matches on Logger
to deal with this new possibility?
![](https://discourse.haskell.org/user_avatar/discourse.haskell.org/tomjaguarpaw/48/1230_2.png)
then I have to fork the package or vendor it into my codebase, add the
Stderr
constructor, and change all pattern matches onLogger
to deal with this new possibility?
Why not? You can even make a PR for all the other users of aloggingpackage
to enjoy.
I don’t think there will be many pattern matches. And if there are, then it might be time for a redesign.