вторник, 30 марта 2010 г.

eval в .NET

В данном случае eval — это вовсе не Eval, который используется в дотнетовском датабиндинге. Здесь речь пойдет о том, как изобразить на .NET что-то, похожее на джаваскриптовый eval.

А дело вот в чем. Сейчас я занимаюсь одним большим вычислительным проектом. И вот, возникла у меня следующая задача. Пользователи, которым нужно проводить эти вычисления, захотели интерфейс для того, чтобы самим править некоторые используемые формулы. Вследствие этого мне пришлось озаботиться вопросом, а как вообще делать произвольные вычисления в .NET. Чтобы взял строчку типа "2 + 2.3 * x * sin(y)", сказал, что x=3 и y=34, и посчиталось.

Ниже результаты моего ресерча. Может, кому пригодится.

muParser

Библиотека написана на C++. Имеется довольно удобная оболочка для использования данной библиотеки на .NET. Работает и на 32-битной, и на 64-битной архитектуре (правда, те dll-ки, которые шли с примером на .NET-е, были как раз 32-битными, и пришлось их перекомпилировать)

Использование

Parser evaluator = new Parser();
evaluator.SetExpr("x * 4 - sin(y)");

ParserVariable x = new ParserVariable(10.8);
ParserVariable y = new ParserVariable(0.8);

evaluator.DefineVar("x", x);
evaluator.DefineVar("y", y);

double result = evaluator.Eval();

Что хорошо

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

Что плохо

Медленно работает! И я даже знаю, почему.

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

Ссылки

CodeDOM

CodeDOM — набор инструментов для генерации кода в рантайме. Честно говоря, мне данный аспект .NET был доселе неизвестен и весьма удивил своими возможностями. С помощью средств CodeDOM можно сгенерировать прямо в коде классы, сборки, тут же подгрузить их и использовать.

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

Использование

Суть: сгенерировать сборку, содержащую класс с единственным методом Eval, куда мы и подпихнем нашу формулу. Потом сборка подгружается, из нее берется класс и юзается.

Генерировать сборку можно несколькими способами. Рассмотрим их.

Генерация сборки из исходников

Сгенерировать сборку из исходников может следующий класс:

class CodeDomEval
{
  CompilerResults compilerResults;

  /// <summary>
    /// Исходник, который будем компилировать
    /// </summary>

    string SourceFormat = @"
using System;

namespace Evaluation
{{
public class Evaluator
{{
public double Evaluate(double[] args)
{{
  return {0};
}}
}}
}}";


  /// <summary>
    /// Конструктор
    /// </summary>
    /// <param name="expression">Выражение, которое будем вычислять
    public CodeDomEval(string expression)
  {
  Dictionary<string, string> providerOptions = new Dictionary<string, string>() { { "CompilerVersion", "v3.5" } };
  CSharpCodeProvider provider = new CSharpCodeProvider(providerOptions);

  // Компиляция сборки с вычисляющим классом
      CompilerParameters compilerParams = CreateCompilerParameters();
  string src = String.Format(SourceFormat, expression);
  compilerResults = provider.CompileAssemblyFromSource(compilerParams, src);

  // Вывод ошибок компиляции
      foreach (CompilerError error in compilerResults.Errors)
  {
      Console.WriteLine("ERROR: {0}", error.ErrorText);
  }
  }

  /// <summary>
    /// Метод для проведения вычисления
    /// </summary>
    /// <param name="args">
    /// <returns>
    public double? Eval(object[] args)
  {
  if (compilerResults != null && !compilerResults.Errors.HasErrors && compilerResults.CompiledAssembly != null)
  {
      // загружаем сборку
        Assembly assembly = compilerResults.CompiledAssembly;
    Type type = assembly.GetType("Evaluation.Evaluator");

    // создаем экземпляр сгенерированного класса
        object instance = assembly.CreateInstance("Evaluation.Evaluator");

    // вызываем метод для вычисления нашей функции с заданными параметрами
            MethodInfo method = type.GetMethod("Evaluate");
          double result = (double)method.Invoke(instance, args);

          // PROFIT
            return result;
      }
      return null;
   }

   /// <summary>
     /// Создание параметров компиляции
     /// </summary>
     /// <returns>
     CompilerParameters CreateCompilerParameters()
   {
       CompilerParameters compilerParams = new CompilerParameters()
       {
           CompilerOptions = "/target:library /optimize",
           GenerateExecutable = false,
           GenerateInMemory = true,
           IncludeDebugInformation = false
       };
       compilerParams.ReferencedAssemblies.Add("mscorlib.dll");
       compilerParams.ReferencedAssemblies.Add("System.dll");

       return compilerParams;
   }
}

После этого можно писать как-нибудь так:

CodeDomEval evaluator = new CodeDomEval("args[0] * 4 - Math.Sin(args[1])");
double? result = evaluator.Eval(new object[] {args});

Генерация сборки с помощью объектной модели кода

Звучит по-дурацки, но не знаю еще, как сформулировать. Просто приведу по возможности небольшой пример. Добавим в наш CodeDomEval такие методы:

CodeNamespace BuildNamespace(string expression)
{
  // Создаем пространство имен
    CodeNamespace myNamespace = new CodeNamespace("ExpressionEvaluator");
  myNamespace.Imports.Add(new CodeNamespaceImport("System"));
  myNamespace.Imports.Add(new CodeNamespaceImport("System.Windows.Forms"));

  // Создаем класс   
    CodeTypeDeclaration classDeclaration = new CodeTypeDeclaration();
  classDeclaration.IsClass = true;
  classDeclaration.Name = "Calculator";
  classDeclaration.Attributes = MemberAttributes.Public;

  // Добавляем поле
    classDeclaration.Members.Add(new CodeMemberField(typeof(double), "result") { Attributes = MemberAttributes.Private });

  // Добавляем свойство, связанное с этим полем
    classDeclaration.Members.Add(this.MakeProperty("Result", "result", typeof(double)));

  // Добавляем метод для вычисления
    CodeMemberMethod myMethod = new CodeMemberMethod();
  myMethod.Name = "Calculate";
  myMethod.ReturnType = new CodeTypeReference(typeof(double));
  myMethod.Comments.Add(new CodeCommentStatement("Calculate an expression", true));
  myMethod.Attributes = MemberAttributes.Public;
  myMethod.Parameters.Add(new CodeParameterDeclarationExpression(typeof(double[]), "args"));

  // Добавляем в код нашу формулу
    myMethod.Statements.Add(new CodeAssignStatement(new CodeSnippetExpression("Result"), new CodeSnippetExpression(expression)));
  myMethod.Statements.Add(
      new CodeMethodReturnStatement(new CodeFieldReferenceExpression(new CodeThisReferenceExpression(), "Result")));
  classDeclaration.Members.Add(myMethod);

  // Добавляем класс в пространство имен
    myNamespace.Types.Add(classDeclaration);

  return myNamespace;
}

CodeMemberProperty MakeProperty(string propertyName, string internalName, Type type)
{
  CodeMemberProperty myProperty = new CodeMemberProperty();
  myProperty.Name = propertyName;
  myProperty.Comments.Add(new CodeCommentStatement(String.Format("The {0} property is the returned result", propertyName)));
  myProperty.Attributes = MemberAttributes.Public;
  myProperty.Type = new CodeTypeReference(type);
  myProperty.HasGet = true;
  myProperty.GetStatements.Add(
      new CodeMethodReturnStatement(
          new CodeFieldReferenceExpression(new CodeThisReferenceExpression(), internalName)));

  myProperty.HasSet = true;
  myProperty.SetStatements.Add(
      new CodeAssignStatement(
          new CodeFieldReferenceExpression(new CodeThisReferenceExpression(), internalName),
          new CodePropertySetValueReferenceExpression()));

  return myProperty;
}

"Небольшой" не получился, но как-то так. Как ясно видно из кода, действительно создается объектная модель .NET-кода, из которой потом можно сгенерировать хоть C#-код, хоть VB (не пробовала, но подозреваю, что можно), хоть сборку. Вот так:

CompilerResults CompileAssembly(CodeNamespace ns)
{
  CodeDomProvider codeProvider = new CSharpCodeProvider();
  CompilerParameters options = CreateCompilerParameters();

  CodeCompileUnit compileUnit = new CodeCompileUnit();
  compileUnit.Namespaces.Add(ns);

  CompilerResults results = codeProvider.CompileAssemblyFromDom(options, compileUnit);

  if (results.Errors.HasErrors)
  {
      foreach (CompilerError error in results.Errors)
          Console.WriteLine("Compilation error: {0}", error.ErrorText);
  }

  return results;
}

Генерация сборки из файла

Об этом я просто упомяну, подробно останавливаться не буду, тут все аналогично генерации из исходников и из объектной модели. У класса CodeDomProvider, наследником которого являются CSharpCodeProvider (а также CppCodeProvider, JScriptCodeProvider и VBCodeProvider), есть метод CompileAssemblyFromFile, который и должен все делать.

Что хорошо

Работает быстро. Честно говоря, даже не ожидала такого. Отмечу, что, если запускать вычисления в цикле, то первое отрабатывает за 2-3 мс, а последующие примерно за 0.02 мс.

Что плохо

  • Многовато кода.
  • Сложно отлаживать.
  • Приходится писать формулы со всеми этими Math.Abs и т.п. Впрочем, автор второй из указанных статей это вроде победил.

Ссылки

Calculator.NET

На самом деле, сам Calculator.NET — это не библиотека, а проект группы LoreSoft, к которому они писали библиотеку и никак не назвали. Разработано это дело было в 2007 г., но все еще успешно используется некоторыми людьми.

Использование

Тут все не менее просто, чем в muParser.

MathEvaluator eval = new MathEvaluator();

eval.Variables.Add("a", 2);
eval.Variables.Add("b", 1);
eval.Evaluate("2 * a + b ^ (0,5)");

