Python multiple ereditari o decoratori per comportamenti componibili

5

Recentemente ho scoperto (o meglio ho capito come usare) l'ereditarietà multipla di Python, e temo che ora lo sto usando nei casi in cui non è una buona idea. Voglio avere una sorgente di dati di partenza ( NewsCacheDB , TwitterStream ) che viene trasformata in vari modi ( Vectorize , SelectKBest , SelectPercentile ).

Mi sono ritrovato a scrivere il seguente tipo di codice ( Esempio 1 ) (il codice effettivo è un po 'più complesso ma l'idea è la stessa). Il punto è che per ExperimentA e ExperimentB posso definire esattamente che cosa self.data è, facendo semplicemente affidamento sull'ereditarietà delle classi. È davvero un modo utile per ottenere il comportamento desiderato?

Potrei anche usare decoratori ( Esempio 2 ). Usare i decoratori sarebbe meno codice.

Quale approccio è preferibile? Non sto cercando argomenti del tipo "Mi piace scrivere decoratori migliori", ma piuttosto argomenti su

  1. leggibilità
  2. manutenibilità
  3. testability
  4. pitonicità (sì, è una parola)

ESEMPIO 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

ESEMPIO 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
    
posta Matti Lyra 22.03.2013 - 15:26
fonte

2 risposte

2

Meno codice, purché sia leggibile è meglio di più codice

Da un punto di vista delle dimensioni del codice vado sempre con la soluzione che richiede la minor quantità di codice che sia ancora leggibile e mantenibile. Meno codice significa meno possibilità di difetti e meno codice da mantenere.

L'ereditarietà multipla non è una buona scelta per Composition

Dal punto di vista della progettazione, non utilizzerei l'ereditarietà multipla come descritto per i seguenti motivi:

  • attributo / overload di metodi

Stai cambiando il modo in cui data si sta comportando nelle diverse classi. Anche se non viola direttamente il Principio Open / Closed di OO con l'implementazione iniziale, qualsiasi modifica futura avere buone possibilità di modificare i comportamenti in uno o più luoghi. Stai anche facendo affidamento sul comportamento tirato attraverso super che funzionerà correttamente solo se hai le classi base ordinate correttamente nella definizione della classe.

  • accoppiamento fragile stretto (verticale)

Affidarsi alla definizione della classe per specificare l'ordine corretto delle classi crea un sistema fragile. È fragile perché non puoi scegliere classi con determinate interfacce definite, devi effettivamente conoscere la logica implementata in modo che le chiamate super vengano eseguite nell'ordine corretto. Di conseguenza è anche un accoppiamento estremamente stretto. Dal momento che utilizza l'ereditarietà di classe otteniamo anche un accoppiamento verticale che fondamentalmente significa che ci sono dipendenze implicite non solo nei singoli metodi, ma potenzialmente tra i diversi livelli (classi).

  • problemi di ereditarietà multipli

L'ereditarietà multipla in qualsiasi lingua ha spesso molte insidie. Python lavora per risolvere alcuni problemi con l'ereditarietà, tuttavia esistono numerosi modi per confondere involontariamente l'ordine di risoluzione dei metodi (mro ) di classi. Queste insidie esistono sempre e rappresentano anche una ragione primaria per evitare l'utilizzo dell'ereditarietà multipla.

Alternative

In alternativa lascerei la logica specifica dell'origine dati nelle classi (es. * _CacheDB). Quindi usa decoratore o composizione funzionale per aggiungere la logica generalizzata per applicare automaticamente le trasformazioni.

    
risposta data 09.04.2013 - 10:09
fonte
2

