• Non ci sono risultati.

0.4 Java vs. Go

1.1.2 Soluzioni

Il codice sorgente di un programma Java è generalmente composto da molti file: un .java

per ogni classe pubblica, file contenenti dati e altri risorse utili al programma, codice di

test , ecc. Il compilatore Java (javac) e la Java Virtual Machine (java) necessaria

all’e-secuzione del bytecode generato dal compilatore, usano opportune strategie per trovare

i file necessari alla produzione e l’esecuzione, ma è facile trovarsi in difficoltà quando

si tratta di risolvere eventuali eccezioni “Class not found” e similari. Strumenti come

Gradle

semplificano molto la produzione di programmi complessi, permettendo anche di

scaricare al bisogno le librerie da cui il programma dipende. Occorre però organizzare

il codice con una struttura convenzionale e scrivere un file build.gradle che elenca le

dipendenze e altre particolarità del progetto. Gli esempi sono preparati per essere

ri-prodotti con Gradle: perciò il codice Java si trova nella cartella src main java come

previsto dalla convenzione (i test vanno invece in src test java), le risorse ulteriori in

src main resources e un build.gradle adeguato descrive il processo di assemblaggio

del programma. Ciò rende possibile eseguire il programma anche con un semplice

co-mando gradle run, che compila ed esegue il programma finale, a patto che il Software

Development Kit (SDK) di Java e Gradle siano installati correttamente; serve inoltre una

connessione di rete funzionante per scaricare le librerie necessarie (p.es. JavaFX). Gradle

1.1 Campo minato può anche essere usato da IntelliJ IDEA: è sufficiente importare il progetto segnalando la presenza del build.gradle.

Attenzione: IntelliJ IDEA è uno strumento complesso (e in continua evoluzione). Non è necessario conoscerne tutti i particolari, anche se una scorsa alla documentazione di base può aumentare parecchio la propria produttività (qui il

video introduttivo

del produttore JetBrains). Utilissima è la funzionalità

Find Action

(attivabile anche premendo due volte in sequenza il tasto ) che permette di cercare una funzionalità anche per nome, senza doversi ricordare in quale menù si trova o quale tasto l’attiva. Per esempio: supponiamo di voler eseguire il nostro programma con Gradle, ma non ricordare dove si trovano i comandi del plugin Gradle

1

(generalmente in un ‘tab’ verticale a destra). Basterà cercare

Gradle

con

Find Action

e cliccare sul risultato principale. A questo punto potremo usare la finestra Gradle per eseguire il programma, cliccando due volte sul task

run

.

1. Reperire la documentazione

Durante la programmazione e la lettura del codice è molto importante disporre facilmente della documentazione di Java e delle librerie in uso. Durante la scrittura IDEA cerca di completare i simboli che scriviamo (eventualmente premendo ) e le scelte possibili appaiono in un menù vicino al cursore. Se invece stiamo leggendo codice già scritto, l’azione

Quick Documentation permette di ottenere la documentazione di un simbolo (i commenti che il programmatore ha inserito secondo lo standard

javadoc

). Ulteriori informazioni si trovano con opportune ricerche in rete: è bene fare attenzione, però, ai numeri di versione, per evitare di trovare documentazione non allineata con ciò che si sta usando.

L’organizzazione orientata agli oggetti del codice Java facilita molto la ricerca delle funzio-nalità. Per esempio, le procedure che manipolano stringhe saranno per lo più metodi della classe

String

: una ricerca "java 11 string" è sufficiente per trovare ladocumentazione ufficiale online di

String

e vedere elencati tutti i metodi predefiniti, oltre a scoprire una delle caratteristiche più importanti dell’implementazione di

String

: si tratta di una classe di oggetti immutabili, ogni metodo che manipola una stringa, produce perciò un nuovo oggetto.

2. Quanti oggettiCellvengono istanziati?

Se cerchiamo gli usi della classe

Cell

(Find Usages), escludendo i test, troviamo due punti significativi:

Nel costruttore di

Minefield

, alla linea 50 di Minefield.java:

aCells = new Cell[pRows][pColumns];

Qui viene creato un array bidimensionale con

pRows

righe e

pColumns

colonne: i valori utilizzati sono i parametri del costruttore della classe

Minefield

. Si noti che viene creato l’array, ma non gli oggetti cui gli elementi dell’array si riferiscono: quindi al momento tutti gli elementi sononull.

