封装不是束缚,是秩序。

为什么需要类

在 C 语言中,数据和操作数据的函数是分开的。以栈为例:

// C 版本:数据和操作分离
struct Stack {
    int* a;
    int top;
    int capacity;
};

void StackInit(Stack* ps);      // 必须传指针
void StackPush(Stack* ps, int x);
int  StackTop(Stack* ps);

这带来三个问题:

  1. 数据暴露——任何人都能直接修改 ps->top = 0,绕过 StackInit
  2. 函数与数据割裂——调用者需要记住"这个函数对应这个结构体";
  3. 命名污染——不得不给每个函数加 Stack 前缀,否则全局命名空间被 InitPush 这类通用名称挤占。

C++ 的类(Class)将数据和操作捆绑在一起,通过访问控制(Access Control)决定哪些对外可见、哪些内部隐藏。本质上,类是一种封装(Encapsulation)机制——面向对象三大特性中排第一的那个。

💡 背景补充:C 中 struct 只能聚合数据;C++ 将 struct 升级为类,可以在里面定义函数。但正式场景推荐用 class 关键字。

类的定义

定义格式

#include <iostream>

class Stack {
public:
    // 成员函数(Member Function / Method)
    void Init(int n = 4) {
        _array = (int*)malloc(sizeof(int) * n);
        if (nullptr == _array) {
            perror("malloc 申请空间失败");
            return;
        }
        _capacity = n;
        _top = 0;
    }

    void Push(int x) {
        _array[_top++] = x;   // 此处省略扩容逻辑,后面完整版会补上
    }

    int Top() {
        assert(_top > 0);
        return _array[_top - 1];
    }

    void Destroy() {
        free(_array);
        _array = nullptr;
        _top = _capacity = 0;
    }

private:
    // 成员变量(Member Variable / Attribute)
    int*   _array;
    size_t _capacity;
    size_t _top;
};  // 分号不能省略

几点值得注意:

  • class 后跟类名,花括号内是类体(Class Body),类定义结束后的分号不可省略——这是 C 结构体时代就有的遗产语法。
  • 成员变量命名常用 _ 前缀或 m 前缀(如 m_year),这并非 C++ 强制要求,而是行业惯例。所在团队有规范就优先遵循。
  • 定义在类内部的成员函数默认是 inline 的——编译器会将函数体直接在调用处展开(当然这只是建议,编译器有权忽略)。
  • C++ 中 struct 也能定义类了。与 class 的唯一区别:struct 的成员默认是 publicclass 默认是 private。推荐用 class 来定义类,语义更明确。
// struct 升级为类:不再需要 typedef,类型名直接可用
struct ListNodeCpp {
    void Init(int x) {
        next = nullptr;
        val = x;
    }
    ListNodeCpp* next;
    int val;
};

int main() {
    ListNodeCpp node;        // 不需要 typedef,直接用类名
    node.Init(42);
    return 0;
}

💡 背景补充:C 中必须 typedef struct ListNodeC { ... } ListNodeC; 才能省略 struct 关键字。C++ 直接把类型名当作可用标识符,清爽不少。

访问限定符

C++ 通过三个访问限定符(Access Specifier)实现封装:

限定符 类外访问 派生类访问 默认(class) 默认(struct)
public ✅ 可以 ✅ 可以 struct 默认
private ❌ 不能 ❌ 不能 class 默认
protected ❌ 不能 ✅ 可以

📖 参考:《Effective C++》条款22:将数据成员声明为 private。protected 并不比 public 更有封装性——一旦修改,所有派生类都要重新编译。

访问限定符的作用范围:从该限定符出现的位置开始,直到下一个限定符出现或类结束(})为止。一个类可以有多个 public: / private: 段,但成员变量通常全部放入 private——这是封装的底线。

class Date {
public:
    void Init(int year, int month, int day) {
        _year = year;
        _month = month;
        _day = day;
    }

private:
    int _year;   // 外部不能直接访问 d._year——编译器报错
    int _month;
    int _day;
};

