语法八股-02

知识列表

  1. 拷贝初始化和直接初始化
  2. 初始化和赋值
  3. 重载,重写和隐藏
  4. 内联函数
  5. 权限
  6. 大小端
  7. volatie,mutable,explicit
  8. 什么情况下会调用拷贝构造函数
  9. plain new , nothrow new , placement new
  10. C++的异常处理
  11. 形参和实参的区别
  12. 深拷贝和浅拷贝
  13. new和malloc的区别
  14. delete p , delete []p , allocator都有什么作用
  15. new和delete的实现原理,delete是如何知道释放内存的大小的
  16. malloc申请的存储空间能用delete释放吗
  17. malloc与free的实现原理
  18. 内存映射的类型
  19. 构造函数的执行顺序,为什么使用初始化列表会快点
  20. 哪些情况必须使用成员列表初始化

1.拷贝初始化和直接初始化

当用于类类型对象时,初始化的拷贝形式和直接形式不同,直接初始化直接调用与实参匹配的构造函数,拷贝初始化总是调用拷贝构造函数。

拷贝初始化首先使用指定构造函数创建一个临时对象,然后用拷贝构造函数将那个临时对象拷贝到正在创建的对象上去

string str1(“1”); 直接初始化

string str2(str1);直接初始化,直接调用拷贝构造函数初始化

string str3 = “I am a string”;拷贝初始化,先将字符串创建一个临时对象,然后调用拷贝构造函数而不是operator =

string str4 = str3;拷贝初始化,隐式调用拷贝构造函数,而不是调用复制运算符

现在为了效率,允许编译器跳过创建临时对象这一步,直接调用构造函数构造要创建的对象

2.初始化和赋值

对于简单类型没什么区别

对于复杂的类,初始化是调用构造函数,赋值是重载赋值运算符

3.重载,重写和隐藏

重载是指在同一范围定义的同名成员函数才存在重载关系。主要特点是函数名相同,参数类型和数目不同;但是不存在参数类型和数量相同,返回值不同的重载,也就是说仅仅依靠返回值是不能区分函数的

重写也就是在派生类中重写基类的虚函数,要求函数名,返回类型,参数类型,参数数量都相同;只是用于修改函数体

隐藏就是派生类在一定情况下屏蔽了基类的同名函数,比如两个函数参数相同,要想访问基类的函数需要加上类名;

对于函数参数不同但是函数同名,不管是不是虚函数,基类的同名函数都会被隐藏,无法直接调用

4.内联函数

内联函数在编译的时候直接将函数代码嵌入到目标代码中,省去函数调用的开销来提高执行效率

5.权限

访问权限,public,protected,private,public外部和子类都能访问,protected,外部不能访问,子类能访问,private,都不能

继承权限,pubic是原翻不动的继承,protected是对public的改为protected,private是private

6.大小端

大端: 0x12 34 56 78

小端: 0x 78 56 34 12

在socket编程中,需要将操作系统所用的小端存储的IP地址转换为大端存储,这样才能进行网络传输

int a = 0x1234;
char c = (char)a;
if(c == 0x12) big;

7.volatile

volatile

volatile关键字用于告诉编译器和处理器,声明的变量可能会被程序的其他部分(如不同的线程或中断服务程序)异步修改。因此,编译器和处理器在优化代码时不会对这些变量进行寄存器缓存或指令重排,确保每次访问该变量都从主内存中读取最新的值

主要特性
  1. 内存可见性:确保一个线程对volatile变量的写操作对其他线程立即可见
  2. 防止指令重排:避免编译器和处理器对volatile变量的读写指令进行重排,从而保证代码执行的顺序性
volatile关键字的用途

volatile通常用于以下场景:

  1. 多线程编程:当多个线程需要共享和更新同一个变量时,使用volatile可以确保变量的最新值对所有线程可见。
  2. 状态标志:如停止线程的标志位,确保一个线程设置的标志位能被其他线程及时看到。
  3. 硬件寄存器(在嵌入式编程中):与硬件交互时,确保每次访问都是对硬件寄存器的实际读取或写入,而不是缓存的值。
