Мистецтво війни з С++. Перекриття віртуальних методів.
Не завжди недоліки мови можна побачити неозброєним оком. Деякі з них проявляються лише при вирішенні дуже специфічних завдань. Про існування цього я не знав аж доки кілька років тому не вирішив почати писати правильний код.
Я вирішував банальну задачу але з використанням популярних і часто рекомендованих підходів. Серед них були інтерфейси чисті абстрактні класи, коваріантність, розумні вказівники та фабричний метод. І можливо все було б добре, якби я не спробував застосувати всі підходи одночасно. У підсумку мені таки вдалось змусити все працювати, але не без велосипедів та милиць. І виною всьому був той факт, що в С++ неможливо перекрити віртуальний метод. Вам напевно цікаво як все це пов'язано?
Мета?
Розглянемо шаблон проектування фабричний метод. Його ідея полягає в тому, що ми делегуємо деякому об'єкту - фабриці - створення інших об'єктів (див. Приклад коду №1). При цьому і продукт який повертає фабрика, і метод його створення є поліморфними. Такий підхід дозволяє розширювати продукт та змінювати алгоритм його попередньої обробки, не втручаючись в код який цей продукт використовує (див. Приклад коду №2).
Приклад коду №1
Фабрика.
class Product
{
public:
virtual ~Product() = default;
};

class Factory
{
public:
virtual Product* Create() = 0;
};
Приклад коду №2
Реалізація фабрики.
class ProductA:
public Product
{
};

class FactoryA:
public Factory
{
public:
Product* Create() override
{
return new ProductA();
}
};

// не залежить від ProductA та FactoryA
void useProduct(Factory* factory)
{
auto product = factory->Create();
};
Шаблон фабричний метод є дуже популярним, оскільки він дає змогу системі розширюватись і робить користувачів залежними не від реалізацій, а від абстракцій. Проте реалізація цього шаблону в С++ може зіштовхнутись з проблемами. Зокрема, фабричний метод може виявитись несумісним з іншими цікавими підходами. Щоб показати це давайте спробуємо додати до нього коваріантність та розумні вказівники.
Пироги!
butterscotch-cinnamon pie
В якості прикладу ми розглянемо кондитерські вироби. Зокрема, пироги. Нам потрібен пекар який здатен їх пекти (див. Приклад коду №3). Причому пекарі та пироги розділені на два конкуруючі табори: пекарі які печуть пироги з корицею (див. Приклад коду №4) та пекарі які печуть пироги з ірисом (див. Приклад коду №5). В даному випадку пекар виступає фабрикою, а пиріг - продуктом цієї фабрики.
Приклад коду №3
Пироги та кондитери.
class Pie
{
public:
virtual ~Pie() = default;
};

class Baker
{
public:
virtual Pie* Bake() = 0;
};
Приклад коду №4
Кориця.
// пиріг з корицею
class CinnamonPie:
public Pie
{
};

// "корицевий" пекар
class CinnamonBaker:
public Baker
{
public:
Pie* Bake() override
{
return new CinnamonPie();
}
};
Приклад коду №5
Ірис.
// пиріг з ірисом
class ButterscotchPie:
public Pie
{
};

// "ірисовий" пекар
class ButterscotchBaker:
public Baker
{
public:
Pie* Bake() override
{
return new ButterscotchPie();
}
};
Час покращувати код!
Можна звернути увагу на те, що метод CinnamonBaker::Bake завжди повертає екземпляр класу CinnamonPie. Це важливо, адже можуть існувати користувачі які працює з класом CinnamonBaker безпосередньо1. В такому випадку вони могли б працювати з класом CinnamonPie замість Pie. Але через те що CinnamonBaker::Bake повертає Pie* а не CinnamonPie* їм доведеться виконувати зайве приведення, та ще й вручну (див. Приклад коду №6).
Аби запобігти цьому ми можемо використати той факт що класи Pie та CinnamonPie коваріанті. Це дозволяє нам замінити тип результату методу CinnamonBaker::Bake при заміщенні та повертати CinnamonPie* замість Pie* (див. Приклад коду №7). Аналогічно можна вчинити і з "ірисовими" класами.
Приклад коду №6
Аби працювати з CinnamonPie нам потрібно виконати приведення типів.
auto baker = new CinnamonBaker();
auto pie = baker->Bake(); // Pie*
auto cinnamonPie = dynamic_cast<CinnamonPie>(pie);
Приклад коду №7
Заміна типу результату при заміщенні методу.
// "корицевий" пекар
class CinnamonBaker:
public Baker
{
public:
// повертаємо CinnamonPie* замість Pie*
CinnamonPie* Bake() override
{
return new CinnamonPie();
}
};
Розумні вказівники?
Але є ще дещо що ми можемо покращити. Працювати з сирими вказівниками не надто приємно, оскільки це спонукає до ручного керування пам'яттю. На щастя саме з цих міркувань в С++ були додані розумні вказівники, які представлені в тому числі і шаблонним класом std::shared_ptr.
Було б просто чудово якби ми могли усього лише замінити зірочки на std::shared_ptr і не докладати зайвих зусиль. Але С++ не дозволить зробити це так просто (див. Приклади коду №8 та №9). З інтерфейсом - абстрактними класами Pie та Baker - жодних проблем, на нього подібна заміна ніяк не вплине. А от реалізація зіштовхнеться з труднощами...
Приклад коду №8
Ніяких проблем.
class Pie
{
public:
virtual ~Pie() = default;
};

