《C++ Primer》学习笔记 — 拷贝控制
一、拷贝控制
1、三五法则
(1)需要析构函数的类也需要拷贝和赋值操作 — 例如对象需要在析构函数中释放动态内存,则需要在拷贝构造函数中考虑为新对象分配新内存,而不能进行位拷贝。
(2)需要拷贝操作的类也需要赋值操作,反之亦然
2、析构函数不能是删除的成员
如果析构函数被删除,将无法销毁此类型的对象。因此,我们无法在栈上创建该对象。对于在堆上创建的对象,我们无法对其进行销毁:
class CLS_Test
{
public:
~CLS_Test() = delete;
};
int main()
{
CLS_Test test; // invalid
CLS_Test* test = new CLS_Test;
delete test; // invalid
}
3、引用计数
我们尝试实现一个引用计数法保存的模板类,使其在成员数据发生改变时重置引用计数:
#include <iostream>
#include <functional>
using namespace std;
template<class T>
class CLS_RefCount
{
public:
CLS_RefCount(const T* _ptr)
{
init(_ptr);
}
CLS_RefCount(const CLS_RefCount& other)
{
copy(other);
}
CLS_RefCount operator=(const CLS_RefCount& other)
{
(*m_iRefCount)++;
release();
m_ptr = other.m_ptr;
m_iRefCount = other.m_iRefCount;
}
~CLS_RefCount()
{
release();
}
void changeVal(function<T(T)> func)
{
release();
m_ptr = other.m_ptr;
m_iRefCount = other.m_iRefCount;
(*m_iRefCount)++;
return *this;
}
T operator*()
{
return *m_ptr;
}
int refCount()
{
return *m_iRefCount;
}
private:
T* m_ptr;
size_t *m_iRefCount;
void init(const T* _ptr)
{
m_ptr = const_cast<T*>(_ptr);
m_iRefCount = new size_t(1);
}
void copy(const CLS_RefCount& other)
{
m_ptr = other.m_ptr;
m_iRefCount = other.m_iRefCount;
(*m_iRefCount)++;
}
void release()
{
(*m_iRefCount)--;
if ((*m_iRefCount) == 0)
{
delete m_iRefCount;
delete m_ptr;
}
}
};
int main()
{
CLS_RefCount<string> m_str(new string("test"));
cout << "refCount of m_str after construction is " << m_str.refCount() << " value is " << *m_str << endl;
CLS_RefCount<string> m_strCopy1 = m_str;
CLS_RefCount<string> m_strCopy2 = m_str;
m_strCopy2 = m_str;
CLS_RefCount<string> m_strOther = m_str;
cout << "refCount of m_str after copy is " << m_str.refCount() <<endl;
m_strOther.changeVal([](std::string val) {return val += "other";});
cout << "refCount of m_str after change is " << m_str.refCount() << " value is " << *m_str << endl;
cout << "refCount of m_strOther after change is " << m_strOther.refCount() << " value is " << *m_strOther << endl;
}
这里,我们需要注意在changeVal时遵循的原则还是先用临时对象保存新数据,然后销毁源数据,再将临时对象中的数据拷贝到当前对象中;对于operator=,我们并没有使用copy函数,而是先将引用计数自增,这是为了避免赋值运算符中最容易出现的问题 —— 自赋值
二、右值引用和移动语义
1、右值引用
左值持久,右值短暂。右值只能绑定到一个即将要销毁的对象上。因此,使用右值引用的代码可以自由地接管所引用的对象的资源。注意,右值引用本身是变量,而变量又是左值,因此:
int&& b = 5;
int&& c = b;// invalid
2、移动操作 和 noexcept
有一个准则我们需要记住:不抛出异常的移动构造函数和移动赋值函数必须标记为noexcept。要理解这点,我们需要看看当我们向标准库容器添加元素引起的容器扩容是怎样实现的(以vector为例):
void push_back(const _Ty& _Val) { // insert element at end, provide strong guarantee
emplace_back(_Val);
}
decltype(auto) emplace_back(_Valty&&... _Val) {
...
if (_Mylast != _My_data._Myend) { // don't need to reallocate
...
}
_Ty& _Result = *_Emplace_reallocate(_Mylast, _STD forward<_Valty>(_Val)...);
}
pointer _Emplace_reallocate(const pointer _Whereptr, _Valty&&... _Val) {
...
if (_Whereptr == _Mylast) { // at back, provide strong guarantee
_Umove_if_noexcept(_Myfirst, _Mylast, _Newvec);
} else { // provide basic guarantee
...
}
...
}
void _Umove_if_noexcept(pointer _First, pointer _Last, pointer _Dest) {
// move_if_noexcept [_First, _Last) to raw _Dest, using allocator
_Umove_if_noexcept1(_First, _Last, _Dest,
bool_constant<disjunction_v<is_nothrow_move_constructible<_Ty>, negation<is_copy_constructible<_Ty>>>>{});
}
void _Umove_if_noexcept1(pointer _First, pointer _Last, pointer _Dest, true_type) {
// move [_First, _Last) to raw _Dest, using allocator
_Uninitialized_move(_First, _Last, _Dest, _Getal());
}
void _Umove_if_noexcept1(pointer _First, pointer _Last, pointer _Dest, false_type) {
// copy [_First, _Last) to raw _Dest, using allocator
_Uninitialized_copy(_First, _Last, _Dest, _Getal());
}
从上面的代码流程我们可以看出来,在扩容操作发生,需要从旧数组向新数组拷贝数据时,标准库根据元素是否支持noexcept的移动操作来决定调用拷贝赋值还是移动赋值。这是因为vector等容器都提供了强烈的保证:在push_back发生异常时,vector自身不会发生变化。因此,如果移动构造函数可能发生异常,那么在进行拷贝时,便不会调用该函数。写个例子验证下:
#include <iostream>
#include <vector>
using namespace std;
class CLS_Test
{
string m_strMem;
public:
CLS_Test(string _str = "")
{
m_strMem = _str;
}
CLS_Test(const CLS_Test& other)
{
cout << "CLS_Test& other" << endl;
m_strMem = other.m_strMem;
}
CLS_Test(CLS_Test&& other) noexcept(false)
{
cout << "CLS_Test&& other" << endl;
swap(m_strMem, other.m_strMem);
}
};
int main()
{
vector<CLS_Test> vecTest(2);
vecTest.push_back(CLS_Test());
vecTest.push_back(CLS_Test());
vecTest.push_back(CLS_Test());
}
当我们将移动构造函数设置为 noexcept(true) 后:
这里,我们需要注意 is_copy_constructible<_Ty> 当且仅当模板参数类有一个参数为 const & 类型的拷贝构造函数才为真。这就是说,如果我们把拷贝构造函数的参数去掉 const &,将无法得到第一个结果。
3、合成移动操作
与拷贝操作不同,编译器根本不会为默写类合成移动操作。当一个类顶一个自己的拷贝构造函数、拷贝赋值运算符或者析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符了。当上述操作都不存在,且每个非静态数据成员都可以移动时,编译器才会为它合成移动操作。
在以下情况中,移动操作将被定义为删除:
(1)有类成员定义了拷贝构造函数且未定义移动构造函数,或者是有类成员未定义拷贝构造函数,而编译器不能为其合成移动构造函数;
(2)有类成员的移动构造函数或移动赋值运算符被定义为删除或不可方位,则类内相应的函数将被定义为删除;
(3)如果析构函数被定义为删除的或不可访问的,则类的移动构造函数(包括拷贝构造函数)被定义为删除的;
(4)如果有成员被const修饰或为引用,则类的移动赋值运算符被定义为删除的。
int main()
{
CLS_TestDel *pTest = new CLS_TestDel;
CLS_TestDel testCopy(*pTest);
CLS_TestDel testMove(std::move(*pTest));
}
除此之外,还有一点需要我们需要注意:当我们在类中定义了移动构造函数和/或移动赋值运算符后,其拷贝构造函数、拷贝赋值运算和默认构造函数将被定义为删除的。
4、移动迭代器
与普通迭代器不同,对移动迭代器解引用将会得到右值引用。
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
int main()
{
vector<string> vec = { "taef", "sadf", "wer", "poiop" };
vector<string> vecMove(4);
uninitialized_copy(make_move_iterator(vec.begin()), make_move_iterator(vec.end()), vecMove.begin());
cout << "vec.size() = " << vec.size() << " :" << endl;
copy(vec.begin(), vec.end(), ostream_iterator<string>(cout, " "));
cout << endl;
cout << "vecMove.size() = " << vecMove.size() << " :" << endl;
copy(vecMove.begin(), vecMove.end(), ostream_iterator<string>(cout, " "));
cout << endl;
}
5、右值和左值引用成员函数
对于以下操作:
int main()
{
string s1 = "foo";
string s2 = "bar";
s1 + s1 = "wow";
}
其本身是没有意义的。然而我们如何阻止这种赋值呢?通过左值引用成员函数。这种成员函数与const成员函数类似,不同的是它要求调用方必须是一个左值引用:
#include <iostream>
using namespace std;
class myString
{
private:
string m_strMem;
public:
myString(const char* _pstr)
{
m_strMem = *_pstr;
}
myString operator+(const myString& right)
{
return myString((this->m_strMem + right.m_strMem).c_str());
}
myString operator=(const myString& right) &
{
m_strMem = right.m_strMem;
}
};
int main()
{
myString s1 = "foo";
myString s2 = "bar";
s1 + s1 = "wow";
}
这样就无法将右值放在赋值操作符的右侧了。
当我们将引用函数和const同时使用时,引用需要放在const后面。
6、重载和引用函数
类似const修饰,引用限定符同样可以区分重载版本。但是与修饰参数类似,无法通过const和 const& 区分成员函数。
#include <iostream>
using namespace std;
class CLS_Test
{
public:
void test() const&
{
cout << "test const &" << endl;
}
void test() &
{
cout << "test &" << endl;
}
void test() &&
{
cout << "test &&" << endl;
}
};
CLS_Test testRet()
{
return CLS_Test();
}
int main()
{
CLS_Test test;
const CLS_Test ctest;
ctest.test();
test.test();
testRet().test();
}
引用成员函数有一个严格的规定:对于同名同参数的成员函数,必须对所有函数加上引用限定符,或者都不加。因此,以下代码无法编译通过:
class CLS_Test
{
public:
void test() const&{}
void test() {} // invalid
};