【C++基础】3. 类的设计

13. 拷贝控制

13.1 拷贝、赋值、移动、销毁

  • 当定义一个类时,通过5仲特殊的成员函数指定此类型对象在拷贝、赋值、移动、销毁时的操作,包括:① 拷贝构造函数;② 拷贝赋值运算符;③ 移动构造函数;④ 移动赋值运算符;⑤ 析构函数。若一个类没有定义上述所有操作,编译器会自动定义,但有些类的默认定义会产生严重的问题
  • 拷贝构造函数 :若一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数:Person(const Person&);,拷贝构造函数通常不是explicit的,且绝大多数情况下都会将参数设置为const的引用。编译器自定义的称为合成拷贝构造函数,该函数会从给定的对象中依次将每个非static成员拷贝到正在创建的对象中,每个成员的类型决定了拷贝的方式:类类型的成员会使用其拷贝构造函数来拷贝、内置类型的成员直接拷贝、数组类型会将其成员逐个拷贝
  • 拷贝初始化在以下情况发生:① 使用=定义变量时;② 将一个对象作为实参传递给一个非引用类型的形参时;③ 从一个返回类型为非引用类型的函数返回一个对象时;④ 用花括号列表初始化一个数组中的元素或一个聚合类中的成员时。某些类类型还会对它们所分配的对象使用拷贝初始化,如:初始化标准库容器时、调用其insert()或push()函数时,而使用emplace()函数时进行的是直接初始化
  • 若一个类的某个构造函数为explicit时:explict Person(int age);,用int类型变量可以直接初始化:Person p(10);/f(Person(10));,但是不能拷贝初始化:Person p = 10;/f(10);,因为无法进行隐式类型转换
  • 拷贝赋值运算符 :为一个已经存在的对象用=重新赋值时,执行类的拷贝赋值运算符,拷贝赋值运算符本质上是一个名为operator=的函数,对=运算符进行了重载,其左侧运算对象绑定到隐式的this参数,右侧运算对象作为显式参数传递:Person& operator=(const Person&);,为了与内置类型的赋值保持一致,赋值运算符通常返回一个指向其左侧运算对象的引用
  • 析构函数 :析构函数执行与构造函数相反的操作,构造函数初始化对象的非static数据成员,析构函数销毁对象的非static数据成员。析构函数是类的一个成员函数,由波浪号接类名构成:~Person();,没有返回值,不接受参数,一个类只会有唯一一个析构函数。构造函数有一个初始化部分和一个函数体,析构函数也有一个函数体和一个析构部分,构造函数的成员初始化是在函数体执行之前完成的,且按照它们在类中出现的顺序进行初始化,析构函数首先执行函数体,然后销毁成员,成员按照初始化顺序的逆序销毁,合成析构函数的函数体通常为空
  • 在析构函数中,不存在类似构造函数的初始化列表来控制成员如何销毁,析构部分是隐式的,成员如何销毁完全依赖于成员的类型,销毁类类型的成员执行成员自己的析构函数,销毁内置类型什么也不需要做,销毁一个内置指针类型的成员不会delete它所指向的对象,智能指针具有析构函数,其析构函数会将关联指针的引用减1,引用计数减为0时会调用delete删除该内存
  • 当一个对象被销毁时,就会自动调用其析构函数:① 变量在离开其作用域时;② 当一个对象被销毁时,其成员也被销毁;③ 容器被销毁时,其元素也被销毁;④ 动态分配的对象,delete指向它的指针时;⑤ 临时对象在创建它的表达式结束时
  • 上述的三个基本操作可以控制类的拷贝:拷贝构造函数、拷贝赋值运算符、析构函数,这些操作并不需要在任何情况下都全部定义,但它们之间有部分联系:① 需要自定义析构函数的类也需要自定义拷贝和赋值操作(防止在析构函数体内的操作报错);② 需要拷贝操作的类也需要赋值操作,反之亦然
  • 可以通过将拷贝控制成员定义为=default来显式地要求编译器生成合成的版本,在类内使用=default时,合成的函数会隐式地声明为内联地,如果不希望合成地函数为内联函数,应该在类外定义时使用=default,=default只能用于默认构造函数和拷贝控制成员
  • 可以通过在拷贝构造函数和拷贝赋值运算符后加上=delete将它们定义为删除的,这样定义后不能以任何方式使用它们。可以对任何函数只当=delete,虽然主要用于拷贝控制成员,析构函数不能定义为删除的(或者说删除析构函数的类型,只能new不能delete)。在新标准发布之前,类是通过将拷贝构造函数和拷贝赋值运算符声明为private来阻止拷贝

13.2 拷贝控制和资源管理

  • 管理类外资源的类必须定义拷贝控制成员,为了定义这些成员,必须首先确定此类型对象的拷贝语义,一般来说有两种选择:① 使类的行为看起来像一个值,此时拷贝对象时,副本对象与原对象完全独立;② 使类的行为看起来像一个指针,此时拷贝对象时,副本对象与原对象公用一份底层数据
  • 行为像值的类:赋值运算符组合了构造函数和析构函数的操作,赋值操作会销毁左侧运算对象的资源(析构函数),会从右侧运算对象拷贝数据(拷贝构造函数),这些操作是顺序执行的,将一个对象赋予它自身时也保证正确,且赋值运算符要保证异常安全,即当异常发生时能将左侧运算对象置于一个有意义的状态,在销毁左侧运算资源之前拷贝右侧运算对象
