语法八股-03

知识列表

  1. 内存泄漏
  2. 对象复用的了解,零拷贝
  3. 面向对象的三大特性
  4. 成员初始化列表
  5. 四个强制类型转换
  6. c++函数调用的压栈过程
  7. 对于coredump文件应该怎么调试
  8. 移动构造函数
  9. C++将临时变量作为返回值时的处理过程
  10. 引用为什么能实现多态
  11. 浮点数怎么判断相等
  12. 方法调用的原理
  13. C++中的指针参数传递和引用参数传递有什么区别?底层原理是什么
  14. C++的组合
  15. 函数指针
  16. 函数调用过程栈的变化,返回值和参数变量哪个先入栈
  17. printf函数的实现原理
  18. 为什么模版类一般放在一个.h文件中
  19. 声明和定义的区别
  20. 程序在执行int main(int argc , char* argv[])的内存结构

1.内存泄漏

内存泄漏是堆内存的泄漏,对于使用malloc,realloc或new申请堆内存未释放

避免内存泄漏方法:

基类析构函数设置为虚函数,数组的释放要delete[],使用智能指针

使用VLD

2.对象复用的了解,零拷贝

对象复用

对象复用是指在程序中重用已经创建对象,而不是每次都创建新的对象。

实现方式:

  1. 对象池,创建一个对象池,预先分配一定数量的对象,当需要使用对象时,从池中获取,使用完后再放回池中,这样可以避免频繁的内存分配和释放
  2. 缓存机制:在某些环境下,可以使用缓存机制来存储最近使用的对象,以便快速复用
零拷贝

零拷贝是一种数据传输技术,旨在减少数据在内存中的复制次数。它允许数据在不同的内存区域之间直接传输,而不需要进行中间复制

实现方式:

  1. 内存映射:使用内存映射文件(mmap)将文件直接映射到进程的地址空间,允许直接访问文件内容,而无需显示的读取和写入
  2. 直接I/O:在某些操作系统中,允许应用程序直接与硬件进行交互,跳过内核缓冲区,从而减少数据复制
  3. 使用特定API:一些网络协议(如TCP)和库(如sendfile)提供了零拷贝的支持,允许在文件描述符之间直接传输数据

零拷贝的技术体现在使用push_back函数需要调用拷贝构造函数和移动构造函数,而使用emplace_back插入的元素原地构造,不需要触发拷贝构造函数和转移构造

3.面向对象的三大特性

  1. 继承,从某种类型对象获得另一个类型对象的属性和方法
  2. 封装,把客观事物封装成抽象的类,并且类可以把自己的数据和方法只让可信的类或者对象操作
  3. 多态:静态多态和动态多态,静态多态:重载和模板函数,动态多态:虚函数,基类的指针或引用指向子类,从而调用子类重写的虚函数

4.成员初始化列表更高效

  1. 直接初始化,在成员初始化列表中,成员变量在构造函数执行之前就被直接初始化,意味着成员变量会在分配内存时直接构造而不是先构造一个默认值如果在构造函数体内进行赋值,首先会调用成员函数变量的默认构造函数,然后再调用赋值构造函数,这样会多一次构造函数和一次赋值的开销
  2. 避免临时变量,在成员初始化列表中,如果有类成员会先调用默认构造函数,然后再调用operator = ,多一次构造和赋值的开销
  3. 编译器优化,堆成员初始化列表进行更多的优化,例如内联,消除冗余等

5.四个强制类型转换

reinterpret_cast<>()

低级别的类型转换,用于在不同类型之间进行转换,通常用于指针或引用类型之间

并且reinterpret_cast可能导致未定义行为,因为它不进行类型检查,可能出现将指针转变为整数,并且reinterpret_cast知识简单的重新解释位模式

const_cast<>()

用于添加或移出对象的const或volatile属性

const_cast仅用于修改对象的常量属性,不会修改对象的值,可以将const指针转化为非const指针

static_cast<>()

static_cast是一种类型安全的转换,主要用于在相关类型之间进行转换,比如基本类型之间,类层次结构中的上行或下行转换

