typelevel / cats-mtl

cats transformer type classes.
https://typelevel.org/cats-mtl/
Other
306 stars 61 forks source link

Rebranding Cats MTL to emphasize the Capabilities, not the Monad Transformers #516

Open benhutchison opened 11 months ago

benhutchison commented 11 months ago

An issue I raised recently with Cats Effect, around WriterT logging not working well with the concurrent parEvalMap in FS2, revealed that the thinking on Cats MTL is shifting.

Cats MTL consists of 2 things:

The documentation currently emphasizes these implementations as the primary role of the library. The homepage leads with the somewhat underwhelming:

Provides transformer typeclasses for cats' Monads, Applicatives and Functors.

You can have multiple cats-mtl transformer typeclasses in scope at once without implicit ambiguity, unlike in pre-1.0.0 cats or Scalaz 7.

However, recent thinking sees more value in the capability traits than the implementations, for at least 2 reasons:

  1. It turns out that MTs prove troublesome in larger, industrial-scale programs that use CE3's Concurrent & friends. https://github.com/typelevel/cats-effect/discussions/3765 is one example of a rash of issues reported in the last couple of years where MTs do surprising things in the presence of concurrency, and consensus is that these surprises are not "fixable". Therefore, we see advice being issued against MT usage, at least in more demanding scenarios.

  2. However, many capability traits can be usefully implemented even without MTs, in ways that are better suited to industrial software development, by backing the trait with a mutable cats.effect.Ref for Tell & Stateful, or by "submarine error propagation" in the case of Raise and Handle.

    Pleasingly, abstraction of the capability is maintained, in that the trait can be implemented either by Ref, or by MT, without touching application code written in terms of the capability. This allows eg unit tests to use a simpler MT-based solution, while larger applications use a Ref approach.

    An example implementation of a Ref-backed Tell:

  def refTell[F[_]: Sync, L, M[_]](ref: Ref[F, M[L]])(using a: Applicative[M], mo: Monoid[M[L]]): Tell[F, L] =
    new Tell[F, L]:
      def tell(l: L): F[Unit] = ref.update(mo.combine(_, a.pure(l)))
      def functor = Functor[F]

The shifting thinking around MTs affects the role that Cats MTL plays in the Typelevel ecosystem. The capability traits are the gold. We ought to:

Such a rebranding, perhaps accompanied by a blog post at https://typelevel.org/blog/, could see Cats MTL become relevant to a wider range of Typelevel users and lead to a well-deserved increase in library adoption.

djspiewak commented 10 months ago

Yes. Very yes. Extremely very yes.

At least in my mind, capability classes have long been divorced from the monad transformers themselves which gave them their conventional name: MTL. I don't mind keeping the name (if nothing else, it inspired a dope logo), but emphasizing the "implicit capabilities" part of this is absolutely key.

More broadly, rewriting the documentation has been a TODO for a long time. I rewrote the library for 1.0 basically to support Coop, which in turn was to support Cats Effect 3's development. As such, I was pretty focused on just hacking out the technical stuff and getting on with the downstream problems as quickly as possible, but that meant I left a lot of stale words behind. At least we no longer have anything referring to the old layered materialization mechanism, but conversely we have nothing which describes MonadPartialOrder (which is actually quite useful if you're doing real monad transformers), and all of the verbiage describing the library at a high level is dated and wrongly-focused, as you described.

All in all: yes. Let's do this.

rossabaker commented 10 months ago

All of Ben's points are good, but I am wary of a breaking change for something already so widely depended on. I'd be in favor of a rebrand that makes people forget the origin of the acronym, like KFC or MTV. (I'm struggling for a less American example...)

benhutchison commented 10 months ago

@rossabaker To be clear, I was proposing a docs change only, no breaking changes to the APIs.

But perhaps you use "breaking change" figuratively, to refer to a major change in the doc content? I wouldn't propose changing the role or semantics of the capability traits, which is what clients should depend on.

Pretty much just this:

  • Revise the Homepage and Getting Started to emphasize the value of abstract capabilities, and deemphasize MTs as the only way to fulfill such capability. Instead mention MT, Refs and "submarine error propagation" as implementation options.
  • Show example code on non-MT solutions.

So concretely, for eg Tell docs there would be one example use case, but two code samples showing alternate ways to implement Tell (WriterT, Ref). And some guidance on the pros & cons of each approach, learned from hard experience ;)

I'd be in favor of a rebrand that makes people forget the origin of the acronym, like KFC or MTV

We can footnote that capability traits of this style originated with a haskell library called MTL, hence the name inheritance, but that the library isn't a "monad transformer library" as such (at the risk of unforgetting the acronym origin 😛 )

rossabaker commented 10 months ago

Yep, I think we're on the same page. New examples and emphases in the docs would be good, and changes that break existing code would not be good.

benhutchison commented 10 months ago

All in all: yes. Let's do this.

