Title: ¿Por qué Python3 es objetivamente mejor que Python2?

Date: 2019-12-27
Content:

Ya está casi por concluir el año 2019 y con el se va uno de los monstruos de software que nos dio esta década, Python2.
Python2 nos ha acompañado a la largo de todo este tiempo como uno de los lenguajes mas simples pero poderosos y para homenajear todo su legado, en este post listaré todas las cosas por las cuales se estaba quedando obsoleto y porque la Python Software Foundation tomó la excelente decisión de dejarlo morir, ahí, solo, desangrándose y pidiendo clemencia.
Este será un repaso de las caracteristicas que hacen a Python3 OBJETIVAMENTE mejor que su antecesor al cual ya no le quedaba superficie para colocar parches.

Optimización de memoria y CPU con iteradores

¿Cuántos range o map no hemos escrito a lo largo de nuestro tiempo desarrollando en Python? pues si bien, estos métodos son muy bonitos y prácticos, también representaban un gran desperdicio de memoria ya que se ejecutaba todo el código de golpe ocupando demasiado espacio.

# Python2
>>> range(100)
[0, 1, 2, 3, 4, 5, ..., 97, 98, 99]

>>> type(range(100))
         ^ ^ ^
         ╚═╩═╩═ La función "range" retorna una lista
╔═╦════════════════════════════════════════════╩═╩═╝
v v
list


# Python3
>>> range(100)
range(0, 100)

>>> type(range(100))
         ^ ^ ^
         ╚═╩═╩═ La función "range" retorna un objeto de tipo "range"
╔═╦═╦═════════════════════════════════════════════════════════╩═╩═╝
v v v
range

La cosa está bastante clara, no? en Python2 cuando nosotros declarábamos un range, en memoria se guardaban todos los números de de inicio a fin en una lista. ¿Son necesarios todos los números de golpe? probablemente no, pero aún así Python2 los generaba todos al mismo tiempo. Otra forma de ver esto es con la función map.

import sys

# Python2
>>> map(sys.exit, [0, 1, 2])
    ^ ^
    ╚═╩═ La función "map" ejecuta la función "sys.exit"
         al momento de ser ejecutado y sale del shell
╔═══════════════════════════════════════════════╩═╩═╝
v
$> echo $?
0

# Python3
>>> map(sys.exit, [0, 1, 2])

>>> next(map(sys.exit, [0, 1, 2]))
    ^ ^ ^ ^
    ╚═╩═╩═╩═ La función "map" ejecuta la función "sys.exit"
             solo al pedir un ítem, y hasta entonces, sale del shell
╔══════════════════════════════════════════════════════════════╩═╩═╝
v
$> echo $?
0

Aquí con map las cosas son claras, en Python2 la función se mapeaba para TOOOODOS los elementos de la lista al momento de declarar el map. A diferencia de Python3, en donde la función es mapeada a cada elemento solo al momento de pedir el siguiente valor, pudiendo evitar así utilizar tiempo de CPU en vano.
La gran desventaja de range fue parcheada con otra función llamada xrange que hacía algo similar a la función range de Python3. También lo corrigieron usuarios con implementaciones propias de range, en este snippet pueden ver la que yo creé en su momento.
Aunque las funciones mencionadas en este apartado solo fueron range y map, esta optimización la recibieron también los zip, filter y los métodos keys, values e items de los diccionarios.

La división retorna enteros

No se si ustedes han tenido ese presentimiento de "creo que no estoy casteando lo suficiente mis variables", pues justo ese sentimiento era el que presentaba al momento de intentar hacer una división en Python2 ya que si ambas variables eran int, el resultado sería otro int aunque la división tuviera residuo, y para evitar eso, se tenía que castear (perdónenme pero no encontré una traducción al español para "cast") uno de los datos a float si queriamos el resultado completo, aunque fuera un 10 / 3.

num1 = 15

# Python2
>>> 10 / 3
       ^
       ╚═ El operador de división nos devuelve unicamente la parte entera
╔═══════════════════════════════════════════════════════════════════╩═╩═╝
v
3

>>> 10. / 3
      ^
      ╚═ Si la división involucra por lo menos a un número "float",
         el resultado será el valor exacto con precisión en decimales
