🔷 第十章 类和对象
📄 一句话概括

类是图纸,对象才是实物(9.2.2 的老话)。声明对象最常用的一句是 类名 对象名;——写这一行,对象就真的存在了(占内存、有了初始状态、可以调方法)。对象也能像普通变量一样组成数组(一排对象)、用指针指着(p->)、用引用起外号(&r)。实例01 演示的就是"对象的引用":给一个已经存在的对象再起一个名字。

📝 本节和前面几节的关系

10.1.1~10.1.3 一直在造"图纸"(类),本节第一次把图纸变成实物。这里你会大量复用前面的老手艺:对象数组用 8.4 的结构体数组(几乎一模一样)、对象指针用 7.6.1 的 ->、对象引用用 7.8.1 的三条铁律(必须初始化、绑定后不能改绑、不能为空)。区别只在于:以前这些操作的是结构体或普通变量,现在操作的是对象——语法完全一样。

一、最常用的一句:类名 对象名;

class Point {
private:
    int x, y;
public:
    void set(int a, int b) { x = a; y = b; }
    void show() { cout << "(" << x << ", " << y << ")" << endl; }
};

int main() {
    Point p;             // ← 声明对象:图纸 → 实物
    p.set(3, 4);         // 用点号调用成员函数
    p.show();            // (3, 4)
    return 0;
}

这一行做了什么:

动作 说明
分配内存 按类的大小(成员变量的总和 + 对齐,8.1.5)在栈上分配一块空间
成员变量初始值 如果没写构造函数,是不确定的(和未初始化的局部变量一样,2.3 的"垃圾值")——10.2 的构造函数就是来解决这个问题的
可以开始用 用 . 访问 public 成员

如果类里没有 public 成员函数,那么声明出来的对象几乎没法用——因为私有成员外面碰不到。这也是"类必须开至少一扇门"的原因。

二、对象的其他"姿势"

对象和普通变量一样,可以有数组、指针、引用,还能做函数参数(8.3 讲过的三种交法全适用)。

① 对象数组(对照 8.4 结构体数组)

Point pts[3];                    // 一排 3 个 Point 对象
pts[0].set(1, 1);
pts[1].set(2, 2);
pts[2].set(3, 3);

for (int i = 0; i < 3; i++)
    pts[i].show();

读法和结构体数组一样:数组名[i].成员——先选行,再选列(8.4.1)。

② 对象指针(对照 7.6.1)

Point  p;
Point *ptr = &p;                 // 指针指向对象,要取地址

ptr->set(5, 6);                  // 指针用 ->
cout << (*ptr).x << endl;        // 不推荐这么写
ptr->show();                     // 推荐:箭头

口诀还是那句(8.3.2):变量点、指针箭头。

③ 对象引用(本节重点,对照 7.8.1)

Point  p;
Point &r = p;                    // r 是 p 的另一个名字(外号)

r.set(7, 8);                     // 改 r 就是改 p
p.show();                        // (7, 8)   ← p 真的变了

三条铁律(7.8.1 原文):

铁律 含义
必须初始化 Point &r; ❌ 编译不过——引用一出生就得绑定到谁身上
绑定后不能改绑 r = other; 不是"改绑到 other",而是"把 other 的值赋给 r 所绑定的对象"(即 p = other)
不能为空 引用必须有实体,没有"空引用"这种东西

三、实例01:对象的引用

场景:p1 已经存在,我们再给它起一个名字 alias,然后通过 alias 修改它——验证"改别名就是改本体"。

#include <iostream>
using namespace std;

class Point {
private:
    int x, y;
public:
    void set(int a, int b) { x = a; y = b; }
    void show(const char *who) {
        cout << who << " = (" << x << ", " << y << ")" << endl;
    }
};

int main() {
    Point p1;
    p1.set(1, 2);

    Point &alias = p1;              // ★ 关键一句:alias 是 p1 的别名
    cout << "&p1    = " << &p1    << endl;
    cout << "&alias = " << &alias << endl;   // 两个地址完全一样

    p1.show("p1 原始");
    alias.set(100, 200);            // 通过别名修改
    p1.show("p1 现在");              // p1 也变了

    // 证明"改的不是绑定,是值"
    Point p2;
    p2.set(7, 8);
    alias = p2;                     // ⚠️ 这不是改绑!是 "p1 = p2"
    p1.show("p1 被赋值后");          // (7, 8) —— p1 的值变成了 p2 的
    p2.show("p2 保持");              // (7, 8) —— p2 自己没变
    cout << "&alias 还是 " << &alias << "(== &p1,没有改绑)" << endl;
    return 0;
}

看到什么算成功(地址数字各机器不同,关键是两组地址相等):

&p1    = 0x61ff08
&alias = 0x61ff08
p1 原始 = (1, 2)
p1 现在 = (100, 200)
p1 被赋值后 = (7, 8)
p2 保持 = (7, 8)
&alias 还是 0x61ff08(== &p1,没有改绑)

读结果三句话:

  1. &p1 和 &alias 是同一个地址——它们是同一块内存的两个名字。
  2. alias.set(...) 后 p1 变了——通过别名改,就是改本体。
  3. alias = p2; 之后 p1 变成了 p2 的值,而 alias 的地址没变——这一句不是"改绑",是"赋值"。这是引用最容易误解的一处,7.8.1 专门强调过。

