1. 父子继承的析构关系

  1. 构造时先调用父类的构造函数,再调用派生类的构造函数。建楼从地基开始。

  2. 析构时,先调用子类的析构函数,再调用父类的析构函数。拆楼从最高层开始拆

  3. 若使用父类指针指向子类对象,delete该对象时,只会调用父类的析构函数,此时可将父类的析构函数设置为虚函数。若使用子类指针指向父类对象,delete该对象时,会先调用子类析构函数,再调用父类析构函数。

基类析构函数为什么是虚函数

在C++中,基类(父类)的析构函数通常被声明为虚函数,这是为了实现多态和正确的资源释放。

  1. 多态行为:多态是面向对象编程的一个重要特性,允许通过基类指针或引用来调用派生类的函数。如果基类的析构函数不是虚函数,那么在使用多态时可能会导致问题。考虑以下情况:

Base* basePtr = new Derived;

如果Base类的析构函数不是虚函数,那么上述代码将调用Base类的析构函数,而Derived类中的资源可能不会被正确释放,导致资源泄漏。

  1. 当类中使用了动态分配的资源(如堆上的内存、文件句柄等),并在析构函数中负责释放这些资源时,需要确保在多态的情况下正确释放资源。只有将析构函数声明为虚函数,才能在基类指针或引用指向派生类对象时,调用派生类的析构函数,确保释放了派生类中的资源。

  2. 在C++中,一个派生类可能继承自多个基类。如果其中某个基类的析构函数不是虚函数,可能导致无法正确地释放所有基类的资源。通过使用虚函数,可以确保正确地调用所有相关基类和派生类的析构函数。

2.结构体的空间计算

struct 的空间计算

遵循的2个原则:
1:整体空间是占用空间最大的成员(的类型)所占字节数的整数倍,但是在32位Linix + gcc环境下,若最大成员类型所占字节数超过4,如double是8,则整体空间是4的倍数即可。
2:数据对齐原则。内存按结构体成员的先后顺序排列,当排到该成员变量时,其前面已摆放的空间大小必须是该成员类型大小的整数倍,如果不够则补齐,依次向后类推。但在Linux + gcc环境下,某成员类型所占字节数超过4,如double是8,则前面已摆放的空间大小是4的整数倍即可,不够则补齐

例子

struct Demo{
  char a;
  double b;
  int c;
  char d;
};
cout<< sizeof(Demo) << endl;

偏移

0

8

16

20

21

字节数

a(占用字节1)

b(占用字节8)(8+8=16)

c(占用字节4)(16+4=20)

d(占用字节1)(20+1=21)

剩余3字节空着

讲解

a是char类型,占用1个字节
a放在偏移量为0的位置上

b是double类型,占用8字节。
根据规则2,前面已摆放空间必须是8的整数倍,不够则补齐。
离 1 最近的地址是8,因此 b 放在偏移量为8的位置上

c是int类型,占用4字节。
根据规则2,前面已摆放空间必须是4的整数倍

d是char类型,占用1字节

根据规则1,总节数必须是8的倍数
现在总字节是21,最接近8的倍数的是24,因此最终字节数就是24

结构体中包含子结构体的空间计算

当结构体中含有结构体时,struct空间计算的原则应该修改为:
1:整体空间是子结构体与父结构体中占用空间最大的成员(的类型)所占字节数的整数倍,但是在32位Linix + gcc环境下,若最大成员类型所占字节数超过4,如double是8,则整体空间是4的倍数即可。
2:数据对齐原则。父结构体内存按结构体成员的先后顺序排序,当排到子结构体成员时,其前面已摆放的空间大小必须是该子结构体成员中最大类型带大小的整数倍,如果不够则补齐,依次向后类推。但在Linux + gcc环境下,某成员类型所占字节数超过4,如double是8,则前面已摆放的空间大小是4的整数倍即可,不够则补齐

struct Demo1{
  char c;//1
  int i;//4
};//8字节
struct Demo2{
  char c1;//1
  Demo1 d;//8
  char c2;//1
};//16字节

结构体中包含数组的空间计算

在结构体中,数组是按照单个变量一个一个进行摆放的,不是视为整体。

struct s1{
  char a[8]; //1*8 = 8
  int b; //4
}; //12字节
struct s2{
  char a;//1
  s1 s;//12
};//16字节

3. 一个函数,如何让它在main函数之前执行

  • 全局对象的构造函数,即在main之前声明一个全局对象

  • 全局变量、对象和静态变量、对象的空间分配和赋初值

  • 进程启动后,要执行一些初始化代码,然后跳转到main函数执行。main函数执行完毕后,返回到入口函数,入口函数进行清理工作,包括全局变量的析构、堆销毁、关闭I/O等,然后等待系统关闭进程。

  • 使用关键字__attribute__,让一个函数在主函数之前运行,进行一些数据初始化、模块加载验证等。

#include<stdio.h>

void func()
{
    printf("hello world\n");
    //exit(0);
    return 0;
}

__attribute__((constructor))void before()
{
    printf("before\n");
    func();
}

__attribute__((destructor))void after()
{
    printf("after\n");

}

int main()
{
    printf("main\n"); //从运行结果来看,并没有执行main函数
}

4. delete

可以在C++的成员函数里调用delete this吗

能够调用。在调用后还可以调用该对象的其他方法,但是前提是:被调用的方法不涉及这个对象的数据成员和虚函数。

根本原因在于delete操作符的功能和类对象的内存模型。在类对象的内存空间中,只有数据成员和虚函数表指针,并不包含代码内容,类的成员函数单独放在代码段中。

在类的析构函数中调用delete this会导致堆栈溢出。delete的本质是为将被释放的内存调用一个或多个析构函数,而析构函数中又调用delete this,形成无限递归,造成堆栈溢出

使用delete删除指针的时候,会删除指针本身吗?

delete 删除指针时,delete 会释放指针所指向的内存,并且指针本身的值并不会被改变。

删除一个已经释放的内存块,会发生什么?

C++标准指出,这样做的结果将是不确定的,这意味着什么情况都可能发生

能否使用delete来释放声明变量所获得的内存?(Mean: 删除一个对指针的引用)

不行,只能用 delete 来释放使用 new 分配的内存

可以对空指针使用delete吗

当你尝试删除一个空指针时,C++ 规范允许这样的操作,因为 delete 操作符会检查指针是否为空,如果指针为空,delete 操作符会被忽略,不会引发运行时错误。这意味着在尝试删除一个空指针时,不会发生任何事情,也不会引发异常。

5. C++的类型转换有哪些

C 风格

强制转换 不管什么类型的转换统统是 (TYPE b = (TYPE)a )

static_cast

编译器隐式执行的任何类型转换都可由static_cast 来完成,在进行静态转换时,编译器不会进行安全检查,需要程序员确保转换的安全性。在编译时,编译器会出现警告如 从"double"转换到"float",可能丢失数据。当精度大的类型转换为精度小的类型时,会使用位截断进行处理

  • 基本数据类型的转换,转换的安全性由开发人员确保

    • int <--> float

    • double<-->float

    • enum<-->int

  • 继承关系的类之间的转换(基类与派生类指针或引用类型之间的转换)

    • 上行转换(把子类的指针或引用转换成基类表示)是安全的

    • 下行转换 (把基类指针或引用转换成子类表示),由于没有动态安全类型检查,是不安全的

  • 把任何类型的表达式转换成void类型。

  • static_cast不能转换掉expression的const、volitale、或者__unaligned属性。

  • cosnt 属性表达式

const int x = 5;
int y = 10;
const int z = static_cast<int>(x); // 无法转换掉x的const属性
int w = static_cast<int>(y); // 可以转换y,因为y不是const
  • volatile 属性表达式

volatile int a = 15;
int b = 20;
volatile int c = static_cast<int>(a); // 无法转换掉a的volatile属性
int d = static_cast<int>(b); // 可以转换b,因为b不是volatile
  • 带有__unaligned属性的表达式:

alignas(4) char buffer[8];
int* ptr = static_cast<int*>(&buffer[0]); // 无法转换掉buffer的__unaligned属性

dynamic_cast

  • 通常用于处理多态类型,即具有继承关系的类之间的转换,如基类指针转换为子类指针时

  • 转换检查,检查指针或引用是否真的指向目标类型的对象,如果是 返回空指针或引用

  • 需要在基类定义虚函数(至少一个,如虚析构),以实现运行时类型信息( RTTI )

const_cast

用于移除变量的const或volatile 限定符

reinterpret_cast

