Последнее обновление:2026-10-12
Оптимизация пустого базового класса EBO в STL¶
0. Введение¶
EBO — сокращение от Empty Base Optimization.
В этом разделе мы начнём с пустых классов, заглянем внутрь STL, проведём тесты, затем сами реализуем EBO, сравним производительность, ещё раз протестируем и подведём итоги.
1. Пустой класс¶
Определим пустой класс: класс без переменных-членов, без наследования, без элементов данных.
Поскольку он пуст, чему будет равен его sizeof?
Результат — 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;
}
Выше мы проверили, что адреса двух разных объектов различаются. Рассмотрим такой сценарий:
Обнаружим: если бы адрес a совпадал с адресом b, то наличие двух объектов по одному адресу означало бы, что при обращении к ним по указателям эти два объекта невозможно было бы различить. Поэтому адреса двух разных объектов различаются.
2. Оптимизация пустого базового класса¶
Теперь сравним два варианта использования. Первый: класс содержит другой класс в качестве члена и через него получает функциональность вложенного класса.
Второй — прямое наследование, чтобы получить функции-члены базового класса и прочую функциональность.
Теперь протестируем:
Вывод:
В первом случае из-за выравнивания байтов исходный 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 единице; тест даёт следующий вывод:
Это показывает, что реализация управления памятью использует наследование и оптимизацию пустого базового класса, чтобы максимально уменьшить размер, занимаемый контейнером.
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 действительно значительно лучше встраивания. На этом изучение этого раздела завершено.
Похожие темы¶
- Анализ исходников STL array | C++那些事
- Глубокий анализ STL string | C++那些事
- Разбор вопросов собеседования про STL string | C++那些事
- Анализ исходников STL deque | C++那些事
- Реализация хеш-таблицы своими руками | C++那些事
- Хеш-таблица STL hashtable | C++那些事
- Анализ исходников STL итераторов | C++那些事
- Анализ исходников STL list | C++那些事
- STL map и multimap | C++那些事
- Анализ исходников STL queue и stack | C++那些事