"勿在浮沙筑高台。"
"胸中自有丘壑。"
"孔雀东南飞,因为西北有高楼。"
"使用一个东西,却不明白它的道理,不高明。"
------《侯捷语录》
笔者面对还需要学习的庞大知识体系,不禁感到压力拉满。这些天又接着学习了侯捷教授的《C++面向对象高级开发》,了解了关于C++这个语言更深层次的一些知识,同时也把本人最近遇到的相关笔面试知识点涵盖进来,供大家学习交流。

一、类及有关基础
(一)、内联函数(inline)
1、有何特点?
内联函数,编译器会将其函数调用直接替换为函数体,免去了函数调用的开销。用关键字"inline"修饰。普通函数可以先声明再使用,内联函数只能先定义后使用。
cpp
inline void print(int x);
int main() {
print(10);
return 0;
}
inline void print(int x) {}
//普通函数可以这样,但我们内联函数就是错的
2、类内定义的内联函数:在类内直接定义的成员函数会隐式的添加inline声明。如果类内声明类外定义需要手动添加inline声明。
3、编译器没有能力将很复杂的函数作为内联函数,所以所有的inline声明(包括隐式声明)对编译器来说都只是一个建议,最终需要编译器自行判断是否将此函数作为内联函数。
哪些情况编译器会忽视我们的inline建议呢?包含复杂控制结构(如循环、选择语句),递归函数。一般只适合1~5行语句的小函数体。
4、define(宏定义)与inline(内联函数)的区别
(1)宏在预处理时展开,识别所有宏定义进行简单的文本替换;内联函数在编译时展开,会被嵌入到目标代码中。
(2)内联函数是真正的函数,和普通函数调用方法一样,在调用处直接展开,避免的参数压栈操作,减少开销;宏定义编写复杂注意点较多,常需要添加括号避免歧义。
(3)宏定义只进行文本替换,不检查参数类型、语句是否正常;内联函数作为函数,会对参数类型、函数体内语句编写进行检查。
cpp
#define MUL(a, b) ((a) * (b))
//不如内联,C++推荐用内联代替
inline int mul(int a, int b){
return a * b;
}
Ps:define仅文本替换是预处理时期的事,但替换后的代码是否合法需要等到编译阶段检查。此时的错误信息指向替换后的代码。
Ps:参数压栈为什么要从右往左?为了支持可变函数参数。即printf这样的函数,确保第一个参数(格式字符串)最后被压入栈,让参数地址固定在栈顶从而访问到可变参数的个数和大小,在后面正确访问这些参数。
(二)、构造函数
1、构造函数的时机
构造函数在创建对象时自动调用。
cpp
//对象初始化的几种方式(new出指针也算初始化)
Complex c;
Complex c1(2, 1); //使用重载的有参版本构造
Complex* p = new Complex(4);
Ps:Complex c()与Complex c是两个东西!后者才是对象的初始化,无参构造只能用这个。而前者变成了一个函数声明。还有Complex()是创建一个临时对象,生命在下一行就结束了。
2、构造函数的列表初始化
形式如下,可以按成员变量声明的顺序进行初始化。
cpp
class B : public A{
B() : d(), c() { //列表初始化,d和c执行默认构造
cout << "B" << endl;
}
C c;
D d;
};
成员对象总是在构造函数体执行前被初始化。也就是说,如果我们不提供列表初始化,编译器就会使用各个成员的默认初始化或默认构造函数,然后再执行构造函数的函数体。
如果在函数体内进行"初始化"了,那就不是初始化,而是赋值。比如下面的代码就会调用两次C、D的构造函数,一次构造体前的默认构造,一次C()与D()的临时创建调用。