In generale, l'ereditarietà è sovrautilizzata. Ricorda: l'ereditarietà riguarda esclusivamente la relazione "è una"; modellizzazione delle relazioni gerarchiche. Pertanto, chiediti: "È un esperimento come SelectK Best?". Questa domanda è priva di senso. SelectKBest non nomina nemmeno una cosa; è una frase imperativa (o l'inceppamento di uno). Ma diciamo che hai cambiato il nome in qualcosa come TopSelector. Quindi, la domanda diventa "Is an ExperimentA a TopSelector?". Di nuovo, questo non ha senso (per me). Senza saperne di più sulla tua app, sembra essere un errore categoriale. Questi tipi non hanno nulla a che fare l'uno con l'altro. Pertanto, l'ereditarietà è la cosa sbagliata da usare.

Questo non significa che i decoratori abbiano ragione.

Non sono proprio sicuro quali siano le migliori pratiche di decoratore. Sarei diffidente nell'impilare molti decoratori. Suppongo che se fanno cose totalmente indipendenti, allora va bene. Per es.

__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.

Una cosa che ci dice che esportazione e autore sono indipendenti è che puoi applicarli in qualsiasi ordine, e il risultato è lo stesso: 'SaveTheWorld' viene aggiunto a __all__ e SaveTheWorld.author == 'allyourcode'.

Mi sembra di ricordare che a Guido piace questa regola: se i decoratori smettono di funzionare quando li applichi in un ordine diverso, allora è male.

In EXAMPLE2, l'ordine è molto significativo; qualsiasi altro ordine, nel migliore dei casi, ti darà un comportamento diverso. Più probabile, si rompe.

Quello che stai cercando di fare è creare una pipeline. Python ha un meccanismo molto semplice per farlo: chiamare le espressioni. Ecco come sarebbe:

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

O se quella linea cresce troppo a lungo, usa le variabili dummy (usa comunque nomi significativi!) per archiviare i risultati intermedi. Non aver paura di andare con una soluzione semplice!

"Semplice è meglio che complesso."
- The Zen of Python

La gente dice che il vantaggio dei decoratori è che consolidano la conoscenza, riducono la ripetizione, ma le funzioni regolari fanno la stessa cosa e (quando possibile) sono spesso più semplici. Inoltre, dato che puoi fare foo (barra (...)) su una singola riga, può (e di solito avviene) produrre meno righe. La differenza principale è che con i decoratori, il codice addizionale va prima della parola chiave def invece che dopo. E 'davvero un vantaggio? Tendo a non pensare.

Nel caso dell'autore e dell'esportazione, le stesse cose non possono essere compiute usando il codice nel corpo def, perché tale codice non viene eseguito fino a quando la funzione non viene chiamata. Mentre i decoratori vengono eseguiti quando la funzione è definita.

Penso che il logging sia più vicino a tagliare la linea in territorio "inapprorpiate use decorators", ma penso che sia ancora ok: essi cambiano comportamento, ma la differenza è minore (ad esempio non romperebbe ragionevolmente nessun test esistente ), e tu (in generale) ottieni ancora l'indipendenza dall'ordine.

I controllori di pre e post-condizione (ad esempio Arg 1 è di tipo Foo) si avvicinano ancora di più alla linea (e forse la attraversano). Se solo le buone chiamate si verificano, allora, non hanno alcun effetto e generalmente ottieni l'indipendenza dall'ordine. Ma il cambiamento di comportamento è più significativo della semplice registrazione.

Poi, ci sono quelli che io definisco "preparare" decoratori, che fanno un passo in più. Per es.

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 è un po 'come un precondizionatore, nel senso che solleva un'eccezione se l'input non riesce a soddisfare alcune condizioni. Ma funziona anche a nome del gestore: imposta l'attributo di sessione su richiesta prima di inoltrare la richiesta al gestore. Questo rende require_login più difficile da capire. La funzione originale non richiede più una richiesta regolare: richiede una richiesta con una sessione aggiunta. Inoltre, la stessa cosa può essere realizzata senza decoratori:

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.

Come per i decoratori, questa soluzione richiede solo una linea aggiuntiva, ma utilizza solo la tecnologia di chiamata di base.

    
risposta data 06.11.2014 - 21:47
fonte

Leggi altre domande sui tag