Skip to content

类型推导、值类别与语言规则 ​

const 与 constexpr ​

const 表示对象不能通过该名字/访问路径被修改;它的值可以在运行期才确定:

cpp
const int retry_limit = read_config(); // 合法,运行期常量

constexpr 表示变量或函数可以参与常量求值;用来初始化 constexpr 变量的表达式必须在编译期可求值:

cpp
constexpr int square(int x) { return x * x; }
constexpr int buffer_size = square(16); // 256
场景更合适的选择
运行期确定后不再修改的值、只读参数、const 成员函数const
数组界限、非类型模板参数、case 标签、编译期表/数学常量constexpr
希望函数既可编译期计算也可运行期调用constexpr 函数

constexpr 不等于“永远只在编译期运行”:当参数是运行期值时,constexpr 函数仍可在运行期执行。consteval(C++20)才要求每次调用都在编译期求值。

mutable:逻辑常量性,不是绕过设计的后门 ​

mutable 只能修饰类的非静态、非引用、非 const 数据成员。它允许 const 成员函数,以及经 const 对象访问的路径,修改这个成员;其他非 mutable 成员仍不可修改:

cpp
class Polygon {
public:
    void add_point(Point point) {
        points_.push_back(point);
        cached_area_.reset(); // 可观察状态变化时,主动使缓存失效
    }

    double area() const {
        if (!cached_area_) {
            cached_area_ = calculate_area(points_); // 合法:缓存是 mutable
        }
        return *cached_area_;
    }

private:
    std::vector<Point> points_;                  // 不能在 area() const 中修改
    mutable std::optional<double> cached_area_;  // 派生缓存,不属于逻辑状态
};

这里区分两种常量性:

  • 位/物理常量性:对象的所有字节都不变;
  • 逻辑常量性:从调用者可观察的抽象状态和结果不变,但可更新缓存、统计信息或同步原语。

mutable 的典型另一个用途是让 const 查询函数能够加锁:

cpp
class OrderStore {
public:
    std::vector<Order> snapshot() const {
        std::lock_guard lock(mutex_);
        return orders_; // 返回副本;业务数据本身不在此修改
    }

private:
    mutable std::mutex mutex_;
    std::vector<Order> orders_;
};

它不提供线程安全:多个线程首次调用 area() 并写缓存仍会产生数据竞争;应使用互斥锁、std::call_once、原子对象或预先计算等明确同步方案。不要把所有“想在 const 函数里改数据”的情况都标记为 mutable:若改变会影响调用者可观察的业务状态,成员函数就不应是 const。mutable 也不能修饰 static 成员、引用成员或本身带 const 的成员。

auto、decltype 与 decltype(auto) ​

auto 按类似模板参数推导的规则从初始化器推导类型,通常会丢弃顶层 const 和引用:

cpp
const int& get_value();

auto a = get_value();           // int:复制值
const auto& b = get_value();    // const int&:显式保留引用

decltype(expr) 获取表达式的精确类型规则:

  • 对未加括号的变量名,得到该变量的声明类型;
  • 对其他左值表达式,得到 T&;对 xvalue 得到 T&&;对 prvalue 得到 T。
cpp
int x = 0;
decltype(x) a = x;   // int
decltype((x)) b = x; // int&,因为 (x) 是左值表达式

decltype(auto) 用 decltype 规则推导变量或函数返回类型,适合完美保留返回表达式:

cpp
template <typename T>
decltype(auto) forward_value(T&& value) {
    return std::forward<T>(value);
}

它也更容易意外返回局部变量引用,因此只有需要保留值类别时使用;普通按值返回优先写明确类型或 auto。

左值、prvalue 与 xvalue ​

  • 左值(lvalue):有可识别身份的表达式,如具名变量、返回 T& 的函数调用;
  • 纯右值(prvalue):用于初始化或计算的纯值,如字面量、返回非引用 T 的函数调用;
  • 将亡值(xvalue):仍有身份但资源可被移走的表达式,如 std::move(x);
  • 右值(rvalue):prvalue 与 xvalue 的统称。

std::move(x) 不移动任何资源,只把 x 转换为 xvalue,让重载决议可以选择移动构造/赋值。具名的 T&& 变量本身仍是左值表达式;继续转发时通常需要 std::move 或 std::forward。

其核心可简化为:

cpp
template <typename T>
constexpr std::remove_reference_t<T>&& my_move(T&& value) noexcept {
    return static_cast<std::remove_reference_t<T>&&>(value);
}

T&& 在这里是转发引用:传左值时 T 推导为 U&,传右值时推导为 U;移除引用后统一转换为 U&&。转换本身不会移动数据,真正的资源转移发生在后续调用的移动构造或移动赋值中。

noexcept 与移动操作 ​

不要把“移动构造函数必须标 noexcept”当作绝对规则;应当只在移动确实不会抛异常时标记:

cpp
class Buffer {
public:
    Buffer(Buffer&& other) noexcept
        : data_(std::exchange(other.data_, nullptr)) {}
private:
    int* data_ = nullptr;
};

当 std::vector 扩容时,若元素可拷贝且移动构造可能抛异常,容器通常倾向拷贝,以保留更强的异常安全保证;若移动为 noexcept,则可安全地移动。对于不可拷贝但移动可能抛异常的类型,容器可能仍需移动,异常保证会变弱。标准不规定容器的增长倍率。

函数默认实参 ​

默认实参(default argument)允许调用者省略一段末尾连续的参数。它是声明处提供给调用点的补全规则,不会生成另一个重载函数,也不是函数类型的一部分:

cpp
#include <chrono>
#include <string_view>

using namespace std::chrono_literals;

void connect(std::string_view host,
             unsigned short port = 443,
             std::chrono::milliseconds timeout = std::chrono::seconds{3});

connect("example.com");                 // 等价于 connect("example.com", 443, 3s)
connect("example.com", 8443);           // 等价于 connect("example.com", 8443, 3s)
connect("example.com", 8443, 500ms);    // 不使用默认实参
// connect("example.com", , 500ms);     // 不合法:不能跳过中间参数

对普通形参而言,一旦某个参数有默认实参,后面的参数也必须在当前声明或同一作用域中此前可见的声明里具有默认实参。默认实参的表达式在每次省略该参数的调用时求值;其中使用的名字在声明默认实参时完成查找。因此适合写常量、轻量配置或工厂函数调用,不应隐藏昂贵 I/O、加锁或可能抛异常的工作。

声明、定义与跨模块边界 ​

默认实参应只写在对调用方公开的声明(通常是头文件)中,定义处不再重复:

cpp
// retry.hpp
void retry(int timeout_ms = 1'000);

// retry.cpp
void retry(int timeout_ms) {
    // ...
}

在同一作用域的后续声明中可以补充此前未出现的默认实参,但不能重新指定已经可见的默认值;实际工程没有必要利用这条规则,统一把默认值集中在一处最清晰。

关键点是:默认实参在调用点按该处可见的声明补入。 因而修改库实现并不能改变已编译调用方传入的旧默认值;发布库接口时,改默认值属于需要重新编译调用方的源代码兼容性变更。函数指针、成员函数指针和 std::function 只保存函数类型,也不会保存默认实参:

cpp
void log_level(int level = 1);

using Log = void (*)(int);
Log log = log_level;

log_level(); // 合法:此处看到默认实参
// log();    // 不合法:函数指针类型要求显式传入 int
log(1);

与重载和虚函数的交互 ​

默认实参可能让多个重载同时可行,从而产生歧义:

cpp
void render(int width);
void render(int width, int height = 0);

// render(800); // 歧义:两个候选都可行

虚函数的覆盖与默认实参也必须分开理解:默认值由静态类型在调用点决定,最终执行的覆盖函数由动态类型在运行期决定。

cpp
struct Shape {
    virtual void draw(int scale = 1);
};

struct Circle : Shape {
    void draw(int scale = 2) override;
};

Circle circle;
Shape& shape = circle;

shape.draw();  // 调用 Circle::draw(1):默认值来自 Shape 声明
circle.draw(); // 调用 Circle::draw(2):默认值来自 Circle 声明

因此不应在同一虚函数层次的不同覆盖中设置不同默认值。需要默认策略时,优先让基类接口统一默认值,或提供非虚包装函数(NVI)/名称不同的便捷重载,再把完整参数显式传给虚函数。

面试速答与设计建议 ​

默认实参在声明处提供,但在调用点按静态可见声明补入;只能省略末尾连续参数,默认值不属于函数类型。它会参与重载可行性判断,并与虚调用分离:默认值看静态类型,函数实现看动态类型。工程中应只在公开声明处定义一次,避免为虚函数覆盖设置不同默认值;复杂配置优先使用选项结构体或具名参数式 Builder,而不是连续多个布尔/整数默认参数。

重载决议 ​

重载决议先找候选函数,筛出可行函数,再比较每个参数的隐式转换序列并选择“整体更好”的函数。常见优先级可粗略理解为:

text
精确匹配 / 引用绑定
  优于 类型提升
  优于 标准转换
  优于 用户定义转换
  优于 省略号转换

实际规则还会比较 const 限定、派生到基类、模板与非模板、模板偏序以及初始化列表等,不能机械地当作单一数字排序。若没有唯一最佳可行函数,调用就歧义:

cpp
void f(int, double);
void f(double, int);

f(1, 2); // 两个候选各需要一次同等级标准转换,歧义

应通过更明确的函数签名、explicit 转换构造函数或调用处 static_cast 消除歧义。

结构化绑定与 std::string_view ​

C++17 结构化绑定让 tuple-like、聚合和 map 元素的解包更清晰:

cpp
for (const auto& [name, score] : scores) {
    // score 对 map 元素的 value 是 const 引用视图
}

绑定说明符决定是否复制:auto [a, b] 通常创建副本,auto& [a, b] 绑定原对象。

std::string_view 是非拥有的字符范围,适合只读参数、解析和切片,避免复制:

cpp
void parse(std::string_view input); // 调用期间立即使用通常安全

它必须不比底层字符存活更久。不要返回局部 std::string 的 view,也不要保存指向临时 std::string、可能 reallocate 的字符串或已释放内存的 view。字符串字面量有静态存储期;由长期存活的 std::string 创建的 view 在原字符串未销毁且未导致重新分配的修改前有效。

使用 Markdown 与 VitePress 构建