DEV Community

Cover image for Argumentos opcionales en Python: ¿por qué no hay que usar listas?
ninja-ia
ninja-ia

Posted on

Argumentos opcionales en Python: ¿por qué no hay que usar listas?

Argumentos opcionales en Python: ¿por qué no hay que usar listas?

Imaginemos esta función (que, ustedes dirán, es bastante tonta) que agrega un elemento a una lista inicial, ambos argumentos de la función.
Pero además, queremos que por defecto, la lista inicial esté vacía, así que pasarle sólo un elemento a la función implique devolver una lista con únicamente ese elemento:

def agregar_a_lista(elemento, inicial=[]):
    inicial.append(elemento)
    return inicial

Así que vamos a usar nuestra humilde, pero correcta función!

>>> una_lista_larga = agregar_a_lista(5, [2, 3])
>>> print(una_lista_larga)
[2, 3, 5]
>>> una_lista_corta = agregar_a_lista(5)
>>> print(una_lista_corta)
[5]
>>> otra_lista_corta = agregar_a_lista(7)

Todo parece andar bien, y otra_lista_corta debería dar [7].
Sin embargo...

>>> print(otra_lista_corta)
[5, 7]

QUÉ!? ¿Qué pasó? ¿Se "acuerda" de que antes le había agregado un 5?

¿Cuándo se calculan los valores por defecto?

Esta es la primera pregunta que nos va a ayudar a entender lo que está sucediendo.
Para eso, vamos a usar: 1. el método __defaults__, que nos permite ver los valores por defecto de una función; y 2. el built-in id que nos devuelve una referencia única a cada objeto (es decir, dos objetos tienen el mismo id si y sólo si son el mismo objeto).
Ahora comenzamos de cero un

def agregar_a_lista(elemento, inicial=[]):
    inicial.append(elemento)
    return inicial


valores_por_defecto = agregar_a_lista.__defaults__
identidades = [id(valor) for valor in agregar_a_lista.__defaults__]
print(f"Valores por defecto: {valores_por_defecto}")
print(f"Identidades: {identidades}")

print("Ejecutando una_lista = agregar_a_lista(5)")
una_lista = agregar_a_lista(5)
print(f"Resultado: {una_lista}")

valores_por_defecto = agregar_a_lista.__defaults__
identidades = [id(valor) for valor in agregar_a_lista.__defaults__]
print(f"Valores por defecto: {valores_por_defecto}")
print(f"Identidades: {identidades}")

print("Ejecutando otra_lista = agregar_a_lista(7)")
otra_lista = agregar_a_lista(7)
print(f"Resultado: {otra_lista}")

Cuando ejecutamos ese script, obtenemos:

Valores por defecto: ([],)
Identidades: [140611099623168]
Ejecutando una_lista = agregar_a_lista(5)
Resultado: [5]
Valores por defecto: ([5],)
Identidades: [140611099623168]
Ejecutando otra_lista = agregar_a_lista(7)
Resultado: [5, 7]

Efectivamente, la función se acuerda de que le agregamos un 5, pero porque el objeto es el mismo (fíjense que las identidades concuerdan).
La primera vez que ejecutamos la función, modificamos su valor; entonces, la segunda vez que lo usamos, lo tomamos con el valor modificado.
La primera conclusión sería: no modifiquemos los valores por defecto dentro de una función de Python o, más general y más seguro, no usemos objetos mutables como valores por defecto en una función.

Bueno, pero ¿qué está pasando?

Como pueden ver, el __defaults__ de la función ya tiene un valor incluso antes de que la función se llame por primera vez.
Esto se debe a que los valores por defecto de la función se calculan cuando la función se define, no cuando se llama.
Este comportamiento se llama early-binding, y su contraparte (late-binding) calcula los valores por defecto en cada ejecución.

Podemos poner esto a prueba bastante fácilmente: ¿qué pasa si entre llamada y llamada volvemos a definir la función tal como estaba?

def agregar_a_lista(elemento, inicial=[]):
    inicial.append(elemento)
    return inicial


valores_por_defecto = agregar_a_lista.__defaults__
identidades = [id(valor) for valor in agregar_a_lista.__defaults__]
print(f"Valores por defecto: {valores_por_defecto}")
print(f"Identidades: {identidades}")

print("Ejecutando una_lista = agregar_a_lista(5)")
una_lista = agregar_a_lista(5)
print(f"Resultado: {una_lista}")


def agregar_a_lista(elemento, inicial=[]):
    inicial.append(elemento)
    return inicial

valores_por_defecto = agregar_a_lista.__defaults__
identidades = [id(valor) for valor in agregar_a_lista.__defaults__]
print(f"Valores por defecto: {valores_por_defecto}")
print(f"Identidades: {identidades}")

print("Ejecutando otra_lista = agregar_a_lista(7)")
otra_lista = agregar_a_lista(7)
print(f"Resultado: {otra_lista}")

Al ejecutar este script obtenemos el resultado esperado:

Valores por defecto: ([],)
Identidades: [140438619595200]
Ejecutando una_lista = agregar_a_lista(5)
Resultado: [5]
Valores por defecto: ([],)
Identidades: [140438619595840]
Ejecutando otra_lista = agregar_a_lista(7)
Resultado: [7]

No sólo los valores por defecto se vuelven a calcular, sino que además fíjense que tienen dos identidades distintas; es decir, son dos objetos distintos.

OK, entendido. Pero ¿cómo lo resuelvo?

La solución cuando no queremos este comportamiento (que es la mayoría de los casos) es utilizar lo que se llama un objeto centinela, para lo que generalmente se usa None:

def agregar_a_lista_bien(elemento, inicial=None):
    if inicial is None:
        inicial = []
    inicial.append(elemento)
    return inicial

Fíjense la diferencia cuando usamos esta función, comparándola con la anterior

una_lista = agregar_a_lista(5)
otra_lista = agregar_a_lista(7)
print(f"Identidades: {id(una_lista)}, {id(otra_lista)}")

una_lista_bien = agregar_a_lista_bien(5)
otra_lista_bien = agregar_a_lista_bien(7)
print(f"Identidades: {id(una_lista_bien)}, {id(otra_lista_bien)}")
Identidades: 139893270457152, 139893270457152
Identidades: 139893271721280, 139893270011200

En agregar_a_lista ambas listas son el mismo objeto (sus identidades son iguales), mientras que en agregar_a_lista_bien se crearon dos listas distintas: [5] y [7].
La diferencia fundamental es que el uso del valor centinela None nos permitió definir inicial dentro de la función y, así, es inicial = [] sucede cada vez que llamamos la función y no sólo al princiio como antes.

Algunas aclaraciones extra (quizás un poco más avanzadas)

Sobre los centinelas

No quiero ahondar mucho en este tema, pero hay veces que, en la función que usamos, el argumento por defecto podría ser None.
En ese caso, nuestra técnica del centinela interfiere con ese valor, porque lo considera simplemente el indicador de "acá va una lista vacía".
¿La solución? Usar centinelas únicos.
La explicación detallada quedará para más adelante, pero la idea es algo así:

_CENTINELA = object()

def agregar_a_lista_bien_unico(elemento, inicial=_CENTINELA):
    if inicial is _CENTINELA:
        inicial = []
    inicial.append(elemento)
    return inicial

Sobre el is

La keyword is en Python es para chequear identidades, no valores.
Es decir, A is B va a dar True si y sólo si id(A) == id(B); es decir, si y sólo si A y B son el mismo objeto.
¿Por qué comparamos con is y no con ==?
Porque en Python el comparador (==) se puede definir para cada clase a través del método __eq__; entonces, si creamos una clase que parece una lista, pero devuelve siempre True en las comparaciones:

class MiLista(list):
    def __eq__(self, other):
        return True

sucede:

>>> lista_prueba = MiLista([1, 2, 3, 4])
>>> lista_prueba == None
True
>>> lista_prueba is None
False

Así que nunca, nunca, nunca comparen valores centinela con ==.
Lo que me lleva al tercer y último punto:

Comparaciones implícitas

Python tiene conversiones implícitas de valores booleanos (True/False, bah).
Por ejemplo, las listas vacías, None y los números iguales a 0 se evalúan como False.
Así que, como None se evalúa a False, uno podría tentarse a escribir:

...
if not inicial:
    inicial = []

en vez del chequeo inicial is None.

A esto, además del problema de comparar con ==, se le suma que quizás quermeos pasar una lista vacía y modificarla (con un append), pero este código nos fuerza a crear una lista nueva inicial dentro del scope de la función.

Entonces...

Estudiar cómo funciona el lenguaje nos puede ayudar a que comportamientos que antes nos parecían raros se vuelvan más intuitivos.
En este caso en particular:

  1. No usemos mutables como valores por defecto en Python.
  2. None casi siempre es un buen valor centinela
  3. Cuando no sirve como centinela, hay que arremangarse un poco; pero la lógica es la misma.
  4. Los centinelas siempre se chequean con is, no con ==.

Top comments (0)