3、C++高频面试真题|21-30

厨子大约 32 分钟编程语言原创技术面试题解析C++面试程序员求职程序厨

C++ 面试真题(21-30)

21. 🌟左值和右值的区别?

回答重点

什么是左值?什么是右值?

  • 左值:可以出现在赋值运算符的左边,并且可以被取地址,通常是有名字的变量
  • 右值:不能出现在赋值运算符的左边,不可以被取地址,表示一个具体的数据值,通常是常量、临时变量

一般可以从两个方向区分左值和右值。

方向 1:

  • 左值:可以放到等号左边的东西叫左值。
  • 右值:不可以放到等号左边的东西就叫右值。

方向 2:

  • 左值:可以取地址并且有名字的东西就是左值。
  • 右值:不能取地址的没有名字的东西就是右值。

示例:

int a = b + c;

a 是左值,有变量名,可以取地址,也可以放到等号左边, 表达式 b+c 的返回值是右值,没有名字且不能取地址,&(b+c)不能通过编译,而且也不能放到等号左边。

int a = 4; // a是左值,4作为普通字面量是右值

扩展知识

左值引用

可以理解为是对左值的引用。对于左值引用,等号右边的值必须可以取地址,如果不能取地址,则会编译失败,或者可以使用 const 引用形式,但这样就只能通过引用来读取输出,不能修改数组,因为是常量引用。

示例代码:

int a = 5;
int &b = a; // b是左值引用
b = 4;
int &c = 10; // error,10无法取地址,无法进行引用
const int &d = 10; // ok,因为是常引用,引用常量数字,这个常量数字会存储在内存中,可以取地址
右值引用

可以理解为是对右值的引用。即对一个临时对象或者即将销毁的对象的引用,开发者可以利用这些临时对象,却不需要复制它们。

如果使用右值引用,那表达式等号右边的值需要是右值,可以使用 std::move 函数强制把左值转换为右值。

int a = 4;
int &&b = a; // error, a是左值
int &&c = std::move(a); // ok
左值引用和右值引用的使用场景
  • 左值引用:当你需要修改对象的值,或者需要引用一个持久对象时使用。
  • 右值引用:当你需要处理一个临时对象,并且想要避免复制,或者实现移动语义时使用。
纯右值

纯右值属于右值。运算表达式产生的临时变量、不和对象关联的原始字面量、非引用返回的临时变量、lambda 表达式等都是纯右值。

举例:

  • 除字符串字面值外的字面值
  • 返回非引用类型的函数调用
  • 后置自增自减表达式 i++、i--
  • 算术表达式(a+b, a*b, a&&b, a==b 等)
  • 取地址表达式等(&a)

22. 介绍下移动语义与完美转发

回答重点

移动语义和完美转发都是 C++11 引入的新特性。

移动语义

一种优化资源管理的机制。常规的资源管理是拷贝别人的资源。而移动语义是转移所有权,转移了资源而不是拷贝资源,性能会更好。

移动语义通常用于那些比较大的对象,搭配移动构造函数或移动赋值运算符来使用。

示例代码:

class A {
public:
    A(int size) : size_(size) {
        data_ = new int[size];
    }
    A(){}
    A(const A& a) {
        size_ = a.size_;
        data_ = new int[size_];
        cout << "copy " << endl;
    }
    A(A&& a) {
        this->data_ = a.data_;
        a.data_ = nullptr;
        cout << "move " << endl;
    }
    ~A() {
        if (data_ != nullptr) {
         delete[] data_;
        }
    }
    int *data_;
    int size_;
};
int main() {
    A a(10);
    A b = a;
    A c = std::move(a); // 调用移动构造函数
    return 0;
}

如果不使用 std::move,会有很大的拷贝代价,使用移动语义可以避免很多无用的拷贝,提供程序性能,C++ 所有的 STL 都实现了移动语义,方便我们使用。例如:

std::vector<string> vecs;
...
std::vector<string> vecm = std::move(vecs); // 免去很多拷贝
完美转发

完美转发指可以写一个接受任意实参的函数模板,并转发到其它函数,目标函数会收到与转发函数完全相同的实参,转发函数实参是左值那目标函数实参也是左值,转发函数实参是右值那目标函数实参也是右值。

那如何实现完美转发呢?答案是使用 ``std::forward,可参考以下代码:

void PrintV(int &t) {
    cout << "lvalue" << endl;
}

void PrintV(int &&t) {
    cout << "rvalue" << endl;
}

template<typename T>
void Test(T &&t) {
    PrintV(t);
    PrintV(std::forward<T>(t));

    PrintV(std::move(t));
}

int main() {
    Test(1); // lvalue rvalue rvalue
    int a = 1;
    Test(a); // lvalue lvalue rvalue
    
    Test(std::forward<int>(a)); // lvalue rvalue rvalue
    
    Test(std::forward<int&>(a)); // lvalue lvalue rvalue
    
    Test(std::forward<int&&>(a)); // lvalue rvalue rvalue
    return 0;
}
  • image-20250511160309828
    image-20250511160309828

