五、C++ 新特性、关键字与编译原理(进阶)(二)

五、C++ 新特性、关键字与编译原理(第 14~25 项补全)

14~25 知识点关系

复制代码
现代 C++ 工程能力
├─ 标准演进
│   └─ 14. C++17/20/23 高频特性
│
├─ 数据与接口
│   ├─ 15. STL 容器选择
│   ├─ 16. string_view / span / optional
│   └─ 24. 移动语义与完美转发
│
├─ 资源与异常
│   ├─ 17. RAII
│   └─ 20. 异常处理
│
├─ 构建、链接与交付
│   ├─ 18. extern "C"
│   ├─ 19. CMake / ABI / 依赖管理
│   └─ 22. 头文件保护
│
├─ 基础关键字与编译机制
│   ├─ 21. static 使用时机
│   └─ 23. inline 类型检查
│
└─ 框架设计
    └─ 25. 模板 / CRTP / 类型擦除

14. C++17/20/23 中哪些特性在工程中高频使用?

核心:C++17 解决"接口更安全、类型表达更清楚";C++20 解决"模板约束、范围处理、异步协程和连续内存视图";C++23 进一步补齐错误处理、多维数组视图、有序扁平容器和日志输出。在推理服务、数据管线、SDK 开发中,这些特性主要用于减少拷贝、提前暴露类型错误、简化资源与错误处理。

C++17 高频特性

复制代码
#include <filesystem>
#include <optional>
#include <string_view>
#include <variant>
#include <iostream>
#include <map>

// 1. string_view:非拥有字符串视图,避免 const string& 触发临时对象
void print_name(std::string_view name) {
    std::cout << name << '\n';
}

// 2. optional:明确表达"可能没有值"
std::optional<int> find_index(const std::map<int, int>& m, int key) {
    auto it = m.find(key);
    if (it == m.end()) return std::nullopt;
    return std::distance(m.begin(), it);
}

// 3. variant:类型安全的 tagged union
using Device = std::variant<CpuDevice, GpuDevice, NpuDevice>;

void run(const Device& device) {
    std::visit([](const auto& dev) {
        dev.execute();
    }, device);
}

// 4. 结构化绑定
std::map<std::string, int> counter;
for (const auto& [name, count] : counter) {
    std::cout << name << count << '\n';
}

// 5. if constexpr:编译期分支
template <class T>
void describe(T value) {
    if constexpr (std::is_integral_v<T>) {
        std::cout << "integer\n";
    } else {
        std::cout << "other\n";
    }
}

高频点还包括:inline 变量、折叠表达式、类模板参数推导 CTAD、std::filesystem、并行算法 std::sort(std::execution::par, ...)

C++20 高频特性

复制代码
#include <concepts>
#include <ranges>
#include <span>
#include <vector>
#include <iostream>

// concepts:约束模板参数,错误信息更清楚
template <std::integral T>
T add(T a, T b) {
    return a + b;
}

void process(std::vector<int>& data) {
    namespace rv = std::views;

    // ranges:惰性管道,不额外构造中间容器
    auto result = data
        | rv::filter([](int x) { return x > 0; })
        | rv::transform([](int x) { return x * 2; })
        | rv::take(10);

    for (int x : result) {
        std::cout << x << ' ';
    }
}

// span:连续内存非拥有视图
float sum(std::span<const float> values) {
    float total = 0;
    for (float x : values) total += x;
    return total;
}

C++20 中工程上常见的还有协程 coroutine、模块 modulestd::jthread 自动 join、std::formatlatch/barrier/semaphoreatomic_refconstexpr 能力增强。协程适合高并发连接、异步任务调度;span 非常适合张量缓冲区、批量输入和零拷贝接口。

C++23 高频特性

复制代码
#include <expected>
#include <print>
#include <mdspan>
#include <vector>

std::expected<int, ErrorCode> parse_port(const std::string& text) {
    if (text.empty()) {
        return std::unexpected(ErrorCode::EmptyInput);
    }
    return 8080;
}

void demo() {
    std::print("server port = {}\n", 8080);
}
  • std::expected<T, E>:函数成功返回 T,失败返回错误对象 E,比异常更适合可预期业务错误。
  • std::mdspan:把一段连续内存解释成多维数组,非常适合张量形状、矩阵和批量数据视图。
  • std::print:类型安全、性能更好的格式化输出。
  • flat_map/flat_set:底层用有序连续数组存储,小数据量和查找密集场景缓存友好。
  • std::move_only_function:可以持有只移动对象,例如 unique_ptr 捕获的回调。
  • std::generator:简化协程生成器。

版本选择建议:

复制代码
C++17:生产环境最稳,优先掌握 optional/variant/string_view/结构化绑定
C++20:现代工程主力,重点掌握 concepts/ranges/span/协程
C++23:看编译器和部署环境,逐步引入 expected/mdspan/print/flat_map

工程中不要为了追新而追新。真正高频的是那些能减少拷贝、明确所有权、表达"可能失败/可能为空"、让模板接口更可读的特性。


15. STL 容器在数据处理和推理服务中如何选择?

核心:热路径默认使用连续内存容器 vector/span,因为张量、batch、特征列都要求高缓存命中和可直接传递给底层计算库;配置和元数据使用 unordered_map/map;任务调度使用 queue/priority_queue;链表在高性能数据管线中通常应避免。

15.1 数据处理场景

张量和批量样本通常是连续内存:

复制代码
#include <vector>
#include <span>

struct Tensor {
    std::vector<float> data;
    std::vector<int64_t> shape;

    std::span<float> view() {
        return data;
    }
};

void infer(std::span<const float> input);

vector 的连续内存可以直接传给 C 接口、推理运行时或 GPU 拷贝函数:

复制代码
std::vector<float> batch(1 * 3 * 224 * 224);
runtime.set_input(0, batch.data(), batch.size() * sizeof(float));

如果只需要读取一段已有缓冲区,不要复制成 vector,用 span

复制代码
void preprocess(std::span<const float> raw,
                std::span<float> output);

15.2 元数据和配置

精确按键查找使用 unordered_map

复制代码
#include <unordered_map>
#include <string>

std::unordered_map<std::string, std::string> config = {
    {"model", "resnet50"},
    {"device", "cuda:0"}
};

需要范围查询或有序输出时使用 map

复制代码
#include <map>
#include <chrono>

std::map<long long, Metric> time_series;

