C++ 学习笔记

C++学习笔记

version : v1.0 「2022.8.7」 最后补充


author: Y.Z.T.

摘要: 自己在C++学习时做的笔记,随便记录一下

简介: 其实就是学习《C++ Primer Plus》的读书笔记,因为目前开发还是主要用C语言,部分感觉不常用的特性,仅简单记录或不记录





1. 基础部分

1.1 std::

作用域解析符(::);

一般在调用c++标准库的时候,要写上std


1.2 cout打印变量

1
cout << carrots;

cout能智能根据其后的数据类型相应调整其行为,是一个运算符重载的例子。

1.2.1 cout与printf()

image-20220721220644065


1.3 cin输入语句

cin是与cout对应的用于输入的对象

image-20220721221903373


1.4 类简介

image-20220721223532560


1.5 位与字节

image-20220721230055905


1.6 数据类型长度

short、int和long类型都表示整型值,存储空间的大小不同。一般,short类型为半个机器字长(word)长,int类型为一个机器字长,而long类型为一个或两个机器字长(在32位机器中int类型和long类型通常字长是相同的)。

类型 16位系统/字节 32位系统/字节 64位系统/字节
char 1 1 1
char* 2 4 8
short 2 2 2
int 2 4 4
long 4 4 8
long long 8 8 8
数据类型 说明 32位字节数 64位字节数 取值范围
bool 布尔型 1 1 true,false
char 字符型 1 1 -128~127
unsigned char 无符号字符型 1 1 0~255
short 短整型 2 2 -32768~32767
unsigned short 无符号短整型 2 2 0~65535
int 整型 4 4 -2147483648~2147483647
unsigned int 无符号整型 4 4 0~4294967295
long 长整型 4 8
unsigned long 无符号长整型 4 8
long long 长整型 8 8 -264 ~ 264 -1
float 单精度浮点数 4 4 范围-2128~2128 精度为6~7位有效数字
double 双精度浮点数 8 8 范围-21024~21024 精度为15~16位
long double 扩展精度浮点数 8 8 范围-21024~21024 精度为15~16位
* 地址 4 8

2的:

8次方: 256

16次方: 65536

32次方: 4 294 967 296


1.7 浮点数的E表示法

用于表达很大或很小的数

image-20220722143421153

1.7.1 浮点数在内存中的储存

1️⃣

现在绝大多数计算机使用的浮点数,一般采用的是 IEEE 制定的国际标准,这种标准形式如下图:

img


这三个重要部分的意义如下:

  • 符号位 表示数字是正数还是负数,为 0 表示正数,为 1 表示负数;
  • 指数位 指定了小数点在数据中的位置,指数可以是负数,也可以是正数,指数位的长度越长则数值的表达范围就越大
  • 尾数位 小数点右侧的数字,也就是小数部分,比如二进制 1.0011 x 2-2,尾数部分就是 0011,而且 尾数的长度决定了这个数的精度 ,因此如果要表示精度更高的小数,则就要提高尾数位的长度;


2️⃣

32 位来表示的浮点数,则称为单精度浮点数,也就是我们编程语言中的 float 变量,而用 64 位来表示的浮点数,称为双精度浮点数,也就是 double 变量,它们的结构如下:

img

可以看到:

  • double尾数部分是 52,float 的尾数部分是 23,由于同时都带有一个固定隐含位(这个后面会说),所以 double 有 53 个二进制有效位,float 有 24 个二进制有效位,所以所以它们的精度在十进制中分别是 log10(2^53) 约等于 15.95log10(2^24)约等于 7.22 位,因此 double 的有效数字是 15~16 位,float 的有效数字是 7~8位,这些是有效位是包含整数部分和小数部分;
  • double 的指数部分是 11 位,而 float 的指数位是 8 位,意味着 double 相比 float 能表示更大的数值范围;


3️⃣

例: 我们就以 10.625 作为例子,看看这个数字在 float 里是如何存储的

img


  • 首先,我们计算出 10.625 的二进制小数为 1010.101;
  • 然后把小数点,移动到第一个有效数字后面,即将 1010.101 右移 3 位成 1.010101
  • 右移 3 位就代表 +3,左移 3 位就是 -3,float 中的「指数位」就跟这里移动的位数有关系,把移动的位数再加上「偏移量」,float 的话偏移量是 127,相加后就是指数位的值了,即指数位这 8 位存的是 10000010(十进制 130),因此你可以认为「指数位」相当于指明了小数点在数据中的位置。
  • 1.010101小数点右侧的数字就是 float 里的「尾数位」,由于尾数位是 23 位,则后面要补充 0,所以最终尾数位存储的数字是 01010100000000000000000


4️⃣

在算指数的时候,为什么要加上偏移量呢?

前面也提到,指数可能是正数,也可能是负数,即指数是有符号的整数,而有符号整数的计算是比无符号整数麻烦的,所以为了减少不必要的麻烦,在实际存储指数的时候,需要把指数转换成无符号整数,float 的指数部分是 8 位,IEEE 标准规定单精度浮点的指数取值范围是 -127 ~ +128,于是为了把指数转换成无符号整数,就要加个偏移量,比如 float 的指数偏移量是 127,这样指数就不会出现负数了。


比如,指数如果是 8,则实际存储的指数是 8 + 127 = 135,即把 135 转换为二进制之后再存储,而当我们需要计算实际的十进制数的时候,再把指数减去偏移量即可。


细心的朋友肯定发现,移动后的小数点左侧的有效位(即 1)消失了,它并没有存储到 float 里,这是因为 IEEE 标准规定,二进制浮点数的小数点左侧只能有 1 位,并且还只能是 1,既然这一位永远都是 1,那就可以不用存起来了,于是就让 23 位尾数只存储小数部分,电路在计算时会自动把这个 1 加上,这样就可以节约 1 位的空间,尾数就能多存一位小数,相应的精度就更高了一点。


