• Главная
  • Карта сайта
Не найдено

Віртуальне спадкування (comp.soft.prog.devdoc): Розсилка: Subscribe.Ru

  1. Вступ
  2. множинне спадкування
  3. Ромбоподібне «успадкування»
  4. віртуальне спадкування
  5. низькорівнева реалізація
  6. Ініціалізація базового класу
  7. висновок
  8. Посилання по темі

Привіт шановні передплатники, сьогодні в номері:

Сьогодні ми будемо говорити про віртуальному спадкуванні. Мало хто його використовує, проте знати про нього вельми корисно. Стаття нижче.

На цьому тижні в движок сайту було внесено деякі зміни. З'явилася можливість додавати коментарі / питання / побажання до всіх статей. Робиться це буквально одним кліком миші, якщо ви читаєте статтю з сайту. Я до речі рекомендую це робити, тому що найчастіше матеріали містять малюнки. Крім того ставити оцінки статей тепер дозволено всім, а не тільки зареєстрованим користувачам.

Я хочу подякувати всім, хто надсилає мені свої відгуки та побажання. На сайт додана форма голосування для визначення тематики публікацій. Користуйтеся!

Зараз йде підготовка до організації конкурсу на кращу статтю для сайту www.devdoc.ru. Ми плануємо опублікувати правила участі в наступному випуску розсилки. Всім учасникам дістанеться подарунок, а переможці будуть нагороджені цінними призами. Прошу надсилати мені свої думки і пропозиції за правилами проведення конкурсу.

Увага! Компанія CycloneSoft набирає програмістів на постійну роботу в м Ростові-на-Дону. Робота в офісі. Обов'язкова вимога - відмінне знання мови C ++ і щирий інтерес до професії. Резюме направляйте за адресою [email protected].

Вступ

Наследованіе- це один з трьох «китів», на яких будується C ++. Ця тема детально розжовується в численних підручниках з мови і публікаціях в журналах і в мережі. Проте, в них акцент робиться більше на опис синтаксису, але не пояснюється, чому відбувається так, а не по-іншому. У статті " Віртуальні функції - низькорівневий погляд »Розглядається просте успадкування. Там наводяться приклади організації пам'яті при спадкуванні, а також те, як методи працюють з полями класу. Рекомендую прочитати цю статтю - вона допоможе зрозуміти справжній матеріал.

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

Всі приклади були протестовані за допомогою MS VC 2003 .NET. Багато речей залежать від реалізації, але базові концепції залишаються однаковими для всіх компіляторів.

множинне спадкування

Про проектування ієрархії класів говорили всі кому не лінь. Хтось у справі, а хтось про «імітації об'єктів реального світу». Я не буду засмічувати статтю міркуваннями про те, навіщо це потрібно і як правильно. Ми будемо розглядати синтаксис мови, а також те, що відбувається по інший бік компілятора.

Отже, ієрархія при множині спадкування може виглядати так:

Отже, ієрархія при множині спадкування може виглядати так:

На C ++ таке дерево описується наступним чином:

class A2 {int a2; }; class B1 {int b1; }; class B2: public A2 {int b2; }; class C: public B1, public B2 {int c; };

Як ми вже знаємо, при створенні об'єкта пам'ять розподіляється тільки для даних класу. Для прикладу кожен клас містить одну змінну типу int. При множині спадкування клас C буде містити дані всіх базових класів. При цьому в пам'яті вони будуть групуватися в тому ж порядку, в якому класи згадуються в списку успадкування. У нашому випадку, клас C буде містити спочатку дані класу B1, потім A2, B2 і, нарешті, С.

На псевдокоді це буде виглядати так:

struct C {int b1; int a2; int b2; int c; };

Конструктори для ініціалізації об'єкта C викликаються як зазвичай: спочатку конструктори базових класів, а потім похідних. Якщо базових класів кілька - конструктори викликаються в тому ж порядку, в якому вони задані в списку успадкування. У нашому випадку конструктори будуть викликатися в наступному порядку:

B1 (), A2 (), B2 (), C ().

Деструктори викликаються в зворотному порядку. Все як завжди.

