Spanish (Español) translation by Rafael Chavarría (you can also view the original English article)
Python proporciona soporte completo para implementar tu propia estructura de datos usando clases y operadores personalizados. En este tutorial implementarás una estructura de datos de tubería personalizada que puede desempeñar operaciones arbitrarias sobre sus propios datos. Usaremos Python 3.
La Estructura de Datos de Tubería
La estructura de datos de tubería es interesante porque es muy flexible. Consiste en una lista de funciones arbitrarias que pueden ser aplicadas a una colección de objetos y producen una lista de resultados. Sacará ventaja de la extensibilidad de Python y usar el caracter de tubo ("|") para construir la tubería.
Ejemplo en Vivo
Antes de sumergirnos en todos los detalles, veamos una tubería muy simple en acción:
1 |
x = range(5) | Pipeline() | double | Ω |
2 |
print(x) |
3 |
|
4 |
[0, 2, 4, 6, 8] |
¿Qué está pasando aquí? Desglosemos paso a paso. El primer elemento range(5)
crea una lista de enteros [0, 1, 2, 3, 4]. Los enteros son alimentados a una tubería vacía diseñada por Pipeline()
. Después una función "doble" es agregada a la tubería, y finalmente la genial función Ω
termina la tubería y causa que se evalúe a sí misma.
La evaluación consiste en tomar la entrada y aplicar a todas las funciones en la tubería (en este caso solo la función double). Finalmente, almacenamos el resultado en una variable llamada x y la imprimimos.
Clases Python
Python soporta clases y tiene un modelo orientado a objetos muy sofisticado incluyendo herencia múltiple, mezclas, y sobrecarga dinámica. Una función __init__()
sirve como un constructor que crea nuevas instancias. Python también soporta un avanzado modelo de meta-programación, en el cuál no nos involucraremos en este artículo.
Aquí hay una simple clase que tiene un constructor __init__
que toma un argumento opcional x
(por defecto en 5) y lo almacena en un atributo self.x
. También tiene un método foo()
que devuelve el atributo self.x
multiplicado por 3:
1 |
class A: |
2 |
def __init__(self, x=5): |
3 |
self.x = x |
4 |
|
5 |
def foo(self): |
6 |
return self.x * 3 |
7 |
Aquí está cómo instanciarla con y sin un argumento explícito x:
1 |
>>> a = A(2) |
2 |
>>> print(a.foo()) |
3 |
6
|
4 |
|
5 |
a = A() |
6 |
print(a.foo()) |
7 |
15
|
Operadores Personalizados
Con Python, puedes usar operadores personalizados para tus clases para una sintaxis más agradable. Hay métodos especiales conocidos como métodos "dunder". El "dunder" significa "doble guión bajo". Estos métodos como "__eq__", "__gt__" y "__or__" te permiten usar operadores como "==", ">" and "|" con tus instancias de clase (objetos). Veamos como funcionan con la clase A.
Si intentas comparar dos instancias diferentes de A entre ellas, el resultado siempre será Falso independientemente del valor de x:
1 |
>>> print(A() == A()) |
2 |
False
|
Esto es porque Python compara las direcciones de memoria de los objetos por defecto. Digamos que queremos comparar el valor de x. Podemos agregar un operadores especial "__eq__" que toma dos argumentos, "self" y "otro", y compara su atributo x:
1 |
def __eq__(self, other): |
2 |
return self.x == other.x |
Verifiquemos:
1 |
>>> print(A() == A()) |
2 |
True
|
3 |
|
4 |
>>> print(A(4) == A(6)) |
5 |
False
|
Implementando la Tubería como una Clase Python
Ahora que hemos cubierto los básicos de clases y operadores personalizados en Python, vamos a usarlos para implementar nuestra tubería. El constructor __init__()
toma tres argumentos: funciones, entrada y terminales. El argumento "functions" es una o más funciones. Estas funciones son las etapas en la tubería que operan en los datos de entrada.
El argumento "input" es la lista de objetos sobre los cuáles operará la tubería. Cada elemento de la entrada será procesado por todas las funciones de la tubería. El argumento "terminals" es una lista de funciones, y cuando uno de ellos es encontrado la tubería se evalúa a ella misma y devuelve el resultado. Las terminales son por defecto solo la función imprimir (en Python 3, "print" es una función).
Nota que dentro del constructor, un misterioso "Ω" es agregado a las terminales. Explicaré eso a continuación.
El Constructor Tubería
Aquí está la definición de clase y el constructor __init__()
:
1 |
class Pipeline: |
2 |
def __init__(self, |
3 |
functions=(), |
4 |
input=(), |
5 |
terminals=(print,)): |
6 |
if hasattr(functions, '__call__'): |
7 |
self.functions = [functions] |
8 |
else: |
9 |
self.functions = list(functions) |
10 |
self.input = input |
11 |
self.terminals = [Ω] + list(terminals) |
Python 3 soporta completamente Unicode en nombres de identificador. Esto significa que podemos usar símbolos geniales como "Ω" para nombres de variable y función. Aquí, declaré una función identidad llamada "Ω", la cuál sirve como una función terminal: Ω = lambda x: x
Podría haber usado la sintaxis tradicional también:
1 |
def Ω(x): |
2 |
return x |
Los Operadores "__or__" y "__ror__"
Aquí viene el núcleo de la clase Tubería. Para poder usar el "|" (símbolo tubería), necesitamos anular un par de operadores. El símbolo "|" es usado por Python para bitwise o de enteros. En nuestro caso, queremos anularlo para implementar encadenamiento de funciones así como alimentar la entrada al inicio de la tubería. Esas son dos operaciones separadas.
El operador "__ror__" es invocado cuando el segundo operando es una instancia Tubería mientras el segundo operando no lo sea. Este considera el primer operando como la entrada y lo almacena en el atributo self.input
, y devuelve la instancia Tubería (el mismo). Esto permite el encadenamiento de más funciones después.
1 |
def __ror__(self, input): |
2 |
self.input = input |
3 |
return self |
Aquí está un ejemplo en donde el operador __ror__()
sería invocado: 'hello there' | Pipeline()
El operador "__or__" es invocado cuando el primer operando es una Tubería (incluso si el segundo operando es también una Tubería). Este acepta el operando para ser una función que se puede llamar y afirma que el operando "func" se puede de hecho llamar.
Entonces, este agrega la función al atributo self.functions
y revisa si la función es una de las funciones terminales. Si es una terminal entonces toda la tubería es evaluada y el resultado es devuelto. Si no es una terminal, la tubería misma es devuelta.
1 |
def __or__(self, func): |
2 |
assert(hasattr(func, '__call__')) |
3 |
self.functions.append(func) |
4 |
if func in self.terminals: |
5 |
return self.eval() |
6 |
return self |
Evaluando la Tubería
A medida que agregas más y más funciones no terminales a la tubería, nada pasa. La evaluación actual es diferida hasta que el método eval()
es llamado. Esto puede suceder ya sea agregando una función terminal a la tubería o llamando eval()
directamente.
La evaluación consiste en iterar sobre todas las funciones en la tubería (incluyendo la función terminal si hay una) y ejecutándolas en orden sobre la salida de la función previa. La primera función en la tubería recibe un elemento de entrada.
1 |
def eval(self): |
2 |
result = [] |
3 |
for x in self.input: |
4 |
for f in self.functions: |
5 |
x = f(x) |
6 |
result.append(x) |
7 |
return result |
Usando la Tubería Efectivamente
Una de las mejores formas de usar una tubería es aplicarla a múltiples conjuntos de salida. En el siguiente ejemplo, una tubería sin salidas y sin funciones terminales es definida. Tiene dos funciones: la infame función double
que definimos anteriormente y la estándar math.floor
.
Después, le proporcionamos tres entradas diferentes. En el ciclo interior, agregamos la función terminal Ω
cuando la invocamos para recolectar los resultados antes de imprimirlos:
1 |
p = Pipeline() | double | math.floor |
2 |
|
3 |
for input in ((0.5, 1.2, 3.1), |
4 |
(11.5, 21.2, -6.7, 34.7), |
5 |
(5, 8, 10.9)): |
6 |
result = input | p | Ω |
7 |
print(result) |
8 |
|
9 |
[1, 2, 6] |
10 |
[23, 42, -14, 69] |
11 |
[10, 16, 21] |
Podrías usar la función terminal print
directamente, pero entonces cada elemento será impreso en una línea diferente:
1 |
keep_palindromes = lambda x: (p for p in x if p[::-1] == p) |
2 |
keep_longer_than_3 = lambda x: (p for p in x if len(p) > 3) |
3 |
|
4 |
p = Pipeline() | keep_palindromes | keep_longer_than_3 | list |
5 |
(('aba', 'abba', 'abcdef'),) | p | print |
6 |
|
7 |
['abba'] |
Mejoras Futuras
Hay algunas cuantas mejoras que pueden hacer a la tubería más útil:
- Agrega transmisión para que pueda trabajar sobre flujos infinitos de objetos (ej. leer desde archivos o eventos de red).
- Proporciona un modo de evaluación en donde la entrada entera es proporcionada como un objeto sencillo para evitar la incómoda solución de proporcionar una colección de un elemento.
- Agrega varias funciones útiles de tubería.
Conclusión
Python es un lenguaje muy expresivo y está bien equipado para diseñar tu propia estructura de datos y tipos personalizados. La habilidad para anular operadores estándar es muy poderosa cuando las semánticas se prestan ellas mismas a tal anotación. Por ejemplo, el símbolo de tubería ("|") es muy natural para una tubería.
Muchos desarrolladores Python disfrutan las estructuras de datos integradas de Python como tuplas, listas, y diccionarios. Sin embargo, diseñar e implementar tu propia estructura de datos puede hacer tu sistema más simple y sencillo de trabajar elevando el nivel de abstracción y ocultando detalles internos a los usuarios. Pruébalo.