Q:为什么C++是采用成员声明的顺序初始化而不采用列表初始化的顺序呢初始化呢?
A:首先,列表初始化不是必须的。更重要的一点是我们必须保证成员初始顺序是永远固定的,以满足一些成员初始化时的依赖关系。如果采用列表初始化的顺序,一旦出现构造函数重载的情况,可能导致成员变量初始化在不同场景下初始化顺序不同,破坏了成员间依赖关系而导致难以维护。故采用一旦写好便不可改变的声明顺序更优。
3、构造函数的重载
形同普通的重载一样,通过参数类型来改变。但注意类似以下的情况,编译器在调用无参构造时不知道使用哪一版本,故不允许共存。
cpp
Complex(double r = 0, double i = 0){}
Complex() : r(0), i(0){}
Ps:通常构造函数都放在public里,但单例模式会放在private里,这样一来就强制保证类的实例只有一个(static的那一个,详情可见往期文章),从而实现"单例"。
4、explicit关键字
用于防止构造函数进行隐式类型转换,只能显式调用构造函数。
cpp
class String {
public:
String(const char* str) {}
explicit MyString(int size) {}
};
int main{
String s1 = "蒙面泳装团"; //隐式转换为String类然后拷贝
String s2 = 10; //错误!!!隐式转换被抑制了......
String s3(10); //显式转换没问题
return 0;
}
如上所示,当企图通过"="进行初始化类时,通常会先把等号右边隐式转换为一个类型的临时对象,然后用这个临时类进行拷贝。explicit就是抑制了构造这个临时对象的过程。
(三)、类的成员
1、常成员函数
侯捷老师再三强调,能加const就一定加const。不然当常量对象调用时无法正常使用。
cpp
void func() const {
cout << "我爱小鸟游星野" << endl;
}
我在这里也简单列一张表。当成员函数的const版本与non-const版本同时存在时,常量对象只能调用const版本,非常量对象只能调用non-const版本,泾渭分明。

2、值传递与引用传递
所有的参数能传引用就传引用。引用的底层是指针,通常比值占用更少内存,避免了值的拷贝开销,时间更快。大部分使用const T&,const为了防止值被不小心改变。
返回值也是,T& func()可以有效减少开销。
Ps:虽然引用底层是指针,但其对外的所有操作都是按照其引用的对象来的,比如对sizeof查看r的大小,实际上返回的就是x的大小,但它实际上就是个指针,只有四个字节,被刻意隐藏了起来。
cpp
int x = 0;
int& r = x;
int x2 = 5;
r = x2; //错!!! 引用一旦初始化便不可代表其他值
cout << sizeof(r) << endl; //输出的全是x的信息
有个关于const指针的经典问题,const放在*前面就是修饰指向的变量,无法通过指针修改值,const放在*后面就是修饰指针,无法改变指针指向,但仍可以修改值。但是对于引用只有const int&这一种写法,表示无法通过这个引用来修改值。
3、类的静态成员
静态成员变量:需要类内声明,类外定义。因为属于类本身,容易造成重复定义。不同于非静态成员变量,静态成员变量的声明与定义分离,必须显式定义才能使用。如果不在类外显式定义就只算是声明。
const或constexpr修饰的静态成员变量(const static int m = 0)可以在类内直接定义,因为其被认定为编译期常量,编译器会保证其唯一性。因为const 变量必须在声明时初始化。但唯一的例外是const的类内非静态成员变量不能再声明时初始化,只能列表初始化(你细品)。
C++17以后,为了简化静态成员的定义,inline static成员(inline static int m = 0)也可以之间再类定义中初始化了。
静态成员函数:没有this指针,只能访问类的静态成员。有两种调用方式:通过对象实例调用或是类名直接调用(::)。
关于继承:静态成员函数属于类本身,不能被派生类重写,不存在多态性。在继承关系中,派生类共享基类的静态成员,所以整个继承体系中只有基类静态成员的那一份。如果派生类定义了一个与基类同名的静态成员,此时会隐藏基类的同名成员,他们彼此独立互不影响。
静态成员的优势:提供了比全局变量或函数更好的封装。静态成员存在于类作用域,避免对全局命名空间的污染,同样遵守三大访问权限的规则,控制访问权限。
单例模式便是使用静态成员的好手,请点击这里查看是怎么实现的!
4、成员模板
上次STL中讲到了类模板与函数模板,现在就来简单介绍一下成员模板吧(其实就是函数模板的一种)。
cpp
template<class T1, class T2>
class pair{
...
template<class U1, class U2>
pair(const pair<U1, U2>& p) :
first(p.first), second(p.second){}
};
通常是为了让类的成员能够接受任意类型的参数,增强灵活性。比如上面的代码就是为了直接让任意U1和U2类转型为T1和T2类。非模板类和模板类都可以定义成员模板。
5、模板模板参数
上次讲STL的时候没有讲的的一些模板相关,这次就都补上了。
模板模板参数(template template parameter),模板的一个参数本身作为一个模板,这种用法。
cpp
template<typename T,
template<typename T>
class Container
>
class A{
Container<T> c; //类内为容器模板提供T参数
};
template<typename T>
using Lst = list<T, allocator<T>>;//有第二参数就用不了,默认也不行
//这样用
A<string, Lst> mylst;
上述的代码,最终就是mylst对象内的容器是一个Lst<string>类型,其类型的确定是在类内发生的。
Q:那template<class T, class Sequence = deque<T>>算不算模板模板参数呢?
A:不算,模板的第二个参数是默认的,当T确定好后,第二参数就确定好了,与类内的逻辑无关。就算传入第二参数,只能"list<int>"这样传,类型是已经确定好了的。
5、可变参数模板
C++11提供,常用于递归操作。语法就是"typename..."这样。
cpp
template<typename T, typename... Types>
void print(const T& firstArg, const Types&... args){//语法要求
cout << firstArg << endl;
print(args...);//传入后面一大包,自动分解为两包,注意语法:展开包
}
最终会回到无参版本的print结束递归。
(四)、操作符重载
1、成员的操作符重载
所有成员函数都自动隐含一个this指针,作为真正的第一个参数,但是我们不必也不被允许写这个this。
成员的操作符重载都会作用在前面的对象上。以+=为例,返回值必须为&,保证多个+=出现时的传递性。

