Module 4: File I/O

Module Overview

File I/O (Input/Output) is a fundamental part of many applications, allowing programs to read from and write to files. In this module, you'll learn about Java's file handling capabilities, including reading, writing, and manipulating files and directories.

Learning Objectives

  • Understand file concepts and the file system
  • Use Java's File class to work with files and directories
  • Read data from files using various techniques
  • Write data to files efficiently
  • Manage file resources properly
  • Handle exceptions related to file operations

File IO in Java

Purpose and Intent

It is difficult to imagine a program of any substance that does not handle files. We use files to store text, pictures, and even programs, and we can manipulate these files to do various tasks. We can send an image to a website, use an email template, or simply show a file's contents on the screen.

In this lesson, we will learn how to access files in Java by covering the following basic file I/O (input/output) operations:

  • locate where a file is
  • read the content of a file into our program
  • write content to a file
  • close a file

Applications

Before looking into how to perform file I/O operations, it is helpful to see how we might use them.

One of the most common applications of file I/O is copying a file. On any personal computer, a user can select a file and make a copy of it with relative ease. Behind the scenes, however, the computer runs a program that opens the selected file and copies the contents to a new file.

Perhaps an even more common occurrence of using file I/O occurs when a web page is opened. When a web address is entered into a browser, the contents of that page are downloaded from a web server and loaded into the web browser. Behind the scenes, the server breaks up files into sections and sends each section to the computer's browser. The browser is then in charge of putting the files back together again.

From these examples, we may use a file's contents differently based on the requirements. When copying a file, storing the file's contents as a string and then writing it to another file is good enough. It can be more complicated if we have to break a file into parts to send it across a network. Regardless of the requirements, Java provides different ways to work with files. Let's start with how to access one of the most common file types: a text file.

Using File I/O Operations with Text Files

As mentioned earlier, there are four basic file I/O operations: locate, read, write, and close. Let's start with locating a file.

Locating a file

Locating a file can be as simple as providing the file's path. The most direct way to locate a file is by creating a File object and providing a path in the constructor:

File file = new File("/Users/jon_smith/my_doc.txt"); //full path on Mac
File file = new File("C:\Documents\my_doc.txt"); //full path on Windows

The File class lets our code see the file at that location. We cannot, however, read the file's contents using the File class alone. We will need to use another class to read what is in the file.

Reading a file's contents into a program

When reading a text file, one solution is to use a FileReader to access the contents of a file and a BufferedReader to load the contents into the program.

Look at the code below that reads and saves the contents of a file using these two classes:

//1
try {
    //2
    FileReader fileReader = new FileReader(file);
    BufferedReader bufferedReader = new BufferedReader(fileReader);
    String fileContents = "";

    //3
    String line = bufferedReader.readLine();  
    while (line != null) {
        fileContents += line + "\n";
        line = bufferedReader.readLine();
    }

    //4
    bufferedReader.close();

} catch(FileNotFoundException e) {
    System.err.println("File not found");
} catch (IOException e) {
    e.printStackTrace();
}

Let's break this down. Comments are in the code above that match the numbers below.

  1. A try-catch is required for two potential errors. The first error happens if the file that FileReader attempts to open does not exist. In this case, a FileNotFoundException is thrown, so we need to handle that error. IOException is a catch-all exception for a variety of other issues that could occur when working with files.
  2. Both FileReader and BufferedReader are created. FileReader provides read access to the file while BufferedReader will pull the contents into the program. A string is also created to store the file's contents for later use.
  3. We create a new variable (line) to store the next line that the BufferedReader gives us. Since the BufferedReader doesn't know how many lines the file has, a simple check if the nextLine method returns null keeps the program from processing null items (and avoids throwing an error). If the line is not null, then the line is appended to the fileContents variable. The BufferedReader then pulls in the next line before restarting the loop, pushing the code through the file one line at a time.
  4. After the file has been completely read, all readers to that file need to be closed. This is an important step, and we'll cover why its important later. For now, just know that its needed.