用于执行低级别的位模式的转换,它不保证转换的安全性。不会进行任何运行时的类型检查,也不保证转换后的对象能正常解析

  • 指针和整数之间的转换:这种转换通常用于在指针中存储额外信息,或者在特定平台上进行底层操作。例如,某些平台可能允许开发者利用指针的最低有效位来存储数据。

#include <iostream>

int main() {
    int a = 10;
    int* p = &a;
    uintptr_t i = reinterpret_cast<uintptr_t>(p);
    std::cout << "i: " << i << std::endl; //11534020

    return 0;
}
//可以看到,通过reinterpret_cast将指针转换为整数后,
//得到的是一个表示该指针的整数值。需要注意的是,这个整数值并不是指针本身所指向的对象的值,
//而是指针在内存中的地址值。
  • 不同类型的指针/成员指针/引用之间的转换:这可以用于通过成员访问完整结构体对象或者从完整结构体对象访问间接成员。虽然在C++中这种用途相对较少,但在某些特殊情况下可能会用到。

#include <iostream>

int main() {
    struct A { int x; };
    struct B { int y; };
    A a;
    B b;
    int* p1 = &a.x;
    int* p2 = reinterpret_cast<int*>(&b.y);
    std::cout << "p1: " << *p1 << std::endl;
    std::cout << "p2: " << *p2 << std::endl;

    return 0;
}
//p1: 15165747
//p2: 19922132
//可以看到,通过reinterpret_cast将一个结构体对象的成员变量的地址转换为另一个类型(这里是int*)的指针后,
//得到的是一个指向该成员变量的指针。需要注意的是,这种转换并不保证安全性,
//因为不同类型之间的大小和布局可能不同,因此直接访问转换后的指针可能会导致未定义行为。 

static_cast与dynamic_cast对比

static_cast不做运行时检查,不如dynamic_cast安全。static_cast 仅仅依靠类型转换语句中提供的信息来进行转换的,而dynamic_cast会遍历整个类继承体系来进行类型检查,因此dynamic_cast 在执行效率上比static_cast 要差一些

6. C++的智能指针

智能指针的使用可以大大简化内存管理,避免出现内存泄漏悬空指针等问题,提高代码的健壮性和可维护性。

shared_ptr

是 C++11 引入的一种独占所有权的智能指针。它确保在其生命周期结束时自动释放所管理的内存。std::unique_ptr 不能被复制,但可以被移动。

它的实现思路

  • 控制块(Control Block):通常包含以下信息

    • 指向动态分配内存的指针

    • 一个引用计数,用于跟踪共有多少个shared_ptr 指向同一块内存

    • 其他信息,比如自定义删除器

  • 构造函数 : 当创建一个std::shared_ptr 时,会动态分配一个控制块,将引用计数初始化为1,并将指针指向动态分配的内存

  • 拷贝构造函数和赋值操作符,当一个 std::shared_ptr 被拷贝构造或赋值给另一个 std::shared_ptr 时,它们会指向同一个控制块,引用计数会增加。

  • 析构函数:每当一个 std::shared_ptr 被销毁时,引用计数减少。当引用计数减少为 0 时,表示没有任何 std::shared_ptr 指向该内存块,此时释放内存并销毁控制块。

线程安全性

为了确保多线程环境下的安全性,引用计数的增减通常会使用原子操作来保证操作的原子性。

unique_ptr

 是 C++11 中引入的智能指针,它允许多个指针共享同一块内存。它使用引用计数来管理内存的释放,当最后一个 std::shared_ptr 被销毁时,内存会被释放

weak_ptr

 是 std::shared_ptr 的一种辅助类,它并不增加引用计数。主要用于解决 std::shared_ptr 的循环引用问题,通过 std::weak_ptr 可以打破循环引用,避免内存泄漏。

std::unique_ptr 和 std::shared_ptr 的自定义删除器

还可以使用 std::unique_ptr 和 std::shared_ptr 的自定义删除器,以便在释放内存时执行特定的操作,比如关闭文件、释放资源等

悬空指针和野指针

野指针(wild pointer)

即指针没有被初始化或者指向未知的内存区域

悬空指针(dangling pointer)

悬空指针是指指向已经释放的内存或者无效内存地址的指针。

使用野指针和悬空指针的危害

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

Undefined Behavior 即 任何可能都会发生。要么编译失败,要么执行得不正确(崩溃(e.g. segmentation fault)或者悄无声息地产生不正确的执行结果),或者偶尔会正确地产生程序员希望运行的结果。

循环引用问题

循环引用问题是指两个或多个对象相互持有对方的std::shared_ptr,导致它们之间形成循环引用,从而阻止内存的正常释放,造成内存泄漏。这种情况下,即使所有外部指针都释放了,由于循环引用导致的引用计数不为零,内存也无法被释放。

一个简单的循环引用示例

#include <memory>

class B;

class A {
public:
    std::shared_ptr<B> b_ptr;

    A() { 
        std::cout << "A constructor" << std::endl; 
    }

    ~A() { 
        std::cout << "A destructor" << std::endl; 
    }
};

class B {
public:
    std::shared_ptr<A> a_ptr;

    B() { 
        std::cout << "B constructor" << std::endl; 
    }

    ~B() { 
        std::cout << "B destructor" << std::endl; 
    }
};

int main() {
    std::shared_ptr<A> a = std::make_shared<A>();
    std::shared_ptr<B> b = std::make_shared<B>();

    a->b_ptr = b;
    b->a_ptr = a;

    return 0;
}

在这个示例中,类 A 持有一个指向类 B 的 std::shared_ptr,而类 B 持有一个指向类 A 的 std::shared_ptr。当 a 和 b 在 main() 函数中被创建并相互引用时,它们之间形成了循环引用。

解决循环引用问题的方法

使用 std::weak_ptr。std::weak_ptr 是一个弱引用指针,它不会增加引用计数,也不会影响对象的生命周期。通过将其中一个指针设置为 std::weak_ptr,可以打破循环引用,避免内存泄漏。

#include <memory>

class B;

class A {
public:
    std::shared_ptr<B> b_ptr;

    A() { 
        std::cout << "A constructor" << std::endl; 
    }

    ~A() { 
        std::cout << "A destructor" << std::endl; 
    }
};

class B {
public:
    std::weak_ptr<A> a_ptr;

    B() { 
        std::cout << "B constructor" << std::endl; 
    }

    ~B() { 
        std::cout << "B destructor" << std::endl; 
    }
};

int main() {
    std::shared_ptr<A> a = std::make_shared<A>();
    std::shared_ptr<B> b = std::make_shared<B>();

    a->b_ptr = b;
    b->a_ptr = a;

    return 0;
}

通过将 B 类中的 a_ptr 改为 std::weak_ptr<A>,循环引用问题得到了解决。这样,即使 A 和 B 相互引用,它们之间的引用关系不会影响对象的生命周期,也不会导致内存泄漏。

7. enum 和 enum class

在C++中,enum class 是C++11引入的一种枚举类型,它是一种强类型的枚举。相比传统的枚举类型,enum class 提供了更好的类型安全和作用域限定。

传统的枚举类型在定义时会导入其枚举值到当前的作用域中,这可能导致命名冲突。而enum class 引入了作用域限定,使得枚举值的作用域被限定在枚举类型内,避免了命名冲突。

总结 enum class 提供了更好的类型安全和作用域限定,使得枚举类型在C++中更加灵活和安全。

使用enum class进行强制类型转换需要使用static_cast。由于enum class是一种强类型的枚举,不能直接进行隐式类型转换,因此需要显式地使用static_cast来进行转换。

下面是一个示例,展示了如何对enum class进行强制类型转换:

#include <iostream>

enum class Color {
    Red,
    Green,
    Blue
};

int main() {
    Color myColor = Color::Red;

    // 进行强制类型转换
    int colorValue = static_cast<int>(myColor);

    std::cout << "The value of myColor is: " << colorValue << std::endl;

    return 0;
}

在这个示例中,我们将myColor枚举值转换为整数类型,使用了static_cast<int>(myColor)来进行强制类型转换。这样就可以将enum class的枚举值转换为其他类型。

8. 多线程

std::lock

定义:同时锁住两个及以上的互斥量。

优点:它不存在因为多个线程中因为锁的顺序导致的死锁的风险问题

