Files, I/O Operations, and Streams
For many applications, working with files and I/O is necessary. Most of the primitives for file operations are already implemented in the standard library for C#.
File System
In C#, there are three static classes for working with the file system: Path, Directory, and File. The Path class has methods for working with the file system in general and extracting information about directories and files in a system-independent and platform-independent manner. Path can be used to perform operations on file paths, such as concatenating paths using "/" on Linux and "\" on Windows, or to extract file extensions.
Directory and File are used to create or delete directories and files, and to iterate over the contents of a directory. To extract more information about them, classes like DirectoryInfo and FileInfo can be used, including details about creation date and last modification. Below are a few examples of how they can be used.
private const string CurrentPath = ".";
private const string DirectoryName = "TestDirectory";
private const string FileName = "TestFile";
private static void RunDirectoryExample()
{
// With Path, we can convert a relative path to an absolute one.
var fullPath = Path.GetFullPath(CurrentPath);
Console.WriteLine("Full path for current directory is \"{0}\"", fullPath);
// We can obtain information about a directory using DirectoryInfo.
var directoryInfo = new DirectoryInfo(fullPath);
Console.WriteLine("Current directory was created at: {0}", directoryInfo.CreationTimeUtc);
// To concatenate paths regardless of the operating system, we use Path.Join.
var newDirectoryPath = Path.Join(CurrentPath, DirectoryName);
// The Directory class provides methods to check if a directory exists and to create one.
if (!Directory.Exists(newDirectoryPath))
{
Console.WriteLine("Creating new directory \"{0}\"", newDirectoryPath);
Directory.CreateDirectory(newDirectoryPath);
}
else
{
Console.WriteLine("Directory \"{0}\" already exists", newDirectoryPath);
}
Console.WriteLine("The directories found are:");
// We can iterate through directories contained in a path.
foreach (var directory in Directory.GetDirectories(CurrentPath))
{
Console.WriteLine(directory);
}
Console.WriteLine("The files found are:");
// Similarly for files.
foreach (var file in Directory.GetFiles(CurrentPath))
{
Console.WriteLine(file);
}
Console.WriteLine("-------------------");
}
private static void RunFileExample()
{
var newFilePath = Path.Join(CurrentPath, FileName);
/* When opening a file, we specify the opening mode, here to open
* or create if it doesn't exist, and the access mode, here for both reading and writing.
* The object returned by Open is a FileStream, which inherits from the Stream class.
*/
using var file = File.Open(newFilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite);
/* To work more easily with streams, StreamReader and StreamWriter are used,
* as they provide methods for working with text, in addition to the byte-level operations of FileStream.
*/
using var reader = new StreamReader(file);
using var writer = new StreamWriter(file);
// There are multiple reading methods; here we read the whole file.
var fileContents = reader.ReadToEnd();
Console.WriteLine("The file contents are:");
Console.WriteLine("-------------------");
Console.WriteLine(fileContents);
Console.WriteLine("-------------------");
var random = new Random().Next();
Console.WriteLine("Writing new random number to file: {0}", random);
// We can write to the file using StreamWriter, similar to writing to the console.
writer.WriteLine(random);
// Just like obtaining information about directories, we can do the same with FileInfo for files.
var fileInfo = new FileInfo(newFilePath);
Console.WriteLine("Last file write was at: {0}", fileInfo.LastWriteTimeUtc);
Console.WriteLine("-------------------");
}
The Stream Class
When working with files, we don't manipulate the file directly; instead, we use multiple levels of abstraction. A FileStream is opened over a file, inheriting from the abstract class Stream. The Stream class abstracts the concept of a binary data stream. Any file can be considered as a stream of bytes, and it can be used for reading, writing, or both.
Over a Stream, we can use StreamReader and StreamWriter to have more functionalities. The Stream class is designed primarily for extracting sequences of bytes using the Read and Write methods, which take a buffer to read from or write to. StreamReader and StreamWriter add operations for working with text data over a Stream they encapsulate. You can see in the previous example how to use them.
The Stream class is encountered not only in file handling but also in various other types of data streams. Files are one example. Then, there's an in-memory data stream called MemoryStream, which can be used to copy other external data streams into the program's memory in binary form. Another use case for the Stream class is for receiving data from network connections via a NetworkStream for TCP connections.
In general, the Stream class will be used predominantly for I/O processing. However, even though it's an abstract class, exceptions and behaviors during reading and writing will vary depending on the stream type. Therefore, it's essential to consult the documentation on how to use them in each specific case.
Every Stream implements the IDisposable interface because it holds resources that need to be released, such as file descriptors and sockets, which are tracked by the operating system. The IDisposable interface ensures a method is available to release these resources. After using a stream, the Dispose method should be called. If the object is no longer needed when exiting the current scope, the using keyword can be used during variable initialization. This automatically calls Dispose upon leaving the scope, even in cases of exceptions.