C/C++ 基础知识

C/C++

[TOC]

1. 数组

一维数组可以用于实现线性表的顺序存储、哈希表等,二维数组可用来保存图的邻接矩阵等。

没有引用数组,但数组可以有引用。

有指针数组和数组指针。

1.1 一维数组

初始化

在函数体外定义的内置类型数组(全局数组),元素会被初始化为0;

在函数体外定义的内置类型数组,元素不会被初始化。但是若初始化了部分元素,其后的元素也会被初始化为0;

若不是内置类型,不管在何处定义,均调用其默认构造函数为其初始化。若无默认构造函数,则报错。

1
2
3
4
int x[4]={0};   //{0,0,0,0}
int y[4]={1}; //{1,0,0,0}
int* a=new int[n]; //大小未知时使用new动态声明
delete []a; //使用完毕后释放内存空间,[]a表示释放a所指数组的内存,如果a是类对象,分别调用每个数组元素a[i]的析构函数

C风格字符串

  • 字符串常量

    双引号括起的字符序列,且C++中**均在末尾自动添加一个空字符’\0’**。注意’A’表示单个字符,”A”表示字符串常量,其表示A和\0两个字符。

  • 字符数组

    可以使用{}初始化(**最后一个元素必须为空字符’\0’**,以其作为字符串结束标志),也可以使用双引号括起的字符串初始化(自动在末尾添加’\0’)。

1
2
3
4
5
char ca1[]={'C','+','+'};        //不是C风格字符串,末尾没有'\0'
char ca2[]={'C','+','+','\0'}; //是C风格字符串,长度为4
char ca3[]="C++"; //是C风格字符串,末尾自动添加'\0',长度为4
char *cp2=ca2; //是C风格字符串
const char ch3[6]="Daniel"; //报错,因为字符串"Daniel"末尾其实还有一个空字符,长度为7而不是6

注意:使用C风格字符串的标准库函数时,牢记参数必须以空字符’\0’结束。

若一char数组变量的末尾没有’\0’,但是又使用了C风格字符串的标准库函数(比如strcpy、strcat,strlen)进行处理,那么程序就会在该变量的内存空间中一直寻找空字符’\0’,直到恰好遇到为止,导致程序出错。


1.2 二维数组

初始化

  • 按行初始化:使用两层花括号初始化,每一个花括号代表一行。

  • 顺序初始化:使用一个花括号初始化,逐行填入,缺少的默认初始化为0。

    C++在声明和初始化二维数组时,若对所有元素都赋值,可以省略第一维。但声明更高维数组时,最多也只能省略第一维。

    1
    2
    3
    4
    5
    6
    7
    8
    int ia[2][4]={
    {0,1,2,3},
    {4,5,6,7}
    };
    int ia[2][4]={0,1,2,3,4,5,6,7}; // 逐行填入,缺少的默认初始化为0
    int ia[2][4]={{0},{4}}; // 部分初始化,每行缺少的默认初始化为0
    int a[2][4] = {0,3,6}; // {0,3,6,0,0,0,0,0}
    int ia[][3]={0,1,2,3,4,5} //初始化所有元素时,可以省略第一维

    C/C++中二维数组按照行优先顺序存储,所以二维数组a在内存空间中的地址顺序b有如下关系:a[x][y] = b[x*列数+y]

    1
    2
    3
    4
    #define M 3
    #define N 4
    int a[M][N] = {{1,2,3,4}, {5,6,7,8}, {9,10,11,12}};
    p = &a[0][0];

    a[i][j]就等于*(p+i*N+j)

    另外由于是行优先顺序存储,所以a[1][5]也不会报错,会直接顺延到下一行,指向值10。

  • 动态声明

    1
    2
    3
    4
    5
    6
    7
    int **a = new int* [m];  // m行
    for(int i=0; i<m; i++)
    a[i] = new int [n]; // n列
    // 手动动态声明的数组需要手动释放内存
    for(int i=0;i<m;i++)
    delete []a[i];
    delete []a;

1.3 指针

指针运算

在C/C++中,指针虽然经常被当作整数来处理,但是其支持的操作非常有限,合法的运算包括:指针与整数的加减、同类型指针的比较、同类型两指针相减。

当指针与一个整数量进行算数运算时,整数在执行加法运算前始终会根据合适的大小进行调整(相乘)。比如,字符指针加1,则运算结果产生的指针指向内存中的下一个字符(整数量乘1);如果指针指向float类型的变量,由于float类型占据4个字节,所以指针加1时实际加到指针上的整型值为4(整数量乘4),即增加一个float大小。所以指针的大小与所指变量类型相关,其运算中指针增加的值也与这个类型相关。

image-20220613141312988

  • 指针的算数运算

C的指针的算术运算只局限于两种形式。第-种形式是: 指针+ / - 整数。这种形式用于指向数组中某个元素的指针。

第二种类型的指针运算具有如下的形式: 指针-指针。只有当两个指针都指向同一个数组中的元素时,才允许从一个指针减去另一个指针。减法运算的值是两个指针在内存中的距离(以数组元素的长度为单位,而不是以字节为单位)。即相差多少个元素就是多少。

如果两个指针所指向的不是同一个数组中的元素,那么它们之间相减的结果是未定义的。程序员无从知道两个数组在内存中的相对位置,如果不知道这一点, 两个指针之间的距离就毫无意义。

  • 指针的关系运算

还可以进行<、<=、>、> =运算,不过前提是它们都指向同-个数组中的元素。根据你所使用的操作符,比较表达式将告诉你哪个指针指向数组中更前或更后的元素。

指针数组与数组指针

  • 指针数组:由指针作为元素组成的数组。定义:int* a[10]

  • 数组指针:一个指向数组的指针。定义:int (*p)[10]为指向有10个元素的整形数组的指针,因为[]的优先级高于,所以要有括号,用[10]表示*p指向的数组有10个元素。*数组指针运算时以一整个数组大小为单位。

对于二维数组int w[3][4],定义一个与w等价的数组指针:int (*pw)[4] = w;

image-20220613143246893

image-20220613143516876

1
2
3
4
5
6
char a[]="hello";
a[0] = 'x'; // 变成xello
char* q=a;
q[0]='b'; // 变成bello
char *p="hello"; // 把存放该字符串的首地址装入指针变量
p[0]='x'; // 该语句错误

最后一个语句错误。a是数组,内存分配在栈上,故可以通过数组名或指向数组的指针进行修改,而p指向的是位于文字常量区的字符串,是不允许被修改的,故通过指针修改错误。但使用p[0]访问相应元素是正确的,只是不能修改。

指针和数组密切相关。特别是在表达式中使用数组名时,该名字会自动转换为指向数组首元素(第0元素)的指针。但是注意数组的首地址是常量,不可以进行赋值操作。

1
2
3
4
5
6
7
8
int ia[]={0, 2, 4, 6, 8};
ia += 1; // 编译错误,数组首地址ia为常量,不可变更;可以使用char* p=a; p+=2;实现
int *ip=ia;
// 修改第四个元素为9,可使用如下操作:
ia[4]=9;
*(ia+4)=9;
ip[4]=9;
*(ip+4)=9;

注意:对于int a[10];来说,&a[0]等价于a,为指向数组首元素的指针,每加1就跳过4个字节(int类型)。而&a为指向数组的指针,与a的类型不同(&a的类型为int(*)[10]),但是指向的单元相同。

例题:

image-20220614102002448

在二维数组int a[4][5],同理,其可以看成由4个数组作为元素组成的数组。那么a的第一个元素为数组a[0],然后是数组a[1]、a[2]、a[3],a表示指向数组首元素a[0]的指针,即数组指针。而a[0]本身为包含5个元素的数组,所以a[0]表示指向数组a[0]首元素a[0][0]的指针

image-20220614110311876

因此:

&a:类型为整个二维数组的数组指针,int(*)[4][5]。&a[0]等价于a,&a+1直接跳到二维数组末尾。

a:类型为int(*)[5],为a的第一个数组元素的数组指针。且a为常量,不可以进行赋值运算。a+i指向a[i],a加1将直接跳过5个元素,即*(a+1)相当于a[i]。

***a或a[0]*:类型为int,指针,指向数组a[0]的首元素a[0][0]。

***(a+1)或a[1]**:指向数组a[1]首元素a[1][0]的指针。

*(*(a+1)+2):为数组a[1]的第二个元素a[1][2].

例题:

image-20220615094712862


1.4 数组的应用

  • 线性表的顺序存储

    线性表是一种逻辑结构,线性表的顺序存储成为顺序表。

**注意**:线性表中元素的位序是从1开始的,而数组元素下标是从0开始的。

时间复杂度:

存取访问:通过首地址和元素序号可以在O(1)内找到指定元素。

插入:表尾插入O(1),表头插入O(n)。平均复杂度O(n)。

删除:表尾插入O(1),表头插入O(n)。平均复杂度O(n)。

按值查找:目标就在表头O(1),目标在表尾O(n)。平均复杂度O(n)。


2. 字符串

2.1 基础操作

子串:串种任意个连续字符组成的子序列。字符串本身以及空串也属于字符串的子串。

子序列:不要求字符连续,但是顺序与其在主串中相一致。

以整数格式%d输出字符时,’\0’会输出0,其他字符会输出相应的ascii码的十进制。因此可以以while(*str)来判断是否到达字符串末尾。

strlen(s) 返回s的长度,以’\0’作为结束标志,但是不包括字符串结束符null
strcmp(s1,s2) 比较两个字符串是否相同。两个字符串自左向右逐个字符比较(ASCII),直到出现不同的字符或遇到’\0’为止。
若相等,则返回0;若s1大于s2,则返回正数;若s1小于s2,则返回负数;
字符串比较不能用if(s1==s2),该语句比较的是首地址,而不是内容。
strcat(s1,s2) 将字符串s2连接到s1之后,并返回s1。
覆盖s1末尾的’\0’,且s1处必须要有足够的空间存放新生成的字符串。
strcpy(s1,s2) 将s2复制给s1,并返回s1。复制的内容到’\0’结束,处理不好容易溢出。
strncat(s1,s2,n) 将s2的前n个字符连接到s1后面,并返回s1
strncpy(s1,s2,n) 将s2的前n个字符复制给s1,并返回s1

**memcpy(void *dest, void *src, size_t n)**:从源src所指内存地址的起始位置拷贝n个字节到目标dest所指的内存地址的起始位置。必须指定拷贝长度n,且可用于各种数据类型,而strcpy仅用于字符串。

**memset(void *s, int ch, size_t n)**:将s中前n个字节用ch替换并返回s,作用是在一段内存中填充某个给定的值,它是对较大的结构体或数组进行清零操作的一种最快方法。

2.2 字符串包含问题

串的模式匹配算法KMP

  • Brute Force算法

    时间复杂度O(mn)

image-20220616153841511

  • KMP算法

    https://www.bilibili.com/video/BV1AY4y157yL?spm_id_from=333.337.search-card.all.click&vd_source=854e3e80724343215a332be36ec7cf83

    时间复杂度O(mn)

    KMP算法每当一趟匹配过程中出现字符比较不等时,不需回溯主串(主串的指针一直向后移动,不回退),而是利用已经得到的“部分匹配”结果将模式串向右“滑动”尽可能远的一段距离后,继续进行比较,且此时并不一定是拿模式串的第一位继续比较。

    next数组的作用:当匹配失败时,查看最后一个匹配成功的字符所对应的next数值。下次匹配时,在模式串中跳过前next个字符继续比对

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int kmp_search(const char* src, int slen, const char* patn, int plen, const int* nextval, int pos){
// nextval数组已知
int i=pos; // 主串的指针
int j=0; // 子串的指针
while(i<slen && j<plen){ // i永远递增
if(j==-1 || src[i]==patn[j]){++i;++j;} // 该字符匹配,指针后移
else{
//匹配失败时直接使用patn[nextval[j]]与s[i]继续比较,即跳过模式串中的nextval[j]个字符
j=nextval[j];
}
}
if(j>=plen) return i-plen; //返回匹配成功的子串开头
else return -1; //匹配失败
}

下面给出计算nextval数组的函数:

思想:在匹配成功的那段模式串中寻找最长的相同前后缀,这个长度就是nextval。那么对于匹配成功的那部分字符串,模式串的前缀就可以匹配到主串的后缀,所以可以跳过nextval个字符。

这个最长的相同前后缀不包括匹配成功的部分模式串本身。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void get_nextval(char const* ptrn, int plen, int* nextval){
int i=0; nextval[0]=-1; // 第0位前面没有字符串,也无法找最长相同前后缀,所以初始化为-1
int k=-1; // K记录字符i之前的字符串中最长相同前后缀的位置,就是next[i]
while(i<plen-1){
// 判断1:确定最长相同前后缀长度
if(k==-1 || ptrn[i] == ptrn[k]){ // 相等时k加1,即在前一位字符的k上加1得到目前的字符的k
++i;
++k;
// 判断2:如何给nextval[i]赋值
if(ptrn[i] != ptrn[k]) // i、k位置的字符是否相同,不相同,nextval[i]就等于k,下次与主串继续比较k处的字符,而不是从模式串开头开始
nextval[i]=k;
else
nextval[i]=nextval[k]; // 相同的话,因为i和k处的字符相等,主串接着比较k处的字符依然是不相等的,所以改为继续比较nextval[k]处的字符
}
else
k=nextval[k]; //不相等时k回退,直到找到k处的字符与i处相等或者k=-1
// 改为判断i、nextval[k]位置的字符是否相等,即查看相同前缀的前缀,继续判断(相同前缀的前缀等于相同后缀的后缀),直到k=-1(不存在任何相同的前后缀)
}
}

以模式串char *ptrn = “ABABC"为例,nextval数组下标为0到4。

对于ptrn[0]来说,不存在更短的前后缀,所以nextval[0]直接为-1;

接着对于ptrn[1]之前的字符串”A“,此时k=-1,所以进入判断1,k和i加1,k=0,i=1,由于ptrn[1] != ptrn[0],所以nextval[1]为k,为0;与ptrn[1]的’B’不匹配,而ptrn[0]与ptrn[1]不相等,所以可以右滑到与ptrn[0]的’A’继续匹配;

接着对于ptrn[2]之前的字符串”AB“,此时k=0,ptrn[1] != ptrn[0],所以进入判断1,k回退为nextval[0],为-1,接着继续判断1,k和i加1,k=0(最长相同前后缀的长度),i=2,ptrn[2] == ptrn[0],所以nextval[2]为nextval[0],为-1;与ptrn[2]的’A’不匹配,那右滑到ptrn[0]的’A’也是仍然不匹配的,所以nextval[2]为-1;