引用的实用场景:给长名字起短名字(比如 Point &p = group.members[0]; 之后直接写 p.set(...))、函数参数免拷贝(7.8.2 的 const T &)、返回值的别名。对象大的时候,用引用传参能省下一次整块复制。

四、实例01 的延伸:引用做函数参数(回收 7.8.2)

对象最好的用法之一:传引用进函数,函数里直接改原件,还不用复制整块。

void moveRight(Point &p, int dx) {      // 引用参数
    // p 就是外面那个对象本身
    // 这里演示用 set 重新设置(假设你知道新坐标)
}

void reset(Point &p) {                  // 常见写法:把对象恢复成默认状态
    p.set(0, 0);
}

int main() {
    Point a;
    a.set(3, 4);
    reset(a);                           // 调用处和平常一样,不写 &
    a.show("reset 后");                  // reset 后 = (0, 0)
    return 0;
}

三种交法(8.3 的表,这里是对象版):

写法 函数里能不能改原件 代价
void f(Point p) ❌ 不能(复制了一份) 复制整个对象
void f(Point *p) ✅ 能(要写 p->) 只传一个地址
void f(Point &p) ✅ 能(写成普通对象的样子) 只传一个地址

只读就用 const Point &p——不复制、也改不了,是传大对象的首选(8.3、7.8.2 的老结论)。

五、动手 Demo:三种姿势一次练完

#include <iostream>
using namespace std;

class Counter {
private:
    int value;
public:
    void init(int v) { value = v; }
    void add(int n)  { value += n; }
    void show(const char *who) {
        cout << who << " → " << value << endl;
    }
};

void bump(Counter &c) {          // 引用参数:能改原件
    c.add(1);
}

int main() {
    // ① 普通对象
    Counter a;
    a.init(10);
    a.add(5);
    a.show("a");                 // a → 15

    // ② 对象数组
    Counter arr[2];
    arr[0].init(1);
    arr[1].init(100);
    arr[0].add(1);
    arr[0].show("arr[0]");       // arr[0] → 2
    arr[1].show("arr[1]");       // arr[1] → 100

    // ③ 对象指针
    Counter *p = &arr[1];
    p->add(50);
    p->show("arr[1] via *p");    // arr[1] via *p → 150

    // ④ 对象引用(并做函数参数)
    Counter &r = arr[0];
    bump(r);
    bump(r);
    arr[0].show("arr[0] after bump");   // arr[0] after bump → 4
    return 0;
}

看到什么算成功:

a → 15
arr[0] → 2
arr[1] → 100
arr[1] via *p → 150
arr[0] after bump → 4

读结果:四种姿势操作的是同一个类,语法和操作结构体/普通变量一模一样——这就是"类只是一种自定义类型"的最好证明。对象数组里 arr[0] 和 arr[1] 各有一份自己的 value(2 和 150),互不干扰——每个对象独立存状态,这是对象的核心特征。

30 秒实验:把 void bump(Counter &c) 的参数改成传值 void bump(Counter c),重新运行。arr[0] after bump 会变回 2——因为函数改的是复制品,原件没动(6.3.1 的 swap 翻车,同一个道理)。这一改一看,传值和传引用的差别就再也忘不了。

六、常见误区

  1. "声明对象不用写 ( ) "——本节的 Point p; 确实不用(因为还没学构造函数)。但 10.2 会看到 Point p(3, 4); 这种带括号的写法——那时括号里是构造函数的实参。看到括号不要慌:它是在"出生时"传参给构造函数。
  2. "对象引用可以用 &r = other 改绑"——不行。r = other; 是赋值(把 other 的值拷给 r 绑定的那个对象)。引用一旦绑定,一辈子只认那一个对象(7.8.1 的铁律)。
  3. "对象指针和对象引用差不多,随便选"——用途不同:引用更安全(必初始化、不能空、不用 ->),能不用指针就用引用;指针能改指向(比如遍历数组时 p++)、可以为空(表示"还没有对象")、能做数组。判据:"这个'谁'会不会变?" 会变用指针,不变用引用。
  4. "对象数组的元素必须一个个 set 初始化"——不一定。如果类写了构造函数,可以在声明数组时统一初始化(10.2 之后就能写 Point pts[3] = {Point(1,1), Point(2,2), Point(3,3)};)。现在只知道有这条路,到 10.2 再回来看。
  5. "p.show() 里的 p 就是函数里说的 x"——不完全是。show() 只是被 p 调用了;show() 里的 x 到底是谁的 x,取决于是哪个对象在调用它。这个"当前对象"的机制,10.4.4 用 this 指针一次说清。
自测三问(点开对答案) 1. `Point p;` 这一行到底做了什么?——**按类的大小在栈上分配内存**,让 `p` 成为按图纸造出的**实体**;成员变量此时**值不确定**(等构造函数来管,10.2),但已经可以用 `.` 调用公有方法了。 2. 对象引用和对象指针,各自适合什么场景?——**引用**:必初始化、不能为空、写法像普通变量,**适合"这个对象不会换"的场合**(尤其是函数传参);**指针**:可以为空、可以改指向、能参与数组和遍历、要用 `->`,**适合"指向会变 / 可能没有"的场合**。 3. `alias = p2;` 这一句在"改绑"吗?——**不是**。它把 `p2` 的**值**赋给了 `alias` 所绑定的对象(也就是 `p1`)。**引用绑定后不能改绑**,`&alias` 始终等于 `&p1`。

相关笔记