Skip to content

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::atomicstd::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::stringstd::vectorstd::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 和引用的关系

  1. const 左值引用(T&)❌:
  2. 不能绑定到临时对象(右值)。
  3. 原因: T& 表明函数可能会修改引用的对象。如果允许修改一个临时对象,那么修改的结果将在函数返回后立即被销毁、丢失,这会造成逻辑上的混乱。编译器为了安全起见,直接禁止了。
  4. const 左值引用(const T&)✅:
  5. 可以绑定到临时对象(右值)。
  6. 原因: 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),用于记录函数调用和对象创建/销毁信息。这会导致最终可执行文件的大小增加

异常抛出过程

当异常被抛出时,整个过程可以分解为三个主要阶段,其中栈展开是核心:

  1. 抛出 (Throwing) 💥:
  2. 异常对象被创建。
  3. 运行时系统查找当前执行函数对应的异常处理元数据(Unwind Tables)
    1. 据包含了当前函数调用栈和局部对象生命周期的所有信息
    2. 析构函数指针 🔨: 元数据会直接存储这个对象的析构函数地址
  4. 栈展开 (Stack Unwinding) 🧹: 递归清理局部对象。
  5. 读取 PC (程序计数器) 寄存器 💻: 确定异常在当前函数内的具体发生位置。
  6. 查表 🔎: 将 PC 地址与 Unwind Table 中的范围进行比对。
  7. 批量析构 🔨: 识别出所有在那个 PC 地址仍然存活的局部对象,并调用它们对应的析构函数。
  8. 捕获 (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 内部主要存储了以下信息:

  1. 虚函数指针 (Function Pointers) 🎯:这是 vtable 的主要内容。数组中的每一个元素都是一个指针,指向该类中每一个虚函数的实际实现地址。
  2. 运行时类型信息 (RTTI) 🔍:vtable 的第一个或第二个元素通常是一个指针,指向该类的 type_info 对象,用于支持 RTTI 功能(如 dynamic_casttypeid)。
  3. 地址偏移量 (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) 时,程序通过 pvptr 找到 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, 当编译器在实例化模板(把模板套用到某个类型)时,如果模板参数替换后产生了编译错误,编译器不会立即报错,而是: 忽略这个模板,去尝试别的可行模板。