2、非成员的操作符重载
即在全局的重载,大部分的操作符重载都可以选择全局或者类内两种方式,当操作符左侧对象为该对象类时优先调用类内操作符版本。
inline A operator+(const A& x, const A& y) { return A(); }
但类似<<的类型只能在全局重载。要是自定义了重载版本,顺序就成了a<<cout了(在类内只能作用在左边)。而且cout这种类型只认识内置类型,如下。
cpp
ostream& operator<<(ostream& os, const A& a) {
return os << '(' << "星野天下第一" << ')';
}
为什么返回&?和+=一样,<<常常连续使用,要以一个个改变。
3、重载*与->
在我们定义"像指针的类(pointer-like class)"时,不得不重载*和->来实现功能。正如STL篇中有关迭代器的描述一样。
cpp
class A{
T* px; //一般这种类内部都维护一个内置指针
T& operator*() const{
return *px;
}
T* operator->() const{
return px;
}
};
发现了吗?当重载*的时候,返回就是px解引用的值,也就是说调用处的"*a"会被一个值代替。而重载->的时候,返回的还是一个指针,也就是说调用处的"a->"被一个"px"代替了?似乎->被消耗掉了,导致"a->func"变成了"px func()",好奇怪。
其实不然,在所有操作符中"->"算很特别的存在,它的使用不会消耗操作符"->"本身,而是继续作用下去,从而保证语法的合理性。
既然说到了"pointer-like class",就顺道说说"function-like class"吧。其实就是仿函数。任何类内部重载了()就是仿函数,不重载就无法调用()这个函数调用运算符。
(五)、友元
1、友元函数
可以将特定的函数在类中用friend关键字声明为友元,这样该函数就可以直接访问该类内的私有成员了。
2、友元类
声明为友元后该类就可以访问到此类的私有成员了。

相同类型的对象之间互为友元,所以在传入同类型对象时可以直接使用该对象的私有成员。
cpp
int haha(const D& p) {
return p.a_gun;
}
3、重载运算符声明为友元
一个类友元函数注定不是该类的成员函数,但其可以访问类的私有成员与保护成员,所以当一些需要重载的对象无法获取或修改该类的成员时,有时会用到友元函数进行重载。

