Lecture 20: 面向对象编程概念及其在 C/C++ 中的实现 (Object-Oriented Concepts: Information Hiding and Encapsulation in C, and the Move to C++)
Lecture 20: 面向对象编程概念及其在 C/C++ 中的实现 (Object-Oriented Concepts: Information Hiding and Encapsulation in C, and the Move to C++)
概述
本讲回答一个看似矛盾的问题:面向对象 (object-oriented) 的四个核心思想——封装、信息隐藏、继承、多态——需要语言提供什么? 答案是几乎不需要:它们是设计思想,在纯 C 里用「结构体 + 首参数为句柄的函数 + 文件作用域 + 函数指针表」就能完整表达,而这正是你写过的 mem220、stack、player 模块在做的事。本讲先在 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) 与函数原型。作用域与存储期:对象状态在.c的static变量(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) = 40、sizeof (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
【代码做什么?】
mem220_allocate(1000)把请求加上头部大小(64 位机实测 16 字节),用log2_ceil求 bin 号(10),优先从 bin 的空闲链表取块,返回new_block + 1即跳过头部的地址;mem220_free用ptr[-1]找回头部并挂回 bin。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++ 只是把它藏了起来。 - 负下标是合法 C:
mem_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 ; 间接调用
【代码做什么?】
circle_init/rectangle_init把base.vtab指向该类唯一的那张表再填数据字段——这就是「构造函数」;&c.base与&c实测是同一个地址,故shapes[0] = &c.base;不产生任何指令。shapes[i]->name按固定偏移 8 读取(静态绑定);shape_area (shapes[i])走 vtable:i = 0调到circle_area,i = 1调到rectangle_area。dispatch_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.1p13(
base在偏移 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 (共三次,逆序)
【代码做什么?】
- 进入作用域构造
Derived d:输出证明构造顺序是 基类 → 成员(按声明顺序)→ 自己的函数体;离开作用域析构顺序完全相反:函数体 → 成员(逆序)→ 基类。 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
【代码做什么?】
- GDB 显示成员函数参数列表里第一个就是
this=0x7fffffffb15c——与 C 版本手写的self完全等价;反汇编确认%rdi(x86-64 第一个整数参数寄存器)里放this,count_访问是(%rax),即偏移 0。 vptr_probe读出两个不同的 vtable 地址;nm -C显示vtable for Shape/Circle/Rectangle三个符号——每类一份,与示例 2 手写的CIRCLE_VTAB/RECTANGLE_VTAB一一对应。- 循环里
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_first用head->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
【代码做什么?】
by_value(a)传副本,a保持 1;by_pointer(&a)与by_reference(a)都把a改成 99;r = a后&a == &r为真——引用就是别名。p*p + q*q在 C 里要写complex_add (complex_multiply (P,P), complex_multiply (Q,Q)),C++ 里直接写数学式(讲义 595 slide 6 的原始动机)。2.0 * p与p * 2.0都工作:单参数构造函数Complex(double)提供从double的隐式转换,friend函数让左右操作数对称。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)是两个不同重载,用起来和把两个变量命名成VaRiAbLe与vArIaBlE差不多。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
【代码做什么?】
BadBase析构函数不是虚函数:delete bad时编译器只看到静态类型BadBase*,只调~BadBase,BadDerived的extra_那 1024 字节永久泄漏(Valgrind 实测「1,024 bytes definitely lost」)。GoodBase析构函数是virtual:delete good通过 vtable 找到~GoodDerived,释放extra_后自动接着调用~GoodBase释放buf_。- 对照实验里
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_all) | template <typename T> | 不同:void* 是运行期泛化(一份代码 + 指针间接),模板是编译期泛化(每类型一份,无间接) |
| 借用别名而不复制 | 传 T*,调用点必须写 &x | 传 const 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 &d与p &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。调试:在基类定义里给aFunc加virtual;用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 ./prog找Mismatched free() / delete / delete []。- 基类析构函数不是 virtual 却通过基类指针删除:派生类独有的资源静默泄漏。调试:
valgrind --leak-check=full --show-leak-kinds=all ./prog看definitely lost的大小与来源;只要层次里有资源成员,就给基类加virtual ~Base ()。 - 在构造函数或析构函数里调用虚函数:vptr 此时指向正在构造/析构的那一层,不会分派到派生类版本。调试:在 GDB 里
break Derived::Derived,用x/2gx this或print *this观察 vptr 的变化。 - 用
printf当调试器而不是 GDB:临时打印既污染代码又看不到栈帧。调试:改用gdb -tui --args ./prog arg1;进 GDB 后b file.c:24、display i、p *ptr、bt,观察完直接改源码重新编译,不必删除调试语句。
关键要点
- 面向对象的四个思想是设计思想,不是语言特性。 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 的特性。