╔═══════════════════════════════════════════════════════════╩═╩═╩═╩═╝
v
3.3333333333333335
                 ^
                 ╚═ (¿Este 5?, pronto explicaré la precisión con decimales)

>>> num1 / 4
3

>>> float(num1) / 4
    ^ ^ ^
    ╚═╩═╩═ Era necesario castear la variable a float si queríamos el valor completo
╔═══════════════════════════════════════════════════════════════════════════╩═╩═╩═╝
v
3.75

# Python3
>>> 10 / 3
       ^
       ╚═ Operador de división normal retorna el valor el valor preciso
╔═══════════════════════════════════════════════════════════════╩═╩═╩═╝
v
3.3333333333333335

>>> num1 / 4
3.75

>>> num1 // 4
         ^
         ╚═ Nuevo operador de división que retorna unicamente la parte entera
╔═══════════════════════════════════════════════════════════════════════╩═╩═╝
v
3

>>> num1 // 4.0
             ^
             ╚═ Si con el nuevo operador uno de los 2 valores es "float",
                el resultado será unicamente la parte entera pero en un float
 ╔══════════════════════════════════════════════════════════════════════╩═╩═╝
 v
3.0

Pero es probable que en ocasiones nos interese unicamente la parte entera de la división en Python3 y los casteos del infierno volverían a aparecer (int(10 / 4)), así que la gente de Python, muy inteligentemente, implementaron un nuevo operador, el //. Este nuevo operador tiene un comportamiento parecido a la división de Python2, si la división es entre enteros, el resultado será un int, y si nuestra división involucra por lo menos float, el valor resultante será unicamente la parte entera de la división pero mostrada en un float. A pesar de esta implementación, muchos seguiremos casteando nuestras variables "por si las dudas".

Scope diferente para las listas de compresiones

Este es muy sencillo de explicar pero cuantos quebraderos de cabeza no nos llegó a dar en su momento y es que antes las variables utilizadas en las listas de compresiones estaban con un scope al mismo nivel que nuestro bloque actual, con el ejemplo se verá mas claramente.

count = -10
^ ^ ^ ^ ^
╚═╩═╩═╩═╩═ Variable "count" declarada en el bloque actual con un valor de -10
items = [count**2 for count in range(1, 10)]
         ^ ^ ^        ^ ^ ^
         ╚═╩═╩════════╩═╩═╩═ Variable "count" también es utilizada en una lista
                             de compresión

# Python2
>>> count
    ^ ^ ^
    ╚═╩═╩═ Al consultar el valor de "count", este cambió al ultimo valor del for
╔════════════════════════════════════════════════════════════════════════════╩═╝
v
4

# Python3
>>> count
    ^ ^ ^
    ╚═╩═╩═ Al consultar el valor de "count", este quedó intacto a pesar de for
╔══════════════════════════════════════════════════════════════════════════╩═╝
v
-10

Como se puede apreciar, las variables utilizadas para iterar dentro de una lista de compresión tiene reservado un espacio aparte en memoria para que no modifiquen nuestro scope actual. Aunque en el ejemplo solo se hizo con listas de compresión, este principio funciona identico en diccionarios, generadores y sets de compresión.

Comparaciones de tipos de datos sin sentido (cof cof JS)

O si, quizás una de las cosas que mas me molestan de Javascript estaba presente en Python2 y es la increíble comparación de tipos sin sentido, ya que, el lenguaje nos dejaba comparar una tupla con un str, o una lista con un diccionario, pero no estaba claro si era la longitud, la cantidad de memoria que necesitaba o simplemente la posición en el stack (por poder, podía ser cualquier cosa). las imágenes hablan por si mismas.

# python2
>>> (1, 2) > "123"
True

>>> [1, 2] > "123"

>>> {1, 2, 3} >= 666
True

>>> 123 > "¿Esto tiene algún sentido? .-."
           ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^
           ╚═╩═╩═╩═╩═╩═╩═╩═╩═╩═╩═╩═╩═ Pregunta
╔═╦═╦════════════════ Respuesta
v v v
False

# Python3
>>> (1, 2) > "123"
---------------------------------------------------------------------------
...
TypeError: '>' not supported between instances of 'tuple' and 'str'