类域

类定义了一个新的作用域(Class Scope)。当成员函数在类外定义时,必须用 ::(作用域解析运算符,Scope Resolution Operator)指明归属:

class Stack {
public:
    void Init(int n = 4);   // 类内只声明

private:
    int*   _array;
    size_t _capacity;
    size_t _top;
};

// 类外定义:Stack:: 告诉编译器"Init 是 Stack 的成员"
void Stack::Init(int n) {
    _array = (int*)malloc(sizeof(int) * n);
    if (nullptr == _array) {
        perror("malloc 申请空间失败");
        return;
    }
    _capacity = n;
    _top = 0;
}

编译器查找规则是这样的:如果 Init 不指定 Stack::,编译器把它当全局函数处理,自然找不到 _array_capacity 这些成员——报错。加上 Stack:: 之后,编译器就知道:在自己域内找不到的标识符,去 Stack 类域里继续找。

补充一个细节:不同类可以有同名成员函数,互不干扰。这是类域带来的天然命名隔离。

实例化:图纸与房子

实例化概念

是对一类对象的抽象描述,它规定了"这类东西有哪些变量",但并不为这些变量分配内存。只有实例化(Instantiation)——用类类型创建出具体对象——才会在物理内存中分配空间。

一个比喻:类是建筑设计图,对象是按图建造的房子。图纸可以复制多次、建造多栋房子;每栋房子有独立的墙体(独立的成员变量),但共用同一套图纸上的施工规范(成员函数)。

class Date {
public:
    void Init(int year, int month, int day) {
        _year = year;
        _month = month;
        _day = day;
    }
    void Print() {
        std::cout << _year << "/" << _month << "/" << _day << std::endl;
    }

private:
    int _year;    // 只是声明,还没有分配空间
    int _month;
    int _day;
};

int main() {
    Date d1;                // 实例化:为 d1 的 _year/_month/_day 分配空间
    Date d2;                // 实例化:为 d2 的 _year/_month/_day 分配空间

    d1.Init(2024, 3, 31);   // d1 的成员变量被赋值为 2024/3/31
    d1.Print();             // 输出: 2024/3/31

    d2.Init(2024, 7, 5);    // d2 的成员变量被赋值为 2024/7/5
    d2.Print();             // 输出: 2024/7/5

    return 0;
}

d1d2 各有自己的 _year / _month / _day,互不影响。但 InitPrint 函数只有一份——如果每个对象都存一份函数副本,100 个对象就浪费 100 次内存。

📖 参考:《C++ Primer Plus》第10章 — 对象只存储成员变量,成员函数被编译成代码段中的指令,所有对象共享。

对象大小的秘密

那么,一个不包含任何成员变量的类,实例化的对象有多大?

#include <iostream>

class A {
public:
    void Print() { std::cout << _ch << std::endl; }
private:
    char _ch;
    int  _i;
};  // 按内存对齐规则,sizeof(A) 通常是 8

class B {
public:
    void Print() { /* ... */ }
};  // 没有成员变量,sizeof(B) = ?

class C {};  // 空类,sizeof(C) = ?

int main() {
    A a;
    B b;
    C c;
    std::cout << sizeof(a) << std::endl;  // 8(受内存对齐影响)
    std::cout << sizeof(b) << std::endl;  // 1
    std::cout << sizeof(c) << std::endl;  // 1
    return 0;
}

BC 没有任何成员变量,但 sizeof 返回 1 而非 0。原因很直接:如果一个字节都不给,编译器拿什么来区分内存中两个不同的 B 对象?这 1 字节纯粹是占位,用来标识这个对象确实存在。

关于有成员变量的类——对象大小遵循与 C 结构体相同的内存对齐(Memory Alignment)规则:

规则 说明
首个成员 在偏移量为 0 的地址处
后续成员 对齐到"对齐数"的整数倍地址
对齐数 min(编译器默认值, 成员类型大小)
VS 默认对齐数 8
结构体总大小 最大对齐数的整数倍
嵌套结构体 对齐到自身最大对齐数的整数倍

