类基本特性概述
作为一门OOP(Object-Oriented Programming)语言,类和继承的相关概念和语法无疑是C++的立身之本。
C++中类(class
)的概念由C语言的结构体(struct
)衍生而来,在C++中二者除成员的默认访问权限、默认继承方式(private/public)外,其余是完全等价的。
类可以拥有数据成员(data member)和函数成员(function
member),其可以是静态(static)或非静态的。
类的静态成员可以通过类或者类的成员来访问,非静态成员则只允许通过类的成员来访问;对于非静态的数据成员,每个类的实例都保有该数据成员的一个实例。因此,类中只有非静态的数据成员才占有空间(依operator sizeof
获取,若一个类里没有任何的非静态数据成员,则其大小为最小值1。)
sizeof
的两个特例:内存对齐(memory alignment)问题和虚类(virtual classes)的隐式虚指针(vptr)。
“一笔带过”初始化问题:对于类的static
数据成员,编写者可以在类外指定一次类内成员的初始化值(没有static
关键字),若不指定,其则会默认采用
zero-initialization(零初始化)的方式初始化。
特别地,static const
的数据成员必须被初始化,但其初始化可以在类体内进行,如static constexpr size_t T = 65537;
1 | class T |
所有类都拥有6个特殊成员函数:构造(constructor)、复制构造(copy
constructor)、移动构造(move constructor)、复制赋值(copy assignment
operator)、移动赋值(move assignment operator)、析构(destructor)。
用户若不定义这些函数,则编译器将自动生成之。默认生成的三个构造函数和析构函数的函数体为空,其中移动构造函数和析构函数自动声明为noexcept
;三个构造函数在初始化列表中会分别用(变量声明中的初始化列表/原对象的对应变量/原对象的对应变量转化为右值后)初始化自身的成员变量。若依此法得到的任一变量的初始化不能通过编译,则该默认生成的构造函数不可用。
更多有关移动构造/移动赋值/移动语义/右值引用可参阅:C++——右值引用和移动语义
上述初始化方法的特例是"POD(Plain Old
Data)-types"(C++17后弃置了这种说法,改为 aggreate-type -
聚合类型)。
简单理解,POD/aggreate-types就是长得像C中的struct
的类型,其所有数据成员都是public
的,且没有定义任何的前五类特殊函数(也不能显式声明为= default;
)。
对它们可以通过依次指定每个成员的方式来初始化(若参数个数小于变量数,则只默认初始化后续的变量),名为“聚合初始化”,此时传参只允许使用大括号{}
。
这里感觉又可以开一个关于C++初始化的新坑,不过C++的初始化确实可讲的也非常非常多
聚合类型可以拥有
private
/public
的函数成员,但不能是虚类,其所有的数据成员也应是 fundamental type(基本类型)或聚合类型。
类的继承
继承和虚继承
类的继承有普通继承和虚继承两种。继承时需要声明继承方式(若不声明,class
默认以private
,struct
默认以public
)。
可以在继承方式前/后声明virtual
声明为虚继承,虚继承下对于最末端的派生类,被虚继承的类(该类的所有直接派生类都需要声明为虚继承)的成员只会保有一个副本,其构造函数的调用发生于其它非虚基类的构造函数之前。
构造函数的调用优先级如下:
- 所有虚基类的构造函数;
- 所有直接基类的构造函数;
- 所有数据成员的构造函数;
- 自身的构造函数。
对于同为直接基类的构造函数,调用顺序由函数继承声明顺序决定;对于同为数据成员的构造函数,调用顺序由变量声明顺序决定(总之和初始化列表的顺序无关)。
若初始化列表所“展现”的构造函数调用顺序与实际顺序不一致,部分编译器会给出warning
。
1 |
|
输出:(不考虑)
[-Wreorder]
的warning
1
2
3
4
5B::B()
D1::D1()
D2::D2()
D3::D3()
3
继承成员、访问权限和赋值兼容
类继承时,会继承被继承类所有的函数成员和数据成员,但只有基类protected
和public
的成员才能被派生类直接访问(private
成员依然存在于派生类中,只是不能被直接访问)。
这点是“赋值兼容原则”的基础,即一个派生类对象(或其地址)可以被用于初始化基类引用或指针,因为基类的成员是派生类成员的真子集。
作为一门编译期语言,C++中一个对象能够访问的成员(包括函数成员、数据成员的寻址、调用及访问权限控制等)是由该变量的当前类型决定的,而非由对象本身决定(对象本身不存储任何类型信息,RTII(Runtime
Type Identity Information,运行时类信息)除外)。
因此,如果一个对象被绑定至其基类的指针或引用,通过基类引用Base&
只能访问基类有的函数/成员变量,而不能访问派生类新定义/重写的函数/成员变量,同时访问权限均以基类的定义为准(就如同派生类从未存在过)。
即使是下面将述的虚函数,其访问权限依然由引用类型类内的定义为基准。
1 | class Base |
虚类和虚函数
虚函数通过virtual
关键字声明。若派生类中某函数和基类虚函数的签名相同,则该函数也会自动成为虚函数。为了保证语义一致,依然建议在编程实践中派生类的对应函数上依然加上virtual
。
类成员函数的函数签名由函数名(含该类的命名空间约束)、形参列表和 cvref (
const
、volatile
、引用) 属性决定。(cvref 中的 cv 属性本质上是形参列表的一部分,由于成员函数的调用协定采用thiscall
,即函数传参时本质是将调用实例的地址传入了(隐式的)第一个参数,为一个变量名为this
,变量类型为 cv +指针修饰的形参; ref 只是约束了允许绑定至的对象类型,&
约束左值,&&
约束右值,空则不约束之)。
虚函数的实现原理是 vptr (虚指针) 和 vtbl
(虚表)。带有虚函数的类被称为虚类(virtual
classes),继承了虚类的类也自动成为虚类。
所有虚类都会隐式地具有一个数据成员
vptr,因而虚类所占的内存空间会偏大一个指针的sizeof
。
动态多态性的实现原理:
编译器会将获取虚函数的地址,并依虚表的次序将之填入一段静态的内存空间内(这段内存可以认为是一个指针的数组),派生类会继承基类的虚表。若一个派生类重写(override)了某个虚函数,则该派生类会将基类该虚函数对应位置的地址,重写为派生类重写函数的地址。
所有该类实例的构造函数中,其vptr都会被赋值为这个指针数组的起始地址(本质是一个二级指针)。
基类(或任何虚类的引用/指针等)调用虚函数时,会获取当前实例的vptr,随后到该类的vtbl中查找地址偏移量,以确定最终调用虚函数的地址。因而这一调用机制被称为“动态多态性”——函数寻址和传参是在运行时而非编译时进行。
上述纯文字讲述可能稍有抽象,留一张参考图: 依此图对vptr和vtbl的实现原理做进一步讲解。
从右侧的C++函数代码来看,这是非常典型的继承结构。A类定义了两个虚函数vfunc1
和vfunc2
,如图。在程序开始运行时会获取这两个虚函数的地址,并且填入A类所对应的vtbl中,形如0x401ed0
和0x401f10
。最后的空地址则起到类似于C样式字符串中“结束符”的作用,标志着vtbl(虚表)的结束。
对于B类,其公有继承了类A,这里需要注意的有两点:
- 如此前所述,虽然A类的两个
int
数据成员m_data1
,m_data2
被声明为private
,但它们依然会被继承(占据了内存空间),只是在B类内不可访问而已。
- B类会先将A类的vtbl复制一份(目标虚函数的地址),但由于B类重写了
vfunc1
,则在B类的vtbl中,对应vfunc1
的地址会被重写为B::vfunc1
的地址0x401f80
。同时因为B类没有重写vfunc2
,因此vfunc2
的地址依然是A::vfunc2
的不变。
假设B类新定义了一个虚函数
vfunc3
,则其地址在vtbl中会被追加在vfunc2
之后;B的派生类的与虚函数有关的规则同上。
vptr和vtbl只处理和虚函数有关的问题;对于非虚函数,其地址和访问以另一种类似
static
的表形式实现,相信读者在理解vptr和vtbl的访问逻辑后能无师自通之。
对于C类,逻辑基本和上述相同。需要留意的是C类定义了一个m_data1
,但由于C++并没有虚数据成员的概念(bushi),,结果就是类C中保有两份m_data1
的副本,一份是继承自A类的A::m_data1
(显然类C内不可直接访问之),另一份是自身定义的m_data1
。
现在观察一下虚函数的调用底层原理:
左侧a
、b
和c
分别是A、B、C三个类的对象。每个虚类对象的实例都会保有一个vptr(一个二级指针),对于同一类的实例,其存储的地址是相同的(都是本类vtbl的起始地址)。
现在假设我们用一个A类的引用指向对象b:A& ra = b;
。观察左侧图中b对象加粗黑框的内容,这就是赋值兼容原则下,通过A类引用能访问到B类的全部成员(即A类既有的成员)。
可见赋值兼容原则的基础就是基类的成员会被全部继承,并在派生类的内存中优先排列。
此时:
- 若调用
ra.vfunc1();
,由于vfunc1
是虚函数,因此程序会根据该对象的vptr,获取到B类的vtbl;随后根据既定的偏移量(A类中,vfunc1
处于vtbl中下标为0
的位置),找到了对应函数B::vfunc1
并开始调用。
- 若调用
ra.vfunc2();
,同理,但因为B类没有重写vfunc2
,因此B类的vtbl中下标1
依然指向A::vfunc2
,因此最终未被重写的函数A::vfunc2
被调用。
- 若调用
ra.func1();
或ra.func2();
,这两个函数并不是虚函数,因此程序只能寻址到A::func1
和A::func2
(程序没有提供任何能寻址到B::func2()
的机制,毕竟当使用类型为A&
的引用变量ra
时,其就决定了只能按A类的方式解读该对象的内存)。
- 即使
b
类新定义了一个虚函数vfunc3()
,即通过b
的vptr可以寻址到B::vfunc3()
,但由于现在的引用类型是A&
,寻址是通过A类的vtbl偏移量进行,而A类并不知道该如何寻址到vfunc3
(因为其在类A中没有定义),因此ra.vfunc3()
这一语句不能通过编译。
实践中由于其在运行时才能确定函数寻址和传参,因而一方面动态多态性的效率远不如静态多态性,另一方面其亦留下了很多漏洞攻击的空间(
pwn警告)。实践中若需要实现类似动态多态性的功能,还有另一选项CRTP(Curiously Recursive Template Parameter,奇异递归模板参数),其是一种较高阶的template
运用技巧,可以在编译期实现基类引用调用派生类函数的效果,缺点是应用难度比较大且容易带来代码膨胀的问题。参考C++——“俗语”盛宴template
通病
纯虚函数和访问权限的差分
长篇大论了这么多有关vptr和vtbl的机制,若能通晓之,也就不难理解纯虚函数和抽象类的底层逻辑了。
纯虚函数的声明就是将虚函数的主体以= 0;
替代。放在vptr和vtbl的机制来看,显然其意义在于让该类的vtbl对应下标存储的地址置空(0
,NULL
,nullptr
)。
显然程序运行中不能寻址一个地址为NULL
的函数。因而:
- 底层来看,该类派生类的vtbl中,该位置的地址被重写之前;
- 顶层来看,该类派生类中,该纯虚函数被定义(给出实现)之前;
这个派生类都是抽象类,即不能创建对象的实例。
结合此前关于类继承中访问权限区分的相关机制,结合访问权限差分,可以衍生出一些虚函数的有趣用法:
- 基类将虚函数声明为
private
,则派生类能重写之,但不能访问基类未被重写的该函数;纯虚函数其理类似。
- 派生类将虚函数声明为
private
(基类依然声明为public
),则只有派生类实例被基类引用绑定时,在类外可以访问该函数。
1 |
|
C++11的“干将”:override
和final
修饰符
为了避免既有代码命名冲突问题,这两个关键字(keyword)并未作为保留字(reserved),意即其可以作为变量名、类名等,其修饰作用只在特定位置生效(
又不是Java,)final
随便用
C++11引入了override
和final
两个修饰符,这两个修饰符并不会生成可执行代码,只是在编译阶段为程序语义提供了更为明确的指引。
对于C++一类的编译型语言,注释之外也有很多声明、定义等,或于一定条件下,或必定——不会生成可执行代码。
作为一门常用于大型程序开发的语言,这些不生成可执行代码的修饰符,代表着无数的语义约束,亦代表着程序设计者与自我、与上下游的合作者、与标准库和三方库、与编译器的永无止境的合作与博弈。
override
关键字用于修饰一个虚函数,等价于显式声明了这个虚函数会重写了基类虚函数(纯虚函数亦然,亦即覆写了既有vtbl的函数地址)。若其没有做到“覆写虚函数”这一点,或其本身不是虚函数,编译阶段将报错。
其起到的检查作用颇为实用,因派生类欲覆写虚函数时,若不慎导致函数签名不一致,则将无法重写既有虚函数,而是创建了一个新的虚函数。
1 | class Base |
尽管返回值类型不是函数签名的一部分,但重写虚函数时,重写函数的返回值类型必须和既有虚函数相同,否则将产生编译错误。
相对地,如果只是普通成员函数(或其它可以明确优先级的命名空间场景下)的同名覆盖则没有对函数签名做出任何约束。
此时,派生类相当于定义了三个新的虚函数,基类的虚函数则未被重写。
而加上override
关键字则可以手动产生一个编译错误,可以让编程者意识到自身函数签名的相关问题:
1 | class Base |
final
关键字则可以用于修饰一个虚函数或一个类。final
修饰的虚函数不能被重写(可以和override
叠加);其修饰的类不能被继承(修饰类时,将之置于类名后)。
1 | class Base |
浅谈友元
友元通过friend
关键字声明。一旦使用friend
关键字,则被声明(declare)的函数/类所处的命名空间就是在当前类外的命名空间,不是当前类的成员函数(因而也就没有隐式this
指针,可以被视为一种特殊的static
函数)。
事实上从访问方式来看,
static
函数和友元函数的唯二区别之一是是否加域解析符::
。
另一个区别是,static
函数依然具有访问权限的属性,但friend
由于本来就被“放置”在了类外,因而不存在访问权限的问题。
在类外给出其定义(definition)时,不应再加friend
/static
关键字。
其作用也很简单:被友元声明的函数/类可以访问自身类的protected
和private
成员。注意友元声明必须是前向性的,意即此“特权”必须在友元声明出现后才会生效。
友元声明不具有对称性和传递性。一个类的派生类/基类也不是本类的友元。
1 | class A |
前向性声明的一段应用示例:
C++20后,为令class C
能被格式化,希望将std::formatter<C,char>
置为其友元。显然,我们应当先完成对class C
的定义(各类数据/函数成员设置)后再定义std::formatter<C,char>
,此时就会用到前向声明:
1 |
|
结语
类和对象提供的封装性、继承性、多态性对于任何OOP语言来说都极为重要,而不同的语言对于OOP中概念的支持、实现及底层逻辑可能大相径庭。
作为一门追求高效、简洁和语义清晰的编译期语言,学习运用C++语言进行面向对象的程序设计时,除了对应用规则更加通晓之外,还应对其底层实现有所涉猎。
如此方能更好地理解各种语法规则,并对现代C++的特性缘由、设计哲学有所知晓;并在和编译器、标准库、其它程序设计者和自身既有的代码更好地合作共进。