Haskell - Look Ma! No Concrete Implementation!
Ken Aguilar
Posted on August 9, 2020
This is my post about tagless final encoding. There are many like it, but this one is mine. My tagless final encoding post is my best friend. It is my life. I must master it as I must master my life.
I can't think of a good intro so I modified the Rifleman's Creed. Anyway, the tagless final encoding technique has been very helpful in structuring my programs without being burdened about dependencies so much, because of this I can change dependencies and mostly don't have to re-structure my program. This technique also makes it almost effortless to do unit testing.
Just like my other posts let's do a contrived example.
This is our user story.
- A command line tool that takes a file path to a text file that contains some text.
- The content of the input file will be processed, and the text content will be capitalized.
- The processed text content will then be printed to an output file.
Let's start by creating the representation of our application.
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
module AppM where
newtype AppM a
= AppM
{ runAppM :: IO a
} deriving newtype ( Functor, Applicative, Monad, MonadIO )
Then, let's define the capabilities of the app based on the made up user story. First, the cli capability.
module Capability.CLI where
class Monad m => ManageCLI m where
parseCliCommand :: m Command
interpretCLI :: Command -> m ()
data Command = Command
{ commandInput :: Text
, commandOutput :: Text
} deriving ( Eq, Show )
Next, the capabalities to manipulate files in the file system.
module Capability.File where
class Monad m => ManageFile m where
getFileContent :: Text -> m Text
printContent :: Text -> Text -> m ()
Finally, the capability to manipulate text.
module Capability.TextContent where
class Monad m => ManageTextContent m where
capitalizeContent :: Text -> m Text
With all these capabilities, I think we are ready to assemble our program. So, let's go back to the AppM
module. We have to create instances but skip the implementation for now, and use undefined
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE InstanceSigs #-}
{-# LANGUAGE RecordWildCards #-}
module AppM where
import Capability.CLI
import Capability.File
import Capability.TextContent
newtype AppM a
= AppM
{ runAppM :: IO a
} deriving newtype ( Functor, Applicative, Monad, MonadIO )
startApp :: IO ()
startApp = runAppM $ interpretCLI =<< parseCliCommand
instance ManageCLI AppM where
parseCliCommand :: AppM Command
parseCliCommand = undefined
interpretCLI :: Command -> AppM ()
interpretCLI Command{..} = do
file <- getFileContent commandInput
content <- capitalizeContent file
printContent commandOutput content
instance ManageFile AppM where
getFileContent :: Text -> AppM Text
getFileContent filePath = undefined
printContent :: Text -> Text -> AppM ()
printContent outputPath content = undefined
instance ManageTextContent AppM where
capitalizeContent :: Text -> AppM Text
capitalizeContent content = undefined
As you can see we were able to assemble our program in interpretCLI
without any concrete implementations. When we provide the concrete implementations, we won't get so bogged down by thinking about the entire the program because we've already done that. We only have to think about the concrete implementation of individual capabilities. For example, we just need to think about how to retrieve a file, print a file, etc.
Proceeding to our concrete implementation we can pick optparse-applicative
as our cli utility and relude
as our prelude.
module AppM where
...
import Relude
import qualified Data.Text as T
import Options.Applicative
instance ManageCLI AppM where
parseCliCommand :: AppM Command
parseCliCommand = liftIO
$ execParser ( info ( helper <*> parseCommand ) fullDesc )
interpretCLI :: Command -> AppM ()
interpretCLI Command{..} = do
file <- getFileContent commandInput
content <- capitalizeContent file
printContent commandOutput content
instance ManageFile AppM where
getFileContent :: Text -> AppM Text
getFileContent filePath = readFileText ( T.unpack filePath )
printContent :: Text -> Text -> AppM ()
printContent outputPath content =
writeFileText ( T.unpack outputPath ) content
instance ManageTextContent AppM where
capitalizeContent :: Text -> AppM Text
capitalizeContent content = pure $ T.toUpper content
By using optparse-applicative
, here's the implementation of parseCommand
parseCommand :: Parser Command
parseCommand = Command
<$> strOption ( long "input" <> short 'i' <> metavar "INPUT_FILE" <> help "Input file" )
<*> strOption ( long "output" <> short 'o' <> metavar "OUTPUT_FILE" <> help "Output file" )
One obvious downside to this is the n^2
instance problem which Vasiliy Kevroletin mentions in his article.
That's it! We import our library code to the Main
module and execute the program.
module Main where
import Relude
import AppM
main :: IO ()
main = startApp
References
Purescript Halogen Realworld repository
Three Layer Cake by Matt Parson
Posted on August 9, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.