Grande classe avec une seule responsabilité

13

J'ai une Characterclasse de 2500 lignes qui:

  • Suit l'état interne du personnage dans le jeu.
  • Charge et persiste cet état.
  • Gère ~ 30 commandes entrantes (généralement = les transmet au Game, mais certaines commandes en lecture seule sont traitées immédiatement).
  • Reçoit environ 80 appels Gameconcernant les actions qu'il prend et les actions pertinentes des autres.

Il me semble que cela Charactera une seule responsabilité: gérer l'état du personnage, faire la médiation entre les commandes entrantes et le jeu.

Il y a quelques autres responsabilités qui ont déjà été réparties:

  • Characterpossède un Outgoingfichier qu'il appelle pour générer des mises à jour sortantes pour l'application cliente.
  • Charactera un Timerqui suit quand il est ensuite autorisé à faire quelque chose. Les commandes entrantes sont validées par rapport à cela.

Ma question est donc la suivante: est-il acceptable d'avoir une classe aussi importante sous SRP et principes similaires? Existe-t-il des meilleures pratiques pour le rendre moins lourd (par exemple, peut-être en divisant les méthodes en fichiers séparés)? Ou est-ce que je manque quelque chose et y a-t-il vraiment un bon moyen de le diviser? Je me rends compte que c'est assez subjectif et j'aimerais avoir des commentaires des autres.

Voici un exemple:

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)
user35358
la source
1
Je suppose que c'est une faute de frappe: db.save_character_data(self, health, self.successful_attacks, self.points)vous vouliez dire self.healthnon?
candied_orange
5
Si votre personnage reste au niveau d'abstraction correct, je ne vois aucun problème. Si d'un autre côté, il gère vraiment tous les détails du chargement et de la persistance, alors vous ne suivez pas la responsabilité unique. La délégation est vraiment la clé ici. Voyant que votre personnage connaît certains détails de bas niveau comme la minuterie et autres, j'ai l'impression qu'il en sait déjà trop.
Philip Stuyck
1
La classe doit fonctionner sur un seul niveau d'abstraction. Il ne devrait pas entrer dans les détails, par exemple pour stocker l'état. Vous devriez pouvoir décomposer des morceaux plus petits responsables des composants internes. Le modèle de commande peut être utile ici. Voir également google.pl/url?sa=t&source=web&rct=j&url=http://…
Piotr Gwiazda
Merci à tous pour les commentaires et réponses. Je pense que je ne décomposais pas assez les choses et que je m'accrochais à en garder trop dans les grandes classes nébuleuses. Jusqu'à présent, l'utilisation du modèle de commande a été d'une grande aide. J'ai également divisé des choses en couches qui opèrent à différents niveaux d'abstraction (par exemple socket, messages de jeu, commandes de jeu). Je fais des progrès!
user35358
1
Une autre façon de résoudre ce problème est d'avoir "CharacterState" en tant que classe, "CharacterInputHandler" en tant qu'autre, "CharacterPersistance" en tant qu'autre ...
T. Sar

Réponses:

14

Dans mes tentatives d'appliquer SRP à un problème, je trouve généralement qu'un bon moyen de s'en tenir à une responsabilité unique par classe est de choisir des noms de classe qui font référence à leur responsabilité, car cela aide souvent à réfléchir plus clairement à la question de savoir si une fonction vraiment «appartient» dans cette classe.

En outre, je pense que les noms simples tels que Character(ou Employee, Person, Car, Animal, etc.) DÉCLARE souvent très pauvres noms de classe parce qu'ils décrivent vraiment les entités (données) dans votre application, et quand ils sont traités comme des classes , il est souvent trop facile de se retrouver avec quelque chose de très gonflé.

Je trouve que les `` bons '' noms de classe ont tendance à être des étiquettes qui transmettent de manière significative un aspect du comportement de votre programme - c'est-à-dire que lorsqu'un autre programmeur voit le nom de votre classe, il a déjà une idée de base sur le comportement / la fonctionnalité de cette classe.

