【c++ primer 5】学习笔记:类设计者的工具—13,14,15,16章
第13章:拷贝控制
拷贝控制操作
拷贝控制操作:
拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符、析构函数- 如果一个类定义了任何一个拷贝操作,就应该定义所有的五个操作。
拷贝构造函数:
第一个参数是自身类型的引用(否则就得拷贝实参,这样会无限循环),且任何额外参数都有默认值。即使定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数。拷贝初始化发生的情况:
- 等号=
- 将对象作为实参传递给一个非引用类型的形参
- 从一个返回类型为非引用类型的函数返回一个对象
- 用花括号初始化一个数组中的元素或一个聚合类中的成员
- 初始化标准库容器或调用其insert或push成员
拷贝赋值运算符:
如果未定义自己的拷贝赋值运算符,编译器会为它合成一个。本身是一个重载的赋值运算符,左侧运算对象绑定到隐含的this参数。会将右侧的非static成员逐个赋予左侧对象的对应成员。重载赋值运算符:
赋值运算符通常应该返回一个指向其左侧运算对象的引用foo& operator=(const foo&);析构函数:
不接收参数所以不支持重载,只会有唯一的析构函数。包含了函数体和析构部分,首先执行函数体,成员按初始化的逆序销毁。当一个类需要析构函数,那么它几乎也需要一个拷贝构造函数和一个拷贝赋值函数。析构函数什么时候被调用:
- 变量在离开其作用域时被销毁
- 当一个对象被销毁时,其成员被销毁
- 容器(标准库容器或者数组) 被销毁时,其元素被销毁
- 对于动态分配的对象,应用delete时被销毁
- 临时对象当它完整的表达式结束时被销毁
- 当指向一个对象的引用或指针离开作用域时,析构函数不会执行
使用=default
,只能对合成的默认构造函数或拷贝控制成员使用default
class Scale_data{
public:
Scale_data()=default;
Scale_data(const Scale_data&)=default;
Scale_data& operator=(const Scale_data &);
~Scale_data() = default;
使用=delete来阻止拷贝
:比如说iostream类就阻止了拷贝,以避免多个对象写入或读取相同的IO缓冲。与default不同,必须出现在函数第一次声明的时候,并且可以对任何函数指定delete。不能删除析构函数。
class NoCopy{
public:
NoCopy()=delete;
NoCopy(const NoCopy&)=delete;
NoCopy& operator=(const NoCopy &) = delete;
~NoCopy() = default;
- 定义拷贝操作,使类的行为看起来更像是一个值或者指针。
像值:标准库容器和string类
像指针:shared_ptr
都不像:IO类型 unique_ptr(不接受拷贝或者赋值) 像值:
编写赋值运算符:首先如果将一个对象赋予它自身,赋值运算符必须能正确工作;其次大多数赋值运算符组合了析构函数和拷贝构造函数的工作。一个好的模式是先将右侧运算对象拷贝到一个局部临时对象中。
-
像指针:
share_ptr 或者 引用计数(将计数器保存在动态内存中,当拷贝或者赋值时,我们拷贝指向计数器的指针,而析构函数也要判断一下计数器的值再删除指针) -
拷贝并交换
:定义swap的类通常用swap来定义它们的赋值运算符
HasPtr& HasPtr::operator=(HasPtr rhs)
{
swap(*this, rhs); //因为rhs不是引用,是右侧运算对象的副本,交换后*this的指针成员刚好指向新分配的string,同时rhs会在结束后被销毁
return *this;
}
动态内存管理类
某些类需要自己进行内存分配,这些类一般来说必须定义自己的拷贝控制成员来管理所分配的内存。
eg:实现一个简化的vector(无模板),用于string
- 移动构造函数:将资源从给定对象“移动”而不是拷贝到正在创建的对象。
- std::move:定义在utility头文件中,当需要在新内存中构造string时,必须调用move来表示希望使用string的移动构造函数,使用时直接用std::move而不是move。有一个左值,但希望像右值一样处理它。
左值引用和右值引用
返回左值的表达式:返回左值引用的函数及赋值、下标、解引用和前置递增减
返回右值的表达式:返回非引用类型的函数及算术、关系、位和后置递增减
右值引用&&:只能绑定到要摧毁的对象,可以窃取状态。
左值引用不能绑定到要求转换的表达式、字面常量或是返回右值的表达式。但右值引用可以,但右值不能绑定到变量上
可以将一个const的引用绑定到一个右值上 const int &r=i*42。
移动构造函数和移动赋值运算符
- 移动构造函数的第一个参数是该类类型的一个引用,是一个右值引用
- 不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept
class StrVec{
public:
StrVec(StrVec&&) noexcept;
}
StrVec::StrVec(StrVec &&s) noexcept:
- 只有当一个类没有定义任何自己版本的拷贝控制成员,且它的所有数据成员都能移动构造或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符。
- 定义了一个移动构造函数或者移动赋值运算符的类必须也定义自己的拷贝操作。否则这些成员默认地被定义为删除的。
- 如果一个类有一个可用的拷贝构造函数而没有移动构造函数,则其对象是通过拷贝构造函数来移动的。
- 拷贝左值、移动右值
- 常用std::move来表示使用移动赋值运算符而不是拷贝赋值运算符
- 移动赋值运算符必须销毁左侧运算对象的旧状态
移动赋值运算符设计思路:
- 显式检查是否自赋值
- 若不是,将赋值号左侧对象的资源释放掉
- 将右侧对象的指针p拷贝给左侧对象
- 将右侧对象置于析构安全的状态
拷贝并交换
一般用拷贝并交换来代替赋值运算符,如果同时实现了拷贝构造与移动构造函数,则拷贝与交换实现的单一赋值运算符自动实现拷贝与移动两种赋值运算符的功能。
class HasPtr{
public:
HasPtr(HasPtr &&p) noexcept: ps(p.ps),i(p.i){p.ps=0;}
HasPtr& operator=(HasPtr rhs){
swap(*this,rhs);
return *this;
}
}
hp = hp2; //hp2 是左值,通过拷贝构造函数来拷贝
hp = std::move(hp2); //移动构造移动hp2
而移动赋值运算符先从hp2转移到rhs,再交换到hp,冗余
class HasPtr{
public:
HasPtr& operator=(HasPtr &&rhs) noexcept{
if(this != &rhs){
delete ps;
ps = rhs.ps;
rhs.ps = nullptr;
rhs.i = 0;
}
return *this;
}
}
hp = hp2; //hp2 是左值,通过拷贝构造函数来拷贝
hp = std::move(hp2); //移动构造移动hp2
移动迭代器
- 利用
make_move_iterator
函数将一个普通迭代器转换为一个移动迭代器。此函数接受一个迭代器参数,返回一个移动迭代器。 - 一个迭代器的解引用运算符返回一个指向元素的左值,而移动迭代器的解引用操作运算符生成一个右值引用。
引用限定符
- 类似于const限定符,只能用于非static成员函数。若有const限定符,引用限定符必须在const后面。
- 对于&限定的函数,只能将其用于左值;对于&&限定的函数,只能将其用于右值。
class Foo{
public:
Foo &operator=(const Foo&) &;//只能向可修改的左值赋值
};
Foo &Foo::operator=(const Foo &rhs) &
{
return *this;
}
- 如果我们定义两个或两个以上具有相同名字和相同参数的成员函数,就必须对所有函数都加上引用限定符,或者都不加。
- 定义成员函数时,对象为右值,意味没有用户,就可以改变对象;对象为左值或const右值,不能改变对象,就得先拷贝data。
第14章:重载运算与类型转换
- 通常条件下,不应该重载逗号、取地址、逻辑与和逻辑或运算符
- 例如算术、相等性、关系和位运算符等应该是普通的非成员函数
重载运算符和内置运算符的区别
不同点:
- 重载操作符必须具有至少一个class或枚举类型的操作数
- 重载运算符不保证操作数的求值顺序,例如对&&和||的重载版本不再具有短路求值的特性
相同点:
对于优先级和结合性及操作数的数目不变
输入输出运算符
-
重载输出运算符必须注重内容而不是格式,不应该打印换行符
-
输入输出运算符必须是非成员函数
-
输入运算符必须对输入失败的情况进行处理
istream &is
If(is) 判断是否读取成功 -
输入运算符应标示失败信息:failbit eofbit badbit
相等运算符 关系运算符
- 相等运算符:既要定义==,也要定义!=
关系运算符:< 当且仅当==的结果和<的定义一致时才能定义<
赋值运算符
-
拷贝赋值,移动赋值,重载赋值运算符,上述三个都要释放当前内存空间
-
复合赋值运算符比如+=,都要返回左侧对象的引用
下标运算符
operator[]
- 必须是成员函数
- 以所访问元素的引用作为返回值,这样下标可以出现在等号的任意端
- 应同时定义常量版本和非常量版本
++ - -
定义递增和递减的类应同时定义前置版本和后置版本。如何区分?
后置多设置一个参传入参数,并且返回值应该是对象的原值而不是引用。后置运算符可以调用前置运算符完成。
成员访问运算符
箭头运算符必须是类的成员,解引用运算符一般也是。
重载的箭头运算符必须返回类的指针或者自定义了箭头运算符的某个类的对象。
函数对象
函数对象常常作为泛型算法的实参
class PrintString{
public:
PrintString(ostream &o=cout, char c=' '):os(o),sep(c){}
void operator()(const string &s) const {os << s << sep;}
private:
ostream &os;
char sep;
}
PrintString printer;
printer(s); //在cout中打印s,后面跟一个空格
for_each(vs.begin(), vs.end(), PrintString(cerr, '/n'))
- lambda表达式产生的类不含默认构造函数、赋值运算符及默认析构函数。
什么时候用lambda?什么时候用类?
前者适用于一个简单的函数,并且不会在其他地方被使用。
后者适用于需要多次使用,并且需要保存某些状态。
标准库函数对象
头文件“functional”
vector<string *> nameTable;
sort(nameTable.begin(), nameTable.end(), less<string*>()); //指针的less是定义良好的,通过比较内存地址来sort
一般与bind2nd、bind1st连用,表示将参数绑定到第几个参数,新标准应该使用bind(第十章泛型算法)
//统计大于1024的值有多少个
count_if(vec.begin(), vec.end(), bind2nd(greater<int>(), 1024));
//找到第一个不等于poch的字符串
find_if(vec.begin(), vec.end(), bind2nd(not_equal_to<string>(), "poch"));
标准库function类型
function是一个模板
function<int(int, int)> f1=[](int i, int j){return i*j;}; //lambda
function<int(int, int)> f2=add; //函数指针
function<int(int, int)> f3=divide(); //函数对象类的对象
f1(4,2);
解决与类中定义的add函数名的二义性 —— 用函数指针
int (*fp)(int, int) = add;
map<string,function<int(int,int)>> binops;
binops.insert({"+",fp});
eg.简单的桌面计算器
#include <iostream>
#include <string>
#include <map>
using namespace std;
map<string, function<int(int, int)>> binops = {
{"+", plus<int>()},
{"-", minus<int>()},
{"/", divides<int>()},
{"*", multiplies<int>()},
{"%", modulus<int>()}
};
int main()
{
while (true) {
std::cout << "\npleasr enter: num operator num :\n";
int n1, n2;
std::string s;
std::cin >> n1 >> s >> n2;
std::cout << binops[s](n1, n2);
}
return 0;
}