对于ptrn[3]之前的字符串”ABA“,此时k=0,ptrn[2] == ptrn[0],所以进入判断1,k和i加1,k=1(最长相同前后缀的长度),i=3,由于ptrn[3] == ptrn[1],nextval[3]为nextval[1],为0;与ptrn[3]的’B’不匹配,那右滑到ptrn[1]的’B’也是仍然不匹配的,所以nextval[3]为0;

对于ptrn[4]之前的字符串”ABAB“,此时k=1,ptrn[3] == ptrn[1],所以进入判断1,k和i加1,k=2(最长相同前后缀的长度),i=4,ptrn[4] != ptrn[2],所以nextval[3]为k,为2。与ptrn[4]的’C’不匹配,那右滑到ptrn[2]的’A’继续匹配的;

字符串移位包含问题

假设有一个函数 isSubstring, 其功能是判断一个字符串是不是另外一个字符串的子串。现在给你两个字符串s1与s2,请仅使用isSubstring函数判断s2是否能够被s1做循环移位得到的字符串包含。
解答思想是:
如果字符串s1的长度小于s2的长度,则返回0; .
否则,连接s1与其自身得到新字符串sls1,然后判断s2是否是sIsl的子串,若是返回1,若不是返回0。


2.3 字符串转数字

将字符串的字符逐个转为数字(*digit - '0'),乘以10然后加上下一个字符表示的数字。

另外还需要考虑特殊字符,比如首字符是否为’+’或者’-‘,是否包含非法字符,最后要以’\0’结束。以及中间结果是否大于上限std: :numeric_ limits<int>: :max()。

image-20220620111637041

**大数乘法**:

image-20220620142029124


2.4 其他问题

  • 字符串中的单词逆转:使用指针交换字符

  • 在主串中删除模式串中出现的字符:

    遍历;也可以给每个字母分配一个素数,从2开始,以此类推。这样a将会是2, b将会是3, c将会是5,等等,然后得出模式串的乘积multi, 现在遍历字符串s,把每个字母代表的素数除multi, 若能
    整除,则将其删除。

  • 删除字符串开头和末尾的空格,并将中间的连续空格转化为1个

  • 在字符串中找到第一个只出现一次的字符:使用数组实现的hash表即可,下标存放ascii码值(char可以直接作为整数处理),元素存放出现次数。在第二次遍历时,取首个为1的元素即可。

  • 判断字符串中所有字符都不相同:同样使用hash表即可,值统一为True。若出现一个字符在hash表中存在,则表明该字符重复。


3. 结构体、共用体和枚举

与数组的不同

结构体可以在一个结构中声明不同的数据类型;相同结构的结构体变量可以相互赋值。

与class的不同

class的成员访问权限默认为private,而struct成员的访问权限默认为public。

3.1 结构体的定义

不允许结构体本身的递归定义,但可以使用指针指向本类型。

结构体定义中可以包含另外的结构体,即可以嵌套。

1
2
3
4
5
6
7
8
9
10
struct Books
{
char title[50];
char author[50];
char subject[100];
int book_id;
struct person *per; // 指向本类型的指针
} book; // 同时声明一个结构体Books的变量book

struct Books book; // 声明一个结构体变量book
1
2
3
4
5
struct person{
char name [20];
char sex;
person(char a[20], char b) :name(a), sex(b)(a){} // 构造函数
}boy1={"zhangbing",'M'}; // 结构体变量可以在定义时初始化赋值

在对结构体变量初始化时,应将各成员所赋初值依照结构体类型说明中成员的顺序依次放在一对大括号中,不允许跳过前面的成员给后面的成员赋值,但可以只给前面若干成员赋初值,后面未赋初值的成员中,数值型和字符型的数据,系统自动赋值零。

1
2
3
4
5
6
7
8
9
//也可以用typedef创建新类型Simple
typedef struct
{
int a;
char b;
double c;
} Simple;
//现在可以用Simple作为类型声明新的结构体变量
Simple u1, u2[20], *u3;

3.2 结构体中的位字段

C/C++允许指定占用特定位数的数据成员,声明时,位字段的类型为整型或枚举,然后是冒号和指定位数的数字,如下:

1
2
3
4
struct reg{
unsigned int a:1; // 占1位
unsigned int b:4; // 占4位,4bits
};

3.3 共用体

结构体和共用体都是由多个不同的数据类型成员组成,但在任何同一时刻,共用体中只存放了一个被选中的成员,而结构体的所有成员都存在。对于共用体的不同成员赋值,将会对其他成员重写,原来成员的值就不存在了,而对于结构体的不同成员赋值是互不影响的。

结构体占用内存,可能超过各成员内存量总和;共用体占用内存为各成员中占用最大者内存。

共用体的用途之一是当数据项使用两种或更多种格式(但不会同时使用)时,可节省空间。

union成员从低地址开始存放

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Intel X86上运行一下程序
int main(int argc, char *argv[]) {
union{
struct {
unsigned short s1:3;
unsigned short s2:3;
unsigned short s3:2;
}x;
char C;
}V;
v.c=103;
cout<<v.x.s1<<endl;
return 0;
}

由于Intel X86是小端模式,103转为二进制为01100111,所以高地址到低地址的内存空间为01100111。而union成员都是从低地址开始存放,故分别分配给s1低地址的111,然后是s2的100,最后是s3的高地址的01。


3.4 大小端存储

字节序

大端存储格式:字数据的高字节存储在低地址中,而字数据的低字节则存放在高地址中;

小端存储格式:低地址中存放的是字数据的低字节,高地址存放的是字数据的高字节。

注意:

printf 函数是最右侧的元素先入栈。若入栈元素为char(占1个字节)、short(占2个字节) 等小于4个字节的类型,入栈时也占4个字节。这里的一个关键点是: char、 short 等类型入栈时由于入栈字节数为4,比它们实际占用的内存数要多,那么高位是补0还是补1呢?当数是无符号类型时(如unsigned short),高位总是补1,当数是有符号类型时(如short),高位补符号位

例题:

image-20220622110310042

array数组后4个元素默认初始化为0x00。printf的输出从右到左先依次入栈,输出时依次出栈。首先pint为int类型的指针,所以运算时4个字节一个单位,*(pint+2)的值为0x00000000。pint64为long long类型指针,运算时8个字节一个单位,又系统为小端,低位在低地址字节,所以*pint64为0x0807060504030201。pshort为short类型指针,运算时2个字节一个单位,所以*(pshort+2)为0x0605,由于入栈时不足4个字节,所以高位补0,得到0x00000605。这些值依次入栈得到如下栈空间(高位先入栈):

image-20220622110332959

位序

在字节内部也存在大小端问题(对于位字段/位数据),相应的大小端定义为:

  • 第一步:将位字段组成的字节,低字节存放在低地址,高字节存放在高地址;

  • 第二步:然后按照大小端格式的定义在每个字节中分配位地址:

大端存储格式:首先将位数据的高位存储在字节的高位中,之后低位数据存放在低位中。

小端存储格式:首先将位数据的低位存储在字节的低位中,之后高位数据存放在高位中。

注意:若位数据(如short in a:9)大于1个字节,则先在位数据组成的字节序中,先按字节序中的大小端的定义分配相应大小的位数据到相应的字节中(此过程位数据可能被拆分到不同字节中),然后再在每个字节中,按位序大小端的定义分配到相应的位地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Test{
unsigned short a:5;
unsigned short b:5;
unsigned short c:6;
};
int main(int argc, char**){
Test t;
t.a=16;
t.b=4;
t.c=0;
short i = *(short*)&t;
cout<<i;
return ;
}

在上述代码中,结构体表示的是由a,b,c三个位字段组成的两个字节,a是高位域员,c是低位域员。

在大端存储格式中,优先将位数据的高位存储在字节的高位中,所以5位的a(10000)和b的三位高位(001)分配到高位字节,剩下的2位b(00)和6位c(000000)分配到低位字节,因此高位字节的位序为[10000][001],低位字节位序为[00][000000];

在小端存储格式中,优先将位数据的低位存储在字节的低位中,所以5位a(10000)和b的三位低位(100)分配到高位字节,b的两位高位(00)和6位c(000000)分配到低位字节,因此高位字节的位序为[100][10000],低位字节位序为[000000][00];

因此,上述代码中t所在内存为:10010000 00000000,由于时小端存储,所以转换为十六进制为0x0090,所以输出应该为144。


3.5 枚举

C++的enum工具提供了另一种创建符号常量的方式,可以用于代替const。语句如下:

1
enum 枚举类型名{枚举常量1[=整形常数], 枚举常量2[=整形常数], …}[变量名列表]

花括号的内容称为枚举表,包含多个枚举常量,声明时可以为其赋初值。若不赋初值,编译器会为每一个枚举常量赋一个不同的整型值,第一个为0,第二个为1等。当枚举表中某个常量赋值后,其后的成员则按依次加1的规则确定其值。

1
2
3
4
int main (void) {
enum{a, b=5,c, d=4, e}; // a为0,c为6,e为5
enum{h, x, v=120,w, r=99, s}; // h为0,x为1,w为121,s为100
return 0;

3.6 sizeof运算符

使用方法

sizeof属于运算符,而不是函数。以字节形式给出其操作数的存储大小。操作数可以是一个表达式或括在括号内的类型名。操作数是类型名时必须加括号,比如sizeof(int)

sizeof的计算发生在编译时刻,可以直接作为常量表达式使用,所以其操作数中的运算会被忽略,比如sizeof(a++),其中的++并不执行。

实际上,sizeof 计算对象的大小也是转换成对对象类型的计算,也就是说,同种类型的不同对象其sizeof值都是一致的。这里,对象可以进一步延伸至表达式,即sizeof可以对一个表达式求值,编译器根据表达式的最终结果类型来确定大小,一般不会对表达式进行计算。比如sizeof(2)等价于sizeof(int)sizeof(2+3.14)等价于sizeof(double)

函数、位域成员不能被计算sizeof值。

使用结果

sizeof操作符的结果类型是size_ t,它被定义为unsigned int 类型。该类型保证能容纳实现所建立的最大对象的字节大小。

数据类型 占据字节长度(16bit编译器) 占据字节长度(32bit编译器) 占据字节长度(64bit编译器)
shortint 2 2 2
int 2 4 4
long 4 4 4
long long 8 8 8
float 4 4 4
double 8 8 8
char 1 1 1
bool 1 1 1
指针 - 4 8
引用 取决于被引用对象 取决于被引用对象 取决于被引用对象

若有:char ch3[]="Danie1";sizeof(ch3) = sizeof("Daniel")=7,而strlen("Daniel")=6

可见sizeof计算数据(包括数组、变量、类型、结构体等)所占内存空间,用字节数表示,故将内容为’\0’的数组元素也会计算在内。而strlen()计算字符数组的字符数,以’\0’为结束标志,且不将’\0’计算在字符数内

注意:sizeof("\0") = 2

指针可视为变量类型的一种。在32位机器系统下,所有指针变量的sizeof操作结果均为4,若在64位机器系统下,所有指针变量的sizeof 操作结果为8。

数组可以使用sizeof计算其大小,等于元素个数*元素类型的sizeof。

struct的空间计算

struct的空间计算总体遵循两个原则:

  • **==整体空间是占用空间最大的成员(的类型)所占字节数的整数倍==**,但在32位Linux+gcc环境下,若最大成员类型所占字节数超过4,如double是8,则整体空间是4的倍数即可。

  • 数据对齐原则:内存按结构体成员的先后顺序排列,==当排到该成员变量时,其前面已摆放的空间大小必须是该成员类型大小的整数倍(当排到子结构体时,其前面已摆放的空间大小必须是该子结构体成员中最大类型大小的整数倍)==,如果不够则补齐,依次向后类推,但在Linux+gcc环境下,若某成员类型所占字节数超过4,如double是8,则前面已摆放的空间大小是4的整数倍即可,不够则补齐。
    对齐问题使结构体的sizeof变得比较复杂。

    ==注意==结构体中,数组时按照单个单个变量一个一个进行摆放,而不是视为整体。空结构体的占用空间大小为1

含位域的结构体的空间计算

使用位域的主要目的是压缩存储,其大致规则为:

  • 如果相邻位域字段的类型相同,且其位宽之和小于类型的sizeof大小,则后面的字段将紧邻前一个字段存储,直到不能容纳为止。所占字节数以其实际占用字节数为准,也就是进行压缩。
  • 如果相邻位域字段的类型相同,但其位宽之和大于类型的sizeof大小,则后面的字段将从新的存储单元开始,其偏移量为其类型大小的整数倍,不进行压缩。
  • 如果相邻的位域字段的类型不同,则各编译器的具体实现有差异,VC6 采取不压缩方式,Dev-C++与linux+gcc采取压缩方式
  • 如果位域字段之间穿插着非位域字段,则不进行压缩
  • 整个结构体的总大小为最宽基本类型成员大小的整数倍
1
2
3
4
5
6
// 环境为linux+gcc
struct a{
int f1:3;
char b;
char c;
};

比如,上述结构体中f1占3个bits,b非位域类型,所以f1占用1个字节,abc总共占用3个字节。最后由于整个结构体的总大小为最宽基本类型成员int大小的整数倍,所以总共要占用4个字节。

union的空间计算

结构体在内存组织上是顺序式的,而联合体union是重叠式的,各成员共享一段内存,所以整个联合体的sizeof也就是每个成员sizeof的最大值,且整体空间是占用空间最大的成员(的类型)所占字节数的整数倍。即取占用内存最多的成员的空间作为自己的空间,且需要考虑对齐。

1
2
3
4
union{
char b[9];
int bh[2];
}c;

上述代码的union中,数组b占用9个字节,bh占用8个,考虑占用内存最大的成员,所以应该是占用9个字节;又需要考虑对齐,占用空间应该是4(int占用空间)的整数倍,所以补齐为12。

枚举的空间计算

enum仅定义一个常量集合,里面没有元素,而枚举类型均作为int类型存储,因此枚举类型的sizeof均为4。


4. 运算符及其优先级

赋值语句

自增与自减运算符

以++操作为例,对于变量a, ++a表示取a的地址,增加它的内容,然后把值放在寄存器中; a++表示取a的地址,把它的值装入寄存器,然后增加内存中a的值。前缀运算是“先变后用”,而后缀运算是“先用后变”。

==注意==a++只能位于等号的右边,而++a可以位于等号的左边。

负号运算符与自增(减)运算符的优先级相同,结合方向是从右向左。比如k=-i++等价于k=-(i++)

特别的,对于指针变量:

*p++ 实现了先输出p所指地址处的数据值,然后指针后移到下一指针处;

*++p 实现了先将指针指向后移,再输出此时指针所指处的数据的值;

(*p)++ 实现的是将指针p所指向地址处的数据值(比如300)输出后再自增1(得到301);

关系与逻辑运算符

关系操作符:<、<=、>、>=。具有左结合性质,先执行左边的部分。但是不建议将多个关系操作符串接使用。

if(i<j<k)这种写法中,只要k大于1,上述表达式的值就为true。 这是因为第二个小于操作符的左操作
数是第一个小于操作符的结果: true 或false。 也就是,该条件将k与整数0或1做比较。为了实现我们想要的条件检验,应重写上述表达式如下:if(i<j && j<k)

逻辑与和逻辑或操作符总是先计算其左操作数,然后再计算其右操作数。只有在仅靠左操作数的值无法确定该逻辑表达式的结果时,才会求解其右操作数。我们常常称这种求值策略为“短路求值”。

位运算符

位运算符使用整型操作数,将其视为二进制位的集合。

image-20220623102913108

(n&(n-1))==0用于判断n的二进制表示是否仅有一位为1。

异或运算满足交换律。两相同的数异或结果为0,可用于寻找数成对出现时缺失的某一个数。

例1:

给你一个由n-1个整数组成的未排序的序列,其元素都是1到n中的不同的整数。请写出一个寻找序列中缺失整数的线性时间算法。

解答:

1)求这n-1个数的和sum,然后计算n(n+1)2-sum可得。此种解法当n很大时,加法运算有可能溢出。

