Skip to content
academia.sh

Lesson 12 / 15

File System API

That a path is a parsed value, and that normalization and resolution answer without ever touching the file system, is shown by running it on two separate providers. It is measured that directory traversal order is unspecified, and that existence-checking and opening are two separate operations.

Contents

The previous lesson concerned content passing through an input/output stream. But there is one more question before a stream is even opened: does the thing at the stream’s end really exist, and what does the path leading to it say? This lesson looks at the file system API — it measures that a path is itself a parsed value, which operations on that value answer without ever touching the file system, and which ones really reach down to disk.

A Path is not a string. The text "a/../b" looks like something held inside a string, but the methods called on it follow the rules not of a text-processing library, but of a file system provider. This lesson’s question is asked the same way as the course’s: which behavior is independent of provider choice — that is, the interface’s promise; which cannot be answered without asking the provider’s own storage — that is, a storage query; and which guarantee no method gives at all, one obtained only by making calls in the right order.

Four sections proceed in order. The first two separate two kinds of methods called on the same path object: those that never touch storage, and those that really ask storage. The third measures where a directory’s listing order comes from, and what it does not guarantee. The fourth shows the cost of splitting the promise a single method gives into two separate calls. All four land on the same principle: it is not a method’s name, it is when and which resource it asks, that decides the promise that method gives.

Path Algebra: Never Touching the File System

  • IO10 — The same three operations — normalization, resolution, file-name separation — are run on two separate file system providers: the default local provider, and the standard library’s own zip provider (a provider, part of the standard library, that opens a zip archive like a file system).
  • IO11 — What is compared is the equality of two results; no path string on its own needs interpretation, because the results are already short, human-readable strings. The same measurement is repeated for equals and compareTo, and it is also checked what equals says when two paths, textually identical, come from different providers.

For this measurement, what is compared is not two separate classes, it is two separate providers: the default local provider, and the standard library’s own zip provider, a FileSystemProvider implementation that opens a zip archive like a file system. In the course’s previous lessons, “implementation” meant choosing between a List or a Map; here, provider choice plays the same role — which file system you are running behind does not change the methods you call on the Path interface.

// ProviderTrial.java — is path algebra the same across two file system providers
import java.net.URI;
import java.nio.file.*;
import java.util.Map;

public class ProviderTrial {
    public static void main(String[] args) throws Exception {
        Path archive = Path.of("archive.zip");
        Files.deleteIfExists(archive);
        try (FileSystem zip = FileSystems.newFileSystem(
                URI.create("jar:" + archive.toUri()), Map.of("create", "true"))) {

            System.out.printf("%-14s%-20s%-20s%s%n", "operation", "local", "zip", "same");

            String localNorm = Path.of("a/./b/../c").normalize().toString();
            String zipNorm = zip.getPath("a/./b/../c").normalize().toString();
            System.out.printf("%-14s%-20s%-20s%s%n", "normalize", localNorm, zipNorm,
                    localNorm.equals(zipNorm));

            String localResolve = Path.of("root").resolve("sub/file.txt").toString();
            String zipResolve = zip.getPath("root").resolve("sub/file.txt").toString();
            System.out.printf("%-14s%-20s%-20s%s%n", "resolve", localResolve, zipResolve,
                    localResolve.equals(zipResolve));

            String localName = Path.of("dir/file.txt").getFileName().toString();
            String zipName = zip.getPath("dir/file.txt").getFileName().toString();
            System.out.printf("%-14s%-20s%-20s%s%n", "getFileName", localName, zipName,
                    localName.equals(zipName));

            String localRelative = Path.of("a/b/c").relativize(Path.of("a/x")).toString();
            String zipRelative = zip.getPath("a/b/c").relativize(zip.getPath("a/x")).toString();
            System.out.printf("%-14s%-20s%-20s%s%n", "relativize", localRelative, zipRelative,
                    localRelative.equals(zipRelative));

            boolean localEqual = Path.of("a/b").equals(Path.of("a/b"));
            boolean zipEqual = zip.getPath("a/b").equals(zip.getPath("a/b"));
            System.out.printf("%-14s%-20s%-20s%s%n", "equals (own)", localEqual, zipEqual,
                    localEqual == zipEqual);

            boolean localOrder = Path.of("a/b").compareTo(Path.of("a/c")) < 0;
            boolean zipOrder = zip.getPath("a/b").compareTo(zip.getPath("a/c")) < 0;
            System.out.printf("%-14s%-20s%-20s%s%n", "compareTo<0", localOrder, zipOrder,
                    localOrder == zipOrder);

            System.out.println("local a/b equal to zip a/b (same text, different provider): "
                    + Path.of("a/b").equals(zip.getPath("a/b")));
        }
        Files.deleteIfExists(archive);
    }
}
operation     local               zip                 same
normalize     a/c                 a/c                 true
resolve       root/sub/file.txt   root/sub/file.txt   true
getFileName   file.txt            file.txt            true
relativize    ../../x             ../../x             true
equals (own)  true                true                true
compareTo<0   true                true                true
local a/b equal to zip a/b (same text, different provider): false