En règle générale, j'ai tendance à considérer les entités comme des modèles de données et les classes comme des représentants du comportement. (Bien que la plupart des langages de programmation utilisent bien sûr un classmot - clé pour les deux, mais l'idée de garder les entités «simples» séparées du comportement de l'application est indépendante du langage)

Étant donné la répartition des différentes responsabilités que vous avez mentionnées pour votre classe de personnage, je commencerais à m'orienter vers des classes dont les noms sont basés sur l'exigence qu'ils remplissent. Par exemple:

  • Considérez une CharacterModelentité qui n'a aucun comportement et maintient simplement l'état de vos personnages (contient des données).
  • Pour la persistance / IO, considérez des noms tels que CharacterReaderet CharacterWriter (ou peut-être un CharacterRepository/ CharacterSerialiser/ etc).
  • Pensez au type de modèles qui existent entre vos commandes; si vous avez 30 commandes, vous avez potentiellement 30 responsabilités distinctes; dont certains peuvent se chevaucher, mais ils semblent être un bon candidat pour la séparation.
  • Demandez-vous si vous pouvez également appliquer le même refactoring à vos actions - encore une fois, 80 actions peuvent suggérer jusqu'à 80 responsabilités distinctes, également éventuellement avec un certain chevauchement.
  • La séparation des commandes et des actions peut également conduire à une autre classe qui est responsable de l'exécution / du déclenchement de ces commandes / actions; peut-être une sorte de CommandBrokerou ActionBrokerqui se comporte comme le "middleware" de votre application envoyant / recevant / exécutant ces commandes et actions entre différents objets

Souvenez-vous également que tout ce qui concerne le comportement ne doit pas nécessairement exister dans le cadre d'une classe; par exemple, vous pourriez envisager d'utiliser une carte / un dictionnaire de pointeurs / délégués / fermetures de fonctions pour encapsuler vos actions / commandes plutôt que d'écrire des dizaines de classes à méthode unique sans état.

Il est assez courant de voir des solutions de «modèle de commande» sans écrire de classes construites à l'aide de méthodes statiques partageant une signature / interface:

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

Enfin, il n'y a pas de règles strictes et rapides quant à la distance à parcourir pour atteindre la responsabilité unique. La complexité pour la complexité n'est pas une bonne chose, mais les classes mégalithiques ont tendance à être assez complexes en elles-mêmes. L'un des principaux objectifs de SRP et des autres principes SOLID est de fournir une structure, une cohérence et de rendre le code plus facile à gérer - ce qui se traduit souvent par quelque chose de plus simple.

Ben Cottrell
la source
Je pense que cette réponse a abordé le nœud de mon problème, merci. Je l'ai utilisé pour refactoriser des parties de mon application et les choses semblent beaucoup plus propres jusqu'à présent.
user35358
1
Vous devez faire attention aux modèles anémiques , il est parfaitement acceptable que le modèle de personnage ait un comportement comme Walk, Attacket Duck. Ce qui ne va pas, c'est d'avoir Saveet Load(persistance). SRP indique qu'une classe ne devrait avoir qu'une seule responsabilité, mais la responsabilité du personnage est d'être un personnage, pas un conteneur de données.
Chris Wohlert
1
@ChrisWohlert C'est la raison du nom CharacterModel, dont la responsabilité est d'être un conteneur de données pour dissocier les préoccupations de la couche de données de la couche de logique applicative. Il peut en effet toujours être souhaitable qu'une Characterclasse comportementale existe aussi, mais avec 80 actions et 30 commandes, je pencherais pour la décomposer davantage. La plupart du temps, je trouve que les noms d'entité sont un "hareng rouge" pour les noms de classe car il est difficile d'extrapoler la responsabilité d'un nom d'entité, et il est trop facile pour eux de se transformer en une sorte de couteau de l'armée suisse.
Ben Cottrell
10

Vous pouvez toujours utiliser une définition plus abstraite d'une «responsabilité». Ce n'est pas un très bon moyen de juger de ces situations, du moins jusqu'à ce que vous ayez beaucoup d'expérience dans ce domaine. Remarquez que vous avez facilement créé quatre puces, que j'appellerais un meilleur point de départ pour la granularité de votre classe. Si vous suivez vraiment le SRP, il est difficile de faire des puces comme ça.

Une autre façon est de regarder les membres de votre classe et de les séparer en fonction des méthodes qui les utilisent réellement. Par exemple, créez une classe de toutes les méthodes qui utilisent réellement self.timer, une autre classe de toutes les méthodes qui utilisent réellement self.outgoinget une autre classe du reste. Faites une autre classe de vos méthodes qui prennent une référence db comme argument. Lorsque vos classes sont trop grandes, il existe souvent des regroupements comme celui-ci.

N'ayez pas peur de le diviser plus petit que vous ne le pensez comme une expérience. C'est à cela que sert le contrôle de version. Le bon point d'équilibre est beaucoup plus facile à voir après l'avoir poussé trop loin.

Karl Bielefeldt
la source
3

La définition de la «responsabilité» est notoirement vague, mais elle devient un peu moins vague si vous la considérez comme une «raison de changer». Encore vague, mais quelque chose que vous pouvez analyser un peu plus directement. Les raisons du changement dépendent de votre domaine et de la façon dont votre logiciel sera utilisé, mais les jeux sont de bons exemples, car vous pouvez faire des hypothèses raisonnables à ce sujet. Dans votre code, je compte cinq responsabilités différentes dans les cinq premières lignes:

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

Votre implémentation changera si les exigences du jeu changent de l'une des manières suivantes:

  1. La notion de ce qui constitue un «jeu» change. C'est peut-être le moins probable.
  2. Comment vous mesurez et suivez les changements des points de vie
  3. Votre système d'attaque change
  4. Votre système de points change
  5. Votre système de chronométrage change

Vous chargez à partir de bases de données, résolvez des attaques, créez des liens avec des jeux, chronométrez des choses; il me semble que la liste des responsabilités est déjà très longue, et nous n'avons vu qu'une petite partie de votre Characterclasse. La réponse à une partie de votre question est donc non: votre classe ne suit certainement pas SRP.

Cependant, je dirais qu'il y a des cas où il est acceptable sous SRP d'avoir une classe de 2 500 lignes ou plus. Quelques exemples pourraient être:

  • Un calcul mathématique très complexe mais bien défini qui prend une entrée bien définie et renvoie une sortie bien définie. Il peut s'agir d'un code hautement optimisé qui nécessite des milliers de lignes. Les méthodes mathématiques éprouvées pour des calculs bien définis n'ont pas beaucoup de raisons de changer.
  • Une classe qui agit comme un magasin de données, comme une classe qui n'a que yield return <N>pour les 10 000 premiers nombres premiers ou les 10 000 premiers mots anglais les plus courants. Il existe des raisons possibles pour lesquelles cette implémentation serait préférable à l'extraction à partir d'un magasin de données ou d'un fichier texte. Ces classes ont très peu de raisons de changer (par exemple, vous trouvez que vous avez besoin de plus de 10 000).
Carl Leth
la source
2

Chaque fois que vous travaillez contre une autre entité, vous pouvez introduire un troisième objet qui effectue la manipulation à la place.

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)

Ici, vous pouvez introduire un «AttackResolver» ou quelque chose du genre qui gère l'envoi et la collecte de statistiques. L'on_attaque ici concerne-t-il uniquement l'état des personnages, fait-il plus?

Vous pouvez également revoir l'état et vous demander si une partie de l'état que vous avez doit vraiment être sur le personnage. 'succès_attaque' sonne comme quelque chose que vous pourriez également suivre sur une autre classe.

Joppe
la source