从 C 到 C++:类和对象(中)—— 默认成员函数与 const 的深度解析( this 指针的补充)

        在上一篇博客中,我们从 C 语言结构体过渡到 C++ 类,掌握了类的定义、封装、实例化和对象大小计算,但留下了一个关键问题:多个对象调用同一个成员函数时,函数如何区分操作的是哪个对象的成员变量? 比如两个Date对象d1d2都调用Print(),函数怎么知道该打印d1._year还是d2._year

        答案就是 C++ 的 “隐藏神器”——this 指针。在讲解默认成员函数前,我们先补上这个核心知识点,它是理解成员函数调用机制的基础。

目录

一、this 指针 —— 成员函数的 “隐藏导航”

 1.1 this 指针的引出:为什么需要它?

1.2 this 指针的本质:编译器的 “隐式操作”

1.3 this 指针的特性:你需要知道的关键细节

(1)不能显式声明,但可以显式使用

(2)this 指针的存储位置:优先用寄存器

(3)this 指针不能为空?空指针调用的坑

(4)this 指针与 const 成员函数:const 修饰的是 this

1.4 总结 this 指针:一句话讲清

二、默认成员函数:编译器的 “隐形助手”

三、构造函数:对象的 “出生证明”

3.1 构造函数的核心特性

示例:日期类的构造函数

3.2 默认构造函数:三种 “不用传参” 的情况

        注意:默认构造函数 “互斥”

2.3 编译器生成的默认构造:“双标” 行为

四、析构函数:对象的 “临终遗言”

4.1 析构函数的核心特性

4.2 什么时候需要自己写析构函数?

4.3 析构顺序:“先构造的后析构”

五、拷贝构造函数:对象的 “克隆术”

5.1 拷贝构造函数的核心特性

5.2 为什么参数必须是 “引用”?

        错误示例:传值参数导致递归

5.3 默认拷贝构造:浅拷贝的 “坑”

六、赋值运算符重载:对象的 “更新术”

6.1 运算符重载的基本规则

6.2 示例:Date 类的赋值运算符重载

6.3 Stack 类的赋值重载:深拷贝 again

七、const 成员函数:对象的 “只读保护”

7.1 const 成员函数的语法

7.2 const 成员函数的调用规则

八、取地址及 const 取地址运算符重载:几乎不用管

九、小结与预告


一、this 指针 —— 成员函数的 “隐藏导航”

 1.1 this 指针的引出:为什么需要它?

        先看一个示例:我们定义Date类,创建两个对象d1d2,分别调用Print()函数:

class Date {

public:
    Date(int y, int m, int d) {
        _year = y;
        _month = m;
        _day = d;
    }

    void Print() {
        cout << _year << "-" << _month << "-" << _day << endl;
    }

private:
    int _year;
    int _month;
    int _day;
};

int main() {
    Date d1(2024, 5, 20);
    Date d2(2023, 12, 31);
    d1.Print();  // 输出2024-5-20
    d2.Print();  // 输出2023-12-31
    return 0;
}

        问题来了:d1.Print()d2.Print()调用的是同一个Print函数(代码段中只有一份Print的指令),函数怎么知道要访问d1的成员还是d2的成员?

        这背后就是this指针在工作 ——编译器会给每个非静态成员函数隐式添加一个this指针参数,该指针指向 “当前调用函数的对象”,函数内部访问的成员变量,实际都是通过this指针访问的。

1.2 this 指针的本质:编译器的 “隐式操作”

        我们写的Print函数,看起来没有参数,但编译器会在编译阶段自动处理成这样

// 编译器未处理的Print函数
void Print() {  
    cout << _year << "-" <<_month << "-" << _day << endl;
}
// 编译器处理后的Print函数(我们看不到,但是实际执行的版本)
void Print(Date* this) {  // 隐式添加this指针参数(第一个参数)
    cout << this->_year << "-" << this->_month << "-" << this->_day << endl;
}

        相应地,我们调用d1.Print()时,编译器也会自动传递d1的地址作为this指针的实参:

