Reading Files
Learn how to read byte sequences from files using the FileStream class and ensure proper disposal of operating system file handles.
We'll cover the following...
A common example of an unmanaged resource is a file residing on a disk. To open it, read it, write to it, or delete it, any programming language must ultimately settle with operating system calls. Because the operating system does not run on top of the CLR, OS calls are considered unmanaged code.
Therefore, we must appropriately dispose of objects that use such external calls. Before we start creating and disposing of file objects, we must first understand how .NET works with files.
The Stream class
A file is fundamentally nothing but a sequence of bytes. Depending on the file format, this sequence is interpreted differently, allowing the user to see meaningful output. Interpreting an image file's bytes as text results in invalid or unreadable characters.
In .NET, a sequence of bytes is represented by the Stream class inside the System.IO namespace. It is an abstract base class for all streams in .NET.
There are two main operations provided by streams:
Reading: This represents the transfer of bytes from the external environment (such as a file, a network connection, or a keyboard) into the program. For instance, we can read from the stream and save everything as an array of bytes (
byte[]).Writing: This represents the transfer of bytes from the application out to the external environment (from a
byte[]to a file, network connection, or output device).
Depending on the source of a byte sequence, there are various Stream types in .NET. For network access, we have NetworkStream, and for files, we have FileStream. In this lesson, we are interested in the latter.
How to check if a file exists
Before opening a file, we must verify that it actually exists on the disk. Attempting to open a non-existent file for reading throws a FileNotFoundException and crashes the application.
We use the File.Exists method from the System.IO namespace to perform this validation safely. This method takes a string representing the file path and returns a boolean value indicating the file’s presence.
Notably, File.Exists robustly handles edge cases without throwing exceptions. It safely returns false under any of the following conditions:
The file does not exist at the specified path.
The provided path is
null, empty, or contains invalid characters.The application lacks the necessary operating system permissions to read the path.
The provided path points to a directory rather than a file.
Line 7: We pass the file path into the
File.Existsmethod. If the operating system confirms the file is present at that location, it returnstrue.
Reading with FileStream
The FileStream class provides a stream to interact with a file. It supports read and write operations in both synchronous and asynchronous manners. With FileStream, we can work with both text and binary files.
To create an instance of FileStream, we use one of its constructors:
var fileStream = new FileStream(path, mode);
Here, we pass the file path and an enumeration value dictating how the operating system should open the file.
The path (string object) is the location of the file we want to open, and mode is an enumeration of type FileMode that can have one of the following values:
Append: If the file exists, bytes are appended to the end of the file. If the file does not exist, it is created. The file is opened exclusively for writing.Create: A new file is created. If a file with the same name already exists, it is overwritten.CreateNew: This attempts to create a new file and open it. An exception is thrown if a file with the same name already exists.Open: This opens the file and throws an exception if no such file exists.OpenOrCreate: This opens the file or creates a new one if it does not exist.Truncate: This attempts to open an existing file and overwrite its contents entirely.
Before reading a file with a FileStream object, we can list the available files in our directory. We can make use of the Directory static class, which lets us interact with the file system’s directory structure.
Line 1: We import the
System.IOnamespace, which contains the classes required for input and output operations.Line 4: We retrieve the absolute path of the directory from which the application is currently executing.
Line 7: We request an array of strings representing the full paths of all files in that directory.
Lines 10–13: We iterate through the array and print each file path to the console.
Assuming we have a file named Program.cs in our directory, let us read its contents using a FileStream instance. To read, we use the Read() method, which has the following signature:
Read(byte[] buffer, int offset, int bytesToRead);
Here, we define the buffer (the destination to store the read bytes), the offset (the starting position in the array), and the bytesToRead (how many bytes to pull from the file).
Now, let us use this method to extract the bytes from the Program.cs file and convert them back into readable text.
Line 5: We open a file stream targeting
Program.cswith the instruction to strictly open an existing file.Line 9: We initialize a byte array. We set its size to match the exact length of the file using
fileStream.Length.Line 13: We instruct the stream to read bytes from the file and place them into
byteSequence, starting at index 0, until the array is full.Line 17: We use the
Encoding.UTF8utility to translate the raw bytes back into a human-readable C# string.Line 19: We print the resulting text to the console.
If we run this, we will print out the exact source code that we just used to read the source code!
Dispose
Although the previous code works, it fails to release the file handle. A file is an unmanaged resource, and any connection to an unmanaged resource must be properly closed after the work is done. In the previous code, we did not release the file handle.
As we learned previously, unmanaged resources can be handled reliably using the using construct. However, to use the using keyword, the object's class must implement the IDisposable interface. Let us use reflection to inspect the FileStream class and verify if it implements this interface.
Line 3: We retrieve the runtime metadata for the
FileStreamtype.Line 4: We extract an array of all interfaces that
FileStreamimplements.Lines 6–9: We iterate through the interfaces and print their names to the console.
If we run this code, we will see IDisposable in the output. This confirms that FileStream implements the IDisposable interface, meaning we can (and should) declare our stream with a using declaration.
Let us rewrite our file-reading logic using the modern using declaration to ensure the file handle is safely released.
Line 5: We prepend the
vardeclaration with theusingkeyword. This ensures that the operating system file handle is automatically released when the application finishes executing the current scope.Lines 7–11: We execute the same reading and decoding logic, but now it is fully protected against memory leaks and file locks.
Modern best practices
While understanding how FileStream works is crucial, modern developers rarely manipulate raw byte arrays manually to read standard text files. The System.IO.File class provides higher-level utility methods. Specifically, File.ReadAllTextAsync() automatically handles opening the stream, reading the bytes, decoding the text into a string, and safely disposing of the unmanaged resources.
Furthermore, we use the Path.Combine() method to build file paths safely. Different operating systems use different directory separator characters. Windows uses a backslash (\), whereas Linux and macOS use a forward slash (/). Path.Combine() automatically applies the correct separator based on the current operating environment.
Line 5: We construct the full file path safely without hardcoding any directory separators.
Line 8: We use the
awaitkeyword to non-blockingly retrieve the entire string content of the file. The method handles theFileStreamandDisposelogic implicitly.