static_cast在编译时进行类型检查,确保转换的安全性,可以在整数,浮点数,指针类型之间进行转换,可以用来进行基类和派生类之间的转换,但不能用于不安全的转换

class Base {};
class Derived : public Base {};

Derived d;
Base* b = static_cast<Base*>(&d); // 上行转换,安全
Derived* d2 = static_cast<Derived*>(b); // 下行转换,安全,但要确保 b 实际上指向 Derived
dynamic_cast<>()

dynamic_cast用于在类层次结构中进行安全的下行转换(从基类到派生类),并且需要使用RTTI(运行是类型信息)

dynamic_cast安全性最高,在运行是检查对象的实际类型,如果转换不安全会返回nullptr(对于指针)或抛出std::bad_cast(对于引用);仅适用于包含虚函数的类

6.c++函数调用的压栈过程

当函数从入口函数main函数开始执行时,编译器会将我们操作系统的运行状态,main函数的返回地址,main的参数,main函数中的变量一次压入栈

当main开始调用func函数时,编译器会将main函数的运行状态压栈,再将func函数的返回地址,函数的参数从右到左,func定义变量依次压栈

7.对于coredump文件应该怎么调试

coredump文件是程序崩溃,操作系统生成的内存快照,包含了程序的堆栈,寄存器状态,内存内容等信息

ulimit -c unlimited

gdb <程序名> <core dump 文件>

查看崩溃时的堆栈信息:使用 bt (backtrace) 命令查看函数调用栈:(gdb) bt

查看特定帧的信息:使用 frame <frame_number> 命令查看特定帧的局部变量和状态:(gdb) frame 2

查看变量的值:使用 print <variable> 命令查看变量的值:(gdb) print my_variable

检查寄存器状态:使用 info registers 命令查看寄存器状态:(gdb) info registers

检查源代码:如果程序是用调试信息编译的(使用 -g 选项),可以查看源代码:(gdb) list

8.移动构造函数

在移动构造函数,浅拷贝是无所谓的

移动构造函数是用于实现资源的移动而不是赋值,从而提高程序的性能,特别是处理大型对象或资源管理类时

移动构造函数的主要作用是将一个对象的资源(如动态分配的内存、文件句柄等)“移动”到另一个对象,而不是进行深拷贝

移动构造函数接受一个右值引用(MyClass&&),表示将要被移动的对象。

noexcept:通常建议将移动构造函数声明为 noexcept,以便在使用标准库容器(如 std::vector)时提高性能,因为容器在重新分配内存时会使用移动构造函数。

移动构造函数的实现通常涉及将源对象的资源转移到目标对象,并将源对象的资源指针置为 nullptr 或其他无效状态,以避免在源对象被销毁时释放资源。

9.C++将临时变量作为返回值时的处理过程

临时变量在函数调用过程中是被压入程序进程的栈中,当函数退出时,临时变量出栈,即临时变量已经被销毁,临时变量占用的内存空间没有被清空,但是可以被分配给其他变量,所以有可能在函数退出时,该内存已经被修改了,对于临时变量来说已经没有意义的值了

C语言固定,16bit程序中,返回值保存在ax寄存器中,32bit程序中,返回值保存在eax寄存器中,如果是64bit程序中,edx寄存器保存高32bit,eax寄存器保存低32bit

由此可见,参数调用结束后,返回值被临时存储到寄存器,并没有放到对或栈中,也就是与内存没有关系了,当退出函数的时候,临时变量可能被销毁,但是返回值却被放到寄存器中与临时变量的生命周期没有关系

10.引用为什么能实现多态

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

11.浮点数怎么判断相等

不能使用==,要相减并与预先设定的精度比较

12.方法调用的原理(栈,汇编)

机器用栈来传递过程参数,存储返回信息,保存寄存器用于以后恢复,以及本地存储。而为单个过程分配的那部分栈成为帧栈,帧栈可以认为是程序栈的一段,帧栈有两个端点,一个标识起始地址,一个标识着结束地址,两个指针:结束地址指针esp,开始地址指针ebp

