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

Вводим динамические языковые средства с помощью библиотеки Gemini

Тэд Ньюард

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

Одна из тенденций в веб-сообществе, причем отнюдь не из самых недавних, — увлечение «динамическими» или «скриптовыми» языками, особенно Ruby (для которого была написана инфраструктура Ruby on Rails, иногда сокращенно называемая RoR) и JavaScript (для которого есть Node.js, позволяющая выполнять приложения на серверной стороне, а также еще сотни инфраструктур). Оба этих языка отличаются отсутствием того, к чему мы привыкли в мире C# и Visual Basic: строгого следования определению класса.

Например, в JavaScript (языке, который иногда характеризуют как «Lisp с фигурными скобками») объект — это полностью изменяемая сущность, в которую можно по мере необходимости добавлять свойства и методы:

var myCar = new Object();
myCar.make = "Ford";
myCar.model = "Mustang";
myCar.year = 1969;
myCar.makeSounds = function () {
  console.log("Vroom! Vroom!")
}

Объект myCar, когда он конструируется впервые, не имеет никаких свойств или методов — они неявно добавляются при присваивании значений данных («Ford», «Mustang», 1969 и функции) именам, показанным в коде: make, model, year и makeSounds. По сути, каждый объект в JavaScript — это просто словарь пар «имя-значение», где значение может быть либо элементом данных, либо функцией, подлежащей вызову. Среди проектировщиков этого языка такую возможность играть с информацией о типах часто называют Metaobject Protocol (MOP), а ограниченное подмножество этого протокола — аспектно-ориентированным программированием (aspect-oriented programming, AOP). Это весьма гибкий и широкий подход к объектам, который сильно отличается от того, что есть в C#. Вместо того чтобы создавать сложную иерархию классов, где вы пытаетесь охватить каждую возможную вариацию через механизм наследования, как при традиционном подходе к объектам в C#, в концепции MOP утверждается, что вещи в реальном мире не всегда одинаковы (кроме их данных, конечно), а значит, и способ их моделирования не должен быть одним и тем же.

Разработчики в сообществе Microsoft .NET Framework наверняка вспомнят, что в C# давно введено ключевое слово и тип dynamic, позволяющие объявлять ссылку на объект, члены которого распознаются в период выполнения, но это другая история. (Языковое средство dynamic упрощает написание кода, использующего отражение, а не создание MOP-разновидностей объектов.) К счастью, у разработчиков на C# теперь есть обе возможности: традиционные статические определения типов через стандартные механизмы дизайна классов в C# или гибкие определения типов через библиотеку с открытым исходным кодом — Gemini, которая, опираясь на функциональность dynamic, дает вам средства почти как в JavaScript.

Основы Gemini

Как и многие пакеты, которые уже обсуждались в этой рубрике, Gemini доступна через NuGet: команда Install-Package Gemini в Package Manager Console приводит к тому, что все это добро устанавливается в ваш проект. Однако в отличие от других пакетов Gemini устанавливается в проект не в виде одной-двух сборок (или большего числа). Вместо этого у вас появляется несколько файлов исходного кода, которые помещаются в папку Oak и напрямую добавляются в проект. (На момент написания этой статьи Gemini 1.2.7 состоит из четырех файлов: Gemini.cs, GeminiInfo.cs, ObjectExtensions.cs и текстового файла, содержащего заметки по данному выпуску.) Причина, по которой данная папка названа Oak, вполне логична: на самом деле Gemini является подмножеством более крупного проекта (называемого, как ни удивительно, Oak), который переносит очень многое из мира динамического программирования в мир ASP.NET MVC; о более крупном пакете Oak я расскажу в одной из будущих статей в этой рубрике.

Тот факт, что сама по себе Gemini поставляется как исходный код, в действительности не столь важен — ее код существует в своем пространстве имен (Oak) и просто компилируется с проектом, как и его остальные файлы исходного кода. Однако наличие файлов исходного кода позволяет до абсурда легко пошагово проходить исходный код Gemini в случае, если что-то пошло не так, или даже внимательно просматривать код, просто чтобы понять, что доступно, поскольку IntelliSense иногда полностью сбивается при использовании ключевого слова или типа dynamic.

Приступаем к работе

И вновь по своей привычке я начал создавать проект для модульного тестирования, в котором написал ряд исследовательских тестов; в этот проект я установил Gemini и протестировал его, создав простой тест в стиле «hello world»:

[TestMethod]
public void CanISetAndGetProperties()
{
  dynamic person = new Gemini(
    new { FirstName = "Ted", LastName = "Neward" });
  Assert.AreEqual(person.FirstName, "Ted");
  Assert.AreEqual(person.LastName, "Neward");
}