扩展知识

深拷贝与浅拷贝

示例代码:

class A {
public:
    A(int size) : size_(size) {
        data_ = new int[size];
    }
    A(){}
    A(const A& a) {
        size_ = a.size_;
        data_ = a.data_;
        cout << "copy " << endl;
    }
    ~A() {
        delete[] data_;
    }
    int *data_;
    int size_;
};
int main() {
    A a(10);
    A b = a;
    cout << "b " << b.data_ << endl;
    cout << "a " << a.data_ << endl;
    return 0;
}

上面代码中,两个输出的是相同的地址,a 和 b 的 data_ 指针指向了同一块内存,这就是浅拷贝,只是数据的简单赋值,那在析构时 data_ 内存会被释放两次,如何消除这种隐患呢,可以使用如下深拷贝:

class A {
public:
    A(int size) : size_(size) {
        data_ = new int[size];
    }
    A(){}
    A(const A& a) {
        size_ = a.size_;
        data_ = new int[size_];
        cout << "copy " << endl;
    }
    ~A() {
        delete[] data_;
    }
    int *data_;
    int size_;
};
int main() {
    A a(10);
    A b = a;
    cout << "b " << b.data_ << endl;
    cout << "a " << a.data_ << endl;
    return 0;
}

深拷贝就是在拷贝对象时,如果被拷贝对象内部还有指针引用指向其它资源,自己需要重新开辟一块新内存存储资源,而不是简单的赋值。

23. 🌟C++ 中 move 有什么作用?它的原理是什么?

回答重点

move 是 C++11 引入的一个新特性,用来实现移动语义。它的主要作用是将对象的资源从一个对象转移到另一个对象,而无需进行深拷贝,减少了资源内存的分配,可提高性能。

它的原理很简单,我们直接看它的源码实现:

// move
template <class T>
LIBC_INLINE constexpr cpp::remove_reference_t<T> &&move(T &&t) {
  return static_cast<typename cpp::remove_reference_t<T> &&>(t);
}

源码取自:https://github.com/llvm/llvm-project/blob/cceedc939a43c7c732a5888364251775bffc2dba/libc/src/__support/CPP/utility/move.h##L19open in new window

从源码中你可以看到,std::move 的作用只有一个,无论输入参数是左值还是右值,都强制转成右值。

扩展知识

1)move 转成右值有什么好处?

这就涉及到移动语义的概念,右值可以触发移动语义,那什么是移动语义?我们可以理解为在对象转换的时候,通过右值可以触发到类的移动构造函数或者移动赋值函数。

因为触发了移动构造函数 或者 移动赋值函数,我们就默认,原对象后面已经不会再使用了(包括内部的某些内存),这样我们就可以在新对象中直接使用原对象的那部分内存,减少了数据的拷贝操作,昂贵的拷贝转为了廉价的移动,提升了程序的性能。

2)是不是 std::move 后的对象就没法使用了?

其实不是,还是取决于搭配的移动构造函数 和 移动赋值函数是如何实现的。

如果在移动构造函数 + 移动赋值函数中,还是使用了拷贝动作,那原对象还是可以使用的,见下面示例。

#include <chrono>
include <functional>
#include <future>
#include <iostream>
#include <string>

class A {
public:
    A() {
        std::cout << "A() \n";
    }
    
    ~A() {
        std::cout << "~A() \n";
    }
    A(const A& a) {
        count_ = a.count_;
        std::cout << "A copy \n";
    }
    A& operator=(const A& a) {
        count_ = a.count_;
        std::cout << "A = \n";
        return *this;
    }
    
    A(A&& a) {
        count_ = a.count_;
        std::cout << "A move \n";
    }
    
    A& operator=(A&& a) {
        count_ = a.count_;
        std::cout << "A move = \n";
        return *this;
    }
    
    std::string count_;
};


int main() {
    A a;
    a.count_ = "12345";
    A b = std::move(a);
    std::cout << a.count_ << std::endl;
    std::cout << b.count_ << std::endl;
    return 0;
}

如果我们在移动构造函数 + 移动赋值函数中,将原对象内部内存废弃掉,新对象使用原对象内存,那原对象的内存就不可以用了,示例代码如下:

#include <chrono>
#include <functional>
#include <future>
#include <iostream>
#include <string>

class A {
public:
    A() {
        std::cout << "A() \n";
    }
    
    ~A() {
        std::cout << "~A() \n";
    }
   
    A(const A& a) {
        count_ = a.count_;
        std::cout << "A copy \n";
    }
    
    A& operator=(const A& a) {
        count_ = a.count_;
        std::cout << "A = \n";
        return *this;
    }
    
    A(A&& a) {
        count_ = std::move(a.count_);
        std::cout << "A move \n";
    }
    
    A& operator=(A&& a) {
        count_ = std::move(a.count_);
        std::cout << "A move = \n";
        return *this;
    }
    
    std::string count_;
};


int main() {
    A a;
    a.count_ = "12345";
    A b = std::move(a);
    std::cout << a.count_ << std::endl;
    std::cout << b.count_ << std::endl;
    return 0;
}

