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

Compiler

Простой интерпретатор Lisp на Umka

26.09.2020 22:13:23 | Автор: admin

Разработка моего статически типизированного скриптового языка Umka вошла в ту стадию, когда потребовалась проверка языковых возможностей на более сложных примерах, чем скрипты в пару десятков строк. Для этого я решил реализовать на своём языке интерпретатор Lisp. На это меня вдохновил педагогический эксперимент Роба Пайка, одного из создателей языка Go. Недавно Пайк опубликовал маленький интерпретатор Lisp на Go. Особенно впечатлило замечание Пайка, что описание интерпретатора заключено на одной странице 13 древнего руководства по Lisp 1.5. Учитывая синтаксическое родство Umka и Go, было трудно не поддаться соблазну построить такой интерпретатор на Umka, но не буквальным переносом кода Пайка, а полностью заново, от основ. Надеюсь, знатоки Lisp и функциональных языков простят мне наивное изумление от соприкосновения с прекрасным.

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

Определение минимального интерпретатора Lisp действительно занимает меньше страницы. Конечно, с некоторой натяжкой: в нём используются функции, определённые на нескольких предыдущих страницах. Кажется, создатель Lisp Джон Маккарти из азарта старался превзойти сам себя в лаконизме и в итоге опубликовал микроруководство по Lisp, содержащее определение языка вместе с исходником интерпретатора в общей сложности две журнальные страницы. Правда, добавил в заголовок: "Not the whole truth".

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

Базовые конструкции языка для тех, кто с ними не знаком
  • (car x) выделение головы списка x

  • (cdr x) выделение хвоста списка x

  • (cons x y) соединение списков x и y

  • (atom x) проверка x на атомарность

  • (eq x y) проверка атомарных элементов x и y на равенство

  • (cond (a x) (b y)) выбор значения x или y по условию a или b

  • (quote x) указание использовать x как есть, без вычисления

  • ((lambda (x) a) y) вызов безымянной функции с телом a, формальным параметром x и фактическим параметром y

  • ((label ff (lambda (x) a)) y) присвоение безымянной функции имени ff

  • t истина

  • nil ложь или пустое выражение

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

((label fac (lambda (n) (cond ((eq n 0) 1) ((quote t) (mul n (fac (sub n 1))))))) 6)

В микроруководстве Маккарти этими средствами выражен весь интерпретатор Lisp, за исключением лексического и синтаксического разбора. В руководстве Lisp 1.5 на той самой странице 13 приведён почти такой же интерпретатор, но в более человекочитаемом псевдокоде. Его я и взял за основу своего маленького проекта. Потребовалось лишь добавить разбор текста программы, некое подобие REPL и импровизированную арифметику. Роб Пайк, видимо, поступил так же, но отказался от конструкции label в пользу defn, которая позволила ему не определять функцию заново всякий раз, когда требуется её вызвать. В ядре Lisp такой возможности не предусмотрено.

Поскольку весь интерпретатор пронизан рекурсией и обработкой деревьев, он послужил отличным тестом многих возможностей языка Umka от формирования стековых кадров до сборки мусора. Думаю, Umka хорошо справился с испытанием. Исправлять пришлось лишь два-три мелких бага, связанных с экспортом имён и опережающим описанием типов. Весь код интерпретатора занял меньше 400 строк.

Желающим поиграть с моим интерпретатором и передать по эстафете вдохновение от Роба Пайка рекомендую взять папку с примерами из ветки master, а не из последнего выпуска.

Подробнее..

Как скомпилировать Python

12.02.2021 16:06:23 | Автор: admin

Привет, Хабр!

Я хочу рассказать об удивительном событии, о котором я узнал пару месяцев назад. Оказывается, одна популярная python-утилита уже более года распространяется в виде бинарных файлов, которые компилируются прямо из python. И речь не про банальную упаковку каким-нибудь PyInstaller-ом, а про честную Ahead-of-time компиляцию целого python-пакета. Если вы удивлены так же как и я, добро пожаловать под кат.

Объясню, почему я считаю это событие по-настоящему удивительным. Существует два вида компиляции: Ahead-of-time (AOT), когда весь код компилируется до запуска программы и Just in time compiler (JIT), когда непосредственно компиляция программы под требуемую архитектуру процессора осуществляется во время ее выполнения. Во втором случае первоначальный запуск программы осуществляется виртуальной машиной или интерпретатором.

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

  • Ahead-of-time compiler: C, C++, Rust, Kotlin, Nim, D, Go, Dart;

  • Just in time compiler: Lua, С#, Groovy, Dart.

В python из коробки нет JIT компилятора, но отдельные библиотеки, предоставляющие такую возможность, существуют давно