cpp 复制代码
Person& Person::operator=(const Person &p){
	auto new_name = new string(*p.name);
	delete name;
	name = new_name;
	age = p.age;
	return *this;
}
  • 行为像指针的类:最好的方法是使用share_ptr来管理类中的资源

13.3 对象移动

  • 在旧C++标准中,没有直接的方法移动对象,因此,即使不必拷贝对象的情况下,也不得不进行拷贝,旧版本的标准库中,容器中保存的类必须是可拷贝的,新标准中,可以用容器保存不可拷贝的类,只要能移动即可
  • 为了支持移动操作,新标准引入了一种新的引用类型右值引用 ,即必须绑定到右值的引用,通过&&来获得右值引用,作为区分,常规引用又称为左值引用。右值引用只能绑定到一个即将销毁的对象,因此可以自由地一个右值引用地资源移动到另一个对象中。
  • ① 返回左值引用的函数、赋值、下标、解引用、前置递增/递减运算符,都返回左值表达式,可以将一个左值引用绑定到这类表达式的结果上;② 返回非引用类型的函数、算术、关系、位、后置递增/递减运算符,都生成右值,不能将左值引用绑定到这类表达式上,但可以将一个const的左值引用或一个右值引用绑定到这类表达式上
  • 左值有持久的状态,而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象,右值引用的对象即将销毁,且没有其他用户,使用右值引用的代码可以自由地接管所引用对象的资源。虽然不能将一个右值引用绑定到一个左值上,但可以显式地将一个左值转换为右值引用类型:int &&r = std::move(a);,使用move函数后地源对象a,只能销毁或赋予新值,不能再使用它
  • 为了让自定义的类支持移动操作,需要为其定义移动构造函数移动赋值运算符 ,移动构造函数的第一个参数是该类型的一个右值引用:Person(Person &&p) noexcept;,除了完成资源移动,移动构造函数还必须确保移动后源对象可以直接销毁(源对象汇总的指针都置为nullptr),移动构造函数不分配任何新内存,故移动操作不会抛出任何异常。类似的,移动赋值运算符也不抛出任何异常:Person& operator=(Person &&p) noecept;
  • 如果一个类没有移动操作,通过正常的函数匹配,类会使用对应的拷贝操作来代替移动操作,只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符
  • 如果一个类既有移动构造函数,也有拷贝构造函数,编译器使用普通的函数匹配规则来确定使用哪个构造函数,一般,拷贝构造函数接受const Person&类型参数,移动构造函数接受Person&&类型参数。如果一个类定义了拷贝构造函数,但未定义移动构造函数,传入右值也会被拷贝
  • 除了构造函数和赋值运算符之外,一个成员函数也能同时提供拷贝和移动版本,一个版本接受一个指向const的左值引用,另一个版本接受一个指向非const的右值引用,定义了push_back的标准库容器就提供了这两个版本。可以使用引用限定符 来指定成员函数只能用于左值或右值,&限定的函数只能用于左值,&&限定的函数只能用于右值,引用限定符必须跟随在const限定符之后:Person get() const &;,限定符必须同时出现在函数的声明和定义中,限定符可用于区分重载版本:Person get() &&;,如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符

14. 重载运算和类型转换

14.1 基本概念

  • 当运算符被用于类类型的对象时,C++允许我们对其指定新的含义。同时,我们也能自定义类类型之间的转换规则,和内置类型的转换一样,类类型转换隐式地将一种类型的对象转换成另一种类型的对象
  • 重载的运算符是具有特殊名字的函数,它们的名字由关键字operator和其后要定义的运算符号共同组成。和其他函数一样,重载的运算符也包含返回类型、参数列表、函数体。重载运算符函数的参数数量与该运算符作用的运算对象数量一样多,一元运算符有一个参数,二元运算符有两个参数。如果一个运算符函数是成员函数,则它的第一个(左侧)运算对象绑定到隐式的this指针上
  • 对于一个运算符函数来说,它或者是类的成员,或者至少含有一个类类型的参数,即不能为内置类型重定义运算符的含义。大多数运算符都可以被重载,但少部分如::.*.?:不能被重载,newdelete也可以被重载。只能重载已有的运算符,无权发明新的运算符号。有4个符号:+-*&既是一元运算符也是二元运算符,通过参数数量来推断重载的是哪种运算符。重载的运算符的优先级和结合律与对应的内置运算符保持一致
  • 某些运算符不应该被重载:有些运算符指定了运算对象求值的顺序,这些关于运算对象求值顺序的规则无法应用到重载的运算符上,如:逻辑与 运算符、逻辑或 运算符、逗号 运算符。除此之外,&&||运算符的重载版本也无法保留内置运算符的短路求值属性,两个运算对象总是会被求值。C++已经定义了逗号运算符和取地址运算符用于类类型对象时的特殊含义,所以一般它们不应该被重载

14.2 选择是否作为成员

