Session 4 · Programming Technology

Reading files and handling exceptions

Turning a text file into objects line by line, and handling every way it can be wrong: checked and unchecked exceptions, your own exception with the line number, try-with-resources – and how a complete first assignment is structured.

20 min readLine-by-line parsing with ScannerChecked vs unchecked exceptionsException translation and propagationtry-with-resourcesAnatomy of the first assignment

The first assignment (beadandó) always starts the same way: a text file describes the input, your program reads it, builds objects from it and runs a simulation or computes statistics. Reading the file is the easy half. The other half is what happens when the file is missing, a line has one number too few, or a letter stands where a number should be – and that is where most first assignments lose points.

This session covers both halves with three examples: Vehicles (refuelling statistics, worked through in class), AnimalFarm (the practice task) and Race, a complete sample solution of a first assignment, with tests and documentation. All three share the same three building blocks: reading a file line by line, throwing an exception when the data is wrong, and an abstract base class that the concrete types extend.

The rule of thumb: detect an error where it happens, describe it where you know the context (which line?), and report it in exactly one place – the part of the program that talks to the user. Everything in between just lets the exception pass.

From a line of text to an object

The Vehicles example keeps a log of refuels. The first line is the number of vehicles; every further line is one vehicle: a type letter (S car, B bus, T truck), the licence plate, the number of refuels, then that many amounts.

data.txt

3
S AAA-111 4 6 7 8 9
B ABC-123 2 3 13
T FFF-888 7 1 2 3 4 5 6 7

The structure of the file tells you the structure of the reading code. One line is one object, so read the file line by line, and take each line apart with its own Scanner. A Scanner splits the line at whitespace – any number of spaces or tabs – and hands out the fields one by one: next() for a word, nextInt() for a whole number (Fig. 1).

one line of data.txtthe count says: 2 more numbersBnext()typeABC-123next()plate2nextInt()count3nextInt()refuel 113nextInt()refuel 2createVehicle(type, plate)switch: "B" → new Bus(plate)newaddRefuel(…)b : Busplate = "ABC-123"category = "B"refuels = [3, 13]After the last amount the Scanner must be empty – if hasNext() is still true, the line has more data than it says.If a field is missing or is not a number, next() / nextInt() throws – that is where the error handling starts.
Fig. 1 · The fields are read in order, and each one has a job. The count field is the interesting one: it is not stored anywhere, it only says how many numbers to read next.

Database.read – the reading loop

try (BufferedReader reader = Files.newBufferedReader(Path.of(filename), StandardCharsets.UTF_8)) {
    String line;
    int lineNumber = 0;
    while ((line = reader.readLine()) != null) {   // null = end of file
        lineNumber++;
        if (line.isBlank()) {
            continue;
        }
        Scanner fields = new Scanner(line);        // one Scanner per line
        loaded.add(parseVehicle(fields, lineNumber));
    }
}

Database.parseVehicle – one line

String type = fields.next();
String plate = fields.next();
Vehicle vehicle = createVehicle(type, plate);

int numRefuels = fields.nextInt();
for (int i = 0; i < numRefuels; i++) {
    vehicle.addRefuel(fields.nextInt());   // throws if the line runs out
}
if (fields.hasNext()) {
    throw new IllegalArgumentException("More data than expected: " + fields.next());
}

Which subclass to create depends on the type letter, so the letter goes into a small factory method: a switch that returns a Car, a Bus or a Truck, and throws for anything else. The rest of the program only ever sees a Vehicle. Keep the letter itself as a constant in the subclass (Bus.CATEGORY = "B"), so the file format is written down in one place.

Database.createVehicle – letter → subclass

private static Vehicle createVehicle(String type, String plate) {
    return switch (type) {
        case Car.CATEGORY -> new Car(plate);
        case Bus.CATEGORY -> new Bus(plate);
        case Truck.CATEGORY -> new Truck(plate);
        default -> throw new IllegalArgumentException("Unknown vehicle type: " + type);
    };
}

Checked and unchecked exceptions

An exception is an object that describes a problem. throw stops the current method immediately, and Java looks for a matching catch in the caller, then in its caller, and so on. Which exceptions you must deal with is decided by where the class sits in the hierarchy (Fig. 2).

