ПРОГРАММИРОВАНИЕ    НА    V I S U A L   C + +
РАССЫЛКА САЙТА       
RSDN.RU  

    Выпуск No. 84 от 23 марта 2003 г.
   
Подписчиков: 20304 

РАССЫЛКА ЯВЛЯЕТСЯ ЧАСТЬЮ ПРОЕКТА RSDN , НА САЙТЕ КОТОРОГО ВСЕГДА МОЖНО НАЙТИ ВСЮ НЕОБХОДИМУЮ РАЗРАБОТЧИКУ ИНФОРМАЦИЮ, СТАТЬИ, ФОРУМЫ, РЕСУРСЫ, ПОЛНЫЙ АРХИВ ПРЕДЫДУЩИХ ВЫПУСКОВ РАССЫЛКИ И МНОГОЕ ДРУГОЕ.

Здравствуйте!


 CТАТЬЯ

Метаданные в среде .Net

(окончание, начало см. No.83)

Автор: Андрей Мартынов
Источник: RSDN Magazine #2
Исходные тексты примеров(~100 кб)
Динамическое создание объекта и вызов методов

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

Создание объекта по его типу

При создании объекта вызывается его конструктор. Если вас устраивает вариант создания объекта с вызовом конструктора без аргументов (конструктора по умолчанию), то задача решается очень просто. Например, вот так:


class Class1
{
  int n = 5; 
    
  static void Main()
  {
    string className = "App1.Class1";
    Type type = Type.GetType(className);
    Object data = Activator.CreateInstance(type);
    Console.WriteLine(Trace1.ObjectFields("data", data));
    Console.ReadLine();
  }
}

Для подобного рода вещей предназначен специальный класс Activator . У него есть набор статических методов CreateInstance, с помощью которых можно создавать объекты, не только имея объект Type, но также по имени сборки и имени класса, или по файлу сборки и имени класса. Среди методов CreateInstance есть и такие, которые позволяют использовать конструкторы с аргументами и задавать пользовательские атрибуты создаваемого класса.

Примеры использования различных вариантов Activator.CreateInstance


using System;
using System.Reflection;
using System.Diagnostics;

namespace ConsoleApplication2
{
  class Class1
  {
    public static string DotNetFrameworkDir()
    {
      Assembly testAssy = Assembly.Load("mscorlib.dll");
      return System.IO.Path.GetDirectoryName(testAssy.Location);
    }
    static void Main()
    {
      // создаём объект по имени сборки и имени класса
      Object intObj = Activator.CreateInstance("mscorlib.dll"
        , "System.Int32");
      intObj = 5;
      Console.WriteLine(Trace2.ObjectFields("intObj", intObj));

      // создаём объект по файлу сборки и имени класса
      Object rgn = Activator.CreateInstanceFrom(DotNetFrameworkDir()
                              + "\\System.Drawing.dll"
                              , "System.Drawing.Region");
      Console.WriteLine(Trace2.ObjectFields("rgn", rgn));

      // Готовим параметры для вызова конструктора
      // Rectangle(Int32 x, Int32 y, Int32 width, Int32 height);
      object[] ctorArgs = { 10, 10, 100, 100 }; 

      // Грузим сборку
      Assembly assembly = Assembly.LoadWithPartialName("System.Drawing");
      Object rect = Activator.CreateInstance(assembly.FullName
          , "System.Drawing.Rectangle"
          , false            // ignoreCase, учитывать регистр!
          , 0, null          // по умолчанию
          , ctorArgs         // вот они - параметры!
          , null, null, null // всё остальное тоже по умолчанию
          );
      Console.WriteLine(Trace2.ObjectFields("rect", rect));

      Console.ReadLine();
    }
  }
}

ПРИМЕЧАНИЕ

Здесь применён простой способ получения пути к каталогу .Net Framework. Дело в том, что сборка mscorlib.dll используется в любой сборке, и потому всегда загружена. Остаётся только получить путь к папке, где лежит файл этой сборки, т.к. эта сборка всегда находится в каталоге .Net Framework.

Здесь приведён вариант создания объекта с использованием конcтруктора с параметрами. Составлен массив из параметров и передан в Activator.CreateInstance. Среди возможного множества конструкторов будет использован тот, который имеет в точности то же количество параметров и те же типы (и в том же порядке!), что и в переданном массиве параметров.

ПРИМЕЧАНИЕ

Наряду с классом Activator способностью создавать объекты по их типу обладают классы Assembly, AppDomain и некоторые другие. У них тоже есть методы CreateInstance, работа с которыми ведётся подобным образом.

Динамический вызов методов

На самом деле мы только что уже занимались динамическим вызовом методов. С конструкторами, по крайней мере, разобрались. Вызов остальных методов производится без помощи дополнительных классов типа Activator. Этим занимается сам класс Type.

Динамический вызов метода динамически созданного объекта:


static void Main()
{
  // Получаем объект-тип для System.Drawing.Rectangle
  Assembly a = Assembly.LoadWithPartialName("System.Drawing");
  AssemblyName an = a.GetName();
  Type typeRect = Type.GetType("System.Drawing.Rectangle," 
                    + an.FullName, true);

  // Готовим параметры для вызова конструктора
  // Rectangle(Int32 x, Int32 y, Int32 width, Int32 height);
  object[] ctorArgs = { 10, 10, 100, 100 }; 
  Object rect = Activator.CreateInstance(typeRect, ctorArgs, null);

  // Выводим поля динамически созданного объекта
  Console.WriteLine(Trace1.ObjectFields("rect", rect));

  // Готовим параметры вызова метода Boolean Contains(Point pt);
  System.Drawing.Point point = new System.Drawing.Point(50, 50);
  object[] argContains = { point }; 
  object contain = typeRect.InvokeMember("Contains"
                          , BindingFlags.Public 
                          | BindingFlags.InvokeMethod
                          | BindingFlags.Instance
                          , null
                          , rect
                          , argContains
                          );  
  Console.WriteLine( contain.Equals(true) ? 
                     "contains" : "does not contain");
  Console.WriteLine(Trace1.ObjectFields("point", point));
  Console.ReadLine();
}

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

Использование интерфейсов

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

Использование динамически созданного объекта через интерфейс


static void Main()
{
  // Получаем объект-тип для System.Drawing.SolidBrush
  Assembly a = Assembly.LoadWithPartialName("System.Drawing");
  AssemblyName an = a.GetName();
  Type typeBrush = Type.GetType("System.Drawing.SolidBrush,"
                                  + an.FullName, true);

  // Готовим параметры для вызова конструктора
  // SolidBrush(Color color);
  object[] ctorArgs2 = { System.Drawing.Color.Blue }; 
  Object brush = Activator.CreateInstance(typeBrush
                                      , ctorArgs2, null);

  // Выводим поля динамически созданного объекта
  Console.WriteLine(Trace1.ObjectFields("brush", brush));

  // Получаем ссылку на интерфейс
  ICloneable cloner = brush as ICloneable;
  if (cloner != null)
  {
    Object brushCloned = cloner.Clone();
    Console.WriteLine(Trace1.ObjectFields("brushCloned"
                                         , brushCloned));
  }
  Console.ReadLine();
}

Очевидно, что эффективность вызова метода динамически созданного объекта через интерфейс гораздо выше, чем через InvokeMember, ведь это обычный вызов виртуального метода. Поэтому, если есть такая возможность, лучше использовать интерфейсы.

Для получения ссылки на интерфейс ICloneable здесь применяется операция as языка C#. Эта операция возвращает ссылку на интерфейс, если он реализован объектом. Если объект не поддерживает данный интерфейс, будет возвращена пустая ссылка (null). Ниже будут подробнее рассмотрены другие способы динамического приведения типов.

Позднее связывание

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

Традиционная постановка задачи позднего связывания: предположим, у нас есть программа, позволяющая выполнять обработку каких-либо файлов. Для обработки используются классы, реализующие интерфейс FileProcessor. Список типов обрабатываемых программой файлов и соответствующих им классов-обработчиков постоянно пополняется. Сборки, содержащие новые классы-обработчики, помещаются в отдельную папку, а программа-обработчик файлов должна уметь использовать всё новые и новые классы-обработчики без перекомпиляции.

Не сомневаюсь, что многие из читающих эти строчки решали подобные задачи. Причем в разные времена эти задачи решались по-разному, начиная от LoadLibrary и GetProcAddress, и кончая COM-интерфейсами и категориями. Посмотрим, какое решение этой классической задачи предлагает технология .Net.

Интерфейс класса-обработчика будет иметь единственную функцию, определяющую, может ли данный класс обработать данный файл. Поместим интерфейс в отдельную сборку. На неё будут ссылаться конкретные классы-обработчики.

Интерфейс-обработчик.


// compile with csc /t:library FileProcessor.cs 
using System;
namespace FileProc
{
  interface IFileProcessor
  {
    bool IsSupport(string filePath);
  }
}

Теперь определим пару классов обработчиков. Один из них будет работать с текстовыми файлами, второй – с картинками в формате ВMP. При этом каждый будет реализовывать интерфейс-обработчик, и будет помещён в отдельную сборку, ссылающуюся на сборку с интерфейсом (см. рис.3).

Обработчик текстовых файлов.


// compile with csc /t:library TextProcessor.cs /r:FileProcessor.cs
using System;
using FileProc;
namespace TextProcessor
{
  public class TextProcessor : IFileProcessor
  {
    override public bool IsSupport(string filePath)
    {
      return ".txt" == IO.Path.GetExtension(filePath).ToLower();
    }
  }
}

Обработчик картинок.


