C++之旅(学习笔记)第6章 基本操作

C++之旅(学习笔记)第6章 基本操作

6.1 基本操作

class X{
public:
    X(Sometype);			// "普通的构造函数": 创建一个对象
    X();					// 默认构造函数
    X(const X&);			// 拷贝构造函数
    X(X&&);					// 移动构造函数
    X& operator=(const X&);	// 拷贝赋值操作符:清空目标对象并拷贝
    X& operator=(X&&);		// 移动赋值操作符:清空目标对象并移动
    ~X();					// 析构函数:清理资源
    //...
};

在下面5种情况下,对象会被移动或拷贝:

  • 赋值给其他对象。
  • 作为对象初始化。
  • 作为函数的实参。
  • 作为函数的返回值。
  • 作为异常。

如果希望显示地使用函数的默认实现:在函数后面加上 =default

class Y{
public:
    Y(Sometype);
    Y(const Y&) = default;	// 我确实需要默认的拷贝构造函数
    Y(Y&&) = default;		// 也确实需要默认的移动构造函数
};
  • 一旦显示地指定了某些函数地默认形式,编译器就不会再为函数生成其他默认定义了。
  • 当类中含有指针成员时,最好显示地指定拷贝操作和移动操作。如果不这样做,当编译器生成地默认函数试图delete指针对象时,系统将发生错误。

如果不想生成目标操作函数:在函数后面加上=delete

class Shape {
public:
    Shape(const Shape&) =delete;		// 禁止拷贝
    Shape& operator=(const Shape&) =delete;
    //...
};
void copy(Shape& s1, const Shape& s2)
{
    s1 = s2;							// 错误:Shape禁止拷贝
}
  • 试图使用=delete的函数会在编译时报错;=delete可以用于禁用任意函数,并非仅仅用于禁用基础成员函数。

6.1.2 转换

接受单个参数的构造函数同时定义了从参数类型到类类型的转换。

例如:complex提供了一个接受double类型的参数的构造函数:

class complex {
    double re,im;		// 成员变量:两个双精度浮点数
public:
    complex(double r,double i):re{r},im{i}{}	// 用两个标量构建该复数
    complex(double r):re{r},im{0}{}				// 用一个标量构建该复数
    complex():re{0},im{0}{}						// 默认的复数是:{0,0}
    //...
};
complex z1 = 3.14;	// z1变成 {3.14,0.0}
complex z2 = z1*2;	// z2变成 z1*{2.0,0} == {6.28,0.0}
  • 这种转换有时似乎合情理,有时则不然。

例如:Vector提供了一个接受int类型的参数构造函数:

class Vector{
private:
    double* elem;
    int sz;
public:
    Vector(int s):elem{new double[s]},sz{s}
    {
        for(int i = 0; i != s; ++i)
            elem[i] = 0;
    }
    ~Vector(){delete[] elem;}
    // ...
};
Vector v1 = 7;		// 可行:v1有7个元素
  • 通常情况下,该语句的执行结果并非如我们的预期,标准库vector禁止这种int到vector的转换。

解决该问题的办法是只允许显示进行类型转换:

class Vector{
public:
    explicit Vector(int s);	// 不能隐式地将int转化为Vector
    // ...
};
Vector v1(7);		// 可行:v1有7个元素
Vector v2 = 7;		// 错误:不能隐式地将int转化为Vector
  • 关于类型转换地问题,complex只是一小部分,大多数类型地情况与Vector类似。

所以除非你有充分地理由,否则最好把接受单个参数的构造函数声明成explicit的。

6.1.3 成员初始值设定项

定义类的数据成员时,可以提供默认的初始值,称其为默认成员初始值设定项。

如下:修订版本的complex

class complex {
    double re = 0;
    double im = 0;	// 表示两个默认值为0.0的double类型的成员
public: 
    complex(double r, double i) : re{r}, im{i} {}	// 从两个标量{r,i}构造complex
    complex(double r) : re{r} {}					// 从一个标量{r,0}构造complex
    complex() {}									// 默认值为{0,0}的complex
    // ...
};

对于所有构造函数没有提供初始值的成员,默认初始值都会起作用。

6.2 拷贝和移动

默认情况下,我们可以拷贝对象,不论是用户自定义类型的对象还是内置类型的对象。

拷贝的默认含义是逐成员地复制,即依次复制每个成员。

6.2.1 拷贝容器

当一个类被作为资源句柄时,换句话说,当这个类负责通过指针访问一个对象时,采用默认的逐成员复制方式通常意味着会产生灾难性的错误。

逐成员复制的方式会违反资源句柄的约束条件。

例如:默认拷贝将产生Vector的一份拷贝,而这个拷贝所指向的元素与原来的元素是同一个:

void bad_copy(Vector v1)
{
    Vector v2 = v1;	// 将v1的表层拷贝到v2
    v1[0] = 2;		// v2[0]也变成了2
    v2[1] = 3;		// v1[1]也变成了3
}

假设v1包含4个元素,则结果如下图所示:

C++之旅(学习笔记)第6章 基本操作_第1张图片

类对象的拷贝操作可以通过两个成员来定义:拷贝函数与拷贝赋值操作符:

class Vector {
public:
    Vector(int s);
    ~Vector() { delete[] elem; }
    
    Vector(const Vector& a);				// 拷贝构造函数
    Vector& operator=(const Vector& a);		// 拷贝赋值操作符
    
    double& operator[](int i);
    const double& operator[](int i) const;
    
    int size() const;
private:
    double* elem;
    int sz;
};

对Vector来说,拷贝构造函数的正确定义应该首先为指定数量的元素分配空间,然后把元素复制到空间中。这样复制完成后,每个Vector就拥有自己的元素拷贝了:

Vector::Vector(const Vector& a) : elem {new double[a.sz]}, sz{a.sz}	//拷贝构造函数,分配元素所需要的空间
{
    for(int i = 0; i != sz; ++i)
        elem[i] = a.elem[i];
}

在这个示例中,v2=v1的结果现在可以表示成:

C++之旅(学习笔记)第6章 基本操作_第2张图片

除了拷贝构造函数,我们还需要一个拷贝赋值操作符:

Vector& Vector::operator=(const Vector& a) 	// 拷贝赋值操作
{
    double* p = new double[a.sz];
    for(int i = 0; i != a.sz; ++i)
        p[i] = a.elem[i];
    delete[] elem;							// 删除旧元素
    elem = p;
    sz = a.sz;
    return *this;
}

其中,名字this被预定义在成员函数中,它指向调用该成员函数的那个对象。

元素拷贝发生在旧元素被删除之前,所以如果在拷贝的过程中抛出异常,Vector的旧值可得以保留。

6.2.2 移动容器

对于大容量的容器,拷贝过程有可能消耗巨大。

当给函数传递对象时,可通过使用引用类型来减少拷贝对象的代价,但是无法返回局部对象的引用(函数的调用者都没机会和返回结果碰面,局部对象就被销毁了)。

我们相比于拷贝一个Vector对象,更希望移动它。

class Vector {
    // ...
    Vector(const Vector& a);			// 拷贝构造函数(复制构造)
    Vector& operator=(const Vector& a);	// 拷贝赋值操作符(复制赋值)
    
    Vector(Vector&& a);					// 移动构造函数
    Vector& operator=(Vector&& a);		// 移动赋值操作符
};

基于上述定义,编译器将选择移动构造函数来执行从函数中移出返回值的任务。

定义Vector移动构造函数的过程非常简单:

Vector::Vector(Vector&& a) : elem {a.elem}, sz{a.sz}
{
    a.elem = nullptr;	//现在a中没有任何元素
    a.sz = 0;
}

符号&&的意思是”右值引用“,右值的含义与左值正好相反。

左值的大致含义是 ”能出现在赋值操作符左侧的内容“,因此右值大致上就是无法为其赋值的值,比如函数调用返回的一个整数就是右值。进一步地,右值引用地含义就是引用了一个别人无法赋值地内容,所以我们可以安全地”窃取“它的值。

移动构造函数不接受const实参:毕竟移动构造函数最终要删除它实参中的值。

6.3 资源管理

通过定义构造函数、拷贝操作、移动操作和析构函数,程序员就能对受控资源(比如容器中元素)的生命周期进行完全控制。

内存也不是唯一的一种资源。资源是指任何在使用前需要获取与(显示或隐式)释放的东西,除了内存,还有锁、套接字、文件句柄和线程句柄等非内存资源。

在C++标准库中,RAII无处不在:例如,内存(string、vector、map、unordered_map等)、文件(ifstream、ofstream等)、线程(thread)、锁(lock_guard、unique_lock等)和通用对象(通过unique_ptr和shared_ptr访问)。

6.4 建议

  1. 尽量让对象的构造、拷贝(复制)、移动和销毁、在掌控之中;

  2. 同时定义所有的基本操作,或者什么都不定义;

  3. 如果默认的构造函数、赋值操作符和析构函数符合要求、那么让编译器负责生成它们;

  4. 如果类含有指针成员,考虑这个类是否需要用户自定义或者删除析构函数、拷贝函数及移动函数;

  5. 默认情况下,把单参数的构造函数声明成explicit的;

  6. 如果默认拷贝函数不适合当前类型,则重新定义或禁止拷贝函数;

  7. 用传值的方式返回容器(依赖拷贝消除和移动以提高效率);

  8. 避免显示使用std::copy();

  9. 对于容量较大的操作数,使用const引用作为参数类型;

  10. 使用RAII管理所有资源——内存和非内存资源;

  11. 如果默认的构造函数、赋值操作符和析构函数符合要求、那么让编译器负责生成它们;

  12. 如果类含有指针成员,考虑这个类是否需要用户自定义或者删除析构函数、拷贝函数及移动函数;

  13. 默认情况下,把单参数的构造函数声明成explicit的;

  14. 如果默认拷贝函数不适合当前类型,则重新定义或禁止拷贝函数;

  15. 用传值的方式返回容器(依赖拷贝消除和移动以提高效率);

  16. 避免显示使用std::copy();

  17. 对于容量较大的操作数,使用const引用作为参数类型;

  18. 使用RAII管理所有资源——内存和非内存资源;

你可能感兴趣的:(C++,c++,学习,笔记)