Haskell pure functions and files

Viewed 164

I've read this article on Haskell's approach to IO: https://wiki.haskell.org/IO_inside

I understand how getChar works, but I don't know how to make the following function pure

getFile :: String -> File

where String is filename and File is some defined type which can be used to futher operate on a file.

To my mind this function cannot be correct as it breaks the rule: "if a function's result changes, it should be because it's arguments have changed." (quote from the article above)

A file can change on the disk and File type can therefore be different for example it can hold a different permission set.

What am I getting wrong?

1 Answers

You are correct, you can't make a getFile that has the behaviour you want, and the type String -> File, and is pure. The simple answer to your question is that because that can't be done, Haskell doesn't do that.

But Haskell doesn't need to do that. We do of course need a way of identifying a file on disk by name and opening it (and we'll assume we can represent an open file by your File type). That's a real need that Haskell needs to support. But there's simply no reason it has to have the specific type String -> File; since that's impossible we should make something else with a different type, that can still fulfil this programming need.

You say you understand how getChar works, but the answer to how Haskell provides something to address the need of your getFile is exactly the same as how it provides something to address the need of getChar.

In an imperative language, getChar would be a thing that takes no parameters and gives you a Char, imperatively. In Haskell a "thing that takes no parameters and gives you a Char" translates to just the type Char. But getChar obviously can't be Char, since that implies it is one particular Char. Instead we give it the type IO Char. Things of type IO a are actions that will produce an a, not the a itself. So getChar :: IO Char is an action that will produce a Char. It doesn't have to be one single Char for all time, rather it is one single action, but each time we run the action we get the next Char from the input stream.

Similarly, getFile in Haskell should have type String -> IO File. It is a "factory for file-opening actions"; once we've given it the name of the file the resulting action knows what name it should look up, but only when the action is actually run will it go look at the disk and see whether there is a file there. getFile doesn't have to always return the same "open file" for each input String, it has to return the same procedure for opening a file of that name. The procedure is always the same, it's just what happens when you try to carry it out that is affected by the disk state.

Haskell the language doesn't provide any way of actually running these IO actions. Haskell code is pure, and purely builds up IO actions without depending on the external world. The runtime system of Haskell can execute them, and it does so to the main :: IO a action (and also if you enter them at the GHCi prompt). But how that happens is not part of the Haskell language, any more than Java defines exactly how system calls work.

That's all there is to it. Things that would be side-effecting Input1 -> Input2 -> Output in imperative languages can't have that type in Haskell, so we use different type to represent them; they end up as Input1 -> Input2 -> IO Output1.

Note I haven't mentioned monads; IO is a monad, and that provides a lot of handy tools for working with IO computations, but it's IO itself that lets us work with external side effects because of the special handling in the Haskell runtime, not the fact that it's a monad.


Just for completeness: I noticed that article is using the "world-passing" explanation for IO, which is actually equivalent to the "IO = opaque action" story I used above, but uses a different metaphor.2

But if you understood the story as explained in that article, you would know that a way to make a pure function that can do the job of an impure function is to add an extra argument and an extra return value. The article calls the type of this value Val or RealWorld, but the point is it's abstract. You only ever get one by calling an IO action, which required you to pass in a previous world. The runtime system supplies the first one to main, so you can thread it through all your calls. In-memory it is an empty value, but it can be said to represent the state of the entire universe: the state of every disk, all the network packets in flight, all the humans poised to strike a key or move a mouse, etc; everything.

So getChar's impure Char type becomes World -> (Char, World); you give it the state of the universe just before you read a character from the input stream, and it gives you back the next character and also the state the world will have just before your next IO action. Inside the Haskell language there is no explanation of how it "computes" what the next universe should be, we just have to assume that it can (just like with the action metaphore we have no explanation for how actions are carried out, we just must assume that the runtime will be able to execute them). Outside the language of course we don't have to do anything to "compute" the universe, because we can just use the passage of time in the real universe to always have a universe available that is consistent with the sequence of universes the program requires.

Similarly, to represent the impure getFile :: String -> File we simply add an extra argument and return value, making it getFile :: String -> World -> (File, World). Exactly the same way we handled getChar.

One big gotcha I haven't mentioned is that even though the RealWorld is abstract so we can't peek inside it or make one up, once the runtime gives us one we could in principle store it and reuse it. That's not how the real universe works, so we have to prevent that. Haskell does so by not actually exposing the extra world arguments to normal code; instead we have the IO type to represent "taking and returning a world on the side". IO is abstract, so you can't look inside it to get access to the World. Instead the builtin libraries expose functions for sequencing and combining things of type IO a for you, and those functions are very careful to make it impossible to accidentally use a World value twice. (There are other languages that have the type system features to ensure that a World value must be used exactly once, and therefore use this "world-passing" system for IO in a way that does expose the World values to user code)

It's still not very relevant on this picture that IO is a monad. That's a very convenient way to provide those builtin functions for combining and sequencing IO values, but it could just as well be done without using the concept of a monad at all.


1 This process isn't really so different from the kind of modelling OO programmers do all the time without batting an eyelid. For example, a filesystem is not an instance of a class; it isn't a thing that exists in memory and has methods. But that doesn't stop OO programmers making a class to represent a filesystem, such that you can call methods on instances of that class which will be translated into lower level operations on the filesystem.

2 The actual details of the real implementation in GHC is messy and complicated; it's not exactly either of these two explanations, but also irrelevant to actually programming in Haskell.

Related