что такое pypy в питоне

Компиляция Python

что такое pypy в питоне. Смотреть фото что такое pypy в питоне. Смотреть картинку что такое pypy в питоне. Картинка про что такое pypy в питоне. Фото что такое pypy в питоне

Предположим, вы разработали приложение или библиотеку на Python и уже готовитесь передать его / её заказчику. И в этот момент появляются самые неудобные вопросы.

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

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

И вот тут настало время скомпилировать Python-код. Меня зовут Руслан, я старший разработчик компании «Цифровое проектирование». Сегодня я расскажу, как выбрать тот самый компилятор из множества доступных.

AOT/JIT

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

AOT-компиляция (ahead-of-time) – компиляция перед исполнением программы. Т.е. программа компилируется один раз, в результате компиляции получается исполняемый файл.

Бенчмарк

Так как одной из целей является ускорение, необходимо оценить, насколько быстро работает скомпилированный код. В качестве бенчмарка будем использовать pyperfomance. К сожалению, pyperfomance не подошел для Cython и Pythran, потому что не позволяет визуализировать все возможности языка. Ускорения для Cython без модификации кода получить не удалось, а Pythran не умеет в пользовательские классы. Для них воспользуемся вычислением числа пи:

Эксперименты будем проводить на процессоре Intel Core i7 10510U. На CPython 3.9.7 время вычисления числа пи до 100.000.000 знака заняло 5.82 секунды.

AOT-компиляция Python

PyInstaller

PyInstaller упаковывает приложения Python в автономные исполняемые файлы в Windows, GNU / Linux, Mac OS X, FreeBSD, Solaris и AIX.

Устанавливается через pip:

pip install pyinstaller

После установки для создания исполняемого файла достаточно выполнить команду:

В результате будет создано:

build/ – директория с метаданными для сборки исполняемого файла;

dist/ – директория, содержащая все зависимости и исполняемый файл.

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

—name – изменение имени исполняемого файла (по умолчанию, такое же, как у сценария);

—onefile – создание только исполняемого файла (по умолчанию, папка с зависимостями и исполняемый файл);

—hidden-import – перечисление импортов, которые PyInstaller не смог обнаружить автоматически;

—add-data – добавление в сборку файлов данных;

—add-binary – добавление в сборку бинарных файлов;

—exclude-module – исключение модулей из исполняемого файла;

У PyInstaller есть ограничения. Он работает с Python 3.5–3.9. Поддерживает создание исполняемых файлов для разных операционных систем, но не умеет выполнять кросскомпиляцию, т. е. необходимо генерировать исполняемый файл для каждой ОС отдельно. Более того, исполняемый файл зависит от пользовательского glibc, т. е. необходимо генерировать исполняемый файл для самой старой версии каждой ОС.

PyInstaller знает о многих Python-пакетах и умеет их учитывать при сборке исполняемого файла. Но не о всех. Например, фреймворк uvicorn практически весь нужно явно импортировать в файл, к которому будет применена команда pyinstaller. Полный список поддерживаемых из коробки пакетов можно посмотреть здесь.

Cython

Ставится Cython через pip:

pip install Cython

Рассмотрим его работу на примере с вычислением числа пи:

Немного модифицируем нашу функцию:

Cython → C:

Компилируем С-шный код:

И замеряем время на бенчмарке: 3,66 секунды.

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

Создадим файл build.py:

Запустим: python build.py build_ext –-inplace

В результате будет сгенерирован .so/.dll файл.

Nuitka

Ставится через pip:

pip install nuitka

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

Для компиляции модуля:

Для компиляции пакета:

Pythran

Pythran – статический компилятор Python, позиционирующий себя как ориентированный на научные вычисления и использующий преимущества многоядерных процессоров и блоков инструкций SIMD. Он транслирует Python-код, аннотированный описаниями интерфейса, в C++. До версии 0.9.5 (включительно) Pythran поддерживал Python 3 и Python 2.7. Последние версии поддерживают только Python 3.

pip install pythran

Генерируем бинарный файл .so:

Pythran по умолчанию не умеет в пользовательские классы, поэтому при попытке их компиляции будет выброшена ошибка:

Top level statements can only be assignments, strings,functions, comments, or imports

Добавим комментарий аннотации функции:

Скомпилируем и бенчмарк выдает 0,00007 секунды.

cx-Freeze

Ставится с помощью pip:

pip install cx_Freeze

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

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

Также возможно использование сценария сборки, например, так:

Сборка исполняемого файла:

python setup.py build

JIT-компиляция Python

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

PyPy

компилятор байт-кода, отвечающий за создание объектов кода Python из исходного кода пользовательского приложения;

