2.6. Архитектура

The rules of software architecture are independent of every other variable.

R. Martin [11]

«…У вас могло сложиться впечатление, что единственная причина создания класса – моделирование объектов реального мира. На самом деле это весьма далеко от истины»

[Макконнелл 10].

§ 26. Принципы проектирования. В предыдущих разделах определение границ классов представлялось вполне очевидным с точки зрения рассмотренной концепции повышения уровня абстракции. Когда мы объявляем в классе Point метод float DistanceTo (Point p), вычисляющий расстояние между точками (см. § 8), то это выглядит логичным решением. Однако даже для этой незамысловатой задачи можно представить ситуации, когда более подходящим будет определение метода расчета расстояния в отдельном классе. Задача проектирования классов может быть решена по-разному, к тому же далеко не всегда класс представляет ясную и однозначную абстракцию типа геометрических фигур. Поэтому помимо общих идей, таких как принцип сокрытия информации, абстрагирование или инкапсуляция, формулируются более частные рекомендации, называемые принципами проектирования. Конечно, проектирование всегда содержит элемент творчества и не может быть сведено к механическому применению свода правил. Однако начинающие разработчики часто склонны переоценивать творческую и недооценивать обуславливаемую правилами составляющую архитектуры программного обеспечения.

В первом приближении принципы проектирования программного обеспечения – это вполне конкретные правила, указывающие, как следует группировать данные и методы в классы и как эти классы должны зависеть друг от друга, чтобы приложение было простым для понимания и внесения изменений. То есть имело бы качественную архитектуру. Общепризнанный базовый свод таких правил состоит из пяти принципов проектирования, называемых SOLID-принципами36. Здесь мы коротко обсудим один из них – принцип единственной ответственности. В последующих главах мы остановимся и на некоторых других, когда будем рассматривать вопросы наследования. При этом следует оговорить, что принципы проектирования, строго говоря, выходят за рамки этой книги, поэтому мы рассматриваем их достаточно обзорно, только чтобы обозначить проблематику и сориентировать читателя на дальнейшее изучение этой темы.

Принцип единственной ответственности37 (single responsibility principle – SRP) формулирует, что каждый модуль (класс или метод) должен иметь одну и только одну причину для изменения. Поясним это на следующем примере. Положим, мы хотим загружать координаты многоугольников из файла. Должны ли мы сделать отдельный класс-загрузчик или лучше реализовать метод в самом классе многоугольника?


class Polygon
{
  public static Polygon [] LoadFromFile (string filePath)
  {
    /* … */
  }
}
class PolygonLoader
{
  public Polygon[] Load (string filePath)
  {
    /* … */
  }
}

В случае отдельного класса PolygonLoader у нас нет доступа к внутренней реализации Polygon и мы вынуждены ограничиваться открытыми конструкторами и методами для создания многоугольников. Кроме того, мы создаем дополнительные абстракции (дополнительный класс), и, на первый взгляд, усложняем структуру кода. Однако размещение метода загрузки в классе Polygon нарушит принцип единственной ответственности. Мы получим две совершенно независимых причины изменения этого класса, или, по-другому, два совершенно независимых инициатора этих изменений. Одна причина – это изменение способа хранения координат в классе Polygon, например, для оптимизации работы программы (инициатор – разработчик, использующий этот класс и столкнувшийся с неудовлетворительным быстродействием). Другая причина – изменение формата файла, из которого выполняется загрузка (инициатор – поставщик данных). Эти два сценария изменений независимы логически, не пересекаются во времени, исходят от разных проблем и пользователей (инициаторов). Программист, работая над одной задачей, имеет доступ к реализации совершенно другой задачи, он потенциально зависит от лишнего кода, всё это нарушает принцип абстрагирования – мы анализировали такую ситуацию в § 4.

Не следует путать с принципом единственной ответственности правило об одной цели класса. Каждый класс (и каждый метод) действительно должен иметь одну внятную цель, решать одну задачу. Однако определение цели всегда более размытое, чем определение причины, или инициатора, изменений. Последовательное применение принципа единственной ответственности должно приводить к тому, что при изменении некоторого фрагмента кода мы будем принимать во внимание только ту часть связанного кода, которая непосредственно имеет отношение к решаемой задаче.

Часто (и неверно) первыми книгами и статьями, с которых начинается знакомство с темой архитектуры программного обеспечения, оказываются материалы про шаблоны проектирования (patterns). Так, в предыдущей главе мы рассмотрели шаблон «одиночка» (singleton), показывающий, как создать объект, существующий в приложении в единственном экземпляре. Шаблон проектирования – это типовое решение типовой проблемы средствами того или иного языка. При этом шаблон проектирования почти не отвечает на вопрос, когда и зачем эту проблему нужно решать. Когда в программе следует создавать объекты, существующие в одном экземпляре, а когда – не следует? Именно принципы проектирования отвечают на эти вопросы. Поэтому, по-крупному, сначала нужно разобраться с инструментом (ООП), далее – с правилами его применения (принципами проектирования) и только потом – с шаблонами проектирования (лучшими практиками реализации типовых проектных решений на конкретном языке программирования).

В заключение краткого обзора понятия принципов проектирования, скажем несколько слов об уровнях архитектуры. Упомянутые принципы относятся к уровню «чуть выше кода» и отвечают на вопросы, как лучше выделить классы, распределить между ними методы и организовать их взаимодействие. На более высоком уровне есть архитектура логически или физически группируемых классов. Так, в следующем параграфе мы познакомимся с механизмами пространств имен и компонентов в C#.

§ 27. Пространства имен и компоненты. Современное приложение может включать сотни и тысячи классов, поэтому любой язык программирования предоставляет способы их группировки. Основной такой способ – группировка классов в именованные области видимости, называемые, в зависимости от языка, пространствами имен, или пакетами. В настоящем параграфе мы коротко рассмотрим этот механизм. Отметим при этом, что пространства имен используются не только в объектно-ориентированных языках и не только для группировки классов.

