C++ 值类别与对象模型面试题(12)

目录

[1. lvalue / xvalue / prvalue 的区别 + 例子](#1. lvalue / xvalue / prvalue 的区别 + 例子)

[2. 为什么 std::move 只是转右值引用?什么时候真的触发移动?](#2. 为什么 std::move 只是转右值引用?什么时候真的触发移动?)

[3. std::forward vs std::move 与适用场景](#3. std::forward vs std::move 与适用场景)

[4. 拷贝构造 / 移动构造 / 拷贝赋值 / 移动赋值 / 析构 的调用时机](#4. 拷贝构造 / 移动构造 / 拷贝赋值 / 移动赋值 / 析构 的调用时机)

[5. Rule of 3 / 5 / 0 是什么?工程中如何取舍?](#5. Rule of 3 / 5 / 0 是什么?工程中如何取舍?)

[6. 含 / 不含虚函数的对象内存布局差异](#6. 含 / 不含虚函数的对象内存布局差异)

[7. 多重继承与虚继承对对象布局、指针转换的影响](#7. 多重继承与虚继承对对象布局、指针转换的影响)

[8. override / final / =default / =delete 的语义与用法](#8. override / final / =default / =delete 的语义与用法)

[9. explicit 解决什么问题?隐式转换风险](#9. explicit 解决什么问题?隐式转换风险)

[10. 友元(friend)的作用与边界](#10. 友元(friend)的作用与边界)

[11. mutable 的典型应用场景?与 const 成员函数的关系?](#11. mutable 的典型应用场景?与 const 成员函数的关系?)

[12. sizeof 常见陷阱:空类、虚函数、padding 等](#12. sizeof 常见陷阱:空类、虚函数、padding 等)


1. lvalue / xvalue / prvalue 的区别 + 例子

大白话:这三种是 C++11 之后"值类别"的三种主要形态。

  • lvalue(左值)

    • 名字 、有可持久的地址 ,可以放在 & 后取地址。

    • 典型:变量、函数、返回左值引用的表达式。

    • 例子:

      复制代码
      int x = 42;        // x 是 lvalue
      int& r = x;        // r 是 lvalue
  • xvalue(将亡值)

    • 一种"即将被销毁"的右值,通常是绑定到右值引用的对象。

    • 典型:std::move(x) 的结果,返回 T&& 的函数结果。

    • 例子:

      复制代码
      int x = 42;
      int&& rx = std::move(x); // std::move(x) 是 xvalue
  • prvalue(纯右值)

    • 算出来的临时值,没有身份,只是一个"值",不能取地址(对临时再取地址是另一回事)。

    • 典型:字面量、返回非引用的函数结果。

    • 例子:

      复制代码
      int y = 42;        // 42 是 prvalue
      int f();
      int z = f();       // f() 这个表达式是 prvalue

简单记法:

  • lvalue:有名字、能取地址。

  • xvalue:右值 + 有资源 + 将要被移动/销毁。

  • prvalue:纯粹的"数字/值"。


2. 为什么 std::move 只是转右值引用?什么时候真的触发移动?

  • std::move 的实现本质就是:

    复制代码
    template<typename T>
    constexpr typename std::remove_reference<T>::type&& move(T&& t) {
        return static_cast<typename std::remove_reference<T>::type&&>(t);
    }
  • 完全不做移动操作 ,只做一个 static_cast

    把一个对象(通常是 lvalue)标记为 xvalue(右值引用)

真正的"移动"在什么时候发生?

当这个 xvalue 被用来:

  • 调用移动构造

    复制代码
    std::string s = "hello";
    std::string t = std::move(s);  // 这里调用 std::string 的移动构造函数
  • 调用移动赋值

    复制代码
    std::string s = "hello";
    std::string t;
    t = std::move(s);              // 调用移动赋值
  • 或者传入 STL 容器 / 算法时,它们内部根据值类别选择调用 move ctor/assign。

总结一句:

  • std::move 只是"贴标签:这个东西可以被当作右值使用了",

  • 真正的移动是后面那个构造函数/赋值运算符里发生的。


3. std::forward vs std::move 与适用场景

  • std::move

    • 无条件地把参数当成右值:总是 xvalue

    • 用途:你就是要把对象"交出去不再用",比如类的 operator=、转移资源等。

    • 例子:

      复制代码
      void sink(std::string&& s);
      
      std::string x = "hi";
      sink(std::move(x));  // 一定以右值方式传入
  • std::forward<T>

    • 条件转发:如果原本是右值,就转成右值;如果原本是左值,就保持左值。

    • 前提:配合转发引用(又叫万能引用)使用

      复制代码
      template<typename T>
      void wrapper(T&& x) {
          foo(std::forward<T>(x)); // 保持 x 的"原始值类别"
      }
    • 适用场景:完美转发(在泛型函数里,把参数原样转发给另一个函数)。

简记:

  • "我就是要把它当右值处理" → std::move

  • "我不知道它是左值还是右值,但要原样转发" → std::forward


4. 拷贝构造 / 移动构造 / 拷贝赋值 / 移动赋值 / 析构 的调用时机

假设类为 T

  • 拷贝构造 T(const T&)

    • 用一个 lvalue T 初始化新对象时:

      复制代码
      T a;
      T b = a;        // 拷贝构造
      T c(a);         // 拷贝构造
  • 移动构造 T(T&&)

    • 用一个 xvalue / 右值 T 初始化新对象时:

      复制代码
      T a;
      T b = std::move(a);   // 移动构造
      T c(T{});             // 移动构造(T{} 是 prvalue)
  • 拷贝赋值 T& operator=(const T&)

    • 已存在对象,被一个 lvalue 赋值:

      复制代码
      T a, b;
      b = a;          // 拷贝赋值
  • 移动赋值 T& operator=(T&&)

    • 已存在对象,被一个 xvalue / 右值 T 赋值:

      复制代码
      T a, b;
      b = std::move(a); // 移动赋值
  • 析构 ~T()

    • 对象生命周期结束时:

      • 变量离开作用域

      • delete 删除对象

      • 容器销毁其元素

说明:返回值优化 (RVO) / NRVO 可能会让一些预期中的拷贝/移动被省略,但从抽象语义上可按拷贝/移动构造理解。


5. Rule of 3 / 5 / 0 是什么?工程中如何取舍?

  • Rule of 3

    • 如果你需要自己写 析构函数,通常也需要写:

      • 拷贝构造函数

      • 拷贝赋值运算符

    • 因为这三者都涉及资源管理(堆内存、文件句柄等)。

  • Rule of 5(C++11 之后)

    • 有资源管理时,除了上面三个,还要考虑:

      • 移动构造函数

      • 移动赋值运算符

    • 一般五个要成套考虑。

  • Rule of 0

    • 最好根本就不要自己管理资源;

    • 资源交给 RAII 类(如 std::vector, std::string, std::unique_ptr 等)管理;

    • 自己的类里只用这些成员对象,不写任何特殊成员函数,让编译器生成默认的就好。

工程实践:

  • 优先遵守 Rule of 0:只用标准容器/智能指针。

  • 如果真的要自己 hold 原始资源(new / 文件句柄 / socket 等),

    要么再封一个 RAII 的小类 + 你的大类里只用这个小类成员;

    要么就老老实实按 Rule of 5 全部写好,防止资源泄漏/双重释放。


6. 含 / 不含虚函数的对象内存布局差异

标准没规定具体布局,但主流 ABI(如 Itanium)通常是:

  • 不含虚函数的类:

    • 对象内存大致是:

      • 从第一个非静态成员开始,按声明顺序排布

      • 中间可能插入填充字节(padding)保证对齐

    • 例如:

      复制代码
      struct A {
          int x;
          char c;
          double d;
      };
    • 布局:[int x][char c][padding][double d]

  • 含虚函数的类:

    • 每个对象里通常多一个 虚函数表指针(vptr)

      • 通常位于对象内存的起始位置(实现细节)。

      • vptr 指向类的虚函数表(vtable)。

    • 大致布局:

      复制代码
      [vptr][成员1][成员2]...[padding...]

影响:

  • sizeof 会变大(至少包含一个指针大小)。

  • 通过基类指针调用虚函数时,先通过 vptr 找到 vtable 再调用对应函数。


7. 多重继承与虚继承对对象布局、指针转换的影响

多重继承:

复制代码
struct Base1 { int a; };
struct Base2 { int b; };
struct Derived : Base1, Base2 { int c; };
  • Derived 对象里会包含两个 base 子对象:

    • [Base1 子对象][Base2 子对象][Derived 自身成员]
  • Base1* 指向的是 Derived 对象的开头;

  • Base2* 指向的是 Derived 对象中间偏移的位置;

  • 所以:Derived* 转为 Base2* 时,编译器需要做指针调整(加上偏移)。

虚继承:

复制代码
struct VBase { int x; };
struct A : virtual VBase {};
struct B : virtual VBase {};
struct C : A, B {};
  • VBase 是虚基类,最终在 C 中只保留一个共享的 VBase 子对象

  • 对象中通常存在一些"虚基表指针"(vbptr)或额外信息,帮助运行时从 A/B 子对象找到那一个 VBase 子对象。

  • 任何从 C* 转到 VBase*、或朴素 A*/B* 再转 VBase* 的过程中,编译器都要做更复杂的指针调整

总结:

  • 多重继承 → 一个派生对象里有多个基类子对象 → 从派生指针到各基类指针要做不同的偏移。

  • 虚继承 → 多条继承链共用一个虚基子对象 → 需要额外的信息来定位虚基 → 指针转换更复杂,布局更复杂。


8. override / final / =default / =delete 的语义与用法

  • override

    • 声明这是一个重写基类虚函数的函数。

    • 编译器会检查签名是否与基类虚函数完全匹配,不匹配就报错。

      struct Base {
      virtual void foo(int);
      };
      struct Derived : Base {
      void foo(int) override; // 正确
      // void foo(double) override; // 编译错误:没真 override
      };

  • final

    • 用在虚函数上:禁止在派生类中继续重写。

    • 用在类上:禁止该类被继承。

      struct Base {
      virtual void foo() final; // 不能再被 override
      };

      struct A final {}; // 不能再继承 A
      // struct B : A {}; // 编译错误

  • =default

    • 显式要求编译器生成默认实现(默认构造,拷贝构造等)。

      struct S {
      S() = default; // 显式要一个默认构造
      S(const S&) = default; // 默认拷贝构造
      ~S() = default; // 默认析构
      };

  • =delete

    • 显式禁止某个函数被使用(禁用拷贝、禁用某种参数等等)。

      struct NonCopyable {
      NonCopyable() = default;
      NonCopyable(const NonCopyable&) = delete; // 禁用拷贝
      NonCopyable& operator=(const NonCopyable&) = delete;
      };

      void foo(int) = delete; // 禁止调用 foo(int)


9. explicit 解决什么问题?隐式转换风险

默认情况下,如果构造函数只带一个实参(或有默认值能当作一个实参使用),就可以参与隐式转换

复制代码
struct X {
    X(int);     // 没有 explicit
};

void f(X);

f(10);         // 10 隐式转换为 X(10),然后传给 f

问题:

  • 容易在不经意间发生转换,产生模棱两可或性能/逻辑问题。

  • 常见风险:构造函数或 operator T() 参与条件判断、重载解析等。

explicit 的作用:

  • 阻止构造函数/转换函数参与隐式转换,只能显式调用:

    复制代码
    struct X {
        explicit X(int);
        explicit operator bool() const;
    };
    
    X x1(10);       // OK
    // f(10);       // 编译错误,不能隐式转换为 X
    if (x1) { }     // C++11 前不行;C++11 后 `if (static_cast<bool>(x1))`

对转换运算符的隐式转换风险:

  • 如果有 operator bool() 未加 explicit,对象可能在 if(obj)、算式中被隐式转成 bool,

    还可能影响与其他重载的解析,带来难以发现的 bug。


10. 友元(friend)的作用与边界

  • 作用:

    • 允许某个函数访问当前类的 private/protected 成员:

      复制代码
      class A {
          friend void foo(A&);
          friend class B;
      private:
          int x;
      };
    • 常用于:

      • 运算符重载(如对称的 operator+, operator<<

      • 两个类之间需要紧密协作(如容器和迭代器)

  • 边界与注意点:

    • 友元不是"成员",只是增加访问权限。

    • 友元关系不传递不继承

      • A 是 B 的友元,不意味着 A 是 B 子类的友元。
    • 滥用 friend 会破坏封装,增加耦合。

    • 常见做法:把友元限制在少数必要的辅助函数和内部类上。


11. mutable 的典型应用场景?与 const 成员函数的关系?

mutable

  • 标记某个非静态成员,即使对象是 const,也允许修改这个成员。

  • 典型用途:逻辑上是"缓存/统计信息"之类的非逻辑状态

例子 1:缓存计算结果

复制代码
class BigCalc {
public:
    int value() const {
        if (!cached_) {
            result_ = expensive_compute();
            cached_ = true;
        }
        return result_;
    }
private:
    int expensive_compute() const;
    mutable bool cached_ = false;
    mutable int result_ = 0;
};

例子 2:const 方法中加锁

复制代码
class ThreadSafe {
public:
    int get() const {
        std::lock_guard<std::mutex> lk(mutex_);
        return data_;
    }
private:
    int data_;
    mutable std::mutex mutex_;
};

const 成员函数的关系:

  • const 成员函数禁止修改(非 mutable 的)成员。

  • mutable 成员是特例:在 const 成员函数中可以修改,用于遵守"逻辑常量性"(logical constness)。


12. sizeof 常见陷阱:空类、虚函数、padding 等

  • 空类大小不为 0

    复制代码
    struct Empty {};
    sizeof(Empty);   // >= 1

    标准要求每个对象有唯一地址,所以至少 1 字节。

  • 含虚函数的类

    复制代码
    struct A { virtual void f(); };
    sizeof(A);       // 至少 = 一个指针大小(存 vptr) + padding

    实际可能 > 一个指针大小,因为还要考虑对齐和成员。

  • 对齐与填充(padding)

    复制代码
    struct S {
        char c;
        int  i;
        char d;
    };

    布局中会在 ci 之间插入 padding,以保证 i 对齐;
    sizeof(S) 可能远大于 sizeof(char)*2 + sizeof(int)

  • 继承中的 sizeof

    • 带虚继承、多重继承时,对象中可能有额外指针/偏移表,sizeof 也会比你想的复杂。

    • 不要简单地"字段相加"。

  • 表达式 sizeof 不求值

    复制代码
    int f();
    sizeof(f());   // 只看类型,不会调用 f()
相关推荐
代码不停2 小时前
Java模拟算法题目练习
java·开发语言·算法
前端小L3 小时前
图论专题(二):“关系”的焦点——一眼找出「星型图的中心节点」
数据结构·算法·深度优先·图论·宽度优先
资深web全栈开发3 小时前
贪心算法套路解析
算法·贪心算法·golang
~~李木子~~3 小时前
贪心算法实验2
算法·贪心算法
FanXing_zl3 小时前
快速掌握线性代数:核心概念与深度解析
线性代数·算法·机器学习
zzzsde4 小时前
【C++】红黑树:使用及实现
开发语言·c++·算法
Kuo-Teng4 小时前
LeetCode 139: Word Break
java·算法·leetcode·职场和发展·word·动态规划
Algor_pro_king_John4 小时前
模板ACM
算法·图论
前端小L4 小时前
图论专题(六):“隐式图”的登场!DFS/BFS 攻克「岛屿数量」
数据结构·算法·深度优先·图论·宽度优先