d1.Print(&d1); //Print(&d1); 

d2.Print(&d2); //Print(&d2); 

// 编译器处理后的调用(我们写的d1.Print()会被转换为)
Print(&d1);  // 自动传递d1的地址给this指针

// d2.Print()会被转换为
Print(&d2);  // 自动传递d2的地址给this指针

        这就是this指针的核心本质:它是成员函数的隐式参数,指向当前对象,用于区分不同对象的成员变量

1.3 this 指针的特性:你需要知道的关键细节

(1)不能显式声明,但可以显式使用

        我们不能在成员函数的参数列表中显式写this指针(比如void Print(Date* this)会编译错误),但可以在函数体内显式使用this指针访问成员:

void Print() {
    // 显式使用this指针(和直接写_year等价,通常省略)
    cout << this->_year << "-" << this->_month << "-" << this->_day << endl;
}

        显式使用this的场景:比如区分成员变量和函数参数(当参数名和成员变量名相同时):

void SetYear(int year) {
    // this->_year指成员变量,year指函数参数
    this->_year = year;
}

(2)this 指针的存储位置:优先用寄存器

this指针是成员函数的形参,但它的存储位置比较特殊:

  • Visual Studio:通过ecx寄存器传递this指针(不占栈空间,效率更高);
  • 其他编译器(如 GCC):可能将this指针放在函数调用栈上。

  无论存储在哪里,我们都不需要关心 —— 编译器会自动处理this的传递和访问。

(3)this 指针不能为空?空指针调用的坑

        正常情况下,this指针指向当前对象,不会为空。但如果用空指针调用成员函数,会出现两种情况:

  1. 如果函数内部不访问成员变量(不通过this指针解引用),不会崩溃;
  2. 如果函数内部访问成员变量(通过this指针解引用),会崩溃(空指针解引用错误)。

示例:空指针调用的两种情况

class Date {
private:
    int _year;
public:
    // 情况1:不访问成员变量(不使用this->_year)
    void PrintHello() {
        cout << "Hello Date!" << endl;
    }

    // 情况2:访问成员变量(使用this->_year)
    void PrintYear() {
        cout << _year << endl;  // 等价于this->_year
    }
};

int main() {
    Date* p = nullptr;  // 空指针

    // 正确:PrintHello不访问成员,this指针虽为空,但未解引用
    p->PrintHello();  // 输出:Hello Date!

    // 错误:PrintYear访问成员,需要解引用this(nullptr->_year),崩溃
    p->PrintYear();  // 程序崩溃(空指针访问错误)
    return 0;
}

        原因分析:成员函数的地址在代码段,空指针调用时,编译器能找到函数地址(所以PrintHello能执行);但访问成员变量时,需要通过this指针解引用(nullptr->_year),这是非法操作,导致崩溃。

(4)this 指针与 const 成员函数:const 修饰的是 this

        在后面 “const 成员函数” 的章节中,我们会讲到 “const 成员函数不能修改成员变量”,其底层原理就是this指针:

  • 普通成员函数的this指针是Date* this(可修改指向的对象);
  • const 成员函数的this指针是const Date* this(不可修改指向的对象)。

        因此,const 成员函数中不能修改成员变量 —— 因为this指针被 const 修饰,无法通过this修改对象内容。

1.4 总结 this 指针:一句话讲清

   this指针是编译器给非静态成员函数隐式添加的第一个参数,指向当前调用函数的对象,用于区分不同对象的成员变量,其存储和传递由编译器自动处理,我们只需知道它的存在和基本规则即可。

二、默认成员函数:编译器的 “隐形助手”

        先明确一个概念:默认成员函数是指当我们不显式编写时,编译器会自动生成的成员函数。一个类哪怕是空类(没有任何成员),也会拥有这 6 个默认成员函数:

默认成员函数核心作用重点程度
构造函数初始化对象(对象创建时自动调用)⭐⭐⭐⭐⭐
析构函数清理对象资源(对象销毁时自动调用)⭐⭐⭐⭐⭐
拷贝构造函数用已有对象初始化新对象⭐⭐⭐⭐⭐
赋值运算符重载已有对象间的赋值操作⭐⭐⭐⭐⭐
取地址运算符重载获取对象地址(普通对象)
const 取地址运算符重载获取对象地址(const 对象)

        其中前 4 个是开发中必须掌握的核心,后 2 个几乎不用手动实现(依赖编译器默认即可),我们重点讲解前 6 个中的 “高频考点”。

三、构造函数:对象的 “出生证明”

        你有没有想过:为什么创建对象时,成员变量可能是随机值?比如我们定义Date d;d._year可能是一个乱码 —— 因为对象创建时没有被 “正确初始化”。而构造函数的作用,就是在对象创建时自动执行,完成初始化工作。

3.1 构造函数的核心特性

构造函数是 “特殊” 的成员函数,它有 3 个关键特征:

  1. 函数名与类名完全相同(比如Date类的构造函数就叫Date);
  2. 无返回值类型(连void都不用写);
  3. 对象创建时自动调用(不用手动调用,且只调用一次)。
示例:日期类的构造函数
class Date {
private:
    int _year;
    int _month;
    int _day;
public:
    // 构造函数:带参版本,初始化年、月、日
    Date(int year, int month, int day) {
        _year = year;
        _month = month;
        _day = day;
        // 还可以加合法性校验,比如月份不能超过12
        if (month < 1 || month > 12) {
            cout << "月份非法!" << endl;
        }
    }

    void Print() {
        cout << _year << "-" << _month << "-" << _day << endl;
    }
};

// 使用:创建对象时自动调用构造函数
int main() {
    Date d(2024, 5, 20);  // 调用带参构造,d被正确初始化
    d.Print();  // 输出:2024-5-20
    return 0;
}

3.2 默认构造函数:三种 “不用传参” 的情况

如果我们没写构造函数,编译器会自动生成一个无参的默认构造函数。但默认构造函数有个关键规则:能不用传参直接调用的,都叫默认构造函数,具体分三种情况:

默认构造函数类型特点示例
编译器自动生成的无参构造对内置类型(int/char 等)不初始化(随机值),对自定义类型调用其默认构造Date d;
用户写的无参构造自己控制初始化逻辑Date() { _year = 2000; }
用户写的全缺省构造最实用!支持传参 / 不传参两种场景Date(int y=2000, int m=1, int d=1)
        注意:默认构造函数 “互斥”

        一个类只能有一个默认构造函数。比如同时写 “无参构造” 和 “全缺省构造” 会编译错误 —— 因为Date d;会歧义,不知道该调用哪个。

        工程建议:优先写 “全缺省构造函数”,既支持Date d;(用缺省值 2000-1-1),也支持Date d(2024,5,20);(传具体值),灵活性最高。

2.3 编译器生成的默认构造:“双标” 行为

当我们没写构造函数时,编译器生成的默认构造有个 “奇怪” 的规则:

  • 内置类型(int、char、指针等):不做初始化,成员变量是随机值;
  • 自定义类型(比如class Stack):会调用该自定义类型的默认构造函数。

示例:内置类型 vs 自定义类型:

class Stack {
public:
    // Stack的全缺省构造
    Stack(int capacity = 4) {
        _array = (int*)malloc(sizeof(int)*capacity);
        _size = 0;
        _capacity = capacity;
        cout << "Stack构造" << endl;
    }
private:
    int* _array;
    int _size;
    int _capacity;
};

class MyClass {
private:
    // 内置类型:默认构造不初始化,是随机值
    int _a;
    // 自定义类型:默认构造会调用Stack的默认构造
    Stack _st;
};

int main() {
    MyClass mc;  // 输出:Stack构造(_st被初始化),_a是随机值
    return 0;
}

        这就是为什么我们需要显式写构造函数—— 如果类里有内置类型(尤其是指针),编译器生成的默认构造无法保证初始化,可能导致野指针等问题。