Nel metodo

Minefield.initialize

, alla linea 215 di Minefield.java:

1Attenzione perché ci siano va installato il plugin Gradle e questo deve essere attivo: è peraltro lo stato delle installazioni standard di IntelliJ IDEA.

private void initialize() {

for( int row = 0; row < aCells.length; row++) {

for( int column = 0; column < aCells[0].length; column++) {

aCells[row][column] = new Cell();

aAllPositions.add(new Position(row, column));

} } }

Qui vengono effettivamente creati gli oggetti

Cell

(con new

Cell()

), uno per ogni ele-mento dell’array. In totale sono 8 × 20 = 160, visto che in Minesweeper.java viene creato l’unico oggetto

Minesweeper

con questi valori di righe e colonne. In realtà sono 160 ogni volta che si crea un oggetto

Minesweeper

: premendo si ricomincia il gioco, creando un nuovo oggetto

Minesweeper

e perciò 160 nuovi oggetti

Cell

.

3. Quanti oggetti Position vengono istanziati?

Contare gli oggetti

Position

è un po’ più difficile. Find Usages trova due punti in cui vengono creati oggetti Position.

Nel primo (linea 216 di Minefield.java) viene creato un oggetto

Position

per ogni

Cell

.

private void initialize()

{ for( int row = 0; row < aCells.length; row++) {

for( int column = 0; column < aCells[0].length; column++) {

aCells[row][column] = new Cell();

aAllPositions.add(new Position(row, column));

} }

}

Nel secondo (linea 289 di Minefield.java) si crea un oggetto

Position

quando si cercano le posizioni adiacenti a una posizione data.

private List<Position> getNeighbours(Position pPosition) { List<Position> neighbours = new ArrayList<>();

for( int row = Math.max(0, pPosition.getRow() -1);

row <= Math.min(getNumberOfRows()-1, pPosition.getRow()+1);

row++)

{ for( int column = Math.max(0, pPosition.getColumn()-1);

column <= Math.min(getNumberOfColumns()-1, pPosition.getColumn()+1);

column++)

{ Position position = new Position(row, column);

if( !position.equals(pPosition)) {

1.1 Campo minato

neighbours.add(position);

} } }

return neighbours;

}

Il numero totale di oggetti

Position

, dunque, è senz’altro ≥ 160, ma dipende dal numero di volte in cui si cercano le posizioni adiacenti, quindi dallo sviluppo del gioco.

Per contarli, una strategia può essere quella di aumentare un contatore a ogni creazione (cioè nel costruttore dell’oggetto Position). Il contatore potrebbe essere una variabile glo-bale del programma, ma questa sarebbe una scelta che non garantisce nessuna informa-tion hiding e qualunque porzione di codice potrebbe influenzarne il valore. Molto meglio creare una variabile condivisa solo tra gli oggetti

Position

. Ciò è possibile marcando un membro di

Position

comestatic.

public class Position {

private final int aRowIndex;

private final int aColumnIndex;

static int counter = 0;

// ...

Senza alcuna indicazione di accesso (public,privateoprotected) la variabile è visibile e alterabile da tutte le classi dello stesso package (in questo caso

ca.mcgill.cs.sw

evo.minesweeper come si desume dalla linea 21). Meglio segnarla come private e aggiungere un metodopublicche permetta solo di leggerne il valore, ma non di cambiarlo (operazione riservata agli oggetti

Position

). Questi metodi sono noti col nome di getter e possono essere prodotti anche automaticamente con l’azione Create getter for.

public class Position

{ private final int aRowIndex;

private final int aColumnIndex;

private static int counter = 0;

public static int getCounter() {

return counter;

} // ...

Il contatore deve poi essere incrementato nel costruttore di Position. A questo punto possiamo stamparne il valore prima del termine del programma.

public static void main(String[] pArgs) {

launch(pArgs);

System.out.println("# of Position objects: " + Position.getCounter());

}

Se eseguiamo il programma col debugger (azione Debug) possiamo anche vedere il valore della variabile

counter

in qualsiasi momento (bloccando l’esecuzione fissando un break-point). In realtà il debugger di IDEA fornisce anche una funzionalità (nella Memory

view del debugger) che permette direttamente di contare gli oggetti presenti in memoria appartenenti a una determinata classe.

4. Quali Responsabilità e Collaborazioni gestisce Cell?

Un oggetto

Cell

ha la responsabilità di gestire le informazioni riguardo un elemento del piano di gioco (gestito da

Minefield

), che può essere minato. L’elemento inoltre può essere nascosto o visibile al giocatore. Quando è nascosto, il giocatore ha la possibilità di aggiungere o togliere una “marcatura”. Collaborazioni: i tipi base e un’enumerazione privata

CellInteractionStatus

.

Ragionare su quali responsabilità e collaborazioni è un buon modo di comprendere (e progettare) sistemi a oggetti che non si prestano ad analisi (e sintesi) gerarchiche come invece succede nei programmi strettamente procedurali

Da http://c2.com/doc/oopsla89/paper.html, l’articolo che presenta il cosiddetto ap-proccio Class, Responsibility, and Collaboration (CRC) per progettare sistemi a oggetti:

The class name of an object creates a vocabulary for discussing a design.

[. . . ]

Responsibilities identify problems to be solved. The solutions will exist in many versions and refinements. A responsibility serves as a handle for discussing potential solutions. The responsibilities of an object are expressed by a handful of short verb phrases, each containing an active verb. The more that can be expressed by these phrases, the more powerful and concise the design.

[. . . ]

One of the distinguishing features of object design is that no object is an island.

All objects stand in relationship to others, on whom they rely for services and control. The last dimension we use in characterizing object designs is the collaborators of an object. We name as collaborators objects which will send or be sent messages in the course of satisfying responsibilities. Collaboration is not necessarily a symmetric relation.

5. Quali Responsabilità e Collaborazioni gestisce Position?

Un oggetto

Position

ha la responsabilità di gestire una posizione (di un oggetto

Cell

) in una griglia rettangolare.

Collaborazioni: i tipi base. Si tratta di una classe facilmente riutilizzabile in contesti diversi: sostanzialmente ovunque serva una coppia di coordinate positive.

6. Quali Responsabilità e Collaborazioni gestisce Minefield?

Un oggetto

Minefield

ha la responsabilità di gestire il piano di gioco. Per farlo collabora con

Cell

e

Position

. Il piano di gioco è una matrice di celle (alcune delle quali minate), ciascuna associata a una posizione.

Vale la pena di notare che la parte “grafica” dell’applicazione (realizzata sfruttando il framework JavaFX) è separata da quella della logica del gioco (incapsulata nelle tre classi

Cell

,

Position

e

Minefield

).

1.1 Campo minato

7. Premendo il tasto r si ricominci

L’interazione grafica tramite la Graphical User Interface (GUI) è responsabilità della classe

Minesweeper

(in collaborazione con le classi della libreria JavaFX). Sarà quindi in questa classe che cercheremo un metodo incaricato di gestire gli eventi di interazione.

Tipicamente si tratta di metodi chiamati dai framework grafici come JavaFX: il pro-grammatore del metodo si aspetta che il metodo venga chiamato al momento giusto (cioè quando l’utente preme un tasto o agisce col mouse, ecc.). Per questo motivo questi me-todi hanno generalmente nomi convenzionali o usano altre tecniche (come le annotazioni) per risultare identificabili dal framework chiamante. Si tratta del cosiddetto “Hollywood principle”, nome scherzoso che deriva da uno schema ricorrente nelle audizioni fatte a Hol-lywood, che generalmente terminano con la raccomandazione di non chiamare per sapere come è andata: «Non si preoccupi, la chiameremo noi. . . ».

In effetti, nel metodo

createScene()

troviamo una chiamata

root.setOnKeyPressed

: per chiarire i dettagli è necessario consultare la documentazione di JavaFX. Ma il nome del messaggio

setOnKeyPressed

(inviato a un oggetto di classe

BorderPane

) ci suggerisce l’esistenza di un metodo per associare l’esecuzione di codice specifico alla pressione di un tasto.

root.setOnKeyPressed(new EventHandler<KeyEvent>() { @Override

public void handle(final KeyEvent pEvent) { if (pEvent.getCode() == KeyCode.ENTER)

{ newGame();

refresh();

}

pEvent.consume();

}); }

Si noti che la chiamata crea “al volo” un oggetto di classe

EventHandler<KeyEvent>

per la quale si fornisce anche (di nuovo “al volo”, cioè senza passare per una definizione indipendente, con un nome e la possibilità di utilizzarla altrove) il metodo

handle

. Tutto ciò è necessario perché in Java ogni procedura deve far parte di una classe. A partire da Java 8, però, è possibile definire procedure anonime, cioè senza nome (quindi non riu-tilizzabili), dette espressioni lambda, secondo una consolidata tradizione informatica2. Con un’espressione lambda diventerebbe (si può ottenere automaticamente l’espressione lambda con IDEA Replace with lambda):

root.setOnKeyPressed(pEvent -> {

if (pEvent.getCode() == KeyCode.ENTER) {

newGame();

refresh();

}pEvent.consume();

});

2Il Lambda-calcolo è lo studio di un modello di computabilità in cui le operazioni primitive fondamentali sono l’astrazione funzionale e l’applicazione di un’astrazione a valori concreti.

Questa scrittura è in effetti preferibile in molti casi (ed è usata nella classe

Minesweeper

poco più avanti, nella chiamata di

button.setOnMouseClicked

), perché permette di concentrarci sul codice dello handler, l’unico metodo dell’oggetto creato precedentemente (si noti che se l’oggetto da creare al volo avesse bisogno di due metodi, l’espressione lambda non andrebbe più bene, perché non avremmo modo di distinguere con nomi diversi; qui in-vece ce n’era uno solo

handle

, quindi quello anonimo fornirà una riscrittura (overriding) di

handle

, come prima risultava esplicitamente dall’annotazione@Override). Si noti anche che

pEvent

è un nome arbitrario, che risulta legato (bound) solo nel contesto dell’espres-sione lambda (tutti gli altri simboli, come

newGame

, si dicono liberi, perché dipendono da definizioni esterne alla lambda): del tutto equivalentemente potremmo scrivere:

root.setOnKeyPressed(event -> {

if (event.getCode() == KeyCode.ENTER) {

newGame();

refresh();

}event.consume();

});

Per far sì che il gioco ricominci anche alla pressione del tasto r , aggiungiamo una clausola oralla condizione di selezione.

root.setOnKeyPressed(event -> {

if( event.getCode() == KeyCode.ENTER

|| event.getCode() == KeyCode.R) {

newGame();

refresh();

}

event.consume();

});

8. Cliccando con il tasto destro su una cella la si marchi in giallo

Il codice eseguito quando si clicca con il tasto destro (

MouseButton.SECONDARY

) è im-postato dalla chiamata di

button.setOnMouseClicked

.

button.setOnMouseClicked(e -> {

if( e.getButton() == MouseButton.SECONDARY ) { aMinefield.toggleMark(pPosition);

}else

{ aMinefield.reveal(pPosition);

}refresh();

});

L’effetto è quello di cambiare lo stato della marcatura della cella su cui si è cliccato (che si trova in

pPosition

nella matrice del campo minato). Chi definisce come rappresentare le celle marcate? Poco più sopra:

1.1 Campo minato

if (aMinefield.isMarked(pPosition)) {

button.setText("!");

}

Questo è il punto in cui aggiungere le istruzioni necessarie ad avere uno sfondo giallo. Lo stile delle celle nascoste è definito all’inizio:

private static final String TILE_STYLE_HIDDEN =

"-fx-background-radius: 0; -fx-pref-width: 22px; -fx-pref-height: 22px;" +

"-fx-focus-color: transparent; -fx-faint-focus-color: transparent;

-fx-font-size: 12; " +

"-fx-text-fill: red; -fx-font-weight: bold;";

Possiamo definire un nuovo stile con lo sfondo giallo:

private static final String TILE_STYLE_MARKED =

"-fx-background-radius: 0; -fx-pref-width: 22px; -fx-pref-height: 22px;" +

"-fx-focus-color: transparent; -fx-faint-focus-color: transparent;

-fx-font-size: 12; " +

"-fx-text-fill: red; -fx-font-weight: bold; -fx-background-color: yellow;";

E usarlo per le celle marcate:

if( aMinefield.isMarked(pPosition)) { button.setText("!");

button.setStyle(TILE_STYLE_MARKED);

}

Questo funziona, ma abbiamo una brutta duplicazione negli stili. Meglio fattorizzare la parte comune.

private static final String TILE_STYLE_MARKED = TILE_STYLE_HIDDEN +

"-fx-background-color: yellow;";

Possiamo anche fattorizzare tutte le parti comuni delle stringhe di stile: se un giorno volessimo cambiare le dimensioni delle celle avremmo un solo punto da cambiare.

private static final String TILE_STYLE_COMMON = "-fx-pref-width: 22px;

-fx-pref-height: 22px;";

private static final String TILE_STYLE_HIDDEN = TILE_STYLE_COMMON + "-fx-background-radius: 0;" +

"-fx-focus-color: transparent; -fx-faint-focus-color: transparent;

-fx-font-size: 12; " +

"-fx-text-fill: red; -fx-font-weight: bold;";

private static final String TILE_STYLE_MARKED = TILE_STYLE_HIDDEN +

"-fx-background-color: yellow;";

private static final String TILE_STYLE_REVEALED =

TILE_STYLE_COMMON + "-fx-border-width: 0; -fx-border-color: black;" +

"-fx-background-color: lightgrey;";

9. Schiacciando s si scoprano tutte le celle

Sappiamo già dove si gestisce la pressione dei tasti. Aggiungeremo quindi la gestione di

s

.

root.setOnKeyPressed(event -> {

if( event.getCode() == KeyCode.ENTER

|| event.getCode() == KeyCode.R) {

newGame();

refresh();

}else if (event.getCode() == KeyCode.S) { // TODO

}event.consume();

});

L’operazione da compiere è scoprire tutte le celle. La classe

Minefield

ha un metodo

revealAll

che farebbe al caso nostro: sfortunatamente èprivatequindi non è possibi-le accedervi dall’oggetto

aMinefield

con cui collabora

Minesweeper

. Rendere public il metodo non sarebbe una buona soluzione: infrangerebbe l’incapsulamento pensato dal progettista di

Minefield

, il quale, una volta che il metodo fosse reso pubblico, avreb-be la responsabilità di gestirlo in modo da non alterare il contratto con tutti i “clienti”

della classe. Meglio attenersi ai metodi già esplicitamente pubblici, che sono quelli che, dichiaratamente, servono per chiedere agli oggetti

Minefield

di portare a compimento le responsabilità loro affidate. Con

getAllPositions

si possono ottenere tutte le

Posit

ion

del campo minato e con

reveal

si può scoprire la cella in una data

Position

. Alla fine occorre anche rinfrescare la grafica.

else if (event.getCode() == KeyCode.S) {

for (Position p : aMinefield.getAllPositions()) { aMinefield.reveal(p);

}

refresh();

}

La sintassifor (Position p :

aMinefield.getAllPositions())

(“for each”) è re-sa possibile dal fatto che il metodo

getAllPositions

ritorna un

Iterable<Position>

da cui si può ottenere un

Iterator<Position>

che è in grado di rispondere ai messaggi

hasNext()

e

next()

usati automaticamente nelfor.

Visto che

refresh

va fatto anche nel caso di Enter/ r , meglio evitare di ripeterlo.

root.setOnKeyPressed(event -> {

if( event.getCode() == KeyCode.ENTER

|| event.getCode() == KeyCode.R) {

newGame();

}

else if (event.getCode() == KeyCode.S) {

for (Position p : aMinefield.getAllPositions()) { aMinefield.reveal(p);

} }refresh();

event.consume();

});

1.1 Campo minato

Ci sono solo due casi, ma unoswitchrende il codice più leggibile e più facile da modificare per aggiungere il trattamento di altri tasti (ma attenzione a non dimenticare ibreak!).

root.setOnKeyPressed(event -> { switch (event.getCode()) { case ENTER:

case R:

newGame();

break;

case S:

for (Position p : aMinefield.getAllPositions()) { aMinefield.reveal(p);

}break;

}refresh();

event.consume();

});

10. Schiacciando h si ottenga un suggerimento

In questo caso le cose da fare sono un po’ più complicate, meglio usare un metodo apposito per aumentare l’incapsulamento.

root.setOnKeyPressed(event -> { switch (event.getCode()) { case ENTER:

case R:

newGame();

break;

case S:

for (Position p : aMinefield.getAllPositions()) { aMinefield.reveal(p);

}break;

case H:

showHint();

break;

}refresh();

event.consume();

});

// ...

private void showHint() { // TODO

}

Una strategia facile per ottenere un suggerimento (cioè la posizione di una cella certamen-te minata secondo le informazioni disponibili al giocatore), potrebbe essere questa (per suggerimenti più elaborati si vedahttp://www.minesweeper.info/wiki/Strategy):

a) prendere in esame tutte le informazioni disponibili, analizzando tutte le celle scoperte;

b) per ogni cella scoperta, prendere in esame tutte le celle vicine ancora nascoste;

c) se il numero di celle vicine nascoste è uguale al numero di mine segnalate dalla cella scoperta, allora tutte quelle nascoste sono mine sicure.

Per implementare questa strategia sarebbe comodo poter ottenere direttamente tutte le celle scoperte. Aggiungiamo quindi un metodo a

Minefield

.

private List<Position> getAllRevealed() { List<Position> revealed = new ArrayList<>();

for (Position position : getAllPositions()) { if (isRevealed(position)){

revealed.add(position);

} }

return revealed;

}

Sarebbe utile anche un metodo per ottenere le vicine nascoste.

private List<Position> getHiddenNeighbours(Position p) { List<Position> hidden = new ArrayList<>();

for (Position neighbour : getNeighbours(p)) { if (!isRevealed(neighbour)){

hidden.add(neighbour);

}

}return hidden;

}

Il numero di mine segnalate da una cella scoperta è già calcolabile con il metodo

getNu

mberOfMinedNeighbours

. Quindi ora possiamo mettere tutto insieme:

/*** @return null if no simple hints are available,

* otherwise a Position hiding a mine.

public*/ Position getHint(){

for (Position p : getAllRevealed()) { int mined = getNumberOfMinedNeighbours(p);

if (mined > 0) {

List<Position> hidden = getHiddenNeighbours(p);

if (hidden.size() == mined){

for (Position h : hidden) { if (!isMarked(h)){

return h;

} } } } }

return null;

}

E a questo punto usare

getHint

in

Minesweeper

1.1 Campo minato

private void showHint() {

Position hint = aMinefield.getHint();

if (hint != null){

aMinefield.toggleMark(hint);

} }

11. Aggiungere a Minefieldla gestione dello spazio dei suoi stati possibili

Al momento gli stati possibili sono gli elementi dell’enumerazione

MinefieldStatus

. Unaenumnon è altro che una classe (cioè un insieme di oggetti) con un numero di istan-ze/oggetti predefinito, fissato e costante. In altre parole

MinefieldStatus

rappresenta l’insieme dei tre oggetti immutabili

MinefieldStatus.NOT_CLEARED

,

MinefieldStat

us.CLEARED

,

MinefieldStatus.EXPLODED

. La classe mette a disposizione anche alcuni metodi predefiniti: per esempio

MinefieldStatus.NOT_CLEARED.ordinal()

restitui-sce il numero d’ordine in cui appare la costante nell’enumerazione (zero-based, quindi 0 nell’esempio). E naturalmente si possono definire nuovi metodi, se ce ne fosse bisogno.

Per attribuire a

Minefield

la responsabilità di gestire

MinefieldStatus

(che attual-mente è una classe pubblica e accessibile senza la mediazione di

Minefield

) possiamo spostarla nell’ambito dell’incapsulamento di

Minefield

, come inner class (come già suc-cede per l’enumerazione

CellInteractionStatus

interna a

Cell

). L’operazione può anche essere fatta automaticamente da IDEA (con il refactoring Move Class). L’enumdeve rimanerepublic(o package protected) però, perché è utilizzata come valore di ritorno di metodi pubblici. Per usare le costanti fuori da

Minefield

occorre usare il nome completo che “attraversa” la classe

Minefield

, p.es.

Minefield.MinefieldStatus.CLEARED

. 12. Associare la rappresentazione della cella allo stato della cella stessa

Ogni oggetto in Java ha un metodo

toString

con cui è possibile ottenere una rappresen-tazione dell’oggetto come

String

: è il metodo usato quando per esempio si stampa con una

System.out.println(oggetto)

. Possiamo usare questo metodo per ottenere una

Ogni oggetto in Java ha un metodo

toString

con cui è possibile ottenere una rappresen-tazione dell’oggetto come

String

: è il metodo usato quando per esempio si stampa con una

System.out.println(oggetto)

. Possiamo usare questo metodo per ottenere una

Documenti correlati