class Baker
{
public:
virtual std::shared_ptr<Pie> Bake() = 0;
};
Приклад коду №9
Заміна типу результату при заміщенні методу.
// "корицевий" пекар
class CinnamonBaker:
public Baker
{
public:
// помилка std::shared_ptr<CinnamonPie> та
// std::shared_ptr<Pie> не є коваріантними
std::shared_ptr<CinnamonPie> Bake() override
{
return std::make_shared<CinnamonPie>();
}
};
Справа в тому що класи std::shared_ptr<Pie> та std::shared_ptr<CinnamonPie> не є коваріантними. Це природно, адже вони навіть не зв'язані ієрархією. Такий зв'язок мають лише аргументи цих шаблонів, що, в принципі, погоду не змінює. Привести шаблони до такої ж ієрархії теж не варіант2... що ж робити?
Можна було б провернути хитрий трюк (див. Приклад коду №10). По-перше, потрібно замістити віртуальний метод Bake() з класу Baker, тобто реалізувати його. По-друге, треба оголосити в класі CinnamonBaker новий метод і перекрити попередній, той що оголошено в базовому класі Baker. Цей новий метод повертатиме std::shared_ptr<CinnamonPie> і може взагалі бути не віртуальним.
Приклад коду №10
Спроба одночасно і замістити, і перекрити віртуальний метод.
// "корицевий" пекар
class CinnamonBaker:
public Baker
{
public:
// цей метод заміщує віртуальний метод базового класу
std::shared_ptr<Pie> Baker::Bake() override;
// а цей перекриває його, не заміщує
std::shared_ptr<CinnamonPie> Bake();
};
Таким чином ми могли б вбити двох зайців одним пострілом (див. Приклад коду №11)! Користувач, що буде працювати з CinnamonBaker через Baker буде звертатись до заміщеного методу Bake(), який повертає std::shared_ptr<Pie>. Якщо ж працювати з об'єктом через основний клас - CinnamonBaker - ми будемо звертатись до перекритого методу Bake() і отримувати std::shared_ptr<CinnamonPie>.
Приклад коду №11
Ось як це могло б працювати.
void process_interface(Baker* baker)
{
// тут ми отримуємо Pie
std::shared_ptr<Pie> type = baker.Bake();
}
void process_implementation(CinnamonBaker* baker)
{
// а тут - CinnamonPie
std::shared_ptr<CinnamonPie> type = baker.Bake();
}

void main()
{
// створюємо "корицевого" пекаря
CinnamonBaker* baker = new CinnamonBaker(type);
// в цій функції буде отримано std::shared_ptr<CinnamonPie>
process_implementation(baker);
// а в цій - std::shared_ptr<Pie>
process_interface(baker);
}
Зруйновані надії, розбиті серця...
Але в С++ неможливо зробити подібний трюк. Вся справа в тому, що ми не можемо перекрити віртуальний метод. Будь які спроби такого перекриття будуть сприйматись компілятором як намагання замістити віртуальний метод. Службових слів для вирішення подібних неоднозначностей немає.
І це проблема. Окрім цього, такий стан справ не дуже виправданий. Наприклад, в мові програмування C# подібної проблеми не існує. Ми вільні як заміщувати віртуальні методи, так і перекривати їх. Єдиний недолік - не вийде зробити це одночасно, в тілі одного класу3 (див. Приклад коду №12).
Приклад коду №12
Ось що про нашу проблему думає C#.
// шаблон для імітації розумних вказівників
class shared_ptr<T> {}

// Класи для пирогів
class Pie {}
class CinnamonPie: Pie {}

// "інтерфейс" для пекаря
abstract class Baker
{
public abstract shared_ptr<Pie> Bake();
}

// проміжний клас для обходу обмежень мови
class IntermediateCinnamonBaker: Baker
{
// заміщуємо віртуальний метод
public override shared_ptr<Pie> Bake()
{
return new shared_ptr<Pie>();
}
}

