Skip to the content.

5.2 Αρχεία

© Γιάννης Κωστάρας


<- Δ ->

Εισαγωγή

Μέχρι τώρα, όλα τα προγράμματά μας αποθήκευαν τ’ αποτελέσματα στην κύρια μνήμη (και ίσως και στη λανθάνουσα μνήμη - cache - του Η/Υ). Η κύρια μνήμη (Κ.Μ.) έχει το καλό ότι είναι πολύ γρήγορη, όμως με το σβήσιμο του Η/Υ τα δεδομένα που είναι αποθηκευμένα σ’ αυτήν σβήνονται επίσης. Γι’ αυτό το λόγο υπάρχουν οι εξωτερικές μονάδες μνήμης (π.χ. σκληρός δίσκος, δίσκος SSD, CD-ROM, DVD-ROM) κλπ. που διατηρούν τα δεδομένα που αποθηκεύονται σ’ αυτές ακόμα κι όταν δεν τροφοδοτούνται με ρεύμα. Οι μονάδες αυτές λέγονται και μονάδες μόνιμης αποθήκευσης.

Τα δεδομένα αποθηκεύονται σε μια μονάδα αποθήκευσης υπό τη μορφή αρχείων (files) σε μια ιεραρχική (δενδρική) δομή. Υπάρχουν δυο κατηγορίες: αρχεία κειμένου και δυαδικά αρχεία (binary files). Τα αρχεία ομαδοποιούνται σε φακέλους (folders) ή καταλόγους (directories). Ένα σύνολο αρχείων και φακέλων αποτελεί ένα σύστημα αρχείων (filesystem). Συνήθως αποτελείται από κάποιους top-level καταλόγους οι οποίοι αποθηκεύουν μια ιεραρχία από αρχεία και υποκαταλόγους. Ο αρχικός κατάλογος ονομάζεται ριζικός κατάλογος ή root. Παραδείγματα συστημάτων αρχείων στα συστήματα Windows είναι C:\, D:\ ενώ σε συστήματα Unix /.

Java I/O

Οι κλάσεις διαχείρισης ενός συστήματος αρχείων βρίσκονται στη βιβλιοθήκη java.io. Η πιο βασική κλάση είναι η File, η οποία βασικά δείχνει τη διαδρομή ή το μονοπάτι (path) ενός αρχείου, οπότε ένα πιο σωστό όνομα για την κλάση αυτή θα ήταν Path. Υπάρχουν σχετικά/αναφορικά (relative) και απόλυτα (absolute) μονοπάτια, μονοπάτια δηλ. που ξεκινούν από τον ριζικό κατάλογο. Π.χ. /home/john ή C:\temp είναι απόλυτα μονοπάτια (ξεκινούν με το ριζικό κατάλογο), ενώ temp ή john είναι σχετικά μονοπάτια (σχετικά δηλ. με τον κατάλογο που βρισκόμαστε).

jshell> File file = new File("test.txt");
file ==> test.txt

jshell> File folder = new File("C:/temp");
folder ==> C:\temp

jshell> File fileInFolder = new File(folder, "test.txt");
fileInFolder ==> C:\temp\test.txt

jshell> file.exists()
$1 ==> false

jshell> folder.exists()
$2 ==> false

jshell> folder.mkdir()      // η mkdirs() δημιουργεί και τυχόν ενδιάμεσα directories που δεν υπάρχουν
$3 ==> true

jshell> fileInFolder.exists()
$4 ==> false

jshell> fileInFolder.createNewFile()
$5 ==> true

jshell> fileInFolder.canRead()
$6 ==> true

jshell> fileInFolder.canWrite()
$7 ==> true

jshell> fileInFolder.canExecute()
$8 ==> false

jshell> fileInFolder.setWritable(false)  // setReadable(), setReadOnly()
$9 ==> true

jshell> fileInFolder.getName()
$10 ==> "test.txt"

jshell> fileInFolder.getParent()
$11 ==> "C:\temp"

jshell> fileInFolder.length()
$12 ==> 0

jshell> import java.time.*;

jshell>LocalDateTime lastmodified =
   ...>     Instant.ofEpochMilli(fileInFolder.lastModified()).atZone(ZoneId.systemDefault()).toLocalDateTime();
lastmodified ==> 2016-06-13T19:14:03

jshell> folder.list()
$13 ==> String[1] { "test.txt" }

jshell> for (File selectedFile : folder.listFiles()) {
   ...> System.out.println((selectedFile.isDirectory() ? "d" : "f") + " " + selectedFile.getAbsolutePath());
   ...> }
f C:\temp\test.txt

jshell> void recurseFolder(File folder) {
   ...> for (File file : folder.listFiles()) {
   ...> System.out.println((file.isDirectory() ? "d" : "f") + " " + file.getAbsolutePath());
   ...> if (file.isDirectory()) {
   ...> recurseFolder(file);    // αν υπάρχουν links θα τα ακολουθήσει
   ...> }
   ...> }
   ...> }