ThrowableErrordon't catchExceptionIOExceptionfile errorsInvalidInputExceptionyours – bad dataRuntimeExceptionunchecked baseNoSuchFileExceptionIllegalArgumentExceptionNoSuchElementExceptionNumberFormatExceptionInputMismatchExceptionchecked: catch or declare (throws)javac rejects code that neithercatches nor declares theseunchecked: RuntimeException and below
Fig. 2 · Everything under Exception except RuntimeException is checked – the compiler makes you catch it or declare it with throws. RuntimeException and its subclasses are unchecked.
checked
The caller must react: catch it, or declare it in the method header with throws. Used for problems that are not bugs and can happen in a correct program – a missing file, invalid input.
unchecked
Subclasses of RuntimeException. No declaration needed. Used for broken arguments and programming errors: IllegalArgumentException, NullPointerException, IllegalStateException.
Error
Problems of the JVM itself (out of memory, stack overflow). Not for your program to catch.

For bad input, write your own checked exception. Extending Exception makes it checked, so nobody can forget to handle a broken file – the compiler won't let them. Give it a constructor that takes the line number and the original exception (the cause), so the message is useful to the user and the stack trace is still useful to you.

InvalidInputException.java

public class InvalidInputException extends Exception {   // extends Exception = checked

    public InvalidInputException(String message) {
        super(message);
    }

    public InvalidInputException(int lineNumber, String message, Throwable cause) {
        super("Line " + lineNumber + ": " + message, cause);
    }
}

Where to catch: let it travel up

The most common beginner instinct is to wrap every risky line in its own try/catch. The opposite is right: catch an exception only where you can do something useful with it. In the Race sample, reading a bad file throws deep inside the parser, and the exception travels up through three methods that don't catch it at all (Fig. 3).

calls go downthe exception goes upMain.run(…)catches it: prints the message, exit code 1the only place that talks to the userInvalidInputExceptionRaceFileReader.read(Path)try-with-resources closes the filethen the exception continues upwardsInvalidInputExceptionreadRace()no catch – declared with throwsthe exception just passes throughInvalidInputExceptionparseNextLine(…)catches it and translates itinto InvalidInputException("Line 3: …")IllegalArgumentExceptionparseCreature(fields)finds the unknown code "x" and throwsIllegalArgumentException
Fig. 3 · Reading teszt_invalid.txt. Only two methods catch anything: parseNextLine, which knows the line number, and Main, which knows how to talk to the user.

Only two places catch. parseNextLine catches because it knows something the thrower didn't – the line number – and adds it. This is exception translation: several low-level exceptions become one exception that means something in your program (Fig. 4).

InputMismatchExceptionnextInt() found "abc"NoSuchElementExceptionthe line ended too earlyIllegalArgumentExceptionone of your own checks failedcatch and wrapadds the line numberInvalidInputExceptionmessage + original as causethe caller needs one catch,the user gets "Line 3: …"Order matters: InputMismatchException is a subclass of NoSuchElementException, so its catch must come first.
Fig. 4 · Translate low-level exceptions in one place. The original goes along as the cause, so the stack trace still shows where it started.

RaceFileReader.parseNextLine – translation

Scanner fields = new Scanner(line);
try {
    T result = parser.apply(fields);
    // ...
    return result;
} catch (InputMismatchException ex) {          // subclass first ...
    throw new InvalidInputException(lineNumber,
            "Expected a whole number, but found: " + fields.next(), ex);
} catch (NoSuchElementException ex) {          // ... then its superclass
    throw new InvalidInputException(lineNumber, "Data is missing from the end of the line", ex);
} catch (IllegalArgumentException ex) {        // our own checks, e.g. an unknown code
    throw new InvalidInputException(lineNumber, ex.getMessage(), ex);
}

The other place is Main. It is the only part of the program that prints, so it is the only part that turns an exception into a message – and into an exit code, so a script calling the program can tell success from failure.

Main.run – the one place that reports

try {
    Race race = RaceFileReader.read(Path.of(fileName.strip()));
    race.run();
    printResult(race, out);
    return EXIT_OK;
} catch (NoSuchFileException ex) {
    return fail(err, "the file does not exist: " + fileName);
} catch (InvalidInputException ex) {
    return fail(err, "invalid input in " + fileName + ": " + ex.getMessage());
} catch (IOException ex) {
    return fail(err, "could not read the input: " + ex.getMessage());
}

Closing the file: try-with-resources

