Относно модулността, добрата архитектура, инжектирането на зависимости в C

Търсете единство не в съвкупността, а по-скоро в еднаквостта на разделението. Козма Прутков

Първо малко вода

Невъзможно е да не забележите, че аспектно-ориентираното програмиране превзема нови граници на популярност всяка година. На Habré вече имаше няколко статии, посветени на този въпрос, от Java до PHP. Време е да обърнете поглед към C/C++. Сега, още в първия параграф, признавам, че не говорим за „реални аспекти“, а за нещо тясно свързано с тях. Също така дискусията ще се проведе в контекста на вградените проекти, въпреки че описаните методи могат да се прилагат навсякъде, но вградените са областта, в която ефектът ще бъде най-забележим. Също така ще използвам думите "заглавка" и "дефиниране", за да се позова на съответно "заглавен файл" и "дефиниция на макрос". Сухият и академичен език е добър, но в този случай, струва ми се, всичко ще бъде по-лесно за разбиране, ако използвате установени англицизми. Една от причините за съществуването на понятието "софтуерна архитектура" е необходимостта от управление на сложността. Досега в тази област не е изобретено нищо по-добро от компонентния модел. Дори стандартите за описание на софтуерната архитектура на IEEE се основават на принципа за разделяне на проекта на компоненти. Компонентите са добри, хлабаво свързване, многократна употреба и в крайна сметка добра архитектура. От своя страна програмирането (в широк смисъл) е до голяма степен опит за структуриране и описание на виртуалния свят под формата на текстово описание. И всичко би било наред, но светът (дори виртуалният) е малко по-сложен от набор от взаимодействащи си обекти/компоненти. Има функционалност, наречена „преминаване“, която не е такавасъдържащи се на едно място, но "размазани" върху други модули. Примерите включват както тези от учебниците: регистриране, проследяване, обработка на грешки и „нетривиални“, като сигурност, поддръжка на виртуална памет или многопроцесорна обработка. Във всички тези случаи, в допълнение към основния модул (в случай на регистриране, това може да бъде самата функция, която извежда регистрационния файл), има някакво друго "нещо", което трябва да се добави към други модули. И тези модули не знаят за това. Това нещо се нарича аспекти. Решението на проблема с въвеждането на аспекти в модули, които не знаят нищо за тези аспекти, всъщност се разглежда от аспектно-ориентирано програмиране (AOP). Свързана концепция с AOP е инжектиране на зависимост (DI). Въпреки че често се противопоставят един на друг, всъщност между тях има повече прилики, отколкото разлики. Основната разлика е, че AOP предполага, че целевият модул не знае нищо за това какво и как може да бъде инжектирано в него. Този метод има недостатъци, които няма да изброявам тук, за да не задълбавам във философията на програмирането като цяло. Само ще отбележа, че AOP не може да реши проблема с „нетривиалните аспекти“, когато кодът трябва да бъде инжектиран в някои специални точки в зависимост от алгоритъма (свързването на аспекти се случва със синтактични конструкции като извиквания на функции, връщания на функции и т.н.). Тук влиза в действие инжектирането на зависимост. Какво е добро за VZ? На първо място, интерфейсът на инжектирания аспект и модула става дефиниран и не е свързан със синтаксиса и имената на функциите в целевия модул. Това проправя пътя за създаването на „модули на ниво източник“, за които ще говоря в тази статия (самата модулност е тема за друг голям разговор). Какво е общото между OT и AOP?Поне фактът, че VZ, точно като AOP, ви позволява да внедрите някакъв код на трета страна (аспекти), за всички възможни опции, за които целевият код не знае. Единствената фундаментална разлика е, че в класическия AOP точките на инжектиране са описани в кода на самия аспект, а в случая на EO кодът се инжектира в извиквания към интерфейсни методи, които вече са в целевия код. Привържениците на Java/C# разумно ще възразят, че EO е просто модел на проектиране, който се прилага на всеки език, просто вместо да създавате обект и да извиквате неговите методи, трябва да извикате фабричен метод, който ще анализира някаква външна конфигурация и ще създаде това, от което се нуждаете. Но отново, тъй като сме във вграден контекст, не трябва да има неразумна индиректност и динамична конфигурация, защото. това има най-пряко въздействие върху производителността. Всичко трябва, ако е възможно, да се случва статично. Така че това е публикация за това как (и защо) можете да направите статично инжектиране на зависимости за C/C++ проекти, какви проблеми може да разреши и как можете да улесните живота си с него.

Специфика на големите вградени проекти

