Pitón, que te quiero Python
Tags: spanish, python, programming, learning
Published: 2023-11-14 17:34:37

---
title: Pitón, que te quiero Python
published: true
description: Las novedades más importantes desde Python 3.0
tags: #spanish #python #programming #learning
cover_image: https://www.python.org/static/img/python-logo.png
---

**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](https://www.tiobe.com/tiobe-index/python/).

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")

### 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:

```python
la_vida = 42
print(f"El significado de la vida, y de todo: {la_vida}")
```

Pero podemos hacer sustituciones más comnplejas:

```python
base = int(input("Dame una base: "))
expo = int(input("Dame un exponente: "))
print(f"base^expo = {base**expo}")
```

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.

```python
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"
```

### 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.

```python
print("Hoy es: ")
print(25, 10, 2023)
```

La salida del programa es:

```
Hoy es: 
25 10 2023
```

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`.

```python
print("Hoy es: ", end='')
print(25, 10, 2023, sep='/')
```

La salida es la que buscábamos:

```
Hoy es: 25/10/2023
```

### 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:

```python
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
```

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:

```python
>>> 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]
```

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:

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

def lazy_range(start, end):
    return range(start, end)
```

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.

```python
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)=}")
```

La salida del anterior programa es como sigue:

```python
type(eager_range_result)=<class 'list'> sys.getsizeof(eager_range_result)=8000040
type(lazy_range_result)=<class 'range'> sys.getsizeof(lazy_range_result)=48
```

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.

```python
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()")
```

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
```

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

```
15 es múltiplo de 3 y 5
         6 function calls in 0.000 seconds
```

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í:

```python
def eager_range(start: int, end: int) -> list:
    return list(range(start, end))
```

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*](https://mypy-lang.org/).

Podemos instalarlo así:

```bash
$ pip install --upgrade mypy
```

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

```bash
$ mypy tries.py
Success: no issues found in 1 source file
```

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

```python
def eager_range(start: int, end: str) -> list:
    return list(range(start, end))
```

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

```bash
$ 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)
```

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.

```python
    # Already present but importing from future
    with <expr> as <id>:
       <block>
```

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:

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

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

        f.close()
        return toret
```

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?

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

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

        return toret
```

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):

```python
    def origen_coordenadas1():
        return (0, 0)
```

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.

```python
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....
```

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.

```python
    from collections import namedtuple


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


    def origen_coordenadas2():
        return Punto(0, 0)
```

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.

```python
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
```

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:

```python
    class Semana:
        LUNES = 10
        MARTES = 11
        MIERCOLES = 12
        JUEVES = 13
        VIERNES = 14
        SABADO = 15
        DOMINGO = 16
```

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:

```python
    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))
```

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.

```python
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]
```

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.

```python
print(DiaSemana.MIERCOLES)                      # DiaSemana.MIERCOLES
print(DiaSemana.MIERCOLES.value)                # 12
print(DiaSemana.MIERCOLES.name)                 # LUNES
print(DiaSemana.nombre(DiaSemana.MIERCOLES))    # lunes
```

## 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).

```Python
from dataclasses import dataclass


@dataclass
class Punto:
    x: int = 0
    y: int = 0
```

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__.

```Python
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)
```

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](https://docs.python.org/3/library/dataclasses.html), 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.

```python
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()
```

**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:

```python
x = 5
>>> print(x := 6)
6
```

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*.

```python
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)=}")
```

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]
```

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!

```python
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
```

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

```python
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]]
```

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

```python
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
```

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.

```python
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.")
```

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 `match`van mucho más allá:

```python
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=}")
```

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!