Post

I just want to access one file

How do I enable a function to read and write to just one file? This post explores some options I’ve considered.

I was writing a program that needed to read and write a timestamp from a file in order to ignore flake’y programs. I could have gone classic imperative programming style and wrote functions like the following:

1
2
3
ignoreFlakes :: FilePath -> IO ()
ignoreFlakes markFp = do
  -- Just do read/writes on markFp

That however wouldn’t be the best way to write ignoreFlakes, because it has a few disadvantages:

  • The interface doesn’t communicate what is being done to markFp. We only need to access a timestamp. We don’t need full I/O capabilities.
  • Testing is harder and it requires medium-sized tests: our test relies on the filesystem.

Object/interface style

It is a good practice to create minimal interfaces, so I started thinking about idiomatic ways to represent the mark interface. I could have implemented an imperative class analogue:

1
2
3
4
data Mark = Mark {
  readMark :: IO UTCTime
  writeMark :: UTCTime -> IO ()
}

It still relies on the IO monad, but it no longer relies on the file system. I can use IORef to hold the timestamp in-memory.

Generalising required effects

To ditch the IO monad, I could have made the monad into a parameter:

1
2
3
4
5
6
-- m doesn't need to be a monad. It just needs to encode required effects
-- somehow.
data Mark m = Mark {
  readMark :: m UTCTime
  writeMark :: UTCTime -> m ()
}

That could have made it possible to use the state monad (State UTCTime) to implement an in-memory mark. However, it’s hard to see for me how callers could be written generically to use such a mark. Perhaps they would also need to accept the monad evaluation function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- Callers could accept an evaluation function
ignoreFlakes :: (m a -> IO a) -> Mark m -> IO ()
ignoreFlakes eval mark = do
  ts <- eval $ readMark mark
  --
  eval $ writeMark mark ts'

-- Callers could run only specific Marks
ignoreFlakes' :: Mark IO -> IO ()

-- Callers that don't need special effects could just run in the monad
foo :: (Monad m) => Mark m -> m a
foo eval mark = do
  ts <- readMark mark
  --
  writeMark mark ts'
  --

mkFileMark :: FilePath -> Mark IO
mkInMemoryMark :: Mark (State UTCTime)

ignoreFlakes id (mkFileMark fp) :: IO ()
ignoreFlakes (runState initTs) mkInMemoryMark :: IO ()

It’s not the worst option, but the IO-version seems more ergonomical.

Self-made monad

The approach presented so far is very much like an interface in OO programming. Are there more FP approaches? Haskell is big on monads to encode effects.

LimitedIO monad

One thing we could do is create a limitted version of the IO monad. Monads such as IO or ST expose read and write functions to files or refs given a handle, so in my case there could be a monad that exposes reads/writes to either a mark container or any mark containers identified by a handle.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
module MarkOnlyIO (
  MarkOnlyIO,
  runMarkOnlyIO,
  Mark,
  readMark,
  writeMark,
  createMarkFromFile,
  createMarkFromIORef
) where
data MarkOnlyIO a = MarkOnlyIO (IO a)
instance Monad MarkOnlyIO

data Mark = Mark {
  readMark' :: IO UTCTime
  writeMark' :: UTCTime -> IO ()
}

runMarkOnlyIO :: MarkOnlyIO a -> IO a
readMark :: Mark -> MarkOnlyIO UTCTime
writeMark :: UTCTime -> MarkOnlyIO ()

createMarkFromFile :: FilePath -> Mark
createMarkFromIORef :: IORef UTCTime -> Mark
1
ignoreFlakes :: Mark -> MarkOnlyIO ()

The limited IO approach is more verbose, but limits the potential power of ignoreFlakes, which is safer.

This approach is quite uncomposable, we can’t easily compose this with other effects or monads. The only thing we can do with MarkOnlyIO is evaluate it to IO.

MarkM monad?

Maybe we can create MarkM monad with readMark :: Something -> MarkM m UTCTime. We could possibly have a free monad, with ReadMark, WriteMark constructors, but it doesn’t seem possible to create a credible MarkF functor. How would an fmap on ReadMark look like?

Extensible Effects

The problem with creating a functor MarkF for a free monad can be overcome by using Polysemy as the free effect system doesn’t require effects to be functors. In fact, limited-resource access is one of the prototypical use-cases of effects.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
data MarkFile m a where
  ReadMarkFile :: MarkFile m UTCTime
  WriteMarkFile :: UTCTime -> MarkFile m ()

makeSem ''MarkFile

runMarkFileInMemory :: Sem (MarkFile : r) a -> Sem (PS.State UTCTime : r) a
runMarkFileInMemory =
  reinterpret
    ( \case
        ReadMarkFile -> PS.get
        WriteMarkFile content -> PS.put content
    )

runMarkFileOnDisk :: (Member (Embed IO) r)
                  => Sem (MarkFile : r) a
                  -> Sem (Input FilePath : r) a
runMarkFileOnDisk =
  reinterpret
    ( \case
        ReadMarkFile -> do
          filename <- P.input
          embed $ readFile filename
        WriteMarkFile content -> do
          filename <- P.input
          embed $ writeFile filename content
    )

I implemented it at GitHub.

The extensible effects approach is neat. I don’t think it can be approved upon.

We avoid defining the effects required for the MarkFile, they are encoded in interpreters. Same applies for encoding the source. This approach simplifies callers that need a single mark file: ignoreFlakes :: (Member MarkFile r) => Sem r ().

Conclusion

The plain object/interface style or the extensible effects approach are the only approaches that seem credible and would result in clean code. The object/interface approach is not polymorphic, but it gets the primary job of turning single-file manipulation into a visible dependency done. The fancy monad solutions considered in-between all have deficiencies (primarily in being hard to compose) that are solved by extensible effects.

This post is licensed under CC BY 4.0 by the author.