DEV Community

Cover image for Pitón, que te quiero Python
Baltasar García Perez-Schofield
Baltasar García Perez-Schofield

Posted on

Pitón, que te quiero Python

Python es el lenguaje de programación de moda. Tanto es así, que es el lenguaje de programación número uno en popularidad, como indica el índice TIOBE.

Lo cierto es que ya ha pasado tiempo desde que se publicó la versión 3.0, la primera que rompía con la compatibilidad hacia atrás para ofrecer mejor diseño y comportamiento, y que ha pasado ya a ser la oficial en solitario en 2020. De acuerdo, pero... ¿cuáles son las características más importantes que se han introducido en el lenguaje de programación desde entonces hasta ahora? Hagamos una revisión (no exhaustiva), de los cambios más importantes.

Desde el principio (Python 3.0)

La versión 3.0 ya trajo consigo mejoras sustanciales: por ejemplo, el uso de Unicode como codificación por defecto, cuando en Python 2 era un tipo más. Esto abarca no solo el funcionamiento interno del lenguaje, sino también la codificación asumida de los archivos .py. Por ejemplo, el siguiente código necesitaría una pista de la codificación del archivo para ejecutarse, mientras que ahora no precisa de ninguna.

print("Hola desde España")
Enter fullscreen mode Exit fullscreen mode

f-strings

Las cadenas de caracteres precedidas de una 'f', como f"", son denominadas f-strings y permiten la interpolación, es decir, colocar directamente código en ciertos lugares para que sea evaluado y el valor final sustituido en la cadena. Para marcar estos lugares donde se aceptan valores, se utilizan los caracteres '{' y '}'. Por ejemplo:

la_vida = 42
print(f"El significado de la vida, y de todo: {la_vida}")
Enter fullscreen mode Exit fullscreen mode

Pero podemos hacer sustituciones más comnplejas:

base = int(input("Dame una base: "))
expo = int(input("Dame un exponente: "))
print(f"base^expo = {base**expo}")
Enter fullscreen mode Exit fullscreen mode

Si lo que estamos haciendo es depurar, entonces añadiendo un '=' antes del cierre de llave ('}'), se repite la expresión, lo que es realmente cómodo.

def foo(x):
    if int(x) == 42:
        return 1.0
    else:
        return 1.0/float(x)


print(f"{f(42)=}")            # Visualiza "f(42)=1.0"
Enter fullscreen mode Exit fullscreen mode

print()

Print antes era una instrucción, mientras ahora es una función, en mi opinión, mucho más poderosa. Por defecto, cuando le pasamos valores entre comas, los imprime con un espacio de separación y añade un salto de línea, como se hacía en Python 2.

print("Hoy es: ")
print(25, 10, 2023)
Enter fullscreen mode Exit fullscreen mode

La salida del programa es:

Hoy es: 
25 10 2023
Enter fullscreen mode Exit fullscreen mode

No es probablemente lo más útil que nos podamos encontrar. Podríamos convertir los valores a texto mediante str() y añadir unas cuantas barras, para obtener Hoy es: 25/10/2023. Sin embargo, podemos lograr el mismo comportamiento directamente desde print(). Al lugar que ocupan las barras ahora es un parámetro llamado sep, y el salto de línea puede configurarse con end.

print("Hoy es: ", end='')
print(25, 10, 2023, sep='/')
Enter fullscreen mode Exit fullscreen mode

La salida es la que buscábamos:

Hoy es: 25/10/2023
Enter fullscreen mode Exit fullscreen mode

Iteradores

Otro cambio importante han sido los iteradores, una adición que se aprecia claramente en range() y xrange() (esta última existía en Python 2 pero ya no en Python 3). En Python 2, la primera devolvía una lista, mientras que la segunda devolvía un iterador. ¿Cuál es la diferencia? Imaginemos un código como el siguiente:

for i in range(2, 1_000_000):
    if i % 3 == 0 and i % 5 == 0:
         print(i, "es múltiplo de 3 y 5")
         break
Enter fullscreen mode Exit fullscreen mode

