Мені хотілося заскочити сюди серед цих вже чудових відповідей і визнати, що я сприйняв некрасивий підхід до того, щоб насправді діяти назад до антитіни зміни поліморфного коду на switches
або if/else
гілки з вимірюваними вигодами. Але я цього не робив оптом, лише для найбільш критичних шляхів. Це не повинно бути таким чорним і білим.
Як відмова від відповідальності, я працюю в таких сферах, як променева стрічка, де коректності не так складно досягти (і часто все-таки нечітка і наближена), в той час як швидкість часто є однією з найбільш конкурентоспроможних якостей. Скорочення часу візуалізації часто є одним із найпоширеніших запитів користувачів, і ми постійно чухаємо голову і придумуємо, як цього досягти для найбільш критичних вимірюваних шляхів.
Поліморфне рефакторинг умов
По-перше, варто зрозуміти, чому поліморфізм може бути кращим з аспекту ремонтопридатності, ніж умовне розгалуження ( switch
або купа if/else
тверджень). Основна перевага тут - розширюваність .
За допомогою поліморфного коду ми можемо ввести новий підтип до нашої кодової бази, додати екземпляри його до якоїсь поліморфної структури даних, і всі існуючі поліморфні коди все ще працюють автоматично без додаткових модифікацій. Якщо у вас є купа коду, розпорошеного по великій кодовій базі, що нагадує форму "Якщо цей тип" foo ", зробіть це" , ви можете зіткнутися з жахливим тягарем оновлення 50 різних розділів коду для того, щоб ввести новий тип речей, і все ще в кінці не вистачає кількох.
Переваги поліморфізму в технічному обслуговуванні тут природно зменшуються, якщо у вас є лише пара або навіть один розділ вашої кодової бази, який потребує проведення таких перевірок.
Оптимізаційний бар'єр
Я б запропонував не дивитись на це з точки зору розгалуження та конвеєрного перегляду, а більше на це дивитись з точки зору дизайнерського мислення оптимізаційних бар'єрів. Існують способи вдосконалення прогнозування галузей, які застосовуються до обох випадків, наприклад сортування даних на основі підтипу (якщо вони вписуються в послідовність).
Що відрізняється між цими двома стратегіями - це кількість інформації, яку оптимізатор має заздалегідь. Відомий виклик функції надає набагато більше інформації, непрямий виклик функції, який викликає невідому функцію під час компіляції, призводить до бар'єру оптимізації.
Коли функція, що викликається, відома, компілятори можуть знищити структуру і зменшити її до мізерних рядків, вбудованих викликів, усуваючи потенційне псевдонім, виконуючи кращу роботу при розподілі інструкцій / реєстрів, можливо, навіть переставляючи петлі та інші форми гілок, генеруючи важко -кодовані мініатюрні LUT, коли це доречно (щось GCC 5.3 нещодавно мене здивувало switch
твердженням, використовуючи жорстко закодований LUT даних для результатів, а не таблицю стрибків).
Деякі з цих переваг втрачаються, коли ми починаємо вводити в суміш невідомі часу компіляції, як у випадку виклику непрямої функції, і саме там умовне розгалуження, швидше за все, може запропонувати перевагу.
Оптимізація пам'яті
Візьмемо приклад відеоігри, яка складається з опрацювання послідовності істот багаторазово в тісному циклі. У такому випадку у нас може бути такий поліморфний контейнер:
vector<Creature*> creatures;
Примітка: для простоти я unique_ptr
тут уникав .
... де Creature
поліморфний базовий тип. У цьому випадку одна з труднощів з поліморфними контейнерами полягає в тому, що вони часто хочуть виділити пам'ять для кожного підтипу окремо / індивідуально (наприклад: використання закидання operator new
за замовчуванням для кожної окремої істоти).
Це часто робить першочерговим завданням для оптимізації (якщо це нам знадобиться) на основі пам'яті, а не розгалуження. Однією з стратегій є використання фіксованого розподільника для кожного підтипу, заохочення суміжного представлення шляхом виділення великих фрагментів та об'єднання пам'яті для кожного виділеного підтипу. З такою стратегією, безумовно, може допомогти сортувати цей creatures
контейнер за підтипом (а також адресою), оскільки це не тільки можливо покращує прогнозування галузей, але й покращує місце розташування (дозволяє отримати доступ до кількох істот одного підтипу з одного рядка кешу до виселення).
Часткова девіртуалізація структур даних і циклів
Скажімо, ви пройшли всі ці рухи і все ще бажаєте більшої швидкості. Варто відзначити, що кожен крок, на який ми ризикуємо тут, погіршує ремонтопридатність, і ми вже знаходимося на стадії трохи шліфування металу з зменшенням віддачі від продуктивності. Таким чином, потрібно мати досить значний попит на продуктивність, якщо ми будемо ступати на цю територію, де ми готові ще більше пожертвувати технічним обслуговуванням для менших та менших прибутків.
Але наступним кроком, який потрібно спробувати (і завжди з готовністю відмовитись від змін, якщо це зовсім не допомагає), може стати ручна девіартуалізація.
Порада щодо управління версіями: якщо ви не набагато більш сприятливі щодо оптимізації, ніж я, може бути варто створити нову гілку в цей момент з готовністю відкинути її, якщо наші зусилля з оптимізації пропустять, що може дуже трапитися. Для мене це все випробування та помилки після подібних очок, навіть із профілером у руці.
Тим не менш, ми не повинні застосовувати цей спосіб мислення оптом. Продовжуючи наш приклад, скажімо, ця відеоігри, безумовно, складається з людських істот. У такому випадку ми можемо деаргуалізувати лише людські істоти, піднявши їх та створивши окрему структуру даних саме для них.
vector<Human> humans; // common case
vector<Creature*> other_creatures; // additional rare-case creatures
Це означає, що всі області в нашій кодовій базі, які потребують обробки істот, потребують окремого циклу для особливих випадків для людських істот. Однак це усуває динамічні накладні витрати (або, можливо, більш доцільно, бар'єр оптимізації) для людей, які, безумовно, є найпоширенішим типом істот. Якщо цих районів велика кількість і ми можемо собі це дозволити, ми можемо зробити це:
vector<Human> humans; // common case
vector<Creature*> other_creatures; // additional rare-case creatures
vector<Creature*> creatures; // contains humans and other creatures
... якщо ми можемо собі це дозволити, менш критичні шляхи можуть залишатися такими, якими вони є, і просто обробляти всі типи істот абстрактно. Критичні шляхи можуть оброблятись humans
в одному циклі та other_creatures
в другому циклі.
Ми можемо розширити цю стратегію в міру необхідності і, можливо, принести певні переваги таким чином, але варто відзначити, наскільки ми деградуємо ремонтопридатність у процесі. Використання тут шаблонів функцій може допомогти генерувати код як для людей, так і для істот, не дублюючи логіку вручну.
Часткова девіртуалізація занять
Щось я робив років тому, що було дійсно грубим, і я навіть не впевнений, що це вже вигідно (це було в епоху С ++ 03), було частковою девіартуалізацією класу. У цьому випадку ми вже зберігали ідентифікатор класу з кожним екземпляром для інших цілей (доступ до них через аксесуар у базовому класі, який був невіртуальним). Там ми зробили щось аналогічне цьому (моя пам'ять трохи туманна):
switch (obj->type())
{
case id_common_type:
static_cast<CommonType*>(obj)->non_virtual_do_something();
break;
...
default:
obj->virtual_do_something();
break;
}
... де virtual_do_something
було реалізовано для виклику невіртуальних версій у підкласі. Я знаю, це грубо, явно роблю явний статичний пониження, щоб відхилити функціональний виклик. Я поняття не маю, наскільки це вигідно зараз, оскільки я не пробував такі речі протягом багатьох років. Під впливом дизайну, орієнтованого на дані, я виявив, що вищезазначена стратегія розбиття структур даних та циклів гарячим / холодним способом набагато корисніша, відкриваючи більше дверей для стратегій оптимізації (і набагато менш потворних).
Девіртуалізація оптом
Я мушу визнати, що я ніколи не стикався із застосуванням оптимізаційного мислення, тому не знаю про переваги. Я уникав непрямих функцій передбачення в тих випадках, коли я знав, що буде лише один центральний набір умовних умов (наприклад: обробка подій із лише одним центральним місцем подій обробки), але ніколи не починався з поліморфного мислення та оптимізував весь шлях аж тут.
Теоретично, безпосередні переваги тут можуть бути потенційно меншим способом ідентифікації типу, ніж віртуальний покажчик (наприклад: один байт, якщо ви можете взяти на себе думку про наявність 256 унікальних типів або менше) на додаток до повного усунення цих бар'єрів оптимізації. .
У деяких випадках це може також допомогти написати простіший в обслуговуванні код (порівняно з оптимізованими прикладами ручної девіатуризації, наведеними вище), якщо ви просто використовуєте одне центральне switch
твердження без необхідності розділяти ваші структури даних і цикли на основі підтипу, або якщо є замовлення -залежність у цих випадках, коли речі доводиться обробляти в точному порядку (навіть якщо це змушує нас гілкуватися всюди). Це буде у випадках, коли у вас немає занадто багато місць, які потрібно зробити switch
.
Як правило, я б не рекомендував це навіть при дуже критичній продуктивності, якщо це досить просто підтримувати. "Легкий в обслуговуванні", як правило, залежить від двох домінуючих факторів:
- Не маючи реальної потреби в розширюваності (наприклад: знаючи точно, що у вас є рівно 8 типів речей для обробки, і ніколи більше).
- У коді не так багато місць, які потребують перевірки цих типів (наприклад, одне центральне місце).
... все ж я рекомендую описаний вище сценарій у більшості випадків і перебирати на ефективніші рішення шляхом часткової девіатуризації за необхідності. Це дає вам набагато більше місця для дихання, щоб збалансувати розтяжність та потребу в ремонті з продуктивністю.
Віртуальні функції проти покажчиків функцій
Як щось над цим, я помітив тут, що була певна дискусія щодо віртуальних функцій проти функціональних покажчиків. Це правда, що віртуальним функціям потрібна трохи додаткова робота для виклику, але це не означає, що вони повільніші. Протиінтуїтивно, це може навіть зробити їх швидшими.
Тут це протиінтуїтивно, тому що ми звикли вимірювати вартість з точки зору інструкцій, не звертаючи уваги на динаміку ієрархії пам’яті, яка, як правило, має значно більший вплив.
Якщо ми порівнюємо class
з 20 віртуальними функціями порівняно з struct
котрими, що зберігає 20 функціональних покажчиків, і обидві інстанціюються багаторазово, накладні витрати пам'яті кожного class
екземпляра в цьому випадку 8 байт для віртуального вказівника на 64-бітних машинах, в той час як пам'ять накладні витрати struct
- 160 байт.
Практична ціна може бути набагато більше примусових і необов’язкових пропусків кешу в таблиці покажчиків функцій порівняно з класом з використанням віртуальних функцій (і, можливо, помилок сторінки при досить великій шкалі введення). Ця вартість, як правило, зменшує додаткову роботу щодо індексації віртуальної таблиці.
Я також мав справу зі застарілими базами С (старіші за мене), де перетворення таких structs
наповнених покажчиками функцій та багато разів інстанційоване фактично дало значні підвищення продуктивності (понад 100% покращення), перетворюючи їх у класи з віртуальними функціями, і просто через масове скорочення використання пам’яті, підвищену зручність кешу тощо.
З іншого боку, коли порівняння стають більше щодо яблук до яблук, я також виявив, що протилежний спосіб мислення переводити з націлювання на віртуальні функції С ++ у настрій мислення, що вказує на стиль С, корисний у таких типах сценаріїв:
class Functionoid
{
public:
virtual ~Functionoid() {}
virtual void operator()() = 0;
};
... де клас зберігав одну непомірну завищену функцію (або дві, якщо рахувати віртуального деструктора). У цих випадках напевно це може допомогти в критичних шляхах перетворити це на це:
void (*func_ptr)(void* instance_data);
... в ідеалі за безпечним для інтерфейсу інтерфейсом, щоб приховати небезпечні касти в / з void*
.
У тих випадках, коли ми спокушаємося використовувати клас з однією віртуальною функцією, це може швидко допомогти використовувати вказівники функції. Велика причина навіть не обов'язково - зниження вартості виклику функціонального вказівника. Це тому, що ми більше не стикаємося з спокусою виділити кожен окремий функціональний агент на розсіяні ділянки купи, якщо ми об'єднаємо їх у стійку структуру. Такий підхід може полегшити уникнення нагромадження, пов’язаних із нагромадженням та надмірною фрагментацією пам'яті, якщо дані екземпляра є однорідними, наприклад, і змінюється лише поведінка.
Тож, безумовно, є деякі випадки, коли використання покажчиків функцій може допомогти, але часто я виявляю це навпаки, якщо ми порівнюємо купу таблиць функціональних покажчиків з єдиною vtable, для якої потрібно зберігати лише один покажчик на екземпляр класу . Цей vtable часто буде сидіти в одній або декількох лініях кешу L1, а також у тісних петлях.
Висновок
Так що все одно, це моя маленька спіна на цю тему. Я рекомендую заходити в цих районах обережно. Довірчі вимірювання, а не інстинкт, а з огляду на те, як часто ці оптимізації погіршують ремонтопридатність, йдуть лише настільки далеко, наскільки ви можете собі дозволити (і мудрий шлях буде помилятися на стороні ремонту).