Grande classe con una sola responsabilità

13

Ho una classe pari a 2500 righe% co_de che:

  • Tiene traccia dello stato interno del personaggio nel gioco.
  • carica e persiste in questo stato.
  • Gestisce ~ 30 comandi in entrata (in genere = li inoltra a Character , ma alcuni comandi di sola lettura ricevono immediatamente risposta).
  • Riceve ~ 80 chiamate da Game relative alle azioni intraprese e alle azioni pertinenti di altri.

Mi sembra che Game abbia una sola responsabilità: gestire lo stato del personaggio, mediando tra i comandi in entrata e il Gioco.

Ci sono alcune altre responsabilità che sono già state scomposte:

  • Character ha un Character che chiama per generare aggiornamenti in uscita per l'applicazione client.
  • Outgoing ha un Character che tiene traccia di quando è consentito fare qualcosa. I comandi in arrivo sono convalidati contro questo.

Quindi la mia domanda è, è accettabile avere una classe così ampia sotto SRP e principi simili? Esistono buone pratiche per renderlo meno ingombrante (ad esempio, magari suddividere i metodi in file separati)? O mi sto perdendo qualcosa e c'è davvero un buon modo per dividerlo? Mi rendo conto che è piuttosto soggettivo e vorrei ricevere feedback dagli altri.

Ecco un esempio:

class Character(object):
    def __init__(self):
        self.game = None
        self.health = 1000
        self.successful_attacks = 0
        self.points = 0
        self.timer = Timer()
        self.outgoing = Outgoing(self)

    def load(self, db, id):
        self.health, self.successful_attacks, self.points = db.load_character_data(id)

    def save(self, db, id):
        db.save_character_data(self, health, self.successful_attacks, self.points)

    def handle_connect_to_game(self, game):
        self.game.connect(self)
        self.game = game
        self.outgoing.send_connect_to_game(game)

    def handle_attack(self, victim, attack_type):
        if time.time() < self.timer.get_next_move_time():
            raise Exception()
        self.game.request_attack(self, victim, attack_type)

    def on_attack(victim, attack_type, points):
        self.points += points
        self.successful_attacks += 1
        self.outgoing.send_attack(self, victim, attack_type)
        self.timer.add_attack(attacker=True)

    def on_miss_attack(victim, attack_type):
        self.missed_attacks += 1
        self.outgoing.send_missed_attack()
        self.timer.add_missed_attack()

    def on_attacked(attacker, attack_type, damage):
        self.start_defenses()
        self.take_damage(damage)
        self.outgoing.send_attack(attacker, self, attack_type)
        self.timer.add_attack(victim=True)

    def on_see_attack(attacker, victim, attack_type):
        self.outgoing.send_attack(attacker, victim, attack_type)
        self.timer.add_attack()


class Outgoing(object):
    def __init__(self, character):
        self.character = character
        self.queue = []

    def send_connect_to_game(game):
        self._queue.append(...)

    def send_attack(self, attacker, victim, attack_type):
        self._queue.append(...)

class Timer(object):
    def get_next_move_time(self):
        return self._next_move_time

    def add_attack(attacker=False, victim=False):
        if attacker:
            self.submit_move()
        self.add_time(ATTACK_TIME)
        if victim:
            self.add_time(ATTACK_VICTIM_TIME)

class Game(object):
    def connect(self, character):
        if not self._accept_character(character):
           raise Exception()
        self.character_manager.add(character)

    def request_attack(character, victim, attack_type):
        if victim.has_immunity(attack_type):
            character.on_miss_attack(victim, attack_type)
        else:
            points = self._calculate_points(character, victim, attack_type)
            damage = self._calculate_damage(character, victim, attack_type)
            character.on_attack(victim, attack_type, points)
            victim.on_attacked(character, attack_type, damage)
            for other in self.character_manager.get_observers(victim):
                other.on_see_attack(character, victim, attack_type)
    
posta user35358 27.01.2017 - 22:20
fonte

4 risposte

14

