Introduzione
Una delle applicazioni più utili della programmazione orientata agli oggetti in JavaScript consiste nel creare oggetti che rappresentano collezioni personalizzate. Una collezione può contenere attività, prodotti, messaggi o qualunque altro tipo di dato, ma spesso non è sufficiente memorizzare gli elementi in un semplice array.
Potremmo voler:
- aggiungere metodi specifici del dominio;
- controllare come gli elementi vengono attraversati;
- filtrare o cercare dati senza esporre la struttura interna;
- utilizzare la collezione con il ciclo for...of;
- comprendere dove vengono memorizzati i metodi grazie ai prototipi.
In questo tutorial costruiremo una classe TaskCollection, cioè una collezione di attività. La classe sarà iterabile perché implementerà il protocollo Symbol.iterator. In questo modo potremo usare la nostra collezione come un array durante l´iterazione, mantenendo però metodi e regole personalizzate.
Le classi JavaScript sono una sintassi più leggibile per lavorare con i prototipi. I metodi definiti nella classe non vengono copiati in ogni istanza: vengono collocati nel suo prototype e condivisi tra gli oggetti creati con new.
Codice completo
class Task {
constructor(title, priority = "normal") {
if (!title || title.trim() === "") {
throw new Error("Il titolo dell´attività è obbligatorio");
}
this.title = title;
this.priority = priority;
this.completed = false;
}
complete() {
this.completed = true;
}
toString() {
const status = this.completed ? "completata" : "da completare";
return `[${this.priority}] ${this.title} - ${status}`;
}
}
class TaskCollection {
constructor(name) {
this.name = name;
this.tasks = [];
}
add(task) {
if (!(task instanceof Task)) {
throw new TypeError("È possibile aggiungere soltanto oggetti Task");
}
this.tasks.push(task);
return this;
}
removeCompleted() {
this.tasks = this.tasks.filter(task => !task.completed);
return this;
}
findByPriority(priority) {
return this.tasks.filter(task => task.priority === priority);
}
get size() {
return this.tasks.length;
}
// Rende la collezione utilizzabile con for...of
[Symbol.iterator]() {
let currentIndex = 0;
const tasks = this.tasks;
return {
next() {
if (currentIndex < tasks.length) {
return {
value: tasks[currentIndex++],
done: false
};
}
return {
value: undefined,
done: true
};
}
};
}
// Consente di stampare la collezione in modo leggibile
toString() {
if (this.size === 0) {
return `${this.name}: nessuna attività`;
}
const content = [...this]
.map((task, index) => `${index + 1}. ${task}`)
.join("n");
return `${this.name}:n${content}`;
}
}
// Creazione delle attività
const study = new Task("Studiare i prototipi", "alta");
const email = new Task("Rispondere alle email", "normale");
const deploy = new Task("Eseguire il deploy", "alta");
// Creazione della collezione
const today = new TaskCollection("Attività di oggi");
today
.add(study)
.add(email)
.add(deploy);
// Completamento di un´attività
email.complete();
// La collezione è iterabile
for (const task of today) {
console.log(task.title);
}
// Ricerca per priorità
const urgentTasks = today.findByPriority("alta");
console.log(urgentTasks);
// Rimozione delle attività completate
today.removeCompleted();
console.log(today.toString());
console.log("Numero di attività:", today.size);
// I metodi sono condivisi tramite il prototype
console.log(
Object.hasOwn(today, "add")
); // false
console.log(
Object.hasOwn(TaskCollection.prototype, "add")
); // true Spiegazione
La classe Task
Task rappresenta una singola attività. Il costruttore riceve un titolo e una priorità, mentre la proprietà completed indica se l´attività è stata completata.
Il controllo iniziale sul titolo è importante perché impedisce di creare oggetti non validi. La validazione nel costruttore è una buona pratica: ogni istanza di Task nasce già in uno stato coerente.
Il metodo complete() modifica lo stato dell´attività. Il metodo toString(), invece, personalizza la conversione dell´oggetto in testo. Per esempio, quando un´attività viene concatenata a una stringa, JavaScript può utilizzare automaticamente questo metodo.
La classe TaskCollection
La collezione mantiene internamente gli elementi nell´array tasks. Il metodo add() verifica che l´argomento sia effettivamente un´istanza di Task. Questo evita di inserire valori incompatibili.
Il valore restituito da add() è this. Questa scelta permette il method chaining:
today
.add(study)
.add(email)
.add(deploy); Il metodo findByPriority() restituisce un nuovo array contenente solo le attività con la priorità richiesta. Non modifica direttamente la collezione, quindi il suo comportamento è prevedibile e sicuro.
La proprietà size è definita come getter. Anche se viene utilizzata come una normale proprietà, in realtà esegue del codice ogni volta che viene letta:
console.log(today.size); Il protocollo Symbol.iterator
Un oggetto è iterabile quando possiede un metodo associato a Symbol.iterator. Questo metodo deve restituire un iteratore, cioè un oggetto dotato di una funzione next().
Ogni chiamata a next() restituisce un oggetto con due proprietà:
- value: il valore corrente;
- done: indica se l´iterazione è terminata.
Grazie a questa implementazione possiamo scrivere:
for (const task of today) {
console.log(task.title);
} La stessa caratteristica consente di utilizzare lo spread operator:
const copiedTasks = [...today]; In questo caso JavaScript invoca automaticamente today[Symbol.iterator]() e raccoglie tutti i valori prodotti dall´iteratore.
Il ruolo dei prototipi
Quando viene eseguito new TaskCollection("Attività di oggi"), JavaScript crea una nuova istanza con le proprietà proprie name e tasks. I metodi come add(), removeCompleted() e findByPriority() non vengono duplicati nell´istanza: si trovano in TaskCollection.prototype.
Possiamo verificarlo con:
console.log(Object.hasOwn(today, "add")); // false
console.log(Object.hasOwn(TaskCollection.prototype, "add")); // true Questa condivisione riduce il consumo di memoria, soprattutto quando vengono create molte istanze della stessa classe.
Best practice
- Valida i dati nel costruttore: un oggetto non dovrebbe essere creato in uno stato chiaramente invalido.
- Proteggi le regole del dominio: se una collezione deve contenere solo oggetti di un certo tipo, controllalo nel metodo add().
- Non restituire inutilmente la struttura interna: metodi come findByPriority() possono restituire risultati filtrati invece dell´array originale.
- Usa Symbol.iterator quando l´oggetto rappresenta una sequenza: in questo modo la classe si integra con for...of, spread e destrutturazione.
- Mantieni separati i ruoli: Task gestisce una singola attività, mentre TaskCollection gestisce l´insieme delle attività.
- Evita di modificare una collezione durante l´iterazione: rimuovere elementi mentre si usa for...of può produrre risultati inattesi.
- Usa nomi espliciti: metodi come removeCompleted() comunicano chiaramente la loro responsabilità.
Riepilogo
Le classi JavaScript permettono di descrivere oggetti e collezioni in modo ordinato, mentre i prototipi sono il meccanismo reale attraverso cui i metodi vengono condivisi tra le istanze.
Abbiamo creato una classe Task per rappresentare un´attività e una classe TaskCollection per raccoglierne diverse. Abbiamo aggiunto validazione, ricerca, rimozione, method chaining e un getter per la dimensione.
L´aspetto più importante è l´implementazione di Symbol.iterator. Un oggetto personalizzato può diventare compatibile con le funzionalità native di JavaScript semplicemente fornendo un iteratore conforme al protocollo previsto dal linguaggio.
Questo approccio è utile per creare repository, paginatori, collezioni di risultati API, code di messaggi e strutture dati specializzate, mantenendo un´interfaccia semplice e coerente.
