Lecture 20: 面向对象编程概念及其在 C/C++ 中的实现 (Object-Oriented Concepts: Information Hiding and Encapsulation in C, and the Move to C++)

目录 · ← l19 · l21 →

Lecture 20: 面向对象编程概念及其在 C/C++ 中的实现 (Object-Oriented Concepts: Information Hiding and Encapsulation in C, and the Move to C++)

概述

本讲回答一个看似矛盾的问题:面向对象 (object-oriented) 的四个核心思想——封装、信息隐藏、继承、多态——需要语言提供什么? 答案是几乎不需要:它们是设计思想,在纯 C 里用「结构体 + 首参数为句柄的函数 + 文件作用域 + 函数指针表」就能完整表达,而这正是你写过的 mem220stackplayer 模块在做的事。本讲先在 C 里把这些思想手工搭出来,再展示 C++ 编译器如何把同一套机制自动化:类与成员函数(隐含的 this)、访问控制、构造/析构与 RAII、new/delete、引用、运算符重载、虚函数与编译器生成的虚函数表 (vtable)。抓住「C++ 虚函数就是 C 的函数指针表,只是表由编译器代填」这一点,就把上一讲的函数指针与回调、ECE 120 的 LC-3 JSRR 间接跳转、以及后续的容器与迭代器缝成了同一条自底向上的线索。

核心概念与底层机制图解

  • 封装 (Encapsulation):把数据作用于它的函数捆绑为一个单元,只通过公开接口访问数据。直观解释:自动售货机——你只按面板按钮(接口),不拆开内部齿轮(数据)。底层机制:C 里的「捆绑」就是一个结构体 + 一组首参数为该结构体指针的函数;C++ 成员函数编译后的签名恰好就是这个形式。作用域与存储期:数据是 automatic(栈上 struct)或 dynamic(malloc/new),接口函数在 .text,存储期为整个程序。
  • 信息隐藏 (Information Hiding):模块只暴露接口,隐藏「用什么数据结构实现」(David Parnas, 1972)。直观解释:餐厅菜单——你点「宫保鸡丁」,不必知道厨房用什么锅;厨房换设备(改数据结构)不影响菜单(接口)。底层机制:C 中作用域就等于访问权限——编译器看得见的名字你就能改;因此隐藏的唯一手段是文件作用域,把结构体定义、static 变量与函数全放进 .c,头文件只留不完整类型 (incomplete type) 与函数原型。作用域与存储期:对象状态在 .cstatic 变量(static storage duration)或堆上(dynamic),外部只能透过句柄 (handle) 间接访问。
  • 继承 (Inheritance)数据继承指子类型拥有父类型的全部字段,函数继承指能作用于父类型的函数也能作用于子类型;父类型称基类 (base class),子类型称派生类 (derived class)。直观解释:「学生」也是一种「人」——人有的姓名、生日学生都有,针对「人」写的手续对「学生」同样管用。底层机制:ABI 层面的实现只有一句话——把基类子对象作为派生类结构体的第一个成员;C99 标准 6.7.2.1p13 规定指向结构体的指针经适当转换后指向其初始成员,故 (base_t*)derived_ptr 合法且零指令开销作用域与存储期:父、子结构体在同一块内存连续存放,一个对象只有一次分配、一个存储期、一次释放。
  • 多态 (Polymorphism):同一个调用表达式,按对象的实际类型运行期选择不同实现。直观解释:同一句「开始工作」,对画家和司机说,执行的动作完全不同。底层机制两步间接——对象里存 vptr 指向 vtable,vtable 是每类一份的函数指针表;obj->vfun() 编译成「取 vptr → 按固定偏移取函数地址 → JSRR/间接 call」,即讲义 slide 27–28 的「两次 load 加一次调用」。作用域与存储期:vtable 在只读段 (.rodata),每类一份,存储期为整个程序;vptr 每对象一份,随对象创建与销毁。
  • 关键论断:四个概念是设计思想,不是语言特性。 C 没有 class/private/virtual 三个关键字,却能完整表达这四个思想——只是由程序员手工保证;C++ 把「手工保证」变成「编译器保证」:只在类定义里写一次类型层次与哪些函数要改写,编译器就替你排布数据、生成 vtable、填表。用讲义的话说,C++「产生的结构与函数通常与 C 中手工写出的一模一样」。
  • 虚函数表指针 (vptr):含虚函数的对象里额外的一个指针字段,位于对象起始处(Itanium C++ ABI 约定),指向其运行时类型的 vtable。实测 sizeof (Shape) = 40sizeof (Circle) = 48——这就是 virtual 的固定空间代价(C++ 的「pay only for what you use」:不用虚函数就不付)。vptr 在构造函数执行到最内层派生类的函数体之前被设为该层 vtable,析构时逐层回退,这就是「构造/析构期间不要调用虚函数」的原因。

图 1:基类子对象必须位于偏移 0(单继承的 ABI 实现)

circle_t(实测 sizeof == 24,offsetof (base) == 0,offsetof (radius) == 16):
偏移   0            8            16           20         24
     +------------+------------+------------+-----------+
     |  vtab      |  name      |  radius    |  (padding)|
     +------------+------------+------------+-----------+
     ^                         ^
     &c == &c.base == (shape_t*)&c   ← 三者地址相同,转型零指令
rectangle_t(实测 sizeof == 32,offsetof (height) == 24):
     +------------+------------+------------+------------+
     |  vtab      |  name      |  width     |  height    |
     +------------+------------+------------+------------+
     ^ &r == &r.base(base 在第一个成员,永远是偏移 0)
反例:base 若不是第一个成员,&obj->base != &obj,(base_t*)obj 就指向错误位置。

图 2:vtable 与动态分派(两次 load + 一次间接调用)

  两个对象(栈上)                       两张 vtable(.rodata,每类一份)
  +------------------+                 +----------------------------------+
  | vptr = 0x403010  |---------------->| "circle"  &circle_area           |
  | name = "C"       |                 | &circle_perimeter                |
  | radius = 2.0     |                 | &circle_describe                 |
  +------------------+                 +----------------------------------+
  +------------------+                 +----------------------------------+
  | vptr = 0x4030E0  |---------------->| "rectangle" &rectangle_area      |
  | name = "R"       |                 | &rectangle_perimeter             |
  | width=3.0 h=4.0  |                 | &rectangle_describe              |
  +------------------+                 +----------------------------------+
调用 shape_area (shapes[i]):1. mov (%rdi),%rax  取 vptr
                             2. mov 0x8(%rax),%rdx  取函数地址
                             3. call *%rdx  间接调用