那么,对于我们在从 float 的二进制浮点数转换成十进制时,要考虑到这个隐含的 1,转换公式如下:

img

举个例子,我们把下图这个 float 的数据转换成十进制,过程如下:

img

类型 符号位 指数 尾数
Float 1位(第31位) 8位(第23~30位) 23位(第0~22位)
Double 1位(第63位) 11位(第52~62位) 52位(第0~51位)

1.8 string类简介

sring类能让我们像处理普通变量一样处理字符串。


1.9 左值/右值

凡是能对其进行取地址的都是左值*[即能开辟内存空间储存数据数值的]*;不能进行取地址的是右值[如:a+b、10等]。


1.10 类vector简介

相当于动态数组

image-20220722171513505


1.11 类array简介

静态数组,用栈分配内存

image-20220722171616347


2. 函数指针

2.1 声明函数指针

如函数原型为:

1
double pam(int);

则可声明一个函数指针:

1
double (*pf)(int);

[其中double是返回值, int 是特征标; 特征标返回值必须与原型相同]

这与函数pam()的声明相似, 只是将pam 替换成了 *pf)

[注意: (*pf)也是函数 , 而pf则是函数指针]。

正确声明pf后 , 可将对应的函数地址赋给它:

1
pf = pam;

2.2 调用函数

将地址赋给函数指针后 , 直接将(*pf)看作函数名就行:

1
double x = (*pf)(5);

3. 函数指针数组

如果想定义一个包含三个函数指针的数组,可定义如下:

1
2
3
4
double *  f1(const double ar[],int n);
double * f2(const double ar[],int n);
double * f3(const double ar[],int n);
double * (*pa[3])(const double *, int) = {f1, f2, f3}

其中 (pa)[i] 表示的是数组中的元素 , 即函数指针*。

4. 内联函数与宏

4.1 宏与带参宏

宏定义#define 本质是替换,从宏变成最终替换文本的过程称为宏展开

4.2 带参宏

带参宏的用法与函数调用不完全相同,可能存在部分陷阱。

1
2
3
4
5
6
7
8
# define SQUARE(X) X*X

int main(void)
{
x = 5;
SQUARE(x+2);
SQUARE(++x);
}

[注意 :上面两个运行结果分别是 1742]

4.2.1 原因如下:

SQUARE(x+2); 被替换成(5+2 * 5 +2 = 17)

SQUARE(++x); 被替换成(++x * ++x = 6*7 =42)

4.2.2 总结:
  • 宏起到的作用只是替换,而不提供计算;
  • 宏是在代码处不加任何验证的简单替代
  • 宏不可以在运行时调试

4.3 内联函数

4.3.1 定义:

内联函数可以定义在头文件中,因为内联函数具有内部链接,所以在多个文件中定义同一个内联函数不会产生什么问题

如:

1
inline double square(double x){return x*x;}

inline是指嵌入代码,就是在调用函数的地方不是跳转,而是把代码直接写到那里去。对于短小的代码来说,inline可以带来一定的效率提升,而且和C时代的宏函数相比,inline 更安全可靠。可是这个是以增加空间消耗为代价的。因为内联函数相当于代码复制,在运行时,将内联的代码复制上去。

内联函数一般使用情况:

  • 一个函数不断被重复调用。
  • 函数只有简单的几行,且函数不包含for、while、switch语句。

[一般来说,我们写小程序没有必要定义成inline,但是如果要完成一个工程项目,当一个简单函数被调用多次时,则应该考虑用inline。]

4.3.2 总结:
  • 宏是在代码处不加任何验证的简单替代,而内联函数是将代码直接插入调用处,而减少了普通函数调用时的资源消耗。
  • 宏不是函数,只是在编译前(编译预处理阶段)将程序中有关字符串替换成宏体。
  • inline函数是函数,但在编译中不单独产生代码,而是将有关代码嵌入到调用处。
  • 编译器会对内联函数的参数类型做安全检查或自动类型转换(同普通函数),而宏定 义则不会;

5. c++引用

用作函数参数,会使得函数中的变量名成为调用程序中的变量的别名。称为引用传递

image-20220723224049976

引用时,两个变量本质上是同样的,修改其中一个,另一个也会改变。

5.1 左值引用:

只能是引用左值:

1
int &c = a;
5.2 常引用

能引用右值,但只能用来读取数据而不能用来修改数据:

1
const int &c = (a + b);
5.3 右值引用:

用于支持移动语义

1
2
int &&x = 10;
int &&y = (a + b);
5.4 结构体引用

引用主要就是用于在结构中应用的:

  1. 可以将指向该结构的引用作为参数:
1
void set_pc(free_throws & ft);

2.如果加入const,这只是使用这些数据 而不进行修改

1
void set_pc(const free_throws & ft);

3.返回引用

1
2
3
4
5
6
7
8
9
10
11
free_throws & accumulate (free_throws & target , const free_throws & source);

free_throws & accumulate (free_throws & target , const free_throws & source)
{
target.a += source.a;
target.m += source.m;
set_pc(target);
return target;
}

display(accumulate(team,one));

accumulate()返回值作为参数传递给了display(),意味着将team传递给了display(),意味着display()中的ft指向team

等效于:

1
2
accumulate(team,one);
display(team);

[注意: 返回引用的函数实际上是被引用的变量的别名]

5.5 类引用

基类引用可以指向派生类,而无需强制类型转换 ;(可以定义一个接收基类引用作为参数的函数,调用该函数时,可以将基类对象作为参数,也可以将派生类对象作为参数)

5.5 何时使用引用参数

image-20220724155849723


6. c++默认参数

指在当函数调用中省略了实参时自动使用的一个值;

[ 在设计类时,通过使用默认参数,可以减少要定义的构析函数、方法、及方法重载的数量];

