Garantindo um código limpo: um olhar sobre Python, parametrizado

Publicados: 2022-03-11

Neste post, falarei sobre o que considero ser a técnica ou padrão mais importante na produção de código Pythonic limpo, ou seja, parametrização. Este post é para você se:

  • Você é relativamente novo em toda a coisa de padrões de projeto e talvez um pouco confuso com longas listas de nomes de padrões e diagramas de classes. A boa notícia é que existe realmente apenas um padrão de design que você deve conhecer para Python. Melhor ainda, você provavelmente já o conhece, mas talvez não de todas as maneiras que ele pode ser aplicado.
  • Você veio para o Python de outra linguagem OOP, como Java ou C#, e quer saber como traduzir seu conhecimento de padrões de design dessa linguagem para o Python. Em Python e outras linguagens tipadas dinamicamente, muitos padrões comuns em linguagens OOP estaticamente tipadas são “invisíveis ou mais simples”, como o autor Peter Norvig colocou.

Neste artigo, exploraremos a aplicação da “parametrização” e como ela pode se relacionar com os padrões de design convencionais conhecidos como injeção de dependência , estratégia , método de modelo , fábrica abstrata , método de fábrica e decorador . Em Python, muitos deles se tornam simples ou desnecessários pelo fato de que os parâmetros em Python podem ser objetos ou classes que podem ser chamados.

A parametrização é o processo de pegar valores ou objetos definidos dentro de uma função ou método, e torná-los parâmetros para aquela função ou método, a fim de generalizar o código. Esse processo também é conhecido como refatoração “extrair parâmetro”. De certa forma, este artigo é sobre padrões de projeto e refatoração.

O caso mais simples de Python parametrizado

Para a maioria de nossos exemplos, usaremos o módulo de instrução tartaruga de biblioteca padrão para fazer alguns gráficos.

Aqui está um código que irá desenhar um quadrado de 100x100 usando turtle :

 from turtle import Turtle turtle = Turtle() for i in range(0, 4): turtle.forward(100) turtle.left(90)

Suponha que agora queremos desenhar um quadrado de tamanho diferente. Um programador muito júnior neste ponto ficaria tentado a copiar e colar este bloco e modificar. Obviamente, um método muito melhor seria primeiro extrair o código de desenho do quadrado em uma função e, em seguida, tornar o tamanho do quadrado um parâmetro para esta função:

 def draw_square(size): for i in range(0, 4): turtle.forward(size) turtle.left(90) draw_square(100)

Então agora podemos desenhar quadrados de qualquer tamanho usando draw_square . Isso é tudo que existe para a técnica essencial de parametrização, e acabamos de ver o primeiro uso principal – eliminando a programação de copiar e colar.

Um problema imediato com o código acima é que draw_square depende de uma variável global. Isso tem muitas consequências ruins, e há duas maneiras fáceis de corrigi-lo. A primeira seria para draw_square criar a própria instância Turtle (que discutirei mais tarde). Isso pode não ser desejável se quisermos usar uma única Turtle para todos os nossos desenhos. Então, por enquanto, vamos simplesmente usar a parametrização novamente para tornar turtle um parâmetro para draw_square :

 from turtle import Turtle def draw_square(turtle, size): for i in range(0, 4): turtle.forward(size) turtle.left(90) turtle = Turtle() draw_square(turtle, 100)

Isso tem um nome chique – injeção de dependência. Significa apenas que se uma função precisa de algum tipo de objeto para fazer seu trabalho, como draw_square precisa de um Turtle , o chamador é responsável por passar esse objeto como um parâmetro. Não, realmente, se você já ficou curioso sobre injeção de dependência do Python, é isso.

Até agora, lidamos com dois usos muito básicos. A observação principal para o restante deste artigo é que, em Python, há uma grande variedade de coisas que podem se tornar parâmetros – mais do que em algumas outras linguagens – e isso a torna uma técnica muito poderosa.