auto begin = time_series.lower_bound(start_ms);
auto end = time_series.upper_bound(end_ms);

小而固定、频繁遍历的配置,C++23 可考虑 flat_map,它比节点式红黑树缓存更友好。

15.3 任务队列

普通 FIFO:

复制代码
#include <queue>
#include <mutex>
#include <condition_variable>

std::queue<Request> pending;
std::mutex mtx;
std::condition_variable cv;

优先级调度:

复制代码
struct Request {
    int priority;
};

struct Compare {
    bool operator()(const Request& a, const Request& b) const {
        return a.priority < b.priority;
    }
};

std::priority_queue<Request, std::vector<Request>, Compare> scheduler;

15.4 容器选择表

场景 推荐容器 原因
张量数据、特征数组 vector 连续内存、缓存友好、可传裸指针
不拥有的数据窗口 span 零拷贝表达连续区间
固定大小数组 array 无堆分配,大小编译期确定
请求 FIFO queue 接口语义明确,默认底层 deque
优先级调度 priority_queue 堆结构,快速取最高优先级
头部尾部都增删 deque 双端 O(1)
精确 key 查询 unordered_map 平均 O(1)
有序 key/范围查询 map O(log n),有序
去重标签 unordered_set/set 按是否需要顺序选择
固定类型集合 variant 类型安全,不使用虚继承
高频流水线 自定义环形缓冲/无锁队列 STL 容器本身不提供线程安全保证

15.5 为什么 list 很少用于热路径

list 理论上已知位置插入删除 O(1),但每个节点独立分配,地址不连续,遍历时 cache miss 很高。数据处理中,即使 vector 中间删除是 O(n),由于内存连续和向量化,实际也经常更快。常见优化是"标记删除 + 最后统一 compact":

复制代码
std::vector<Item> items;

size_t write = 0;
for (size_t read = 0; read < items.size(); ++read) {
    if (items[read].valid) {
        items[write++] = std::move(items[read]);
    }
}
items.resize(write);

15.6 并发注意事项

STL 容器不是线程安全容器。多线程中:

  • 多线程只读可以共享。
  • 一个线程写、其他线程读或多个线程同时写,都需要外部同步。
  • 不要在遍历线程中让另一个线程修改容器。
  • 高吞吐场景可使用分片锁、MPSC 队列、环形缓冲区或对象池。

总结:数据面追求连续内存和零拷贝,控制面追求清晰映射和稳定查找;不要只根据理论复杂度选型,要结合缓存、分配次数、对象大小和并发模型。


16. std::string_view、std::span、std::optional 在高性能接口中有什么价值?

核心:string_viewspan 是非拥有视图,用"指针 + 长度"描述已有内存,避免为了传参复制字符串或数组;optional 用类型系统表达"可能没有值",避免魔法值和空指针歧义。它们共同提高接口表达力并减少不必要拷贝。

16.1 string_view:非拥有字符串视图

传统接口:

复制代码
void process(const std::string& s);

它能接收 std::string,但传入 C 字符串时可能构造临时 string,产生堆分配。string_view 只保存起始指针和长度:

复制代码
#include <string_view>

void process(std::string_view s) {
    if (!s.empty()) {
        char first = s.front();
    }
}

int main() {
    process("hello");              // 不拥有字面量内存
    process(std::string("world")); // 不复制原字符串
}

截取子串是 O(1),因为它只调整指针和长度:

复制代码
std::string_view sv = "model/resnet50/layer1";
auto name = sv.substr(6); // 视图,不复制底层字符

注意:string_view 不保证以 \0 结尾,所以不能无脑传给要求 C 字符串的接口:

复制代码
void c_api(const char* s);

// c_api(sv.data()); // 如果 sv 是子串,可能没有 \0

16.2 span:连续对象序列视图

span<T> 类似连续数组的 string_view,但可用于任意类型:

复制代码
#include <span>
#include <vector>
#include <array>
#include <numeric>

float mean(std::span<const float> values) {
    if (values.empty()) return 0;

    float sum = std::accumulate(values.begin(), values.end(), 0.0f);
    return sum / static_cast<float>(values.size());
}

int main() {
    float raw[3] = {1.0f, 2.0f, 3.0f};
    std::vector<float> vec = {4.0f, 5.0f};
    std::array<float, 2> arr = {6.0f, 7.0f};

    mean(raw);
    mean(vec);
    mean(arr);
}

span<const T> 表示只读视图,span<T> 表示可写视图:

复制代码
void normalize(std::span<float> data) {
    for (float& x : data) x = x / 255.0f;
}

它统一了数组、vectorarray 和一段连续缓冲区,不需要写多个重载,也不需要传 pointer + size 两个参数。

16.3 optional:明确可能缺失

复制代码
#include <optional>
#include <string>
#include <unordered_map>

std::optional<std::string> get_header(
    const std::unordered_map<std::string, std::string>& headers,
    const std::string& key
) {
    auto it = headers.find(key);
    if (it == headers.end()) {
        return std::nullopt;
    }
    return it->second;
}

调用方必须处理"没有值"的情况:

复制代码
if (auto token = get_header(headers, "token")) {
    use(token.value());
} else {
    reject();
}

int timeout = get_header(headers, "timeout")
                  .and_then(parse_int)
                  .value_or(30);

它比返回 -1nullptr 更清晰,因为不存在的值不会和正常业务值混淆。

16.4 生命周期是最大风险

视图不拥有内存:

复制代码
std::string_view bad() {
    std::string s = "temporary";
    return s; // 错误:s 销毁后 view 悬空
}

std::span<const float> bad2() {
    std::vector<float> data(1024);
    return data; // 错误:vector 销毁后 span 悬空
}

关系图:

复制代码
拥有者:string / vector / array / 原始 buffer
   │
   ├─ string_view:借用字符区间
   ├─ span:借用对象区间
   └─ 所有者销毁 → 所有视图立即失效

因此,视图适合作为函数参数和短期局部处理对象,不适合作为长期保存成员,除非能明确保证所有者生命周期更长。

16.5 optional 与 expected 的分工

复制代码
optional<T>:值可能不存在,这是正常情况
expected<T,E>:可能成功,也可能失败,且失败原因重要
异常:异常是意外错误,不用于普通控制流

总结:高性能接口中,string_view/span 解决"借用而不复制",optional 解决"存在或不存在"。但视图类型必须严格遵守生命周期,optional 不应替代完整错误码。


