【C++】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))
程序在执行int main(int argc, char *argv[])时的内存结构,你了解吗?
  • 参数的含义是程序在命令行下运行的时候,需要输入argc 个参数,每个参数是以char 类型输入的,依次存在数组里面,数组是 argv[],所有的参数在指针
  • char * 指向的内存中,数组的中元素的个数为 argc 个,第一个参数为程序的名称。
C++从代码到可执行程序- 预处理、编译、汇编、链接;
  • 预编译
  • 主要处理源代码文件中的以“#”开头的预编译指令。处理规则见下:
  1. 删除所有的#define,展开所有的宏定义。
  2. 处理所有的条件预编译指令,如“#if”、“#endif”、“#ifdef”、“#elif”和“#else”。
  3. 处理“#include”预编译指令,将文件内容替换到它的位置,这个过程是递归进行的,文件中包含其他文件。
  4. 删除所有的注释,“//”和“/**/”。
  5. 保留所有的#pragma 编译器指令,编译器需要用到他们,如:#pragma once 是为了防止有文件被重复引用。
  6. 添加行号和文件标识,便于编译时编译器产生调试用的行号信息,和编译时产生编译错误或警告是能够显示行号。
  • 编译
  • 把预编译之后生成的xxx.i或xxx.ii文件,进行一系列词法分析、语法分析、语义分析及优化后,生成相应的汇编代码文件。
  1. 词法分析:利用类似于“有限状态机”的算法,将源代码程序输入到扫描机中,将其中的字符序列分割成一系列的记号。
  2. 语法分析:语法分析器对由扫描器产生的记号,进行语法分析,产生语法树。由语法分析器输出的语法树是一种以表达式为节点的树。
  3. 语义分析:语法分析器只是完成了对表达式语法层面的分析,语义分析器则对表达式是否有意义进行判断,其分析的语义是静态语义——在编译期能分期的语义,相对应的动态语义是在运行期才能确定
    的语义。
  4. 优化:源代码级别的一个优化过程。
  5. 目标代码生成:由代码生成器将中间代码转换成目标机器代码,生成一系列的代码序列——汇编语言表示。
  6. 目标代码优化:目标代码优化器对上述的目标机器代码进行优化:寻找合适的寻址方式、使用位移来替代乘法运算、删除多余的指令等。
  • 汇编
  • 将汇编代码转变成机器可以执行的指令(机器码文件)。
  • 汇编器的汇编过程相对于编译器来说更简单,没有复杂的语法,也没有语义,更不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译过来,汇编过程有汇编器as完成。
  • 经汇编之后,产生目标文件(与可执行文件格式几乎一样)xxx.o(Windows下)、xxx.obj(Linux下)。
  • 链接
  • 将不同的源文件产生的目标文件进行链接,从而形成一个可以执行的程序。链接分为静态链接和动态链接:
  • 静态链接
  • 函数和数据被编译进一个二进制文件。在使用静态库的情况下,在编译链接可执行文件时,链接器从库中复制这些函数和数据并把它们和应用程序的其它模块组合起来创建最终的可执行文件。
  • 空间浪费:因为每个可执行程序中对所有需要的目标文件都要有一份副本,所以如果多个程序对同一个目标文件都有依赖,会出现同一个目标文件都在内存存在多个副本;
  • 更新困难:每当库函数的代码修改了,这个时候就需要重新进行编译链接形成可执行程序。
  • 运行速度快:但是静态链接的优点就是,在可执行程序中已经具备了所有执行程序所需要的任何东西,在执行的时候运行速度快。
  • 动态链接
  • 动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。
  • 在执行时,需要调用其对应动态链接库的命令。
  • 共享库:就是即使需要每个程序都依赖同一个库,但是该库不会像静态链接那样在内存中存在多份副本,而是这多个程序在执行时共享同一份副本
  • 更新方便:更新时只需要替换原来的目标文件,而无需将所有的程序再重新链接一遍。当程序下一次运行时,新版本的目标文件会被自动加载到内存并且链接起来,程序就完成了升级的目标。
  • 性能损耗:因为把链接推迟到了程序运行时,所以每次执行程序都需要进行链接,所以性能会有一定损失。
    • 动态库中的数据,不同进程运行时,有自己的一份拷贝;
      在这里插入图片描述

C++特点

为什么C++没有垃圾回收机制?这点跟Java不太一样。
  • 首先,实现一个垃圾回收器会带来额外的空间和时间开销。你需要开辟一定的空间保存指针的引用计数和对他们进行标记mark。然后需要单独开辟一个线程在空闲的时候进行free操作。
  • 垃圾回收会使得C++不适合进行很多底层的操作。
变量声明和定义区别?
  • 声明仅仅是把变量的声明的位置及类型提供给编译器,并不分配内存空间;定义要在定义的地方为其分配存储空间。
  • 相同变量可以在多处声明(外部变量extern),但只能在一处定义。
strlen和sizeof区别?
  • sizeof是运算符,并不是函数,结果在编译时得到而非运行中获得;strlen是字符处理的库函数。
  • sizeof参数可以是任何数据的类型或者数据(sizeof参数不退化);strlen的参数只能是字符指针且结尾是’\0’的字符串。
  • 因为sizeof值在编译时确定,所以不能用来得到动态分配(运行时分配)存储空间的大小。
  • c++的数组作为函数形参的退化问题;
  • sizeof参数不退化;即传入参数不退化,下例中,一个10、一个4;
void cal(char var[])
{
	cout << sizeof(var) << endl;	//4
}
int main() {
	char var[10];
	cout << sizeof(var) << endl;	//10
	cal(var);
}
C++中struct和class的区别
  • 相同点

*两者都拥有成员函数、公有和私有部分
*任何可以使用class完成的工作,同样可以使用struct完成

  • 不同点

*两者中如果不对成员不指定公私有,struct默认是公有的,class则默认是私有的class默认是>*private继承,而struct模式是public继承

  • 引申:C++和C的struct区别

*C语言中:struct是用户自定义数据类型(UDT);C++中struct是抽象数据类型(ADT),支持成员函数的定义,(C++中的struct能继承,能实现多态)
*C中struct是没有权限的设置的,且struct中只能是一些变量的集合体,可以封装数据却不可以隐藏数据,而且成员不可以是函数
*C++中,struct增加了访问权限,且可以和类一样有成员函数,成员默认访问说明符为public(为了与C兼容)
*struct作为类的一种特例是用来自定义数据结构的。一个结构标记声明后,在C中必须在结构标记前加上struct,才能做结构类型名(除:typedef struct class{};);C++中结构体标记(结构体名)可以直接作为结构体类型名使用,此外结构体struct在C++中被当作类的一种特例

关键字

final和override关键字
  • override:用于标记是对虚函数重写,如果写错了函数名或者参数列表,会报错;它指定了子类的这个虚函数是重写的父类的
  • 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
{
};
  • 重载Overloading是一个类中多态性的一种表现。
  • 方法重写又称方法覆盖。
volatile、mutable和explicit关键字的用法
  • volatile
  • volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改,比如:操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。
  • 当要求使用 volatile 声明的变量的值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。
  • volatile定义变量的值是易变的,每次用到这个变量的值的时候都要去重新读取这个变量的值,而不是读寄存器内的备份。
  • 多线程中被几个任务共享的变量需要定义为volatile类型
  • volatile用在如下的几个地方:
  • 中断服务程序中修改的供其它程序检测的变量需要加volatile;
  • 多任务环境下各任务间共享的标志应该加volatile;
  • 存储器映射的硬件寄存器通常也要加volatile说明,因为每次对它的读写都可能由不同意义;
  • volatile 指针
  • volatile 指针和 const 修饰词类似,const 有常量指针和指针常量的说法,volatile 也有相应的概念
  • 修饰由指针指向的对象、数据是 const 或 volatile 的:const char* cpch;volatile char* vpch;
  • 指针自身的值(一个代表地址的整数变量),是 const 或 volatile 的:char* const pchc;char* volatile pchv;
  • 注意:
  • 可以把一个非volatile int赋给volatile int,但是不能把非volatile对象赋给一个volatile对象。
  • 除了基本类型外,对用户定义类型也可以用volatile类型进行修饰。
  • C++中一个有volatile标识符的类只能访问它接口的子集,一个由类的实现者控制的子集。用户只能用const_cast来获得对类型接口的完全访问。此外,volatile向const一样会从类传递到它的成员。
  • 多线程下的volatile
  • 当两个线程都要用到某一个变量且该变量的值会被改变时,应该用volatile声明,该关键字的作用是防止优化编译器把变量从内存装入CPU寄存器中。
  • 如果变量被装入寄存器,那么两个线程有可能一个使用内存中的变量,一个使用寄存器中的变量,这会造成程序的错误执行。
  • volatile的意思是让编译器每次操作该变量时一定要从内存中真正取出,而不是使用已经存在寄存器中的值。
  • mutable
  • mutable的中文意思是“可变的,易变的”,跟constant(既C++中的const)是反义词。
  • 在C++中,mutable也是为了突破const的限制而设置的。
  • 被mutable修饰的变量,将永远处于可变的状态,即使在一个const函数中。
  • 我们知道,如果类的成员函数不会改变对象的状态,那么这个成员函数一般会声明成const的。但是,有些时候,我们需要在const函数里面修改一些跟类状态无关的数据成员,那么这个函数就应该被mutable来修饰,并且放在函数后后面关键字位置。
class person
{
	int m_A;
	mutable int m_B;//特殊变量 在常函数里值也可以被修改public:
	void add() const//在函数里不可修改this指针指向的值 常量指针      
	{
		m_A=10;//错误  不可修改值,this已经被修饰为常量指针
		m_B=20;//正确
	}
}
class person
{
	int m_A;
	mutable int m_B;//特殊变量 在常函数里值也可以被修改 
}
int main()
{
	const person p;//修饰常对象 不可修改类成员的值
	p.m_A=10;//错误,被修饰了指针常量
	p.m_B=200;//正确,特殊变量,修饰了mutable
}
  • explicit
  • explicit关键字用来修饰类的构造函数;
  • 被修饰的构造函数的类,不能发生相应的隐式类型转换,只能以显示的方式进行类型转换,注意以下几点:
  • explicit 关键字只能用于类内部的构造函数声明上;
  • explicit 关键字作用于单个参数的构造函数;
  • 被explicit修饰的构造函数的类,不能发生相应的隐式类型转换;
static的用法和作用
  • 隐藏
  • 未加static前缀的全局变量和函数都具有全局可见性;加上static ,就是只对加static 的模块可见的全局变量;
  • 内容全局
  • 共有两种变量存储在静态存储区:全局变量和static变量,只不过和全局变量比起来,static可以控制变量的可见范围;说到底static还是用来隐藏的;
  • 存储在静态数据区的变量会在程序刚开始运行时就完成初始化,也是唯一的一次初始化;
  • 默认值
  • static变量,默认初始化为0
  • 全局变量也具备这一属性,因为全局变量也存储在静态数据区。在静态数据区,内存中所有的字节默认值都是0;
  • 类成员声明static :
  1. 函数体内static变量的作用范围为该函数体,该变量的内存只被分配一次,因此其值在下次调用时仍维持上次的值;
  2. 在模块内的static全局变量可以被模块内所用函数访问,但不能被模块外其它函数访问;
  3. 在模块内的static函数只可被这一模块内的其它函数调用,这个函数的使用范围被限制在声明它的模块内;
  4. 在类中的static成员变量属于整个类所拥有,对类的所有对象只有一份拷贝;
  5. 在类中的static成员函数属于整个类所拥有,这个函数不接收this指针,因而只能访问类的static成员变量。
  6. static类对象必须要在类外进行初始化,static修饰的变量先于对象存在,所以static修饰的变量要在类外初始化;
  7. static成员函数不能被virtual修饰,static成员不属于任何对象或实例,所以加上virtual没有任何实际意义;(和this 指针应该是没关系的)
  8. 构造函数、析构函数不能为静态函数;(因为就不能访问普通成员变量了,还怎么初始化)
  • 虚函数的调用关系,this->vptr->ctable->virtual function
  • 对象能调用静态函数,静态函数不能访问this 对象;

关键字的区别

define宏定义和typedef区别?
  • 执行时间不同,typedef在编译阶段有效,typedef有类型检查的功能;#define是宏定义,发生在预处理阶段,不进行类型检查;
  • 功能差异,typedef用来定义类型的别名,定义与平台无关的数据类型,与struct的结合使用等。#define不只是可以为类型取别名,还可以定义常量、变量、编译开关等。
  • 作用域不同,#define没有作用域的限制,只要是之前预定义过的宏,在以后的程序中都可以使用。而typedef有自己的作用域。
  • 宏不是语句,不在在最后加分号;typedef是语句,要加分号标识结束。
  • 注意对指针的操作,typedef char * p_char和#define p_char char *区别巨大。
define宏定义和const的区别
  • 内存占用
  • const定义的常量是变量带类型,而#define定义的只是个常数不带类型;
  • define只在预处理阶段起作用,简单的文本替换,而const在编译、链接过程中起作用;
  • define只是简单的字符串替换没有类型检查。而const是有数据类型的,是要进行判断的,可以避免一些低级错误;
  • define预处理后,占用代码段空间,const占用数据段空间;
  • const不能重定义,而define可以通过#undef取消某个符号的定义,进行重定义;
  • define独特功能,比如可以用来防止文件重复引用。
  • define只是将宏名称进行替换,在内存中会产生多分相同的备份。const在程序运行中只有一份备份,且可以执行常量折叠,能将复杂的的表达式计算出结果放入常量表
define 宏定义 和 inline 内联
  • #define是关键字,inline是函数;
  • 宏在预编译时进行,只做简单字符串替换。inline函数有类型检查,相比宏定义比较安全;
  • 内联函数在编译时直接将函数代码嵌入到目标代码中,省去函数调用的开销来提高执行效率,并且进行参数类型检查,具有返回值,可以实现重载。
  • 宏定义时要注意书写(参数要括起来)否则容易出现歧义,内联函数不会产生歧义
  • 内联函数适用场景:
  • 使用宏定义的地方都可以使用 inline 函数
  • 作为类成员接口函数来读写类的私有成员或者保护成员,会提高效率。
  • 为什么不能把所有的函数写成内联函数
  • 函数体内的代码比较长,将导致内存消耗大。
  • 函数体内有循环,函数执行时间要比函数调用开销大。
const和static的作用
  • static
  • 不考虑类的情况
  • 隐藏。所有不加static的全局变量和函数具有全局可见性,可以在其他文件中使用,加了之后只能在该文件所在的编译模块中使用
  • 默认初始化为0,包括未初始化的全局静态变量与局部静态变量,都存在全局未初始化区
  • 静态变量在函数内定义,始终存在,且只进行一次初始化,具有记忆性,其作用范围与局部变量相同,函数退出后仍然存在,但不能使用
  • 考虑类的情况
  • static成员变量:只与类关联,不与类的对象关联。定义时要分配空间,不能在类声明中初始化,必须在类定义体外部初始化,初始化时不需要标示为static;可以被非static成员函数任意访问。
  • static成员函数:不具有this指针,无法访问类对象的非static成员变量和非static成员函数;不能被声明为const、虚函数和volatile;可以被非static成员函数任意访问
  • const
  • 不考虑类的情况
  • const常量在定义时必须初始化,之后无法更改
  • const形参可以接收const和非const类型的实参,例如// i 可以是 int 型或者 const int 型void fun(const int& i){ //…}
  • 考虑类的情况
  • const成员变量:不能在类定义外部初始化,只能通过构造函数初始化列表进行初始化,并且必须有构造函数;不同类对其const数据成员的值可以不同,所以不能在类中声明时初始化
  • const成员函数:const对象不可以调用非const成员函数;非const对象都可以调用;不可以改变非mutable(用该关键字声明的变量可以在const成员函数中被修改)数据的值
  • const 只能调const;而非const 则没有要求;
  • onst类型变量可以通过类型转换符const_cast将const类型转换为非const类型;
  • 形参中,引用、指针传递加上const ,可以作为重载;而值传递加const 不能作为重载;
静态成员与普通成员的区别?
  • 生命周期
  • 静态成员变量从类被加载开始到类被卸载,一直存在;
  • 普通成员变量只有在类创建对象后才开始存在,对象结束,它的生命期结束;
  • 共享方式
  • 静态成员变量是全类共享;普通成员变量是每个对象单独享用的;
  • 定义位置
  • 普通成员变量存储在栈或堆中,而静态成员变量存储在静态全局区;
  • 初始化位置
  • 普通成员变量在类中初始化;静态成员变量在类外初始化;
  • 默认实参
  • 可以使用静态成员变量作为默认实参

构造

