Herança ou decoradores múltiplos em Python para comportamentos componíveis

5

Eu descobri recentemente (ou melhor, percebi como usar) a herança múltipla do Python, e estou com medo de que agora eu esteja usando isso nos casos em que não é um bom ajuste. Eu quero ter uma fonte de dados inicial ( NewsCacheDB , TwitterStream ) que seja transformada de várias maneiras ( Vectorize , SelectKBest , SelectPercentile ).

Eu me vi escrevendo o seguinte tipo de código ( Exemplo 1 ) (o código atual é um pouco mais complexo, mas a idéia é a mesma). O ponto é que, para ExperimentA e ExperimentB , posso definir exatamente o que é self.data , confiando apenas na herança de classes. Esta é realmente uma maneira útil de alcançar o comportamento desejado?

Eu também posso usar decoradores ( Exemplo 2 ). Usando os decoradores seria menos código.

Qual abordagem é preferível? Eu não estou procurando argumentos do tipo "Eu gosto de escrever decoradores melhor", mas sim argumentos sobre

  1. legibilidade
  2. manutenção
  3. testabilidade
  4. pythonicity (sim, é uma palavra).

EXEMPLO 1

class NewsCacheDB(object):
    """Play back cached news articles from a database""" 
    def __init__(self):
        super(NewsArticleCache, self).__init__()

    @property
    def data(self):
        # setup access to data base
        while db.isalive():
            yield db.next() # slight simplification here

class TwitterCacheDB(object):
    """Play back cached tweets from a database""" 
    def __init__(self):
        super(TwitterCache, self).__init__()

    @property
    def data(self):
        # setup access to data base
        while db.isalive():
            yield db.next() # slight simplification here

class TwitterStream(object):
    def __init__(self):
        super(TwitterStream, self).__init__()

    @property
    def data(self):
        # setup access to live twitter stream
        while stream.isalive():
            yield stream.next()

class Vectorize(object):
    """Turn raw data into numpy vectors"""
    def __init__(self):
        super(Vectorize, self).__init__()

    @property
    def data(self):
        for item in super(Vectorize, self).data:
            transformed = vectorize(item) # slight simplification here
            yield transformed

class SelectKBest(object):
    """Select K best features based on some metric"""
    def __init__(self):
        super(SelectKBest, self).__init__()

    @property
    def data(self):
        for item in super(SelectKBest, self).data:
            transformed = select_kbest(item)  # slight simplification here
            yield transformed

class SelectPercentile(object):
    """Select the top X percentile features based on some metric"""
    def __init__(self):
        super(SelectPercentile, self).__init__()

    @property
    def data(self):
        for item in super(SelectPercentile, self).data:
            transformed = select_kbest(item)  # slight simplification here
            yield transformed

class ExperimentA(SelectKBest, Vectorize, TwitterCacheDB):
    # lots of control code goes here

class ExperimentB(SelectKBest, Vectorize, NewsCacheDB):
    # lots of control code goes here

class ExperimentC(SelectPercentile, Vectorize, NewsCacheDB):
    # lots of control code goes here

EXEMPLO 2

def multiply(fn):
    def wrapped(self):
        return fn(self) * 2
    return wrapped


def twitter_cacheDB(fn):
    def wrapped(self):
        user, pass = fn(self)
        # setup access to data base
        while db.isalive():
            yield db.next() # slight simplification here
    return wrapped

def twitter_live(fn):
    def wrapped(self):
        user, pass = fn(self)
        # setup access to data base
        while stream.isalive():
            yield stream.next() # slight simplification here
    return wrapped

def news_cacheDB(fn):
    def wrapped(self):
        user, pass = fn(self)
        # setup access to data base
        while db.isalive():
            yield db.next() # slight simplification here
    return wrapped

def vectorize(fn):
    def wrapped(self):
        for item in fn():
            transformed = do_vectorize(item)  # slight simplification here
            yield transformed
    yield wrapped

def select_kbest(fn):
    def wrapped(self):
        for item in fn():
            transformed = do_selection(item)  # slight simplification here
            yield transformed
    yield wrapped

class ExperimentA():
    @property
    @select_kbest
    @vectorize
    @twitter_cacheDB
    def a(self):
        return 'me','123' # return user and pass to connect to DB

class ExperimentB():
    @property
    @select_kbest
    @vectorize
    @news_cacheDB
    def a(self):
        return 'me','123' # return user and pass to connect to DB
    
por Matti Lyra 22.03.2013 / 15:26
fonte

2 respostas

2

Menos código, desde que seja legível, é melhor que mais código

De um ponto de vista de tamanho de código, sempre uso a solução que requer a menor quantidade de código que ainda pode ser lido e mantido. Menos código significa menos chance de defeitos e menos código para manter.

A herança múltipla não é uma boa escolha para Composição

De um ponto de vista de design, eu não usaria várias heranças da maneira que você descreve pelos seguintes motivos:

  • sobrecarga de atributo / método

Você está mudando a maneira como o data está se comportando nas diferentes classes. Embora não viole diretamente o Princípio Aberto / Fechado da OO com a implementação inicial, quaisquer alterações no futuro tenha uma boa chance de modificar os comportamentos em um ou mais locais. Você também está contando com o comportamento puxado através de super , que só funcionará corretamente se você tiver as classes base ordenadas corretamente na definição da classe.

  • acoplamento apertado (vertical) frágil

Baseando-se na definição de classe para especificar a ordem correta de classes, crie um sistema frágil. É frágil porque você não pode escolher classes que possuem interfaces específicas definidas, você realmente precisa conhecer a lógica implementada para que as chamadas super sejam executadas na ordem correta. É também um acoplamento extremamente apertado como resultado. Como ele está usando a herança de classes, também obtemos o acoplamento vertical, que basicamente significa que há dependências implícitas não apenas em métodos individuais, mas potencialmente entre as diferentes camadas (classes).

  • várias armadilhas de herança

