dep-t (empty) → 0.1.0.0
raw patch · 7 files changed
+608/−0 lines, 7 filesdep +basedep +dep-tdep +mtlsetup-changed
Dependencies added: base, dep-t, mtl, rank2classes, tasty, tasty-hunit, template-haskell, transformers, unliftio-core
Files
- CHANGELOG.md +5/−0
- LICENSE +30/−0
- README.md +194/−0
- Setup.hs +2/−0
- dep-t.cabal +42/−0
- lib/Control/Monad/Dep.hs +146/−0
- test/tests.hs +189/−0
+ CHANGELOG.md view
@@ -0,0 +1,5 @@+# Revision history for dep-t++## 0.1.0.0 -- YYYY-mm-dd++* First version. Released on an unsuspecting world.
+ LICENSE view
@@ -0,0 +1,30 @@+Copyright (c) 2021, Daniel Diaz++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++ * Redistributions of source code must retain the above copyright+ notice, this list of conditions and the following disclaimer.++ * Redistributions in binary form must reproduce the above+ copyright notice, this list of conditions and the following+ disclaimer in the documentation and/or other materials provided+ with the distribution.++ * Neither the name of Daniel Diaz nor the names of other+ contributors may be used to endorse or promote products derived+ from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,194 @@+# dep-t++`DepT` is a+[ReaderT](http://hackage.haskell.org/package/mtl-2.2.2/docs/Control-Monad-Reader.html)-like+monad transformer for dependency injection.++The difference with `ReaderT` is that `DepT` takes an enviroment whose type is+parameterized by `DepT` itself.++## Rationale++To achieve dependency injection in Haskell, a common solution is to build a+record of functions and pass it to the program logic some variant of+[`ReaderT`](http://hackage.haskell.org/package/mtl-2.2.2/docs/Control-Monad-Reader.html).++Let's start by defining some auxiliary typeclasses to extract functions from an+environment record:++ type HasLogger :: Type -> (Type -> Type) -> Constraint+ class HasLogger r m | r -> m where+ logger :: r -> String -> m ()++ type HasRepository :: Type -> (Type -> Type) -> Constraint+ class HasRepository r m | r -> m where+ repository :: r -> Int -> m ()++We see that the type of the record determines the monad in which the effects take place.++Let's define a monomorphic record with effects in `IO`:++ type EnvIO :: Type+ data EnvIO = EnvIO+ { _loggerIO :: String -> IO (),+ _repositoryIO :: Int -> IO ()+ }++ instance HasLogger EnvIO IO where+ logger = _loggerIO++ instance HasRepository EnvIO IO where+ repository = _repositoryIO++Record-of-functions-in-IO is a simple technique which works well in many+situations. There are even [specialized+libraries](http://hackage.haskell.org/package/rio) that support it.++Here's a function which obtains its dependencies from the environment record:++ _mkControllerIO :: (HasLogger e IO, HasRepository e IO) => Int -> ReaderT e IO Int+ _mkControllerIO x = do+ doLog <- asks logger+ liftIO $ doLog "I'm going to insert in the db!"+ insert <- asks repository+ liftIO $ insert x+ return $ x * x++That's all and well, but there are two issues that bug me:++- What if the repository function needs access to the logger, too? The+ repository lives in the environment record, but isn't aware of it. That means+ it can't use the `HasLogger` typeclass for easy and convenient dependency+ injection. Why privilege the controller in such a way?++ In a sufficiently complex app, the diverse functions that comprise it will be+ organized in a big+ [DAG](https://en.wikipedia.org/wiki/Directed_acyclic_graph) of dependencies.+ And it would be nice if all the functions taking part in dependency injection+ were treated uniformly; if all of them had access to (some view of) the+ environment record.++- We might want to write code that is innocent of `IO` and polymorphic over the+ monad, to ensure that the program logic can't do some unexpected missile+ launch, or to allow testing our app in a "pure" way. ++Let's parameterize our environment by a monad: ++ type Env :: (Type -> Type) -> Type+ data Env m = Env+ { _logger :: String -> m (),+ _repository :: Int -> m (),+ _controller :: Int -> m Int+ }+ -- helper from the "rank2classes" package+ $(Rank2.TH.deriveFunctor ''Env)++ instance HasLogger (Env m) m where+ logger = _logger++ instance HasRepository (Env m) m where+ repository = _repository++Notice that the controller function is now part of the environment. No+favorites here!++The following implementation of the logger function has no dependencies besides+`MonadIO`:++ mkStdoutLogger :: MonadIO m => String -> m ()+ mkStdoutLogger msg = liftIO (putStrLn msg)++But look at this implementation of the repository function. It gets hold of the+logger through `HasLogger`:++ mkStdoutRepository :: (MonadReader e m, HasLogger e m, MonadIO m) => Int -> m ()+ mkStdoutRepository entity = do+ doLog <- asks logger+ doLog "I'm going to write the entity!"+ liftIO $ print entity++And here's the controller:++ mkController :: (MonadReader e m, HasLogger e m, HasRepository e m) => Int -> m Int+ mkController x = do+ doLog <- asks logger+ doLog "I'm going to insert in the db!"+ insert <- asks repository+ insert x+ return $ x * x++Now, lets choose `IO` as the base monad and assemble an environment record:++ envIO :: Env (DepT Env IO)+ envIO =+ let _logger = mkStdoutLogger+ _repository = mkStdoutRepository+ _controller = mkController+ in Env {_logger, _repository, _controller}++Not very complicated, except... what is that weird `DepT Env IO` doing there in+the signature? ++Well, that's the whole reason this library exists. Trying to use a `ReaderT+(Env something) IO` to parameterize `Env` won't fly; you'll get weird "infinite+type" kind of errors because the `Env` needs to be parameterized with the monad+that provides the `Env` environment. So I created the `DepT` newtype over+`ReaderT` to mollify the compiler.++## How to embed environments into other environments?++Sometimes it might be convenient to nest an environment into another one,+basically making it a field of the bigger environment:++ type BiggerEnv :: (Type -> Type) -> Type+ data BiggerEnv m = BiggerEnv+ { _inner :: Env m,+ _extra :: Int -> m Int+ }+ $(Rank2.TH.deriveFunctor ''BiggerEnv)++When constructing the bigger environment, we have to tweak the monad parameter+of the smaller one, to make the types match. This can be done with the+`zoomEnv` function:++ biggerEnvIO :: BiggerEnv (DepT BiggerEnv IO)+ biggerEnvIO =+ let _inner' = zoomEnv (Rank2.<$>) _inner envIO+ _extra = pure+ in BiggerEnv {_inner = _inner', _extra}++We need to pass as the first parameter of `zoomEnv` a function that tweaks the+monad parameter of `Env` using a natural transformation. We can write such a+function ourselves, but here we are using the function generated for us by the+[rank2classes+TH](http://hackage.haskell.org/package/rank2classes-1.4.1/docs/Rank2-TH.html#v:deriveFunctor).++## How to use "pure fakes" during testing?++The [test suite](./test/tests.hs) has an example of using a `Writer` monad for+collecting the outputs of functions working as ["test+doubles"](https://martinfowler.com/bliki/TestDouble.html).++## Caveats++The structure of the `DepT` type might be prone to trigger a [known infelicity+of the GHC+simplifier](https://twitter.com/DiazCarrete/status/1350116413445439493).++## Links++- This library was extracted from my answer to [this Stack Overflow+ question](https://stackoverflow.com/a/61782258/1364288).++- The implementation of `mapDepT` was teased out in [this other SO question](https://stackoverflow.com/questions/65710657/writing-a-zooming-function-for-a-readert-like-monad-transformer).++- I'm unsure of the relationship between `DepT` and the technique described in+ [Adventures assembling records of+ capabilities](https://discourse.haskell.org/t/adventures-assembling-records-of-capabilities/623). ++ It seems that, with `DepT`, functions in the environment obtain their+ dependencies anew every time they are invoked. If we change a function in the+ environment record, all other functions which depend on it will be affected+ in subsequent invocations. I don't think this happens with "Adventures..." at+ least when changing an already "assembled" record.+
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ dep-t.cabal view
@@ -0,0 +1,42 @@+cabal-version: 3.0++name: dep-t+version: 0.1.0.0+synopsis: Reader-like monad transformer for dependency injection.+description: Put all your functions in the environment record! Let all+ your functions read from the environment record! No favorites!+-- bug-reports:+license: BSD-3-Clause+license-file: LICENSE+author: Daniel Diaz+maintainer: diaz_carrete@yahoo.com+category: Control+extra-source-files: CHANGELOG.md, README.md++source-repository head+ type: git+ location: https://github.com/danidiaz/dep-t.git++common common+ build-depends: base >=4.10.0.0 && < 5,+ transformers ^>= 0.5.0.0,+ mtl ^>= 2.2,+ unliftio-core ^>= 0.2.0.0,+ default-language: Haskell2010++library+ import: common+ exposed-modules: Control.Monad.Dep+ hs-source-dirs: lib ++test-suite dep-t-test+ import: common+ type: exitcode-stdio-1.0+ hs-source-dirs: test+ main-is: tests.hs+ build-depends: + dep-t, + rank2classes ^>= 1.4.1,+ template-haskell,+ tasty >= 1.3.1,+ tasty-hunit >= 0.10.0.2,
+ lib/Control/Monad/Dep.hs view
@@ -0,0 +1,146 @@+{-# LANGUAGE DeriveFunctor #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE GeneralizedNewtypeDeriving #-}+{-# LANGUAGE ImportQualifiedPost #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE StandaloneDeriving #-}+{-# LANGUAGE StandaloneKindSignatures #-}+{-# LANGUAGE UndecidableInstances #-}++-- |+-- This package provides 'DepT', a monad transformer similar to 'ReaderT'.+--+-- The difference is that the environment of 'DepT' must be parameterized by+-- @DepT@'s own monad stack.+--+-- There's a function 'withDepT' which is analogous to 'withReaderT'.+--+-- There's no analogue of 'mapReaderT' however. This means you can't tweak+-- the monad below the 'DepT' with a natural transformation.+module Control.Monad.Dep+ ( DepT (DepT),+ runDepT,+ toReaderT,+ withDepT,+ zoomEnv,+ )+where++import Control.Applicative+import Control.Monad.Cont.Class+import Control.Monad.Error.Class+import Control.Monad.IO.Unlift+import Control.Monad.Reader+import Control.Monad.State.Class+import Control.Monad.Trans.Class+import Control.Monad.Trans.Identity+import Control.Monad.Writer.Class+import Control.Monad.Zip+import Data.Kind (Type)++-- |+-- A monad transformer which adds a read-only environment to the given monad.+-- The environment type must be parameterized with the transformer's stack.+--+-- The 'return' function ignores the environment, while @>>=@ passes the+-- inherited environment to both subcomputations.+type DepT ::+ ((Type -> Type) -> Type) ->+ (Type -> Type) ->+ Type ->+ Type+newtype DepT env m r = DepT {toReaderT :: ReaderT (env (DepT env m)) m r}+ deriving+ ( Functor,+ Applicative,+ Alternative,+ Monad,+ MonadFix,+ MonadFail,+ MonadZip,+ MonadPlus,+ MonadCont,+ MonadIO,+ MonadUnliftIO,+ MonadReader (env (DepT env m))+ )++instance MonadTrans (DepT env) where+ lift = DepT . lift++deriving instance MonadState s m => MonadState s (DepT env m)++deriving instance MonadWriter w m => MonadWriter w (DepT env m)++deriving instance MonadError e m => MonadError e (DepT env m)++-- |+-- Runs a 'DepT' action in an environment.+runDepT :: DepT env m r -> env (DepT env m) -> m r+runDepT = runReaderT . toReaderT++-- |+-- Changes the environment of a 'DepT', for example making the 'DepT' work in+-- a "bigger" environment than the one in which was defined initially.+--+-- The scary first parameter is a function that, given a natural+-- transformation of monads, changes the monad parameter of the environment+-- record. This function can be defined manually for each environment record,+-- or it can be generated using TH from the "rank2classes" package.+withDepT ::+ forall small big m a.+ Monad m =>+ -- | rank-2 map function+ ( forall p q.+ (forall x. p x -> q x) ->+ small p ->+ small q+ ) ->+ -- | get a small environment from a big one+ (forall t. big t -> small t) ->+ DepT small m a ->+ DepT big m a+withDepT mapEnv inner (DepT (ReaderT f)) =+ DepT+ ( ReaderT+ ( \big ->+ let small :: small (DepT small m)+ -- we have a big environment at hand, so let's extract the+ -- small environment, transform every function in the small+ -- environment by supplying the big environment and, as a+ -- finishing touch, lift from the base monad m so that it+ -- matches the monad expected by f.+ small = mapEnv (lift . flip runDepT big) (inner big)+ in f small+ )+ )++-- |+-- Makes the functions inside a small environment require a bigger environment.+--+-- This can be useful if we are encasing the small environment as a field of+-- the big environment, ir order to make the types match.+--+-- The scary first parameter is a function that, given a natural+-- transformation of monads, changes the monad parameter of the environment+-- record. This function can be defined manually for each environment record,+-- or it can be generated using TH from the "rank2classes" package.++-- For the reason for not inlining, see https://twitter.com/DiazCarrete/status/1350116413445439493+{-# NOINLINE zoomEnv #-}+zoomEnv ::+ forall small big m a.+ Monad m =>+ -- | rank-2 map function+ ( forall p q.+ (forall x. p x -> q x) ->+ small p ->+ small q+ ) ->+ -- | get a small environment from a big one+ (forall t. big t -> small t) ->+ small (DepT small m) ->+ small (DepT big m)+zoomEnv mapEnv inner = mapEnv (withDepT mapEnv inner)
+ test/tests.hs view
@@ -0,0 +1,189 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE FunctionalDependencies #-}+{-# LANGUAGE ImportQualifiedPost #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE NamedFieldPuns #-}+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE StandaloneKindSignatures #-}+{-# LANGUAGE TemplateHaskell #-}++module Main (main) where++import Control.Monad.Dep+import Control.Monad.Reader+import Control.Monad.Writer+import Data.Kind+import Rank2 qualified+import Rank2.TH qualified+import Test.Tasty+import Test.Tasty.HUnit+import Prelude hiding (log)++-- Some helper typeclasses.+--+-- Has-style typeclasses can be provided to avoid depending on concrete+-- environments.+-- Note that the environment determines the monad.+type HasLogger :: Type -> (Type -> Type) -> Constraint+class HasLogger r m | r -> m where+ logger :: r -> String -> m ()++type HasRepository :: Type -> (Type -> Type) -> Constraint+class HasRepository r m | r -> m where+ repository :: r -> Int -> m ()++-- Some possible implementations.+--+-- An implementation of the controller, done programming against interfaces+-- (well, against typeclasses).+-- Polymorphic on the monad.+mkController :: (MonadReader e m, HasLogger e m, HasRepository e m) => Int -> m Int+mkController x = do+ doLog <- asks logger+ doLog "I'm going to insert in the db!"+ insert <- asks repository+ insert x+ return $ x * x++-- A "real" logger implementation that interacts with the external world.+mkStdoutLogger :: MonadIO m => String -> m ()+mkStdoutLogger msg = liftIO (putStrLn msg)++-- A "real" repository implementation +mkStdoutRepository :: (MonadReader e m, HasLogger e m, MonadIO m) => Int -> m ()+mkStdoutRepository entity = do+ doLog <- asks logger+ doLog "I'm going to write the entity!"+ liftIO $ print entity++-- The traces we accumulate from the fakes during tests+type TestTrace = ([String], [Int])++-- A "fake". A pure implementation for tests.+mkFakeLogger :: MonadWriter TestTrace m => String -> m ()+mkFakeLogger msg = tell ([msg], [])++-- Ditto.+mkFakeRepository :: (MonadReader e m, HasLogger e m, MonadWriter TestTrace m) => Int -> m ()+mkFakeRepository entity = do+ doLog <- asks logger+ doLog "I'm going to write the entity!"+ tell ([], [entity])+++--+--+-- Here we define a monomorphic environment working on IO+type EnvIO :: Type+data EnvIO = EnvIO+ { _loggerIO :: String -> IO (),+ _repositoryIO :: Int -> IO ()+ }++instance HasLogger EnvIO IO where+ logger = _loggerIO++instance HasRepository EnvIO IO where+ repository = _repositoryIO++-- In the monomorphic environment, the controller function lives "separate",+-- having access to the logger and the repository through the ReaderT+-- environment.+--+-- The question is: the repository function *also* needs to know about the+-- logger! Shouldn't it be aware of the ReaderT environment as well? Why+-- privilege the controller function in such a manner?+--+-- In a sufficiently complex app, the diverse functions will form a DAG of+-- dependencies between each other. So it would be nice if the functions were+-- treated uniformly, all having access to (views of) the environment record.+_mkControllerIO :: (HasLogger e IO, HasRepository e IO) => Int -> ReaderT e IO Int+_mkControllerIO x = do+ doLog <- asks logger+ liftIO $ doLog "I'm going to insert in the db!"+ insert <- asks repository+ liftIO $ insert x+ return $ x * x++--+--+-- Here we define some polymorphic environments, which are basically+-- records-of-functions parameterized by an effect monad.+type Env :: (Type -> Type) -> Type+data Env m = Env+ { _logger :: String -> m (),+ _repository :: Int -> m (),+ _controller :: Int -> m Int+ }+$(Rank2.TH.deriveFunctor ''Env)++-- If our environment is parmeterized by the monad m, then logging is done in+-- m.+instance HasLogger (Env m) m where+ logger = _logger++instance HasRepository (Env m) m where+ repository = _repository++-- This bigger environment is for demonstrating how to "nest" environments.+type BiggerEnv :: (Type -> Type) -> Type+data BiggerEnv m = BiggerEnv+ { _inner :: Env m,+ _extra :: Int -> m Int+ }+$(Rank2.TH.deriveFunctor ''BiggerEnv)++--+--+-- Creating environment values and commiting to a concrete monad.+--+-- This is the first time DepT is used in this module.+-- Note that it is only here where we settle for a concrete monad for the+-- polymorphic environments.+env :: Env (DepT Env (Writer TestTrace))+env =+ let _logger = mkFakeLogger+ _repository = mkFakeRepository+ _controller = mkController+ in Env {_logger, _repository, _controller}++-- An IO variant+envIO :: Env (DepT Env IO)+envIO =+ let _logger = mkStdoutLogger+ _repository = mkStdoutRepository+ _controller = mkController+ in Env {_logger, _repository, _controller}++biggerEnv :: BiggerEnv (DepT BiggerEnv (Writer TestTrace))+biggerEnv =+ let -- We embed the small environment into the bigger one using "zoomEnv"+ -- and the rank-2 fmap that allows us to change the monad which+ -- parameterized the environment.+ --+ -- _inner' = (Rank2.<$>) (withDepT (Rank2.<$>) inner) env,+ _inner' = zoomEnv (Rank2.<$>) _inner env+ _extra = pure+ in BiggerEnv {_inner = _inner', _extra}++biggerEnvIO :: BiggerEnv (DepT BiggerEnv IO)+biggerEnvIO =+ let _inner' = zoomEnv (Rank2.<$>) _inner envIO+ _extra = pure+ in BiggerEnv {_inner = _inner', _extra}++expected :: TestTrace+expected = (["I'm going to insert in the db!", "I'm going to write the entity!"], [7])++tests :: TestTree+tests =+ testGroup+ "All"+ [ testCase "hopeThisWorks" $+ assertEqual "" expected $+ execWriter $ runDepT ((_controller . _inner $ biggerEnv) 7) biggerEnv+ ]++main :: IO ()+main = defaultMain tests