Mejora el rendimiento y la seguridad de tu código Python con normalización y cadenas únicas (interned strings)

Autor: | Última modificación: 8 de marzo de 2024 | Tiempo de Lectura: 3 minutos
Temas en este post: , , ,

Python no tiene fama de ser un lenguaje muy rápido, lo que es cierto. Sin embargo, sí que es relativamente bueno a la hora de optimizar otro recurso igual de importante que el tiempo: la memoria.

Al no crear constantemente copias de los datos y compartirlos entre varias estructuras de datos, se reduce mucho el consumo de memoria. 

Vas a crear el nuevo Twitter… pero sin Elon

Supongamos que estás haciendo el nuevo Twitter para competir con Elon Musk. Tendrás colas o listas de usuarios, mensajes, seguidores y seguidos.

Esos usuarios estarán en varias listas, como la general, la de seguidores, la de seguidos, etc. Por suerte, realmente solo habrá una instancia compartida entre las distintas listas, lo cual es bueno.

La clave de un sistema de micro-mensajería como Twitter serán los mensajes. Estos podrían estar representados de la siguiente forma en Python:

# Types
User = str
Users = Set[User]
Message = str
Timestamp = float
Post = NamedTuple("Post",
                  [("timestamp", Timestamp),
                   ("user", User), ("message", Message)])
Posts = Deque[Post]

Una gran parte del sistema son cadenas, y deberíamos asegurarnos de que ese componente mayoritario está debidamente optimizado.

Los problemas que pueden surgir al manejar muchas cadenas, algo muy común, son los siguientes:

  1. Datos no normalizados.
  2. Datos duplicados.

En el fondo se trata de aplicar el viejo principio de DRY (Do Not Repeat Yourself).

Normalización de Unicode en Python

Podríamos tener exactamente el mismo texto codificado de diferentes formas en Unicode. Esto no solo malgasta recursos, sino que nos puede traer bugs muy difíciles de entender, ya que tendríamos dos cadenas que parecen la misma, pero que no son iguales (__eq__).

Veamos un ejemplo:

# El texto Löwis, se puede representar de dos formas
s = 'L' + chr(246) + 'wis' 
t = 'L' + chr(111) + chr(776) + 'wis'

print(s)
print(t)

s == t # devuelve False

Realmente, la len de una y otra cadena son distintas y están compuestas por caracteres diferentes (cosas de Unicode):

[ord(c) for c in s] # [76, 246, 119, 105, 115]
[ord(c) for c in t]  # [76, 111, 776, 119, 105, 115]

Para evitar este tipo de problema, sobre todo con texto que proviene de fuera de tu sistema, es indispensable que, antes de meterlo en nuestro sistema (en las bases de datos o lo que sea), lo normalicemos.

Normalización

El objetivo de la normalización es tener una representación única. Hay varias formas de normalizar texto usando unicodedata. Una de ellas, que además reduce la longitud de la representación, es la llamada NFC (Normal Form C).

import unicodedata

nt = unicodedata.normalize('NFC', t)
s == nt # Ahora sí devuelve True

Normaliza siempre el texto, especialmente si viene de fuera.

String Interning

Dado que las cadenas son inmutables, no tiene ningún sentido tener varias instancias de la misma cadena en memoria. Por ejemplo, volviendo al sistema de mensajería, si el nombre de un usuario es frr, ¿qué sentido tiene que cada vez que aparezca su nombre sea una instancia distinta, lo que ocupa más memoria?

El string interning no es más que aplicar esa norma a las cadenas. En el fondo, es una instancia del patrón de diseño Flyweight.

Lo mismo ocurre con los booleanos: solo hay una instancia de true y otra de false que se comparten en todas partes. 

Al igual que con los booleanos, el propio Python se debería de encargar de esto con las cadenas, pero lamentablemente no siempre lo hace. Por lo tanto, tenemos que hacerlo nosotros.

Lo primero es comprobar que el problema existe:

s = 'he'
t = 'llo'
u = "hello"
v = s+t

u == v  # son equivalentes, pero ¿son el mismo objeto?

id(v) == id(s) # No, no son el mismo objeto, tienen ids diferentes

La función sys.intern resuelve esto.

import sys
u = sys.intern('hello')
v = sys.intern(s+t)
u is v

Si vas a tener muchas cadenas repetidas, mételas siempre en la tabla de cadenas internas.

Conclusión

Ahora ya sabes cómo eliminar bugs y ahorrar memoria cuando tratas con cadenas en Python. Ya estás listo para crear el nuevo Twitter y competir con Elon. Eso sí, no olvides crear un bot llamado ElonBot que se encargue de despedir usuarios al azar.