四、析构函数:对象的 “临终遗言”

        如果构造函数是 “出生初始化”,那析构函数就是 “临终清理”—— 当对象生命周期结束时(比如出作用域),析构函数会自动调用,释放对象占用的资源(比如 malloc 的内存、文件句柄等)。

4.1 析构函数的核心特性

析构函数也是 “特殊” 的成员函数,特征与构造函数对应:

  1. 函数名是 “~ 类名”(比如Date类的析构函数是~Date);
  2. 无返回值类型(连void都不用写);
  3. 无参数(因此不能重载,一个类只有一个析构函数);
  4. 对象销毁时自动调用(只调用一次)。

4.2 什么时候需要自己写析构函数?

编译器也会自动生成默认析构函数,但它的行为和默认构造类似:

  • 内置类型:不做任何清理(比如指针指向的内存不会释放);
  • 自定义类型:调用其析构函数。

        因此,只有当类需要 “手动释放资源” 时(比如有动态内存、文件句柄),才需要自己写析构函数。最典型的例子就是Stack类:

示例:Stack 类的析构函数

class Stack {
public:
    // 构造函数:动态申请内存
    Stack(int capacity = 4) {
        _array = (int*)malloc(sizeof(int)*capacity);
        _size = 0;
        _capacity = capacity;
        cout << "Stack构造" << endl;
    }

    // 析构函数:释放动态内存(必须自己写!)
    ~Stack() {
        free(_array);  // 释放malloc的内存
        _array = nullptr;  // 避免野指针
        _size = _capacity = 0;
        cout << "Stack析构" << endl;
    }
private:
    int* _array;  // 动态内存,需要手动释放
    int _size;
    int _capacity;
};

int main() {
    Stack st;  // 调用构造:Stack构造
    // st出作用域,自动调用析构:Stack析构
    return 0;
}

如果不写析构函数,_array指向的内存会泄漏 —— 编译器生成的默认析构不会帮我们free

4.3 析构顺序:“先构造的后析构”

        析构函数的调用顺序和构造顺序相反,类似 “栈” 的后进先出(LIFO):

int main() {
    Stack st1;  // 先构造st1
    Stack st2;  // 后构造st2
    // 析构顺序:先st2,后st1
    return 0;
}
// 输出:
// Stack构造(st1)
// Stack构造(st2)
// Stack析构(st2)
// Stack析构(st1)

五、拷贝构造函数:对象的 “克隆术”

        当我们用一个已存在的对象初始化新对象时(比如Date d2 = d1;),就会调用拷贝构造函数。它的作用是 “克隆” 一个和原对象完全相同的新对象。

5.1 拷贝构造函数的核心特性

  1. 函数名与类名相同(和构造函数一样);
  2. 参数必须是 “同类对象的 const 引用”(这是重点,后面讲为什么);
  3. 如果不写,编译器会生成默认拷贝构造函数(浅拷贝)。

示例:日期类的拷贝构造

class Date {

public:
    // 全缺省构造
    Date(int y=2000, int m=1, int d=1) 
    : _year(y)
    , _month(m)
    , _day(d) {}

    // 拷贝构造函数:参数是const Date&
    Date(const Date& d) {
        _year = d._year;  // 拷贝原对象的年
        _month = d._month;  // 拷贝月
        _day = d._day;  // 拷贝日
        cout << "Date拷贝构造" << endl;
    }

    void Print() 
    {
        cout << _year << "-" << _month << "-" << _day << endl;
    }

private:
    int _year;
    int _month;
    int _day;
};

int main() {
    Date d1(2024, 5, 20);
    Date d2 = d1;  // 调用拷贝构造,d2是d1的克隆
    d2.Print();  // 输出:2024-5-20
    return 0;
}

5.2 为什么参数必须是 “引用”?

        如果参数是 “传值” 而不是 “引用”,会导致无限递归调用—— 因为传值时,编译器需要用原对象拷贝构造一个 “形参对象”,而拷贝形参又需要调用拷贝构造,循环往复直到栈溢出。

        错误示例:传值参数导致递归
