Великий клас з єдиною відповідальністю


13

У мене Characterклас 2500 ліній, який:

  • Відстежує внутрішній стан персонажа в грі.
  • Навантажує та зберігає цю державу.
  • Обробляє ~ 30 вхідних команд (як правило, = пересилає їх до Game, але на деякі команди лише для читання відповідають негайно).
  • Отримує ~ 80 дзвінків Gameстосовно дій, які він вживає, та відповідних дій інших осіб.

Мені здається, Characterє одна відповідальність: керувати станом персонажа, опосередковуючи вхідні команди та Гру.

Є кілька інших обов'язків, які вже були розбиті:

  • Characterмає він, на Outgoingякий він закликає генерувати вихідні оновлення для клієнтської програми.
  • Characterмає, Timerякий слід, коли наступне дозволено щось робити. Команди вхідних даних підтверджені проти цього.

Отже, моє запитання: чи допустимо мати такий великий клас за СРП та подібними принципами? Чи є найкращі практики для того, щоб зробити його менш громіздким (наприклад, можливо, способи розділення на окремі файли)? Або я щось пропускаю і чи дійсно є хороший спосіб розділити це? Я усвідомлюю, що це досить суб'єктивно, і хотів би отримати відгуки інших.

Ось зразок:

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
Я припускаю, що це помилка: db.save_character_data(self, health, self.successful_attacks, self.points)ви мали на увазі self.healthправильно?
candied_orange

5
Якщо ваш персонаж залишається на правильному рівні абстракції, я не бачу проблем. Якщо, з іншого боку, він справді обробляє всі деталі, скажімо, завантаження та збереження себе, то ви не несете єдиної відповідальності. Тут дійсно ключова делегація. Бачачи, що ваш персонаж знає про такі деталі низького рівня, як таймер тощо, я відчуваю, що це вже занадто багато знає.
Філіп Стюйк

1
Клас повинен працювати на одному рівні абстракції. Він не повинен вдаватися до деталей, наприклад, про збереження держави. Ви повинні мати можливість розкласти менші шматки, відповідальні за внутрішні. Тут може бути корисний шаблон команди. Також дивіться google.pl/url?sa=t&source=web&rct=j&url=http://…
Piotr Gwiazda

Дякую всім за коментарі та відповіді. Я думаю, що я просто не розкладав речі досить, і чіплявся за те, щоб утримувати занадто багато у великих туманних заняттях. Використання шаблону команд досі було великою допомогою. Я також розбивав речі на шари, які працюють на різних рівнях абстракції (наприклад, сокет, ігрові повідомлення, ігрові команди). Я прогресую!
користувач35358

1
Інший спосіб вирішити це - мати "CharacterState" як клас, "CharacterInputHandler" як інший, "CharacterPersistance" як інший ...
Т. Сар

Відповіді:


14

У своїх спробах застосувати SRP до проблеми, я, як правило, вважаю, що хорошим способом дотримуватися єдиної відповідальності за клас є вибір назв класів, які натякають на їх відповідальність, оскільки це часто допомагає чіткіше думати про те, чи є якась функція дійсно "належить" до цього класу.

Крім того, я відчуваю , що прості іменники , такі як Character(або Employee, Person, Car, Animalі т.д.) часто роблю дуже погані імена класів , тому що вони дійсно описують об'єкти (дані) в додатку, і коли розглядаються як класи часто буває занадто легко в кінцевому підсумку щось дуже роздуте.

Я вважаю, що назви «хороших» класів, як правило, є мітками, які значимо передають певний аспект поведінки вашої програми, тобто, коли інший програміст бачить ім’я вашого класу, вони вже отримують основне уявлення про поведінку / функціональність цього класу.

