面试基础知识(1)—— C++面试基础考点
本文主要介绍C++面试中的基本考点!
基础语法
在main执行之前和之后执行的代码可能是什么?
main函数执行之前,主要就是初始化系统相关资源:
- 设置栈指针
- 初始化静态
static
变量和global
全局变量,即.data
段的内容 - 将未初始化部分的全局变量赋初值:数值型
short
,int
,long
等为0
,bool
为FALSE
,指针为NULL
等等,即.bss
段的内容 - 全局对象初始化,在
main
之前调用构造函数,这是可能会执行前的一些代码 - 将main函数的参数
argc
,argv
等传递给main
函数,然后才真正运行main
函数 __attribute__((constructor))
main函数执行之后:
- 全局对象的析构函数会在main函数之后执行;
- 可以用
atexit
注册一个函数,它会在main 之后执行; __attribute__((destructor))
指针和引用的区别
- 指针是一个变量,存放另一个变量的地址,我们可以通过访问这个地址来修改另一个变量;而引用是一个别名,跟原来的变量实质上是一个东西,对引用的操作就是对变量的本身进行操作
- 指针可以有多级,引用只有一级
- 指针可以为空,引用不可以为NULL且在定义时必须初始化。
- 指针在初始化后可以改变指向,而引用在初始化后不可再改变
- 当把指针作为参数进行传递时,也是将实参的一个拷贝传递给形参,两者指向的地址相同,但不是同一个变量,在函数中改变这个变量的指向不影响实参,而引用却可以直接对参数进行修改并影响实参。
- 指针的大小一般是4个字节,引用的大小取决于被引用对象的大小
- 不存在指向空值的引用,必须有具体实体;但是存在指向空值的指针
在函数参数传递的时候,什么时候使用指针,什么时候使用引用?
- 需要返回函数内局部变量的内存的时候用指针。使用指针传参需要开辟内存,用完要记得释放指针,不然会内存泄漏。而返回局部变量的引用是没有意义的
- 对栈空间大小比较敏感(比如递归)的时候使用引用。使用引用传递不需要创建临时变量,开销要更小
- 类对象作为参数传递的时候使用引用,这是C++类对象传递的标准方式
new/delete和malloc/free异同
类似问题:既然有了malloc/free,C++中为什么还需要new/delete呢?直接用malloc/free不好吗?
new和delete是如何实现的?
相同点: 都可用于内存的动态申请和释放。
不同点:
前者是C++运算符,后者是C/C++语言标准库函数,需要库文件支持。
new自动计算要分配的空间大小,malloc需要显示指出所需内存大小。
new是类型安全的,malloc不是。例如:
int *p = new float[2]; //编译错误 int *p = (int*)malloc(2 * sizeof(double));//编译无错误
new在底层调用operator new全局函数(实际上通过malloc)来申请空间并调用对象的构造函数进行初始化,最后返回指向新分配并构造后的对象的指针;delete会调用对象的析构函数,并在底层调用operator delete全局函数(实际上通过free)来释放对象所占用内存。后者没有相关调用,malloc/free是库函数,是已经编译的代码,所以不能把构造函数和析构函数的功能强加给malloc/free。
malloc和free返回的是void类型指针(必须进行类型转换),new和delete返回的是具体类型指针。
new内存分配失败时,会抛出bac_alloc异常。malloc分配内存失败时返回NULL。
malloc和free是标准库函数,支持覆盖;new和delete是运算符,不重载。
Noting:delete是如何知道释放内存的大小的?
需要在 new [] 一个对象数组时,需要保存数组的维度,C++ 的做法是在分配数组空间时多分配了 4 个字节的大小,专门保存数组的大小,在 delete [] 时就可以取出这个保存的数,就知道了需要调用析构函数多少次了。
malloc和free的实现原理
1、 在标准C库中,提供了malloc/free函数分配释放内存,这两个函数底层是由brk、mmap、munmap这些系统调用实现的;
2、 brk是将数据段(.data)的最高地址指针_edata往高地址推,mmap是在进程的虚拟地址空间中(堆和栈中间,称为文件映射区域的地方)找一块空闲的虚拟内存。这两种方式分配的都是虚拟内存,没有分配物理内存。在第一次访问已分配的虚拟地址空间的时候,发生缺页中断,操作系统负责分配物理内存,然后建立虚拟内存和物理内存之间的映射关系;
3、 malloc小于128k的内存,使用brk分配内存,将_edata往高地址推;malloc大于128k的内存,使用mmap分配内存,在堆和栈之间找一块空闲内存分配;brk分配的内存需要等到高地址内存释放以后才能释放,而mmap分配的内存可以单独释放。当最高地址空间的空闲内存超过128K(可由M_TRIM_THRESHOLD选项调节)时,执行内存紧缩操作(trim)。在上一个步骤free的时候,发现最高地址空闲内存超过128K,于是内存紧缩。
4、 malloc是从堆里面申请内存,也就是说函数返回的指针是指向堆里面的一块内存。操作系统中有一个记录空闲内存地址的链表。当操作系统收到程序的申请时,就会遍历该链表,然后就寻找第一个空间大于所申请空间的堆结点,然后就将该结点从空闲结点链表中删除,并将该结点的空间分配给程序。
被free回收的内存是立即返还给操作系统吗?
不是的,被free回收的内存会首先被ptmalloc使用双链表保存起来,当用户下一次申请内存的时候,会尝试从这些内存中寻找合适的返回。这样就避免了频繁的系统调用,占用过多的系统资源。同时ptmalloc也会尝试对小块内存进行合并,避免过多的内存碎片。
malloc申请的存储空间能用delete释放吗
不能,malloc /free的操作对象都是必须明确大小的,而new 和delete会自动进行类型检查和大小,并且malloc/free不能执行构造函数与析构函数,所以动态对象它是不行的。
malloc、realloc和calloc的区别
1)malloc函数
void* malloc(unsigned int num_size);
int *p = malloc(20*sizeof(int));申请20个int类型的空间;
2)calloc函数
void* calloc(size_t n,size_t size);
int *p = calloc(20, sizeof(int));
省去了人为空间计算;malloc申请的空间的值是随机初始化的,calloc申请的空间的值是初始化为0的;
3)realloc函数
void realloc(void *p, size_t new_size);
给动态分配的空间分配额外的空间,用于扩充容量。
delete和delete[]区别
delete只会调用一次析构函数,而delete[]会调用每个成员的析构函数
用new分配的内存用delete释放,用new[]分配的内存用delete[]释放
sprintf、memcpy和strcpy的区别
void *memcpy(void *str1, const void *str2, size_t n)从存储区 str2 复制 n 个字节到存储区 str1。
int sprintf(char *str, const char *format, ...) 发送格式化输出到 str 所指向的字符串。
char *strcpy(char *dest, const char *src) 把 src 所指向的字符串复制到 dest
1)操作对象不同
① strcpy的两个操作对象均为字符串,只能复制字符串;
② sprintf的操作源对象可以是多种数据类型,目的操作对象是字符串
③ memcpy的两个对象就是两个任意可操作的内存地址,并不限于何种数据类型。即可以复制任意内容,例如字符数组,整型、结构体、类等;
2)执行效率不同
memcpy最高,strcpy次之,sprintf的效率最低。
memcpy和strcpy复制方式不同:strcpy不需要指定长度,它遇到被复制字符的串结束符"\0"才结束,所以容易溢出。memcpy则是根据其第3个参数决定复制的长度。
3)实现功能不同
① strcpy主要实现字符串变量间的拷贝
② sprintf主要实现其他数据类型格式到字符串的转化
③ memcpy主要是内存块间的拷贝。
strcpy和strncpy函数的区别,哪个更安全
函数原型:
char* strcpy(char* strDest, const char* strSrc)
char *strncpy(char *dest, const char *src, size_t n)
strcpy函数: 如果参数 dest 所指的内存空间不够大,可能会造成缓冲溢出(buffer Overflow)的错误情况,在编写程序时请特别留意,或者用strncpy()来取代。
strncpy函数:用来复制源字符串的前n个字符,src 和 dest 所指的内存区域不能重叠,且 dest 必须有足够的空间放置n个字符。
如果目标长>指定长>源长,则将源长全部拷贝到目标长,自动加上’\0’
如果指定长<源长,则将源长中按指定长度拷贝到目标字符串,不包括’\0’
如果指定长>目标长,运行时错误 ;
变量声明和定义区别
- 声明仅仅是把变量的类型和名字提供给编译器,并不分配内存空间;定义就是对这个变量和函数进行内存分配和初始化,需要分配空间。
- 相同变量可以在多处声明(使用extern修饰的是变量的声明,说明该变量在文件以外或文件后面部分定义),但只能在一处定义。
函数的声明和定义: 声明:一般在头文件里,对编译器说:这里我有一个函数叫function() 让编译器知道这个函数的存在。 定义:一般在源文件里,具体就是函数的实现过程 写明函数体。
简述#ifdef、#else、#endif和#ifndef作用
一般情况下,源程序中所有的行都参加编译。但是有时希望对其中一部分内容只在满足一定条件才进行编译,这就是“条件编译”。有时,希望当满足某条件时对一组语句进行编译,而当条件不满足时则编译另一组语句。
可以利用#ifdef、#endif将某程序功能模块包括进去,向特定用户提供该功能。在不需要时用户可轻易将其屏蔽。条件编译命令最常见形式:
#ifdef 标识符 程序段1 #else 程序段2 #endif
作用是:当标识符已经被定义过(一般是用#define命令定义),则对程序段1进行编译,否则编译程序段2。 其中#else部分也可以没有。
采用条件编译,可以减少被编译的语句,从而减少程序的长度,减少运行时间。
在一个大的软件工程里面,可能会有多个文件同时包含一个头文件,当这些文件编译链接成一个可执行文件上时,就会出现大量**“重定义**”错误。
在头文件中使用#define、#ifndef、#ifdef、#endif能避免头文件重定义。
#ifndef _NAME_H #define _NAME_H //头文件内容 #endif
其中,_NAME_H 是宏的名称。需要注意的是,这里设置的宏名必须是独一无二的,不要和项目中其他宏的名称相同。宏定义里面的字母不用都是大写字母。
当程序中第一次 #include 该文件时,由于 _NAME_H 尚未定义,所以会定义 _NAME_H 并执行“头文件内容”部分的代码;当发生多次 #include 时,因为前面已经定义了 _NAME_H,所以不会再重复执行“头文件内容”部分的代码。
也可以使用
#pragma once
防止头文件被重复引入。
写出int、bool、float、指针变量与”零值“比较的if语句,判断两个浮点数是否相等(联想)
//int与零值比较
if(n == 0)
if(n != 0)
//bool与零值比较
if(flag) //表示flag为真
if(!flag) // 表示flag为假
//float与零值比较
const float EPSINON = 0.00001;
if((x >= -EPSINON) && (x <= EPSINON)) //其中EPSINON是允许的误差(即精度)
//指针变量与零值比较
if(p == NULL)
if(p != NULL)
对两个浮点数判断大小和是否相等不能直接用==来判断,会出错!明明相等的两个数比较反而是不相等!对于两个浮点数比较只能通过相减并与预先设定的精度比较,记得要取绝对值!
设置地址为0x67a9的整型变量的值为0xaa66
int *ptr;
ptr = (int *)0x67a9;
*ptr = 0xaa66
strlen和sizeof区别
- sizeof是运算符,并不是函数,结果在编译时得到而非运行中获得,获取的是数据类型占内存的大小;strlen是字符处理的库函数,必须在运行时才能计算出来,获取的是字符串实际长度。
- sizeof参数可以是任何数据的类型或者数据;strlen的参数只能是字符指针且结尾是'\0'的字符串。
- 因为sizeof值在编译时确定,所以不能用来得到动态分配(运行时分配)存储空间的大小。
注意:
针对字符串,sizeof()返回的是变量声明后所占的内存数,strlen获取到是字符串的实际长度(不包含结尾的'\0', 计算至
\0
结束)int main(int argc, char const *argv[]){ const char* str = "name"; sizeof(str); // 取的是指针str的长度,是8 strlen(str); // 取的是这个字符串的长度,不包含结尾的 \0。大小是4 char *str1 = "asdfgh"; //sizeof(str1) = 8, strlen(str1) = 6 char str2[] = "asdfgh"; // sizeof(str2) = 7, strlen(str2)=6 char str3[8] = {'a', 's', 'd'}; // sizeof(str3) = 8, strlen(str3)=3 char str4[] = "as\0df"; // sizeof(str4)=6, strlen(str4) = 2; sizeof计算的是变量的大小 return 0; }
在64位的编译环境下的,指针的占用大小为8字节;而在32位环境下,指针占用大小为4字节。一个指针占内存的大小跟编译环境有关,而与机器的位数无关。
宏定义和函数的区别
- 宏在预处理阶段完成替换,之后被替换的文本参与编译,相当于直接插入了代码,运行时不存在函数调用,执行起来更快;函数调用在运行时需要跳转到具体调用函数。
- 宏定义属于在结构中插入代码,没有返回值;函数调用具有返回值。
- 宏定义参数没有类型,不进行类型检查;函数参数具有类型,需要检查类型。
- 宏定义不要在最后加分号。
宏定义和typedef区别
宏主要用于定义常量及书写复杂使用频繁的宏;typedef主要用于定义类型别名。
宏替换发生在编译阶段之前,属于文本插入替换;typedef是编译的一部分。
宏不检查类型;typedef会检查数据类型。
宏不是语句,不在在最后加分号;typedef是语句,要加分号标识结束。
注意对指针的操作,typedef char * p_char和#define p_char char *区别巨大。
typedef char * PCHAR1; #define PCHAR2 char *…… /* c1、c2 都为char *,typedef为char *引入了一个新的别名*/ PCHAR1 c1, c2; /*相当于char * c3, c4;c3是char *,而c4是char */ PCHAR2 c3, c4;
C++中struct 和class 区别
相同点
- 两者都拥有成员函数、公有和私有部分
- 任何可以使用class完成的工作,同样可以使用struct完成
不同点
- 使用struct时,它的成员的访问权限默认是public的,而class的成员默认是private的
- struct的继承默认是public继承,而class的继承默认是private继承
- class可以用作模板,而struct不能
引申: C++和C的struct区别
- C语言中结构体没有函数成员,只是一些变量的集合体;但是c++支持成员函数的定义
- C中struct是没有权限的设置的;C++中的struct增加了访问权限,成员默认访问说明符为public
- C中结构体是没有继承关系的,而C++中struct能继承,能实现多态;
- struct作为类的一种特例是用来自定义数据结构的。一个结构标记声明后,在C中必须在结构标记前加上struct,才能做结构类型名(除:typedef struct class{};);C++中结构体标记(结构体名)可以直接作为结构体类型名使用,此外结构体struct在C++中被当作类的一种特例
结构体可以直接赋值吗?
声明时可以直接初始化,同一结构体的不同对象之间也可以直接赋值,但是当结构体中含有指针成员时一定小心。
Note:当有多个指针指向同一段内存时,某个指针释放这段内存可能会导致其他指针的非法操作。
define宏定义和const的联系与区别
- 联系:都是定义常量的一种方法
- 区别:
- 编译器处理方式不同:define定义的常量是在预处理阶段进行替换,而const在编译阶段确定它的值。
- define定义的常量没有类型,只做简单的替换,不会进行类型安全检查和计算,也不求解,容易产生错误,一般最好加上一个大括号包含住全部的内容;而const常量是有类型的,编译器会进行类型安全检查,安全性更高。
- const可以定义函数而define不可以。
- 存储方式不同:宏定义的数据没有分配内存空间,存储在代码段中,只是插入替换掉,可能会有多个拷贝,占用的内存空间大;const定义的变量只是值不能改变,但要分配内存空间,存放在静态存储区(数据段中),只有一个拷贝,占用的内存空间小。
- 定义域不同:#define宏不受定义域限制,而const常量只在定义域内有效。
对于一个频繁使用的短小函数,应该是用什么实现?有什么优缺点?
使用inline内联函数,即编译器将内联函数内的代码替换到函数被调用的地方。
优点:
- 将函数代码嵌入到目标代码中,省去函数调用的开销来提高执行效率
- 相比宏函数,内联函数在代码展开时,编译器会进行语法安全检查或数据类型转换,使用更安全。
缺点:
- 代码膨胀,产生更多开销;
- 如果内联函数内代码的执行时间比调用时间长很多,效率提升并没有那么大;
- 如果修改内联函数,那么所有调用该函数的代码文件都需要重新编译;
内联函数和宏定义的区别
inline是内联的意思,可以定义比较小的函数。因为函数频繁调用会占用很多的栈空间,进行入栈出栈操作也耗费计算资源,所以可以用inline关键字修饰频繁调用的小函数。编译器会在编译阶段将代码体嵌入内联函数的调用语句块中。
区别:
- 内联函数在编译时展开,而宏在预编译时展开
- 在使用时,宏只做简单字符串替换(编译前)。而内联函数在编译时直接将函数代码嵌入到目标代码中,省去函数调用的开销来提高执行效率,并且进行参数类型检查,具有返回值,可以实现重载。
- 宏不是函数,而inline是函数
- 宏定义时要注意书写(参数要括起来)否则容易出现歧义,内联函数不会产生歧义
- 内联函数有类型检测、语法判断等功能,而宏没有
内联函数适用场景:
- 使用宏定义的地方都可以使用 inline 函数。
- 作为类成员接口函数来读写类的私有成员或者保护成员,会提高效率。
区别以下指针类型
int *p[10]
int (*p)[10]
int *p(int)
int (*p)(int)
int *p[10]表示指针数组,强调数组概念,是一个数组变量,数组大小为10,数组内每个元素都是指向int类型的指针变量。
int (*p)[10]表示数组指针,强调是指针,只有一个变量,是指针类型,不过指向的是一个int类型的数组,这个数组大小是10。
int *p(int)是函数声明,函数名是p,参数是int类型的,返回值是int *类型的。
int (*p)(int)是函数指针,强调是指针,该指针指向的函数具有int类型参数,并且返回值是int类型的。
#include <stdlib.h> #include <stdio.h> // 回调函数 void populate_array(int *array, size_t arraySize, int (*getNextValue)(void)) { for (size_t i=0; i<arraySize; i++) array[i] = getNextValue(); } // 获取随机值 int getNextRandomValue(void) { return rand(); } int main(void) { int myarray[10]; /* getNextRandomValue 不能加括号,否则无法编译,因为加上括号之后相当于传入此参数时传入了 int , 而不是函数指针*/ populate_array(myarray, 10, getNextRandomValue); for(int i = 0; i < 10; i++) { printf("%d ", myarray[i]); } printf("\n"); return 0; }
函数指针
1)什么是函数指针?
如果在程序中定义了一个函数,那么在编译时系统就会为这个函数代码分配一段存储空间,这段存储空间的首地址称为这个函数的地址。而且函数名表示的就是这个地址。既然是地址我们就可以定义一个指针变量来存放,这个指针变量就叫作函数指针变量,简称函数指针。
2)函数指针的定义方法
int (*pf)(const int&, const int&); (1)
上面的pf就是一个函数指针,指向所有返回类型为int,并带有两个const int&参数的函数。注意*pf两边的括号是必须的,否则上面的定义就变成了:
int *pf(const int&, const int&); (2)
而这声明了一个函数pf,其返回类型为int *, 带有两个const int&参数。
3)为什么有函数指针(函数指针的作用)
- 函数指针主要是能够用一个指针的方式指向一个函数,并且还可以换换指向别的函数,比如有多个函数的申明,它们有不同的具体实现,如果需要调用它们,就可以用一个指针轮流指向它们。
- 回调机制:很好的应用函数指针的例子,这是函数指针作为回调函数的一个参数。
4) 一个函数名就是一个指针,它指向函数的代码。
一个函数地址是该函数的进入点,也就是调用函数的地址。函数的调用可以通过函数名,也可以通过指向函数的指针来调用。函数指针还允许将函数作为变量传递给其他函数;
5) 两种方法赋值:
指针名 = 函数名; 指针名 = &函数名
回调函数和它的作用
- 回调函数就相当于一个中断处理函数,由系统在符合设定的条件时自动调用。为此,需要做三件事:1,声明;2,定义;3,设置触发条件,就是在函数中把回调函数名称转化为地址作为一个参数,以便于系统调用;
- 什么是回调函数: 回调函数就是一个通过函数指针调用的函数。如果你把函数的指针(地址)作为参数传递给另一个函数,当这个指针被用为调用它所指向的函数时,我们就说这是回调函数;
- 为什么要使用回调函数: 因为可以把调用者与被调用者分开。调用者不关心谁是被调用者,所有它需知道的,只是存在一个具有某种特定原型、某些限制条件(如返回值为int)的被调用函数。
如果想知道回调函数在实际中有什么作用,先假设有这样一种情况,我们要编写一个库,它提供了某些排序算法的实现,如冒泡排序、快速排序、shell排序、shake排序等等,但为使库更加通用,不想在函数中嵌入排序逻辑,而让使用者来实现相应的逻辑;或者,想让库可用于多种数据类型(int、float、string),此时,该怎么办呢?可以使用函数指针,并进行回调。
static的用法和作用
- 修饰局部变量时,该变量在静态存储区分配内存;只能在首次函数调用中进行首次初始化,之后的函数调用不再进行初始化;其生命周期与程序相同,但其作用域为局部作用域,并不能在作用域外被访问;
- 修饰全局变量时,该变量在静态存储区分配内存;在声明该变量的整个⽂件中都是可见的,而在⽂件外是不可见的;
- 修饰函数时,在声明该函数的整个⽂件中都是可见的,⽽在⽂件外是不可见的,从而可以在多人协作时避免同名的函数冲突;
- 修饰成员变量时,所有的对象都只维持⼀份拷贝,可以实现不同对象间的数据共享;不需要实例化对象即可访问;不能在类内部初始化,⼀般在类外部初始化,并且初始化时不加 static (形式如int 类名:: C = 0);
- 修饰成员函数时,该函数不接受 this 指针,只能访问类的静态成员;不需要实例化对象即可访问。
下面内容作为扩展:
1)最重要的一条:隐藏(static函数、static变量均可)
当同时编译多个文件时,所有未加static前缀的全局变量和函数都具有全局可见性。
2)static的第二个作用是保持变量内容的持久。(static变量中的记忆功能和全局生存期)存储在静态数据区的变量会在程序刚开始运行时就完成初始化,也是唯一的一次初始化。共有两种变量存储在静态存储区:全局变量和static变量,只不过和全局变量比起来,static可以控制变量的可见范围,说到底static还是用来隐藏的。
3)static的第三个作用是默认初始化为0(static变量)
其实全局变量也具备这一属性,因为全局变量也存储在静态数据区。在静态数据区,内存中所有的字节默认值都是0x00,某些时候这一特点可以减少程序员的工作量。
局部变量未初始化,没有默认初始值。
4)static的第四个作用:C++中的类成员声明static
- 函数体内static变量的作用范围为该函数体,不同于auto变量,该变量的内存只被分配一次,因此其值在下次调用时仍维持上次的值;
- 在模块内的static全局变量可以被模块内所有函数访问,但不能被模块外其它函数访问;
- 在模块内的static函数只可被这一模块内的其它函数调用,这个函数的使用范围被限制在声明它的模块内;
- 在类中的static成员变量属于整个类所拥有,对类的所有对象只有一份拷贝;
- 在类中的static成员函数属于整个类所拥有,这个函数不接收this指针,因而只能访问类的static成员变量。
类内:
- static类对象必须要在类外进行初始化,static修饰的变量先于对象存在,所以static修饰的变量要在类外初始化;
- 由于static修饰的类成员属于类,不属于对象,因此static类成员函数是没有this指针的,this指针是指向本对象的指针。正因为没有this指针,所以static类成员函数不能访问非static的类成员,只能访问 static修饰的类成员;
- static成员函数不能被virtual修饰,static成员不属于任何对象或实例,所以加上virtual没有任何实际意义;静态成员函数没有this指针,虚函数的实现是为每一个对象分配一个vptr指针,而vptr是通过this指针调用的,所以不能为virtual;虚函数的调用关系,this->vptr->ctable->virtual function
静态成员与普通成员的区别
- 生命周期
静态成员变量从类被加载开始到类被卸载,一直存在;
普通成员变量只有在类创建对象后才开始存在,对象结束,它的生命期结束;
- 共享方式
静态成员变量是全类共享;普通成员变量是每个对象单独享用的;
- 定义位置
普通成员变量存储在栈或堆中,而静态成员变量存储在静态全局区;
- 初始化位置
普通成员变量在类中初始化;静态成员变量在类外初始化;
- 默认实参
可以使用静态成员变量作为默认实参
C++中const和static作用
static:
static的意思是静态的,可以用来修饰变量,函数和类成员。
1)不考虑类的情况
- 被static修饰的变量为静态变量,它会在程序运行过程中一直存在,会被放在静态存储区。局部静态变量的作用域在函数体中,全局静态变量的作用域在这个文件里。默认初始化为0,包括未初始化的全局静态变量与局部静态变量,都存在全局未初始化区
- 静态函数(被static修饰的函数)只能在本文件中使用,不能被其他文件调用,也不会和其他文件中的同名函数冲突。
- 静态变量在函数内定义,只进行一次初始化,其作用范围与局部变量(在函数外不能使用)相同,在函数调用结束后也不会被回收,会一直在程序内存中,直到该函数再次被调用,它的值还是保持上一次调用结束后的值。
- 隐藏。所有不加static的全局变量和函数具有全局可见性,可以在其他文件中使用,加了之后只能在该文件所在的编译模块中使用
2)考虑类的情况
- static成员变量:只与类关联,不与类的对象关联。这个静态成员会被类的多个对象公用。定义时要分配空间,不能在类声明中初始化,必须在类定义体外部初始化,初始化时不需要标示为static;可以被非static成员函数任意访问。
- static成员函数:不具有this指针,无法访问类对象的非static成员变量和非static成员函数,如果要访问非静态成员,要用对象来引用;不能被声明为const、虚函数和volatile;可以被非static成员函数任意访问。不属于某个对象的,访问这个静态函数不需要引用对象名,而是通过引用类名来访问。
const:
1)不考虑类的情况
- const常量在定义时必须初始化,之后无法更改
- const形参可以接收const和非const类型的实参,例如// i 可以是 int 型或者 const int 型void fun(const int& i)
2)考虑类的情况
- const修饰类的成员变量:表示常量不能被修改,不能在类定义外部初始化,只能通过构造函数初始化列表进行初始化,并且必须有构造函数;
- const修饰类的成员函数:表示该函数不会调用非const成员函数,不可以改变非mutable(用该关键字声明的变量可以在const成员函数中被修改)数据的值;非const成员函数都可以调用;常成员函数声明:<类型标志符>函数名(参数表)const
- const修饰类的对象:类的常对象只能访问类的常成员函数;
补充一点const相关:const修饰变量是也与static有一样的隐藏作用。只能在该文件中使用,其他文件不可以引用声明使用。 因此在头文件中声明const变量是没问题的,因为即使被多个文件包含,链接性都是内部的,不会出现符号冲突。除此之外:
- const成员函数可以访问非const对象的非const数据成员、const数据成员,也可以访问const对象内的所有数据成员;
- 非const成员函数可以访问非const对象的非const数据成员、const数据成员,但不可以访问const对象的任意数据成员
注意和const的区别。const强调值不能被修改,而static强调唯一的拷贝,对所有类的对象都共用。
C++的顶层const和底层const
概念区分
- 顶层const:指的是const修饰的变量本身是一个常量,无法修改,指的是指针,就是 * 号的右边
- 底层const:指的是const修饰的变量所指向的对象是一个常量,指的是所指变量,就是 * 号的左边
举个例子
int a = 10;int* const b1 = &a; //顶层const,b1本身是一个常量
const int* b2 = &a; //底层const,b2本身可变,所指的对象是常量
const int b3 = 20; //顶层const,b3是常量不可变
const int* const b4 = &a; //前一个const为底层,后一个为顶层,b4不可变
const int& b5 = a; //用于声明引用变量,都是底层const
区分作用
- 执行对象拷贝时有限制,常量的底层const不能赋值给非常量的底层const
- 使用命名的强制类型转换函数const_cast时,只能改变运算对象的底层const
const int a;int const a;const int *a;int *const a;
- int const a和const int a均表示定义常量类型a。
- const int *a,其中a为指向int型变量的指针,const在 * 左侧,表示a指向不可变常量。(看成const (*a),对引用加const)
- int *const a,依旧是指针类型,表示a为指向整型数据的常指针。(看成const(a),对指针const)
数组名和指针(指向数组首元素的指针)区别
- 二者均可通过增减偏移量来访问数组中的元素。
- 数组名不是真正意义上的指针,可以理解为常指针,所以数组名没有自增、自减等操作。
- 当数组名当做形参传递给调用函数后,就失去了原有特性,退化成一般指针,多了自增、自减操作,但sizeof运算符不能再得到原数组的大小了。
遇到指针的计算,需要明确的是指针每移动一位,它实际跨越的内存间隔是指针类型的长度,建议都转成10进制计算,计算结果除以类型长度取得结果。
野指针和悬空指针
都是指向无效内存区域(这里的无效指的是"不安全不可控")的指针,访问行为将会导致未定义行为。
野指针:没有被初始化过的指针
int main(void) { int* p; // 未初始化 std::cout<< *p << std::endl; // 未初始化就被使用 return 0; }
因此,为了防止出错,对于指针初始化时都是赋值为 nullptr
,这样在使用时编译器就会直接报错,产生非法内存访问。
悬空指针:当指针所指向的对象被释放,但是该指针没有任何改变,以至于其仍然指向已经被回收的内存地址,这种情况下地指针称为悬空指针;
int main(void) { int * p = nullptr; int* p2 = new int; p = p2; delete p2; }
此时 p和p2就是悬空指针,指向的内存已经被释放。继续使用这两个指针,行为不可预料。需要设置为
p=p2=nullptr
。此时再使用,编译器会直接保错。 避免野指针比较简单,但悬空指针比较麻烦。c++引入了智能指针,C++智能指针的本质就是避免悬空指针的产生。
产生原因及解决办法:
- 野指针:指针变量未及时初始化 => 定义指针变量及时初始化,要么置空。
- 悬空指针:指针free或delete之后没有及时置空 => 释放操作后立即置空
常量指针和指针常量区别(联想)
- 指针常量是一个指针,指向常量的指针,也就是后面所指明的int const 和 const int,都是一个常量,可以写作int const *p或const int *p。指针指向可以改,指针指向的值不可以更改。即不能通过指针修改变量的值,但是可以通过修改变量的值.
- 常量指针是一个不能改变指向的指针。指针是个常量,必须初始化,一旦初始化完成,它的值(也就是存放在指针中的地址)就不能在改变了,即不能改变指向,但指针指向的值可以更改,如int *const p。
- const既可以修饰指针,也可以修饰常量:const int * const p;
注意:
常量引用:const int &r、int const &r;
- 常量引用不能通过引用修改其绑定的对象,但可以通过直接修改被绑定对象的值;
- 不存在引用常量
形参和实参的区别
- 形参变量只有在被调用时才分配内存单元,在调用结束时, 即刻释放所分配的内存单元。因此,形参只有在函数内部有效。 函数调用结束返回主调函数后则不能再使用该形参变量。
- 无论实参是何种类型的量,在进行函数调用时,它们都必须具有确定的值, 以便把这些值传送给形参。
- 实参和形参在数量上,类型上,顺序上应严格一致, 否则会发生“类型不匹配”的错误。
- 函数调用中发生的数据传送是单向的。 即只能把实参的值传送给形参,而不能把形参的值反向传送给实参。当形参和实参不是指针类型时,在该函数运行时,形参和实参是不同的变量,他们在内存中位于不同的位置,形参将实参的内容复制一份,在该函数运行结束的时候形参被释放,而实参内容不会改变。
值传递、指针传递、引用传递的区别和效率
- 值传递:有一个形参向函数所属的栈拷贝数据的过程,如果值传递的对象是类对象 或是大的结构体对象,将耗费一定的时间和空间。(传值)
- 指针传递:同样有一个形参向函数所属的栈拷贝数据的过程,但拷贝的数据是一个固定为4字节的地址。(本质是值传递,传递的是地址值)。如果想通过指针参数传递来改变主调函数中的相关变量(地址),那就得使用指向指针的指针或者指针引用。
- 引用传递:同样有上述的数据拷贝过程,但其是针对地址的,相当于为该数据所在的地址起了一个别名。(传地址)
- 效率上讲,指针传递和引用传递比值传递效率高。一般主张使用引用传递,代码逻辑上更加紧凑、清晰。
- 值传递过程中,被调函数的形参作为被调函数的局部变量处理,会在栈中开辟内存空间以存放由主调函数传递进来的实参值,从而形成了实参的一个副本。值传递的特点是,被调函数对形参的任何操作都是作为局部变量进行的,不会影响主调函数的实参的值(形参指针变了,实参指针不会变)。
- 引用参数传递过程中,被调函数的形参也作为局部变量在栈中开辟了内存空间,但是这时存放的是由主调函数放进来的实参的地址。被调函数对形参的任何操作都被处理成间接寻址,即通过栈中存放的地址访问主调函数中的实参。因此,被调函数对形参的任何操作都会影响主调函数中的实参变量。
引用传参和作为返回值的优点
对比值传递,引用传参的好处:
- 在函数内部可以对此参数进行修改
- 提高函数调用和运行的效率(因为没有了传值和生成副本的时间和空间消耗)
用引用作为返回值最大的好处就是在内存中不产生被返回值的副本。但是有以下的限制:
1)不能返回局部变量的引用。因为函数返回以后局部变量就会被销毁
2)不能返回函数内部new分配的内存的引用。这个引用所指向的空间(由new分配)无法释放,会造成memory leak
3)可以返回类成员的引用,但是最好是const。因为如果其他对象可以获得该属性的非常量的引用,那么对该属性的单纯赋值就会破坏业务规则的完整性。
#include <iostream>
using namespace std;
double vals[] = {10.1, 12.6, 33.1, 24.1, 50.0};
double& setValues(int i) {
double& ref = vals[i];
return ref; // 返回第 i 个元素的引用,ref 是一个引用变量,ref 引用 vals[i]
}
// 要调用上面定义函数的主函数
int main ()
{
cout << "改变前的值" << endl;
for ( int i = 0; i < 5; i++ )
{
cout << "vals[" << i << "] = ";
cout << vals[i] << endl;
}
setValues(1) = 20.23; // 改变第 2 个元素
setValues(3) = 70.8; // 改变第 4 个元素
cout << "改变后的值" << endl;
for ( int i = 0; i < 5; i++ )
{
cout << "vals[" << i << "] = ";
cout << vals[i] << endl;
}
return 0;
}
改变前的值
vals[0] = 10.1
vals[1] = 12.6
vals[2] = 33.1
vals[3] = 24.1
vals[4] = 50
改变后的值
vals[0] = 10.1
vals[1] = 20.23
vals[2] = 33.1
vals[3] = 70.8
vals[4] = 50
全局变量和静态变量什么时候初始化
全局变量、文件域中的静态变量、类中的成员静态变量在main函数执行前初始化;局部变量中的静态变量在第一次调用时初始化。
C和C++的区别:局部静态变量:
- 在C语言中是编译期初始化并分配内存,故不能用变量给静态局部变量赋值,只能用常量。
- 在C++中是第一次执行时初始化,因为C++引入了对象的概念,对象一般需要构造函数,无法简单的分配内存,故可以用变量赋值,并且在第一次使用时初始化。
Noting:
- 静态变量初始化只有一次,但是可以多次赋值,在主程序之前,编译器已经为其分配好了内存。
- 静态局部变量和全局变量一样,数据都存放在全局区域,所以在主程序之前,编译器已经为其分配好了内存,但在C和C++中静态局部变量的初始化节点又有点不太一样。在C中,初始化发生在代码执行之前,编译阶段分配好内存之后,就会进行初始化,所以我们看到在C语言中无法使用变量对静态局部变量进行初始化,在程序运行结束,变量所处的全局内存会被全部回收。
- 而在C++中,初始化时在执行相关代码时才会进行初始化,主要是由于C++引入对象后,要进行初始化必须执行相应构造函数和析构函数,在构造函数或析构函数中经常会需要进行某些程序中需要进行的特定操作,并非简单地分配内存。所以C++标准定为全局或静态对象是有首次用到时才会进行构造,并通过atexit()来管理。在程序结束,按照构造顺序反方向进行逐个析构。所以在C++中是可以使用变量对静态局部变量进行初始化的。
a和&a区别
假设数组int a[10]; int (*p)[10] = &a;其中:
- a是数组名,是数组首元素地址,+1表示地址值加上一个int类型的大小,如果a的值是0x00000001,加1操作后变为0x00000005。*(a + 1) = a[1]。
- &a是数组的指针,其类型为int (*)[10](就是前面提到的数组指针),其加1时,系统会认为是数组首地址加上整个数组的偏移(10个int型变量),值为数组a尾元素后一个元素的地址。
- 若(int *)p ,此时输出 *p时,其值为a[0]的值,因为被转为int *类型,解引用时按照int类型大小来读取。
解析((void()())0)()的含义
- void(*0)():是一个返回值为void,参数为空的函数指针0;
- (void (*)())0:将0转变为一个返回值为void,参数为空的函数指针;
- (void()())0:在上局的基础上加*表示整个是一个返回值为void,无参数,并且起始地址为0的函数的名字;
- ((void()())0)():上局的函数名所对应的函数的调用。
C++和python的区别
包括但不限于:
- python是一种脚本语言,是解释执行的,而C++是编译语言,是需要编译后在特定平台运行的。python可以很方便的跨平台,但是效率没有C++高。
- python使用缩进来区分不同的代码块,C++使用花括号来区分
- C++中需要事先定义变量的类型,而python不需要,python的基本数据类型只有数字,布尔值,字符串,列表,元组等等
- python的库函数比C++的多,调用起来很方便
C、C++和java的区别
1)C++与C语言的区别
包括但不限于:
- C是面向过程的语言,C++是面向对象的语言,C++有“封装,继承和多态”的特性。封装隐藏了实现细节,使得代码模块化。继承通过子类继承父类的方法和属性,实现了代码重用。多态则是“一个接口,多个实现”,通过子类重写父类的虚函数,实现了接口重用。
- C和C++内存管理的方法不一样,C使用malloc/free,C++除此之外还用new/delete
- C++中还有函数重载和引用等概念,C中没有标准C++中的字符串类取代了标准C函数库头文件中的字符数组处理函数(C中没有字符串类型)。
- C++中的try/catch/throw异常处理机制取代了标准C中的setjmp()和longjmp()函数。
2)C++与java的联系与区别
包括但不限于:
- C++ 和Java都是面向对象的语言,C++是编译成可执行文件直接运行的,JAVA是编译之后在JAVA虚拟机上运行的,因此JAVA有良好的跨平台特性,但是执行效率没有C++ 高。
- C++的内存管理由程序员手动管理,JAVA的内存管理是由Java虚拟机完成的,它的垃圾回收使用的是标记-回收算法
- C++有指针,Java没有指针,只有引用
- JAVA和C++都有构造函数,但是C++有析构函数但是Java没有
其余说法:
1)语言特性:
- Java语言给开发人员提供了更为简洁的语法;完全面向对象,由于JVM可以安装到任何的操作系统上,所以说它的可移植性强
- Java语言中没有指针的概念,引入了真正的数组。不同于C++中利用指针实现的“伪数组”,Java引入了真正的数组,同时将容易造成麻烦的指针从语言中去掉,这将有利于防止在C++程序中常见的因为数组操作越界等指针操作而对系统数据进行非法读写带来的不安全问题
- C++也可以在其他系统运行,但是需要不同的编码(这一点不如Java,只编写一次代码,到处运行),例如对一个数字,在windows下是大端存储,在unix中则为小端存储。Java程序一般都是生成字节码,在JVM里面运行得到结果
- Java用接口(Interface)技术取代C++程序中的抽象类。接口与抽象类有同样的功能,但是省却了在实现和维护上的复杂性
2)垃圾回收:
- C++用析构函数回收垃圾,写C和C++程序时一定要注意内存的申请和释放
- Java语言不使用指针,内存的分配和回收都是自动进行的,程序员无须考虑内存碎片的问题
3)应用场景:
- Java在桌面程序上不如C++实用,C++可以直接编译成exe文件,指针是c++的优势,可以直接对内存的操作,但同时具有危险性 。(操作内存的确是一项非常危险的事情,一旦指针指向的位置发生错误,或者误删除了内存中某个地址单元存放的重要数据,后果是可想而知的)
- Java在Web 应用上具有C++ 无可比拟的优势,具有丰富多样的框架
- 对于底层程序的编程以及控制方面的编程,C++很灵活,因为有句柄的存在
为什么C++没有垃圾回收机制?这点和Java不同
- 首先,实现一个垃圾回收器会带来额外的空间和时间开销。你需要开辟一定的空间保存指针的引用计数和对他们进行标记mark。然后需要单独开辟一个线程在空闲的时候进行free操作。
- 垃圾回收会使得C++不适合进行很多底层的操作。
面对对象和面对过程语言的区别
面向对象是把问题抽象成一个个对象,给这些对象绑上属性和方法,即以功能而非步骤划分问题。优点:更易低耦合、易维护、易复用;缺点:因为需要实例化所以开销较大,性能比面向过程要低
面向过程是把问题拆分成一个个函数和数据,按照一定的顺序来执行这些方法。优点:性能较高;缺点:不易维护,不易复用
堆和栈的区别(蔚来)
- 管理方式不同:堆是由new和malloc开辟的一块内存,由程序员手动管理(申请和释放),容易产生内存泄漏,栈是编译器自动管理的内存,无需人工控制,存放函数的参数和局部变量。
- 生长方向: 栈的生长空间向下,地址越来越小;堆的生长空间向上,地址越来越大。
- 空间大小: 堆是不连续的内存区域(因为系统是用链表来存储空闲内存地址,自然不是连续的),堆大小受限于计算机系统中有效的虚拟内存(32bit 系统理论上是4G),所以堆的空间比较灵活,比较大。栈是一块连续的内存区域,大小是操作系统预定好的,栈是向栈底扩展,大小固定,windows下栈大小是2M(也有是1M,在 编译时确定,VC中可设置)
- 碎片: 对于堆,频繁的new/delete会造成大量碎片,使程序效率降低。对于栈,它是有点类似于数据结构上的一个先进后出的栈,进出一一对应,不会产生碎片。
- 分配效率不同:堆由C/C++函数库提供,机制很复杂。栈是其系统提供的数据结构,计算机在底层对栈提供支持,分配专门寄存器存放栈地址,栈操作有专门指令。所以堆的效率比栈低很多。
Linux环境下有操作系统决定,一般是8MB,8192KB,通过ulimit命令查看以及修改, 堆区一般是 1G - 4G。
堆快一点还是栈快一点?(字节提前批一面)
栈快一点。因为操作系统会在底层对栈提供支持,会分配专门的寄存器存放栈的地址,栈的入栈出栈操作也十分简单,并且有专门的指令执行,所以栈的效率比较高也比较快。而堆的操作是由C/C++函数库提供的,在分配堆内存的时候需要一定的算法寻找合适大小的内存,并且获取堆的内容需要两次访问,第一次访问指针,第二次根据指针保存的地址访问内存,因此堆比较慢。
C++的四种强制转换
四种强制类型转换操作符分别为:static_cast、dynamic_cast、const_cast、reinterpret_cast
static_cast :
static_cast < type-id > (expression)
该运算符把expression转换为type-id类型,用于各种隐式转换。特性与要点:
- 用于非多态类型的转换;
- 没有运行时类型检查来保证转换的安全,所以有安全隐患;
- 通常用于基本数据类型之间的转换、子类向父类的安全转换、void *和其他类型指针之间的转换。如把int转换成char,把int转换成enum。这种转换的安全性也要开发人员来保证。在用于类层次结构中基类(父类)和派生类(子类)之间指针或引用的转换时,进行向上类型转换(把派生类的指针或引用转换成基类表示,默认安全)是安全的,进行向下类型转换(把基类指针或引用转换成派生类表示)时,由于没有运行时类型检查,所以是不安全的。
dynamic_cast
dynamic_cast <type-id> (expression)
该运算符把expression转换成type-id类型的对象。type-id 必须是类的指针、类的引用或者void*。
如果 type-id 是类指针类型,那么expression也必须是一个指针,如果 type-id 是一个引用,那么 expression 也必须是一个引用。
特点:
- 基类必须含有虚函数的类,进行多态类型的转换,因为 dynamic_cast 是运行时类型检查,需要运行时类型信息,而这个信息是存储在类的虚函数表中。
- 只适用于指针或引用
- 主要用于子类和父类之间的安全转换。在类层次间进行上行转换时,dynamic_cast和static_cast的效果是一样的。在进行下行转换时,dynamic_cast具有运行时类型检查的功能,比static_cast更安全。即如果下行转换是安全的(即如果基类指针或者引用确实指向一个派生类对象),就会传回转换过的指针。如果不能转换,对于指针会返回目标类型的nullptr(NULL),对于引用会返回bad_cast异常。
问题:dynamic_cast转换时运用了什么技术进行类型检查
const_cast:
const_cast<type_id> (expression)
该运算符用来修改类型的const或volatile属性。比如,用于去除const常量属性,使其可以修改 ,也就是说,原本定义为const的变量在定义后就不能进行修改的,但是使用const_cast操作之后,可以通过这个指针或变量进行修改。具体用法:
- 常量指针被转化成非常量的指针,并且仍然指向原来的对象
- 常量引用被转换成非常量的引用,并且仍然指向原来的对象
- const_cast一般用于修改底指针。如const char *p形式
reinterpret_cast
reinterpret_cast<type-id> (expression)
type-id 必须是一个指针、引用、算术类型、函数指针或者成员指针。它可以用于类型之间进行强制转换。但是不够安全。
用在任意指针(或引用)类型之间的转换。能够将整型转换为指针,也可以把指针转换为整型或数组。
举例:
#include <bits/stdc++.h>
using namespace std;
class Base
{
public:
Base() :b(1) {}
virtual void fun() {};
int b;
};
class Son : public Base
{
public:
Son() :d(2) {}
int d;
};
int main()
{
int n = 97;
//reinterpret_cast
int *p = &n;
//以下两者效果相同
char *c = reinterpret_cast<char*> (p);
char *c2 = (char*)(p);
cout << "reinterpret_cast输出:"<< *c2 << endl;
//const_cast
const int *p2 = &n;
int *p3 = const_cast<int*>(p2);
*p3 = 100;
cout << "const_cast输出:" << *p3 << endl;
Base* b1 = new Son;
Base* b2 = new Base;
//static_cast
Son* s1 = static_cast<Son*>(b1); //同类型转换
Son* s2 = static_cast<Son*>(b2); //下行转换,不安全
cout << "static_cast输出:"<< endl;
cout << s1->d << endl;
cout << s2->d << endl; //下行转换,原先父对象没有d成员,输出垃圾值
//dynamic_cast
Son* s3 = dynamic_cast<Son*>(b1); //同类型转换
Son* s4 = dynamic_cast<Son*>(b2); //下行转换,安全
cout << "dynamic_cast输出:" << endl;
cout << s3->d << endl;
if(s4 == nullptr)
cout << "s4指针为nullptr" << endl;
else
cout << s4->d << endl;
return 0;
}
//输出结果
//reinterpret_cast输出:a
//const_cast输出:100
//static_cast输出:
//2
//-33686019
//dynamic_cast输出:
//2
//s4指针为nullptr
从输出结果可以看出,在进行下行转换时,dynamic_cast安全的,如果下行转换不安全的话其会返回空指针,这样在进行操作的时候可以预先判断。而使用static_cast下行转换存在不安全的情况也可以转换成功,但是直接使用转换后的对象进行操作容易造成错误。
volatile、mutable和explicit关键字的用法
(1) volatile
volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改,比如:操作系统、硬件或者其它线程等。
volatile定义变量的值是易变的,所以编译器就不会对这个变量进行优化(CPU的优化是让该变量存放到CPU寄存器(缓存)而不是内存),进而提供稳定的访问。
每次用到这个变量的值的时候都要从内存中重新读取这个变量的值,而不是读寄存器内的备份,即使它前面的指令刚刚从该处读取过数据。多线程中被几个任务共享的变量需要定义为volatile类型。
声明时语法:int volatile vInt;
多线程下的volatile: 有些变量是用volatile关键字声明的。当两个线程都要用到某一个变量且该变量的值会被改变时,应该用volatile声明,该关键字的作用是防止优化编译器把变量从内存装入CPU寄存器中。 如果变量被装入寄存器,那么两个线程有可能一个使用内存中的变量,一个使用寄存器中的变量,这会造成程序的错误执行。volatile的意思是让编译器每次操作该变量时一定要从内存中真正取出,而不是使用已经存在寄存器中的值。
(2)mutable
mutable的中文意思是“可变的,易变的”,在C++中,mutable也是为了突破const的限制而设置的。被mutable修饰的变量,将永远处于可变的状态,即使在一个const函数中。如果类的成员函数不会改变对象的状态,那么这个成员函数一般会声明成const的。mutable
只能用来修饰类的数据成员;而被 mutable
修饰的数据成员,可以在 const
成员函数中修改。
样例
class person
{
int m_A;
mutable int m_B;//特殊变量 在常函数里值也可以被修改
public:
void add() const//在函数里不可修改this指针指向的值 常量指针
{
m_A=10;//错误 不可修改值,this已经被修饰为常量指针
m_B=20;//正确
}
}
class person
{
public:
int m_A;
mutable int m_B;//特殊变量 在常函数里值也可以被修改
}
int main()
{
const person p;//修饰常对象 不可修改类成员的值
p.m_A=10;//错误,被修饰了指针常量
p.m_B=200;//正确,特殊变量,修饰了mutable
}
(3)explicit
explicit关键字用来修饰类的构造函数,被修饰的构造函数的类,不能发生相应的隐式类型转换,只能以显示的方式进行类型转换,注意以下几点:
- explicit 关键字只能用于类内部的构造函数声明上
- explicit 关键字作用于单个参数的构造函数
- 被explicit修饰的构造函数的类,不能发生相应的隐式类型转换
一个参数可以既是const又是volatile吗
可以,用const和volatile同时修饰变量,表示这个变量在程序内部是只读的,不能改变的,只在程序外部条件变化下改变,并且编译器不会优化这个变量。每次使用这个变量时,都要去内存读取这个变量的值,而不是去寄存器读取它的备份。
注意:const只是不允许程序中的代码改变某一变量,其在编译期发挥作用,它并没有实际禁止某段内存的读写特性。
全局变量和局部变量的区别?操作系统和编译器是怎么知道的?
- 全局变量是整个程序都可访问的变量,生存期在整个程序从运行到结束(在程序结束时所占内存释放);
- 局部变量存在于模块(子程序、函数)中,只有所在模块可以访问,其他模块不可直接访问,模块结束(函数调用完毕),局部变量消失,所占据的内存释放。
- 操作系统和编译器,可能是通过内存分配的位置知道的,全局变量分配在全局数据段并且在程序开始运行的时候被加载,局部变量则分配在堆栈里面。
C++的异常处理方法
在程序执行过程中,由于程序员的疏忽或是系统资源紧张等因素都有可能导致异常,任何程序都无法保证绝对的稳定,常见的异常有:
- 数组下标越界
- 除法计算时除数为0
- 动态分配空间时空间不足
- ...
如果不及时对这些异常进行处理,程序多数情况下都会崩溃。
(1)try、throw和catch关键字
C++中的异常处理机制主要使用try、throw和catch三个关键字,其在程序中的用法如下:
#include <iostream>
using namespace std;
int main()
{
double m = 1, n = 0;
try {
cout << "before dividing." << endl;
if (n == 0)
throw - 1; //抛出int型异常
else if (m == 0)
throw - 1.0; //拋出 double 型异常
else
cout << m / n << endl;
cout << "after dividing." << endl;
}
catch (double d) {
cout << "catch (double)" << d << endl;
}
catch (...) {
cout << "catch (...)" << endl;
}
cout << "finished" << endl;
return 0;
}
//运行结果
//before dividing.
//catch (...)
//finished
代码中,对两个数进行除法计算,其中除数为0。可以看到以上三个关键字,程序的执行流程是先执行try包裹的语句块,如果执行过程中没有异常发生,则不会进入任何catch包裹的语句块,如果发生异常,则使用throw进行异常抛出,再由catch进行捕获,throw可以抛出各种数据类型的信息,代码中使用的是数字,也可以自定义异常class。catch根据throw抛出的数据类型进行精确捕获(不会出现类型转换),如果匹配不到就直接报错,可以使用catch(...)的方式捕获任何异常(不推荐)。 当然,如果catch了异常,当前函数如果不进行处理,或者已经处理了想通知上一层的调用者,可以在catch里面再throw异常。
(2)函数的异常声明列表
有时候,程序员在定义函数的时候知道函数可能发生的异常,可以在函数声明和定义时,指出所能抛出异常的列表,写法如下:
int fun() throw(int,double,A,B,C){...};
这种写法表名函数可能会抛出int,double型或者A、B、C三种类型的异常,如果throw中为空,表明不会抛出任何异常,如果没有throw则可能抛出任何异常
(3)C++标准异常类 exception
C++ 标准库中有一些类代表异常,这些类都是从 exception 类派生而来的,如下图所示
- bad_typeid:使用typeid运算符,如果其操作数是一个多态类的指针,而该指针的值为 NULL,则会拋出此异常,例如:
#include <iostream>
#include <typeinfo>
using namespace std;
class A{
public:
virtual ~A();
};
using namespace std;
int main() {
A* a = NULL;
try {
cout << typeid(*a).name() << endl; // Error condition
}
catch (bad_typeid){
cout << "Object is NULL" << endl;
}
return 0;
}
//运行结果:bject is NULL
- bad_cast:在用 dynamic_cast 进行从多态基类对象(或引用)到派生类的引用的强制类型转换时,如果转换是不安全的,则会拋出此异常
- bad_alloc:在用 new 运算符进行动态内存分配时,如果没有足够的内存,则会引发此异常
- out_of_range:用 vector 或 string的at 成员函数根据下标访问元素时,如果下标越界,则会拋出此异常
总结:
C++中的异常情况: 语法错误(编译错误):比如变量未定义、括号不匹配、关键字拼写错误等等编译器在编译时能发现的错误,这类错误可以及时被编译器发现,而且可以及时知道出错的位置及原因,方便改正。 运行时错误:比如数组下标越界、系统内存不足等等。这类错误不易被程序员发现,它能通过编译且能进入运行,但运行时会出错,导致程序崩溃。为了有效处理程序运行时错误,C++中引入异常处理机制来解决此问题。
链接阶段是要报错的,链接器在各个目标文件、库中都找不到这个变量或函数的定义,一般就会报未定义错误
C++异常处理机制: 异常处理基本思想:执行一个函数的过程中发现异常,可以不用在本函数内立即进行处理, 而是抛出该异常,让函数的调用者直接或间接处理这个问题。 C++异常处理机制由3个模块组成:try(检查)、throw(抛出)、catch(捕获) 抛出异常的语句格式为:throw 表达式;如果try块中程序段发现了异常则抛出异常。
Debug和Release的区别是什么?
Debug:调试版本,包含调试信息,所以容量比Release大很多,并且不进行任何优化(优化会使调试复杂化,因为源代码和生成的指令间关系会更复杂),便于程序员调试。Debug模式下生成两个文件,除了.exe或.dll文件外,还有一个.pdb文件,该文件记录了代码中断点等调试信息
Release:发布版本,不对源代码进行调试,编译时对应用程序的速度进行优化,使得程序在代码大小和运行速度上都是最优的。(调试信息可在单独的PDB文件中生成)。Release模式下生成一个文件.exe或.dll文件
Debug 和 Release 并没有本质的界限,他们只是一组编译选项的集合,编译器只是按照预定的选项行动。
如何在不使用额外空间的情况下,交换两个数
1) 算术
x = x + y;
y = x - y;
x = x - y;
2) 异或,x = x^y;// 只能对int,char..
void swap(int& a,int& b){
a=a^b;
b=a^b;
a=a^b;
}
结构体内存对齐和为什么进行内存对齐
内存对齐原因:
- 平台原因(移植原因),不是所有的硬件平台都能任意访问地址上的任意数据的,某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常
- 性能原因,由于CPU读取数据是按块读取的,经过内存对齐后,CPU的访问效率会得到很大的提高。
内存对齐的一些规则:
- 分配内存的顺序是按照声明的顺序。
- 每个变量相对于起始位置的偏移量必须是该变量类型大小的整数倍,不是整数倍空出内存,直到偏移量是整数倍为止。
- 最后整个结构体的大小必须是里面变量类型最大值的整数倍。
- 如对内存对齐有明确要求,可用#pragma pack(n)指定,规则就变成了下面这样:
- 偏移量要是n和当前变量大小中较小值的整数倍
- 整体大小要是n和最大变量大小中较小值的整数倍
- n值必须为1,2,4,8…,为其他值时就按照默认的分配规则
其余需要注意的点:
一般来说,内存对齐过程对coding者来说是透明的,是由编译器控制完成的
结构体作为成员,如果一个结构体里有某些结构体成员,则结构体成员要从其内部最大元素大小的整数倍地址开始存储
union(联合体)类型中的数据共用内存,联合的所有成员共用一段内存空间,存储地址的起始位置都相同,一般来说最大成员的内存宽度作为union的内存大小,主要的原因是为了节省内存空间,默认的访问权限是公有的,但是它同样要遵守内存对齐的原则。
C++中空结构体占用 1Byte,C++中空类同样是占用 1Byte的内存空间,当声明该类型的实例的时候,必须在内存中占有一定的空间,否则无法使用这些实例,占用多少内存由编译器决定。
用的mingw64编译器,默认对齐值为8
#include <stdio.h>
#include <iostream>
using namespace std;
struct Test
{//设结构体中第一个成员的offset = 0x0000000000000000
char c; //offset = 0x0000000000000000. 区间:[0x0000000000000000,0x0000000000000000]
int i; //offset = min{8,4}的整数倍为0x0000000000000004. 区间:[0x0000000000000004,0x0000000000000007]
short s; //offset = min{8,2}的整数倍,0x0000000000000008. 区间:[0x0000000000000008,0x00000000000000009]
double d; //offset = min{8,8}的整数倍为16,0x0000000000000010. 区间:[0x0000000000000010,0x0000000000000017]
//整体占24字节,并且24 为 min{8,8}的整数倍,故对齐,无需在尾部填充占位。
};
int main()
{
cout << sizeof(char) << endl; // 1
cout << sizeof(short) << endl; // 2
cout << sizeof(int) << endl; // 4
cout << sizeof(double) << endl; // 8
Test test;
/*
cout<<"&test.c= "<<&test.c<<endl;//这里会出错
int型变量可以直接使用“&”获取地址,而char型变量取不行。
原因:&a是一个char*变量,应该指向一个字符串,并且字符串需要以终止符(“\0”)结尾,但&a却没有终止符,所以会输出后面的乱码
解决办法:1. 可以用printf代替cout输出
2. 将&a强制转换成(void *)&a
*/
cout<<"&test.c= "<<(void *)&test.c<<endl;
cout<<"&test.i= "<<&test.i<<endl;
cout<<"&test.s= "<<&test.s<<endl;
cout<<"&test.d= "<<&test.d<<endl;
cout<<endl<<"&test= "<<&test<<endl;
cout << "Test struct: " << sizeof(Test) << " " << sizeof(test) << endl;
system("pause");
return 0;
}
使用#pragma pack()
struct One{
double d;
char c;
int i;
}
struct Two{
char c;
double d;
int i;
}
在#pragma pack(4)和#pragma pack(8)的情况下,结构体的大小分别是:
16, 16; 16,24
1 #pragma pack (n)
2 //等价于上面的
3 //#pragma pack(push,n)
4 //作用:指定按n字节对齐
5 struct A{
6 char b; //1 byte
7 int a; //4 bytes
8 short c; //2 bytes
9 };
10 //#pragma pack(pop)
11 //等价于下面
12 //作用:取消指定对齐,恢复缺省对齐
13 #pragma pack()
-----------------------------------
如果上面的是按1个字节对齐,那么sizeof(struct A)的值为7;
如果是编译器默认的方式,sizeof(struct A)的值为12
extern "C"的用法(联想)
为了能够正确的在C++代码中调用C语言的代码:在程序中加上extern "C"后,相当于告诉编译器这部分代码是C语言写的,因此要按照C语言进行编译,而不是C++。
由于C++支持函数重载,因此编译器编译函数的过程中会把函数的参数类型也加到编译后的代码中,而不仅仅是函数名;而C语言并不支持函数重载,因此编译C语言代码的函数时不会带上函数的参数类型,一般指包括函数名。
哪些情况下使用extern "C":
(1)C++代码中调用C语言代码;
(2)在C++中的头文件中使用;
(3)在多个人协同开发时,可能有人擅长C语言,而有人擅长C++;
举个例子,C++中调用C代码:
#ifndef __MY_HANDLE_H__
#define __MY_HANDLE_H__
extern "C"{
typedef unsigned int result_t;
typedef void* my_handle_t;
my_handle_t create_handle(const char* name);
result_t operate_on_handle(my_handle_t handle);
void close_handle(my_handle_t handle);
}
综上,总结出使用方法**,在C语言的头文件中,对其外部函数只能指定为extern类型,C语言中不支持extern "C"声明,在.c文件中包含了extern "C"时会出现编译语法错误。**所以使用extern "C"全部都放在于cpp程序相关文件或其头文件中。
总结出如下形式:
(1)C++调用C函数:
//xx.h
extern int add(...)
//xx.c
int add(){
}
//xx.cpp
extern "C" {
#include "xx.h"
}
2)C调用C++函数
//xx.h
extern "C"{
int add();
}
//xx.cpp
int add(){
}
//xx.c
extern int add();
C和C++的类型安全
什么是类型安全?
类型安全很大程度上可以等价于内存安全,类型安全的代码不会试图访问自己没被授权的内存区域。“类型安全”常被用来形容编程语言,其根据在于该门编程语言是否提供保障类型安全的机制;有的时候也用“类型安全”形容某个程序,判别的标准在于该程序是否隐含类型错误。
类型安全的编程语言与类型安全的程序之间,没有必然联系。好的程序员可以使用类型不那么安全的语言写出类型相当安全的程序,相反的,差一点儿的程序员可能使用类型相当安全的语言写出类型不太安全的程序。绝对类型安全的编程语言暂时还没有。
(1)C的类型安全
C只在局部上下文中表现出类型安全,比如试图从一种结构体的指针转换成另一种结构体的指针时,编译器将会报告错误,除非使用显式类型转换。然而,C中相当多的操作是不安全的。以下是两个十分常见的例子:
- printf格式输出
上述代码中,使用%d控制整型数字的输出,没有问题,但是改成%f时,明显输出错误,再改成%s时,运行直接报segmentation fault错误
- malloc函数的返回值
malloc是C中进行内存分配的函数,它的返回类型是void*即空类型指针,常常有这样的用法char* pStr=(char*)malloc(100*sizeof(char)),这里明显做了显式的类型转换。
类型匹配尚且没有问题,但是一旦出现int* pInt=(int*)malloc(100*sizeof(char))就很可能带来一些问题,而这样的转换C并不会提示错误。
(2)C++的类型安全
如果C++使用得当,它将远比C更有类型安全性。相比于C语言,C++提供了一些新的机制保障类型安全:
操作符new返回的指针类型严格与对象匹配,而不是void*
C中很多以void*为参数的函数可以改写为C++模板函数,而模板是支持类型检查的;
引入const关键字代替#define constants,它是有类型、有作用域的,而#define constants只是简单的文本替换
一些#define宏可被改写为inline函数,结合函数的重载,可在类型安全的前提下支持多种类型,当然改写为模板也能保证类型安全
C++提供了dynamic_cast关键字,使得转换过程更加安全,因为dynamic_cast比static_cast涉及更多具体的类型检查。
例1:使用void*进行类型转换
例2:不同类型指针之间转换
#include<iostream>
using namespace std;
class Parent{};
class Child1 : public Parent
{
public:
int i;
Child1(int e):i(e){}
};
class Child2 : public Parent
{
public:
double d;
Child2(double e):d(e){}
};
int main()
{
Child1 c1(5);
Child2 c2(4.1);
Parent* pp;
Child1* pc1;
pp=&c1;
pc1=(Child1*)pp; // 类型向下转换 强制转换,由于类型仍然为Child1*,不造成错误
cout<<pc1->i<<endl; //输出:5
pp=&c2;
pc1=(Child1*)pp; //强制转换,且类型发生变化,将造成错误
cout<<pc1->i<<endl;// 输出:1717986918
return 0;
}
上面两个例子之所以引起类型不安全的问题,是因为程序员使用不得当。第一个例子用到了空类型指针void*,第二个例子则是在两个类型指针之间进行强制转换。因此,想保证程序的类型安全性,应尽量避免使用空类型指针void*,尽量不对两种类型指针做强制转换。
什么是字节序?如何用代码判断大小端存储?有什么用?如何转换?
[C/C++]大小端字节序转换程序 - cs_wu - 博客园
字节序是对象在内存中存储的方式。
大端模式,是指数据的高字节保存在内存的低地址中,而数据的低字节保存在内存的高地址中,这样的存储模式有点儿类似于把数据当作字符串顺序处理:地址由小向大增加,而数据从高位往低位放;
小端模式,是指数据的高字节保存在内存的高地址中,而数据的低字节保存在内存的低地址中。
例如:32bit的数字0x12345678,
我们可以用unsigned char buf[4]来表示value
Big-Endian: 低地址存放高位,如下:
高地址
---------------
buf[3] (0x78) -- 低位
buf[2] (0x56)
buf[1] (0x34)
buf[0] (0x12) -- 高位
---------------
低地址
Little-Endian: 低地址存放低位,如下:
高地址
---------------
buf[3] (0x12) -- 高位
buf[2] (0x34)
buf[1] (0x56)
buf[0] (0x78) -- 低位
--------------
低地址
例子:对于内存中存放的数0x12345678来说 如果是采用大端模式存放的,则其真实的数是:0x12345678 如果是采用小端模式存放的,则其真实的数是:0x78563412
所以在Socket编程中,往往需要将操作系统所用的小端存储的IP地址转换为大端存储,这样才能进行网络传输
了解了大小端存储的方式,如何在代码中进行判断呢?下面介绍两种判断方式:
方式一:使用强制类型转换-这种法子不错
#include <iostream>
using namespace std;
int main()
{
int a = 0x1234;
//由于int和char的长度不同,借助int型转换成char型,只会留下低地址的部分
char c = (char)(a);
if (c == 0x12)
cout << "big endian" << endl;
else if(c == 0x34)
cout << "little endian" << endl;
}
方式二:巧用union联合体
#include <iostream>
using namespace std;
//union联合体的重叠式存储,endian联合体占用内存的空间为每个成员字节长度的最大值
union endian
{
int a;
char ch;
};
int main()
{
endian value;
value.a = 0x1234;
//a和ch共用4字节的内存空间
if (value.ch == 0x12)
cout << "big endian"<<endl;
else if (value.ch == 0x34)
cout << "little endian"<<endl;
}
在网络编程中不同字节序的机器发送和接收的顺序不同。
如果称某个系统所采用的字节序为主机字节序,则它可能是小端模式的,也可能是大端模式的。而端口号和IP地址都是以网络字节序存储的,不是主机字节序,网络字节序都是大端模式。
要把主机字节序和网络字节序相互对应起来,需要对这两个字节存储优先顺序进行相互转化。
这里用到四个函数:htons(),ntohs(),htonl()和ntohl()。这四个地址分别实现网络字节序和主机字节序的转化,这里的h代表host,n代表network,s代表short,l代表long。通常16位的IP端口号用s代表,而IP地址用l来代表。
#include <arpa/inet.h>
uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);
htonl 表示 host to network long ,用于将主机 unsigned int 型数据转换成网络字节顺序; htons 表示 host to network short ,用于将主机 unsigned short 型数据转换成网络字节顺序; ntohl、ntohs 的功能分别与 htonl、htons 相反。
如图,i为int类型占4个字节,但只有1个字节的值为1,另外3个字节值为0;取出低地址上的值,当其为1时则为小端模式,为0时为大端模式。
[C&C++]大小端字节序转换程序 - 腾讯云开发者社区-腾讯云
什么是内存泄露,如何检测与避免
动态分配内存所开辟的空间,在使用完毕后未手动释放,导致一直占据该内存,即为内存泄漏。
内存泄漏是指由于疏忽或错误造成了程序未能释放掉不再使用的内存的情况。内存泄漏并非指内存在物理上消失,而是应用程序分配某段内存后,由于设计错误,失去了对该段内存的控制;
内存泄露的后果:性能下降到内存逐渐用完,导致另一个程序失败;
造成内存泄漏的几种原因:
1)类的构造函数和析构函数中new和delete没有配套
2)在释放对象数组时没有使用delete[],使用了delete
3)没有将基类的析构函数定义为虚函数,当基类指针指向子类对象时,如果基类的析构函数不是virtual,那么子类的析构函数将不会被调用,子类的资源没有正确释放,因此造成内存泄露
4)没有正确的清楚嵌套的对象指针
避免方法:
- 有new就有delete,有malloc就有free,保证它们一定成对出现
- 使用智能指针;
- 计数法:使用new或者malloc时,让该数+1,delete或free时,该数-1,程序执行完打印这个计数,如果不为0则表示存在内存泄露
- 对象数组的释放一定要用delete[]
- 将基类的析构函数设为虚函数;
检测工具:
- Linux下可以使用Valgrind工具
- Windows下可以使用CRT库;
对象复用的了解,零拷贝的了解
对象复用本质是一种设计模式:Flyweight享元模式。
通过将对象存储到“对象池”中实现对象的重复利用,这样可以避免多次创建重复对象的开销,节约系统资源。
零拷贝指的是在进行操作时,避免CPU从一处存储拷贝到另一处存储。零拷贝技术可以减少数据拷贝和共享总线操作的次数。在Linux中,我们可以减少数据在内核空间和用户空间的来回拷贝实现,比如通过调用mmap()来代替read调用。
用程序调用mmap(),磁盘上的数据会通过DMA被拷贝的内核缓冲区,接着操作系统会把这段内核缓冲区与应用程序共享,这样就不需要把内核缓冲区的内容往用户空间拷贝。应用程序再调用write(),操作系统直接将内核缓冲区的内容拷贝到socket缓冲区中,这一切都发生在内核态,最后,socket缓冲区再把数据发到网卡去。
比如,在C++中,vector的一个成员函数emplace_back()很好地体现了零拷贝技术,它跟push_back()函数一样可以将一个元素插入容器尾部,区别在于: 使用push_back()函数需要调用拷贝构造函数和转移构造函数,而使用emplace_back()插入的元素原地构造,不需要触发拷贝构造和转移构造,效率更高。
C++的调用惯例(简单一点C++函数调用的压栈过程)
函数的调用过程:
1)从栈空间分配存储空间
2)从实参的存储空间复制值到形参栈空间
3)进行运算
形参在函数未调用之前都是没有分配存储空间的,在函数调用结束之后,形参弹出栈空间,清除形参空间。
数组作为参数的函数调用方式是地址传递,形参和实参都指向相同的内存空间,调用完成后,形参指针被销毁,但是所指向的内存空间依然存在,不能也不会被销毁。
当函数有多个返回值的时候,不能用普通的 return 的方式实现,需要通过传回地址的形式进行,即地址/指针传递。
方法调用的原理(栈、汇编)
1)机器用栈来传递过程参数、存储返回信息、保存寄存器用于以后恢复,以及本地存储。而为单个过程分配的那部分栈称为帧栈;帧栈可以认为是程序栈的一段,它有两个端点,一个标识起始地址,一个标识着结束地址,两个指针结束地址指针esp,开始地址指针ebp;
2)由一系列栈帧构成,这些栈帧对应一个过程,而且每一个栈指针+4的位置存储函数返回地址;每一个栈帧都建立在调用者的下方,当被调用者执行完毕时,这一段栈帧会被释放。由于栈帧是向地址递减的方向延伸,因此如果我们将栈指针减去一定的值,就相当于给栈帧分配了一定空间的内存。如果将栈指针加上一定的值,也就是向上移动,那么就相当于压缩了栈帧的长度,也就是说内存被释放了。
3)过程实现
① 备份原来的帧指针,调整当前的栈帧指针到栈指针位置;
② 建立起来的栈帧就是为被调用者准备的,当被调用者使用栈帧时,需要给临时变量分配预留内存;
③ 使用建立好的栈帧,比如读取和写入,一般使用mov,push以及pop指令等等。
④ 恢复被调用者寄存器当中的值,这一过程其实是从栈帧中将备份的值再恢复到寄存器,不过此时这些值可能已经不在栈顶了
⑤ 恢复被调用者寄存器当中的值,这一过程其实是从栈帧中将备份的值再恢复到寄存器,不过此时这些值可能已经不在栈顶了。
⑥ 释放被调用者的栈帧,释放就意味着将栈指针加大,而具体的做法一般是直接将栈指针指向帧指针,因此会采用类似下面的汇编代码处理。
⑦ 恢复调用者的栈帧,恢复其实就是调整栈帧两端,使得当前栈帧的区域又回到了原始的位置。
⑧ 弹出返回地址,跳出当前过程,继续执行调用者的代码。
4)过程调用和返回指令
① call指令
② leave指令
③ ret指令
函数调用过程栈的变化,返回值和参数变量哪个先入栈?
1、调用者函数把被调函数所需要的参数按照与被调函数的形参顺序相反的顺序压入栈中,即:从右向左依次把被调函数所需要的参数压入栈;
2、调用者函数使用call指令调用被调函数,并把call指令的下一条指令的地址当成返回地址压入栈中(这个压栈操作隐含在call指令中);
3、在被调函数中,被调函数会先保存调用者函数的栈底地址(push ebp),然后再保存调用者函数的栈顶地址,即:当前被调函数的栈底地址(mov ebp,esp);
4、在被调函数中,从ebp的位置处开始存放被调函数中的局部变量和临时变量,并且这些变量的地址按照定义时的顺序依次减小,即:这些变量的地址是按照栈的延伸方向排列的,先定义的变量先入栈,后定义的变量后入栈;
调试程序的方法
- 通过设置断点进行调试
- 打印log进行调试
- 打印中间结果进行调试
遇到coredump要怎么调试
coredump是程序由于异常或者bug在运行时异常退出或者终止,在一定的条件下生成的一个叫做core的文件,这个core文件会记录程序在运行时的内存,寄存器状态,内存指针和函数堆栈信息等等。对这个文件进行分析可以定位到程序异常的时候对应的堆栈调用信息。
- 使用gdb命令对core文件进行调试
以下例子在Linux上编写一段代码并导致segment fault 并产生core文件
mkdir coredumpTest
vim coredumpTest.cpp
在编辑器内键入
#include<stdio.h>
int main(){
int i;
scanf("%d",i);//正确的应该是&i,这里使用i会导致segment fault
printf("%d\n",i);
return 0;
}
编译
g++ coredumpTest.cpp -g -o coredumpTest //-g指示编译器在编译时产生调试信息
运行
./coredumpTest
使用gdb调试coredump
gdb [可执行文件名] [core文件名]
segmentation fault(core dumped)
Segmentation fault (core dumped)多为内存不当操作造成。空指针、野指针的读写操作,数组越界访问,破坏常量等。对每个指针声明后进行初始化为NULL是避免这个问题的好办法。排除此问题的最好办法则是调试。
更为详细的原因:
- 内存访问越界:
- a) 由于使用错误的下标,导致数组访问越界
- b) 搜索字符串时,依靠字符串结束符来判断字符串是否结束,但是字符串没有正常的使用结束符
- c) 使用strcpy, strcat, sprintf, strcmp, strcasecmp等字符串操作函数,将目标字符串读/写爆。应该使用strncpy, strlcpy, strncat, strlcat, snprintf, strncmp, strncasecmp等函数防止读写越界。
- 多线程程序使用了线程不安全的函数
- 多线程读写的数据未加锁保护;
- 非法使用指针
- 使用空指针
- 随意使用指针转换
- 堆栈溢出:不要使用大的局部变量(因为局部变量都分配在栈上),这样容易造成堆栈溢出,破坏系统的栈和堆结构,导致出现莫名其妙的错误。
C++将临时变量作为返回值时的处理过程
首先需要明白一件事情,临时变量,在函数调用过程中是被压到程序进程的栈中的,当函数退出时,临时变量出栈,即临时变量已经被销毁,临时变量占用的内存空间没有被清空,但是可以被分配给其他变量,所以有可能在函数退出时,该内存已经被修改了,对于临时变量来说已经是没有意义的值了
C语言里规定:16bit程序中,返回值保存在ax寄存器中,32bit程序中,返回值保持在eax寄存器中,如果是64bit返回值,edx寄存器保存高32bit,eax寄存器保存低32bit
由此可见,函数调用结束后,返回值被临时存储到寄存器中,并没有放到堆或栈中,也就是说与内存没有关系了。当退出函数的时候,临时变量可能被销毁,但是返回值却被放到寄存器中与临时变量的生命周期没有关系。
如果我们需要返回值,一般使用赋值语句就可以了。
如何获得结构成员相对于结构开头的字节偏移量
使用<stddef.h>头文件中, offsetof宏。
#include <iostream>
#include <stddef.h>
using namespace std;
struct S
{
int x;
char y;
int z;
double a;
};
int main()
{
cout << offsetof(S, x) << endl; // 0
cout << offsetof(S, y) << endl; // 4
cout << offsetof(S, z) << endl; // 8
cout << offsetof(S, a) << endl; // 12
return 0;
}
在Visual Studio 2019 + Win10 下的输出情况如下
cout << offsetof(S, x) << endl; // 0
cout << offsetof(S, y) << endl; // 4
cout << offsetof(S, z) << endl; // 8
cout << offsetof(S, a) << endl; // 16 这里是 16的位置,因为 double是8字节,需要找一个8的倍数对齐,
当然了,如果加上 #pragma pack(4) 指定4字节对齐方式就可以了。
#pragma pack(4)
struct S
{
int x;
char y;
int z;
double a;
};
void test02()
{
cout << offsetof(S, x) << endl; // 0
cout << offsetof(S, y) << endl; // 4
cout << offsetof(S, z) << endl; // 8
cout << offsetof(S, a) << endl; // 12
}
S结构体中各个数据成员的内存空间划分如下所示,需要注意内存对齐
指针加减计算要注意什么?
指针加减本质是对其所指地址的移动,移动的步长跟指针的类型是有关系的,因此在涉及到指针加减运算需要十分小心,加多或者减多都会导致指针指向一块未知的内存地址,如果再进行操作就会很危险。
#include <iostream>
using namespace std;
int main()
{
int *a, *b, c;
a = (int*)0x500;
b = (int*)0x520;
c = b - a;
printf("%d\n", c); // 8
a += 0x020;
c = b - a;
printf("%d\n", c); // -24
return 0;
}
首先变量a和b都是以16进制的形式初始化,将它们转成10进制分别是1280(5*16^2=1280)和1312(5*16^2+2*16=1312), 那么它们的差值为32,也就是说a和b所指向的地址之间间隔32个位,但是考虑到是int类型占4位,所以c的值为32/4=8
a自增16进制0x20之后,其实际地址变为1280 + 2*16*4 = 1408,(因为一个int占4位,所以要乘4),这样它们的差值就变成了1312 - 1280 = -96,所以c的值就变成了-96/4 = -24
遇到指针的计算,需要明确的是指针每移动一位,它实际跨越的内存间隔是指针类型的长度,建议都转成10进制计算,计算结果除以类型长度取得结果
信号处理
可以通过按 Ctrl+C 产生中断,产生SIGINT信号;
#include<>和#include""区别
- 使用尖括号
< >
,编译器会到系统路径下查找头文件; - 而使用双引号
" "
,编译器首先在当前目录下查找头文件,如果没有找到,再到系统路径下查找。
printf函数的实现原理是什么?
- 在C/C++中,对函数参数的扫描是从后向前的。C/C++的函数参数值通过压入堆栈的方式来给函数传参数的。
- 最先压⼊的参数最后出来,在计算机的内存中,数据有 2 块,⼀块是堆,⼀块是栈(函数参数及局部变量在这⾥),⽽栈是从内存的⾼地址向低地址⽣⻓的,控制⽣⻓的就是堆栈指针了,最先压⼊的参数是在最上⾯,就是说在所有参数的最后⾯,最后压⼊的参数在最下⾯,结构上看起来是第⼀个,所以最后压⼊的参数总是能够被函数找到。
- 因为它就在堆栈指针的上方。printf的第一个被找到的参数就是那个字符指针,就是被双引号括起来的那一部分,函数通过判断字符串里控制参数的个数来判断参数个数以及数据类型,通过这些可以算出数据需要的堆栈指针的偏移变量了
cout和printf的区别
(198条消息) iostream 缓冲区详解(cin和cout)_codedoctor的博客-CSDN博客_cin cout iostream
cout<<是一个函数,cout<<后可以跟不同的类型是因为cout<<已存在针对各种类型数据的重载,所以会自动识别数据的类型。
输出过程会首先将输出字符放入缓冲区,然后输出到屏幕。
cout是有缓冲输出:
cout < < "abc " < <endl;
或cout < < "abc\n "; cout < <flush; 这两个才是一样的.
flush立即强迫缓冲输出。
printf是无缓冲输出。有输出时立即输出。
程序在执行int main(int argc, char *argv[])时的内存结构,你了解吗?
参数的含义是程序在命令行下运行的时候,需要输入argc 个参数,每个参数是以char 类型输入的,依次存在数组里面,数组是 argv[],所有的参数在指针
char * 指向的内存中,数组的中元素的个数为 argc 个,第一个参数为程序的名称。
核心编程
构造函数和析构函数
1)构造函数只是起初始化值的作用,但实例化一个对象的时候,可以通过实例去传递参数,从主函数传递到其他的函数里面,这样就使其他的函数里面有值了。
规则,只要你一实例化对象,系统自动回调用一个构造函数就是你不写,编译器也自动调用一次。
2)析构函数:用于撤销对象的一些特殊任务处理,可以是释放对象分配的内存空间;特点:析构函数与构造函数同名,但该函数前面加~。析构函数没有参数,也没有返回值,而且不能重载,在一个类中只能有一个析构函数。 当撤销对象时,编译器也会自动调用析构函数。每一个类必须有一个析构函数,用户可以自定义析构函数,也可以是编译器自动生成默认的析构函数。一般析构函数定义为类的公有成员。
类什么时候会析构?
- 对象生命周期结束,被销毁时;
- delete指向对象的指针时,或delete指向对象的基类类型指针,而其基类虚构函数是虚函数时;
- 对象i是对象o的成员,o的析构函数被调用时,对象i的析构函数也被调用
public,protected和private访问权限和继承权限/public/protected/private的区别?
- public的变量和函数在类的内部外部都可以访问。
- protected的变量和函数只能在类的内部和其派生类中访问。
- private修饰的元素只能在类内和友元函数访问。
(一)访问权限
派生类可以继承基类中除了构造/析构、赋值运算符重载函数之外的成员。三种派生方式的访问权限如下表所示:注意外部访问并不是真正的外部访问,而是在通过派生类的对象对基类成员的访问。
派生类对基类成员的访问形式有如下两种:
- 内部访问:由派生类中新增的成员函数对从基类继承来的成员的访问
- 外部访问:在派生类外部,通过派生类的对象对从基类继承来的成员的访问
(二)继承权限
public继承:
- 基类的 public 和 protected 成员的访问属性在派生类中保持不变,但基类的 private 成员不可直接访问。
- 派生类中的成员函数可以直接访问基类中的 public 和 protected 成员,但不能直接访问基类的 private 成员。
- 通过派生类的对象只能访问基类的 public 成员。
protected继承:
- 基类的 public 和 protected 成员都以 protected 身份出现在派生类中,但基类的 private 成员不可直接访问。
- 派生类中的成员函数可以直接访问基类中的 public 和 protected 成员,但不能直接访问基类的 private 成员。
- 通过派生类的对象不能直接访问基类中的任何成员
private继承:
- 基类的 public 和 protected 成员都以 private 身份出现在派生类中,但基类的 private 成员不可直接访问。
- 派生类中的成员函数可以直接访问基类中的 public 和 protected 成员,但不能直接访问基类的 private 成员。
- 通过派生类的对象不能直接访问基类中的任何成员。
总结:
一、访问权限
访问权限 | 外部 | 派生类 | 内部 |
---|---|---|---|
public | ✔ | ✔ | ✔ |
protected | ❌ | ✔ | ✔ |
private | ❌ | ❌ | ✔ |
public、protected、private 的访问权限范围关系:
public > protected > private
二、继承权限
- 派生类继承自基类的成员权限有四种状态:public、protected、private、不可见
- 派生类对基类成员的访问权限取决于两点:一、继承方式;二、基类成员在基类中的访问权限
- 派生类对基类成员的访问权限是取以上两点中的更小的访问范围(除了 private 的继承方式遇到 private 成员是不可见外)。例如:
- public 继承 + private 成员 => private
- private 继承 + protected 成员 => private
- private 继承 + private 成员 => 不可见
C++中重载、重写(覆盖)和隐藏的区别
1)重载overload
重载是指同一范围定义中的同名函数才存在重载关系。主要特点是函数名相同,参数类型和数目有所不同,它们的返回值可以不同,但返回值不可以作为区分不同重载函数的标志。重载和函数成员是否是虚函数无关。
class A{
...
virtual int fun();
void fun(int);
void fun(double, double);
static int fun(char);
...
}
2)重写override
重写指的是在派生类中覆盖基类中的同名函数,重写就是重写函数体,要求基类函数必须是虚函数且:
- 与基类的虚函数有相同的参数个数
- 与基类的虚函数有相同的参数类型
- 函数返回值有以下要求:
- 如果虚函数返回值类型是基本数据类型:返回值必须相同;
- 如果虚函数的返回值类型是类本身的指针或引用:返回值类型可以不同,派生类虚函数的返回类型可以是Base类或者Base派生类的指针或引用。
class A {
public:
int a;
public:
A(int num) :a(num) {};
virtual A& func() {}; //虚函数
virtual int fun(int a){}
};
class B:public A{
public:
int b;
public:
B(int num) :A(num) {};
//重写,一般加override可以确保是重写父类的函数
virtual int fun(int a) override{}
virtual B& func() {}; //重写了基类的虚函数
};
重载与重写的区别:
- 重载是指同一范围定义中的同名函数才存在重载关系(比如在一个类中)。主要特点是函数名相同,参数列表不同,返回值不要求。重写指的是在派生类中覆盖基类中的同名函数(在两个类中),参数列表相同,重写就是重写函数体,要求基类函数必须是虚函数。
- 重写只发生在类的成员函数中,重载可以是类的成员函数,也可以是普通函数。
- 重写关系中,调用方法根据对象类型决定,重载根据调用时实参表与形参表的对应关系来选择函数体
- 重写发生在运行时(因为在编译时,编译器无法知道我们到底是调用父类的方法还是子类的方法,只有实际运行时才知道调用哪个方法),重载发生在编译时(编译过程中,编译器必须根据参数类型和长度来确定调用哪个方法);
3)隐藏
覆盖(重写)与隐藏的关系:
- 隐藏可以适用于成员变量和函数,但是覆盖只能用于函数
- 覆盖(重写)在多态中有很重要的作用
隐藏指的是某些情况下,派生类中的函数屏蔽了基类中的同名函数(不同类中),包括以下情况:
- 两个函数参数相同,但是基类函数不是虚函数。和重写的区别在于基类函数是否是虚函数。 举个例子:
//父类
class A{
public:
void fun(int a){
cout << "A中的fun函数" << endl;
}
};
//子类
class B : public A{
public:
//隐藏父类的fun函数
void fun(int a){
cout << "B中的fun函数" << endl;
}
};
int main(){
B b;
b.fun(2); //调用的是B中的fun函数
b.A::fun(2); //调用A中fun函数
return 0;
}
- 两个函数参数不同,无论基类函数是不是虚函数,都会被隐藏。和重载的区别在于两个函数不在同一个类中。举个例子:
//父类
class A{
public:
virtual void fun(int a){
cout << "A中的fun函数" << endl;
}
};
//子类
class B : public A{
public:
//隐藏父类的fun函数
virtual void fun(char* a){
cout << "A中的fun函数" << endl;
}
};
int main(){
B b;
b.fun(2); //报错,调用的是B中的fun函数,参数类型不对
b.A::fun(2); //调用A中fun函数
return 0;
}
注意: 基类指针指向派生类对象时,基类指针可以直接调用到派生类的覆盖函数,也可以通过 :: 调用到基类被覆盖的虚函数;而基类指针只能调用基类的被隐藏函数,无法识别派生类中的隐藏函数。
// 父类
class A {
public:
virtual void fun(int a) { // 虚函数
cout << "This is A fun " << a << endl;
}
void add(int a, int b) {
cout << "This is A add " << a + b << endl;
}
};
// 子类
class B: public A {
public:
void fun(int a) override { // 覆盖
cout << "this is B fun " << a << endl;
}
void add(int a) { // 隐藏
cout << "This is B add " << a + a << endl;
}
};
int main() {
// 基类指针指向派生类对象时,基类指针可以直接调用到派生类的覆盖函数,也可以通过 :: 调用到基类被覆盖
// 的虚函数;而基类指针只能调用基类的被隐藏函数,无法识别派生类中的隐藏函数。
A *p = new B();
p->fun(1); // 调用子类 fun 覆盖函数
p->A::fun(1); // 调用父类 fun
p->add(1, 2);
// p->add(1); // 错误,识别的是 A 类中的 add 函数,参数不匹配
// p->B::add(1); // 错误,无法识别子类 add 函数
return 0;
}
介绍面对对象的三大特性,并举例说明
三大特性:封装、继承、多态。
1)封装
将数据和操作数据的方法进行有机结合,封装隐藏了类的实现细节和成员数据,仅对外公开接口来和对象进行交互,实现了代码模块化,如类里面的private和public;
2)继承
继承使得子类可以复用父类的成员和方法,实现了代码重用;在使用现有类的功能基础上,也不可以在不需要重新编写原来的类的情况下对这些功能进行扩展
三种继承方式:
继承方式 | private继承 | protected继承 | public继承 |
---|---|---|---|
基类的private成员 | 不可见 | 不可见 | 不可见 |
基类的protected成员 | 变为private成员 | 仍为protected成员 | 仍为protected成员 |
基类的public成员 | 变为private成员 | 变为protected成员 | 仍为public成员仍为public成员 |
例如,将人定义为一个抽象类,拥有姓名、性别、年龄等公共属性,吃饭、睡觉、走路等公共方法,在定义一个具体的人时,就可以继承这个抽象类,既保留了公共属性和方法,也可以在此基础上扩展跳舞、唱歌等特有方法
3)多态
多态则是“一个接口,多个实现”,通过父类调用子类的成员,实现了接口重用,如父类的指针指向子类的对象。
同一事物表现出不同事物的能力,即向不同对象发送同一消息,不同的对象在接收时会产生不同的行为**(模板和重载实现编译时多态,虚函数实现运行时多态)**。
实现多态有二种方式:覆盖(override),重载(overload)。
覆盖:是指子类重新定义父类的虚函数的做法。
重载:是指允许存在多个同名函数,而这些函数的参数表不同(或许参数个数不同,或许参数类型不同,或许两者都不同)。
例如:基类是一个抽象对象——人,那教师、运动员也是人,而使用这个抽象对象既可以表示教师、也可以表示运动员。
多态的实现方式?动态多态作用?有哪些必要条件?如何实现动态绑定( 实现编译器处理虚函数表应该如何处理)
1)多态的实现方式
多态分为静态多态和动态多态。其中,静态多态是通过重载和模板技术实现的,在编译期间确定;动态多态是通过虚函数和继承关系实现的,执行动态绑定,在运行期间确定;
2)动态多态作用,有哪些必要条件
动态多态作用:
- 隐藏实现细节,使代码模块化,提高代码可复用性;
- 接口重用,使派生类的功能可以被基类的指针/引用所调用,即向后兼容,提高代码的可扩充性和可维护性。
动态多态的必要条件:
- 需要有继承;
- 需要有虚函数覆盖;
- 需要有基类指针/引用指向子类对象;
3)动态绑定的实现( 实现编译器处理虚函数表应该如何处理)
当编译器发现类中有虚函数时,会创建一张虚函数表,把虚函数的函数入口地址放到虚函数表中,并且在对象中增加一个虚表指针vptr,用于指向类的虚函数表,通过这个虚函数表指针找到类中的虚函数表,找到相应的函数进行调用。当派生类覆盖基类的虚函数时,会将虚函数表中对应的指针进行替换,从而调用派生类中覆盖后的虚函数,从而实现动态绑定。
静态绑定和动态绑定
静态绑定也就是将该对象相关的属性或函数绑定为它的静态类型,也就是它在声明的类型,在编译的时候就确定。在调用的时候编译器会寻找它声明的类型进行访问。
动态绑定就是将该对象相关的属性或函数绑定为它的动态类型,具体的属性或函数在运行期确定,通常通过虚函数实现动态绑定。
除了指针,引用可以实现动态绑定。引用在创建的时候必须初始化,在访问虚函数时,编译器会根据其所绑定的对象类型决定要调用哪个函数。注意只能调用虚函数。
#include <iostream>
using namespace std;
class Base{
public:
virtual void fun(){
cout << " Base::func()" <<endl;
}
};
class Son1 : public Base{
public:
virtual void fun() override{
cout << " Son1::func()" <<endl;
}
};
class Son2 : public Base{
};
int main()
{
Base* base = new Son1;
base->fun();
base = new Son2;
base->fun();
delete base;
base = NULL;
return 0;
}
// 运行结果
// Son1::func()
// Base::func()
总结一下静态绑定和动态绑定的区别:
- 静态绑定发生在编译期,动态绑定发生在运行期;
- 对象的动态类型可以更改,但是静态类型无法更改;
- 在继承体系中只有虚函数使用的是动态绑定,其他的全部是静态绑定;
纯虚函数是什么?有什么作用?如何实现?
1)纯虚函数是只有声明没有实现的虚函数,是对子类的约束,是接口继承。包含纯虚函数的类是抽象类,它不能被实例化,只有实现了这个纯虚函数的子类才能生成对象。
使用场景:当这个类本身(比如动物)产生一个实例没有意义的情况下,把这个类的函数实现为纯虚函数。
2)定义纯虚函数是为了实现一个接口,起到规范的作用,想要继承这个类必须覆盖该函数。
3)实现方式:在虚函数声明的结尾加上 = 0
即可。
对虚函数(虚函数表、虚函数指针、虚函数的实现原理)和多态的理解
C++ 多态包括编译时多态(静态多态)和运行时多态(动态多态),编译时多态体现在函数重载和模板上,在编译时就已经确定,运行时多态体现在虚函数上,在运行期间动态绑定。
虚函数:是为了实现多态,在基类的函数前加上virtual关键字,在派生类中重写该函数,运行时将会根据对象的实际类型来调用相应的函数。如果对象类型是派生类,就调用派生类的函数;如果对象类型是基类,就调用基类的函数。在父类中声明为加了virtual关键字的函数,在子类中重写时候不需要加virtual也是虚函数。
C++的虚函数是实现多态的机制。它是通过虚函数表实现的,虚函数表是每个类中存放虚函数地址的指针数组,类的实例在调用函数时会在虚函数表中寻找函数地址进行调用,如果子类覆盖了父类的函数,则子类的虚函数表会指向子类实现的函数地址,否则指向父类的函数地址。一个类的所有实例都共享同一张虚函数表。
虚表指针: 在含有虚函数的类实例化对象时,对象地址的前四个字节存储的指向虚表的指针。
上图中展示了虚表和虚表指针在基类对象和派生类对象中的模型,下面阐述实现多态的过程:
**(1)**编译器在发现基类中有虚函数时,会自动为每个含有虚函数的类生成一份虚表,该表是一个一维数组,虚表里保存了虚函数的入口地址
(2)编译器会在每个对象的前四个字节中保存一个虚表指针,即vptr,指向对象所属类的虚表。在构造时,根据对象的类型去初始化虚指针vptr,从而让vptr指向正确的虚表,从而在调用虚函数时,能找到正确的函数
**(3)**所谓的合适时机,在派生类定义对象时,程序运行会自动调用构造函数,在构造函数中创建虚表并对虚表初始化。在构造子类对象时,会先调用父类的构造函数,此时,编译器只“看到了”父类,并为父类对象初始化虚表指针,令它指向父类的虚表;当调用子类的构造函数时,为子类对象初始化虚表指针,令它指向子类的虚表
**(4)**当派生类对基类的虚函数没有重写时,派生类的虚表指针指向的是基类的虚表;当派生类对基类的虚函数重写时,派生类的虚表指针指向的是自身的虚表;当派生类中有自己的虚函数时,在自己的虚表中将此虚函数地址添加在后面
这样指向派生类的基类指针在运行时,就可以根据派生类对虚函数重写情况动态的进行调用,从而实现多态性。
虚函数表是针对类的还是针对对象的?同一个类的两个对象的虚函数表如何维护?
虚函数表是针对类的,类的所有对象共享这个类的虚函数表,因为每个对象内部都保存一个指向该类虚函数表的指针vptr
,每个对象的 vptr
的存放地址都不同,但都指向同一虚函数表。
基类的虚函数表存放在内存的什么区,虚表指针vptr的初始化时间
首先整理一下虚函数表的特征:
- 虚函数表是全局共享的元素,即全局仅有一个,在编译时就构造完成
- 虚函数表类似一个数组,类对象中存储vptr指针,指向虚函数表,即虚函数表不是函数,不是程序代码,不可能存储在代码段
- 虚函数表存储虚函数的地址,即虚函数表的元素是指向类成员函数的指针,而类中虚函数的个数在编译时期可以确定,即虚函数表的大小可以确定,即大小是在编译时期确定的,不必动态分配内存空间存储虚函数表,所以不在堆中
根据以上特征,虚函数表类似于类中静态成员变量。静态成员变量也是全局共享,大小确定,因此最有可能存在全局数据区,测试结果显示:
虚函数表vtable在Linux/Unix中存放在可执行文件的只读数据段中(rodata),这与微软的编译器将虚函数表存放在常量段存在一些差别。
由于虚表指针vptr跟虚函数密不可分,对于有虚函数或者继承于拥有虚函数的基类,对该类进行实例化时,在构造函数执行时会对虚表指针进行初始化,并且存在对象内存布局的最前面。
C++中虚函数表位于只读数据段(.rodata),也就是C++内存模型中的常量区;而虚函数则位于代码段(.text),也就是C++内存模型中的代码区。
为什么基类的构造函数不能定义为虚函数?
1)因为创建一个对象时需要确定对象的类型,而虚函数是在运行时确定其类型的。而在构造一个对象时,由于对象还未创建成功,编译器无法知道对象的实际类型,是类本身还是类的派生类等等
2)虚函数的调用需要虚函数表指针,而该指针存放在对象的内存空间中;若构造函数声明为虚函数,那么由于对象还未创建,还没有内存空间,更没有虚函数表地址用来调用虚函数即构造函数了。
为什么基类的析构函数需要定义为虚函数?
首先析构函数可以为虚函数,当析构一个指向子类的父类指针时,编译器可以根据虚函数表寻找到子类的析构函数进行调用,从而正确释放子类对象的资源。
如果析构函数不被声明成虚函数,则编译器实施静态绑定,在删除指向子类的父类指针时,只会调用父类的析构函数而不调用子类析构函数,这样就会造成子类对象析构不完全造成内存泄漏。
同时,析构函数可以是纯虚函数,含有纯虚函数的类是抽象类,此时不能被实例化。但派生类中可以根据自身需求重新改写基类中的纯虚函数。
#include <iostream>
using namespace std;
class Parent{
public:
Parent(){
cout << "Parent construct function" << endl;
};
virtual ~Parent(){
cout << "Parent destructor function" <<endl;
}
};
class Son : public Parent{
public:
Son(){
cout << "Son construct function" << endl;
};
~Son(){
cout << "Son destructor function" <<endl;
}
};
int main()
{
Parent* p = new Son();
delete p;
p = NULL;
return 0;
}
//运行结果:
//Parent construct function
//Son construct function
//Son destructor function
//Parent destructor function
构造函数和析构函数能抛出异常吗?(华为)
- 从语法的角度来说,构造函数可以抛出异常,但从逻辑和风险控制的角度来说,尽量不要抛出异常,否则可能导致内存泄漏。
- 析构函数不可以抛出异常,如果析构函数抛出异常,则异常点之后的程序,比如释放内存等操作,就不会被执行,从而造成内存泄露的问题;而且当异常发生时,C++通常会调用对象的析构函数来释放资源,如果此时析构函数也抛出异常,即前一个异常未处理又出现了新的异常,从而造成程序崩溃的问题。
构造函数、析构函数、虚函数可否声明为内联函数
将构造函数和析构函数声明为inline是没有什么意义的,即编译器并不真正对声明为inline的构造和析构函数进行内联操作,因为编译器会在构造和析构函数中添加额外的操作(申请/释放内存,构造/析构对象等),致使构造函数/析构函数并不像看上去的那么精简。其次,class中的函数默认是inline型的,编译器也只是有选择性的inline,将构造函数和析构函数声明为内联函数是没有什么意义的。
将虚函数声明为inline,要分情况讨论,当是指向派生类的指针(多态性)调用声明为inline的虚函数时,不会内联展开(inline编译期决定,虚函数运行期决定);当是对象本身调用虚函数时,会内联展开,当然前提依然是函数并不复杂的情况下。
如何让一个类不能实例化
将类定义为抽象类(即存在纯虚函数)或者将构造函数声明为private。
多继承存在什么问题?如何消除多继承中的二义性?
增加程序的复杂度,使程序的编写和维护比较困难,容易出错;
在继承时,基类之间或基类和派生类之间发生成员同名时,将出现对成员访问的不确定性,即同名二义性。消除同名二义性方法:
- 利用作用域运算符
::
,用于限定派生类使用的是那个基类的成员; - 在派生类中定义同名成员,覆盖基类中的相关成员;
- 利用作用域运算符
当派生类从多个基类派生,而这些基类又是从同一个基类派生,则在访问此共同基类的成员时,将产生另一种不确定性,即路径二义性。消除路径二义性:
消除同名二义性的两种方法都可以;
使用虚继承,使得不同路径继承来的同名成员在内存中只有一份拷贝。
虚基类是用关键字virtual声明继承的父类,即便该基类在多条链路上被一个子类继承,但是该子类中只包含一个该虚基类的备份,虚基类主要用来解决继承中的二义性问题,这就是是虚基类的作用所在。
虚拟继承
#include <iostream>
using namespace std;
class A{}
class B : virtual public A{};
class C : virtual public A{};
class D : public B, public C{};
int main()
{
cout << "sizeof(A):" << sizeof A <<endl; // 1,空对象,只有一个占位
cout << "sizeof(B):" << sizeof B <<endl; // 4,一个bptr指针,省去占位,不需要对齐
cout << "sizeof(C):" << sizeof C <<endl; // 4,一个bptr指针,省去占位,不需要对齐
cout << "sizeof(D):" << sizeof D <<endl; // 8,两个bptr,省去占位,不需要对齐
}
上述代码所体现的关系是,B和C虚拟继承A,D又公有继承B和C,这种方式是一种菱形继承或者钻石继承,可以用如下图来表示
**虚拟继承的情况下,无论基类被继承多少次,只会存在一个实体。**虚拟继承基类的子类中,子类会增加某种形式的指针,或者指向虚基类子对象,或者指向一个相关的表格;表格中存放的不是虚基类子对象的地址,就是其偏移量,此类指针被称为bptr,如上图所示。如果既存在vptr又存在bptr,某些编译器会将其优化,合并为一个指针。
C++的空类有哪些成员函数
缺省构造函数;
缺省拷贝构造函数;
缺省析构函数;
赋值运算符;
计算下面几个类的大小
class A {};
int main(){
cout<<sizeof(A)<<endl;// 输出 1;
A a;
cout<<sizeof(a)<<endl;// 输出 1;
return 0;
}
空类的大小是1, 在C++中空类会占一个字节,因为编译器需要区分这个空类的不同实例,分配⼀个字节,可以使这个空类的不同实例拥有独⼀⽆⼆的地址。当该空白类作为基类时,该类的大小就优化为0了,子类的大小就是子类本身的大小。这就是所谓的空白基类最优化。
空类的实例大小就是类的大小,所以sizeof(a)=1字节,如果a是指针,则sizeof(a)就是指针的大小,即4字节。
class A { virtual Fun(){} };
int main(){
cout<<sizeof(A)<<endl;// 输出 4(32位机器)/8(64位机器);
A a;
cout<<sizeof(a)<<endl;// 输出 4(32位机器)/8(64位机器);
return 0;
}
因为有虚函数的类对象中都有一个虚函数表指针 __vptr,其大小是4字节
class A { static int a; };
int main(){
cout<<sizeof(A)<<endl;// 输出 1;
A a;
cout<<sizeof(a)<<endl;// 输出 1;
return 0;
}
静态成员存放在静态存储区,不占用类的大小, 普通函数也不占用类大小。
class A { int a; };
int main(){
cout<<sizeof(A)<<endl;// 输出 4;
A a;
cout<<sizeof(a)<<endl;// 输出 4;
return 0;
}
class A { static int a; int b; };
int main(){
cout<<sizeof(A)<<endl;// 输出 4;
A a;
cout<<sizeof(a)<<endl;// 输出 4;
return 0;
}
静态成员a不占用类的大小,所以类的大小就是b变量的大小 即4个字节。
Noting:
成员函数不占用对象的内存。这是因为所有的函数都是存放在代码区的,不管是全局函数,还是成员函数。
静态成员函数与一般成员函数的唯一区别就是没有this指针,因此不能访问非静态数据成员。所有函数都存放在代码区,静态函数也不例外。所有有人一看到 static 这个单词就主观的认为是存放在全局数据区,那是不对的。
类对象大小影响因素:
- 类的非静态成员变量大小,静态成员不占据类的空间,成员函数也不占据类的空间大小;
- 内存对齐另外分配的空间大小,类内的数据也是需要进行内存对齐操作的;
- 虚函数的话,会在类对象插入vptr指针,加上指针大小;
- 当该类是某类的派生类,那么派生类继承的基类部分的数据成员也会存在在派生类中的空间中,也会对派生类进行扩展。
静态成员函数可以直接访问非静态数据成员吗?
不可以,静态成员函数只是和类实现了绑定,而没有和任何对象绑定在一起,不包含this指针,无法访问静态成员。(静态成员函数所需内存在程序执行前就分配好了,给静态成员必须要等到这个类在堆/栈上分配内存才能使用,所以如果静态成员函数访问非静态,可能非静态成员还没有内存)
关于this指针
- this指针是类的指针,指向对象的首地址。
- this指针只能在成员函数中使用,在全局函数、静态成员函数中都不能用this。
- this指针只有在成员函数中才有定义,且存储位置会因编译器不同有不同存储位置
this指针的用处
一个对象的this指针并不是对象本身的一部分,不会影响 sizeof(对象) 的结果。this作用域是在类内部,当在类的非静态成员函数中访问类的非静态成员的时候(全局函数,静态函数中不能使用this指针),编译器会自动将对象本身的地址作为一个隐含参数传递给函数。也就是说,即使你没有写上this指针,编译器在编译的时候也是加上this的,它作为非静态成员函数的隐含形参,对各成员的访问均通过this进行
this指针的使用
一种情况就是,在类的非静态成员函数中返回类对象本身的时候,直接使用 return *this;
另外一种情况是当形参数与成员变量名相同时用于区分,如this->n = n (不能写成n = n)
类的this指针有以下特点
(1)this只能在成员函数中使用,全局函数、静态函数都不能使用this。实际上,传入参数为当前对象地址,成员函数第一个参数为为T * const this
如:
class A{public: int func(int p){}};
其中,func的原型在编译器看来应该是:int func(A * const this,int p);
(2)由此可见,this在成员函数的开始前构造,在成员函数的结束后清除。这个生命周期同任何一个函数的参数是一样的,没有任何区别。当调用一个类的成员函数时,编译器将类的指针作为函数的this参数传递进去。如:
A a;a.func(10);//此处,编译器将会编译成:A::func(&a,10);
看起来和静态函数没差别,对吗?不过,区别还是有的。编译器通常会对this指针做一些优化,因此,this指针的传递效率比较高,例如VC通常是通过ecx(计数寄存器)传递this参数的。
在成员函数中调用delete this会出现什么问题?对象还可以使用吗?
在类对象的内存空间中,只有数据成员和虚函数表指针,并不包含代码内容,类的成员函数单独放在代码段中。在调用成员函数时,隐含传递一个this指针,让成员函数知道当前是哪个对象在调用它。当调用delete this时,类对象的内存空间被释放。在delete this之后进行的其他任何函数调用,只要不涉及到this指针的内容,都能够正常运行。一旦涉及到this指针,如操作数据成员,调用虚函数等,就会出现不可预期的问题。
不可预期的问题指什么?
delete this释放了类对象的内存空间,但是内存空间却并不是马上被回收到系统中,可能是缓冲或者其他什么原因,导致这段内存空间暂时并没有被系统收回。此时这段内存是可以访问的,你可以加上100,加上200,但是其中的值却是不确定的。当你获取数据成员,可能得到的是一串很长的未初始化的随机数;访问虚函数表,指针无效的可能性非常高,造成系统崩溃。
在类的析构函数中调用delete this,会发生什么?
会导致堆栈溢出。原因很简单,delete的本质是“为将被释放的内存调用一个或多个析构函数,然后,释放内存”。显然,delete this会去调用本对象的析构函数,而析构函数中又调用delete this,形成无限递归,造成堆栈溢出,系统崩溃。
this指针调用成员变量时,堆栈会发生什么变化?
当在类的非静态成员函数访问类的非静态成员时,编译器会自动将对象的地址传给作为隐含参数传递给函数,这个隐含参数就是this指针。
即使你并没有写this指针,编译器在链接时也会加上this的,对各成员的访问都是通过this的。
例如你建立了类的多个对象时,在调用类的成员函数时,你并不知道具体是哪个对象在调用,此时你可以通过查看this指针来查看具体是哪个对象在调用。This指针首先入栈,然后成员函数的参数从右向左进行入栈,最后函数返回地址入栈。
final和override关键字
override:
当在父类中使用了虚函数时候,你可能需要在某个子类中对这个虚函数进行重写,以下方法都可以:
class A
{
virtual void foo();
}
class B : public A
{
void foo(); //OK
virtual void foo(); // OK
void foo() override; //OK
}
如果不使用override,当你手一抖,将**foo()写成了f00()**会怎么样呢?结果是编译器并不会报错,因为它并不知道你的目的是重写虚函数,而是把它当成了新的函数。如果这个虚函数很重要的话,那就会对整个程序不利。所以,override可以指定子类的这个虚函数是重写的父类的,如果名字不小心打错了的话,编译器是不会编译通过的:
class A
{
virtual void foo();
};
class B : public A
{
virtual void f00(); //OK,这个函数是B新增的,不是继承的
virtual void f0o() override; //Error, 加了override之后,这个函数一定是继承自A的,A找不到就报错
};
final
当不希望某个类被继承,或不希望某个虚函数被重写,可以在类名和虚函数后添加final关键字,添加final关键字后被继承或重写,编译器会报错。例子如下:
class Base
{
virtual void foo();
};
class A : public Base
{
void foo() final; // foo 被override并且是最后一个override,在其子类中不可以重写
};
class B final : A // 指明B是不可以被继承的
{
void foo() override; // Error: 在A中已经被final了
};
class C : B // Error: B is final
{
};
拷贝构造函数与赋值运算符重载区别
联系:默认情况,编译器以“位拷贝”的方式自动生成缺省的构造函数和赋值函数。若类中有指针变量,那么缺省的构造函数会出现错误。一般情况下,类中包含指针变量时需要重载拷贝构造函数、赋值运算符和析构函数.
区别:
拷贝构造函数是函数,赋值运算符是运算符重载。
拷贝构造函数用于构建新的对象,在对象创建时被调用,赋值运算符函数用于将源对象的内容拷贝到目标对象中,只能被已存在的对象调用。在看到“=”操作符为对象赋值的时候,如果是在对象定义时(Test B = (Test)c),此时调用拷贝构造函数;如果不是在对象定义赋值时(B = c),此时调用赋值运算符重载函数。
Student s;
Student s1 = s; // 隐式调⽤拷⻉构造函数
Student s2(s); // 显式调⽤拷⻉构造函数
Student s;
Student s1;
s1 = s; // 使⽤赋值运算符
- 内存空间角度:1)拷贝构造函数的使用,是在建立对象时;当时对象没有占有内存,故不需要释放内存,不重新建立内存空间。2)赋值函数的使用,是在对象建立后;当时对象已经占有内存,故需要释放先前内存,然后重新获取内存空间。
构造函数、拷贝构造函数,带有构造两个字,顾名思义,就是在对象声明或定义时才会使用。
拷贝初始化和直接初始化
- 当用于类类型对象时,初始化的拷贝形式和直接形式有所不同:直接初始化直接调用与实参匹配的构造函数,拷贝初始化总是调用拷贝构造函数。拷贝初始化首先使用指定构造函数创建一个临时对象,然后用拷贝构造函数将那个临时对象拷贝到正在创建的对象。举例如下
string str1("I am a string");//语句1 直接初始化
string str2(str1);//语句2 str1是已经存在的对象,直接调用拷贝构造函数对str2进行初始化
string str3 = "I am a string";//语句3 拷贝初始化,先为字符串”I am a string“创建临时对象,再把临时对象作为参数,使用拷贝构造函数构造str3
string str4 = str1;//语句4 拷贝初始化,这里相当于隐式调用拷贝构造函数,而不是调用赋值运算符函数
str4 = str1;//调用了赋值运算符函数
为了提高效率,允许编译器跳过创建临时对象这一步,直接调用构造函数构造要创建的对象,这样就完全等价于直接初始化了
(语句1和语句3等价),但是需要辨别两种情况。
- 当拷贝构造函数为private时:语句2、3、4在编译时会报错
- 使用explicit修饰构造函数时:如果构造函数存在隐式转换,编译时会报错
构造函数、拷贝构造函数和赋值操作符
构造函数
对象不存在,没用别的对象初始化,在创建一个新的对象时调用构造函数
拷贝构造函数
对象不存在,但是使用别的已经存在的对象来进行初始化
赋值运算符
对象存在,用别的对象给它赋值,这属于重载“=”号运算符的范畴,“=”号两侧的对象都是已存在的
c++中的构造函数
主要三种类型:默认构造函数、重载构造函数和拷贝构造函数
- 默认构造函数是当类没有实现自己的构造函数时,编译器默认提供的一个构造函数。
- 重载构造函数也称为一般构造函数,一个类可以有多个重载构造函数,但是需要参数类型或个数不相同。可以在重载构造函数中自定义类的初始化方式。
- 拷贝构造函数是在发生对象复制的时候调用的。
深拷贝和浅拷贝的区别
浅拷贝
浅拷贝就是将对象的指针进行简单的复制,并没有新开辟一个地址,拷贝的指针和原来的指针指向同一块地址,如果被拷贝的对象析构释放资源之后,拷贝对象析构时会再次释放一个已经释放的资源,就会出现错误;
深拷贝
深拷贝不仅拷贝值,还开辟出一块新的空间用来存放新的值,并返回给空间的地址;即使原先的对象被析构掉,释放内存了也不会影响到深拷贝得到的值。在自己实现拷贝赋值的时候,如果有指针变量的话是需要自己实现深拷贝的。
#include <iostream>
#include <string.h>
using namespace std;
class Student
{
private:
int num;
char *name;
public:
Student(){
name = new char(20);
cout << "Student" << endl;
};
~Student(){
cout << "~Student " << &name << endl;
delete name;
name = NULL;
};
Student(const Student &s){//拷贝构造函数
//浅拷贝,当对象的name和传入对象的name指向相同的地址
name = s.name;
//深拷贝
//name = new char(20);
//memcpy(name, s.name, strlen(s.name));
cout << "copy Student" << endl;
};
};
int main()
{
{// 花括号让s1和s2变成局部对象,方便测试
Student s1;
Student s2(s1);// 复制对象
}
system("pause");
return 0;
}
//浅拷贝执行结果:
//Student
//copy Student
//~Student 0x7fffed0c3ec0
//~Student 0x7fffed0c3ed0
//*** Error in `/tmp/815453382/a.out': double free or corruption (fasttop): 0x0000000001c82c20 ***
//深拷贝执行结果:
//Student
//copy Student
//~Student 0x7fffebca9fb0
//~Student 0x7fffebca9fc0
从执行结果可以看出,浅拷贝在对象的拷贝创建时存在风险,即重复释放问题。深拷贝的结果是两个对象之间没有任何关系,各自成员地址不同。深拷贝可以避免重复释放和写冲突。
什么情况下会调用拷贝构造函数
- 用类的一个实例化对象去初始化另一个对象的时候
- 函数的参数是类的对象时(非引用传递)
- 函数的返回值是函数体内局部对象的类的对象时 ,此时虽然发生(Named return Value优化)NRV优化,但是由于返回方式是值传递,所以会在返回值的地方调用拷贝构造函数,如
Dog func(){ Dog d; return d;}
另:第三种情况在Linux g++ 下则不会发生拷贝构造函数,不仅如此即使返回局部对象的引用,依然不会发生拷贝构造函数
总结就是:即使发生NRV优化的情况下,Linux+ g++的环境是不管值返回方式还是引用方式返回的方式都不会发生拷贝构造函数,而Windows + VS2019在值返回的情况下发生拷贝构造函数,引用返回方式则不发生拷贝构造函数。
在c++编译器发生NRV优化,如果是引用返回的形式则不会调用拷贝构造函数,如果是值传递的方式依然会发生拷贝构造函数。
在VS2019下进行下述实验:
举个例子:
class A
{
public:
A() {};
A(const A& a)
{
cout << "copy constructor is called" << endl;
};
~A() {};
};
void useClassA(A a) {}
A getClassA()//此时会发生拷贝构造函数的调用,虽然发生NRV优化,但是依然调用拷贝构造函数
{
A a;
return a;
}
//A& getClassA2()// VS2019下,此时编辑器会进行(Named return Value优化)NRV优化,不调用拷贝构造函数 ,如果是引用传递的方式返回当前函数体内生成的对象时,并不发生拷贝构造函数的调用
//{
// A a;
// return a;
//}
int main()
{
A a1,a3,a4;
A a2 = a1; //调用拷贝构造函数,对应情况1
useClassA(a1);//调用拷贝构造函数,对应情况2
a3 = getClassA();//发生NRV优化,但是值返回,依然会有拷贝构造函数的调用 情况3
a4 = getClassA2(a1);//发生NRV优化,且引用返回自身,不会调用
return 0;
}
情况1比较好理解,情况2的实现过程是,调用函数时先根据传入的实参产生临时对象,再用拷贝构造去初始化这个临时对象,在函数中与形参对应,函数调用结束后析构临时对象。情况3在执行return时,理论的执行过程是:产生临时对象,调用拷贝构造函数把返回对象拷贝给临时对象,函数执行完先析构局部变量,再析构临时对象, 依然会调用拷贝构造函数
哪些函数不能是虚函数?把你知道的都说一说
构造函数,构造函数初始化对象,派生类必须知道基类函数干了什么,才能进行构造;当有虚函数时,每一个类有一个虚表,每一个对象有一个虚表指针,虚表指针在构造函数中初始化;
内联函数,内联函数表示在编译阶段进行函数体的替换操作,而虚函数意味着在运行期间进行类型确定,所以内联函数不能是虚函数;
静态函数,静态函数不属于对象属于类,静态成员函数没有this指针,因此静态函数设置为虚函数没有任何意义。
友元函数,友元函数不属于类的成员函数,不能被继承。对于没有继承特性的函数没有虚函数的说法。
普通函数,普通函数不属于类的成员函数,不具有继承特性,因此普通函数没有虚函数
类成员初始化方式?构造/析构函数的执行顺序 ?为什么用成员初始化列表会快一些?
1)类成员初始化方式:
赋值初始化,通过在构造函数体内进行赋值初始化;
成员初始化列表,成员初始化列表就是在类或者结构体的构造函数中,在参数列表后以冒号开头,逗号进行分隔的一系列初始化字段。如下:
class A{ int id; string name; FaceImage face; A(int& inputID,string& inputName,FaceImage& inputFace):id(inputID),name(inputName),face(inputFace){} // 成员初始化列表 };
这两种方式的主要区别在于:
对于在函数体中初始化,是在所有的数据成员被分配内存空间后才进行的。
列表初始化是给数据成员分配内存空间时就进行初始化,就是说分配一个数据成员只要冒号后有此数据成员的赋值表达式(此表达式必须是括号赋值表达式),那么在进入函数体之前数据成员就分配了内存空间并进行初始化,即初始化这个数据成员此时函数体还未执行。
2)构造函数执行顺序
一个派生类构造函数的执行顺序如下:
① 基类构造函数(多个普通基类也按照继承的顺序执行构造函数)。
② 成员类对象构造函数(按照按照声明的顺序初始化)。
③ 派生类构造函数。
析构函数的顺序:
① 调用派生类的析构函数;
② 调用成员类对象的析构函数;
③ 调用基类的析构函数。
3)为什么用成员初始化列表会快一些
方法一是在构造函数当中做赋值的操作,而方法二是做纯粹的初始化操作。C++的赋值操作是会产生临时对象的。临时对象的出现会降低程序的效率。所以使用成员初始化列表效率会高一些。
另外,有三种情况是必须使用成员初始化列表进行初始化的:
常量成员的初始化,因为常量成员只能初始化不能赋值
引用类型,因为其只能初始化不能赋值;
成员类型是没有默认构造函数的类。若没有提供显示初始化式,则编译器隐式使用成员类型的默认构造函数,若类没有默认构造函数,则编译器尝试使用默认构造函数将会失败。
一个类中定义成员类变量,该成员类变量必须要有默认构造函数(可以是自己定义的无参默认构造函数),否则会报错。 class B{ int a; public: B(int aa):a(aa){}//没有默认构造函数 }; class A{B b;};//在这一行,会报错,因为B没有默认构造函数 改进: class A { B b; public: A():b(4){} };
Noting:C++ 初始化类成员时,是按照声明的顺序初始化的,而不是按照出现在初始化列表中的顺序。
构造函数或者析构函数中调用虚函数会怎样
在构造函数中调用虚函数,由于当前对象还没有构造完成,此时调用的虚函数指向的是基类的函数实现方式。
在析构函数中调用虚函数,此时调用的是基类的函数实现方式,不调用子类的函数实现方式,因为析构函数不是虚函数。
#include<iostream>
using namespace std;
class Base
{
public:
Base()
{
Function();
}
virtual void Function()
{
cout << "Base::Fuction" << endl;
}
~Base()
{
Function();
}
};
class A : public Base
{
public:
A()
{
Function();
}
virtual void Function()
{
cout << "A::Function" << endl;
}
~A()
{
Function();
}
};
int main()
{
Base* a = new Base;
delete a;
cout << "-------------------------" <<endl;
Base* b = new A;//语句1
delete b;
}
//输出结果
//Base::Fuction
//Base::Fuction
//-------------------------
//Base::Fuction
//A::Function
//Base::Fuction
RTTI是什么?其原理是什么?
RTTI即运行时类型识别,其功能由两个运算符实现:
- typeid运算符,用于返回表达式的类型,可以通过基类的指针获取派生类的数据类型;
- dynamic_cast运算符,具有类型检查的功能,用于将基类的指针或引用安全地转换成派生类的指针或引用。
继承机制中对象如何转换,指针和引用之间如何转换
- 向上类型转换:将派生类指针或引用转换为基类的指针或引用被称为向上类型转换,向上类型转换会自动进行,而且向上类型转换是安全的。
- 向下类型转换:将基类指针或引用转换为派生类指针或引用被称为向下类型转换,向下类型转换不会自动进行,因为一个基类对应几个派生类,所以向下类型转换时不知道对应哪个派生类,所以在向下类型转换时必须加动态类型识别技术。RTTI技术,用dynamic_cast进行向下类型转换。
如何阻止一个类被实例化?
- 将类定义为抽象基类或者将构造函数声明为private;
- 不允许类外部创建类对象,只能在类内部创建对象
如何禁止程序自动生成拷贝构造函数
- 为了阻止编译器默认生成拷贝构造函数和拷贝赋值函数,我们需要手动去重写这两个函数,某些情况下,为了避免调用拷贝构造函数和拷贝赋值函数,我们需要将他们设置成private,防止被调用。
- 类的成员函数和friend函数还是可以调用private函数,如果这个private函数只声明不定义,则会产生一个连接错误;
- 针对上述两种情况,我们可以定一个base类,在base类中将拷贝构造函数和拷贝赋值函数设置成private,那么派生类中编译器将不会自动生成这两个函数,且由于base类中该函数是私有的,因此,派生类将阻止编译器执行相关的操作。
友元函数和友元类的基本情况
友元提供了不同类的成员函数之间、类的成员函数和一般函数之间进行数据共享的机制。通过友元,一个不同函数或者另一个类中的成员函数可以访问类中的私有成员和保护成员。友元的正确使用能提高程序的运行效率,但同时也破坏了类的封装性和数据的隐藏性,导致程序可维护性变差。
1)友元函数
友元函数是定义在类外的普通函数,不属于任何类,可以访问其他类的私有成员。但是需要在类的定义中声明所有可以访问它的友元函数。
#include <iostream>
using namespace std;
class A
{
public:
friend void set_show(int x, A &a); //该函数是友元函数的声明
private:
int data;
};
void set_show(int x, A &a) //友元函数定义,为了访问类A中的成员
{
a.data = x;
cout << a.data << endl;
}
int main(void)
{
class A a;
set_show(1, a);
return 0;
}
一个函数可以是多个类的友元函数,但是每个类中都要声明这个函数。
2)友元类
友元类的所有成员函数都是另一个类的友元函数,都可以访问另一个类中的隐藏信息(包括私有成员和保护成员)。 但是另一个类里面也要相应的进行声明:
#include <iostream>
using namespace std;
class A
{
public:
friend class C; //这是友元类的声明
private:
int data;
};
class C //友元类定义,为了访问类A中的成员
{
public:
void set_show(int x, A &a) { a.data = x; cout<<a.data<<endl;}
};
int main(void)
{
class A a;
class C c;
c.set_show(1, a);
return 0;
}
使用友元类时注意:
(1) 友元关系不能被继承。
(2) 友元关系是单向的,不具有交换性。若类B是类A的友元,类A不一定是类B的友元,要看在类中是否有相应的声明。
(3) 友元关系不具有传递性。若类B是类A的友元,类C是B的友元,类C不一定是类A的友元,同样要看类中是否有相应的申明.
用C语言实现C++的继承
#include <iostream>
using namespace std;
//C++中的继承与多态
struct A
{
virtual void fun() //C++中的多态:通过虚函数实现
{
cout<<"A:fun()"<<endl;
}
int a;
};
struct B:public A //C++中的继承:B类公有继承A类
{
virtual void fun() //C++中的多态:通过虚函数实现(子类的关键字virtual可加可不加)
{
cout<<"B:fun()"<<endl;
}
int b;
};
//C语言模拟C++的继承与多态
typedef void (*FUN)(); //定义一个函数指针来实现对成员函数的继承
struct _A //父类
{
FUN _fun; //由于C语言中结构体不能包含函数,故只能用函数指针在外面实现
int _a;
};
struct _B //子类
{
_A _a_; //在子类中定义一个基类的对象即可实现对父类的继承
int _b;
};
void _fA() //父类的同名函数
{
printf("_A:_fun()\n");
}
void _fB() //子类的同名函数
{
printf("_B:_fun()\n");
}
void Test()
{
//测试C++中的继承与多态
A a; //定义一个父类对象a
B b; //定义一个子类对象b
A* p1 = &a; //定义一个父类指针指向父类的对象
p1->fun(); //调用父类的同名函数
p1 = &b; //让父类指针指向子类的对象
p1->fun(); //调用子类的同名函数
//C语言模拟继承与多态的测试
_A _a; //定义一个父类对象_a
_B _b; //定义一个子类对象_b
_a._fun = _fA; //父类的对象调用父类的同名函数
_b._a_._fun = _fB; //子类的对象调用子类的同名函数
_A* p2 = &_a; //定义一个父类指针指向父类的对象
p2->_fun(); //调用父类的同名函数
p2 = (_A*)&_b; //让父类指针指向子类的对象,由于类型不匹配所以要进行强转
p2->_fun(); //调用子类的同名函数
}
运算符重载
1、 我们只能重载已有的运算符,而无权发明新的运算符;对于一个重载的运算符,其优先级和结合律与内置类型一致才可以;不能改变运算符操作数个数;
2、 两种重载方式:成员运算符和非成员运算符,成员运算符比非成员运算符少一个参数;下标运算符、箭头运算符必须是成员运算符;
3、 引入运算符重载,是为了实现类的多态性;
4、 当重载的运算符是成员函数时,this绑定到左侧运算符对象。成员运算符函数的参数数量比运算符对象的数量少一个;至少含有一个类类型的参数;
5、 从参数的个数推断到底定义的是哪种运算符,当运算符既是一元运算符又是二元运算符(+,-,*,&);
6、 下标运算符必须是成员函数,下标运算符通常以所访问元素的引用作为返回值,同时最好定义下标运算符的常量版本和非常量版本;
7、 箭头运算符必须是类的成员,解引用通常也是类的成员;重载的箭头运算符必须返回类的指针;
当程序中有函数重载时,函数的匹配原则和顺序是什么?
- 名字查找
- 确定候选函数
- 寻找最佳匹配
模板,写一个比较大小的模板
#include<iostream>
using namespace std;
template<typename type1,typename type2>//函数模板
type1 Max(type1 a,type2 b)
{
return a > b ? a : b;
}
void main()
{
cout<<"Max = "<<Max(5.5,'a')<<endl;
}
其实该模板有个比较隐晦的bug,那就是a、b只有在能进行转型的时候才能进行比较,否则 a > b 这一步是会报错的。
这个时候往往需要对于 > 号进行重载,这代码量瞬间上来了。
C++模板,底层原理
用template <typename T>关键字进行声明,接下来就可以进行模板函数和模板类的编写了
- 编译器并不是把函数模板处理成能够处理任意类的函数;编译器从函数模板通过具体类型产生不同的函数;编译器会对函数模板进行两次编译:在声明的地方对模板代码本身进行编译,在调用的地方对参数替换后的代码进行编译。
- 这是因为函数模板要被实例化后才能成为真正的函数,在使用函数模板的源文件中包含函数模板的头文件,如果该头文件中只有声明,没有定义,那编译器无法实例化该模板,最终导致链接错误
模板类和模板函数的区别:
函数模板的实例化是由编译程序在处理函数调用时自动完成的,而类模板的实例化必须由程序员在程序中显式地指定。即函数模板允许隐式调用和显式调用而类模板只能显示调用。在使用时类模板必须加T,而函数模板不必。
如何设计一个计算仅单个子类的对象个数?
1、为类设计一个static静态变量count作为计数器;
2、类定义结束后初始化count;
3、在构造函数中对count进行+1;
4、 设计拷贝构造函数,在进行拷贝构造函数中进行count +1,操作;
5、设计赋值构造函数,在进行赋值函数中对count+1操作;
6、在析构函数中对count进行-1;
结构体变量比较是否相等
重载了“==”操作符
struct foo { int a; int b; bool operator==(const foo& rhs) *//* *操作运算符重载* { return( a == rhs.a) && (b == rhs.b); } };
元素的话,一个个比;
指针直接比较,如果保存的是同一个实例地址,则(p1==p2)为真
C++11特性
C++11新特性
- nullptr替代 NULL:为了解决原来C++中NULL的二义性问题而引进的一种新的类型,因为NULL实际上代表的是0,而nullptr是void*类型的
- 引入了 auto 和 decltype 这两个关键字实现了类型推导
- 基于范围的 for 循环for(auto& i : res){}
- 类和结构体的中初始化列表
- Lambda 表达式(匿名函数):用于创建并定义匿名的函数对象,以简化编程工作。
- std::forward_list(单向链表)
- 右值引用和move语义
智能指针的原理、常用的智能指针和实现
(1)原理
智能指针是一个类,用来存储指向动态分配对象的指针,负责自动释放动态分配的对象,防止堆内存泄漏。因为存在以下这种情况:申请的空间在函数结束时忘记释放,造成内存泄露。用智能指针可以很大程度上避免这个问题,因为智能指针就是一个类,当超出了类的作用域时(在类对象声明周期结束时),类会自动调用析构函数释放资源,不需要手动释放内存空间。
智能指针的作⽤是,能够处理内存泄漏问题和空悬指针问题。
(2)常用的智能指针
1)auto_ptr(C++98的方案,cpp11已经弃用)
auto_ptr的出现,主要是为了解决“有异常抛出时发生内存泄漏”的问题;抛出异常,将导致指针p所指向的空间得不到释放而导致内存泄漏;
对于 auto_ptr ,实现所有权拥有的概念,同⼀时间只能有⼀个智能指针可以指向该对象;
auto_ptr<string> p1(new string("I reigned lonely as a cloud."));
auto_ptr<string> p2;
p2 = p1;//suto_ptr不会报错。
此时不会报错,p2剥夺了p1的所有权,但是当程序运行时访问p1将会报错。auto_ptr导致的问题在于,当所有权转让之后,原来的指针被用来访问原来指向的对象,当程序访问该指针指向的内容时,会发现这是一个悬空指针,程序就会出错,但是如果换成shared_ptr程序就能正常运行。
所以auto_ptr的缺点是:存在潜在的内存崩溃问题。
2)unique_ptr(替换auto_ptr)
对于 unique_ptr ,实现独占式拥有的概念,同⼀时间只能有⼀个智能指针可以指向该对象,因为无法进行 拷贝构造和拷贝赋值,但是可以进⾏移动构造和移动赋值,即对资源管理权限可以实现转移;
unique_ptr<string> p3(new string("auto"));
unique_ptr<stirng> p4;
p4 = p3; // 报错
编译器认为p4 = p3非法,避免了P3不再指向有效数据的问题,因此,unique_ptr比auto_ptr更安全。
另外,当程序试图将一个unique_ptr赋值给另一个时,如果源unique_ptr是一个临时右值,编译器允许这么做;如果源unique_ptr将存在一段时间,编译器将禁止这么做,比如:
unique_ptr<string> pu1(new string ("hello world"));
unique_ptr<string> pu2;
pu2 = pu1; // #1 not allowed
unique_ptr<string> pu3;
pu3 = unique_ptr<string>(new string ("You")); // #2 allowed
其中#1留下悬挂的unique_ptr(pu1),这可能导致危害。而#2不会留下悬挂的unique_ptr,因为它调用unique_ptr的构造函数,该构造函数创建的临时对象在其所有权让给pu3后就会被销毁。
如果想要执行类似于#1的操作,要安全的使用这种指针,可给它赋新值。C++有一个标准库函数std::move(),让你能够将一个unique_ptr赋给另一个。
unique_ptr<string> ps1, ps2;
ps1 = demo("hello");
ps2 = move(ps1);
ps1 = demo("alexia");
cout << *ps2 << *ps1 << endl;
3)shared_ptr
对于 shared_ptr ,实现共享式拥有的概念,即多个智能指针可以指向相同的对象,该对象及相关资源会在其所指对象不再使⽤之后,⾃动释放与对象相关的资源;
实现原理:采用引用计数器的方法,允许多个智能指针指向同一个对象,每当多一个指针指向该对象时,指向该对象的所有智能指针内部的引用计数加1,每当减少一个智能指针指向对象时,引用计数会减1,当计数为0的时候会自动的释放动态分配的资源。
可以通过成员函数use_count()来查看资源的所有者个数。除了可以通过new来构造,还可以通过传入auto_ptr,unique_ptr,weak_ptr来构造。当我们调用release()时,当前指针会释放资源所有权,计数减一。当计数等于0时,资源会被释放。
shared_ptr是为了解决auto_ptr在对象所有权上的局限性(auto_ptr是独占的),在使用引用计数的机制上提供了可以共享所有权的智能指针。
实现过程:
智能指针将一个计数器与类指向的对象相关联,引用计数器跟踪共有多少个类对象共享同一指针。
- 构造函数中计数初始化为1;
- 拷贝构造函数中计数值加1;
- 赋值运算符中,赋值操作符减少左操作数所指对象的引用计数,并增加右操作数所指对象的引用计数。
- 析构函数中引用计数减1;
- 在赋值运算符和析构函数中,如果减1后为0,则调用delete释放对象。
循环引用:
循环引用是指使用多个智能指针share_ptr时,出现了指针之间相互指向,从而形成环的情况,有点类似于死锁的情况,这种情况下,智能指针往往不能正常调用对象的析构函数,从而造成内存泄漏。举个例子:
#include <iostream>
#include <memory>
class classB;
class classA {
public:
classA() {
std::cout << "classA()" << std::endl;
}
~classA() {
std::cout << "~classA()" << std::endl;
}
void set_ptr(std::shared_ptr<classB>& ptr) {
std::cout << "在类A中,拷贝之前m_ptr_b的引用次数为" << m_ptr_b.use_count() << std::endl;
m_ptr_b = ptr;
std::cout << "在类A中,拷贝之后m_ptr_b的引用次数为" << m_ptr_b.use_count() << std::endl;
}
private:
std::shared_ptr<classB> m_ptr_b;
};
class classB {
public:
classB() {
std::cout << "classB()" << std::endl;
}
~classB() {
std::cout << "~classB()" << std::endl;
}
void set_ptr(std::shared_ptr<classA>& ptr) {
std::cout << "在类B中,拷贝之前m_ptr_a的引用次数为" << m_ptr_a.use_count() << std::endl;
m_ptr_a = ptr;
std::cout << "在类B中,拷贝之后m_ptr_a的引用次数为" << m_ptr_a.use_count() << std::endl;
}
private:
std::shared_ptr<classA> m_ptr_a;
};
int main()
{
std::shared_ptr<classA> ptr_a(new classA()); //调用类A构造函数,此时ptr_a的引用计数已经加1
std::cout << "new 完之后 ptr_a的引用计数为" << ptr_a.use_count() << std::endl;
std::shared_ptr<classB> ptr_b(new classB()); //调用类B构造函数,此时ptr_b的引用计数已经加1
std::cout << "new 完之后 ptr_b的引用计数为" << ptr_b.use_count() << std::endl;
ptr_a->set_ptr(ptr_b); //set内部会发生拷贝动作,导致智能指针的引用计数加1,
ptr_b->set_ptr(ptr_a);
std::cout << ptr_a.use_count() << " " << ptr_b.use_count() << std::endl;
system("pause");
return 0;
}
//执行结果:
classA()
new 完之后 ptr_a的引用计数为1
classB()
new 完之后 ptr_b的引用计数为1
在类A中,拷贝之前m_ptr_b的引用次数为0
在类A中,拷贝之后m_ptr_b的引用次数为2
在类B中,拷贝之前m_ptr_a的引用次数为0
在类B中,拷贝之后m_ptr_a的引用次数为2
2 2
上述代码执行完之后,没有执行类A和类B的析构函数。由于引用计数的值在退出main函数之前没有减到1,因此也没执行对象的析构函数。
在实际编程过程中,应该尽量避免出现智能指针之前相互指向的情况,如果不可避免,可以使用使用弱指针——weak_ptr,它不增加引用计数,只要出了作用域就会自动析构。
#include <iostream>
#include <string>
#include <memory>
class classB;
class classA {
public:
classA() {
std::cout << "classA()" << std::endl;
}
~classA() {
std::cout << "~classA()" << std::endl;
}
void set_ptr(std::shared_ptr<classB>& ptr) {
std::cout << "在类A中,拷贝之前m_ptr_b的引用次数为" << m_ptr_b.use_count() << std::endl;
m_ptr_b = ptr;
std::cout << "在类A中,拷贝之后m_ptr_b的引用次数为" << m_ptr_b.use_count() << std::endl;
}
private:
std::weak_ptr<classB> m_ptr_b;
};
class classB {
public:
classB() {
std::cout << "classB()" << std::endl;
}
~classB() {
std::cout << "~classB()" << std::endl;
}
void set_ptr(std::shared_ptr<classA>& ptr) {
std::cout << "在类B中,拷贝之前m_ptr_a的引用次数为" << m_ptr_a.use_count() << std::endl;
m_ptr_a = ptr;
std::cout << "在类B中,拷贝之后m_ptr_a的引用次数为" << m_ptr_a.use_count() << std::endl;
}
private:
std::weak_ptr<classA> m_ptr_a;//变为shared_ptr也是可以调用析构函数
//shared_ptr:ptr_b引用计数为2,ptr_a引用计数为1,离开作用于后,ptr_a计数为0,ptr_b为1,ptr_a中m_ptr_b会析构,引用计数减1,ptr_b引用计数为0,也析构。
};
int main()
{
std::shared_ptr<classA> ptr_a(new classA()); //调用类A构造函数,此时ptr_a的引用计数已经加1
std::cout << "new 完之后 ptr_a的引用计数为" << ptr_a.use_count() << std::endl;
std::shared_ptr<classB> ptr_b(new classB()); //调用类B构造函数,此时ptr_b的引用计数已经加1
std::cout << "new 完之后 ptr_b的引用计数为" << ptr_b.use_count() << std::endl;
ptr_a->set_ptr(ptr_b); //set内部会发生拷贝动作,导致智能指针的引用计数加1,
ptr_b->set_ptr(ptr_a);
std::cout << ptr_a.use_count() << " " << ptr_b.use_count() << std::endl;
system("pause");
return 0;
}
//结果
classA()
new 完之后 ptr_a的引用计数为1
classB()
new 完之后 ptr_b的引用计数为1
在类A中,拷贝之前m_ptr_b的引用次数为0
在类A中,拷贝之后m_ptr_b的引用次数为1
在类B中,拷贝之前m_ptr_a的引用次数为0
在类B中,拷贝之后m_ptr_a的引用次数为1
1 1
~classB()
~classA()
weak_ptr只是指向shared_ptr对象的一个指针,它可以从一个shared_ptr或者weak_ptr构造而来,但是weak_ptr的构造和析构不会引起shared_ptr的引用计数的增减。weak_ptr是shared_ptr的一个助手。
4)weak_ptr
shared_ptr 存在的问题:互相引⽤成环(环形引用),两个指针的引⽤计数永远不会下降为0,这样两个指针指向的内存都无法释放,需要使用weak_ptr打破环形引用。
对于 weak_ptr , 是对对象的⼀种弱引⽤,它是为了配合shared_ptr而引入的一种智能指针,它指向一个由shared_ptr管理的对象而不影响所指对象的生命周期,也就是说,它只引用,不计数。如果一块内存被shared_ptr和weak_ptr同时引用,当所有shared_ptr析构了之后,不管还有没有weak_ptr引用该内存,内存也会被释放。所以weak_ptr不保证它指向的内存一定是有效的,在使用之前使用函数lock()检查weak_ptr是否为空指针。
Note:不能通过weak_ptr直接访问对象的方法,比如B对象中有一个方法print(,我们不能这样访问,pa->pb->print(;英文pb是一个weak_ptr,应该先把它转化为shared_ptr,如: shared_ptr p= pa->pb_.lock(); p->print();
智能指针shared_ptr代码实现:
template<typename T>
class SharedPtr
{
public:
SharedPtr(T* ptr = NULL):_ptr(ptr), _pcount(new int(1))
{}
SharedPtr(const SharedPtr& s):_ptr(s._ptr), _pcount(s._pcount){
(*_pcount)++;
}
SharedPtr<T>& operator=(const SharedPtr& s){
if (this != &s)
{
if (--(*(this->_pcount)) == 0)
{
delete this->_ptr;
delete this->_pcount;
}
_ptr = s._ptr;
_pcount = s._pcount;
*(_pcount)++;
}
return *this;
}
T& operator*()
{
return *(this->_ptr);
}
T* operator->()
{
return this->_ptr;
}
~SharedPtr()
{
--(*(this->_pcount));
if (*(this->_pcount) == 0)
{
delete _ptr;
_ptr = NULL;
delete _pcount;
_pcount = NULL;
}
}
private:
T* _ptr;
int* _pcount;//指向引用计数的指针
};
总结:
智能指针其实是将指针进行了封装,可以像普通指针一样进行使用,同时可以自行进行释放,避免忘记释放指针指向的内存地址造成内存泄漏。
- auto_ptr是较早版本的智能指针,在进行指针拷贝和赋值的时候,新指针直接接管旧指针的资源并且将旧指针指向空,但是这种方式在需要访问旧指针的时候,就会出现问题。
- unique_ptr是auto_ptr的一个改良版,不能赋值也不能拷贝,保证一个对象同一时间只有一个智能指针。
- shared_ptr可以使得一个对象可以有多个智能指针,当这个对象所有的智能指针被销毁时就会自动进行回收。(内部使用计数机制进行维护)
- weak_ptr是为了协助shared_ptr而出现的。它不能访问对象,只能观测shared_ptr的引用计数,防止出现死锁。
移动构造函数
我们用对象a初始化对象b后,对象a我们就不在使用了,但是对象a的空间还在(在析构之前),拷贝构造函数实际上就是把a对象的内容复制一份到b中,那么为什么我们不能直接使用a的空间呢?这样就避免了新的空间的分配,大大降低了构造的成本。这就是移动构造函数设计的初衷;
拷贝构造函数中,对于指针需要进行深拷贝,而移动构造函数中,对于指针采用浅层复制。浅层复制之所以危险,是因为两个指针共同指向一片内存空间,若第一个指针将其释放,另一个指针的指向就不合法了。
所以我们只要避免第一个指针释放空间就可以了。避免的方法就是将第一个指针(比如a->value)置为NULL,这样在调用析构函数的时候,由于有判断是否为NULL的语句,所以析构a的时候并不会回收a->value指向的空间;
移动构造函数与拷贝构造函数的区别: 移动构造函数是c++11的新特性,移动构造函数传入的参数是一个右值 用&&标出。一般来说左值可以通过使用std:move方法强制转换为右值。拷贝构造函数是先将传入的参数对象进行一次深拷贝,再传给新对象。这就会有一次拷贝对象的开销,并且进行了深拷贝,就需要给对象分配地址空间。而移动构造函数就是为了解决这个拷贝开销而产生的。移动构造函数首先将传递参数的内存地址空间接管,然后将内部所有指针设置为nullptr,并且在原地址上进行新对象的构造,最后调用原对象的的析构函数,这样做既不会产生额外的拷贝开销,也不会给新对象分配内存空间。
而对于指针参数来讲,需要注意的是,移动构造函数是对传递参数进行一次浅拷贝。也就是说如果参数为指针变量,进行拷贝之后将会有两个指针指向同一地址空间,这个时候如果前一个指针对象进行了析构,则后一个指针将会变成野指针,从而引发错误。所以当变量是指针的时候,要将指针置为空,这样在调用析构函数的时候会进行判断指针是否为空,如果为空则不回收指针的地址空间,这样就不会释放掉前一个指针。
#include <iostream>
#include <string>
#include <cstring>
using namespace std;
class String
{
public:
char* str;
String() : str(new char[1])
{
str[0] = 0;
}
// 构造函数
String(const char* s)
{
cout << "调用构造函数" << endl;
int len = strlen(s) + 1;
str = new char[len];
strcpy_s(str, len, s);
}
// 复制构造函数
String(const String & s)
{
cout << "调用复制构造函数" << endl;
int len = strlen(s.str) + 1;
str = new char[len];
strcpy_s(str, len, s.str);
}
// 复制赋值运算符
String & operator = (const String & s)
{
cout << "调用复制赋值运算符" << endl;
if (str != s.str)
{
delete[] str;
int len = strlen(s.str) + 1;
str = new char[len];
strcpy_s(str, len, s.str);
}
return *this;
}
// 移动构造函数
// 和复制构造函数的区别在于,其参数是右值引用
String(String && s) : str(s.str)
{
cout << "调用移动构造函数" << endl;
s.str = NULL;
}
// 移动赋值运算符
// 和复制赋值运算符的区别在于,其参数是右值引用
String & operator = (String && s)
{
cout << "调用移动赋值运算符" << endl;
if (str != s.str)
{
// 在移动赋值运算符函数中没有执行深复制操作,
// 而是直接将对象的 str 指向了参数 s 的成员变量 str 指向的地方,
// 然后修改 s.str 让它指向别处,以免 s.str 原来指向的空间被释放两次。
str = s.str;
s.str = NULL;
}
return *this;
}
// 析构函数
~String()
{
if(str!=NULL){
delete[] str;
str = NULL;
}
}
};
template <class T>
void MoveSwap(T & a, T & b)
{
T tmp = move(a); //std::move(a) 为右值,这里会调用移动构造函数
a = move(b); //move(b) 为右值,因此这里会调用移动赋值运算符
b = move(tmp); //move(tmp) 为右值,因此这里会调用移动赋值运算符
}
template <class T>
void Swap(T & a, T & b)
{
T tmp = a; //调用复制构造函数
a = b; //调用复制赋值运算符
b = tmp; //调用复制赋值运算符
}
int main()
{
String s;
// 如果没有定义移动赋值运算符,则会导致复制赋值运算符被调用,引发深复制操作。
s = String("this"); //调用移动赋值运算符
cout << "print " << s.str << endl;
String s1 = "hello", s2 = "world";
//MoveSwap(s1, s2); //调用一次移动构造函数和两次移动赋值运算符
Swap(s1, s2);//调用一次复制构造函数,两次复制赋值运算符
cout << "print " << s2.str << endl;
system("pause");
return 0;
}
当执行MoveSwap函数时,打印如下:
当执行Swap函数时,打印如下:
C++左值引用和右值引用
**右值引用的作用:**主要目的是为了实现转移语义和完美转发,消除两个对象交互时不必要的对象拷贝,也能更加简洁明确地定义泛型函数;
右值引用和std::move被广泛用于在STL和自定义类中实现移动语义,避免拷贝,从而提升程序性能。
在C++11中所有的值必属于左值、右值两者之一,右值又可以细分为纯右值、将亡值。
左值和右值
左值:表示的是可以获取地址的表达式,它能出现在赋值语句的左边。但是修饰符const的出现使得可以声明如下的标识符,它可以取得地址,但是没办法对其进行赋值。
const int& a = 10;
右值(将亡值或纯右值):表示无法获取地址的对象,有常量值、函数返回值、lambda表达式等。无法获取地址,但不表示其不可改变,当定义了右值的右值引用时就可以更改右值。
int a = b+c;
a 就是左值,其有变量名为a,通过&a可以获取该变量的地址;表达式b+c、函数int func()的返回值是右值,在其被赋值给某一变量前,我们不能通过变量名找到它,&(b+c)这样的操作则不会通过编译。
C++11对C++98中的右值进行了扩充。在C++11中右值又分为纯右值和将亡值。其中纯右值的概念等同于我们在C++98标准中右值的概念,指的是临时变量和不跟对象关联的字面量值;将亡值则是C++11新增的跟右值引用相关的表达式,这样表达式通常是将要被移动的对象(移为他用),比如返回右值引用T&&的函数返回值、std::move的返回值,或者转换为T&&的类型转换函数的返回值。将亡值可以理解为通过“盗取”其他变量内存空间的方式获取到的值。在确保其他变量不再被使用、或即将被销毁时,通过“盗取”的方式可以避免内存空间的释放和分配,能够延长变量值的生命期。
左值引用和右值引用:
- 左值引用:传统的C++中引用被称为左值引用,对一个左值进行引用
- 右值引用:是对一个右值进行引用的类型,右值引用关联到右值时,右值被存储到特定位置,右值引用指向该特定位置,也就是说,右值虽然无法获取地址,但是右值引用是可以获取地址的,该地址表示临时对象的存储位置
右值引用和左值引用都是属于引用类型。无论是声明一个左值引用还是右值引用,都必须立即进行初始化。而其原因可以理解为是引用类型只是该对象的一个别名。左值引用是具名变量值的别名,而右值引用则是不具名(匿名)变量的别名。左值引用通常也不能绑定到右值,但常量左值引用是个“万能”的引用类型。它可以接受非常量左值、常量左值、右值对其进行初始化。
右值值引用通常不能绑定到任何的左值,要想绑定一个左值到右值引用,通常需要std::move()将左值强制转换为右值。
#include <bits/stdc++.h>
using namespace std;
template<typename T>
void fun(T&& t)
{
cout << t << endl;
}
int getInt()
{
return 5;
}
int main() {
int a = 10;
int& b = a; //b是左值引用
int& c = 10; //错误,c是左值不能使用右值初始化
int&& d = 10; //正确,右值引用用右值初始化
int&& e = a; //错误,e是右值引用不能使用左值初始化
const int& f = a; //正确,左值常引用相当于是万能型,可以用左值或者右值初始化
const int& g = 10;//正确,左值常引用相当于是万能型,可以用左值或者右值初始化
const int&& h = 10; //正确,右值常引用
const int& aa = h;//正确
int& i = getInt(); //错误,i是左值引用不能使用临时变量(右值)初始化
int&& j = getInt(); //正确,函数返回值是右值
fun(10); //此时fun函数的参数t是右值
fun(a); //编译不通过,a是左值,fun参数要求右值,可以改为fun(std::move(a))
return 0;
}
C++11线程安全
nullptr和NULL区别
算是为了与C语言进行兼容而定义的一个问题吧
NULL来自C语言,一般由宏定义实现,而 nullptr 则是C++11的新增关键字。在C语言中,NULL被定义为(void*)0,而在C++语言中,NULL则被定义为整数0。编译器一般对其实际定义如下:
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
在C++中指针必须有明确的类型定义。但是将NULL定义为0带来的另一个问题是无法与整数的0区分。因为C++中允许有函数重载,所以可以试想如下函数定义情况:
#include <iostream>
using namespace std;
void fun(char* p) {
cout << "char*" << endl;
}
void fun(int p) {
cout << "int" << endl;
}
int main()
{
fun(NULL);
return 0;
}
//输出结果:int
那么在传入NULL参数时,会把NULL当做整数0来看,如果我们想调用参数是指针的函数,该怎么办呢?。nullptr在C++11被引入用于解决这一问题,nullptr可以明确区分整型和指针类型,能够根据环境自动转换成相应的指针类型,但不会被转换为任何整型,所以不会造成参数传递错误。
nullptr的一种实现方式如下:
const class nullptr_t{
public:
template<class T> inline operator T*() const{ return 0; }
template<class C, class T> inline operator T C::*() const { return 0; }
private:
void operator&() const;
} nullptr = {};
以上通过模板类和运算符重载的方式来对不同类型的指针进行实例化从而解决了(void*)指针带来参数类型不明的问题,**另外由于nullptr是明确的指针类型,所以不会与整形变量相混淆。**但nullptr仍然存在一定问题,例如:
#include <iostream>
using namespace std;
void fun(char* p)
{
cout<< "char* p" <<endl;
}
void fun(int* p)
{
cout<< "int* p" <<endl;
}
void fun(int p)
{
cout<< "int p" <<endl;
}
int main()
{
fun((char*)nullptr);//语句1
fun(nullptr);//语句2
fun(NULL);//语句3
return 0;
}
//运行结果:
//语句1:char* p
//语句2:报错,有多个匹配
//3:int p
在这种情况下存在对不同指针类型的函数重载,此时如果传入nullptr指针则仍然存在无法区分应实际调用哪个函数,这种情况下必须显示的指明参数类型。
auto、decltype和decltype(auto)用法
(1) auto
C++11新标准引入了auto类型说明符,用它就能让编译器替我们去分析表达式所属的类型。和原来那些只对应某种特定的类型说明符(例如 int)不同,
auto 让编译器通过初始值来进行类型推演,从而获得定义变量的类型,所以说 auto 定义的变量必须有初始值。
//普通;类型
int a = 1, b = 3;
auto c = a + b;// c为int型
auto sz = 0, pi = 3.14;//sz和pi的类型不一样
//const类型
const int i = 5, &m = i;
auto j = i; // 变量i是顶层const, 会被忽略, 所以j的类型是int
auto n = m; //n是一个整数(m是i的别名,i本身是一个顶层const)
auto k = &i; // 变量i是一个常量, 对常量取地址是一种底层const, 所以k的类型是const int*,即指向整数常量的指针
const auto l = i; //如果希望推断出的类型是顶层const的, 那么就需要在auto前面加上cosnt,l是const int
auto &g = i;//g是一个整型常量引用,绑定到i
const auto &j = 42;//正确,可以为常量引用绑定字面值
auto &h = 42;//错误,不能为非常量引用绑定字面值
//引用和指针类型
int x = 2;
int& y = x;
auto z = y; //z是int型不是int& 型,编译器以引用对象的类型作为auto的类型
auto& p1 = y; //p1是int&型
auto p2 = &x; //p2是指针类型int*
(2)decltype
有的时候我们还会遇到这种情况,**我们希望从表达式中推断出要定义变量的类型,但却不想用表达式的值去初始化变量。**还有可能是函数的返回类型为某表达式的值类型。在这些时候auto显得就无力了,所以C++11又引入了第二种类型说明符decltype,它的作用是选择并返回操作数的数据类型。在此过程中,编译器只是分析表达式并得到它的类型,却不进行实际的计算表达式的值。
int func() {return 0};
//普通类型
decltype(func()) sum = 5; // sum的类型是函数func()的返回值的类型int, 但是这时不会实际调用函数func()
int a = 0;
decltype(a) b = 4; // a的类型是int, 所以b的类型也是int
//不论是顶层const还是底层const, decltype都会保留
const int c = 3;
decltype(c) d = c; // d的类型和c是一样的, 都是顶层const
int e = 4;
const int* f = &e; // f是底层const
decltype(f) g = f; // g也是底层const
//引用与指针类型
//1. 如果表达式是引用类型, 那么decltype的类型也是引用
const int i = 3, &j = i;
decltype(j) k = 5; // k的类型是 const int&
//2. 如果表达式是引用类型, 但是想要得到这个引用所指向的类型, 需要修改表达式:
int i = 3, &r = i;
decltype(r + 0) t = 5; // 此时是int类型
//3. 对指针的解引用操作返回的是引用类型
int i = 3, j = 6, *p = &i;
decltype(*p) c = j; // c是int&类型, c和j绑定在一起
//4. 如果一个表达式的类型不是引用, 但是我们需要推断出引用, 那么可以加上一对括号, 就变成了引用类型了
int i = 3;
decltype((i)) j = i; // 此时j的类型是int&类型, j和i绑定在了一起
(3) decltype(auto)
decltype(auto)是C++14新增的类型指示符,可以用来声明变量以及指示函数返回类型。在使用时,会将“=”号左边的表达式替换掉auto,再根据decltype的语法规则来确定类型。举个例子:
int e = 4;
const int* f = &e; // f是底层const
decltype(auto) j = f;//j的类型是const int* 并且指向的是e
关于lambda函数
- 利用lambda表达式可以编写内嵌的匿名函数,用以替换独立函数或者函数对象;
- 每当你定义一个lambda表达式后,编译器会自动生成一个匿名类(这个类当然重载了()运算符),我们称为闭包类型(closure type)。那么在运行时,这个lambda表达式就会返回一个匿名的闭包实例,其实一个右值。所以,我们上面的lambda表达式的结果就是一个个闭包。闭包的一个强大之处是其可以通过传值或者引用的方式捕捉其封装作用域内的变量,前面的方括号就是用来定义捕捉模式以及变量,我们又将其称为lambda捕捉块。
- lambda表达式的语法定义如下:
[capture] (parameters) mutable ->return-type {statement};
- lambda必须使用尾置返回来指定返回类型,可以忽略参数列表和返回值,但必须永远包含捕获列表和函数体;
结构体内存对齐alignas和alignof
- 结构体内成员按照声明顺序存储,第一个成员地址和整个结构体地址相同。
- 未特殊说明时,按结构体中size最大的成员对齐(若有double成员,按8字节对齐。)
c++11以后引入两个关键字alignas和alignof。其中alignof
可以计算出类型的对齐方式,alignas
可以指定结构体的对齐方式。
但是alignas
在某些情况下是不能使用的,具体见下面的例子:
// alignas 生效的情况
struct Info {
uint8_t a; // uint8_t :unsigned char
uint16_t b; //uint16_t:unsigned short int
uint8_t c;
};
std::cout << sizeof(Info) << std::endl; // 6 2 + 2 + 2
std::cout << alignof(Info) << std::endl; // 2
struct alignas(4) Info2 {
uint8_t a;
uint16_t b;
uint8_t c;
};
std::cout << sizeof(Info2) << std::endl; // 8 4 + 4
std::cout << alignof(Info2) << std::endl; // 4
alignas
将内存对齐调整为4个字节。所以sizeof(Info2)
的值变为了8。
// alignas 失效的情况
struct Info {
uint8_t a;
uint32_t b;
uint8_t c;
};
std::cout << sizeof(Info) << std::endl; // 12 4 + 4 + 4
std::cout << alignof(Info) << std::endl; // 4
struct alignas(2) Info2 {
uint8_t a;
uint32_t b;
uint8_t c;
};
std::cout << sizeof(Info2) << std::endl; // 12 4 + 4 + 4
std::cout << alignof(Info2) << std::endl; // 4
若alignas
小于自然对齐的最小单位,则被忽略。
如果想使用单字节对齐的方式,使用
alignas
是无效的。应该使用#pragma pack(push,1)
或者使用__attribute__((packed))
。#if defined(__GNUC__) || defined(__GNUG__) #define ONEBYTE_ALIGN __attribute__((packed)) #elif defined(_MSC_VER) #define ONEBYTE_ALIGN #pragma pack(push,1) #endif struct Info { uint8_t a; uint32_t b; uint8_t c; } ONEBYTE_ALIGN; #if defined(__GNUC__) || defined(__GNUG__) #undef ONEBYTE_ALIGN #elif defined(_MSC_VER) #pragma pack(pop) #undef ONEBYTE_ALIGN #endif std::cout << sizeof(Info) << std::endl; // 6 1 + 4 + 1 std::cout << alignof(Info) << std::endl; // 6
确定结构体中每个元素大小可以通过下面这种方法:
#if defined(__GNUC__) || defined(__GNUG__) #define ONEBYTE_ALIGN __attribute__((packed)) #elif defined(_MSC_VER) #define ONEBYTE_ALIGN #pragma pack(push,1) #endif /** * 0 1 3 6 8 9 15 * +-+---+-----+---+-+-------------+ * | | | | | | | * |a| b | c | d |e| pad | * | | | | | | | * +-+---+-----+---+-+-------------+ */ struct Info { uint16_t a : 1; uint16_t b : 2; uint16_t c : 3; uint16_t d : 2; uint16_t e : 1; uint16_t pad : 7; } ONEBYTE_ALIGN; #if defined(__GNUC__) || defined(__GNUG__) #undef ONEBYTE_ALIGN #elif defined(_MSC_VER) #pragma pack(pop) #undef ONEBYTE_ALIGN #endif std::cout << sizeof(Info) << std::endl; // 2 std::cout << alignof(Info) << std::endl; // 1
这种处理方式是
alignas
处理不了的。
STL模板库
什么是STL
C++ STL从广义来讲包括了三类:算法,容器和迭代器。
算法包括排序,复制等常用算法,以及不同容器特定的算法。
容器就是数据的存放形式,包括序列式容器和关联式容器,序列式容器就是list,vector等,关联式容器就是set,map等。
迭代器就是在不暴露容器内部结构的情况下对容器的遍历。除此之外,STL中迭代器一个最重要的作用就是作为容器与STL算法的粘合剂。
迭代器的作用就是提供一个遍历容器内部所有元素的接口,因此迭代器内部必须保存一个与容器相关联的指针,然后重载各种运算操作来遍历,其中最重要的是*运算符与->运算符,以及++、--等可能需要重载的运算符重载。这和C++中的智能指针很像,智能指针也是将一个指针封装,然后通过引用计数或是其他方法完成自动释放内存的功能。
迭代器:++it、it++哪个好,为什么
前置返回一个引用,后置返回一个对象;
// ++i实现代码为: int& operator++() { *this += 1; return *this; }
前置不会产生临时对象,后置必须产生临时对象,临时对象会导致效率降低
//i++实现代码为: int operator++(int) { int temp = *this; ++*this; return temp; }
STL中hashtable的实现
STL中的hashtable使用的是开链法解决hash冲突问题,如下图所示。
SGI STL的哈希表结构:
- 哈希表用vector实现
- vector的一个索引出代表一个桶子(bucket)
- 每个桶子内含有一串链表,链中有含有节点
hashtable的迭代器只提供前进操作,不提供后退操作。
向前操作:首先尝试从目前所指的节点出发,前进一个位置(节点),由于节点被安置于list内,所以利用节点的next指针即可轻易完成前进操作,如果目前正巧是list的尾端,就跳至下一个bucket身上,那正是指向下一个list的头部节点。
在hashtable设计bucket的数量上,其内置了28个质数[53, 97, 193,...,429496729],在创建hashtable时,会根据存入的元素个数选择大于等于元素个数的质数作为hashtable的容量(vector的长度),其中每个bucket所维护的linked-list长度也等于hashtable的容量。如果插入hashtable的元素个数超过了bucket的容量,就要进行重建table操作,即找出下一个质数,创建新的buckets vector,重新计算元素在新hashtable的位置。
之所以选择vector为存放桶元素的基础容器,主要是因为vector容器本身具有动态扩容能力,无需人工干预。
**什么时候扩容以及如何扩容:**当向容器添加元素的时候,会判断当前容器的元素个数,如果大于等于阈值---即当前数组的长度乘以加载因子的值的时候,就要自动扩容啦。**扩容(resize)**就是重新计算容量,向HashMap对象里不停的添加元素,而HashMap对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。
红黑树
1、它是二叉排序树(继承二叉排序树特性):
- 若左子树不空,则左子树上所有结点的值均小于或等于它的根结点的值。
- 若右子树不空,则右子树上所有结点的值均大于或等于它的根结点的值。
- 左、右子树也分别为二叉排序树。
2、它满足如下几点要求:
- 树中所有节点非红即黑。
- 根节点必为黑节点。
- 红节点的子节点必为黑(黑节点子节点可为黑)。
- 从根到NULL的任何路径上黑结点数相同。
3、查找时间一定可以控制在O(logn)
map、set是如何实现的,红黑树是怎么能够同时实现这两种容器?为什么使用红黑树;
- 他们的底层都是以红黑树的结构实现,因此插入删除等操作都在O(logn)时间内完成,因此可以完成高效的插入删除;
- 在这里我们定义了一个模版参数,如果它是key那么它就是set,如果它是map,那么它就是map;底层是红黑树,实现map的红黑树的节点数据类型是key+value,而实现set的节点数据类型是value
- 因为map和set要求是自动排序的,红黑树能够实现这一功能,而且时间复杂度比较低
set和map的区别,multimap和multiset的区别
set只提供一种数据类型的接口,但是会将这一个元素分配到key和value上,而且它的compare_function用的是 identity()函数,这个函数是输入什么输出什么,这样就实现了set机制,set的key和value其实是一样的了。其实他保存的是两份元素,而不是只保存一份元素
map则提供两种数据类型的接口,分别放在key和value的位置上,他的比较function采用的是红黑树的compare_function(),保存的确实是两份元素。
他们两个的insert都是采用红黑树的insert_unique() 独一无二的插入 。
multimap和map的唯一区别就是:multimap调用的是红黑树的insert_equal(),可以重复插入而map调用的则是独一无二的插入insert_unique(),multiset和set也一样,底层实现都是一样的,只是在插入的时候调用的方法不一样。
STL中list、deque、vector之间区别
- list是由双向链表实现的,节点不保证在存储空间中连续存在。只能通过指针访问数据,所以list的随机存取非常没有效率;但由于链表的特点,能高效地进行插入和删除。
- deque是一种双向开口的连续线性空间,所谓双向开口,意思是可以在头尾两端分别做元素的插入和删除操作;
- deque和vector最大的差异,一在于deque允许常数时间内对头部进行元素的插入或移除操作,二在于deque没有所谓容量概念,vector被定义后一般会有一个预定的空间capacity(),空间不够的时候才回去申请新的空间,deque在定义存储空间之后没有预设的空间大小,是在元素添加的时候实时扩充空间的,所以没有capacity()函数;
STL中unordered_map和map的区别和应用场景
联系和区别:
- 联系:都可以通过key快速索引到value
- 区别:
- map支持键值的自动排序,底层机制是红黑树,红黑树的查询和维护时间复杂度均为O(logn),但是空间占用比较大,因为每个节点要保持父节点、孩子节点及颜色的信息
- unordered_map是使用哈希表实现,通过hash函数计算元素位置,其查询时间复杂度为O(1),维护时间与bucket桶所维护的list长度有关,但是建立hash表耗时较大。它内部是无序的,hash_table使用的开链法进行冲突避免,所有hash_map采用开链法进行冲突解决。
从两者的底层机制和特点可以看出:map适用于有序数据的应用场景,unordered_map适用于高效查询的应用场景。
C++中新增了string,它与C语言中的 char *有什么区别吗?它是如何实现的?
- string 需要头文件#include string,是C++的一个类,专门实现字符串的相关操作。数据类型为
string
,字符串结尾没有\0
字符(字符串结束符);string的内存管理是由系统处理,除非系统内存池用完,不然不会出现这种内存问题。
- const char * 可以表示C中的字符串,char *是一个指向字符的指针,可以指向一个字符,也可以表示字符数组的首地址,用来表示一个字符串,类型为
const char *
,字符串结尾以\0
结尾。char *的内存管理由用户自己处理,很容易出现内存不足的问题。字符串处理函数需要添加头文件#include<string.h>
**string的底层实现:**string继承自basic_string,其实是对char*进行了封装,封装的string包含了char*数组,容量,长度等等属性。实际操作过程我们可以用const char*给string类初始化。
string可以进行动态扩展,在每次扩展的时候另外申请一块原空间大小两倍的空间(2^n),然后将原字符串拷贝过去,并加上新增的内容。
三者之间的转换:
a) string转const char*
string s = “abc”;
const char* c_s = s.c_str();
b) const char* 转string,直接赋值即可
const char* c_s = “abc”;
string s(c_s);
c) string 转char*
string s = “abc”;
char* c;
const int len = s.length();
c = new char[len+1];
strcpy(c,s.c_str());
d) char* 转string
char* c = “abc”;
string s(c);
e) const char* 转char*
const char* cpc = “abc”;
char* pc = new char[strlen(cpc)+1];
strcpy(pc,cpc);
f) char* 转const char*,直接赋值即可
char* pc = “abc”;
const char* cpc = pc;
STL中vector和list的区别和应用,怎么找某vector或者list的倒数第二个元素?
(1)vector
1)vector和数组
vector和数组类似,拥有一段连续的内存空间,并且起始地址不变。因此能高效的进行随机存取,时间复杂度为O(1);但因为内存空间是连续的,所以在进行插入和删除操作时,会造成内存块的拷贝,时间复杂度为O(n)。
另外,当数组中内存空间不够时,会重新申请一块内存空间并进行内存拷贝。vector是可以实现动态增长的对象数组,支持对数组高效率的访问和在数组尾端的删除和插入操作,在中间和头部删除和插入相对不易,需要挪动大量的数据。
它与数组最大的区别就是vector不需程序员自己去考虑容量问题,库里面本身已经实现了容量的动态增长,而数组需要程序员手动写入扩容函数进行扩容。
2)vector如何实现动态增长
size()函数返回的是已用空间大小,capacity()返回的是总空间大小,capacity()-size()则是剩余的可用空间大小。当size()和capacity()相等,说明vector目前的空间已被用完,如果再添加新元素,则会引起vector空间的动态增长。
由于动态增长会引起重新分配内存空间、拷贝原空间、释放原空间,这些过程会降低程序效率。因此,可以使用reserve(n)预先分配一块较大的指定大小的内存空间,这样当指定大小的内存空间未使用完时,是不会重新分配内存空间的,这样便提升了效率。只有当n>capacity()时,调用reserve(n)才会改变vector容量。
resize() :容器内的对象内存空间是真正存在的,只改变容器的元素数目,未改变容器大小。
reserve(): 仅仅只是修改了 capacity 的值,容器内的对象并没有真实的内存空间(空间是"野"的),如果此时使用“[ ]”来访问,则可能会越界。
#include <iostream>
#include <vector>
using std::vector;
int main(void)
{
vector<int> v;
std::cout<<"v.size() == " << v.size() << " v.capacity() = " << v.capacity() << std::endl;
v.reserve(10); //容器预留len个元素长度,但在空间内并不真正创建元素对象
std::cout<<"v.size() == " << v.size() << " v.capacity() = " << v.capacity() << std::endl;
v.resize(10); //重新指定容器的长度
v.push_back(0);
std::cout<<"v.size() == " << v.size() << " v.capacity() = " << v.capacity() << std::endl;
return 0;
}
//结果是
0 0
0 10
11 15 //重新分配多少,不同库有不同的实现,这里是增加原先大小的一半;
Noting:
- 对vector的任何操作,一旦引起空间重新配置,指向原vector的所有迭代器就都失效了 ;
- 初始时刻vector的capacity为0,塞入第一个元素后capacity增加为1;
- 不同的编译器,vector有不同的扩容大小。在vs下是1.5倍,在GCC下是2倍;空间和时间的权衡。简单来说, 空间分配的多,平摊时间复杂度低,但浪费空间也多。
- 频繁对vector调用push_back()对性能是有影响的,这是因为每插入一个元素,如果空间够用的话还能直接插入,若空间不够用,则需要重新配置空间,移动数据,释放原空间等操作,对程序性能会造成一定的影响,这种情况可以考虑使用list。
3)vector如何释放空间
由于vector的内存占用空间只增不减,比如分配了10,000个字节,然后erase掉后面9,999个,留下一个有效元素,但是内存占用仍为10,000个。所有内存空间是在vector析构时候才能被系统回收。empty()用来检测容器是否为空的,clear()可以清空所有元素,单vector所占用的内存空间依然存在,无法保证内存的回收。
如果需要空间动态缩小,可以考虑使用deque。
如果使用vector,可以用swap()来帮助你释放多余内存或者清空全部内存。
vector(Vec).swap(Vec); //将Vec中多余内存清除;
vector().swap(Vec); //清空Vec的全部内存;
实例
#include <iostream>
#include <vector>
using namespace std;
int main ()
{
vector<int> vec (100,100); // three ints with a value of 100
vec.push_back(1);
vec.push_back(2);
cout <<"vec.size(): " << vec.size() << endl;
cout <<"vec.capasity(): " << vec.capacity() << endl;
vector<int>(vec).swap(vec); //清空vec中多余的空间,相当于vec.shrink_to_fit();
cout <<"vec.size(): " << vec.size() << endl;
cout <<"vec.capasity(): " << vec.capacity() << endl;
vector<int>().swap(vec); //清空vec的全部空间
cout <<"vec.size(): " << vec.size() << endl;
cout <<"vec.capasity(): " << vec.capacity() << endl;
return 0;
}
/*
运行结果:
vec.size(): 102
vec.capasity(): 200
vec.size(): 102
vec.capasity(): 102
vec.size(): 0
vec.capasity(): 0
*/
总结:
采用成倍方式扩容,可以保证常数的时间复杂度,而增加指定大小的容量只能达到O(n)的时间复杂度,因此,使用成倍的方式扩容。
- 考虑可能产生的堆空间浪费,成倍增长倍数不能太大,使用较为广泛的扩容方式有两种,以2倍的方式扩容,或者以1.5倍的方式扩容。
- 以2倍的方式扩容,导致下一次申请的内存必然大于之前分配内存的总和,导致之前分配的内存不能再被使用,所以最好倍增长因子设置为(1,2)之间:
- 向量容器vector的成员函数pop_back()可以删除最后一个元素.
- 而函数erase()可以删除由一个iterator指出的元素,也可以删除一个指定范围的元素。
- 还可以采用通用算法remove()来删除vector容器中的元素.
- 不同的是:采用remove一般情况下不会改变容器的大小,而pop_back()与erase()等成员函数会改变容器的大小。
(2)list
list是由双向链表实现的,因此内存空间是不连续的。只能通过指针访问数据,所以list的随机存取非常没有效率,时间复杂度为o(n);但由于链表的特点,能高效地进行插入和删除。list是一个双链表结构,支持对链表的双向遍历。list可以高效率的对数据元素任意位置进行访问和插入删除等操作。由于涉及对额外指针的维护,所以开销比较大。
(3)区别
- vector的随机访问效率高,但在插入和删除时(不包括尾部)需要挪动数据,不易操作。
- list的访问要遍历整个链表,它的随机访问效率低。但对数据的插入和删除操作等都比较方便,改变指针的指向即可。
- 从遍历上来说,list是单向的,vector是双向的。
- list在插入和接合操作之后,都不会造成原迭代器失效,而vector可能因为空间重新配置导致迭代器失效。
总之,如果需要高效的随机存取,而不在乎插入和删除的效率,使用vector;
如果需要大量的插入和删除,而不关心随机存取,则应使用list。
(4)怎么找某vector或者list的倒数第二个元素
int mySize = vec.size();vec.at(mySize -2);
list不提供随机访问,所以不能用下标直接访问到某个位置的元素,要访问list里的元素只能遍历,不过你要是只需要访问list的最后N个元素的话,可以用反向迭代器来遍历:
vector越界访问下标,map越界访问下标?vector删除元素时会不会释放空间?
通过下标访问vector中的元素时会做边界检查,但该处的实现方式要看具体IDE,不同IDE的实现方式不一样,确保不可访问越界地址。
map的下标运算符[]的作用是:将key作为下标去执行查找,并返回相应的值;如果不存在这个key,就将一个具有该key和value的某值插入这个map。
map的find函数:用关键码执行查找,找到了返回该位置的迭代器;如果不存在这个关键码,就返回尾迭代器。
erase()函数,只能删除内容,不能改变容量大小;它删除了itVect迭代器指向的元素,并且返回要被删除的itVect之后的迭代器,迭代器相当于一个智能指针;clear()函数,只能清空内容,不能改变容量大小;如果要想在删除内容的同时释放内存,那么你可以选择deque容器。
容器内部删除一个元素
- 顺序容器(序列式容器,比如vector、deque)
erase迭代器不仅使所指向被删除的迭代器失效,而且使被删元素之后的所有迭代器失效(list除外),所以不能使用erase(it++)的方式,但是erase的返回值是下一个有效迭代器;It = c.erase(it);
- 关联容器(关联式容器,比如map、set、multimap、multiset等)
erase迭代器只是被删除元素的迭代器失效,但是返回值是void,所以要采用erase(it++)的方式删除迭代器;c.erase(it++)
STL中sort()算法使用什么实现的,stable_sort()呢
STL中的sort是用快速排序和插入排序结合的方式实现的,stable_sort()是归并排序。
map实现
map插入方式
mapStudent.insert(pair<int, string>(1, "student_one")); //插入pair数据
mapStudent.insert(map<int, string>::value_type (1, "student_one"));//插入value_type数据
mapStudent.insert(make_pair(1, "student_one"));//在insert函数中使用make_pair()函数
mapStudent[1] = "student_one"; //用数组方式插入数据
map<string,int> 插入key为1到10但为string类型,遍历输出key顺序是啥
存入map中的数据按照key进行排序,string类型的比较规则是:
1)如果两个字符串长度相等,且每一个相应位置上的字符都一样,
2)如果两个字符串长度不相等,则以第一个不相同的字符作为基准,而不考虑他们的长度及其后字符的比较结果。
3)如果两个字符串长度不相等,且所有相应位置上的字符都一样,则长者为大。
所以遍历输出key顺序为:9、.....2、10、1
STL中迭代器失效的情况有哪些?
以vector为例:
插入元素:
1、尾后插入:size < capacity时,首迭代器不失效尾迭代失效(未重新分配空间),size == capacity时,所有迭代器均失效(需要重新分配空间)。
2、中间插入:中间插入:size < capacity时,首迭代器不失效但插入元素之后所有迭代器失效,size == capacity时,所有迭代器均失效。
删除元素:
尾后删除:只有尾迭代失效。
中间删除:删除位置之后所有迭代失效。
deque 和 vector 的情况类似,
而list双向链表每一个节点内存不连续, 删除节点仅当前迭代器失效,erase返回下一个有效迭代器;
map/set等关联容器底层是红黑树删除节点不会影响其他节点的迭代器, 使用递增方法获取下一个迭代器 mmp.erase(iter++);
unordered_(hash) 迭代器意义不大, rehash之后, 迭代器应该也是全部失效
容器总结
容器 | 底层数据结构 | 时间复杂度 | 有无序 | 可不可重复 | 其它 |
---|---|---|---|---|---|
序列容器:实现可以按顺序访问的数据结构 | |||||
vector | 动态连续数组 | 随机读改、尾部插入、尾部删除 O(1)、头部插入、头部删除 O(n) | 无序 | 可重复 | 支持随机访问 |
list | 双向链表 | 插入、删除 O(1) | 无序 | 可重复 | 不支持随机访问,支持快速增删 |
deque | 双端队列 | 头尾插入、头尾删除 O(1) | 无序 | 可重复 | 一个中央控制器 + 多个缓冲区,支持首尾快速增删,支持随机访问 |
arrays | 静态连续数组 | 随机读改 O(1) | 无序 | 可重复 | 支持随机访问 |
forward list(C++ 11引入) | 单向链表 | 插入、删除 O(1) | 无序 | 可重复 | 不支持随机访问 |
容器适配器:为顺序容器提供不同的接口 | |||||
queue | deque / list | 尾部插入、头部删除 O(1) | 无序 | 可重复 | deque 或 list 封闭头端开口,不用 vector 的原因应该是容量大小有限制,扩容耗时 |
priority_queue | vector + max-heap | 插入、删除 O(log2n) | 有序 | 可重复 | vector容器+heap处理规则 |
stack | deque / list | 顶部插入、顶部删除 O(1) | 无序 | 可重复 | deque 或 list 封闭头端开口,不用 vector 的原因应该是容量大小有限制,扩容耗时 |
关联容器:实现可以快速搜索的排序数据结构O(logn) | |||||
set | 红黑树 | 插入、删除、查找 O(log2n) | 有序 | 不可重复 | |
multiset | 红黑树 | 插入、删除、查找 O(log2n) | 有序 | 可重复 | |
map | 红黑树 | 插入、删除、查找 O(log2n) | key有序 | key不可重复 | |
multimap | 红黑树 | 插入、删除、查找 O(log2n) | 有序 | 可重复 | |
无序关联容器(C++ 11引入):实现快速搜索的无序数据结构 | |||||
unordered_set | 哈希表 | 插入、删除、查找 O(1) 最差 O(n) | key无序 | key不可重复 | |
unordered_multiset | 哈希表 | 插入、删除、查找 O(1) 最差 O(n) | 无序 | 可重复 | |
unordered_map | 哈希表 | 插入、删除、查找 O(1) 最差 O(n) | 无序 | 不可重复 | |
unordered_multimap | 哈希表 | 插入、删除、查找 O(1) 最差 O(n) | 无序 | 可重复 |
STL每种容器对应的迭代器:
容器 | 迭代器 |
---|---|
vector、deque | 随机访问迭代器 |
stack、queue、priority_queue | 无 |
list、(multi)set/map | 双向迭代器 |
unordered_(multi)set/map、forward_list | 前向迭代器 |
内存管理
Windows和Linux环境下内存分布情况
通过这张图你可以看到,用户空间内存,从低到高分别是 7 种不同的内存段:
- 程序文件段,包括二进制可执行代码;
- 已初始化数据段.data,包括已初始化的全局变量和静态常量;
- 未初始化数据段.bss,包括未初始化的全局变量和静态变量(局部+全局);
- 堆段,包括动态分配的内存,从低地址开始向上增长;
- 文件映射段,包括动态库、共享内存等,从低地址开始向上增长(跟硬件和内核版本有关)
- 栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是
8 MB
。当然系统也提供了参数,以便我们自定义大小;
一个由C/C++编译的程序占用的内存分为哪几个部分
C++中的内存分区,分别是堆、栈、全局/静态存储区、常量存储区和代码区。如下图所示
1、栈区(stack)— 地址向下增长,由编译器自动分配释放,存放函数内的参数值,局部变量的值等。其操作方式类似于数据结构中的数据结构中的栈,先进后出。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。
2、堆区(heap)— 地址向上增长,一般由程序员分配释放,若程序员不释放,程序结束时可能由OS回收。注意它与数据结构中的堆是两回事,分配方式倒是类似于链表。
3、全局(静态)存储区(static)—内存在程序编译的时候就已经分配好,这块内存在程序的整个运行期间都存在。全局变量和静态变量的存储是放在一块的。初始化的全局变量和静态变量在一块区域,未初始化的全局变量和未初始化的静态变量在相邻的另一块区域。 - 程序结束后有系统释放
4、文字常量区(常量存储区.rodata) —const、#define、“string”等定义的数据常量(ro = read only 这段内存空间数据受系统保护为只读,不允许修改,如果尝试更改则会出现Segmentation Fault)。程序结束后由系统释放。
5、程序代码区(text)—存放函数体的二进制代码。
还有一种说法:
在C++中,内存分为五个区,它们分别是堆,栈,自由存储区,全局/静态存储区和常量存储区
堆,由程序员进行分配释放,就是那些由new分配的内存块,一般一个new对应一个delete
栈,由操作系统自动分配释放,存放函数的参数值,局部变量的值等,函数结束时这些存储单元被自动的释放
自由存储区,就是由malloc分配的内存块,一般一个malloc对应一个free
全局/静态存储区:全局变量和静态变量被分配在同一块内存中
常量存储区:这是一块比较特殊的存储区,里面存储的是常量,不允许修改
常用内存分配方式
(1) 从静态存储区域分配。内存在程序编译的时候就已经分配好,这块内存在程序的整个运行期间都存在。例如全局变量,static变量。
(2) 在栈上创建。在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。
(3) 从堆上分配,亦称动态内存分配。程序在运行的时候用malloc或new申请任意多少的内存,程序员自己负责在何时用free或delete释放内存。动态内存的生存期由我们决定,使用非常灵活,但问题也最多。
常见内存分配内存错误
(1)内存分配未成功,却使用了它。
常用解决办法是,在使用内存之前检查指针是否为NULL。如果指针p是函数的参数,那么在函数的入口处用assert(p!=NULL)进行检查。如果是用malloc或new来申请内存,应该用if(p==NULL) 或if(p!=NULL)进行防错处理。
(2)内存分配虽然成功,但是尚未初始化就引用它。
犯这种错误主要有两个起因:一是没有初始化的观念;二是误以为内存的缺省初值全为零,导致引用初值错误(例如数组)。内存的缺省初值究竟是什么并没有统一的标准,尽管有些时候为零值,无论用何种方式创建数组,都别忘了赋初值,即便是赋零值也不可省略。
(3)内存分配成功并且已经初始化,但操作越过了内存的边界。
数组操作越界。
(4)忘记了释放内存,造成内存泄露。
含有这种错误的函数每被调用一次就丢失一块内存。刚开始时系统的内存充足,你看不到错误。终有一次程序突然挂掉,系统出现提示:内存耗尽。动态内存的申请与释放必须配对,程序中malloc与free的使用次数一定要相同,否则肯定有错误(new/delete同理)。
(5)释放了内存却继续使用它。常见于以下有三种情况:
- 程序中的对象调用关系过于复杂,实在难以搞清楚某个对象究竟是否已经释放了内存,此时应该重新设计数据结构,从根本上解决对象管理的混乱局面。
- 函数的return语句写错了,注意不要返回指向“栈内存”的“指针”或者“引用”,因为该内存在函数体结束时被自动销毁。
- 使用free或delete释放了内存后,没有将指针设置为NULL。导致产生“野指针”。
其他
- 逻辑运算符两侧运算对象的数据类型可以是任何类型的数据。
笔试
1.宏定义解析
#define offsetof(TYPE, MEMBER) ((size_t) &((TYPE *)0)->sex)
typedef struct tagStudent{
int age;
int class;
char sex;
}Student;
printf("%d",offsetof(Student_t,sex));//结果为8
先分析一下这个 宏的运行机理,一共4步:
- ( (TYPE *)0 ) 将零转型为TYPE类型指针;
- ((TYPE *)0)->MEMBER 访问结构中的数据成员;
- &( ( (TYPE *)0 )->MEMBER )取出数据成员的地址;
- (size_t)(&(((TYPE*)0)->MEMBER))结果转换类型。巧妙之处在于将0转 换成(TYPE*),结构以内存空间首地址0作为起始地址,则成员地址自然为偏移地址;