源码

  template<typename _L1, typename _L2, typename... _L3>
    void
    lock(_L1& __l1, _L2& __l2, _L3&... __l3)
    {
      while (true)
        {
          using __try_locker = __try_lock_impl<0, sizeof...(_L3) != 0>;
          unique_lock<_L1> __first(__l1);
          int __idx;
          auto __locks = std::tie(__l2, __l3...);
          __try_locker::__do_try_lock(__locks, __idx);
          if (__idx == -1)
            {
              __first.release();
              return;
            }
        }
    }

简化版实现

template <typename... Lockable>
void lock(Lockable&... locks) {
    std::unique_lock<Lockable> guard1(locks...);
    std::unique_lock<Lockable> guard2(locks...);
    // ...
    // Check for deadlock and resolve it
    // ...
}
std::lock为什么它不会产生死锁

在这个简化的实现中,std::lock() 使用 std::unique_lock 来尝试获取多个锁,但是并不是简单地一次性获取所有的锁,而是采用了一种技术,即 "锁定或不锁定"。这意味着 std::lock() 会尝试获取所有的锁,如果它无法同时获取所有的锁,它会释放已经获取的锁,然后再次尝试获取所有的锁,直到所有的锁都成功获取,或者一个也没有获取。

这种方法避免了死锁的发生,因为它要么获取所有的锁,要么一个也不获取,不会出现线程之间相互等待对方释放资源的情况。因此,即使在复杂的多线程环境中,使用 std::lock() 可以帮助程序员避免死锁问题。

std::unique_lock

在 C++ 中,互斥锁通常是通过 RAII 机制来保证自动释放,即在锁对象的生命周期结束时自动释放锁。但是,在某些情况下,可能需要手动释放互斥锁。在 C++11 中,可以通过 std::unique_lock::unlock() 方法来手动释放互斥锁。

简单实现

template <class Mutex>
class unique_lock {
public:
    explicit unique_lock(Mutex& m) : mutex(m) {
        mutex.lock();
        locked = true;
    }

    ~unique_lock() {
        if (locked) {
            mutex.unlock();
        }
    }

    void lock() {
        if (!locked) {
            mutex.lock();
            locked = true;
        }
    }

    void unlock() {
        if (locked) {
            mutex.unlock();
            locked = false;
        }
    }

private:
    Mutex& mutex;
    bool locked;
};

应用场景:

  1. 手动加锁和解锁:与 std::lock_guard 不同,std::unique_lock 实例可以在不同的时间点手动加锁和解锁,而不是在构造和析构时自动加锁和解锁。

  2. 延迟加锁:可以在构造 std::unique_lock 实例时不立即加锁,而是在稍后的某个时间点再加锁。

  3. 与条件变量的结合使用:std::unique_lock 可以与条件变量一起使用,用于等待条件的满足和通知其他线程条件的变化。

std::mutex mtx;
std::condition_variable cv;
bool dataReady = false;

void waitingThread() {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, []{ return dataReady; }); // 等待条件变量满足
    // 执行等待后的操作
}

void notifyingThread() {
    {
        std::lock_guard<std::mutex> lock(mtx);
        dataReady = true;
    }
    cv.notify_one(); // 通知条件变量
}

std::lock_guard

std::lock_guard 是 C++11 中的标准库,是一个实现互斥锁的简单模板类。
它的使用方法很简单,只需要在代码中创建一个 std::lock_guard 对象,并传入一个互斥锁,在它的生命周期内,互斥锁的访问权限将被控制。 

std::unique_lock 和 std::lock_guard的区别

lock_guard 和 unique_lock 都是 C++ 标准库提供的互斥锁 RAII 封装工具,用于实现互斥访问,但它们有一些不同之处:

  • lock_guard 是基于互斥锁 std::mutex 实现的,unique_lock 是基于通用锁 std::unique_lock 实现的

  • unique_lock 可以实现比 lock_guard 更灵活的锁操作。

  • unique_lock 提供了更多的控制锁的行为,比如锁超时、不锁定、条件变量等。

  • unique_lock 比 lock_guard 更重,因为它有更多的功能,更多的开销。如果只需要简单的互斥保护,使用 lock_guard 更好。

另外,unique_lock 支持手动解锁,而 lock_guard 不支持。

示例代码:

#include <iostream>
#include <mutex>
#include <thread>
 
std::mutex m;
 
void worker()
{
    std::lock_guard<std::mutex> lg(m);  // lock_guard 方式上锁
    std::cout << "worker thread is running..." << std::endl;
    // 这里可以写一些需要互斥保护的代码
    std::this_thread::sleep_for(std::chrono::seconds(1));
    std::cout << "worker thread is done." << std::endl;
}  // lock_guard 不支持手动解锁,会在此自动释放锁
 
void another_worker()
{
    std::unique_lock<std::mutex> ul(m);  // unique_lock 方式上锁
    std::cout << "another worker thread is running..." << std::endl;
    // 这里可以写一些需要互斥保护的代码
    std::this_thread::sleep_for(std::chrono::seconds(1));
    std::cout << "another worker thread is done." << std::endl;
    ul.unlock();  // 手动释放锁
    //do something...
}  // 如果锁未释放,unique_lock 会在此自动释放锁
 
int main()
{
    std::thread t1(worker);
    std::thread t2(another_worker);
    t1.join();
    t2.join();
    return 0;
}

死锁

什么是死锁

死锁是指在多线程程序中的一种情况,其中每个线程都在等待其他线程释放资源,导致所有线程都无法继续执行的状态。

通常情况下,死锁发生在多个线程试图获取一些资源,但由于资源的竞争和互斥访问,导致线程之间相互等待。死锁通常涉及多个资源,例如锁、内存、或者其他共享资源。

一个简单的例子是两个线程分别持有对两个不同的资源的锁,并且每个线程都试图获取对方持有的资源的锁。这种情况下,两个线程会相互等待对方释放资源的锁,导致死锁。

如何避免死锁

见上述的 unique_lock,lock,lock_guard等

原子操作

简介和示例

原子操作是指在多线程环境下不会被中断的操作。在计算机科学中,原子操作是指一组操作要么全部执行,要么全部不执行,不会出现部分执行的情况。原子操作通常用于确保多线程环境下的数据一致性和并发访问的正确性。 在多线程编程中,如果多个线程同时访问共享的数据,可能会出现竞态条件(race condition)的问题,导致数据不一致或者程序出现未定义的行为。原子操作可以用来避免这种情况的发生。 在硬件层面,原子操作通常依赖于特定的指令或者硬件支持,这些指令或者硬件能够确保操作的原子性,例如在多核处理器上的原子指令或者内存屏障。 在软件层面,原子操作通常由编程语言或者操作系统提供的原子操作接口来实现,比如 C++11 中引入的原子操作库 <atomic>,以及操作系统提供的原子操作接口。 原子操作的一个重要特性是它们能够在不需要额外的同步机制(比如互斥锁)的情况下确保线程安全。这使得原子操作成为并发编程中非常重要的工具,能够减少竞争和死锁等问题的发生。

当涉及到原子操作时,通常会使用原子类型,比如 C++ 中的 std::atomic。以下是一个简单的示例,演示了如何使用原子操作来确保对共享变量的安全访问。

#include <iostream>
#include <thread>
#include <atomic>

std::atomic<int> counter(0);

void incrementCounter() {
    for (int i = 0; i < 10000; ++i) {
        counter.fetch_add(1, std::memory_order_relaxed);
    }
}

int main() {
    std::thread t1(incrementCounter);
    std::thread t2(incrementCounter);

    t1.join();
    t2.join();

    std::cout << "Final counter value: " << counter << std::endl;

    return 0;
}

在这个示例中,我们创建了一个 std::atomic<int> 类型的 counter 变量,它是一个原子类型的整数。我们创建了两个线程,每个线程都会调用 incrementCounter 函数来增加 counter 的值。在 incrementCounter 函数中,我们使用 fetch_add 来原子地增加 counter 的值。

在这个例子中,fetch_add 函数确保了对 counter 的递增操作是原子的,即使两个线程同时调用 fetch_add,也不会导致竞争条件或其他并发问题。这样,我们可以确保在多线程环境下对 counter 的安全访问。

这个例子展示了原子操作在并发编程中的应用,通过使用原子操作,我们可以避免显式地使用互斥锁或其他同步机制,从而简化了并发编程的复杂性。

原子操作需要和锁配合使用吗

在一般情况下,原子操作不需要和锁配合使用。原子操作的设计初衷之一就是为了避免使用显式的锁来实现线程安全。原子操作能够确保对共享数据的操作是原子的,从而避免了竞态条件和数据不一致的问题。