// реалізація "корицевого" пекаря
class CinnamonBaker: IntermediateCinnamonBaker
{
// перекриваємо віртуальний метод
public new shared_ptr<CinnamonPie> Bake()
{
return new shared_ptr<CinnamonPie>();
}
}
Цікаво, що заважало реалізувати подібне в С++? Як на мене, то мові явно не вистачає службового слова яке б вказувало що даний віртуальний метод потрібно перекрити а не замістити. Можливо таким службовим словом міг би стати overlap (див. Приклад коду №13).
Приклад коду №13
Явна вказівка на те, що метод потрібно перекрити, а не замістити.
class CinnamonBaker:
public Baker
{
...
public:
// перекриваємо метод
std::shared_ptr<CinnamonPie> Bake() overlap;
};
Милиці та ве́лики!
Оскільки в С++ не передбачено штатних засобів для вирішення даної задачі - доведеться вдатись до винахідництва. Обійти цей недолік можна по різному, але я приведу тут лише два класичних підходи.
Перша стратегія банальна і проста - ми просто дамо другому методу інше ім'я. Так, це не дуже гарно, але принаймні це працює. Якщо ми маємо можливість змінювати базовий клас то є сенс обізвати методи відповідно до назви класів які вони повертають (див. Приклад коду №14).
Приклад коду №14
Перша стратегія обходу даної вади - назвати методи довгими і надмірними іменами.
class Pie
{
};
class Baker
{
public:
virtual std::shared_ptr<Pie> BakePie();
};

class CinnamonPie: public Pie
{
};
class CinnamonBaker: public Baker
{
public:
std::shared_ptr<Pie> BakePie() override;
std::shared_ptr<CinnamonPie> BakeCinnamonPie(); // OK!
};
Але є і інша стратегія, причому вона дозволяє зберегти однакові назви публічних методів. Оскільки віртуальні методи перекривати не можна - ми будемо перекривати звичайні. Але щоб забезпечити поліморфізм ми введемо додатковий віртуальний метод який і буде надавати змогу змінити поведінку в похідних класах (див. Приклад коду №15). В даному випадку метод Bake() не віртуальний, його можна перекрити. Але доки його викликають через посилання на базовий клас Baker він завжди буде повертати std::shared_ptr<Pie>.
Приклад коду №15
Звичайни метод викликає віртуальний, який в свою чергу забезпечує поліморфізм.
class Pie
{
};
class Baker
{
protected:
virtual std::shared_ptr<Pie> BakePie() = 0;
public:
std::shared_ptr<Pie> Bake()
{
return BakePie();
}
};
При реалізації похідного класу нам необхідно замістити BakePie(). Тут ми зможемо проводити ручне приведення типів, перетворювати std::shared_ptr<CinnamonPie> в std::shared_ptr<Pie>. Але з іншого боку у нас зберігається можливість перекрити Bake() і повертати саме той клас, який є актуальним при роботі з CinnamonBaker (див. Приклад коду №16).
Приклад коду №16
Тепер похідний клас може в деякому розумінні і перекрити і замістити необхідний метод.
class CinnamonPie: public Pie
{
};
class CinnamonBaker: public Baker
{
protected:
std::shared_ptr<Pie> BakePie() override
{
return std::static_pointer_cast(Bake());
}
public:
std::shared_ptr<CinnamonPie> Bake()
{
return std::make_shared<CinnamonPie>();
}
};
Підсумки
Остання реалізація виглядає трохи дивною, вивернутою назовні. Це і справді так, адже віртуальним довелось зробити зовсім інший метод. Але принаймні вона дозволяє забезпечити однакові назви для публічних методів.
Це сумно що в наші дні доводиться вдаватись до подібних трюків. Але якщо дуже хочеться працювати з С++ і разом з цим використовувати популярні підходи до проектування, то, схоже, такі хитрощі неминучі. Залишається лише сподіватись на краще :с
1
Тут йдеться про тих користувачів які не користуються абстракцією Pie та Baker, а працюють з об'єктами котрі вже приведені до типів CinnamonPie та CinnamonBaker.
2
Тих засобів якими оперують шаблони в С++ в загальному випадку може бути недостатньо.
3
В C# дану задачу можна було б вирішити і набагато простіше. Але задля коректності порівняння механізмів мови C# та С++ я наклав додаткові умови. Зокрема я використав шаблонний клас для shared_ptr замість вбудованих посилань та оголосив IType і IVariable як абстрактні класи, а не як інтерфейси. Таким чином порівняння є більш справедливим, оскільки ми вирішуємо одну і ту ж задачу одними і тими ж методами.