La salida del código de arriba es 15 es múltiplo de 3 y 5, terminando el programa. Si range() no devolviera ahora (en Python 3), un iterador, una lista completa de casi un millón de posiciones se habría creado en memoria para prácticamente no ser utilizada. Python 2 creó en su momento xrange() para paliar este comportamiento, en Python 3 el comportamiento de range() es el que tenía el desaparecido xrange().

¿Pero cuál es la diferencia entre una lista y un iterador? Pues que el iterador tan solo genera los resultados a medida que se le piden. Un iterador puede convertirse en una lista de manera muy sencilla:

>>> range(1, 15)
range(1, 15)
>>> list(range(1, 15))
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
Enter fullscreen mode Exit fullscreen mode

Volviendo al anterior ejemplo, range() no creará una lista de un millón de posiciones, sino un iterador que generará los valores de 2 al 15 para después dejar de ser utilizado y finalmente pasar a ser eliminado por el recolector de basura.

Podemos emplear la siguiente analogía: el range() de Python 2 es eager. es decir, ansioso, se pone a hacer el trabajo enseguida, mientras que el range()de Python 3 es lazy o perezoso, solo trabaja cuando realmente no queda más remedio. Podríamos simular el range() de Python 2 de la siguiente forma. Vamos a crear una función similar para el range() de Python 3 que no va a hacer gran cosa, solo por mantener la analogía:

def eager_range(start, end):
    return list(range(start, end))

def lazy_range(start, end):
    return range(start, end)
Enter fullscreen mode Exit fullscreen mode

Al convertir el resultado de range() en una lista, estamos deshaciendo la prevención de Python de no crearla enseguida, sino esperar a que realmente sea necesario crear cada uno de los elementos. Este comportamiento, que es el estándar en Python 3, se mantiene en lazy_range() donde no cambiamos nada.

eager_range_result = eager_range(2, 1_000_000)
lazy_range_result = lazy_range(2, 1_000_000)
print(f"{type(eager_range_result)=} {sys.getsizeof(eager_range_result)=}")
print(f"{type(lazy_range_result)=} {sys.getsizeof(lazy_range_result)=}")
Enter fullscreen mode Exit fullscreen mode

La salida del anterior programa es como sigue:

type(eager_range_result)=<class 'list'> sys.getsizeof(eager_range_result)=8000040
type(lazy_range_result)=<class 'range'> sys.getsizeof(lazy_range_result)=48
Enter fullscreen mode Exit fullscreen mode

La función built-in type() nos devuelve el tipo de un objeto. La función getsizeof() en el módulo sys nos devuele el tamaño de un objeto. En el caso de un rango de dos a un millón (en realidad, para cualquier rango), lazy_range() devuelve un iterador que ocupa 48 bytes. En cambio, la función eager_range() devuelve ya directamente una lista. Al tener cerca de un millón de elementos, el tamaño es de 8,000,040 bytes. O lo que es lo mismo: aproximadamente ocho veces el número de elementos en la lista. Estos son 7,6 MiB.

Pero dado que nos evitamos tener que construir una lista así, ¿tendremos alguna diferencia en velocidad? Sí, utilizando el módulo cProfile podemos medir el tiempo que nos lleva un bucle utilizando uno u otro tipo de rango.

def for_with_py_range2():
    for i in eager_range(2, 8_000_000):
        if i % 3 == 0 and i % 5 == 0:
             print(i, "es múltiplo de 3 y 5")
             break

    return


def for_with_py_range3():
    for i in lazy_range(2, 8_000_000):
        if i % 3 == 0 and i % 5 == 0:
             print(i, "es múltiplo de 3 y 5")
             break

    return


cProfile.run("for_with_py_range2()")
cProfile.run("for_with_py_range3()")
Enter fullscreen mode Exit fullscreen mode

El resultado cuando utilizamos eager_range() es de un total, consistente entre ejecuciones, de dos décimas de segundo:

15 es múltiplo de 3 y 5
         6 function calls in 0.215 seconds
Enter fullscreen mode Exit fullscreen mode

Y cuando lo hacemos con lazy_range(), el siguiente:

15 es múltiplo de 3 y 5
         6 function calls in 0.000 seconds
