八股文之C++基础语法

编译内存相关

编译与链接

  1. 为什么需要编译

    我们常见的 C/C++ 语言,CPU 是无法理解的,这就需要将我们编写好的代码最终翻译为机器可执行的二进制指令,编译的过程本质上也即是翻译的过程,当然中间涉及的细节非常复杂。

  2. 编译的过程

    1. 编译预处理:引入头文件,去除注释,处理条件编译指令,宏替换,添加行号
    2. 编译:对预处理后的文件进行词法分析、语法分析、语义分析、符号汇总、汇编代码的生成,代码优化。简单来说就是将.cpp源文件翻译成汇编代码
    3. 汇编:将汇编代码翻译成机器指令,一个.cpp文件生成一个.o文件
    4. 链接:单独的.o文件可能无法执行,因为一个程序可能由多个源文件组成。链接的目的是为了将多个目标文件链接成一个整体,从而生成一个可被操作系统加载执行的ELF文件

image.png

动态链接与静态链接

  1. 静态链接:在链接生成可执行文件时,将所有外部调用函数拷贝到最终的可执行文件中,该程序被执行时,运行所需的全部代码都会装入到该进程的虚拟空间中。

    命名规则:Linux:.a,Windows:.lib

  2. 动态链接:代码生成可执行文件时,该程序调用的部分程序被放到动态链接库或共享对象的某个目标文件中,链接程序只在最终的可执行文件中记录了共享对象的名字等一些信息,最终生成的ELF文件中并不包含这些调用程序的二进制指令。在程序执行时,当需要调用这部分程序时,操作系统会从将这些动态链或者共享对象进行加载,并将全部内容会被映射到该进行运行的虚拟地址的空间。动态链接库采用了延迟绑定技术。
    命名规则:Linux:.so, Windows:.dll

  3. 二者的优缺点:

    1. 静态链接
      1. 缺点:浪费空间,每个可执行程序都会有目标文件的一个副本,这样如果目标文件进行了更新操作,就需要重新进行编译链接生成可执行程序(更新困难
      2. 优点:执行的时候运行速度,因为可执行程序具备了程序运行的所有内容
    2. 动态链接
      1. 缺点:动态链接是在程序运行时,每次执行都需要链接,相比静态链接会有一定的性能损失
      2. 优点:节省内存、更新方便
    3. 静态链接是由链接器完成的,动态链接最终是由操作系统来完成链接的功能

补充:静态库和动态库

C++内存管理

  1. ELF文件

    可执行与可链接格式 (Executable and Linkable Format) 是一种用于可执行文件、目标代码、共享库和核心转储 (core dump)的标准文件格式。
    其文件构成如下:
    image.png
    ELF文件内部是分段存储的 :

    • .text section:代码段,存放机器代码,只读
    • .rodata section:只读数据段,文字常量
    • .data section:数据段,存放已初始化的全局/静态变量、常量
    • .bss section:未初始化的全局变量,仅是占位符,不占据任何磁盘空间,区分.data和.bss是为了空间效率
  2. 内存分区:

    C++ 程序在运行时也会按照不同的功能划分不同的段,C++ 程序使用的内存分区一般包括:栈、堆、全局/静态存储区、常量存储区、代码区。

    • :栈中主要存放函数的局部变量、函数参数、返回地址等,栈空间一般由操作系统进行默认分配或者程序指定分配,栈空间在进程生存周期一直都存在,当进程退出时,操作系统才会对栈空间进行回收。
    • :动态申请的内存空间,就是由 malloc 函数或者 new 函数分配的内存块,由程序控制它的分配和释放,可以在程序运行周期内随时进行申请和释放,如果进程结束后还没有释放,操作系统会自动回收
    • 全局区/静态存储区:主要为 .bss 段和 .data 段,存放全局变量和静态变量,程序运行结束操作系统自动释放,在 C 中,未初始化的放在 .bss 段中,初始化的放在 .data 段中,C++ 中不再区分了。
    • 常量存储区:.rodata 段,存放的是常量,不允许修改,程序运行结束自动释放。
    • 代码区:.text 段,存放代码,不允许修改,但可以执行。编译后的二进制文件存放在这里。

    补充:

    1. static变量存放在全局数据区/静态存储区
    2. 全局const存放在.rodata段,局部const存放在栈区

image.png

程序栈帧

程序中函数的调用过程:
每次在调用函数时,会按照从右向左的顺序依次将函数调用参数压入到栈中,并在栈中压入返回地址当前的栈帧基地址rbp,然后跳转到调用函数内部,pc 跳转函数内部执行该函数的指令。

程序栈帧

堆和栈的区别

  1. 申请方式不同
    • 栈由系统自动分配
    • 堆是程序员申请和释放的
  2. 申请后系统响应
    • 分配栈空间,如果剩余空间大于申请空间则分配成功,否则分配失败栈溢出
    • 申请堆空间,堆在内存中呈现的方式类似于链表(记录空闲地址空间的链表),在链表上寻找第一个大于申请空间的节点分配给程序,将该节点从链表中删除,大多数系统中该块空间的首地址存放的是本次分配空间的大小,便于释放,将该块空间上的剩余空间再次连接在空闲链表上
  3. 栈在内存中是连续的一块空间(向低地址扩展)最大容量是系统预定好的,堆在内存中的空间(向高地址扩展)是不连续
  4. 申请效率
    • 栈是有系统自动分配,申请效率高,但程序员无法控制
    • 堆是由程序员主动申请,效率低,使用起来方便但是容易产生碎片
  5. 存放内容
    • 栈中存放的是局部变量,函数的参数
    • 堆中存放的内容由程序员控制

补充:

  1. 你觉得堆快一点还是栈快一点?

    毫无疑问是栈快一点。
    因为操作系统会在底层对栈提供支持,会分配专门的寄存器存放栈的地址,栈的入栈出栈操作也十分简单,并且有专门的指令执行,所以栈的效率比较高也比较快。
    堆的操作是由C/C++函数库提供的,在分配堆内存的时候需要一定的算法寻找合适大小的内存。并且获取堆的内容需要两次访问,第一次访问指针,第二次根据指针保存的地址访问内存,因此堆比较慢。

  2. Linux下非编译器决定栈带下,而是有OS决定的,Windows平台下栈的大小是被记录在可执行文件中的(由编译器来设置),即:Windows下可以由编译器来决定栈大小,而在Linux下由系统环境变量来控制栈的大小

    1. Linux 默认栈大小:8M
    2. Windows默认栈大小:1M

变量定义与生存周期

  1. 作用域
    1. 全局变量:全局作用域,extern 声明
    2. 静态全局变量:文件作用域
    3. 局部变量/静态局部变量:局部作用域
  2. 生命周期
    1. 全局变量:整个程序运行期间
    2. 局部变量:函数被调用期间,程序块内部
    3. 静态局部变量:整个程序运行期间
  3. 分配内存
    1. 静态变量一般存储在数据段,其中 .data 存储已经已经初始化的静态变量和全局变量,.bss 存储未初始化的静态变量与全局变量。这里静态变量包括全局变量,局部全局变量,静态局部变量
    2. 局部变量一边存储在栈区或者堆区
  4. 补充
    1. 静态变量和栈变量(存储在栈中的变量)、堆变量(存储在堆中的变量)的区别:静态变量会被放在程序的静态数据存储区(.data 段,bss 段)中(静态变量会自动初始化),这样可以在下一次调用的时候还可以保持原来的赋值。而栈变量或堆变量不能保证在下一次调用的时候依然保持原来的值
    2. 静态变量和全局变量的区别:静态变量仅在变量的作用范围内可见,实际是依靠编译器来控制作用域。全局变量在整个程序范围内都可可见,只需声明该全局变量,即可使用。
    3. 全局变量定义在不要在头文件中定义:如果在头文件中定义全局变量,当该头文件被多个文件 include 时,该头文件中的全局变量就会被定义多次,编译时会因为重复定义而报错,因此不能再头文件中定义全局变量。一般情况下我们将变量的定义放在 .cpp 文件中,一般在 .h 文件使用extern 对变量进行声明

内存对齐

原则:

  1. 结构体变量的首地址能够被其最宽基本类型成员大小与对齐基数中的较小者所整除;
  2. 结构体每个成员相对于结构体首地址的偏移量 (offset) 都是该成员大小与对齐基数中的较小者的整数倍,如有需要编译器会在成员之间加上填充字节 (internal padding);
  3. 结构体的总大小为结构体最宽基本类型成员大小与对齐基数中的较小者的整数倍,如有需要编译器会在最末一个成员之后加上填充字节 (trailing padding)。

进行内存对齐的原因:(主要是硬件设备方面的问题)

  1. 某些硬件设备只能存取对齐数据,存取非对齐的数据可能会引发异常;
  2. 某些硬件设备不能保证在存取非对齐数据的时候的操作是原子操作;
  3. 相比于存取对齐的数据,存取非对齐的数据需要花费更多的时间;
  4. 某些处理器虽然支持非对齐数据的访问,但会引发对齐陷阱(alignment trap);
  5. 某些硬件设备只支持简单数据指令非对齐存取,不支持复杂数据指令的非对齐存取。

优点:

  1. 便于在不同的平台之间进行移植,因为有些硬件平台不能够支持任意地址的数据访问,只能在某些地址处取某些特定的数据,否则会抛出异常;
  2. 提高内存的访问效率,因为 CPU 在读取内存时,是一块一块的读取。

补充:静态成员变量存放在全局数据区内,在编译的时候已经分配好内存空间,所以对结构体的总内存大小不做任何贡献。

大端与小端

  1. 大小端判断

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    #include<bits/stdc++.h>

    using namespace std;

    int main() {
    int a = 0x1234;
    char c = (char)(a);
    if(c == 0x12)
    printf("big endian\n");
    else if(c == 0x34)
    printf("little endian\n");
    return 0;
    }
  2. 大小端转换

    1
    #define ORDER_TRANS(i) ((i & 0xff000000) >> 24 ) |  ( (i & 0x00ff0000) >> 8 ) | ( (i & 0x0000ff00) << 8 )  | ( (i & 0x000000ff) << 24 )
  3. 网络字节序转换

    1
    2
    3
    4
    ntohl(uint32 x)       // uint32 类型 网络序转主机序
    htonl(uint32 x) // uint32 类型 主机序转网络序
    ntohs(uint16 x) // uint16 类型 网络序转主机序
    htons(uint16 x) // uint16 类型 主机序转网络序

内存泄漏

  1. 内存泄漏

    程序在中申请的动态内存,在程序使用完成时没有得到及时的释放。当这些变量的生命周期已结束时,该变量在堆中所占用的内存未能得到释放,从而就导致了堆中可使用的内存越来越少,最终可能产生系统运行较慢或者系统因内存不足而崩溃的问题。

    1. 内存泄漏并非指内存从物理上消失,而是指程序在运行过程中,由于疏忽或错误而失去了对该内存的控制,从而造成了内存的浪费。
    2. 内存泄漏主要指上分配的变量,因为栈中分配的变量,随着函数退出时会自动回收。而堆是动态分配的,一旦用户申请了内存分配而为及时释放,那么该部分内存在整个程序运行周期内都是被占用的,其他程序无法再使用这部分内存。
    3. 对于实际的程序来说,我们在调用过程中使用 malloc、calloc、realloc、new 等分配内存时,使用完后要调用相应的 free 或 delete 释放内存,否则这块内存就会造成内存泄漏。
  2. 内存泄漏导致的问题:

由于内存未得到及时释放,从而可能导致可使用的动态内存空间会越来越少,一旦内存空间全部使用完,则程序可能会导致因为内存不够中止运行。由于内存泄漏导致的问题比较严重,现在许多语言都带有 GC 程序会自动对不使用的内存进行回收,从而避免内存泄漏。

内存泄漏的预防与检测

  1. 预防

    1. 内部封装:将内存的分配和释放封装到类中,在构造的时候申请内存,析构的时候释放内存
    2. 智能指针
    3. 良好的编码习惯
      1. 将基类的析构函数定义为虚函数
      2. 遵循RAII(Resource acquisition is initialization)原则:在对象构造时获取资源,在对象生命期控制对资源的访问使之始终保持有效,最后在对象析构的时候释放资源;
      3. 使用智能指针
      4. 有效引入内存检测工具
  2. 检测

    Valgrind 是一套 Linux 下,开放源代码(GPL V2)的仿真调试工具的集合,包括以下工具:

    • Memcheck:内存检查器(valgrind 应用最广泛的工具),能够发现开发中绝大多数内存错误的使用情况,比如:使用未初始化的内存,使用已经释放了的内存,内存访问越界等。

      • Callgrind:检查程序中函数调用过程中出现的问题。
      • Cachegrind:检查程序中缓存使用出现的问题。
      • Helgrind:检查多线程程序中出现的竞争问题。
      • Massif:检查程序中堆栈使用中出现的问题。
      • Extension:可以利用 core 提供的功能,自己编写特定的内存调试工具。

    Memcheck 能够检测出内存问题,关键在于其建立了两个全局表:

    • Valid-Value 表:对于进程的整个地址空间中的每一个字节(byte),都有与之对应的 8 个 bits ;对于 CPU 的每个寄存器,也有一个与之对应的 bit 向量。这些 bits 负责记录该字节或者寄存器值是否具有有效的、已初始化的值。

    • Valid-Address 表:对于进程整个地址空间中的每一个字节(byte),还有与之对应的 1 个 bit,负责记录该地址是否能够被读写。

    • 检测原理:

      当要读写内存中某个字节时,首先检查这个字节对应的 Valid-Address 表中对应的 bit。如果该 bit 显示该位置是无效位置,Memcheck 则报告读写错误。
      内核(core)类似于一个虚拟的 CPU 环境,这样当内存中的某个字节被加载到真实的 CPU 中时,该字节在 Valid-Value 表对应的 bits 也被加载到虚拟的 CPU 环境中。一旦寄存器中的值,被用来产生内存地址,或者该值能够影响程序输出,则 Memcheck 会检查 Valid-Value 表对应的 bits,如果该值尚未初始化,则会报告使用未初始化内存错误。

image.png

智能指针的简介与使用

  1. 智能指针

    智能指针是为了解决动态内存分配时带来的内存泄漏以及多次释放同一块内存空间而提出的。C++ 11 中提供了智能指针的定义,所有关于智能指针的定义可以参考 头文件。
    传统的指针在申请完成后,必须要调用 free 或者 delete 来释放指针,否则容易产生内存泄漏的问题;smart pointer 遵循 RAII 原则,当 smart pointer 对象创建时,即为该指针分配了相应的内存,当对象销毁时,析构函数会自动释放内存
    需要注意的是,智能指针不能像普通指针那样支持加减运算

  2. 常用的3类智能指针:

    1. unique_ptr

      独享所有权的智能指针,资源只能被一个指针占有,该指针不能拷贝构造和赋值。但可以进行移动构造移动赋值(调用 move() 函数),即一个 unique_ptr 对象赋值给另一个 unique_ptr 对象,可以通过move进行赋值。

    2. shared_ptr

      与 unique_ptr 不同的是,shared_ptr 中资源可以被多个指针共享,但是多个指针指向同一个资源不能被释放多次,因此使用计数机制表明资源被几个指针共享。

      通过 use_count() 查看资源的所有者的个数,可以通过 unique_ptr、weak_ptr 来构造,调用 release() 释放资源的所有权,同时将计数减一,当计数减为 0 时会自动释放内存空间(T object),从而避免了内存泄漏。特别需要注意的是 shared_ptr 并不是线程安全的,但 shared_ptr 的计数是原子操作实现的,利用 atmoic CAS 指令实现。当引用计数和 weak count 同时为 0 时,控制块才会被最终释放掉。

      shared_ptr能够实现其功能依赖于对于多个shared_ptr只实例化一个**_Sp_counted_base<_Lp>(控制块)**

    3. weak_ptr

      指向 share_ptr 指向的对象,能够解决由 shared_ptr 带来的循环引用问题。与 shared_ptr 配合使用,将 weak_ptr 转换为 share_ptr 时,虽然它能访问 share_ptr 所指向的资源但却不享有资源的所有权,不影响该资源的引用计数。有可能资源已被释放,但 weak_ptr 仍然存在,share_ptr 必须等待所有引用的 weak_ptr 全部被释放才会进行释放。因此每次访问资源时都需要判断资源是否有效
      shared_ptr 通过引用计数的方式来管理对象,当进行拷贝或赋值操作时,每个 shared_ptr 都会记录当前对象的引用计数,当引用计数为0时,内存将被自动释放。当对 shared_ptr 赋予新值或者 shared_ptr 对象被销毁时,引用计数会递减。但特殊情况出现循环引用时,shared_ptr 无法正常释放资源。循环引用,即 A 指向 B,B 指向 A,在表示双向关系时,是很可能出现这种情况的。

image.png

智能指针创建

C++14中加入了make_unique和make_shared,《Effective Modern C++》 学习笔记之条款二十一:优先选用 std::make_unique 和 std::make_shared,而非直接 new。

  1. make_unique

    减少代码量,能够加快编译速度,定义两遍类型时,编译器需要进行类型推导会降低编译速度,某些意外意外情况下可能会导致内存泄漏。但是 make_unique 不允许自定析构器,不接受 std::initializer_list 对象。

  2. make_share

    这个主要是可以减少对堆中申请内存的次数,只需要申请一次即可。
    当我们使用 new 时,我们将 new 出的资源指针赋给 share_ptr 的 ptr, 然后 share_ptr 本身还需要再次在堆上申请一块单独的内存作为它的管理区,存放引用计数、用户自定的函数等,因此创建 shared_ptr 时需要在堆上申请两次。
    当我们使用 make_share 时,我们只需要申请一块大的内存,一半用来存储资源,另一半作为管理区, 存放引用计数、用户自定的函数等,此时需要在堆上申请一次即可。
    make_share 虽然效率高,但是同样不能自定义析构器,同时 share_ptr 的对象资源可能会延迟释放,因为此时对象资源与管理区域在同一块内存中,必须要同时释放。

include “ “ 和 < > 的区别

  1. #include:

    include 关键字主要用来标识 C/C++ 程序源代码编译时需要引用的头文件,编译器会自动去查找这些头文件中的变量、函数声明、结构体定义等相关信息,常见的有 include和 #include “filename”,二者之间的区别:

    1. include通常在编译器或者 IDE 中预先指定的搜索目录中进行搜索,通常会搜索 /usr/include 目录,此方法通常用于包括标准库头文件;
    2. #include “filename” 在当前源文件所在目录中进行查找,如果没有;再到当前已经添加的系统目录(编译时以 -I 指定的目录)中查找,最后会在 /usr/include 目录下查找 。

日常编写程序时,对于标准库中的头文件常用 include,对于自己定义的头文件常用 #include “filename”。

  1. __has_include:

    C++ 17 支持该特性,用来检查是否已经包含某个文件。

语言对比

C与 C++ 对比

C 语言是典型面向过程(Procedure Oriented)的编程语言,C++ 则是典型面向对象(Object Oriented)的编程语言,当然 C++ 也支持面向过程编程

  • 面向过程(Procedure Oriented):一种以过程为中心的编程思想,侧重于分析解决问题所需的步骤,使用函数把这些步骤依次实现。
  • 面向对象(Object Oriented):侧重于把构成问题的事务分解为各个对象。建立对象的目的不是完成其中的一个步骤,而是描述某个事务在解决整个具体问题步骤中的属性和行为。面向对象语言的显著特征就是支持封装、继承、多态
  1. C 语言:

    C 语言诞生于 1969 年在贝尔实验室诞生,C 语言是面向过程的编程,它最重要的特点是函数,通过 main 函数来调用各个子函数。程序运行的顺序都是程序员事先决定好的(数据结构+算法)。

  2. C++ 语言:

    C++ 诞生于 1979 年,设计者为 Bjarne Stroustrup.
    C++ 是面向对象的编程,类是它的主要特点,在程序执行过程中,先由主 main 函数进入,定义一些类,根据需要执行类的成员函数,过程的概念被淡化了(实际上过程还是有的,就是主函数的那些语句)。以类驱动程序运行,类就是对象,所以我们称之为面向对象程序设计。面向对象在分析和解决问题的时候,将涉及到的数据和数据的操作封装在类中,通过类可以创建对象,以事件或消息来驱动对象执行处理。

  3. 两者之间的比较:

    C++ 既继承了 C 强大的底层操作特性,又被赋予了面向对象机制。它特性繁多,支持面向对象语言的多继承、对值传递与引用传递的区分以及 const 关键字,现代 C++ 编译器完全兼容 C 语言语法。

  • 二者的相同之处:

    C++ 能够大部分兼容 C 的语法,且二者之间相同的关键字和运算符功能和作用也几乎相同;二者之间的内存模型与硬件比较接近,几乎都可以直接操纵硬件。栈、堆、静态变量这些概念在两种语言都存在。

  • 二者的不同之处:

    1. C 为面向过程的编程语言,不支持面向对象,不支持继承、多态、封装。
    2. 类型检查更为严格,C 语言中的类型转换几乎是任意的,但是 C++ 编译器对于类型转换进行非常严格检查,部分强制类型转换在 C 语言编译器下可以通过,但在 C++ 编译器下无法通过。
    3. C 和 C++ 中都有结构的概念,但是在 C 语言中结构只有成员变量,而没成员方法,C 的成员变量没有权限控制,该结构体的变量对所有调用全部可见;而在 C++ 中结构中,它可以有自己的成员变量和成员函数,C++ 对类的成员变量具有访问权限控制
    4. 增加了面向对象的机制、泛型编程的机制(Template)、异常处理、引用、运算符重载、标准模板库(STL)、命名空间(避免全局命名冲突)
    5. 应用领域:对于 C 语言程序员来说,程序的底层实现和内存分布基本上都可见,所以一般常用于直接控制硬件,特别是 C 语言在嵌入式领域应用很广,比如常见的驱动开发等与硬件直接打交道的领域,C++ 可以用于应用层开发,用户界面开发等与操作系统打交道的领域,特别是图形图像编程领域,几乎所有的高性能图形图像库都是用 C++ 实现的。

Java与C的区别

  1. 二者的相同之处:

    C++ 与 Java 均支持面对对象(Object Oriented),支持类、继承、封装等常见的概念。

  2. 二者的不同之处:

    1. Java 被编译成字节码,并运行在虚拟机 JVM 上,和开发平台无关,具有跨平台的特性;C++ 直接编译成可执行文件,是否跨平台在于用到的编译器的特性是否有多平台的支持
    2. Java 是完全面向对象的语言,所有函数和变量部必须是类的一部分。除了基本数据类型之外,其余的都作为类对象,包括数组。对象将数据和方法结合起来,把它们封装在类中,这样每个对象都可实现自己的特点和行为。而 C++ 允许将函数和变量定义为全局的。
    3. 由于Java 被编译为字节码,只要安装能够运行 Java 的虚拟机即可运行 Java 程序,因此 Java 程序具有很强的可移植性,具有“一次编写,到处运行” 的跨平台特性;而 C++ 跨平台后,必须需要重新编译
    4. Java 语言具有垃圾回收机制,由系统进行分配和回收内存,编程人员无需考虑内存管理的问题,可以有效的防止内存泄漏,有效的使用空闲的内存。Java 所有的对象都是用 new 操作符建立在内存堆栈上,类似于 C++ 中的 new 操作符,但是当要释放该申请的内存空间时,Java 自动进行内存回收操作,Java 中的内存回收是以线程的方式在后台运行的,利用空闲时间。C++ 则需要程序员进行内存管理,当资源释放时需要程序员进行手动释放内存空间。
    5. C++ 支持多重继承,允许多个父类派生一个类,虽然功能很强大,但是如果使用的不当会造成很多问题,例如:菱形继承;Java 不支持多重继承,但允许一个类可以继承多个接口,可以实现 C++ 多重继承的功能,但又避免了多重继承带来的许多不便。
    6. C++ 支持方法与操作符的重载;但 Java 只支持方法重载,不支持操作符重载。
    7. C++ 用 virtual 关键字标记的方法可以被覆盖;Java 中非 static 方法均可被覆盖,Java 中的方法默认均可以被覆盖。
    8. C++ 可以直接操作指针,容易产生内存泄漏以及非法指针引用的问题;Java 并不是没有指针,虚拟机(JVM)内部还是使用了指针,只是编程人员不能直接使用指针,不能通过指针来直接访问内存,并且 Java 增加了内存管理机制。
    9. C++ 标准库不提供**thread 相关接口;Java 的标准 SDK 提供 thread 类
    10. C++ 支持结构体(structure)联合体(union),Java 不支持结构体(structure)与联合体(union)。
    11. 从应用场景来说, C++ 可以直接编译成可执行文件,运行效率比 Java 高。Java 目前主要用来开发 Web 应用。C++ 主要用在嵌入式开发、网络、并发编程、图形图像处理、系统编程的方面。

Python和C++的区别

  1. 二者的相同之处:

    C++ 与 Python 均支持面向对象,二者均可用来编写大型应用程序。

  2. 二者的不同之处:

    1. 从语言自身来说,Python 为脚本语言,解释执行,不需要经过编译,所有的 python 源代码都是经过 Python 解释器;C++ 是一种需要编译后才能运行的语言,在特定的机器上编译后运行。
    2. Python 变量的作用域不仅局限于(while,for)循环内,在循环外还可以继续访问在循环内定义的变量;C++ 则不允许循环外访问循环内定义的变量。
    3. Python 没有严格限定函数的参数类型和返回值类型;C++ 则严格限定函数参数和返回值的类型。
    4. 从运行效率来说,C++ 运行效率高,安全稳定。Python 代码和 C++ 最终都会变成 CPU 指令来跑,但一般情况下,比如反转和合并两个字符串,Python 最终转换出来的 CPU 指令会比 C++ 多很多。首先,Python 中涉及的内容比 C++ 多,经过了更多层,Python 中甚至连数字都是 object;其次,Python 是边解释边执行,和物理机 CPU 之间多了解释器这层,而 C++ 是编译执行的,直接就是机器码,编译的时候编译器又可以进行一些优化。
    5. 从开发效率来说,Python 开发效率高。Python 一两句代码就能实现的功能,C++ 往往需要更多的代码才能实现。
    6. 书写格式和语法不同,Python 的语法格式不同于其 C++ 定义声明才能使用,而且极其灵活,完全面向更上层的开发者,C++ 是严格静态类型声明语言,编译器在进行编译时必须经过严格的静态类型检查,如果发现类型检查错误,则中止编译;Python 为动态类型语言,我们在编写代码时不用指定变量的类型,只在执行时才会进行变量类型推导,确定变量类型。
    7. C++ 可以直接用来操纵硬件,适合用来作为系统编程;Python 作为一门脚本语言,功能小巧而精湛,非常适合做工具开发运维开发

Go和C++的区别

  1. 二者的相同之处:

    二者都为静态类型编程语言,二者都为编译性语言,都具有高性能的特点。

  2. 二者的不同之处:

    1. Go 的许多越语法和逻辑跟 C 非常类似,Go 的运行效率很高,Go 主要是面向过程,对于面向对象支持较弱,不支持继承、多态这些概念,Go 通过结构体中含有方法来支持面向对象Go 没有类的概念,同时也不支持构造函数与析构函数;C++ 则是面向对象(Object Oriented),支持继承、多重继承、多态、重载这些特性。
    2. Go 语言自带垃圾回收(garbage collection);C++ 不支持内存垃圾自动回收,需要程序手动管理动态申请的内存。
    3. Go 语言也支持指针,但是 Go 语言不支持指针的运算;C++ 支持指针,同时也支持指针运算。
    4. C++ 编译器提供 SIMD 指令生成,但是 Go 编译器不支持 SIMD 指令的生成。
    5. C++ 遵循的许可为 open source project 2.0,而 Go 遵循的许可为 BSD。
    6. C++ 与 Go 都属于静态类型编程语言,但是 Go 语言需要遵循强类型语言规则Go 不支持隐式类型转换
    7. Go 编译时如果需要引用外部函数则使用 import关键字,引入 packages,而 C++ 则使用**#include**关键字,引入头文件。
    8. Go 不支持函数重载和操作符重载,而 C++ 支持函数重载与操作符重载。
    9. Go 中的空指针用 nil 表示,而 C++ 中空指针可以用 nullptr 或者 0 表示。
    10. C++ 支持异常处理,可以捕获异常,Go 使用 panic 并保存所有的错误信息。
    11. Go 可以利用 goroutines 与 channel 来进行并发与多线程,C++ 只能使用线程

Rust和C++的区别

  1. 二者的相同之处:

    二者都支持指针操作,都可以用来作为系统编程语言,二者都可以用来操作底层硬件,二者都都具有与 C 语言程序相当的性能。

  2. 二者的不同之处:

    1. Rust 不允许控制指针和悬空指针,C++ 则允许空指针;
    2. Rust 只支持函数式编程,C++ 支持的语言特性较多;
    3. Rust 没有头文件,C++ 有头文件;
    4. Rust 语言自带有内存管理,保证内存使用安全,Rust 利用编译时的静态分析很大程度上保证了代码使用内存的安全性;而 C++ 需要进行手动申请和释放内存;
    5. Rust 利用静态分析,在编译时会分析代码由于并发引起的数据竞争,较好的做好的并发处理;C++ 的使用多线程并发容易引起各种数据竞争的问题。

C++11新特性

详细:C++11 新特性

  1. 类型推导

    1. auto
    2. decltype
  2. 闭包

    1. lambda表达式

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      [capture list] (parameter list) mutable -> return type
      {
      function body;
      };

      // capture list
      [] // 沒有定义任何变量。使用未定义变量会引发错误。
      [x, &y] // x以传值方式传入(默认),y以引用方式传入。
      [&] // 任何被使用到的外部变量都隐式地以引用方式加以引用。
      [=] // 任何被使用到的外部变量都隐式地以传值方式加以引用。
      [&, x] // x显式地以传值方式加以引用。其余变量以引用方式加以引用。
      [=, &z] // z显式地以引用方式加以引用。其余变量以传值方式加以引用。
    2. 仿函数

    3. bind绑定器

  3. 范围for

  4. 右值引用

    C++ 表达式中的 “值分类”(value categories)属性为左值右值。其中左值是对应(refer to)内存中有确定存储地址的对象的表达式的值,而右值是所有不是左值的表达式的值。因而,右值可以是字面量、临时对象等表达式。能否被赋值不是区分 C++ 左值与右值的依据,C++ 的 const 左值是不可赋值的;而作为临时对象的右值可能允许被赋值。左值与右值的根本区别在于是否允许取地址 & 运算符获得对应的内存地址
    C++ 03 在用临时对象或函数返回值给左值对象赋值时的深度拷贝(deep copy),因此造成性能低下。考虑到临时对象的生命期仅在表达式中持续,如果把临时对象的内容直接移动(move)给被赋值的左值对象(右值参数所绑定的内部指针复制给新的对象,然后把该指针置为空),效率改善将是显著的。
    C++ 右值引用即绑定到右值的引用,用 && 来获得右值引用,右值引用只能绑定到要销毁的对象。为了和右值引用区分开,常规的引用称为左值引用。左值引用是绑定到左值对象上;右值引用是绑定到临时对象上。左值对象是指可以通过取地址 & 运算符得到该对象的内存地址;而临时对象是不能用取地址 & 运算符获取到对象的内存地址,具体的引用绑定规则如下:

    • 非常量左值引用(X &):只能绑定到 X 类型的左值对象;
    • 常量左值引用(const X &):可以绑定到 X、const X 类型的左值对象,或 X、const X 类型的右值;
    • 非常量右值引用(X &&):只能绑定到 X 类型的右值;
    • 常量右值引用(const X &&):可以绑定规定到 X、const X 类型的右值。
  5. 标准库 move() 函数

    move() 函数:通过该函数可获得绑定到左值上的右值引用。通过 move 获取变量的右值引用,从而可以调用对象的移动拷贝构造函数和移动赋值构造函数。

  6. 智能指针

  7. 使用或禁用对象的默认函数

  8. constexpr

    1. 常量表示式对编译器来说是优化的机会,编译器时常在编译期执行它们并且将值存入程序中。同样地,在许多场合下,C++ 标准要求使用常量表示式。例如在数组大小的定义上,以及枚举值(enumerator values)都要求必须是常量表示式。
    2. 与const的区别:const修饰的变量可以在运行时才初始化,而constexpr则一定会在编译期初始化,constexpr才是名副其实的常量,而const表示的是read only的语义,但是也可能通过指针去修改它
    3. 用 constexpr 修饰函数将限制函数的行为:
      1. 函数的回返值类型不能为void;
      2. 函数体不能声明变量或定义新的类型;
      3. 函数体只能包含声明、null语句或者一段return语句;
      4. 函数的内容必须依照 “return expr” 的形式,在参数替换后,expr 必须是个常量表达式;
      5. 这些常量表达式只能够调用其他被定义为 constexpr 的函数,或是其他常量形式的参数;
      6. constexpr 修饰符的函数直到在该编译单元内被定义之前是不能够被调用的,声明为 constexpr 的函数也可以像其他函数一样用于常量表达式以外的调用。
  9. 初始化列表

  10. nullptr

关键字与关键库函数

sizeof 和 strlen 的区别

  1. strlen 本身是库函数,因此在程序运行过程中计算长度;而 sizeof 是运算符,在编译时计算长度;sizeof 的参数可以是类型,也可以是变量,且必须是完整类型;strlen 的参数必须是 char * 类型的变量。
  2. sizeof 接受的参数可以是对象也可以是表达式,但是 sizeof(expression) 在运行时不会对接受的表达式进行计算,编译器只会推导表达式的类型从而计算占用的字节大小;而 strlen 是一个函数,如果接受表达式则会对表达式进行运算

lambda 表达式的应用

闭包有很多种定义,一种说法是,闭包是带有上下文的函数,即有状态的函数
那什么叫 “带上状态” 呢? 意思是这个闭包有属于自己的变量,这些个变量的值是创建闭包的时候设置的,并在调用闭包的时候,可以访问这些变量。
lambda表达式的价值在于,就地封装短小的功能闭包,可以及其方便地表达出我们希望执行的具体操作,并让上下文结合更加紧密

explicit的作用

用来声明类构造函数是显式调用的,而非隐式调用,可以阻止调用构造函数时进行隐式转换和赋值初始化。只可用于修饰单参构造函数,因为无参构造函数和多参构造函数本身就是显示调用的,再加上 explicit 关键字也没有什么意义。

C 和 C++ 中 static 的作用

在 C 语言中,使用 static 可以定义局部静态变量、外部静态变量、静态函数。
在 C++ 中,使用 static 可以定义局部静态变量、外部静态变量、静态函数、静态成员变量和静态成员函数。因为 C++ 中有类的概念,静态成员变量、静态成员函数都是与类有关的概念。

  1. static 全局静态变量:

    普通全局变量和 static 全局静态变量都为静态存储方式。普通全局变量的作用域是整个源程序,当一个源程序由多个源文件组成时,普通全局变量在各个源文件中都是有效的;
    静态全局变量则限制了其作用域,即只在定义该变量的源文件内有效,在同一源程序的其它源文件中不能使用它。

  2. static 局部静态变量:

    局部静态变量只能被初始化一次。与全局静态变量不同的是静态局部变量的作用域仅限于函数内部,它的作用域与函数内部的局部变量相同。实际上局部静态变量同样也存储在静态存储区,因此它的生命周期贯穿于整个程序运行期间。

  3. static 静态函数:
    static 函数限制函数的作用域,仅可在定义该函数的文件内部调用。

  4. static 静态成员变量:

    静态成员变量是在类内进行声明,在类外进行定义和初始化,在类外进行定义和初始化的时候不要出现 static 关键字和 private、public、protected 访问规则。
    静态成员变量相当于类域中的全局变量,被类的所有对象所共享,包括派生类的对象,且只能该变量只能被初始化一次,不能在类的构造函数中对静态成员变量进行初始化。
    静态成员变量可以作为成员函数的参数,而普通成员变量不可以。

  5. static 静态成员函数:

    静态成员函数不能调用非静态成员变量或者非静态成员函数,因为静态成员函数没有 this 指针。静态成员函数做为类作用域的全局函数。
    静态成员函数不能声明成虚函数(virtual)、const 函数和 volatile 函数。

const 作用以及用法

  1. const变量

  2. const指针

  3. const引用

  4. cosnt成员变量

    const 成员变量只能在类内声明、定义,在构造函数初始化列表中初始化。
    const 成员变量只在某个对象的生存周期内是常量,对于整个类而言却是可变的,因为类可以创建多个对象,不同类的 const 成员变量的值是不同的。因此不能在类的声明中初始化 const 成员变量

  5. const参数与返回值

  6. const成员函数

define 和 const 的区别

  1. 编译阶段:define 是在编译预处理阶段进行替换,const 是在编译阶段确定其值。
  2. 安全性:define 定义的宏常量没有数据类型,只是进行简单的代码替换,不会进行类型安全的检查;const 定义的常量是有类型的,是要进行判断的,可以避免一些低级的错误。
  3. 存储空间:define 定义的宏定义只是作为代码替换的表达式而已,宏定义本身不占用内存空间,define 定义的宏常量,在程序中使用多少次就会进行多少次替换,内存中有多个备份,实际使用宏定义替换代码时占用的是代码段的空间;const 定义的常量占用静态存储区的只读空间,程序运行过程中常量只有一份
  4. 调试:define 定义的宏常量不能调试,因为在预编译阶段就已经进行替换了;const 定义的常量可以进行调试
  5. define 可以接受参数构造非常复杂的表达式,const 不能接受参数

define 和 typedef 的区别

  1. #define 作为预处理指令,在编译预处理时进行替换操作不作正确性检查,只有在编译已被展开的源程序时才会发现可能的错误并报错。typedef 是关键字,在编译时处理,有类型检查功能,用来给一个已经存在的类型一个别名,但不能在一个函数定义里面使用 typedef 。
  2. typedef 用来定义类型的别名,方便使用。#define 不仅可以为类型取别名,还可以定义常量、变量、编译开关等。
  3. #define 没有作用域的限制,只要是之前预定义过的宏,在以后的程序中都可以使用,如果在 .cpp 文件中定义了宏,则在整个文件中都可以使用该宏,如果在 .h 文件中定义了宏,则只要包含该头文件都可以使用;而 typedef 有自己的作用域,如果在函数之外定义了类型,则在整个文件中都可以使用该类型定义,如果在函数内部定义了该类型,则只能在函数内部使用该类型。
  4. 指针的操作:typedef 和 #define 在处理指针时不完全一样。

inline的作用以及使用方法

inline 是一个关键字,可以用于定义内联函数。内联函数,像普通函数一样被调用,但是在调用时并不通过函数调用的机制而是直接在调用点处展开,这样可以大大减少由函数调用带来的开销,从而提高程序的运行效率。在内联函数出现之前,在 C/C++ 的大型工程中常见用 #define 定义一些“函数”来消除调用这些函数的开销。内联函数设计的目的之一,就是取代 #define 的这项功能。由于使用 #define 定义的“函数”,编译器不会检查其参数的正确性等,而使用 inline 定义的函数,可以指定参数类型,则会被编译器校验)。内联函数可以在头文件中被定义,并被多个 .cpp 文件 include,而不会有重定义错误。这也是设计内联函数的主要目的之一。

  1. 使用方法:类内定义成员函数默认是内联函数除了虚函数以外,因为虚函数是在运行时决定的,在编译时还无法确定虚函数的实际调用。在类内定义成员函数,可以不用在函数头部加 inline 关键字,因为编译器会自动将类内定义的函数(构造函数、析构函数、普通成员函数等)声明为内联函数。
  2. 类外定义成员函数,若想定义为内联函数,需用关键字声明。当在类内声明函数,在类外定义函数时,如果想将该函数定义为内联函数,则可以在类内声明时不加 inline 关键字,而在类外定义函数时加上 inline 关键字。关键字 inline 必须与函数定义体放在一起才能使函数成为内联,如果只是 inline 放在函数声明前面不起任何作用

inline 的工作原理

  1. 内联函数的工作原理:

    内联函数不是在调用时发生控制转移关系,而是在编译阶段将函数体嵌入到每一个调用该函数的语句块中,编译器会将程序中出现内联函数的调用表达式用内联函数的函数体来替换。函数调用时,需要切换栈帧寄存器,同时栈中压入参数、返回值,然后进行跳转,这些都需要开销,而内联函数则可以不要这些开销,直接将内联函数中函数体直接插入或者替换到该函数调用点。
    普通函数是将程序执行转移到被调用函数所存放的内存地址,当函数执行完后,返回到执行此函数前的地方。转移操作需要保护现场,被调函数执行完后,再恢复现场,该过程需要较大的资源开销
    虽然内联函数在调用时直接进行展开,但实际在编译后代码中存在内联函数的定义,可以供编译器进行调用。普通函数可以有指向它的函数指针,内联函数也可以有指向它的函数指针。

  2. 内联函数的优缺点:

    1. 内联函数具有以下优点:

      不会产生函数调用开销。节省了调用函数时在堆栈上推送/弹出变量的开销。节省了函数返回调用的开销。当你内联一个函数时,你可以让编译器对函数体执行上下文特定的优化,其他优化可以通过考虑调用上下文和被调用上下文的流程来获得,而对于普通函数不会有这种优化。

    2. 内联函数的缺点:

      1. 从内联函数中添加的变量会消耗额外的寄存器,在内联函数之后,如果要使用寄存器的变量数量增加,则可能会在寄存器变量资源利用方面产生开销。在函数调用点替换内联函数体时,函数使用的变量总数也会增加,用于存储变量的寄存器数量也会增加。因此,如果在函数内联变量数量急剧增加之后,它肯定会导致寄存器利用率的开销。
      2. 如果你使用太多的内联函数,那么二进制可执行文件的大小会很大,因为相同的代码重复
      3. 过多的内联也会降低指令缓存命中率,从而降低从缓存内存到主内存的指令获取速度。
      4. 如果有人更改内联函数内的代码,内联函数可能会增加编译时间开销,那么所有调用位置都必须重新编译,因为编译器需要再次替换所有代码,否则它将继续使用旧功能.
      5. 内联函数可能会导致抖动,因为内联可能会增加二进制可执行文件的大小。内存抖动会导致计算机性能下降。
  3. inline 函数的使用场景:

    1. 内联函数一般只适用于比较短小,处理较为简单的函数。内联只是对编译器的请求,而不是命令。编译器可以忽略内联请求。编译器可能不会在以下情况下执行内联:
    2. 如果函数包含循环(for, while, do-while);
    3. 如果一个函数包含静态变量;
    4. 如果一个函数是递归的;
    5. 如果函数返回类型不是 void,并且函数体中不存在 return 语句;
    6. 如果函数包含 switch 或 goto 语句;
  4. 内联可以去除函数只能定义一次的限制:

    内联函数可以在程序中定义不止一次, 但是 inline 函数的定义在某个源文件中只能出现一次,而且在所有源文件中,其定义必须是完全相同的。一般情况下,我们可以在头文件中定义 inline 函数,所有 include 该头文件,如果修改了头文件中的 inline 函数时,使用了该头文件的所有源文件都必须重新编译。比如我们可以在定义以下两个文件包含相同的函数。

define 和 inline 的区别

  1. 内联函数是在编译时展开,而宏在编译预处理时展开;在编译的时候,内联函数直接被嵌入到目标代码中去,而宏只是一个简单的文本替换。
  2. 内联函数是真正的函数,和普通函数调用的方法一样,在调用点处直接展开,避免了函数的参数压栈操作,减少了调用的开销,在编译后的代码段中可以看到内联函数的定义。宏定义编写较为复杂,常需要增加一些括号来避免歧义。宏定义只进行文本替换,不会对参数的类型、语句能否正常编译等进行检查,因此在实际使用宏时非常容易出错。而内联函数是真正的函数,会对参数的类型、函数体内的语句编写是否正确等进行检查
  3. 内联函数可以进行调试,宏定义的“函数”无法调试
  4. 由于类的成员函数全部为内联函数,通过内联函数,可以访问类的数据成员,而宏不能访问类的数据成员。
  5. 在 inline 函数传递参数只计算一次,而在使用宏定义的情况下,每次在程序中使用宏时都会计算表达式参数,因此宏会对表达式参数计算多次。( ?)

new的作用

  1. new 的简介:
    1. new 是 C++ 中的关键字,尝试分配和初始化指定或占位符类型的对象或对象数组,并返回指向对象 (或数组的初始对象) 的指针。
    2. 用 new 创建对象时
      1. 首先从中申请相应的内存空间
      2. 然后调用对象的构造函数
      3. 最后返回指向对象的指针
    3. operator new 从自由存储区(free store)上为对象动态分配内存空间,而 malloc 函数从堆上动态分配内存。自由存储区是 C++ 基于 operator new 的一个抽象概念,凡是通过 new 操作符进行内存申请,该内存即为自由存储区。而堆是操作系统中的术语,是操作系统所维护的一块特殊内存,用于程序的内存动态分配,C 语言使用 malloc 从堆上分配内存,使用 free 释放已分配的对应内存。new 可以指定在内存地址空间创建对象,但 placement new 需要手动调用析构,不能直接delete(会释放空间)。
    4. 在 cpp 中由于 new 作为操作符可以进行重载,所以可以对 new 进行重载,比如可以指定构造函数对对象进行初始化。对 new 操作符可以进行全局重载局部重载,全局重载后,所有调用 new 的操作都会被重写;局部重载就是在类中重写 operator new 函数,但只会对该类生效,即该类执行 new 操作时会生效。

new和delete是如何实现的

new:

  1. 申请空间
  2. 构造对象
  3. 返回指针

delete:

  1. 析构对象
  2. 释放空间

new 和 malloc 的区别

  1. malloc 的实现原理:
    1. malloc 为 C 语言的库函数,主要用来 从堆中申请指定大小且连续的内存空间。实际的底层实现可能较为复杂,每个程序都带有自己的动态内存管理子模块,常见的动态内存分配算法有 best fit 和 first fit 等
    2. malloc 分配内存的方式
      1. brk() 和 sbrk() 修改堆顶指针
      2. mmap() 文件映射区
  2. new 与 malloc 的区别:
    1. new 在申请内存的同时,会调用对象的构造函数,对象会进行初始化,malloc 仅仅在堆中申请一块指定大小的内存空间,并不会对内存和对象进行初始化。
    2. new 可以指定内存空间初始化对象,而 malloc 只能从堆中申请内存。
    3. new 是 c++ 中的一个关键字,而 malloc 是 C 中的一个函数
    4. new 的返回值为一个对象的指针类型,而 malloc 统一返回 void * 指针
    5. new 内存分配成功,返回该对象类型的指针,分配失败,抛出 bad_alloc 异常;而 malloc 成功申请到内存,返回指向该内存的指针;分配失败,返回 NULL 指针
    6. new 的空间大小由编译器会自动计算,而 malloc 则需要指定空间大小。
    7. new 作为一个运算符可以进行重载,而 malloc 作为一个C库函数不支持重载
    8. malloc 可以更改申请过的空间大小,我们可以realloc 指定空间大小,而 new一旦申请则无法更改。

delete 和 free 的区别

  1. free 的简介:

    free 释放 heap 中申请的动态内存空间,只能释放 malloc,calloc,realloc 申请的内存。需要注意的是,free 函数只是将参数指针指向的内存归还给操作系统,并不会把参数指针置 NULL,为了以后访问到被操作系统重新分配后的错误数据,所以在调用 free 之后,通常需要手动将指针置 NULL。内存资源都是由操作系统来管理的,而不是编译器,编译器只是向操作系统提出申请,所以 free 函数是没有能力去真正的 free 内存的,只是向内存管理模块归还了内存,其他模块还可以继续申请使用这些内存。free 后指针仍然指向原来的堆地址,实际还可以使用,但操作系统可能将这块内存已经分配给其他模块使用,一般建议在 free 以后将指针置为空。一个指针经过两次 free,也是比较危险的操作,因为可能该段内存已被别的内存使用申请使用了,free 之后会造成严重后果。

  2. delete 的简介:

    delete 是 C++ 中的一个操作符,如果对象存在析构函数,它首先执行该对象所属类的析构函数,进而通过调用 operator delete 的标准库函数来释放所占的内存空间。delete 用来释放单个对象所占的空间,只会调用一次析构函数;delete [] 用来释放数组空间,会对数组中的每个元素都调用一次析构函数。delete 只能用来释放 new 操作返回的指针,否则会产生不可预知的后果。在单个对象上的删除 使用 delete [] 的数组形式,以及对数组使用非数组形式的删除都会产生不可预知的后果。如果 new 的对象是指定地址,则不能直接调用 delete。

  3. delete 与 free 的区别:

    1. delete 是 C++ 中的一个操作符,可以进行重载;而 free 是 C 中的一个函数,不能进行重载;
    2. free 只会释放指向的内存,不会执行对象的析构函数;delete 则可以执行对象的析构函数;

new/delete 和new[]/delete[]的区别

new/delete只调用一次构造/析构函数,new[]/delete[] 根据数组大小,多次调用构造/析构函数,分配/释放的空间大小是整个数组的大小。
如果析构的对象里面没有指针类型的成员,直接使用delete释放new[]的对象并不会导致内存泄漏。

C 和 C++ 中 struct 的区别

  1. 在 C 语言中 struct 是用户自定义数据类型;在 C++ 中 struct 是抽象数据类型,支持成员函数的定义。C++ 中的 class 可以实现 struct 的所有功能,C++ 为了兼容 C 语言保留了 struct 关键字
  2. C 语言中 struct 没有访问权限的设置,是一些变量的集合体,不能定义成员函数;C++ 中 struct 可以和类一样,有访问权限,并可以定义成员函数。
  3. C 语言中 struct 定义的自定义数据类型,在定义该类型的变量时,需要加上 struct 关键字,例如:struct A var;,定义 A 类型的变量;而 C++ 中,不用加该关键字,例如:A var。
  4. C++ 中 struct 可以继承,也可以实现多态,而 C 语言中不支持继承和多态

struct 和 union 的区别

  1. union 是联合体,struct 是结构体。union 中的所有成员变量共享同一段内存空间,struct 中的每个成员变量独占内存空间
  2. 联合体和结构体都是由若干个数据类型不同的数据成员组成。使用时,联合体只有一个有效的成员;而结构体所有的成员都有效
  3. 对联合体的不同成员赋值,将会对覆盖其他成员的值,而对于结构体的对不同成员赋值时,相互不影响
  4. 联合体的大小为其内部所有变量的最大值,按照最大类型的倍数进行分配大小;结构体分配内存的大小遵循内存对齐原则
  5. struct 可以定义变长数组成员变量 int a[],union 中不能包含有这种不确定长度的变量

C++ 中 struct 和 class 的区别

C++ 中为了兼容 C 语言而保留了 C 语言的 struct 关键字,并且加以扩充。在 C 语言中,struct 只能包含成员变量,不能包含成员函数。而在 C++ 中,struct 类似于 class,既可以包含成员变量,又可以包含成员函数。
C++ 中的 struct 和 class 基本是通用的,唯有几个细节不同:

  1. class 中类中的成员默认访问权限都是 private 属性的;而在 struct 中结构体中的成员默认都是 public 属性的。
  2. class 默认继承权限是 private 继承,而 struct 继承默认是 public 继承。
  3. class 可以用于定义模板参数,struct 不能用于定义模板参数。

volatile 的作用与使用场景

  1. volatile 的简介:

    当对象的值可能在程序的控制或检测之外被改变时,应该将该对象声明为 volatile,告知编译器不应对这样的对象进行优化。volatile 关键字修饰变量后,提醒编译器它后面所定义的变量随时都有可能改变,因此编译后的程序每次需要存储或读取这个变量的时候,都会直接从变量地址中读取数据。如果没有 volatile 关键字,则编译器可能优化读取和存储,可能暂时使用寄存器中的值,如果这个变量由别的程序更新了的话,将出现不一致的现象。
    使用 volatile 关键字试图阻止编译器过度优化,volatile 主要作用如下:

      1. 阻止编译器为了提高速度将一个变量缓存到寄存器内而不写回;(缓存一致性协议、轻量级同步)
      2. 阻止编译器调整操作 volatile 变量的指令排序。
    
  2. volatile 的作用:

    读取变量时,阻止编译器对缓存的优化:volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改,比如:操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。比如声明时 volatile 变量,int volatile vInt; 当要求使用 volatile 声明的变量的值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据,而且读取的数据立刻被保存。

  3. volatile 的应用场景:

    在实际场景中除了操纵硬件需要用到 volatile 以外,更多的可能是多线程并发访问共享变量时,一个线程改变了变量的值,怎样让改变后的值对其它线程可见,此时我们就需要使用 volatile 进行修饰。一般说来,volatile 用在如下的几个地方:

    1. 中断服务程序中修改的供其它程序检测的变量需要加 volatile;

    2. 多任务环境下各任务间共享的标志应该加 volatile;

    3. 存储器映射的硬件寄存器通常也要加 volatile 说明,因为每次对它的读写都可能有不同意义;

返回函数中的静态变量的地址会发生什么

前面的章节中讲过,静态局部变量存在静态区,程序初始化时则已经创建了改变量,变量的生存周期为整个程序的生命周期。在函数中定义静态局部变量 var,使得离开该函数的作用域后,该变量不会销毁,返回到主函数中,该变量依然存在,从而使程序得到正确的运行结果,该静态局部变量直到程序运行结束后才销毁。
需要注意的是,全局静态对象在程序初始化时,则进行了初始化。局部静态对象的初始化在第一次进入函数内部时,才会调用对象的构造函数进行初始化。程序退出时,先释放静态局部变量,再释放全局静态变量。

extern C 的作用

C 和 C++ 对同一个函数经过编译后生成的函数名是不同的,由于 C++ 支持函数重载,因此编译器编译函数的过程中会将函数的参数类型也加到编译后的函数名中,而不仅仅是原始的函数名。
由于 C 语言并不支持函数重载,在 C 语言中函数不能重名,因此编译 C 语言代码的函数时不会带上函数的参数类型,一般只包括函数名。如果在 C++ 中调用一个使用 C 语言编写的模块中的某个函数 test,C++ 是根据 C++ 的函数名称修饰方式来查找并链接这个函数,去在生成的符号表查找 _Z4testv 这个函数的代码,此时就会发生链接错误。而此时我们用 extern C 声明,那么在链接时,C++ 编译器则按照 C 语言的函数命名规则 test 去符号表中查找对应的函数。因此当 C++ 程序需要调用 C 语言编写的函数,C++ 使用链接指示,即 extern “C” 指出任意非 C++ 函数所用的语言。

sizeof(1 == 1) 在C和C++中的结果

sizeof 接受的参数可以是对象也可以是表达式,但是 sizeof(expression) 在运行时不会对接受的表达式进行计算,编译器只会推导表达式的类型从而计算占用的字节大小;

  • 由于 C 语言没有 bool 类型,用整形表示布尔型,因此 sizeof(1 == 1) 返回 4;
  • 由于 C++ 语言有 bool 类型,布尔型占 1 个字节,因此 sizeof(1 == 1) 返回 1。

memmove 函数的底层原理

memmove 用于拷贝字节,如果目标区域和源区域有重叠的话,memmove 能够保证源串在被覆盖之前将重叠区域的字节拷贝到目标区域中,但复制后源内容会被更改。但是当目标区域与源区域没有重叠则和 memcpy 函数功能相同。面试时会经常要求实现 memmove 函数,在实现的时候需要特殊处理地址重叠的情况。

  1. memcpy实现

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    void* my_memcpy(void* dest, const void* src, size_t count)
    {
    assert(src != nullptr&&dest != nullptr);
    //判断dest指针和src指针是否为空,若为空抛出异常
    char* tmp_dest = (char*)dest;
    const char* tmp_src = (const char*)src;
    //将指针dest和指针src由void强转为char,
    //使得每次均是对内存中的一个字节进行拷贝
    while (count--)
    *tmp_dest++ = *tmp_src++;
    return dest;
    }
  2. memmove实现

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    void my_memmove(void* dest, void* src, size_t num)
    {
    void* ret = dest;
    assert(dest);
    assert(src);

    if (dest < src)//1 前->后
    {
    while(num--)
    {
    *(char*)dest = *(char*)src;
    dest = (char*)dest + 1;
    src = (char*)src + 1;
    }
    }
    else // 后->前
    {
    while (num--)
    {
    *((char*)dest + num) = *((char*)src + num);
    }
    }
    }

strcpy 函数的缺陷

  1. strcpy 函数的实现:

    strcpy 是 C++ 语言的一个标准函数 ,strcpy 把含有 ‘\0’ 结束符的字符串复制到另一个地址空间,返回值的类型为 char*,返回值为拷贝后的字符串的首地址。

  2. strcpy 函数的缺陷:

    strcpy 函数不检查目的缓冲区的大小边界,而是将源字符串逐一的全部赋值给目的字符串地址起始的一块连续的内存空间,同时加上字符串终止符,会导致其他变量被覆盖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <cstring>
using namespace std;

int main() {
int var = 0x11112222;
char arr[10];
cout << "Address : var " << &var << endl;
cout << "Address : arr " << &arr << endl;
strcpy(arr, "hello world!");
cout << "var:" << hex << var << endl; // 将变量 var 以 16 进制输出
cout << "arr:" << arr << endl;
return 0;
}

/*
Address : var 0x23fe4c
Address : arr 0x23fe42
var:11002164
arr:hello world!
*/

说明:从上述代码中可以看出,变量 var 的后六位被字符串 “hello world!” 的 “d!\0” 这三个字符改变,这三个字符对应的 ascii 码的十六进制为:\0(0x00),!(0x21),d(0x64)。
原因:变量 arr 只分配的 10 个内存空间,通过上述程序中的地址可以看出 arr 和 var 在内存中是连续存放的,但是在调用 strcpy 函数进行拷贝时,源字符串 “hello world!” 所占的内存空间为 13,因此在拷贝的过程中会占用 var 的内存空间,导致 var 的后六位被覆盖。由于 strcpy 函数存在一定的安全风险,如果使用不当容易出现安全问题,利用 strcpy 的特性可以编写 shellcode 来进行缓冲区溢出攻击。在大多数工程代码中,为了保证代码的健壮性和安全性,一般会使用 strncpy 代替 strcpy

在传递函数参数时,什么时候该使用指针,什么时候该使用引用呢

  1. 需要返回函数内局部变量的内存的时候用指针。使用指针传参需要开辟内存,用完要记得释放指针,不然会内存泄漏。而返回局部变量的引用是没有意义的
  2. 对栈空间大小比较敏感(比如递归)的时候使用引用。使用引用传递不需要创建临时变量,开销要更小;
  3. 类对象作为参数传递的时候使用引用,这是C++类对象传递的标准方式。

区别以下指针类型

1
2
3
4
int *p[10]; 	// p是一个大小为10的数组,数组中的每个元素是一个int指针
int (*p)[10]; // p是一个指针,指向大小为10的int数组
int *p(int); // p是一个函数名,参数为int,返回值为int*
int (*p)(int); // p是一个函数指针,参数为int,返回值为int

被free回收的内存是立即返还给操作系统吗

不是的,被free回收的内存会首先被ptmalloc使用双链表保存起来,当用户下一次申请内存的时候,会尝试从这些内存中寻找合适的返回。这样就避免了频繁的系统调用,占用过多的系统资源。同时ptmalloc也会尝试对小块内存进行合并,避免过多的内存碎片。

宏定义和函数有何区别

  1. 宏在预处理阶段完成替换,之后被替换的文本参与编译,相当于直接插入了代码,运行时不存在函数调用,执行起来更;函数调用在运行时需要跳转到具体调用函数。
  2. 宏定义属于在结构中插入代码,没有返回值;函数调用具有返回值。
  3. 宏定义参数没有类型,不进行类型检查;函数参数具有类型,需要检查类型。
  4. 宏定义不要在最后加分号

宏定义和typedef区别

  1. 宏主要用于定义常量及书写复杂的内容;typedef主要用于定义类型别名
  2. 宏替换发生在预处理,属于文本插入替换;typedef是编译的一部分。
  3. 宏不检查类型;typedef会检查数据类型。
  4. 宏不是语句,不在在最后加分号;typedef是语句,要加分号标识结束。
  5. 注意对指针的操作,typedef char _ p_char和#define p_char char _区别巨大。

变量声明和定义区别

  1. 声明仅仅是把变量的声明的位置及类型提供给编译器,并不分配内存空间;定义要在定义的地方为其分配存储空间
  2. 相同变量可以在多处声明(外部变量extern),但只能在一处定义。

strlen和sizeof区别

  1. sizeof是运算符,并不是函数,结果在编译时得到而非运行中获得;strlen是字符处理的库函数
  2. sizeof参数可以是任何数据的类型或者数据(sizeof参数不退化);strlen的参数只能是字符指针且结尾是’\0’的字符串
  3. 因为sizeof值在编译时确定,所以不能用来得到动态分配(运行时分配)存储空间的大小

a和&a有什么区别

假设数组int a[10]; int (*p)[10] = &a;其中:

  1. a是数组名,是数组首元素地址,+1表示地址值加上一个int类型的大小,如果a的值是0x00000001,加1操作后变为0x00000005。*(a + 1) = a[1]。
  2. &a是数组的指针,其类型为int (_)[10](_就是前面提到的数组指针),其加1**时,系统会认为是数组首地址加上整个数组的偏移(10个int型变量),值为数组a尾元素后一个元素的地址。
  3. 若(int _)p ,此时输出 _p时,其值为a[0]的值,因为被转为int *类型,解引用时按照int类型大小来读取。

面向对象

面向对象及其三大特征

  1. 面向对象:对象是指具体的某一个事物,这些事物的抽象就是类,类中包含数据(成员变量)动作(成员方法)
  2. 面向对象的三大特性:
    1. 封装:将具体的实现过程和数据封装成一个函数,只能通过接口进行访问,降低耦合性
    2. 继承:子类继承父类的特征和行为,子类有父类的非 private 方法或成员变量,子类可以对父类的方法进行重写增强了类之间的耦合性,但是当父类中的成员变量、成员函数或者类本身被 final 关键字修饰时,修饰的类不能继承,修饰的成员不能重写或修改。
    3. 多态:多态就是不同继承类的对象,对同一消息做出不同的响应,基类的指针指向或绑定到派生类的对象,使得基类指针呈现不同的表现方式。在 C++ 中多态一般是使用虚函数来实现的,使用基类指针调用函数方法时,如果该指针指向的是一个基类的对象,则调用的是基类的虚函数;如果该指针指向的是一个派生类的对象,则调用的是派生类的虚函数。

重载、重写、隐藏的区别

  1. 函数重载:
    重载是指同一可访问区内被声明几个具有不同参数列表(参数的类型、个数、顺序)的同名函数,根据参数列表确定调用哪个函数,重载不关心函数返回类型

  2. 函数隐藏:
    函数隐藏是指派生类的函数屏蔽了与其同名的基类函数,只要是与基类同名的成员函数,不管参数列表是否相同,基类函数都会被隐藏。

  3. 函数重写(覆盖):

    函数覆盖是指派生类中存在重新定义的函数。函数名、参数列表、返回值类型都必须同基类中被重写的函数一致,只有函数体不同。派生类调用时会调用派生类的重写函数,不会调用被重写函数。重写的基类中被重写的函数必须有 virtual 修饰。

  4. 重写和重载的区别:

    1. 范围区别:对于类中函数的重载或者重写而言,重载发生在同一个类的内部,重写发生在不同的类之间(子类和父类之间)。
    2. 参数区别:重载的函数需要与原函数有相同的函数名、不同的参数列表,不关注函数的返回值类型;重写的函数的函数名、参数列表和返回值类型都需要和原函数相同,父类中被重写的函数需要有 virtual 修饰
    3. virtual 关键字:重写的函数基类中必须有 virtual 关键字的修饰,重载的函数可以有 virtual 关键字的修饰也可以没有。
  5. 隐藏和重写,重载的区别:

    1. 范围区别:隐藏与重载范围不同,隐藏发生在不同类中。
    2. 参数区别:隐藏函数和被隐藏函数参数列表可以相同,也可以不同,但函数名一定相同;当参数不同时,无论基类中的函数是否被 virtual 修饰,基类函数都是被隐藏,而不是重写。
    3. 利用重写可以实现多态,而隐藏不可以。如果使用基类指针 p 指向派生类对象,利用这个指针调用函数时,对于隐藏的函数,会根据指针的类型去调用函数(静态绑定);对于重写的函数,会根据指针所指对象的类型去调用函数(动态绑定)。重写必须使用 virtual 关键字,此时会更改派生类虚函数表的表项。
    4. 隐藏是发生在编译时,即在编译时由编译器实现隐藏,而重写一般发生运行时,即运行时会查找类的虚函数表,决定调用函数接口。

多态及其实现方法

多态性(polymorphism)可以简单地概括为“一个接口,多种方法”,它是面向对象编程领域的核心概念。
多态性指相同对象收到不同消息或不同对象收到相同消息时产生不同的实现动作。C++支持两种多态性:编译时多态性,运行时多态性。

  1. 编译时多态性(静态多态):通过重载函数、类模板实现:先期联编 early binding
  2. 运行时多态性(动态多态):通过虚函数实现 :滞后联编 late binding

动态多态的实现原理:
动态多态是通过虚函数实现的,虚函数的地址保存在虚函数表中,虚函数表的地址保存在含有虚函数的类的实例对象的内存空间中。

  1. 在类中用 virtual 关键字声明的函数叫做虚函数;
  2. 存在虚函数的类都有一个虚函数表,当创建一个该类的对象时,该对象有一个指向虚函数表的虚表指针(虚函数表和类对应的,虚表指针是和对象对应);
  3. 当基类指针指向派生类对象,基类指针调用虚函数时,该基类指针指的虚表指针实际指向派生类虚函数表,通过遍历虚表,寻找相应的虚函数然后调用执行。

限制对象创建在堆或栈上

  1. 限制对象只能建立在堆上:

    1. 最直观的思想:避免直接调用类的构造函数,因为对象静态建立时,会调用类的构造函数创建对象。直接将类的构造函数设为私有,并提供另外的接口给外部调用。
    2. 将析构函数设置为私有。原因:静态对象建立在栈上,是由编译器分配和释放内存空间,编译器为对象分配内存空间时,会对类的非静态函数进行检查,即编译器会检查析构函数的访问性。当析构函数设为私有时,编译器创建的对象就无法通过访问析构函数来释放对象的内存空间,因此,编译器不会在栈上为对象分配内存。
    3. 构造函数设置为 protected,并提供一个 public 的静态函数来完成构造,而不是在类的外部使用 new 构造;将析构函数设置为 protected。原因:类似于单例模式,也保证了在派生类中能够访问析构函数。通过调用 create() 函数在堆上创建对象。
  2. 限制对象只能建立在栈上:

    将 operator new() 设置为私有。原因:当对象建立在堆上时,是采用 new 的方式进行建立,其底层会调用 operator new() 函数,因此只要对该函数加以限制,就能够防止对象建立在堆上。

C++ 模板编程

模板是 C++ 编程语言的一个特性,它允许函数和类使用泛型类型进行操作。这允许一个函数或类在许多不同的数据类型上工作,而无需为每个类型重写。C++ 模板是泛型编程的基础,泛型编程即以一种独立于任何特定类型的方式编写代码,C++ 中使用 template 关键字。模板是创建泛型类或函数的蓝图或公式。库容器,比如迭代器和算法,都是泛型编程的例子,它们都使用了模板的概念。
共有三种模板:函数模板、类模板以及自 C++ 14 以来的变量模板:

  1. 函数模板:
    函数模板的行为类似于函数,只是模板可以有许多不同类型的参数。一个函数模板代表一个函数族。使用类型参数声明函数模板的格式是:

  2. 类模板:

    类模板提供了基于参数生成类的规范。类模板通常用于实现容器。类模板通过将一组给定的类型作为模板参数传递给它来实例化。C++ 标准库包含许多类模板,特别是改编自标准模板库的容器,例如 vector,list。

  3. 变量模板:
    在 C++14 以后,变量也可以参数化为特定的类型,这称为变量模板。

虚函数和纯虚函数

  1. 虚函数:
    被 virtual 关键字修饰的成员函数,C++ 的虚函数在运行时动态绑定,从而实现多态。
  2. 纯虚函数:
    1. 纯虚函数在类中声明时,用 virtual 关键字修饰且加上 =0,且没有函数的具体实现;
    2. 含有纯虚函数的类称为抽象类(只要含有纯虚函数这个类就是抽象类),类中只有接口定义,没有具体的实现方法;
    3. 继承纯虚函数的派生类,如果没有完全实现基类纯虚函数,依然是抽象类,不能实例化对象

对于抽象类需要说明的是:

  1. 抽象类对象不能作为函数的参数,不能创建对象,不能作为函数返回类型
  2. 可以声明抽象类指针,可以声明抽象类的引用
  3. 抽象类只能作为基类来使用,其纯虚函数的实现由派生类给出。如果派生类中没有重新定义纯虚函数,而只是继承基类的纯虚函数,则这个派生类仍然还是一个抽象类。如果派生类中给出了基类纯虚函数的实现,则该派生类就不再是抽象类了,它是一个可以建立对象的具体的类。

纯虚函数的作用:含有纯虚函数的基类要求任何派生类都要定义自己的实现方法,以实现多态性。实现了纯虚函数的子类,该纯虚函数在子类中就变成了虚函数。定义纯虚函数是为了实现统一的接口属性,用来规范派生类的接口属性,也即强制要求继承这个类的程序员必须实现这个函数。纯虚函数的意义在于,让所有的类对象(主要是派生类对象)都可以要求实现纯虚函数的属性,在面对对象设计中非常有用的一个特性。

虚函数和纯虚函数的区别

  1. 虚函数和纯虚函数可以出现在同一个类中,该类称为抽象基类(含有纯虚函数的类称为抽象基类)。
  2. 使用方式不同:虚函数可以直接使用,纯虚函数必须在派生类中实现后才能使用;
  3. 定义形式不同:虚函数在定义时在普通函数的基础上加上 virtual 关键字,纯虚函数定义时除了加上 virtual 关键字还需要加上 =0;
  4. 虚函数必须实现,否则编译器会报错;
  5. 对于实现纯虚函数的派生类,该纯虚函数在派生类中被称为虚函数,虚函数和纯虚函数都可以在派生类中重写;
  6. 析构函数最好定义为虚函数,特别是对于含有继承关系的类;析构函数可以定义为纯虚函数,但纯虚析构函数必须有定义体,因为析构函数的调用是在子类中隐含的。

构造函数与析构函数是否可以定义为虚函数

  1. 构造函数一般不定义为虚函数:

    1. 从存储空间的角度考虑:构造函数是在实例化对象的时候进行调用,如果此时将构造函数定义成虚函数,需要通过访问该对象所在的内存空间才能进行虚函数的调用(因为需要通过指向虚函数表的指针调用虚函数表,虽然虚函数表在编译时就有了,但是没有虚函数的指针,虚函数的指针只有在创建了对象才有),但是此时该对象还未创建,便无法进行虚函数的调用。所以构造函数不能定义成虚函数。
    2. 从使用的角度考虑:虚函数是基类的指针指向派生类的对象时,通过该指针实现对派生类的虚函数的调用,构造函数是在创建对象时自动调用的。
    3. 从实现上考虑:虚函数表是在创建对象之后才有的,因此不能定义成虚函数。
    4. 从类型上考虑:在创建对象时需要明确其类型。
  2. 析构函数一般定义成虚函数:

    析构函数定义成虚函数是为了防止内存泄漏,因为当基类的指针或者引用指向或绑定到派生类的对象时,如果未将基类的析构函数定义成虚函数,会调用基类的析构函数,那么只能将基类的成员所占的空间释放掉,派生类中特有的就会无法释放内存空间导致内存泄漏。

空类

  1. 空类的大小

    由于在实际程序中,空类同样可以被实例化,而每个实例在内存中都有一个独一无二的地址,为了达到这个目的,编译器往往会给一个空类隐含的加一个字节,这样空类在实例化后在内存得到了独一无二的地址,所以 sizeof(A) 的大小为 1。

  2. 空类的成员函数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class A
    {
    public:
    A(){}; // 缺省构造函数
    A(const A &tmp){}; // 拷贝构造函数
    ~A(){}; // 析构函数
    A &operator=(const A &tmp){}; // 赋值运算符
    A *operator&() { return this; }; // 取址运算符
    const A *operator&() const { return this; }; // 取址运算符(const 版本)
    };

类的大小

说明:类的大小是指类的实例化对象的大小,用 sizeof 对类型名操作时,结果是该类型的对象的大小。计算原则如下:

  1. 遵循结构体的成员变量对齐原则。
  2. 普通成员变量有关,与成员函数和静态成员无关。即普通成员函数,静态成员函数,静态数据成员,静态常量数据成员均对类的大小无影响。因为静态数据成员被类的对象共享,并不属于哪个具体的对象。
  3. 虚函数对类的大小有影响,是因为虚函数表指针的影响。
  4. 虚继承对类的大小有影响,是因为虚基表指针带来的影响。
  5. 空类的大小是一个特殊情况,空类的大小为 1,空类同样可以被实例化,而每个实例在内存中都有一个独一无二的地址,为了达到这个目的,编译器往往会给一个空类隐含的加一个字节,这样空类在实例化后在内存得到了独一无二的地址,所以sizeof(A) 的大小为 1。

为什么拷贝构造必须声明为引用

避免拷贝构造函数无限制的递归而导致栈溢出。

成员初始化列表效率高的原因

对象的成员函数数据类型可分为语言内置类型和用户自定义类,对于用户自定义类型,利用成员初始化列表效率高。用户自定义类型如果使用类初始化列表,直接调用该成员变量对应的构造函数即完成初始化;如果在构造函数中初始化,由于 C++ 规定对象的成员变量的初始化动作发生在进入自身的构造函数本体之前,那么在执行构造函数之前首先调用默认的构造函数为成员变量设初值,在进入函数体之后,再显式调用该成员变量对应的构造函数。因此使用列表初始化会减少调用默认的构造函数的过程,效率更高一些。

如何让类不被继承

  1. final
  2. 使用友元、虚继承和私有构造函数来实现

具体原因:

  1. 虽然 Base 类构造函数和析构函数被声明为私有 private,在 B 类中,由于 B 是 Base 的友元,因此可以访问 Base 类构造函数,从而正常创建 B 类的对象;
  2. B 类继承 Base 类采用虚继承的方式,创建 C 类的对象时,C 类的构造函数要负责 Base 类的构造,但是 Base 类的构造函数私有化了,C 类没有权限访问。因此,无法创建 C 类的对象, B 类是不能被继承的类。

注意:在继承体系中,友元关系不能被继承,虽然 C 类继承了 B 类,B 类是 Base 类的友元,但是 C 类和 Base 类没有友元关系。

语言特性相关

左值与右值:区别、引用及转化

  1. 左值与右值:

    1. 左值:指表达式结束后依然存在的持久对象。可以取地址,可以通过内置(不包含重载) & 来获取地址,我们可以将一个右值赋给左值。
    2. 右值:表达式结束就不再存在的临时对象。不可取地址,不可以通过内置(不包含重载) & 来获取地址。由于右值不可取地址,因此我们不能将任何值赋给右值。
  2. 左值引用与右值引用

    1. 左值引用
      1. 可以区分为常量左值引用和非常量左值引用。左值引用的底层实现是指针实现
      2. 非常量左值引用只能绑定到非常量左值,不能绑定到常量左值和右值。如果绑定到非常量右值,就有可能指向一个已经被销毁的对象。
      3. 常量左值引用能绑定到非常量左值,常量左值和右值;
    2. 右值引用:
      1. 右值引用 (Rvalue Referene) 是 C++ 11 中引入的新特性 , 它实现了转移语义 (Move Sementics)完美转发 (Perfect Forwarding),&& 作为右值引用的声明符。右值引用必须绑定到右值的引用,通过 && 获得。右值引用只能绑定到一个将要销毁的对象上,因此可以自由地移动其资源
      2. 从实践角度讲,它能够完美解决 C++ 中长久以来为人所诟病的临时对象效率问题。从语言本身讲,它健全了 C++ 中的引用类型在左值右值方面的缺陷。从库设计者的角度讲,它给库设计者又带来了一把利器。从使用者的角度来看,可以获得效率的提升,避免对象在传递过程中重复创建。
      3. 右值引用两个主要功能:
        1. 消除两个对象交互时不必要的对象拷贝,节省运算存储资源,提高效率。
        2. 能够更简洁明确地定义泛型函数。
  3. 左值转为右值

    我们可以通过 std::move 可以将一个左值强制转化为右值,继而可以通过右值引用使用该值,以用于移动语义,从而完成将资源的所有权进行转移

  4. 引用折叠

    1. 所有的右值引用叠加到右值引用上仍然还是一个右值引用;T&& && 折叠成 T&&
    2. 所有的其他引用类型之间的叠加都将变成左值引用。T& &&,T&& &, T&& 折叠成 T&。
  5. 万能引用类型:

    在模板中 T&& t 在发生自动类型推断的时候,它是未定的引用类型(universal references),它既可以接受一个左值又可以接受一个右值。如果被一个左值初始化,它就是一个左值;如果它被一个右值初始化,它就是一个右值,它是左值还是右值取决于它的初始化。

move() 的实现原理

  1. 首先利用万能模板将传入的参数 t 进行处理,我们知道右值经过 T&& 传递类型保持不变还是右值,而左值经过 T&& 变为普通的左值引用,以保证模板可以传递任意实参,且保持类型不变;对参数 t 做一次右值引用,根据引用折叠规则,右值的右值引用是右值引用,而左值的右值引用是普通的左值引用。万能模板既可以接受左值作为实参也可以接受右值作为实参。
  2. 通过 remove_refrence 移除引用,得到参数 t 具体的类型 type;
  3. 最后通过 static_cast<> 进行强制类型转换,返回 type && 右值引用。

remove_reference 主要作用是解除类型中引用并返回变量的实际类型。

完美转发的实现

forward 保证了在转发时左值右值特性不会被更改,实现完美转发。主要解决引用函数参数为右值时,传进来之后有了变量名就变成了左值

forward 的实现:
forward 利用引用折叠的特性,对参数 t 做一次右值引用,根据引用折叠规则,右值的右值引用是右值引用,而左值的右值引用是普通的左值引用。forward 的实现有两个函数:
第一个,接受的参数是左值引用,只能接受左值。
第二个,接受的参数是右值引用,只能接受右值。

forward 与 move 最大的区别是,move 在进行类型转换时,利用 remove_reference 将外层的引用全部去掉,这样可以将 t 强制转换为指定类型的右值引用,而 forward 则利用引用折叠的技巧,巧妙的保留了变量原有的属性。

悬空指针和野指针

  1. 悬空指针

    若指针指向一块内存空间,当这块内存空间被释放后,该指针依然指向这块内存空间,此时,称该指针为“悬空指针”。如果对悬空指针再次释放可能会出现不可预估的错误,比如可能该段内存被别的程序申请使用了,而此时对该段内存进行释放可能会产生不可预估的后果。

  2. 野指针

    野指针是指不确定其指向的指针,未初始化的指针为“野指针”,未初始化的指针的初始值可能是随机的,如果使用未初始化的指针可能会导致段错误,从而程序会崩溃。

  3. 如何避免野指针:
    指针在定义时即初始化,指针在释放完成后,需要将其置为空。

nullptr 和 NULL 的区别

  1. NULL:预处理变量,是一个,它的值是 0,定义在头文件中,即 #define NULL 0。
  2. nullptr:C++ 11 中的关键字,是一种特殊类型的字面值,可以被转换成任意其他类型。

nullptr 的优势:

  1. 类型,类型是 typdef decltype(nullptr) nullptr_t;,使用 nullptr 提高代码的健壮性
  2. 函数重载:因为 NULL 本质上是 0,在函数调用过程中,若出现函数重载并且传递的实参是 NULL,可能会出现不知和哪一个函数匹配的情况;但是传递实参 nullptr 就不会出现这种情况。

指针和引用的区别

  1. 指针是一个变量,存储的是一个地址,引用跟原来的变量实质上是同一个东西,是原变量的别名;
  2. 指针可以有多级,引用只有一级;
  3. 指针可以为空,引用不能为NULL且在定义时必须初始化;
  4. 指针在初始化后可以改变指向,而引用在初始化之后不可再改变;
  5. sizeof 指针得到的是本指针的大小,sizeof 引用得到的是引用所指向变量的大小;
  6. 当把指针作为参数进行传递时,也是将实参的一个拷贝传递给形参,两者指向的地址相同,但不是同一个变量,在函数中改变这个变量的指向不影响实参,而引用却可以

C++的四种强制转换类型

  1. static_cast:

    static_cast 是“静态转换”的意思,也即在编译期间转换,转换失败的话会抛出一个编译错误。一般用于如下:

    1. 用于数据的强制类型转换,强制将一种数据类型转换为另一种数据类型。
    2. 用于基本数据类型的转换。
    3. 用于类层次之间的基类和派生类之间指针或者引用的转换(不要求必须包含虚函数,但必须是有相互联系的类),进行上行转换(派生类的指针或引用转换成基类表示)是安全的;进行下行转换(基类的指针或引用转换成派生类表示)由于没有动态类型检查,所以是不安全的,最好用 dynamic_cast 进行下行转换。
    4. 可以将空指针转化成目标类型的空指针。
    5. 可以将任何类型的表达式转化成 void 类型。
    6. 不能用于在不同类型的指针之间互相转换,也不能用于整型和指针之间的互相转换,当然也不能用于不同类型的引用之间的转换。
  2. const_cast:

    主要用于 const 与非 const、volatile 与非 volatile 之间的转换。强制去掉常量属性,不能用于去掉变量的常量性,只能用于去除指针或引用的常量性,将常量指针转化为非常量指针或者将常量引用转化为非常量引用(注意:表达式的类型和要转化的类型是相同的)。

  3. reinterpret_cast:

    改变指针或引用的类型、将指针或引用转换为一个足够长度的整型、将整型转化为指针或引用类型。reinterpret_cast 转换时,执行的过程是逐个比特复制的操作。

  4. dynamic_cast:

    1. 其他三种都是编译时完成的,动态类型转换是在程序运行时处理的,运行时会进行类型检查。
    2. 只能用于带有虚函数的基类或派生类的指针或者引用对象的转换,转换成功返回指向类型的指针或引用,转换失败返回 NULL;不能用于基本数据类型的转换。
    3. 在向上进行转换时,即派生类的指针转换成基类的指针和 static_cast 效果是一样的,(注意:这里只是改变了指针的类型,指针指向的对象的类型并未发生改变)。

结构体相等的判断方式及 memcmp 函数的使用

符号重载:
需要重载操作符 == 判断两个结构体是否相等,不能用函数 memcmp 来判断两个结构体是否相等,因为 memcmp 函数是逐个字节进行比较的,而结构体存在内存空间中保存时存在字节对齐,字节对齐时补的字节内容是随机的,会产生垃圾值,所以无法比较。

函数模板与类模板的区别

  1. 实例化方式不同:函数模板实例化由编译程序在处理函数调用时自动完成,类模板实例化需要在程序中显式指定。
  2. 实例化的结果不同:函数模板实例化后是一个函数,类模板实例化后是一个类。
  3. 默认参数:函数模板不允许有默认参数,类模板在模板参数列表中可以有默认参数。
  4. 特化:函数模板只能全特化;而类模板可以全特化,也可以偏特化。
  5. 调用方式不同:函数模板可以进行类型推导,可以隐式调用,也可以显式调用;类模板只能显式调用。

模板特化

所谓特化,就是将泛型的东西搞得具体化一些,从字面上来解释,就是为已有的模板参数进行一些使其特殊化的指定,使得以前不受任何约束的模板参数,或受到特定的修饰(例如const或者摇身一变成为了指针之类的东东,甚至是经过别的模板类包装之后的模板类型)或完全被指定了下来。

  1. 模板特化的原因:因为编译器认为,对于特定的类型,如果你能对某一功能更好的实现,那么就该听你的。
  2. 模板特化:模板参数在某种特定类型下的具体实现。分为函数模板特化和类模板特化
    1. 函数模板特化:将函数模板中的全部类型进行特例化,称为函数模板特化。
    2. 类模板特化:将类模板中的部分全部类型进行特例化,称为类模板特化。

特化分为全特化和偏特化:

  1. 全特化:模板中的模板参数全部特例化。
  2. 偏特化:模板中的模板参数只确定了一部分,剩余部分需要在编译器编译时确定。

说明:要区分下函数重载与函数模板特化
定义函数模板的特化版本,本质上是接管了编译器的工作,为原函数模板定义了一个特殊实例,而不是函数重载,函数模板特化并不影响函数匹配

switch 的 case 里为何不建议定义变量

switch 下面的这个花括号表示一块作用域,而不是每一个 case 表示一块作用域。如果在某一 case 中定义了变量,其作用域在这块花括号内,按理说在另一个 case 内可以使用该变量,但是在实际使用时,每一个 case 之间互不影响,是相对封闭的。

迭代器的作用

迭代器:一种抽象的设计概念,在设计模式中有迭代器模式,即提供一种方法,使之能够依序寻访某个容器所含的各个元素,而无需暴露该容器的内部表述方式。迭代器只是一种概念上的抽象,具有迭代器通用功能和方法的对象都可以叫做迭代器。迭代器有很多不同的能力,可以把抽象容器和通用算法有机的统一起来。迭代器基本分为五种,输入输出迭代器,前向逆向迭代器,双向迭代器和随机迭代器。

  1. 输入迭代器(Input Iterator):只能向前单步迭代元素,不允许修改由该迭代器所引用的元素;
  2. 输出迭代器(Output Iterator):只能向前单步迭代元素,对由该迭代器所引用的元素只有写权限
  3. 向前迭代器(Forward Iterator):该迭代器可以在一个区间中进行读写操作,它拥有输入迭代器的所有特性和输出迭代器的部分特性,以及向前单步迭代元素的能力;
  4. 双向迭代器(Bidirectional Iterator):在向前迭代器的基础上增加了向后单步迭代元素的能力;
  5. 随机访问迭代器(Random Access Iterator):不仅综合以后 4 种迭代器的所有功能,还可以像指针那样进行算术计算;

image.png

泛型编程如何实现

泛型编程实现的基础:模板。模板是创建类或者函数的蓝图或者说公式,当时用一个 vector 这样的泛型,或者 find 这样的泛型函数时,编译时会实例化为特定的类或者函数。
泛型编程涉及到的知识点较广,例如:容器、迭代器、算法等都是泛型编程的实现实例。面试者可选择自己掌握比较扎实的一方面进行展开。

  1. 容器:涉及到 STL 中的容器,例如:vector、list、map 等,可选其中熟悉底层原理的容器进行展开讲解。
  2. 迭代器:在无需知道容器底层原理的情况下,遍历容器中的元素。
  3. 模板:可参考本章节中的模板相关问题。

泛型编程优缺点:

  1. 通用性强:泛型算法是建立在语法一致性上,运用到的类型集是无限的/非绑定的。
  2. 效率高:编译期能确定静态类型信息,其效率与针对某特定数据类型而设计的算法相同。
  3. 类型检查严:静态类型信息被完整的保存在了编译期,在编译时可以发现更多潜在的错误。
  4. 二进制复用性差:泛型算法是建立在语法一致性上,语法是代码层面的,语法上的约定无法体现在机器指令中。泛型算法实现的库,其源代码基本上是必须公开的,引用泛型中库都需要重新编译生成新的机器指令(实例化)。而传统的 C 库全是以二进制目标文件形式发布的,需要使用这些库时直接动态链接加载使用即可,不需要进行再次编译。

什么是类型萃取

类型萃取(type traits)使用模板技术来萃取类型(包含自定义类型和内置类型)的某些特性,用以判断该类型是否含有某些特性,从而在泛型算法中来对该类型进行特殊的处理用来提高效率或者得到其他优化。简单的来说类型萃取即确定变量去除引用修饰以后的真正的变量类型或者 CV 属性。C++ 关于 type traits 的详细使用技巧可以参考头文件 #include

为什么需要 type traits:
对于普通的变量来说,确定变量的类型比较容易,比如 int a = 10; 可以很容易确定变量的实际类型为 int,但在使用模板时确定变量的类型就比较困难,模板传入的类型为不确定性。为什么需要确定变量的实际类型?因为模板函数针对传入的对不同的类型可能作出不同的处理,这就需要我们在处理函数模板对传入的参数类型和特性进行提取。比如自定义拷贝函数 copy(T *dest, const T *src) ,如果 T 此时为 int 类型,则此时我们只需要 *dest = *src 即可,但是如果我们此时传入的 T 为 char * 字符串类型时,则就不能简单进行指针赋值,所以函数在实际处理时则需要对传入的类型进行甄别,从而针对不同的类型给予不同的处理,这样才能使得函数具有通用性。

C++标准模板库中大量使用了traits。将因为模板形参(包括类型形参、非类型形参)不同而导致的不同抽取到新的模板(即traits)中去;然后通过traits的模板特化来实现针对具体情况的优化实现。Traits作为模板类,既声明了统一的接口(包括类型、枚举、函数方法等),又可以通过模板特化,针对不同数据类型或其他模板参数,为类、函数或者通用算法在因为使用的数据类型不同而导致处理逻辑不同时,提供了区分不同类型的具体细节,从而把这部分用Traits实现的功能与其它共同的功能区分开来。例如,容器的元素的不同数据类型,或者iostream是使用char还是wchar_t。一个traits包括了enum、typedef、模板偏特化(template partial specialization)

  1. enum定义了各种类的标识的统一表示;
  2. typedef定义了各个类的各自不同的类型定义,这对于使用模板元编程(template meta-programming)的灵活性非常重要;
  3. 模板偏特化用于实现各个类的不同功能

traits技法利用“内嵌型别”的编程技巧与编译器的template参数推导功能,增强C++未能提供的关于型别认证方面的能力。常用的有iterator_traits和type_traits。
iterator_traits被称为特性萃取机,能够方便的让外界获取以下5种型别:

  1. value_type:迭代器所指对象的型别
  2. difference_type:两个迭代器之间的距离
  3. pointer:迭代器所指向的型别
  4. reference:迭代器所引用的型别
  5. iterator_category:三两句说不清楚,建议看书

八股文之C++基础语法
https://ww1820.github.io/posts/4abcf3b2/
作者
AWei
发布于
2022年10月9日
更新于
2022年10月16日
许可协议