C++有哪几种的构造函数
  • 默认构造函数
  • 初始化构造函数(有参数)
  • 拷贝构造函数 (用于复制本类的对象)
  • 移动构造函数(move和右值引用)
  • 委托构造函数 (student() : student(1) { } // 初始化列表中委托其他构造函数)(可用于调用父类构造函数)
  • 转换构造函数(用于将其他类型的变量,隐式转换为本类对象)(只带一个形参,形参为其他类型)
  • 隐式转换
  • C++中提供了explicit关键字,在构造函数声明的时候加上explicit关键字,能够禁止隐式转换。
  • 如果构造函数只接受一个参数,则它实际上定义了转换为此类类型的隐式转换机制。可以通过将构造函数声明为explicit加以制止隐式类型转换;
  • 关键字explicit只对一个实参的构造函数有效,需要多个实参的构造函数不能用于执行隐式转换,所以无需将这些构造函数指定为explicit。
什么情况下会调用拷贝构造函数
  1. 用对象构造:用类的一个实例化对象去初始化另一个对象的时候
  2. 值传递:函数的参数是类的对象时(非引用传递);
  3. 返回为值类型:函数的返回值是函数体内局部对象的类的对象时 ,此时虽然发生(Named return Value优化)NRV优化,但是由于返回方式是值传递,所以会在返回值的地方调用拷贝构造函数;(不同编译器下可能有不同的优化)
移动构造函数
  1. 我们用对象a初始化对象b,后对象a我们就不在使用了,但是对象a的空间还在,既然拷贝构造函数,实际上就是把a对象的内容复制一份到b中,那么为什么我们不能直接使用a的空间呢?这样就避免了新的空间的分配,大大降低了构造的成本。这就是移动构造函数设计的初衷;
  2. 拷贝构造函数中,对于指针,我们一定要采用深层复制,而移动构造函数中,对于指针,我们采用浅层复制。浅层复制之所以危险,是因为两个指针共同指向一片内存空间,若第一个指针将其释放,另一个指针的指向就不合法了。
  • 所以我们只要避免第一个指针释放空间就可以了。避免的方法就是将第一个指针(比如a->value)置为NULL,这样在调用析构函数的时候,由于有判断是否为NULL的语句,所以析构a的时候并不会回收a->value指向的空间;
  1. 移动构造函数的参数和拷贝构造函数不同,拷贝构造函数的参数是一个左值引用,但是移动构造函数的初值是一个右值引用。意味着,移动构造函数的参数是一个右值或者将亡值的引用。也就是说,只用用一个右值,或者将亡值初始化另一个对象的时候,才会调用移动构造函数。而那个move语句,就是将一个左值变成一个将亡值。
为什么用成员初始化列表、哪些情况必须用;
  • 列表初始化是给数据成员分配内存空间时就进行初始化;
  • 就是说初始化这个数据成员此时函数体还未执行;
  • 就是说分配一个数据成员只要冒号后有此数据成员的赋值表达式(此表达式必须是括号赋值表达式),那么分配了内存空间后在进入函数体之前给数据成员赋值;
  • 除了内置数据类型,函数体内赋值,会多调用一次构造函数;
  • 必须用初始化列表:
  • 当初始化一个引用成员时;
  • 当初始化一个 const 常量成员时;
  • 当调用一个基类的构造函数,而它拥有一组参数时;(委托构造)
  • 当调用一个成员类的构造函数,而它拥有一组参数时;(委托构造)
成员函数里memset(this,0,sizeof(*this))会发生什么
  • 有时候类里面定义了很多int,char,struct等c语言里的那些类型的变量,我习惯在构造函数中将它们初始化为0,但是一句句的写太麻烦,所以直接就memset(this, 0, sizeof *this);将整个对象的内存全部置为0。
  • 对于这种情形可以很好的工作,但是下面几种情形是不可以这么使用的;
  • 类含有虚函数表:这么做会破坏虚函数表,后续对虚函数的调用都将出现异常;
  • 类中含有C++类型的对象:例如,类中定义了一个list的对象,由于在构造函数体的代码执行之前就对list对象完成了初始化,假设list在它的构造函数里分配了内存,那么我们这么一做就破坏了list对象的内存。
C++有几种类型的new

在C++中,new有三种典型的使用方法:plain newnothrow newplacement new

  • plain new;普通的new
  • plain new在空间分配失败的情况下,抛出异常std::bad_alloc而不是返回NULL,因此通过判断返回值是否为NULL是徒劳的;
	void* operator new(std::size_t) throw(std::bad_alloc); 
	void operator delete(void *) throw();
  • nothrow new
  • nothrow new在空间分配失败的情况下是不抛出异常,而是返回NULL;
	void * operator new(std::size_t,const std::nothrow_t&) throw(); 
	void operator delete(void*) throw();
    char *p = new(nothrow) char[10e11];     
    if (p == NULL) 
    {
        cout << "alloc failed" << endl;
    }
  • placement new
  • 这种new允许在一块已经分配成功的内存上重新构造对象或对象数组。placement new不用担心内存分配失败,因为它根本不分配内存;
  • 它做的唯一一件事情就是调用对象的构造函数。
	void* operator new(size_t,void*); 
	void operator delete(void*,void*);
  • 使用placement new需要注意
  • palcement new的主要用途就是反复使用一块较大的动态分配的内存来构造不同类型的对象或者他们的数组
  • placement new构造起来的对象数组,要显式的调用他们的析构函数来销毁(析构函数并不释放对象的内存)。
  • 不要使用delete,这是因为placement new构造起来的对象或数组大小并不一定等于原来分配的内存大小,使用delete会造成内存泄漏或者之后释放内存时出现运行时错误。
	char *p = malloc(1024);
	student *q = new(p) ADT;  //placement new:
    q->ADT::~ADT();//显示调用析构函数

内存分配:new 、delete、malloc、free

new和delete的实现原理, delete是如何知道释放内存的大小的?
  • new
  • new简单类型直接调用operator new分配内存;
  • 而对于复杂结构,先调用operator new分配内存,然后在分配的内存上调用构造函数;
  • new []
  • 对于简单类型,new[]计算好大小后调用operator new;
  • 对于复杂数据结构,new[]先调用operator new[]分配内存,然后在p的前四个字节写入数组大小n,然后调用n次构造函数,针对复杂类型,new[]会额外存储数组大小;
  • delete
  • delete简单数据类型默认只是调用free函数;
  • 复杂数据类型先调用析构函数再调用operator delete;
  • delete [] ;其实就是多用一块区域存储了数组长度;
  • 针对简单类型,delete和delete[]等同。
  • 假设指针p指向new[]分配的内存。因为要4字节存储数组大小,实际分配的内存地址为[p-4],系统记录的也是这个地址。delete[]实际释放的就是p-4指向的内存。而delete会直接释放p指向的内存,这个内存根本没有被系统记录,所以会崩溃。
  • new [] 一个对象数组时,需要保存数组的维度,C++ 的做法是在分配数组空间时多分配了 4 个字节的大小,专门保存数组的大小,在 delete [] 时就可以取出这个保存的数,就知道了需要调用析构函数多少次了。
allocator
  • new在内存分配上面有一些局限性,new的机制是将内存分配和对象构造组合在一起,同样的, delete也是将对象析构和内存释放组合在一起的。
  • allocator将这两部分分开进行,allocator申请一部分内存,不进行初始化对象,只有当需要的时候才进行初始化操作。
new和malloc的区别
  • new/delete是C++关键字,需要编译器支持。malloc/free是库函数,需要头文件支持;
  • 使用new操作符申请内存分配时无须指定内存块的大小,编译器会根据类型信息自行计算。而malloc则需要显式地指出所需内存的尺寸。
  • new操作符内存分配成功时,返回的是对象类型的指针,类型严格与对象匹配,无须进行类型转换,故new是符合类型安全性的操作符。而malloc内存分配成功则是返回void * ,需要通过强制类型转换将void*指针转换成我们需要的类型。
  • new内存分配失败时,会抛出bac_alloc异常。malloc分配内存失败时返回NULL。
  • new会先调用operator new函数,申请足够的内存(通常底层使用malloc实现)。然后调用类型的构造函数,初始化成员变量,最后返回自定义类型指针。delete先调用析构函数,然后调用operator delete函数释放内存(通常底层使用free实现)。malloc/free是库函数,只能动态的申请和释放内存,无法强制要求其做自定义类型对象构造和析构工作。
malloc与free的实现原理、brk、mmap
  • 在标准C库中,提供了malloc/free函数分配释放内存,这两个函数底层是由brk、mmap、,munmap这些系统调用实现的;

操作系统-brk()和mmap()详解

  • brk、mmap
  • 这两种方式分配的都是虚拟内存,没有分配物理内存。
  • brk 是将数据段(.data)的最高地址指针_edata往高地址推;(_edata 堆顶)
  • mmap 是在进程的虚拟地址空间中(堆和栈中间,称为文件映射区域的地方)找一块空闲的虚拟内存。
  • 在第一次访问已分配的虚拟地址空间的时候,发生缺页中断,操作系统负责分配物理内存,然后建立虚拟内存和物理内存之间的映射关系;(一般是写入的时候才缺页中断,第一次访问返回的还是0)
  • 不同的选择
  • malloc小于128k的内存,使用brk分配内存,将_edata往高地址推;(有点像栈的分配,直接推栈顶指针即可)
  • malloc大于128k的内存,使用mmap分配内存,在堆和栈之间找一块空闲内存分配;
  • brk分配的内存需要等到高地址内存释放以后才能释 放 ; 而 mmap 分 配 的 内 存 可 以 单 独 释 放 。
  • 当 最 高 地 址 空 间 的 空 闲 内 存 超 过 128K ( 可 由M_TRIM_THRESHOLD选项调节)时,执行内存紧缩操作(trim)。
  • trim 过程有点像栈顶指针回落,其实也就是_edata 位置回退。引发trim 的条件就是指针处内存空闲超过阈值;
  • 宏观
  • malloc是从堆里面申请内存,也就是说函数返回的指针是指向堆里面的一块内存。操作系统中有一个记录空闲内存地址的链表。当操作系统收到程序的申请时,就会遍历该链表,然后就寻找第一个空间大于所申请空间的堆结点,然后就将该结点从空闲结点链表中删除,并将该结点的空间分配给程序。
allocator中construct 与 uninitialized_copy
  • allocatedeallocate 负责内存配置和内存释放
  • constructdestroy 用来构造和析构对象;
  • allocate包括了一级、二级空间适配器;如下所讲;分别在不同情况调用不同函数;
  • deallocate 根据对象空间大小,决定选择一级 deallocate ;否则二级:在free list中找到并释放;
  • construct 用 placement new 来显式构造对象; destroy 显式调用析构函数来析构对象;
  • uninitialized_copyuninitialized_filluninitialized_fill_n 是另外三个全局函数;(还有另外俩 constructdestroy
  • uninitialized_copy 这种有两种策略:简单类型,使用memmove 来复制;复杂类型用for循环来构造;
alloc中一级空间配置器、二级空间配置器;allocate
  • 存在小型区块使得内存破碎的情况;
  • 一级配置器直接使用malloc()\ free();应对与较大数据块;(大于128 bytes)
  • __malloc_alloc_template
  • 二级数据块使用memory pool策略;应对较小数据块(小于128 bytes)
  • __default_alloc_template
  • 小额区块带来了:内存碎片、配置时额外负担(有很多cookie来记录区块信息);
  • memory pool策略:
  • free-list 来链接所有的未使用的区块;
  • 区块内存大小为 8 的倍数;取的内存需求会被上调至 8 的倍数;
malloc、realloc、calloc的区别
  • calloc 省去了人为空间计算;
  • malloc申请的空间的值是随机初始化的,calloc申请的空间的值是初始化为0的;
  • realloc 给动态分配的空间分配额外的空间,用于扩充容量;
  • malloc
	void* malloc(unsigned int num_size);
	int *p = malloc(20*sizeof(int));//申请20个int类型的空间;
  • calloc
	void* calloc(size_t n,size_t size); 
	int *p = calloc(20, sizeof(int));
  • realloc
	void realloc(void *p, size_t new_size);
内存对齐以及原因
  • 分配内存的顺序是按照声明的顺序。
  • 每个变量相对于起始位置的偏移量必须是该变量类型大小的整数倍,不是整数倍空出内存,直到偏移量是整数倍为止。
  • 最后整个结构体的大小必须是里面变量类型最大值的整数倍。
  • 添加了#pragma pack(n)后规则就变成了下面这样:
  • 偏移量要是n和当前变量大小中较小值的整数倍
  • 整体大小要是n和最大变量大小中较小值的整数倍
  • n值必须为1,2,4,8…,为其他值时就按照默认的分配规则

指针

区别以下指针类型
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类型的。
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类型大小来读取。
数组名和指针(这里为指向数组首元素的指针)区别?
  • 数组在内存中是连续存放的,开辟一块连续的内存空间;数组所占存储空间(字节数):sizeof(数组名);数组大小:sizeof(数组名)/sizeof(数组元素数据类型);
  • sizeof§,p 为指针得到的是一个指针变量的字节数,而不是p 所指的内存容量。
  • 二者均可通过增减偏移量来访问数组中的元素。
  • 数组名不是真正意义上的指针,可以理解为常指针,所以数组名没有自增、自减等操作。
  • 当数组名当做形参传递给调用函数后,就失去了原有特性,退化成一般指针,多了自增、自减操作,但sizeof运算符不能再得到原数组的大小了。(指针退化)
C和C++的类型安全
  • 什么是类型安全?
  • 类型安全很大程度上可以等价于内存安全,类型安全的代码不会试图访问自己没被授权的内存区域。 “类型安全”常被用来形容编程语言,其根据在于该门编程语言是否提供保障类型安全的机制;有的时候也用“类型安全”形容某个程序,判别的标准在于该程序是否隐含类型错误。
  • 类型安全的编程语言与类型安全的程序之间,没有必然联系。好的程序员可以使用类型不那么安全的语言写出类型相当安全的程序,相反的,差一点儿的程序员可能使用类型相当安全的语言写出类型不太安全的程序。绝对类型安全的编程语言暂时还没有。
  • C的类型安全
  • C只在局部上下文中表现出类型安全,比如试图从一种结构体的指针转换成另一种结构体的指针时,编译器将会报告错误,除非使用显式类型转换。然而,C中相当多的操作是不安全的。以下是两个十分常见的例子:
  1. 使用%d控制整型数字的输出,没有问题,但是改成%f时,明显输出错误,再改成%s时,运行直接报segmentation fault错误
  2. malloc函数的返回值 : malloc是C中进行内存分配的函数,它的返回类型是void即空类型指针,常常有这样的用法char pStr= (char*)malloc(100*sizeof(char)),这里明显做了显式的类型转换。
  • C++的类型安全

相比于C语言,C++提供了一些新的机制保障类型安全:

  • 操作符new返回的指针类型严格与对象匹配,而不是void*
  • C中很多以void*为参数的函数可以改写为C++模板函数,而模板是支持类型检查的;
  • 引入const关键字代替#define constants,它是有类型、有作用域的,而#define constants只是简单的文本替换
  • 一些#define宏可被改写为inline函数,结合函数的重载,可在类型安全的前提下支持多种类型,当然改写为模板也能保证类型安全
  • C++提供了dynamic_cast关键字,使得转换过程更加安全,因为dynamic_cast比static_cast涉及更多具体的类型检查。
  • 想保证程序的类型安全性,应尽量避免使用空类型指针void*,尽量不对两种类型指针做强制转换。

函数指针

回调函数,及其作用?
  • 当发生某种事件时,系统或其他函数将会自动调用你定义的一段函数;
  • 回调函数就相当于一个中断处理函数,由系统在符合你设定的条件时自动调用。为此,你需要做三件事:1,声明;2,定义;3,设置触发条件,就是在你的函数中把你的回调函数名称转化为地址作为一个参数,以便于系统调用;
  • 回调函数就是一个通过函数指针调用的函数。如果你把函数的指针(地址)作为参数传递给另一个函数,当这个指针被用为调用它所指向的函数时,我们就说这是回调函数;
  • 因为可以把调用者与被调用者分开调用者不关心谁是被调用者,所有它需知道的,只是存在一个具有某种特定原型、某些限制条件(如返回值为int)的被调用函数

容器

string 与char *
  • string继承自basic_string,其实是对char进行了封装,封装的string包含了char数组,容量,长度等等属性。
  • string可以进行动态扩展,在每次扩展的时候另外申请一块原空间大小两倍的空间(2^n),然后将原字符串拷贝过去,并加上新增的内容。
  • 就像vector 一样的;
对象复用的了解,零拷贝的了解
  • 对象复用
  • 对象复用其本质是一种设计模式:Flyweight享元模式。
  • 通过将对象存储到“对象池”中实现对象的重复利用,这样可以避免多次创建重复对象的开销,节约系统资源。
  • 零拷贝
  • 零拷贝就是一种避免 CPU 将数据从一块存储拷贝到另外一块存储的技术。
  • 零拷贝技术可以减少数据拷贝和共享总线操作的次数。
  • 在C++中,vector的一个成员函数emplace_back()很好地体现了零拷贝技术,它跟push_back()函数一样可以将一个元素插入容器尾部,区别在于:使用push_back()函数需要调用拷贝构造函数和转移构造函数,而使用emplace_back()插入的元素原地构造,不需要触发拷贝构造和转移构造,效率更高。举个例子:
#include <vector>
#include <string>
#include <iostream>
using namespace std;

struct Person
{
    string name;
    int age;
    //初始构造函数
    Person(string p_name, int p_age): name(std::move(p_name)), age(p_age)
    {
         cout << "I have been constructed" <<endl;
    }
     //拷贝构造函数
     Person(const Person& other): name(std::move(other.name)), age(other.age)
    {
         cout << "I have been copy constructed" <<endl;
    }
     //转移构造函数
     Person(Person&& other): name(std::move(other.name)), age(other.age)
    {
         cout << "I have been moved"<<endl;
    }
};

int main()
{
    vector<Person> e;
    cout << "emplace_back:" <<endl;
    e.emplace_back("Jane", 23); //不用构造类对象

    vector<Person> p;
    cout << "push_back:"<<endl;
    p.push_back(Person("Mike",36));
    return 0;
}
//输出结果:
//emplace_back:
//I have been constructed
//push_back:
//I have been constructed
//I am being moved.
介绍面向对象的三大特性,并且举例说明

(1)继承

  • 让某种类型对象获得另一个类型对象的属性和方法。
  • 它可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展

(2)封装

  • 数据和代码捆绑在一起,避免外界干扰和不确定性访问。
  • 封装,也就是把客观事物封装成抽象的类,并且类可以把自己的数据和方法只让可信的类或者对象操作,对不可信的进行信息隐藏;
  • 例如:将公共的数据或方法使用public修饰,而不希望被访问的数据或方法采用private修饰。

(3)多态

  • 同一事物表现出不同事物的能力,即向不同对象发送同一消息,不同的对象在接收时会产生不同的行为 (重载实现编译时多态,虚函数实现运行时多态) 。(即重载和覆盖)
  • 多态性是允许你将父对象设置成为和一个或更多的他的子对象相等的技术,赋值之后,父对象就可以根据当前赋值给它的子对象的特性以不同的方式运作。简单一句话:允许将子类类型的指针赋值给父类类型的指针

多态

C++中的重载、重写(覆盖)和隐藏的区别
  • 构成重载:
  • 构成重载智能是参数列表不同;
  • 参数列表值传递 加const 不构成重载,只有引用、指针加const 才构成重载;
  • 返回值不同不构成重载;
  • 类内:成员函数与常成员函数 构成重载;(常函数为内部不修改成员变量)
  • 重写/覆盖:
  • 重写指的是在派生类中覆盖基类中的同名函数;
  • 重写就是重写函数体,要求基类函数必须是虚函数;
  • 隐藏
  • 隐藏指的是某些情况下,派生类中的函数屏蔽了基类中的同名函数;
  • 两个函数参数相同,但是基类函数不是虚函数。和重写的区别在于基类函数是否是虚函数;
  • 两个函数参数不同,无论基类函数是不是虚函数,都会被隐藏。
  • 和重载的区别在于两个函数不在同一个类中。
  • 重载与重写的区别:
  • 重写是父类和子类之间的垂直关系,重载是不同函数之间的水平关系
  • 重写要求参数列表相同,重载则要求参数列表不同,返回值不要求
  • 重写关系中,调用方法根据对象类型决定,重载根据调用时实参表与形参表的对应关系来选择函数体
静态类型和动态类型,静态绑定和动态绑定的介绍
  • 静态类型:对象在声明时采用的类型,在编译期既已确定;
  • 动态类型:通常是指一个指针或引用目前所指对象的类型,是在运行期决定的;
  • 静态绑定:绑定的是静态类型,所对应的函数或属性依赖于对象的静态类型,发生在编译期;
  • 动态绑定:绑定的是动态类型,所对应的函数或属性依赖于对象的动态类型,发生在运行期;

从上面的定义也可以看出,非虚函数一般都是静态绑定,而虚函数都是动态绑定(如此才可实现多态性)。

  • 如果func是虚函数,那所有的调用都要等到运行时根据其指向对象的类型才能确定,比起静态绑定自然是要有性能损失的,但是却能实现多态特性;
  • 至此总结一下静态绑定和动态绑定的区别:
  • 静态绑定发生在编译期,动态绑定发生在运行期;
  • 对象的动态类型可以更改,但是静态类型无法更改;
  • 要想实现动态,必须使用动态绑定;
  • 在继承体系中只有虚函数使用的是动态绑定,其他的全部是静态绑定;
  • 注意:空指针,是可以调用成员函数的;
  • 因为对于非虚成员函数,C++这门语言是静态绑定的。这也是C++语言和其它语言Java, Python的一个显著区别。其他语言可能是动态绑定,即运行时才绑定函数名与其对应的实际代码;
  • 对 : p->func(); 而言;C++ 为了保证运行效率,如果能编译阶段就确定的事情,就不拖在运行时才查找。所以编译器:
  • 发现p类型有个非虚函数func
  • 找到此函数,在此直接生成一个函数调用;
  • 所以运行时,并没有解引用 p 指针;也不会引发 segment fault
引用是否能实现动态绑定
  • 可以。

  • 引用在创建的时候必须初始化,在访问虚函数时,编译器会根据其所绑定的对象类型决定要调用哪个函数。注意只能调用虚函数。

类如何实现只能静态分配和只能动态分配
  1. 前者是把new、delete运算符重载为private属性。后者是把构造、析构函数设为protected属性,再用子类来动态创建

  2. 建立类的对象有两种方式:

① 静态建立,静态建立一个类对象,就是由编译器为对象在栈空间中分配内存;

② 动态建立,A *p = new A();动态建立一个类对象,就是使用new运算符为对象在堆空间中分配内存。这个过程分为两步,第一步执行operator new()函数,在堆中搜索一块内存并进行分配;第二步调用类构造函数构造对象;

  1. 只有使用new运算符,对象才会被建立在堆上,因此只要限制new运算符就可以实现类对象只能建立在栈上,可以将new运算符设为私有。
如果想将某个类用作基类,为什么该类必须定义而非声明?
  • 派生类中包含并且可以使用它从基类继承而来的成员,为了使用这些成员,派生类必须知道他们是什么。
  • 所以必须定义而非声明。

介绍一下几种典型的锁

读写锁

  • 多个读者可以同时进行读
  • 写者必须互斥(只允许一个写者写,也不能读者写者同时进行)
  • 写者优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者)

互斥锁

  • 一次只能一个线程拥有互斥锁,其他线程只有等待
  • 互斥锁是在抢锁失败的情况下主动放弃CPU进入睡眠状态直到锁的状态改变时再唤醒;
  • 而操作系统负责线程调度,为了实现锁的状态发生改变时唤醒阻塞的线程或者进程,需要把锁交给操作系统管理,所以互斥锁在加锁操作时涉及上下文的切换。
  • 互斥锁实际的效率还是可以让人接受的,加锁的时间大概100ns左右,而实际上互斥锁的一种可能的实现是先自旋一段时间,当自旋的时间超过阀值之后再将线程投入睡眠中,因此在并发运算中使用互斥锁(每次占用锁的时间很短)的效果可能不亚于使用自旋锁;

条件变量

  • 互斥锁一个明显的缺点是他只有两种状态:锁定和非锁定。
  • 而条件变量通过允许线程阻塞和等待另一个线程发送信号的方法弥补了互斥锁的不足,他常和互斥锁一起使用,以免出现竞态条件。
  • 当条件不满足时,线程往往解开相应的互斥锁并阻塞线程然后等待条件发生变化。一旦其他的某个线程改变了条件变量,他将通知相应的条件变量唤醒一个或多个正被此条件变量阻塞的线程。
  • 总的来说互斥锁是线程间互斥的机制,条件变量则是同步机制。

自旋锁

  • 如果进线程无法取得锁,进线程不会立刻放弃CPU时间片,而是一直循环尝试获取锁,直到获取为止。如果别的线程长时期占有锁那么自旋就是在浪费CPU做无用功,但是自旋锁一般应用于加锁时间很短的场景,这个时候效率比较高。

总结

  • 互斥锁一次只能被一个线程拥有,其他线程只有等待,用来给一个需要对临界区进行读写的操作加锁。
  • 信号量与互斥量不同的地方在于,信号量一般用在多个进程或者线程中,分别执行P/V操作。
  • 条件变量一般和互斥锁同时使用,或者用在管程中。
  • 互斥锁,条件变量都只用于同一个进程的各线程间,而信号量(有名信号量)可用于不同进程间的同步。当信号量用于进程间同步时,要求信号量建立在共享内存区。
  • 互斥锁是为上锁而优化的;条件变量是为等待而优化的; 信号量既可用于上锁,也可用于等待,因此会有更多的开销和更高的复杂性。
  • 并发有两大需求,一是互斥,二是等待。互斥是因为线程间存在共享数据,等待则是因为线程间存在依赖。
  • 互斥的话,通过互斥锁能搞定,常见的有依赖操作系统的 mutex,或是纯用户态 spinlock(但这种 spinlock 不通用,很容易出现性能差的 bad case )。
  • 条件变量,是为了解决等待需求。考虑实现生产者消费者队列,生产者和消费者各是一个线程。一个明显的依赖是,消费者线程依赖生产者线程 push 元素进队列。
  1. 没有条件变量,你会怎么实现消费者呢?让消费者线程一直轮询队列(需要加 mutex)。如果是队列里有值,就去消费;如果为空,要么是继续查( spin 策略),要么 sleep 一下,让系统过一会再唤醒你,你再次查。可以想到,无论哪种策略,都不通用,要么费 cpu,要么线程过分 sleep,影响该线程的性能。
  2. 有条件变量后,你就能用事件模式了。上面的消费者线程,发现队列为空,就告诉操作系统,我要 wait,一会肯定有其他线程发信号来唤醒我的。这个『其他线程』,实际上就是生产者线程。生产者线程 push 队列之后,则调用 signal,告诉操作系统,之前有个线程在 wait,你现在可以唤醒它了。
  • 上述两种等待方式,前者是轮询(poll),后者是事件(event)。
  • 一般来说,事件方式比较通用,性能不会太差(但存在切换上下文的开销)。轮询方式的性能,就非常依赖并发 pattern,也特别消耗 cpu。
  • 考虑到操作系统尽量为通用设计,而且当年的 cpu 只有单核,不太够用,支持事件模式是必然选择,条件变量不可或缺。
  • 实现:
  • 大概就是,会为每个条件变量维护一个等待队列称之为 waitingThreads。在 wait 时,线程会把自己放进去,signal 时,会从 waitingThreads 里取出一个线程,放入全局的 readyQueue 里,交由操作系统唤醒。

https://www.zhihu.com/question/68017337/answer/796332672

参考

https://github.com/forthespada/InterviewGuide/blob/main/Doc/Knowledge/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F.md#%E8%BF%9B%E7%A8%8B%E7%BA%BF%E7%A8%8B%E5%92%8C%E5%8D%8F%E7%A8%8B%E7%9A%84%E5%8C%BA%E5%88%AB%E5%92%8C%E8%81%94%E7%B3%BB

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值