Виртуални функции - изглед от ниско ниво (c) Бюлетин

За съжаление, полиморфизмът е областта, която причинява най-много трудности за овладяване на начинаещите. И дори след като успешно започна да прилага знанията на практика, сенчестата страна и характеристиките на внедряването на ниско ниво остават скрити. Междувременно днес е трудно да си представим проект, който да не използва полиморфизъм. COM технологията е изградена изцяло върху абстрактни интерфейси. Добавките за много популярни програми са просто немислими без виртуални функции.

Тънкостите на наследството

Виртуалните функции нямат смисъл, ако няма наследяване. Няма да навлизам в подробности - основите могат да бъдат намерени във всеки учебник по C++.

Има няколко неща, които просто трябва да знаете.

Наследяването е създаването на нови обекти

Компилаторът изгражда дърво на наследяване по време на компилация. За да компилира кода, той трябва да има достъп до информация за всички базови класове. Всичко това с причина, както е казал Мечо Пух.

Да кажем, че имаме класове:

Гледайки този запис, може да си помислите, че компилаторът генерира код, който има индикация за семейни връзки между класовете. Това е грешно!

В литературата, форумите и дори в общуването между програмистите класове A и B се говорят като различни единици. Това е удобно, когато става въпрос за разграничаване на функционалността между класовете или дървото на наследяването. За съжаление, това е объркващо за начинаещите.

Какво наистина се случва? Тази форма на нотация е измислена, за да не се дублира кодът на родителския клас. Това улеснява разбирането на програмиста и позволява всички класове да бъдат представени като дърво. Разбира се, компилаторът също използва тази информация в работата си, например за кастингвидове. От друга страна, в паметта на процеса, от гледна точка на компилатора, клас B ще изглежда като монолитен блок памет, който съдържа 2 променливи. Тези. излиза нещо подобно:

Съзнателно използвах struct, защото в компилирания код няма модификатори за достъп - това са езикови конвенции. Тези. можете да видите, че компилаторът просто ОБЕДИНИ двата класа на изхода. При множественото наследяване се случва същото, само малко по-сложно: данните в паметта се комбинират в същия ред, както в списъка с наследяване. Например:

Такъв клас става:

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

Разбирането как обектите (екземпляри на клас) се формират в паметта е много важно. Това дава указания за това как методите работят с данни от клас, как се извършва преобразуването на типа при множествено наследяване и т.н.

Е, няколко думи за методите. При наследяване в получения клас се комбинират само членове с данни! Всички методи съществуват в един екземпляр.

Забележка: Дадените примери са валидни, докато не се използва виртуално наследяване и класовете не съдържат виртуални методи. Тези случаи ще бъдат обсъдени по-нататък.

Достъп до данни от методи

Има често срещано погрешно схващане, че когато се създава обект, паметта се разпределя за самия клас, както и за всички функции. И че, както се предполага, това позволява на методите да манипулират данните на техния клас.

Това е фундаментално погрешно. Въпреки че всеки знае за тази ключова дума, не много хора се замислят какво всъщност представлява тя. Всичко е по-лесно от всякога.

Този запис може да се трансформира в:

Това е невалиден записгледна точка на компилатора и трябва да се третира като псевдокод. Той показва как компилаторът обработва извиквания на метод на ниско ниво.

Обикновени VS виртуални функции

  1. Методи на статични класове
  2. Конвенционални методи
  3. Виртуални функции

Как обикновените функции се различават от статичните функции, можете да намерите в предишния раздел. Тези два типа функции винаги се извикват директно от компилаторите.

Нека разгледаме извикването на обикновени функции по-подробно.

Ако сте внимателни, ще забележите, че скритият този параметър за bar е от тип B и ние предаваме тип D. Компилаторът извършва преобразуването на типа автоматично. Ето как се прави. В псевдокод клас D изглежда така:

Този интересен механизъм ви позволява да имате само едно копие на методите. Методите могат да работят само върху обекта, за който са дефинирани.

