Post

一文看懂C++

一文看懂C++

C++学习需要掌握的两大部分内容,一个是C++语法,一个是C++标准模板库。这篇笔记主要总结C++语法,C++标准模板库将在下一篇笔记中总结。笔记结构以《C++从入门到精通》一书为基础。

编译和执行

我们平时所说的程序,一般指双击后就可以直接运行的程序,这样的程序又称为可执行程序。

Windows系统下,可执行程序的后缀一般为.exe。可执行程序的内部是一系列计算机指令和数据的集合,它们都是二进制形式的,CPU可以直接识别。但我们使用C、C++、Java、Python等高级语言编写的程序(又称为源代码),对于开发人员来说更易理解,但CPU却无法识别(CPU只认识几百个二进制形式的指令)。这时就需要一个工具,将这些源代码转换成CPU能够识别的二进制指令,即将其翻译成.exe可执行程序。该工具就称为编译器(compiler),这个翻译过程就称为编译(compile)。

C/C++编译器有很多种,不同的操作系统下通常使用不同的编译器。例如,Windows平台下常用微软编译器(cl.exr),它被集成在Visual Studio或Visual C++中,一般不单独使用;Linux平台下常用GUN组织开发的GCC;Mac平台下常用LLVM/Clang,它被集成在Xcode中。

用户开发C++程序的过程,可以简化为编辑、编译、连接、运行4个步骤。

  1. 编辑。编辑就是在任一款C++开发工具中输入代码,然后将其保存为.cpp源文件的过程。例如,编辑一段代码,并将其保存为Sample.cpp源文件。
  2. 编译。编译就是将代码源文件(.cpp文件)编译成目标文件(.obj文件)的过程。例如,编写好代码后,在任一款C++开发工具中单击编译按钮,系统将自动对代码进行编译,得到Sample.obj文件。
  3. 连接。连接是将编译后的目标文件连接生成可执行程序的过程,如将Sample.obj和lib库文件连接成Sample.exe可执行程序(lib库是编译好的提供给开发者使用的目标模块)。在有多个源文件的工程中,例如有Sample1.cpp、Sample2.cpp、Sample3.cpp,会编译成多个目标模块Sample1.obj、Sample2.obj、Sample3.obj,链接器会将程序涉及的目标模块连接成可执行程序。
  4. 运行。运行是执行.exe可执行程序的过程,执行程序后可得到程序运行结果。
1
2
g++ -o hello hello.cpp
./hello

变量和常量

变量和常量是C++中的基本概念。变量是存储数据的容器,常量是不可变的值。常量包括整型常量、浮点型常量、字符常量、字符串常量等。

  • 整型常量:100, -100, 0
  • 浮点型常量:3.14, -3.14, 0.0
  • 字符常量:’a’, ‘b’, ‘c’
  • 字符串常量:”Hello, World!”

注意:字符’A’与字符串”A”含义不同。内存中,字符’A’则只包含一个字符;字符串”A”由’A’和’\0’两个字符组成,字符串的长度是2。

变量使用之前,一定要先进行声明或定义。int a;是变量声明,int a = 10;是变量定义。

变量数据类型

C++常见的变量数据类型和其占用的内存字节数:

数据类型子类型内存占用(字节)
整数类型int4
 short2
 long4
 long long8
浮点类型float4
 double8
 long double16
布尔类型bool1
指针类型void*4
字符类型char1
字符串类型char*4

类型转换

1
2
3
4
int a = 10;
double b = 3.14;
double c = a + b; // 隐式类型转换
double d = (double)a + b; // 显式类型转换

数据的输入与输出

C++定义了iostream基类,以及由其派生的输入流类istream和输出流类ostream。标准I/O操作有4个类对象,分别是cin、cout、cerr和clog。

  • 当进行键盘输入操作时,使用cin流;
  • 当进行显示器输出操作时,使用cout流;
  • 当进行错误信息输出操作时,使用cerr流或clog流。

C++数据流通过重载运算符” » “和” « “执行输入和输出操作。输出操作使用左移运算符” « “向流中插入一个字符序列,输入操作使用右移运算符” » “从流中提取一个字符序列。

1
2
3
int a;
cin >> a;
cout << a;

通过键盘输入数据时,只有输入完数据并按下Enter键后,系统才会把该行数据存入键盘缓冲区,供cin流顺序读取给变量。另外,从键盘上输入的每个数据之间必须用空格或Enter键分开,因为cin为一个变量读入数据时是以空格或Enter键作为其结束标志的。

printf()函数输出:C++中保留了C语言的输出函数printf(),使用它可将任意数量、类型的数据输出到屏幕中。

运算符和表达式

C++的运算符包括算术运算符、关系运算符、逻辑运算符、位运算符、赋值运算符、其他运算符。

  • 算术运算符:+、-、*、/、%
  • 关系运算符:==、!=、>、<、>=、<=
  • 逻辑运算符:&&(逻辑与)、 (逻辑或)、!(逻辑非)
  • 位运算符:&(位与)、(位或)、^(位异或)、~(位取反)、«(左移)、»(右移)
  • 赋值运算符:=、+=、-=、*=、/=、%=
  • 其他运算符:,、->、::、.、()、[]、{}
    • 逗号运算符:,用于在单个语句中同时执行多个操作,并返回最后一个表达式的值。逗号运算符的优先级最低,结合方向为自左至右。
    • 箭头运算符:->用于指向类、结构、联合、共用体等成员的指针。
    • 域运算符:::用于访问类、结构、联合、共用体等成员的成员。
    • 点运算符:.用于访问类、结构、联合、共用体等成员的成员。
    • 括号运算符:()用于改变表达式的运算顺序。
    • 下标运算符:[]用于访问数组、字符串、向量等容器中的元素。
    • 花括号运算符:{}用于初始化数组、字符串、向量等容器。
    • sizeof运算符:sizeof用于计算变量或数据类型所占用的内存字节数。

运算符的优先级:位运算符>逻辑运算符>关系运算符>算术运算符>赋值运算符>逗号运算符。