All six of the six operations give byte-for-byte identical results on both providers. normalize collapses . and .. segments by the same rule, resolve appends one path onto another the same way, relativize computes the relative path between two paths the same way, and equals and compareTo also compare the segment list. None of these six operations, while running, looks at the provider’s storage — the zip archive is still empty, the local directory has nothing under these names either, and yet all six produce a result. Because the result is provider-independent, this behavior is Path‘s own interface promise: path algebra is a pure computation working on a list of segments, and never asks about the storage’s content.

The last line shows this promise’s boundary. Two paths, textually identical — one from the local provider, one from the zip provider — are not equal when compared with equals. Path.equals looks not only at the segments, but also at which provider they belong to; this, too, is a provider-independent rule, because both providers enforce this restriction the same way. The promise path algebra gives is not “the same segments produce the same result,” it is “the same segments on the same provider produce the same result.”

This purity has a measurable consequence: a rule built on top of path algebra — validating an extension, blocking an escape into a parent directory, computing a relative path — can be tested without creating a single file, without setting up a single directory. The measurement above shows this itself: the zip archive was opened empty, the local directory had nothing under the relevant names, and all six of the six comparisons completed without error. Had this been an operation touching storage, the same measurement would first have required a setup.

The Same Question’s Answer That Touches Storage

  • IO12 — On the same two providers, Files.exists is called this time. A record is written first to the path on the zip provider, while the same-named file on the local provider is never created at all.
// TouchingStorage.java — operations touching storage ask the provider, path algebra never does
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.Map;

public class TouchingStorage {
    public static void main(String[] args) throws Exception {
        Path archive = Path.of("archive.zip");
        Files.deleteIfExists(archive);
        try (FileSystem zip = FileSystems.newFileSystem(
                URI.create("jar:" + archive.toUri()), Map.of("create", "true"))) {

            Path zipFile = zip.getPath("record.txt");
            Files.writeString(zipFile, "data", StandardCharsets.UTF_8);

            Path localFile = Path.of("record.txt");
            Files.deleteIfExists(localFile);

            System.out.printf("%-14s%s%n", "provider", "Files.exists result");
            System.out.printf("%-14s%s%n", "local", Files.exists(localFile));
            System.out.printf("%-14s%s%n", "zip", Files.exists(zipFile));
        }
        Files.deleteIfExists(archive);
    }
}
provider      Files.exists result
local         false
zip           true

The same name gives two separate answers on two separate providers — and this is not a defect, it is proof of what Files.exists really does. Unlike the six operations in the previous section, exists really asks the provider’s own storage; the answer can be “no” or “yes,” and that answer depends on what is in storage at that moment. Files.newInputStream, Files.size, Files.readAllBytes, Files.isDirectory belong to the same family: all of them go to the provider’s storage the moment they are called. A shortcut for telling whether a method called on a path object touches storage: if the result can be produced even while storage is empty, it is algebra; if it requires storage to hold something, it is a storage query.