然而,在某些情况下,原子操作和锁可能会同时使用。例如,在复杂的并发场景中,有时候可能需要将多个原子操作组合成一个原子操作序列,这时可能会使用锁来确保这个序列的原子性。另外,在一些特殊的并发模型中,也可能会出现原子操作和锁同时使用的情况。

总的来说,原子操作通常用于简化并发编程中的线程安全问题,而锁通常用于保护临界区,确保在同一时间只有一个线程可以访问共享资源。在实际编程中,需要根据具体的并发场景来决定是否需要同时使用原子操作和锁。

一个需要的和锁配合使用的场景

在某些情况下,需要将多个原子操作组合成一个原子操作序列,以确保这个序列的原子性。这种情况通常涉及到对多个共享变量的复杂操作,需要保证这些操作作为一个整体是原子的。

考虑以下情景:假设有两个共享变量 x 和 y,我们希望在多线程环境下,能够原子地执行以下操作:

  1. 读取 x 的值

  2. 根据 x 的值计算出一个新值

  3. 将新值写入 y

如果我们只使用原子操作,无法直接实现上述的操作序列的原子性。在这种情况下,可以使用互斥锁来保护这个操作序列,确保在同一时间只有一个线程可以执行这个序列,从而保证整个操作序列的原子性。

以下是一个简单的示例,演示了如何使用互斥锁来保护一个操作序列:

#include <iostream>
#include <thread>
#include <mutex>

std::mutex mtx;
int x = 0;
int y = 0;

void updateValues() {
    int new_x = 0;
    {
        std::lock_guard<std::mutex> lock(mtx);
        // 读取 x 的值
        new_x = x;
        // 根据 x 的值计算出新值
        // 这里可以进行一些复杂的计算
        // 假设这里的计算需要保护
        // 然后将新值写入 y
        y = new_x * 2;
    }
}

int main() {
    std::thread t1(updateValues);
    std::thread t2(updateValues);

    t1.join();
    t2.join();

    std::cout << "Final values: x = " << x << ", y = " << y << std::endl;

    return 0;
}

在这个示例中,我们使用了 std::mutex 来保护 updateValues 函数中的操作序列,确保在同一时间只有一个线程可以执行这个序列。这样,即使在多线程环境下,我们也可以确保整个操作序列的原子性。

需要注意的是,使用锁来保护操作序列可能会引入一些潜在的性能开销和死锁风险,因此在实际应用中需要谨慎考虑是否真的需要将多个原子操作组合成一个原子操作序列并使用锁来保护。

9. sizeof 运算符

  • 定义:sizeof 是一个运算符,用于获取给定类型或变量的大小(以字节为单位)。在 C 和 C++ 中,sizeof 运算符通常用于确定数据类型、数组或结构体的大小

  • 什么时候使用

    • 确定数据类型的大小,特别是在编写与内存分配和指针操作相关的代码时。

    • 在动态内存分配中,使用 malloc、calloc 或 realloc 时,通常需要使用 sizeof 来确保分配的内存大小是正确的。

  • 返回类型:返回 size_t 类型的值,这是一个无符号整数类型,通常是 unsigned int 或 unsigned long

一个空的类型,没有任何成员函数及变量,它的大小是多少

1 ,为什么:在声明该类型时,它在内存中占有一定的空间,否则无法使用。占用的多少由编译器决定,vs下为1字节

如果该类型中加入一个构造和析构函数,大小为多少

1 ,因为构造函数和析构函数的调用只需要函数地址即可执行,而函数地址只与类型有关,与实例 无关

如果析构函数为虚函数,大小会如何变化

取决于指针所占的字节大小,当有虚函数时,会对该类型生成虚函数表,并在每一个实例添加虚表指针,32 位下为4个字节,64位下为8个字节

sizeof 是一个编译时的运算符吗?还是一个运行时的函数?

尽管 sizeof 看起来像一个函数,但它实际上是一个编译时的运算符,而不是一个运行时的函数。因此,它不需要函数调用的开销,并且可以在编译时计算出大小。

10. auto 是如何推导常量表达式的

auto推导的核心是让编译器根据变量的初始化表达式推断出变量的类型,从而简化代码编写过程并提高代码的可读性和灵活性。使用auto关键字可以让编译器在编译时根据初始化表达式的类型自动推导出变量的类型,而无需显式指定类型。

以下是auto推导的核心要点:

  1. 类型推导:auto关键字让编译器根据初始化表达式的类型来推断变量的类型。这意味着程序员不需要显式指定变量的类型,编译器会根据上下文自动确定。

  2. 灵活性:使用auto可以使代码更加灵活,特别是在处理复杂的模板类型或迭代器类型时。它可以简化代码,减少重复的类型声明,提高代码的可维护性。

  3. 可读性:auto可以使代码更易读,因为它减少了冗长的类型声明,使代码更加简洁和易于理解。

  4. 不失类型安全性:虽然使用auto可以减少代码中的类型声明,但编译器仍然会在编译时执行类型检查,确保类型匹配,从而保持类型安全性。

总的来说,auto推导的核心是让编译器根据初始化表达式推断变量的类型,从而简化代码编写,并提高可读性和灵活性。

11. 什么是缩窄转换?列表初始化过程中可以进行缩窄转换吗?

缩窄转换(Narrowing Conversion)是指将一个数值转换为另一种数值类型时可能导致信息丢失的转换过程。在C++中,缩窄转换指的是将一个数值转换为比其范围更小的类型,可能导致精度丢失或溢出的情况。

在列表初始化过程中,如果发生了缩窄转换,编译器会发出警告或错误,取决于编译器的设置。例如,如果将一个浮点数赋值给一个整数类型,并且这个浮点数的值有小数部分,这种转换就会被视为缩窄转换。

12. Union

简介

共用体(union)是一种数据格式,它能够存储不同的数据类型,但只能同时存储其中的一种类型。也就是说,结构可以同时存储 int、10ng 和 double, 共用体只能存储 int、 long 或 double。共用体的句法与结构相似,但含义不同。

共用体常用于 (但并非只能用于) 节省内存。当前,系统的内存多达数 GB 甚至数 TB, 好像没有必要节省内存,但并非所有的 C++程序都是为这样的系统编写的。C++还用于嵌入式系统编程,如控制烤箱、MP3 播放器或火星漫步者的处理器。对这些应用程序来说,内存可能非常宝贵。另外,共用体常用于操作系统数据结构或硬件数据结构

例子


union one4all{
  int int_val;
  long long_val;
  double double__val;
} 

one4all pail;
pail.int_val = 15;
cout<< pail.int_val;
pail.double_val=1.38;
cout<< pail.double_val;

因此,pail 有时可以是 int 变量,而有时又可以是 double 变量。成员名称标识了变量的容量。由于共用体每次只能存储一个值,因此它必须有足够的空间来存储最大的成员,所以,共用体的长度为其最大成员的长度。

共用体的用途之一是,当数据项使用两种或更多种格式(但不会同时使用)时,可节省空间。例如,假设管理一个小商品目录,其中有一些商品的 ID 为整数,而另一些的 ID 为字符串。在这种情况下,可以这样做:

struct widget{
    char brand[20];
    int type;
    union id {
        long id_num; // type 1 widgets
        char id_char[20]; // other widgets
    } id_val;
};

...
widget prize;
...
if (prize.type == 1) {
    std::cout << "The prize id is " << prize.id_val.id_num << std::endl;
} else {
    std::cout << "The prize id is " << prize.id_val.id_char << std::endl;
}

匿名共用体

没有名称,其成员将成为位于相同地址处的变量。显然,每次只有一个成员是当前的成员:

struct widget{
    char brand[20];
    int type;
    union {
        long id_num; // type 1 widgets
        char id_char[20]; // other widgets
    } id_val;
};

...
widget prize;
...
if (prize.type == 1) {
    std::cout << "The prize id is " << prize.id_num << std::endl;
} else {
    std::cout << "The prize id is " << prize.id_char << std::endl;
}