但是,=、[]、()、->这四个都只能重载为类的非静态成员函数,不允许通过全局函数或友元函数实现。这四个绝对属于对象本身,且可能会修改自身状态,强制要求左操作数必须为调用者对象,从而规定不能成为外部操作。
(六)、拷贝构造、拷贝赋值、析构
这三个常常一起出现。通常是类内带有指针的情况,必须定义拷贝构造与拷贝赋值防止浅拷贝,定义析构函数来释放内存。
cpp
C(const C& c);
C& operator=(const C& c);
~C();
1、如果采用默认合成版本的拷贝,会出现浅拷贝的情况。比如A有一个指向m的指针,此时创建B调用拷贝构造或赋值时,B只是创建一个同一指向m的指针,二者指向一个位置,容易造成使用置空指针。
2、注意"="在不同情况下调用的方法不一样。赋值总是在已有对象上进行的。
cpp
//这是用拷贝构造
string s1;
string s2(s1);
string s3 = s1;
//这是拷贝赋值
string s4;
s4 = s1;
注意在编写拷贝赋值时,要检测自我赋值的情况。一般的赋值是将自己的指针释放掉再重新创造,但自我复制在创造前指针已经被杀掉了,就会出现错误。
Q:Sale_data::Sale_data(Sale_data rhs);这样的声明为什么是非法的。
A:《C++Primer》p443。编译器是通过函数名与第一个参数的类型来判断是否为拷贝构造函数的,这个过程不区分是否为引用。一旦判断为拷贝构造函数,就不会生成默认的拷贝构造了。同样的,引用版本和非引用版本不在重载的考虑范围,也就是说Sale_data(Sale_data& rhs)与Sale_data(Sale_data rhs)本身就是无法共存。但是没有&,就成了值传递,而值传递又要调用拷贝构造函数,于是就会陷入无限递归。故C++明确要求拷贝构造必须接受引用类型。
不过参数的const和非const版本可以共存,函数调用是通过函数签名来定位的,这个前面中不包含&,却包含参数的const,然后编译器会按照最佳匹配规则选择重载版本(和上文const成员选用很像啊)。
3、深拷贝与浅拷贝的区别是什么?
核心区别在于是否赋值指针指向的数据。浅拷贝只复制指针,不复制指向,会导致新旧对象共享同一份数据,可能造成重复释放。深拷贝创建新的内存空间,复制指针指向的数据,新旧对象完全独立互不影响。
二、内存管理
(一)、C++内存分区
栈:生命周期只存在于作用域内的一块内存空间。
存放函数的局部变量、函数参数、返回地址等,编译器自动分配释放,由高地址向低地址增长,是一块连续的空间。栈的大小是固定的,不过系统提供参数我们可以自定义大小。
堆:是一块全局的空间,程序可以动态分配。
由malloc动态申请的内存空间,存至手动释放结束。由低地址向高地址增长。如果程序结束还没有释放,操作系统会自动回收。
全局区:存储全局变量、静态变量和未初始化的全局/静态变量。C语言中,未初始化的放在.BSS段,初始化的放在.data段,C++中不再区分。
常量区:存储常量数据,即程序中不可修改的数据。程序运行结束自动释放。
代码区:存放程序执行代码,只读不允许修改,用于执行。函数指针能够指向函数的地址,这函数的地址就在代码段里。
(二)、内存管理的各个等级
C++的内存分配分为四个层面。每个层面最终的底层都是malloc与free。

四个层面分别是:malloc、new、operator new()、allocator。其中new和operator new都属于上图中的"基本工具"。这里的new指的是我们最常用的new表达式中的new,对应delete,operator new()指的是一个函数,相当于可以被重载的操作符,对应operator delete()。用法如下。
cpp
int main() {
//1、malloc
//malloc从堆内存中分配512字节的未初始化内存块,返回内存块的起始地址
//仅分配内存,不涉及任何初始化,换成类指针也一样
void* p1 = malloc(512);
free(p1);
//2、new(表达式)
//new会自动调用构造函数,后文会细说
A* p2 = new A();
delete p2;
//3、new(操作符)
//用法与malloc完全一致,底层也只是转调用而已
//但是可以编写全局或类内重载版本,后文会说
void* p3 = ::operator new(512);
::operator delete(p3);
//4、alloctor(标准库)
//allocate的参数是个数,分配5个整数的内存(这里通过临时对象调用)
//deallocate的也需要传入个数,因为是给容器用,需要考虑其动态变化
int* p4 = allocator<int>().allocate(5);
allocator<int>().deallocate(p4, 5);
return 0;
}
(三)、new/delete表达式
1、底层动作
new有两个动作:分配内存+构造函数。
cpp
void* mem = operator new(sizeof(A)); // 转调用malloc
A* p = static_cast<A*>(mem); //void*转型为对应类型
p->A::A(1, 2); //调用构造函数
注意,我们自己写这种构造函数的直接调用是不行的,但是编译器有特权,他们可以这样调用。
delete也是两个动作:析构函数+释放内存。
cpp
p->A::~A(); //调用析构函数
operator delete(p); //转调用free
Ps:正好谈到了static_cast,就乘此聊聊C++的四种强制类型转换吧(《C++Primer》p145)。
static_cast:用于非多态类型的转换,在编译期完成检查(天然安全的转换)。不包含底层const的类型都可以用static_cast。由于编译器可以确定派生类到基类的合法性,所以可以用于多态中的上行转换。
dynamic_cast:仅用于类结构的下行转换,只能用于多态类(基类有虚函数),从基类指针/引用转换为派生类指针/引用,或者同一基类的不同派生类之间的转换。
const_cast:只能用于改变运算对象底层的const。只有const_cast能改变表达式的const属性,其他的转换会引发错误。
reinterpret_cast:意为"重新解释"。最暴力的转换,在类型的二进制层面重新解释这种类型,完全忽略类型本身的含义和安全性。编译器就完成转换。
2、Array new/delete
就是new[]与delete[]。
cpp
A* p = new A[3];
delete[] p;
刚刚我们看到,new的底层代码一共是三个步骤。而new[]就只是在第一步使用new操作符与第三步调用构造函数有所不同。
cpp
void* mem = operator new(3 * sizeof(A)); // 转调用malloc
A* p = static_cast<A*>(mem); //void*转型为对应类型
for (int i = 0; i < 3; ++i) {
p[i]->A::A(); //调用构造函数
}
相当于是malloc先分配一大块能够容纳指定数量对象大小的内存,然后对每个对象从上到下依次调用构造函数(底层或许不是我这样写的,但逻辑是这样)。最终这大块内存就被已经存好数据的每一小块内存填满了。
那delete就是让每个对象依次析构了,这个顺序必须与构造逆反,从下向上。
cpp
for (int i = 2; i >= 0; --i) {
p[i]->A::~A(); //调用析构函数
}
operator delete(p); //转调用free
delete是怎么怎么知道元素个数的?new[]在分配内存时会创建一个专用于记录数组元素个数的内存块,以便于正确调用析构函数,称为Cookie。malloc本身在分配内存时也会生成一个用于管理堆内存块的Cookie,用于记录块大小等信息,这两种Cookie可能合并,也可能分开存储,取决于编译器实现。但delete[]使用的信息是new[]的Cookie。

