Итак, что имеется:
- Контроллер, который обрабатывает события от UI.
- Окошко, события которого обрабатывает контроллер, может находиться в нескольких режимах. В зависимости от режима, одно и то же событие может обрабатываться по разному — в одном режиме щелчок по объекту приводит к переходу на экран с подробной информацией об объекте, в другом режиме — к удалению объекта и т.п. Всего режимов 3 штуки.
- Чтобы обработчики событий не превратились в спагетти от условной логики, завязанной на режим, код обработки событий выносится в отдельные классы (state-классы); по одному классу на каждый режим.
- Есть события, которые во всех режимах обрабатываются одинаково, они реализованы в базовом state-классе.
- В некоторых режимах некоторые действия не поддерживаются. Соответственно, на UI эти действия запрещаются (контролы делаются недоступными), а в обработчиках кидаются исключения.
Реализовано всё это было примерно так:
public sealed class Controller { private StateBase currentState; public void Action1() { currentState.Action1(); } public void Action2() { currentState.Action2(); } } public abstract class StateBase { public void Action1() { // Тут код, общий для всех режимов } // Реализацию предоставляют наследники public abstract void Action2(); } public sealed class ConcreteState1 : StateBase { public override void Action2() { // В этом режиме действие Action2 не поддерживается, // метод вызываться не должен throw new NotSupportedException(); } } public sealed class ConcreteState2 : StateBase { public override void Action2() { // В этом режиме действие Action2 не поддерживается, // метод вызываться не должен throw new NotSupportedException(); } } public sealed class ConcreteState3 : StateBase { public override void Action2() { // Логика выполнения действия Action2 для этого режима } }
В один прекрасный день один из коллег увидел всё это дело и решил избавиться от дублирования кода — в двух из трёх наследников код в Action2 одинаковый. Получилось такое:
public abstract class StateBase { // ... public virtual void Action2() { // Общий код из ConcreteState1 и ConcreteState2 // перенесли сюда throw new NotSupportedException(); } } public sealed class ConcreteState1 : StateBase { // ... } public sealed class ConcreteState2 : StateBase { // ... } public sealed class ConcreteState3 : StateBase { public override void Action2() { // Логика выполнения действия Action2 для этого режима, // реализация Action2 из базового класса не вызывается. } }
На самом деле, действий, таких как Action2, которые поддерживаются только в одном из state-классов, было несколько; ко всем были применены аналогичные преобразования. Результат оказался компактнее по размеру, но хуже по качеству кода. Что тут есть плохого?
Проблема №1: формально ConcreteState3 продолжает оставаться наследником StateBase, фактически — нет. После изменений StateBase превратился из «базового класса для всех состояний» в «базовый класс для всех состояний, в которых Action2 не поддерживается», но притворяется, что он остался всё тем же «базовым классом для всех состояний».
Внести эти изменения в StateBase можно было только зная реализацию его наследников (плохо). Вносить изменения/добавлять новых наследников StateBase можно только зная реализацию StateBase (плохо). В частности, базовый класс разрешает переопределить Action2 в наследнике, но вызывать при этом Action2 базового класса наследник не должен.
StateBase после изменений стал, по терминологии Эрика Липперта, «хрупким базовым классом» (brittle base class). От таких классов тяжело наследоваться, в них тяжело вносить изменения из-за необоснованно большого сцепления между базовым классом и наследниками.
Проблема №2: состояния, которые не поддерживали Action2, не просто так бросали исключения. Это средство для обнаружения других ошибок в коде. В этом конкретном примере исключение NotSupportedException говорит об одном из двух:
- UI-слой содержит ошибку — интерфейс позволяет нажать кнопку, которая должна быть недоступна или
- Ошибка есть в коде, который отвечает за изменение поля currentState у контроллера.
До внесения изменений исключения бросались из конкретных классов. Это значит, что стектрейс такого исключения будет содержать чёткое указание на то, в каком режиме работало окно на момент нажатия кнопки (какое состояние было текущим). После внесения изменений этой информации в исключении не будет.
Вывод из всего этого простой: иерархия наследования из-за сильной зависимости между базовыми и производными классами — крайне ломкая штука; прежде чем вносить в неё изменения, нужно семь раз отмерить. Даже незначительные изменения могут внести ошибки в код, и вы этого не заметите.
Согласен. Еще отмечу, что если бы использовались интерфейсы, то проблемы не возникло. Наследование- более жесткая связь, чем интерфейсы. Его надо использовать аккуратно.
ОтветитьУдалитьНу по такой логике Action1 не надо вносить в базовый класс, а лучше все сделать через интерфейсы.
ОтветитьУдалить