Nei miei tentativi di applicare SRP a un problema, in genere trovo che un buon modo per attenersi a una singola responsabilità per classe sia scegliere i nomi di classe che alludono alle loro responsabilità, perché spesso aiuta a pensare più chiaramente a se alcune funzioni realmente "appartengono" a quella classe.

Inoltre, sento che nomi semplici come Character (o Employee , Person , Car , Animal , ecc.) fanno spesso nomi di classi molto scarsi perché descrivono veramente entità (dati) nella tua applicazione, e quando trattate come classi è spesso troppo facile finire con qualcosa di molto gonfio.

Trovo che i nomi di classe "buoni" tendano ad essere etichette che trasmettono in modo significativo alcuni aspetti del comportamento del tuo programma - cioè quando un altro programmatore vede il nome della tua classe già acquisiscono un'idea di base sul comportamento / funzionalità di quella classe. / p>

Come regola generale, tendo a pensare a Entità come modelli di dati e Classi come rappresentanti del comportamento. (Sebbene ovviamente la maggior parte dei linguaggi di programmazione utilizzi una parola chiave class per entrambi, ma l'idea di mantenere le entità "semplici" separate dal comportamento dell'applicazione è neutrale rispetto alla lingua)

Data la ripartizione delle varie responsabilità che hai menzionato per la tua classe di personaggio, inizierei ad orientarmi verso classi i cui nomi sono basati sul requisito che soddisfano. Ad esempio:

  • Considera un'entità CharacterModel che non ha comportamenti e mantiene semplicemente lo stato dei tuoi caratteri (trattieni i dati).
  • Per persistenza / IO, considera nomi come CharacterReader e CharacterWriter (o forse un CharacterRepository / CharacterSerialiser / etc).
  • Pensa a quale tipo di schemi esistono tra i tuoi comandi; se hai 30 comandi allora hai potenzialmente 30 responsabilità separate; alcuni dei quali potrebbero sovrapporsi, ma sembrano un buon candidato per la separazione.
  • Considera se puoi applicare lo stesso refactoring anche alle tue azioni: ancora una volta, 80 azioni potrebbero suggerire fino a 80 diverse responsabilità, anche eventualmente con qualche sovrapposizione.
  • La separazione di comandi e azioni potrebbe anche portare a un'altra classe che è responsabile dell'esecuzione / attivazione di quei comandi / azioni; forse una sorta di CommandBroker o ActionBroker che si comporta come il "middleware" dell'applicazione che invia / riceve / esegue quei comandi e azioni tra diversi oggetti

Ricorda inoltre che non tutto ciò che riguarda il comportamento deve necessariamente esistere come parte di una classe; per esempio, potresti prendere in considerazione l'utilizzo di una mappa / dizionario di puntatori / delegati / chiusure di funzioni per incapsulare le tue azioni / comandi piuttosto che scrivere dozzine di classi di metodi mono-stateless.

È abbastanza comune vedere le soluzioni di "command pattern" senza scrivere classi costruite usando metodi statici che condividono una firma / interfaccia:

 void AttackAction(CharacterModel) { ... }
 void ReloadAction(CharacterModel) { ... }
 void RunAction(CharacterModel) { ... }
 void DuckAction(CharacterModel) { ... }
 // etc.

Infine, non ci sono regole ferree su quanto dovresti arrivare per ottenere una singola responsabilità. La complessità per motivi di complessità non è una buona cosa, ma le classi megalitiche tendono ad essere abbastanza complesse in se stesse. Un obiettivo chiave dell'SRP e in effetti degli altri principi SOLID è fornire struttura, coerenza e rendere il codice più gestibile, il che spesso si traduce in qualcosa di più semplice.

    
risposta data 28.01.2017 - 13:52
fonte
10

Puoi sempre usare una definizione più astratta di "responsabilità". Non è un buon modo per giudicare queste situazioni, almeno finché non hai molta esperienza in merito. Nota che hai facilmente creato quattro punti elenco, che definirei un punto di partenza migliore per la granularità della tua classe. Se segui veramente l'SRP, è difficile creare punti elenco del genere.

