C++ 知识总结 P06:面向对象

move 语义

左值与右值

在C++中可以取得内存地址的、有名字的变量就是左值,左值在使用时可以放在等号左侧;右值则是指不能取得内存地址的、没有名字的变量,只能在等号的右侧使用。右值直观上就是临时变量,如执行 a = b + c 时,等号右侧优先计算,加和结果会被保存在临时变量中,这个临时变量就是右值。
在C++中,使用左值的方式,可以是通过其“本名”访问,也可以通过引用访问,此时使用的引用是左值引用。但是右值没有关联名字,为了能够使用右值,C++引入了右值引用的概念。

void func(string& str);    // 这是一个左值引用
void func(string&& str);   // 这是一个右值引用

问题是我们为什么要直接使用右值?可以看下面这个例子

struct Node {
    int val;
    Node(const int& _val) : val(_val) {}
};
int input = 10;
Node node_a(input);
Node node_b(10);

上述例子中,两个实例的构造过程基本相同;区别在于 node_a 中传入的是左值引用,在使用完后 input 变量仍然存活,而 node_b 中传入的是一个临时变量,临时变量的值会被复制到 val 中,临时变量使用完后就被销毁了。如果传入的临时变量空间占用非常大,复制过程就是一笔很大的开销,何不保留临时变量并直接将 val 指向临时变量呢?

move 语义

C++中提供了 move 语义,可以将“将亡值”(即将被销毁的临时变量)有效期的有效期延长,直接保留临时变量而避免上述的拷贝过程。

struct Node {
    int val;
    Node(const int& _val) : val(_val) {}
    Node(int&& _val) { val = std::move(_val); }
};
int input = 10;
Node node_a(input);
Node node_b(10);

a = std::move(b) 可以理解为将 b 这个名字擦掉,以 a 这个名字替换,此时将再也无法使用 b 访问该变量。

指针与智能指针

使用指针

C++ 提供了 new 和 delete 关键字来分配和释放内存(堆)

// 或者直接用 auto
string* p_str = new string{"hello, world"}; // 这种花括号的使用也是可以的
delete p_str;

使用智能指针

智能指针是C++为了能够更安全地使用动态内存而产生的一种指针,与传统指针不同的地方,智能指针在不使用时会自动释放所指向的对象,避免内存空间浪费。

智能指针使用模板创建:

  • share_ptr 允许多个指针指向同一个对象;
  • unique_ptr 独占指向的对象;
  • weak_ptr 指向 share_ptr 指向的对象,但是不参与其生存期的计数,因此使用前要检查。
share_ptr<int> p_int = make_shared<int>(10);  // 无须手动释放

类的定义

关键字 struct 与 class 搜可以由于类的创建,struct 中的所有成员都是公开的,而 class 中的所有成员权限默认是私有的。

构造函数

默认构造函数

默认构造函数没有参数,会对类的成员进行默认初始化:如果成员变量有默认初始值,则使用默认初始值,如果成员有默认初始化的方法,则使用这些方法;否则,默认构造函数会报错。

如果没有在类中声明任何构造函数,编译器会自动创建一个默认构造函数,但是如果有声明构造函数,编译器就不会自动生成默认构造函数了。此时如果需要默认构造函数,可以手工指定 classname() = default

普通的构造函数

使用带有参数的构造函数,为类的成员变量赋初始值,以及进行一些其它的初始化动作。这里可以使用初始值列表

classname(const type& _a, const type& _b) : var_a(_a), var_b(_b), var_c(0) {}

初始值列表即函数参数之后、函数体之前的部分,还可以在这一部分使用默认初始值来初始化成员变量。

对于类内的 const 成员变量,只能使用初始值列表进行初始化。
使用列表初始化构建类的实例时,会使用该初始值列表,按照成员变量的声明顺序进行初始化操作。

委托构造函数

在构造函数中使用同一类的其它构造函数,就是委托构造函数。委托构造函数的形式:

classname(const type& _a) : classname(_a, 0) {}

这个构造函数使用了上述的带有初始值列表的构造函数。

转换构造函数
由于 C++ 提供的隐式转换,使得构造函数能够使用的参数类型范围更大。如果不想要这种隐式类型转换,可以使用 explicit 修饰构造函数,只能使用在声明处。

拷贝构造函数
拷贝构造函数的参数是类本身,用来实现类的拷贝行为。此外,还有拷贝赋值也实现了类的拷贝,是通过重载 = 运算符实现的。

classname(const classname& _c) : var_a(_c.a), var_b(_c.b), var_c(_c.c) {}
classname& operator=(const classname& _c) { return *this; }

classname p1(p2);        // 拷贝
classname p1 = p2;       // 赋值

