Быстрая реализация простого, виртуального, наблюдателя-типа, шаблона в С++?

Я работаю над своей дочерью, пытаясь реализовать альтернативу для vtables, используя перечисления и тонну макромагии, которая действительно начинает путаться с моим мозгом. Я начинаю думать, что я не хожу по правильному пути, так как код становится более уродливым и уродливым, и не будет пригодным для производства любыми средствами.

Как можно реализовать шаблон следующего кода с наименьшим количеством перенаправления/операций?

Это должно быть сделано в стандартном С++, до 17.

class A{
    virtual void Update() = 0; // A is so pure *¬*
};

class B: public A
{
    override void Update() final
    {
        // DO B STUFF
    }
}

class C: public A
{
    override void Update() final
    {
        // DO C STUFF
    }
}

// class...

int main()
{
    std::vector<A*> vecA{};

    // Insert instances of B, C, ..., into vecA

    for(auto a: vecA) // This for will be inside a main loop
        a->Update(); // Ridiculous amount of calls per unit of time

    // Free memory
}

PS: Если перечисление, переключатель и макросы действительно лучший вариант, я думаю, я просто попытаюсь освежить свои кеши и придумать лучший дизайн.

PSS: Я знаю, что это микро-оптимизация... Черт, мне нужно нано или даже пико оптимизировать это (образно говоря), поэтому я просто проигнорирую любые утилитарные ответы, которые могут возникнуть.

Ответ 1

Как сказал первый комментарий, у вас есть проблема XY. Сортировка/переупорядочение в порядке, и у вас много объектов, а не огромное количество разных классов, и нет необходимости поддерживать типы, которые ваш код не знает о компиляции время. Полиморфизм + виртуальное наследование - неправильный выбор.

Вместо этого используйте N разных контейнеров, по одному для каждого типа объектов, без косвенности. Предоставление компилятору inline B::Update() в цикл по всем объектам B намного лучше. (Для тривиального примера ниже приращения одного члена int мой статический анализ производительности от взгляда на asm ставит его примерно на 24 раза быстрее на Skylake с горячими данными в кеше L1D. Автоинъекция AVX2 против call в цикл действительно такой огромный.)

Если между объектами, в том числе между различными типами объектов, существовал некоторый требуемый порядок, тогда был бы необходим какой-то полиморфизм или ручная диспетчеризация. (например, если было важно, какой заказ вы обработали vecA, оставив все объекты B отдельно от всех объектов C не эквивалентными.)


Если вы заботитесь о производительности, вы должны понимать, что увеличение источника может упростить работу компилятора/выхода asm. Проверка/диспетчеризация на основе типа каждого объекта внутри внутреннего цикла является дорогостоящей. Использование любого вида указателя функции или перечисления для отправки по каждому объекту может легко страдать от неверных прогнозов ветки, когда у вас есть смешивание разных объектов.

Цикл отдельно по нескольким контейнерам эффективно поднимает этот тип проверки внутреннего цикла и позволяет компилятору девиртуализировать. (Или лучше, сжимает каждый объект, чтобы в первую очередь не требовался указатель указателя, перечисления или указателя функции, поскольку его тип статически известен.)

Записывание отдельного цикла для каждого контейнера с другим типом похоже на полное разворачивание цикла по различным типам после подъема типа диспетчеризации из внутреннего цикла. Это необходимо, чтобы компилятор ввел вызовы, которые вы хотите, если есть много объектов каждого типа. Inlining позволяет сохранять константы в регистре по всем объектам, активировать автоинъекцию SIMD для нескольких объектов и просто избегать накладных расходов на фактический вызов функции. (И сам вызов, и разлив/перезагрузка регистров.)


Вы были правы, что , если вам нужна диспетчеризация для каждого объекта, виртуальные функции С++ - это дорогостоящий способ получить это, когда вы используете переопределения final. Вы платите ту же стоимость времени исполнения, которая позволила бы вашему коду поддерживать новые производные классы произвольного размера, о которых он не знал во время компиляции, но не получая от этого никакой пользы.

