C++——第一篇 基础语法

C++——第一篇 基础语法

——从C语言到现代C++的进化之路

你好,欢迎来到C++的世界。在正式开始之前,我想和你聊聊:为什么我们要花时间学习C++?

你可能会在很多地方看到"C++很难"的说法。确实,C++是一门"重型"语言,它不像Python那样"随写随用",也不像JavaScript那样与浏览器天然结合。但正是这种"重",让它成为了软件工业的基石——操作系统、数据库、游戏引擎、高性能服务器,背后都有C++的身影。

学习C++,本质上是在学习计算机系统的工作原理。它不会替你包办太多事情,但正因如此,它给了你最大的控制权。这种控制权,是成为一名真正的"工程师"而非仅仅是"码农"的关键。

那么,让我们开始这段旅程吧。

注意:本频道的文章默认读者已具备C语言的相关知识。

第一章:理解C++的"第一性原理"

1.1 什么是C++的"第一性原理"?

在开始学习任何一门语言之前,我们都需要问一个根本性的问题:这门语言为什么存在?它要解决什么问题? 这就像你想了解一个人,不能只看他的外表,而要了解他的成长经历、性格形成的原因。

在物理学中,“第一性原理"指的是从最基本的定律出发,不依赖经验参数推导出整个理论体系。在编程语言的世界里,C++也有自己的"第一性原理”——它的设计目标和核心原则。理解这些,你就抓住了C++的灵魂,而不仅仅是记住它的语法规则。

1.2 C++到底要解决什么问题?

想象你在整理一个巨大的仓库。C语言给你的是最基础的货架和标签,你需要亲手摆放每一件货物。当仓库很小时,这没问题,你能掌控一切。但当仓库变成像亚马逊物流中心那样庞大时,你就需要更高效的组织方式:比如引入"分类区"、“自动分拣系统”、“条形码管理”。

C++就是在C语言这套基础工具之上,为你增加了这些高级组织能力。它保留了C语言接近硬件的"手感",让你能精细控制内存(就像亲手搬运货物),同时又引入了面向对象编程这种更高级的思维方式(就像建立分类管理体系)。

更具体地说,C++的设计目标可以概括为四个层面:

目标含义生活类比
更好的C保持C语言的高效和灵活性,同时修复一些C的缺陷保留你原有的工具箱,但换成更耐用的材质
支持数据抽象让你能把数据和操作封装在一起,形成更自然的代码组织把货物和它的搬运说明书放在同一个箱子里
支持面向对象通过类、继承、多态等机制模拟现实世界建立货物分类体系:电子产品、食品、服装各有规则
支持泛型编程写出与类型无关的通用代码设计一种能适用于任何货物的通用搬运流程

所以,学习C++时,你经常会看到两种风格的代码:

  • 面向过程风格:就像C语言那样,一步步告诉计算机"先做什么,再做什么"
  • 面向对象风格:定义"类"和"对象",让代码更贴近现实世界的模型

1.3 C++的设计哲学:三个核心原则

任何伟大的建筑背后都有一套设计理念。C++之父本贾尼·斯特劳斯特卢普(Bjarne Stroustrup)在设计C++时,遵循了一套明确的原则。理解这些原则,能帮你理解为什么C++长成现在这个样子。

原则一:效率优先——不为用不到的东西付出代价

C++最核心的设计哲学之一是零开销原则:你不需要为没用的功能付出代价,你用到的功能,也无法用手工编码做得更好。

这意味着什么?当你用C++写一个简单的循环时,它运行的效率和C语言写的几乎一样快。当你定义了一个类但没用虚函数时,你不会因此承担虚函数表的开销。C++不会偷偷给你塞一些"隐形"的东西。

生活类比:就像去自助餐厅,你只为你拿的食物付费。如果你只拿了一盘沙拉,不需要为别人吃的牛排买单。C++就是这个餐厅,让你精确控制自己消耗的资源。

原则二:渐进式改进——从实际问题出发

C++的每一步演化都不是凭空想象的,而是由实际问题推动的。Bjarne曾明确表示:“C++的发展必须由实际问题推动,不被牵涉到无益的对完美的追求之中”。

这意味着C++始终保持着与现实世界的紧密联系。当程序员们遇到新问题时,C++会逐步增加解决方案,而不是推倒重来。这也是为什么C++能保持向后兼容性——你今天写的代码,20年后依然能运行。

生活类比:这就像城市规划。不是推倒整个城市重建,而是在原有基础上修路、建地铁、改造老城区。虽然会留下一些历史痕迹,但城市始终在运转。

原则三:实用主义——给程序员选择的自由

C++不强制你使用某种编程风格。你可以写纯C风格的代码,也可以全面采用面向对象,还可以用泛型编程,或者混合使用。这种多范式支持给了程序员极大的自由度。

Bjarne说:“C++是一种语言,而不是一个完整的系统”。它不试图包办一切,而是提供工具,让你自己做出最适合项目的决策。

生活类比:就像给你一套多功能工具箱,里面有螺丝刀、扳手、锤子、电钻。你是用螺丝刀还是电钻,取决于你要拧什么样的螺丝。C++相信你的判断力。

1.4 C++的演化观

理解了C++的"第一性原理",你就能明白为什么C++会演化成今天这个样子。它不是某个理论家凭空设计的完美语言,而是在四十多年的实践中,一步步解决实际问题打磨出来的。就像一辆汽车,从最初的内燃机原型,到今天的智能电动汽车,每一次改进都是为了解决当时遇到的具体问题。

从1979年的"C with Classes"(带类的C),到1998年的第一个国际标准,再到C++11、C++14、C++17、C++20,直到C++23,C++一直在演进。但无论怎么变,它的核心设计哲学始终如一:效率、实用、给程序员自由

这给了你一个重要的启示:学习C++时,不必追求一次性掌握所有特性。就像Bjarne自己说的,C++的学习可以分为几个层次,从基础到深入,在实践中逐步成长。重要的是理解它的设计思想,而不是背诵语法规则。

让我们沿着时间线,看看C++是如何一步步走到今天的。这不仅是一段技术史,更是一个关于如何解决实际问题、如何平衡各种需求的生动案例。

1979-1989:萌芽期——从"带类的C"到C++的诞生

1979年,贝尔实验室的本贾尼·斯特劳斯特卢普面临一个实际的问题:他需要分析UNIX内核在分布式计算环境下的表现。当时有两种主流语言:Simula虽然支持面向对象,但运行太慢;BCPL虽然快,但又太低层,不适合大型软件开发。这种"既要又要"的困境,促使他开始思考:能不能在C语言的基础上,加上Simula那样的类机制?

这个想法催生了"C with Classes"(带类的C),并于1979年10月首次实现。它保留了C语言的高效和可移植性,同时增加了类、派生类、public/private访问控制、构造函数、析构函数等特性。你可以把它理解为:给C语言这辆"手动挡汽车"加上了"定速巡航"功能——基础框架没变,但开起来更省心了。

1983年,这门语言正式更名为C++。名字的由来很有趣:Rick Mascitti在1983年中期提出了这个名字,用的是C语言的"++“运算符——它表示递增1,寓意着C++是C语言的"增强版”。就像iPhone 15 Pro相比iPhone 15是增强版,但基础操作逻辑是一样的。

1985年,第一个商业版本Cfront 1.0发布,同年《The C++ Programming Language》第一版出版。1989年Cfront 2.0发布,增加了多重继承、抽象类、静态成员函数、const成员函数等特性。至此,C++的核心骨架基本成型。

这个阶段的关键贡献:证明了"在C语言基础上加面向对象"这条路走得通,为C++的后续发展奠定了根基。

1990-1997:标准化前夜——从理论到实践的打磨

进入90年代,C++开始从贝尔实验室走向更广阔的天地。1990年,ANSI C++委员会(X3J16)成立,同年召开了第一次技术会议。同年,《The Annotated C++ Reference Manual》出版,这本书在ISO标准化之前起到了事实标准的作用。

这一年还发生了两件大事:模板异常处理被正式接纳。模板的加入是一个里程碑——它让C++从此具备了泛型编程的能力。你可以这样理解:如果说类是对数据的抽象,模板就是对"抽象"本身的抽象。它让你能写出与类型无关的通用代码,比如一个sort函数既能排序整数数组,也能排序浮点数数组,甚至能排序自定义类型的数组。

1991年,ISO WG21(C++标准工作组)成立。1993年,运行时类型识别(RTTI)、命名空间被接纳。命名空间的出现,解决了C语言中困扰已久的命名冲突问题——就像给每个函数加上"姓氏"。

1994年,**标准模板库(STL)**被接纳。STL的加入是C++标准库的转折点。它由Alexander Stepanov设计,包含容器(如vector、list)、迭代器、算法(如sort、find)三大组件,以及函数对象等辅助工具。STL的设计思想极为超前:它将数据(容器)和操作(算法)通过迭代器解耦,让不同的组件可以灵活组合。比如sort(v.begin(), v.end())可以对任何支持随机访问的容器排序。

这个阶段的关键贡献:模板、异常、RTTI、命名空间、STL相继加入,C++的特性集基本完备,为第一个国际标准做好了准备。

1998-2003:里程碑——C++98与C++03

1998年,C++98正式成为国际标准(ISO/IEC 14882:1998)。这是C++发展史上的第一个里程碑,它把之前十多年积累的特性统一到一个标准框架下,包括类、继承、多态、模板、异常、命名空间、STL等等。从此,C++有了"官方定义"。

2003年,C++03发布,但它只是一个技术性修订版本(Technical Corrigendum),主要修复C++98中的缺陷,没有增加新特性。所以,大家通常把C++98和C++03统称为C++98/03。

这个阶段的遗憾:C++98/03发布后,标准委员会进入了一个长达8年的"沉寂期"。而这8年,恰恰是Java、C#等现代语言快速崛起的时期。C++因为语法繁琐、标准库薄弱、缺乏现代特性,逐渐显得"老态龙钟"。

2011:涅槃重生——C++11的爆发

C++11最初被命名为"C++0x",寄托了大家希望它在21世纪第一个十年(即"0x")发布的期望。但由于特性设计的复杂性,发布时间一再推迟,直到2011年才正式落地,因此更名为C++11。

这8年的等待没有白费。C++11是C++历史上最重大的一次更新,它引入了约140个新特性,修正了C++03中的约600个缺陷。有人形容:C++11之后的C++,和C++98/03几乎不是同一种语言。

C++11的核心变革

类别核心特性作用
语法简化auto类型推导、范围for循环让代码更简洁,减少重复
智能指针shared_ptr、unique_ptr、weak_ptr自动管理内存,减少内存泄漏
并发支持标准线程库、std::async让C++终于有了跨平台的多线程能力
性能提升右值引用、移动语义、完美转发大幅减少不必要的拷贝,提高效率
现代化特性Lambda表达式、nullptr、静态断言让C++有了现代语言的表达能力
库增强正则表达式、哈希表、随机数库让C++标准库真正"丰富"起来

你可以这样理解C++11的意义:它把C++从一辆"手动挡老爷车"改造成了"自动挡新能源车"。操作更简单了,性能更强了,功能更丰富了,但依然保留了手动模式——如果你愿意,依然可以精细控制每一个细节。

2014-2017:稳步迭代——C++14与C++17

C++11之后,C++进入了每3年一个版本的稳定迭代期。

C++14(2014)是C++11的补充和完善。它没有引入革命性新特性,而是优化了C++11已有的功能:泛型Lambda表达式(auto参数)、返回值类型推导、二进制字面量、数字分隔符等。你可以把它理解为C++11的"服务包"——修复了一些小问题,让日常使用更顺手。

C++17(2017)带来了更多实用的特性:

  • 结构化绑定:让auto [x, y] = getPoint()成为可能,直接从函数返回多个值
  • if constexpr:编译期条件判断,让模板元编程更简单
  • 文件系统库:终于有了跨平台的文件操作标准库
  • 并行算法:标准库算法支持并行执行
  • std::optional、std::variant、std::string_view:新的实用工具类型

这些特性让C++在保持高性能的同时,越来越像一门"现代语言"——写起来更舒服,用起来更安全。

2020-2023:巨变再临——C++20与C++23

C++20(2020)被称为继C++11之后的又一个"大版本"。它引入了四个重量级特性:

特性作用类比
概念(Concepts)对模板参数施加约束,让模板错误信息更友好给模板参数加上"说明书"
协程(Coroutines)支持异步编程的轻量级语法函数可以暂停并恢复执行
模块(Modules)替代头文件机制,加快编译速度告别#include的"复制粘贴"
范围库(Ranges)对STL算法的函数式编程增强用view管道组合算法

概念的加入尤其重要。在C++20之前,如果你用错了模板参数,编译器会吐出一堆"火星文"错误信息。有了概念,你可以明确告诉编译器:“这个模板参数必须支持加法运算”,错误信息也变得清晰易懂。

C++23(2023)是C++的最新标准。它进一步增强了语言和标准库,主要特性包括:

  • 显式对象参数(Deducing this):让成员函数可以更灵活地处理this指针
  • 多维下标运算符:像matrix[1, 2, 3]这样访问多维容器
  • std::print / std::println:基于fmt库的更安全、更高效的输出函数
  • std::mdspan:多维数组视图,对科学计算非常友好
  • std::stacktrace:获取调用堆栈信息,调试更方便

C++演化路线图总结

版本发布年份核心特性意义
C with Classes1979类、派生类、public/private起点,证明了概念可行
C++981998第一个国际标准、STL奠定基础,规范化
C++032003技术修正修复缺陷,无新特性
C++112011auto、智能指针、移动语义、Lambda革命性更新,现代C++起点
C++142014泛型Lambda、返回值推导完善C++11
C++172017结构化绑定、文件系统、if constexpr实用特性增强
C++202020概念、协程、模块、范围又一次重大革新
C++232023deducing this、print、mdspan持续改进

这个演化史给我们的启示

回顾C++这四十多年的发展,你会发现一个有趣的规律:C++从来不追求"完美",而是追求"解决问题"。它的每个特性,都是为了解决程序员在实际工作中遇到的具体问题。

  • 类解决了代码复用和数据封装的问题
  • 模板解决了通用算法、泛型编程的问题
  • STL解决了数据结构和算法分离的问题
  • 智能指针解决了内存管理的问题
  • 移动语义解决了临时对象拷贝效率的问题
  • 协程解决了异步编程的问题

所以,当你学习C++时,不妨带着这样的视角:这个特性是为了解决什么实际问题? 当你理解了问题本身,你对解决方案的理解就会深刻得多。

就像Bjarne在《The Design and Evolution of C++》中说的:“C++不是完美语言的尝试,而是对一系列实际问题的务实回应。” 理解这一点,你就抓住了C++的灵魂。

1.5 你的第一个C++程序

理论铺垫够了,让我们动手写第一个程序。几乎每一门编程语言的第一课都是输出"Hello World"。我们来看看C++怎么写:

#include<iostream>// 第1行:包含头文件intmain(){// 第2行:主函数入口 std::cout <<"Hello, C++ World!"<< std::endl;// 第3行:输出语句return0;// 第4行:程序正常结束}

逐行深度解析

  • 第1行 #include <iostream>:这是一个预处理指令#include的作用就是把<iostream>这个文件的内容"复制粘贴"到这里。iostream是"Input Output Stream"的缩写,它里面定义了标准输入输出的相关功能。你可以把它理解为:你要用打印机,就得先接通电源线——这就是在接通"输入输出"的电源。
  • 第2行 int main():这是程序的入口点。当你运行一个程序,操作系统第一件事就是找到这个叫main的函数,然后从它的第一行代码开始执行。int表示这个函数执行完毕后,会返回一个整数值给操作系统。按照惯例,返回0代表"一切正常",返回非0值代表"出问题了"。
  • **第3行 **std::cout << "Hello, C++ World!" << std::endl;:这行是核心输出语句。我们拆开来看:
    • std:::这是命名空间前缀,下一节会详细讲。你可以先理解为"标准库的"。
    • cout:是"character output"的缩写,代表标准输出流,通常就是你的屏幕
    • <<:这个符号叫流插入运算符。你可以把它想象成一个箭头:箭头指向的方向就是数据流动的方向。这里数据从右边的字符串流向左边屏幕。
    • std::endl:是"end line"的缩写,作用是换行刷新输出缓冲区。简单理解就是:把这句话打印出来,然后光标移到下一行,并且确保内容立即显示。
  • **第4行 **return 0;:向操作系统报告:“程序运行完毕,一切顺利”。

1.6 初识类与对象

通过前面的学习,我们已经知道C++在C语言的基础上增加了面向对象编程的支持。那么,什么是面向对象?它和C语言的过程式编程有什么区别?让我们用一个生活中的例子来理解。

1.6.1 从“做事情”到“描述事物”

在C语言中,我们习惯按照步骤来解决问题:先做什么,再做什么,最后做什么。这种编程方式称为面向过程,它关注的是“怎么做”。比如,要描述一个学生,我们会定义几个变量:char name[20]; int age; double score;,然后写一些函数来处理这些数据,如 printStudentInfoupdateScore 等。数据和操作是分离的。

而在C++中,我们可以将学生这个“事物”本身作为一个整体来描述:一个学生拥有姓名、年龄、成绩等属性,同时还能做一些事情(比如学习、考试)。这种将数据和操作封装在一起的方式,就是面向对象的基本思想。

1.6.2 什么是类?什么是对象?

(Class)就像是一张设计图纸模具。它定义了某类事物应该具有的属性和行为。比如,我们可以设计一个“学生类”,规定每个学生都有姓名、年龄、成绩这些属性(在C++中称为成员变量),以及学习、考试这些行为(在C++中称为成员函数)。

对象则是按照这张图纸制造出来的具体实例。比如,张三是一个学生,李四也是一个学生。张三有具体的姓名“张三”、具体的年龄20、具体的成绩95;李四也有自己的具体信息。张三和李四就是“学生类”的两个对象。

用代码来表示:

// 定义一个“学生”类classStudent{public:// 公共访问权限,后面会详细讲// 成员变量(属性)char name[20];int age;double score;// 成员函数(行为)voidstudy(){ std::cout << name <<" 正在学习..."<< std::endl;}};intmain(){// 创建两个学生对象 Student stu1;// 对象1:张三 Student stu2;// 对象2:李四// 给对象赋值strcpy(stu1.name,"张三"); stu1.age =20; stu1.score =95;strcpy(stu2.name,"李四"); stu2.age =21; stu2.score =88;// 调用对象的行为 stu1.study();// 输出:张三 正在学习... stu2.study();// 输出:李四 正在学习...return0;}
1.6.3 面向对象的三大核心特性

面向对象编程有三大核心特性,它们将在后续章节详细展开:

  • 封装:将数据和操作封装在类内部,隐藏实现细节,只暴露必要的接口。就像手机,你只需要知道怎么用,不需要知道内部电路如何工作。
  • 继承:可以在已有类的基础上定义新类,新类继承原有类的属性和行为,并可以添加自己的特性。就像“学生”继承自“人”,学生拥有人的所有特征,还增加了“学号”、“成绩”等专有属性。
  • 多态:同一个行为在不同对象上可以有不同的表现。比如“动物”类有一个“叫”的行为,猫叫“喵喵”,狗叫“汪汪”,但调用同样的“叫”函数,会得到不同的结果。
1.6.4 构造函数和析构函数(简单介绍)

在 1.6.2 节中,我们定义了一个 Student 类,并在创建对象后手动给成员变量赋值。但 C++ 提供了一种更优雅的初始化方式——构造函数。同时,当对象生命周期结束时,还需要做一些清理工作,这就是析构函数的任务。


构造函数:对象的“出生证”

构造函数(Constructor)是一个特殊的成员函数,它会在对象创建时自动调用。构造函数的主要作用是初始化对象的成员变量(比如给姓名、年龄赋初值),或者申请必要的资源(比如动态内存)。

构造函数的特点:

  • 函数名与类名完全相同(例如 Student)。
  • 没有返回值(连 void 都不写)。
  • 可以重载(可以有多个参数列表不同的构造函数)。
  • 如果程序员没有定义任何构造函数,编译器会自动生成一个默认构造函数(无参数,什么都不做,成员变量保持未初始化状态)。

析构函数:对象的“身后事”

析构函数(Destructor)也是一个特殊的成员函数,它在对象销毁时自动调用(例如对象离开作用域,或被 delete 释放)。析构函数的主要作用是释放对象占用的资源(比如释放动态内存、关闭文件等),防止资源泄漏。

析构函数的特点:

  • 函数名是 ~ 后跟类名(例如 ~Student())。
  • 没有返回值不能有参数,因此不能重载,一个类只有一个析构函数。
  • 如果程序员没有定义,编译器会自动生成一个默认析构函数(什么都不做)。

代码示例

为之前的 Student 类添加构造函数和析构函数,观察它们的调用时机。

#include<iostream>#include<cstring>// for strcpyusingnamespace std;classStudent{public:// 成员变量char name[20];int age;double score;// 构造函数:创建对象时自动调用Student(constchar* n,int a,double s){strcpy(name, n); age = a; score = s; cout <<"构造函数被调用,创建学生: "<< name << endl;}// 析构函数:对象销毁时自动调用~Student(){ cout <<"析构函数被调用,销毁学生: "<< name << endl;}// 成员函数voidstudy(){ cout << name <<" 正在学习..."<< endl;}};intmain(){{ Student stu1("张三",20,95);// 自动调用构造函数 stu1.study();}// 离开花括号,stu1被销毁,自动调用析构函数 cout <<"--------------------"<< endl;// 动态创建对象 Student* p =newStudent("李四",21,88); p->study();delete p;// 手动释放,会调用析构函数return0;}

运行结果

构造函数被调用,创建学生: 张三 张三 正在学习... 析构函数被调用,销毁学生: 张三 -------------------- 构造函数被调用,创建学生: 李四 李四 正在学习... 析构函数被调用,销毁学生: 李四 