定义默认值:

image-20220724160815350

image-20220724165139378


7. 函数重载

image-20220724164739037

image-20220724165139378

[注意: 编译器会把类型引用和类型本身视为同一个特征标]

如:

1
2
double cube(double x);
double cube(double &x)

这两个函数会被视为同样的特征标。

7.1 何时使用函数重载

image-20220724172347565


8. 函数模版

需要多个对不同类型使用同一种算法的函数时,可使用模版。

8.1 定义
1
2
3
4
5
6
7
8
template <typename AnyType>
void Swap(AnyType &a , AnyType &b)
{
AnyType AnyType;
temp = a;
a = b;
b = temp;
}

image-20220724175145943

类型名:AnyType 可任意命名

[注意:原型声明时,记得将template <typename AnyType> 带上]

8.2 注意事项:

编写一个函数,在编译调用后仍会生成多个不同的函数

image-20220724180111337


9. 实例化和具体化

image-20220724212225673


10. const 限定符

const定义的变量,在初始化之后不可被改变

10.1 const 和 指针

1
2
3
4
5
6
7
8
9
10
11
const int x = 0;
int *p1 = &x; //错误,p1只是一个普通指针
const int *p2 = &x; //正确

int y = 0;
const *p3 = &y; //正确
*p3 = 2; //错误,(const *) (但y可以改变,可以用于权限管理)

int *const p4 = &y; //正确,p4将一直指向y
const int *const p5 = x; //正确,p5是一个指向常量对象的常量指针

10.2 顶层 const

如前所述,指针本身是一个对象,它又可以指向另外一个对象。因此, 指针本身是不是常量 以及 指针所指的是不是一个常量 就是两个相互独立的问题。

  • 用名词顶层const(top-level const)表示指针本身是个常量
  • 而用名词底层const (low-level const)表示指针所指的对象是一个常量。

更一般的,顶层const可以表示任意的对象是常量,这一点对任何数据类型都适用,如算术类型、类、指针等。底层const则与指针和引用等复合类型的基本类型部分有关。比较特殊的是,指针类型既可以是顶层const也可以是底层const,这一点和其他类型相比区别明显

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int i = 0;
int *const p1 = &i; //不能改变p1的值, 这是一个顶层const
const int ci = 42; //不能改变ci的值, 这是一个顶层const
const int *p2 = &ci; //允许改变p2的值, 这是一个底层const
const int *const p3 = p2; //靠右的const是顶层const, 靠左的是底层const
const int &r = ci; //用于声明引用的const都是底层const

/* 使用示例 */
i = ci; //正确: 拷贝ci的值, ci是一个顶层const,对此操作无影响
p2 = p3; //正确: p2和p3指向的对象类型相同, p3顶层const的部分不影响

int *p = p3; //! 错误: p3包含底层const的定义, 而p没有
p2 = p3; //正确: p2和p3都是底层const
p2 = &i; //正确: int*能转换成const int*
int &r = ci; //! 错误: 普通的int&不能绑定到int常量上
const int &r2 = i; //正确: const int&可以绑定到一个普通int上

10. 3constexpr 变量

常量表达式(const expression) ,声明为此类型表示此变量一定为常量,而且必须用常量初始化


11. 作用域

11.1 头文件的内容

  • 函数原型
  • 使用#defineconst定义的符号常量
  • 结构声明
  • 类声明
  • 模版声明
  • 内联函数

[注意: 不要将函数定义和变量声明放在头文件中,容易导致重定义。 除非该函数是内联函数]

11.2 #inlcude 中“ ” 和 < >的区别

image-20220802175044341

11.3 内存在程序中保留的时间

  • 自动储存:局部变量函数参数 在程序开始执行其所属的函数和代码块时被创建, 在执行完函数和代码块时,内存被释放
  • 静态储存:全局变量staic关键字定义的局部变量全局变量 。在整个程序运行过程中都存在
  • 动态存储: 用new 分配的内存会一直存在直到使用delete 关键字将其释放。 也称为

11.4 作用域和链接

  • 作用域: 值在上面范围内能看到这个( 函数/变量 ),描述了名称在文件的多大范围内可见。
  • 链接性: 描述了名称在不同单元键的共享

局部变量作用域: 作用域只在定义它的代码块中

全局变量作用域: 作用域为定义位置到文件结尾

自动变量作用域: 作用域为局部

函数体作用域: 整个类或整个名称空间,但不能是局部的

[例如: 局部变量和函数形参的 储存持续性为自动,作用域为局部; 没有链接性。 当程序开始执行时,为该变量分配内存,当函数结束时,这些变量会消失。]

特别注意:

image-20220803173542707


11.5 静态储存的链接性

11.5.1 静态变量的链接性:
  • 外部链接性: 可在其他文件中访问
  • 内部链接性: 只能在当前文件中访问
  • 无链接性: 只能在当前函数或代码块中访问

静态变量的数量在程序运行期间是不变的, 编译器会分配固定的内存块来储存所有的静态变量,这些变量在程序

执行期间一直存在

1
2
3
4
5
6
7
8
9
int a = 0;    // 静态持续变量,连接性为外部

staic int b = 0; // 静态持续变量,连接性为内部

void funct(void)
{
staic int c = 0; //静态变量,无链接性
int d = 0; // 自动变量,无链接性
}

特别注意: 变量c是储存在静态数据区的,会在程序刚开始运行时就完成初始化,也是唯一的一次初始化

即使funct函数没有被执行,c变量也是留在内存中的,而变量d则会消失。

11.5.2 静态变量的初始化特性

所有静态持续变量在初始化时都会被初始化为0 ,称为零初始化