当定义重载运算符时,必须首先决定是将其声明为类的成员函数还是声明为一个普通的非成员函数。有的运算符必须作为成员,而有些运算符作为普通函数比作为成员更好

  • 赋值=、下标[]、调用()、成员访问箭头->运算符必须是成员
  • 复合赋值运算符一般来说应该是成员,但并非必须
  • 改变对象状态的运算符或与给定类型密切相关的运算符,如递增、递减、解引用运算符,通常应该是成员
  • 具有对称性的运算符可能转换任意一端的运算对象,如算术、相等性、关系、位运算符等,因此它们通常应该是普通的非成员函数。当把运算符定义为成员函数时,它的左侧运算对象必须是运算符所属类的一个对象

14.3 重载运算符

  • 输入和输出运算符必须是非成员函数,若为成员函数,则自定义类必须是istream或ostream的成员,而这两个类属于标准库,无法添加任何成员
  • 输出运算符<<:通常情况下,输出运算符的第一个形参是一个非常量ostream对象的引用,非常量因为向流写入会改变其状态,引用是因为无法直接复制一个ostream对象。第二个形参一般来说是一个常量的引用,该常量是想要打印的类型,常量引用是因为避免复制,且不改变对象的内容。为了与其他输出运算符一直,operator<<一般要返回ostream形参
cpp 复制代码
ostream &operator<<(ostream &os, const Person &p){
	os << p.age << " " << p.name << " " << p.sex;
	return os;
}
  • 输入运算符>>:通常情况下,输入运算符的第一个形参是运算符将要读取的流的引用,第二个形参是将要读到的非常量对象的引用,该运算符通常会返回给某个给定流的引用
cpp 复制代码
istream &operator>>(istream &is, Person &p){
}
  • 算术和关系运算符:通常情况下,把算术(如+-)和关系(如==<)运算符定义为非成员函数,以允许对左侧或右侧的对象进行转换,因为这些运算符一般不需要改变运算对象的状态,所以形参都是常量的引用
  • 赋值运算符=:拷贝赋值和移动赋值运算符可以把类的一个对象赋值给另一个对象,除此之外,类还可以定义赋值运算符以使用别的类型作为右侧运算对象,和拷贝赋值和移动赋值运算符一样,其他重载的赋值运算符也必须先释放当前内存空间,再创建一片新空间,且必须定义为成员函数。复合赋值运算符+=不一定非要是类的成员,不过推荐这么定义
  • 下标运算符[]:必须是成员函数,下标运算符通常会定义两个版本,一个返回普通引用,使得下标可以出现在赋值运算符的任意一端,另一个是类的常量成员并返回常量引用,保证作用于常量对象时不会给返回的对象赋值
cpp 复制代码
class People{
public:
	string& operator[](std::size_t n){return names[n];}
	const string& operator[](std::size_t n) const{return names[n];}
private:
	string *names;
}
  • 递增++和递减--运算符:C++不要求递增和递减运算符必须是类的成员,但因为它们改变的正好是所操作对象的状态,所以建议设定为成员函数。内置类型中,递增递减运算符有前置版本和后置版本,因此也应该为类定义两个版本的递增递减运算符。由于前置和后置运算符的符号相同,即重载版本使用的名字也相同,为了区分,后置版本使用一个额外的int类型形参,在使用后置运算符时,编译器会提供一个值为0的实参,该形参的唯一作用就是区分前置和后置版本,并不会参与运算
cpp 复制代码
Person& Person::operator++(){}			//前置
Person& Person::operator++(int){}		//后置
p.operator++();			//显式调用前置
p.operator++(0);		//显式调用后置
  • 成员访问运算符:包括解引用运算符*和箭头运算符->,箭头运算符必须是类的成员,解引用运算符通常也是类的成员。由于这两个运算符不会改变对象的状态,所以定义为const成员。重载的箭头运算符必须返回类的指针或者自定义了箭头运算符的某个类对象

14.4 函数调用运算符:

  • 如果类重载了函数调用运算符,则可以像使用函数一样使用该类的对象,因为类也能存储状态,所以比普通函数更加灵活。函数调用运算符必须是成员函数,一个类可以定义多个不同版本的调用运算符,相互之间应该在参数数量或类型上有所区别。如果类定义了调用运算符,则该类的对象成为函数对象,函数对象常常作为泛型算法的实参
  • lambda表达式是函数对象,当编写了一个lambda后,编译器将该表达式翻译成一个未命名类的未命名对象,lambda表达式产生的类不含默认构造函数、赋值运算符、默认析构函数,它是否含有默认拷贝/移动构造函数要视捕获的数据成员类型而定
cpp 复制代码
//lambda表达式
stable_sort(words.begin(), words.end(), [](const string &a, const string &b){return a.size()<b.size();});
//等同类的未命名对象
class ShorterString{
public:
	bool operator()(const string &s1, const string &s2) const{return s1.size()<s2.size();}
};
stable_sort(words.begin(), words.end(), ShorterString());
  • 标准库定义的函数对象:标准库定义了一组表示算术运算符、关系运算符、逻辑运算符的类,每个类分别定义了一个执行命名操作的调用运算符。如,plus类定义的函数调用运算符用于执行+操作,modulus类执行%操作,equal_to类执行==操作,这些类都被定义成模板的形式,可以为其指定具体的应用类型
cpp 复制代码
//svec是一个vector<string>,greater函数对象执行>运算
sort(svec.begin(), svec.end(), greater<string>());
  • C++中有几种可调用的对象:函数、函数指针、lambda表达式、bind创建的对象、重载了函数调用运算符的类。和其他对象一样,可调用的对象也有类型,两个不同类型的可调用对象可以共享同一种调用形式 ,一种调用形式对应一个函数类型,指明了调用返回的类型及实参类型。标准库中有一个名为function的类型,function定义在头文件functional中,是一个模板,function<T> f;是一个用来存储可调用对象的空function,这些可调用对象的调用形式应该与T相同