This may seem like a tedious task. After all, why can't Java return all the file's contents at once? Why does one class only offer access while another is needed for loading? Fair points. Other options for reading a file exist, and choosing the right one depends largely on the task that's being solved. We are covering BufferedReader here because it can be used for various use cases. However, you'll see a different solution in the Guided Project that allows reading in any type of file with its own set of use cases that it can solve. More on that later, but for now, let's get to writing!

Writing content to a file

Similar to the classes we used for reading a file's contents, writing to a file involves the FileWriter class which provides write access to the file and the BufferedWriter for writing to the file. Take a look at the following implementation:

try {
    String output = "Hello World123";

    //1
    FileWriter fileWriter = new FileWriter(file);
    BufferedWriter bufferedWriter = new BufferedWriter(fileWriter);

    //2
    bufferedWriter.write(output);

    //3
    bufferedWriter.close();
} catch (IOException e) {
    e.printStackTrace();
}

Writing follows a similar process to reading. Let's break it down:

  1. The FileWriter and BufferedWriter are instantiated. Similarly to their reading counterparts, the FileWriter provides access to write to the file while BufferedWriter loads the contents into the file.
  2. The write method is a simple method that adds the provided string to the file. No need for a while loop here like in the previous reading example.
  3. After the program is done writing to a file, the writers need to call close. Like with reading, we only need to close the BufferedWriter, which will close the FileWriter for us.

Closing our resources

Let's talk about why we call close. It's important to understand accessing a file requires dedicated computing resources. By calling close on the readers and writers, those computing resources are made available for other tasks. This is a good practice, especially if the program runs for an extended time. If you forget to call close, the resources are freed up when the program ends, but you limit your program's resources while it's running. To ensure the program has resources available, always call close on readers and writers when they're no longer needed.

Reading Files of Any Type using Bytes

As mentioned above, Java has many ways to read from and write to files. The previous examples show reading content as Strings, but files can also be handled by accessing the content in bytes. Bytes are sets of 1s and 0s that a computer can quickly process and can represent anything like text, images, movies, and programs. As humans, however, determining what a row of bytes represents is challenging.

//"Hello World!" in bytes
01001000011001010110110001101100011011110010110000100000010101110110111101110010011011000110010000100001

We as humans don't like to read in bytes, but seeing as computers do, we can leverage bytes in many circumstances. For example, if processing a file doesn't require a human to read its contents, a program can keep data in bytes and streamline the process. Let's see how we might do this.

Reading in Bytes

A file can be read into a program as bytes using a FileInputStream. The stream part of this class means that the file is broken up and delivered in an array. Reading in large amounts of bytes can be resource-heavy, so FileInputStream lets us specify how much of the file we want at a time.