This distinction has a practical consequence: a path object can safely be built, stored, and combined with other paths even while the file it points to does not exist at all. Naming an output directory in advance with resolve, before it is ever written, blows up nothing at all, because algebra never asks storage. A problem can only surface once a call touching storage arrives — Files.createDirectories or Files.newOutputStream, say — and at that point, exactly where to look for the error is clear, because the methods touching storage form a bounded, named set.

Every answer that touches storage also has a time dimension: exists gives an answer that is correct at the moment it is called; it gives no promise that the answer stays correct after the call ends. Path algebra, in this respect, carries a stronger guarantee — once normalize is computed, it never becomes invalid again, because it never looked at storage at all. A storage query’s result, in turn, is a snapshot, and this lesson’s last section measures exactly how short-lived that snapshot is.

Directory Traversal Order Is Unspecified

  • IO13 — Four files are created in a directory in a given order. The traversal order obtained with Files.list is compared against both the creation order and alphabetical order.
  • IO14 — The traversal order itself is not printed; only the two comparisons’ results and whether two separate traversal methods agree with each other are printed. What Files.list returns is also a resource — it holds a directory handle behind it — and is opened with try-with-resources (Object-Oriented Java, java-generics/04).
// TraversalOrder.java — directory traversal order is unspecified, ordering is up to the caller
import java.nio.file.*;
import java.util.*;
import java.util.stream.Collectors;

public class TraversalOrder {
    public static void main(String[] args) throws Exception {
        Path dir = Path.of("traversal");
        Files.createDirectories(dir);
        List<String> creationOrder = List.of("many.txt", "few.txt", "mid.txt", "one.txt", "top.txt", "low.txt");
        for (String name : creationOrder) Files.createFile(dir.resolve(name));

        List<String> traversalList;
        try (var stream = Files.list(dir)) {
            traversalList = stream.map(p -> p.getFileName().toString()).collect(Collectors.toList());
        }
        List<String> traversalDirStream = new ArrayList<>();
        try (DirectoryStream<Path> dirStream = Files.newDirectoryStream(dir)) {
            for (Path p : dirStream) traversalDirStream.add(p.getFileName().toString());
        }
        List<String> alphabetical = traversalList.stream().sorted().collect(Collectors.toList());

        System.out.println("traversal order same as creation order: "
                + traversalList.equals(creationOrder));
        System.out.println("traversal order same as alphabetical order: "
                + traversalList.equals(alphabetical));
        System.out.println("does Files.list give the same order as the directory stream: "
                + traversalList.equals(traversalDirStream));

        for (String name : creationOrder) Files.deleteIfExists(dir.resolve(name));
        Files.deleteIfExists(dir);
    }
}
traversal order same as creation order: false
traversal order same as alphabetical order: false
does Files.list give the same order as the directory stream: true

Traversal order matches neither creation order nor alphabetical order; the provider lists its own storage according to its own internal layout, and this layout is not a documented promise of Files.list. In contrast, Files.list (a method returning a stream) and Files.newDirectoryStream (an iterator interface, in the input/output-stream sense, returning a directory stream) give the same order on the same traversal — because both wrap the same underlying listing call from the same provider. The two APIs agreeing with each other does not mean traversal has a documented order; it only shows they rest on the same provider call. A caller wanting a sorted result has to sort the list itself; the API gives no such promise anywhere.

The source of the uncertainty is the provider’s own storage structure. A directory entry is mostly held not by name, but by the file system’s own internal indexing scheme, and listing walks that internal layout as is. Files.list‘s documentation never commits to this layout at all; the only thing it commits to is that every entry appears in the list exactly once. This has the same shape as the previous lesson’s buffering measurement: silently assuming a promise the API never gave builds a dependency that goes unnoticed as long as the code runs, and surfaces only once it is moved elsewhere.