由一系列栈帧构成,这些栈帧对应一个过程,而且每一个栈指针+4的位置存储函数返回地址;每个栈帧都建立在调用者的下方,当被调用者执行完毕时,这一段栈帧会被释放。由于栈帧是向地址递减的方向延伸,因此我们如果将栈指针减去一定的值,就相当于给栈帧分配了一定空间的内存。如果将栈指针加上一定的值,也就是向上移动,那么就相当于压缩了栈帧的长度,也就是说内存被释放了

过程实现
  1. 备份原来的帧指针,调整当前的栈帧指针的栈指针位置
  2. 建立起来的栈帧就是为被调用者准备的,当被调用者使用栈帧时,需要给临时变量分配内存
  3. 使用建立好的栈帧,比如读取和写入,一般使用movpushpop指令
  4. 恢复被调用者寄存器当中的值,这一过程起始就是从栈帧中将备份的值再恢复到寄存器,不过此时这些值可能已经不在栈顶了
  5. 恢复被调用者寄存器当中的值,这一过程其实是从栈帧备份的值再恢复到寄存器,不过此时这些值可能已经不在栈顶了
  6. 释放被调用者的栈帧,释放就意味着将栈指针加大,而具体的做法一般是直接将栈指针指向帧指针
  7. 恢复调用者的栈帧,恢复其实就是调整栈帧两端,使得当前栈帧的区域又回到了原始的位置
  8. 弹出返回地址,跳出当前过程,继续指向调用者代码

13.C++中的指针参数传递和引用参数传递有什么区别?底层原理是什么

指针参数传递
特点
  • 语法:使用指针类型作为参数。
  • 可以为空:指针可以指向 nullptr,表示没有指向任何有效对象。
  • 需要解引用:在函数内部,需要使用解引用操作符(*)来访问指针指向的对象。
  • 显式传递:调用函数时需要显式传递指针。
引用参数传递
特点
  • 语法:使用引用类型作为参数,语法上更像普通变量。
  • 不能为零:引用必须始终指向一个有效对象,不能为 nullptr
  • 不需要解引用:在函数内部,可以直接使用引用名来访问对象,无需解引用。
  • 隐式传递:在调用函数时,引用的传递显得更加自然,不需要显式地使用地址运算符。

底层原理

从编译的角度来讲,程序在编译时分别将指针和引用添加到符号表上,符号表中记录的是变量名及变量所对应的地址。

指针变量在符号表上对应的地址值为指针变量的地址值,而引用在符号表上对应的地址值为引用对象的地址值(与实参名字不同,地址相同)

符号表生成之后就不在修改,因此指针可以改变改变其指向的对象(指针变量中的值可改),而引用对象不可以

14.C++的组合

在 C++ 中,组合(Composition)是一种对象组合的设计模式,它允许一个类将一个或多个对象作为其成员变量,从而形成一个更复杂的对象。组合是一种“拥有”关系,意味着一个对象可以包含其他对象的实例,而这些对象的生命周期通常与包含它们的对象相同。

组合的基本概念

组合是面向对象编程(OOP)中的一种重要概念,它允许我们构建复杂的对象,利用已有的类来创建新的类。组合的主要优点是代码的重用性和模块化设计。

特点

  • “拥有”关系:组合表示一个类“拥有”另一个类的实例。比如,一个 Car 类可以组合一个 Engine 类。
  • 生命周期管理:组合对象的生命周期通常与其包含的对象相同。当组合对象被销毁时,其包含的对象也会被销毁。
  • 灵活性:通过组合,可以灵活地创建复杂的对象结构,而不需要使用继承。

15.函数指针

函数指针是特殊的数据类型,函数的类型是由其返回的数据类型和其参数列表共同决定的,而函数的名称则不是其类型的一部分;函数在内存中有一个地址,我们可以将这个地址存储在一个指针变量中

应用场景:

  • 回调函数:可以将函数指针作为参数传递给另一个函数,以实现回调机制。
  • 动态函数调用:根据程序的运行时条件选择不同的函数进行调用。
  • 函数数组:可以创建一个函数指针数组,存储多个函数的地址,并根据索引调用相应的函数。

两种方法赋值

指针名=函数名,指针名=&函数名