Qualquer coisa que seja um objeto

Em Python, você pode usar essa técnica para parametrizar qualquer coisa que seja um objeto e, em Python, a maioria das coisas que você encontra são, na verdade, objetos. Isso inclui:

  • Instâncias de tipos internos, como a string "I'm a string" e o inteiro 42 ou um dicionário
  • Instâncias de outros tipos e classes, por exemplo, um objeto datetime.datetime
  • Funções e métodos
  • Tipos integrados e classes personalizadas

Os dois últimos são os mais surpreendentes, especialmente se você vem de outros idiomas, e eles precisam de mais discussão.

Funções como parâmetros

A instrução de função em Python faz duas coisas:

  1. Ele cria um objeto de função.
  2. Ele cria um nome no escopo local que aponta para esse objeto.

Podemos brincar com esses objetos em um REPL:

 > >> def foo(): ... return "Hello from foo" > >> > >> foo() 'Hello from foo' > >> print(foo) <function foo at 0x7fc233d706a8> > >> type(foo) <class 'function'> > >> foo.name 'foo'

E assim como todos os objetos, podemos atribuir funções a outras variáveis:

 > >> bar = foo > >> bar() 'Hello from foo'

Observe que bar é outro nome para o mesmo objeto, portanto, possui a mesma propriedade __name__ interna de antes:

 > >> bar.name 'foo' > >> bar <function foo at 0x7fc233d706a8>

Mas o ponto crucial é que, como as funções são apenas objetos, em qualquer lugar que você veja uma função sendo usada, ela pode ser um parâmetro.

Então, suponha que estendemos nossa função de desenho de quadrados acima, e agora, às vezes, quando desenhamos quadrados, queremos pausar em cada canto - uma chamada para time.sleep() .

Mas suponha que às vezes não queremos fazer uma pausa. A maneira mais simples de conseguir isso seria adicionar um parâmetro de pause , talvez com um padrão de zero para que, por padrão, não façamos uma pausa.

No entanto, mais tarde descobrimos que às vezes queremos fazer algo completamente diferente nos cantos. Talvez queiramos desenhar outra forma em cada canto, mudar a cor da caneta, etc. Podemos ficar tentados a adicionar muito mais parâmetros, um para cada coisa que precisamos fazer. No entanto, uma solução muito melhor seria permitir que qualquer função fosse passada como a ação a ser executada. Para um padrão, faremos uma função que não faz nada. Também faremos com que esta função aceite os parâmetros locais turtle e size , caso sejam necessários:

 def do_nothing(turtle, size): pass def draw_square(turtle, size, at_corner=do_nothing): for i in range(0, 4): turtle.forward(size) at_corner(turtle, size) turtle.left(90) def pause(turtle, size): time.sleep(5) turtle = Turtle() draw_square(turtle, 100, at_corner=pause)

Ou poderíamos fazer algo um pouco mais legal, como desenhar recursivamente quadrados menores em cada canto:

 def smaller_square(turtle, size): if size < 10: return draw_square(turtle, size / 2, at_corner=smaller_square) draw_square(turtle, 128, at_corner=smaller_square) 

Ilustração de quadrados menores desenhados recursivamente, conforme demonstrado no código parametrizado python acima

É claro que existem variações disso. Em muitos exemplos, o valor de retorno da função seria usado. Aqui, temos um estilo de programação mais imperativo, e a função é chamada apenas por seus efeitos colaterais.

Em outros idiomas…

Ter funções de primeira classe em Python torna isso muito fácil. Em linguagens que não as possuem, ou algumas linguagens de tipagem estática que exigem assinaturas de tipo para parâmetros, isso pode ser mais difícil. Como faríamos isso se não tivéssemos funções de primeira classe?

Uma solução seria transformar draw_square em uma classe, SquareDrawer :

 class SquareDrawer: def __init__(self, size): self.size = size def draw(self, t): for i in range(0, 4): t.forward(self.size) self.at_corner(t, size) t.left(90) def at_corner(self, t, size): pass