Un altro modo è quello di guardare i membri della tua classe e scindere in base ai metodi che effettivamente li usano. Ad esempio, crea una classe tra tutti i metodi che effettivamente usano self.timer , un'altra classe tra tutti i metodi che effettivamente usano self.outgoing e un'altra classe fuori dal resto. Crea un'altra classe con i tuoi metodi che prendono come riferimento un riferimento a db. Quando le tue classi sono troppo grandi, ci sono spesso raggruppamenti come questo.

Non aver paura di dividerlo più piccolo di quanto pensi sia ragionevole come esperimento. Ecco a cosa serve il controllo della versione. Il giusto punto di equilibrio è molto più facile da vedere dopo averlo portato troppo lontano.

    
risposta data 28.01.2017 - 00:02
fonte
3

La definizione di "responsabilità" è notoriamente vaga, ma diventa leggermente meno vaga se la si considera una "ragione per cambiare". Ancora vago, ma qualcosa che puoi analizzare un po 'più direttamente. Le ragioni del cambiamento dipendono dal tuo dominio e dal modo in cui verrà utilizzato il tuo software, ma i giochi sono dei casi esemplari perché puoi formulare ipotesi ragionevoli a riguardo. Nel tuo codice conto cinque diverse responsabilità nelle prime cinque righe:

self.game = None
self.health = 1000
self.successful_attacks = 0
self.points = 0
self.timer = Timer()

La tua implementazione cambierà se i requisiti di gioco cambiano in uno dei seguenti modi:

  1. La nozione di cosa costituisce un "gioco" cambia. Questo potrebbe essere il meno probabile.
  2. Come misuri e traccia le modifiche dei punti vita
  3. Il tuo sistema di attacco cambia
  4. Il tuo sistema di punti cambia
  5. Il tuo sistema di cronometraggio cambia

Stai caricando dai database, risolvendo gli attacchi, collegandoti con i giochi, cronometrando le cose; mi sembra che l'elenco delle responsabilità sia già molto lungo e abbiamo visto solo una piccola parte della tua classe Character . Quindi la risposta a una parte della tua domanda è no: la tua classe quasi certamente non segue SRP.

Tuttavia, direi che ci sono casi in cui è accettabile in SRP avere una classe di 2.500 righe o più. Alcuni esempi potrebbero essere:

  • Un calcolo matematico molto complesso ma ben definito che accetta input ben definiti e restituisce output ben definito. Questo potrebbe essere un codice altamente ottimizzato che richiede migliaia di righe. Metodi matematici comprovati per calcoli ben definiti non hanno molti motivi per cambiare.
  • Una classe che funge da archivio dati, ad esempio una classe che ha appena yield return <N> per i primi 10.000 numeri primi, o le prime 10.000 parole inglesi più comuni. Ci sono possibili ragioni per cui questa implementazione sarebbe preferibile rispetto alla estrazione da un archivio dati o da un file di testo. Queste classi hanno pochissimi motivi per cambiare (ad esempio, trovi che hai bisogno di più di 10.000).
risposta data 02.02.2017 - 08:05
fonte
2

Ogni volta che lavori contro un'altra entità, puoi introdurre un terzo oggetto che invece gestisce invece.

def on_attack(victim, attack_type, points):
    self.points += points
    self.successful_attacks += 1
    self.outgoing.send_attack(self, victim, attack_type)
    self.timer.add_attack(attacker=True)

Qui puoi introdurre un "AttackResolver" o qualcosa del genere che gestisce l'invio e la raccolta di statistiche. L'on_attack qui riguarda solo lo stato dei caratteri? Sta facendo di più?

Puoi anche rivisitare lo stato e chiediti se parte dello stato di cui hai veramente bisogno sia nel personaggio. 'successful_attack' suona come qualcosa che potresti potenzialmente tracciare anche su qualche altra classe.

    
risposta data 28.01.2017 - 11:09
fonte

Leggi altre domande sui tag