在静态数据区,内存中所有的字节默认值都是 0x00,某些时候这一特点可以减少程序员的工作量。比如初始化一个稀疏矩阵,我们可以一个一个地把所有元素都置 0,然后把不是 0 的几个元素赋值。如果定义成静态的,就省去了一开始置 0 的操作。再比如要把一个字符数组当字符串来用,但又觉得每次在字符数组末尾加 \0 太麻烦。如果把字符串定义成静态的,就省去了这个麻烦,因为那里本来就是 \0

11.6 变量储存方式总结

image-20220803181043603

11.7 volatile 限定符

image-20220803231002719

11.8 C++在哪里查找函数

image-20220803232557408

12. C++名称空间

12.1 声明区域与作用域

  • 声明区域: 声明区域是可以在其中进行声明的区域(如:全局变量 -> 声明区域在所在文件; 局部变量 -> 声

    明区域在其所在的代码块)

  • 作用域: 变量对程序而言可见的范围称为作用域(变量的作用域从声明点开始,到其声明区域的结尾,比声明区域小。)

特别注意: 变量并非在作用域的任何位置都是可见的。(例如:在函数中定义的局部变量[ 该变量的声明区域

为整个函数],将隐藏同一文件下的同名全局变量)


12.2 namespace 关键字

通过namespace关键字 来定义一个新的名称空间;

一个名称空间中的名称不会与另一个名称空间中的名称发生冲突

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
namespace Jack
{
int pail; // 变量定义
struct Well // 结构定义
{
...
};
void fetch(); // 函数声明
}

namespace Jill
{
int pail; // 变量定义
struct Well // 结构定义
{
...
};
void bucket(void) // 函数定义
{
...
};
}

特别注意: 名称空间可以是全局的,也可以在另一个名称空间中; 但不能代码块

除了自定义的名称空间外,还存在一个全局名称空间全局变量都位于全局名称空间。

12.3 访问名称空间中的名称

12.3.1 通过作用域解析运算符 ::
1
2
3
Jack :: pail = 12;  // 使用变量
Jack :: Well a; // 创建结构体变量

  • 其中 包含名称空间的名称(如Jack :: pail)称为限定的名称
  • 未被修饰的名称 (如 pail) 称为未限定的名称

12.3.2 using声明和using 编译指令
  • using声明 : using声明由限定的名称和前面的关键字using组成

    1
    using Jack :: pail;			// using声明
  • using 编译指令:名称空间名和前面关键字 using namespace 组成

1
using namespace Jack;		// 让名称空间内的所有名称可用

12.3.2.1 using 声明

using 声明 会将特定的名称添加到其所属的声明区域中

1
2
3
4
5
void main()
{
using Jack :: pail; // 将pail放入本地名称空间
pail = 0; // 使用变量pail
}
  • main函数中的 using 声明将 Jack :: pail 中的pail添加到main()定义的声明区域中。
  • 完成声明后,可用pail 代替Jack :: pail 使用。
  • 如果在函数外面使用using声明 会将名称添加到全局名称空间中

12.3.2.2 using 编译指令
  • using 编译指令会使得所有名称可用,而不需要使用作用域解析运算符
  • 全局声明区域使用using 编译指令,会使得该名称空间中的名称全局可用
  • 对应的在函数中 使用using 编译指令, 会使得该名称空间中的名称在该函数中可用。

特别注意: 使用 using 编译指令using 声明 可能会增加名称冲突的可能

例如:

  1. 使用作用域解析运算符:
1
2
3
jack :: pail = 3;
jill :: pail = 1;

[ 变量jack :: pailjill :: pail不同的标识符,表示不同的内存单元 。]

  1. 使用using声明
1
2
3
using jack :: pail ;
using jill :: pail ;
pail = 4;

[ 这样会导致名称冲突,导致编译器报错 ]


12.3.3 using声明和using 编译指令的区别
  • 使用using声明 就像声明了相应的名称一样。
  • using 编译指令 就像是大量使用作用域解析符。

image-20220804235425551


12.4 名称空间嵌套

1
2
3
4
5
6
7
8
9
namespace ele
{
namespace fire
{
int flame;
..
}
float water;
}

这里flame 指的是 ele :: fire :: flame ,也可以使用using编译指令是内部空间可用:

1
using namespace ele :: fire ;

12.5 在名称空间内使用using声明和using 编译指令

1
2
3
4
namespace ma
{
using jill :: pail;
}

因为jill :: pail 既位于名称空间 ma也位于 名称空间 jill 中, 所以

jill :: pail 等价ma :: pail


12.6 未命名名称空间

1
2
3
4
5
namespace
{
int ad;
}

image-20220805000848573


12.7 名称空间使用原则

image-20220805001137249


13. 面向对象

13.1 OPP的特性

  • 抽象
  • 封装和数据隐藏
  • 多态
  • 继承
  • 代码的可重用性

13.2 类的组成部分

  • 类声明: 以数据成员的方式描述了数据部分,以成员函数(即方法)的方式描述了接口。

  • 类方法: 描述如何实现类成员的函数。

[ 类声明提供了类的蓝图 ,方法定义则提供细节 ]

通常: 会将类定义 放在头文件中,将类方法的代码放在源文件中。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
chass Stock				// 类声明
{
private:
std :: string company;
long shares;
double share_val;
double total_val;
void set_tol() { total_val = shares * share_val; }

public:
void acquire (const std :: string & co, long n , double pr);
void buy(long num, double price);
void sell(long num , double price);
void update(double price);
void show();
} ;

Stock a; // 声明a对象
Stock b; // 声明b对象

13.3 访问控制

  • 类一般会有两个部分:private(私有的,数据隐藏); public(公共接口)
  • private部分,凡是在private 部分出现的标识(如函数,变量等),只能 通过public 的接口访问。
  • 不必在类声明中显式的使用private 关键字,因为这是类对象默认的访问控制

image-20220806202226940

13.3.1 类和结构体

image-20220806210804363

13.3.2 类成员函数定义

类成员函数的定义与正常函数类似, 不同点主要是;

  • 定义成员函数时,使用作用域解析符(::)来标识所属的类。

  • 可以将另一个类成员函数命名为相同的名字(比如可以将另一个类成员函数也命名为update())

    1
    2
    3
    4
    5
    6
    7
    8
    9
    void Stock::update(double price)
    {
    ...
    }

    void abc::update(double price)
    {
    ....
    }
  • 类方法可以访问类的private部分

  • 同一个类(如 Stock类)的其他成员,可以不加解析符(::),直接使用update()方法,因为同属一个类,因此update()可见的

  • 定义位于类声明中的函数会自动成为内联函数 , 通常将短小的成员函数作为内联函数。


13.4 类对象的内存

  • 每个创建的类对象都有自己的储存空间,用于储存器内部的变量和成员。

  • 所有类对象共享一组类方法,即所有类对象执行统一代码块的类方法。

    例如:

    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
    /****************** 头文件 *****************/

    chass Stock // 类声明
    {
    private:
    std :: string company;
    long shares;
    double share_val;
    double total_val;
    void set_tol() { total_val = shares * share_val; }

    public:
    void acquire (const std :: string & co, long n , double pr);
    void buy(long num, double price);
    void sell(long num , double price);
    void update(double price);
    void show();
    } ;

    /*********************** mian.cpp ************************/
    Stock a; // 声明a对象
    Stock b; // 声明b对象

    void main(void)
    {
    a.shares = 1; // 变量占有独立的内存块
    b.shares = 1;

    a.show(); // 类方法都调用的是 Stock::show() 代码块中的内容,只是用于不同数据
    b.show();
    }
    image-20220806215752623

13.5 数据封装

所有的 C++ 程序都有以下两个基本要素:

  1. 程序语句(代码): 这是程序中执行动作的部分,它们被称为函数。
  2. 程序数据: 数据是程序的信息,会受到程序函数的影响。
  • 封装是面向对象编程中的把数据和操作数据的函数绑定在一起的一个概念,这样能避免受到外界的干扰和误用,从而确保了安全。数据封装引申出了另一个重要的 OOP 概念,即数据隐藏
  • 数据封装是一种把数据和操作数据的函数捆绑在一起的机制,数据抽象是一种仅向用户暴露接口而把具体的实现细节隐藏起来的机制。

C++ 通过创建来支持封装和数据隐藏(public、protected、private)。我们已经知道,类包含私有成员(private)、保护成员(protected)和公有成员(public)成员。默认情况下,在类中定义的所有项目都是私有的。

例如:

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
35
#include <iostream>
using namespace std;

class Adder{
public:
// 构造函数
Adder(int i = 0)
{
total = i;
}
// 对外的接口
void addNum(int number)
{
total += number;
}
// 对外的接口
int getTotal()
{
return total;
};
private:
// 对外隐藏的数据
int total;
};
int main( )
{
Adder a;

a.addNum(10);
a.addNum(20);
a.addNum(30);

cout << "Total " << a.getTotal() <<endl;
return 0;
}

14. 构造函数 和 析构函数

因为类的数据成员大部分情况下是被隐藏的,无法直接访问来进行初始化

1
2
3
4
5
6
7
8
struct thing
{
char* pn;
int m;
};

thing a = {"wod",-23}; // 初始化结构体
Stock hot = {"auid",200,50.25}; // 非法的,会编译报错

类的构造函数是类的一种特殊的成员函数,它会在每次创建类的新对象时执行。

构造函数的名称与类的名称是完全相同的,并且不会返回任何类型,也不会返回 void。构造函数可用于为某些成员变量设置初始值。

14.1 构造函数定义