Проанализируем следующий код.


namespace Geometry
{
  public class Point { /* … */ }
  public class Triangle { /* … */ }
}
namespace Map
{
  public class Bus { /* … */ }
}

Программа использует три класса, но мы сгруппировали их в две именованных области видимости – пространства имен: Geometry и Map. Для использования класса Point в коде класса Triangle мы используем только имя класса, так как имя Point размещено в той же области видимости, что и имя Triangle:


public class Triangle
{
  private Point pointA;
  /* … */
}

Но чтобы обратиться к классу Point из класса Bus мы должны явно указать имя пространства имен Geometry перед именем класса, иначе возникнет ошибка на этапе компиляции:


public class Bus
{
  private Geometry.Point currentPosition;
  /* … */
}

В приведенном примере, как и везде в предыдущих главах, мы использовали модификатор видимости public перед ключевым словом class. Модификатор видимости для класса обозначает видимость класса относительно пространства имен: к открытым (public) классам можно обратиться из кода в других пространствах имен, к закрытым же (private) – нельзя.

В нотации UML пространства имен называются пакетами и отображаются следующим образом (вверху на рисунке – сокращенное отображение без содержимого пакета, внизу – развернутое отображение с содержимым):

Сформулируем определение:

Пространство имен (namespace), или пакет (package) – именованная область видимости, включающая классы или другие пространства имен; механизм логической группировки классов в модули.

Вернемся к рассматриваемому примеру. Если класс Point часто используется в коде класса Bus, то может быть неудобно каждый раз писать перед именем класса Point его пространство имен. Чтобы этого избежать, мы можем указать в начале файла кода класса Bus перечень пространств имен, классы из которых могут быть использованы в этом файле без явного указания их пространств имен. Это выполняется с помощью директивы using:


// Bus.cs
using Geometry;
namespace Map
{
  public class Bus
  {
    private Point currentPosition;
    /* … */
  }
}

Указание, что некоторые пространства имен должны быть видны без явного указания их имени в текущем файле также называется импортированием пространства имен.

В C# пространства имен могут быть вложенными (nested):


namespace MyApp.Geometry
{
  public class Point { /* … */ }
  public class Triangle { /* … */ }
}
namespace MyApp.Map
{
  public class Bus { /* … */ }
}
//ИЛИ:
namespace MyApp
{
  namespace Geometry
  {
    public class Point { /* … */ }
    public class Triangle { /* … */ }
  }
  namespace Map
  {
    public class Bus { /* … */ }
  }
}

На диаграмме UML вложенность отображается следующим образом:

При импорте становятся доступны классы из импортируемого пространства имен и всех его родительских пространств имен. В других языках это может работать иначе, например, в Java импортируется только классы из импортируемого пространства имен. Строго говоря, Java не поддерживает вложенность пространств имен (называемых пакетами), а точка в имени пакета для компилятора – такой же обычный символ как буква. В общем случае реализация пространств имен в различных языках программирования может достаточно сильно отличаться в деталях.

В C# стандартная библиотека размещается в корневом пространстве имен System. Пользовательские пространства имен как правило начинаются с названия организации, далее следует название продукта, далее, собственно модули приложения.

Отметим, что хотя мы говорим о пространствах имен как о механизме логической группировки классов, во многих языках программирования (и в целом – исторически) эта группировка в большей или меньшей мере опирается на файловую структуру исходного кода. Например, в Python, пакет – это и есть файл.

С пространствами имен не следует путать концепцию компонентов.

Компонент (component) – это единица развертывания [11].

В C# компоненты называются сборками (assembly) – это файлы dll или exe, в которые компилируется исходный код. Хорошей практикой считается совпадение границ компонентов (физических модулей) и пространств имен (логических модулей) в том смысле, что все классы каждой сборки включаются в одно и то же корневое для этой сборки пространство имен, которое, в свою очередь, не встречается в других сборках.

Повторимся, что и пространства имен, и компоненты – темы не специфичные для объектно-ориентированных языков, поэтому мы ограничимся кратким обзором.

Вопросы и задания

  1. Сформулируйте ключевые критерии хорошей, качественной, архитектуры программного обеспечения.

  2. В чем отличие принципов проектирования (design principles) и шаблонов проектирования (patterns)?

  3. Сформулируйте и охарактеризуйте принцип единственной ответственности (single responsibility principle – SRP). Приведите примеры.

  4. ** Сформулируйте и охарактеризуйте принцип открытости/закрытости (open/close principle – OCP). Приведите примеры.

  5. Дайте определения следующим терминам и сопоставьте русские термины с английскими: пространство имен, пакет, компонент, сборка (в C#), namespace, package, component, assembly.

  6. Можно ли сказать, что классы для статических методов и полей – лишь аналог пространств имен или они представляют дополнительные возможности?

  7. Будет ли доступен класс, объявленный с модификатором видимости private, в другом пространстве имен, если его явно импортировать директивой using?

  8. * Сравните реализацию идеи пространств имен в языках программирования C#, Java, Python, C++. Какие из этих языков в большей мере полагаются на файловую структуру кода, какие – в меньшей?


Примечания:

  1. 36. Аббревиатура составлена из первых букв названий каждого из принципов, при этом само слово solid можно перевести как «качественный».

    ↩︎
  2. 37. Строго говоря, в русском языке некорректно говорить о нескольких «ответственностях», однако за неимением лучшего перевода повсеместно используется этот термин.

    ↩︎

О сайте, об авторе, контакты, оставить отзыв.

Телеграм-канал: newobjx.

© Тимофей Усов, 2019—2020.