For general-purpose file copying, we do not make assumptions about the nature of the file; it can be plain text, binary data, executable programs, and so forth. Therefore, it is best to declare the array of type byte so it can handle any type of file (if it's hard to understand what exactly bytes are, try thinking of them as characters).

File inFile = new File("");
// 1
FileInputStream inStream;
byte[] buffer = new byte[256];
int bytesRead;

try {
    // 2
    inStream = new FileInputStream(inFile);
    bytesRead = inStream.read(buffer);
    
    // 3
    while (bytesRead > 0) {
        //do something with bytes...
        bytesRead = inStream.read(buffer);
    }

    // 4
    inStream.close()

} catch (IOException e) {
    System.err.println(e);
    System.exit(1);
}

This should look familiar to what we did above with FileReader. Let's break it down to understand the differences:

  1. A few different objects are required to read in bytes. First, the FileInputStream is used to read the bytes into the program. It handles both accessing the file and loading the contents into the program. Next, we have a buffer, which is a byte array. As content is brought in from the FileInputStream, the content will be temporarily stored in the buffer. The buffer will also tell the FileInputStream how many bytes of data should be read at a time (in this case, 256). The last item needed is bytesRead, which will tell us exactly how many bytes FileInputStream brings in each time.
  2. The FileInputStream is created by passing in the file we want to read into the program. Then we can call read and pass in the buffer, which stores the file's bytes. Notice that this method returns an integer that is saved to bytesRead. This integer tells us how many bytes were read in during the read call. Generally, we expect this number to be the size of the byte array (in our case, 256), but if there are fewer bytes at the end of the file, that number may be less. If that number is less than 0, then no bytes were read, and we can assume we've reached the end of the file.
// Let's assume the file has 513 bytes
byte[] buffer = new byte[256];
inStream.read(buffer); // returns 256
inStream.read(buffer); // returns 256 (512 total)
inStream.read(buffer); // returns 1 (513 total)
inStream.read(buffer); // returns -1 (end of file)
  1. The program will continue reading the contents until bytesRead is less than 0, indicating that the entire file has been read.
  2. Similar to our previous example, we close the FileInputStream to free up resources.

Writing in Bytes

Similarly to using FileInputStream to read a file's contents as bytes, we can use FileOutputStream to write bytes to a file. Here's a simple example:

byte[] buffer = new byte[256];
// fill buffer with bytes to write to the file
File inFile = new File("");
FileOutputStream outStream = new FileOutputStream(outFile);
outStream.write(buffer, 0, buffer.length);
outStream.close();

When we call write, we pass in three things. The first is the buffer which represents the content that will go into the file. The second value gives the index for the buffer array where we should start. Since we're putting all the content into the file, we can leave this value as 0. bytesRead is how many bytes of the buffer should be put into the file.

We can add our writing code to the reading code above to make a copy of a file like so:

File inFile = new File("");
File outFile = new File("");

FileInputStream inStream;
FileOutputStream outStream;

byte[] buffer = new byte[256];
int bytesRead;

try {
    inStream = new FileInputStream(inFile);
    outStream = new FileOutputStream(outFile);
    bytesRead = inStream.read(buffer);

    while (bytesRead > 0) {
        outStream.write(buffer, 0, bytesRead);
        bytesRead = inStream.read(buffer);
    }

    inStream.close();
    outStream.close();

} catch (IOException e) {
    System.err.println(e);
    System.exit(1);
}

Conclusion

Reading and writing from files is something developers will come across again and again throughout their careers. It's not as simple a process as one might initially think. Still, the way Java allows for file I/O to happen gives developers the flexibility to access files based on what needs to be done. This is not a one-solution-fits-all case. Consider the task's requirements when you encounter a problem requiring file I/O. This will help you determine which method to use.

File Basics

Understanding Files and Paths

A file is a named location on disk that stores information. Java provides several ways to work with files, starting with the java.io.File class for basic file operations and the java.nio.file package for more advanced features.

File and Directory Operations

  • Creating files and directories
  • Checking if a file exists
  • Getting file information (size, last modified, etc.)
  • Listing directory contents
  • Deleting files and directories

Paths in Java

// Creating File objects
File file = new File("data.txt");            // Relative path
File file = new File("/home/user/data.txt"); // Absolute path

// Using Path interface (NIO)
Path path = Paths.get("data.txt");
Path path = Paths.get("/home/user", "data.txt");

Reading Files

Reading Text Files

Java provides multiple ways to read text files, from character-based approaches to line-by-line reading:

// Reading with BufferedReader
try (BufferedReader reader = new BufferedReader(new FileReader("data.txt"))) {
    String line;
    while ((line = reader.readLine()) != null) {
        System.out.println(line);
    }
} catch (IOException e) {
    e.printStackTrace();
}

// Reading all lines at once with Files class (Java 7+)
try {
    List lines = Files.readAllLines(Paths.get("data.txt"));
    for (String line : lines) {
        System.out.println(line);
    }
} catch (IOException e) {
    e.printStackTrace();
}

Reading Binary Files

// Reading binary data
try (FileInputStream fis = new FileInputStream("image.jpg")) {
    byte[] buffer = new byte[1024];
    int bytesRead;
    while ((bytesRead = fis.read(buffer)) != -1) {
        // Process bytes
    }
} catch (IOException e) {
    e.printStackTrace();
}

Writing Files

Writing Text Files

// Writing with BufferedWriter
try (BufferedWriter writer = new BufferedWriter(new FileWriter("output.txt"))) {
    writer.write("Hello, world!");
    writer.newLine();
    writer.write("This is a test file.");
} catch (IOException e) {
    e.printStackTrace();
}

// Writing all lines at once with Files class (Java 7+)
try {
    List lines = Arrays.asList("Line 1", "Line 2", "Line 3");
    Files.write(Paths.get("output.txt"), lines);
} catch (IOException e) {
    e.printStackTrace();
}

Writing Binary Files

// Writing binary data
try (FileOutputStream fos = new FileOutputStream("data.bin")) {
    byte[] data = {65, 66, 67, 68, 69}; // ASCII values for ABCDE
    fos.write(data);
} catch (IOException e) {
    e.printStackTrace();
}

Resource Management

Using try-with-resources

Always close file resources properly to prevent resource leaks. The try-with-resources statement (introduced in Java 7) automatically closes resources when they are no longer needed:

// Before Java 7
BufferedReader reader = null;
try {
    reader = new BufferedReader(new FileReader("data.txt"));
    // Use reader
} catch (IOException e) {
    e.printStackTrace();
} finally {
    try {
        if (reader != null) reader.close();
    } catch (IOException e) {
        e.printStackTrace();
    }
}

// With try-with-resources (Java 7+)
try (BufferedReader reader = new BufferedReader(new FileReader("data.txt"))) {
    // Use reader - automatically closed when block exits
} catch (IOException e) {
    e.printStackTrace();
}

Resources

Practice Exercises

  • Create a text file and write multiple lines to it
  • Read a text file line by line and count the occurrences of a specific word
  • Copy the contents of one file to another
  • Create a directory and populate it with multiple files
  • Implement a simple file-based data storage system

Guided Projects

Mastery Task 4: Implement File I/O

Mastery Task Guidelines

Mastery Tasks are opportunities to test your knowledge and understanding through code. When a mastery task is shown in a module, it means that we've covered all the concepts that you need to complete that task.

Each mastery task must pass 100% of the automated tests and code styling checks to pass each sprint. Your code must be your own. If you have any questions, feel free to reach out for support.

In order for our FormLetterService to generate a welcome letter, it will need to go through the currently unimplemented FileManager class. An instance of this class should be able to read text from a file and write text to a file.

Implement the getTextFromFile method

This method takes the name of a file as input, and should then search for a file at the path: src/resources/{filename}.txt. If it cannot find the file it should throw a FileNotFoundException. Otherwise, it should store the file's contents exactly into a String and return them.

Note: some methods of reading a file may cause it to truncate the newline character \n. In this case you may need to add a \n to the end of each line.

You can print your returned String to the console and see if the line indentation is working properly.

Implement the FormLetterService

This class has a single method generateWelcomeLetter which takes an Employee as input.

This method needs to request the template data from LetterTemplate.txt in the resources directory, from the FileManager.

Then it should use the String .replace() method to replace any []ed text with its correct value.

Finally, the finalized String should be passed to the FileManagers writeTextToFile method.

Implement the writeTextToFile method

After the FormLetterService updates the template string to the final result, your FileManager need to write that data exactly to the output file path: src/resources/out/{filename}.txt.

Completion

Run the gradle command:

./gradlew -q clean :test --tests 'com.bloomtech.welcomeletter.MasteryTask_4*'

and ensure all tests pass.

Next Steps

After completing this module:

  1. Complete the practice exercises above
  2. Review the additional resources for deeper understanding
  3. Move on to the Sprint Challenge to test your knowledge