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

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

Тэд Ньюард

Ted NewardВ предыдущей статье мы покончили с третьим из трех механизмов метапрограммирования, поддерживаемых языками в Microsoft .NET Framework (параметрическим полиморфизмом, или обобщениями), и обсудили, как он обеспечивает вариативность в отношении структур и поведений. На сегодняшний день параметрическое метапрограммирование дает нам некоторые весьма впечатляющие возможности. Но это не панацея, не ответ на все проблемы проектирования — всех ответов нет ни в одной отдельно взятой парадигме программирования.

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

Рис. 1. Money<>

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() };
  }
}

Как указывалось в прошлый раз, поддержка такого преобразования важна, и именно для этого предназначена процедура Convert<> — она позволяет конвертировать доллары в евро или в песо, или в канадские доллары, или в любую другую валюту.

Но это подразумевает наличие какого-то кода для конвертации валют, чего явно нет в реализации на рис. 1; на данный момент мы выполняем конвертацию по курсу «один к одному», просто заменяя текущий тип свойства Currency на новый — C2, и так у нас ничего не получится.

Мои деньги, ваши деньги — все это просто фантики

Чтобы устранить проблему, нужна некая процедура конвертации, выполняющая пересчет валют, и решения могут быть самыми разнообразными. Одним из подходов могло бы быть использование оси наследования и преобразование USD и EUR в типы ICurrency с процедурами, созданными для конвертации именно этих валют. Если пойти этим путем, то все начнется с определения типа ICurrency и обозначения USD и EUR как реализаторов этого интерфейса (рис. 2).

Рис. 2. ICurrency

interface ICurrency { }
class USD : ICurrency { }
class EUR : ICurrency { }
class Money<C> where C : ICurrency {
  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() };
  }
}

Эта стратегия отлично работает — пока. По сути, дополнительное ограничение типа в параметре-типе Money<> — полезное расширение, гарантированно избавляющее от передачи Money<string> или Money<Button>. Выглядит необычно, но этот прием (известный в Java как идиома интерфейса маркера) служит интересной и важной цели.

В Java до версии 5, в которой появился эквивалент собственных атрибутов, мы использовали этот прием для размещения статических объявлений в типах. В .NET Framework атрибут [Serializable] указывает, что данный класс можно сериализовать в поток байтов, а в Java аналогичные классы реализуются (наследуются) от интерфейса Serializable, в котором нет членов.

Ограничения типов дополняют собственные атрибуты, и введение такого ограничения в C является важным расширением, поэтому мы прибегли к интерфейсу маркера (marker interface). Немного необычно, но, если рассматривать интерфейсы как способ создания декларативных утверждений о том, что представляет собой конкретный тип, а не о том, что он может делать, это имеет смысл. (Мы добавим конструкторы, которые в какой-то мере облегчат создание экземпляров Money<>.)

Но попытка объявить преобразование валют в ICurrency немедленно вызывает проблему: ICurrency ничего не знает о подтипе (конкретном типе валюты), из-за чего здесь нельзя объявить метод, который принимал бы Money<USD> и преобразовывал бы в Money<EUR> через какое-нибудь автоматически изменяемое вычисление, необходимое для конвертации. (На самом деле в данном случае реализация должна быть основана на какой-то разновидности поиска в Интернете или веб-сервиса, но пока что давайте останемся на позициях статического программирования.) Но даже если бы это было возможно, написание такого рода метода оказалось бы делом крайне затруднительным, поскольку нам понадобилась бы диспетчеризация на основе двух типов (исходной и конечной валют) и с единственным параметром (количеством конвертируемой валюты).

Учитывая, что мы предпочитаем рассматривать валюту как тип, первым шагом в написании этого метода было бы следующее:

interface ICurrency {
  float Convert<C1, C2>(float from);
}

Затем, казалось бы, можно было бы написать нечто вроде показанного ниже для специализации метода Convert в производных типах:

class USD : ICurrency {
  public float Convert<USD, EUR>(float from) {
    return (from * 1.2f); }
  public float Convert<EUR, USD>(float from) {
    return (from / 1.2f); }
}

Увы, это было бы неправильно. Компилятор интерпретирует USD и EUR как параметры-типы — так же, как C1 и C2.

Далее мы могли бы попробовать так:

class USD : ICurrency {
  public float Convert<C1,C2>(float from) {
    if (C1 is USD && C2 is EUR) {
    }
  }
}

Но компилятор опять пожалуется: C1 является параметром-типом, а используется как переменная. Иначе говоря, использовать C1 так, будто он является самим типом, нельзя. Это просто параметр для подстановки. Вот и все — тупик.

Одно из потенциально возможных решений — прибегнуть к простой передаче типов как параметров Type на основе механизма отражения, что позволит написать примерно такой код, который показан на рис. 3.