13 .C++ 编译的程序占用的内存分区

  • 代码段(Text Segment):也称为只读段(Read-Only Segment),存储程序的机器代码,即编译后的指令。这部分内存通常是只读的,因为存储的是程序的指令。在程序运行时,代码段会被加载到内存中,并且通常是共享的,以便多个程序可以共享相同的代码段,节省内存空间。

  • 数据段(Data Segment):数据段包含了程序中已初始化的全局变量和静态变量。这些变量在程序运行时会被初始化,并且在程序的整个生命周期中保持不变。数据段通常可以分为两部分:全局初始化数据段(Initialized Data Segment)和全局未初始化数据段(Uninitialized Data Segment)。

  • 堆(Heap):堆是动态分配内存的区域,用于存储程序运行时动态分配的内存。在堆上分配的内存需要手动释放,否则可能会导致内存泄漏。在 C++ 中,使用 new 和 delete 或 malloc 和 free 等函数来管理堆内存。

  • 栈(Stack):栈用于存储函数调用时的局部变量、函数参数、返回地址等信息。每当函数被调用时,栈会分配一块内存用于存储这些信息,函数执行完毕后,栈会自动释放这部分内存。栈是一种后进先出(LIFO)的数据结构。

  • 全局/静态区(BSS Segment):全局/静态区用于存储未初始化的全局变量和静态变量。这部分内存在程序加载时会被初始化为零或空值。全局变量和静态变量在程序的整个生命周期中保持不变。

14 . volatile

在C和C++中,volatile是一个关键字,用于告诉编译器不要对被声明为volatile的变量进行优化。当一个变量被声明为volatile时,意味着这个变量的值可能会在程序的控制之外被改变,因此编译器不应该对这个变量的访问进行优化或者假设其值不会改变。

主要用途包括:

  1. 多线程编程:在多线程环境中,一个变量可能被多个线程同时访问和修改。将这样的变量声明为volatile可以确保每次访问都会从内存中读取最新的值,而不是依赖于缓存的值。

  2. 中断处理:在嵌入式系统或者操作系统开发中,volatile关键字经常用于处理中断。中断服务程序中的变量通常会被声明为volatile,以确保对这些变量的访问不会被编译器优化掉。

  3. 硬件访问:当与硬件进行交互时,例如访问内存映射的寄存器或者外设寄存器,这些变量通常被声明为volatile,以确保编译器不会对这些访问进行优化,保证程序的正确性。

需要注意的是,volatile关键字只是告诉编译器不要对变量的访问进行优化,并不提供线程安全性或者原子性。在多线程环境下,volatile并不能完全保证线程安全,需要配合其他同步机制来确保线程安全性。

15 . mutable

当一个成员变量被声明为mutable时,意味着即使在一个const成员函数内部,该成员变量仍然可以被修改。

主要用途包括:

  1. 在const成员函数中修改成员变量:通常,const成员函数被设计为不修改类的状态。然而,有时候可能有一些特定的成员变量需要在const成员函数内部被修改,这时就可以将这些成员变量声明为mutable。

  2. 缓存数据:有时候在类中会使用缓存来提高性能。这些缓存数据在逻辑上不改变类的状态,但是需要在const成员函数中更新。这时可以使用mutable修饰缓存变量。

示例代码如下:

class Cache {
public:
    int getValue() const {
        if (!cached) {
            // Calculate the value and cache it
            cachedValue = calculateValue();
            cached = true;
        }
        return cachedValue;
    }

private:
    mutable bool cached = false;
    mutable int cachedValue;

    int calculateValue() const {
        // Some expensive calculation
        return 42;
    }
};

在这个示例中,cached和cachedValue被声明为mutable,这样在getValue()函数中即使是const成员函数也可以更新这些缓存数据。

15 . 多重继承

二义性

即当一个类继承了两个(或多个)具有相同成员函数名的基类时,如果在派生类中调用这个成员函数,编译器可能无法确定应该使用哪个基类中的版本,从而产生二义性。

为了解决多重继承的二义性问题,可以采取以下几种方法:

  1. 使用作用域解析运算符 ::: 在调用有二义性的成员函数时,可以使用作用域解析运算符来明确指定是哪个基类的成员函数。例如,Base1::function() 或 Base2::function()。

  2. 虚继承(virtual inheritance): 虚继承是一种解决菱形继承问题(diamond inheritance)和多重继承二义性的方法。在虚继承中,最终派生类只会包含一份共享基类的实例。通过在继承链中使用虚继承,可以避免多个基类中同名成员函数的二义性。

  3. 重定义函数(override functions): 如果可能,可以在派生类中重新定义同名函数,以消除二义性。通过在派生类中提供一个新的函数实现,可以明确指明应该调用哪个版本。

  4. using 声明: 使用 using 声明可以将特定的基类成员引入到派生类的作用域中,这样可以避免二义性问题。但要注意,using 声明可能会引入其他问题,如名称遮掩(name hiding)。

菱形继承

菱形继承(Diamond Inheritance)是指在一个类继承体系中,某个类同时继承了两个间接基类,而这两个间接基类又共同继承自一个共同的基类,形成了一个菱形的继承结构。这种继承结构看起来像一个菱形,因此得名“菱形继承”。

菱形继承可能会导致一些问题,其中最常见的问题是资源的重复拷贝。考虑以下继承结构:

   A
  / \
 B   C
  \ /
   D

在这个结构中,类 A 是顶层基类,类 B 和 C 分别继承自 A,而类 D 同时继承自 B 和 C。如果 A 中有一些资源(比如成员变量或函数),当 B 和 C 各自拷贝这些资源时,D 中就会包含两份相同的资源,这可能导致资源浪费和其他问题。

为了解决菱形继承问题,C++ 提供了虚继承(virtual inheritance)的机制。通过在继承关系中使用虚继承,可以确保最终派生类只包含一份共享基类的实例。这样可以避免资源的重复拷贝和其他与菱形继承相关的问题。

在菱形继承中,还可能出现二义性问题,即当两个间接基类具有同名成员函数时,派生类可能无法确定应该使用哪个基类中的版本。解决这种二义性问题的方法在前面的回答中已经提到过,包括使用作用域解析运算符、重定义函数、虚继承等。

16 . 友元

友元类

在 C++ 中,友元类(Friend Class)是指一个类可以访问另一个类的私有成员和保护成员。通常情况下,类的成员(包括成员函数和成员变量)只能被该类的对象或者该类的派生类对象访问,但如果一个类被声明为另一个类的友元类,那么这个友元类就可以访问该类的私有成员和保护成员。

友元类的声明通常在类的定义中,可以在类的开始或结束处使用 friend 关键字声明其他类为友元类。例如:

class A {
private:
    int privateMember;

    // 声明类 B 是类 A 的友元类
    friend class B;
};

class B {
public:
    void accessPrivateMemberOfA(A& obj) {
        obj.privateMember = 5; // 类 B 可以访问类 A 的私有成员
    }
};

在上面的例子中,类 B 被声明为类 A 的友元类,因此类 B 的成员函数 accessPrivateMemberOfA 可以直接访问类 A 的私有成员 privateMember。

友元类的使用有一些注意事项:

  1. 友元关系不具有传递性。如果类 A 声明类 B 为友元类,类 B 不会自动成为类 A 的友元类,也就是说类 B 不能访问类 A 的私有成员。

  2. 友元关系是单向的。如果类 A 声明类 B 为友元类,只有类 B 可以访问类 A 的私有成员,反之不成立。

  3. 友元类增加了类之间的耦合性,因为友元类可以直接访问另一个类的私有成员和保护成员,这可能会降低代码的封装性和可维护性。因此,在使用友元类时需要谨慎考虑设计和安全性。

友元类在某些情况下可以提供灵活性和方便性,但应该避免滥用,以确保代码的安全性和可维护性。

17 . 泛型编程

面向对象编程关注的是编程数据的方向,而泛型编程关心的是算法。它们之间的共同点是抽象和创建可重用代码,但它们的理念绝然不同。

泛型编程旨在编写独立于数据类型的代码。在 C++中,完成通用程序的工具是模板。当然,模板使得能够按泛型定义函数或类,而 STL 通过通用算法更进了一步。模板让这一切成为可能,但必须对元素进行仔细地设计。为解模板和设计是如何协同工作的,来看一看需要迭代器的原因

为什么使用迭代器

理解迭代器是理解 STL 的关键所在。模板使得算法独立于存储的数据类型,而迭代器使算法独立于使用的容器类型。因此,它们都是 STL 通用方法的重要组成部分。 为了解为何需要迭代器,我们来看如何为两种不同数据表示实现 find 函数,然后来看如何推广这种方法。首先看一个在 double 数组中搜索特定值的函数,可以这样编写该函数

double * find_ar(double * ar, int n, const double & val)
{for (int i =0 ; i <n; i++)
  if (ar[i] == val)
  return &ar[i];
  return 0; // or, in C++11, return nullptr;
}

假设有一个指向链表第一个节点的指针,每个节点的 p_next 指针都指向下一个节点,链表最后一个节点的 p_next 指针被设置为 0, 则可以这样编写 find( )函数

