Pull to refresh

NFun — expression evaluator для .Net

Reading time6 min
Views4.1K

Репозиторий.

Примеры и спецификация.

Что есть "Expression evaluator" ?

Expression evaluator позволяет вычислять указанные выражения, например:

  • 12*3 это 36

  • [1,2,3].reverse() это массив [3,2,1]

  • 'Kate'.reverse() это "etaK"

Выражения могут зависеть от входных переменных:

  • 10*x + 4 зависит от значения x.

  • 'My name is {userName}. Age is {2022-birthYear}' зависит от значений userName и birthYear.

Nfun скрипты в обработке сигналов scada - системы Sonica
Nfun скрипты в обработке сигналов scada - системы Sonica

Скрипт может содержать несколько таких выражений:

x = Vx*t
y = Vy*t

distance = sqrt(x**2 + y**2)
average = (x+y)/2

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

// ...`settings.json`...
{
    "offset": "25",
    "timeOffset": "3* 60 * 60 * 24 #sec",
    "condition": "if (age>18) isEmpty(orders) else isEmpty(parent.orders)",
    "bonus": "(min(max(order.price, 20.0), 100) + prevBonus)/ordersCount"
}

Вот несколько примеров использования:

  • Backend: фильтры входящих запросов;

  • Embeded: настройка обработки сигналов;

  • Система лояльности: настройки бонусной программы;

  • Робототехника: кинематическая модель. Описания траекторий;

  • Low-code решения.

Что умеет NFun

Nfun продолжает идею библиотеки Ncalc, но для богатой системы типов

  • Примитивные типы - byte, u16, u32, u64, i16, i32, i64, real, bool, ip, char, text, any

  • Массивы, структуры, лямбда выражения и Linq.

  • Арифметические, бинарные, дискретные операторы, операторы по работе с массивами

  • Условный оператор if.

  • Интерполяция строк.

  • Именованные выражения и пользовательские функции.

  • Строгая типизация с выведением типов.

  • Встроенные функции.

  • Кастомизация семантики.

Playground

Установите nuget пакет NFun

PM> Install-Package NFun

Начнем с классики!

var a = Funny.Calc("'Hello world'");
Console.WriteLine(a);

Посчитаем константы

bool   b = Funny.Calc<bool>("false and (2 > 1)"); // false of bool  
double d = Funny.Calc<double>(" 2 * 10 + 1 "); // 21  of double  
int    i = Funny.Calc<int>(" 2 * 10 + 1 "); // 21 of int

Посчитаем выходные данные

class User { public string Age {get;set;} public string Name {get;set;} }

var inputUser = new User{ Age = 42; Name = "Ivan"; }

string userAlias = 
    Funny.Calc<User,string> ( 
        "if(age < 18) name else 'Mr. {name}' ", 
        inputUser);
  

А теперь перейдем в режим хардкора. Этот режим предоставляет доступ ко всем переменным, и к контролю исполнения на низком уровне

var runtime = Funny.Hardcore.Build(
    "y = a-b; " +
    "out = 2*y/(a+b)"
);
// Set inputs
runtime["a"].Value = 30;
runtime["b"].Value = 20;
// Run script
runtime.Run();
// Get outputs
Assert.AreEqual(0.4, runtime["out"].Value);
Мы можем продолжить ...

Вычисление нескольких значений, на основании входных переменных

// Assume we have some С# model
/*
    class SomeModel {
        public SomeModel(int age, Car[] cars) {
            Age = age;
            Cars = cars;
        }
        public int Age { get; }    //Used as input
        public Car[] Cars { get; } //Used as input
        public bool Adult { get; set; }   //Used as output
        public double Price { get; set; } //Used as output
    }
*/

var context =  new SomeModel(
  age:42, 
  cars: new []{ new Car{ Price = 6000 }, new Car{ Price = 6200 }}
);

// then we can set the 'Adult' and 'Price' properties based on the value of the 'Age' and 'Cars' properties
Funny.CalcContext(
    @"  
        adult = age>18
        price = cars.sum(rule it.price)
    ",
    context);
