C++ 类和对象基础:从封装理解对象生命周期

C++ 类和对象基础:从封装到对象生命周期

前言

复习 C++ 类和对象时,我一开始只记住了一个很粗的说法:类就是把变量和函数放到一起。这个说法不能算错,但它只能算入口。

后面再看构造、析构、拷贝这些内容,才发现类和对象的难点不只是 class 怎么写,而是对象在整个生命周期里到底发生了什么。比如:

  • 成员变量为什么一般要放到 private 里?
  • 构造函数和普通 Init 函数到底有什么区别?
  • 为什么有的类不用写析构,有的类必须写?
  • 拷贝构造和赋值重载为什么经常和资源管理绑在一起?
  • 初始化列表、static、友元这些语法分别解决什么问题?

文章目录

前言

[1. 类到底在解决什么问题](#1. 类到底在解决什么问题)

[2. 类的定义、访问限定符和封装](#2. 类的定义、访问限定符和封装)

[3. 对象、成员函数和 this 指针](#3. 对象、成员函数和 this 指针)

[4. 对象大小和内存对齐](#4. 对象大小和内存对齐)

[5. 默认成员函数是什么](#5. 默认成员函数是什么)

[6. 构造函数:让对象一出生就是可用的](#6. 构造函数:让对象一出生就是可用的)

[7. 析构函数:对象结束前清理资源](#7. 析构函数:对象结束前清理资源)

[8. 拷贝构造:新对象用旧对象初始化](#8. 拷贝构造:新对象用旧对象初始化)

[9. 赋值运算符重载:已经存在的对象互相赋值](#9. 赋值运算符重载:已经存在的对象互相赋值)

[10. 运算符重载:让类对象按自然方式使用](#10. 运算符重载:让类对象按自然方式使用)

[11. 流插入为什么常写成全局函数](#11. 流插入为什么常写成全局函数)

[12. const 成员函数:承诺不修改对象](#12. const 成员函数:承诺不修改对象)

[13. 初始化列表:不是语法装饰](#13. 初始化列表:不是语法装饰)

[14. explicit:挡住不想要的隐式转换](#14. explicit:挡住不想要的隐式转换)

[15. static 成员:属于类,不属于某个对象](#15. static 成员:属于类,不属于某个对象)

[16. 友元:方便,但不要滥用](#16. 友元:方便,但不要滥用)

[17. 内部类:把强相关类型收进类域](#17. 内部类:把强相关类型收进类域)

[18. 匿名对象和拷贝优化](#18. 匿名对象和拷贝优化)

[19. 复习时怎么串起来](#19. 复习时怎么串起来)

小结

一、类到底在解决什么问题

如果只用 C 语言风格写代码,我们通常会把数据和函数分开。

比如写一个学生信息:

cpp 复制代码
struct Student {
    char name[32];
    int age;
    int score;
};

void PrintStudent(const Student* stu);
void SetScore(Student* stu, int score);

这种写法能用,但数据边界几乎全靠调用者自觉,外部代码可以直接修改结构体里的字段。

cpp 复制代码
Student stu;
stu.age = -10;
stu.score = 999;

从语法上看没有问题,但从业务含义上看明显不合理。年龄不应该是负数,分数也不应该随便越界。

C++ 的类把数据和操作放到一起,并且用访问权限控制"谁能改、怎么改"。理解类时,可以先抓住这一点:

text 复制代码
类不是单纯把变量和函数包起来,而是给数据加上使用规则。

这是封装最基础的意义。

二、类的定义、访问限定符和封装

先用一个小类看封装的写法:

cpp 复制代码
#include <iostream>
#include <string>

class Student {
public:
    void SetInfo(const std::string& name, int age, int score) {
        name_ = name;
        age_ = age;
        SetScore(score);
    }

    void SetScore(int score) {
        if (score < 0) {
            score_ = 0;
        } else if (score > 100) {
            score_ = 100;
        } else {
            score_ = score;
        }
    }

    void Print() const {
        std::cout << name_ << ", age: " << age_
                  << ", score: " << score_ << '\n';
    }

private:
    std::string name_;
    int age_ = 0;
    int score_ = 0;
};

这段代码里有三个访问限定符需要先分清:

访问限定符 类外能不能直接访问 作用
public 可以 对外提供的接口
protected 不可以 主要留给派生类使用
private 不可以 类自己的实现细节

Student 的成员变量放在 private 里,外部代码不能直接写:

cpp 复制代码
// stu.score_ = 999; // 编译不过

外部只能通过 SetScore 修改分数,而 SetScore 里面可以做范围控制。封装不是单纯把成员藏起来,而是把修改数据的入口收回来,让对象按设计好的规则工作。

还有一个容易忽略的细节:class 默认访问权限是 privatestruct 默认访问权限是 public。在 C++ 中,struct 也可以写成员函数,只是习惯上更常用 class 表达有封装规则的类型。

三、对象、成员函数和 this 指针

类本身只是一个类型,对象才是这个类型创建出来的具体变量。

cpp 复制代码
int main() {
    Student s1;
    Student s2;

    s1.SetInfo("Li Hua", 18, 92);
    s2.SetInfo("Wang Ming", 19, 105);

    s1.Print();
    s2.Print();
}

s1s2 都是 Student 对象,它们各自保存一份成员变量。s1 的分数和 s2 的分数不会混在一起。

成员函数不需要每个对象都保存一份。对象里主要保存成员变量,成员函数是一份公共代码。那成员函数怎么知道自己现在操作的是 s1 还是 s2 呢?

关键就在隐藏的 this 指针。

比如我们写:

cpp 复制代码
s1.SetScore(80);

可以粗略理解成:编译器帮我们把当前对象的地址传了进去。

text 复制代码
SetScore(&s1, 80);

真实语法不是这样写,但这样理解比较顺。成员函数内部访问成员变量时,本质上是在通过 this 找到当前对象。

cpp 复制代码
void SetScore(int score) {
    this->score_ = score;
}

大多数时候 this-> 可以省略。只有在形参名和成员变量名冲突,或者需要返回当前对象本身时,this 才会比较常见。

四、对象大小和内存对齐

我之前容易误以为:类里面有成员函数,对象大小应该也要把函数算进去。

实际并不是这样。在不考虑虚函数等机制时,对象大小主要和成员变量有关,成员函数不会放进每个对象里。

cpp 复制代码
#include <iostream>

class Empty {};

class Example {
private:
    char c_;
    int i_;
};

int main() {
    std::cout << sizeof(Empty) << '\n';
    std::cout << sizeof(Example) << '\n';
}

空类对象的大小通常是 1。因为对象需要有唯一地址,如果大小是 0,多个对象的地址就不好区分。

Example 的大小也不一定是 1 + 4 = 5,因为还要考虑内存对齐。可以先这样理解:编译器会为了让 CPU 更方便访问数据,在成员之间补一些空字节。

刚开始不用死记所有对齐计算细节,先记住两个结论就够用:

  • 对象大小主要看非静态成员变量,不是看成员函数。
  • 成员变量顺序会影响对象大小,因为有内存对齐。

五、默认成员函数是什么

学到类和对象中间部分时,会遇到"默认成员函数"这个说法。

它指的是:我们不写时,编译器可能自动生成的成员函数。基础阶段先重点看这几个:

默认成员函数 作用
默认构造函数 不传实参时初始化对象
析构函数 清理对象管理的资源
拷贝构造函数 用已有对象创建新对象
赋值运算符重载 已存在对象之间赋值
取地址运算符重载 对对象取地址
const 取地址运算符重载 对 const 对象取地址

这里先按基础阶段常见的六个默认成员函数理解,不建议只背数量,更重要的是问一句:

text 复制代码
编译器默认生成的行为,够不够这个类使用?

如果类里只是普通成员变量,默认生成的函数很多时候够用。但如果类自己管理资源,比如申请堆空间、打开文件、管理锁,就要谨慎。默认拷贝可能只是浅拷贝,默认析构也不一定能完成资源释放。

后面几个小节,主要围绕构造、析构、拷贝构造和赋值重载展开。

六、构造函数:让对象一出生就是可用的

在没有构造函数时,我们可能会写一个 Init 函数:

cpp 复制代码
Student s;
s.SetInfo("Li Hua", 18, 92);

问题在于,如果忘了调用 SetInfo,对象就可能处在一个没有正确初始化的状态。

构造函数正是用来处理这件事的:对象创建时自动调用,用来完成初始化。

cpp 复制代码
class Student {
public:
    Student(const std::string& name, int age, int score)
        : name_(name), age_(age) {
        SetScore(score);
    }

    void SetScore(int score) {
        if (score < 0) {
            score_ = 0;
        } else if (score > 100) {
            score_ = 100;
        } else {
            score_ = score;
        }
    }

    void Print() const {
        std::cout << name_ << ", age: " << age_
                  << ", score: " << score_ << '\n';
    }

private:
    std::string name_;
    int age_;
    int score_;
};

使用时直接写成:

cpp 复制代码
Student s("Li Hua", 18, 92);
s.Print();

构造函数的几个基本特点:

  • 函数名和类名相同。
  • 没有返回值,连 void 也不写。
  • 创建对象时自动调用。
  • 可以重载。

默认构造函数也需要单独分清。只要"不传实参就能调用"的构造函数,都可以叫默认构造函数。

cpp 复制代码
class Point {
public:
    Point() : x_(0), y_(0) {}
    Point(int x, int y) : x_(x), y_(y) {}

private:
    int x_;
    int y_;
};

这里的 Point() 是默认构造函数。

如果写成全缺省参数,也属于不传参就能调用:

cpp 复制代码
class Point {
public:
    Point(int x = 0, int y = 0) : x_(x), y_(y) {}

private:
    int x_;
    int y_;
};

注意不要同时写一个无参构造和一个全缺省构造,否则 Point p; 调用哪个函数会产生歧义。

七、析构函数:对象结束前清理资源

构造函数负责初始化,析构函数负责对象销毁前的清理。

如果一个类只是保存 intdoublestd::string 这类成员,通常不需要自己写析构函数。编译器生成的默认析构就够用了。

但如果类里自己申请了资源,比如手动 new[] 了一块空间,就必须考虑释放问题。

cpp 复制代码
#include <algorithm>
#include <cstddef>

class IntBuffer {
public:
    explicit IntBuffer(std::size_t size)
        : data_(new int[size]{}), size_(size) {}

    ~IntBuffer() {
        delete[] data_;
    }

private:
    int* data_;
    std::size_t size_;
};

析构函数的特点:

  • 名字是 ~类名
  • 没有参数,也没有返回值。
  • 对象生命周期结束时自动调用。

这里最需要分清的是:析构函数不是用来销毁对象本身的内存,而是用来清理对象内部管理的资源。

局部对象在函数结束时,它所在的栈帧会被释放;析构函数真正负责的是把对象内部申请的堆空间、文件句柄、锁等资源处理干净。

八、拷贝构造:新对象用旧对象初始化

拷贝构造函数处理的是"用一个同类型对象初始化另一个新对象"。

cpp 复制代码
Student s1("Li Hua", 18, 92);
Student s2(s1);
Student s3 = s1;

s2(s1)s3 = s1 都是在创建新对象,因此调用的是拷贝构造。

如果类里没有自己管理资源,编译器默认生成的拷贝构造通常够用。比如 Student 里面的 std::string 自己知道怎么拷贝。

IntBuffer 这种类不能直接依赖默认拷贝构造。

cpp 复制代码
IntBuffer b1(10);
IntBuffer b2 = b1; // 如果用默认拷贝,会有问题

默认拷贝只会把指针值复制一份,也就是两个对象的 data_ 指向同一块堆空间。等两个对象析构时,就可能释放同一块空间两次。

这就是浅拷贝容易出问题的地方。

更合适的做法是自己写深拷贝:不仅复制指针变量,还要重新申请一块空间,把内容复制过去。

cpp 复制代码
class IntBuffer {
public:
    explicit IntBuffer(std::size_t size)
        : data_(new int[size]{}), size_(size) {}

    IntBuffer(const IntBuffer& other)
        : data_(new int[other.size_]), size_(other.size_) {
        std::copy(other.data_, other.data_ + other.size_, data_);
    }

    ~IntBuffer() {
        delete[] data_;
    }

private:
    int* data_;
    std::size_t size_;
};

判断要不要写拷贝构造时,可以先看这一点:

text 复制代码
如果这个类自己管理资源,就要检查默认拷贝是不是只拷贝了资源句柄。

如果只是普通成员变量,不一定需要自己写;如果有裸指针管理堆空间,就要认真考虑自己实现。

九、赋值运算符重载:已经存在的对象互相赋值

拷贝构造和赋值看起来很像,但场景不同。

cpp 复制代码
IntBuffer b1(10);
IntBuffer b2 = b1; // 拷贝构造:创建 b2

IntBuffer b3(5);
b3 = b1;           // 赋值:b3 已经存在

赋值运算符重载一般可以这样写:

cpp 复制代码
class IntBuffer {
public:
    explicit IntBuffer(std::size_t size)
        : data_(new int[size]{}), size_(size) {}

    IntBuffer(const IntBuffer& other)
        : data_(new int[other.size_]), size_(other.size_) {
        std::copy(other.data_, other.data_ + other.size_, data_);
    }

    IntBuffer& operator=(const IntBuffer& other) {
        if (this == &other) {
            return *this;
        }

        int* newData = new int[other.size_];
        std::copy(other.data_, other.data_ + other.size_, newData);

        delete[] data_;
        data_ = newData;
        size_ = other.size_;

        return *this;
    }

    ~IntBuffer() {
        delete[] data_;
    }

private:
    int* data_;
    std::size_t size_;
};

这段代码里有几个细节值得注意:

  • operator= 返回 IntBuffer&,是为了支持连续赋值,比如 a = b = c
  • 要判断自赋值,避免 a = a 时把自己的资源先释放掉。
  • 先申请新资源,再释放旧资源,能避免中途申请失败时把原对象破坏掉。

这也引出一个常见判断:如果一个类需要自己写析构函数释放资源,通常也要认真检查拷贝构造和赋值运算符。

这三个函数经常要放在一起考虑:

函数 什么时候调用 主要任务
析构函数 对象生命周期结束 清理资源
拷贝构造 用旧对象创建新对象 初始化新对象
赋值重载 已存在对象之间赋值 替换当前对象内容

十、运算符重载:让类对象按自然方式使用

运算符重载不是为了让语法看起来花哨,而是为了让类对象的使用方式更符合直觉。

比如二维点 Point,判断两个点是否相等,用 == 通常比写 IsEqual 更自然。

cpp 复制代码
#include <iostream>

class Point {
public:
    Point(int x = 0, int y = 0)
        : x_(x), y_(y) {}

    bool operator==(const Point& other) const {
        return x_ == other.x_ && y_ == other.y_;
    }

    Point operator+(const Point& other) const {
        return Point(x_ + other.x_, y_ + other.y_);
    }

    void Print() const {
        std::cout << "(" << x_ << ", " << y_ << ")\n";
    }

private:
    int x_;
    int y_;
};

使用时:

cpp 复制代码
Point p1(1, 2);
Point p2(3, 4);

Point p3 = p1 + p2;

if (p1 == p2) {
    std::cout << "same\n";
}

p3.Print();

运算符重载有几条边界:

  • 不能自己创造 C++ 没有的运算符,比如 operator@
  • 运算符的优先级和结合性不会因为重载而改变。
  • 至少有一个操作数要是类类型,不能重载两个内置类型的运算。
  • 不是所有运算符都适合重载,关键要看语义是否自然。

比如 Point + Point 比较自然,但给 Student 重载 + 就不一定有意义。

++ 有一个容易混的地方:前置 ++ 和后置 ++ 都叫 operator++,C++ 用一个 int 形参区分后置版本。

cpp 复制代码
class Counter {
public:
    Counter(int value = 0) : value_(value) {}

    Counter& operator++() {
        ++value_;
        return *this;
    }

    Counter operator++(int) {
        Counter old(*this);
        ++value_;
        return old;
    }

private:
    int value_;
};

前置 ++ 返回自增后的自己,后置 ++ 返回自增前的临时副本。这个行为要和内置类型保持一致。

十一、流插入为什么常写成全局函数

如果希望这样输出对象:

cpp 复制代码
std::cout << p1 << '\n';

一般会把 operator<< 写成全局函数,而不是成员函数。

cpp 复制代码
#include <iostream>

class Point {
public:
    Point(int x = 0, int y = 0)
        : x_(x), y_(y) {}

    int X() const { return x_; }
    int Y() const { return y_; }

private:
    int x_;
    int y_;
};

std::ostream& operator<<(std::ostream& out, const Point& p) {
    out << "(" << p.X() << ", " << p.Y() << ")";
    return out;
}

原因在于 << 左边是 std::cout,右边才是 Point 对象。如果写成成员函数,左操作数默认会变成当前对象,也就不符合 std::cout << p 这种使用习惯。

这里用 X()Y() 这样的公开接口读取私有成员。另一种做法是把 operator<< 声明成友元函数,后面会讲。

十二、const 成员函数:承诺不修改对象

成员函数后面加 const,表示这个函数不会修改当前对象。

cpp 复制代码
class Point {
public:
    Point(int x = 0, int y = 0)
        : x_(x), y_(y) {}

    int X() const {
        return x_;
    }

    int Y() const {
        return y_;
    }

private:
    int x_;
    int y_;
};

const 实际修饰的是隐藏的 this 指针。普通成员函数里,this 可以理解成:

text 复制代码
Point* const this

const 成员函数里,this 可以理解成:

text 复制代码
const Point* const this

也就是说,不能通过这个 this 修改成员变量。

这点在写只读接口时很重要。比如 Print()Size()Empty()X() 这类函数,只要不修改对象,就应该考虑加 const。这样 const 对象也能调用它们。

十三、初始化列表:不是语法装饰

刚学构造函数时,我容易把初始化列表看成"另一种赋值写法":

cpp 复制代码
Point(int x, int y)
    : x_(x), y_(y) {}

后面再看引用成员、const 成员和类类型成员时,才发现初始化列表不是装饰,它表示成员变量真正被初始化的位置。

有些成员必须在初始化列表中初始化,比如:

  • 引用成员变量。
  • const 成员变量。
  • 没有默认构造函数的类类型成员。

看一个例子:

cpp 复制代码
#include <string>

class FileName {
public:
    explicit FileName(const std::string& name)
        : name_(name) {}

private:
    std::string name_;
};

class LogRecord {
public:
    LogRecord(int id, int& level, const std::string& file)
        : id_(id), level_(level), file_(file) {}

private:
    const int id_;
    int& level_;
    FileName file_;
};

id_constlevel_ 是引用,file_ 的类型 FileName 没有默认构造函数,所以它们都必须在初始化列表中处理。

还有一个细节:成员变量的初始化顺序不是看初始化列表里写的顺序,而是看它们在类中声明的顺序。

cpp 复制代码
class Demo {
public:
    Demo(int x) : b_(x), a_(b_) {}

private:
    int a_;
    int b_;
};

这段代码看起来先初始化 b_,再初始化 a_,但实际上 a_ 声明在前,会先初始化。这样的代码很容易埋下隐患。

所以写初始化列表时,最好让顺序和成员声明顺序保持一致。

十四、explicit:挡住不想要的隐式转换

单参数构造函数可能触发隐式类型转换。

cpp 复制代码
class Id {
public:
    Id(int value) : value_(value) {}

private:
    int value_;
};

void PrintId(Id id) {}

int main() {
    PrintId(10); // int 被隐式转换成 Id
}

有时这种转换很方便,但很多时候会让代码不够明确。加上 explicit 后,就必须显式构造对象。

cpp 复制代码
class Id {
public:
    explicit Id(int value) : value_(value) {}

private:
    int value_;
};

void PrintId(Id id) {}

int main() {
    // PrintId(10);      // 不允许隐式转换
    PrintId(Id(10));     // 明确创建 Id 对象
}

可以把 explicit 理解成一个提醒:这个类型不是随便从 int 变过来的,调用者要明确表达自己的意图。

十五、static 成员:属于类,不属于某个对象

普通成员变量是每个对象一份,而 static 成员变量是所有对象共享一份。

比如可以用它统计当前创建了多少个对象:

cpp 复制代码
#include <iostream>

class ObjectCounter {
public:
    ObjectCounter() {
        ++count_;
    }

    ObjectCounter(const ObjectCounter&) {
        ++count_;
    }

    ~ObjectCounter() {
        --count_;
    }

    static int Count() {
        return count_;
    }

private:
    static int count_;
};

int ObjectCounter::count_ = 0;

int main() {
    std::cout << ObjectCounter::Count() << '\n';

    ObjectCounter a;
    ObjectCounter b;

    std::cout << ObjectCounter::Count() << '\n';
}

静态成员有几个特点:

  • 静态成员变量不属于某个具体对象。
  • 静态成员变量一般需要在类外定义和初始化。
  • 静态成员函数没有 this 指针。
  • 静态成员函数只能直接访问静态成员,不能直接访问普通成员变量。

因此,static 成员适合表示"这个类整体共享的一份信息",比如对象计数、全局配置、工厂函数等。

十六、友元:方便,但不要滥用

友元可以让一个外部函数或另一个类访问当前类的私有成员。

以前面 Point 的输出函数为例,如果不想写 X()Y() 这种访问接口,也可以把它声明成友元:

cpp 复制代码
#include <iostream>

class Point {
public:
    Point(int x = 0, int y = 0)
        : x_(x), y_(y) {}

    friend std::ostream& operator<<(std::ostream& out, const Point& p);

private:
    int x_;
    int y_;
};

std::ostream& operator<<(std::ostream& out, const Point& p) {
    out << "(" << p.x_ << ", " << p.y_ << ")";
    return out;
}

友元函数不是成员函数,只是被允许访问这个类的私有和保护成员。

友元的好处是方便,代价是会削弱封装。如果到处都开友元,类的私有成员就不再真正私有,后面维护会比较难。

可以这样记:

text 复制代码
友元是给特殊关系开的门,不是为了图省事随便开后门。

十七、内部类:把强相关类型收进类域

如果一个类型只服务于另一个类,可以考虑把它定义成内部类。

cpp 复制代码
class LinkedList {
private:
    struct Node {
        int value;
        Node* next;
    };

public:
    LinkedList() : head_(nullptr) {}

private:
    Node* head_;
};

这里的 Node 只给 LinkedList 内部使用,放在 private 里可以减少对外暴露。

内部类本质上还是一个独立的类型,只是它被放到了另一个类的作用域里,并且受访问限定符控制。

内部类的意义主要是表达"这个类型和外部类关系很近,外部不需要直接关心它"。

十八、匿名对象和拷贝优化

匿名对象就是没有名字的临时对象。

cpp 复制代码
#include <iostream>

class Task {
public:
    void Run() {
        std::cout << "run task\n";
    }
};

int main() {
    Task().Run();
}

Task() 创建了一个临时对象,用完这一行基本就结束生命周期。

匿名对象在一些临时调用里很方便,但不要为了省一个变量名,把代码写得难读。

再看拷贝优化。按语法理解,函数传参、返回对象时可能会发生构造、拷贝构造、赋值等步骤。但现代编译器会在不改变程序结果的情况下省略一些拷贝。

cpp 复制代码
Point MakePoint() {
    return Point(1, 2);
}

int main() {
    Point p = MakePoint();
}

从学习角度看,需要知道这里可能涉及临时对象和拷贝;从实际运行看,编译器可能直接把返回值构造到 p 的位置上。

这里不必一开始就纠结"到底调用了几次拷贝构造"。不同编译器、不同标准、不同优化选项下结果可能不同。更重要的是先理解对象生命周期,再知道编译器可能帮我们省掉一些中间步骤。

十九、复习时怎么串起来

如果把类和对象这块内容压缩成一条主线,可以这样记:

text 复制代码
类负责封装数据和规则,对象负责承载具体状态。
对象创建时构造,使用中可能拷贝和赋值,结束时析构。
如果类自己管理资源,就要特别小心拷贝构造、赋值重载和析构函数。

再细一点,可以按下面这张表复习:

知识点 先记什么
类和对象 类是类型,对象是实例
封装 成员变量放私有,通过接口控制访问
this 指针 成员函数靠它知道当前对象是谁
构造函数 对象创建时自动初始化
析构函数 对象结束前清理资源
拷贝构造 新对象用旧对象初始化
赋值重载 已存在对象之间赋值
运算符重载 让类对象按自然语义使用运算符
初始化列表 成员真正初始化的位置
static 成员 属于类,所有对象共享
友元 有控制地突破访问限制
内部类 把强相关的辅助类型收进类域

小结

C++ 类和对象这部分知识点不少,但主线可以收束到两件事:

  • 怎么把数据和操作组织成一个可靠的类型。
  • 怎么管理对象从创建到销毁的整个生命周期。

如果只是会写 classpublicprivate,还只能算知道语法。更需要慢慢建立的是对象意识:这个对象什么时候初始化?谁负责释放资源?拷贝时是复制值,还是共享了同一块资源?接口有没有把内部状态保护好?

这些问题理顺后,再往后学继承、多态、模板和 STL,很多设计就不会显得那么突然。它们本质上仍然在围绕一个问题展开:如何定义一个好用、可靠、边界清楚的类型。