И накрая, помислете за 5-та опция за кол. В псевдокод изглежда така:

По принцип не се различава от варианта, разгледан по-горе. Просто трябва да обърнете внимание, че компилаторът използва метод от клас A, въпреки че всъщност създадохме обект от тип D. Това се дължи на факта, че в точката на извикване компилаторът работи с указател към обект от клас A и не знае нищо за това къде всъщност сочи.

Виртуалните функции се извикват индиректно и по време на компилиране не се знае кой метод ще бъде извикан. Това се нарича късно свързване. Всеки е чувал този термин, но само малцина представят какво се крие зад него и как работи. Опростено, можем да считаме, че виртуален метод винаги се извиква чрез указател на функция.

В псевдокод може да се напише:

Компилаторът ще преобразува тези редове в нещо подобно в псевдокод:

С други думи, всяка виртуална функция имауникален индекс в същата йерархия на класа. При извикване компилаторът намира указател към функцията с посочения индекс във виртуалната функционална таблица и я извиква. В нашия случай таблицата се състои от 2 елемента.

Веднага хваща окото ви, че извикващият код е много по-сложен, отколкото при обикновените функции и е малко по-бавен, защото. съдържа повече операции.

Сложността се е увеличила и кодът прави същото като невиртуалните функции. Каква е уловката? Всичко е свързано с указателя към виртуалната функционална таблица. Ако го промените така, че да сочи към различна таблица, ще бъдат извикани напълно различни методи. Красотата е, че кодът за извикване на функция остава постоянен и не е необходимо да се компилира отново, за да извика друг метод. За да илюстрираме как се случва това, нека създадем друг клас:

Както знаете, в процеса на създаване на клас първо се извикват конструкторите на базовия клас, а след това собственият конструктор. В нашия случай първо Vbase(), а след това V(). защото не сме дефинирали конструктор - компилаторът ще направи конструктор по подразбиране. Той, подобно на изричния конструктор, извършва редица манипулации, за да поддържа полиморфизма.

В самото начало на своята работа той задава указателя на виртуалната функционална таблица. За нашия пример конструкторът Vbase() ще го настрои на таблица от 2 елемента. Първият съдържа указател към Vbase::foo, а вторият съдържа указател към Vbase::bar(). След това ще бъде извикан конструкторът V() и ще нулира указателя към друга таблица, която вече съдържа 3 елемента: V::foo(), Vbase::bar(), V::alpha().

Конструкторът модифицира указатели към виртуалната функционална таблица за всички базови класове.

Обърнете внимание на следните неща:

  1. Ако производен клас претовари метод, записът в таблицата се променявиртуални функции.
  2. Ако производният клас съдържа виртуални методи, които не са били декларирани в родителския клас, те се добавят в края на таблицата.

Как се съхранява указателят и се създават виртуалните функционални таблици до голяма степен зависи от компилатора. Обикновено - таблиците на виртуалните функции са статични. Тези. те не се конструират, когато се създава всеки екземпляр на класа. Дори на етапа на компилация е налична цялата йерархия от полиморфни класове, така че компилаторът може да изгради тези таблици предварително. По време на изпълнение конструкторът на класа просто променя указателя към своята таблица. Тези. конструкторът на производния клас се извиква последен и следователно виртуалната функционална таблица винаги ще съответства на него.

Много е важно да разберете това. Квинтесенцията на всички тези теоретични обяснения е следната. Ако сме създали клас V, тогава обектът ще съдържа указател към таблица от този конкретен клас.

Сега нека преминем към практиката, за да покажем как работи всичко.

Първото извикване се прави на екземпляр на обект от клас V. Неговата таблица с виртуални функции съдържа указател към V::foo(), така че този метод ще бъде извикан. Вижда се, че тук извикването ще се окаже правилно, дори и да не използваме таблицата с виртуални функции, т.к. прави се за самия обект. Кой метод ще се използва зависи от компилатора и неговия оптимизатор.