// compile with csc /t:library BmpProcessor.cs /r:FileProcessor.cs
using System;
using FileProc;
namespace BmpProcessor
{
  public class BmpProcessor : IFileProcessor
  {
    override public bool IsSupport(string filePath)
    {
      return ".bmp" == IO.Path.GetExtension(filePath).ToLower();
    }
  }
}

Рисунок 4

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

Приложение, использующее позднее связывание с классами-обработчиками.


// compile with csc LateBind.cs /r:FileProcessor.cs
using System;
using System.IO;
using System.Reflection;
using FileProc;

namespace LateBind
{
  class Class1
  {
    [STAThread]
    static void Main(string[] args)
    {
      if(args.Length < 1)            
        return;
      string[] files = Directory.GetFiles(
        System.Environment.CurrentDirectory, "*.dll");
      foreach (string file in files)
      {
        Assembly assembly = Assembly.LoadFrom(file);
        Type[]   types    = assembly.GetTypes();
        foreach (Type type in types)
        {
        
          if (typeof(IFileProcessor).IsAssignableFrom(type) 
            && ! type.IsAbstract
            )
          {
            IFileProcessor processor 
              = (IFileProcessor)Activator.CreateInstance(type);
                    
            if (processor.IsSupport(args[0]))
            {
              Console.WriteLine(
                  "Assembly {0} can process file {1}."
                , assembly.GetName().Name, args[0]);
              Console.ReadLine();
              return;
            }
          }
        }
      }
      Console.WriteLine("Can't process file {0}.", args[0]);
      Console.ReadLine();
    }
  }
}

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

Динамическое приведение типов

Хочу обратить ваше внимание на то, как определялось, что тип, считанный из сборки, реализует необходимый интерфейс. В данном случае не было экземпляра объекта, к которому применялась бы операция as. Поэтому был использован метод IsAssignableFrom класса Type.

ПРИМЕЧАНИЕ

Говоря точнее, в большинстве случаев у нас есть возможность создать экземпляр объекта, а потом применить к нему as, но делать так не совсем рационально. Во-первых, создание объекта может быть недешёвым удовольствием, тем более что неизвестно, будем ли мы его использовать в дальнейшем, или он нам не подойдёт. Во-вторых, возможно, что объект всё равно не удастся создать, потому что тип может быть абстрактным классом или интерфейсом.

Класс Type имеет ещё один метод со схожей функциональностью – IsInstanceOfType. В отличие от IsAssignableFrom он принимает в качестве аргумента не объект Type, а экземпляр объекта. Метод IsInstanceOfType определяет, является ли тип объекта, переданного в аргументе, наследником типа, для которого этот метод вызван. Иначе можно это сформулировать вот так:


type.IsInstanceOfType(obj) = obj.GetType().IsAssignableFrom(type)

ПРИМЕЧАНИЕ

У Java-программистов первое время может возникнуть в голове путаница. Похожая по смыслу операция instanceof в Javа имеет такие же аргументы, но только в другом порядке. Она применяется к объекту и принимает в качестве аргумента имя типа. В Java операция instanceof совпадает по смыслу с операцией is в С#.

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

Возвращаемое значение Применяется к: Оператор или функция Тип аргумента Комментарий
bool Object is тип Оператор is позволяет определить, является ли объект (Object) наследником некоторого класса или интерфейса. Оператор также применим к массивам, и позволяет узнать, является ли объект массивом, совместимым с заданным типом.
Object Object as тип Оператор as пытается привести ссылку на объект или интерфейс (Object) к некоторому типу (возможно, интерфейсу). Если это невозможно (в смысле is), возвращается null.
bool Type IsInstanceOfType Object Позволяет определить, является ли некоторый экземпляр объекта (Object) экземпляром указанного типа (Type).
bool Type IsSubclassOf Type Позволяет определить, что тип является наследником типа, переданного в качестве аргумента, и не является абстрактным. Если типы одинаковые, возвращается false.
bool Type IsAssignableFrom Type Позволяет определить, что в переменную данного типа можно установить значение, тип которого указан в качестве параметра метода IsAssignableFrom. Также говорит о возможности приведения типов.
Атрибуты

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

ПРИМЕЧАНИЕ

Работа с атрибутами подробно освещена в статье Андрея Алифанова [5]. В ней вы найдёте подробные примеры работы, а также исходные тексты демонстрационного приложения. Наличие этой статьи на RSDN.ru сильно облегчает мою задачу и позволяет не останавливаться подробно на технической стороне дела. Я позволю себе подчеркнуть только некоторые принципиальные моменты и привести небольшой пример использования атрибутов (эту статью можно найти на нашем компакт-диске).

Декларативное программирование

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

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


[ AttributeUsage(AttributeTargets.Field) ]
public class NotTrace : Attribute
{
}

Чтобы упростить использование этого атрибута, напишем простую вспомогательную функцию:


static bool NeedTrace(FieldInfo fi)
{
  return ! Attribute.IsDefined(fi, typeof(NotTrace));
}

Теперь можно модифицировать функцию протоколирования, чтобы она выводила только те поля, у которых не задан атрибут NotTrace:


foreach (FieldInfo fi in type.GetFields(...))
{
  if (! NeedTrace(fi))
    continue;
  ... 
}

Заметьте, что теперь управление выводом полей производится не изменением кода функции протоколирования, а изменением описаний полей – заданием их атрибутов. Другими словами, судьба объекта задаётся при его декларации. Отсюда название данного метода программирования – декларативное программирование.

ПРИМЕЧАНИЕ

Советую заглянуть в статью Игоря Ткачёва "Память больше не ресурс", опубликованную в RSDN Magazine #1, где приводится изящный пример использования атрибутов. В этой статье атрибуты используются для управления автоматическим вызовом Dispose.

Код, исполняющийся в design-time

На примере атрибутов мы впервые (я, по крайней мере, впервые) столкнулись с тем, что часть кода, который пишет программист, выполняется в runtime (это как обычно), а часть кода выполняется в момент разработки (design time)! Не просто используются описания типов (декларации), а именно код! Ведь чтобы получить информацию об атрибутах, надо создать экземпляры объектов-атрибутов, а значит, в этот момент отрабатывают конструкторы атрибутов.

Вот как пишет об использовании атрибутов компилятором Э. Гуннерсон [2]:

СОВЕТ

Когда компилятор находит атрибут X, установленный для класса, он сначала ищет класс, производный от Attribute, с именем X. Не обнаружив такого класса, он начинает искать класс XAttribute. На этот раз поиск заканчивается успешно. После этого компилятор проверяет, можно ли использовать данный атрибут для классов (AttrubuteUsage). Затем начинается поиск конструктора, который бы соответствовал параметрам, указанным при установке атрибута, если такой конструктор найден, компилятор создаёт экземпляр объекта вызовом конструктора с заданными параметрами. При передаче именованных параметров компилятор сопоставляет имя параметра с именем переменной класса или свойства, после чего присваивает переменной или свойству указанное значение. Во всяком случае, так должно происходить на логическом уровне. <...>

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

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

От себя же добавлю, что такая схема работы компилятора принципиально ничего не меняет. Дело в том, что другие программные инструменты могут создавать объекты атрибутов "на самом деле", да и сам компилятор в следующих версиях .Net Framework вполне может начать работать по алгоритму, близкому к тому, по которому он работает на логическом уровне. Потому нам следует думать об атрибутах как об объектах, которые могут работать в design time.

Новые механизмы абстракции?

Теперь, когда мы завершили краткое рассмотрение методов исследования типов (методов рефлексии), у нас есть повод задуматься над вопросом: "Какое новое качество могут принести методы рефлексии в наши программы. Дают ли они в наши руки принципиально новые возможности? Думаю этот вопрос имеет положительный ответ.

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

Формулируя алгоритм с помощью абстрактных типов данных (шаблонов), вы предполагаете наличие определённых свойств у типов, которые будут «подставлены» в шаблон (параметров шаблона). Конкретная реализация алгоритма становится ясной тогда, когда становятся известны типы-параметры шаблона, т.е. во время компиляции программы, использующей шаблон. («конкретизация при компиляции»)

Формулируя алгоритм с помощью абстрактных (виртуальных) методов класса, вы откладываете окончательную реализацию алгоритма на время исполнения программы. Именно тогда, во время исполнения, решается то, какие реальные методы будут участвовать в работе алгоритма. Это зависит от того с какими конкретными объектами придётся работать алгоритму. Можно сказать, что виртуальные методы конкретизируются в runtime. («конкретизация времени исполнения»).

Но заметьте, виртуальными (абстрактными) до сих пор могли быть только методы. А если вам нужно применить тот же самый механизм конкретизации времени исполнения, но по отношению к полям объекта? А если вам необходимы абстрактные структуры данных, конкретизируемые не при компиляции, а во время исполнения? Всё это позволяет сделать reflection. Короче, reflection – это виртуализация всех элементов программы.

Теперь нам позволительно формулировать алгоритмы подобные таким:

«Найди в этой структуре поле обозначающее цену товара и верни её». Перебираем все поля класса, в поисках поля типа Currency. Заметьте вам не надо знать имя поля. Если вы знаете что поле такого типа есть и оно одно – этого достаточно!

«Свяжи поля этой структуры с аргументами той хранимой процедуры на основе типов и имён полей и параметров». В последнем номере русского MSDN отличная статья про подобного рода методы.

«Выведи на консоль поля c атрибутом «видимый» (этим мы только что занимались).

«Предложи пользователю выбрать класс, заполнить параметры его конструктора класса и создай экземпляр этого класса». Эту задачу и многие подобные ей решает программа .Net Explorer описанная в статье этом номере нашего журнала.

У меня складывается впечатление, что наконец то мы с вами стали настоящими хозяевами своих программ! Наконец то нам стало доступно то, что раньше мог делать только «его величество компилятор». Уверен, что эти новые возможности породят совершенно новые классы алгоритмов, новые подходы к программированию, что в свою очередь принесёт новое качество в наши программы. Поживём – увидим.

