Перейти к содержанию

Последнее обновление:2026-10-12

Оптимизация пустого базового класса EBO в STL

0. Введение

EBO — сокращение от Empty Base Optimization.

В этом разделе мы начнём с пустых классов, заглянем внутрь STL, проведём тесты, затем сами реализуем EBO, сравним производительность, ещё раз протестируем и подведём итоги.

1. Пустой класс

Определим пустой класс: класс без переменных-членов, без наследования, без элементов данных.

class Empty{
public:
    void print() {
        std::cout<<"I am Empty class"<<std::endl;
    }
};

Поскольку он пуст, чему будет равен его sizeof?

std::cout<<sizeof(Empty)<<std::endl; //1

Результат — 1. Но он ведь пуст, почему не 0?

Потому что пустой класс точно так же можно инстанцировать, и каждый экземпляр должен иметь в памяти уникальный адрес. Чтобы этого добиться, компилятор зачастую неявно добавляет пустому классу один байт, и после инстанцирования пустой класс получает в памяти уникальный адрес. Поэтому размер выше равен 1.

Исходя из этого ответа, у вас, вероятно, возникнет ещё один вопрос: почему адреса двух разных объектов должны различаться?

Вот пример:

class Empty{
public:
    void print() {
        std::cout<<"I am Empty class"<<std::endl;
    }
};
template < typename T >
bool isSame( T const & t1, T const & t2 )
{
    return &t1 == &t2;
}

Протестируем:

int main() {
    Empty a,b;
    assert(!isSame(a,b));  // 编译通过,a与b的地址不同

    Empty *p=new Empty;
    Empty *q=new Empty;
    assert(!isSame(p,q)); // 编译通过,a与b的地址不同
    return 0;
}

Выше мы проверили, что адреса двух разных объектов различаются. Рассмотрим такой сценарий:

Empty a,b;
// 在同一地址具有两个对象将意味着在使用指针引用它们时将无法区分这两个对象。
Empty *p1=&a;
p1->print();

Обнаружим: если бы адрес a совпадал с адресом b, то наличие двух объектов по одному адресу означало бы, что при обращении к ним по указателям эти два объекта невозможно было бы различить. Поэтому адреса двух разных объектов различаются.

2. Оптимизация пустого базового класса

Теперь сравним два варианта использования. Первый: класс содержит другой класс в качестве члена и через него получает функциональность вложенного класса.

class notEbo  {
    int i;
    Empty e;
    // do other things
};

Второй — прямое наследование, чтобы получить функции-члены базового класса и прочую функциональность.

class ebo:public Empty {
    int i;
    // do other things
};

Теперь протестируем:

std::cout<<sizeof(notEbo)<<std::endl;
std::cout<<sizeof(ebo)<<std::endl;

Вывод:

8 
4

В первом случае из-за выравнивания байтов исходный 1 байт расширяется до кратного 4, и в итоге получается 8 байт.

Сравнив эти два варианта, обнаруживаем: во втором, где функциональность базового класса получается через наследование, дополнительного размера не возникает, и эта оптимизация называется EBO (оптимизацией пустого базового класса).

Теперь вернёмся к исходникам STL и посмотрим, как это там используется!

3. Мир EBO в STL

Контейнерам deque, rb_tree, list и другим не обойтись без управления памятью: каждый из них содержит соответствующее управление памятью и через _xx_impl наследует вот эти классы:

std::allocator<_Tp>
__gnu_cxx::bitmap_allocator<_Tp>
__gnu_cxx::bitmap_allocator<_Tp>
__gnu_cxx::__mt_alloc<_Tp>
__gnu_cxx::__pool_alloc<_Tp>
__gnu_cxx::malloc_allocator<_Tp>

Какое же это имеет отношение к нашему EBO?

На самом деле все перечисленные выше базовые классы, от которых происходит наследование, — это управление памятью в форме EBO (пустого базового класса).

В каждом контейнере используется вызов rebind<_Tp>::other соответствующего механизма управления памятью.

Например, структура исходников красно-чёрного дерева:

typedef typename __gnu_cxx::__alloc_traits<_Alloc>::template
        rebind<_Rb_tree_node<_Val> >::other _Node_allocator;
struct _Rb_tree_impl : public _Node_allocator
{
// do somethings
};

Теперь взглянем на структуру исходников перечисленных классов управления памятью — возьмём для примера allocator:

template<typename _Tp>
class allocator: public __allocator_base<_Tp>
{
     template<typename _Tp1>
     struct rebind { typedef allocator<_Tp1> other; };
};

Видите? Через rebind<_Tp>::other получается переданный извне аллокатор — как раз те, что были упомянуты ранее.

std::allocator<_Tp>
__gnu_cxx::bitmap_allocator<_Tp>
__gnu_cxx::bitmap_allocator<_Tp>
__gnu_cxx::__mt_alloc<_Tp>
__gnu_cxx::__pool_alloc<_Tp>
__gnu_cxx::malloc_allocator<_Tp>

Разобравшись с этим, устроим проверку:

void print() {
    cout<<sizeof(std::allocator<int>)<<" "<<sizeof(std::allocator<int>::rebind<int>::other)<<endl;
    cout<<sizeof(__gnu_cxx::bitmap_allocator<int>)<<" "<<sizeof(__gnu_cxx::bitmap_allocator<int>::rebind<int>::other)<<endl;
    cout<<sizeof(__gnu_cxx::new_allocator<int>)<<" "<<sizeof(__gnu_cxx::new_allocator<int>::rebind<int>::other)<<endl;
    cout<<sizeof(__gnu_cxx::__mt_alloc<int>)<<" "<<sizeof(__gnu_cxx::__mt_alloc<int>::rebind<int>::other)<<endl;
    cout<<sizeof(__gnu_cxx::__pool_alloc<int>)<<" "<<sizeof(__gnu_cxx::__pool_alloc<int>::rebind<int>::other)<<endl;
    cout<<sizeof(__gnu_cxx::malloc_allocator<int>)<<" "<<sizeof(__gnu_cxx::malloc_allocator<int>::rebind<int>::other)<<endl;
}

Проверим, равен ли каждый из этих sizeof единице; тест даёт следующий вывод:

1 1
1 1
1 1
1 1
1 1
1 1

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

4. Используем EBO: вручную реализуем простое выделение и освобождение памяти

Сначала определим класс с sizeof(class) = 1; как и в STL, внутри используются allocate и deallocate для управления памятью.

class MyAllocator {
public:
    void *allocate(std::size_t size) {
        return std::malloc(size);
    }

    void deallocate(void *ptr) {
        std::free(ptr);
    }
};

Первый способ управления памятью: встроить класс управления памятью

template<class T, class Allocator>
class MyContainerNotEBO {
    T *data_ = nullptr;
    std::size_t capacity_;
    Allocator allocator_;   // 嵌入一个MyAllocator
public:
    MyContainerNotEBO(std::size_t capacity)
            : capacity_(capacity), allocator_(), data_(nullptr) {
        std::cout << "alloc malloc" << std::endl;
        data_ = reinterpret_cast<T *>(allocator_.allocate(capacity * sizeof(T))); // 分配内存
    }

    ~MyContainerNotEBO() {
        std::cout << "MyContainerNotEBO free malloc" << std::endl;
        allocator_.deallocate(data_);
    }
};

Второй способ: оптимизация пустого базового класса — наследование для получения функциональности управления памятью

template<class T, class Allocator>
class MyContainerEBO
        : public Allocator {    // 继承一个EBO
    T *data_ = nullptr;
    std::size_t capacity_;
public:
    MyContainerEBO(std::size_t capacity)
            : capacity_(capacity), data_(nullptr) {
        std::cout << "alloc malloc" << std::endl;
        data_ = reinterpret_cast<T *>(this->allocate(capacity * sizeof(T)));
    }

    ~MyContainerEBO() {
        std::cout << "MyContainerEBO free malloc" << std::endl;
        this->deallocate(data_);
    }
};

Начнём тест:

int main() {
    MyContainerNotEBO<int, MyAllocator> notEbo = MyContainerNotEBO<int, MyAllocator>(0);
    std::cout << "Using Not EBO Test sizeof is " << sizeof(notEbo) << std::endl;
    MyContainerEBO<int, MyAllocator> ebo = MyContainerEBO<int, MyAllocator>(0);
    std::cout << "Using EBO Test sizeof is " << sizeof(ebo) << std::endl;

    return 0;
}

Результаты теста:

alloc malloc
Using Not EBO Test sizeof is 24
alloc malloc
Using EBO Test sizeof is 16
MyContainerEBO free malloc
MyContainerNotEBO free malloc

Мы видим, что дизайн с EBO действительно значительно лучше встраивания. На этом изучение этого раздела завершено.

Похожие темы

Комментарии