An open file is held by the operating system until you close it. Closing it at the end of the method is not enough: if anything throws halfway through, the close() line is never reached.

The leak

BufferedReader reader = new BufferedReader(new FileReader(name));
String first = reader.readLine();
int count = Integer.parseInt(first);   // throws – the next line never runs
reader.close();

try-with-resources fixes this for good. Whatever you open in the parentheses after try is closed automatically when the block ends – normally or with an exception (Fig. 5).

try ( … open … )the file is openedthe block runsreadLine, parse …finishes normallythrowsclose()always, automaticallythen: return the value,or rethrow the exceptionAnything that implements AutoCloseable can go in theparentheses: readers, writers, a Scanner, a databaseconnection. A catch block is optional.
Fig. 5 · close() runs on both paths, and it runs before the value is returned or the exception continues to the caller.

RaceFileReader.read – a try with no catch

public static Race read(Path file) throws IOException, InvalidInputException {
    // No catch: the try is only here to close the file.
    // The exceptions go on to the caller (Main), after the file is closed.
    try (BufferedReader source = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
        return read(source);
    }
}

Note that this try has no catch at all. Its only job is closing the file; the exceptions are declared with throws and handled by Main. A try doesn't have to catch.

Validate everything, keep nothing half-read

A file is either valid or it isn't. If line 5 of 7 is broken, the program should not continue with the first four vehicles as if nothing happened. The Vehicles reader collects into a local list and adds it to the database only after the last check has passed:

Database.read – all or nothing

List<Vehicle> loaded = new ArrayList<>();   // a local list, not the field
// ... read every line into loaded; any error throws out of the method ...

checkUniquePlates(loaded);
vehicles.addAll(loaded);                     // only reached if the whole file was valid

The rules about a single object – a name must not be blank, the water must be between 1 and the capacity – belong in the constructor, which throws an IllegalArgumentException. That way an invalid object cannot exist at all, no matter who creates it: the reader, a test, or next month's GUI. The reader then translates that exception into an InvalidInputException with the line number.

Creature – the constructor guards its own rules

protected Creature(String name, int initialWater, int maxWater) {
    if (name == null || name.isBlank()) {
        throw new IllegalArgumentException("The name of a creature must not be blank");
    }
    if (initialWater <= 0 || initialWater > maxWater) {
        throw new IllegalArgumentException("Invalid initial water for " + name + ": " + initialWater);
    }
    // ... assign the fields
}

Rules about the whole file, like unique names, need a collection. A HashSet is the shortest way: its add returns false when the element is already there.

Race – no two creatures with the same name

Set<String> names = new HashSet<>();
for (Creature creature : creatures) {
    if (!names.add(creature.getName())) {    // add returns false for a duplicate
        throw new IllegalArgumentException("Duplicate creature name: " + creature.getName());
    }
}

Anatomy of a good first assignment

The Race project is a complete solution of a typical first assignment: creatures of three species race for several days; each day is sunny, cloudy or rainy; each species gains or loses water and moves forward according to a table, and dies when its water runs out. The winner is whoever travelled the farthest while staying alive. The input looks like this:

teszt.txt

4
Vandor h 4
Seta l 7
Csuszo s 12
Siklo s 10
nffeeennf

Name, species code and initial water per creature, then one letter per day (n sunny, f cloudy, e rainy). What makes the solution good is not the simulation – that is ten lines – but how the program is cut into parts.

Three layers

Every class has one kind of job (Fig. 6). Main talks to the user. RaceFileReader turns text into objects. The model – Race, Creature, Weather – knows the rules of the race and nothing about files or the console.

Main – the user interfacefile name, printing, exit codes, the catch blocksSystem.out and System.err only hereRaceFileReader – inputtext → objects, validation, throwsfiles and Scanner only hereRace · Creature · Weather – the modelthe rules of the race, nothing elseunit-tested without any fileread(path)new Race(creatures, days)Main also calls the model directly: race.run(), race.getWinners().
Fig. 6 · System.out appears in one class, file handling in one other. The model can be tested, reused and later put behind a GUI without changing a line.

An abstract base class that does the work once

The three species differ only in numbers. The rules of a day – change the water, cap it, die at zero, otherwise move – are the same for everyone, so they are written once, in the abstract Creature (Fig. 7).