可以看到,构造函数和析构函数在对象的“一生”中自动被调用,确保了初始化和清理的时机恰到好处。这就是 C++ 管理对象生命周期的核心机制。在后续的学习中,你会更深入地理解它们的强大之处。

第二章:解决冲突的命名空间

2.1 为什么需要命名空间?

想象这样一个场景:你叫“张伟”,这是一个在中国非常常见的名字。你在公司里工作,同事喊一声“张伟”,你知道是在叫你,因为在这个小圈子里,只有一个张伟。但如果你去参加一个大型聚会,现场有好几个张伟,有人喊一声“张伟”,可能好几个都会回头,大家面面相觑,不知道到底在叫谁。

这就是命名冲突。在编程世界里,这个问题同样存在,而且更加棘手。当你编写一个稍微大型的程序时,通常会用到很多第三方库,或者多人协作开发。假设你引入了一个数学计算库,里面定义了一个max函数;又引入了一个图像处理库,里面也定义了一个max函数。当你调用max时,编译器就懵了:你到底想用哪个max

在C语言中,这个问题没有很好的解决方案。唯一的办法就是修改函数名,比如叫math_maximage_max,但这需要你手动干预,而且随着项目变大,这种冲突会层出不穷。

C++的解决方案是引入命名空间。命名空间就像给每个“张伟”加上了前缀——“数学库的张伟”、“图像库的张伟”。通过前缀,我们能清楚地知道指的是谁。在代码中,这个前缀就是命名空间::,比如Math::maxImage::max,编译器就能准确区分了。

2.2 命名空间的定义与基本用法

2.2.1 定义命名空间

使用关键字namespace后跟名字,再跟一对花括号,就可以定义一个命名空间。花括号内的所有内容(变量、函数、类、甚至另一个命名空间)都属于这个命名空间。

namespace Math {intadd(int a,int b){return a + b;}constdouble PI =3.14159;}

命名空间可以在全局作用域中定义,也可以嵌套定义(后面会讲)。它的作用就是创建一个新的作用域,将内部的名字“隐藏”起来,避免与外界冲突。

2.2.2 使用命名空间的三种方式

方式一:通过作用域解析运算符 :: 显式指定

这是最精确的方式,也是最安全的方式。

int sum =Math::add(5,3);// 明确告诉编译器用Math里的adddouble area = Math::PI * r * r;

这种方式没有歧义,但每次都要写前缀,略显繁琐。

方式二:使用 using 声明引入特定成员

如果你频繁使用某个命名空间中的特定成员,可以用 using 声明把它“拉”到当前作用域,之后就可以直接使用。

using Math::PI;// 只引入PIusing Math::add;// 只引入addintmain(){double area = PI *5*5;// 直接使用PIint result =add(10,20);// 直接使用addreturn0;}

using 声明的作用范围是从声明点开始,到当前作用域结束。它只引入指定的名字,不会把整个命名空间都带进来,因此比较安全。

方式三:使用 using namespace 指令引入整个命名空间

如果某个命名空间里的成员你用得特别多,可以用 using namespace 打开整个“房间门”,之后就可以直接使用该命名空间内的所有名字。

usingnamespace Math;// 引入Math里所有成员intmain(){double area = PI *5*5;// 直接用PIint result =add(10,20);// 直接用addreturn0;}

这种方式最方便,但也最危险,因为它可能引入大量名字,导致命名空间污染(稍后会详细讲)。

2.2.3 命名空间的重要特性

特性一:命名空间可以嵌套

命名空间内部还可以定义命名空间,形成层级结构。这在组织大型项目时非常有用,比如按模块划分。

namespace Company {namespace HR {voidhire(){/* ... */}}namespace Finance {voidpay(){/* ... */}}}intmain(){ Company::HR::hire();// 必须逐级指定return0;}

为了简化,可以使用命名空间别名

namespace HR = Company::HR;// 给Company::HR起个别名HRHR::hire();// 直接使用别名

特性二:命名空间是开放的

你可以在多个地方定义同一个命名空间,它们会自动合并。这个特性非常有用,允许你将一个命名空间的成员分散到多个文件或代码块中。

// math_utils.hnamespace MyLib {intadd(int a,int b);}// string_utils.hnamespace MyLib {voidprint(constchar* s);}// main.cpp#include"math_utils.h"#include"string_utils.h"// 此时MyLib同时包含add和print

注意:合并的是同一个命名空间,而不是覆盖。如果你在两个地方定义了同名的函数(参数相同),就会违反单一定义规则(ODR),导致链接错误。

特性三:可以定义匿名命名空间

如果你定义一个没有名字的命名空间,那么它里面的所有成员只在当前文件可见(类似C语言中的static全局变量或函数)。这是C++推荐的替代static的方式。

namespace{int internalVar =42;// 只在当前文件可见voidhelper(){/* ... */}// 只在当前文件可见}

匿名命名空间的成员具有内部链接,不会被其他文件看到,可以有效避免全局命名冲突。

特性四:可以定义内联命名空间(C++11)

内联命名空间是一种特殊的命名空间,它允许外层作用域直接访问其成员,就像它们在外层一样。常用于库的版本管理。

namespace MyLib {inlinenamespace v2 {voidfunc(){ std::cout <<"v2"<< std::endl;}}namespace v1 {voidfunc(){ std::cout <<"v1"<< std::endl;}}}intmain(){MyLib::func();// 调用v2版本,因为v2是内联的 MyLib::v1::func();// 仍然可以显式调用v1}

内联命名空间让库开发者可以升级接口而不破坏现有代码(比如在内联的v2命名空间内定义接口函数而函数的实现借助v1命名空间内的函数,用户在使用时会默认使用内联的v2命名空间,实现了接口的升级而不破坏现有代码)。

2.3 using namespace 的风险:命名空间污染

using namespace 虽然方便,但可能引入意想不到的冲突。我们称这种现象为命名空间污染

假设你在代码中写:

usingnamespace std;usingnamespace MyLib;

如果stdMyLib里都有count这个函数,当你调用count时,编译器就不知道用哪个,导致二义性错误。更糟糕的是,这种二义性可能只有在特定调用时才暴露,很难提前发现。

另一个常见陷阱:在头文件中使用using namespace。因为头文件会被多个源文件包含,相当于在每个包含它的文件中都引入了整个命名空间,这会把命名空间污染扩散到整个项目,导致难以调试的冲突。

最佳实践

  • 绝对不要在头文件中使用using namespace
  • 在源文件中,如果必须使用,尽量限制在小范围内(比如函数内部)。
  • 优先使用using声明(using std::cout;)而不是using namespace

2.4 命名空间的高级应用

2.4.1 命名空间别名

当命名空间层级很深或名字很长时,可以用别名简化。

namespace fs = std::filesystem;// C++17起 fs::path p ="/home/user";

这大大提升了代码的可读性。

2.4.2 与类/函数重载的关系

命名空间不影响函数重载的规则。同一个命名空间内的同名函数可以重载,不同命名空间的同名函数则属于不同的作用域,不会构成重载,只是独立存在(后续会讲有关函数重载的知识,先记住不同命名空间的同名函数不会构成重载)。

2.4.3 使用命名空间组织项目

在一个大型项目中,命名空间的层次设计至关重要。通常的做法是:

  • 顶层命名空间用公司名或项目名。
  • 第二层用模块名(如DatabaseNetworkUI)。
  • 第三层可以用子模块或工具集。

例如:

namespace Google {namespace Protobuf {// ...}namespace GLog {// ...}}

这样的设计既清晰,又能有效避免与其他库的冲突。

2.5 标准命名空间 std 的使用建议

std 是C++标准库的命名空间,所有标准库组件(coutvectorstring等)都在其中。关于std的使用,有几个建议:

  1. 在小型练习或教学代码中,可以使用 using namespace std; 以简化书写。
  2. 在真实项目或大型代码中,尽量避免全局 using namespace std;,而是采用 using std::cout; 等声明,或者直接写 std::cout
  3. 在头文件中绝对不要 using namespace std;,以免影响所有包含该头文件的用户。

2.6 常见陷阱与误区

陷阱一:命名空间内的函数定义与实现分离

在头文件中声明函数,在源文件中定义时,必须确保函数定义在正确的命名空间中。

// mylib.hnamespace MyLib {voidfunc();}// mylib.cpp#include"mylib.h"voidMyLib::func(){// 必须用MyLib::限定// ...}

如果忘记加MyLib::,就会定义一个全局函数,而不是MyLib中的函数。

陷阱二:命名空间与实参依赖查找(ADL)

C++有一种特殊的查找规则,称为依赖于参数的查找(ADL)。当调用一个函数时,编译器不仅会查找当前作用域,还会在函数参数所属的命名空间中查找。这有时会导致意想不到的调用。

namespace N {classA{};voidfunc(A){}}intmain(){ N::A obj;func(obj);// 可以调用N::func,因为obj这个实参的类型N::A所在的命名空间N被加入查找return0;}

ADL在编写运算符重载和泛型代码时非常有用,但有时也会带来歧义。了解ADL能帮你理解一些奇怪的重载行为。


第三章:变量的“别名”——引用

3.1 引用的基本概念

引用就是一个已存在变量的别名。你可以理解成:一个人有正式名字,也有小名,但都是指同一个人。

// 03_reference_basic.cpp#include<iostream>intmain(){int original =100;// 原始变量int& alias = original;// alias 是 original 的引用(别名) std::cout <<"original = "<< original << std::endl;// 输出 100 std::cout <<"alias = "<< alias << std::endl;// 输出 100 alias =200;// 通过引用修改值 std::cout <<"original = "<< original << std::endl;// 输出 200 std::cout <<"alias = "<< alias << std::endl;// 输出 200// 验证地址相同 std::cout <<"&original = "<<&original << std::endl; std::cout <<"&alias = "<<&alias << std::endl;// 输出相同地址return0;}

🌟****引用的核心特性

  1. 必须初始化:定义引用时,必须告诉它绑定到哪个变量。没有“空引用”!
  2. 从一而终:一旦绑定,就不能再改为绑定到其他变量。
  3. 共享内存:引用和被引用的变量指向同一块内存空间。

3.2 引用的本质:从汇编层面揭开神秘面纱

很多初学者会问:引用到底占不占内存?它和指针到底是什么关系?让我们通过汇编代码来揭开引用的本质。

3.2.1 汇编视角看引用

考虑下面这段简单的代码:

voidadd(int a,int b,int& c){ c = a + b;}intmain(){int a =1;int b =2;int c =0;add(a, b, c);return0;}

在VS2012的debug模式下,查看其汇编代码:

; main函数中调用add的代码 ; 获取c的地址并压栈 lea eax, DWORD PTR _c$[ebp] ; 将c的地址加载到eax寄存器 push eax ; 将c的地址压栈 mov ecx, DWORD PTR _b$[ebp] ; 将b的值加载到ecx push ecx ; 将b的值压栈 mov edx, DWORD PTR _a$[ebp] ; 将a的值加载到edx push edx ; 将a的值压栈 call ?add@@YAXHHAAH@Z ; 调用add函数 ; add函数内部的代码 mov eax, DWORD PTR _a$[ebp] ; 取a的值 add eax, DWORD PTR _b$[ebp] ; 加上b的值 mov ecx, DWORD PTR _c$[ebp] ; 取c的地址(注意:这里取的是地址!) mov DWORD PTR [ecx], eax ; 将结果写入c的地址指向的内存 

关键:对于参数c(引用类型),编译器传递的是变量的地址,而不是变量的值。这和指针的传参方式完全一致。

3.2.2 指针常量的对比

再看一下使用指针常量实现相同功能的代码:

voidadd(int a,int b,int*const c){*c = a + b;}

其汇编代码与引用版本完全一致。这揭示了一个重要事实:引用的底层实现就是指针常量

3.2.3 引用的底层本质

通过汇编分析,我们可以得出以下结论:

  1. 引用是一个变量:在底层,引用确实占用内存空间(通常为4或8字节,取决于系统位数),存放的是被引用对象的地址。
  2. 引用等价于指针常量:引用在功能上等同于Type* const,即一个指向特定类型的常量指针——指针本身的值(即指向的地址)不可改变。
  3. 语法糖的体现:C++在语法层面隐藏了引用的指针本质,让我们可以像操作普通变量一样使用引用(自动解引用),而不需要显式的*操作。
3.2.4 引用与指针常量的微妙差异

虽然引用底层是用指针常量实现的,但在语法层面,它们有着重要的区别:

特性引用指针常量 (Type* const)
自身地址是否可见&ref 返回被引用对象的地址,引用自身的地址对程序员不可见&p 返回指针变量本身的地址
访问被引用对象直接使用 ref需要使用 *p
重新绑定语法上不支持语法上支持(尽管是常量指针,但可通过强制手段修改)
数组元素不能定义“引用的数组”可以定义“指针常量的数组”

3.3 数组的引用

上一节我们详细讨论了为什么不能定义“引用的数组”,但C++允许另一种与数组相关的引用——指向数组的引用,简称数组的引用。这个概念非常有用,尤其是在需要将数组作为整体传递而不丢失其大小信息的场景中。

3.3.1 数组的引用的语法

数组的引用定义语法如下:

类型 (&引用名)[数组大小]= 数组名;

例如:

int arr[5]={1,2,3,4,5};int(&ref)[5]= arr;// ref 是 arr 的引用,类型为 int(&)[5]

这里 ref 是一个引用,它引用了整个数组 arr。注意括号 (&ref) 是必须的,因为下标运算符 [] 的优先级高于 &,如果不加括号,int &ref[5] 会被解析为“引用的数组”,而这不被允许(详见3.3.7节)。

数组的引用具有以下重要性质:

  1. 保留数组大小ref 知道它引用的数组有5个元素,sizeof(ref) 返回整个数组的大小(即 5 * sizeof(int)),而不是指针的大小。这与数组本身的行为一致。
  2. 不会退化为指针:当数组作为参数传递给函数时,通常会退化为指向首元素的指针,丢失大小信息。但数组的引用作为参数时,不会发生退化,数组大小被保留。
  3. 可以通过引用修改原数组元素:就像任何引用一样,可以通过数组的引用修改原数组的内容。
3.3.2 数组的引用作为函数参数

数组的引用最常见的用途是在函数参数中,让函数能够接收固定大小的数组,并保留大小信息。

#include<iostream>// 接收数组的引用,保留大小信息voidprintArray(int(&arr)[5]){// 必须指定大小for(int i =0; i <5;++i){ std::cout << arr[i]<<" ";} std::cout << std::endl;}intmain(){int myArray[5]={10,20,30,40,50};printArray(myArray);// 正确,可以传递// int otherArray[10] = {1,2,3,4,5,6,7,8,9,10};// printArray(otherArray); // 错误!参数类型不匹配,需要 int(&)[5],但传入的是 int(&)[10]return0;}

这个例子中,printArray 的参数被声明为 int (&arr)[5],这意味着它只能接受大小为5的int数组。这既是优点也是限制:优点是编译器可以确保传入的数组大小正确,避免了越界风险;缺点是函数只能处理特定大小的数组,缺乏灵活性。

为了处理任意大小的数组,我们可以使用函数模板:

template<size_t N>voidprintArray(int(&arr)[N]){for(size_t i =0; i < N;++i){ std::cout << arr[i]<<" ";} std::cout << std::endl;}intmain(){int arr5[5]={1,2,3,4,5};int arr10[10]={1,2,3,4,5,6,7,8,9,10};printArray(arr5);// N 推导为 5printArray(arr10);// N 推导为 10return0;}

通过模板,数组大小 N 被自动推导,实现了对任意大小数组的通用处理,同时保留了大小信息。

3.3.4 数组的引用与指针的数组、数组指针的区别

很多初学者容易混淆这几个概念,这里用一个表格清晰区分:

概念语法含义示例
指针的数组int* arr[5]数组,元素是指针arr[0] 是一个 int*
数组指针int (*ptr)[5]指针,指向一个含有5个int的数组ptr 是一个指针,指向 int[5] 类型的数组
数组的引用int (&ref)[5]引用,引用一个含有5个int的数组refint[5] 的别名

重要区别

  • 指针的数组:它是一个数组,里面存放的是指针。例如 int* p[3] 可以存储三个 int* 指针。
  • 数组指针:它是一个指针,指向一个完整的数组。例如 int (*p)[5] 可以指向任何包含5个int的数组。当对 p 进行 p+1 操作时,它会跳过整个数组(即5个int)。
  • 数组的引用:它是一个引用,绑定到一个完整的数组。它和数组指针类似,但语法上更像一个别名,并且不能重新绑定。

3.4 为什么没有引用数组

3.4.1 为什么引用不是对象?

在C++中,“对象”是一个具有特定含义的术语:它占用内存空间,有自己的地址,可以执行拷贝、移动等操作。变量、数组元素、类实例等都是对象。

引用在语法层面上被设计为“别名”,它不独立占用存储空间(尽管底层实现通常用指针,但对程序员不可见)。引用本身没有地址(&ref返回的是被引用对象的地址),不能进行指针运算,不能通过sizeof获取其自身大小(sizeof(ref)返回被引用对象的大小)。这些特性都表明:引用不符合对象的定义

C++标准的规定

C++标准明确要求:数组的元素必须是对象类型(object type)。引用类型不属于对象类型,因此不能作为数组的元素。这是类型系统的基本规则。

3.4.2 如果允许“引用的数组”,会引发什么问题?

假设C++允许这样写:

int a =1, b =2, c =3;int& arr[3]={a, b, c};// 假设合法

那么会面临以下难题:

  1. 初始化问题

数组的初始化必须为每个元素提供初始值,这似乎可以通过列表初始化解决。但数组还有默认初始化的场景,例如:

int& arr[5];// 如果不提供初始化列表,每个引用应该绑定到什么对象?

引用没有“空引用”,必须绑定到一个有效对象。这导致数组无法被默认构造。

  1. 数组的底层内存布局

数组要求元素在内存中连续排列,且每个元素的大小相同。但引用在语法上“没有大小”,如果编译器用指针实现引用,那么每个引用元素实际占用的空间就是指针的大小。这会导致一个悖论:

- 如果引用不占空间,数组如何连续存放“不存在”的东西? - 如果引用占空间(如用指针实现),那么它又变成了“对象”,与引用的语法语义冲突。 

C++的设计哲学是:不为不用的特性付出代价。如果允许引用的数组,编译器就必须为引用元素分配存储空间(如同指针),但这会破坏“引用是别名”的抽象。与其引入这种混乱,不如直接禁止。

  1. 指针运算的失效

数组支持指针运算,例如 arr + 1 应该指向下一个元素。如果元素是引用,那么 arr + 1 是什么含义?引用没有地址,无法进行这样的运算。即使底层用指针实现,这个“指针”指向的是引用变量本身,还是被引用的对象?这会造成语义混乱。

  1. 与模板和泛型代码的兼容性

标准库中的容器(如 std::vector)要求元素类型是可拷贝、可赋值的。引用不满足这些要求,因为它们无法被重新赋值(只能初始化一次)。如果允许引用的数组,那么 std::vector<int&> 也会变得可能,但这会引发一系列问题(例如,如何调整大小?如何移动元素?)。

3.5 引用作为函数参数

引用最常见的用途是作为函数参数,实现“传址”效果,但语法比指针简洁得多。

// 03_reference_param.cpp#include<iostream>// 方式1:值传递(不会改变实参)voidswapByValue(int x,int y){int temp = x; x = y; y = temp;// x和y是副本,修改不影响实参}// 方式2:指针传递(C风格)voidswapByPointer(int* x,int* y){if(x ==nullptr|| y ==nullptr)return;// 需要判空int temp =*x;*x =*y;*y = temp;}// 方式3:引用传递(C++风格)voidswapByReference(int& x,int& y){// x和y是实参的别名int temp = x;// 直接使用x,无需解引用 x = y; y = temp;// 无需判空,因为引用不能为空}intmain(){int a =5, b =10; std::cout <<"初始: a="<< a <<", b="<< b << std::endl;swapByValue(a, b); std::cout <<"值传递后: a="<< a <<", b="<< b << std::endl;// 没变swapByPointer(&a,&b); std::cout <<"指针传递后: a="<< a <<", b="<< b << std::endl;// 变了swapByReference(a, b); std::cout <<"引用传递后: a="<< a <<", b="<< b << std::endl;// 又变回return0;}

为什么引用更安全?

指针可能为nullptr,使用前必须判空;而引用必须绑定到一个有效对象,不存在“空引用”。因此引用在使用上更安全,代码也更简洁。

引用的“维度”概念

在函数参数中,引用有一个非常强大的特性:要修改N维指针的指向,只需要传入N维指针的引用即可,而不需要像指针那样升维。

// 如果需要修改指针本身的指向(不仅仅是修改指针指向的内容)voidmodifyPointer(int*& ptrRef,int* newPtr){ ptrRef = newPtr;// 直接修改指针的指向}intmain(){int x =10, y =20;int* p =&x; cout <<"*p = "<<*p << endl;// 输出 10modifyPointer(p,&y);// 传入指针的引用 cout <<"*p = "<<*p << endl;// 输出 20(p现在指向y)return0;}

如果使用指针来实现同样的功能,需要传入指针的指针(二级指针):

voidmodifyPointerWithPtr(int** pp,int* newPtr){*pp = newPtr;// 通过二级指针修改一级指针的指向}

这就是引用的优势:它让代码保持在最自然的维度上

3.6 引用作为函数返回值

函数返回引用,意味着返回的是变量本身,而不是它的副本。这可以用于实现链式调用或返回容器元素的访问权。

// 03_reference_return.cpp#include<iostream>int scores[]={85,92,78,90,88};// 返回数组元素的引用int&getScore(int index){return scores[index];// 返回的是scores[index]的别名}intmain(){ std::cout <<"修改前 scores[2] = "<< scores[2]<< std::endl;// 78getScore(2)=100;// 函数返回引用,可以直接赋值! std::cout <<"修改后 scores[2] = "<< scores[2]<< std::endl;// 100// 链式赋值getScore(0)=getScore(1)=getScore(2)=95;for(int i =0; i <5;++i){ std::cout << scores[i]<<" ";} std::cout << std::endl;return0;}

⚠️** 致命警告:永远不要返回局部变量的引用**

int&badFunction(){int localVar =10;return localVar;// 致命的错误!}// localVar在这里被销毁,返回的引用指向了已回收的内存

局部变量在函数结束时会被销毁,返回的引用就成了“悬空引用”,后续使用会导致未定义行为(程序可能崩溃、输出乱码、看似正常但随时出问题)。

3.7 const引用

const引用是C++中一个非常特殊且实用的存在。它不仅具有普通引用的特性(作为别名),还拥有一个独特的超能力:可以绑定到字面常量和临时对象上。这一特性让const引用在函数参数传递、返回值处理等场景中大放异彩。

3.7.1 const引用的基本用法
// 03_const_ref.cpp#include<iostream>intmain(){int a =10;// 普通引用// int& r1 = 20; // 错误!不能将非常量引用绑定到字面量// const引用constint& r2 =20;// 正确!const引用可以绑定到字面量constint& r3 = a +5;// 正确!可以绑定到临时对象 std::cout <<"r2 = "<< r2 << std::endl;// 输出20// r2 = 30; // 错误!const引用不能修改return0;}

为什么普通引用不行?
普通引用承诺可以通过它修改被引用的对象。如果允许将普通引用绑定到字面量 20,那么就可以通过引用修改字面量的值,而字面量本身是不可修改的,这会造成矛盾。因此C++禁止这种用法。

为什么const引用可以?
const引用承诺不会修改所绑定的对象,因此绑定到不可修改的字面量或临时对象是安全的。编译器会在背后做必要的“兼容”工作,让这种绑定成为可能。

3.7.2 临时变量的创建

当编译器看到 const int& r = 20; 这样的语句时,它并不会直接让引用指向一个没有地址的字面量,而是会执行一个隐式的转换:

  1. 创建一个临时变量:编译器会生成一个未命名的临时变量(比如叫 __temp),类型与字面量兼容(这里就是 int),并将字面量的值拷贝到这个临时变量中。
  2. 将const引用绑定到这个临时变量:引用 r 实际上绑定的是这个临时变量,而不是直接绑定字面量。

从程序员的角度看,就好像引用直接绑定到了字面量,但实际上中间有一个“隐身”的临时变量。这个临时变量拥有自己的内存地址,因此引用可以安全地指向它。整个过程对程序员透明,但它确保了引用的语义一致性。

代码层面的模拟(编译器实际做的事):

int __temp =20;// 创建临时变量constint& r = __temp;// 将const引用绑定到临时变量

同样地,对于表达式 a + 5,它会产生一个临时结果,编译器也会创建临时变量来持有这个结果,然后让const引用绑定到它。

3.7.3 生命周期的延长

当 const 引用绑定到一个临时对象时,临时对象的生命周期会被延长,变得与 const 引用的生命周期一样长。这个机制确保了引用不会指向一个已经被销毁的对象,从而避免了悬垂引用(dangling reference)的风险。

#include<iostream>intgetValue(){return42;// 返回一个临时 int 对象(值 42)}intmain(){constint& ref =getValue();// 临时 int 的生命周期被延长 std::cout << ref << std::endl;// 正确!临时对象仍然存在,输出 42return0;}// 临时对象在此之后才被销毁

执行流程

  1. getValue() 返回一个临时的 int 对象(值为 42)。这个临时对象通常是在函数返回时生成的,它没有名字,但确实存在于内存中(可能是寄存器或栈上的临时空间)。
  2. const int& ref = getValue();ref 绑定到这个临时对象。
  3. 按照 C++ 的常规规则,临时对象在包含它的完整表达式结束时就会被销毁。在这个例子中,完整表达式是 const int& ref = getValue();,按理说临时对象在 ref 初始化完成后就应该被销毁。
  4. 但是,C++ 标准规定:当一个临时对象被直接绑定到一个 const 引用时,它的生命周期会被延长,与这个引用的生命周期相同。
  5. 因此,ref 在整个 main 函数中都可以安全使用,直到 main 函数结束时临时对象才被销毁。所以 std::cout << ref; 能够正确输出 42。

为什么需要这种机制?

如果临时对象的生命周期没有被延长,那么 ref 将成为一个悬垂引用——指向一块已经被回收的内存,后续使用 ref 会导致未定义行为。C++ 通过生命周期延长机制,让 const 引用能够安全地持有临时对象,这在函数返回值和临时结果传递中非常实用。


生命周期延长的适用范围

  • 适用类型:这种机制适用于任何类型的临时对象,包括内置类型(如 intdouble)、数组、指针,以及用户自定义类型(尽管我们还未讲到类和对象,但规则是一样的)。
  • 触发条件:生命周期延长只发生在直接绑定时,即引用初始化时直接绑定到一个临时对象。例如:
constint& r =10;// 直接绑定到字面量(临时对象),生命周期延长constdouble& d =1.23;// 直接绑定到临时对象,生命周期延长
  • 不触发的情况:如果临时对象是通过函数参数传递等间接方式绑定的,则不会延长生命周期。例如:
voidfunc(constint& x){// x 绑定到传入的临时对象,但临时对象在 func 调用结束后就销毁}intmain(){func(10);// 临时对象在 func 返回后销毁,不会延长到 main 中return0;}

这里,临时对象 10func(10) 调用期间存活,函数返回后即被销毁,不会因为 func 内部有 const 引用而延长到 main 函数。


一个容易混淆的示例

考虑以下代码:

intmain(){constint& r1 =100;// 正确:生命周期延长constint& r2 = r1;// r2 绑定到 r1 所引用的对象,但 r1 所引用的临时对象已经延长了生命周期,所以仍然安全 std::cout << r2 << std::endl;// 输出 100return0;}

这里 r2 绑定到 r1 所引用的对象,而不是直接绑定到临时对象,但由于 r1 已经延长了临时对象的生命周期,所以 r2 也能安全使用。这并不违反“直接绑定”的规则,因为延长的是底层对象的生命周期,而不是引用本身。

3.7.4 const引用作为函数参数的优势

const引用最强大的应用之一就是作为函数参数。它既能避免不必要的拷贝(提高效率),又能接受各种类型的实参(灵活性高)。

对比示例

#include<iostream>#include<string>/*std::string 是 C++ 标准库提供的字符串类型,定义在 <string> 头文件中。 它比 C 语言的字符数组更安全、更方便,能自动管理内存,支持各种常用操作。*/// 值传递:发生拷贝,效率低voidprintByValue(std::string s){ std::cout << s << std::endl;}// const引用传递:不拷贝,效率高,且能接受各种实参voidprintByConstRef(const std::string& s){ std::cout << s << std::endl;}intmain(){ std::string name ="Alice";printByValue(name);// 拷贝一次printByConstRef(name);// 不拷贝printByValue("Bob");// 先创建临时string,再拷贝(两次构造)printByConstRef("Bob");// 直接绑定到临时string,只构造一次return0;}

为什么const引用能接受字面量?
当调用 printByConstRef("Bob") 时,字面量 "Bob" 被用来构造一个临时的 std::string 对象,然后 const 引用绑定到这个临时对象。整个过程只发生一次临时对象的构造,没有额外拷贝。这就是const引用被称为“万能引用”的原因。

总结const引用作为参数的优点

  • 高效:对于大对象,避免了拷贝开销。
  • 灵活:可以接受左值、右值、字面量等各种形式的实参(后续会讲解何为左值、右值,现在记住即可)。
  • 安全:const修饰保证了函数内部不会意外修改实参。
3.7.5 注意事项与常见陷阱

尽管const引用非常强大,但在使用时也有一些需要留意的陷阱。

陷阱一:通过const引用绑定的临时对象的生命周期延长不是无限的

只有在直接绑定时才会延长生命周期。例如:

constint& r =10;// 直接绑定,生命周期延长voidfunc(constint& x){// x 绑定到临时对象,但临时对象在函数调用结束后就销毁}intmain(){func(10);// 临时对象在 func 结束后销毁,不会延长到 main 中}

陷阱二:不要返回局部变量的const引用

无论是不是const,返回局部变量的引用都是错误的:

constint&badFunc(){int local =5;return local;// 错误!local在函数结束时销毁,返回悬垂引用}

陷阱三:const引用绑定到临时对象后,通过其他方式修改原始对象可能无效

如果const引用绑定到一个临时对象,该临时对象是原始值的一个副本,对原始值的修改不会影响引用(反之亦然)。这通常不是问题,但需要理解背后的语义。


陷阱四:const 引用绑定到临时对象后,临时对象的生命周期被延长,但要注意作用域

虽然生命周期被延长,但引用的作用域仍然受限于其声明所在的花括号。例如:

intmain(){constint* ptr =nullptr;{constint& r =10;// 临时对象生命周期延长到 r 的作用域结束 ptr =&r;// ptr 指向临时对象}// r 销毁,临时对象也随之销毁// ptr 现在成了悬垂指针!不可解引用return0;}

即使生命周期延长,当引用本身离开作用域时,临时对象仍然会被销毁。如果保存了引用的地址并试图在作用域外使用,就会产生悬垂指针。

3.8 引用与指针的详细对比

特性引用指针说明
本质变量的别名存储地址的变量引用是“分身”,指针是“指向”
初始化必须初始化可以不初始化(危险)不存在空引用,但有空指针
空值不能为空可以为nullptr引用更安全
重新绑定绑定后不可改变可随时改变指向引用“从一而终”
解引用编译器自动处理需显式用*引用用起来像普通变量
内存占用语法上不占内存占用固定大小(4/8字节)引用在底层可能用指针实现,但对程序员透明
访问成员.运算符->运算符与普通类型保持一致
sizeof得到所指对象大小得到指针本身大小可以用来区分
多级无“引用的引用”有多级指针引用层级简单
数组不能定义“引用的数组”可以定义指针数组语法设计的选择
自身地址不可见(&ref返回所指对象地址)可见(&p返回指针变量地址)引用的重要抽象

3.9 使用场景

优先使用引用的场景:

  1. 函数参数传递,尤其是对于大型对象,可以避免拷贝开销,同时不希望参数为null
  2. 函数返回值,当函数需要返回容器元素或对象成员的访问权时
  3. 操作符重载(如operator=),使语法更自然
  4. 范围for循环中修改容器元素for (auto& elem : container)
  5. 需要确保对象一定存在的场景

需要使用指针的场景:

  1. **需要表示“空”**的情况(可以使用nullptr
  2. 需要在运行时改变指向的目标
  3. 处理动态分配的内存new/delete
  4. 实现数据结构(如链表、树等),节点之间需要重新链接
  5. 函数需要返回多个值时(通过指针参数)
  6. 与C语言API交互

黄金法则能用引用就用引用,需要指针特性时再用指针。引用更安全、更简洁,指针更灵活、更强大。理解它们的本质区别,你就能在不同的场景下做出正确的选择。

第四章:更友好的输入输出

4.1 为什么C++的输入输出更友好?

C语言的printfscanf需要你手动指定格式符:%d是整数,%f是浮点数,%c是字符。一旦写错,程序可能行为异常。更麻烦的是,如果你自己定义了一个新的数据类型(比如一个Student类),printf完全不知道该怎么处理它。

C++的cincout的核心理念是:类型是变量自己的事,编译器知道该怎么处理。你不需要告诉它"这是一个整数"还是"这是一个浮点数",编译器自己就能推断出来。

#include<iostream>#include<string>intmain(){ std::string name;int age;double height; std::cout <<"请输入你的姓名:"; std::cin >> name;// 自动识别为字符串输入 std::cout <<"请输入你的年龄:"; std::cin >> age;// 自动识别为整数输入 std::cout <<"请输入你的身高(米):"; std::cin >> height;// 自动识别为浮点数输入 std::cout <<"你好,"<< name <<"!你今年"<< age <<"岁,身高"<< height <<"米。"<< std::endl;return0;}

关键点

  • >>流提取运算符,数据从cin流向变量
  • <<流插入运算符,数据从变量/常量流向cout
  • 编译器根据变量的类型决定如何解析输入/输出,你无需操心格式符

C++的输入输出相比C语言,有四个核心优势:

优势说明生活类比
类型安全编译器在编译时就确定了对象的类型,而不是靠%格式符在运行时动态判断就像快递员知道你要寄的是衣服还是电子产品,提前准备了合适的包装
减少错误没有冗余的%格式符需要与对象类型保持一致,消除了一个错误类别不用再担心"我把整数当成字符串寄出去了"
可扩展可以让你自己定义的类型(如Student类)也能用<<>>输入输出就像快递公司可以接受任何形状的包裹,只要你有合适的包装规则
可继承ostreamistream是真正的类,你可以继承它们创建自己的流类型就像在标准快递服务基础上,你可以定制"冷链物流"、“贵重物品专送”

4.2 理解cin >>cout <<的原理

你可能会好奇:为什么cin >> x能自动识别x的类型?这背后的秘密是运算符重载(见第五章)。

实际上,cinistream类的对象,coutostream类的对象。这两个类为各种内置类型重载了>><<运算符:

// 在istream类中,大致有这样的定义(简化版)classistream{public: istream&operator>>(int& n);// 读取整数 istream&operator>>(double& d);// 读取浮点数  istream&operator>>(char& c);// 读取字符 istream&operator>>(char* s);// 读取字符串// ... 等等};// 在ostream类中classostream{public: ostream&operator<<(int n);// 输出整数 ostream&operator<<(double d);// 输出浮点数 ostream&operator<<(char c);// 输出字符 ostream&operator<<(constchar* s);// 输出字符串// ... 等等};

当你写cin >> age时,编译器看到ageint类型,就会自动调用istream::operator>>(int&)这个版本。这就像函数重载——同一个名字的函数,根据参数类型不同调用不同的版本。

链式调用的秘密:为什么能写cin >> a >> b?因为每个>>运算符都返回cin本身的引用。所以cin >> a >> b等价于(cin >> a) >> b,第一次调用返回cin,然后继续用这个cin读取b

4.3 缓冲区与输入的工作原理

当你从键盘输入数据时,数据并不会直接送到你的程序里,而是先进入一个称为输入缓冲区的地方。程序从缓冲区读取数据,而不是直接从键盘读取。

这个过程有几个重要特点:

  1. 按回车才提交:你在键盘上敲的所有字符,只有按下回车键后,才会被送入缓冲区。程序才能开始读取。
  2. 空格是分隔符:默认情况下,cin >>遇到空格、制表符或换行符就会停止读取,并把空格留在缓冲区中。
  3. 缓冲区残留问题:这是一个常见的陷阱。看下面的例子:
int age;char name[100]; std::cout <<"请输入年龄:"; std::cin >> age;// 输入 25 并回车 std::cout <<"请输入姓名:"; std::cin.getline(name,100);// 问题!这行不会等待输入

问题出在哪里?当你输入25并回车时,缓冲区里实际上是25\ncin >> age读取了25,但\n还留在缓冲区。接下来的cin.getline()一上来就遇到了\n,以为你输入了一个空行,就直接结束了。

解决方案:用cin.ignore()清除缓冲区中的残留字符。

std::cin.ignore();// 忽略缓冲区中的一个字符(通常是换行符)// 或者 std::cin.ignore(std::numeric_limits<std::streamsize>::max(),'\n');// 忽略直到换行符

4.4 神奇的 while (std::cin >> x) 语法

你可能会在很多代码中看到这样的写法:

int x;while(std::cin >> x){// 处理x}

这个语法为什么能工作?std::cin >> x返回的是cin本身的引用,但在while的条件中,这个cin引用会被隐式转换为一个可以当作布尔值使用的东西。

在C++11之前,是通过operator void*()转换函数实现的:如果流处于正常状态,返回一个非空指针(转换为true);如果流处于错误状态或到达文件尾,返回空指针(转换为false)。

从C++11开始,更直接地使用explicit operator bool()转换。但效果是一样的:当输入成功时条件为真,当输入失败或到达文件尾时条件为假

这种写法的好处是:它会在每次循环时都尝试读取,一旦读取失败(比如遇到文件尾或输入无效数据),循环自动结束。

4.5 输入错误处理

cin >>有一个重要特性:如果用户输入的类型不匹配(比如程序期待整数,用户输入了字母),cin会进入错误状态。一旦进入错误状态,后续的所有输入操作都会立即失败,不会做任何事情。

int age; std::cin >> age;// 用户输入了 "abc"// 此时cin进入了错误状态 std::string name; std::cin >> name;// 这行不会等待输入,立即失败

更糟糕的是,如果不处理,程序可能进入无限循环:

int i =0;while(i !=-1){ std::cin >> i;// 如果用户输入的不是整数,cin进入错误状态// 后续循环中,cin >> i 会立即返回,不等待输入,i的值保持不变// 导致无限循环输出最后一次成功读取的值}

正确的处理方式

#include<iostream>#include<limits>intmain(){int age; std::cout <<"请输入你的年龄:";// 只要输入失败,就循环while(!(std::cin >> age)){ std::cout <<"输入无效,请输入一个整数:";// 1. 清除错误状态 std::cin.clear();// 2. 忽略缓冲区中所有无效字符(直到换行符) std::cin.ignore(std::numeric_limits<std::streamsize>::max(),'\n');}// 还可以检查数值范围 std::cin.ignore(std::numeric_limits<std::streamsize>::max(),'\n'); std::cout <<"你的年龄是:"<< age << std::endl;return0;}

这段代码做了三件事:

  1. while (!(std::cin >> age)):尝试读取整数,如果失败(比如输入了字母),条件为真
  2. std::cin.clear():清除错误状态,让cin可以继续工作
  3. std::cin.ignore(...):丢弃缓冲区中的所有无效字符,为下一次输入做准备

4.6 cin >>getline 的区别

cin >> 读取字符串时有一个特点:遇到空白字符就停止

std::string name; std::cin >> name;// 输入 "John Doe" std::cout << name;// 只输出 "John","Doe"留在缓冲区

如果需要读取一整行(包括空格),需要用getline函数:

std::string fullName; std::cout <<"请输入你的全名:"; std::getline(std::cin, fullName);// 读取一整行,包括空格 std::cout <<"你好,"<< fullName << std::endl;

std::getline会一直读取,直到遇到换行符(\n),然后把读取的内容存入字符串,并丢弃最后的换行符。

注意事项

  • 如果在getline之前用过cin >>,需要用cin.ignore()清除缓冲区中的换行符
  • getline也可以指定其他结束符:getline(std::cin, str, '#')表示读到#就停止

4.7 格式化输出

很多初学者觉得cout不如printf灵活,无法控制输出格式。其实cout完全能做到,只是需要借助<iomanip>头文件中的一些工具。

4.7.1 控制输出宽度和对齐方式
#include<iostream>#include<iomanip>// 格式化输出需要这个头文件intmain(){int num =123;// 设置宽度为10,默认右对齐 std::cout <<"|"<< std::setw(10)<< num <<"|"<< std::endl;// 输出: | 123|// 左对齐 std::cout <<"|"<< std::left << std::setw(10)<< num <<"|"<< std::endl;// 输出: |123 |// 设置填充字符 std::cout <<"|"<< std::setfill('*')<< std::setw(10)<< num <<"|"<< std::endl;// 输出: |*******123|return0;}

注意:std::setw只影响下一次输出,而std::leftstd::rightstd::setfill的设置会一直有效,直到你改变它们。

4.7.2 控制浮点数精度
#include<iostream>#include<iomanip>intmain(){double pi =3.14159265358979;// 默认精度是6位有效数字 std::cout <<"默认: "<< pi << std::endl;// 输出: 3.14159// 设置定点表示法,并控制小数位数 std::cout << std::fixed << std::setprecision(2); std::cout <<"保留2位小数: "<< pi << std::endl;// 输出: 3.14// 科学计数法 std::cout << std::scientific << std::setprecision(4); std::cout <<"科学计数法: "<< pi << std::endl;// 输出: 3.1416e+00return0;}
4.7.3 控制整数的进制
#include<iostream>#include<iomanip>intmain(){int num =255; std::cout <<"十进制: "<< num << std::endl;// 255 std::cout <<"十六进制: "<< std::hex << num << std::endl;// ff std::cout <<"八进制: "<< std::oct << num << std::endl;// 377// 显示进制前缀 std::cout << std::showbase; std::cout <<"十六进制(带前缀): "<< std::hex << num << std::endl;// 0xff std::cout <<"八进制(带前缀): "<< std::oct << num << std::endl;// 0377return0;}
4.7.4 格式化控制速查表
操纵符作用示例
std::setw(n)设置输出宽度为ncout << setw(5) << 123; // " 123"
std::setfill(c)设置填充字符为ccout << setfill('0') << setw(5) << 123; // “00123”
std::left左对齐cout << left << setw(5) << 123; // “12300”
std::right右对齐cout << right << setw(5) << 123; // “00123”
std::setprecision(n)设置浮点数精度cout << setprecision(3) << 3.14159; // “3.14”
std::fixed定点表示法cout << fixed << 3.14159; // “3.142”
std::scientific科学计数法cout << scientific << 3.14159; // “3.142e+00”
std::hex十六进制输出cout << hex << 255; // ff
std::oct八进制输出cout << oct << 255; // 377
std::dec十进制输出cout << dec << 255; // 255
std::showbase显示进制前缀cout << showbase << hex << 255; // 0xff
std::boolalphabool值显示为true/falsecout << boolalpha << true; // true

4.8 让自定义类型也支持<<>>

注意:本小节应在学习完函数重载与类和对象后回过头来学习(后续对应章节也有相关讲解),当前仅了解即可。

C++的输入输出最强大的地方在于它的可扩展性:你可以让自己的类型也能用<<>>进行输入输出。

#include<iostream>#include<string>classStudent{private: std::string name;int age;double score;public:// 构造函数Student(const std::string& n ="",int a =0,double s =0.0):name(n),age(a),score(s){}// 声明为友元函数,以便访问私有成员friend std::ostream&operator<<(std::ostream& os,const Student& stu);friend std::istream&operator>>(std::istream& is, Student& stu);};// 重载输出运算符 std::ostream&operator<<(std::ostream& os,const Student& stu){ os <<"姓名:"<< stu.name <<",年龄:"<< stu.age <<",成绩:"<< stu.score;return os;// 返回os以支持链式调用}// 重载输入运算符 std::istream&operator>>(std::istream& is, Student& stu){ std::cout <<"请输入姓名、年龄、成绩(用空格分隔):"; is >> stu.name >> stu.age >> stu.score;return is;// 返回is以支持链式调用}intmain(){ Student stu; std::cin >> stu;// 现在可以这样输入了! std::cout << stu << std::endl;// 也可以这样输出了!return0;}

关键点

  • 重载运算符的函数必须是全局函数(不能是成员函数),因为第一个参数是流对象
  • 通常需要声明为类的友元,以便访问私有成员
  • 函数必须返回流的引用,以支持链式调用(cout << a << b
  • 第一个参数和返回值都必须是流的引用

4.9 std::endl vs '\n'

最后,一个性能相关的小知识:std::endl'\n'都输出换行,但它们有一个重要区别:

  • '\n':仅仅输出一个换行符
  • std::endl:输出换行符并刷新输出缓冲区(确保内容立即显示)

刷新缓冲区是一个相对昂贵的操作。如果你在循环中频繁使用std::endl,可能会对性能有明显影响。

// 低效版本for(int i =0; i <10000;++i){ std::cout << i << std::endl;// 每次循环都刷新缓冲区}// 高效版本for(int i =0; i <10000;++i){ std::cout << i <<'\n';// 只输出换行,不刷新}// 最后手动刷新一次 std::cout << std::flush;// 或者 std::endl

什么时候用std::endl 当你需要确保输出立即显示时,比如:

  • 提示用户输入前(确保提示信息已经显示)
  • 记录关键日志(防止程序崩溃时丢失最后几条日志)
  • 调试时(确保输出立即显示,便于观察)

其他时候,用'\n'更高效。


第五章:函数的“备胎”与“分身”

在C语言中,函数就像是一个个独立的个体,每个函数必须有独一无二的名字。如果你想要一个既能计算整数加法、又能计算浮点数加法的功能,抱歉,你得给它们起不同的名字,比如add_intadd_double。这就像你明明想点一份“牛肉面”,却要根据口味不同喊出“红烧牛肉面”、“清汤牛肉面”、“麻辣牛肉面”——是不是很麻烦?

C++的设计者本贾尼·斯特劳斯特卢普博士显然也受够了这种繁琐。他在设计C++时,给函数赋予了两种强大的能力:缺省参数让函数有了“备胎”,函数重载让函数有了“分身”。这两项特性让C++的函数变得更加灵活、更加智能。

5.1 缺省参数:给函数一个“备胎”

5.1.1 什么是缺省参数?

缺省参数(也叫默认参数)允许你在定义函数时为参数指定一个默认值。调用函数时,如果你没有提供这个参数,编译器就帮你用默认值顶上。这就像是给你的函数准备了一个“备胎”,当你需要的时候,它会自动顶上。

// 05_default_param.cpp#include<iostream>#include<string>// 定义一个带缺省参数的函数voidbookTicket(std::string destination, std::string seatType ="经济舱",int passengerCount =1){ std::cout <<"目的地:"<< destination <<",舱位:"<< seatType <<",乘客人数:"<< passengerCount << std::endl;}intmain(){bookTicket("北京","商务舱",2);// 全部指定:商务舱,2人bookTicket("上海","经济舱");// 只指定前两个:乘客数用默认值1bookTicket("广州");// 只指定第一个:后两个都用默认值return0;}

输出结果:

目的地:北京,舱位:商务舱,乘客人数:2 目的地:上海,舱位:经济舱,乘客人数:1 目的地:广州,舱位:经济舱,乘客人数:1 
5.1.2 缺省参数的分类

缺省参数分为两类:全缺省参数半缺省参数

全缺省参数:所有参数都提供了默认值。

voidfunc1(int a =10,int b =20,int c =30){ cout <<"a = "<< a <<", b = "<< b <<", c = "<< c << endl;}intmain(){func1();// 使用全部默认值:a=10, b=20, c=30func1(1);// 相当于func1(1,20,30)func1(1,2);// 相当于func1(1,2,30)func1(1,2,3);// 全部指定return0;}

半缺省参数:只有部分参数提供了默认值。

voidfunc2(int a,int b =20,int c =30){ cout <<"a = "<< a <<", b = "<< b <<", c = "<< c << endl;}intmain(){// func2(); // 错误!第一个参数没有默认值,必须提供func2(1);// 相当于func2(1,20,30)func2(1,2);// 相当于func2(1,2,30)func2(1,2,3);// 全部指定return0;}
5.1.3 核心规则

缺省参数有一个非常重要的规则:必须从参数列表的最右边开始连续给出默认值。你不能跳过一个参数不给默认值。

// 正确的写法voidfunc1(int a,int b =10,int c =20);// 正确:从右向左连续voidfunc2(int a =10,int b =20,int c =30);// 正确:全缺省// 错误的写法// void func3(int a = 10, int b, int c = 30); // 错误!b没有默认值,但a和c有,跳过了b// void func4(int a, int b = 20, int c); // 错误!c在最右边,必须给它默认值

为什么要有这个规则? 因为函数调用时,参数是从左到右匹配的。如果允许跳跃缺省,编译器就无法知道func(10, 20)里哪个参数是你想指定的。比如对于void func(int a = 10, int b, int c = 30);,当你调用func(10, 20)时,编译器就懵了:你是想给a和b赋值,还是给a和c赋值?

5.1.4 声明与定义不可同时出现缺省值

在头文件声明和源文件定义分离时,缺省参数只能出现在声明中,不能在定义中重复出现。

// .h 头文件voiddisplayInfo(std::string name,int age =18);// 声明中给默认值// .cpp 源文件voiddisplayInfo(std::string name,int age){// 定义中不能再给默认值 std::cout << name <<" is "<< age <<" years old."<< std::endl;}

如果声明和定义都给了缺省值,且值不同,编译器无法确定该用哪个,会报错。

5.1.5 缺省值可以是表达式

缺省值不仅可以是常量,还可以是表达式,甚至是函数调用。

#include<iostream>#include<cstdlib>#include<ctime>intgetDefaultValue(){srand(time(nullptr));returnrand()%100;// 返回0-99的随机数}voidprocess(int value =getDefaultValue()){// 缺省值可以是函数调用 std::cout <<"处理值:"<< value << std::endl;}intmain(){process();// 使用随机默认值process(42);// 指定值42return0;}

注意:每次调用没有提供实参时,表达式都会被重新求值。这意味着每次默认值可能不同。

5.1.6 缺省参数的实际应用场景

场景一:初始化配置

structStack{int* data;int top;int capacity;};voidStackInit(Stack* ps,int capacity =4){// 默认容量为4 ps->data =(int*)malloc(capacity *sizeof(int)); ps->top =-1; ps->capacity = capacity;}intmain(){ Stack s1, s2;StackInit(&s1);// 使用默认容量4StackInit(&s2,10);// 显式指定容量10return0;}

这个例子中,大多数情况下我们可能只需要默认大小的栈,但偶尔需要大容量时,也可以显式指定。缺省参数让这个设计变得非常优雅。

场景二:日志输出

voidlogMessage(const std::string& msg,const std::string& level ="INFO",const std::string& file =__FILE__){ std::cout <<"["<< level <<"] "<< file <<": "<< msg << std::endl;}intmain(){logMessage("程序启动");// [INFO] main.cpp: 程序启动logMessage("内存不足","ERROR");// [ERROR] main.cpp: 内存不足logMessage("调试信息","DEBUG","debug.cpp");// 指定文件名return0;}

5.2 函数重载:函数的“分身术”

5.2.1 什么是函数重载?

函数重载允许你在同一作用域中定义多个同名函数,只要它们的参数列表不同即可。这就像是一个人有多个“分身”,每个分身做相似但又不同的事情。调用时,编译器会根据你提供的参数,自动选择合适的“分身”去执行任务。

// 05_overload.cpp#include<iostream>#include<string>// 1. 参数类型不同voidprint(int x){ std::cout <<"整数: "<< x << std::endl;}voidprint(double x){ std::cout <<"浮点数: "<< x << std::endl;}// 2. 参数个数不同voidprint(const std::string& s){ std::cout <<"字符串: "<< s << std::endl;}voidprint(const std::string& s,int times){for(int i =0; i < times;++i){ std::cout <<"重复字符串: "<< s << std::endl;}}// 3. 参数顺序不同(实际上就是类型不同)voidprint(int id,const std::string& name){ std::cout <<"ID: "<< id <<", 姓名: "<< name << std::endl;}voidprint(const std::string& name,int id){ std::cout <<"姓名: "<< name <<", ID: "<< id << std::endl;}intmain(){print(10);// 调用 int 版本print(3.14159);// 调用 double 版本print("Hello");// 调用 string 版本print("Hello",3);// 调用 string, int 版本print(1001,"张三");// 调用 int, string 版本print("李四",2002);// 调用 string, int 版本return0;}
5.2.2 函数重载的判定标准

函数重载的判定标准只有一条:参数列表必须不同。这里的“不同”体现在三个方面:

  1. 参数类型不同
voidfunc(int a);voidfunc(double a);// 类型不同,构成重载
  1. 参数个数不同
voidfunc(int a);voidfunc(int a,int b);// 个数不同,构成重载
  1. 参数顺序不同
voidfunc(int a,double b);voidfunc(double a,int b);// 顺序不同,构成重载

** 重要:返回值类型不能作为重载依据**

intgetValue(){return1;}doublegetValue(){return1.0;}// 编译错误!函数重定义

为什么?因为调用时,编译器只看函数名和参数列表,不看你怎么使用返回值。如果你写getValue(),编译器不知道你想把它赋给int还是double,无法决定调用哪个版本。

5.2.3 重载与const形参

当const参与进来时,情况变得稍微复杂一些。这里需要区分顶层const底层const

值传递时的const

对于值传递的参数,顶层const(指针本身是const)不能作为重载的依据,因为调用者无法区分。

voidfunc(int x);// 版本1voidfunc(constint x);// 错误!与版本1重复定义

为什么?因为对于调用者来说,传进来的值无论是不是const,都是拷贝一份给函数。函数内部是否修改这个拷贝,对调用者完全没有影响。

指针/引用传递时的const

对于指针或引用,底层const(所指对象是const)可以作为重载的依据。

voidprocess(int* ptr);// 版本1:可以修改指针指向的值voidprocess(constint* ptr);// 版本2:不能修改指针指向的值intmain(){int a =10;constint b =20;process(&a);// 调用版本1process(&b);// 调用版本2return0;}

对于引用也是类似的:

voidmodify(int& ref);// 版本1:可修改引用对象voidmodify(constint& ref);// 版本2:不可修改引用对象intmain(){int x =5;constint y =10;modify(x);// 调用版本1modify(y);// 调用版本2modify(20);// 调用版本2(字面量只能绑定到const引用)return0;}
5.2.4 名字修饰:编译器如何区分重载函数?

现在你知道了C++支持函数重载,但C语言不支持。为什么?这涉及到编译器的底层机制:名字修饰(Name Mangling)。

在编译过程中,编译器会给每个函数生成一个内部名字(修饰名)。C语言只是简单地在函数名前加一个下划线(如_func),所以同名函数会冲突。而C++编译器会把函数名和参数类型信息一起编码进去,生成独一无二的修饰名。

Linux下g++的修饰规则

函数声明修饰名说明
int add(int a, int b)_Z3addii_Z开头,3是函数名长度,ii是两个int参数
double add(double a, double b)_Z3adddddd是两个double参数
void func(int a, double b)_Z4funci d注意空格是分隔,实际无空格

Windows下VS的修饰规则

函数声明修饰名
int func(int a)?func@@YAHH@Z

整体结构:

? func @@ Y A H H @ Z 
  • ?:所有 Visual C++ 修饰名的固定起始标志,表示这是一个 C++ 名字(与 C 风格函数区分)。
  • func:原始函数名(用户定义的名称)。
  • @@:分隔符,用于分隔函数名和后面的签名信息。
  • Y:表示函数的调用约定(后面章节介绍)。此处 Y 代表 __cdecl(C/C++ 默认调用约定,参数从右向左入栈,由调用者清理栈)。
  • A:表示函数是一个普通函数(不是类成员函数、全局函数等)。实际上,YA 合起来表示该函数是全局函数,使用 __cdecl 调用约定。
  • H:返回值类型编码。H 代表 int。常见编码对照:
    • Hint
    • Ndouble
    • Evoid
    • Dchar
    • Mfloat
    • PA → 指针(例如 PAH 表示 int*
  • **第二个 **H:参数类型编码。这里只有一个参数,类型也是 int。如果有多个参数,会连续列出,如 HH 表示两个 int 参数。
  • @:参数列表结束标记。
  • Z:整个修饰名的结束标记。

正是因为有了这种修饰机制,链接器才能准确区分add(int,int)add(double,double),让函数重载成为可能。

你可以自己验证一下:在Linux下编译一个包含重载函数的文件,然后用nm命令查看符号表:

g++ -c test.cpp nm test.o |grep print 

你会看到类似这样的输出:

0000000000000000 T _Z5printi 0000000000000000 T _Z5printd 0000000000000000 T _Z5printSs 

三个print函数变成了三个完全不同的符号!

5.2.5 函数重载的匹配规则

当你调用一个重载函数时,编译器会按照严格的规则来选择最匹配的版本。

第一步:筛选可行函数

编译器会找出所有满足以下条件的函数:

  1. 函数名相同
  2. 参数个数匹配(考虑默认参数后)
  3. 每个实参都能隐式转换为对应形参类型

第二步:寻找最佳匹配

编译器按照以下优先级选择最佳匹配:

  1. 完全匹配:实参类型与形参类型完全相同
  2. 提升转换:如charintfloatdouble
  3. 标准转换:如intdoubledoubleint
  4. 用户定义转换:如类构造函数转换

第三步:检查二义性

如果找到多个同样“好”的匹配,编译器就会报二义性错误。

voidfunc(int a,double b);voidfunc(double a,int b);intmain(){func(1,2);// 编译错误!二义性调用// 第一个参数1可以匹配int或double,第二个参数2也是return0;}

实际匹配示例

voidtest(int x);voidtest(double x);intmain(){test(10);// 完全匹配:调用test(int)test(3.14);// 完全匹配:调用test(double)test(3.14f);// 提升转换:float→double,调用test(double)test('A');// 提升转换:char→int,调用test(int)test(true);// 提升转换:bool→int,调用test(int)return0;}

记住:编译器总是选择“转换代价”最小的那个版本。

5.3 缺省参数与函数重载的“陷阱”

当缺省参数和函数重载一起使用时,要特别小心二义性问题。

// 05_trap.cpp#include<iostream>voidfunc(int a){ std::cout <<"func(int)"<< std::endl;}voidfunc(int a,int b =20){ std::cout <<"func(int, int)"<< std::endl;}intmain(){// func(10); // 编译错误!二义性调用// 编译器不知道应该调用 func(int) 还是 func(int, int=20)return0;}

当两个重载函数在“有效调用方式”上重叠时,编译器就会陷入两难。在这个例子中,func(10)既可以匹配第一个函数(一个参数),也可以通过缺省参数匹配第二个函数(两个参数,第二个用默认值)。编译器无法决定用哪个,只能报错。

另一个例子:

voidf(int a);voidf(int a,int b =10);intmain(){f(1,2);// OK,唯一匹配void f(int, int)f(1);// 错误!二义性return0;}

最佳实践:在设计函数时,避免让缺省参数和重载函数产生重叠的调用方式。好的设计应该消除这种歧义。

5.4 运算符重载:函数重载的一种特殊形式

5.4.1 为什么需要运算符重载?

C++预定义的运算符(如+-*/)只能用于基本数据类型。但是,在现实世界中,我们经常需要对自定义类型进行运算。比如,数学上的复数可以直接相加,但在C++中,如果你直接写c1 + c2,编译器会报错,因为它不知道如何对两个复数变量执行加法。

运算符重载的实质就是函数重载的一种特殊形式。它允许我们为自定义类型重新定义运算符的含义,让这种类型的变量也能像基本类型一样使用运算符。

5.4.2 运算符重载的语法

运算符重载的基本形式如下:

返回值类型 operator 运算符(参数表){// 实现代码}

其中operator是关键字,后面跟着要重载的运算符。例如,重载加法运算符的函数名就是operator+。调用时,a + b会被编译器解释为operator+(a, b)(如果定义为全局函数)或a.operator+(b)(如果定义为成员函数)。这里我们先介绍全局函数的形式。

5.4.3 复数类型运算符重载的完整示例

让我们通过一个复数类型的例子,深入理解运算符重载。这里我们使用C风格的结构体来定义复数类型,所有运算符都用全局函数实现。

// 05_operator_overload.cpp#include<iostream>// 定义复数结构体structComplex{double real;// 实部double imag;// 虚部};// 全局函数重载加法运算符 Complex operator+(const Complex& c1,const Complex& c2){ Complex result; result.real = c1.real + c2.real; result.imag = c1.imag + c2.imag;return result;}// 全局函数重载减法运算符 Complex operator-(const Complex& c1,const Complex& c2){ Complex result; result.real = c1.real - c2.real; result.imag = c1.imag - c2.imag;return result;}// 全局函数重载乘法运算符 Complex operator*(const Complex& c1,const Complex& c2){ Complex result;// (a+bi)(c+di) = (ac-bd) + (ad+bc)i result.real = c1.real * c2.real - c1.imag * c2.imag; result.imag = c1.real * c2.imag + c1.imag * c2.real;return result;}// 全局函数重载输出运算符(必须为全局函数,因为左操作数是std::ostream) std::ostream&operator<<(std::ostream& os,const Complex& c){ os << c.real;if(c.imag >=0){ os <<"+"<< c.imag <<"i";}else{ os <<"-"<<-c.imag <<"i";}return os;// 返回os以支持链式调用}// 全局函数重载输入运算符 std::istream&operator>>(std::istream& is, Complex& c){ std::cout <<"请输入实部和虚部:"; is >> c.real >> c.imag;return is;}// 全局函数重载复合加法赋值运算符 Complex&operator+=(Complex& c1,const Complex& c2){ c1.real += c2.real; c1.imag += c2.imag;return c1;// 返回引用以支持链式操作}intmain(){// 初始化复数变量(C++11的列表初始化,也可用c1.real=3; c1.imag=4;) Complex c1 ={3,4};// 3+4i Complex c2 ={1,2};// 1+2i// 使用重载的运算符 Complex c3 = c1 + c2;// 等价于 operator+(c1, c2) Complex c4 = c1 - c2; Complex c5 = c1 * c2; std::cout <<"c1 = "<< c1 << std::endl;// 输出: 3+4i std::cout <<"c2 = "<< c2 << std::endl;// 输出: 1+2i std::cout <<"c1 + c2 = "<< c3 << std::endl;// 输出: 4+6i std::cout <<"c1 - c2 = "<< c4 << std::endl;// 输出: 2+2i std::cout <<"c1 * c2 = "<< c5 << std::endl;// 输出: -5+10i// 链式赋值(使用编译器生成的默认赋值运算符,它会逐个成员赋值) Complex c6, c7; c6 = c7 = c1;// 先c7=c1,再c6=c7,工作正常// 复合赋值 c1 += c2;// 等价于 operator+=(c1, c2) std::cout <<"c1 += c2 后: "<< c1 << std::endl;return0;}

要点解析

  • 所有运算符重载函数都是全局函数,第一个参数通常是const引用(或左值引用,如果需要修改)。
  • 对于不修改操作数的运算符(如+-),应使用const引用以避免拷贝,并返回新对象(值返回)。
  • 对于修改左操作数的运算符(如+=),应返回左操作数的引用,以支持链式操作。
  • 输入输出运算符<<>>必须定义为全局函数,因为它们的左操作数是流对象(std::ostreamstd::istream),不是我们自定义的类型。
5.4.4 全局函数实现运算符重载的要点
  1. 参数传递:尽量使用const引用,避免不必要的拷贝。对于需要修改的参数(如+=的左操作数),使用非const引用。
  2. 返回值
    • 对于算术运算符(+-等),返回新对象(值返回)。
    • 对于复合赋值运算符(+=-=等),返回左操作数的引用(*this的引用),以支持链式操作。
    • 对于关系运算符(==<等),返回bool类型。
    • 对于输入输出运算符,返回流对象的引用,以支持链式调用。
  3. 为什么输入输出运算符必须为全局函数?
    如果定义为成员函数,调用形式会变成c << cout,这显然不符合我们的直觉。我们希望的是cout << c,因此左操作数必须是ostream对象,右操作数是自定义类型。所以只能定义为全局函数。
  4. 运算符的优先级和结合性:重载运算符不会改变原运算符的优先级和结合性。例如,*的优先级仍高于+
5.4.5 可重载与不可重载的运算符

C++中的运算符并非都可以重载,有严格的限制。

可以重载的运算符(大部分):

  • 算术运算符:+-*/%
  • 关系运算符:==!=<><=>=
  • 逻辑运算符:&&||!
  • 位运算符:&|^~<<>>
  • 赋值运算符:=+=-=等(注意赋值运算符必须定义为成员函数)
  • 自增自减:++--
  • 下标运算符:[]
  • 函数调用运算符:()(必须定义为成员函数)
  • 成员访问:->(必须定义为成员函数)
  • 内存管理:newdelete

不能重载的运算符

  • 作用域解析:::
  • 成员访问:.(点运算符)
  • 成员指针访问:.*
  • 三元条件:?:
  • sizeoftypeid
5.4.6 重载自增自减运算符的特殊性

自增(++)和自减(--)运算符有前缀和后缀之分,重载时需要区分。在全局函数中,可以通过参数的不同来区分:前缀版本没有额外参数,后缀版本有一个int参数(用来占位)。

structCounter{int value;};// 前缀自增:++c Counter&operator++(Counter& c){++c.value;return c;// 返回自增后的引用}// 后缀自增:c++(带一个int参数作为标记) Counter operator++(Counter& c,int){ Counter temp = c;// 保存原值++c.value;// 自增return temp;// 返回原值(值返回)}// 前缀自减:--c Counter&operator--(Counter& c){--c.value;return c;}// 后缀自减:c-- Counter operator--(Counter& c,int){ Counter temp = c;--c.value;return temp;}

关键点

  • 前缀版本返回引用,后缀版本返回原值的拷贝。
  • 后缀版本带一个额外的int参数,这个参数在调用时不需要显式传递,只是用来区分前缀和后缀版本。
5.4.7 重载函数调用运算符(简要说明)

重载函数调用运算符()可以让一个变量像函数一样被调用。这种变量通常称为函数对象(也称仿函数)。不过,operator()必须定义为成员函数,这涉及类的概念,我们将在后续学习面向对象编程时再详细探讨。这里只做简单了解。

5.4.8 运算符重载的最佳实践

原则一:保持语义一致
重载运算符应该尽量保持其原有的语义。operator+应该做加法,而不是减法;operator==应该判断相等,而不是不等。违反直觉的重载会让代码难以理解和维护。

原则二:使用const引用减少拷贝
对于不修改操作数的运算符,参数应使用const引用,避免不必要的拷贝开销。

原则三:返回引用支持链式操作
对于复合赋值运算符(如+=),应返回左操作数的引用,以支持a = b = ccout << a << b这样的链式操作。

原则四:为不修改对象的运算符添加const限定
虽然我们在全局函数中无法添加const限定,但参数使用const引用就能保证不修改原对象。


第六章:高效与智能

在C语言中,我们常常用宏(#define)来定义常量或简单的“函数”。宏在预处理阶段进行文本替换,虽然方便,但缺乏类型检查,容易引发难以排查的错误(比如优先级问题、多次求值副作用等)。C++的设计哲学之一是“高效且安全”,因此引入了内联函数这一编译阶段的解决方案——它既有函数的语义和类型检查,又能像宏一样在调用处展开,省去函数调用开销。

如果说内联函数是对C语言宏的现代化改进,那么C++11标准则是对C++自身的一次全面革新,让这门语言焕发了新的生机。本章将深入探讨内联函数的奥秘,并详细解析C++11中几个关键的现代化特性:auto类型推导让编译器帮你“猜”类型,简化代码书写;基于范围的for循环让遍历容器变得异常简洁;空指针nullptr彻底终结了NULL的二义性问题;decltype类型推导让你能精确获取任意表达式的类型;常变量const常量)作为类型安全的宏替代,让常量定义更规范;而统一初始化(列表初始化)则用一套{}语法统一了所有初始化场景,防止窄化转换,让代码更一致、更安全。

6.1 内联函数

6.1.1 为什么需要内联函数?

想象一下,你正在组织一个万人大会,需要反复宣读一条非常简短的指令(比如“请保持安静”)。如果每次都用广播系统呼叫主持人,主持人再拿着话筒喊,这个过程的“准备-呼叫-结束”本身就会耗费不少精力。更好的方式是直接让广播系统把这句话录下来,在每个需要的地方直接播放录音。

在程序中,函数调用也是有开销的:参数压栈、栈帧建立、跳转执行、返回值传递、栈帧销毁。对于一些非常短小、频繁调用的函数(比如getter/setter、简单判断),这个“呼叫主持人”的开销可能比“喊话”本身还要大。

C语言中常用宏来解决这个问题,但宏是预处理阶段的简单文本替换,没有类型检查,容易出现意想不到的错误。

// 宏的陷阱:由于文本替换,优先级问题导致错误#defineSQUARE(x) x * xint result =SQUARE(2+3);// 预期 25,实际被替换为 2 + 3 * 2 + 3 = 11

C++引入了内联函数,它既有函数的语法和类型检查,又能像宏一样在调用处“展开”,省去函数调用的开销。

6.1.2 内联函数的定义与使用

使用关键字inline修饰函数即可将其声明为内联函数。

// 06_inline.cpp#include<iostream>// 定义内联函数inlineboolisEven(int num){return num %2==0;}intmain(){int numbers[]={1,2,3,4,5,6};for(int n : numbers){if(isEven(n)){// 编译后可能被展开为 if (n % 2 == 0) std::cout << n <<"是偶数"<< std::endl;}else{ std::cout << n <<"是奇数"<< std::endl;}}return0;}

关键点

  • inline关键字放在函数定义前,它告诉编译器:“我希望这个函数以内联方式处理”。
  • 内联函数的定义通常放在头文件中,因为编译器在每个调用点都需要看到完整的函数体才能进行展开。
6.1.3 深入理解inline:是建议而非命令

很多初学者误以为加了inline就一定会内联展开。实际上,inline对于编译器而言只是一个请求或建议,并非强制命令。编译器会根据函数的复杂程度、自身的优化策略来决定是否真正内联。

编译器通常会忽略inline的情况

  • 函数体过长:如果一个函数包含大量代码,内联展开会导致代码急剧膨胀,得不偿失。
  • 包含循环或递归:递归函数无法内联,因为理论上会无限展开。包含forwhile等循环语句的函数,编译器也倾向于不内联。
  • 函数地址被使用:如果程序中获取了函数的地址,编译器必须为该函数生成独立的代码,此时无法内联。

你可以这样理解:内联函数是程序员对编译器的一种“温柔的建议”,而编译器才是最终的“决策者”

6.1.4 内联函数的特性
  1. 空间换时间:内联展开会在每个调用点复制一份函数代码,导致最终可执行文件的体积增大(空间代价)。但节省了函数调用的开销,换来了更快的执行速度(时间收益)。
  2. 声明与定义不分离:内联函数的定义通常要放在头文件中。因为编译器在调用处展开时,必须能看到完整的函数体。如果只在头文件中放声明,在源文件中放定义,链接时就会找不到函数地址(因为内联函数可能不生成独立的函数符号)。
  3. 类内定义的成员函数默认是内联的:在类定义内部实现的成员函数,编译器会隐式地将其视为内联函数(尽管是否真的内联仍由编译器决定)。
6.1.5 内联函数 vs 宏
特性内联函数
处理阶段编译期预处理期
类型检查有严格的类型检查和安全检查无类型检查,纯文本替换
副作用处理参数只求值一次,安全参数可能被多次求值,有风险
调试支持支持单步调试(在Debug模式下)无法调试
访问权限可以作为类的成员函数,访问私有成员无法访问类的私有成员

总结:在C++中,对于简单的常量,用constenum class(将在本章6.6、6.7小节讲解);对于简单的函数,用inline函数。宏,这个C语言的“老将”,在C++中应该逐渐退出历史舞台。


6.2 auto关键字

6.2.1 auto的前世今生

在C++98/03标准中,auto是一个存储类说明符,用于声明变量的自动存储周期(即局部变量),但由于局部变量默认就是自动存储,这个关键字几乎无人使用,形同虚设。

C++11标准对auto进行了彻底的“改造”,赋予它全新的含义:自动类型推导。从此,auto成为了一个类型占位符,编译器根据变量的初始化表达式,在编译期自动推断出变量的类型。

6.2.2 auto的基本用法
// 06_auto.cpp#include<iostream>#include<vector>#include<map>intmain(){// 基础类型推导auto i =10;// i -> intauto d =3.14159;// d -> doubleauto b =true;// b -> boolauto c ='A';// c -> charauto str ="Hello";// str -> const char*// 复杂类型简化(这才是auto的真正威力) std::vector<std::map<int, std::string>> complexData;// 传统写法:又臭又长 std::vector<std::map<int, std::string>>::iterator it1 = complexData.begin();// auto写法:简洁优雅auto it2 = complexData.begin();// 类型完全相同,但写法简单多了// 结合引用和constint x =100;constint cx = x;auto v1 = cx;// v1是int(const被忽略)constauto v2 = cx;// v2是const int(显式加const)auto& ref = x;// ref是int&constauto& cref = cx;// cref是const int&// 在范围for循环中auto的妙用 std::vector<double> vec ={1.1,2.2,3.3,4.4};for(constauto& val : vec){// const引用,避免拷贝且只读 std::cout << val <<" ";} std::cout << std::endl;return0;}
6.2.3 auto的推导规则

规则一:按值推导(auto
当变量声明为auto var = expr;时,推导行为类似于模板函数的按值参数传递。

  • 忽略顶层const/volatile:如果exprconst intvar推导为int
  • 忽略引用语义:如果exprint&var推导为int(复制引用对象的值)。
  • 数组/函数退化为指针:如果expr是数组名或函数名,推导为对应类型的指针。
constint ci =10;int& ri = ci;auto a = ci;// a: int(顶层const被忽略)auto b = ri;// b: int(引用语义被忽略)int arr[3]={1,2,3};auto c = arr;// c: int*(数组退化为指针)voidfunc(){}auto d = func;// d: void(*)()(函数退化为函数指针)

规则二:引用/指针推导(auto&** / auto*)**
当变量声明为auto& var = expr;auto* var = expr;时,推导行为类似于模板函数的引用/指针参数传递。

  • 保留底层const:如果exprconst intauto& var推导为const int&
  • 必须匹配引用/指针类型auto*要求expr必须是指针类型,否则编译错误。
  • 数组/函数不退化:如果expr是数组名且声明为auto&,推导为数组类型(如int (&)[3])。
constint ci =10;auto& ra = ci;// ra: const int&(保留底层const)int arr[3]={1,2,3};auto& arr_ref = arr;// arr_ref: int(&)[3](数组类型,未退化)// auto* pa = ci; // 错误!ci不是指针类型auto* pb =&ci;// pb: const int*(正确)

规则三:万能引用推导(auto&&
auto&&是一个万能引用,它可以根据初始化表达式的值类别(后续讲解)进行推导,结合了左值引用与右值引用的特性。

  • expr左值(如变量名、左值引用),auto&&推导为左值引用
  • expr右值(如字面量、临时对象),auto&&推导为右值引用
int x =5;auto&& r1 = x;// r1: int&(x为左值,推导为左值引用)auto&& r2 =10;// r2: int&&(10为右值,推导为右值引用)auto&& r3 = r1;// r3: int&(r1为左值引用,折叠为左值引用)
6.2.4 auto的使用限制
  1. 必须初始化auto变量的类型完全依赖初始化表达式,未初始化的auto声明会直接编译错误。
auto a;// 错误:无法推导类型
  1. 不能用于函数参数:C++11中,auto不能作为函数参数类型。C++14起支持lambda参数使用auto,C++20起支持普通函数使用auto参数。
  2. 不能用于定义数组
auto arr[3]={1,2,3};// 错误
  1. 不能用于非静态成员变量
structS{auto a =10;// 错误staticconstauto b =20;// 正确:静态常量成员可结合const使用};
6.2.5 auto与std::initializer_list的特殊情况

这是auto推导规则与模板类型推导唯一不同的地方:

auto a ={1,2,3};// a被推导为std::initializer_list<int>// 但在模板中template<typenameT>voidfunc(T param);// func({1, 2, 3}); // 错误!无法推导T

当使用花括号初始化时,auto会将其推导为std::initializer_list,而模板参数推导则无法直接处理这种形式。

6.2.6 使用auto的注意事项
  • 优先用于复杂类型:如STL迭代器、lambda表达式类型(无法显式写出)等场景。
  • 基础类型谨慎使用:对于intdouble等简单类型,过度使用auto可能降低代码可读性。
  • 显式控制const与引用:若需保留初始化表达式的const或引用属性,显式添加const&&&

6.3 基于范围的for循环

6.3.1 从传统到现代

在C++98/03中,遍历一个数组或容器通常需要这样写:

int arr[]={1,2,3,4,5};for(int i =0; i <5;++i){ std::cout << arr[i]<<" ";} std::vector<int> vec ={1,2,3,4,5};for(std::vector<int>::iterator it = vec.begin(); it != vec.end();++it){ std::cout <<*it <<" ";}

这种写法暴露了太多的实现细节:索引变量、边界条件、迭代器类型、begin/end调用……我们真正关心的其实是“对容器中的每个元素做某事”,而不是这些琐碎的“如何做”。

C++11引入了基于范围的for循环,让我们能够用更简洁、更安全的方式表达遍历的意图。

6.3.2 基本语法
// 06_range_for.cpp#include<iostream>#include<vector>intmain(){// 遍历数组int arr[]={1,2,3,4,5}; std::cout <<"数组遍历(只读): ";for(int val : arr){// val是arr中每个元素的拷贝 std::cout << val <<" ";} std::cout << std::endl;// 修改数组元素(需要引用) std::cout <<"数组遍历(修改): ";for(int& val : arr){// val是arr中每个元素的引用 val *=2; std::cout << val <<" ";} std::cout << std::endl;// STL容器 std::vector<std::string> names ={"Alice","Bob","Charlie"}; std::cout <<"姓名列表: ";for(constauto& name : names){// const引用:高效且只读 std::cout << name <<" ";} std::cout << std::endl;return0;}

语法格式for (元素类型 变量名 : 容器/数组) { 循环体 }

6.3.3 使用技巧与注意事项

1. 元素类型的选择

声明方式特点适用场景
int val值拷贝,不修改原容器元素类型简单,且无需修改时
int& val引用,可修改原容器需要修改容器元素时
const int& valconst引用,避免拷贝元素类型较大,且只读时
auto&& val万能引用泛型代码中,自动匹配值类别

2. 容器必须支持迭代语义

基于范围的for循环本质上会被编译器转换为类似这样的代码:

// for (auto&& var : container) { body; }// 等价于:auto&& __range = container;for(auto __begin =begin(__range), __end =end(__range); __begin != __end;++__begin){auto&& __var =*__begin;// body中使用var}

因此,被遍历的对象必须支持begin()end(),或者对于数组,必须有确定的边界。

3. 数组参数的陷阱

voidbadFunction(int arr[]){// 这里的arr实际上退化为指针for(auto val : arr){// 编译错误!arr没有begin/end,大小未知 std::cout << val <<" ";}}

解决方案:

  1. 使用模板推导数组大小:
template<size_t N>voidgoodFunction(int(&arr)[N]){for(auto val : arr){// 现在可以了 std::cout << val <<" ";}}
  1. 使用std::vector等标准容器(后续讲解)

4. 遍历map

**注意:**关于map等容器后续讲解。

std::map<std::string,int> scores ={{"Alice",95},{"Bob",87}};for(constauto& kv : scores){// kv是std::pair<const std::string, int> std::cout << kv.first <<": "<< kv.second << std::endl;}// C++17起,可以使用结构化绑定进一步简化for(constauto&[name, score]: scores){ std::cout << name <<": "<< score << std::endl;}

5. 优缺点

优点:

  • 简洁性:代码更少,意图更明确
  • 安全性:自动处理边界,避免越界错误
  • 可读性:对不熟悉C++的人也更友好

缺点:

  • 不支持逆向遍历:如需反向,需用reverse_iterator或传统循环
  • 无法获取索引:如需要元素索引,仍需传统循环
  • 遍历中删除元素困难:因为迭代器管理是隐式的

6.4 nullptr

6.4.1 NULL的困境:一个整数伪装的空指针

在C++98/03中,我们用NULL0表示空指针。但NULL通常被定义为0(一个整数常量),这在某些场景下会导致严重问题。

// 06_nullptr.cpp#include<iostream>voidhandle(int* ptr){ std::cout <<"处理指针,指向的值: "<<(ptr ?*ptr :0)<< std::endl;}voidhandle(int value){ std::cout <<"处理整数: "<< value << std::endl;}intmain(){handle(0);// 调用 handle(int)handle(NULL);// 如果NULL被定义为0,也调用 handle(int) —— 通常不是程序员期望的// handle(NULL); // 结果调用了handle(int),这很可能不是我们想要的!handle(nullptr);// C++11: 完美调用 handle(int*)return0;}

问题的根源在于:NULL本质上是整数0,而不是真正的指针类型。当编译器进行重载解析时,它会选择最匹配的版本——对于整数0,自然匹配int版本。

6.4.2 nullptr的诞生

C++11引入了nullptr关键字,代表一个指针空值常量。它有两大核心特点:

  1. 可以隐式转换为任何类型的指针
  2. 不能转换为任何非指针类型(如整数)

这就完美解决了NULL的二义性问题。

6.4.3 nullptr的底层原理

nullptr本身有一个确定的类型:std::nullptr_t。这个类型定义在<cstddef>头文件中(但使用nullptr时无需包含特定头文件,它是语言内置的关键字)。

**注意:**关键字可以有类型,这是语言设计中的常见现象——关键字只是语法层面的标记,它们所代表的实体依然属于类型系统的一部分。nullptr 是一个关键字,但它同时也是 C++ 的一个字面常量,因此必然有类型。

nullptr 并不是唯一有类型的关键字。例如:

  • truefalse 的类型是 bool
  • this 的类型是指向当前类的指针(ClassName* const)。
  • typeid 运算符的结果是 std::type_info 类型的引用。
// nullptr_t的典型定义(标准库实现可能不同,但语义相同)typedefdecltype(nullptr) nullptr_t;

为什么要这样做?

  • 类型安全:C++ 需要一种方式来表示“空指针”的类型,而不是简单地用 0 或 NULL(它们本质是整数)。nullptr_t 就是专门用于表示空指针常量的类型。
  • 重载和模板:有了 nullptr_t 类型,我们可以编写专门接受空指针的函数重载,或者在模板中特化处理空指针。例如:
voidf(int* p){/* ... */}voidf(std::nullptr_t){/* 专门处理空指针 */}
  • 类型推导:当我们将 nullptr 传递给模板时,编译器可以推导出它的精确类型 std::nullptr_t,从而做出正确的决策。

nullptr_t具有以下特性:

  1. 可以隐式转换为任何指针类型
int* p1 =nullptr;char* p2 =nullptr;void* p3 =nullptr;
  1. 不能隐式转换为非指针类型
// int a = nullptr; // 错误!不能转换// bool b = nullptr; // 注意:在条件表达式中,nullptr可以隐式转换为bool(false),但不能用于初始化bool变量
  1. 只能与指针类型或nullptr_t类型比较
if(nullptr==nullptr){}// 正确if(nullptr==0){}// 在某些编译器上可能编译,但语义上不建议
  1. 在关系运算中,nullptr表现得像一个空指针
int* p =nullptr;if(!p){}// p为nullptr,条件为真
6.4.4 nullptr在模板编程中的优势

在模板编程中,nullptr的优势更加明显,因为它是一个类型安全的空指针表示。

#include<iostream>#include<type_traits>template<typenameT>voidprocess(T ptr){ifconstexpr(std::is_pointer_v<T>){ std::cout <<"处理指针"<< std::endl;}else{ std::cout <<"处理非指针"<< std::endl;}}intmain(){process(0);// T推导为int,输出"处理非指针"process(NULL);// T推导为int(如果NULL是0),输出"处理非指针"process(nullptr);// T推导为std::nullptr_t,但nullptr_t可以转换为指针,在指针相关上下文中表现正确return0;}
6.4.5 函数重载与nullptr

可以为nullptr_t类型专门提供重载版本,以精确处理空指针情况:

#include<iostream>#include<cstddef>// 虽然nullptr不需要,但nullptr_t需要包含此头文件才能使用voidf(int*){ std::cout <<"f(int*)"<< std::endl;}voidf(double*){ std::cout <<"f(double*)"<< std::endl;}voidf(std::nullptr_t){ std::cout <<"f(nullptr)"<< std::endl;}intmain(){int* p =nullptr;f(p);// 调用f(int*)f(nullptr);// 调用f(std::nullptr_t) —— 专门处理空指针return0;}
6.4.6 最佳实践
  • 始终使用nullptr:在C++11及以后的代码中,始终使用**nullptr**来表示空指针,告别NULL0
  • 头文件包含:使用nullptr本身不需要头文件,但如果需要显式使用std::nullptr_t类型,需包含<cstddef>
  • 条件判断if (ptr == nullptr)if (!ptr)都是有效的写法,后者更简洁,前者更明确。

6.5 decltype关键字

6.5.1 decltype的使命

如果说auto是“让编译器帮我推导变量的类型”,那么decltype的使命则是:获取任意表达式的类型,并在编译期返回这个类型。它不会计算表达式,只是“审视”并返回其类型。

decltype是一个编译时操作,不会产生任何运行时开销。

6.5.2 decltype的基本规则

decltype的推导规则比auto稍微复杂一些,可以概括为三条:

  1. 如果表达式e是一个未加括号的变量名或类成员访问,那么decltype(e)就是该变量或成员被声明的类型。
  2. 否则,如果表达式e是一个左值,那么decltype(e)T&,其中Te的类型。
  3. 否则(即e是一个右值)decltype(e)T
#include<iostream>#include<typeinfo>intmain(){int x =0;constint cx =0;int& rx = x;int* px =&x;// 规则1:未加括号的变量名decltype(x) a;// a 是 intdecltype(cx) b =0;// b 是 const int,必须初始化decltype(rx) c = x;// c 是 int&,必须初始化decltype(px) d;// d 是 int*// 规则2:左值表达式decltype((x)) e = x;// (x) 是左值表达式,e 是 int&decltype(x +0) f;// x+0 是右值(临时对象),f 是 int(规则3)// 重点:decltype((variable)) 总是返回引用!// 数组int arr[10];decltype(arr) g;// g 是 int[10]decltype((arr)) h = arr;// h 是 int(&)[10]return0;}

关键洞察decltype((x))decltype(x)的区别至关重要。多加一层括号,表达式就从“变量名”变成了“左值表达式”(在编译器眼里加了括号相当于进行了运算,(x)和*(&x)在编译器眼里是同一个类型,结果是左值的表达式),根据规则2,会推导出引用类型。

6.5.3 decltype vs auto:一张表看懂区别
场景autodecltype
顶层const按值推导时忽略保留
引用按值推导时忽略保留
数组退化为指针保留数组类型
函数退化为函数指针保留函数类型
表达式按值规则推导区分值类别(左值/右值)
用途变量类型推导获取类型用于其他声明

示例对比

constint ci =10;int& ri = x;auto a1 = ci;// intdecltype(ci) a2 = ci;// const intauto b1 = ri;// intdecltype(ri) b2 = ri;// int&int arr[5];auto c1 = arr;// int*decltype(arr) c2;// int[5]
6.5.4 decltype的主要应用场景

场景一:声明依赖于模板参数的类型

在模板编程中,有时我们无法预知某个表达式的确切类型,但需要声明该类型的变量。

template<typenameContainer>voidprocess(Container& c){// 需要声明一个变量,其类型与容器元素相同decltype(c.front()) element = c.front();// 获取容器第一个元素的类型// 处理element...}

场景二:函数返回值类型推导(C++11尾置返回类型)

在C++11中,函数的返回值类型有时依赖于参数的类型,此时可以使用尾置返回类型,结合decltype进行推导。

template<typenameT,typenameU>autoadd(T t, U u)->decltype(t + u){return t + u;}// 如果T是int,U是double,则decltype(t+u)推导为double,函数返回double

场景三:与auto结合:decltype(auto)(C++14)

C++14引入了decltype(auto),它告诉编译器:使用decltype的规则来推导auto的类型。这在需要完美保留表达式类型的场景中非常有用。

int x =10;constint& cx = x;auto a = cx;// a是int(const和引用被丢弃)decltype(auto) b = cx;// b是const int&(完美保留)// 在函数返回类型中的应用template<typenameContainer>decltype(auto)getElement(Container& c, size_t index){return c[index];// 如果c[index]返回引用,则函数返回引用}
6.5.5 应用示例:实现一个通用的最大值函数
#include<iostream>// 使用decltype处理返回类型template<typenameT,typenameU>automaxValue(const T& a,const U& b)->decltype(a > b ? a : b){return a > b ? a : b;}intmain(){int i =42;double d =3.14;auto result1 =maxValue(i, d);// result1是doubleauto result2 =maxValue(d, i);// result2是double std::cout << result1 << std::endl;// 输出42 std::cout << result2 << std::endl;// 输出42return0;}

6.6 const常量(也称常变量)

在C语言中,我们常用#define来定义常量,例如:

#defineMAX_SIZE100#definePI3.14159

宏在预处理阶段进行简单的文本替换,虽然方便,但存在诸多问题。C++引入了const常量,作为宏的替代品,它既保留了宏的“替换”效率,又增加了类型安全和作用域控制。

6.6.1 宏定义的缺陷
  1. 没有类型检查:宏只是文本替换,编译器看不到类型信息,无法进行类型检查。
#defineMAX100char c = MAX;// 可能发生截断,但编译器不会警告
  1. 没有作用域:宏一旦定义,从定义点到文件结束都有效,容易污染命名空间。
#definePI3.14voidfunc(){double PI =3.14159;// 错误!PI被宏展开,编译失败}
  1. 难以调试:宏在预处理阶段被替换,调试器中看不到宏的名字,只能看到替换后的值。
  2. 可能引起歧义:复杂的宏展开可能因为优先级问题导致意料之外的结果。
#defineSQUARE(x) x * xint y =SQUARE(2+3);// 被替换为 2 + 3 * 2 + 3 = 11,而不是25
6.6.2 const常量的优势

C++的const关键字可以定义具有类型的常量,完美解决了宏的缺陷。

constint MAX_SIZE =100;// 整数常量constdouble PI =3.14159;// 浮点常量constchar NEWLINE ='\n';// 字符常量const std::string GREETING ="Hello";// 字符串常量(需包含<string>)

优点

  • 类型安全const常量有明确的类型,编译器会进行类型检查。
  • 作用域可控const常量遵循C++的作用域规则,可以在函数内、类内、命名空间内定义,不会污染全局。
  • 可调试const常量是真正的变量(但不可修改),调试器可以看到它的名字和值。
  • 支持复杂类型:可以定义数组、结构体等类型的常量。
6.6.3 编译器的优化:常量折叠

虽然const常量是变量,但编译器通常会对它们进行优化,在编译阶段直接将常量值替换到使用处,这个过程称为常量折叠(Constant Folding)。这使得const常量在效率上可以媲美宏。

constint MAX =100;int arr[MAX];// 编译器会将MAX替换为100,不需要在运行时读取内存

在某些情况下,编译器甚至不会为const常量分配存储空间,而是将其直接嵌入到指令中,就像宏一样。但和宏不同的是,这种替换是有类型检查的,更安全。

6.6.4 使用场景示例

场景一:定义数组大小

constint ARRAY_SIZE =10;int numbers[ARRAY_SIZE];// 编译期常量,可用于定义数组

场景二:避免魔法数字

constdouble TAX_RATE =0.06;double price =100.0;double tax = price * TAX_RATE;// 比直接写 0.06 更清晰

场景三:与枚举配合

constint MONDAY =0;constint TUESDAY =1;// 但更推荐使用enum,这里仅作示例

6.7 统一初始化

在深入C++11的新特性时,有一个看似简单却影响深远的改进——统一初始化(Uniform Initialization),也称为列表初始化(List Initialization)。它允许使用统一的 {} 语法来初始化任何类型的对象,无论是内置类型、数组、结构体,还是自定义的类对象。这一特性让C++的初始化语法变得前所未有的简洁和一致。

6.7.1 为什么需要统一初始化?

在C++11之前,初始化对象的方式可谓“百花齐放”,不同的类型需要不同的初始化语法,这不仅增加了学习成本,还带来了不少隐患。

// C++98/03中的各种初始化方式int a =10;// 赋值初始化intb(20);// 直接初始化int arr1[]={1,2,3,4,5};// 数组初始化语法(只有聚合类型可用)structPoint{int x, y;}; Point p1 ={1,2};// 聚合初始化 Point p2(1,2);// 构造函数初始化(需要定义构造函数)// 动态分配的数组无法在创建时初始化内容int* pArr =newint[3]; pArr[0]=1; pArr[1]=2; pArr[2]=3;// 需要手动赋值

这种不一致性带来了几个问题:

  1. 语法混乱:初学者需要记住不同场景下的初始化规则,增加了学习曲线。
  2. 窄化转换风险:传统初始化允许隐式窄化转换,可能导致数据丢失。
int x =3.14;// 合法!x变为3,但编译器通常只给警告
  1. 最令人烦恼的解析T object(); 本意是调用默认构造函数,却被解析为函数声明。
Widget w();// 这不是创建对象,而是声明了一个返回Widget的函数!

C++11的统一初始化正是为了解决这些问题而诞生的。

6.7.2 统一初始化的基本语法

统一初始化的核心思想很简单:用花括号 {} 包围初始化值,可以初始化任何类型的对象。等号 = 是可选的,加与不加效果相同。

// 6.7_uniform_initialization.cpp#include<iostream>#include<vector>#include<map>structPoint{int x, y;};classDate{public:Date(int year,int month,int day):y(year),m(month),d(day){ std::cout <<"Date constructed: "<< year <<"-"<< month <<"-"<< day << std::endl;}private:int y, m, d;};intmain(){// 1. 内置类型int a{10};// 直接列表初始化int b ={20};// 拷贝列表初始化(效果相同)int c{};// 值初始化为0double d{3.14}; std::cout <<"a = "<< a <<", b = "<< b <<", c = "<< c <<", d = "<< d << std::endl;// 2. 数组int arr1[]{1,2,3,4,5};// 自动推导大小int arr2[5]{1,2,3};// 指定大小,剩余元素初始化为0int arr3[3]{};// 全部初始化为0// 3. 结构体/聚合类型 Point p1{10,20};// 聚合初始化 Point p2{};// 值初始化为{0, 0}// 4. 类对象(调用构造函数) Date today{2026,3,12};// 调用Date(int, int, int) Date tomorrow ={2026,3,13};// 同样调用构造函数// 5. STL容器(利用std::initializer_list) std::vector<int> vec{1,2,3,4,5}; std::map<std::string,int> score ={{"Alice",95},{"Bob",87}};// 6. 动态内存分配int* pInt =newint{100};int* pArr =newint[5]{1,2,3,4,5}; Point* pPoint =new Point{1,2};delete pInt;delete[] pArr;delete pPoint;return0;}

关键点

  • 统一性:无论什么类型,都可以用 {} 初始化。
  • 值初始化:空花括号 {} 会将对象初始化为默认值(内置类型为0,类类型调用默认构造函数)。
  • 等号可选T obj{args};T obj = {args}; 在语义上等价,都执行列表初始化。
6.7.3 防止窄化转换

统一初始化最重要的特性之一就是禁止窄化转换(Narrowing Conversion)。所谓窄化转换,是指那些可能导致数据丢失或精度降低的隐式类型转换,例如从浮点数到整数、从 longint、从大范围整数到小范围整数等。

// 6.7_narrowing.cppintmain(){// 传统初始化:允许窄化转换(通常只产生警告)int x1 =3.14;// 合法,x1 = 3(但编译器可能警告)intx2(3.14);// 合法,x2 = 3// 统一初始化:禁止窄化转换!// int x3{3.14}; // 编译错误!double到int的窄化被禁止// int x4 = {3.14}; // 同样错误// 其他窄化示例double d =1e70;// int i{d}; // 错误!超出int范围long l =1000;// char c{l}; // 错误!long可能无法放入charreturn0;}

为什么只有列表初始化禁止窄化? 这是C++标准的有意设计。传统初始化方式允许窄化是为了兼容大量遗留代码,而列表初始化作为新特性,从一开始就坚持类型安全的原则。当发生窄化时,编译器必须给出诊断(通常是错误),而不是仅仅警告。

如果需要明确进行窄化转换,可以用显式类型转换:

int x{static_cast<int>(3.14)};// 明确意图,编译通过//static_cast<int>可以理解为(int)强转,后续会详细讲解
6.7.4 解决“最令人烦恼的解析”

统一初始化还优雅地解决了C++中一个经典的语法歧义问题——“最令人烦恼的解析”

classWidget{};intmain(){ Widget w1();// 这是函数声明,不是创建对象! Widget w2{};// 明确创建Widget对象(调用默认构造函数)// 另一个例子 std::vector<int>v1(5,0);// 创建包含5个0的vector std::vector<int>v2();// 函数声明! std::vector<int> v3{};// 创建空vector}

C++有一条规则:任何可以被解析为声明的东西,都会被解析为声明。因此 Widget w1(); 被理解为“返回Widget、无参数的函数声明”,而不是创建对象。使用花括号 Widget w1{}; 则没有歧义,明确表示创建对象。

6.7.5 统一初始化的适用范围与限制

适用范围

统一初始化几乎可以用于任何场景:

  • 内置类型int x{5}; double d{3.14};
  • 数组int arr[]{1,2,3};
  • 聚合类型(所有成员public、没有用户定义的构造函数、没有基类等)
  • 类类型:调用相应的构造函数
  • STL容器:利用 std::initializer_list
  • 动态内存new int[]{1,2,3};
    注意std::initializer_list可以先理解为一个特殊的数组,<>内的类型是其元素的类型,后续会详细讲解。

限制与注意事项

  1. 聚合类型的条件:如果类的成员是 privateprotected,或者类有用户提供的构造函数,或者有虚函数,则不是聚合类型,不能使用聚合初始化。
classNonAggregate{private:int x;// 私有成员 -> 不是聚合public:int y;};// NonAggregate na{1, 2}; // 错误!不能聚合初始化
  1. 构造函数重载的优先级:如果一个类同时有接受 std::initializer_list 的构造函数和其他构造函数,编译器会强烈优先选择initializer_list 版本,即使其他版本看起来更匹配。
classWidget{public:Widget(int i,bool b){ std::cout <<"Widget(int, bool)"<< std::endl;}Widget(std::initializer_list<bool> il){ std::cout <<"Widget(initializer_list<bool>)"<< std::endl;}};intmain(){ Widget w1{10,true};// 输出:Widget(initializer_list<bool>)!// 为什么?10被隐式转换为bool(非0为true),匹配initializer_list版本// Widget w2{10, 3.14}; // 错误!bool不能从double转换(窄化)return0;}

这个优先级规则有时会导致意想不到的结果,使用时需注意。

  1. 空花括号的歧义:空花括号 {} 会调用默认构造函数,而不是空的 initializer_list 构造函数。
classWidget{public:Widget(){ std::cout <<"Default constructor"<< std::endl;}Widget(std::initializer_list<int>){ std::cout <<"initializer_list constructor"<< std::endl;}};intmain(){ Widget w1{};// 输出:Default constructor Widget w2({});// 输出:initializer_list constructor(传递空列表)return0;}
  1. auto的类型推导:用 auto 和花括号初始化时,可能会推导出 std::initializer_list
auto a ={1,2,3};// a 的类型是 std::initializer_list<int>// auto b{1, 2, 3}; // 在C++17中,直接列表初始化不允许多个元素auto c{4};// c 的类型是 int(C++17规则)
6.7.6 统一初始化与动态内存分配

C++11还允许在 new 表达式(下一章讲解)中使用列表初始化,这填补了之前无法直接初始化动态分配数组的空白。

// 6.7_new_init.cpp#include<iostream>structPoint{int x, y;};intmain(){// 单个对象int* p1 =newint{100};double* p2 =newdouble{3.14159}; Point* p3 =new Point{10,20}; std::cout <<"*p1 = "<<*p1 << std::endl; std::cout <<"*p2 = "<<*p2 << std::endl; std::cout <<"p3->x = "<< p3->x <<", p3->y = "<< p3->y << std::endl;// 数组int* arr1 =newint[5]{1,2,3,4,5};// 完全初始化int* arr2 =newint[5]{1,2};// 前两个初始化,剩余为0int* arr3 =newint[5]{};// 全部初始化为0 Point* points =new Point[3]{{1,2},{3,4},{5,6}};// 对象数组delete p1;delete p2;delete p3;delete[] arr1;delete[] arr2;delete[] arr3;delete[] points;return0;}

这种能力在需要动态分配并立即初始化对象的场景中非常有用,避免了先分配再手动赋值的繁琐步骤。

6.8 枚举类

在C语言和早期的C++中,枚举(enum)一直是个“二等公民”。它虽然提供了一种定义命名常量的便捷方式,但长期以来存在着诸多设计缺陷。C++11引入了枚举类enum class),也称为强类型枚举(strong-typed enum)或作用域枚举(scoped enumeration),从根本上解决了传统枚举的顽疾。这一特性与autonullptr、统一初始化等一样,都是C++迈向“更现代、更安全”的重要一步。

6.8.1 传统枚举的三大缺陷

在理解枚举类的价值之前,我们先看看传统枚举(C++98/03中的enum)存在哪些问题。

缺陷一:作用域污染

传统枚举的成员会被“导出”到枚举定义所在的上一层作用域中,这会导致严重的命名冲突问题。

#include<iostream>// 定义一个颜色枚举enumColor{ RED, GREEN, BLUE };// 试图定义另一个包含RED的枚举——————————编译错误!enumStatus{ RED, YELLOW, GREEN };// 错误!RED、GREEN重定义intmain(){int color = RED;// 可以直接访问,不需要Color::前缀return0;}

在上面的例子中,Color枚举的REDGREEN已经“污染”了全局作用域,导致Status枚举无法再定义同名的枚举成员。为了解决这个问题,开发者不得不采用冗长的命名前缀(如COLOR_REDSTATUS_RED),或者用命名空间、结构体来模拟作用域隔离:

// C++98/03时代的变通方案:用命名空间封装namespace Color {enumType{ RED, GREEN, BLUE };}namespace Status {enumType{ RED, YELLOW, GREEN };}intmain(){ Color::Type c = Color::RED; Status::Type s = Status::RED;return0;}

但这终归是“曲线救国”,而且命名空间是开放的,仍然存在被意外扩充的风险。

缺陷二:隐式整数转换

传统枚举的成员会隐式转换为整数类型,这在某些场景下会引发意料之外的错误。

enumColor{ RED, GREEN, BLUE };enumFruit{ APPLE, BANANA, ORANGE };intmain(){ Color c = RED; Fruit f = BANANA;// 不同枚举类型可以直接比较,这本应禁止!if(c == f){ std::cout <<"颜色和水果相等?"<< std::endl;// 居然输出这句话!}int value = c;// 隐式转换为0int number = GREEN +10;// 枚举值直接参与算术运算return0;}

由于REDBANANA默认都是0,这段代码会输出“颜色和水果相等?”。这种错误在编译期完全无法察觉,只有在运行时才会暴露,而且往往难以调试。枚举值可以随意参与整数运算,也让类型系统形同虚设。

缺陷三:底层类型不确定

传统枚举的底层存储类型由编译器自行决定,这带来了跨平台和内存布局的不确定性。

enumColor{ RED, GREEN, BLUE };enumBigEnum{ SMALL =1, LARGE =0xFFFFFFFFFFFFFFFF};// 超出int范围intmain(){ std::cout <<sizeof(Color)<< std::endl;// 在不同编译器上结果可能不同 std::cout <<sizeof(BigEnum)<< std::endl;// 可能为4或8,取决于编译器return0;}

C++标准只规定枚举的底层类型必须是能容纳所有枚举值的整数类型,但具体是intunsigned int还是更大的类型,由编译器“自由发挥”。这导致:

  • 无法前向声明:因为不知道枚举的大小,编译器无法确定类型,所以传统枚举不能前向声明。
  • 内存布局不可控:在结构体中对齐和填充时,无法精确控制枚举成员占用的空间。
  • 兼容性问题:不同编译器甚至同一编译器的不同版本,可能对同一枚举采用不同的底层类型。
6.8.2 枚举类的解决方案

针对以上三大缺陷,C++11引入了枚举类enum class)。它从根本上重构了枚举的语义,带来了类型安全、作用域隔离和底层类型可控三大改进。

定义与基本使用

// 6.8_enum_class.cpp#include<iostream>// 定义枚举类enumclassColor{ RED,// 默认值为0 GREEN,// 默认值为1 BLUE // 默认值为2};enumclassStatus{ RED,// 可以和Color中的RED共存 YELLOW, GREEN };intmain(){// 必须通过作用域访问,前缀不可省略 Color c = Color::RED; Status s = Status::RED;// Color c2 = RED; // 错误!RED不在当前作用域// if (c == s) { } // 错误!不同类型不能比较// 需要显示转换为整数int value =static_cast<int>(c);// value = 0 std::cout <<"Color value: "<< value << std::endl;return0;}

枚举类完美解决了传统枚举的三个问题:

问题传统enum枚举类enum class
作用域成员暴露到外层作用域成员限定在类作用域内,必须用类型::成员访问
类型安全隐式转换为整数禁止隐式转换,需要显式static_cast
底层类型由编译器决定,不确定可显式指定,默认为int

指定底层类型

枚举类允许我们显式指定底层存储类型,语法为enum class 名字 : 类型。可用的类型包括除wchar_t以外的任何整型(如charshortintlonglong long及其无符号版本)。

// 6.8_underlying_type.cpp#include<iostream>// 指定底层类型为unsigned charenumclassPermission:unsignedchar{ NONE =0, READ =1, WRITE =2, EXECUTE =4};// 指定底层类型为long longenumclassBigEnum:longlong{ VALUE1 =0x100000000,// 超出int范围的值 VALUE2 =0x200000000};intmain(){ std::cout <<"sizeof(Permission) = "<<sizeof(Permission)<< std::endl;// 1 std::cout <<"sizeof(BigEnum) = "<<sizeof(BigEnum)<< std::endl;// 8// 底层类型的显式转换unsignedchar raw =static_cast<unsignedchar>(Permission::READ); std::cout <<"raw value: "<<(int)raw << std::endl;// 输出1return0;}

这一特性带来了诸多好处:

  • 内存优化:可以用最小的类型存储枚举值,节省内存(尤其是在数组或结构体中)。
  • 可预测的内存布局:枚举的大小确定,便于与外部接口(如网络协议、硬件寄存器)交互。
  • 前向声明:指定底层类型后,枚举就可以进行前向声明了。
// 前向声明enumclassErrorCode:short;// 告知编译器ErrorCode占2字节// 在其他地方定义enumclassErrorCode:short{ SUCCESS =0, NOT_FOUND =-1, ACCESS_DENIED =-2};

显式转换与整数运算

枚举类禁止隐式转换到整数,但允许显式转换。这意味着当你确实需要将枚举值作为整数使用时,必须明确表达你的意图。

enumclassFlag{ READ =1, WRITE =2, EXECUTE =4};intmain(){ Flag f = Flag::READ;// int x = f; // 错误!禁止隐式转换int x =static_cast<int>(f);// 正确:显式转换,这种转换后续会详细介绍,可以暂时理解为强转// 位运算时需要转换到底层类型int combined =static_cast<int>(Flag::READ)|static_cast<int>(Flag::WRITE);// combined = 3// 从整数构造枚举值也需要显式转换 Flag f2 =static_cast<Flag>(combined &static_cast<int>(Flag::READ));return0;}

这种设计遵循了C++11强化类型安全的一贯理念:宁可多写几行代码,也不让潜在的错误悄悄溜走

enum struct 与 enum class 的关系

你可能还会看到 enum struct 的写法。事实上,enum structenum class 在语法和语义上完全等价,没有任何区别。你可以根据个人喜好选择使用哪个关键字。通常 enum class 更常见,因为它更明确地表达了“这是一个类枚举”的含义。

enumstructColor{ RED, GREEN, BLUE };// 等价于 enum class Color
6.8.3 枚举类的实际应用场景

枚举类在现代C++中有着广泛的应用,以下几个典型场景充分体现了它的价值。

场景一:状态机设计

枚举类是定义状态机的理想选择,它让状态转移更加清晰和安全。

enumclassConnectionState{ DISCONNECTED, CONNECTING, CONNECTED, ERROR };classConnection{private: ConnectionState state = ConnectionState::DISCONNECTED;public:voidconnect(){if(state == ConnectionState::DISCONNECTED){// 执行连接操作 state = ConnectionState::CONNECTING;}// 其他状态不允许连接}voidonConnected(){if(state == ConnectionState::CONNECTING){ state = ConnectionState::CONNECTED;}}};

场景二:函数参数限定

用枚举类作为函数参数,可以明确限定调用者只能传入预定义的选项。

enumclassLogLevel{ DEBUG, INFO, WARNING, ERROR };voidlog(LogLevel level,const std::string& message){// 实现日志输出}intmain(){log(LogLevel::INFO,"程序启动");// 正确// log(2, "程序启动"); // 错误!不能传入整数return0;}

场景三:位标志组合

当需要将枚举值用作位标志时,可以结合底层类型和显式转换实现类型安全的位操作。

enumclassFileAccess:unsignedint{ NONE =0, READ =1<<0,// 1 WRITE =1<<1,// 2 EXECUTE =1<<2// 4};// 定义位操作运算符(在类外重载) FileAccess operator|(FileAccess a, FileAccess b){returnstatic_cast<FileAccess>(static_cast<unsignedint>(a)|static_cast<unsignedint>(b));}boolhasFlag(FileAccess value, FileAccess flag){return(static_cast<unsignedint>(value)&static_cast<unsignedint>(flag))!=0;}intmain(){ FileAccess access = FileAccess::READ | FileAccess::WRITE;if(hasFlag(access, FileAccess::READ)){ std::cout <<"有读权限"<< std::endl;}return0;}
6.8.4 C++11对传统枚举的兼容性改进

为了与枚举类保持一定的一致性,C++11也对传统枚举做了一些扩展,让它们能“沾点光”。

  • 可以指定底层类型:传统枚举现在也可以指定底层类型。
enumColor:char{ RED, GREEN, BLUE };// 指定底层类型为char
  • 支持类作用域访问:传统枚举的成员现在也可以通过类型名访问了(尽管仍可以直接访问)。
enumColor{ RED, GREEN }; Color c1 = RED;// 仍然可以 Color c2 = Color::GREEN;// 现在也可以这样写!

但这些改进并未改变传统枚举的本质问题:它们仍然会导出成员到外层作用域,仍然可以隐式转换为整数。因此,在编写新代码时,应当优先使用枚举类。Bjarne Stroustrup在《C++程序设计:原理与实践》中也明确建议:“我们倾向于使用更简单、更安全的作用域枚举类型,少用平坦枚举类型”。

第七章:动态内存管理

在C语言中,我们使用malloccallocreallocfree来管理动态内存。这些函数虽然灵活,但使用起来颇为繁琐:需要手动计算字节数、强制类型转换、检查返回值是否为NULL,而且无法自动调用构造函数和析构函数。C++作为C语言的“威力加强版”,自然要解决这些问题。它引入了两个新的运算符——newdelete,让动态内存管理变得更简洁、更安全、更强大。

7.1 进程的虚拟地址空间

在深入动态内存之前,我们先了解一下程序运行时内存的布局。这就像你要管理一个仓库,得先知道仓库里有哪些区域、每个区域是干什么用的。

栈区:由编译器自动管理,存放函数的参数值、局部变量等。它的分配和释放是自动的,就像你走进一个房间,离开时房间会自动清理——你不需要操心。栈是向下增长的(向低地址方向)。

堆区:由程序员手动管理,通过new/deletemalloc/free进行分配和释放。堆的生存期由程序员决定,给了我们更大的灵活性。堆是向上增长的(向高地址方向)。你可以把堆想象成一个自助仓储区,你需要时租一个格子,用完后自己退租——如果你忘了退租,这个格子就会一直被占用(内存泄漏)。

7.2 回顾C语言的动态内存管理

// 07_malloc.cpp#include<cstdlib>// malloc/free 需要这个头文件#include<iostream>intmain(){int n =5;// malloc:分配未初始化的内存int* p1 =(int*)malloc(n *sizeof(int));if(p1 ==nullptr){ std::cout <<"内存分配失败"<< std::endl;return1;}// calloc:分配并初始化为0int* p2 =(int*)calloc(n,sizeof(int));// realloc:调整内存大小int* p3 =(int*)realloc(p1,10*sizeof(int));if(p3 !=nullptr){ p1 = p3;// 重新赋值}// 使用内存...for(int i =0; i <10;++i){ p1[i]= i;}// 释放内存free(p1);free(p2);return0;}

C语言动态内存的缺点

  • 需要手动计算字节数(sizeof(int) * n),容易算错
  • 返回void*,需要强制类型转换
  • 无法自动调用构造函数/析构函数(对于C语言这不是问题,但对于C++的类对象就是大问题)
  • 内存分配失败返回NULL,需要手动检查

7.3 C++的new/delete运算符

C++引入了newdelete运算符,提供了更简洁、更安全的动态内存管理。

7.3.1 new/delete的基本用法
// 07_new_delete.cpp#include<iostream>intmain(){// 1. 分配单个对象int* p1 =newint;// 分配,但未初始化(值不确定)int* p2 =newint();// 分配并初始化为0int* p3 =newint(100);// 分配并初始化为100 std::cout <<"*p1 = "<<*p1 << std::endl;// 可能随机值 std::cout <<"*p2 = "<<*p2 << std::endl;// 0 std::cout <<"*p3 = "<<*p3 << std::endl;// 100delete p1;// 释放单个对象delete p2;delete p3;// 2. 分配数组int n =5;int* arr1 =newint[n];// 分配数组,未初始化int* arr2 =newint[n]();// 分配数组,所有元素初始化为0int* arr3 =newint[n]{1,2,3,4,5};// 分配并列表初始化(C++11起)for(int i =0; i < n;++i){ std::cout << arr3[i]<<" ";} std::cout << std::endl;delete[] arr1;// 释放数组必须用 delete[]delete[] arr2;delete[] arr3;return0;}

关键点

  • new返回的是确切类型的指针,无需强制转换
  • 分配数组必须用delete[]释放,单个对象用delete
  • 内存分配失败时,new默认会抛出异常(std::bad_alloc),而不是返回NULL
7.3.2 初始化形式的详细说明

new提供了多种初始化方式,理解它们的区别很重要:

int* p1 =newint;// 默认初始化:内置类型的话,值未定义(随机值)int* p2 =newint();// 值初始化:内置类型会被初始化为0int* p3 =newint(42);// 直接初始化:初始化为42int* p4 =newint{};// C++11列表初始化:等价于new int()

对于数组:

int* arr1 =newint[5];// 默认初始化:每个元素都是随机值int* arr2 =newint[5]();// 值初始化:所有元素初始化为0int* arr3 =newint[5]{1,2,3,4,5};// 列表初始化(C++11起)
7.3.3 nothrow版本

如果你不希望new在分配失败时抛出异常(后续章节讲解),可以使用nothrow版本,这样分配失败时会返回nullptr,就像malloc一样。

#include<iostream>#include<new>// 需要包含这个头文件intmain(){// 尝试分配一个非常大的内存块int* p =new(std::nothrow)int[1000000000];if(p ==nullptr){ std::cout <<"内存分配失败!"<< std::endl;}else{ std::cout <<"内存分配成功!"<< std::endl;delete[] p;}return0;}

这种形式在某些场景下很有用,比如在实时系统中不希望引入异常处理的开销。

7.4 new/delete vs malloc/free 深度对比

特性new/deletemalloc/free说明
本质C++运算符C标准库函数new是语言的一部分,malloc是函数
头文件无需额外头文件(但nothrow需要<new><cstdlib><stdlib.h>
大小计算编译器自动计算需手动计算new知道类型大小,malloc只认字节数
返回值类型安全的指针void*,需强制转换new返回的指针类型正确,无需强转
初始化可调用构造函数/初始化不初始化new可以初始化内存,malloc只分配
失败处理默认抛出异常返回NULLnew也可以使用nothrow版本返回NULL
重载可重载不可重载可以定制特定类的内存分配行为
数组处理有专门的new[]/delete[]需手动计算总大小new[]会记录数组元素个数
内存位置自由存储区(free store)堆(heap)概念上不同,但实现上通常都是堆

7.5 operator new函数:new的底层实现

很多初学者以为new只是一个简单的运算符,其实它背后大有文章。当我们写int* p = new int(42);时,编译器实际上做了两件事:

  1. 调用一个名为**operator new**的函数分配原始内存
  2. 在分配的内存上调用构造函数初始化对象

delete p;也做两件事:

  1. 调用析构函数销毁对象
  2. 调用**operator delete**函数释放内存
7.5.1 operator new函数的原型

C++标准库提供了全局的operator new函数,其基本原型为:

void*operatornew(size_t size);// 分配单个对象void*operatornew[](size_t size);// 分配数组voidoperatordelete(void* ptr);// 释放单个对象voidoperatordelete[](void* ptr);// 释放数组

这些函数是可以被重载的,这也是为什么说new是可重载的而malloc不可重载的原因。

7.5.2 直接调用operator new

虽然不常见,但你可以直接调用operator new来分配原始内存,就像调用malloc一样:

#include<iostream>#include<new>intmain(){// 直接调用operator new分配100字节void* raw_memory =operatornew(100);if(raw_memory){ std::cout <<"分配了100字节的内存,地址:"<< raw_memory << std::endl;// 使用完毕后,调用operator delete释放operatordelete(raw_memory);}return0;}

当然,这样做失去了new运算符自动调用构造函数的优势,所以直接调用operator new的场景比较少见。

7.5.3 为特定类重载operator new

operator new的一个强大之处在于,你可以为特定的类重载它,从而实现自定义的内存分配策略:

#include<iostream>classMyClass{public:int data[100];// 重载operator newstaticvoid*operatornew(size_t size){ std::cout <<"自定义分配: "<< size <<" 字节"<< std::endl;returnmalloc(size);// 实际上还是用malloc,但可以换成其他分配器}// 重载operator deletestaticvoidoperatordelete(void* ptr){ std::cout <<"自定义释放"<< std::endl;free(ptr);}};intmain(){ MyClass* p =newMyClass();// 会调用自定义的operator newdelete p;// 会调用自定义的operator deletereturn0;}

这在实现内存池、对象池等高级技术时非常有用。

7.5.4 带额外参数的operator new(placement new的底层)

operator new还可以接受额外的参数,这就是定位new(placement new)的底层基础。标准库提供了一个特殊版本的operator new,它接受一个指针参数,不做任何分配,直接返回该指针:

// 标准库中的原型(简化)void*operatornew(size_t,void* ptr)noexcept{//定位new的实现是在该位置调用构造函数,并将给定的指针传给this指针return ptr;}

这也就是为什么定位new能在已分配的内存上构造对象。

7.6 定位new

定位new是C++中一个非常强大但相对少用的特性。它的核心思想是:在已经分配好的内存上构造对象,而不是重新分配内存

7.6.1 为什么需要定位new?

想象一下这些场景:

  • 你的程序需要运行在内存受限的嵌入式系统中,不能频繁地进行动态内存分配
  • 你需要实现一个内存池,预先分配一大块内存,然后在这块内存上反复创建和销毁对象
  • 你的硬件有一个内存映射的I/O设备,你想在特定的物理内存地址上放置一个对象
  • 你正在实现一个高性能的缓存系统,希望完全控制对象的内存布局

在这些场景中,定位new就派上了用场。

7.6.2 定位new的基本语法

定位new的语法形式是:new (地址) 类型(初始化参数)

#include<iostream>#include<new>// 使用定位new需要包含这个头文件intmain(){// 1. 在栈上分配原始内存char buffer[sizeof(int)*10];// 足够容纳10个int的原始内存// 2. 在buffer上构造一个int对象int* p =new(buffer)int(42);// 定位new std::cout <<"p指向的值: "<<*p << std::endl; std::cout <<"p的地址: "<< p << std::endl; std::cout <<"buffer的地址: "<<(void*)buffer << std::endl;// 相同地址// 对于基本类型,不需要显式调用析构函数// p->~int(); // 对于类类型才需要// buffer在栈上,函数结束自动释放return0;}
7.6.3 定位new与类对象:必须显式调用析构函数

当使用定位new构造类对象时,有一个非常重要的注意事项:必须显式调用析构函数

#include<iostream>#include<new>classMyClass{public:MyClass(){ std::cout <<"构造函数被调用"<< std::endl;}~MyClass(){ std::cout <<"析构函数被调用"<< std::endl;}voidshow(){ std::cout <<"MyClass对象"<< std::endl;}};intmain(){// 分配原始内存(可以在栈上,也可以在堆上)char buffer[sizeof(MyClass)];// 在buffer上构造MyClass对象 MyClass* p =new(buffer)MyClass();// 调用构造函数 p->show();// 必须显式调用析构函数! p->~MyClass();// 不调用的话,对象不会被正确清理// 原始内存的释放取决于它的来源,这里buffer在栈上,自动释放return0;}

为什么需要显式调用析构函数? 因为定位new“借用”的是别人的内存,这块内存的生命周期不由delete管理。如果你用了delete p;,它会在释放内存前调用析构函数,但问题在于:p指向的内存可能根本就不是从堆上分配的(比如在栈上),调用delete会导致未定义行为!

7.6.4 定位new的注意事项
  1. 对齐问题:定位new要求提供的内存地址满足对象的对齐要求。对于基本类型,通常没问题;但对于自定义类型,尤其是包含SSE指令需要的16字节对齐的类型,必须确保内存地址正确对齐。否则会导致未定义行为。
  2. 异常安全:如果对象的构造函数抛出异常,定位new不会自动释放内存(因为它根本没分配),你需要自己处理这种情况。
  3. 不要混用delete:用定位new构造的对象,绝对不能用delete释放,只能显式调用析构函数。
  4. 包含头文件:使用定位new需要包含<new>头文件。

7.7 new[]和delete[]的内部机制

当你使用new[]分配对象数组时,编译器在背后做了额外的处理,以确保每个对象的构造函数都被正确调用,以及在delete[]时每个对象的析构函数都被正确调用。

7.7.1 额外空间的分配

对于需要调用析构函数的类类型(即非平凡析构函数),new[]会在实际分配的内存块前面额外多分配几个字节,用来存储数组的元素个数。

实际内存布局: ┌─────────────┬─────────────────────────────┐ │ 数组大小(n) │ 实际对象数组区域 │ │ (4字节) │ 对象0 │ 对象1 │ ... │ 对象n-1 │ └─────────────┴─────────────────────────────┘ 返回的指针指向对象0(偏移4字节) 

当你调用delete[]时,它会:

  1. 从指针向前偏移4字节,读取数组大小n
  2. 循环n次,从最后一个对象开始往前依次调用析构函数
  3. 释放整块内存(包括那4字节)
7.7.2 为什么内置类型不需要这个机制

对于内置类型(如intdouble)或没有自定义析构函数的简单类,它们不需要调用析构函数,所以new[]不会多分配这4字节,delete[]delete的行为也完全一样。

int* p =newint[10];delete p;// 对于内置类型,这样也没问题,但强烈不推荐!delete[] p;// 这才是正确写法
7.7.3 为什么必须配对使用

这就是为什么我们反复强调:new搭配deletenew[]搭配delete[],绝对不能混用

classMyClass{public:~MyClass(){}// 自定义析构函数}; MyClass* p =new MyClass[10];// delete p; // 严重错误!只会调用一次析构函数,而且可能释放错误的地址delete[] p;// 正确!调用10次析构函数

如果你用new[]分配,却用delete释放,会发生什么?

  • 如果类有自定义析构函数:只会调用一次析构函数(第一个对象),其他9个对象无法正确清理,可能导致资源泄漏
  • 更糟糕的是:因为delete认为内存块中没有那4字节的计数信息,它会释放错误的起始地址(少偏移了4字节),导致堆内存泄漏

第八章:初探STL与字符串处理

在前七章中,我们学习了C++的核心语法——命名空间、引用、函数重载、动态内存管理等。从本章开始,我们将进入一个更广阔的世界:C++标准模板库(STL)。STL是C++标准库的核心组成部分,它提供了一套通用的数据结构和算法,让我们能够更高效地编写代码。

8.1 初识STL:C++的“万能工具箱”

8.1.1 什么是STL?

STL(Standard Template Library,标准模板库)是C++标准库的一部分,它包含了一系列通用的模板类和函数,用于处理常见的数据结构和算法。你可以把STL想象成一个“万能工具箱”——里面预置了各种常用的容器(如数组、链表、映射)和算法(如排序、查找),你只需要拿来用就行,不用自己从头实现。

STL的设计理念是**“泛型编程”**,它通过模板机制将数据结构和算法解耦,使得同一套算法可以适用于不同的数据类型。比如,sort函数既可以排序整数数组,也可以排序浮点数数组,甚至可以排序自定义类型的数组。

8.1.2 STL的三大核心组件

STL由三个核心部分组成,它们相互配合,构成了整个框架:

  1. 容器(Containers):用于存储数据的“仓库”。比如向量(vector)、列表(list)、映射(map)等。
  2. 算法(Algorithms):用于处理容器中数据的“加工工具”。比如排序(sort)、查找(find)、替换(replace)等。
  3. 迭代器(Iterators):连接容器和算法的“桥梁”。它像一种泛化的指针,让我们能够统一的方式遍历和访问容器中的元素。
一个生活化的类比:容器就像图书馆里的书架,上面摆满了书。迭代器就像图书馆里的索引卡,告诉你书架上的每本书在第几排第几列。算法就像图书管理员,他根据索引卡找到书,然后进行分类、排序、清点等工作。
8.1.3 STL的头文件

STL的功能分布在多个头文件中,常用的有:

头文件内容
<vector>向量容器
<list>列表容器
<map>映射容器
<set>集合容器
<string>字符串(虽然不是容器,但用法类似)
<algorithm>通用算法(排序、查找等)
<iterator>迭代器相关
<functional>函数对象

我们在本章将重点学习<string>,并初步了解迭代器的概念。后续章节会逐一介绍各种容器和算法。

8.2 字符串处理:std::string

在C语言中,字符串是用字符数组表示的,操作起来相当繁琐(需要手动管理内存、处理结尾的\0等)。C++标准库提供的std::string类封装了字符串的各种操作,让我们可以像处理基本类型一样处理字符串。

**注意:**本章只简单介绍常用函数,需详细了解需查阅官网C++参考手册中的字符串库。

8.2.1 包含头文件

使用std::string需要包含头文件<string>

#include<string>
8.2.2 定义与初始化

string有多种初始化方式:

#include<iostream>#include<string>usingnamespace std;// 为了简化,本章使用using namespace stdintmain(){// 方式1:直接初始化(C风格字符串) string s1 ="Hello, World!";// 方式2:直接初始化(构造函数形式) string s2("Hello, C++!");// 方式3:空字符串 string s3;// 空字符串,长度为0// 方式4:拷贝初始化 string s4 = s1;// s4是s1的副本// 方式5:重复字符初始化 string s5(5,'*');// s5 = "*****"// 方式6:统一初始化(C++11起) string s6{"Hello, Modern C++!"}; cout <<"s1 = "<< s1 << endl; cout <<"s2 = "<< s2 << endl; cout <<"s3 = '"<< s3 <<"' (empty)"<< endl; cout <<"s4 = "<< s4 << endl; cout <<"s5 = "<< s5 << endl; cout <<"s6 = "<< s6 << endl;return0;}
8.2.3 访问字符

可以使用下标运算符[]at()函数访问字符串中的单个字符:

#include<iostream>#include<string>usingnamespace std;intmain(){ string str ="Hello";// 使用下标访问(不检查越界,速度快)char first = str[0];// 'H'char second = str[1];// 'e'// 使用at()访问(检查越界,越界时抛出out_of_range异常)char last = str.at(4);// 'o' cout <<"first = "<< first << endl; cout <<"second = "<< second << endl; cout <<"last = "<< last << endl;// 修改字符 str[0]='h';// str变为"hello" str.at(4)='O';// str变为"hellO" cout <<"modified str = "<< str << endl;// 越界访问的风险// char ch = str[10]; // 错误!下标越界,但程序不会报错(未定义行为)// char ch2 = str.at(10); // 错误!at()会抛出异常return0;}

使用建议:如果你确定下标不会越界,使用[]效率更高;如果不确定,使用at()更安全。

8.2.4 获取字符串信息

string提供了几个成员函数来获取字符串的属性和状态:

#include<iostream>#include<string>usingnamespace std;intmain(){ string str ="Hello, C++";// 长度:length()和size()等价 size_t len = str.length();// 返回字符串长度(字符个数) size_t size = str.size();// 同上// 容量 size_t cap = str.capacity();// 当前分配的存储空间大小(字节数)// 是否为空bool empty = str.empty();// 如果字符串为空,返回true cout <<"字符串: \""<< str <<"\""<< endl; cout <<"长度: "<< len << endl; cout <<"容量: "<< cap << endl; cout <<"是否为空: "<<(empty ?"是":"否")<< endl;// 清空字符串 str.clear();// 清空内容,字符串变为空 cout <<"清空后是否为空: "<<(str.empty()?"是":"否")<< endl;return0;}

注意empty()比检查length() == 0更直观,也更高效。

8.2.5 字符串连接

string重载了++=运算符,让字符串连接变得非常简单:

#include<iostream>#include<string>usingnamespace std;intmain(){ string s1 ="Hello"; string s2 ="World";// 使用+连接 string s3 = s1 +", "+ s2 +"!";// "Hello, World!"// 使用+=追加 s1 +=", C++";// s1变为"Hello, C++"// append成员函数(效果相同) s2.append("!!!");// s2变为"World!!!" cout <<"s1 = "<< s1 << endl; cout <<"s2 = "<< s2 << endl; cout <<"s3 = "<< s3 << endl;// 可以混合字符串和C风格字符串 string s4 = s1 +" and "+ s2; cout <<"s4 = "<< s4 << endl;return0;}
8.2.6 查找子串

find()函数用于在字符串中查找子串的位置:

#include<iostream>#include<string>usingnamespace std;intmain(){ string str ="Hello, C++ Programming!";// 查找子串 size_t pos = str.find("C++");if(pos != string::npos){ cout <<"找到了'C++',位置:"<< pos << endl;// 输出7}else{ cout <<"未找到"<< endl;}// 从指定位置开始查找 pos = str.find("o",5);// 从索引5开始查找'o'// 查找字符 pos = str.find('P');// 查找单个字符// 反向查找(从后往前) pos = str.rfind("o");// 最后一个'o'的位置// 查找第一个匹配的字符之一 pos = str.find_first_of("aeiou");// 第一个元音字母的位置// 查找不匹配的字符 pos = str.find_first_not_of("Hello");// 第一个不在"Hello"中的字符// string::npos是一个特殊值,表示“未找到”if(str.find("Java")== string::npos){ cout <<"没有找到Java"<< endl;}return0;}

注意string::npos是一个静态常量,表示“未找到”的情况。

8.2.7 提取子串

substr()函数可以提取字符串的一部分:

#include<iostream>#include<string>usingnamespace std;intmain(){ string str ="Hello, C++ Programming!";// 提取从索引7开始的3个字符 string sub = str.substr(7,3);// "C++" cout <<"sub = "<< sub << endl;// 提取从索引13开始到结束 string sub2 = str.substr(13);// "Programming!" cout <<"sub2 = "<< sub2 << endl;// 如果开始位置超出范围,抛出out_of_range异常// string sub3 = str.substr(100); // 错误!return0;}
8.2.8 替换子串

replace()函数可以替换字符串中的一部分:

#include<iostream>#include<string>usingnamespace std;intmain(){ string str ="I like C++";// 将从索引2开始的4个字符("like")替换为"love" str.replace(2,4,"love"); cout << str << endl;// "I love C++"// 也可以使用迭代器版本(稍后讲解)return0;}
8.2.9 插入与删除

insert()erase()函数可以在指定位置插入或删除字符:

#include<iostream>#include<string>usingnamespace std;intmain(){ string str ="C++ Programming";// 在索引0处插入"Hello, " str.insert(0,"Hello, "); cout << str << endl;// "Hello, C++ Programming"// 在末尾插入字符 str.push_back('!');// 追加单个字符 cout << str << endl;// "Hello, C++ Programming!"// 删除从索引7开始的4个字符 str.erase(7,4); cout << str << endl;// "Hello, Programming!"// 删除最后一个字符 str.pop_back(); cout << str << endl;// "Hello, Programming"return0;}
8.2.10 字符串比较

string重载了比较运算符,可以直接进行字典序比较:

#include<iostream>#include<string>usingnamespace std;intmain(){ string s1 ="apple"; string s2 ="banana"; string s3 ="apple";if(s1 == s3){ cout <<"s1和s3相等"<< endl;}if(s1 < s2){ cout <<"apple在banana之前"<< endl;// 因为'a' < 'b'}// 也可以使用compare成员函数int result = s1.compare(s2);// 负数表示s1 < s2 cout <<"比较结果: "<< result << endl;return0;}
8.2.11 与C风格字符串互转

有时需要与C风格字符串交互(比如调用旧的C库函数),string提供了相应的方法:

#include<iostream>#include<string>#include<cstring>// C风格字符串函数usingnamespace std;intmain(){ string cppStr ="Hello, World";// 转换为C风格字符串(只读)constchar* cStr = cppStr.c_str(); cout <<"C风格字符串: "<< cStr << endl;// 使用C函数 size_t len =strlen(cStr); cout <<"长度: "<< len << endl;// 从C风格字符串构造char cArray[]="C-style string"; string fromC = cArray; cout <<"从C数组构造: "<< fromC << endl;return0;}
8.2.12 性能优化:预分配内存

size 和 capacity 的区别

在理解这两个函数之前,必须先分清两个概念:

  • size():容器当前实际存储的元素个数(即“已用空间”)。
  • capacity():容器当前分配的内存可以容纳的元素总数(即“总容量”)。

打个比方:你租了一间 100 平方米的仓库(capacity = 100),但只放了 30 平方米的货物(size = 30)。resize 决定你要放多少货物,reserve 决定你要租多大的仓库。


resize() 函数

原型(以 vector 为例,string 类似):

voidresize(size_type n);// 新大小 = nvoidresize(size_type n,const T& val);// 新大小 = n,新元素用 val 初始化

作用:调整容器的实际元素个数(size)为 n

  • 如果 n < 当前 size:容器末尾多余的元素被删除(析构),size 变为 n,capacity 通常不变(但可能由实现决定,标准未强制缩容)。
  • 如果 n > 当前 size:在末尾添加新元素,使 size 达到 n。新添加的元素:
    • 对于单参数版本 resize(n),新元素使用默认构造函数初始化(内置类型为 0,类类型调用默认构造)。
    • 对于双参数版本 resize(n, val),新元素是 val 的副本。
  • 如果 n == 当前 size,什么也不做。

示例

#include<iostream>#include<vector>usingnamespace std;intmain(){ vector<int> v ={1,2,3,4,5};// size = 5, capacity >= 5 cout <<"初始: size = "<< v.size()<<", capacity = "<< v.capacity()<< endl;// 增加 size v.resize(8);// 添加 3 个默认值 0 cout <<"resize(8): size = "<< v.size()<<", capacity = "<< v.capacity()<< endl;for(int x : v) cout << x <<" ";// 1 2 3 4 5 0 0 0 cout << endl;// 增加 size 并指定填充值 v.resize(10,99);// 再添加 2 个 99 cout <<"resize(10,99): size = "<< v.size()<< endl;for(int x : v) cout << x <<" ";// 1 2 3 4 5 0 0 0 99 99 cout << endl;// 减小 size v.resize(3);// 只保留前 3 个,后面的丢弃 cout <<"resize(3): size = "<< v.size()<<", capacity = "<< v.capacity()<< endl;for(int x : v) cout << x <<" ";// 1 2 3 cout << endl;return0;}

输出示例(capacity 可能因编译器而异):

初始: size = 5, capacity = 5 resize(8): size = 8, capacity = 8 (可能自动扩容) 1 2 3 4 5 0 0 0 resize(10,99): size = 10 1 2 3 4 5 0 0 0 99 99 resize(3): size = 3, capacity = 10 (capacity 通常不缩小) 1 2 3 

注意resize可能引起容量变化(当 n > capacity 时,需要重新分配内存,capacity 会随之增大)。但减少 size 通常不释放内存,capacity 不变。


reserve() 函数

原型(同样适用于 vectorstring):

voidreserve(size_type n);

作用:预分配至少能容纳 n 个元素的内存空间(即设置 capacity ≥ n)。

  • 如果 n > 当前 capacity:重新分配内存,将 capacity 扩大到至少 n(可能更大),同时将已有元素拷贝/移动到新内存,然后释放旧内存。size 不变
  • 如果 n ≤ 当前 capacity:什么也不做(不会缩容,也不会释放内存)。

重要reserve只影响 capacity,不影响 size。它不会创建或销毁任何元素,仅仅为将来可能添加的元素预留空间。

示例

#include<iostream>#include<vector>usingnamespace std;intmain(){ vector<int> v; cout <<"初始: size = "<< v.size()<<", capacity = "<< v.capacity()<< endl;// 通常 0 v.reserve(100);// 预分配 100 个元素的空间 cout <<"reserve(100) 后: size = "<< v.size()<<", capacity = "<< v.capacity()<< endl;// 现在添加元素不会导致多次重新分配for(int i =0; i <100;++i){ v.push_back(i);} cout <<"添加 100 个元素后: size = "<< v.size()<<", capacity = "<< v.capacity()<< endl; v.reserve(50);// n < capacity,什么也不做,capacity 不变 cout <<"reserve(50) 后: capacity = "<< v.capacity()<< endl;return0;}

输出

初始: size = 0, capacity = 0 reserve(100) 后: size = 0, capacity = 100 添加 100 个元素后: size = 100, capacity = 100 reserve(50) 后: capacity = 100 

主要区别总结

特性resize(n)reserve(n)
影响改变 size改变 capacity
元素操作可能创建或销毁元素不创建也不销毁元素
内存重分配当 n > capacity 时才会扩容当 n > capacity 时才会扩容
缩容减小 size 但 capacity 通常不变不支持缩容(n < capacity 时无效果)
典型用途实际需要 n 个元素,如初始化数组大小预分配内存,优化多次 push_back 的性能

8.5.5 使用场景指南

  • **当你确定需要多少个元素时,用 **resize
    例如,你需要一个存放 10 个整数的动态数组,且每个元素初始值为 0:
vector<int> v; v.resize(10,0);// 现在 v 有 10 个元素,全为 0

等价于直接初始化 vector<int> v(10, 0);

  • **当你大概知道最终会有多少元素,但不想马上创建它们时,用 **reserve
    例如,你要向容器中添加大量元素,为了避免多次重新分配:
vector<int> v; v.reserve(1000);// 预留足够空间for(int i =0; i <1000;++i){ v.push_back(i);// 不会触发多次扩容}
  • 两者结合使用
    可以先 reserve 预留空间,再用 push_back 添加元素;也可以直接用 resize 创建元素并用下标访问。

8.3 迭代器:容器与算法的桥梁

8.3.1 什么是迭代器?

**迭代器(Iterator)**是一种抽象的指针,用于遍历容器中的元素。它提供了统一的访问方式,让我们可以用相同的代码遍历不同的容器。

你可以把迭代器想象成图书馆里的“索引卡”——无论书架上摆的是什么书,你都可以通过索引卡找到它们的位置,然后一本一本地阅读。

8.3.2 为什么需要迭代器?

在C语言中,遍历数组通常用下标:

int arr[5]={1,2,3,4,5};for(int i =0; i <5;++i){printf("%d ", arr[i]);}

但如果要遍历链表呢?下标就不适用了。C++的STL通过迭代器解决了这个问题:每种容器都提供自己的迭代器,但使用方式保持一致

8.3.3 迭代器的基本操作

迭代器支持的操作类似于指针:

操作含义
*it访问迭代器指向的元素
it->membermember为成员名)访问元素的成员(相当于(*it).member
++it向前移动一个位置
--it向后移动一个位置(双向迭代器才支持)
it1 == it2比较两个迭代器是否相等
it1 != it2比较两个迭代器是否不等
8.3.4 容器的begin()和end()

每个容器都有两个成员函数,用于获取迭代器:

  • begin():返回指向第一个元素的迭代器
  • end():返回指向最后一个元素之后的迭代器(尾后迭代器)

重要end()指向的位置不包含有效元素,它只是一个“哨兵”,表示遍历的终点。这和我们用for (int i = 0; i < n; ++i)中的i < n是一个道理。

8.3.5 迭代器类型

迭代器有多种类型,按功能强弱排列:

  1. 输入迭代器(Input Iterator):只能读取,一次读一个元素向前移动
  2. 输出迭代器(Output Iterator):只能写入,一次写一个元素向前移动
  3. 前向迭代器(Forward Iterator):组合了输入和输出,可多次遍历
  4. 双向迭代器(Bidirectional Iterator):支持向前和向后移动(++--
  5. 随机访问迭代器(Random Access Iterator):支持跳跃式移动(+=[]等)

不同容器的迭代器类型不同:

  • vectorstringdeque:随机访问迭代器
  • listsetmap:双向迭代器
  • forward_list:前向迭代器
8.3.6 string的迭代器使用

string支持随机访问迭代器,让我们先通过string感受迭代器的使用:

#include<iostream>#include<string>usingnamespace std;intmain(){ string str ="Hello";// 定义迭代器 string::iterator it;// 使用迭代器遍历 cout <<"正向遍历: ";for(it = str.begin(); it != str.end();++it){ cout <<*it <<" ";} cout << endl;// 通过迭代器修改元素for(it = str.begin(); it != str.end();++it){*it =toupper(*it);// 转为大写} cout <<"修改后: "<< str << endl;// 常量迭代器(只读) string::const_iterator cit;for(cit = str.cbegin(); cit != str.cend();++cit){// *cit = 'x'; // 错误!常量迭代器不能修改 cout <<*cit <<" ";} cout << endl;return0;}
8.3.7 vector的迭代器使用

vector是最常用的容器之一(它就像是一个被扩展了功能的数组),它的迭代器用法和string非常相似:

#include<iostream>#include<vector>usingnamespace std;intmain(){// 创建vector并初始化 vector<int> vec; vec.push_back(10); vec.push_back(20); vec.push_back(30); vec.push_back(40); vec.push_back(50);// 方式1:使用下标遍历 cout <<"下标遍历: ";for(size_t i =0; i < vec.size();++i){ cout << vec[i]<<" ";} cout << endl;// 方式2:使用迭代器遍历(推荐) cout <<"迭代器遍历: ";for(vector<int>::iterator it = vec.begin(); it != vec.end();++it){ cout <<*it <<" ";} cout << endl;// 使用auto简化迭代器类型 cout <<"使用auto: ";for(auto it = vec.begin(); it != vec.end();++it){ cout <<*it <<" ";} cout << endl;// 使用范围for循环(C++11起,本质上是迭代器的语法糖) cout <<"范围for: ";for(int x : vec){ cout << x <<" ";} cout << endl;// 通过迭代器修改元素for(auto it = vec.begin(); it != vec.end();++it){*it *=2;// 每个元素乘以2} cout <<"修改后: ";for(int x : vec){ cout << x <<" ";} cout << endl;return0;}
8.3.8 迭代器辅助函数

STL提供了几个操作迭代器的辅助函数,需要包含头文件<iterator><algorithm>

#include<iostream>#include<vector>#include<iterator>// 迭代器辅助函数#include<algorithm>// advance也在<algorithm>中usingnamespace std;intmain(){ vector<int> vec ={1,2,3,4,5,6,7,8,9,10};// 1. advance:将迭代器移动n步auto it = vec.begin();advance(it,3);// it现在指向第4个元素(索引3) cout <<"advance后: "<<*it << endl;// 输出4// 2. distance:计算两个迭代器的距离auto it1 = vec.begin();auto it2 = vec.end();int dist =distance(it1, it2); cout <<"distance: "<< dist << endl;// 输出10// 3. iter_swap:交换两个迭代器指向的值iter_swap(vec.begin(), vec.begin()+4);// 交换第一个和第五个元素 cout <<"交换后第一个元素: "<< vec[0]<< endl;// 输出5 cout <<"交换后第五个元素: "<< vec[4]<< endl;// 输出1return0;}
8.3.9 反向迭代器

除了正向遍历,STL还提供了反向迭代器,用于从后向前遍历容器:

#include<iostream>#include<vector>usingnamespace std;intmain(){ vector<int> vec ={1,2,3,4,5};// 正向迭代器:begin -> end cout <<"正向遍历: ";for(auto it = vec.begin(); it != vec.end();++it){ cout <<*it <<" ";} cout << endl;// 反向迭代器:rbegin -> rend// rbegin指向最后一个元素,rend指向第一个元素之前 cout <<"反向遍历: ";for(auto rit = vec.rbegin(); rit != vec.rend();++rit){ cout <<*rit <<" ";} cout << endl;// 常量反向迭代器 vector<int>::const_reverse_iterator crit;for(crit = vec.crbegin(); crit != vec.crend();++crit){ cout <<*crit <<" ";} cout << endl;return0;}
8.3.10 迭代器的使用要点
  1. 尽量用++it而不是it++:后置自增会返回旧值并产生临时对象,效率稍低。
  2. 范围for循环是迭代器的语法糖
for(int x : vec){...}// 等价于for(auto it = vec.begin(); it != vec.end();++it){int x =*it;...}
  1. 使用auto简化类型声明
// 不用写长长的类型名for(auto it = vec.begin(); it != vec.end();++it){...}
  1. 注意迭代器失效问题:在遍历过程中修改容器(如插入、删除)可能导致迭代器失效。这部分内容将在后续章节详细讲解。

小结

至此,C++基础入门的核心内容已经完结。我们从C++的“第一性原理”出发,理解了它效率优先、渐进改进、实用主义的设计哲学。沿着这条主线,你学会了:

  • 命名空间解决命名冲突,给代码加上“姓氏”。
  • 引用的本质(底层指针常量)及其应用:数组引用保留数组大小,const引用可绑定到字面量和临时对象并延长其生命周期。
  • cin/cout的类型安全输入输出,背后是运算符重载,以及格式化控制与性能细节('\n'优于endl)。
  • 函数进阶:缺省参数从右向左连续给出,函数重载依赖参数列表不同,名字修饰支撑其底层实现,运算符重载让自定义类型也能使用运算符。
  • C++11现代化特性auto类型推导、范围fornullptrdecltypeconst常量(类型安全宏替代)、统一初始化(防止窄化)、枚举类(解决传统枚举三大缺陷)。
  • 动态内存管理new/deletemalloc/free的对比,operator new底层机制,定位new在指定内存构造对象,new[]/delete[]的内部计数原理。
  • STL入门与string:STL由容器、算法、迭代器构成;string提供丰富的字符串操作(访问、连接、查找、子串、插入删除等);迭代器是容器与算法的桥梁,支持begin/end、反向迭代器;resize调整实际元素个数,reserve预分配内存以优化性能。

这些知识不仅帮你掌握了语法,更让你理解了C++为什么能成为软件工业的基石——它给你最大的控制权,同时不断引入现代特性让代码更安全、更简洁。记住,编程是练会的,不是看会的。打开编译器,亲手敲一遍所有示例,在实践中消化它们。现在,你已准备好迈向面向对象编程的下一个阶段。加油!

Read more

08 Python 数据分析:学生画像匹配与相似度计算

Python 数据分析:学生画像匹配与相似度计算 适合人群:Python 初学者 / 数据分析入门 / 推荐系统基础学习者 / 教学案例分享 在数据分析和机器学习中,我们经常会遇到这样的问题: * 如何判断两个学生的学习习惯是否相似? * 如何衡量两个商品是不是“同类竞品”? * 为什么推荐系统能给你推送“你可能喜欢”的内容? * 两段文本内容相似,应该怎么用数据来表示? 这些问题,归根到底,都指向一个核心概念: 相似性度量 本文将通过“学生画像匹配”和“课程评价文本分析”两个小案例,带你理解下面几个非常常用的概念: * 欧氏距离(Euclidean Distance) * 曼哈顿距离(Manhattan Distance) * 余弦相似度(Cosine Similarity) 并结合 Python 完成简单实战。 一、案例引入:谁和你最像? 假设我们想根据学生的学习数据,寻找“和你最相似的同学”。 比如现在有三位学生的成绩数据: 学生数学英语A8085B8288C6070 问题来了:

By Ne0inhk
java面试这一篇就够了(干货)

java面试这一篇就够了(干货)

前言 一、基础篇 1.1.Java语言有哪些特点 1、简单易学、有丰富的类库 2、面向对象(Java最重要的特性,让程序耦合度更低,内聚性更高) 3、与平台无关性(JVM是Java跨平台使用的根本) 4、可靠安全 5、支持多线程 1.2.面向对象和面向过程的区别 面向过程:是分析解决问题的步骤,然后用函数把这些步骤一步一步地实现,然后在使用的时候一一调用则可。性能较高,所以单片机、嵌入式开发等一般采用面向过程开发,以函数为单位,一步一步完成,后期出现问题 可能会牵一发而动全身. 面向对象:以对象为最小单位是把构成问题的事务分解成各个对象,而建立对象的目的也不是为了完成一个个步骤,而是为了描述某个事物在解决整个问题的过程中所发生的行为。面向对象有封装、继承、多态的特性,所以易维护、易复用、易扩展。可以设计出低耦合的系统。 但是性能上来说,比面向过程要低。 1.3.

By Ne0inhk
一次搭好、终身不乱Windows Python 环境治理(EPGF)系列总览 / 阅读路线图 [目录]

一次搭好、终身不乱Windows Python 环境治理(EPGF)系列总览 / 阅读路线图 [目录]

【EPGF 白皮书】路径治理驱动的多版本 Python 架构—— Windows 环境治理与 AI 教学开发体系 一次搭好、终身不乱 Windows Python 环境治理(EPGF)系列总览 / 阅读路线图 [目录] EPGF(Engineering Python Governance Framework) 一套专为 Windows 设计的 Python 环境治理与教学落地体系 —— 用工程化方法,终结“环境地狱” 一、这不是一套“工具教程”,而是一套环境治理体系 如果你点进这个系列,是因为你曾遇到过下面任意一种情况: * Python 装了,但不知道现在用的是哪个 * 项目能跑,但换电脑 / 换同学就全崩 * 虚拟环境创建了,依赖却“跑丢了” * 工具越装越多,C 盘越来越乱 * 教学中

By Ne0inhk
2024第十六届蓝桥杯模拟赛(第二期)-Python

2024第十六届蓝桥杯模拟赛(第二期)-Python

提示:前五题是填空,不需要提交代码。 知识点: 1、质因数、质数、约数。 2、动态规划(dp)。 3、思维。    一、【问题描述】 如果一个数 p 是个质数,同时又是整数 a 的约数,则 p 称为 a 的一个质因数。 请问, 2024 的最大的质因数是多少?【答案提交】 这是一道结果填空的题,你只需要算出结果后提交即可。本题的结果为一个整数,在提交答案时只填写这个整数,填写多余的内容将无法得分。 1、思路:质数的判定https://www.acwing.com/file_system/file/content/whole/index/content/4443622/ 2、

By Ne0inhk