cpp 复制代码
function<int(int, int)> f1 = add;							//函数指针
function<int(int, int)> f2 = divide();						//函数对象类的对象
function<int(int, int)> f3 = [](int i, int j){return i*j;};	//lambda

14.5 类型转换运算符

  • 类型转换运算符是类的一种特殊成员函数,它负责将一个类类型的值转换成其他类型,类型转换运算符的一般形式为:operator type() const;,其中type表示某种类型。类型转换运算符可以转换为任意函数能返回的类型,因此不能转换成数组或函数类型,但能转换为指针或引用类型
  • 类型转换运算符没有显式的返回类型,也没有形参,且必须定义成类的成员函数,通常不会改变待转换对象的内容,因此一般被定义为const。某些情况下不经意间的类型转换会引发意想不到的结果,为了防止异常情况的发生,C++11引入了显式的类型转换运算符:explicit operator int() const {return val;},此时必须使用强制类型转换:static_cast<int>(si);,该规定存在一个例外,如果表达式被用作条件,则编译器会将显式的类型转换自动应用于它
  • 避免有二义性的类型转换:如果类型转换运算符和转换构造函数同时出现会产生二义性
cpp 复制代码
// 两种方式将B转为A
struct B;
struct A{
	A(const B&);
};
struct B{
	operator A() const;
};

15. 面向对象程序设计

15.1 OOP概述

  • 面向对象程序设计的核心思想是数据抽象、继承、动态绑定。使用数据抽象可以将类的接口与实现分离,使用继承可以定义相似的类型并对其相似关系建模,使用动态绑定可以再一定程度上忽略相似类型的区别,以统一的方式使用它们的对象
  • 继承:在层次关系根部的类称为基类,其他类直接或间接地从基类继承而来,称为派生类。基类负责定义在层次关系中所有类共同拥有的成员,每个派生类定义各自特有的成员。C++中将基类相关函数和派生类相关函数区分对待,对于要定义派生类版本的函数,基类要将这些函数声明为虚函数(virtual function) ,派生类必须通过使用类派生列表明确指出它是从哪个基类继承而来的,派生类必须在其内部对所有重新定义的虚函数进行声明,派生类可以在这样的函数前加上virtual关键字,但不是必须这么做,C++11允许派生类显式注明哪个成员函数为虚函数,在形参列表后增加一个override关键字
cpp 复制代码
class Person{
public :
	std::string name const;
	virtual int get_age() const;
};

class Man:public Person{
public :
	int get_age() const override;
}
  • 动态绑定:通过使用动态绑定可以用同一段代码分别处理基类和派生类的对象,函数运行的版本由实参决定,故动态绑定又被称为运行时绑定
cpp 复制代码
void print(const Person &p){
	int age = p.get_age();
	cout << age << endl;
}

15.2 定义基类和派生类

  • 基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作。任何构造函数之外的非静态函数都可以是虚函数,关键字virtual只能出现在类内部的声明语句之前而不能用于类外部的函数定义。如果基类把一个函数声明为虚函数,则该函数在派生类中隐式地也是虚函数。成员函数如果没被声明为虚函数,则其解析过程发生在编译时而非运行时
cpp 复制代码
class Person{
public:
	Person() = default;
	Person(const string &_name, int _age):name(_name), age(_age){}
	string get_name() const {return name;}
	virtual int get_age() const {return age;}
	virtual ~Person() = default;
private:
	string name;
protected:
	int age = 0;
};
  • 派生类必须通过使用类派生列表明确指出它是从哪个基类继承而来的,类派生列表的格式为:冒号后面紧跟以逗号分隔的基类列表,其中每个基类前面可以有以下三种访问说明符中的一个:public、protected、private,大多数类都只继承自一个类,被称为单继承
cpp 复制代码
class Man : public Person{
public:
	Man() = default;
	Man(string&, double, int);
	int get_age() const override;
private:
	double money = 0.0;
};
  • 派生类经常覆盖它继承的虚函数,如果派生类没有覆盖其基类中的某个虚函数,则该虚函数的行为类似于其他的普通成员,派生类会直接继承其在基类中的版本。尽管在派生类对象中含有从基类继承而来的成员,但是派生类并不能直接初始化这些成员,必须使用基类的构造函数来初始化它的基类部分,首先初始化基类的部分,然后按照声明的顺序依次初始化派生类的成员
  • 如果基类定义了一个静态成员,则在整个继承体系中只存在该成员的唯一定义。如果不希望一个类被其他类继承,C++11提供了一种防止继承的方法,即在类名后跟一个关键字finalclass Last final:Base{};
  • 通常情况下,如果想把引用或指针绑定到一个对象上,则引用或指针的类型应与对象的类型一致,或者对象的类型含有一个可接受的const类型转换规则,但存在继承关系的类是一个例外,可以将基类的指针或引用绑定到派生类对象上。当使用存在继承关系的类型时,必须将其静态类型和动态类型区分开,如果表达式既不是引用也不是指针,则它的动态类型永远也静态类型一致
  • 当用一个派生类对象为一个基类对象初始化或赋值时(通过拷贝控制成员),只有该派生类对象中的基类部分会被拷贝、移动、赋值,它的派生类部分将被忽略掉

