11. Working with files
So far, we've worked with data that we've defined ourselves in the iex
prompt. This has been incredibly
handy because it has allowed us (and Izzy) to experiment with Elixir's features. When we program in the real world,
data typically comes from sources external to our code. One of those sources is files from the file system.
In this chapter, we'll look at how we can read existing files, create new ones and even delete files using functions that are built-in to Elixir.
Reading a file
The first thing we're going to take a look at here is how to read a file's contents and then use Elixir to do something with those contents.
Certain files may contain data that we can use in our Elixir programs. Elixir can read any file you throw at it. In this chapter, just for simplicity, we're going to stick to a single file format: text files.
A text file is just a file with a bunch of words in it. You probably have a file like that on your computer right
now. You could open one of these files in your text editor and read what was written in that file. If for some
weird reason you don't have one of these, you can take this content and put it in a new file called
haiku.txt
:
rixilE evol I
nrael ot ysae os si tI
edoc lanoitcnuf taerG
I've put this file in a directory on my computer called "Joy of Elixir":
And here's the content of that file that I see when I double-click on it:Izzy squints at the haiku. "Um... This haiku looks a little... backwards". Yes, Izzy! Each line is written backwards! The haiku should read:
I love Elixir
It is so easy to learn
Great functional code
We could go in to the file and correct this ourselves, but we're programmers learning a super-duper-awesome programming language and by golly if we aren't going to use it to solve every problem we come across. To solve this problem, we're going to use the power of Elixir.
What we're going to do is to read this haiku into Elixir, and then we're going to reverse the order of words in each line. I make it sound so easy, and that's because it is. Really!
To read this file, we'll need to open an iex
prompt where the file is, and then we can use this code
to read it:
iex> File.read("haiku.txt")
This code tells Elixir to read a file by calling the File.read/1
function. Calling this function will
give us the following output:
{:ok, "I evol rixilE\nnrael ot ysae os si tI\nedoc lanoitcnuf taerG\n"}
In this output, there's not one but two new concepts. You've gotten so far through the book that you're now so great at learning and that means I can introduce things rapid-fire and you'll pick 'em up with no effort.
The first of these is the curly brackets. Did you notice them? They wrap all of the output from the
File.read/1
. Did you notice that these curly brackets, unlike all the other curly brackets we've seen
before, are not prefixed with a percent-sign (%
)?
This particular concept in Elixir is called a tuple. You can think of tuples as a fixed-length list and
they're used to link a bunch of information together in a particular order. In this case, it's telling us that the
file operation was :ok
, and then it's giving us a string containing all the file's information.
Izzy pipes up: "What's that :ok
thing and why does it have a colon before it?". Good spot, Izzy. That
is called an atom
. We first saw these back in Chapter 4 when they
were used as keys in maps.
We've seen atoms since then, but here's a refresher: Atoms are used to provide informational messages, like in
this case. Atoms names are their values. This is unlike strings, maps and lists where we would assign to a
variable in order to give a meaningful name to. This atom is telling us that the operation we asked
File.read/1
to perform went "ok"; we were able to read the file successfully.
If we specified a file that wasn't present, File.read/1
would give us a different atom in that first
place:
iex> File.read("haiboo.txt")
{:error, :enoent}
This cryptic error message uses a tuple containing two atoms, :error
and
:enoent
. The first one is self-explanatory -- there was an error loading this file. The second one
gives us a non-regular-human-friendly error message: :enoent
. This is computer-lingo for "I couldn't
find that file you were looking for, sorry."
The best part about these tuples and atoms being returned from the File.read/1
call is that we can
use pattern matching (Chapter 6). If we want to only proceed if our
File.read/1
function executes successfully, we can pattern match like this:
iex> {:ok, contents} = File.read("haiku.txt")
Go ahead and try this code out in your iex
prompt. Also try it with the wrong path too and see what
happens. If you give it the wrong path, you should see it fail like this:
** (MatchError) no match of right hand side value: {:error, :enoent}
This error happens because we're telling Elixir to expect that the tuple returned from this call contains an
:ok
atom at the start, but it doesn't. Pattern matching can be a useful way of stopping your program
in its tracks like this.
Let's look back at the successful read:
iex> {:ok, contents} = File.read("haiku.txt")
You'll see exactly the same values come back as when we did the first File.read/1
invocation:
{:ok, "I evol rixilE\nnrael ot ysae os si tI\nedoc lanoitcnuf taerG\n"}
The difference is: this time, we've got the contents of the file in a contents
variable. Oooh, that
was sneaky! Let's get back to the task at hand: we still need to correct this haiku back to its proper form. We've
now got the contents of this file and we need to reverse each line. To do that, we need some way of splitting
apart the string so that we can process each line separately from each other line. To do this, we can use our old
friend, String.split/3
:
iex> contents |> String.split("\n", trim: true)
This code takes the string stored in contents
and passes it as the first argument to
String.split/3
. The other two arguments are: 2) the string "\n"
and 3) the option
trim: true
. The 2nd argument tells String.split/3
to split the string on the newline
characters (\n
), and the 3rd argument tells String.split/3
to remove any trailing space
at the end.
When this function does its thing, we'll see this output:
["rixilE evol I", "nrael ot ysae os si tI", "edoc lanoitcnuf taerG"]
Yay! We've now got a list of strings here. We need each string here to be reversed. Izzy sticks her hand up and
wiggles it around. "Ooh ooh ooh I know how to do this! String.reverse/1
!", she says, monospaced font
and all. Impressive. Yes, Izzy is correct! We can reverse a string by calling String.reverse/1
as we
first saw back in Chapter 8. We know we can do it with a single string
like this:
iex> "rixilE evol I" |> String.reverse
"I love Elixir
But we don't have a single string here, we have three strings within a list. But we have knowledge on our side. We
have special skills that we built up in Chapter 9, and with those special skills we know
that we can call Enum.map/2
to apply a function to multiple elements within a list. We've done
exactly this back in Chapter 9:
iex> Enum.map(cities, &String.capitalize/1)
So let's take our list, adapt this code a little bit to use the pipe operator and String.reverse/1
and reverse each string with this code:
iex> contents \
|> String.split("\n", trim: true) \
|> Enum.map(&String.reverse/1)
We're using a multi-line pipe operator chain here to accomplish what we want. Because we're in iex
,
we need to put a backslash (\
) on the end of every line to tell Elixir to treat all these lines as
one long line and one chain of operations. If we were writing code in an Elixir file, we wouldn't need to do this.
iex
is a little special in this regard. Another way we could've written the code is like this:
iex> contents |> String.split("\n", trim: true) |> Enum.map(&String.reverse/1)
But it is considered best practice to split long pipe chains up onto multiple lines when they're really long just to help with readability. Think of it as the same rule that applies when you're writing a book: you split logical breaks into separate paragraphs to make it easier for people to read here. We're applying almost that same rule to our Elixir code.
This code (in either the one-line or three-line form) will give us back a list of strings that now look like proper English:
["I love Elixir", "It is so easy to learn", "Great functional code"]
All we need to do now is to put the file back into a big string, which we can do with a new-to-us function called
Enum.join/2
:
iex> fixed_contents = contents \
|> String.split("\n", trim: true) \
|> Enum.map(&String.reverse/1) \
|> Enum.join("\n")
"I love Elixir\nIt is so easy to learn\nGreat functional code"
Excellent! We've now got our file's text around the right way. We did this with a combination of quite a few
functions and that is really demonstrating the repertoire of things that we know how to do with Elixir now. We
have used File.read/1
function to read this file's contents and to bring them into our Elixir code.
Once we have the contents there, we can do whatever we wish with them.
Before we move on, I want to show you another way that Elixir has for reading files.
Streaming a file
Elixir provides us at least one very clear way to read a file: File.read/1
. We know how this works.
But there is another function that Elixir gives us which can also be used to read files. That function is
called File.stream!/1
.
This function works by reading a file one line at a time. This is useful in cases where we might be
reading very large files. If we used File.read/1
to load a large file, it might take a long time for
Elixir to read it all in and it might take up a lot of our computer's resources. On the contrary,
File.stream!
can be used to read an entire file, but will read a file line-by-line.
We call this type of thing in programming parlance "eagerness" or "laziness". The File.read/1
function can be said to be eager: it eagerly loads the whole file without a care in the world if
we're going to use the whole lot or not. While the File.stream!/1
function is its more chilled out
cousin: it will lazily read a single line at a time from the file.
Think of it in terms of your favourite internet streaming service. When you stream from that service, you don't
have to wait for every single episode of your favourite Scandi Noir TV series to download. You don't even have to
wait for a single episode to fully download because it only sends through a small part of the episode at a time.
This is what File.stream/1
does: gives us the file line-by-line.
Ok, I think we've talked enough about what File.stream/1
does! It's time to see this in action. Let's
use this code to start reading our haiku.txt
file again:
iex> stream = File.stream!("haiku.txt")
%File.Stream{
line_or_bytes: :line,
modes: [:raw, :read_ahead, :binary],
path: "haiku.txt",
raw: true
}
This time, we're not given back the contents of the file. We're given back something we've never seen before: a struct.
We can think of structs as maps that follow a particular set of rules. Just like maps, structs have key-value
pairs, as we can see from the output here. If we removed the File.Stream
part of this output, the
syntax would be a map:
iex> %{
line_or_bytes: :line,
modes: [:raw, :read_ahead, :binary],
path: "haiku.txt",
raw: true
}
What makes a struct different here is that it is named and structs with the same name always have the
same keys. Every time we call File.stream!
, we'll see a File.Stream
struct come back
with exactly the same keys.
The keys in this particular struct tell Elixir what this file stream is all about. It tells Elixir that we're
going to read from the haiku.txt
(path
) file line-by-line (line_or_bytes
).
I won't go into what modes
and raw
are for here.
At this point, all we have this File.Stream
struct. Consider it as an "intent to read" the file. No
reading has occurred at this point yet. To trigger this reading, we can pipe this stream into any
Enum
method. For instance, we could trim all the newline characters (\n
) off each line
like this:
fixed_contents = stream \
|> Enum.map(&String.trim/1)
["rixilE evol I", "nrael ot ysae os si tI", "edoc lanoitcnuf taerG"]
That's a good start. We're not going to be able to peer into exactly what Elixir is doing here, so you'll have to
take my word for it that Elixir is now reading each line in this file one at a time and sending it through to
Enum.map/2
. The output from calling Enum.map/2
is good, but what we really need is a
list of sentences the right way around. We can flip these by piping the output again:
fixed_contents = stream \
|> Enum.map(&String.trim/1) \
|> Enum.map(&String.reverse/1)
["I love Elixir", "It is so easy to learn", "Great functional code"]
That's getting better! Now we need to join these back together again and put line breaks in between them with
Enum.join/2
again:
fixed_contents = stream \
|> Enum.map(&String.trim/1) \
|> Enum.map(&String.reverse/1) \
|> Enum.join("\n")
"I love Elixir\nIt is so easy to learn\nGreat functional code"
Excellent! We now have the fixed haiku in string form inside Elixir. We could get this through either
File.read/1
or File.stream!/1
. Normally, we would only use File.stream/1
if
the file was really long, but in this section we've used it for illustrative purposes and to prove that there's
more than one way to read a file in Elixir.
It would be really handy if we could take this string out of Elixir and put it into a new file so that nobody else has to read a backwards haiku. That's what we'll be looking at next!
Creating and writing to a new file
We now have the re-arranged contents of our haiku stored within a variable called fixed_contents
. If
you've lost this since the last section, you can use this code to get it back:
iex> fixed_contents = "haiku.txt" \
|> File.stream! \
|> Enum.map(&String.trim/1) \
|> Enum.map(&String.reverse/1) \
|> Enum.join("\n")
The pipe operator makes this code so much easier to read! Now that we most certain have the file contents stored
in Elixir and put the right away around, we want to write these contents to a new file. We saw that we could read
a file with File.read/1
, so it would make sense for there to be a File.write
function
too. It definitely does exist, and it's called File.write/2
. It takes two arguments: 1) the path we
want to write to 2) the contents that we want to put in the file. It works like this:
iex> File.write("fixed-haiku.txt", fixed_contents)
:ok
Elixir dutifully takes the file path and the fixed contents and puts them into a file called
fixed-haiku.txt
. Well, we assume so. All that we know about this operation is that Elixir thought it
went OK, as indicated by the atom that it returned: :ok
.
If we want to make sure that the fixed-haiku.txt
file is definitely there and contains what we expect
it to contain, we could simply find that file in the file browser on our computers and double click on it. That
would be the easiest way. But, because we're programmers and we've got powerful new skills up our sleeves, we can
use what we know to check this. Let's use File.read/1
to see if that file is there:
iex> File.read("fixed-haiku.txt")
{:ok, "I love Elixir\nIt is so easy to learn\nGreat functional code"}
Great! Our file has definitely been written with the right content. Our haiku is in the right order.
Renaming a file
Izzy asks another question: "Why don't we switch these haiku.txt
and fixed-haiku.txt
files around? If I was opening a file called haiku.txt
, I would expect that to be a proper Haiku, not
this weird reversed one! I wouldn't think to look in fixed-haiku.txt
.
Izzy's right. It's weird that haiku.txt
doesn't contain the haiku. We should rename this strange
reversed version to reversed-haiku.txt
, and then rename fixed-haiku.txt
to
haiku.txt
to ease any confusion people might have.
To read a file there was File.read/1
. To write a file there was File.write/2
. So it
makes sense that in order to rename a file, there is File.rename/2
. Elixir is pretty sensible like
that, if you haven't realised already. File.rename/2
's two arguments are the original name of the
file, and then the new name we want.
Let's use this function to first rename haiku.txt
to reversed-haiku.txt
:
iex> File.rename("haiku.txt", "reversed-haiku.txt")
:ok
Elixir has told us that this operation has succeeded. Well, if that's the case then we should see when we look into
that directory again that the haiku.txt
file isn't there, but a reversed-haiku.txt
file
is there instead. Oh, and fixed-haiku.txt
exists too. Let's take a peek:
Yes! Our file has been renamed successfully. That's a good start. Let's rename fixed-haiku.txt
into
haiku.txt
with File.rename/2
too:
iex> File.rename("fixed-haiku.txt", "haiku.txt")
:ok
Elixir tells us that the file rename operation was successful yet again. Two for two! Peeking into that directory one more time, we'll see that indeed it was:
Wonderful stuff. Whenever we need to rename a file within Elixir we now know that we can reach for
File.rename/2
.
So now we've seen how to read existing files, create new ones and rename them. The final file operation that we'll look at this chapter is to delete the files.
Deleting a file
We've all deleted files from our computers in our lifetime. Sometimes even on purpose. The files reach a point where they're no longer useful and we want them gone. The way we might do this is to drag the file to the Recycle Bin, or to right click and choose "Delete", or... well, there's so many ways to delete files.
Elixir provides us a way to delete files too, but it isn't as intuitively named as the operations for reading
(File.read/1
), writing (File.write/2
), and renaming (File.rename/2
). To
delete a file in Elixir we call the function called File.rm/1
.
"RM? Like R.M. Williams, the world famous Australian manufacturer of
leather things? What does leather shoes and belts have to do with removing files?", Izzy asks with quite the
perplexed look on her face, cork hat bobbling quizzically along. Woah, slow down there Izzy. Elixir's
File.rm/1
has nothing to do with boots!
This File.rm/1
file is called rm
as a shorthand for
"remove". We can use this function to remove any file we like. We would like to
keep our haiku files, so let's create a separate file that we can remove later by using File.write/2
first, and then we'll delete it with File.rm/1
. Let's do this slow so that we can see that the file
definitely does exist before we delete it. Let's start with creating the file:
iex> File.write("delete-me.txt", "delete me")
:ok
If we look in our directory, we can see that this file definitely exists:
Let's try removing this file now:
iex> File.rm("delete-me.txt")
:ok
Elixir says that removing this file was successful. Let's take a look at the directory:
The file is gone!
We've now got our head around some of the essential things that we can do with files with Elixir. We know that we
can read existing files with File.read/1
, write completely new ones with File.write/2
and delete existing files with File.rm/1
. For good measure, we also learned that these functions may
return tuples that indicate errors, like {:error, :enoent}
, which tells us that files do not exist.
We've now encountered a situation (well, situations) in Elixir where we can call code and get different outcomes depending on external factors like whether files exist or not. The code that we've written so far hasn't been particularly resilient to this sort of thing. We've become accustomed to running code and always seeing the same result.
But the rules have changed. We now will need to account for this sort of thing. In the chapter I will show you a few ways that we can make our code more resilient by making it behave differently, depending on the circumstances.
Exercises
-
Can you make Elixir write a program for itself? Put this code into a file called
script.ex
withFile.write/2
:IO.puts "This file was generated from Elixir"
and then make it run by runningelixir that-file.ex
. -
Figure out what happens if you try to delete a file that doesn't exist with
File.rm/1
. Is this what you expected to happen?