оценщик байт-кода, ответственный за интерпретацию объектов кода Python;

стандартное объектное пространство, отвечающее за создание и управление объектами Python, видимыми приложением.

PyPy поддерживает сотни библиотек Python, включая NumPy.

Основные особенности (сравнение с CPython):

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

Использование памяти. Программы Python, требующие много памяти (несколько сотен Мб или более), могут занимать меньше места, чем в CPython. Однако это не всегда так, поскольку зависит от множества деталей. Также базовый уровень потребления оперативной памяти выше, чем у CPython.

Скачать PyPy можно с здесь. После скачивания PyPy готов к запуску после распаковки архива. Если необходимо сделать PyPy доступным для всей системы, достаточно поместить символическую ссылку на исполняемый файл pypy в /usr/local/bin. Также можно поставить с помощью pyenv.

PyPy работает на Mac, Linux (не все дистрибутивы) или Windows.

Для запуска кода с помощью PyPy вместо команды python3 (как c помощью CPython) достаточно воспользоваться командой pypy3:

Pyston

В Pyston поддерживаются все возможности CPython, в том числе C API для разработки расширений на языке Си. Среди основных отличий Pyston от CPython помимо общих оптимизаций выделяется использование DynASM JIT и inline-кэширования.

Заключение

Итак, мы рассмотрели 5 фреймворков AOT-компиляции Python. Для любителей аналитики, ниже приведена таблица со сравнительным анализом.

PyInstaller

Cython

Nuitka

Pythran

cx-Freeze

Генерация автономных исполняемых файлов

Компиляция python-модуля в исполняемый файл, совместимый с CPython

Источник

Ускоряем Python — 4 быстрых компилирующих транслятора для Python

что такое pypy в питоне. Смотреть фото что такое pypy в питоне. Смотреть картинку что такое pypy в питоне. Картинка про что такое pypy в питоне. Фото что такое pypy в питоне

Эта статья является переводом и адаптацией оригинальной англоязычной статьи авторства Дэвида Болтона.

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

Однако, по мере того, как язык набирает популярность, разработчики хотят создавать и быстро работающие программы на Python, поэтому за последние годы появилось несколько компиляторов Python, включая IronPython и Jython.

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

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

Я хотел сравнить несколько компиляторов Python на одной платформе, особенно те, которые поддерживают Python 3.x. В итоге я выбрал четыре, все они работают на Ubuntu Linux: Nuitka, PyPy, Cython и cx_Freeze.

Сравнение трансляторов Python

В качестве бенчмарка будет использоваться пакет PyStone, адаптация C-программы, которую сделал Гвидо ван Россум, создатель Python (сама C-программа была переводом Ада-программы). Я нашел версию бенчмарка от Кристофера Арндта, которая способна тестировать Python 3. Чтобы получить представление о базовой производительности, оценим производительность CPython (то есть стандартного Python) с PyStone.

Все бенчмарки при переводе были выполнены заново на CPU Intel(R) Core(TM) i5-7440HQ CPU @ 2.80GHz

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

Nuitka

В Ubuntu 18.04 установить Nuitka возможно с помощью APT:

Результат выполнения бенчмарка для компилятора Nuitka:

Как можно видеть, Nuitka позволила получить увеличение производительности на 50%, по сравнению со стандартной реализацией Python 3.

PyPy

Гидо ван Россум однажды сказал: «Если вы хотите, чтобы ваш код работал быстрее, вам, вероятно, следует просто использовать PyPy». Я загрузил переносимые двоичные файлы в папку, а в папке bin скопировал pystone.py. Затем я запустил это так: Мы просто установили PyPy 3 с помощью Ubuntu Snap:

Для данного теста результаты выполнения до «разгона» показывают более 10 кратное, а после «разгона» более чем 26 кратное ускорение производительности.

Создание исполняемого файла требует больше работы. Вы должны написать свой Python в подмножестве RPython.

Cython

Cython — это не просто компилятор для Python; это языковое надмножество языка Python, который поддерживает взаимодействие с C/C ++. CPython написан на C, поэтому это язык, который обычно хорошо сочетается с Python:

Сборка программы с помощью Cython немного сложна. Это не похоже на Nuitka, которая просто работает из коробки:

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

Cx_Freeze

Cx_freeze — это набор скриптов и модулей для «замораживания» скриптов Python в исполняемые файлы. Установить cx_Freeze можно с помощью PIP3:

Как можно видеть, производительность даже ниже, чем у стандартного интерпретатора CPython. Данное решение разумно использовать только для упаковки всего Python-окружения в независимый исполняемый пакет. Стоит отметить, что для этой цели можно использовать и Pyinstaller.