this 指针:隐式的主角

看看这段代码:

d1.Init(2024, 3, 31);
d2.Init(2024, 7, 5);

Init 函数体内只有一行行 _year = year,没有任何地方区分 “谁调用的我”。那编译器怎么知道 d1.Init 应该操作 d1 的成员,而 d2.Init 操作 d2 的成员?

答案是一个隐式参数——this 指针

编译器在编译成员函数时,会在参数列表的第一个位置悄悄插入一个当前类类型的 const 指针:

// 你写的:
void Date::Init(int year, int month, int day);

// 编译器实际生成的:
void Date::Init(Date* const this, int year, int month, int day);

thisconst 指针——你不能修改它指向哪个对象(this = nullptr; 会编译报错)。但通过 this 可以访问对象成员:

void Date::Init(int year, int month, int day) {
    // this = nullptr;           // 编译报错:左操作数必须为左值

    _year = year;                // 等效于 this->_year = year;
    this->_month = month;        // 显式使用 this,等效写法
    this->_day = day;
}

this 不能在形参列表中显式写出(那是编译器的工作),但函数体内可以显式使用 this

调用端也发生了隐式转换:

// 你写的:
d1.Init(2024, 3, 31);

// 编译器实际生成的:
d1.Init(&d1, 2024, 3, 31);

📖 参考:《C++ Primer Plus》第10章 — this 指针是指向调用对象的指针,*this 即调用对象本身。

两个经典的 this 指针测试题:

第一题:

class A {
public:
    void Print() {
        std::cout << "A::Print()" << std::endl;
    }
private:
    int _a;
};

int main() {
    A* p = nullptr;
    p->Print();   // 输出: A::Print() — 正常执行!
    return 0;
}

p 虽然是空指针,但 Print 函数体内没有访问任何成员变量——也就是说没有解引用 thisp->Print() 只是把 p 的值(nullptr)作为 this 传给 Print,函数没用到 this,自然不会崩溃。能不能跑,取决于函数体有没有通过 this 访问成员,而不是看指针空不空。

第二题:

class A {
public:
    void Print() {
        std::cout << "A::Print()" << std::endl;
        std::cout << _a << std::endl;   // 访问成员变量 → 解引用 this
    }
private:
    int _a;
};

int main() {
    A* p = nullptr;
    p->Print();   // 运行崩溃!访问 _a 等价于 this->_a,this 是 nullptr
    return 0;
}

this 存储在内存的哪个区域?——栈区this 本质上是成员函数的形参(编译器隐式传入),形参自然放在栈上。

C vs C++ Stack 实现对比

封装到底改变了什么?下面是同一个 Stack 的两种实现,放在一起看最直观。

C 版本

#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <assert.h>

typedef int STDataType;
typedef struct Stack {
    STDataType* a;
    int top;
    int capacity;
} ST;

void STInit(ST* ps) {
    assert(ps);
    ps->a = NULL;
    ps->top = 0;
    ps->capacity = 0;
}

void STDestroy(ST* ps) {
    assert(ps);
    free(ps->a);
    ps->a = NULL;
    ps->top = ps->capacity = 0;
}

void STPush(ST* ps, STDataType x) {
    assert(ps);
    if (ps->top == ps->capacity) {
        int newcapacity = ps->capacity == 0 ? 4 : ps->capacity * 2;
        STDataType* tmp = (STDataType*)realloc(ps->a,
                            newcapacity * sizeof(STDataType));
        if (tmp == NULL) {
            perror("realloc fail");
            return;
        }
        ps->a = tmp;
        ps->capacity = newcapacity;
    }
    ps->a[ps->top] = x;
    ps->top++;
}

// ... STPop, STTop, STEmpty, STSize 省略