«abstract»Creature- name : String- water : int- maxWater : int- distance : int- alive : boolean+ liveThrough(Weather)# waterChange(Weather) : int# distanceGain(Weather) : int+ getSpecies() : StringRace- creatures : List<Creature>- days : List<Weather>- finished : boolean+ run()+ getWinners() : List<Creature>«enum»WeatherSUNNYCLOUDYRAINY+ fromCode(char) : Weather**SandWalker+ CODE = "h"+ MAX_WATER = 8# waterChange(…)# distanceGain(…)+ getSpecies()Sponge+ CODE = "s"+ MAX_WATER = 20# waterChange(…)# distanceGain(…)+ getSpecies()Walker+ CODE = "l"+ MAX_WATER = 12# waterChange(…)# distanceGain(…)+ getSpecies()Creature implements a day once (liveThrough is final); the species only supply the numbers from the table.
Fig. 7 · Creature holds the state and the rules; each species overrides two small methods that return numbers from the table. Weather is an enum that knows its own letter in the file.

Creature – the day, written once

public final void liveThrough(Weather weather) {
    if (!alive) {
        return;
    }
    water = Math.min(maxWater, water + waterChange(weather));   // capped at the capacity
    if (water <= 0) {
        water = 0;
        alive = false;
        return;                                                  // no move on the day of death
    }
    distance += distanceGain(weather);
}

protected abstract int waterChange(Weather weather);
protected abstract int distanceGain(Weather weather);

SandWalker – only the numbers

@Override
protected int waterChange(Weather weather) {
    return switch (weather) {
        case SUNNY -> -1;
        case CLOUDY -> 0;
        case RAINY -> 3;
    };
}

liveThrough is final, so no subclass can accidentally change the rules, and the species classes become a few lines each – almost a copy of the table in the task. This pattern, a fixed algorithm in the base class with abstract "slots" the subclasses fill, is called a template method.

The weather letters get the same treatment: an enum where every constant knows its letter, and a static fromCode that throws for an unknown one. The file format is written down in one place, and the rest of the program works with Weather.RAINY, never with 'e'.

Weather.java

public enum Weather {
    SUNNY('n'), CLOUDY('f'), RAINY('e');

    private final char code;

    Weather(char code) {
        this.code = code;
    }

    public static Weather fromCode(char code) {
        for (Weather weather : values()) {
            if (weather.code == code) {
                return weather;
            }
        }
        throw new IllegalArgumentException("Unknown weather code: '" + code + "'");
    }
}

The simulation and the edge cases

Running the race is two nested loops: days outside, creatures inside, because every creature has to live through day 1 before anyone starts day 2 (Fig. 8).

for each day (in file order)for each creaturealive?yeschange the watercapped at the capacitywater ≤ 0 ?nomovedistance += gainnoskip the daydead stays deadyesdiesno move today
Fig. 8 · The order inside one day matters: water first, then the check for death, and only then the move. A creature that dies today does not move today.

The task says "the winner", but a good solution asks: what if two creatures tie, and what if nobody survives? getWinners returns a list – one element normally, several on a tie, empty if everyone died – and Main prints a sentence for each case.

Race.getWinners – ties and no survivors

int bestDistance = 0;
for (Creature creature : creatures) {
    if (creature.isAlive()) {
        bestDistance = Math.max(bestDistance, creature.getDistance());
    }
}
List<Creature> winners = new ArrayList<>();
for (Creature creature : creatures) {
    if (creature.isAlive() && creature.getDistance() == bestDistance) {
        winners.add(creature);
    }
}

java race.Main teszt2.txt

The race lasted 9 day(s).

Final standings:
  Vandor (sand walker): distance 17, water 4/8, alive
  Seta (walker): distance 11, water 1/12, alive
  Csuszo (sponge): distance 8, water 4/20, alive

Winner: Vandor (distance: 17)

Tests and documentation

The sample comes with JUnit tests, one test class per class, and every input error from the list above has its own test. Because Main doesn't call System.exit directly but returns the exit code from a separate run method that receives its streams as parameters, even the whole program can be tested.

Main – testable entry point

public static void main(String[] args) {
    System.exit(run(args, System.in, System.out, System.err));
}

// Testable: no System.exit inside, the streams come in as parameters.
static int run(String[] args, InputStream in, PrintStream out, PrintStream err) {
    // ...
}

RaceTest – one rule, one test

