O Python fornece suporte completo para implementar sua própria estrutura de dados usando classes e operadores personalizados. Neste tutorial, você implementará uma estrutura de dados de pipeline personalizada que pode executar operações arbitrárias em seus dados. Usaremos o Python 3.
A estrutura de dados do pipeline
A estrutura de dados do pipeline é interessante porque é muito flexível. Consiste em uma lista de funções arbitrárias que podem ser aplicadas a uma coleção de objetos e produzir uma lista de resultados. Vou aproveitar a extensibilidade do Python e usar o caractere pipe (|
) para construir o pipeline.
Exemplo ao Vivo
Antes de mergulhar em todos os detalhes, vamos ver um pipeline muito simples em ação:
x = range(5) | Pipeline() | double | Ω print(x) [0, 2, 4, 6, 8]
O que está acontecendo aqui? Vamos decompô-lo passo a passo. o primeiro elemento range(5)
cria uma lista de inteiros [0, 1, 2, 3, 4]. Os inteiros são alimentados em um pipeline vazio designado por Pipeline()
. Então uma double
função é adicionada ao pipeline e, finalmente, o cool Ω
A função encerra o pipeline e faz com que ele se avalie.
A avaliação consiste em pegar a entrada e aplicar todas as funções do pipeline (neste caso apenas o double
função). Finalmente, armazenamos o resultado em uma variável chamada x
e imprimi-lo.
Aulas de Python
O Python oferece suporte a classes e possui um modelo orientado a objetos muito sofisticado, incluindo herança múltipla, mixins e sobrecarga dinâmica. Um __init__()
função serve como um construtor que cria novas instâncias. O Python também oferece suporte a um modelo avançado de metaprogramação, que não abordaremos neste artigo.
Aqui está uma classe simples que tem um __init__()
construtor que recebe um argumento opcional x
(o padrão é 5) e o armazena em um self.x
atributo. Ele também tem um foo()
método que retorna o self.x
atributo multiplicado por 3:
class A: def __init__(self, x=5): self.x = x def foo(self): return self.x * 3
Aqui está como instanciá-lo com e sem um argumento x explícito:
>>> a = A(2) >>> print(a.foo()) 6 a = A() print(a.foo()) 15
Operadores personalizados
Com o Python, você pode usar operadores personalizados para suas classes para obter uma sintaxe mais agradável. Existem métodos especiais conhecidos como métodos “dunder”. O “dunder” significa “sublinhado duplo”. Esses métodos como __eq__
, __gt__
e __or__
permitem que você use operadores como ==
, >
e |
com suas instâncias de classe (objetos). Vamos ver como eles trabalham com o A
classe.
Se você tentar comparar duas instâncias diferentes de A
entre si, o resultado será sempre False
independente do valor x
:
>>> print(A() == A()) False
Isso ocorre porque o Python compara os endereços de memória dos objetos por padrão. Digamos que queremos comparar o valor de x. Podemos adicionar um especial __eq__
operador que recebe dois argumentos, self
e other
e compara seus x
atributo:
def __eq__(self, other): return self.x == other.x
Vamos verificar:
>>> print(A() == A()) True >>> print(A(4) == A(6)) False
Implementando o Pipeline como uma classe Python
Agora que abordamos o básico de classes e operadores personalizados em Python, vamos usá-lo para implementar nosso pipeline. o __init__()
O construtor recebe três argumentos: funções, entrada e terminais. O argumento “funções” é uma ou mais funções. Essas funções são os estágios no pipeline que operam nos dados de entrada.
O argumento “input” é a lista de objetos nos quais o pipeline irá operar. Cada item da entrada será processado por todas as funções do pipeline. O argumento “terminals” é uma lista de funções e, quando uma delas é encontrada, o pipeline avalia a si mesmo e retorna o resultado. Os terminais são por padrão apenas a função de impressão (no Python 3, print
é uma função).
Observe que dentro do construtor, um misterioso Ω
é adicionado aos terminais. Vou explicar isso a seguir.
O Construtor de Oleoduto
Aqui está a definição de classe e o __init__()
construtor:
class Pipeline: def __init__(self, functions=(), input=(), terminals=(print,)): if hasattr(functions, '__call__'): self.functions = [functions] else: self.functions = list(functions) self.input = input self.terminals = [Ω] + list(terminals)
Python 3 suporta totalmente Unicode em nomes de identificadores. Isso significa que podemos usar símbolos legais como Ω
para nomes de variáveis e funções. Aqui, declarei uma função de identidade chamada Ω
que serve como uma função terminal:
Ω = lambda x: x
Eu poderia ter usado a sintaxe tradicional também:
def Ω(x): return x
o __or__
e __ror__
operadores
Aqui vem o núcleo da classe Pipeline. Para usar o |
(símbolo de tubo), precisamos substituir alguns operadores. o |
O símbolo é usado pelo Python para bit a bit ou inteiros. No nosso caso, queremos substituí-lo para implementar o encadeamento de funções, bem como alimentar a entrada no início do pipeline. Essas são duas operações separadas.
o __ror__
operador é invocado quando o segundo operando é uma instância de Pipeline, desde que o primeiro operando não seja. Ele considera o primeiro operando como entrada e o armazena na self.input
atributo e retorna a instância do Pipeline de volta (o self). Isso permite o encadeamento de mais funções posteriormente.
def __ror__(self, input): self.input = input return self
Aqui está um exemplo onde o __ror__()
operador seria invocado: 'hello there' | Pipeline()
o __or__
operador é invocado quando o primeiro operando é um Pipeline (mesmo que o segundo operando também seja um Pipeline). Ele aceita o operando como uma função chamável e afirma que o func
operando é realmente chamável.
Em seguida, ele anexa a função ao self.functions
atributo e verifica se a função é uma das funções do terminal. Se for um terminal, todo o pipeline é avaliado e o resultado é retornado. Se não for um terminal, o próprio pipeline é retornado.
def __or__(self, func): assert(hasattr(func, '__call__')) self.functions.append(func) if func in self.terminals: return self.eval() return self
Avaliando o Pipeline
À medida que você adiciona mais e mais funções não terminais ao pipeline, nada acontece. A avaliação efetiva é adiada até o eval()
método é chamado. Isso pode acontecer adicionando uma função de terminal ao pipeline ou chamando eval()
diretamente.
A avaliação consiste em iterar todas as funções no pipeline (incluindo a função de terminal, se houver) e executá-las em ordem na saída da função anterior. A primeira função no pipeline recebe um elemento de entrada.
def eval(self): result = [] for x in self.input: for f in self.functions: x = f(x) result.append(x) return result
Usando o Pipeline Efetivamente
Uma das melhores maneiras de usar um pipeline é aplicá-lo a vários conjuntos de entrada. No exemplo a seguir, um pipeline sem entradas e sem funções de terminal é definido. Tem duas funções: a famigerada double
função que definimos anteriormente e o padrão math.floor
.
Em seguida, fornecemos três entradas diferentes. No loop interno, adicionamos o Ω
função terminal quando a invocamos para coletar os resultados antes de imprimi-los:
p = Pipeline() | double | math.floor for input in ((0.5, 1.2, 3.1), (11.5, 21.2, -6.7, 34.7), (5, 8, 10.9)): result = input | p | Ω print(result) [1, 2, 6] [23, 42, -14, 69] [10, 16, 21]
Você poderia usar o print
função de terminal diretamente, mas cada item será impresso em uma linha diferente:
keep_palindromes = lambda x: (p for p in x if p[::-1] == p) keep_longer_than_3 = lambda x: (p for p in x if len(p) > 3) p = Pipeline() | keep_palindromes | keep_longer_than_3 | list (('aba', 'abba', 'abcdef'),) | p | print ['abba']
Melhorias Futuras
Existem algumas melhorias que podem tornar o pipeline mais útil:
- Adicione streaming para que ele possa funcionar em fluxos infinitos de objetos (por exemplo, leitura de arquivos ou eventos de rede).
- Forneça um modo de avaliação em que toda a entrada é fornecida como um único objeto para evitar a incômoda solução alternativa de fornecer uma coleção de um item.
- Adicione várias funções de pipeline úteis.
Conclusão
Python é uma linguagem muito expressiva e está bem equipada para projetar sua própria estrutura de dados e tipos personalizados. A capacidade de substituir operadores padrão é muito poderosa quando a semântica se presta a essa notação. Por exemplo, o símbolo de tubo (|
) é muito natural para um pipeline.
Muitos desenvolvedores Python apreciam as estruturas de dados integradas do Python, como tuplas, listas e dicionários. No entanto, projetar e implementar sua própria estrutura de dados pode tornar seu sistema mais simples e fácil de trabalhar, elevando o nível de abstração e ocultando detalhes internos dos usuários. De uma chance.