Because resizing a vector is expensive, the std::vector
class uses exponential growth to minimize the number of times that the vector is resized: a technique we replicate in this version. But every now and then, you still need to resize the internal buffer.
In the current version, resizing the vector requires allocating a new buffer and copying all the members into it. We use the copy and swap idiom to provide the strong exception guarantee (If an exception is thrown, all resources are cleaned up, and the object remains unchanged).
Vector Resize with Copy
void pushBackInternal(T const& value)
{
new (buffer + length) T(value);
++length;
}
void reserveCapacity(std::size_t newCapacity)
{
Vector<T> tmpBuffer(newCapacity);
std::for_each(buffer, buffer + length,
[&tmpBuffer](T const& v){tmpBuffer.pushBackInternal(v);}
);
tmpBuffer.swap(*this);
}
Resize With Move Construction
Thus, resizing a Vector can be a very expensive operation because of all the copying that can occur.
Using the move constructor rather than the copy constructor during a resize operation could potentially be much more efficient. However, the move constructor mutates the original object, and if there is a problem, we need to undo the mutations to maintain the strong exception guarantee.
The first attempt at this is:
Vector Resize with Move With Exceptions
void moveBackInternal(T&& value)
{
new (buffer + length) T(std::move(value));
++length;
}
void reserveCapacity(std::size_t newCapacity)
{
Vector<T> tmpBuffer(newCapacity);
try
{
std::for_each(buffer, buffer + length,
[&tmpBuffer](T& v){tmpBuffer.moveBackInternal(std::move(v));}
);
}
catch(...)
{
for(int loop=0; loop < tmpBuffer.length; ++loop)
{
buffer[loop] = std::move(tmpBuffer[loop]);
}
throw;
}
tmpBuffer.swap(*this);
}
Resize With NoThrow Move Construction
As the above code shows, if the type T
can throw during its move constructor, you can't guarantee that the object will be returned to its original state (moving the elements back may cause another exception). So, we cannot use the move constructor to resize the vector if the type T
can throw during move construction.
However, not all types throw when being moved. In fact, it is recommended that move constructors never throw. If we can guarantee that the move constructor does not throw, we can simplify the above code considerably and still provide a strong exception guarantee.
Vector Resize with Move
void reserveCapacity(std::size_t newCapacity)
{
Vector<T> tmpBuffer(newCapacity);
std::for_each(buffer, buffer + length,
[&tmpBuffer](T& v){tmpBuffer.moveBackInternal(std::move(v));}
);
tmpBuffer.swap(*this);
}
void moveBackInternal(T&& value)
{
new (buffer + length) T(std::move(value));
++length;
}
Resize Template Specialization
So now we have to write the code that decides which version we should use at compile time. The simplest way to do this is to use template specialization of a class using the standard class std::is_nothrow_move_constructible<T>
to help differentiate types with a non-throwing move constructor. This is simple enough:
Template class Specialization
template<typename T, bool = std::is_nothrow_move_constructible<T>::value>
struct SimpleCopy
{
void operator()(Vector<T>& src, Vector<T>& dst) const;
};
template<typename T>
class Vector
{
public:
.....
private:
friend struct SimpleCopy<T, true>;
friend struct SimpleCopy<T, false>;
void reserveCapacity(std::size_t newCapacity)
{
Vector<T> tmpBuffer(newCapacity);
SimpleCopy<T> copier;
copier(*this, tmpBuffer);
tmpBuffer.swap(*this);
}
void pushBackInternal(T const& value)
{
new (buffer + length) T(value);
++length;
}
void moveBackInternal(T&& value)
{
new (buffer + length) T(std::move(value));
++length;
}
}
template<typename T>
struct SimpleCopy<T, false>
{
void operator()(Vector<T>& src, Vector<T>& dst) const
{
std::for_each(buffer, buffer + length,
[&dst](T const& v){dst.pushBackInternal(v);}
);
}
};
template<typename T>
struct SimpleCopy<T, true>
{
void operator()(Vector<T>& src, Vector<T>& dst) const
{
std::for_each(buffer, buffer + length,
[&dst](T& v){dst.moveBackInternal(std::move(v));}
);
}
};
Resize With NoThrow SFINAE
The above technique has a couple of issues.
The type SimpleClass
is publicly available and is a friend of Vector<T>
. This makes it susceptible to accidentally being used (even if not explicitly documented). Unfortunately, it can't be included as a member class and also be specialized.
Additionally, it looks awful!!
But we can also use SFINAE and method overloading.
SFINAE allows us to define several versions of a method with exactly the same arguments, as long as only one is valid at compile time. So in the example below, we define two versions of the method SimpleCopy(Vector<T>& src, Vector<T>& dst)
but then use std::enable_if
to make sure only one version of the function is valid at compile time.
SFINAE method overload
template<typename T>
class Vector
{
public:
.....
private:
void reserveCapacity(std::size_t newCapacity)
{
Vector<T> tmpBuffer(newCapacity);
SimpleCopy<T>(*this, tmpBuffer);
tmpBuffer.swap(*this);
}
void pushBackInternal(T const& value)
{
new (buffer + length) T(value);
++length;
}
void moveBackInternal(T&& value)
{
new (buffer + length) T(std::move(value));
++length;
}
template<typename X>
typename std::enable_if<std::is_nothrow_move_constructible<X>::value == false>::type
simpleCopy(Vector<T>& src, Vector<T>& dst)
{
std::for_each(buffer, buffer + length,
[&dst](T const& v){dst.pushBackInternal(v);}
);
}
template<typename X>
typename std::enable_if<std::is_nothrow_move_constructible<X>::value == true>::type
simpleCopy()(Vector<T>& src, Vector<T>& dst)
{
std::for_each(buffer, buffer + length,
[&dst](T& v){dst.moveBackInternal(std::move(v));}
);
}
}
Final Version
Vector Final Version
template<typename T>
class Vector
{
std::size_t capacity;
std::size_t length;
T* buffer;
struct Deleter
{
void operator()(T* buffer) const
{
::operator delete(buffer);
}
};
public:
Vector(int capacity = 10)
: capacity(capacity)
, length(0)
, buffer(static_cast<T*>(::operator new(sizeof(T) * capacity)))
{}
~Vector()
{
std::unique_ptr<T, Deleter> deleter(buffer, Deleter());
for(int loop = 0; loop < length; ++loop)
{
buffer[length - 1 - loop].~T();
}
}
Vector(Vector const& copy)
: capacity(copy.length)
, length(0)
, buffer(static_cast<T*>(::operator new(sizeof(T) * capacity)))
{
try
{
for(int loop = 0; loop < copy.length; ++loop)
{
push_back(copy.buffer[loop]);
}
}
catch(...)
{
std::unique_ptr<T, Deleter> deleter(buffer, Deleter());
for(int loop = 0; loop < length; ++loop)
{
buffer[length - 1 - loop].~T();
}
throw;
}
}
Vector& operator=(Vector const& copy)
{
Vector<T> tmp(copy);
tmp.swap(*this);
return *this;
}
Vector(Vector&& move) noexcept
: capacity(0)
, length(0)
, buffer(nullptr)
{
move.swap(*this);
}
Vector& operator=(Vector&& move) noexcept
{
move.swap(*this);
return *this;
}
void swap(Vector& other) noexcept
{
using std::swap;
swap(capacity, other.capacity);
swap(length, other.length);
swap(buffer, other.buffer);
}
void push_back(T const& value)
{
resizeIfRequire();
pushBackInternal(value);
}
void pop_back()
{
--length;
buffer[length].~T();
}
void reserve(std::size_t capacityUpperBound)
{
if (capacityUpperBound > capacity)
{
reserveCapacity(capacityUpperBound);
}
}
private:
void resizeIfRequire()
{
if (length == capacity)
{
std::size_t newCapacity = std::max(2.0, capacity * 1.5);
reserveCapacity(newCapacity);
}
}
void reserveCapacity(std::size_t newCapacity)
{
Vector<T> tmpBuffer(newCapacity);
simpleCopy<T>(tmpBuffer);
tmpBuffer.swap(*this);
}
void pushBackInternal(T const& value)
{
new (buffer + length) T(value);
++length;
}
void moveBackInternal(T&& value)
{
new (buffer + length) T(std::move(value));
++length;
}
template<typename X>
typename std::enable_if<std::is_nothrow_move_constructible<X>::value == false>::type
simpleCopy(Vector<T>& dst)
{
std::for_each(buffer, buffer + length,
[&dst](T const& v){dst.pushBackInternal(v);}
);
}
template<typename X>
typename std::enable_if<std::is_nothrow_move_constructible<X>::value == true>::type
simpleCopy(Vector<T>& dst)
{
std::for_each(buffer, buffer + length,
[&dst](T& v){dst.moveBackInternal(std::move(v));}
);
}
};
Summary
This article has gone over the design of resizing the internal buffer. We have covered a couple of techniques on the way:
- Move Constructor Concepts
- Template Class Specialization
- SFINAE
- std::is_nothrow_move_constructible<>
- std::enable_if<>