目录
- C++发展历史与演进
- C++版本更新与Networking争议
- C++参考文档的正确使用姿势
- C++的行业地位与TIOBE排行
- C++在工作领域中的核心应用
- C++学习建议与经典书籍推荐
- C++的第一个程序与编译机制
- 命名空间(namespace)的底层逻辑与工程实践
- C++标准输入输出与IO性能优化
- 函数缺省参数与编译期约束
- 函数重载与名字修饰(Name Mangling)
- 引用(Reference)的内存模型与实战
- inline内联函数与宏函数的底层博弈
- nullptr与空指针的类型安全
1. C++发展历史与演进
关于C++的起源,我的理解是它本质上是为了解决工程复杂性而诞生的。1979年,Bjarne Stroustrup(本贾尼·斯特劳斯特卢普)在贝尔实验室做计算机科学和软件工程研究时,发现C语言在应对复杂的模拟和操作系统开发时,表达能力、可维护性和可扩展性明显不足。
1983年是C历史上的一个里程碑,Bjarne在C语言的基础上引入了面向对象编程(OOP)特性,设计出了C的雏形。这时候已经有了类、封装、继承等核心概念,正式命名为C++。
随后几年,C在学术界和工业界迅速铺开。很多大学把它作为首选教学语言,企业也开始在产品中试水。在这个阶段,C的标准库和模板(Template)等特性得到了极大的完善。
到了1989年,C开启了标准化进程,成立了ANSI和ISO的联合委员会。1994年发布了第一个国际标准C98。此后C进入了快速迭代期,经历了C03、C11、C14、C17、C20,一直到最新的C++23。
2. C++版本更新与Networking争议
这里有一个很容易踩坑(或者说吃瓜)的地方:C一直被诟病没有官方的网络库(Networking)。Networking原本在C23的计划中,但C++23已经发布了,Networking却缺席了,这在社区引发了一系列吐槽,中间的过程简直像宫斗剧一样精彩。
如果你关注过 P2452R0 2021 October Library Evolution and Concurrency Networking and Executors Poll Outcomes 这个提案,就会发现标准委员会在并发、网络和执行器(Executors)的投票和取舍上经历了极其复杂的博弈。面试时如果问到C++23的目标或者网络库,可以从这个历史背景切入,说明标准制定的严谨性和工程权衡。
3. C++参考文档的正确使用姿势
查文档是程序员的基本功,我平时主要用这三个:
https://legacy.cplusplus.com/reference/:注意,这不是官方文档,标准只更新到C11。但它的优势是以头文件形式呈现,排版易看,适合快速查阅C11及以前的基础API。https://zh.cppreference.com/w/cpp:C++官方文档的中文版,信息极全,更新到了最新标准。缺点是排版相对密集,不如第一个直观。https://en.cppreference.com/w/:英文版官方文档,内容最权威、最及时。
我的建议是:日常快速查阅用第一个,遇到新标准特性或底层细节,直接切到后两个官方文档。结合使用效率最高。
4. C++的行业地位与TIOBE排行
TIOBE排行榜是通过统计互联网上有经验的程序员、课程、第三方厂商数量,以及搜索引擎(Google、Bing、Yahoo)、Wikipedia、Amazon、YouTube等平台的数据来评估流行度的。在2026年4月TIOBE发布的编程语言排行榜中,C++依然稳居前列。这充分说明了它在底层和高性能领域的不可替代性。
5. C++在工作领域中的核心应用
面试时经常被问到"C++能做什么",其实它的应用领域非常硬核,主要集中在以下8个方向:
- 大型系统软件开发:如编译器、数据库、操作系统、浏览器等,这些是对性能和内存控制要求极高的领域。
- 音视频处理:音视频开发最主要的技术栈就是C++。常见的开源库和方案有FFmpeg、WebRTC、Mediasoup、ijkplayer。
- PC客户端开发:主要是Windows桌面软件(如WPS),技术栈一般是C++搭配QT(跨平台GUI程序)。
- 服务端开发:大型应用的高并发后台。虽然Java用得多,但C++主要占据对性能要求极高的生态位,如游戏服务、流媒体服务、量化高频交易服务。
- 游戏引擎开发:主流游戏引擎基本都是C++写的,做游戏开发必须掌握。
- 嵌入式开发:把具有计算能力的主控板嵌入到机器内部。比如智能手环、摄像头、扫地机器人、车载系统等。粗略分为嵌入式应用和嵌入式驱动开发。
- 机器学习引擎:底层算法多用C实现,上层用Python封装。如果只想训练模型,学Python够了;但如果要做机器学习系统的底层开发,C是必经之路。
- 测试开发/测试:测试开发通常使用Selenium、JMeter等工具,设计用例并写脚本进行自动化和性能测试,有时需要自行开发测试用具。功能测试则偏向手动执行用例。
6. C++学习建议与经典书籍推荐
C难学吗?客观地说,它是一个相对难学难精的语言。学习曲线陡峭,既有历史包袱的原因,也有语言本身设计和发展历史的问题。网上以前一直流传"21天内自学精通C"的梗,自学难度确实很大。
书籍推荐(亲测有效):
- 《C++ Primer》:经典的语法书,前中后期都能看。前期自学可能觉得晦涩,能看懂多少看多少,当预习;学了课程后,中后期把它当语法字典,非常好用。
- 《STL源码剖析》:侯捷老师的经典之作。庖丁解牛式地剖析STL底层实现,能帮我们学习别人如何用语法实现高效简洁的数据结构和算法,以及如何使用泛型封装。让我们不再坐井观天、闭门造车。建议课程过半、中后期看。
- 《Effective C++》:同样是侯捷老师翻译。业界有一句评价:"C程序员分为看过此书的和没看过此书的"。书中讲了55个正确高效使用C的条款,建议中后期看一遍,工作1-2年后再看一遍,绝对会有不一样的收获。
7. C++的第一个程序与编译机制
C兼容C语言绝大多数的语法,所以C语言写的hello world在C里照样能跑。但在C工程中,我们需要把文件后缀改为 .cpp。VS编译器看到 .cpp 就会自动调用C编译器进行编译。其他平台也有对应的编译器,比如 g++、clang++ 等。
cpp
#include<iostream>
using namespace std;
int main()
{
cout << "hello world\n" << endl;
return 0;
}
8. 命名空间(namespace)的底层逻辑与工程实践
8.1 namespace的价值与底层机制
在C/C中,变量、函数、类大量存在。如果全在全局作用域,命名冲突是必然的。C语言项目中这种冲突极其普遍,C引入 namespace 就是为了对标识符进行本地化,避免名字污染。
这里有一个底层逻辑需要理解:C++中的"域"包括函数局部域、全局域、命名空间域、类域。域影响的是编译时语法查找一个变量/函数/类型出处(声明或定义)的逻辑。有了域隔离,名字冲突就解决了。注意:局部域和全局域除了影响编译查找,还会影响变量的生命周期;但命名空间域和类域不影响变量生命周期。
关于命名空间的几个核心规则:
namespace只能定义在全局,但可以嵌套定义。- 项目工程中多文件中定义的同名
namespace会被编译器认为是一个,不会冲突。 - C++标准库都放在一个叫
std(standard) 的命名空间中。
cpp
#include <stdio.h>
#include <stdlib.h>
namespace bit
{
// 命名空间中可以定义变量/函数/类型
int rand = 10;
int Add(int left, int right)
{
return left + right;
}
}
int main()
{
printf("%d\n", bit::rand);
printf("%d\n", bit::Add(1, 2));
return 0;
}
8.2 命名空间嵌套与多文件工程
命名空间支持嵌套,这在大型工程中用于模块化隔离非常有用:
cpp
namespace bit
{
// 鹏哥
namespace pg
{
int rand = 1;
int Add(int left, int right)
{
return left + right;
}
}
// 杭哥
namespace hg
{
int rand = 2;
int Add(int left, int right)
{
return (left + right)*10;
}
}
}
int main()
{
printf("%d\n", bit::pg::rand);
printf("%d\n", bit::hg::rand);
printf("%d\n", bit::pg::Add(1, 2));
printf("%d\n", bit::hg::Add(1, 2));
return 0;
}
在多文件工程中,同名namespace会自动合并。下面是一个完整的栈(Stack)工程示例:
cpp
// Stack.h
namespace bit
{
typedef int STDataType;
typedef struct Stack
{
STDataType* a;
int top;
int capacity;
}ST;
void STInit(ST* ps);
void STDestroy(ST* ps);
void STPush(ST* ps, STDataType x);
void STPop(ST* ps);
STDataType STTop(ST* ps);
int STSize(ST* ps);
bool STEmpty(ST* ps);
}
// Stack.cpp
#include"Stack.h"
namespace bit
{
void STInit(ST* ps)
{
assert(ps);
ps->a = (STDataType*)malloc(4 * sizeof(STDataType));
ps->top = 0;
ps->capacity = 4;
}
void STDestroy(ST* ps)
{
assert(ps);
free(ps->a);
ps->a = NULL;
ps->top = 0;
ps->capacity = 0;
}
void STPush(ST* ps, STDataType x)
{
assert(ps);
if (ps->top == ps->capacity)
{
int newcapacity = ps->capacity * 2;
STDataType* tmp = (STDataType*)realloc(ps->a, newcapacity * sizeof(STDataType));
if (tmp == NULL)
{
perror("realloc fail");
return;
}
ps->a = tmp;
ps->capacity = newcapacity;
}
ps->a[ps->top] = x;
ps->top++;
}
// ... 其他函数实现
}
// test.cpp
#include"Stack.h"
int main()
{
ST st;
STInit(&st);
STPush(&st, 1);
STPush(&st, 2);
return 0;
}
8.3 命名空间的使用方式与工程规范
编译查找变量时,默认只在局部或全局找,不会主动进命名空间。直接使用会报错。展开成员有三种方式:
- 指定命名空间访问 (如
N::a):项目中强烈推荐这种方式,最安全。 - using展开某个成员 (如
using N::b):经常访问且不存在冲突的成员推荐用。 - 展开全部成员 (如
using namespace N):项目中极度不推荐,冲突风险极大;日常小练习为了方便可以用。
cpp
#include<stdio.h>
namespace N
{
int a = 0;
int b = 1;
}
int main()
{
// 编译报错:error C2065: "a": 未声明的标识符
// printf("%d\n", a);
// 指定命名空间访问
printf("%d\n", N::a);
// using将命名空间中某个成员展开
using N::b;
printf("%d\n", b);
// 展开命名空间中全部成员
// using namespace N;
// printf("%d\n", a);
// printf("%d\n", b);
return 0;
}
9. C++标准输入输出与IO性能优化
<iostream> 是标准输入输出流库。std::cin 是 istream 类对象,面向窄字符标准输入;std::cout 是 ostream 类对象,面向窄字符标准输出。std::endl 是个操纵符,底层是函数,相当于插入换行符并刷新缓冲区。<< 和 >> 分别是流插入和流提取运算符(在C语言中是位运算左移/右移)。
相比 printf/scanf,C++的IO不需要手动指定格式,能自动识别变量类型,更方便。
cpp
#include<iostream>
using namespace std;
int main()
{
int a = 0;
double b = 0.1;
char c = 'x';
cout << a << " " << b << " " << c << endl;
std::cout << a << " " << b << " " << c << std::endl;
scanf("%d%lf", &a, &b);
printf("%d %lf\n", a, b);
// 可以自动识别变量的类型
cin >> a;
cin >> b >> c;
cout << a << endl;
cout << b << " " << c << endl;
return 0;
}
避坑与性能优化:在IO需求极高的地方(如算法竞赛的大量输入),C++默认IO较慢。加上以下3行代码可以解除与C标准IO的同步绑定,大幅提升效率:
cpp
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
10. 函数缺省参数与编译期约束
缺省参数是在声明或定义时为参数指定默认值。调用时若不传实参,则采用默认值。
cpp
#include <iostream>
#include <assert.h>
using namespace std;
void Func(int a = 0)
{
cout << a << endl;
}
int main()
{
Func(); // 没有传参时,使用参数的默认值
Func(10); // 传参时,使用指定的实参
return 0;
}
缺省分为全缺省和半缺省:
cpp
// 全缺省
void Func1(int a = 10, int b = 20, int c = 30)
{
cout << "a = " << a << endl;
cout << "b = " << b << endl;
cout << "c = " << c << endl;
}
// 半缺省
void Func2(int a, int b = 20, int c = 30)
{
cout << "a = " << a << endl;
cout << "b = " << b << endl;
cout << "c = " << c << endl;
}
这里有两个极易踩坑的编译期约束:
- 必须从左到右依次给实参,不能跳跃。
- 声明和定义分离时,缺省参数不能在两处同时出现,规定必须在函数声明处给缺省值。
cpp
// Stack.h
#include <iostream>
#include <assert.h>
using namespace std;
typedef int STDataType;
typedef struct Stack
{
STDataType* a;
int top;
int capacity;
}ST;
void STInit(ST* ps, int n = 4); // 声明处给默认值
// Stack.cpp
#include"Stack.h"
// 定义处不能再给默认值
void STInit(ST* ps, int n)
{
assert(ps && n > 0);
ps->a = (STDataType*)malloc(n * sizeof(STDataType));
ps->top = 0;
ps->capacity = n;
}
// test.cpp
int main()
{
ST s1;
STInit(&s1);
ST s2;
STInit(&s2, 1000); // 确定知道要插入1000个数据,初始化时一把开好,避免扩容
return 0;
}
11. 函数重载与名字修饰(Name Mangling)
C++支持同一作用域下的同名函数,只要形参不同(个数、类型、顺序)。这体现了多态行为,而C语言是不支持的。
cpp
#include<iostream>
using namespace std;
// 1、参数类型不同
int Add(int left, int right)
{
cout << "int Add(int left, int right)" << endl;
return left + right;
}
double Add(double left, double right)
{
cout << "double Add(double left, double right)" << endl;
return left + right;
}
// 2、参数个数不同
void f()
{
cout << "f()" << endl;
}
void f(int a)
{
cout << "f(int a)" << endl;
}
// 参数顺序不同也算重载
void f(int a, char b)
{
cout << "f(int a,char b)" << endl;
}
void f(char b, int a)
{
cout << "f(char b, int a)" << endl;
}
// 返回值不同不能作为重载条件,因为调用时也无法区分
// 下面两个函数构成重载
void f1()
{
cout << "f()" << endl;
}
void f1(int a = 10)
{
cout << "f(int a)" << endl;
}
int main()
{
Add(10, 20);
Add(10.1, 20.2);
f();
f(10);
f(10, 'a');
f('a', 10);
f(); // 调用f(),存在歧义,编译器不知道调用谁
f(10); // 调用f(int a)
return 0;
}
12. 引用(Reference)的内存模型与实战
12.1 引用的底层概念
引用不是新定义变量,而是给已存在变量取别名。编译器不会为引用开辟新内存,它和原变量共用同一块空间。就像水浒传里的李逵叫"铁牛",林冲外号"豹子头"。语法是 类型& 引用别名 = 引用对象;。注意:C++为了减少运算符,复用了C语言的 & 符号,这里要按语境区分它是"引用"还是"取地址"。
cpp
#include<iostream>
using namespace std;
int main()
{
int a = 0;
// 引用:b和c是a的别名
int& b = a;
int& c = a;
// 也可以给别名b取别名,d相当于还是a的别名
int& d = b;
++d;
cout << a << endl; // 输出1
return 0;
}
12.2 引用的核心特性
- 定义时必须初始化。
- 一个变量可以有多个引用。
- 一旦引用了某个实体,不能再引用其他实体。
cpp
#include<iostream>
using namespace std;
int main()
{
int a = 10;
int& b = a;
int c = 20;
// 这里并非让b引用c,因为C++引用不能改变指向,这里是一个赋值操作
b = c;
cout << &a << endl;
cout << &b << endl;
cout << &c << endl;
return 0;
}
12.3 引用的实战场景
引用主要用于传参和返回值,目的是减少拷贝、提高效率,以及允许修改原对象。引用传参和指针传参功能类似,但语法更优雅。传值返回会产生临时对象,传引用返回本质是返回对象的别名。
这里有一个很容易被忽视的点:C++的引用跟其他语言(如Java)的引用有很大区别。C++引用定义后不能改变指向,而Java的引用可以重新指向另一个对象。这一点在面试中经常被问到。
cpp
void Swap(int& rx, int& ry)
{
int tmp = rx;
rx = ry;
ry = tmp;
}
int main()
{
int x = 0, y = 1;
cout << x <<" " << y << endl;
Swap(x, y);
cout << x << " " << y << endl;
return 0;
}
用引用替代指针传参是C++中简化数据结构代码的常见手法。以栈为例,用指针传参需要到处写 ps->,用引用可以直接写 rs.,代码可读性大幅提升:
cpp
#include<iostream>
using namespace std;
typedef int STDataType;
typedef struct Stack
{
STDataType* a;
int top;
int capacity;
}ST;
void STInit(ST& rs, int n = 4)
{
rs.a = (STDataType*)malloc(n * sizeof(STDataType));
rs.top = 0;
rs.capacity = n;
}
void STPush(ST& rs, STDataType x)
{
if (rs.top == rs.capacity)
{
int newcapacity = rs.capacity * 2;
STDataType* tmp = (STDataType*)realloc(rs.a, newcapacity * sizeof(STDataType));
if (tmp == NULL)
{
perror("realloc fail");
return;
}
rs.a = tmp;
rs.capacity = newcapacity;
}
rs.a[rs.top] = x;
rs.top++;
}
int& STTop(ST& rs)
{
assert(rs.top > 0);
return rs.a[rs.top];
}
int main()
{
ST st1;
STInit(st1);
STPush(st1, 1);
STPush(st1, 2);
STTop(st1) += 10;
cout << STTop(st1) << endl;
return 0;
}
传引用返回的一个妙用是可以直接修改返回值。上面的 STTop 返回 int&,所以 STTop(st1) += 10 直接修改了栈顶元素。
再看一个更典型的场景:链表插入时需要修改头指针,用C语言需要二级指针,用C++引用可以直接简化为一级指针的引用:
typedef struct ListNode
{
int val;
struct ListNode* next;
}LTNode, *PNode;
// 指针变量也可以取别名,这里LTNode*& phead就是给指针变量取别名
// 这样就不需要用二级指针了,相对而言简化了程序
void ListPushBack(PNode& phead, int x)
{
PNode newnode = (PNode)malloc(sizeof(LTNode));
newnode->val = x;
newnode->next = NULL;
if (phead == NULL)
{
phead = newnode;
}
else
{
PNode tail = phead;
while (tail->next != NULL)
{
tail = tail->next;
}
tail->next = newnode;
}
}
12.4 常引用(const Reference)
这里有一个很容易踩坑的地方:权限放大。当一个 const 对象或临时对象的地址被赋予一个普通引用时,编译器会报错,因为这相当于放大了访问权限。
int main()
{
const int a = 10;
// 编译报错:error C2440: "初始化": 无法从"const int"转换为"int &"
// 这里的引用是对a访问权限的放大
//int& ra = a;
// 这样才可以
const int& ra = a;
// 编译报错:error C3892: "ra": 不能给常量赋值
// ra = 20;
// a*3的结果保存在一个临时对象中,临时对象具有常性
// 所以rb引用的都是临时对象,必须用常引用
const int& rb = a * 3;
double d = 12.34;
// 类型转换中会产生临时对象存储中间值
// int& rd = d; // 编译报错
const int& rd = d; // 正确
return 0;
}
所谓临时对象,就是编译器需要一个空间暂存表达式的求值结果时临时创建的一个未命名的对象。C++中临时对象具有常性,所以只能用 const 引用去绑定它。
12.5 指针和引用的关系
C++中指针和引用就像两个性格迥异的亲兄弟,指针是哥哥,引用是弟弟,在实践中他们相辅相成,功能有重叠性,但是各有自己的特点,互相不可替代。
| 对比维度 | 指针 | 引用 |
|---|---|---|
| 语法概念 | 存储变量地址,要开空间 | 变量取别名,不开空间 |
| 初始化 | 建议初始化,但语法上不是必须的 | 定义时必须初始化 |
| 重新指向 | 可以不断改变指向对象 | 初始化后不能再引用其他实体 |
| 访问方式 | 需要解引用 *p |
直接使用 |
| sizeof | 始终是地址空间大小(32位4字节,64位8字节) | 结果为引用类型的大小 |
| 安全性 | 容易出现空指针和野指针 | 很少出现安全问题,使用更安全 |
| 空值 | 可以为NULL | 不能为NULL(C++11前没有null reference概念) |
13. inline内联函数与宏函数的底层博弈
内联函数是以 inline 修饰的函数,编译时会在调用内联函数的地方进行展开,没有函数调用建立栈帧的开销,提高性能。
#include<iostream>
using namespace std;
inline int Add(int x, int y)
{
int ret = x + y;
ret += 1;
ret += 1;
ret += 1;
return ret;
}
int main()
{
// 可以通过汇编观察程序是否展开
// 有call Add语句就是没有展开,没有就是展开了
int ret = Add(1, 2);
cout << Add(1, 2) * 5 << endl;
return 0;
}
内联函数相比宏函数有几个明显优势:宏函数只是简单的文本替换,不涉及类型检查,容易因为运算符优先级问题产生Bug。而内联函数有完整的类型检查,编译器会做参数求值,行为更可预测。
宏函数的经典坑点:
// 正确的宏实现
#define ADD(a, b) ((a) + (b))
// 为什么要加外面的括号? -> 防止运算符优先级问题
// 为什么要加里面的括号? -> 防止参数是表达式时优先级问题
// 为什么不能加分号? -> 宏只是文本替换,加分号会导致语法错误
int main()
{
int ret = ADD(1, 2);
cout << ADD(1, 2) << endl;
cout << ADD(1, 2)*5 << endl;
int x = 1, y = 2;
ADD(x & y, x | y); // 展开后变成 (x&y+x|y),运算符优先级导致Bug
return 0;
}
内联函数的声明和定义可以分离,但需要注意:声明处和定义处都必须有 inline 关键字,且定义必须对所有调用点可见(通常在头文件中)。
cpp
// F.h
#include <iostream>
using namespace std;
inline void f(int i);
// F.cpp
#include "F.h"
void f(int i)
{
cout << i << endl;
}
// main.cpp
#include "F.h"
int main()
{
f(1);
return 0;
}
14. nullptr与空指针的类型安全
NULL 实际是一个宏,在传统的C头文件(stddef.h)中可以看到:
cpp
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
#endif
C++中 NULL 可能被定义为字面常量 0,或者C中被定义为无类型指针 (void*) 的常量。不论采取何种定义,在使用空值的指针时,都不可避免的会遇到一些麻烦。
比如,本想通过 f(NULL) 调用指针版本的 f(int*) 函数,但是由于 NULL 被定义成 0,调用了 f(int x),与程序初衷相悖。而 f((void*)NULL) 又会报错,因为 (void*) 无法隐式转换为其他指针类型。
C++11 引入了 nullptr,它是一个特殊的关键字,是一种特殊类型的字面量,可以转换成任意其他类型的指针类型。使用 nullptr 定义空指针可以避免类型转换的问题,因为 nullptr 只能被隐式转换为指针类型,不能被转换为整数类型。
cpp
#include<iostream>
using namespace std;
void f(int x)
{
cout << "f(int x)" << endl;
}
void f(int* p)
{
cout << "f(int* p)" << endl;
}
int main()
{
f(NULL); // 调用f(int x),因为NULL被定义为0
// f((void*)NULL); // 编译报错:2个重载中没有一个可以转换所有参数类型
f(nullptr); // 正确调用f(int* p)
return 0;
}
面试时如果问到 nullptr 和 NULL 的区别,可以从类型安全、重载解析、C++11新特性等角度展开,说明 nullptr 的设计初衷就是为了解决 NULL 在重载场景下的二义性问题。