Втората опция показва пълната мощ на виртуалните методи. Въпреки факта, че указателят е от тип Vbase, той сочи към обект от тип V. По-специално това означава, че указателят вътре в обекта сочи към таблицата с виртуални функции от клас V. Тоест в този случай ще бъде извикан и методът V::foo().

Третият вариант е подобен на втория. Изключение прави таблицатавиртуални функции от клас V съдържа указател към Vbase::bar(), защото този метод не е претоварен. Той ще бъде призован.

Третата опция за обаждане също не би трябвало да повдига въпроси, ако сте разбрали опция 1 и сте извикали обичайните методи. Vbase::bar() ще бъде извикан.

Четвъртият вариант е особено интересен. Очевидно ще бъде извикан методът Vbase::do(). Ако погледнете тялото му:

Вижда се, че извиква виртуалния метод. Както споменахме по-горе, достъпът до всички елементи на класа, вкл. и методи, се изпълнява чрез този указател. Това ви позволява точно да идентифицирате обекта, върху който трябва да работи методът. Тези. повикването може да бъде написано в псевдокод:

Може да се види, че се прави непряко извикване и следователно ще се използва таблицата с виртуални функции.

Не забравяйте, че това може да сочи към производни класове?

защото do() се извиква на екземпляр от клас V, тогава таблицата с виртуални методи съдържа указател към V::foo()! Това е фундаментално нещо, което ви позволява да промените поведението на базовите класове чрез производни. Ако виртуална функция се извика в базов клас, тогава тя може да бъде претоварена в производен клас. Базовият клас ще използва новия метод и няма нужда от повторно компилиране на кода.

C++ има абстрактни виртуални функции. Те се определят, както следва:

Интерфейси

Интерфейсът е конвенция за извикване на функция за модул. Интерфейсите могат да бъдат много различни. Това може просто да е списък с прототипи на функции, които са експортирани от DLL. Прототипът на клас може също да се разглежда като интерфейс. За COM технологията интерфейсът обикновено е фундаментална концепция.

Когато програмирате в C++, интерфейсът обикновено се разбира като полиморфен клас, който се състои от abstractметоди. Самият интерфейс не е от съществена стойност. Той дава вид конвенция за това как трябва да се използва обектът.

Книгите за ООП и C++ обичат да използват класове от графични примитиви за примери: точка, линия, кръг и т.н. Нека не преоткриваме колелото. Да предположим, че имаме някои от обектите, описани по-горе, и трябва да ги нарисуваме на екрана. Въпреки че обектите се рисуват по различен начин, операцията е същата. Следователно можем да направим един интерфейс за всички графични обекти:

Съдържа само един абстрактен метод. Конкретни случаи го използват по следния начин:

Сега, ако направим указател:

Таблицата с виртуални функции влиза в действие и въпреки факта, че указателят p е от тип IDraw, ще бъде извикан методът Cline::Draw(). Това е много удобно, т.к използвайки същия указател, можем да рисуваме обекти от всякакъв тип, които са извлечени от IDraw.

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

Конкретните начини за използване на интерфейси зависят само от вашето въображение.

Заключение

Невъзможно е да се натъпчат всички тънкости на езика C ++ в една статия. В езика има много конструкции, разбирането на които прави начинаещия специалист. Статията разглежда само основните понятия на езика, без познаването на които не е възможно по-нататъшно развитие и усъвършенстване.

Импулсът за написването на тази статия бяха голям брой разговори с различни програмисти. Сред тях имаше висшисти и такива, работили няколко години по специалността си. Сред стотиците хора има само дузина наистина знаещи програмисти. Повечето от тях са на много ниско ниво. И вината не е тяхна. Образователна система в регионапрограмирането не е перфектно - това е факт. Уроците съдържат само първоначална информация и инструкции кое меню и къде да щракнете с мишката. Днес ни учат как да правим конкретно нещо, но не ни показват пътя, който трябва да извървим, за да израснем в професионална посока.

В тази статия се опитах да събера отговори на тези въпроси, които най-често имат начинаещите. И се надявам, че помогна да открием някои аспекти на езика.