笔者有幸得到一次和行业前辈交流的机会,前辈也分享了他对于职业规划、人生发展的理解和感悟,笔者也获益颇丰。另外由于笔者主要方向是C/C++,刚入门不久,前辈也是针对能力提升方面推荐了很多课程和书籍,其中就有这本 《高质量程序设计指南C/C++》作者林锐,第三版。
笔者先大致浏览了一遍该书,发现有很多平时开发或学习中没有注意到的小细节,因此新开一帖,作为自用的学习笔记。本系列由于是读书笔记,因此主要会记录平时没有留意的细节问题,并针对这些问题会提出一些额外问题和分析,如底层实现和延伸思考。
自己也是刚入门不久,可能会有些错误,欢迎大家一起学习,不吝赐教,有任何问题可以评论私信。
前面几篇:
林锐《高质量程序设计指南C/C++》笔记01
林锐《高质量程序设计指南C/C++》笔记02
主要记录了一些变量常量、数据结构、编译器实现的底层原理等。
本篇将开始记录第十二章到十四章,涉及到C++面向对象的程序设计,包括类和对象的三大特性等等。
文章目录
第十二章
一、虚继承
1、作用及使用
虚继承是解决C++中多重继承中的菱形继承问题的一种方式。
什么是菱形继承:
菱形继承是指一个派生类同时继承自两个基类,而这两个基类又共同继承自一个公共基类,这样会导致派生类中有两份公共基类的成员,造成数据冗余和访问二义性。
在继承方式前面加上 virtual关键字就是虚继承:
// 公共基类A
class A{
public:
int m_a;
};
// 直接基类B
class B: virtual public A{ //虚继承
public:
int m_b;
};
// 直接基类C
class C: virtual public A{ //虚继承
public:
int m_c;
};
// 派生类D
class D: public B, public C{ // 这里不用虚继承
public:
void seta(int a){ m_a = a; } //正确,不用虚继承这里会编译出错
void setb(int b){ m_b = b; } //正确
void setc(int c){ m_c = c; } //正确
void setd(int d){ m_d = d; } //正确
private:
int m_d;
};
int main(){
D d;
return 0;
}
注意,上面虚继承的代码中,是在直接基类B
和C
的继承关系中加上了关键字virtual
,而在派生类D
的继承关系中没有关键字virtual
。
如果不采用虚继承,则类D
中会有两个A
的拷贝,分别是B::A
和C::A
,这样会导致D
的对象占用的空间增加,而且如果访问D
的对象的m_a
属性,会出现二义性,不知道是访问B::A::m_a
还是C::A::m_a
。
如果不采用虚继承,可以通过指明要访问的数据来自哪个直接基类的方式来访问,如访问D
中的m_a
:
void seta(int a){ B::m_a = a; }
// 或
void seta(int a){ C::m_a = a; }
2、虚继承实现原理
虚继承一般通过虚基类指针vbptr
和虚基类表vbtable
实现。
每个虚继承的子类都有一个虚基类指针(占用一个指针的存储空间,4字节)和虚基类表(不占用类对象的存储空间),其中存放了虚基类的偏移量。通过这种方式,可以通过派生类的地址找到虚基类的地址,从而访问虚基类的成员。
(需要强调的是,虚基类依旧会在子类里面存在拷贝,只是仅仅最多存在一份而已,并不是不在子类里面了);
当虚继承的子类被当做父类继承时,虚基类指针也会被继承。
因此,对于上述代码的虚继承可以分析如下:
- 类
B
和类C
中都有一个虚基类指针vbptr
,指向各自的虚基类表vbtable
,该表中记录了虚基类A
相对于B
和C
的偏移量。类D
中也有两个虚基类指针,分别属于B
和C
,指向各自的虚基类表。 - 这两个表中都记录了虚基类
A
相对于类D
的偏移量,即B::A
和C::A
相对于d
的偏移量,这两个偏移量的值是相同的,都是0,因为B::A
就是d
的起始地址(这种内存布局是一种编译器的优化策略)。 - 当访问
d.m_a
时,编译器会根据d
的地址,找到d
中包含的类B的部分。加上B
的虚基类指针vbptr
的值,得到B
的虚基类表vbtable
的地址,再从该表中读取虚基类A
相对于D
的偏移量,加上d
的地址,得到虚基类A
的实例的地址,再从该实例中访问m_a
的值。这样就实现了虚继承的功能。
3、问题1:
对于派生类
D
,为什么不加上virtual
关键字写成class D : virtual public B, virtual public C
呢?
派生类 D
不需要使用虚继承,因为它已经从B
和 C
类中继承了虚基类 A
的唯一实例。如果 D
类也使用虚继承,那么它的子类(如果有的话)才会受到影响,而不是 D
类本身。
“虚继承的目的是让某个类做出声明,承诺愿意共享它的基类,而不是强制它的子类也要共享。”
必须在虚派生的真实需求出现前就已经完成虚派生的操作。在上面代码中,当定义 D
类时才出现了对虚派生的需求,但是如果 B
类和 C
类不是从 A
类虚派生得到的,那么 D
类还是会保留 A
类的两份成员。
4、注意:
虚继承和虚函数是完全无关的两个概念。他们的区别与联系如下:
- 二者有相似之处,都利用了虚指针(均占用类的存储空间)和虚表(均不占用类的存储空间)。
- 虚基类依旧存在继承类中,只占用存储空间;虚函数不占用存储空间。
- 虚基类表存储的是虚基类相对直接继承类的偏移;而虚函数表存储的是虚函数地址。
参考:
C++中虚继承的作用及底层实现原理
C++虚继承和虚基类详解
C++——来讲讲虚函数、虚继承、多态和虚函数表
C++之虚函数与虚继承详解
二、抽象基类
关于什么是抽象基类这里不做赘述,可以参考:
虚函数(Virtual Function)和抽象函数(Abstract Function)
C++的抽象基类
这里解释一下书中出现的一句话:
由于抽象基类不能实例化,并且实现类被完全隐藏,所以必须以其他的途径使用户能够获得实现类的对象,比如提供入口函数来动态创建实现类的对象。入口函数可以是全局函数,但最好是静态成员函数。
在笔者的理解中,对于抽象类的使用,就是通过继承了抽象类的实现类,new一个对象,然后通过这个对象调用抽象类中的一些函数。但实际工程中实现类可能有很多种,而且用户不一定知道它们的名字和特点。所以,我们需要提供一种方法,让用户可以根据自己的需求,动态地选择和创建合适的实现类的对象。比如:
// 抽象基类 Shape
class Shape {
public:
// 纯虚函数,计算面积
virtual double area() = 0;
// 静态成员函数,创建实现类对象
static Shape* createShape(int type, double a, double b) {
if (type == 1) // 圆形
return new Circle(a); // a 为半径
else if (type == 2) // 椭圆
return new Ellipse(a, b); // a, b 为长短轴
else
return nullptr; // 无效类型
}
};
// 实现类 Circle
class Circle : public Shape {
private:
double radius; // 半径
public:
Circle(double r) : radius(r) {}
virtual double area() {
return 3.14 * radius * radius;
}
};
// 实现类 Ellipse
class Ellipse : public Shape {
private:
double a, b; // 长短轴
public:
Ellipse(double x, double y) : a(x), b(y) {}
virtual double area() {
return 3.14 * a * b;
}
};
在上述代码中,通过在抽象类Shape
中提供一个静态成员函数createShape(int type, double a, double b)
,用户就可以传入相应的参数type
,比如1或2,来获取相应的实现类对象。
// 创建一个半径为 5 的圆形对象
Shape* s1 = Shape::createShape(1, 5, 0);
// 创建一个长轴为 3,短轴为 2 的椭圆对象
Shape* s2 = Shape::createShape(2, 3, 2);
// 计算它们的面积
cout << "s1's area is " << s1->area() << endl; // 输出 78.5
cout << "s2's area is " << s2->area() << endl; // 输出 18.84
这种统一的获取方式要比单独调用构造函数会方便很多。
三、C++对象模型
- 非静态数据成员被放在每一个对象体内作为对象专有的数据成员。
- 静态数据成员被提取出来放在程序的静态数据区内为该类所有对象共享,因此仅存在一份。
- 静态和非静态成员函数最终都被提取出来放在程序的代码段中并为该类的所有对象共享,因此每一个成员函数也只存在一份代码实体。
- 类内嵌套定义的各种类型(typedef、 class、struct、union、enum等)与放在类外面定义的类型除了作用域不同外没有本质区别。
因此,构成对象本身的只有数据,任何成员函数都不隶属于任何一个对象,非静态成员函数与对象的关系就是绑定,绑定的中介就是 this 指针。
测试代码可以参考:sizeof(),strlen(),length(),size()区别中的 sizeof(对象)。
第十三章
一、拷贝构造
关于拷贝构造的自赋值检查,以及为什么检查自赋值的时候,是if (this != &other)
而不是if (*this != other)
,可以参考这一篇:一、c++11智能指针详解之shared_ptr代码实现:基本功能
第十四章
一、函数重载
函数重载指的是,C++允许在同一作用域中声明几个类似的同名函数,这些同名函数的形参列表(参数个数,类型,顺序)必须不同,常用来处理实现功能类似数据类型不同的问题。
这里补充几句书中原文:
并不是两个函数的名字相同就能构成重载。全局函数和类的成员函数同名不算重载,因为他们的作用域不同。
例如下面这个例子,有一个全局函数Print()
,还有一个类A
中也有一个成员函数Print()
,由于他们有不同的作用域,因此在程序中使用的时候,需要指明作用域,即:
- 全局函数
::Print()
- 类的成员函数
A::Print()
void Print() {
cout << "Print Gobal" << endl;
}
class A {
public:
void Print() {
cout << "Print Class A" << endl;
::Print(); // 表示调用全局函数
}
};
int main() {
Print();
A a;
a.Print();
return 0;
}
如果类A
中的成员函数写成下面这样:
class A {
public:
void Print() {
cout << "Print Class A" << endl;
Print(); // 没有使用作用域符
}
};
则会出现运行报错,表示栈溢出异常,是因为类A
中的Print()
函数没有使用作用域限定符,导致它调用的是自己,而不是全局的Print()
函数。这样就会造成无限递归,每次递归都会消耗栈空间,直到栈空间耗尽,抛出异常。
二、成员函数的隐藏
上面介绍了函数重载,函数重载满足的条件之一就是函数位于同一作用域,那么当这些函数位于不同的类中的时候,由于在不同作用域,因此会发生隐藏。
这里“隐藏”指的是**派生类的成员函数会遮蔽与其同名的基类成员函数**。
“隐藏”的具体规则如下:
- 派生类的函数与基类的函数同名,但是参数列表有所差异。此时,不论有无
virtual
关键字,基类的函数在派生类中将被隐藏(注意别与重载混淆,因为不在一个作用域,不能算作重载)。 - 派生类的函数与基类的函数同名,参数列表也相同,但是基类函数没有
virtual
关键字。此时基类的函数在派生类中将被隐藏(注意别与重写混淆,因为没有virtual
关键字)。
三、函数内联
关于什么是内联函数,参考:【C++】 内联函数详解(搞清内联的本质及用法)
1、问题:
c++中宏和内联函数都是文本替换,那为什么内联函数可以进行调试检查,而同样是文本替换的宏不行?
内敛函数的“可调试”不是说它展开后还能调试,而是在在调试(Debug)模式下,编译器为了方便程序员进行调试,不会对内联函数进行真正的内联,而是像普通函数一样生成可执行代码,并且包含调试信息,例如函数名、参数、返回值等。这样,程序员可以在调试器中跟踪和查看内联函数的执行过程。在发行(Release)模式下,编译器为了提高程序的运行效率,才会对内联函数进行真正的内联,即将内联函数的代码直接嵌入到调用处,不生成函数调用的指令。这样,程序员就不能在调试器中看到内联函数的执行过程,只能看到嵌入的代码。
2、问题2:
在项目中,经常会遇到
static inline
,always_inline
,noline
,这些都是什么意思?
- static inline是一种常见的内联函数的声明方式,它表示这个函数是静态的,也就是说它的作用域只限于定义它的文件,不会被其他文件访问。
- always_inline是一种强制性的属性,它表示这个函数一定要被编译器内联,不管它有多复杂或多大。这个属性一般用于那些非常简单且频繁调用的函数,或者那些需要保证执行时间的函数。
- noinline是一种禁止性的属性,它表示这个函数一定不要被编译器内联,即使它很简单或很小。这个属性一般用于那些不需要优化或者不适合内联的函数,或者那些需要方便调试的函数。
3、补充:
- 内联函数有两种声明方式:
- 一种是用inline关键字来修饰函数的声明或定义。
- 另一种是在类中直接定义函数的实现,而不是在类外。
// 类外inline关键字来修饰函数
inline int max(int a, int b) {
return a > b ? a : b;
}
// 类中直接定义函数的实现
class A {
public:
int get_x() {
return x;
}
private:
int x;
};
这两种方式都可以让编译器知道这个函数是一个内联函数
- 关键字
inline
必须和函数定义体放在一起,仅把inline
和函数声明放在一起不起到任何作用。
比如,以下代码不会成为内敛函数:
inline int max(int a, int b); // 这里的inline没有作用。
int max(int a, int b) {
return a > b ? a : b;
}
应当写成:
int max(int a, int b); // 这里可以不写inline
inline int max(int a, int b) { // 这里必须写inline
return a > b ? a : b;
}
或
inline int max(int a, int b) { // 这里可以同时写声明和定义
return a > b ? a : b;
}
这样编译器才会知道你想让这个函数成为内联函数。
参考:
c++17的inline、static和inline static变量和函数
noinline & always_inline 内联函数探究
【C++】 内联函数详解(搞清内联的本质及用法)
四、const函数
1、问题4:
为什么成员函数不能被
static
和const
同时修饰?
C++编译器在实现const的成员函数的时候为了确保该函数不能修改类的实例的状态,会在函数中添加一个隐式的参数const this*。但当一个成员为static的时候,该函数是没有this指针的。也就是说此时const的用法和static是冲突的。
可以这么理解:static的作用是表示该函数只作用在类型的静态变量上,与类的实例没有关系;而const的作用是确保函数不能修改类的实例的状态,与类型的静态变量没有关系。因此不能同时用它们。
参考:
能不能同时用static和const修饰类的成员函数?
const和static能同时修饰成员函数吗
总结
本篇主要涉及到C++区别于C的面向对象程序设计中的一些特性,主要是继承中的一些问题,以及对象的构造析构和拷贝等,此外,还记录了重载重写隐藏和内联等高级特性。在下一篇,会讲到C++的异常、内存管理和STL容器。