Динамическая генерация кода - это прием программирования, заключающийся в том, что фрагменты кода порождаются и запускаются непосредственно во время выполнения программы. Этот прием был известен достаточно давно, но усложнение архитектуры компьютеров, и, что особенно важно, усложнение наборов команд процессоров привело к тому, что в последние 10-15 лет динамическая генерация кода в некоторой степени потеряла популярность.
Целью динамической генерации кода является использование информации, доступной только во время выполнения программы, для повышения качества исполняемого кода. В терминах метавычислений можно сказать, что динамическая генерация кода позволяет специализировать фрагменты программы по данным, известным во время выполнения.
В некотором смысле, любой
Естественно, не стоит чересчур увлекаться динамической генерацией кода: этот прием далеко не всегда дает ускорение программы. Можно сказать, что применение динамической генерации оправдано, если:
В .NET доступно два способа организации динамической генерации кода:
Если сравнить эти два способа, можно придти к выводу, что порождение C#-программы несколько проще, нежели генерация CIL-кода. Однако, наличие в библиотеке классов пространства имен System. позволяет избежать рутинных и трудоемких операций по работе с физическим представлением метаданных и CIL-кода. Кроме того, генерация CIL-кода выполняется на порядок быстрее и дает большую гибкость. Поэтому для программиста, знакомого с набором инструкций CIL, второй способ является более предпочтительным.
В этом разделе мы рассмотрим простой пример программы на языке C#, выполняющей численное интегрирование функции, которую пользователь вводит с клавиатуры (то есть интегрируемая функция становится известной только в процессе выполнения программы). Исходный код примера приведен в Приложении B. Характерной особенностью задачи
Мы будем выполнять вычисление значения функции тремя способами:
Затем мы сравним эффективность каждого способа.
Для интегрирования функций нам потребуется некое представление функции, которое бы не зависело от конкретного способа вычисления значения функции. Идеальным вариантом такого представления является абстрактный класс Function:
public abstract class Function
{
public abstract double Eval(double x);
}
Объявив такой класс, мы предполагаем, что от него будут наследоваться другие классы, реализующие в методе Eval конкретный способ вычисления значения функции в заданной точке.
Имея класс Function, мы можем записать обобщенный алгоритм интегрирования f, представляющий интегрируемую функцию, пределы интегрирования a и b, а также количество разбиений n:
static double Integrate(Function f, double a, double b, int n)
{
double h = (b-a)/n, sum = 0.0;
for (int i = 0; i < n; i++)
sum += h*f.Eval((i+0.5)*h);
return sum;
}
Для проверки работоспособности алгоритма можно объявить тестовый класс TestFunction, реализующий вычисление функции f(x) = x * sin(x):
public class TestFunction: Function
{
public override double Eval(double x)
{
return x * Math.Sin(x);
}
}
В нашем примере пользователь будет вводить выражение с клавиатуры, то есть оно будет представлено в виде текстовой строки. Такое представление неудобно ни для непосредственного вычисления значения функции, ни для генерации кода, вычисляющего ее значение. Поэтому нам понадобится
Детали синтаксического анализа и представления выражений мы рассматривать не будем: интересующиеся могут обратиться к полным исходным текстам примера. Скажем лишь, что анализ осуществляется методом рекурсивного спуска и транслирует выражение в дерево, в узлах которого расположены объекты, представляющие арифметические операции и их операнды. Каждый из этих объектов является экземпляром одного из четырех классов, обозначающих числовые константы, переменные, унарные и Expression:
public abstract class Expression
{
public abstract string GenerateCS();
public abstract void GenerateCIL(ILGenerator il);
public abstract double Evaluate(double x);
}
В классе Expression объявлены три выполняет непосредственное вычисление значения выражения, метод GenerateCS транслирует выражение в фрагмент программы на C#, а метод GenerateCIL транслирует выражение в CIL-код.
Вопросы генерации кода будут обсуждаться в следующих разделах, поэтому сейчас мы только приведем пример CIL-кода, генерируемого методом GenerateCIL для дерева объектов, которые представляют выражение "2*x*x*x+3*x*x+4*x+5":
ldc.r8 2.0 ldarg.1 mul ldarg.1 mul ldarg.1 mul ldc.r8 3.0 ldarg.1 mul ldarg.1 mul add ldc.r8 4.0 ldarg.1 mul add ldc.r8 5.0 add
Метод GenerateCS фактически восстанавливает из дерева строковое представление выражения и в особых комментариях не нуждается.
Как уже говорилось, самым простым способом динамической генерации кода является порождение текста C#-программы и компиляция этой программы с помощью компилятора C#, доступного через библиотеку классов .NET.
В нашем примере динамическую генерацию сборки осуществляет статический метод CompileToCS, который получает транслируемое выражение в виде объекта класса Expression и возвращает объект Function:
static Function CompileToCS(Expression expr)
{
ICodeCompiler compiler = new CSharpCodeProvider().CreateCompiler();
CompilerParameters parameters = new CompilerParameters();
parameters.ReferencedAssemblies.Add("System.dll");
parameters.ReferencedAssemblies.Add("Integral.exe");
parameters.GenerateInMemory = true;
string e = expr.GenerateCS();
string code = "public class FunctionCS: Function\n"+
"{\n"+
" public override double Eval(double x)\n"+
" {\n"+
" return "+e+";\n"+
" }\n"+
"}\n";
CompilerResults compilerResults =
compiler.CompileAssemblyFromSource(parameters,code);
Assembly assembly = compilerResults.CompiledAssembly;
return assembly.CreateInstance("FunctionCS") as Function;
}
Классы, отвечающие за компиляцию исходного кода, относятся к пространству имен System.CodeDom.Compiler.
Основную функциональность, необходимую нам для компиляции сгенерированной C#-программы, обеспечивает класс CSharpCodeProvider. Метод CreateCompiler этого класса создает экземпляр компилятора C#, к которому можно обращаться через интерфейс ICodeCompiler.
Параметры компиляции задаются через объект класса CompilerParameters. В нашем случае это имена сборок, импортируемых генерируемой программой: System.dll и Integral.exe. Обратите внимание, что Integral.exe - это сборка, получаемая при компиляции рассматриваемого нами примера. Она импортируется по причине того, что динамически генерируемый класс FunctionCS должен наследовать от определенного в ней абстрактного класса Function.
Параметры компиляции и строка, содержащая текст программы, передаются методу CompileAssemblyFromSource экземпляра компилятора C#. Метод компилирует программу и возвращает объект класса CompilerResults, содержащий результаты компиляции. Из этого объекта мы можем получить объект рефлексии Assembly, представляющий созданную в памяти динамическую сборку. Используя данный объект, мы создаем экземпляр определенного в динамической сборке класса FunctionCS, который в дальнейшем может быть использован для вычисления значения функции в процессе интегрирования.
Более сложный, но эффективный способ динамической генерации кода предоставляется классами, относящимися к пространству имен System.. Эти классы в нашем примере используются в статическом методе CompileToCIL, который осуществляет трансляцию выражения напрямую в CIL:
static Function CompileToCIL(Expression expr)
Метод начинается с создания заготовки для будущей динамической сборки. Сборка будет выполняться в том же Thread.GetDomain. Затем вызываем метод DefineDynamicAssembly домена приложений и получаем объект класса AssemblyBuilder, позволяющий строить динамическую сборку:
AppDomain appDomain = Thread.GetDomain();
AssemblyName assemblyName = new AssemblyName();
assemblyName.Name = "f";
AssemblyBuilder assembly =
appDomain.DefineDynamicAssembly(
assemblyName,
AssemblyBuilderAccess.RunAndSave
);
Теперь мы можем создать в FunctionCIL, наследующий от класса Function. Обратите внимание, что при генерации кода через классы пространства имен System. явно прописывать импортируемые сборки не надо (например, не надо прописывать сборку Integral.exe, из которой импортируется класс Function ), так как это выполняется автоматически:
ModuleBuilder module =
assembly.DefineDynamicModule("f.dll", "f.dll");
TypeBuilder typeBuilder =
module.DefineType(
"FunctionCIL",
TypeAttributes.Public | TypeAttributes.Class,
typeof(Function)
);
В каждом классе должен быть конструктор. Компилятор C# создает конструкторы без параметров по умолчанию, поэтому при генерации C#-кода нам не надо было явно объявлять конструктор в классе FunctionCS. Однако, при генерации динамической сборки через классы пространства имен System. конструкторы автоматически не добавляются, и нам придется сделать это самостоятельно:
ConstructorBuilder cons =
typeBuilder.DefineConstructor(
MethodAttributes.Public,
CallingConventions.Standard,
new Type[] { }
);
ILGenerator consIl = cons.GetILGenerator();
consIl.Emit(OpCodes.Ldarg_0);
consIl.Emit(OpCodes.Call,
typeof(object).GetConstructor(new Type[0]));
consIl.Emit(OpCodes.Ret);
Разобравшись с конструктором, переходим к методу Eval. Как уже говорилось, код этого метода почти полностью генерируется в методе GenerateCIL выражения, остается лишь добавить в конец инструкцию :
MethodBuilder evalMethod =
typeBuilder.DefineMethod(
"Eval",
MethodAttributes.Public | MethodAttributes.Virtual,
typeof(double),
new Type[] { typeof(double) }
);
ILGenerator il = evalMethod.GetILGenerator();
expr.GenerateCIL(il);
il.Emit(OpCodes.Ret);
Итак, мы закончили формирование класса FunctionCIL. Осталось создать для него объект рефлексии и через этот объект вызвать конструктор:
Type type = typeBuilder.CreateType(); ConstructorInfo ctor = type.GetConstructor(new Type[0]); return ctor.Invoke(null) as Function;
Таким образом, получается объект класса FunctionCIL, который в дальнейшем можно использовать для вычисления значения функции в процессе интегрирования.
Давайте оценим эффективность рассмотренных способов вычисления выражений при интегрировании. Для этого будем интегрировать функцию "2*x*x*x+3*x*x+4*x+5" от 0.0 до 10.0 с 10000000 разбиений.
В таблице 5.1 представлены результаты измерений, проведенных на компьютере с процессором Intel Pentium 4 с тактовой частотой 3000 МГц и 1 Гб оперативной памяти.
| Способ вычисления значения функции | Время на создание динамической сборки, мс | Время вычисления интеграла функции, мс |
|---|---|---|
| Интерпретация дерева выражения | - | 29422 |
| Предварительная компиляция С# | 547 | 172 |
| Предварительная компиляция в CIL | 63 | 172 |
Результаты показывают, что динамическая генерация кода может на два порядка уменьшить время работы программы.
В этом разделе мы рассмотрим специфику генерации линейных участков кода на языке CIL.
Линейными участками мы будем называть участки кода, не содержащие развилок и защищенных блоков.
Вообще говоря, линейные участки кода получаются, главным образом, в процессе генерации кода для выражений. Естественно, выражения бывают разные, и заранее неизвестно, какого именно типа выражения придется генерировать в различных задачах, связанных с динамической генерацией кода. Поэтому мы рассмотрим некоторый тип выражений, наиболее часто встречающийся на практике, и покажем, как для выражений этого типа порождать линейные последовательности инструкций CIL.
Пусть наши выражения оперируют с константами, переменными и параметрами. Разрешим использование в выражениях как значений
Пусть
| Правило | Описание |
|---|---|
Expr ::= const c |
Некоторая константа. Мы не уточняем тип константы, так как для дальнейшего изложения это несущественно. Это может быть целое число, число с плавающей запятой, строка, значение null |
Expr ::= local x |
Локальная переменная с именем х |
Expr ::= arg x |
Параметр метода с именем х |
Expr ::= Expr index Expr |
Доступ к элементу массива. Здесь первое выражение должно возвращать ссылку, а второе - целое число, означающее индекс элемента |
Expr ::= Expr field f |
Доступ к полю f объекта |
Expr ::= minus Expr |
Унарный минус |
Expr ::= Expr BinOp Expr |
Бинарная арифметическая операция |
Expr ::= local x assign Expr |
Операция присваивания переменной х значения выражения |
Expr ::= arg x assign Expr |
Операция присваивания параметру х значения выражения |
Expr ::= Expr index Expr assign Expr |
Операция присваивания элементу массива значения выражения |
Expr ::= Expr field f assign Expr |
Операция присваивания полю f объекта значения выражения |
Expr ::= Expr call s Arglist |
Вызов s для некоторого объекта с передачей списка фактических параметров |
Arglist ::= Expr Arglist |
Непустой список фактических параметров метода |
Arglist ::= пусто |
Пустой список фактических параметров метода |
BinaryOp ::= plus |
Сложение |
BinaryOp ::= minus |
Вычитание |
BinaryOp ::= mul |
Умножение |
BinaryOp ::= div |
Деление |
Определим набор функций, отображающих различные деревья
Будем считать, что каждая функция принимает в качестве параметра дерево
GenExpr[const c] = нужный вариант инструкции ldc; GenExpr[local x] = ldloc x; GenExpr[arg x] = ldarg x; GenExpr[Expr1 index Expr2] = GenExpr[Expr1], GenExpr[Expr2], ldelem.нужный тип; GenExpr[Expr field f] = GenExpr[Expr], ldfld f; GenExpr[minus Expr] = GenExpr[Expr], neg; GenExpr[Expr1 BinOp Expr2] = GenExpr[Expr1], GenExpr[Expr2], GenBinOp[BinOp]; GenExpr[local x assign Expr] = GenExpr[Expr], dup, stloc x; GenExpr[arg x assign Expr] = GenExpr[Expr], dup, starg x; GenExpr[Expr1 index Expr2 assign Expr3] = GenExpr[Expr1], GenExpr[Expr2], GenExpr[Expr3], dup, stloc временная переменная, stelem.нужный тип, ldloc временная переменная; GenExpr[Expr1 field f assign Expr2] = GenExpr[Expr1], GenExpr[Expr2], dup, stloc временная переменная, stfld f, ldloc временная переменная; GenExpr[Expr call s ArgList] = GenExpr[Expr], GenArgList[ArgList], call(callvirt) s; GenArgList[Expr ArgList] = GenExpr[Expr], GenArgList[ArgList]; GenArgList[пусто] = ; GenBinOp[plus] = add; GenBinOp[minus] = sub; GenBinOp[mul] = mul; GenBinOp[div] = div;
Зачастую бывает удобно разделить фазы генерации и оптимизации кода. Это позволяет существенно упростить генератор, сделать его независимым от оптимизатора и, кроме того, повторно использовать оптимизатор с другими генераторами.
Существует большое количество методов оптимизации, но в контексте динамической генерации кода, требующей быстрой работы оптимизатора, не все эти методы применимы. Поэтому мы рассмотрим один из самых простых методов - так называемую peephole-оптимизацию.
Суть peephole-оптимизации заключается в том, что оптимизатор ищет в коде метода сравнительно короткую последовательность инструкций, удовлетворяющую некоторому образцу, и заменяет ее более эффективной последовательностью инструкций.
Алгоритм peephole-оптимизации использует понятие фрейма. Фрейм можно представить как окошко, двигающееся по коду метода. Содержимое фрейма сравнивается с образцом, и в случае совпадения выполняется преобразование (см. рис. 5.1).
(рис 5.1) Peephole-оптимизация
Peephole-оптимизация линейного участка кода должна выполняться многократно до тех пор, пока на очередном проходе фрейма по этому участку кода не будет найдено ни одного образца. С другой стороны, алгоритм peephole-оптимизации может быть остановлен в любой момент, что позволяет добиться требуемой скорости работы оптимизатора.
В таблице 5.3 приведен список некоторых образцов и замен, которые можно использовать для peephole-оптимизации CIL-кода.
| Образец | Замена |
|---|---|
stloc(starg) x ldloc(Ldarg) х |
dup stloc(starg) x |
ldloc(ldarg) x ldloc(ldarg) x |
ldloc(ldarg) x dup |
ckfinite ckfinite |
ckfinite |
not(neg) pop |
pop |
add(sub, mul, div,...) pop |
pop pop |
idc.i4.0 add(sub) |
- |
ldloca(ldarga) x initobj int32 |
idc.i4.0 stloc(starg) x |
stloc(starg) x ldloc(ldarg) y ldloc(ldarg) x add (или любая коммутативная бинарная операция) |
dup stloc(starg) x ldloc(ldarg) y add (или любая коммутативная бинарная операция) |
Генерация кода, содержащего инструкции переходов, представляет некоторую сложность по сравнению с генерацией
Интересен факт, что генерация развилок существенно упрощается, если в процессе генерации придерживаться определенных требований структурированной парадигмы в программировании. Эти требования заключаются в том, что в генерируемой программе используются только пять структурных конструкций, а именно: последовательность (рис. 5.2a), выбор (рис. 5.2b), множественный выбор (рис. 5.2c),
(рис 5.2) Peephole-оптимизация
Структурные конструкции удобны тем, что имеют ровно один вход и ровно один выход. Этот факт в сочетании с тем, что они вкладываются друг в друга, позволяет использовать для их порождения рекурсивные алгоритмы. В данном разделе мы предложим как раз рекурсивный вариант генерации структурных конструкций.
Логические выражения отличаются от рассмотренных ранее в этой главе арифметических выражений тем, что могут вычисляться не полностью. Например, в выражении
(a = 10) and (sin(x) = 0.5)
второе равенство имеет смысл вычислять, только если первое равенство истинно (то есть если значение переменной a равно 10).
Это означает, что в коде, вычисляющем логические выражения, должны активно использоваться условные переходы.
Будем рассматривать логические выражения, которые содержат арифметические выражения, рассмотренные ранее, в качестве
Дополним LogExpr. Правила для этого
| Правило | Описание |
|---|---|
LogExpr ::= Expr |
Вырожденный случай, когда логическое выражение не содержит ни одной логической операции или операции сравнения |
LogExpr ::= LogExpr ComparisonOp LogExpr |
Сравнение двух выражений |
LogExpr ::= LogExpr and LogExpr |
Применение логического И |
LogExpr ::= LogExpr or LogExpr |
Применение логического ИЛИ |
LogExpr ::= not LogExpr |
Применение логического НЕ |
ComparisonOp ::= equal |
Равенство |
ComparisonOp ::= less |
Меньше |
ComparisonOp ::= greather |
Больше |
Аналогично функциям GenExpr из раздела приведенного ранее, определим набор функций GenLogExpr, которые отображают деревья
Напомним, что каждая функция принимает в качестве параметра дерево
GenLogExpr[Expr] = GenExpr[Expr]; GenLogExpr[LogExpr1 ComparisonOp LogExpr2] = GenLogExpr[LogExpr1], GenLogExpr[LogExpr2], GenComparisonOp[ComparisonOp]; GenLogExpr[LogExpr1 and LogExpr2] = GenLogExpr[LogExpr1], dup, brfalse LABEL, GenLogExpr[LogExpr2], and, LABEL: ; GenLogExpr[LogExpr1 or LogExpr2] = GenLogExpr[LogExpr1], dup, brtrue LABEL, GenLogExpr[LogExpr2], or, LABEL: ; GenLogExpr[not LogExpr] = GenLogExpr[LogExpr], not; ComparisonOp[equal] = ceq; ComparisonOp[less] = нужный вариант инструкции clt; ComparisonOp[greater] = нужный вариант инструкции cgt;
Воспользовавшись уже отработанной схемой генерации кода, перейдем на уровень выше и рассмотрим генерацию основных структурных
В таблице 5.5 приведен LogExpr для представления условий выбора и циклов.
| Правило | Описание |
|---|---|
Statement ::= Expr |
Предложение является выражением. Это может быть, например, выражение, содержащее операцию присваивания или вызов метода объекта |
Statement ::= if LogExpr StatementList else StatementList |
Выбор с двумя альтернативами |
Statement ::= while LogExpr StatementList |
|
Statement ::= do StatementList while LogExpr |
|
Statement ::= Statement StatementList |
Непустая последовательность предложений |
StatementList ::= пусто |
Пустая последовательность предложений |
Как уже говорилось, структурные GenStatement, транслирующих деревья
GenStatement[Expr] = GenExpr[Expr], pop; GenStatement[if LogExpr StatementList1 else StatementList2] = GenLogExpr[LogExpr], brfalse LABEL1, GenStatementList[StatementList1], br LABEL2, LABEL1: GenStatementList[StatementList2], LABEL2: ; GenStatement[while LogExpr StatementList] = LABEL1: GenLogExpr[LogExpr], brfalse LABEL2, GenStatementList[StatementList], br LABEL1, LABEL2: ; GenStatement[do StatementList while LogExpr] = LABEL: GenStatementList[StatementList], GenLogExpr[LogExpr], brtrue LABEL; GenStatementList[Statement StatementList] = GenStatement[Statement], GenStatementList[StatementList]; GenStatementList[пусто] = ;
Рассмотрим несколько простых методов оптимизации кода, содержащего развилки, а именно:
Хороших результатов можно достичь, если применять эти методы в совокупности с peephole-оптимизацией. При этом получаемая цепочка оптимизирующих преобразований должна выполняться над одним и тем же кодом многократно до тех пор, пока не будет достигнута неподвижная точка.
Это преобразование уменьшает количество присваиваний. Оно осуществляется только для переменных, адреса которых не используются. Под переменными мы будем понимать как локальные переменные, так и параметры методов.
Другими словами, избыточные инструкции stloc и starg удаляются только для переменных, не использующихся в инструкциях ldloca и ldarga.
Для обнаружения избыточных инструкций сохранения значений выполняется анализ использования переменной, состоящий из двух фаз:
Инструкции ldloc(ldarg) X и stloc(starg) X будем называть инструкциями использования переменной X.
Мы будем говорить, что в графе потока управления инструкция использования B следует за инструкцией использования A на пути w, если:
A и B используют одну и ту же переменную X ;w соединяет A и B ;w не содержит ни одной инструкции использования переменной X, кроме инструкций A и B.Граф использования переменной X - это ориентированный граф, в узлах которого находятся инструкции использования переменной X, а дуги задают отношение следования для этих инструкций. То есть, если инструкция B следует за инструкцией A на каком-либо пути в графе потока управления, то в графе использования переменной X имеется дуга от инструкции A к инструкции B.
Анализ графа использования переменной заключается в нахождении таких инструкций stloc(starg), за которыми не следует ни одной инструкции ldloc(ldarg). Эти инструкции являются избыточными и заменяются инструкциями pop.
На рисунке 5.3 изображен пример графа использования переменной. Серым цветом обозначены избыточные инструкции stloc.
(рис 5.3) Пример графа использования переменной
Это преобразование позволяет избавиться от лишних присваиваний и уменьшает количество локальных переменных. Под переменной будем понимать, опять же, как локальные переменные, так и параметры методов.
Переменная Y является псевдонимом переменной X тогда и только тогда, когда:
X используется в теле метода только один раз, причем в инструкции ldloc(ldarg) X ;ldloc(ldarg) X непосредственно следует инструкция stloc(starg) Y (впрочем, допускается наличие между ними любого количества инструкций dup ). Причем инструкция stloc(starg) Y является первым использованием переменной Y (назовем ее инструкцией инициализации переменной Y ).
(рис 5.4) Удаление псевдонима Y переменной X
Схема удаления псевдонима Y переменной X показана на рис. 5.4. При удалении осуществляются два действия:
Y заменяется инструкцией pop.Y заменяются использованиями переменной X.Это преобразование позволяет избавиться от переменных, имеющих константное значение.
Переменная Y имеет константное значение C тогда и только тогда, когда:
Y является инструкция stloc(starg) Y (назовем ее инструкцией инициализации переменной Y );Y непосредственно следует за инструкцией ldc C (любая инструкция загрузки константы на стек вычислений). Впрочем, допускается наличие между ними любого количества инструкций dup ;ldloc(ldarg) Y.Схема воспроизведения константы C, являющейся значением переменной Y, показана на рисунке 5.5. При воспроизведении осуществляются два действия:
(рис 5.5) Схема воспроизведения константы C, являющейся значением переменной Y
Y заменяется инструкцией pop.ldloc(ldarg) Y заменяются инструкциями ldc C.Если некоторая переменная не используется в графе метода или встречается только в инструкциях stloc(starg), то она удаляется. При этом все инструкции stloc(starg), использующие эту переменную, заменяются инструкциями pop.
Динамическая генерация кода - это прием программирования, заключающийся в том, что фрагменты кода порождаются и запускаются непосредственно во время выполнения программы. Этот прием был известен достаточно давно, но усложнение архитектуры компьютеров, и, что особенно важно, усложнение наборов команд процессоров привело к тому, что в последние 10-15 лет динамическая генерация кода в некоторой степени потеряла популярность.
Целью динамической генерации кода является использование информации, доступной только во время выполнения программы, для повышения качества исполняемого кода. В терминах метавычислений можно сказать, что динамическая генерация кода позволяет специализировать фрагменты программы по данным, известным во время выполнения.
В некотором смысле, любой
Естественно, не стоит чересчур увлекаться динамической генерацией кода: этот прием далеко не всегда дает ускорение программы. Можно сказать, что применение динамической генерации оправдано, если:
В .NET доступно два способа организации динамической генерации кода:
Если сравнить эти два способа, можно придти к выводу, что порождение C#-программы несколько проще, нежели генерация CIL-кода. Однако, наличие в библиотеке классов пространства имен System. позволяет избежать рутинных и трудоемких операций по работе с физическим представлением метаданных и CIL-кода. Кроме того, генерация CIL-кода выполняется на порядок быстрее и дает большую гибкость. Поэтому для программиста, знакомого с набором инструкций CIL, второй способ является более предпочтительным.
В этом разделе мы рассмотрим простой пример программы на языке C#, выполняющей численное интегрирование функции, которую пользователь вводит с клавиатуры (то есть интегрируемая функция становится известной только в процессе выполнения программы). Исходный код примера приведен в Приложении B. Характерной особенностью задачи
Мы будем выполнять вычисление значения функции тремя способами:
Затем мы сравним эффективность каждого способа.
Для интегрирования функций нам потребуется некое представление функции, которое бы не зависело от конкретного способа вычисления значения функции. Идеальным вариантом такого представления является абстрактный класс Function:
public abstract class Function
{
public abstract double Eval(double x);
}
Объявив такой класс, мы предполагаем, что от него будут наследоваться другие классы, реализующие в методе Eval конкретный способ вычисления значения функции в заданной точке.
Имея класс Function, мы можем записать обобщенный алгоритм интегрирования f, представляющий интегрируемую функцию, пределы интегрирования a и b, а также количество разбиений n:
static double Integrate(Function f, double a, double b, int n)
{
double h = (b-a)/n, sum = 0.0;
for (int i = 0; i < n; i++)
sum += h*f.Eval((i+0.5)*h);
return sum;
}
Для проверки работоспособности алгоритма можно объявить тестовый класс TestFunction, реализующий вычисление функции f(x) = x * sin(x):
public class TestFunction: Function
{
public override double Eval(double x)
{
return x * Math.Sin(x);
}
}
В нашем примере пользователь будет вводить выражение с клавиатуры, то есть оно будет представлено в виде текстовой строки. Такое представление неудобно ни для непосредственного вычисления значения функции, ни для генерации кода, вычисляющего ее значение. Поэтому нам понадобится
Детали синтаксического анализа и представления выражений мы рассматривать не будем: интересующиеся могут обратиться к полным исходным текстам примера. Скажем лишь, что анализ осуществляется методом рекурсивного спуска и транслирует выражение в дерево, в узлах которого расположены объекты, представляющие арифметические операции и их операнды. Каждый из этих объектов является экземпляром одного из четырех классов, обозначающих числовые константы, переменные, унарные и Expression:
public abstract class Expression
{
public abstract string GenerateCS();
public abstract void GenerateCIL(ILGenerator il);
public abstract double Evaluate(double x);
}
В классе Expression объявлены три выполняет непосредственное вычисление значения выражения, метод GenerateCS транслирует выражение в фрагмент программы на C#, а метод GenerateCIL транслирует выражение в CIL-код.
Вопросы генерации кода будут обсуждаться в следующих разделах, поэтому сейчас мы только приведем пример CIL-кода, генерируемого методом GenerateCIL для дерева объектов, которые представляют выражение "2*x*x*x+3*x*x+4*x+5":
ldc.r8 2.0 ldarg.1 mul ldarg.1 mul ldarg.1 mul ldc.r8 3.0 ldarg.1 mul ldarg.1 mul add ldc.r8 4.0 ldarg.1 mul add ldc.r8 5.0 add
Метод GenerateCS фактически восстанавливает из дерева строковое представление выражения и в особых комментариях не нуждается.
Как уже говорилось, самым простым способом динамической генерации кода является порождение текста C#-программы и компиляция этой программы с помощью компилятора C#, доступного через библиотеку классов .NET.
В нашем примере динамическую генерацию сборки осуществляет статический метод CompileToCS, который получает транслируемое выражение в виде объекта класса Expression и возвращает объект Function:
static Function CompileToCS(Expression expr)
{
ICodeCompiler compiler = new CSharpCodeProvider().CreateCompiler();
CompilerParameters parameters = new CompilerParameters();
parameters.ReferencedAssemblies.Add("System.dll");
parameters.ReferencedAssemblies.Add("Integral.exe");
parameters.GenerateInMemory = true;
string e = expr.GenerateCS();
string code = "public class FunctionCS: Function\n"+
"{\n"+
" public override double Eval(double x)\n"+
" {\n"+
" return "+e+";\n"+
" }\n"+
"}\n";
CompilerResults compilerResults =
compiler.CompileAssemblyFromSource(parameters,code);
Assembly assembly = compilerResults.CompiledAssembly;
return assembly.CreateInstance("FunctionCS") as Function;
}
Классы, отвечающие за компиляцию исходного кода, относятся к пространству имен System.CodeDom.Compiler.
Основную функциональность, необходимую нам для компиляции сгенерированной C#-программы, обеспечивает класс CSharpCodeProvider. Метод CreateCompiler этого класса создает экземпляр компилятора C#, к которому можно обращаться через интерфейс ICodeCompiler.
Параметры компиляции задаются через объект класса CompilerParameters. В нашем случае это имена сборок, импортируемых генерируемой программой: System.dll и Integral.exe. Обратите внимание, что Integral.exe - это сборка, получаемая при компиляции рассматриваемого нами примера. Она импортируется по причине того, что динамически генерируемый класс FunctionCS должен наследовать от определенного в ней абстрактного класса Function.
Параметры компиляции и строка, содержащая текст программы, передаются методу CompileAssemblyFromSource экземпляра компилятора C#. Метод компилирует программу и возвращает объект класса CompilerResults, содержащий результаты компиляции. Из этого объекта мы можем получить объект рефлексии Assembly, представляющий созданную в памяти динамическую сборку. Используя данный объект, мы создаем экземпляр определенного в динамической сборке класса FunctionCS, который в дальнейшем может быть использован для вычисления значения функции в процессе интегрирования.
Более сложный, но эффективный способ динамической генерации кода предоставляется классами, относящимися к пространству имен System.. Эти классы в нашем примере используются в статическом методе CompileToCIL, который осуществляет трансляцию выражения напрямую в CIL:
static Function CompileToCIL(Expression expr)
Метод начинается с создания заготовки для будущей динамической сборки. Сборка будет выполняться в том же Thread.GetDomain. Затем вызываем метод DefineDynamicAssembly домена приложений и получаем объект класса AssemblyBuilder, позволяющий строить динамическую сборку:
AppDomain appDomain = Thread.GetDomain();
AssemblyName assemblyName = new AssemblyName();
assemblyName.Name = "f";
AssemblyBuilder assembly =
appDomain.DefineDynamicAssembly(
assemblyName,
AssemblyBuilderAccess.RunAndSave
);
Теперь мы можем создать в FunctionCIL, наследующий от класса Function. Обратите внимание, что при генерации кода через классы пространства имен System. явно прописывать импортируемые сборки не надо (например, не надо прописывать сборку Integral.exe, из которой импортируется класс Function ), так как это выполняется автоматически:
ModuleBuilder module =
assembly.DefineDynamicModule("f.dll", "f.dll");
TypeBuilder typeBuilder =
module.DefineType(
"FunctionCIL",
TypeAttributes.Public | TypeAttributes.Class,
typeof(Function)
);
В каждом классе должен быть конструктор. Компилятор C# создает конструкторы без параметров по умолчанию, поэтому при генерации C#-кода нам не надо было явно объявлять конструктор в классе FunctionCS. Однако, при генерации динамической сборки через классы пространства имен System. конструкторы автоматически не добавляются, и нам придется сделать это самостоятельно:
ConstructorBuilder cons =
typeBuilder.DefineConstructor(
MethodAttributes.Public,
CallingConventions.Standard,
new Type[] { }
);
ILGenerator consIl = cons.GetILGenerator();
consIl.Emit(OpCodes.Ldarg_0);
consIl.Emit(OpCodes.Call,
typeof(object).GetConstructor(new Type[0]));
consIl.Emit(OpCodes.Ret);
Разобравшись с конструктором, переходим к методу Eval. Как уже говорилось, код этого метода почти полностью генерируется в методе GenerateCIL выражения, остается лишь добавить в конец инструкцию :
MethodBuilder evalMethod =
typeBuilder.DefineMethod(
"Eval",
MethodAttributes.Public | MethodAttributes.Virtual,
typeof(double),
new Type[] { typeof(double) }
);
ILGenerator il = evalMethod.GetILGenerator();
expr.GenerateCIL(il);
il.Emit(OpCodes.Ret);
Итак, мы закончили формирование класса FunctionCIL. Осталось создать для него объект рефлексии и через этот объект вызвать конструктор:
Type type = typeBuilder.CreateType(); ConstructorInfo ctor = type.GetConstructor(new Type[0]); return ctor.Invoke(null) as Function;
Таким образом, получается объект класса FunctionCIL, который в дальнейшем можно использовать для вычисления значения функции в процессе интегрирования.
Давайте оценим эффективность рассмотренных способов вычисления выражений при интегрировании. Для этого будем интегрировать функцию "2*x*x*x+3*x*x+4*x+5" от 0.0 до 10.0 с 10000000 разбиений.
В таблице 5.1 представлены результаты измерений, проведенных на компьютере с процессором Intel Pentium 4 с тактовой частотой 3000 МГц и 1 Гб оперативной памяти.
| Способ вычисления значения функции | Время на создание динамической сборки, мс | Время вычисления интеграла функции, мс |
|---|---|---|
| Интерпретация дерева выражения | - | 29422 |
| Предварительная компиляция С# | 547 | 172 |
| Предварительная компиляция в CIL | 63 | 172 |
Результаты показывают, что динамическая генерация кода может на два порядка уменьшить время работы программы.
В этом разделе мы рассмотрим специфику генерации линейных участков кода на языке CIL.
Линейными участками мы будем называть участки кода, не содержащие развилок и защищенных блоков.
Вообще говоря, линейные участки кода получаются, главным образом, в процессе генерации кода для выражений. Естественно, выражения бывают разные, и заранее неизвестно, какого именно типа выражения придется генерировать в различных задачах, связанных с динамической генерацией кода. Поэтому мы рассмотрим некоторый тип выражений, наиболее часто встречающийся на практике, и покажем, как для выражений этого типа порождать линейные последовательности инструкций CIL.
Пусть наши выражения оперируют с константами, переменными и параметрами. Разрешим использование в выражениях как значений
Пусть
| Правило | Описание |
|---|---|
Expr ::= const c |
Некоторая константа. Мы не уточняем тип константы, так как для дальнейшего изложения это несущественно. Это может быть целое число, число с плавающей запятой, строка, значение null |
Expr ::= local x |
Локальная переменная с именем х |
Expr ::= arg x |
Параметр метода с именем х |
Expr ::= Expr index Expr |
Доступ к элементу массива. Здесь первое выражение должно возвращать ссылку, а второе - целое число, означающее индекс элемента |
Expr ::= Expr field f |
Доступ к полю f объекта |
Expr ::= minus Expr |
Унарный минус |
Expr ::= Expr BinOp Expr |
Бинарная арифметическая операция |
Expr ::= local x assign Expr |
Операция присваивания переменной х значения выражения |
Expr ::= arg x assign Expr |
Операция присваивания параметру х значения выражения |
Expr ::= Expr index Expr assign Expr |
Операция присваивания элементу массива значения выражения |
Expr ::= Expr field f assign Expr |
Операция присваивания полю f объекта значения выражения |
Expr ::= Expr call s Arglist |
Вызов s для некоторого объекта с передачей списка фактических параметров |
Arglist ::= Expr Arglist |
Непустой список фактических параметров метода |
Arglist ::= пусто |
Пустой список фактических параметров метода |
BinaryOp ::= plus |
Сложение |
BinaryOp ::= minus |
Вычитание |
BinaryOp ::= mul |
Умножение |
BinaryOp ::= div |
Деление |
Определим набор функций, отображающих различные деревья
Будем считать, что каждая функция принимает в качестве параметра дерево
GenExpr[const c] = нужный вариант инструкции ldc; GenExpr[local x] = ldloc x; GenExpr[arg x] = ldarg x; GenExpr[Expr1 index Expr2] = GenExpr[Expr1], GenExpr[Expr2], ldelem.нужный тип; GenExpr[Expr field f] = GenExpr[Expr], ldfld f; GenExpr[minus Expr] = GenExpr[Expr], neg; GenExpr[Expr1 BinOp Expr2] = GenExpr[Expr1], GenExpr[Expr2], GenBinOp[BinOp]; GenExpr[local x assign Expr] = GenExpr[Expr], dup, stloc x; GenExpr[arg x assign Expr] = GenExpr[Expr], dup, starg x; GenExpr[Expr1 index Expr2 assign Expr3] = GenExpr[Expr1], GenExpr[Expr2], GenExpr[Expr3], dup, stloc временная переменная, stelem.нужный тип, ldloc временная переменная; GenExpr[Expr1 field f assign Expr2] = GenExpr[Expr1], GenExpr[Expr2], dup, stloc временная переменная, stfld f, ldloc временная переменная; GenExpr[Expr call s ArgList] = GenExpr[Expr], GenArgList[ArgList], call(callvirt) s; GenArgList[Expr ArgList] = GenExpr[Expr], GenArgList[ArgList]; GenArgList[пусто] = ; GenBinOp[plus] = add; GenBinOp[minus] = sub; GenBinOp[mul] = mul; GenBinOp[div] = div;
Зачастую бывает удобно разделить фазы генерации и оптимизации кода. Это позволяет существенно упростить генератор, сделать его независимым от оптимизатора и, кроме того, повторно использовать оптимизатор с другими генераторами.
Существует большое количество методов оптимизации, но в контексте динамической генерации кода, требующей быстрой работы оптимизатора, не все эти методы применимы. Поэтому мы рассмотрим один из самых простых методов - так называемую peephole-оптимизацию.
Суть peephole-оптимизации заключается в том, что оптимизатор ищет в коде метода сравнительно короткую последовательность инструкций, удовлетворяющую некоторому образцу, и заменяет ее более эффективной последовательностью инструкций.
Алгоритм peephole-оптимизации использует понятие фрейма. Фрейм можно представить как окошко, двигающееся по коду метода. Содержимое фрейма сравнивается с образцом, и в случае совпадения выполняется преобразование (см. рис. 5.1).
(рис 5.1) Peephole-оптимизация
Peephole-оптимизация линейного участка кода должна выполняться многократно до тех пор, пока на очередном проходе фрейма по этому участку кода не будет найдено ни одного образца. С другой стороны, алгоритм peephole-оптимизации может быть остановлен в любой момент, что позволяет добиться требуемой скорости работы оптимизатора.
В таблице 5.3 приведен список некоторых образцов и замен, которые можно использовать для peephole-оптимизации CIL-кода.
| Образец | Замена |
|---|---|
stloc(starg) x ldloc(Ldarg) х |
dup stloc(starg) x |
ldloc(ldarg) x ldloc(ldarg) x |
ldloc(ldarg) x dup |
ckfinite ckfinite |
ckfinite |
not(neg) pop |
pop |
add(sub, mul, div,...) pop |
pop pop |
idc.i4.0 add(sub) |
- |
ldloca(ldarga) x initobj int32 |
idc.i4.0 stloc(starg) x |
stloc(starg) x ldloc(ldarg) y ldloc(ldarg) x add (или любая коммутативная бинарная операция) |
dup stloc(starg) x ldloc(ldarg) y add (или любая коммутативная бинарная операция) |
Генерация кода, содержащего инструкции переходов, представляет некоторую сложность по сравнению с генерацией
Интересен факт, что генерация развилок существенно упрощается, если в процессе генерации придерживаться определенных требований структурированной парадигмы в программировании. Эти требования заключаются в том, что в генерируемой программе используются только пять структурных конструкций, а именно: последовательность (рис. 5.2a), выбор (рис. 5.2b), множественный выбор (рис. 5.2c),
(рис 5.2) Peephole-оптимизация
Структурные конструкции удобны тем, что имеют ровно один вход и ровно один выход. Этот факт в сочетании с тем, что они вкладываются друг в друга, позволяет использовать для их порождения рекурсивные алгоритмы. В данном разделе мы предложим как раз рекурсивный вариант генерации структурных конструкций.
Логические выражения отличаются от рассмотренных ранее в этой главе арифметических выражений тем, что могут вычисляться не полностью. Например, в выражении
(a = 10) and (sin(x) = 0.5)
второе равенство имеет смысл вычислять, только если первое равенство истинно (то есть если значение переменной a равно 10).
Это означает, что в коде, вычисляющем логические выражения, должны активно использоваться условные переходы.
Будем рассматривать логические выражения, которые содержат арифметические выражения, рассмотренные ранее, в качестве
Дополним LogExpr. Правила для этого
| Правило | Описание |
|---|---|
LogExpr ::= Expr |
Вырожденный случай, когда логическое выражение не содержит ни одной логической операции или операции сравнения |
LogExpr ::= LogExpr ComparisonOp LogExpr |
Сравнение двух выражений |
LogExpr ::= LogExpr and LogExpr |
Применение логического И |
LogExpr ::= LogExpr or LogExpr |
Применение логического ИЛИ |
LogExpr ::= not LogExpr |
Применение логического НЕ |
ComparisonOp ::= equal |
Равенство |
ComparisonOp ::= less |
Меньше |
ComparisonOp ::= greather |
Больше |
Аналогично функциям GenExpr из раздела приведенного ранее, определим набор функций GenLogExpr, которые отображают деревья
Напомним, что каждая функция принимает в качестве параметра дерево
GenLogExpr[Expr] = GenExpr[Expr]; GenLogExpr[LogExpr1 ComparisonOp LogExpr2] = GenLogExpr[LogExpr1], GenLogExpr[LogExpr2], GenComparisonOp[ComparisonOp]; GenLogExpr[LogExpr1 and LogExpr2] = GenLogExpr[LogExpr1], dup, brfalse LABEL, GenLogExpr[LogExpr2], and, LABEL: ; GenLogExpr[LogExpr1 or LogExpr2] = GenLogExpr[LogExpr1], dup, brtrue LABEL, GenLogExpr[LogExpr2], or, LABEL: ; GenLogExpr[not LogExpr] = GenLogExpr[LogExpr], not; ComparisonOp[equal] = ceq; ComparisonOp[less] = нужный вариант инструкции clt; ComparisonOp[greater] = нужный вариант инструкции cgt;
Воспользовавшись уже отработанной схемой генерации кода, перейдем на уровень выше и рассмотрим генерацию основных структурных
В таблице 5.5 приведен LogExpr для представления условий выбора и циклов.
| Правило | Описание |
|---|---|
Statement ::= Expr |
Предложение является выражением. Это может быть, например, выражение, содержащее операцию присваивания или вызов метода объекта |
Statement ::= if LogExpr StatementList else StatementList |
Выбор с двумя альтернативами |
Statement ::= while LogExpr StatementList |
|
Statement ::= do StatementList while LogExpr |
|
Statement ::= Statement StatementList |
Непустая последовательность предложений |
StatementList ::= пусто |
Пустая последовательность предложений |
Как уже говорилось, структурные GenStatement, транслирующих деревья
GenStatement[Expr] = GenExpr[Expr], pop; GenStatement[if LogExpr StatementList1 else StatementList2] = GenLogExpr[LogExpr], brfalse LABEL1, GenStatementList[StatementList1], br LABEL2, LABEL1: GenStatementList[StatementList2], LABEL2: ; GenStatement[while LogExpr StatementList] = LABEL1: GenLogExpr[LogExpr], brfalse LABEL2, GenStatementList[StatementList], br LABEL1, LABEL2: ; GenStatement[do StatementList while LogExpr] = LABEL: GenStatementList[StatementList], GenLogExpr[LogExpr], brtrue LABEL; GenStatementList[Statement StatementList] = GenStatement[Statement], GenStatementList[StatementList]; GenStatementList[пусто] = ;
Рассмотрим несколько простых методов оптимизации кода, содержащего развилки, а именно:
Хороших результатов можно достичь, если применять эти методы в совокупности с peephole-оптимизацией. При этом получаемая цепочка оптимизирующих преобразований должна выполняться над одним и тем же кодом многократно до тех пор, пока не будет достигнута неподвижная точка.
Это преобразование уменьшает количество присваиваний. Оно осуществляется только для переменных, адреса которых не используются. Под переменными мы будем понимать как локальные переменные, так и параметры методов.
Другими словами, избыточные инструкции stloc и starg удаляются только для переменных, не использующихся в инструкциях ldloca и ldarga.
Для обнаружения избыточных инструкций сохранения значений выполняется анализ использования переменной, состоящий из двух фаз:
Инструкции ldloc(ldarg) X и stloc(starg) X будем называть инструкциями использования переменной X.
Мы будем говорить, что в графе потока управления инструкция использования B следует за инструкцией использования A на пути w, если:
A и B используют одну и ту же переменную X ;w соединяет A и B ;w не содержит ни одной инструкции использования переменной X, кроме инструкций A и B.Граф использования переменной X - это ориентированный граф, в узлах которого находятся инструкции использования переменной X, а дуги задают отношение следования для этих инструкций. То есть, если инструкция B следует за инструкцией A на каком-либо пути в графе потока управления, то в графе использования переменной X имеется дуга от инструкции A к инструкции B.
Анализ графа использования переменной заключается в нахождении таких инструкций stloc(starg), за которыми не следует ни одной инструкции ldloc(ldarg). Эти инструкции являются избыточными и заменяются инструкциями pop.
На рисунке 5.3 изображен пример графа использования переменной. Серым цветом обозначены избыточные инструкции stloc.
(рис 5.3) Пример графа использования переменной
Это преобразование позволяет избавиться от лишних присваиваний и уменьшает количество локальных переменных. Под переменной будем понимать, опять же, как локальные переменные, так и параметры методов.
Переменная Y является псевдонимом переменной X тогда и только тогда, когда:
X используется в теле метода только один раз, причем в инструкции ldloc(ldarg) X ;ldloc(ldarg) X непосредственно следует инструкция stloc(starg) Y (впрочем, допускается наличие между ними любого количества инструкций dup ). Причем инструкция stloc(starg) Y является первым использованием переменной Y (назовем ее инструкцией инициализации переменной Y ).
(рис 5.4) Удаление псевдонима Y переменной X
Схема удаления псевдонима Y переменной X показана на рис. 5.4. При удалении осуществляются два действия:
Y заменяется инструкцией pop.Y заменяются использованиями переменной X.Это преобразование позволяет избавиться от переменных, имеющих константное значение.
Переменная Y имеет константное значение C тогда и только тогда, когда:
Y является инструкция stloc(starg) Y (назовем ее инструкцией инициализации переменной Y );Y непосредственно следует за инструкцией ldc C (любая инструкция загрузки константы на стек вычислений). Впрочем, допускается наличие между ними любого количества инструкций dup ;ldloc(ldarg) Y.Схема воспроизведения константы C, являющейся значением переменной Y, показана на рисунке 5.5. При воспроизведении осуществляются два действия:
(рис 5.5) Схема воспроизведения константы C, являющейся значением переменной Y
Y заменяется инструкцией pop.ldloc(ldarg) Y заменяются инструкциями ldc C.Если некоторая переменная не используется в графе метода или встречается только в инструкциях stloc(starg), то она удаляется. При этом все инструкции stloc(starg), использующие эту переменную, заменяются инструкциями pop.
Для получения официальных документов о завершении программы дополнительного профессионального образования (удостоверения о повышении квалификации, дипломов о профессиональной переподготовке и MBA) необходимо предоставить:
Внимание! Вы можете не заказывать доставку бумажной версии официального документы, а скачать его в электронном виде и распечатать самостоятельно. Информация о выданном документе в течение 1 месяца загружается в Федеральную информационную систему «Федеральный реестр сведений о документах об образовании и (или) о квалификации, документах об обучении» - ФИС ФРДО.
Доступ на новый сайт осуществляется с использованием адреса электронной почты, который был указан вами при регистрации на "старом". Мы постарались перенести все ваши данные с прежнего ресурса, однако не исключена вероятность потери части информации.
При возникновении проблемы со входом, воспользуйтесь функцией сброса пароля
Если вы обнаружите несоответствия, пожалуйста, сообщите нам.