volatile的局限性

虽然volatile在确保内存可见性和防止指令重排方面有用,但它并不提供以下功能:

  1. 原子性volatile变量的读写操作本身仍然不是原子操作,多个线程同时修改可能导致竞态条件。
  2. 同步volatile不能替代同步机制(如互斥锁),无法保证多个操作的原子性。

8.什么情况下会调用拷贝构造函数

用类的实例对象去初始化另一个对象时

函数的参数是类的对象时(非引用)

函数的返回值是函数体的局部类对象时,此时虽然发生NRV(Named return Value优化),但是由于返回方式是值传递,所以会在返回值的地方调用拷贝构造函数

所以即使发生在NRV优化的情况,Linux g++的环境是不管是值返回方式还是引用返回方式都不会调用拷贝构造函数,而vs2019在值返回的情况下发生拷贝构造函数,引用返回则不会

A get(){ A a; return a;}这样 auto a = get();这样会发生拷贝构造函数

9.plain new , nothrow new , placement new

plain new就是常用的new:Type* ptr = new Type(arguments);

nothrow new用于内存分配,但是分配失败后不会抛出std::bad_alloc的异常,而是返回一个空指针: Type* ptr = new (std::nothrow) Type(arguments);

placement new,是定位new,允许在预先分配好的内存空间中构造对象,而不是让new分配内存,这对于在特定内存未知创建对象非常有用:Type* ptr = new(memory_address) Type(arguments);(#include <new>)确保提供给定位new的内存地址满足对象的对齐要求,否则可能导致未定义行为。

char buffer[sizeof(MyClass)];
MyClass * obj = new(bufffer) MyClass(0);

10.C++的异常处理

异常(Exception) 是指程序在运行过程中发生的错误或意外情况。在传统的错误处理方式中,程序通过返回错误码或设置状态标志来指示错误。然而,这种方式可能导致错误处理代码与业务逻辑混杂,增加代码复杂性。

C++ 的异常处理机制 提供了一种结构化的错误处理方式,通过 throw 来抛出异常,try 块中包含可能抛出异常的代码,catch 块用于捕获和处理异常。这样,错误处理与正常的业务逻辑分离,使代码更加清晰和可维护。

C++ 标准库提供了一系列异常类,位于 <stdexcept> 头文件中。这些类继承自 std::exception,提供了一种统一的异常处理接口。

异常在 C++ 中可以跨函数、跨调用栈传递,直到被某个 catch 块捕获。如果异常在任何 try 块中都没有被捕获,程序将调用 std::terminate 并终止执行。

当异常被抛出后,catch 块按从上到下的顺序进行匹配,找到第一个与异常类型匹配的 catch 块并执行。若没有匹配的 catch 块,异常继续向上传递。

RAII 是一种编程惯用法,通过对象的生命周期管理资源(如内存、文件句柄、互斥锁等)。当对象被创建时获取资源,析构时释放资源,即使在异常情况下也能确保资源被正确释放。

在 C++11 中引入的 noexcept 关键字用于声明函数不抛出异常。这有助于编译器进行优化,并提高代码的可靠性。

  1. 性能优化:编译器可以更好地优化不抛出异常的函数,特别是在移动语义和容器操作中。
  2. 异常传播控制:当一个 noexcept 函数意外抛出异常时,程序会调用 std::terminate,防止异常进一步传播,确保程序不会进入不一致的状态。

11.形参和实参的区别

方面形参(形式参数)实参(实际参数)
定义位置函数定义时声明的参数函数调用时传递的值或变量
作用域仅在函数内部有效在函数外部作用域中定义,可在多个函数中使用
数量固定在函数声明/定义中调用时需要传递与形参数量匹配的值或变量
生命周期和函数调用相同,一旦函数执行完毕,形参数就被销毁实参的生命周期取决于其定义的位置和类型
传递方式取决于函数声明时的方式(按值、按引用、按指针等)传递到函数中的具体值或变量

C++允许在函数声明时为形参提供默认值,如果调用函数时省略对应的实参,则使用默认值。

为了保证函数内部不修改传入的实参,C++允许将形参声明为const。这适用于按值、按引用和按指针传递的情况。

即使形参是const,按值传递仍然不会影响实参,只能防止函数内部修改形参。

通过将形参声明为const引用,可以避免复制,同时保证函数内部不修改实参。

C++11引入了可变参数模板,使函数能够接受任意数量的实参。参数包允许在编译时展开。

template<typename T, typename... Args>
void print(T first, Args... args) {
   std::cout << first << " ";
   print(args...); // 递归展开
}

形参和实参虽然在功能上相关,但在代码中具有不同的名称和作用域。避免使用相同的名称,以减少混淆和潜在的错误。

12.深拷贝和浅拷贝

浅拷贝只复制变量本身的值,而深拷贝是考虑到了指针的情况,将指针指向的值新开辟一块内存拷贝上去并且返回新的指针

13.new和malloc的区别

new是C++的关键字,malloc是C语言函数

new和malloc的核心功能都是在堆上开辟内存空间,但是new会调用对象的构造函数,而malloc只是开辟一块内存空间,在分配内存空间失败时,new会抛出std::alloc的异常,而malloc返回的是空指针;对于返回类型,new返回的是分配对象类型的指针,而malloc返回的是void*,那么malloc在类型转换的时候可能会出现错误

14.delete p , delete []p , allocator都有什么作用

delelte []时,数组的元素按逆序的顺序进行销毁,调用对应的析构函数

new的机制是将内存分配和对象的构造组合在一起,同时,delete也是将对象的析构和内存释放组合在一起,allocator将这两部分分开,allocator申请一部分内存,不进行对象的初始化,只有当需要的时候才会进行初始化操作

15.new和delete的实现原理,delete是如何知道释放内存的大小的

new对于复杂类型,先调用operator new分配内存,然后在分配的内存上调用构造函数;对于简单类型,new[] 计算好大小后调用operator new;对于复杂类型,new[] 先调用operator new[] 分配内存,然后在前四个字节写入数组大小n,然后调用n次构造函数,针对复杂类型,new[]会额外存储数组大小

delete简单类型只是调用free函数,复杂类型delete会先调用析构函数再调用operator delete

针对new[]分配出来的内存,因为需要4字节存储数组大小,实际分配的内存地址是[p-4]指向的内存,而delete会直接释放p指向的内存,这个内存根本没被系统几率会崩溃

delete[]会取出new[]保存的n,然后调用n次析构函数

16.malloc申请的存储空间能用delete释放吗

不能。

  1. 未调用构造/析构函数:使用 malloc 分配内存后,如果使用 delete 释放,不会调用析构函数,这可能导致资源泄漏或其他未定义行为。
  2. 内存管理策略不同mallocdelete 可能采用不同的内存管理策略,导致内存堆损坏或程序崩溃。
  3. 类型不匹配malloc 返回 void*,需要进行类型转换,而 delete 需要知道对象的确切类型以调用析构函数。

根据C++标准,混用不同的内存分配和释放机制会导致未定义行为。具体而言:

  • 使用 malloc 分配的内存只能使用 free 释放。
  • 使用 new 分配的内存只能使用 delete 释放。

17.malloc与free的实现原理

malloc与free的实现底层依赖由brkmmapmunmap这些系统调用实现的

brk是将堆顶指针向高地址移动,获取新的内存空间

mmap是在进程的虚拟地址空间中(堆和栈中间,成为文件地址映射区域的地方)获取一块空闲的虚拟内存

brkmmap 的调用确实是在用户空间内分配虚拟地址空间,没有分配物理内存。在第一次访问分配的虚拟地址空间时,会发生缺页中断(Page Fault),操作系统随后分配物理内存并建立虚拟地址与物理内存的映射关系

当使用 brkmmap 分配内存时,操作系统会为进程的虚拟地址空间预留相应的地址范围,但不会立即分配物理内存。这种机制称为按需分页。只有当进程尝试访问(读/写)这块虚拟内存时,硬件会触发一个缺页中断,操作系统响应中断,分配一个物理页,并将其映射到相应的虚拟地址。

malloc 请求小于 128KB 的内存时,会通过 brk 分配内存, 将堆顶指针向高地址方向移动。而对于大于 128KB 的内存请求,malloc 则使用 mmap 在堆和栈之间的空闲区域分配内存。通过 brk 分配的内存只能在高地址的内存被释放后才能被回收,而使用 mmap 分配的内存则可以独立释放。当最高地址的空闲内存超过 128KB 时,系统会执行内存紧缩(trim)操作。在上一次的 free 操作中,如果发现最高地址的空闲内存超过 128KB,系统便会触发内存紧缩。

malloc是从堆里面申请分配内存,也就是说函数返回的指针是指向堆里面的一块内存,操作系统中有一个记录空闲内存地址的链表,当操作系统收到程序申请的时候,就会遍历该链表,然后就寻找第一个内存空间大于所申请空间的堆节点,然后就将该节点从空闲节点链表中删除,并将该节点的空间分配给程序,释放内存时,free 会将内存块重新加入到空闲链表中,供未来的分配请求使用

即使在使用 brk 分配内存的情况下,malloc 仍然会遍历空闲链表,原因如下:

  1. 内存重用
    • 如果有之前分配的内存块仍然可用,malloc 可以直接重用这些内存块,而不需要调用 brk 来向操作系统请求更多内存。这可以提高性能,减少系统调用的开销。
  2. 减少内存碎片
    • 通过维护一个空闲链表,malloc 可以有效管理内存,减少内存碎片。即使是小内存块,频繁的分配和释放也可能导致内存碎片,如果不进行管理,可能会导致后续的内存分配失败。
  3. 分配策略
    • malloc 通常实现了多种分配策略,以优化内存的使用。例如,它可能会根据请求的大小选择合适的空闲块,或者将多个小块合并成一个大的块以减少碎片。
  4. 多线程支持
    • 在多线程环境中,malloc 需要考虑线程安全,这意味着它可能需要跟踪多个线程的内存分配请求。遍历空闲链表可以帮助实现这一点。

18.内存映射的类型

    • 匿名映射(Anonymous Mapping):
      • 使用 mmap 分配的内存不与任何文件关联,主要作为堆或栈的扩展。
    • 文件映射(File Mapping):
      • 使用 mmap 将文件内容映射到进程的地址空间,方便文件的随机访问和共享。
  1. 内存重排(Memory Reuse)和碎片管理
    • 虚拟内存管理允许操作系统有效地重用和重排内存,减少碎片化。
    • mmap 通常不会受 brk 管理的堆空间的影响,避免了 brk 可能带来的内存碎片问题。
  2. 内存保护和权限
    • 通过 mmap 可以为不同的内存区域设置不同的访问权限(如只读、可读写、可执行等),增强了内存的安全性。
    • brk 分配的堆空间通常具有统一的访问权限。
  3. 大页内存(Huge Pages)
    • 对于需要大量内存的应用,mmap 支持使用大页(Huge Pages),减少页表项,提高内存访问效率。

19.构造函数的执行顺序,为什么使用初始化列表会快点

构造函数的执行顺序:基类的构造函数,类类型的成员变量的构造,自己

列表初始化是给数据成员分配内存空间时就进行初始化,也就是执行构造函数前就创建并初始化好了对象,之后在构造函数体内就直接对已分配内存的对象进行操作,那么就快点

20.哪些情况必须使用成员列表初始化

  1. 成员变量是引用
  2. 成员变量是const常量
  3. 当这个类是一个派生类,且派生类需要传入参数
  4. 当这个类有类类型的成员变量