Виртуальная диспетчеризация работает только с уровнем косвенности (например, вектором указателей, который вы используете), что означает, что вам нужно каким-то образом управлять объектами с указателем. путем выделения их из vector<B> poolB и vector<C> poolC. Хотя я не уверен, что большинство реализаций vector<> используют realloc(), когда им нужно расти; API new/delete не имеет realloc, поэтому vector может копировать каждый раз, когда он растет, вместо того, чтобы пытаться расширить существующее распределение на месте. Проверьте, что делает ваша реализация на С++, поскольку она может сосать по сравнению с тем, что вы можете делать с malloc/realloc.

И BTW, должно быть возможно сделать new/delete с RAII без дополнительных накладных расходов для распределения/освобождения, если все ваши классы тривиально разрушаемы. (Но обратите внимание, что unique_ptr может победить другие оптимизации для использования вектора указателей). std::unique_ptr предупреждает, что UB уничтожает его с помощью указателя на базовый класс, поэтому вам, возможно, придется катиться самостоятельно. Тем не менее, на gcc на x86-64, sizeof(unique_ptr<class C>) всего 8, поэтому он имеет только один элемент-указатель. Но что бы то ни было, отдельно выделяя циллионы крошечных объектов, так что не делайте этого в первую очередь.


Если вам нужна какая-то отправка, как в заголовке, запрашивается

Если объекты имеют одинаковые размеры, вы действительно хотите перебирать объекты, а не указатели на объекты. Это позволило бы избежать лишнего размера кеша вектора указателей, и это позволит избежать дополнительной задержки лазера указателя, которую не удалось выполнить из-за порядка выполнения, чтобы сохранить рабочие блоки. Но виртуальное наследование С++ не предоставляет никакого стандартного способа получения полиморфизма для union upoly { B b; C c; } poly_array[1024];Вы можете взломать это с помощью reinterpret_cast<> таким образом, который, вероятно, работает на x86-64 gcc, но вы, вероятно, не должны этого делать. См. @BeeOnRope followup: Смежное хранение полиморфных типов. (Также более старый Q & A: С++ полиморфизм объекта в массиве).

Если вам это нужно, наиболее эффективным способом, вероятно, будет создание его с помощью enum для индексирования таблицы указателей функций (или используйте switch(), если ваши функции могут быть встроены). Если ваши функции не встроены, switch() в кучу функции-вызова case обычно не оптимизируется до таблицы указателей функций, даже если все они имеют одни и те же аргументы (или без аргументов). Обычно вы получаете таблицу перехода в блок инструкций вызова, вместо того, чтобы делать косвенный call. Таким образом, в каждой отправке есть дополнительный прыжок.

С++ 17 std::visit с std::variant<B, C> (используя не виртуальное наследование для B и C), кажется, дает вам отправку на основе внутреннего enum. std::visit использует свою собственную таблицу переходов для отправки даже с двумя возможными типами вместо того, чтобы вставлять их как в условную ветвь. Он также должен постоянно проверять "неинициализированное" состояние. Вы можете получить хороший код если вы вручную обходите это с помощью B *tmp = std::get_if<B>(&my_variant) и __builtin_unreachable(), чтобы сообщить gcc, что nullptr не является возможным. Но в этот момент вы можете просто свернуть свой собственный struct polymorph { enum type; union { B b; C c; }; }; (с не виртуальными функциями), если вам не нужно "неинициализированное" состояние. Связанный: С++ полиморфизм объекта в массиве.

В этом случае у вас есть только одна функция, поэтому вы можете поместить указатель функции внутри каждого объекта в качестве члена. Как void (*m_update)(A* this_object). В вызывающем, передайте указатель на объект как void* или A*, так как он является нечленой функцией. Реализация функции будет reinterpret_cast<C*>(this_object). (Не dynamic_cast: мы делаем нашу рассылку, не используя С++).

Если вы хотите использовать B и C в других контекстах, где элемент-указатель будет занимать место без каких-либо преимуществ, вы можете удерживать указатели на функции в отдельном контейнере, а не в базовом классе. Таким образом, это будет for(i=0..n) funcptrs[i]( &objects[i] );. Пока ваши контейнеры не синхронизируются, вы всегда передаете указатель на функцию, которая знает, что с ней делать. Используйте это с помощью union {B b; C c} objects[] (или vector<union>).

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