15.3 虚函数

  • 在C++中,当使用基类的引用或指针调用一个虚成员函数时会执行动态绑定,因此直到运行时才能知道到底调用了哪个版本的虚函数,所以所有虚函数都必须有定义。对于不使用的函数,无需提供定义,但是每个虚函数都必须定义,无论它是否被用到,因为编译器无法确定会使用哪个虚函数
  • 动态绑定只有通过指针或引用调用虚函数时才会发生,当通过一个具有普通类型的表达式调用虚函数时,在编译时就会将调用的版本确定下来
  • 当在派生类中覆盖了某个虚函数时,可以再次使用virtual关键字指出该函数的性质,但这么做并非必须,因为一旦某个函数被声明为虚函数,则在所有派生类中它都是虚函数。派生类中继承的虚函数,其形参类型必须与它覆盖的基类函数完全一致,返回类型也必须与基类函数匹配,除非返回类型是类本身的指针或引用
  • 派生类如果定义了一个函数与基类中虚函数的名字相同但形参列表不同,编译器会认为新定义的这个函数与基类原有的函数是相互独立的,此时派生类的函数并没有覆盖基类中的版本。为了使编程意图更为清晰,在C++11新标准中可以使用override关键字(在派生类中使用 )来说明派生类中的虚函数,如果使用override标记了某个函数,但该函数并没有覆盖已存在的虚函数,此时编译器将报错。还可以把某个函数指定为final在基类中使用),如果做了此定义,之后任何尝试覆盖该函数的操作都将引发错误
  • 在某些情况下,我们希望对虚函数的调用不要进行动态绑定,而是强迫其执行虚函数的某个特定版本,使用作用域运算符可以实现这一目的:double d = baseP->ClassB::fun(10);,通常情况下,只有成员函数或友元中的代码才需要使用作用域运算符来回避函数的机制,如当一个派生类虚函数要调用它覆盖的基类的虚函数版本时

15.4 抽象基类

  • 通过在函数体声明语句的分号前加上=0就可以将一个虚函数说明为纯虚函数,其中=0只能出现在类内部的虚函数声明语句处。含有纯虚函数的类是抽象基类,抽象基类负责定义接口,后续的类可以覆盖该接口,不能直接创建一个抽象基类的对象

15.5 访问控制与继承

  • 一个类使用protected关键字来声明那些它希望与派生类分享但不想被其他公共访问使用的成员,派生类的成员或友元只能通过派生类对象来访问基类的protected成员,而不能通过基类对象访问
  • 某个类对其继承而来的成员的访问权限受到两个因素影响:① 在基类中该成员的访问说明符;② 在派生类的派生列表中的访问说明符。派生类访问说明符对于派生类的成员能否访问其直接基类的成员没有影响,只是控制派生类的用户对于基类成员的访问权限
cpp 复制代码
class A {
public:
	void fun();
protected:
	int a;
private:
	double b;
};
struct B : public A {
};
struct C : private A {
};
  • 派生类向基类的转换是否可访问由使用该转换的代码决定,同时派生类的派生访问说明符也会有影响,假定D继承自B:① 只有当D公有地继承B时,用户代码才能使用派生类向基类地转换;② 无论D以什么方式继承B,D的成员函数和友元都能使用派生类向基类的转换;③ 如果D继承B的方式是公有的或者受保护的,则D的派生类的成员和友元可以使用D向B的类型转换,若是私有的则不能使用
  • 友元关系不能传递也不能继承,基类的友元在访问派生类成员时不具有特殊性,派生类的友元也不能随意访问基类的成员
  • 有时需要改变派生类继承的某个名字的访问级别,通过使用using声明可达到这一目的,改变后,B的用户可以使用size成员,B的派生类可以使用n。派生类只能为那些它可以访问的名字提供using声明
cpp 复制代码
class A {
public:
	std::size_t size() const { return n; }
protected:
	std:size_t n;
};
class B : private A {
public:
	using A::size;
protected:
	using A::n;
};
  • 使用class关键字定义的派生类是私有继承的,使用struct关键字定义的派生类是共有继承的

15.6 继承中的类作用域

  • 每个类定义自己的作用域,在这个作用域内定义类的成员,当存在继承关系时,派生类的作用域嵌套在基类的作用域中,如果一个名字在派生类的作用域内无法正确解析,则编译器将继续在外层的基类作用域中寻找该名字的定义
  • 一个对象、引用、指针的静态类型决定了该对象的哪些成员是可见的,即使静态类型与动态类型可能不一致:B b; A *p = &b;
  • 和其他作用域一样,派生类也能重用定义在其直接基类或间接基类中的名字,此时定义在派生类的名字将隐藏定义在基类的名字,即使派生类成员和基类成员的形参列表不一致也会隐藏,可以通过作用域运算符来使用一个被隐藏的基类成员。基类与派生类中的虚函数必须有相同的形参列表,如果不同则无法通过基类的引用或指针调用派生类的虚函数
  • 和其他函数一样,成员函数无论是否是虚函数都能被重载,如果派生类希望所有的重载版本对它都是可见的,那么它就需要覆盖所有的版本,或者一个也不覆盖。为了减少繁琐的操作,可以为重载的成员提供一条using声明语句,该语句指定一个名字而不指定形参列表,即可把该函数的所有重载实例添加到派生类的作用域中

