Copiere superficială vs. copiere profundă în Go (shallow copy vs. deep copy)

Umbre de frunze pe un perete alb
Foto: Milad Fakurian pe Unsplash
Aceasta este traducerea în română a unui articol publicat inițial în engleză pe Medium: Shadow Copy vs. Deep Copy in Go: What You Need to Know.

În dezvoltarea software, una dintre cele mai frecvente operații este copierea datelor. Dar atenție: nu toate copiile sunt la fel.

În Go (Golang) există două tipuri principale de copiere: copierea superficială (shallow copy, numită uneori și shadow copy) și copierea profundă (deep copy). Diferența contează atunci când vrei să lucrezi cu datele în mod sigur și eficient.

Înainte să intrăm în detaliile copierii în Go, trecem pe scurt prin conceptele și tipurile de date pe care le folosim în articol. Dacă știi deja Go, poți sări peste această parte. Textele afișate de programe au rămas în engleză, ca în original.

I. Concepte și tipuri de date în Go

Struct-uri

Struct-urile grupează mai multe valori într-o singură entitate. Pot conține variabile de orice tip și sunt esențiale pentru modelarea obiectelor din lumea reală în cod.

type Person struct {
    Name string
    Age  int
}

Slice-uri

În Go, un slice este ca o fereastră flexibilă prin care vezi o parte dintr-un array. Array-urile au o dimensiune fixă, care nu se mai poate schimba după creare, în timp ce slice-urile sunt dinamice: poți adăuga sau elimina elemente. Important de reținut: un slice are întotdeauna în spate un array.

package main

import "fmt"

func main() {
    // Creăm un slice inițial, originalSlice.
    originalSlice := []int{1, 2, 3, 4, 5}
    fmt.Println("Original Slice:", originalSlice)

    // Creăm un slice nou din originalSlice.
    // newSlice va folosi același array ca originalSlice.
    newSlice := originalSlice[1:4]
    fmt.Println("New Slice:", newSlice)

    // Modificăm un element din newSlice.
    // Pentru că newSlice împarte același array cu originalSlice,
    // modificarea le afectează pe amândouă.
    newSlice[0] = 99

    // Afișăm ambele slice-uri după modificare.
    fmt.Println("Modified New Slice:", newSlice)
    fmt.Println("Original Slice after modification:", originalSlice)

    // Modificarea lui newSlice a schimbat și originalSlice,
    // pentru că ambele folosesc același array.
}

Pointeri

Pointerii sunt variabile care stochează adresa altei variabile. În Go, pointerii se folosesc pentru a modifica variabila spre care indică sau pentru a evita copierea unor valori mari.

package main

import "fmt"

func main() {
    var x int = 5
    var p *int
    p = &x
    fmt.Println(p)
}

II. Copierea superficială în Go: ce este și cum funcționează

În Go, atribuirea copiază întotdeauna valoarea. La un struct se copiază fiecare câmp în parte. Pentru slice-uri, map-uri și pointeri însă, valoarea copiată este doar descriptorul sau adresa, nu datele în sine: copia și originalul ajung să folosească aceleași date. Asta este o copie superficială.

Prin contrast, un array (de exemplu [3]string) se copiază complet la atribuire, iar string-urile sunt imutabile, deci copierea lor este întotdeauna sigură.

package main

import "fmt"

type Car struct {
    Brand  string
    Model  string
    Owners []string
}

func main() {
    car1 := Car{Brand: "Tesla", Model: "Model X", Owners: []string{"Alice", "Bob"}}
    car2 := car1 // copie superficială

    // schimbăm elementul 0 din Owners pe copie
    car2.Owners[0] = "John"

    // și Owners din car1 s-a schimbat
    fmt.Printf("car1 :%v , Car2: %v", car1, car2)
}

Problema copierii superficiale: modificarea elementelor din car2.Owners se vede și în car1.Owners, pentru că ambele indică spre același array. Asta poate produce erori greu de depistat.

Atenție: doar modificarea elementelor se propagă. Dacă reatribui câmpul (car2.Owners = []string{"John"}), car1 nu este afectat.

Avantajul copierii superficiale: este rapidă și consumă puțină memorie.

III. Copierea profundă în Go: ce este și cum funcționează

O copie profundă creează o instanță nouă a variabilei și copiază toate elementele interne, indiferent cât de „adânc” se află în structura de date.

Poți folosi un pachet existent, dar personal prefer un mic truc: copierea profundă prin json.Marshal și json.Unmarshal.

package main

import (
    "encoding/json"
    "fmt"
)

type Engine struct {
    Capacity int
    Power    int
}

type Car struct {
    Brand  string
    Model  string
    Owners []string
    Engine Engine
}

func deepCopy(src, dst any) error {
    bytes, err := json.Marshal(src)
    if err != nil {
        return err
    }
    return json.Unmarshal(bytes, dst)
}

func main() {
    car1 := Car{
        Brand:  "Tesla",
        Model:  "Model X",
        Owners: []string{"Alice", "Bob"},
        Engine: Engine{Capacity: 100, Power: 300},
    }

    var car2 Car
    // Facem copia profundă
    if err := deepCopy(&car1, &car2); err != nil {
        fmt.Println("copy failed:", err)
        return
    }

    // Modificăm primul element din Owners și motorul în car2
    car2.Owners[0] = "John"
    car2.Engine.Capacity = 200

    fmt.Printf("Car1: %v\nCar2: %v\n", car1, car2)
    // Acum cele două sunt diferite
    fmt.Printf("Car engine: %v\nCar2: %v\n", car1.Engine, car2.Engine)
}