А пока рассмотрим ещё один приём программирования в среде .Net, тесно связанный с метаданными, и сулящий не менее революционные изменения в методах программирования.

Динамическое создание типов

Да. Заголовок этой главки написан правильно. Опечатки нет. Именно создание типа в момент исполнения программы. То есть в каком-то смысле программы начинают программировать себя сами...

Динамический "Hello World!"

Значит, начинаем процесс обучения программ программированию? Тогда не будем отступать от традиций и начнём обучение с классической программы "Hello World!".

Динамический вариант "Hello World!"


using System;
using System.Reflection;
using System.Reflection.Emit;

namespace DynHelloWorld
{
  class Programmer
  {
    static public Type WriteCode()
    {
      AssemblyName assemblyName = new AssemblyName();
      assemblyName.Name = "HelloWorldAssembly";

      AssemblyBuilder assemblyBuilder = 
        AppDomain.CurrentDomain.DefineDynamicAssembly(
              assemblyName, AssemblyBuilderAccess.Run);

      ModuleBuilder moduleBuilder = 
        assemblyBuilder.DefineDynamicModule("HelloWorldModule");

      TypeBuilder typeBuilder = 
        moduleBuilder.DefineType("HelloWorldClass"
                                , TypeAttributes.Public);

      MethodBuilder methodBuilder = 
        typeBuilder.DefineMethod("HelloWorld"
                        , MethodAttributes.Public
                        , null, null);

      ILGenerator il = methodBuilder.GetILGenerator();

      // Генерируем код.
      il.EmitWriteLine("Hello World!");
      il.Emit(OpCodes.Ret);

      return typeBuilder.CreateType();
    }
  }

  class Class1
  {
    static void Main()
    {
      Type       typeCode   = Programmer.WriteCode();
      object     objCode    = Activator.CreateInstance(typeCode);
      MethodInfo methodInfo = typeCode.GetMethod("HelloWorld");

      methodInfo.Invoke(objCode, null);

      Console.ReadLine();
    }
  }
}

ПРИМЕЧАНИЕ

Помните, в программистском юморе была история о создании HelloWorld программистами разного уровня подготовки? Самый квалифицированный программист ваял все это на COM, и сумел занять около 30 строк кода, при этом чувствовалась явная натянутость. Салага! Вот как надо писать HelloWorld'ы. На новом технологическом витке HelloWorld в 53 строки вполне строен и лаконичен. - прим. ред.

Класс-программист (класса Programmer) пишет код методом WriteCode(). Код – это метод класса HelloWorldClass, который содержится в модуле HelloWorldModule, который принадлежит сборке HelloWorldAssembly.

Класс-программист создаёт эти объекты с помощью набора соответствующих объектов-Buider'oв, прописанных в пространстве имён System.Reflection.Emit, попутно задавая атрибуты создаваемых объектов. В данном случае и тип, и метод создаются как открытые (об этом говорят флаги TypeAttributes.Public и MethodAttributes.Public).

Самое интересное, конечно – это непосредственное генерирование кода. Он в данном случае состоит всего из двух команд языка IL: вывод строки на консоль и возврат из процедуры.

Забавно? Только и всего?

ПРИМЕЧАНИЕ

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

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

Среди прилагающихся к статье примеров есть соответствующий этому случаю.

Динамическое разворачивание циклов

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

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


int result = 0;
for (int i = 1; i <= valMax; i++)
    result += i;

ПРЕДУПРЕЖДЕНИЕ

Нас интересует именно непосредственное суммирование, т.е. мы не хотим пользоваться общеизвестной формулой нахождения суммы арифметической прогрессии. Представьте себе, что наша задача как раз и состоит в проверке правильности этой формулы! 8-)

Для решения этой задачи используется простейший цикл с верхней границей valMax. Величина valMax является параметром этого алгоритма и может меняться от одного запуска алгоритма к другому.

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


int result = 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10
     + 11 + 12 + 13 + 14 + 15 + 16 + 17 + 18 + 19 + 20;

Какой смысл в таком преобразовании кода? – спросите вы. Думаю, что убедительнее всего будет вам самим произвести замер скорости выполнения двух вариантов суммирования. Вы убедитесь, что вариант с циклом проигрывает по скорости второму варианту в несколько раз! Надо сказать, что разработчики компиляторов прекрасно осведомлены в этом вопросе и давно используют приём разворачивания циклов при трансляции кода циклов.

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

Постойте, но ведь технология Reflection.Emit позволяет генерировать код во время исполнения программы. Это значит, что мы можем отложить генерирование кода цикла до того момента, когда количество необходимых повторений станет известным. В этот момент мы можем "на лету" сгенерировать код развёрнутого цикла и получить огромный выигрыш в скорости!

Динамическое разворачивание цикла


