C++ 面向对象编程
C++ 的类
this 指针
调用成员函数时,成员函数通过名为this的额外隐式参数访问调用它的那个对象。
在成员函数内部,任何对类成员的直接访问都被看作是对this的隐式使用
this指针总是指向调用成员函数的对象,因此,this指针是一个常量指针,它的const是顶层const,即它永远不会改变它指向的位置。
常量成员函数
即使this指针是隐式的,但初始化this指针也要遵循初始化规则,即不能用一个常量去初始化非底层const
的的this指针。
由于这条规则的存在,我们无法调用一个被声明成const的对象的普通成员函数,因为这个成员函数的this指针不是底层const的指针。
为了解决这个问题,我们可以将成员函数声明成const
例如,像下面这样,在参数列表后添加上const
1 | class A { |
此时,这个this指针,既是顶层const
(this指针所固有的属性),又是底层const
(我们声明的结果)
友元
P241, P250,P545
想让其他类或其他函数访问类的私有成员,需要在一条友元声明,友元声明必须出现在类的内部
由于友元声明并不是类的成员,因此他和访问说明符(public:, private:) 无关,可以出现在类的任意位置
可以将一个类声明成另一个类的友元
如果一个类是另一个类的友元,那么友元类的成员函数可以访问类的所有成员
可以将一个类的成员函数声明成另一个类的友元函数
友元声明是单向的,友元声明不具有传递性
关于友元的作用域:P252
友元关系是不能继承的
- 基类的友元在访问派生类时没有特殊性。
- 派生类的友元也不能访问基类的私有成员
类的类型成员
类的成员不只可以有变量和函数,还可以有类型
类内定义的类型也有访问权限的限制
用来定义类型的成员必须先定义再使用
原因:// TODO
类的内联函数
内联函数(inline):一些小的,简单的函数可以被定义为内联函数,以减少调用函数的开销
内联函数会在编译时,被编译器展开
例如
1
2
3 inline const string & shorterString(const string &s1, const string &s2) {
return s1.size() <= s2.size() ? s1 : s2;
}max(), min() 之类的函数特别适合被定义为内联函数
- 定义在类内部的成员函数,它们默认是内联函数
- 可以在类内声明时使用
inline
,同时在类外定义函数时也同时使用inline
,但最好是只在类外部定义函数时使用inline
声明。
可变数据成员
from effective c++
编译器执行的const检查时定义上的,不是逻辑上的,如果类内有些成员是可变的,但不影响它在逻辑上是const,我们可以把它声明为
mutable
如果在定义类的成员时,使用mutable
关键字,那么,即使这个对象被声明成const
,我们依然可以改变这个成员变量的值。
1 | class A { |
返回*this 左值
当成员函数是const 版本时,this指针具有底层的const, 因此它的返回的左值也是带底层const的左值
我们可以根据this指针是否具有底层const
来对函数进行重载
底层const的重载见:P206
P247
类的前向声明
可以使用类似这种形式声明类
1 | class A; |
这是一个不完整类型,我们可以使用这种类型的指针或者引用,但不能创建这种类型的引用。
前向声明可以放在类的头文件中,从而减少依赖。
类的名字查找
我们可以在成员函数的任意地方使用类的成员
原因在于编译器如何处理类:
- 编译所有的成员声明
- 编译函数体
如果编译器无法在类内找到一个符号,编译器会在类的外层作用域继续寻找该名字,但只会考虑该语句之前出现过的外层作用域里的符号。
在内层作用域中可以重定义名字,但外层作用域中定义的类型(使用
typedef
或使用using symbol = type
)不能再类中重定义。成员函数的名字查找规则
1
2
31. 在成员函数内查找名字(成员函数体定义的名字,形参的名字)
2. 在类作用域内查找名字
3. 在成员函数定义之前的作用域内继续查找如果想要在成员函数内使用一个类作用域的名字(该名字已经在成员函数的块作用域内被覆盖),可以使用作用域运算符。
类的隐式类型转换
P263 隐式的类类型转换 P514 重载类型转换运算符
如果类的某个构造函数只接受一个参数,它就是一个转换构造函数,定义了一个由其他类型到这个类的隐式类型转换
这种类型转换只允许一步类型转换
一直类的隐式类型转换:
使用关键字
explicit
该关键字只能在类内声明中使用,不能在类外重复
该关键字只能用于接受一个值的构造函数前面
类的静态成员
类的静态成员存在于任何对象之外,对象中不包括任何于静态成员有关的数据,相当于一个全局变量。合成的默认构造函数也不会初始化它,他被初始化为0
类的静态成员函数不与任何成员绑定,没有this指针,因此也不能是const的
由于静态成员和全局变量一样,都需要链接器来处理,因此,两者的性质十分相似
- 在
.h
文件的类的定义中,静态成员可以是不完整类型,因为它需要链接器处理 - 我们可以使用类的静态成员作为成员函数的默认实参
拷贝控制
类的初始化、对象拷贝、移动、销毁
由以下六个函数决定:
- 构造函数
- 拷贝构造函数
- 拷贝赋值运算符
- 移动构造函数
- 移动赋值运算符
- 析构函数
后五个称为类的拷贝控制操作
构造函数
C++ Primer P235、P257、P551、P689
- 构造函数没有返回值
- 构造函数可以重载
- 构造函数不能被声明称const,在构造const对象时,构造函数可以改变它,只有构造函数完成初始化之后,该对象才具有const的属性。
合成的默认构造函数
如果没有定义构造函数,编译器会为类定义默认的构造函数,规则如下
- 如果存在类内的初始值,用它来初始化成员
- 否则,默认初始化。(内置类型垃圾值,其他类调用默认构造函数)
如果出现以下情况,编译器无法为类构造合成的默认构造函数
类有定义一个构造函数
遇到这种情况,可以使用
类名() = defalut
来给予类一个默认的构造函数类存在一个成员,这个成员没有默认的构造函数
构造函数的初始化列表
1 | Sales_data(const std::string &s) : BookNo(s) {} |
当某个数据成员被构造函数的初始化列表忽略时,它将以合成默认构造函数相同的方式隐式初始化
(重要)当某个数据成员被构造函数的初始化列表忽略时,他将在执行构造函数函数体之前被默认初始化
由于有的类没有默认构造函数,因此我们必须使用列表初始化对其进行初始化。
1 | class A |
因为所有的类成员都会在构造函数体执行前进行初始化(列表初始化或默认初始化)因此使用列表初始化一定会比在构造函数中赋值更加高效。
(重要)列表初始化的初始化顺序
列表初始化的顺序与他们在类的定义中出现的顺序一样,而与列表初始化的列表顺序无关,如果初始化变量之间会相互依赖,一定要注意他们初始化的顺序!!
委托构造函数
1 | class Sales_data { |
委托构造函数首先执行列表初始化的被委托构造函数的列表初始化,在执行被委托构造函数的函数体,最后执行委托构造函数的函数体
默认构造函数被调用的时机
C++ Primer P262
默认初始化
- 在块作用域内不适用任何初始值定义一个非静态变量
- 一个类类型本身含有类成员,并且使用合成的默认构造函数时
- 没有在列表初始化中被显示初始化时
值初始化
- 数组初始化时,提供的值小于数组的大小
- 不适用初始值定义一个局部静态变量
- 使用
T()
这种表达式来显示请求值初始化
继承体系中的合成的默认构造函数
派生类的合成的默认构造函数,首先会调用其基类的构造函数,其基类的构造函数也会调用其基类的构造函数,直到继承链的顶端,接着继承链顶端的基类开始初始化其成员,再执行其构造函数体,接着向下,最后执行派生类的构造函数。
继承直接基类的构造函数,使用
using
语句 P557 一种语法糖,可以让编译器根据直接基类的构造函数为派生类生成构造函数
拷贝构造函数
定义:如果一个构造函数的第一个参数是自身类型的引用,且其他任何的额外参数都有默认值,则此构造函数是一个拷贝构造函数。
拷贝构造函数的形式通常是
ClassType(const ClassType &)
拷贝构造函数通常不应该是explicit的
合成的拷贝构造函数
如果我们没有定义类的拷贝构造函数,编译器会为我们合成一个默认的拷贝构造函数
对于内置类型,合成的拷贝构造函数会直接拷贝
对于类类型,合成的拷贝构造函数会调用它的拷贝构造函数
对于数组,合成的拷贝构造函数会逐个拷贝它的元素
一、默认初始化、值初始化、直接初始化和拷贝初始化 几个术语的含义及其区别:
几种初始化的区别:https://blog.csdn.net/qq_38231713/article/details/106291397
默认初始化:对象可能产生未定义的值,出现场景:
- 块作用域内不使用任何初始值定义一个非静态变量;
类通过默认构造函数来控制默认初始化过程,默认构造函数以如下规则初始化类的数据成员
- 如果存在类内初始值,用它来初始化成员
- 否则,默认初始化该成员
值初始化:对象的值是确定(预设)的,出现场景:
数组初始化时,初始值数量小于数组的长度。
不使用初始值定义一个静态变量(带有初始值0)
使用类似
classType()
形式表达式显示请求值初始化只提供vector可以容纳的元素数量,不提供初始值,库会自动进行值初始化
vector<int> vec(10);
值初始化为0直接初始化:使用
classType()
初始化对象拷贝初始化:使用
classType foo = classType()
初始化对象
- 不光在使用 = 赋值时会发生拷贝初始化,在以下情况时也会发生拷贝初始化
- 将参数作为实参传递给非引用类型的形参
- 从返回类型为非引用的函数返回一个对象
- 使用花括号列表初始化数组或聚合类
- 初始化标准库容器或者使用insert或push(顺带一提使用emplace会直接初始化,不会调用拷贝构造函数)
二、不同的初始化方式会调用那种构造函数
当定义类时,会有直接初始化和拷贝初始化两种区别
1
2
3
4
5 foo var1; // 直接初始化,使用默认构造函数
foo var2(1); // 直接初始化,使用一个参数的构造函数
foo var3 = 50; // 拷贝初始化,本来是50先使用构造函数构造临时对象,再使用拷贝构造函数初始化var3,但经过实际测试,编译优化了这一部分,直接使用构造函数初始化了var3
foo var4 = foo(50); // 和var3的情况完全相同,优化后也是只使用了一次接受一个对象的构造函数
foo var5 = var3; // 拷贝初始化,调用拷贝构造函数
继承体系中的拷贝构造函数
我们需要使用基类的拷贝构造函数显示的初始化基类的成员
1 | class base {/* ... */}; |
拷贝赋值运算符
可能会误以为拷贝初始化
classType obj = ori;
会使用拷贝赋值运算符,事实上,这只调用拷贝构造函数当我们定义了拷贝赋值运算符,我们就重载了它的赋值运算符
此外我们还可以定义两种赋值运算符
- 移动赋值运算符
- 其他类型到此类型的赋值运算符
但这个运算符是最常用的运算符
拷贝赋值运算符的形式通常是classType operator=(const classType &)
因为他是一个成员函数,因此它的左侧成员自动绑定
合成的拷贝赋值运算符
合成的拷贝赋值运算符会将右侧的每个非static成员赋值给左侧成员,对于数组类型,它会逐个拷贝
继承体系中的拷贝赋值运算符
派生类的拷贝赋值运算符要显式调用基类的拷贝赋值运算符,之后我们再为派生类的成员完成赋值操作
1 | classType & classType::operator=(const classType & rhs) |
析构函数
释放对象使用的资源,销毁对象的非static成员
析构函数的形式是~classType()
没有返回值,不接受参数
不能被重载,对于任意一个类有且只有一个析构函数
析构函数不能是删除的
析构函数首先执行析构函数体,接着按初始化顺序逆序销毁成员
继承体系中的析构函数
派生类的析构函数只需要处理它自己的成员,不需要在析构函数体里显示调用基类的析构函数。它们所占用的资源(除了申请的堆资源),都会隐式的销毁。
对象销毁的顺序:先销毁派生类的资源,再销毁基类的成员直到继承体系顶端。
移动构造函数
类的拷贝控制成员被默认定义为删除的
P450、P476、P553、P751
面向对象
类派生列表:首先是一个类名之后的冒号,接着是基类名,基类名之间以逗号隔开,每个基类名之前有访问说明符,
派生类列表的访问说明符的作用:控制派生类从基类继承的成员是否对派生类用户可见
动态绑定(运行时绑定):在运行时,根据传入的实参,动态选择函数版本,称为动态绑定
基类通过在成员函数前加上
virtual
关键字使得函数执行运行时动态绑定。任何构造函数之外的非静态函数都可以是虚函数
关键字
virtual
只能出现在类内声明语句中,而不能出现在定义语句中没有用
virtual
声明的函数,其解析发生在编译时,而非运行时访问控制:
如果类的成员能被派生类访问,但不能被其他类访问,它应该被定义为
protected
派生类的构造函数
派生类必须使用基类的构造函数初始化其基类的部分
防止继承 定义类的时候使用
final
1
class NoDerived final {/* ... */};
纯虚函数和抽象基类
纯虚函数
通过在函数声明的分号前添加 = 0 即可声明这个函数为纯虚函数
纯虚函数只是提供了一个接口,无需定义纯虚函数
含有纯虚函数的类是抽象基类, 不能直接创造一个抽象基类对象
Protected
- 派生类的成员和友元只能访问派生类中的基类部分的受保护成员,而不能通过派生类访问基类的受保护成员
- 基类成员对于它的派生类 的用户来说,访问权限主要受两部分影响
- 若基类的访问说明符
- 派生类派生列表的访问说明符(对于派生类用户来说,此条的优先级更高)
- 对于派生类成员来说,它可以访问基类的
public
和private
部分
改变个别成员的可访问性
可以在派生类中使用using
声明改变它继承自基类的成员的可访问性
但using
声明只能改变它能访问的成员的可访问性,即它不能改变基类的私有成员的在派生类中的可访问性。
struct 和 class的区别
- struct是C语言的关键字,它定义结构体,而C语言没有构造函数和拷贝控制函数
- 在C++中struct和class除了他们的可访问性以外,没有任何不同,c++的编译器都会为他们生成构造析构函数和拷贝控制函数
- 他们的可访问性:
- struct的默认成员都是public,class的默认成员都是private
- struct定义的类,它的继承默认是公有继承,class定义的类,默认继承方式是私有继承
类的作用域
每个类定义自己的作用域,在这个作用域内我们定义自己的成员函数。
当存在派生关系时,派生类的作用域嵌套在基类的作用域内:当我们遇到一个派生类中的名字,在派生类作用域中找不到这个名字,就会在基类的作用域内继续搜索这个名字。
一个对象、引用 或 指针的静态类型,决定了该对象究竟有那些对象是可见的。
如果我们用基类指针绑定了派生类的成员,我们不能用这个指针去访问派生了特有的成员,因为对于这个成员名字的搜索将从基类的作用域开始,这决定了我们永远不会搜索派生类的作用域。
与往常一样,内层作用域会隐藏与存在于它内部的名字重名的外层作用域名字。
编译器解析类的函数调用的过程:
例如
obj.func()
确定obj的静态类型,去对应的类的作用域内查找该名称
如果在类的作用域内找不到该名称,则继续查找它的基类的作用域,如果一直到派生链的顶端也找不到该名子,编译器报错。
如果找到了该名子,编译器进行类型检查,看这次调用是否合法。
如果调用合法,看这个函数是不是虚函数,调用对象是不是指针或引用,如果是,编译器执行动态绑定,生成运行时决定调用那个函数的代码。
否则直接进行函数调用。
- 声明在内层作用域中的函数不会重载声明在外层作用域中的函数,即使派生类中的函数的形参列表与基类中的形参列表不一样,也不会重载。
因此虚函数与其在派生类中的覆盖函数,它们的形参列表必须相同,否则就不会导致虚函数机制,而是直接用内层作用域中的函数覆盖外层作用域中的函数。
成员函数无论是否是虚函数,都可以重载,因此对于基类的重载函数,派生类如果想要看到基类的全部重载的某个成员函数,它必须全部覆盖基类的重载虚函数 或者 一个重载虚函数也不覆盖。
如果想要让覆盖重载虚函数的一些函数,但其他函数也不会因为作用域的原因而不可见,可以使用
using
声明1
2
3
4
5
6
7
8
9
10
11
12
13
14
15class base
{
public:
virtual func(int);
virtual func(int, int);
virtual func(char, int);
};
class derived : public base
{
public:
using base::func;
// 使用using 我们可以只覆盖重载虚函数的一个实例而让其他的虚函数也可见
func(int) override;
}