|  created method recurseFolder(File)

jshell> recurseFolder(folder)
f C:\temp\test.txt

jshell> fileInFolder.getCanonicalPath()
$14 ==> C:\temp\test.txt

jshell> fileInFolder.getFreeSpace()
$15 ==> 5795504128

jshell> fileInFolder.getTotalSpace()
$16 ==> 239197650944

jshell> fileInFolder.getUsableSpace()
$17 ==> 5533339648
jshell> file.getPath()
$18 ==> "test.txt"
jshell> File f = new File("/tmp/../tmp/test.txt");
f ==> /tmp/../tmp/test.txt

jshell> file.getAbsolutePath()
$19 ==> "/tmp/../tmp/test.txt"

jshell> file.getCanonicalPath()
$20 ==> "/private/tmp/test.txt"

Όταν κάνετε έλεγχο (επικύρωση - validation) για το κατά πόσο υπάρχει μια διαδρομή, ο ασφαλέστερος τρόπος είναι να χρησιμοποιείτε την getCanonicalPath().

Μπορείτε ν’ αλλάξετε τα δικαιώματα πρόσβασης (permissions) με τις παρακάτω μεθόδους της κλάσης File (το αρχείο πρέπει να υπάρχει):

Αν εκτελεστούν με επιτυχία τότε επιστρέφουν true.

jshell> File.listRoots()
$21 ==> File[1] { / }

Ανάγνωση/εγγραφή αρχείων

Η ανάγνωση/εγγραφή σε αρχεία γίνεται με τη βοήθεια ροών (streams). Μία ροή εισόδου (εξόδου) (input(output) stream) χρησιμοποιείται για να διαβάσουμε(γράψουμε) δεδομένα από(σε) μία πηγή (είτε αυτή είναι αρχείο στο δίσκο, είτε δεδομένα από το δίκτυο, είτε από άλλα προγράμματα κλπ.). Οι ροές υποστηρίζουν πολλά είδη δεδομένων όπως απλά bytes, πρωτογενείς τύπους, τοπικοποιημένους χαρακτήρες και αντικείμενα. Κάποιες ροές απλά μεταφέρουν δεδομένα, άλλες τα διαμορφώνουν και τα μετασχηματίζουν με διάφορους τρόπους (π.χ. σε δυαδικά).

Ένα πρόγραμμα χρησιμοποιεί μία ροή εισόδου (input stream) για να διαβάσει δεδομένα από μία πηγή.

Εικόνα 5.2.1 Ιεραρχία κλάσεων ροών εισόδου

Ένα πρόγραμμα χρησιμοποιεί μία ροή εξόδου (output stream) για να γράψει δεδομένα σε μία πηγή.

Εικόνα 5.2.2 Ιεραρχία κλάσεων ροών εξόδου

Οι βασικές διεπαφές InputStream και OutputStream μεταφέρουν δεδομένα σε μορφή bytes. Για ανάγνωση από ή εγγραφή σε αρχεία χρησιμοποιήστε τις FileInputStream, FileOutputStream (σε συνδυασμό με BufferedInputStream, BufferedOutputStream αντίστοιχα για καλύτερη απόδοση). Για ανάγνωση ή εγγραφή αρχέγονων (raw) τύπων δεδομένων (byte, short, int, long, float, double) χρησιμοποιήστε DataInputStream, DataOutputStream. Για ανάγνωση/εγγραφή αντικειμένων, χρησιμοποιήστε ObjectInputStream, ObjectOutputStream (η κλάση θα πρέπει να υλοποιεί τη διεπαφή Serializable). Τέλος, για ανάγνωση/εγγραφή χαρακτήρων (αλφαριθμητικών), υπάρχουν οι διεπαφές Reader, Writer αντίστοιχα.

Εικόνα 5.2.3 Ιεραρχία κλάσεων ανάγνωσης χαρακτήρων

Εικόνα 5.2.4 Ιεραρχία κλάσεων εγγραφής χαρακτήρων

Η Java υποστηρίζει 3 βασικές ροές: Standard Input(System.in) τύπου java.io.InputStream, Standard Output (System.out) και Standard Error (System.err) τύπου java.io.PrintStream.

Στο μάθημα των εξαιρέσεων της προηγούμενης εβδομάδας, είδαμε παραδείγματα χρήσης των παραπάνω. Θα πρέπει πάντα να κλείνουμε μια ροή ή να χρησιμοποιούμε την try-with-resources που κλείνει τη ροή αυτόματα.

Εγγραφή αρχείων

Στα ακόλουθα υποκεφάλαια θα δούμε πώς μπορούμε να αποθηκεύσουμε bytes, χαρακτήρες, αρχέγονους (raw) τύπους και αντικείμενα σε αρχεία.

