C++语言程序设计精讲——清华大学郑莉教授PPT全解析
简介:《C++语言程序设计》由清华大学郑莉教授编著,以13章系统化PPT形式全面讲解C++编程语言的核心知识体系。课程从C++简介与基本语法入手,逐步深入至面向对象编程、模板、异常处理、STL及多线程等高级特性,内容涵盖变量、函数、类与对象、构造析构、访问控制、运算符重载、动态内存管理等关键知识点。本系列PPT结构清晰、由浅入深,适合初学者入门与进阶开发者巩固基础,帮助学习者掌握现代C++编程技术,培养面向对象设计思维和高效编程实践能力。
C++语言的深度探索:从内存布局到现代编程范式
你有没有遇到过这样的情况——明明只是定义了一个简单的结构体,结果 sizeof() 返回的大小却比所有字段加起来还要大?或者写了个看似无害的函数重载,编译器却报出“调用歧义”错误?这些看似奇怪的行为背后,其实都藏着C++这门语言精妙又复杂的底层机制。今天我们就来揭开这些“黑箱”,一起深入理解这门既强大又容易踩坑的语言。
想象一下你在开发一个高性能网络服务,每秒要处理数万次请求。这时候哪怕每个对象多浪费几个字节、每次调用多一次不必要的拷贝,累积起来都会成为系统瓶颈。而这一切优化的基础,正是对 数据类型如何在内存中排列 的理解。
比如我们常见的 struct :
#include <iostream>
using namespace std;
struct DataLayout {
char c; // 1 byte
int i; // 4 bytes
short s; // 2 bytes
double d; // 8 bytes
};
int main() {
cout << "Size of struct DataLayout: " << sizeof(DataLayout) << " bytes\n";
return 0;
}
直觉上你会觉得这个结构体应该是 1 + 4 + 2 + 8 = 15 字节?但实际运行结果很可能是 24 字节!🤯 这不是编译器搞错了,而是现代CPU为了访问效率引入的“内存对齐”规则在起作用。
简单来说,CPU读取数据时喜欢按“自然边界”对齐。例如:
- int (4字节)最好从地址能被4整除的地方开始;
- double (8字节)则要求地址是8的倍数。
所以当你写下上面那个结构体时,编译器会自动插入“填充字节”来满足这些要求:
| 成员 | 类型 | 大小(字节) | 实际偏移 |
|---|---|---|---|
| c | char | 1 | 0 |
| [padding] | - | 3 | 1~3 |
| i | int | 4 | 4 |
| s | short | 2 | 8 |
| [padding] | - | 6 | 10~15 |
| d | double | 8 | 16 |
总大小变成了 24 字节。你可以通过 alignof(double) 来查看某个类型的对齐需求,在x86-64平台上通常是8。
💡 小贴士:如果你正在做嵌入式通信或网络协议打包,这种额外的填充可能会破坏协议格式。这时可以用
#pragma pack(1)强制关闭对齐,或者使用std::aligned_storage显式控制布局。
更进一步,为了避免跨平台移植时出现意外(比如有些平台 int 是2字节),C++11推荐使用 <cstdint> 中的固定宽度类型:
#include <cstdint>
uint32_t packet_id; // 稳定的32位无符号整数
int64_t timestamp_ns; // 跨平台一致的64位时间戳
这才是写出真正可移植代码的关键!
graph TD
A[基本数据类型] --> B[整型: char, short, int, long]
A --> C[浮点型: float, double, long double]
A --> D[布尔型: bool]
A --> E[字符型: char, wchar_t, char16_t, char32_t]
B --> F[有符号 vs 无符号]
C --> G[IEEE 754 标准表示]
F --> H[决定数值范围与溢出行为]
G --> I[精度与舍入误差问题]
style A fill:#f9f,stroke:#333
style B fill:#bbf,stroke:#333
style C fill:#bbf,stroke:#333
说完了“数据怎么放”,我们再来看看“谁可以改”。这是很多初学者最容易混淆的部分之一: const 到底是谁不能变?
来看这几个声明:
const int* ptr1; // 指向常量的指针(值不能改,指针能改)
int* const ptr2; // 常量指针(指针不能改,值能改)
const int* const ptr3; // 指向常量的常量指针(都不能改)
这里的诀窍是: 从右往左读 !
ptr2是const的 → 指针本身不可变;*ptr1是const int→ 所指向的内容不可变。
这个区分在函数参数设计中极其重要。比如你想传一个大对象进去但不想它被修改,应该这么写:
void processBigData(const BigObject& data); // ✅ 推荐
而不是:
void processBigData(BigObject data); // ❌ 会触发深拷贝,性能爆炸!
为什么这里要用 const T& ?因为它既能避免拷贝开销,又能防止误修改,还能接受临时对象(右值)。要知道,只有 const 引用才能绑定到临时对象上:
double getValue(); // 返回临时 double 对象
const double& ref = getValue(); // 合法!生命周期被延长
// double& bad = getValue(); // 错误!非常量引用不能绑定右值
是不是有点像JavaScript里的“冻结对象”?只不过C++是在编译期就帮你检查清楚了 😎
而且 const 不仅可以用在变量上,还能用在成员函数上,表示“这个方法不会改变对象状态”:
class Counter {
mutable int cache_hits; // 即使在const函数里也能改
int count;
public:
int getValue() const {
cache_hits++; // 允许,因为标记为 mutable
return count;
}
};
这样你就可以放心地对 const Counter obj 调用 obj.getValue() ,不用担心它偷偷改了内部状态。这对多线程编程特别友好,因为你可以在不加锁的情况下安全地读取数据。
接下来聊聊一个看似方便却暗藏杀机的功能: 隐式类型转换 。
比如你写了这样一个类:
class String {
public:
String(const char* str); // 构造函数
};
void printString(const String& s);
printString("Hello"); // 看起来很方便?
确实很方便,但这其实是编译器悄悄帮你做了转换:“const char*” → “String”。但如果另一个函数是:
void expectInt(int x);
expectInt("oops"); // 编译失败?不一定!
如果存在某种方式能把字符串转成整数(比如 String(int) 构造函数),那这行代码可能就会莫名其妙通过编译……然后运行时报错。
为了避免这种“太聪明”的转换,我们应该养成习惯: 所有单参数构造函数都加 explicit :
explicit String(const char* str); // 阻止隐式转换
这样一来, printString("Hello") 就必须显式写成 printString(String("Hello")) 或 printString{} ,虽然多打几个字,但语义清晰多了。
当然,C++也提供了四种显式转换操作符,比C风格的 (T)value 安全得多:
| 转换操作符 | 用途 | 风险等级 |
|---|---|---|
static_cast<T> |
数值转换、上行转型 | ⭐️⭐️⭐️☆☆ |
dynamic_cast<T> |
安全的向下转型(多态) | ⭐️⭐️⭐️⭐️☆ |
const_cast<T> |
移除 const 属性 |
⭐️⭐️⭐️⭐️⭐️ |
reinterpret_cast<T> |
重新解释比特模式 | 💣💣💣💣💣 |
特别是最后两个,除非你在写驱动、序列化或低层库,否则真的要慎用。我见过太多因为滥用 reinterpret_cast 导致段错误的案例了……
讲完基础语法,咱们进入真正的“C++核心战场”: 函数与类的设计哲学 。
先看个经典问题:下面两种传参方式,哪个更好?
void func(ValueType obj); // 值传递
void func(const ValueType& obj); // const引用传递
答案几乎总是后者,尤其是当 ValueType 是像 std::string 、 std::vector 这样的重型对象时。值传递会触发拷贝构造函数,代价可能是 O(n) 时间和额外内存分配;而引用传递只是传递一个指针,成本固定且极低。
Mermaid图示两者差异:
graph TD
A[主函数栈帧] --> B[调用 func(obj)]
B --> C[创建副本 obj']
C --> D[复制内部资源]
D --> E[执行函数体]
E --> F[析构副本 obj']
G[主函数栈帧] --> H[调用 func(const_ref)]
H --> I[绑定引用到原对象]
I --> J[直接访问数据]
J --> K[函数返回,无销毁]
看出区别了吗?引用传递跳过了中间的所有复制和清理步骤,就像快递员直接去你家取件,而不是先把东西寄到公司再转交。
这也引出了C++的一个重要原则: 优先使用引用传递大对象,小对象可以直接传值 。POD(Plain Old Data)类型如 int , double , bool 反而适合值传递,因为它们本来就很轻。
说到函数,还有一个强大的特性叫“默认参数”:
void log(const string& msg, int level = 1, bool with_time = true);
log("Error"); // 等价于 log("Error", 1, true)
log("Warning", 2); // with_time 仍为 true
很实用吧?但要注意两点:
1. 默认参数只能出现在声明中(一般在头文件);
2. 必须从右往左连续设置,不能跳着来。
否则就会出现这种尴尬:
void bad_func(int a = 1, int b); // ❌ 错误!不能只给第一个参数设默认值
相比之下,函数重载虽然写起来麻烦一点,但语义更明确:
void print(int);
void print(double);
void print(const string&);
print(42); // 调用 print(int)
print(3.14); // 调用 print(double)
而且重载支持不同类型之间的区分,不像默认参数只能靠参数数量判断。两者各有用途,合理搭配才是王道。
再来谈谈性能优化神器: inline 函数。
inline int square(int x) { return x * x; }
它的本意是让编译器把函数体“内联展开”,消除函数调用开销。比如:
int result = square(5);
很可能被优化成:
int result = 5 * 5;
听起来很棒,对吧?但别忘了:
- inline 只是一个建议,编译器可以忽略;
- 太长的函数不会被内联(通常限制在10行以内);
- 内联会导致代码膨胀,影响缓存命中率。
所以不要盲目给所有小函数加 inline ,最好配合性能分析工具(如 perf、VTune)验证是否真的提升了速度。
终于来到面向对象的核心: 类与封装 。
一个设计良好的类应该像一个黑盒子——外部只能通过公开接口与之交互,内部实现细节对外隐藏。这就是 private 和 public 的意义所在。
class BankAccount {
private:
string owner;
double balance;
public:
double get_balance() const; // 只读接口
void deposit(double amount); // 修改接口
};
你看,你不允许直接访问 balance ,而是通过 get_balance() 获取。这样未来如果你想加入审计日志、汇率转换等功能,用户代码完全不需要改动!
甚至还可以用 mutable 关键字标记某些“逻辑上不变但物理上可变”的字段:
mutable int access_count; // 统计查询次数,不影响对象“值”
这让 const 方法也能做一些副作用操作,比如缓存更新、调试计数等。
最考验功力的部分来了: 构造函数、析构函数与拷贝控制 。
考虑这个类:
class String {
char* data;
public:
String(const char* str) {
data = new char[strlen(str)+1];
strcpy(data, str);
}
~String() {
delete[] data;
}
};
看起来没问题?但如果有人这么用:
String s1 = "hello";
String s2 = s1; // 发生了什么?
此时会发生 浅拷贝 : s2.data 和 s1.data 指向同一块内存。等到 s1 和 s2 析构时,同一块内存会被 delete[] 两次——未定义行为,程序崩溃!
解决办法就是遵循“三法则”(Rule of Three):如果你需要自定义析构函数,那么大概率也需要自定义拷贝构造函数和赋值操作符:
String(const String& other) {
data = new char[strlen(other.data)+1];
strcpy(data, other.data);
}
String& operator=(const String& other) {
if (this != &other) {
delete[] data;
data = new char[strlen(other.data)+1];
strcpy(data, other.data);
}
return *this;
}
现在每次复制都是“深拷贝”,各自拥有独立内存,安全可靠。
而在C++11之后,还有“五法则”,加入了移动构造和移动赋值,进一步提升性能(比如 std::move() 能把资源“偷走”而不复制)。这部分我们后面还会详细展开。
进入现代C++的世界,不得不提的就是 模板与STL 。
模板让你写一份代码就能适用于多种类型,真正做到“一次编写,处处使用”。
template<typename T>
T max(T a, T b) {
return a > b ? a : b;
}
当你调用 max(3, 5) 或 max(3.14, 2.71) 时,编译器会自动生成对应的 int 和 double 版本。这就是所谓的“泛型编程”。
结合STL容器,威力更是惊人:
| 容器 | 特性 | 使用场景 |
|---|---|---|
vector |
动态数组,尾插高效 | 大多数场景首选 |
list |
双向链表,任意位置插入快 | 频繁中间插入 |
map |
红黑树,有序键值对 | 需要排序查找 |
unordered_map |
哈希表,平均O(1)查找 | 快速查表 |
选择合适的容器,往往比算法优化更能带来性能飞跃。比如你在做一个缓存系统,频繁根据key查找value,那就果断选 unordered_map ;如果还需要按键排序输出,才考虑 map 。
再配上迭代器和算法库,简直是组合拳:
vector<int> nums = {5,2,8,1,9};
sort(nums.begin(), nums.end()); // 排序
auto it = find(nums.begin(), nums.end(), 8); // 查找
transform(nums.begin(), nums.end(), nums.begin(), [](int x){ return x*2; }); // 映射
这一套下来,代码简洁又高效,完全没有手动循环的臃肿感。
最后压轴登场的是 Lambda表达式 ,它让函数式编程风格融入C++:
auto is_even = [](int n) { return n % 2 == 0; };
copy_if(nums.begin(), nums.end(), back_inserter(evens), is_even);
Lambda不仅能捕获外部变量( [=] 值捕获, [&] 引用捕获),还能被编译器高度优化,通常能内联执行,几乎没有额外开销。
再加上 std::function 的包装能力,你可以轻松实现回调、事件处理器、策略模式等高级设计:
function<bool(int)> predicate = [](int x) { return x > 0; };
if (predicate(42)) { /* ... */ }
这才是现代C++应有的模样: 高效、安全、表达力强 。
回顾整篇文章,我们从最基本的内存布局出发,一步步深入到类设计、资源管理、泛型编程,最终抵达现代C++的最佳实践。你会发现,C++的强大之处不仅在于它允许你做很多事情,更在于它提供了足够的工具让你 以最小的代价写出高效的代码 。
但这门语言也有它的代价:学习曲线陡峭、容易犯错、陷阱众多。因此,掌握它的关键是——理解背后的原理,而不是死记语法。
记住这几个核心理念:
- 零成本抽象 :高层语法不带来运行时开销;
- RAII :资源获取即初始化,用对象生命周期管理资源;
- 值语义优先 :尽量使用值和引用,减少裸指针;
- const 正确性 :尽可能标记 const ,增强安全性;
- STL 优先 :别重复造轮子,标准库已经为你优化好了。
当你把这些思想内化于心,你会发现C++不再是那个令人畏惧的“复杂怪物”,而是一个精准、强大、充满美感的工程利器。🚀
毕竟,能让你写出既快又稳的系统级程序的语言,全世界也没几个。而C++,就是其中之一。✨
简介:《C++语言程序设计》由清华大学郑莉教授编著,以13章系统化PPT形式全面讲解C++编程语言的核心知识体系。课程从C++简介与基本语法入手,逐步深入至面向对象编程、模板、异常处理、STL及多线程等高级特性,内容涵盖变量、函数、类与对象、构造析构、访问控制、运算符重载、动态内存管理等关键知识点。本系列PPT结构清晰、由浅入深,适合初学者入门与进阶开发者巩固基础,帮助学习者掌握现代C++编程技术,培养面向对象设计思维和高效编程实践能力。
更多推荐




所有评论(0)