using System;
using System.Reflection;
using System.Reflection.Emit;

namespace DynUnloop
{
  // Суммирование в цикле
  class SumLooping
  {
    public int Summ(int valMax)
    {
      int result = 0;
      for (int i = 0; i <= valMax; i++)
        result += i;
      return result;
    }
  }

  // Плоское суммирование
  class SumFlat
  {
    interface ISumCode
    {
      int ComputeSumm(int valMax);
    }
    void WriteCode(int valMax)
    {
      AssemblyName assemblyName = new AssemblyName();
      assemblyName.Name = "SumFlatAssembly";

      AssemblyBuilder assemblyBuilder = 
        AppDomain.CurrentDomain.DefineDynamicAssembly(
        assemblyName, AssemblyBuilderAccess.Run);

      ModuleBuilder moduleBuilder = 
        assemblyBuilder.DefineDynamicModule("SumFlatModule");

      TypeBuilder typeBuilder = 
        moduleBuilder.DefineType("SumFlatClass"
                    , TypeAttributes.Public);

      typeBuilder.AddInterfaceImplementation(typeof(ISumCode));

      /// Задаём возвращаемое зачение и параметр
      Type[] paramTypes = { typeof(int) };
      Type   returnType = typeof(int);

      MethodBuilder methodBuilder = 
        typeBuilder.DefineMethod("ComputeSumm"
          , MethodAttributes.Public 
          | MethodAttributes.Virtual
          , returnType, paramTypes);

      ILGenerator il = methodBuilder.GetILGenerator();

      // Генерируем плоский код.
      il.Emit(OpCodes.Ldc_I4, 0);
      for (int i = 1; i <= valMax; i++)
      {
        il.Emit(OpCodes.Ldc_I4, i);
        il.Emit(OpCodes.Add);
      }
      il.Emit(OpCodes.Ret);

      // Перекрываем метод ComputeSumm и создаём тип SumFlatClass.
      MethodInfo methodInfo = typeof(ISumCode).GetMethod("ComputeSumm");
      typeBuilder.DefineMethodOverride(methodBuilder, methodInfo);
      typeBuilder.CreateType();

      /// Код готов, создаём объект и берем его интерфейс.
      code = (ISumCode)assemblyBuilder.CreateInstance("SumFlatClass");
    }

    public int Summ(int val)
    {
      if (this.code == null)
        WriteCode(val);
      return this.code.ComputeSumm(val);
    }

    ISumCode code;
  }

  class Test
  {
    static void Main()
    {
      const int valMax = 3000;
      const int countIterations = 200000;

      ////////////////////////////////////////////
      
      SumLooping sumLoop = new SumLooping();
      DateTime start = DateTime.Now;
      
      int sum = 0;
      for (int it = 0; it < countIterations; it++)
        sum = sumLoop.Summ(valMax);

      TimeSpan span = DateTime.Now - start;
      Console.WriteLine("Sum Looping. Sum = {0}, Elapsed msec= {1}"
          , sum, span.TotalMilliseconds);

      ///////////////////////////////////////////
      
      SumFlat sumFlat = new SumFlat();
      DateTime start2 = DateTime.Now;

      int sum2 = 0;
      for (int it = 0; it < countIterations; it++)
        sum2 = sumFlat.Summ(valMax);;

      TimeSpan span2 = DateTime.Now - start2;
      Console.WriteLine("Sum Flat. Sum = {0}, Elapsed msec= {1}"
        , sum2, span2.TotalMilliseconds);

      Console.ReadLine();
    }
  }
}

Вот такой получается результат:


Sum Looping. Sum = 4501500, Elapsed msec= 4967,1424
Sum Flat. Sum = 4501500, Elapsed msec= 731,0512

Выигрыш от разворачивания цикла – в 4-7 раз.

ПРИМЕЧАНИЕ

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

Результат, я думаю, получился убедительным, но вот способ получения его результата, думаю, привёл некоторых в смущение. Всё-таки генерирование Op-кодов MSIL, да ещё динамическое, при помощи методов Reflection.Emit – это занятие непростое и трудоёмкое. А нельзя ли применить обычные языки среды .Net для динамического программирования? C#, например? Можно.

Динамическое программирование на C#.

Рассмотрим ещё одну задачу. Имеется два массива целых чисел – массив А и массив Б. Наша задача подсчитать количество элементов массива Б, совпадающих с каким либо элементом массива А.

Простейшее решение


public int Search(int[] a, int[] b)
{
  int count = 0;
  foreach (int i in b)
  { 
    foreach (int j in a)
    { 
      if (i == j)
      { 
        count++; 
        break;
      } 
    } 
  }
  return countt;
}

Ясно, что быстродействие данного алгоритма зависит главным образом от внутреннего цикла. Потому можно предположить, что быстродействие существенно возросло, ели бы нам удалось заменить внутренний цикл примерно вот такой гипотетический код