Agora podemos subclassificar SquareDrawer e adicionar um método at_corner que faz o que precisamos. Esse padrão python é conhecido como padrão de método de modelo - uma classe base define a forma de toda a operação ou algoritmo e as partes variantes da operação são colocadas em métodos que precisam ser implementados por subclasses.

Embora isso às vezes possa ser útil em Python, extrair o código variante em uma função que é simplesmente passada como um parâmetro geralmente será muito mais simples.

Uma segunda maneira de abordar esse problema em linguagens sem funções de primeira classe é agrupar nossas funções como métodos dentro de classes, assim:

 class DoNothing: def run(self, turtle, size): pass def draw_square(turtle, size, at_corner=DoNothing()): for i in range(0, 4): turtle.forward(size) at_corner.run(turtle, size) t.left(90) class Pauser: def run(self, turtle, size): time.sleep(5) draw_square(turtle, 100, at_corner=Pauser())

Isso é conhecido como o padrão de estratégia. Novamente, este é certamente um padrão válido para usar em Python, especialmente se a classe de estratégia realmente contiver um conjunto de funções relacionadas, em vez de apenas uma. No entanto, muitas vezes tudo o que realmente precisamos é de uma função e podemos parar de escrever classes.

Outras chamadas

Nos exemplos acima, falei sobre passar funções para outras funções como parâmetros. No entanto, tudo o que escrevi era, de fato, verdadeiro para qualquer objeto que pode ser chamado. Funções são o exemplo mais simples, mas também podemos considerar métodos.

Suponha que tenhamos uma lista foo :

 foo = [1, 2, 3]

foo agora tem vários métodos anexados a ele, como .append() e .count() . Esses “métodos vinculados” podem ser passados ​​e usados ​​como funções:

 > >> appendtofoo = foo.append > >> appendtofoo(4) > >> foo [1, 2, 3, 4]

Além desses métodos de instância, existem outros tipos de objetos que podem ser chamados - staticmethods e classmethods , instâncias de classes que implementam __call__ e classes/tipos em si.

Classes como parâmetros

Em Python, as classes são de “primeira classe”—são objetos de tempo de execução como dicts, strings, etc. Isso pode parecer ainda mais estranho do que funções serem objetos, mas felizmente, é mais fácil demonstrar esse fato do que para funções.

A instrução class com a qual você está familiarizado é uma boa maneira de criar classes, mas não é a única maneira - também podemos usar a versão de três argumentos do tipo. As duas declarações a seguir fazem exatamente a mesma coisa:

 class Foo: pass Foo = type('Foo', (), {})

Na segunda versão, observe as duas coisas que acabamos de fazer (que são feitas de forma mais conveniente usando a instrução class):

  1. No lado direito do sinal de igual, criamos uma nova classe, com um nome interno de Foo . Este é o nome que você receberá de volta se fizer Foo.__name__ .
  2. Com a atribuição, criamos um nome no escopo atual, Foo, que se refere ao objeto de classe que acabamos de criar.

Fizemos as mesmas observações para o que a instrução de função faz.

O insight chave aqui é que as classes são objetos que podem receber nomes (ou seja, podem ser colocados em uma variável). Em qualquer lugar que você vê uma classe em uso, você está apenas vendo uma variável em uso. E se for uma variável, pode ser um parâmetro.

Podemos dividir isso em vários usos:

Classes como fábricas

Uma classe é um objeto que pode ser chamado que cria uma instância de si mesmo:

 > >> class Foo: ... pass > >> Foo() <__main__.Foo at 0x7f73e0c96780>

E como objeto, pode ser atribuído a outras variáveis:

 > >> myclass = Foo > >> myclass() <__main__.Foo at 0x7f73e0ca93c8>

