一、C++与C语言的关系
1.1 C++ 是由C语言发展而来
C++ 由C语言发展而来(在C语言的基础上进行的扩展),是站在C语言的肩膀上发展起来的,C++ 包含了C语言的全部内容,几乎完全兼容C语言;换句话说,你编写的C语言代码几乎可以不加修改地用于 C++中。
C语言 是面向过程的编程语言,C++ 是在 C语言 基础上增加了 面向对象编程(OOP) 及 泛型编程 等机制的编程语言,因此 C++ 更适合大中型程序的开发。然而,C++ 并没有牺牲效率,如果不使用高级特性,它的效率和C语言几乎没有差异。
Tips: 将C语言代码放在.cpp文件中不会有错,但是,强烈建议将C语言代码放在.c文件中,这样能够更加严格地遵循C语言的语法,也能够更加清晰地了解C语言和C++的区别。
C++ 是一门灵活多变、特性丰富的比较复杂的编程语言。
C++ 支持面向过程编程、面向对象编程和泛型编程,而C语言仅支持面向过程编程。
面向对象编程的三要素:封装,继承,多态。
二、C++版本发展及新特性介绍
2.1 C++版本发展简介
C++ 是一门以C为基础发展而来的面向对象的高级程序设计语言。1979年,Bjame Stroustrup到了Bell实验室,开始从事将C改良为带类的C(C with Classes)的工作;1983年该语言被正式命名为C++,主要意图是表明 C++ 是 C语言 的增强版。C++从最初的C with class开始,经历了从C++98、C++03、C++11、C++14、C++17 再到 C++20 的多次标准化改造增强,功能得到了极大的丰富,已经演变为一门集面向过程、面向对象、函数式、泛型 和 元编程 等多种编程范式于一体的复杂编程语言:
年份 | C++标准名称 | 非正式名称 |
---|---|---|
1998 | ISO/IEC 14882:1998 | C++98 |
2003 | ISO/IEC 14882:2003 | C++03 |
2011 | ISO/IEC 14882:2011 | C++11 |
2014 | ISO/IEC 14882:2014 | C++14 |
2017 | ISO/IEC 14882:2017 | C++17 |
2020 | ISO/IEC 14882:2020 | C++20 |
C++ 编译器支持情况表:
2.2 C++版本发展及新特性介绍
C++版本发展史: https://blog.csdn.net/MOU_IT/article/details/117756468
- C++98
- 1979年,Bjame Stroustrup到了Bell实验室,开始从事将C改良为带类的C(C with Classes)的工作,1983年该语言被正式命名为C++,主要意图是表明C++是C的增强版。
- 1985年发布了第一个C++版本。第一个版本的C++,因其面向对象的思想使得编程变得简单,并且又保持了C语言的运行效率,在推出的一段时间内,得到了快速的发展,占据了编程语言界的半壁江山。
- 从1985年到1998年,C++从最初的C with Classes新增了很多其他的特性,比如异常处理、模板、标准模板库(STL)、运行时异常处理(RTTI)与名字空间(Namespace)等。
- 1998年,C++标准委员会统筹C++的所有特性,发布了第一个C++国际标准C++98。
- C++03
从1998年到2003年,是 C++标准 从 C++98 到 C++03 的迭代期,期间 C++ 扩增了很多额外的特性,比如以 Boost MPL(Boost Metaprogramming Library)与 Loki 等为代表的模板元编程库的出现,让开发者更加便捷的使用 C++ 在编译期的执行能力,即通过代码编译获得计算结果,学术性的称为 模板元编程。到了2003年,C++标准委员会 总结最新技术并发布了 C++03标准。C++03 是给 C++98 打的补丁,所以现在的人提到 C++98, C++03 往往指的是同一个版本 C++98 或 C++98/03。
C++11
从2003年到2011年,也就是从C++03到C++11,期间C++引入了对象移动、右值引用、lamba表达式(函数式编程)、编译时类型识别(auto)、别名模板以及很多新型关键词(如nullptr、decltype、constexpr)等现代编程语言常具备的能力,让 C++ 与时俱进,开发效率得到了很大的提升。这些新的特性随着 C++11标准 的发布而被正式确立下来。C++ 11版本也被称为 现代C++,而 C++98/03版本 也被称为 传统C++。
- C++14
C++14 引入了二进制文字常量、将类型推导从Lambda函数扩展到所有函数、变量模板以及数字分位符等。C++14 是对 C++11的重要补充和优化,是C++发展历程中的一个小型版本,虽然新增的内容较少,但是仍然为用户“带来了极大的方便”,为实现使C++“对新手更为友好”这一目标作出努力。
- C++17
2017年,C++迎来了 C++17标准。此次对 C++ 的改进和扩增,让 C++ 变得更加容易接受和便于使用了。C++17 引入了许多新的特性,比如类模板参数推导、UTF-8文字常量、fold表达式、新类型以及新的库函数等。
- C++20
C++20 的 Big Four(四大新特性:概念、范围、协程和模块)以及核心语言(包括一些新的运算符和指示符)。
C++20模块 (Modules)简介
module的引入至少有如下几个优点:
- 更快的编译时间;
- 宏的隔离;
- 可以将大量代码划分为逻辑部分;
- 让头文件成为“过去式”;
- 摆脱丑陋的宏环境;
C++分离编译带来的一个问题就是编译会非常慢,因为C++ 的编译器在处理一个源代码文件的时候,首先要做的就是用相应的头文件的内容替换 #include
预编译指令。这就存在一个问题,对每一个源代码文件编译器都要重复一遍内容替换,这会占用大量的处理器时间。而引入module 以后就不存在这个问题了,只需要 import 一下就可以在所有的源代码文件中使用,没有头文件的替换动作,使得编译时间可以大大减小。
三、C++关键字概述
3.1 C++关键字列表
备注:
- 图中是 C++98/03 中的63个关键字,红色标注的是C语言的32个关键字。
- C++11 中有73个关键字,新增加的10个关键字分别为:alignas、alignof、char16_t、char32_t、constexpr、decltype、noexpect、nullptr、static_assert、thread_local
3.2.C++关键字分类及概述
3.2.1 数据类型相关关键字(14个)
- bool、true、false
bool 即为布尔类型,属于基本类型中的整数类型,取值为真和假。true 和 false 是具有布尔类型的字面量,为右值,即表示真和假。
注:字面量 用于源代码中一个固定值的表示方法。
- char、wchar_t
表示字符型和宽字符型这些整数类型(属于基本类型),但一般只专用于表示字符。char(和signed char、unsigned char一起)事实上定义了字节的大小。char表示单字节字符,wchar_t表示多字节字符。
- int、short、long、signed、unsigned、float、double
- signed 和 unsigned 作为前缀修饰整数类型,分别表示有符号和无符号;
- signed 和 unsigned 修饰 char 类型,构成signed char和unsigned char,和 char 都不是相同的类型;
- signed 和 unsigned 不可修饰 wchar_t、char16_t 和 char32_t;
- 其它整数类型的signed省略或不省略,含义不变;
- signed或unsigned可单独作为类型,相当于signed int和unsigned int;
- double和float专用于浮点数,double表示双精度,精度不小于float表示的浮点数;long double则是C++11指定的精度不小于double的浮点数。
- explicit
该关键字的作用是避免自定义类型隐式转换为类类型。
- auto
auto关键字会根据初始值自动推断变量的数据类型。
示例:
|
|
不是每个编译器都支持auto。
3.2.2 语句相关关键字(11个)
- switch、case、default
switch分支语句的起始,根据switch条件跳转到case标号或defalut标记的分支上。
- for、do、while
循环语句的组成部分,C和C++都支持这3种循环。
- if和else
条件语句的组成部分。if表示条件,之后else表示否定分支。
- break、cotinue、goto
break用于跳出for、while循环或switch语句。continue用于调到一个循环的起始位置。goto用于无条件跳转到函数内得标号处。一般情况不建议使用goto,风险较大。
3.2.3 定义、初始化相关关键字(15个)
- const、volatile
const和volatile是类型修饰符,语法类似,用于变量或函数参数声明,也可以限制非静态成员函数。const表示只读类型(指定类型安全性,保护对象不被意外修改),volatile指定被修饰的对象类型的读操作是副作用(因此读取不能被随便优化合并,适合映射I/O寄存器等)。
volatile:
- 当读取一个变量时,为提高存取速度,编译器优化时有时会先把变量读取到一个寄存器中,以后再取变量值时,就直接从寄存器中取值。
- 优化器在用到volatile变量时必须每次都小心地重新读取这个变量的值,而不是使用保存到寄存器里的备份。
- volatile适用于多线程应用中被几个任务共享的变量。
- enum
构成枚举类型名的关键字。
- export
使用该关键字可实现模板函数的外部调用。对模板类型,可以在头文件中声明模板类和模板函数;在代码文件中,使用关键字export来定义具体的模板类对象和模板函数;然后在其他用户代码文件中,包含声明头文件后,就可以使用该这些对象和函数。
- extern
当出现extern “C”时,表示 extern “C”之后的代码按照C语言的规则去编译;当extern修饰变量或函数时,表示其具有外部链接属性,即其既可以在本模块中使用也可以在其他模块中使用。
- public、protected、private
这三个都为权限修饰符。public为公有的,访问不受限制;protected为保护的,只能在本类和友元中访问;private为私有的,只能在本类、派生类和友元中访问。
- template
声明一个模板,模板函数,模板类等。模板的特化。
- static
可修饰变量(静态全局变量,静态局部变量),也可以修饰函数和类中的成员函数。static修饰的变量的周期为整个函数的生命周期。具有静态生存期的变量,只在函数第一次调用时进行初始化,在没有显示初始化的情况下,系统把他们初始化微0.
- struct、class、union
用于类型声明。class是一般的类类型。struct在C++中是特殊的类类型,声明中仅默认隐式的成员和基类访问限定与class不同(struct是public,class是private)。union是联合体类型。满足特定条件类类型——POD struct或POD union可以和C语言中的struct和union对应兼容。 注:POD类型(Plain Old Data),plain—代表普通类型,old—代表可以与C语言兼容。
- mutable
mutable也是为了突破const的限制而设置的。被mutable修饰的变量,将永远处于可变的状态,即使在一个const函数中。
- virtual
声明虚基类,虚函数。虚函数=0时,则为纯虚函数,纯虚函数所在的类称为抽象类。
3.2.4 系统操作相关关键字(10个)
- catch、throw、try
用于异常处理。try指定try块的起始,try块后的catch可以捕获异常。异常由throw抛出。throw在函数中还表示动态异常规范。
- new、delete
new、delete属于操作符,可以被重载。new表示向内存申请一段新的空间,申请失败会抛出异常。new会先调用operator new函数,再在operator new函数里调用malloc函数分配空间,然后再调构造函数。delete不仅会清理资源,还会释放空间。delete县调用析构函数,其次调用operator delete函数,最后在operator delete函数里面调用free函数。malloc申请内存失败会返回空。free只是清理了资源,并没有释放空间。
- friend
友元。使其不受访问权限控制的限制。例如,在1个类中,私有变量外部是不能直接访问的。可是假如另外1个类或函数要访问本类的1个私有变量时,可以把这个函数或类声明为本类的友元函数或友元类。这样他们就可以直接访问本类的私有变量。
- inline
内联函数,在编译时将所调用的函数代码直接嵌入到主调函数中。各个编译器的实现方式可能不同。
- operator
和操作符连用,指定一个重载了的操作符函数,比如,operator+。
- register
提示编译器尽可能把变量存入到CPU内部寄存器中。
- typename
typename关键字告诉编译器把一个特殊的名字解释为一个类型。
3.2.5 命名相关关键字(3个)
- using
- 在当前文件引入命名空间,例using namespace std;
- 在子类中使用,using声明引入基类成员名称。
- namespace
C++标准程序库中的所有标识符都被定义于一个名为std的namespace中。命名空间除了系统定义的名字空间之外,还可以自己定义,定义命名空间用关键字“namespace”,使用命名空间时用符号“::”指定。
- typedef
typedef声明,为现有数据类型创建一个新的名字。便于程序的阅读和编写。
3.2.6 函数和返回值相关关键字(4个)
- void
特殊的"空"类型,指定函数无返回值或无参数。
- return
return表示从被调函数返回到主调函数继续执行,返回时可附带一个返回值,由return后面的参数指定。return通常是必要的,因为函数调用的时候计算结果通常是通过返回值带出的。如果函数执行不需要返回计算结果,也经常需要返回一个状态码来表示函数执行的顺利与否(-1和0就是最常用的状态码),主调函数可以通过返回值判断被调函数的执行情况.
- sizeof
返回类型名或表达式具有的类型对应的大小。
- typeid
typeid是一个操作符,返回结果为标准库种类型的引用。
3.2.7 其他关键字(3个)
- this
每个类成员函数都隐含了一个this指针,用来指向类本身。this指针一般可以省略。但在赋值运算符重载的时候要显示使用。静态成员函数没有this指针。
- asm
_asm是一个语句的分隔符。不能单独出现,必须接汇编指令。一组被大括号包含的指令或一对空括号。
示例:
|
|
也可以在每个汇编指令前加_asm
|
|
*_cast
- 即 const_cast、dynamic_cast、reinterpret_cast、static_cast,C++类型风格类型转换;
- const_cast删除const变量的属性,方便赋值;
- dynamic_cast用于将一个父类对象的指针转换为子类对象的指针或引用;
- reinterpret_cast将一种类型转换为另一种不同的类型;
- static_cast用于静态转换,任何转换都可以用它,但他不能用于两个不相关的类型转换。
四、命名空间(Namespace)
4.1 命名空间(Namespace)的功能及概念
一个中大型软件往往由多名程序员共同开发,会使用大量的变量和函数,不可避免地会出现变量或函数的命名冲突。当所有人的代码都测试通过,没有问题时,将它们结合到一起就有可能会出现命名冲突。
例如小李和小韩都参与了一个文件管理系统的开发,它们都定义了一个全局变量 fp,用来指明当前打开的文件,在将他们的代码整合在一起编译时,很明显编译器会提示 fp 重复定义(Redefinition)的错误。
为了解决合作开发时的命名冲突问题,C++ 引入了 命名空间(Namespace) 的概念。
Tips: 命名空间有时也被称为名字空间、名称空间。
4.2 命名空间(Namespace)的应用
namespace 是 C++中用来定义一个 命名空间(Namespace) 的关键字,它语法格式为:
|
|
name 是命名空间的名字,它里面可以包含变量、函数、类、typedef、#define 等,由{ }包围。
使用变量、函数、类时要指明它们所在的命名空间。以C++标准输出为例,可以这样来直接使用:
|
|
::
是一个新符号,称为域解析操作符,在C++中用来指明要使用的命名空间。
除了直接使用域解析操作符,还可以采用 using 关键字声明,例如:
|
|
在代码的开头用using声明了 std::cout,它的意思是,using 声明以后的程序中如果出现了未指明命名空间的 cout 标识符,就使用 std::cout;但是若要使用std中的其它,仍然需要 std指明, 如 std::endl 。
using 声明不仅可以针对命名空间中的一个变量,也可以用于声明整个命名空间,例如:
|
|
在 using 声明 std 后,如果有未具体指定命名空间的变量 与 std 中的产生了命名冲突,那么默认采用命名空间 std 中的变量。
命名空间内部不仅可以声明或定义变量,对于其它能在命名空间以外声明或定义的名称,同样也都能在命名空间内部进行声明或定义,例如类、函数、typedef、#define 等都可以出现在命名空间中。
站在编译和链接的角度,代码中出现的变量名、函数名、类名等都是一种符号(Symbol)。有的符号可以指代一个内存位置,例如变量名、函数名;有的符号仅仅是一个新的名称,例如 typedef 定义的类型别名。
五、C++头文件和标准库std命名空间简介
5.1 C++头文件发展历程简介
C++ 是在C语言的基础上开发的,早期的 C++ 还不完善,不支持命名空间,没有自己的编译器,而是将 C++ 代码翻译成C代码,再通过C编译器完成编译。这个时候的 C++ 仍然在使用C语言的库,stdio.h、stdlib.h、string.h 等头文件依然有效;此外 C++ 也开发了一些新的库,增加了自己的头文件,例如:
- iostream.h:用于控制台输入输出头文件
- fstream.h:用于文件操作的头文件
- complex.h:用于复数计算的头文件
和C语言一样,这个时候 C++ 头文件仍然以 .h
为后缀,它们所包含的类、函数、宏等都是全局范围的。
后来 C++ 引入了 命名空间 的概念,计划重新编写库,将类、函数、宏等都统一纳入一个命名空间,这个命名空间的名字就是 std, 是 standard 的缩写,意思是 “标准命名空间”。
但是这时已经有很多用老式 C++ 开发的程序了,它们的代码中并没有使用命名空间,直接修改原来的库会带来一个很严重的后果:程序员会因为不愿花费大量时间修改老式代码而极力反抗,拒绝使用新标准的 C++ 代码。
C++ 开发人员想了一个好办法,保留原来的库和头文件,它们在 C++ 中可以继续使用,然后再把原来的库复制一份,在此基础上进行修改,把类、函数、宏等纳入命名空间 std 下,构成了新版 C++ 标准库。这样共存在了两份功能相似的库,使用了老式 C++ 的程序可以继续使用原来的库,新开发的程序可以使用新版的 C++ 库。
为了避免新版本和旧版本标准库头文件重名,新版 C++ 库也对头文件的命名做了调整,去掉了后缀.h,所以老式 C++ 的iostream.h 在新版本库中变成了 iostream,fstream.h 在新版本库中变成了 fstream。而对于原来C语言的头文件,也采用同样的方法,但在每个名字前还要添加一个 c
字母,所以C语言的 stdio.h 在新版本库中变成了 cstdio,stdlib.h在新版本库中变成了 cstdlib。
Tips: 需要注意的是,旧的 C++ 头文件是官方所反对使用的,已明确提出不再支持,但旧的C头文件仍然可以使用,以保持对C的兼容性。实际上,编译器开发商不会停止对客户现有软件提供支持,可以预计,旧的 C++ 头文件在未来数年内还是会被支持。
下面是我总结的 C++ 头文件的现状:
- 旧的 C++ 头文件,如 iostream.h、fstream.h 等将会继续被支持,尽管它们不在官方标准中。这些头文件的内容不在命名空间 std 中;
- 新的 C++ 头文件,如 iostream、fstream 等包含的基本功能和对应的旧版头文件相似,但头文件的内容在命名空间 std 中;
注意:在标准化的过程中,库中有些部分的细节被修改了,所以旧的头文件和新的头文件不一定完全对应。
- 标准C头文件如 stdio.h、stdlib.h 等继续被支持。头文件的内容不在 std 中;
- 具有C库功能的新C++头文件具有如 cstdio、cstdlib 这样的名字。它们提供的内容和相应的旧的C头文件相同,只是内容在 std 中;
Tips: 可以发现,对于不带.h的头文件,所有的符号都位于命名空间 std 中,使用时需要声明命名空间 std;对于带.h的头文件,没有使用任何命名空间,所有符号都位于全局作用域。这也是 C++ 标准所规定的。
不过现实情况和 C++ 标准所期望的有些不同,对于原来C语言的头文件,即使按照 C++ 的方式来使用,即#include
|
|
这两种形式在 Microsoft Visual C++ 和 GCC 下都能够编译通过,也就是说,大部分编译器在实现时并没有严格遵循C++标准,它们对两种写法都支持,程序员可以使用 std 也可以不使用。
Tips: 第 1 种写法(带std 命名空间)是标准的,第 2 种不标准,虽然它们在目前的编译器中都没有错误,但依然推荐使用第 1 种写法,因为标准写法会一直被编译器支持,非标准写法可能会在以后的升级版本中不再支持。
5.2 使用C++的头文件
虽然 C++ 几乎完全兼容C语言,C语言的头文件在 C++ 中依然被支持,但 C++ 新增的库更加强大和灵活,请读者尽量使用这些 C++ 新增的头文件,例如 iostream、fstream、string 等。
六、C++中const 的新特性
6.1 C++中const 的新特性简介
在C语言中,const 用来限制一个变量,表示这个变量不能被修改,通常称这样的变量为 常量。
在C++中,const 的含义并没有改变,只是对细节进行了一些调整,以下是最主要的两点。
6.2 const更像是编译阶段的 #define
宏常量
示例代码:
|
|
我们知道 变量是要占用内存的,即使被 const 修饰也不例外。m、n 两个变量占用不同的内存,int mn = m;
表示将 m 的值赋给 n,这个赋值的过程在C和C++中是有区别的。
在C语言中,编译器会先到 m 所在的内存中取出一份m的数据10,再将这份数据赋给 n;而在C++中,编译器会直接将 10 赋给 n,没有读取内存的过程,和 int n = 10;
的效果一样。C++ 中的常量更类似于 #define
命令,是一个值替换的过程,只不过 #define
是在预处理阶段替换,而const常量是在编译阶段替换。
C++ 对 const 的处理少了读取内存的过程,优点是提高了程序执行效率,缺点是不能反映内存的变化,一旦 const 变量被修改,C++ 就不能取得最新的值。
有读者提出疑问,const 变量不是禁止被修改吗?对,这种说法没错!不过这只是语法层面上的限制,和C语言一样,通过指针仍然可以修改。下面的代码演示了如何通过指针修改 const 变量:
|
|
将代码放到 .c
文件中,以C语言的方式编译,运行结果为 99。再将代码放到.cpp
文件中,以C++的方式编译,运行结果就变成了10。这种差异正是由于C和C++对 const 的处理方式不同造成的。
在C语言中,使用 printf 输出 n 时会到内存中获取 n 的值,这个时候 n 所在内存中的数据已经被修改成了 99,所以输出结果也是 99。而在C++中,printf("%d\n", n);
语句在编译时就将 n 的值替换成了 10,效果和 printf("%d\n", 10);
一样,不管 n 所在的内存如何变化,都不会影响输出结果。
当然,这种修改常量的变态代码在实际开发中基本不会出现,本例只是为了说明C和C++对 const 的处理方式的差异:
- 语言对 const 的处理和普通变量一样,会到内存中读取数据;
- C++ 对 const 的处理更像是编译时期的
#define
,是一个值替换的过程;
6.3 C++中全局 const 变量的可见范围是当前文件
我们知道,普通全局变量的作用域是当前文件,但是在其他文件中也是可见的,使用 extern
声明后就可以使用,这在 C语言 中进行了深入讲解。
在C语言中,const 变量和普通变量一样,在其他源文件中也是可见的使用 extern
声明后就可以使用;
在 C++ 对 const 的特性做了调整,C++ 规定,全局 const 变量的作用域仍然是当前文件,但是它在其他文件中是不可见的,这和添加了 static
关键字的效果类似, 在其它文件中不再可以使用 extern 声明 const 变量,这在编译链接时将回报不到定义 的错误。
由于 C++ 中全局 const 变量的可见范围仅限于当前源文件,所以可以将它放在头文件中,这样即使头文件被包含多次也不会出错。
Tips: C和C++中全局 const 变量的作用域相同,都是当前文件,不同的是它们的可见范围:C语言中 const 全局变量的可见范围是整个程序,在其他文件中使用 extern 声明后就可以使用;而C++中 const 全局变量的可见范围仅限于当前文件,在其他文件中不可见,所以它可以定义在头文件中,多次引入后也不会出错。
如果你使用的是 GCC,那么可以通过添加 extern 关键字来增大 C++ 全局 const 变量的可见范围,如下所示:
|
|
这样 n 的可见范围就变成了整个程序,在其他文件中使用 extern 声明后就可以使用了。不过这种方式只适用于 GCC,不适用于 VS/VC。
C++ 中的 const 变量虽然也会占用内存,也能使用 &
获取得它的地址,但是在使用时却更像编译时期的 #define
也是值替换,可见范围也仅限于当前文件。
很多C++教程在对比 const
和 #define
的优缺点时提到,#define
定义的常量仅仅是字符串的替换,不会进行类型检查,而 const
定义的常量是有类型的,编译器会进行类型检查,相对来说比 #define
更安全,所以鼓励大家使用 const
代替 #define
。
七、C++中 new 和 delete 运算符简介
7.1 new 和 delete 运算符简介
在C语言中,动态分配内存用 malloc() 函数,释放内存用 free() 函数。如下所示:
|
|
在C++中,这两个函数仍然可以使用,但是C++又新增了两个关键字,new 和 delete, new 用来动态分配内存,delete 用来释放内存。
用 new 和 delete 分配内存更加简单:
|
|
new 操作符会根据后面的数据类型来推断所需空间的大小。用 new 分配的内存需要用 delete 释放,它们是一一对应的。
如果希望分配一组连续的数据,可以使用 new[]:
|
|
用 new[] 分配的内存需要用 delete[] 释放,它们是一一对应的。
和 malloc() 一样,new 也是在堆区分配内存,必须手动释放,否则只能等到程序运行结束由操作系统回收。为了避免内存泄露,通常 new 和 delete、new[] 和 delete[] 操作符应该成对出现,并且不要和C语言中 malloc()、free() 一起混用。
在C++中,建议使用 new 和 delete 来管理内存,它们可以使用C++的一些新特性,最明显的是可以自动调用 构造函数 和 析构函数,后续我们将会讲解。
八、C++中的函数
8.1 函数简介
函数 是一个可以重复使用的代码块,CPU 会一条一条地顺着执行其中的代码。CPU 在执行主调函数代码时如果遇到了被调函数,主调函数就会暂停,CPU 转而执行被调函数的代码;被调函数执行完毕后再返回到主调函数,主调函数根据刚才的状态继续往下执行。
一个 C/C++ 程序的执行过程可以认为是多个函数之间的相互调用过程,它们形成了一个或简单或复杂的调用链条,这个链条的起点是 main(),终点也是 main()。当 main() 调用完了所有的函数,它会返回一个值(例如return 0;)来结束自己的生命,从而结束整个程序。
Tips: 函数调用 是有时间和空间开销的。程序在执行一个函数之前需要做一些准备工作,**要将实参、局部变量、返回地址以及若干寄存器都压入栈中,然后才能执行函数体中的代码;函数体中的代码执行完毕后还要清理现场,将之前压入栈中的数据都出栈,才能接着执行函数调用位置以后的代码。
8.2 函数编译原理
C++和C语言的编译方式不同。
C语言中的函数在编译时名字不变,或者只是简单的加一个下划线 _
前缀(不同的编译器有不同的实现),例如 func() 编译后为 func() 或 _func()。
而在C++中的函数(普通函数和成员函数)在编译时会根据它所在的命名空间、它所属的类、以及它的参数列表(也叫参数签名)等信息进行重新命名,形成一个新的函数名(全局函数)。这个新的函数名只有编译器知道,对用户是不可见的。对函数重命名的过程叫做 名字编码(Name Mangling),是通过一种特殊的算法来实现的。
名字编码(Name Mangling) 的算法是可逆的,既可以通过函数名计算出新函数名,也可以通过新函数名逆向推演出原有函数名。
名字编码(Name Mangling) 可以确保新函数名的唯一性,只要函数所在的命名空间、所属的类、包含的参数列表等有一个不同,最后产生的新函数名也不同。
Tips: 我们可将C/C++源代码编译成汇编代码后,就可以在汇编代码中查看C/C++编译器经 名字编码(Name Mangling) 产生的新的函数名了。
除了函数,某些变量也会经 Name Mangling 算法产生新名字,这里不再赘述。
8.3 内联函数
上节讲到,函数调用是有时间和空间开销的。如果函数体代码比较多,需要较长的执行时间,那么函数调用机制占用的时间(占比很小)可以忽略;如果函数只有一两条语句,那么大部分的时间都会花费在函数调用机制上,这种时间开销就就不容忽视。
为了消除函数调用的时空开销,C++ 提供一种提高效率的方法,即在编译时将函数调用处用函数体替换,类似于C语言中的宏展开。这种在函数调用处直接嵌入函数体的函数称为 内联函数(Inline Function),又称 内嵌函数 或者 内置函数。
指定内联函数的方法很简单,只需要在函数定义处增加 inline 关键字。
注意: 要在函数定义处添加 inline 关键字,在函数声明处添加 inline 关键字虽然没有错,但这种做法是无效的,编译器会忽略函数声明处的 inline 关键字。
当函数比较复杂时,函数调用的时空开销可以忽略,大部分的 CPU 时间都会花费在执行函数体代码上,所以我们一般是将非常短小的函数声明为内联函数。
由于内联函数比较短小,我们通常的做法是省略函数原型声明,将整个函数定义(包括函数头和函数体)放在本应该提供函数原型声明的地方。
使用内联函数的缺点也是非常明显的,编译后的程序会存在多份相同的函数拷贝,如果被声明为内联函数的函数体非常大,调用的地方比较多,那么编译后的程序体积也将会变得很大,所以再次强调,一般只将那些短小的、频繁调用的函数声明为内联函数。
Tips:需要说明的是,对函数作 inline 声明只是程序员对编译器提出的一个建议,而不是强制性的,并非一经指定为 inline 编译器就必须这样做。编译器有自己的判断能力,它会根据具体情况决定是否这样做。
和宏一样,内联函数可以定义在头文件中(不用加 static 关键字),并且头文件被多次 #include
后也不会引发重复定义错误。这一点和非内联函数不同,非内联函数是禁止定义在头文件中的,它所在的头文件被多次 #include
后会引发重复定义错误。
内联函数在编译时会将函数调用处用函数体替换,编译完成后函数就不存在了,所以在链接时不会引发重复定义错误。这一点和宏很像,宏在预处理时被展开,编译时就不存在了。从这个角度讲,内联函数更像是编译期间的宏。
Tips: 综合本节的内容,可以看到内联函数主要有两个作用,一是消除函数调用时的开销,二是取代带参数的宏。
内联函数虽然叫做函数,在定义和声明的语法上也和普通函数一样,但它已经失去了函数的本质。函数是一段可以重复使用的代码,它位于虚拟地址空间中的代码区,也占用可执行文件的体积,而内联函数的代码在编译后就被消除了,不存在于虚拟地址空间中,没法重复使用。
8.4 函数的默认参数
在 C++ 中,定义函数时可以给形参指定一个默认的值,这样调用函数时如果没有给这个形参赋值(没有对应的实参),那么就使用这个默认的值。也就是说,调用函数时可以省略有默认值的参数。如果用户指定了参数的值,那么就使用用户指定的值,否则使用参数的默认值。
所谓默认参数,指的是当函数调用中省略了实参时自动使用的一个值,这个值就是给形参指定的默认值。
指定了默认参数后,调用函数时就可以省略对应的实参了。
默认参数除了使用数值常量指定,也可以使用表达式指定,例如:
|
|
C++规定,默认参数只能放在形参列表的最后,而且一旦为某个形参指定了默认值,那么它后面的所有形参都必须有默认值。 实参和形参的传值是从左到右依次匹配的,默认参数的连续性是保证正确传参的前提。
默认参数并非编程方面的重大突破,而只是提供了一种便捷的方式。在以后设计类时你将发现,通过使用默认参数,可以减少要定义的析构函数、方法以及方法重载的数量。
除了在函数定义处指定默认参数,也可以在函数声明处指定默认参数。不过当出现函数声明时情况会变得稍微复杂,有时候可以在声明处和定义处同时指定默认参数,有时候你只能在声明处指定:
- 函数声明 和 定义在 同一源文件内时,不能在函数定义和函数声明中同时指定默认参数。这是因为 C++ 规定,在给定的作用域中只能指定一次默认参数。定义和声明位于同一个源文件,它们的作用域也都是整个源文件,这样就导致在同一个文件作用域中指定了两次默认参数,违反了 C++ 的规定。
- 函数声明 和 定义不在 同一源文件内时,可以在函数定义 和 函数声明中 同时指定默认参数。声明和定义位于不同的作用域,相互之间不影响。对于不同源文件,在函数定义 和 函数声明中 同时指定默认参数,编译器使用的是当前作用域中的默认参数。站在编译器的角度看,它不管当前作用域中是函数声明还是函数定义,只要有默认参数就可以使用。
Tips: C语言有四种作用域,分别是函数原型作用域、局部作用域(函数作用域)、块作用域、文件作用域(全局作用域),C++ 也有这几种作用域。
多次声明同一函数默认参数:
- 在多文件编程时,我们通常的做法是将函数声明放在头文件中,并且一个函数只声明一次,但是多次声明同一函数也是合法的。
- 不过有一点需要注意,在给定的作用域中一个形参只能被赋予一次默认参数。换句话说,函数的后续声明只能为之前那些没有默认值的形参添加默认值,而且该形参右侧的所有形参必须都有默认值。
|
|
8.5 函数重载详解
8.5.1 函数重载的概念和功能
C++ 允许多个函数拥有相同的名字,只要它们的参数列表不同就可以,这就是 函数的重载(Function Overloading)。借助重载,一个函数名可以有多种用途。
Tips: 参数列表又叫参数签名,包括参数的类型、参数的个数和参数的顺序,只要有一个不同就叫做参数列表不同。
函数重载 就是在同一个作用范围内(同一个命名空间、同一个文件、同一个类等)有多个名称相同但参数不同的函数。重载的结果是让一个函数名拥有了多种用途,使得命名更加方便(在中大型项目中,给变量、函数、类起名字是一件让人苦恼的问题),调用更加灵活。
在使用重载函数时,同名函数的功能应当相同或相近,不要用同一函数名去实现完全不相干的功能,虽然程序也能运行,但可读性不好,使人觉得莫名其妙。
Tips: 参数列表不同包括参数的个数不同、类型不同或顺序不同,仅仅参数名称不同是不可以的。函数返回值也不能作为重载的依据。
函数的重载的规则:
- 函数名称必须相同;
- 参数列表必须不同(个数不同、类型不同、参数排列顺序不同等);
- 函数的返回类型可以相同也可以不相同;
- 仅仅返回类型不同不足以成为函数的重载;
8.5.2 函数重载的底层原理
在第2小节 “函数编译原理” 中介绍过, C++代码在编译时会根据参数列表对函数进行 重命名,重命名的函数名具有唯一性。
所以说,C++中 函数重载 其实是编译器给我们的一种假象,在编译器编译代码时 函数重命名 机制 会将具有相同函数名、不同参数的函数重命名为不同(全局唯一)的函数,这样在机器指令层面上,重载的函数就拥有了不同的函数名(实际上机器指令层面没有重载的概念存在)。
显然通过重命名,可以有效避免编译器在程序链接阶段无法找到对应的函数。
C++ 程序就是依赖 编译代码时 函数重命名 机制 实现的 函数重载。
从这个角度讲,函数重载仅仅是语法层面的,本质上它们还是不同的函数,占用不同的内存,入口地址也不一样。
8.5.3 重载决议(Overload Resolution)
C++函数重载过程中的二义性和类型转换: http://www.gamecolg.com/program_m_onvideo_m_inkey_m_gta1659766209014.html
当发生函数调用时,编译器会根据传入的实参去逐个匹配,以选择对应的函数,如果匹配失败,编译器就会报错,这叫做 重载决议(Overload Resolution)。
C++ 标准规定,在进行重载决议时编译器应该按照下面的优先级顺序来处理实参的类型:
C++ 标准还规定,编译器应该按照从高到低的顺序来搜索重载函数,首先是精确匹配,然后是类型提升,最后才是类型转换;一旦在某个优先级中找到唯一的一个重载函数就匹配成功,不再继续往下搜索。
如果在一个优先级中找到多个(两个以及以上)合适的重载函数,编译器就会陷入两难境地,不知道如何抉择,编译器会将这种模棱两可的函数调用视为一种错误,因为这些合适的重载函数同等“优秀”,没有一个脱颖而出,调用谁都一样。这就是函数重载过程中的二义性错误。
Tips: 注意,类型提升和类型转换不是一码事!类型提升是积极的,是为了更加高效地利用计算机硬件,不会导致数据丢失或精度降低;而类型转换是不得已而为之,不能保证数据的正确性,也不能保证应有的精度。类型提升只有上表中列出的几种情况,其他情况都是类型转换。
多个参数时的二义性 当重载函数有多个参数时也会产生二义性,而且情况更加复杂。C++ 标准规定,如果有且只有一个函数满足下列条件,则匹配成功:
- 1、该函数对每个实参的匹配都不劣于其他函数;
- 2、至少有一个实参的匹配优于其他函数。
九、实现C++和C的混合编程
9.1 C++ 和 C 语言之间的关系
简单的理解,C++ 就是在 C 语言的基础上增加了一些新特性,从大的方面讲,C++ 不仅支持面向过程编程,还支持面向对象编程和泛型编程;从小的方面讲,C++ 还支持命名空间、函数重载、内联函数等等。
既然这样,那么在一个项目中,能否既包含 C++ 程序又包含 C 程序呢?换句话说,C++ 和 C 可以进行混合编程吗?
要知道,在 C++ 出现之前,很多实用的功能都是用 C 语言开发的,很多底层的库也是用 C 语言编写的。这意味着,如果能在 C++ 代码中兼容 C 语言代码,无疑能极大地提高 C++ 程序员的开发效率。
而恰恰答案也正是我们想要的,C++ 和 C 可以进行混合编程。但需要注意的是,由于 C++ 和 C 在程序的编译、链接等方面都存在一定的差异,而这些差异往往会导致程序运行失败。 示例:
|
|
在此项目中,主程序是用 C++ 编写的,而 display() 函数的定义是用 C 语言编写的。从表面上看,这个项目很完整,我们可以尝试运行它:
|
|
如上是调用 GCC 编译器运行此项目时给出的错误信息,指的是编译器无法找到 main.cpp 文件中 display() 函数的实现代码。导致此错误的原因,就是因为 C++ 和 C 编译程序的方式存在差异。
比如对函数 重命名 的方式存在差异, C语言只是简单在函数名前加
_
, C++ 重命名操作更加复杂。
我们知道,之所以 C++ 支持函数的重载,是因为 C++ 会在程序的编译阶段对函数的函数名进行 重命名, 函数调用处根据函数名和参数列表 也对函数进行 重命名。
这也就意味着,使用 C 和 C++ 进行混合编程时,考虑到对函数名的处理方式不同,势必会造成编译器在程序链接阶段无法找到函数具体的实现,导致链接失败。
幸运的是,C++ 给出了相应的解决方案,即借助 extern "C"
,就可以轻松解决 C++ 和 C 在处理代码方式上的差异性。
9.2 使用 extern “C” 实现C++和C的混合编程
extern
是 C 和 C++ 的一个关键字,但对于 extern "C"``,大可以将其看做一个整体,和 extern 毫无关系。
extern “C”``` 既可以修饰一句 C++ 代码,也可以修饰一段 C++ 代码,它的功能是让编译器以处理 C 语言代码的方式来处理修饰的 C++ 代码。
仍以本节前面的实例项目来说,main.cpp 和 myfun.c 文件中都包含 myfun.h 头文件,当程序进行预处理操作时,myfun.h 头文件中的内容会被分别复制到这 2 个源文件中。对于 main.cpp 文件中包含的 display() 函数来说,编译器会以 C++ 代码的编译方式来处理它;而对于 myfun.c 文件中的 display() 函数来说,编译器会以 C 语言代码的编译方式来处理它。
为了避免 display() 函数以不同的编译方式处理,我们应该使其在 main.cpp 文件中仍以 C 语言代码的方式处理,这样就可以解决函数名不一致的问题。因此,可以像如下这样来修改 myfun.h:
|
|
可以看到,当 myfun.h 被引入到 C++ 程序中时,会选择带有 extern “C” 修饰的 display() 函数;反之如果 myfun.h 被引入到 C 语言程序中,则会选择不带 extern “C” 修饰的 display() 函数。由此,无论 display() 函数位于 C++ 程序还是 C 语言程序,都保证了 display() 函数可以按照 C 语言的标准来处理。
再次运行该项目,会发现之前的问题消失了,可以正常运行.
在实际开发中,对于解决 C++ 和 C 混合编程的问题,通常在头文件中使用如下格式:
|
|
由此可以看出,extern "C"
有 2 种用法:
- 如果仅用于修饰一句 C++ 代码时,直接将其添加到该函数代码的开头即可;
- 如果用于修饰一段 C++ 代码,只需为 extern “C” 添加一对大括号
{}
,并将要修饰的代码囊括到括号内即可。