2)用异或运算可以解决。首先求得从1到n共n个数的异或结果A,即A=1^2^3..^n,然后用题目中的序列依次与A求异或,最后得到的数,就是丢失的整数。

例2:

不使用第三方变量,交换两个变量的值:

a=a^b;
b=a^b;
a=a^b;

==~运算符的优先级 > 移位运算符的优先级 > 与、或、异或运算符的优先级。==

赋值转换

赋值转换指的是将一种类型的值赋给另一种类型的变量,这时,值将会转换为接收变量的类型。

比如int val = 3.14;得到的val为3,int *p; p = 0;中int型的0转换为int *类型的空指针。

当把一个超出其取值范围的值赋给一个指定类型的对象时,比如将一个 int 类型的数赋值为short类型的数,当前大多数的系统都是将int低字节赋值给short,而将高位舍去(相当于取余)。当把一个取值范围小的值赋给一个取值范围大的值,则进行符号位扩展。

表达式转换

  • 整型提升
    在表达式计算中,C++将bool、char、unsigned char、signed char、short 和signed short型值都会自
    动转换成int型,对bool类型而言,true 转换为1, false 则转换为0。

    ==同一类型的无符号类型与有符号类型所占内存空间相同,只不过无符号类型将符号位作为数值位而已。所以在C++中, 有符号数与无符号数转换时,内存中的内容并没改变,只是对内存中相同的数据解释不同而已。==

    int和unsigned int混合运算时,int会被转换为unsigned int,内存的内容不变,但是符号位被当作数值,所表示的数值发生改变,且恒大于等于0。

    比如int类型的-1的字节是100……001,共32位,第一位为符号位。由于在计算机中用补码表示数值(负数的补码为其符号位之外的位数求反然后加1),所以-1在内存中为其补码111……111。当其转为unsigned int时,所有位均表示数值,那么此数就是2^32-1。

    所以unsigned int类型的变量一直减1,结果也不会小于0。

  • 运算时的转换

    当运算涉及两种类型时,较小的类型将会被转换成较大的类型,换言之,表达力低的类型将会被转换成表达力高的类型。各类型表达能力从低到高排列为:

    int (等价于signed int)、unsigned int、long (等价与signed long) 、unsigned long、float、double、long double

==其余例题见P74例5==

显示转换(强制类型转换)

运算符优先级表

image-20220623211239436

image-20220623211447726

运算符优先级有几个简单的规则:

  • 括号,下标,>和.(成员)最高;
  • 单目的比双目的高;算术双目的比其他双目的高;
  • 移位运算高于关系运算;**关系运算高于按位运算(与,或,异或)**;按位运算高于逻辑运算;
  • 三目的只有一个条件运算,低于逻辑运算;
  • 赋值运算仅比”,“高,且所有的赋值运算符优先级相同,结合访问位从右向左。

image-20220623212501031

5. C预处理器、作用域、static、const以及内存管理

5.1 C预处理器

宏定义与宏替换

宏定义不分配内存,变量定义才会分配内存。宏定义末尾不加分号。

#define指示接受一个名字并定义该名字为预处理器变量。

1
2
3
4
// 符号常量的宏定义及宏替换
# define 标识符 字符串
// 带有参数的宏定义及宏替换,如#define FUN(x) ((x)*(x)) 为避免宏替换时发生错误,参数最好加上括号
# define 标识符(参数列表) 字符串

宏替换的本质很简单——文本替换。关于宏定义与宏替换请注意以下几点:

  • 宏名一般用大写(避免名字冲突),宏名和参数的括号间不能有空格,宏定义末尾不加分号;
  • 宏替换只作替换,不做语法检查,不做计算,不做表达式求解
  • 宏替换在编译前进行,不分配内存,函数调用在编译后程序运行时进行,并且分配内存;
  • 函数只有一个返回值,利用宏则可以设法得到多个值;
  • 宏替换使源程序变长,函数调用不会;
  • 宏替换不占运行时间,只占编译时间,函数调用占运行时间(分配内存、保留现场、值传递、返回值)。

==注意==:应尽量少用宏替换。在C++中,宏替换实现的符号常量功能由const、enum代替,带参数的宏替换可由模版内联函数代替。

文件包含

1
2
3
4
// 标准头文件
#include <standard_header>
// 非系统头文件
#include "myfile.h"

条件编译

提供条件编译措施使同一源程序可以根据不同编译条件(参数)产生不同的目标代码,其作用在于便于调试和移植。条件编译控制语句有不同形式:

1
2
3
4
#if/ifdef/ifndef
#elif
#else
#endif

#ifndef检测指定的预处理器变量是否未定义。如果预处理器变量未定义,那么跟在其后的所有语句都被处理,直到出现#endif。如果预处理器变量已定义,那么跟在其后直到出现#endif的所有语句都被忽略。

5.2 全局变量与局部变量

  • 全局变量

    在函数外部定义的变量,属于源程序文件,作用域为整个源程序。

    在函数中使用全局变量时,需要说明使用的是全局变量。

    在不同文件中引用一个已经定义过的全局变量:可以用引用头文件的方式,也可以用extern关键字。下面的代码给出了使用extern引用已经定义过的全局变量的例子。

1
2
3
4
5
// file_ 1.cpp
int counter; //定义counter
// file_ 2.cpp
extern int counter; //使用file 1中的counter
++counter; // 使file_ 1中的counter自增1
  • 局部变量

    在程序中,只在特定过程或函数中可以访问的变量。局部变量可以与全局变量同名且屏蔽全局变量。

    在语句的控制结构中定义的变量尽在定义它们的块语句结束前有效。这种变量的作用域限制在语句体内。比如比如while(int i =get_num())中的i。

    在同一个文件中,当局部变量屏蔽了全局变量,而又想要使用全局变量时,有两种方法。一种是使用做用域操作符”::”,一种是使用”extern”。

    1
    2
    3
    4
    ::counter++;
    // 或者
    extern int count;
    counter++;

5.3 static

static的作用

  • **隐藏:使变量不能被其他文件访问**(对于函数和全局变量)

    当编译多个文件时,所有未加static前缀的全局变量和函数都具有全局可见性,其他的源文件也能访问。如果加了static前缀就会对其他源文件隐藏。利用这一特性可以在不同的文件中定义同名函数和同名变量,不必担心命名冲突。

  • 默认初始化为0(未初始化的全局静态变量和局部静态变量)

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

    函数体外的内置数组,不管有没有static前缀,均会将各元素初始化为0;在函数体内定义的内置函数,若没有static前缀,各元素未初始化,其值不确定。

  • 保持局部变量内容的持久

    函数内的自动(局部)变量,当调用时就存在,退出函数时就消失,但静态局部变量虽然在函数内定义,但静态局部变量始终存在着,也就是说它的生存期为整个源程序,其特点是只进行一次初始化且具有“记忆性”
    静态局部变量的生存期虽然为整个源程序,但是其作用域仍与局部变量相同,即只能在定义该变量的函数内使用该变量。退出该函数后,尽管该变量还继续存在,但不能使用它。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    int count=3;    // 外部变量
    int main (void) {
    int i, sum, count=2;
    for(i=0, sum=0; i<count; i+=2, count++) {
    static int count=4; //局部静态变量,只初始化一次
    count++;
    if(i%2 == 0){
    extern int count; // 此处为外部变量,即第一行的count
    count++ ;
    sum += count; //语句1,sum第一次循环+4,第二次循环+5
    }
    sum +=count; //语句2,此处count为局部静态变量,sum第一次循环+5,第二次循环+6
    }
    printf ("%d %d\n", count, sum); // 此处为第三行的count,输出结果为4 20
    return 0;
    }

类中static的作用

用于表示属于一个类而不属于此类的任何特定对象的变量和函数(与java中此关键字的含义相同)。

  • 静态数据成员

    在类内数据成员的声明前加上关键字static,静态数据成员独立于该类的任意对象而存在,即当某个类的实例修改了该静态成员变量,其修改值为该类的其他所有实例所见。静态数据成员和普通数据成员一样遵从public, protected, private访问规则。

    由于静态数据成员定义时需要分配空间,所以不能在类声明中定义。**==static数据成员必须在类定义体的外部定义==**。一般而言,类的static 成员,像普通数据成员一 样,不能在类的定义体中初始化,static数据成员通常在类定义体的外部定义时才初始化。即在类定义体中对静态变量赋初值是错误的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    class Account (
    public:
    void applyint() { amount += amount*interestRate; }
    static double rate() { return interestRate; }
    static void rate (double); // sets a new rate
    private:
    std: :string Owner;
    double amount ;
    static double interestRate; // 仅声明,需要在类外定义
    static double interestRate=0.3; // 错误,不可以在类定义体中对静态变量赋初值

    static double initRate() ;
    };

    double Account::interestRate = initRate(); // 在类外定义

    ==例外:const static数据成员可以在类的定义体中进行初始化==。基本整型const static数据成员在类的定义体中初始化时,该数据成员仍必须在类的定义体之外进行定义,只不过定义时,不再需要初始化。(相当于在类外定义,在类定义体中进行const static声明和初始化)

    例:C++中关于对象成员内存分布的描述正确的是()。

    A. 不管该类被产生多少个对象,静态成员变量永远只有一个实例,且在没有对象实例的情况下已经存在。
    B, 费静态成员数据在类中的排列顺序将和其被声明的顺序相同,任何中间介入的静态成员都不会被放进对象的内存布局中。
    C. 在同一访问段(也就是private,public,protected等区间段内),数据成员的排列符合“较晚出现的成员在对象中有较高的内存地址”。
    D. 带有虚函数的类对象占用的内存大小跟虚函数的个数成正比。

    解析:ABC。

    类中数据成员的布局情况:

    1. 非静态成员在类对象中的排列顺序和声明顺序一致, 任何在其中间声明的静态成员都不会被放进对象布局中。
    2. 静态数据成员存放在程序的全局(静态)存储中,和个别类对象无关。
      C++标准规定,在同一个访问块即private、public、 protected 等区段中,成员的排列只需符合较晚出现的成员在类对象中有较高的地址即可。
  • 静态成员函数

    ​ 静态成员函数同样属于类定义的一部分,为类服务,而不是某个具体对象。普通成员函数总是具体的属于某个类的具体对象,所以普通的成员函数一般都隐含了一个this指针,this指针指向类的对象本身。但是静态成员由于不与任何的对象相关联,因此不具有this指针因而它无法访问类对象的非静态数据成员,也无法访问非静成员函数,它只能调用其余的静态成员函数与访问静态数据成员。
      static成员函数不是任何对象的组成部分,因此static成员函数不能声明const。毕竟,将成员函数声明为const后就承诺不会修改函数所属的对象,而static成员函数不属于任何对象。
      static成员函数也不能被声明为虚函数、volatile

    关于静态成员函数,可以总结为以下几点:

    • 静态成员之间可以相互访问,包括静态成员函数访问静态数据成员和访问静态成员函数。**静态成员函数不能访问非静态成员函数和非静态数据成员,非静态成员函数可以任意地访问静态成员函数和静态数据成员**(静态成员变量可被该类的所有方法访问);
    • 由于没有this指针的额外开销,因此静态成员函数与类的非静态成员函数相比速度上会有少许的增长。

5.4 const

常量

const限定符将一个对象转换为一个常量。常量在定义后就不能被修改,所以在定义时必须进行初始化

在全局作用域里定义非const 变量时,它在整个程序中都可以访问。

但是除非特别说明,在全局作用域声明的const 变量是定义该对象的文件的局部变量。此变量只存在于那个
文件中,不能被其他文件访问。通过指定const 变更为extern,就可以在整个程序中访问const 对象:

1
2
3
4
5
6
// file 1.cpp
extern const int counter=10; // 定义counter,extern使const常量可以被其他文件访问
// file 2.cpp
extern const int counter; //使用file 1中的counter
for (int index=0; index != counter; ++index)
..

在C语言中多使用#define进行常量声明

如果在C中使用const,下面的语句在C语言中编译错误,因为在C中const意思是“一个不能被改变的普通变量”,即它被放在内存中,C编译器不知道它在编译时的值。但在C++中,下面的语句是可行的。

1
2
const bufSize = 100;
int buf[bufSize ];

const相比#define的优势:

  • const常量有数据类型,而宏常量没有数据类型。编译器可以对前者进行类型安全检查。而对
    后者只进行字符替换,没有类型安全检查;
  • 使用常量可能比使用#define导致产生更小的目标代码,这是因为预处理器”盲目地将宏名称
    BUFSIZE替换为其代替的值100”可能导致目标代码出现多份100的备份,但常量就不会出现这种情况。
  • const还可以执行常量折叠(常量折叠是在编译时间简单化常量表达的一个过程,简单来说就是将常量表达式计算求值,并用求得的值来替换表达式,放入常量表),也就是说,编译器在编译时可以通过必要的计算把一一个复杂的常量表达式缩减成简单的。

综上,在C++中,我们应该用const取代#define。

指针和const

在指针的声明中,需要区分指向const对象的指针和const指针。

  • 指向const对象的指针

如果指针指向const对象,则不允许用指针来改变其所指的const值。为了保证这个特性,C++强制要求指向const对象的指针也必须具有const特性。

1
2
3
const double *cptr = &value;
// 等价于
double const *cptr = &value;

cptr是一个指向const double类型的指针,cptr 的值可以改变,但是不能通过ptr改变value的值;

  • const指针

使指针本身成为一个const指针,所指向的值可以改变,但是地址不变。声明时必须把const标明的部分放在*的右边,如:

1
2
double value=0.1;
double* const cptr = &value; // 由于指针是const,所以编译时必须有初始化

cptr 的值不可以改变,但是可以通过ptr改变value的值。

const修饰函数参数与返回值

  • const修饰返回值

    const 修饰返回值常用在处理用户定义的类型时。当处理用户定义的类型时,返回值不为常量有时会对用户造成困扰。

    函数除了返回值类型外,还可以返回指针。函数不能返回指向局部栈变量的指针,这是因为在函数返回后它们是无效的,而且栈也被清理了(栈会自动分配和释放)。可返回的指针是指向堆中分配的存储空间的指针或指向静态存储区的指针,在函数返回后它仍然有效。

    比如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    char *GetMemory (void) {
    char p[]="he1lo world"; // 数组,内存分配在栈上
    return P: // 返回指向栈内存的指针,但是由于是局部变量,返回时原来的内容已被清除,p指向的新内容不可知
    }

    void Test (void) {
    char *str-NULL;
    str=GetMemory();
    printf(str); // 输出可能是乱码
    }

    Test();

    可以改为如下内容:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    1) 
    char *GetMemory (void) {
    static char p[]="hello world" ; // 数组位于静态存储区,生存周期为整个源程序,可通过函数返回
    return p;
    }

    2)
    char *GetMemory (void) {
    char *p="hel1o world"; /* "hel1o world"位于文字常量区,所以p是指向全局(静态)存储区的指针,可通过函数返回*/
    return p;
    }

    3)
    char *GetMemory (void) {
    char *p = (char*)malloc(12); /* p是指向堆中分配存储空间的指针,可通过函数返回,但需要以后调用delete []释放内存,否则会造成内存泄露*/
    if(p == NULL)
    return NULL;
    else
    P=" hello world";
    return P;
    }
  • const修饰函数参数

    使参数值在函数体内不会发生改变。主要是用来修饰地址,使地址不发生改变。

    若使用值或者函数返回值作为函数参数,那么传递给函数的均为临时变量,会被函数作为常量,编译器会为其分派临时存储单元,并产生一个地址和其引用捆绑在一起,存储的内容是常量,所以实参必须是const。

    image-20220627110937750

cosnt在类中的应用

const只能作用于成员函数,不能作用于全局函数。

  • const成员函数

    确保该成员函数可作用于const对象。

    1
    2
    3
    4
    class base{
    void func1();
    void func2() const;
    }

    func1默认会有对象的this指针作为形参。func2声明时末尾的const使得this所指向的对象也为const,这使得该函数可作用于const对象。因为const对象只能调用其const成员函数,无法调用其非const成员函数

    非const对象可以调用所有成员函数。

  • const数据成员

    常量数据成员(常量成员变量)==必须在构造函数的成员初始化列表中进行初始化==,并且必须有构造函数。因为const 数据成员只在某个对象生存期内是常量,而对于整个类而言却是可变的。而类可以创建多个对象,不同的对象其const数据成员的值可以不同。所以不能在类的声明中初始化const数据成员,因为类的对象没被创建时,编译器不知道const数据成员的值是什么。

    1
    2
    3
    4
    5
    struct Thing{
    Thing():valueB(1){*}) // 使用构造函数初始化列表对const数据成员valueB进行初始化
    int valueA;
    const int valueB;
    };

    ==例外:当const整型数据成员同时被声明为static时,可以使用外部初始化。==因为static使得该数据成员为类所有,而不是对象,只能在类外进行定义。

    如果想要建立在整个类中都恒定的常量,除了使用上面的const static外,还可以使用枚举常量实现,如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class Test{
    public:
    Test():a(0) {} // 在构造函数初始化列表中对const数据成员a进行初始化
    enum {size1-100, size2=200};
    private :
    const int a; //只能在构造函数初始化列表中初始化
    static int b; //在类的实现文件中(即类定义体的外部)定义并初始化
    const static int c; /*与static const int c; 相同,c为整型,故也可在此处初始化,但仍需在类定义体外进行定义,注意c为非整型时,不能在此处初始化,整型包括char、short、int、long*/
    };
    int Test::b=0; /* static成员变量不能在构造函数初始化列表中初始化,因为它不属于某个对象*/
    const int Test::c=0; /*注意:给const static成员变量赋值时,不需要加static修饰符,但要加const*/

5.5 内存管理与释放

一个C/C+ +的程序,用户使用的内存主要分为以下几个部分:

  1. 手动分配和释放,与数据结构中的堆不是同一个概念,分配方式类似链表。一般速度较慢,容易产生内存碎片,不过用起来方便。C中由malloc、free操作,C++中由new、delete操作。若不手动释放,则在程序结束后由系统释放。malloc与free是C/C++语言的标准库函数,new/delete 是C++的运算符

    1
    2
    char* p1 = (char *)malloc(10);  // 申请10个字节空间,由free释放
    char* p2 = new char[10]; // 由delete[]释放

    但是注意指针p1、p2 本身是在栈中的,它们指向在堆上分配的内存。**回收用new[]分配的一组对象的内存空间时用delete[]**。

  2. 栈区(stack)

    由编译器自动分配和释放,存放函数的参数值、局部变量的值等。操作方式类似数据结构中的栈,速度较快。

  3. 全局(静态)存储区

    存放全局变量和静态变量。初始化的全局变量和静态变量存放在DATA段,未初始化的存放在BSS段。程序结束后由系统释放,

    BSS段的特点是在程序执行之前BSS段会自动清0。所以未初始化的全局变量和静态变量在程序执行前已经为0

  4. 文字常量区:存储常量字符串。程序结束后由系统释放。

  5. 程序代码区:存放函数体的二进制代码。

    image-20220609144830856

C语言内存操作函数

1
2
3
4
5
6
7
8
9
10
void GetMemory(char *p){
p=(char*)malloc(11);
}
int main(){
char *str="hello";
GetMemory(str);
strcpy(str, "hello word"); // 运行错误
printf("%s",str);
return 0;
}

上述程序会运行错误。

image-20220627144556450

开始时,str是指向文字常量区的指针,GetMemory函数并不会为str新分配空间。如上图所示,函数调用传参时,str和形参的p虽然指向相同,但它们自身的地址不同,是两个不同的变量。

image-20220627144623164

如上图所示,p在执行malloc之后就指向不同的位置了,随后因为p是局部变量而被释放,malloc的空间没有free,成为无法引用的空间了。

str一直指向的是”hello”的文字常量区,而文字常量是不允许修改的,故调用strcpy时会出错。

C++内存管理

动态创建对象如果不是显示初始化(如string()),那么对于类类型的对象,用该类默认构造函数初始化;而内置类型的对象则无法初始化,如:

1
2
3
4
string *ps=new string;	    //调用默认构造函数初始化
string *ps=new string(); //调用默认构造函数初始化
int *pi=new int; // pi指向的内容未初始化
int *pi=new int(); // 显式初始化,pi指向一个初始化为0的int值

可见对于提供了默认构造函数的类类型(如string),没有必要对其对象进行显式初始化。因为无论程序是明确地不初始化还是要求进行初始化,都会自动调用其默认构造函数初始化该对象。

而对于内置类型或没有定义默认构造函数的类型,采用不同初始化方式则有显著的差别。内置类型对象或未提供默认构造函数的类类型对象必须显式初始化。

new的执行过程是:首先,调用名为operator new的标准库函数,分配足够大的原始未类型化的内存,以保存指定类型的一个对象;接下来,运行该类型的一个构造函数,用指定初始化式构造对象;最后,返回指向新分配并构造的对象的指针

delete的执行过程是:首先,对sp指向的对象运行适当的析构函数;然后,通过调用名为operator delete的标准库函数释放该对象所用内存

malloc/free与new/delete的区别

  • malloc/free是C/C++语言的标准库函数,new/delete是C++运算符
  • new自动计算需要分配的空间,而malloc需要手工计算字节数
  • new是类型安全的,而malloc则不是
  • new调用operator new分配足够的空间,并调用相关对象的构造函数,而malloc只负责分配空间,不能调用构造函数;delete将调用实例的析构函数,然后调用operator delete,以释放该实例占用的控件,而free只负责释放空间,不能调用析构函数
  • malloc/free需要库文件支持,new/delete不需要

6. 函数

一般的来说,函数是可以返回局部变量的。 局部变量的作用域只在函数内部,在函数返回后,局部变量的内存已经释放了。因此,如果函数返回的是局部变量的值,不涉及地址,程序不会出错。但是如果返回的是局部变量的地址(指针)的话,程序运行后会出错。因为函数只是把指针复制后返回了,但是指针指向的内容已经被释放了,这样指针指向的内容就是不可预料的内容,调用就会出错。准确的来说,函数不能通过返回指向栈内存的指针(注意这里指的是栈,返回指向堆内存的指针是可以的。

参数传递

形参和实参用作数据传送。形参出现在函数定义中,仅在函数体中可以使用。实参出现在主调函数中,进入被调函数后,实参变量也不能使用。主调函数只是把实参的值传送给被调函数的形参,只有引用才会改变实参

C语言的函数参数传递可以分为传递值和传递地址(指针)。C++中可以分为传递值、传递指针、传递引用。

1
2
3
4
5
viod f1(int* m, long& n) ;
int a;
long b;
}
f1(&a, b); // m为指针传递,n为引用传递

给函数传递实参遵循变量初始化的规则。非引用类型的形参以相应实参的副本(值)初始化,若是对象还会调用拷贝构造函数。对(非引用)形参的任何修改仅作用于局部副本,并不影响实参本身。为了避免传递副本的开销,可将形参指定为引用类型,这时内存中不会产生实参的副本。对引用形参的任何修改会直接影响实参本身。应将不需要修改相应实参的引用形参定义为const引用。

要使引用pr代表变量char *p, 则pr的初始化语句为char* &pr=p;

使用指针和解引用来交换变量的值:

1
2
3
4
5
6
7
8
9
10
void swap(int *p1,int *p2)
{
// 交换指针所指向地址的内容
int t=*p1;
*p1=*p2;
*p2=t;
}
int a=10;
int b=20;
swap(&a,&b);

内联函数

通常编译时,调用内联函数的地方,将不进行函数调用,而是使用函数体替换调用处的函数名,形式类似宏替换,这种替换称为内联扩展。

内联扩展可以消除函数调用时的时间开销。将函数指定为inline函数,通常就是将它在程序中每个调用点上“内联地”展开。

一般来说,内联机制适用于优化小的、只有几行的而且经常被调用的函数。大多数的编译器都不支持递归函数的内联。

  • 成员函数成为内联函数
    在类中定义的成员函数全部默认为内联函数,可以显式加上inline标识符,或者不加。在类中声明的成员函数,如果加了inline, 则其为内联函数;如果没加inline,而在类外定义该成员函数时加了inline,该成员函数也为内联函数。

  • 普通函数成为内联函数
    在普通函数声明或定义前加inline使其成为内联函数。

注意:宏定义与内联函数的区别

首先,宏定义是在预处理阶段进行代码替换,而内联函数是在编译阶段插入代码;

其次, 宏定义没有类型检查,而内联函数有类型检查。

默认参数

  • 默认参数只可以在函数声明中设定一次,只有在无函数声明时,才可以在函数定义中设定。
  • 默认参数定义的顺序为自右到左。即如果一个参数设定了默认值,其右边的参数都要有默认值。
  • 默认值可以是全局变量、全局常量,甚至一个函数,但不可以是局部变量。因为默认参数是在编译时确定的,而局部变量位置与默认值在编译时无法确定。

接受可变参数的函数实现多个数的相加:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int add(int num, ...){
int sum=0;
int index=0;
int* p=(int*)&num+1;
for(;index<num;++index){
sum += *p++;
}
return sum;
}
int main(){
int i=1, j=2, k=3;
cout<<add(3,i,j,k);
return 0;
}

函数重载

进行函数重载时,要求同名函数在参数个数上不同,或者参数类型上不同。

操作符重载,本质上也是函数重载,它大大丰富了已有操作符的含义,方便使用,如+可用于连接字符串等。

函数模板与泛型

在泛型编程中,我们所编写的类和函数能够多态地用于跨越编译时不相关的类型。一个类或一个函数可以用来操纵多种类型的对象。标准库中的容器、迭代器和算法是很好的泛型编程的例子。标准库用独立于类型的方式定义每个容器、迭代器和算法,因此几乎可以在任意类型上使用标准库的类和函数。

函数模板

在C++中,模板是泛型编程的基础。模板是创建类或函数的蓝图或公式。

模板定义以关键字template开始,后接模板形参表,模板形参表是用尖括号括住的一个或多个模板形参的列表,形参之间以逗号分隔。模板形参表不能为空。同样,模板形参表示可以在类或函数的定义中使用的类型或值。

1
2
3
4
template <typename T>  // 关键字使用class或者typename
T add(T x, T y){ // T表示哪个实际类型由编译器根据所用的函数参数而确定
return x+y;
}

image-20220627211022576

类模板

1
2
3
4
5
6
7
8
9
10
11
12
template <class Type>
class Queue {
public:
Queue(); // default constructor
Type &front (); // return element from head of Queue
const Type &front () const;
void push (const Type &); // add element to back of Queue
void pop(); // remove element from head of Queue
bool empty() const; / true if no elements in the Queue
private:
// ……
};

使用类模板时,必须为模板形参显式指定实参Queue<int> qi;编译器使用实参来实例化这个类的特定类型版本,即编译器用用户提供的实际特定类型(比如int)代替Type,重新编写Queue。

函数的递归

必须注意递归模型不能是循环定义的,其必须满足下面的两个条件:

  • 递归表达式(递归体)
  • 边界条件(递归出口)

递归的精髓在于能否将原始问题转换为属性相同但规模较小的问题。

在递归调用的过程中,系统为每一层的返回点、 局部变量、传入实参等开辟了递归工作栈来进行数据存储,递归次数过多容易造成栈溢出等。而其效率不高的原因是递归调用过程中包含很多重复的计算。

7. 指针与引用

指针

指针的声明

一个有效的指针必然是以下三种状态之一:

保存一个特定对象的地址;指向某个对象后面的另一对象;或者是0值。若指针保存0值,表明它不指向任何对象。未初始化的指针是无效的,直到给该指针赋值后,才可使用它。

特别的,对于指针变量:

*p++ 实现了先输出p所指地址处的数据值,然后指针后移到下一指针处;

*++p 实现了先将指针指向后移,再输出此时指针所指处的数据的值;

(*p)++ 实现的是将指针p所指向地址处的数据值(比如300)输出后再自增1(得到301);

typedef

C语言允许用typedef说明一种新类型名,来代替已有类型名,形式为:

1
typedef 类型名 标识符;

typedef并未产生新的数据类型,它的作用仅仅是给已存在的类型名起一个“别名”,且原有类型名依然有效。

例1: typedef char* String_t;#define string_d char \*这两句在使用上有什么区别?(2012●腾讯)

解答:前者声明一个类型的别名,在编译时处理,有类型检查;后者是-一个简单的替换,在预编译时处理,无类型检查。从使用上来说,String_t a, b;“中a和b都是char*类型的,但String _d a, b;中只有a是char*类型的,b是char型的。

void* 指针

void* 指针是一种特殊类型的指针,其可以保存任何类型对象的地址。

void*表明该指针与一地址有关,但是不清楚此地址上的对象的类型,故仅支持几种有限的操作:

  • 与另一个指针进行比较
  • 向函数传递void*指针或从函数返回void* 指针
  • 给另一个void*指针赋值。

不允许使用void*指针操纵它所指向的对象。

指向指针的指针

指针本身也是占用内存空间的存放其值的,所以也可用指针指向。

1
2
3
int ival=1024;
int *pi=&ival;
int **ppi=&pi; // 指向指针的指针

32位系统下,有如下代码:

1
2
3
4
5
6
7
8
9
int main() {
double* (*a)[3][6]; // (*a)[3][6]表示数组指针,a指向一个二维数组,而数组的元素是double*类型
cout<<sizeof(a)<<endl; // 4,指针占用4个字节
cout<<sizeof(*a)<<endl; // 72,二维数组有18个元素,每个元素(元素类型为指针)占4个字节
cout<<sizeof(**a)<<endl; // 24,*a为二维数组,*a[0]就是**a,即第一个元素,内容为一维数组,6*4得24
cout<<sizeof(***a)<<endl; // 4,**a为一个一维数组,**a[0]就是***a,类型为double*
cout<<sizeof(****a)<<end1; // 8,***a为double*类型,所以****a为取double*指针指向地址的内容,存储大小为double的大小,即8
return 0;
}

函数指针

函数指针指向某个特定的函数类型,函数类型由其返回类型以及形参决定。

1
2
// 函数指针变量的声明,类型为bool (*)(const string &, const string &)
bool (*pf)(const string &, const string &);

这个语句将pf声明为指向函数的指针,它所指向的函数带有两个const string& 类型的形参和bool类型的返回值。

==注意:*pf两侧的圆括号是必须的。且形参只需写类型名==

由于函数指针类型冗长,所以可以使用typedef简化函数指针的定义:

1
2
typedef bool (*cmpFcn)(const string &,const string &);
cmpFcn pf1=0; // 定义一个空的函数指针,使用前一行typedef定义的cmpFcn函数指针类型

在要使用这种函数指针类型时,只需直接使用cmpFcn即可,不必每次都把整个类型声明全部写出来。

在引用函数名但又没有调用该函数时,函数名将被自动解释为指向函数的指针,等效于在函数名上应用取地址符。可使用函数名对函数指针做初始化或赋值。

1
2
bool lengthCompare(const string &,const string &);   // 有一同返回类型以及形参的函数声明
cmpFcn pf2=lengthCompare; // 使用该函数名初始化函数指针

==注意:函数指针只能通过同类型的函数或函数指针或0值常量表达式进行初始化或赋值。==指向不同函数类型的指针之间不存在转换。将函数指针初始化为0,表示该指针不指向任何函数。

  • 函数指针的使用

    指向函数的指针可用于调用它所指向的函数。可以不需要使用解引用操作符,直接通过指针调用函数,若有:

    1
    2
    typedef bool (*cmpFcn)(const string &,const string &);
    bool lengthCompare(const string &,const string &);

    则:

    1
    2
    3
    4
    cmpFcn pf=lengthCompare;
    lengthCompare("hi","bye"); // 使用函数名
    pf("hi","bye"); // 使用函数指针,未使用*
    (*pf)("hi","bye"); // 使用函数指针,使用*
  • 函数指针形参
    函数的形参可以是指向函数的指针。这种形参可以用以下两种形式编写:
    void useBigger (const string &,const string &, bool (const string &,const string &));
    上述定义等价于:
    void useBigger (const string & const string &, bool (*) (const string &, const string&));

  • 返回指向函数的指针

    函数可以返回指向函数的指针:

    1
    int (*ff(int))(int*, int);   // 声明返回类型为函数指针的函数

    这个语句中,函数为ff(int),其返回值类型为int (*)(int*, int)的函数指针。这样子比较难理解,使用typedef更简明:

    1
    2
    typedef int (*PF)(int*, int);
    PF ff(int); // 返回类型为函数指针

    例1:

    用变量a给出下面的定义,一个有10个指针的数组,每个指针指向-一个函数,该函数有一个整型参数并返回一个整型( )。

    解答:

    int (*a[10]) (int)

    例2:

    定义一个函数指针,指向的函数有两个int形参并且返回-一个函数指针,返回的指针指向一个有一个int形参且返回int的函数。

    解答:

    int (*(*p)[10])(int *)。变量为*p,类型为int (*[10])(int *)

野指针

野指针是指向不可用内存的指针,任何指针变量在创建时,不会自动成为NULL指针(空指针),其默认值是随机的,此时的指针就是野指针。

当指针调用free或者delete释放后,未能将其设置为NULL,也会导致该指针便成为野指针,此时虽然free或delete把指针所指的内存释放掉了,但它们并没有把指针本身释放掉。

第三个造成野指针的原因是指针操作超越了变量的作用范围

引用

C++中规定一旦定义了引用,就必须把它跟一个变量绑定起来,并且不能修改这个绑定。

1
2
3
4
5
6
int i=3,j =1;
int &ref=i; // 定义i的引用ref
cout<<ref; //输出3
ref=j; //注意这里是将i修改为1,而不是修改ref使其绑定到j上
cout<<ref; //输出1
cout<<i; //输出1

虽然使用引用和指针都可以间接访问另一个值,但它们之间有几个重要区别:

  • 引用不能为空,当引用被创建时,必须被初始化。而指针可以为空值,可以在任何时候被初始化;
  • 一旦一个引用被初始化为指向一个对象,他就不能被改变为对另外一个对象的引用。指针则可以在任何时候指向另一个对象。
  • 不可能有NULL引用。必须保证引用是一块合法的存储单元关联;
  • “sizeof(引用)”所得到的的是指向的变量(对象)的大小,而“sizeof(指针)”得到的是指针本身的大小,通常为4;
  • 给引用赋值修改的是该引用所关联的对象的值,而并不是使用引用于另一个对象关联;
  • 引用使用时不需要解引用,而指针需要解引用,引用和指针的自增(++)操作运算符意义不一样;
  • 如果返回动态分派的对象或内存,必须使用指针,引用可能引起内存泄漏;
  • 当使用&运算符去一个引用的地址时,其值为所引用变量的地址;而对指针使用&运算符,取的是指针变量的地址。

const引用(常引用)

const引用是指const对象的引用,当引用的对象是const对象时,引用也必须是const,如下:

1
2
3
const int ival=1024;
const int &ref1=ival; //正确
int &ref2=ival; //错误

如果既要利用引用提高程序的效率,又要保护传递给函数的数据不再函数中被改变,就应该使用常引用。常引用主要用于定义一个普通变量的只读属性的别名,作为函数的传入形参,避免实参在调用函数中被意外改变。

引用做类的数据成员

引用是可以作为类的数据成员的。引用类型数据成员的初始化有以下特点:

  • 不能直接在构造函数里初始化,必须用到初始化列表
  • 凡是有引用类型的数据成员的类,必须定义构造函数

如下:

1
2
3
4
5
6
7
8
9
class ConstRef{
public:
//ci与ri必须在成员初始化列表中初始化,因此必须自定义构造函数,书写成员初始化列表
ConstRef(int ii):i(ii), ci(i), ri(ii){}
private:
int i;
const int ci;
int &ri;
};

8. 类

在C++中,模板是泛型编程的基础。模板是创建类或函数的蓝图或公式。

8.1 访问标号

访问标号public、 private、 protected 可以多次出现在类定义中。给定的访问标号应用到下一个访问标号出现时为止。

对于在第一个访问标号之前定义的成员,其访问级别依赖于类是如何定义的。如果类是用struct 关键字定义的,则在第一个访问标号
之前的成员是公有的;如果类是用class关键字定义的,则这些成员是私有的
。类对其成员的访问形式主要有以下两种:

  • 内部访问:由类中的成员函数对类的成员的访问。
  • 对象访问:在类外部,通过类的对象对类的成员的访问。

类的成员可以有public、protected、 private 三种访问属性,类的成员函数( 内部访问)以及友元函数可以访问类中所有成员,但是在类外通过类的对象(对象访问)就只能访问该类的公有成员上述权限说明并未考虑有继承的情况,有继承的情况将在下章详细说明。

8.2 类成员简介

空类默认产生默认构造函数、复制构造函数、析构函数、赋值运算符重载函数、取址运算符重载函数、const 取址运算符重载函数等。

成员函数

在类内部,声明成员函数是必需的,而定义成员函数则是可选的。在类内部定义的函数默认为inline(内联函数)

调用成员函数时,实际上是使用对象来调用的。每个成员函数(除了static 成员函数外)都有一个额外的、隐含的形参this。在调用成员函数时,形参this初始化为调用函数的对象的地址。

构造函数

特殊的成员函数,与类同名,没有返回类型。主要用来在创建对象时初始化对象, 即为对象成员变量赋初始值,总与new运算符一起使用在创建对象的语句中。一个类可以有多个构造函数(构造函数可以重载),每个构造函数必须有与其他构造函数不同的数目或类型的形参。

若没有定义显式的构造函数,编译器将自动为这个类生成默认构造函数(不带参数,或者所有的形参都有默认实参)。

若使用编译器自动生成的默认构造函数(或自己定义一个未进行任何操作的默认构造函数),则类中每个成员,使用与初始化变量相同的规则来进行初始化。

  • 类成员:运行该类型的默认构造函数来初始化。
  • 内置或复合类型的成员的初始值依赖于对象的作用域:在局部作用域中这些成员不被初始化,而在全局作用域中它们被初始化为0
1
2
3
4
5
6
7
8
9
10
11
12
13
class Student{
public:
Student(){}
void show();
private:
string name;
int number;
int score;
};
Student a;
int main(){
Student b;
}

上述代码中,a与b的name都调用string类的默认构造函数初始化(运行该类型的默认构造函数来初始化)。a中number和score初始化为0,而b是局部对象,故b中number和score不被初始化,为垃圾值。

成员初始化列表

构造函数的成员初始化列表为类的一个或多个数据成员指定初值。

在C++中,成员变量的初始化顺序与变量在类型中的声明顺序相同,而于它们在构造函数的初始化列表中的顺序无关。构造函数的初始化列表仅仅指定用于初始化成员的值,并不指定这些初始化执行的次序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class A{
private:
int i;
int j;
public:
A():j(0), i(j+2){} // 按照声明顺序初始化,先初始化i(此时j还未初始化,所以i是个垃圾值),后初始化j为0。
void print(){
cout<<i<<" "<<j<<endl;
}
};
int main(){
A a;
a.print();
return 0;
}

省略初始化列表在构造函数的函数体内对数据成员赋值是合法的。从概念上讲,可以认为构造函数分两个阶段执行:

  • 初始化阶段(成员初始化列表)
  • 普通的计算阶段。计算阶段由构造函数函数体中的所有语句组成。

类类型的数据成员总是在初始化阶段初始化(使用其构造函数),内置和复合类型的尘谷氨只对定义在全局作用域中的对象才初始化(初始化为0),定义在局部作用域中的对象包含包含的内置(int等类型)和复合类型(数组、指针等)的成员没有初始化。

没有默认构造函数的类类型的成员,以及const类型的成员变量和引用类型的成员变量,都必须在构造函数初始化列表中进行初始化。

假定有一个NoDefault类,它没有定义自己的默认构造函数,却有一个接受一个 string实参的构造函数。因为该类定义了一个构造函数,因此编译器将不合成默认构造函数。编译器将不会为具有NoDefault类型成员的类合成默认构造函数。如果这样的类希望提供默认构造函数,就必须显式地定义,并且默认构造函数必须显式地初始化其NoDefault成员(在成员初始化列表中通过传递一个初始的string值给NoDefault构造函数)。

拷贝构造函数

拷贝构造函数、赋值操作符和析构函数总称为复制控制。编译器自动实现这些操作,但类也可以定义自己的版本。

如果类需要析构函数,则它也需要赋值操作符和拷贝构造函数,这是一个有用的经验法则。这个规则常称为三法则,指的是如果需要析构函数,则需要所有这三个复制控制成员。有一种特别常见的情况需要类定义自己的复制控制成员的:类具有指针成员。

概念:只有单个形参,而且该形参是对本类类型对象的引用(常用const 修饰),这样的构造函数称为拷贝构造函数(或复制构造函数)。如果拷贝构造函数的形参不是引用,那么就相当于采用了传值的方式(pass-by-value),而传值的方式会调用该类的拷贝构造函数,从而造成无穷递归地调用拷贝构造函数。因此拷贝构造函数的参数必须是一个引用

与默认构造函数一样,拷贝构造函数可由编译器隐式调用。拷贝构造函数可用于:

  • 根据另一个同类型的对象初始化一个对象

    C++支持两种初始化形式:直接初始化和复制初始化。复制初始化使用=符号,而直接初始化将初始化式放在圆括号中。

    1
    2
    3
    4
    5
    6
    string null_book1("9-999-99999-9");    //直接初始化
    string null_book2 = null_book1; //复制初始化
    string null_book2(null_book1); //复制初始化
    string null_book3 = "9-999-99999-9"; //复制初始化,等号右侧相当于一个C风格字符串作为形参创建的string临时对象,会产生新的对象
    string null_book4;
    null_book4 = null_book3; //不是调用复制构造函数,而是利用赋值运算符将null_book3赋值给null_book4,因为之前已经创建了空字符串对象null_book4(属于赋值运算符重载,没有产生新的对象)
  • 复制一个对象,将它作为实参传给一个函数或从函数返回时复制一个对象

    当函数的形参或返回值为类类型时,将由拷贝构造函数进行复制。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    class Myclass{
    public:
    Myclass(int n){number = n;}
    Myclass(const Myclass &other){
    number=other.number;
    cout<<"a ";
    }
    private:
    int number;
    };
    void fun(Myclass p){ // 函数的形参为类类型时,将由复制构造函数进行复制
    Myclass temp(p); // 使用复制构造函数来初始化对象temp
    }
    int main(void){
    Myclass obj1(10), obj2(0);
    Myclass obj3(obj1); // 复制构造函数进行初始化
    fun(obj3);
    return 0;
    }

    上述代码的输出为a a a 。调用了三次拷贝构造函数,第一次是main中Myclass obj3(obj1); ,第二次是实参obj3到fun形参p,第三次是函数fun中的Myclass temp(p);语句。

  • 初始化顺序容器中的元素

    拷贝构造函数可用于初始化顺序容器中的元素。例如,可以用表示容量的单个形参来初始化容器。容器的这种构造方式使用默认构造函数和拷贝构造函数:vector<string> svec(5);编译器首先使用string 默认构造函数创建一个临时值来初始化 svec,然后使用拷贝构造函数将临时值复制到svec的每个元素。

  • 根据元素初始化列表初始化数组元素

    如果用常规的花括号括住的数组初始化列表来提供显式元素初始化式,则使用复制初始化来初始化每个元素。根据指定值创建适当类型的元素,然后用复制构造函数将该值复制到相应元素:

    1
    Sales_ item primer_ eds[] = {string ("0-201-16487-6"), string ("0-201-54848-8"), string ("0-201-82470-1")};
==浅复制与深复制==
  • 浅复制

    被复制对象的所有变量都含有与原来的对象相同的值,而变量中所有的对其他对象的引用仍然指向原来的对象。换言之,浅复制仅仅复制所考虑的对象,而不复制它所引用的对象。

  • 深复制

    被复制对象的所有变量都含有与原来的对象相同的值,除去那些引用其他对象的变量。那些引用其他对象的变量将指向被复制过的新对象,也就是把引用变量所引用的对象也复制一遍。

1
2
3
4
5
6
7
8
9
10
struct Test {
char *ptr;
};
void shallow_copy(Test & src, Test & dest) {
dest.ptr=src.ptr;
}
void deep_copy(Test & srC, Test & dest) {
dest.ptr=malloc(strlen(src.ptr) +1);
memcpy(dest.ptr, src.ptr);
}

浅复制可能会导致运行时错误,特别是在对象的创建与删除过程中。

析构函数

析构函数进行资源的回收,作为类构造函数的补充。当对象超出作用域或动态分配的对象被删除时,将自动应用析构函数。析构函数可用于释放对象构造时或在对象的生命期中所获取的资源。不管类是否定义了自己的析构函数,编译器都自动执行类中非static数据成员的析构函数。

虽然构造函数不能被定义成虚函数,但析构函数可以定义为虚函数,一般来说,如果类中定义了虛函数,析构函数也应被定义为虚析构函数,尤其是类内有申请的动态内存,需要清理和释放的时候。

与复制构造函数和赋值操作符不同,无论类是否定义了自己的析构函数,都会创建和运行合成析构函数。如果类定义了析构函数,则在类定义的析构函数结束之后运行合成析构函数。合成析构函数**按对象创建时的逆序撤销每个非static 成员,因此,它按成员在类中声明次序的逆序撤销成员**。对于类类型的每个成员,合成析构函数调用该成员的析构函数来撤销对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class A{
public:
A(){cout<<"A";};
~A(){cout<<"~A";}
};
class B{
public:
B(A &a):_a(a){ // _a(a)调用了拷贝构造函数,用对象a初始化对象_a,拷贝构造函数自动生成,无输出
cout<<"B";
};
~B(){cout<<"~B";}
private:
A _a;
};
int main(void){
A a;
B b(a);
return 0;
}

输出为AB~B~A~A。构造过程:A A B,那么析构过程为:B A A。注意之所以构造了两个A,是因为“a _(a)”调用了拷贝构造函数对B类对象中A初始化,而拷贝构造函数采用的是系统自动生成的版本,没有输出。

构造函数与析构函数调用顺序

  • 单继承

    派生时,构造函数和析构函数是不能继承的,为了对基类成员进行初始化,必须对派生类重新定义构造函数和析构函数,并在构造函数的初始化列表中调用基类的构造函数。由于派生类对象通过继承而包含了基类数据成员,因此,创建派生类对象时,系统==首先通过派生类的构造函数来调用基类的构造函数,完成基类成员的初始化,而后对派生类中新增的成员进行初始化==。

    **必须将基类的构造函数放在派生类的初始化列表中,以调用基类构造函数完成基类数据成员的初始化(若无,则调用基类的默认构造函数)**,派生类构造函数实现的功能,或者说调用顺序为:

    1. 完成对象所占整块内存的开辟,由系统在调用构造函数时自动完成。

    2. 调用基类的构造函数完成基类成员的初始化。

    3. 若派生类中含对象成员、const 成员或引用成员,则必须在初始化表中完成其初始化。

    4. 派生类构造函数体执行。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    class A{
    public:
    A(){cout<<"A";};
    ~A(){cout<<"~A";}
    };
    class B:public A{ // 类B继承自类A
    public:
    B(A &a):_a(a){ // _a(a)调用了拷贝构造函数,用对象a初始化对象_a,拷贝构造函数自动生成,无输出
    cout<<"B";
    };
    ~B(){cout<<"~B";}
    private:
    A _a;
    };
    int main(void){
    A a; // 语句1
    B b(a); // 语句2
    return 0;
    }

    输出为AAB~B~A~A~A。构造过程:A A A B,那么析构过程为:B A A A。

    首先语句1构造一个A的对象,输出A;

    然后语句2中,由于B有父类A,所以先调用父类A的构造函数,输出A。

    然后B的构造函数初始化列表“a _(a)”调用了拷贝构造函数构造一个A的对象,而拷贝构造函数采用的是系统自动生成的版本,没有输出。但是析构的时候会输出。

    最后执行B的构造函数,输出B。析构时与构造顺序相反。

  • 多继承

    多继承时,派生类的构造函数初始化列表需要调用各个基类的构造函数。

    注意:此时构造函数初始化列表只能控制用于初始化基类的值,不能控制基类的构造次序。基类构造函数按照基类构造函数在类派生列表中的出现次序调用

  • 虚继承

    首先调用虚基类的构造函数,虚基类如果有多个,则虚基类构造函数的调用顺序是此虚基类在当前类派生表中出现的顺序而不是它们在成员初始化表中的顺序。

操作符重载

操作符重载函数的名字为operator 后跟着所定义的操作符的符号。像任何其他函数一样,操作符重载函数有一个返回值和一个形参表形参表必须具有与该操作符数目相同的形参(如果操作符是一个类成员,则包括隐式this形参)。

大多数操作符可以定义为成员函数或非成员函数。当操作符为成员函数时,它的第一个操作数隐式绑定到this 指针。有些操作符(包括赋值操作符)必须是类的成员函数。比如赋值就必须是类的成员,所以this绑定到指向左操作数的指针。因此,赋值操作符接受单个形参,且该形参是同一类类型的对象。右操作数一般作为const引用传递。

并非所有操作符都是可重载的,下表给出可重载和不可重载的操作符。带“点”的都不能重载。

image-20220629152653268

赋值操作符重载

在写赋值操作符重载函数时需要注意:

  • 返回值类型为引用(允许连续赋值),形参为常量引用(避免调用拷贝构造函数,产生无谓的消耗)
  • 记得判断传入实例和当前实例*this是否为同一实例
  • 释放实例自身已有的内存,否则可能引起内存泄露

例子如下:

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
class CMyString{
public:
CMyString(const char* pData_NULL); // 自定义构造函数
CMyString (const CMyString& other); // 自定义拷贝构造函数
~CMyString(); // 自定义析构函数
CMyString& CMyString::operator=(const CMyString &str); // 赋值运算符的重载,函数名为operator=,形参为const CMyString &str,返回类型为CMyString&
private:
char* m pData;
};

String::String (const char *pData) {
if (pData == NULL) {
m_pData=new char[1] ;
*m_pData='\0';
else{
int length-strlen (pData);
m pData=new char [length+1];
strcpy(m_pData, pData) ;
}
}
// 自定义构造函数
CMyString::CMyString (const CMyString &other){
int iLen=strlen (other .m pData) ;
m pData=new char[iLen+1];
strcpy (m_pData, other.m_pData) ;
}
// 自定义拷贝构造函数
CMyString::~CMyString(){
delete []m_pData;
}
// 赋值运算符重载函数
CMyString& CMyString::operator=(const CMyString &str){ // 注意返回值类型为引用,形参为常量引用
if(this== &str) // 记得判断传入实例str和当前实例*this是否为同一实例
return *this;
delete []m_ pData; // 记得释放实例自身已有的内存,否则可能引起内存泄露
m_pData_NULL;
m_pData=new char[strlen(str.m_pData)+1];
strcpy(m_pData, str.m_pData) ;
return *this;
}

并不是出现“=”就是调用赋值构造函数,赋值运算符重载的情况没有新对象产生,而拷贝构造函数是生成新的对象

1
2
3
4
5
6
string null_book1("9-999-99999-9");    //直接初始化
string null_book2 = null_book1; //复制初始化
string null_book2(null_book1); //复制初始化
string null_book3 = "9-999-99999-9"; //复制初始化,等号右侧相当于一个C风格字符串作为形参创建的string临时对象(产生新的对象)
string null_book4;
null_book4 = null_book3; //不是调用复制构造函数,而是利用赋值运算符将null_book3赋值给null_book4,因为之前已经创建了空字符串对象null_book4(属于赋值运算符重载,没有产生新的对象)

==**复制构造函数与赋值运算符的区别:**是否有新对象产生==

首先要说明的是,若用户没有定义, C++隐式声明一个拷贝构造函数和一个赋值运算符。

  • 拷贝构造函数涉及对象实例化,只在对象实例化时才会被调用,也就是说,在复制构造函数调用期间,这个对象处于一个未决状态(直到复制构造函数被成功调用)。而赋值运算符对现存对象进行赋值操作。
  • 拷贝构造函数不返回任何值,void 都没有。而赋值运算符则在一个现存的对象被赋予新的值时被调用,并且它有返回值
operator new和operator delete的重载

new的执行过程是:首先,调用名为operator new的标准库函数,分配足够大的原始未类型化的内存,以保存指定类型的一个对象;接下来,运行该类型的一个构造函数,用指定初始化式构造对象;最后,返回指向新分配并构造的对象的指针

delete的执行过程是:首先,对sp指向的对象运行适当的析构函数;然后,通过调用名为operator delete的标准库函数释放该对象所用内存

new和delete运算符的重载,实际上是对标准库函数operator new和operator delete的重载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class X {
public:
X() {cout<<"constructor"<<endl; }
// 重载操作符operate new,第一个参数为分配的空间大小(字节),类型为size_t,返回类型必须为void*
static void* operator new(size_ t size) {
cout<<"new"<<endl;
return ::operator new(size); // 只分配所要求的空间,不调用相关对象的构造函数
}
static void operator delete (void* pointee) {
cout<<"delete"<<endl ;
::operator delete(pointee);
}
~X() {cout<<"destructor"<<endl; }
};
int main (void) {
X* px=new X(); // 调用operator new分配空间,然后再调用构造函数
delete px; // 先调用析构函数,再调用operator delete释放空间
return 0;
}

如何禁止产生堆对象:禁用new,也就是使operator new为private。同时为了对称,最好将operator delete也重载为private。

如何禁止产生栈对象:将构造函数或析构函数设为private。

8.3 成员函数的重载、覆盖与隐藏

成员函数的重载

在同一类中定义的同名函数。重载函数的形参类型和数目有所不同。重载和成员函数是否为虚函数无关

成员函数的覆盖

在派生类中覆盖基类中的同名函数要求基类函数必须是虚函数,且:

1)与基类的虚函数有相同的参数个数

2)与基类的虚函数有相同的参数类型

3)与基类的虚函数有相同的返回类型;或者都返回指针(或引用),并且派生类虚函数所返回的指针(或引用)类型是基类中被替换的虚函数所返回的指针(或引用)类型的子类型(派生类型)。

1
2
3
4
5
6
7
8
class A{
public:
virtual void fun1(int ,int){} // 虚函数
};
class B:public A{
public:
void fun1(int ,int){} // 具有相同的函数名、参数个数、参数类型、返回类型,覆盖了A中的fun1
};

覆盖的特征如下:

  • 不同的范围(分别位于派生类与基类);
  • 相同的函数名字;
  • 相同的参数;
  • 基类函数必须有vitural关键字。

重载与覆盖的区别如下:

  • 覆盖是子类和父类之间的关系,是垂直关系;重载是同一个类中不同方法之间的关系,是水平关系。
  • 覆盖要求参数列表相同,重载要求参数列表不同;覆盖要求返回类型相同,重载则不要求;
  • 覆盖关系中,调用方法体是根据对象的类型来决定的,重载关系是根据调用时的实参表与形参表来选择方法体的。

成员函数的隐藏

隐藏指的是在某些情况下,派生类中的函数屏蔽了基类中的同名函数,这些情况包括:

  • 两个函数参数相同,但基类函数不是虚函数。和覆盖的区别在于基类函数是否是虚函数。

    例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    class A{
    public:
    void fun(int xp){ //成员函数fun,非虚函数
    cout << xp << endl;
    }
    };
    class B : public A{ //类B由类A派生而来
    public:
    void fun(int xp){} // 参数相同,但是基类函数不是虚函数,所以隐藏父类的fun函数
    }

    B b;
    b.fun(2); // 调用B中的函数fun
    b.A::fun(2); // 调用A中的函数fun
  • 两个函数参数不同,无论基类函数是否是虚函数,基类函数都会被屏蔽。和重载的区别在于两个函数不在同一类中。

    例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    class A{
    public:
    virtual void fun(int xp){ //非虚成员函数fun,参数为int型
    cout << xp << endl;
    }
    };
    class B : public A{ //类B由类A派生而来
    public:
    void fun(cahr* xp){} // 参数不同,隐藏父类的fun函数
    }

    B b;
    b.fun(2); // 错误,参数类型错误
    b.A::fun(2); // 通过,调用A中的函数fun

9. 面向对象编程

9.1 继承

基类的构造函数(包括拷贝构造函数)、析构函数、赋值操作符重载函数,都不能被派生类继承。.

一个派生类可以从一个或多个基类派生(单继承、多继承)。

多继承的定义格式如下:

1
2
3
class <派生类>:<继承方式1> <基类名1>,<继承方式2> <基类名2>, ...{
<派生类新定义成员>
};

派生类对象由多个部分组成:派生类本身定义的(非static)成员加上由基类(非static)成员组成的子对象。

如果一个类有多个直接基类,而这些直接基类又有一个共同的基类,则在最低层的派生类中会保留这个间接的共同基类数据成员的多份同名成员。为了解决这个问题,提出了虚继承的概念。虚继承时,公共基类在对象模型中只有一份拷贝。

基类成员在派生类中的访问属性

派生类可以继承基类中除了构造函数与析构函数(赋值运算符重载函数也不能被继承)之外的成员,但是这些成员的访问属性在派生过程中是可以调整的。从基类继承来的成员在派生类中的访问属性是由继承方式控制的。

image-20220629211927705

  • 公有继承

    父类的public成员成为子类的public成员,可以被该子类中的函数(内部访问)及其友元函数访问,除此之外,也可以由该子类的对象(属于外部访问)访问

    父类的private成员仍旧是父类的private成员,子类成员不可以访问这些成员,包括子类中的函数及其友元函数、子类对象。

    父类的protected成员成为子类的protected成员,可以被该子类中的函数及其友元函数访问,除此之外,不可以由该子类的对象访问(不允许外部访问)

  • 私有继承

    私有继承的特点是基类的公有成员和保护成员都作为派生类的私有成员,并且不能被这个派生类的子类所访问。在私有继承时,基类的成员只能由直接派生类访问,而无法再往下继承。

  • 保护继承

    保护继承的特点是基类的所有公有成员和保护成员都成为派生类的保护成员,并且只能被它的派生类成员函数或友元访问,基类的私有成员仍然是私有的。

继承时导致的二义性

类间的转换

1) 在公有继承方式(私有、保护继承时,不能隐式转换)下,派生类的对象/对象指针/对象引用可以赋值给基类的对象/对象指针/对象引用(发生隐式转换)(上行转换),基类的对象/对象指针/对象引用不能赋值给派生类的对象/对象指针/对象引用。因为派生类包含了基类的所有信息,而基类缺乏派生类中的信息。如:

1
2
3
4
5
6
7
8
9
10
11
class A{};
class B: public A{};
A a;
B b;

a=b; //合法,派生类向基类隐式转换(向上转换)
b=a; //错误,基类向派生类转换,语句1
A* pa=&b; //合法,隐式转换,派生类指针转换为基类指针
B* pb=&a; //错误,语句2
A& ra=b; //合法,隐式转换
B& rb=a; //错误,语句3

2)C++允许把基类对象指针/引用强制转换(显式)成派生类的对象指针/引用(下行转换),如1)中代码,语句2可以改为:

1
B* pb=(B*)&a;

语句3可以改为:

1
B& rb=(B&)a;

但是语句1不能通过强制转换完成。

3)一个指向基类的指针可以用来指向该基类公有派生类的任何对象,这是C++实现程序运行时的多态性的关键。

若存在多重继承,由于对象在往上转换期间(派生类转换为基类)出现多个类,因而对象会存在多个this指针

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
class base1{
char c[16];
public:
void printthis1(){
cout<<"base1 this="<<this<<endl; // this为本类对象的地址
}
};
class base2{
char c[16];
public:
void printthis2(){
cout<<"base2 this="<<this<<endl;
}
};
class member1{
char c[16];
public:
void printthism1(){
cout<<"member1 this="<<this<<endl;
}
};
class member2{
char c[16];
public:
void printthism2(){
cout<<"member2 this="<<this<<endl;
}
};
class mi:public base1, public base2{
member1 m1;
member2 m2;
public:
void printthis(){
cout<<"m1 this="<<this<<endl;
printthis1(); //调用继承自base1的printthis1函数
printthis2(); //调用继承自base2的printthis2函数
m1.printthism1();
m2.printthism2();
}
};
int main(){
mi MI;
cout<<"sizeof(mi)="<<sizeof(mi)<<endl;
MI.printthis();
base1* b1=&MI; // 派生对象的指针赋值给基类指针(发生隐式转换)
base2* b2=&MI;
cout<<"base 1 pointer="<<b1<<endl;
cout<<"base 2 pointer="<<b2<<endl;
}

输出为:

1
2
3
4
5
6
7
8
sizeof(mi)=64
m1 this=0031FCB0
base1 this=0031FCB0
base2 this=0031FCC0
member1 this=0031FCD0
member2 this=0031FCE0
base 1 pointer=0031FCB0
base 2 pointer=0031FCC0

每一个类都有打印一个this指针函数,这些类通过多重继承和组合被装配成类mi,它打印自己和其他所有子对象的地址,有主程序调用这些打印功能。可以清楚地看到,能在一个相同的对象中获得两个不同的this指针
  派生对象MI的起始地址和它的基类列表中的第一个类(base1)的地址是一致的,第二个类base2的地址随后,接着根据声明的次序安排成员对象(member1、member2的地址)。当向base1和base2进行上行转换时(语句base1* b1=&MI;和语句base2* b2=&MI;),产生的指针表面上是指向同一个对象MI,而实际上有不同的this指针,b1指向base1类的子对象,b2指向base2类的子对象。

派生对象MI的地址空间:

image-20220630111042948

在上述代码中加入如下语句:

1
2
3
mi *b3= &MI;
if(b1 == b3) cout<<"b1==b3"<<endl ;
if(b2 == b3) cout<<"b2==b3";

实际上,b1 与b3的比较过程中,由于两者类型不同,会发生隐式类型转换,b3 (mi*类型)会被隐式转换为basel* (派生类被隐式转换为基类,这是b1能与b3比较的基础,反过来转换不成立),然后与b1进行比较;同理,b2与b3的比较过程中,b3会被转换为base2*,然后与b2进行比较,故实际输出为:

1
2
b1==b3
b2==b3
多基继承

一般来说, 在派生类中对基类成员的访问应当具有唯一性, 但在多基继承时,如果多个基类中存在同名成员的情况,造成编译器无从判断具体要访问哪个基类中的成员,则称为对基类成员访问的:二义性问题。

若两个基类中具有同名的数据成员或成员函数,应使用成员名限定来消除二义性。比如A::print()。或者实现对基类同名成员函数的隐藏(见8.3节)。

菱形继承
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class A{  //公共基类
public:
void print() {
cout << "this is x inA:”<< endl;
}
};
class B: public A{}; //类B由类A派生而来
class C: public A{}; //类C由类A派生而来
class D : public B, public C{}; //类 D由类B和类C派生而来
void main() {
D d; //声明一个D类对象d,其含有2个基类对象A,一个基类对象B,一个基类对象C
A* pa=(A*) &d; //上行转换产生二义性,语句1
d.print(); //print()具有二义性,系统不知道是调用B类的还是C类的print()函数,语句2
}

上述代码的语句2d.print();编译错误,可改为以下的其中一种:

1
2
d.B.print();
d.C.print();

但是不能改为d.A.print();,因为d对象中有2个A类对象,故编译会报“基类A不明确”。

而语句2A* pa=(A*) &d;产生的二义性也是因为d对象中有2个A类对象,转换时不知道让pa指向哪个子对象,可以改为以下的一种:

1
2
A* pa=(A*) (B*)&d;
A* pa=(A*) (C*)&d;

事实上,使用关键字virtual将共同基类A声明为虚基类,可有效解决上述二义性的问题。

转换构造函数

转换构造函数可以用单个实参来调用,其定义从形参类型到该类类型的一个隐式转换

1
2
3
4
5
6
7
class Integral {
public:
Integral(int=0);//转换构造函数
private :
int real;
};
Integral integ=1; //调用转换构造函数将1转换为Integral类的对象

转换构造函数需满足以下条件之一:

  • Integral类的定义和实现中给出了仅包括只有一个int类型参数的构造函数
  • Integral 类的定义和实现中给出了包含一个int类型参数,且其他参数都有缺省值的构造函数
  • Integral 类的定义和实现中虽然不包含int 类型参数,但包含一个非 int类型参数如float类型,此外没有其他参数或者其他参数都有缺省值,且int类型参数可隐式转换为float类型参数。

可以通过将构造函数声明为explicit, 来禁止隐式转换。

类型转换函数

类型转换函数的作用是将一个类的对象转换成另一类型的数据,与转换构造函数作用相反。在类中,定义类型转换函数的一般格式为:

1
2
3
4
5
6
7
8
9
10
class Integral {
public:
Integral(int=0); //转换构造函数
operator int(){ //类型转换函数,函数名为operator int,指明转换的目标类型为int
return real;
}
private:
int real;
Integral integ=1; //调用转换构造函数将int型的1转换为Integral类的对象
int i=integ; //调用类型转换函数将integ转换为int类型

定义类型转换函数,需要注意以下几点:

  • 转换函数必须是成员函数,不能是友元形式;
  • 转换函数不能指定返回类型,但在函数体内必须用return语句以传值方式返回一个目标类型的变量
  • 转换函数不能有参数。

非C++内建型别A和B,在以下几种情况下B能隐式转化为A

  • B公有继承自A,可以是间接继承的。

    1
    2
    class B:public A{
    };

    此时若有A a; B b;, 则a=b;合法。

  • B中有类型转换函数。

    1
    2
    3
    class B{
    operator A(); // 类型转换函数,将B类对象强制转换为A类类型
    };

    此时若有A a; B b;, 则a=b;合法。

  • A实现了非explicit的参数为B (可以有其他带默认值的参数)的构造函数

    1
    2
    3
    class A{
    A(const B&); // 转换构造函数
    };

    此时若有A a; B b;, 则a=b;合法。

9.2 虚函数多态

通俗地说,多态性是指同一个操作作用于不同的对象就会产生不同的响应

多态性分为静态多态性动态多态性

  • 静态多态性:函数重载和运算符重载
  • 动态多态性:虛函数

静态联编与动态联编

以函数重载为例,C++编译器根据传递给函数的参数和函数名决定具体要使用哪一个函数,称为联编或绑定(binding)。

编译器可以在编译过程中完成这种联编,在编译过程中进行的联编叫静态联编(static binding)或早期联编(early binding)。
在一些场合下,编译器无法在编译过程中完成联编,必须在程序运行时完成选择,因此编译器必须提供一套称为“动态联编”(dynamic binding)的机制,也叫晚期联编(late binding),C++通过虚函数来实现动态联编

如果一个基类的成员函数定义为虚函数,那么,它在所有派生类中也保持为虚函数;即使在派生类中省略了virtual 关键字,也仍然是虚函数。

派生类中可根据需要对虚函数进行重定义,重定义的格式有一定的要求:

  • 与基类的虚函数有相同的参数个数
  • 与基类的虚函数有相同的参数类型
  • 与基类的虚函数有相同的返回类型;或者都返回指针(或引用),并且派生类虚函数所返回的指针(或引用)类型是基类中被替换的虚函数所返回的指针(或引用)类型的子类型(派生类型)。
虚函数的访问

虚函数可以通过对象名来调用,此时编译器采用的是静态联编。通过对象名访问虚函数时,调用哪个类的函数取决于定义对象名的类型。

