3 ms·
I agree that being able to reason about mutability and other effects is useful. However, that doesn’t necessarily imply an all-or-nothing approach where either
by Chris_Newton 6y ago
I agree that being able to reason about mutability and other effects is useful. However, that doesn’t necessarily imply an all-or-nothing approach where either you’re in a pure function or you can do anything with anything. In a sibling comment, gwd mentioned const in C, which is one example of something in between. Rust’s ownership semantics and borrow checker are another.
- pwm 6y ago> However, that doesn’t necessarily imply an all-or-nothing approach where either you’re in a pure function or you can do anything with anything. To elaborate this point I'd say that the most important practical use of all that "monad mumbo-jumbo" in Haskell is that you can tag your functions with what they can and can't do and then the type system tracks this for you: -- pure function f1 :: Text -> Int -- can fail f2 :: Text -> Maybe Int -- can read from some MyEnv record f3 :: Text -> Reader MyEnv Int -- can keep a set of bool as state around f4 :: Text -> State (Set Bool) Int etc... and of course to go nuclear: -- can do anything f5 :: Text -> IO Int The tracking part is that you can't call f5 from within f1, the type checker says no. It enforces separation between all these various effect boundaries. Also we don't have to stop here. A natural next step is defining exact effects one is after. For example say I want my function to be able to get some entity from a DB: -- any type that is an instance of Entity can identify itself by uuid class Entity e where identify :: e -> UUID -- The effect we want, ie. get an entity via its uuid class (Monad m, Entity e) => GetEntity e m where getEntityById :: UUID -> m (Maybe e) now we can say things like: data User = MkUser {uId :: UUID, uName :: Text} -- a User can identify itself instance Entity User where identify = uId -- as User is an Entity so we can get it via its uuid (if it exists) getUserById :: (GetEntity User m) => UUID -> m (Maybe User) getUserById = getEntityById Also notice how getUserById does not say anything about IO or a DB. All it states is that whatever context it will run in that context must know how to get a user via its uuid. You can then plug in whatever actual context you want, say: newtype Prod a = MkProd {unProd :: ReaderT ProdDB IO a} deriving newtype (Functor, Applicative, Monad, MonadReader ProdDB, MonadIO) -- get a user from a DB for real instance GetEntity User Prod where getEntityById :: UUID -> Prod (Maybe User) getEntityById eid = do ProdDB {..} <- ask -- query the DB, etc... or newtype Mock a = MkMock {unMock :: State (Map UUID User) a} deriving newtype (Functor, Applicative, Monad, MonadState (Map UUID User)) -- get a user from a mock DB instance GetEntity User Mock where getEntityById :: UUID -> Mock (Maybe User) getEntityById eid = gets (Map.lookup eid) All in all the programmer have fine-grained control over what various parts of their code can or can't do.
- Chris_Newton 6y agoThe trick seems to be how to implement this kind of idea in a composable way, so we can still have a clear, modular design and write generic, reusable code even in the presence of several different types of effect. IMHO, the research into type and effect systems in recent years has been promising, but also shows that this is not an easy problem to solve. I’m hoping that in a few more years we might see a new generation of programming languages that incorporate this kind of “effect safety” as easily and naturally as many developers use const and qualifiers and resource management idioms today.
- sullyj3 6y agoUnison's ability system (an implementation of algebraic effects) is looking promising as an ergonomic method of tracking effects.
- pwm 6y agoI haven't yet looked much into effect systems so can't really talk about them but I think even with something like MTL you can achieve a decent level composability/modularity where you build up a library of capabilities that you can then use to pick and choose effects from, eg.: type FooContext m = (GetTime m, GenUUID m, Log m, Auth m, ReadDB m, WriteS3 m, BarApi m, etc...) foo :: (FooContext m) => UUID -> m Foo The main downside is ergonomics, ie. the amount of boilerplate needed, but personally I don't think that's a high price to pay.