最近在啃 C++ 面向对象的基础,越学越觉得类和对象这东西,简直是把我们写过的 C 语言数据结构 “重新打包升级” 的神器。今天就用我最熟悉的栈(Stack)来串一串这些知识点,顺便把 this 指针、构造 / 析构函数这些难点一次性讲明白。


一、从 “结构体 + 函数” 到 “类”:栈的两种写法对比

还记得我们用 C 语言写栈的时候,是怎么写的吗?

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

void STInit(ST* ps) {
    ps->a = (STDatatype*)malloc(sizeof(STDatatype) * 4);
    ps->top = 0;
    ps->capacity = 4;
}

我们得手动传结构体指针 ps,每次调用 STPush(&st, x),还得自己管内存、自己判空、自己扩容,非常 “半自动”。

而 C++ 的类,直接把 “数据” 和 “操作” 绑在一起,变成了一个整体:

typedef int STDatatype;
class Stack {
public:
    void Init(int n = 4) {
        _a = (STDatatype*)malloc(sizeof(STDatatype) * n);
        _capacity = n;
        _top = 0;
    }
    void Push(STDatatype x) {
        if (_top == _capacity) {
            // 自动扩容
            STDatatype* tmp = (STDatatype*)realloc(_a, _capacity * 2 * sizeof(STDatatype));
            if (tmp == nullptr) { perror("realloc fail"); return; }
            _a = tmp;
            _capacity *= 2;
        }
        _a[_top++] = x;
    }
private:
    STDatatype* _a;
    int _top;
    int _capacity;
};

现在我们只需要创建对象,直接调用成员函数就行:

Stack st;
st.Init();
st.Push(10);

不用再传指针,也不用再担心传错结构体,这就是类的封装魅力 —— 把数据和操作打包,对外只暴露必要的接口。


二、this 指针:解决 “成员函数怎么知道操作哪个对象” 的问题

上面的 st.Push(10),编译器处理后,其实会变成:

Stack::Push(&st, 10);

它偷偷给成员函数传了一个当前对象的地址,也就是 this 指针。

1. this 指针的本质

  • 成员函数的第一个隐藏参数,类型是 类名* const this(比如 Stack* const this)。

  • 成员函数里访问 _a,其实就是 this->_a,只不过编译器帮我们省略了。

  • 它存放在栈或寄存器里(VS 里会用 ecx 寄存器传递),不属于对象本身,不占用对象的内存空间。

2. 两个经典的 nullptr 调用测试题

  1. 只打印信息,不访问成员变量:

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

int main() {
    A* p = nullptr;
    p->Print(); // 正常运行,因为没访问成员变量,不需要解引用 this
    return 0;
}
  1. 访问成员变量:

class A {
public:
    void Print() { cout << this->_a << endl; }
private:
    int _a;
};

int main() {
    A* p = nullptr;
    p->Print(); // 运行崩溃,因为 this 是 nullptr,解引用访问成员变量会报错
    return 0;
}

这就是为什么空指针调用成员函数,有的能跑,有的会崩 —— 关键看函数里有没有访问成员变量。


三、构造函数:告别手动 Init,让对象 “出生即初始化”

以前我们写栈,每次都要手动调用 Init(),很容易忘写导致野指针。构造函数就是来解决这个问题的。

1. 构造函数的核心规则

  • 函数名和类名完全相同,没有返回值(连 void 都不用写)。

  • 对象创建时自动调用,只调用一次。

  • 可以重载,支持带参数版本,也可以写无参的默认构造函数。

class Date {
public:
    // 无参默认构造函数
    Date() {
        _year = 1;
        _month = 1;
        _day = 1;
    }
    // 带参数构造函数(重载)
    Date(int year, int month, int day) {
        _year = year;
        _month = month;
        _day = day;
    }
private:
    int _year;
    int _month;
    int _day;
};

int main() {
    Date d1;          // 调用无参构造函数
    Date d2(2025, 4, 22); // 调用带参数构造函数
    return 0;
}

2. 默认构造函数的坑

  • 如果你不写任何构造函数,编译器会自动生成一个无参的默认构造函数。

  • 但只要你写了任何一个构造函数,编译器就不会再生成默认构造函数了。

  • 无参构造函数和全缺省构造函数不能同时存在,会产生调用歧义。


四、析构函数:自动释放资源,告别手动 Destroy

栈这种动态申请了内存的类,以前我们必须手动调用 Destroy() 释放内存,不然会内存泄漏。析构函数就是来做这个的。

1. 析构函数的核心规则

  • 函数名是 ~类名(),没有返回值,也没有参数,不能重载。

  • 对象生命周期结束时自动调用,只调用一次。

  • 编译器会自动生成默认析构函数,对内置类型不处理,对自定义类型会调用它的析构函数。

class Stack {
public:
    Stack(int n = 4) {
        _a = (STDatatype*)malloc(sizeof(STDatatype) * n);
        _capacity = n;
        _top = 0;
    }
    ~Stack() {
        free(_a); // 自动释放内存,不用再手动调用 Destroy
        _a = nullptr;
        _top = _capacity = 0;
    }
    // ... 其他成员函数
private:
    STDatatype* _a;
    int _top;
    int _capacity;
};

现在我们写括号匹配的代码,就不用再手动 STDestroy(&st) 了,栈对象 st 出了作用域,析构函数会自动调用释放内存:

bool isValid(const char* s) {
    Stack st; // 自动调用构造函数
    while (*s) {
        if (*s == '(' || *s == '[' || *s == '{') {
            st.Push(*s);
        } else {
            if (st.Empty()) return false;
            char top = st.Top();
            st.Pop();
            if ((*s == ')' && top != '(') || 
                (*s == ']' && top != '[') || 
                (*s == '}' && top != '{')) {
                return false;
            }
        }
        s++;
    }
    return st.Empty();
    // 函数结束时,st 出作用域,自动调用析构函数释放内存
}

五、类的内存对齐:为什么 char + int 占 8 字节?

看这个例子:

class A {
public:
    void Print() { cout << _ch << endl; }
private:
    char _ch; // 1 字节
    int _i;   // 4 字节
};

你以为 sizeof(A) 是 5 字节?实际上它是 8 字节,这就是内存对齐。

内存对齐规则

  1. 第一个成员变量在偏移量为 0 的地址处。

  2. 后续成员变量要对齐到 “自身大小和对齐数的较小值” 的整数倍地址处。

  3. 结构体整体大小要对齐到 “最大成员大小和对齐数的较小值” 的整数倍。

  4. 目的是为了以空间换时间,让 CPU 能更高效地访问内存。

所以 char _ch 占 1 字节,后面要补 3 个填充字节,int _i 占 4 字节,总共 8 字节,刚好是 4 的倍数(对齐)。


六、写在最后:面向对象的第一印象

以前觉得面向对象很虚,直到用栈的例子改造了一遍,才发现它解决的都是我们写 C 语言时最头疼的问题:

  • 封装:数据和操作绑定,减少传参错误。

  • this 指针:解决了成员函数如何区分对象的问题。

  • 构造 / 析构:自动初始化和释放资源,减少内存泄漏和野指针。

  • 内存对齐:理解了对象在内存中是怎么布局的。

这些都是面向对象的基础,后面还有拷贝构造、运算符重载、继承多态这些硬骨头,一步一步啃下来,感觉 C++ 越来越有意思了。

 

更多推荐