Enter fullscreen mode Exit fullscreen mode

Es tan rápido que no arroja un resultado distinto de cero.

Type hints

Aunque las anotaciones de tipo o type hints fueron introducidas ya desde Python 3.0, estas eran muy primitivas comparadas con lo que ofrecen actualmente, incluyendo ya el soporte incluso de tipos genéricos.

Muy básicamente, nos permite anotar a qué tipo pertenece una referencia, un parámetro, el tipo de retorno de una función, etc. Por ejemplo, la función eager_range() anterior podría escribirse así:

def eager_range(start: int, end: int) -> list:
    return list(range(start, end))
Enter fullscreen mode Exit fullscreen mode

Estamos indicando que la función acepta dos parámetros de tipo entero, y que devuelve una lista. ¿Qué sucede si rompemos las anotaciones de tipo, y pasamos texto? Nada. Las anotaciones de tipo sirven sobre todo para documentar nuestro código. Por supuesto, si queremos un comportamiento más agresivo, siempre podemos utilizar un comprobador de tipos como mypy.

Podemos instalarlo así:

$ pip install --upgrade mypy
Enter fullscreen mode Exit fullscreen mode

Si lo ejecutamos sobre nuestro programa (el mío se llama tries.py):

$ mypy tries.py
Success: no issues found in 1 source file
Enter fullscreen mode Exit fullscreen mode

Solo para probar, cambiemos un tanto la función:

def eager_range(start: int, end: str) -> list:
    return list(range(start, end))
Enter fullscreen mode Exit fullscreen mode

Hemos marcado equivocadamente end como que acepta un texto. El resultado ahora es:

$ mypy tries.py                                                                           tries.py:6: error: No overload variant of "range" matches argument types "int", "str"  [call-overload]
tries.py:6: note: Possible overload variants:
tries.py:6: note:     def range(self, SupportsIndex, /) -> range
tries.py:6: note:     def range(self, SupportsIndex, SupportsIndex, SupportsIndex = ..., /) -> range
tries.py:32: error: Argument 2 to "eager_range" has incompatible type "int"; expected "str"  [arg-type]
Found 2 errors in 1 file (checked 1 source file)
Enter fullscreen mode Exit fullscreen mode

Como podemos ver, las anotaciones de tipo pueden ser un valioso aliado.

bloques with

El bloque with es verdaderamente novedoso en Python 3. Mientras las características anteriores estaban más o menos disponibles en Python 2, los bloques with han pasado a formar parte del lenguaje de programación íntegramente en esta nueva versión.

    # Already present but importing from future
    with <expr> as <id>:
       <block>
Enter fullscreen mode Exit fullscreen mode

El objetivo es no olvidar "recoger al terminar". El caso más típico es el de un archivo. Utilizando el bloque with, en cuanto la ejecución sale del bloque with, el archivo se cierra sin que tengamos que hacer nada.

Un ejemplo directo o ingenuo sería el siguiente:

    def lee(nf: str) -> list[str]:
        toret = []

        f = open(nf, "r")
        for linea in f:
            toret.append(linea)

        f.close()
        return toret
Enter fullscreen mode Exit fullscreen mode

En este código hay dos problemas principales. Cerrar el archivo es una cuestión repetitiva y que se suele olvidar. El segundo problema consiste en lo siguiente: ¿qué sucede si ocurre un error de lectura durante el bucle? ¿se cerraría el archivo?

    def lee(nf: str) -> list[str]:
        toret = []

        with open(nf, "r") as f:
            for linea in f:
                toret.append(linea)

        return toret
Enter fullscreen mode Exit fullscreen mode

Utilizando el bloque with, resolvemos ambos problemas: no es necesario acordarse de cerrar el archivo. Pero más importante todavía: si se produce un error de lectura dentro del bucle, antes de salir del bloque with, el archivo se cierra antes de propagar el error.

Named tuples (Python 3.1)

Las tuplas con nombre son una forma de crear una información estructurada sin necesidad de recurrir a una clase o a un diccionario.

Imaginemos una función origen_coordenadas() que devuelvan una tupla (0, 0):

    def origen_coordenadas1():
        return (0, 0)