A herança múltipla em qualquer idioma geralmente tem muitas armadilhas. O Python faz algum trabalho para corrigir alguns problemas com a herança, no entanto, há várias maneiras de confundir acidentalmente a ordem de resolução do método (mro ) de classes. Essas armadilhas sempre existem, e elas também são uma das principais razões para evitar o uso de herança múltipla.

Alternativas

Alternativamente, eu deixaria a lógica específica da fonte de dados nas classes (ou seja, * _CacheDB). Em seguida, use o decorador ou a composição funcional para adicionar a lógica generalizada para aplicar automaticamente as transformações.

    
por 09.04.2013 / 10:09
fonte
2

Em geral, a herança é usada em excesso. Lembre-se: herança é toda sobre o relacionamento "é um"; modelagem de relações hierárquicas. Portanto, pergunte a si mesmo: "Um experimento é um SelectKBest?". Essa pergunta é sem sentido. SelectKBest nem nomeia nada; é uma frase imperativa (ou a interferência de uma). Mas digamos que você mudou o nome para algo como o TopSelector. Então, a questão torna-se "É um Experimento Um TopSelector?". Mais uma vez, isso não faz sentido (para mim). Sem saber mais sobre seu aplicativo, parece ser um erro categórico. Esses tipos não têm nada a ver um com o outro. Portanto, a herança é a coisa errada a ser usada.

Isso não significa que decoradores estão certos também.

Não tenho certeza do que são as melhores práticas de decorador. Eu ficaria desconfiado de empilhar muitos decoradores. Eu suponho que se eles fazem coisas que são totalmente independentes, então tudo bem. Por exemplo,

__all__ = []

def export(f):
   __all__.append(f.func_name)
   return f

def author(name):
    def decorate(f):
        f.author = name
        return f
    return decorate

@author('allyourcode')
@export
def SaveTheWorld():
    # Left as an exercise to the reader.

Uma coisa que nos diz que exportar e autor são independentes é que você pode aplicá-los em qualquer ordem, e o resultado é o mesmo: 'SaveTheWorld' é anexado a __all__ e SaveTheWorld.author == 'allyourcode'.

Parece que me lembro de ouvir que Guido gosta dessa regra: se os decoradores param de trabalhar quando você os aplica em uma ordem diferente, então é ruim.

No EXAMPLE2, a ordem é muito significativa; qualquer outro pedido, na melhor das hipóteses, lhe dará um comportamento diferente. Mais provavelmente, quebra.

O que você está tentando fazer é criar um pipeline. O Python tem um mecanismo muito simples para fazer isso: expressões de chamada. Veja como ficaria isso:

def a(self):
  return select_kbest(vectorize(twitter_cache(('me', '123'))))

Ou se essa linha ficar muito longa, use variáveis dummy (ainda use nomes significativos!) para armazenar resultados intermediários. Não tenha medo de ir com uma solução simples!

"Simples é melhor que complexo". - O Zen do Python

As pessoas dizem que a vantagem dos decoradores é que eles consolidam o conhecimento, reduzem a repetição, mas as funções regulares fazem a mesma coisa e (quando aplicável) são geralmente mais simples. Além disso, como você pode fazer foo (bar (...)) em uma única linha, isso pode (e geralmente) resultar em menos linhas. A principal diferença é que, com os decoradores, o código adicional passa antes da palavra-chave def em vez de depois. Isso é realmente uma vantagem? Eu costumo pensar não.

No caso de autor e exportação, as mesmas coisas não podem ser realizadas usando código no corpo de def, porque tal código não é executado até que a função seja chamada. Considerando que decoradores são executados quando a função é definida.

Eu acho que o logging está mais perto de cruzar a linha do território "uso impróprio de decoradores", mas acho que ainda está tudo bem: eles mudam de comportamento, mas a diferença é menor (por exemplo, não seria razoável quebrar nenhum teste existente) ) e você (geralmente) ainda obtém independência de pedido.

Os verificadores pré e pós-condição (por exemplo, arg 1 é do tipo Foo) aproximam-se ainda mais da linha (e talvez cruzam-na). Se apenas as boas ligações ocorrerem, elas não terão efeito, e você geralmente obtém a independência do pedido. Mas a mudança de comportamento é mais significativa do que apenas o registro.

Então, há o que eu chamo de "preparar" decoradores, que levam as coisas um passo adiante. Por exemplo,

def require_login(handler):
    @functools.wraps(handler)
    def decorated(request):
        session = decode_session(request.cookies['session'])
        if not session.user_is_logged_in:
            raise HttpError(403)

        # Warning: side effect!
        request.session = session
        return handler(request)
    return decorated

require_login é uma espécie de verificador de pré-condição, pois gera uma exceção se a entrada não atender a alguma condição. Mas também faz algum trabalho em nome do manipulador: ele define o atributo da sessão a pedido antes de encaminhar a solicitação ao manipulador. Isso faz com que require_login seja mais difícil de entender. A função original não recebe mais uma solicitação regular: ela recebe uma solicitação com uma sessão anexada. Além disso, a mesma coisa pode ser realizada sem decoradores:

def handle(request):
    session = require_login(request.cookies['session'])
    # if require_login did not raise an HttpError, then session must be that
    # of a logged in user. Proceed as before.

Tal como acontece com decoradores, esta solução requer apenas uma linha adicional, mas só usa tecnologia de chamada básica.

    
por 06.11.2014 / 21:47
fonte