Розробка модульних програм на С/C++ з використанням анотацій

В моїй першої статті я розповів про використання препроцесора для організації модульності на рівні вихідних текстів у мовах С/C++. Коротко цей спосіб зводиться до написання специфічних метаданих всередині вихідного, які аналізуються зовнішнім інструментом і використовуються для генерації glue-джерел, що дозволяють реалізувати модульність. Деталі реалізації описані в згаданій статті, тому не буду тут повторюватися. У даній статті я піду трохи далі і спробую показати, що з допомогою метаданих або анотацій можна реалізувати не тільки модульність, але і деякі інші корисні фічі. Повинно вийти щось на зразок Google Guice або Spring для З (тієї його частини, яка пов'язана з модульність і аспектами). Окремо наголошую, що ця стаття — доповнення і поліпшення першої, тому тут я буду говорити не стільки технічних деталях реалізації, скільки про те, як це все виглядає для користувача. Якщо ця тема викличе інтерес, то я напишу продовження з поясненнями про те, як влаштовано всередині сам додаток-конфігуратор.

Універсальні анотації

Попередній підхід був заснований на використанні директив #pragma в якості способу запису метаданих всередині исходников. Формат метаданих був обраний довільно і загострювався під конкретне застосування (опис зв'язку між попередником і абстрактним модулем, з яким він асоційований). Насправді поняття анотацій кілька більш широко. Зокрема, можна провести паралелі з мовами начебто Java або C#, де анотації можуть описувати довільні твердження про коді, в якому вони містяться, тому було б зручно використовувати деякий універсальний формат.

В якості прикладів існуючих реалізацій такого підходу можна навести штуки типу Keil configuration wizard, які дозволяють описувати у вигляді XML різні змінні всередині файлу (наприклад, якісь константи, розміри масивів тощо). Потім, прямо в редакторі, можна перейти на вкладку configuration і налаштовувати ці опції в графічному вигляді, а не шукати їх усередині файлу. Крім цього, в исходниках часто є й інша інформація, яку можна було б назвати анотаціями (наприклад те, що пишеться всередині __attribute__ GCC, всілякі __declspec і тощо).

З ряду причин було вирішено відмовитися від запису анотацій в коментарях, оскільки останні, на моє глибоке переконання, повинні завжди виконувати тільки одну функцію. Як відомо, " so much complexity in software comes from trying to make one thing do two things ". З іншого боку, використання #pragma також пов'язане з проблемами: невідомі#pragma викликають попередження, що при великій кількості файлів в проекті виглядає не дуже красиво: (кілька попереджень в кожному файлі). Щоб уникнути цього, було прийнято рішення обернути анотації в макрос, який при компіляції відображати в порожній. Якщо який-то компілятор раптом почне підтримувати такий формат для своїх внутрішніх потреб і потрібно #pragma, то стандарт C99 нарешті вводить можливість визначення #pragma через макрос, так що макрос у цьому сенсі більш універсальне рішення.

Друге питання був пов'язаний з форматом самих анотацій, записуваних всередині макросу. Хоча, останнім часом, все більшого поширення для даних задач отримує XML. Він використовується, крім наведеного вище прикладу з Keil, ще і в проекті FreeRTOS (теж в коментарях). Треба сказати, що XML все-таки не дуже гарний з естетичних міркувань — він досить багатослівний, тому людині не дуже зручно працювати з інформацією в такому форматі. У той же час, існує машинночитаемый мова розмітки, який адаптований для читання і запису людиною, а також є сумісним з синтаксисом — це JSON. Однак, необхідність лапки ключі призводила до не дуже красивому зовнішньому вигляду анотацій в коді, тому був використаний розширений варіант — YAML. Слід особливо відзначити, що з усіх можливостей YAML використовується ТІЛЬКИ можливість писати ключі без лапок. Анотації, перед тим як передаватися парсеру, завжди перетворюються в один рядок, тому можливості YAML, пов'язані з перенесенням рядків, використовувати не можна. Оскільки JSON є підмножиною YAML, то можна все писати і на чистому JSON, можливість опустити лапки в ключів слід розглядати як маленький приємний бонус.

Таким чином, в сухому залишку, ми маємо наступне: исходники на С можуть містити макрос FX_METADATA(x) (з одним аргументом), всередині якого записуються анотації у форматі JSON. Анотації укладені в дужки, для того, щоб уникнути помилок у разі використання закриваючої дужки всередині самих анотацій. За угодою, анотації завжди являють собою набір пар ключ-значення, тобто, в термінах JSON, являють собою хеш. Приклад:

FX_METADATA(( { annotation: "hello world!" } ))

Як ви вже здогадалися, прагми з першою статті були «переведені» в новий формат, і тепер те, що раніше записувалося як:

#pragma fx interface MY_INTERFACE:MY_IMPLEMENTATION

Тепер записується так:

FX_METADATA(( { interface: [MY_INTERFACE, MY_IMPLEMENTATION] } ))

По частині реалізації модульності, зміни чисто косметичні, просто все те, що раніше записувалося як попало, стало записуватися в більш-менш універсальному форматі.

Що стосується #include, то все залишилося майже без змін: як аргумент раніше використовується макрос FX_INTERFACE, тільки тепер він має завжди один аргумент — завжди мається на увазі включення інтерфейсу за умовчанням, без можливості вказати якийсь конкретний інтерфейс.

Підсумуємо: Исходники містять анотації, в яких описується належність оригіналу до якого-небудь модуля. Модуль — це набір джерел, що містить один заголовковий файл (описує інтерфейс) і довільну кількість файлів вихідних текстів (*.c *.cpp і т. д.). Інформація про належність файлів до модулів витягується на етапі до компіляції. Після цього, зовнішнім додатком генерується файл, який відображає імена заголовних файлів на імена модулів, що і дозволяє #include працювати в термінах модулів, а не просто імен файлів, як це відбувається зазвичай. Оскільки у нас є спосіб дізнатися, які модулі повинні увійти до складу системи, а кожен ісходник при цьому містить інформацію про належність до модулів, то ми можемо автоматично дізнатися і всі файли, які потрібно скомпілювати. Тобто, написавши в якомусь исходнике #include FX_INTERFACE(MY_MODULE), отримаємо, що в систему включаться всі вихідні коди, які містять позначку про належність до інтерфейсу MY_MODULE.

Конфігуратор

Природно, те, що в керованих мовами робить runtime, тут теж має хтось робити, тому я розповім трохи про програму, яка це реалізує. Фреймворк роботи з анотаціями реалізований на C# як набір класів (DLL). Питання про те, чи варто було його писати саме на C# поки залишимо за кадром. Фреймворк створювався таким чином, щоб використання метаданих не обмежується складанням. Крім іншого, він надає інтерфейси для того, щоб доступ до сховища метаданих міг бути отриманий і ззовні. Це відкриває можливості для створення сторонніх модулів, які, на основі метаданих, можуть робити ті чи інші дії. У перспективі, всі плагіни планується підключати через MEF і відв'язати їх від структури проекту.

Крім набору DLL, існують також два додатки, які реалізують функції конфігуратора: консольне і графічне. Останнє знаходиться в сильно прототипном стані і розглядати його я зараз не буду, консольне ж буде розглянуто коротко.

Конфігуратор має 7 ключів командного рядка, причому обов'язковими з них є тільки 3, список шляхів до папок исходников, цільовий модуль (той, з якого почнеться дозвіл залежностей) і вихідний файл/папка. Наприклад, якщо наші анотовані исходники лежать в папках c:\src1 і d:\src2 і потрібно зібрати модуль MY_MODULE, то команда буде виглядати приблизно так:

fx_mgr-p c:\src1,d:\src2 -t MY_MODULE-o output_folder

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

Існує два способи «виводу».

Перший спосіб
Він підходить для невеликих проектів, а також зручний для постачання чого-небудь у вигляді вихідних текстів. Коли в якості аргументу з ключем - була вказана існуюча папка, в цьому випадку всі потрібні файли будуть скопійовані в вказану папку, причому відмінності файли будуть перейменовані відповідно з назвою реалізованого ними інтерфейсу. Так як система може містити лише одну реалізацію кожного інтерфейсу, то конфлікту імен не виникне і при розміщенні файлів для компіляції в плоскому вигляді в одній папці, а макрос FX_INTERFACE (аргумент #include) може бути визначений дуже просто:

#define FX_INTERFACE(i) <i.h>

Ніяких додаткових файлів не потрібно. В embedded досить часто доводиться використовувати бібліотеки поставляються у вигляді джерел, за необхідності компілювати їх зі специфічними ключами компілятора. Такий підхід з плоским набором файлів спрощує використання make, так і додавання файлів в яку-небудь IDE і т. д.

Другий спосіб
Зрозуміло, кожен раз щось копіювати при складанні це поганий варіант, тому існує ще один спосіб: якщо те, що було зазначено з ключем -, не є текою, то створюється новий файл з вказаним ім'ям (або перезаписується існуючий) і в нього поміщається список абсолютних шляхів до файлів, які потрібно скомпілювати. Після цього даний файл можна використовувати в системі складання (у тому числі в make або MSBuild) для компіляції файлів. Тут виникає питання, якщо файли містять анотації, а include використовують модулі, звідки ж візьмуться макроси FX_INTERFACE/FX_METADATA? Дійсно, нізвідки. Для їх одержання використовується ключ-h. Якщо він вказаний, то створюється т. н. загальний заголовковий файл, той самий, який містить відображення імен файлів на імена модулів, а також визначення FX_INTERFACE і FX_METADATA. Даний файл повинен бути включений примусово директивою компілятора у всі компилируемые файли, якщо використовується такий тип складання.
Це все були основи, які були реалізовані і раніше чудово працювали на #pragma без універсальних анотацій, тепер розглянемо коротко впровадження залежностей, і перейдемо до питань, для яких вже необхідно наявність структурованих метаданих.

Впровадження залежностей

Варто приділити пару слів необхідності підтримувати декілька реалізацій одного інтерфейсу. Спочатку весь фреймворк виріс із системи конфігурування для RTOS, яка повинна була ідеально підлаштовуватися під вимоги програми. Настільки ідеально, що якоїсь однієї реалізації кожного інтерфейсу могло бути вже недостатньо. Самої ОС, як єдиної суті, не існувало, вона будувалася з більш дрібних блоків. У такому разі робилося декілька реалізацій модуля, кожна з яких «заточувалася» під певні вимоги. Потім, оскільки інтерфейси у всіх реалізацій однакові, можна було підбирати ідеальні для програми реалізації кожного користувача. Іншими словами, це статичне впровадження залежностей на рівні вихідного коду. Исходники в дереві проекту не містять ніякої інформації про те, які фактично модулі будуть використані, вони імпортують абстрактні інтерфейси без вказівки реалізацій. Фактичні реалізації зазначаються конфигуратору на етапі складання системи у вигляді файлу, в якому описана ця інформація, за аналогією з тим, як в «ентерпрайз»-мовах вона записується у зовнішніх XML (при бажанні, нічого не заважає і тут написати такі XML). Нарешті, користуючись тими ж метаданими можна визначити якісь додаткові ознаки, за якими конфігуратор міг би сам визначити, які реалізації потрібно використовувати.

Якщо вихідні матеріали містять тільки одну реалізацію кожного інтерфейсу, то конфігуратор сам розуміє, що альтернативи немає і в разі якщо даний інтерфейс кимось імпортований, автоматично використовує єдиний наявний модуль з зазначеним інтерфейсом. Якщо ж у кожного інтерфейсу безліч реалізацій, то, у відсутності підказок, конфігуратор вибирає першу-ліпшу реалізацію (і видає в консоль warning про подію). Так як для даної конфігурації системи зазвичай відомо, які реалізації інтерфейсів треба використовувати, конфигуратору можна дати підказку, вона дається з допомогою ключа a <ім'я файлу>. Зазначений файл містить рядки виду INTERFACE = VERSION, де INTERFACE це ім'я користувача, а VERSION — ім'я реалізації, яку потрібно використовувати. Це все близько до того, що було описано в першій частині, тільки трохи спрощений синтаксис, так що за технічними подробицями краще звернутися до першоджерела.

Аспекти

Ну, ось і підібралися до найцікавішого, заради чого все і затівалося. Існує безліч визначень аспектів в програмуванні, тому я не буду намагатися осягнути неосяжне в одній короткій замітці. Аспект це частина чогось, що має збиратися в залежності від модулів, що входять в проект. Наприклад, якщо є кілька типів якихось об'єктів, іноді дуже хочеться мати enum, що містить всі ці типи, причому при додаванні нового типу (просто включення файлу в проект), цей enum повинен автоматично розширюватися. Може знадобитися дізнатися кількість якихось модулів, що входять в даний проект і так далі.

Очевидно, що ніякими хитрощами з препроцесором і кодом досягти цього не вдасться, оскільки інформація про модулях які входять в даний конкретний проект недоступна навіть компілятору. Напевно, краще відразу почати з передісторії: серед вимог до ОС була можливість підстроювати таблицю системних викликів під наявний в ОС набір сервісів, при цьому, якщо якийсь модуль включався в збірку і мав функціонал, що експортується в user-mode, таблиця системних викликів повинна була це врахувати. Це все не новина, подібні методи використовуються в деяких ОС (наприклад, в NuttX), однак, як правило, все це робиться на препроцессоре. Якщо якийсь модуль включений, то визначено деякий #define, якщо він визначений, то працює #ifdef, в якому перераховані функції цього модуля всередині таблиці. Сама таблиця, таким чином, складається з#ifdef-ов. Цей підхід, крім утрудненою поддерживаемости, має також таку проблему, що якщо з'являється новий модуль, то відповідні йому функції треба вручну додати в цю таблицю, і таблиця, таким чином, містить знання про все, що тільки може з'являтися в системі. Аспекти покликані вирішити проблему, коли для додавання чого-то в проект, потрібно зробити безліч дрібних виправлень у різних місцях: розширення enum-ів, додавання записів у конфігурацію системи складання і так далі.

У пропонованому конфігураторі реалізований інший підхід. Раз вже у нас є модулі, чому б не зробити деякі модулі генеруються самим конфігуратором? Адже він володіє знаннями про структуру системи і метаданих всіх модулів, після того, як було здійснено впровадження залежностей.

Вихідні коди містять в якості спеціального ключа анотацій набір пар виду «ключ-масив значень». Після дозволу конфігуратором всіх залежностей, стають відомі модулі, що входять в дану конфігурацію. Далі всі ці модулі досліджуються на предмет наявності в них ключа aspects, і якщо він знайдений, то створюється #define з іменем ключа, в якому перераховуються всі значення (з усіх модулів у системі). Тобто, якщо один з модулів містить наприклад ось такий запис:

FX_METADATA(({ aspects: [ { key: [ val1, val2, val3 ] } ] }))

А інший, ось таку:

FX_METADATA(({ aspects: [ { key: [ val4, val5, val6 ] } ] }))

То буде згенерований #define виду

#define key val1 val2 val3 val4 val5 val6

Всі значення (і ключі і значення) розглядаються як текст, тому можна написати, наприклад, так:

FX_METADATA(({ aspects: [ { key: [ "val1," , "val2," , "val3," ] } ] }))

І тоді значення всередині #define будуть йти через кому. Ну, ви зрозуміли ідею.

Де буде згенерований цей #define? У заголовочном файлі, який вказується конфигуратору в якості ключа-e <ім'я файлу>. Якщо вказано цей параметр, то конфігуратор додає згенерований інтерфейс в сховище метаданих. Цей інтерфейс називається CFG_ASPECTS і доступний для включення #include FX_INTERFACE(CFG_ASPECTS), там містяться аспекти з усіх модулів. Важливо відзначити, що якщо такий інтерфейс вже є (тобто користувач вже написав його вручну і він міститься в пулі исходников після їх аналізу), то генеруватися нічого не буде, а буде використовуватися наявний модуль.

Опції

Опції — це деякі зовнішні #define, які визначають деякі конфігураційні константи для даного модуля. За аналогією з тим, що у Keil, тільки не в термінах файлів, а в термінах модулів. Основне їх призначення — встановлення цих параметрів через GUI, тому використовувати опції в консольному додатку досить важко. Як і у випадку з аспектами, опції представлені утворюваним інтерфейсом CFG_OPTIONS, який містить всі опції для даної конфігурації модулів, які були описані у вигляді відповідних анотацій всередині исходников. Консольний додаток генерує файл з опціями встановленими в значення за замовчуванням, графічне ж додаток дозволяє ставити їх.

Модель використання опцій передбачалася наступному: користувач отримує ОС у вигляді набору вихідних, запускає конфігуратор, який аналізує ці вихідні коди і показує наявні опції, користувач налаштовує їх під свої потреби, і конфігуратор генерує той самий файл CFG_OPTIONS. Після цього у користувача є повний набір необхідних сконфігурованих исходников. Тобто в контексті розробки від них в загальному-то толку небагато, тому на цьому я думаю варто закінчити.

Обмеження

У поточній версії використовується спрощена модель одержання метаданих з файлу просто виключаються коментарі (регулярними виразами) і далі там проводиться пошук include і метаданих (теж регулярними виразами). У 98% випадків цей підхід працює безвідмовно, але в якихось складних випадках, начебто перевизначення макросів і вкючаемых файлів, він працювати не буде. У цьому випадку треба використовувати повноцінний постачальник метаданих, який препроцессит кожен файл (сишным препроцесором) і витягує метадані і включаються файли вже зі 100% гарантією, навіть у випадку будь-яких трюків з препроцесором. Але, зрозуміло, у випадку великої кількості файлів, це буде досить повільно (секунди для проекту в кілька сотень файлів, що при налагодженні досить утомливо).

У поточній реалізації підтримується паралельне витяг метаданих: так як ця операція ідеально распараллеливается, витяг і парсинг метаданих відбувається з використанням циклу Parallel.ForEach, який працює на основі пулу потоків, так що, при наявності декількох процесорів, ця операція серйозно прискорюється.

Також є невелика складність з використанням IDE, справа в тому, що сишные IDE припускають, що всі файли, які знаходяться в поточному проекті, потрібно компілювати. У пропонованому підході, конфігуратор, за списком шляхів до папок, проекту сам визначає, які файли повинні бути скомпільовані, тому якщо IDE не дозволяє якось фільтрувати вхідні файли (з допомогою якогось плагіна, який міг би в момент білду сказати, що треба, а що не треба компілювати), то використовувати таку IDE буде дещо проблематично. Я сам IDE не користуюся, в якості редактора використовую Sublime Text або Visual Studio Code, а збірка налаштована так, щоб запускатися з командного рядка. До того ж RTOS поставляється у вигляді джерел для конкретної конфігурації, тому з нею проблем немає, але той, хто не може жити без IDE, може бути розчарований, це так. По суті, вводиться додаткова фаза складання, виконувана до компіляції — фаза конфігурування, і не всі IDE сумісні з цим підходом.

Висновок

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

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.