Заключение

Я в восторге от производительности PyPy. Компиляция была очень быстрой, и приложение работало в десятки раз быстрее аналогов и оригинального кода CPython. Если вы хотите распространять бинарный файл, выбирайте Nuitka — решение дает как ускорение, так и позволяет выполнить упаковку кода.

От переводчика: в статье рассмотрено использование решений PyPy, Nuitka, Cython и Cx_Freeze для очень простого кода, который без проблем собирается данными трансляторами. Код, используемый в реальных приложениях, может быть затруднительно скомпилировать или его производительность может стать еще хуже, чем у стандартного Python. Необходимо производить бенчмарки на том коде, который вы собираетесь распространять, поскольку синтетические бенчмарки, как в этой статье не дают представления о реальном варианте использования, который будет в вашем случае.

Источник

Немного внутренностей словарей в CPython (и PyPy)

Внутреннее устройство словарей в Python не ограничивается одними лишь бакетами и закрытым хешированием. Это удивительный мир разделяемых ключей, кеширования хешей, DKIX_DUMMY и быстрого сравнения, которое можно сделать ещё быстрее (ценой бага с примерной вероятностью в 2^-64).

Если вы не знаете количество элементов в только что созданном словаре, сколько памяти расходуется на каждый элемент, почему теперь (CPython 3.6 и далее) словарь реализован двумя массивами и как это связано с сохранением порядка вставки, или просто не смотрели презентацию Raymond Hettinger «Modern Python Dictionaries A confluence of a dozen great ideas». Тогда добро пожаловать.

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

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

Базовая реализация словарей (через Hashmap)

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

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

что такое pypy в питоне. Смотреть фото что такое pypy в питоне. Смотреть картинку что такое pypy в питоне. Картинка про что такое pypy в питоне. Фото что такое pypy в питоне

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

Но что, если мы хотим организовать поиск не по целочисленному индексу, а по переменной другого типа, например, находить пользователей по адресу их почты?

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

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

что такое pypy в питоне. Смотреть фото что такое pypy в питоне. Смотреть картинку что такое pypy в питоне. Картинка про что такое pypy в питоне. Фото что такое pypy в питоне

В итоге линейный поиск осуществляется не по всему большому массиву, а по его части.

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

что такое pypy в питоне. Смотреть фото что такое pypy в питоне. Смотреть картинку что такое pypy в питоне. Картинка про что такое pypy в питоне. Фото что такое pypy в питоне

Данный тип хеширования называется закрытым. Если в словаре остаётся мало свободных ячеек, то такой поиск грозит выродиться в линейный, соответственно мы потеряем весь выигрыш, ради которого и создавался словарь, во избежание подобного интерпретатор сохраняет массив заполненным на 1/2 — 2/3. Если свободных ячеек не хватает, то создаётся новый массив в два раза больше предыдущего и элементы из старого переносятся в новый по одному.

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

Особенности реализации в Python

Каждый элемент словаря должен содержать ссылку на целевой объект и ключ. Ключ необходимо хранить для обработки коллизий, объект — по очевидным причинам. Так как и ключ, и объект могут быть любого типа и размера мы не можем хранить в структуре непосредственно их, они лежат в динамической памяти, а в структуре элемента списка хранятся ссылки на них. То есть размер одного элемента должен быть равен минимум размеру двух указателей (16 байт на 64-битных системах). Однако интерпретатор хранит ещё и хеш, сделано это для того, чтобы не перевычислять его при каждом увеличении размера словаря. Вместо того, чтобы вычислять хеш от каждого ключа по-новому и брать остаток от деления на количество бакетов, интерпретатор просто читает уже сохранённое значение. Но, что если объект ключа изменили? В таком случае хеш должен пересчитаться и сохранённое значение будет неверным? Такая ситуация невозможна, так как изменяемые типы не могут быть ключами словаря.

Структура элемента словаря определена следующим образом:

Минимальный размер словаря объявлен константой PyDict_MINSIZE, которая равна 8. Разработчики решили, что это оптимальный размер, для того, чтобы избежать лишнего расходования памяти на хранение пустых значений и времени на динамическое расширение массива. Таким образом при создании словаря (до версии 3.6) вам требовалось минимум 8 элементов в словаре * 24 байт в структуре = 192 байта (это без учёта остальных полей: расходы на саму переменную типа словарь, счётчик числа элементов и т.д.)

