Copia Superficiale vs Copia Profonda in JavaScript: Una Guida Completa per Principianti

Principiante
JavaScript

Scopri la differenza fondamentale tra copia superficiale (shallow copy) e copia profonda (deep copy) in JavaScript. Questa guida ti aiuterà a capire come manipolare correttamente oggetti e array, evitando bug inattesi e gestendo i dati in modo efficiente.

Pubblicato
Tag
programmazione web array javascript frontend shallow copy immutabilità Dati oggetti deep copy

Introduzione alla Copia di Dati in JavaScript: Shallow vs Deep

Nel mondo della programmazione web, e in particolare con JavaScript, lavorare con i dati è un'attività quotidiana. Spesso ci troviamo nella situazione di dover creare una "copia" di un oggetto o un array esistente. Sembra un'operazione semplice, ma in JavaScript, a causa del modo in cui gestisce i tipi di dati primitivi e quelli a riferimento, questa operazione può nascondere delle insidie. Non tutte le copie sono uguali: esistono la copia superficiale (shallow copy) e la copia profonda (deep copy).

Comprendere la differenza tra questi due concetti è assolutamente fondamentale per ogni sviluppatore JavaScript, specialmente per i principianti. Ignorare questa distinzione può portare a bug difficili da individuare, modifiche indesiderate ai dati originali e comportamenti inaspettati nelle tue applicazioni. In questa guida completa, esploreremo in dettaglio cosa significano questi due termini, come implementarli e, soprattutto, quando e perché dovresti scegliere l'uno o l'altro.

Preparati a immergerti nel cuore della manipolazione dei dati in JavaScript, un passo cruciale per scrivere codice robusto e predicibile.

Fondamentali di JavaScript: Tipi di Dati e Riferimenti

Prima di addentrarci nelle copie, è essenziale rinfrescare la memoria su come JavaScript gestisce i tipi di dati. Questo è il punto chiave per capire perché le copie superficiali e profonde sono necessarie.

Tipi Primitivi

In JavaScript, esistono sette tipi di dati primitivi: string, number, bigint, boolean, undefined, symbol e null. Quando lavoriamo con questi tipi, una variabile contiene direttamente il valore del dato. Se assegni una variabile primitiva a un'altra, il valore viene copiato.

Consideriamo questo esempio:

let nomeOriginale = "Mario";
let nomeCopiato = nomeOriginale;

console.log(nomeOriginale); // Mario
console.log(nomeCopiato);   // Mario

nomeCopiato = "Luigi";

console.log(nomeOriginale); // Mario (il valore originale non è cambiato)
console.log(nomeCopiato);   // Luigi

Come puoi vedere, la modifica di nomeCopiato non ha alcun impatto su nomeOriginale. Questo perché "Mario" è un valore primitivo e quando nomeCopiato = nomeOriginale; è stato eseguito, è stata creata una copia indipendente del valore "Mario".

Tipi a Riferimento (Oggetti e Array)

La situazione cambia radicalmente quando si parla di tipi a riferimento. Questi includono Object, Array e Function. In JavaScript, gli oggetti e gli array non vengono memorizzati direttamente nelle variabili. Invece, la variabile contiene un riferimento (un puntatore) alla posizione in memoria dove l'oggetto o l'array è effettivamente memorizzato.

Immagina il riferimento come un indirizzo: più variabili possono puntare allo stesso indirizzo in memoria. Se modifichi l'oggetto attraverso uno di questi riferimenti, la modifica sarà visibile attraverso tutti gli altri riferimenti che puntano allo stesso oggetto.

Ecco un esempio:

let personaOriginale = {
  nome: "Alice",
  eta: 30
};

let personaCopiata = personaOriginale; // Qui non viene copiato l'oggetto, ma il riferimento!

console.log(personaOriginale); // { nome: 'Alice', eta: 30 }
console.log(personaCopiata);   // { nome: 'Alice', eta: 30 }

// Modifichiamo una proprietà attraverso 'personaCopiata'
personaCopiata.eta = 31;

console.log(personaOriginale); // { nome: 'Alice', eta: 31 } (anche l'originale è cambiato!)
console.log(personaCopiata);   // { nome: 'Alice', eta: 31 }

