类
类的声明只是说明了如何创建一个对象,并没有实际分配内存,只有当有对象被创建的时候才会分配内存。
类内定义的成员函数
在类的定义声明并实现的成员函数默认为inline函数,不论前面是否加了inline关键字类的构造函数和析构函数
构造函数和析构函数都没有返回值
默认构造函数
当程序创建未被显示初始化的类对象时,总是调用默认构造函数如果没有提供任何构造函数,则编译器将自动提供默认构造函数,这个构造函数不会做任何事。它是默认构造函数的隐式版本。
当且仅当没有定义任何构造函数时,编译器才会提供默认构造函数显示定义默认构造函数的方式:
1 | //方法1:给构造函数所有参数提供默认值 |
隐式地调用默认构造函数的时候,不要使用圆括号
1 | MyClass my_class;//隐式调用默认构造函数 |
nullptr 构造函数
nullptr构造函数用于解决存在多个(如果只有一个,传入空指针也不会产生歧义)接受指针的构造函数时,如果传入空指针时函数不明确的问题,例如:
1 | class A { |
构造函数的实际运行
1 | MyClass my_class = MyClass(); |
对于上述代码,编译器有两种实现方式。第一种:不会创建临时对象,直接将对象赋给my_class;第二种:调用构造函数来创建一个临时对象,然后将该临时对象拷贝到my_class中,并丢弃该临时对象,则这样会为临时对象调用析构函数。
1 | MyClass my_class = MyClass(); |
对于上述代码的第二个赋值语句,在这样的赋值语句中使用构造函数总会
导致在赋值前创建一个临时对象。
如果既可以通过初始化,也可以通过赋值来设置对象的值,应采用初始化方式,通常这种方式的效率更高,即可以避免创建临时对象。
析构函数
每个类都只能有一个析构函数
类成员的构造和析构顺序
构造时
如果某个类具有父类,先执行父类的构造函数
类的非静态数据成员,按照声明的顺序创建
执行构造函数体内部的代码
析构时
调用类的析构函数
销毁数据成员,与创建的顺序相反
如果有父类,调用父类的析构函数
带const的类成员函数
1 | const MyClass my_class; |
但是如果func()的类方法声明为const类方法就可以:
1 | void func() const;//承诺不修改调用对象 |
对象数组
1 | MyClass my_classes[4]; |
上述声明要求,这个类要么显式的定义了默认构造函数,要么没有显式地定义任何构造函数(即编译器提供了默认构造函数)
可以使用构造函数来初始化数组元素
1 | MyClass my_classes[4] = { |
上述代码初始化了对象数组的部分元素,剩余的2个将会使用默认构造函数进行初始化。
初始化对象数组的方案是,首先使用默认构造函数创建数组元素,然后花括号中的构造函数将创建临时对象,然后将临时对象的内容复制到相应的元素中。因此,要创建对象数组并且有部分数组元素未使用显式构造函数创建时,则这个类必须要有默认构造函数。
作用域内枚举
1 | enum egg {Small, Medium, Large, Jumbo}; |
上述代码在两个枚举中定义了同一个枚举变量,会发生冲突,编译器会报错。
为了避免这种冲突,C++11提供了一种新枚举,其枚举变量的作用域为类。
1 | enum class egg {Small, Medium, Large, Jumbo}; |
可以使用关键字struc代替关键字class,创建作用域内枚举时都需要使用枚举名来限定枚举量
1 | egg choice = egg::Large; |
1 | int a = egg::Small;//错误 |
作用域内枚举变量可以设置底层数据类型,但是必须为整型数据:
1 | enum class : short pizza {Small, Medium, Large, XLarge};//指定底层类型为短整型 |
运算符重载
要重载运算符,需使用被称为运算符函数的特殊形式。运算符函数的格式如下:
1 | return-type operator op(argument-list); |
当编译器发现如下代码:
1 | MyClass my_class = my_class_1 + my_class_2; |
编译器会使相印的运算符函数替换上述运算符:
1 | MyClass my_class = my_class_1.operator+(my_class_2); |
运算符重载限制:
重载后的运算符必须至少有一个操作数是用户定义的类型,这将防止用户为标准类型重载运算符
使用运算符时不能违反运算符原来的句法规则
为什么要使用友元?
对于如下代码:
1 | //假设类A实现了A operator +(int value); |
使用友元函数可以解决这个问题
创建友元函数
创建友元函数的第一步是将其原型放在类声明中
,并在原型声明前加上关键字friend
第二步编写函数定义,因为友元函数不是成员函数,所以不要使用"::"限定符,且在定义中不要使用关键字friend
所以对于上面的这个问题,只需要实现
1 | friend A operator+(int value, const A &a) |
重载<<
运算符
直接上代码:
1 | //第一种重载方式 |
对于上述代码1,你觉得可以这样使用吗?
1 | A a; |
答案是不可以
对于上述代码2,调用的格式因该是这样的:
1 | A a; |
很别扭是吧
第三种方式可以实现
1 | cout << a.data |
但是第三种方式有种缺憾,就是不可以这样调用:
1 | cout << "a.data is" << a.data << "and b.data is" << b.data; |
解决方法——让第三种方式返回ostream对象的引用即可:
1 | friend ostream & operator<<(osteam &os, const A &a) { |
重载单操作数运算符
对于某些运算符既可以作单数运算符,又可以作为双操作数运算符。例如“-”既可以作为自反运算符,又可以作为减号操作符。
那么重载这种运算符的时候,作为单操作数运算符是不同于双操作运算符的。
1 | //作为成员函数的情况 |
有了上述成员函数的情况,作为非成员函数的情况就可以类推出来了
重载++和—运算符
++运算符和—运算符既可以做前缀可以做后缀,重载前缀++(—)和后缀++(—)的情况是不同的。
1 | void operator++();//前缀++ |
C++规定后缀形式有一个int类型的参数,但是这个参数永远不会用到,所以不必写参数名,也不要写这个参数名。
运算符重载:作为成员函数还是非成员函数?
前面说过运算符的重载可以通过成员函数或非成员函数进行重载,但是不能同时声明这两种格式,否则将造成二义性错误,导致编译错误。
对于某些运算符来说,成员函数是唯一合法的选择,例如前面说的”=”(赋值运算符)。有时,根据类设计,使用非成员函数版本可能更好(尤其是为类定义类型转换时)。其他情况下,这两种格式没有太大区别。
可以对运算符重载再进行重载
例如对于”-“运算符,它既可以表示减法,也可以表示自反,那么就可以实现两种:
1 | //以向量为例 |
类的自动转换和强制类型转换
在c++中,如果一个类有接受一个参数的构造函数,则c++支持将与该参数相同类型的值转换为类。例如:
1 | class A { |
程序将使用构造函数A(10)来创建一个临时对象,并将19.6作为初始化值,然后将该临时对象复制到a中。这一过程称为隐式转换。
c++中新增了关键字explicit用于关闭这种自动特性
1 | class A { |
explicit关键字只有用来修饰单参数的构造函数才有意义
建议不要使用隐式转换,最好将单参数的构造函数都加上explicit关键字,使用强制类型转换
转换函数
上面的例子是将整型转换成A类型,同时转换函数可以让A类型转换成整型。
1 | class A { |
声明转换函数注意以下几点
:
- 转换函数必须是类方法
- 转换函数不能指定返回类型
- 转换函数不能有参数
同时c++11及之后可以将explicit关键字用于转换函数,这样就只能进行强制类型转换而不能进行隐式类型转换
使用转换函数的原则
:
谨慎使用转换函数,最好使用功能相同的非转换函数,例如上例中完全可以实现一个int to_int();的成员方法来实现相同的功能。
类和动态内存分配
static成员变量的声明及初始化
1 | class MyClass { |
一般不能在类声明中初始化静态成员变量,这是因为类声明只描述了如何分配内存,但不分配内存。对于静态类成员,可以在类声明之外使用单独的语句来进行初始化,这是因为静态类成员是单独存储的,而不是对象的组成部分。初始化语句指出了类型,并使用了作用域运算符,但没有使用关键字static
。
但是如果静态成员是const整型或const枚举型,则可以在类声明中初始化。
static成员函数
- 不能通过对象调用static成员函数,对于声明在共有部分的static成员函数,可以使用类名和作用域解析运算符来调用它。
static成员函数中不能使用this指针
- static成员函数只能访问static成员变量
特殊成员函数
c++自动提供了下面这些成员函数:
- 默认构造函数,如果没有定义构造函数
- 默认析构函数,如果没有定义
- 复制(拷贝)构造函数,如果没有定义
- 赋值运算符(“=”),如果没有定义
- 地址运算符(“&”),如果没有定义
复制(拷贝)构造函数
复制构造函数用于将一个对象复制到新创建的对象中,也就是说,它用于初始化过程中。复制构造函数的原型通常如下:
1 | MyClass(const MyClass &); |
调用复制构造函数的时机:
新建一个对象并
将其初始化为同类对象时,复制(拷贝)构造函数都将被调用。- 当函数按值传递对象或函数返回对象时(
返回引用则不会调用
),都将使用复制(拷贝)构造函数
默认的复制(拷贝)构造函数逐个复制非静态成员的值(即浅拷贝
)
赋值运算符
将已有对象赋值给另一个对象时,将使用重载的赋值运算符。
1 | MyClass my_class_1; |
同默认的复制(拷贝)构造函数一样,默认的赋值运算函数也对成员进行逐个复制(浅拷贝)
编写赋值运算符函数的规范:
由于目标对象可能引用了以前分配的数据,所以函数应使用delete或delete[]来释放这些数据
函数应当避免将对象赋给自身,首先这样做没有太大意义,并且,给对象重新赋值前,释放内存操作可能删除对象的内容(详见示例代码)
函数最后返回一个指向调用对象的引用,这是为了能够连续赋值
1
2
3
4
5
6
7
8
9
10
11MyClass & operator=(const MyClass & my_class) {
if (this == &my_class) {
return *this;
}
//释放动态内存,如果有的话
//如果没有上面的判断且this == &my_class的话,那么 delete p也会将my_class中的p delete掉,那么下面的内存拷贝就会出错
delete p;
data = my_class.data;
p = new ...;//重新分配动态内存
memcpy(...);
}
调用拷贝构造函数还是赋值运算符函数?
1 | MyClass my1(my2);//只调用拷贝构造函数 |
一般有新对象被创建时就会调用一个构造函数,可能就是拷贝构造函数
重载[]运算符的一个技巧
重载运算符最好能够返回引用,因为这样不仅能够使用”[index]”获取值,而且可以方便的为”[index]”处对应的值进行赋值。例如:
1 | char & operator[](unsigned i); |
使用const版本的operator[]实现非const版本的operator[]
1 | const char & operator[](unsigned i) const { |
第一次cast将this转换成const MyClass&类型,即为this添加const,第二次则从const operator[]的返回值中移除const。只能用const版本来实现非const版本,注意不要用非const版本来实现const版本。
是否需要const版本来实现非const版本取决于你自己。
包含类成员的类的逐成员复制
1 | class MyBigClass { |
对于MyBigClass而言,默认的逐成员复制和赋值行为有一定的智能,逐成员复制或赋值将使用成员类型定义的复制(拷贝)构造函数和赋值运算符
。然而,如果MyBigClass有需要定义复制(拷贝)构造函数和赋值运算符函数,则最好重新为MyBigClass编写复制(拷贝)构造函数和赋值运算符函数。
需要重新编写复制(拷贝)构造函数和赋值运算符函数的一种情况
如果一个类的成员是需要动态内存分配的,那么这个类一般是在构造函数中动态申请内存,而在析构函数中一般会释放该动态内存。
因为默认复制(拷贝)构造函数和赋值运算符函数是浅拷贝,所以浅拷贝复制的只是指向动态内存的指针的值,当其中一个对象被释放掉了,其析构函数释放掉动态内存,那个另外一个对象也将不再拥有该动态分配的内存。
这时就应该为该类重新编写复制(拷贝)构造函数和赋值运算符函数,让其进行深拷贝,即重新申请内存,而不是简单的指针赋值。
对于使用定位new运算符分配的动态对象
1 | char *buffer = new char[100]; |
由于对于定位new运算符不能使用delete,所以,对使用定义new运算符创建的对象,要显式的调用函数的析构函数
1 | p1->~MyClass(); |
嵌套结构和类
在类声明的结构、类或枚举被称为是嵌套在类中的,其作用域为整个类。这种声明不会创建数据对象,而只是指定了可以在类中使用的类型。如果声明是类的私有部分进行的,则只能在这个类使用被声明的类型,如果声明是在共有部分进行的,则可以从类的外部通过作用域解析运算符使用被声明的类。
成员初始化列表的语法
1 | MyClass(int a, double b) : mem1(a), mem2(b), mem3(a * b + 1) { |
- 成员初始化列表格式只能用于构造函数
必须使用这个格式来初始化非静态const数据成员,即单const修饰的成员
必须用这种格式来初始化引用数据成员
(一般很少有引用数据成员,因为这种设计很不好)当初始化列表包含多个项目时,这些项目被初始化的顺序为它们被声明的顺序,而不是它们在初始化列表中的顺序
。如果代码使用一个成员的值作为另一个成员的初始化表达式的一部分时,初始化顺序就非常重要了。
成员初始化列表会覆盖类内初始化的成员的初始值。
1 | class MyClass { |
子类的初始化
1 | class BaseClass { |
派生类的构造函数必须使用相邻基类
的构造函数,且只能使用成员初始化列表的方式
,创建派生类对象时,程序首先创建基类对象。如果不显示的调用基类的构造函数,程序将使用默认的基类构造函数(如果有的话,否则将会报错)
派生类对象过期时,程序将先调用派生类的析构函数,然后再调用基类析构函数
在派生类成员函数中调用父类的方法
1 | class BaseClass { |
在派生类成员函数中调用父类定义的方法是使用作用域解析运算符来调用的。
多态
派生类和基类之间的特殊关系
基类指针可以在不进行显式类型转换的情况下指向派生类对象;基类引用可以在不进行显式类型转换的条件下引用派生类对象,也可以将派生对象赋给基类对象(向上强制转换)。但是不可以将基类对象赋给派生类对象和派生类引用,不可以把基类对象地址赋给派生类对象指针(向下强制转换)
虚函数(virtual function)与多态实现
如果没有使用关键字virtual,程序将根据引用类型或指针类型选择方法;如果使用virtual,程序将根据引用或指针指向的具体对象的类型来选择方法
不使用virtual:
1 | class BaseClass { |
使用virtual关键字:
1 | class BaseClass { |
经常在基类中将派生类会重新定义的方法声明为虚方法,方法在基类中被声明为虚方法后,它在所有派生类中
将自动成为
虚方法。但是最好在派生类声明中使用virtual来指出哪些函数是虚函数,这可以增加程序的可读性
virtual关键字只用于类声明的方法原型中,而不用于方法实现中
总结实现多态的方法
公有继承
,因为只有公有继承是才允许将基类指针或基类引用指向派生类- 成员函数要用virtual修饰
- 使用对象的引用或指针
虚析构函数
1 | class BaseClass { |
对于上述代码,如果析构函数不是虚函数,当delete p2时,将只会调用基类的虚构函数。如果是虚析构函数,则当delete p2时,将会调用SubClass的析构函数。
所以,一般将类的析构函数定义为虚函数
静态联编和动态联编
将源代码中的函数调用解释为执行特定的函数代码块被称为函数名联编(binding)。在编译过程中进行联编被称为静态联编(static binding),又称为早期联编(early binding)。在程序运行时选择正确的函数代码块,被称为动态联编(dynamic binding),又称为晚期联编(late dinding)
为什么有两种类型的联编?:
由于动态联编需要采用一些方法来追踪基类指针或引用指向的对象类型,这增加了额外的开销,所以静态联编的效率比动态联编的效率高,因此静态联编也被设置为c++的默认选择
。
虚成员函数与动态联编
编译器会对非虚方法使用静态联编,对虚方法使用动态联编。但是由于动态联编需要额外开销,所以对不需要重新定义的成员函数,不要将这些函数设置为虚函数
,这样有两种好处:首先效率更高;其次,指出不要重新定义该函数
。仅将那些需要被重新定义的方法声明为虚的
虚函数的工作原理
编译器处理虚函数的方法是:给每个对象添加一个隐藏成员,隐藏成员中保存了一个指向函数地址数组的指针,这个指针一般会成为对象的第一个数据成员。这个数组被称为虚函数表
。虚函数表中存储了为类对象进行声明的虚函数的地址。无论类中包含的虚函数还是1个还是10个,都只需要在对象中添加一个地址成员,只是表的大小不同而已。使用虚函数表也就是为了不增大类所占的内存,并加快函数查找速度
对于上图,基类对象包含一个指针,该指针指向基类中所有虚函数的地址表。派生类对象将包含一个指向独立地址
的指针。如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地址;如果派生类没有重新定义虚函数,虚函数表将保存函数原始版本的地址
。如果派生类定义了新的虚函数,则该函数的地址也将被添加到虚函数表中。
如果使用类声明中定义的第一个虚函数,则程序将使用数组 中的第一个函数地址,并执行具有该地址的函数。如果使用类声明中的第三个虚函数,程序将使用地址为数组中第三个元素的函数。
虚表指针初始化的时机
序表指针的初始化在进入类构造函数之前,
一个例子:
1 | class Base { |
最后打印的结构为“Base”,因为在进入s的构造函数之前,会先进行Base的构造,而在进入Base的构造函数之前,虚表指针被初始化为指向Base的虚函数表,所以这是执行虚函数调用的是父类的虚函数。当父类被构造完,进入自己的构造函数之前,虚表指针再被初始化为指向自己的虚函数表
关于虚函数的注意事项
构造函数不能是虚函数
,派生类不继承基类的构造函数,所以将类构造函数声明为虚的没什么意义。析构函数最好设计成虚函数
,除非类不用做基类。也就是说即使基类不需要显示析构函数提供服务,也不应该依赖于默认析构函数,而应该提供析构函数,友元不能是虚函数
,因为友元不是类成员,而只有成员才能是虚函数。
在派生类中重新定义方法将隐藏方法
1 | class Dwelling { |
如果不在继承类中重新定义showperks方法,则继承类中可以使用基类所有的showperks方法。但是重新定义将showperks()定义为一个不接受任何参数的函数。重新定义不会生成函数的两个重载版本
,而是隐藏了接收一个int参数的基类版本。总之,重新定义继承的方法并不是重载
。
这引出了两条经验准则
:
第一,如果重新定义继承的方法,应确保与原来的原型完全相同,但如果返回类型是基类应用或指针,则可以修改为指向派生类的引用或指针。这种特性被称为返回类型协变。
1 | class Dwelling { |
注意这种例外只适用于返回值,而不适用于参数
第二,如果基类声明被重载了,则应在派生类中重新定义所有的基类版本,或者使用继承方法函数
1 | class Dwelling { |
如果派生类中只定义一个版本,则另外两个版本将被隐藏,派生对象将无法使用它们。如果不需要需要修改,则新定义可只调用基类版本
:
1 | void Hovel::showperks() const { |
当typedef出现在类定义的私有部分
则只有在类中使用,在类外和子类中不能使用
私有继承
使用私有继承,基类的公有成员和保护成员都将成为派生类的私有成员,基类的私有成员子类无法访问。使用私有继承时,只能在派生类中访问基类的非私有成员及成员函数。
使用私有继承,不支持隐式向上转换(隐式向上转换:无需进行显示类型转换,就可以将基类指针或引用指向派生类对象)
保护继承
基类的公有成员和保护成员都将成为派生类的保护成员
使用using重新定义访问权限
使用保护继承或私有继承时, 基类的公有成员将成为保护成员或私有成员。假设要让基类的方法在派生类外面可用,方法之一是定义一个使用基类方法的派生类方法。
1 | class BaseClass { |
另一种方法是使用一个using声明,来指出派生类可以使用特定的基类成员,即使采用的是私有派生
1 | class SubClass : private BaseClass { |
注意using声明只使用成员名——没有圆括号、函数特征标和返回类型。且using声明只适用于继承,而不适用于包含