new[]创建的对象使用delete释放会怎么样?首先先明确这是非常危险的未定义行为。delete和delete[]的唯一区别就是析构函数是调用了一个还是多个,那这种情况delete只会对第一个元素调用析构函数,其余的元素不会调用析构。造成内存泄漏、资源泄漏等风险。当然如果类型析构函数为空或者没有析构那其实是可行的,因为这种情况下依然能free正确的内存(根据malloc的Cookie)。
如果是new的delete[]呢?那更危险了。delete[]会去尝试去对象前面读取Cookie信息,但内存上根本没有有效的Cookie信息,delete[]会读取的未知信息或无意义的垃圾值。我们无法确定这个值是什么,甚至会调用无数次析构函数。
开发中需要严格遵守new配delete,new[]配delete[]的规范。
(四)、new/delete操作符
刚刚谈的都算是表达式,现在说一下operator new/delete。
这个的主要作用就是供给我们进行操作符重载,默认就是转调用malloc和free。
1、全局重载:很危险,牵一发而动全身。用侯捷教授的话说就是没有足够的功力是很难实现的。会影响到标准库所有动态内存分配的底层机制,甚至会与其他内存管理机制冲突(智能指针等)。
cpp
inline void* operator new(size_t size);
inline void* operator delete(void* p);
2、类内重载:这是最常见的情况。如果不显式使用全局的new表达式,会优先使用new的类内重载版本。写法和上面一样,C++比较体贴,不需要我们重载时显示指定为静态,让new与delete的类内重载版本自动成为静态,这样才能创建对象。
cpp
Foo* p = ::new Foo(17); //使用::强制全局new,绕过类内重载
3、Placement new:重载时参数列表是可以不同的。如果new在重载时除了size_t类型的第一参数也使用了其他的参数,就叫做Placement new。用法如下。
cpp
inline void* operator new(size_t, long, char);
Foo* pf = new(300, 'c')Foo; //传入除size_t以外的参数,Foo后括号可省略
重载new操作符的第一个参数必须为size_t,不允许为其他类型。Placement delete作用有限,通常只负责处理异常。
(五)、malloc/free
程序运行使用用户空间内存,以Linux系统为例,用户空间从低到高分为六种不同的内存段(区别于内存分区,这里多出了较为特殊的内存映射段)。