17. 为什么 RAII 是 C++ 资源管理的核心思想?

核心:RAII,Resource Acquisition Is Initialization,即"资源获取即初始化"。它把资源生命周期绑定到对象生命周期:构造函数获取资源,析构函数释放资源;对象离开作用域时自动释放,即使发生异常也能通过栈展开完成清理。

这里的资源不仅是内存,还包括文件句柄、锁、socket、线程、GPU context、模型会话、共享内存等。

17.1 手动资源管理的问题

复制代码
void bad() {
    FILE* fp = std::fopen("a.txt", "r");
    if (!fp) return;

    void* buffer = std::malloc(1024);
    if (!buffer) {
        std::fclose(fp); // 每一个返回路径都要记得释放
        return;
    }

    if (process_failed()) {
        std::free(buffer);
        std::fclose(fp);
        return;
    }

    std::free(buffer);
    std::fclose(fp);
}

一旦分支增多或中途抛异常,就很容易泄漏。RAII 让清理动作自动发生:

复制代码
#include <fstream>
#include <stdexcept>

void good() {
    std::ifstream file("a.txt");
    std::vector<char> buffer(1024);

    if (process_failed()) {
        throw std::runtime_error("failed");
    }

    // 离开作用域时,vector 和 ifstream 自动释放
}

17.2 自定义 RAII 资源类

复制代码
#include <stdexcept>
#include <utility>

class FileHandle {
private:
    FILE* fp = nullptr;

public:
    explicit FileHandle(const char* path, const char* mode) {
        fp = std::fopen(path, mode);
        if (!fp) {
            throw std::runtime_error("open file failed");
        }
    }

    ~FileHandle() {
        if (fp) {
            std::fclose(fp);
        }
    }

    FileHandle(const FileHandle&) = delete;
    FileHandle& operator=(const FileHandle&) = delete;

    FileHandle(FileHandle&& other) noexcept : fp(other.fp) {
        other.fp = nullptr;
    }

    FileHandle& operator=(FileHandle&& other) noexcept {
        if (this != &other) {
            if (fp) std::fclose(fp);
            fp = other.fp;
            other.fp = nullptr;
        }
        return *this;
    }

    FILE* get() const { return fp; }
};

17.3 锁是最常见 RAII

复制代码
#include <mutex>
#include <vector>

std::mutex mtx;
std::vector<int> data;

void push_safe(int x) {
    std::lock_guard<std::mutex> lock(mtx);
    data.push_back(x);
    // 无论正常退出还是异常,都会自动 unlock
}

执行过程:

复制代码
进入作用域
  ├─ lock_guard 构造:lock
  ├─ 执行临界区
  └─ 离开作用域:析构自动 unlock

17.4 智能指针是内存 RAII

复制代码
#include <memory>

void f() {
    auto p = std::make_unique<Buffer>(1024);
    process(*p);
} // 自动 delete,不需要手写释放
  • unique_ptr:独占资源,零额外引用计数开销。
  • shared_ptr:共享所有权,引用计数归零释放。
  • weak_ptr:弱引用,避免循环引用。

17.5 异常安全

RAII 是 C++ 异常安全的基础。异常抛出后,编译器进行栈展开,局部对象按构造逆序析构:

复制代码
构造顺序:A → B → C
异常退出:C → B → A

如果资源不是 RAII 对象,异常可能直接跳过释放语句。

17.6 Rule of Zero / Rule of Five

  • Rule of Zero:业务类尽量只使用 RAII 成员,不手写析构函数。

  • Rule of Five:如果类自己管理资源,就应同时考虑析构、拷贝构造、拷贝赋值、移动构造、移动赋值。

    class GoodSession {
    private:
    std::unique_ptr context;
    std::lock_guardstd::mutex lock; // 实际中注意成员生命周期
    };

在推理服务中,模型会话、设备内存、流句柄、临时 buffer 都可以封装成 RAII 对象:构造时申请,析构时归还。这样可以避免请求异常中断后显存或句柄泄漏。总结:RAII 把"记得释放"变成"不可能忘记释放",是 C++ 区别于很多语言的核心资源管理机制。


18. C++ 程序调用被 C 编译器编译后的函数,为什么要加 extern "C"?

核心:C++ 支持函数重载,编译时会对函数名进行名字修饰;C 编译器不会。若 C++ 直接按 C++ 规则寻找 C 函数符号,链接器会找不到符号。extern "C" 告诉 C++ 编译器按 C 链接规则生成和查找符号。

18.1 名字修饰差异

C 函数:

复制代码
int add(int a, int b);

C 编译后符号通常就是:

复制代码
add

C++ 支持重载:

复制代码
int add(int a, int b);
double add(double a, double b);

编译后符号可能类似:

复制代码
_Z3addii
_Z3adddd

也就是把参数类型编码进符号名。链接器正是靠这些符号解析函数地址。

18.2 不加 extern "C" 的错误

假设有 C 库:

复制代码
// math_c.c
int c_add(int a, int b) {
    return a + b;
}

C++ 直接声明:

复制代码
int c_add(int, int);

int main() {
    return c_add(1, 2);
}

C++ 编译器会寻找 C++ 修饰后的符号,而 C 目标文件里只有 C 风格符号,于是链接报错:

复制代码
undefined reference to `c_add(int, int)'

正确写法:

复制代码
extern "C" int c_add(int a, int b);

int main() {
    return c_add(1, 2);
}

18.3 标准兼容头文件

如果头文件同时被 C 和 C++ 包含:

复制代码
#ifndef MATH_C_H
#define MATH_C_H

#ifdef __cplusplus
extern "C" {
#endif

int c_add(int a, int b);
void c_log(const char* message);

#ifdef __cplusplus
}
#endif

#endif

__cplusplus 只在 C++ 编译时定义,因此 C 编译器不会看到 extern "C" 语法。

18.4 extern "C" 的含义

它指定的是链接约定,不是说函数内部必须用 C 写。函数实现仍然可以是 C++:

复制代码
extern "C" int create_engine() {
    // 内部可以使用 std::string、vector、类
    return 0;
}

但导出后,外部按 C 符号名调用它。

18.5 注意限制

extern "C" 函数不能靠参数重载:

复制代码
extern "C" void f(int);
// extern "C" void f(double); // 冲突:C 链接无法区分重载

更重要的是,C 边界不应随意传递 C++ 对象:

复制代码
extern "C" void process(const std::string& s); // 不推荐跨编译器边界

因为不同编译器、不同标准库、不同编译选项下,std::string 的内存布局和 ABI 可能不同。稳定 SDK 边界通常只传递:

  • 基本类型;
  • 不透明指针;
  • C 结构体;
  • 函数指针;
  • 错误码。

异常也不能穿越 C 调用边界:

复制代码
extern "C" int safe_entry() noexcept {
    try {
        run_cpp_logic();
        return 0;
    } catch (...) {
        return -1;
    }
}

18.6 链接流程图

复制代码
C 源码 ──C编译器──> C 目标文件:符号 add
                         │
C++ 源码 ─C++编译器─> C++ 目标文件:寻找 _Z3addii
                         │
                  不加 extern "C" → 符号不匹配,链接失败
                  加 extern "C"   → 寻找 add,链接成功

总结:extern "C" 的本质是关闭 C++ 的名字修饰,让 C++ 和 C 在链接层使用同一套符号约定。它是 C/C++ 混合编程、动态库导出和稳定 ABI 边界的基础。


19. CMake、ABI 兼容和依赖管理在 SDK 交付中为什么重要?

核心:CMake 决定 SDK 能否在不同平台和编译环境中被正确构建与接入;ABI 决定编译后的库能否被另一个编译器或选项下的程序安全链接;依赖管理决定交付是否可复现、可升级、可审计。三者共同决定 SDK 是"能在作者机器运行"还是"客户能稳定集成"。

19.1 CMake:描述构建而不是手写编译命令

一个现代 CMake 目标通常这样写:

复制代码
cmake_minimum_required(VERSION 3.16)
project(inference_sdk LANGUAGES CXX)

add_library(inference_sdk
    src/session.cpp
    src/tensor.cpp
)

target_include_directories(inference_sdk
    PUBLIC
        $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
        $<INSTALL_INTERFACE:include>
)

target_compile_features(inference_sdk PUBLIC cxx_std_17)

target_link_libraries(inference_sdk
    PRIVATE
        Threads::Threads
)

install(TARGETS inference_sdk
    EXPORT inference_sdkTargets
    LIBRARY DESTINATION lib
    ARCHIVE DESTINATION lib
    RUNTIME DESTINATION bin
    INCLUDES DESTINATION include
)

PRIVATE/PUBLIC/INTERFACE 表达依赖传播:

复制代码
PRIVATE:自己实现需要,调用者不需要
PUBLIC :自己需要,调用者也需要
INTERFACE:自己不需要,调用者需要

SDK 还要安装 xxxConfig.cmake,让客户可以:

复制代码
find_package(inference_sdk REQUIRED)
target_link_libraries(app PRIVATE inference_sdk::inference_sdk)

19.2 ABI 兼容为什么关键

API 是源码层面的接口,ABI 是二进制层面的接口:

复制代码
API 兼容:重新编译后能通过
ABI 兼容:不重新编译,旧程序直接链接新库也能运行

ABI 涉及:

  • 类对象内存布局;
  • 虚函数表顺序;
  • 函数符号修饰;
  • 参数传递和调用约定;
  • 标准库类型布局;
  • 结构体对齐;
  • 模板实例化;
  • 异常对象和 RTTI;
  • 动态库符号可见性。

例如,在类中间增加一个成员变量,可能改变对象布局:

复制代码
// 旧版本
class Session {
    void* handle;
};

// 新版本
class Session {
    int version;
    void* handle; // 偏移变了
};

旧客户程序按旧布局访问,就可能崩溃。虚函数顺序变化也会破坏虚表:

复制代码
class IPlugin {
public:
    virtual void init();
    virtual void run();
    virtual ~IPlugin();
};

已发布接口中不要在中间插入虚函数,只能追加,并明确版本规则。

19.3 稳定 SDK 边界

跨编译器交付时,优先暴露 C ABI:

复制代码
// sdk_c_api.h
#ifdef __cplusplus
extern "C" {
#endif

typedef struct EngineHandle EngineHandle;

EngineHandle* engine_create();
int engine_init(EngineHandle* handle, const char* path);
int engine_run(EngineHandle* handle, const float* input, int size);
void engine_destroy(EngineHandle* handle);

#ifdef __cplusplus
}
#endif

内部继续使用 C++:

复制代码
EngineHandle* engine_create() {
    return reinterpret_cast<EngineHandle*>(new Engine());
}

void engine_destroy(EngineHandle* handle) {
    delete reinterpret_cast<Engine*>(handle);
}

也可以使用 PImpl 隐藏类布局:

复制代码
class Session {
private:
    class Impl;
    std::unique_ptr<Impl> impl;

public:
    Session();
    ~Session();
};

19.4 依赖管理

依赖来源常见有:

复制代码
find_package:使用系统或已安装依赖
FetchContent:配置阶段下载并纳入构建
add_subdirectory:源码内置第三方库
ExternalProject:构建阶段单独构建外部项目
包管理器:vcpkg / Conan

SDK 交付时要回答:

  1. 依赖版本是否固定?
  2. 静态链接还是动态链接?
  3. 第三方许可证是否允许分发?
  4. 是否重复传递依赖导致符号冲突?
  5. 交叉编译时能否找到目标平台依赖?
  6. 客户项目已经使用不同版本依赖时怎么办?
  7. 运行时动态库搜索路径如何处理?

19.5 交付清单

复制代码
include/:公共头文件
lib/:静态库/动态库
cmake/:Config 文件和目标导出
bin/:Windows 运行时 dll 和工具
LICENSES/:第三方许可证
docs/:版本、ABI、升级说明

总结:CMake 解决"怎么正确构建和接入",ABI 解决"编译后的二进制能否安全替换",依赖管理解决"构建是否可复现、许可证和版本是否可控"。 对 SDK 来说,代码功能正确只是第一步,可构建、可链接、可升级同样重要。


20. C++ 中异常处理机制

核心:C++ 使用 throw 抛出异常,用 try/catch 捕获异常。异常沿调用栈向上传播,期间局部对象通过栈展开自动析构。异常适合处理意外错误,正常业务分支更适合返回值、optionalexpected

20.1 基本语法

复制代码
#include <stdexcept>
#include <iostream>

double divide(double a, double b) {
    if (b == 0) {
        throw std::invalid_argument("divide by zero");
    }
    return a / b;
}

int main() {
    try {
        std::cout << divide(10, 0) << '\n';
    } catch (const std::invalid_argument& e) {
        std::cout << "error: " << e.what() << '\n';
    } catch (const std::exception& e) {
        std::cout << "standard exception: " << e.what() << '\n';
    } catch (...) {
        std::cout << "unknown exception\n";
    }
}

捕获顺序应从派生类型到基类类型,通常使用 const T&,避免对象切片和额外拷贝。

20.2 栈展开过程

复制代码
f() throw
  ↑
e() 局部对象析构
  ↑
d() 局部对象析构
  ↑
c() 局部对象析构
  ↑
main() 中 try/catch 捕获

示例:

复制代码
struct Resource {
    ~Resource() {
        std::cout << "release\n";
    }
};

void inner() {
    Resource r;
    throw std::runtime_error("fail");
}

void outer() {
    inner();
}

int main() {
    try {
        outer();
    } catch (const std::exception& e) {
        // r 的析构函数仍然会执行
    }
}

这就是 RAII 与异常机制的配合:局部对象在栈展开过程中自动释放资源。

20.3 标准异常层次

复制代码
std::exception
├─ std::logic_error
│   ├─ invalid_argument
│   ├─ out_of_range
│   └─ length_error
├─ std::runtime_error
│   ├─ overflow_error
│   ├─ underflow_error
│   └─ system_error
├─ std::bad_alloc
├─ std::bad_cast
└─ std::bad_weak_ptr

自定义异常推荐继承 std::exception

复制代码
class SessionError : public std::runtime_error {
public:
    explicit SessionError(const std::string& msg)
        : std::runtime_error(msg) {}
};

20.4 noexcept

复制代码
void safe_cleanup() noexcept {
    // 承诺不抛异常
}

如果 noexcept 函数内部真的抛出异常且没有捕获,程序会调用 std::terminate。移动构造函数通常应标记 noexcept,因为 vector 扩容时只有知道移动不会抛异常,才会安全使用移动而不是拷贝。

20.5 异常性能

主流编译器常使用"零成本异常"模型:

  • 不抛异常时,正常路径开销通常很小。
  • 一旦抛出异常,需要查找处理表、展开栈、析构对象,成本较高。

因此:

复制代码
高频正常路径:不要用异常表达普通分支
真正意外错误:可以使用异常
跨 C ABI:禁止异常穿越
线程入口:最外层兜底捕获

线程函数示例:

复制代码
#include <thread>

void worker() noexcept {
    try {
        run_task();
    } catch (const std::exception& e) {
        log_error(e.what());
    } catch (...) {
        log_error("unknown error");
    }
}

20.6 异常与错误码选择

方式 适合场景 特点
异常 构造失败、不可忽略的意外错误 正常路径简洁,抛出成本高
optional 值可能不存在 不表达失败原因
expected 可预期错误且需要原因 显式返回,调用方必须处理
错误码 C 边界、实时系统、禁用异常环境 无异常开销,但容易忽略

20.7 禁用异常的工程

部分嵌入式、游戏、实时系统会关闭异常,此时应统一错误码或 expected 风格,并确保 RAII 仍可用于资源释放。跨动态库边界尤其不要让异常逃出,因为不同编译器和运行时的异常 ABI 未必兼容。

总结:异常机制不是"返回错误的另一种简单写法",而是一套配合栈展开和 RAII 的错误传播机制。设计接口时要明确哪些错误是正常可预期结果,哪些才是真正异常。


21. 什么时候用 static?

核心:当你需要让局部变量只初始化一次并保持状态、让全局符号只在当前翻译单元可见、让成员变量被所有对象共享、让成员函数不依赖具体对象时,使用 static

21.1 函数内持久状态

需要跨多次函数调用保留值,但又不希望暴露为全局变量:

复制代码
#include <iostream>

int next_id() {
    static int id = 0;
    return ++id;
}

int main() {
    std::cout << next_id(); // 1
    std::cout << next_id(); // 2
}

适合局部缓存、一次性初始化、Meyers 单例:

复制代码
class Config {
public:
    static Config& instance() {
        static Config config;
        return config;
    }

private:
    Config() = default;
};

C++11 保证局部 static 初始化线程安全。

21.2 当前源文件私有辅助函数或变量

复制代码
// parser.cpp
static int internal_state = 0;

static void helper() {
    // 只能在 parser.cpp 中访问
}

这可以避免与其他 .cpp 中的同名符号冲突。现代 C++ 更推荐匿名命名空间:

复制代码
namespace {
    int internal_state = 0;

    void helper() {}
}

21.3 类对象之间共享数据

复制代码
#include <iostream>

class Request {
private:
    inline static int total = 0;

public:
    Request() { ++total; }
    ~Request() { --total; }

    static int active_count() {
        return total;
    }
};

所有对象共享同一份 total,适合对象计数、全局注册表、类级配置。

21.4 不依赖对象的工具函数

复制代码
class MathUtil {
public:
    static int square(int x) {
        return x * x;
    }
};

int main() {
    int x = MathUtil::square(5);
}

静态成员函数没有 this,不能访问普通非静态成员,不能是虚函数,也不能加 const

21.5 使用决策表

需求 是否使用 static
函数调用后保留局部值 用 static 局部变量
只初始化一次的单例 用函数内 static 对象
隐藏 .cpp 内部辅助函数 用 static 或匿名命名空间
所有对象共享计数器 用 static 成员变量
不访问对象状态的工具函数 用 static 成员函数或命名空间函数
每个对象独立的数据 不要用 static
多线程共享可变状态 用 static 但必须加锁或 atomic

21.6 不适合使用的情况

static 本质上会引入隐藏共享状态,滥用会降低可重入性和可测试性:

复制代码
int bad_process(int x) {
    static int cache = 0; // 多线程同时访问可能竞争
    cache = x;
    return cache;
}

多线程下:

复制代码
#include <atomic>

class Counter {
private:
    inline static std::atomic<int> total{0};

public:
    static void add() {
        total.fetch_add(1);
    }
};

还要注意静态对象初始化顺序。不同编译单元中的全局静态对象不应互相依赖,否则可能遇到"静态初始化顺序灾难"。函数内 static 可以规避一部分问题。

21.7 static 五种作用回顾

复制代码
1. static 局部变量:生命周期延长,只初始化一次
2. static 全局变量:内部链接,当前文件可见
3. static 函数:内部链接,当前文件可见
4. static 成员变量:类所有,对象共享
5. static 成员函数:属于类,无 this

总结:static 同时管理"生命周期、链接属性、类级共享"三件事。使用前先问:这个状态到底属于当前对象、当前函数调用、当前源文件,还是整个类?


22. 头文件中的 #ifndef/#define/#endif 有什么作用?

核心:它们构成头文件保护宏,防止同一个头文件在同一个翻译单元中被重复包含,从而避免类、结构体、函数声明、模板定义重复出现而导致编译错误。

22.1 为什么会重复包含

复制代码
// a.h
class A {};

// b.h
#include "a.h"
class B {};

// main.cpp
#include "a.h"
#include "b.h"

预处理后,main.cpp 中实际变成:

复制代码
class A {};
class A {}; // 重复定义
class B {};

于是编译器报错:A 重定义。

22.2 头文件保护写法

复制代码
#ifndef MODULE_STUDENT_H
#define MODULE_STUDENT_H

#include <string>

class Student {
private:
    std::string name;

public:
    explicit Student(std::string name);
};

#endif // MODULE_STUDENT_H

第一次包含时:

复制代码
MODULE_STUDENT_H 未定义
  → 定义 MODULE_STUDENT_H
  → 保留头文件内容

第二次包含时:

复制代码
MODULE_STUDENT_H 已定义
  → #ifndef 条件为假
  → 跳过头文件内容

22.3 预处理展开示意

复制代码
第一次 #include "student.h"
┌────────────────────────┐
│ #ifndef STUDENT_H      │ 未定义,进入
│ #define STUDENT_H      │ 定义保护宏
│ class Student { ... }; │ 保留
│ #endif                 │ 结束
└────────────────────────┘

第二次 #include "student.h"
┌────────────────────────┐
│ #ifndef STUDENT_H      │ 已定义,整段跳过
└────────────────────────┘

22.4 与 #pragma once 的区别

复制代码
#pragma once

class Student {};

#pragma once 由编译器根据文件物理身份保证只包含一次,写法简洁,不会出现宏名冲突。大多数主流编译器都支持。

对比 include guard #pragma once
标准程度 C/C++ 标准支持 编译器扩展,但支持极广
拷贝文件/硬链接场景 更可控 依赖编译器识别同一文件
可移植性 最好 主流平台基本无问题

很多工程会同时使用:

复制代码
#pragma once
#ifndef PROJECT_STUDENT_H
#define PROJECT_STUDENT_H

class Student {};

#endif

22.5 它不能解决什么

头文件保护只防止同一个翻译单元内重复包含 ,不能阻止不同 .cpp 分别包含同一个头文件,这本来就是正常行为:

复制代码
a.cpp ──包含──> student.h
b.cpp ──包含──> student.h

每个 .cpp 是独立翻译单元,都会看到类定义,这是允许的。它也不能自动解决循环依赖:

复制代码
// a.h
#include "b.h"
class A { B* b; };

// b.h
#include "a.h"
class B { A* a; };

如果只是指针或引用,可以用前置声明减少包含:

复制代码
class B;

class A {
    B* b;
};

22.6 宏命名建议

保护宏应尽量唯一,通常使用:

复制代码
项目名_目录_文件名_H

例如:

复制代码
#ifndef INFERENCE_SDK_CORE_SESSION_H
#define INFERENCE_SDK_CORE_SESSION_H

总结:#ifndef/#define/#endif 是预处理阶段的重复包含防护,它保证一个头文件在同一个 .cpp 展开结果中只出现一次,是 C/C++ 传统工程中避免重定义错误的基础手段。


23. 内联函数在编译时是做参数类型检查吗?

核心:是的,内联函数是真正的 C++ 函数,会在编译阶段进行参数类型检查、返回值检查和作用域检查;这正是它比函数式宏安全的根本原因。但 inline 只是内联建议,不保证函数体一定被展开。

23.1 宏没有类型检查

复制代码
#include <iostream>

#define SQUARE(x) ((x) * (x))

int main() {
    int n = 3;
    std::cout << SQUARE(n++) << '\n';
}

宏在预处理阶段直接文本替换:

复制代码
((n++) * (n++))

它不知道 x 的类型,也不会检查参数是否合理,还会造成多次求值。

23.2 inline 函数有完整类型检查

复制代码
inline int square(int x) {
    return x * x;
}

int main() {
    int n = 3;
    int y = square(n++); // 参数只求值一次,n 按值传入
}

编译器会检查:

  • 实参能否转换为形参类型;
  • 返回值类型是否匹配;
  • 函数体中的操作是否合法;
  • 名称查找和访问权限是否正确。

模板内联函数同样会在实例化时进行类型检查:

复制代码
template <class T>
inline T square(T x) {
    return x * x;
}

如果 T 不支持乘法,编译期直接报错。

23.3 inline 的真正含义

很多人误以为 inline 就是"把函数代码复制到调用处"。实际上它有两层含义:

  1. 建议编译器内联展开,编译器可以忽略。
  2. 允许函数在多个翻译单元中出现相同定义,即突破单一定义规则中对函数的普通限制。

第二点在头文件中尤其重要:

复制代码
// math.h
#pragma once

inline int add(int a, int b) {
    return a + b;
}

多个 .cpp 都包含这个头文件时,不会产生重复定义链接错误。编译器或链接器会合并这些相同定义。

23.4 类内定义函数隐式 inline

复制代码
class Calculator {
public:
    int add(int a, int b) const {
        return a + b; // 类内定义,隐式 inline
    }

    int sub(int a, int b) const;
};

inline int Calculator::sub(int a, int b) const {
    return a - b;
}

23.5 编译器是否内联由它决定

以下情况编译器可能拒绝内联:

  • 函数太大;

  • 函数包含循环或复杂分支;

  • 函数是虚函数且真实类型运行时才知道;

  • 函数地址被取用;

  • 调试构建关闭优化;

  • 跨动态库边界。

    inline virtual void f() {}
    // 通过指针动态调用时仍可能无法内联

现代 C++ 更推荐让编译器根据优化级别自动判断,不必给小函数机械添加 inline。但如果函数定义放在头文件中,inline 仍然承担 ODR 作用。

23.6 对比宏、inline、模板

方式 处理阶段 类型检查 多次求值 适合场景
函数式宏 预处理 可能 极少,需利用文本特性时
inline 函数 编译阶段 不会 头文件中的小函数
模板函数 编译实例化 有,且更强 不会 类型无关通用逻辑
普通函数 编译/链接 不会 一般函数

23.7 编译流程位置

复制代码
源代码
  ↓
预处理:宏展开、#include,不做 C++ 类型检查
  ↓
编译:词法/语法/语义分析,inline 函数在这里做类型检查
  ↓
优化:决定是否内联展开
  ↓
汇编/链接:合并 inline 定义

所以准确回答是:内联函数当然会做参数类型检查,因为它不是宏;预处理只负责文本,类型检查发生在编译阶段。inline 是否展开是编译器优化决策,而它在头文件中允许多处定义的链接语义同样重要。


24. C++ 移动语义和完美转发如何减少数据拷贝?

核心:拷贝是复制一份资源,移动是转移资源所有权;std::move 把左值强制转换成右值引用,让移动构造或移动赋值接管资源;完美转发通过转发引用和 std::forward 保持参数原本的左值/右值属性,避免中间层产生多余拷贝。

24.1 拷贝与移动的区别

复制代码
#include <cstring>
#include <utility>

class Tensor {
private:
    float* data = nullptr;
    std::size_t size = 0;

public:
    explicit Tensor(std::size_t n) : size(n) {
        data = new float[n];
    }

    ~Tensor() {
        delete[] data;
    }

    // 拷贝构造:深拷贝
    Tensor(const Tensor& other) : size(other.size) {
        data = new float[size];
        std::memcpy(data, other.data, size * sizeof(float));
    }

    // 移动构造:直接接管资源
    Tensor(Tensor&& other) noexcept
        : data(other.data), size(other.size) {
        other.data = nullptr;
        other.size = 0;
    }
};

对比:

复制代码
拷贝:
源对象 ──复制一块新内存──> 新对象
源对象仍然完整保留

移动:
源对象 ──指针所有权转移──> 新对象
源对象进入有效但状态未指定的 moved-from 状态

24.2 std::move 本身不移动任何东西

复制代码
Tensor a(1024 * 1024);
Tensor b = a;              // 拷贝
Tensor c = std::move(a);   // 选择移动构造

std::move(a) 只是一个类型转换,近似:

复制代码
static_cast<Tensor&&>(a);

真正执行资源转移的是移动构造函数或移动赋值运算符。移动后的 a 仍然是合法对象,可以重新赋值或析构,但不要假设其值具体是什么。

24.3 vector 中的移动收益

复制代码
#include <vector>
#include <string>

std::vector<std::string> pipeline;

std::string large_text = load_big_text();

pipeline.push_back(large_text);             // 拷贝
pipeline.push_back(std::move(large_text));  // 移动,避免复制大字符串
pipeline.emplace_back(1024, 'x');           // 原地构造

vector 扩容时,如果元素的移动构造是 noexcept,它会直接移动;否则为了异常安全,可能退化为拷贝。因此移动构造和移动赋值通常应标记 noexcept

24.4 右值引用成员接口

复制代码
class Request {
private:
    std::vector<float> payload;

public:
    void set_payload(const std::vector<float>& data) {
        payload = data; // 左值:拷贝
    }

    void set_payload(std::vector<float>&& data) {
        payload = std::move(data); // 右值:移动
    }
};

更简洁的方式是按值传参再移动:

复制代码
void set_payload(std::vector<float> data) {
    payload = std::move(data);
}

调用方传左值时发生一次拷贝,传右值时发生移动。

24.5 完美转发

普通包装函数可能丢失右值属性:

复制代码
template <class T>
void bad_wrapper(T x) {
    target(x); // x 是左值,即使调用者传右值,也按左值处理
}

转发引用配合 std::forward

复制代码
#include <utility>

template <class... Args>
void wrapper(Args&&... args) {
    target(std::forward<Args>(args)...);
}

Args&& 在模板类型推导下是转发引用,也称万能引用:

复制代码
void use(std::string s);

template <class T>
void relay(T&& value) {
    use(std::forward<T>(value));
}

int main() {
    std::string s = "hello";

    relay(s);                  // 左值仍按左值传
    relay(std::string("tmp")); // 临时对象继续按右值移动
}

24.6 工厂和 emplace

复制代码
#include <memory>
#include <string>

struct Model {
    Model(std::string name, int device);
};

template <class... Args>
std::unique_ptr<Model> make_model(Args&&... args) {
    return std::make_unique<Model>(std::forward<Args>(args)...);
}

参数被原样转发给构造函数,避免中间临时对象。

24.7 在数据管线中的价值

复制代码
未优化:
读取数据 → 复制成 Request → 复制到队列 → 复制给 Worker → 复制到输出

移动/转发后:
读取数据 → 构造 Request → 所有权移动到队列 → 所有权移动给 Worker → 移动输出

大块 tensor、字符串、vector、文件句柄、模型上下文都属于"移动远便宜于拷贝"的对象。

注意事项:

  • 不要移动后继续使用原对象的业务数据。
  • const T&& 基本没有实用价值,因为不能修改源对象。
  • 基本类型移动和拷贝成本相同。
  • 移动函数应保证源对象仍可安全析构和重新赋值。
  • 跨接口移动时明确所有权转移方向。

总结:移动语义把"复制资源"变成"转移句柄",完美转发保证泛型中间层不破坏值类别;二者组合可以显著减少大缓冲区、容器和请求对象在管线中的复制次数。


25. C++ 模板、CRTP 和类型擦除如何用于高性能框架?

核心:模板在编译期生成类型专用代码,实现零运行时开销的静态多态;CRTP 通过派生类作为基类模板参数,在编译期实现多态和能力混入;类型擦除在边界隐藏具体类型,对外提供统一运行时接口。高性能框架通常内部用模板和 CRTP 追求性能,边界用类型擦除保持扩展性。

25.1 模板:编译期泛型

复制代码
#include <vector>
#include <numeric>

template <class T>
T sum(const std::vector<T>& values) {
    T total{};
    for (const auto& x : values) {
        total += x;
    }
    return total;
}

编译器为 sum<int>sum<float> 分别生成专用代码,没有虚函数调用,也没有运行时类型判断。

C++20 可用 concepts 约束类型:

复制代码
#include <concepts>

template <std::arithmetic T>
T scale(T value, T factor) {
    return value * factor;
}

25.2 CRTP:静态多态

普通虚函数多态:

复制代码
class LayerBase {
public:
    virtual void forward() = 0;
};

每次调用可能经过虚表。CRTP 写法:

复制代码
template <class Derived>
class LayerBase {
public:
    void forward() {
        static_cast<Derived*>(this)->forward_impl();
    }
};

class ConvLayer : public LayerBase<ConvLayer> {
public:
    void forward_impl() {
        // 卷积实现
    }
};

class ReluLayer : public LayerBase<ReluLayer> {
public:
    void forward_impl() {
        // ReLU 实现
    }
};

调用关系:

复制代码
LayerBase<Derived>::forward()
  → 编译期已知 Derived
  → static_cast<Derived*>
  → 直接调用 Derived::forward_impl()
  → 可内联,无 vptr/vtable

CRTP 也适合实现通用能力混入:

复制代码
template <class Derived>
class EqualityComparable {
public:
    friend bool operator!=(const Derived& a, const Derived& b) {
        return !(a == b);
    }
};

class Point : public EqualityComparable<Point> {
public:
    int x, y;

    bool operator==(const Point& other) const {
        return x == other.x && y == other.y;
    }
};

25.3 类型擦除:隐藏具体类型

模板性能高,但类型必须编译期已知,容器中不能自然存放不同模板类型。类型擦除把具体实现藏在统一接口后面。

手写简化版:

复制代码
#include <memory>
#include <utility>

class AnyTask {
private:
    struct Concept {
        virtual ~Concept() = default;
        virtual void run() = 0;
    };

    template <class T>
    struct Model : Concept {
        T object;

        explicit Model(T obj) : object(std::move(obj)) {}

        void run() override {
            object.run();
        }
    };

    std::unique_ptr<Concept> ptr;

public:
    template <class T>
    AnyTask(T task) : ptr(std::make_unique<Model<T>>(std::move(task))) {}

    void run() {
        ptr->run();
    }
};

于是不同类型可以放进同一容器:

复制代码
#include <vector>

class DecodeTask {
public:
    void run();
};

class EncodeTask {
public:
    void run();
};

int main() {
    std::vector<AnyTask> tasks;
    tasks.emplace_back(DecodeTask{});
    tasks.emplace_back(EncodeTask{});

    for (auto& task : tasks) {
        task.run();
    }
}

标准库中的 std::functionstd::anystd::shared_ptr 的删除器都使用了类型擦除思想。

25.4 三者分工

复制代码
模板
├─ 优点:编译期展开、可内联、零虚调用
├─ 缺点:不同类型代码膨胀,类型必须编译期知道
└─ 场景:热路径、算法、数值计算

CRTP
├─ 优点:静态多态、无虚表、可复用基类能力
├─ 缺点:继承写法不直观,不能运行时替换
└─ 场景:算子层、Mixin、统一接口但类型固定

类型擦除
├─ 优点:统一运行时接口,可存放异构对象
├─ 缺点:通常有虚调用或间接访问
└─ 场景:框架边界、任务队列、插件、回调

25.5 框架中的组合方式

复制代码
编译期热路径:
TensorOp<T> / CRTP Layer
  → 内联、优化、向量化、零虚调用

运行时边界:
统一 Scheduler / Task / Plugin
  → 类型擦除,接收不同算子和任务类型

示例:

复制代码
template <class Derived>
class OperatorBase {
public:
    void execute() {
        static_cast<Derived*>(this)->compute();
    }
};

class AddOp : public OperatorBase<AddOp> {
public:
    void compute();
};

class MulOp : public OperatorBase<MulOp> {
public:
    void compute();
};

内部算子使用 CRTP,外部调度器使用类型擦除:

复制代码
std::vector<AnyTask> runtime_tasks;

25.6 与虚函数比较

技术 分派时间 是否需要共同基类 适用范围
虚函数 运行时 需要 经典多态、插件
模板 编译期 不需要 算法泛型
CRTP 编译期 需要模板基类 静态多态、能力复用
类型擦除 运行时 对外不需要暴露具体基类 异构对象统一封装

总结:模板和 CRTP 用编译期信息换运行时性能,类型擦除用少量运行时间接换接口统一。高性能框架不是完全排斥虚函数,而是在热路径静态化,在扩展边界动态化。


第 14~25 项总结

题号 关键词 核心结论
14 C++17/20/23 重点掌握零拷贝视图、概念约束、范围处理、协程、expected 与 mdspan
15 STL 选择 数据面优先连续内存,控制面按查找和顺序需求选 map/unordered_map
16 view/optional 视图借用内存避免拷贝,optional 表达可能缺失
17 RAII 资源绑定对象生命周期,构造获取、析构释放
18 extern "C" 关闭 C++ 名字修饰,实现 C/C++ 链接兼容
19 SDK 交付 CMake 管构建,ABI 管二进制兼容,依赖管理管可复现
20 异常 throw/try/catch 配合栈展开和 RAII,异常不能跨 C 边界
21 static 管理持久局部状态、内部链接、类共享成员和静态函数
22 include guard 防止同一翻译单元重复包含头文件
23 inline 是真正函数,有类型检查;是否展开由编译器决定
24 move/forward 转移所有权、保留值类别,减少大对象复制
25 模板/CRTP/擦除 热路径静态多态,框架边界类型擦除

整体记忆主线:

复制代码
现代接口:string_view / span / optional / expected
现代资源:RAII / unique_ptr / 移动语义
现代泛型:template / concepts / CRTP / 类型擦除
现代交付:CMake / ABI / extern "C" / 依赖管理
现代健壮性:异常、static、头文件保护、inline 规则
相关推荐
OPEN-F29 分钟前
C++11/14新特性精讲:移动语义与智能指针实战
开发语言·c++·算法
洋不写bug30 分钟前
绕过权限检查,访问修改私有属性,反射,枚举,lambda
java·枚举·lambda·反射
小灰灰搞电子30 分钟前
Rust+Slint 实现动态轮播图源码分享,支持动态删除、添加
开发语言·rust·slint·动态轮播图
前端 贾公子34 分钟前
第09章:上下文与记忆 (6)
开发语言·前端·python
evans在进步37 分钟前
Java 常用设计模式入门:建造者、工厂、单例、外观与代理
java·python·设计模式
闭月之泪舞41 分钟前
C++编程学习
c++·学习
lisin-lee-cooper1 小时前
【leetcode658】有序数组找出k个最接近x的数
java·数据结构·算法
sunburn-1 小时前
Java堆(Heap)详解与实战教学
java·开发语言·数据结构·ide·算法
Kyrie_kk1 小时前
Java--TimeUnit时间单位枚举类(时间单位规范)
java·后端