Все кредиты AtmaWeapon из http://www.xtremevbtalk.com, ответ в этой теме
Ядром обеих ситуаций является то, что я считаю фундаментальным правилом объектно-ориентированного проектирования: принципом единой ответственности. Два способа выразить это:
"A class should have one, and only one, reason to change."
"A class should have one, and only one, responsibility."
SRP - это идеал, который не всегда может быть достигнут, и следование этому принципу hard . Я склонен стрелять за «У класса должно быть как можно меньше обязанностей». Наш мозг очень хорошо убеждает нас, что очень сложный отдельный класс менее сложен, чем несколько очень простых классов. В последнее время я начал делать все возможное, чтобы писать меньшие классы, и в моем коде значительно сократилось количество ошибок. Дайте ему шанс для нескольких проектов, прежде чем отклонить его.
Сначала я предлагаю вместо того, чтобы начинать дизайн с создания базового класса карты и трех дочерних классов, начинать с дизайна, который разделяет уникальное поведение каждой карты на вторичный класс, который представляет общее «поведение карты». Этот пост посвящен доказательству того, что этот подход является лучшим. Мне трудно быть конкретным, не имея достаточно глубоких знаний о вашем коде, но я буду использовать очень простое понятие карты:
Public Class Map
Public ReadOnly Property MapType As MapType
Public Sub Load(mapType)
Public Sub Start()
End Class
MapType указывает, какой из трех типов карт представляет карта. Когда вы хотите изменить тип карты, вы вызываете Load()
с типом карты, который вы хотите использовать; это делает все, что нужно для очистки текущего состояния карты, сброса фона и т. д. После загрузки карты вызывается Start () . Если на карте есть такие поведения, как «spawn monster x каждые y секунд», Start () отвечает за настройку этих действий.
Это то, что у вас есть сейчас, и вам стоит подумать, что это плохая идея. Поскольку я упомянул SRP, давайте посчитаем обязанности Map .
- Он должен управлять информацией о состоянии для всех трех типов карт. (3+ обязанности *)
Load()
должен понимать, как очистить состояние для всех трех типов карт и как установить начальное состояние для всех трех типов карт (6 обязанностей)
Start()
должен знать, что делать для каждого типа карты. (3 обязанности)
** Технически каждая переменная является ответственностью, но я упростил ее. *
В итоге, если вы добавите четвертый тип карты? Вы должны добавить больше переменных состояния (1+ обязанностей), обновить Load()
, чтобы иметь возможность очистить и инициализировать состояние (2 обязанности), и обновить Start()
для обработки нового поведения (1 ответственность) , Итак:
Количество Map
обязанностей: 12 +
Количество изменений, необходимых для новой карты: 4 +
Есть и другие проблемы. Скорее всего, некоторые типы карт будут иметь похожую информацию о состоянии, поэтому вы будете делить переменные между состояниями. Это повышает вероятность того, что Load()
забудет установить или очистить переменную, поскольку вы можете не помнить, что одна карта использует _foo для одной цели, а другая - полностью для другой.
Это тоже нелегко проверить. Предположим, вы хотите написать тест для сценария «Когда я создаю карту« монстров возрождения », карта должна порождать одного нового монстра каждые пять секунд». Легко обсудить, как вы можете это проверить: создайте карту, установите ее тип, запустите ее, подождите немного дольше пяти секунд и проверьте количество врагов. Однако наш интерфейс в настоящее время не имеет свойства «счетчик врагов». Мы могли бы добавить это, но что, если это единственная карта с количеством врагов? Если мы добавим свойство, у нас будет свойство, которое недопустимо в 2/3 случаев. Также не очень ясно, что мы тестируем карту «spawn monsters» без чтения кода теста, так как все тесты будут тестировать класс Map
.
Вы, безусловно, могли бы сделать Map
абстрактный базовый класс Start()
MustOverride и получить по одному новому типу для каждого типа карты. Теперь ответственность за Load()
лежит где-то еще, потому что объект не может заменить себя другим экземпляром. Вы можете также сделать фабричный класс для этого:
Class MapCreator
Public Function GetMap(mapType) As Map
End Class
Теперь наша иерархия карт может выглядеть примерно так (для простоты была определена только одна производная карта):
Public MustInherit Class Map
Public MustOverride Sub Start()
End Class
Public Class RentalMap
Inherits Map
Public Overrides Sub Start()
End Class
Load()
больше не требуется по причинам, которые уже обсуждались. MapType
излишен на карте, потому что вы можете проверить тип объекта, чтобы увидеть, что это такое (если у вас нет нескольких типов RentalMap
, тогда он снова станет полезным.) Start()
переопределяется в каждом производном классе, так что вы переместили обязанности государственного управления на отдельные занятия. Давайте сделаем еще одну проверку SRP:
Карта базового класса
0 обязанностей
Карта производного класса
- Должен управлять государством (1)
- Должен выполнять какую-то специфическую работу (1)
Всего: 2 обязанности
Добавление новой карты
(То же, что и выше) 2 обязанности
Общее количество обязанностей в классе: 2
Стоимость добавления нового класса карты: 2
Это намного лучше. Как насчет нашего тестового сценария? Мы в лучшей форме, но все еще не совсем правы. Мы можем избежать использования свойства «количество врагов» в нашем производном классе, потому что каждый класс является отдельным, и мы можем привести к определенным типам карт, если нам нужна конкретная информация. Тем не менее, что если у вас есть RentalMapSlow
и RentalMapFast
? Вы должны продублировать свои тесты для каждого из этих классов, так как у каждого своя логика. Итак, если у вас есть 4 теста и 12 разных карт, вы будете писать и немного дорабатывать 48 тестов. Как мы это исправим?
Что мы делали, когда создавали производные классы? Мы определили часть класса, которая менялась каждый раз, и поместили ее в подклассы. Что если вместо подклассов мы создадим отдельный класс MapBehavior
, который мы можем менять по своему усмотрению? Давайте посмотрим, как это может выглядеть с одним производным поведением:
Public Class Map
Public ReadOnly Property Behavior As MapBehavior
Public Sub SetBehavior(behavior)
Public Sub Start()
End Class
Public MustInherit Class MapBehavior
Public MustOverride Sub Start()
End Class
Public Class PlayerSpawnBehavior
Public Property EnemiesPerSpawn As Integer
Public Property MaximumNumberOfEnemies As Integer
Public ReadOnly Property NumberOfEnemies As Integer
Public Sub SpawnEnemy()
Public Sub Start()
End Class
Теперь использование карты включает в себя присвоение ей MapBehavior
и вызов Start()
, который делегирует поведению Start()
. Вся информация о состоянии находится в объекте поведения, поэтому карта не должна ничего знать об этом. Тем не менее, что, если вам нужен конкретный тип карты, кажется неудобным создавать поведение, а затем создавать карту, верно? Итак, вы получили несколько классов:
Public Class PlayerSpawnMap
Public Sub New()
MyBase.New(New PlayerSpawnBehavior())
End Sub
End Class
Вот и все, одна строка кода для нового класса. Хотите карту спавна хард-плеера?
Public Class HardPlayerSpawnMap
Public Sub New()
' Base constructor must be first line so call a function that creates the behavior
MyBase.New(CreateBehavior())
End Sub
Private Function CreateBehavior() As MapBehavior
Dim myBehavior As New PlayerSpawnBehavior()
myBehavior.EnemiesPerSpawn = 10
myBehavior.MaximumNumberOfEnemies = 300
End Function
End Class
Итак, чем это отличается от наличия свойств в производных классах? С поведенческой точки зрения мало что отличается. С точки зрения тестирования, это большой прорыв. PlayerSpawnBehavior
имеет свой набор тестов. Но поскольку HardPlayerSpawnMap
и PlayerSpawnMap
оба используют PlayerSpawnBehavior
, то, если я проверил PlayerSpawnBehavior
, мне не нужно писать никаких тестов, связанных с поведением, для карты, которая использует поведение! Давайте сравним тестовые сценарии.
В случае «один класс с параметром типа», если есть 3 уровня сложности для 3 поведений, и у каждого поведения есть 10 тестов, вы будете писать 90 тестов (не включая тесты, чтобы увидеть, исходят ли из каждого поведения) в другой сценарий.) В сценарии «производные классы» у вас будет 9 классов, каждый из которых требует 10 тестов: 90 тестов. В сценарии «класс поведения» вы напишите 10 тестов для каждого поведения: 30 тестов.
Вот подсчет ответственности:
Карта несет 1 ответственность: отслеживать поведение.
Поведение имеет 2 обязанности: поддерживать состояние и выполнять действия.
Общее количество обязанностей в классе: 3
Стоимость добавления нового класса карты: 0 (повторное использование поведения) или 2 (новое поведение)
Итак, мое мнение таково, что сценарий "класса поведения" написать не сложнее, чем сценарий "производных классов", но он может значительно снизить нагрузку на тестирование. Я читал о таких методах и отвергал их как «слишком много проблем» в течение многих лет, и только недавно осознал их ценность. Вот почему я написал почти 10 000 символов, чтобы объяснить и обосновать это.