Enter fullscreen mode Exit fullscreen mode

El problema de devolver esta tupla es que no tenemos ningún contexto, ninguna información sobre ella más allá de su contenido. Así, si asignamos el retorno de origen_coordenadas() a p, entonces podemos acceder al primer elemento con p[0], al segundo con p[1], sabemos que existen dos elementos con len(p), y las tuplas en general pueden ser comparadas entre sí, y además se obtiene su valor de hash de una forma muy rápida.

p1 = origen_coordenadas1()
print(p1[0])                  # 0
print(p1[1])                  # 0
print(len(p1))                # 2
print(p1 == (0, 0))           # True
print(p1 == (5, 6))           # False
print(str(p1))                # (0, 0)
print(hash(p1))               # -85....
Enter fullscreen mode Exit fullscreen mode

Pero como decíamos más arriba, no hay nada que diferencia esta tupla en la que devolvemos dos coordenadas x e y de otra en la que devolvamos los números de tornillos y tuercas en un almacén.

Así, podemos optar por utilizar las tuplas nombradas o named tuples, de forma que podemos efectivamente darle a la tupla la información que le falta.

    from collections import namedtuple


    Punto = namedtuple("Punto", ["x", "y"])


    def origen_coordenadas2():
        return Punto(0, 0)
Enter fullscreen mode Exit fullscreen mode

Ahora las tuplas Punto sigue siendo tuplas, pero con la diferencia de que cada uno de sus valores tiene un nombre asociado, lo cuál es muy parecido a lo que obtendríamos (un objeto) al instanciar una clase Point. Es muy importante darse cuenta de que al seguir siendo una tupla, a) no podemos modificar los valores de x e y, y b) No podremos añadir métodos, sobreescribir __str__ o __len__, hacerla derivar de otra clase, etc.

p2 = origen_coordenadas2()
print(p2.x)                   # 0
print(p2.y)                   # 0
print(len(p2))                # 2
print(p2 == Punto(0, 0))      # True
print(p2 == Punto(5, 6))      # False
print(str(p2))                # Point(x=0, y=0)
print(hash(p2))               # -85....
print(p1 == p2)               # True
print((5, 6) == Punto(3, 4))  # False
Enter fullscreen mode Exit fullscreen mode

Así, tenemos una tupla en la que podemos acceder a los campos mediante un nombre. El resto de operaciones, como hash() o el operador ==. siguen siendo accesibles. Pero es que además, como siguen siendo tuplas, podemos comparar p1 con p2, y obtener un booleano que será True si todos los valores son iguales.

Enumerados (Python 3.4)

Los enumerados han sido una larga demanda para Python. Aunque la falta de estos podía paliarse fácilmente con una clase que definiera una serie de constantes, era una alternativa que seguía presentando problemas como podemos ver a continuación:

    class Semana:
        LUNES = 10
        MARTES = 11
        MIERCOLES = 12
        JUEVES = 13
        VIERNES = 14
        SABADO = 15
        DOMINGO = 16
Enter fullscreen mode Exit fullscreen mode

Aunque es verdad que hemos identificado cada día de la semana con una constante, y que además estas constantes están dentro del espacio de nombres de la clase Semana sin contaminar el resto, la problemática es que aún siendo más intuitivo e informativo sigue sin poder reforzar la corrección. Por ejemplo:

    def nombre_dia_semana_desde_entero(ns: int) -> str:
        nombres_dias_semana = [
            "lunes",
            "martes",
            "miércoles",
            "jueves",
            "viernes",
            "sábado",
            "domingo",
        ]

        return nombres_dias_semana[ns - 10]


print(nombre_dia_semana_desde_entero(Semana.MIERCOLES))
Enter fullscreen mode Exit fullscreen mode

El problema de esta función es que nada impide pasar un, por ejemplo, 100 o un -42 como valor de ns, produciendo un error al intentar indexar nombres_dias_semana con una posición que no existe.

from enum import Enum, auto