Voltando ao nosso exemplo de tartaruga acima, um problema com o uso de tartarugas para desenhar é que a posição e orientação do desenho dependem da posição e orientação atuais da tartaruga, e também pode deixá-la em um estado diferente que pode ser inútil para o chamador. Para resolver isso, nossa função draw_square pode criar sua própria tartaruga, movê-la para a posição desejada e então desenhar um quadrado:

 def draw_square(x, y, size): turtle = Turtle() turtle.penup() # Don't draw while moving to the start position turtle.goto(x, y) turtle.pendown() for i in range(0, 4): turtle.forward(size) turtle.left(90)

No entanto, agora temos um problema de personalização. Suponha que o chamador queira definir alguns atributos da tartaruga ou usar um tipo diferente de tartaruga que tenha a mesma interface, mas tenha algum comportamento especial?

Poderíamos resolver isso com injeção de dependência, como fizemos antes - o chamador seria responsável por configurar o objeto Turtle . Mas e se nossa função às vezes precisar fazer muitas tartarugas para diferentes propósitos de desenho, ou se talvez ela queira lançar quatro fios, cada um com sua própria tartaruga para desenhar um lado do quadrado? A resposta é simplesmente fazer da classe Turtle um parâmetro para a função. Podemos usar um argumento de palavra-chave com um valor padrão, para manter as coisas simples para chamadores que não se importam:

 def draw_square(x, y, size, make_turtle=Turtle): turtle = make_turtle() turtle.penup() turtle.goto(x, y) turtle.pendown() for i in range(0, 4): turtle.forward(size) turtle.left(90)

Para usar isso, poderíamos escrever uma função make_turtle que cria uma tartaruga e a modifica. Suponha que queremos esconder a tartaruga ao desenhar quadrados:

 def make_hidden_turtle(): turtle = Turtle() turtle.hideturtle() return turtle draw_square(5, 10, 20, make_turtle=make_hidden_turtle)

Ou podemos criar uma subclasse Turtle para criar esse comportamento e passar a subclasse como parâmetro:

 class HiddenTurtle(Turtle): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.hideturtle() draw_square(5, 10, 20, make_turtle=HiddenTurtle)

Em outros idiomas…

Várias outras linguagens OOP, como Java e C#, não possuem classes de primeira classe. Para instanciar uma classe, você deve usar a palavra-chave new seguida por um nome de classe real.

Essa limitação é a razão de padrões como abstract factory (que requer a criação de um conjunto de classes cujo único trabalho é instanciar outras classes) e o padrão Factory Method. Como você pode ver, em Python, é apenas uma questão de retirar a classe como parâmetro porque uma classe é sua própria fábrica.

Classes como Classes Base

Suponha que nos encontremos criando subclasses para adicionar o mesmo recurso a diferentes classes. Por exemplo, queremos uma subclasse Turtle que será gravada em um log quando for criada:

 import logging logger = logging.getLogger() class LoggingTurtle(Turtle): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) logger.debug("Turtle got created")

Mas então, nos encontramos fazendo exatamente a mesma coisa com outra classe:

 class LoggingHippo(Hippo): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) logger.debug("Hippo got created")

As únicas coisas que variam entre esses dois são:

  1. A classe básica
  2. O nome da subclasse—mas não nos importamos com isso e poderíamos gerá-lo automaticamente a partir do atributo __name__ da classe base.
  3. O nome usado dentro da chamada de debug - mas, novamente, podemos gerar isso a partir do nome da classe base.

Diante de dois bits de código muito semelhantes com apenas uma variante, o que podemos fazer? Assim como em nosso primeiro exemplo, criamos uma função e extraímos a parte variante como parâmetro:

 def make_logging_class(cls): class LoggingThing(cls): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) logger.debug("{0} got created".format(cls.__name__)) LoggingThing.__name__ = "Logging{0}".format(cls.__name__) return LoggingThing LoggingTurtle = make_logging_class(Turtle) LoggingHippo = make_logging_class(Hippo)