如果没有手工定义拷贝构造函数和拷贝赋值函数,编译器会自动合成;如果想显式地要求编译器合成拷贝构造函数和拷贝赋值函数,使用 =default;如果想禁止类有拷贝的能力,阻止任何的拷贝构造函数和拷贝赋值函数,使用 =delete

移动构造函数
移动构造函数与拷贝构造函数很类似,用来实现类的移动行为。同样的,也有移动赋值运算符。

classname(classname&& _c) : /* 这里不写了 */ {}
classname& operator=(classname&& _c) { return *this; }

编译器不会为类合成移动构造函数和移动赋值函数。

析构函数

析构函数用于销毁实例。如使用 delete p 是会调用 p 的析构函数进行销毁。

友元

友元是用来控制其它的函数或对象,访问本对象的非公开成员的方法。比如使用打印函数将本对象打印到屏幕,本对象中的某些成员变量是私有的,通过对打印函数添加 friend 前缀,使它可以访问这些私有成员变量。

操作符重载

在类中重载运算符可以丰富类的行为,使类更方便使用。如

  • 重载 = 运算符来实现各种赋值的行为。
  • 重载比较运算符实现实例的比较,这样实例就可以使用标准算法库进行查找排序等操作。
  • 重载 () 使类成为一个函数对象。
  • 重载 [] 使类表现地向容器一样可以通过下标访问。

重载运算符以符合日常使用的经验为佳。

类的类

虚函数

虚函数为允许基类调用的派生类的函数。在派生类中不一定要重新定义基类的虚函数,但是重新定义后,基类和派生类就会对该函数拥有各自的版本,在调用该函数时动态绑定。借助指针或引用,虚函数能够实现多态的效果。

class base {
    virtual type func() {}
}
class deriver : public base {
    virtual type func() {}
}

// 此时使用 base 类型的指针定义 deriver 的对象
base* obj = new deriver();
obj.func();    // 此时会使用 deriver 中的 func,而不是 base 中的 func

从上面的例子可以看出,析构函数最好定义成虚函数,否则 obj 可能不能正确地析构。

纯虚函数为没有实现的函数。纯虚函数的目的是要求派生类必须实现这一函数,主要是为了规范派生类的功能。带有纯虚函数的类称为抽象类,因为纯虚函数没有实现,无法进行实例化。

virtual type function(args) = 0;    // 纯虚函数

面向对象

面向对象编程的三个特征是:封装、继承、多态。

对于 C++ 而言,封装的概念很好理解:通过类的抽象,许多细节被隐藏在了类的内部,使类的用户只需要关心接口如何使用即可;以及使用 public, private, protected 对类成员的权限控制,使类的使用者不会触及他们不该访问的类成员。

C++ 支持单一继承或多重继承,提供的抽象类的概念,以及使用 public, private, protected 进行继承中权限的控制,都是对继承的支持。在继承中比较容易混淆的是普通成员函数、虚函数、纯虚函数,这里以类指针下的三个函数调用说明区别:

class base {
public:
    string f() { return "base.f"; }            // 普通函数
    virtual string g() { return "base.g"; }    // 虚函数
    virtual string h() = 0;                    // 纯虚函数
};
class deriver : public base {
public:
    string f() { return "deriver.f"; }
    virtual string g() { return "derivr.g"; }
    string h() { return "deriver.h"; }
};
int main() {
    base*     pb = new deriver();
    deriver*  pd = new deriver();
    // 普通的成员函数
    cout << pb->f() << endl;  // -> base.f
    cout << pd->f() << endl;  // -> deriver.f
    // 虚函数
    cout << pb->g() << endl;  // -> deriver.g
    cout << pd->g() << endl;  // -> deriver.g
    // 纯虚函数
    cout << pb->h() << endl;  // -> deriver.h
    cout << pd->h() << endl;  // -> deriver.h
}

从上面的比较可以看出:

  • 对于普通的成员函数,对它的调用取决与实例声明的类型,声明为一个 base 类型时,就会使用 base 的普通成员函数,声明为一个 deriver 类型时,就会使用 deriver 的普通成员函数。
  • 对于虚函数,对它的调用取决与实例的实质类型,这里实质上两个指针指向的都是 deriver 类型的,所有都会使用 deriver 类型的。
  • 对于纯虚函数,抽象类不会实现,只会使用派生类的。

上面的比较可以看出,使用普通成员函数时,基类更像是多种类型相同点的抽象,使用中直接使用派生类会比较合适(实现继承)。而使用虚函数和纯虚函数时,基类看上去更像是多种派生类的统一接口,通过使用基类调用接口相同但行为不同的派生类的功能(接口继承)。这样对于同一基类下的派生类,使用基类作为某个函数的参数就能定义出所有派生类的行为,否则就需要每一个派生类单独定义。这种方式是多态的体现。

posted @ 2020-08-17 13:06  ixtwuko  阅读(284)  评论(0编辑  收藏  举报