Una de las cosas que más he notado que dejan en segundo plano las personas que recién comienzan en la programación, es el entendimiento de cómo se almacena la información en la memoria, ya sea en variables, objetos, o cualquier otra estructura de datos. Es por eso que en esta entrada vamos a hablar un poco sobre las referencias de memoria y cómo estas pueden ser muy útiles, pero también un dolor de cabeza si no tenemos bien entendido el concepto.
Tipos datos primitivos y no primitivos
Primero que nada, me gustaría dejar definido qué es un tipo de dato primitivo y uno no primitivo.
Se dice que en programación, un tipo primitivo es aquel tipo de dato más básico y estos están en prácticamente cualquier lenguaje de programación moderno, y no tan moderno (int, float, char, bool, string). En algunos libros y recursos dicen que estos normalmente se reconocen porque no se pueden descomponer en otros tipos de datos. También se dice que se puede definir un tipo primitivo por la forma en la que este se almacena en memoria, ya que la mayoría de los tipos anteriormente mencionados tienen un tamaño fijo de memoria (a excepción de los strings).
Un no primitivo, por el contrario, lo vamos a definir como un tipo de dato compuesto por varios elementos de otro tipo, por ejemplo:
- Arrays
- Sets
- Tuplas
- Diccionarios
- Clases y Objetos
- Strings (caso especial en algunos lenguajes)
¿Que es la referencia y que es el valor?
Bueno, veamos de qué tratan estas dos cosas raras.
Vamos a definir dos enteros cualquiera, num_a
con un valor y otro num_b
con el valor que tenga num_a
.
num_a: int = 5
num_b: int = num_a
Luego a num_b
le sumaremos un número cualquiera y analicemos qué pasó.
num_b += 5
print(f'{num_b=} - Direccion: {id(num_b)}')
# >>> num_b=10 - Direccion: 11234253
print(f'{num_a=} - Direccion: {id(num_a)}')
# >>> num_a=5 - Direccion: 12343665
print(id(num_a)==id(num_b))
# >>> False
Como podemos ver, todo parece funcionar como muchos esperábamos al inicio. A pesar de que a num_b
le asignamos el valor de num_a
, se asignó el valor que tenía num_a
en otra dirección nueva, por lo que a num_b
le podremos hacer todas las modificaciones que queramos sin que estas afecten a num_a
.
Hagamos un experimento similar con los tipos string
.
# Definimos un string
str_a: str = 'Dev'
# Definimos otro string al cual le asignamos el valor de str_a
str_b = str_a
# A str_b le concatenamos cualquier otro string
str_b += 'Wanabi'
# Imprimimos los resultados
print(f'{str_b=} - Direccion: {id(str_b)}')
# >>> str_b=DevWanabi - Dirección: 12343456
print(f'{str_a=} - Direccion: {id(str_a)}')
# >>> str_a=Dev - Dirección: 12343656
print(id(str_b)==id(str_a))
# >>> False
El comportamiento es similar al de los tipos enteros. Estos crean una copia del valor, por decirlo de alguna forma, por lo que las mutaciones que le hagas a uno no pueden afectar al otro. Normalmente, este comportamiento se da en los tipos de dato primitivo.
Ahora veamos cómo se comportan algunos datos no primitivos en acciones similares.
- Con Arrays/List
# Definimos una lista de enteros
list_a: list[int] = [1, 2, 3]
# Definimos otra lista a la que le asignamos el valor de list_a
list_b: list[int] = list_a
# A list_b le agregamos un entero
list_b.append(4)
# Veamos como se comportaron
print(f'{list_b=} - Direccion: {id(list_b)}')
# >>> list_b=[1, 2, 3, 4] - Direccion: 140353392123584
print(f'{list_a=} - Direccion: {id(list_a)}')
# >>> list_a=[1, 2, 3, 4] - Direccion: 140353392123584
print(id(list_b)==id(list_a))
# >>> True
- Con Diccionarios:
# Definimos un diccionario con valores enteros
dict_a: dict[str, int] = {'a': 1, 'b': 2}
# Luego definimos otro al cual le asignamos el valor de dict_a
dict_b: dict[str, int] = dict_a
# A dict_b le agregamos un nuevo par clave-valor
dict_b['c'] = 3
# Vemos como se comportan
print(f'{dict_b=} - Direccion: {hex(id(dict_b))}')
# >>> dict_b={'a': 1, 'b': 2, 'c': 3} - Direccion: 0x7fa6922663c0
print(f'{dict_a=} - Direccion: {hex(id(dict_a))}')
# >>> dict_a={'a': 1, 'b': 2, 'c': 3} - Direccion: 0x7fa6922663c0
print(id(dict_a)==id(dict_b))
# >>> True
- Con Objetos
# Primero creamos una clase
class Persona:
def __init__(self, nombre: str, apellido: str, edad: int):
self.nombre = nombre
self.apellido = apellido
self.edad = edad
def __repr__(self):
return f'Nombre={self.nombre} {self.apellido}, edad={self.edad})'
# Definimos una instancia nueva
persona_a: Persona = Persona('Paquito', 'Dev', 23)
# Luego, porque soy un vago, creo otra persona y le asigno
# el valor de persona_a para despues cambiarle el atributo 'nombre'
persona_b: Persona = persona_a
# A persona_b le cambiamos el nombre
persona_b.nombre = 'Pedrito'
print(f'{persona_b=} - Direccion: {hex(id(persona_b))}')
# >>> persona_b=Nombre=Pedrito Dev, edad=23) - Direccion: 0x7fe2c7183f40
print(f'{persona_a=} - Direccion: {hex(id(persona_a))}')
# >>> persona_a=Nombre=Pedrito Dev, edad=23) - Direccion: 0x7fe2c7183f40
print(id(persona_a)==id(persona_b))
# >>> True
Como puedes observar, estos tipos de datos más complejos, al asignarlos a otras variables, no se copian como algunos podrían esperar. Esto sucede porque para la gestión de memoria y recursos, el lenguaje lo único que hace es asignar la dirección de memoria del dato que queremos copiar, haciendo que no sea precisamente una copia. Solo es una variable más con la cual podemos acceder al mismo conjunto de datos.
Solo como nota, por ahí en tus cursos de python habrás escuchado que en python TODO es un objeto, hasta los datos primitivos. Las variables en python siempre almacenan referencias a objetos, no los valores directamente. Sin embargo, para los tipos inmutables (como los int, float, strings), parece que se copian por valor porque cualquier cambio resulta en un nuevo objeto.
Es aquí donde, si no conocemos estos comportamientos de nuestro lenguaje, pueden ocurrir mutaciones no deseadas de información en nuestro software, afectando al flujo de información de nuestro programa.
Ahora sí, ¿cómo rompemos la referencia para tener copias exactas de otros tipos de datos no primitivos?
Desde luego, hay muchas maneras, en la programación siempre hay muchas maneras, unas más buenas o prácticas que otras. En esta ocasión, te voy a explicar algunas formas usando herramientas y funcionalidades nativas de Python. Es mi deber decirte que puede existir alguna muy similar en otro lenguaje; lo importante es que te quede claro el concepto, ya que este es el mismo en casi todos los lenguajes populares.
Comencemos por lo más sencillo: los arrays, o en este caso, listas. Aquí te propongo cuatro formas: usando slicing, usando el operador de desempaquetado, el constructor list()
, y usando el método nativo copy()
.
- Usando Slicing:
# Declaramos una lista de enteros
list_a: list[int] = [1, 2, 3]
# Forma numero 1: Usando la propiedad nativa de las listas, el slicing
list_b: list[int] = list_a[:]
# Si a la list_b la modifico de alguna forma, ya no se modifica la list_a
list_b.append(4)
print(f'{list_a=} - Direccion: {hex(id(list_a))}')
# >>> list_a=[1, 2, 3] - Direccion: 0x7f444562f8c0
print(f'{list_b=} - Direccion: {hex(id(list_b))}')
# >>> list_b=[1, 2, 3, 4] - Direccion: 0x7f4445542ac0
print(id(list_a) == id(list_b))
# >>> False
- Usando el Operador de Desempaquetado
# Forma numero 2: Usando el operador de desempaquetado
list_a: list[str] = ['a', 'b', 'c']
# Aplicamos el operador de desempaquetado (splat operator *)
list_b: list[str] = [*list_a]
# Si a la list_b la modifico de alguna forma, ya no se modifica la list_a
list_b.append('d')
print(f'{list_a=} - Direccion: {hex(id(list_a))}')
# >>> list_a=['a', 'b', 'c'] - Direccion: 0x7f89fda2d2c0
print(f'{list_b=} - Direccion: {hex(id(list_b))}')
# >>> list_b=['a', 'b', 'c', 'd'] - Direccion: 0x7f89fd962b80
print(id(list_a) == id(list_b))
# >>> False
- Usando el Constructor
list()
# Forma numero 3: Usando constructor de listas
list_a: list[float] = [1.1, 1.2, 1.3]
# Generamos otra instancia nueva de list_a con el constructor de listas
list_b: list[float] = list(list_a)
# Si a la list_b la modifico de alguna forma, ya no se modifica la list_a
list_b.append(1.4)
print(f'{list_a=} - Direccion: {hex(id(list_a))}')
# >>> list_a=[1.1, 1.2, 1.3] - Direccion: 0x7f72d51538c0
print(f'{list_b=} - Direccion: {hex(id(list_b))}')
# >>> list_b=[1.1, 1.2, 1.3, 1.4] - Direccion: 0x7f72d5002cc0
print(id(list_a) == id(list_b))
# >>> False
- Usando el Método
copy()
# Forma numero 4: Usando el metodo copy
list_a: list[str] = ['Dev', 'Wanabi']
# Generamos una copia de list_a con el metodo copy
list_b: list[str] = list_a.copy()
# Si a la list_b la modifico de alguna forma, ya no se modifica la list_a
list_b.append('Python')
print(f'{list_a=} - Direccion: {hex(id(list_a))}')
# >>> list_a=['Dev', 'Wanabi'] - Direccion: 0x7fcdf499b8c0
print(f'{list_b=} - Direccion: {hex(id(list_b))}')
# >>> list_b=['Dev', 'Wanabi', 'Python'] - Direccion: 0x7fcdf48aeac0
print(id(list_a) == id(list_b))
# >>> False
Ahora veremos qué procede con otra estructura de datos muy común: los diccionarios. Vamos un poco más rápido.
# Declaramos un diccionario
dict_a: dict[str, int] = {'a': 1, 'b': 2}
# Forma numero 1: Usando el operador de desempaquetado
dict_copy_1: dict[str, int] = {**dict_a}
print(id(dict_a) == id(dict_copy_1))
# >>> False
# Forma numero 2: Usando el metodo copy
dict_copy_2: dict[str, int] = dict_a.copy()
print(id(dict_a) == id(dict_copy_2))
# >>> False
# Forma numero 3: Usando el constructor de diccionarios
dict_copy_3: dict[str, int] = dict(dict_a)
print(id(dict_a) == id(dict_copy_3))
# >>> False
Creo que ya vamos entendiendo cómo va el asunto. Solo que hay un pequeño detalle: los ejemplos anteriores sirven para hacer copias superficiales, es decir, que no romperían la referencia completa en dado caso de que, por ejemplo, tengamos una lista dentro de otra lista, o listas en los valores de un diccionario, u otros objetos dentro de una lista. Pueden haber muchos más casos complejos que se dan cuando estamos programando en serio y no escribiendo entradas tontas para un blog que solo ve tu abuela. Veamos un ejemplo de la problemática que mencioné.
# Tenemos una lista que tiene varias cosas
list_con_cosas: list[any] = [1, 'a', {'a': 1, 'b': 2}, [1, 2, 3]]
# Intentamos hacer una copia de la lista
list_con_cosas_copy: list[any] = list_con_cosas.copy()
# Si modificamos algun elemento no primitivo de la original veamos que pasa
list_con_cosas[2]['c'] = 3
print(f'{list_con_cosas=}')
# >>> list_con_cosas=[1, 'a', {'a': 1, 'b': 2, 'c': 3}, [1, 2, 3]]
print(f'{list_con_cosas_copy=}')
# >>> list_con_cosas_copy=[1, 'a', {'a': 1, 'b': 2, 'c': 3}, [1, 2, 3]]
Como puedes ver, se hizo una copia, sí, pero no una copia de los objetos dentro de la lista original. En otras palabras, se creó una copia, pero en el caso del diccionario y la lista anidada, solo se pasó su referencia de memoria, no se hizo una copia. Por lo que si mutamos alguno de esos elementos desde list_con_cosas
o list_con_cosas_copy
, estos van a verse reflejados en ambas listas que contengan su referencia.
Una copia superficial copia los elementos del contenedor original, pero no los subelementos, mientras que una copia profunda crea nuevas instancias de todos los elementos recursivamente.
Ya para terminar, vamos a ver cómo solucionar esto rápidamente, haciendo copias profundas de algunos objetos más complejos que un simple primitivo usando un módulo nativo en Python llamado copy
.
- Con listas
import copy
# Tenemos nuestra lista con varias cosas
list_con_cosas: list[any] = [1, 'a', {'a': 1, 'b': 2}, [1, 2, 3]]
# Podemos hacer una copia profunda de la lista con el modulo copy
list_con_cosas_copy: list[any] = copy.deepcopy(list_con_cosas)
# Si modificamos algun elemento no primitivo de la original veamos que pasa
list_con_cosas[2]['c'] = 3
print(f'{list_con_cosas=}')
# >>> list_con_cosas=[1, 'a', {'a': 1, 'b': 2, 'c': 3}, [1, 2, 3]]
print(f'{list_con_cosas_copy=}')
# >>> list_con_cosas_copy=[1, 'a', {'a': 1, 'b': 2}, [1, 2, 3]]
- Con diccionarios
# Tenemos nuestro diccionario con varias cosas
dict_con_cosas: dict[str, any] = {'b': 1, 'c': [1, 2, 3], 'd': {'a': 1, 'b': 2}}
# Copia profunda del diccionario con el modulo copy
dict_con_cosas_copy: dict[str, any] = copy.deepcopy(dict_con_cosas)
# Si modificamos algun elemento no primitivo de la original veamos que pasa
dict_con_cosas['d']['c'] = 3
print(f'{dict_con_cosas=}')
# >>> dict_con_cosas={'b': 1, 'c': [1, 2, 3], 'd': {'a': 1, 'b': 2, 'c': 3}}
print(f'{dict_con_cosas_copy=}')
# >>> dict_con_cosas_copy={'b': 1, 'c': [1, 2, 3], 'd': {'a': 1, 'b': 2}}
- Con objetos mas complejos
# Tenemos un objeto de la clase Persona que creamos anteriormente
paquito: Persona = Persona('Paquito', 'Dev', 23)
# Podemos hacer una copia profunda del objeto con el modulo copy
pedrito: Persona = copy.deepcopy(paquito)
# Si modificamos algun atributo del objeto original veamos que pasa
paquito.edad = 24
paquito.nombre = 'Pedrito'
print(f'{paquito=}')
# >>> paquito=Nombre=Pedrito Dev, edad=24)
print(f'{pedrito=}')
# >>> pedrito=Nombre=Paquito Dev, edad=23)
Como puedes observar, el módulo copy
nos puede ayudar a hacer copias profundas hasta de instancias de objetos. Cabe destacar que el método deepcopy()
no es super poderoso y el hecho de que pueda o no crear una copia de otra instancia dependerá de qué tan compleja sea, es decir, cuántos elementos complejos tenga en su interior.
Conclusiones
Bueno, al final de cuentas, el objetivo de esta entrada es hacer que tengas muy presente este comportamiento en los lenguajes de programación y cómo puede o no ayudarte en tus desarrollos. Principalmente cuando estamos empezando, entender la inmutabilidad es un requisito indispensable si queremos explorar más áreas de la programación, como el paradigma funcional o tambien te puede llevar a programas más robustos y menos propensos a errores. Aprender a manejar tus datos es esencial y espero que con este post, por lo menos te sirva como una introducción o recordatorio a este tema que es básico. Obviamente, queda abierta la invitación para que indagues sobre más formas de romper la referencia y también que lo cheques con otras estructuras de datos como los sets o las tuplas.
¡Hasta luego!