Защо C/C++ проектите са трудни за конфигуриране

сребърен куршум

Основната идея за това как да се решат тези проблеми е, че информацията за зависимостта трябва да се съдържа в самите източници. Връзката между заглавните файлове и имплементацията трябва да е ясна, а не само в главата на разработчика. Нещо подобно се случи преди няколко десетилетия в Паскал (в залата се чуват обвинения на оратора в ерес). Старите хора си спомнят, че модулите на Pascal съдържат две секции интерфейс и имплементация, а употребите се използват като включвания. В тази версия самите източници съдържаха цялата информация за зависимостите и беше възможно да сесамо изходен код за изграждане на пълна графика на зависимости и разбиране какво трябва да се компилира, за да се изгради някакъв вид модул. Бих искал да добавя малко информация към C-източниците, която да помогне на външната система за анализ на източниците да проследява зависимостите между тях. Когато търсехме решения на този проблем, на всички беше ясно, че времето, когато беше възможно да се правят синтактични промени от такъв мащаб в езика, отдавна е отминало. Дори гигантите на софтуерната индустрия се нуждаят от много ресурси, за да съживят нов език, да не говорим за извършването на някои промени в светая светих - C / C ++, в който вече е написан повече от много код. Затова трябваше да се задоволя само с това, което стандартът предлага. Можете да напишете някои етикети във файл по много начини, основният въпрос беше как да направите „контролиран импорт“ (Include), както и да проследите зависимостите. За включванията изборът е малък, според стандарта там могат да се записват само имена на файлове (в кавички или ъглови скоби) и макроси. Именно последното даде ключа за решаването на проблема. Но на първо място. Без повече шум, беше решено да се добавят подобни тагове на Pascal към заглавки и източници. Нещо като надпис INTERFACE:IMPLEMENTATION. Имаше много дискусии какво точно да правят, но в крайна сметка се спряха на #pragma. Досега има съмнения дали си струва да се правят етикети в тази форма (и да се получават пакети от предупреждения като unknown pragma). Теоретично, #pragma ви позволява да добавяте информация за импортиране / експортиране и към двоични файлове, след което както източниците, така и библиотеките могат да участват в процеса на конфигуриране. Версиите са разработени (и се разработват) с помощта на макроси (заместване на стойности, в които се случва на етапа на анализ на източника, а работният файл съдържапразен макрос за мъниче). Този въпрос остава дискусионен. Както бе споменато по-горе, всеки хедър трябва да има етикет като

където е името на интерфейса и е идентификаторът на конкретната реализация (версия, вариант и т.н.). Тоест всеки компонент трябва да има уникален етикет. Тези компоненти, които имат един и същ интерфейс, също трябва да имат една и съща част interface_name. Версията или изпълнението е необходимо, за да се разграничат имплементациите едно от друго. Файлът за изпълнение трябва да има подобен етикет, само че вместо fx интерфейс - fx изпълнение. Необходими са различни прагми за интерфейс/изпълнение, за да се прави разлика между етикетите на заглавката и източника (защо това не може да се разбере от разширението на файла, ще бъде обсъдено по-долу). По този начин компонент, състоящ се например от заглавка и sish файлове, трябва да съдържа един и същ етикет име на интерфейса и версия във всичките си файлове, само в заглавката ще бъде #pragma fx интерфейс, а в изходния код pragma fx реализация. Друга реализация на същия компонент (със същия интерфейс) също трябва да съдържа същия етикет във всички свои файлове, но версията трябва да е различна от написаната в първия компонент и т.н. Включването на нещо с #include трябва да използва името на интерфейса, а не обичайното име на файл. Текущата реализация използва макроса за това.