Assert.AreEqual(true, context.Adult);
Assert.AreEqual(12200, context.Price);
// So input values and output values are properties of the same object

Кастомизация

Nfun предоставляет кастомизацию синтаксиса и семантики под ваши нужды
- Запрет или разрешение if-выражений
- Decimal или Double арифметика
- Integer overflow поведения
- Запрет или разрешение пользовательских функций
- Тип по умолчанию для целочисленных констант

var uintResult = Funny
        .WithDialect(integerOverflow: IntegerOverflow.Unchecked)
        .Calc<uint>("0xFFFF_FFFF + 1");
    Assert.AreEqual((uint)0, uintResult);


//now you cannot launch script with such an expression
var builder = Funny.WithDialect(IfExpressionSetup.Deny);
Assert.Throws<FunnyParseException>(
  () => builder.Calc("if(2<1) true else false"));

Добавление функций и констант

//assume we have custom function (method or Func<...>)
Func<int, int, int> myFunctionMin = (i1, i2) => Math.Min(i1, i2);

object a = Funny
            .WithConstant("foo", 42)
            .WithFunction("myMin", myFunctionMin)
            // now you can use 'myMin' function and 'foo' constant in script!
            .Calc("myMin(foo,123) == foo");
Assert.AreEqual(true, a);

Синтаксис

Nfun поддерживает однострочные выражения:

12 * x**3 - 3

Многострочные именованные выражения:

nameStr = 'My name is: "{name}"'
ageStr = 'My age is {age}'
result = '{nameStr}. {ageStr}'

И пользовательские функции:

maxOf3(a,b,c) = max(max(a,b),c)

y = maxOf3(1,2,3) # 3

В зависимости от задачи, вы можете включать и отключать эти возможности.

Подробнее про синтаксис

Операторы

# Arithmetic operators: + - * / % // ** 
y1 = 2*(x//2 + 1) / (x % 3 -1)**0.5

# Bitwise:     ~ | & ^ << >>
y2 = (x | y & 0xF0FF << 2) ^ 0x1234

# Discreet:    and or not > >= < <= == !=
y3 = x and false or not (y>0)

If-выражение

simple  = if (x>0) x else if (x==0) 0 else -1
complex = if (age>18)
            if (weight>100) 1
            if (weight>50)  2
            else 3
        if (age>16) 0
        else       -1

Пользовательские функции и обобщенная арифметика

sum3(a,b,c) = a+b+c #define generic user function sum3

r:real = sum3(1,2,3) 
i:int  = sum3(1,2,3)

Массивы

# Инициализация
a:int[] = [1,2,3,4]      # [1,2,3,4]  type: int[]
b = ['a','b','foo']# ['a','b','foo'] type: text[]
c = [1..4] 	   # [1,2,3,4]  type: int[]
d = [1..7 step 2]      # [1,3,5,7]  type: int[]

# Оператор In
a = 1 in [1,2,3,4]		# true

# Чтение
c = (x[5]+ x[4])/3

# Срезы
y = [0..10][1:3] #[1,2,3]
y = [0..10][7:]  #[7,8,9,10]
y = [0..10][:2]  #[0,1,2]
y = [0..10][1:5 step 2] #[1,3,5]

# Функции
# concat, intersect, except, unite, unique, find, max, min, avg, median, sum, count, any, sort, reverse, chunk, fold, repeat

Структуры

# initialization
user = {
    age = 12, 
    name = 'Kate'
    cars = [ # array of structures
        { name = 'Creta',   id = 112, power = 140, price = 5000},
        { name = 'Camaro', id = 113, power = 353, price = 10000} 
    ]
}
userName = user.name # field access

Строки

a =  ['one', 'two', 'three'].join(', ') # "one, two, three" of String

# Interpolation:
x = 42
out = '21*2= {x}, arr = {[1,2,x]}' 
#"21*2= 42, arr = [1,2,42]" of String

Linq (лямбды)

[1,2,3,4]
    .filter(rule it>2)
    .map(rule it**3)
    .max() # 64   

Семантика

Nfun строго типизирован - это было основным вызовом, и ключевой особенность для гармоничной интеграции в C#, а так же защиты от ошибок. Однако, синтаксис языков со строгой типизацией всегда сложнее.

Что бы решить эту проблему я опирался на постулат:

Все, что выглядит как правильный скрипт (в рамках синтаксиса/семантики) - должно запуститься

Или, более формализовано:

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

Либо показать, что такой код не может быть выполнен без ошибок.

Это потребовало разработки сложной системы выведения типов, от которой и отталкивается вся семантика языка. Результатом этого является повсеместное использование Generic-ов.

В примере ниже - функции, вычисления, и даже константы - являются обобщенными типами (из списка int32, uint32, int64, uint64, real). Однако, пользователь не должен задумываться об этом:

var expr = @"
  # generic function
  inc(a) = a + 1  

  # generic calculation
  out = 42.inc().inc()
";
double d = Funny.Calc<double>(expr); // 44  of double  
int    i = Funny.Calc<int>(expr); // 44 of int

Таким образом, удалось собрать все преимущества строгой типизации:

  • Если типы выражения не сходятся - вы получаете ошибку на этапе интерпретации.

  • Если типы не сходятся с ожидаемыми C# типами - вы получаете ошибку на этапе интерпретации.

  • Производительность.

При этом, синтаксис остался максимально простым для неподготовленного пользователя!

Технические детали

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

Интерпритацию кода можно разделить на несколько этапов:

1. Токенизация (лексер).

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

2. Парсинг.

Разбор токенов в дерево AST. Используется самописный парсер. Спецификация синтаксиса опирается на спецификацию языка (и очень много тестов). Формальная грамматика не описана.

3. Выведение типов.

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

Нюансы:

  • Целочисленные константы являются "обобщенными константами"

one() = 1 # returns T, where byte -> T -> real 

y:int  = one() # 1 of int
z:real = one() # 1.0 of real
  • Тип узла может зависеть, как от предыдущего, так и от последующего кода

y = 1 # y:real, так как используется в знаменателе на следующей строчке
x:real = 1/y
  • Ограничения на дженерик-переменные могут быть заданы, как сверху (наследование, тип к которому можно привести данный), так и снизу ( потомок, тип, которой может быть приведен к данному)

sum(a,b) = a+b

x:int  = 1 + 2  # x = 3 of int
y:real = 1 + 2 # y = 3.0 of real
z:byte = 1 + 2 # error! operator '+' is defined for (uint16|int16)-> T -> real
               # so it cannot return 'byte'

4. Сборка выражений (построение рантайма)

Из результатов решения типов и Ast-дерева собирается самописное дерево вычисляемых выражений.

  1. Конвертация CLR-значений в Nfun.

  2. Исполнение выражений.

  3. Конвертация результатов в CLR значения.

Я сознательно не использовал Csharp-expression-tree, так как, одним из важнейших критериев была скорость "one-time-shot". То есть запуска, от момента получения строки со скриптом и до момента получения результатов выполнения.

Состояние проекта

Проект готов к использованию в продакшене.

Используется в течении года в составе scada-системы, покрыт 6000+ тестами, к нему написана спецификация, и.. даже есть несколько звездочек на гитхабе (да-да, это call to action!). Почти успех!

Заключение

Когда я начинал Nfun, я мечтал создать простой, надежный и интуитивный опенсорс-инструмент. Я хотел реализовать синтаксические идеи и поэкспериментировать с системами выведения типов, попробовать для себя что-то новое...

По итогу, Nfun стоил огромного количества сил, времени и инвестигаций. Я впервые столкнулся с подобной задачей. Теперь мне хочется, чтобы люди пользовались этим инструментом! И писали тикеты, да реквесты на гитхаб. Ну, или комментарии под этот пост ;)

Tags:
Hubs:
Total votes 9: ↑9 and ↓0+9
Comments34

Articles