本人志在持续更新计算机系统、计算机网络、C++语言的核心知识点的系列合集,以易懂、全面的方式讲解底层知识。对于正在准备面试八股的朋友来说,本系列涵盖了本人面试中遇到的所有考点以及许多相关拓展知识,读完后能帮助你从容面对大部分面试拷打;对于想要深入学习计算机知识的朋友来说,本系列比较系统地介绍了操作系统和网络等重点内容,也举了不少例子,大大有助于你从底层的视角去理解计算机系统。
先说明,本系列恐怕不是计算机小白或是想速通期末的朋友们的目标,它需要一定系统和语言基础,也并不是面向教材和考试要求去讲解,所以更适合那些实操过代码、了解一些计算机系统知识、并且想要深入底层和扎实基础的朋友们去耐心学习。如果你是这样的人,欢迎阅读该系列文章,并分享自己的理解或提出文章中的模糊、错误的地方(不排除有)。
想要阅读系列中其他内容或想要持续关注本系列更新可移步:https://github.com/feiyangyang11/Cpp-Core-CS-Interview-Guide.git。
C++ 语法基础与拓展
本篇既讲述了一些基本的 C++ 语法,又从内存布局的视角拓展了一些语法的底层机制
类型与表达式
C++ 类型决定一个实体/表达式在 C++ 语言中的语义,表达式是一段可以被求值的代码
它们是 C++ 大厦的基石,同时有着丰富的含义,非常值得作为我们深入认识 C++ 的第一步
C++ 基础类型
主要有 void、空类型、整型、浮点型三类,不同类型占用不同大小的内存、有着不同的计算方式
除了基础类型外,程序员还可以自己定义类型,这里不展开,后面会讲
fundamental types//大小主要以64位系统为基准
├── void//表示无具体类型
│
├── nullptr_t
│
├── integral types
│ ├── bool//一字节
│ ├── char / signed char / unsigned char//一字节
│ ├── wchar_t//一字节
│ ├── char8_t//一字节
│ ├── char16_t//二字节
│ ├── char32_t//四字节
│ └── short / int / long / long long //二字节~八字节
│
└── floating–point types
├── float//四字节
├── double//八字节
└── long double
表达式
表达式是一段可以被求值的代码
比如:
10
a
a + b//计算表达式
func()//函数调用表达式
a = 10//赋值表达式,表达式的值就是 a 被赋值后的值
表达式的类型
表达式同样具有类型,它的类型取决于表达式组成成员的类型
比如 a+b,如果 a 和 b 类型不同,C++ 会优先考虑转换类型,将表达式中所有子表达式先转换成同一类型,转换规则是小类型适配大类型。若 a 是 int(4字节),b 是 double(8字节),就会把 a 转换成 double,然后整个表达式的结果类型就是 double
隐式类型转换
表达式存在隐式类型转换,有一些常见的特殊表现如下:
整数提升:char + char 或 short + short,会先将两个数都变成 int 后再相加,结果也是 int
signed / unsigned 混合:对 signed 和 unsigned 进行比较运算时,signed 会被转换成 unsigned int,可能导致值变大,结果错误
size():vector、string 等的 size()函数返回的都是无符号整型,这点要注意
表达式的性质
表达式除了有类型,还有性质。主要分为左值、右值
类型相同,不代表表达式性质相同
- 左值指的是能被稳定取地址、生命周期确定、有名字的表达式,比如 int x
- 右值指的是不能被稳定取地址、生命周期不确定、匿名的表达式,比如 x + 1、10、new object()
左值、右值又可以被细分为一下几种
expression
├── glvalue//左值
│ ├── lvalue//纯正的左值。能被稳定取地址、生命周期确定、有名字,我们代码中定义的变量 int x; 就属于这种
│ └── xvalue//将亡值。也能被稳定取地址、生命周期确定、有名字,但是允许资源被转移出去,典型将亡值如 std::move(x)
│
└── rvalue//右值
├── xvalue//将亡值。
└── prvalue//用来产生值/初始化对象,如 42、a + b
左值、右值常见的作用就是搭配引用等进行资源的转移和复用,后面再详细展开
指针
指针是一个栈上的变量,它保存另一个对象的内存地址
如int* p=&a;表示变量 p 保存了变量 a 的内存地址(栈上或堆上)
因此通过指针变量可以访问它指向的变量
64 位环境中,指针变量本身一般是占 8 字节大小
解引用
*是解引用符号,*p表示指针指向的对象本身
或者理解成指向的那片内存地址,修改*p就表示直接修改那片内存中的数据,自然就修改了该变量
指针变量的类型描述了“这个地址所指向对象的类型”,用于限制访问这片内存时怎么解释这片内存
int*p=&a;表示用*p访问a所在内存时,将它解释为 int 类型,读 4 个字节
悬空指针与空指针
悬空指针,意思是指针变量没有绑定有效的地址,或绑定了对象地址后对象被销毁,此时指针变量保存的就是垃圾值
空指针,nullptr,值为 0,是进程虚拟地址的起址,禁止访问,解引用会报错
引用
引用是已有对象的别名。它不在内存上创建新的值对象,而是让另一个名字绑定到原对象,如下:
a ─┐
├── 同一个 int 对象
ref ─┘
和指针的实际意义差不多,本质都是访问同一片内存区域,不过访问时不需要解引用
左值引用T&
创建一个引用变量,绑定到一个左值表达式
int a = 10;
int& ref = a;
注意:
左值引用不能绑定到右值,如int& ref = 10;,但 const T& 可以绑定右值(这种情况下会发生生命周期延长)
可以用常量左值引用绑定到左值,如const int& ref = a;,表示以只读方式引用对象
右值引用T&&
创建一个引用变量,绑定到一个右值表达式
int&& ref = 10;
右值引用主要服务于移动语义、完美转发,后面细讲
引用折叠与完美转发
C++ 规定了一套规则,用来处理“引用和引用叠在一起”的情况,因为**模板类型推导、typedef、using 等场景下,编译器内部可能组合出这种“引用的引用”**这种情况。规定两层引用碰到一起,最后应该变成什么,这就是引用折叠
模板推导/别名展开后出现引用的引用时,规则是:
T& & → T&
T& && → T&
T&& & → T&
T&& && → T&&
//只要有一个是左值引用,折叠后就是左值引用
//只有两个都是右值引用,折叠后才是右值引用
以 std::move 为例讲解
template<class T>
constexpr std::remove_reference_t<T>&& move(T&& t) noexcept {
return static_cast<std::remove_reference_t<T>&&>(t);
}
//假设外部传入的是 int x;它的类型是 int,类别是左值
//参数进入 move,因为把左值传给转发引用 T&&,所以模板推导得到 T = int&
//根据引用折叠规则把 T&& & 折叠成 T&,所以可知此时 t 类型是左值引用 int&,类别是左值
//remove_reference_t 将引用去掉,得到 int 类型,然后此处就变成了 return static_cast<int&&>(t);
//也就是将 t 强转成了右值引用 int&& 类型,所以 move 返回的就是一个右值引用
什么是完美转发?就是把一个参数“原封不动地”继续传给下一个函数,既保留它的类型,也保留它是左值引用还是右值引用,依靠的是模板类型推导 + 引用折叠 + std::forward,比如:
//写一个包装函数
template<class T>
void Wrapper(T&& value)
//由于引用折叠的存在,保证了传入的 value 如果原来是左值那在这里是左值引用,如果原来是右值在这里是右值引用
{
Func(std::forward<T>(value));
}
//但是 value 本身是个左值,所以再往下直接 Func(value),那么在下一层参数就一定会被推导为左值引用,这不符合完美转发
//所以引入 std::forward<T>(value)。它内部做的是,根据 T 去还原引用状态
//这里以外部调用 ① std::string s;Wrapper(s); 和 ② Wrapper(std::string("hello")); 为例,forward 的源码如下:
template<class T>
T&& forward(std::remove_reference_t<T>& value)
{
return static_cast<T&&>(value);
}
//1. 如果 value 是左值,那么推导为 T = std::string&
//在 forward 内 value 被强转为 std::string& &&,折叠为 std::string&,即返回一个左值引用
//2. 如果 value 是右值,那么推导为 T = std::string
//在 forward 内 value 被强转为 std::string&&,即返回一个右值引用
//那么这样传给 func 的参数的引用性质就没变,达成了完美转发
和指针的区别
引用必须初始化。因为引用的意义就是某个对象的别名,所以定义时一定要绑定某个对象,不能悬空
引用绑定以后不能改绑,这是引用和指针非常重要的区别
引用不能为空
引用变量本身不拥有独立内存,但指针变量通常自己拥有 8 字节大小内存。编译器优化时可能将引用变量直接替换成被引用对象本身
类与对象
类:程序员自定义的一种类型,用来描述一类对象有哪些数据,以及能进行哪些操作。
对象:某个类实际创建出来的一个具体实例,它真正占用内存,并拥有自己的成员数据
this 指针
每个对象被创建时会带上一个 this 指针,表示对象内存起址。每个对象都有自己的 this 指针
调用成员函数或访问成员变量时,都是通过 this 指针访问的
//成员函数中:
hp = value;
//可以理解为:
this->hp = value;
Player a;
//外部调用:
a.SetHP(100);
//可以理解为:
Player::SetHP(&a, 100);
class 和 struct
唯一区别是,class 成员默认是 protected 权限,struct 成员默认是 public 权限
const 成员函数
成员函数加 const 修饰,会限制修改对象的成员变量,如:
class Player{
public:
int GetHP() const{//该函数内禁止修改成员变量 hp
return hp;
}
private:
int hp;
};
普通成员函数中的 this 可以理解为 Player* const this,表示不可改变 this 的指向
而 const 成员函数中的 this 是 const Player* const this,表示既不可改变 this 的指向,也不可改变 this 指向的变量的值
对象的内存对齐
类成员的不同排布顺序,会影响对象的内存大小,因为要遵循内存对齐原则:
//顺序1:sizeof(A) == 12
class A
{
public:
char c;
int x;
char d;
};
//顺序2:sizeof(A) == 8
class A
{
public:
int x;
char c;
char d;
};
为什么会产生这样的差异?基于上述两例子分析:
类需要内存对齐,其对齐要求通常等于其成员中最大的对齐要求
- int 要求 4 字节对齐,表示要求任何一个 int 变量的起址 % 4 = 0
- char 要求 1 字节对齐,表示要求任何一个 char变量的起址 % 4 = 0
所以对于顺序 1,在定义A a;时会填充字节以对齐:
offset 0
┌─────────────┐
│ char c │ 1
├─────────────┤
│ padding │ 3
├─────────────┤
│ int x │ 4
└─────────────┘
│ char d │ 1
├─────────────┤
│ padding │ 3
offset 12
填充了两处:
空类对象的内存大小
空类对象的内存占用不为 0,而是 1,也就是:
class Empty
{
};
sizeof(Empty) == 1
原因是Empty a 和 Empty b 是两个独立对象,需要拥有各自的对象身份,因此实现一般需要让空对象也占据至少一定存储
构造 / 赋值 / 析构
它们对应对象的三种状态:
- 构造:对象怎么出生
- 赋值:已经存在的对象怎么更新
- 析构:对象怎么死亡
如果定义一个空类,编译器隐式声明默认构造函数、拷贝 / 移动构造函数、拷贝 / 移动赋值函数、析构函数
如果类显式定义了构造函数,编译器隐式声明拷贝 / 移动构造函数、拷贝 / 移动赋值函数、析构函数(抑制默认构造)
如果类显式定义了析构函数,编译器隐式声明默认构造函数、拷贝构造函数、拷贝赋值函数(抑制移动)
如果类显式定义了拷贝构造函数,编译器隐式声明拷贝赋值函数、析构函数(抑制移动)
如果类显式定义了移动构造函数,编译器隐式声明析构函数(抑制默认构造和拷贝)
如果类显式定义了拷贝赋值函数,编译器隐式声明默认构造函数、拷贝构造函数、析构函数
如果类显式定义了移动赋值函数,编译器隐式声明默认构造函数、析构函数
注意,编译器隐式声明的拷贝是浅拷贝,也就是直接拷贝值。如果成员是指针,且析构有free / delete逻辑,那么浅拷贝可能导致两个对象的指针值相同,在析构时产生重复释放——报错
所以在成员为指针这样资源的情况下,应该手动定义拷贝赋值 / 构造,实现深拷贝逻辑——即拷贝时申请新的一片内存区域和一个新的指针,把被拷贝对象指向的内存区域中的值拷贝到新内存区域,相当于创建一个新的副本
构造
创建对象时自动调用,无论是栈上创建MyClass c;还是堆上创建MyClass* c = new MyClass()
分为:默认构造、(有参构造)、拷贝构造、移动构造
- 默认构造:没有任何参数,默认初始化对象以及成员,如MyClass c;
- 有参构造:带有参数,根据传入参数初始化对象以及成员,如MyClass c(a,b);
- 拷贝构造:传入同类型的左值对象,将它的成员拷贝到自身成员,如MyClass c(c2);
- 移动构造:传入同类型的右值对象,将它的成员转移 / 拷贝到自身成员,如MyClass c(std::move(c2));
赋值
将对象赋值给已存在对象时调用,分为:拷贝赋值、移动赋值
- 拷贝赋值:将传入的同类型左值对象的成员拷贝到自身成员,如c = c2;
- 移动赋值:将传入的同类型右值对象的成员拷贝到自身成员,如c = std::move(c2);
析构
对象生命周期结束时自动调用,也可主动调用以销毁对象,如c.~MyClass();
在函数内部进行资源释放,比如当成员变量为有效的指针时,析构中应free / delete
移动语义
移动语义的意思是让新对象尽量接管旧对象内部可转移的资源,避免昂贵拷贝
什么是可转移资源?
比如对象 A 有指针成员,指向一大片内存区域。不希望拷贝这片内存中的数据,而是通过拷贝指针值的方式来转移资源
这样就与拷贝中的深拷贝原则相反了。可以理解为拷贝依赖深拷贝,而移动依赖浅拷贝
原因是,移动发生时,被移动的对象被认为是即将弃用的,所以不应再复制一份它的副本出来,而是直接拿走它的资源
移动语义的基础:右值
当对一个对象进行构造或赋值时,如果传入的参数是右值引用,那么就会触发移动构造 / 赋值
示例:
Class A{
A(){}
~A(){}
A(A&& other){
p = other.p;//指针值浅拷贝
other.p = nullptr;//将旧对象的指针资源标记为无效
}
char* p;
}
A a2;
A a1 = std::move(a2);//声明为右值,触发移动构造
std::move没有移动资源,它做的是返回一个对传入对象的右值引用,可以理解为static_cast<A&&>(a);,前面有细讲
然后 a1 知道了传入的 a2 是右值,触发了移动构造函数,将资源所有权从 a2 转移到自身
这种方式和浅拷贝的区别是:浅拷贝只做了值拷贝,但移动语义还置空了旧对象的资源,保证资源只有一份
移动语义退化
当传入移动构造 / 赋值函数的参数是int、char、long这种基本类型时,移动会退化为拷贝
因为这种类型的资源就是它的值,而不像指针变量它的指针值和资源不是一体的,
继承
继承是面向对象的一大特性,就是让一个新类在已有类的基础上继续扩展,使派生类自动拥有基类的一部分数据和行为
基本示例:
class Base
{
public:
int x;
void Func(){}
};
class Derived : public Base
{
public:
//还继承了父类的 x 和 Func(),且继承于父类的成员变量会存放在子类对象的前半部分,后半部分才是子类成员,以支持类型转换
int y;
};
//可以理解为:
Derived d
┌─────────────────┐
│ Base 子对象 │
│ ┌─────────────┐ │
│ │ int a │ │
│ └─────────────┘ │
├─────────────────┤
│ int b │
└─────────────────┘
权限继承
| public 继承 | public | protected | 不可访问 | 权限不变 |
| protected 继承 | protected | protected | 不可访问 | 公开变保护 |
| private 继承 | private | private | 不可访问 |
总结:父类私有成员在子类中不可访问,保护权限优先级 > 公开优先级
class 默认权限是 private,默认继承权限是 private;struct 默认权限是 public,默认继承权限是 public
隐藏和重写
隐藏:子类定义了与父类函数重名但参数不同的函数,父类函数被隐藏
class Base
{
public:
void Func(int);
};
class Derived : public Base
{
public:
void Func(double);
};
重写:子类继承后定义了与父类虚函数重名且参数相同的函数,后面虚函数中细讲
构造与析构顺序
创建子类对象时:
先构造父类,再构造子类
先分配子类对象大小的内存。在这片内存的前半部分使用父类对象大小的空间,先构造父类成员,然后执行父类构造函数;再在父类成员后构造子类成员,执行子类的构造函数
所以Base* d = new Derived();是合法的,因为来自父类的成员就是放在对象靠前的那部分内存,可以正常访问,这叫向上转型
而Derived* b = new Base();通常不行,因为 new base 出来的缺少后半的子类成员部分
销毁子类对象时:
先调用子类析构函数,子类析构中隐式调用父类析构函数,对象内存是否释放取决于创建方式
多态
核心意义:同一个接口,面对不同实际对象时,表现出不同的行为
虚函数
虚函数就是在类中被 virtual 修饰的成员函数:
class Base
{
public:
virtual void Func()
{
std::cout << "Base\\n";
}
};
存在虚函数的类,在编译时会生成一张虚函数表,里面存放着这个类的所有虚函数的函数指针,指针指向虚函数实际实现代码
虚函数表存放在 .rodata,虚函数代码在 .text
创建类对象实例时,类对象会隐式增加一个虚表指针 vptr(8 字节),指向类的虚函数表
vptr 一般在对象内存的起始位置。这种情况下,访问对象成员就要多 8 字节偏移,但是编译器会解决
继承时,如果子类重写了父类的虚函数,会用子类虚函数指针覆盖子类虚函数表中的该虚函数
运行时多态
同一套继承体系中,通过基类指针/引用操作对象时,虚函数最终调用哪个版本,由对象的实际类型在运行时决定,如:
Derived d;
Base* p = &d;
p->Func();//实际调用的是子类的 Func(),这就是运行时多态
底层实现是:调用虚函数接口时,根据对象中的 vptr 找到虚函数表,然后找到对应虚函数指针,然后执行虚函数逻辑
由于运行时才动态绑定实际对象,所以是运行时多态
多态的核心设计思想:基类定义统一接口,派生类提供不同实现
纯虚函数与抽象类
如果基类只是想规定接口,不提供任何实现,可以声明为纯虚函数:
class Animal//只要有一个成员函数是纯虚函数,该类变成抽象类,无法实例化;如果子类没有 override 这个纯虚函数,也会变成抽象类
{
public:
virtual void Speak() = 0;
};
虚析构
继承多态体系中的析构函数通常声明为 virtual
目的是:对象析构时多态调用到实际子类的析构函数,在自然顺着逆向析构链向上析构,保证资源正确释放
多继承
当子类继承了多个含有虚函数的父类时,子类对象就会拥有多个 vptr,布局可以理解为:
//无虚函数
C object
┌──────────────┐
│ A subobject │
│ int a │
├──────────────┤
│ B subobject │
│ int b │
├──────────────┤
│ int c │
└──────────────┘
//有虚函数
C object
offset 0
┌──────────────────┐
│ A::vptr │ 8
├──────────────────┤
│ A::a │ 4
├──────────────────┤
│ padding │
├──────────────────┤
│ B::vptr │ 8
├──────────────────┤
│ B::b │ 4
├──────────────────┤
│ padding │
├──────────────────┤
│ C::c │ 4
├──────────────────┤
│ padding │
└──────────────────┘
这种情况下,C* C = new C();、B* C = new C();、A* C = new C();都能正确访问其对应的部分,不会错误
多继承会产生一种叫“菱形继承”的情况,解决方法是虚继承,这里不展开讲
多态约束
final 可以帮助表达多态约束
class Derived final : public Base//表示 Derived 不允许继续继承
{
virtual void Func() final;//声明后面的派生类不能再 override
};
//两种写法都可以约束多态泛滥或不确定,帮助编译器更容易确定最终调用目标
静态多态
调用哪个函数,在编译期就已经确定
C++ 里常见的静态多态主要有三种,这里不展开讲了:
- 函数重载:同名不同参的函数
- 模板
- CRTP(更典型的“面向对象式静态多态”)
本篇收官。。。后面会说说智能指针
网硕互联帮助中心



评论前必须登录!
注册