Вы можете использовать std::function<> для хранения указателей на функции-члены экземпляра, но на x86-64 gcc - 32-байтовый объект. Лучше для вашего кеша использовать только 8-байтные регулярные указатели функций и писать код, который знает, чтобы передать явный указатель, эквивалентный указателю this.

Помещение указателя функции в каждый объект может занимать больше места, чем enum или uint8_t, в зависимости от текущего размера/выравнивания. Небольшой целочисленный индекс в таблицу указателей функций может уменьшить размер каждого экземпляра ваших объектов по сравнению с элементом указателя, особенно для 64-битных целей. Меньшие объекты могут легко стоить пара дополнительных инструкций, чтобы индексировать массив указателей на функции и, возможно, более высокое неверное предсказание от дополнительного разыменования указателя. Пропуски памяти/кэша часто являются узким местом.

Я предполагаю, что у вас есть какое-то состояние для каждого экземпляра, даже если вы его не показываете. Если нет, то вектор обычных указателей функции на функции, не являющиеся членами, будет намного дешевле!


Сравнение служебных данных:

Я рассмотрел созданный компилятором asm (gcc и clang targeting x86-64) для нескольких способов сделать это.

Источник для нескольких способов сделать это + asm из x86-64 clang 5.0 в проводнике компилятора Godbolt, Вы можете перевернуть его на gcc или архитектуры без архитектуры x86.

class A{
    public:
    virtual void Update() = 0; // A is so pure *¬*
};

struct C : public A {
    int m_c = 0;
    public:
    void Update() override final
    {  m_c++;  }
};
int SC = sizeof(C);  // 16 bytes because of the vtable pointer

C global_c;  // to instantiate a definition for C::Update();

// not inheriting at all gives equivalent asm to making Update non-virtual
struct nonvirt_B //: public A
{
    int m_b = 0;
    void Update() //override final
    {  m_b++;  }
};
int SB = sizeof(nonvirt_B);  // only 4 bytes per object with no vtable pointer

void separate_containers(std::vector<nonvirt_B> &vecB, std::vector<C> &vecC)
{
    for(auto &b: vecB)        b.Update();
    for(auto &c: vecC)        c.Update();   
}

clang и gcc автоматически векторизовать цикл через vecB с помощью AVX2 для обработки 8 int элементов параллельно, поэтому, если вы не узкополотите пропускную способность памяти (то есть, горячая в кеше L1D), этот цикл может увеличивать 8 элементов за такт. Этот цикл работает так же быстро, как цикл над vector<int>; все встроено и оптимизируется, и это просто увеличение указателя.

Цикл над vecC может выполнять только 1 элемент за такт, потому что каждый объект имеет 16 байтов (8 байтов vtable указатель, 4 байта int m_c), 4 байта заполнения на следующую границу выравнивания, потому что указатель имеет требование выравнивания 8B.) Без final компилятор также проверяет указатель vtable чтобы увидеть, действительно ли это C, прежде чем использовать встроенный C::Update(), иначе он отправит. Это похоже на то, что вы получили бы за цикл над struct { int a,b,c,d; } vecC[SIZE];, делая vecC[i].c++;

final допускает полную девиртуализацию, но наши данные смешиваются с указателями vtable, поэтому компиляторы просто выполняют скалярный add [mem], 1, который может работать только с 1 за такт (узкое место по 1 на пропускную способность хранилища часов, независимо от размера если он горячий в кеше L1D). Это в основном проигрывает SIMD для этого примера. (С -march=skylake-avx512 gcc и clang делают некоторые смешные перетасовки или собирают/рассеивают, что даже медленнее, чем скаляр, вместо того, чтобы просто загружать/восстанавливать весь объект и добавлять с помощью вектора, который только изменяет член int. не содержит никаких изменчивых или атомных членов и будет запускать 2 за часы с AVX2 или 4 за часы с AVX512.) Если ваши объекты размером до 12 байт являются серьезным недостатком, если они маленькие, и у вас много из них.