16.函数调用过程栈的变化,返回值和参数变量哪个先入栈

调用者函数把被调函数所需要按照与被调函数的形参顺序相反的顺序压入栈中,从右向左依次把被调函数所需要的参数压入栈

调用者函数使用call指令调用被调函数,并把call指令的下一条指令的地址当成返回地址压入栈中(压栈指令隐藏在call指令中)

在被调函数中,被调函数会先保存调用者函数的栈底指针(ebp),然后再保存调用者函数的栈顶指针(esp)

在被调函数中,从ebp的位置开始存放被调用函数中的局部变量和临时变量,并且这些变量的地址按照定义时的顺序依次减小,这些变量的地址是按照栈的延伸方向排列的,先定义的变量先入栈,后定义的后入栈

17.printf函数的实现原理

printf 首先解析格式字符串,识别其中的格式说明符。它会逐字符读取字符串,并在遇到 % 时检查后续字符以确定格式类型。

在解析格式说明符后,printf 使用 va_list 来获取对应的参数。例如,对于 %d,它会调用 va_arg 获取下一个参数,并将其转换为整数。

在获取参数后,printf 会根据格式说明符的要求将参数转换为字符串。这个过程可能涉及到类型转换、精度控制、填充和对齐等

最后,格式化后的字符串将被输出到标准输出。通常,printf 会调用底层的系统调用(如 write)来实现这一点。

cout是一个std::ostream的全局对象,cout 输出过程首先将输出字符放入缓冲区再输出屏幕

  • printf:直接输出到标准输出流,不能方便地重定向到文件或其他输出流。
  • cout:可以方便地重定向到其他输出流(如文件),并且可以与其他流操作符结合使用。
  • printf:在某些情况下,printf 的性能可能优于 cout,尤其是在格式化输出时,因为 printf 是 C 语言中的一个低级函数。
  • cout:由于使用了类型安全和流机制,cout 在某些情况下可能会稍慢,但在现代编译器中,这种性能差异通常是微不足道的。

18.为什么模版类一般放在一个.h文件中

模板是一种泛型编程机制,允许我们在编写代码时不指定具体的类型。模板类在使用时需要被实例化为具体的类型

当我们在某个源文件中使用模板类时,编译器需要知道模板的具体实现,以便生成相应的代码。如果模板的实现放在源文件中,当你在其他文件中使用该模板时,编译器无法找到其实现,导致链接错误

C++ 是一种静态类型语言,模板的实例化是在编译时进行的。因此,编译器需要在编译时看到模板的完整定义。将模板类的实现放在头文件中,确保在每次实例化时,编译器都能访问到模板的定义。将模板类放在头文件中,可以在多个源文件中重用该模板,而不需要重复实现。这种方式促进了代码的复用,提高了代码的可维护性。

如果模板类的定义和实现分开,放在头文件和源文件中,可能会导致多个源文件中多次包含同一模板类的定义,进而引发链接错误。将模板类的实现放在头文件中,可以避免这种问题。

如果模板的实现放在源文件中(.cpp 文件),而你在其他源文件中使用该模板,编译器会遇到以下问题:

  1. 编译单元:C++ 的编译过程是分为多个编译单元的。每个源文件(.cpp 文件)被单独编译。模板的定义和实现如果放在某个源文件中,那么其他源文件在编译时不会访问到这个源文件的内容。
  2. 链接时错误:当编译器在其他源文件中遇到模板的实例化请求时,它会查找该模板的定义。如果模板的实现不在当前编译单元中,编译器无法找到它,这会导致编译器报告错误,通常是“未定义的引用”或“找不到模板定义”的错误。
  3. 缺乏上下文:即使在某个源文件中你有模板的实现,其他文件在编译时并不知道这个实现的存在,因此无法在链接阶段找到相应的代码。

普通类的编译过程

对于普通类(非模板类),编译器在编译源文件时会找到类的定义和实现,并在链接阶段将这些信息合并。普通类的实现可以在源文件中,因为:

  • 已知类型:普通类在编译时已经是具体的类型,编译器在编译源文件时能够看到该类的定义和实现。
  • 编译单元:当你在源文件中包含了头文件,这个头文件提供了类的声明。编译器在编译时会知道这个类的具体实现(如果在源文件中定义了),并能够正确生成代码。

