Русский
Русский
English
Статистика
Реклама

Abstract syntax tree

Анализатор C на первом курсе миф, иллюзия или выдумка?

06.11.2020 12:09:31 | Автор: admin
Для программистов настали тяжёлые времена. Хотя Утечка Памяти была уничтожена valgrind-ом, оставшиеся силы UB преследовали программистов по всей галактике.

Избегая встречи с грозными знаковыми переполнениями, группа борцов за свободу, ведомая Кириллом Бриллиантовым, Глебом Соловьевым и Денисом Лочмелисом, обустроила новый секретный репозиторий.

Тёмная владычица UB неинициализированная переменная, одержимая желанием сломать все программы галактики, разослала тысячи раздражающих ошибок в самые далекие уголки космоса


Мы трое студентов бакалавриата Прикладная математика и информатика в Питерской Вышке. В качестве учебного проекта во втором полугодии мы решили написать UB-tester анализатор кода на С++.




Вступление


Реальная история: контест идет, времени остается мало, кодить задачки надо быстро. Именно в тот момент, когда разум участника охвачен быстрыми алгоритмами и тикающими секундами, С++ начинает выходить из-под контроля. В итоге программист старательно и очень долго ищет ошибку в реализации алгоритма, в неразобранном крайнем случае, в себе. Но пропускает случайно набранный неправильный индекс при обращении к массиву, неожиданное переполнение при арифметических операциях или заезженную неинициализированную переменную: ошибку или опечатку непосредственно в коде.

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

Небольшое введение в контекст
Анализаторы кода делятся на две группы: статические и динамические. Статические анализируют код без реального выполнения программы. Динамические, напротив, пытаются извлечь информацию во время ее исполнения. Самые известные примеры статический clang static analyzer с большим функционалом, его платный аналог PVS studio, а также динамические valgrind и sanitizer.

Нам предстояло выбрать основной метод обнаружения ошибок. Источником вдохновения послужил достопочтенный С-шный макрос assert. Если программист регулярно проверяет инварианты с помощью assert-а, то поиск ошибки при отладке становится намного более локализованной и простой задачей. Но вряд ли кто-то в здравом уме использовал бы такую проверку постоянно, обращаясь к массиву или складывая два int-а. Мы решили создать инструмент, который будет анализировать пользовательский код, а затем заменять ошибкоопасные места на написанные нами обертки, в случае чего выбрасывающие пользователю подробный лог ошибки. К примеру:


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

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

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

Финальной версии не было суждено стать новым valgrind-ом, а функционалу покорить сердца миллионов, но процесс разработки оказался весьма захватывающим. Про это мы и расскажем далее.

Сlang и AST


Первым шагом работы ub-tester является нахождение всех ошибкоопасных мест в программе. Парсить код на C++ вручную и с нуля путь в могилу (или по крайней мере в отдельный проект), поэтому нам предстояло подобрать инструменты для решения этой задачи. Пожалуй, самым известным и наиболее практичным инструментом по разбору исходного кода на плюсах является clang, поэтому мы выбрали его в качестве основы для нашего проекта. Сlang большая библиотека, поэтому остановимся только на важных для понимания статьи понятиях.

Сперва стоит сказать об AST (Abstract Syntax Tree). Это структура данных дерево (как можно было догадаться из названия), внутренние вершины которого соответствуют операторам языка, а листья операндам. Вот пример AST для простой программы.

Исходный код:
if (a > b) {print(a);} else { print(b);}

AST:


Описанная абстракция и используется в clang-е для представления исходного кода. Такой структуры данных достаточно для обнаружения ошибкоопасных мест. К примеру, чтобы найти в программе теоретически возможные index out of bounds, нужно изучить все вершины AST, соответствующие операторам обращения к массиву по индексу.

Теперь дело остается за малым: получить доступ к AST. В clang-е существует несколько механизмов для решения этой задачи. Мы пользовались Visitor-ами.

В clang-е реализован базовый класс RecursiveASTVisitor, осуществляющий дфсный обход AST. Каждый Visitor должен от него наследоваться, чтобы уметь путешествовать по дереву. Далее, чтобы выполнять специфичные для определенных вершин операции, класс-наследник должен иметь набор методов вида Visit***, где *** название узла AST. Оказываясь в вершине дерева, Visitor вызывает соответствующий ее названию метод Visit***, затем рекурсивно спускается в ее детей. Таким образом, чтобы создать своего Visitor-а, достаточно реализовать методы обработки конкретных вершин.

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

class FindBinaryOperatorVisitor : public clang::RecursiveASTVisitor<FindBinaryOperatorVisitor> { public:bool VisitBinaryOperator(clang::BinaryOperator* BinOp) { std::cout << BinOp->getBeginLoc() << \n;return true;}};

Теперь можно со спокойной душой переходить к следующему пункту: к замене участков исходного кода.

CodeInjector


Сразу скажем, что, вероятно, мы избрали далеко не лучшее решение этой задачи. Мы разработали класс CodeInjector, который
  • лениво применяет предоставленные ему замены исходного кода: сохраняет все пришедшие ему запросы, а затем разом их выполняет в конце обработки файла;
  • никак не связан с clang-ом: сам открывает файл на чтение, сам ищет нужные строки и так далее.

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

Простой пример. Есть такое выражение:
arr[1] + arr[2];

Пусть мы сначала захотим поменять сложение на ASSERT_SUM, получим следующее:
ASSERT_SUM(arr[1], arr[2]);