Тепер розглянемо іншу ієрархію, в якій класи B1 і B2 похідні від класу A.

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

Тепер припустимо, що нам треба створити клас C, похідний від B1 і B2. Тоді при класичному множині спадкування ланцюжок набуде вигляду:

Як бачимо, тепер клас C містить дві копії класу A. На C ++ така ієрархія описується так:

class A {int a; public: void foo () {a = 0; }; }; class B1: public A {int b1; }; class B2: public A {int b2; }; class C: public B1, public B2 {int c; };

Цей код компілюється без помилок. Якщо тепер створити об'єкт і викликати метод foo:

C c; c. foo ();

Компілятор видасть помилку. Чому? Давайте згадаємо, як працює приведення типів, і як методи працюють з полями об'єктів через покажчик this.

На псевдокоді попередній виклик виглядає так:

foo ((A *) & c); // функції передається прихований покажчик this на об'єкт // з яким вона повинна працювати.

Помилка полягає в приведенні типу, тому що клас C містить дві копії класу A. Ми повинні явно вказати, або яку копію використовувати, або заново визначити метод foo в класі C. Нижче наведені обидва варіанти вирішення:

1. class C: public B1, public B2 {int c; public: using B1 :: foo; }; 2. class C: public B1, public B2 {int c; public: void foo () {B2 :: foo (); B1 :: foo ();}; };

Варіанти мають невеликі відмінності. У першому ми явно вказуємо, що треба використовувати B1 :: foo (), а в другому ми переопределили метод foo і перенаправили виклик до обох копіях класу A. В какіх_то випадках це може бути прийнятним поведінкою, але обидва методи мають істотні недоліки. У першому випадку - ми будемо використовувати тільки одну з копій об'єкта A. Друга копія у нас просто «повисне» і програма в цілому може працювати невірно, тому що клас B нічого не знає про те, що його предок не використовується. Тобто друга копія підоб'єкту A ніяк не використовуватиметься, але буде займати пам'ять. У другому варіанті - просто виконується дублювання операцій, і як наслідок виникає необхідність підтримувати синхронність обох подоб'ектов типу A. Це поганий стиль і розсадник всіляких помилок.

Ромбоподібне «успадкування»

Дивлячись на попередній малюнок, напрошується рішення виниклої проблеми. Було б здорово, якби клас C містив в собі тільки одну копію класу A. Можна вирішити задачу в лоб. Розглянемо наступний приклад:

class A {int a; public: void foo () {a = 0;}; }; class B1 {int b1; protected: A * pClassA; B1 (A * p): pClassA (p) {}; }; class B2 {int b2; protected: A * pClassA; B2 (A * p): pClassA (p) {}; }; class C: public B1, public B2 {int c; public: C (A * p): B1 (p), B2 (p) {}; void foo () {B1 :: pClassA -> foo (); }; }; int _tmain (int argc, _TCHAR * argv []) {A * pA = new A; C c (pA); c. foo (); delete pA; return 0; }

Тут спадкування замінили агрегацией. Діаграма виглядає наступним чином:

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

Програма створює екземпляр класу A і передає його в якості параметра в конструктор класу C. Той в свою чергу инициализирует цим значенням покажчики в B1 і B2. Т.о, ці два класи посилаються на один екземпляр об'єкта A. Що власне і було потрібно отримати. Безумовно, створення екземпляра класу A таким способом - не найкращий архітектурне рішення. Оптимальний варіант, коли клас C сам буде створювати і руйнувати цю копію для уникнення плутанини. Це також дозволить реалізувати стратегію функціонального замикання. Тобто об'єкт типу A створюється в конструкторі класу C, а руйнується в деструкції. Це гарантовано позбавляє нас від витоку пам'яті.

Зверніть увагу, що в класі C довелося перевизначити метод foo. Це потрібно для того, щоб вирішити конфлікт. Програма повинна використовувати покажчик або з об'єкта B1, або з B2 для доступу до A. Насправді їх можна використовувати упереміш - все одно вони вказують на один і той же об'єкт.

