Поделиться через


Работающий программист

Мультипарадигматическая .NET. Часть 6: параметрическое метапрограммирование

Тэд Ньюард

Тэд НьюардКогда я учился в университете, на одной из лекций по микроэкономике, обильно приправленной математическими расчетами, профессор высказал мудрую мысль, которую я до сих пор не забыл:

«Мы с вами корпим над нашим скучным предметом, и, если вы обнаружите, что не понимаете, зачем это делается, вы просто обязаны прервать меня и спросить: “Профессор Андерсон, в чем смысл?” И мы потратим несколько минут на то, чтобы вернуться к началу и прояснить, зачем мы этим занимаемся».

Те, кто прочитал все статьи из этой серии, вполне могли зайти в тупик, поэтому позвольте мне потратить несколько минут на то, чтобы вернуться к началу и прояснить, зачем мы занимаемся именно этим.

Краткое повторение

По сути, как описывается в книге Джеймса Коплена (James Coplien) «Multi-Paradigm Design for C++» (Addison-Wesley, 1998), которая и подтолкнула меня к написанию этой серии статей, все программирование сводится к выделению общности — разработке кода, отражающего неизменную часть, — и последующему использованию конструкций вариативности в конкретном языке, чтобы по-разному структурировать ее в разных обстоятельствах.

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

К счастью для читателей этого журнала, языки, предоставляемые Microsoft в Visual Studio 2010 являются мультипарадигматическими, а это значит, что они объединяют в себе несколько парадигм программирования. Коплен первым определил C++ как мультипарадигматический язык, включающий три основные парадигмы: процедурную, объектную и мета-программную (иногда точнее называемую мета-объектной). C++ также широко критиковали как чрезмерно сложный язык, слишком трудный для полного освоения средним программистом — по большей части из-за неясности того, когда именно следует использовать различные языковые средства для решения конкретных задач.

Современные языки часто становятся в высшей степени мультипарадигматическими. Например, F#, C# и Visual Basic в Visual Studio 2010 прямо поддерживают пять парадигм: процедурную, объектно-ориентированную, мета-объектную, динамическую и функциональную. В таком случае все три этих языка — даже четыре, если считать C++/CLI, — подвергаются риску повторить ту же судьбу, что и у C++.

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

Процедурная и объектно-ориентированная парадигмы

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

Также не забывайте о другой проблеме: при таком наследовании возможна (по большей части) только позитивная вариативность — мы не можем что-то удалить из базового класса, например метод-член или поле. В CLR можно скрыть доступные для наследования члены затенением (скажем, используя ключевое слово virtual вместо override в C#). Однако это подразумевает изменение их поведения каким-либо иным образом, а не полное исключение. Поля в любом случае остаются.

Это наблюдение ведет к неприятному для некоторых откровению: объекты не могут делать все, что нам нужно, — по крайней мере, чистые объекты. Например, объекты не позволяют инкапсулировать вариативность по структурным линиям наследования: класс набора способен инкапсулировать поведение в стиле стека, но для множества других типов данных (целых, двойной точности, строк и др.) он не может отражать это структурное различие. Согласен, внутри CLR можно использовать унифицированную систему типов. Можно хранить ссылочные экземпляры System.Object и приводить их к более простым типам при необходимости, но это не то же самое, что возможность создать тип, который хранит лишь один тип.

Осознание этого подводит нас к предмету мета-объектного программирования, поскольку мы ищем способы представлять такие вещи, которые находятся вне традиционной объектной системы координат.

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

Второй подход к мета-объектной парадигме заключался в рефлексивно-ориентированном (reflective), или атрибутивном (attributive) программировании. В период выполнения используются средства поддержки достоверных метаданных конкретной платформы (механизм отражения) для анализа кода и изменения поведения в зависимости от результатов анализа.

Это обеспечивало гибкость реализации или поведения на основе принятия решений в период выполнения, но вводило свои ограничения: в архитектуре на основе отражения или атрибутов нет никаких отношений между типами, а значит, нет и никакого способа программно удостовериться, что методу могут быть переданы, например, только типы, которые можно сохранять в базе данных, в противоположность типам, сохраняемым в формате XML. Отсутствие наследования или других видов отношений ведет к потере некоторой доли безопасности типов (и, следовательно, важной возможности предотвращать ошибки).

А это подводит нас к третьему средству мета-объектного программирования в среде .NET: параметрическому полиморфизму. Под ним подразумевается возможность определения типов, у которых есть типы в виде параметров. Или, проще говоря, то, что в Microsoft .NET Framework называют обобщениями (generics).

Обобщения

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

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

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

Таким образом, на первый взгляд, тщательно продуманная библиотека точек в декартовых координатах должна содержать несколько разных типов Point: в одном для полей X и Y используются беззнаковые байты, во втором для тех же полей — типы double и т. д. И такое поведение по большей части будет идентичным для всех этих типов, а это ярко показывает нарушение нашего стремления к инкапсуляции общности (что в разговорной речи нередко называют принципом DRY (Don’t Repeat Yourself) [«не повторяйся»]).

Используя параметрический полиморфизм, можно весьма изящно инкапсулировать общность:

class Point2D<T> {
  public Point2D(T x, T y) { this.X = x; this.Y = y; }

  public T X { get; private set; }
  public T Y { get; private set; }
  // Остальные методы оставляю на воображение читателей
}

Now the developer can specify precisely the range and type properties of the Cartesian point he wishes to use. When working in a mathematical domain, he creates instances of Point2D<double> values, and when working to display those values to the screen, he creates instances of Point2D<sbyte> or Point2D<ushort>. Each is its own distinct type, so attempts to compare or assign Point2D<sbyte> to Point2D<double> will fail miserably at compile time, exactly as a strongly typed language would prefer.

Однако у типа Point2D все еще сохраняются некоторые недостатки. Мы определенно захватили общность точек в декартовых координатах, но фактически разрешили использовать в качестве X и Y любые значения. Хотя в ряде ситуаций это, безусловно, полезно («на этой диаграмме отражены рейтинги, выставленные каждым зрителем конкретному фильму»), попытка создать Point2D<DateTime> может вызвать замешательство, а уж попытка создать Point2D<System.Windows.Forms.Form> вызовет его почти наверняка. Здесь нужно ввести какую-то разновидность негативной вариативности (или, если вы так предпочитаете, регулировку степени позитивной вариативности), ограничив диапазон возможных типов до тех, которые могут выступать в роли значений координат в Point2D.

Во многих .NET-языках эта негативная вариативность реализуется ограничениями параметризации (иногда называемыми ограничениями типа) за счет явного описания условий, которым должен соответствовать параметр-тип:

class Point2D<T> where T : struct {
  public Point2D(T x, T y) { this.X = x; this.Y = y; }

  public T X { get; private set; }
  public T Y { get; private set; }
  // Остальные методы оставляю на воображение читателей
}

Это означает, что компилятор не примет в качестве T любой тип, отличный от значимого.

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

Варьирование поведения

Параметрический полиморфизм обычно используется для поддержки вариативности в структуризации, но, как продемонстрировали разработчики библиотек C++ Boost, это не единственное направление, для которого пригодна эта парадигма. При осмотрительном использовании ограничений типа обобщения также позволяют предоставлять механизм политик, в котором клиенты могут указывать поведенческий механизм для конструируемых объектов.

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

Мы начинаем с определения интерфейса:

interface ILoggerPolicy {
  void Log(string msg);
}

Это простой интерфейс с одним или более методами, определяющими поведение, которое нам нужно варьировать, и для этого мы создаем несколько подтипов этого интерфейса:

class ConsoleLogger : ILoggerPolicy {
  public void Log(string msg) { Console.WriteLine(msg); }
}

class NullLogger : ILoggerPolicy {
  public void Log(string msg) { }
}

Здесь мы имеем две возможные реализации, одна из которых выводит сообщение в консоль, а другая его отбрасывает.

Использование этого кода требует от клиента объявления регистратора (logger) как типизированного параметра и создания его экземпляра для выполнения реальной работы:

class Person<A> where A : ILoggerPolicy, new() {
  public Person(string fn, string ln, int a) {
    this.FirstName = fn; this.LastName = ln; this.Age = a;
    logger.Log("Constructing Person instance");
  }

  public string FirstName { get; private set; }
  public string LastName { get; private set; }
  public int Age { get; private set; }

  private A logger = new A();
}

Далее, чтобы указать, какой тип регистратора следует использовать, просто передается параметр на этапе работы конструктора, например:

Person<ConsoleLogger> ted =
  new Person<ConsoleLogger>("Ted", "Neward", 40);
var anotherTed  =
  new Person<NullLogger>("Ted", "Neward", 40);

Этот механизм позволяет разработчикам создавать собственные реализации регистратора и подключать их для использования экземплярами Person<>; при этом разработчику Person<> не требуется знать никаких деталей применяемого механизма протоколирования. Аналогичное возможно и при использовании множества других подходов, например создают поле или свойство Logger, которое передает экземпляр Logger извне (или получают через встраивание зависимости). Однако у подхода с обобщениями есть одно преимущество, которого нет у подхода на основе полей, — различение типов на этапе компиляции: Person<ConsoleLogger> и Person<NullLogger> являются разными, четко различаемыми типами.

Деньги, деньги, деньги

Одна из проблем, досаждающих разработчикам, заключается в том, что величины бесполезны без соответствующих единиц измерения. Одна тысяча центов — вовсе не то же самое, что 1000 лошадей или 1000 сотрудников или 1000 пицц. И в то же время совершенно очевидно, что 1000 центов и 10 долларов — фактически одно и то же значение.

Это становится еще важнее в математических расчетах, где нужно учитывать единицы измерения (градусы/радианы, футы/метры, градусы по Фаренгейту/Цельсию), особенно если вы пишете программное обеспечение, управляющее полетом ракеты. Вспомните ракету Ariane 5, при первом полете которой пришлось выдать команду на самоуничтожение из-за ошибки в одном из преобразований. Или один из марсианских зондов NASA, который на полной скорости врезался в марсианский грунт из-за ошибки преобразования.

Недавно в новых языках вроде F# решили сделать единицы измерения непосредственно языковым средством, но даже C# и Visual Basic позволяют делать аналогичные вещи благодаря обобщениям.

Давайте начнем с простого класса Money, которому известны величина (количество) и валюта (тип) конкретной денежной суммы:

class Money {
  public float Quantity { get; set; }
  public string Currency { get; set; }
}

На первый взгляд он кажется вполне работоспособным, но очень скоро мы захотим выполнять над ним такие операции, как сложение экземпляров Money (весьма распространенная, если подумать, операция в денежных расчетах):

class Money {
  public float Quantity { get; set; }
  public string Currency { get; set; }

  public static Money operator +(Money lhs, Money rhs) {
    return new Money() {
      Quantity = lhs.Quantity + rhs.Quantity,
      Currency = lhs.Currency };
  }
}

Конечно, проблема возникнет, когда вы попытаетесь сложить доллары США (USD) и европейские евро (EUR), например отправившись на ланч (в конце концов, всем известно, что европейское пиво лучшее, зато в Америке делают лучшую пиццу):

var pizza = new Money() {
  Quantity = 4.99f, Currency = "USD" };
var beer = new Money() {
  Quantity = 3.5f, Currency = "EUR" };
var lunch = pizza + beer;

Любой, кто хоть раз обращал внимание на курсы обмена валют, тут же сообразит, что ему собираются обчистить карманы: евро конвертируются в доллары по курсу «один к одному». Чтобы предотвратить случайное мошенничество, мы, вероятно, предпочтем убедиться, что компилятор понимает недопустимость преобразования USD в EUR без выполнения утвержденного процесса конвертации, где учитывается текущий курс обмена валют (рис. 1).

Рис. 1. Правомерная конвертация валют

class USD { }
class EUR { }
class Money<C> {
  public float Quantity { get; set; }
  public C Currency { get; set; }

  public static Money<C> operator +(
    Money<C> lhs, Money<C> rhs) {
    return new Money<C>() {
      Quantity = lhs.Quantity + rhs.Quantity,
      Currency = lhs.Currency };
  }
}
...
var pizza = new Money<USD>() {
  Quantity = 4.99f, Currency = new USD() };
var beer = new Money<EUR>() {
  Quantity = 3.5f, Currency = new EUR() };
var lunch = pizza + beer;    // ОШИБКА

Notice how USD and EUR are basically just placeholders, designed to give the compiler something to compare against. If the two C type parameters aren’t the same, it’s a problem.

Конечно, попутно мы потеряли возможность комбинировать эти два типа, а необходимость делать именно это время от времени возникает. Для этого потребуется более сложный параметрический синтаксис (рис. 2).

Рис. 2. Преднамеренное комбинирование типов

class USD { }
class EUR { }
class Money<C> {
  public float Quantity { get; set; }
  public C Currency { get; set; }

  public static Money<C> operator +(
    Money<C> lhs, Money<C> rhs) {
    return new Money<C>() {
      Quantity = lhs.Quantity + rhs.Quantity,
      Currency = lhs.Currency };
  }

  public Money<C2> Convert<C2>() where C2 : new() {
    return new Money<C2>() { Quantity =
      this.Quantity, Currency = new C2() };
  }
}

Это специализированный обобщенный метод в обобщенном классе, и синтаксис <> после имени метода вводит в область видимости метода больше параметров-типов — в данном случае тип второй валюты, в которую осуществляется конвертация. Таким образом, теперь покупка пиццы и пива превращается в нечто вроде:

var pizza = new Money<USD>() {
  Quantity = 4.99f, Currency = new USD() };
var beer = new Money<EUR>() {
  Quantity = 3.5f, Currency = new EUR() };
var lunch = pizza + beer.Convert<USD>();

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

Заключение

В примере с Money<> явно не хватает какого-либо способа конвертации долларов в евро и евро в доллары. Но цель таких проектов отчасти заключается в том, чтобы избегать построения закрытых систем, т. е. по мере появления новых валют (рублей, рупий, фунтов стерлингов, лир или другой валюты) было бы очень неплохо, чтобы нас, первоначальных проектировщиков типа Money<>, никто не призывал для их суммирования. В идеале, в открытой системе у других программистов должна быть возможность подключать то, что нужно им, и все должно «просто работать».

Но не отключайтесь и, уж конечно, не начинайте поставку этого кода «как есть». В будущем мы внесем несколько изменений в тип Money<>, чтобы сделать его еще более мощным, безопасным и расширяемым. И заодно рассмотрим динамическое и функциональное программирование.

Ну а пока — удачи в кодировании!


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

Выражаю благодарность за рецензирование статьи эксперту Кржиштофу Квалине (KrzysztofCwalina).