Questo è il motivo principale per cui abbiamo bisogno di meccanismi specifici per la copia di oggetti e array. L'assegnazione diretta (=) copia solo il riferimento, non l'oggetto stesso. Questo è il comportamento di default e, se non compreso, può portare a molti grattacapi.

Cos'è una Copia Superficiale (Shallow Copy)?

Una copia superficiale crea un nuovo oggetto o array, ma le proprietà (o gli elementi, nel caso degli array) di questo nuovo contenitore sono ancora riferimenti agli stessi oggetti o array originali, non copie indipendenti di essi. In altre parole, la copia è "superficiale" perché duplica solo la struttura di primo livello.

Se l'oggetto o l'array originale contiene solo tipi primitivi, una copia superficiale si comporterà come una copia profonda. Tuttavia, se contiene altri oggetti o array (oggetti annidati), le modifiche a questi oggetti annidati nella copia si rifletteranno anche nell'originale, e viceversa.

Vediamo i metodi più comuni per creare una copia superficiale.

Spread Syntax (...)

Lo spread syntax (...) è uno dei modi più moderni e leggibili per creare copie superficiali di array e oggetti.

Per gli Oggetti:

const utenteOriginale = {
  id: 1,
  nome: "Giovanni",
  indirizzo: {
    citta: "Roma",
    cap: "00100"
  }
};

// Copia superficiale dell'oggetto
const utenteCopiato = { ...utenteOriginale };

console.log(utenteOriginale); // { id: 1, nome: 'Giovanni', indirizzo: { citta: 'Roma', cap: '00100' } }
console.log(utenteCopiato);   // { id: 1, nome: 'Giovanni', indirizzo: { citta: 'Roma', cap: '00100' } }

// Modifica una proprietà di primo livello nella copia
utenteCopiato.nome = "Marco";
console.log(utenteOriginale.nome); // Giovanni (l'originale non è cambiato)
console.log(utenteCopiato.nome);   // Marco

// Modifica una proprietà annidata nella copia
utenteCopiato.indirizzo.citta = "Milano";
console.log(utenteOriginale.indirizzo.citta); // Milano (L'originale *è* cambiato!)
console.log(utenteCopiato.indirizzo.citta);   // Milano

Come puoi notare, utenteCopiato.nome è stato modificato senza influenzare utenteOriginale.nome. Ma quando abbiamo modificato utenteCopiato.indirizzo.citta, anche utenteOriginale.indirizzo.citta è cambiato. Questo perché l'oggetto indirizzo non è stato copiato, ma è stato copiato solo il suo riferimento.

Per gli Array:

const numeriOriginali = [1, 2, [3, 4]];
const numeriCopiati = [...numeriOriginali]; // Copia superficiale dell'array

console.log(numeriOriginali); // [ 1, 2, [ 3, 4 ] ]
console.log(numeriCopiati);   // [ 1, 2, [ 3, 4 ] ]

// Modifica un elemento di primo livello nella copia
numeriCopiati[0] = 10;
console.log(numeriOriginali[0]); // 1 (l'originale non è cambiato)
console.log(numeriCopiati[0]);   // 10

// Modifica un elemento annidato (un array) nella copia
numeriCopiati[2][0] = 30;
console.log(numeriOriginali[2][0]); // 30 (L'originale *è* cambiato!)
console.log(numeriCopiati[2][0]);   // 30