Здесь проявляются малозаметные на первый взгляд, но могущественные возможности: Gemini — объект в правой стороне ссылки person — это тип, в котором, по сути, нет свойств или методов до тех пор, пока ему не назначаются эти члены (как в предыдущем коде) или явным образом не добавляются к объекту через методы SetMember и GetMember, например:

[TestMethod]
public void CanISetAndGetPropertiesDifferentWays()
{
  dynamic person = new Gemini(
    new { FirstName = "Ted", LastName = "Neward" });
  Assert.AreEqual(person.FirstName, "Ted");
  Assert.AreEqual(person.LastName, "Neward");
  person = new Gemini();
  person.SetMember("FirstName", "Ted");
  person.SetMember("LastName", "Neward");
  Assert.AreEqual(person.GetMember("FirstName"), "Ted");
  Assert.AreEqual(person.GetMember("LastName"), "Neward");
}

Хотя здесь я делаю это для элементов данных, не менее легко сделать то же самое для поведенческих членов (т. е. методов), присваивая их экземплярам DynamicMethod (возвращает void) или DynamicFunction (возвращает некое значение), и все они не принимают никаких параметров. Но, если метод или функция может принимать параметр, их можно присвоить эквивалентам с «WithParam»:

[TestMethod]
public void MakeNoise()
{
  dynamic person =
    new Gemini(new { FirstName = "Ted", LastName = "Neward" });
  person.MakeNoise =
    new DynamicFunction(() => "Uh, is this thing on?");
  person.Greet =
    new DynamicFunctionWithParam(name => "Howdy, " + name);
    Assert.IsTrue(person.MakeNoise().Contains("this thing"));
}

Кстати, библиотека Gemini дает одну интересную фишку: объекты Gemini (в отсутствие любой альтернативной реализации) используют «структурную типизацию», чтобы определить, тождественны они или удовлетворяют конкретной реализации. В противоположность системам типов ООП, — где используется проверка наследования или IS-A для определения того, удовлетворяет ли данный объект ограничениям, накладываемым на тип параметра объекта, — структурно типизируемые системы просто спрашивают, имеет ли переданный объект все, что необходимо (в данном случае, члены) для корректного выполнения кода. Структурная типизация в том виде, в каком она известна в функциональных языках, в динамических языках также проходит под названием «утиная типизация» («duck typing») (но такой вариант на слух звучит не очень хорошо).

Давайте рассмотрим метод, который принимает какой-то объект и выводит сообщение с описанием этого объекта, как показано на рис. 1.

Рис. 1. Метод, который принимает объект и выводит сообщение

string SayHello(dynamic thing)
{
  return String.Format("Hello, {0}, you are {1} years old!",
    thing.FirstName, thing.Age);
}
[TestMethod]
public void DemonstrateStructuralTyping()
{
  dynamic person = new Gemini(
    new { FirstName = "Ted", LastName = 
      "Neward", Age = 42 });
    string message = SayHello(person);
    Assert.AreEqual(
      "Hello, Ted, you are 42 years old!", message);
    dynamic pet = new Gemini(
      new { FirstName = "Scooter", Age = 3, Hunter = true });
  string otherMessage = SayHello(pet);
  Assert.AreEqual("Hello, Scooter, you are 3 years old!",
    otherMessage);
}

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

Опрос

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

Рассмотрим, к примеру, метод, ожидающий объект, которому известно, как охотиться:

int Hunt(dynamic thing)
{
  return thing.Hunt();
}

Когда передается Scooter, все прекрасно работает, как показано на рис. 2.

Рис. 2. Динамическое программирование, когда оно работает

[TestMethod]
public void AHuntingWeWillGo()
{
  dynamic pet = new Gemini(
    new
    {
      FirstName = "Scooter",
      Age = 3,
      Hunter = true,
      Hunt = new DynamicFunction(() => new Random().Next(4))
    });
  int hunted = Hunt(pet);
  Assert.IsTrue(hunted >= 0 && hunted < 4);
  // ...
}

Но, когда передается объект, не знающий, как охотиться, результатом будут исключения (рис. 3).

Рис. 3. Динамическое программирование, когда оно не работает

[TestMethod]
public void AHuntingWeWillGo()
{
  // ...
  dynamic person = new Gemini(
    new
    {
      FirstName = "Ted",
      LastName = "Neward",
      Age = 42
    });
  hunted = Hunt(person);
  Assert.IsTrue(hunted >= 0 && hunted < 4);
}

Чтобы предотвратить это, метод Hunt должен проверять с помощью метода RespondsTo, существует ли необходимый член. Вот простая оболочка вокруг метода TryGetMember, возвращающая простые булевы ответы «да-нет»:

int Hunt(dynamic thing)
{
  if (thing.RespondsTo("Hunt"))
    return thing.Hunt();
  else
    // Если вы не знаете, как охотиться,
    // то вряд ли поймаете кого-нибудь
  return 0;
}

Кстати, если все это кажется вам довольно простым стереотипным кодом или оболочкой вокруг Dictionary<string,object>, вы не далеки от истины — нижележащий класс Gemini является тем самым интерфейсом Dictionary. Но типы оболочек помогают избавиться от некоторых пассы с системой типов, которые иначе были бы просто необходимы, как это происходит при использовании ключевого слова dynamic.

А что будет, если несколько объектов совместно используют похожие виды поведения? Например, четыре кошки знают, как охотиться, и было бы довольно неэффективно писать новое определение анонимного метода для них всех, особенно потому, что у них один общий охотничий инстинкт. В традиционном ООП это не представляло бы проблемы, поскольку все они были бы членами класса Cat и тем самым использовали бы одну и ту же реализацию. В MOP-системах, таких как JavaScript, обычно есть механизм, позволяющий объекту откладывать или соединять в цепочку какое-либо свойство или вызов другого объекта, называемого прототипом. В Gemini вы используете интересную комбинацию статической типизации и MOP под названием «расширения».

Прототип

Сначала вам нужен базовый тип, идентифицирующий кошек:

public class Cat : Gemini
{
  public Cat() : base() { }
  public Cat(string name) : base(new { FirstName = name }) { }
}

Заметьте, что класс Cat наследует от Gemini, обеспечивающего классу Cat всю ту динамическую гибкость, о которой мы говорили до сих пор; по сути, второй конструктор Cat использует тот же конструктор Gemini, который использовался для создания всех динамических экземпляров.

Но Gemini также позволяет нам объявлять, как именно можно расширять Cat, чтобы экземпляры Cat получали одинаковую функциональность без необходимости явно добавлять ее в каждый экземпляр по отдельности.

Расширение класса

Для практического использования этой функциональности предположим, что мы разрабатываем некое веб-приложение. Зачастую в HTML-коде нужно переименовывать некоторые сохраняемые и возвращаемые значения, чтобы избежать случайного открытия возможности встраивания стороннего HTML-кода (или, что хуже, SQL-кода):

string Htmlize(string incoming)
{
  string temp = incoming;
  temp = temp.Replace("&", "&amp;");
  temp = temp.Replace("<", "&lt;");
  temp = temp.Replace(">", "&gt;");
  return temp;
}

Об этом следует помнить в каждом объекте модели, определяемом в системе; к счастью, MOP позволяет систематически определять новые поведенческие члены в объектах модели, как показано на рис. 4.

Рис. 4. Написание методов без их написания

[TestMethod]
public void HtmlizeKittyNames()
{
  Gemini.Extend<Cat>(cat =>
  {
    cat.MakeNoise = new DynamicFunction(() => "Meow");
    cat.Hunt = new DynamicFunction(() => new Random().Next(4));
    var members = 
       (cat.HashOfProperties() as IDictionary<string, object>).ToList();
    members.ForEach(keyValuePair =>
    {
      cat.SetMember(keyValuePair.Key + "Html",
        new DynamicFunction( () =>
          Htmlize(cat.GetMember(keyValuePair.Key))));
    });
  });
  dynamic scooter = new Cat("Sco<tag>oter");
  Assert.AreEqual("Sco<tag>oter", scooter.FirstName);
  Assert.AreEqual("Sco&lt;tag&gt;oter", scooter.FirstNameHtml());
}

По сути, вызов Extend добавляет в каждый тип Cat новые методы с суффиксом «Html», чтобы к свойству FirstName можно было обращаться в HTML-безопасной версии вызовом метода FirstNameHtml.

И это можно делать полностью в период выполнения для любого производного от Gemini типа в системе.

Постоянство и прочее

Gemini не предназначена для замены всей среды C# набором динамически разрешаемых объектов — она далека от этого. По прямому назначению, внутри инфраструктуры Oak MVC библиотека Gemini используется, помимо прочего, для добавления поддержки постоянства и другого полезного поведения в классы модели и включения проверок без загромождения пользовательского кода или необходимости создания частичных классов. Однако даже вне Oak библиотека Gemini представляет весьма мощные для проектирования механизмы, о которых, возможно, помнят некоторые читатели из восьмой части моей серии статей по мультипарадигматической .NET (msdn.microsoft.com/magazine/hh205754).

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

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


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

Выражаю благодарность за рецензирование статьи эксперту Improving Enterprises Амиру Раджану (Amir Rajan).