Смотря на эту таблицу, можно заметить определенную закономерность: статически типизированные языки находятся в обеих строках. Некоторые даже могут распространяться с двумя версиями компиляторов: Kotlin может исполняться как с JIT JavaVM, так и с AOT Kotlin/Native. То же самое можно сказать про Dart (версии 2). A вот динамически типизированные языки компилируются только JIT-ом, что впрочем вполне логично.

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

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

Итак, вернемся к утилите, о которой говорилось в начале статьи. Речь про mypy - наиболее популярный синтаксический анализатор python-кода.

С апреля 2019 года эта утилита распространяется в скомпилированном виде, о чем рассказывается в блоге проекта. А для компиляции используется еще одна утилита от тех же авторов mypyc. Погуглив немного, я нашел достаточно большую статью Путь к проверке типов 4 миллионов строк Python-кода про становление и развитие mypy (на Хабре доступен перевод: часть 1, часть 2, часть 3). Там немного рассказывается о целях создания mypyc: столкнувшись с недостаточной производительностью mypy при разборе крупных python-проектов в Dropbox, разработчики добавили кеширование результатов проверки кода, а затем возможность запуска утилиты как сервиса. Но исчерпав очевидные возможности оптимизации, столкнулись с выбором: переписать все на go или на cython. В результате проект пошел по третьему пути написание своего AOT python-компилятора.

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

Думаю тут многие решили, что разобрались в вопросе того, как скомпилировать динамически типизированный python-код. Python c версии 3.4 поддерживает аннотацию типов, а mypy как раз и используется для проверки корректности аннотаций. Получается, python как бы уже и не динамически типизированный язык, что позволяет применить AOT компиляцию. Но загвоздка в том, что mypyc может компилировать и не аннотированный код!

Функция bubble_sort

Для примера рассмотрим функцию сортировки пузырьком.Файл lib.py:

def bubble_sort(data):n = len(data)for i in range(n - 1):for j in range(n - i - 1):if data[j] > data[j + 1]:buff = data[j]data[j] = data[j + 1]data[j + 1] = buffreturn data

У типов нет аннотаций, но это не мешает mypyc ее скомпилировать. Чтобы запустить компиляцию, нужно установить mypyc. Он не распространяется отдельным пакетом, но если у вас установлен mypy, то и mypyc уже присутствует в системе! Запускаем mypyc, следующей командой:

> mypyc lib.py

После запуска в проекте будут созданы следующие директории:

  • .mypy_cache mypy кэш, mypyc неявно запускает mypy для разбора программы и получения AST;

  • build артефакты сборки;

  • lib.cpython-38-x86_64-linux-gnu.so собственно сборка под целевую платформу. Данный файл представляет из себя готовый CPython Extension.

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

Компиляция состоит из двух фаз:

  1. Компиляция python кода в код С;

  2. Компиляция С в бинарный .so файл, для этого mypyc сам запускает gcc (gcc и python-dev также должен быть установлены).

Файл lib.cpython-38-x86_64-linux-gnu.so имеет преимущество перед lib.py при импорте на соответствующей платформе, и исполняться теперь будет именно он.

Ну и давайте сравним производительность модуля до и после компиляции. Для этого создадим файл main.py с кодом запуска сортировки:

import libdata = lib.bubble_sort(list(range(5000, 0, -1)))assert data == list(range(1, 5001))

Получим примерно следующие результаты:

До

После

real 5.68

user 5.60

sys 0.01

real 2.78

user 2.73

sys 0.01

Ожидаемо скомпилированный код оказался быстрее (~ в 2 раза), что неплохо, так как для такого результата нам потребовалось запустить лишь одну команду. Хотя от скомпилированного кода привычно ожидаешь большего.

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

Функция sum(a, b)

Скомпилируем функцию суммы от двух переменных:

def sum(a, b):return a + b

Перед запуском компиляции я ожидал увидеть примерно следующий код на С:

int sum(int a, int b) {return a + b;}

Однако результат оказался cущественно иным (код немного упрощен):

PyObject *CPyDef_sum(PyObject *cpy_r_a, PyObject *cpy_r_b){return PyNumber_Add(cpy_r_a, cpy_r_b);}

Рассмотрим, что тут происходит. Во-первых, так как мы не знаем типы входных переменных, функция в качестве аргументов принимает указатели на объекты класса PyObject, по сути это внутренние CPython структуры. Далее компилятор должен сложить эти объекты, но как, если настоящие типы аргументов неизвестны во время компиляции: это могут быть целые числа, числа с плавающей точкой, списки и вообще не факт, что аргументы можно складывать, тогда нужно вернуть ошибку. И что же делает в этом случае mypyc?

Как оказалось, все очень просто: он просит CPython самостоятельно сложить эти аргументы. Функция PyNumber_Add это внутренняя функция СPython, которая доступна из расширения, ведь СPython отлично умеет складывать свои объекты.

Взаимодействие CPython c Extension можно изобразить следующим диалогом:

А посчитай-ка мне функцию sum для A, B;

Хорошо, но скажи сначала, сколько будет A + B;