Рис. 3. Применение параметров Type на основе отражения

interface ICurrency {
  float Convert(Type src, Type dest, float from);
}

class USD : ICurrency {
  public float Convert(Type src, Type dest, float from) {
    if (src.Name == "USD" && dest.Name == "EUR")
      return from / 1.2f;
    else if (src.Name == "EUR" && dest.Name == "USD")
      return from * 1.2f;
    else
      throw new Exception("Illegal currency conversion");
  }
}

class EUR : ICurrency {
  public float Convert(Type src, Type dest, float from) {
    if (src.Name == "USD" && dest.Name == "EUR")
      return from / 1.2f;
    else if (src.Name == "EUR" && dest.Name == "USD")
      return from * 1.2f;
    else
      throw new Exception("Illegal currency conversion");
  }
}

class Money<C> where C : ICurrency, new() {
  public Money() { Currency = new C(); }
  public Money(float amt) : this() { Quantity = amt; }

  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>(lhs.Quantity + rhs.Quantity);
  }

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

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

Что заключено в имени?

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

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

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

Динамические языки и динамическое программирование

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

И, конечно же, .NET Framework дает смекалистому проектировщику ту же гибкость в связывании через отражение. Вы можете создать статический класс, содержащий названия валют, а затем вызывать его, используя отражение, как показано на рис. 4.

Рис. 4. Динамическое связывание с помощью отражения

static class Conversions {
  public static Money<EUR> USDToEUR(Money<USD> usd) {
    return new Money<EUR>(usd.Quantity * 1.2f);
  }

  public static Money<USD> EURToUSD(Money<EUR> eur) {
    return new Money<USD>(eur.Quantity / 1.2f);
  }
}

class Money<C> where C : ICurrency, new() {
  public Money() { Currency = new C(); }
  public Money(float amt) : this() { Quantity = amt; }

  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>(lhs.Quantity + rhs.Quantity);
  }

  public Money<C2> Convert<C2>() where C2 : ICurrency, new() {
    MethodBase converter = typeof(Conversions).GetMethod(
      typeof(C).Name + "To" + typeof(C2).Name);
    return (Money<C2>)converter.Invoke(null, new object[]
      { this });
  }
}

Добавление новой валюты, например британских фунтов, сводится к созданию пустого класса GBP (реализующего ICurrency) и добавлению необходимых процедур конвертации в Conversions.

Разумеется, в C# 4 (и любой версии Visual Basic) есть встроенные средства, которые упрощают эту задачу при условии, что нам известно нужное имя на этапе компиляции. C# предоставляет динамический тип, а Visual Basic уже десятилетиями поддерживает Option Strict Off и Option Explicit Off.

По сути, как показывает Apple Objective-C, динамическое программирование не обязательно ограничено интерпретируемыми языками. Objective-C — компилируемый язык, в котором динамическое программирование используется повсюду, особенно для связывания обработчиков событий. Клиенты, которым нужно получать события, просто предоставляют метод обработки событий с правильным именем. Когда источник хочет уведомить клиент о чем-то интересном, он ищет метод по имени и вызывает его, если таковой есть. (Для тех, кто помнит совсем давние времена, точно так же работал и Smalltalk.)

Конечно, разрешения связи по имени имеет свои минусы, большая часть которых проявляется в обработке ошибок. Что будет, когда ожидаемого вами метода или класса не окажется? В некоторых языках (например, Smalltalk и Apple-реализации Objective-C) просто делают вид, что ничего не произошло. В других (скажем, Ruby) предполагается, что в таком случае следует генерировать ошибку или исключение.

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

Создание общности

Вариативность, связанная с именами, представляет мощный механизм для анализа общности/вариативности, но динамическое программирование на этом не заканчивается. Благодаря впечатляющим средствам работы с метаданными в CLR становится возможным создание общности на основе критериев, отличных от имен: возвращаемых методами типов, типов параметров методов и т. д. Фактически можно было бы привести доводы в пользу того, что атрибутивное метапрограммирование — на самом деле просто побочная ветвь динамического программирования, основанная на собственных атрибутах. Более того, вариантивность, связанная с именами, не обязательно должна привязываться к полным именам. В ранних сборках инфраструктуры модульного тестирования NUnit предполагалось, что тестируемым методом является любой метод, имя которого начинается со слова «test».

В следующей статье мы рассмотрим последнюю из парадигм, общую для языков .NET Framework: функциональное программирование. Попутно мы обсудим, как оно предоставляет еще один способ анализа общности/вариативности, почти диаметрально противоположный тому, который применяется в традиционной «объектологии».


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

Выражаю благодарность за рецензирование статьи эксперту Мирче Трофин (Mircea Trofin).