int main() {
    ST s;
    STInit(&s);              // 每步操作都要显式传 &s
    STPush(&s, 1);
    STPush(&s, 2);
    STPush(&s, 3);
    STPush(&s, 4);
    while (!STEmpty(&s)) {
        printf("%d\n", STTop(&s));
        STPop(&s);
    }
    STDestroy(&s);
    return 0;
}

C++ 版本

#include <iostream>
#include <cassert>

typedef int STDataType;
class Stack {
public:
    void Init(int n = 4) {      // 缺省参数,默认容量为 4
        _a = (STDataType*)malloc(sizeof(STDataType) * n);
        if (nullptr == _a) {
            perror("malloc 申请空间失败");
            return;
        }
        _capacity = n;
        _top = 0;
    }

    void Push(STDataType x) {
        if (_top == _capacity) {
            int newcapacity = _capacity * 2;
            STDataType* tmp = (STDataType*)realloc(_a,
                                newcapacity * sizeof(STDataType));
            if (tmp == NULL) {
                perror("realloc fail");
                return;
            }
            _a = tmp;
            _capacity = newcapacity;
        }
        _a[_top++] = x;
    }

    void Pop() {
        assert(_top > 0);
        --_top;
    }

    bool Empty() { return _top == 0; }

    int Top() {
        assert(_top > 0);
        return _a[_top - 1];
    }

    void Destroy() {
        free(_a);
        _a = nullptr;
        _top = _capacity = 0;
    }

private:
    STDataType* _a;
    size_t _capacity;
    size_t _top;
};  // 分号不能省略——这是我的规则。

int main() {
    Stack s;
    s.Init();            // 不再需要传 &s,this 自动处理
    s.Push(1);
    s.Push(2);
    s.Push(3);
    s.Push(4);
    while (!s.Empty()) {
        std::cout << s.Top() << std::endl;
        s.Pop();
    }
    s.Destroy();
    return 0;
}

对比下来,变化集中在三点:

维度 C C++
数据保护 任何代码都能改 s.top private 成员只能通过成员函数访问
传参方式 每次显式传 &s this 隐式传递,调用端零负担
类型使用 typedef ... ST 类名直接当类型用
缺省参数 不支持 Init(int n = 4) 一行搞定默认容量

底层逻辑完全一样——mallocrealloc、内存布局都没有变。但在接口层面,C++ 少掉了"显式传对象地址"的噪音,把数据关进了 private,用户不能再绕过 Push 直接往 _top 上写字。

📖 参考:《C++ Primer Plus》第10章:封装不仅改变了代码组织方式,更改变了使用代码的思维模式——从"操作数据"到"向对象发送请求"。

常见误区

误区一:分号漏写

class A {
    int _x;
}   // 编译报错:expected ';' after class definition
// 类定义末尾分号不可省——编译器需要它来判断类体结束。

误区二:把成员变量声明为 public

class Bad {
public:
    int _value;  // 外部代码可以直接修改,封装形同虚设
};
// 任何时候想要修改 _value 的类型或约束,所有引用该成员的代码都要改。

误区三:误认为"每调用一次成员函数,对象里就存一份函数"

对象只存成员变量。成员函数在代码段有一份,通过 this 指针找到操作的是哪个对象。1000 个对象,也是一份函数。

本节要点

  • 类 = 数据 + 操作 + 访问控制。封装的核心是把能暴露的和不能暴露的分开管理。
  • class 默认 private,struct 默认 public——除此之外没有区别,但推荐用 class 定义类。
  • 类域用 :: 操作符。类外定义成员函数时,不写 ClassName:: 编译器就把它当全局函数。
  • 对象只存成员变量,不存成员函数。空类占 1 字节纯粹是为了占位标识对象存在与否。
  • this 指针是编译器隐式传入的形参,类型是 ClassName* const。函数体内可以不写,但心里要有。

📖 参考:《C++ Primer Plus》第10章 —— 对象和类 / this 指针 / 对象数组;《Effective C++》条款22 —— 将数据成员声明为 private;《高质量C++/C编程指南》第9章 —— 类的基本规范。


*迪亚波罗学编程 *

更多推荐