【C++入门基础(下)】默认参数、函数重载、引用、inline 与 nullptr

【C++入门基础(下)】默认参数、函数重载、引用、inline 与 nullptr

🔥 本文定位 :本文是 C++ 入门系列的下篇,系统讲清默认参数、函数重载、引用、const 引用、内联函数与 nullptr。这些特性看似零散,实质都围绕"怎样让接口更准确、更安全、更容易使用"展开。

💡 学习目标 :不仅要会写 int&inline,还要真正理解 默认实参在调用点如何生效、重载决议为什么可能产生二义性、引用为什么不能重新绑定、返回引用何时会悬空、inline 为什么不等于强制展开,以及 nullptr 如何避免整数 0 带来的重载歧义

📌 阅读说明 :本文保留原讲义的知识顺序,并修正几个常见但不严谨的说法。尤其要注意:引用的语言语义不等于"实现中绝不占任何空间";inline 的核心规则也不只是编译器优化。


文章目录


一、先建立全局视角:这些语法在解决什么问题

本篇五组语法并不是孤立的记忆点:

特性 主要解决的问题 核心风险
默认参数 常用值不必在每次调用时重复传递 声明位置、重定义、调用点可见性
函数重载 一组相关操作共享名称 转换排序与二义性
引用 用别名表达必选对象、传参和返回 悬空、生命周期、可修改性
inline 支持适合放在头文件中的多处定义语义 把它误解成强制展开
nullptr 用专门类型表达空指针 仍需检查空值,不能消除悬空指针

学习这些特性时,建议始终分三层思考:

text 复制代码
语法写法
  -> 编译器怎样查找和选择
  -> 对接口设计与对象生命周期有什么影响

只记住写法,遇到重载、头文件或生命周期边界时很快就会失效。


二、默认参数:让调用者省略尾部实参

2.1 基本写法

默认参数,也常称默认实参,是函数声明中为形参指定的默认值:

cpp 复制代码
#include <iostream>

void print_line(char ch = '-', int count = 20) {
    for (int i = 0; i < count; ++i) {
        std::cout << ch;
    }
    std::cout << '\n';
}

int main() {
    print_line();          // print_line('-', 20)
    print_line('*');       // print_line('*', 20)
    print_line('=', 8);    // 完整指定
}

调用者省略尾部实参时,编译器使用调用点可见声明中的默认实参完成调用。

2.2 为什么必须从右向左连续提供默认值

合法声明:

cpp 复制代码
void connect(const char* host, int port = 443, bool tls = true);

不合法声明:

cpp 复制代码
// 错误:port 有默认值,但它右侧的 tls 没有默认值
void connect(const char* host, int port = 443, bool tls);

函数调用按位置匹配参数,没有"跳过中间参数、只填写后面参数"的通用语法。如果默认值不从右侧连续出现,调用表达式就无法明确表示省略了哪一个参数。

2.3 默认参数通常写在声明处

头文件:

cpp 复制代码
// logger.h
#pragma once

void log_message(const char* text, int level = 1);

源文件:

cpp 复制代码
// logger.cpp
#include "logger.h"

void log_message(const char* text, int level) {
    // 使用 text 和 level
}

调用文件只需要看到声明,编译器就在调用点补上默认实参。把默认值放在通常对调用者可见的声明中,接口最清晰。

不要在声明和定义中重复写同一个默认值:

cpp 复制代码
void log_message(const char* text, int level = 1);

// 错误:同一作用域中重复指定默认实参
void log_message(const char* text, int level = 1) {
}

更精确地说,非模板函数可以在同一宿主作用域的后续声明中补充尚未提供的默认实参,但不能重定义已有默认实参。工程代码仍应把默认值集中在公开声明处,避免读者到处拼接接口。

2.4 默认参数不属于函数类型

cpp 复制代码
int calculate(int value = 10);

int (*function_pointer)(int) = &calculate; // 正确
// int (*wrong_pointer)() = &calculate;    // 错误

calculate 的函数类型仍然是"接收一个 int,返回一个 int"。默认参数只影响调用表达式可以省略多少尾部实参,不会把函数变成零参数函数。

2.5 默认实参在每次缺省调用时求值

cpp 复制代码
int next_id() {
    static int id = 0;
    return ++id;
}

void create_task(int id = next_id());

create_task(); // 使用 1
create_task(); // 使用 2

默认表达式不是在声明时只计算一次;当调用省略对应实参时,它会被求值。这意味着有副作用的默认表达式会增加接口理解成本,应谨慎使用。

2.6 默认参数是静态接口,不是运行时多态

以后学习虚函数时会遇到一个重要边界:虚函数的动态分派决定调用哪个函数体,但默认实参根据调用表达式处的静态类型和可见声明确定。初学阶段先记住:

默认参数属于调用接口的编译期规则,不会跟随运行时覆盖函数自动变化。

2.7 什么时候不应该继续堆默认参数

下面的调用几乎无法从代码本身看懂:

cpp 复制代码
open_file("data.bin", true, false, 3, 0);

当布尔开关和相似整数越来越多时,可以考虑:

  • 使用枚举表达模式;
  • 使用配置结构体;
  • 提供语义清晰的命名函数;
  • 使用构建器或具名选项风格。

默认参数适合表达"自然且稳定的常用值",不适合掩盖复杂配置。

2.8 修改默认值会影响哪些代码

默认实参由调用点可见声明参与处理。假设库头文件原来是:

cpp 复制代码
void retry(int count = 3);

后来改成:

cpp 复制代码
void retry(int count = 5);

已经编译完成、调用 retry() 的旧目标文件可能仍然带着旧值 3;重新编译调用者后才会使用新值 5。这说明默认参数是源代码接口的一部分,修改它不仅是函数实现内部变化。

发布二进制库时,需要考虑:

  • 调用者是否会重新编译;
  • 默认值变化是否改变行为契约;
  • 旧客户端与新库混用是否可接受;
  • 是否更适合提供新的命名函数或配置对象。

2.9 默认参数与配置结构体的取舍

参数少、含义稳定时:

cpp 复制代码
void draw_line(Color color = Color::black, int width = 1);

配置逐渐复杂时:

cpp 复制代码
struct DrawOptions {
    Color color = Color::black;
    int width = 1;
    bool antialias = true;
    LineStyle style = LineStyle::solid;
};

void draw_line(const DrawOptions& options = {});

配置结构体的优势是字段带名字、添加选项更清晰,也更容易验证整体不变量。默认参数的优势是轻量直接。不要等到函数出现十几个布尔与整数参数后才考虑接口重构。


三、函数重载:同一个名字表达一组相关操作

3.1 什么是函数重载

同一作用域中,一组同名函数可以通过不同的参数列表形成重载:

cpp 复制代码
#include <iostream>
#include <string>

int add(int left, int right) {
    return left + right;
}

double add(double left, double right) {
    return left + right;
}

std::string add(std::string left, const std::string& right) {
    left += right;
    return left;
}

调用时,编译器根据实参和候选函数执行重载决议:

cpp 复制代码
add(1, 2);                       // int 版本
add(1.5, 2.5);                   // double 版本
add(std::string{"A"}, "B");     // string 版本

3.2 哪些差异可以构成重载

常见可区分条件包括:

  • 参数个数不同;
  • 参数类型不同;
  • 参数类型顺序不同;
  • 引用限定或成员函数的 const 限定不同;
  • 模板与非模板候选共同参与选择。
cpp 复制代码
void show(int value);
void show(double value);
void show(int value, int width);
void show(const std::string& value);

3.3 只改变返回类型不能构成重载

cpp 复制代码
int parse();
// double parse(); // 错误:参数列表完全相同

调用 parse() 时,编译器不能仅靠接收结果的上下文稳定地区分两个声明。C++ 不允许只按返回类型重载普通函数。

3.4 顶层 const 也不能制造值参数重载

cpp 复制代码
void process(int value);
// void process(const int value); // 不是新的重载

按值传参会创建函数自己的参数对象,调用者不关心函数体内部是否修改该副本,因此顶层 const 不改变函数类型。

但指向对象的 const 属于类型的一部分,可以区分:

cpp 复制代码
void process(int* pointer);
void process(const int* pointer);

void inspect(int& value);
void inspect(const int& value);

3.5 重载决议不是"名字相同就随便挑一个"

可以先建立一个简化模型:

  1. 名字查找:找到同名候选;
  2. 形成候选集:加入相关普通函数、模板等;
  3. 筛选可行函数:参数个数与转换必须可满足;
  4. 比较转换序列:精确匹配通常优于提升,提升通常优于一般转换;
  5. 选择唯一最佳函数:若没有唯一最佳候选,则报错或二义性。

真实规则还涉及用户定义转换、模板偏序、列表初始化等,初学阶段先抓住"候选---可行---最佳"三层。

3.6 一个典型二义性

cpp 复制代码
void print(long value);
void print(double value);

print(10); // int -> long 与 int -> double 都需要转换,可能二义

解决方法不是"猜编译器会选哪个",而是让接口或实参更明确:

cpp 复制代码
print(10L);
print(10.0);

3.7 默认参数与重载可能互相制造歧义

cpp 复制代码
void run();
void run(int count = 1);

// run(); // 二义:两个候选都可以无实参调用

默认参数和重载各自都能简化接口,但组合不当会让调用集合重叠。设计时应检查每种调用形式是否只有一个明显目标。

3.8 C 为什么不能直接使用同样的重载

C++ 编译器通常会在符号中编码函数名、参数类型、命名空间等信息,常被称为 name mangling。C 语言的链接接口通常不采用同一套重载机制。

与 C 库交互时常见:

cpp 复制代码
extern "C" int c_api_start(int mode);

extern "C" 指定 C 语言链接。名称修饰的具体格式属于实现细节,不要手写或依赖某个编译器的编码结果。

3.9 常见转换的简化优先级

入门阶段可以先用下面的近似顺序理解:

匹配类别 示例 一般理解
精确匹配 int 实参到 int 形参 最优先
提升 charintfloatdouble 优于一般转换
一般标准转换 intlongintdouble 可能互相无法分出高下
用户定义转换 构造函数、转换函数 需要调用用户代码定义的转换
省略号匹配 ... 通常最弱

真实标准规则比这张表更细,不能只比较"转换次数"。当结果不符合直觉时,应构造最小示例并查重载决议规则,而不是继续增加强制转换掩盖接口问题。

3.10 引用重载经常表达可修改性差异

cpp 复制代码
void inspect(int& value) {
    std::cout << "modifiable lvalue\n";
}

void inspect(const int& value) {
    std::cout << "read-only or temporary\n";
}
cpp 复制代码
int value = 10;
const int fixed = 20;

inspect(value); // int& 精确绑定
inspect(fixed); // const int&
inspect(30);    // const int& 可绑定临时量

这种重载可以表达对象是否可修改。以后学习右值引用后,还可能看到 T&& 重载用于移动语义。

3.11 好的重载是一组"同概念操作"

不要因为 C++ 允许重载,就把无关操作塞进同一个名字:

cpp 复制代码
// 难以理解:两个 save 表达完全不同概念
void save(const Document& document);
int save(int left, int right);

重载名称应让调用者在不知道具体类型细节时,仍能预测操作含义。若功能语义不同,使用不同动词往往更清楚。


四、引用:给已有对象建立别名

4.1 基本概念

cpp 复制代码
int value = 10;
int& alias = value;

alias += 5;
std::cout << value << '\n'; // 15

aliasvalue 的引用。对 alias 的访问作用于它所绑定的对象。

4.2 引用必须初始化

cpp 复制代码
int value = 10;
int& reference = value;

// int& invalid; // 错误:引用必须绑定对象

语言不提供普通的"空左值引用"状态。通过解引用空指针等非法方式强行构造引用,会产生未定义行为,不是合法的"空引用"用法。

4.3 引用不能重新绑定

cpp 复制代码
int first = 10;
int second = 20;

int& reference = first;
reference = second;

最后一行不是让 reference 改为引用 second,而是把 second 的值赋给 first。执行后:

text 复制代码
first == 20
second == 20
reference 仍然是 first 的别名

4.4 一个对象可以有多个引用

cpp 复制代码
int score = 90;
int& current = score;
int& displayed = current;

++displayed;

currentdisplayed 最终都绑定 score。引用引用仍得到原对象的别名,不会形成一条需要逐层解引用的运行时链。

4.5 引用在语言层面是别名,不等于实现中永不占空间

常见教学说法是"引用不开空间"。更准确的理解是:

  • C++ 语言把引用表达为对对象或函数的绑定关系;
  • sizeof(reference) 得到的是被引用类型的大小;
  • 对引用取地址,得到被引用对象的地址;
  • 编译器在许多局部场景中不需要为引用单独保存数据;
  • 但引用作为类的数据成员、跨越 ABI 边界或无法优化时,实现通常需要某种表示。

因此不要通过"引用到底是不是指针实现的"来推导语言规则。实现可以使用地址,也可以优化掉;程序应依赖引用语义。

4.6 sizeof 与取地址

cpp 复制代码
int value = 42;
int& reference = value;

static_assert(sizeof(reference) == sizeof(int));
std::cout << (&reference == &value) << '\n'; // 1

这说明表达式中的引用表现为被引用对象,不意味着每一种 ABI 中引用成员都不需要表示空间。

4.7 左值引用只是引用体系的第一部分

本文主要讨论 T&,它通常称为左值引用。C++11 还引入了右值引用 T&&

cpp 复制代码
std::string&& temporary = std::string{"hello"};

右值引用是移动语义和完美转发的重要基础,但不能简单解释为"可以修改的临时量引用"。后续还涉及值类别、引用折叠、移动构造与转发规则。

初学阶段先把两条主线分开:

text 复制代码
T& / const T&
  -> 别名、传参、返回、只读绑定

T&&
  -> 资源转移、重载选择、泛型转发

不要在尚未理解对象生命周期时,为了"优化"把所有参数改成 T&&


五、引用传参与引用返回

5.1 按值传参:函数得到副本

cpp 复制代码
void increment(int value) {
    ++value;
}

int number = 10;
increment(number);
// number 仍为 10

按值传参适合小型、廉价复制的类型,也能明确表达函数不修改调用者对象。

5.2 非 const 引用传参:函数可以修改实参

cpp 复制代码
void increment(int& value) {
    ++value;
}

int number = 10;
increment(number);
// number 变为 11

经典交换函数:

cpp 复制代码
void swap_values(int& left, int& right) {
    int temp = left;
    left = right;
    right = temp;
}

工程中优先使用标准库的 std::swap,这个例子主要用于理解引用传参。

5.3 const 引用传参:只读访问并避免不必要复制

cpp 复制代码
#include <string>

void print_name(const std::string& name) {
    std::cout << name << '\n';
}

const std::string& 表示函数观察传入字符串但不通过该引用修改它。对于大型对象,这通常能避免一次复制。

但不要把"所有参数都写成 const T&"当成机械规则:

  • int、指针、迭代器等小型类型按值传递通常更自然;
  • 函数需要取得所有权时,按值参数可能更适合;
  • 泛型代码还要考虑转发引用和 Concepts;
  • 性能应结合类型与调用方式判断。

5.4 引用参数与指针参数都能修改调用者

cpp 复制代码
void reset_by_pointer(int* value) {
    if (value != nullptr) {
        *value = 0;
    }
}

void reset_by_reference(int& value) {
    value = 0;
}

接口语义不同:

  • 指针天然可以表达"没有对象",调用者传 nullptr
  • 引用通常表达"必须提供一个有效对象";
  • 指针可重新指向其他对象,引用不能重新绑定;
  • 引用语法更接近直接访问对象。

5.5 返回引用可以让结果继续代表原对象

cpp 复制代码
int& at(int* data, std::size_t index) {
    return data[index];
}

int values[]{10, 20, 30};
at(values, 1) = 99;

at(values, 1) 返回的是 values[1] 的引用,因此可以出现在赋值号左侧。标准容器的 operator[] 也经常返回元素引用。

5.6 返回引用最大的风险是悬空

错误示例:

cpp 复制代码
int& broken() {
    int local = 42;
    return local; // 错误:函数结束后 local 生命周期结束
}

调用者拿到的引用不再指向存活对象。即使程序某次"看起来输出 42",行为仍不可靠。

安全返回引用的基本前提是:被引用对象的生命周期必须覆盖返回引用的所有使用。

常见可行来源:

  • 调用者传入且仍然存活的对象;
  • 生命周期足够长的静态对象;
  • 某个仍存活对象的成员或元素;
  • 由接口契约保证不会失效的资源。

5.7 修正一个栈顶示例中的边界

如果 top 表示下一个可插入位置,栈顶元素下标应为 top - 1

cpp 复制代码
struct Stack {
    int* data;
    std::size_t top;
    std::size_t capacity;
};

int& stack_top(Stack& stack) {
    assert(stack.top > 0);
    return stack.data[stack.top - 1];
}

返回 stack.data[stack.top] 会访问有效元素范围之外的位置。引用返回不会替你修复下标错误,反而可能让错误位置成为可修改左值。

5.8 指针引用可以替代部分二级指针写法

cpp 复制代码
struct Node {
    int value;
    Node* next;
};

void push_front(Node*& head, int value) {
    head = new Node{value, head};
}

Node*& 是"指向 Node 的指针变量"的引用。函数可以修改调用者的 head 指针本身。它与 Node** 都能表达类似能力,但接口风格不同。

在现代 C++ 代码中,还应进一步考虑所有权是否应该由 std::unique_ptr<Node> 表达,而不是只在裸指针与二级指针之间选择。

5.9 引用不能直接放进普通容器元素类型

引用不是普通可重新赋值对象,因此不能直接写:

cpp 复制代码
// std::vector<int&> references; // 不满足容器元素要求

如果确实需要保存对既有对象的引用关系,可以考虑:

cpp 复制代码
#include <functional>
#include <vector>

int first = 1;
int second = 2;

std::vector<std::reference_wrapper<int>> references{
    std::ref(first),
    std::ref(second)
};
references[0].get() = 10;

也可以保存指针,但要明确空值、生命周期和失效规则。reference_wrapper 不拥有对象,被引用对象销毁后同样会悬空。

5.10 容器元素引用为什么容易失效

cpp 复制代码
std::vector<int> values{1, 2, 3};
int& first = values[0];

values.push_back(4); // 如果触发重新分配,first 可能失效

引用本身没有"自动追踪对象搬家"的能力。容器操作是否使引用、指针和迭代器失效,取决于容器类型与具体操作。学习每个容器时,都要把失效规则与接口一起掌握。


六、const 引用、临时对象与生命周期

6.1 权限可以缩小,不能通过普通引用放大

cpp 复制代码
const int constant = 10;

// int& wrong = constant;       // 错误:会获得修改 const 对象的能力
const int& correct = constant;  // 正确

普通对象可以绑定到 const 引用:

cpp 复制代码
int value = 20;
const int& view = value;

// view = 30; // 错误:不能通过 view 修改
value = 30;   // 正确:对象本身不是 const

const 限制的是通过该表达式访问对象时的权限,并不会把原对象永久改造成常量。

6.2 为什么普通引用不能绑定右值临时对象

cpp 复制代码
int value = 10;

// int& invalid = value * 3;
const int& valid = value * 3;

value * 3 产生临时结果。允许非常量左值引用绑定它,会给人一种可以长期修改稳定对象的错觉。const 左值引用可以绑定临时对象,并在特定直接绑定场景延长其生命周期。

6.3 类型转换也可能产生临时对象

cpp 复制代码
double price = 12.34;
const int& rounded = price;

这里 rounded 不引用原来的 double 对象,而是绑定到把 price 转换为 int 后得到的临时对象。修改 price 不会同步改变 rounded

6.4 一张表看懂常见绑定

表达式 int& const int& 说明
int 左值 可以 可以 后者只读访问
const int 左值 不可以 可以 不能放大修改权限
int 临时值 不可以 可以 直接绑定时可延长生命周期
double 左值绑定到 int 引用 不可以 可以产生转换临时量 引用的不是原 double 对象

6.5 生命周期延长不是"引用让所有临时量永生"

cpp 复制代码
const std::string& text = std::string{"hello"};

在这种局部变量直接绑定的典型场景中,临时 std::string 的生命周期延长到 text 的生命周期结束。

但不要把规则扩大成"只要返回或传递一个 const 引用,临时对象就一直有效"。例如:

cpp 复制代码
const std::string& bad() {
    return std::string{"temporary"}; // 返回后悬空
}

函数返回引用不会把函数内部临时对象的生命周期延长到调用者。判断引用安全时,要追踪对象在哪里创建、哪条规则延长了多久、引用最终使用到什么时候

6.6 const 引用也会悬空

const 只限制修改,不保证生命周期:

cpp 复制代码
const int& get_value() {
    int local = 7;
    return local; // 仍然悬空
}

"只读"不等于"安全","不能改"不等于"对象仍存在"。

6.7 判断临时对象生命周期的四步法

遇到复杂引用表达式时,按以下顺序判断:

  1. 找对象:真正创建了哪些对象,是否发生类型转换;
  2. 找绑定:引用直接绑定哪个对象或子对象;
  3. 找延长规则:是否属于标准规定的直接绑定延寿场景;
  4. 找使用点:最后一次使用是否早于对象销毁。

例如:

cpp 复制代码
const int& result = 1 + 2;

1 + 2 产生 int 临时对象,局部 const int& 直接绑定它,生命周期延长到 result 的作用域结束。

而:

cpp 复制代码
const int& identity(const int& value) {
    return value;
}

const int& result = identity(1 + 2);

不能仅凭"最终也是 const int&"就断言临时量一直有效。函数参数绑定、返回引用与完整表达式边界需要分别分析。最安全的接口往往是按值返回。


七、指针与引用怎样选择

7.1 核心对比

维度 引用 指针
初始化 必须绑定对象 应初始化,但语法可允许未初始化局部指针
空状态 普通引用不表达合法空状态 可以为 nullptr
改变目标 不能重新绑定 可以重新赋值指向其他对象
访问语法 像直接使用对象 需要 *->
sizeof 得到被引用类型大小 得到指针类型大小
常见语义 必须存在的别名、参数、返回值 可选对象、数组遍历、低层接口、所有权句柄的一部分

7.2 不要用"引用一定安全"代替设计

引用避免了显式空值状态,但仍可能:

  • 引用已经销毁的局部对象;
  • 引用容器扩容后失效的元素;
  • 引用已释放的动态对象;
  • 引用被移动或重置后不再满足业务不变量的对象。

安全来自清晰的生命周期和所有权,而不是把 * 换成 &

7.3 一个简单选择原则

text 复制代码
对象必须存在,函数不接管所有权
  -> 优先考虑引用

对象可以缺席
  -> 考虑指针或 optional 的值语义

接口表达所有权
  -> 考虑值类型、unique_ptr、shared_ptr 等更明确工具

需要与 C API、硬件地址或连续缓冲区协作
  -> 指针往往更自然

这不是绝对规则,但比"引用比指针高级"更接近工程判断。


八、inline:优化建议之外的语言规则

8.1 初学者最先接触的是"调用处展开"

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

编译器可能把调用:

cpp 复制代码
int result = square(5);

优化成近似直接计算,从而避免一次真实函数调用。但是否展开由编译器根据优化级别、函数大小、调用上下文、调试需求和目标平台决定。

8.2 inline 不是强制命令

编译器可以:

  • 忽略你写的 inline,保留真实调用;
  • 对没有写 inline 的函数主动做内联优化;
  • 在一个调用点展开、另一个调用点不展开;
  • 在链接时优化阶段跨翻译单元决定。

所以不能通过源代码里有没有 inline 推断最终机器码。

8.3 inline 的关键语言意义与 ODR 有关

普通具有外部链接的非内联函数,如果在多个翻译单元中各有定义,通常违反单一定义规则。内联函数则可以在多个翻译单元中出现满足要求的定义,这正是它适合定义在头文件中的重要原因。

cpp 复制代码
// math_utils.h
#pragma once

inline int cube(int value) {
    return value * value * value;
}

多个 .cpp 包含这个头文件时,每个翻译单元都能看到定义。各处定义必须满足 ODR 要求,不能通过宏让它们变成互相矛盾的函数体。

8.4 "inline 函数没有地址"是错误说法

cpp 复制代码
inline int add(int left, int right) {
    return left + right;
}

auto pointer = &add;

可以取得内联函数地址。即使某些直接调用被展开,程序仍可能因为函数指针、调试、虚调用或未优化路径需要一个可调用实体。

8.5 为什么只在头文件声明 inline、在 cpp 定义容易出问题

cpp 复制代码
// f.h
inline void f(int value); // 只有声明
cpp 复制代码
// f.cpp
#include "f.h"
void f(int value) {
    // 定义只在这个翻译单元可见
}

另一个翻译单元调用 f 时看不到可达定义,不满足内联函数的使用要求。问题的根源不是"展开后绝对没有函数地址",而是需要使用该内联函数的定义域必须能看到相应定义。通常把完整定义放在头文件中。

8.6 宏函数为什么难以替代真正函数

cpp 复制代码
#define SQUARE(x) ((x) * (x))

即使加足括号,仍有重复求值问题:

cpp 复制代码
int value = 3;
int result = SQUARE(++value); // ++value 被展开两次,行为难以接受

函数具有类型检查、作用域、单次参数求值和调试语义。现代 C++ 中,优先使用函数、inline 函数、模板或 constexpr,不要为追求"像函数一样"而滥用宏。

8.7 constexpr 与 inline 不完全相同

constexpr 函数适合在满足条件时参与常量求值,同时它也隐含相应的内联规则。但两个关键字的目的不同:

  • inline 主要涉及多定义语义,并可作为优化提示;
  • constexpr 主要表达函数可以参与常量表达式求值的要求。

不要把 constexpr 只理解成"更强的 inline"。

8.8 类内定义的成员函数与函数模板

在类定义内部给出的成员函数定义通常隐含相应的内联语义:

cpp 复制代码
class Counter {
public:
    int value() const {
        return value_;
    }

private:
    int value_ = 0;
};

函数模板也通常把完整定义放在头文件中,因为实例化点需要看到定义:

cpp 复制代码
template<class T>
T max_value(const T& left, const T& right) {
    return left < right ? right : left;
}

"放在头文件"背后的原因并不完全相同:内联函数依赖多定义与可达定义规则;模板还涉及按需实例化。两者经常一起出现,但不要混成一句"因为编译器要展开"。

8.9 C++17 的 inline 变量

inline 也可以用于变量:

cpp 复制代码
// config.h
inline constexpr int default_port = 8080;

它让满足要求的定义可以出现在多个翻译单元,同时表示同一个实体,适合头文件中的全局常量或静态数据成员定义。这里同样与"函数调用展开"无关,进一步说明 inline 的语言意义不只是优化。


九、nullptr:类型安全的空指针字面量

9.1 0、NULL 与指针重载问题

在 C++11 之前,代码常用 0NULL 表示空指针:

cpp 复制代码
void select(int value);
void select(int* pointer);

select(0); // 选择 int 版本

0 首先是整数字面量。即使它也能作为空指针常量参与指针转换,面对重载时精确的整数匹配会选择 int 版本。

NULL 是实现定义的空指针常量宏。它的具体展开方式不应该被当成跨平台接口契约;在重载场景中使用 NULL,结果可能不符合"我要指针版本"的直觉,甚至产生二义性。

9.2 nullptr 有专门类型

cpp 复制代码
select(nullptr); // 选择 int* 版本

nullptr 是空指针字面量,类型为 std::nullptr_t。它可以转换为任意合适的指针或成员指针类型,但不会像普通整数 0 那样匹配整数重载。

cpp 复制代码
#include <cstddef>

std::nullptr_t empty = nullptr;
int* int_pointer = nullptr;
double* double_pointer = nullptr;

9.3 nullptr 让意图和重载都更明确

cpp 复制代码
void open(int descriptor);
void open(const char* path);

open(nullptr); // 明确表达空指针,选择 path 版本

相比 0NULLnullptr 同时改善了:

  • 可读性:读者立即知道这是空指针;
  • 类型安全:不会被当成普通整数实参;
  • 模板推导:保留专门的 std::nullptr_t 类型;
  • 重载决议:更准确地进入指针候选。

9.4 nullptr 不会自动消除空指针错误

cpp 复制代码
int* pointer = nullptr;
// std::cout << *pointer; // 仍然是错误

nullptr 只让"空"这个状态表达得更准确,不会让解引用空指针变得合法,也不会解决:

  • 指针悬空;
  • 重复释放;
  • 所有权不清;
  • 数组越界;
  • 并发访问冲突。

9.5 现代代码的直接规则

需要表达空指针时:

cpp 复制代码
Widget* widget = nullptr;

不要再写:

cpp 复制代码
Widget* widget1 = 0;
Widget* widget2 = NULL;

与旧接口协作时可能看到 NULL,但新写的 C++ 代码优先使用 nullptr

9.6 nullptr 与模板推导

cpp 复制代码
template<class T>
void inspect(T value);

inspect(0);       // T 推导为 int
inspect(nullptr); // T 推导为 std::nullptr_t

nullptr 保留专门类型,让泛型代码能够区分"整数零"和"空指针意图"。但如果模板要求某个具体指针类型,std::nullptr_t 不会在模板实参推导阶段自动替你猜出 Widget*

cpp 复制代码
template<class T>
void consume(T* pointer);

// consume(nullptr); // 无法从 nullptr 推导 T
consume(static_cast<int*>(nullptr)); // T 为 int

模板实参推导与普通函数调用转换是不同阶段。遇到这类代码,可以显式提供类型、改造接口,或为 std::nullptr_t 提供清晰重载。

9.7 空指针、悬空指针与无所有权指针

三个概念要分开:

状态 含义 典型处理
空指针 明确不指向对象 nullptr 表达并在使用前分支判断
悬空指针 曾指向对象,但对象已销毁或位置失效 修复生命周期与失效逻辑,不能只判空
无所有权指针 指向对象但不负责销毁 在接口契约中说明观察关系和有效期

悬空指针的值通常并不会自动变成 nullptr,所以"使用前 if (p)"不能发现所有生命周期错误。


十、常见误区、高频问题与练习

10.1 常见误区

误区一:默认参数是函数内部保存的一份默认值

默认实参由调用点可见声明参与处理,不属于函数类型,也不是进入函数后才"发现没传参数"。

误区二:返回类型不同就能重载

普通函数不能只靠返回类型区分重载。

误区三:有普通函数和模板时,永远选普通函数

重载决议先比较候选可行性与匹配质量;模板与非模板的优先关系只在相应条件下参与最终选择。

误区四:引用可以重新指向另一个对象

给引用赋值是在修改其绑定对象,不是重新绑定。

误区五:引用一定不占空间

语言规定的是别名语义;具体表示取决于实现和上下文。

误区六:const 引用绝不会悬空

const 只限制修改权限,不保证对象生命周期。

误区七:inline 一定让程序更快

是否展开由编译器决定;过度展开还可能增大代码体积并损害指令缓存。

误区八:inline 函数没有地址

可以取得内联函数地址。优化展开与函数实体是两个层面的概念。

误区九:NULL 在 C++ 中永远就是 0

NULL 是实现定义的空指针常量宏,不应依赖具体展开。新代码使用 nullptr

10.2 高频问题

问题一:默认参数为什么放在声明中

调用者编译调用表达式时必须看到默认实参。公开声明通常位于头文件,因此把默认值放在那里最自然。

问题二:函数重载发生在编译期还是运行期

普通函数重载决议发生在编译期。它与虚函数的运行时动态分派不是一回事。

问题三:引用与指针底层是否完全相同

实现经常用地址表示引用,但标准要求程序依赖引用语义,而不是依赖某种固定内存布局。

问题四:什么时候返回引用

当结果必须继续代表一个仍存活对象,且接口能清楚保证生命周期和失效规则时。否则优先按值返回。

问题五:const T& 为什么能接收临时对象

它可以只读绑定临时对象,并在特定直接绑定场景延长生命周期;普通左值引用不能这样绑定。

问题六:inline 定义为什么常放在头文件

需要使用内联函数的定义域必须能看到可达定义,而且内联规则允许满足要求的定义出现在多个翻译单元。

问题七:nullptr 是否等于地址全是 0 比特

nullptr 表达空指针语义。空指针的机器表示由实现决定,不能从关键字直接推导所有位一定为零。

10.3 建议练习

  1. 写一个带两个默认参数的分页函数,列出所有合法调用形式;
  2. 设计一组 print 重载,观察 charintlongdouble 的匹配;
  3. 构造一个默认参数与重载产生二义性的示例,再重新设计接口消除二义;
  4. 分别使用值、指针、引用实现交换函数,并解释接口语义;
  5. 写一个安全返回数组元素引用的函数,再故意返回局部变量引用观察编译器警告;
  6. 验证 const int& 绑定临时量和类型转换临时量的行为;
  7. 把一个短函数定义在头文件中,用两个 .cpp 包含并链接,理解 inline 与 ODR;
  8. f(int)f(int*) 编写 0NULLnullptr 三组调用并比较诊断。

10.4 排查清单

遇到相关编译或运行问题时,依次检查:

  • 默认实参是否在调用点可见;
  • 是否在同一作用域重复指定默认实参;
  • 省略的参数是否都位于右侧;
  • 同名函数是否存在唯一最佳匹配;
  • 是否误以为返回类型参与重载区分;
  • 引用绑定对象是否仍然存活;
  • 返回引用是否指向局部变量或临时对象;
  • const 是否被错误放大或错误缩小;
  • 内联函数定义是否在使用处可达;
  • 头文件中的内联定义是否在各翻译单元保持一致;
  • 空指针是否使用 nullptr
  • 指针是否在解引用前验证并满足所有权约束。

10.5 参考资料


总结

C++ 入门下篇的主线可以压缩为:

text 复制代码
默认参数减少常用调用中的重复实参
        ↓
函数重载让一组相关操作共享名称
        ↓
编译器经过候选、可行与最佳匹配完成选择
        ↓
引用用别名表达必须存在的对象关系
        ↓
引用传参与返回必须服从对象生命周期
        ↓
const 引用可以只读绑定对象或特定临时量
        ↓
inline 既是优化提示,更是多定义与可达定义规则
        ↓
nullptr 用专门类型准确表达空指针

请牢记:

  1. 默认参数只允许省略尾部实参,通常集中写在调用者可见的声明中
  2. 默认参数不属于函数类型,也不能在同一作用域重复定义
  3. 函数重载依靠参数列表和重载决议,返回类型不同不能单独构成重载
  4. 默认参数与重载组合时,要防止多个候选覆盖同一种调用形式
  5. 引用必须绑定对象且不能重新绑定,赋值修改的是被引用对象
  6. 引用的别名语义不等于实现中永远不需要表示空间
  7. 返回引用前必须证明被引用对象比所有引用使用活得更久
  8. const 引用限制修改权限,但不会自动消除悬空
  9. inline 不强制编译器展开,也不意味着函数没有地址
  10. 内联函数定义通常放在头文件中,并必须在各翻译单元保持符合 ODR 的一致性
  11. 现代 C++ 使用 nullptr 表达空指针,不使用整数 0NULL 替代意图
  12. 类型安全语法不能替代生命周期、所有权和边界检查。

如果本文对你有帮助,欢迎点赞、收藏。真正掌握这些入门语法的标志,不是能背出 &inlinenullptr 的写法,而是看到一次调用或返回时,能够准确回答:编译器选择了哪个接口、默认值从哪里来、对象是否仍然存活,以及代码表达的空值和所有权是否足够清楚。

相关推荐
艾莉丝努力练剑31 分钟前
【AI大模型接入SDK】Provider分析与实现
c++·人工智能·学习·面试·大模型·llm
老当益壮梁奶奶8 小时前
Linux软件编程学习笔记(七):线程分离与线程间通信详解
linux·c语言·c++·笔记·学习
wen_zhufeng11 小时前
用 vLLM 加速 TTS 推理:通用改造指南
android·vllm
宠友信息11 小时前
社区类源码开发实践中的仿小红书系统技术要点分析
java·spring boot·redis·mysql·uni-app·vue·内容运营
南城以南溫暖如初14713 小时前
从零搭建24小时自助健身系统:技术选型与核心模块实战
java·spring boot·redis·mysql·vue·mybatis
长友cy13 小时前
windows 搭建Qt编译Android 应用程序,快速搭建
android·windows
hweiyu0013 小时前
Redis命令:EXPIRE
redis
InfinitePlus13 小时前
redis数据持久化
数据库·redis·缓存
水饺编程13 小时前
第5章,[Win32 章节] :绘图模式
c语言·c++·windows·visual studio