I will take a stab at starting this. Timeframe weeks.

Getting Started page will need to be heavily reworked/reordered first. I don't think it makes sense to start including examples of eg Ref-backed Tells until the core concepts are expressed succinctly.

armanbilge commented 10 months ago

👍 I can't merge my own PRs but I can definitely review/merge contributions!

I don't think it makes sense to start including examples of eg Ref-backed Tells until the core concepts are expressed succinctly.

@benhutchison have you seen https://github.com/typelevel/cats-effect/issues/3385 and https://github.com/typelevel/cats-effect/pull/3429? The idea would be to provide implementations of MTL typeclasses in Cats Effect itself. Primarily we were discussing Ask/Local based on IOLocal but a Ref-based Tell is definitely a possibility as well.

benhutchison commented 10 months ago

I notice:

core takes on a cats-mtl dependency

Interesting, thanks for the pointer. High time we joined the dots between these 2 key libraries for "Effectful FP". And all the more reason to re-express MTL's concept in updated form.

Well, a Ref could provide any/all of Tell, Ask and Stateful depending on what the user was in the mood for. I wonder ...

armanbilge commented 10 months ago

and Stateful

Unfortunately the current Stateful interface is not really concurrency-ready. See https://github.com/typelevel/cats-mtl/pull/120#issuecomment-516894852. So we may need to introduce a new MTL type with weaker laws and Stateful would extend that with its stronger laws.


  • We typically think of Tell's Monoid as an append, but it could also replace in a state update..

Sure. Fortunately this is all abstracted by the Monoid, so we don't need two implementations for these different "modes".


  • Does Asking require the asker to always receive the same answer?

Do you mean if you sequence two asks back-to-back with no other steps in between? e.g. (ask, ask).tupled. In that case, yes, the current laws for Stateful and Local do assert that the asker will receive the same answer. See my comments above about how Stateful was not designed with concurrency in mind.

benhutchison commented 10 months ago

and Stateful

Unfortunately the current Stateful interface is not really concurrency-ready. See #120 (comment). So we may need to introduce a new MTL type with weaker laws and Stateful would extend that with its stronger laws.

  • Does Asking require the asker to always receive the same answer?

Do you mean if you sequence two asks back-to-back with no other steps in between? e.g. (ask, ask).tupled. In that case, yes, the current laws for Stateful and Local do assert that the asker will receive the same answer. See my comments above about how Stateful was not designed with concurrency in mind.

Yes, that one's still at large... Interesting! So at some point, the MTL capability traits ought to be renovated to model weaker, concurrent-friendly forms of State and Ask.

This is a classic example of how implementations shape the abstractions that are harvested from them. Monad transformers inherited from Haskell were non-concurrent, and the "missing interfaces" were not visible until people tried to implement them with concurrent backing stores.

I'd say something about how a rewrite/rebrand of MTLs docs might be an opportune time to fix the trait hierarchy, but Im worried @rossabaker will roll his eyes knowingly and mutter something about "changes that break existing code would not be good" ;)

(In truth, there's no need to bundle the two things, beyond a certain human urge to do so.)

  • We typically think of Tell's Monoid as an append, but it could also replace in a state update..

Sure. Fortunately this is all abstracted by the Monoid, so we don't need two implementations for these different "modes".

Thinking on this, I don't think Monoid quite captures state update. A Semigroup plus an initial value is needed; almost but not quite the same thing. The difference is that if I later write out the empty value of the Monoid, I'd still expect the state to be updated. But Monoid laws would require the state be unchanged. Hope that makes sense.

armanbilge commented 10 months ago

an opportune time to fix the trait hierarchy, but Im worried @rossabaker will roll his eyes knowingly and mutter something about "changes that break existing code would not be good" ;)

We should be able to add a "ConcurrentStateful" to the hierarchy compatibly, without breaking anything. It is effectively a weaker form of Stateful and will essentially need to replicate the Ref API to enable atomic updates. Then, Stateful can extend "ConcurrentStateful" with default implementations based on its existing methods.

I can put up a PR with that concept.


The difference is that if I later write out the empty value of the Monoid, I'd still expect the state to be updated. But Monoid laws would require the state be unchanged. Hope that makes sense.

Ah, yes indeed!

benhutchison commented 10 months ago

We should be able to add a "ConcurrentStateful" to the hierarchy compatibly, without breaking anything. It is effectively a weaker form of Stateful and will essentially need to replicate the Ref API to enable atomic updates. Then, Stateful can extend "ConcurrentStateful" with default implementations based on its existing methods.

I can put up a PR with that concept.

I for one would really welcome this reform. However, could we consider doing it consistently across Stateful and Ask?

ConcurrentAsk would permit reading an unstable value, or put another way, what one gets from reading but not modifying aConcurrentStateful.

Edited. While I was originally proposing a ConcurrentTell, I'm just not sure anyone would care, and its behavior is hard to define.