记载学习cpp的过程中一些比较容易遗忘和比较特殊的点(相比Java)
Const in Cpp
const int* p1; // 不能改变指针指向的地址的值 |
class Entity { |
Static in Cpp
Cpp中的 static
变量分成两类:
Static Variables
:函数中用static
修饰的变量或者类中的成员变量。Static Members of Class
:static
修饰的类对象和类成员方法。
Static Variables
static variables in a function
当一个变量被声明为 static
的时候,尽管可能是在某个函数中声明的,但 static
让其的声明周期延长到了程序结束运行,即使我们多次调用同一个函数,这个变量还是只能够初始化一次(其所在内存空间会一直保存,不会随着方法的退栈而被清空)。并且前一次调用中的变量值将通过下一次函数调用进行传递。比如如下的代码:
|
Output:
0 1 2 3 4 |
static variables in class
由于声明为静态的变量仅被初始化一次,因为它们在单独的静态存储中分配空间,因此,类中的静态变量由对象共享。不同对象不能有相同静态变量的多个副本。也因为这个原因,静态变量不能使用构造函数初始化。(这个特性和Java中类的静态成员变量是一样的)。我们可以看如下的示例代码。
|
Output
1 |
Static Members of Class
class objects as static
类的静态对象和在函数中声明的静态变量是一样的,其生命周期被延长到了程序运行结束的时候,比如如下的代码:
|
Output:
Inside Constructor |
可以看到在程序的末尾才调用了析构函数,而不是在作用域一结束时就调用了析构函数 这说明静态对象的生命周期被延长到了整个程序运行结束的时候。
static funcations in a class
类的静态成员方法和Java中是一样的,支持被类名直接调用,且在该方法中只能访问类的静态变量。(在Cpp中,类名直接调用的方式是ClassName::MethodName()
)
就像类内部的静态数据成员或静态变量一样,静态成员函数也不依赖于类的对象。我们可以使用对象和“.”运算符调用静态成员函数,但建议使用类名和**范围解析运算符(::
)**调用静态成员。 静态成员函数只允许访问静态数据成员或其他静态成员函数,不能访问类的非静态数据成员或成员函数。
|
Output:
Welcome to GfG! |
初始化成员表
为什么要用初始化成员表:因为如果在构造函数中对成员变量进行赋值,实际上是经历了两步,第一步是成员变量的初始化,第二步才是构造函数中的赋值,这样会导致效率的降低。
初始化成员表的初始化顺序是按照成员变量的定义来的,而不是初始化成员表写的顺序来的
class Entity { |
Mutable
Mutable是cpp中的一个关键字,其目的在于让某个变量变成可修改的,同时在lambda中也有用处。比如我们将某个类中的方法声明为const:
class Entity { |
将函数声明为const表明在该函数中我们不能对成员变量进行修改,但如果我们有对某个变量进行修改的需求呢?只需要将该变量标上mutable
即可:
class Entity { |
此时在const函数中,mutable的成员变量是可修改的了
new and delete
什么时候应该使用
new
和delete
?在对象所占用的内存很大,或者我们想要在不同的域(Scope)中控制该对象时,我们应该将该对象声明在堆中,否则,我们应该将对象声明在栈中,因为声明在栈中的速度比较快。
同时应该注意的是,
new
和delete
本质上也是操作符。且相较于malloc
和free
,new
会调用构造函数 而delete
会调用析构函数
Entity e; |
explicit
在C++中, explicit
关键字是为了声明某个构造函数是不能被编译器进行隐式的类型转换的,比如我们有如下的代码:
class Entity { |
如果我们在main
函数中写如下的语句,是可以通过编译并且正确执行的。
int main() { |
这是因为编译器能够帮助你做隐式的类型转换,只要你有对应的构造函数即可。这种写法等价于下面两种写法。
Entity e1("Cherno"); |
需要注意的是,对编译器来说,隐式转换只会发生一次,例如我们有下面这样的一个函数:
void PrintEntity(const Entity& entity) |
我们可以看一下下面两种传值方式
PrintEntity(22); // right |
原因在于下面这一种函数调用实际上传递的是一个const char[]
,对于编译器来说,这意味着要先把这个数组变成一个std::string
,再将std::string
变成Entity
,此时是无法通过编译的,如果要通过编译,可以通过以下的调用方式:
PrintEntity(std::string("Cherno")); // right |
至于explict
关键字有什么用呢?就是为了防止这种隐式转换的发生,如果你在构造函数上声明了explict
,那么编译器就不能够对对应的参数类型进行隐式转换,比如:
class Entity { |
此时,上面的两种有关Entity
的定义方式就是无效的了。
Operator Overloading
操作符重载是cpp中的一个十分重要的特性。我们可以通过例子来看一下:
struct Vector2 { |
在Java中,我们可能只能通过Add
和SpeedUp
方法来进行操作,但在cpp
中,我们可以通过操作符重载来实现我们需要的功能,之后我们调用该操作符和调用对应的方法就是等价的了:
Vector2 v3 = v1 + v2; // equal to v1.Add(v2) |
在cpp中,操作符还包括new
、delete
甚至是()
等,下面是对cout
中的<<
一个重载的例子(类似Java的toString()
)。对==
和!=
的重写则类似于Java的equals()
std::ostream& operator<<(std::ostream& stream, const Vector2& other) |
this
this指针很类似Java中的this。只是在cpp中它是一个指针而不是一个引用。即对Entity
来说,它代表的类型是Entity* const
。
cpp中的对象声明周期
- 在栈中声明的对象随着当前栈帧的
pop
而被销毁(调用析构函数) - 在堆中声明的对象会一直到程序员调用
delete / delete[]
才会被销毁。
栈帧:任何一个作用域都能成为一个栈帧,比如一个函数,一个类,一个for
、while
,甚至简单的一个大括号之间。
Smart Pointer
简单来看,smart pointer是一种会自动帮我们释放内存的指针
Unique Pointer
声明该指针时 new 一个对象,但是当超过scope的时候该指针会自动delete。该指针是不可被拷贝的。即不能有两个指向同一个内存的unique pointer
Shared Pointer
可以有多个指针指向同一块内存空间,内部有一个对指针的计数器,当计数器置0时(即没有指针指向这块空间的时候),这块空间会自动释放。
它需要新开辟一块空间(Control Block
)来存储计数。
Weak Pointer
将SharedPointer拷贝给WeakPointer不会导致计数器的增长,WeakPointer相当于只是保存一个引用但不关心其中是否还指向真正的对象。
Copying and Copying Constructor
cpp中的拷贝:
栈上声明的对象,赋值操作都是在拷贝,比如:
struct Vector2 |
但如果是声明在堆中的对象,此时我们持有的是指针,指针之间的赋值会导致两个指针指向同一个对象,即指针中的值拷贝了,但是实际的对象并没有被拷贝。
即:除了引用之间的赋值,其他的赋值其实都是拷贝,会在一个新的内存空间中拷贝一份当前内存空间中的值并存进去。
class String |
String second = string |
这里的赋值会使得程序在新的一块内存空间里拷贝一份string
的成员变量的值。
当我们要调用析构函数释放这两个String
对象的内存时,我们会发现程序出现了错误,这是因为编译器自动实现的拷贝构造函数实际上是一种“浅拷贝(Shallow Copy)”,即对于某个对象中的指针,它不会将指针指向的值再复制一份,而只是复制一个指针,但是指向的还是同一块内存,当我们调用delete[]
释放这个指针时,其实它已经被释放过了,因此就会出现错误。
如果我们要实现“深拷贝(Deep Copy)”,我们应该使用拷贝构造函数:
String(const String& other) |
如果我们不想要拷贝构造函数(即禁止两个对象之间赋值),我们可以这样声明:
String(const String& other) = delete; |
在函数调用中的传参也会拷贝值。因为要将参数压栈,优化性能的做法应该是将参数声明为const String& string
。
Arrow Operator
->
:解引用并调用对应的方法。
class Entity { |
->
作为操作符,我们也可以重载它。
Vector
|
Optimization of Vector
|
- 在main方法的栈帧上创建的
Vertex
需要拷贝到vector
中 – 优化:直接在vector
的地址创建新的Vertex
。 – 3次拷贝 vector
初始容量是1,扩容一次变成2,再扩容一次才能容纳三个元素,因此需要拷贝三个Vertex
(1 + 2) – 优化:减少Vector的resize
vertices.reserve(3); // 给vector3个容量的大小 |
Using Libraries in Cpp
虚函数表
成员函数中自带const this
指针,比如针对某个类
class A |
在B类的g()
中,会自带一个B* const this
的指针,这让动态绑定中存在着静态绑定,比如我们有如下的一个声明:
A* p = new B(); |
则调p.f()
时,根据虚函数的规则,会调用实际类型的方法,即B的f()
,在B类型的f()
中有B* const this
指针,所以f()
中调用的g()
实际上是this->g()
。即会调用B的g()
虚函数表是在某个内存空间里放了一个所有已经定义的虚函数表,然后再每个定义的类的头部位置之前放一个四个字节大小的内存空间,用来存对应的虚函数指向的虚函数表的内存地址。这样可以保证所有类头部位置存放的字节大小是一样的,也不用担心变长问题,只需要在虚函数表里将所有虚函数的实际地址存放好以便引用即可。
为了实现虚函数,C++ 使用了一种特殊形式的后期绑定,称为虚表。虚拟表是用于以动态/后期绑定方式解析函数调用的函数查找表。虚拟表有时有其他名称,例如“vtable”、“虚拟函数表”、“虚拟方法表”或“调度表”。
虚函数表其实很简单,虽然用文字描述起来有点复杂。首先,每个使用虚函数的类(或派生自使用虚函数的类)都有自己的虚表。该表只是编译器在编译时设置的静态数组。一个虚拟表包含一个条目,对应于类对象可以调用的每个虚函数。此表中的每个条目只是一个函数指针,它指向该类可访问的最衍生函数。 其次,编译器还添加了一个隐藏指针,它是基类的成员,我们将其称为 *__vptr
。 *__vptr
在创建类实例时(自动)设置,以便它指向该类的虚拟表。 *this 指针实际上是编译器用来解析自引用的函数参数,与 *__vptr
不同的是,*__vptr
是一个真正的指针。因此,它使分配的每个类实例都增大了一个指针的大小。这也意味着 *__vptr
被派生类继承,这很重要。 到目前为止,您可能对这些东西如何组合在一起感到困惑,所以让我们看一个简单的例子:
class Base |
因为这里有 3 个类,所以编译器会设置 3 个虚函数表:一个用于 Base,一个用于 D1,一个用于 D2。 编译器还将隐藏指针成员添加到使用虚函数的最顶层基类中。尽管编译器会自动执行此操作,但我们会将其放在下一个示例中,以显示它的添加位置:
class Base |
创建类实例时, *__vptr
设置为指向该类的虚函数表。例如,当创建 Base 类型的对象时,*__vptr
被设置为指向 Base 的虚函数表。当构造 D1 或 D2 类型的对象时,*__vptr
被设置为分别指向 D1 或 D2 的虚函数表。 现在,我们来谈谈这些虚拟表是如何填写的。因为这里只有两个虚函数,所以每个虚表将有两个条目(一个用于 function1(),一个用于 function2())。请记住,当填写这些虚拟表时,每个条目都会填写该类类型的对象可以调用的最派生(即在继承结构里最下层的)函数。 Base 对象的虚拟表很简单。 Base 类型的对象只能访问 Base 的成员。 Base 无法访问 D1 或 D2 功能。因此,function1 的入口指向 Base::function1(),而 function2 的入口指向 Base::function2()。 D1 的虚拟表稍微复杂一些。 D1 类型的对象可以访问 D1 和 Base 的成员。然而,D1 已经覆盖了 function1(),使得 D1::function1() 比 Base::function1() 更加派生。因此,function1 的条目指向 D1::function1()。 D1 没有覆盖 function2(),所以 function2 的入口将指向 Base::function2()。 D2 的虚拟表与 D1 类似,只是 function1 的入口指向 Base::function1(),而 function2 的入口指向 D2::function2()。 这是一张图形化的图片:
int main() |
在这种情况下,当 b 创建时,__vptr
指向 Base 的虚拟表,而不是 D1 的虚拟表。因此,bPtr->__vptr
也将指向 Base 的虚拟表。 Base 的 function1() 的虚拟表条目指向 Base::function1()。因此,bPtr->function1() 解析为 Base::function1(),这是 Base 对象应该能够调用的 function1() 的最衍生版本。 通过使用这些表,编译器和程序能够确保函数调用解析为适当的虚函数,即使您只使用指针或对基类的引用! 调用虚函数比调用非虚函数慢,原因有二:首先,我们必须使用 *__vptr
来访问适当的虚表。其次,我们必须索引虚拟表以找到要调用的正确函数。只有这样我们才能调用该函数。因此,我们必须执行 3 次操作才能找到要调用的函数,而普通间接函数调用需要 2 次操作,直接函数调用需要 1 次操作。然而,对于现代计算机,这个增加的时间通常是微不足道的。 另外提醒一下,任何使用虚函数的类都有一个 *__vptr
,因此该类的每个对象都会大一个指针。虚函数很强大,但它们确实有性能成本。
Templates
现在我们需要一个能够打印不同类型的Print()
函数
|
我们只改变了传入的参数的类型,但是函数内部的实现其实是基本一样的,这个时候我们可以用模板来实现。实现如下:
template<typename T> |
模板一开始是不存在,直到我们真正的调用这个函数或这个类,因此,如果我们在某个模板中出现了语法错误,那么编译器可能并不会马上识别出它,而是要到我们调用它的时候才能够发现它的错误。
模板在传不同参数时才会创建对应类型的真正实现,比如对于上面的例子,如果我们没有调用Print()
函数,那么该函数是不会被编译器创建的(即在内存中是没有这一块代码的内存的)。当我们调用了Print(5)
时,编译器会为我们创建关于Print()
函数的int
版实现,如下所示:
void Print(int value) |
当我们如果继续调用,调用了Print()
函数的float实现时,比如我们调用了Print(5.5f)
,那么编译器就会帮我们实现模板函数的float版本,如下所示:
void Print(float value) |
STL: standard template library。标准模板库
|
Inheritance in Cpp
继承的权限访问控制
cpp中的继承有三种权限访问控制,分别是 public
、protected
、private
。比如如下的语法
class Base { |
public inheritance
:在基类中public
的成员变量和成员方法在派生类中仍然是public
的,在基类中protected
的成员变量在派生类中仍然是protected
的。protected inheritance
:在基类中public
和protected
的成员变量和成员方法在派生类中 变成protected
的。private inheritance
:在基类中public
和protected
的成员变量和成员方法在派生类中变成private
的。
需要注意的是:基类中
private
的成员变量在派生类中是无法被直接访问到的。
class Base { |
Example 1: Cpp public inheritance
|
这里我们用 public inheritance
从基类 Base
继承到派生类 PublicDerived
,作为结果,在派生类中:
m_ProtectedMember
继承后仍然是protected
的m_PublicMember
继承后仍然是public
的m_PrivateMember
继承后是不可被派生类访问的,即仍然是内部可见的private
的。
在 main()
方法中,我们是无法访问类的 protected
和 private
变量的,所以就需要用 get()
方法来访问。需要注意的是,GetPrivateMember()
方法是定义在基类 Base
中的,因为只有在 Base
中才访问的到 private
的变量,GetProtectedMember()
则是定义在派生类 GetProtectedMember()
中的
Accessibility | private members | protected members | public members |
---|---|---|---|
Base Class | Yes | Yes | Yes |
Derived Class | No | Yes | Yes |
Example 2: Cpp protected Inheritance
|
在这里,我们在 protected inheritance
下从 Base
派生了 ProtectedDerived
。 因此,在 ProtectedDerived
中: m_ProtectedMember
、m_PublicMember
和 GetPrivateMember()
被继承为 protected
的。 m_PrivateMember
不可访问,因为它在 Base
中是私有的。 众所周知,protected
的成员变量和成员方法不能从类外部直接访问。因此,我们不能使用 ProtectedDerived
中的 GetPrivateMember()
方法。 这也是我们需要在 ProtectedDerived
中创建 GetPublicMember()
方法以访问 m_PublicMember
变量的原因。
Accessibility | private members | protected members | public members |
---|---|---|---|
Base Class | Yes | Yes | Yes |
Derived Class | No | Yes | Yes (inherited as protected variables) |
Example 3: Cpp private Inheritance
|
在这里,我们以 private inheritance
从 Base
派生出 PrivateDerived
。 因此,在 PrivateDerived
中: m_ProtectedMember
、m_PublicMember
和 GetPrivateMember()
作为私有的成员变量和成员方法继承。 m_PrivateMember
不可访问,因为它在 Base
中是私有的。 众所周知,私有成员不能从类外部直接访问。因此,我们不能使用 PrivateDerived
的 GetPrivateMember
。 这也是我们需要在 PrivateDerived
中创建 GetPublicMember()
函数以访问 m_PublicMember
变量的原因。
Accessibility | private members | protected members | public members |
---|---|---|---|
Base Class | Yes | Yes | Yes |
Derived Class | No | Yes (inherited as private variables) | Yes (inherited as private variables) |
Switch In Cpp
作为一款追求性能的语言,Cpp的编译器会通过不同的方式来提升 switch
语句的性能,方式主要有以下的三种:
- 逐条件判断(类似
if-else
) - 跳转表实现
- 二分查找法(类似二叉搜索树)
逐条件判断
这种方法主要是用于 switch-case
比较少的场景,即使使用逐个条件判断也不会导致大量时间和空间的浪费,比如说下面这段代码:
|
对应的汇编如下:
movl -4(%rbp), %eax |
其实就是逐一比较,如果条件满足的话就跳转到对应的代码段执行。
跳转表实现法
该方法是用空间换时间的一种典型应用,在编译 switch
语句的时候,会生成一张跳转表,跳转表存放着各个 case
语句指令快的位置,程序运行时就判断 switch
条件的值,然后把该条件值作为跳转表的偏移量去找到对应 case
语句的指令地址。这种情况适用于 case
比较多但是相差的数值不大的情况。
|
对应的汇编代码如下:
movl -4(%rbp), %eax |
一般情况下会先将 switch
的变量(这里是 a
) 先减去最小的 case
值,然后再与 最大和最小的 case
值的差值比较,如果大于的话说明是 default
,然后再根据值跳转到对应的地址。
二分查找法
如果case值较多且分布极其离散的话,如果采用逐条件判断的话,时间效率会很低,如果采用跳转表方法的话,跳转表占用的空间就会很大,前两种方法均会导致程序效率低。在这种情况下,编译器就会采用二分查找法实现switch语句,程序编译时,编译器先将所有case值排序后按照二分查找顺序写入汇编代码,在程序执行时则采二分查找的方法在各个case值中查找条件值,如果查找到则执行对应的case语句,如果最终没有查找到则执行default语句。对于如下C++代码编译器就会采用这种二分查找法实现switch语句:
|
对应的汇编如下:
movl -4(%rbp), %eax |
实际上就是二叉搜索树,先与中间的 case
值比较,然后再一步步往叶节点走,直到找到符合的 case
值或者什么也没找到。
综上,cpp中 swtich
会根据不同的使用场景有不同的性能优化,但缺点就在于只能使用整型值作为 case
的量。
Reference
- TheCherno
- 南京大学软件学院2022年春季学期C++高级程序设计
- GeeksforGeeks Cpp
- cppreference
- cplusplus