Node* find_ll(Node * head, const double & val)
{
Node * start;
for (start = head;start!= 0; start = start->p_next)
  if (start->item == val)
    return start;
  return 0;
}

同样,也可以使用模板将这种算法推广到支持==运算符的任何数据类型的链表。然而,这种算法也是与特定的数据结构——链表关联在一起。

从实现细节上看,这两个 find 函数的算法是不同的:一个使用数组索引来遍历元素,另一个则将 start重置为 start->p_next。但从广义上说,这两种算法是相同的:将值依次与容器中的每个值进行比较,直到找到匹配的为止。

泛型编程旨在使用同一个 find 函数来处理数组、链表或任何其他容器类型。即函数不仅独立于容器中存储的数据类型,而且独立于容器本身的数据结构。模板提供了存储在容器中的数据类型的通用表示,因此还需要遍历容器中的值的通用表示,迭代器正是这样的通用表示。

要实现 find 函数,迭代器应具备哪些特征呢?下面是一个简短的列表

  • 应能够对迭代器执行解除引用的操作,以便能够访问它引用的值。即如果 p 是一个迭代器,则应对*p 进行定义

  • 应能够将一个迭代器赋给另一个。即如果 p 和 q 都是迭代器,则应对表达式 p=q 进行定义

  • 应能够将一个迭代器与另一个进行比较,看它们是否相等。即如果 p 和 q 都是迭代器,则应对p= =q 和 p!=q 进行定义

  • 应能够使用迭代器遍历容器中的所有元素,这可以通过为迭代器 p 定义++p 和 p++ 来实现

迭代器也可以完成其他的操作,但有上述功能就足够了,至少对于 find 函数是如此。实际上,STL 按功能的强弱定义了多种级别的迭代器,这将在后面介绍。顺便说一句,常规指针就能满足迭代器的要求,因此,可以这样重新编写 find_arr()函数 :

typedef double * iterator;
iterator find_ar(iterator ar, int n, const double & val)
{
    for (int i = 0; i <n; i++, ar++)
    if (*ar == val)
        return ar;
    return 0;
}

然后可以修改函数参数,使之接受两个指示区间的指针参数,其中的一个指向数组的起始位置,另一个指向数组的超尾 (程序清单 7.8 与此类似);同时函数可以通过返回尾指针,来指出没有找到要找的值。下面的 find_ar( )版本完成了这些修改

typedef double * iterator;
iterator find_ar(iterator ar, int n, const double & val)
{
    iterator ar;
    for (ar = begin;ar != end; ar++)
    if (*ar == val)
        return ar;
    return end; // indicates val not found
}

设计一个迭代器

#include <iostream>

// 定义容器类 MyContainer
class MyContainer {
private:
    int data[5] = {1, 2, 3, 4, 5};

public:
    // 定义迭代器类 MyIterator
    class MyIterator {
    private:
        int* ptr;

    public:
        MyIterator(int* p) : ptr(p) {}

        // 迭代器的解引用操作符 *
        int& operator*() {
            return *ptr;
        }

        // 迭代器的递增操作符 ++
        MyIterator& operator++() {
            ptr++;
            return *this;
        }

        // 迭代器的比较操作符 !=
        bool operator!=(const MyIterator& other) const {
            return ptr != other.ptr;
        }
    };

    MyIterator begin() {
        return MyIterator(data);
    }

    MyIterator end() {
        return MyIterator(data + 5);
    }
};

int main() {
    MyContainer container;

    // 使用迭代器遍历容器中的元素并输出
    for (MyContainer::MyIterator it = container.begin(); it != container.end(); ++it) {
        std::cout << *it << " ";
    }
    std::cout << std::endl;

    return 0;
}

18 . 右值引用和移动语义

右值引用

右值引用是 C++11 中引入的一种新类型的引用,用于表示对临时对象(右值)的引用。在 C++ 中,表达式可以分为左值和右值:

  • 左值(lvalue):指向内存中一个确定的位置的表达式,通常是具名的变量或对象。

  • 右值(rvalue):不能被取地址的表达式,通常是临时对象、字面常量或表达式的计算结果。

右值引用的语法是在类型名后添加 &&,例如 int&& 表示一个右值引用。右值引用允许我们对右值进行引用绑定,从而可以实现移动语义和完美转发。

右值引用主要用于以下两个方面:

  1. 移动语义(Move Semantics):通过将资源的所有权从一个对象转移到另一个对象,避免不必要的资源复制和销毁,提高程序性能。移动构造函数和移动赋值运算符通常使用右值引用。

  2. 完美转发(Perfect Forwarding):在模板编程中,允许将参数按原样转发给其他函数,包括保留参数的值类别(左值或右值)。这在实现通用代码时非常有用。

下面是一个简单的示例,展示了右值引用的使用:

#include <iostream>

void processInt(int&& x) {
    std::cout << "Received rvalue reference: " << x << std::endl;
}

int main() {
    int a = 5; // 左值
    processInt(10); // 传递右值

    return 0;
}

在这个示例中,processInt 函数接受一个右值引用参数 int&& x。在 main 函数中,我们调用 processInt(10),传递一个右值 10 给 processInt 函数。右值 10 被绑定到 int&& x,并输出 "Received rvalue reference: 10"。

移动语义

移动语义是 C++11 中引入的一项重要特性,旨在提高程序的性能和资源利用效率。移动语义允许将资源(如堆上分配的内存、文件句柄等)从一个对象“移动”到另一个对象,而不是传统的复制操作。这主要通过右值引用和移动构造函数、移动赋值运算符来实现。

传统的复制操作会导致资源的深度复制,包括申请新的内存、复制数据等,这在处理临时对象或资源管理类时可能会带来性能开销。移动语义的引入可以避免不必要的资源复制,提高程序的效率。

移动语义的实现依赖于右值引用。右值引用允许我们区分左值和右值,并通过将资源的所有权从一个对象“窃取”到另一个对象来实现高效的资源转移。移动构造函数和移动赋值运算符通常会接受右值引用参数,并在资源转移后将原对象的资源置为空,避免资源重复释放。

使用移动语义可以提高性能,特别是在涉及大规模数据结构或资源管理的情况下。移动语义的典型应用包括:

  1. 移动语义在标准库中的应用,如移动语义对容器类的性能优化(如 std::vector、std::string)。

  2. 在自定义类中实现移动构造函数和移动赋值运算符,以提高对象的性能和效率。

总的来说,移动语义是 C++11 引入的一个重要特性,通过允许资源的高效转移,提高了程序的性能和资源利用效率。

例子

vector 和 string 类都使用动态内存分配,因此它们必须定义使用某种 new 版本的复制构造函数。为初始化对象 vstr copyl , 复制构造函数 vector<string>将使用 new 给20000 个 string 对象分配内存,而每个 string 对象又将调用 string 的复制构造函数,该构造函数使用 new 为 1000 个字符分配内存。接下来,全部 20000000 个字符都将从 vstr 控制的内存中复制到 vstr_copyl 控制的内存中。这里的工作量很大,但只要妥当就行

但这确实妥当吗?有时候答案是否定的。例如,假设有一个函数,它返回一个 vector<string>对象:


std::vector<std::string> allcaps(std::vector<std::string> & words) {
    std::vector<std::string> result;
    for (auto word : words) {
        result.push_back(word); // use copy constructor
    }
    return result;
}

接下来使用以下方式使用

vector<string> vstr;
〃build up a vector of 20,000 strings, each of 1000 characters
vector<string> vstr__copyl(vstr); // #1
vector<string> vstr_copy2(allcaps(vstr)); // #2

从表面上看,语句#1 和#2 类似,它们都使用一个现有的对象初始化一个 vector<string>对象。如果深入探索这些代码,将发现 allcaps( )创建了对象 temp, 该对象管理着 20000000 个字符;vector 和 string 的复制构造函数创建这 20000000 个字符的副本,然后程序删除 allcaps( )返回的临时对象(迟钝的编译器甚至可能将 temp 复制给一个临时返回对象,删除 temp, 再删除临时返回对象)。这里的要点是,做了大量的无用功。考虑到临时对象被删除了,如果编译器将对数据的所有权直接转让给 vstr_copy2, 不是更好吗?也就是说,不将 20000000 个字符复制到新地方,再删除原来的字符,而将字符留在原来的地方,并将 vstr_copy2 与之相关联。这类似于在计算机中移动文件的情形:实际文件还留在原来的地方,而只修改记录。这种方法被称为移动语义 (move semantics)。有点悖论的是,移动语义实际上避免了移动原始数据,而只是修改了记录。