15.7 构造函数与拷贝控制

  • 和其他类一样,位于继承体系中的类也需要控制当其对象执行一系列操作时发生什么样的行为,包括创建、拷贝、移动、赋值、销毁。如果一个类没有定义拷贝控制操作,编译器会合成一个版本,这个合成的版本也可以定义成被删除的函数
  • 继承关系对基类拷贝控制最直接的影响是基类通常应该定义一个虚析构函数,当delete一个动态分配的对象的指针时将执行析构函数,如果该指针指向继承体系的某个类型,可能出现指针的静态类型与被删除对象的动态类型不符的情况,如果delete一个基类类型的指针,该指针可能实际指向一个派生类类型的对象,为此需要在基类中将析构函数定义成虚函数
cpp 复制代码
class A {
public:
	virtual ~A() = default;
};
  • 如果定义了析构函数,即使是合成的版本,编译器也不会为这个类合成移动操作。虚析构函数并不会使得类需要拷贝和赋值操作
  • 如果基类中的默认构造函数、拷贝构造函数、拷贝赋值运算符、析构函数是被删除或不可访问的,则派生类中对应的成员将是被删除的
  • 如果基类中有一个不可访问或删除掉的析构函数,则派生类中合成的默认和拷贝构造函数将是被删除的,因为编译器无法销毁派生类对象的基类部分
  • 当使用=default请求一个移动操作时,如果基类中的对应操作是删除的或不可访问的,,那么派生类中该函数将是被删除的,因为派生类对象的基类部分不可移动
  • 大多数基类都会定义一个虚析构函数,因此在默认情况下,基类通常不含有合成的移动操作,且在它的派生类中也没有合成的移动操作,当确实需要执行移动操作时应该首先在基类中定义
cpp 复制代码
class A {
public:
	A() = default;						// 对成员依次进行默认初始化
	A(const A&) = default;				// 对成员依次拷贝
	A(A&&) = default;					// 对成员依次拷贝
	A& operator=(const A&) = default;	// 拷贝赋值
	A& operator=(A&&) = default;		// 移动赋值
	virtual ~A() = default;
};
  • 派生类构造函数在其初始化阶段不但要初始化派生类自己的成员,还要初始化派生类对象的基类部分。因此,派生类的拷贝和移动构造函数在拷贝和移动自有成员的同时,也要拷贝和移动基类部分的成员,派生类赋值运算符也必须为其基类部分的成员赋值
  • 与构造函数和赋值运算符不同,析构函数只负责销毁派生类自己分配的资源,对象的成员是被隐式销毁的,派生类对象的基类部分也是自动销毁的
  • 在默认情况下,基类默认构造函数初始化派生类对象的基类部分,如果想拷贝或移动基类部分,则必须在派生类的构造函数初始值列表中显示地使用基类地拷贝或移动构造函数。同样,派生类地赋值运算符也必须显式地为其基类部分赋值
cpp 复制代码
class A {};
class B: public A {
public:
	B(const B& b): A(b) {}			// 拷贝基类成员
	B(B&& b): A(std::move(b)) {}	// 移动基类成员
};
  • C++11中,派生类能重用其直接基类定义的构造函数,这些构造函数并非以常规的方式继承而来,一个类只继承其直接基类的构造函数,类不能继承默认、拷贝、移动构造函数,如果派生类没有直接定义这些构造函数,编译器将会为派生类合成它们
  • 派生类继承基类构造函数的方式是提供一条注明了基类名的using声明语句,对于基类的每个构造函数,编译器都生成一个与之对应的派生类构造函数,如果派生类有自己的数据成员,则这些成员将被默认初始化。和普通成员的using声明不同,构造函数的using声明不会改变该构造函数的访问级别
cpp 复制代码
class B : A {
public:
	using A::A;
}

15.8 容器与继承

  • 当使用容器存放继承体系中的对象时,通常必须采用间接存储的方式,因为不允许在容器中保存不同类型的元素,所以不能把具有继承关系的多种类型对象直接存放在容器中。当派生类对象被赋值给基类对象时,其中的派生类部分将被切除,因此容器和存在继承关系的类型无法兼容
  • 当需要在容器中存放具有继承关系的对象时,实际上存放的通常是基类的指针,这些指针所指对象的动态类型可能是基类类型,也可能是派生类类型

16. 模板和泛型编程

16.1 定义模板

  • 面向对象编程(OOP)和泛型编程都能处理在编写程序时不知道类型的情况。不同之处在于,OOP能处理类型在程序运行前都未知的情况,而泛型编程在编译时就能获知类型了
  • 模板是C++泛型编程的基础,一个模板就是一个创建类或函数的公式
  • 函数模板 的定义:以关键字template开始,后跟一个模板参数列表 ,其中包括一个逗号分隔的一个或多个模板参数 列表,用<>包围,类型参数 前必须使用关键字classtypename非类型参数表示一个值,通过特定的类型名来指定
cpp 复制代码
template <typename T>
int compare(const T &v1, const T &v2) {
	if (v1 < v2) return -1;
	if (v2 < v1) return 1;
	return 0;
}
  • 函数模板可以声明为inlineconstexpr的,和普通函数一样,inlineconstexpr说明符放在模板参数列表后,返回类型之前