各个段的作用在上文已经提及。但这个位于栈与堆之间的文件映射段,是用来映射动态库、共享内存的,从低地址向上增长。其中,堆和文件映射段的内存是动态分配的。
1、malloc分配内存
第一种方式是通过brk()系统调用(在Linux系统可以直接使用)在堆分配内存。brk()函数能够将堆顶指针(位于堆的上界限)向高地址移动获得新的内存空间。
第二种方式是通过mmap()系统调用以"私有匿名映射"的方式在文件映射区分配一块内存。
malloc源码将小于128kb的内存通过brk()分配,大于128kb的内通过mmap()分配(GNUC)。
2、free释放内存
对于malloc通过brk()在堆上申请的内存,free释放内存后堆内存仍然存在,并没有归还给操作系统,放进了malloc的内存池(一块维护空闲内存块的缓存)了,申请其他内存时可以直接复用,速度更快。
通过mmap()在文件映射区申请的内存,free释放便会归还给操作系统,内存真正地释放。
3、new/delete和malloc/free有什么区别
new分配内存并且调用析构函数,malloc只分配原始内存,不初始化对象。delete调用析构函数并释放内存,free只释放内存不析构。
new返回具体类型的指针,类型安全。malloc默认返回void*,需要进行显式转换。
在new分配失败时会抛出bad_alloc异常,malloc失败会返回NULL空指针。注意delete和free不会抛出异常,如果析构函数抛出异常行为是未定义的。
new分配内存编译器能自动计算大小,malloc需要手动指定字节数。
new从"自由存储区"上为对象动态分配内存,而malloc从堆上动态分配内存。自由存储区如果没有重载operator new的话就是堆,如果重载了的话,new可能不会转调用malloc而是从其他区域分配内存。
(六)、智能指针
C++提供了两种智能指针,shared_ptr和unique_ptr,还有一个名为weak_ptr的伴随类,是一种弱引用。封住于<memory>头文件中(C++11)。
1、shared_ptr 共享指针
资源可以被多个指针共享。通过p.use_count查看与p共享对象的智能指针数量。每个shared_ptr都拥有一个引用计数,记录多少个智能指针指向该共享的对象。一旦变为0,自动释放自己所管理的对象。调用release()释放资源的所有权,计数递减。
通过make_shared<T>()初始化。
cpp
shared_ptr<int> p = make_shared<int>(42);
不要混用shared_ptr与内置指针,通过内置指针初始化的智能指针,和其他智能指针的引用计数相互独立,容易造成重复释放。
底层原理:维护一个对象指针,再额外维护一个控制块,控制块中存放指向对象的指针和两个计数器:强引用计数与弱引用计数。弱引用计数专门用来记录有多少个weak_ptr指向该资源,不影响资源释放,只会影响控制块释放。
多个shared_ptr指向同一个对象时是共享一个控制块的!在第一个shared_ptr初始化时创建唯一的控制块。弱引用计数决定了强引用计数为0时需不需要释放内存块,确保weak_ptr可以正常使用。
2、unique_ptr 独占指针
独享所有权,资源智能被一个指针所占有,不能拷贝构造与赋值。但可以进行移动构造与移动赋值(move),转移所有权。p.release()放弃自身的控制权然后返回指针,并将p置空。reset()如果提供了内置指针则指向这一对象,否则置空。
只能直接初始化。
cpp
unique_ptr<int> p(new int(42));
所以unique_ptr<string> p2(p1.release())可以转移所有权。p2,reset(p1.release())也可以转移所有权。
底层原理:维护一个内置指针,禁用拷贝,支持移动。
3、weak_ptr 弱指针
指向share_ptr指向的对象,通过shared_ptr初始化,用于解决share_ptr循环引用的问题。当两个对象相互持有对方的shared_ptr造成循环引用。
weak_ptr与普通对象的生命周期一致,当最后一个weak_ptr销毁后(强引用弱引用计数都为0)才会释放控制块。
它是如何解决循环引用的问题的呢?循环引用的核心问题是共享指针的引用计数无法递减为0。而弱引用让weak_ptr指向对象但不增加引用计数,当对象没有被任何其他的强引用(shared_ptr)指向时,对象就会立即释放。本质就是让循环链中的某一环降级为弱引用,从而引用计数可以正常归零。
如何使用weak_ptr?weak_ptr作为弱引用指向的对象可能存在也可能不存在,在访问所引用的对象前必须先通过lock()转换为shared_ptr使用。若引用计数为0会返回空的shared_ptr。
cpp
if (auto locked = p.lock()) {
//对象存在
} else {
//对象已被释放
}
4、两个智能指针的区别以及使用场景
unique_ptr独占对象的所有权,禁止拷贝,只支持移动。shared_ptr共享所有权,支持拷贝和移动。
unique_ptr开销更少,内部只维护一个对象的指针,没有额外开销。shared_ptr有更新引用计数的开销,内存方面需要两个指针,分别指向对象和控制块。
unique_ptr不会出现上文循环引用的问题。
大部分情况下都可以使用unique_ptr,其性能更好,比如可以用来指向一个数组。只有真正需要多个对象访问同一资源时使用shared_ptr。
(七)、RAII机制
RAII(Resource Acquisition Is Initialization),意为资源获取即初始化,核心思想是让资源的生命周期与对象的生命周期绑定,在构造函数中获取资源,在析构函数中自动释放资源,无需手动操作。
RAII的重要性:
1、避免资源泄漏:确保资源在任何情况下都能正确释放。
2、异常安全:发生异常时,析构函数能够被调用。
3、简化代码:避免手动管理资源,更亲民。
Eg:比如说内置指针,因为没有类的封装所以不具备RAII机制,我们就需要时刻控制释放内存。而通过类包装的unique_ptr就不需要考虑何时释放资源了。
cpp
void func() {
int* ptr = new int(10); //获取资源
if (my_love == "小鸟游星野") {
return; //提前返回,完蛋,忘记 delete了,内存泄漏
}
delete ptr; //我想在最后释放资源
}
三、类之间的关系
(一)、组合 Composition
所谓面向对象其实就是在探讨类之间的关系,我们先从组合开始说起(三大关系:组合,委托,继承)。
组合就是类内部维护一个其他类,就是说另一个类型的对象作为一个类的成员存在。如下。
cpp
//具体的省略,这里以上次讲STL的容器适配器为例
class queue{
deque<T> c;
};
这里简单用UML图(丐版)表示一下。事实上只有箭头算UML......

