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

Porting

Портируем старую игру в жанре shoot em up на JavaScript на коленке

30.06.2020 02:04:50 | Автор: admin

Имеется древняя игрушка LaserAge, которая написана на Flash (на очень древнем Macromedia Flash 4) и работает только под Windows. В детстве она мне очень понравилась, поэтому я решил для души портировать её, чтобы можно было играть с браузера со всех устройств.


Цель игры заключается в том, чтобы уничтожать противников своим космическим кораблём на различных уровнях и получать бонусы, если поймать бонус улучшается оружие. При попадании торпеды противника даунгрейд оружия игрока.
При уничтожении всех противников на уровне происходит переключение на следующий уровень. Всего 100 уровней.
В терминах игры уровень волна (Wave), а несколько волн объединены в большой уровень (Level), который представляет из себя просто смену заднего фона,
т. е. всего 4 больших уровня в каждом из которых 25 волн. В последней волне большого уровня обычно бывает босс противник с огромным значением жизни и мощным оружием.


http://personeltest.ru/aways/github.com/EntityFX/laseroid/blob/master/doc/LaserAgeNext.png?raw=true


[TOC]


Бизнес логика игры


Игровое пространство


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


http://personeltest.ru/aways/github.com/EntityFX/laseroid/blob/master/doc/Stage.png?raw=true


Оружие


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


Оружие космического корабля игрока


  • Торпеда стреляет маленькими ракетами
    • Одинарная Торпеда 1 уровень апгрейда
    • Двойная 2 уровень апгрейда
    • Тройная 3 уровень апгрейда
  • Автоматические пушки
    • Дополнительная автоматическая Торпеда слева корабля 4 уровень апгрейда
    • Дополнительная автоматическая Торпеда справа корабля 5 уровень апгрейда
  • Зелёная плазма 6 и 7 уровень апгрейда (увеличивается скорострельность)
  • Фиолетовая плазма 8 уровень апгрейда (наносит урон всем противникам по траектории полёта)
  • Зелёный лазер 9 уровень (наносит урон всем противникам, а также активно одну секунду, тем самым можно задеть соседних противников)

Дополнительное оружие:


  • Красная плазма 15-19 уровень (наносит урон всем противникам, а также активно одну секунду, тем самым можно задеть соседних противников)
  • Зелёная плазма 20-24 уровень
  • Синяя плазма 25-29 уровень апгрейда
  • Фиолетовая плазма 30-34 уровень апгрейда
  • Фиолетовая плазма 30-34 уровень апгрейда
  • Дополнительная автоматическая Торпеда слева стреляет желтой плазмой 35 39 уровень апгрейда
  • Дополнительная автоматическая Торпеда справа стреляет желтой плазмой 40+ уровень апгрейда

Таблица с характеристиками оружия игрока


Оружие Hit Points Скорость спрайта Интенсивность Тип Дополнительно Вид
Торпеда 1 5 25 Торпеда Одинарная, двойная, тройная
Автоматическая Торпеда 1 5 50 Торпеда Слева и Справа
Зелёная плазма 3 7 30 Торпеда
Фиолетовая плазма 2 8 30 Торпеда Атакует до 3х целей
Красная плазма 2 4 30 Торпеда
Синяя плазма 4 4.5 30 Торпеда
Жёлтая плазма 2 3.8 40 Торпеда Только автоматическая
Зелёный Лазер 4 - 15/55 Лазер Атакует до 5ти целей одновременно

Таблица с конфигурацией оружия игрока в зависимости от уровня жизни


Уровень жизни Конфигурация оружия
1 Торпеда
2 Торпеда + Торпеда
3 Торпеда + Торпеда + Торпеда
4 Торпеда + Торпеда + Торпеда + Автоматическая торпеда слева
5 Торпеда + Торпеда + Торпеда + Автоматическая торпеда слева + справа
6 Зелёная плазма + Автоматическая торпеда слева + справа
7 Зелёная плазма + Автоматическая торпеда слева + справа
8 Фиолетовая плазма + Автоматическая торпеда слева + справа
9 Зелёный лазер + Автоматическая торпеда слева + справа
15 19 Зелёный лазер + Красная плазма + Автоматическая торпеда слева + справа
20 24 Зелёный лазер + Красная плазма + Автоматическая торпеда слева + справа
25 29 Зелёный лазер + Синяя плазма + Автоматическая торпеда слева + справа
30 34 Зелёный лазер + Фиолетовая плазма + Автоматическая торпеда слева + справа
35 39 Зелёный лазер + Фиолетовая плазма + Автоматическая желтая плазма слева + торпеда справа
40+ Зелёный лазер + Фиолетовая плазма + Автоматическая желтая плазма слева + желтая плазма справа

Оружие противников


Таблица с конфигурацией оружия противников


Оружие Скорость спрайта Тип
Торпеда 2.5 Торпеда
Красная плазма 3.5 Торпеда
Синяя плазма 4.5 Торпеда
Зелёная плазма 5 Торпеда
Синяя Торпеда 3 Торпеда
Жёлтая плазма 3.2 3.8 Торпеда
Белая плазма 4 6 Торпеда
Зелёный Лазер - Лазер

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


Пример конфигурации оружия:


"torpedo": {    "sprite": "Bullet1_1.png", //картинка спрайта    "isRandomIntensity": false, //нужно ли переключать случайно слоты - true или по порядку - false    "intensity": [        //слот 0        {            "min": 50, //минимальное число фреймов            "max": 200, //максимальное число фреймов            "type": "pause" //pause - оружие неактивно, shoot - активное (стреляет)        },        //слот 1        {            "min": 100,            "max": 200,            "type": "shoot"        },        {            "min": 50,            "max": 80,            "type": "pause"        },        {            "min": 30,            "max": 100,            "repeat": 2        }    ],    "speed": 2.5, //скорость    "type": "bullet", //тип оружия    "sound": "alienTorpedo"}

Действующие лица


Корабль игрока


Корабль игрока может перемещаться в ограниченной области, чтобы не пересекаться с кораблями противников.
Управляется движением мыши или стрелочками и . На экране мобильного телефона тапом и движением по экрану.
Оружие активирует при удержании левой клавиши мыши (тапом и удержанием по экрану на мобильном телефоне).


Противники


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


Корабль противника Жизнь Тип Движения Оружие Вид
Чужой 1 2 Обычный Нормальное горизонтальное Торпеда
Чужой 2 4 Обычный Нормальное все направления Торпеда
Быстрый чужой 10 Обычный Быстрое горизонтальное Торпеда (Интенсивная)
Фрегат чужого 10 Обычный Нормально-быстрое все направления Красная плазма
Броневик чужого 10 Обычный Медленное вниз Торпеда (Очень интенсивная)
Быстрый Фрегат чужого 30 Обычный Медленное вниз (следит за игроком) Красная плазма (Очень интенсивная)
Красный истребитель 30 Обычный Медленное вниз (следит за игроком) Синяя плазма
Зелёный истребитель 30 Обычный Быстро вертикально Синяя плазма
Чужой 1 модификация 2 Обычный Нормальное горизонтальное Синяя Торпеда
Бомбардировщик 30 Обычный Нормальное все направления (следит за игроком) Зелёная плазма
Тяжёлый Чужой 30 Обычный Нормальное все направления Торпеда
Тяжёлый Фрегат Чужого 35 Обычный Нормальное все направления Синяя Торпеда + Синяя Торпеда
Тяжёлый броневик 35 Обычный Нормальное вниз Жёлтая Плазма + Жёлтая Плазма + Жёлтая Плазма + Жёлтая Плазма
Линкор 100 Босс Нормальное все направления Синяя плазма (очень интенсивная) + Зелёная плазма (очень интенсивная)
Крейсер 250 Босс Нормальное все направления Зелёная плазма (сверх интенсивная)
Тяжёлый Крейсер 500 Босс Быстрое все направления Жёлтая Плазма + Жёлтая Плазма + Синяя Торпеда + Синяя Торпеда + Синяя Торпеда + Синяя Торпеда + Белая плазма + Белая плазма
Эпичный Тяжёлый Крейсер 1000 (восстанавливается) Босс Быстрое все направления Жёлтая Плазма + Жёлтая Плазма + Синяя Торпеда + Синяя Торпеда + Синяя Торпеда + Синяя Торпеда + Белая плазма + Белая плазма+ Зелёная плазма (очень интенсивная)

JSON-конфигурация противника:


"alien10": {    "life": 35,    "weapons": [        {            "weapon": "blueTorpedo",            "position": {                "x": -6,                "y": 0            }        },        {            "weapon": "blueTorpedo",            "position": {                "x": 6,                "y": 0            }        }    ],    "sprite": "AlienShip10_1.png",    "movement": "horizontalFast",    "killPoints": 2100}

JSON-конфигурация движения противника :


"horizontalFast": {    "movements": [        {            "type": "freeMovement", //freeMovement - обычное, followPlayer - следит за игроком (движется в направление)            "speedDelta": {                "vx": -6,                "vy": 0            },            "intensity": [ //интенсивность движения в виде слотов                {                    "min": 20,                    "max": 150                },                {                    "min": 150,                    "max": 350                }            ]        }    ]}

Бонусы


Специальный вид противника http://personeltest.ru/aways/raw.githubusercontent.com/EntityFX/laseroid/master/resources/laser-age/graphics/PowerUps_1.png , который не имеет оружия и при уничтожении порождает спрайт с бонусом http://personeltest.ru/aways/raw.githubusercontent.com/EntityFX/laseroid/master/resources/laser-age/graphics/Upgrade.png , который должен поймать корабль игрока. Если игрок поймает бонус, то увеличивается его уровень (жизнь).


Уровни


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


JSON-конфигурация уровня :


        "2": {            "level": 1,             "enemies": [ // список противников                {                    "id": "alien1",                    "position": {                        "x": 200,                        "y": 35                    }                },                //...                {                    "id": "alien1",                    "position": {                        "x": 525,                        "y": 40                    }                }            ],            "bonuses": [ // список бонусов                {                    "id": "bonus1",                    "position": {                        "x": 350,                        "y": 10                    }                }            ]        },

Выбор JavaScript библиотеки для реализации


Я просмотрел множество библиотек графики для JavaScript, но остановился на Hexi JS: https://github.com/kittykatattack/hexi .


Возможности библиотеки:


  • Простота
  • Рисование примитивов
  • Рисование просты интерфейсов (кнопки, события)
  • Перемещение, масштабирование, вращение
  • Рисование спрайтов
    • Анимированные спрайты
    • Работа со спрайтами как с объектами
    • Загрузка спрайтов в виде большой текстуры-атласа. Можно разместить множество изображений в одном файлы и на выходе получить одну большую текстуру и JSON файл с описанием спрайтов (область, смещение)
  • Логика столкновений
  • Работа с устройствами ввода (клавиатура), тач-скрин.

Пример текстуры-атласа создаваемого с помощью программы TexturePacker
http://personeltest.ru/aways/github.com/EntityFX/laseroid/blob/master/doc/ships-atlas-texture.png?raw=true


Звуковая библиотека: https://github.com/kittykatattack/sound.js


Возможности библиотеки:


  • Простота
  • Воспроизведение звуков
  • Воспроизведение музыки
  • Эффекты

Архитектура


Общая диаграмма классов:


http://personeltest.ru/aways/github.com/EntityFX/laseroid/blob/master/doc/diagrams/game.png?raw=true


Ядро игры


http://personeltest.ru/aways/github.com/EntityFX/laseroid/blob/master/doc/diagrams/core.png?raw=true


Класс Main


Является точкой входа и контейнером игрового кода.


Поля:


  • resources содержит список всех загружаемых ресурсов (текстуры, звук, json)
  • sounds словарь звуков: Ключ название, Значение путь
  • gameScene объект HexiJS на
  • game экземпляр объекта Game
  • hexi инстанс HexiJS
  • gameStorage сохраняет состояние игры в localStorage

Методы:


  • init() инициализирует HexiJS
  • load() загружает ресурсы (текстуры, звук, json)
  • setup() устанавливает игровую область, события нажатия кнопок, запускает фоновую музыку
  • playLoop() точка изменения состояния игры (считает движение, коллизии, снаряды, перерисовывает пространство).
  • saveGame() сохраняет игру
  • loadGame() загружает игру

Пример списка ресурсов текущей реализации игры:


Main.resources = [        "images/environment1.png",        "images/environment2.png",        "images/environment3.png",        "images/environment4.png",        "images/interface.png",        "images/life-icon.png",        "images/ships-texture.json",        "images/bullet-texture.json",        "sounds/alien-torpedo-shoot.wav",        "sounds/alien-red-plasma-shoot.wav",        "sounds/hero-torpedo-shoot.wav",        "sounds/explode.wav",        "sounds/hero-green-plasma-shoot.wav",        "sounds/alien-green-plasma-shoot.wav",        "sounds/alien-blue-torpedo-shoot.wav",        "sounds/alien-yellow-laser.wav",        "sounds/pulse-plasma.wav",        "sounds/laser.wav",        "sounds/track0.ogg",        "sounds/track1.ogg",        "sounds/track2.ogg",        "sounds/track3.ogg",        "sounds/track4.ogg",        "data/hero-configuration.json",        "data/levels-configuration.json",        "data/enemy-configuration.json",        "data/ui-configuration.json",    ];

Класс Game


Основной класс игры.


Поля:


  • level информация о уровне. Значение: { "wave": 1 //номер волны, "type": 1 }
  • score информация об очках. Значение: {"points": 0 }
  • bulletsController Экземпляр класса BulletsController. Управляет поведение торпед и лазеров оружия
  • enemyController Экземпляр класса EnemyController. Управляет поведением всех противников на уровне (в т.ч. и бонусами)
  • player Экземпляр Player
  • hexi экземпляр класса Hexi (ссылка)
  • game экземпляр объекта Game
  • gameStorage экземпляр объекта GameStorage

Методы:


  • clearShips() очистка всех проиивников, бонусов
  • setupLevel() настроить уровень (добавить противников, бонусы, расстановка)
  • nextLevel() переход на следующий уровень
  • previousLevel() переход на предыдущий уровень
  • forwardLevel() перепрыгнуть на несколько уровней вперёд (на 5)
  • rewindLevel() перепрыгнуть на несколько уровней назад (на 5)
  • restoreState(gameState: JSON) восстановить по объекту gameState
  • resetGame() сбросить игру (начать сначала)
  • update() обновить игровой мир
  • enemyDestroyed() обработчик срабатывает при уничтожении всех противников

Класс GameStorage


Сохраняет и загружает состояние игры .


Поля:


  • game экземпляр объекта Game

Методы:


  • save() сохранить состояние игры
  • load() загрузить состояние игры

Класс InputDevice


Работает с событиями устройств ввода: click и touch кнопок, нажатие клавиш клавиатуры.


Поля:


  • game экземпляр объекта Game

Методы:


  • init() инициализирует все обработчики события и callback'и
  • loadTapped() нажата кнопка "Load"
  • storeTapped() нажата кнопка "Store"
  • resetTapped() нажата кнопка "Reset"
  • pauseTapped() нажата кнопка "Pause"

Иерархия классов действующих лиц


http://personeltest.ru/aways/github.com/EntityFX/laseroid/blob/master/doc/diagrams/actors.png?raw=true


Actor


Класс участника.


Поля:


  • hexi экземпляр класса Hexi (ссылка)
  • game экземпляр объекта Game
  • life текущее значение жизни
  • initialLife начальное значение жизни
  • sprite экземпляр класса Hexi.Sprite
  • shipConfiguration конфигурация бонуса

Методы:


  • move() переместить действующее лицо
  • update() обновить действующее лицо
  • setPosition(position: {x, y}) установить по координатам

WeaponedActor


Класс участника (противник или игрок) обладающем оружием.


Поля:


  • automatedWeapons массив автоматических оружий
  • canShoot мжет ли стрелять
  • isWeaponShooting активено ли оружие

Методы:


  • startShoot() запустить выстрелы оружием
  • stopShoot() остановить выстрелы оружием
  • onShootStarted() обработчик события, что запущены выстрелы оружием
  • onShootStopped() обработчик события, что остановлены выстрелы оружием
  • updateShooting() выполняет алгоритмы выстрелов

Enemy


Класс противника.


Поля:


  • type тип противника
  • syncWeapons массив конфигураций для синхронного оружия
  • movementEngine экземпляр класса MovementEngine

Методы:


  • setWeapon() установить оружие используя текущую конфигурацию
  • shootWithWeapon() выполняет выстрел противником
  • setLifeLine() рисует линию жизни противника
  • hit() проверяет столкновение торпед (лазера) игрока с текущим противником

MovementEngine


Класс управляющий движением.
Для придания сложности движения, используется конфигурация со слотами. В каждом слоте задаётся
вектор направления vx, vy и интенсивность. Имеется возможность отключения отражения от нижней границы и
режим слежения за игроком (противник всегда движется за игроком).


Поля:


  • movementsConfiguration конфигурация движения
  • firstMovementConfiguration первый элемент из списка конфигураций
  • movementItensity интенсивность движения
  • movementItensityCounter счётчик интенсивности движения
  • movementItensitySlot номер слота интенсивности
  • isBounceBottom флаг на проверку отражения от нижней границы. Если false, то противник не отражается от нижней границы

Методы:


  • setMovement() настраивает движение
  • updateMovement() обновляет движение по конфигурации движения

Player


Класс игрока.


Поля:


  • weapons массив оружия игрока
  • collisionSprite спрайт коллизии (торпеды противника сталкиваются со спрайтом коллизии, а не спрайтом игрока)
  • weaponLifeLevels значения уровня жизни для проверки на апгрейд оружия
  • invisibilityCounter счётчик невидимости от торпед (нужен для того, чтобы при столкновении с торпедой противника игрок стал временно недосягаем для других торпед)

Методы:


  • upgrade() апгрейд игрока (+1 жизнь)
  • downgrade() даунгрейд игрока (+1 жизнь)
  • shootWithLaser(currentWeapon, weapon) выстрел лазером
  • shootWithBullets(currentWeapon, weapon) выстрел торпедой
  • setWeapon() установить оружие используя текущую конфигурацию
  • setLife(life: number) установить значение жизни (меняет оружие в соответствии со значением жизни)
  • hitUpgrade(upgradeItem) проверить столкновение со спрайтом апгрейда

Bonus


Класс Бонуса. При уничтожении порождает спрайт апгрейда.


Поля:


  • type тип бонуса
  • movementEngine экземпляр класса MovementEngine
  • upgradeBonus конфигурация апгрейда

Методы:


  • shootWithUpgrade(upgradeBonus: JSON) породить спрайт апгрейда

EnemyController


Управляет состоянием противников, бонусов, апгрейдами.


Поля:


  • enemies массив всех противников на уровне
  • bonuses массив всех бонусных кораблей на уровне
  • player объект игрока
  • upgrades массив всех спрайтов апгрейда

Методы:


  • isLevelCompleted() проверка на завершённость уровня (уничтожены все противники и бонусы, пойманы апгрейды)
  • update() обновляет состояние всех противников
  • clear() очистка уровня от проиивников, бонусов

BulletsController


Управляет состоянием торпед (перемещение), лазерами игрока и противников.


Поля:


  • playerBullets массив торпед игрока
  • enemyBullets массив торпед всех противников
  • explosionSplashes массив спрайтов взрыва
  • playerLaser состояние спрайта лазера игрока (Если оружие доступно).

Методы:


  • update() обновляет состояние всех торпед, лазеров
  • clear() очищает уровень от всех торпед, лазеров
  • updatePlayerBullets() изменяет состояние всех торпед игрока
  • updatePlayerLaser() изменяет состояние лазера игрока
  • updateEnemyBullets() изменяет состояние всех торпед противника
  • updateExplosions() изменяет состояние всех взрывов

Выводы


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


Спасибо и интересных Вам проектов!


Ссылки


http://laseroid.azurewebsites.net/ сама игра
https://github.com/EntityFX/laseroid исходный код игры

Подробнее..

Опыт портирования legacy enterprise проекта c Net Framwork на Net Core

10.08.2020 12:13:56 | Автор: admin

Опыт портирования legacy enterprise проекта c Net Framwork на Net Core


net framework to net core


Вводная часть


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


Что имеем


Дано: Enterprise система, которая написана с использованием следующих технологий (всё написано на C# под Net Framework):


  • Множество ASMX web-служб
  • Множество WCF служб
  • Бэкграунд задачи на Workflow Foundation
  • Web-приложения на WebForms и частично на ASP.NET MVC 4
  • Отчёты SQL Server Reporting Services
  • Утилиты на Windows Forms и консольные приложения

Зачем переходим на Net Core:


  • Обновляем стек технологий
  • Избавляемся от старых технологий (разработчики счастливы, а новые нанимаемые разработчики не пугаются)
  • Используем все преимущества NetCore: мультиплатформенность, масштабируемость, контейнеризация

Есть статья Выбор между .NET Core и .NET Framework для серверных приложений, которая вам поможет что выбрать.


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


Для того, чтобы добиться переносимости кода между различными средами исполнения (Framework и Core), нам на помощью приходит NetStandard (а конкретнее netstandard2.0).


Ещё нужно быть готовым к тому, что часть технологий частично или полностью отсутствует в NetCore, а конкретно:


  • Windows Communication Foundation. Нет API для служб, только API для клиентской стороны
  • Workflow Foundation
  • ASP.NET Web Forms (отсутствует System.Web)
  • Отсутствует API работы с очередями MSMQ

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


Детали переноса компонентов, служб, подсистем


Директивы препроцессора и условная компиляция


Директивы препроцессора (условная компиляция #if) помогает в тех случаях, если нет нет необходимых API и часть методов или целиком классы нельзя портировать с .Net Framewrk на .Net Core.


Когда приходилось использовать #if:


  • Часть API переехало в другие namespace'ы и приходилось их импортировать для .Net Core реализации
  • Частично переписывать реализацию методов в связи с отсутствующем API
  • Полносстью отказываться от нектороых классов в связи с невозможностью их портирования на .Net Core

Примеры условных директив, которые приходилось применять на проекте:


Часто используемая проверка: реализация кода для Net Framework иначе реализуем для NetStandard или NetCore:


#if NETFRAMEWORK#elif NETSTANDARD || NETCOREAPP#endif

В очень редких случаях если нужна была реализация для NetCore или NetFramework (связано с тем, что использовались API, которых нет в .NetStandard2.0):


#if NETFRAMEWORK#elif NETCOREAPP#endif

Как вы заметили, специально не применялись проверки на конкретные версии runtime'ов для упрощения кода.


Target'ы в *.csproj проектах выглядят так:


    <TargetFrameworks>netstandard2.0;net471;netcoreapp3.1</TargetFrameworks>

Больше про Кроссплатформенное нацеливание


Реализация заглушек API


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


Например, в наших контрактах часто используется WCFный атрибут TransactionFlowAttribute, но он будет использоваться, но интерфесы, нак которые он навешивается используется повсеместно, поэтому далем так:


#if NETFRAMEWORK#elif NETSTANDARD || NETCOREAPPnamespace System.ServiceModel{    [System.AttributeUsage(System.AttributeTargets.Method)]    public sealed class TransactionFlowAttribute : Attribute, System.ServiceModel.Description.IOperationBehavior    {        public TransactionFlowAttribute(TransactionFlowOption transactions)        {            Transactions = transactions;        }        public TransactionFlowOption Transactions { get; }        public void AddBindingParameters(OperationDescription operationDescription,            BindingParameterCollection bindingParameters)        {        }        public void ApplyClientBehavior(OperationDescription operationDescription, ClientOperation clientOperation)        {        }        public void ApplyDispatchBehavior(OperationDescription operationDescription,            DispatchOperation dispatchOperation)        {        }        public void Validate(OperationDescription operationDescription)        {        }    }    public enum TransactionFlowOption    {        NotAllowed,        Allowed,        Mandatory,    }}#endif

Заворачиваем всё в NuGet пакеты


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


http://personeltest.ru/aways/docs.microsoft.com/ru-ru/dotnet/standard/library-guidance/media/cross-platform-targeting/nuget-package-multiple-assemblies.png


Инфраструктурная часть


К инфраструктурной части приложения относится:


  • Хостинг приложения (Реализация служб)
  • Логирование
  • Обработка ошибок
  • Конфигурация
  • Загрузочная часть (Bootstrapper)
  • Мониторинг

Конфигурация приложений


Конфигурация классических приложений Net Framework основывается на файлах app.config, и web.config, которые представляют из себя XML файлы и API работы с ними: System.Configuration и класс System.Configuration.ConfigurationManager. Например, часто приходится считывать данные из AppSettings и гораздо реже делать свои классы конфигурации в ConfigurationSection.


В NetCore появилось новое API, которое позволяет работать с конфигурацией в различных форматах (JSON, INI, XML) и использовать различные источники (Файлы, Командная строка, Переменные окружения и т. д.)


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


Кстати, в StackOverflow имеется куча вопросов как организовать конфигурацию


Если всё-таки нужно использовать старую реализацию, то имеется NuGet пакет System.Configuration.ConfigurationManager.


NetCore реализация конфигурации более интуитивная и простая. Здесь вы работаете с конфигурацией по конкретному пути в конфигурационном файле, либо как с объектом (и не нужно описывать сложных ConfigurationSection)


Логирование


В старом проекте применялось логирование в EventLog с использованием API из System.Diagnostics, но а так же была абстракция в виде интерфейса ILogger, которая позволяла логировать с различными уровнями messageLevel (Debug, Info, Warning, Error) и указанием категорий. В более новых проектах уже применялся NLog c той же самой абстракцией ILogger.


В NetCore появилось новое универсальное API: Microsoft.Extensions.Logging, которое предоставляет интерфейс ILogger<T>.


Мы же продолжили использовать нашу абстракцию ILogger, потому что она везде, но конкретная реализация уже использует Microsoft.Extensions.Logging.ILogger<T>, а также она легко позволяет подключить и сконфигурировать кучу существующих логеров, например: NLog, log4Net, Serilog и т.д.


Внедрение зависимостей и инверсия управления


В наших проектах использовались IoC-контейнеры Unity, а в более новых AutoFac, либо вовсе отсутствовали.


В NetCore добавлена абстракция Microsoft.Extensions.DependencyInjection с использованием класса ServiceCollection, которая позволяет регистрировать типы с уровнями:


  • Singleton создаст инстанс один раз и будет всегда переиспользовать его
  • Scoped в рамках некого контекста, например на время Http-запроса
  • Transient будет создавать инстанс каждый раз

Также имеется класс IServiceProvider, который обеспечивающий получение нужной регистрации.


Более подробнее читаем тут: Внедрение зависимостей в ASP.NET Core.


Советы:


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

Избавляемся от Global Assembly Cache


Очень давно на проекте было принято решение использовать Global Assembly Cache, чтобы приложения не искали сборки и было централизованное место где они лежат.


Net Core не умеет в GAC, поэтому было принято решение написать кастомный AssemblyResolver, который искал бы в заданной директории используя конфигурацию.


Модели приложений


Консольные приложения


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


Достаточно поменять *.cproj файл в формат SDK: <Project Sdk="Microsoft.NET.Sdk"> ну и соответственно использовать <TargetFramework>netcoreapp3.1</TargetFramework>


Windows Forms


Начиная с версии NetCore 3.0 появилась возможность запускать приложения Windows Forms, но после перехода на версию Net Core 3.1 часть legacy контроллов было удалено, поэтому
придётся немного переписать приложения.


Вот список контроллов, которые были "выпилены":


  • DataGrid и связанные с ним типы. Можно заменить на DataGridView;
  • ToolBar. Заменяем на ToolStrip;
  • MainMenu. Заменяем на MenuStrip;
  • ContextMenu. Заменяем на ContextMenuStrip.

Более подробно про изменения можно почитать Критические изменения в Windows Forms.


Позднее Microsoft выпустили руководство Процесс переноса классического приложения Windows Forms в .NET Core,
которое сильно помогло нам.


На первых этапах существования Windows Forms для NetCore 3.0 отсутствовал дизайнер форм для Visual Studio 2019, поэтому приходилось рисовать GUI в Net Framework, а потом переключаться на NetCore 3.0, но более поздних редакция появилась такая возможность.


Переносим ASP.NET MVC приложения в Asp Net Core MVC


Перенос будет чуть сложнее чем Windows Form приложения, но всё-равно всё происходит достаточно безболезненно.


Первое большое изменение убран бутрстраппер Global.asax и заменён на класс Startup.


Второе изменение отсутствует сборка System.Web и все типы HttpSession, Cookies, HttpRequest и т. д. соответственно.


Для общего представления читаем тут: Запуск приложения в ASP.NET Core


Третье большое изменение изменена модель аутентификации, авторизации, обработка ошибок, фильтры, отсутствуют HTTP модули.


Подробнее можно прочитать здесь: Миграция обработчиков и модулей HTTP в ASP.NET Core по промежуточного слоя. Для этого всего используется новая модель Middleware. Теперь можно организовать конвейер запроса и вклиниваться на каждом этапе прохождения запроса:


Конвейер ПО промежуточного слоя ASP.NET Core


Конвейер фильтра ASP.NET Core


Пример простейшего Middleware:


public class Startup{    public void Configure(IApplicationBuilder app)    {        app.Use(async (context, next) =>        {            // Do work that doesn't write to the Response.            await next.Invoke();            // Do logging or other work that doesn't write to the Response.        });        app.Run(async context =>        {            await context.Response.WriteAsync("Hello");        });    }}

API контроллеров практически не поменялось, разметка представлений Razor также практически не поменялась, смотрим тут: Обработка запросов с помощью контроллеров в ASP.NET Core MVC и тут: Представления в ASP.NET Core MVC.


Следующее изменение отсутствие Bundler'а для объединения и минификации js и css файлов, поэтому читаем тут: Объединение и минификация статических ресурсов в ASP.NET Core


ASPNET ASMX переносим на AspNetCore WebAPI


В данном случае всё-таки придётся отказаться от использования SOAP и использовать HTTP с JSON или XML.


На каждый asmx сервис создаём контроллер WebAPI и на каждый WebMethod создаём
Action POST метод и переносим соответствующий код с реализацией из asmx сервиса. Недостаток заключается в том, что мы полностью отходим от SOAP модели и вам также придётся переписывать клиентов. Если хотите, то можете оформить в виде Rest-служб.


Другой вариант придётся использовать сторонние библиотеки, которые могут в SOAP, например: SoapCore.


Ещё один вариант JSON-RPC, имеется куча различных библиотек под NetCore, все они хорошо внедряются в AspNet Core через Middleware.


Также у Microsoft имеется guide как написать с помощью AspNetCore Middleware свой SOAP обработчик: Custom ASP.NET Core Middleware Example.


ASPNET WebApi переносим на AspNetCore WebAPI


Достаточно простая задача, потому что идеология простоя: есть контроллеры и экшны.


Следуем этому guide'у: Переход с веб-API ASP.NET на ASP.NET Core


шаги почти аналогичные с переносом MVC:


  • Global.asax и заменяем на класс Startup
  • Настраиваем авторизация и аутентификацию
  • Настраиваем Logger, Exception Handler
  • Портируем фильтры и т.д.

ASPNET Web Forms переносим на Blazor


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


Почему было решено портировать Web Forms на Blazor:


  • Компонентная структура кода (контроллы)
  • Code-behind стиль: есть разметка и есть логика и они друг с другом сильно связаны
  • Минимум Java-Script разработки (для кастомизации заказчики и разработчики предпочитают кодить на C#)

Для переноса было написано 2 утилиты:


  • Первая парсила aspx страницы, конвертировала в XML код (так удобнее работать на C#) и описывала иерархию контроллов в виде дерева в достаточно абстрактном виде (тут кнопка, grid и т.д.), также генерировала html-разметку отделённую от aspx
  • Вторая утилита делала обратную работу: генерировала Blazor-страницы и code-behind мы дописывали сами

В связи с тем, что у нас интерфейсы достаточно однотипные и простые, нам достаточно легко удалось портировать ASPNET Web Forms приложения.


Избавляемся от WCF


В NetCore есть частичная реализация WCF Client API:


  • Имеются только простые BasicHttpBinding, NetTcpBinding
  • Нет security на Massage уровне, только на Transport
  • Нет поддержки распределённых транзакций

Так как серверная сторона WCF полностью отсутствует в Net Core, то есть несколько вариантов:


  • Портируем как AspNetCore WebAPI
  • Портируем как AspNetCore gRPC
  • Используем стороннюю библиотеку CoreWCF с многими ограничениям
  • Портируем как AspNetCore + JSON-RPC

Перенос WCF сервисов на AspNetCore WebAPI аналогичен с asmx службами: на каждый сервис свой контроллер и свой action-метод на каждый метод WCF. Способ реализации полностью ложится на Вас: каждый метод может принимать Post-запрос, где URL будет иметь название соответствующего метода как https://you-service.com/MethodName, ну или выбираем JSON-RPC.


Другой вариант AspNetCore gRPC: Перенос службы WCF "запрос ответ" в gRPC унарный RPC


Пример WCF службы:


[ServiceContract]public interface IItemService{    [OperationContract]    Task<Item> GetItem(int id);}[DataContract]public class Item{    [DataMember]    public int Id { get; set; }    [DataMember]    public string Name { get; set; }}

Пример gRPC контракта в protobuf формате:


message GetItemRequest {    int32 id = 1;}message Item {    int32 id = 1;    string name = 2;}service ItemService {    rpc GetItem(GetItemRequest) returns (Item);}

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


  • Все внешние службы реализовать в виде WebAPI в стиле REST, либо JSON-RPC
  • Внутренние службы взаимодействуют по gRPC

Избавляемся от Workflow Foundation


Службы на Workflow Foundation полностью отсутствуют (в Microsoft, видимо, поняли что графическое представление службы никому не удобно и проще всё писать кодом), поэтому у вас есть такие варианты:



Лично мы решили просто избавиться от Workflow Foundation, он нам всегда доставлял неудобства и сделали старым добрым кодом. А что же может быть лучше старого доброго кода?


Подводим итоги


В итоге к чему приходим? А приходим к тому, что часть подсистем переносятся в лоб, часть с небольшими доработками, но часть придётся глубоко перерабатывать,
что может потребовать много усилий. Но у нас уже выработался стиль реализации подсистем: для внешних систем службы всегда делать HTTP Rest like службы, если нужна производительность
(а для бизнес задач стандартных средств хватает с головой) gRPC лучший подход. И да Побыстрее избавляйтесь (по возможности) от Web Forms.

Подробнее..

Как мы заставили код, портированный с C, работать с моделью памяти C

24.11.2020 22:21:52 | Автор: admin
Привет, Хабр. В прошлой статье я рассказывал о том, как мы создали фреймворк для перевода кода C# на (неуправляемый) C++, чтобы выпускать свои библиотеки, изначально разработанные для платформы .Net, и под C++ тоже. В этой статье я расскажу о том, как нам удалось согласовать модели памяти этих двух языков, добившись работы портированного кода в необычном для него окружении.

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



Модель работы с памятью в C#



Код C# выполняется в управляемой среде со сборкой мусора. Для наших целей это означает, прежде всего, то, что программист C#, в отличие своего коллеги из числа разработчиков C++, освобождён от необходимости заботиться о возвращении системе выделенной на куче памяти, которая более не используется. За него это делает сборщик мусора (GC) компонент среды CLR, периодически проверяющий, какие из объектов ещё используются в программе, и очищающий те, на которые больше нет активных ссылок.

Активной считается ссылка:
  1. Расположенная на стеке (локальная переменная, аргумент метода);
  2. Расположенная в области статических данных (статические поля и свойства);
  3. Расположенная в объекте, на который есть активные ссылки.


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

В отличие от умных указателей, подход с уборкой мусора свободен от проблемы перекрёстных или циклических ссылок: если два объекта ссылаются друг на друга (возможно, через некоторое количество промежуточных объектов), это не удерживает GC от того, чтобы удалить их в тот момент, когда на всю группу (остров изоляции) не остаётся активных ссылок. Отсюда следует, в частности, то, что у программистов C# не существует каких-либо предубеждений против того, чтобы связывать объекты друг с другом в любой момент и в любых комбинациях.

Типы данных в C# делятся на ссылочные и значимые. Экземпляры значимых типов всегда располагаются на стеке (либо в статической области памяти, либо непосредственно в полях структур и объектов), в то время как экземпляры ссылочных типов всегда создаются в куче, оставляя на стеке (в статической памяти, в полях) лишь ссылки (адреса). К значимым типам относятся структуры и примитивные арифметические значения (а также ссылки), а к ссылочным классы и делегаты (исторически). Исключение из этого правила составляют разве что случаи боксинга, когда структура или другой значимый тип копируются в кучу для использования в специфическом контексте (на что существует целая категория каверзных задачек).

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

Ещё один важный момент связан с поддержкой обобщённых (generic) типов и методов в C#. C# позволяет писать дженерики один раз и затем использовать их как со ссылочными, так и со значимыми типами-параметрами. Как будет показано далее, этот момент для нас важен.

Модель отображения типов



Несколько слов о том, как мы отображаем типы C# на типы C++. Поскольку важным требованием для нас является как можно более точное воспроизведение API оригинального проекта (т. к. мы поставляем библиотеки, а не приложения), мы превращаем классы, интерфейсы и структуры C# в классы C++, наследующие соответствующие базовые типы.

Например, рассмотрим следующий код:

interface I1 {}interface I2 {}interface I3 : I2 {}class A {}class B : A, I1 {}class C : B, I2 {}class D : C, I3 {}class Generic<T> { public T value; }struct S {}


Он будет портирован так:

class I1 : public virtual System::Object {};class I2 : public virtual System::Object {};class I3 : public virtual I2 {};class A : public virtual System::Object {};class B : public A, public virtual I1 {};class C : public B, public virtual I2 {};class D : public C, public virtual I3 {};template <typename T> class Generic { public: T value; };class S : public System::Object {};


Класс System::Object является системным и объявлен в библиотеке, внешней по отношению к портированному проекту. Классы и интерфейсы наследуются от него виртуально (чтобы избежать проблемы бриллианта). Незначимое виртуальное наследование может быть опущено. Структуры в портированном коде наследуются от System::Object, в то время как в C# они наследуются от него через System::ValueType (лишнее наследование убрано с целью оптимизации). Обобщённые типы и методы транслируются в шаблонные классы и методы соответственно.

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

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

C++: умные указатели или ...?



При разработке фреймворка для портирования кода C# на Java проблем с удалением неиспользуемых объектов не было: Java предоставляет механизм сборки мусора, в достаточной мере похожий на таковой в C#, и портированный код, использующий классы, просто собирается и работает. Отличия проявляются уже в других аспектах например, при портировании структур (которые приходится отображать на классы и следить за тем, чтобы они копировались в нужных местах).

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

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

  1. Использовать подсчёт ссылок на объекты (например, через умные указатели);
  2. Использовать реализацию сборщика мусора для C++ (например, Boehm GC);
  3. Использовать статический анализ для определения мест, в которых производится удаление объектов.


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

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

Таким образом, мы пришли к последнему оставшемуся варианту к использованию умных указателей с подсчётом ссылок, что является довольно типичным для C++ (не случайно 11-ый стандарт расширил их поддержку). Это, в свою очередь, означало, что для решения проблемы циклических ссылок нам придётся использовать слабые ссылки в дополнение к сильным.

Вид умных указателей



Существует несколько широко известных типов умных указателей. shared_ptr можно было бы назвать самым ожидаемым выбором, однако он имеет тот недостаток, что располагает счётчик ссылок на куче отдельно от объекта даже при использовании enable_shared_from_this, а выделение/высвобождение памяти под счётчик относительно дорогая операция. intrusive_ptr в этом смысле лучше, поскольку на наших задачах наличие неиспользуемого поля в 4/8 байт внутри структуры является меньшим злом, чем лишняя аллокация при создании каждого временного объекта.

Теперь рассмотрим следующий код:

class Document{    private Node node;    public Document()    {        node = new Node(this);    }    public void Prepare(Node n) { ... }}class Node{    private Document document;    public Node(Document d)    {        document = d;        d.Prepare(this);    }}


Этот код будет портирован примерно в следующий (нотация для указателей свободная, поскольку решение о конкретном их типе ещё не принято):

class Document : public virtual System::Object{    intrusive_ptr<Node> node;public:    Document()    {        node = make_shared_intrusive<Node>(this);    }    void Prepare(intrusive_ptr<Node> n) { ... }}class Node : public virtual System::Object{    intrusive_ptr<Document> document;public:    Node(intrusive_ptr<Document> d)    {        document = d;        d->Prepare(this);    }}


Здесь видны сразу три проблемы:

  1. Необходим способ разорвать циклическую ссылку, сделав в данном случае Node::document слабой ссылкой.
  2. Должен существовать способ преобразования this в intrusive_ptr (аналог shared_from_this). Если вместо этого начать менять сигнатуры (например, заставив Document::Prepare принимать Node* вместо intrusive_ptr<Node>), начнутся проблемы с вызовом тех же методов с передачей уже сконструированных объектов и/или управлением временем жизни объектов.
  3. Преобразование this в intrusive_ptr на этапе создания объекта с последующим уменьшением счётчика ссылок до нуля (как это происходит, например, в конструкторе Node при выходе из Document::Prepare) не должно приводить к немедленному удалению недоконструированного объекта, на который ещё не существует внешних ссылок.


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

Вторая проблема решается элементарно, если intrusive_ptr допускает конверсию из голого указателя (каковым является this). Для реализации из boost это так.

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

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

class Document{    private Node node;    public Document()    {        node = new Node(this);    }    public void Prepare(Node n) { ... }}class Node{    [CppWeakPtr] private Document document;    public Node(Document d)    {        document = d;        d.Prepare(this);    }}


class Document : public virtual System::Object{    intrusive_ptr<Node> node;public:    Document()    {        System::Details::ThisProtector guard(this);        node = make_shared_intrusive<Node>(this);    }    void Prepare(intrusive_ptr<Node> n) { ... }}class Node : public virtual System::Object{    weak_intrusive_ptr<Document> document;public:    Node(intrusive_ptr<Document> d)    {        System::Details::ThisProtector guard(this);        document = d;        d->Prepare(this);    }}


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

Шаблоны



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

class MyContainer<T>{    public T field;    public void Set(T val)    {        field = val;    }}class MyClass {}struct MyStruct {}var a = new MyContainer<MyClass>();var b = new MyContainer<MyStruct>();


Портирование в лоб даёт следующий результат:

template <typename T> class MyContainer : public virtual System::Object{public:    T field;    void Set(T val)    {        field = val;    }};class MyClass : public virtual System::Object {};class MyStruct : public System::Object {};auto a = make_shared_intrusive<MyContainer<MyClass>>();auto b = make_shared_intrusive<MyContainer<MyStruct>>();


Очевидно, этот код будет работать совсем не так, как оригинал, поскольку при инстанциировании MyContainer<MyClass> объект field переехал из кучи в поле MyContainer, сломав всю семантику копирования ссылок. В то же время, расположение структуры MyStruct в поле совершенно правильно, поскольку соответствует поведению C#.

Разрешить данную ситуацию можно двумя способами:

  1. Перейдя от семантики MyContainer<MyClass> к семантике MyContainer<intrusive_ptr<MyClass>>:

    auto a = make_shared_intrusive<MyContainer<MyClass>>();
    
  2. Для каждого шаблонного класса создав две специализации: одну обрабатывающую случаи, когда аргумент-тип является значимым типом, вторую для случаев ссылочных типов:

    template <typename T, bool is_T_reference_type = is_reference_type_v<T>> class MyContainer : public virtual System::Object{public:    T field;    void Set(T val)    {        field = val;    }};template <typename T> class MyContainer<T, true> : public virtual System::Object{public:    intrusive_ptr<T> field;    void Set(intrusive_ptr<T> val)    {        field = val;    }};
    


Помимо многословности, растущей экспоненциально с каждым новым парамтером-типом (кроме случаев, когда через синтаксис where ясно указано, может ли параметр-тип быть только ссылочным или только значимым), второй вариант плох тем, что каждый контекст, в котором используется MyContainer<T>, должен знать, является ли T значимым или ссылочным типом, что во многих случаях нежелательно (например, когда мы хотим иметь минимально возможное количество включаемых заголовков или и вовсе спрятать информацию о неких внутренних типах). Кроме того, выбор типа ссылки (сильная или слабая) возможен лишь один раз на контейнер то есть, становится невозможно иметь одновременно List сильных ссылок и List слабых ссылок, хотя в коде наших продуктов существовала необходимость в обоих вариантах.

С учётом этих соображений, было решено портировать MyContainer<MyClass> в семантике MyContainer<System::SharedPtr<MyClass>> (либо MyContainer<System::WeakPtr<MyClass>> для случая слабых ссылок). Поскольку наиболее популярные библиотеки не предоставляют указателей с требуемыми характеристиками, нами были разработаны собственные реализации, получившие названия System::SharedPtr (сильная ссылка, использующая счётчик ссылок в объекте) и System::WeakPtr (слабая ссылка, использующая счётчик ссылок вне объекта). За создание объектов в стиле std::make_shared отвечает функция System::MakeObject.

Тип ссылки как часть состояния указателя



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

  1. Код не портируется (работа портера завершается с ошибкой).
  2. Код портируется, но не компилируется.
  3. Код компилируется, но не линкуется.
  4. Код линкуется и запускается, но тесты не проходят (или происходят падения в рантайме).
  5. Тесты проходят, но при их работе возникают проблемы, не связанные напрямую с функциональностью продукта (утечки памяти, низкая производительность и т. п.).


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

В начале, когда мы исправляли простые случаи утечки памяти, вызванные циклическими зависимостями между объектами, мы навешивали атрибут CppWeakPtr на поля, получая в итоге поля типов WeakPtr. До тех пор, пока WeakPtr может быть преобразован в SharedPtr вызовом метода lock() (или неявно, что удобнее синтаксически), это не вызывает проблем. Единственный нетривиальный сценарий, который приходит в голову, это передача такой ссылки в качестве ref-аргумента, однако это настолько редкая ситуация, что на нашем коде она не стала проблемой. Далее, однако, нам пришлось также делать слабыми ссылки, содержащиеся в контейнерах, используя специальный синтаксис атрибута CppWeakPtr, и вот тут нас ждала пара неприятных сюрпризов.

Первым звоночком, сообщившим о проблемах с принятым нами подходом, стало то, что с точки зрения C++ MyContainer<SharedPtr<MyClass>> и MyContainer<WeakPtr<MyClass>> это два разных типа. Соответственно, они не могут быть сохранены в одну и ту же переменную, переданы в один и тот же метод (или возвращены из него), и так далее. Атрибут, предназначенный сугубо для управления способом хранения ссылок в полях объектов, начал появляться во всё более странных контекстах, затрагивая возвращаемые значения, аргументы, локальные переменные, и так далее. Код портера, отвечающий за его обработку, становился сложнее день ото дня.

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

Очевидно, эти две проблемы не решались в рамках существующей парадигмы, и типы указателей были вновь пересмотрены. Результатом пересмотра подхода стал класс SmartPtr, имеющий метод set_Mode(), принимающий одно из двух значений: SmartPtrMode::Shared и SmartPtrMode::Weak. Те же значения принимают все конструкторы SmartPtr. В итоге каждый экземпляр указателя может находиться в одном из двух состояний:

  1. Сильная ссылка, счётчик ссылок инкапсулирован в объект;
  2. Слабая ссылка, счётчик ссылок находится вне объекта.


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

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

  1. Хранение сильной ссылки: управление временем жизни объекта с подсчётом ссылок.
  2. Хранение слабой ссылки на объект.
  3. Семантика intrusive_ptr: любое количество указателей, созданных на один и тот же объект, будут разделять один счётчик ссылок.
  4. Разыменование и оператор стрелка для доступа к объекту, на который указывает указатель.
  5. Полный набор конструкторов и операторов присваивания.
  6. Раздленеие объекта, на который указывает указатель, и объекта, для которого ведётся подсчёт ссылок (aliasing constructor): поскольку наши библиотеки работают с документами, у нас часто бывает ситуация, когда указатель на элемент документа должен держать живым весь документ.
  7. Полный набор кастов.
  8. Полный набор операций сравнения.
  9. Присваивание и удаление указателей работают на неполных типах.
  10. Набор методов для проверки и изменения состояния указателя (режим псевдонима, режим хранения ссылки, число ссылок на объект и т. д.).


Класс SmartPtr является шаблонным и не содержит виртуальных методов. Он тесно связан с классом System::Object, который осуществляет хранение счётчика ссылок, и работает исключительно с его дочерними классами.

Существуют отступления от типового поведения указателей:

  1. Перемещение (конструктор перемещения, перемещающий оператор присваивания) изменяет не всё состояние, сохраняя тип ссылки (слабая/сильная).
  2. Доступ к объекту по слабой ссылке не требует локинга (создания временной сильной ссылки), так как подход, при котором оператор стрелка возвращает временный объект, слишком сильно просаживает производительность на сильных ссылках.


Для сохранения работоспособности старого кода тип SharedPtr стал псевдонимом SmartPtr. Класс WeakPtr теперь наследуется от SmartPtr, не добавляя каких-либо полей, и лишь переопределяет конструкторы, всегда создавая слабые ссылки.

Контейнеры теперь всегда портируются в семантике MyContainer<SmartPtr<MyClass>>, а тип хранимых ссылок выбирается в рантайме. Для контейнеров, написанных вручную на базе структур данных из STL (в первую очередь, контейнеров из пространства имён System), тип ссылки по умолчанию задаётся при помощи кастомного аллокатора, при этом для отдельных элементов контейнера остаётся возможность изменения режима. Для портированных контейнеров необходимый код переключения режима хранения ссылок генерируется портером.

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

Подготовка кода к портированию



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

struct S {    MyClass s; // Сильная ссылка на объёкт    [CppWeakPtr] MyClass w; // Слабая ссылка на объект    MyContainer<MyClass> s_s; // Сильная ссылка на контейнер сильных ссылок    [CppWeakPtr] MyContainer<MyClass> w_s; // Слабая ссылка на контейнер сильных ссылок    [CppWeakPtr(0)] MyContainer<MyClass> s_w; // Сильная ссылка на контейнер слабых ссылок    [CppWeakPtr(1)] Dictionary<MyClass, MyClass> s_s_w; // Сильная ссылка на контейнер, в котором ключи хранятся по сильным ссылкам, а значения - по слабым    [CppWeakPtr, CppWeakPtr(0)] Dictionary<MyClass, MyClass> w_w_s; // Слабая ссылка на контейнер, в котором ключи хранятся по слабым ссылкам, а значения - по сильным}


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

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

    class Service {    public static void SetWeak<T>(T arg) {}}
    

    class Service {public:    template <typename T> static void SetWeak<T>(SmartPtr<T> &arg)    {        arg.set_Mode(SmartPtrMode::Weak);    }};
    
  2. Мы можем размещать в коде C# специальным образом оформленные комментарии, которые портер преобразует в код C++:

    class MyClass {    private Dictionary<string, object> data;    public void Add(string key, object value)    {        data.Add(key, value);        //CPPCODE: if (key == u"Parent") data->data()[key].set_Mode(SmartPtrMode::Weak);    }}
    


    Здесь метод data() в System::Collections::Generic::Dictionary возвращает ссылку на std::unordered_map, лежащую в основе данного контейнета.


Проблемы



Теперь поговорим о проблемах, относящихся к работе с памятью в нашем проекте.

Циклические сильные ссылки



class Document {    private Element root;    public Document()    {        root = new Element(this);    }}class Element {    private Document owner;    public Element(Document doc)    {        owner = doc;    }}


Этот код портируется в следующий:

class Document : public Object {    SharedPtr<Element> root;public:    Document()    {        root = MakeObject<Element>(this);    }}class Element {    SharedPtr<Document> owner;public:    Element(SharedPtr<Document> doc)    {        owner = doc;    }}


Цепочка сильных ссылок не позволяет удалить объекты Document и Element после их создания. Это решается установкой атрибута CppWeakPtr на поле Element.owner.

class Document {    private Element root;    public Document()    {        root = new Element(this);    }}class Element {    [CppWeakPtr] private Document owner;    public Element(Document doc)    {        owner = doc;    }}


class Document : public Object {    SharedPtr<Element> root;public:    Document()    {        root = MakeObject<Element>(this);    }}class Element {    WeakPtr<Document> owner;public:    Element(SharedPtr<Document> doc)    {        owner = doc;    }}


Удаление объекта на этапе создания



class Document {    private Element root;    public Document()    {        root = new Element(this);    }    public void Prepare(Element elm)    {        ...    }}class Element {    public Element(Document doc)    {        doc.Prepare(this);    }}


На выходе портера получаем:

class Document : public Object {    SharedPtr<Element> root;public:    Document()    {        ThisProtector guard(this);        root = MakeObject<Element>(this);    }    void Prepare(SharedPtr<Element> elm)    {        ...    }}class Element {public:    Element(SharedPtr<Document> doc)    {        ThisProtector guard(this);        doc->Prepare(this);    }}


При входе в метод Document::Prepare создаётся временный объект SharedPtr, который затем может удалить недоконструированный объект Element, так как на него не остаётся сильных ссылок. Как было показано выше, эта проблема решается добавлением локальной переменной ThisProtector guard в код конструктора Element. Портер делает это автоматически. Объект guard в своём конструкторе увеличивает число сильных ссылок на this на единицу, а в деструкторе опять уменьшает, не производя удаление объекта.

Двойное удаление объекта при выбросе исключения конструктором



class Document {    private Element root;    public Document()    {        root = new Element(this);        throw new Exception("Failed to construct Document object");    }}class Element {    private Document owner;    public Element(Document doc)    {        owner = doc;    }}


После портирования получаем:

class Document : public Object {    SharedPtr<Element> root;public:    Document()    {        ThisProtector guard(this);        root = MakeObject<Element>(this);        throw Exception(u"Failed to construct Document object");    }}class Element {    SharedPtr<Document> owner;public:    Element(SharedPtr<Document> doc)    {        ThisProtector guard(this);        owner = doc;    }}


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

Установка атрибута CppWeakPtr на поле Element.owner решает эту проблему, однако до того, как атрибуты будут расставлены, отладка таких приложений затруднена из-за непредсказуемых завершений. Для упрощения поиска проблем существует особый отладочный режим сборки нашего кода, в котором внутриобъектный счётчик ссылок переносится на кучу, дополняясь флагом, выставляемым лишь после того, как объект будет доконструирован (на уровне функции MakeObject после выхода из конструктора объекта). Если указатель уничтожается до выставления флага, удаление объекта не производится.

Удаление цепочек объектов



class Node {    public Node next;}


class Node : public Object {public:    SharedPtr<Node> next;}


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

Поиск циклических ссылок



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

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

Глобальный реестр содержит список объектов, существующих в данный момент. Конструктор класса System::Object помещает ссылку на текущий объект в данный реестр, а деструктор удаляет. Разумеется, реестр существует лишь в специальном отладочном режиме сборки, чтобы не влиять на производительность портированного кода в пользовательском режиме.

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

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

  1. При вызове соответствующей функции в файл сохраняется полный граф существующих на данный момент объектов, включая информацию о типах, полях и связях. Этот граф может затем быть визуализирован при помощи утилиты graphviz. Как правило, данный файл создаётся после каждого теста, чтобы было удобно отслеживать утечки.
  2. При вызове соответствующей функции в файл сохраняется граф существующих на данный момент объектов, между которыми существуют циклические связи (все ссылки которых являются сильными). Таким образом, граф содержит лишь значащую информацию. Объекты, которые уже были проанализированы, исключаются из анализа при следующем вызове данной функции. Таким образом, видеть, что именно утекло из конкретного теста, становится гораздо проще.
  3. При вызове соответствующей функции в консоль выводится информация о существующих на данный момент островах изоляции наборах объектов, все ссылки на которые находятся в полях других объектов набора. Объекты, на которые ссылаются статические либо локальные переменные, не попадают в данный вывод. Информация о каждом типе острова изоляции (о наборе классов, создающих типовой остров) выводится только один раз.
  4. Деструктор класса SharedPtr проходит по ссылкам между объектами, начиная с объекта, временем жизни которого он управляет, и выводит информацию обо всех найденных циклах (обо всех случаях, когда от текущего объекта по сильным связям можно дойти до него же).


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

Резюме



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

Трансляция кода с C на C работа портера

14.04.2021 14:22:42 | Автор: admin

Привет, Хабр. Некоторое время назад ярассказывало том, как нам удалось наладить ежемесячный выпуск релизов для платформы C++ (на Windows и Linux) библиотек, исходный код которых получается путём автоматической трансляции кода оригинальных продуктов, написанных на C#. Также яписало том, как мы заставили такой транспилированный код выполняться в рамках нативного C++ без сборки мусора.

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


Поколения фреймворка

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

На сегодняшний день наш стек технологий включает в себя следующие продукты:

  1. Самый первый портер с C# на Java на основе текстового процессора - устарел, более не используется.

  2. Портер с C# на Java на основе синтаксического анализатора Metaspec - актуален.

  3. Портер с C# на C++ на основе синтаксического анализатора NRefactory - актуален.

  4. Построенный на Roslyn и рефлексии генератор модулей Python, являющихся обёртками над машиной .Net, в которой выполняются оригинальные продукты на C# - актуален.

  5. Портеры с C# на Java и C++ на основе общего фреймворка, построенного на Roslyn - в разработке.

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

  1. Подготовка кода C# к портированию существующими портерами - например, понижение версии языка до той, которая поддерживается синтаксическими анализаторами Metaspec (3.0) и/или NRefactory (5.0).

  2. Анализ кода C# на удовлетворение требованиям, накладываемым процедурами портирования.

  3. Трансляция аспектов кода C#, плохо покрываемых существующими портерами (документация, примеры использования и т. д.).

Архитектура

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

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

Когда такого подхода перестало хватать, была начата разработка первого портера на базе синтаксического анализатора Metaspec. В этой реализации код исходного проекта на C# загружается анализатором и преобразуется в AST-представление (отдельное дерево для каждого файла с исходным кодом). Кроме того, Metaspec строит семантическую модель, позволяющую разрешать имена типов и обращения к членам. Используя эти две модели, портер выполняет две стадии: анализ кода C# и генерацию кода Java. Стадия анализа нужна для поиска нетранслируемых конструкций и вывода соответствующих ошибок и предупреждений, а также для сбора дополнительной информации, влияющей на кодогенерацию. На стадии кодогенерации происходит непосредственное формирование выходного кода.

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

Архитектура портера с C# на C++, построенного на базе синтаксического анализатора NRefactory несколькими годами позже, во многом подобна описанной выше. После загрузки кода в AST-представление и построения семантической модели по дереву совершается несколько проходов посетителями для сбора предварительной информации, после чего генерируется код C++ - опять же, в один проход. Дерево кода C# остаётся неизменным и в этой модели. Отличия касаются, прежде всего, декомпозиции кода и разделения обязанностей на этапе кодогенерации, хотя полностью изолировать алгоритмы и избавиться от божественных объектов не удалось и на этой итерации.

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

  1. Был ли данный класс исключён из портирования атрибутом или указанием его имени в соответствующем разделе конфигурационного файла.

  2. Является ли данный класс обобщённым типом.

  3. Если да, существуют ли другие классы с тем же полным именем, но другим набором параметров типа (перегрузка по числу аргументов шаблона в C++ не поддерживается, в отличие от C#).

  4. Заданы ли для класса или его членов атрибуты, влияющие на порядок членов класса в генерируемом коде.

  5. Заданы ли для класса или его членов атрибуты, либо заданы ли глобальные опции, влекущие изменение области видимости членов класса.

  6. Является ли хоть один из классов, внешних по отношению к текущему, обобщённым.

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

  8. Является ли класс коллекцией тестов (TestFixture или Theory).

  9. Является ли класс абстрактным.

  10. Заданы ли для класса атрибуты, влекущие его переименование в выходном коде.

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

  12. Заданы ли для обобщённых параметров класса ограничения.

  13. Является ли класс наследником System.Exception.

  14. Удовлетворены ли условия для добавления к классу конструкторов или деструктора, отсутствующих в исходном коде.

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

  16. Относится ли класс к цепочкам наследования, для которых в коде присутствуют вызовы Clone() или MemberwiseClone(), которые нужно эмулировать отдельно.

  17. Существуют ли условия для добавления к методам выходного класса перегрузок, отсутствующих в исходном классе.

  18. Зависят ли инициализаторы констант класса друг от друга.

  19. Включена ли для данного класса (или для всех классов) поддержка рефлексии.

  20. Есть ли у класса комментарий, является ли он описанием в формате XML, и какие опции заданы для его обработки.

  21. Прочие условия.

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

Архитектура общего движка, на базе которого в настоящее время пишутся портеры с C# на Java и на C++, была пересмотрена с учётом данной проблемы, а также дополнительных возможностей, которые даёт Roslyn. Дерево кода в этой структуре более не является неизменным и последовательно модифицируется различными алгоритмами на новой стадии - стадии трансформации. Данные алгоритмы пишутся в соответствии с правилом единственной обязанности. На стадии трансформации выполняется столько работы, сколько возможно: синтаксический сахар C# заменяется конструкциями, доступными в C++ и Java, производится переименование типов и членов, удаляются сущности, исключённые из портирования, изменяются области видимости, модифицируются списки базовых типов, и так далее. В итоге логика кодогенерации существенно упрощается. С другой стороны, появляются дополнительные накладные расходы по управлению доступными алгоритмами модификации дерева и очерёдностью их выполнения.

Стадия анализа слилась со стадией трансформации. Алгоритмы стадии кодогенерации подверглись дополнительной декомпозиции: теперь за обработку отдельного типа узла отвечает, как правило, отдельный класс. Кроме того, было сделано большое количество других полезных изменений: улучшена подсистема конфигурирования, пересмотрен механизм замены типов C# типами целевых языков, поддержана работа не только в виде приложения (командной строки или оконного), но и в виде плагина для Visual Studio, работающего непосредственно с загруженным решением и встроенными средствами диагностики, и так далее.

Операции над исходным кодом

На первый взгляд может показаться, что у портера может быть лишь один способ использования: подав ему на вход код C#, мы ожидаем получить на выходе эквивалентный код C++. Действительно, такой способ является наиболее распространённым, однако далеко не единственным. Ниже перечислены другие режимы, предоставляемые фреймворками для портирования кода и связанными утилитами.

  1. Анализ кода C# на портируемость.
    Продукты разрабатываются программистами, редко знающими в подробностях процедуру портирования кода на другие языки и связанные с ней ограничения. В результате возникают ситуации, когда корректные с точки зрения C# изменения, сделанные продуктовыми разработчиками, ломают процедуру выпуска релизов для других языков. Например, на сегодняшний день ни один из наших портеров не имеет поддержки оператора yield, и его использование в коде C# приведёт к генерации некорректного кода Java или C++.
    За время развития проекта нами были испробованы несколько способов автоматизации обнаружения таких проблем.

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

    2. Проблема может быть обнаружена в среде CI (мы используем Jenkins и SonarQube). Таким образом, о проблеме узнают разработчики C# перед слиянием в общую ветку или после такого слияния, в зависимости от принятых конкретной командой практик. Это увеличивает оперативность исправления проблем, но требует программирования дополнительных проверок в инфраструктуре портера или в сторонних утилитах.

    3. Проблема может быть обнаружена локально разработчиком C# при запуске специализированных инструментов - например, портера в режиме анализатора. Это удобно, но требует разработки дополнительных утилит и дисциплины. Кроме того, это скрывает информацию о том, была ли проверка на самом деле запущена и пройдена.

    4. Проблема может быть обнаружена локально при работе в IDE. Установка плагина к Visual Studio позволяет разработчику C# обнаруживать проблемы в реальном времени. Это по-прежнему требует дополнительных затрат на разработку экосистемы, зато предоставляет наиболее оперативный способ обнаружения проблем. В этом смысле интеграция Roslyn в современные версии Visual Studio особенно удобна, так как позволяет использовать одни и те же анализаторы как в контексте загруженного в данный момент решения, так и в ином окружении - например, в среде CI.

  2. Понижение версии языка C#.
    Как уже говорилось выше, мы ограничены в использовании версий языка C#: 3.0 для портирования на Java и 5.0 для портирования на C++. Это требует дисциплины от программистов C# и во многих случаях неудобно. Чтобы обойти эти ограничения, портирование можно провести в два этапа: сначала заменить конструкции современных версий языка C# поддерживаемыми аналогами из прошлых стандартов, затем приступить непосредственно к портированию.
    При использовании портеров, основанных на устаревших синтаксических анализаторах, понижение может быть выполнено только путём использования внешних инструментов (например, утилит, написанных на базе Roslyn). С другой стороны, портеры, основанные на Roslyn, выполняют оба этапа последовательно, что позволяет использовать один и тот же код как при портировании кода ими, так и при подготовке кода к портированию более старыми инструментами.

  3. Подготовка примеров использования портированных библиотек.
    Это похоже на портирование кода продуктов, однако подразумевает несколько иные требования. При портировании библиотеки на десятки миллионов строк важно, прежде всего, максимально строгое следование поведению оригинального кода даже в ущерб читаемости: более простой, но отличающийся по эффектам код отлаживать придётся дольше. С другой стороны, примеры использования нашего портированного кода должны выглядеть максимально просто, давая понять, как пользоваться нашим кодом в C++, даже если это не соответствует поведению оригинальных примеров, написанных на C#.
    Так, при создании временных объектов программисты C# часто пользуютсяusing statement, чтобы избежать утечки ресурсов и строго задать момент их высвобождения, не полагаясь на GC. Строгое портирование using даёт достаточно сложный код C++ (см. ниже) из-за множества нюансов вида "если в блоке using statement вылетает исключение и из Dispose тоже вылетает исключение, какое из них попадёт в перехватывающий контекст?". Такой код лишь введёт в заблуждение программиста C++, создав впечатление, что использовать библиотеку сложно, однако на самом деле умного указателя на стеке, в нужный момент удаляющего объект и высвобождающего ресурсы, вполне достаточно.

  4. Подготовка документации к коду.
    Наши библиотеки предоставляют богатый API, задокументированный через XML-комментарии в соответствии с практиками C#. Перенос комментариев в C++ (мы используем Doxygen) - задача отнюдь не тривиальная: помимо разметки, необходимо заменить ссылки на типы (в C# полные имена записываются через точку, в C++ - через пару двоеточий) и их члены (а в случае использования свойств - ещё и понять, идёт ли речь о геттере или сеттере), а также оттранслировать фрагменты кода (которые лишены семантики и могут быть неполными).
    Эта задача решается как средствами самого портера, так и внешними утилитами - например, анализирующими сгенерированную XML-документацию и дополнительно подготовленные фрагменты вроде примеров использования методов.

Правила трансляции кода с C# на C++

Поговорим о том, каким образом синтаксические конструкции языка C# отображаются на C++. Эти правила не зависят от поколения продукта, поскольку иное существенно затруднило бы процесс миграции. Данный список не будет исчерпывающим ввиду ограниченного объёма статьи, но я постараюсь уделить внимание хотя бы основным моментам.

Проекты и единицы компиляции

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

Один проект C# преобразуется в один или два проекта C++. Первый проект (приложение или библиотека) аналогичен проекту C#, второй представляет собой googletest-приложение для запуска тестов (если они присутствуют в исходном проекте). Тип выходной библиотеки (статическая или динамическая) задаётся опциями портера. Для каждого входного проекта портер генерирует файл CMakeLists.txt, который позволяет создавать проекты для большинства сборочных систем. Зависимости между оттранслированными проектами настраиваются вручную в конфигурации портера или скриптах Cmake.

В большинстве случаев одному файлу .cs соответствует один файл .h и один файл .cpp. Имена файлов по возможности сохраняются (хотя из-за особенностей некоторых сборочных систем для C++ портер старается не допускать присутствия файлов с одинаковыми именами, пусть и в разных каталогах). Обычно определения типов попадают в заголовочный файл, а определения методов - в файл исходного кода, но это не так для шаблонных типов, весь код которых остаётся в заголовочных файлах. Файлы .cpp, в которые не попадает никакого кода, опускаются за ненадобностью.

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

Кроме файлов с кодом, полученных трансляцией оригинального кода C#, портер создаёт дополнительные файлы, содержащие некоторый сервисный код. Это включает в себя, в частности, проверку того, что версия и опции компиляции системной библиотеки, использованной при сборке выходного проекта, совпадают с таковыми у библиотеки, фактически доступной в момент выполнения, что позволяет избежать некоторых трудноотлаживаемых багов. Другие проверки касаются, например, того, что при удалении информации из публичных заголовочных файлов у нас не изменились размеры классов.

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

Общая структура исходного кода

Пространства имён C# отображаются в пространства имён C++. Операторы использования пространств имён превращаются в аналоги из C++, по умолчанию попадая лишь в файлы .cpp (если опциями портирования не задано иное). Комментарии переносятся как есть, кроме документации к типам и методам, обрабатываемой отдельно. Форматирование сохраняется частично. Директивы препроцессора не переносятся (максимум - добавляются соответствующие комментарии), поскольку при построении синтаксического дерева необходимо уже задать все константы.

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

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

Определения типов

Псевдонимы типов транслируются с использованием синтаксиса "using <typename> = ...". Перечисления C# транслируются в перечисления C++14 (синтаксис enum class).

Делегаты преобразуются в псевдонимы для специализаций класса System::MulticastDelegate:

public delegate int IntIntDlg(int n);
using IntIntDlg = System::MulticastDelegate<int32_t(int32_t)>;

Классы и структуры C# отображаются на классы C++. Интерфейсы превращаются в абстрактные классы. Структура наследования соответствует таковой в C# (неявное наследование от System.Object становится явным), если атрибутами не задано иное (например, для создания компактной структуры данных без лишних наследований и виртуальных функций). Свойства и индексаторы разбиваются на геттеры и сеттеры, представленные отдельными методами.

Виртуальные функции C# отображаются на виртуальные функции C++. Реализация интерфейсов также производится с использованием механизма виртуальных функций. Обобщённые (generic) типы и методы превращаются в шаблоны C++. Финализаторы переходят в деструкторы. Всё это вместе задаёт несколько ограничений:

  1. Трансляция виртуальных обобщённых методов не поддерживается.

  2. Реализация интерфейсных методов виртуальна, даже если в исходном коде это не так.

  3. Введение новых (new) методов с именами и сигнатурами, повторяющими имена и сигнатуры существующих виртуальных и/или интерфейсных методов, невозможно (но портер позволяет переименовывать такие методы).

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

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

Понятно, что строгая имитация поведения C# требовала бы несколько иного подхода, и, если бы речь шла о трансляции приложений, это было бы оправдано. Тем не менее, мы предпочли следовать именно такой логике, поскольку в этом случае API портированных библиотек в наиболее полной мере соответствует парадигмам C++. Приведённый ниже пример демонстрирует эти особенности.

Код C#:

using System;public class Base{    public virtual void Foo1()    { }    public void Bar()    { }}public interface IFoo{    void Foo1();    void Foo2();    void Foo3();}public interface IBar{    void Bar();}public class Child : Base, IFoo, IBar{    public void Foo2()    { }    public virtual void Foo3()    { }    public T Bazz<T>(object o) where T : class    {        if (o is T)            return (T)o;        else            return default(T);    }}

Заголовочный файл C++:

#pragma once#include <system/object_ext.h>#include <system/exceptions.h>#include <system/default.h>#include <system/constraints.h>class Base : public virtual System::Object{    typedef Base ThisType;    typedef System::Object BaseType;        typedef ::System::BaseTypesInfo<BaseType> ThisTypeBaseTypesInfo;    RTTI_INFO_DECL();    public:    virtual void Foo1();    void Bar();    };class IFoo : public virtual System::Object{    typedef IFoo ThisType;    typedef System::Object BaseType;        typedef ::System::BaseTypesInfo<BaseType> ThisTypeBaseTypesInfo;    RTTI_INFO_DECL();    public:    virtual void Foo1() = 0;    virtual void Foo2() = 0;    virtual void Foo3() = 0;    };class IBar : public virtual System::Object{    typedef IBar ThisType;    typedef System::Object BaseType;        typedef ::System::BaseTypesInfo<BaseType> ThisTypeBaseTypesInfo;    RTTI_INFO_DECL();    public:    virtual void Bar() = 0;    };class Child : public Base, public IFoo, public IBar{    typedef Child ThisType;    typedef Base BaseType;    typedef IFoo BaseType1;    typedef IBar BaseType2;        typedef ::System::BaseTypesInfo<BaseType, BaseType1, BaseType2> ThisTypeBaseTypesInfo;    RTTI_INFO_DECL();    public:    void Foo1() override;    void Bar() override;    void Foo2() override;    void Foo3() override;    template <typename T>    T Bazz(System::SharedPtr<System::Object> o)    {        assert_is_cs_class(T);                if (System::ObjectExt::Is<T>(o))        {            return System::StaticCast<typename T::Pointee_>(o);        }        else        {            return System::Default<T>();        }    }    };

Исходный код C++:

#include "Class1.h"RTTI_INFO_IMPL_HASH(788057553u, ::Base, ThisTypeBaseTypesInfo);void Base::Foo1(){}void Base::Bar(){}RTTI_INFO_IMPL_HASH(1733877629u, ::IFoo, ThisTypeBaseTypesInfo);RTTI_INFO_IMPL_HASH(1699913226u, ::IBar, ThisTypeBaseTypesInfo);RTTI_INFO_IMPL_HASH(3787596220u, ::Child, ThisTypeBaseTypesInfo);void Child::Foo1(){    Base::Foo1();}void Child::Bar(){    Base::Bar();}void Child::Foo2(){}void Child::Foo3(){}

Серия псевдонимов и макросов в начале каждого портированного класса нужна для эмуляции некоторых механизмов C# (прежде всего, GetType, typeof и is). Хэш-коды из файла .cpp используются для быстрого сравнения типов. Все функции, реализующие интерфейсы, виртуальны, хотя в C# это не так.

Члены классов

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

Экземплярные поля C# становятся экземплярными полями C++. Статические поля также остаются без изменений (кроме случаев, когда важен порядок инициализации - это исправляется портированием таких полей в виде синглтонов).

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

События транслируются в поля (экземплярные или статические), тип которых соответствует нужной специализации System::Event. Трансляция в виде трёх методов (add, remove и invoke) была бы более правильной и, к тому же, позволила бы поддержать абстрактные и виртуальные события. Возможно, в будущем мы придём к такой модели, однако на данный момент вариант с классом Event полностью покрывает потребности нашего кода.

Методы расширения и операторы транслируются в статические методы и вызываются явно. Финализаторы становятся деструкторами.

Следующий пример иллюстрирует описанные выше правила. Как обычно, я удалил незначащую часть кода C++.

public abstract class Generic<T>{    private T m_value;    public Generic(T value)    {        m_value = value;    }    ~Generic()    {        m_value = default(T);    }    public string Property { get; set; }    public abstract int Property2 { get; }    public T this[int index]    {        get        {            return index == 0 ? m_value : default(T);        }        set        {            if (index == 0)                m_value = value;            else                throw new ArgumentException();        }    }    public event Action<int, int> IntIntEvent;}
template<typename T>class Generic : public System::Object{public:    System::String get_Property()    {        return pr_Property;    }    void set_Property(System::String value)    {        pr_Property = value;    }        virtual int32_t get_Property2() = 0;        Generic(T value) : m_value(T())    {        m_value = value;    }        T idx_get(int32_t index)    {        return index == 0 ? m_value : System::Default<T>();    }    void idx_set(int32_t index, T value)    {        if (index == 0)        {            m_value = value;        }        else        {            throw System::ArgumentException();        }    }        System::Event<void(int32_t, int32_t)> IntIntEvent;        virtual ~Generic()    {        m_value = System::Default<T>();    }private:    T m_value;    System::String pr_Property;};

Переменные и поля

Константные и статические поля транслируются в статические поля, статические константы (в некоторых случаях - constexpr) либо в статические методы (дающие доступ к синглтону). Экземплярные поля C# преобразуются в экземплярные поля C++, при этом все сколько-нибудь сложные инициализаторы переносятся в конструкторы (иногда для этого приходится явно добавлять конструкторы по умолчанию там, где их не было в C#). Переменные на стеке переносятся как есть. Аргументы методов - тоже, за исключением того, что и ref-, и out-аргументы становятся ссылочными (благо, IL их всё равно не различает, и потому перегрузка по ним запрещена).

Типы полей и переменных заменяются их аналогами из C++. В большинстве случаев такие аналоги генерируются самим портером. Библиотечные (дотнетовские и некоторые другие) типы написаны нами на C++ в составе библиотеки, поставляемой вместе с портированными продуктами. var портируется в auto, кроме случаев, когда явное указание типа нужно, чтобы сгладить разницу в поведении.

Кроме того, ссылочные типы оборачиваются в SmartPtr (ранее яписало том, что он по большей части следует семантике intrusive_ptr, но позволяет переключать режим ссылки - слабая или сильная - во время выполнения). Значимые типы подставляются как есть. Поскольку аргументы-типы могут быть как значимыми, так и ссылочными, они также подставляются как есть, но при инстанциировании ссылочные аргументы оборачиваются в SharedPtr (таким образом,List<int>транслируется какList<int32_t>, ноList<Object>становитсяList<SmartPtr<Object>>. В некоторых исключительных случаях ссылочные типы портируются как значимые (например, наша реализация System::String написана на базе типа UnicodeString из ICU и оптимизирована для хранения на стеке).

Для примера портируем следующий класс:

public class Variables{    public int m_int;    private string m_string = new StringBuilder().Append("foobazz").ToString();    private Regex m_regex = new Regex("foo|bar");    public object Foo(int a, out int b)    {        b = a + m_int;        return m_regex.Match(m_string);    }}

После портирования он принимает следующий вид (я удалил код, не относящийся к делу):

class Variables : public System::Object{public:    int32_t m_int;    System::SharedPtr<System::Object> Foo(int32_t a, int32_t& b);    Variables();private:    System::String m_string;    System::SharedPtr<System::Text::RegularExpressions::Regex> m_regex;};System::SharedPtr<System::Object> Variables::Foo(int32_t a, int32_t& b){    b = a + m_int;    return m_regex->Match(m_string);}Variables::Variables()    : m_int(0)    , m_regex(System::MakeObject<System::Text::RegularExpressions::Regex>(u"foo|bar")){    this->m_string = System::MakeObject<System::Text::StringBuilder>()->        Append(u"foobazz")->ToString();}

Управляющие структуры

Подобие основных управляющих структур сыграло нам на руку. Такие операторы, как if, else, switch, while, do-while, for, try-catch, return, break и continue в большинстве случаев переносятся как есть. Исключением в данном списке является разве что switch, требующий пары специальных обработок. Во-первых, C# допускает его использование со строковым типом - в C++ мы в этом случае генерируем последовательность if-else if. Во-вторых, относительно недавно добавилась возможность сопоставлять проверяемое выражение шаблону типа - что, впрочем, также легко разворачивается в последовательность ifов.

Интерес представляют конструкции, которых нет в C++. Так, оператор using даёт гарантию вызова метода Dispose() при выходе из контекста - в C++ мы эмулируем это поведение, создавая объект-часового на стеке, который вызывает нужный метод в своём деструкторе. Перед этим, правда, нужно перехватить исключение, вылетевшее из кода, бывшего телом using, и сохранить exception_ptr в поле часового - если Dispose() не бросит своё исключение, будет переброшено то, которое мы сохранили. Это как раз тот редкий случай, когда вылет исключения из деструктора оправдан и не является ошибкой. Блок finally транслируется по похожей схеме, только вместо метода Dispose() вызывается лямбда-функция, в которую портер обернул его тело.

Ещё один оператор, которого нет в C# и который мы вынуждены эмулировать, - это foreach. Изначально мы портировали его в эквивалентный while(), вызывающий метод MoveNext() у перечислителя, что универсально, но довольно медленно. Поскольку в большинстве своём плюсовые реализации контейнеров из .Net используют структуры данных STL, мы пришли к тому, чтобы там, где это возможно, использовать их оригинальные итераторы, конвертируя foreach в range-based for. В тех случаях, когда оригинальные итераторы недоступны (например, контейнер реализован на чистом C#), используются итераторы-обёртки, внутри себя работающие с перечислителями. Раньше за выбор нужного способа итерации отвечала внешняя функция, написанная с использованием техники SFINAE, сейчас мы близки к тому, чтобы иметь правильные версии методов begin-end во всех контейнерах (в т. ч. портированных).

Операторы unsafe и unchecked в нашем коде практически не используются, так что их портер попросту игнорирует.

Операторы

Как и в случае с управляющими структурами, большинство операторов (по крайней мере, арифметических, логических и присваивания) не требуют особой обработки. Тут, правда, есть тонкий момент: в C# порядок вычисления частей выражения детерминирован, тогда как в C++ в некоторых случаях возникает неопределённое поведение. Например, следующий портированный код ведёт себя неодинаково после компиляции разными инструментами:

auto offset32 = block[i++] + block[i++] * 256 + block[i++] * 256 * 256 +    block[i++] * 256 * 256 * 256;

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

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

obj1.Property = obj2.Property;string s = GetObj().Property += "suffix";
obj1->set_Property(obj2->get_Property());System::String s = System::setter_add_wrap(static_cast<MyClass*>(GetObj().GetPointer()),    &MyClass::get_Property, &MyClass::set_Property, u"suffix")

В первой строке замена оказалась тривиальной. Во второй пришлось использовать обёртку setter_add_wrap, гарантирующую, что функция GetObj() будет вызвана всего один раз, а результат конкатенации вызова get_Property() и строкового литерала будет передан не только в метод set_Property() (который возвращает void), но и далее для использования в выражении. Тот же подход применяются при обращении к индексаторам.

Операторы C#, которых нет в C++ (as, is, typeof, default, ??, ?., и так далее), эмулируются при помощи библиотечных функций. В тех случаях, когда требуется избежать двойного вычисления аргументов (например, чтобы не разворачивать "GetObj()?.Invoke()" в "GetObj() ? GetObj().Invoke() : nullptr)", используется подход, подобный показанному выше.

Оператор доступа к члену (.) в зависимости от контекста может заменяться на аналог из C++: на оператор разрешения области видимости (::) или на "стрелку" (->). При доступе к членам структур такая замена не требуется.

Исключения

Эмуляция поведения C# в аспекте работы с исключениями является весьма нетривиальной. Дело в том, что в C# и в C++ исключения ведут себя по-разному:

  • В C# исключения создаются на куче и удаляются сборщиком мусора.

  • В C++ исключения в разные моменты копируются между стеком и выделенной для них областью памяти.

Здесь возникает противоречие. Если транслировать типы исключений C# как ссылочные, работая с ними по голым указателям (throw new ArgumentException), это приведёт к утечкам памяти (или большим проблемам с определением точек их удаления). Если транслировать их как ссылочные, но владеть ими по умному указателю (throw SharedPtr<ArgumentException>(MakeObject<ArgumentException>())), исключение будет невозможно перехватить по его базовому типу (потому что SharedPtr<ArgumentException> не наследует SharedPtr<Exception>). Если же размещать объекты исключений на стеке, они будут корректно перехватываться по базовому типу, но при сохранении в переменную базового типа информация о конечном типе будет усекаться (к сожалению, у нас есть даже код, хранящий коллекции исключений, так что это не пустая тревога).

Для решения этой проблемы мы создали специальный тип умных указателей ExceptionWrapper. Его ключевая особенность заключается в том, что, если класс ArgumentException наследуется от Exception, то и ExceptionWrapper<ArgumentException> наследуется от ExceptionWrapper<Exception>. Экземпляры ExceptionWrapper используются для управления временем жизни экземпляров классов исключений, при этом усечение типа ExceptionWrapper не приводит к усечению типа связанного Exception. За выброс исключений отвечает виртуальный метод, переопределяемый наследниками Exception, который создаёт ExceptionWrapper, параметризованный конечным типом исключения, и выбрасывает его. Виртуальность позволяет выбросить правильный тип исключения, даже если тип ExceptionWrapper был усечён ранее, а связь между объектом исключения и ExceptionWrapper предотвращает утечку памяти.

Создание объектов и инициализация

Для создания объектов ссылочных типов, кроме нескольких специальных случаев, мы используем функцию MakeObject (аналог std::make_shared), которая создаёт объект оператором new и сразу оборачивает его в SharedPtr. Кроме того, MakeObject инкапсулирует некую сервисную логику. Использование этой функции позволило избежать проблем, привносимых голыми указателями, однако породило проблему прав доступа: поскольку она находится вне всех классов, она не имела доступа к закрытым конструкторам, даже будучи вызванной из самих классов или их друзей. Объявление этой функции в качестве друга классов с непубличными конструкторами эффективно открывало эти конструкторы для всех контекстов. В результате внешняя версия этой функции была ограничена использованием с публичными конструкторами, а для непубличных конструкторов были добавлены статические методы MakeObject, имеющие тот же уровень доступа и те же аргументы, что и проксируемый конструктор.

Литералы часто приходится менять при портировании: так, @"C:\Users" превращается в u"C:\\Users", а 15L - в INT64_C(15).

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

Foo(new MyClass() { Property1 = "abc", Property2 = 1, Field1 = 3.14 });
Foo([&]{ auto tmp_0 = System::MakeObject<MyClass>();        tmp_0->set_Property1(u"abc");        tmp_0->set_Property2(1);        tmp_0->Field1 = 3.14;        return tmp_0;    }());

Вызовы, делегаты и анонимные методы

Вызовы методов переносятся как есть. При наличии перегруженных методов иногда приходится явно приводить типы аргументов, поскольку правила разрешения перегрузок в C++ отличаются от таковых в C#. Рассмотрим, например, следующий код:

class MyClass<T>{    public void Foo(string s) { }    public void Bar(string s) { }    public void Bar(bool b) { }    public void Call()    {        Foo("abc");        Bar("def");    }}

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

template<typename T>class MyClass : public System::Object{public:    void Foo(System::String s)    {        ASPOSE_UNUSED(s);    }    void Bar(System::String s)    {        ASPOSE_UNUSED(s);    }    void Bar(bool b)    {        ASPOSE_UNUSED(b);    }    void Call()    {        Foo(u"abc");        Bar(System::String(u"def"));    }};

Обратите внимание: вызовы методов Foo и Bar внутри метода Call записаны по-разному. Это связано с тем, что без явного вызова конструктора String была бы вызвана перегрузка Bar, принимающая bool, т. к. такое приведение типа имеет более высокий приоритет по правилам C++. В случае метода Foo такой неоднозначности нет, и портер генерирует более простой код.

Ещё один пример, когда C# и C++ ведут себя по-разному - это разворачивание шаблонов. В C# подстановка типов-параметров производится уже в рантайме и не влияют на разрешение вызовов внутри обобщённых методов. В C++ подстановка аргументов шаблонов происходит в момент вызова, так что поведение C# приходится эмулировать. Например, рассмотрим следующий код:

class GenericMethods{    public void Foo<T>(T value) { }    public void Foo(string s) { }    public void Bar<T>(T value)    {        Foo(value);    }    public void Call()    {        Bar("abc");    }}
class GenericMethods : public System::Object{public:    template <typename T>    void Foo(T value)    {        ASPOSE_UNUSED(value);    }    void Foo(System::String s);    template <typename T>    void Bar(T value)    {        Foo<T>(value);    }    void Call();};void GenericMethods::Foo(System::String s){}void GenericMethods::Call(){    Bar<System::String>(u"abc");}

Здесь стоит обратить внимание на явное указание аргументов шаблона при вызове Foo и Bar. В первом случае это необходимо, потому что иначе при инстанциировании версии для T=System::String будет вызвана нешаблонная версия, что отличается от поведения C#. Во втором случае аргумент нужен, поскольку в противном случае он будет выведен на основе типа строкового литерала. Вообще, явно указывать аргументы шаблона портеру приходится почти всегда, чтобы избежать неожиданного поведения.

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

В .Net встречаются методы, которые поддерживают перегрузку по числу и типу аргументов через синтаксис params, через указание object в качестве типа аргумента, либо через то и другое сразу - например, подобные перегрузки есть у StringBuilder.Append() и у Console.WriteLine(). Прямой перенос таких конструкций показывает плохую производительность из-за боксирования и создания временных массивов. В таких случаях мы добавляем перегрузку, принимающую переменное число аргументов произвольных типов с использованием вариативных шаблонов, и заставляем портер транслировать аргументы как есть, без приведений типов и объединений в массивы. В результате удаётся поднять производительность таких вызовов.

Делегаты транслируются в специализации шаблона MulticastDelegate, который, как правило, содержит внутри себя контейнер экземпляров std::function. Их вызов, хранение и присваивание осуществляются тривиально. Анонимные методы превращаются в лямбда-функции.

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

Тесты

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

Программисты C# используют фреймворки NUnit и xUnit. Портер переводит соответствующие тестовые примеры на GoogleTest, заменяя синтаксис проверок и вызывая методы, помеченные флагом Test или Fact, из соответствующих тестовых функций. Поддерживаются как тесты без аргументов, так и входные данные вроде TestCase или TestCaseData. Пример портирования тестового класса приведён ниже.

[TestFixture]class MyTestCase{    [Test]    public void Test1()    {        Assert.AreEqual(2*2, 4);    }    [TestCase("123")]    [TestCase("abc")]    public void Test2(string s)    {        Assert.NotNull(s);    }}
class MyTestCase : public System::Object{public:    void Test1();    void Test2(System::String s);};namespace gtest_test{class MyTestCase : public ::testing::Test{protected:    static System::SharedPtr<::ClassLibrary1::MyTestCase> s_instance;    public:    static void SetUpTestCase()    {        s_instance = System::MakeObject<::ClassLibrary1::MyTestCase>();    };        static void TearDownTestCase()    {        s_instance = nullptr;    };    };System::SharedPtr<::ClassLibrary1::MyTestCase> MyTestCase::s_instance;} // namespace gtest_testvoid MyTestCase::Test1(){    ASSERT_EQ(2 * 2, 4);}namespace gtest_test{TEST_F(MyTestCase, Test1){    s_instance->Test1();}} // namespace gtest_testvoid MyTestCase::Test2(System::String s){    ASSERT_FALSE(System::TestTools::IsNull(s));}namespace gtest_test{using MyTestCase_Test2_Args = System::MethodArgumentTuple<decltype(    &ClassLibrary1::MyTestCase::Test2)>::type;struct MyTestCase_Test2 : public MyTestCase, public ClassLibrary1::MyTestCase,    public ::testing::WithParamInterface<MyTestCase_Test2_Args>{    static std::vector<ParamType> TestCases()    {        return        {            std::make_tuple(u"123"),            std::make_tuple(u"abc"),        };    }};TEST_P(MyTestCase_Test2, Test){    const auto& params = GetParam();    ASSERT_NO_FATAL_FAILURE(s_instance->Test2(std::get<0>(params)));}INSTANTIATE_TEST_SUITE_P(, MyTestCase_Test2,     ::testing::ValuesIn(MyTestCase_Test2::TestCases()));} // namespace gtest_test

Проблемы

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

  1. Синтаксис C# не имеет прямых аналогов на C++. Это относится, например, к операторам using и yeild.
    В таких случаях нам приходится писать довольно сложный код для эмуляции поведения оригинального кода - как в портере, так и в библиотеке - либо отказываться от поддержки таких конструкций.

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

  3. Работа кода C# зависит от окружения, специфичного для .Net. Это включает, например, ресурсы, рефлексию, динамическое подключение сборок и импорт функций.
    В таких случаях нам, как правило, приходится эмулировать соответствующие механизмы. Это включает в себя поддержку ресурсов (которые внедряются в сборку в виде статических массивов и затем читаются через специализированные реализации потоков) и рефлексию. С другой стороны, очевидно, что напрямую подключать сборки .Net к коду C++ или импортировать функции из динамических библиотек Windows при выполнении на другой платформе мы не можем - подобный код приходится урезать либо переписывать.

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

  5. Работа библиотечного кода отличается от работы оригинальных классов из .Net.
    В каких-то случаях речь идёт о простых ошибках в имплементации - как правило, их несложно исправить. Гораздо хуже дело обстоит, когда разница в поведении лежит на уровне подсистем, используемых библиотечным кодом. Например, многие наши библиотеки активно используют классы из библиотеки System.Drawing, построенной на GDI+. Версии этих классов, разработанных нами для C++, используют Skia в качестве графического движка. Поведение Skia зачастую отличается от такового в GDI+, особенно под Linux, и на то, чтобы добиться одинаковой отрисовки, нам приходится тратить значительные ресурсы. Аналогично, libxml2, на которой построена наша реализация System::Xml, ведёт себя в иных случаях не так, и нам приходится патчить её или усложнять свои обёртки.

  6. Портированный код порой работает медленнее оригинала.
    Программисты на C# оптимизируют свой код под те условия, в которых он выполняется. В то же время, многие структуры начинают работать медленнее в необычном для себя окружении. Например, создание большого количества мелких объектов в C# обычно работает быстрее, чем в C++, из-за иной схемы работы кучи (даже с учётом сборки мусора). Динамическое приведение типов в C++ также несколько медленнее. Подсчёт ссылок при копировании указателей - ещё один источник накладных расходов, которых нет в C#. Наконец, использование вместо встроенных, оптимизированных концепций C++ (итераторы) переводных с C# (перечислители) также замедляет работу кода.
    Способ устранения бутылочных горлышек во многом зависит от ситуации. Если библиотечный код сравнительно легко оптимизировать, то сохранить поведение портированных концепций и в то же время оптимизировать их работу в чуждом окружении порой не так-то просто.

  7. Портированный код не соответствует духу C++. Например, в публичном API присутствуют методы, принимающие SharedPtr<Object>, у контейнеров отсутствуют итераторы, методы для работы с потоками принимают System::IO::Stream вместо istream, ostream или iostream, и так далее.
    Мы последовательно расширяем портер и библиотеку таким образом, чтобы нашим кодом было удобно пользоваться программистам C++. Например, портер уже умеет генерировать методы begin-end и перегрузки, работающие со стандартными потоками.

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

Планы развития проекта

Релизы для языка C++, полученные с использованием нашего фреймворка, успешно выпускаются уже несколько лет. Если в начале мы выпускали урезанные версии продуктов, то в настоящее время удаётся поддерживать куда более полную функциональность.

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

Помимо решения текущих проблем и плановых улучшений, мы заняты переводом портеров с C# на Java и C++ на современный синтаксический анализатор (Roslyn). Это небыстрый процесс, ведь количество случаев, которые продукт должен обрабатывать, весьма велико. Мы начинаем с поддержки наиболее общих структур, а затем переходим ко всё более редким случаям. Для этого у нас есть большое количество тестов: тесты на вывод портера, тесты на вывод портированного приложения, тесты в портированных проектах. В какой-то момент происходит переход от специально подготовленных тестов к тестированию на реальных продуктах, содержащих сотни тысяч и даже десятки миллионов строк кода, что неизбежно вскрывает какие-то недоработки.

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

Наконец, мы думаем о том, чтобы замахнуться на расширение числа поддерживаемых языков - как целевых, так и исходных. Адаптировать решения, основанные на Roslyn, для чтения кода VB будет относительно легко - тем более, что библиотеки для C++ и Java уже готовы. С другой стороны, подход, который мы применили для поддержки Python, во многом проще, и по аналогии можно поддержать иные скриптовые языки - например, PHP.

Подробнее..

Категории

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

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