cpp 复制代码
template <typename T> inline T min(const T&, const T&);
  • 当编译器遇到一个模板定义时,并不生成代码,只有当实例化出模板的一个特定版本时才会生成代码,这一特性影响了如何组织代码以及错误何时被检测到。与非模板代码不同,模板的头文件通过既包含声明也包含定义
  • 与函数模板不同,编译器不能为类模板 推断模板参数类型,实例化模板类时必须显式在<>内提供实参列表。在使用类模板类型时也必须提供模板实参,除非在该类模板自己的作用域内
cpp 复制代码
template <typename T> class A {};
A<int>	a;
  • 一个类模板的每个实例都形成一个独立的类,类型A<int>与其他任何A类型都没有关联,也不会对其他A类型的成员有特殊访问权限
  • 与普通类相同,既可以在类模板内部,也可以在类模板外部定义成员函数,定义在类模板内的成员函数被隐式声明为内联函数,定义在类模板外部的成员函数必须以关键字template开始,后接类模板参数列表:template <typename T> int A<T>::get()。默认情况下,一个类模板的成员函数只有在程序用到它时才进行实例化
  • 当一个类包含一个友元声明时,类与友元各自是否是模板是无关的,如果一个类模板包含一个非模板友元,则友元被授权可以访问所有模板实例。如果友元自身是模板,类可以授权给所有友元模板实例,也可以只授权给特定实例
  • 类似于函数参数,一个模板参数的名字没有内在意义,可以使用任何名字,通常命名为T。一个模板参数名的可用范围是在其声明之后,到模板声明或定义结束之前。与其他名字一样,模板参数会隐藏外层作用域中声明的相同名字,但模板内不能重用模板参数名。与函数参数相同,声明中的模板参数名不必与定义中相同,一个特定文件所需要的所有模板声明通常一起放在文件开始位置
  • 与函数参数类似,模板参数也可以提供默认模板实参。函数模板的默认模板实参一般是可调用对象。如果类模板的所有模板参数都提供了默认实参,在使用这些默认实参时,必须在模板名之后跟一个尖括号对
cpp 复制代码
template <typename T, typename F = less<T>>
int compare(const T &v1, const T &v2, F f = F()){}

template <class T = int> class A{};
  • 一个类可以包含本身是模板的成员函数,这种成员被称为成员模板,成员模板不能是虚函数。如果为类模板定义成员模板,类和成员有各自的独立的模板参数,类模板的参数列表在前,后跟成员模板的参数列表
  • 模板被使用时才会被实例化这一特性意味着,相同的实例可能出现在多个对象文件中,当两个或多个独立编译的源文件使用了相同的模板,并提供了相同的模板参数时,每个文件中就都会有该模板的一个实例。为了防止实例化相同模板产生的额外消耗,使用extern template ...来进行模板声明,template ...进行模板定义。注意类模板的实例化定义会实例化该模板的所有成员

16.2 模板实参推断

  • 对于函数模板,编译器利用调用中的函数实参来确定其模板参数,顶层const无论是在形参还是实参中都会被忽略,在其他类型转换中,能在调用中应用于函数模板的包括两项:① const转换:可以将一个非const对象的引用或指针传递给一个const的引用或指针形参;② 数组或函数指针转换:如果函数形参不是引用类型,一个数组实参可以转换为一个指向其首元素的指针,一个函数实参可以转换为一个该函数类型的指针
  • 在某些情况下,编译器无法推断出模板实参的类型,或函数返回类型与参数列表中任何类型都不相同时,需要指定显式模板实参
cpp 复制代码
template <typename T1, typename T2, typename T3>
T1 sum(T2, T3);
auto a = sum<int>(b, c);	// 显式指定T1
  • 若函数返回类型在参数列表之前不确定,需要使用尾置返回类型:auto f(T beg, T end) -> decltype(*beg){return *beg;},所有的迭代器操作都不会生成元素,只能生成元素的引用,为了获得元素类型,可以使用类型转换模板,这些模板定义在头文件type_traits中,从而获得所需的类型:auto f(T beg, T end) -> typename remove_reference<decltype(*beg)>::type{return *beg;},其中type是一个类的成员,该类依赖于一个模板参数,必须在返回类型的声明中使用typename来告知编辑器它表示一个类型
  • 当使用一个函数模板初始化一个函数指针或为一个函数指针赋值时,编译器使用指针的类型来推断模板实参
  • 当一个函数参数是模板类型参数的普通左值引用时,只能传递给它一个左值:template <typename T> void f(T&);,如果实参是const的,则T将被推断为const类型。如果一个函数参数的类型是const T&,可以传递给它任何类型的实参:template <typename T> void f(const T&);,接收到const实参时也不会推断为const类型
  • 当一个函数参数是一个右值引用T&&时,可以传递给它一个右值,通常不能将一个右值绑定在一个左值上。若将一个左值传递给函数的右值引用参数,且右值引用指向模板类型参数时,编译器推断模板类型参数为实参的左值引用类型T&。通常情况下不能直接定义一个引用的引用,但是可以通过类型别名或模板类型参数间接定义。由于右值引用的模板参数涉及的类型既可能时普通类型,也可能是引用类型,编写代码变得异常困难,所以在使用右值引用的函数模板时一般会进行重载:
cpp 复制代码
template <typename T> void f(T&&);		// 绑定到非const右值
template <typename T> void f(const T&);	// 绑定到左值和const右值
  • 虽然不能直接将一个右值引用绑定到一个左值上,但可以用move获得要给绑定到左值上的右值引用,move是一个函数模板,可以接受任何类型的实参。虽然不能隐式地将一个左值转换为右值引用,但可以用static_cast显式地将一个左值转换为右值引用
  • 某些函数需要将其一个或多个实参连同类型不变地转发给其他函数,对于函数void g(F f, T1 t1, T2 t2),该函数在调用一个接受引用参数的函数void h(int v1, int &v2)时会出现问题,通过g调用h改变参数值时,h做出的改变不会影响实参。为了能传递一个引用,重写函数为void g(F f, T1 &&t1, T2 &&t2),该函数可以接受一个左值引用参数的函数,但不能用于接受右值引用参数的函数。为此,使用一个名为forward的标准库来传递参数,重写函数为:
cpp 复制代码
void g(F f, T1 &&t1, T2 &&t2){
	f(std::forward<T1>(t1), std::forward<T2>(t2))
}

16.3 重载与模板

  • 函数模板可以被另一个模板或普通函数重载,名字相同的函数必须具有不同数量或类型的参数。当有多个重载模板对一个调用提供同样好的匹配时,选择最特例化的版本。在定义任何函数之前,要声明所有重载的函数版本,这样编译器才不会实例化一个不需要的版本

16.4 可变参数模板

  • 一个可变参数模板就是一个接受可变数目参数的模板函数或模板类,可变数目的参数被称为参数包。存在两种参数包:模板参数包 :表示零个或多个模板参数;函数参数包 :表示零个或多个函数参数。用一个省略号来指出一个模板参数或函数参数表示一个包,在一个模板参数列表中,class...typename...指出接下来的参数表示零个或多个类型的列表template <typename T, typename... Args>,一个类型名后面跟一个省略号表示零个或多个给定类型的非类型参数列表void f(const T &t, const Args ... r);。在函数参数列表中,如果一个参数的类型是一个模板参数包,则此参数也是一个函数参数包。当需要知道包中有多少运算符时,可以使用sizeof...运算符,该运算符返回一个常量表达式,且不会对其实参求值sizeof...(Args)
  • 可变参数函数通常是递归的,先调用处理包中第一个实参,然后用剩余实参调用自身。当定义可变参数版本函数时,非可变参数版本的声明必须在作用域中,否则将会无限递归
cpp 复制代码
template<typename T>
ostream &print(ostream &os, const T &t){
	return os << t;
}

template <typename T, typename... Args>		// 扩展Args
ostream &print(ostream &os, const T &t, const Args&... rest){
	os << t << ", ";
	return print(os, rest...);		// 扩展rest
}
  • 对于一个参数包,除了获取其大小外,对它唯一事情就是扩展 ,扩展一个包就是将它分解为构成的元素,通过在模式右边放一个省略号...来触发扩展操作
  • 在新标准下,可以组合使用可变参数模板和forward机制来编写函数,实现将实参不变地传递给其他函数
cpp 复制代码
template<typename... Args>
void fun(Args&&... args){
	work(std::forward<Args>(args)...);
}

16.5 模板特例化

  • 当不希望使用模板版本时,可以定义类或函数模板的一个特例化版本。当特例化一个函数模板时,必须为原模板中的每个模板参数都提供实参,为了指出正在实例化一个模板,要使用关键字template后跟一个空尖括号对<>,特例化的本质是实例化一个模板,而非重载它,因此特例化不影响函数匹配
cpp 复制代码
template <typename T> int compare(const T&, const T&);	// 函数模板
template <> int compare(const char* const&p1, const char* const&p2);	//特例化版本
  • 模板及其特例化版本应该声明在同一个头文件中,所有同名模板的声明应该放在前面,然后是这些模板的特例化版本
  • 对类进行特例化时,可以在类内或类外定义特例化版本的成员,与函数模板不同,类模板的特例化不必为所有模板参数提供实参,可以只指定一部分而非所有模板参数,一个类模板的部分特例化本身是一个模板,使用它时用户还必须为那些在特例化版本中未指定的模板提供实参
相关推荐
Microsoft Word13 分钟前
c++基础语法
开发语言·c++·算法
一只小小汤圆38 分钟前
opencascade源码学习之BRepOffsetAPI包 -BRepOffsetAPI_DraftAngle
c++·学习·opencascade
legend_jz1 小时前
【Linux】线程控制
linux·服务器·开发语言·c++·笔记·学习·学习方法
嘿BRE1 小时前
【C++】几个基本容器的模拟实现(string,vector,list,stack,queue,priority_queue)
c++
ö Constancy2 小时前
c++ 笔记
开发语言·c++
fengbizhe2 小时前
笔试-笔记2
c++·笔记
徐霞客3202 小时前
Qt入门1——认识Qt的几个常用头文件和常用函数
开发语言·c++·笔记·qt
fpcc2 小时前
redis6.0之后的多线程版本的问题
c++·redis
螺旋天光极锐斩空闪壹式!3 小时前
自制游戏:监狱逃亡
c++·游戏
工业3D_大熊4 小时前
3D可视化引擎HOOPS Luminate场景图详解:形状的创建、销毁与管理
java·c++·3d·docker·c#·制造·数据可视化