Кроме того, мы хотим проверить, что не произойдет выход за границы массива. Снова обратимся к этому же участку кода:
ASSERT_SUM(ASSERT_IOB(arr, 1), ASSERT_IOB(arr, 2));

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

Мы заметили, что схемы замен состоят из чередования кусков, которые мы оставляем, и кусков, которые мы заменяем. Проиллюстрируем на примере: выражение: arr[1+2]. В нем есть два ошибкоопасных места: обращение к массиву и операция сложения. Должны произойти следующие замены:
  • arr[1+2] заменяется на ASSERT_IOB(arr, 1 + 2)
  • ASSERT_IOB(arr, 1 + 2) заменяется на ASSERT_IOB(arr, ASSERT_SUM(1, 2))
    arr[1 + 2] ~~~~~> ASSERT_IOB(arr, 1 + 2) ~~~~~~> ASSERT_IOB(arr, ASSERT_SUM(1, 2)




Далее мы, внутри команды, договорились об интерфейсе для взаимодейстия с CodeInjector-ом, основанном на этой идее.

Все готово! Можно приступать к самому интересному работе с конкретными видами UB.

Функционал


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

Основные ошибки, выявляемые нашей программой:
  • выход за границы С-шного массива;
  • UB в целочисленной арифметике;
  • обращение к неинициализированной переменной.

Подробно про некоторые аспекты функционала мы расскажем далее.

Целочисленная арифметика


Известнейшие представители UB арифметике целочисленные переполнения в самых разных операциях. Есть и более изощренные UB, которыми могут похвастаться, например, остаток от деления и особенно битовые сдвиги. Кроме того, часто можно встретить составные операторы присваивания (+= и другие) и операторы инкремента/декремента. Во время их использования происходит целая цепочка неявных преобразований типов, которая и может повлечь крайне неожиданный и нежеланный результат. Да и неявные касты сами по себе могут служить большой бедой.

Так или иначе, общее правило для всех UB в арифметике их сложно найти, легко проглядеть (никаких segfault-ов!) и невозможно простить.

Важно: впредь и далее речь пойдет про стандарт С++17, на который и ориентирован ub-tester. Он также поддерживает C++20, но из-за того, что в нем исчезли некоторые виды UB в арифметике (а новых не добавилось), про него говорить не так интересно.

int a = INT_MIN, b = -1, z = 0;int test1 = a + b; // overflowint test2 = a * b; // overflowint test3 = a << 5; // lhs is negative => UBint test5 = 5 % z; // % by 0 => UBint test6 = --a; // overflow

Мы много работали с целочисленными вычислениями, дробные пока остались в стороне. В первую очередь, из-за их отличия в представлении в железе, соответственно, и в методах обработки ошибок. Насколько мы знаем, самым удачным вариантом борьбы с UB в арифметике с плавающей точкой является проверка специальных флагов с помощью fenv.h, но дальше первого знакомства мы не зашли. В любом случае, и целочисленного случая хватило, чтобы один из членов нашей команды вполне увлекательно вычислился провел половину учебного года. Перейдем к сути.

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

template <typename T>ArithmCheckRes checkSum(T Lhs, T Rhs) {  FLT_POINT_NOT_SUPPORTED(T);  if (Rhs > 0 && Lhs > std::numeric_limits<T>::max() - Rhs)    return ArithmCheckRes::OVERFLOW_MAX;  if (Rhs < 0 && Lhs < std::numeric_limits<T>::lowest() - Rhs)    return ArithmCheckRes::OVERFLOW_MIN;  return ArithmCheckRes::SAFE_OPERATION;}

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

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

Например
Например, неопределенное поведение во время взятия остатка INT_MIN % (-1), вызванное тем, что результат не помещается в int. Если же вы, как и я, всегда немного осторожничали с битовыми сдвигами то не зря. Битовый сдвиг влево может похвастаться рекордом по количеству различных UB, которые может вызывать. К примеру, сдвиг на отрицательное число бит это неопределенное поведение. Сдвиг на большее число бит, чем есть в типе тоже. А если попробовать битово сдвинуть влево отрицательное число, то до C++20, безусловно, снова UB. Но если вам повезло, и ваше число в знаковом типе неотрицательно, то корректность результата будет зависеть от возможности представить его в беззнаковой версии, что тоже можно не удержать в голове. В общем, хоть эти правила и понятные, но, в случае неаккуратного с ними обращения, последствия могут оказаться весьма плачевными.

int a = INT_MIN, b = -1;int test = a % b; // -INT_MIN > INT_MAX => UBint test1 = a << 5; // lhs is negative => UBint test2 = 5 << b; // rhs is negative => UBint32_t c = 1, d = 33;int32_t test3 = c << d; // rhs is >= size of c in bits => UBint test4 = 2e9 << c; // test = -294967296, not UBint test5 = 2e9 << (c + 1); // (2e9 << 2) > UINT_MAX => UB


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

Самыми интересными в работе оказались операторы составного присваивания и инкремента/декремента за счет неявной цепочки особенно неявных преобразований. Простой пример.

Вы с другом пошли в лес и решили прибавить к чару единицу Красиво прибавить, то есть ++c. Будет ли вас ожидать в следующем примере UB?

char c = 127;++c;

И тут понеслось. Во-первых, безобидный префиксный инкремент решил вычисляться наподобие своему старшему собрату, то есть как составное присваивание c += 1. Для этого он привел c и 1 к общему типу, в данном случае к int-у. Затем честно посчитал сумму. И перед записью результата обратно в c, не забыл вызвать неявное преобразование к char-у. Уф.

И UB не случилось. Действительно, само сложение произошло в большом типе int, где число 128 законно существует. Но при этом результат все равно получился с изюминкой, благодаря неявному приведению результата к char-у. Более того, такое приведение чисто теоретически могло закончится не -128, а чем-то особенным в зависимости от компилятора документация это допускает, ведь тип знаковый.

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

В случае с операторами составного присваивания AST не жалеет информации, поэтому проследить цепочку преобразований типов несложно. Далее несложно и проверить корректность вычисления в одном типе, затем неявное приведение к исходному (обе такие проверки уже есть в арсенале). Однако по поводу инкремента/декремента AST отказалось сотрудничать, скрыв всю кухню преобразований. Решением было внимательно прочитать документацию С++, а затем начать цепочку приведений с std::common_type от типа аргумента и int-a (типа единицы). Искушенный clang-ом читатель может поймать нас за руку, заметив, что именно по поводу таинственных ++ и AST, вообще говоря, знает, возможно переполнение или нет.

Однако мы здесь собрались не просто так

Читатель ловит нас за руку, в то время как AST делает нашу работу

Вопреки названию нашего проекта, ub-tester находит не только UB. Мотивацией арифметических проверок были не только ошибки, влекущие за собой обессмысливание всей программы, но и неприметные на первый взгляд источники неожиданных результатов (при этом корректных с точки зрения С++). Поэтому информации о возможности/невозможности UB в операторах инкремента/декремента мало, проверка должна уметь детектировать еще и сужающее неявное преобразование и уметь рассказывать про это пользователю. По этой причине в случае ++ и пришлось дополнительно поработать, чтобы добиться желаемого функционала.


Вывод ub-tester-а на примере с char c = 127; ++c;

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

Неинициализированные переменные


Каждому, кто знаком с C++, известно (но зачастую это анти-интуитивно для новичков, пришедших из других языков): если объявить переменную базового типа, но не проинициализировать её никаким значением, а потом в неё посмотреть возникает UB. В то же время, такой синтаксис бывает удобен и иногда даже необходим, к примеру, для использования `cin` или `scanf`, поэтому мы решили побороться с этим типом ошибок.

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

template <typename T>class UBSafeType final {public:  UBSafeType() : Value_{}, IsInit_{false} {}  UBSafeType(T t) : Value_{t}, IsInit_{true} {}  T assertGetValue_() const;  T& assertGetRef();  T& setValue_(T Val);  private:  T Value_;  bool IsInit_;};

А затем возникают вопросы. Как мы понимаем, когда происходит именно чтение значения переменной, а когда она просто упоминается каким-либо другим образом? Суть в том, что согласно стандарту, UB возникает именно в момент lvalue to rvalue cast, а его Clang AST выделяет в отдельную вершину `ImplicitCastExpr` нужного типа. Хорошо, тогда более сложный вопрос: пусть есть `scanf`, читающий несколько переменных, но пользователь дал некорректный ввод и прочиталась только одна переменная. Тогда как понять, что происходит внутри наших оберток?

После некоторых размышлений волевым усилием отвечаем: никак. Если нет доступа к исходному коду функции, которая принимает переменную по ссылке или указателю, и при этом на момент вызова этой функции переменная не была проинициализирована, мы можем только сказать пользователю: Эй, у тебя тут потенциально опасная ситуация, но мы за это не отвечаем, а затем про эту переменную забыть.

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


Пример: обработанная версия программы, читающая IP-адрес по шаблону при помощи scanf, не может обнаружить UB, возникающее при выводе результата на экран

Перейдем к тому, как мы использовали AST. По вершинам `VarDecl` мы находим объявления переменных и заменяем их на конструкторы оберток, по операторам присваивания находим собственно присваивания, которые и являются инициализациями. Интереснее с доступом к значению: информацию о переменной содержат вершины `DeclRefExpr`, но мы-то ищем `ImplicitCastExpr`, находящийся выше в дереве. При этом принципиально важно обработать случаи доступа по значению (с кастом) и по ссылке (без него) по-разному. На помощь приходит технология Parent-ов, позволяющая подниматься по дереву независимо от обхода Visitor-ов. (Передаю привет неполной документации ParentMapContext, из-за которой пришлось долго гулять по исходникам clang-а). Увы, это палка о двух концах из-за потенциального обхода целого дерева асимптотика нашего анализатора возрастает до квадратной, и время работы самого анализатора ухудшается значительно. А что же делать, если обращение к переменной по ссылке? Возможно, эта ссылка куда-то передается, возможно, сохраняется в новую переменную И что делать с полями классов? А вложенных классов?

int main() {  int a = 5;  int& b{a};  int c = b++ + (int&)a;  (b = c) = (b);}


Пример: страшный сон исследователей неинициализированных переменных и его AST

Не будем утомлять читателя миллионом вопросов, которые мы сами себе задавали и вынуждены были искать ответы. Скажем только, что ключевой сложностью оказалось отсутствие какого бы то ни было источника информации, в котором были бы собраны все пререквизиты и ценные мысли, объясняющие, каким вообще образом стоит реализовывать данную идею. Как следствие, пришлось наступать на все старательно разложенные языком C++ и clang-ом грабли, и по мере осмысления всё большего количества тонкостей и деталей задачи, глобальное представление об этой части проекта сильно видоизменялось и обрастало подробностями и замысловатостями. На продумывание и размышления ушло много больше времени, чем на саму реализацию.

Результаты


Полномасштабное тестирование мы, к сожалению, не успели провести. Тем не менее, мы проверяли, как наша программа работает, на 1) отдельных изощренных ситуациях, 2) примерах простеньких программ на C++ из интернета, 3) своих решениях контестов и домашек. Вот пример преобразования, получающегося в результате работы нашего анализатора:

Пример до:
#include "../../include/UBTester.h"#include <iostream>int main() {  int a, b;  std::cin >> a;    char c = 89;  c = 2 * c; // unsafe conversion    int arr[10];  arr[a] = a; // possible index out of bounds    a = b; // uninit variable}

Пример после:
#include "../../include/UBTester.h"#include <iostream>int main() {  UBSafeType<int> a, b;  std::cin >> ASSERT_GET_REF_IGNORE(a);    UBSafeType<char> c(IMPLICIT_CAST(89, int, char));  ASSERT_SET_VALUE(c, IMPLICIT_CAST(ASSERT_BINOP(Mul, 2, IMPLICIT_CAST(c, char, int), int, int), int, char)); // unsafe conversion    UBSafeCArray<int, 10> arr(ASSERT_INVALID_SIZE(std::vector<int>({10})));  ASSERT_IOB(arr, ASSERT_GET_VALUE(a)) = ASSERT_GET_VALUE(a); // possible index out of bounds    ASSERT_SET_VALUE(a, ASSERT_GET_VALUE(b)); // uninit variable}

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


Пример: вот так работает код, приведенный выше, на входных данных 5 и 15

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


В заключение скажем: перед нами не стояло цели создать готовый продукт нам было важно столкнуться с LLVM, clang и С++ и разбиться насмерть. Возможно, результат вышел не самым практичным, но чтение документаций и стандарта оказалось занимательным и особенно полезным времяпрепровождением. Будьте осторожны при использовании С++ и да хранит вас Бьёрн Страуструп.