构造函数原型在类声明的共有部分 ,构造函数定义与正常函数定义类似(注意没有返回值

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
/*******************  头文件 ********************/
chass Stock // 类声明
{
private:
std :: string company;
long shares;
double share_val;
double total_val;
void set_tol() { total_val = shares * share_val; }

public:
void acquire (const std :: string & co, long n , double pr);
void buy(long num, double price);
void sell(long num , double price);
void update(double price);
void show();
Stock(); // 这是构造函数声明
} ;

/****************** 源文件 *****************/

Stock:: Stock(const string & co , long n, double pr)
{
company = co ; //初始化数据成员 company
shares = n; // 初始化数据成员
share_val = pr; // 初始化数据成员
}

特别注意: 构造函数的参数表示的是不是类成员,而是赋给类成员的值, 因此参数名不能与类成员相同

所以通常可以在数据成员前面使用 m_前缀。

1
2
3
4
5
6
7
chass Stock				// 类声明
{
private:
std :: string m_company;
long m_shares;

...

14.2 使用构造函数

  • 可以显式的调用构造函数

    1
    Stock a = Stock("wor",1 ,1.5);
  • 也可以用更常见的方式来调用

    1
    Stock b("wor",1 ,1.5);  // 等价于 Stock b = Stock("wor",1 ,1.5);

特别注意: 无法使用对象来调用构造函数 , 因为在构造函数构造出对象之前 ,对象是不存在的 ,因此构造

函数用来创建对象,而不能通过对象来调用


14.3 析构函数

类的析构函数是类的一种特殊的成员函数,它会在每次删除所创建的对象时执行。

析构函数的名称与类的名称是完全相同的,只是在前面加了个波浪号(~)作为前缀,它不会返回任何值,也不能带有任何参数。析构函数有助于在跳出程序(比如关闭文件、释放内存等)前释放资源。

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
35
36
37
38
39
40
41
42
43
44
45
46
 /********************* 头文件 ********************/
class Line
{
public:
void setLength( double len );
double getLength( void );
Line(); // 这是构造函数声明
~Line(); // 这是析构函数声明

private:
double length;
};

/************************* 源文件 ***********************/
// 成员函数定义,包括构造函数
Line::Line(void)
{
cout << "Object is being created" << endl;
}
Line::~Line(void)
{
cout << "Object is being deleted" << endl;
}

void Line::setLength( double len )
{
length = len;
}

double Line::getLength( void )
{
return length;
}
/**************************** main.cpp **************************/
// 程序的主函数
int main( )
{
Line line;

// 设置长度
line.setLength(6.0);
cout << "Length of line : " << line.getLength() <<endl;

return 0;
}

运行结果:

1
2
3
Object is being created
Length of line : 6
Object is being deleted

总结:

  • 就像对象被创建时调用构造函数一样 , 当对象被删除时,程序将调用析构函数 ;
  • 每个类只能有一个析构函数
  • 析构函数没有返回值,也没有参数。

15. this指针

15.1 简介

在 C++ 中,每一个对象都能通过 this 指针来访问自己的地址this 指针是所有成员函数的隐含参数。因此,在成

员函数内部,它可以用来指向调用对象,只有成员函数才有 this 指针。

15.2 为什么要引入this指针

当我们调用成员函数时,实际上是替某个对象调用它。

成员函数通过一个名为 this 的额外隐式参数来访问调用它的那个对象

用c语言的方式表达this指针的作用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct Car
{
int price;
};


void SetPrice(struct Car* this, int p) // car 指针就相当于隐式参数this
{
this->price = p;
}

int main()
{
struct Car car;
SetPrice( &car, 20000); // 给car结构体的price变量赋值
return 0;
}

16. 对象数组初始化

16.1 使用默认构造函数初始化

1
Stock buff[4];   // 创建一个包含4个成员的Stock对象数组

注意: 要求没有显示定义任何构造函数的时候,才能这样使用声明对象数组

16.2 使用构造函数初始化

1
2
3
4
5
6
Stock buff[4] = {
Stock("one",1,2,3);
Stock("two",1,2,3);
Stock("three",1,2,3);
Stock("four",1,2,3);
}

注意: 这样必须为每个元素调用构造函数 , 除非定义了多个构造函数


17. 类作用域

17.1 简单特性

  • c++相比C语言,在全局作用域局部作用域外,引入了新的类作用域
  • 类中定义的名称(如数据成员名类成员函数名等) 作用域是整个类. 只有在该类中是已知的,在类外都是不可知
  • 可以在不同类中使用相同的成员
  • 不能直接从外部访问类的成员,公有函数也不可以, 要调用公有函数成员,必须通过对象
  • 在定义成员函数时,必须使用类作用域解析符

18. C++中的运算符重载

可以重定义或重载大部分 C++ 内置的运算符, 之后就能使用自定义类型的运算符。

重载的运算符是带有特殊名称的函数,函数名是由关键字 operator 和其后要重载的运算符符号构成的。与其他函数一样,重载运算符有一个返回类型和一个参数列表。

1
Box operator+(const Box&);

可以通过重载 + 运算符 用于把两个 Box 对象相加,返回最终的 Box 对象。大多数的重载运算符可被定义为普通的非成员函数或者被定义为类成员函数

例:

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
#include <iostream>
using namespace std;

class Box
{
public:

double getVolume(void)
{
return length * breadth * height;
}
void setLength( double len )
{
length = len;
}

void setBreadth( double bre )
{
breadth = bre;
}

void setHeight( double hei )
{
height = hei;
}
// 重载 + 运算符,用于把两个 Box 对象相加
Box operator+(const Box& b)
{
Box box;
box.length = this->length + b.length;
box.breadth = this->breadth + b.breadth;
box.height = this->height + b.height;
return box;
}
private:
double length; // 长度
double breadth; // 宽度
double height; // 高度
};
// 程序的主函数
int main( )
{
Box Box1; // 声明 Box1,类型为 Box
Box Box2; // 声明 Box2,类型为 Box
Box Box3; // 声明 Box3,类型为 Box
double volume = 0.0; // 把体积存储在该变量中

// Box1 详述
Box1.setLength(6.0);
Box1.setBreadth(7.0);
Box1.setHeight(5.0);

// Box2 详述
Box2.setLength(12.0);
Box2.setBreadth(13.0);
Box2.setHeight(10.0);

// 把两个对象相加,得到 Box3
Box3 = Box1 + Box2;

// Box3 的体积
volume = Box3.getVolume();

return 0;
}

19. 嵌套结构和类

  • 在类中声明中声明的结构枚举被称为是被嵌套在类中, 其作用域为整个类 ;
  • 这个被嵌套的类的作用域就只在它的上一级类中。
  • 这种声明不会创建数据对象,而只是指定了可以在类中使用的类型。
  • 如果是在类的私有部分 进行声明的, 则只能在这个类中使用被声明的类型,在上一级类中可见,而对于外部是不可见的。
  • 如果是在类的公共部分 进行声明的,则可以在类的外部使用作用域解析符(::) 使用被声明的类型。

例:

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>
using namespace std;

class c1
{
public:
int a;
void foo();
class c2
{
public:
int a;
void foo();
} b;
};
void c1::foo()
{
a = 1;
}
void c1::c2::foo()
{
a = 2;
}

int main()
{

class c1 f;
f.foo();
f.b.foo();
cout << f.a << endl;
cout << f.b.a << endl;
return 0;
}

运行结果:

1
2
1
2

20. 类继承

20.1 基本概念

  • 面向对象程序设计中最重要的一个概念是继承。继承允许我们依据另一个类来定义一个类,这使得创建和维护一个应用程序变得更容易。这样做,也达到了重用代码功能和提高执行效率的效果。
  • 当创建一个类时,您不需要重新编写新的数据成员和成员函数,只需指定新建的类继承了一个已有的类的成员即可。这个已有的类称为 基类 ,新建的类称为 派生类
  • 继承代表了 is a 关系。例如,哺乳动物是动物,狗是哺乳动物,因此,狗是动物,等等。

image-20220808213540848

1
2
3
4
5
6
7
8
9
10
11
// 基类
class Animal {
// eat() 函数
// sleep() 函数
};


//派生类
class Dog : public Animal {
// bark() 函数
};

20.2 派生类

一个类可以派生自多个类,这意味着,它可以从多个基类继承数据和函数。定义一个派生类,我们使用一个类派生

列表来指定基类。类派生列表以一个或多个基类命名,形式如下:

1
class derived-class: access-specifier base-class
  • 其中derived-class 是派生类的名
  • 修饰符access-specifierpublic、protectedprivate 其中的一个。如果未使用访问修饰符 access-specifier,则默认为 private
    • 使用公用派生,基类的公有成员将成为派生类的公有成员
    • 而基类的私有成员也将成为派生类的一部分,但只能通过基类的公有和保护方法访问。
  • base-class 是之前定义过的某个类的名称(即基类

举例如下:

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
35
36
37
38
39
40
41
42
43
44
#include <iostream>

using namespace std;

// 基类
class Shape
{
public:
void setWidth(int w)
{
width = w;
}
void setHeight(int h)
{
height = h;
}
protected:
int width;
int height;
};

// 派生类
class Rectangle: public Shape
{
public:
int m_b;
int getArea()
{
return (width * height);
}
};

int main(void)
{
Rectangle Rect;

Rect.setWidth(5);
Rect.setHeight(7);

// 输出对象的面积
cout << "Total area: " << Rect.getArea() << endl;

return 0;
}

一般需要在继承特性中添加:

  • 派生类需要自己的构造函数
  • 派生可以根据需要添加额外的成员数据成员函数

20.3 继承与访问控制

20.3.1 派生类对象初始化:

派生类不能直接访问基类的私有成员而必须通过基类方法访问,所以派生类构造函数必须使用基类构造函数

1
2
3
4
5
6
7
Rectangle::Rectangle(int b, int wid, int hig):Shape(wid,hig)
{
m_b = b;
}

// 声明对象
Rectangle rec(1,2,3);

如上所示:

  • Shape(wid,hig) 调用基类的Shape() 构造函数
  • Rectangle构造函数把实参“2”,“3” 赋予形参 “wid”,“hig” ,然后这些参数作为实参传递给Shape()构造函数
  • 后者将创建一个嵌套的Shape对象 ,并将数据“2”,“3” 储存在该对象中。
  • 然后,程序进入Rectangle构造函数体 ,完成对Rectangle对象的创建。

注意:

  • 创建派生类对象时,程序先调用基类构造函数,再调用派生类构造函数
  • 基类构造函数负责初始化继承的数据成员;派生类构造函数主要用于初始化新的数据成员
  • 派生类构造函数总是调用一个基类构造函数。
  • 可以使用初始化列表指定所使用的基类构造函数,否则将使用默认的基类构造函数

20.3.2 访问权限

派生类可以访问基类中所有的非私有成员。因此基类成员如果不想被派生类的成员函数访问,则应在基类中声明为 private

C++ 中 class 的访问权限分的很细,有 publicprotectedprivate 三种,派生子类时又有三种派生类型,搭配起来就有 9 中不同的组合了。下面用一张表总结了这些搭配的不同。

其中,括号中的是在派生类中这些成员变量(函数)的角色。

成员变量修饰符 类外的普通函数 public 派生类 private 派生类 protected 派生类
public 可以访问 可以访问(public) 可以访问(private) 可以访问(protected)
protected 无法访问 可以访问(protected) 可以访问(private) 可以访问(protected)
private 无法访问 无法访问(private) 无法访问(private) 无法访问(private)

如上所示: [派生类的成员可以直接访问基类的保护成员,但不能直接访问基类的私有成员]

一个派生类继承了所有的基类方法,但下列情况除外:

  • 基类的构造函数、析构函数和拷贝构造函数。
  • 基类的重载运算符。
  • 基类的友元函数。

20.3.3 继承类型

当一个类派生自基类,该基类可以被继承为 public、protectedprivate 几种类型。继承类型是通过前面讲解的访问修饰符 access-specifier 来指定的。

我们几乎不使用 protectedprivate 继承,通常使用 public 继承。当使用不同类型的继承时,遵循以下几个规则:

  • 公有继承(public):当一个类派生自公有基类时,基类的公有成员也是派生类的公有成员,基类的保护成员也是派生类的保护成员,基类的私有成员不能直接被派生类访问,但是可以通过调用基类的公有保护成员来访问。 (即派生类对象也是一个基类对象,可以对基类对象执行的任何操作,也可以对派生类对象执行任何操作。)
  • 保护继承(protected): 当一个类派生自保护基类时,基类的公有保护成员将成为派生类的保护成员。
  • 私有继承(private):当一个类派生自私有基类时,基类的公有保护成员将成为派生类的私有成员。

20.3.4 多继承

多继承即一个子类可以有多个父类,它继承了多个父类的特性。

C++ 类可以从多个类继承成员,语法如下:

1
2
3
4
class <派生类名>:<继承方式1><基类名1>,<继承方式2><基类名2>,…
{
<派生类类体>
};

其中,访问修饰符继承方式是 public、protectedprivate 其中的一个,用来修饰每个基类,各个基类之间用逗号分隔

如下面例子所示:

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include <iostream>

using namespace std;

// 基类 Shape
class Shape
{
public:
void setWidth(int w)
{
width = w;
}
void setHeight(int h)
{
height = h;
}
protected:
int width;
int height;
};

// 基类 PaintCost
class PaintCost
{
public:
int getCost(int area)
{
return area * 70;
}
};

// 派生类
class Rectangle: public Shape, public PaintCost
{
public:
int getArea()
{
return (width * height);
}
};

int main(void)
{
Rectangle Rect;
int area;

Rect.setWidth(5);
Rect.setHeight(7);

area = Rect.getArea();

// 输出对象的面积
cout << "Total area: " << Rect.getArea() << endl;

// 输出总花费
cout << "Total paint cost: $" << Rect.getCost(area) << endl;

return 0;
}

21. 多态

多态按字面的意思就是多种形态。当类之间存在层次结构,并且类之间是通过继承关联时,就会用到多态。

C++ 多态意味着调用成员函数时,会根据调用函数的对象的类型来执行不同的函数。方法的行为应取决于调用

该方法的对象

构成多态的条件:

  • 必须存在继承关系
  • 继承关系中必须有同名的虚函数,并且它们是覆盖关系(函数原型相同)。
  • 存在基类的指针,通过该指针调用虚函数

有两种重要机制可用于实现多态公有继承

  • 在派生类中重新定义基类的方法
  • 使用虚函数

21.1 虚函数

21.1.1 静态联编译和动态联编

image-20220809214943891


21.1.2 虚函数简介

C++多态是通过虚函数来实现的,虚函数允许子类重新定义成员函数,而子类重新定义父类的做法称为覆盖,或者称为重写

最常见的用法就是声明基类的指针,利用该指针指向任意一个子类对象,调用相应的虚函数,动态绑定。由于编写代码的时候并不能确定被调用的是基类的函数还是哪个派生类的函数,所以被成为 “虚”函数 。如果没有使用虚函数的话,即没有利用C++多态性,则利用基类指针调用相应的函数的时候,将总被限制在基类函数本身,而无法调用到子类中被重写过的函数。

例:

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
35
36
37
38
39
40
41
42
#include<iostream>  
using namespace std;

// 基类
class A
{
public:
void foo()
{
printf("1\n");
}
virtual void fun() //基类中声明为虚函数
{
printf("2\n");
}
};

// 派生类
class B : public A
{
public:
void foo() //隐藏:派生类的函数屏蔽了与其同名的基类函数
{
printf("3\n");
}
void fun() //多态、覆盖
{
printf("4\n");
}
};
int main(void)
{
A a;
B b;
A *p = &a;
p->foo(); //输出1
p->fun(); //输出2
p = &b;
p->foo(); //取决于指针类型,输出1
p->fun(); //取决于对象类型,输出4,体现了多态
return 0;
}

注意: 虚函数只能借助于指针或者引用来达到多态的效果。


21.1.3 虚函数定义
  • 通常可以在基类中将派生类会重新定义的函数声明为虚函数
  • 函数在基类中被声明为虚函数后,它在派生类中将自动成为虚函数
  • 如果在派生类中使用关键字virtual 来指出哪些函数是虚函数也可以。
  • 如果要在派生类中重新定义基类的方法,通常应将基类函数声明为虚的,这样程序将根据对象类型而不是引用或指针的类型来选择函数版本
  • 将基类声明一个虚析构函数也是惯例。

注意: 关键字virtual 只用于类声明的方法原型中,而不能用在定义


21.1.4 虚函数的注意事项
  • 使用虚函数时,在内存和执行速度方面有一定的成本,包括:

    • 每个对象都将增大,增大量为存储地址的空间;

    • 对于每个类,编译器都创建一个虚函数地址表(数组);

    • 对于每个函数调用,都需要执行一项额外的操作,即到表中查找地址

  • 基类函数的声明中使用关键字virtual可使该方法在基类以及 所有的派生类 (包括从派生类派生出来的类)中是虚的。

  • 如果使用指向对象的引用或指针来调用虚方法,程序将使用为 对象类型定义的方法, 而不使用为引用或指针类型定义的方法。这称为动态联编或晚期联编。

  • 如果定义的类将被用作基类,则应将那些要在派生类中重新定义的类方法声明为虚的

  • 构造函数不能是虚函数。

  • 如果派生类没有重新定义函数,将使用该函数的基类版本


21.2 抽象基类和纯虚函数

21.2.1 纯虚函数介绍

如果想要在基类中定义虚函数,以便在派生类中重新定义该函数更好地适用于对象,但是您在基类中又不能对虚函数给出有意义的实现,这个时候就会用到纯虚函数

例如:

在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。

所以引入纯虚函数的概念:

  • 将函数定义为纯虚函数(方法:virtual ReturnType Function()= 0;
  • 则编译器要求在派生类中必须予以重写以实现多态性。同时含有纯虚拟函数的类称为抽象类,它不能生成对象
  • 声明了纯虚函数的类是一个抽象类。所以,用户不能创建类的实例,只能创建它的派生类的实例。

21.2.2 纯虚函数定义

纯虚函数是在基类中声明的虚函数,它在基类中没有定义,但要求任何派生类都要定义自己的实现方法。在基类中实现纯虚函数的方法是在函数原型后加 =0:

1
virtual void funtion1()=0

21.2.3 抽象类

抽象类是一种特殊的类,它是为了抽象和设计的目的为建立的,它处于继承层次结构的较上层

(1)抽象类的定义: 称带有纯虚函数的类为抽象类。

(2)抽象类的作用: 抽象类的主要作用是将有关的操作作为结果接口组织在一个继承层次结构中,由它来为派生类提供一个公共的根,派生类将具体实现在其基类中作为接口的操作。所以派生类实际上刻画了一组子类的操作接口的通用语义,这些语义也传给子类,子类可以具体实现这些语义,也可以再将这些语义传给自己的子类。

(3)使用抽象类时注意:

  • 抽象类只能作为基类来使用,其纯虚函数的实现由派生类给出。如果派生类中没有重新定义纯虚函数,而只是继承基类的纯虚函数,则这个派生类仍然还是一个抽象类。如果派生类中给出了基类纯虚函数的实现,则该派生类就不再是抽象类了,它是一个可以建立对象的具体的类

-------------已经到底啦! -------------