C++ Basic
C++的编译过程
在modern C++里面写了
核心概念
初始化
Default initialization
A a1;
如果类有构造函数,就调用它;
如果是内置类型(如 int i;),则不会自动初始化,值不确定。
Value initialization
A a1{};
with curly braces
若类有用户定义的构造函数:调用构造函数;
若类是聚合类型(无构造函数、纯成员变量):所有成员初始化为 0。
Direct Initialization
std::string s("hello");
A a(10);
用括号 () 传递初始值
调用匹配的构造函数
与函数调用语法类似
Copy Initialization
使用 = 进行初始化(不是赋值)
可以隐式调用拷贝构造函数
若可能 → 优化为直接初始化(RVO / copy elision)
Aggregate Initialization
struct Point { int x; int y; };
Point p = {1, 2};
int arr[3] = {1, 2, 3};
所有成员按顺序初始化;
不能有构造函数、私有成员或基类;
List Initialization
C++11
std::vector<int> v{1,2,3};
A obj{1,2};
统一语法 {},可用于任意类型;
防止窄化(比如 int a{2.5}; 编译错误);
左值右值
| 名称 | 含义 | 是否有内存地址 | 是否可以出现在“=”左边 | 举例 |
|---|---|---|---|---|
| 左值(lvalue) | 表示一个在内存中有名字、可取地址的对象 | ✅ 有 | ✅ 可以 | int a; a = 10; 中的 a |
| 右值(rvalue) | 表示一个临时值、没有固定内存地址 | ❌ 没有(临时) | ❌ 不行 | 10, a + b, func() 返回的临时值 |
引用与左右值
int& ref = x 是左值引用, 绑定的x必须是左值。
int&& ref = 3 是右值引用,
const 左值引用可绑定右值(编译器生成临时对象)const int& r3 = 10;
value categories
C++11引入的
| 分类 | 含义 | 举例 |
|---|---|---|
| 左值(lvalue) | 有名字、可取地址 | x, *p, arr[i] |
| 纯右值(prvalue) | 临时值、字面量 | 10, x + y, MyClass() |
| 将亡值(xvalue) | 即将被销毁的资源,可“偷走”内容 | std::move(x) 的结果 |
“右值引用” (int&&) 主要是为了操作 xvalue(将亡值)expiring value。
std::move 并不“移动”,它只是把左值强转为右值。
[C++17 §5.1.1] 每个表达式都属于以下之一:
- glvalue:指代一个对象或函数(有内存身份)。
- prvalue:计算产生一个纯值(没有对象身份)。
- xvalue:glvalue 的一种,表示即将销毁的对象(资源可被“移动”)。
- lvalue:glvalue 的一种,表示可持续存在的对象(有名字)。
- rvalue:prvalue 或 xvalue。
move semantics
避免不必要的“深拷贝”,把资源从临时对象“搬走”而不是复制。
C++ 11
perfect forwarding
void process(int& x) { std::cout << "lvalue\n"; }
void process(int&& x) { std::cout << "rvalue\n"; }
template<typename T>
void wrapper(T arg) {
process(arg); // ❌ arg 总是左值,因为 arg 有名字!
}
要改成
void foo(int& x) { std::cout << "lvalue\n"; }
void foo(int&& x) { std::cout << "rvalue\n"; }
template <typename T>
void wraper(T&& x) {
foo(std::forward<T>(x));
}
参数加上 && 可以接受左右值, 只有出现要转发的场景时候写
移动语义
- 右值引用 (
T&&) 的唯一作用就是安全地绑定到将亡值(例如表达式的结果、函数返回的临时对象)。 - 当一个函数(如移动构造函数)接收一个
T&&参数时,它就知道:这个参数指向的资源,我在操作结束后可以随意处置,因为它很快就要被销毁了。
“偷取资源”的两个 O(1) 步骤:
- 浅拷贝(Stealing) 🤝将源对象(即将销毁)内部的资源指针(例如
data_指针)直接复制给目标对象。 - 置空(Nullification) 🧹将源对象的资源指针(
data_指针)设置为nullptr或其他空值。保证当源对象被销毁时,它的析构函数不会错误地释放已经被目标对象接管的内存,从而避免双重释放(Double Free)。
模板万能引用
在 C++ 模板中,当函数参数被声明为 T&& 形式时,它具有特殊的性质,它既可以绑定到左值,也可以绑定到右值,因此被称为“万能引用”。
引用折叠规则(Reference Collapsing Rule)
template<typename T>
struct valuetype {
constexpr static int value = 0;
};
template<typename T>
struct valuetype<T&> {
constexpr static int value = 1; // lvalue
};
template<typename T>
struct valuetype<T&&> {
constexpr static int value = 2;
};
template<class T>
auto wraper(T&& x) {
std::cout << "T : " << valuetype<T>::value << '\n';
std::cout << "T&& : " << valuetype<T&&>::value << '\n';
valuefunc(std::forward<T>(x));
}
- T 的推导
- 如果参数是左值,那么
T=T& - 否则就是
T T&&的推导- 左值
T& && = T& - 右值
T&& && = T&& T&的推导T& & = T&T&& & = T&
完美转发的实现
template<typename T>
T&& myforward(std::remove_reference_t<T>& x) {
return static_cast<T&&>(x);
}
构造
class MyStr {
char* data;
public:
MyStr(const char* s); // ① Regular (normal) constructor
MyStr(const MyStr& other); // ② Copy constructor
MyStr(MyStr&& other) noexcept; // ③ Move constructor
~MyStr();
};
noexcept 是必要的,例子
struct A {
A() {}
A(A&&) noexcept { std::cout << "move\n"; }
A(const A&) { std::cout << "copy\n"; }
};
struct B {
B() {}
B(B&&) { std::cout << "move (maybe throw)\n"; }
B(const B&) { std::cout << "copy\n"; }
};
std::vector<A> va(3);
std::vector<B> vb(3);
va.push_back(A()); // ✅ 调用 move
vb.push_back(B()); // ⚠️ 调用 copy,因为 move 可能抛异常
regular constructor
创建新对象的时候
emplace_back语义允许编译器直接在容器的内存中构造元素
copy constructor
复制文件的时候
MyStr a("abc");
MyStr b = a; // copy constructor
MyStr c(a); // copy constructor
这个时候会调用
move constructor
MyStr::MyStr(MyStr&& other) noexcept {
data = other.data; // take ownership
other.data = nullptr; // leave source empty
}
MyStr makeStr() {
MyStr temp("hello");
return temp; // ✅ 直接在调用者栈上构造,无拷贝、无移动
}
MyStr a("hello");
MyStr b(std::move(a)); // move constructor
MyStr c = makeStr(); // move constructor when returning temporaries
强制省略拷贝(RVO(Return Value Optimization)),C++17之后
-
RVO (Return Value Optimization) 和 NRVO (Named Return Value Optimization) 是C++中非常重要且强大的编译器优化技术,它们允许编译器省略掉函数按值返回时本应发生的拷贝或移动构造。
-
RVO 变为强制 (Mandatory Elision for Prvalues): C++17规定,当返回一个临时对象(Prvalue,如
return MyType();)时,拷贝省略不再是优化,而是强制的语言规则。这意味着,你现在可以返回一个不可拷贝也不可移动的对象(例如std::atomic或std::mutex)。
rule of three
如果你的类需要自己管理资源(new/delete、open/close 等),那么你至少要自己定义以下三个函数:
| 函数 | 名称 | 作用 |
|---|---|---|
~T() |
Destructor(析构函数) | 释放资源 |
T(const T&) |
Copy Constructor(拷贝构造) | 进行深拷贝 |
T& operator=(const T&) |
Copy Assignment(拷贝赋值) | 释放旧资源并深拷贝新资源 |
Rule of Five (C++11)
C++11 引入了 移动语义(move semantics),因此多了两种特殊函数来“移动资源”而不是拷贝它们。
如果你需要自定义前面三种函数,通常也应该定义下面两种:
| 函数 | 名称 | 作用 |
|---|---|---|
T(T&&) |
Move Constructor(移动构造) | 转移资源所有权 |
T& operator=(T&&) |
Move Assignment(移动赋值) | 释放旧资源,接管新资源 |
Rule of Zero
C++17. 如果你能用标准库的类型(如 std::string、std::vector、std::unique_ptr)管理资源,那就不需要写任何特殊函数。
字符串
构造
std::string{"abc"};
数字转字符串
std::to_string(n);
拿到const char*
s.c_str();
format (C++20)
头文件 <format> 可以用 std::format 来格式化字符串
时间
头文件 <chrono>
获取当前时间
auto cur_time = std::chrono::system_clock::now()
auto now = floor<seconds>(system_clock::now()); // round to second
c返回 std::chrono::time_point , 转成时间戳 std::chrono::system_clock::to_time_t(cur_time)
时间戳转化成localtime: *std::localtime(&t)
高精度计时 : std::chrono::high_resolution_clock::now()
Duration
std::chrono::duration 是一个模板类,
有常用的 duration 类型
using nanoseconds = duration<long long, std::nano>;
using microseconds = duration<long long, std::micro>;
using milliseconds = duration<long long, std::milli>;
using seconds = duration<long long>;
using minutes = duration<long long, std::ratio<60>>;
using hours = duration<long long, std::ratio<3600>>;
测量一段代码执行时间
auto start = steady_clock::now();
for (volatile int i = 0; i < 1000000; ++i); // 模拟工作
auto end = steady_clock::now();
duration<double, std::milli> elapsed = end - start;
std::cout << "耗时:" << elapsed.count() << " 毫秒\n";
时间转换 duration_cast
milliseconds ms2(1500);
seconds s2 = duration_cast<seconds>(ms2); // s2 = 1 秒
const常数
指针常量和常量指针
int a = 10;
const int* p1 = &a;
int* const p2 = &a;
“p1 是一个指向 const int 的指针”, p2 是一个常量指针,指向 int
const 函数
对成员函数:
struct T {
void foo();
void bar() const;
};
编译器实际上会“想象”成(伪代码):
void foo(T* const this); // 普通成员函数
void bar(const T* const this); // const 成员函数
也就是说,每个成员函数其实都多了一个隐藏的第一个参数,就是 this 指针。
const 和引用的关系
- 非
const左值引用(T&)❌: - 不能绑定到临时对象(右值)。
- 原因:
T&表明函数可能会修改引用的对象。如果允许修改一个临时对象,那么修改的结果将在函数返回后立即被销毁、丢失,这会造成逻辑上的混乱。编译器为了安全起见,直接禁止了。 const左值引用(const T&)✅:- 可以绑定到临时对象(右值)。
- 原因:
const关键字保证了函数不会修改该对象。既然没有修改的风险,编译器就允许它绑定,这极大地增强了灵活性。
void print(const T& v); // 只读
print(T()); // OK:传一个表达式产生的临时过去
print(a + b); // OK:可以直接用表达式结果
异常
包含头文件
#include <stdexcept>
抛出异常
auto foo() -> void {
auto a = std::make_unique<A>();
throw std::runtime_error("exception here");
}
捕获异常
try {
auto res = Server::compute(A, B);
std::cout << res << std::endl;
}
catch (std::invalid_argument& e) {
std::cout << "Exception: " << e.what() << std::endl;
}
catch (std::bad_alloc& e) {
std::cout << "Not enough memory" << std::endl;
}
catch (std::exception& e) {
std::cout << "Exception: " << e.what() << std::endl;
}
catch (...) {
std::cout << "Other Exception" << std::endl;
}
- 为什么析构函数要noexcept
抛出异常的时候会栈展开 (stack unwinding), 把异常向上传递。如果抛出异常会直接terminate
- 移动构造为什么要noexcept
移动构造一般不会失败,std的容器里只有noexcept才会move
C++异常类型
std::exception
├── std::bad_alloc // 内存分配不足
├── std::bad_cast
├── std::bad_typeid
├── std::bad_exception
├── std::logic_error
│ ├── std::invalid_argument
│ ├── std::domain_error
│ ├── std::length_error
│ ├── std::out_of_range
│ └── std::future_error (C++11)
└── std::runtime_error
├── std::range_error
├── std::overflow_error
├── std::underflow_error
├── std::system_error (C++11)
└── std::regex_error (C++11)
自己定义异常
有 #include <exception> 头文件里面的 exception 类
class MyException : public std::exception {
private:
std::string msg;
public:
explicit MyException(const std::string& message) : msg(message) {}
const char* what() const noexcept override {
return msg.c_str();
}
};
try catch 的开销
| 机制 | 正常执行路径 (Normal Path) | 异常抛出路径 (Exception Path) |
|---|---|---|
| 开销 | 接近于零 🚀 | 非常高昂 💥 |
代码膨胀: 尽管运行时开销低,但为了实现异常处理,编译器必须生成额外的元数据(Unwind Tables),用于记录函数调用和对象创建/销毁信息。这会导致最终可执行文件的大小增加。
异常抛出过程
当异常被抛出时,整个过程可以分解为三个主要阶段,其中栈展开是核心:
- 抛出 (Throwing) 💥:
- 异常对象被创建。
- 运行时系统查找当前执行函数对应的异常处理元数据(Unwind Tables)。
- 据包含了当前函数调用栈和局部对象生命周期的所有信息
- 析构函数指针 🔨: 元数据会直接存储这个对象的析构函数地址。
- 栈展开 (Stack Unwinding) 🧹: 递归清理局部对象。
- 读取 PC (程序计数器) 寄存器 💻: 确定异常在当前函数内的具体发生位置。
- 查表 🔎: 将 PC 地址与 Unwind Table 中的范围进行比对。
- 批量析构 🔨: 识别出所有在那个 PC 地址仍然存活的局部对象,并调用它们对应的析构函数。
- 捕获 (Catching) 🤝: 转移控制权到匹配的
catch块。
关键词
static
- 函数内部的局部变量使用: 它的生命周期是整个程序运行期(静态存储期),但作用域仍然局限在函数内部。
- 全局变量或函数前使用: 限制变量或函数的 链接性(linkage) 为 内部链接(internal linkage), 外部库不能看见
inline static C++17 后,类static初始化
int A::count = 0; // Version < C++17
class B {
public:
inline static int count = 0; // C++17
};
volatile
- 这个变量的值可能会被意外改变: 编译器不能对这个变量的访问做优化, 保证每次访问都从内存读
explicit
- 防止隐式构造
多态
先构造基类,再构造子类
构造函数可以是虚函数吗
构造函数写virtual会编译报错,带虚函数的类里,每个对象里通常有一个“虚指针”(vptr)指向虚函数表(vtable),这个 vptr 是在构造函数里被设置好的。
构造,析构函数里可以调用虚函数吗
构造函数,可以调用,但没有多态,是当前类的函数。
在构造函数和析构函数的执行过程中,对象的“动态类型”视为“当前正在构造/析构的那个类”,而不是最终的派生类。
默认参数和虚函数一起使用,有什么坑?
默认参数是静态绑定的,虚函数是动态绑定的。所以一般我们不再虚函数里写默认参数
virtual析构函数
如果一个类打算被继承,并且会通过“基类指针或引用”来操作对象,就必须让基类的析构函数是 virtual。否则,通过基类指针 delete 派生类对象时,只会调用基类析构函数,派生类的析构函数不会执行,导致资源泄漏或未定义行为。
默认不会加virtual,但如果基类写了virtual,那么子类的析构默认virtual
vptr 的内存布局
在一个包含虚函数的对象中,vptr 通常是对象中第一个插入的隐藏数据成员
在构造该类的时候设置vptr的指针
对于一个子类继承自一个基类的简单情况,且基类定义了虚函数,内存布局通常如下:
| 内存偏移 | 存储内容 | 描述 |
|---|---|---|
| 0 | 虚指针 (vptr) | 指向该对象类对应的虚函数表 (vtable)。 |
| \(8/4\) 字节后 | 基类数据成员 | 基类中非虚函数的成员变量。 |
| ... | 子类数据成员 | 子类自身特有的成员变量。 |
要特别注意:
- vptr 是对象的一部分:每一个拥有虚函数的类实例,都会包含一个 vptr 成员。
- vtable 是类的部分:同一个类的所有对象共享同一个 vtable。vtable 是静态的,只在程序的数据段(Data Segment)中存在一份。
vtable存储的信息
vtable 内部主要存储了以下信息:
- 虚函数指针 (Function Pointers) 🎯:这是 vtable 的主要内容。数组中的每一个元素都是一个指针,指向该类中每一个虚函数的实际实现地址。
- 运行时类型信息 (RTTI) 🔍:vtable 的第一个或第二个元素通常是一个指针,指向该类的
type_info对象,用于支持 RTTI 功能(如dynamic_cast和typeid)。 - 地址偏移量 (Adjustment Offsets) ⚙️:在多重继承或虚继承的复杂场景下,vtable 可能存储额外的偏移量,用于修正
this指针的地址。
当程序执行 p->virtual_func() 时,底层汇编指令通常分解为以下三个步骤:
索引时编译器硬编码 (Hardcoded) 到编译器生成的汇编代码中。
| 步骤 | 动作 (汇编级) | 作用 |
|---|---|---|
| 1. 查找 vptr | 从对象指针 p 的起始地址 (偏移量 0) 取出 vptr 的值。 |
找到该对象的 vtable 入口。 |
| 2. 索引 vtable | 以 vptr 为基地址,计算出要调用函数的地址:vptr + (索引 * 函数指针大小)。 |
找到正确的虚函数指针。索引就在这里使用。 |
| 3. 调用函数 | 跳转到第 2 步计算出的地址,执行函数。 | 实现动态绑定。 |
多继承的布局
一个派生类对象 (Derived) 包含所有基类的子对象 (BaseA, BaseB)。在内存中,它们是连续排列的:
当你将 Derived* 转换为第二个基类 (BaseB*) 指针时,编译器必须在地址上加上一个正向偏移量(即 SizeOf(BaseA)),才能让指针指向 BaseB 子对象的正确起始地址
对象内存通常是这样组织的:
| 内存偏移 | 存储内容 | 作用 |
|---|---|---|
| 0 | vptr 1 (BaseA 的) | 指向 BaseA 虚函数集合的 vtable。 |
| \(8/4\) 字节后 | BaseA 的数据成员 | 构成 BaseA 子对象。 |
| ... | vptr 2 (BaseB 的) | 指向 BaseB 虚函数集合的 vtable。 |
| ... | BaseB 的数据成员 | 构成 BaseB 子对象。 |
| ... | Derived 自己的数据成员 |
多继承的问题
- 使用场景:接口多继承:多个基类都是纯虚接口(无数据成员),类似多实现接口,风险较小。
RTTI
vtable 关联:vtable 的特定位置存储了一个指针,指向由编译器为每个类创建的静态 std::type_info 对象。
typeid:当你使用 typeid(*p) 时,程序通过 p 的 vptr 找到 vtable,然后取出 type_info 对象的地址,并返回该对象。
dynamic cast:
- 首先,程序通过指针
p访问对象的 vptr,找到对象的 vtable。 - 从 vtable 中取出指向 实际类型(例如
Derived类)的type_info对象的指针。 - 编译器在 RTTI 里额外存了一张“类继承树 + 偏移”的表
怎么看是哪个子类
void identify(Base* b) {
if (dynamic_cast<A*>(b))
cout << "It's an A\n";
else if (dynamic_cast<B*>(b))
cout << "It's a B\n";
else if (dynamic_cast<C*>(b))
cout << "It's a C\n";
else
cout << "Unknown type\n";
}
Object Slicing
struct Base { int x; };
struct Derived : Base { int y; };
Derived d;
Base b = d; // 对象切片
直接赋值的话,Base 对象只能保存基类部分;Derived 中的 y 被“切掉”;解决方法是用指针
菱形继承(Diamond Inheritance)
struct A { int x; };
struct B : A {};
struct C : A {};
struct D : B, C {}; // 菱形继承
解决方法:
struct B : virtual A {};
struct C : virtual A {};
struct D : B, C {}; // A 只存在一份
不加 virtual 时,菱形结构中基类会被复制成多个子对象。
使用 虚拟继承(virtual inheritance) 确保只保留一份基类副本.为了在运行时正确定位那唯一的虚基类 A,
编译器必须为每个涉及虚继承的类生成一个虚基类表(vbtable) 或偏移表,用于记录:
“当前对象中虚基类 A 的实际偏移是多少?”
对象中会额外保存一个指针(或通过 vptr 间接访问)。
D 对象:
[ B 子对象 ]:
+---------------------+
| vptr_B | // B 的虚函数表指针
+---------------------+
| vbptr_B | // 虚基表指针
+---------------------+
| B::b |
+---------------------+
[ C 子对象 ]:
+---------------------+
| vptr_C |
+---------------------+
| vbptr_C |
+---------------------+
| C::c |
+---------------------+
[ shared A subobject ]:
+---------------------+
| vptr_A | // A 的虚函数表指针(最终指向 D 版的 fa)
+---------------------+
| A::a |
+---------------------+
[ D 自身成员 ]:
+---------------------+
| D::d |
+---------------------+
构造顺序 ABCD
二义性
struct A { void f(){} };
struct B { void f(){} };
struct C : A, B {};
C c;
c.f(); // ❌ 二义性
解决: c.A::f(); 调用,或者 void f() override { A::f(); }
继承与组合(composition)
设计原则:优先使用组合而非继承(Prefer composition over inheritance)
Meta Programming
constexpr
可以在编译期求值 的表达式或函数
constexpr int square(int x) {
return x * x;
}
int arr[square(3)]; // ✅ OK,编译时计算 3*3=9
和 const 的区别
const int x = 3;
constexpr int y = 3;
int arr1[x]; // ❌ 不保证编译期常量, const int x = func();
int arr2[y]; // ✅ 一定是编译期常量
const = 运行时只读变量 constexpr = 编译时已知常量
concept
我们希望能告诉编译器:
“T 必须是能相加的类型”, “T 必须是数值类型”
这就是 concept 登场的地方
头文件 #include <concepts>
template<std::integral T>
T add(T a, T b) {
return a + b;
}
自带的常用 Concepts
| Concept 名 | 意义 |
|---|---|
std::integral |
整型类型(int、char、long...) |
std::floating_point |
浮点型(float、double...) |
std::signed_integral / std::unsigned_integral |
有/无符号整数 |
std::same_as<T> |
类型与 T 相同 |
std::convertible_to<T> |
可以隐式转换为 T |
std::invocable<F, Args...> |
可调用对象(函数、lambda) |
std::default_initializable |
能默认构造 |
自定义 Concept
template<typename T>
concept Addable = requires(T a, T b) {
{ a + b } -> std::convertible_to<T>;
};
使用的时候
Addable auto add(Addable auto a, Addable auto b) {
return a + b;
}
C++23 推荐这种写法:参数内直接约束类型
与requires 结合
template<typename T>
requires std::integral<T> && (sizeof(T) > 2)
T twice(T x) {
return x * 2;
}
SFINAE
Substitution Failure Is Not An Error, 当编译器在实例化模板(把模板套用到某个类型)时,如果模板参数替换后产生了编译错误,编译器不会立即报错,而是: 忽略这个模板,去尝试别的可行模板。