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

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

Что есть "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 стоил огромного количества сил, времени и инвестигаций. Я впервые столкнулся с подобной задачей. Теперь мне хочется, чтобы люди пользовались этим инструментом! И писали тикеты, да реквесты на гитхаб. Ну, или комментарии под этот пост ;)

Комментарии (21)


  1. ARad
    01.12.2022 07:06
    +2

    Почему вы не использовали стандартный компилятор C#? Зачем такие сложности?


    1. Bonart
      01.12.2022 09:57
      +1

      Скорость компиляции


    1. a-tk
      01.12.2022 10:14
      +2

      Компилятор C# компилирует C#. Здесь же грамматика явно отличается.


      1. ARad
        01.12.2022 10:26

        Я понял что отличается, но зачем это, если как раз можно использовать и стандартный C# и не писать компилятор...


        1. CrazyMaks
          01.12.2022 14:46
          +2

          Потому что скрипт задает пользователь, а не программист хардкодит его в проект.


        1. mayorovp
          01.12.2022 15:25
          +1

          Нельзя сувать непроверенный ввод пользователя в компилятор C#. А проверка ввода тут сама по себе по сложности как компиляция.


    1. tmteam Автор
      01.12.2022 15:36

      C# и F# не подходят для этих задач:

      1. Пользователям (бугалтерам, редакторам, пусконаладчикам, проектантам) очень сложно (а скорее невозможно) писать код с явной типизацией (пускай даже простой)

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

        Сравните с точки зрения не программиста эти две клинописи:

      var i = new[]{1,2,3}.Select(a=>Math.Pow(a,2));
      var b = new User(){ Age = 18, Name = "Kate"};
      var c = i.First() >0 ? "positive" : "negative";

      и

      i = [1,2,3].map(rule it**2)
      b = {age = 18, name = 'Kate'}
      с = if(i[0]>0) 'positive' else 'negative'

      1. Скорость компиляции у C#/F# - отстой. Это критично как для embedded -сценариев,так и для одиночного запуска

      2. Кол-во звездочек у пакета Ncalc

      1. Потому что я могу написать свой мини-ЯП !


      1. mayorovp
        01.12.2022 15:46

        Только вот у вас во втором пункте две разные "клинописи". Ни один пример не будет понятным бухгалтеру или редактору.


        1. tmteam Автор
          01.12.2022 16:16

          Сегодня как раз на стриме будем обсуждать.

          Проверяли на практике (проэктанты Scada-системы Sonica). Там был питон/С# и Nfun. 

          По итогу - проектанты:

          • При касании C# - сразу говорили "Нет, мы это делать не будем, лучше на костылях".

          • На питоне плакали кололись и допускали очень много ошибок.

          • На Nfun спокойно пишут, ошибок не допускают (но лямбды не используют).

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

          А по факту - действительно "идеального" решения пока нет. Но сложность например SQL дается большинству. Python уже сложнее. C# это ппц для обычных ребят. Но так как мы идем в мир где минимальное знание ЯП становится таким же естественным как умение решать простые уравнения - то это вселяет в меня оптимизм


      1. ARad
        01.12.2022 18:11

        Я точно не уверен насколько это просто, но рассматривали ли вы интеграцию с .NET Interactive как альтернативу делать свое решение? .NET Interactive поддерживает скрипты на C#, F#, PowerShell, JavaScript. Я думаю что F# и JavaScript достаточно просты. Жаль конечно что нет поддержки Python. Это сейчас один из самых популярных скриптовых языков. И мне безусловно интересно как в .NET Interactive реализованна защита песочницы, и есть ли она там...

        Рассматривали ли вы Python или JavaScript как языки для написания скриптов? Их достаточно часто применяют в похожих сценариях. Я не искал готовые для этого библиотеки в .NET, но возможно они есть. Тут конечно надо серьёзно взвешивать все за и против, особенно в части передачи данных между .NET и скриптом.

        По 4 пункту — если вы будете сохранять результат компиляции, то скорость компиляции C#, F# оригинальным компилятором не должна стать проблемой. Тут конечно возникает вопрос защиты песочницы. Насколько просто сделать защиту песочницы, при использовании оригинального компилятора.

        По 2 пункту, если честно синтаксис спорный в том плане что он нестандартный, опять напомню про синтаксис Python или JavaScript как альтернативу придумывать свой. Ваш мне очень напоминает смесь C# и JavaScript с расширениями. Особенно нестандартно и крипово выглядит (rule it**2).

        Я осознаю что вам уже поздно все переделывать, когда вы уже использовали свое решение в Проде. И нисколько вас к этому не призываю.


      1. GbrtR
        01.12.2022 18:18

        Выглядит круто.

        Есть ли подобные библиотеки или прямые конкуренты?

        Написали ли language server (и/или плагин для редактора кода на веб страницах, чтобы синтаксис подсвечивался) чтобы пользователи могли проще видеть что они делают?

        Можно ли отключать поддерживаемые фичи в рантайм? Например мы не хотим переусложнять синтаксис и разрешать сложные выражения, было бы полезно иметь разные поддерживаемой уровни сложности, типа basic, advanced, prof.

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


  1. asilzhan11
    01.12.2022 15:05
    +1

    Круто


  1. AuDim
    01.12.2022 15:05

    В последнее время в большинстве статей по C# идет описание "Как это сделать"
    Но я почти всегда перестаю понимать "Зачем это делать"?

    Так и здесь - объясните зачем это делать?
    Можете описать прикладное пример этого?


    1. codecity
      01.12.2022 15:31
      +1

      В админке для вычисления курсов валют задавать формулу, а не просто коэффициенты.


    1. tmteam Автор
      01.12.2022 15:59

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

      • Запись выражений в скада системах и или лоукод решениях (смотрите скриншоты в статье)

      • Хранение выражений вместо констант в конфигах (пример с Settings.json в статье). Можно сказать что это сериализация лямбд.

      • Web UI - например фильтры запросов, правила начисления бонусов и так далее (скриншоты в статье)

      • Тонкая настройка фильтров изображений

      • Верстка, в том числе email. Например если раньше вы писали

      Дорого пользователь. Ваш баланс низкий и скоро мы вас заболим

      то теперь можно писать:

      {if (user.gender==m} 'Дорогой' else 'Дорогая'} {user.name}. Ваш баланс равен {user.balance} $ и скоро мы вас заблочим

      Раньше для такого нужно было напрягать программистов. Теперь это можно писать просто в вашей CRM

      Это примеры которые были в практике или сходу пришли мне в голову.

      Примеры:


  1. ka4ep
    01.12.2022 15:38
    +2

    Можете попробовать перевести слово "инвестигация" на русский, это не сложно.


    1. tmteam Автор
      01.12.2022 15:44
      -1

      Это действительно не сложно - но у этих слов сейчас различные оттенки:

      • Исследования - это что то связаное с формальной наукой. "Делал дома исследования" - звучит кустарно

      • Изучение - это больше про учебу и не смотрится в оригинальном тексте

      • Инвестигации - это именно кустарное, айтишное исследование

      А так как слово "Инвестигация" - прекрасно склоняется и встраивается в язык - не вижу проблем. Мы же не боимся слова "инфа" или "комп" чьи корени так же заимствованы.

      Процесс обогащения языка заимствованными корнями, при условии склоняемости слов - это не плохо. Даже хорошо


      1. ka4ep
        01.12.2022 15:57
        +1

        Наверное это только мне глаза режет, потому и занудствую. Но какое у этого слова, обозначающее процесс, есть существительное? Климатизация - климат, диспетчеризация - диспетчер, инвестигация - ...?


  1. ka4ep
    01.12.2022 15:55

    ...


  1. DimonSmart
    01.12.2022 17:30
    +1

    Спасибо за статью! Проделана огромная работа!

    Добавлю свои 5 копеек про шуточный калькулятор на регулярках! Fun with C# Regex based Expression calculator - DEV Community ????‍????????‍????


  1. kemsky
    01.12.2022 20:47

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

    Нужна подсветка синтаксиса или хотя бы минимальный валидатор на js.