Grande classe con una sola responsabilità


13

Ho una Characterclasse di 2500 linee che:

  • Tiene traccia dello stato interno del personaggio nel gioco.
  • Carica e persiste quello stato.
  • Gestisce ~ 30 comandi in arrivo (di solito = li inoltra a Game, ma alcuni comandi di sola lettura ricevono immediatamente una risposta).
  • Riceve ~ 80 chiamate dalle Gameazioni intraprese e dalle azioni pertinenti degli altri.

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

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

  • Characterha una Outgoingchiamata in cui chiama per generare aggiornamenti in uscita per l'applicazione client.
  • Characterha una Timertraccia che la prossima volta che gli è permesso fare qualcosa. I comandi in arrivo sono convalidati rispetto a questo.

Quindi la mia domanda è: è accettabile avere una classe così grande sotto SRP e principi simili? Esistono buone pratiche per renderlo meno ingombrante (ad es. Forse suddividere i metodi in file separati)? O mi sto perdendo qualcosa e c'è davvero un buon modo per dividerlo? Mi rendo conto che questo è abbastanza soggettivo e vorrei un 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)

1
Presumo che questo sia un refuso: db.save_character_data(self, health, self.successful_attacks, self.points)intendevi self.healthgiusto?
candied_orange,

5
Se il tuo personaggio rimane al livello di astrazione corretto, non vedo alcun problema. Se d'altra parte sta davvero gestendo tutti i dettagli del caricamento e della persistenza di sé, allora non stai seguendo la singola responsabilità. La delega è davvero la chiave qui. Vedendo che il tuo personaggio conosce alcuni dettagli di basso livello come il timer e simili, ho la sensazione che ne sappia già troppo.
Philip Stuyck,

1
La classe dovrebbe operare su un unico livello di astrazione. Non dovrebbe entrare nei dettagli della memorizzazione dello stato, ad esempio. Dovresti essere in grado di scomporre pezzi più piccoli responsabili degli interni. Il modello di comando potrebbe essere utile qui. Vedi anche google.pl/url?sa=t&source=web&rct=j&url=http://…
Piotr Gwiazda,

Grazie a tutti per i commenti e le risposte. Penso che non stavo decomposendo abbastanza le cose e mi stavo aggrappando a tenere troppo in grandi classi nebulose. L'uso del modello di comando è stato finora di grande aiuto. Ho anche diviso le cose in livelli che operano a diversi livelli di astrazione (es. Socket, messaggi di gioco, comandi di gioco). Sto facendo progressi!
user35358

1
Un altro modo per affrontarlo è avere "CharacterState" come classe, "CharacterInputHandler" come altro, "CharacterPersistance" come altro ...
T. Sar

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 quello di scegliere i nomi delle classi che alludono alla loro responsabilità, perché spesso aiuta a pensare più chiaramente se alcune funzioni 'appartiene' davvero a quella classe.

Inoltre, ritengo che semplici nomi quali Character(o Employee, Person, Car, Animal, etc.) spesso fanno i nomi di classe molto povere, perché davvero descrivono le entità (dati) nell'applicazione, e se trattati come classi è spesso troppo facile da finire con qualcosa di molto gonfio.

Trovo che i nomi di classe "buoni" tendano ad essere etichette che trasmettano in modo significativo alcuni aspetti del comportamento del tuo programma, vale a dire quando un altro programmatore vede il nome della tua classe, hanno già un'idea di base sul comportamento / funzionalità di quella classe.

Come regola empirica, tendo a considerare le Entità come modelli di dati e le Classi come rappresentanti del comportamento. (Anche se, naturalmente, la maggior parte dei linguaggi di programmazione utilizza una classparola chiave per entrambi, ma l'idea di mantenere le entità "semplici" separate dal comportamento dell'applicazione è neutra dal punto di vista linguistico)

Vista la suddivisione delle varie responsabilità che hai citato per la tua classe di personaggi, inizierei a inclinarmi verso le classi i cui nomi si basano sul requisito che soddisfano. Per esempio:

  • Considera CharacterModelun'entità che non ha alcun comportamento e mantiene semplicemente lo stato dei tuoi Personaggi (contiene dati).
  • Per persistenza / I / O, prendere in considerazione nomi come CharacterReadere CharacterWriter (o forse a CharacterRepository/ CharacterSerialiser/ etc).
  • Pensa a che tipo di schemi esistono tra i tuoi comandi; se hai 30 comandi allora hai potenzialmente 30 responsabilità separate; alcuni dei quali possono 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 responsabilità separate, anche possibilmente con qualche sovrapposizione.
  • La separazione di comandi e azioni potrebbe anche portare a un'altra classe che è responsabile dell'esecuzione / attivazione di tali comandi / azioni; forse una sorta di CommandBrokero ActionBrokerche si comporta come il "middleware" dell'applicazione che invia / riceve / esegue quei comandi e azioni tra oggetti diversi