总结:

  • std::move 函数的作用是将参数强制转换为右值。而且,只是转换为右值,并不会对对象进行任何操作。
  • 转换为右值可以触发移动语义,减少数据的拷贝操作,提升程序的性能。
  • 在使用 std::move 函数后,原对象是否可以继续使用取决于移动构造函数和移动赋值函数的实现。

24. 介绍 C++ 中三种智能指针的使用场景?

回答重点

C++ 中的智能指针主要用于管理动态分配的内存,避免内存泄漏。

C++11 标准引入了三种主要的智能指针:std::unique_ptrstd::shared_ptrstd::weak_ptr

1. std::unique_ptr

std::unique_ptr 是一种独占所有权的智能指针,意味着同一时间内只能有一个 unique_ptr 指向一个特定的对象。当 unique_ptr 被销毁时,它所指向的对象也会被销毁。

使用场景:

  • 当你需要确保一个对象只被一个指针所拥有时。
  • 当你需要自动管理资源,如文件句柄或互斥锁时。

示例代码:

#include <iostream>
#include <memory>

class Test {
public:
    Test() { std::cout << "Test::Test()"; }
    ~Test() { std::cout << "Test::~Test()"; }
    void test() { std::cout << "Test::test()"; }
};

int main() {
    std::unique_ptr<Test> 
    ptr(new Test());
    ptr->test();
    // 当ptr离开作用域时,它指向的对象会被自动销毁return 0;
}
2. std::shared_ptr

std::shared_ptr 是一种共享所有权的智能指针,多个 shared_ptr 可以指向同一个对象。内部使用引用计数来确保只有当最后一个指向对象的 shared_ptr 被销毁时,对象才会被销毁。

使用场景:

  • 当你需要在多个所有者之间共享对象时。
  • 当你需要通过复制构造函数或赋值操作符来复制智能指针时。

示例代码:

#include <iostream>
#include <memory>

class Test {
public:
    Test() { std::cout << "Test::Test()"; }
    ~Test() { std::cout << "Test::~Test()"; }
    void test() { std::cout << "Test::test()"; }
};

int main() {
    std::shared_ptr<Test> ptr1(new Test());
    std::shared_ptr<Test> ptr2 = ptr1;
    ptr1->test();
    // 当ptr1和ptr2离开作用域时,它们指向的对象会被自动销毁
    return 0;
}
3. std::weak_ptr

std::weak_ptr 是一种不拥有对象所有权的智能指针,它指向一个由 std::shared_ptr 管理的对象。weak_ptr 用于解决 shared_ptr 之间的循环引用问题。

是另外一种智能指针,它是对 shared_ptr 的补充,std::weak_ptr 是一种弱引用智能指针,用于观察 std::shared_ptr 指向的对象,而不影响引用计数。它主要用于解决循环引用问题,从而避免内存泄漏,另外如果需要追踪指向某个对象的第一个指针,则可以使用 weak_ptr。

可以考虑在对象本身中维护一个指向第一个 shared_ptr 的弱引用(std::weak_ptr)。当创建对象的第一个 shared_ptr 时,将这个 shared_ptr 赋值给对象的 weak_ptr 成员。这样,在需要时,可以通过检查对象的 weak_ptr 成员来获取指向对象的第一个 shared_ptr(如果仍然存在的话).

使用场景:

  • 当你需要访问但不拥有由 shared_ptr 管理的对象时。
  • 当你需要解决 shared_ptr 之间的循环引用问题时。
  • 注意 weak_ptr 肯定要和 shared_ptr 搭配使用。

示例代码:

#include <iostream>
#include <memory>

class Test {
public:
    Test() { std::cout << "Test::Test()"; }
    ~Test() { std::cout << "Test::~Test()"; }
    void test() { std::cout << "Test::test()"; }
};

int main() {
    std::shared_ptr<Test> sharedPtr(new Test());
    std::weak_ptr<Test> weakPtr = sharedPtr;
    
    if (auto lockedSharedPtr = weakPtr.lock()) {
        lockedSharedPtr->test();
    }// 当sharedPtr离开作用域时,它指向的对象会被自动销毁
    return 0;
}

这三种智能指针各有其用途,选择哪一种取决于你的具体需求。

扩展知识

1)智能指针方面的建议:

  • 尽量使用智能指针,而非裸指针来管理内存,很多时候利用 RAII 机制管理内存肯定更靠谱安全的多。
  • 如果没有多个所有者共享对象的需求,建议优先使用 unique_ptr 管理内存,它相对 shared_ptr 会更轻量一些。
  • 在使用 shared_ptr 时,一定要注意是否有循环引用的问题,因为这会导致内存泄漏。
  • shared_ptr 的引用计数是安全的,但是里面的对象不是线程安全的,这点要区别开。

2)为什么 std::unique_ptr 可以做到不可复制,只可移动?

因为把拷贝构造函数和赋值运算符标记为了 delete,见源码:

template <typename _Tp, typename _Tp_Deleter = default_delete<_Tp> > 
class unique_ptr {
        // Disable copy from lvalue.
        unique_ptr(const unique_ptr&) = delete;
       
        template<typename _Up, typename _Up_Deleter> 
        unique_ptr(const unique_ptr<_Up, _Up_Deleter>&) = delete;
        
        unique_ptr& operator=(const unique_ptr&) = delete;
    
     template<typename _Up, typename _Up_Deleter> 
     unique_ptr& operator=(const unique_ptr<_Up, _Up_Deleter>&) = delete;
};

3)shared_ptr 的原理:

每个 std::shared_ptr 对象包含两个成员变量:一个指向被管理对象的原始指针,一个指向引用计数块的指针(control block pointer)。

引用计数块是一个单独的内存块,引用计数块允许多个 std::shared_ptr 对象共享相同的引用计数,从而实现共享所有权。

当创建一个新的 std::shared_ptr 时,引用计数初始化为 1,表示对象当前被一个 shared_ptr 管理。

  1. 拷贝 std::shared_ptr:当用一个 shared_ptr 拷贝出另一个 shared_ptr 时,需要拷贝两个成员变量(被管理对象的原始指针和引用计数块的指针),并同时将引用计数值加 1。这样,多个 shared_ptr 对象可以共享相同的引用计数。
  2. 析构 std::shared_ptr:当 shared_ptr 对象析构时,引用计数值减 1。然后检测引用计数是否为 0。如果引用计数为 0,说明没有其他 shared_ptr 对象指向该资源,因此需要同时删除原始对象(通过调用自定义删除器,如果有的话)。

4)智能指针的缺点

  1. 性能开销,需要额外的内存来存储他们的控制块,控制块包括引用计数,以及运行时的原子操作来增加或减少引用技术,这可能导致裸指针的性能下降。
  2. 循环引用问题,如果两个对象通过成员变量 shared_ptr 相互引用,并且没有其他指针指向这两个对象中的任何一个,那么这两个对象的内存将永远不会被释放,导致内存泄露。
#include<iostream>
#include<memory>
class B; // 前向声明
class A {
public:
    std::shared_ptr<B> b_ptr;
    ~A() {
        std::cout << "A has been destroyed."<< std::endl;
    }
};
class B {
public:
    std::shared_ptr<A> a_ptr;
    ~B() {
        std::cout << "B has been destroyed."<< 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; // A 引用 B
    b->a_ptr = a; // B 引用 A
    // 由于存在循环引用,A 和 B 的析构函数将不会被调用,从而导致内存泄漏
    return 0;
}
  1. 不一定适用于所有场景:有一些容器类,内部实现依赖于裸指针,另外在考虑某些性能关键场景下,使用裸指针可能更合适。
image-20250511155712503
image-20250511155712503

25. 🌟C++11 中有哪些常用的新特性?

回答重点

C++11 新特性几乎是面试必问的一个话题,可以主要回答以下几个特性:

  • auto 类型推导
  • 智能指针
  • RAII lock
  • std::thread
  • 左值右值
  • std::function 和 lambda 表达式
auto 类型推导

auto 可以让编译器在编译时就推导出变量的类型,看代码:

auto a = 10; // 10是int型,可以自动推导出a是int

int i = 10;
auto b = i; // b是int型

auto d = 2.0; // d是double型
auto f =  { // f是啥类型?直接用auto就行
    return std::string("d");
}

利用 auto 可以通过=右边的类型推导出变量的类型。

什么时候使用 auto 呢?简单类型其实没必要使用 auto,某些复杂类型就有必要使用 auto,比如 lambda 表达式的类型,async 函数的类型等,例如:

auto func = [&] {
    cout << "xxx";
}; // 对于func你难道不使用auto吗,反正我是不关心lambda表达式究竟是什么类型。
auto asyncfunc = std::async(std::launch::async, func);
智能指针

C++11 新特性中主要有两种智能指针 std::shared_ptrstd::unique_ptr

那什么时候使用 std::shared_ptr,什么时候使用 std::unique_ptr 呢?

  • 当所有权不明晰的情况,有可能多个对象共同管理同一块内存时,要使用 std::shared_ptr
  • std::unique_ptr 强调的是独占,同一时刻只能有一个对象占用这块内存,不支持多个对象共同管理同一块内存。

两类智能指针使用方式类似,拿 std::unique_ptr 举例:

using namespace std;

struct A {
   ~A() {
       cout << "A delete" << endl;
   }
   void Print() {
       cout << "A" << endl;
   }
};

int main() {
    auto ptr = std::unique_ptr<A>(new A);
    auto tptr = std::make_unique<A>(); // error, c++11还不行,需要c++14
    std::unique_ptr<A> tem = ptr; // error, unique_ptr不允许移动,编译失败
    ptr->Print();
    return 0;
}
RAII lock

C++11 提供了两种锁封装,通过 RAII 方式可动态的释放锁资源,防止编码失误导致始终持有锁。

这两种封装是 std::lock_guardstd::unique_lock,使用方式类似,看下面的代码:

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

using namespace std;
std::mutex mutex_;

int main() {
   auto func1 = [](int k) {
       // std::lock_guard<std::mutex> lock(mutex_);
       std::unique_lock<std::mutex> lock(mutex_);
       for (int i = 0; i < k; ++i) {
           cout << i << " ";
      }
      cout << endl;
  };
   std::thread threads[5];
   for (int i = 0; i < 5; ++i) {
       threads[i] = std::thread(func1, 200);
  }
   for (auto& th : threads) {
       th.join();
  }
   return 0;
}

普通情况下建议使用 std::lock_guard,因为 std::lock_guard 更加轻量级,但如果用在条件变量的 wait 中环境中,必须使用 std::unique_lock。

std::thread

什么是多线程这里就不过多介绍,新特性关于多线程最主要的就是 std::thread 的使用,它的使用也很简单,看代码:

#include <iostream>
#include <thread>

using namespace std;

int main() {
    auto func =  {
        for (int i = 0; i < 10; ++i) {
            cout << i << " ";
      }
        cout << endl;
  };
   std::thread t(func);
   if (t.joinable()) {
       t.detach();
  }
   auto func1 = [](int k) {
       for (int i = 0; i < k; ++i) {
          cout << i << " ";
      }
       cout << endl;
  };
   std::thread tt(func1, 20);
   if (tt.joinable()) { // 检查线程可否被join
       tt.join();
  }
   return 0;
}

这里记住,std::thread 在其对象生命周期结束时必须要调用 join() 或者 detach(),否则程序会 terminate(),这个问题在 C++20 中的 std::jthread 得到解决,但是 C++20 现在多数编译器还没有完全支持所有特性,先暂时了解下即可,项目中没必要着急使用。

左值右值

关于左值和右值,有两种方式理解:

概念 1:

左值:可以放到等号左边的东西叫左值。

右值:不可以放到等号左边的东西就叫右值。

概念 2:

左值:可以取地址并且有名字的东西就是左值。

右值:不能取地址的没有名字的东西就是右值。

std::function 和 lambda 表达式

这两个可以说是很常用的特性,使用它们会让函数的调用相当方便。使用 std::function 可以完全替代以前那种繁琐的函数指针形式。

还可以结合 std::bind 一起使用,直接看一段示例代码:

std::function<void(int)> f; // 这里表示function的对象f的参数是int,返回值是void
#include <functional>
#include <iostream>

struct Foo {
   Foo(int num) : num_(num) {}
   void print_add(int i) const { std::cout << num_ + i << '\n'; }
   int num_;
};

void print_num(int i) { std::cout << i << '\n'; }

struct PrintNum {
   void operator()(int i) const { std::cout << i << '\n'; }
};

int main() {
   // 存储自由函数
   std::function<void(int)> f_display = print_num;
   f_display(-9);
  
   // 存储 lambda
   std::function<void()> f_display_42 =  { print_num(42); };
   f_display_42();
   
   // 存储到 std::bind 调用的结果
   std::function<void()> f_display_31337 = std::bind(print_num, 31337);
   f_display_31337();
   
   // 存储到成员函数的调用
   std::function<void(const Foo&, int)> f_add_display = &Foo::print_add;const Foo foo(314159);
   f_add_display(foo, 1);
   f_add_display(314159, 1);
   
   // 存储到数据成员访问器的调用
   std::function<int>(Foo const&)> f_num = &Foo::num_;std::cout << "num_: " << f_num(foo) << '\n';
   
   // 存储到成员函数及对象的调用
   using std::placeholders::_1;std::function<void(int)> f_add_display2 = std::bind(&Foo::print_add, foo, _1);
   f_add_display2(2);
   
   // 存储到成员函数和对象指针的调用
   std::function<void(int)> f_add_display3 = std::bind(&Foo::print_add, &foo, _1);
   f_add_display3(3);
   
   // 存储到函数对象的调用
   std::function<void(int)> f_display_obj = PrintNum();
   f_display_obj(18);
}

从上面可以看到 std::function 的使用方法,当给 std::function 填入合适的参数表和返回值后,它就变成了可以容纳所有这一类调用方式的函数封装器。std::function 还可以用作回调函数,或者在 C++ 里如果需要使用回调那就一定要使用 std::function,特别方便。

lambda 表达式可以说是 C++11 引入的最重要的特性之一,它定义了一个匿名函数,可以捕获一定范围的变量在函数内部使用,一般有如下语法形式:

auto func = [capture] (params) opt -> ret { func_body; };

其中 func 是可以当作 lambda 表达式的名字,作为一个函数使用,capture 是捕获列表,params 是参数表,opt 是函数选项(mutable 之类), ret 是返回值类型,func_body 是函数体。

看下面这段使用 lambda 表达式的示例:

auto func1 = [](int a) -> int { return a + 1; }; 
auto func2 = [](int a) { return a + 2; }; 
cout << func1(1) << " " << func2(2) << endl;

std::functionstd::bind 使得我们平时编程过程中封装函数更加的方便,而 lambda 表达式将这种方便发挥到了极致,可以在需要的时间就地定义匿名函数,不再需要定义类或者函数等,在自定义 STL 规则时候也非常方便,让代码更简洁,更灵活,提高开发效率。

扩展知识

std::chrono

chrono 很强大,平时的打印函数耗时,休眠某段时间等,都可使用 chrono

C++11 中引入了 durationtime_pointclocks,在 C++20 中还进一步支持了日期和时区。这里简要介绍下 C++11 中的这几个新特性。

duration

std::chrono::duration 表示一段时间,常见的单位有 s、ms 等,示例代码:

// 拿休眠一段时间举例,这里表示休眠100ms
std::this_thread::sleep_for(std::chrono::milliseconds(100));

sleep_for 里面其实就是 std::chrono::duration,表示一段时间,实际是这样:

typedef duration<int64_t, milli> milliseconds;
typedef duration<int64_tseconds;

duration 具体模板如下:

template <class Rep, class Period = ratio<1> > class duration;

Rep 表示一种数值类型,用来表示 Period 的数量,比如 int、float、double,Period 是 ratio 类型,用来表示【用秒表示的时间单位】比如 second,常用的 duration 已经定义好了,在 std::chrono::duration 下:

  • ratio<3600, 1>:hours
  • ratio<60, 1>:minutes
  • ratio<1, 1>:seconds
  • ratio<1, 1000>:microseconds
  • ratio<1, 1000000>:microseconds
  • ratio<1, 1000000000>:nanosecons

ratio 的具体模板如下:

template <intmax_t N, intmax_t D = 1class ratio;

N 代表分子,D 代表分母,所以 ratio 表示一个分数,我们可以自定义 Period,比如 ratio<2, 1> 表示单位时间是 2 秒。

time_point

表示一个具体时间点,如 2020 年 5 月 10 日 10 点 10 分 10 秒,拿获取当前时间举例:

std::chrono::time_point<std::chrono::high_resolution_clock> Now() {return std::chrono::high_resolution_clock::now();
}
// std::chrono::high_resolution_clock为高精度时钟,下面会提到

clocks

时钟,chrono 里面提供了三种时钟:

1)steady_clock

稳定的时间间隔,表示相对时间,相对于系统开机启动的时间,无论系统时间如何被更改,后一次调用 now()肯定比前一次调用 now()的数值大,可用于计时。

2)system_clock

表示当前的系统时钟,可以用于获取当前时间:

int main() {
   using std::chrono::system_clock;
   system_clock::time_point today = system_clock::now();
   std::time_t tt = system_clock::to_time_t(today);
   std::cout << "today is: " << ctime(&tt);
   return 0;
}
// today is: Sun May 10 09:48:36 2020

3)high_resolution_clock

high_resolution_clock 表示系统可用的最高精度的时钟,实际上就是 system_clock 或者 steady_clock 其中一种的定义,官方没有说明具体是哪个,不同系统可能不一样,之前看 gcc chrono 源码中 high_resolution_clocksteady_clock 的 typedef。

条件变量

条件变量是 C++11 引入的一种同步机制,它可以阻塞一个线程或多个线程,直到有线程通知或者超时才会唤醒正在阻塞的线程,条件变量需要和锁配合使用,这里的锁就是上面介绍的 std::unique_lock

这里使用条件变量实现一个 CountDownLatch

class CountDownLatch {
   public:
    explicit CountDownLatch(uint32_t count) : count_(count);

    void CountDown() {
        std::unique_lock<std::mutex> lock(mutex_);
        --count_;
        if (count_ == 0) {
            cv_.notify_all();
        }
    }
    
void Await(uint32_t time_ms = 0) {std::unique_lock<std::mutex> lock(mutex_);while (count_ > 0) {if (time_ms > 0) {
                cv_.wait_for(lock, std::chrono::milliseconds(time_ms));
            } else {
                cv_.wait(lock);
            }
        }
    }
    
    uint32_t GetCount() const {
        std::unique_lock<std::mutex> lock(mutex_);
     return count_;
    }
   
    private:
    std::condition_variable cv_;
    mutable std::mutex mutex_;
    uint32_t count_ = 0;
};
image-20250511160409369
image-20250511160409369

26. 🌟C++ 中 inline 的作用?它有什么优缺点?

回答重点

inline 的作用是建议编译器将函数调用替换为函数体,以减少函数调用的开销,和宏比较类似。

使用 inline 函数的目的一定是希望可以提高程序的运行效率,特别是那些频繁调用的小函数。

优点:

  • 降低函数调用的开销,原理就是因为省去了调用和返回的指令开销。
  • 如果函数体较小,可以提高代码执行的效率。

缺点:

  • 容易导致代码膨胀,整个可执行程序体积变大,特别是当 inline 函数体较大且被多次调用时。
  • 内联是一种建议,编译器可以选择忽略 inline 关键字。

扩展知识

下面深入了解下 inline 函数。

  • 内联函数的典型应用场景:

    • 内联函数不仅适用于短小的函数,例如简单的 getter 和 setter,它还适合一些占用时间较短的算法,很多算法都会在语言层面考虑内联来提升性能。
    • 需要频繁调用而且内联能显著提高性能的地方。
  • 内联函数与宏的区别:宏是在预处理阶段进行文本替换,而内联函数是在编译阶段展开。内联函数有类型安全和作用域控制,宏没有这一特性。内联函数可以更好地报告调试信息,相对来说调试比较方便。

  • 在优化级别较高时,即使未加 inline 关键字,编译器也可能自动将频繁调用的小函数设为内联。

  • inline 的作用不仅仅是优先内联,它逐渐演变成了允许多重定义的含义。

27. C++ 中 explicit 的作用?

回答重点

关键字 explicit 的主要作用是防止构造函数或转换函数在不合适的情况下被隐式调用。

例如,如果有一个只有一个参数的构造函数,加上 explicit 关键字后,编译器就不会自动用该构造函数进行隐式转换。这可以避免由于意外的隐式转换导致的难以调试的行为。

class Foo {
public:
    explicit Foo(int x) : value(x) {}
private:
    int value;
};

void func(Foo f) {
    // ...
}

int main() {
    Foo foo = 10;  // 错误,必须使用 Foo foo(10) 或 Foo foo = Foo(10)
    func(10);      // 错误,必须使用 func(Foo(10))
}

如果没有 explicit 关键字,Foo foo = 10; 以及 func(10); 这样的代码是可以通过编译的,这会导致一些意想不到的隐式转换。

扩展知识

1)历史背景

explicit 关键字在 C++98 标准中引入,用来增强类型安全,防止不经意的隐式转换。从 C++11 开始,explicit 可以用于 conversion operator。

2)使用场景

  • 防止单参数构造函数隐式转换
  • 如果一个类的构造函数接受一个参数,而你并不希望通过隐式转换来创建这个类的实例,就应该在构造函数前加 explicit。这也是它最主要的作用。
class Bar {
public:
    explicit Bar(int x) : value(x) {}
private:
   int value;
};

Bar bar = 10;  // 错误,无法隐式转换
  • 防止 conversion operator 隐式转换
  • 类中有时会定义一些转换操作符,但有些转换是需要显式调用的,这时也可以使用 explicit
class Double {
public:
    explicit operator int() const {
        return static_cast<int>(value);
    }
private:
   double value;
};

Double d;
int i = d;               // 错误,无法隐式转换
int j = static_cast<int>(d);  // 正确,显式转换

3)复杂构造函数

对于那些带有默认参数的复杂构造函数,explicit 尤其重要,它们可能会被意外地调用。

class Widget {
public:explicit Widget(int x = 0, bool flag = true) : value(x), flag(flag) {}
private:int value;bool flag;
};

这种情况下,如果不加 explicit,没有任何参数传递给构造函数也可能会进行隐式转换,引发难以察觉的错误。

28. 🌟C++ 中野指针和悬挂指针的区别?

回答重点

两者都可能导致程序产生不可预测的行为。但它们有明显的区别:

**1)野指针:**一种未被初始化的指针,通常会指向一个随机的内存地址。这个地址不可控,使用它可能会导致程序崩溃或数据损坏。

int *p;
std::cout<< *p << std::endl;

**2)悬挂指针:**一个原本合法的指针,但指向的内存已被释放或重新分配。当访问此指针指向的内存时,会导致未定义行为,因为那块内存数据可能已经不是期望的数据了。

int main(void) {
  int * p = nullptr;
  int* p2 = new int;

  p = p2;
  delete p2;
}

扩展知识

展开说一说,弄清楚这两个概念不难,但如何避免和处理它们才是关键,这直接关系到写出更健壮的代码。

1)如何避免野指针

  • 初始化指针:在声明一个指针时,立即赋予它一个明确的数值,可以是一个有效的地址,也可以是 nullptr。
int *ptr = nullptr; // 初始化
  • 使用智能指针:C++ 中的智能指针(如 std::unique_ptrstd::shared_ptr)可以帮助自动管理指针的生命周期,减少手动管理的错误。
std::unique_ptr<int> ptr(new int(10));

2)如何避免悬挂指针

  • 在删除对象后,将指针设置为 nullptr,确保指针不再指向已经释放的内存。
delete ptr;
ptr = nullptr;
  • 尽量使用智能指针,它们会自动处理指针的生命周期,减少悬挂指针的产生。
std::shared_ptr<int> ptr1 = std::make_shared<int>(10);
{
    std::shared_ptr<int> ptr2 = ptr1;
    // 当 ptr2 离开作用域后,资源仍然被 ptr1 管理
}
// 仍然可以使用 ptr1

3)检测工具

  • 静态分析工具(如 Clang-Tidy、cppcheck)和动态分析工具(Valgrind、AddressSanitizer)可以帮助检测这些错误,确保代码质量。
image-20250511160459848
image-20250511160459848

29. 🌟 什么是内存对齐?为什么要内存对齐?

回答重点

内存对齐是指计算机在访问内存时,会根据一些规则来为数据指定一个合适的起始地址。

计算机的内存是以字节为基本单位进行编址,但是不同类型的数据所占据的内存空间大小是不一样的,在 C++ 语言中可以用 sizeof()来获得对应数据类型的字节数,一些计算机硬件平台要求存储在内存中的变量按照自然边界对齐,也就是说必须使数据存储的起始地址可以整除数据实际占据内存的字节数,这叫做内存对齐。

通常,这些地址是固定数字的整数倍。这样做,可以提高 CPU 的访问效率,尤其是在读取和写入数据时。

为什么要内存对齐?主要有以下几个原因:

**1)性能提升:**对齐的数据操作可以让 CPU 在一次内存周期内更高效地读取和写入,减少内存访问次数。通过内存布局优化技术,可以提高程序的运行效率和可靠性,这是因为可以减少 CPU 访问内存的次数,提高计算机新能,计算机在每次访问内存的时候,每次读取到的一定长度,就是操作系统的默认对齐系数,或者其系数的整数倍。

以 32 位 Intel CPU 为例(16 和 64 位类同),一次可以对一个 32 位的数进行运算,它的数据总线的宽度是 32 位,即 CPU 字长为 32 位。

假设 long1 和 long2 变量内存分配结构如下: long1,long2 类型都为 long,long1 在内存中的位置正好与内存字边界对齐,CPU 存取这个数只需访问内存 1 次,而 long2 在内存中跨越字边界,导致 CPU 存取这个数则需访问内存 2 次。由此可以看出,字节对齐主要提高 CPU 访问内存的效率。内存对齐之后,存取 long2 ,一次访问即可。

**2)硬件限制:**某些架构要求数据必须对齐,否则可能会引发硬件异常或需要额外的处理时间。有些 CPU 可以访问任意地址上的任意数据,而有些 CPU 只能在特定地址访问数据,因此不同硬件平台具有差异性,这样的代码就不具有移植性。

**3)可移植性:**代码在不同架构上运行时,遵从内存对齐规则可以减少潜在的问题。

扩展知识

内存对齐虽然看起来只是一个约定或者优化策略,其实背后还是有不少细致的讲究的:

**1)对齐要求:**不同的数据类型有不同的对齐要求。例如,在大多数 32 位系统中,int 通常要求 4 字节对齐,而 double 可能要求 8 字节对齐。编译器会根据这些对齐要求调整结构体或类的成员变量的布局。

**2)填充字节:**为了确保对齐,编译器有时会在数据成员之间插入一些“填充字节”(padding bytes),这些字节本身不保存有用的数据,只是为了使下一个成员变量满足对齐要求。例如,如果一个结构体的成员变量有 int 和 char,编译器可能会在 char 后面插入几个字节的填充,以确保下一个 int 的对齐。

**3)控制内存对齐:**在 C/C++ 中,我们可以使用编译器提供的关键字或扩展来控制数据的对齐方式,例如通过 ##pragma pack 控制。

**4)与缓存一致性相关:**内存对齐有时候还与缓存一致性联系在一起。CPU 有自己的缓存系统,合理的内存对齐往往能使缓存更高效地工作,减少 cache miss 的概率。

结构体内存对齐

例子 1:假设 32 位系统,long 占 4 个字节。

struct AlignA
{
    char a;
    long b;
    int c;
}

该结构体,占用内存为 4+ 4+ 4 =12 个字节,这就是内存对齐的原因。

例子 2

struct AlignB
{
    int b;
    char c[10];
    double a;
}

成员变量布局调整

通过上面了解了结构体的内存对齐,是不是就可以想到,如果修改了结构体内变量的位置,就可以减少结构体的大小了。

30. C++ 中静态多态,动态多态是什么?

静态多态也叫编译时多态,它是在编译阶段就确定要调用哪个函数。

实现方式主要有函数重载和模板,函数重载就是在一个类或者同一个作用域里有好几个同名函数,但它们的参数列表不同,编译器会根据你调用函数时传的实参类型和数量,在编译的时候就选好要调用哪个函数。

模板则是可以创建通用的函数或者类,根据不同的模板参数,编译器在编译时生成不同的代码,静态多态的优点是速度快,因为编译时就确定了调用,没有额外的运行时开销。

动态多态也叫运行时多态,它是在程序运行的时候才确定要调用哪个函数。主要通过虚函数和继承来实现。在基类里把函数声明成虚函数,派生类可以重写这个虚函数。

当用基类的指针或者引用去调用这个虚函数时,程序在运行时会根据指针或者引用实际指向的对象类型,来决定调用基类的函数还是派生类重写后的函数。动态多态的好处是灵活性高,能让代码更有扩展性,但因为要在运行时做判断,会有一些额外的开销。

打个比方,静态多态就像你出门前就根据天气决定穿什么衣服;动态多态就像你出门了,到地方才根据实际情况换衣服。

image-20250511160518787
image-20250511160518787