Трудящийся программист

Мультипарадигматическая .NET. Часть 2

Тэд Ньюард

В предыдущей статье (msdn.microsoft.com/magazine/ff955611), первой в этой серии, я упомянул, что два основных языка в Microsoft .NET Framework — C# и Visual Basic — являются мультипарадигматическими, как и C++ — их синтаксический (в случае C#) или концептуальный (в случае Visual Basic) предшественник. Применение мультипарадигматических языков может оказаться запутанным и трудным делом, особенно когда цели различных парадигм не ясны.

Общность и вариативность

Но перед тем, как начать выделять различные парадигмы в этих языках, на ум приходит более существенный вопрос: что именно мы пытаемся сделать, проектируя программную систему? Забудьте на мгновение конечные цели — модульность, расширяемость, простота и все остальное — и сосредоточьтесь на том, как именно мы пытаемся достичь всех этих конечных целей?

Ответ на этот вопрос есть в книге Джеймса О. Коплена (James O. Coplien) «Multi-Paradigm Design for C++» (Addison-Wesley Professional, 1998):

Рассуждая абстрактно, мы выделяем общее и опускаем детали. Хорошая программная абстракция требует достаточно хорошего знания проблемы во всей ее широте, чтобы понять, что общего в интересующих нас связанных элементах и какие детали варьируются между этими элементами. Такие элементы носят собирательное название «семейство», и именно семейства (а не индивидуальные приложения) являются целью при создании архитектуры и проектировании. Мы можем использовать эту модель общности/вариативности независимо от того, какие члены семейства представляют собой модули, классы, функции, процессы или типы; она годится для любой парадигмы. Общность и вариативность лежат в основе большинства методик проектирования..

Задумайтесь на минутку о традиционной объектной парадигме. Как объектно-ориентированные разработчики мы с пеленок обучены выявлять сущности в системе и искать вещи, которые составляют конкретную сущность, — например, чтобы найти все, что можно отнести к сущности «teacher» (учитель), и поместить это в класс Teacher. Но если у нескольких сущностей взаимосвязанное и перекрытое поведение (как у «student», имеющего некие данные и операции, которые перекрываются с аналогичными у «person», но с некоторыми заметными отличиями), то, как нас учили, вместо того чтобы дублировать общий код, мы должны вынести общность в базовый класс и связать типы один с другим через наследование. Иначе говоря, общность собирается в базовый класс, а вариативность обеспечивается расширением этого класса и введением вариаций. Поиск общности и вариативности в системе и их выражение лежит в основе объектно-ориентированного проектирования.

Общности часто являются частями, которые трудно выявить не потому, что мы не распознаем их, а по прямо противоположной причине. Например, если я скажу «транспортное средство», что вам придет в голову? Если опробовать это на группе людей, окажется, что каждый подумает о своем, и тем не менее все «фантазии» будут иметь глобальную общность. Однако, если мы начнем перечислять разные виды транспорта, начнут проявляться самые разнообразные вариации, и, будем надеяться, их можно будет разбить на категории, чтобы все же идентифицировать некий набор общностей в транспортных средствах.

Позитивная и негативная вариативность

Вариативность может проявляться в двух основных формах, одна из которых распознается легко, а другая — гораздо труднее. Позитивной считается форма вариативности, которую можно добавить к базовой общности. Например, вообразите, что вам нужна абстракция наподобие сообщения, такого как SOAP-сообщение или сообщение электронной почты. Если мы решим, что тип Message должен иметь заголовок и тело, то для различных сообщений это будет общностью, а позитивной вариативностью — некое сообщение, которое несет в своем заголовке конкретное значение, скажем, дату и время. Это обычно легко выражается с помощью языковых конструкций; например, в объектно-ориентированной парадигме создать подкласс Message, который добавляет поддержку передачи в заголовке даты и времени, — дело почти тривиальное.

Однако негативная вариативность — штука куда более хитрая. Она исключает или противоречит некоторым аспектам общности; Message, у которого есть заголовок, но нет тела (как, например, в сообщениях подтверждения, используемых в инфраструктурах обмена сообщениями), является формой негативной вариативности. И, как вы, вероятно, уже догадались, ее выражение в языковой конструкции весьма проблематично: ни в C#, ни в Visual Basic нет возможности удалить член, объявленный в базовом классе. Лучшее, что можно было бы сделать в этом случае, — вернуть null или nothing от члена Body, что определенно посеет хаос в любом коде, ожидающем наличия Body, например в процедурах верификации, которые вычисляют контрольную сумму содержимого Body для проверки правильности его передачи.