Ricorda anche che non tutto ciò che riguarda il comportamento deve necessariamente esistere come parte di una classe; ad esempio, potresti prendere in considerazione l'uso di una mappa / dizionario di puntatori / delegati / chiusure di funzioni per incapsulare azioni / comandi piuttosto che scrivere dozzine di classi a stato unico senza metodo.

È abbastanza comune vedere le soluzioni 'modello di comando' senza scrivere alcuna classe creata 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 rigide e veloci su quanto dovresti andare per raggiungere la responsabilità singola. La complessità per il bene della complessità non è una buona cosa, ma le classi megalitiche tendono ad essere abbastanza complesse in se stesse. Un obiettivo chiave di SRP e in effetti gli altri principi SOLID è quello di fornire struttura, coerenza e rendere il codice più gestibile, il che si traduce spesso in qualcosa di più semplice.


Penso che questa risposta abbia affrontato il nocciolo del mio problema, grazie. L'ho utilizzato per il refactoring di parti della mia applicazione e finora le cose sembrano molto più pulite.
user35358

1
Bisogna stare attenti a modelli anemici , è perfettamente accettabile per il modello personaggio ad avere un comportamento simile Walk, Attacke Duck. Ciò che non va bene è avere Savee Load(persistenza). SRP afferma che una classe dovrebbe avere una sola responsabilità, ma la responsabilità del personaggio è quella di essere un personaggio, non un contenitore di dati.
Chris Wohlert,

1
@ChrisWohlert Questo è il motivo del nome CharacterModel, la cui responsabilità è quella di essere un contenitore di dati per separare le preoccupazioni del livello di dati dal livello di business logic. Potrebbe davvero essere desiderabile Characterche esista anche una classe comportamentale da qualche parte, ma con 80 azioni e 30 comandi mi spingerei ad abbatterlo ulteriormente. Il più delle volte trovo che i nomi delle entità siano una "aringa rossa" per i nomi delle classi perché è difficile estrapolare la responsabilità da un nome di entità, ed è fin troppo facile per loro trasformarsi in una specie di coltellino svizzero.
Ben Cottrell,

10

Puoi sempre usare una definizione più astratta di "responsabilità". Non è un ottimo modo per giudicare queste situazioni, almeno fino a quando non avrai molta esperienza. Nota che hai facilmente fatto quattro punti elenco, che definirei un punto di partenza migliore per la granularità della tua classe. Se stai davvero seguendo l'SRP, è difficile fare punti elenco del genere.

Un altro modo è quello di guardare i membri della tua classe e separarli in base ai metodi che li utilizzano effettivamente. Ad esempio, crea una classe da tutti i metodi che effettivamente utilizzano self.timer, un'altra classe da tutti i metodi che effettivamente utilizzano self.outgoinge un'altra classe dal resto. Crea un'altra classe dai tuoi metodi che prendono come riferimento un riferimento db. Quando le tue lezioni 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.


3

La definizione di "responsabilità" è notoriamente vaga, ma diventa leggermente meno vaga se la si considera come 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 il tuo software verrà utilizzato, ma i giochi sono dei bei casi di esempio perché puoi fare ipotesi ragionevoli al 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()

L'implementazione cambierà se i requisiti di gioco cambiano in uno di questi modi:

  1. La nozione di ciò che costituisce un "gioco" cambia. Questo potrebbe essere il meno probabile.
  2. Come misurare e tenere traccia delle modifiche ai punti di salute
  3. Il tuo sistema di attacco cambia
  4. Il sistema di punti cambia
  5. Il sistema di temporizzazione cambia

Stai caricando da database, risolvendo attacchi, collegandoti a giochi, cronometrando cose; mi sembra che l'elenco delle responsabilità sia già molto lungo e abbiamo visto solo una piccola parte della tua Characterclasse. 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 sotto SRP è accettabile 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 definiti. Questo potrebbe essere un codice altamente ottimizzato che richiede migliaia di righe. Metodi matematici comprovati per calcoli ben definiti non hanno molte ragioni per cambiare.
  • Una classe che funge da archivio dati, ad esempio una classe che contiene solo yield return <N>i primi 10.000 numeri primi o le prime 10.000 parole inglesi più comuni. Vi sono possibili ragioni per cui questa implementazione sarebbe preferibile rispetto al pull da un archivio dati o file di testo. Queste classi hanno pochissime ragioni per cambiare (ad esempio, ti accorgi che hai bisogno di oltre 10.000).

2

Ogni volta che lavori contro qualche altra entità potresti introdurre un terzo oggetto che fa invece la gestione.

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 del personaggio, sta facendo di più?

Puoi anche rivisitare lo stato e chiederti se parte dello stato che hai davvero deve essere sul personaggio. "successful_attack" suona come qualcosa che potresti potenzialmente rintracciare anche su qualche altra classe.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.