Зверніть увагу, що наведене рішення не можна назвати «спадкуванням». Справжнє ромбовидное спадкування досягається іншими засобами. Про них читайте в наступному розділі.

віртуальне спадкування

І ось тепер найсмачніше! Розробники мови передбачили, що при множині спадкування може утворюватися ромбовидна ієрархія. Для вирішення конфлікту доступу до подоб'екти можна використовувати віртуальне успадкування:

class A {int a; public: void foo () {a = 0;}; }; class B1: virtual public A {int b1; }; class B2: virtual public A {int b2; }; class C: public B1, public B2 {int c; };

Такий код породжує наступну ієрархію:

Як бачимо, цей приклад не сильно відрізняється від того, який використовує класичне множинне спадкування. Різниця тільки в списку базових класів для B1, B2. Там додалося ключове слово virtual. Воно дає вказівку компілятору, що ці класи можуть утворювати ромбовидні структури, як на малюнку вище.

низькорівнева реалізація

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

Насправді компілятор робить практично те ж саме, що ми розглядали в розділі Ромбоподібне «успадкування». Він додає в початок класів B2 і B1 додатковий покажчик на таблицю віртуальних класів. Тепер, коли потрібно звернутися до полів класу A - компілятор визначає адресу єдиного екземпляра через цю таблицю. Незважаючи на те, що внутрішня структура віртуального успадкування більше схожа на агрегацію, з точки зору мови вона нічим не відрізняється від звичайного.

class A {int a; public: void foo () {a = 0;}; }; class B1: virtual public A {int b1; }; class B2: virtual public A {int b2; }; class C: public B1, public B2 {int c; }; int _tmain (int argc, _TCHAR * argv []) {Із c; c. foo (); return 0; }

Тепер виклик c.foo () не викликає ніяких проблем, тому що компілятор автоматично виконує всі необхідні перетворення для приведення типів. Зараз в ієрархії знаходиться тільки один подоб'екти класу A, тому конфлікту не виникає.

Реалізація таблиці віртуальних класів (ТВК) ніяк не регламентується і лежить на совісті розробників компілятора. Я для прикладу розберу, що робить компілятор MS VC ++ 2003 .NET, щоб ви краще розуміли що відбувається.

Отже, будемо розглядати класи з попередніх прикладів. При створенні класу C компілятор формує в пам'яті такі структури:

При створенні класу C компілятор формує в пам'яті такі структури:

Таблиці віртуальних класів містять зміщення відносно початку класу для доступу до полів класу А. Так ТВК для B1 містить зсув 0x18 (24) щодо початку класу B1 для доступу до полів класу A. Зверніть увагу, що перший елемент ТВК містить нульовий зсув. Очевидно, що це зміщення для доступу до власних полях. ТВК для B2 містить вже інше зсув, тому що його позиція в межах всього класу C вже інша. ТВК, як і таблиці віртуальних функцій, розподіляються статично. Тобто одна копія таблиці використовується всіма класами типу C.

Зверніть увагу ще на одну відмінність: розподіл пам'яті для класу C. У нашому прикладі з агрегацією ми використовували динамічний розподіл, в той час як компілятор просто розміщує єдину копію підоб'єкту A в кінці об'єкта. Для цього є ряд об'єктивних причин. Виділяючи пам'ять для об'єкта єдиним блоком істотно зменшуються накладні витрати. Крім того, з'являється можливість розподіляти пам'ять для об'єкта на стеку. Об'єкт C займає одну безперервну область пам'яті. Тому ТВК замість покажчиків містять зміщення. Це теж істотно заощаджує ресурси, тому що дозволяє мати тільки одну копію ТВК для всіх класів. Все зміщення завжди константи, незалежно від того, де виділена пам'ять для об'єкта.

Ініціалізація базового класу

Уважні читачі вже напевно помітили одну цікаву річ, пов'язану з ініціалізацією базового класу. Розглядаючи попередній приклад, можна побачити, що поля класу A доступні двома способами: B1 :: a і B2 :: a. Якщо не використовується віртуальне успадкування, то ініціалізація відбувається як завжди. Спочатку викликаються конструктори базових класів, а потім похідних. Навіть якщо ієрархія містить два і більше однакових об'єкта - вони будуть проініціалізовані. Кожен з них по своїй гілці дерева успадкування.

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