(Любопытно, что типы XML Schema поддерживают негативную вариативность в своих определениях методов проверки схемы на допустимость — нечто, еще не поддерживаемое ни одним мейнстримовым языком программирования; здесь XML Schema Definition может расходиться с языками программирования. Появится ли такая поддержка в пока еще не созданном языке программирования и будет ли она хорошей идеей — это интереснее обсуждать за кружкой пива.)

Во многих системах негативная вариативность часто реализуется с помощью явных конструкций в коде на клиентском уровне; тем самым подразумевается, что выполнение каких-либо проверок if/else для распознавания разновидности Message перед анализом Body возлагается на пользователей типа Message, а это делает фактически бессмысленной работу, проделанную над семейством Message. Слишком много негативной вариативности, бьющей из проекта, обычно является главной причиной воплей разработчиков с призывом «снести все и начать заново».

Связывание общности и вариативности

Реальный момент, в который задаются общность и вариативность, зависит от конкретной парадигмы, и, в целом, чем ближе к периоду выполнения мы можем связать эти решения, тем больший контроль мы даем клиентам и пользователям над эволюцией системы. Обсуждая конкретную парадигму или методику в парадигме, важно понимать, в какой из перечисленных ниже «периодов связывания» на сцену выходит вариативность.

  1. Период создания исходного кода Это время до начала работы компилятора, когда разработчик (или некая другая сущность) создает файл исходного кода, который в конечном счете будет передан компилятору. Методики с применением генерации кода, например ядра шаблонов T4, — и в меньшей степени система ASP.NET — имеют дело со связыванием на этом этапе.
  2. Этап компиляции Как и подразумевает название этого периода, связывание осуществляется, когда компилятор проходит по исходному коду для его преобразования в скомпилированный байт-код (byte-code) или исполняемые машинные инструкции. На этом этапе принимается много решений, хотя и не все, как мы еще увидим.
  3. Этап связывания/загрузки В это время программа загружается и запускается; здесь появляется дополнительная точка вариативности, связанная с загрузкой специфических модулей (сборок в случае .NET или DLL в случае неуправляемого Windows-кода). Такую архитектуру применительно к программе в целом обычно называют основанной на плагинах (подключаемых модулях) или надстройках.
  4. Период выполнения При выполнении программы могут распознаваться определенные вариативности на основе пользовательского ввода и принятия решений; в итоге, исходя из ввода и/или решений, потенциально возможно выполнение разного кода (или даже его генерация).

В некоторых случаях процесс проектирования понадобится начинать с этих «периодов связывания» и работать в обратном порядке, чтобы понять, какие языковые конструкции могут обеспечить предъявляемые требования; например, пользователю может потребоваться возможность добавлять/удалять/модифицировать вариативность в период выполнения (так чтобы не приходилось возвращаться к циклу компиляции или перезагрузки кода), а это значит, что какую бы парадигму ни применял проектировщик, он должен обеспечить связывание вариативности в период выполнения.

Постановка задачи

В предыдущей статье я оставил читателей размышлять над следующим вопросом.

В качестве упражнения подумайте над следующим: в .NET Framework 2.0 появились обобщения (параметризованные типы). Почему, спросите вы? Какой цели они служат с точки зрения проектирования? (И между прочим, с ответами наподобие «они позволяют создавать типизированные наборы» вы попадете пальцем в небо — Windows Communication Foundation интенсивно использует обобщения, причем так, что дело явно не только в типизированных наборах.)

Делая еще один шаг вперед, рассмотрим (частичную) реализацию класса Point (рис. 1), представляющего точку в декартовой системе координат, например координаты пикселя на экране и т. д.

Рис. 1. Частичная реализация класса Point

Public Class Point
  Public Sub New(ByVal XX As Integer, ByVal YY As Integer)
    Me.X = XX
    Me.y = YY
  End Sub

  Public Property X() As Integer
  Public Property y() As Integer

  Public Function Distance(ByVal other As Point) As Double
    Dim XDiff = Me.X - other.X
    Dim YDiff = Me.y - other.y
    Return System.Math.Sqrt((XDiff * XDiff) + (YDiff * YDiff))
  End Function

  Public Overrides Function Equals(ByVal obj As Object) As Boolean
    ' Are these the same type?
    If Me.GetType() = obj.GetType() Then
      Dim other As Point = obj
      Return other.X = Me.X And other.y = Me.y
    End If
    Return False
  End Function

  Public Overrides Function ToString() As String
    Return String.Format("({0},{1}", Me.X, Me.y)
  End Function

End Class

Сам по себе этот класс не содержит ничего особо интересного. Остальную реализацию я оставляю читателям, так как она не имеет значения для обсуждения.

Заметьте, что в этой реализации Point сделано несколько допущений на предмет того, каким образом предполагается использование этих точек. Например, элементы X и Y класса Point являются целыми, а значит, этот класс Point не может представлять точки с дробными значениями вроде (0.5, 0.5). На первых порах это может оказаться приемлемым решением, но впоследствии неизбежно придет запрос на то, чтобы этот класс мог представлять «дробные точки» (по любой причине). Теперь у разработчика появляется интересная проблема: как ответить на новое требование?

Давайте сделаем то, что делать не советуют («О, Боже, не делай этого»), и просто создадим новый класс Point, использующий не целочисленные члены, а с плавающей точкой, и посмотрим, что получится (см. рис. 2; обратите внимание на то, что PointD — это сокращение от «Point-Double», т. е. подразумевается использование значений типа Double). Совершенно очевидно, что концептуально эти два типа Point имеют много общего. Согласно теории проектирования на основе общности и вариативности, это означает, что нам нужно каким-то образом выделить общие части и обеспечить поддержку вариативности. Классическое объектно-ориентированное программирование потребовало бы от нас делать это через наследование, выделяя общность в базовый класс или интерфейс (Point), а затем реализовать подклассы (например, PointI и PointD).

Рис. 2. Новый класс Point, использующий значения с плавающей точкой

Public Class PointD
  Public Sub New(ByVal XX As Double, ByVal YY As Double)
    Me.X = XX
    Me.y = YY
  End Sub

  Public Property X() As Double
  Public Property y() As Double

  Public Function Distance(ByVal other As Point) As Double
    Dim XDiff = Me.X - other.X
    Dim YDiff = Me.y - other.y
    Return System.Math.Sqrt((XDiff * XDiff) + (YDiff * YDiff))
  End Function

  Public Overrides Function Equals(ByVal obj As Object) As Boolean
    ' Are these the same type?
    If Me.GetType() = obj.GetType() Then
      Dim other As PointD = obj
      Return other.X = Me.X And other.y = Me.y
    End If
    Return False
  End Function

  Public Overrides Function ToString() As String
    Return String.Format("({0},{1}", Me.X, Me.y)
  End Function

End Class

Однако тут возникает интересная проблема. Во-первых, со свойствами X и Y нужно сопоставить тип, но вариативность в двух разных подклассах касается того, как хранятся координаты X и Y и тем самым представляются пользователям. Проектировщик мог бы всегда просто выбирать самое широкое представление, которое в данном случае было бы типом Double, но тогда способность класса Point содержать только целые значения была бы утеряна, а это пускает насмарку всю работу по наследованию. Кроме того, поскольку они теперь связаны через механизм наследования, эти две реализации, наследуемые от Point, как предполагается, взаимозаменяемые, поэтому у нас должна быть возможность передавать PointD в метод Distance подкласса PointI, что может оказаться нежелательным. И еще. Эквивалентны ли PointD (0.0, 0.0) и PointI (0,0)? Все эти вопросы нуждаются в обсуждении.

Если как-то исправить эти проблемы или взять их под контроль, возникают другие проблемы. Впоследствии нам мог бы понадобиться Point, который принимает значения, большие, чем позволяет тип Integer. Или мы сочли бы, что приемлемы только абсолютно положительные значения (что подразумевает нахождение начала координат в нижнем левом углу). Каждое из этих требований повлечет за собой необходимость в создании новых подклассов Point.

Сделаем шаг назад и вспомним, что изначальным побудительным мотивом было желание повторно использовать общность реализации Point, но разрешать вариативность в типе/представлении значений, которые образуют координаты точки. В идеале, в зависимости от типа графика, с которым мы работаем, у нас должна быть возможность выбора представления в момент создания Point, и тогда он мог бы проявлять себя как совершенно иной тип — именно для этого и предназначены обобщения.

Однако в этом случае возникает проблема: компилятор настаивает на том, что у типов Rep необязательно наличие определений операторов «+» и «-», поскольку он считает, что мы хотим помещать сюда любые возможные типы — Integer, Long, String, Button, DatabaseConnection или что там еще может прийти нам в голову, — а это явно слишком вариативно. Поэтому нам вновь нужно выразить некую общность в используемом здесь типе в форме обобщенного ограничения на то, какими могут быть типы Rep (рис. 3).

Рис. 3. Обобщенное ограничение типа

Public Class GPoint(Of Rep As {IComparable, IConvertible})
  Public Sub New(ByVal XX As Rep, ByVal YY As Rep)
    Me.X = XX
    Me.Y = YY
  End Sub

  Public Property X() As Rep
  Public Property Y() As Rep

  Public Function Distance(ByVal other As GPoint(Of Rep)) As Double
    Dim XDiff = (Me.X.ToDouble(Nothing)) - (other.X.ToDouble(Nothing))
    Dim YDiff = (Me.Y.ToDouble(Nothing)) - (other.Y.ToDouble(Nothing))
    Return System.Math.Sqrt((XDiff * XDiff) + (YDiff * YDiff))
  End Function

  Public Overrides Function Equals(ByVal obj As Object) As Boolean
    ' Are these the same type?
    If Me.GetType() = obj.GetType() Then
      Dim other As GPoint(Of Rep) = obj
      Return (other.X.CompareTo(Me.X) = 0) And (other.y.CompareTo(Me.Y) = 0)
    End If
    Return False
  End Function

  Public Overrides Function ToString() As String
    Return String.Format("({0},{1}", Me.X, Me.Y)
  End Function

End Class

В данном случае накладываются два ограничения: одно из них гарантирует, что любой тип Rep может быть преобразован в значения двойной точности (double) (для вычисления расстояния между двумя точками), а другое — что составляющие значения X и Y можно сравнивать, чтобы определять, больше они, меньше или равны.

И теперь причина введения обобщений становится понятнее: они поддерживают другую «ось» вариативности в проектировании, кардинально отличающуюся от традиционной, основанной на наследовании. Это позволяет проектировщику выражать реализацию в общностях, а типы, над которыми выполняются операции в этой реализации, — как вариативности.

Заметьте, что эта реализация предполагает, что вариативность осуществляется на этапе компиляции, а не при связывании/загрузки или в период выполнения; если пользователю нужно указывать тип членов X и Y класса Point в период выполнения, потребуется другое решение.

Еще не мертв (или не закончил)!

Если все проектирование программного обеспечения является гигантским упражнением в выявлении общности и вариативности, тогда потребность в понимании мультипарадигматического проектирования становится яснее: каждая из парадигм предлагает разные способы достижения общности/вариативности, а смешение парадигм создает путаницу и приводит к призывам полной переработки ПО. Так же, как человек напрочь теряется при попытке мысленно представить трехмерные конструкции в четырех- или пятимерном пространстве, слишком большое число измерений вариативности в ПО вызывает ту же растерянность.

В следующих пяти-шести статьях я буду рассматривать различные способы, которыми каждая из парадигм в C# и Visual Basic — структурная, объектно-ориентированная, метапрограммная, функциональная и динамическая парадигмы — обеспечивает функциональность для выделения общности и поддержки вариативности. Пройдя через все это, мы узнаем, как комбинировать некоторые из парадигм весьма интересными способами, чтобы сделать ваши проекты более модульными, расширяемыми, простыми в сопровождении и т. д. и т. п.

Удачи в кодировании!

Тэд Ньюард  (Ted Neward) — глава компании Neward and Associates, специализирующейся на гибких и надежных корпоративных системах с применением .NET и Java. Он автор более 100 статей, обладатель статуса MVP по C#, является спикером INETA, а также автором и соавтором десятка книг, включая «Professional F# 2.0» (Wrox, 2010). Он регулярно занимается консультированием преподаванием. Связаться с ним можно по адресуted@tedneward.com или через блог blogs.tedneward.com.

Выражаю благодарность за рецензирование статьи эксперту Энтони Грину (Anthony Green).