由于位运算的速度很快,在程序中遇到表达式乘以或除以2的幂的情况,一般采用位运算来代替。但是要注意数据位溢出问题。

条件判断语句

C++的条件判断语句包括if…else语句、switch语句。

1
2
3
4
5
6
7
if (a > b) {
    cout << "a is greater than b" << endl;
} else if (a < b) {
    cout << "a is less than b" << endl;
} else {
    cout << "a is equal to b" << endl;
}

条件运算符?:是一个三目运算符,由其构成的条件表达式能像if…else语句一样完成判断。

1
int c = a > b ? a : b; // 条件运算符

switch语句用于多分支选择,每个分支对应一个常量或常量表达式。

1
2
3
4
5
6
7
8
9
10
switch (a) {
    case 1:
        cout << "a is 1" << endl;
        break;
    case 2:
        cout << "a is 2" << endl;
        break;
    default:
        cout << "a is not 1 or 2" << endl;
}

注意,switch语句中,每个case分支都使用了break语句,作用是匹配成功后跳出整个switch判断。如果不使用break语句会导致无法跳出switch判断,自匹配成功的case语句起,后面的每条case语句都会被执行一次,且不再进行判断。

循环语句

C++的循环语句包括for循环、while循环、do-while循环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// for循环
for (int i = 0; i < 10; i++) {
    cout << i << endl;
}

// while循环
while (a < 10) {
    cout << a << endl;
    a++;
}

// do-while循环
do {
    cout << a << endl;
    a++;
} while (a < 10);

函数

1
2
3
int add(int a, int b) {
    return a + b;
}

为了降低程序出错的概率,凡不要求返回值的函数都应定义为void空类型。

函数重载

C++中同一作用域内不能定义同名的变量,否则程序会编译出错;也不能定义同名的函数,否则会带来冲突问题。但实际开发中,经常需要处理功能几乎相似,仅传入参数不同(参数类型或参数个数不同)的问题,编写大量的函数并分别命名非常麻烦。为了提高代码的复用性和可读性,C++允许通过函数参数列表来识别同名函数,这就是函数重载。在定义重载函数时,应注意函数的返回值类型不作为区分重载函数的一部分。

1
2
3
4
5
6
7
int add(int a, int b) {
    return a + b;
}

double add(double a, double b) {
    return a + b;
}

内联函数

内联函数是C++中的一种函数,它可以在编译时将函数体插入到调用它的地方,从而避免函数调用的开销(即程序文件中移动指针寻找调用函数地址带来的开销)。内联函数在编译时会展开,因此会增加代码量,但可以提高程序的执行效率。

内联函数通常用于频繁调用的函数,例如数学运算、字符串操作等。内联函数通常定义一条返回语句,不能包含循环或者switch语句。

1
2
3
inline int add(int a, int b) {
    return a + b;
}

变量的存储类型

变量的数据类型之外,变量还可以分为4种存储类型:

  • 自动变量(auto):自动变量在函数内部定义,当函数调用结束时自动销毁。
  • 静态变量(static):静态变量在函数内部定义,当函数调用结束时不会销毁,下次调用时仍保留上一次的值。
  • 全局变量(extern):一个C++程序通常包含多个源文件。由于C++文件中定义的变量和函数,只能被本文件中的函数调用。所以,要想调用其他源文件中的某个全局变量,就需要使用extern关键字声明该变量。
  • 寄存器变量(register):寄存器变量在函数内部定义,存储在CPU的寄存器中,速度比自动变量更快。

数组

数组是C++中的一种数据结构,它是一组相同类型的数据元素的集合。数组可以是一维的,也可以是多维的。在C++中,数组声明时的长度必须是编译时常量,不能是变量。

