Віртуальні функції – низькорівневий погляд (c) Розсилка

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

Тонкощі наслідування

Віртуальні функції немає сенсу, якщо немає успадкування. Я не буду докладно на ньому зупинятися - основи можна знайти в будь-якому підручнику C++.

Є кілька речей, які знати просто обов'язково.

Спадкування – це створення нових об'єктів

Компілятор будує дерево наслідування у процесі компіляції. Щоб скомпілювати код, йому має бути доступна інформація про всі базові класи. Все це недарма, як казав Вінні-Пух.

Скажімо, у нас є класи:

Дивлячись на цей запис, можна подумати, що компілятор генерує код, в якому є вказівка ​​на зв'язок між класами. Це не так!

У літературі, форумах і навіть при спілкуванні між програмістами про класи A та B говорять як про різні сутності. Це зручно, коли йдеться про розмежування функціоналу між класами або дерево успадкування. На жаль, це вводить в оману початківців.

Що ж відбувається насправді? Така форма запису була вигадана, щоб не дублювати код батьківського класу. Це полегшує сприйняття програміста і дозволяє подати всі класи у вигляді дерева. Безумовно, у своїй роботі компілятор використовує і цю інформацію, наприклад, для приведеннятипів. З іншого боку, у пам'яті процесу, з погляду компілятора, клас B буде виглядати як монолітний блок пам'яті, який містить 2 змінні. Тобто. виходить щось на кшталт цього:

Я свідомо використав struct, т.к. у відкомпілюваному коді немає модифікаторів доступу – це угоди мови. Тобто. видно, що компілятор на виході просто об'єднав обидва класи. При множинному успадкування відбувається те саме, тільки трохи складніше: дані в пам'яті об'єднуються в тому ж порядку, що і в списку успадкування. Наприклад:

Такий клас перетворюється на:

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

Розуміння того, як формуються об'єкти (примірники класів) у пам'яті дуже важливе. Це дає ключ до того, як методи оперують даними класів, як відбувається перетворення типів при множинному успадкування тощо.

Та й кілька слів про методи. При наслідуванні в результуючому класі об'єднуються лише члени з даними! Усі методи існують у єдиному екземплярі.

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

Доступ до даних методів

Існує поширена хибна думка, що при створенні об'єкта виділяється пам'ять під сам клас, а також під всі функції. І що нібито це дозволяє методам маніпулювати даними свого екземпляра класу.

Це докорінно неправильно. Хоча всі знають про ключове слово this - не багато хто замислюється про те, що ж це таке насправді. Все простіше нема куди.

Такий запис можна трансформувати в:

Це некоректний запис зточки зору компілятора та її треба розглядати як псевдокод. Вона показує, як компілятор опрацьовує виклики методів на низькому рівні.

Звичайні функції VS віртуальні

  1. Статичні методи класу
  2. Звичайні методи
  3. Віртуальні функції

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

Розглянемо виклик звичайних функцій докладніше.

Якщо ви уважні – можна помітити, що прихований параметр this для 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++ під інтерфейсом зазвичай розуміють поліморфний клас, що складається з абстрактнихметодів. Сам собою інтерфейс не становить істотної цінності. Він дає своєрідну угоду про спосіб використання об'єкта.

У книгах по ООП і C++ люблять використовувати для прикладів класи графічних примітивів: точка, лінія, коло тощо. Не винаходитимемо велосипед. Припустимо, що ми маємо декілька об'єктів, описаних вище, і нам треба малювати їх на екран. Незважаючи на те, що об'єкти малюють себе по-різному – операція одна й та сама. Тому ми можемо створити один інтерфейс для всіх графічних об'єктів:

Він містить лише один абстрактний метод. Конкретні екземпляри використовують його так:

Тепер якщо ми зробимо покажчик:

У гру входить таблиця віртуальних функцій і, незважаючи на те, що покажчик p має тип IDraw, - викликається метод Cline:: Draw (). Це зручно, т.к. використовуючи той самий покажчик, ми можемо малювати об'єкти будь-якого типу, які є похідними від IDraw.

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

Конкретні методи використання інтерфейсів залежать тільки від Вашої фантазії.

Висновок

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

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

У цій статті я постарався зібрати відповіді на ті питання, які виникають у початківців найчастіше. І я сподіваюся, що вона допомогла відкрити собі якісь межі мови.