switch (i)
{
  case a[0]:
  case a[1]: 
    case a[b.Length - 1]:
    return true;
}
return false;

Разработчики компиляторов достигли больших успехов в оптимизации оператора switch. Он выполняется много быстрее соответствующего цикла. Этим мы и хотим воспользоваться. Мешает одно – в операторе case могут фигурировать только константные выражения, а значения элементов массива нам при компиляции не известны.

Но известны в во время исполнения! Поэтому мы принимаем решение написать код этого switch’а во время исполнения. Надеюсь, что теперь вас это уже совсем не удивляет. Остаётся только вопрос «как?». Ответ - switch будем писать прямо на C# ! Готовы? Тогда вперёд!

Пример динамического программирования на C#.


using System;
using System.Reflection;
using System.Text;
using System.CodeDom.Compiler;
using Microsoft.CSharp;

namespace DynUnloop
{
  // Проверка в цикле
  class SearchLooping
  {
    public int Search(int[] a, int[] b)
    {
      int count = 0;
      foreach (int i in b)
      { foreach (int j in a)
        { if (i == j)
          { 
            count++; 
            break;
      } } }
      return count;
    }
  }

  // Проверка cгенерированным switch'ом
  public interface IChecker
  {
    bool Check(int valMax);
  }
  class SearchFlat
  {
    IChecker WriteCode(int[] a)
    {
      StringBuilder code = new StringBuilder();
      code.Append("\n namespace DynUnloop" );
      code.Append("\n { class Checker : IChecker" );
      code.Append("\n   { bool IChecker.Check(int n)");
      code.Append("\n     { switch (n)" );
      code.Append("\n       {" );
      foreach (int j in a)
        code.Append("\n       case " + j + ":" );
      code.Append("\n           return true;" );
      code.Append("\n       }" );
      code.Append("\n       return false;" );
      code.Append("\n } } }\n" );
      //Console.Write(code.ToString()); // проверяем сгенерированный код

      CSharpCodeProvider cs   = new CSharpCodeProvider();
      ICodeCompiler      csc  = cs.CreateCompiler();
      CompilerParameters pars = new CompilerParameters();

      pars.ReferencedAssemblies.Add( 
        Assembly.GetExecutingAssembly().Location); 

      CompilerResults result = csc.CompileAssemblyFromSource(
        pars, code.ToString()); // компилируем!

      if (result.Errors.Count != 0)
      {
        foreach(CompilerError err in result.Errors)
          Console.WriteLine(err.ToString());
        return null;
      }
      Assembly asm = Assembly.LoadFrom(result.PathToAssembly);
      return (IChecker)asm.CreateInstance("DynUnloop.Checker");
    }

    public int Search(int[] arr1, int[] arr2)
    {
      if (this.code == null)
        this.code = WriteCode(arr1);

      int result = 0;
      foreach (int i in arr2)
      {
        if (this.code.Check(i)) // используем сгенерированный код
          result++;
      }
      return result;
    }

    IChecker code = null;
  }

  class Test
  {
    [STAThread]
    static void Main(string[] args)
    {
      int[] a = { // Подсчитываем вхождения этих чисел в масссив arr2.
74, 97, 93, 86, 2, 78, 48, 14, 21, 58, 60, 5, 39, 4, 66, 9
, 31, 15, 69, 27, 37, 46, 62, 61, 81, 17, 88, 19, 44, 8 
                };
      int[] b = { // В этом массиве ищем числа из, содержащиеся в arr1.
  98, 53, 79, 47,  0, 39, 28, 18, 39, 49, 56, 17, 33, 19, 72, 13
, 28, 48, 21, 80, 10,  3, 67, 76, 83,  6, 40, 58, 23, 74, 81, 88
, 13, 48, 59, 83, 47,  1, 38, 63, 70, 21, 23, 30, 86, 71, 15, 25
, 32, 73, 23, 55, 52, 19, 90, 95, 84,  2, 63, 93, 98, 69, 93, 64
, 66, 66,  3, 84, 58, 88, 64, 26,  9, 56,  9, 88, 78, 37, 88, 11
, 89, 14, 26, 49, 50, 26, 36, 93, 56, 63, 97, 44, 37, 44, 64,  1
, 26, 58, 62, 19, 68, 30, 66, 42,  9, 96, 45, 94,  9,  2, 17, 46
, 12, 51,  3, 83, 43, 44, 14, 40, 30,  9, 27, 94, 90, 19, 87, 64
, 91,  8, 61, 20, 74, 69, 42, 59, 47, 82, 40, 52, 80, 41, 83, 54
, 45, 50, 31, 85, 41, 80, 56, 80, 44, 22, 88, 58,  3, 70, 51, 88
,  8, 80,  2,  1, 39, 96, 71, 42,  8, 43, 35, 59,  4, 60, 59, 88
, 25, 72, 48, 39, 86,  1, 23, 11, 50, 79, 74, 52, 79, 83, 56, 75
, 31, 50, 43,  0, 38, 82, 14, 48, 78, 88, 77, 97, 44, 96, 76, 83
, 61,  0, 32, 30, 22, 12,  1,  7, 56, 90, 49, 58, 21, 18, 62, 23
, 85, 58, 28, 52, 16, 58, 49, 42, 57, 98, 59, 97, 23, 25, 65, 53
,  3, 90, 89, 79, 50, 25, 53, 18, 49, 36, 42, 47, 33, 54, 27, 59 
                };

      const int countIterations = 200000;

      ////////////////////////////////////////////

      SearchLooping searchLoop = new SearchLooping();
      DateTime start = DateTime.Now;

      int count = 0;
      for (int it = 0; it < countIterations; it++)
        count = searchLoop.Search(a, b);

      TimeSpan span = DateTime.Now - start;
      Console.WriteLine(
        "Search with looping. Count = {0}, Elapsed msec= {1}"
        , count, span.TotalMilliseconds);

      ///////////////////////////////////////////

      SearchFlat searchFlat = new SearchFlat();
      DateTime start2 = DateTime.Now;

      int count2 = 0;
      for (int it = 0; it < countIterations; it++)
        count2 = searchFlat.Search(a, b);

      TimeSpan span2 = DateTime.Now - start2;
      Console.WriteLine(
        "Search with switch. Count = {0}, Elapsed msec= {1}"
        , count2, span2.TotalMilliseconds);

      Console.ReadLine();
    }
  }
}

Вот результат, говорящий сам за себя.


Search with looping. Count = 84, Elapsed msec= 22482,328
Search with switch. Count = 84, Elapsed msec= 5487,8912

СОВЕТ

Пытливый читатель конечно заметит, что динамически созданный код соревновался с не совсем оптимально написанным поиском по массиву. Было бы гораздо оптимальнее предварительно отсортировать массив, с тем чтобы потом применить двоичный поиск. А вот в этом соревновании вряд ли будет выявлен явный лидер, т.к. сам реализация switch’а сама использует двоичный поиск. Ну что ж. Тогда можно считать, что предложенный способ это всего лишь автоматизация сортировки и двоичного поиска, переложенная на плечи компилятора. 8-)

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

Это задачи с гибким (многовариантным) нижним уровнем. (Название я придумал сам, так что наверняка есть более правильное.) Что я имею в виду под словами "гибкий нижний уровень"?

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

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

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

Примером может служить реализация класса RegExp в демонстрационной версии CLR – Rotor. Класс RegExp позволяет производить поиск и замену в тексте на основе регулярных выражений. В данном случае регулярное выражение играет роль того самого гибкого метода доступа (фильтра). Динамическое генерирование метода проверки соответствия регулярному выражению, позволяет в несколько раз повысить скорость поиска и замены текста. Подробности реализации класса RegExp вы можете изучить по исходникам Rotor'а [7].

Заключение

Мне же на этом пора заканчивать. Надеюсь, мне удалось вызвать у вас интерес к обсуждаемой теме и помочь сделать первые шаги в столь увлекательной и перспективной области, как программирование с использованием метаданных. Удачи!

Что ещё почитать про метаданные в .Net
  1. Том Арчер. Основы C#. Русская редакция. 2001.
    Глава 8. Атрибуты.
    Глава 16. Получение метаданных с помощью отражения..
  2. Э. Гунерсон. Введение в C#. Питер. 2001.
    Глава 31. Подробности о рефлексии.
  3. O'Reilly Book Excerpts: Programming C#
    Chapter 18: Attributes and Reflection (by Jesse Liberty)
  4. Владислав Чистяков. CLR
    Цитата: "Если модель программирования CLR так похожа на модель программирования COM, зачем она вообще нужна? Ответ в эволюции информации о типах. Главное достоинство CLR - всепроникающая, расширяемая, качественная информация о типах."
    Сайт издательства Оптим.Ру богат и другими материалами по .Net на русском языке.
    http://www.optim.ru
  5. Андрей Алифанов. Использование атрибутов в среде .Net
    Подробно и всестороннее рассмотрение работы с атрибутами, сопровождающееся примерами кода и исходными тестами демонстрационного приложения.
    http://www.rsdn.ru
  6. Олег Степанов. Reflection в .NET
    Помимо этой статьи на сайте GotDotNet вы найдёте большое количество информации по .Net на русском языке.
    http://www.gotdotnet.ru
  7. The Shared Source CLI Beta.
    Исходные тексты демонстрационной версии CLR. Предназначены "для изучения и проведения исследований". Компилируются для двух платформ – WindowsXP и FreeBSD. В прилагающейся документации можно обратиться к документам: "Metadata definition and semantics", "Assembly Manifest Specification" и "Metadata Unmanaged API".


Ведущий рассылки: Алекс Jenter   jenter@rsdn.ru
Публикуемые в рассылке материалы принадлежат сайту RSDN.

| Предыдущие выпуски     | Статистика рассылки