如上图,实心菱形表示组合关系。先调用成员对象的构造,再调用自身的构造(还记得之前讲的构造函数体前初始化成员吗)。
这样构造有什么深意呢?因为整体依赖部分的完整性,类的构造函数可能依赖于成员。同理析构函数也需要整体完成清理,再清理部分。
(二)、委托 Delegation
又称通过引用实现的组合(Composition by reference),类内部维护一个指向其他类型对象的指针或引用。
cpp
class String{
stringRep* rep;
};
这里也画个图。

对于组合关系,容器和组件的生命周期是同步的,组件会随着容器的死亡而死亡。但委托不同于组合,类内只是对象的引用,生命并不同步。
这引出了一个很好的程序设计即"pImpl"(第二个字母是i,大写),指针指向实现。很多设计如策略模式,前文的智能指针都使用了这种方式。让一个类(String)对外提供接口,称为"Handle",内部指针指向的对象(StringRep)负责真正的实现,称为"Body"。这样一来拓展性很强,指针可以指向不同的实现,从而实现多种行为的切换。
当然这样可能会发生前文浅拷贝的危险,这里就不多说了。
(三)、继承 Inheritance
又称通过引用实现的组合(Composition by reference),类内部维护一个指向其他类型对象的指针或引用。
cpp
class B : public A{};
画个图。

子类里是专门有一片内存是存放父类成员,位于子类的头部,可以把这个父类区域当作是子类包含的一个对象。当使用父类指针指向子类对象时,这个指针实际上指的就是这个父类的对象。这也是为什么如果没有虚函数就无法通过父类指针访问到子类特有的成员。
(四)、复合关系
在实际开发中,我们往往遇到的都是上述三种关系的复合结构。所以我再在此画个图,展示他们的内存空间,一目了然。