模板类的编译过程

对于模板类,情况则不同:

  • 泛型类型:模板类在编写时是泛型的,编译器在编译时并不知道将要实例化哪些具体类型。模板的定义只是一个蓝图,只有在实际使用时(即实例化时),编译器才会生成具体的代码。
  • 需要完整定义:当你在某个源文件中使用模板类时,编译器需要看到模板的完整定义和实现,以便为特定类型生成相应的代码。如果模板的实现放在源文件中,编译器在其他源文件中使用该模板时无法找到其实现,这会导致链接错误。

19.声明和定义的区别

特征声明定义
目的告诉编译器某个标识符的类型和名称提供标识符的具体内容或实现
内存分配不分配内存分配内存(对于变量和类)
可以多次出现可以多次声明同一标识符(如函数)只能定义一次同一标识符
例子extern int x;int x = 10;

20.程序在执行int main(int argc , char* argv[])的内存结构

在 C++ 中,int main(int argc, char* argv[]) 是程序的入口点,argcargv 用于处理命令行参数。理解该函数执行时的内存结构有助于掌握程序的运行机制。

内存结构概述

在程序执行时,内存通常分为几个主要区域:

  1. 代码段(Text Segment):存储程序的可执行代码。
  2. 数据段(Data Segment):存储全局变量和静态变量。
    • 初始化数据段:存储已初始化的全局和静态变量。
    • 未初始化数据段(BSS Segment):存储未初始化的全局和静态变量。
  3. 堆(Heap):用于动态分配内存(通过 newmalloc 等)。
  4. 栈(Stack):用于存储局部变量、函数参数和返回地址。

main 函数的内存结构

当程序执行到 int main(int argc, char* argv[]) 时,以下是内存结构的具体情况:

  1. 栈内存
    • main 函数被调用时,会在栈上分配空间来存储函数的局部变量和参数。
    • argc 是一个整数,表示命令行参数的数量。
    • argv 是一个指向字符指针数组的指针,每个元素指向一个命令行参数的字符串(以 char* 类型表示)。
    • 栈中会存储 argcargv 的值及其指向的内存地址。
    +—————–+
    |   argv[0]     | // 指向程序名称的字符串
    +—————–+
    |   argv[1]     | // 指向第一个参数的字符串
    +—————–+
    |   argv[2]     | // 指向第二个参数的字符串
    +—————–+
    |       …     |
    +—————–+
    |   argv[n]     | // 指向第 n 个参数的字符串
    +—————–+
    |     argc     | // 参数数量
    +—————–+
  2. 堆内存
    • 如果在 main 中使用动态内存分配(例如,使用 newmalloc),则分配的内存会在堆中。
  3. 数据段
    • 如果有全局变量或静态变量,它们会被存储在数据段中。
  4. 代码段
    • 包含 main 函数的机器代码。

示例

考虑下面的简单程序:

#include <iostream>

int main(int argc, char* argv[]) {
   std::cout << "Number of arguments: " << argc << std::endl;
   for (int i = 0; i < argc; ++i) {
       std::cout << "Argument " << i << ": " << argv[i] << std::endl;
  }
   return 0;
}

执行过程:

  1. 当你从命令行运行程序时,命令行参数会被传递到 main 函数。
  2. argc 会被设置为参数的数量(包括程序名称)。
  3. argv 数组会存储每个参数的字符串表示。

假设你用以下命令运行程序:

./my_program arg1 arg2
  • argc 将是 3(包括程序名称 ./my_program)。
  • argv[0] 将指向字符串 ./my_program
  • argv[1] 将指向字符串 arg1
  • argv[2] 将指向字符串 arg2
  • int main(int argc, char* argv[]) 的执行涉及栈、堆、数据段和代码段的内存结构。
  • argcargv 在栈上分配内存,存储命令行参数的信息。
  • 理解这些内存结构有助于更好地管理程序的输入和输出,以及内存的使用。