class DiaSemana(Enum):
    LUNES = 10
    MARTES = auto()
    MIERCOLES= auto()
    JUEVES = auto()
    VIERNES = auto()
    SABADO = auto()
    DOMINGO = auto()

    @staticmethod
    def nombre(d: "DiaSemana"):
        NOMBRES_DIAS_SEMANA = [
        "lunes",
        "martes",
        "miércoles",
        "jueves",
        "viernes",
        "sábado",
        "domingo"]

        return NOMBRES_DIAS_SEMANA[d.value - 10]
Enter fullscreen mode Exit fullscreen mode

Tenemos varias ventajas al utilizar este código. La primera es que podemos emplear auto() para que, dado ya el valor a una de las constantes, las siguientes se asignen como un incremento. Además, Existen formas explícitas de convertir una constante de DiaSemana desde entero y a entero, pero ninguna implícita, por lo que se producirán errores si se intenta utilizar un entero directamente. Finalmente, la clase la podemos extender con los métodos que necesitemos (como el método estático DiaSemana.nombre() mostrado más arriba.

print(DiaSemana.MIERCOLES)                      # DiaSemana.MIERCOLES
print(DiaSemana.MIERCOLES.value)                # 12
print(DiaSemana.MIERCOLES.name)                 # LUNES
print(DiaSemana.nombre(DiaSemana.MIERCOLES))    # lunes
Enter fullscreen mode Exit fullscreen mode

Data classes (Python 3.7)

Las clases dataclass se parecen un poco a las namedtuples. La gran diferencia es que mientras las dataclass permiten ser extendidas, así como los miembros mutables, cosa que no es posible en las tuplas pues son inmutables (una vez creadas).

from dataclasses import dataclass


@dataclass
class Punto:
    x: int = 0
    y: int = 0
Enter fullscreen mode Exit fullscreen mode

Una dataclass es, básicamente, un generador de código. Es decir, le indicamos qué atributos queremos (en este caso, x e y), y obtenemos una clase completa. En el ejemplo más arriba indicamos el tipo de los atributos y el valor por defecto.

  • Un inicializador (i.e., __init__), para dar valor a todos los atributos.
  • Un operador == sobrecargado para comparar objetos del mismo tipo.
  • Un método str.
p0 = Punto()
p1 = Punto(4, 5)
print(f"{p0=}")                    # p0=Punto(x=0, y=0)
print(f"{p1=}")                    # p1=Punto(x=4, y=5)
print(f"{p1 == Punto3(0, 0)=}")    # p1 == Punto(0, 0)=False
print(f"{p1 == Punto3(4, 5)=}")    # p1 == Punto(4, 5)=True
print(f"{p1 == Punto3(5, 6)=}")    # p1 == Punto(5, 6)=False

p1.x = 42
p1.y = 21
print(f"{p1=}")                    # p1=Punto(x=42, y=21)
Enter fullscreen mode Exit fullscreen mode

Las diferencias entre las namedtuples y las dataclasses reside básicamente en la posibilidad de mutabilidad. Las tuplas no pueden ser modificadas (son inmutables), mientras las dataclasses sí. Además, a estas últimas se les pueden añadir métodos e incluso crearlas empleando herencia.

Las dataclasses pueden construirse con múltiples parámetros, que se escapan de los objetivos de este texto.

Expresiones de asignación (Python 3.8)

Las asignaciones en Python son una instrucción, no una expresión. Así, el siguiente código produce un error.

x = 5
>>> print(x = 6)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'x' is an invalid keyword argument for print()
Enter fullscreen mode Exit fullscreen mode

Python interpreta que hay un parámetro x al que queremos darle un valor, pero print() no tiene un parámetro así. En otras situaciones, simplemente se producirá un error de sintaxis.

Sin embargo, si intentamos lo siguiente:

x = 5
>>> print(x := 6)
6
Enter fullscreen mode Exit fullscreen mode

Utilizando :=, se asigna 6 a x, y finalmente se devuelve 6 como resultado. Los ejemplos que se estilan cuando se habla de esta característica son bastante perjudiciales, utilizando esta posibilidad para escribir código más propio de lenguajes como C (que "confunden" los tipos entero y boleano).

En realidad, este nuevo operador es muy útil para utilizar en lambdas. Pongamos el siguiente ejemplo, la manida sucesión de fibonacci.

fibo = lambda n:\
        [] if n <= 0 else \
        [1] if n == 1 else \
        [1, 1] if n == 2 else \
        fibo(n - 1) + [fibo(n - 1)[-1] + fibo(n - 1)[-2]]

print(f"{fibo(0)=}")
print(f"{fibo(1)=}")
print(f"{fibo(2)=}")
print(f"{fibo(3)=}")
print(f"{fibo(4)=}")
print(f"{fibo(5)=}")
print(f"{fibo(9)=}")
Enter fullscreen mode Exit fullscreen mode

La salida de este código es la siguiente:

fibo(0)=[]
fibo(1)=[1]
fibo(2)=[1, 1]
fibo(3)=[1, 1, 2]
fibo(4)=[1, 1, 2, 3]
fibo(5)=[1, 1, 2, 3, 5]
fibo(9)=[1, 1, 2, 3, 5, 8, 13, 21, 34]
Enter fullscreen mode Exit fullscreen mode

Tiene un problema esta función lambda, y es que es bastante ineficiente. Si tenemos en cuenta que la sucesión de fibonacci se define por empezar en [1, 1], y que el elemento n se calcula utilizando la sucesión hasta n - 1, sumando las dos últimos elementos. El problema es que para realizar este cálculo se llama tres veces a fibo(n - 1). Es verdad que llamar a fibo(3) tres veces no tiene demasiada importancia, pero cuando n crece, el rendimiento se resiente enormemente.

Por ejemplo, ya solo con n = 20, tenemos el extremo resultado de... ¡más de cincuenta segundos!

import time


t1 = time()
print(fibo(20))                     # [1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, 6765]
t2 = time()
print(t2 - t1)                      # 51.65156435966492
Enter fullscreen mode Exit fullscreen mode

Modifiquemos ligeramente el código anterior, y repitamos la prueba.

fibo = lambda n:\
        [] if n <= 0 else \
        [1] if n == 1 else \
        [1, 1] if n == 2 else \
        (fiboant := fibo(n - 1)) + [fiboant[-1] + fiboant[-2]]
Enter fullscreen mode Exit fullscreen mode

El resultado está ahora por debajo de de 1 segundo:

import time


t1 = time()
print(fibo(20))                     # [1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, 6765]
t2 = time()
print(t2 - t1)                      # 1.049041748046875e-05
Enter fullscreen mode Exit fullscreen mode

La razón de esta mejora tan drástica radica precisamente en llamar a fibo(n - 1) tan solo una vez por llamada recursiva, utilizada más tarde para encontrar el último y penúltimo elemento y sumarlos.

Structural pattern matching (Python 3.10)

Finalmente, ¡tenemos switch en Python! Solo que no se llama switch, sino match, y además, es muchísimo más potente.

Veamos un ejemplo muy sencillo.

p1 = Punto(42, 21)

match p1.x:
    case 0:  print("Es cero.")
    case 1:  print("Es uno.")
    case 42: print("El sentido de la vida.")
Enter fullscreen mode Exit fullscreen mode

La salida del código anterior es la cadena de caracteres de la tercera línea del match: "El sentido de la vida."

Pero en realidad las posibilidades de matchvan mucho más allá:

p1 = Punto(42, 21)

match p1:
    case Punto(0, 0):   print("El origen de coordenadas.")
    case Punto(42, 42): print("Doblemente, el sentido de la vida.")
    case Punto(0, y):   print(f"{p1=}, {y=}")
    case Punto(x, 21):  print(f"{p1=}, {x=}")
Enter fullscreen mode Exit fullscreen mode

La salida del programa es: "p1=Punto3(x=42, y=21), x=42". Es decir, teniendo en cuenta que p1.x es 42, y p1.y es 21, p1 se empareja con el cuarto caso, de manera que x toma el valor de 42.

Conclusiones

Python está ganando muchas características desde Python 3. Si se ha ganado en expresividad, también se ha ganado en rendimiento. Python 3.11 reclama haber logrado mejoras de entre el 10% y el 60%.

¡El futuro es brillante!

Top comments (0)