Module 6 ended with a promise and a shortcoming. The shortcoming: nothing is saved on exit. Six modules of work —the catalogue, the loans, the fines, the reservations, the history— live entirely in the process memory, and when the process ends, the memory is released and everything disappears. The promise: that this module solves it.
We start with the simpler half of persistence, which is reading. Reading is simpler than writing for a practical reason: if you get reading wrong, you destroy nothing. If you get writing wrong, you can lose an entire file, and that is exactly what is going to happen to somebody in the next lesson if they do not read the table for the append parameter.
This lesson covers the complete mental model of what a file is, how it is located and how text is read from it with the two classic APIs —FileReader and Scanner—, plus the detail that causes more real incidents than anything else in the module: character encoding. By the end, BiblioTech will start up by reading its catalogue from a text file, and if that file does not exist it will start up anyway with an empty catalogue and a record in the log, applying the graceful degradation you already know how to design.
Everything opened in this lesson is closed with
try-with-resources. It is the construct from 06-06 and is taken as completely known: reverse closing order, suppressed exceptions, idempotentclose(). It is not explained again; it is always used.
Contents
- Why a program needs persistence
- What a file is: bytes on disk versus objects in memory
- The journey of a piece of data from the application to the disk
- Absolute and relative paths, and the working directory
- Path separators and portability
- The
Fileclass: the legacy API you will still run into - Reading with
FileReader: character by character - Reading with
Scannerover a file useDelimiterand convenient parsing- Character encoding: UTF-8 and the accent disaster
FileNotFoundExceptionversusIOException- Graceful degradation: BiblioTech starts with no catalogue
- Large files and why not to read everything into memory
- Common Mistakes and Tips
- Exercises
- Why a program needs persistence
The memory of a process is volatile: it exists while the process exists. When main returns, when the user closes the window or when the operating system kills the process, the JVM releases its memory and everything that was in it ceases to exist. There is nothing to recover.
This is not a defect of Java: it is the nature of RAM. A program that only works in memory is a program with no long-term memory, and that disqualifies it for almost any real use:
| Need | Without persistence | With persistence |
|---|---|---|
| Remember data between runs | Impossible | It is the base case |
| Load a thousand materials | Typing them in one by one | A file of a thousand lines |
| Share data with another program | Impossible | A file both of them understand |
| Audit what happened yesterday | Impossible | A log file |
| Configure without recompiling | Impossible | A properties file |
| Recover from a crash | Everything is lost | The last saved state is recovered |
Look at the last row of the left-hand column applied to BiblioTech: if the application crashes with twenty active loans, those twenty loans never existed. The real library will still have twenty books off the shelf, but the system will say they are all available. The system and the world stop agreeing, which is the worst thing that can happen to a management system.
To persist means writing the state to a medium that outlives the process. In this module that medium is the file system. In a large professional system it is usually a database —you will see that with Hibernate in 11-03—, but a database is, underneath, files with a lot of engineering on top. It all starts here.
- What a file is: bytes on disk versus objects in memory
This is the idea to internalise before writing a line of code:
A file is a sequence of bytes with a name. Nothing more. It has no types, no objects, no fields. Just bytes numbered from 0 upwards.
Compare the two worlds:
| Object in memory | File on disk | |
|---|---|---|
| Nature | Structure with typed fields | Flat sequence of bytes |
| Identity | Reference (address) | Path (name) |
| Lifetime | As long as the process lasts | Until somebody deletes it |
| Access | Direct, nanoseconds | Through the operating system, microseconds or more |
| References between data | Native pointers | You have to invent them (identifiers, offsets) |
| Types | The compiler knows them | They do not exist: everything is bytes |
| Cost of an operation | Cheap | Expensive: there is a system call in the middle |
When you have in memory a Book with title "Effective Java", author "Bloch" and ISBN 978-0000000001, the object occupies an area of memory with three references to three String objects. On disk you cannot store references: a memory address means nothing tomorrow, nor on another machine. You have to serialise: turn that structure into a sequence of bytes you can interpret again later. The simplest way to do it is to write text:
That line is a complete design decision. You have chosen: one material per line, fields separated by ;, a fixed field order, true/false for availability. Any program that knows that convention can read it. It is exactly what a CSV file does, and you will formalise it in 07-07.
The alternative is to let Java do the conversion for you, with the native serialisation of 07-05, which produces unreadable bytes but preserves the complete object structure. Each option has its price, and you will see it.
An important nuance about the vocabulary: people talk about text files and binary files, but that distinction does not exist on the disk. All files are binary. A "text file" is simply a file whose bytes, interpreted with a particular encoding, produce readable characters. If you open a .jpg with a text editor you will see rubbish: not because the file is different, but because you are applying the wrong interpretation. We will come back to this in section 10 and, in depth, in 07-03.
- The journey of a piece of data from the application to the disk
Between your String and the platter of the disk there are more layers than it seems. Knowing them explains why I/O is slow, why the buffer exists and why a close() can fail.
flowchart TD
A["Your Java code<br/>String line = reader.readLine()"] --> B["java.io classes<br/>FileReader, Scanner"]
B --> C["Decoding bytes into characters<br/>charset UTF-8"]
C --> D["Operating system call<br/>read syscall"]
D --> E["Operating system disk cache<br/>page cache in RAM"]
E --> F["Device driver"]
F --> G["Physical disk<br/>SSD or hard drive"]
style A fill:#e3f2fd
style D fill:#fff3e0
style G fill:#f3e5f5
The points that matter along that journey:
- The expensive frontier is the system call (orange box). Every
readforces a crossing from your application code into the operating system kernel and back. That context switch costs orders of magnitude more than executing a few instructions in memory. If you make one system call per character, the cost is devastating: it is exactly what happens with an unbufferedFileReader, and you will measure it in section 7. - Decoding is a real step (the light blue box in the middle). The bytes arriving from the disk are not characters. Somebody has to decide which byte or group of bytes forms which character, and that somebody is the charset. If nobody specifies it, a default one is used, and that is where the problems of section 10 begin.
- The operating system already has its own cache (the page cache). That is why reading the same small file twice is much faster the second time: the second read never reaches the disk. This also explains why measuring I/O performance is treacherous.
- Writing does not mean "it is on the disk". When you write, the data goes into intermediate buffers. Until they are flushed, a power cut loses it. It is the central topic of 07-02.
Keep hold of the rule that governs the whole module: the fewer system calls, the better. The entire design of Java I/O —the buffers, the blocks, transferTo— exists to reduce that number.
- Absolute and relative paths, and the working directory
To read a file you have to locate it, and here the first classic stumble appears: "it worked on my machine and on the server it says it cannot find the file".
An absolute path starts from the root of the file system and is self-sufficient:
/home/marta/bibliotech/data/catalog.txt (Linux, macOS)
C:\Users\marta\bibliotech\data\catalog.txt (Windows)A relative path does not start from the root, but from the current working directory of the process:
The key question is: relative to what? And the answer is the one that surprises everybody: relative to the directory the JVM was launched from, not to the directory where the .class is, nor to the project directory. That directory is in the user.dir system property:
public class WhereAmI {
public static void main(String[] args) {
System.out.println("Working directory: " + System.getProperty("user.dir"));
System.out.println("User home: " + System.getProperty("user.home"));
System.out.println("Temp directory: " + System.getProperty("java.io.tmpdir"));
java.io.File f = new java.io.File("data/catalog.txt");
System.out.println("Relative path given: " + f.getPath());
System.out.println("Resolves to: " + f.getAbsolutePath());
System.out.println("Exists? " + f.exists());
}
}Run that program from two different places and you will see the problem live:
# From the project root
$ cd /home/marta/bibliotech
$ java -cp target/classes WhereAmI
Working directory: /home/marta/bibliotech
Resolves to: /home/marta/bibliotech/data/catalog.txt
Exists? true
# From the classes directory
$ cd /home/marta/bibliotech/target/classes
$ java WhereAmI
Working directory: /home/marta/bibliotech/target/classes
Resolves to: /home/marta/bibliotech/target/classes/data/catalog.txt
Exists? falseThe same program, the same file on disk, two different results. This is what happens when it works in the IDE (which launches from the project root) and fails when run from a script (which launches from somewhere else).
How this is handled professionally:
| Strategy | When to use it |
|---|---|
Path as a parameter (args[0] or a -Dpath=... property) |
Almost always. Whoever runs it decides where the data is |
Path relative to user.home |
User configuration: System.getProperty("user.home") + "/.bibliotech/" |
| Absolute path in a configuration file | Server deployments. You will see it in 07-07 |
Classpath resource (getResourceAsStream) |
Data that travels inside the .jar and does not change |
| Plain relative path | Only in tests and command-line tools, and documenting it |
And a piece of advice that saves a lot of time: when a file "does not exist", always print the absolute path, never the one you were given. The message Cannot find 'data/catalog.txt' tells you nothing; Cannot find '/home/marta/bibliotech/target/classes/data/catalog.txt' solves the problem for you in three seconds. This is a direct application of lesson 06-04: an exception must carry the data whoever reads it needs in order to decide.
- Path separators and portability
Windows uses \ as the directory separator; Linux, macOS and practically everything else use /. Java offers two constants so you do not hard-code it:
import java.io.File;
public class Separators {
public static void main(String[] args) {
System.out.println("Path separator: '" + File.separator + "'");
System.out.println("List separator: '" + File.pathSeparator + "'");
// Portable construction of a path
String path = "data" + File.separator + "catalog.txt";
System.out.println("Portable path: " + path);
// A much better alternative: the File constructor with parent and child
File f = new File("data", "catalog.txt");
System.out.println("With File(parent, child): " + f.getPath());
}
}Tell the two constants apart, because they are frequently confused:
| Constant | Linux/macOS | Windows | What it is for |
|---|---|---|---|
File.separator |
/ |
\ |
Separating directories within a path |
File.pathSeparator |
: |
; |
Separating paths within a list, such as the CLASSPATH |
Now, the good practical news: Windows accepts / in almost every Java API. new File("data/catalog.txt") works on Windows. So, in practice:
- For short literals in the code, write
/and do not overthink it: it is readable and works everywhere. - To compose paths from pieces, do not concatenate strings: use
new File(parent, child)or, better still,Path.resolve()from NIO.2, which you will see in 07-06 and is the modern, correct way. - Never write
"C:\\data\\catalog.txt"in the code. Besides not being portable, that double backslash is an endless source of typos.
- The
File class: the legacy API you will still run into
File class: the legacy API you will still run intojava.io.File is from Java 1.0. It represents a path, not an open file: you can create a File pointing at something non-existent and nothing happens.
import java.io.File;
public class FileInspector {
public static void inspect(String path) {
File f = new File(path);
System.out.println("=== " + path + " ===");
System.out.println(" Absolute path : " + f.getAbsolutePath());
System.out.println(" Name : " + f.getName());
System.out.println(" Directory : " + f.getParent());
System.out.println(" Exists? : " + f.exists());
if (!f.exists()) {
System.out.println(" (nothing more to inspect)");
return;
}
System.out.println(" Is a file? : " + f.isFile());
System.out.println(" Is a folder? : " + f.isDirectory());
System.out.println(" Readable? : " + f.canRead());
System.out.println(" Writable? : " + f.canWrite());
System.out.println(" Hidden? : " + f.isHidden());
System.out.println(" Size : " + f.length() + " bytes");
System.out.println(" Modified : " + f.lastModified() + " (milliseconds since 1970)");
}
public static void main(String[] args) {
inspect("data/catalog.txt");
inspect("data");
inspect("does-not-exist.txt");
}
}The most used methods:
| Method | Returns | Important note |
|---|---|---|
exists() |
boolean |
false also if you do not have permission to find out |
isFile() / isDirectory() |
boolean |
Both false if it does not exist |
canRead() / canWrite() |
boolean |
Depends on the user running the JVM |
length() |
long |
Bytes. 0 if it does not exist, indistinguishable from an empty file |
getName() |
String |
Just the name, without directories |
getPath() |
String |
The path exactly as it was given |
getAbsolutePath() |
String |
Resolved against user.dir, without normalising the .. |
getCanonicalPath() |
String |
Absolute and normalised. Throws IOException |
lastModified() |
long |
Milliseconds since 1970. 0 if it does not exist |
listFiles() |
File[] |
null if it is not a directory or if it fails. A classic trap |
delete() |
boolean |
false without saying why |
mkdirs() |
boolean |
Also creates the intermediate directories |
And here is the great defect of this API, the one that prompted its replacement:
File f = new File("/data/important.txt");
boolean deleted = f.delete();
if (!deleted) {
// Why? Did it not exist? Do I not have permission? Was it open?
// Is it a non-empty directory? The API does not say. A mute false.
System.out.println("Could not delete it");
}That mute false is exactly the anti-pattern that module 6 spent seven lessons eradicating: reporting a failure without saying which one. The modern API, NIO.2 (java.nio.file.Path and Files, lesson 07-06), throws specific exceptions —NoSuchFileException, AccessDeniedException, DirectoryNotEmptyException— instead of returning a boolean.
So why do we learn
File? For three very practical reasons. First, because there are millions of lines of code written with it and you are going to read them. Second, because many APIs still ask for aFileas a parameter. And third, because the conversion between the two worlds is trivial:file.toPath()andpath.toFile(). Use NIO.2 in new code; understandFilein order to read the old code.
- Reading with
FileReader: character by character
FileReader: character by characterFileReader is the most elementary class for reading text from a file. Its fundamental method is read(), and its contract has a detail that must be properly understood:
It returns an int, not a char. Why? Because it needs an extra value that is not a valid character in order to signal end of file, and that value is -1. Valid characters run from 0 to 65535; -1 collides with none of them. If the method returned char, there would be no way to tell "I have read character 0" apart from "we are done".
import java.io.FileReader;
import java.io.IOException;
public class CharByCharReading {
public static void show(String path) throws IOException {
try (FileReader reader = new FileReader(path)) {
int code; // int, NOT char
while ((code = reader.read()) != -1) {
char c = (char) code; // explicit conversion
System.out.print(c);
}
}
// try-with-resources has already closed it (06-06)
}
public static void main(String[] args) throws IOException {
show("data/catalog.txt");
}
}Let us take the loop apart, because its shape is idiomatic and you will see it everywhere:
reader.read()reads the next character and advances the position.code = ...stores the result in the variable. In Java an assignment is an expression whose value is the assigned one; that is why it can be used inside the condition.- The outer parentheses are mandatory: without them,
code = reader.read() != -1would try to assign abooleanto anintand would not compile. - The comparison decides whether to carry on.
And now, the problem. This code is correct but slow, and it is worth understanding exactly how slow:
import java.io.FileReader;
import java.io.IOException;
public class MeasureSlowReading {
public static long countCharacters(String path) throws IOException {
long total = 0;
try (FileReader reader = new FileReader(path)) {
while (reader.read() != -1) {
total++;
}
}
return total;
}
public static void main(String[] args) throws IOException {
long start = System.nanoTime();
long n = countCharacters("data/catalog-large.txt");
long ms = (System.nanoTime() - start) / 1_000_000;
System.out.printf("%d characters read in %d ms%n", n, ms);
}
}With a 5 MB file, on an ordinary desktop machine, the orders of magnitude are these (illustrative figures, not a formal measurement):
| Technique | Approximate system calls | Indicative time |
|---|---|---|
FileReader.read() character by character |
~5,000,000 | ~4,500 ms |
FileReader.read(char[8192]) in blocks |
~640 | ~90 ms |
BufferedReader.readLine() (lesson 07-04) |
~640 | ~60 ms |
Two orders of magnitude of difference, and the cause is exactly the one from section 3: every unbuffered read() translates into a request to the operating system. Five million frontier crossings to read five megabytes.
The intermediate solution, without leaving FileReader yet, is to read in blocks with the overload that accepts an array:
import java.io.FileReader;
import java.io.IOException;
public class BlockReading {
public static String readAll(String path) throws IOException {
StringBuilder content = new StringBuilder();
try (FileReader reader = new FileReader(path)) {
char[] block = new char[8192]; // 8 KB, the usual size
int read;
// read(char[]) returns HOW MANY characters it has put into the
// array, or -1 if there was nothing to read. It may return FEWER
// than 8192 even with data left: never assume it fills the array.
while ((read = reader.read(block)) != -1) {
content.append(block, 0, read); // only the useful part
}
}
return content.toString();
}
}Two critical details in that code, and both are sources of real bugs:
read(char[])returns the number of elements read, which may be smaller than the size of the array even though the file has not finished. Never assume it fills the buffer.- You have to use
append(block, 0, read), notappend(block). If you use the unbounded version, on the last pass you will add the rubbish left in the array from the previous pass. It is a failure that only shows up at the end of the file and with apparently random content.
The definitive way of solving this —wrapping the FileReader in a BufferedReader that manages the block for you— is the topic of 07-04. Here the why is clear; there you will see the how in full detail.
- Reading with
Scanner over a file
Scanner over a fileYou already know Scanner from module 1, reading from System.in. The same class reads from a file: all that changes is what you pass to the constructor.
import java.io.File;
import java.io.FileNotFoundException;
import java.util.Scanner;
public class ScannerReading {
public static void showLines(String path) throws FileNotFoundException {
// Scanner is Closeable: try-with-resources compulsory (06-06).
// Closing it also closes the underlying file.
try (Scanner sc = new Scanner(new File(path))) {
int number = 1;
while (sc.hasNextLine()) {
String line = sc.nextLine();
System.out.printf("%3d | %s%n", number++, line);
}
}
}
}The hasNextLine() / nextLine() pair is the canonical pattern and deserves a warning:
Always check with
hasNextLine()before callingnextLine(). If you callnextLine()with no lines left, it throwsNoSuchElementException, which is unchecked: the compiler does not warn you and it blows up at run time. It is the sameIteratorcontract you saw in 05-02.
Scanner does not stop at lines: it knows how to parse types, and that is where its convenience lies. Suppose this BiblioTech loans file:
LN-0001 978-0000000001 EMP-001 12 0.25
LN-0002 978-0000000002 EMP-002 30 0.25
LN-0003 978-0000000003 EMP-003 5 0.10It is read like this, field by field, without splitting strings by hand:
import java.io.File;
import java.io.FileNotFoundException;
import java.util.Scanner;
public class ReadLoans {
public static void read(String path) throws FileNotFoundException {
try (Scanner sc = new Scanner(new File(path))) {
while (sc.hasNext()) {
String reference = sc.next(); // LN-0001
String isbn = sc.next(); // 978-0000000001
String employee = sc.next(); // EMP-001
int days = sc.nextInt(); // 12
double rate = sc.nextDouble(); // 0.25
System.out.printf("%s: %s to %s, %d days, %.2f EUR/day%n",
reference, isbn, employee, days, rate);
}
}
}
}Watch out for two Scanner behaviours that cause surprises:
nextDouble()depends on the locale. With a Spanish locale the decimal separator is the comma, so0.25may fail withInputMismatchExceptionwhile0,25works. It is a failure that only appears on some machines and is baffling. The fix is to set the locale explicitly:
import java.util.Locale;
Scanner sc = new Scanner(new File(path));
sc.useLocale(Locale.ROOT); // decimal point, always, on any machineThis same problem, applied to CSV files opened in a Spanish spreadsheet, comes back in 07-07.
nextInt()does not consume the line break. If you mixnextInt()withnextLine(), thenextLine()will return the empty string left up to the end of the current line. It is the trap you already met with the console in module 1, and here it is identical.
An honest comparison of the two APIs seen so far:
Raw FileReader |
Scanner over a file |
|
|---|---|---|
| Unit of reading | Character or block of characters | Line, word, int, double, regular expression |
| Parsing | By hand | Included |
| Speed | Slow without a buffer | Slow: it uses regular expressions internally |
| Format errors | Does not detect them | InputMismatchException |
| Constructor exception | FileNotFoundException |
FileNotFoundException |
| Encoding | Second Charset parameter |
useLocale for numbers, Charset in the constructor |
| Good for | Copying, processing without structure | Small files with a tabulated format |
| Bad for | Anything with structure | Large files: it is the slowest of the three |
For a twenty-line configuration file, Scanner is convenient and its slowness is irrelevant. For the catalogue file with ten thousand materials, the right answer is BufferedReader, and you will see it in 07-04.
useDelimiter and convenient parsing
useDelimiter and convenient parsingBy default, Scanner splits on whitespace. useDelimiter changes that criterion to any regular expression, which lets you read files with their own separators without splitting strings by hand.
With this catalogue file:
978-0000000001;Effective Java;Bloch;2018
978-0000000002;Design Patterns;Gamma;1994
978-0000000003;Refactoring;Fowler;1999It is read like this:
import java.io.File;
import java.io.FileNotFoundException;
import java.util.Scanner;
public class ReadWithDelimiter {
public static void read(String path) throws FileNotFoundException {
try (Scanner sc = new Scanner(new File(path))) {
// Delimiter: a semicolon OR a line break (in any of its three
// forms). \\R stands for any line terminator.
sc.useDelimiter(";|\\R");
while (sc.hasNext()) {
String isbn = sc.next();
String title = sc.next();
String author = sc.next();
int year = sc.nextInt();
System.out.printf("[%s] %-25s %-10s %d%n", isbn, title, author, year);
}
}
}
}Useful delimiters:
| Pattern | Meaning |
|---|---|
";" |
Semicolon only |
";|\\R" |
Semicolon or any line break |
"\\s*,\\s*" |
Comma with optional spaces around it |
"\\R" |
Line breaks only: equivalent to reading line by line |
"\\Z" |
End of input: reads the whole file as a single token |
That last trick has its charm for very small files:
try (Scanner sc = new Scanner(new File("note.txt"))) {
sc.useDelimiter("\\Z");
String everything = sc.hasNext() ? sc.next() : "";
System.out.println(everything);
}It works, but it is a workaround. Since Java 11 there is Files.readString(Path), which does the same thing explicitly and correctly, and you will see it in 07-06. Recognise the trick when you read it in somebody else's code; do not write it yourself.
And a warning that must be given now, even though it is developed in 07-07: splitting a CSV on ; or , is correct only as long as no field contains the separator. As soon as a title like "Java: the language, the machine and the ecosystem" appears inside quotes, this code silently produces misaligned fields. Lesson 07-07 implements a CSV reader that does get it right.
- Character encoding: UTF-8 and the accent disaster
This section is the most important one in the lesson. Most of the I/O failures you will see in your professional career are not logic failures: they are encoding failures.
The underlying problem
A file contains bytes. A String contains characters. Converting from one to the other requires a mapping table, and that table is the charset, or encoding.
- ASCII (1963): 128 characters, one byte each. No
é, noï, no€. - ISO-8859-1 (Latin-1): 256 characters, one byte each. It has
é,ï,ëand the rest of the Western European accented letters. It has no€and nothing outside Western Europe. - Windows-1252: a variant of the previous one, the Windows encoding for Western Europe. Almost the same but not exactly, which generates subtle failures.
- UTF-8 (1993): covers all of Unicode with a variable length of 1 to 4 bytes. ASCII is an exact subset of it. It is the de facto standard of the web and of data interchange.
How much each character takes in UTF-8:
| Character | Bytes in UTF-8 | Bytes in ISO-8859-1 |
|---|---|---|
a |
1 (0x61) |
1 (0x61) |
é |
2 (0xC3 0xA9) |
1 (0xE9) |
€ |
3 (0xE2 0x82 0xAC) |
Not representable |
| An emoji | 4 | Not representable |
A direct consequence that surprises a lot of people: in UTF-8, the number of characters in a text is not the number of bytes in the file. "café" is 4 characters and 5 bytes. That is why File.length() (bytes) does not tell you how many characters there are.
What exactly happens if you get it wrong
You write "Naïve Set Theory" in UTF-8 and read it as ISO-8859-1. The two bytes of the ï (0xC3 0xAF) are interpreted as two independent characters:
The other way round, you write in ISO-8859-1 and read in UTF-8. The byte 0xEF of the ï does not form a valid UTF-8 sequence, and the decoder replaces it with the replacement character:
That ï and that � are the two symptoms you have to learn to recognise instantly. When you see them, do not look for the failure in your logic: it is encoding.
And there is something worse than the ugliness. Look at this case:
// File written in UTF-8, read as ISO-8859-1
String title = "Naïve Set Theory"; // what has been loaded into memory
// The user's search, correctly written, finds nothing:
if (title.equals("Naïve Set Theory")) { // false
// never entered
}
System.out.println(title.length()); // 17, not 16The data is still wrong after you read it. It will be stored wrong, compared wrong and displayed wrong. The corruption spreads.
The default encoding and why you must not trust it
Up to and including Java 17, when you do not specify a charset, FileReader and FileWriter use the platform default encoding, which depends on the operating system and the locale:
| Environment | Historical default encoding |
|---|---|
| Modern Linux | UTF-8 |
| macOS | UTF-8 |
| Windows in Western Europe | Windows-1252 |
| Windows in Japan | Shift_JIS |
| Minimal Docker container | Frequently US-ASCII |
This means that the same program reading the same file produces different results on different machines. It is the cause of the classic "on my laptop it looks fine and on the server it comes out with question marks".
Java 18 (JEP 400) changed the default value of file.encoding to UTF-8 on all platforms, which is a big improvement. But:
- If you work with Java 17 —the reference version of this course and the most used in production— you still have the old behaviour.
- Even if your project is Java 21, code that specifies the charset is self-explanatory and does not depend on a property somebody may change with
-Dfile.encoding.
Professional rule, no exceptions: always specify the charset explicitly in every text operation. It costs nothing and eliminates an entire category of failures.
How to do it properly
Since Java 11, FileReader accepts a Charset in the constructor:
import java.io.FileReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
public class ReadingWithCharset {
public static void read(String path) throws IOException {
// Java 11+: explicit charset in the constructor
try (FileReader reader = new FileReader(path, StandardCharsets.UTF_8)) {
int c;
while ((c = reader.read()) != -1) {
System.out.print((char) c);
}
}
}
}In earlier versions —and it is still the most general form, the one you will see in 07-03— the InputStreamReader bridge is used:
import java.io.FileInputStream;
import java.io.InputStreamReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
try (InputStreamReader reader =
new InputStreamReader(new FileInputStream(path), StandardCharsets.UTF_8)) {
// ...
}And Scanner accepts it too:
The constants available in java.nio.charset.StandardCharsets, guaranteed in every JVM:
| Constant | Name |
|---|---|
StandardCharsets.UTF_8 |
UTF-8. Your default choice, always |
StandardCharsets.ISO_8859_1 |
Latin-1. Only for reading old files |
StandardCharsets.US_ASCII |
7-bit ASCII |
StandardCharsets.UTF_16 |
UTF-16 with a byte order mark |
Use the constants, not the strings.
new FileReader(path, StandardCharsets.UTF_8)cannot fail; the version that accepts the name as a string throwsUnsupportedEncodingExceptionif you write"UTF8","utf-8 "with a space or any other typo. It is a compile error turned into a run-time error with no advantage whatsoever.
FileNotFoundException versus IOException
FileNotFoundException versus IOExceptionI/O is the natural territory of the checked exceptions of module 6, and for a reason that is now obvious: the file belongs to the outside world. It may not exist, its permissions may have changed, the disk may fill up, somebody may unplug the network drive. None of that is a failure of your program, and all of it is foreseeable.
The relevant hierarchy:
flowchart TD
T["Throwable"] --> E["Exception"]
E --> IOE["IOException<br/>(checked)"]
IOE --> FNF["FileNotFoundException"]
IOE --> EOF["EOFException"]
IOE --> UEE["UnsupportedEncodingException"]
IOE --> NSF["NoSuchFileException<br/>(NIO.2, 07-06)"]
IOE --> ADE["AccessDeniedException<br/>(NIO.2, 07-06)"]
IOE --> MFE["MalformedInputException<br/>(invalid charset)"]
style IOE fill:#ffe0b2
style FNF fill:#e3f2fd
What each one means in practice:
| Exception | When it is thrown | What is usually done |
|---|---|---|
FileNotFoundException |
The file does not exist, is a directory, or there is no read permission | Degrade: use a default value, or ask for the correct path |
IOException |
Generic failure while reading: disk, network, device | Log and propagate. Rarely recoverable |
EOFException |
Unexpected end while reading structured binary data (07-03) | Truncated or corrupt file: abort the load |
MalformedInputException |
Bytes that do not form valid characters in the given charset | Wrong charset. Review section 10 |
A misleading note about the name: FileNotFoundException does not only mean "it does not exist". It is also thrown if the path points to a directory or if the file exists but you do not have read permission. That is why the operating system message is so valuable and you must never discard it.
The order of the catch blocks follows the rule from 06-02, from specific to general:
import java.io.FileNotFoundException;
import java.io.IOException;
public void load(String path) {
try (Scanner sc = new Scanner(new File(path), StandardCharsets.UTF_8)) {
// ... read ...
} catch (FileNotFoundException e) {
// The specific one first. This case can be handled differently.
LOG.log(Level.WARNING, "File not found: " + path, e);
} catch (IOException e) {
// The general one afterwards. The other way round DOES NOT COMPILE: unreachable code.
LOG.log(Level.SEVERE, "I/O failure reading " + path, e);
}
}
- Graceful degradation: BiblioTech starts with no catalogue
Let us put it all together with the first real piece of the module. CatalogLoader reads a text file and builds materials, and —this is the important part— if the file does not exist, the application starts up anyway with an empty catalogue.
This is exactly the criterion from 06-07 for telling the recoverable from the unrecoverable: degrade when the reduced service is still correct. A library with no materials is an empty library, which is a perfectly valid state and one in which materials can be registered. There is nothing incorrect about it. It would be very different to start up without knowing the fine rate: there you cannot degrade, because we would be charging false amounts.
The file format:
# BiblioTech catalogue - Nexus Software
# type;reference;title;author;year
BOOK;978-0000000001;Effective Java;Joshua Bloch;2018
BOOK;978-0000000002;Design Patterns;Erich Gamma;1994
BOOK;978-0000000003;Refactoring;Martin Fowler;1999And the loader:
package com.nexussoftware.bibliotech.service;
import java.io.File;
import java.io.FileNotFoundException;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;
import java.util.logging.Level;
import java.util.logging.Logger;
import com.nexussoftware.bibliotech.domain.Book;
import com.nexussoftware.bibliotech.domain.DuplicateReferenceException;
/**
* Loads the BiblioTech catalogue from a text file.
*
* Error policy (06-07):
* - Missing file -> DEGRADE: empty catalogue and a warning in the log.
* - Badly formatted line -> DISCARD that line and carry on with the rest.
* - Serious I/O failure -> propagate as CatalogNotAccessibleException.
*
* This version uses Scanner for clarity. In 07-04 it is replaced by
* BufferedReader, which is the right choice for files of thousands of lines.
*/
public class CatalogLoader {
private static final Logger LOG = Logger.getLogger(CatalogLoader.class.getName());
private static final String COMMENT = "#";
private static final String SEPARATOR = ";";
private static final int BOOK_FIELDS = 5;
private final String path;
private int linesRead = 0;
private int materialsAdded = 0;
private int linesDiscarded = 0;
public CatalogLoader(String path) {
this.path = java.util.Objects.requireNonNull(path, "The path cannot be null");
}
/**
* Loads the catalogue. NEVER throws because of a missing file: it degrades.
*
* @return a Catalog, possibly empty, never null
*/
public Catalog load() {
Catalog catalog = new Catalog();
File file = new File(path);
// Useful warning: ALWAYS log the absolute path, not the relative one (section 4)
LOG.config(() -> "Loading catalogue from " + file.getAbsolutePath());
try (Scanner sc = new Scanner(file, StandardCharsets.UTF_8)) {
while (sc.hasNextLine()) {
String line = sc.nextLine();
linesRead++;
processLine(catalog, line, linesRead);
}
LOG.info(String.format(
"Catalogue loaded: %d materials from %d lines (%d discarded)",
materialsAdded, linesRead, linesDiscarded));
} catch (FileNotFoundException e) {
// GRACEFUL DEGRADATION. It is not an application failure: the first
// time BiblioTech is run, this file does not exist yet.
// WARNING level, not SEVERE: when everything is SEVERE nobody looks
// at the real errors (06-07).
LOG.log(Level.WARNING,
"There is no catalogue file at " + file.getAbsolutePath()
+ "; starting with an empty catalogue", e);
}
return catalog;
}
/** Processes one line. Format failures discard the line, they do not abort the load. */
private void processLine(Catalog catalog, String line, int number) {
String clean = line.trim();
// Empty lines and comments: ignored silently, they are normal
if (clean.isEmpty() || clean.startsWith(COMMENT)) {
return;
}
String[] fields = clean.split(SEPARATOR);
if (fields.length != BOOK_FIELDS) {
discard(number, "expected " + BOOK_FIELDS
+ " fields but found " + fields.length);
return;
}
if (!"BOOK".equalsIgnoreCase(fields[0].trim())) {
discard(number, "unknown type '" + fields[0].trim() + "'");
return;
}
try {
String isbn = fields[1].trim();
String title = fields[2].trim();
String author = fields[3].trim();
int year = Integer.parseInt(fields[4].trim());
catalog.register(new Book(title, author, isbn, year));
materialsAdded++;
} catch (NumberFormatException e) {
// The year was not a number. Translated into a discard with context (06-03).
discard(number, "the year '" + fields[4].trim() + "' is not a number");
} catch (DuplicateReferenceException e) {
// Our own exception from module 6: the ISBN was already there. The line is discarded.
discard(number, e.getMessage());
}
}
private void discard(int number, String reason) {
linesDiscarded++;
LOG.warning(() -> "Line " + number + " discarded: " + reason);
}
public int getLinesRead() { return linesRead; }
public int getMaterialsAdded() { return materialsAdded; }
public int getLinesDiscarded() { return linesDiscarded; }
}And the application start-up now looks like this:
package com.nexussoftware.bibliotech.presentation;
import com.nexussoftware.bibliotech.service.CatalogLoader;
import com.nexussoftware.bibliotech.service.Catalog;
import com.nexussoftware.bibliotech.infrastructure.LogConfiguration;
public class BiblioTechApp {
/** Default path; it can be changed with a command-line argument. */
private static final String DEFAULT_CATALOG_PATH = "data/catalog.txt";
public static void main(String[] args) {
LogConfiguration.initialise(); // 06-07
String path = (args.length > 0) ? args[0] : DEFAULT_CATALOG_PATH;
Catalog catalog = new CatalogLoader(path).load();
System.out.println("BiblioTech - Nexus Software");
System.out.println("Materials in the catalogue: " + catalog.size());
new BiblioTechMenu(catalog).start();
}
}The three design decisions worth pointing out:
- The path arrives as an argument, with a default value. It is the recommended strategy from section 4: whoever runs it decides where the data is.
load()never returnsnull. It returns an emptyCatalogif there is no file. It is the rule 06-07 established:nullis not a way of communicating anything.- A bad line does not abort the file. It is discarded, counted and logged. Having a ten-thousand-line catalogue fail to load because line 4,200 has a badly written year would be a dreadful policy. This is the "result object" from 06-07 applied to importing, and in 07-04 it will become a complete report.
- Large files and why not to read everything into memory
We close with a sizing warning. It is tempting to write this:
And for a 2 KB configuration file it is perfectly reasonable. But for three years of loan history, no:
| File size | Approximate memory of the String |
Viable with a 512 MB heap |
|---|---|---|
| 10 KB | ~20 KB | Yes, without thinking about it |
| 5 MB | ~10 MB | Yes |
| 200 MB | ~400 MB | At the limit, risky |
| 2 GB | ~4 GB | OutOfMemoryError |
Why the factor of two: internally a Java String from Java 9 onwards stores one byte per character when all the text is Latin-1 and two bytes per character as soon as a character outside that range appears. A single emoji or one Cyrillic character in a 2 GB file doubles the memory of the whole String. And you also need the intermediate StringBuilder while you build it, so at the peak you have the content twice over.
An OutOfMemoryError is an Error, not an Exception: it is not caught, it is not recovered from and it brings the process down, with the exception of the main boundary you saw in 06-07.
The correct alternative is to process in a stream: read a line, process it, forget it, read the next one. The memory used is that of one line, not that of the file, and the consumption is constant even if the file is 50 GB:
// Outline of stream processing. The full version, in 07-04.
try (BufferedReader reader = new BufferedReader(
new FileReader(path, StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
process(line); // used and discarded
}
}The practical rule:
Load the whole file into memory only if you know its maximum size and it is small. Configuration, templates, files of a few KB: go ahead. Data, logs, exports, anything that grows with use: process in a stream.
That is exactly the reason BufferedReader exists, which is lesson 07-04.
Common Mistakes and Tips
- Using relative paths and not knowing what they are relative to. The classic "it works in the IDE and fails in production". Print
System.getProperty("user.dir")as soon as you have doubts and pass paths as parameters. - Reporting the error with the relative path.
Cannot find 'data/catalog.txt'is useless. Always loggetAbsolutePath(). - Not specifying the charset. The most expensive mistake in the module in the long run, because it does not fail: it corrupts silently.
StandardCharsets.UTF_8, always, in every text operation. - Confusing
ïwith?.ïmeans you wrote UTF-8 and read Latin-1.?or�means the opposite. Recognising the symptom saves you half an hour every time. - Using strings instead of charset constants.
"UTF8"compiles and blows up at run time.StandardCharsets.UTF_8cannot be misspelled. - Declaring
char c = reader.read(). It does not compile, and for a good reason: the-1for end of file is needed, and it does not fit in achar. - Forgetting the parentheses in
while ((c = reader.read()) != -1). Without them it does not compile. With them, it is the idiomatic loop you will always see. - Using
append(block)instead ofappend(block, 0, read). It adds rubbish from the previous pass on the last read. It only fails at the end of the file, which is what makes it hard to diagnose. - Calling
nextLine()without checkinghasNextLine().NoSuchElementException, unchecked, at run time. - Mixing
nextInt()andnextLine()without consuming the line break. The same trap as in module 1, identical over files. - Trusting
nextDouble()without setting theLocale. On a machine with a Spanish locale,0.25may fail.sc.useLocale(Locale.ROOT). - Reading a large file character by character. Correct and a hundred times slower. Blocks or
BufferedReader(07-04). - Loading a file of unknown size into a
String.OutOfMemoryError, which is not caught. Process in a stream. - Interpreting
length() == 0as "it does not exist". An empty file gives the same result. Useexists(). - Ignoring that
listFiles()can returnnull.NullPointerExceptionin thefor. NIO.2 solves it (07-06). - Tip: write a single class that knows how to read each type of file. If you have
new FileReader(...)scattered across twenty classes, changing the charset or the format will be a manhunt. Concentrate the I/O in the layer it belongs to, asCatalogLoaderdoes. - Tip: always test with a file containing
é,ïand€. A test file with ASCII only will hide every encoding failure from you until it reaches production. - Tip: test with the missing file and with the empty file too. They are the two cases most often forgotten and the two that happen most, especially on the system's first day of life.
Exercises
Exercise 1: path diagnosis
Write a class PathDiagnostic with a method public static void diagnose(String path) that prints a complete and useful report about a path, designed so that somebody can solve a problem with it:
- The path exactly as it was given and its absolute form.
- The current working directory.
- Whether it exists; and if it does not exist, whether its parent directory exists (to tell "the file is missing" from "that is not the directory").
- If it exists: whether it is a file or a directory, whether it is readable, its size and its number of lines.
- A final one-sentence diagnosis with the most likely cause.
Test it with a correct path, a non-existent one and a directory.
Exercise 2: encoding detector
Write EncodingDetector with a method public static void compare(String path) that reads the same file three times —as UTF-8, as ISO-8859-1 and as US-ASCII— and shows the first three lines of each reading, so as to see the effect on the accented characters.
Then add public static boolean looksLikeUtf8(String path), which returns true if the file decodes without a single replacement character (\uFFFD) when read as UTF-8. Explain in a comment why that check detects "this is not UTF-8" but cannot detect "this is Latin-1 read as UTF-8 the other way round".
Prepare a test file with Naïve Set Theory, The Café Method and Zoë Blackwood, 2018.
Exercise 3: employee loader with a report
Extend the idea of CatalogLoader to employees. Given a file like this:
Write EmployeeLoader with:
public LoadResult load(), returning a result object (the idea from 06-07) with the list of loaded employees and the list of errors, instead of throwing on the first failure.- Validation of each line: exactly 3 fields, identifier with the format
EMP-NNN, non-empty name and a number of loans between 0 andEmployee.MAX_CONCURRENT_LOANS. - Degradation if the file does not exist: an empty result with a warning, no exception.
- Explicit charset and
try-with-resources. - A demonstration
mainthat prints the report withprintf.
Add to the test file a line with two fields, one with the identifier X-999 and one with 9 loans, to check that all three are discarded with their reason and the good ones are loaded.
Solutions
Solution 1
package com.nexussoftware.bibliotech.util;
import java.io.File;
import java.io.FileNotFoundException;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;
/**
* Diagnostic report for a path.
*
* The aim is NOT to find out whether the file exists, but to be able to explain
* WHY it does not exist, which is what is really needed to fix it (06-04).
*/
public class PathDiagnostic {
public static void diagnose(String path) {
File f = new File(path);
System.out.println("========================================");
System.out.println("DIAGNOSIS OF: " + path);
System.out.println("========================================");
// 1. Path given and path resolved
System.out.println(" Path given : " + f.getPath());
System.out.println(" Absolute path : " + f.getAbsolutePath());
System.out.println(" Is it absolute? : " + f.isAbsolute());
// 2. Execution context: the piece that is almost always missing
System.out.println(" Working directory: " + System.getProperty("user.dir"));
// 3. Existence, telling the file from the parent directory
boolean exists = f.exists();
System.out.println(" Exists? : " + exists);
File parent = f.getAbsoluteFile().getParentFile();
boolean parentExists = (parent != null && parent.exists());
System.out.println(" Parent directory : "
+ (parent == null ? "(none)" : parent.getAbsolutePath())
+ (parentExists ? " [exists]" : " [does NOT exist]"));
// 4. Details only if it exists
if (exists) {
System.out.println(" Is a file? : " + f.isFile());
System.out.println(" Is a directory? : " + f.isDirectory());
System.out.println(" Readable? : " + f.canRead());
System.out.println(" Writable? : " + f.canWrite());
System.out.println(" Size : " + f.length() + " bytes");
if (f.isFile() && f.canRead()) {
System.out.println(" Lines : " + countLines(f));
}
}
// 5. Conclusion in one sentence
System.out.println(" ----");
System.out.println(" CONCLUSION: " + conclude(f, exists, parentExists));
System.out.println();
}
/** Counts lines safely: if it fails, it says so instead of propagating. */
private static String countLines(File f) {
try (Scanner sc = new Scanner(f, StandardCharsets.UTF_8)) {
int n = 0;
while (sc.hasNextLine()) {
sc.nextLine();
n++;
}
return String.valueOf(n);
} catch (FileNotFoundException e) {
// It can happen between the exists() and the open(): it is a TOCTOU
// race condition, exactly the one warned about in 06-07.
return "(could not be read: " + e.getMessage() + ")";
}
}
private static String conclude(File f, boolean exists, boolean parentExists) {
if (exists && f.isFile() && f.canRead()) {
return "The file exists and can be read. All correct.";
}
if (exists && f.isDirectory()) {
return "The path points to a DIRECTORY, not to a file. "
+ "Opening it would throw FileNotFoundException, "
+ "in spite of what its name suggests.";
}
if (exists && !f.canRead()) {
return "The file exists but the user running the JVM "
+ "does not have read permission. Check the permissions.";
}
if (!exists && !parentExists) {
return "Neither the file nor its directory exists. The most likely "
+ "thing is that the relative path is being resolved from a "
+ "different working directory than expected.";
}
return "The directory exists but the file does not. Check the exact "
+ "name, including capitals: on Linux they matter.";
}
public static void main(String[] args) {
diagnose("data/catalog.txt"); // correct
diagnose("data"); // a directory
diagnose("data/does-not-exist.txt"); // the file is missing
diagnose("other/bad/path.txt"); // the directory is missing
}
}What this exercise teaches: the difference between detecting a failure and explaining it. The five cases in conclude correspond to five different causes that the API sums up in a single exists() returning false. Telling them apart is what turns an error message into a solution. Notice too the comment about TOCTOU: between checking exists() and opening the file anything can happen, so the prior check does not replace the catch.
Solution 2
package com.nexussoftware.bibliotech.util;
import java.io.FileReader;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
/**
* Shows the effect of reading the same file with different encodings.
*/
public class EncodingDetector {
/** Replacement character the decoder inserts when it meets invalid bytes. */
private static final char REPLACEMENT = '\uFFFD';
public static void compare(String path) {
Charset[] candidates = {
StandardCharsets.UTF_8,
StandardCharsets.ISO_8859_1,
StandardCharsets.US_ASCII
};
for (Charset cs : candidates) {
System.out.println("--- Read as " + cs.name() + " ---");
try {
String[] lines = firstLines(path, cs, 3);
for (String l : lines) {
System.out.println(" " + l);
}
} catch (IOException e) {
System.out.println(" ERROR: " + e.getMessage());
}
System.out.println();
}
}
/** Reads the first n lines with the given charset. */
private static String[] firstLines(String path, Charset cs, int n) throws IOException {
String[] result = new String[n];
int index = 0;
StringBuilder current = new StringBuilder();
try (FileReader reader = new FileReader(path, cs)) {
int c;
while (index < n && (c = reader.read()) != -1) {
if (c == '\n') {
result[index++] = current.toString();
current.setLength(0);
} else if (c != '\r') {
current.append((char) c);
}
}
}
// Last line with no trailing break
if (index < n && current.length() > 0) {
result[index++] = current.toString();
}
for (int i = index; i < n; i++) {
result[i] = "(no more lines)";
}
return result;
}
/**
* Does the file decode as UTF-8 without replacement characters?
*
* WHY IT WORKS IN ONE DIRECTION: UTF-8 has a strict grammar.
* The byte 0xE9 (the 'e' with an acute accent in Latin-1) announces a
* 3-byte sequence that in a Latin-1 file will not be well formed, so the
* decoder inserts \uFFFD. We detect "this is NOT UTF-8".
*
* WHY IT DOES NOT WORK IN THE OTHER: a UTF-8 file read as Latin-1
* NEVER produces replacements, because in Latin-1 all 256 possible bytes
* are valid characters. The wrong characters simply come out
* ("NaA-ve"). A decoder cannot know that this is wrong: to it,
* it is perfectly legal text. That is why RELIABLE encoding detection
* DOES NOT EXIST, and why it must always be SPECIFIED.
*/
public static boolean looksLikeUtf8(String path) throws IOException {
try (FileReader reader = new FileReader(path, StandardCharsets.UTF_8)) {
int c;
while ((c = reader.read()) != -1) {
if (c == REPLACEMENT) {
return false;
}
}
}
return true;
}
public static void main(String[] args) throws IOException {
String path = "data/accent-test.txt";
compare(path);
System.out.println("Looks like UTF-8? " + looksLikeUtf8(path));
}
}With a test file saved in UTF-8 containing Naïve Set Theory, The Café Method and Zoë Blackwood, 2018, the output is:
--- Read as UTF-8 ---
Naïve Set Theory
The Café Method
Zoë Blackwood, 2018
--- Read as ISO-8859-1 ---
Naïve Set Theory
The Café Method
Zoë Blackwood, 2018
--- Read as US-ASCII ---
Na??ve Set Theory
The Caf?? Method
Zo?? Blackwood, 2018
Looks like UTF-8? trueThe practical conclusion is the one in the comment: encoding detection is fundamentally impossible in the general case. Text editors guess it with heuristics and sometimes get it wrong. That is why no amount of cleverness in the code replaces the simple rule: always specify the charset, when reading and when writing.
Solution 3
package com.nexussoftware.bibliotech.service;
import java.io.File;
import java.io.FileNotFoundException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Scanner;
import java.util.logging.Level;
import java.util.logging.Logger;
import com.nexussoftware.bibliotech.domain.Employee;
/**
* Loads employees from a text file accumulating errors instead of
* aborting on the first one (result object, 06-07).
*/
public class EmployeeLoader {
private static final Logger LOG = Logger.getLogger(EmployeeLoader.class.getName());
private static final String SEPARATOR = ";";
private static final String COMMENT = "#";
private static final int NUM_FIELDS = 3;
private static final String ID_PATTERN = "EMP-\\d{3}";
private final String path;
public EmployeeLoader(String path) {
this.path = Objects.requireNonNull(path, "The path cannot be null");
}
/** Result of the load: what went well and what went wrong, together. */
public static class LoadResult {
private final List<Employee> employees = new ArrayList<>();
private final List<String> errors = new ArrayList<>();
private boolean fileFound = true;
void add(Employee e) { employees.add(e); }
void error(int line, String m) { errors.add("Line " + line + ": " + m); }
void markFileMissing() { fileFound = false; }
public List<Employee> getEmployees() { return List.copyOf(employees); }
public List<String> getErrors() { return List.copyOf(errors); }
public boolean hasErrors() { return !errors.isEmpty(); }
public boolean fileFound() { return fileFound; }
public int total() { return employees.size() + errors.size(); }
}
public LoadResult load() {
LoadResult result = new LoadResult();
File file = new File(path);
try (Scanner sc = new Scanner(file, StandardCharsets.UTF_8)) {
int number = 0;
while (sc.hasNextLine()) {
number++;
process(sc.nextLine(), number, result);
}
} catch (FileNotFoundException e) {
// DEGRADATION: empty result, a warning, and no exception upwards
result.markFileMissing();
LOG.log(Level.WARNING, "There is no employee file at "
+ file.getAbsolutePath() + "; starting with no employees", e);
}
return result;
}
private void process(String line, int number, LoadResult r) {
String clean = line.trim();
if (clean.isEmpty() || clean.startsWith(COMMENT)) {
return; // normal, not an error
}
// -1 in split keeps the trailing empty fields: "EMP-001;;" gives 3 fields.
// Without the -1 it would give 1, and the error message would be misleading.
String[] fields = clean.split(SEPARATOR, -1);
if (fields.length != NUM_FIELDS) {
r.error(number, "expected " + NUM_FIELDS + " fields but found " + fields.length);
return;
}
String id = fields[0].trim();
String name = fields[1].trim();
String active = fields[2].trim();
if (!id.matches(ID_PATTERN)) {
r.error(number, "identifier '" + id + "' does not have the format EMP-NNN");
return;
}
if (name.isEmpty()) {
r.error(number, "the name is empty");
return;
}
int loans;
try {
loans = Integer.parseInt(active);
} catch (NumberFormatException e) {
r.error(number, "'" + active + "' is not a valid number of loans");
return;
}
if (loans < 0 || loans > Employee.MAX_CONCURRENT_LOANS) {
r.error(number, "active loans out of range (0.."
+ Employee.MAX_CONCURRENT_LOANS + "): " + loans);
return;
}
Employee employee = new Employee(name, id);
for (int i = 0; i < loans; i++) {
employee.registerLoan(); // restores the saved state
}
r.add(employee);
}
public static void main(String[] args) {
LoadResult r = new EmployeeLoader("data/employees.txt").load();
System.out.println("=== EMPLOYEE LOAD ===");
if (!r.fileFound()) {
System.out.println(" (there was no file: starting with no employees)");
return;
}
System.out.printf(" Lines processed : %d%n", r.total());
System.out.printf(" Employees loaded : %d%n", r.getEmployees().size());
System.out.printf(" Lines with errors : %d%n", r.getErrors().size());
if (!r.getEmployees().isEmpty()) {
System.out.println(" --- Loaded ---");
for (Employee e : r.getEmployees()) {
System.out.printf(" %-8s %s%n", e.getIdentifier(), e.getName());
}
}
if (r.hasErrors()) {
System.out.println(" --- Discarded ---");
for (String error : r.getErrors()) {
System.out.println(" " + error);
}
}
}
}With this test file:
# identifier;name;active_loans
EMP-001;Marta Ruiz;2
EMP-002;Diego Alonso;0
EMP-003;Nuria Vidal;1
EMP-004;Only two fields
X-999;Bad format;1
EMP-005;Too many;9The output is:
=== EMPLOYEE LOAD ===
Lines processed : 6
Employees loaded : 3
Lines with errors : 3
--- Loaded ---
EMP-001 Marta Ruiz
EMP-002 Diego Alonso
EMP-003 Nuria Vidal
--- Discarded ---
Line 5: expected 3 fields but found 2
Line 6: identifier 'X-999' does not have the format EMP-NNN
Line 7: active loans out of range (0..3): 9The three decisions worth pointing out:
- The result object instead of one exception per line. Whoever imports a file wants to see all the problems at once so as to fix them and try again, not discover them one by one. It is exactly the use case 06-07 put forward for the result object.
split(SEPARATOR, -1). Without that-1,splitdiscards the trailing empty fields, soEMP-001;;would give 1 field instead of 3 and the error message would be wrong. It is a classic trap that will reappear in 07-07.- A missing file is not a line error. It is flagged separately, because they are two different situations: "there is no data" and "there is badly written data". Confusing them produces useless reports.
Conclusion
You have laid the foundations of BiblioTech's persistence.
You understand what a file really is: a sequence of bytes with a name, with no types and no structure, as against an object in memory with fields and references. You know that moving from one to the other demands an explicit format decision —one material per line, fields separated by ;— and that this decision is the seed of the CSV of 07-07. And you know the complete journey of the data down to the disk, with the conclusion that governs the whole module: the system call is the expensive operation, and the entire design of I/O exists to reduce their number.
You know how to locate a file: absolute and relative paths, the role of user.dir as the origin of the relative ones, and why the same program "cannot find" the same file depending on where it is launched from. You have the five professional strategies for not depending on that, with the recommendation to pass the path as a parameter, and the rule of always logging the absolute path in error messages. You know File.separator versus File.pathSeparator and why composing paths is better than concatenating strings.
You know java.io.File —exists, isFile, canRead, length, getAbsolutePath— and, above all, the defect that condemned it: the mute booleans that say something failed without saying why, exactly the anti-pattern module 6 eradicated. You know that 07-06 replaces it with Path and Files, and that converting between the two is a toPath().
You know how to read with FileReader character by character, with the int return value and the -1 for end of file, and why that loop is two orders of magnitude slower than necessary. You know block reading with read(char[]) and its two traps: the return value that may be smaller than the array, and the append(block, 0, read) that avoids dragging rubbish along. And you know how to read with Scanner over a file, with hasNextLine/nextLine, type parsing, useDelimiter with regular expressions, and its two surprises: the Locale that changes the decimal separator and the nextInt() that does not consume the line break.
And you have the section that prevents more real incidents than any other: encoding. You know what UTF-8 is, why é takes two bytes, what exactly it means to see ï and what it means to see �, why the platform default encoding is a time bomb between machines, what Java 18 changed and why that does not excuse you from specifying it. And you know that detecting the encoding is impossible in the general case, which makes the rule absolute: StandardCharsets.UTF_8, explicit, always.
BiblioTech now reads. CatalogLoader builds the catalogue from a text file, discards badly formed lines by counting and logging them instead of aborting the whole load, translates NumberFormatException and DuplicateReferenceException into discards with context, and —most importantly— degrades gracefully: if there is no catalogue file, it starts with an empty catalogue and a WARNING, because an empty library is a correct state. It is the error policy of 06-07 applied to I/O from the very first line of code.
What remains is the missing half, and it is the frightening one. Reading is safe; writing destroys. In lesson 07-02, Writing Files, you will see FileWriter and the append parameter that decides between adding at the end and wiping the entire file, which is the most expensive mistake in the whole module; PrintWriter with the printf you have been using since module 1; the buffer and flushing, and why what you have written may not be on the disk until you close it; the atomic write pattern —write to a temporary file and rename— which stops a file being left half finished when something fails midway, a direct sibling of the compensation in finally you already know how to write; and the system line separator, with the reason why \n is not always enough. By the end of it, CatalogExporter will stop being a class that knows how to close itself but not how to write, and BiblioTech will save what it has done for the first time.
Java Programming Course
Module 1: Introduction to Java
- Introduction to Java
- Setting Up the Development Environment
- Basic Syntax and Structure
- Variables and Data Types
- Operators
- Console Input and Output
- Your First Complete Program: BiblioTech
Module 2: Control Flow
- Conditional Statements
- Loops
- Switch Statements
- Break and Continue
- Debugging and Execution Traces
- Project: The BiblioTech Interactive Menu
Module 3: Object-Oriented Programming
- Introduction to OOP
- Classes and Objects
- Methods
- Constructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- The Object Class: equals, hashCode and toString
Module 4: Advanced Object-Oriented Programming
- Interfaces
- Abstract Classes
- Inner Classes
- Anonymous Classes
- Lambda Expressions
- Functional Interfaces and Method References
- Enums and Records
Module 5: Data Structures and Collections
- Arrays
- The Collections Framework
- ArrayList
- LinkedList
- HashMap
- HashSet
- Queue and Deque
- Stack
- Sorting and Searching Collections
Module 6: Exception Handling
- Introduction to Exceptions
- The Try-Catch Block
- Throw and Throws
- Custom Exceptions
- The Finally Block
- Try-with-resources and AutoCloseable
- Error Handling Strategies and Logging
Module 7: File Input/Output
- Reading Files
- Writing Files
- File Streams
- BufferedReader and BufferedWriter
- Serialization
- The NIO.2 API: Path and Files
- Interchange Formats: CSV and Properties
Module 8: Multithreading and Concurrency
- Introduction to Multithreading
- Creating Threads
- Thread Lifecycle
- Synchronization
- Concurrency Utilities
- Concurrent Collections and Atomic Variables
- Asynchronous Tasks with CompletableFuture
Module 9: Networking
- Introduction to Networking
- Sockets
- ServerSocket
- DatagramSocket and DatagramPacket
- URL and HttpURLConnection
- The Modern HTTP Client
Module 10: Advanced Topics
- Generics
- Annotations
- Reflection
- Java 8 Features: Streams and Optional
- Dates and Times with java.time
- Java 9 and Beyond
- Memory, Garbage Collection and Performance
Module 11: Java Frameworks and Libraries
- Introduction to Java Frameworks
- Spring Framework
- Hibernate
- JUnit
- Maven
- Advanced Testing with Mockito
- Essential Ecosystem Libraries
