Diferencias entre Copia Superficial y Profunda en Java

Shallow Copy vs Deep Copy en Java: Diferencias y Métodos de Implementación

1. Introducción

Cuando queremos copiar un objeto en Java, debemos considerar dos posibilidades: una copia superficial (shallow copy) y una copia profunda (deep copy). En el enfoque de la copia superficial, solo copiamos los valores de los campos, por lo que la copia puede depender del objeto original. Por otro lado, en el enfoque de la copia profunda, nos aseguramos de que todos los objetos en el árbol sean copiados de forma completa, de manera que la copia no dependa de ningún objeto existente que pueda cambiar en el futuro.

En este tutorial, compararemos estos dos enfoques y aprenderemos cuatro métodos para implementar la copia profunda.

2. Configuración de Maven

Para probar diferentes maneras de realizar una copia profunda, utilizaremos tres dependencias de Maven: Gson, Jackson y Apache Commons Lang. Vamos a añadir estas dependencias a nuestro archivo pom.xml:

<dependency>
    <groupId>com.google.code.gson</groupId>
    <artifactId>gson</artifactId>
    <version>2.10.1</version>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
    <version>3.14.0</version>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.17.2</version>
</dependency>

Las últimas versiones de Gson, Jackson y Apache Commons Lang se pueden encontrar en Maven Central.

3. Modelo

Para comparar diferentes métodos de copia de objetos en Java, necesitaremos dos clases con las que trabajar:

class Address {
    private String street;
    private String city;
    private String country;

    // Constructores estándar, getters y setters
}

class User {
    private String firstName;
    private String lastName;
    private Address address;

    // Constructores estándar, getters y setters
}

4. Copia Superficial

Una copia superficial es aquella en la que solo copiamos los valores de los campos de un objeto a otro.

@Test
public void whenShallowCopying_thenObjectsShouldNotBeSame() {
    Address address = new Address("Downing St 10", "London", "England");
    User pm = new User("Prime", "Minister", address);
    
    User shallowCopy = new User(pm.getFirstName(), pm.getLastName(), pm.getAddress());

    assertThat(shallowCopy).isNotSameAs(pm);
}

En este caso, pm != shallowCopy, lo que significa que son objetos diferentes; sin embargo, el problema es que cuando cambiamos alguna de las propiedades del objeto original address, esto también afectará a la dirección de shallowCopy.

No nos preocuparíamos por esto si Address fuera inmutable, pero no lo es:

@Test
public void whenModifyingOriginalObject_ThenCopyShouldChange() {
    Address address = new Address("Downing St 10", "London", "England");
    User pm = new User("Prime", "Minister", address);
    User shallowCopy = new User(pm.getFirstName(), pm.getLastName(), pm.getAddress());

    address.setCountry("Great Britain");
    assertThat(shallowCopy.getAddress().getCountry()).isEqualTo(pm.getAddress().getCountry());
}

5. Copia Profunda

Una copia profunda es una alternativa que soluciona este problema. Su ventaja es que cada objeto mutable en el grafo de objetos se copia de forma recursiva.

Debido a que la copia no depende de ningún objeto mutable que se haya creado anteriormente, no se modificará accidentalmente como vimos con la copia superficial.

En las secciones siguientes, discutiremos varias implementaciones de copia profunda y demostraremos esta ventaja.

5.1. Constructor de Copia

La primera implementación que examinaremos se basa en constructores de copia:

public Address(Address that) {
    this(that.getStreet(), that.getCity(), that.getCountry());
}

public User(User that) {
    this(that.getFirstName(), that.getLastName(), new Address(that.getAddress()));
}

En la implementación anterior de la copia profunda, no hemos creado nuevas instancias de String en nuestro constructor de copia porque String es una clase inmutable.

@Test
public void whenModifyingOriginalObject_thenCopyShouldNotChange() {
    Address address = new Address("Downing St 10", "London", "England");
    User pm = new User("Prime", "Minister", address);
    User deepCopy = new User(pm);

    address.setCountry("Great Britain");
    assertNotEquals(pm.getAddress().getCountry(), deepCopy.getAddress().getCountry());
}

5.2. Interfaz Cloneable

La segunda implementación se basa en el método clone heredado de Object. Este es protegido, pero necesitamos sobreescribirlo como public. También agregaremos una interfaz marcador, Cloneable, a las clases para indicar que las clases son realmente clonables.

Veamos cómo añadir el método clone() a la clase Address:

@Override
public Object clone() {
    try {
        return (Address) super.clone();
    } catch (CloneNotSupportedException e) {
        return new Address(this.street, this.getCity(), this.getCountry());
    }
}