对比:非虚函数调用只需第 3 步,地址是编译期常量,没有前两次 load。

代码示例与底层机制分析

示例 1:C 中的信息隐藏 —— 真实 mem220 包与不透明句柄惯用法

代码 (C)

/* mem220.h —— 真实课程文件(节选):头文件只暴露函数 */
void*   mem220_allocate   (size_t n_bytes);
int32_t mem220_reallocate (void** ptr_to_ptr, size_t n_bytes);
void    mem220_free       (void* ptr);
/* mem220.c —— 实现文件(节选):头部结构体只在本文件可见 */
typedef struct mem_block_t mem_block_t;   /* 块头部,存在每块内存前端 */
struct mem_block_t { size_t size; mem_block_t* next; };
static uint8_t*     free_bytes;           /* 文件私有状态:static = 内部链接 */
static mem_block_t* mem_bin[MEM220_MAX_ALLOC_LOG+1];
void* mem220_allocate (size_t n_bytes)
{
    return (new_block + 1);               /* 查 bin 取块后,跳过头部返回 */
}
void mem220_free (void* ptr)
{
    mem_block_t* mem_block = ptr;
    int32_t      bin;
    if (NULL == ptr) { return; }
    bin = log2_ceil (mem_block[-1].size); /* ptr[-1] 就是头部! */
    mem_block[-1].next = mem_bin[bin];
    mem_bin[bin] = &mem_block[-1];
}
/* 不透明句柄惯用法:把同一个模块写成「类」——只有不完整类型与原型 */
typedef struct memory_system memory_system_t;  /* 可声明指针,不能 sizeof */
memory_system_t* mem_system_create  (size_t capacity);       /* 构造函数 */
void             mem_system_destroy (memory_system_t* self); /* 析构函数 */
void* mem_system_allocate (memory_system_t* self, size_t n_bytes); /* 方法 */
/* mem_system.c —— 结构体定义与全部实现藏在这里 */
struct memory_system {          /* 只有本文件看得见这个定义 */
    size_t capacity;  size_t in_use;  size_t blocks_live;
    struct alloc_header_t* live_list;
};
void* mem_system_allocate (memory_system_t* self, size_t n_bytes)
{
    struct alloc_header_t* header;
    if (NULL == self || self->in_use + n_bytes + sizeof (*header) > self->capacity) {
        return NULL;                             /* 对象自己判定失败 */
    }
    if (NULL == (header = malloc (sizeof (*header) + n_bytes))) { return NULL; }
    header->next = self->live_list;  self->live_list = header;
    self->in_use += n_bytes;  self->blocks_live += 1;
    return (void*)(header + 1);                  /* 跳过头部 */
}

真实运行结果(gcc -g -std=c99 -Wall -Werror,两个包都实际编译运行过):

# mem220.c + demo.c            # mem_system.c + handle_demo.c
MEM220_MAX_ALLOC = 1048576      greeting = "hello, ECE 220"   numbers = 0 1 4 9 16
all zero? yes                   in_use = 36   blocks_live = 2   blocks_freed = 0
data preserved? yes             free 两次后:in_use = 0, live = 0, freed = 2
allocate(0) = NULL              over-capacity request returns NULL
allocate(> MAX) = NULL

【代码做什么?】

  1. mem220_allocate(1000) 把请求加上头部大小(64 位机实测 16 字节),用 log2_ceil 求 bin 号(10),优先从 bin 的空闲链表取块,返回 new_block + 1跳过头部的地址;mem220_freeptr[-1] 找回头部并挂回 bin。
  2. mem_system_* 版本改成让句柄 self 作首参数、用魔术数字自检,并统计 in_use/blocks_live——实测 36/2,free 两次后归零;超容量请求返回 NULL,调用者绕不过这个检查。

【底层机制透视】

  • 信息隐藏完全由文件作用域实现:头文件没有 struct mem_block_t 定义,任何 #include "mem220.h".c写不出 buf[-1].size——不是被禁止,而是编译器不知道这些字段存在。此处 static 的含义是内部链接(名字不出本翻译单元),不是静态存储期。
  • typedef struct memory_system memory_system_t; 是句柄惯用法的核心:这是不完整类型memory_system_t* 完整(指针大小已知),memory_system_t 不完整(sizeof 编译失败)。调用者能传递、能保存句柄,却永远无法ms->capacity
  • 「句柄作第一个参数」就是「方法」mem_system_allocate (ms, n) 与 C++ 的 ms->allocate (n) 编译后第一个整数参数寄存器(x86-64 的 %rdi)里都是 ms,C++ 只是把它藏了起来。
  • 负下标是合法 Cmem_block[-1]*(mem_block - 1),指针算术以 sizeof (mem_block_t) 为单位,p[-1] 正好回退 16 字节落在头部起点——这就是 560 讲「用块上方头部保存管理信息」的实现。

【内存布局图解】

buf = mem220_allocate_and_zero (1000) 之后(实测 sizeof (mem_block_t) == 16):
 +-------------------+=======================================+==========
 | mem_block_t 头部   |        调用者使用的 1000 字节          | 后续空闲区
 | size = 1024  next |                                       |
 +-------------------+=======================================+==========
 ^                   ^
 &block         buf = &block + 1   ← mem220_allocate 的返回值
句柄版本:栈上 ms ──► 堆上 struct memory_system {capacity, in_use, live_list}
                      live_list ──► [头部|数据] ──► [头部|数据]
调用者只能看到两端的数据;头部与 struct memory_system 全都不可见。

【与汇编的对应】

        LDR   R1, R4, #0         ; R1 = ms(句柄,作为第一个参数)
        LD    R0, SIZE_16        ; R0 = 16(第二个参数 n_bytes)
        JSR   mem_system_alloc   ; R7 <- 返回地址
mem_system_alloc                 ; 子程序入口:建立栈帧
        ADD   R6, R6, #-1        ; 压栈:先减栈指针
        STR   R5, R6, #0         ; 保存调用者帧指针
        ADD   R5, R6, #0         ; R5 = 本帧基址(局部变量底部)
        STR   R7, R5, #2         ; 保存返回地址(R5+2)
        LDR   R2, R1, #2         ; R2 = self->in_use(基址 + 常量偏移)
        ADD   R3, R2, R0         ; R3 = in_use + n
        LDR   R4, R1, #0         ; R4 = self->capacity
        NOT   R5, R4
        ADD   R5, R5, #1         ; R5 = -capacity
        ADD   R5, R5, R3
        BRp   alloc_fail         ; R5 > 0(超出容量)则失败返回 NULL
        ADD   R0, R1, #1         ; R0 = 新块地址(跳过头部)
        LDR   R7, R5, #2         ; 恢复返回地址
        ADD   R6, R5, #0
        LDR   R5, R6, #0         ; 恢复上一帧的 R5
        RET                      ; 即 JMP R7

示例 2:C 的 shape「类」、vtable 与跳转表(单继承与多态)

代码 (C)

/* shape.c —— 用 C 手工实现单继承 + 动态分派 */
typedef struct shape_vtab_t shape_vtab_t;   /* 每个类一份的「虚函数表」 */
struct shape_vtab_t {
    const char* class_name;
    double      (*area)      (const void* self);
    double      (*perimeter) (const void* self);
    void        (*describe)  (const void* self);
};
struct shape_t { const shape_vtab_t* vtab; const char* name; }; /* vptr 在头 */
struct circle_t    { shape_t base; double radius; };    /* 基类作首成员 */
struct rectangle_t { shape_t base; double width, height; };
static double circle_area (const void* self)
{
    const circle_t* c = self;
    return 3.14159265358979 * c->radius * c->radius;
}
static const shape_vtab_t CIRCLE_VTAB = {
    "circle", circle_area, circle_perimeter, circle_describe
};
static const shape_vtab_t RECTANGLE_VTAB = {
    "rectangle", rectangle_area, rectangle_perimeter, rectangle_describe
};
static double shape_area (const shape_t* self)  /* 「虚函数」分派器 */
{
    return (*self->vtab->area) (self);          /* 两次 load,然后间接调用 */
}
int main (void)
{
    circle_t c;  rectangle_t r;  shape_t* shapes[2];  size_t i;
    circle_init (&c, "C", 2.0);  rectangle_init (&r, "R", 3.0, 4.0);
    shapes[0] = &c.base;         shapes[1] = &r.base;  /* 向上转型:零指令 */
    for (i = 0; i < 2; i++) {
        printf ("%s: ", shapes[i]->name);
        shape_describe (shapes[i]);
        printf (" area = %.2f\n", shape_area (shapes[i]));
    }
    return 0;
}
/* 更朴素的形式:跳转表 —— 枚举值直接做数组下标 */
static op_func_t dispatch_table[NUM_OPS] = { op_add, op_mul, op_div, op_sub };
        op_t op = (op_t)row[2];  int32_t r = (*dispatch_table[op]) (row[0], row[1]);

真实运行结果与真实反汇编:

sizeof (shape_t) = 16, sizeof (circle_t) = 24
&c = 0x7ffe911c7230, &c.base = 0x7ffe911c7230, equal? yes
C: circle(radius=2.00) area = 12.57    R: rectangle(w=3.00,h=4.00) area = 12.00
sizeof (op_func_t) = 8 bytes; table base = 0x404040
add(12, 5) = 17    mul(12, 5) = 60    div(12, 0) = -1    sub(12, 5) = 7
0000000000401270 <shape_area>:          ← 真实 objdump 输出
  401280:	mov    (%rax),%rax           ; load 1:取 vptr = self->vtab
  401283:	mov    0x8(%rax),%rdx        ; load 2:取 vtab->area
  40128e:	callq  *%rdx                 ; 间接调用

【代码做什么?】

  1. circle_init/rectangle_initbase.vtab 指向该类唯一的那张表再填数据字段——这就是「构造函数」;&c.base&c 实测是同一个地址,故 shapes[0] = &c.base; 不产生任何指令。
  2. shapes[i]->name 按固定偏移 8 读取(静态绑定);shape_area (shapes[i]) 走 vtable:i = 0 调到 circle_areai = 1 调到 rectangle_areadispatch_table 是更朴素的版本:枚举顺序就是下标,实测函数指针都是 8 字节。

【底层机制透视】

  • 反汇编完全印证讲义论断shape_area 里只有两条 mov 取地址,随后 callq *%rdx——正是 slide 27「两次内存读取」与 slide 28「Two loads followed by a call」。
  • vtable 每类一份,不是每对象一份(讲义 slide 24–25):若给每个对象都塞函数指针,1000 个 circle 就要 1000 份 area 指针;用 vtable 后只有 1 张表,对象只多一个 vptr
  • 向上转型安全的真正原因是 C99 6.7.2.1p13base 在偏移 0 使位模式相同);向下转型在 C 中不安全,因为给定 shape_t* 无法知道后面是 radius 还是 width/height——讲义 slide 12–13 正是这个论点,解法是加 type 字段或用 vtab 里携带的类型信息。
  • 跳转表与 switch 同源:分支密集时编译器为 switch 生成的也是地址表,跳转表是机器层面的通用机制,vtable 只是它的一个特例。讲义 583 的 isort (void* base, int32_t n_elts, size_t size, int32_t (*is_smaller)(void*, void*)) 是同一思想在泛型算法上的应用;const void* self 则是签名统一的折中(讲义 slide 20)。

【内存布局图解】

栈(main 帧)                                .rodata(每类一份,只读)
 c (circle_t, 24 B)                          CIRCLE_VTAB
 +-------------------+                    +--------------------------+
 | vtab = 0x403010 --|------------------> | class_name = "circle"    |
 +-------------------+                    | area      = &circle_area |
 | name = "C"        |                    | perimeter = &circle_per. |
 +-------------------+                    | describe  = &circle_desc.|
 | radius = 2.0      |                    +--------------------------+
 +-------------------+
       ^  &c == &c.base == (shape_t*)&c       RECTANGLE_VTAB
 r (rectangle_t, 32 B)                     +--------------------------+
 +-------------------+                    | class_name = "rectangle" |
 | vtab = 0x4030E0 --|------------------> | area      = &rect_area   |
 +-------------------+                    | perimeter = &rect_per.   |
 | name="R" width=3.0 height=4.0           | describe  = &rect_desc.  |
 +-------------------+                    +--------------------------+
 shapes[](栈上)| &c.base | &r.base |   跳转表 dispatch_table (0x404040)
                                            | &op_add | &op_mul | &op_div | &op_sub |
                                             [0]       [1]       [2]       [3]

【与汇编的对应】

; (a) LC-3 实现 shape_area —— 讲义 slide 28 建议的练习
shape_area
        LDR   R1, R0, #0         ; R1 = self->vtab(vptr 在偏移 0)
        LDR   R2, R1, #1         ; R2 = vtab->area(表里第 1 个字)
        JSRR  R2                 ; 间接调用:多态的全部成本
        RET
; 对比:非虚函数调用,目标地址是编译期常量
        JSR   rectangle_area     ; 一条指令,无需任何 load
; (b) LC-3 跳转表 —— 与 dispatch_table 一一对应
        LEA   R2, DISPATCH       ; R2 = 跳转表基址
        LDR   R1, R0, #0         ; R1 = 操作码(作下标)
        ADD   R3, R1, R1         ; R3 = 2*op(每项 1 字,用 2 演示缩放)
        ADD   R3, R3, R2
        LDR   R3, R3, #0         ; R3 = DISPATCH[op](函数入口地址)
        JSRR  R3                 ; 间接调用
DISPATCH  .FILL op_add           ; [0]
          .FILL op_mul           ; [1]
          .FILL op_div           ; [2]

示例 3:C++ 构造/析构与 RAII —— 真实的构造与析构顺序

代码 (C++)

// ctor_dtor.cpp
class Base {
public:
    Base ()  { std::cout << "  construct Base\n"; }
    virtual ~Base () { std::cout << "  destruct  Base\n"; }
private:
    Member m_;                       // 成员本身是对象:自动构造/析构
};
class Derived : public Base {
public:
    Derived () : Base (), first_("first"), second_("second")
    { std::cout << "  construct Derived body\n"; }
    ~Derived () override { std::cout << "  destruct  Derived body\n"; }
private:
    Tracer first_, second_;          // Tracer 的构造/析构都打印自己的名字
};
class Widget {
public:
    Widget () : n_(0), buf_(new char[8]) { }   // 数组元素用无参构造函数
    explicit Widget (std::int32_t n) : n_(n), buf_(new char[8]) { }
    ~Widget () { delete[] buf_; }    // 指针成员必须手工释放
private:
    std::int32_t n_;   char* buf_;
};
int main ()
{
    { Derived d; }                   // 离开作用域:析构自动发生
    Widget* w = new Widget (7);   delete w;
    Widget* arr = new Widget[3];  delete[] arr;
    return 0;
}

真实运行结果(g++ -g -std=c++17 -Wall -Werror -o ctor_dtor ctor_dtor.cpp):

  construct Member    construct Base    construct first
  construct second    construct Derived body
--- Derived fully constructed ---
  destruct  Derived body    destruct  second    destruct  first
  destruct  Base            destruct  Member
--- array of 3 Widgets (no-argument ctor) ---
  Widget() is constructing element 0    (共三次,n_ 都是 0)
  ~Widget(0) releases its own buffer    (共三次,逆序)

【代码做什么?】

  1. 进入作用域构造 Derived d:输出证明构造顺序是 基类 → 成员(按声明顺序)→ 自己的函数体;离开作用域析构顺序完全相反:函数体 → 成员(逆序)→ 基类。
  2. new Widget (7) 在堆上构造:构造函数在分配之后立即执行;new Widget[3]每个元素调用无参构造函数(实测三次 Widget(),字段 n_ 均为 0),delete[] 逆序析构三个元素。

【底层机制透视】

  • RAII(Resource Acquisition Is Initialization):资源在构造函数获取、在析构函数释放,于是「忘了释放」在 C++ 里结构上不可能发生——只要对象离开作用域,析构一定被调用;C 里必须在每一条返回路径上手工 free
  • 顺序规则来源(讲义 593 slide 13):初始化列表的执行顺序不受书写顺序影响,固定为「基类(按继承列表顺序)→ 成员(按声明顺序)」;析构顺序恰为构造的逆序,这是栈式内存管理的自然结果。初始化应写在初始化列表而不是函数体(slide 14),否则成员被默认构造一次后又赋值,属于无谓的工作。
  • 析构函数不销毁「指针成员所指的对象」:实测 m_first_second_ 都自动析构,但 buf_ 必须由 ~Widget 里的 delete[] 显式释放。
  • 析构函数不会在异常终止时运行(slide 16):exit()、段错误、除零崩溃都不运行析构函数;RAII 保护的是正常控制流

【内存布局图解】

构造方向 ──────────────────────────────────────────►
 [1] 基类 Base          [2] 成员 first_  [3] 成员 second_  [4] Derived 函数体
     └─ 内部成员 m_ 更先构造
◄──────────────────────────────────────────  析构方向
 [4'] 函数体  [3'] second_  [2'] first_  [1'] 基类 Base(成员 m_ 最后)
对象在栈上(继承 = 基类子对象在偏移 0):
   &d ──► +------------------------+
          | Base 子对象(vptr+m_)  |   ← &d 与 (Base*)&d 位模式相同
          +------------------------+
          | Tracer first_ / second_|
          +------------------------+
new Widget (7):1) operator new(sizeof(Widget)) 取内存
                2) 在该地址上执行 Widget::Widget(7)  3) 结果 = 该地址
   Widget 对象                     另一块堆内存
   +---------------+              +-------------------------+
   | n_  = 7       |              | 8 字节(buf_ 指向这里)  |
   +---------------+              +-------------------------+
   | buf_ ---------|-------------> ^  析构函数负责 delete[] 它
   +---------------+

【与汇编的对应】

        LEA   R0, d                  ; R0 = 对象地址(相当于 this)
        JSR   Derived_ctor
Derived_ctor
        STR   R7, R5, #2             ; 保存返回地址
        ADD   R1, R0, #0             ; R1 = this(基类子对象在偏移 0)
        JSR   Base_ctor              ; 1) 先构造基类子对象
        LEA   R2, FIRST_STR
        ADD   R1, R0, #2             ; first_ 的偏移
        JSR   Tracer_ctor            ; 2) 再按声明顺序构造成员
        ADD   R1, R0, #4
        JSR   Tracer_ctor            ; second_
        RET                          ; 3) 最后执行构造函数体

示例 4:C++ virtual、隐藏的 this 指针与 vtable 探测

代码 (C++)

// this_ptr.cpp —— 成员函数有隐含的第一个参数
class Counter {
public:
    explicit Counter (std::int32_t start) : count_(start) {}
    void bump (std::int32_t by) { count_ += by; }  // 实为 this->count_ += by;
private:
    std::int32_t count_;
};
int main () { Counter c (10); c.bump (5); std::cout << c.value () << "\n"; }

// virtual.cpp —— 抽象基类 + 运行期多态
class Shape {
public:
    Shape (const std::string& name) : name_(name) {}
    virtual ~Shape () {}
    virtual double area () const = 0;                 // 纯虚:抽象基类
    virtual void   describe () const { std::cout << name_; }
    void tag () const { std::cout << "[shape tag]"; } // 非虚:静态绑定
private:
    std::string name_;
};
class Circle : public Shape {
public:
    Circle (double r) : Shape ("circle"), r_(r) {}
    double area () const override { return 3.14159265358979 * r_ * r_; }
    void   describe () const override { std::cout << "circle(r=" << r_ << ")"; }
private:
    double r_;
};
int main ()
{
    Circle c (2.0);  Rectangle r (3.0, 4.0);
    Shape* shapes[2] = { &c, &r };
    for (Shape* s : shapes) { s->describe (); s->area (); s->tag (); }
}

真实运行结果(含真实反汇编、真实 GDB 输出、真实符号表):

000000000040123c <Counter::bump(int)>:      ← 真实 objdump 输出
  401240:	mov    %rdi,-0x8(%rbp)   ; 第 1 个整数参数寄存器 = this!
  401244:	mov    %esi,-0xc(%rbp)   ; 第 2 个 = by
  40124b:	mov    (%rax),%edx       ; edx = this->count_(偏移 0)
(gdb) break Counter::bump
(gdb) run
Breakpoint 1, Counter::bump (this=0x7fffffffb15c, by=5) at this_ptr.cpp:10
(gdb) info args
this = 0x7fffffffb15c
by = 5
sizeof (Shape) = 40   sizeof (Circle) = 48   sizeof (Rectangle) = 56
circle(r=2) area = 12.5664 [shape tag]   rectangle(w=3,h=4) area = 12 [shape tag]
address of b = 0x7ffe5ce32e70  b's vptr = 0x4020e8
address of d = 0x7ffe5ce32e60  d's vptr = 0x4020c0  different? yes  sizeof (Base) = 16
0000000000403160 V vtable for Shape          ← nm -C virtual
0000000000403130 V vtable for Circle
0000000000403100 V vtable for Rectangle

【代码做什么?】

  1. GDB 显示成员函数参数列表里第一个就是 this=0x7fffffffb15c——与 C 版本手写的 self 完全等价;反汇编确认 %rdi(x86-64 第一个整数参数寄存器)里放 thiscount_ 访问是 (%rax),即偏移 0。
  2. vptr_probe 读出两个不同的 vtable 地址;nm -C 显示 vtable for Shape/Circle/Rectangle 三个符号——每类一份,与示例 2 手写的 CIRCLE_VTAB/RECTANGLE_VTAB 一一对应。
  3. 循环里 s->describe()s->area() 动态分派(输出 circle(...)rectangle(...)),而 s->tag() 永远输出 [shape tag]

【底层机制透视】

  • this 是隐式的,不是没有(讲义 591 slide 23):类里的 int32_t memFunc (char x, double* y); 实际签名是 int32_t memFunc (MyClass* this, char x, double* y);,因此成员函数「遵守通常的调用约定」,不需要任何硬件支持。
  • virtual 必须写在基类里(讲义 591 slide 37 的陷阱):virtual 被派生类继承,但不会反向传播;只在派生类写 virtual,通过基类指针调用仍走基类版本。
  • 两种绑定时机describe/area 是虚函数 → 运行期按 vptr 决定;tag 不是 → 编译期按指针静态类型决定,永远调 Shape::tag。含纯虚函数(= 0)的抽象基类不能实例化,只能作接口,强制每个派生类提供 area,把「忘了实现」变成编译错误。
  • sizeof (Shape) = 40 = std::string 成员 32 B + vptr 8 B;Circle 再加一个 double 与对齐共 48 B。空间代价是每对象一个指针,时间代价是每次调用两次 load——不用虚函数就不付这个代价。
  • 容器与迭代器(讲义 584)正是靠「基类指针 + 虚函数」实现的:容器代码只处理 Animal*,具体行为由运行时类型决定。讲义 584 的 dl_execute_on_all (double_list_t* head, dl_execute_func_t func, void* arg) 是同一思想的 C 版本——回调返回 DL_CONTINUE / DL_REMOVE_AND_CONTINUE / DL_FREE_AND_CONTINUE 等枚举值决定容器如何行动;dl_firsthead->next 是否等于 head 判断空表。

【内存布局图解】

  对象 b (Base)                              vtable for Base
  +--------------------+                  +--------------------------+
  | vptr = 0x4020e8  --|----------------> | &Base::~Base (D1)        | 偏移 0
  +--------------------+                  | &Base::~Base (D0)        | 偏移 8
  | extra = 0x2222     |                  | &Base::tag               | 偏移 16
  +--------------------+   sizeof == 16   +--------------------------+
  对象 d (Derived)                           vtable for Derived
  +--------------------+                  +--------------------------+
  | vptr = 0x4020c0  --|----------------> | &Derived::~Derived (D1)  | 偏移 0
  +--------------------+                  | &Derived::~Derived (D0)  | 偏移 8
  | extra = 0x2222     |                  | &Derived::tag            | 偏移 16
  +--------------------+                  +--------------------------+
       (padding 4 B)                       ↑ 只有被 override 的项被替换
虚函数 d.tag():mov (%rdi),%rax → mov 0x10(%rax),%rax → call *%rax

【与汇编的对应】

; LC-3 的虚函数调用:与示例 2 完全一样,只是表由编译器填
; R0 = 对象指针
        LDR   R1, R0, #0         ; R1 = *(R0+0) = vptr(vtab 在结构体最前面)
        LDR   R2, R1, #1         ; R2 = vtab[1] = 要调用的函数地址
        JSRR  R2                 ; 间接调用
        RET
; 对比:非虚成员函数,编译器直接生成 JSR,连 vptr 都不看
        JSR   Shape_tag          ; R0 = this 已经就位

示例 5:C++ 引用、运算符重载、模板 vs 虚函数

代码 (C++)

// refs.cpp —— 三种参数语义
void by_value     (std::int32_t  x) { x = 99; }   // 改副本,调用者看不到
void by_pointer   (std::int32_t* x) { *x = 99; }  // 调用点必须写 &
void by_reference (std::int32_t& x) { x = 99; }   // 静默的输出参数!
std::int32_t sum (const std::int32_t& a, const std::int32_t& b) { return a + b; }
std::int32_t& r = a;    // r 是 a 的别名

// overload.cpp —— 复数类与运算符重载
class Complex {
public:
    Complex (double re, double im) : re_(re), im_(im) {}
    Complex (double re) : re_(re), im_(0.0) {}     // 单参数构造 => 隐式转换
    Complex& operator+= (const Complex& rhs)
    { re_ += rhs.re_; im_ += rhs.im_; return *this; }
    friend Complex operator+ (const Complex& x, const Complex& y);
    friend Complex operator* (const Complex& x, const Complex& y);
private:
    double re_, im_;
};
Complex operator* (const Complex& x, const Complex& y)
{
    return Complex (x.re_ * y.re_ - x.im_ * y.im_,  // 返回整实例(栈上临时量)
                    x.re_ * y.im_ + x.im_ * y.re_);
}
// template_vs_virtual.cpp —— 两种多态
template <typename T> T twice (const T& x) { return x + x; }      // 编译期
class Animal { public: virtual std::string sound () const = 0; }; // 运行期

真实运行结果:

after by_value(a):     a = 1     p         = (1+2i)     twice(21)      = 42
after by_pointer(&a):  a = 99    p + q     = (4+1i)     twice(1.5)     = 3
after by_reference(a): a = 99    p * q     = (5+5i)     twice(string)  = abab
sum(a, b) = 101                  p*p + q*q = (5-2i)     Box<int>       = 42
a = 1234, r = 1234,              p * 2.0   = (2+4i)     Box<string>    = hello
&a == &r ? yes                   2.0 * p   = (2+4i)     animal says woof / meow

【代码做什么?】

  1. by_value(a) 传副本,a 保持 1;by_pointer(&a)by_reference(a) 都把 a 改成 99;r = a&a == &r 为真——引用就是别名
  2. p*p + q*q 在 C 里要写 complex_add (complex_multiply (P,P), complex_multiply (Q,Q)),C++ 里直接写数学式(讲义 595 slide 6 的原始动机)。2.0 * pp * 2.0 都工作:单参数构造函数 Complex(double) 提供从 double隐式转换friend 函数让左右操作数对称。
  3. twice<int>/twice<double>/twice<std::string>三个独立函数实例(编译期确定目标);Animal::sound() 是同一调用点在运行期分派。

【底层机制透视】

  • 引用在底层就是一个指针(讲义 595 slide 16):「引用被实现得与指针完全相同,但在语法上等价于它所指向的基类型。」实测 &a == &r 证实;区别只在引用不能为 NULL不能重新绑定(单赋值,slide 17)。
  • 引用是「容易被滥用」的特性(slide 23–25):把参数从值改成非常量引用,调用点不会产生任何警告,「某参数可能被改变」这一信息就消失了。讲义处方:能用 const 引用就用 const 引用;需要修改的参数用指针,这样调用点一定会出现 &
  • 运算符重载能毁掉可读性(讲义 596 slide 3):「C++ 允许极其细微的差别——请自担风险。」operator+= (int)operator+= (char) 是两个不同重载,用起来和把两个变量命名成 VaRiAbLevArIaBlE 差不多。Lumetta 的建议很直接:如果你不知道重载解析的答案,不要查,直接别用——因为新函数可能「偷走」既有代码的调用(slide 6)。
  • operator[] 可能不再等价于 *(596 slide 8):C 里 array[10] 必然等价于 *(array + 10);C++ 里前者调 operator[],后者调 operator+operator*两者可以定义得互不相容。另外 ALPHA b = a;拷贝构造b = a;b 已存在)用 operator=重写其中一个不会重写另一个,编译器不会警告(596 slide 10–11)。
  • 返回实例会构造栈上临时量(595 slide 12、28–32):理论上 complex b = a + a; 会调用三次构造函数;实践中编译器用具名返回值优化 (NRVO),由调用者在自己的栈帧里为 b 留空间、把指向它的指针作为隐含首参数传给 operator+,于是只构造一次。
  • 模板 vs 虚函数:模板是编译期多态(零运行时开销,但目标类型必须编译期已知,每类型生成一份代码);虚函数是运行期多态(一次 vptr 间接加两次 load,但类型可以运行期才确定)。两者不可互相替代。

【内存布局图解】

引用 r 与变量 a(同一地址):        模板实例化(.text 里三份独立代码):
 +------------------+               twice<int>         -> 整数加法
 | a = 1234         |               twice<double>      -> SSE 浮点加法
 +------------------+  ← &a == &r  twice<std::string>  -> std::string::operator+
 | r = &a 的地址     |              (-O0 下引用占一个指针大小的单元,
 +------------------+                优化后常直接当别名,不占存储)
虚函数分派(运行期,一份代码):
   +------------------+     对象 Dog              vtable for Dog
   | &Dog 对象        |---->+------------+       +----------------+
   +------------------+     | vptr ------|------>| &Dog::sound    |
   | &Cat 对象        |---->+------------+       +----------------+
   +------------------+     | vptr ------|--+    vtable for Cat
                            +------------+  +--->| &Cat::sound    |
   同一个调用点 a->sound() 对两个对象走两张不同的表。

【与汇编的对应】

; LC-3:引用参数与指针参数生成的代码完全相同
by_reference
        ; R0 = &x(引用参数传的就是地址)
        AND   R1, R1, #0
        ADD   R1, R1, #15
        ADD   R1, R1, #15
        ADD   R1, R1, #15
        ADD   R1, R1, #15
        ADD   R1, R1, #15
        ADD   R1, R1, #15        ; R1 = 90
        ADD   R1, R1, #9         ; R1 = 99
        STR   R1, R0, #0         ; *(&x) = 99,与 by_pointer 一模一样
        RET
by_value                         ; 只改自己栈帧里的副本,对外界无影响
        ADD   R0, R0, #1
        RET

示例 6:new/delete、数组与虚析构函数(Valgrind 实证)

代码 (C++) —— 第二段是明确标注「仅供演示、请勿模仿」的对照实验

// destructor_virtual.cpp —— 一个写错、一个写对
class BadBase {                       // 析构函数不是 virtual:错
public:
    BadBase () : buf_ (new char[1024]) { }
    ~BadBase () { delete[] buf_; }
private:
    char* buf_;
};
class BadDerived : public BadBase {
public:
    BadDerived () : extra_ (new char[1024]) { }
    ~BadDerived () { delete[] extra_; }
private:
    char* extra_;
};
class GoodBase {                      // 析构函数是 virtual:对
public:
    GoodBase () : buf_ (new char[1024]) { }
    virtual ~GoodBase () { delete[] buf_; }
private:
    char* buf_;
};
int main ()
{
    BadBase*  bad  = new BadDerived ();
    delete bad;    // 只运行 ~BadBase,extra_ 那 1024 字节泄漏
    GoodBase* good = new GoodDerived ();
    delete good;   // ~GoodDerived 再 ~GoodBase
    return 0;
}
// newdelete_mismatch.cpp —— 仅供演示,请勿模仿!两处未定义行为
    Tracked* a = new Tracked ();
    free (a);                 /* UB:析构函数根本不会运行 */
    Tracked* b = new Tracked[3];
    delete b;                 /* UB:错误释放器 + 只析构 1 个而非 3 个 */

真实运行结果与真实 Valgrind / 编译器输出:

  ~BadBase runs                        ~GoodDerived runs   ~GoodBase runs
==1221098== 1,024 bytes in 1 blocks are definitely lost in loss record 1 of 2
==1221098==    definitely lost: 1,024 bytes in 1 blocks
==1221098==    indirectly lost: 0 bytes in 0 blocks
==1221098==      possibly lost: 0 bytes in 0 blocks
==1221098==    still reachable: 4,096 bytes in 1 blocks
==1221098== ERROR SUMMARY: 1 errors from 1 contexts
# g++ -g -std=c++17 -Wall -Werror   真的把 new/delete 不匹配拦下来了!
newdelete_mismatch.cpp:30:10: error: 'void free(void*)' called on pointer returned
    from a mismatched allocation function [-Werror=mismatched-new-delete]
newdelete_mismatch.cpp:34:12: error: 'void operator delete(void*, std::size_t)' called
    on pointer returned from a mismatched allocation function
cc1plus: all warnings being treated as errors
# 去掉 -Werror 让它跑起来:析构次数不对,然后崩溃
  Tracked() acquired a 64-byte buffer      (共三次构造)
  ~Tracked() released its buffer           ← 只析构了 1 个,不是 3 个
munmap_chunk(): invalid pointer   Aborted (core dumped)
==1221491== Mismatched free() / delete / delete []
==1221491==    by 0x401220: main (newdelete_mismatch.cpp:30)
==1221491== Invalid free() / delete / delete[] / realloc()
==1221491==    by 0x401296: main (newdelete_mismatch.cpp:34)
==1221491==  Address 0x4da7d98 is 8 bytes inside a block of size 32 alloc'd

【代码做什么?】

  1. BadBase 析构函数不是虚函数:delete bad 时编译器只看到静态类型 BadBase*,只调 ~BadBaseBadDerivedextra_ 那 1024 字节永久泄漏(Valgrind 实测「1,024 bytes definitely lost」)。
  2. GoodBase 析构函数是 virtualdelete good 通过 vtable 找到 ~GoodDerived,释放 extra_自动接着调用 ~GoodBase 释放 buf_
  3. 对照实验里 free(a) 完全跳过析构函数;delete b 用在 new Tracked[3] 上时实测析构函数只运行一次,随后 munmap_chunk(): invalid pointer 崩溃。

【底层机制透视】

  • malloc 不调用构造函数,free 不调用析构函数(讲义 594 slide 2):构造 = 「分配内存」+「在内存上执行构造函数」两步,只有 new 做第二步,因此在 C++ 中不要用它们管理类实例。
  • new[] / delete[] 必须配对(讲义 594 slide 6 的原话:「选错了就祝你好运找到那个 bug」):实现通常在数组前面存一个元素个数delete[] 逐个析构,而 delete 不读这个计数,于是既用错释放器,也只析构第一个元素。
  • 虚析构函数是「通过基类指针删除派生对象」的必要条件(讲义 593 slide 17):ParentClass* p = new MyClass; delete p; 若基类析构不是 virtual,调用的就是错的那个;加 virtual 的成本只是「对象多一个 vptr、销毁多一次间接调用」。
  • 一个真实发现的更新:讲义说编译器不会警告,但在本机 GCC 12.2.0 上 -Wall -Werror 确实会报 -Wmismatched-new-delete 并拒绝编译。这是坚持使用 -Wall -Werror 的最有力理由之一——工具在进步,老经验需要被重新检验。
  • new 失败时抛异常(594 slide 4):默认会终止程序;要得到 NULL 需写 new (std::nothrow) MyClass (...)(含 #include <new>),此时构造函数不会被调用。值初始化(slide 8):new MyClass ()(带括号)先清零所有非实例字段再调构造函数;new MyClass(不带括号)不保证清零。

【内存布局图解】

delete bad;(析构函数不是 virtual)        delete good;(析构函数是 virtual)
  bad (静态类型 BadBase*)                   good (静态类型 GoodBase*)
  +------------------+                     +------------------+
  | 指向 BadDerived  |                     | 指向 GoodDerived |
  +------------------+                     +------------------+
  静态类型是 BadBase* ==>                   取 vptr -> 取析构函数地址 ->
  直接生成 call BadBase::~BadBase,        间接调用 -> ~GoodBase 自动接着跑
  不查 vtable ==> extra_ 泄漏 1024 字节     (实测输出 ~GoodDerived、~GoodBase)
new Tracked[3] 的实际内存(典型实现):
 +-------------+------------+------------+------------+
 | 元素个数 = 3 | Tracked[0] | Tracked[1] | Tracked[2] |
 +-------------+------------+------------+------------+
 ^ delete[] 从这里读回 3,因此能析构 3 次
 ^ delete   不认识这个计数 -> 崩溃 / 只析构一次

【与汇编的对应】

; LC-3 中的「虚析构」:delete 必须通过对象的 vtab 找到真实析构函数
; R0 = 对象指针(静态类型是基类)
        LDR   R1, R0, #0         ; R1 = vptr
        LDR   R2, R1, #0         ; R2 = vtab[0] = 该对象的真实析构函数
        JSRR  R2                 ; 间接调用(先跑派生类析构函数体)
        RET                      ; 返回前自动 JSR 基类析构函数
; 若析构函数不是 virtual,编译器直接生成常量地址:
        JSR   Base_destructor    ; 永远只跑基类版本

【C 与 C++ 对照总结表】

关心的能力纯 C 的做法C++ 的做法底层机制是否相同
数据与操作绑定struct + 首参数为句柄的函数:mem_system_allocate (ms, n)class + 成员函数:ms->allocate (n)相同this 就是那个首参数,都走 %rdi
隐藏实现结构体定义放 .c,头文件只留不完整类型与原型的句柄惯用法private/protected/public 访问说明符不同:C 靠文件作用域(编译器看不见就无法访问),C++ 靠编译器检查,无硬件保护
多态分派手写函数指针表(shape_vtab_t)+ 手动填表virtual 函数 + 编译器生成的 vtable相同:都是「两次 load + JSRR/间接 call」;vtable 每类一份、vptr 每对象一份
释放资源到处手写 free,每条返回路径都要记得析构函数在作用域结束时自动运行(RAII)不同:C++ 的析构调用由编译器插入;而 malloc/free 对构造/析构一无所知
通用容器 / 泛型算法void* + 元素大小 + 比较回调(讲义 583 isort、584 dl_execute_on_alltemplate <typename T>不同void*运行期泛化(一份代码 + 指针间接),模板是编译期泛化(每类型一份,无间接)
借用别名而不复制T*,调用点必须写 &xconst T&(只读)或 T*(可写)相同:引用在底层就是一个指针;但不能为 NULL、不能重绑定

常见错误与调试技巧

  • 基类子对象不在第一个成员struct derived_t { int tag; base_t b; };,现象是 (base_t*)ptr 之后访问的字段全是垃圾。调试printf ("offsetof = %zu\n", offsetof (derived_t, b)); 正常应为 0;或在 GDB 里对比 p &dp &d.b
  • 函数指针类型写错(漏 const、参数类型不符):-Wall 报 incompatible pointer type。调试:不要用强转掩盖,用 gcc -g -std=c99 -Wall -Werror 让它拒绝编译;或在 GDB 里 ptype CIRCLE_VTAB 检查表的真实签名。
  • virtual 只写在派生类里ParentClass* ptr = &m; ptr->aFunc(); 仍调用 ParentClass::aFunc调试:在基类定义里给 aFuncvirtual;用 nm -C yourprog \| grep vtable 确认真的生成了 vtable 符号。
  • delete 用在 new[] 得到的指针上:析构次数不对,随后 munmap_chunk(): invalid pointer 崩溃。调试g++ -g -std=c++17 -Wall -Werror(GCC 12 报 -Wmismatched-new-delete);若已编译通过,用 valgrind --leak-check=full ./progMismatched free() / delete / delete []
  • 基类析构函数不是 virtual 却通过基类指针删除:派生类独有的资源静默泄漏。调试valgrind --leak-check=full --show-leak-kinds=all ./progdefinitely lost 的大小与来源;只要层次里有资源成员,就给基类加 virtual ~Base ()
  • 在构造函数或析构函数里调用虚函数:vptr 此时指向正在构造/析构的那一层,不会分派到派生类版本。调试:在 GDB 里 break Derived::Derived,用 x/2gx thisprint *this 观察 vptr 的变化。
  • printf 当调试器而不是 GDB:临时打印既污染代码又看不到栈帧。调试:改用 gdb -tui --args ./prog arg1;进 GDB 后 b file.c:24display ip *ptrbt,观察完直接改源码重新编译,不必删除调试语句。

关键要点

  • 面向对象的四个思想是设计思想,不是语言特性。 C 用「结构体 + 首参数为句柄的函数 + 文件作用域 + 函数指针表」就能完整表达封装、信息隐藏、继承与多态;C++ 的价值是让编译器自动完成这些手工劳动,并额外提供访问控制检查与自动清理。
  • 单继承的 ABI 实现只有一句话:把基类子对象放在偏移 0。 由此 &derived == &derived.base,向上转型零开销且合法(C99 6.7.2.1p13);向下转型不安全,因为无法知道基类后面是什么。
  • C++ 的 virtual 与 C 的函数指针表是同一个机器机制。 调用成本是「取 vptr、取函数地址、间接调用」——两次 load 加一次 JSRR/间接 call;vtable 每类一份放在只读段,vptr 每对象一份,代价换来运行期类型决定权。
  • 析构函数是 C++ 相对 C 最真实的收益(RAII)。 但要注意:malloc/free 不调用构造/析构、new[]/delete[] 必须配对、基类析构函数必须 virtual;异常终止(崩溃、exit)不会运行析构函数。
  • 新特性都带新陷阱:引用可能静默地变成输出参数(讲义 595「引用很容易被滥用」),运算符重载可以毁掉可读性(讲义 596「如果不知道答案,就别用」),拷贝构造与赋值不等价且编译器不警告。收益来自克制地使用这些特性。

思考题(带答案)

问题 1. 下面这段 C 代码为什么是安全的?如果把 base 移到 derived_t 的第二个成员,会发生什么?

typedef struct { const void* vtab; int id; } base_t;
typedef struct { base_t base; double radius; } derived_t;
base_t*   p = (base_t*)&d;   /* 安全吗? */

答案. 安全。C99 6.7.2.1p13 规定:指向结构体对象的指针经适当转换后指向该结构体的初始成员。因为 base 是第一个成员,它与 d 地址相同,所以 (base_t*)&d&d.base 位模式一致,不产生任何指令。若把 base 移到第二位,&d.base 就变成 &d + sizeof(第一个字段)(还可能加对齐填充),此时 (base_t*)&d 指向的位置不是一个 base_t 对象,属于未定义行为——p->id 会读到错误的内存。

问题 2. 下面 C++ 程序输出什么?为什么?

class Base {
public:
    Base () { who (); }
    virtual void who () const { std::cout << "Base::who\n"; }
    virtual ~Base () {}
};
class Derived : public Base {
public:
    Derived () { who (); }
    void who () const override { std::cout << "Derived::who\n"; }
};
int main () { Derived d; }

答案. 输出两行:先 Base::who,再 Derived::who。构造 Derived d基类子对象先构造,此期间对象 vptr 被设为该子对象的 vtable(Base::who),所以 Base::Base() 里的 who() 分派到 Base::who。等基类构造完成、进入 Derived::Derived() 的函数体之前,vptr 才被改写成 Derived 的 vtable,此时 who() 才分派到 Derived::who。这就是「构造函数期间不要调用虚函数」的原因:你以为会调用派生类版本,实际不会。

问题 3. 为什么 C++ 编译器生成的 vtable 不需要任何硬件支持?请从内存与指令两个层面说明。

答案. 因为 vtable 只是普通的只读数据,vptr 只是对象里的普通指针字段。实现多态只需要三条已有指令:一条 load(从对象取 vptr)、另一条 load(从 vtable 固定偏移取函数地址),再加一条间接跳转。LC-3 用 LDR + LDR + JSRR 就够(JSRR 本是为「子程序地址存在寄存器里」而设计,函数指针与回调一讲已经用过它);x86-64 上对应 mov (%rdi),%rax + mov 0x8(%rax),%rdx + call *%rdx,本讲的真实反汇编输出已完全印证。所以多态是编译器与 ABI 的约定,不是 CPU 的特性。