The code for this lecture is available in this archive.
Motivation
Files are useful for permanency: when a program terminates, all the objects created, strings entered by the user, and even the messages displayed on the screen, are lost. Saving some information as files allows to retrieve this information after the program terminates, possibly using a different tool. Retrieving information from a file allows to continue works that was started by a different program, or to use a better-suited tool to carry on some task (typically, counting the number of words in a document).
This lecture is concerned with files: how to write into them, how to
read from them, how to make sure that our program does not throw
exceptions when dealing with “I/O” (read: input / output) operations? We
will only consider textual files, with the .txt extension, for now.
Warm-Up: Finding a Correct Path
Each file has a (full, or absolute) path, which is a string describing where it is stored: it is made of a directory path and a file (or base) name. Paths are complicated because they can
- Vary with the operating system (windows uses
\as a path separator, but macOS and Unix systems use/), - Vary with the user (windows store information in the
C:\Users\<username>\folder, that will change based on your username), - Vary with the language (windows calls the “Downloads” folder “Overførsler” in Danish),
- Point to a folder that the current user is not allowed to explore
(
/root/in Unix systems), - Not exist,
- etc.
A fairly reliable way of handling this diversity is to select the folder
/bin/Debug naturally present in your solution (it is where the
executable is stored). We can access its directory path using:
string directoryPath = AppDomain
.CurrentDomain
.BaseDirectory;
Console.WriteLine(
"Directory path is " + directoryPath + "."
);On most Unix systems, this would display at the screen something like
~/source/code/projects/FileDemo/FileDemo/bin/Debug/To add to this directory path the file name, we will be using the
Combine method from the Path class, as follows:
string filePath = Path.Combine(
directoryPath,
"Hello.txt"
);
Console.WriteLine("File path is " + filePath + ".");| ⚠ Warning |
|---|
| Unless otherwise stated, we will always use this folder in our examples. |
Writing Into a File
Writing into a file is done using the StreamWriter class and a couple
of methods:
- a constructor, that takes a string describing a path as an argument,
- the
WriteLinemethod, that write itsstringargument to the file, followed by a new line, - the
Writemethod, that write itsstringargument to the file, - the
Closemethod, that closes the file.
Even if we will not go into details about the role of the Close
method, it is extremely important and should never be omitted.
As an example, we can create a HelloWorld.txt file containing
Hello World!!
From the StreamWriter class0123456789using the following code:
StreamWriter sw = new StreamWriter(filePath);
sw.WriteLine("Hello World!!");
sw.Write("From the StreamWriter class");
for (int x = 0; x < 10; x++)
{
sw.Write(x);
}
sw.Close();Reading From a File
Reading from a file is very similar to writing from a file, as we are
using a StreamReader class and a couple of methods:
- a constructor, that takes a string describing a path as an argument,
- the
ReadLinemethod, that read the current line of the file and move the cursor to the next line, - the
Closemethod, that closes the file.
The Close method is similarly important and should never be omitted.
As an example, we can open a file located at filePath and display its
content at the screen using:
string line;
StreamReader sr = new StreamReader(filePath);
line = sr.ReadLine();
while (line != null)
{
Console.WriteLine(line);
line = sr.ReadLine();
}
sr.Close();What Can Go Wrong?
When manipulating files, many things can go wrong.
What if we are trying to read a file that does not exist?
If the StreamReader constructor is given a path to a file that does
not exist, it will raise an exception (discussed
below). We can catch this exception, but a
better mechanism is to simply warn the user, using the File.Exists
method that return true if its string argument points to a file,
false otherwise.
if (!File.Exists(filePath))
{
Console.WriteLine("File does not exist.");
}What if we are trying to write into a file that already exist?
A dual problem is if the path we are using as an argument to the
StreamWriter constructor points to a file that already exists: by
default, that file will be overwritten. An alternative mechanism is to
use the overloaded StreamWriter constructor that additionally takes a
bool as
argument:
if the bool is set to true, then the new content will be appended
into the existing file instead of overwriting it.
Hence, we can use the following:
StreamWriter sw = new StreamWriter(filePath, true);with benefits:
- If the file at
filePathalready exists, we will append to it (add at its end) instead of overwriting it, - If the file at
filePathdoes not exist, it is created.
The previous code
StreamWriter sw = new StreamWriter(filePath);should be used only if we do not care about existing files.
Many Things Can Go Wrong!
We will not list in detail all the ways things can go wrong with manipulating files, but some examples include:
- memory shortage, for example triggering a
OutOfMemoryExceptionfrom theStreamReader.ReadLinemethod, - File not being writable (for example, because of access right
limitations), triggering an
ArgumentExceptionfrom theStreamWriterconstructor.
In any cases, accessing files in general should always take place in
try-catch blocks. Some simple(r) examples are:
using System;
using System.IO;
class Program
{
static void Main(string[] args)
{
Console.WriteLine(
@"
***************************************
* Streamreader constructor exceptions *
***************************************"
);
try
{
string filePath = null;
StreamReader sr = new StreamReader(filePath);
}
catch (Exception e)
{
Console.WriteLine(e); // System.ArgumentNullException: Value cannot be null.
}
try
{
string filePath = "/not/a/proper/path/Test.txt";
StreamReader sr = new StreamReader(filePath);
}
catch (Exception e)
{
Console.WriteLine(e); // System.IO.DirectoryNotFoundException: Could not find a part of the path "/not/a/proper/path/Test.txt".
}
Console.WriteLine(
@"
***************************************
* Streamwriter constructor exceptions *
***************************************"
);
try
{
string filePath = null;
StreamWriter sr = new StreamWriter(filePath);
}
catch (Exception e)
{
Console.WriteLine(e); // System.ArgumentNullException: Value cannot be null.
}
try
{
string filePath = "/not/a/proper/path/Test.txt";
StreamWriter sr = new StreamWriter(filePath);
}
catch (Exception e)
{
Console.WriteLine(e); // System.IO.DirectoryNotFoundException: Could not find a part of the path "/not/a/proper/path/Test.txt".
}
string directoryPath = AppDomain
.CurrentDomain
.BaseDirectory;
}
}Note also that scoping matter, as C# is “pessimistic”: it supposes that
everything inside the try portion will fail. For example, one cannot
have
// The following would *not* compile
try
{
StreamWriter sw = new StreamWriter(filePath);
// …
}
catch (Exception ex) { Console.WriteLine(ex); }
finally
{
sw.Close();
}as C# would complain that
The name 'sw' does not exist in the current contextsince it assumes that the creation of sw will fail.
In More Details: Streams
The StreamWriter and StreamReader constructors we discussed above
can also take System.IO.Stream objects as arguments, and it makes
handling of files a bit easier to understand. Indeed,
System.IO.Stream
is an abstract class that provides a generic view on sequences of bytes.
An object in its class can be a file, a I/O device, a TCP/IP socket,
etc.
That means that our StreamWriter and StreamReader objects don’t
really see the whole file “all at once”, they process it by loading some
of it in a buffer (whose size can actually be specified when creating
e.g. a StreamWriter
object.
Since all the program “see” is a series of characters containing new
line
characters,
as many as its buffer can hold, we cannot for example
- Open the file at an arbitrary line,
- Read the file backward (that is, from the end),
- Search for a particular word,
unless we write a program to complete this task ourselves.
Here is an example of a program asking the user to enter a file name, populating it with a random number of random numbers, and then reading it backward:
using System;
using System.IO;
class Program
{
static void Main(string[] args)
{
// Setting the directory path to the current
// folder, where the binary is located.
string directoryPath = AppDomain
.CurrentDomain
.BaseDirectory;
// Asking the user for file name.
Console.WriteLine(
"Please, enter a file name (hit \"enter\" to use \"test.txt\")."
);
string fileName = Console.ReadLine();
if (fileName == null || fileName == "")
fileName = "test.txt";
string filePath = Path.Combine(directoryPath, fileName);
// Creating a generator for random numbers.
Random gen = new Random();
int numNum = gen.Next(20, 100);
// We will store between 20 and 99 numbers in our file.
// We first populate the file with random numbers.
try
{
Console.WriteLine(
"Now storing "
+ numNum
+ " random numbers into "
+ filePath
+ "."
);
StreamWriter sw = new StreamWriter(filePath);
// Further references to sw *have to be* on the same try block.
for (int i = 0; i < numNum; i++)
sw.WriteLine(gen.Next(i + 2));
sw.Close();
}
catch (Exception ex)
{
Console.WriteLine(ex);
}
// Reading the file and storing its values in a
// partially filled array, cf.
// https://princomp.github.io/lectures/collections/default_resizing#partially-filled-arrays
int aSize = 5;
int cLine = 0;
string line;
string[] text = new string[aSize];
try
{
StreamReader sr = new StreamReader(filePath);
line = sr.ReadLine();
while (line != null)
{
// Uncomment if you would like to test / see the file displayed forward first.
// Console.WriteLine(line);
if (cLine + 1 > aSize)
{
aSize *= 2;
Array.Resize(ref text, aSize);
}
text[cLine] = line;
cLine++;
line = sr.ReadLine();
}
sr.Close();
Console.WriteLine(
"Displaying the file starting from the end:"
);
for (int i = cLine - 1; i >= 0; i--)
{
Console.WriteLine(text[i]);
}
}
catch (Exception ex)
{
Console.WriteLine(ex);
}
}
}