C++类和对象完全指南(上)——从面向过程到面向对象

前言

学C++绕不开"类和对象"这个坎。很多从C语言转过来的同学,一开始看到满屏的 classpublicprivate 会觉得莫名其妙——C语言里一个 struct 搞定的事,C++为什么要搞这么复杂?

这篇文章就从头到尾把C++类和对象的核心知识梳理一遍。内容分上下两篇,上篇讲类的基础概念、对象模型、构造函数、析构函数和拷贝构造,下篇讲运算符重载、初始化列表进阶、static成员、友元等内容。

一、从C到C++:封装思想的诞生

面向对象有三大特性:封装、继承、多态。封装是第一个接触到的,也是最基础的。

什么叫封装?简单说就是:把数据和方法绑在一起,对外暴露接口,隐藏实现细节

拿栈(Stack)来举例。用C语言实现一个栈,数据(数组、top、capacity)和操作(Init、Push、Pop)是分离的,谁都能直接去改栈里面的数据:

// C语言版本
typedef struct Stack
{
    int* a;
    int top;
    int capacity;
}ST;

void STInit(ST* ps);
void STPush(ST* ps, int x);
void STPop(ST* ps);

用户完全可以直接 s.top = -1,把栈搞乱。C++的做法是把数据和方法放到一起,用访问限定符控制谁能碰:

// C++版本
class Stack
{
public:
    // 对外开放的接口
    void Init(int n = 4);
    void Push(int x);
    void Pop();
    int Top();

private:
    // 外部不能直接访问
    int* _a;
    size_t _capacity;
    size_t _top;
};

封装的本质是一种更严格的管理方式,防止外部代码随意篡改内部数据。你不需要关心栈内部怎么存数据,只需要调 PushPop 就行。

除了封装,C++在语法上也做了一些便利性改进:

  • 成员函数不需要手动传对象地址(this 指针隐含传递)
  • 构造函数支持缺省参数,省去手动调 Init
  • struct 名直接代表类型,不需要 typedef

二、类的基础概念

2.1 类的定义格式

class 关键字定义类,后面跟类名,花括号里写成员,类定义结束时后面的分号不能省略

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

private:
    int _year;
    int _month;
    int _day;
};  // 分号!

类里面的内容分两种:

  • 成员变量(属性):存储数据的变量
  • 成员函数(方法):操作数据的函数

关于成员变量的命名,有个惯例:在变量名前面加 _ 或者后面加 _,或者用 m 开头,比如 _yearyear_m_year。这不是C++语法强制要求的,但实际开发中几乎所有人都这么做,主要是为了和函数参数区分开。具体用哪种风格,看公司或团队的规范。

C++中 struct 也能定义类,它兼容了C语言 struct 的用法,同时升级为类——可以在里面定义函数,struct 名直接代表类型。structclass 的唯一区别是默认访问权限不同:class 默认 privatestruct 默认 public

还有一个细节:定义在类内部的成员函数默认是 inline。不过 inline 只是对编译器的建议,编译器不一定会真的内联展开。

2.2 访问限定符

C++用访问限定符来实现封装,有三个关键字:

限定符 类外能否访问 说明
public ✅ 可以 对外开放的接口
private ❌ 不行 仅类内部可访问
protected ❌ 不行 和private类似,区别在继承时体现

访问权限从限定符出现的位置开始生效,到下一个限定符出现为止。如果后面没有限定符,就到类结束的 } 为止。

一般的设计原则是:成员变量放 private,需要给外部用的成员函数放 public

2.3 类域

类定义了一个新的作用域。如果在类外面定义成员函数,需要用 ::(作用域解析运算符)指明它属于哪个类:

class Stack
{
public:
    void Init(int n = 4);  // 声明
private:
    int* _a;
    size_t _capacity;
    size_t _top;
};

// 在类外定义,必须用 Stack:: 指定类域
void Stack::Init(int n)
{
    _a = (int*)malloc(sizeof(int) * n);
    if (nullptr == _a)
    {
        perror("malloc申请空间失败");
        return;
    }
    _capacity = n;
    _top = 0;
}

如果不加 Stack::,编译器会把 Init 当成全局函数,编译时找不到 _a 等成员就会报错。指定了类域之后,编译器就知道去类的成员里找这些变量了。

三、对象的实例化与内存模型

3.1 什么是实例化

用类类型创建对象的过程叫实例化。类本身只是一个抽象描述(就像建筑图纸),不占用存储数据的空间。只有实例化出对象,才会在内存中分配空间。