>>> [1, 2] > "123"
---------------------------------------------------------------------------
...
TypeError: '>' not supported between instances of 'list' and 'str'

>>> "foo" > {"a": 11, "b": lambda x: x**2}
---------------------------------------------------------------------------
...
TypeError: '>' not supported between instances of 'str' and 'dict'

Aquí ni pondré anotaciones en el código, creo que es bastante autodescriptivo... una autentica masacre a la razón.

print - función vs palabra reservada

La función print es quizás una de mas cosas que primero nos enseñamos a usar al momento de estar aprendiendo a utilizar Python. Y digo función solo refiriéndome a a Python3, ya que en Python2 print era en realidad una palabra reservada, esto a priori debería de ser muy irrelevante para muchas personas, pero implicaba toparse con pared tarde o temprano ya que las funciones otorgan características interesantes de las cuales Python2 se estaba perdiendo, tal como elegir con que caractér terminará nuestra impresión, o con que caractér unirá el conjunto de datos que le arrojemos a la función.

# Python2
>>> print 123
         ^   ^
         ╚═══╩═ Al ser una palabra reservada no necesita paréntesis
123

>>> print "CodigoComentado"
CodigoComentado

>>> print("123", "456", sep="")
         ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^
         ╚═╩═╩═╩═╩═╩═╩═╩═╩═╩═╩═ Si se intenta utilizar como función, dará error
  File "<stdin>", line 1
    print("123", "456", sep="")

from __future__ import print_function
                       ^ ^ ^ ^ ^ ^ ^
                       ╚═╩═╩═╩═╩═╩═╩═ Se puede importar la función "print" moderna

>>> print("123", "456", sep="")
123456

>>> print "Hola mundo"
         ^            ^
         ╚════════════╩═ Pero al hacerlo ya no podremos utilizar la sintaxis anterior
  File "<stdin>", line 1
    print 123

# Python3
>>> print(123)
123

>>> print("CodigoComentado")
Codigocomentado

>>> print("Codigo", "Comentado", sep="-", end="\n=============\n")
                                 ^ ^      ^ ^
                                 ╚═╩══════╩═╩═ "print" al ser una función por defecto,
                                               se le pueden pasar parámetros como a cualquier otra función
Codigo-Comentado
=============

El equipo de Python incluyó en Python2 un parche como paquete llamado __future__ en el que incluian un montón de caracteristicas de Python3 para llevarlas a Python2, entre ellas incluye a print como función. Un movimiento muy inteligente por parte del equipo. A pesar del incoveniente de tener que hacer el import en todos los archivos, era mucho mejor que tener chapuzas con el sys.stdin y nuestra implementación pobre del print, sin contar que además ahora a print la podiamos pasar como parámetro como cualquier otra función.

Unicode por defecto

Y ahora el mejor y mi cambio favorito de todos.
¿Quién no llegó a sufrir por intentar meter una tilde al código de Python?, ¿O por leer un archivo que en su interior tuviera una ñ? que yo desde luego si, y es que Python2 no era unicode por defecto, sino que utilizaba el antigüisimo "ascíi" como estándar de codificación. Y es que en la época que Python2 nació, era de la mas común encontrarse con "ascii" por todos lados, pero esa época ya pasó y era necesario para todos que este hermoso lenguaje estuviera adaptado a las necesidad de un mundo globalizado.
Python en realidad nunca se ha llevado demasiado bien con los tipos de datos que tienen que almacenar textos, pero por lo menos en Python3 esta tarea se hizo menos tortuosa teniendo unicamente 2 tipos de datos con tareas muy claras, el str y los bytes, a diferencia de python2 que teníamos el str, el unicode y el bytes que en realidad era solo un apodo cutre para str. ¿Todo muy confuso cierto? y es que en Python si queríamos manipular un str que almacenara caracteres que estaban fuera del dominio de "ascii", debíamos decodificarlo a "utf-8" para convertirlo de str a unicode, pero aparte se debía poner al inicio de nuestro archivo .py el shebang # -*- coding: utf-8 -*- para así manipular textos no-anglosajones con libertad.

# Python2
# -*- coding: utf-8 -*-
      ^ ^ ^
      ╚═╩═╩═ Shebang indicando que manipularemos caracteres utf-8, no lo olvides