С несколькими членами для каждого объекта это не обязательно приводит к поражению SIMD, но по-прежнему стоит пространство в каждом объекте, как и указатель перечисления или указателя функции.

Поскольку вы упомянули теорему разделительной оси, надеюсь, вы не планируете хранить пары float x,y в каждом объекте. Array-of-structs в основном отстой для SIMD, потому что ему нужно много перетасовки, чтобы использовать x с y для одного и того же объекта. То, что вы хотите, это std::vector<float> x, y или подобное, поэтому ваш CPU может загружать значения 4 x в регистр и 4 y значения в другой регистр. (Или 8 одновременно с AVX).

См. Слайды: SIMD в Insomniac Games (GDC 2015) для ознакомления с тем, как структурировать ваши данные для SIMD и некоторые более продвинутые вещи. См. Также теги wiki для получения дополнительных руководств, Кроме того, x86 тег wiki имеет множество низкоуровневых материалов для оптимизации x86. Даже если вы ничего не рисуете вручную, с отдельными массивами для x и y есть хороший шанс, что компилятор может авто-векторизовать для вас. (Посмотрите на выход asm или на тест gcc -O3 -march=native vs. gcc -O3 -march=native -fno-tree-vectorize). Вам может понадобиться -ffast-math для некоторых видов векторизации FP.


Виртуальная диспетчеризация С++:

Написание его так, как вы делаете в вопросе, с виртуальным наследованием и

std::vector<A*> vecA{};

void vec_virtual_pointers() {
    for(auto a: vecA)
        a->Update();
}

Мы получаем этот цикл из clang5.0 -O3 -march=skylake

   # rbx = &vecA[0]
.LBB2_1:                         # do{
    mov     rdi, qword ptr [rbx]   # load a pointer from the vector (will be the this pointer for Update())
    mov     rax, qword ptr [rdi]   # load the vtable pointer
    call    qword ptr [rax]        # memory-indirect call using the first entry in the vtable
    add     rbx, 8                 # pointers are 8 bytes
    cmp     r14, rbx
    jne     .LBB2_1              # }while(p != vecA.end())