class Date
{
public:
    void Init(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;     // 可以实例化多个对象
    d1.Init(2024, 3, 31);
    d2.Init(2024, 7, 5);
    return 0;
}

一个类可以实例化出多个对象,每个对象都有自己独立的数据空间。

3.2 对象的大小

对象里到底存了什么?答案是:只存成员变量,不存成员函数

成员函数编译后是一段指令,存在代码段里。所有同类的对象共享同一份函数代码,没必要每个对象都存一份。调用成员函数时,编译器直接 call 函数地址,不需要通过对象去找。

class A
{
public:
    void Print() { cout << _ch << endl; }
private:
    char _ch;
    int _i;
};

class B
{
public:
    void Print() { }
};

class C {};

int main()
{
    cout << sizeof(A) << endl;  // 8(内存对齐后)
    cout << sizeof(B) << endl;  // 1
    cout << sizeof(C) << endl;  // 1
    return 0;
}

没有成员变量的类,对象大小是 1字节。这不是为了存数据,纯粹是占位——至少给一个字节,表示这个对象确实存在过。

3.3 内存对齐规则

C++对象的大小要遵循和C语言结构体一样的内存对齐规则:

  1. 第一个成员在偏移量为0的地址处
  2. 其他成员对齐到 对齐数 的整数倍地址处
  3. 对齐数 = 编译器默认对齐数 与 该成员大小 的较小值(VS默认对齐数为8)
  4. 结构体总大小为最大对齐数的整数倍
class A
{
public:
    void Print() { cout << _ch << endl; }
private:
    char _ch;   // 1字节,对齐数1
    int _i;     // 4字节,对齐数4
};
// _ch占1字节(偏移0),_i需要4字节对齐(偏移4-7),总大小8字节

内存对齐主要是为了提高CPU的访问效率。虽然会浪费一些空间,但在性能上的收益更大。

3.4 this指针

来看一个问题:Date 类有 InitPrint 两个成员函数,d1d2 都调用了 Init,函数体里并没有区分是哪个对象。那函数怎么知道该修改 d1 的数据还是 d2 的数据?

答案就是 this指针

编译器在编译成员函数时,会隐含地在第一个参数位置加一个当前类类型的指针:

// 你写的代码
void Init(int year, int month, int day)
{
    _year = year;    // 实际上是 this->_year = year;
    _month = month;
    _day = day;
}

// 编译器实际处理的
void Init(Date* const this, int year, int month, int day)
{
    this->_year = year;
    this->_month = month;
    this->_day = day;
}

调用 d1.Init(2024, 3, 31) 时,编译器实际处理成 Init(&d1, 2024, 3, 31)this 就指向 d1

关于this指针的几个要点:

  • 类型类类型* const,指针本身不能修改(this = nullptr 会编译报错)
  • 不能在实参和形参位置显式写this,但可以在函数体内显式使用
  • 存储位置:栈上(作为函数形参),有些编译器会用寄存器优化

经典面试题:空指针能调用成员函数吗?

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

int main()
{
    A* p = nullptr;
    p->Print();  // 能运行吗?
    return 0;
}

答案是能正常运行。因为 Print 函数里没有访问成员变量 _a,不涉及对 this 指针的解引用。p->Print() 本质是 A::Print(p),虽然 p 是空指针,但函数体内根本没用到 this

但如果 Print 里访问了 _a(即 this->_a),那就会对空指针解引用,运行崩溃

四、构造函数

4.1 为什么需要构造函数

以前我们写C++类,都要手动调一个 Init 函数来初始化:

Stack st;
st.Init();   // 忘了调就出bug

构造函数就是为了解决这个问题——对象实例化时编译器自动调用,不需要你手动去初始化。

4.2 构造函数的特点

构造函数虽然名字叫"构造",但它不是创建对象(栈上的对象在栈帧创建时空间就已经开好了),而是初始化对象

特点如下:

  1. 函数名与类名相同
  2. 无返回值(不需要写 void,什么都不写就行)
  3. 对象实例化时自动调用
  4. 可以重载
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(2024, 7, 5);  // 调带参构造
    return 0;
}

4.3 默认构造函数

不传实参就能调用的构造函数叫默认构造函数。有三种:

  1. 无参构造函数
  2. 全缺省构造函数
  3. 没有显式定义时编译器自动生成的构造函数

这三者只能存在一个。无参和全缺省虽然构成重载,但调用时会歧义(Date d; 编译器不知道调哪个),所以不能同时存在。

class Date
{
public:
    // 全缺省构造——推荐写法,既能无参调用也能带参调用
    Date(int year = 1, int month = 1, int day = 1)
    {
        _year = year;
        _month = month;
        _day = day;
    }
private:
    int _year;
    int _month;
    int _day;
};

int main()
{
    Date d1;              // OK,用缺省值
    Date d2(2024, 7, 5);  // OK,用传入的值
    return 0;
}

4.4 编译器默认生成的构造函数做了什么

如果你不写构造函数,编译器会自动生成一个。但它的行为可能和你想的不一样:

  • 内置类型intchardouble、指针等):是否初始化取决于编译器,C++标准没有规定,VS下一般不初始化(随机值)
  • 自定义类型:调用该成员的默认构造函数
class Stack
{
public:
    Stack(int n = 4)  // Stack有构造函数
    {
        _a = (int*)malloc(sizeof(int) * n);
        _capacity = n;
        _top = 0;
    }
private:
    int* _a;
    size_t _capacity;
    size_t _top;
};

class MyQueue
{
    // 不写构造函数,编译器自动生成
    // 会自动调用 pushst 和 popst 的构造函数
private:
    Stack pushst;
    Stack popst;
    size_t _size;  // 内置类型,不初始化(随机值)
};

注意:如果自定义类型成员没有默认构造函数(比如 Stack 只有一个带参的构造函数),编译器就会报错。这时需要用初始化列表来解决(下篇讲)。

4.5 一个容易踩的坑

用无参构造创建对象时,对象后面不要加括号

Date d1();   // 编译器把它当成函数声明了!
Date d1;     // 正确,调用无参构造

Date d1() 这行代码在编译器看来是一个函数声明——返回类型是 Date,函数名是 d1,参数列表为空。这是C++的一个经典陷阱。

五、析构函数

5.1 析构函数的作用

析构函数和构造函数相反,在对象生命周期结束时自动调用,完成资源清理

注意,析构函数不是销毁对象本身。栈上的对象在函数结束、栈帧销毁时自动释放,堆上的对象在 delete 时释放。析构函数做的是释放对象持有的资源(比如动态分配的内存)。

5.2 析构函数的特点

  1. 函数名是 ~ 加类名(如 ~Stack()
  2. 无参数、无返回值
  3. 一个类只能有一个析构函数
  4. 对象生命周期结束时自动调用
  5. 不写的话编译器自动生成,行为和构造函数类似:内置类型不处理,自定义类型调它的析构
  6. 后定义的对象先析构(栈的后进先出特性)
class Stack
{
public:
    Stack(int n = 4)
    {
        _a = (int*)malloc(sizeof(int) * n);
        _capacity = n;
        _top = 0;
    }

    ~Stack()
    {
        free(_a);        // 释放动态分配的内存
        _a = nullptr;
        _top = _capacity = 0;
    }

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

5.3 什么时候需要自己写析构

  • 不需要写:像 Date 这种没有动态资源的类,编译器生成的就够了
  • 不需要写:像 MyQueue 这种内部全是自定义类型的类,编译器生成的会自动调用成员的析构
  • 必须写:像 Stack 这种申请了动态内存的类,不写就会内存泄漏

判断标准很简单:如果一个类显式实现了析构并释放资源,那它通常也需要自己写拷贝构造和赋值运算符重载。这个"三法则"后面会反复遇到。

5.4 C++ vs C:括号匹配问题

来看一个实际例子——括号匹配。用C语言写的话,每个 return false 之前都要记得调 STDestroy,否则就会内存泄漏:

// C语言版本——每个return前都要记得Destroy
bool isValid(const char* s) {
    ST st;
    STInit(&st);
    while (*s) {
        if (*s == '(' || *s == '[' || *s == '{') {
            STPush(&st, *s);
        } else {
            if (STEmpty(&st)) {
                STDestroy(&st);  // 别忘了!
                return false;
            }
            char top = STTop(&st);
            STPop(&st);
            if (...) {
                STDestroy(&st);  // 别忘了!
                return false;
            }
        }
        ++s;
    }
    bool ret = STEmpty(&st);
    STDestroy(&st);  // 别忘了!
    return ret;
}

C++版本就清爽多了,析构函数自动调用,不用担心资源泄漏:

// C++版本——析构函数自动清理
bool isValid(const char* s) {
    Stack st;  // 构造函数自动调用
    while (*s) {
        if (*s == '[' || *s == '(' || *s == '{') {
            st.Push(*s);
        } else {
            if (st.Empty()) return false;  // st自动析构
            char top = st.Top();
            st.Pop();
            if (...) return false;  // st自动析构
        }
        ++s;
    }
    return st.Empty();  // st自动析构
}

这就是构造函数和析构函数带来的便利——资源管理自动化。

六、拷贝构造函数

6.1 什么是拷贝构造

拷贝构造是一个特殊的构造函数,第一个参数是自身类类型的引用,用于用已有对象创建新对象:

class Date
{
public:
    // 拷贝构造函数
    Date(const Date& d)
    {
        _year = d._year;
        _month = d._month;
        _day = d._day;
    }
private:
    int _year;
    int _month;
    int _day;
};

int main()
{
    Date d1(2024, 7, 5);
    Date d2(d1);      // 拷贝构造
    Date d3 = d1;     // 也是拷贝构造(不是赋值!)
    return 0;
}

拷贝构造的参数必须是引用。如果写成传值 Date(Date d),编译器会报错——因为传值本身就需要调用拷贝构造,这就形成了无穷递归。

6.2 浅拷贝 vs 深拷贝

这是拷贝构造里最重要的概念。

编译器自动生成的拷贝构造做的是浅拷贝(值拷贝),按字节逐个复制。对于 Date 这种全是内置类型且没有指向资源的类,浅拷贝就够了:

// Date类的浅拷贝完全OK
Date d1(2024, 7, 5);
Date d2(d1);  // _year/_month/_day各自复制一份,没问题

但对于 Stack 这种有动态内存的类,浅拷贝就出大问题了:

Stack st1;
st1.Push(1);
st1.Push(2);

Stack st2 = st1;  // 浅拷贝:st2._a = st1._a,两个指针指向同一块内存!

浅拷贝之后,st1._ast2._a 指向同一块内存。当两个对象析构时,同一块内存被 free 了两次,程序直接崩溃。而且修改其中一个的数据,另一个也会被影响。

解决方案是深拷贝——自己实现拷贝构造,为 st2 分配独立的内存空间:

Stack(const Stack& st)
{
    // 开辟和st一样大的空间
    _a = (int*)malloc(sizeof(int) * st._capacity);
    if (nullptr == _a)
    {
        perror("malloc申请空间失败");
        return;
    }

    // 把数据拷贝过来
    memcpy(_a, st._a, sizeof(int) * st._top);
    _top = st._top;
    _capacity = st._capacity;
}

判断标准:如果一个类显式实现了析构并释放资源,那它就需要自己写拷贝构造。这条规则几乎不会错。

6.3 拷贝构造的调用场景

C++规定,自定义类型对象进行拷贝行为必须调用拷贝构造。常见场景:

  1. 传值传参void Func(Date d) 调用时,实参拷贝给形参
  2. 传值返回Date Func() 返回时,返回值拷贝给接收对象
void Func1(Date d) { }     // 传值传参,调拷贝构造

Date& Func2() {
    Date tmp(2024, 7, 5);
    return tmp;            // 危险!tmp是局部对象,函数结束就销毁了
}

int main()
{
    Date d1(2024, 7, 5);
    Func1(d1);             // d1拷贝给形参d

    Date ret = Func2();    // ret引用的是已销毁的tmp,未定义行为
    return 0;
}

传引用返回可以减少拷贝,但前提是返回的对象在函数结束后仍然存在。返回局部变量的引用是未定义行为,相当于野指针。

6.4 什么时候不需要自己写拷贝构造

  • Date 类:全是内置类型,没有资源指向,编译器生成的浅拷贝就够用
  • MyQueue 类:内部主要是自定义类型 Stack 成员,编译器生成的拷贝构造会自动调用 Stack 的拷贝构造
class MyQueue
{
private:
    Stack pushst;
    Stack popst;
    size_t _size;
};

MyQueue mq1;
MyQueue mq2 = mq1;  // 编译器自动生成的拷贝构造会调用Stack的拷贝构造

只要 Stack 自己实现了深拷贝,MyQueue 就不需要额外写。

七、本篇知识点速查表

序号 知识点 要点
1 类的定义 class + 类名 + {} + 分号;成员变量命名加 _ 前缀
2 访问限定符 public 类外可访问,private/protected 不可;class默认private
3 类域 类外定义成员用 :: 指定类域
4 实例化 类是抽象描述(声明),对象才分配空间
5 对象大小 只存成员变量,不存成员函数;空类1字节(占位)
6 内存对齐 对齐数 = min(编译器默认值, 成员大小);总大小为最大对齐数的整数倍
7 this指针 隐含形参,类型 类类型* const;存在栈上;空指针调用需看是否解引用
8 构造函数 函数名=类名,无返回值,自动调用;默认构造三选一(无参/全缺省/编译器生成)
9 析构函数 ~类名,无参无返回值,自动调用;后定义先析构
10 拷贝构造 参数必须是引用;浅拷贝vs深拷贝;有资源必须深拷贝
11 三法则 实现了析构释放资源 → 需要自己写拷贝构造和赋值重载

下篇将深入讲解运算符重载、初始化列表、类型转换、static成员、友元、内部类、匿名对象以及编译器的拷贝优化等内容。这些是C++类和对象中更进阶但也更实用的知识点,我们下篇见。

更多推荐