// 错误!参数是传值,会无限递归
Date(Date d) {
    _year = d._year;
    // ...
}

// 调用Date d2 = d1时:
// 1. 需要将d1传值给形参d,这需要调用拷贝构造生成d
// 2. 生成d又需要传值,再调用拷贝构造... 无限循环

        因此,拷贝构造函数的参数必须是引用(加const是为了防止修改原对象,且能接收 const 对象)。

5.3 默认拷贝构造:浅拷贝的 “坑”

        如果我们没写拷贝构造,编译器会生成默认拷贝构造 —— 它做的是浅拷贝(按字节拷贝,逐个复制成员变量的值)。

        浅拷贝在大多数情况下没问题(比如Date类,成员都是 int),但如果类里有 “动态内存”(比如Stack类的_array指针),浅拷贝会导致两个对象指向同一块内存,析构时重复释放,程序崩溃。

        示例:Stack 类的浅拷贝问题

lass Stack {
public:
    Stack(int capacity = 4) {
        _array = (int*)malloc(sizeof(int)*capacity);
        _size = 0;
        _capacity = capacity;
    }

    ~Stack() {
        free(_array);  // 释放内存
        _array = nullptr;
    }
private:
    int* _array;
    int _size;
    int _capacity;
};

int main() {
    Stack st1;
    Stack st2 = st1;  // 调用默认拷贝构造(浅拷贝)
    // st2._array = st1._array(指向同一块内存)
    return 0;
    // 析构时:先析构st2,free(_array);再析构st1,再次free同一块内存 → 崩溃!
}

        解决方案:自己写拷贝构造函数,实现深拷贝—— 为新对象单独申请一块内存,再复制原对象的数据:

// Stack类的深拷贝构造
Stack(const Stack& st) {
    // 为st2单独申请内存
    _array = (int*)malloc(sizeof(int)*st._capacity);
    memcpy(_array, st._array, sizeof(int)*st._size);  // 复制数据
    _size = st._size;
    _capacity = st._capaci
}

六、赋值运算符重载:对象的 “更新术”

        拷贝构造是 “用旧对象初始化新对象”(新对象不存在),而赋值运算符重载是 “给已存在的对象赋值”(两个对象都已存在)。比如d1 = d2;,其中d1d2都已经创建好了。

6.1 运算符重载的基本规则

        C++ 允许我们重载大部分运算符(比如+===),让自定义类型像内置类型一样使用运算符。重载的语法是:返回值类型 operator运算符(参数列表)

        赋值运算符重载有几个关键要求:

  1. 参数是 const 引用(避免拷贝,防止修改原对象);
  2. 返回 * this 的引用(支持链式赋值,比如d1 = d2 = d3;);
  3. 处理自赋值(比如d1 = d1;,避免自己赋值给自己导致错误);
  4. 如果不写,编译器生成默认赋值重载(浅拷贝,同样有 Stack 类的问题)。

6.2 示例:Date 类的赋值运算符重载

class Date {

public:
    Date(int y=2000, int m=1, int d=1) : _year(y), _month(m), _day(d) {}

    // 赋值运算符重载
    Date& operator=(const Date& d) {
        // 1. 处理自赋值(如果d就是this,直接返回)
        if (this != &d) {
            // 2. 赋值成员变量
            _year = d._year;
            _month = d._month;
            _day = d._day;

        }
        // 3. 返回*this,支持链式赋值
        return *this;
    }

    void Print() {
        cout << _year << "-" << _month << "-" << _day << endl;
    }

private:
    int _year;
    int _month;
    int _day;
};

int main() {
    Date d1(2024, 5, 20);
    Date d2(2023, 12, 31);
    Date d3;

    d3 = d2 = d1;  // 链式赋值:d2 = d1,返回d2;再d3 = d2
    d3.Print();  // 输出:2024-5-20
    return 0;
}

6.3 Stack 类的赋值重载:深拷贝 again

        和拷贝构造类似,Stack 类的默认赋值重载是浅拷贝,会导致重复释放。因此需要自己实现深拷贝的赋值重载:

Stack& operator=(const Stack& st) {
    // 1. 处理自赋值
    if (this == &st) {
        return *this;
    }

    // 2. 释放当前对象的旧内存
    free(_array);

    // 3. 深拷贝:申请新内存,复制数据
    _array = (int*)malloc(sizeof(int)*st._capacity);
    memcpy(_array, st._array, sizeof(int)*st._size);
    _size = st._size;
    _capacity = st._capacity;

    // 4. 返回*this
    return *this;
}

七、const 成员函数:对象的 “只读保护”

        有时候我们需要创建 “只读对象”(比如const Date d(2024,5,20);),这类对象不能被修改。但如果调用非 const 成员函数(比如d.Print(),如果Print没加 const),编译器会报错 —— 因为非 const 成员函数可能修改对象。

        const 成员函数的作用就是:修饰成员函数,保证该函数不会修改成员变量,从而允许 const 对象调用。

7.1 const 成员函数的语法

        在成员函数的参数列表后加const,就是 const 成员函数。本质上,它修饰的是隐含的this指针(thisconst Date*类型,而非Date*)。

示例:const 成员函数的正确写法

class Date {
public:
    Date(int y=2000, int m=1, int d=1) : _year(y), _month(m), _day(d) {}

    // const成员函数:不会修改成员变量
    void Print() const {
        // _year = 2025;  // 错误!const成员函数不能修改成员变量
        cout << _year << "-" << _month << "-" << _day << endl;
    }

    // 非const成员函数:可能修改成员变量
    void SetYear(int year) {
        _year = year;
    }

private:
    int _year;
    int _month;
    int _day;
};

int main() {
    const Date d(2024, 5, 20);  // const对象
    d.Print();  // 正确:const对象可以调用const成员函数
    // d.SetYear(2025);  // 错误:const对象不能调用非const成员函数

    Date d2(2023, 12, 31);  // 非const对象
    d2.Print();  // 正确:非const对象可以调用const成员函数
    d2.SetYear(2024);  // 正确:非const对象可以调用非const成员函数
    return 0;
}

7.2 const 成员函数的调用规则

总结一下关键规则:

  1. const 对象:只能调用 const 成员函数(不能调用非 const 成员函数);
  2. 非 const 对象:可以调用 const 成员函数和非 const 成员函数;
  3. const 成员函数:不能调用非 const 成员函数(因为非 const 成员函数可能修改对象);
  4. 非 const 成员函数:可以调用 const 成员函数。

        工程建议:只要成员函数不修改成员变量,就加上const—— 这样既能支持 const 对象,也不影响非 const 对象,兼容性更好。

八、取地址及 const 取地址运算符重载:几乎不用管

        这两个默认成员函数的作用是 “获取对象的地址”,编译器会自动生成默认版本,行为很简单:

  • 普通取地址重载:Date* operator&() { return this; }
  • const 取地址重载:const Date* operator&() const { return this; }

只有一种情况需要自己写:禁止外部获取对象地址(比如单例模式),此时可以返回nullptr

// 禁止获取地址
Date* operator&() {
    return nullptr;
}

const Date* operator&() const {
    return nullptr;
}

        除此之外,完全依赖编译器默认实现即可。

九、小结与预告

        中篇我们深入了类的 “核心自动行为”:

  1. 构造函数负责初始化,析构函数负责清理,二者是 “生命周期搭档”;
  2. 拷贝构造(初始化新对象)和赋值重载(更新旧对象),都要注意浅拷贝的坑;
  3. const 成员函数是 “只读保护”,让 const 对象也能安全调用函数。

        这些内容是 C++ 面向对象的 “基石”,接下来的下篇,我们会讲更进阶的特性:构造函数的初始化列表、static 成员、友元、匿名对象、编译器优化,以及如何从工程角度 “再理解封装”。

        如果这篇文章对你有帮助,欢迎点赞收藏,我们下篇见!

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值