Ahora, implementemos clone() para la clase User:

@Override
public Object clone() {
    User user = null;
    try {
        user = (User) super.clone();
    } catch (CloneNotSupportedException e) {
        user = new User(this.getFirstName(), this.getLastName(), this.getAddress());
    }
    user.address = (Address) this.address.clone();
    return user;
}

Nota importante: el llamado a super.clone() devuelve una copia superficial del objeto, pero configuramos copias profundas de los campos mutables manualmente, por lo que el resultado es correcto:

@Test
public void whenModifyingOriginalObject_thenCloneCopyShouldNotChange() {
    Address address = new Address("Downing St 10", "London", "England");
    User pm = new User("Prime", "Minister", address);
    User deepCopy = (User) pm.clone();

    address.setCountry("Great Britain");

    assertThat(deepCopy.getAddress().getCountry()).isNotEqualTo(pm.getAddress().getCountry());
}

6. Bibliotecas Externas

Los ejemplos anteriores parecen sencillos, pero a veces no funcionan como solución cuando no podemos agregar un constructor adicional o sobreescribir el método clone. Esto puede suceder si no poseemos el código, o si el grafo de objetos es tan complicado que no terminaríamos nuestro proyecto a tiempo si nos enfocáramos en escribir constructores adicionales o en implementar el método clone en todas las clases del grafo de objetos.

Entonces, ¿qué podemos hacer en ese caso? En este caso, podemos usar una biblioteca externa. Para lograr una copia profunda, podemos serializar un objeto y luego deserializarlo a un nuevo objeto.

Veamos algunos ejemplos.

6.1. Apache Commons Lang

Apache Commons Lang tiene SerializationUtils#clone, que realiza una copia profunda cuando todas las clases en el grafo de objetos implementan la interfaz Serializable.

Nota: Si el método encuentra una clase que no es serializable, fallará y lanzará una SerializationException:

@Test
public void whenModifyingOriginalObject_thenCommonsCloneShouldNotChange() {
    Address address = new Address("Downing St 10", "London", "England");
    User pm = new User("Prime", "Minister", address);
    User deepCopy = (User) SerializationUtils.clone(pm);

    address.setCountry("Great Britain");

    assertThat(deepCopy.getAddress().getCountry()).isNotEqualTo(pm.getAddress().getCountry());
}

6.2. Serialización JSON con Gson

La otra manera de serializar es mediante la serialización JSON. Gson es una biblioteca que se utiliza para convertir objetos en JSON y viceversa. A diferencia de Apache Commons Lang, GSON no necesita la interfaz Serializable para realizar las conversiones. Adicionalmente, los campos transient no son permitidos con Gson.

Veamos un rápido ejemplo:

@Test
public void whenModifyingOriginalObject_thenGsonCloneShouldNotChange() {
    Address address = new Address("Downing St 10", "London", "England");
    User pm = new User("Prime", "Minister", address);
    Gson gson = new Gson();
    User deepCopy = gson.fromJson(gson.toJson(pm), User.class);

    address.setCountry("Great Britain");

    assertThat(deepCopy.getAddress().getCountry()).isNotEqualTo(pm.getAddress().getCountry());
}

6.3. Serialización JSON con Jackson

Jackson es otra biblioteca que soporta la serialización JSON. Esta implementación será muy similar a la que usa Gson, pero necesitamos agregar un constructor por defecto a nuestras clases.

Veamos un ejemplo:

@Test
public void whenModifyingOriginalObject_thenJacksonCopyShouldNotChange() throws IOException {
    Address address = new Address("Downing St 10", "London", "England");
    User pm = new User("Prime", "Minister", address);
    ObjectMapper objectMapper = new ObjectMapper();

    User deepCopy = objectMapper.readValue(objectMapper.writeValueAsString(pm), User.class);

    address.setCountry("Great Britain");

    assertThat(deepCopy.getAddress().getCountry()).isNotEqualTo(pm.getAddress().getCountry());
}

7. Conclusión

¿Qué implementación deberíamos utilizar al realizar una copia profunda? La decisión final dependerá a menudo de las clases que copiaremos y de si poseemos las clases en el grafo de objetos.

La copia profunda es fundamental cuando necesitamos asegurarnos de que las modificaciones en el objeto original no afecten el objeto copiado. Ya sea mediante constructores de copia, el uso de la interfaz Cloneable o bibliotecas externas como Gson y Jackson, entender cómo funciona la copia profunda puede ahorrarle a un programador una gran cantidad de problemas y bugs.

Si trabajas con copias de objetos en Java, recuerda evaluar cada situación por separado para determinar qué enfoque es mejor para tus necesidades.