Aqui, temos uma demonstração de classes de primeira classe:

  • Passamos uma classe para uma função, dando ao parâmetro um nome convencional cls para evitar o conflito com a palavra-chave class (você também verá class_ e klass usados ​​para esse propósito).
  • Dentro da função, criamos uma classe - observe que cada chamada para essa função cria uma nova classe.
  • Retornamos essa classe como o valor de retorno da função.

Também configuramos LoggingThing.__name__ que é totalmente opcional, mas pode ajudar na depuração.

Outra aplicação dessa técnica é quando temos um monte de recursos que às vezes queremos adicionar a uma classe e podemos querer adicionar várias combinações desses recursos. Criar manualmente todas as combinações diferentes de que precisamos pode ser muito complicado.

Em linguagens em que as classes são criadas em tempo de compilação em vez de em tempo de execução, isso não é possível. Em vez disso, você deve usar o padrão decorador. Esse padrão pode ser útil às vezes em Python, mas principalmente você pode usar a técnica acima.

Normalmente, evito criar muitas subclasses para customização. Normalmente, existem métodos mais simples e mais Pythonic que não envolvem classes. Mas esta técnica está disponível se você precisar. Veja também o tratamento completo de Brandon Rhodes do padrão decorador em Python.

Classes como exceções

Outro lugar onde você vê classes sendo usadas é na cláusula except de uma instrução try/except/finally. Sem surpresas para adivinhar que podemos parametrizar essas classes também.

Por exemplo, o código a seguir implementa uma estratégia muito genérica de tentar uma ação que pode falhar e tentar novamente com backoff exponencial até que um número máximo de tentativas seja alcançado:

 import time def retry_with_backoff(action, exceptions_to_catch, max_attempts=10, attempts_so_far=0): try: return action() except exceptions_to_catch: attempts_so_far += 1 if attempts_so_far >= max_attempts: raise else: time.sleep(attempts_so_far ** 2) return retry_with_backoff(action, exceptions_to_catch, attempts_so_far=attempts_so_far, max_attempts=max_attempts)

Retiramos a ação a ser executada e as exceções a serem capturadas como parâmetros. O parâmetro exceptions_to_catch pode ser uma única classe, como IOError ou httplib.client.HTTPConnectionError , ou uma tupla dessas classes. (Queremos evitar cláusulas “bare except” ou mesmo except Exception porque isso é conhecido por ocultar outros erros de programação).

Avisos e Conclusão

A parametrização é uma técnica poderosa para reutilizar código e reduzir a duplicação de código. Não é sem alguns inconvenientes. Na busca pela reutilização de código, vários problemas geralmente surgem:

  • Código excessivamente genérico ou abstrato que se torna muito difícil de entender.
  • Código com uma proliferação de parâmetros que obscurece o quadro geral ou introduz bugs porque, na realidade, apenas certas combinações de parâmetros são testadas adequadamente.
  • Acoplamento inútil de diferentes partes da base de código porque seu “código comum” foi fatorado em um único lugar. Às vezes, o código em dois locais é semelhante apenas acidentalmente, e os dois locais devem ser independentes um do outro, pois podem precisar ser alterados independentemente.

Às vezes, um pouco de código “duplicado” é muito melhor do que esses problemas, então use essa técnica com cuidado.

Neste post, abordamos padrões de design conhecidos como injeção de dependência , estratégia , método de modelo , fábrica abstrata , método de fábrica e decorador . Em Python, muitos deles realmente se tornam uma simples aplicação de parametrização ou são definitivamente desnecessários pelo fato de que parâmetros em Python podem ser objetos ou classes que podem ser chamados. Felizmente, isso ajuda a aliviar a carga conceitual de “coisas que você deveria saber como um desenvolvedor Python real” e permite que você escreva um código Pythonic conciso!

Leitura adicional:

  • Padrões de design Python: para código elegante e moderno
  • Padrões Python: para padrões de design Python
  • Log do Python: um tutorial aprofundado