a = "ñ"
>>> a
    ^
    ╚═ La inofensiva "ñ" es en realidad "\xc3\xb1" en ascii.
╔═════════════════════════════════════════╩═╩═╩═╝
v
'\xc3\xb1'

>>> type(a)
    ^ ^ ^ ^
    ╚═╩═╩═╩═ La variable es de tipo "str"
       ╔═╦═══════════════════════════╩═╝
       v v
<type 'str'>

a = a.decode("utf-8")
              ^ ^ ^
              ╚═╩═╩═ Se decodifica a "utf-8"

>>> a
    ^
    ╚═ Y ahora la variable si guarda el valor correcto de la "ñ" en unicode
╔═══════════════════════════════════════════════════════════════════╩═╩═╩═╝
v
u'\xf1'

>>> type(a)
<type 'unicode'>
       ^ ^ ^ ^
       ╚═╩═╩═╩═ Otro tipo de dato para los strings con caracteres fuera de "ascii"

>>> bytes == str
True
^ ^
╚═╩═ El tipo "bytes" es solo una broma de mal gusto en python2 ya que es solo
     un alias para el tipo "str"

# Python3
a = "ñ"

>>> a
    ^
    ╚═ La "ñ" es una "ñ", correcto
 ╔════════════════════╝
 v
'ñ'

>>> type(a)
    ^ ^ ^ ^
    ╚═╩═╩═╩═ El tipo de dato es "str", correcto 
 ╔═╦═════════════════════════════╩═╝
 v v
'str'

a = a.encode("utf-8")
              ^ ^ ^
              ╚═╩═╩═ Se codifica con el estandar "utf-8" para utilizar a "ascíi"
>>> a
    ^
    ╚═ Valor real en ascíi, correcto 
  ╔═╦═╦═╦════════════╩═╩═╝
  v v v v
b'\xc3\xb1'

>>> type(a)
    ^ ^ ^ ^
    ╚═╩═╩═╩═ Ahora el tipo de dato es uno llamado "bytes" que solo almacena
             textos con caracteres en ascii        ║ ║ ║
╔═╦═╦══════════════════════════════════════════════╩═╩═╝
v v v
bytes

>>> bytes == str
False
^ ^ ^
╚═╩═╩═ El tipo "bytes" ahora si es distinto a "str" y no solo una fachada

Como podemos darnos cuenta, en Python2 todo era mucho mas caótico al momento de manipular textos. Por suerte en Python3 todo quedó en solo 2 tipos con funciones bastante claras: El tipo str será el encargado de almacenar todo tipo de textos y es compatible por defecto con el estandar utf-8 (utf-8 = emojis[😂, 😅, 🤔, 🔥, 💯, 🦆]) y el tipo bytes será el encargado de almacenar cadenas de textos que solo deban almacenar caracteres ascíi, es decir, unicamente binarios.
Como anecdota, recuerdo en cada archivo de Python3 que creaba, debía escribir al inicio:

try:
    unicode
except:
    unicode = str

para que el archivo no tuviera problemas para correr tanto en Python2 como en Python3 y poder utilizar a unicode como tipo de dato para compararlo con isinstance.

Conclusión

Si bien Python2 fue una de las mejores cosas que le pasaron a mi vida en esta década y con la que aprendí realmente a programar y todo lo que envolvía al mundo de las ciencias de la computación, puedo decir con completa convicción... que bueno que vas a morir, no te extrañaré. Hasta nunca, Python2.

Fuentes

  1. https://docs.python.org/3/howto/pyporting.html
  2. https://python-future.org/compatible_idioms.html
  3. https://sebastianraschka.com/Articles/2014_python_2_3_key_diff.html
  4. Experiencia personal

Actualizaciones

27-12-2019: Al parecer el EOL de Python2 no será en Enero, sino hasta Abril. creo que quieren hacer coincidir la muerte de Python2 con la PyCon 2020 para hacerle un funeral con todos los asistentes o yo que se... de verdad, déjenlo morir, ya está agonizando. Otro tema diferente a tratar sería ver hasta cuando retirarán a Python2 de los repositorios de la N distribuciones *NIX... Gracias @irl_ry. 28-02-2020: Arreglados algunos typos. Gracias VMS.