Ссылка на гитхаб
Подробнее..

Из песочницы Как скомпилировать декоратор C, Python и собственная реализация. Часть 2

29.06.2020 22:07:32 | Автор: admin

Декораторы одна из самых необычных особенностей Python. Это инструмент, который полноценно может существовать только в динамически типизированном, интерпретируемом языке. В первой части статьи мой товарищ Witcher136 показал, как в С++ реализовать наиболее приближенную к эталонной (питоновской) версию декораторов.


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



Оглавление


  1. Как работают декораторы в Python
  2. Haskell и LLVM собственный компилятор
  3. Так как же скомпилировать декоратор?


Как работают декораторы в Python


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


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


Как работают декораторы в Python, в общем-то интуитивно понятно любому человеку, знакомому с этим языком:


Функции decorator, принимающей другую функцию как свой аргумент func, в момент применения декоратора в качестве данного аргумента передается декорируемая функция old. Результатом является новая функция new и с этого момента она привязывается к имени old

def decorator(func):    def new(*args, **kwargs):        print('Hey!')        return func(*args, **kwargs)    return new@decoratordef old():    pass# old() выведет "Hey!" - к имени old теперь приязан вызов new

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


Про интерпретатор CPython

Интерпретатор питона является виртуальной машиной для Python-байткода. Вирутальная же машина, в свою очередь является стэк-машиной несколько упрощая, можно сказать, что она имеет набор встроенных инструкций и стек, на котором вперемешку лежат данные и коды инструкций. Когда интерпретатор встречает на стеке команду, он пытается ее выполнить, использая как аргументы то, что лежит на стеке после нее либо какие-то константы или явно переданные данные из "обычной" памяти.


Благодаря этому, интерпретатору не надо знать о типах объектов, соответстующих символам в коде, вплоть до момент выполнения операций над ними когда очередь дойдет до выполнения какой-либо "конкретной" инструкции тогда он и проверит тип. Сильно упрощая можно объяснить это так: BINARY_SUBSTRACT (вычитание) упадет с TypeError, если дальше на стэке лежат число 1 и строка 'a'. В тоже время, выполнение STORE_FAST для одного и того же имени (запись в одну и ту же переменную), когда один раз на стэке лежит число, а в другой раз строка, не приведет к TypeError, т.к. в инструкцию по выполнению команды STORE_FAST не входит проверка типа только связывание имени с новым объектом.


Создание замыканий, таких как new в примере выше это также одна из инструкций виртуальной машины. А если разобрать байт-код, соответствующий применению декоратора, то можно увидеть, что это действительно просто вызов функции decorator с соответствующими аргументами и сохранение результата под именем old.


Проблема 1. Декораторы это просто функции


Декораторы применяются в рантайме. В примере выше значение decorator может меняться вплоть до самого его использования, например вот так:


name = input('Введите имя декоратора')def first(func):    ...  # тело декоратораdef second (func):    ...  # тело декоратораif name == 'first':    decorator = firstelif name == 'second':    decorator = secondelse:    decorator = lambda f: f   # оставляем функцию в покое@decorator def old():    pass

С точки зрения нашего умозрительного компилятора, значение функции old может поменяться на что угодно в процессе выполнения программы. В некоторых языках (например, C++) замыкания реализованы так, что даже при одинаковой сигнатуре они будут разного типа (из-за разницы в захваченном ими окружении), что не позволит провернуть такой трюк. В Python же каждое замыкание носит всё свое окружение с собой в питоне всё, включая функции, это объекты, так что замыкания "могут себе это позволить", тем более потребление памяти и быстродействие не являются приоритетом для этого языка.


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


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


Проблема 2. Python мало интересуют типы


def decorator(func):    def two_args(x, y):        ...    return two_args@decoratordef one_arg(x):    ...

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


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


