Copiar listas en Python, rápido

Listas

Un lenguaje interpretado, como lo es Python, no se distingue precisamente por su velocidad. Y justo por ello nos interesamos en explotar al máximo sus capacidades y crear programas rápidos. Una de las operaciones más comunes que existen es copiar una lista, que todo aquel que ha trabajado con este lenguaje, sabe que tiene sus peculiaridades.

Cuando a = b no basta

Todo en Python es un objeto, y toda variable es en realidad un apuntador a dichos objetos. Así que una asignación típica como a = b en realidad hace que dos apuntadores diferentes señalen al mismo objeto, pero con frecuencia ese no es el comportamiento deseado, ya que alterar un objeto invocando a una variable implica un cambio en la otra (a fin de cuentas es el mismo objeto). Un caso particular de todo este embrollo es la lista.

Una lista en Python es, visto de forma simple, un arreglo de apuntadores. Así que alterar una lista implica modificar el destino de dichos apuntadores, agregar nuevos o retirarlos. Un lista es una variable de variables.

Así que copiar una lista puede significar dos cosas:

  1. Copiar los apuntadores que la constituyen o,
  2. Copiar a dichos apuntadores y a los objetos que apuntan.

En esta ocasión nos centraremos en el primer caso: duplicar los apuntadores que constituyen a una lista, además de indagar por el mecanismo que lo haga más rápido.

Existen tres formas generales de lograr nuestro cometido, listadas a continuación:

Método copy()

Toda lista posee el método copy(), que devuelve una copia de la lista, tal cual se explicó. Es decir:

a = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

# La copia de a en b:

b = a.copy()

Es uno de los mecanismos más rápidos y se ha construido expresamente para dicha labor.

Asignando un corte de la original

Todo corte en Python devuelve una copia de los apuntadores de la lista. Por tanto, un corte que contenga a toda la lista es una copia de la lista entera. Es decir:

a = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

# La copia de a en b:

b = a[:]

Es una sintaxis un tanto críptica para realizar la copia, si no estás acostumbrado al lenguaje.

Asignando la original a un corte en el destino

Si el elemento b es ya una lista, entonces, es posible asignar a toda esa lista el contenido de a usando cortes de la siguiente forma:

a = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
b = []  # b Es ya una lista arbitraria (no tiene que ser vacía)

# La copia de a en b:

b[:] = a

Este mecanismo, además, nos permite deshacernos del contenido previo de b, aunque también hace posible concatenar o insertar el contenido de a en b, según se definan los extremos del corte.

Usando ambos mecanismos

Combinando los últimos dos métodos, es posible crear una nueva sintaxis para la copia, donde la variable b no tiene porque ser una lista con anterioridad. Es la siguiente:

a = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

# La copia de a en b:

b = [][:] = a

¿Qué sucede en este caso? Primero, la variable a es asignada a un corte de una nueva lista vacía que se crea al momento. Posteriormente, esa nueva lista anónima se hace apuntar por b.

Puede parecer extraño y rebuscado. A fin de cuentas hay dos asignaciones y eso no parece que logre un buen resultado, ¿o sí?

Mediciones y resultados

Esta no pretende ser una prueba exhaustiva. Se ha realizado en una sola computadora, utilizando una sola implementación del lenguaje. Otras implementaciones pueden ofrecer resultados diferentes. En este caso se ha utilizado una arquitectura x86_64, bajo Linux con Python 3.5.2.

Se ha empleado el tradicional mecanismo de timeit, para calcular los resultados, haciendo la operación 100,000 veces a un solo ciclo y a cincuenta. ¿Por qué ambos mecanismos? La repetición de un ciclo puede viciar los resultados para el método que asigna la lista original a un corte de la lista destino. Y la diferencia radica en que esa lista destino puede o no tener elementos, según si se repite el ciclo o no. Si se repite, a partir de la segunda iteración la lista contendrá los elementos asignados en la anterior. Y resulta plausible suponer que un corte en una lista con elementos puede ser más costosa que en una lista vacía.

Método1 ciclo50 ciclos
b = a.copy()2.26 usec3.04 usec
b = a[:]2.22 usec3.00 usec
b[:] = a2.02 usec3.14 usec
b = [][:] = a3.20 usec2.69 usec

En esta pequeña prueba, sin ser resultados generales y de alcance global, resulta por demás interesante que el método más rápido para un ciclo es también el más lento para la prueba con cincuenta ciclos y viceversa.

La diferencia entre las pruebas radica en que la lista b ya existe poblada en el momento de la copia (salvo la primera de ellas) para cincuenta ciclos. En el caso de un ciclo ese no es el caso.

Las sentencias aplicadas para la prueba:

$ python3 -m timeit -r 100000 -n 1 -s 'a = list(range(1000));' 'b = a.copy()'
$ python3 -m timeit -r 100000 -n 50 -s 'a = list(range(1000));' 'b = a.copy()'
$ python3 -m timeit -r 100000 -n 1 -s 'a = list(range(1000));' 'b = a[:]'
$ python3 -m timeit -r 100000 -n 50 -s 'a = list(range(1000));' 'b = a[:]'
$ python3 -m timeit -r 100000 -n 1 -s 'a = list(range(1000)); b = []' 'b[:] = a'
$ python3 -m timeit -r 100000 -n 50 -s 'a = list(range(1000)); b = []' 'b[:] = a'
$ python3 -m timeit -r 100000 -n 1 -s 'a = list(range(1000));' 'b = [][:] = a'
$ python3 -m timeit -r 100000 -n 50 -s 'a = list(range(1000));' 'b = [][:] = a'

¿Alguien puede aclarar el porqué de estos resultados?

Espero que te hayan resultado interesante y, ¡haz tus propias pruebas!