К сожалению, библиотека плохо работает с отрицательными числами: выдаются ошибки о несоответствии числа параметров. Выражение -2 * (-4) уже не посчитаешь.

Зато есть фишка, которой нет наверно ни у кого больше. Можно конвертировать единицы измерения. Причем не только единицы длины, но и, скорости. Синтаксис такой:

eval.Evaluate("12[in->m]"); // из дюймов в метры
eval.Evaluate("12[knot->kph]"); // из узлов в километры в час

Работают вычисления на удивление быстро. Если вычислять одну функцию в цикле, то в первой итерации она будет вычисляться около 22 мс, зато потом 0.06 и меньше

Что плохо

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

Что хорошо

  • Простой синтаксис
  • Быстро работает
  • Конвертация единиц измерения.

Ссылки

Остальное

Честно говоря, после беглого знакомства с CodeDOM искать что-то еще стало окончательно лень. Из чувства долга я еще порыла подобные библиотеки, даже покопалась в Calculator.NET, но оказалось, что в основном они залиты не позже 2007 года и с тех пор признаков жизни не подают. Так что на этом я и остановлюсь.

В заключение приведу решение для Silverlight, найденное на StackOverflow, которое особенно меня порадовало:

double result = (double) HtmlPage.Window.Eval("15 + 35");

Сравнение производительности

Собственно, ради этого все и затевалось.

Конфигурация: Intel Core i7 860 (2.80 GHz), 4096 Мб ОЗУ

Тест первый: посчитать значение выражения a1 * 4 - sin(a2) / log10(a3 * max(a4,a5,a6,a7,a8) / (avg(a4,a5,a6,a7,a9) + exp(a10 / 10 + a11/500 + 0.001) ^ a12 ) ) (Calculator.NET не умеет искать максимум, так что у него было произведение)

Тест второй: посчитать некоторую часть моего вычисления. В мирное время она считается минуту с небольшим.
Библиотека Тест 1 (5000 испытаний) Тест 2 Заключение
muParser
  • Среднее: 2.56 мс
  • Минимальное: 2.31 мс
  • Максимальное: 4.42 мс
02:59:24 Подходит для небольших вычислений. Для тяжелых вычислительных задач слишком медленный.
CodeDOM
  • Среднее: 0.022 мс
  • Минимальное: 0.0182 мс
  • Максимальное: 2.8 мс
00:04:58 Подходит для любых вычислений, в том числе больших и злобных. Но требует написать довольно много кода для оболочки.
Calculator.NET
  • Среднее: 0.064 мс
  • Минимальное: 0.0494 мс
  • Максимальное: 22.96 мс
Не удалось запустить из-за проблем библиотеки с отрицательными числами. Довольно сырая библиотека. Но для ряда несложных задач вполне подойдет.

13 комментариев:

Andrey комментирует...

Вроде бы в IronPython есть eval, его не пробывали использовать?

darja комментирует...

Не, не пробовала. У меня все вычисления на C#, не хотелось бы в данном случае языки мешать.
И питона я не знаю.

kosiakk комментирует...

В Java для этого специальный Scripting Framework. Выглядит как Caluclator.NET, только работает =)

Я уверен, в Microsoft.Scripting получится ничуть не хуже:

http://www.secretgeek.net/host_ironpython.asp


python просто для примера. хотя не вижу никакой разницы, на чём "писать" математические формулы. они будут одинаковы на любом языке.

Анонимный комментирует...

Есть вот такой еще проект:
http://www.csscript.net/

проект живет, поддерживается, обладает массой возможностей :)

Сумкин комментирует...

http://ncalc.codeplex.com/

Yasix комментирует...

Посмотрите Fast Lightweight Expression Evaluator
Страница проекта http://flee.codeplex.com/Wikipage
На ней есть описание и примеры.

darja комментирует...

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

Vit комментирует...

Я бы смотрел в сторону Expression и LinqExpression. Там все это можно изящно достаточно выполнить

Анонимный комментирует...

Дарья, а как ваша организация называется и чем занимается, если не секрет?

Сергей из Владивостока.

darja комментирует...

Я фрилансер.
А данную задачу делала для одной организации, которая занимается строительством гидросооружений.

Анонимный комментирует...

Что касается калькулятора через сборку, то я делаю так:

1. система на основе шаблонов генерируются C# исходники в текстовые файлы;
2. дальше компилится сборка под временным файлом;
3. сразу же на лету подгружается;
4. создается экземпляр;
5. вычисляется, если нужно.

PS: при загрузке софта, временные сборки удаляются.

К примеру, у нас шаблон:
double func(double x, double y),
тогда человек просто пишет в тексте, к примеру:

return x*Math.Sin(x*y);

Система сама создает исходит, компилит его, подключает, вычисляет при разных x,y, и выдает результаты. А основной плюс, что можно использовать полноценно C# для вычислений.

darja комментирует...

2 Анонимный №3:
Я рада, что Вы тоже используете CodeDom.

Вячеслав комментирует...

Microsoft Linq Dynamic можно погуглить. Отлично справляется с подобной задачей без лишних проблем