  • ==使用指针访问非虚函数时,编译器根据指针本身的类型决定要调用哪个函数==,而不是根据指针指向的对象类型;
  • ==使用指针访问虚函数时,编译器根据指针所指对象的类型决定要调用哪个函数(动态联编)==,而与指针本身的类型无关。

因此这里虚函数的作用就是使得指向基类的指针在操作它的多态类对象时,是根据不同的类对象来调用相应的函数,而不是调用基类的函数。

使用引用访问虚函数,与使用指针访问虚函数类似,不同的是,引用一经声明后,引用变量本身无论如何改变,其调用的函数就不会再改变,始终指向其开始定义时的函数。因此在使用上有一定限制,但这在一定程度上提高了代码的安全性。

总结如下,C++中的函数调用默认不使用动态绑定。要触发动态绑定,需满足两个条件:

第一,只有指定为虚函数的成员函数才能进行动态绑定,成员函数默认为非虚函数,非虚函数不进行动态绑定;

第二,必须通过基类类型的引用或指针进行函数调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class base{
public:
virtual void disp() {cout << "hel1o, base1"<< endl; } // 虚函数
void disp2() {cout << "hello, base2" << endl; } // 默认为非虚函数
};
class childl :public base {
public:
void disp() {cout << "hello, child1" << endl; } // 覆盖基类的虚函数disp
void disp2 () {cout << "hello, child2" << endl; }
};
void main() {
base * base=NULL;
childl obj_child1;
base = &obj_childl; // 派生类地址给基类指针赋值,发生隐式转换
base->disp(); // 通过指针访问虚函数,根据指针所指对象的类型决定调用的函数,base指向childl类类型
base->disp2() ; // 通过指针访问非虚函数,根据指针本身的类型决定调用的函数,base为base类类型

上述代码的输出为:

1
2
hello, child1
hello, base2

常见的不能声明为虚函数的有

普通函数(非成员函数)、静态成员函数、构造函数、友元函数,而内联成员函数、赋值操作符重载函数即使声明为虚函数也无意义

析构函数可以被声明为虚函数,因为销毁对象时需要识别对象类型。

  • 构造函数不能为虚函数:

    若基类的构造函数为虚函数,那么派生类的构造函数会覆盖基类的构造函数,使得基类无法构造。且虚函数旨在在不同类型的对象上产生不同动作,而构造函数运行时对象还未产生。

  • 普通函数不能为虚函数:

    普通函数只能被重载,不能被覆盖,声明为虚函数没有意义。

  • 静态函数不能为虚函数:

    静态函数属于类,而不是对象,所以没有动态绑定的需要。

  • 友元函数不能为虚函数:

    C++不支持友元函数的继承

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class A{
public:
virtual void Fun (int number=10) {
std: :cout << "A::Fun with number”<< number<<endl;
};
class B: public A{
public:
virtual void Fun (int number=20) {
std::cout << "B::Fun with number ”<< number<<endl ;
};
int main() {
B b;
A &a=b;
a.Fun();
return 0;
}

上述代码输出为B::Fun with number 10 。由于A中的Fun函数为虚函数,所以a.Fun()会动态联编到a所引用的对象b的Fun函数上,因此输出B::Fun with number 10。之所以number变量为10,是因为缺省实参是编译时确定的,在动态联编之前。

构造函数和析构函数中的虚函数

构造派生类对象时,首先运行基类构造函数初始化对象的基类部分。在执行基类构造函数时,对象的派生类部分是未初始化的。实际上,此时对象还不是一个派生类对象。

撤销派生类对象时,首先撤销它的派生类部分,然后按照与构造顺序的逆序撤销它的基类部分。

在这两种情况下,运行构造函数或析构函数时,对象都是不完整的。为了适应这种不完整,编译器将对象的类型视为在构造或析构期间发生了变化。在基类构造函数或析构函数中,将派生类对象当作基类类型对象对待。

如果在构造函数或析构函数中调用虚函数,则运行的是为构造函数或析构函数自身类型定义的版本(静态联编)。

image-20220701105151776

解析:构造函数不需要是虚函数,也不允许是虚函数,因为创建一个对象时我们总是要明确指定对象的类型,尽管我们可能通过实验室的基类的指针或引用去访问它但析构却不一定,我们往往通过基类的指针来销毁对象。这时候如果析构函数不是虚函数,就不能正确识别对象类型从而不能正确调用析构函数。

虚函数表指针(vptr)及虚基类表指针(bptr)

C+ +在布局以及存取时间上主要的额外负担是由virtual引起的,包括:

virtual function 机制:用以支持-一个有效率的“执行期绑定”;

virtual base class:用以实现多次出现在继承体系中的基类,有一个单一而被共享的实体。

虚函数表指针

C++中数据成员可以分为静态和非静态,以及三种类成员函数:静态、非静态和虚函数。

其中,非static数据成员被配置于每一个对象之内,static 数据成员则被存放在所有的对象之外,通常被放置在程序的全局(静态)存储区内,故不会影响个别的对象大小。static 和非static函数也被放在所有的对象之外。virtual 函数则以两个步骤支持之:

  1. 每一个类产生出一堆指向virtual functions的指针,放在表格之中,这个表格被称为virtual table(vtbl);

  2. 每一个对象被添加了一个指针,指向相关的vitual table。通常这个指针被称为**vptr (虚函数表指针)**。vptr 的设定和重置都由每一个类的构造函数、析构函数和复制构造函数自动完成。(每个虚函数的存在会为类的内存空间增加一个虚函数表指针)

含静态变量、虚函数的类的空间计算

sizeof应用在类和结构的处理情况是相同的。但需要注意结构或者类中的静态成员不对结构或者类的大小产生影响,因为静态变量的存储位置与单个对象的地址无关。

空类的大小为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
class A{};    // 空类占1个字节
class B{ // 有一个虚函数,因此B中有指针vptr,占4个字节
public:
B() ;
virtual ~B() ;
};
class C{ // 大小为4、4(2对其为4)、4、64、4、4的和,为84
private:
#pragma pack(4) // 设置编译器按照4个字节对齐
int i;
short j;
float k;
char 1[64] ;
long m;
char *p;
#pragma pack()
};
class D{ // 大小为4、2、4、64、4、4的和,为82
private:
#pragma pack(1) // 设置编译器按照1个字节对齐
int i;
short j;
float k;
char 1 [64];
long m;
char *p;
#pragma pack()
};

上述代码中,各类的sizeof结果为1、4、84、82。

虚函数表的实现

使用指针访问虚函数时,编译器根据指针所指对象的类型决定要调用哪个函数(动态联编)。比如有基类A及其派生类B,基类A中有一个虚函数fun(),派生类也有一个函数fun()进行覆盖。若有一B类对象b,对于语句A* a=&b; a->fun();,其运行时会发生动态联编,调用的fun()为指针所指的B类对象b的函数,而不是A类。

但是此过程中,父类指针a是如何根据虚函数表找到子类B的虚函数的?

首先父类指针a所指空间为对象b,其中存在虚函数表指针vptr,通过其可以找到对象b的虚函数表,进而找到类B的函数fun()。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class A{
public:
virtual void a() {cout << "a() in A"<< endl; }
virtual void b() {cout << "b() in A" << endl; }
virtual void c() {cout << "c() in A" << endl; }
virtual void d() {cout << "d() in A" << endl; }
};
class B : public A{
public:
void a() {cout << "a() in B" << endl; }
void b() {cout<< "b() in B" << endl; }
};
class C : public A{
public:
void a() {cout << "a() in C" << endl; }
void b() {cout << "b() in C" << endl; }
};
class D : public B, public C{
public:
void a() {cout << "a() in D" << endl; }
void d() {cout << "d() in D" << endl; }
};

上述代码中,类A的对象的虚函数表如下,每个各自记录一个函数的地址

image-20220701150726541

由于B、C继承自A,所以其定义的a()、b()也为虚函数。类B、C的对象的虚函数表如下:

image-20220701150850252

image-20220701150927278

可见单基继承时,仅有一个vptr。派生类的函数覆盖了基类的同名函数,虚函数表中相应位置也替换为了新函数的地址。通过对象的虚函数表指针vptr就可以找到所属类的函数了。

类D的对象的虚函数表如下:

image-20220701151349316

可见,多基继承时,有几个基类就有几个vptr。D类中的函数a与d覆盖了B类中的同名函数,故虚函数表中对应位置替换为新函数的地址。D类中的函数a与d覆盖了C类中的同名函数,故虚函数表中对应位置替换为新函数的地址。

虚基类表指针

继承也可以指定为虚拟(virtual)。在虚拟继承的情况下,基类不管在继承串链中被派生多少次,永远只会存在一个实体。

在虚拟继承基类的子类中,子类会增加某种形式的指针,或者指向虚基类子对象,或者指向一个相关的表格;表格中存放的不是虚基类子对象的地址,就是其偏移量。此指针被称为bptr,如下图所示。

image-20220701204518663

注意:在同时存在vptr与bptr时,某些编译器会将其进行优化,合并为一个指针。

1
2
3
4
5
6
7
8
9
10
class X{};                    // 空类,占1个字节
class Y:public virtual X{}; // 虚拟继承,需要虚基表指针bptr的空间,4个字节,继承自X的1字节被优化为0
class Z:public virtual X{};
class A:public Y, public Z{}; // 继承Y,占4个字节;继承Z,占4个字节;总共占8个字节
int main() {
cout << "sizeof(X) : "<< sizeof(X) << endl;
cout.<< "sizeof(Y) : "<< sizeof(Y) << endl;
cout << "sizeof(Z) : "<< sizeof(Z) << endl;
cout << "sizeof(A) : "<< sizeof(A) << endl;
}

虚拟继承时构造函数的书写

对普通的多层继承而言,构造函数的调用是嵌套的,如由C1类派生C2类,C2 类又派生C3类时,则各个构造函数有如下形式:

1
2
C2 (总参数表) :C1 (参数表)
C3 (总参数表) :C2 (参数表)

而对虚基派生来说,如果按照上述规则,若A类虚拟派生B类、C类,D类继承B类、C类,则各个构造函数有如下形式:

1
2
3
B(总参数表) :A(参数表)
C(总参数表) :A(参数表)
D(总参数表) :B(参数表),C(参数表),A(参数表)

​ 根据虚基派生的性质,类D中只有一份虚基类A的拷贝,因此A类的构造函数在D类中只能被调用一次。所以,从A类直接虚拟派生(B和C)和间接派生(D)的类中,其构造函数的初始化列表中都要列出对虚基类A构造函数的调用。这种机制保证了不管有多少层继承,虚基类的构造函数必须且只能被调用一次

若在初始化列表中没有显式调用虚基类的构造函数,则将调用虚基类的默认构造函数,若虚基类没有定义默认构造函数,则编译出错。

纯虚函数

纯虚函数是一一种特殊的虚函数,它的一般格式如下:

1
2
3
class <类名>{
virtual <类型> <函数名> (<参数表>)=0;
};

在许多情况下,在基类中不对虚函数给出有意义的实现,而把它声明为纯虚函数,它的实现留给该基类的派生类去做。这就是纯虚函数的作用。纯虚函数可以让类先具有一个操作名称,而没有操作内容,让派生类在继承时再去具体地给出定义。**凡是含有纯虚函数的类称为抽象类**。这种类不能声明对象,只是作为基类为派生类服务。除非在派生类中完全实现基类中所有的纯虚函数,否则,派生类也是抽象类,不能实例化对象。

只定义了protected 型构造函数的类也是抽象类。对一个类来说,如果只定义了protected 型的构造函数而没有提供public构造函数,无论是在外部还是在派生类中都不能创建该类的对象,但可以由其派生出新的类,这种能派生新类,却不能创建自己对象的类是另一种形式的抽象类。

抽象类不能声明对象,但是可以作为指针或者引用类型使用。

9.3 动态运行时类型识别与显式转换

typeid

通过运行时类型识别(RTTI),程序能够使用基类的指针或引用来检索这些指针或引用所指对象的实际类型。

C++通过下面两个操作符提供RTTI:

  • typeid 操作符,返回指针或引用所指对象的实际类型操作对象为指针所指对象。

  • dynamic_ cast 操作符,将基类类型的指针或引用安全地转换为派生类型的指针或引用。

typeid 操作符使程序能够询问一个表达式的类型。

typeid表达式形如: typeid(e)。 这里e是任意表达式或者是类型名。用法如下所示:

1
2
3
4
5
Base *bp;
Derived *dp;
if (typeid(*bp) == typeid(*dp)) {…} // 比较bp所指对象与dp所指对象的实际类型
if (typeid(*bp) == typeid(Derived)) {…} // 判断bp所指对象是否为Derived
if (typeid(bp) == typeid(Derived)) {…} // 比较Base *类型和Derived类型,两类型不相等,测试失败

只有当typeid的操作数是带虚函数的类类型的对象的时,才返回动态类型信息。测试指针(相对于指针指向的对象)返回指针的静态的、编译时类型。

显示转换

也叫强制类型转换,包括以下强制类型转换操作符:static_cast、dynamic_cast、const_cast、reinterpret_cast。

命名的强制类型转换符号的一-般形式如下:

1
cast-name<type> (expression) ;

其中,cast-name 为static_ cast、 dynamic_ cast、 const_ cast 和reinterpret cast 之一, type 为转换的目标类型,而expression则是被强制转换的表达式。强制转换的类型指定了在expression上执行某种特定类型的转换。

reinterpret_cast

在引入命名的强制类型转换操作符之前,显式强制转换用圆括号将类型括起来实现:

1
2
int *ip;
char *pc = (char*) ip;

效果与使用reinterpret cast符号相同。

1
2
int ip;
char *pc = reinterpret_cast<char*>(ip) ;
const_cast

将转换掉表达式的const性质。

1
2
const char *ip;
char *pc = const_cast<char*>(ip);

只有使用const_ cast 才能将const性质转换掉。在这种情况下,试图使用其他三种形式的强制转换都会导致编译时的错误。类似地,除了添加或删除const特性,用const _cast 符来执行其他任何类型转换,都会引起编译错误。

static_cast

编译器隐式执行的任何类型转换都可以由static_ cast显式完成:

1
2
3
4
5
double d=97.0;
int i = static cast<int>(d) ;
// 等价于:
double d=97.0;
int i=d;

仅当类型之间可隐式转换时(除类层次间的下行转换以外),static cast 的转换才是合法的,否则将出错。
类层次间的下行转换属于强制转换,是不能通过隐式转换完成的,请看下例。

1
2
3
4
5
6
class base{};
class child:public base{};
base* b;
child* c;
c = static cast<child*>(b);// 下行转换,错误
c = b; // 基类对象不能给派生类赋值,编译不正确
dynamic_cast

​ 该运算符把expression转换成type类型的对象。type 必须是类的指针、类的引用或者void*。如果type是指针类型,那么expression也必须是一个指针, 如果type是一个引用,那么expression也必须是一个引用。
​ 与其他强制类型转换不同,dynamic_ cast 涉及运行时类型检查。dynamic_ cast 运行时的类型检查需要运行时的类型信息,而这个类型信息存储在类的虚函数表中,只有定义了虚函数的类才有虚函数表,没有定义虚函数的类是没有虚函数表的,对没有虚函数表的类使用会导致dynamic_ cast 编译错误
​ 如果绑定到引用或指针的对象的类型不是目标类型,则dynamic_cast 失败。如果转换到指针类型的dynamic_cast 失败,则dynamic_cast 的结果是0值;如果转换到引用类型的dynamic_cast失败,则抛出一个bad_cast 类型的异常。
​ 因此,dynamic_cast 操作符一次执行两个操作。 它首先验证被请求的转换是否有效,只有转换有效,然后操作符才实际进行转换。一般而言,引用或指针所绑定的对象的类型在编译时是未知的,基类的指针可以赋值为指向派生类对象,同样,基类的引用也可以用派生类对象初始化,因此,dynamic_cast操作符执行的验证必须在运行时进行。

1
2
3
4
5
6
7
8
9
10
class A{
public:
A() {}
};
class B:public A{
public:
B() {}
};
A *pb=new B(); // 定义A*类型的指针,指向B类类型的对象
B b;

针对上述代码,下列语句的执行情况如何:

1
2
3
4
5
A *pa = dynamic_cast<A *>(pb); // 通过。pb本来就是A*类型,实际上不需要转换类型
A *pa = dynamic_cast<B *>(pb); // 编译错误。运行时dynamic cast的操作数必须包含多态类类型,而B类没有虚函数
A *pa = static_ cast<A *>(pb); // 通过。pb本来就是A*类型,实际上不需要转换类型
A a = static_ cast<A >(b); // 通过。派生类对象赋值给基类对象,发生隐式转换
A a = dynamic cast<A >(b); // 编译错误。用dynamic_cast进行转换时,待转换的类型只能是指针或引用

dynamic_cast 主要用于类层次间的上行转换和下行转换。dynamic_cast 运算符可以在执行期决定真正的类型。如果下行转换是安全的(也就说,如果基类指针或者引用确实指向一个派生类对象),这个运算符会传回转型过的指针。如果downcast不安全,这个运算符会传回空指针(也就是说,基类指针或者引用没有指向一个派生类对象)。

在类层次间进行上行转换时,dynamic cast 和static cast 的效果是一样的;在进行下行转换时,dynamic_ cast 具有类型检查的功能,比static_ cast 更安全。