Итак, конечный указатель функции находится в конце цепочки из трех зависимых нагрузок. Выполнение вне порядка позволяет это совпадение между итерациями (если ветвь предсказывает правильно), но что много накладных расходов просто в общих инструкциях для front-end, а также в неправильном предложении. (call [m] - это 3 uops, так что только сам цикл равен 8 uops, и он может выдавать только один за 2 цикла на Skylake. Call/return также имеет накладные расходы. Если вызываемый пользователь не является полностью тривиальным, мы, вероятно, store-forwarding для push/popping обратного адреса. Loop с вызовом функции быстрее, чем пустой цикл. (Я не уверен в пропускной способности независимых операций хранения/перезагрузки на том же адресе. Это обычно требует переименования памяти, которое Skylake не делает, чтобы не быть узким местом в этом случае, если вызывающая сторона крошечная, как здесь.)

Определение Clang для C:: Update() -

C::Update():                         # @C::Update()
    inc     dword ptr [rdi + 8]
    ret

Если это необходимо для настройки каких-либо констант, прежде чем вычислять что-то, было бы еще дороже не иметь его вложенным. Таким образом, с виртуальной диспетчеризацией это, вероятно, работает примерно от одного на 3 до 5 тактов, а не около 1 члена за такт, на Skylake. (Или 8 членов за такт с AVX2 для не виртуальных class B, которые не теряют места и делают автоинтеграцию хорошо работать.) http://agner.org/optimize/ говорит, что Skylake имеет одну пропускную способность в течение 3 часов call, поэтому позволяет сказать, что потеря производительности 24 раза, когда данные горячие в кеше L1D. Разумеется, разные микроархитектуры будут разными. См. теги wiki для более x86 perf Информация.


Взлом соединения:

Вероятно, вы никогда не должны использовать это, но вы можете видеть из asm, что он будет работать на x86-64 с clang и gcc. Я сделал массив союзов и зациклился над ним:

union upoly {
    upoly() {}   // needs an explicit constructor for compilers not to choke
     B b;
     C c;
} poly_array[1024];

void union_polymorph() {
    upoly *p = &poly_array[0];
    upoly *endp = &poly_array[1024];
    for ( ; p != endp ; p++) {
        A *base = reinterpret_cast<A*>(p);
        base->Update();           // virtual dispatch
    }
}

A B и C все имеют свою виртуальную таблицу в начале, поэтому я думаю, что это, как правило, будет работать. Мы asm, что в основном то же самое, с одним меньшим шагом в стрельбе. (Я использовал статический массив вместо вектора, так как я делал вещи простыми и C-like, сортируя, что делать.)

    lea     rdi, [rbx + poly_array]       ; this pointer
    mov     rax, qword ptr [rbx + poly_array]   ; load it too, first "member" is the vtable pointer
    call    qword ptr [rax]
    add     rbx, 16                       ; stride is 16 bytes per object
    cmp     rbx, 16384                    ; 16 * 1024
    jne     .LBB4_1

Это лучше и затрагивает меньше памяти, но это немного лучше для накладных расходов.


std::function от #include <functional>

Он может содержать любую вызывающую вещь. Но у него есть еще больше накладных расходов, чем отправка в формате vtable, поскольку она позволяет находиться в состоянии, используемом с ошибкой. Поэтому внутренний цикл должен проверять каждый экземпляр для этого и ловушку, если он есть. Кроме того, sizeof(std::function<void()>); - 32 байта (на x86-64 System V ABI).

#include <functional>
// pretty crappy: checks for being possibly unset to see if it should throw().
std::vector<std::function<void()>> vecF{};
void vec_functional() {
    for(auto f: vecF)     f();
}

                                # do {
.LBB6_2:                                # =>This Inner Loop Header: Depth=1
    mov     qword ptr [rsp + 16], 0       # store a 0 to a local on the stack?
    mov     rax, qword ptr [rbx + 16]
    test    rax, rax
    je      .LBB6_5           # throw on pointer==0  (nullptr)
    mov     edx, 2            # third arg:  2
    mov     rdi, r14          # first arg: pointer to local stack memory (r14 = rsp outside the loop)
    mov     rsi, rbx          # second arg: point to current object in the vector
    call    rax               # otherwise call into it with 2 args
    mov     rax, qword ptr [rbx + 24]    # another pointer from the std::function<>
    mov     qword ptr [rsp + 24], rax    # store it to a local
    mov     rcx, qword ptr [rbx + 16]    # load the first pointer again
    mov     qword ptr [rsp + 16], rcx
    test    rcx, rcx
    je      .LBB6_5           # check the first pointer for null again (and throw if null)
    mov     rdi, r14
    call    rax               # call through the 2nd pointer
    mov     rax, qword ptr [rsp + 16]
    test    rax, rax
    je      .LBB6_12          # optionally skip a final call
    mov     edx, 3
    mov     rdi, r14
    mov     rsi, r14
    call    rax
.LBB6_12:                               #   in Loop: Header=BB6_2 Depth=1
    add     rbx, 32
    cmp     r15, rbx
    jne     .LBB6_2

.LBB6_13:                       # return
    add     rsp, 32
    pop     rbx
    pop     r14
    pop     r15
    ret

.LBB6_5:
    call    std::__throw_bad_function_call()
    jmp     .LBB6_16
    mov     rdi, rax
    call    __clang_call_terminate

Таким образом, существует до трех команд call, если указатель не равен nullptr. Это выглядит намного хуже, чем виртуальная отправка.

Он немного отличается от clang -stdlib=libc++, а не по умолчанию libstdc++. (https://libcxx.llvm.org/). Но все же три инструкции call во внутреннем цикле, с условностями, чтобы пропустить их или выбросить.

Если код-ген не отличается для разных типов function<T>, он, вероятно, даже не стоит рассматривать его для указателей на функции-члены, если вы можете написать более эффективную альтернативу.

Ответ 2

Если вам действительно нужна виртуальная диспетчеризация, один из способов ускорить диспетчеризацию для того же виртуального метода в списке объектов различных производных типов - это использовать то, что я назову без переключения типов.

Несколько аналогично переключению цикла, это преобразует один цикл, вызывающий метод для каждого объекта, в N циклов (для N поддерживаемых типов), каждый из которых вызывает метод для всех объектов определенного типа. Это позволяет избежать основной стоимости непредсказуемой виртуальной диспетчеризации: ошибочные прогнозы ветвления, подразумеваемые косвенным вызовом неизвестной непредсказуемой функции в виртуальной таблице.

Общая реализация этого метода включает в себя первый проход для разделения объектов по типам: информация об этом разделе используется вторым проходом, который имеет отдельные циклы для каждого типа 1, вызывая метод. Обычно это вообще не связано с непредсказуемыми ветвями, если они реализованы осторожно.

В случае двух производных классов B и C вы можете просто использовать растровое изображение для хранения информации о типе. Вот пример реализации, использующий типы A, B, C из кода в вопросе:

void virtual_call_unswitch(std::vector<A*>& vec) {

    // first create a bitmap which specifies whether each element is B or C type
    std::vector<uint64_t> bitmap(vec.size() / 64);

    for (size_t block = 0; block < bitmap.size(); block++) {
        uint64_t blockmap = 0;
        for (size_t idx = block * 64; idx < block * 64 + 64; idx++) {
            blockmap >>= 1;    
            blockmap |= (uint64_t)vec[idx + 0]->typecode_ << 63;
        }
        bitmap[block] = blockmap;
    }

    // now loop over the bitmap handling all the B elements, and then again for all the C elements

    size_t blockidx;
    // B loop
    blockidx = 0;
    for (uint64_t block : bitmap) {
        block = ~block;
        while (block) {
            size_t idx = blockidx + __builtin_ctzl(block);
            B* obj = static_cast<B*>(vec[idx]);
            obj->Update();
            block &= (block - 1);
        }
        blockidx += 64;
    }

    // C loop
    blockidx = 0;
    for (uint64_t block : bitmap) {
        while (block) {
            size_t idx = blockidx + __builtin_ctzl(block);
            C* obj = static_cast<C*>(vec[idx]);
            obj->Update();
            block &= (block - 1);
        }
        blockidx += 64;
    }
}

Здесь typecode - это общее поле в A, которое идентифицирует тип объекта, 0 для B и 1 для C. Нечто подобное необходимо для того, чтобы сделать классификацию по типу выполнимой (это не может быть виртуальный вызов, поскольку в первую очередь мы пытаемся избежать непредсказуемого вызова).

Слегка оптимизированная версия выше показывает примерно 3,5-кратное ускорение для некоммутируемой версии по обычному виртуально отправленному циклу, с виртуальной версией тактирования примерно в 19 циклах на одну отправку, а неконтролируемая версия на уровне около 5,5. Полные результаты:

-----------------------------------------------------------------------------
Benchmark                                      Time           CPU Iterations
-----------------------------------------------------------------------------
BenchWithFixture/VirtualDispatchTrue       30392 ns      30364 ns      23033   128.646M items/s
BenchWithFixture/VirtualDispatchFakeB       3564 ns       3560 ns     196712   1097.34M items/s
BenchWithFixture/StaticBPtr                 3496 ns       3495 ns     200506    1117.6M items/s
BenchWithFixture/UnswitchTypes              8573 ns       8571 ns      80437   455.744M items/s
BenchWithFixture/StaticB                    1981 ns       1981 ns     352397    1.9259G items/s

VirtualDispatchTrue - это простой цикл, вызывающий Update() для указателя типа A:

for (A *a : vecA) {
    a->Update();
}

VirtualDispatchFakeB переводит указатель на B* (независимо от того, что является базовым типом) перед вызовом Update(). Поскольку B::Update() является окончательным, компилятор может полностью де-виртуализировать и встроить вызов. Конечно, это не совсем правильно: он обрабатывает любые объекты C как B и вызывает неправильный метод (и полностью UB) - но здесь, чтобы оценить, как быстро вы можете вызывать методы на вектор указателей, если каждый объект был одного и того же статически известного типа.

for (A *a : vecA) {
    ((B *)a)->Update();
}

StaticBPtr выполняет итерации по std::vector<B*>, а не по std::vector<A*>. Как и ожидалось, производительность такая же, как и у "поддельного B" кода выше, поскольку цель для Update() статически известна и полностью встроена. Это здесь как проверка здравомыслия.

UnswitchTypes - описанный выше трюк с переключением типов.

StaticB выполняет итерации по std::vector<B>. То есть смежные объекты B, а не вектор указателей на B-объекты. Это устраняет один уровень косвенности и показывает что-то вроде лучшего случая для этого макета объекта 2.

полный исходный код доступен и опубликован в открытом доступе.

Ограничения

Побочные эффекты и порядок

Основное ограничение этого метода заключается в том, что порядок вызовов Update() не должен иметь значения. Хотя Update() по-прежнему вызывается один раз для каждого объекта, порядок явно изменился. Пока объект не обновляет никакое изменяемое глобальное или общее состояние, это должно быть легко удовлетворить.

Подставки для двух типов

Приведенный выше код поддерживает только два типа, основанные на использовании растрового изображения для записи информации о типе.

Это ограничение довольно легко снять. Во-первых, растровый подход может быть расширен. Например, чтобы поддерживать 4 типа, могут быть созданы две аналогичные битовые карты, для которых соответствующие биты каждой битовой карты по существу для 2-битового поля, кодирующего тип. Циклы аналогичны, за исключением того, что во внешнем цикле они & и ~ растровых изображений вместе, что во всех 4 типах. Например.:

// type 1 (code 11)
for (size_t i = 0; i < bitmap1.size(); i++) {
        block = bitmap1[i] & bitmap2[i];
        ...
}


// type 2 (code 01)
for (size_t i = 0; i < bitmap1.size(); i++) {
        block = ~bitmap1[i] & bitmap2[i];
        ...
}

...

Другой подход заключается в том, чтобы вообще не использовать растровые изображения, а просто хранить массив индексов для каждого типа. Каждый индекс в массиве указывает на объект этого типа в главном массиве. По сути это 1-проходная сортировка по основанию кода типа. Это, вероятно, делает часть сортировки типов немного медленнее, но потенциально ускоряет логику итерации цикла (вещи x & (x - 1) и ctz исчезают за счет другого косвенного обращения).

Фиксированное количество поддерживаемых типов

Приведенный выше код поддерживает фиксированное количество известных типов времени компиляции (а именно, B и C). Если будет введен новый тип, приведенный выше код либо сломается, и, безусловно, не сможет вызвать Update() для этих новых типов.

Однако легко добавить поддержку неизвестных типов. Просто сгруппируйте все неизвестные типы, а затем только для этих типов выполните полную виртуальную диспетчеризацию в цикле (то есть вызовите Update() непосредственно на A*). Вы заплатите полную цену, но только за типы, которые вы явно не поддерживали! Таким образом, этот метод реализует в общих чертах механизм виртуальной диспетчеризации.

PolyCollection

Возможно, вас заинтересует Boost PolyCollection. Это в основном вектороподобный контейнер, специализированный для этого случая: он содержит объекты различных полиморфных типов и эффективно их перебирает.

Он поддерживает полиморфизм на основе метода virtual, а также функционально-подобный полиморфизм объектов и полиморфизм на основе утиной типизации. Он реализует описанное выше "отключение", храня различные типы объектов в хранилище отдельно, поэтому он не сохраняет порядок вставки между объектами разных типов. Если это соответствует вашим потребностям, это может быть готовое решение.


1 На самом деле, вам нужен только один цикл на группу типов, которые совместно используют одну и ту же реализацию виртуального метода, хотя это может быть сложно реализовать в общем виде, поскольку эта информация недоступна. Например, если классы Y и Z являются производными от X, но не переопределяют реализацию какого-либо виртуального метода из X, то все из X, Y и Z могут обрабатываться та же петля.

2 Под "макетом объекта" я подразумеваю B объекты, которые все еще имеют виртуальные методы и, следовательно, vtable. Если вы удалите все виртуальные методы и избавитесь от vtable, дела пойдут намного быстрее, так как компилятор затем векторизует добавление к компактно расположенным полям. Vtable портит это.