要实现移动语义,需要采取某种方式,让编译器知道什么时候需要复制,什么时候不需要。这就是右值引用发挥作用的地方。可定义两个构造函数。其中一个是常规复制构造函数,它使用 const 左值引用作为参数,这个引用关联到左值实参,如语句#1 中的 vstr; 另一个是移动构造函数,它使用右值引用作为参数,该引用关联到右值实参,如语句#2 中 allcaps(vstr)的返回值。复制构造函数可执行深复制,而移动构造函数只调整记录。在将所有权转移给新对象的过程中,移动构造函数可能修改其实参,这意味着右值引用参数不应是 const。

一个移动示例

#include <iostream>
#include <string>

class MyString {
private:
    std::string* data;

public:
    // 默认构造函数
    MyString() : data(nullptr) {}

    // 移动构造函数
    MyString(MyString&& other) noexcept : data(other.data) {
        other.data = nullptr;
    }

    // 析构函数
    ~MyString() {
        if (data != nullptr) {
            delete data;
        }
    }

    // 设置字符串数据
    void setData(const std::string& str) {
        if (data == nullptr) {
            data = new std::string(str);
        } else {
            *data = str;
        }
    }

    // 打印字符串数据
    void printData() {
        if (data != nullptr) {
            std::cout << *data << std::endl;
        } else {
            std::cout << "No data" << std::endl;
        }
    }
};

int main() {
    MyString str1;
    str1.setData("Hello");

    MyString str2 = std::move(str1); // 使用 std::move 将 str1 转为右值

    std::cout << "str1: ";
    str1.printData(); // 输出 "No data"

    std::cout << "str2: ";
    str2.printData(); // 输出 "Hello"

    return 0;
}

在这个示例中,MyString 类管理一个动态分配的字符串数据。在 main 函数中,我们创建了一个 MyString 对象 str1,设置其数据为 "Hello"。然后,我们使用 std::move 将 str1 转换为右值,并将其移动构造到另一个对象 str2 中。

由于移动构造函数的实现,str2 接管了 str1 的资源,而 str1 的资源被置为空。因此,最终输出显示 str1 没有数据,而 str2 包含原先的数据 "Hello"。

这个示例展示了移动语义和右值引用的工作原理,通过转移资源的所有权而不是复制,提高了程序的性能和效率。

如何让移动语义发生

首先,右值引用让编译器知道何时可使用移动语义

Useless two = one; // matches Useless::Useless(const Useless &)

Useless four (one + three); // matches Useless::Useless(Useless &&)

对象 one 是左值,与左值引用匹配,而表达式 one + three 是右值,与右值引用匹配。因此,右值引用让编译器使用移动构造函数来初始化对象 fbur。实现移动语义的第二步是,编写移动构造函数,使其提供所需的行为。

总之,通过提供一个使用左值引用的构造函数和一个使用右值引用的构造函数,将初始化分成了两组。使用左值对象初始化对象时,将使用复制构造函数,而使用右值对象初始化对象时,将使用移动构造函数。程序员可根据需要赋予这些构造函数不同的行为。

完美转发

完美转发(perfect forwarding)是 C++11 中引入的一个特性,旨在解决函数模板中参数传递时的类型保持和引用折叠问题。完美转发允许将参数传递给另一个函数,同时保持原始参数的值类别(左值或右值)和 const 修饰符。

在 C++ 中,通常情况下,当我们将参数传递给另一个函数时,参数的值类别和 const 修饰符可能会发生改变。通过使用完美转发,我们可以确保传递的参数的值类别和 const 修饰符保持不变。

完美转发通常与模板和 std::forward 结合使用。std::forward 是一个用于在函数模板中进行完美转发的标准库函数,它会根据传递的参数类型和值类别,正确地保持参数的类型和值类别。

以下是一个简单的示例,演示了完美转发的用法:

#include <iostream>
#include <utility>

// 模板函数,实现完美转发
template <typename T>
void forwarder(T&& arg) {
    anotherFunction(std::forward<T>(arg));
}

// 另一个函数,接受参数并打印
void anotherFunction(int& arg) {
    std::cout << "Lvalue: " << arg << std::endl;
}

void anotherFunction(int&& arg) {
    std::cout << "Rvalue: " << arg << std::endl;
}

int main() {
    int x = 42;

    forwarder(x); // 传递左值
    forwarder(100); // 传递右值

    return 0;
}

在这个示例中,forwarder 函数是一个模板函数,使用了完美转发,它接受任意类型的参数并将其传递给 anotherFunction。anotherFunction 包含了两个重载版本,一个接受左值引用,另一个接受右值引用。

在 main 函数中,我们分别调用 forwarder 函数传递一个左值 x 和一个右值 100。通过完美转发,参数的值类别被正确保持,从而调用了对应的 anotherFunction 版本,分别打印出 "Lvalue: 42" 和 "Rvalue: 100"。

这个示例展示了完美转发的用法,通过保持参数的值类别和 const 修饰符,可以有效地传递参数并保持其原始特性。

19 . 编译程序生成可执行文件的几个阶段

在C++编译程序生成可执行文件的过程中,通常也会经历类似的四个阶段,这些阶段包括:

  1. 预处理阶段(Preprocessing Stage):在这个阶段,预处理器会处理源文件,包括展开宏定义、包含头文件、去除注释等。预处理器生成一个经过预处理的中间文件。

  2. 编译阶段(Compilation Stage):在这个阶段,编译器会将预处理后的文件转换成汇编代码。编译器会进行词法分析、语法分析、语义分析等操作,生成中间表示(比如汇编代码)。

  3. 汇编阶段(Assembly Stage):在这个阶段,汇编器会将编译器生成的汇编代码转换成机器代码(二进制指令)。汇编器会生成目标文件,其中包含机器代码、符号表等信息。

  4. 链接阶段(Linking Stage):在这个阶段,链接器会将各个目标文件(包括库文件)以及所需的库函数链接在一起,生成最终的可执行文件。链接器会解析符号引用、地址重定位等操作,最终生成可执行文件。

这些阶段构成了C++程序从源代码到可执行文件的完整编译过程。每个阶段都有特定的任务,确保最终生成的可执行文件可以在目标平台上正确运行

20 . 指针和引用的区别

指针

  1. 指针是一个变量,其存储的是另一个变量的内存地址。

  2. 指针可以被重新赋值指向不同的变量。

  3. 指针可以为空(null),表示不指向任何有效的内存地址。

  4. 指针需要使用解引用运算符(*)来访问所指向的变量。

  5. 指针可以进行算术运算,如指针加法和指针减

引用

  • 引用是变量的别名,它在声明时必须初始化,并且一旦初始化后就不能再指向其他变量。

  • 引用不占用额外的内存,因为它只是变量的别名。

  • 引用在使用时不需要解引用运算符,因为它们本身就是变量的别名。

  • 引用通常用于函数参数传递,可以避免复制大型对象的开销。

21 . 拷贝构造函数和移动构造函数

拷贝构造函数(Copy Constructor):

  1. 定义:拷贝构造函数是一种特殊的构造函数,用于创建一个新对象,其内容是另一个同类对象的副本。

  2. 语法:通常形式为ClassName(const ClassName &source)。

  3. 用途:主要用于在创建对象时,通过复制另一个对象的数据来初始化新对象。

  4. 示例:

class MyClass {
public:
    // Copy Constructor
    MyClass(const MyClass &source) {
        // Copy data from source to this object
    }
};

移动构造函数(Move Constructor)

  1. 定义:移动构造函数是C++11引入的新特性,用于将资源从一个对象“移动”到另一个对象,而不是进行深层复制。

  2. 语法:通常形式为ClassName(ClassName &&source),其中&&表示右值引用。

  3. 用途:主要用于提高性能,避免不必要的资源复制,特别是对于临时对象的处理。

  4. 示例:

    class MyResource {
        // Constructor, Destructor, and other members
    };
    
    class MyClass {
    public:
        MyResource *data;
    
        // Move Constructor
        MyClass(MyClass &&source) noexcept : data(source.data) {
            source.data = nullptr;
        }
    };

什么情况下必须使用拷贝构造函数,而不是移动构造呢