Это также подводит нас к обратной проблеме какой тип должен быть у аргумента декоратора в наших примерах это аргумент с названием func? Чаще всего этот аргумент, представляющий декорируемую функцию, вызвается внутри замыкания значит нам хотелось бы знать хотя бы тип возвращаемого значения, не говоря уже об аргументах. Если мы его строго зафиксируем с помощью объявления func как функции типа A, мы ограничили область применения декоратора функциями типа A. Если же мы и это объявляем как void* func, и предлагаем программисту самому везде приводить нужные типы, то проще писать на питоне.


Один из вариантов выводить тип func автоматически, например с помощью шаблонов так и сделал Witcher136 в предыдущей статье. Однако это не решает проблему с разными сигнатурыми возвращаемых замыканий, а так же делает определения декораторов совершенно нечитаемыми для простых смертных (см. примеры на C++ в предыдущей статье).




Подведем итоги. Реализация декораторов в компилируемом языке создает следующие сложности:


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

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

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


Про этот компилятор я и расскажу далее.



Haskell и LLVM собственный компилятор


Для создания компилятора я выбрал Haskell, как язык для написания фронтенда, и LLVM в качестве компиляторного бэкенда. Для Haskell есть замечательная библиотека llvm-hs, предоставляющая все необходимые биндинги к LLVM. В поставку самого языка также входит библиотека Parsec, предназначенная для создания парсеров, путем комбинации различных парсер-функций этой библиотеки (я думаю, что на этом моменте читатель догадался, что Parsec сокращение от parser combinators).


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


Grit expression-oriented (фразированный) язык


Любое выражение в Grit, будь то сложение, блок кода или if-else, возвращает результат, который можно использовать внутри любого другого выражения например, присвоить переменной или передать функции как аргумент.


int main() = {    int i = 0;    i = i + if(someFunction() > 0) {        1;    }    else {        0;    };};

В данном примере, i будет равен 1, если функция someFunction вернет положительное значение, и нулю, если вернется 0 или меньше.


Нет ключевого слова return


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


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


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


int simple(int x) = {    /*       Данная фукция вернет результат сложения      своего аргумента x и переменной y    */    int y = someOtherFunction();    x + y;};/*  Для функций, состоящих из одного выражения, фигурные скобки можно опустить.  Эта функция вернет свой аргумент, увеличенный на единицу*/int incr(int x) = x + 1;int main() returns statusCode {    /*      В объявлении функции с помощью ключевого слова returns      можно указать название переменной, значение которой      будет возвращено после окончания работы функции.      Это переменная будет "объявлена" автоматически      с таким же типом, как у возвращаемого значения функции    */    int statusCode = 0;    int result = someFunction();    if (someFunction < 0) {        statusCode = 1;    };};

Auto компилятор Grit обладает базовой возможностью выведения типов


В языке Grit есть ключевое слово auto, означающее, что тип переменной (или функции) должен быть выведен автоматически.


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


auto half (int x) = x / 2;   // для функции incr будет выведен тип float



Фразированность (expression-oriented), отсутствие return из произвольного места (тело функции тоже выражение) и базовое выведение типов это самые интересные для нас особенности Grit. Я выделил их потому, что они напрямую используются в реализации декораторов в этом языке.

Если вам интересно внутреннее устройство языка, то сообщите об этом в комментариях, и я постараюсь написать отдельную статью про это.


А для нас теперь пришло время наконец ответить на главный вопрос этой серии статей как скомпилировать декоратор?



Так как же скомпилировать декоратор?


Как было указано ранее, при разработке декораторов для компилируемого языка программирования, нужно определиться с двумя вещами будут они применяться в runtime или compile-time, и как определять типы аргументов и результата итоговой функции.


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


Во-первых, декораторы в Grit применяются на этапе компиляции это позволяет, после перестройки синтаксического дерева (оно же AST, abstract syntax tree), вывести тип возвращаемого значения и скопировать объявления аргументов. Во-вторых, декораторы не являются функциями, а лишь похожими на них конструкциями.


Посмотрим на конкретный пример и разберемся, как данный способ объявления декораторов решает указнные в первой части статьи проблемы:


@auto flatten = {    auto result = @target;    if (result < 0) {        0;    }    else {         result;    };};

Это декоратор, который вызовет исходную функцию, и вернет 0, если ее результат меньше 0, иначе он вернет результат без изменений.


@auto flatten объявление декоратора с именем flatten и типом @auto то есть тип будет выведен в зависимости от функции, к которой он применен (@ указатель но то, что это объявление декоратора, а не функции).


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


Вы наверняка уже обратили внимание на необычную метку в теле декоратора @target. Это указание компилятору на то, что в это место надо вставить целиком тело декорируемой функции. Аргументы функции будут скопированы как аргументы для данного инстанса декоратора (что на уровне синтаксического дерева превращает его в новую функцию), а блок кода, являющийся телом исходной функции, вернет свой результат в место вызова, поскольку язык фразированный (этот механизм был описан ранее).
Таким образом, подобное изменение AST эквивалентно вызову исходной функции на месте @target, с правильными аргументами. После этого, исходную функцию можно просто заменить получившейся новой функцией, и все декоратор применен. Если же декораторов у функции несколько, то этот процесс можно повторять несколько раз.


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

Декораторы в виде, реализованном в Grit, позволяют делать множество различных вещей большинство из них те же, что и в Python.


Например, можно ожидать захвата ресурса:


@auto lockFunction = {    mutex.lock();    @target};

Или вызывать функцию, только если установлен какой-либо флаг:


@auto optional = if (checkCondition()) {    @target;}else {    someDefaultValue;};

