Академия С
Съдържанието на статията
Когато резултатът от SQL заявка включва безкрайни преобразувания на типове към всички възможни варианти на типове полета. Когато кодът е изпълнен с неясна логика с огромно изброяване на претоварвания на типове boost::variant. Когато не знаете как да приемете аргумент от произволен тип чрез RPC протокола. След това се нуждаете от механизъм за емулиране на динамично писане в C++. Разширяем и удобен, създаващ ясен API. Такъв, който не изисква предварително дефиниран списък от типове и не ви принуждава да работите с указатели към базовия клас. Има такъв механизъм - двойното изпращане ще ни помогне!
За да разберете какво е двойно изпращане и как да го подготвите правилно, ефективно и разбираемо в C++, първо трябва да обясните за какво служи и да извървите целия еволюционен път към това решение. Без това обяснение начинаещ разработчик ще полудее до края на четенето, а опитен разработчик най-вероятно ще се удави в собствените си асоциации и ще направи грешни заключения. Затова нека започнем от самото начало - с основите на динамичното писане и за какво служи в C ++.
Динамично писане в C++
В езика C++ въвеждането е статично, което ви позволява да проследявате грешки при работа с операции с типове на етапа на компилация. Като правило, в 90% от случаите знаем предварително или типа на резултата от всяка операция, или базовия клас на всички възможни стойности. Има обаче клас проблеми, при които типът на стойностите, произтичащи от операцията, не е известен предварително и се изчислява на етапа на изпълнение. Класически пример е резултатът от заявка към база данни, където в резултат на изпълнение на SQL заявка получаваме набор от сериализирани стойности, които трябва да бъдат разопаковани в подходящите типове след изпълнение на заявката. Друг пример е резултатът от извикване на отдалечена функция чрез RPC-протокол, резултатът ще бъде известен по време на изпълнение и не винаги можем да предвидим набора от върнати стойности на етапа на задаване на задачата, особено ако решаваме обща задача с предоставяне на междинен API за вашия RPC протокол. Същото важи и за всяка потенциално разширяема функционалност, която работи със система от типове, която е по-удобна за изчисляване по време на изпълнение, например същите аргументи на функцията или параметри на SQL заявка, които в общия случай са по-удобни за обобщаване, но в същото време трябва да се съхраняват и предават по някакъв начин.
Нека започнем с класическото решение чрез базовия клас и неговите наследници.
Основен интерфейс и наследяване
Класическото решение чрез основния интерфейс в C++ директно използва една от OOP парадигмите – полиморфизма. Разпределя се общ клас, обикновено абстрактен, той въвежда редица методи, които се отменят в наследниците, и работата се извършва с препратка или указател към типа на общия предшественик на стойностите на наследниците.
Получаваме следните предимства:
- разширяемостта е основният плюс, можете да създавате наследници във всяка библиотека и да работите с тях на споделена основа; това няма да работи, например, ако изберете метода на безкрайно превключване, в даден момент системата ще се задуши от излишък от опции за регистър в различни части на един и същи тип код;
- динамично типизиране - всъщност типът може да бъде зададен по време на изпълнение, създавайки екземпляр на един или друг клас-наследник, в зависимост от логиката на задачата, като резултат можете например да попълните резултата от анализиране на JSON обект или SQL заявка;
- видимост - способността за много просто изграждане на разбираема диаграма за всеки с дърво от наследници, самото описание на базовия клас предполага очевидността на поведението на класа наследник.
Има обачеи минуси, има само три от тях, но игнорирайки ги, получаваме постоянно главоболие, защото губим всички предимства на класовете C ++, прехвърляйки работата на указатели към основния интерфейсен клас:
Всъщност с този класически подход кодът на едно място изглежда като филм на ужасите и вместо обичайния конструктор се появява подобно чудовище:
Всичко изглежда в най-лошите традиции на C++ и затова не е изненадващо, че най-често разработчиците смятат подобни конструкции за нормални, дори ако са поставени в интерфейс или са изложени в система с отворен код.
Толкова ли е лошо всичко в C++ и не можете да се справите с обикновени класове с генерирани конструктори за копиране и преместване, оператор за присвояване и други радости от живота? Какво ни пречи да капсулираме цялата логика на работа с указател към базов клас в обект от клас контейнер? Да, общо взето, нищо.
Наследяване на клас данни
Време е да възстановим малко логиката на базовия клас. Ние ще опаковаме цялата логика на работа с базовия интерфейс в обикновен C++ клас. Класът на базовия интерфейс вече няма да бъде абстрактен и обектите на класа ще получат обичайната логика на конструктори и деструктори, те ще могат да копират и присвояват стойности, но най-важното е, че няма да загубим всички предимства на предишния подход, отървавайки се от минусите!
С други думи, базовият клас получава някои данни под формата на клас, поведението на който се определя от класовете наследници, в които класът данни е наследен от класа данни на базовия клас ... звучи объркващо, нали? Сега нека разгледаме един пример и всичко ще стане ясно.
// Интерфейсът на класа е достъпен в публичния API
Всъщност обикновено има повече приемници и те са склонни да се появяват в зависими библиотеки. Сега е време да разберете какво позволява товасмешен дизайн.
Реализацията на методите на API класовете е очевидна и замества методите за работа с данни. Конструкторът на предшественик не създава данни и оставя нулев указател, конструкторите на потомък инициализират указателя на предшественик с типа данни от желания тип.
Сега нищо не ви пречи да създадете нов наследник на обектния клас, давайки му логиката на преобразуване в низ и проверка за наличие на стойност. Например, можем да извлечем обектния клас обувки:
Класът shoes::data е описан по аналогия с flower::data. Вярно, сега можем да получим забавен резултат, когато работим с нашата цветна градина от предишния пример:
Добре дошли в света на динамичното писане, използвайки обикновени C++ класове с типична логика. Въпреки че не! Копирането на клас ще копира само препратката. Време е да коригираме последното несъответствие с логиката на класа C++.
Копиране при промяна на обект
Същността на подхода е следната:
- обект препраща към данни чрез помощен тип като указател;
- обектните методи могат да бъдат постоянни и неконстантни, важно е ясно да се поддържа постоянството на метода поради следните точки;
- при извикване на обектен метод, извикването се проксира за извикване на метода на класа данни чрез същия спомагателен тип указател от първия параграф, в който два оператора -> са претоварени за тази цел, за по-добра четимост, съответно const и non-const.
Операторът за претоварване const -> просто извиква необходимия метод директно в класа данни, прокси извикването на външния клас;
Копирането при промяна е относително лесно за прилагане в C++, чрез оператора -> върху капсулирания помощен клас. Важно е да претоварите както const, така и non-constпретоварване на оператора
За да стане ясно, нека вземем най-опростената версия на такъв междинен референтен тип:
В добър смисъл трябва да защитите този клас за многонишков достъп, както и от изключения по време на копиране, но по принцип класът е достатъчно прост, за да предаде основната идея за внедряването на COW в C ++. Също така си струва да се има предвид, че в конструктора за копиране на класа данни се подразбира извикване на виртуален метод за клониране на данните.
Сега всичко, което ни остава, е да променим съхранението на данни в базовия клас на обекта:
Всъщност имаме хибрид от подходи Pimpl и Double dispatch за динамично въвеждане на данни, за които получаваме типа по време на изпълнение.
ПРЕМАХВАНЕ ОТ ТЕКСТА
Всъщност имаме хибрид от подходи Pimpl и Double dispatch за динамично въвеждане на данни
Внедряване на интерфейс за клас данни?
Когато имплементирате клас данни, не е необходимо да дублирате всички методи на външния клас, както се прави от модела Pimpl. Класът данни изпълнява две основни задачи: скриване на детайлите на капсулирането в имплементацията и осигуряване на достъп до данни при имплементацията на методите на външния клас. Достатъчно е да направите get и set методи и някои спомагателни функции и да извършите обработка на данни директно в методите на външния клас. По този начин отделяме изпълнението на класа от детайлите на капсулирането.
Прилагане на динамично въвеждане
И така, да кажем, че имаме протокол за отдалечено извикване на функции, като опция това е параметризиране на SQL заявка към базата данни. Ние изчисляваме типовете аргументи и резултата по време на изпълнение, ако направим общ механизъм с предоставяне на API на крайния потребител, тъй като не е известно предварително какво потребителят ще иска да предаде катоаргументи и какви типове резултати ще бъдат получени от отдалечената страна (понякога дори разработчик, който пише върху такъв API, не знае това, защото при верижно обаждане, аргументите на следващото извикване често се основават на резултатите от предишното).
В такива случаи, когато базовият клас е не само интерфейс за наследници, но и контейнер за данните на потомъка, ние получаваме възможност да опишем всяка функционалност, която изисква динамично типизиране по отношение на C ++ класове и обекти.
Помислете за примерна SQL заявка. Списъкът с аргументи за изпълнение на заявка може да бъде генериран от същия Boost.Preprocessor за функция от произволен брой аргументи от типа обект.
Можете да използвате произволен набор от обекти като аргументи на db::SqlQuery::operator(), в който случай трябва да дефинирате шаблонен имплицитен конструктор за преобразуване към общия тип обект:
В този случай се нуждаем от наследници от обектния клас на формата integer, boolean, floating, text, datetime и други, чиито данни ще бъдат поставени в обекта, когато обектът се инициализира със съответната стойност. В този случай инициализирането на обект с произволен тип ще бъде разширяемо и всичко, което е необходимо, за да настроите обекта на желания тип, е да напишете подходяща специализация, като тази за bool:
Най-важното тук е нещо друго, резултатът от заявката е таблица с данни, изчислени от отдалечената страна на базата данни в момента на изпълнение. Въпреки това можем спокойно да итерираме всеки ред от резултата от заявката, получавайки обект от напълно специфичен тип, а не някакъв вид недостатъчно десериализирани данни. Можете да работите с обект, можете да претоварвате операции за сравнение, можете по аналогия с конструктор да направите шаблонен метод за получаване на стойностот определен тип, могат да бъдат прехвърлени към низ, изведени към поток. Обект от тип обект е доста контейнер, с който може да се работи като нормален клас обект.
Освен това, ако желаете, можете да добавите контейнерна логика към обекта и като цяло да се справите с един тип за всяка стойност, върната от заявката. Тоест чрез претоварване на методите begin(), end(), size(), както и оператора []:
По принцип идеята може да се подобри до такава степен, че можете да използвате всичко чрез контейнера и базовия клас на обекта, но тук не трябва да забравяте здравия разум. Идеята за статично писане, която открива грешки на етапа на компилация, е много добра и е крайно неразумно да се отказва навсякъде, където е възможно изкуствено да се използва динамично въвеждане!
ПРЕМАХВАНЕ ОТ ТЕКСТА
Идеята за статично писане, която открива грешки по време на компилация, е много добра и е изключително неразумно да се изоставя навсякъде!
В същото време базовият клас е както интерфейсен клас за работа с различни данни, така и контейнер. За удобство можете да дефинирате всички необходими операции за базовия клас: сравнения, индексиране, математически и логически операции и операции с потоци. Като цяло можете да внедрите най-четливия и логичен код, най-защитен от грешки при съхраняване на указател към базовия клас, копиране и достъп от различни нишки. Това е особено полезно, ако този API е разработен за широк набор от задачи, когато работите с набор от типове, които първоначално са неизвестни, и самият набор от типове може потенциално да бъде разширен.
Динамичното писане е отговорност!
Трябва да сте изключително внимателни, когато въвеждате динамично писане. Не забравяйте, че разработчиците на скриптови езици често завиждат на функциите на C++,C# и Java проверяват типовете, преди да изпълнят алгоритъма по време на компилиране. Впрегнете силата на статичното писане, като емулирате пропускането му само там, където има смисъл! Като правило е необходимо динамично въвеждане, за да се изпълни обща заявка за API към отдалечен сървър за сериализирани данни (включително заявка за база данни).
След десериализацията могат да бъдат получени редица типове по време на изпълнение. Отказът от динамично извлечени типове и работата с данни, сериализирани в текст или поток от байтове, обикновено е неразумно, тъй като обикновено се изисква обработка, когато данните се получат. Удобството да анализирате данни и да получавате познати C++ типове, работещи не с интерфейсни указатели, а с обикновени обекти на добре проектирани класове, е безценно.
Нов начин
Получаваме отличен API, използвайки познатата логика на клас C++. Зад неговата простота е скрит мощен механизъм за обработка на динамично въведени данни, който ви позволява да създавате и копирате данни от различни типове не по-рано от необходимото. Различни данни могат да се съхраняват в общ клас, който съчетава интерфейсна логика и функционалност на контейнера, което предпазва от често срещани грешки при работа с указател към интерфейсен клас в класическия подход.
Потребителят на такъв API получава всичко и няма да ни струва почти нищо. Всичко, което е необходимо, е просто да създадете добър API, използвайки новия път.