Poți rula codul în Go Playground.

Soluția are însă câteva limitări:

  • Performanță: serializarea și deserializarea consumă timp de procesor și memorie.
  • Tipuri compatibile: funcționează doar pentru struct-uri care pot fi serializate în JSON. Sunt excluse tipuri precum chan, func și unsafe.Pointer.
  • Referințe ciclice: metoda nu funcționează cu struct-uri care au referințe ciclice.
  • Câmpuri pierdute: câmpurile neexportate și orice nu se poate reprezenta în JSON nu sunt copiate.

O soluție mai bună este o implementare manuală pentru tipul tău de date, de exemplu:

package main

import (
    "fmt"
)

type Car struct {
    Brand  string
    Model  string
    Owners []string
}

// deepCopyCar face o copie profundă din src în dest.
func deepCopyCar(src, dest *Car) {
    dest.Brand = src.Brand
    dest.Model = src.Model

    // Creăm un slice nou, cu propriul array
    dest.Owners = make([]string, len(src.Owners))

    // Copiem conținutul slice-ului original în cel nou
    copy(dest.Owners, src.Owners)
}

func main() {
    car1 := &Car{Brand: "Tesla", Model: "Model X", Owners: []string{"Alice", "Bob"}}
    var car2 Car

    // Facem copia profundă manual
    deepCopyCar(car1, &car2)

    // Modificăm primul element din Owners în car2
    car2.Owners[0] = "John"

    fmt.Printf("Car1: %v\nCar2: %v\n", car1, car2)
    // Acum cele două sunt diferite
}

Observație: make creează un slice gol chiar dacă originalul era nil, așa că un Owners nesetat devine [] în JSON în loc de null. Varianta care păstrează nil este în secțiunea „Actualizare 2026” de mai jos.

Problema copierii profunde: poate costa timp de procesor și memorie.

Avantajul copierii profunde: obții o copie complet izolată de datele originale.

Când folosești copierea superficială și când pe cea profundă

  • Copiere superficială: când datele nu trebuie modificate sau când știi că modificările vor afecta toate instanțele.
  • Copiere profundă: când ai nevoie de o copie completă și independentă, care nu este afectată de modificările făcute pe original.

Concluzie

Fiecare tip de copiere are avantaje și dezavantaje, în funcție de cerințele proiectului. Dacă înțelegi ce se copiază efectiv la o atribuire în Go și ce rămâne comun, eviți erori subtile și consumul inutil de memorie, iar aplicațiile tale devin mai robuste.


Actualizare 2026

Secțiune adăugată la traducere; nu există în articolul original din 2023. Actualizată pentru Go 1.27, versiunea curentă la data publicării.

1. append poate rupe legătura dintre slice-uri. Un slice are lungime (len) și capacitate (cap). Cât timp append încape în capacitate, copia și originalul folosesc același array. Când capacitatea e depășită, Go alocă un array nou și legătura dispare. De aici vine cea mai greu de reprodus variantă a bug-ului: uneori modificarea se vede în original, alteori nu.

a := make([]int, 3, 4) // len 3, cap 4
b := a

b = append(b, 4) // încape în capacitate: același array
b[0] = 99
fmt.Println(a[0]) // 99

b = append(b, 5) // depășește capacitatea: array nou
b[0] = 1
fmt.Println(a[0]) // tot 99, a nu mai este afectat

2. Map-urile sunt cea mai frecventă capcană. Un map dintr-un struct copiat este comun ambelor copii. Din Go 1.21 îl copiezi cu maps.Clone.

type Config struct {
    Labels map[string]string
}

c1 := Config{Labels: map[string]string{"env": "prod"}}
c2 := c1
c2.Labels["env"] = "dev"
fmt.Println(c1.Labels["env"]) // dev: c1 a fost modificat

c2.Labels = maps.Clone(c1.Labels) // acum c2 are propriul map

3. Varianta idiomatică: o metodă Clone. În loc de o funcție cu doi pointeri, o metodă cu value receiver returnează copia. slices.Clone (Go 1.21) păstrează nil dacă originalul era nil.

func (c Car) Clone() Car {
    c.Owners = slices.Clone(c.Owners)
    return c
}

car2 := car1.Clone()

Atenție: slices.Clone și maps.Clone copiază elementele prin atribuire. Pentru []string e suficient, dar pentru []*Engine sau [][]int elementele interioare rămân comune și trebuie copiate și ele.

4. Copii defensive la granițele API-ului. O metodă care returnează un câmp de tip slice sau map dă apelantului acces direct la starea internă. Returnează o copie:

func (c *Car) OwnerList() []string {
    return slices.Clone(c.Owners)
}

5. Încă două limite ale trucului cu JSON. Câmpurile de tip interfață își pierd tipul concret, iar doi pointeri care indicau spre același obiect devin, după copiere, două obiecte separate.

6. Copiile superficiale și goroutine-urile. Date comune modificate din mai multe goroutine produc data race-uri. Rulează testele cu go test -race: race detector-ul e făcut exact pentru asta.


La DO IT MAGIC SOFTWARE scriem în Go sisteme care rulează în producție, de la platforme web la soluții AI locale. Dacă ai un proiect Go care are nevoie de o a doua opinie, scrie-ne.