2.5 - Approfondimenti su Dart
1. Costruzione di Classi, Ereditarietà e Mixin
Dart è un linguaggio orientato agli oggetti. Qui vediamo come definire una classe, un costruttore e come implementare l’ereditarietà e i mixin.
Esempio: Classe e Costruttore
class Persona {
//Attributi
final String nome; // Questo attributo è dichiarato immutabile
int eta;
// Esempi di costruttori
// Costruttore shorthand
Persona(this.nome, this.eta);
Persona.neonato(String nome): this(nome,0);
/*
* Questo però richiede che gli attributi siano nullable (Non usato in dart)
Persona(nome,eta){
this.nome = nome;
this.eta = eta;
}
*/
/* Costruttore con valori posizionali e valori nominali
Persona(this.eta,{required this.nome});
*/
//Metodi
void saluta() {
print("Ciao, sono $nome e ho $eta anni.");
}
int getAge() {
return eta;
}
}
void main() {
var p = Persona("Alice", 28);
p.eta = 27;
// p.nome = "Anna"; // Questa riga produce un errore.
p.saluta();
}Per creare un istanza di una classe come avete notato non è necessario utilizzare la keyword new questo semplifica la generazione di layout visto che ogni widget che noi usiamo è un istanza di una classe
Visibilità degli attributi in Dart
In Dart, il controllo dell’accesso a variabili e metodi si basa su una convenzione semplice e precisa: l’uso dell’underscore _.
Pubblico
Per impostazione predefinita, tutti i campi e metodi in Dart sono pubblici. Ciò significa che sono accessibili da qualsiasi punto dell’app, da altri file, classi o package.
Privato con _
In Dart non esiste la parola chiave private.
Per rendere un membro privato, si antepone un underscore _ al suo nome.
Questo rende il membro privato al file, cioè visibile solo dentro lo stesso file Dart.
class Persona {
String _segreto = '1234';
}
void main() {
final p = Persona();
print(p._segreto); // ❌ Errore se questo codice è in un altro file
}Dart è progettato per essere semplice, leggibile e facilmente integrabile con altri ambienti, incluso JavaScript (in quanto può essere compilato in JS).
A differenza di Java o C++, Dart non punta su un sistema complesso di visibilità (private, protected, internal, ecc.)
Questo incoraggia l’organizzazione del codice per file: se vuoi nascondere un dettaglio, mettilo nello stesso file e rendilo _privato.
Getter e Setter in Dart
In Dart, puoi accedere e modificare campi privati in modo controllato usando getter e setter.
Perché usarli?
- Per nascondere l’implementazione interna
- Per controllare/modificare i dati quando vengono letti o scritti
- Per proteggere campi privati (
_variabile) mantenendo l’accesso pubblico
Sintassi di base
class Conto {
double _saldo = 0;
double get saldo => _saldo;
set saldo(double valore) {
if (valore >= 0) {
_saldo = valore;
}
}
}Utilizzo
void main() {
final conto = Conto();
conto.saldo = 100.0; // usa il setter
print(conto.saldo); // usa il getter
}Nota su prestazioni e semplicità
- Getter e setter in Dart non usano parentesi tonde quando li chiami.
- Questo li fa sembrare variabili, ma in realtà sono metodi.
persona.nome = 'Mario'; // chiama il setter
print(persona.nome); // chiama il getter🧠 Quiz:
Rispondi alle seguenti domande per verificare quanto hai capito.
Cosa significa quando un campo in Dart inizia con _?
Il seguente getter in Dart è valido? String get nome => _nome;
Cosa succede se accedi a _password da un file diverso da quello in cui è definito?
Variabili static
In Dart, la parola chiave static serve per dichiarare variabili o metodi che appartengono alla classe, non alle singole istanze.
Cos’è una variabile static?
- Una variabile
staticè condivisa da tutte le istanze della classe. - Non serve creare un oggetto per accedervi.
- Si accede con
NomeClasse.nomeVariabile.
Sintassi base
class Contatore {
static int valore = 0;
}
void main() {
print(Contatore.valore); // ➜ 0
Contatore.valore++;
print(Contatore.valore); // ➜ 1
}Esempio con istanze
class Persona {
static int numeroTotale = 0;
String nome;
Persona(this.nome) {
numeroTotale++;
}
}
void main() {
var p1 = Persona('Luca');
var p2 = Persona('Anna');
print(Persona.numeroTotale); // ➜ 2
}La variabile numeroTotale è incrementata da ogni oggetto, ma è unica per la classe.
Metodi statici
Anche i metodi possono essere statici. Servono per funzioni utilitarie o di calcolo:
class Matematica {
static int quadrato(int x) => x * x;
}
void main() {
print(Matematica.quadrato(4)); // ➜ 16
}Attenzione
- I metodi
staticnon possono accedere a campi non statici della classe.
class Test {
int numero = 5;
static void stampa() {
// print(numero); ❌ Errore!
}
}Dart non ha classi static come in Java o C#.
Dart ha un approccio semplice e flessibile:
- Se una classe non ha stato interno, non serve istanziarla
- Se non vuoi che venga istanziata, nascondi il costruttore
Il costruttore factory
In Dart, il costruttore factory ti permette di personalizzare la creazione di un oggetto.
A differenza del costruttore classico, può:
- Restituire un’istanza già esistente
- Applicare logica condizionale o modificare l’input
- Usare costruttori privati per creare oggetti interni alla classe
Sintassi base
class Persona {
final String nome;
// Costruttore privato
Persona._(this.nome);
// Costruttore factory
factory Persona(String nome) {
return Persona._(nome.toUpperCase());
}
}
void main() {
final p = Persona('mario');
print(p.nome); // ➜ 'MARIO'
}Perché usare factory?
- Per controllare come e quando viene creato l’oggetto
- Per applicare validazioni o trasformazioni
- Per implementare il pattern Singleton
- Per riutilizzare istanze già esistenti
Confronto: factory vs costruttore classico
| Caratteristica | Costruttore classico | factory constructor |
|---|---|---|
| Crea sempre una nuova istanza | ✅ | ❌ può restituire oggetto esistente |
| Può contenere logica condizionale | ❌ | ✅ |
| Può restituire valori diversi | ❌ | ✅ |
| Può accedere a costruttori privati | ❌ | ✅ |
| Utile per Singleton/cache | ❌ | ✅ |
Esempio: Singleton con factory
class Config {
static final Config _istanza = Config._();
Config._();
factory Config() => _istanza;
}
void main() {
var a = Config();
var b = Config();
print(identical(a, b)); // true
}Esempio: validazione in factory
class CodiceFiscale {
final String codice;
factory CodiceFiscale(String codice) {
if (codice.length != 16) {
throw FormatException('Codice non valido');
}
return CodiceFiscale._(codice.toUpperCase());
}
CodiceFiscale._(this.codice);
}Ereditarietà
L’ereditarietà permette a una classe di riutilizzare codice da un’altra classe, estendendola e personalizzandola.
Uso di super nel costruttore
class Persona {
String nome;
int eta;
Persona(this.nome, this.eta);
}
class Studente extends Persona {
String corso;
Studente(String nome, int eta, this.corso) : super(nome, eta); //chiama il costruttore della superclasse `Persona`
}Override dei metodi
Puoi sovrascrivere un metodo della superclasse usando @override.
class Persona {
String nome;
int eta;
Persona(this.nome, this.eta);
void saluta() => print("Ciao, sono $nome e ho $eta anni.");
}
class Studente extends Persona {
String corso;
Studente(String nome, int eta, this.corso) : super(nome, eta);
@override
void saluta() {
print("Ciao, sono $nome, ho $eta anni e studio $corso.");
}
}Polimorfismo
Puoi usare una variabile di tipo Persona per contenere un Studente.
void main() {
Persona p = Studente("Marco", 22, "Informatica");
p.saluta(); // ➜ Chiama comunque il metodo sovrascritto!
}polimorfismo: il comportamento si adatta al tipo reale dell’oggetto.
Casting: is, as, is!
Controllo del tipo con is
if (p is Studente) {
print("È uno studente.");
}Cast esplicito con as
if (p is Studente) {
Studente s = p as Studente;
print("Corso: ${s.corso}");
}Negazione con is!
if (p is! Studente) {
print("Non è uno studente.");
}Esempio completo
class Persona {
String nome;
int eta;
Persona(this.nome, this.eta);
void saluta() => print("Ciao, sono $nome e ho $eta anni.");
}
class Studente extends Persona {
String corso;
Studente(String nome, int eta, this.corso) : super(nome, eta);
@override
void saluta() {
print("Ciao, sono $nome, ho $eta anni e studio $corso.");
}
}
void main() {
Persona p = Studente("Marco", 22, "Informatica");
p.saluta(); // ➜ Chiama metodo di Studente
if (p is Studente) {
Studente s = p as Studente;
print("Corso: ${s.corso}");
}
}implements: usare una classe come interfaccia
In Dart, non esiste la parola chiave interface.
Tuttavia, qualsiasi classe può essere usata come interfaccia grazie alla parola chiave implements.
Quando una classe implements un’altra:
- Non eredita l’implementazione
- Deve riscrivere tutti i metodi e le proprietà
- Agisce come se stesse soddisfacendo un contratto
Esempio pratico
class Stampabile {
void stampa() {
print('Stampo...');
}
}
class Fattura implements Stampabile {
@override
void stampa() {
print('Stampo la fattura');
}
}Differenza tra extends e implements
| Cosa fa? | extends | implements |
|---|---|---|
| Eredita implementazione | ✅ sì | ❌ no, devi riscrivere tutto |
| Richiede override | ❌ no | ✅ sì |
| Serve per interfacce | ❌ no (ma possibile) | ✅ sì |
Implementazione multipla
Puoi implementare più classi/interfacce:
class Salva {
void salva() {}
}
class Invia {
void invia() {}
}
class Report implements Salva, Invia {
@override
void salva() => print('Report salvato');
@override
void invia() => print('Report inviato');
}Le classi abstract
In Dart puoi definire una classe astratta usando la keyword abstract.
Una classe abstract serve come base per altre classi, ma non può essere istanziata direttamente.
A cosa serve una classe astratta?
- A definire metodi da implementare obbligatoriamente
- A fornire funzionalità condivise per più sottoclassi
- A definire un modello comune (contratto parziale)
Sintassi base
abstract class Animale {
void parla(); // metodo astratto
}
void main() {
// var a = Animale(); ❌ Errore: una classe astratta non può essere istanziata
}Può avere anche metodi normali
Una abstract class può anche contenere metodi con corpo:
abstract class Forma {
void disegna(); // metodo astratto
void descrizione() {
print("Sono una forma geometrica.");
}
}Confronto: abstract vs implements
| Caratteristica | abstract class | implements |
|---|---|---|
| Può avere codice | ✅ sì | ❌ no |
Può essere estesa (extends) | ✅ sì | ❌ no |
| Obbligo di override totale | ❌ solo se il metodo è astratto | ✅ sempre |
| Esempio d’uso tipico | Base comune con logica condivisa | Interfaccia pura |
Mixins
Un mixin è un modo per riutilizzare codice tra più classi, senza dover usare l’ereditarietà classica (extends) o le interfacce (implements).
A cosa servono i mixin?
- Per aggiungere comportamenti comuni a più classi
- Per evitare la duplicazione di codice
- Per non usare ereditarietà multipla, che Dart non supporta
Sintassi base
Per creare un mixin:
mixin Volante {
void vola() => print("Sto volando!");
}Per usarlo in una classe:
class Uccello with Volante {}Esempio pratico
mixin Nuotante {
void nuota() => print("Sto nuotando!");
}
mixin Volante {
void vola() => print("Sto volando!");
}
class Anatra with Nuotante, Volante {}
void main() {
var a = Anatra();
a.nuota(); // ➜ Sto nuotando!
a.vola(); // ➜ Sto volando!
}Limitazioni: on
Se vuoi limitare l’uso di un mixin solo a classi che estendono un certo tipo, puoi usare la keyword on.
class Animale {}
mixin Coda on Animale {
void scodinzola() => print("Scodinzolo!");
}
class Cane extends Animale with Coda {} // Ok
class Robot with Coda {} // ❌ Errore: Robot non è un AnimaleMixin con stato
Un mixin può avere variabili, ma deve essere usato con attenzione:
mixin Logger {
void log(String msg) {
final now = DateTime.now();
print("[$now] $msg");
}
}
class Servizio with Logger {
void esegui() {
log("Servizio eseguito");
}
}Questo perché i mixin vengono iniettati nella classe, e ogni classe ne ottiene una copia separata, il che può generare:
- Conflitti di nome tra mixin
- Stato duplicato non intenzionale
- Confusione nella gestione del comportamento
Conflitti di nome tra mixin
mixin A {
String status = 'da A';
}
mixin B {
String status = 'da B'; // ❌ Dart non sa quale usare
}
class C with A, B {} // Errore di ambiguitàUso corretto dei mixin
Lo stato dovrebbe stare nella classe che usa il mixin.
mixin Logger {
void log(String msg) {
print("LOG: $msg");
}
}
class Servizio with Logger {
int operazioni = 0;
void esegui() {
operazioni++;
log("Operazione n° $operazioni");
}
}📌 In questo modo, Logger è riutilizzabile e non introduce stato.
sealed in Dart
La keyword sealed permette di creare classi con gerarchie chiuse.
Significa che solo le classi nello stesso file possono estendere o implementare una classe sealed.
A cosa serve?
- Definire un insieme limitato e controllato di sottotipi
- Forzare la gestione di tutti i casi possibili in
switchoif - Impedire l’estensione da parte di altri file o moduli
Sintassi di base
sealed class StatoConnessione {}
class Connesso extends StatoConnessione {}
class Disconnesso extends StatoConnessione {}
class InCaricamento extends StatoConnessione {}Solo queste classi possono estendere StatoConnessione perché si trovano nello stesso file.
Uso pratico con switch
void gestisci(StatoConnessione stato) {
switch (stato) {
case Connesso():
print("Utente connesso");
case Disconnesso():
print("Utente disconnesso");
case InCaricamento():
print("Caricamento...");
}
}Il compilatore è a conoscenza di tutti i sottotipi possibili e ti avvisa se ne dimentichi uno.
Differenza tra abstract e sealed
| Modificatore | Istanziabile? | Estendibile da altri file? | Finalità |
|---|---|---|---|
abstract | ❌ No | ✅ Sì | Modello base, libero |
sealed | ❌ No | ❌ Solo nello stesso file | Insieme chiuso di sottotipi |
Quando usarlo?
- Quando vuoi creare uno stato o evento chiuso
- Quando vuoi forzare la gestione completa in un
switch - Quando vuoi evitare estensioni non desiderate
Nota sulle classi avanzate in Dart
Dart offre diverse keyword per gestire in modo preciso l’estensione, l’implementazione e l’uso delle classi:
abstract, sealed, base, final, e interface.
Queste parole chiave permettono di:
- Creare classi che non possono essere estese (
final) - Definire contratti da implementare (
interface) - Limitare l’estensione a un solo file (
sealed) - Controllare gerarchie (
base) - Definire modelli astratti (
abstract)
Tuttavia, sarà utile approfondire in futuro ma non sono necessarie queste informazioni adesso.
Future e Async con FutureBuilder
Introduzione alle operazioni asincrone
- In Dart, le operazioni che richiedono tempo (es. richieste HTTP, operazioni di I/O) vengono gestite in modo asincrono tramite
Future. - Un
Futurerappresenta un’operazione che verrà completata in un momento futuro, restituendo un valore o un errore. - Un
Futurepuò avere tre stati: incompleto, completato con successo o completato con errore. - L’uso di
asynceawaitsemplifica la gestione delle operazioni asincrone.
Esempio base:
void main() {
print('Inizio operazione');
caricaDati();
print('Operazione avviata');
}
Future<void> caricaDati() async {
await Future.delayed(Duration(seconds: 2));
print('Dati caricati');
}Output:
Inizio operazione
Operazione avviata
Dati caricatiGestione degli errori nei Future
- Dart consente di gestire gli errori nelle operazioni asincrone utilizzando
try-catch. - Se un
Futuretermina con un errore, possiamo catturarlo concatchError.
Esempio:
Future<void> caricaDatiConErrore() async {
try {
await Future.delayed(Duration(seconds: 2));
throw Exception('Errore durante il caricamento');
} catch (e) {
print('Errore: $e');
}
}Esecuzione di più Future
- Possiamo eseguire più
Futurein parallelo utilizzandoFuture.wait().
Esempio:
Future<void> caricaTuttiIDati() async {
List<Future<void>> operazioni = [
Future.delayed(Duration(seconds: 2), () => print('Operazione 1 completata')),
Future.delayed(Duration(seconds: 3), () => print('Operazione 2 completata')),
Future.delayed(Duration(seconds: 1), () => print('Operazione 3 completata')),
];
await Future.wait(operazioni);
print('Tutte le operazioni completate');
}Introduzione a FutureBuilder
FutureBuilderè un widget che costruisce la sua UI in base allo stato di unFuture.- Utile per gestire operazioni asincrone come il caricamento di dati da un’API.
Esempio di FutureBuilder:
import 'package:flutter/material.dart';
import 'dart:async';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('FutureBuilder Example')),
body: Center(
child: FutureBuilder<String>(
future: caricaDati(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return CircularProgressIndicator();
} else if (snapshot.hasError) {
return Text('Errore: ${snapshot.error}');
} else {
return Text('Dati: ${snapshot.data}');
}
},
),
),
),
);
}
}
Future<String> caricaDati() async {
await Future.delayed(Duration(seconds: 3));
return 'Dati caricati con successo!';
}Gestione degli stati in FutureBuilder
Un FutureBuilder gestisce i suoi stati in base al ciclo di vita del Future. I principali stati sono:
ConnectionState.none: IlFuturenon è ancora stato avviato.ConnectionState.waiting: IlFutureè in esecuzione, ma non ha ancora restituito un valore.ConnectionState.active: IlFutureha prodotto un valore parziale (utile perStreamBuilder).ConnectionState.done: IlFutureha completato la sua esecuzione, con successo o con errore.
Esempio di gestione degli stati:
builder: (context, snapshot) {
switch (snapshot.connectionState) {
case ConnectionState.none:
return Text('Nessun Future avviato');
case ConnectionState.waiting:
return CircularProgressIndicator();
case ConnectionState.done:
if (snapshot.hasError) {
return Text('Errore: ${snapshot.error}');
} else {
return Text('Risultato: ${snapshot.data}');
}
default:
return Text('Stato sconosciuto');
}
}Gestione degli errori
La gestione degli errori è essenziale per prevenire crash o comportamenti imprevisti. In un FutureBuilder, possiamo verificare la presenza di errori con snapshot.hasError.
if (snapshot.hasError) {
return Text('Si è verificato un errore: ${snapshot.error}');
}In alternativa, possiamo gestire gli errori direttamente nel Future usando try-catch:
Future<String> caricaDati() async {
try {
await Future.delayed(Duration(seconds: 2));
return 'Dati caricati correttamente';
} catch (e) {
throw Exception('Errore durante il caricamento: $e');
}
}setState() non è necessario in FutureBuilder
FutureBuilder si ricostruisce automaticamente ogni volta che il Future completa la sua esecuzione. Pertanto, non è necessario utilizzare setState() per aggiornare la UI.
Esempio:
FutureBuilder<String>(
future: caricaDati(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return CircularProgressIndicator();
}
return Text(snapshot.data ?? 'Nessun dato disponibile');
},
);Uso di Future.wait() per operazioni multiple
Future.wait() consente di eseguire più operazioni asincrone contemporaneamente e attenderne il completamento.
Esempio:
Future<void> caricaTuttiIDati() async {
List<Future<String>> operazioni = [
Future.delayed(Duration(seconds: 1), () => 'Dati 1'),
Future.delayed(Duration(seconds: 2), () => 'Dati 2'),
Future.delayed(Duration(seconds: 3), () => 'Dati 3'),
];
List<String> risultati = await Future.wait(operazioni);
print('Risultati: $risultati');
}Approfondimento su try-catch
L’uso di try-catch consente di gestire in modo strutturato gli errori nelle operazioni asincrone.
Esempio:
Future<String> operazionePericolosa() async {
try {
await Future.delayed(Duration(seconds: 2));
throw Exception('Errore critico');
} catch (e) {
return 'Gestione errore: $e';
}
}Possiamo anche utilizzare un blocco finally per eseguire operazioni indipendentemente dal successo o fallimento:
Future<void> eseguiOperazione() async {
try {
await operazionePericolosa();
} catch (e) {
print('Errore gestito: $e');
} finally {
print('Operazione conclusa');
}
}Stream e StreamBuilder
Obiettivi della lezione:
Introduzione agli Stream
Uno Stream è una sequenza di dati asincroni che possono essere emessi nel tempo. A differenza di un Future, che emette un singolo valore una sola volta, uno Stream può emettere più valori nel tempo, compreso un errore o un segnale di completamento.
Tipi di Stream:
Single Subscription Stream: Ascoltabile una sola volta.Broadcast Stream: Ascoltabile più volte.
Esempio di Stream base:
import 'dart:async';
void main() {
Stream<int> contatore = Stream.periodic(Duration(seconds: 1), (count) => count).take(5);
contatore.listen((numero) {
print('Numero ricevuto: $numero');
});
}Output:
Numero ricevuto: 0
Numero ricevuto: 1
Numero ricevuto: 2
Numero ricevuto: 3
Numero ricevuto: 4Creare uno Stream personalizzato
Possiamo creare uno Stream personalizzato utilizzando StreamController. Questo consente di avere un maggiore controllo sui dati emessi.
Esempio di StreamController:
import 'dart:async';
void main() {
StreamController<String> controller = StreamController<String>();
controller.stream.listen(
(data) => print('Dato ricevuto: $data'),
onError: (error) => print('Errore: $error'),
onDone: () => print('Stream terminato'),
);
controller.sink.add('Messaggio 1');
controller.sink.add('Messaggio 2');
controller.addError('Errore simulato');
controller.sink.add('Messaggio 3');
controller.close();
}Output:
Dato ricevuto: Messaggio 1
Dato ricevuto: Messaggio 2
Errore: Errore simulato
Dato ricevuto: Messaggio 3
Stream terminatoGestione degli stati in StreamBuilder
StreamBuilder gestisce gli stati in modo simile a FutureBuilder, ma opera con flussi di dati multipli che possono emettere valori più volte.
I principali stati di StreamBuilder sono:
ConnectionState.none: NessunStreamcollegato.ConnectionState.waiting: In attesa del primo valore dalloStream.ConnectionState.active: LoStreamsta emettendo dati.ConnectionState.done: LoStreamha completato la sua esecuzione.
Esempio di gestione degli stati:
builder: (context, snapshot) {
switch (snapshot.connectionState) {
case ConnectionState.none:
return Text('Nessun Stream collegato');
case ConnectionState.waiting:
return CircularProgressIndicator();
case ConnectionState.active:
return Text('Dati ricevuti: ${snapshot.data}');
case ConnectionState.done:
return Text('Stream terminato');
default:
return Text('Stato sconosciuto');
}
}Gestione degli errori negli StreamBuilder
Gli errori in uno StreamBuilder possono essere gestiti verificando snapshot.hasError e utilizzando il blocco onError del Stream.
if (snapshot.hasError) {
return Text('Errore nello stream: ${snapshot.error}');
}setState() non è necessario in StreamBuilder
Analogamente a FutureBuilder, StreamBuilder ricostruisce automaticamente il widget ogni volta che viene emesso un nuovo valore dallo Stream.
Non è necessario utilizzare setState().
Esempio:
Stream<int> contatoreStream = Stream.periodic(
Duration(seconds: 1),
(count) => count,
).take(10);
StreamBuilder<int>(
stream: contatoreStream,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return CircularProgressIndicator();
}
return Text('Contatore: ${snapshot.data}');
},
);Uso di StreamController per operazioni multiple
Possiamo creare uno Stream personalizzato utilizzando StreamController.
Questo è utile per combinare più operazioni o eventi in un unico Stream.
Esempio:
import 'dart:async';
StreamController<String> controller = StreamController<String>();
void main() {
controller.stream.listen(
(data) => print('Dato ricevuto: $data'),
onError: (error) => print('Errore: $error'),
onDone: () => print('Stream terminato'),
);
controller.sink.add('Evento 1');
controller.sink.add('Evento 2');
controller.addError('Errore simulato');
controller.sink.add('Evento 3');
controller.close();
}