Откъде ще дойдат тези макроси? Някой външен инструмент, след като е получил пътеките до източниците, трябва да ги анализира, да намери тези етикети в тях, да изгради всички графики и след това да генерира общ заглавен файл, съдържащ съпоставяне на имена на файлове с имена на интерфейси и да дефинира макроса FX_INTERFACE. Този файл трябва да бъде принудително включен във всички компилирани файлове от директивата на компилатора ВМЕСТО пътеки за Включване на директории, тъй като товафайлът вече съдържа пътищата до всички необходими файлове (обаче никой не забранява смесването на това във всякакви пропорции със стандартния подход с обичайния #include със скоби). Нека разгледаме някои модул A, разположен във файла module_a.c и модул B, във файла module_b.c, както и съответните им заглавки. module_b.h включва интерфейса, дефиниран в module_a.h. Като първо приближение всичко изглежда така:

Изпълнението е дефинирано във файла module_a.c:

Създайте модул B, който използва (препраща към) модул A:

Реализация на модул B във файл „module_b.c“:

От това вече е ясно как можете да получите зависимостите на източника от заглавния файл. Ако някой използва заглавка, съдържаща специфичен интерфейс, всички файлове за изпълнение (с разширение c или cpp), съдържащи същите тагове за изпълнение, също трябва да бъдат компилирани. Как можем сега да проследим зависимостите и да разберем, че ако трябва да компилираме module_b.c, това води до необходимостта да компилираме и module_a.c? Ако всеки файл включва всички модули, от които зависи, под формата на include-s, и всеки заглавен файл съдържа #pragma fx интерфейс, тогава чрез обработка на файла с препроцесор и намиране на всички #pragma в изхода, можете да разберете какви зависимости има този модул (всички намерени реализации зависят от всички намерени интерфейси). По-специално, предавайки файла module_b.c през препроцесора, получаваме нещо като:

От този пример става ясно защо са необходими различни прагми за заглавки и източници: след като препроцесорът работи, информацията за разширенията се губи, следователно, за да се проследят зависимостите на източника от заглавките, таговете трябва да се различават. Сега помислете за основния инсулт, който всъщност беше основната причина за цялото ни изследване. Разглеждайки класическия пример за AOP или EOI −регистриране, тогава обикновено се дефинира като набор от макроси, деактивирането на които в един модул, те се деактивират в целия проект. Същият трик се прави с проследяване и някои други неща. Логично е да зададем въпроса защо да не направим същото с който и да е модул (интерфейс) изобщо? Тъй като включването на заглавка обикновено е включване на някакъв абстрактен интерфейс, защо да не го направите такъв. Като пише така:

добрата

модулността

Друг проблем са цикличните зависимости. Всъщност този проблем съществува и в "нормален" C, когато няколко заглавки се включват един друг. Тук се прилагат същите правила като "общо", тъй като не се използва нищо извън препроцесора и всички #include FX_INTERFACE в крайна сметка се преобразуват в имена на файлове, тези файлове трябва да бъдат защитени от повторно включване с помощта на обичайните методи: #ifndef/#define или #pragma веднъж. Ситуацията, когато два модула зависят един от друг, е приемлива, тъй като файловете се обработват отделно от препроцесора, няма да има рекурсия и възможните дублиращи се файлове се премахват от списъка с източници за компилация (това не е хак, това е функция: един и същ модул може да бъде включен няколко пъти, но това не означава, че неговите източници трябва да бъдат записани няколко пъти в make-файла). Като цяло все още има проблеми, но, както се казва, „работим по въпроса“.

Имплементацията му не е важна, важното е, че ако някой използва такъв хедър, тогава имплементацията автоматично ще влезе в компилацията. Понятието „интерфейс“ се използва тук в широк смисъл, за разлика от дефиницията в ООП стил: под „интерфейс“ имаме предвид някаква конвенция, а не просто набор и сигнатури от функции. По-специално, типът данни "runtime_protection" е част от интерфейса RUNTIME_PROTECTION, тоест всички заглавки, които имат такиваpragma трябва по някакъв начин да дефинира такъв тип данни (не непременно като структура). Модулите, използващи тази защита, трябва, преди да използват споменатите типове данни и функции, да импортират интерфейса:

Реализацията на обект просто използва функции, без да мисли какво представляват или откъде идват:

В случай, че искаме да деактивираме проверката на типа, трябва да напишем интерфейс като:

Финалът на нашето изследване (което в момента може да бъде показано на публиката) беше инструментът FX-DJ, FX - защото първоначално беше предназначен само за конфигуриране на FX-RTOS - другият ни проект, и всички инструменти бяха с префикс FX (защо самата ОС се нарича така, е тема за друг разговор). Е, DJ, както може би се досещате, означава Dependency inJector, а не DJ изобщо, въпреки че все още има определени паралели с DJ по отношение на изпълняваните задачи. Това е допълнителна фаза на изграждане, която се изпълнява преди компилацията - фазата на конфигуриране, когато йерархична система, базирана на зависимости, се формира от някакъв абстрактен набор от изходни компоненти съгласно определени правила, в резултат на което се определят списъци с файлове, които да бъдат изградени, след което, както обикновено, се извършват фазите на компилация и свързване. Поддържа се списък с файлове за компилация, както като плосък списък за използване от някакъв външен инструмент, така и във формат make. Целият процес може да бъде изобразен по следния начин:

относно