Eserciziario di Programmazione II
Carlo Bellettini Mattia Monga
14 dicembre 2021
Indice
0 Dalla programmazione ‘in piccolo’ a quella ‘in grande’
7
0.1 Programmazione ‘in piccolo’
. . . . 7
0.1.1 Un esempio
. . . . 7
0.2 Programmazione ‘in grande’
. . . 12
0.3 Programmazione orientata agli oggetti
. . . 13
0.4 Java vs. Go
. . . 13
0.4.1 Hello world!
. . . 14
0.4.2 Schiacciatine in Java
. . . 16
1 Lab01: Esperimenti iniziali
25
1.1 Campo minato. . . 25
1.1.1 Esercizi
. . . 25
1.1.2 Soluzioni
. . . 26
1.2 Hello World!
. . . 38
1.2.1 Funzionalità del programma
. . . 39
1.2.2 Soluzioni
. . . 40
2 Lab02: Pokerhand
43
2.1 Repository. . . 43
2.2 Esercizi
. . . 43
2.2.1 Obiettivi
. . . 43
2.3 Soluzioni
. . . 44
3 Lab03: Pokerhand (cont.)
51
3.1 Repository. . . 51
3.2 Esercizi
. . . 51
3.2.1 Obiettivi
. . . 51
3.3 Soluzioni
. . . 52
4 Lab04: Rubamazzetto
59
4.1 Repository. . . 59
4.2 Esercizi
. . . 59
4.3 Obiettivi
. . . 59
4.3.1 Esame e comprensione del codice
. . . 59
4.3.2 Scrittura del codice
. . . 60
4.4 Soluzioni
. . . 60
4.4.1 Codice
. . . 61
5 Lab05: BlackJack
67
5.1 Esercizi
. . . 67
5.1.1 Il gioco
. . . 67
5.1.2 I giocatori
. . . 67
5.1.3 Obiettivi
. . . 68
5.2 Soluzioni
. . . 68
5.2.1 RandomStrategy
. . . 68
5.2.2 Sfidante
. . . 68
5.2.3 Mazziere
. . . 69
5.2.4 MultiMazzo
. . . 70
5.2.5 Strategia
. . . 70
5.2.6 BlackJack
. . . 72
6 Lab06: Musica Maestro!
75
6.1 Esercizi. . . 75
6.1.1 Obiettivi
. . . 75
6.2 Soluzioni
. . . 76
7 Lab07: Musica Maestro (con gli spettatori)!
81
7.1 Esercizi. . . 81
7.1.1 Requisiti
. . . 81
7.2 Soluzioni
. . . 82
8 Lab08: Temperature
91
8.1 Esercizi. . . 91
8.1.1 Requisiti
. . . 91
8.1.2 Obiettivi
. . . 91
8.1.3 TDD
. . . 93
8.1.4 Verifica e convalida
. . . 93
8.2 Soluzioni
. . . 93
8.2.1 Strategie di conversione temperature
. . . 93
8.2.2 Model
. . . 94
8.2.3 Viste
. . . 95
8.2.4 Controller
. . . 96
8.2.5 TestIntegrazione
. . . 96
Glossario
99
Indice
https://github.com/prmr/DesignBookhttps://rd.springer.com/book/10.1007%2F978-3-030-24094-3
0 Dalla programmazione ‘in piccolo’ a quella ‘in grande’
Questa dispensa si rivolge a studenti che abbiano già acquisito le competenze base della programmazione ‘in piccolo’, conoscendo quindi un linguaggio di programmazione della famiglia “imperativa”, per esempio Go.
0.1 Programmazione ‘in piccolo’
Si parla di programmazione ‘in piccolo’ quando il punto centrale dell’attività è la ricerca di un algoritmo efficace per una data elaborazione dell’informazione, seguita da una sua opportuna codifica in un linguaggio di programmazione che ne permetta l’esecuzione tramite un interprete automatico.
0.1.1 Un esempio
Un tipico esempio di esercizio di programmazione ‘in piccolo’ è il seguente (tratto dal materiale di supporto al tutorato di “Programmazione”, autori Violetta Lonati e Anna Morpurgo).
Si scriva un programma che legga (dallo standard input) due interi r e c, seguiti da una matrice di r righe e c colonne contenente lettere maiuscole e asterischi, e che stampi (sullo standard output) la matrice che si ottiene da quella in input “schiacciando” verso il basso le lettere e facendo “galleggiare” gli asterischi. Ad esempio, se la matrice è data da
V * S
* * B K * *
* S *
il programma dovrà stampare la matrice seguente:
* * *
* * * V * S K S B
Per risolvere l’esercizio bisogna ideare una strategia: si tratta di un processo in buona
parte, ma non del tutto, indipendente dal linguaggio di programmazione. Ogni linguag-
gio mette a disposizione diversi modi di rappresentare i dati e di esprimere computazioni,
anche se nella famiglia “imperativa” la sovrapposizione fra i diversi linguaggi è general- mente molto ampia, una volta scontata la varietà della sintassi. La strategia, quindi, dovrà tener conto della famiglia linguistica (risolvere un problema con un paradigma “lo- gico”
1, per esempio, richiede spesso un’impostazione specializzata) e in qualche misura anche delle peculiarità del linguaggio (costrutti espressivi, strutture dati di base, librerie standard, forme idiomatiche) in cui se ne prevede l’implementazione.
Per esempio in Go esaminare (scorrere gli elementi) di una matrice per righe è più facile che per colonne; nulla vieta però di leggere l’input e scrivere l’output come richiesto dall’esercizio e manipolare internamente una struttura dati trasposta.
Per immaginare una strategia è fondamentale suddividere il problema in sottoparti quasi elementari, in modo da articolare correttamente il pensiero
2.
Nell’esempio, potrebbe essere utile considerare separatamente almeno tre parti: (1) schiac- ciamento di una singola serie, (2) composizione degli schiacciamenti e (3) I/O.
Innanzitutto, quindi, ci concentriamo sullo schiacciamento di una singola serie.
package main import (
"unicode"
)
// schiaccia prende una serie di lettere e asterischi e li ordina in modo // da avere all'inizio tutti gli asterischi
func schiaccia(s []rune) (result []rune) { result = make([]rune, len(s))
var i uint
for _, c := range s { if c == '*' {
result[i] = c } i++
}for _, c := range s {
1Se questo termine è completamente nuovo, ci si può fare un’idea consultando la pagina Wikipedia dedicata all’argomento: https://en.wikipedia.org/wiki/Logic_programming
2Cartesio nel suo “Discorso sul metodo”, dopo aver dichiarato di non prendere mai niente per vero, “se non ciò che io avessi chiaramente riconosciuto come tale”, si ripromette di procedere seguendo questi famosi princìpi:
1. Il secondo, di dividere ognuna delle difficoltà sotto esame nel maggior numero di parti possibile, e per quanto fosse necessario per un’adeguata soluzione.
2. Il terzo, di condurre i miei pensieri in un ordine tale che, cominciando con oggetti semplici e facili da conoscere, potessi salire poco alla volta, e come per gradini, alla conoscenza di oggetti più complessi; assegnando nel pensiero un certo ordine anche a quegli oggetti che nella loro natura non stanno in una relazione di antecedenza e conseguenza.
3. E per ultimo, di fare in ogni caso delle enumerazioni così complete, e delle sintesi così generali, da poter essere sicuro di non aver tralasciato nulla.
[Cartesio, “Discorso sul metodo” a cura di A. Carlini, Bari 1963]
0.1 Programmazione ‘in piccolo’
if unicode.IsLetter(c) { result[i] = c
} i++
}return result }
È utile cercare di convincerci che la soluzione di questo sotto problema è corretta:
i pezzi semplici sono più facili da controllare (ed eventualmente correggere) di quelli complessi. La parte di confronto fra due slice di rune è opportuno scorporarla: fra l’altro potrà essere utile anche in altri test.
package main import "testing"
func equal(a, b []rune) bool { if len(a) != len(b) {
return false }for i, v := range a {
if v != b[i] { return false }
}return true }
// TestSchiaccia tests the first example func TestSchiaccia(t *testing.T) {
actual := schiaccia([]rune{'V', '*', 'K', '*'}) expected := []rune{'*', '*', 'V', 'K'}
if !equal(actual, expected) {
t.Errorf("\nGot: %v\nExp: %v\n", actual, expected) }
}
Passiamo poi a comporre le schiacciatine.
// SchiacciaTutte Schiaccia ogni riga
func SchiacciaTutte(s [][]rune) (result [][]rune) { for _, line := range s {
result = append(result, schiaccia(line)) }
return result }
Anche in questo caso possiamo controllare che funzioni almeno nel caso d’esempio, cui aggiungiamo anche alcune linee particolari (solo asterischi e solo lettere).
// TestSchiacciaTutte tests the example func TestSchiacciaTutte(t *testing.T) {
data := [][]rune{{'V', '*', 'K', 'S'}, {'*', '*', '*', 'S'},
{'S', 'B', '*', '*'}, {'*', '*', '*', '*'}, {'S', 'B', 'S', 'B'}}
actual := SchiacciaTutte(data)
expected := [][]rune{{'*', 'V', 'K', 'S'}, {'*', '*', '*', 'S'},
{'*', '*', 'S', 'B'}, {'*', '*', '*', '*'}, {'S', 'B', 'S', 'B'}}
for i, line := range actual { if !equal(line, expected[i]) {
t.Errorf("\nGot: %v\nExp: %v\n", actual, expected) } }
}
A questo punto manca solo l’I/O che però richiede una trasposizione.
// trasponi traspone le righe con le colonne func trasponi(m [][]rune) (result [][]rune) {
result = make([][]rune, len(m[0])) for i := range result {
result[i] = make([]rune, len(m)) }for i := range result {
for j := range result[i] { result[i][j] = m[j][i]
}
}return result }
Con il relativo test.
// TestTrasponi tests the example func TestTrasponi(t *testing.T) {
data := [][]rune{{'V', '*', 'K', 'S'}, {'*', '*', '*', 'S'},
{'S', 'B', '*', '*'}}
actual := trasponi(data)
expected := [][]rune{{'V', '*', 'S'}, {'*', '*', 'B'},
{'K', '*', '*'}, {'S', 'S', '*'}}
for i, line := range actual { if !equal(line, expected[i]) {
t.Errorf("\nGot: %v\nExp: %v\n", actual, expected) } }
}
0.1 Programmazione ‘in piccolo’
Ora possiamo passare all’I/O.
func main() { var r, c int
scanner := bufio.NewScanner(os.Stdin) fmt.Print("Dammi il numero di righe: ") if scanner.Scan() {
r, _ = strconv.Atoi(scanner.Text()) }
fmt.Print("Dammi il numero di colonne: ") if scanner.Scan() {
c, _ = strconv.Atoi(scanner.Text()) }
matrice := make([][]rune, r) for i := 0; i<r; i++ {
fmt.Println("Dammi la riga ", i) matrice[i] = make([]rune, c) if scanner.Scan() {
matrice[i] = []rune(scanner.Text()) } }
result := trasponi(SchiacciaTutte(trasponi(matrice))) var output string
for _, line := range result { for _, x := range line {
output += string(x) + " "
}
output += "\n"
}
fmt.Print(output) }
Le parti di I/O possono essere più macchinose da verificare in modo sistematico, so- prattutto nel caso si abbia a che fare con interfacce grafiche, ma ne caso dello standard input e standard output ci si può aiutare con la redirezione dei flussi standard di dati che il sistema operativo associa a ogni processo cui è collegato un “terminale”.
Creiamo un file input.txt:
3 2Z Z
* *A A
che possiamo usare come input: ./schiacciatine < input.txt. Attenzione che
l’output conterrà anche i messaggi stampati per richiedere l’input: ecco perché a vol-
te può essere utile distinguere diversi stream di output, come nel caso di standard output
e standard error.
Provare il programma per aumentare la fiducia nella sua correttezza è molto impor- tante, ma non bisogna mai dimenticare che un test può dimostrare che un programma non funziona secondo le aspettative, ma mai la sua generale aderenza alle specifiche del problema algoritmico che intende risolvere, né, tantomeno, ai requisiti di chi vuole ser- virsi del programma per farne qualcosa di utile. In altre parole, per accorgersi che un programma che dovrebbe calcolare la radice quadrata della somma dei quadrati di due numeri positivi è sbagliato, basta scoprire che per 3 e 4 non restituisce 5, ma qualora il risultato fosse 5, nulla possiamo dire della correttezza per tutte le coppie di nume- ri: anche limitandosi ai soli interi a 32 bit, ciò richiederebbe un numero (2
64≈ 10
19) del tutto impraticabile di test. Considerazioni ancora diverse servono poi per capire se effettivamente è il ‘Teorema di Pitagora’ che è utile a chi userà il programma. Il mot- to classico dell’ingegneria del software è «Fa’ la cosa giusta, falla giusta!», distico cui corrispondono rispettivamente le attività di convalida (che necessitano il coinvolgimento dell’utente finale), e di verifica (che possono essere svolte dallo sviluppatore, spesso in modo parzialmente automatico).
0.2 Programmazione ‘in grande’
Sapersi concentrare sul singolo problema algoritmico per risolverlo correttamente è fon- damentale ed è senz’altro l’abilità primaria che ogni buon informatico deve sviluppare.
Ma questa prospettiva porta con sé un rischio che potremmo chiamare “la tirannia della matematica platonica”, il rischio cioè di scambiare un dato programma corretto come la soluzione “vera” ed eterna di un problema (ideale). Un po’ come un teorema
3che rimane eternamente dimostrato in un determinato contesto assiomatico. Il software non è però solo un ente di natura matematica, ma ha anche caratteristiche dei manufatti dell’inge- gneria (“macchine”) perché deve servire a qualche utilità pratica, come rimarcato anche nel paragrafo precedente con la distinzione fra convalida e verifica. La conseguenza di ciò è che il software si deteriora nel tempo, ha bisogno di continua manutenzione che ne assicuri il costante allineamento con i bisogni reali per cui è stato costruito. Ecco quindi l’importanza di avere programmi che non solo siano corretti, ma siano anche adattabili a nuove (o semplicemente non esplicite a priori) esigenze.
Questa è la prospettiva della cosiddetta programmazione ‘in grande’. In grande, perché adottando questo angolo visuale si ragiona per lo più su come strutturare un programma (che a questo punto verrà considerato un “sistema”) in pezzi (“componenti”) con precise responsabilità rispetto alla funzionalità complessiva. In grande, perché occorrerà tener conto della “vitalità” e “mobilità” dei componenti, che potrebbero essere utili in sistemi e contesti differenti. In altre parole, se il punto centrale della programmazione ‘in piccolo’
è la correttezza (e in misura minore l’efficienza) di un programma, nella programmazione
‘in grande’ l’obiettivo è la sua “capacità d’evoluzione”: entrambe le prospettive sono necessarie per produrre software di qualità.
3La corrispondenza fra programmi e teoremi è molto più profonda di una semplice metafora retorica, ma, con le ipotesi opportune, un vero e proprio isomorfismo, detto di “Curry-Howard-(Lambek)”.
0.3 Programmazione orientata agli oggetti È forse il caso di aggiungere che le prospettive della programmazione ‘in piccolo’ e ‘in grande’ non sono di per sé sufficienti ad assicurare la capacità di costruire programmi atti a essere prodotti e scambiati per mutuo profitto di committenti e sviluppatori: per questo servono orizzonti ancora più ampi, che i corsi successivi nell’ambito dell’ingegneria del software si prefiggono di esplorare.
0.3 Programmazione orientata agli oggetti
Una delle tecniche maggiormente consolidate di programmazione in grande è la cosiddet- ta “programmazione orientata agli oggetti”, Object-Oriented Programming (OOP). Le origini della OOP risalgono al linguaggio Simula 67, nato nel gli anni ’60 del Novecento per descrivere simulazioni di sistemi fisici. Negli anni ’80 e ’90 sono stati sviluppati molti nuovi linguaggi orientati agli oggetti, il più influente dei quali è probabilmente Small- talk, ma vanno citati anche il C++, Eiffel e naturalmente Java, che è il linguaggio di riferimento per questo corso.
Alan Kay (uno dei progettisti di Smalltalk, premio Turing nel 2003) consiglia di av- vicinarsi alla OOP avendo in testa una metafora biologica: un sistema dovrebbe essere progettato come un organismo composto da cellule reciprocamente impenetrabili che comunicano solo tramite messaggi biochimici che ne alterano lo stato. Questa “impene- trabilità” dei componenti (che prende il nome tecnico di incapsulamento) richiede parec- chio sforzo progettuale, perché occorre chiarire bene le responsabilità del componente e quali parti sono più suscettibili di cambiamento nel suo “orizzonte evolutivo”: saranno proprio queste che dovranno essere “nascoste” (information hiding) cioè schermate dalla
“membrana cellulare”, in modo che l’interazione con le altre cellule possa esserne indi- pendente (dipenderà solo dai messaggi possibili). Ogni cellula/oggetto è un esemplare che ha un’identità e una vita propria nel sistema, ma è parte di una “famiglia cellulare”, la sua classe, che ne definisce i comportamenti generali, ciò il modo con cui reagisce ai messaggi che riceve.
La programmazione ad oggetti, partendo da una serie di oggetti elementari come i numeri interi o le stringhe, procede nel progettare sistemi complessi. Oggetti elementari e complessi verranno poi manipolati con procedure (“metodi”) scatenate dalla ricezione di messaggi. Nella descrizione dei metodi si ricorrerà naturalmente a tutte le tecniche opportune di programmazione ‘in piccolo’, ma un nuovo sforzo di programmazione ‘in grande’ è assolutamente necessario se si vuole ottenere un programma capace di resistere alle degradazioni che la sua evoluzione nel tempo immancabilmente impone.
0.4 Java vs. Go
In questo paragrafo l’obiettivo è mostrare come molte delle conoscenze e abilità acquisite
nella programmazione in Go possono essere trasferite senza troppe difficoltà in ambito
Java: in effetti le principali strutture sintattiche di Go e di Java si assomigliano molto,
quindi le tecniche di programmazione ‘in piccolo’ con cui si ha dimestichezza sono di
facile applicazione, anche grazie all’aiuto fornito dagli strumenti di sviluppo, che spesso sopperiscono alle nostre lacune mnemoniche riguardo a sintassi e librerie.
Java è un linguaggio complesso, con molti costrutti diversi (al contrario di Go il cui progetto è senz’altro più minimalista): il consiglio è di procedere per gradi, cercando di capire bene l’obiettivo che un determinato costrutto linguistico permette di perseguire.
La specifica completa di Java 11
4è disponibile all’indirizzo
https://docs.oracle.com/javase/specs/jls/se11/html/index.html
. Documentazione e tutorial sono reperibili all’indirizzo
https://dev.java/. Per approfondire le caratteristiche di Java si suggerisce anche: Joshua Bloch, “Effective Java”.
0.4.1 Hello world!
// hello.go package main import "fmt"
func main() {
fmt.Println("Hello World!") }
Per eseguirlo l’eseguibile nativo prodotto dalla compilazione:
go build hello.go ./hello
// Hello.java
// (il nome del file deve corrispondere // a quello della classe)
// Il package è facoltativo package it.unimi.di.Hello
public class Hello {
public static void main(String[] args) { System.out.println("Hello World!");
} }
Per eseguirlo serve una macchina virtuale Ja- va che interpreta il bytecode prodotto dal compilatore:
javac Hello.java java Hello.class
Sintatticamente Java adotta un stile OOP, anche quando risulta sostanzialmente inu- tile, come nell’esempio: per la programmazione ‘in piccolo’ questo sembra spesso un appesantimento fastidioso e Java è senz’altro un linguaggio particolarmente verboso; ini- zialmente può non essere facile raccapezzarsi, ma in realtà il modello è piuttosto uniforme quando se ne segue la logica. In Java, ogni procedura (p.es. main) deve appartenere a una classe, lo “stampo” col quale verranno prodotti (“istanziati”) gli oggetti sui quali la procedura agisce: per questo motivo deve sempre trovarsi all’interno di un costrutto
class; potrà accedere a eventuali variabili (nell’esempio non ce ne sono) definite nel- l’ambito della classe stessa e di cui ogni oggetto ha copia. Nell’esempio la procedura main è marcata però come
staticperché in realtà si tratta di una procedura il cui com- portamento è indipendente dallo stato (cioè, in pratica, del valore delle sue variabili) di un oggetto della classe Hello: in realtà è davvero una procedura che il compilatore può staticamente associare alla classe Hello senza necessità di istanziare (dinamicamente) oggetti. Riassumendo: Java obbliga a legare a una classe anche procedure che non lavo- rano sull’insieme di valori (detti “attributi”) che costituiscono lo stato di ciascun oggetto della stessa “famiglia cellulare” come l’abbiamo chiamata nel paragrafo
0.3.4La versione corrente è in realtà la 17, ma la 11 è tuttora la più diffusa.
0.4 Java vs. Go
1 // Hello.java
2 package it.unimi.di.Hello
3
4 public class Hello {
5 private String who; // attributo privato
6
7 public Hello(String name){ // costruttore
8 who = name;
9 }
10
11 public String getGreeting() { // metodo
12 return "Hello " + who + "!";
13 }
14
15 public static void main(String[] args) { // metodo statico
16 System.out.println(new Hello("World").getGreeting()); // creazione di un oggetto Hello
17 }
18 }
In totale nell’esempio appaiono quattro oggetti:
1. la stringa letterale
"Hello World!": nel programma non ha nome, ma è l’oggetto cui si riferisce il primo parametro della println;
2. args, un array di String che in questo caso viene popolato dalla Java Virtual Machine stessa (per convenzione essa esegue la procedura main della classe che gli viene indicata, caricando nel parametro args gli “argomenti” ricevuti dal sistema operativo; args[0] è il primo argomento, non il nome del programma come avviene in Go);
3. System.out a cui viene inviato il messaggio println: si tratta dell’oggetto che rappresenta lo standard output e il messaggio avrà l’effetto di farvi apparire la scritta passata come parametro a println;
4. la classe Hello: anche le classi infatti sono oggetti, in quanto esemplari della classe Class !
Il qualificatore
publicserve a marcare i simboli corrispondenti come “pubblici” cioè visibili anche all’esterno del
package, analogamente a quanto avviene in Go con l’uso dell’iniziale maiuscola.
Volendo impostare il programma secondo un approccio più aderente all’approccio OOP di Java, potremmo riscriverlo così:
In questo caso ogni oggetto Hello conserva una stringa (linea
5): la procedura senzaindicazione del tipo ritornato e con lo stesso nome della classe (linea
7) serve per istanziaree allocare così la memoria necessaria per un oggetto di tipo Hello: è cioè un costruttore degli oggetti della classe. Per creare un oggetto occorre dire (in questo caso usando il parametro name richiesto da Hello(String)) quale stringa inizialmente costituisce lo stato dell’oggetto: è l’operazione scatenata dalla
newdella linea
16. Oggetti diversipotranno, naturalmente, conservare stringhe differenti e il risultato della procedura g
⌋etGreeting (che chiameremo più propriamente metodo) è potenzialmente diverso per
ogni oggetto. Si noti che la procedure main rimane
staticperché viene chiamata dalla Java Virtual Machine senza bisogno di creare un oggetto Hello, ma in questo caso occorre invece creare un oggetto Hello (senza nome) per ottenere (chiamandone il metodo getGreeting) la stringa da passare come parametro alla println.
0.4.2 Schiacciatine in Java Traduzione letterale
Una traduzione “letterale” della versione in Go è la seguente:
package schiacciatine;
import java.util.Scanner;
public class Schiacciatine {
public static char[] schiaccia(char[] s) { char[] result = new char[s.length];
int i = 0;
for (char c : s) { if (c == '*') {
result[i] = c;
i++;
} }
for (char c : s) {
if (Character.isLetter(c)) { result[i] = c;
} i++;
}return result;
}
public static char[][] schiacciaTutte(char[][] s) { char[][] result = new char[s.length][s[0].length];
int i = 0;
for (char[] line : s) { result[i] = schiaccia(line);
} i++;
return result;
}
public static char[][] trasponi(char[][] s) { char[][] result = new char[s[0].length][s.length];
for (int i = 0; i < result.length; i++) { for (int j = 0; j < result[i].length; j++) {
result[i][j] = s[j][i];
} }
return result;
0.4 Java vs. Go
}
public static void main(String[] args) { Scanner scanner = new Scanner(System.in);
int r, c;
System.out.print("Dammi il numero di righe: ");
r = scanner.nextInt();
System.out.print("Dammi il numero di colonne: ");
c = scanner.nextInt();
char[][] matrice = new char[r][c];
for (int i = 0; i < r; i++) {
System.out.println("Dammi la riga " + i);
for (int j = 0; j < c; j++) {
matrice[i][j] = scanner.next().charAt(0);
} }
char[][] result = trasponi(schiacciaTutte(trasponi(matrice)));
for (int i = 0; i < result.length; i++) { for (int j = 0; j < result[0].length; j++) {
System.out.print(result[i][j] + " ");
}
System.out.println();
} } }
Traduzione Object-oriented
Invece, con uno stile molto più orientato agli oggetti:
package schiacciatine;
import java.util.Scanner;
public class SchiacciatineOO { private char[][] matrice;
public SchiacciatineOO(char[][] matrice) { this.matrice = matrice;
}
public static void main(String[] args) { Scanner scanner = new Scanner(System.in);
int r, c;
System.out.print("Dammi il numero di righe: ");
r = scanner.nextInt();
System.out.print("Dammi il numero di colonne: ");
c = scanner.nextInt();
char[][] matriceTarget = new char[r][c];
for (int i = 0; i < r; i++) {
System.out.print("Dammi la riga " + i);
for (int j = 0; j < c; j++) {
matriceTarget[i][j] = scanner.next().charAt(0);
} }
SchiacciatineOO obj = new SchiacciatineOO(matriceTarget);
obj.trasponi();
obj.schiacciaTutte();
obj.trasponi();
System.out.println("\n" + obj);
}
private char[] schiaccia(int line) {
char[] result = new char[matrice[line].length];
int i = 0;
for (char c : matrice[line]) { if (c == '*') {
result[i] = c;
i++;
} }
for (char c : matrice[line]) { if (Character.isLetter(c)) {
result[i] = c;
} i++;
}return result;
}
public void schiacciaTutte() {
char[][] result = new char[matrice.length][matrice[0].length];
for (int i = 0; i < matrice.length; i++) { result[i] = schiaccia(i);
}
matrice = result;
}
public void trasponi() {
char[][] result = new char[matrice[0].length][matrice.length];
0.4 Java vs. Go
for (int i = 0; i < result.length; i++) { for (int j = 0; j < result[i].length; j++) {
result[i][j] = matrice[j][i];
}
}matrice = result;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < matrice.length; i++) { for (int j = 0; j < matrice[i].length; j++) {
sb.append(matrice[i][j]);
}
sb.append('\n');
}return sb.toString();
} }
Questa versione permette già di fare alcune osservazioni di programmazione ‘in grande’.
Consideriamo il “componente” SchiacciatineOO (in programmazione ‘in grande’ siamo interessati ai “pezzi” che compongono il nostro sistema e a come interagiscono, cosa nascondono e cosa espongono): la sua responsabilità è quella di “schiacciare” una matrice di caratteri; si noti che tale matrice è un altro componente, esterno agli oggetti Schiac
⌋ciatineOO
5, infatti il main deve creare (e riempire) una matrice (matriceTarget) prima di passarla a un oggetto SchiacciatineOO. La matrice, quindi, non è affatto incapsulata nell’oggetto SchiacciatineOO, nonostante sia un attributo
privatedi Schiacciatine
⌋OO : con
privatesi regola la visibilità dell’identificatore (matrice) con cui ci si riferisce alla matrice, ma non necessariamente la sua accessibilità nell’ambito del sistema. Di fatto un programmatore disattento potrebbe scrivere (nel main):
SchiacciatineOO obj = new SchiacciatineOO(matriceTarget);
obj.trasponi();
obj.schiacciaTutte();
matriceTarget[0][0] = '?';
obj.trasponi();
Una simile operazione violerebbe i principi progettuali del componente, ma risulta del tutto lecita dal punto di vista sintattico. Vale la pena osservare che la classe Hello vista nell’esempio
0.4.1, che pure è simile, non ha questo problema perché gli oggetti stringasono immutabili.
5A rigore i componenti del sistema sono sempre oggetti; quando ne parliamo, però, generalmente facciamo riferimento alle loro caratteristiche comuni, cioè alla classe di cui sono esemplari. Del resto per parlare di un esercito, diremmo probabilmente che i soldati fanno questo e quest’altro, anche se naturalmente le azioni sono effettivamente svolte da Caio e Sempronio (soldati arruolati nell’esercito in questione).
Per incapsulare la matrice negli oggetti SchiacciatineOO una possibile soluzione è spostarne la creazione nel costruttore (un’altra possibilità, piuttosto inefficiente in questo caso, potrebbe essere quella di farne una copia).
public classSchiacciatineOO{ privatechar[][]matrice;
publicSchiacciatineOO() {
Scanner scanner=newScanner(System.in);
intr,c;
System.out.print("Dammi il numero di righe: ");
r=scanner.nextInt();
System.out.print("Dammi il numero di colonne: ");
c=scanner.nextInt();
matrice=newchar[r][c];
for(inti= 0;i<r;i++) {
System.out.print("Dammi la riga "+i);
for(intj= 0;j<c;j++) {
matrice[i][j] =scanner.next().charAt(0);
} } }
public staticvoidmain(String[]args) {
SchiacciatineOO obj=newSchiacciatineOO();
obj.trasponi();
obj.schiacciaTutte();
obj.trasponi();
System.out.println("\n"+obj);
}
// Questi metodi non cambiano:
// private char[] schiaccia(int line) // public void schiacciaTutte() // public void trasponi() // public String toString() }
// Versione precedente public classSchiacciatineOO{
private char[][]matrice;
publicSchiacciatineOO(char[][]matrice) { this.matrice=matrice;
}
public staticvoidmain(String[]args) { Scanner scanner=newScanner(System.in);
intr,c;
System.out.print("Dammi il numero di righe: ");
r=scanner.nextInt();
System.out.print("Dammi il numero di colonne: ");
c=scanner.nextInt();
char[][]matriceTarget=newchar[r][c];
for(inti= 0;i<r;i++) {
System.out.print("Dammi la riga "+i);
for(intj= 0;j<c;j++) {
matriceTarget[i][j] =scanner.next().charAt(0);
} }
SchiacciatineOO obj=new SchiacciatineOO(matriceTarget);
⤶
⤷
obj.trasponi();
obj.schiacciaTutte();
obj.trasponi();
System.out.println("\n"+obj);
}
// ecc.
}
Sempre ragionando sulle responsabilità della classe SchiacciatineOO, cioè sul perché l’abbiamo progettata viene da chiedersi perché esponga un metodo trasponi: si tratta infatti di un servizio interno, utile all’implementazione (come schiaccia), ma non im- mediatamente comprensibile come necessario allo scopo di SchiacciatineOO. In effetti, dovendo spiegare o documentare il componente SchiacciatineOO, dovremmo probabil- mente chiarire a un potenziale utilizzatore che dovrà ricordarsi di chiamare (due volte!) trasponi.
Per evitare anche questo problema possiamo spostare le trasposizioni alla fine del co- struttore e alla fine di schiacciaTutte, e, conseguentemente rendere
privateil metodo trasponi .
public SchiacciatineOO() {
Scanner scanner = new Scanner(System.in);
int r, c;
System.out.print("Dammi il numero di righe: ");
r = scanner.nextInt();
System.out.print("Dammi il numero di colonne: ");
c = scanner.nextInt();
matrice = new char[r][c];
0.4 Java vs. Go
for (int i = 0; i < r; i++) {
System.out.print("Dammi la riga " + i);
for (int j = 0; j < c; j++) {
matrice[i][j] = scanner.next().charAt(0);
} }
trasponi();
}
public static void main(String[] args) { SchiacciatineOO obj = new SchiacciatineOO();
obj.schiacciaTutte();
System.out.println("\n" + obj);
}
public void schiacciaTutte() {
char[][] result = new char[matrice.length][matrice[0].length];
for (int i = 0; i < matrice.length; i++) { result[i] = schiaccia(i);
}
matrice = result;
trasponi();
}
private void trasponi() { // come prima
}
// Questi metodi non cambiano:
// private char[] schiaccia(int line) // public String toString()
}
A questo punto, però, salta all’occhio l’inefficienza della doppia trasposizione! Il nostro componente conserva la matrice in forma trasposta rispetto a ciò che provie- ne dall’input e ri-traspone nella sua operazione principale: possiamo evitare almeno la prima trasposizione costruendo la matrice già in forma trasposta.
public SchiacciatineOO() {
Scanner scanner = new Scanner(System.in);
int r, c;
System.out.print("Dammi il numero di righe: ");
r = scanner.nextInt();
System.out.print("Dammi il numero di colonne: ");
c = scanner.nextInt();
matrice = new char[c][r]; // trasposta!
for (int i = 0; i < r; i++) {
System.out.print("Dammi la riga " + i);
for (int j = 0; j < c; j++) {
matrice[j][i] = scanner.next().charAt(0);
} } }
// Questi metodi non cambiano:
// public static void main(String[] args) // public void schiacciaTutte()
// private char[] schiaccia(int line) // private void trasponi()
// public String toString() }
Attenzione però: ora diventa importante non chiamare schiacciaTutte più di una volta, altrimenti la trasposizione introduce un problema. Per non avere questo vincolo, meglio spostare la trasposizione nel metodo che serializza lo stato dell’oggetto come Str
⌋ing (cioè il metodo toString), invece di cambiare lo stato con il metodo trasponi (che a questo punto diventa inutile e si può rimuovere).
public class SchiacciatineOO { private char[][] matrice;
public void schiacciaTutte() {
char[][] result = new char[matrice.length][matrice[0].length];
for (int i = 0; i < matrice.length; i++) { result[i] = schiaccia(i);
}
matrice = result;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
for (int col = 0; col < matrice[0].length; col++) { for (int i = 0; i < matrice.length; i++) {
sb.append(matrice[i][col]);
sb.append(" ");
}
sb.append('\n');
0.4 Java vs. Go
}return sb.toString();
}
// Questi metodi non cambiano:
// public SchiacciatineOO()
// public static void main(String[] args) // private char[] schiaccia(int line) }
C’è un ultimo miglioramento interessante, miglioramento che probabilmente un pro- grammatore ‘in grande’ esperto avrebbe introdotto fin dalle fasi iniziali. Il componente SchiacciatineOO attualmente è difficile da verificare con test di unità non interattivi:
senza tecniche particolari possiamo solo “provarlo” con un file di input (serve qualcosa tipo gradle run < input.txt, si veda l’esempio in
1.2) e vedere se il risultato è quelloatteso.
Possiamo migliorare molto la situazione esponendo la dipendenza con l’input: il fatto che gli oggetti vengano creati prendendo i dati dallo standard input non dovrebbe essere cablato nel costruttore, ma dipendere da un parametro col quale si sceglie la sorgente dei dati. L’esposizione permette di alterare l’oggetto che se ne occupa in fase di test (si noti la simmetria con il caso discusso sopra in cui abbiamo invece perfezionato l’incapsulamento per evitare alterazioni esterne, che qui invece sono desiderabili).
public SchiacciatineOO(InputStream sorgente) { Scanner scanner = new Scanner(sorgente);
int r, c;
System.out.print("Dammi il numero di righe: ");
r = scanner.nextInt();
System.out.print("Dammi il numero di colonne: ");
c = scanner.nextInt();
matrice = new char[c][r];
for (int i = 0; i < r; i++) {
System.out.print("Dammi la riga " + i);
for (int j = 0; j < c; j++) {
matrice[j][i] = scanner.next().charAt(0);
} } }
public static void main(String[] args) {
SchiacciatineOO obj = new SchiacciatineOO(System.in);
obj.schiacciaTutte();
System.out.println("\n" + obj);
}
// Questi metodi non cambiano:
// public void schiacciaTutte() // private char[] schiaccia(int line) // public String toString()
Ora possiamo scrivere anche la classe di test (con la libreria Junit “jupiter”).
package schiacciatine;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
public class SchiacciatineOOTest {
@Test
void testInput(){
String sb = "3\n"
+ "2\n"
+ "Z Z\n"
+ "* *\n"
+ "A A\n";
InputStream in = new ByteArrayInputStream(sb.getBytes(StandardCharsets.UTF_8));
String expected = "Z Z \n* * \nA A \n";
SchiacciatineOO soo = new SchiacciatineOO(in);
assertEquals(expected, soo.toString());
}
@Test
void testSchiacciaTutte() { String sb = "5\n"
+ "4\n"
+ "V * K S\n"
+ "* * * S\n"
+ "S B * *\n"
+ "* * * *\n"
+ "S B S B\n";
InputStream in = new ByteArrayInputStream(sb.getBytes(StandardCharsets.UTF_8));
SchiacciatineOO soo = new SchiacciatineOO(in);
String expected = "* * * * \n* * * * \nV * * S \nS B K S \nS B S B \n";
soo.schiacciaTutte();
assertEquals(expected, soo.toString());
} }
1 Lab01: Esperimenti iniziali
1.1 Campo minato
Il punto di partenza di questo laboratorio è disponibile all’indirizzo:
https://gitlab.com/programmazione2/lab01.
Scritto da Martin P. Robillard e adattato per il corso di “Programmazione II” @ Unimi.
Figura 1.1: Screenshot dell’applicazione Minesweeper
1.1.1 Esercizi
Dopo aver importato il progetto con in IntelliJ IDEA, studiare il codice dell’applicazione e svolgere i seguenti esercizi.
1. Familiarizzare con la documentazione e l’Integrated Development Environment (IDE)
• Reperire la documentazione di base dei tipi Insets e Stage (suggerimento:
Quick Documentation
)
• Reperire la documentazione di String
• Reperire online la documentazione di JavaFX
2. Analizzare gli oggetti in gioco
• Quanti oggetti Cell vengono istanziati?
• Quanti oggetti Position vengono istanziati? Come si può fare per contarli esattamente?
• Quali Responsabilità e Collaborazioni gestisce Cell?
• Quali Responsabilità e Collaborazioni gestisce Position?
• Quali Responsabilità e Collaborazioni gestisce Minefield?
3. Modificare il programma in modo che:
• Premendo il tasto
rsi ricominci (come già succede con )
• Cliccando con il tasto destro su una cella la si marchi in giallo (oltre al punto esclamativo e alla diminuzione del numero di mine nascoste)
• Schiacciando
ssi scoprano tutte le celle
• Schiacciando
hsi ottenga un suggerimento (cioè la segnalazione di una cella sicuramenta libera, se è possibile determinarne una dalle informazioni disponibili al giocatore)
• Aggiungere alle responsabilità della classe Minefield la gestione dello spazio dei suoi stati possibili (cioè NOT_CLEARED, CLEARED, EXPLODED)
• Associare la rappresentazione della cella ("X",
" ") allo stato della cella stessa(isMined o
!isMined)
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
Gradlesemplificano 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 introduttivodel 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 Actione 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 diString
e vedere elencati tutti i metodi predefiniti, oltre a scoprire una delle caratteristiche più importanti dell’implementazione diString
: 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 epColumns
colonne: i valori utilizzati sono i parametri del costruttore della classeMinefield
. 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 newCell()
), uno per ogni ele- mento dell’array. In totale sono 8 × 20 = 160, visto che in Minesweeper.java viene creato l’unico oggettoMinesweeper
con questi valori di righe e colonne. In realtà sono 160 ogni volta che si crea un oggettoMinesweeper
: premendo si ricomincia il gioco, creando un nuovo oggettoMinesweeper
e perciò 160 nuovi oggettiCell
.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 ogniCell
.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 diPosition
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 oggettiPosition
). 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 Memoryview 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 daMinefield
), 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 privataCellInteractionStatus
.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 oggettoCell
) 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 conCell
ePosition
. 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
eMinefield
).1.1 Campo minato
7. Premendo il tasto r si ricominciL’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 chiamataroot.setOnKeyPressed
: per chiarire i dettagli è necessario consultare la documentazione di JavaFX. Ma il nome del messaggiosetOnKeyPressed
(inviato a un oggetto di classeBorderPane
) 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 dibutton.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 solohandle
, quindi quello anonimo fornirà una riscrittura (overriding) dihandle
, come prima risultava esplicitamente dall’annotazione@Override). Si noti anche chepEvent
è un nome arbitrario, che risulta legato (bound) solo nel contesto dell’espres- sione lambda (tutti gli altri simboli, comenewGame
, 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 dibutton.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 metodorevealAll
che farebbe al caso nostro: sfortunatamente èprivatequindi non è possibi- le accedervi dall’oggettoaMinefield
con cui collaboraMinesweeper
. Rendere public il metodo non sarebbe una buona soluzione: infrangerebbe l’incapsulamento pensato dal progettista diMinefield
, 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. CongetAllPositions
si possono ottenere tutte lePosit
⌋ion
del campo minato e conreveal
si può scoprire la cella in una dataPosition
. 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 metodogetAllPositions
ritorna unIterable<Position>
da cui si può ottenere un
Iterator<Position>
che è in grado di rispondere ai messaggihasNext()
enext()
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