Будет С;

Хорошо, тогда держи ответ - С.

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

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

Функция sum(a: int, b: int)

Итак, у нас получилось скомпилировать python, и мы разобрались с тем, как это работает, а также увидели определенную неэффективность полученного результата. Теперь попробуем разобраться в том, как можно это улучшить. Очевидно, что основная проблема заключается во множественном взаимодействии CPython - Extension. Но как это побороть?

Для повышения эффективности, нужно, чтобы расширение, получив управление, могло как можно дольше оставлять его у себя без обращения к CPython. Если бы у mypyc была информация о типах переменных, то он бы мог самостоятельно произвести сложение без возврата управления. Но вывести типы самостоятельно mypyc не может, он даже не контролирует код, из которого осуществляется вызов функции sum. Соответственно, ему нужно помочь, проставив аннотации вручную. Давайте посмотрим, как поменяется результирующая С-функция, если добавить аннотацию типов:

def sum(a: int, b: int):return a + b

Скомпилированный результат на C (немного очищенный):

PyObject *CPyDef_sum(CPyTagged cpy_r_a, CPyTagged cpy_r_b) {CPyTagged cpy_r_r0;PyObject *cpy_r_r1;cpy_r_r0 = CPyTagged_Add(cpy_r_a, cpy_r_b);cpy_r_r1 = CPyTagged_StealAsObject(cpy_r_r0);return cpy_r_r1;}

Главное, что можно заметить: функция существенно поменялась, а значит, компилятор реагирует на появление аннотации. Давайте разбираться, что изменилось.

Теперь CPyDef_sum получает на вход не указатели на PyObject, а структуры CPyTagged. Это все еще не int, но уже и не часть CPython, а часть библиотек mypyc, которую он добавляет в скомпилированный код расширения. Для ее инициализации в рантайме сначала проверяется тип, так что теперь функция sum работает только с int и обойти аннотацию не получится.

Далее происходит вызов CPyTaggetAdd вместо PyNumber_Add. Это уже внутренняя функция mypyc. Если заглянуть в код CPyTaggetAdd, то можно понять, что там происходит проверка диапазонов значений a и b, и если они укладываются в int, то происходит простое суммирование, а также проверка на переполнение:

if (likely(CPyTagged_CheckShort(left) && CPyTagged_CheckShort(right))) {CPyTagged sum = left + right;if (likely(!CPyTagged_IsAddOverflow(sum, left, right))) {return sum;}}

Таким образом, наш диалог CPython - Extension превращается из абсурдного в нормальный:

А посчитай-ка мне функцию sum для A, B;

Хорошо, тогда держи ответ С.

Функция bubble_sort(data: List[int])

Настало время вернуться к функции сортировки, чтобы провести замеры скорости. Изменим начальную функцию, добавив аннотацию для data:

def bubble_sort(data: List[int]):

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

Без компиляции

С компиляцией, без аннотации типов

С компиляцией и аннотацией типов

real 5.68

user 5.60

sys 0.01

real 2.78

user 2.73

sys 0.01

real 1.32

user 1.30

sys 0.01

Итак, мы получили еще двукратное ускорение относительно скомпилированного, не аннотированного кода, и четырехкратное относительно оригинального!

Пара слов о mypyc

Если вы уже бросились компилировать ваши пакеты, то стоит задержаться на пару минут, чтобы дочитать этот абзац до конца. Главным недостатком mypyc пока остается стабильность: он все еще в альфе, точнее, сейчас это вообще не самостоятельный проект, а часть mypy. Собственно он и создавался специально под задачу увеличения производительности mypy и для этой цели он уже более года как стабилен. Но как общее решение по компиляции любого python-кода, он еще сыроват, о чем авторы предупреждают на странице проекта.

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

  • Принудительная проверка типов в рантайме;

  • В компилируемом коде запрещается monkey patching;

  • Mypy хранит классы в С структурах для увеличения скорости доступа к атрибутам, но это приводит к проблемам совместимости.

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

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

Nuitka

Уже в процессе работы над статьей, я узнал про еще один проект с аналогичными целями. Механизм работы Nuitka сильно напоминает описанный выше. Разница заключается в том, что Nuitka компилирует Python модуль в С++ код, который также собирается в СPython Extension. Дополнительно существует возможность собрать весь проект в один исполняемый файл, тогда уже сам CPython подключается к проекту как динамическая библиотека libpython.

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

Завершение

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

P.S.

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

Подробнее..

Должен ли out-параметр быть проинициализирован до возврата из метода?

13.02.2021 00:15:04 | Автор: admin

0800_OutParamsCs_ru/image1.png


Наверняка каждый, кто писал на C#, сталкивался с использованием out-параметров. Кажется, что с ними всё предельно просто и понятно. Но так ли это на самом деле? Для затравки предлагаю начать с задачки для самопроверки.