Εγγραφή bytes

Η διεπαφή OutputStream διαθέτει τη μέθοδο write(int b) για εγγραφή ενός byte σε έναν πόρο (π.χ. αρχείο). Δέχεται σαν όρισμα έναν ακέραιο (κι όχι ένα byte) μεταξύ 0 και 255 (0xFF) (γι’ αυτό και θα πρέπει να γίνεται έλεγχος ώστε το byte προς εγγραφή να βρίσκεται σ’ αυτό το διάστημα). Μόνο τα 8 χαμηλότερα bits γράφονται στην έξοδο, τα υψηλότερα 24 bits αγνοούνται (ένας ακέραιος έχει 32 bits):

jshell> String filepath ="/tmp/test.txt";
filepath ==> "/tmp/test.txt"

jshell> int value = 300;
i ==> 300

jshell> try (OutputStream out = new FileOutputStream(filepath)) {
   ...> if (value < 0 || value > 255) {
   ...> throw new ArithmeticException("Value is out of range");
   ...> }
   ...> out.write(value);
   ...> out.flush();
   ...> }

Eναλλακτικά αν θέλουμε να γράψουμε τιμές μεγαλύτερες του 255:

jshell> try (DataOutputStream out = new DataOutputStream(new FileOutputStream(filepath))) {
   ...> out.writeInt(value);
   ...> out.flush();
   ...> }

Χρησιμοποιούμε την try-with-resources η οποία κλείνει αυτόματα τους ανοικτούς πόρους (στην περίπτωσή μας το αρχείο που γράφουμε) κι έτσι δε χρειάζεται να κλείσουμε το μπλοκ με τη finally:

finally {
  try {	
  	if (bos != null)
	   bos.close();
	} catch (IOException x) {
		// handle error
	}
}

Η BufferedOutputStream είναι πιο αποδοτική από την FileOutputStream καθώς διατηρεί μια εσωτερική μνήμη (buffer):

jshell> String filepath ="/tmp/test.txt";
filepath ==> "/tmp/test.txt"

jshell> String s = "Σε γνωρίζω από την κόψη\r\n του σπαθιού την τρομερή";
s ==> "Σε γνωρίζω από την κόψη\r\n του σπαθιού την τρομερή"

jshell> try (BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(filepath))) {
   ...> byte[] buffer = s.getBytes("UTF-8");
   ...> bos.write(buffer, 0, buffer.length);
   ...> } catch (FileNotFoundException e) {
   ...> e.printStackTrace();
   ...> } catch (IOException e) {
   ...> e.printStackTrace();
   ...> }

jshell> List<String> lines = Arrays.asList(
   ...> new String[]{"Σε γνωρίζω από την κόψη", "του σπαθιού την τρομερή"});
lines ==> [Σε γνωρίζω από την κόψη, του σπαθιού την τρομερή]

jshell> try (BufferedOutputStream outputStream = new BufferedOutputStream(new FileOutputStream(file))) {
   ...> for (String line : lines) {
   ...> outputStream.write((line + System.lineSeparator()).getBytes(StandardCharsets.UTF_8));
   ...> }
   ...> }

Εγγραφή αρχέγονων δεδομένων

Είδαμε μια χρήση της DataOutputStream προηγουμένως. Πέραν της μεθόδου writeInt() υπάρχουν και οι writeByte(), writeShort(), writeLong(), writeFloat() και writeDouble().

Εγγραφή αντικειμένων

Τα αντικείμενα που πρόκειται να αποθηκευθούν σε κάποιο αρχείο θα πρέπει να υλοποιούν τη διεπαφή Serializable. Οι τιμές των στατικών (static) γνωρισμάτων δεν αποθηκεύονται. Τέλος, γνωρίσματα που δεν θέλουμε ν’ αποθηκευθούν τα δηλώνουμε ως transient.

Ας δούμε την ακόλουθη ιεραρχία κλάσεων:

public abstract class Track implements Serializable {

    protected TrackType trackType;
    protected int speed;
    protected double x, y;

    public enum TrackType {
        AIR, LAND, SPACE;
    }

    public Track(int speed, double x, double y) {
        this.speed = speed;
        this.x = x;
        this.y = y;
    }
// getters και setters
}

public class AirTrack extends Track {

    protected int height;

    public AirTrack(int height, int speed, double x, double y) {
        super(speed, x, y);
        this.trackType = TrackType.AIR;
        this.height = height;
    }

    public int getHeight() {
        return height;
    }

    public void setHeight(int height) {
        this.height = height;
    }
}

public class LandTrack extends Track {
    
    public LandTrack(int speed, double x, double y) {
        super(speed, x, y);
        this.trackType = TrackType.LAND;
    }
    
}