Розробники мови передбачили такий варіант. Погляньте ще раз на приклад, де ми замінили спадкування агрегацией. Компілятор робить все приблизно так само! Ініціалізація класу A виконується з самого нижнього похідного класу. Робиться це в такий спосіб:

class A {int a; public: A (int iInit): a (iInit) {}; void foo () {a = 0;}; }; class B1: virtual public A {int b1; public: B1 (): b1 (0xb1), A (0xb10a) {}; }; class B2: virtual public A {int b2; public: B2 (): b2 (0xb2), A (0xb20a) {}; }; class C: public B1, public B2 {int c; public: C (): c (0x0c), A (0x0c0a) {}; }; class D: public C {int d; public: D (): d (0x0d), A (0x0d0a) {}; }; int _tmain (int argc, _TCHAR * argv []) {D d; d. foo (); return 0; }

Перед вами трохи модифікований приклад. Як бачите, кожен клас, який є похідним від класів з віртуальним спадкуванням, повинен викликати конструктор базового класу A. Це робиться для того, щоб можна було використовувати кожен тип для створення об'єктів. У нашому випадку відбувається наступне. Коли ми створюємо об'єкт класу D, викликається його конструктор. Він починає ініціалізацію з виклику конструкторів базових класів. В першу чергу він викликає конструктор A :: A (int). Далі викликається C :: C і потім виконується ініціалізація самого об'єкта D. Конструктор класу C також діє схожим чином. Його список ініціалізації теж містить звернення до конструктору A :: A (int). Однак перед його викликом перевіряється, чи був подоб'екти А ініціалізованим першим. Якщо ініціалізація виконувалася, то виклик A :: A (int) не проводиться. І так далі по ланцюжки. Насправді в конструкторі D :: D () теж перевіряється, чи потрібно форматувати клас A. Це зроблено для того, щоб весь код був типовим.

висновок

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

Вивчати все особливості генерації коду на різних компіляторах немає сенсу. Всі вони схожі один на одного. Головне - вловити ідею того, як компілятор реалізує складні конструкції. Як виконуються операції, в якій послідовності і т.п. Це сильно допомагає в пошуках «чарівних» помилок і в розробці надійного, красивого і високопродуктивного коду.

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

Сподіваюся, що для Вас стаття також принесла користь, і Ви зможете подолати ще одну сходинку майстерності.

Посилання по темі

  1. Віртуальні функції - низькорівневий погляд
  2. Порядок ініціалізації C ++ об'єкта - це важливо!

Якщо вам подобатися ця розсилка рекомендуйте її своїм друзям. Підписатися можна за адресою http://subscribe.ru/catalog/comp.soft.prog.devdoc

Чому?
Який варіант вибирає компілятор?
Провайдеры:
  • 08.09.2015

    Batyevka.NET предоставляет услуги доступа к сети Интернет на территории Соломенского района г. Киева.Наша миссия —... 
    Читать полностью

  • 08.09.2015
    IPNET

    Компания IPNET — это крупнейший оператор и технологический лидер на рынке телекоммуникаций Киева. Мы предоставляем... 
    Читать полностью

  • 08.09.2015
    Boryspil.Net

    Интернет-провайдер «Boryspil.net» начал свою работу в 2008 году и на данный момент является одним из крупнейших поставщиков... 
    Читать полностью

  • 08.09.2015
    4OKNET

    Наша компания работает в сфере телекоммуникационных услуг, а именно — предоставлении доступа в сеть интернет.Уже... 
    Читать полностью

  • 08.09.2015
    Телегруп

    ДП «Телегруп-Украина» – IT-компания с 15-летним опытом работы на рынке телекоммуникационных услуг, а также официальный... 
    Читать полностью

  • 08.09.2015
    Софтлинк

    Высокая скоростьМы являемся участником Украинского центра обмена трафиком (UA — IX) с включением 10 Гбит / сек... 
    Читать полностью