И так далее


Рассмотрим этот механизм на примере сгенерированного компилятором Grit синтаксического дерева для простой программы с декораторами:


@auto flatten = {    auto result = @target;    if (result < 0) {        0;    }    else {         result;    };};@flattenint incr(int x) = x+1;

Здесь уже рассмотренный нами декоратор flatten применяется к функции, увеличивающей свой аргумент на единицу.


Так выглядит "сырое" внутреннее представление, до какой-либо обработки вообще:


Decorator "flatten" auto {  BinaryOp = (Def auto "result") (DecoratorTarget)  If (BinaryOp < (Var "result") (Int 0)) {    Int 0  }  else {    Var "result"  }}Function "incr" int ; args [Def int "x"] ; modifiers [Decorator "flatten"] ; returns Nothing {  BinaryOp + (Var "x") (Int 1)}

Не вдаваясь в конкретные обозначения, сразу видно две вещи определение декоратора это самостоятельная конструкция с типом Decorator, и функция incr на данном этапе существует в своем исходном виде, и хранит информацию о том что к ней применен модификатор Decorator "flatten". В теле декоратора же мы видим метку DecoratorTarget сюда будет вставленно тело функции incr.


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


Посмотрим на аннотированное, полностью обработанное и готовое к кодогенерации предсавление той же программы:


Function (int -> int) incr ["x"] {  BinaryOp i= (Def int "result") (    Block int {      BinaryOp i+ (Var int "x") (int 1)    }  )  If int (BinaryOp b< (Var int "result") (int 0)) {    int 0  }  else {    Var int "result"  }}

Здесь мы можем заметить несколько важных вещей:


  • Определение декоратора было удалено после его применения на этапе обработки AST, он больше не нужен.
  • Тело функции incr изменилось теперь оно такое же, каким было тело декоратора flatten, но на месте DecoratorTarget теперь Block {...} выражение вида "блок кода", совпадающее с исходным телом функции. Внутри этого выражения есть обращения к аргументам функции, и оно возвращает то же значение, которое вернула бы исходная функция это значение присваивается новой переменной int "result", с которой декоратор и работает дальше. BinaryOp i= это операция присваивания int-а, но в исходном коде тип result был указан как auto значит тип возвращаемого значения и переменных в теле функции, работающих с ним, был выведен правильно.

Таким образом, в процессе разработки этого компилятора, удалось вывести общий алгоритм применения декораторов для компилируемого, статически типизированного языка. Он не обладает всеми возможностями декораторов в Python, но приближается к ним настолько, насколько это возможно в таком языке, как Grit.


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


@auto lockF(mutex M) {    M.lock();    @target;};@lockF(мьютексКоторыйФункцияДолжнаЗахватить)int someFunction(...)

Это вполне сработало бы при текущем подходе самый простой вариант это при применении декоратора убрать аргумент mutex M, и в теле конкретного инстанса декоратора заменить обращения к этому аргументу обращениями к "мьютексКоторыйФункцияДолжнаЗахватить", который должен существовать в области видимости декорируемой функции исходя из объявления (кстати, такой способ создавать декораторы с аргументами выглядит гораздо привлекательнее того, как они реализованы в Python замыкание внутри замыкания внутри функции).


Кроме того, я экспереминтировал с меткой @args, дающей внутри декоратора доступ к аргументам целевой функции, и так же разварачивающейся в "обычный код" на этапе обработки синтаксического дерева. Например, @args.length количество аргументов, @args.1 ссылка на первый аргумент и так далее. Что-то из этого работает, что-то пока не совсем но принципиально сделать это возможно.


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


Код получившегося компилятора можно найти здесь, собирается он с помощью stack


P.S. Это был очень интересный и необычный для меня опыт надеюсь, что и вы смогли вынести для себя что-нибудь полезное из этого рассказа. Если нужна отдельная статья про написание компилятора на Haskell на основе LLVM пишите в комментарии.
На любые вопросы постараюсь ответить в комментариях, или в телеграмме @nu11_pointer_exception

Подробнее..

Перевод Дерево синтаксиса и альтернатива LINQ при взаимодействии с базами данных SQL

24.10.2020 12:06:33 | Автор: admin


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


История из жизни разработчика


Это был какой-то легаси проект, и меня попросили улучшить его расширенные возможности фильтрации.


Раньше у них было что-то вроде этого:


А хотели они что-то такое:



Процедура, которая выполняла расширенный поиск выглядела примерно так:


CREATE PROCEDURE dbo.SomeContextUserFind    (@ContextId int, @Filter nvarchar(max)) ASBEGINDECLARE @sql nvarchar(max) =     N'SELECT U.UserId, U.FirstName, U.LastName    FROM [User] U    INNER JOIN [SomeContext] [C]      ON ....    WHERE [C].ContextId = @p1 AND ' + @Filter;EXEC sp_executesql     @sql,    N'@p1 int',    @p1=@ContextIdEND

и код, генерировавший строку фильтра, выглядел примерно так:


string BuildFilter(IEnumerable<FilterItem> items){    var builder = new StringBuilder();    foreach (var item in items)    {        builder.Append(item.Field);        bool isLike = false;        switch (item.Operation)        {            case Operation.Equals:                builder.Append(" = ");                break;            case Operation.Like:                isLike = true;                builder.Append(" LIKE ");                break;            //...        }        builder.Append('\'');        if (isLike)            builder.Append('%');        builder.Append(Escape(item.Value));        if (isLike)            builder.Append('%');        builder.Append('\'');    }    return builder.ToString();}

Конечно, это не лучший код, который вы когда-либо видели, но, к сожалению, легаси проекты (или даже еще не легаси) часто полны таких вещей. В любом случае, что есть, то есть, и это нужно как-то улучшать.


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


В этот момент я вспомнил про абстрактное синтаксическое дерево, которое, очевидно, является лучшим выбором в данном случае, и далее я объясню, что это такое и как оно помогает.


Абстрактное синтаксическое дерево


Во-первых, давайте проанализируем строки фильтра, которые мы собираемся генерить, например вот эту:


[FirstName]='John' AND ([LastName]='Smith' OR [LastName]='Doe')

И здесь мы можем заметить некоторую структуру:



Эта структура представляет собой дерево, и это означает, что мы можем создать несколько простых классов, которые будут ее описывать:


abstract class Expr{ }class ExprColumn : Expr{    public string Name;}class ExprStr : Expr{    public string Value;}abstract class ExprBoolean : Expr{ }class ExprEqPredicate : ExprBoolean{    public ExprColumn Column;    public ExprStr Value;}class ExprAnd : ExprBoolean{    public ExprBoolean Left;    public ExprBoolean Right;}class ExprOr : ExprBoolean{    public ExprBoolean Left;    public ExprBoolean Right;}

Используя классы, мы можем создать объект, который будет представлять исходный фильтр:


var filterExpression = new ExprAnd{    Left = new ExprEqPredicate    {        Column = new ExprColumn        {            Name = "FirstName"        },        Value = new ExprStr        {            Value = "John"        }    },    Right = new ExprOr    {        Left = new ExprEqPredicate        {            Column = new ExprColumn            {                Name = "LastName"            },            Value = new ExprStr            {                Value = "Smith"            }        },        Right = new ExprEqPredicate        {            Column = new ExprColumn            {                Name = "LastName"            },            Value = new ExprStr            {                Value = "Doe"            }        }    }};

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


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


<eqPredicate> ::= <column> = <str><or> ::= <eqPredicate>|or|<and> OR <eqPredicate>|or|<and><and> ::= <eqPredicate>|(<or>)|<and> AND <eqPredicate>|(<or>)|<and>

Примечание: Абстрактное означает, что синтаксис не описывает все детали языка, например, группирующие скобки, лишние пробелы и т. д.


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


Генерация SQL кода


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


Первый способ использовать сопоставление с образцом (Pattern Matching), что довольно просто:


var filterExpression = ...;StringBuilder stringBuilder = new StringBuilder();Match(filterExpression);void Match(Expr expr){    switch (expr)    {        case ExprColumn col:            stringBuilder.Append('[' + Escape(col.Name, ']') + ']');            break;        case ExprStr str:            stringBuilder.Append('\'' + Escape(str.Value, '\'') + '\'');            break;        case ExprEqPredicate predicate:            Match(predicate.Column);            stringBuilder.Append('=');            Match(predicate.Value);            break;        case ExprOr or:            Match(or.Left);            stringBuilder.Append(" OR ");            Match(or.Right);            break;        case ExprAnd and:            ParenthesisForOr(and.Left);            stringBuilder.Append(" AND ");            ParenthesisForOr(and.Right);            break;    }}void ParenthesisForOr(ExprBoolean expr){    if (expr is ExprOr)    {        stringBuilder.Append('(');        Match(expr);        stringBuilder.Append(')');    }    else    {        Match(expr);    }}

В результате в билдере будет следующая строка:


[FirstName]='John' AND ([LastName]='Smith' OR [LastName]='Joe')

Вроде это то, что нам нужно!


"Посетитель"


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


interface IExprVisitor{    void VisitColumn(ExprColumn column);    void VisitStr(ExprStr str);    void VisitEqPredicate(ExprEqPredicate eqPredicate);    void VisitOr(ExprOr or);    void VisitAnd(ExprAnd and);}

И любой объект (нашей структуры) может сделать выбор:


abstract class Expr{    public abstract void Accept(IExprVisitor visitor);}class ExprColumn : Expr{    public string Name;    public override void Accept(IExprVisitor visitor)        => visitor.VisitColumn(this);}class ExprStr : Expr{    public string Value;    public override void Accept(IExprVisitor visitor)        => visitor.VisitStr(this);}...

Теперь мы можем выделить генерацию sql кода в отдельный класс:


class SqlBuilder : IExprVisitor{    private readonly StringBuilder _stringBuilder         = new StringBuilder();    public string GetResult()        => this._stringBuilder.ToString();    public void VisitColumn(ExprColumn column)    {        this._stringBuilder            .Append('[' + Escape(column.Name, ']') + ']');    }    public void VisitStr(ExprStr str)    {        this._stringBuilder            .Append('\'' + Escape(str.Value, '\'') + '\'');    }    public void VisitEqPredicate(ExprEqPredicate eqPredicate)    {        eqPredicate.Column.Accept(this);        this._stringBuilder.Append('=');        eqPredicate.Value.Accept(this);    }    public void VisitAnd(ExprAnd and)    {        and.Left.Accept(this);        this._stringBuilder.Append(" AND ");        and.Right.Accept(this);    }    public void VisitOr(ExprOr or)    {        ParenthesisForOr(or.Left);        this._stringBuilder.Append(" OR ");        ParenthesisForOr(or.Right);    }    void ParenthesisForOr(ExprBoolean expr)    {        if (expr is ExprOr)        {            this._stringBuilder.Append('(');            expr.Accept(this);            this._stringBuilder.Append(')');        }        else        {            expr.Accept(this);        }    }    private static string Escape(string str, char ch)    {        ...    }}

И использовать его следующим образом:


var filterExpression = BuildFilter();var sqlBuilder = new SqlBuilder();filterExpression.Accept(sqlBuilder);string sql = sqlBuilder.GetResult();

В результате мы получаем желаемую строку:


[FirstName]='John' AND ([LastName]='Smith' OR [LastName]='Joe')

Использование шаблона "Посетитель (Visitor)" имеет несколько преимуществ перед сопоставлением с шаблоном. Так, например, выбор конкретных типов всегда является исчерпывающим, поскольку добавление нового типа в структуру всегда приводит к изменению интерфейса IExprVisitor и, как следствие, необходимости расширения всех его реализаций (в противном случае будут ошибки компиляции).


Обход дерева и круглые скобки


В этом алгоритме есть несколько аспектов, на которые следует обратить внимание.


Во-первых, как это вообще работает?


Фактически, здесь мы выполняем обход синтаксического дерева в глубину, и код sql является следом этого обхода:



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


Во-вторых, что происходит со скобками?


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


Расширение синтаксиса


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


class ExprNotEqPredicate : ExprBoolean{    public ExprColumn Column;    public ExprStr Value;    public override void Accept(IExprVisitor visitor)        => visitor.VisitNotEqPredicate(this);}

А поскольку у нас есть новый тип, то компилятор сообщает нам, что нам нужно реализовать для него генерацию кода SQL:


public void VisitNotEqPredicate(ExprNotEqPredicate exprNotEqPredicate){    exprNotEqPredicate.Column.Accept(this);    this._stringBuilder.Append("!=");    exprNotEqPredicate.Value.Accept(this);}

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


Кстати, в документации по SQL Server есть все правила синтаксиса SQL, которые весьма полезны для подобного расширения:


Перегрузка операторов


Очевидно, что создание синтаксических деревьев путем вызова конструкторов классов это не самый удобный способ. Однако нам может помочь перегрузка оператора C#. Давайте сделаем следующее:


class ExprColumn : Expr{    ...    public static ExprBoolean operator==(ExprColumn column, string value)        => new ExprEqPredicate         {            Column = column, Value = new ExprStr {Value = value}        };    public static ExprBoolean operator !=(ExprColumn column, string value)        => new ExprNotEqPredicate        {            Column = column, Value = new ExprStr {Value = value}        };}abstract class ExprBoolean : Expr{    public static ExprBoolean operator &(ExprBoolean left, ExprBoolean right)        => new ExprAnd{Left = left, Right = right};    public static ExprBoolean operator |(ExprBoolean left, ExprBoolean right)        => new ExprOr { Left = left, Right = right };}

Теперь мы можем создать дерево синтаксиса очень простым и понятным способом:


ExprColumn firstName = new ExprColumn{Name = "FirstName"};ExprColumn lastName = new ExprColumn{Name = "LastName"};var expr = firstName == "John" & (lastName == "Smith" | lastName == "Doe");var builder = new SqlBuilder();expr.Accept(builder);var sql = builder.GetResult();

И результатом будет:


[FirstName]='John' AND ([LastName]='Smith' OR [LastName]='Doe')

Примечание: C# не допускает перегрузки операторов && и ||, и в этом есть смысл, поскольку эти операторы прекращают дальнейшую дальнейшее вычисление, если результат уже заранее известен (Например true || ....), но нам нужно вычислить все части для построения синтаксического дерева (результат будет выполняться базой данных SQL).


Что дальше


Кажется, мы решили проблему с фильтрацией, но как насчет сортировки и пейджинга? Также иногда необходимо добавить к оригинальному запросу какой-нибудь под-запрос (View или Derived Table) для фильтрации (или сортировки) по вычисляемым полям.


Не проблема! Давайте просто реализуем весь синтаксис SQL SELECT:


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


Альтернатива LINQ


То, что мы сделали с перегрузкой операторов, до некоторой степени напоминает выражения LINQ, и на самом деле тут есть некоторые сходства. Компилятор C# генерирует синтаксические деревья, а затем библиотеки, такие как Entity Framework или LINQ to SQL, преобразуют эти деревья в реальный код SQL.


Основная проблема этого подхода заключается в том, что компилятор генерирует синтаксическое дерево языка C#, а нам то нужен SQL! Отражение императивного C# в декларативный SQL это не простая задача, а результаты часто непредсказуемы.


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


При таком подходе, с одной стороны, мы получаем почти такую же гибкость, как если бы мы использовали хранимые процедуры. С другой стороны, у нас строгая типизация, intellisense и бизнес-логика не перемещается в базу данных. И не нужно каждый раз гуглить, как выполнить LEFT JOIN в LINQ)


Еще одно преимущество заключается в том, что операторы обновления данных (INSERT, UPDATE, DELETE и даже MERGE) также могут быть реализованы таким же образом, и нет необходимости загружать тысячи записей из базы данных для обновления только одного столбца.


Просто пример того, что можно сделать, используя в качестве основы реальный синтаксис SQL (взято отсюда):


await InsertInto(tCustomer, tCustomer.UserId)    .From(        Select(tUser.UserId)            .From(tUser)            .Where(!Exists(                SelectOne()                    .From(tSubCustomer)                    .Where(tSubCustomer.UserId == tUser.UserId))))    .Exec(database);

Заключение


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


Link to SqExpress проект, который частично включает код из этой статьи.

Подробнее..
Категории: C , Sql , Net , Abstract syntax tree , Query builder , Linq , Linq to sql

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru