先说一个很多人问过的问题:我已经学了 C 语言,为什么还要再学 C++?

答案藏在两者的关系里。C++ 几乎算是 C 语言的一个"超集"——你在 C 里学到的语法、思维、甚至绝大多数程序,搬到 C++ 里照样能编译、能运行。但 C++ 又绝不只是"加了面向对象"的 C。它解决了一堆 C 语言年代最让人头大的问题:名字冲突、指针难懂、代码复用笨拙。它像是一位继承家族企业又大刀阔斧改革的老大哥:底子还是当年那套,但处处都在告诉你"可以写得更优雅"。

这篇文章是 C++ 入门系列的第一篇。我们从它的历史讲起,一路写到命名空间、C++ 原生的输入输出、缺省参数、函数重载、引用、内联函数,最后落到 auto、范围 for 和 nullptr 这几个现代 C++ 的宠儿。你不需要是 C 语言高手,只要写过基本语法、能被指针折腾过几回,就够了。

你应该有的知识准备

在往下读之前,我建议你至少对下面这些东西有概念。不用担心,它们大多散落在 C 语言课里,这里我只做一句话回顾,看不懂可以回头翻 C 篇:

  • 编译与链接:源代码要先经过"预处理→编译→汇编→链接"四步才能变成可执行程序,.c/.cpp 是源代码,头文件负责提供声明。
  • 作用域与生命周期:变量/函数的名字存在于某个"域"里;变量在其生命周期内有自己的内存。这两者是本文理解命名空间和引用的地基。
  • 函数调用与栈帧:调用一个普通函数,系统会在"调用栈"上为它分配一块叫"栈帧"的区域,用来放局部变量、参数和返回地址。内联函数那一节会大量用到它。
  • 指针与取地址 &、解引用 *:C 语言的灵魂。C++ 的引用虽然和指针不是一回事,但理解指针会让你理解引用"为什么这样设计"。
  • #define 宏:内联函数那一节我们要拿它做反面教材。

如果你是带着 C 语言基础进来的,以上大部分你已经会了;如果哪条还不熟,恰好说明接下来这堂课会对你有大用。

C++ 的历史与发展

理解一门语言,最好从它"为什么出生"讲起。C++ 的起源要追溯到 1979 年。那一年,贝尔实验室的 Bjarne Stroustrup(本贾尼·斯特劳斯特卢普,不同资料翻译略有差异)在从事计算机科学和软件工程的研究。面对项目中复杂的软件开发任务——尤其是模拟和操作系统这类系统级软件的开发——他逐渐觉得 C 语言在表达能力、可维护性和可扩展性上不够用了。

这里要插一句:C 语言是 Dennis Ritchie 在 1972 年前后发明的,它的哲学是"简洁、贴近硬件、信任程序员"。问题恰恰出在"简洁"上——C 没有类、没有类内的数据封装,一个数据结构要配一堆独立函数来操作它,代码一多就变得松散、易错。Stroustrup 想要的是:C 的性能和底层能力不能丢,但代码组织和抽象的能力要大幅提升。

1983 年,Stroustrup 在 C 语言的基础之上加入了面向对象编程的特性,设计出了 C++ 的雏形。其实早在 1979 年他就写了一个叫 "C with Classes"(带类的 C)的实验版本,后来经过若干年的打磨,1983 年到 1985 年间语言逐步成型,才被正式命名为 C++。这里"面向对象"(Object Oriented Programming,简称 OOP)是一种编程思想:把数据和对数据的操作封装成一个整体,也就是"类",再通过封装、继承、多态等机制来组织代码。正是从这一年开始,这门语言被正式命名为 C++。名字里的两个加号其实是个小心思——在 C 语言里,++ 是自增运算符,C++ 字面上就是"让 C 更进一步"。

在接下来的几年里,C++ 在学术界和工业界的应用逐渐增多,一些大学和研究所把它当作教学和研究的首选语言。那段时间,C++ 的标准库和模板等特性也逐步完善。所谓"模板",是指一种让代码能处理多种类型的泛型机制——你可以先写一份不规定具体类型的函数或类,用的时候再填入具体类型。比如后面我们会遇到的 vector<int>、vector<double>,用的就是同一份模板代码,只是填入的类型不同。

C++ 的标准化工作于 1989 年启动,成立了一个由 ANSI(美国国家标准协会)和 ISO(International Standards Organization,国际标准化组织)联合的标准化委员会。1994 年,委员会提出了第一份标准化草案,并投票通过了将 STL 纳入 C++ 标准。STL(Standard Template Library,标准模板库)由 Alexander Stepanov、Meng Lee 和 David R Musser 在惠普实验室开发,它包含容器、算法、迭代器等一整套泛型工具。你可能听说过 vector、list、map、sort 这些名字,它们基本都是 STL 的出身。不过把 STL 加进标准也一度延缓了标准化进程——因为它太庞大、牵涉太多细节。1997 年 11 月 14 日,该标准的最终草案获得通过,到了 1998 年,C++ 的 ANSI/ISO 标准正式投入使用——这就是今天大家常说的 C++98。

之后 C++ 一直以几年一更新的节奏演进,版本号基本接年份:C++98、C++11、C++14、C++17、C++20、C++23,以及刚刚在 2026 年 3 月定稿的 C++26。其中 C++11 是继 C++98 之后的又一座里程碑,这一版一口气引入了 auto 类型推导、范围 for、nullptr、智能指针、Lambda 表达式等大量新特性——我们今天要讲的不少概念,正是从 C++11 开始才进入主流视野的。判断自己写的代码属于哪个版本,一个简单的办法就是看有没有用到这些"版本特征"关键字。我把几个关键版本的招牌特性列成一张表,方便你对号入座:

版本发布年份几个代表性新特性
C++981998第一个国际标准,STL 进标准、模板、异常、bool
C++112011auto 类型推导、范围 for、nullptr、智能指针、Lambda、移动语义、constexpr
C++142014泛型 Lambda、auto 返回值推导、二进制字面量、数字分隔符 1'000
C++172017结构化绑定、if constexpr、std::optional、折叠表达式
C++202020concept(概念)、ranges(范围库)、协程、模块、三路比较 <=>
C++232023std::print、std::expected、if consteval
C++262026 定稿静态反射、契约、std::execution、内存安全加固

看到没有,越到后面越是"面向系统程序设计的大招"。但你别被这张表吓到——入门阶段,我们脚踏实地的从 C++98 时代的语法框架学起,再逐步吸收 C++11 带来的那一批便利糖。对你的绝大多数需求,auto + 范围 for + nullptr 已经够用手了。

还有一个跟网络相关的趣闻:C++ 一直被人诟病迟迟没有官方网络库(networking),原本它被排进了 C++23 的计划里,但 C++23 正式发布时里却没有 networking,网上因此引发了一连串的吐槽。中间的决策过程曲折,像上演了一场"宫斗剧"。关于网络到底怎么进标准,委员会内部常年争论不休,方案推了又改、改了又推,最后不了了之。这也提醒我们:学习 C++ 要习惯于它的慢与保守——它求稳,宁可推迟也不贸然上线不成熟的特性。反过来这也是它几十年来一直"退休不了"的底气:向后兼容,改得极其慎重。

C++ 的重要性与学习建议

C++ 难学吗?坦白说,C++ 是一门相对难学也难精的语言,学习曲线比很多语言更陡峭。这既有历史包袱的原因,也有语言自身设计和发展历史的因素。网上那个"21 天自学精通 C++"的段子,正是对这种难度的一种调侃——可见它的难度是出了名的。那句笑话说的是:封面画个"21 天",翻开书里字缝里夹着"精通 C++"四个字,等到岁月流逝、书页落灰,你才终于"精通"了它。所以千万别被"速成"心态带偏,慢慢来,反而最快。

但难,恰恰说明它有用。在 TIOBE(一个追踪编程语言流行度的权威榜单,通过统计互联网上有经验的程序员数量、课程数量、第三方厂商数量,并用搜索引擎加 Wikipedia、Amazon、YouTube、百度等综合加权得出排名)上,C++ 常年稳居前五。要说明的是,TIOBE 只反映一门语言的"热门程度",不代表它好不好、也不代表它写的代码行数多不多——它是一张"哪些语言正在被大量使用"的风向图。而 C++ 能常年霸榜,本身就说明了它在生产环境的不可替代性。如果你看过它的应用领域,就会发现它在"讲究性能"的地方几乎无处不在:

  • 大型系统软件开发:编译器、数据库、操作系统、浏览器等底层软件,大量用 C++ 编写。你天天用的 Chrome 浏览器、MySQL 数据库的背后,都有成体系的 C++ 代码;
  • 音视频处理:FFmpeg、WebRTC、Mediasoup、ijkplayer 等主流音视频开源库,技术栈核心就是 C++。你在 App 里看视频的流畅体验,底层大概率有 C++ 在拼命;
  • PC 客户端开发:Windows 桌面软件,比如 WPS,常见搭配是 C++ 加 Qt——Qt 是一套跨平台的 C++ 图形界面(GUI,Graphical User Interface)框架。凡是"原生桌面软件",C++ 都是主力选手;
  • 服务端开发:在高并发、高要求的场景(游戏服务、流媒体服务、量化高频交易)里,C++ 是常客。Java 也常做服务端,但那些"性能敏感到极致"的地方,往往还得 C++ 亲自上;
  • 游戏引擎开发:UE4、Cocos2d-x 等引擎本体几乎都是 C++ 写的。想深入游戏底层,绕过 C++ 是不现实的;
  • 嵌入式开发:智能手环、摄像头、扫地机器人、智能音箱、门禁系统、车载系统等设备内部,跑的多是 C/C++。嵌入式开发还能进一步分为嵌入式应用开发和嵌入式驱动开发两条路线;
  • 机器学习引擎:机器学习底层的很多算法用 C++ 实现,上层再包一层 Python。如果你只想准备数据、训练模型,学 Python 就够了;但如果你想做系统层的开发,就必须懂 C++。TensorFlow、PyTorch 的底层核心(张量运算、算子实现)就是 C++ 写的。

你可能会注意到上面少了很多"轻量场景",比如快速迭代的 Web CRUD、脚本自动化、数据分析小工具——这些 Python、Java 往往更在行。C++ 的定位从来不是"啥都能干",而是"需要极高性能和资源控制的地方,你得能接住这个活"。想清楚这一点,你选择学 C++ 的心就不会慌:你不是在选一条更难的弯路,而是在为"啃最硬骨头"的资格投资。

学习 C++ 的建议其实朴素:每节课后把课堂样例亲手敲一遍,理解的深度远非"看懂"可比;如果时间允许,每节课整理成博客或笔记,至少重点章节别落下。经典的参考文档值得常翻——cppreference.com 的中文版和英文版信息最全、更新到了最新标准;而 legacy.cplusplus.com 以头文件形式呈现、读起来更直观,虽然标准只更新到 C++11,但作为入门词典非常友好。书籍方面,前期可以翻翻《C++ Primer》当语法字典(它偏正典、系统,前期自学看可能有点晦涩,先能看多少看多少,当中后期语法字典非常顺手),中后期结合侯捷老师的《STL源码剖析》和《Effective C++》进阶。其中《Effective C++》有句广为流传的评价——"把 C++ 程序员分为看过此书的和没看过此书的",它讲的是一批正确高效使用 C++ 的实用条款。把这些资源当作工具,而不是一次背完的任务,随用随查,才能越走越快。

第一个 C++ 程序

前面说过,C++ 兼容 C 语言绝大部分语法。所以你以前写的 C 语言 hello world,只要把文件后缀改成 .cpp,几乎原样就能跑。这里的 .cpp 就是 C++ 源文件的扩展名,编译器(VS 里的 MSVC,或 Linux 下的 g++)一看到 .cpp,就会调用 C++ 编译器来处理它。也就是说,同一个工程里,.c 文件按 C 编译,.cpp 文件按 C++ 编译——这两类文件是各自的编译单元。所谓编译单元,就是"一个被独立编译的源文件":先经过预处理、编译、汇编各自的代码,最后再由链接器合并成可执行程序。后缀不同,决定了编译器用哪套规则来解读这份代码。这里有个重要的心法:你永远可以把".c 文件按 C 规则编译、.cpp 文件按 C++ 规则编译"当作默认真相,但如果你想让某个函数保持 C 的名字导出(不经过 C++ 的名称修饰),就要用 extern "C" 来干预——这块我们在"函数重载"一节会展开,因为它和 C 语言"不支持同名函数"的根由是同一件事。

我们先写一个"C 口味"的 hello world:

// test.cpp
#include <stdio.h>   // 引入 C 的标准输入输出头文件,C++ 也兼容
int main()           // 程序入口,main 函数
{
    printf("hello world\n");  // 用 C 的 printf 打印,C++ 里照样能用
    return 0;                 // 返回 0,表示程序正常结束
}

上面这段是能运行的,但它不是"地道的 C++"。C++ 有自己的一套输入输出方案,用它写出来的 hello world 长这样:

// test.cpp
#include <iostream>   // 引入 C++ 的标准输入输出流库
using namespace std;  // 展开 std 命名空间,简化写法(后面会细讲)
int main()
{
    // cout 往标准输出写,<< 是流插入运算符,endl 是换行操纵符
    cout << "hello world\n" << endl;
    return 0;
}

这两段代码的差异,恰恰浓缩了 C++ 的价值观:不再是"你要告诉我要格式化什么、占位符是什么",而是"我把对象丢给流,剩下的类型匹配你看着办"。第一行 #include <iostream> 引入的是 C++ 的标准输入输出流库,注意它不是以 .h 结尾——<iostream> 这种不带 .h 的头是 C++ 标准库头文件的典型写法。第二行 using namespace std; 的意思是"把 std 命名空间里所有名字都直接拉到当前作用域",这样你才能直接写 cout 而不是 std::cout。这一行简写了代码,却也让"名字冲突"的风险回来了,所以它在工程里名声很坏,这一点我们放到"命名空间"一节专门聊。

cout << "hello world\n" << endl; 这行是 C++ 打印的标准姿势:cout(输出流对象)负责"写出去",<<(流插入运算符)把右边的数据喂给左边,于是字符串被送到屏幕上;endl 是一个"操纵符",它的作用等价于"输出一个换行符 + 刷新缓冲区"。关于缓冲区和 '\n' 的区别,我们在"输入输出"一节会详细拆——现在你先记住,endl 总能保证文本立刻落屏。

这里你还不用急着搞懂 std、cout 这些是什么——接下来几节我会一个个拆开讲。你现在只需要建立两个印象:第一,C++ 想要输出,就 #include <iostream>;第二,这一行 cout << ... << endl; 就是 C++ 打印的标准姿势。

写 C++ 程序还有一个你可能遇到的提问:"我该用哪个标准?" 在 VS 里,你可以在项目属性的"C/C++ → 语言 → C++ 语言标准"里指定;在 Linux/g++ 下用编译选项,比如 g++ -std=c++11 test.cpp 就按 C++11 编译,-std=c++17 就按 C++17 编译。默认情况下很多编译器会用一个较新的默认标准。我们这里的所有例子,用 C++11 及以上的标准都能顺利编译,个别需要新特性的地方我会单独说明。

命名空间

在 C/C++ 里,变量、函数还有后面会学到的类,这些名字都会存在于某个"作用域"里。如果都堆在全局作用域,一旦数量多起来,名字冲突几乎是必然的。你可以把作用域理解成"名字的领地",而全局作用域就是最大的那片公共领地。问题是,这片公共领地是所有人的——你写的、库写的、队友写的名字全挤在一起,稍不留神就重名了。

我给你看一个 C 语言里真实存在的坑:

// 现场演示一个命名冲突:rand 同时是函数名,也是变量名
#include <stdio.h>
#include <stdlib.h>   // 这个头文件里声明了库函数 rand()
int rand = 10;        // 我又定义了一个全局变量叫 rand
int main()
{
    // 报错:'rand' 重定义——文档里既有一个函数 rand(),又有我定义的变量 rand
    printf("%d\n", rand);
    return 0;
}

这就是典型的名字污染:全局变量和库函数撞车了。你也别觉得这是小题大做——rand、max、min、abs 这类"通用词"在 C 标准库里到处都是,你随便给全局变量取个通用名,很可能在某天夜里就踩中了它。过去 C 语言项目里"It compiles"却一堆这样的隐患,C++ 引入 namespace(命名空间)就是为了更好地解决它。

那么命名空间到底是什么?一句话:它定义出一个独立的"域",把一堆名字隔离起来,域之间各管各的,同名也没关系。定义方式是用 namespace 关键字,后面跟一个名字,再接一对大括号:

#include <stdio.h>
#include <stdlib.h>
 
// 定义一个叫 bit 的命名空间
namespace bit
{
    // 命名空间里可以放变量
    int rand = 10;
    // 也可以放函数
    int Add(int left, int right)
    {
        return left + right;
    }
    // 还可以放类型
    struct Node
    {
        struct Node* next;   // 链表指针,暂不用管细节
        int val;
    };
}
 
int main()
{
    // 默认访问的是全局的 rand(这里是库函数指针)
    printf("%p\n", rand);
    // 用 "::" 作用域限定符,指定访问 bit 命名空间里的 rand
    printf("%d\n", bit::rand);
    return 0;
}

注意 :: 这个符号,它叫作用域限定符,读音是"双冒号",作用是"到某个域里去找这个名字"。bit::rand 就是明确地说"我要 bit 里的 rand,不是全局的那个"。这样即使全局和 bit 里都有 rand,也互不干扰。

域的几种类型,和它们各自管什么

C++ 里的"域"有好几种:函数局部域、全局域、命名空间域、类域。这些域主要影响的是"编译时到一个变量/函数/类型的时候,应该去哪里找它的声明或定义"这套查找逻辑——正是有了域的隔离,名字冲突才被化解。需要区分的是:局部域和全局域除了影响查找,还会影响变量的生命周期(也就是变量什么时候创建、什么时候销毁);而命名空间域和类域不影响生命周期,它们纯粹是"起名字的隔离区"。

换句话说,你在 namespace bit 里定义一个 int rand = 10;,它的生命周期其实和"全局变量的生命周期"完全一样,从程序启动到结束——命名空间并没有给它单独的生命周期规则,它只是把"rand 这个名字放在哪里能查到"这件事做了隔离。

命名空间的几个硬脾气

有几个细节值得记住。第一,命名空间只能定义在全局,但允许嵌套——在一个命名空间里还可以再写命名空间。所谓"只能定义在全局",是说 namespace 关键词本身不能写在函数内部或类内部,它必须出现在文件作用域。第二,同一个工程里多个文件定义的同名命名空间会被识别为同一个,自动合并,不会冲突;这方便你把一个逻辑模块的代码拆到多个文件里——你甚至可以在 Stack.h 里声明接口、在 Stack.cpp 里实现接口,两边都写在 namespace 某个名字 里,编译链接后它们就是同一个命名空间。第三,C++ 标准库的所有东西都被装进了一个叫 std(standard,标准)的命名空间里。所以 std::cout、std::vector 都属于 std,你只有通过命名空间方式(前缀 std::、using、using namespace std)才能用它们。

来看嵌套的例子:

#include <stdio.h>
 
// 命名空间嵌套:bit 里再套 pg 和 hg
namespace bit
{
    namespace pg      // 内层命名空间 pg
    {
        int rand = 1;
        int Add(int a, int b) { return a + b; }       // 返回 a+b
    }
    namespace hg      // 内层命名空间 hg
    {
        int rand = 2;
        int Add(int a, int b) { return (a + b) * 10; } // 返回 a+b 再乘 10
    }
}
 
int main()
{
    printf("%d\n", bit::pg::rand);     // 访问 pg 的 rand,输出 1
    printf("%d\n", bit::hg::rand);     // 访问 hg 的 rand,输出 2
    printf("%d\n", bit::pg::Add(1, 2)); // 1+2,输出 3
    printf("%d\n", bit::hg::Add(1, 2)); // (1+2)*10,输出 30
    return 0;
}

嵌套层数多时,一层层 bit::pg::x 会写得很长,但胜在意图清晰、绝无歧义。在需要彻底避免重名的地方(比如开源库对外暴露的 API),宁可多写几层前缀,也比将来头疼的冲突强。

关于"多文件合并"这个特性,值得彻底讲清楚,它是大型项目能跑起来的基石。设想你有一个栈(Stack)模块,接口和实现分散在两个文件里,但它们都声明在同一命名空间内,编译器把它们当成同一个域:

// Stack.h —— 只放接口声明
#pragma once                       // 防止头文件被重复包含
namespace bit
{
    void init(int n = 4);          // 只声明,不实现
    void push(int x);
    int  top();
    void pop();
}
 
// Stack.cpp —— 放具体实现,仍在同一命名空间 bit 里
#include <cstdio>
#include "Stack.h"
namespace bit
{
    // 注意:这里 init 的缺省值 n = 4 只能写在 Stack.h 的声明里,
    // 不能在这里再写一遍,否则两个不同的默认值会冲突(缺省参数一节会讲)
    void init(int n)
    {
        printf("初始化栈容量 %d\n", n);
    }
    void push(int x)   { printf("入栈 %d\n", x); }
    int  top()         { return 42; }
    void pop()         { /* 略 */ }
}
 
// test.cpp —— 使用者只 include 头文件,照样能调到 bit 里的函数
#include <cstdio>
#include "Stack.h"     // 只需要看声明,就知道 bit 里有 init/push/top/pop
int main()
{
    bit::init();       // 输出:初始化栈容量 4
    bit::push(1);
    bit::push(2);
    printf("栈顶:%d\n", bit::top());   // 输出:栈顶:42
    return 0;
}

(这是一个由 Stack.h、Stack.cpp、test.cpp 三个文件组成的多文件工程,编译时把 Stack.cpp 和 test.cpp 一起交给编译器即可:g++ test.cpp Stack.cpp。头文件本身不是编译单元,它只是被各个 .cpp 以 #include 的方式"复制粘贴"进来。)

命名空间的使用方式

现在问题来了:名字都装进命名空间了,我调用的时候总不能每回都写一大串前缀吧?这就要讲到命名空间的使用方式了。先要知道一个前提:编译在查找一个名字的声明/定义时,默认只会在局部或者全局去查找,不会跑进命名空间里翻。所以如果你不写前缀,直接访问命名空间里的成员,会编译报错——哪怕它明明定义在那里。这就是我们反反复复强调"命名空间起了隔离作用"的本质:隔离不只是"听起来有个储物柜",而是实实在在的"编译器默认不去那些柜子里找东西"。

下面这个例子就是为了让你亲眼看到"不写前缀就报错":

#include <stdio.h>
namespace N
{
    int a = 0;   // 声明在命名空间 N 里
    int b = 1;
}
int main()
{
    // 编译报错:error C2065: "a": 未声明的标识符
    // 因为编译器默认只查局部和全局,不会进 N 里找 a
    // printf("%d\n", a);
    (void)0;   // 占位,保证 main 有内容
    return 0;
}

既然直接访问不行,C++ 提供了三种使用方式,各有适用场景:

  • 指定命名空间访问(bit::rand):最直白、最安全,项目中推荐优先使用;
  • using 声明单个成员(using bit::b;):把一个常用的成员"引入"当前作用域,适合频繁使用且没有冲突的成员;
  • using namespace 展开全部(using namespace bit;):把整个命名空间的所有成员都拉到当前作用域,日常小练习图省事常用,但项目里不推荐——冲突风险很大,等于把命名空间辛辛苦苦建立的隔离又给拆了。
#include <stdio.h>
 
namespace N
{
    int a = 0;
    int b = 1;
}
 
// 方式三:展开 N 的全部成员,之后就能直接写 a、b
using namespace N;
 
// 方式二:只展开 N 里的 b 这一个成员
// using N::b;   // 取消上面那行、解开这行,改成单独展开 b
 
int main()
{
    // 方式一:指定命名空间访问,最稳妥 —— 输出 0
    printf("%d\n", N::a);
    // 因为用了 using namespace N(或 using N::b),所以可以直接写 b —— 输出 1
    printf("%d\n", b);
    return 0;
}

上面这段代码同时演示了三种方式:N::a 用指定访问,b 用了展开(无论 using namespace N 还是 using N::b 都能让 b 直接可见)。你也可以动手把 using namespace N; 注掉、换成 using N::b; 试试效果——你会发现挨着 N::a 的那行 printf("%d\n", a) 就会因为 a 找不到而报错,只有 b 是可见的。这就直观体现了"展开单个成员"和"展开全部"的差别。

还有一个你可能在真实项目里见到的点:using 声明(第二种)和 using 指令(第三种)虽然都是"用 using",语义完全不同。 using N::b; 是"把 b 这一个名字引入作用域",此后在本作用域直接写 b 即可;而 using namespace N; 是"把 N 里所有名字的查找都开放出来",更像一个"允许我去 N 里找"的通行证,但并不真正创建这些名字的影子。正因为第三种是"开放查找",一旦同时展开多个命名空间,冲突概率直线上升——这就是它"在项目里名声很差"的根源。

开头那个 hello world 里的 using namespace std;,就是方式三的典型代表:把标准库整个展开,写着省事,但正因为它把隔离拆了,实际项目中大家通常只用 std:: 前缀。**给一个可操作的建议:早期小练习为了快速看到效果,using namespace std; 无所谓;但一旦你开始写文件较多的工程,请老老实实写 std:: 前缀。**还要叮嘱一句:函数内部写 using namespace std; 和文件顶部写 using namespace std; 作用范围不同,后者影响整个文件。尽量把 using 声明/指令放在最小的、需要的局部作用域里,冲突影响面就越小。

C++ 的输入输出

C 语言里输入输出用的是 printf 和 scanf,用起来要先想格式:整数用 %d、浮点用 %lf、字符串用 %s……一个也不能写错。写错一个占位符,轻则打出错值,重则读内存读到不该读的地方,足以让新手头皮发麻。C++ 决定摆脱这种麻烦,它提供了一套基于流的输入输出。

<iostream> 是 Input Output Stream(输入输出流)的缩写,是 C++ 标准的输入输出流库。这套体系里几个关键角色:

  • std::cout 是 ostream(输出流)类的一个对象,负责把数据送出去写到屏幕上;
  • std::cin 是 istream(输入流)类的一个对象,负责从键盘读进来;
  • << 叫流插入运算符,作用是把右侧的数据"插入"到左侧的流里,也就是输出;
  • >> 叫流提取运算符,作用是从流里"提取"数据到右侧的变量,也就是输入。顺带一提,这两个符号在 C 语言里还兼职做位运算的左移/右移,C++ 复用了它们,靠"使用场景"来区分语义。你仔细一想就会发现,cout << x(把 x 从右侧送进左侧流)和 a << b(把 a 的各二进制位左移 b 位)的视觉方向其实是"巧合般"地自洽的;
  • std::endl 是一个操纵符,它本质上是个函数。把它插入到输出流时,相当于插入一个换行符并刷新缓冲区(flush),让输出立即落屏。

关于 std::cin 为什么"能读进变量而不用取地址":你可能已经注意到,scanf("%d", &a) 要写 &a,而 cin >> a 直接写 a 就行。这是因为它用 >> 这个运算符把 a "提取"出来时,C++ 的引用机制让流能直接作用到 a 本身,而不需要你手动取地址。这件事等你学完"引用"一节,回头看就完全通了。

最舒服的一点是:C++ 的输入输出能自动识别变量类型,你不用再纠结格式占位符。这背后的机制是函数重载(下一节就讲),编译器自动帮你匹配。更重要的其实是,流能更好地支持你以后自定义的类对象进行输入输出——你给一个类写好运算符重载,以后这个类的对象就能像 int 一样 cin >> / cout <<,这是 printf 那套占位符永远做不到的。

#define _CRT_SECURE_NO_WARNINGS 1   // 屏蔽部分 VS 下 scanf 的安全警告(仅 MSVC 需要)
#include <iostream>
using namespace std;   // 为省事展开 std,练习场景可接受
 
int main()
{
    int a = 0;
    double b = 0.1;
    char c = 'x';
 
    // 用 cout 输出,多种类型用 << 串起来,末尾换行
    cout << a << " " << b << "  " << c << endl;
    // 不用 using namespace std 时,就要带上 std:: 前缀
    std::cout << a << " " << b << "  " << c << std::endl;
 
    // 也能混着用 C 的 scanf/printf(在 VS 下包含 iostream 会间接引入)
    scanf("%d%lf", &a, &b);
    printf("%d %lf\n", a, b);
 
    // cin 自动识别类型读入,一次可以读多个变量
    cin >> a;          // 从键盘读一个整数给 a
    cin >> b >> c;     // 连续读:先读到 double 的 b,再读到 char 的 c
    cout << a << endl;         // 输出 a
    cout << b << "  " << c << endl;  // 输出 b 和 c
    return 0;
}

注意 cin >> b >> c; 这种链式写法,它等价于"先 cin >> b,再 cin >> c",一次管够。C++ 的输入在读取时会自动跳过前面的空白(空格、换行、制表符等),所以这种连续读一般不用手动处理格式。这里有个值得标记的细节:cin 在遇到类型匹配失败时,会让流进入"错误状态"(fail state),后续再怎么读都读不进来——这属于进阶话题,但你如果哪天发现"cin 怎么读不出来我输入的东西了",先检查是不是流已经 fail 了。

还有一个在竞赛或大量 IO 场景里很实用的小配方:如果某个程序 IO 量特别大,可以在 main 开头加上下面三行,把 C++ 流和 C 流的同步关掉并解绑,从而显著提高输入输出速度。这个先记住,以后遇到大量数据输入再回头理解它为什么快。但请注意,它有一个铁一般的代价:关掉同步后,你就不能再在同一份代码里混用 C 和 C++ 的输入输出了(比如一边 cout 一边 printf),否则输出顺序和结果都可能错乱。所以它是"二选一"的开关,别又想要快又想要混用。

#include <iostream>
 
int main()
{
    // 以下三行用于提升 C++ IO 效率,IO 密集的竞赛题常用
    ios_base::sync_with_stdio(false);  // 关闭与 C 标准流的同步
    cin.tie(nullptr);                  // 解绑 cin 与 cout 的绑定
    cout.tie(nullptr);                 // 解绑 cout(习惯性写成对,实际无效果)
    return 0;
}

听我说说这里到底发生了什么,你会记住得更牢。默认情况下,C++ 流要和 C 的 stdio(printf/scanf)保持同步,因为标准允许你在一个程序里混用两者,为了让"输出顺序"在两套体系下一致,C++ 流每做一点操作都要先去和 C 流对齐,这就是额外开销。ios_base::sync_with_stdio(false) 把这个同步关了,速度立刻上去,代价是不能再混用。而 cin.tie(nullptr) 解决的是另一个问题:默认 cin "绑定"在 cout 上,意思是"当你要读输入前,先把输出缓冲区刷新一下",这是为了保证"提示你输入/你输入"的顺序不乱;但刷新也是开销,IO 量大时反复刷新会很慢,于是把它解绑。绝大多数仅用 C++ 流的场景,关同步加解绑能带来数倍乃至一个数量级的提速——对百万级数据的输入非常可观。

顺带一提,这里出现了 endl 和 '\n' 的选择问题。endl 每次都会刷新缓冲区,如果你在循环里狂打 cout << x << endl;,每次都有刷新开销;如果改成 cout << x << '\n'; 或者把 '\n' 攒进字符串里一起输出,就能省掉大量刷新。你现在就在脑中种下一个习惯:只要能接受"稍后再落屏",优先用 '\n' 而不是 endl;只有当文本确实需要立刻显示(比如提示用户输入、写日志)时才用 endl。

缺省参数

缺省参数(也叫默认参数)指的是:在声明或定义函数时,为它的形参指定一个默认值。调用这个函数的时候,如果你没传实参,就使用这个默认值;传了,就用你传的值。这就好比餐厅点餐:菜单上写"米饭:一份"(缺省值),你上来坐定直接说"来份米饭",服务员默认给你一份;但如果你想加量,也可以明说"米饭两份"。

#include <iostream>
using namespace std;
 
// 给形参 a 一个缺省值 0
void Func(int a = 0)
{
    cout << a << endl;
}
 
int main()
{
    Func();    // 没传参,用默认值 0,输出 0
    Func(10);  // 传了参,用实参 10,输出 10
    return 0;
}

根据给缺省值的形参数量,缺省参数分两种:全缺省(所有形参都给默认值)和半缺省(只有部分形参给默认值)。

#include <iostream>
using namespace std;
 
// 全缺省:三个形参都有默认值
void Func1(int a = 10, int b = 20, int c = 30)
{
    cout << "a = " << a << ", b = " << b << ", c = " << c << endl;
}
 
// 半缺省:只有 b、c 有默认值
void Func2(int a, int b = 10, int c = 20)
{
    cout << "a = " << a << ", b = " << b << ", c = " << c << endl;
}
 
int main()
{
    Func1();             // 全缺省,一个都不传,10 20 30
    Func1(1);            // 1 20 30
    Func1(1, 2);         // 1 2 30
    Func1(1, 2, 3);      // 1 2 3
    Func2(100);          // 半缺省:第一个必须传,100 10 20
    Func2(100, 200);     // 100 200 20
    Func2(100, 200, 300);// 100 200 300
    return 0;
}

关于缺省参数,有两条由语言规定的硬性约束,踩了就直接报错,一定要当心:

第一,半缺省必须从右往左连续缺省。 你可以写 void f(int a, int b = 10, int c = 20)(b、c 从右往左连续给),但绝不能写成像 void f(int a = 10, int b, int c = 30) 这样——b 没给默认值,结果 a 和 c 却给了,中间断了,这是非法的。为什么?因为缺省值是在"调用时从右往左逐个省略实参"的过程中使用的,如果中间断档,编译器就不知道该为谁填空了。

第二,调用时必须从左到右依次给实参,不能跳跃。 假如函数是 Func2(int a, int b = 10, int c = 20),你可以只传 a,可以传 a 和 b,也可以三个全传;但不允许"跳过 b 只给 a 和 c"。这是同一个道理:实参和形参是按位置一一对应的,跳过中间位会让位置错乱。

另外还有一个工程里常见的坑:当函数声明和定义分离时(比如声明写在 .h 头文件、定义写在 .cpp 源文件),缺省参数不能同时出现在声明和定义里。规定必须在声明里给缺省值。因为调用方看到的通常只有头文件里的声明,缺省值只能在那里被解析;如果只在定义里给了,别处的调用就看不到这个默认值。所以正确姿势是:头文件里 void init(int n = 4);,源文件里 void init(int n) { ... },缺省值只此一处。这也是很多初学多文件项目时栽跟头的地方。如果你非要两边都写,编译器会直接报"重复默认参数"之类的错误,它不会迁就你。

缺省参数最典型的用途是初始化参数可省:比如一版栈的初始化函数 init(int n = 4),不知道规模时省着调,明确要放 1000 个元素时就显式传 1000,避免频繁扩容。同一个函数兼顾了"省心"和"性能",这就是缺省参数的价值。你可以把缺省值看作"最合理的默认出厂设置":多数时候默认值够用,少数时候你想覆盖它,再显式传参。

最后补一个可能被你忽略、但很有价值的细节:缺省值是"延迟求值"的。这意思是,缺省值表达式并不是在函数定义那一刻就固定死一个值,而是在"这次调用确实省略了实参时"才去求值。绝大多数时候你会写常量(n = 4),这没问题;即便你写了一个会变化的表达式,它也会每次按需去取。理解了这一点,你就不会在"缺省值是不是只能写常量"上纠结过多了——规则是"可以写表达式",只不过大家习惯写常量罢了。

函数重载

接下来是一个 C 语言里想都不敢想的特性——函数重载。它的意思是:在同一作用域里,允许出现名字相同的多个函数,条件是它们的形参不同,可以是参数个数不同、类型不同,或类型的顺序不同。这样调用同一个名字时,编译器会根据实参的类型和数量,自动挑选最匹配的那一个。这体现的是一种"多态"行为——同一个调用接口,在不同输入下表现出不同的行为。需要强调的是,C 语言不支持同名函数。

#include <iostream>
using namespace std;
 
// 1. 参数类型不同:两个 Add 名字相同,一个处理 int,一个处理 double
int Add(int left, int right)
{
    cout << "int Add" << endl;
    return left + right;
}
double Add(double left, double right)
{
    cout << "double Add" << endl;
    return left + right;
}
 
// 2. 参数个数不同:f 无参,f(int) 一个参
void f()       { cout << "f()" << endl; }
void f(int a)  { cout << "f(int a)" << endl; }
 
// 3. 参数的"顺序"不同:f(int,char) 与 f(char,int)
void f(int a, char b)   { cout << "f(int, char)" << endl; }
void f(char b, int a)   { cout << "f(char, int)" << endl; }
 
int main()
{
    // 自动匹配对应的重载
    Add(10, 20);       // int 版本,输出:int Add
    Add(10.1, 20.2);   // double 版本,输出:double Add
    f();               // f()
    f(10);             // f(int a)
    f(10, 'a');        // f(int, char)
    f('a', 10);        // f(char, int)
    return 0;
}

为什么 C++ 能重载,而 C 不能?——名称修饰

如果你只用一句话理解重载,就是说"同名函数换个形参就能并存"。但你可能追问一句:编译器怎么做到"同名还能分辨"的?这背后藏着一个 C++ 底层机制,叫名称修饰(name mangling,也叫名字改写/名称修饰)。

回忆一下编译流程里的"链接"步:多个编译单元(.obj/.o 文件)最终靠"符号名"相互引用函数。链接器只认符号名,不认"参数类型"。C 语言里一个函数名就是一个符号名,所以同名函数根本没法共存(链接器区分不开,就是同一个符号两个定义)。C++ 为了支持重载,在生成符号名时会把"函数名 + 函数的参数类型 + 所在命名空间/类"等信息编码进符号名,于是同一个 Add 的两个版本,在链接器眼里其实是两个完全不同的符号:

  • int Add(int,int)、double Add(double,double) 是同一文件里的两个重载,但在 MSVC 下图示化后大概是 ?Add@@YAHHH@Z 和 ?Add@@YANNN@Z 这种"天文符号";
  • 在 GCC/Clang 采用的 Itanium ABI 下,void foo(int) 会变成 _Z3fooi,void foo(double) 变成 _Z3food,ns::foo(int) 变成 _ZN2ns3fooEi。你可以用 c++filt 工具把这些符号反解回可读的函数签名。

正因为 C++ 的函数符号里编码了参数和名字空间信息,"同名不同参"的函数才能在链接层和谐共存;也正是因为这一切建立在 C++ 的编译规则上,C 语言没有这套机制,所以 C 不支持函数重载。这还顺带解释了一件事:当一个模块要同时以"C 名"被 C 程序和 C++ 程序调用时(比如某个库既希望 C 能用也希望 C++ 能用),就要用 extern "C" 告诉编译器"这个函数别给我做名称修饰"。一旦全 C 链接,就意味着该函数不能再重载——因为你把"区分多个同名函数"的唯一手段(名称修饰)给禁掉了。

两个容易踩的坑:返回值不行,重载撞缺省参数

这里有个隐蔽但重要的边界:返回值不同不能作为重载条件。因为调用端常常不在乎返回值(比如写成 f(); 单独一行),编译器靠返回值根本没法区分该调用哪个版本。所以"只改返回类型"地再来一个同名函数,是不被允许的。这一点你再结合"名称修饰"来记就格外顺:C++ 函数符号里编码了参数却不编码返回类型(否则你得先知道要什么返回值才能决定代码怎么写,这是鸡生蛋的问题),所以返回值天然无法参与区分。

另一个更阴的坑,是重载和缺省参数叠加时产生的歧义。比如:

#include <iostream>
using namespace std;
 
// 一个无参版本,一个带缺省参数版本
void f1()           { cout << "f()" << endl; }
void f1(int a = 10) { cout << "f(int a)" << endl; }
 
int main()
{
    // 歧义!编译器不知道调 f1() 还是 f1(10),报错:二义性调用
    // f1();
    return 0;
}

当我们调用 f1() 时,编译器陷入两难:既可以直接匹配 f1(),也可以匹配"没给实参、自动使用默认值 10"的 f1(int a = 10)。两个都行,等于都不确定,于是报"调用有歧义"。这句话也是教训:重载与缺省参数放一起时要格外小心,别让编译器无从选择。

还有个细节你以后会反复遇到:const 也可以参与重载,即 void f(int&) 和 void f(const int&) 是两个不同的重载,编译器会根据"实参是否常量"去选。这背后和引用的"权限缩放"(下一节讲)是同一套逻辑,现阶段的你知道"存在这么回事"即可,等学引用时再一并消化。

重载的解决顺序(先建立一个低配直觉)

其实重载的完整匹配规则非常复杂(要讲"精确匹配、标准转换、用户定义转换、变参兜底"一大套),真正学"重载决议"是后面的事。但这节课我给你一个够用的直觉:编译器尽量找"改动最小"的匹配——一模一样的类型最优先;实在没有,才考虑做类型转换(比如 char 提升为 int)。等你以后熟练了,再回头啃那套完整的重载决议规则也不迟。

引用

现在轮到 C++ 里最重要也最容易被误解的概念之一——引用。

一句话定义:引用不是新定义一个变量,而是给一个已经存在的变量取了一个"别名"。编译器不会为引用变量另行开辟内存空间,它和它引用的那个变量共用同一块内存。打个比方,就像水浒传里李逵:宋江叫他"铁牛",江湖人称"黑旋风"——不管哪个名字,指向的都是同一个人。这些名字看着是两个称呼,其实是同一张"身份证"。计算机里也是一样,int& 别名 = 原变量; 之后,"别名"和"原变量"就是同一块内存上的两个名字,改谁都一样。

它的语法形式是:类型& 引用别名 = 引用对象;。特别注意这里的 & 符号——C++ 为了避免引入太多新运算符,复用了 C 语言已有的符号。前面我们看到 <<、>> 兼职做输入输出和位运算,这里 & 也复用做了"引用"和"取地址"两种用途,只能靠使用位置来区分。说实话这不是个特别好的设计,很多人刚学时会被它绕晕,但多写几次就习惯了。

#include <iostream>
using namespace std;
 
int main()
{
    int a = 0;
    // b 和 c 都是 a 的别名
    int& b = a;
    int& c = a;
    // 也可以给别名 b 再取别名 d,d 到头来还是 a 的别名
    int& d = b;
 
    ++d;   // 对 d 自增,实际改变的是 a
 
    // 打印四个变量的地址,会发现它们是同一个地址
    cout << &a << endl;
    cout << &b << endl;
    cout << &c << endl;
    cout << &d << endl;
    // 运行后 a 变成了 1,因为 ++d 修改了同一块内存
    cout << a << endl;
    return 0;
}

运行一下你会发现,&a、&b、&c、&d 打印出来的地址一模一样,而且 a 的值也变成了 1。这就是"别名共用同一块内存"的铁证。

这里值得先把"引用变量"和"被引用对象"的关系讲透一层:引用本身并不是一个普通对象,C++ 标准把引用定义为"另一个对象的别名"。至于编译器在机器层面怎么实现引用,标准并没有强制规定,绝大多数编译器是把它当成"一个隐含的指针"来实现的——但作为程序员,你在语义层面完全可以把引用理解成"对象的别名,不额外占内存、和对象是一体的"。这里用"别名"而非"新建变量"的心智模型,能帮你避开一大批陷阱。

引用的三条特性

关于引用,有三条特性必须刻进脑子:

  1. 引用在定义时必须初始化。int& ra; 这行会直接报错——你没告诉它"别的是谁",它也没法凭空存在。
  2. 一个变量可以拥有多个引用。上面 b、c、d 同时都是 a 的别名。
  3. 引用一旦"指向"一个实体,就再也不能改变指向其他实体。这一点和指针形成鲜明对比——指针可以随时改指别的对象,引用不行。

我们来验证一下第三条里那个最常踩的误解:

#include <iostream>
using namespace std;
 
int main()
{
    int a = 10;
    int& b = a;   // b 是 a 的别名
    int c = 20;
 
    // 下面的 b = c 是"赋值",不是"让 b 改去引用 c"
    // C++ 引用不能改变指向,这里只是把 c 的值拷给 a(因为 b 是 a 的别名)
    b = c;
 
    cout << &a << endl;  // 和 b 同地址
    cout << &b << endl;  // 和 a 同地址
    cout << &c << endl;  // c 自己独立的地址
    cout << a << endl;   // a 的值被改成 20 了
    cout << c << endl;   // c 仍是 20
    return 0;
}

注意 b = c; 这一行,很多新手会以为"让 b 改去引用 c"。错了!C++ 规定引用不能改变指向,所以这行执行的是赋值:把 c 的值 20 写进 b 所引用的那块内存,也就是把 a 从 10 改成了 20。b 依然死盯着 a,地址没变。这个"变更引用对象"的误读,是初学者最常见的 bug 之源。一句话记住:引用一出生就锁死了归宿,之后你写的"等于号"都只是往那块内存里写值,而不是改引用本身。

那引用到底解决什么问题?答案是引用传参和传引用返回,它们解决两件事:减少拷贝、并让函数能直接操作用户传进来的对象。

先看引用传参。它和指针传参功能类似——都能让函数直接操作实参本身,而不是拷贝一份进来。但引用传参写起来清爽得多:不需要取地址符 &,函数体内直接用名字,看起来和普通变量一样。经典例子是交换:

#include <iostream>
using namespace std;
 
// 用引用传参:rx、ry 是实参的别名
void Swap(int& rx, int& ry)
{
    int tmp = rx;   // 先把 rx 的值存起来
    rx = ry;        // 把 ry 的值赋给 rx
    ry = tmp;       // 再把 tmp 赋给 ry,完成交换
}
 
int main()
{
    int x = 0, y = 1;
    cout << x << " " << y << endl;   // 交换前:0 1
    Swap(x, y);                      // 传的是实参本身(借助引用),不是拷贝
    cout << x << " " << y << endl;   // 交换后:1 0
    return 0;
}

对比一下 C 语言版本的 Swap(int* px, int* py):函数里全是 *px、*py 的解引用,调用还要写成 Swap(&x, &y)。用了引用,函数体和调用处都清爽了,作用和指针一样——这就是"功能有重叠,但引用用起来更顺手"。一个典型的应用是:很多用 C 实现的数据结构教材,里面对外接口用指针传参,换成 C++ 版时会用引用替代,目的就是简化接口、避开一层层解引用,很多没学过引用的同学看到 int& 会一头雾水,你现在弄懂了,就不会再被吓到。

引用还能进一步简化一个"原来需要二级指针"的场景:你想在一个函数里修改"某个指针本身"(比如带头节点链表插入时需要改动头指针),在 C 里你得传"指针的指针"(ListNode**);在 C++ 里,你可以把实参声明成指针类型 PNode(比如 typedef struct ListNode* PNode;),然后函数的参数写成 PNode& phead——这是"给指针变量取别名",于是函数里直接改 phead 就能同时改掉外部那个指针,省下一整个 * 层级。这是引用在数据结构实战里非常常见的用法,记下来,以后写链表你会感谢它。

再说传引用返回。如果你的函数要返回一个可被修改的对象本身(而不是一份拷贝),可以用引用作返回类型。比如让它返回栈里某个位置元素的引用,这样调用端甚至可以直接改这个元素:

#include <iostream>
#include <cassert>
using namespace std;
 
// 一个简易的"栈",用数组模拟,这里只看引用返回的用法
int arr[10] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
int top = 10;   // 模拟栈顶位置
 
// 返回类型是 int&,即返回栈顶元素的引用
int& Top()
{
    assert(top > 0);   // 空栈保护
    return arr[top - 1];
}
 
int main()
{
    // 返回的是元素的引用,所以可以"就地修改"
    Top() += 10;        // 把栈顶元素直接加 10
    cout << Top() << endl;   // 输出修改后的栈顶
    return 0;
}

Top() += 10; 这行很能说明问题:因为 Top 返回的是 int&,它得出的是某个元素的"别名",于是可以在它上面直接累加。如果返回类型改成普通的 int(传值返回),那就只能拿到一份拷贝,+= 10 改不到原数据了。传值返回会在函数返回时,把返回对象复制出一份临时对象交给调用方;传引用返回返回的是对象自身的别名,省去了这一份拷贝,还能让调用方直接改原对象。

不过这里要提前打好预防针:传引用返回不是所有场景都能用。如果函数要返回的是它自己的局部对象(函数结束后就销毁的那个),再返回它的引用就有风险——函数返回后,那块内存可能已经被回收,外面拿到的引用指向了一块不确定的区域。这一点等你以后学"类和对象"会深入展开,现在先记住:引用返回时,返回的对象必须是函数结束后依然"活着"的东西。换句话说,返回的对象要么是全局的,要么是调用方传进来的,要么是堆上分配的,绝不能是函数自己的局部临时变量。

const 引用

引用还有一种高频用法是 const 引用。先理解引用的访问权限概念:一个普通对象是"可读可写"的,一个 const 对象是"只读"的。当我们用引用去引用一个对象时,可以缩小访问权限,但不能放大。

  • 引用可以引用一个 const 对象,但此时必须用 const 引用(权限持平,都是只读);
  • const 引用可以引用普通对象(普通对象明明可写,你却只读它,属于权限缩小,允许);
  • 反过来,普通引用去引用一个 const 对象,就是权限放大(她本来只读,你却要写),编译报错。
#include <iostream>
using namespace std;
 
int main()
{
    const int a = 10;   // a 是常量,只读
    // int& ra = a;     // 报错:权限放大,不能把 const 对象用非 const 引用
    const int& ra = a;  // 正确:const 引用常量对象
    // ra++;            // 报错:ra 只读,不能赋值
 
    int b = 20;         // b 是普通变量,可读可写
    const int& rb = b;  // 权限缩小:const 引用普通对象,允许
    b = 30;             // b 自己仍然可以改
    // rb++;            // 报错:虽然 b 能改,但通过 rb 这条通道只读
    return 0;
}

const 引用在工程里的意义非常大,因为它意味着"我用最安全的只读方式去看一个对象,绝不意外改动它"。以后你写函数形参时,凡是"只需要读、不需要改"的对象,都应该写成 const 类型& ——这既是对调用方的承诺,也防止你自己在函数里手误改掉它们。这块等到你学函数时会成为最常用的一条规约。

还有一个新手极易混淆的点,涉及临时对象。所谓临时对象,是指编译器需要一个空间来暂存某个表达式求值结果时,临时创建的一个未命名对象。C++ 规定:临时对象具有"常性",即它本质上是只读的。所以像下面这些写法,如果引用它,必须用 const 引用,否则就是权限放大而报错:

#include <iostream>
using namespace std;
 
int main()
{
    int a = 10;
    const int& ra = 30;    // 30 是字面量,产生临时对象,用 const 引用
 
    // int& rb = a * 3;     // 报错:a*3 的结果是临时对象,只有常性
    const int& rb = a * 3; // 正确:const 引用一个临时对象
 
    double d = 12.34;
    // int& rd = d;        // 报错:double 转 int 会产生临时对象,同样只有常性
    const int& rd = d;     // 正确:const 引用,d 被截断成 12 存入临时对象
 
    cout << ra << " " << rb << " " << rd << endl;  // 30 30 12
    return 0;
}

为什么 int& rb = a * 3; 会报错? 因为 a * 3 这个表达式求出来的结果必须先找个地方暂存,这个暂存空间就是一个临时对象。临时对象是只读的,而你试图用一个非 const 引用去引用它(将来想写它),这就是权限放大,于是报错。同理,double d 转成 int 时,类型转换也会产生一个中间的临时 int,所以也只能用 const int& 来引用。

这里有个记忆锚点:凡是因为"计算"或"类型转换"得到的结果,都是临时对象,临时对象只能被 const 引用。 掌握这一点,一大批莫名其妙的编译错误就都有了解释。顺带说一个你可能急着想知道的点:const 引用绑定临时对象时,C++ 会"延长临时对象的生命周期"——让这个临时对象活到引用变量的生命周期结束为止。这正是 const int& rd = d; 能安全使用的原因。这是标准明确保证的行为,你现在知道结论即可。

引用和指针的关系:一对性格迥异的兄弟

最后把引用和指针放在一起做个总结。它们像一对兄弟:功能有重叠,但各有个性、互相不可替代。指针像稳重缜密的哥哥,引用像清爽利落的弟弟。

对比维度引用指针
本质给变量取别名,不开新空间存变量的地址,要开空间
是否必须初始化必须初始化建议初始化,但语法上非必须
能否改变指向不能,一旦引用就锁死可以不断改成指向别的对象
访问方式直接当普通变量用需要解引用 *p 才能访问
sizeof 结果引用类型的大小指针本身大小(32 位平台 4 字节,64 位 8 字节)
安全性很少出现空/野引用容易出空指针和野指针问题

最后一行值得单独强调。指针可以有"空指针"(指向空地址)和"野指针"(指向一块已经不该访问的内存)。引用由于"必须初始化 + 一旦绑定不改",天然规避了这两种问题,所以用起来相对更安全。这也是很多用 C 写的数据结构教材换用 C++ 引用传参的原因——在守规矩的前提下,函数接口更简洁、出错面更小。当然,这不代表可以滥用引用去强行替代指针;两者各有用武之地,比如链表这个需要"指针可以重新指向别的节点"、"可以有 NULL"的场景,就仍然离不开指针。

还要顺口提一句,避免你对"引用"这个词产生跨语言误判:C++ 的引用,和 Java 里的"引用"/"对象引用"是两码事。C++ 引用一旦绑定就不能改指向(而且语义上是别名,对象本身就地使用);Java 的引用更像 C++ 的指针(拿着地址、可以重新赋值指向别的对象)。很多从 Java 转过学 C++ 的同学,会被"引用"这个名字带偏,这里先立个反歧义的锚。

内联函数

进入 C++ 之前,C 程序员是用宏函数(#define)来替代一些短小函数的,因为调用函数有开销。但它有两个致命短板:一要写一大堆括号,稍不注意就出错;二它只是纯粹的文本替换,占位符本身都可能导致反直觉的结果。来看一个经典案例:

#include <iostream>
using namespace std;
 
// 正确的宏写法:外层括号 + 内部每个参数都加括号,是为了防止优先级问题
#define ADD(a, b) ((a) + (b))
 
int main()
{
    cout << ADD(1, 2) << endl;        // 3
    cout << ADD(1, 2) * 5 << endl;    // (1+2)*5 = 15
    int x = 1, y = 2;
    // 如果宏没加内层括号,(x&y+x|y) 会因为运算符优先级算出错误结果
    int r = ADD(x & y, x | y);        // ((x&y)+(x|y))
    cout << r << endl;
    return 0;
}

如果宏里这个参数不加括号,ADD(x & y, x | y) 会被原样替换成 x&y+x|y——由于 + 优先级高于 &,结果就完全变味了。这就是宏"防不胜防"的体现:它不知道自己是函数,只知道填文本。

好,到这里我要把宏的坑一次性讲透,因为它直接决定了 C++ 为啥要造 inline。除了"优先级括号"这个坑,宏还有三个更隐蔽的毛病:

  • 宏没有类型检查。ADD(hello, world) 这种明显荒谬的调用,宏在"文本层面"根本不管类型,只要替换后能过语法关就行;而真正的函数会逐个检查形参类型。
  • 宏可能把实参求值两次。比如 #define SQUARE(x) ((x) * (x)),你写 SQUARE(a++),会被展开成 ((a++) * (a++))——a 被自增了两次!而普通函数 square(a++) 只会求值一次。这种"参数副作用翻倍"的 bug 极难排查。
  • 宏没有作用域。宏是预处理器在编译前的文本替换,它没有"函数局部"的概念,宏体里的临时变量可能和外部的名字冲突。

C++ 用 inline 关键字提供的内联函数来替代宏。被 inline 修饰的函数叫内联函数,它的特点是:编译时,C++ 编译器会在调用它的地方直接展开函数体,而不是像普通函数那样建立一次函数调用栈帧。所谓栈帧,是函数被调用时,系统在"调用栈"上为它分配的一块区域,用来容纳它的局部变量、参数和返回地址。如果函数很小又到处调用,反复建栈帧、销毁栈帧就有点浪费——内联展开直接省掉了这层开销。而且,因为内联函数是"真函数",它天生就躲开了宏的三大坑:有类型检查、实参只求值一次、有正常的作用域。

#include <iostream>
using namespace std;
 
// inline 修饰一个短小函数
inline int Add(int x, int y)
{
    int ret = x + y;
    ret += 1;
    ret += 1;
    return ret;
}
 
int main()
{
    // 在 release 版本下,编译器会把 Add 的代码直接展开到这里,不再调用
    int ret = Add(1, 2);
    cout << ret << endl;
    return 0;
}

要观察内联到底有没有展开,一个办法是看编译出来的汇编或者看 release 的调试结果:如果调用处能看到一条 call Add 指令,说明没展开;没有 call,说明展开了。不过这里必须澄清一个关键认识:inline 对编译器而言只是一个"建议",不是命令。你加了 inline,编译器完全可以决定不展开。C++ 标准并没有规定什么条件下必须展开,所以不同编译器策略各不相同。大体上,inline 适合那种"被频繁调用、代码又短小"的函数;对于递归函数、代码量较大的函数,加上 inline 也基本会被编译器忽略。一个具体的例子是:MSVC 在 Debug 模式下默认不展开内联(哪怕你写了 inline),方便调试时能看真实的调用栈;只有在 Release 模式或打开相应优化选项(如 /Ob2)时才可能展开。所以别拿"Debug 模式没展开"当证据说 inline 没用——它要在 Release 优化下才发力。

还有一条工程上的铁规:内联函数不要声明(头文件)和定义(源文件)分离到不同文件。因为内联函数在调用处被展开后,说白了就没有那个独立的函数地址了。如果你在 .h 里只写了声明、在 .cpp 里写定义,链接阶段就会发现那个内联函数"查无此人",报链接错误(比如 VS 下的"无法解析的外部符号")。所以内联函数要像宏一样,把完整定义放在头文件里。这背后的本质我们其实已经摸过了——编译器要"就地展开"就必须看到函数体,而每个 .cpp 只能看到它 #include 进来的头文件内容;如果把函数体藏在某个 .cpp 里,其它 .cpp 在编译时根本看不到函数长什么样,自然无从展开,链接时又找不到一个可调用的地址,于是一头撞上"symbol 未定义"。

// F.h —— 错误示范:只声明不定义
// inline void f(int i);      // 这样写,外部调用处无法展开,别人也找不到实现
 
// 正确姿势:内联函数完整定义直接放在 .h 里
inline void f(int i)
{
    // 一堆短小的实现
}

你可能会问:那多文件里每个 .cpp 把同一个内联函数定义 #include 进来,会不会"重复定义"?不会。因为 inline 触发了 C++ 对"同一实体定义在多处"的特殊容忍(学术界叫它 ODR,单一定义规则对内联函数的放宽):只要每个编译单元看到的定义完全一致,链接器就放心地只留一份。这正是"内联函数必须定义在头文件里"能成立的底层原因。

auto 类型推导

从 C++11 开始,现代 C++ 变得"懒"且"聪明",其中一个代表就是 auto。

先说一个小知识:auto 在 C++98 里其实就存在,但含义完全不同——那时它是"储存类说明符",表示一个自动存储期的普通局部变量,几乎没人用。C++11 对 auto 进行了改造,赋予它全新的含义:让编译器根据初始化表达式自动推断变量的类型。因为在旧版里几乎用不上,C++11 干脆把它"废物利用"重定义成了类型推导关键词。换句话说,auto 这个词经历了"从几乎没用的存储类说明符,到能力惊人的类型推导器"的华丽转身——这也提醒你别在网上看到旧教程说 auto 是啥"自动变量"就照单全收,那是 C++98 的旧闻了。

#include <iostream>
using namespace std;
 
int main()
{
    int a = 10;
    // 编译器根据 a 推断出 i 是 int
    auto i = a;
    // 常量推导:auto 会丢掉 const,b 是 int 而不是 const int
    auto b = 20.5;          // b 被推断为 double
    auto* p = &a;           // p 被推断为 int* (指针)
    auto& r = a;            // 想保留引用语义,要显式写 auto&,否则 r 是拷贝
 
    cout << i << " " << b << endl;       // 10 20.5
    cout << *p << " " << r << endl;      // 10 10
    return 0;
}

用 auto 有一条铁规:定义时必须初始化,因为类型来源于初始化的表达式,不给初值它就没有信息可推断。真正让 auto 大放异彩的场合,是那些类型名很长很啰嗦的场景(比如迭代器),用 auto 能让代码清爽一大截。前面讲 IO 时写的 cin.tie(nullptr),里面那个 nullptr 我们马上就要讲到了。

关于 auto 的推导规则,有几个你必须知道的细节,否则你会写出"我以为它是引用/常量"的困惑代码:

  • auto 默认按"值"推导:它会丢弃表达式结果里的引用和最外层的 const/volatile。比如 int a = 10; auto b = a; 得到的 b 是 int(按值拷贝);int& r = a; auto c = r; 得到的 c 仍是 int 而不是引用;const int ci = 5; auto v = ci; 得到的 v 也是 int 而非 const int。
  • 想保留引用,写 auto&:auto& r = a; 让 r 成为 a 的引用(别名),改 r 改 a。
  • 想保留常量,写 const auto 或 const auto&:const auto x = foo(); 明确是只读的 x;传参给"只读不修改"的对象,习惯写法就是 const auto& 。

一句话总结推导直觉:auto x = 表达式 拿走一个"按值拷贝",auto& 拿走"引用",const auto& 拿走"只读引用"。 你把这三档记住,日常 95% 的 auto 用法就不会翻车。

另外再提醒一个边界:auto 不能用于"裸地"声明函数参数或在没有初始化信息时推断。函数的形参类型(如果没有 C++20 的 template 特性配合)不能用 auto 直接声明;一个单独的 auto x; 因为没有初值必然报错。这些都源于同一句话:auto 的类型来自初始化表达式,脱离表达式它就无从谈起。

基于范围的 for 循环

C++11 还带来了一种专门遍历容器的循环写法——基于范围的 for 循环(range-based for)。它的语法是:for (元素类型 变量 : 容器),它会自动从头到尾遍历容器里的每一个元素,你完全不需要写"从下标几到几、步长几"这些细节——容器有多少元素,它就遍历多少遍,天然不会像手写下标那样越界。

#include <iostream>
using namespace std;
 
int main()
{
    int arr[] = { 1, 2, 3, 4, 5 };   // 一个整型数组
 
    // 遍历打印每个元素
    for (int x : arr)          // 每轮把 arr 里的一个元素拷给 x
    {
        cout << x << " ";
    }
    cout << endl;              // 输出:1 2 3 4 5
 
    // 想修改元素,就要用引用,否则改的是拷贝
    for (int& x : arr)         // x 是元素的引用,改 x 就是改数组
    {
        x = x * 2;
    }
 
    // 再看一次,数组里的元素确实被翻倍了
    for (int x : arr)
    {
        cout << x << " ";
    }
    cout << endl;              // 输出:2 4 6 8 10
    return 0;
}

注意第二次循环用了 int& x(引用)——因为 for (int x : arr) 里 x 是每个元素的拷贝,改 x 不影响数组。想真正写回数组,就得让 x 成为元素的别名,也就是引用。这个细节非常重要,和前面引用的思想完全打通了。等你后面学到 vector 这类容器,这种不带下标的遍历方式会非常常用。

这里我可以给你一个立刻能上手、也极常见的实践组合:for (auto& x : 容器)。它同时用好 auto(不用手写元素类型)和 &(不拷贝、可修改)。如果只是遍历只读,那就 for (const auto& x : 容器)——用只读引用,既不拷贝又承诺不修改,是遍历大型容器的标准姿势。

关于范围 for,再补两个技术要点,它们能帮你躲过常见的坑:

  • 它的工作依赖"有始有终":范围 for 底层要求容器提供 begin() 和 end()(或者它是"大小已知的数组")。对 C 数组来说,前提是编译器能在编译期确定数组大小,也就是数组必须在当前作用域直接定义。一旦你把数组作为函数参数传进去,它就已经"衰变"成了指针,sizeof 都拿不到大小了,范围 for 也就无从遍历。这是"C 数组 + 函数传参 + 范围 for"组合在一起时最容易犯的错。
  • 遍历过程中别改动容器结构:如果容器是 vector 这类可变的,你在循环体里去"往容器里加/删元素",会让迭代器失效,行为未定义。入门阶段记住一条经验:范围 for 里只读或修改已有元素的值,不要增删容器元素本身。

指针空值 nullptr

最后,我们来揭晓 C 语言时代一个著名的历史谜题:NULL 到底是什么?

NULL 其实是一个宏。在传统的 C 头文件 stddef.h 里,大致是这样定义的:

#ifdef __cplusplus
    #define NULL 0           // 在 C++ 里,NULL 被定义成整数 0
#else
    #define NULL ((void*)0)  // 在 C 里,NULL 被定义成 (void*)0
#endif

也就是说,在 C++ 里,NULL 实际就是整数常量 0。这意味着,当你想用 NULL 指示一个空指针时,编译器看到的其实是 0。这在函数重载时就会闹出乌龙:

#include <iostream>
using namespace std;
 
void f(int x)       { cout << "f(int x)" << endl; }
void f(int* ptr)    { cout << "f(int* ptr)" << endl; }
 
int main()
{
    f(0);          // 指名道姓,调用 int 版本
    // 本想调用指针版本 f(int*),但 NULL 被定义成 0,于是调用了 f(int x)
    f(NULL);       // 输出 f(int x) —— 和初衷相悖!
    f((int*)NULL); // 强转才能调到指针版本,很别扭
    // f((void*)NULL); // 报错:无法把 void* 转成 int*(隐式不允许)
    return 0;
}

你看,f(NULL) 明明想调指针版本,结果因为 NULL 是 0,调了 f(int)——程序行为和你的意图完全相反。而在 C 语言里,NULL 又可能被定义成 (void*)0,两种定义各有利弊,总会在"空指针"这件事上引出麻烦。

为了解决这个问题,C++11 引入了 nullptr。它是个特殊的关键字,是一种"特殊类型的字面量",可以隐式地转换成任意类型的指针,但不能被转换成整数。于是:

#include <iostream>
using namespace std;
 
void f(int x)       { cout << "f(int x)" << endl; }
void f(int* ptr)    { cout << "f(int* ptr)" << endl; }
 
int main()
{
    // 只有 int 版本能匹配 nullptr 吗?不,nullptr 只能转成指针
    // f(0);        // 0 是整数,调用 f(int);但 nullptr 不是整数
    f(nullptr);      // 精确匹配指针版本,输出 f(int* ptr)
 
    int* p = nullptr;   // 现代 C++ 定义空指针的标准写法
    if (p == nullptr)   // 判断是否为空指针
    {
        cout << "p 是空指针" << endl;
    }
    // nullptr 不能赋给整数、不能参与整数运算
    // int n = nullptr;  // 报错:不能把 nullptr 转为整数
    return 0;
}

f(nullptr) 这次准确命中了 f(int* ptr)——因为 nullptr 天生就是"指针的空值",只有指针版本能接受它。同时,nullptr 不能赋值给整数(区别于 NULL 的 0),这堵住了"把空指针当整数用"的一道口子。所以现代 C++ 的规范写法是:定义空指针一律用 nullptr,不要再写 NULL或 0。这也回应了前面判断标准版本的方法:能用 nullptr,至少说明你用的是 C++11 及以后的标准(C++98 只有 NULL)。

关于 nullptr 的类型,再说一个准确的小知识,方便你日后查阅时不被绕晕:nullptr 的类型叫 std::nullptr_t,它定义在头文件 <cstddef> 里。它是一种"特殊到不能再特殊"的指针类型——你几乎永远不需要直接声明一个 std::nullptr_t 变量,你只会在"要不要接收一个空指针字面量"的接口里间接碰见它。你只要记住:nullptr 是"地地道道的空指针",能转成任何指针类型,就是转不成整数,这就够了。

还有个呼应历史的小彩蛋:C++ 在 C++11 解决了这个问题的确凿方案是 nullptr 后,C 语言直到 2023 年的 C23 标准也引入了 nullptr。也就是说,这条"C 和 C++ 在空指针上的纠葛"最终两边都用很接近的思路做了收尾。这也从侧面说明:nullptr 是一个连最保守的 C 委员会都认可的、解决真实痛点的好设计。


到此,我们从 C++ 的历史出发,走过了它的第一堂课。你如今知道了命名空间如何用一对大括号把名字隔离起来、避免那场 C 时代的 rand 冲突,也知道了它背后"编译器默认不翻柜子"的查找机制和名称修饰这层底层原理;学会了用 cout << 和 cin >> 摆脱 % 占位符,还掌握了 IO 提速三板斧与 '\n'/endl 的取舍;理解了缺省参数让调用可省可全、但要守住"从右往左连续缺省"与"从左到右给实参"两条铁律;见识了函数重载如何在同名下按形参"智能分流",理解了它依赖名称修饰才得以成立,也记下了它和缺省参数叠加可能产生的歧义。重头戏是引用——它不是新变量,只是别名,必须初始化、一旦绑定不再更改,配合 const 引用能优雅处理临时对象和权限缩放,并最终看清了它和指针这对兄弟各自的擅长领域。随后 inline 帮我们告别危险的宏函数,auto 让类型推导化繁为简,范围 for 让遍历容器变得清爽,nullptr 也终于为"空指针到底长什么样"画上了句号。

如果你跟着上面的代码一个个敲下来、把每个报错都亲手踩过一次,这第一堂课就算真正拿下了。这些概念看似零碎,但它们之间其实是环环相扣的:命名空间的查找规则、引用的别名本质、缺省参数和重载的边界、auto 和范围 for 的搭配……每一块都像是为下一块准备的砖。别急着一次吃透,代码里多碰几次、"踩坑—查错—明白了",这套逻辑链就长在你脑子里了。

而这第一堂课,其实是通往后面"类与对象""内存管理"这些大题的承重墙——地基打得越实,后面爬坡越轻松。下一篇文章,我们进入 C++ 最核心的地带:类与对象,看看 C++ 是如何把数据和行为封装成一个整体的。准备好了吗?