Словари используются и для реализации полей пользовательских классов. Python позволяет динамически изменять количество атрибутов, эта динамика не требует дополнительных расходов, так как добавление/удаление атрибута по сути эквивалентно соответствующей операции над словарём. Однако данным функционалом пользуется меньшинство программ, большинство ограничивается полями, объявленными в __init__. Но каждый объект должен хранить свой словарь, со своими ключами и хешами, несмотря на то, что они совпадают с другими объектами. Логичным улучшением тут выглядит хранение общих ключей только в одном месте, именно это и было реализовано в PEP 412 — Key-Sharing Dictionary. Возможность динамического изменения словаря при этом не исчезла: если меняется порядок или количество ключей словарь преобразуется из разделяющего ключи в обычный.

Во избежание коллизий максимальная «загрузка» словаря составляет 2/3 от текущего размера массива.

Таким образом первое расширение произойдёт при добавлении 6-го элемента.

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

Вместо хранения разряженного массива, например:

Начиная с версии 3.6 словари организованны следующим образом:

Т.е. хранятся только те записи, которые действительно необходимы, они вынесены из хеш-таблицы в отдельный массив, а в хеш-таблице хранятся только индексы соответствующих записей. Если изначально на массив уходило 192 байт, то сейчас только 80 (3 * 24-байт для каждой записи + 8 байт на indices). Достигнуто сжатие в 58%.[2]

Размер элемента в indices тоже меняется динамически, изначально он равен одному байту, то есть весь массив может быть помещён в один регистр, когда индекс начинает не влезать в 8 бит, то элементы расширяются до 16, потом до 32 бит. Есть специальные константы DKIX_EMPTY и DKIX_DUMMY, для пустого и удалённого элемента, соответственно расширение индексов до 16 байт происходит, когда элементов в словаре становится более 127.

Новые объекты добавляются в entries, то есть при расширении словаря нет необходимости их перемещать, необходимо лишь увеличить размер indices и перезаполнить его индексами.

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

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

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

Вот как это выглядит в коде CPython:

Но итерирование устроено сложнее, чем можно было изначально подумать, существуют дополнительные механизмы проверки, что словарь не был изменён во время итерирования, один из них — версия словаря в виде 64-битного числа, который хранит каждый словарь.

Напоследок рассмотрим механизм разрешения коллизий. Дело в том, что в python значения хеш-функции легко предсказуемы:

А так как при создании словаря от этих хешей берётся остаток от деления, то по сути определяют, в какой бакет пойдёт запись, лишь несколько последних бит ключа (если он целочисленный). Можно представить себе ситуацию, когда у нас много объектов «хотят» попасть в соседние бакеты, в таком случае при поиске придётся просмотреть множество объектов, которые находятся не на своих местах. Для уменьшения числа коллизий и увеличения числа бит, определяющих, в какой бакет пойдёт запись был реализован следующий механизм:

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

При извлечении элемента из словаря осуществляется такой же поиск: вычисляется индекс слота, в котором должен находиться элемент, если слот пуст, то выбрасывается исключение «значение не найдено». Если же значение в данном слоте есть, необходимо проверить, что его ключ соответствует искомому, это вполне может не выполняться, если произошла коллизия. Однако ключом может быть почти любой объект, в том числе такой, для которого, операция сравнения занимает значительное время. Дабы избежать длительной операции сравнения, в Python применено несколько трюков:

сначала сравниваются указатели, если указатель ключа искомого объекта равен указателю объекта, по которому осуществляется поиск, то есть указывают на одну и ту же область памяти, то сравнение сразу же возвращает истинно. Но и это не всё. Как известно, у равных объектов должны быть равные хеши, из чего следует, что объекты с разными хешами не равны. После проверки указателей проверяются хеши, если они не равны, вернётся false. И лишь, если хеши равны, будет вызвано честное сравнение.

Какова вероятность такого исхода? Примерно 2^-64, разумеется из-за лёгкой предсказуемости значения хеша, можно легко подобрать такой пример, но в реальности до этой проверки выполнение доходит не часто, насколько? Raymond Hettinger собрал интерпретатор, изменив последнюю операцию сравнения простым return true. Т.е. интерпретатор считал объекты равными, если их хеши равны. После чего натравил на такой интерпретатор автоматизированные тесты многих популярных проектов, которые завершились успешно. Может показаться странным считать объекты с равными хешами равными, не проверять дополнительно их содержимое, и целиком полагаться только на хеш, но вы это делаете регулярно, когда пользуетесь протоколами git или torrent. Они считают файлы (блоки файлов) равными, если равны их хеши, что вполне может привести к ошибкам, но их создатели (и все мы) надеемся, стоит заметить, небезосновательно, что вероятность коллизии крайне мала.

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

Будущие изменения

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

Дополнительные материалы

Для более глубокого погружения в тему рекомендуется ознакомиться со следующими материалами:

Источник

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *