Java – Gestione dell’input nelle applicazioni “console”

Nelle applicazioni Java di tipo “console”, cioè quelle che girano all’interno di una console (o “terminale”) a caratteri del sistema operativo, è molto tipico dover chiedere dei dati in input all’utente. In questo contesto ci sono diversi modi per gestire l’input:

  • La classe BufferedReader che incapsula un InputStreamReader che incapsula il System.in
  • La classe Scanner connessa al System.in
  • La classe Console

In questo articolo descriverò queste tre tecniche mostrando anche degli esempi pratici basilari.

Premessa

In tutti i moderni sistemi operativi esiste la nozione dei “canali” (stream) che permettono ad un programma di gestire l’input/output verso il resto del sistema, principalmente verso dei file. Questo approccio è una caratteristica peculiare dei sistemi operativi Unix e Unix-like ma esiste in modo similare anche in altri sistemi operativi come quelli Windows. Inoltre è un concetto alla base del I/O nei linguaggi C/C++ e altri derivati da questi.

Di questi canali ne esistono tre predefiniti chiamati convenzionalmente standard-input (abbreviato stdin), standard-output (abbreviato stdout) e standard-error (abbreviato stderr). Il canale stdin rappresenta lo stream per la lettura dei dati in input (in una console/terminale questo input arriva normalmente dalla tastiera) mentre i canali stdout/stderr rappresentano gli stream per scrivere qualcosa in output (stderr in modo più specifico serve per gli errori). Questi stream standard possono anche essere eventualmente “redirezionati” da/verso un file o altro dispositivo, nel qual caso ovviamente non hanno più la funzione originale/predefinita.

In Java questi stream vengono rappresentati dagli oggetti referenziati dalle costanti in, out e err della classe java.lang.System. Nel corso di questo articolo, l’attenzione sarà ovviamente focalizzata su System.in che è un oggetto di classe java.io.InputStream .

Uso delle classi BufferedReader e InputStreamReader con System.in

L’utilizzo delle classi di I/O BufferedReader e InputStreamReader con System.in rappresenta il modo più classico e “portabile” per chiedere dell’input all’utente da standard-input, poiché queste due classi esistono praticamente da sempre nel framework della piattaforma Standard Edition di Java.

InputStreamReader innanzitutto è semplicemente una classe che si comporta da “adattatore” per permettere di utilizzare un java.io.InputStream (e in questo caso specifico il System.in) dove è richiesto un java.io.Reader, che è in grado di leggere “caratteri” invece che solo byte “crudi”.

Al di “sopra” del InputStreamReader si mette solitamente un BufferedReader per un motivo molto semplice: ha il metodo readLine() che consente di leggere facilmente e comodamente una “riga” di testo.

L’esempio di utilizzo basilare di queste classi con System.in è il seguente:

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;

public class ProvaInput1 {
    public static void main(String[] args) throws IOException {
        BufferedReader input = new BufferedReader(new InputStreamReader(System.in));

        System.out.print("Inserisci il tuo nome: ");
        String nome = input.readLine();
        System.out.print("Inserisci il tuo cognome: ");
        String cognome = input.readLine();

        // .......
    }
}

Questa soluzione, pur essendo quella più classica e tradizionale, ha però due aspetti negativi che sono un po’ scomodi e noiosi:

  1. BufferedReader è solamente in grado di leggere una riga di testo oppure (ma per la lettura da System.in è più raro) un singolo carattere o un blocco di n caratteri. Qualunque altra logica per estrarre dei token o per convertire l’input in un valore numerico primitivo (es. int, long, ecc...) richiede del codice extra da scrivere appositamente.

  2. BufferedReader, essendo una classe che fa parte del package java.io per la gestione base del I/O, tratta e lancia in caso di errore l’eccezione java.io.IOException, che tra l’altro è una eccezione “checked” e quindi il programmatore non può ignorarla.

Riguardo le eccezioni, c’è comunque da dire che leggendo da System.in è abbastanza raro/improbabile che avvenga una eccezione, specialmente se l’input arriva direttamente da tastiera. Nell’esempio sopra, per pura semplicità, IOException è dichiarata con il throws nel metodo main e quindi può uscire fuori facendo così terminare l’applicazione. In altri scenari più reali e complessi potrebbe essere necessario fare una gestione più accurata.

Prima di concludere questa sezione, è bene precisare che l’oggetto BufferedReader connesso (indirettamente) a System.in dovrebbe essere creato una volta sola all’interno della applicazione. In altre parole, non c’è bisogno di ricreare più volte gli oggetti BufferedReader e InputStreamReader collegati a System.in .

Uso della classe Scanner con System.in

La classe java.util.Scanner è stata introdotta nella release Java 5 per gestire la tokenizzazione (ovvero la separazione in token) di una sequenza di testo sfruttando in maniera sofisticata le regular expression.

In generale Scanner legge una sequenza di caratteri da una certa “sorgente” (che può essere un file rappresentato da java.io.File oppure può essere un java.io.InputStream, un String o altro) e separa la sequenza in token utilizzando in maniera predefinita una regular expression che rappresenta “uno o più whitespace”. Un whitespace è uno spazio, un horizontal tab, un line feed, un carriage return e altro (vedere la documentazione javadoc di Character.isWhitespace(int) per i dettagli).

La classe Scanner quindi può essere facilmente usata anche per leggere l’input da System.in. Rispetto alla soluzione con BufferedReader e InputStreamReader, l’utilizzo di Scanner risulta differente per diversi motivi. Innanzitutto Scanner in generale non è una classe di I/O quindi, a parte l’oggetto che riceve come “sorgente”, non è interoperabile con le altre classi del package java.io poiché non deriva da una di queste classi (infatti estende direttamente Object). Inoltre i metodi di Scanner non lanciano la eccezione IOException ma altre eccezioni “unchecked” tra cui principalmente InputMismatchException e NoSuchElementException.

Scanner però permette di estrarre dei singoli token come stringa con il metodo next() e anche come valori primitivi con i metodi nextBoolean(), nextByte(), nextInt(), ecc... e inoltre permette di leggere una intera riga di testo con il metodo nextLine(), praticamente in maniera similare/equivalente al readLine() di BufferedReader.
In più offre dei metodi nella forma hasNextXyz() per verificare a priori se il prossimo token è estraibile secondo un certo tipo.

L’esempio di utilizzo basilare di Scanner con System.in è il seguente:

import java.util.Scanner;

public class ProvaInput2 {
    public static void main(String[] args) {
        Scanner input = new Scanner(System.in);

        System.out.print("Inserisci il tuo nome: ");
        String nome = input.nextLine();
        System.out.print("Inserisci il tuo cognome: ");
        String cognome = input.nextLine();

        // .......
    }
}

La soluzione con Scanner è quindi più interessante e pratica rispetto alla soluzione con BufferedReader e InputStreamReader, specialmente per non dover gestire eccezioni “checked” e anche per poter sfruttare i metodi nextXyz() per estrarre i token come valori primitivi.

Con Scanner però bisogna prestare attenzione ad alcuni aspetti che possono trarre in inganno o peggio creare problemi:

  1. il ben noto problema dell’uso combinato di nextLine() insieme agli altri nextXyz() (es. nextInt())

  2. il fatto che l’input dei valori numerici (in modo specifico quelli decimali) è “localizzato”, cioè si basa su un java.util.Locale e quindi il formato cambia in base alla lingua/paese impostato

  3. il fatto che se l’input è “malformato” (es. si inserisce 1x3 ad un nextInt()) il metodo lancia InputMismatchException ma il token non viene immediatamente rimosso perché resta disponibile in Scanner in modo da poter essere estratto e (eventualmente) trattato diversamente.

(nota: una spiegazione dettagliata di questi aspetti richiederebbe un ulteriore intero articolo che spero di poter fare in futuro)

In maniera similare a quanto detto nella precedente sezione, è bene precisare che l’oggetto Scanner connesso a System.in dovrebbe essere creato una volta sola all’interno della applicazione. In altre parole, non c’è bisogno di ricreare più volte l’oggetto Scanner collegato a System.in .

Uso della classe Console

La classe java.io.Console è una piccola novità introdotta nella release Java 6. Questa classe, come dice bene il nome, è specifica per la gestione dell’input/output in una “console” (o “terminale”) del sistema.

Console ha la caratteristica principale di essere un oggetto singleton, infatti non c’è un costruttore pubblico nella classe e l’unica istanza di Console si può ottenere solamente invocando il metodo statico System.console() .

Prima di continuare, bisogna chiarire innanzitutto una questione fondamentale. Le due precedenti tecniche descritte si basano sull’utilizzo di System.in, cioè lo stream di standard-input. Console invece non si basa su System.in, in quanto input e output vengono gestiti ad un livello più “basso”. Questo ha una implicazione molto importante: per poter usare la classe Console è necessario utilizzare una “vera” console, cioè l’applicazione disponibile nel sistema operativo che apre una finestra per operare in modalità “a caratteri”.

Se lo standard-input venisse redirezionato (es. da un file) o se non si sta utilizzando una vera console (ad esempio la vista Console del IDE Eclipse), la classe Console non può funzionare e il metodo System.console() restituisce semplicemente un null.

Tramite Console si possono fare diversi tipi di operazioni:

  • scrivere una stringa “formattata” (metodi format e printf)
  • leggere una intera riga (metodi readLine) con la possibilità di usare una stringa “formattata” di prompt
  • leggere una password (metodi readPassword) con la possibilità di usare una stringa “formattata” di prompt
  • ottenere un Reader e/o un PrintWriter (metodi reader e writer) per fare operazioni più sofisticate o particolari (ad esempio per incapsulare il Reader in Scanner)

In particolare Console offre una funzionalità esclusiva che non è possibile realizzare con le altre tecniche descritte prima: permette di leggere con readPassword una password in maniera “mascherata”, cioè senza fare l’eco dei caratteri (in pratica non si vede la password che si sta scrivendo). Inoltre la password viene fornita come array char[] in modo da poter essere successivamente azzerato per questioni di sicurezza.

Riguardo la gestione delle eccezioni, i metodi di lettura readLine e readPassword di Console non lanciano IOException ma java.io.IOError (introdotta in Java 6) per indicare l’occorrenza di un errore di I/O “serio”, cioè molto grave (e che in teoria non dovrebbe mai capitare).

L’esempio di utilizzo basilare di Console è il seguente:

import java.io.Console;

public class ProvaInput3 {
    public static void main(String[] args) {
        Console console = System.console();

        if (console == null) {
            System.err.println("Console non disponibile!");
            System.exit(0);
        }

        String nome = console.readLine("Inserisci il tuo nome: ");
        String cognome = console.readLine("Inserisci il tuo cognome: ");

        // .......
    }
}

Come si può vedere dal codice, una buona prassi è verificare innanzitutto se l’oggetto Console è disponibile. Se non lo fosse, generalmente la soluzione più semplice (ma drastica) è terminare subito l’applicazione (con il metodo System.exit(status) o facendo un return per uscire dal main).

Per concludere, a differenza delle precedenti soluzioni, non ci sono problemi se si dovesse invocare più volte il metodo System.console() in quanto l’oggetto Console restituito è sempre lo stesso essendo gestito come singleton.