1
2
3
4
5
6
7
8
9
// 正确 - 使用常量定义数组
int a[10]; // 一维数组
int b[3][4]; // 二维数组
int c[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; // 初始化数组
int c[10] = {1, 2, 3, 4, 5}; // 部分初始化,未初始化部分自动赋值为0

// 错误 - 不能使用变量定义数组长度
int n = 10;
int arr3[n];  // 编译错误!

如果需要在运行时动态确定数组大小,应该使用:

  1. vector容器(推荐)
    1
    2
    
    #include <vector>
    vector<int> arr(n);  // n可以是变量
    
  2. 动态内存分配
    1
    2
    3
    4
    
    int n = 10;
    int* arr = new int[n];  // 使用完需要删除
    // ... 使用数组 ...
    delete[] arr;  // 不要忘记释放内存
    

字符数组和字符串

用来存放字符的数组就是字符数组。字符数组中,一个元素存放一个字符。

1
char str2[10] = {'H', 'e', 'l', 'l', 'o'}; // 字符串常量,没有结束符

字符数组常用于存储字符串,此时要连同字符串结束符’\0’一起保存。可以使用字符串为字符数组赋值

1
char str[] = "Hello"; // 字符串常量,有结束符

字符数组与字符串的本质区别就在于是否含有字符串结束符’\0’。字符数组中,其元素可以存放任意字符,并不要求最后一个字符必须是’\0’。但作为字符串使用时,就必须以’\0’结束,缺少这一标志时,系统虽然不一定会报错(有时甚至会得到看似正确的运行结果),但这种潜在的错误可能会导致严重后果。因为在字符串处理过程中,系统在未遇到字符串结束符之前,会一直向后访问,以致会超出分配给字符串的内存空间,或者访问到其他数据所在的存储单元。

字符串处理函数

C++中提供了一些常用的字符串处理函数,可以方便地进行字符串的输入、输出、拼接、比较等操作。

1
2
3
4
5
6
char str[10] = "Hello";
char str2[10] = "World";
strcat(str, str2); // 拼接字符串,注意,字符数组str的长度要足够大,以便能装下连接后的字符串。
strcpy(str, str2); // 复制字符串,把str2中的字符串复制到str中,字符串结束符'\0'也一同复制过去。
strcmp(str, str2); // 比较字符串,对两个数组中的字符串逐个字符(ASCII码值)进行比较并返回比较结果。如果str=str2,则返回0;如果str>str2,则返回1;如果str<str2,则返回-1。
strlen(str);       // 计算字符串长度,返回字符串中字符的个数,不包括字符串结束符'\0'。

指针

指针是一种灵活、高效的内存访问机制,可以通过地址对变量直接进行操作。注意,指针只能访问内存,不能访问寄存器,因此也不能访问寄存器变量。

指针

1
2
3
int *p = NULL; // 定义一个指针变量,并初始化为NULL,表示指针变量p不指向任何有效的内存地址,防止野指针的出现
int a[10]; // 定义一个数组,数组名a是一个常量,表示数组首元素的地址
p = a; // 将指针p指向数组a

指针变量p的值是数组a的首地址,即a[0]的地址。p+1表示指针p指向下一个元素,即a[1]的地址。p+i表示指针p指向第i个元素,即a[i]的地址。指针指向的是某种数据类型的地址,由于不同数据类型有着不一样的字节宽度,所以指针自增或自减一次,会增加或减少不同的字节数,而不是单纯的加1或减1。通过sizeof运算符可快速得知不同数据类型的字节宽度。

指针和数组

内存分配:堆与栈

在程序中定义一个变量后,其值会被放入内存中。你是否好奇,变量的值到底被存放在哪里?

如果开发者未向系统申请动态内存,变量的值将会被放入栈中。在栈中,变量占用的内存大小是无法改变的,它们的占用与释放与变量定义的位置和储存方式有关。

与栈相对应,堆是一种动态内存。当开发者向系统申请了动态分配内存,该变量将会被放入堆中。根据需要,这个变量的内存大小可以改变,内存申请和释放的时机由开发者操控。

C++中,new用于申请动态堆内存空间,delete用于释放动态堆内存空间。动态分配方式虽然灵活,但也带来了新的问题。开发者申请堆内存后,这块内存将由开发者掌握,也就是说系统无法自动释放它,只有开发者使用delete关键字才能将其回收,或者等到程序结束时由系统自动回收。

内存销毁

所谓内存销毁,指的是系统判定该内存不是开发者可正常使用的空间,因此不允许再使用它。

栈内存由系统自动分配和回收内存空间。例如,局部变量都存储在栈内存中,退出局部作用域,对应的内存空间就会被系统销毁(本质上是将它们分配给别的任务),此时该内存区域将不可复用。若有指针曾指向之前的栈空间,需将其置为空值(NULL)。开发者使用被销毁的内存,会造成许多意想不到的问题。

内存泄漏

堆内存需要由开发者自行申请和回收。开发者使用new关键字申请堆内存时,会用一个指针指向它,且释放堆内存前该指针都不能指向其他位置。一旦指针指向了其他位置,该堆内存将无法再被定位和回收,犹如丢失了一般,这种情况称为内存泄漏。

内存泄漏是指在程序运行过程中,由于某些原因导致某些内存块无法被回收而一直占用系统资源的现象。内存泄露会导致程序运行速度变慢甚至崩溃。注意,内存泄漏主要针对堆内存。

依靠调试程序很难发现内存泄漏问题。所以,开发者在使用动态堆内存时一定要形成良好的编程习惯。分配完的内存及时释放,使用完的指针及时置空。

1
2
3
4
int *p = new int; // 申请一个int类型的内存空间
*p = 10; // 给内存空间赋值
delete p; // 释放内存空间
p = NULL; // 释放后将指针置空

引用

左值引用

C++ 11标准中提出了左值引用的概念。引用实际上是一种隐式指针,它为对象建立了一个别名,通过操作符“&”来实现。

  1. 一个C++引用被初始化后,无法再使用它去引用另一个对象,即引用不能被重新约束。
  2. 引用变量只是其他对象的别名,操作它与操作原对象的效果完全相同。
  3. 引用必须进行初始化,否则会系统报错。
1
2
3
4
5
int a = 10;
int &ia = a; // 定义一个引用变量ia,并将其初始化为a的别名

//ia和a的值相同,占用同一块内存空间,对ia的任何操作都会影响到a的值。
ia = 20; // 修改ia的值,a的值也会被修改

指针变量与引用变量的区别:

  1. 指针是一种数据类型,引用不是数据类型;
  2. 引用和变量的数据类型必须相同,不能进行类型转换;
  3. 指针和引用都可以指向其他变量,指针的语法更复杂,而引用的使用方法与普通变量相同。

右值引用

右值引用是C++ 11中引入的新特性,右值与左值的区别在于,右值引用是临时变量,如函数的返回值,当右值引用初始化后,临时变量消失。右值引用初始化后,具有该类型数据的所有操作。

1
2
3
4
5
6
int get() {
    return 10;
}

int &&a = get(); // 右值引用初始化后,临时变量消失
a = 20; // 右值引用初始化后,具有该类型数据的所有操作

构造数据类型

  • 结构体可以将不同的数据类型组合在一起,形成一个新的数据类型。结构体类型是对数据的整合,可使代码更加简洁。
  • 共用体和结构体很相近,它像一个存储空间可变的数据类型,可使程序设计更加灵活。
  • 枚举类型是特殊的常量,增加了代码的可读性。
  • 自定义类型则增加了代码的复用性。

结构体

结构体是C++中的一种数据类型,它是由多个不同类型的数据组合在一起形成的一个新的数据类型。结构体可以包含多个成员变量,每个成员变量可以是不同的数据类型。

1
2
3
4
5
struct student {
    int id;
    char name[20];
    int age;
};

结构体成员的引用:

1
2
3
4
student stu;
stu.id = 1;
stu.name = "张三";
stu.age = 20;

指向结构体的指针:使用结构体,还可以定义指针变量,让一个指针指向该结构体。

1
2
3
4
student *p = &stu;
p->id = 1;
p->name = "张三";
p->age = 20;

共用体

共用体又称为联合体,也是一种构造数据类型。它和结构体有些类似,都是将不同的数据项组织成为一个整体,区别是共用体在内存中占用首地址相同的一段存储单元。

1
2
3
4
5
union data {
    int id;
    char name[20];
    int age;
};

共用体中,多个成员使用的是同一段内存空间,因此其变量所占的内存大小等于其最长成员的大小。一个共用体变量不能同时存放多个成员的值,某一时刻只能存放最后赋给它的值。

枚举类型

实际问题中,有些变量会被限定在一个有限范围内,如一周有7天,一年有12个月、三原色是红黄蓝等。可以将这些变量声明为枚举类型。C++中,枚举类型是一些标识符的集合,从形式上看就是用大括号将不同标识符名称放在一起。枚举变量的值只能取括号内的标识符,每个标识符都对应一个整数值,称为枚举常量。

1
2
3
4
5
enum color {
    red,
    yellow,
    blue
};

枚举类型的值:

  1. 枚举元素作为常量,是有值的,C++编译器按定义时的顺序默认为其赋值0, 1, 2, …。
  2. 可以在定义枚举类型时另行指定枚举元素的值
  3. 如果只给前几个枚举元素赋值,编译器会给后面标识符自动累加赋值。

自定义类型

C++中,使用关键字typedef可以给数据类型定义一个别名。构造数据类型或类在声明时,也可以使用typedef定义别名。

typedef关键字的作用域范围是别名声明所在的区域(包含名称空间)。使用typedef定义的新数据类型的大小同原数据类型一样。

1
2
typedef int INTEGER;
INTEGER a = 10;

typedef主要的两种用途:

  1. 代替复杂、不方便书写和记忆的基本类型名称,如typedef int (*)(int i) pFunc,pFunc就是一个函数指针类型。
  2. 使用其他人开发的类型时,使其类型名符合自己的编码习惯,如typedef std::string STRING,STRING就是std::string的别名。

使用宏定义替换复杂的数据

宏定义是预处理命令的一种,它提供了一种可以替换源代码中字符串的机制。简单来说,宏定义指令#define用来定义一个标识符和一个字符串,以这个标识符来代表这个字符串,在程序中每次遇到该标识符时就用所定义的字符串替换它。宏定义的作用相当于给指定的字符串起了一个别名。

1
#define PI 3.14159

使用#define进行宏定义的好处是:当需要改变一个常量时,只需改变#define命令行,整个程序的常量都会改变,大大提高了程序的灵活性。

宏名要简单且意义明确,一般习惯用大写字母表示,以便与变量名相区别。

实际开发中,一般将所有#define都统一放在文件开始代码处,或放在一个独立的文件中,而不会将它们分散到整个程序中。

带参数的宏定义不是简单的字符串替换,它还要进行参数替换。

1
#define MAX(a, b) ((a) > (b) ? (a) : (b))

用宏替换的一个好处是,因为不存在函数调用,所以可提高代码的速度。但提高速度也有代价,因为重复编码会增加代码的长度。

面向对象

面向对象(object oriented)是一种从组织结构上模拟客观世界的方法,它将所有预处理的问题抽象为对象,通过了解这些对象的属性和行为,解决各类实际问题。

面向对象程序设计具有3个特点,即封装、继承和多态。

  1. 封装:将对象的属性和行为封装起来,其载体就是。类通常对客户隐藏其实现细节,这就是“封装”的含义。
  2. 继承:类与类之间可能存在某种关联,如继承关系。通过继承,子类可直接获得父类的数据结构和方法,节省从头定义这些属性、行为的时间,这就是继承的基本思想。
  3. 多态:多态就是多种形态,即同一个行为,当不同的对象去完成时会产生出不同的状态。例如,“买票”这个行为,普通人买票时是全价买票,学生买票时是半价买票,军人买票时是优先买票。
  • 面向过程编程:现实世界→面向过程建模(流程图、变量、函数)→面向过程语言→执行求解
  • 面向对象编程:现实世界→面向对象建模(类、对象、方法)→面向对象语言→执行求解

C++中的类可以是对同一类型事物进行抽象处理后的结果。类是一种新的数据类型,它和结构体有些相似,是由不同数据类型组成的集合体。不同的是,类比结构体增加了操作数据的行为,这个行为就是成员函数。

类在使用前,需要先进行声明和定义。C++中,使用class关键字定义一个类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Student {
    // 成员变量,表示类的属性,可以是整型、浮点型、字符型、数组、指针和引用等,类的成员变量不能在类的声明中初始化。
    int id;
    char name[20];
    int age;
    char* address;
    // 成员函数,表示类的行为,类体内定义的成员函数称为内联函数,类体外定义的成员函数称为外联函数
    void study() {
        cout << "学习" << endl;
    }
    void play();
};

// 将类体内成员函数的实现放在类体外,此时需要使用域限定符“::”标识该方法属于哪个类。
void Student::play() {
    cout << "玩耍" << endl;
}

大括号内被称为类体或类空间,是定义和声明类成员的地方。关键字public、private、protected是类成员访问修饰符,限定了类成员的访问权限。大括号后要有分号。

C++中还可以将函数的声明和定义放在不同的文件内。一般在头文件中放入函数的声明部分,在实现文件中放入成员函数的实现部分。

构造函数

为类建立对象时,需要调用构造函数,这是因为建立对象时需要对其初始化,如为成员变量赋值、设置类的属性等,这些操作需要在构造函数中完成。

1
2
3
4
5
6
7
// 构造函数也可以具有参数,通过参数完成初始化操作。
Student::Student(int id, char name[], int age) {
    this->id = id;
    strcpy(this->name, name);
    this->age = age;
    this->address = new char[100];
}

复制构造函数:其本质上仍然是一个构造函数,但函数参数是一个已初始化的类对象。通过复制构造函数,可快速为对象建立一个一模一样的副本。

1
2
3
4
5
6
7
8
// 复制构造函数
Student::Student(const Student &stu) {
    this->id = stu.id;
    strcpy(this->name, stu.name);
    this->age = stu.age;
    this->address = new char[100];
    strcpy(this->address, stu.address);
}

析构函数

构造函数和析构函数是类定义中两个比较特殊的成员函数,它们都没有返回值。构造函数名和类名相同,析构函数名则是在类名前加上符号。构造函数用于创建对象时为数据成员赋值,目的是初始化对象。析构函数的功能则相反,在对象删除前起资源清理作用。

使用析构函数时,有如下注意事项:

  1. 一个类中只能定义一个析构函数,且不支持重载。
  2. 析构函数无参数值和返回值,不能使用return语句,也不用加上关键字void。
  3. 若类中未显式定义析构函数,编译器会默认生成一个析构函数。
  4. 析构函数由编译器在对象声明周期结束时自动调用。
1
2
3
4
Student::~Student() {
    cout << "析构函数" << endl;
    delete[] address;
}

类成员

类具有封装性,封装在类里的数据可根据需要设置为外部可见或外部不可见,即其他类是否可以访问该数据成员。类的访问属性可通过public、private和protected3个关键字实现。

  • public属性:公有属性,即类成员对外可见,对内也可见。
  • private属性:私有属性,即类成员对外不可见,对内可见。此为默认属性,未指定访问属性的成员会被默认为是private属性。
  • protected属性:保护属性,即成员对外不可见,对内可见,对派生类可见。

使用inline关键字可以将成员函数定义为内联成员函数。其实,对于成员函数,如果其定义是在类体中,即使没有使用inline关键字,该成员函数也默认是内联成员函数。

  • 在成员变量使用static关键字,可为类定义一个静态成员变量,可被所有的类对象共享,即无论定义多少个类对象,静态成员变量都只有一个。如果静态成员变量被修改,则所有对象的静态成员变量都将发生改变。
  • 在成员函数前添加static关键字,可为类定义一个静态成员函数。静态成员函数只能访问静态成员变量,不能访问普通成员变量。

友元

为了安全性,类中的成员变量一般都是隐藏的。但有时需要允许一些特殊函数能直接读写类的私有或保护成员变量,这就要通过friend关键字和友元机制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Rectangle {
    protected:
        int width;
        int height;
    public: 
        Rectangle() { // 无参构造函数
            width = 0;
            height = 0;
        }
        Rectangle(int lt_x, int lt_y, int rb_x, int rb_y) { // 有参构造函数
            width = rb_x - lt_x;  // 宽
            height = rb_y - lt_y; // 高
        }
        friend int area(const Rectangle &a); // 声明友元函数,计算矩形面积
};

int area(const Rectangle &a) { // 定义友元函数,在外部访问类的保护成员变量
    return a.width * a.height;
}

类的私有方法只允许在该类中访问,其他类是不能访问的。但实际开发中,如果两个类的耦合度比较大,则通过一个类访问另一个类的私有成员会带来很大的便捷性。C++提供了友元类和友元方法(又称为友元函数),以帮助访问其他类的私有成员。

命名空间

一个应用程序中可能存在多个文件,有时会存在同名问题,从而导致程序链接错误。使用命名空间是消除命名冲突的最佳方式。简而言之,命名空间就是一个命名的范围区域,开发者在这个特定范围内创建的所有标识符都是唯一的。

1
2
3
4
5
6
7
8
9
10
11
12
13
namespace myspace1 {
    int a = 10;
}
namespace myspace2 {
    int a = 20;
}
int a = 30;

int main() {
    cout << myspace1::a << endl;  // 输出10
    cout << myspace2::a << endl;  // 输出20
    cout << a << endl;            // 输出30
}

继承

继承(inheritance)是面向对象的三大特征之一,它使一个类可以从现有类中派生,而不必从头开始定义。类继承的实质是以旧类为基础创建新类,新类包含旧类的数据成员和成员函数,且可添加新的数据成员和成员函数。其中,旧类被称为基类或父类,新类被称为派生类或子类。

1
2
3
4
5
6
7
8
9
10
class Employee {
    int id;
    char name[20];
    int age;
    char* department;
};

class Manager : public Employee { // 定义Manager类,继承Employee类,继承方式为public
    char* password;  // 添加新的数据成员
};

不同继承方式下基类成员在派生类中的可访问性:

继承方式基类public成员基类protected成员基类private成员
public继承publicprotected不可访问
protected继承protectedprotected不可访问
private继承privateprivate不可访问

如果希望基类成员既不向外暴露(不能通过对象访问),还能在派生类中使用,可将其声明为protected属性。

重载运算符

运算符实际上是一个函数,运算符重载本质上就是函数重载。函数重载可以让一个函数根据传入的参数执行不同的操作,运算符重载也一样。当遇到模糊运算时,编译程序会自动寻找与传入参数相匹配的运算符函数。

实际上,C++中已经对很多运算符进行了重载。例如,“+”可对不同类型的数据(如int、float等)进行加法操作;“«”既是位移运算符,又可以配合cout向控制台输出数据。

1
2
3
4
5
6
7
8
9
10
11
12
class Complex {
    public:
        double real;
        double imag;

        Complex operator+(const Complex &a, const Complex &b) { // 重载运算符+,实现两个Complex类对象的求和
            Complex c;
            c.real = a.real + b.real;
            c.imag = a.imag + b.imag;
            return c;
        }
};

多态

多态(polymorphism)是面向对象的三大特征之一。C++中,多态是指不同功能的函数可以使用同样的函数名,因此发出同样的消息可以被不同类型的对象接收,产生完全不同的行为。这里,“消息”指对类成员函数的调用,“不同的行为”指不同的实现方式。 简单来说,多态就是调用同一接口,表现出不同的特性。多态分为两类:静态多态和动态多态。静态多态在编译期就能确定要调用哪个函数,包含重载和模板两种方式。动态多态(又称为常规多态)在程序运行阶段根据实际情况来确定调用哪个函数,主要通过虚函数来实现。

虚函数

在类的继承层次结构中,不同层次中可以出现名字、参数个数和类型都相同但功能不同的函数,此时编译器会按照先自身、后基类的顺序进行查找覆盖。

虚函数可以解决派生类和基类中有相同原型成员函数时的函数调用问题。虚函数允许在派生类中重新定义与基类同名的函数,且允许通过基类指针或引用访问基类和派生类中的同名函数,实现了”一个接口,多种实现“的效果。

想象一个简单的场景:动物园里有不同的动物,它们都会”叫”,但叫声不一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Animal {
public:
    virtual void makeSound() {
        cout << "动物发出声音" << endl;
    }
};

class Dog : public Animal {
public:
    void makeSound() {
        cout << "汪汪汪!" << endl;
    }
};

class Cat : public Animal {
public:
    void makeSound() {
        cout << "喵喵喵~" << endl;
    }
};

不使用虚函数时,如果用Animal指针指向Dog或Cat,调用makeSound()永远只会输出”动物发出声音”,因为编译器会根据指针的类型来确定调用哪个版本的makeSound()。

使用虚函数时,如果用Animal指针指向Dog或Cat,调用makeSound()会根据对象的实际类型来确定调用哪个版本的makeSound()。

1
2
3
4
5
Animal* pet1 = new Dog();
Animal* pet2 = new Cat();

pet1->makeSound(); // 输出:"汪汪汪!"
pet2->makeSound(); // 输出:"喵喵喵~"

抽象类

实际软件开发过程中,代码编写通常由多人分工协作完成。其中,软件架构师可以通过纯虚函数建立接口和抽象类,由具体程序员填写代码实现接口。

纯虚函数(pure virtual function)是指被不负责具体实现的虚成员函数,它不具备函数的功能。通常情况下,在基类中定义纯虚函数,在派生类中给出具体实现。纯虚函数不能被直接调用,其作用是提供一个与派生类一致的接口。

什么样的函数需要被声明为纯虚函数呢?比如,通过图形类可派生出三角形类、矩形类、圆形类等,每个派生类中都有一个求面积的成员函数,它们都继承自图形类。这个求面积的成员函数,在三角形类、矩形类、圆形类中很容易实现,但在图形类中能否实现呢?你会发现,图形类是一个抽象类,没法对其求面积。为解决这个问题,就可以在图形类中定义一个求面积的纯虚函数,不负责实现,但可以继承,具体的实现交给派生类去完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Shape {
public:
    virtual double getArea() = 0; // 纯虚函数没有函数体,只有函数声明。在虚函数声明的结尾加上“=0”,表明此函数为纯虚函数。
};

class Triangle : public Shape {
public:
    double getArea() {
        return 0.5 * base * height;
    }
};  

class Rectangle : public Shape {
public:
    double getArea() {
        return width * height;
    }
};

包含纯虚函数的类称为抽象类,抽象类不能在程序中被实例化(即不能定义抽象类的对象),但可以定义指向抽象类的指针。当基类是抽象类时,派生类中必须给出基类中纯虚函数的实现,或再次声明其为纯虚函数。当派生类给出基类中纯虚函数的具体实现时,该派生类就不再是抽象类。

模板

模板是C++的高级特性,使用模板可以快速建立类型安全的类库集合和函数集合,非常有利于大规模软件的开发。

C++泛型编程

泛型编程(generic programming)对C++来说,其重要性不亚于面向对象特性。泛型编程是一种算法在实现时不指定具体要操作的数据类型的程序设计方法。所谓“泛型”,指的是算法只要实现一遍,就能适用于多种数据类型。泛型程序设计的优势在于能有效减少重复代码的编写,其最成功的应用就是C++标准模板库(STL)。

C++中,模板分为函数模板和类模板两种。熟练的C++程序员,编写函数时会考虑能否将其写成函数模板,编写类时会考虑能否将其写成类模板,以便实现代码的复用。换句话说,泛型编程就是大量编写模板和使用模板的程序设计。

根据编程语言“在定义变量时是否需要显式地指明数据类型”,可以分为强类型语言和弱类型语言。

  • 强类型语言:C/C++、Java、C#等
  • 弱类型语言:Python、JavaScript、Shell等

C++是强类型语言,比较“死板”,而C++模板则很好地弥补了强类型语言“不够灵活”的缺点。

事实上,C++模板是被迫推出的,起初是为了封装数据结构。数据结构关注的是数据的存储,以及存储后如何进行增加、删除、修改和查询操作。C++开发者们希望为线性表、链表、图、树等常见数据结构都定义一个类,并把它们加入到标准库中,这样后续开发时可以直接调用,而不用重复地造“轮子”。但想法美好,却遇到一个无法解决的问题——数据结构中每份数据的类型都无法提前预测。以链表为例,它的结点可以用来存储小数、整数、字符串等,也可以用来存储类、结构体等,还可以直接存储二进制数据。C++是强类型语言,对数据类型有着严格限制,这种矛盾是无法调和的。于是,模板就诞生了。模板虽然不是C++的首创,但却在C++中大放异彩,后来也被Java、C#等其他强类型语言采用。

C++模板非常重要,整个标准库几乎都是使用模板来开发的。标准模板库(STL)是C++对数据结构进行封装后的称呼,是C++的灵魂之所在。

函数模板

所谓函数模板(function template),指的是建立一个通用函数,它所用到的数据类型(包括返回值类型、形参类型、局部变量类型)不具体指定,而是用一个虚拟类型代替(即使用一个标识符进行占位),发生函数调用时再根据传入的实参倒推出其真正类型。

函数模板是对函数功能框架的描述,不是一个实在的函数,编译器不能为其生成可执行代码。

函数重载和函数模板的区别:

  • 函数重载是指同一作用域下编写多个函数名相同,但参数类型、参数个数或参数顺序不同的函数。函数调用时,根据传入的实参情况自动调用其中的某个函数。
  • 函数模板则是编写一个函数并作为模板,其参数类型不具体指定,函数调用时根据传入的实参倒推其真正类型。函数模板的代码复用度要比函数重载高。
1
2
3
4
template <typename T> // 关键字template表示定义的是一个模板;尖括号内为该模板的类型参数列表,不能为空,多个类型参数间用逗号隔开。
T add(T a, T b) { // 函数定义,圆括号内为形式参数列表,使用类型参数T,而不是具体的类型。
    return a + b;
}

定义函数模板后,可在程序中调用该函数模板。

1
2
3
4
5
int main() {
    cout << add(10, 20) << endl;  // 调用add()函数模板,输出:30
    cout << add(10.5, 20) << endl;  // 错误调用,传递了两个不同类型的参数,编译器会报错
    cout << add<double>(10.5, 20) << endl;  // 正确调用,使用<double>指定类型参数,输出:30.5
}

类模板

关键字template不但可以定义函数模板,还可以定义类模板。在之前的类定义中,每个类的成员变量及成员函数的参数、返回值类型都是确定的。类模板的作用则是,类中的成员变量及成员函数的参数、返回值类型不具体指定,编译时自动根据传入的数据类型进行判断。本质上,类模板是用类生成类,能有效减少类的定义数量。

1
2
3
4
5
6
template <class T1, class T2 = double> // 类模板,T2默认类型为double
class Box {
    T1 length;
    T2 breadth;
    T2 height;
};

RTTI

面向对象编程中的多态,体现在运行时类型识别(run-time type identification,RTTI)上。使用RTTI,类的设计可以更抽象,更符合人类的思维模式,对象的动态生成能够增加设计的灵活性。

异常处理

C++异常(Exception)处理机制就是为解决运行时错误而引入的,其处理流程为“抛出异常(throw)→检测异常(try)→捕获异常(catch)”。

抛出异常

程序执行到某个函数或方法内部时,可能会出现异常。这些异常并不能由系统捕获,而是需要创建一个错误信息,再由系统捕获该错误信息并进行处理。创建错误信息并发送的过程就称为抛出异常。C++中,抛出异常使用throw关键字来实现,关键字后可以是任何类型的值。

1
2
3
throw 1; // 抛出整数1
throw "Error"; // 抛出字符串"Error"
throw 3.14; // 抛出小数3.14

捕获异常

异常信号抛出后,一旦被异常处理器接收到就会被销毁。异常处理器通常紧跟在try语句后,处理方法由关键字catch引导。捕获异常时,异常处理器还可以成组出现,根据捕获到的不同信息进行不同的处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
try {
    throw 1; // 抛出整数1
} 
catch (int e) {
    cout << "捕获到整数异常:" << e << endl;
}
catch (const char* e) {
    cout << "捕获到字符串异常:" << e << endl;
}
catch (...) {  // 有时需要重新抛出刚接收到的异常,尤其在程序无法得到相关异常信息而用省略号表示时。重新抛出异常可通过不带参数的throw语句完成。
    cout << "捕获到其他异常" << endl;
    throw;
}

异常匹配

当程序中有异常抛出时,异常处理系统根据异常处理器的顺序找到最近的异常处理块,并不会搜索更多的异常处理块。

异常匹配并不要求异常与异常处理器进行完美匹配,一个对象或一个派生类对象的引用将与基类处理器进行匹配。若抛出的是类对象的指针,则会匹配相应的对象类型,但不会自动转换成其他对象的类型。

标准异常类

C++标准库中定义了异常类,开发者可以直接使用,这些异常类都是从基类exception类继承而来的。

1
2
3
4
5
6
7
8
9
10
11
12
namespace std {
    class logic_error;  // 逻辑错误,例如无效参数,程序运行前可以检测出来
    class runtime_error; // 运行时错误,例如数组越界,仅在程序运行时检测

    class domain_error; // 参数错误
    class invalid_argument; // 函数存在无效参数
    class length_error; // 长度错误
    class out_of_range; // 越界错误
    class overflow_error; // 溢出错误
    class range_error; // 范围错误
    class underflow_error; // 下溢错误
}

文件操作

C++为数据的标准输入输出定义了专门的类。

  • ios为根基类,由其派生出istream(输入流)、ostream(输出流)、fstreambase(文件流基)和strstreambase(字符串流基)。istream和ostream派生出iostream(输入/输出流)。
  • 文件流有3个,分别是ifstream(输入文件流)、ofstream(输出文件流)和fstream(输入/输出文件流)。
  • 字符串流有3个,分别是istrstream(输入字符串流)、ostrstream(输出字符串)和strstream(输入/输出字符串流)。

C++中,I/O(输入/输出)标准类定义在iostream.h、fstream.h和strstream.h 3个头文件中。只要引入了对应的头文件,就可以使用其中的类进行各种输入/输出操作。

I/O操作中,是输入(input)还是输出(output)是针对内存而言的。把数据从内存中取出来,就是输出;把数据存进内存中,就是输入。

打开文件

在对文件进行读、写操作前,先要打开文件。打开文件的目的有两个:

  • 通过指定文件名,建立起文件和文件流对象间的关联。以后对文件进行操作时,就可以通过关联的流对象进行。
  • 指明文件的使用方式。使用方式有只读、只写、既读又写、在文件末尾添加数据、以文本方式使用、以二进制方式使用等多种。

以ifstream类为例,该类有一个open()成员函数,其有两个参数,第一个参数是指向文件名的指针,第二个参数是文件的打开模式标记。其他两个文件流类也有同样的open()成员函数。

1
2
3
4
5
6
7
8
ifstream infile;
infile.open("test.txt", ios::in);  // 打开文件,以只读方式

ifstream infile;
infile.open("test.txt", ios::out);  // 打开文件,以只写方式

ifstream infile;
infile.open("test.txt", ios::app);  // 打开文件,以在文件末尾添加数据的方式

读写文件

文件流分为3类,即输入流、输出流和输入/输出流,相应地,必须将流声明为ifstream、ofstream和fstream类的对象。声明了流对象之后,可以使用open()函数打开文件。打开文件就是在流与文件之间建立一个连接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream> // 包含输入输出流库
#include <fstream> // 包含文件流库
#include <string> // 包含字符串库
using namespace std;

int main() {
    ofstream outfile("test.txt");
    if (!outfile) {
        cout << "文件打开失败" << endl;
        return 1;
    }
    outfile << "Hello, World!" << endl;
    outfile.close();

    ifstream infile("test.txt");
    if (!infile) {
        cout << "文件打开失败" << endl;
        return 1;
    }
    string buffer;
    infile >> buffer;
    cout << buffer << endl;
    infile.close();

    fstream file("test.txt");
    if (!file) {
        cout << "文件打开失败" << endl;
        return 1;
    }
    file << "Hello, World!" << endl;
    file.close();

    return 0;
}

网络通信

套接字

套接字(Socket)是网络通信的基本概念,它允许程序在网络上进行数据交换。套接字是网络通信的基本构件,是UCB为UNIX开发的网络通信编程接口。为了在Windows操作系统上使用套接字,20世纪90年代初,微软公司和第三方厂商共同制定了一套标准,即Windows socket规范,简称Winsock。

根据性质和作用的不同,套接字可以分为3种,即原始套接字、流式套接字和数据包套接字。

  1. 原始套接字是在Winsock2规范中提出的,允许程序开发人员对底层的网络传输机制进行控制。在原始套接字下接收的数据中含有IP头。
  2. 流式套接字提供了双向、有序、可靠的数据传输服务,在通信前需要双方建立连接。TCP协议采用的就是流式套接字。
  3. 数据包套接字提供双向的数据流,但不保证数据传输的可靠性、有序性和无重复性。UDP协议采用的就是数据包套接字。

Windows系统提供的套接字函数通常封装在ws2_32.dll动态链接库中,其头文件Winsock2.h提供了套接字函数的原型,库文件ws2_32.lib提供了ws2_32.dll动态链接库的输出。在使用套接字函数前,用户需要引用winsock2.h头文件,并链接ws2_32.lib库文件。此外,在使用套接字函数前还需要初始化套接字,可以使用WSAStartup()函数来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <winsock2.h>  // 包含套接字函数原型
#pragma comment(lib, "ws2_32.lib")  // 链接ws2_32.dll动态链接库

int main() {
    // 初始化套接字
    WSADATA wsaData;
    int result = WSAStartup(MAKEWORD(2, 2), &wsaData);
    if (result != 0) {
        cout << "套接字初始化失败" << endl;
        return 1;
    }

    // 创建套接字
    SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    
}

常用套接字函数

  1. WSAStartup():初始化套接字。
  2. socket():创建套接字。语法格式:SOCKET socket(int af, int type, int protocol);
  3. bind():将套接字绑定到指定的端口和地址上。语法格式:int bind(SOCKET s, const struct sockaddr *name, int namelen);
  4. listen():将套接字设置为监听模式。在流式套接字中,只有套接字处于监听状态,才能接受客户端的连接。语法格式:int listen(SOCKET s, int backlog);
  5. accept():接受客户端的连接。在流式套接字中,只有套接字处于监听状态,才能接受客户端的连接。语法格式:SOCKET accept(SOCKET s, struct sockaddr *addr, int *addrlen);
  6. connect():发送一个连接请求。语法格式:int connect(SOCKET s, const struct sockaddr *name, int namelen);
  7. send():发送数据。语法格式:int send(SOCKET s, const char FAR *buf, int len, int flags);
  8. recv():接收数据。语法格式:int recv(SOCKET s, char FAR *buf, int len, int flags);
  9. closesocket():关闭套接字。语法格式:int closesocket(SOCKET s);
  10. htons(), htonl():将主机字节顺序转换为网络字节顺序。后缀s表示short,l表示long。
  11. ntohs(), ntohl():将网络字节顺序转换为主机字节顺序。后缀s表示short,l表示long。
  12. inet_addr():将点分十进制IP地址转换为网络字节顺序。语法格式:unsigned long inet_addr(const char FAR *cp);
  13. inet_ntoa():将网络字节顺序的IP地址转换为点分十进制IP地址。语法格式:char FAR *inet_ntoa(struct in_addr in);
  14. select():检查一个或多个套接字是否处于可读、可写或错误状态。语法格式:int select(int nfds, fd_set FAR *readfds, fd_set FAR *writefds, fd_set FAR *exceptfds, struct timeval FAR *timeout);

套接字阻塞模式

依据函数执行方式的不同,可以将套接字分为两类,即阻塞套接字和非阻塞套接字。

  1. 在阻塞套接字中,套接字函数的执行会一直等待,直到函数调用完成才返回。这主要出现在I/O操作过程中,在I/O操作完成之前不会将控制权交给程序。这也意味着在一个线程中同时只能进行一项I/O操作,其后的I/O操作必须等待正在执行的I/O操作完成后才会执行。
  2. 在非阻塞套接字中,套接字函数的调用会立即返回,将控制权交给程序。默认情况下,套接字为阻塞套接字。为了将套接字设置为非阻塞套接字,需要使用ioctlsocket()函数。例如,下面的代码在创建一个套接字后,将套接字设置为非阻塞套接字。

为了将套接字设置为非阻塞套接字,需要使用ioctlsocket()函数。例如,下面的代码在创建一个套接字后,将套接字设置为非阻塞套接字。

字节顺序

不同的计算机结构使用不同的字节顺序存储数据。例如,基于Intel的计算机存储数据的顺序与Macintosh(Motorola)计算机相反。通常情况下,开发者不必为在网络上发送和接收数据的字节顺序转换担心,但在有些情况下必须转换字节顺序。例如,程序中将指定的整数设置为套接字的端口号,在绑定端口号之前,必须将端口号从主机顺序转换为网络顺序。

建立连接和通信

使用套接字建立TCP网络连接的步骤如下:

  1. 创建套接字socket()
  2. 将创建的套接字绑定bind()到本地的地址和端口上。
  3. 服务器端设置套接字的状态为监听状态listen(),准备接受客户端的连接请求。
  4. 服务器端接受请求accept(),同时返回得到一个用于连接的新套接字。
  5. 使用这个新套接字进行通信,通信函数使用send()/recv()
  6. 释放套接字资源closesocket()

如果是UDP通信,则不需要提前建立连接,只需要将数据发送到指定的IP地址和端口号即可。没有服务器端和客户端的区别,发送方和接收方是平等的。

  1. 创建套接字socket()
  2. 将创建的套接字绑定bind()到本地的地址和端口上。
  3. 使用sendto()函数发送数据。
  4. 使用recvfrom()函数接收数据。
  5. 释放套接字资源closesocket()
This post is licensed under CC BY 4.0 by the author.