@Test
public void deadCreatureCannotWinEvenWithTheLargestDistance() {
    // The sand walker runs 9 in three sunny days, then dies on the fourth.
    Race race = finishedRace(List.of(
            new SandWalker("Fast", 4),
            new Walker("Slow", 12)), "nnnn");

    assertFalse(race.getCreatures().get(0).isAlive());
    assertEquals(List.of("Slow"), names(race.getWinners()));
}
Part of the submissionWhat it contains in the sample
Task descriptionThe task in your own words, including the table of species and the input format.
Class diagramAll classes with their fields, methods and relationships, like Fig. 7.
Input validationThe list of invalid inputs and what the program does with each.
UsageHow to run it, from the IDE and from the command line, with a sample output.
TestsWhich test class covers what; every rule and every error case at least once.
JavadocA comment on every class and public method: what it does, parameters, what it throws.

Practice

The AnimalFarm task follows the Vehicles pattern. A text file lists the animals of a farm; every animal is identified by its name and is a cow (T), a horse (L), a goat (K) or an emu (E). A line holds the kind, the name, the weight in kg, the number of meals that day, and the food eaten at each meal in decagrams (dkg, 10 g).

data.txt

3
E Borisz 32 4 20 30 30 20
K Kelemen 21 3 30 40 50
T Narcisz 134 8 20 40 20 30 10 20 40 50

Task 1

Read the farm

Create an abstract Animal class that cannot be instantiated, and a subclass for each kind. Read the file into a list of animals. Throw your own InvalidInputException – with the line number – if the file can't be opened or a line is wrong: unknown kind, missing field, not a number, fewer meals than declared, or a non-positive weight or meal.

Approach

Copy the shape of Database.read: a BufferedReader in try-with-resources, a Scanner per line, a factory method that switches on the kind letter. The weight check belongs in the Animal constructor, the meal check in addMeal; both throw IllegalArgumentException, which the line parser translates.

Task 2

Malnourished animals

An animal is malnourished if its weight is below the threshold of its kind: cow 100 kg, horse 60 kg, goat 12 kg, emu 20 kg. List the malnourished animals.

Where does the threshold go?

The threshold differs per kind but the rule is the same – a perfect abstract method. Each subclass returns its own constant; the comparison is written once in the base class.

public abstract class Animal {
    public abstract int getMalnourishedThreshold();

    public boolean isMalnourished() {
        return weight < getMalnourishedThreshold();
    }
}

Task 3

Big eaters

List the animals that ate more than 1 kg (more than 100 dkg) that day.

Approach

Store the meals in a List<Integer> inside the animal, add a totalConsumed() method, and a consumedMore(int dkg) that compares. Keep the unit in the name or the comment – mixing kg and dkg is the classic bug here.

Task 4

Without the count

Change the reader so it works without the first line: read until the end of the file, and if the count is there, only use it to check the number of animals.

Telling the count line apart

Animal lines start with a letter, so a first data line that starts with a number must be the count. Remember it, and compare it with the size of the list after the loop.

// The animal lines start with a letter, so a first data line that
// starts with a number must be the count.
boolean isCountLine = declaredCount == null && loaded.isEmpty() && fields.hasNextInt();

Task 5

Break it on purpose

Write a broken data file with at least three different errors and run your program on it after each fix. Every run should stop with one clear message naming a line, and an exit code of 1. Then add a duplicate name and check that it is rejected too.

broken.txt

3
E Borisz 32 4 20 30 30
X Kelemen 21 3 30 40 50
T Narcisz -5 1 20
What should each line produce?

Line 2 has four meals declared but three amounts (data missing), line 3 has an unknown kind, line 4 a negative weight. Only the first error is reported – the program stops at the first exception, and that is fine. For duplicates, collect the names in a HashSet after reading.

Summary

  • Read files line by line and parse each line with its own Scanner; the structure of the file is the structure of the code.
  • Use a factory method to turn a type letter into the right subclass, and keep the letters as constants.
  • Checked exceptions (Exception) must be caught or declared; unchecked ones (RuntimeException) need not be. Write your own checked exception for bad input.
  • Catch only where you can add something: translate low-level exceptions into yours with the line number, and report in one place – Main.
  • Open files in try-with-resources; the file is closed on every path, and a try does not need a catch.
  • Validate in constructors and in the reader, and keep nothing from a half-read file.
  • A good first assignment has three layers – user interface, reader, model – an abstract base class with the shared rules, decided edge cases, tests and documentation.