Existence-Checking and Opening Are Two Separate Operations

This section turns the previous section’s last sentence into a numeric measurement. Files.exists and Files.newInputStream are two separate storage queries, and the time passing between them is not zero; code running in that gap can change storage’s state. The measurement sets this up with a real deletion, but the source does not matter — the same gap could just as well be filled by another process, another thread, or the user themselves.

  • IO15 — A file is written, its existence is checked with Files.exists, and right after, the file is deleted — standing in for another code path stepping in. After the check, an attempt is made to open the file.
  • IO16 — For comparison, the same open call is also tried directly on a file that never existed at all; it is seen that both errors come from the same class.
// TwoSteps.java — checking existence and opening are two separate operations, a gap sits between them
import java.nio.file.*;

public class TwoSteps {
    public static void main(String[] args) throws Exception {
        Path file = Path.of("temp-record.txt");
        Files.writeString(file, "record");

        boolean checkResult = Files.exists(file);
        Files.delete(file);

        String openResult;
        try {
            Files.newInputStream(file).close();
            openResult = "opened";
        } catch (NoSuchFileException e) {
            openResult = "could not open: " + e.getClass().getSimpleName();
        }
        System.out.printf("%-18s%s%n", "check result", checkResult);
        System.out.printf("%-18s%s%n", "open result", openResult);

        String directResult;
        try {
            Files.newInputStream(Path.of("does-not-exist.txt")).close();
            directResult = "opened";
        } catch (NoSuchFileException e) {
            directResult = "could not open: " + e.getClass().getSimpleName();
        }
        System.out.printf("%-18s%s%n", "direct open", directResult);
    }
}
check result      true
open result       could not open: NoSuchFileException
direct open       could not open: NoSuchFileException

Right after the check returns true, opening fails, because the file was deleted in between. This defect does not come from Files.exists lying — the check gave the correct answer at the moment it was called. The defect comes from the check and the open being two separate calls, and from something else being able to change storage in the time passing between them. The last line confirms this: trying to open a file that never existed at all directly also drops the same exception, from the same class. The API already offers the promise “open or give an error” in a single call; it is the caller who adds the checking step in front of it, and that extra step buys no safety, it only widens the gap. The right order is to open without checking, and catch the open’s own exception.

This is not a gap in the library — the interface is already designed to carry both the result and the error in a single call. The gap only appears once the caller separates the two calls and puts other code between them; no matter how short it looks, every line sitting between the two steps widens that gap. This is the plainest form of what the course calls the “caller’s obligation”: the library said nothing wrong, the caller used correct information at the wrong time.

Summary

  • Path is not a string, it is a parsed value; normalize, resolve, getFileName, relativize, equals, and compareTo run without ever touching the file system, and this behavior is the same on two separate providers.
  • Two textually identical paths from different providers are not equal with equals; equality looks not just at the segments, but at the segments plus the provider.
  • Operations like Files.exists, Files.size, Files.readAllBytes really ask the provider’s storage; the answer depends on storage’s real state and can change with the provider.
  • Directory traversal order matches neither creation order nor alphabetical order, and the API documents this order nowhere; a caller wanting a sorted result has to sort the list itself.
  • Two separate traversal methods giving the same order does not mean the order is documented — it only shows they rest on the same provider call.
  • Bounding measurement: existence-checking and opening are two separate operations. If the file is deleted in between, the check passes and the open falls; the API already offers “open or give an error” in a single call, and the extra check is a gap the caller added themselves.

Next Step

This lesson measured whether a file exists, and which bytes it carries — but never asked what happens once an object itself is turned into bytes. The next lesson looks at serialization: it measures that when an object is dumped to a byte array and read back, the object’s constructor never runs at all, and that the invariants a class establishes can, for this reason, be broken by bytes given from outside.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close