硬核 | C++ 基础大全

Posted C语言与CPP编程

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了硬核 | C++ 基础大全相关的知识,希望对你有一定的参考价值。

简述智能指针

智能指针其作用是管理一个指针,避免咋们程序员申请的空间在函数结束时忘记释放,造成内存泄漏这种情况滴发生。

然后使用智能指针可以很大程度上的避免这个问题,因为智能指针就是一个类,当超出了类的作用域是,类会自动调用析构函数,析构函数会自动释放资源。所以智能指针的作用原理就是在函数结束时自动释放内存空间,不需要手动释放内存空间。

智能指针的常见接口

T* get();
T& operator*();
T* operator->();
T& operator=(const T& val);
T* release();
void reset (T* ptr = nullptr);
  • T 是模板参数, 也就是传入的类型;

  • get() 用来获取 auto_ptr 封装在内部的指针, 也就是获取原生指针;

  • operator*() 重载* , operator->() 重载了->, operator=()重载了=;

  • realease() 将 auto_ptr 封装在内部的指针置为 nullptr, 但并不会破坏指针所指向的内容, 函数返回的是内部指针置空之前的值;

  • 直接释放封装的内部指针所指向的内存, 如果指定了 ptr 的值, 则将内部指针初始化为该值 (否则将其设置为nullptr;

CPP智能指针有哪几种?

  1. auto_ptr

  2. unique_ptr

  3. shared_ptr

  4. weak_ptr

简述auto_ptr

auto_ptr(C++98 的方案,C11 已抛弃)采用所有权模式。

auto_ptr<std::string> p1 (new string ("hello"));
auto_ptr<std::string> p2;
p2 = p1; //auto_ptr 不会报错.

此时不会报错,p2 剥夺了 p1 的所有权,但是当程序运行时访问 p1 将会报错。所以 auto_ptr 的缺点是:存在潜在的内存崩溃问题!

简述unique_ptr

unique_ptr 实现独占式拥有或严格拥有概念,保证同一时间内只有一个智能指针可以指向该对象。它对于避免资源泄露特别有用。

采用所有权模式,还是上面那个例子

unique_ptr<string> p3 (new string (auto));//#4
unique_ptr<string> p4;//#5
p4 = p3;//此时会报错

编译器认为 p4=p3 非法,避免了 p3 不再指向有效数据的问题。

因此,unique_ptr 比 auto_ptr 更安全。

简述shared_ptr

shared_ptr 实现共享式拥有概念,多个智能指针可以指向相同对象,该对象和其相关资源会在“最后一个引用被销毁”时候释放。从名字 share 就可以看出了资源可以被多个指针共享,它使用计数机制来表明资源被几个指针共享。

可以通过成员函数 use_count() 来查看资源的所有者个数,除了可以通过 new 来构造,还可以通过传入auto_ptr, unique_ptr,weak_ptr 来构造。当我们调用 release() 时,当前指针会释放资源所有权,计数减一。当计数等于 0 时,资源会被释放。

shared_ptr 是为了解决 auto_ptr 在对象所有权上的局限性 (auto_ptr 是独占的),在使用引用计数的机制上提供了可以共享所有权的智能指针。

简述weak_ptr

weak_ptr 是一种不控制对象生命周期的智能指针,它指向一个  shared_ptr 管理的对象。进行该对象的内存管理的是那个强引用的 shared_ptr。

weak_ptr 只是提供了对管理对象的一个访问手段。weak_ptr  设计的目的是为配合  shared_ptr 而引入的一种智能指针来协助 shared_ptr 工作,它只可以从一个 shared_ptr 或另一个 weak_ptr 对象构造,,它的构造和析构不会引起引用记数的增加或减少。

weak_ptr 是用来解决 shared_ptr 相互引用时的死锁问题,如果说两个 shared_ptr 相互引用,那么这两个指针的引用计数永远不可能下降为0,也就是资源永远不会释放。它是对对象的一种弱引用,不会增加对象的引用计数,和 shared_ptr 之间可以相互转化,shared_ptr 可以直接赋值给它,它可以通过调用 lock 函数来获得shared_ptr。

当两个智能指针都是 shared_ptr 类型的时候,析构时两个资源引用计数会减一,但是两者引用计数还是为 1,导致跳出函数时资源没有被释放(的析构函数没有被调用),解决办法:把其中一个改为weak_ptr就可以。

C++ 中内存分配情况

栈:由编译器管理分配和回收,存放局部变量和函数参数。

堆:由程序员管理,需要手动 new malloc delete free 进行分配和回收,空间较大,但可能会出现内存泄漏和空闲碎片的情况。

全局/静态存储区:分为初始化和未初始化两个相邻区域,存储初始化和未初始化的全局变量和静态变量。

常量存储区:存储常量,一般不允许修改。

代码区:存放程序的二进制代码。

C++ 中的指针参数传递

指针参数传递本质上是值传递,它所传递的是一个地址值。值传递过程中,被调函数的形式参数作为被调函数的局部变量处理,会在栈中开辟内存空间以存放由主调函数传递进来的实参值,从而形成了实参的一个副本(替身)。值传递的特点是,被调函数对形式参数的任何操作都是作为局部变量进行的,不会影响主调函数的实参变量的值(形参指针变了,实参指针不会变)。

引用参数传递过程中,被调函数的形式参数也作为局部变量在栈中开辟了内存空间,但是这时存放的是由主调函数放进来的实参变量的地址。被调函数对形参(本体)的任何操作都被处理成间接寻址,即通过栈中存放的地址访问主调函数中的实参变量(根据别名找到主调函数中的本体)。因此,被调函数对形参的任何操作都会影响主调函数中的实参变量。

简述C++ 中的引用参数传递

引用传递和指针传递是不同的,虽然他们都是在被调函数栈空间上的一个局部变量,但是任何对于引用参数的处理都会通过一个间接寻址的方式操作到主调函数中的相关变量。而对于指针传递的参数,如果改变被调函数中的指针地址,它将应用不到主调函数的相关变量。如果想通过指针参数传递来改变主调函数中的相关变量(地址),那就得使用指向指针的指针或者指针引用。

从编译的角度来讲,程序在编译时分别将指针和引用添加到符号表上,符号表中记录的是变量名及变量所对应地址。指针变量在符号表上对应的地址值为指针变量的地址值,而引用在符号表上对应的地址值为引用对象的地址值(与实参名字不同,地址相同)。符号表生成之后就不会再改,因此指针可以改变其指向的对象(指针变量中的值可以改),而引用对象则不能修改。

简述C++ 中 const 关键词

const 修饰基本类型数据类型:基本数据类型,修饰符 const 可以用在类型说明符前,也可以用在类型说明符后,其结果是一样的。在使用这些常量的时候,只要不改变这些常量的值即可。

const 修饰指针变量和引用变量:如果 const 位于小星星的左侧,则 const 就是用来修饰指针所指向的变量,即指针指向为常量;如果 const 位于小星星的右侧,则 const 就是修饰指针本身,即指针本身是常量。

const 应用到函数中:作为参数的 const 修饰符:调用函数的时候,用相应的变量初始化 const 常量,则在函数体中,按照 const 所修饰的部分进行常量化,保护了原对象的属性。[注意]:参数 const 通常用于参数为指针或引用的情况; 作为函数返回值的 const 修饰符:声明了返回值后,const 按照"修饰原则"进行修饰,起到相应的保护作用。

const 在类中的用法:const 成员变量,只在某个对象生命周期内是常量,而对于整个类而言是可以改变的。因为类可以创建多个对象,不同的对象其 const 数据成员值可以不同。所以不能在类的声明中初始化 const 数据成员,因为类的对象在没有创建时候,编译器不知道 const 数据成员的值是什么。const 数据成员的初始化只能在类的构造函数的初始化列表中进行。const 成员函数:const 成员函数的主要目的是防止成员函数修改对象的内容。要注意,const 关键字和 static 关键字对于成员函数来说是不能同时使用的,因为 static 关键字修饰静态成员函数不含有 this 指针,即不能实例化,const 成员函数又必须具体到某一个函数。

const 修饰类对象,定义常量对象:常量对象只能调用常量函数,别的成员函数都不能调用。

**补充:**const 成员函数中如果实在想修改某个变量,可以使用 mutable 进行修饰。成员变量中如果想建立在整个类中都恒定的常量,应该用类中的枚举常量来实现或者 static const。

C ++ 中的 const类成员函数(用法和意义)

常量对象可以调用类中的 const 成员函数,但不能调用非 const 成员函数;(原因:对象调用成员函数时,在形参列表的最前面加一个形参 this,但这是隐式的。this 指针是默认指向调用函数的当前对象的,所以,很自然,this 是一个常量指针 test * const,因为不可以修改 this 指针代表的地址。但当成员函数的参数列表(即小括号)后加了 const 关键字(void print() const;),此成员函数为常量成员函数,此时它的隐式this形参为 const test * const,即不可以通过 this 指针来改变指向对象的值。

非常量对象可以调用类中的 const 成员函数,也可以调用非 const 成员函数。

简述static关键词

作用一:修饰局部变量:一般情况下,对于局部变量在程序中是存放在栈区的,并且局部的生命周期在包含语句块执行结束时便结束了。但是如果用 static 关键字修饰的话,该变量便会存放在静态数据区,其生命周期会一直延续到整个程序执行结束。但是要注意的是,虽然用 static  对局部变量进行修饰之后,其生命周期以及存储空间发生了变化,但其作用域并没有改变,作用域还是限制在其语句块。

作用二:修饰全部变量:对于一个全局变量,它既可以在本文件中被访问到,也可以在同一个工程中其它源文件被访问(添加 extern进行声明即可)。用 static 对全局变量进行修饰改变了其作用域范围,由原来的整个工程可见变成了本文件可见。

作用三:修饰函数:用 static 修饰函数,情况和修饰全局变量类似,也是改变了函数的作用域。

作用四:修饰类:如果 C++ 中对类中的某个函数用 static  修饰,则表示该函数属于一个类而不是属于此类的任何特定对象;如果对类中的某个变量进行 static 修饰,则表示该变量以及所有的对象所有,存储空间中只存在一个副本,可以通过;类和对象去调用。

(补充:静态非常量数据成员,其只能在类外定义和初始化,在类内仅是声明而已。)

作用五:类成员/类函数声明 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 和 C++ 区别

首先,C 和 C++ 在基本语句上没有过大的区别。

C++ 有新增的语法和关键字,语法的区别有头文件的不同和命名空间的不同,C++ 允许我们自己定义自己的空间,C 中不可以。关键字方面比如 C++ 与 C 动态管理内存的方式不同,C++ 中在 malloc 和 free 的基础上增加了 new  和 delete,而且 C++ 中在指针的基础上增加了引用的概念,关键字例如 C++中还增加了 auto,explicit 体现显示和隐式转换上的概念要求,还有 dynamic_cast 增加类型安全方面的内容。

函数方面 C++ 中有重载和虚函数的概念:C++ 支持函数重载而 C 不支持,是因为 C++ 函数的名字修饰与 C 不同,C++ 函数名字的修饰会将参数加在后面,例如,int func(int,double)经过名字修饰之后会变成_func_int_double,而 C 中则会变成 _func,所以 C++ 中会支持不同参数调用不同函数。

C++ 还有虚函数概念,用以实现多态。

类方面,C 的 struct 和 C++ 的类也有很大不同:C++ 中的 struct 不仅可以有成员变量还可以成员函数,而且对于  struct 增加了权限访问的概念,struct 的默认成员访问权限和默认继承权限都是 public,C++ 中除了 struct  还有  class 表示类,struct  和 class 还有一点不同在于 class 的默认成员访问权限和默认继承权限都是 private。

C++ 中增加了模板还重用代码,提供了更加强大的 STL 标准库。

最后补充一点就是 C 是一种结构化的语言,重点在于算法和数据结构。C 程序的设计首先考虑的是如何通过一个代码,一个过程对输入进行运算处理输出。而 C++ 首先考虑的是如何构造一个对象模型,让这个模型能够契合与之对应的问题领域,这样就能通过获取对象的状态信息得到输出。

C 的 struct 更适合看成是一个数据结构的实现体,而 C++ 的 class 更适合看成是一个对象的实现体。

C++ 和 Java 区别

**指针:**Java 语言让程序员没法找到指针来直接访问内存,没有指针的概念,并有内存的自动管理功能,从而有效的防止了 C++ 语言中的指针操作失误的影响。但并非 Java 中没有指针,Java 虚拟机内部中还是用了指针,保证了 Java  程序的安全。

**多重继承:**C++ 支持多重继承但 Java 不支持,但支持一个类继承多个接口,实现 C++ 中多重继承的功能,又避免了 C++ 的多重继承带来的不便。

**数据类型和类:**Java 是完全面向对象的语言,所有的函数和变量必须是类的一部分。除了基本数据类型之外,其余的都作为类对象,对象将数据和方法结合起来,把它们封装在类中,这样每个对象都可以实现自己的特点和行为。Java 中取消了 C++ 中的 struct 和  union 。

**自动内存管理:**Java 程序中所有对象都是用 new 操作符建立在内存堆栈上,Java 自动进行无用内存回收操作,不需要程序员进行手动删除。而 C++ 中必须由程序员释放内存资源,增加了程序设计者的负担。Java 中当一个对象不再被用到时, 无用内存回收器将给他们加上标签。Java 里无用内存回收程序是以线程方式在后台运行的,利用空闲时间工作来删除。

Java 不支持操作符重载。操作符重载被认为是 C++ 的突出特性。

Java 不支持预处理功能。C++ 在编译过程中都有一个预编译阶段,Java 没有预处理器,但它提供了 import 与 C++ 预处理器具有类似功能。

**类型转换:**C++ 中有数据类型隐含转换的机制,Java 中需要限时强制类型转换。

**字符串:**C++中字符串是以 Null 终止符代表字符串的结束,而 Java 的字符串 是用类对象(string 和 stringBuffer)来实现的。

Java 中不提供 goto 语句,虽然指定 goto 作为关键字,但不支持它的使用,使程序简洁易读。

Java 的异常机制用于捕获例外事件,增强系统容错能力。

说一下 C++ 里是怎么定义常量的?常量存放在内存的哪个位置?

对于局部常量,存放在栈区;

对于全局常量,编译期一般不分配内存,放在符号表中以提高访问效率;

字面值常量,比如字符串,放在常量区。

C++ 中重载和重写,重定义的区别

重载

翻译自 overload,是指同一可访问区内被声明的几个具有不同参数列表的同名函数,依赖于 C++函数名字的修饰会将参数加在后面,可以是参数类型,个数,顺序的不同。根据参数列表决定调用哪个函数,重载不关心函数的返回类型。

重写

翻译自 override,派生类中重新定义父类中除了函数体外完全相同的虚函数,注意被重写的函数不能是 static 的,一定要是虚函数,且其他一定要完全相同。要注意,重写和被重写的函数是在不同的类当中的,重写函数的访问修饰符是可以不同的,尽管 virtual 中是 private 的,派生类中重写可以改为 public。

重定义(隐藏)

派生类重新定义父类中相同名字的非 virtual 函数,参数列表

和返回类型都可以不同,即父类中除了定义成 virtual 且完全相同的同名函数才

不会被派生类中的同名函数所隐藏(重定义)。

介绍 C++ 的构造函数

类的对象被创建时,编译系统为对象分配内存空间,并自动调用构造函数,由构造函数完成成员的初始化工作。

即构造函数的作用:初始化对象的数据成员。

无参数构造函数: 即默认构造函数,如果没有明确写出无参数构造函数,编译器会自动生成默认的无参数构造函数,函数为空,什么也不做,如果不想使用自动生成的无参构造函数,必需要自己显示写出一个无参构造函数。

一般构造函数: 也称重载构造函数,一般构造函数可以有各种参数形式,一个类可以有多个一般构造函数,前提是参数的个数或者类型不同,创建对象时根据传入参数不同调用不同的构造函数。

拷贝构造函数: 拷贝构造函数的函数参数为对象本身的引用,用于根据一个已存在的对象复制出一个新的该类的对象,一般在函数中会将已存在的对象的数据成员的值一一复制到新创建的对象中。如果没有显示的写拷贝构造函数,则系统会默认创建一个拷贝构造函数,但当类中有指针成员时,最好不要使用编译器提供的默认的拷贝构造函数,最好自己定义并且在函数中执行深拷贝。

类型转换构造函数: 根据一个指定类型的对象创建一个本类的对象,也可以算是一般构造函数的一种,这里提出来,是想说有的时候不允许默认转换的话,要记得将其声明为 explict 的,来阻止一些隐式转换的发生。

赋值运算符的重载 :注意,这个类似拷贝构造函数,将=右边的本类对象的值复制给=左边的对象,它不属于构造函数,=左右两边的对象必需已经被创建。如果没有显示的写赋值运算符的重载,系统也会生成默认的赋值运算符,做一些基本的拷贝工作。

这里区分

A a1, A a2; a1 = a2;//调用赋值运算符 
 A a3 = a1;//调用拷贝构造函数,因为进行的是初始化工作,a3 并未存在

简述C++ 的四种强制转换

C++ 的四种强制转换包括:static_cast, dynamic_cast, const_cast, reinterpret_cast

  • static_cast:明确指出类型转换,一般建议将隐式转换都替换成显示转换,因为没有动态类型检查,上行转换(派生类->基类)安全,下行转换(基类->派生类) 不安全,所以主要执行非多态的转换操作;

  • dynamic_cast:专门用于派生类之间的转换,type-id 必须是类指针,类引用或 void*,对于下行转换是安全的,当类型不一致时,转换过来的是空指针,而static_cast,当类型不一致时,转换过来的事错误意义的指针,可能造成非法访问等问题。

  • const_cast:专门用于 const 属性的转换,去除 const 性质,或增加 const 性质, 是四个转换符中唯一一个可以操作常量的转换符。

  • reinterpret_cast:不到万不得已,不要使用这个转换符,高危操作。使用特点:从底层对数据进行重新解释,依赖具体的平台,可移植性差;可以将整形转 换为指针,也可以把指针转换为数组;可以在指针和引用之间进行肆无忌惮的转换。

简述指针和引用的区别

指针和引用都是一种内存地址的概念,区别呢,指针是一个实体,引用只是一个别名。

在程序编译的时候,将指针和引用添加到符号表中。

指针它指向一块内存,指针的内容是所指向的内存的地址,在编译的时候,则是将“指针变量名-指针变量的地址”添加到符号表中,所以说,指针包含的内容是可以改变的,允许拷贝和赋值,有 const 和非 const 区别,甚至可以为空,sizeof 指针得到的是指针类型的大小。

而对于引用来说,它只是一块内存的别名,在添加到符号表的时候,是将"引用变量名-引用对象的地址"添加到符号表中,符号表一经完成不能改变,所以引用必须而且只能在定义时被绑定到一块内存上,后续不能更改,也不能为空,也没有 const 和非 const 区别。

sizeof 引用得到代表对象的大小。而 sizeof 指针得到的是指针本身的大小。另外在参数传递中,指针需要被解引用后才可以对对象进行操作,而直接对引用进行的修改会直接作用到引用对象上。

作为参数时也不同,传指针的实质是传值,传递的值是指针的地址;传引用的实质是传地址,传递的是变量的地址。

野(wild)指针与悬空(dangling)指针有什么区别?如何避免?

野指针(wild pointer):就是没有被初始化过的指针。用 gcc -Wall 编译, 会出现 used uninitialized警告。

悬空指针:是指针最初指向的内存已经被释放了的一种指针。

无论是野指针还是悬空指针,都是指向无效内存区域(这里的无效指的是"不安全不可控")的指针。访问"不安全可控"(invalid)的内存区域将导致"Undefined Behavior"。

如何避免使用野指针?在平时的编码中,养成在定义指针后且在使用之前完成初始化的习惯或者使用智能指针。

说一下 const 修饰指针如何区分?

下面都是合法的声明,但是含义大不同:

const int * p1;  //指向整形常量 的指针,它指向的值不能修改

int * const p2;  //指向整形的常量指针 ,它不能在指向别的变量,但指向(变量)的值可以修改。

const int *const p3; //指向整形常量 的 常量指针 。它既不能再指向别的常量,指向的值也不能修改。

理解这些声明的技巧在于,查看关键字const右边来确定什么被声明为常量 ,如果该关键字的右边是类型,则值是常量;如果关键字的右边是指针变量,则指针本身是常量。

简单说一下函数指针

从定义和用途两方面来说一下自己的理解:

首先是定义:函数指针是指向函数的指针变量。函数指针本身首先是一个指针变量,该指针变量指向一个具体的函数。这正如用指针变量可指向整型变量、字符型、数组一样,这里是指向函数。

在编译时,每一个函数都有一个入口地址,该入口地址就是函数指针所指向的地址。有了指向函数的指针变量后,可用该指针变量调用函数,就如同用指针变量可引用其他类型变量一样,在这些概念上是大体一致的。

其次是用途:调用函数和做函数的参数,比如回调函数。

示例:

char * fun(char * p)  …  // 函数fun
char * (*pf)(char * p);    // 函数指针pf
pf = fun;                 // 函数指针pf指向函数fun
pf(p);                    // 通过函数指针pf调用函数fun

堆和栈区别

由编译器进行管理,在需要时由编译器自动分配空间,在不需要时候自动回收空间,一般保存的是局部变量和函数参数等。

连续的内存空间,在函数调用的时候,首先入栈的主函数的下一条可执行指令的地址,然后是函数的各个参数。

大多数编译器中,参数是从右向左入栈(原因在于采用这种顺序,是为了让程序员在使用C/C++的“函数参数长度可变”这个特性时更方便。如果是从左向右压栈,第一个参数(即描述可变参数表各变量类型的那个参数)将被放在栈底,由于可变参的函数第一步就需要解析可变参数表的各参数类型,即第一步就需要得到上述参数,因此,将它放在栈底是很不方便的。)本次函数调用结束时,局部变量先出栈,然后是参数,最后是栈顶指针最开始存放的地址,程序由该点继续运行,不会产生碎片。

栈是高地址向低地址扩展,栈低高地址,空间较小。

由程序员管理,需要手动 new malloc delete free 进行分配和回收,如果不进行回收的话,会造成内存泄漏的问题。

不连续的空间,实际上系统中有一个空闲链表,当有程序申请的时候,系统遍历空闲链表找到第一个大于等于申请大小的空间分配给程序,一般在分配程序的时候,也会空间头部写入内存大小,方便 delete 回收空间大小。当然如果有剩余的,也会将剩余的插入到空闲链表中,这也是产生内存碎片的原因。

堆是低地址向高地址扩展,空间交大,较为灵活。

函数传递参数的几种方式

值传递: 形参是实参的拷贝,函数内部对形参的操作并不会影响到外部的实参。

指针传递: 也是值传递的一种方式,形参是指向实参地址的指针,当对形参的指向操作时,就相当于对实参本身进行操作。

引用传递: 实际上就是把引用对象的地址放在了开辟的栈空间中,函数内部对形参的任何操作可以直接映射到外部的实参上面。

new / delete ,malloc / free 区别

都可以用来在堆上分配和回收空间。new /delete 是操作符,malloc/free 是库函数。

执行 new 实际上执行两个过程:1.分配未初始化的内存空间(malloc);2.使用对象的构造函数对空间进行初始化;返回空间的首地址。如果在第一步分配空间中出现问题,则抛出 std::bad_alloc 异常,或被某个设定的异常处理函数捕获处理;如果在第二步构造对象时出现异常,则自动调用 delete 释放内存。

执行 delete 实际上也有两个过程:1. 使用析构函数对对象进行析构;2.回收内存空间(free)。

以上也可以看出 new 和 malloc 的区别,new 得到的是经过初始化的空间,而 malloc 得到的是未初始化的空间。所以 new 是 new 一个类型,而 malloc 则是malloc 一个字节长度的空间。delete 和 free 同理,delete 不仅释放空间还析构对象,delete 一个类型,free 一个字节长度的空间。

为什么有了 malloc/free 还需要 new/delete? 因为对于非内部数据类型而言,光用 malloc/free 无法满足动态对象的要求。对象在创建的同时需要自动执行构造函数,对象在消亡以前要自动执行析构函数。由于 mallo/free 是库函数而不是运算符,不在编译器控制权限之内,不能够把执行的构造函数和析构函数的任务强加于 malloc/free,所以有了 new/delete 操作符。

volatile 和 extern 关键字

volatile 三个特性

易变性:在汇编层面反映出来,就是两条语句,下一条语句不会直接使用上一条语句对应的 volatile 变量的寄存器内容,而是重新从内存中读取。

不可优化性:volatile 告诉编译器,不要对我这个变量进行各种激进的优化,甚至将变量直接消除,保证程序员写在代码中的指令,一定会被执行。

顺序性:能够保证 volatile 变量之间的顺序性,编译器不会进行乱序优化。

extern

在 C 语言中,修饰符 extern 用在变量或者函数的声明前,用来说明 “此变量/函数是在别处定义的,要在此处引用”。

注意 extern 声明的位置对其作用域也有关系,如果是在 main 函数中进行声明的,则只能在 main 函数中调用,在其它函数中不能调用。其实要调用其它文件中的函数和变量,只需把该文件用 #include 包含进来即可,为啥要用 extern?因为用 extern 会加速程序的编译过程,这样能节省时间。

在 C++ 中 extern 还有另外一种作用,用于指示 C 或者 C++函数的调用规范。比如在 C++ 中调用 C 库函数,就需要在 C++ 程序中用 extern “C” 声明要引用的函数。这是给链接器用的,告诉链接器在链接的时候用C 函数规范来链接。主要原因是 C++ 和 C 程序编译完成后在目标代码中命名规则不同,用此来解决名字匹配的问题。

define 和 const 区别

对于 define 来说, 宏定义实际上是在预编译阶段进行处理,没有类型,也就没有类型检查,仅仅做的是遇到宏定义进行字符串的展开,遇到多少次就展开多少次,而且这个简单的展开过程中,很容易出现边界效应,达不到预期的效果。因为 define 宏定义仅仅是展开,因此运行时系统并不为宏定义分配内存,但是从汇编 的角度来讲,define 却以立即数的方式保留了多份数据的拷贝。

对于 const 来说, const 是在编译期间进行处理的,const 有类型,也有类型检查,程序运行时系统会为 const 常量分配内存,而且从汇编的角度讲,const 常量在出现的地方保留的是真正数据的内存地址,只保留了一份数据的拷贝,省去了不必要的内存空间。而且,有时编译器不会为普通的 const 常量分配内存,而是直接将 const 常量添加到符号表中,省去了读取和写入内存的操作,效率更高。

计算下面几个类的大小

class A; sizeof(A) = 1; //空类在实例化时得到一个独一无二的地址,所以为 1. 
class Avirtual Fun() ; sizeof(A) = 4(32bit)/8(64bit) //当 C++ 类中有虚函数的时候,会有一个指向虚函数表的指针(vptr)
class Astatic int a; ; sizeof(A) = 1; 
class Aint a; ; sizeof(A) = 4; 
class Astatic int a; int b; ; sizeof(A) = 4;

面向对象的三大特性,并举例说明

C++ 面向对象的三大特征是:封装、继承、多态。

所谓封装

就是把客观事物封装成抽象的类,并且类可以把自己的数据和方法只让信任的类或者对象操作,对不可信的进行信息隐藏。一个类就是一个封装了数据以及操作这些数据的代码的逻辑实体。在一个对象内部,某些代码或某些数据可以是私有的,不能被外界访问。通过这种方式,对象对内部数据提供了不同级别的保护,以防止程序中无关的部分意外的改变或错误的使用了对象的私有部分。

所谓继承

是指可以让某个类型的对象获得另一个类型的对象的属性的方法。它支持按级分类的概念。继承是指这样一种能力:它可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展。通过继承创建的新类称为“子类”或者“派生类”,被继承的类称为“基类”、“父类”或“超类”。继承的过程,就是从一般到特殊的过程。要实现继承,可以通过“继承”和“组合”来实现。

继承概念的实现方式有两类:

实现继承:实现继承是指直接使用基类的属性和方法而无需额外编码的能力。

接口继承:接口继承是指仅使用属性和方法的名称、但是子类必需提供实现的能力。

所谓多态

就是向不同的对象发送同一个消息,不同对象在接收时会产生不同的行为(即方法)。即一个接口,可以实现多种方法。

多态与非多态的实质区别就是函数地址是早绑定还是晚绑定的。如果函数的调用,在编译器编译期间就可以确定函数的调用地址,并产生代码,则是静态的,即地址早绑定。而如果函数调用的地址不能在编译器期间确定,需要在运行时才确定,这就属于晚绑定。

多态的实现

多态其实一般就是指继承加虚函数实现的多态,对于重载来说,实际上基于的原理是,编译器为函数生成符号表时的不同规则,重载只是一种语言特性,与多态无关,与面向对象也无关,但这又是 C++中增加的新规则,所以也算属于 C++,所以如果非要说重载算是多态的一种,那就可以说:多态可以分为静态多态和动态多态。

静态多态其实就是重载,因为静态多态是指在编译时期就决定了调用哪个函数,根据参数列表来决定;

动态多态是指通过子类重写父类的虚函数来实现的,因为是在运行期间决定调用的函数,所以称为动态多态,

一般情况下我们不区分这两个时所说的多态就是指动态多态。

动态多态的实现与虚函数表,虚函数指针相关。

扩展: 子类是否要重写父类的虚函数?子类继承父类时, 父类的纯虚函数必须重写,否则子类也是一个虚类不可实例化。定义纯虚函数是为了实现一个接口,起到一个规范的作用,规范继承这个类的程序员必须实现这个函数。

虚函数相关(虚函数表,虚函数指针),虚函数的实现原理

首先我们来说一下,C++中多态的表象,在基类的函数前加上 virtual 关键字,在派生类中重写该函数,运行时将会根据对象的实际类型来调用相应的函数。如果对象类型是派生类,就调用派生类的函数,如果是基类,就调用基类的函数。

实际上,当一个类中包含虚函数时,编译器会为该类生成一个虚函数表,保存该类中虚函数的地址,同样,派生类继承基类,派生类中自然一定有虚函数,所以编译器也会为派生类生成自己的虚函数表。当我们定义一个派生类对象时,编译器检测该类型有虚函数,所以为这个派生类对象生成一个虚函数指针,指向该类型的虚函数表,这个虚函数指针的初始化是在构造函数中完成的。

后续如果有一个基类类型的指针,指向派生类,那么当调用虚函数时,就会根据所指真正对象的虚函数表指针去寻找虚函数的地址,也就可以调用派生类的虚函数表中的虚函数以此实现多态。

补充:如果基类中没有定义成 virtual,那么进行 Base B; Derived D; Base *p = D; p->function(); 这种情况下调用的则是 Base 中的 function()。因为基类和派生类中都没有虚函数的定义,那么编译器就会认为不用留给动态多态的机会,就事先进行函数地址的绑定(早绑定),详述过程就是,定义了一个派生类对象,首先要构造基类的空间,然后构造派生类的自身内容,形成一个派生类对象,那么在进行类型转换时,直接截取基类的部分的内存,编译器认为类型就是基类,那么(函数符号表[不同于虚函数表的另一个表]中)绑定的函数地址也就是基类中函数的地址,所以执行的是基类的函数。

编译器处理虚函数表应该如何处理

对于派生类来说,编译器建立虚函数表的过程其实一共是三个步骤:

  • 拷贝基类的虚函数表,如果是多继承,就拷贝每个有虚函数基类的虚函数表

  • 当然还有一个基类的虚函数表和派生类自身的虚函数表共用了一个虚函数表,也称为某个基类为派生类的主基类

  • 查看派生类中是否有重写基类中的虚函数, 如果有,就替换成已经重写的虚函数地址;查看派生类是否有自身的虚函数,如果有,就追加自身的虚函数到自身的虚函数表中。

析构函数一般写成虚函数的原因

直观的讲:是为了降低内存泄漏的可能性。举例来说就是,一个基类的指针指向一个派生类的对象,在使用完毕准备销毁时,如果基类的析构函数没有定义成虚函数,那 么编译器根据指针类型就会认为当前对象的类型是基类,调用基类的析构函数 (该对象的析构函数的函数地址早就被绑定为基类的析构函数),仅执行基类的析构,派生类的自身内容将无法被析构,造成内存泄漏。

如果基类的析构函数定义成虚函数,那么编译器就可以根据实际对象,执行派生类的析构函数,再执行基类的析构函数,成功释放内存。

构造函数为什么一般不定义为虚函数

  • 虚函数调用只需要知道“部分的”信息,即只需要知道函数接口,而不需要知道对象的具体类型。但是,我们要创建一个对象的话,是需要知道对象的完整信息的。特别是,需要知道要创建对象的确切类型,因此,构造函数不应该被定义成虚函数;

  • 而且从目前编译器实现虚函数进行多态的方式来看,虚函数的调用是通过实例化之后对象的虚函数表指针来找到虚函数的地址进行调用的,如果说构造函数是虚的,那么虚函数表指针则是不存在的,无法找到对应的虚函数表来调用虚函数,那么这个调用实际上也是违反了先实例化后调用的准则。

构造函数或析构函数中调用虚函数会怎样

实际上是不应该在构造函数或析构函数中调用虚函数的,因为这样的调用其实并不会带来所想要的效果。

举例来说就是,有一个动物的基类,基类中定义了一个动物本身行为的虚函数 action_type(),在基类的构造函数中调用了这个虚函数。

派生类中重写了这个虚函数,我们期望着根据对象的真实类型不同,而调用各自实现的虚函数,但实际上当我们创建一个派生类对象时,首先会创建派生类的基类部分,执行基类的构造函数,此时,派生类的自身部分还没有被初始化,对于这种还没有初始化的东西,C++选择当它们还不存在作为一种安全的方法。

也就是说构造派生类的基类部分是,编译器会认为这就是一个基类类型的对象,然后调用基类类型中的虚函数实现,并没有按照我们想要的方式进行。即对象在派生类构造函数执行前并不会成为一个派生类对象。

在析构函数中也是同理,派生类执行了析构函数后,派生类的自身成员呈现未定义的状态,那么在执行基类的析构函数中是不可能调用到派生类重写的方法的。所以说,我们不应该在构在函数或析构函数中调用虚函数,就算调用一般也不会达到我们想要的结果。

析构函数的作用,如何起作用?

构造函数只是起初始化值的作用,但实例化一个对象的时候,可以通过实例去传递参数,从主函数传递到其他的函数里面,这样就使其他的函数里面有值了。规则,只要你一实例化对象,系统自动回调用一个构造函数,就是你不写,编译器也自动调用一次。

析构函数与构造函数的作用相反,用于撤销对象的一些特殊任务处理,可以是释放对象分配的内存空间;特点:析构函数与构造函数同名,但该函数前面加~。

析构函数没有参数,也没有返回值,而且不能重载,在一个类中只能有一个析构函数。当撤销对象时,编译器也会自动调用析构函数。每一个类必须有一个析构函数,用户可以自定义析构函数,也可以是编译器自动生成默认的析构函数。一般析构函数定义为类的公有成员。

构造函数的执行顺序?析构函数的执行顺序?

构造函数顺序

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

  • 成员类对象构造函数。如果有多个成员类对象则构造函数的调用顺序是对象在类中被声明的顺序,而不是它们出现在成员初始化表中的顺序。

  • 派生类构造函数。

析构函数顺序

  • 调用派生类的析构函数;

  • 调用成员类对象的析构函数;

  • 调用基类的析构函数。

纯虚函数 (应用于接口继承和实现继承)

实际上,纯虚函数的出现就是为了让继承可以出现多种情况:

  • 有时我们希望派生类只继承成员函数的接口

  • 有时我们又希望派生类既继承成员函数的接口,又继承成员函数的实现,而且可以在派生类中可以重写成员函数以实现多态

  • 有的时候我们又希望派生类在继承成员函数接口和实现的情况下,不能重写缺省的实现。

其实,声明一个纯虚函数的目的就是为了让派生类只继承函数的接口,而且派生类中必需提供一个这个纯虚函数的实现,否则含有纯虚函数的类将是抽象类,不能进行实例化。

对于纯虚函数来说,我们其实是可以给它提供实现代码的,但是由于抽象类不能实例化,调用这个实现的唯一方式是在派生类对象中指出其 class 名称来调用。

静态绑定和动态绑定的介绍

说起静态绑定和动态绑定,我们首先要知道静态类型和动态类型,静态类型就是它在程序中被声明时所采用的类型,在编译期间确定。动态类型则是指“目前所指对象的实际类型”,在运行期间确定。

静态绑定,又名早绑定,绑定的是静态类型,所对应的函数或属性依赖于对象的静态类型,发生在编译期间。

动态绑定,又名晚绑定,绑定的是动态类型,所对应的函数或属性依赖于动态类型,发生在运行期间。

比如说,virtual 函数是动态绑定的,非虚函数是静态绑定的,缺省参数值也是静态绑定的。这里呢,就需要注意,我们不应该重新定义继承而来的缺省参数,因为即使我们重定义了,也不会起到效果。因为一个基类的指针指向一个派生类对象,在派生类的对象中针对虚函数的参数缺省值进行了重定义, 但是缺省参数值是静态绑定的,静态绑定绑定的是静态类型相关的内容,所以会出现一种派生类的虚函数实现方式结合了基类的缺省参数值的调用效果,这个与所期望的效果不同。

深拷贝和浅拷贝的区别(举例说明深拷贝的安全性)

当出现类的等号赋值时,会调用拷贝函数,在未定义显示拷贝构造函数的情况下, 系统会调用默认的拷贝函数-即浅拷贝,它能够完成成员的一一复制。当数据成员中没有指针时,浅拷贝是可行的。

但当数据成员中有指针时,如果采用简单的浅拷贝,则两类中的两个指针指向同一个地址,当对象快要结束时,会调用两次析构函数,而导致指野指针的问题。

所以,这时必需采用深拷贝。深拷贝与浅拷贝之间的区别就在于深拷贝会在堆内存中另外申请空间来存储数据,从而也就解决来野指针的问题。简而言之,当数据成员中有指针时,必需要用深拷贝更加安全。

什么情况下会调用拷贝构造函数(三种情况)

类的对象需要拷贝时,拷贝构造函数将会被调用,以下的情况都会调用拷贝构造函数:

  • 一个对象以值传递的方式传入函数体,需要拷贝构造函数创建一个临时对象压入到栈空间中。

  • 一个对象以值传递的方式从函数返回,需要执行拷贝构造函数创建一个临时对象作为返回值。

  • 一个对象需要通过另外一个对象进行初始化。

为什么拷贝构造函数必需时引用传递,不能是值传递?

为了防止递归调用。当一个对象需要以值方式进行传递时,编译器会生成代码调用它的拷贝构造函数生成一个副本,如果类 A 的拷贝构造函数的参数不是引用传递,而是采用值传递,那么就又需要为了创建传递给拷贝构造函数的参数的临时对象,而又一次调用类 A 的拷贝构造函数,这就是一个无限递归。

结构体内存对齐方式和为什么要进行内存对齐?

首先我们来说一下结构体中内存对齐的规则:

  • 对于结构体中的各个成员,第一个成员位于偏移为 0 的位置,以后的每个数据成员的偏移量必须是 min(#pragma pack() 制定的数,数据成员本身长度) 的倍数。

  • 在所有的数据成员完成各自对齐之后,结构体或联合体本身也要进行对齐,整体长度是 min(#pragma pack()制定的数,长度最长的数据成员的长度) 的倍数。

那么内存对齐的作用是什么呢?

  • 经过内存对齐之后,CPU 的内存访问速度大大提升。因为 CPU 把内存当成是一块一块的,块的大小可以是 2,4,8,16 个字节,因此 CPU 在读取内存的时候是一块一块进行读取的,块的大小称为内存读取粒度。比如说 CPU 要读取一个 4 个字节的数据到寄存器中(假设内存读取粒度是 4),如果数据是从 0 字节开始的,那么直接将 0-3 四个字节完全读取到寄存器中进行处理即可。

  • 如果数据是从 1 字节开始的,就首先要将前 4 个字节读取到寄存器,并再次读取 4-7 个字节数据进入寄存器,接着把 0 字节,5,6,7 字节的数据剔除,最后合并 1,2,3,4 字节的数据进入寄存器,所以说,当内存没有对齐时,寄存器进行了很多额外的操作,大大降低了 CPU 的性能。

  • 另外,还有一个就是,有的 CPU 遇到未进行内存对齐的处理直接拒绝处理,不是所有的硬件平台都能访问任意地址上的任意数据,某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。所以内存对齐还有利于平台移植。

内存泄漏的定义,如何检测与避免?

定义:内存泄漏简单的说就是申请了一块内存空间,使用完毕后没有释放掉。它的一般表现方式是程序运行时间越长,占用内存越多,最终用尽全部内存,整个系统崩溃。由程序申请的一块内存,且没有任何一个指针指向它,那么这块内存就泄漏了。

如何检测内存泄漏

  • 首先可以通过观察猜测是否可能发生内存泄漏,Linux 中使用 swap 命令观察还有多少可用的交换空间,在一两分钟内键入该命令三到四次,看看可用的交换区是否在减少。

  • 还可以使用 其他一些 /usr/bin/stat 工具如 netstat、vmstat 等。如发现波段有内存被分配且从不释放,一个可能的解释就是有个进程出现了内存泄漏。

  • 当然也有用于内存调试,内存泄漏检测以及性能分析的软件开发工具 valgrind 这样的工具来进行内存泄漏的检测。

说一下 define、const、typedef、inline 使用方法?

1、const 与 #define 的区别

const 定义的常量是变量带类型,而 #define 定义的只是个常数不带类型;

define 只在预处理阶段起作用,简单的文本替换,而 const 在编译、链接过程中起作用;

define 只是简单的字符串替换没有类型检查。而const是有数据类型的,是要进行判断的,可以避免一些低级错误;

define 预处理后,占用代码段空间,const 占用数据段空间;

const 不能重定义,而 define 可以通过 #undef 取消某个符号的定义,进行重定义;

define 独特功能,比如可以用来防止文件重复引用。

2、#define 和别名 typedef 的区别

执行时间不同,typedef 在编译阶段有效,typedef 有类型检查的功能;#define 是宏定义,发生在预处理阶段,不进行类型检查;

功能差异,typedef 用来定义类型的别名,定义与平台无关的数据类型,与 struct 的结合使用等。

#define 不只是可以为类型取别名,还可以定义常量、变量、编译开关等。

作用域不同,#define 没有作用域的限制,只要是之前预定义过的宏,在以后的程序中都可以使用。

而 typedef 有自己的作用域。

3、define 与 inline 的区别

#define是关键字,inline是函数;

宏定义在预处理阶段进行文本替换,inline 函数在编译阶段进行替换;

inline 函数有类型检查,相比宏定义比较安全;

预处理,编译,汇编,链接程序的区别

一段高级语言代码经过四个阶段的处理形成可执行的目标二进制代码。

预处理器→编译器→汇编器→链接器:最难理解的是编译与汇编的区别。

这里采用《深入理解计算机系统》的说法。

预处理阶段: 写好的高级语言的程序文本比如 hello.c,预处理器根据 #开头的命令,修改原始的程序,如#include<stdio.h> 将把系统中的头文件插入到程序文本中,通常是以 .i 结尾的文件。

编译阶段: 编译器将 hello.i 文件翻译成文本文件 *hello.s,这个是汇编语言程序。高级语言是源程序。所以注意概念之间的区别。汇编语言程序是干嘛的?每条语句都以标准的文本格式确切描述一条低级机器语言指令。*不同的高级语言翻译的汇编语言相同。

汇编阶段: 汇编器将 hello.s 翻译成机器语言指令。把这些指令打包成可重定位目标程序,即 .o文件。hello.o是一个二进制文件,它的字节码是机器语言指令,不再是字符。前面两个阶段都还有字符。

链接阶段: 比如 hello 程序调用 printf 程序,它是每个 C 编译器都会提供的标准库 C 的函数。这个函数存在于一个名叫 printf.o 的单独编译好的目标文件中,这个文件将以某种方式合并到 hello.o 中。链接器就负责这种合并。得到的是可执行目标文件。

说一下 fork,wait,exec 函数

父进程产生子进程使用 fork 拷贝出来一个父进程的副本,此时只拷贝了父进程的页表,两个进程都读同一块内存。

当有进程写的时候使用写实拷贝机制分配内存,exec 函数可以加载一个 elf 文件去替换父进程,从此父进程和子进程就可以运行不同的程序了。

fork 从父进程返回子进程的 pid,从子进程返回 0,调用了 wait 的父进程将会发生阻塞

以上是关于硬核 | C++ 基础大全的主要内容,如果未能解决你的问题,请参考以下文章

程序员不得不了解的硬核知识大全

程序员不得不了解的硬核知识大全

VSCode自定义代码片段3——url大全

VSCode自定义代码片段3——url大全

VSCode自定义代码片段3——url大全

VSCode自定义代码片段——cli的终端命令大全