Напомню, что out-параметры должны быть проинициализированы вызываемым методом до выхода из него.


А теперь посмотрите на следующий фрагмент кода и ответьте, компилируется ли он.


void CheckYourself(out MyStruct obj){  // Do nothing}

MyStruct какой-то значимый тип:


public struct MyStruct{ .... }

Если вы уверенно ответили 'да' или 'нет' приглашаю к дальнейшему прочтению, так как всё не так однозначно...


Предыстория


Начнём с небольшой предыстории. Как мы вообще погрузились в изучение out-параметров?


Всё началось с разработки очередного диагностического правила для PVS-Studio. Идея диагностики заключается в следующем один из параметров метода имеет тип CancellationToken. При этом данный параметр в теле метода не используется. Как следствие, программа может не реагировать (или реагировать несвоевременно) на какие-то действия отмены, например, отмены операции со стороны пользователя. В ходе просмотра срабатываний одной из первых версий диагностики нашли код примерно следующего вида:


void Foo(out CancellationToken ct, ....){  ....  if (flag)    ct = someValue;  else    ct = otherValue;  ....}

Очевидно, что это было false positive срабатыванием, поэтому я попросил коллегу добавить в набор модульных тестов ещё один, "с out параметрами". Он добавил тестов, в том числе тест такого вида:


void TestN(out CancellationToken ct){  Console.WriteLine("....");}

В первую очередь меня интересовали тесты с инициализаций параметров, но я повнимательнее присмотрелся к этому И тут меня осенило! А как этот код, собственно, компилируется? И компилируется ли вообще? Код компилировался. Тут я понял, что намечается статья. :)


Ради эксперимента решили поменять CancellationToken на какой-нибудь другой значимый тип. Например, TimeSpan:


void TestN(out TimeSpan timeSpan){  Console.WriteLine("....");}

Не компилируется. Что ж, ожидаемо. Но почему компилируется пример с CancellationToken?


Модификатор параметра out


Давайте вновь вспомним, что за модификатор параметра такой out. Вот основные тезисы, взятые с docs.microsoft.com (out parameter modifier):


  • The out keyword causes arguments to be passed by reference;
  • Variables passed as out arguments do not have to be initialized before being passed in a method call. However, the called method is required to assign a value before the method returns.

Особо прошу обратить внимание на выделенное предложение.


Внимание вопрос. В чём отличие следующих трёх методов, и почему последний компилируется, а первый и второй нет?


void Method1(out String obj) // compilation error{ }void Method2(out TimeSpan obj) // compilation error{ }void Method3(out CancellationToken obj) // no compilation error{ }

Пока закономерности не видно. Может быть есть какие-то исключения, которые описаны в доках? Для типа CancellationToken, например. Хотя это было бы немного странно что в нём такого особенного? В приведённой выше документации я никакой информации по этому поводу не нашёл. За дополнительными сведениями предлагают обращаться к спецификации языка: For more information, see the C# Language Specification. The language specification is the definitive source for C# syntax and usage.


Что ж, посмотрим спецификацию. Нас интересует раздел "Output parameters". Ничего нового всё то же самое: Every output parameter of a method must be definitely assigned before the method returns.


0800_OutParamsCs_ru/image2.png


Что ж, раз официальная документация и спецификация языка ответов нам не дали, придётся немного поковыряться в компиляторе. :)


Погружаемся в Roslyn


Исходники Roslyn можно загрузить со страницы проекта на GitHub. Для экспериментов я взял ветку master. Работать будем с решением Compilers.sln. В качестве стартового проекта для экспериментов используем csc.csproj. Можно даже его запустить на файле с нашими тестами, чтобы убедиться в воспроизводимости проблемы.


Для экспериментов возьмём следующий код:


struct MyStruct{  String _field;}void CheckYourself(out MyStruct obj){  // Do nothing}

Для проверки, что ошибка на месте, соберём и запустим компилятор на файле, содержащем этот код. И действительно ошибка на месте: error CS0177: The out parameter 'obj' must be assigned to before control leaves the current method


Кстати, это сообщение может стать неплохой отправной точкой для погружения в код. Сам код ошибки (CS0177) наверняка формируется динамически, а вот строка формата для сообщения, скорее всего, лежит где-нибудь в ресурсах. И это действительно так находим ресурс ERR_ParamUnassigned:


<data name="ERR_ParamUnassigned" xml:space="preserve">  <value>The out parameter '{0}' must be assigned to          before control leaves the current method</value></data>

По тому же имени находим код ошибки ERR_ParamUnassigned = 177, а также несколько мест использования в коде. Нас интересует место, где добавляется ошибка (метод DefiniteAssignmentPass.ReportUnassignedOutParameter):


protected virtual void ReportUnassignedOutParameter(  ParameterSymbol parameter,   SyntaxNode node,   Location location){  ....  bool reported = false;  if (parameter.IsThis)  {    ....  }  if (!reported)  {    Debug.Assert(!parameter.IsThis);    Diagnostics.Add(ErrorCode.ERR_ParamUnassigned, // <=                    location,                     parameter.Name);  }}

Что ж, очень похоже на интересующее нас место! Ставим точку останова и убеждаемся, что это нужное нам место. По результатам в Diagnostics будет записано как раз то сообщение, которое мы видели:


0800_OutParamsCs_ru/image3.png


Что ж, шикарно. А теперь поменяем MyStruct на CancellationToken, иии Мы всё также проходим эту ветку исполнения кода, в которой ошибка записывается в Diagnostics. То есть, она всё ещё на месте! Вот это поворот.


Следовательно, недостаточно отследить место, где ошибка компиляции добавляется, нужно мониторить дальше.


Немного покопавшись в коде, выходим на метод DefiniteAssignmentPass.Analyze, который инициировал запуск анализа, проверяющего, в том числе, что out-параметры инициализируются. В нём обнаруживаем, что соответствующий анализ запускается 2 раза:


// Run the strongest version of analysisDiagnosticBag strictDiagnostics = analyze(strictAnalysis: true);....// Also run the compat (weaker) version of analysis to see    if we get the same diagnostics.// If any are missing, the extra ones from the strong analysis    will be downgraded to a warning.DiagnosticBag compatDiagnostics = analyze(strictAnalysis: false);

А немного ниже находится интересное условие:


// If the compat diagnostics did not overflow and we have the same    number of diagnostics, we just report the stricter set.// It is OK if the strict analysis had an overflow here,   causing the sets to be incomparable: the reported diagnostics will// include the error reporting that fact.if (strictDiagnostics.Count == compatDiagnostics.Count){  diagnostics.AddRangeAndFree(strictDiagnostics);  compatDiagnostics.Free();  return;}

Ситуация понемногу проясняется. В результате работы и, strict, и compat анализа, когда мы пытаемся скомпилировать наш код с MyStruct, оказывается одинаковое количество диагностик, которые мы в результате и выдадим.


0800_OutParamsCs_ru/image4.png


Если же мы меняем в нашем примере MyStruct на CancellationToken, strictDiagnostics будет содержать 1 ошибку (как мы уже видели), а в compatDiagnostics не будет ничего.


0800_OutParamsCs_ru/image5.png


Как следствие, приведённое выше условие не выполняется и исполнение метода не прерывается. Куда же девается ошибка компиляции? А она понижается до предупреждения:


HashSet<Diagnostic> compatDiagnosticSet   = new HashSet<Diagnostic>(compatDiagnostics.AsEnumerable(),                             SameDiagnosticComparer.Instance);compatDiagnostics.Free();foreach (var diagnostic in strictDiagnostics.AsEnumerable()){  // If it is a warning (e.g. WRN_AsyncLacksAwaits),      or an error that would be reported by the compatible analysis,      just report it.  if (   diagnostic.Severity != DiagnosticSeverity.Error       || compatDiagnosticSet.Contains(diagnostic))  {    diagnostics.Add(diagnostic);    continue;  }  // Otherwise downgrade the error to a warning.  ErrorCode oldCode = (ErrorCode)diagnostic.Code;  ErrorCode newCode = oldCode switch  {#pragma warning disable format    ErrorCode.ERR_UnassignedThisAutoProperty       => ErrorCode.WRN_UnassignedThisAutoProperty,    ErrorCode.ERR_UnassignedThis                   => ErrorCode.WRN_UnassignedThis,    ErrorCode.ERR_ParamUnassigned                   // <=            => ErrorCode.WRN_ParamUnassigned,    ErrorCode.ERR_UseDefViolationProperty          => ErrorCode.WRN_UseDefViolationProperty,    ErrorCode.ERR_UseDefViolationField             => ErrorCode.WRN_UseDefViolationField,    ErrorCode.ERR_UseDefViolationThis              => ErrorCode.WRN_UseDefViolationThis,    ErrorCode.ERR_UseDefViolationOut               => ErrorCode.WRN_UseDefViolationOut,    ErrorCode.ERR_UseDefViolation                  => ErrorCode.WRN_UseDefViolation,    _ => oldCode, // rare but possible, e.g.                      ErrorCode.ERR_InsufficientStack occurring in                      strict mode only due to needing extra frames#pragma warning restore format  };  ....  var args      = diagnostic is DiagnosticWithInfo {          Info: { Arguments: var arguments }        }        ? arguments        : diagnostic.Arguments.ToArray();  diagnostics.Add(newCode, diagnostic.Location, args);}

Что здесь происходит в нашем случае при использовании CancellationToken? В цикле происходит обход strictDiagnostics (напоминаю, что там содержится ошибка про неинициализированный out-параметр). Then-ветвь оператора if не исполняется, так как diagnostic.Severity имеет значение DiagnosticSeverity.Error, а коллекция compatDiagnosticSet пуста. А далее происходит маппинг кода ошибки компиляции на новый код уже предупреждения, после чего это предупреждение формируется и записывается в результирующую коллекцию. Таким вот образом ошибка компиляции превратилась в предупреждение. :)


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


Выставляем запуск компилятора, указав дополнительный флаг: csc.exe %pathToFile% -w:5


И видим ожидаемое предупреждение:


0800_OutParamsCs_ru/image6.png


Теперь мы разобрались, куда пропадает ошибка компиляции, она заменяется на низкоприоритетное предупреждение. Однако у нас до сих пор нет ответа на вопрос, в чём же особенность CancellationToken и его отличие от MyStruct? Почему при анализе метода с out-параметром MyStruct compat анализ находит ошибку, а когда тип параметра CancellationToken ошибка не обнаруживается?


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


0800_OutParamsCs_ru/image7.png


Надеюсь, вы воспользовались советом и подготовились. Мы продолжаем. :)


Помните метод ReportUnassignedParameter, в котором происходила запись ошибки компиляции? Поднимаемся немного выше и смотрим вызывающий метод:


protected override void LeaveParameter(ParameterSymbol parameter,                                        SyntaxNode syntax,                                        Location location){  if (parameter.RefKind != RefKind.None)  {    var slot = VariableSlot(parameter);    if (slot > 0 && !this.State.IsAssigned(slot))    {      ReportUnassignedOutParameter(parameter, syntax, location);    }    NoteRead(parameter);  }}

Разница при выполнении этих методов из strict и compat анализа в том, что в первом случае переменная slot имеет значение 1, а во втором -1. Следовательно, во втором случае не выполняется then-ветвь оператора if. Теперь нужно выяснить, почему во втором случае slot имеет значение -1.


Смотрим метод LocalDataFlowPass.VariableSlot:


protected int VariableSlot(Symbol symbol, int containingSlot = 0){  containingSlot = DescendThroughTupleRestFields(                     ref symbol,                      containingSlot,                                                        forceContainingSlotsToExist: false);  int slot;  return     (_variableSlot.TryGetValue(new VariableIdentifier(symbol,                                                       containingSlot),                                out slot))     ? slot     : -1;}

В нашем случае _variableSlot не содержит слота под out-параметр, соответственно, _variableSlot.TryGetValue(....) возвращает значение false, исполнение кода идёт по alternative-ветви оператора ?:, и из метода возвращается значение -1. Теперь нужно понять, почему _variableSlot не содержит out-параметра.


0800_OutParamsCs_ru/image8.png


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


protected virtual int GetOrCreateSlot(  Symbol symbol,   int containingSlot = 0,   bool forceSlotEvenIfEmpty = false,   bool createIfMissing = true){  Debug.Assert(containingSlot >= 0);  Debug.Assert(symbol != null);  if (symbol.Kind == SymbolKind.RangeVariable) return -1;  containingSlot     = DescendThroughTupleRestFields(        ref symbol,         containingSlot,        forceContainingSlotsToExist: true);  if (containingSlot < 0)  {    // Error case. Diagnostics should already have been produced.    return -1;  }  VariableIdentifier identifier     = new VariableIdentifier(symbol, containingSlot);  int slot;  // Since analysis may proceed in multiple passes,      it is possible the slot is already assigned.  if (!_variableSlot.TryGetValue(identifier, out slot))  {    if (!createIfMissing)    {      return -1;    }    var variableType = symbol.GetTypeOrReturnType().Type;    if (!forceSlotEvenIfEmpty && IsEmptyStructType(variableType))    {      return -1;    }    if (   _maxSlotDepth > 0         && GetSlotDepth(containingSlot) >= _maxSlotDepth)    {      return -1;    }    slot = nextVariableSlot++;    _variableSlot.Add(identifier, slot);    if (slot >= variableBySlot.Length)    {      Array.Resize(ref this.variableBySlot, slot * 2);    }    variableBySlot[slot] = identifier;  }  if (IsConditionalState)  {    Normalize(ref this.StateWhenTrue);    Normalize(ref this.StateWhenFalse);  }  else  {    Normalize(ref this.State);  }  return slot;}

Из метода видно, что есть ряд условий, когда метод вернёт значение -1, а слот не будет добавлен в _variableSlot. Если же слота под переменную ещё нет, и все проверки проходят успешно, то происходит запись в _variableSlot: _variableSlot.Add(identifier, slot). Отлаживаем код и видим, что при выполнении strict анализа все проверки успешно проходят, а вот при compat анализе мы заканчиваем выполнение метода в следующем операторе if:


var variableType = symbol.GetTypeOrReturnType().Type;if (!forceSlotEvenIfEmpty && IsEmptyStructType(variableType)){  return -1;}

Значение переменной forceSlotEvenIfEmpty в обоих случаях одинаковое (false), а разница в том, какое значение возвращает метод IsEmptyStructType: для strict анализа false, для compat анализа true.


0800_OutParamsCs_ru/image9.png


Здесь сразу же возникают новые вопросы и желание поэкспериментировать. То есть получается, что, если тип out-параметра "пустая структура" (позже мы поймём, что это значит), компилятор считает такой код допустимым и не генерирует ошибку? Убираем в нашем примере из MyStruct поле и компилируем.


struct MyStruct{  }void CheckYourself(out MyStruct obj){  // Do nothing}

И этот код успешно компилируется! Интересно Упоминаний таких особенностей в документации и спецификации я что-то не помню. :)


Но тогда возникает другой вопрос: а как же работает код в случае, когда тип out-параметра CancellationToken? Ведь это явно не "пустая структура" если посмотреть код на referencesource.microsoft.com (ссылка на CancellationToken), становится видно, что этот тип содержит и методы, и свойства, и поля Непонятно, копаем дальше.


Мы остановились на методе LocalDataFlowPass.IsEmptyStructType:


protected virtual bool IsEmptyStructType(TypeSymbol type){  return _emptyStructTypeCache.IsEmptyStructType(type);}

Идём глубже (EmptyStructTypeCache.IsEmptyStructType):


public virtual bool IsEmptyStructType(TypeSymbol type){  return IsEmptyStructType(type, ConsList<NamedTypeSymbol>.Empty);}

И ещё глубже:


private bool IsEmptyStructType(  TypeSymbol type,   ConsList<NamedTypeSymbol> typesWithMembersOfThisType){  var nts = type as NamedTypeSymbol;  if ((object)nts == null || !IsTrackableStructType(nts))  {    return false;  }  // Consult the cache.  bool result;  if (Cache.TryGetValue(nts, out result))  {    return result;  }  result = CheckStruct(typesWithMembersOfThisType, nts);  Debug.Assert(!Cache.ContainsKey(nts) || Cache[nts] == result);  Cache[nts] = result;  return result;}

Выполнение кода идёт через вызов метода EmptyStructTypeCache.CheckStruct:


private bool CheckStruct(  ConsList<NamedTypeSymbol> typesWithMembersOfThisType,   NamedTypeSymbol nts){  ....   if (!typesWithMembersOfThisType.ContainsReference(nts))  {    ....    typesWithMembersOfThisType       = new ConsList<NamedTypeSymbol>(nts,                                       typesWithMembersOfThisType);    return CheckStructInstanceFields(typesWithMembersOfThisType, nts);  }  return true;}

Здесь исполнение заходит в then-ветвь оператора if, т.к. коллекция typesWithMembersOfThisType пустая (см. метод EmptyStructTypeCache.IsEmptyStructType, где она начинает передаваться в качестве аргумента).


Какая-то картина уже начинает вырисовываться теперь становится понятно, что такое "пустая структура". Судя по названиям методов, это такая структура, которая не содержит экземплярных полей. Но я напоминаю, что в CancellationToken экземплярные поля есть. Значит, идём ещё глубже, в метод EmptyStructTypeCache.CheckStructInstanceFields.


private bool CheckStructInstanceFields(  ConsList<NamedTypeSymbol> typesWithMembersOfThisType,   NamedTypeSymbol type){  ....  foreach (var member in type.OriginalDefinition                             .GetMembersUnordered())  {    if (member.IsStatic)    {      continue;    }    var field = GetActualField(member, type);    if ((object)field != null)    {      var actualFieldType = field.Type;      if (!IsEmptyStructType(actualFieldType,                              typesWithMembersOfThisType))      {        return false;      }    }  }  return true;}

В методе обходятся экземплярные члены, для каждого из которых получается 'actualField'. Дальше, если удалось получить это значение (field не null) опять выполняется проверка: а является ли тип этого поля "пустой структурой"? Соответственно, если нашли хотя бы одну "не пустую структуру", изначальный тип также считаем "не пустой структурой". Если все экземплярные поля "пустые структуры", то изначальный тип также считается "пустой структурой".


Придётся опуститься ещё немного глубже. Не беспокойтесь, скоро наше погружение закончится, и мы расставим точки над 'i'. :)


Смотрим метод EmptyStructTypeCache.GetActualField:


private FieldSymbol GetActualField(Symbol member, NamedTypeSymbol type){  switch (member.Kind)  {    case SymbolKind.Field:      var field = (FieldSymbol)member;      ....      if (field.IsVirtualTupleField)      {        return null;      }      return (field.IsFixedSizeBuffer ||               ShouldIgnoreStructField(field, field.Type))             ? null             : field.AsMember(type);      case SymbolKind.Event:        var eventSymbol = (EventSymbol)member;        return (!eventSymbol.HasAssociatedField ||                ShouldIgnoreStructField(eventSymbol, eventSymbol.Type))              ? null              : eventSymbol.AssociatedField.AsMember(type);  }  return null;}

Соответственно, для типа CancellationToken нас интересует case-ветвь SymbolKind.Field. В неё мы можем попасть только при анализе члена m_source этого типа (т.к. тип CancellationToken содержит только одно экземплярное поле m_source).


Рассмотрим, как происходят вычисления в этой case-ветви в нашем случае.


field.IsVirtualTupleField false. Переходим к условному оператору и разберём условное выражение field.IsFixedSizeBuffer || ShouldIgnoreStructField(field, field.Type). field.IsFixedSizeBuffer не наш случай. Значение, ожидаемо, false. А вот значение, возвращаемое вызовом метода ShouldIgnoreStructField(field, field.Type), различается для strict и compat анализа (напоминаю, мы анализируем одно и то же поле одного и того же типа).


Смотрим тело метода EmptyStructTypeCache.ShouldIgnoreStructField:


private bool ShouldIgnoreStructField(Symbol member,                                      TypeSymbol memberType){  // when we're trying to be compatible with the native compiler, we      ignore imported fields (an added module is imported)     of reference type (but not type parameters,      looking through arrays)     that are inaccessible to our assembly.  return _dev12CompilerCompatibility &&                                      ((object)member.ContainingAssembly != _sourceAssembly ||             member.ContainingModule.Ordinal != 0) &&                               IsIgnorableType(memberType) &&                                          !IsAccessibleInAssembly(member, _sourceAssembly);          }

Посмотрим, что отличается для strict и compat анализа. Хотя, возможно, вы уже догадались самостоятельно. :)


Strict анализ: _dev12CompilerCompatibility false, следовательно, результат всего выражения false. Compat анализ: значения всех подвыражений true, результат всего выражения true.


А теперь сворачиваем цепочку, поднимаясь с самого конца. :)


При compat анализе мы считаем, что должны игнорировать единственное экземплярное поле типа CancellationSource m_source. Таким образом, мы считаем, что CancellationToken "пустая структура", следовательно для неё не создаётся слот, и не происходит записи в кэш "пустых структур". Так как слот отсутствует, мы не обрабатываем out-параметр и не записываем ошибку компиляции при выполнении compat анализа. Как результат, strict и compat анализ дают разные результаты, из-за чего происходит понижение ошибки компиляции до низкоприоритетного предупреждения.


То есть это не какая-то особая обработка типа CancellationToken есть целый ряд типов, для которых отсутствие инициализации out-параметра не будет приводить к ошибкам компиляции.


Давайте попробуем посмотреть на практике, для каких типов компиляция будет успешно проходить. Как обычно, берём наш типичный метод:


void CheckYourself(out MyType obj){  // Do nothing}

И пробуем подставлять вместо MyType различные типы. Мы уже разобрали, что этот код успешно компилируется для CancellationToken и для пустой структуры. Что ещё?


struct MyStruct{ }struct MyStruct2{  private MyStruct _field;}

Если вместо MyType используем MyStruct2, код также успешно компилируется.


public struct MyExternalStruct{  private String _field;}

При использовании этого типа код будет успешно компилироваться, если MyExternalStruct объявлен во внешней сборке. Если в одной сборке с методом CheckYourself не скомпилируется.


При использовании такого типа из внешней сборки код уже не скомпилируется (поменяли уровень доступа поля _field с private на public):


public struct MyExternalStruct{  public String _field;}

При таком изменении типа код тоже не будет компилироваться (поменяли тип поля со String на int):


public struct MyExternalStruct{  private int _field;}

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


Подытожим


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


Что же по поводу типов, для которых можно не инициализировать out-параметры? Например, не обязательна инициализация параметра, если тип структура, в которой нет полей. Или если все поля структуры без полей. Или вот случай с CancellationToken: с ним компиляция успешно проходит, так как этот тип находится во внешней библиотеке, единственное поле m_source ссылочного типа, а само поле недоступно из внешнего кода. В принципе, несложно придумать и составить ещё своих подобных типов, при использовании которых вы сможете не инициализировать out-параметры и успешно компилировать ваш код.


Возвращаясь к вопросу из начала статьи:


void CheckYourself(out MyStruct obj){  // Do nothing}public struct MyStruct{ .... }

Компилируется ли этот код? Как вы уже поняли, ни 'Да', ни 'Нет' не являются правильным ответом. В зависимости от того, что такое MyStruct (какие есть поля, где объявлен тип и т. п.), этот код может либо компилироваться, либо не компилироваться.


Заключение


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


Кстати, приглашаю подписаться на мой аккаунт в Twitter, где я также выкладываю статьи и прочие интересные находки. Так точно ничего интересного не пропустите. :)


Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Sergey Vasiliev. Should We Initialize an Out Parameter Before a Method Returns?.

Подробнее..

Категории

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

  • Имя: Макс
    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