C++快速入门(三)
C++快速入门(三)
1. 高级强制类型转换
静态对象强制类型转换
先上一段代码:
我们定义一个基类 Company ,让 TechCompany 完全继承 Company 的属性(相当于一个空头公司)。这样,Company 和 TechCompany 的属性以及占用的空间大小相同。尝试用 Company 类型的指针指向 TechCompany 类型的对象,再将该指针赋值给 TechCompany 的指针。
1 |
|
可以看到这样编译会报错,用 Company 类型的指针指向 TechCompany 类型的对象成功了,但是将该指针赋值给 TechCompany 的指针不行。
我们强转一下38行处的赋值操作。
1 | TechCompany *techCompany = (TechCompany *)company; |
可以看到代码正常执行了。
注意:company 和 techCompany 指向同一片内存,只能进行一次释放,不能重复释放:
如果修改为下面的代码,重复释放,将会导致程序异常,被终止。
1 | delete company; // 释放 company 指向的内存 |
可以看到,虽然编译正常通过了,但是程序执行过程中出现问题。
动态对象强制类型转换
上面的例子还有一个问题没有解决:万一被强制转换的类型和目标类型的结构完全不同,怎么办?
编译器很笨,它将仍然按照我们的代码执行,这样是相当危险的!随时可能崩溃以及被崩溃。
因为在类继承关系之间跳来跳去(也就是对有关对象进行强制类型转换)在面向对象的程序里很重要,所以 C++ 准备了几个新的强制类型转换操作符(高级)!
语法 | 说明 |
---|---|
const_cast<MyClass *>(value) |
用来改变 value 的“常量性” |
dynamic_cast<MyClass *>(value) |
最常用的一个。 用来把一种类型的对象指针安全地强制转换为另一种类型的对象指针。 注:如果 value 的类型不是一个 MyClass 类(或 MyClass 的子类)的指针,这个操作符将返回 NULL。 |
reinterpret_cast<T>(value) |
在不进行任何实质性的转换的情况下,把一种类型的指针解释为另一种类型的指针或者把一种整数解释为另一种整数。 |
static_cast<T>(value) |
用来进行强制类型转换而不做任何运行时检查,老式强制类型转换操作的替代品。 |
注:当然,你可以在 C++ 中继续使用 C 的强制转换操作符,但表中的操作符还能进行必要的类型检查,因而能够改善程序的可靠性。
动态强制类型转换的语法看起来更像一个函数调用:
1 | Company *company = new Company("Huawei", "Nova"); |
修改一下上面主函数的部分:
1 | int main() { |
2. 避免内存泄漏
分配一个内存块但忘记释放它,这时一种严重的错误。这样的内存块将等到程序执行结束时才会被释放掉。
如果程序会运行很长时间(例如在服务器上,注意不是所有的操作系统都像 Windows 一样每天都要重启)并且在不停地申请新内存块,忘记释放那些已经不在使用地老内存块将迟早把内存消耗殆尽,直接导致后面地 new 操作无法执行甚至是崩溃!
这样的编程漏洞称为内存泄漏(memory leak)。
除静态内存等其他存储空间外,栈空间向下发展,堆空间向上发展,当二者撞到一块时,程序就崩溃了。
Windows 系统过一段时间要重启,不然会出现性能下降,这时内存碎片管理机制不够强导致的。
内存泄漏方式一:new 语句返回地址丢失
new 语句所返回的地址是访问这个内存块的唯一线索,同时也是 delete 语句用来把这个内存块归还给内存池的唯一线索。
1 | int *x; |
上面的代码块意味着地址值(保存在 x 中)丢失了,就会发生内存泄漏问题。
内存泄漏方式二:指针变量无意中被改写
地址丢失还可能是指针变量无意中被改写导致的:
1 | int *x; |
这将导致内存泄漏。
内存泄漏方式三:用来保存内存块地址的指针变量作用域出问题
导致内存泄漏的原因还可能是:用来保存内存块地址的指针变量作用域问题。例如:
1 | void foo() { |
foo()
函数结束时,指针变量 x 将超出它的作用域,这意味着它将消失,它的值当然也跟着消失了。
有两种方法可以堵住这样的漏洞:
- 在 return 之前
delete x;
- 让函数把内存块地址
x
返回给调用者
虽然全局变量的作用域是整个程序,但不建议使用全局变量,跨文件调用可能导致忘记定义过。
动态内存不存在作用域的问题,一旦被分配,内存块就可以在程序的任何地方使用。
因为动态内存没有作用域,所以必须由程序员来跟踪它们的使用情况,并在不再需要用到它们的时候把它们及时归还给系统。
这里需要特别注意:虽然动态内存分配的内存块没有作用域,但用来保存其地址的指针变量是受作用域影响的。
3. 命名空间和模块化编程
模块化和命名空间是两个相互关联的概念:
-
模块化(modularization)
把程序划分为多个组成部分(即“模块”)
通过把程序代码分散到多个文件里,等编译程序时再把那些文件重新组合在一起。
-
命名空间(namespace)
3.1 模块化编程
头文件
可以借助于 C++ 的预编译器和编译器的能力把一个复杂的应用程序划分为多个不同的文件,而仍保持它在内容和功能上的完整。
C++ 预处理器的 #include
指令提供了一种能够让编译器再编译主程序时把其他文件的内容包括进来的机制。
头文件的基本用途是提供必要的函数声明和类声明。
头文件可以细分为系统头文件和自定义头文件。
- 系统头文件:可以保证 C++ 代码的可移植性,确保同样的 C++ 代码在不同的操作系统上做同样的事情。
- 自定义头文件:自定义头文件的文件名要放在双引号里。
#include "awellfrog.h"
头文件时一些以 .h
作为扩展名的头文件和其余的程序文件放在同一个子目录里,或者在主程序目录下专门创建一个子文件夹来集中存放它们。可以用头文件来保存程序的任何一段代码,如函数或类的声明,但一定不要用头文件来保存它们的实现。
与标准的 C++ 源代码文件(.cpp
)文件相比,在头文件里应该使用更多的注释。绝大多数头文件是通用型的,不隶属于任何特定的程序,所以至少要把它的用途和用法描述清楚。
应该在注释里的内容包括:
- 创建日期、文件用途、创建者姓名、最后一次修改日期、有什么限制和前提条件等。
- 另外头文件里的每一个类和函数也应该有说明。
提示
虽说头文件可以用来保存任意代码片段,但典型的做法是只用它们来保存函数声明、用户自定义类型数据(结构和类)、模板和全局性的常量。
一个需要多次调用的函数或一组函数,或者该函数需要在多个程序里调用,就应该把它们的声明拿出来放到一个头文件里。
头文件应该只包含最必要的代码,比如只声明一个类或只包含一组彼此相关的函数。
使用头文件
在创建了头文件后可以用这样的方式引入 #include"awellfrog.h"
。
如果没有给出路径名,编译器将到当前子目录以及当前开发环境中的其他逻辑子目录里去寻找头文件。
为了消除编译器的猜测,在导入自己的头文件时可以使用相对路径。如
-
同一目录下
#include "./awellfrog.h"
。 -
头文件位于某个下级子目录(如 include )中,可以用
#include "include/awellfrog.h"
。 -
如果头文件位于某个与当前子目录平行的兄弟子目录中,则需要
#include "../includes/awellfrog.h"
请注意: 在 Windows 中通常使用反斜杠作为路径名里的分隔符,要加个转义符所以
.\\
,比如#include ".\\awellfrog.h"
。
把接口(函数的原型)和实现(函数体的定义)分开是对代码进行模块化的基本原则之一。
创建实现文件
作为一个通用原则,应该把声明放在一个头文件里,把实现代码放在一个 .cpp
文件里。
Linux 命令行编译:g++ -o file1.cpp main.cpp file1.h
C预处理器
如果在两个不同的源文件 .cpp
中包含了同一个头文件 .h
,可能导致其内部的类被声明两次,这时没有必要的。如果一个结构被声明两次还会报错。
怎么解决呢?当然是多个文件保留一个 #include<>
。
C++ 为我们提供了更好的解决方案。利用 C++ 预处理器,我们可以让头文件只在这个类还没有被生命过的情况下才可以声明它。
预处理器的条件指令:
指令 | 说明 |
---|---|
#if |
如果表达式未真,执行代码 |
#else |
如果前面的 #if 表达式为假,执行代码。 |
#elif |
相当于 else if |
#endif |
用来标志一个条件指令的结束 |
#ifdef |
如果本指令所引用的定义已存在,执行代码 |
#ifndef |
如果本指令所引用的定义不存在,执行代码 |
在头文件中常常这样写,防止头文件多次被声明:
1 |
|
3.2 命名空间
随着程序变得越来越复杂,全局作用域里的东西会越来越多,尤其是在使用外部函数库时。
这回导致一个问题:重名。
解决方案之一是给每个变量、函数和类等取一个独一无二的名字,但这难,可能演变成非常庞大的前缀。
更好的解决方案是:巧用命名空间。
命名空间(namespace) 是由用户定义的范围,同一个命名空间里的东西只要在这个命名空间由独一无二的名字就行。因此,如果某个程序有许多的头文件或已编译文件,它们又各自声明了许多的东西,命名空间可以为它们提供保护。
创建命名空间
先写出关键字 namespace ,再写出这个命名空间的名字,然后把这个命名空间里的东西全部括在一对花括号里就行了。
1 | namespace myNamespace { |
使用命名空间
访问命名空间中的内容有三种方式:
1 | // 方法一: |
4. 链接和作用域
当一个项目由多个文件构成时,变量的作用域也会受到一定的影响。
与作用域有关的另一个概念是链接。
当同时编译多个文件时:
1 | g++ -o test main.cpp file1.cpp |
每个源文件都被称为一个翻译单元(translation unit),再某个翻译单元里定义的东西在另一个翻译单元里使用正是链接发挥作用的地方。
作用域、链接和存储类是相互关联的概念,它们有许多共通的术语,只是观察和描述问题的角度不同罢了。
存储类(storage class)
每个变量都有一个存储类,它决定着程序将把变量的值存储在计算机上的神马地方、如何存储,以及变量应该有着怎样的作用域。
auto: 默认的存储类是 auto,但你不会经常看到这个关键字,因为它是默认的。自动变量存储在称为栈(stack)的临时内存里并有着最小的作用域,当程序执行到语句块或函数末尾的右花括号时,它们将被系统回收(栈回收),不复存在。
static: static 变量在程序的声明期内将一直保有它的值而不会消亡,因为它们是存储在静态存储区,生命周期为从申请到程序退出(和全局变量一样)。
static 变量可以有 external 或 internal 链接。
extern: 有多个翻译单元(源文件)时非常重要。这个关键字用来把另一个翻译单元里的某个变量声明为本翻译单元里的一个同名全局变量。用 extern 关键字相当于告诉编译器:“请相信我,这个变量在其他翻译单元里肯定存在,它只是没有在这个文件里声明而已!”
注意:编译器不会为 extern 变量分配内存,因为在其他地方已经为它分配过内存了。
register: register 变量存储速度最快,但有些编译器可能不允许使用这类变量(因为寄存器数量有限,如果随意使用可能导致调度效率下降)。
变量的链接和作用域
链接是什么?
在使用编译器建议程序时,它实际上是由 3 个步骤构成:
-
执行预处理器指令;
例如把
#include
指令替换为相应的头文件里的代码,总的效果是头文件里的代码就像从一开始就在.cpp
文件里似的。 -
把
.cpp
文件编译成.o
文件(二进制文件);把 C++ 代码转换为一个编译目标文件,在这一步骤中,编译器将为文件里的变量分配必要的内存并进行各种错误检查。
-
把
.o
文件链接成一个可执行文件。如果只有一个 C++ 源文件,该步骤只是增加一些标准库代码和生成一个可执行文件。
当同时编译多个源文件时,在编译好每一个组件之后,编译器还需要把它们链接在一起才能生成最终的可执行文件。
如今的编译器都是一次完成所有的处理,所有看不到这些步骤。
当一个编译好的对象(即翻译单元)引用一个可能不存在于另一个翻译单元里的东西时,潜在的混乱就开始出现了。例如,extern int flag;
但是这个 flag 并没有被定义过。
链接分为三种情况: 凡是有名字的东西(函数、类、常量、变量、模板、命名空间,等等)必然术语其中之一
-
外链接(external): 每个翻译单元都可以访问这个东西(前提是只要它知道有这么个东西存在)。
普通的函数、变量、模板和命名空间都有外链接。
1
2
3
4
5
6
7
8
9
10
11
12
13
14错误用法:
// file1.cpp
int a = 1;
// file2.cpp
int b = a;
正确用法:
// file1.cpp
int a = 1;
// file2.cpp
extern int a;
int b = a; -
内链接(internal): 在某个翻译单元里定义的东西只能在翻译单元里使用,在任何函数以外定义的静态变量都有内链接。
1
2
3
4
5// this.cpp
static int c = 8;
// that.cpp
static int c = 9;这两个文件各有一个同名变量,但它们是毫不相干的两个变量。
-
无链接(none): 在函数里定义的变量值存在于该函数内部,根本没有任何链接。例如局部变量。
下面看一个多文件调用的例子:
1 | // header.h |
1 | // this.cpp |
1 | // that.cpp |
可以看到上面的程序成功执行了。
如果将 header.h
中的静态变量改为全局变量的定义:
1 | // header.h |
可以看到,程序报错,提示重复定义 headerNum
。所以说,着中国写法是不规范的,不建议在 .h
中定义变量,尤其是全局变量。
5. 函数模板
C++ 程序设计范型:
- 面向过程式范型把程序划分成不同的函数
- 按照面向对象式范型把代码和数据组织成各种各样的类并建立类之间的继承关系
- 泛型编程技术支持程序员创建函数和类的模板(template),而不是具体的函数和类
这些模板可以没有任何类型:它们可以处理的数据并不仅限于某种特定的数据类型。当程序需要用到这些函数中的某一个时,编译器将根据模板即时生成一个能够对特定数据类型进行处理的代码版本。
泛型编程技术可以让程序员用一个解决方案解决多个问题。
C++ 有强大的标准模板库(Standart Template Library, STL)。
STL 库是泛型编程技术的经典之作,它包含了许多非常有用的数据类型和算法。
基本的模板语法
以下代码定义了一个名为 foo()
的函数模板:
1 | template <class T> // 字母 T 用来告诉编译器,接下来函数里传入的不确定的数据类型是 T |
这里的 class 不是类,只是一种约定俗称的写法。
在告诉计算机 T 是一种类型之后,就可以像对待一种普通数据类型那样使用它了。
函数模板
普通交换变量的程序:
1 | void swap(int &a, int &b) { |
想要交换其他数据类型可以重载函数 swap(double &a, double &b);
,但是如果有很多个需要交换的数据类型,这将变得很麻烦,需要重复写很多相同的函数。
用模板可以很好的解决这个问题:
1 |
|
可以看到,只定义了一个模板函数,成功实现了两种数据类型的交换。真方便!
注意:
在创建模板时,还可以使用
template<typename T>
来替代template<class T>
,它们的含义是一样的。不要把函数模板分成原型和实现两部分。如果编译器看不到模板的完整代码,它就无法正确地生成代码。所以得到的结果将无法预测。
为了明确地表示
swap()
是一个函数模板,还可以使用swap<int>(i1, i2)
语法来调用这个函数。这将明确告诉编译器它应该使用哪一种类型。如果某个函数对所有数据类型都将进行同样地处理,就应该把它写为一个模板。
如果某个函数对不同地数据类型将进行不同地处理,就应该对它进行重载。
6. 类模板
类模板和函数模板非常相似:同样是先由你编写一个类地模板,再由编译器在你第一次使用这个模板时生成实际代码。
1 | template <class T> |
下面利用类模板实现栈操作:
1 |
|
可以看到输出结果满足后入先出。
7. 内联模板
内联函数
内联即 inline。内联函数从源代码层看,有函数的结构,而在编译后却不具备函数的性质。编译时,类似宏替换,使用函数体替换调用处的函数名。
一般在代码中用 inline 修饰,但能否形成内联函数,需要看编译器对该函数定义的具体处理。
内联模板
1 | inline int add(int x, int y, int z) { |
在程序中,调用其函数时,该函数在编译时被替代,而不像一般函数那样是在运行时被调用。
1 | class Person { |
除了可以更好地帮助编译器处理类模板之外,使用内联方法还有一个很好地作用:可以少打些字,并让源代码地可读性变得更好。
例子:把前面模板类栈的定义直接写进去:
1 |
|
写类模板地时候,使用内联简直方便快捷,我直接泪目。
注意:
- 这个 Stack 模板是不安全的,一定要给它增加一个副本构造器和一个赋值操作符重载。
- 这里地
push()
和pop()
操作可能访问越界。遇到上述两种情况应该抛出一个异常处理。
C++ 并没有限制只能使用一个类型占位符,如果类模板需要一种以上地类型,根据具体情况多使用几个占位符即可。
1
2
3
4
5
6 template <class T, class U> // T U 可以换成其他任意字母
class MyClass {
// ... ...
};
MyClass<int, float> myClass; // 实例化地时候声明数据类型
8. 容器
容器: 能容纳两个或更多个值得数据结构。
数组是 C++ 唯一直接支持得容器,但数据并不适用来解决所有问题。
C++ 标准库提供的向量(vector)类型从根本上解决了数组先天不足的问题。
就像可以创建各种不同类型的数组一样,我们也可以创建不同类型的向量:
1 | std::vector<type> vectorName; |
1 |
|
迭代器
上面的例中,遍历容器仍然使用的是数组下标访问的方式。对于 vector 来说刚好支持下标访问,但如果是栈这种容器,我们就不得不对程序做很多修改才能实现访问。
C++ 标准库提供的各种迭代器(iterator)就是这么来的。
迭代器: 是一种功能非常有限却非常实用的函数,提供一些基本操作符:* ++ == != =
等等。
迭代器是所谓的智能指针,具有遍历复杂数据结构的能力。对标准库中的每一种容器,迭代器都支持遍历。每一种容器都碧玺提供自己的迭代器,事实上每种容器都将器迭代器以嵌套的方式定义于内部。因此各种迭代器的接口相同,型号却不同,这就是所谓泛型程序设计的概念:所有操作行为都使用相同接口,虽然它们的具体实现不同。
1 |
|
迭代器的真正价值体现在它们可以和所有容器配合使用,而迭代器去访问容器元素的算法可以和任何一种容器配合使用。
《C++快速入门》部分结束,算是基本了解了 C++ ,接下来要继续努力呀!