Il comportamento è lo stesso degli oggetti: gli elementi primitivi sono copiati per valore, mentre gli elementi a riferimento (come l'array [3, 4]) sono copiati per riferimento.

Object.assign()

Il metodo Object.assign() può essere usato per copiare le proprietà enumerabili e proprie da uno o più oggetti sorgente a un oggetto target. Se usato con un oggetto vuoto come target, crea una copia superficiale.

const prodottoOriginale = {
  nome: "Laptop",
  prezzo: 1200,
  specifiche: {
    cpu: "i7",
    ram: "16GB"
  }
};

const prodottoCopiato = Object.assign({}, prodottoOriginale);

prodottoCopiato.nome = "Desktop"; // Modifica di primo livello
prodottoCopiato.specifiche.ram = "32GB"; // Modifica annidata

console.log(prodottoOriginale.nome);      // Laptop
console.log(prodottoCopiato.nome);       // Desktop

console.log(prodottoOriginale.specifiche.ram); // 32GB (L'originale è cambiato!)
console.log(prodottoCopiato.specifiche.ram);   // 32GB

Il comportamento è identico allo spread syntax per quanto riguarda la copia superficiale.

Array.prototype.slice() e Array.from()

Per gli array, Array.prototype.slice() senza argomenti e Array.from() sono metodi comuni per creare una copia superficiale.

const coloriOriginali = ["rosso", "verde", { codice: "#0000FF" }];

const coloriCopiatiSlice = coloriOriginali.slice();
const coloriCopiatiFrom = Array.from(coloriOriginali);

coloriCopiatiSlice[0] = "arancione";
coloriCopiatiFrom[2].codice = "#FFFF00"; // Modifica l'oggetto annidato

console.log(coloriOriginali[0]);             // rosso
console.log(coloriCopiatiSlice[0]);          // arancione

console.log(coloriOriginali[2].codice);      // #FFFF00 (L'originale è cambiato!)
console.log(coloriCopiatiFrom[2].codice);    // #FFFF00

Anche in questo caso, gli elementi a riferimento all'interno dell'array vengono copiati per riferimento.

Limitazioni della Copia Superficiale

La copia superficiale è veloce e efficiente, ma ha una limitazione fondamentale: non duplica gli oggetti annidati. Se i tuoi dati hanno una struttura complessa con oggetti o array all'interno di altri oggetti o array, una copia superficiale non sarà sufficiente per creare un'istanza completamente indipendente. Le modifiche ai livelli più profondi della struttura si rifletteranno sia nella copia che nell'originale, il che è spesso un comportamento indesiderato.

Cos'è una Copia Profonda (Deep Copy)?

Una copia profonda crea un nuovo oggetto o array e ricorsivamente copia tutti gli oggetti e gli array annidati. Il risultato è un'istanza completamente indipendente dell'originale, dove nessuna modifica nella copia influenzerà l'originale, e viceversa.

Questo è il tipo di copia che desideri quando hai bisogno di manipolare una struttura di dati complessa senza il rischio di alterare i dati originali in nessun modo. Non esiste un metodo nativo singolo e universale in JavaScript per eseguire una copia profonda in modo completo e sicuro per tutti i tipi di dati.

Vediamo i metodi più comuni e le loro sfide.

JSON.parse(JSON.stringify())

Questo è il trucco più comune e spesso il più semplice per ottenere una copia profonda per oggetti e array semplici. Funziona serializzando l'oggetto in una stringa JSON e poi parsificando quella stringa in un nuovo oggetto JavaScript.

const impostazioniOriginali = {
  tema: "scuro",
  notifiche: {
    email: true,
    sms: false
  },
  preferiti: ["pagina1", "pagina2"]
};

// Copia profonda usando JSON.parse(JSON.stringify())
const impostazioniCopiaProfonda = JSON.parse(JSON.stringify(impostazioniOriginali));

console.log(impostazioniOriginali); // { tema: 'scuro', notifiche: { email: true, sms: false }, preferiti: [ 'pagina1', 'pagina2' ] }
console.log(impostazioniCopiaProfonda); // { tema: 'scuro', notifiche: { email: true, sms: false }, preferiti: [ 'pagina1', 'pagina2' ] }

impostazioniCopiaProfonda.notifiche.email = false;
impostazioniCopiaProfonda.preferiti.push("pagina3");

console.log(impostazioniOriginali.notifiche.email); // true (L'originale *non* è cambiato!)
console.log(impostazioniCopiaProfonda.notifiche.email); // false

console.log(impostazioniOriginali.preferiti); // [ 'pagina1', 'pagina2' ] (L'originale *non* è cambiato!)
console.log(impostazioniCopiaProfonda.preferiti); // [ 'pagina1', 'pagina2', 'pagina3' ]

In questo caso, sia le proprietà annidate che gli array annidati sono stati copiati in modo indipendente. Le modifiche nella copia profonda non influiscono sull'originale.

Limitazioni di JSON.parse(JSON.stringify())

Sebbene sia comodo, questo metodo ha delle limitazioni importanti che lo rendono inadatto per tutti gli scenari:

  • Funzioni e undefined: Le funzioni e i valori undefined all'interno dell'oggetto vengono semplicemente ignorati e non inclusi nella copia. Se il tuo oggetto contiene metodi o proprietà undefined, questi andranno persi.
  • Date: Gli oggetti Date vengono convertiti in stringhe ISO 8601 e non vengono ripristinati come oggetti Date validi dopo JSON.parse(). Dovresti riconvertirli manualmente.
  • Regex: Le espressioni regolari (oggetti RegExp) vengono convertite in oggetti vuoti {}.
  • Map, Set, BigInt: Questi tipi di dati non sono supportati nativamente da JSON e non verranno serializzati correttamente.
  • Riferimenti Circolari: Se l'oggetto ha riferimenti circolari (cioè, un oggetto fa riferimento a se stesso o a un suo antenato), JSON.stringify() lancerà un errore (TypeError: Converting circular structure to JSON).

Per questi motivi, JSON.parse(JSON.stringify()) è adatto solo per oggetti e array che contengono dati semplici (stringhe, numeri, booleani, oggetti e array non complessi e senza riferimenti circolari).

Implementare una Copia Profonda Ricorsiva

Per una copia profonda più robusta, specialmente in scenari complessi, è necessario implementare una funzione ricorsiva che attraversi l'intera struttura dell'oggetto o dell'array. Questa è la base di come funzionano le librerie di deep copy.

Ecco un esempio semplificato di una funzione di copia profonda che gestisce oggetti, array e tipi primitivi, ma non i casi limite come Date, RegExp, Map, Set o riferimenti circolari (per i quali servirebbe una gestione più avanzata, ad esempio tenendo traccia degli oggetti già visitati).

function deepCopy(obj) {
  // Gestisce i tipi primitivi e null/undefined
  if (obj === null || typeof obj !== 'object') {
    return obj;
  }

  // Gestisce gli oggetti Date
  if (obj instanceof Date) {
    return new Date(obj.getTime());
  }

  // Gestisce gli array
  if (Array.isArray(obj)) {
    let arrCopy = [];
    for (let i = 0; i < obj.length; i++) {
      arrCopy[i] = deepCopy(obj[i]);
    }
    return arrCopy;
  }

  // Gestisce gli oggetti "plain" (semplici)
  if (typeof obj === 'object') {
    let objCopy = {};
    for (let key in obj) {
      // Assicurati che la proprietà sia propria dell'oggetto e non dell'ereditarietà
      if (Object.prototype.hasOwnProperty.call(obj, key)) {
        objCopy[key] = deepCopy(obj[key]);
      }
    }
    return objCopy;
  }

  // Caso di fallback, se arriviamo qui è un tipo non gestito (es. Function, RegExp, etc.)
  // In un'implementazione più robusta, potremmo voler lanciare un errore o gestirlo diversamente
  return obj;
}

const configurazioneOriginale = {
  id: 123,
  utente: {
    nome: "Luca",
    email: "luca@example.com"
  },
  preferenze: ["notifiche", "temaScuro"],
  dataCreazione: new Date()
};

const configurazioneCopiata = deepCopy(configurazioneOriginale);

configurazioneCopiata.utente.nome = "Giulia";
configurazioneCopiata.preferenze.push("linguaItaliana");
configurazioneCopiata.dataCreazione.setFullYear(2025);

console.log(configurazioneOriginale.utente.nome);       // Luca
console.log(configurazioneCopiata.utente.nome);        // Giulia

console.log(configurazioneOriginale.preferenze);       // [ 'notifiche', 'temaScuro' ]
console.log(configurazioneCopiata.preferenze);         // [ 'notifiche', 'temaScuro', 'linguaItaliana' ]

console.log(configurazioneOriginale.dataCreazione.getFullYear()); // Anno originale (es. 2024)
console.log(configurazioneCopiata.dataCreazione.getFullYear());  // 2025

Questa funzione deepCopy ricorsiva è più flessibile di JSON.parse(JSON.stringify()) perché può gestire oggetti Date e non ha le stesse limitazioni sui tipi di dati complessi. Tuttavia, per gestire tutti i casi limite (funzioni, simboli, Map, Set, riferimenti circolari, ecc.) sarebbe necessaria una logica molto più complessa.

Librerie Esterne per la Copia Profonda

Per la maggior parte delle applicazioni reali, specialmente quando si tratta di oggetti complessi con tipi di dati misti e potenziali riferimenti circolari, la soluzione più pratica e robusta è utilizzare una libreria esterna che offre una funzione di deep copy ben testata e ottimizzata. Le più popolari includono:

  • Lodash: La funzione _.cloneDeep() di Lodash è uno standard de facto per la copia profonda in JavaScript. Gestisce la maggior parte dei casi limite, inclusi i riferimenti circolari, le funzioni, le date, le espressioni regolari, Map, Set e altro ancora.
  • Rambda: Anche Rambda offre una funzione R.clone() con capacità simili.

L'utilizzo di queste librerie garantisce una maggiore affidabilità e ti solleva dalla necessità di scrivere e mantenere una complessa logica di deep copy.

Quando Usare Shallow Copy e Quando Deep Copy?

La scelta tra copia superficiale e copia profonda dipende interamente dal contesto e dalla struttura dei tuoi dati.

Usa una Copia Superficiale (Shallow Copy) quando:

  • Hai oggetti o array che contengono solo tipi primitivi. In questo caso, una copia superficiale è equivalente a una copia profonda e molto più performante.
  • Devi modificare solo le proprietà di primo livello dell'oggetto o dell'array e non ti preoccupi se gli oggetti annidati rimangono condivisi con l'originale.
  • Stai lavorando con strutture di dati immutabili (come in React o Vuex/Pinia), e sai esattamente quali parti dell'oggetto stai modificando. Spesso, per aggiornare lo stato, si crea una shallow copy dell'oggetto principale e poi, se necessario, shallow copy anche degli oggetti annidati che devono essere modificati, per mantenere l'immutabilità.

Usa una Copia Profonda (Deep Copy) quando:

  • Hai oggetti o array complessi con più livelli di annidamento (oggetti dentro oggetti, array dentro oggetti, ecc.).
  • Devi assicurarti che la copia sia completamente indipendente dall'originale, in modo che nessuna modifica nella copia, a qualsiasi livello di annidamento, influenzi l'originale.
  • Stai lavorando con dati di configurazione, impostazioni utente, o qualsiasi altro dato che potrebbe essere modificato pesantemente in una parte dell'applicazione senza influenzare altre parti che potrebbero ancora aver bisogno dei dati originali.
  • Stai implementando una funzionalità di "undo" o "reset", dove hai bisogno di conservare uno stato precedente completamente intatto.

Esempi Pratici e Scenari d'Uso

Vediamo alcuni scenari comuni nello sviluppo web dove la distinzione tra shallow e deep copy è cruciale.

Gestione dello Stato in Applicazioni Web (React/Vue)

Nei framework moderni come React o Vue.js, la gestione dello stato è spesso basata sul principio dell'immutabilità. Non si modificano direttamente gli oggetti di stato, ma si crea una nuova versione dello stato. Questo permette ai framework di rilevare in modo efficiente i cambiamenti e aggiornare l'interfaccia utente.

Immagina uno stato in React:

const [userProfile, setUserProfile] = useState({
  id: 1,
  name: "Anna",
  settings: {
    theme: "light",
    notifications: true
  }
});

// SCENARIO 1: Aggiornare solo una proprietà di primo livello (Shallow Copy sufficiente)
// Vogliamo cambiare il nome dell'utente
const updateUserProfileName = (newName) => {
  setUserProfile({ ...userProfile, name: newName });
};

// SCENARIO 2: Aggiornare una proprietà annidata (Richiede Shallow Copy + Shallow Copy o Deep Copy)
// Vogliamo cambiare il tema delle impostazioni
const updateUserProfileTheme = (newTheme) => {
  setUserProfile({
    ...userProfile,
    settings: { ...userProfile.settings, theme: newTheme } // Shallow copy di settings
  });
};

// SCENARIO 3: Se 'settings' fosse molto più complesso con array annidati, potremmo aver bisogno di una deep copy per 'settings'
// (Ma spesso si preferisce una catena di shallow copy per chiarezza e performance)

Nel secondo scenario, una semplice shallow copy di userProfile non sarebbe bastata se avessimo modificato userProfile.settings.theme direttamente, perché settings sarebbe rimasto un riferimento all'oggetto originale. Abbiamo dovuto fare una shallow copy anche di settings per creare un nuovo oggetto settings che poi è stato assegnato alla shallow copy di userProfile.

Modifica di Configurazioni

Supponiamo di avere un oggetto di configurazione predefinito e di volerlo personalizzare per un utente specifico senza alterare la configurazione globale.

const defaultConfig = {
  lang: "en",
  timezone: "UTC",
  features: {
    dark_mode: true,
    analytics: false
  },
  plugins: ["auth", "logging"]
};

// Un utente vuole personalizzare le impostazioni
// Se usassimo una shallow copy, le modifiche a 'features' o 'plugins' influirebbero su defaultConfig!

// Copia profonda per l'utente specifico
const userConfig = JSON.parse(JSON.stringify(defaultConfig)); // O _.cloneDeep(defaultConfig)

userConfig.lang = "it";
userConfig.features.analytics = true;
userConfig.plugins.push("notifications");

console.log(defaultConfig.lang);           // en
console.log(userConfig.lang);              // it

console.log(defaultConfig.features.analytics); // false
console.log(userConfig.features.analytics);  // true

console.log(defaultConfig.plugins);        // [ 'auth', 'logging' ]
console.log(userConfig.plugins);           // [ 'auth', 'logging', 'notifications' ]

In questo caso, una deep copy è essenziale per garantire che le personalizzazioni dell'utente non "perdano" nella configurazione predefinita.

Operazioni su Dati Immutabili

Quando si lavora con principi di immutabilità (spesso in contesti funzionali o di gestione dello stato), la deep copy o una serie di shallow copy mirate sono fondamentali. L'immutabilità rende il codice più facile da ragionare, testare e debuggare, poiché gli oggetti non cambiano mai inaspettatamente dopo essere stati creati.

Errori Comuni e Trappole

Dimenticare la Natura dei Tipi di Dati

L'errore più comune è trattare tutti i tipi di dati allo stesso modo. Ricorda sempre: i primitivi vengono copiati per valore, gli oggetti e gli array vengono copiati per riferimento. Se non hai chiara questa distinzione, finirai per creare copie superficiali quando avresti bisogno di copie profonde, portando a modifiche inaspettate dei dati originali.

Usare JSON.parse(JSON.stringify()) per Tutto

Mentre è una tecnica utile per oggetti e array semplici, affidarsi ciecamente a JSON.parse(JSON.stringify()) per tutti i casi di deep copy è una trappola. Come abbiamo visto, non gestisce funzioni, undefined, Date, RegExp, Map, Set o riferimenti circolari. Usalo solo quando sei certo che i tuoi dati rientrino nelle sue capacità.

Non Gestire i Riferimenti Circolari

Se scrivi la tua funzione di deep copy ricorsiva, dimenticare di gestire i riferimenti circolari può portare a un loop infinito e a un errore di "Maximum call stack size exceeded". Le librerie di deep copy sono progettate per gestire questo aspetto in modo robusto.

Prossimi Passi e Risorse per Approfondire

Comprendere shallow vs deep copy è un pilastro fondamentale per la manipolazione dei dati in JavaScript. Ecco cosa potresti esplorare in seguito:

  1. Immutabilità in JavaScript: Approfondisci il concetto di immutabilità e come può migliorare la robustezza e la prevedibilità del tuo codice. Esistono librerie come Immer che semplificano la gestione di stati immutabili.
  2. Librerie di Deep Copy: Sperimenta con _.cloneDeep() di Lodash o R.clone() di Rambda per vedere come gestiscono casi complessi che un'implementazione manuale potrebbe non coprire.
  3. Strutture Dati Avvanzate: Esplora come Map e Set si comportano con le copie e come serializzarli/deserializzarli per una deep copy.
  4. Performance: Per oggetti molto grandi, la deep copy può essere costosa in termini di performance. In alcuni scenari, potrebbe essere più efficiente riprogettare la struttura dei dati o utilizzare tecniche di aggiornamento parziale.
  5. Proxy API: Per scenari più avanzati e di meta-programmazione, l'API Proxy di JavaScript offre un controllo granulare sulle operazioni sugli oggetti, inclusa la gestione delle copie.

Continuare a praticare e a sperimentare con diversi tipi di dati e scenari ti aiuterà a solidificare la tua comprensione e a prendere decisioni informate quando scrivi il tuo codice JavaScript. La padronanza di questi concetti ti renderà uno sviluppatore più competente e in grado di affrontare sfide più complesse nella programmazione web.