Когда процесс написания ПО эволюционировал из научной области в настоящую инженерную дисциплину, появился ряд принципов. И когда я говорю «принцип», я подразумеваю некую особенность компьютерного кода, которая помогает поддерживать ценность этого кода. Шаблон относится к распространенному сценарию кодирования, будь он хорошим или плохим.
Например, вы можете высоко ценить компьютерный код, надежно работающий в многопоточной среде или не приводящий к краху, когда вы модифицируете код в другом месте. В самом деле, вы можете ценить многие полезные качества в своем компьютерном коде, но ежедневно сталкиваетесь с прямо противоположными вещами.
Под аббревиатурой SOLID скрываются некоторые фантастически полезные принципы разработки ПО: Single responsibility (принцип одной обязанности), Open for extension and closed for modification (принцип открытости/закрытости, или открытость для расширения и закрытость для модификации), Liskov substitution (принцип подстановки Лисков), Interface segregation (принцип разделения интерфейса) и Dependency inversion (принцип инверсии зависимостей). Вы должны быть в какой-то мере знакомы с этими принципами, так как я продемонстрирую разнообразные специфичные для C# шаблоны, которые нарушают эти принципы. Если вы не знакомы с принципами SOLID, то, по-видимому, предпочтете что-нибудь узнать о них, прежде чем читать мою статью. Я также исхожу из того, что вы имеете некоторое представление об архитектурных терминах Model и ViewModel.
Аббревиатура SOLID и охватываемые ею принципы — отнюдь не мое изобретение. Спасибо Роберту Мартину (Robert C. Martin), Майклу Физерсу (Michael Feathers), Бертрану Мейеру (Bertrand Meyer), Джеймсу Коплену (James Coplien) и другим за то, что поделились своей мудростью со всеми нами. Эти приниципы подробно исследованы и усовершенствованы во множестве книг и статей в блогах. Я надеюсь помочь вам расширить области применения этих принципов.
Работая со многими начинающими инженерами ПО и обучая их, я обнаружил большой разрыв между тем, что получается у новичков, и надежным кодом, написанным настоящими профессионалами. В этой статье я попытаюсь максимально сократить этот разрыв. Примеры могут показаться надуманными и слегка глуповатыми, но моя цель в другом: помочь вам осознать, что принципы SOLID применимы ко всем формам ПО.
Среда профессиональной разработки ставит много трудных задач перед амбициозными инженерами ПО. Полученное вами образование приучило вас думать о решении задач, рассматривая их сверху вниз. Вы будете придерживаться этого подхода в своих начальных заданиях в мире внутрикорпоративных приложений. Очень скоро вы обнаружите, что функция верхнего уровня разрослась до неприличного размера. Внесение даже самых мелких изменений требует полного знания всей системы, и держать их под контролем почти невозможно. Следование принципам построения программного обеспечения (из которых здесь упоминается лишь часть) поможет избежать развития его конструкции, опережающего расширение ее фундамента.
Принцип одной обязанности
Принцип одной обязанности (Single Responsibility Principle) часто определяют так: у объекта должна быть только одна причина для изменения; чем больше файл или класс, тем труднее достичь этой цели. Памятуя об этом определении, рассмотрим такой код:
public IList<IList<Nerd>> ComputeNerdClusters(
List<Nerd> nerds,
IPlotter plotter = null) {
...
foreach (var nerd in nerds) {
...
if (plotter != null)
plotter.Draw(nerd.Location,
Brushes.PeachPuff, radius: 10);
...
}
...
}
Что не так в этом коде? Этот код уже написан или только на стадии отладки? Может оказаться так, что этот код рисования годится только для целей отладки. Это хорошо, что он находится в сервисе, известном лишь по интерфейсу, но не является его частью. Хорошей зацепкой является кисть. Как бы хороша ни была эта кисть, она специфична для конкретной платформы. Она находится вне иерархии типов данной вычислительной модели. Существует много способов разделения вычислений и связанных с ними отладочных вспомогательных средств. Как минимум, вы можете предоставлять необходимые данные через наследование или события. Разделяйте тесты и тестовые представления.
Вот еще один пример неправильного кода:
class Nerd {
public int IQ { get; protected set; }
public double SuspenderTension { get; set; }
public double Radius { get; protected set; }
/// <summary>Книги для повышения IQ</summary>
public event Func<Nerd, IBook> InTheMoodForBook;
/// <summary>Рекомендации для увеличения радиуса</summary>
public event Func<Nerd, ISweet> InTheMoodForTwink;
public IList<Nerd> FitNerdsIntoPaddedRoom(
IList<Nerd> nerds, IList<Point> boundary)
{
...
}
}
А с этим кодом какие проблемы? В нем смешаны то, что называют «школьными предметами». Помните, как вы изучали разные предметы в разных классах школы? Такое разделение важно поддерживать и в коде — не потому, что они совершенно не связаны, а просто для организации кода. В принципе, избегайте появления в одном классе любых двух из следующих элементов: математики, моделей, грамматики, представлений, физических или платформенных адаптеров, специфичного для заказчика кода и т. д.
Здесь прослеживается некоторая аналогия с тем, что вы делаете на уроках труда из глины, дерева и металла. Все эти материалы требуют разных замеров, анализа, инструкций и прочего. В предыдущем примере были смешаны математика и модель: FitNerdsIntoPaddedRoom здесь ни к чему. Этот метод можно было бы легко переместить во вспомогательный класс, даже в статический. Не следует создавать экземпляры моделей в подпрограммах проверки математических операций.
Другой пример множества обязанностей:
class AvatarBotPath
{
public IReadOnlyList<ISegment> Segments { get; private set; }
public double TargetVelocity { get; set; }
public bool IsReverse { get { return TargetVelocity < 0; } }
...
}
public interface ISegment // где угодно
{
Point Start { get; }
Point End { get; }
...
}
Что здесь неправильно? Очевидно, что здесь один объект представляет две разные абстракции. Одна из них относится к перемещению по форме, а другая представляет саму геометрическую форму. Такое часто встречается в коде. У вас есть представление и отдельные, специфичные для области использования этого представления параметры.
В данном случае вам поможет наследование. Вы можете выделить свойства TargetVelocity и IsReverse в производный класс и включить их в четко определенный интерфейс IHasTravelInfo. В качестве альтернативы можно было бы добавить к форме универсальный набор features. Тот, кому нужна скорость (velocity), запрашивал бы набор features, чтобы выяснить, определена ли она в конкретной форме. Вы также могли бы использовать какой-то другой механизм наборов для сопоставления представлений с параметрами перемещения.
Принцип открытости/закрытости
Это подводит нас к следующему принципу: открытости для расширения и закрытости для модификации. Как это делается? Уж точно не так:
void DrawNerd(Nerd nerd) {
if (nerd.IsSelected)
DrawEllipseAroundNerd(nerd.Position, nerd.Radius);
if (nerd.Image != null)
DrawImageOfNerd(nerd.Image, nerd.Position, nerd.Heading);
if (nerd is IHasBelt) // редкий случай
DrawBelt(((IHasBelt)nerd).Belt);
// И т. д.
}
Что тут не так? Ну, вам придется модифицировать этот метод всякий раз, когда заказчику понадобится отображать что-то новое (а заказчикам всегда нужно отображать что-то новое). Почти каждая новая функциональность программы требует какого-либо UI-элемента. В конце концов, именно из-за нехватки чего-либо в существующем UI и возникает пожелание ввести новую функциональность. Шаблон, показанный в этом методе, — хорошая зацепка, но можно выделить эти выражения if в методы, которые они «сторожат», и… проблема останется.
Нужно решение получше, но какое? Как оно будет выглядеть? Что ж, у вас есть код, которому известно, как рисовать определенные вещи. Отлично. Вам просто нужна универсальная процедура, которая будет связывать эти вещи с кодом, их рисующим. Фактически она сводится к такому шаблону:
readonly IList<IRenderer> _renderers = new List<IRenderer>();
void Draw(Nerd nerd)
{
foreach (var renderer in _renderers)
renderer.DrawIfPossible(_context, nerd);
}
Есть другие способы добавлять нужное в список рендеров. Однако суть кода в том, чтобы написать классы рисования (или классы о классах рисования), которые реализуют некий общеизвестный интерфейс. Тогда рендер должен уметь определять, может он или должен рисовать что-то на основе своего ввода. Например, код, рисующий пояс, можно выделить в «рендер пояса», который проверяет наличие интерфейса и при необходимости продолжает работу.
Вам может понадобиться отделение CanDraw от метода Draw, но это не нарушает принцип открытости/закрытости (Open Closed Principle, OCP). Код, использующий рендеры, не должен изменяться, если вы добавляете новый рендер. Вот так просто. Вы также должны иметь возможность добавить новый рендер в правильном порядке. Хотя я использую рендеринг как пример, это применимо к обработке ввода, обработке и хранению данных. У этого принципа много областей применения во всех типах ПО. Эмулировать этот шаблон в Windows Presentation Foundation (WPF) труднее, но возможно. Один из вариантов см. на рис. 1.
Рис. 1. Пример объединения WPF-рендеров в один источник
public abstract class RenderDefinition : ViewModelBase
{
public abstract DataTemplate Template { get; }
public abstract Style TemplateStyle { get; }
public abstract bool SourceContains(object o); // Для селекторов
public abstract IEnumerable Source { get; }
}
public void LoadItemsControlFromRenderers(
ItemsControl control,
IEnumerable<RenderDefinition> defs) {
control.ItemTemplateSelector = new DefTemplateSelector(defs);
control.ItemContainerStyleSelector = new DefStyleSelector(defs);
var compositeCollection = new CompositeCollection();
foreach (var renderDefinition in defs)
{
var container = new CollectionContainer
{
Collection = renderDefinition.Source
};
compositeCollection.Add(container);
}
control.ItemsSource = compositeCollection;
}
Еще один пример скверного кода:
class Nerd
{
public void WriteName(string name)
{
var pocketProtector = new PocketProtector();
WriteNameOnPaper(pocketProtector.Pen, name);
}
private void WriteNameOnPaper(Pen pen, string text)
{
...
}
}
Что здесь неправильно? Проблемы с этим кодом обширны и разнообразны. Главная проблема, которую я хочу выделить, — невозможность переопределить создание экземпляра PocketProtector. Кодирование в таком духе затрудняет написание производных классов. В этом сценарии у вас есть несколько вариантов. Вы можете изменить код, чтобы:
- сделать метод WriteName виртуальным. Это также потребует сделать WriteNameOnPaper защищенным, чтобы добиться цели создания экземпляра модифицированного PocketProtector;
- сделать метод WriteNameOnPaper открытым, но это сохранит «сломанный» метод WriteName в ваших производных классах. Это неподходящий вариант, пока вы не избавитесь от WriteName, и тогда вы сможете передавать экземпляр PocketProtector в метод;
- добавить дополнительный защищенный виртуальный метод, единственное предназначение которого — конструирование PocketProtector;
- придать классу обобщенный тип T, который является типом PocketProtector, и конструировать его с помощью какой-то фабрики объектов. Тогда вам понадобится встраивать фабрику объектов;
- передать экземпляр PocketProtector этому классу в его конструктор или через открытое свойство вместо конструирования внутри класса.
Последний из перечисленных вариант, в целом, является лучшим, предполагая, что вы можете повторно использовать PocketProtector. Виртуальный метод создания тоже хороший и простой вариант.
Вы должны подумать, какие методы следует сделать виртуальными, чтобы добиться соответствия принципу OCP. Это решение зачастую оставляют на последний момент: «Я сделаю методы виртуальными, когда мне понадобится вызывать их из производного класса, которого у меня сейчас нет». Другие могут предпочесть сделать все методы виртуальными в надежде, что это позволит тем, кто будет расширять данный код, обойти любые недочеты в изначальном коде.
Оба подхода неверны. Они служат примером неспособности придерживаться какого-либо открытого интерфейса. Наличие слишком большого количества виртуальных методов ограничивает ваши возможности в последующем изменении кода. Нехватка методов, которые можно переопределять, ограничивает расширяемость и повторное использование кода. А значит, все это ограничивает полезность и срок службы кода.
Вот еще один распространенный пример нарушений OCP:
class Nerd
{
public void DanceTheDisco()
{
if (this is ChildOfNerd)
throw new CoordinationException("Can't");
...
}
}
class ChildOfNerd : Nerd { ... }
Какая проблема в этом коде? Класс Nerd имеет жесткую ссылку (hard reference) на свой дочерний тип. Видеть это просто невыносимо, и, к сожалению, это распространенная ошибка начинающих разработчиков. Она нарушает OCP. Вам придется модифицировать несколько классов, чтобы расширить или переработать ChildOfNerd.
Базовые классы никогда не должны напрямую ссылаться на производные от них классы.
Базовые классы никогда не должны напрямую ссылаться на производные от них классы. Иначе функционал производного класса (наследника) (inheritor) оказывается рассогласованным среди других наследников. Отличный способ предотвратить этот конфликт — помещать производные классы в отдельные проекты. Благодаря этому структура дерева ссылок проекта просто не позволит создать столь неприятную ситуацию.
Эта проблема не ограничивается отношениями «предок-потомок». Она существует и в равноправных классах (peer classes). Допустим, что у вас есть какой-то такой код:
class NerdsInAnArc
{
public bool Intersects(NerdsInAnLine line)
{
...
}
...
}
Дуги (arcs) и линии (lines), как правило, являются равноправными в иерархии объектов. Они не должны ничего знать о ненаследуемых внутренних деталях друг друга, так как эти детали часто требуются для использования оптимальных алгоритмов пересечения (intersection algorithms). Вы сохраняете свободу рук в модификации одного из них без изменения другого. Но тут опять нарушается принцип одной обязанности. Вы сохраняете дуги или анализируете их? Выделите все операции анализа во вспомогательный класс.
Если вам нужен этот конкретный функционал между равноправными объектами в иерархии, тогда придется вводить соответствующий интерфейс. Следуйте правилу, чтобы избежать путаницы между сущностями: вы должны использовать ключевое слово is с абстракцией вместо конкретного класса. Потенциально для этого примера можно было бы изобрести интерфейс IIntersectable или INerdsInAPattern, хотя вы все же наверняка предпочтете положиться на какой-то другой вспомогательный класс проверки пересечений для анализа данных, предоставляемых интерфейсом.
Принцип подстановки Лисков
Принцип подстановки Лисков (Liskov Substitution Principle, LSP) определяет некоторые правила для поддержки замещения наследника. Передача наследника объекта вместо базового класса не должна разрушать никакую существующую функциональность в вызываемом методе. У вас должна быть возможность подстановки всех реализаций данного интерфейса.
C# не разрешает модифицировать возвращаемые типы или типы параметров в переопределяющих методах (даже если возвращаемый тип является наследником возвращаемого типа в базовом классе). Поэтому он не препятствует самому распространенному нарушению принципа LSP: контравариантности аргументов метода (переопределенные версии должны иметь тот же или базовый тип родительского метода) и вариантности возвращаемых типов (возвращаемые типы в переопределяющих методах [overriding methods] должны быть теми же или наследовать от возвращаемых типов в базовом классе). Однако это ограничение весьма часто пытаются обойти:
class Nerd : Mammal {
public double Diopter { get; protected set; }
public Nerd(int vertebrae, double diopter)
: base(vertebrae) { Diopter = diopter; }
protected Nerd(Nerd toBeCloned)
: base (toBeCloned) { Diopter = toBeCloned.Diopter; }
// Предпочел бы возвращать Nerd:
// public override Mammal Clone() { return new Nerd(this); }
public new Nerd Clone() { return new Nerd(this); }
}
Какие проблемы в этом коде? Поведение объекта изменяется при вызове со ссылкой на абстракцию. Метод клонирования new не является виртуальным и поэтому не выполняется при использовании ссылки на Mammal. Ключевое слово new в контексте объявления метода вроде бы является некоей особенностью. Но если вы не контролируете базовый класс, то как вы сумеете обеспечить должное выполнение?
В C# есть несколько обходных альтернатив, хотя все они не слишком хороши. Вы можете использовать обобщенный интерфейс (нечто вроде IComparable<T>) для явной реализации в каждом наследнике. Однако вам все равно понадобится какой-то виртуальный метод, который выполняет саму операцию клонирования. Это необходимо, чтобы ваш клон соответствовал производному типу. C# также поддерживает стандарт Лисков применительно к контравариантности возвращаемых типов и ковариантности аргументов методов при использовании событий, но это не поможет вам изменить предоставляемый интерфейс через наследование классов.
Судя по коду, вы могли подумать, что C# включает возвращаемый тип в отпечаток метода (method footprint), который используется механизмом разрешения методов класса (class method resolver). Это неправильно: у вас не может быть несколько переопределенных версий с разными возвращаемыми типами, но под одним именем и теми же входными типами. Ограничения метода также игнорируются при его разрешении. На рис. 2 показан пример синтаксически правильного кода, который не будет компилироваться из-за неоднозначности метода.
Рис. 2. Неоднозначный метод
interface INerd {
public int Smartness { get; set; }
}
static class Program
{
public static string RecallSomeDigitsOfPi<T>(
this IList<T> nerdSmartnesses) where T : int
{
var smartest = nerdSmartnesses.Max();
return Math.PI.ToString("F" + Math.Min(14, smartest));
}
public static string RecallSomeDigitsOfPi<T>(
this IList<T> nerds) where T : INerd
{
var smartest = nerds.OrderByDescending(n => n.Smartness).First();
return Math.PI.ToString("F" + Math.Min(14, smartest.Smartness));
}
static void Main(string[] args)
{
IList<int> list = new List<int> { 2, 3, 4 };
var digits = list.RecallSomeDigitsOfPi();
Console.WriteLine("Digits: " + digits);
}
}
Код на рис. 3 показывает, как может быть нарушена возможность подстановки. Всегда тщательно продумывайте производные классы. Один из них может по случайности модифицировать поле isMoonWalking, как в этом примере. Если это случится, базовый класс рискует лишиться критической секции очистки. Поле isMoonWalking должно быть закрытым. Если о нем должны знать производные классы, предусмотрите для него защищенное свойство с аксессором get для его чтения, но не для изменения.
Рис. 3. Пример того, как можно нарушить возможность подстановки
class GrooveControl: Control {
protected bool isMoonWalking;
protected override void OnMouseDown(MouseButtonEventArgs e) {
isMoonWalking = CaptureMouse();
base.OnMouseDown(e);
}
protected override void OnMouseUp(MouseButtonEventArgs e) {
base.OnMouseUp(e);
if (isMoonWalking) {
ReleaseMouseCapture();
isMoonWalking = false;
}
}
}
Умудренные и, возможно, педантичные программисты сделают еще один шаг. Запечатывайте обработчики событий от мыши (или любые другие методы, которые полагаются на закрытое состояние или модифицируют его) и разрешайте производным классам использовать события или другие виртуальные методы, которые не являются обязательными для вызова (must-call methods). Шаблон, требующий базового вызова, приемлем, но не идеален. Все мы время от времени забывали вызывать ожидаемые базовые методы. Не позволяйте производным типам разрушать инкапсулированное состояние.
Подстановка Лисков также требует от производных типов не генерировать новые типы исключений (хотя производные исключения, уже генерируемые в базовом классе, вполне допустимы). В C# нет никакого способа принудить программиста к соблюдению этого.
Принцип разделения интерфейса
Суть принципа: много малых интерфейсов лучше, чем один большой. У каждого интерфейса должно быть специфическое предназначение. Вы не должны вынужденно реализовать тот интерфейс, который не имеет смысла в вашем объекте. На основе экстраполяции, чем больше интерфейс, тем вероятнее, что он включает методы, достижимые не для всех кто, реализует этот интерфейс. В этом и заключается принцип разделения интерфейса (Interface Segregation Principle). Возьмем старую и распространенную пару интерфейсов из Microsoft .NET Framework:
public interface ICollection<T> : IEnumerable<T> {
void Add(T item);
void Clear();
bool Contains(T item);
void CopyTo(T[] array, int arrayIndex);
bool Remove(T item);
}
public interface IList<T> : ICollection<T> {
T this[int index] { get; set; }
int IndexOf(T item);
void Insert(int index, T item);
void RemoveAt(int index);
}
Эти интерфейсы все еще в какой-то мере полезны, но здесь делается неявное допущение, что если вы используете их, то хотите модифицировать наборы. Зачастую, кто бы ни создавал эти наборы данных, желает предотвратить их модификацию. Поэтому очень полезно разделять интерфейсы на источники (sources) и потребители (consumers).
Во многих хранилищах данных стараются создавать общий индексируемый интерфейс, не разрешающий изменения этого хранилища. Возьмите, к примеру, программное обеспечение для анализа или поиска данных. Как правило, оно считывает большой файл журнала или таблицу базы данных для последующего анализа. Модификация данных в таком ПО никогда не предполагается.
Нужно признать, что IEnumerable задумывался как минимальный интерфейс только для чтения. С добавлением методов расширения LINQ в нем начались неизбежные изменения. Microsoft тоже осознавала пробел в интерфейсах индексируемых наборов. Она устранила его в .NET Framework 4.5 введением IReadOnlyList<T>, теперь реализуемого многими наборами этой инфраструктуры.
Вспомните эти красоты в старом интерфейсе ICollection:
public interface ICollection : IEnumerable {
...
object SyncRoot { get; }
bool IsSynchronized { get; }
...
}
Иначе говоря, прежде чем перебирать набор, ваш код должен сначала потенциально блокироваться на его SyncRoot. Ряд производных классов, даже явно реализующих эти конкретные элементы, просто прячут свой позор из-за того, что приходится реализовать их. В многопоточных средах ожидается, что ваш код будет блокироваться на наборе везде, где вы используете его (вместо применения SyncRoot).
Большинство из вас хочет инкапсулировать свои наборы, чтобы к ним можно было безопасно обращаться в многопоточной среде. Вместо использования foreach вы должны инкапсулировать многопоточное хранилище данных и предоставлять только метод ForEach, принимающий делегат. К счастью, более новые классы-наборы, такие как параллельные наборы (concurrent collections) в .NET Framework 4 или неизменяемые наборы (immutable collections), теперь доступные в .NET Framework 4.5 (через NuGet), устранили многие из прежних проблем.
.NET-абстракции Stream свойственны те же промахи: слишком велика, включает читаемые и записываемые элементы и флаги синхронизации. Однако в ней все же есть свойства, определяющие возможность записи: CanRead, CanWrite, CanSeek и т. д. Сравните if (stream.CanWrite) с if (stream is IWritableStream). Последнее определенно оценит тот, кто создает потоки данных (streams), не являющиеся записываемыми.
Теперь взгляните на код с рис. 4.
Рис. 4. Пример ненужной инициализации и очистки
// Вверх на один уровень в иерархии проекта
public interface INerdService {
Type[] Dependencies { get; }
void Initialize(IEnumerable<INerdService> dependencies);
void Cleanup();
}
public class SocialIntroductionsService: INerdService
{
public Type[] Dependencies { get { return Type.EmptyTypes; } }
public void Initialize(IEnumerable<INerdService> dependencies)
{ ... }
public void Cleanup() { ... }
...
}
Какая здесь проблема? Не надо изобретать колесо. Инициализация и очистка вашего сервиса должны осуществляться через один из фантастически удобных контейнеров инверсии управления (inversion of control, IoC), широко доступных для .NET Framework. В этом примере сервисы Initialize и Cleanup никого не волнуют, кроме диспетчера сервисов/контейнера/начального загрузчика — какой бы код ни загружал эти сервисы. Вы же не хотите, чтобы кто-то другой преждевременно вызвал Cleanup. В C# есть механизм, называемый явной реализацией (explicit implementation), который помогает в этом. Вы можете реализовать сервис более четко:
public class SocialIntroductionsService: INerdService
{
Type[] INerdService.Dependencies {
get { return Type.EmptyTypes; } }
void INerdService.Initialize(IEnumerable<INerdService> dependencies)
{ ... }
void INerdService.Cleanup() { ... }
...
}
В принципе, вам нужно проектировать свои интерфейсы с некоей целью, а не только как чистую абстракцию одного конкретного класса. Это открывает возможности в структуризации и расширении. Однако есть минимум два важных исключения.
Во-первых, интерфейсы обычно изменяются не столь часто, как их конкретные реализации. Этим можно воспользоваться к своей выгоде. Поместите интерфейсы в отдельную сборку. Позвольте потребителям ссылаться только на сборку интерфейсов. Это ускорит компиляцию и поможет избежать включения в интерфейс свойств, не имеющих к нему отношения (поскольку неподходящие типы свойств не доступны при должной иерархии проектов). Если соответствующие абстракции и интерфейсы находятся в одном и том же файле, считайте, что вы где-то сбились с пути истинного. Интерфейсы рассматриваются в иерархии как предки своих реализаций и как одноуровневые для сервисов (или абстракций сервисов), которые используют их.
Во-вторых, по определению, интерфейсы не имеют никаких зависимостей. Следовательно, они легко поддаются модульному тестированию с применением инфраструктур имитации/прокси. И это подводит нас к последнему принципу.
Принцип инверсии зависимостей
Этот принцип подразумевает зависимость от абстракций, а не конкретных типов. Между этим принципом и другими, рассмотренными ранее, есть много перекрывающихся областей. Во многих из предыдущих примеров можно было увидеть ошибку с зависимостью от конкретных типов, а не от абстракций.
Эрик Эванс (Eric Evans) в своей книге «Domain Driven Design» (Addison-Wesley Professional, 2003) обрисовал некоторые классификации объектов, полезные в обсуждении принципа инверсии зависимостей. В сухом остатке можно сказать, что всегда полезно относить объект к одной из трех групп: значения, сущности или сервисы.
Значения относятся к объектам без зависимостей, и обычно они являются временными (переходными) и неизменяемыми. В целом, они не абстрагируются, и вы можете создавать их экземпляры, когда угодно. Однако в их абстрагировании нет ничего неправильного, особенно если это дает вам все преимущества абстракций. Некоторые значения со временем могут вырасти в сущности. Сущностями являются ваши бизнес-модели и модели представлений. Они создаются из значимых типов (value types) и других сущностей. Для этих элементов полезно иметь абстракции, особенно если у вас есть один ViewModel, который представляет несколько вариантов Model или наоборот. Сервисы являются классами, которые содержат, организуют, обслуживают и используют сущности.
С учетом этой классификации инверсия зависимостей в основном имеет дело с сервисами и объектами, которые нуждаются в них. Методы, специфичные для сервисов, всегда должны помещаться в какой-то интерфейс. Всякий раз, когда вам нужно обратиться к сервису, вы делаете это через интерфейс. Не используйте конкретный тип сервиса где-либо в коде, кроме того места, где сервис конструируется.
Сервисы нередко зависят от других сервисов. Некоторые ViewModel зависят от сервисов, особенно от сервисов типа контейнеров и фабрик. Поэтому создавать экземпляры сервисов для тестирования, как правило, довольно затруднительно, потому что вам требуется полное дерево сервисов. Абстрагируйте их суть в интерфейсе. Тогда все ссылки на сервисы должны осуществляться через этот интерфейс, и их можно будет легко имитировать для целей тестирования.
Вы можете создавать абстракции на любом уровне в коде. Когда вы ловите себя на том, что думаете «ого, это будет весьма болезненно для A поддерживать интерфейс B, а для B — интерфейс A», это означает, что вам самое время ввести новую абстракцию между ними. Создавайте удобные в использовании интерфейсы и опирайтесь на них.
Шаблоны Adapter и Mediator могут помочь вам добиться соответствия желательному интерфейсу. Кажется, что дополнительные абстракции принесут дополнительный код, но обычно это не так. Сделав несколько шагов, вы сможете организовать код, который все равно должен был бы существовать для того, чтобы A и B могли взаимодействовать.
Много лет назад я прочел, что разработчик должен «всегда повторно использовать код». В то время это было так просто. Я не мог поверить, что столь простая мантра способна как-то помочь разгрести ту мешанину, которая была на моем экране. Однако со временем я многому научился — и этому тоже. Взгляните на этот код:
private readonly IRamenContainer _ramenContainer; // зависимость
public bool Recharge()
{
if (_ramenContainer != null)
{
var toBeConsumed = _ramenContainer.Prepare();
return Consume(toBeConsumed);
}
return false;
}
Вы замечаете какой-нибудь повторяющийся код? Здесь дважды считывается _ramenContainer. С технической точки зрения, компилятор исключит это за счет оптимизации, называемой удалением общих подвыражений (common sub-expression elimination). Предположим, что ваш код работает в многопоточной среде и компилятор на самом деле повторил чтение поля класса из метода. Тогда вы рискуете тем, что переменная класса изменится на null еще до того, как она будет где-то использована.
Как это исправить? Введите локальную ссылку перед выражением if. Эта реорганизация требует добавить новый элемент во внешнюю область видимости или выше нее. Тот же принцип действует в структуре проектов! Повторно используя код или абстракции, вы в конечном счете попадаете на полезный уровень в иерархии проектов. Позвольте зависимостям управлять иерархией ссылок между проектами.
Теперь посмотрите на этот код:
public IList<Nerd> RestoreNerds(string filename)
{
if (File.Exists(filename))
{
var serializer = new XmlSerializer(typeof(List<Nerd>));
using (var reader = new XmlTextReader(filename))
return (List<Nerd>)serializer.Deserialize(reader);
}
return null;
}
Зависит он от абстракций?
Нет, не зависит. Он начинается со статической ссылки на файловую систему. Использует «зашитый» в код десериализатор с «зашитыми» в код ссылками на тип. В этом коде ожидается, что обработка исключений будет происходить вне класса. Этот код невозможно протестировать без сопутствующего кода, работающего с хранилищем.
Как правило, вы выделили бы такое в две абстракции: одну — для формата хранилища и одну — для несущей среды хранилища. Некоторые примеры форматов хранилища включают XML, JSON и двоичные данные Protobuf. Несущие среды хранилища — непосредственно файлы на диске и базы данных. Для системы этого типа также характерна третья абстракция: некая разновидность редко меняющейся «реликвии», представляющей объект, который подлежит хранению.
Рассмотрим этот пример:
class MonsterCardCollection
{
private readonly IMsSqlDatabase _storage;
public MonsterCardCollection(IMsSqlDatabase storage)
{
_storage = storage;
}
...
}
Вы замечаете что-нибудь неладное с этими зависимостями? Все дело в имени зависимости. Оно специфично для платформы. А сервис не является специфичным для платформы (или, по крайней мере, пытается избежать зависимости от платформы, используя внешний механизм хранения). Это ситуация, где нужно применить шаблон Adapter.
Много лет назад я прочел, что разработчик должен «всегда повторно использовать код». В то время это было так просто.
Когда зависимости специфичны для платформы, в конечном счете в зависимых появится собственный код, специфичный для платформы. Вы можете избежать этого с помощью одного дополнительного уровня. Этот уровень поможет так организовать проекты, чтобы специфичные для платформы реализации находились в отдельном специальном проекте (со всеми их ссылками, специфичными для платформы). Вам потребуется лишь ссылаться из стартового проекта приложения на проект, содержащий весь специфичный для платформы код. Оболочки платформ имеют тенденцию к разбуханию; избегайте излишнего их дублирования.
Инверсия зависимостей связывает воедино весь набор принципов, рассмотренных в этой статье. Она использует четкие, предназначенные для определенных целей абстракции, которые вы можете наполнять конкретными реализациями, не нарушающими нижележащее состояние сервиса. В этом весь смысл.
Принципы SOLID, в целом, перекрываются друг с другом в профессионально написанном компьютерном коде. Огромный мир промежуточного (а значит, легко декомпилируемого) кода просто фантастичен в своей способности в полной мере раскрывать ваши возможности в расширении любого объекта. Ряд проектов .NET-библиотек со временем уходит со сцены. Не потому, что их идея была неправильной; просто оказалось, что их нельзя надежно расширить под непредвиденные потребности. Поэтому применяйте принципы SOLID и вы увидите, насколько продлится срок службы вашего кода.