一、关于对象:
1. OOP :
OOP的核心思想是多态性。
多态性的含义是“多种形式”,我们把具有继承关系的多个类型称为多态类型,因为我们使用这些类型的多种形式,而无须在意他们的差异。
当我们使用基类类型的“指针”或 “引用” 调用 基类中定义的一个函数时,我们并不知道该函数真正作用的对象是什么类型,直到运行时才能决定执行哪个版本。
面向对象的三要素:封装,继承,多态。缺一不可。
(单有 封装 的叫 “基于对象”。)
(注意: 不仅可以依靠指针,使用基类 引用 调用派生类对象的方式同样可以实现多态。)
2. C++对象模型:
当一个类中含有虚函数时,编译器将会为这个类生成一个“虚函数表”(virtual table),虚函数表相当于一个独立的 结构体,其中存储类中的所有虚函数指针。
编译器还会为类对象中插入一个 “虚函数表指针”(vtpr)(实际上类中已经包含了这个vptr成员),这个指针指向类的 虚函数表,类对象中的虚函数表指针的值则由 构造函数、拷贝构造函数、拷贝赋值运算符负责初始化。
虚函数表中除了保存所有虚函数指针外,还会在表中的头部位置保存一个 “type_info” 字段,用于支持RTTI(RunTime Type Indentification,运行时类型识别)。
一个虚函数表 可能长这样:
二、Data语意学:
1. C++中一个空类的大小:
编译器会向一个空类中插入一个 char,目的是:使得这一class的两个object对象 得以在内存中分配独一无二的地址。
(为的是当定义类对象时,可以在内存中分配一个字节的内存空间,否则空类的对象将没有内存空间,也就没有内存地址可言。)
2. C++中一个 含有虚函数的类大小:
如上所述,含有虚函数的类中会增加一个虚函数表指针,所以类的大小也会包含这个指针的内存空间。
class X {
};
class Y {
virtual void func();
};
class Z : public X {
virtual void func();
};
sizeof(X) = 1;
sizeof(Y) = 8;
sizeof(Z) = 8;
sizeof(int*) = 8;
3. Data Member 的布局:
==> 类中的数据成员在内存中的存储位置:
对于非静态数据成员(即普通的数据成员),按照其声明的顺序进行排列,较晚出现的排在较高的地址。
编译器还会合成一些内部使用的数据成员,如 vptr虚函数表指针。
C++标准并未明确要求应该把 vptr放在类对象内存的哪个位置,但大多数编译器在实现上都会把vptr放在类对象的 最前端。(在起初的C++编译器中会将vptr放在类对象的最尾端,这样做的好处是可以兼容C语言中的结构体;而把vptr放在类对象的最前端,在多重继承下会带来一些帮助。)
4. Data Member 的存取:
4.1 Static Data Member:静态数据成员:
静态数据成员独立于class之外,可以将其视为一个global全局变量。
注意:静态成员变量 在类内只是声明,要放到类外 定义和初始化!
(如果在类内初始化会编译报错,且static关键字只需要放在变量的声明处,在定义处不必写出,否则将编译报错)
对于静态成员变量 和 静态成员函数,都可以通过 类外直接访问或者类对象访问。
通过类对象访问静态成员变量只是为了程序编写方便,实际上静态成员变量并不存储在对象的内存空间中。所以实际上存取静态成员变量也并不需要类对象,直接使用类名就可以达到目的。
静态成员变量既可以在public块声明,也可以在private块声明。访问静态成员同样遵循访问限制,即private不能在类外访问。
举例:
class A {
public:
void print() { cout << y << endl; }
static int x; //类内只是声明,类外定义
private:
static int y;
};
int A::x = 1; //定义处不能加static关键字
int A::y = 2;
int main() {
A a;
a.x += 10; //通过类对象访问静态成员变量
A::x += 10; //通过类名直接访问静态成员变量
}
如果两个不同的类,分别声明了一个同名的静态成员,则它们存放在程序的静态区时会发生名称冲突。
编译器的解决方法是暗中为每个静态成员“编码”(name-manling),以获得独一无二的程序识别码。
4.2 Nonstatic Data Member:非静态数据成员:
如果通过成员函数去访问类对象中的非静态成员变量,实际上是要经过一层“隐式的类对象转换”(implicit class object),成员函数借助调用它的对象传入的this指针才能访问到对象的数据成员。
当我们进行如下调用时:
Point3d::translate(const Point3d &pt) {
x += pt.x;
y += pt.y;
z += pt.z;
}
实际上发生的操作是这样的:
Point3d::translate(Point3d const *this, const Point3d &pt) {
this->x += pt.x;
this->y += pt.y;
this->z += pt.z;
}
//注意this指针是常量指针,指向不能改变
三、Function语意学:
C++中有三种类型的成员函数:
- 静态成员函数;
- 非静态成员函数;
- 虚函数。
每一种成员函数的调用方式都不一样,以下为各种成员函数的调用方式:
1. Member的各种调用方式:
1.1 Nonstatic Member Function:非静态成员函数:
C++的设计准则之一就是:
非静态成员函数 至少必须 和一般的非成员函数有相同的效率。
即调用以下两个函数的效率应该相当:
float magnitude3d(const Point3d *_this) {}
float Point3d::magnitude3d() const {}
成员函数虽然是写在类的内部,而实际上在编译阶段,编译器会将其转换为 非成员函数,所以二者在底层并无什么区别!
成员函数 到 非成员函数的具体转化过程如下:
(1)改写成员函数的函数原型,增加它的形参,接收一个类类型的*this指针:
float magnitude3d() {}
==>
float magnitude3d(Point3d const *this) {}
(2)改写成员函数内部对非静态成员变量操作时的形式,改为经由this指针来存取:
float magnitude3d() { return x; }
==>
float magnitude3d(Point3d const *this) { return this->x; }
(3)将成员函数重写为一个外部函数,将函数名经过“mangling”处理,使其在程序中成为独一无二的语汇(这样当另外的类中定义同名成员函数时,就不会发生冲突,编译器可以将它们区分开来):
float magnitude3d() { return x; }
==>
float Point3d_magnitude3d(Point3d const *this) { return this->x; }
1.2 Virtual Member Function:虚函数:
当调用一个虚函数时,编译器会将其转换为:
ptr->normalize();
==>
(*ptr->vptr[1])(this);
1.3 Static Member Function:静态成员函数:
C++类中的静态成员函数的特点:
(1)不能直接操作 非静态成员变量;(因为静态成员函数没有this指针,所以只能访问静态数据成员)
(2)不能声明为const、virtual、volatile;
(3)不必非要通过类对象调用。
静态成员函数在某些时候特别重要:
当类的设计者希望支持“没有类的对象存在”的情况下,也能进行类的存取操作。
静态成员函数由于没有this指针,因此基本等同于非成员函数。
这个特性提供了一个意想不到的好处:成为一个callback函数,这可以与C兼容,同时也能方便的应用于线程函数上(pthread_create()时被传入为线程起始函数)。
2. Virtual Member Function虚函数的调用方式:
先来理解“面向对象”程序设计:
《C++ Primer》中《第15章 面向对象程序设计》中这样解释“面向对象程序设计”:
面向对象程序设计基于三个基本概念(通过三个阶段实现面向对象):
数据抽象(类的封装)、继承、动态绑定。
(1)通过使用 数据抽象,可以将类的接口与实现分离;
(2)通过使用 继承,可以定义相似的类型,对其相似关系建模;
(3)通过使用 动态绑定,可以在一定程度上 忽略相似类型的区别(基类与派生类之间),而以统一的方式使用它们的对象。
所以,“封装成类 --> 基类与派生类继承 --> 基类指针与虚函数动态绑定”,这一路操作下来,就是为了实现多态的效果。
关于C++多态,简言之,就是用基类类型的指针操作派生类类型的对象,通过基类指针统一调用不同派生类对象,可以让基类指针有“多种形态”(多态)。也就是试图用不变的代码,实现可变的算法。
Tips: C++中的“多态”,修饰的是“基类类型的指针&引用”,使“基类指针&引用”具有“多种形态”。
2.1 动态绑定的实现:
封装与继承的实现都比较好理解,关键在于理解动态绑定是如何实现的。
“多态”首先要支持的是“以一个基类指针,寻址出一个派生类对象”。
示例:
class Base {
public:
virtual void print() { cout << "hello"; }
};
class Derived : public Base {
public:
virtual void print() { cout << "world"; }
};
int main() {
Base *ptr = new Derived;
ptr->print();
}
考虑 ptr->print()
虚函数调用的实现:
无论何种形式的函数调用,最终编译器都要将其转换为对一个函数指针地址的访问,所以 分析虚函数的调用过程的关键在于如何找到通过指针调用的虚函数的位置。
通过一个基类指针分别调用基类对象与派生类对象的示意图如下:
其中,虚函数表是由编译器在编译阶段构建起来,虚函数表的大小、地址都是固定不变的,在编译期就已经决定的,在执行期不可能改变,因此 关于“虚函数表” 这部分的大小、保存的虚函数内容、表地址,都是不需要执行期做任何介入的。
一个类中的虚函数可能有以下几种情况:
(1)重新定义并覆盖继承自基类的同名虚函数;
(2)与基类中的同名虚函数版本保持一致,不重新改写;
(3)一个纯虚函数(如果一个纯虚函数被意外调用,那么系统将会结束掉这个程序)。
但是,无论派生类 是直接继承基类的虚函数,还是对继承而来的虚函数进行重新改写,在基类与派生类这两个类的虚函数表中,有一点是对应的:
同一个虚函数在基类与派生类的虚函数表中的槽位号slot是相同的。
(如上图中virutal func虚函数在Base基类与Derived派生类中都是处在slot[2]槽位上)
在编译阶段,当编译器发现这个类中包含virtual虚函数时,它就会增加一些额外信息:
- 首先,为每个类构建一个虚函数表,其中包含这个类的所有虚函数地址;
- 其次,在每个类对象中插入一个vptr指针,指向类的虚函数表(这里描述有些不准确,事实上vptr是类的一个隐式成员,类对象中的vptr只是通过构造函数在对象构造阶段将vptr初始化为类的虚函数表地址)。
在编译阶段,当发现有基类指针 应用虚函数时,编译器会将虚函数的调用形式改写为:
ptr->print();
==>
(*ptr->vptr[2])();
==>
(*ptr->_vptr_Derived[2])(Derived const *this); //name mangling + 传入this指针
通过ptr所指对象中的vptr指针,找到对象对应类的虚函数表,然后根据索引值找到具体的函数地址,进行调用。
唯一一个需要在执行期才能知道的东西是:
slot[2]所指的导致是 virtual void print() 虚函数的哪一个实例?
如上所述,一个虚函数可能有三种版本:直接继承基类的版本、重新改写覆盖基类版本、纯虚函数,具体调用的是虚函数的哪个版本,只有在执行期才能确定。
2.2 多继承下的虚函数表结构:
多继承(也称多重继承),是相对于单继承而言。
“单继承”指的是派生类只有一个基类;“多继承”指的是一个派生类可以有两个及以上的基类。
(多继承容易让代码逻辑复杂、思路混乱,一直备受争议,中小型项目中很少使用。后来的Java等语言干脆取消了多继承。)
多继承的语法也很简单,多个基类用逗号隔开即可,例如:
class D : public A , private B, protected C
{
//类D新增加的类成员
};
当在多继承场景下,多个基类中都含有虚函数时,派生类的虚函数表结构为:
与单继承相同的是所有的虚函数都包含在虚函数表中;所不同的是多继承时,派生类有多个虚函数表, 当派生类对基类的虚函数有覆盖(override)时,派生类的虚函数覆盖基类的虚函数在对应的虚函数表的位置;当派生类有新的虚函数时,这些虚函数被加在第一个虚函数表的后面。
因此,当调用 dynamic_cast 的时候,派生类和第一个基类的地址相同,不需要移动指针,但是当 dynamic_cast 强制转换到其他父类的时候,需要做相应的指针的移动。
四、补充:
1. 如何回避虚函数:
避免动态绑定,强制执行虚函数的某个特定版本:
使用 作用域 运算符。
例如:
Bulk_Quote item;
Quote *ptr = &item;
ptr->Quote::net_price(100); //强制执行 基类中的net_price函数
ptr->Bulk_Quote::net_price(100); //错误!不允许这种写法
因此,需要回避虚函数的场景只有一种:一个派生类虚函数需要调用它的基类版本。
2. 虚析构函数与动态绑定:
和其他虚函数一样,析构函数的虚属性也会被继承。
(无论是派生类使用的是自定义的析构函数,还是编译器生成的合成的析构函数,只要基类中将析构函数声明为virtual虚函数,派生类都会继承析构函数的虚属性)
所以当delete释放一个 基类指针 指向的动态分配的对象时,也会发生动态绑定,来决定具体调用哪个类型的析构函数。
举例:
class Base {
public:
virtual ~Base() {
std::cout << "base class delete" << std::endl;
}
};
class Derived : public Base {
virtual ~Derived() {
std::cout << "derived class delete" << std::endl;
}
};
int main() {
Base *ptr = new Derived;
delete ptr; //delete释放一个基类指针,实际对应的是释放派生类对象
}
在上面的例子中,delete一个基类指针,实际上释放的是派生类对象,此时,系统会先调用 派生类的虚析构函数,再基类的虚析构函数。
由于 派生类的作用域在 基类作用域的内部,所以在派生类的对象构造时:
先调用 基类的构造函数,再调用 派生类的构造函数;
在派生类的对象析构时:
先调用 派生类的析构函数,再调用 基类的析构函数。
基类 与 派生类 作用域示意图:
基类 {
base_constructor();
派生类 {
derived_constructor();
...
derived_destructor();
}
base_destructor();
}
3. std::bind() 函数的用法:
前文中提到,类的static静态成员函数有一个重要用途是可以用作回调函数。在C++中,由于非静态成员函数含有一个隐式形参this指针,所以不能直接被用作回调函数(成员函数this指针的类型是 Base const *this,类类型的指针常量,与类类型强相关)。
解决办法目前有两种:
(1)使用 std::bind() 函数;
(2)使用 static静态成员函数。
bind函数可以看作是一个函数的改造器,它接受一个旧函数作为入参,返回一个新函数,使新函数适配新的调用类型要求。
调用新函数时会转去调用老函数,只是参数形式有变化。
bind函数的一般形式:
auto newCallable = bind(oldCallable, arg_list);
当调用 newCallable时,newCallable 会调用 oldCallable,并传递 arg_list 参数列表中的参数。
arg_list中有 “_n” 形式的名字,表示将newCallable 位置为n的形参传递给 oldCallable函数。
使用举例:
#include <functional> //bind函数的头文件
double my_divide(double x, double y) { return x / y; }
int main() {
using namespace std::placeholders; //_1, _2 的命名空间
auto new_func = std::bind(my_divide, 10, 2);
std::cout << new_func() << std::endl;
//等价于:my_divide(10, 2);
auto another_func = std::bind(my_divide, _2, _1); //std::placeholders::_1, std::placeholders::_2;
std::cout << another_func(5, 50) << std::endl;
//等价于:my_divide(50, 5);
}
一个使用bind函数操作类的成员函数的示例:
class Channel {
public:
Channel();
void setReadCallback(std::function<void()> cb) { readCallback_ = cb; }
private:
std::function<void()> readCallback_; //一个void()类型的函数
};
class TimeQueue {
public:
TimeQueue::TimeQueue() {
timerfdChannel_.setReadCallback(std::bind(handleRead, this));
}
void handleRead() { ... }
private:
Channel timerfdChannel_;
};
4. 对编程语言的一点理解:
C语言通过指针和结构体也能实现多态,但是在面向对象设计时使用C++的原因是:如果使用C语言,动态绑定等等一系列内部细节需要自己编码实现,复杂且容易出错;而C++的编译器会为我们自动实现,只需要按照C++的规范进行简单的基类、派生类、虚函数写好,就可以自动实现动态绑定。
所以,所谓的“语言特性”,C++也好,Java也好,都是语言为了针对某些特定的用途、场景、需求,在语言规范中明确要求需要提供什么样的功能(类似于协议),不同厂商的编译器就会按照语言规范进行实现,从而达到高效开发的目的。
C语言则是一种非常简朴的语言,它提供的高级特性不多。
但是,使用高级语言则会使程序运行速度变慢,因为它提供的特性更多,就必然更复杂,而有些特性可能并不是你所需要的,而不得不接受的;如果使用C语言自行实现一些特性,虽然更困难,但是却可以按照你的要求来实现,更灵活,更高效。
所以还是要按照具体的业务需求场景选择合适的开发语言。
5. 一个典型的可执行文件在内存中的样子:
可以将一个程序的内存简易划分为4个区:
只读代码段、读/写段、堆区、栈区。
只读代码段: 用于存放所有类的成员函数和非成员函数;常量类型存放在常量区,即 .rodata区;
读/写段: 全局变量、静态数据类型;
栈区: 为运行函数而分配的 局部变量、函数参数、返回值、返回地址等;
堆区: 动态申请的内存。
因此,C++类中的各类成员所在的内存区为:
(1)类的普通成员函数,存放在代码区;
(2)类的静态成员函数,存放在全局数据区;
(3)类的非静态成员变量,是在实例化过程中(构造对象时)才在栈区或者堆区为其分配内存;
(4)类的静态成员变量,在类中声明,在类外定义和初始化,存放在静态数据区。