欢迎光临
我们一直在努力

对于C++:类和对象的解析—中

开篇介绍:

Hello 大家,在《C++:类和对象的解析 — 上》中,我们一起搭建了类与对象的 “骨架”—— 从类的定义语法、访问限定符的权限控制,到对象的内存布局与大小计算,再到 this 指针的隐式机制,初步触摸到了面向对象编程的轮廓。

那么很明显,C++中类和对象的内容远远不止上面这些,所以,在本篇博客中,我们就将了解更多有关C++类和对象的知识。

OK,话不多说,相信大家早已迫不及待了,我们直接发车~~~

类的默认成员函数:

默认成员函数指的是,当用户没有显式实现时,编译器会自动生成的成员函数。一个类在我们不编写相关函数的情况下,编译器会默认生成以下 6 个默认成员函数。需要注意的是,这 6 个函数中前 4 个最为关键,最后两个(取地址相关的重载函数)相对次要,我们稍作了解即可;另外,C++11 标准之后,还新增了两个默认成员函数 —— 移动构造函数和移动赋值运算符,这部分内容我们后续再展开讲解。

默认成员函数的学习很重要,但也存在一定复杂性,建议从两个角度去掌握:

  • 第一,当我们不编写这些函数时,编译器默认生成的函数具体行为是什么,是否能满足我们的使用需求;
  • 第二,若编译器默认生成的函数无法满足需求,我们应当如何自行实现这些函数。

那么类的默认成员函数分别是这些:

C++98 里的 6 个默认成员函数

  • 构造函数:对象创建时自动调用,给对象 “初始化” 用的。
  • 析构函数:对象销毁时自动调用,负责 “清理资源”(比如释放动态内存、关闭文件等)。
  • 拷贝构造函数:用已经存在的对象,去初始化新创建的对象时调用。
  • 拷贝赋值运算符重载:把一个对象的 “值”,拷贝给另一个已经存在的对象时调用(比如 obj2 = obj1; 这种场景)。
  • 取地址运算符重载:返回对象的地址(编译器默认生成的一般就能满足需求)。
  • const 取地址运算符重载:返回 const 修饰的对象的地址(同理,默认生成的通常够用)。
  • C++11 新增的 2 个(移动语义相关)

  • 移动构造函数:用 ** 临时对象(右值)** 的资源,“转移” 给新对象(避免深拷贝的开销,更高效)。
  • 移动赋值运算符:把 ** 临时对象(右值)** 的资源,“转移” 给已有对象(同样是为了高效)。
  • OK,接下来就让我们先来了解构造函数。

    构造函数:

    构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但它的主要任务并不是开辟空间创建对象(我们常使用的局部对象,在栈帧创建时空间就已经开好了),而是在对象实例化时初始化对象。构造函数的本质是要替代我们以前在Stack(栈)和Date类中写的Init函数的功能,而构造函数自动调用的特点,又完美替代了Init函数需要手动调用的操作。

    大家光看上面,是不是就已经有所心动了呢。那么接下来,就让我们来深入了解一下构造函数:

    构造函数的特点:

    • 函数名与类名相同。

    • 无返回值。 (返回值啥都不需要给,也不需要写void,不要纠结,C++规定如此)

    上面这两句呢,其实主要是用于我们自己手动写构造函数所用的,因为怎么说呢,虽然C++会自动生成构造函数,但是别人的哪里有我们自己的好呢,尤其是在面对一些比较复杂的项目,那么它的初始化,我们又怎么可能放心交给编译器自己生成呢,所以呀,大多数时候,我们还是需要自己手动写构造函数的,但是呢,我们又得让编译器知道,哦,原来这里写的是构造函数呀,我要去自动调用它。

    那么就要求我们所写的构造函数符合C++中对构造函数的要求,也就是上面两句,第一点是,构造函数的函数名字要和类的名字一模一样,第二点就是,构造函数是没有返回值的,也就是说,我们不用在函数名前面去加个返回类型的:

    //构造函数的特点:
    //1. 函数名与类名相同。
    //2. 无返回值。(返回值啥都不需要给,也不需要写void,不要纠结,C++规定如此)

    //平时写的初始化函数
    void stinit(……)
    {
    //……
    }

    //构造函数
    class st
    {
    //函数名字和类名字一样,函数没有返回值
    st(……)
    {
    ……;
    }
    };

    其实就是像上面的代码类似。大家在自己手动写构造函数的时候,还是要注意一下的,避免想写构造函数,但是编译器认不出来的情况。

    • 对象实例化时系统会自动调用对应的构造函数。

    那么这一句话其实就是告诉我们,构造函数是可以被编译器自动调用的,是可以不用我们自己去手动调用的。

    当然,这种情况一般是构造函数是没有形参或者形参全有缺省值的情况下,才会在你创建类变量的时候,就直接对类里面的成员变量进行初始化。

    而要是你所写的构造函数是有形参的,但是又没有全部都有缺省值,那么这个时候你创建类变量的时候,就得在变量名字后面再加上括号,里面放上你要传入的参数。

    • 构造函数可以重载。

    这一句话也是说明,在同一类中,我们是可以写多个构造函数的,但是也是有局限性的,并不是说你想写几个就写几个,有一些构造函数是会冲突的,这个我们会在后面讲到,在这里我们只需要先知道,构造函数是可以重载的。

    • 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。

    那么这一句话,也算是挺关键的,那就是如果我们没有在类中手动(显式)创建构造函数的话,那么C++编译器会自动生成一个无参(也就是没有任何形参的构造函数)的默认构造函数,当然,在编译器自动生成的无参构造函数中,究竟会对类中的成员变量怎么初始化,那可就不知道了,这要看编译器自己的脾气。

    那么要是我们自己在类中手动(显式)创建构造函数的话,那编译器就不会自动生成一个无参的默认构造函数,毕竟我们都写了,那么编译器也不会去自讨苦吃,多此一举的去写个构造函数。

    我们可以看一个示例:

    class date
    {
    public:
    void dateprint()
    {
    cout << _year <<"/" << _month <<"/" << _day << endl;
    }
    private:
    int _year;
    int _month;
    int _day;
    };

    int main()
    {
    date d;
    d.dateprint();
    return 0;
    }

    可以看到,由于我们没有自己手动创建构造函数,所以vs编译器中就自动生成无参构造函数,只不过看起来编译器生成的构造函数是把成员变量初始化为-858993460了。

    所以呀,我们还是要能自己就尽量自己,编译器的还是不靠谱。

    无参构造函数、全缺省构造函数,以及我们不写构造时编译器默认生成的构造函数(本质也是无参构造函数),都被称为默认构造函数。

    不过我们要注意,这三种构造函数有且只能有一个存在(否则无参构造函数和全缺省构造函数会因构成函数重载,导致调用时产生歧义)。

    为什么呢?其实很简单,对于无参构造函数和全缺省构造函数,编译器都是在我们创建类变量的时候,就进行了构造函数,那么对于无参构造函数,我们是不用传入参数的,即不用在类变量名字后面加括号,然后括号里面放我们要传入的参数,同样的,即使对于有参数的构造函数,如果我们想传入参数的话,想要用参数的缺省值,那么我们在创建类变量的时候也是一样不要在后面加括号的,一旦加了括号,然后里面不放参数的话,编译器只会认为那是函数,即如下面的例子:

    可以看到,明显报错,所以,就不能加括号,希望大家注意,而编译器默认生成的构造函数,它的本质也是无参构造函数,这个大家可不要忘记了

    而对于全缺省构造函数,根据前面所学的知识,我们知道,我们也是可以不用传入参数的,那么如果一个类中无参构造函数和全缺省构造函数都有的话,那么这个时候我们创建类变量,然后后面也不加括号,那么编译器肯定就会蒙了,不是,要用哪个函数呀,我不知道呀,哈哈,是的,就是这样子的,大家要注意哦。

    那么一般来说,我是建议,我们在手动创建构造函数的时候,可以用全缺省构造函数,因为如果我们不传入参数,这个构造函数也是能ok的运行,只不过用的是我们初始创建构造函数所给的缺省值,而要是我们想传入参数,也可以传进,那么此时的构造函数就是用的我们传进的实参,那么这就是无参构造函数做不到的了。

    我们可以看一下下面的示例:

    class date
    {
    public:
    date(int year = 2025, int month = 10, int day = 8)
    {
    _year = year;
    _month = month;
    _day = day;
    }
    void dateprint()
    {
    cout << _year <<"/" << _month <<"/" << _day << endl;
    }
    private:
    int _year;
    int _month;
    int _day;
    };

    int main()
    {
    date d1;//不传入参数的时候,后面不用加括号
    d1.dateprint();

    date d2(2025,10,9);//要传入参数的时候,后面就得加上括号
    d2.dateprint();
    return 0;
    }

    大家看看,是不是嘎嘎好使呢。

    还有就是不少同学会误以为只有编译器默认生成的构造函数才叫默认构造,实际上,只要是不需要传递实参就能调用的构造函数,都属于默认构造函数(比如无参构造函数、全缺省构造函数),这一点还是很重要的,也是很多新手的误区,大家可以注意,其实也是名字起的不好,要是起名叫做没有实参构造函数,大家也就不会误解了(虽然看起来土了些)。

    较重要的一点:

    C++ 将类型分为内置类型(也叫基本类型)和自定义类型,其中内置类型是语言本身提供的原生数据类型,像 int、char、double、指针等都属于此类,自定义类型则是我们通过 class、struct 等关键字自行定义的类型。

    当我们不编写构造函数时,编译器默认生成的构造函数,对上述内置类型的成员变量没有强制的初始化要求 —— 即是否初始化是不确定的,由编译器决定;而对于自定义类型的成员变量,会调用该成员自身的默认构造函数来完成初始化。若这个自定义类型的成员变量没有默认构造函数,程序就会报错;此时若要初始化该成员变量,需要借助初始化列表(这部分内容我们在下个章节详细讲解)。

    光看上面的解释大家可能不太理解,我们拿代码来分析分析:

    #include<iostream>
    using namespace std;
    typedef int stdatatype;
    class stack
    {
    public:
    stack(int n = 4)
    {
    _a = (stdatatype*)malloc(sizeof(stdatatype) * n);
    if (nullptr == _a)
    {
    perror("malloc申请空间失败");
    return;
    }
    _capacity = n;
    _top = 0;
    }
    // …
    private:
    stdatatype* _a;
    size_t _capacity;
    size_t _top;
    };

    可以看到,上面是我们创建的栈的类,那么我们如果想用两个栈实现队列呢,在我们以前的时候,是不是还得对这两个栈进行单独的初始化,而在C++中,队列的类只需要这样子:

    // 两个stack实现队列
    class myqueue
    {
    public:
    //编译器默认生成myqueue的构造函数调用了stack的构造,完成了两个成员的初始化
    private:
    stack pushst;
    stack popst;
    };
    int main()
    {
    myqueue mq;
    return 0;
    }

    我们并不用在队列类中去设置一个对队列中的栈的初始化函数,因为上面说了于自定义类型的成员变量,会调用该成员自身的默认构造函数来完成初始化,而我们上面的栈类里面,又有对栈的构造函数,那么,编译器就会自动去调用了,不用我们自己去动手,ohyeah,简直是太方便了。

    我希望大家能对上面这一点,格外的重视,因为它确实是很重要的。

    到这里,我们对构造函数的了解,就可以暂时告一段落了。

    析构函数:

    析构函数与构造函数功能相反,析构函数的任务不是完成对象本身的销毁 —— 比如局部对象存储在栈帧中,函数结束时栈帧销毁,对象空间就会自动释放,无需我们额外处理。C++ 规定,对象在销毁时会自动调用析构函数,其核心作用是完成对象中资源的清理与释放(比如动态分配的内存、打开的文件等)

    析构函数的功能可以类比我们之前实现Stack类时写的Destroy函数(用于释放栈的动态内存);而像Date类这类没有动态资源需要释放的类,本质上不需要析构函数的—— 因为它没有需要手动清理的资源。

    所以我们就要知道,析构函数的作用其实就是对我们在类中申请的空间或者打开的文件进行清理以及释放,而且,由于我们上面已经了解了构造函数,所以我们接下来理解析构函数,可就简单多了。

    析构函数的特点:

    • 析构函数名是在类名前加上字符 ~。

    • 无参数、无返回值。 (无返回值这里跟构造类似,也不需要加void)

    这个其实和构造函数类似,第一个区别就是要在函数名(函数名也依旧是类名)前面加个~,

    而第二个区别却是非常重要,那就是我们的析构函数中,是绝对不能有参数的,很多人写习惯了销毁函数,会很自然也对析构函数加个栈或者什么的指针,但是呢 ,在析构函数中,则是不能加参数的,C++编译器自然会自动识别哪些需要释放处理的,这一点很重要,大家要格外注意。

    • 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。

    那么这个也是和构造函数类似,只不过会更严一点,那就是,只能有一个析构函数,你上面构造函数还可以设置一个无参构造函数,再设置一个无缺省值的有参构造函数,但是对于析构函数,就只能有一个析构函数,所以,这也就说明,析构函数并不能进行函数重载。大家注意。

    • 对象生命周期结束时,系统会自动调用析构函数。

    那么这句话我觉得还是很好理解的,无非就是当对象走到尽头时,比如函数运行完毕呀什么什么的,系统就会自动取调用析构函数,并不需要我们去手动显式调用析构函数,很明显,这是一个非常棒的设计。

    • 跟构造函数类似,我们不写编译器自动生成的析构函数对内置类型成员不做处理,自定类型成员会调用他的析构函数。

    这个就与上面讲的构造函数类似了,但是也有区别,如下:

    • 还需要注意的是我们显示写析构函数,对于自定义类型成员也会调用他的析构,也就是说自定义类型成员无论什么情况都会自动调用析构函数。

    这个就很关键了,因为是销毁,是很重要的一个功能,依旧拿这个代码作为示例:

    #include<iostream>
    using namespace std;
    typedef int stdatatype;
    class stack
    {
    public:
    stack(int n = 4)
    {
    _a = (stdatatype*)malloc(sizeof(stdatatype) * n);
    if (nullptr == _a)
    {
    perror("malloc申请空间失败");
    return;
    }
    _capacity = n;
    _top = 0;
    }

    // 析构函数:释放动态分配的内存资源
    ~stack()
    {
    free(_a);
    _a = nullptr;
    _capacity = 0;
    _top = 0;
    }

    // …
    private:
    stdatatype* _a;
    size_t _capacity;
    size_t _top;
    };

    // 两个stack实现队列
    class myqueue
    {
    public:
    // 编译器默认生成myqueue的析构函数,会自动调用两个stack成员的析构函数
    // 显示写析构,也会自动调用Stack的析构
    ~myqueue()
    {
    cout << "我没有释放空间" << endl;
    }
    private:
    stack pushst;
    stack popst;
    };

    int main()
    {
    myqueue mq;
    return 0;
    }

    可以看到我们上面的代码虽然在队列类中写了析构函数,但是却没有对那两个栈成员变量进行空间释放,那么这就很容易造成内存泄露,极其危险,但是呢,不用担心:

    当main函数中的mq对象销毁时:

  • 首先执行myqueue显式定义的析构函数体,输出"我没有释放空间"
  • 编译器自动调用pushst的析构函数,释放其动态内存
  • 编译器自动调用popst的析构函数,释放其动态内存
  • 所以,这个设计不可谓不是一个很牛波一的设计。

    在这里补充说明一下:如果类中没有申请资源(如动态内存、打开的文件等),析构函数可以不写,直接使用编译器生成的默认析构函数(例如Date类);若默认生成的析构函数能满足需求,也无需显式编写析构函数(例如MyQueue类,因为它会自动调用Stack类里面的析构函数);但如果类中有资源申请,就一定要自己写析构函数,否则会造成资源泄漏(例如Stack类)。

    • 一个局部域的多个对象,C++规定后定义的先析构

    这个其实和栈的后进先出有点像,假设下面这么一段代码:

    class stack
    {
    public:
    stack(int n = 4)
    {
    _a = (stdatatype*)malloc(sizeof(stdatatype) * n);
    if (nullptr == _a)
    {
    perror("malloc申请空间失败");
    return;
    }
    _capacity = n;
    _top = 0;
    }

    // 析构函数:释放动态分配的内存资源
    ~stack()
    {
    free(_a);
    _a = nullptr;
    _capacity = 0;
    _top = 0;
    }

    // …
    private:
    stdatatype* _a;
    size_t _capacity;
    size_t _top;
    };

    int main()
    {
    stack st1;
    stack st2;
    return 0;
    }

    那么大家觉得在运行完了之后,是会先对st1进行析构函数呢?还是先对st2进行析构函数呢?

    答案显而易见,是st2,原因即如上所述。

    好处:

    那么光看析构函数,大家可能还察觉不太出,析构函数方便在哪里,那么我就拿之前我们做过的一道题来举个例子,就是左右括号匹配问题:

    // 用最新加了构造和析构的C++版本Stack实现
    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();
    }

    在之前,我们每次返回都得去调用一下销毁函数,感觉好麻烦哦,而在C++版本中,就不用了,因为系统会自动调用析构函数,所以很明显,析构函数还是很不错的。

    OK,到了这里,我们对析构函数的了解也就差不多了,我们接下来看拷贝构造函数。

    拷贝构造函数:

    OK,那么现在我们来继续了解拷贝构造函数,其实大家听名字应该就能猜出个大概,这也是个构造函数。

    若一个构造函数的第一个参数为自身类类型的引用,且其余所有额外参数均带有默认值(其实也就是缺省值),那么该构造函数也被称为拷贝构造函数。换言之,拷贝构造函数是一种特殊的构造函数。

    那么大家光听拷贝这两个字,应该都能猜出来很多,那么这个拷贝构造函数的作用应该是,把一个类的成员变量复制给另一个相同类但是不同变量的成员变量,比如都是Date类,然后有两个Date类变量d1和d2,然后要把d2中的成员变量变得和d1中的成员变量变得一样,这就是拷贝的含义,

    拷贝构造的特点:

    • 拷贝构造函数是构造函数的一个重载。

    这句话其实就是告诉我们,拷贝构造函数的函数名是和类名一样的,而且同样没有返回值,所以也就是构造函数的重载,那么上面我们也说了拷贝构造函数的一个大概的作用,那么又由于拷贝构造函数是特殊的构造函数,那么那么,是不是相当于,拷贝构造函数发挥作用的时候,是和我们前面学的构造函数发挥作用的时候一样的,其实也就是拷贝构造函数的使用时机以及方法是和构造函数一模一样的,同样都是在我们创建一个类变量的时候就会自动调用拷贝构造函数。

    • 拷贝构造函数的第一个参数必须是类类型对象的引用,使用传值方式编译器直接报错,因为语法逻辑上会引发无穷递归调用。 拷贝构造函数也可以多个参数,但是第一个参数必须是类类型对象的引用,后面的参数必须有缺省值。

    这句话是最重要的一点,就是无论如何,你的拷贝构造函数的第一个参数,都得是类类型对象的引用,这是为什么呢?其实很简单,因为上面说了,拷贝构造函数的作用是把一个类的成员变量复制给另一个相同类但是不同变量的成员变量,那么问题就是,你想要实现这个功能,那么你肯定得指定一下你要复制的是哪一个类变量(其实就是形参,但是我们会传入实参)吧,你不可能啥也不说,然后就让编译器去自己寻找,那我觉得编译器应该还没有那么聪明,哈哈哈。

    所以呢,咱肯定就得去指定一下,诶,你要复制什么(哪个)类型的类的成员变量呀,你传给我一下,比如按照上面的例子,我们已经给d1的成员变量初始化了一下,那么这个时候,我们想让d2的成员变量去和d1的成员变量一样,那么这个时候,我们的拷贝构造函数的第一个参数就得是Date& d(这里的d只是起到形参的作用,我们是不用去和我们要复制的类变量长得一模一样的,只不过是类型要知道),但是这就有一个问题了,为什么我们要传的是类的引用呢?不引用难道就不行了吗?传值传参不是也是相当于把d1的成员变量值都给传进去了吗?为什么非得要传引用传参?

    诶,还真是不行,你说牛不牛,那么是为什么呢?原因如下:

    要彻底弄清楚 “拷贝构造函数的第一个参数必须是自身类类型的引用,不能传值”,需从函数传值机制、拷贝构造的调用逻辑和递归依赖三个层面深度剖析:

    1. 函数 “传值” 的本质:“复制实参给形参”

    在 C++ 中,当函数参数为值传递(比如参数写为 Date d)时,调用函数的过程是:编译器会生成 “实参的一份副本”,并将这份副本传递给形参。

    对于自定义类型(如类对象),“生成副本” 的本质是:用 “实参对象” 去初始化 “形参对象”—— 而这一 “初始化自定义对象” 的行为,会触发该类的拷贝构造函数。

    2. 假设 “拷贝构造函数参数是传值”,会触发 “无限递归”

    现在假设拷贝构造函数的签名是 Date(Date d)(参数为值传递),当我们用已存在的对象 d1 去初始化新对象 d2 时(即 Date d2 = d1;),会发生以下循环:

    • 第一步:调用拷贝构造函数 Date(Date d) 来构造 d2。
    • 第二步:为了把 d1 传递给形参 d(值传递),需要复制 d1 的所有成员到 d 中。
    • 第三步:“复制 d1 到 d” 的本质是 “用 d1 初始化 d”—— 而 d 是 Date 类型的对象,因此这一操作会再次调用 Date 类的拷贝构造函数(即又回到第一步)。

    如此循环往复,会陷入无限递归:调用拷贝构造 → 传值需要复制 → 复制需要调用拷贝构造 → 传值需要复制 → …… 最终导致栈溢出(stack overflow),程序崩溃。

    3. “传引用” 如何解决递归问题?

    如果拷贝构造函数的参数是引用传递(比如 Date(Date& d)),引用的核心特性是:它是原对象的 “别名”,传递引用时不会生成新的对象副本。

    此时,用 d1 初始化 d2 的过程是:

    • 调用拷贝构造函数 Date(Date& d),形参 d 成为 d1 的 “别名”(而非新对象)。
    • 拷贝构造函数内部,直接通过引用 d(即原对象 d1)访问成员变量,并将这些值复制到新对象 d2 中。

    整个过程不会触发新的 “对象复制”,因此也就不会递归调用拷贝构造函数,完美避免了无限递归。

    所以大家就明白了吧,在C++中,传值传参不是个比较好的东西,虽然这样评价不怎么好,但是这是实话哦各位,额,这么说吧,我建议以后大家在C++中写函数时,形参就全部写引用参数,你要是不行通过形参改变实参的话,可以加个const,这样子就不用担心会通过形参改变实参。比如如下这样子:

    int add(const int& a, const int& b)
    {
    return a + b;
    }

    如此一来,就不能通过形参改变实参,但是我这里提醒一下,const是加在类型前面的,而不是加在&后面,const的位置不同,代表意思完全不一样:

    const 试图修饰 **“引用本身”,但这种写法在 C++ 中是多余且无意义 ** 的,原因如下:

    一、分析 int& const a

    1. 引用的 “天生不可变绑定” 特性

    引用的核心规则是:一旦在初始化时绑定到某个对象,就永久与该对象绑定,无法后续重新绑定到其他对象。比如:

    int x = 10, y = 20;
    int& a = x; // a 永久绑定到 x
    a = y; // 不是“把 a 重新绑定到 y”,而是“把 y 的值赋给 x(因为 a 是 x 的别名)”

    可见,引用的 “绑定关系” 天生就是不可变的,不需要 const 来额外限制。

    2. const 修饰 “引用本身” 的冗余性

    const 的作用是限制 “变量的可修改性”,但对于引用来说,“能否修改绑定关系” 已经被 C++ 语法强制规定为 “不能改”。此时用 const 修饰 “引用本身”,相当于 “对一个天生不可变的东西,再重复强调‘不可变’”,没有任何额外约束效果。

    3. 编译器的处理

    大多数 C++ 编译器(如 GCC、MSVC)会将 int& const 视为语法错误或直接忽略 const 修饰。例如,GCC 会报类似 “top-level const on reference type ‘int&’ is ignored” 的错误,明确表示:引用本身的 const 是无效的。

    二、分析 const int& b

    const 修饰 **“引用所指向的 int 对象”,是 C++ 中非常实用的写法,核心作用是“保护被引用的对象不被通过引用修改”**,细节如下:

    1. const 的修饰目标:“被引用的对象”

    这里的 const 是 **“底层 const”**(修饰 “所指向 / 引用的对象”),它限制的是:通过这个引用 b,无法修改被引用的 int 变量的值。

    2. 具体行为约束(结合例子理解)

    假设存在变量 int x = 10;,然后声明 const int& b = x;:

    • 合法操作:可以通过 b 读取 x 的值。比如 int y = b;(此时 y 会被赋值为 10)。
    • 非法操作:不能通过 b 修改 x 的值。比如 b = 20;(编译器会报错,因为 b 是 const int&,不允许通过它修改所引用的 int)。

    3. 原始变量的 “独立性”

    const int& b 只限制 “通过 b 来修改”,不影响原始变量 x 自身的可修改性。比如:

    int x = 10;
    const int& b = x;
    x = 20; // 合法!因为 x 本身不是 const,可以直接修改
    std::cout << b; // 输出 20(b 是 x 的别名,x 变了,b 读取的结果也变)

    简言之:const 引用是 “堵死了‘通过引用修改’的路”,但原始变量自己想改,还是能改的。

    4. 函数传参场景的关键作用

    在函数参数中使用 const int& 非常常见,核心优势是 **“兼顾效率与安全性”**:

    • 效率:引用传参(&)避免了对 int 进行拷贝(如果是大对象,拷贝开销更明显)。
    • 安全性:const 保证函数内部无法通过引用意外修改外部传入的变量。

    比如:

    void printSum(const int& a, const int& b) {
    // int sum = a + b; // 合法:读取是允许的
    // a = 100; // 非法:编译器报错,不能通过引用修改外部变量
    std::cout << a + b << std::endl;
    }

    最终总结

    写法const 修饰目标核心意义 / 效果编译器处理 / 实用性
    int& const a 引用本身 多余!因为引用绑定关系天生不可变,const 修饰无额外作用 报错或忽略,无实用性
    const int& b 引用所指向的 int 对象 禁止通过该引用修改被引用的 int 值;常用于函数传参(减少拷贝 + 保护数据) 合法且实用,是常见写法

    那么这个时候大家可能会有点忘记了,const加在类型前面和加在变量前面,有什么区别呢?我们不妨复习复习:

    const不同位置的作用:

    要彻底弄清 const 加在 “类型前面” 和 “变量前面” 的区别,需结合 “const 就近修饰” 原则,分普通变量、指针、引用三大场景详细分析:

    一、普通变量(以 int 为例)

    const 修饰 “普通变量的值” 时,“const 在类型前” 和 “在变量前” 是等价的,仅书写风格不同。

    场景 1:const int a;(const 在类型 int 前)

    const 修饰 **“int 类型的变量 a”,表示:a 的值不能被修改 **。

    const int a = 10;
    a = 20; // 编译错误!因为 `a` 是 const int,值不可改。

    场景 2:int const a;(const 在变量 a 前)

    效果与 const int a; 完全一致——const 同样修饰 “int 类型的变量 a”,a 的值仍不可修改。

    int const a = 10;
    a = 20; // 编译错误,原因同上。

    二、指针变量(最能体现 “const 位置差异”)

    指针涉及 **“指针本身的指向”和“指针指向的内容”** 两个维度,const 位置不同,限制的目标完全不同。

    场景 1:const int* p;(const 在类型 int 前)

    const 修饰 **“指针 p 所指向的 int 内容”**,表示:不能通过 p 修改它指向的 int 值,但 p 本身可以 “换指向”。

    • 拆解理解:const 靠近 int,所以限制 “指向的 int”。

    int x = 10, y = 20;
    const int* p = &x;

    // *p = 30; // 编译错误!不能通过 p 修改 x 的值(p 指向的是 const int)。
    p = &y; // 合法!p 可以指向其他 int 变量(指针本身的“指向”能改)。

    场景 2:int* const p;(const 在变量 p 前)

    const 修饰 **“指针变量 p 本身”**,表示:指针 p 的 “指向” 不能改,但可以通过 p 修改它指向的 int 值(只要目标 int 不是 const)。

    • 拆解理解:const 靠近 p(指针变量),所以限制 “指针本身的指向”。

    int x = 10, y = 20;
    int* const p = &x;

    *p = 30; // 合法!可以通过 p 修改 x 的值(x 是普通 int)。
    // p = &y; // 编译错误!p 是 const 指针,“指向”不能改。

    场景 3:const int* const p;(const 同时在类型和变量前)

    const 同时限制 **“指针指向的内容”和“指针本身的指向”**:既不能通过 p 修改指向的 int 值,也不能改变 p 的指向。

    int x = 10, y = 20;
    const int* const p = &x;

    // *p = 30; // 错误:不能改指向的内容。
    // p = &y; // 错误:不能改指针的指向。

    三、引用变量(结合 “引用天生不可变绑定”)

    引用的核心特性是:一旦绑定对象,就无法再绑定其他对象(绑定关系天生不可变)。因此,const 和引用结合时,主要限制 “通过引用访问的对象的可修改性”。

    场景 1:const int& ref;(const 在类型 int 前)

    const 修饰 **“引用所绑定的 int 对象”**,表示:不能通过引用 ref 修改绑定的 int 值。

    int x = 10;
    const int& ref = x;

    // ref = 20; // 编译错误!不能通过 ref 修改 x 的值。
    x = 20; // 合法!x 本身是普通 int,能直接修改(ref 会同步反映 x 的新值)。

    场景 2:int& const ref;(const 在变量 ref 前)

    这种写法是多余的—— 因为引用的 “绑定关系” 天生不可变,const 修饰 “引用变量本身” 没有额外效果,编译器通常会忽略或报错。

    核心总结:const 的 “就近修饰原则”

    • 普通变量:const 类型 变量 和 类型 const 变量 等价,都表示 “变量的值不可修改”。
    • 指针:const 离 “类型” 近(如 const int*)→ 限制 “指针指向的内容”;const 离 “指针变量” 近(如 int* const)→ 限制 “指针本身的指向”。
    • 引用:const 主要限制 “通过引用访问的对象的可修改性”,而 “引用的绑定关系” 天生不可变,无需 const 额外修饰。

    简单记忆:const 离谁近,就管谁的 “可修改性”—— 离类型近,管 “类型对应的值”;离变量(指针 / 引用变量)近,管 “变量(指针)本身的操作(如指向、绑定)”。

    大家把上面这个记忆记住,就差不多了。

    继续:

    OK,刚在给大家狠狠复习了一下,现在我们转回正题,我们知道了说,拷贝构造函数的第一个参数必须是类类型的引用,因为这个是留给我们到时候传进我们被赋值的类变量,比如d1,那么这时候有人就有疑问了,诶,那编译器知道了被复制的类变量,它怎么知道复制的类变量呢?比如d2,哎呦,大家可别忘记了,还有隐形的this指针呢,其实呀,this指针才是真正的拷贝构造函数的第一个参数,只不过它是隐形的,所以看起来,类类型的引用才是拷贝构造函数的第一个参数,这一点大家可别忘记了,this指针这个东西,还是挺重要的说实话,我们后面还会再用到它。

    那么还有一点就是,如果你想在拷贝构造函数再加一些参数,OK,那是可以的,但是呢,要求你必须那写多加的参数都要有缺省值,也就是全缺省啦,这也是一个需要注意的点。

    拷贝构造函数的使用:

    那么我在这里顺便也说一下拷贝构造函数怎么使用:

    首先我们知道,其实拷贝构造函数也是构造函数,那么构造函数的使用方法,拷贝构造函数也是可以使用的,比如下面:

    class date
    {
    public:
    //构造函数
    date(int year = 2025, int month = 10, int day = 8)
    {
    _year = year;
    _month = month;
    _day = day;
    }
    //拷贝构造函数
    date(date& d)//其实是长date(date* this,date& d)
    {
    _year = d._year;
    _month = d._month;
    _day = d._day;
    }
    void dateprint()
    {
    cout << _year <<"/" << _month <<"/" << _day << endl;
    }
    private:
    int _year;
    int _month;
    int _day;
    };

    int main()
    {
    date d1;//不传入参数的时候,后面不用加括号
    d1.dateprint();

    date d2(d1);//调用拷贝构造函数,将d1传进去,要传入参数的时候,后面就得加上括号
    d2.dateprint();
    return 0;
    }

    可以看到,d2的成员变量就是和d1的成员变量一模一样了。

    那么,这么使用,看起来感觉不是很直观,因为是拷贝嘛,感觉就像是把d1赋值给d2,那要是能改成d2=d1的话,不就看起来舒服多了呢,诶,巧了,还真可以哦:

    是的,这样子也是可以的,那么我们这么使用的时候,C++就是默认把左边的实参传给函数的第一个参数(this指针),而右边的实参传给函数的第二个参数(类的引用),这一点大家要知道哦,所以呀,以后我们也可以使用=去进行调用拷贝构造函数哦,嘎嘎好使真的。

    • C++规定自定义类型对象进行拷贝行为必须调用拷贝构造,所以这里自定义类型传值传参和传值返回都会调用拷贝构造完成。

    那么这一句话呢,也是挺重要的,意思就是说,对于类、结构体这种自定义的类型,如果我们想要拷贝的话,就必须要调用拷贝构造。

    大家知道,对于内置类型,比如int、char什么什么的,我们想要拷贝,也就是赋值,是不是直接像下面这样子就可以:,是不需要什么拷贝构造函数的,编译器会自动解决,但是呢,对于类、结构体这种自定义的类型,要是没有拷贝构造函数的话,比如上面的d1、d2,我们直接就来一个d2=d1,大家觉得编译会不会搭理我们?肯定不会的,会报错的,所以,对于自定义类型的赋值,就需要拷贝构造函数。

    • 若未显式定义拷贝构造,编译器会生成自动生成拷贝构造函数。自动生成的拷贝构造对内置类型成员变量会完成值拷贝/浅拷贝(一个字节一个字节的拷贝),对自定义类型成员变量会调用他的拷贝构造。

    那么这句话呢,意思就是说,如果我们没有自己手动创建拷贝构造函数的话,那么编译器会自动生成一个拷贝构造函数,这个和构造函数、析构函数是不是很像,但是呢,注意,区别来了,我们知道,对于构造函数和析构函数,它们会不会对内置类型成员变量进行处理呢?是不会的,然而对于拷贝构造函数来说,它是会的,这一点,大家要注意哦。

    那么剩下的就是,系统自动形成的拷贝构造函数对内置类型成员变量是会进行值拷贝/浅拷贝(一个字节一个字节的拷贝),而对自定义类型成员变量会调用的自定义类型成员变量自己的拷贝构造函数,后面这半句,和构造函数已经析构函数差不多,大家也都已经清楚了。

    那么,系统自动生成的拷贝构造函数,如何呢?实话实话,还是可以的,但是,肯定也有需要我们自己创建拷贝构造函数的地方:

    • 如果类的成员变量都是内置类型且不指向外部资源(如 Date 类),编译器自动生成的拷贝构造函数就能完成所需拷贝,无需我们显式实现,因为我们知道,Date类里面的成员变量其实都是内置类型,并不是我们的自定义类型,那么系统自动生成的拷贝构造函数进行的值拷贝自然是是游刃有余。
    • 如果类的成员虽为内置类型,但存在指针指向外部资源(如 Stack 类的 _a),编译器自动生成的浅拷贝(值拷贝)无法满足需求,因此需要我们自行实现深拷贝(对指针指向的资源也进行拷贝),这是因为呀,值拷贝就是比较无脑,它是一个一个字节的拷贝,但问题是对于申请的空间,它也是无脑的一个一个拷贝进去,所以这也就导致了,两个类变量的成员变量(有申请空间的变量)所在的空间,是一模一样的,也就是说,两个需要空间的变量,竟然被丢在了一块空间,那你要是把一个变量给销毁了,那不是相当于把另一个变量也给销毁了,这不是大无语事件吗,所以呀,对于有空间申请之类的,我们就不能再用系统默认生成的拷贝构造函数,而是需要我们自己去手动显式设置一个拷贝构造函数,在那里面我们去申请空间给新的成员变量(有申请空间的变量),这样才不会导致两个变量所占的空间是一样的,拿栈做例子,大家看下面:

    #include<iostream>
    using namespace std;
    typedef int STDataType;
    class Stack
    {
    public:
    //构造函数:
    Stack(int n = 4)
    {
    _a = (STDataType*)malloc(sizeof(STDataType) * n);
    if (nullptr == _a)
    {
    perror("malloc申请空间失败");
    return;
    }
    _capacity = n;
    _top = 0;
    }
    //拷贝构造函数:
    Stack(const Stack& st)
    {
    // 需要对_a指向资源创建同样大的资源再拷贝值
    _a = (STDataType*)malloc(sizeof(STDataType) * st._capacity);
    if (nullptr == _a)
    {
    perror("malloc申请空间失败!!!");
    return;
    }
    memcpy(_a, st._a, sizeof(STDataType) * st._top);
    _top = st._top;
    _capacity = st._capacity;
    }
    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;
    }
    //析构函数:
    ~Stack()
    {
    cout << "~Stack()" << endl;
    free(_a);
    _a = nullptr;
    _top = _capacity = 0;
    }
    private:
    STDataType* _a;
    size_t _capacity;
    size_t _top;
    };

    int main()
    {
    Stack st1;
    st1.Push(1);
    st1.Push(2);
    // Stack不显式实现拷贝构造,用自动生成的拷贝构造完成浅拷贝
    // 会导致st1和st2里面的_a指针指向同一块资源,析构时会析构两次,程序崩溃
    Stack st2 = st1;
    return 0;
    }

    • 如果类的内部主要包含自定义类型的成员(如 MyQueue 类包含 Stack 成员),编译器自动生成的拷贝构造函数会调用该自定义类型的拷贝构造函数,因此也无需显式实现当前类的拷贝构造函数,这个就和之前所说的一样了,还是很简单的,我不赘述。

    还有一个小技巧:若一个类显式实现了析构函数来释放资源,就需要显式实现拷贝构造函数;否则不需要。

    最后再提个一嘴:

    传值返回时,会产生一个临时对象并调用拷贝构造函数;传引用返回时,返回的是被返回对象的别名(引用),不会产生拷贝。但如果返回的对象是当前函数局部作用域内的局部对象,函数结束后该对象会被销毁,此时使用引用返回就会出现问题,这样的引用相当于野引用,类似野指针。传引用返回虽能减少拷贝,但必须确保返回的对象在当前函数结束后仍然存在,才能使用引用返回。

    这一个我在之前C++入门基础的引用部分有提到过一嘴,希望大家注意,引用虽然好,但是也得正确使用才行。

    运算符重载:

    OK大家,那么接下来,我们来了解一下运算符重载,这个呢,其实是用于我们自定义类型的比较,就这么说吧大家,对于我们平时的int、float,我们想要比较大小或者是++呀–呀,又或者是+=、-=之类的,是不是直接用就行了,不需要我们再去做什么操作,那么,这些都只是针对内置类型的使用,那如果我们想比较一下,自定义类型中成员变量的大小或者相对自定义类型中成员变量的大小进行改变?此时,我们要怎么办呢?诶,那么此时,我们就需要运算符重载了。先给大家运算符大全,避免大家忘记了某个运算符

    优先级

    运算符 名称或含义 使用形式 结合方向 说明

     

     

     

     

           1

       []

    数组下标 数组名[长度]

     

     

     

     

      从左往右

     
    () 小括号 (表达式)或
    函数名(形参表)
     
      . 取成员 结构体名.成员  
      -> 指针 结构体指针->成员  

     

     

     

     

     

     

     

           2

      – 负号运算符 -表达式

     

     

     

     

     

     

     

      从右往左

    单目运算符
    () 强制类型转换 (数据类型)表达式  
      ++ 自增运算符 ++变量或变量++ 单目运算符
      — 自减运算符 –变量或变量– 单目运算符
      * 取内容 *指针变量 单目运算符
      & 取地址 &变量名 单目运算符
      ! 逻辑非 !表达式 单目运算符
      ~ 按位取反 ~整型表达式 单目运算符
      sizeof 求长度 sizeof(表达式) 单目运算符

     

     

     

           3 

     / 表达式 / 表达式

     

     

     

      从左往右

    双目运算符
     * 表达式 * 表达式 双目运算符
      % 取余 表达式 / 表达式 双目运算符

     

     

           4

     + 表达式+表达式

     

     

      从左往右

    双目运算符
      – 表达式-表达式 双目运算符

     

     

           5

    << 左移 变量<<表达式

     

     

      从左往右

    双目运算符
    >> 右移 变量<<表达式 双目运算符

     

     

     

     

           6

    > 大于 表达式>表达式

     

     

     

     

      从左往右

    双目运算符
    >= 大于或等于 表达式>=表达式 双目运算符
    < 小于 表达式<表达式 双目运算符
    <= 小于或等于 表达式<=表达式 双目运算符

     

     

           7

     == 等于 表达式==表达式

     

     

      从左往右

    双目运算符
     != 不等于 表达式!=表达式 双目运算符
           8   & 按位与 表达式&表达式   从左往右 双目运算符
           9   ^ 按位异或 表达式^表达式   从左往右  双目运算符
          10   | 按位或 表达式|表达式   从左往右 双目运算符
          11 && 逻辑与 表达式&&表达式   从左往右 双目运算符
          12 || 逻辑或 表达式||表达式   从左往右 双目运算符

     

          13

    ?: 条件运算符 表达式1? 表达式2: 表达式3

     

      从右往左

     

    三目运算符

     

     

     

     

     

     

     

     

     

     

     

     

          14

    = 赋值运算符 变量=表达式

     

     

     

     

     

     

     

     

     

     

     

     

      从右往左

     

     

     

     

     

     

     

     

     

     

     

     

    双目运算符

    /= 除后再赋值 变量/=表达式
    *= 乘后再赋值 变量*=表达式
    %= 取余后再赋值 变量%=表达式
    += 加后再赋值 变量+=表达式
    -= 减后再赋值 变量-=表达式
    <<= 左移再赋值 变量<<=表达式
    >>= 右移再赋值 变量>>=表达式
    &= 按位与再赋值 变量&=表达式
    ^= 按位异或再赋值 变量^=表达式
    |= 按位或再赋值 变量|=表达式
          15 , 逗号表达式 表达式,表达式,…   从左往右  
    • 当运算符(如<、>、++、–等等)被用于类类型的对象时,C++ 允许通过运算符重载为其指定新含义。C++ 规定,类类型对象使用运算符时,必须转换为调用对应的运算符重载函数;若不存在对应的运算符重载,则会编译报错。

    那么这句话其实就是告诉我们,当我们想要对类使用运算符的时候,是需要用对应的运算符重载函数,不难就会报错,比如上面的d和d2,如果我们想要比较d1和d2的大小,在我们没有洗运算符重载函数之前,我们直接d1>d2,那么编译器就会报错。

    • 运算符重载是具有特殊名字的函数,他的名字是由operator和后面要定义的运算符共同构成。和其他函数一样,它也具有其返回类型和参数列表以及函数体。

    • 重载运算符函数的参数个数和该运算符作用的运算对象数量一样多。一元运算符有一个参数,二元运算符有两个参数,二元运算符的左侧运算对象传给第一个参数,右侧运算对象传给第二个参数。

    这两句话的含金量,不可谓不高哦各位,也就是告诉我们,运算符重置函数,它的格式是

    // 返回类型 函数名字 参数
    date operator+ (date d1)
    {
    函数体
    }

    那么嘞,大家要对运算符重载函数的格式记得清清楚楚哦,还有就是,重载运算符函数的参数个数和该运算符作用的运算对象数量一样多,这个也是一样的重要,比如+运算符,他就是一个典型的二元运算符,那么此时在+左边的就是要传给operator+的第一个参数,而在+右边的就是要传给operator+的第二个参数,这个大家也同样要记得清清楚楚。

    • 运算符重载以后,其优先级和结合性与对应的内置类型运算符保持一致。

    这句话其实就是告诉我们,即使你进行了运算符重载,那么这个运算符的优先级和结合性与对应的内置类型运算符保持一致,即和没有运算符重载之前的运算符的优先级和结合性一样,换句话说就是,即使你对+进行了运算符重载,那么在有*和+的情况下,也依旧是*先。

    • 不能通过连接语法中没有的符号来创建新的操作符:比如operator@。

    这句话就是一个提醒,就是你要运算符重载,可以,但是只能是C++中有规定的运算符,不能是你自己新加的,比如@¥什么什么的,对这类运算符进行运算符重载,是不被通过的。

    • .*   ::   sizeof   ?:    .    注意以上5个运算符不能重载。

    那么有没有即使是C++中规定的运算符,也不能重载的呢?有的有的,那就是.*   ::   sizeof   ?:    .这5个运算符,大家可能对.*不认识,但是没事,这个其实以后很少很少用到,大家只需要记住就可以了,那么我建议大家要对这5个运算符记清楚,因为它是选择题的常考内容。

    • 一个类需要重载哪些运算符,是看哪些运算符重载后有意义,比如Date类重载operator-就有意义,但是重载operator+就没有意义。

    那么这句话,其实也是提醒我们,我们要进行运算符重载,可以,但是要是有意义的运算符重载,比如比如Date类重载operator-就有意义,因为可以计算两个日期之间隔了多少天,但是重载operator+就没有意义,我们不可能计算两个日期加起来等于什么吧?这是肯定没意义的,虽然我们也可以写个operator+,但是这不是相当于我们自己自讨苦吃吗,所以呀,要运算符重载,也得是有意义的运算符重载才行。

    • 如果一个重载运算符函数是成员函数,则它的第一个运算对象默认传给隐式的this指针,因此运算符重载作为成员函数时,参数比运算对象少一个。

    这句话就是告诉我们呀,如果你写的运算符重载函数,是一个类的成员函数,那么它是默认它的第一个参数是隐式的this指针,那么对于二元运算符来说,我们就不用写两个参数,写一个就行了,而对于一元运算符,更是一个参数都不用写,而且还有一点大家要格外注意,this指针是默认丢在第一个参数的,所以咱用运算符重载的时候,左边的实参,是默认传给this的哦,很多人在这个地方可能会出现差错,望注意。

    • 重载操作符至少有一个类类型参数,不能通过运算符重载改变内置类型对象的含义,如: int  operator+(int x, int y)

    注意,this也是类类型参数哦,那么这句话意思就是说,你可以运算符重载,但是你只能用于自定义类型的运算符,所以,就要求你的运算符重载函数参数必须至少要有一个类类型的参数,this也算,可不要想着去,诶,我用运算符重载函数去改变运算符在内置类型对象的含义,即不能运算符重载函数参数一个类类型的参数都没有。至少要有一个,有了其他的参数你爱怎么样就怎么样。

    • 重载++运算符时,有前置++和后置++,运算符重载函数名都是operator++,无法很好的区分。C++规定,后置++重载时,增加一个int形参,跟前置++构成函数重载,方便区分。

    那么大家,其实这句话呀,就是想要说明,对于前置++和后置++这两个运算符,它们两个长得都是一模一样的,所以在进行运算符重载的时候,函数名也都是operator++,那么此时问题就来了,我们和编译器怎么知道,哪个是前置++和后置++ ,所以呀,C++就提供了一个方法,那就是对于后置++的运算符重载函数,它的函数参数要加一个int,仅此而已,只用加一个int类型就行,后面可以不用跟形参名字,当然,大家要是想要加,也是可以加的,这个其实不影响,我们在调用这个函数的时候,随便往括号里面丢一个整型类型的变量或者常量就行,一般是直接丢个1进去,这样子一来,就能够区分出前置++和后置++了,那么对于–呢,也是和++一样的,一模一样哦各位,所以我这里就不进行多余的解释,大家肯定都已经知道了。

    • 重载 <<和>> 时,需将其重载为全局函数。若重载为成员函数,this 指针会默认占据第一个形参位置,而第一个形参对应运算符的左侧运算对象,这会导致调用时变成 “对象 <<cout”,不符合使用习惯与可读性。若重载为全局函数,将 ostream/istream 类型对象放在第一个形参位置,第二个形参位置放置类类型对象即可。

    那么这句话,那么那么,因为我们知道呢,我们有时候想直接对自定义类型的成员变量直接进行输入,因为在我们以往的时候,想要进行输入,就得对每个变量都输入一下,然后再把每个变量都传进去(通过初始化函数或者构造函数又或者是拷贝构造函数),使得那个自定义类型的成员变量有值,

    那么如果我们不想这么搞呢,就想直接来个<<类变量,然后就可以进行输入所有成员变量的值,使得那个自定义类型的成员变量有值,可不可以呢?其实是可以的大家,我们用>>,<<这两个运算符的运算符重载函数就能够实现,

    那么我在这里先说明一下,虽然看起来是可以,但是本质上也只是我们在运算符重载函数里面完成了对输入成员变量值的一堆操作,并没有说就可以操作简便很多,只不过是在外面调用输入的时候,看起来简便很多,但是实际上,都是在运算符重载函数里面默默付出,一切的一切 都是有人在默默的付出,所以才会有现如今的岁月静好~。
    那么,在我们进行<<,>>的运算符重载的时候,就会有一些讲究了,其实就是如上面那句话所说的,因为我们肯定是要对类中的成员变量进行访问(毕竟是要输入值给成员变量),那么我们一般来说,是要把运算符重载函数设置为类的成员函数,而对于其他的运算符重载函数,可能没什么,但是对于<<,>>的这两个运算符重载函数,这么设置就会有点问题了,

    因为我们知道,对于类的成员函数,编译器是会默认在函数的第一个参数设置this指针,那么此时就会有问题了,因为它是强制第一个参数设置为this指针,那么这个时候我们要调用cout<<d1的时候,

    这里先说一下,cout也算是个参数(实参),是ostrem类类型的参数,所以呢,我们在设置<<,>>运算符重载函数的时候,函数参数是得加入ostream类型的形参的,这样子在<<,>>运算符重载函数里面,我们才能直接动用ostream类型的形参进行输入,额,光看文字大家可能不是很理解,我先给大家一个不完整版的代码,即如下,

    operator<<(ostream& out, const Date& d)
    {
    out << d._year << "年" << d._month << "月" << d._day << "日" << endl;
    }

    大家可以看到,这里面的形参out后面是跟着<<的,然后呢,<<后面是跟着要被输入的成员变量,那么当我们将cout传进<<运算符重载函数的时候,那个形参out就会被自动替代为cout,就变成了

    cout << d._year << "年" << d._month << "月" << d._day << "日" << endl;

    ,那么这一下子就相当于是调用std里面cout,那么这个时候如果我们把这个函数丢进类里面当类的成员函数,那是不是就相当于变成了

    operator<<(Date* this,ostream& out)
    {
    out << d._year << "年" << d._month << "月" << d._day << "日" << endl;
    }

    那么这个时候,我们还能在外面用cout<<d1吗?肯定就不行了,因为会把cout传给this指针,而这是不兼容的,所以就会程序崩溃,无法运行,所以呀,为了能够符合我们平时的习惯,我们就得把<<,>>运算符重载函数设置为全局函数,函数设置为operator<<(ostream& out, const Date& d)这样子,就能符合我们平时的习惯了。

    那么我们不把这个函数丢进类里面的话,我们还怎么去访问类里面的成员变量呢?直接把成员变量设置为public肯定不怎么行,因为这样子就会有成员变量被修改的风险,那么还有什么办法呢?

    其实还是有的,那就是友元,但是这个在下一个类与对象——下才会讲到,那么我在这里先讲个简单的,其实友元就是把我们的写的函数的声明丢在类里面,然后再前面加个friend就行,就像下面这样子:

    class Date
    {
    // 友元函数声明
    friend ostream& operator<<(ostream& out, const Date& d);
    friend istream& operator>>(istream& in, Date& d);
    ……
    }

    这样子一来,全局函数就可以访问类中的私有的成员变量了,那么关于友元的具体分析,我们后面会再说的,目前大家先了解到这里就行。

    OK,那这么一来,这一点我们也就了解的差不多了。

    接下来我们来看运算符重载的使用方法:

    运算符重载的使用方法:

    那么对于运算符重载的使用方法其实也很简单,我们依旧是假设有d1和d2这两个Date类的类变量,那么我们假设是用==这个运算符重载,那么怎么设计==这个运算符重载函数,我这里就不多做说明了,其实就是逐个比较两个类变量的_year、_month、_day罢了,下面是详细代码:

    bool Date::operator==(const Date& d) const
    {
    return _year == d._year
    && _month == d._month
    && _day == d._day;
    }

    我在这里再说明一下,其实这个函数是有两个参数的,第一个参数是this指针,第二个参数才是const Date& d,那么我们的第一个使用方法就是:

    第一个使用方法:

    就是因为我们设置的是类的成员函数,那么就代表我们得去访问某个类变量的这个==运算符重载成员函数,我们假设用d1中的这个==运算符重载成员函数去进行比较,那么这个时候,我们就可以这么进行比较:

    d1.operator(d2);

    这个时候编译器就会把d1传进this指针,把d2传进const Date& d这个形参,然后就会用d1中的成员变量去代替函数中的_year、_month、_day,d2中的成员变量去代替后面的d._year……,就是这么个道理,但是呢,这么写,感觉好麻烦呀,也不符合我们平时用运算符的样子,那么能不能不这样子呢?诶,是可以的,那就是我们的第二个使用方法:

    第二个使用方法:

    那么这个使用方法就简单多了,也很符合我们之前的用运算符的样子,那就是:

    d1==d2

    是的,就是这么简单,当我们这么用的时候,编译器就会自动取寻找左边参数的类的成员函数中的==运算符重载函数,同时把d1传给1this指针,把d2传给const Date& d这个形参,剩下的就和上面一样了。

    所以呢,平时的时候,我比较推荐大家使用第二个使用方法,更加直观和方便。

    OK,接下来我们来看赋值运算符重载:

    赋值运算符重载:

    众所周知,赋值运算符就是=这个运算符,它的作用就是把右边变量的值赋值给左边变量,那么如果我们想要把赋值运算符重载呢,就是把一个自定义类型的成员变量都赋值给另一个相同自定义类型的成员变量呢?

    那么这个时候大家可能会有疑问,诶,这个不是已经有拷贝构造函数了吗,拷贝构造函数不是也可以把一个自定义类型的成员变量都赋值给另一个相同自定义类型的成员变量,为什么还要赋值运算符重载呢?

    其实呀,这是因为:

    拷贝构造函数和赋值运算符重载的作用时机完全不同:

    • 拷贝构造函数,是在创建新对象时(用一个已有对象去初始化另一个刚定义的对象)被调用。比如 MyClass obj2 = obj1;(obj2 是刚创建的新对象),这时候走拷贝构造。

    • 赋值运算符重载,是针对两个已经存在的对象,把一个对象的值 “赋给” 另一个。比如先定义 MyClass obj1, obj2;,再执行 obj2 = obj1;(此时 obj1 和 obj2 都已经存在了),这时候才会调用赋值运算符重载。

    简单讲:一个是 “创建新对象时的初始化”,一个是 “已有对象之间的赋值”,场景不同,所以需要赋值运算符重载来处理 “已有对象间赋值” 的情况。

    大家懂了吧,那么其实赋值运算符重载函数是一个默认成员函数。

    赋值运算符重载的特点:

  • 赋值运算符重载作为一种特殊的运算符重载,C++ 明确规定其必须重载为类的成员函数(不能是全局函数)(毕竟它是一个默认成员函数)。这是因为赋值运算符的左侧操作数通常是当前类的对象,而成员函数的this指针会隐式指向该左侧对象,符合赋值运算 “对象 = 对象” 的语法逻辑。其参数建议声明为const 当前类类型&(如const MyClass& other):

    • 使用 “引用” 可以避免参数传递时的对象拷贝(若传值,会调用拷贝构造函数生成临时副本,增加额外开销);
    • 加const则是为了保证传入的实参(右侧对象)不会被赋值运算符内部意外修改,符合 “赋值仅修改左侧对象” 的语义。
  • 赋值运算符重载必须有返回值,且建议返回 “当前类类型的引用”(如MyClass&):

    • 返回引用的核心目的是提高效率:若返回值为对象(非引用),则返回时会触发拷贝构造函数生成临时对象(运行中间值),增加内存开销;而返回引用(return *this)直接返回当前对象本身,无额外拷贝。
    • 有返回值的设计是为了支持连续赋值场景(如a = b = c):连续赋值的本质是a = (b = c),b = c的返回值会作为a = …的右侧操作数,因此必须有返回值才能完成链式操作。
  • 若未显式实现赋值运算符重载,编译器会自动生成默认赋值运算符重载。其行为与默认拷贝构造函数类似:

    • 对内置类型成员变量(如int、double、指针等)执行值拷贝 / 浅拷贝(逐字节复制,直接复制成员变量的值);
    • 对自定义类型成员变量(如类对象),则会自动调用该自定义类型的赋值运算符重载函数,完成成员的赋值。(注:默认版本仅适用于无动态资源管理的场景,若类包含指向堆内存的指针等资源,默认浅拷贝可能导致重复释放、野指针等问题,需显式实现深拷贝版本。)
  • 若类(如 Date)的成员变量均为内置类型且不指向外部资源,编译器自动生成的赋值运算符重载即可完成所需拷贝,因此无需显式实现赋值运算符重载。

    若类(如 Stack)的成员虽为内置类型,但存在指针(如 _a)指向外部资源,编译器自动生成的浅拷贝(值拷贝)无法满足需求,因此需要自行实现深拷贝(对指针指向的资源也进行拷贝)。

    若类(如 MyQueue)的内部主要包含自定义类型的成员(如 Stack 成员),编译器自动生成的赋值运算符重载会调用该自定义类型的赋值运算符重载,因此也无需显式实现当前类的赋值运算符重载。

    这里还有一个小技巧:若一个类显式实现了析构函数来释放资源,则该类需要显式实现赋值运算符重载;反之,则不需要。

    这一点其实和拷贝构造函数是一模一样的,大家看拷贝构造函数的解释就行。

    #include <iostream>
    using namespace std;

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

    Date(const Date& d)
    {
    cout << "Date(const Date& d)" << endl;
    _year = d._year;
    _month = d._month;
    _day = d._day;
    }

    // 传引用返回减少拷贝,d1 = d2;
    Date& operator=(const Date& d)
    {
    // 不要检查自己给自己赋值的情况
    if (this != &d)
    {
    _year = d._year;
    _month = d._month;
    _day = d._day;
    }
    // d1 = d2表达式的返回对象应该为d1,也就是*this
    return *this;
    }

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

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

    int main()
    {
    Date d1(2024, 7, 5);
    // 拷贝构造函数的调用
    Date d2(d1);
    Date d3(2024, 7, 6);
    d1 = d3;

    // 需要注意这里是拷贝构造,不是赋值重载
    // 记住赋值重载完成两个已经存在的对象直接的拷贝赋值
    // 而拷贝构造用于一个对象拷贝初始化给另一个要创建的对象
    Date d4 = d1;

    return 0;
    }

    const成员函数:

    那么接下来,我们来看看const成员函数,那么这个是有什么用呢?其实呀,这个东西还是挺有用的。

    • 将const修饰的成员函数称之为const成员函数,const修饰成员函数放到成员函数参数列表的后面。

    首先,要求函数必须得是类的成员函数,然后就是要成为const成员函数的话,得在成员函数参数列表的后面,其实就是长这个样子:

    bool operator==(const Date& d) const;

    const是加在括号后面的。

    同样的,如果你把函数声明在类中,而把函数定义在类外面的话,那么你的声明和定义的函数括号后面都得加const,缺一不可。

    那么对成员函数加了const有什么用呢?其实是有用的:

    const 修饰成员函数时,实际作用于该函数隐含的 this 指针,其核心效果是:限制在当前成员函数中,无法通过 this 指针修改类的任何成员变量。

    以 Date 类的 Print 成员函数为例:

    • 未用 const 修饰时,Print 函数隐含的 this 指针类型为 Date* const this(指向 Date 对象的指针,指针本身指向不可改,但指向的对象内容可改);
    • 用 const 修饰后(即 void Print() const),this 指针类型会变为 const Date* const this(指向 “const Date 对象” 的指针,既限制指针本身指向不可改,也限制通过指针修改对象的成员变量)。

    还有就是 它的 “只读承诺” 是全方位的,不仅要求函数内部不能直接修改成员变量,更要禁止通过函数的返回值(或输出参数)给外部提供 “间接修改成员变量 / 对象状态的通道”。

     

    一、基础:const 成员函数的核心约束(直接修改限制)

    const 成员函数通过修饰隐含的 this 指针,将其类型从 类名* const(指向非 const 对象的常量指针)变为 const 类名* const(指向 const 对象的常量指针)。这直接带来两个约束:

    • 函数内部不能直接修改非 mutable 修饰的成员变量;
    • 函数内部不能调用其他非 const 成员函数(因为非 const 成员函数可能修改对象)。

    例子(参考摘要 1 的 Date 类):

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

    // const 成员函数:承诺不修改对象
    void Print() const {
    // _year = 2026; // 错误!直接修改成员变量会编译报错
    cout << _year << "年" << _month << "月" << _day << "日" << endl;
    }

    private:
    int _year, _month, _day;
    };

    当 Date 对象被声明为 const 时(如 const Date d(2025, 10, 18);),只能调用 const 成员函数(如 d.Print()),且函数内部无法直接修改 _year 等成员。

    二、关键:禁止 “通过返回值间接修改”(全方位约束的核心)

    const 成员函数的 “只读” 不是仅限制 “函数内部行为”,更要保证外部无法通过函数的返回值(或输出参数)间接修改对象。如果返回值提供了 “可修改对象的通道”,即使函数内部没直接改成员,也违背了 const 的语义。

    反例:返回 “可写迭代器 / 指针” 的 const 成员函数

    假设我们有一个简单的动态数组类 MyArray,错误地让 const 版本的 begin() 返回可写指针:

    class MyArray {
    private:
    int* _data; // 指向动态数组的指针
    size_t _size; // 元素个数
    public:
    // 错误设计:const 成员函数返回“可写指针”
    int* begin() const {
    return _data; // 函数内没直接改成员,但返回了可写的 int*
    }
    };

    使用时,const 对象能通过返回值间接修改内部数据:

    int main() {
    const MyArray arr; // const 对象,本应“只读”
    int* p = arr.begin(); // 通过 const 成员函数拿到“可写指针”
    *p = 100; // 间接修改了 const 对象的内部数据!
    return 0;
    }

    这里,begin() 是 const 成员函数(内部没直接改成员),但返回的 int* 给外部提供了 “修改 const 对象” 的通道,完全破坏了 const 的 “只读承诺”。

    正确做法:返回 “只读迭代器 / 指针”

    为保证 const 对象的只读性,const 成员函数的返回值必须是 “只读类型”(如 const int*、const_iterator):

    class MyArray {
    private:
    int* _data;
    size_t _size;
    public:
    // 非 const 版本:返回“可写指针”,供非 const 对象修改
    int* begin() {
    return _data;
    }

    // const 版本:返回“只读指针”,供 const 对象读取
    const int* begin() const {
    return _data;
    }
    };

    此时,const MyArray 对象调用 begin() 会得到 const int*,无法通过它修改数据:

    int main() {
    const MyArray arr;
    const int* p = arr.begin();
    // *p = 100; // 错误!const 指针无法修改指向的内容
    cout << *p; // 只能读取,符合“只读”语义
    return 0;
    }

    三、总结:const 成员函数的 “全方位只读”

    const 成员函数的 “只读承诺” 是双重约束:

    • 内部行为:不能直接修改非 mutable 成员,不能调用非 const 成员函数;
    • 外部接口:不能通过返回值(或输出参数)给外部提供 “修改对象的通道”。

    只有同时满足这两点,才能真正保证 const 对象的 “只读性”,这也是 C++ 中 “const 正确性(Const Correctness)” 的核心要求。

      当我们在成员函数后面加上了const后,那么this指向的类变量的成员变量也无法在成员函数中被修改,不难是会有在成员函数内被修改的风险的,大家注意哦。

      取地址运算符重载:

      顾名思义,就是对&这个取地址运算符的运算符重载。那么说实话,这个没啥用。

      一、先明确:取地址运算符重载的两种类型

      C++ 中,取地址运算符 & 的重载分为 普通版本 和 const 版本,两者针对不同类型的对象(非 const 对象、const 对象)提供地址获取逻辑,函数原型有明确规范:

      1. 普通取地址运算符重载(针对非 const 对象)

      当对非 const 类对象使用 & 时(如 &obj),会调用该版本。函数原型(以 Date 类为例):

      // 返回非 const 指针,指向当前对象(this 指针类型为 Date* const)
      Date* operator&() {
      return this; // 编译器默认实现逻辑:直接返回 this 指针(对象的真实地址)
      }

      2. const 取地址运算符重载(针对 const 对象)

      当对 **const 类对象 ** 使用 & 时(如 &const_obj),会调用该版本(const 对象只能匹配 const 版本的成员函数)。函数原型(以 Date 类为例):

      // 返回 const 指针,指向当前 const 对象(this 指针类型为 const Date* const)
      const Date* operator&() const {
      return this; // 编译器默认实现逻辑:直接返回 this 指针(const 对象的真实地址)
      }

      二、编译器的默认行为:通常无需手动实现

      和 “默认拷贝构造函数”“默认赋值运算符” 类似,C++ 编译器会自动生成上述两种取地址运算符重载的默认版本,且默认实现完全能满足绝大多数场景的需求 —— 核心逻辑是 “直接返回对象的真实地址(this 指针)”。

      为什么默认实现够用?

      日常开发中,我们对 “取地址” 的需求本质是 “获取对象在内存中的真实地址”,用于:

      • 传递对象指针(如 func(&obj));
      • 比较对象地址(如 if (&obj1 == &obj2) 判断是否为同一对象);
      • 管理动态对象(如 delete &obj,虽不常用,但需真实地址)。

      编译器默认生成的取地址重载,直接返回 this 指针(对象的真实内存地址),完全匹配这些需求,因此99% 的场景下,无需手动写这两个函数。

      三、特殊场景:何时需要手动实现取地址重载?

      仅当存在 “不想让外部获取对象真实地址” 的特殊需求时,才需要手动实现取地址重载,通过 “返回虚假地址” 隐藏真实内存位置。常见特殊场景包括:

      1. 隐藏对象的真实内存地址(保护隐私)

      某些场景下,对象的真实地址可能关联敏感信息(如底层内存管理、硬件地址映射等),不希望外部通过 & 获取真实地址。此时可手动实现重载,返回一个无关的虚假地址。

      示例(Date 类自定义取地址重载):

      class Date {
      public:
      // 普通版本:对非 const 对象取地址,返回虚假地址
      Date* operator&() {
      // 故意返回一个固定的、与当前对象无关的地址(如 nullptr 或静态变量地址)
      static Date fake_obj;
      return &fake_obj; // 外部获取的是 fake_obj 的地址,而非当前对象真实地址
      }

      // const 版本:对 const 对象取地址,同样返回虚假地址
      const Date* operator&() const {
      static const Date fake_const_obj;
      return &fake_const_obj;
      }

      // 其他成员(构造、Print 等)…
      private:
      int _year, _month, _day;
      };

      此时外部调用 & 得到的是虚假地址,无法获取对象真实内存位置:

      Date d1(2024, 7, 5);
      const Date d2(2024, 7, 6);

      cout << &d1 << endl; // 输出 fake_obj 的地址(非 d1 真实地址)
      cout << &d2 << endl; // 输出 fake_const_obj 的地址(非 d2 真实地址)

      2. 禁止外部获取地址(返回无效地址)

      若完全不想让外部通过 & 获得任何可用地址,可直接返回 nullptr(空指针),强制外部无法通过地址操作对象。

      示例:

      class Date {
      public:
      Date* operator&() {
      return nullptr; // 返回空指针,外部无法通过该地址访问对象
      }

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

      // 其他成员…
      };

      此时 &d1 得到 nullptr,若外部尝试通过该地址访问成员(如 (*&d1).Print()),会直接触发空指针访问错误,间接 “禁止” 了地址获取。

      四、关键注意事项:自定义重载的潜在风险

      手动实现取地址重载时,需明确其 “非常规性” 可能带来的问题:

    • 违反开发者直觉:开发者默认 &obj 获取的是真实地址,返回虚假地址可能导致逻辑混乱(如误以为 &obj1 == &obj2 代表同一对象,实际是虚假地址相等);
    • 兼容性问题:若对象需配合 STL 容器(如 std::vector、std::map)或其他依赖真实地址的库,返回虚假地址可能导致容器存储错误、内存访问异常等问题;
    • 无法完全屏蔽真实地址:自定义重载仅拦截 & 运算符,若外部通过 “指针强转”“内存偏移” 等底层手段,仍可能获取真实地址(自定义重载是 “规范层面的隐藏”,非 “底层层面的屏蔽”)。
    • 五、最终总结

      取地址运算符重载类型编译器默认实现逻辑适用场景是否需手动实现
      普通版本(operator&) 返回 this(对象真实地址) 非 const 对象取地址 通常不需要
      const 版本(operator&() const) 返回 this(const 对象真实地址) const 对象取地址 通常不需要
      自定义版本 返回虚假地址(如 nullptr、静态对象地址) 隐藏对象真实地址的特殊场景 仅特殊需求时需要

      简言之:取地址运算符重载的默认实现已能满足 “获取对象真实地址” 的常规需求,仅当存在 “隐藏真实地址” 的特殊业务场景(如保护敏感内存信息)时,才需手动实现并返回虚假地址,且需谨慎评估其对代码兼容性和可读性的影响。

      OK,到了这里,我们的类和对象——中,便算是大功告成了,类和对象剩下的知识就是:类和对象——下。

      结语:

      结语:于类与对象的脉络中,叩响面向对象编程的大门

      当我们逐行敲完Date类的operator==重载函数,看着d1 == d2在控制台准确返回true或false;当MyQueue对象生命周期结束时,编译器自动调用其内部Stack成员的析构函数,默默释放掉堆内存而无需我们手动编写一行清理代码;当我们为Stack类手动实现深拷贝构造后,st2拷贝st1后两者能各自正常析构,不再出现因重复释放内存导致的程序崩溃 —— 此刻,我们不再是机械地堆砌代码,而是真正在 “构建一个有逻辑、有生命周期的对象世界”。

      回顾这篇关于 C++ 类与对象的深度解析,我们始终围绕 “默认成员函数” 这一核心脉络展开,一点点揭开了面向对象编程的神秘面纱。从构造函数 “替代传统 Init 函数、实现对象自动初始化” 的便捷,到析构函数 “无需手动调用、自动清理资源” 的安心;从拷贝构造函数 “解决对象复制难题、区分浅拷贝与深拷贝边界” 的严谨,到赋值运算符重载 “处理已有对象赋值、支持链式操作” 的灵活;再到const成员函数 “保护对象只读属性、避免误修改成员变量” 的稳妥,以及取地址运算符重载 “默认场景够用、特殊需求可自定义” 的适配 —— 每一个知识点都不是孤立的碎片,而是相互关联、层层递进,共同支撑起 “对象” 这个面向对象编程的核心概念。

      我们曾为 “拷贝构造函数的第一个参数为何必须是引用” 而反复琢磨,直到拆解 “传值参数会触发无限递归” 的底层逻辑:当参数为传值时,调用拷贝构造函数前需要先复制实参生成形参,而复制实参又会再次调用拷贝构造函数,如此循环往复直至栈溢出。这一刻我们才明白,这并非 C++ 语法的 “刻意刁难”,而是语言设计者对内存安全与逻辑自洽的严谨守护。我们也曾为 “const int&与int& const的区别” 而困惑不已,直到通过代码示例验证 “const 就近修饰” 的原则 ——const int&修饰的是 “引用指向的对象”,禁止通过引用修改对象值;int& const修饰的是 “引用本身”,但引用天生绑定后不可修改,因此这种写法冗余且无意义。当这些曾让我们抓耳挠腮的 “难点” 被逐一攻克时,我们对 C++ 的理解也从 “记住语法” 走向了 “理解设计”。

      我们还曾为 “前置 ++ 与后置 ++ 如何通过重载区分” 而好奇,当看到 C++ 规定 “后置 ++ 重载需额外添加一个无实际意义的int形参” 时,不禁惊叹于语法设计的巧妙 —— 这个看似 “多余” 的参数,既不影响实际功能,又能让编译器明确区分两种重载形式,完美解决了 “操作符外观相同但语义不同” 的问题。还有operator<<与operator>>的重载,当我们尝试将其作为成员函数时,发现调用会变成 “对象 << cout” 这种不符合直觉的形式,直到将其改为全局函数并声明为友元,才实现了 “cout << 对象” 的自然语法 —— 这些调试过程中的 “试错与修正”,让我们深刻体会到:C++ 的语法规则背后,始终藏着 “贴合开发者直觉、兼顾易用性与灵活性” 的设计逻辑。

      尤其难忘的是那些 “从崩溃到成功” 的调试瞬间:当我们用编译器默认生成的拷贝构造函数实现Stack类的对象复制时,程序运行到析构阶段突然崩溃,调试窗口弹出 “堆内存重复释放” 的错误提示。那一刻的挫败感如此真实 —— 我们明明只是简单地写了Stack st2 = st1,却为何会触发内存错误?直到我们查看内存布局才发现,默认拷贝构造函数的浅拷贝只是复制了_a指针的地址,导致st1与st2的_a指向同一块堆内存,析构时自然会出现重复释放的问题。而当我们手动实现深拷贝构造函数,为st2的_a重新分配内存并复制st1的内容后,看着程序正常运行、控制台不再弹出错误提示时,那种 “掌控代码” 的成就感足以抵消所有的疲惫。这正是编程的魅力所在:它从不吝啬用 “问题” 考验我们,却也总会在我们攻克难关后,赠予我们最真切的成长。

      或许此刻的你,对某些知识点仍有模糊之处:比如 “何时该手动实现默认成员函数,何时可以放心依赖编译器生成”,比如 “深拷贝与浅拷贝的边界到底在哪里,如何判断一个类是否需要深拷贝”,比如 “this指针在成员函数中如何隐式传递,为何const成员函数能限制this指针的权限”—— 但请不必焦虑。面向对象编程本就不是 “一蹴而就” 的学问,它需要我们在一次次编写class、调试成员函数、修复内存错误的过程中,慢慢沉淀手感与理解。就像我们从一开始分不清 “拷贝构造与赋值重载的区别”,看到Date d4 = d1时会误以为是赋值操作,到后来能一眼判断出这是拷贝构造(因为d4是新创建的对象);从一开始写operator<<时会把ostream& out的位置放错,到后来能熟练地将其重载为全局友元函数,并在函数体内实现对象成员的打印 —— 每一次的 “恍然大悟”,都源于我们对代码的反复打磨与思考。

      在 C++ 的世界里,类与对象从来不是简单的 “语法糖”,而是一种 “将现实问题抽象为代码逻辑” 的编程思想载体。它让我们跳出 “面向过程” 中 “一步一步编写指令” 的局限,转而用 “对象” 的视角去拆解复杂问题。比如要实现一个队列,我们不必再像面向过程那样,纠结 “如何手动管理两个栈的内存分配与释放”,而是将 “栈的初始化、入栈、出栈、析构” 等功能封装成Stack类,MyQueue类只需持有两个Stack对象(pushst用于入队,popst用于出队),便能借助Stack的成员函数轻松实现队列的 “入队”“出队”“判空” 等功能。这便是 “封装” 的力量 —— 将数据与操作数据的方法绑定在一起,隐藏实现细节,只对外暴露必要的接口,这也是面向对象编程 “高内聚、低耦合” 的核心精髓。

      当我们掌握了这些知识后,再回头看最初写的Date类,会发现它早已不是一个简单的 “存储年月日” 的结构体:它有自己的 “初始化规则”(构造函数支持默认参数,可无参创建默认日期,也可传参创建指定日期);有自己的 “复制逻辑”(拷贝构造函数能将一个日期对象的年月日完整复制到新对象);有自己的 “赋值方式”(赋值运算符重载支持已有日期对象间的赋值,且能避免自赋值问题);有自己的 “比较规则”(operator==能判断两个日期是否相等,operator<能比较两个日期的先后);还有自己的 “打印方式”(Print成员函数能按 “年 – 月 – 日” 的格式输出日期)—— 它就像一个 “有生命的实体”,拥有自己的属性(_year、_month、_day)与行为(构造、拷贝、赋值、比较、打印),这正是面向对象编程最迷人的地方:我们在代码中构建的,是一个与现实世界相似的、充满 “对象交互” 的逻辑体系。

      我们还曾在 “编译器默认生成的成员函数行为” 上收获诸多启发:比如默认构造函数对内置类型成员不做初始化(因此Date类若不手动写构造函数,_year可能是随机值),但对自定义类型成员会调用其默认构造函数(因此MyQueue的默认构造会自动调用Stack的默认构造,初始化pushst与popst);默认析构函数对内置类型成员不做处理,但对自定义类型成员会调用其析构函数(因此MyQueue析构时,会先执行自身析构函数体,再自动调用pushst与popst的析构函数释放栈内存);默认拷贝构造与默认赋值运算符重载对内置类型做浅拷贝,对自定义类型调用其对应函数 —— 这些 “默认行为” 看似复杂,实则是 C++ 在 “开发者便捷性” 与 “内存安全性” 之间做出的平衡:对于简单的类(如无动态资源的Date),默认成员函数足以满足需求,无需开发者手动编写;对于复杂的类(如有动态内存的Stack),则需要开发者手动实现深拷贝等逻辑,避免内存错误。

      在学习过程中,我们也逐渐掌握了一些 “实用技巧”:比如 “若一个类显式实现了析构函数来释放动态资源,那么它大概率也需要显式实现拷贝构造函数与赋值运算符重载”—— 这是因为需要释放动态资源的类,其成员中往往有指向堆内存的指针,默认的浅拷贝会导致多个对象共享同一块内存,进而引发重复释放等问题。再比如 “全缺省构造函数是更优的选择”—— 它既能像无参构造函数那样支持Date d1的创建,又能像有参构造函数那样支持Date d2(2024,10,1)的创建,兼顾了灵活性与易用性。还有 “const成员函数应尽可能多用”—— 对于Print、IsLeapYear(判断闰年)等仅读取成员变量、不修改成员变量的函数,将其声明为const成员函数,既能防止函数内部误修改成员变量,又能支持const Date对象调用,拓宽了函数的适用场景。

      或许你会问:“这些知识点如此琐碎,我们真的需要全部掌握吗?” 答案是肯定的。因为在实际开发中,很多 “难以调试的 bug” 都源于对这些基础知识点的理解不透彻:比如因未手动实现深拷贝导致的内存泄漏,因混淆拷贝构造与赋值重载导致的逻辑错误,因未将成员函数声明为const导致const对象无法调用函数 —— 这些问题看似 “低级”,却可能耗费大量的调试时间。只有扎实掌握这些基础,我们才能在面对复杂项目时游刃有余,写出健壮、高效、易维护的代码。

      接下来,在 “类与对象 —— 下” 的内容中,我们还将探索更多精彩的知识点:友元函数如何打破类的访问权限边界,让operator<<等全局函数能访问类的私有成员;静态成员如何实现 “多个对象共享同一数据”,比如用static int count统计Date类对象的创建个数;继承与多态如何让代码更具扩展性,比如让Student类继承Person类,实现代码的复用与逻辑的分层 —— 这些知识点将继续丰富我们的 “面向对象工具箱”,让我们能构建更复杂、更贴合实际需求的程序。

      编程的学习从来没有 “终点”,只有 “新的起点”。此刻的你,或许刚刚能熟练编写Date类的所有默认成员函数,或许还在为 “深拷贝的实现细节” 而反复调试,但请相信:每一次的 “困惑” 都是成长的契机,每一次的 “调试成功” 都是能力的提升。面向对象编程就像学骑自行车,初期需要刻意记住 “平衡技巧”,但随着练习的增多,这些技巧会逐渐内化为 “肌肉记忆”,最终能轻松应对各种 “路况”(项目需求)。

      愿你带着对类与对象的理解,继续在 C++ 的世界里探索、尝试、犯错、成长;愿你始终保持对 “代码背后逻辑” 的好奇,不满足于 “会用”,更追求 “理解为什么这么用”;愿你在未来的项目中,能让 “类与对象” 成为你的得力助手 —— 用class封装复杂逻辑,用对象管理资源生命周期,用成员函数定义对象行为,用运算符重载让代码更直观,用const成员函数保证内存安全。

      或许未来的某一天,当你设计一个复杂的系统(如电商平台的订单管理模块、游戏中的角色系统)时,会想起此刻为Stack类写深拷贝构造函数的午后,想起为Date类重载operator<<时的反复试错,想起搞懂this指针隐式传递逻辑时的兴奋 —— 正是这些细碎的、真实的瞬间,构成了我们编程之路的坚实基石。

      下一站,“类与对象 —— 下”,我们将继续深入探索面向对象编程的奥秘,解锁友元、静态成员、隐式转换等新知识点。让我们带着此刻的收获与好奇,继续同行,在 C++ 的世界里,写出更优雅、更强大的代码,不见不散。

       

      赞(0)
      未经允许不得转载:171主机测评 » 对于C++:类和对象的解析—中
      分享到: 更多 (0)

      评论 抢沙发

      • 昵称 (必填)
      • 邮箱 (必填)
      • 网址