Εφόσον η υπερκλάση Track υλοποιεί τη διεπαφή Serializable, οι υποκλάσεις δε χρειάζεται να την υλοποιήσουν κι αυτές.

public class Main {
	
   public static void main(String[] args) {
        Track airTrack = new AirTrack(5000, 10000, 20.0, -20.0);    
        Track landTrack = new LandTrack(20, 10.0, -25.0);            
        List<Track> tracks = new ArrayList<>();
        tracks.add(airTrack);
        tracks.add(landTrack);

        try (ObjectOutputStream oos =
                new ObjectOutputStream(new FileOutputStream("./tracks.dump"))) {
            oos.writeObject(tracks);
        } catch(FileNotFoundException fnfe) {
            System.err.println("Cannot create a file with the given file name.");
        } catch(IOException ioe) {
            System.err.println("An I/O error occurred while processing the file.");
        }
}

Σημειώστε ότι ένα αντικείμενο σειριοποιείται (serialized) μια φορά μόνο. Αν αλλάξετε κάποια γνωρίσματά του και δοκιμάσετε να το ξανα-σειριοποιήσετε τότε η ΕΜ της Java δεν το ξανα-σειριοποιεί.

Σημείωση Ο όρος Σειριοποίηση (Serialization) σημαίνει τη μετατροπή δεδομένων σε μορφή ροής από bytes (byte-stream) για αποθήκευση σε κάποιον πόρο ή αποστολή στο δίκτυο. Χρησιμοποιείται και η ορολογία Παράταξη bytes (Marshalling). Η αντίστροφή διαδικασία λέγεται Αποσειριοποίηση (Deserialization) ή Αντιπαράταξη bytes(Unmarshalling).

Εγγραφή χαρακτήρων

Η διεπαφή Writer διαθέτει τη μέθοδο write(int c) για εγγραφή ενός χαρακτήρα σε έναν πόρο (π.χ. ένα αρχείο κειμένου). Δέχεται σαν όρισμα έναν ακέραιο (όχι χαρακτήρα) αλλά μόνο τα 16 χαμηλότερα bits γράφονται στην έξοδο, τα υψηλότερα 16 bits αγνοούνται (ένας ακέραιος έχει 32 bits). Όπως και στην περίπτωση της OutputStream.write() θα πρέπει να γίνεται έλεγχος ώστε ο ακέραιος προς εγγραφή να βρίσκεται σ’ αυτό το διάστημα. Για ευκολία διαθέτει και την Writer.write(String s).

Αν θέλετε να μετατρέψετε συστοιχίες από bytes σε χαρακτήρες (με συγκεκριμένη κωδικοποίηση), τότε υπάρχει η OutputStreamWriter.

jshell> Writer writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream("C:/temp/test.txt"), StandardCharsets.UTF_8));
writer ==> java.io.BufferedWriter@5f71c76a

jshell> writer.write(s);

jshell> writer.flush(); // write data to file

jshell> writer.close(); // close() calls flush()

Καθώς η BufferedWriter δεν έχει κάτι αντίστοιχο της BufferedReader.readLine(), χρησιμοποιήστε την PrintStream ή την PrintWriter οι οποίες παρέχουν μία οικογένεια από μεθόδους για εκτύπωση σε ροές (π.χ. print(), println(), format()):

jshell> import java.time.*

jshell> String destination = "C:/temp/bla.txt";
destination ==> "C:/temp/bla.txt"

jshell> String name = "Ζηνοβία";
name ==> "Ζηνοβία"

jshell> int age = 12;
age ==> 12

jshell> LocalDate registration = LocalDate.now();
registration ==> 2019-12-24

jshell> try(PrintStream ps = new PrintStream(destination)) {
   ...> ps.println("Όνομα: " + name);
   ...> ps.println("Ηλικία: " + age);
   ...> ps.printf("Registration: %1$td/%1$tm/%1$tY", registration);
   ...> ps.flush();
   ...> } catch (FileNotFoundException e) {
   ...> e.printStackTrace();
   ...> }

Το αποτέλεσμα είναι ένα αρχείο κειμένου με περιεχόμενα:

Όνομα: Ζηνοβία
Ηλικία: 12
Registration: 24/12/2019

Ανάγνωση αρχείων

Στα ακόλουθα υποκεφάλαια θα δούμε πώς μπορούμε να διαβάσουμε bytes, χαρακτήρες, αρχέγονους (raw) τύπους και αντικείμενα από αρχεία.

Ανάγνωση bytes

Η διεπαφή InputStream διαθέτει τη μέθοδο read() για ανάγνωση ενός byte από έναν πόρο. Επιστρέφει έναν ακέραιο μεταξύ 0 και 255 (0xFF) ή -1 (0xFFFFFFF) αν διάβασε το τέλος του πόρου. Ένα σύνηθες λάθος πολλών προγραμματιστών (που έχουν εκμετταλευτεί πολλές φορές οι hackers) είναι η πρόωρη μετατροπή του byte που διαβάζεται σε byte:

jshell> String filepath ="/tmp/test.txt";
filepath ==> "/tmp/test.txt

jshell> try (InputStream in = new FileInputStream(filepath)) {
   ...> byte data;
   ...> while ((data = (byte) in.read()) != -1) {
   ...> // ...
   ...> }

Στον παραπάνω έλεγχο γίνεται πρώτα η μετατροπή του χαρακτήρα που διαβάζεται από την ροή σε byte και μετά ελέγχεται αν αυτός είναι ο -1. Αν το -1 υπάρχει μέσα στο αρχείο που διαβάζεται, μετατρέποντάς το πρώτα σεbyte (δηλ. στο 0xFF), θα ‘χει ως αποτέλεσμα ο βρόγχος να τερματιστεί πρόωρα (το 0xFF θα μετατραπεί πάλι στον ακέραιο 0xFFFFFFF για να συγκριθεί με το -1).

Ο σωστός τρόπος είναι:

jshell> try (InputStream in = new FileInputStream(filepath)) {
   ...> int dataIn;
   ...> byte data;
   ...> while ((dataIn = in.read()) != -1) {
   ...> data = (byte)dataIn;
   ...> // ...
   ...> }

Παρατηρήστε ότι ο παραπάνω κώδικας χρησιμοποιεί την try-with-resources η οποία κλείνει αυτόματα τους ανοικτούς πόρους (στην περίπτωσή μας το αρχείο που διαβάζουμε) κι έτσι δε χρειάζεται να κλείσουμε το μπλοκ με τη finally:

finally {
  try {
    if (bis != null)
	   bis.close();
  } catch (IOException x) {
  // handle error
  }	   
}

Υπενθυμίζουμε εδώ ότι για να μπορέσουμε να χρησιμοποιήσουμε την try-with-resources, η κλάση θα πρέπει να υλοποιεί το interface Autocloseable.

Στο παρακάτω παράδειγμα διαβάζουμε έναν ακέραιο από τη μονάδα εισόδου (συνήθως το πληκτρολόγιο):

jshell> int num = 0;
num ==> 0

jshell> try {
   ...> num = System.in.read();
   ...> } catch(IOException ioe) {
   ...> System.err.println("Cannot read input " + ioe);
   ...> }
1

jshell> System.out.println(num);
49

Το αποτέλεσμα δεν είναι όμως το αναμενόμενο. Ο τύπος επιστροφής της μεθόδου System.in.read() είναι int αλλά επιστρέφει μια τιμή τύπου byte (στην περιοχή 0 έως 255). Συνεπώς, για την είσοδο 1, το πρόγραμμα εκτυπώνει την τιμή ASCII 49. Η μέθοδος ανάγνωσης “μπλοκάρει” μέχρι να λάβει την είσοδο του χρήστη. Αν υπάρξει εξαίρεση κατά την ανάγνωση, η μέθοδος εγείρει μια IOException.

Αυτό το πρόγραμμα απεικονίζει τη χρήση και των τριών ρευμάτων: η System.in χρησιμοποιείται εδώ για να λάβει το πρόγραμμα την είσοδο από την κονσόλα, η System.out χρησιμοποιείται για την εκτύπωση της τιμής ακέραιας ανάγνωσης στη μονάδα εξόδου (συνήθως οθόνη) και η System.err χρησιμοποιείται στην περίπτωση που υπάρξει κάποια εξαίρεση και εμφανίζει ένα μήνυμα στη μονάδα σφάλματος (συνήθως οθόνη).

Η BufferedInputStream είναι πιο αποδοτική από την FileInputStream καθώς διατηρεί μια εσωτερική μνήμη (buffer) οπότε συνίσταται αν θέλετε να διαβάσετε μεγάλα αρχεία.

Ο παρακάτω κώδικας που διαβάζει ένα αρχείο στο σύνολό του χρησιμοποιεί μια υπερφορτωμένη μέθοδο της read(), την int read(byte[] buffer, int offset, int length) η οποία επιστρέφει τον αριθμό των bytes που διαβάστηκαν:

jshell> String filepath ="/tmp/test.txt";
filepath ==> "/tmp/test.txt"

jshell> try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(filepath))) {
   ...> int length = (int) new File(filepath).length();
   ...> byte[] buffer = new byte[length];
   ...> if (bis.read(buffer, 0, length) == length) {	// read(byte[] buffer, int offset, int length)
   ...> System.out.println(new String(buffer, "UTF-8"));
   ...> }
   ...> } catch (FileNotFoundException e) {
   ...> e.printStackTrace();
   ...> } catch (IOException e) {
   ...> e.printStackTrace();
   ...> }

Άλλος τρόπος για να διαβάσουμε ένα αρχείο. Αν δε διαβαστεί ολόκληρο εγείρεται μια εξαίρεση.

jshell> try (DataInputStream dis = new DataInputStream(new FileInputStream(filepath))) {
   ...> int length = (int) new File(filepath).length();
   ...> byte[] buffer = new byte[length];
   ...> dis.readFully(buffer);
   ...> System.out.println(new String(buffer, "UTF-8"));
   ...> } catch (FileNotFoundException e) {
   ...> e.printStackTrace();
   ...> } catch (IOException e) {
   ...> e.printStackTrace();
   ...> }

Ανάγνωση αρχέγονων δεδομένων

Είδαμε μια χρήση της DataInputStream προηγουμένως. Πέραν της μεθόδου readFully που διαβάζει μια συστοιχία από bytes υπάρχουν και οι readByte(), readShort(), readInt(), readLong(), readFloat() και readDouble().

Ανάγνωση αντικειμένων

try (ObjectInputStream ois =
         new ObjectInputStream(new FileInputStream("./tracks.dump"))) {
    Object obj = ois.readObject();
    if (obj != null && obj instanceof List) {
        List<Track> tracksRead = (List<Track>) obj;
        for (Track track : tracksRead) {
            System.out.println(track.toString());
        }
    }
} catch(FileNotFoundException fnfe) {
    System.err.println("Cannot read a file with the given file name.");
} catch(IOException ioe) {
    System.err.println("An I/O error occurred while processing the file.");
} catch(ClassNotFoundException cnfe) {
    System.err.println("Cannot recognize the object's class. Is the file corrupted?");
}

Το αποτέλεσμα θα πρέπει να είναι παρόμοιο του παρακάτω:

Track{trackType=AIR, speed=10000, x=20.0, y=-20.0 height=5000} 
Track{trackType=LAND, speed=20, x=10.0, y=-25.0} 

αν έχετε υλοποιήσει κατάλληλα την μέθοδο toString() των κλάσεων Track, AirTrack, LandTrack.

Ανάγνωση χαρακτήρων

Η διεπαφή Reader διαθέτει τη μέθοδο read() για να διαβάσει έναν χαρακτήρα από κάποιον πόρο και επιστρέφει την τιμή του ως ακέραιο μεταξύ 0 και 65,535 ή −1 αν διάβασε το τέλος του αρχείου (επειδή πρέπει να διαβάσει το -1 που δηλώνει το τέλος του αρχείου, και το -1 είναι εκτός του εύρους χαρακτήρων 0-65536 επιστρέφει int αντί για char). Ένα σύνηθες λάθος (που έχει εκμετταλευτεί πολλές φορές από hackers) είναι η πρόωρη μετατροπή του χαρακτήρα που διαβάζεται σε char:

jshell> String filepath ="/tmp/test.txt";
filepath ==> "/tmp/test.txt

jshell> try (Reader in = new FileReader(filepath)) {
   ...> char data;
   ...> while ((data = (char) in.read()) != -1) {
   ...> // ...
   ...> }

Στον παραπάνω έλεγχο γίνεται πρώτα η μετατροπή ανάγνωσης του χαρακτήρα σε char και μετά ελέγχεται αν αυτός είναι ο -1. Στην περίπτωση της πρόωρης μετατροπής σε char, ο -1 μετατρέπεται στον χαρακτήρα Character.MAX_VALUE ή 0xFFFF και ποτέ πίσω σε int με αποτέλεσμα ο βρόγχος να μην τερματίζεται ποτέ.

Ο σωστός τρόπος είναι:

jshell> try (Reader in = new FileReader(filepath)) {
   ...> int dataIn;
   ...> char data;
   ...> while ((dataIn = in.read()) != -1) {
   ...> data = (char)dataIn;
   ...> // ...
   ...> }

Αν θέλετε να μετατρέψετε συστοιχίες από bytes σε χαρακτήρες (με συγκεκριμένη κωδικοποίηση), τότε υπάρχει η InputStreamReader για καλύτερη απόδοση.

jshell> import java.nio.charset.*

jshell> BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("C:/temp/test.txt"), StandardCharsets.UTF_8));
reader ==> java.io.BufferedReader@51b279c9

jshell> String line;
line ==> null

jshell> while((line = reader.readLine()) != null) {
   ...> System.out.println(line);
   ...> }
Σε γνωρίζω από την κόψη
 του σπαθιού την τρομερή

Για να διορθώσουμε το πρόβλημα με την System.in που είδαμε πιο πάνω:

BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
String line = br.readLine();
// ή
Scanner scanner = new Scanner(System.in);
String line = scanner.next();

Αντιγραφή αρχείων

Ανά byte

String srcFile = ...;
String destFile = ...;
final int BUFFER_SIZE = 4096;
try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(srcFile));
     BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(dstFile));) {
    byte[] buffer = new byte[BUFFER_SIZE];
    int bytesRead = 0;
    while ((bytesRead = bis.read(buffer)) >= 0)
        bos.write(buffer, 0, (int) bytesRead);
} catch (FileNotFoundException e) {
    e.printStackTrace();
} catch (IOException e) {
    e.printStackTrace();
}

Ανά χαρακτήρα

String srcFile = ...;
String destFile = ...;
try (BufferedReader inFile = new BufferedReader(new FileReader(srcFile));
	 BufferedWriter outFile = new BufferedWriter(new FileWriter(dstFile))) {
	int ch = 0;
	while( (ch = inFile.read()) != -1) {
		outFile.write((char)ch);
	}
} catch (FileNotFoundException e) {
    e.printStackTrace();
} catch (IOException e) {
    e.printStackTrace();
}

Μετονομασία αρχείου

jshell> File srcFile = new File("C:\temp\test.txt");
srcFile ==> C:\temp\test.txt

jshell> File destFile = new File("C:\temp\test.bak");
destFile ==> C:\temp\test.bak

jshell> srcFile.exists();
$1 ==> false

jshell> srcFile.createNewFile();
$2 ==> true

jshell> srcFile.renameTo(destFile);
$3 ==> false

Αν η μετονομασία αποτύχει, δεν εγείρεται κάποια εξαίρεση. Ο μόνος τρόπος να γνωρίζουμε αν κάτι πήγε στραβά είναι να ελέγξουμε την τιμή επιστροφής της εντολής, αλλά και πάλι δεν είναι ξεκάθαρο τι μπορεί να έχει συμβεί (π.χ. το αρχείο στο οποίο προσπαθούμε να μετονομάσουμε το αρχείο μας υπάρχει ήδη ή προσπαθούμε να μετονομάσουμε ένα αρχείο που δεν υπάρχει;).

Η μετονομασία στο παραπάνω παράδειγμα αποτυγχάνει επειδή δεν υπάρχει το πηγαίο αρχείο srcFile.

jshell> srcFile.createNewFile();
$4 ==> true

jshell> srcFile.exists();
$5 ==> true

jshell> destFile.exists();
$6 ==> false

jshell> srcFile.renameTo(destFile);
$7 ==> true

jshell> srcFile.delete()
$8 ==> false

Προσέξτε ότι η renameTo(File f) δέχεται ως παράμετρο ένα αντικείμενο τύπου File αντί για String. Έτσι, το srcFile δεν υπάρχει πια, κι αν θέλετε να διαγράψετε το αρχείο θα πρέπει να δώσετε:

jshell> destFile.delete()
$9 ==> true

Ως άσκηση δοκιμάστε διάφορες περιπτώσεις και δείτε τι συμβαίνει σε κάθε περίπτωση:

Διαγραφή αρχείων

Όπως είδαμε προηγουμένως:

jshell> file.delete()
$1 ==> false

Αν η διαγραφή αποτύχει, δεν εγείρεται κάποια εξαίρεση. Ο μόνος τρόπος να γνωρίζουμε αν κάτι πήγε στραβά είναι να ελέγξουμε την τιμή επιστροφής της εντολής:

jshell> if (!file.delete()) {
   ...> System.out.println("Deletion failed");
   ...> } 
Deletion failed

Δυαδικά αρχεία τυχαίας προσπέλασης

Πέρα από τα αρχεία κειμένου υπάρχουν και τα δυαδικά αρχεία ή αλλοιώς Αρχεία Τυχαίας Προσπέλασης (Random Access Files).

jshell> File dataFile = new File("C:/temp/data.bin")
dataFile ==> C:\temp\data.bin

jshell> RandomAccessFile data = new RandomAccessFile(dataFile, "rw")
data ==> java.io.RandomAccessFile@1e9e725a

jshell> data.writeUTF("Κατερίνα")

jshell> data.writeInt(35)

jshell> data.close()

Δημιουργήσαμε ένα νέο δυαδικό αρχείο στο οποίο αποθηκεύσαμε ένα αλφαριθμητικό και έναν ακέραιο αριθμό.

jshell> RandomAccessFile data = new RandomAccessFile(dataFile, "r")
data ==> java.io.RandomAccessFile@797badd3

jshell> data.readUTF()
$7 ==> "Κατερίνα"

jshell> data.readInt()
$8 ==> 35

jshell> data.length()
$9 ==> 22

Το αλφαριθμητικό “Κατερίνα” χρησιμοποιεί 2x8 = 16 bytes (κάθε χαρακτήρας σε μορφή Unicode χρησιμοποιεί 2 bytes) και ο ακέραιος άλλα 4 bytes οπότε σύνολο 20 bytes. Από που προέκυψαν τα 22 bytes; H writeUTF() χρησιμοποιεί άλλα 2 bytes στην αρχή που περιέχουν το μήκος των bytes του αλφαριθμητικού που θα γραφεί. Επομένως, αν θέλουμε να διαβάσουμε την ηλικία της Κατερίνας, θα πρέπει να μετακινηθούμε 2+16 = 18 bytes:

jshell> RandomAccessFile data = new RandomAccessFile(dataFile, "r")
data ==> java.io.RandomAccessFile@709ba3fb

jshell> data.seek(18)

jshell> data.readInt()
$10 ==> 35

Σημείωση Αν το αλφαριθμητικό αποτελείται μόνο από λατινικούς χαρακτήρες (ASCII) τότε κάθε χαρακτήρας αποθηκεύεται σε ένα μόνο byte (αντί για 2 στην περίπτωση UTF8). Π.χ. αν αντί για “Κατερίνα” πληκτρολογούσατε “Katerina” το data.length() θα επέστρεφε 14 bytes αντί για 22, 8+2 για το αλφαριθμητικό + 4 για την ηλικία.

Περίληψη

Σ’ αυτό το μάθημα μάθαμε πώς να διαχειριζόμαστε το σύστημα αρχείων του Η/Υ μας, να προσπελάζουμε αρχεία και φακέλους, να δημιουργούμε αρχεία και φακέλους και να διαβάζουμε/γράφουμε σε αρχεία, τόσο κειμένου όσο και δυαδικά. Το ΑΡΙ της Java για διαχείριση αρχείων δεν είναι και το καλύτερο καθώς εξελίχθηκε μαζί με τη γλώσσα. Υπάρχουν πολλές ασυνέπειες, πολλές από τις οποίες προσπαθεί να διορθώσει το New I/O (ΝΙΟ) όπως θα δούμε στο επόμενο μάθημα.

Κλάσεις για τις οποίες δε μιλήσαμε:

interface FileNameFilter {
	boolean accept(File dir, String filename); // Tests if a specified file should be included in a file list.
}

interface FileFilter {
	boolean accept(File pathname); // Tests whether or not the specified abstract pathname should be included in a pathname list.
}

class FileDescriptor { // ένα χειριστήριο (handle) προς μια δομή αρχείου του Λ.Σ.
	void sync(); // Force all system buffers to synchronize with the underlying device.
	boolean	valid(); // Tests if this file descriptor object is valid.
} 

/*
Actions:
* read 
* write
* execute (επιτρέπει την κλήση της Runtime.exec() και αντιστοιχεί στην SecurityManager.checkExec())
* delete (επιστρέπει την κλήση της File.delete και αντιστοιχεί στην SecurityManager.checkDelete())
* readlink (επιστρέπει την ανάγνωση του προορισμού ενός symbolic link καλώντας τη μέθοδο readSymbolicLink())
*/
class FilePermission {
	FilePermission(String path, String actions); // actions, βλ. παραπάνω
}

Στο επόμενο μάθημα θα δούμε τις δυνατότητες του New I/O 2 για τη διαχείριση συστημάτων αρχείων.

Ασκήσεις

1) Να δημιουργήσετε ένα πρόγραμμα σε Java το οποίο θα διαβάζει το όνομα ενός αρχείου ή φακέλου που δίνεται από τον χρήστη και θα επιστρέφει το μέγεθός του σε KiloBytes.

System.out.print("Enter a file or directory: ");
Scanner input = new Scanner(System.in);
String directory = input.nextLine();
System.out.println(getSize(new File(directory)) + " KB");

long getSize(File file) {
// υλοποιήστε αυτή τη μέθοδο
}

2) Κάθε τύπος αρχείου διακρίνεται από κάποιον ‘μαγικό αριθμό’ που το χαρακτηρίζει. Ο ‘μαγικός αριθμός’ που χαρακτηρίζει ένα αρχείο .class αποθηκεύεται στα τέσσερα πρώτα bytes. Χρησιμοποιώντας αυτά που μάθατε σ’ αυτό το μάθημα βρείτε ποιος είναι αυτός ο ‘μαγικός αριθμός’ διαβάζοντας τα 4 πρώτα bytes ενός οποιουδήποτε αρχείου .class.

3) Στο παράδειγμα με την ιεραρχία κλάσεων Track, AirTrack, LandTrack, αντί να αποθηκεύετε/διαβάζετε μια λίστα από Tracks, δημιουργήστε δυο μεθόδους στην κλάση Track που θα μετατρέπουν το κάθε ίχνος σε/από μια συστοιχία από bytes για αποθήκευση σε/ανάγνωση από ένα δυαδικό αρχείο και τροποιήστε την main() ώστε να χρησιμοποιεί αυτές τις μεθόδους.

public byte[] toByteArray() { ... }

public Track toObject(byte[] bytes) { ... }

Πηγές

  1. Daconta M.C. et al. (2003), More Java Pitfalls, Wiley.

<- Δ ->