Як правило, я схильний вважати Сутності як моделі даних, а Класи - як представники поведінки. (Хоча, звичайно, більшість мов програмування використовують classключове слово для обох, але ідея зберігати "прості" об'єкти відокремленими від поведінки програми є нейтральною для мови)

Враховуючи розподіл різних обов'язків, які ви згадали для свого класу персонажів, я б почав схилятися до класів, назви яких засновані на вимозі, яку вони виконують. Наприклад:

  • Розглянемо CharacterModelсутність, яка не має поведінки і просто підтримує стан своїх персонажів (містить дані).
  • Для стійкості / IO врахуйте такі назви, як CharacterReaderі CharacterWriter (або, можливо, CharacterRepository/ CharacterSerialiser/ тощо).
  • Подумайте, які шаблони існують між вашими командами; якщо у вас є 30 команд, то потенційно у вас є 30 окремих обов'язків; деякі з яких можуть перетинатися, але вони здаються хорошим кандидатом на розлуку.
  • Поміркуйте, чи можете ви застосувати те саме рефакторинг до своїх дій - знову ж таки, 80 дій можуть запропонувати до 80 окремих обов'язків, можливо, з певним збігом.
  • Розмежування команд та дій також може призвести до іншого класу, який відповідає за виконання / запуску цих команд / дій; можливо, якесь CommandBrokerабо ActionBrokerяке поводиться як "проміжне програмне забезпечення" вашої програми, що надсилає / приймає / виконує ці команди та дії між різними об'єктами

Також пам’ятайте, що не все, що стосується поведінки, обов'язково має існувати як частина класу; Наприклад, ви можете розглянути можливість використання карти / словника функціональних покажчиків / делегатів / закриттів для інкапсулювання своїх дій / команд, а не написання десятків класів одностатевого стану без стану.

Досить часто бачити рішення «командного шаблону» без написання класів, побудованих за допомогою статичних методів спільного використання підпису / інтерфейсу:

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

Нарешті, немає жорстких і швидких правил щодо того, як далеко ви повинні пройти, щоб досягти єдиної відповідальності. Складність заради складності - це не дуже добре, але мегалітичні заняття, як правило, самі по собі є досить складними. Основна мета SRP та, власне, інших принципів SOLID - забезпечити структуру, узгодженість та зробити код більш ретельним - що часто призводить до чогось більш простого.


Я думаю, що ця відповідь вирішила суть моєї проблеми, дякую. Я використовую його для рефакторингу частин моєї програми, і поки що речі виглядають набагато чистішими.
користувач35358

1
Ви повинні бути обережними з анемічними моделями , цілком прийнятно, щоб модель персонажа мала поведінку Walk, Attackі Duck. Що не гаразд, це мати Saveі Load(наполегливість). SRP стверджує, що клас повинен нести лише одну відповідальність, але відповідальність персонажа - це персонаж, а не контейнер даних.
Кріс Волерт

1
@ChrisWohlert Це причина для імені CharacterModel, відповідальність якого полягає в тому, щоб бути контейнером даних для роз'єднання проблем рівня шару даних від рівня Business Logic. Дійсно, все ще хочеться, щоб поведінковий Characterклас існував десь теж, але з 80 діями та 30 командами я схиляюся до подальшого його розбиття. Більшу частину часу я вважаю, що іменники сутностей - це "червона оселедець" для назв класів, тому що важко екстраполювати відповідальність від іменника сутності, і все занадто легко перетворити їх на свого роду нож швейцарської армії.
Бен Коттрелл

10

Завжди можна використовувати більш абстрактне визначення поняття "відповідальність". Це не дуже вдалий спосіб судити про ці ситуації, принаймні, поки у вас не буде багато досвіду. Зауважте, ви легко склали чотири кулі, які я б назвав кращою відправною точкою для деталізації вашого класу. Якщо ви справді стежите за СРП, складно зробити такі пункти кулями.

Інший спосіб - подивитися на членів свого класу та розколотись на основі методів, які фактично їх використовують. Наприклад, зробити один клас з усіх методів, які фактично використовують self.timer, інший клас з усіх методів, які фактично використовують self.outgoing, а інший клас із залишку. Зробіть ще один клас зі своїх методів, який бере аргумент db як аргумент. Коли ваші заняття занадто великі, часто існують такі групи.

Не бійтеся розділити його на менший розмір, ніж, на вашу думку, є розумним як експеримент. Ось для чого призначений контроль версій. Правильну точку балансу набагато легше помітити після того, як ви завели занадто далеко.


3

Визначення "відповідальності", як відомо, розпливчасте, але воно стає трохи менш розпливчастим, якщо ви вважаєте це "причиною змін". Ще нечітко, але щось можна проаналізувати трохи більш безпосередньо. Причини змін залежать від вашого домену та того, як буде використовуватися ваше програмне забезпечення, але ігри є прикладними випадками, оскільки ви можете зробити обґрунтовані припущення щодо цього. У вашому коді я рахую п'ять різних обов'язків у перших п'яти рядках:

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

Ваша реалізація зміниться, якщо вимоги до гри зміниться будь-яким із цих способів:

  1. Поняття про те, що є "грою", змінюється. Це може бути найменш вірогідним.
  2. Як вимірювати та відстежувати зміни балів здоров’я
  3. Ваша атакова система змінюється
  4. Ваша бальна система змінюється
  5. Ваша система синхронізації змінюється

Ви завантажуєтесь із баз даних, вирішуєте атаки, зв’язуєтесь з іграми, призначаєте час; мені здається, перелік обов'язків уже дуже довгий, і ми бачили лише невелику частину вашого Characterкласу. Тож відповідь на одну частину вашого запитання - ні: ваш клас майже точно не відповідає SRP.

Однак я б сказав, що є випадки, коли в SRP прийнятно мати клас 2 500 ліній або довше. Деякі приклади можуть бути:

  • Дуже складний, але чітко визначений математичний розрахунок, який бере чітко визначений вхід і повертає чітко визначений вихід. Це може бути оптимізований код, для якого потрібні тисячі рядків. Перевірені математичні методи для чітко визначених обчислень не мають багатьох причин для зміни.
  • Клас, який виконує функції сховища даних, наприклад клас, який нараховує yield return <N>перші 10 000 простих чисел, або 10 000 найпоширеніших англійських слів. Існують можливі причини, через які ця реалізація буде віддана перевазі над витягуванням із сховища даних або текстового файлу. Ці класи мають дуже мало причин для зміни (наприклад, вам здається, що вам потрібно більше 10 000).

2

Щоразу, коли ви працюєте проти якоїсь іншої сутності, ви можете ввести третій об'єкт, який виконує обробку замість цього.

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)

Тут ви можете ввести "AttackResolver" або щось подібне до тих ліній, що обробляють відправлення та збір статистики. Чи є тут on_attack лише про стан символів, чи це більше?

Ви також можете переглянути стан і запитати себе, чи є у вас певний стан справді потрібного персонажа. "success_attack" звучить як щось, що ви могли б відстежувати і в іншому класі.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.