在某些情况下,必须使用拷贝构造函数而不是移动构造函数。以下是一些情况:

  1. 资源共享:如果一个类的对象在多个地方共享同一资源(如共享指针),在这种情况下,使用移动构造函数可能会导致资源被移动到新对象后,原对象和新对象共享同一资源,可能会导致资源管理问题。

  2. 需要保留源对象的状态:如果在移动对象后,源对象的状态需要保留(例如,源对象在后续代码中仍然需要使用),那么使用拷贝构造函数来创建新对象的副本是更合适的选择。

  3. 深层复制:如果资源是不可共享的,或者移动资源后源对象必须保持不变,这种情况下需要执行深层复制,因此需要使用拷贝构造函数。

  4. 异常安全性:在某些情况下,移动构造函数可能会抛出异常,而拷贝构造函数通常不会。如果需要确保对象的复制是异常安全的,可能需要使用拷贝构造函数。

  5. 性能考虑:尽管移动构造函数通常比拷贝构造函数更高效,但在某些情况下,由于特定的资源管理方式或对象状态,拷贝构造函数可能更合适。

总的来说,选择使用拷贝构造函数还是移动构造函数取决于具体情况和设计需求。在设计类时,需要考虑对象的资源管理方式、对象的共享性质以及对异常安全性和性能的需求,以确定何时使用拷贝构造函数或移动构造函数。

22 . 虚函数

虚函数在内存中的分布

class A {
public:
    virtual void v_a(){}
    virtual ~A(){}
    int64_t _m_a;
};
int main()
{
    A* a = new A();
    return 0;
}

定义一个类A,那么它在内存中分布的情况是什么样的呢?接下来一起看看

  • 首先在主函数的栈帧上有一个 A 类型的指针指向堆里面分配好的对象 A 实例。

  • 对象 A 实例的头部是一个 vtable 指针,紧接着是 A 对象按照声明顺序排列的成员变量。(当我们创建一个对象时,便可以通过实例对象的地址,得到该实例的虚函数表,从而获取其函数指针。)

  • vtable 指针指向的是代码段中的 A 类型的虚函数表中的第一个虚函数起始地址。

  • 虚函数表的结构其实是有一个头部的,叫做 vtable_prefix ,紧接着是按照声明顺序排列的虚函数。

  • 注意到这里有两个虚析构函数,因为对象有两种构造方式,栈构造和堆构造,所以对应的,对象会有两种析构方式,其中堆上对象的析构和栈上对象的析构不同之处在于,栈内存的析构不需要执行 delete 函数,会自动被回收。

  • typeinfo 存储着 A 的类基础信息,包括父类与类名称,C++关键字 typeid 返回的就是这个对象。

  • typeinfo 也是一个类,对于没有父类的 A 来说,当前 tinfo 是 class_type_info 类型的,从虚函数指针指向的vtable 起始位置可以看出。

虚函数表实现原理

虚函数表是一个指向虚函数的指针数组,每个带有虚函数的类都有一个对应的虚函数表。

虚函数指针

其本质就是一个指向函数的指针,与普通的函数指针并没有什么大的区别。它指向程序员自己定义的虚函数,当子类调用虚函数的时候,实际上就是通过调用这个虚函数指针从而找到接口。

虚函数指针是一个真实存在的数据类型,在对象实例化的时候,放在这个对象地址的首位,目的就是为了保证运行的快速性。与对象的成员函数不一样的是,虚函数指针对外部是完全不可见的,除非直接访问地址或者是debug模式,否则它是不能被外部调用的。

只有拥有虚函数的类才能拥有虚函数指针,每个虚函数都会对应一个虚函数指针。那么,拥有虚函数的类都会产生额外的开销,并且也会在一定程度上影响程序的运行速度。

虚函数表

当一个类包含虚函数时,编译器会在该类的对象中添加一个指向虚函数表的指针。这个指针通常位于对象的内存布局的开头(虚指针),它们按照一定的顺序组织起来就会构成一个表状结构,叫做虚函数表。虚函数表本身是一个全局的、类特定的数组,其中包含了该类中所有虚函数的地址。

先来定义一个基类:

class Panent
{
public:
    virtual void A(){cout<<"Panent::A"<<endl;}
    virtual void B(){cout<<"Panent::B"<<endl;}
    virtual void C(){cout<<"Panent::C"<<endl;}
};

对于基类Base的虚函数表记录的只有自己定义的虚函数

下来再看看子类:

class Children: public Panent
{
public:
    virtual void A(){cout<<"Children::A"<<endl;}
    virtual void B1(){cout<<"Children::B1"<<endl;}
    virtual void C1(){cout<<"Children::C1"<<endl;}
}

最常见的继承,就是子类对基类的虚函数进行覆盖继承

此时的虚函数表:

基函数的表项仍然会保留,而得到正确继承的虚函数的指针将会被覆盖,而子类自己的虚函数将跟在表后。

当多继承的时候,表项将会增多,顺序将会体现为继承的顺序,那么子类的虚函数就跟在第一个表项后。

C++中一个类是公用一个虚函数表的,基类有基类的虚函数表,子类有子类的虚函数表,这样极大的节省了内存。

虚表指针

为了指定对象的虚表,对象内部包含一个虚表的指针,来指向自己所使用的虚表。为了让每个包含虚表的类的对象都拥有一个虚表指针,在编译阶段,编译器在类中添加了一个指针*__vptr,用来指向虚表。这样,当类的对象在创建时便拥有了这个指针,且这个指针的值会自动被设置为指向类的虚表,*__vptr一般在对象内存分布的最前面。

虚表指针的初始化确实发生在构造函数的调用过程中, 但是在执行构造函数体之前,即进入到构造函数的"{“和”}"之前。为了更好的理解这一问题, 我们可以把构造函数的调用过程细分为两个阶段,即:

  • 进入到构造函数体之前。在这个阶段如果存在虚函数的话,虚表指针被初始化。如果存在构造函数的初始化列表的话,初始化列表也会被执行。

  • 进入到构造函数体内。这一阶段是我们通常意义上说的构造函数。

带缺省参数的虚函数

当缺省参数和虚函数一起出现的时候情况有点复杂,极易出错。我们知道,虚函数是动态绑定的,但是为了执行效率,缺省参数是静态绑定的。

23. malloc 和 free 如何知道释放内存具体大小

它们并不存储关于分配内存块大小的信息。这是因为这两个函数设计时的出发点是提供一个轻量级的、固定的内存管理机制,以降低运行时的开销。

这也就意味着,一旦你使用 malloc 分配了内存,你需要自行追踪该内存块的大小。有几种常见的方法来做到这一点:

  1. 固定大小分配:在分配内存之前,你可以明确知道所需的内存块大小,然后使用这个大小来分配内存。在释放内存时,你可以使用相同的大小信息。

size_t block_size = 100; // 假设内存块大小是100字节
  1. 分配包含大小信息的内存块:在实际分配的内存块前存储大小信息。这是一种常见的做法,通常称为'头信息'。这样,你可以在释放内存时,根据头信息来确定内存块的大小。

size_t block_size = 100;
  1. 自定义内存分配器:你可以实现自己的内存分配器,该分配器跟踪分配的内存块的大小。这对于需要灵活内存管理的情况非常有用。

需要注意的是,使用mallocfree时,确保精确地跟踪内存块的大小至关重要,以避免内存泄漏和未定义的行为。

24. const A* 和 A* const的区别

const A*(指向常量对象的指针)

  • 含义:指针指向的对象是常量(不可修改),但指针本身可以指向其他地址。

  • 特点

    • 不能通过这个指针修改指向的对象(对象内容不可变)。

    • 指针本身可以指向其他地址(指针可变)。

示例

const A* ptr = &a;
ptr = &b;     // 合法:指针可以指向其他地址
// *ptr = ... // 非法:不能通过 ptr 修改指向的对象

2. A* const(常量指针)

  • 含义:指针本身是常量(不可修改),但可以通过它修改指向的对象。

  • 特点

    • 指针的地址固定(不可指向其他地址)。

    • 可以通过指针修改指向的对象(对象内容可变)。

示例

A* const ptr = &a;
// ptr = &b;   // 非法:指针不能指向其他地址
*ptr = value;  // 合法:可以修改指向的对象

记忆技巧

  • const* 左侧const A*):修饰的是指向的对象(对象不可变)。

  • const* 右侧A* const):修饰的是指针本身(指针不可变)。


组合用法

还可以同时限制指针和对象:

const A* const ptr = &a; // 指针不可变,指向的对象也不可变
// ptr = &b;    // 非法
// *ptr = ...  // 非法

应用场景

  • const A*:用于函数参数,表示函数不会修改传入的对象。

  • A* const:较少用于函数参数,通常用于局部变量或成员变量,强制指针固定指向某个对象。

理解这一区别有助于避免因误用 const 导致的编译错误或逻辑错误。


本站由 困困鱼 使用 Stellar 创建。