Examples of Using std::allocator in C++ Memory Management

In the last article, we introduced how containers use std::allocator. But what if we want to use std::allocator directly?Typically, we do not use std::allocator methods directly, but rather through a utility class (pseudo code below, Code Example 1):

// Code Example 1

namespace std
{
    template<class Alloc>
    struct allocator_traits {
        // Alloc is usually template<class T> class allocator
        // or its subclasses
        // Thus, type T is usually referred to as std::allocator::value_type

        // These two are wrappers for the Alloc::allocate method
        static Alloc::value_type* allocate(Alloc& a, std::size_t n);
        static Alloc::value_type* allocate(Alloc& a, size_type n, const void* hint);

        // This is a wrapper for the Alloc::deallocate method
        static void deallocate(Alloc& a, Alloc::value_type* p, std::size_t n);

        // This method is a wrapper for initializing p -- calling the constructor
        template<class... Args>
        static void construct(Alloc& a, Alloc::value_type* p, Args&&... args);

        // This method is a wrapper for calling the destructor of p
        static void destroy(Alloc& a, Alloc::value_type* p);
    };
}

Now let’s look at how to use it (Code Example 2):

// Code Example 2
#include <cstdlib>
#include <iostream>
#include <memory>

class Data {
public:
    Data(int v): v_(v) {
        std::cout << "Data constructor: " << reinterpret_cast<void*>(this) << std::endl;
    }
    ~Data() {
        std::cout << "Data destructor: " << reinterpret_cast<void*>(this) << std::endl;
    }

private:
    int v_;
};

int main()
{
    std::allocator<Data> alloc;
    // Suppose there is an instance of std::allocator
    // For example
    // std::allocator<int> other_alloc;
    // using AllocType = std::allocator_traits<decltype(other_alloc)>::rebind_alloc<Data>;
    // AllocType alloc;

    // Allocate memory
    Data* d = std::allocator_traits<decltype(alloc)>::allocate(alloc, 1);

    std::cout << "Allocated memory address: " << reinterpret_cast<void*>(d) << std::endl;

    // Call constructor
    // Here std::allocator_traits<decltype(alloc)>::construct(alloc, d); will fail to compile
    // This is because the Data class does not have a default constructor
    std::allocator_traits<decltype(alloc)>::construct(alloc, d, 10);

    std::cout << "==================" << std::endl;

    // Call destructor
    std::allocator_traits<decltype(alloc)>::destroy(alloc, d);

    std::cout << "Freed memory address: " << reinterpret_cast<void*>(d) << std::endl;

    // Free memory
    std::allocator_traits<decltype(alloc)>::deallocate(alloc, d, 1);
}

The output is as follows:

Allocated memory address: 0x7f9c8c4059b0
Data constructor: 0x7f9c8c4059b0
==================
Data destructor: 0x7f9c8c4059b0
Freed memory address: 0x7f9c8c4059b0

In fact, the container classes of the C++ standard library also call instances of std::allocator (or its subclasses) through std::allocator_traits.std::allocator_traits can only initialize one at a time, but there is a method for batch initialization, as shown below (Code Example 3):

// Code Example 3
#include <iostream>
#include <list>
#include <memory>
#include <vector>

struct Data {
    Data() : v_(-1) {}
    Data(int v): v_(v) {}

    int v_;
};

int main()
{
    const std::size_t data_count = 4;
    std::allocator<Data> alloc;
    // In fact, most of the time we use the following
    // Data* p = alloc.allocate(data_count);
    // Simple and direct
    Data* p = std::allocator_traits<decltype(alloc)>::allocate(alloc, data_count);

    // Print data
    auto print_p = [&p] {
        for(std::size_t index = 0; index < data_count; ++index) {
            std::cout << (p + index)->v_;
            if (index + 1 == data_count) std::cout << std::endl;
            else std::cout << ", ";
        }
    };

    // Print uninitialized data -- just to prove it is uninitialized
    std::cout << "Uninitialized: ";
    print_p();

    // We can consider p's type as Data[4]
    // Here we initialize p[1] -- using the initialization function Data()
    std::allocator_traits<decltype(alloc)>::construct(alloc, p + 1);

    // Here we initialize p[2] -- using the initialization function Data(16)
    std::allocator_traits<decltype(alloc)>::construct(alloc, p + 2, 16);

    std::cout << "Partially initialized: ";
    print_p();

    // Batch initialization
    std::uninitialized_fill_n(p, data_count, Data(10));
    // This is equivalent to the following line
    // std::uninitialized_fill(p, p + data_count, Data(10));
    // This batch initialization uses Data's copy constructor
    // Additionally, calling this batch initialization will cause p[1] and p[2] to be initialized twice
    // Will initializing twice (i.e., calling the constructor twice) cause problems?
    // In fact, constructors and destructors are essentially no different from other functions
    // Whether multiple calls are problematic depends entirely on the implementation of the function -- that is, what it actually does
    // Here, this is certainly not a problem
    // By the way, Data does not explicitly define a copy constructor, it is generated by the compiler by default

    std::cout << "Batch initialization: ";
    print_p();

    // Free memory
    // Note: Here, the destructor is not called before freeing either
    // Just like construction, not calling the destructor here will not be a problem
    std::allocator_traits<decltype(alloc)>::deallocate(alloc, p, data_count);
}

The output is as follows (uninitialized values may differ under different compilation conditions):

Uninitialized: 0, 0, 0, 0
Partially initialized: 0, -1, 16, 0
Batch initialization: 10, 10, 10, 10

Leave a Comment