继承+组合的复合结构,需要注意第一种关系下,构造函数调用的顺序即可:父类构造->成员构造->子类构造。
另一种就是委托+继承的结构。观察者模式就是很好的例子,主题中维护了一个观察者的引用列表(委托),不同的观察者都继承自一个观察者父类实现更新接口(继承)。
还有另外两种设计模式组合模式和原型模式都有着这种复合结构,笔者会在后续文章中单独谈。
(五)、虚函数与多态
1、什么是多态?
多态是面向对象三大特性之一,指同一操作作用于不同对象时能够产生不同的行为。多态分为编译时多态(静态多态)与运行时多态(动态多态)。
编译时多态:通过函数重载、模板、运算符重载实现。在编译期就确定好了调用关系。
运行时多态:虚函数继承。运行时根据对象的实际类型决定调用的行为。
Ps:重载(Overload)和重写(Override)就是在这个层面进行区分的。重写只能指虚函数的重新定义,不可用在其他地方。
2、虚函数
虚函数:通过virtual关键字修饰,表面希望子类重写这个函数。大名鼎鼎的模板方法模式就是通过虚函数可重写可不重写的特性实现的。
纯虚函数:通过在虚函数后面写"=0"来声明。表示子类必须重写这个函数。任何包含了纯虚函数的类就成为了抽象类,不能实例化,只能作为接口基类存在。
override关键字:显式声明重写虚函数。也就是说如果重写虚函数时参数、返回值、函数名什么的不匹配,编译器会告诉你有错。
cpp
class Base {
public:
virtual void print() {
cout << "星野,你在哪~";
}
};
class Derived : public Base {
public:
void print() override {
cout << "sensei,我在这~";
}
};
final关键字:既可以修饰类,也可以修饰虚函数。表示此类不能够被继承或者此虚函数不能被派生类重写。表示这个是"最终类"了,不用向下继承了。
cpp
//修饰类
class Base final{};
//修饰虚函数
class Base {
public:
virtual void func() final {};
};
虚函数的多态性必须通过指针或者引用来实现,不能直接使用对象,否则会发生对象切片,丢失派生类信息。
cpp
Animal ani = dog; //仅复制了基类部分,无法用到子类的virtual函数
Animal* ani_p = new Dog(); //通过指针实现,没问题
Animal& ani_ref = dog; //通过引用绑定,没问题
//&深藏不露的特性还在,sizeof(ani_ref)返回Animal的大小而不是dog
如果只是直接使用对象,虽然合法,但会导致多态失效,ani最终只能使用自己类内的方法,使用使用子类的行为。相当于时ani只复制了Animal大小的内存而无法考虑到派生类的成员,虚指针自动设为基类的,不然它想拷贝子类的内存里也没有。
Ps:那些不能被声明为虚函数的成员:
(1)静态成员函数:类的静态成员是类共用的。一套继承体系只有一套静态成员,见上文。
(2)构造函数:虚指针就是在构造时初始化的。要是构造函数成virtual上哪找虚指针。而且构造函数在被调用时是必然知道是哪个对象的构造,也没有多态的需求。
(3)友元函数:友元函数与该类无关,没有this指针。
(Sp)内联函数:内联函数如果成功内联,那函数体是替换过来的,算是静态的。内联函数是可以被声明为虚函数的,但如果被声明为virtual,需要其能够被动态调用,编译器就会忽略掉inline声明,作为普通函数。
Ps:析构函数为什么可以是虚函数?如果不设置为虚函数会造成什么后果?
正常的继承关系下,析构函数总是先调用派生类的析构函数再调用基类的析构函数。但如果是父类指针指向子类对象时,没有virtual析构函数的话,delete指针只会看指针的数据类型,而不是赋值的对象。这样一来就会只执行父类的析构而错过了子类的析构,造成内存泄漏(仅释放了子类中父类的那块空间)。
3、虚表与虚指针
又叫虚函数表与虚表指针。
虚函数的实现机制:虚函数是通过虚函数表来实现的。各个虚函数的地址保存在虚函数表中,拥有虚函数的对象会维护一个虚指针指向虚函数表。在调用虚函数时,通过虚指针来访问对应的虚函数表,通过指针偏移调用对应的虚函数。
所以带虚函数的类内存占用会比普通的多4个字节。

虚函数调用的机制:每个虚函数的类在编译阶段都会生成独立的虚函数表,此时基类的虚函数表存放基类自身的虚函数地址,派生类先拷贝一份基类虚表,然后用自身重写的虚函数地址覆盖原来基类的地址。如果派生类有新增虚函数,添加他们的地址。在运行时,对象的虚指针各自指向对应的虚表,根据调用偏移到正确位置。虚指针存放在对象内存空间中最前面的位置,降低寻址开销,保证能够正确取到虚函数的偏移量。
Ps:虚函数表与类绑定,虚表指针和对象绑定,二者是分离的。也就是说不同的对象维护各自的虚指针,但这些虚指针都指向同一份虚函数表。
Ps:构造函数的执行可以分为两个步骤:初始化基类子对象与成员变量+执行函数体。vptr的初始化就是在第一个步骤(初始化成员变量)里的,所以前文中讲构造函数如果是虚函数的话就无法被通过vptr调用。
小结
以上就是有关C++面向对象的全部内容了。虽说还有C++11有关的知识没学,不过笔者决定先放下C++一小段时间,来攻读一下操作系统八股。如有错误或是漏洞还请指出!欢迎各位讨论交流。
那么晚安喵~

参考列表:
小林Coding:malloc是如何分配内存的?(https://xiaolincoding.com/)