欢迎光临
我们一直在努力

【C++】类和对象(中)--(轻松理解C++重载的难点以及必坑点,以及深拷贝,浅拷贝等)

上节内容我们初步学习了类和对象,接下来我们进一步深入学习类和对象


一.类的默认成员函数

默认成员函数就是⽤⼾没有显式实现,编译器会⾃动⽣成的成员函数称为默认成员函数。⼀个类,我们不写的情况下编译器会默认⽣成以下6个默认成员函数,需要注意的是这6个中最重要的是前4个,最后两个取地址重载不重要,我们稍微了解⼀下即可.其次就是C++11以后还会增加两个默认成员函数,移动构造和移动赋值,这个我们后⾯再讲解。

默认成员函数很重要,也⽐较复杂

学习默认成员函数我们从两方面进行学习

  • 第⼀:我们不写时,编译器默认⽣成的函数⾏为是什么,是否满⾜我们的需求。
  • 第⼆:编译器默认⽣成的函数不满⾜我们的需求,我们需要⾃⼰实现,那么如何⾃⼰实现?

​​​​​​


1-1构造函数(重要)

1.概念

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

没有构造函数之前:

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

int main()
{
Date d1;
d1.Init(2026, 6, 4);
d1.Print();

Date d2;
d2.Init(2026, 6, 3);
d2.Print();

return 0;
}

有了构造函数之后:

class Date
{
public:
// 构造函数:名字与类名相同,无返回值
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}

// 其他成员函数
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}

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

int main()
{
// 创建对象时,编译器自动调用构造函数
Date d1(2023, 6, 4); // 自动初始化
Date d2(2023, 6, 3); // 自动初始化

d1.Print();
d2.Print();

return 0;
}

对于 Date类,可以通过 Init 公有方法给对象设置日期,但存在两个问题:

  • 每次创建对象后都要手动调用 Init 方法

  • 容易忘记初始化,导致对象中存储的是随机值

  • 那么如何解决呢?

    答案就是:构造函数。

    构造函数是一个特殊的成员函数,名字与类名相同,在创建类类型对象时由编译器自动调用,保证每个数据成员都有一个合适的初始值,并且在对象整个生命周期内只调用一次。


    2.构造函数的特点:

  • 函数名与类名相同。
  • ⽆返回值。 (返回值啥都不需要给,也不需要写void,不要纠结,C++规定如此)
  •  对象实例化时系统会⾃动调⽤对应的构造函数。
  •  构造函数可以重载。
  • #include<iostream>
    using namespace std;

    class Date
    {
    public:
        // 特征1:函数名与类名相同
        // 特征2:无返回值,不用写void
        Date()  // 无参构造函数
        {
            _year = 2000;
            _month = 1;
            _day = 1;
            cout << "无参构造函数被调用" << endl;
        }
        
        Date(int year, int month, int day)  // 带参构造函数
        {
            _year = year;
            _month = month;
            _day = day;
            cout << "带参构造函数被调用" << endl;
        }
        
        void Print()
        {
            cout << _year << "-" << _month << "-" << _day << endl;
        }
        
    private:
        int _year;
        int _month;
        int _day;
    };

    int main()
    {
        // 特征3:对象实例化时编译器自动调用对应的构造函数
        Date d1;                    // 自动调用无参构造函数
        Date d2(2026, 6, 4);        // 自动调用带参构造函数
        
        // 特征4:构造函数可以重载(上面两个就是重载)
        
        d1.Print();  // 2000-1-1
        d2.Print();  // 2026-6-4
        
        return 0;
    }

    说明:无参构造函数创建对象时,对象后不用跟括号,否则就成了函数声明。

    5.如果类中没有显式定义构造函数,则C++编译器会⾃动⽣成⼀个⽆参的默认构造函数,⼀旦⽤⼾显式定义编译器将不再⽣成。

    代码说明:

    #include<iostream>
    using namespace std;

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

    int main()
    {
    A a; // 没有写构造函数,编译器自动生成一个
    a.Print(); // 打印随机值(编译器生成的构造函数没初始化_a)
    return 0;
    }

    如果进行了显示定义构造我们看看啥样子:

    #include<iostream>
    using namespace std;

    class B
    {
    public:
    // 显式定义了构造函数
    B(int x)
    {
    _b = x;
    }
    private:
    int _b;
    };

    int main()
    {
    // B b; // 错误!编译器不再生成无参构造函数
    B b(10); // 必须用带参的构造函数
    return 0;
    }

    简单理解:

    用户没写构造函数    编译器自动生成一个无参的
    用户写了构造函数    编译器不再生成,用户负责


    6.我们不写,编译器默认⽣成的构造,对内置类型成员变量的初始化没有要求,也就是说是是否初始化是不确定的,看编译器。对于⾃定义类型成员变量,要求调⽤这个成员变量的默认构造函数初始化。如果这个成员变量,没有默认构造函数,那么就会报错,我们要初始化这个成员变量,需要⽤初始化列表才能解决

    说明:C++把类型分成内置类型(基本类型)和⾃定义类型。内置类型就是语⾔提供的原⽣数据类型,如:int/char/double/指针等,⾃定义类型就是我们使⽤class/struct等关键字⾃⼰定义的类型。

    #include<iostream>
    using namespace std;

    class Time
    {
    public:
    // Time有默认构造函数
    Time()
    {
    _hour = 8;
    _minute = 0;
    _second = 0;
    cout << "Time的默认构造函数被调用" << endl;
    }
    private:
    int _hour;
    int _minute;
    int _second;
    };

    class Date
    {
    public:
    // 我们不写任何构造函数,让编译器自己生成

    // C++11:可以在声明时给内置类型成员设置缺省值
    int _year = 2000; // 缺省值
    int _month = 1; // 缺省值
    int _day = 1; // 缺省值

    Time _t; // 自定义类型成员
    };

    int main()
    {
    Date d;

    cout << d._year << endl; // 2000(用了缺省值)
    cout << d._month << endl; // 1
    cout << d._day << endl; // 1
    // 如果没有缺省值,这里就是随机值

    return 0;
    }

    看看编译器生成的默认构造函数

    // 编译器生成的默认构造函数,逻辑上相当于:
    Date()
    : _t() // 调用Time的默认构造函数
    {
    // 对内置类型成员:什么都不做
    // 但如果有C++11缺省值,就用缺省值初始化
    }

    • 内置类型(int/char/double/指针)

        不处理,值是随机的    int _year;  随机值

    • 内置类型 + C++11缺省值  

     用缺省值初始化    int _year = 2000; → 2000

    • 自定义类型(class/struct)    

    自动调用它的默认构造函数    Time _t; 调用Time()

    • C++11 中针对内置类型成员不初始化的缺陷,又打了补丁,即:内置类型成员变量在 类中声明时可以给默认值,注意这里不是初始化,是声明,给缺省值。

    #include<iostream>
    using namespace std;

    class Date
    {
    public:
    // 这不是初始化,是声明时给缺省值
    int _year = 2000; // 缺省值
    int _month = 1; // 缺省值
    int _day = 1; // 缺省值

    // 等价于上面的写法
    // int _year{2000};
    // int _month{1};
    // int _day{1};
    };

    int main()
    {
    Date d1; // d1._year = 2000
    Date d2; // d2._year = 2000

    // 如果提供了构造函数,缺省值可能被覆盖
    // 如果没有构造函数,缺省值就会用上

    return 0;
    }

    int _year = 2000; 不是初始化,是声明时指定缺省值。真正的初始化发生在对象创建时,如果那时没有显式指定值,就用这个缺省值。

    表格显示:

    概念说明代码位置
    声明 在类中定义成员变量 int _a;
    缺省值(C++11) 声明时给一个默认值 int _a = 10;
    初始化 创建对象时实际赋初值 Date d; 或初始化列表

    7.默认构造函数分为:

    ⽆参构造函数;全缺省构造函数;我们不写构造时编译器默认⽣成的构造函数,

    这三个函数有且只有⼀个存在,不能同时存在。

    ⽆参构造函数和全缺省构造函数虽然构成函数重载,但是调⽤时会存在歧义。要注意我们很多人会认为默认构造函数是编译器默认⽣成那个叫默认构造,实际上⽆参构造函数、全缺省构造函数也是默认构造,总结⼀下就是不传实参就可以调⽤的构造就叫默认构造。

    class Date
    {
    public:
    // 1、无参构造函数
    Date()
    {
    _year = 1;
    _month = 1;
    _day = 1;
    }

    // 2、带参构造函数
    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; //调用存在二义性,编辑器不知道调用哪个
    return 0;
    }

    解决方法一.

    删除无参构造函数:

    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; // 调用 Date(1,1,1)
    Date d2(2023, 6, 4); // 调用 Date(2026,6,4)
    return 0;
    }

    方法二.

    删除全缺省构造函数,保留无参 + 带参(无缺省)

    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(2023, 8, 9); // 调用带参构造函数
    return 0;
    }

    构造函数特点

  • 不用传参数就可以调用(无参/全缺省/编译器生成)

  • 大多数类:显式写全缺省构造函数,一个函数同时支持有参和无参调用

  • 特殊类(如MyQueue):让编译器默认生成即可,会自动调用自定义类型成员的默认构造

  • 核心原则:每个类最好都要提供默认构造函数


  • 1-2析构函数

    1.概念:

    析构函数与构造函数功能相反,析构函数不是完成对对象本⾝的销毁,⽐如局部对象是存在栈帧的,函数结束栈帧销毁,他就释放了,不需要我们管,C++规定对象在销毁时会⾃动调⽤析构函数,完成对象中资源的清理释放⼯作

    咱简单来说,构造函数是为了替代 Init,析构函数是为了替代 Destroy。 

    2.析构函数的特点:

    • 1.析构函数名是在类名前加上字符 ~。
    • 2.⽆参数⽆返回值。 (这⾥跟构造类似,也不需要加void)。
    • 3. ⼀个类只能有⼀个析构函数。若未显式定义,系统会⾃动⽣成默认的析构函数。
    • 4.对象⽣命周期结束时,系统会⾃动调⽤析构函数。
    • 5.后定义的先析构,跟数据结构中的栈性质相同。

    代码说明:

    typedef int DataType;
    class Stack
    {
    public:
    // 特点1:析构函数名是在类名前加上字符 ~
    // 特点2:无参数无返回值(跟构造类似,也不需要加void)
    // 特点3:一个类只能有一个析构函数
    ~Stack()
    {
    // 特点4:对象生命周期结束时,系统会自动调用析构函数
    if (_array)
    {
    free(_array);
    _array = NULL;
    _capacity = 0;
    _size = 0;
    }
    }

    Stack(size_t capacity = 4)
    {
    _array = (DataType*)malloc(sizeof(DataType) * capacity);
    if (NULL == _array)
    {
    perror("malloc申请空间失败!!!");
    return;
    }
    _capacity = capacity;
    _size = 0;
    }

    void Push(DataType data)
    {
    _array[_size] = data;
    _size++;
    }

    private:
    DataType* _array;
    int _capacity;
    int _size;
    };

    void TestStack()
    {
    Stack s; // 创建对象
    s.Push(1);// 析构顺序:先析构 s2,再析构 s1
    s.Push(2);
    } // 特点4验证:s生命周期结束,~Stack()自动调用

    6.编译器自动生成的析构函数

    特点: 跟构造函数类似,我们不写编译器自动生成的析构函数:

    • 对内置类型成员不做处理

    • 对自定义类型成员会调用它的析构函数

    代码说明:

    #include<iostream>
    using namespace std;

    class Time
    {
    public:
    ~Time()
    {
    cout << "Time的析构函数" << endl;
    }
    private:
    int _hour;
    int _minute;
    int _second;
    };

    class Date
    {
    public:
    // 不写析构函数,让编译器生成
    private:
    int _year = 2024;
    int _month = 1;
    int _day = 1;
    Time _t; // 自定义类型成员
    };

    int main()
    {
    Date d;
    return 0;
    }

    main函数中创建的是Date对象d,根本没有直接创建Time对象,为什么最后会调用Time的析构函数?

    这是因为d中包含成员_t(Time类对象)。当main函数结束时,d生命周期结束,编译器会自动调用Date的析构函数。由于Date类没有显式写析构函数,编译器会生成一个默认析构函数,这个默认析构函数会自动调用_t的析构函数,所以输出了~Time()。整个过程都是编译器自动完成的,main函数中没有显式调用任何析构函数。核心规则是:创建哪个类的对象,销毁时就调用该类的析构函数。

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

    特点7:

    • 如果类中没有申请资源,析构函数可以不写,直接使用编译器生成的默认析构函数(比如说 Date)

    • 如果默认生成的析构函数就可以用,就不需要显式写(比如说 MyQueue)

    • 但是有资源申请时(如 malloc、new 打开文件等),一定要自己写析构,否则会造成资源泄漏(如 Stack)

    代码说明:

    // 不需要写析构的类
    class Date
    {
    private:
    int _year;
    int _month;
    int _day;
    };
    // 没有申请资源,编译器生成的析构就够了

    // 必须写析构的类
    class Stack
    {
    public:
    ~Stack() //
    {
    free(_array); // 释放申请的资源
    }
    private:
    DataType* _array; // 申请了堆空间
    };

    默认生成析构函数的特点

    跟构造函数类似,编译器默认生成的析构函数:

    • 对内置类型成员不作处理(不清理,直接回收内存)

    • 对自定义类型成员会去调用它的析构函数

    代码说明:

    class Stack
    {
    public:
    ~Stack() // 显式写析构,释放堆空间
    {
    free(_a);
    }
    private:
    int* _a;
    };

    class MyQueue
    {
    // 不写析构,让编译器生成
    private:
    Stack pushst; // 自定义类型 调用~Stack()
    Stack popst; // 自定义类型 调用~Stack()
    int size; // 内置类型 不作处理
    };


    1-3拷⻉构造函数

    1.概念:

    在创建对象时,可否创建一个与已存在对象一模一样的新对象呢?

    当然可以:方法就是拷贝构造。

    拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用 const 修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。

    2.拷⻉构造的特点:

    • 1.拷⻉构造函数是构造函数的⼀个重载。
    • 2. 拷⻉构造函数的第⼀个参数必须是类类型对象的引⽤,使⽤传值⽅式编译器直接报错,因为语法逻辑上会引发⽆穷递归调⽤。 拷⻉构造函数也可以多个参数,但是第⼀个参数必须是类类型对象的引⽤,后⾯的参数必须有缺省值。

    拷⻉构造是⼀个特殊的构造函数,拷贝构造函数也是特殊的成员函数

    class Date
    {
    public:
    // 特点1:是构造函数的一个重载形式
    Date(int year = 1, int month = 1, int day = 1)
    {
    _year = year;
    _month = month;
    _day = day;
    }

    // 特点2:只有一个参数,参数类型是本类类型的引用(必须用引用)
    // 特点3:const修饰可以防止修改原对象
    Date(const Date& d) // 正确写法:传引用
    // Date(const Date d) // 错误写法:传值会引发无穷递归
    {
    _year = d._year;
    _month = d._month;
    _day = d._day;
    }

    // 若未显式定义,编译器会自动生成(浅拷贝)

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

    int main()
    {
    Date d1; // 调用普通构造
    Date d2(d1); // 用已存在对象创建新对象时,自动调用拷贝构造
    Date d3 = d1; // 也是拷贝构造

    return 0;
    }

    正常函数传值

    void func(int x) // 参数是传值
    {
    cout << x << endl;
    }

    int main()
    {
    int a = 10;
    func(a); // 把 a 的值拷贝给 x
    return 0;
    }

    当调用 func(a) 时,形参 x 是用实参 a 来初始化的,相当于 int x = a。这是一个拷贝过程。

    拷贝构造函数传值

    class Date
    {
    public:
    // 拷贝构造函数,参数是传值
    Date(Date d) // 错误写法
    {
    _year = d._year;
    }
    private:
    int _year;
    };

    int main()
    {
    Date d1;
    Date d2(d1); // 调用拷贝构造
    return 0;
    }

    第1步: Date d2(d1) 
     编译器去找拷贝构造  Date(Date d) ,要把实参 d1 送给形参 d 。
     这里是值传递: Date d = d1 (值传递本质:用实参拷贝初始化形参对象)
     第2步: Date d = d1  

    这条代码本身就要再次调用拷贝构造函数d 是一个全新的 Date 对象,用 d1 初始化 d ,C++语法规则:同类对象互相初始化,自动调用拷贝构造,于是又回去调用  Date(Date d) ,回到第一步。

    闭环死循环:
     d2(d1) →调用构造→ 形参d= d1 →又调用构造→新的形参 d'=d1 →无限循环调用构造函数,栈爆满崩溃。
     对比正确写法  Date(const Date &d) (引用)
     &d 是别名,不创建新对象, d 直接就是 d1 的外号,没有 Date d = d1 创建新对象这一步,不会二次调用拷贝构造,循环消失。

    总结

    •  引用传参:不开辟新对象 → 只调用1次拷贝构造
    • 值传参:必须新建形参对象 → 新建对象就要再调用拷贝构造 → 死递归

    理解

    就像你去复印一张纸:,你要复印文件,但是复印机说:复印之前,需要先复印一份文件A当作模板,为了复印这个模板,又需要先复印一份模板的模板,永远开始不了。

    为什么拷贝构造函数的参数必须用引用?

    错误写法:

    Date(const Date d) // 参数是传值
    {
    _year = d._year;
    }

    正确写法:

    Date(const Date& d) // 参数是引用
    {
    _year = d._year;
    }

    执行 Date d2(d1):引用不会拷贝对象,直接使用 d1;不需要再调用拷贝构造;不会递归

    传值 = 拷贝 = 调用拷贝构造 = 无限递归
    传引用 = 不拷贝 = 直接使用 = 正常调用

    d. 是通过对象 d 去访问它的成员变量。


    3.若未显式定义拷⻉构造,编译器会⽣成⾃动⽣成拷⻉构造函数。⾃动⽣成的拷⻉构造对内置类型成员变量会完成值拷⻉/浅拷⻉(默认的拷贝构造函数对象按内存存储按字节序完成拷贝,⼀个字节⼀个字节的拷⻉),对⾃定义类型成员变量会调⽤他的拷⻉构造。

    #include<iostream>
    using namespace std;

    class Score
    {
    public:
    Score()
    {
    _math = 90;
    _english = 85;
    _chinese = 88;
    }

    Score(const Score& s)
    {
    _math = s._math;
    _english = s._english;
    _chinese = s._chinese;
    cout << "Score::Score(const Score&)" << endl;
    }

    private:
    int _math;
    int _english;
    int _chinese;
    };

    class Student
    {
    public:
    // 不写拷贝构造函数,让编译器生成默认的

    private:
    // 内置类型成员
    int _id = 1001;
    int _age = 18;
    char _sex = 'M';

    // 自定义类型成员
    Score _score;
    };

    int main()
    {
    Student s1;
    Student s2(s1); // 调用编译器生成的Student默认拷贝构造

    return 0;
    }

    4.编译器生成的默认拷贝构造函数已经可以完成字节序的值拷贝了,还需要自己显式实现吗?当然像日期类这样的类是没必要的。但有些需要深拷贝的类,其内部往往是很复杂的,是需要用户显式定义拷贝构造函数来完成深拷贝的。

    #include<iostream>
    using namespace std;
    typedef int DataType;

    class Stack
    {
    public:
    Stack(size_t capacity = 10)
    {
    _array = (DataType*)malloc(capacity * sizeof(DataType));
    if (nullptr == _array)
    {
    perror("malloc申请空间失败");
    return;
    }
    _size = 0;
    _capacity = capacity;
    }

    void Push(const DataType& data)
    {
    // CheckCapacity();
    _array[_size] = data;
    _size++;
    }

    ~Stack()
    {
    if (_array)
    {
    free(_array);
    _array = nullptr;
    _capacity = 0;
    _size = 0;
    }
    }

    private:
    DataType* _array;
    size_t _size;
    size_t _capacity;
    };

    int main()
    {
    Stack s1;
    s1.Push(1);
    s1.Push(2);
    s1.Push(3);
    s1.Push(4);

    Stack s2(s1); // 会造成指向的同一块区域的内容被两次释放
    return 0;
    }

     

    第一步:创建 s1 对象

    Stack s1 调用构造函数,在堆上申请了一块内存空间(地址假设为 0x11223344),可以存放 10 个元素。然后 Push 了 1、2、3、4 四个元素。

    此时 s1 的状态:_array 指向 0x11223344,_size = 4,_capacity = 10。

    第二步:用 s1 拷贝构造 s2

    Stack s2(s1) 执行时,因为 Stack 类没有显式定义拷贝构造函数,编译器生成了默认的拷贝构造函数。默认拷贝构造按照值拷贝(浅拷贝)的方式,把 s1 中的内容原封不动地拷贝到 s2 中。这导致 s2 的 _array 也指向了同一块内存地址 0x11223344。

    此时 s1 和 s2 的 _array 指向同一块堆空间。

    第三步:程序结束,对象销毁

    程序退出时,s2 和 s1 要销毁。销毁顺序是后构造的先析构,所以先销毁 s2,再销毁 s1。

    s2 销毁时调用析构函数,free(0x11223344),将堆内存释放。接着 s1 销毁时再次调用析构函数,又要 free(0x11223344),但这块内存已经被释放过了。同一块内存空间被释放两次,造成程序崩溃。

    结论:

    类中如果没有涉及资源申请时,拷贝构造函数写不写都可以。一旦涉及资源申请(比如 malloc 了堆空间),则拷贝构造函数一定要自己写深拷贝,否则默认的浅拷贝会导致多个对象共享同一块内存,析构时重复释放程序崩溃。

    图像简单说明:

    如图:指向了同一块空间,_str 指向的空间被释放两次,引发程序崩溃。


    5.

    • 传值返回会产⽣⼀个临时对象调⽤拷⻉构造。
    • 传值引⽤返回,返回的是返回对象的别名(引⽤),没有产⽣拷⻉。
    • 但是如果返回对象是⼀个当前函数局部域的局部对象,函数结束就销毁了,那么使⽤引⽤返回是有问题的,这时的引⽤相当于⼀个野引⽤,类似⼀个野指针⼀样。
    • 传引⽤返回可以减少拷⻉,但是⼀定要确保返回对象,在当前函数结束后还在,才能⽤引⽤返回。

    实验1:传值返回

    #include<iostream>
    using namespace std;

    class Stack
    {
    public:
    Stack()
    {
    cout << "Stack() 构造" << endl;
    }

    Stack(const Stack& s)
    {
    cout << "Stack(const Stack&) 拷贝构造" << endl;
    }

    ~Stack()
    {
    cout << "~Stack() 析构" << endl;
    }
    };

    Stack Test1()
    {
    Stack s;
    cout << "准备返回" << endl;
    return s;
    }

    int main()
    {
    Stack ret = Test1();
    return 0;
    }

    优化的结果:

    缺少了拷贝构造,这说明编译器做了优化(拷贝省略)。

    编译器优化导致看不到拷贝构造本来。 Test1() 里的 s 和 main 里的 ret 是两个不同的对象,需要拷贝。但编译器直接让 ret 取代了 s,少创建了一个对象,也少了一次拷贝和一次析构。

    理论结果:

    Stack() 构造
    准备返回
    Stack(const Stack&) 拷贝构造
    ~Stack() 析构
    ~Stack() 析构

    传值返回:产生临时对象,调用拷贝构造。


    实验2:传引用返回局部对象

    Stack& Test2()
    {
    Stack s;
    cout << "准备返回引用" << endl;
    return s;
    }

    int main()
    {
    Stack& ret = Test2();
    return 0;
    }

    没有拷贝构造,但 s 在函数结束时已经被析构,ret 是野引用。


    实验3:传引用返回静态对象

    Stack& Test3()
    {
    static Stack s;
    cout << "准备返回静态对象引用" << endl;
    return s;
    }

    int main()
    {
    Stack& ret = Test3();
    return 0;
    }


    3.拷贝构造函数典型调用场景

  • 使用已存在对象创建新对象

  • 函数参数为类类型对象(传值传参)

  • 函数返回值为类类型对象(传值返回)

  • 代码示例:

    #include<iostream>
    using namespace std;

    class Date
    {
    public:
    Date(int year, int month, int day)
    {
    cout << "构造:" << this << endl;
    }

    Date(const Date& d)
    {
    cout << "拷贝构造:" << this << endl;
    }

    ~Date()
    {
    cout << "析构:" << this << endl;
    }
    };

    Date Test(Date d) // 传参:拷贝构造
    {
    Date temp(d); // 拷贝构造
    return temp; // 返回:拷贝构造
    }

    int main()
    {
    Date d1(2026, 6, 4);
    Test(d1);
    return 0;
    }

    运行结果

    构造: 0000005BC8CFF654 // 第1个对象
    拷贝构造: 0000005BC8CFF734 // 第2个对象
    拷贝构造: 0000005BC8CFF774 // 第3个对象
    析构: 0000005BC8CFF734 // 第2个对象销毁
    析构: 0000005BC8CFF774 // 第3个对象销毁
    析构: 0000005BCCFF654 // 第1个对象销毁

    只有 3 次构造/拷贝构造,不是 4 次。理论应该有的理论上传值返回还会产生一次临时对象的拷贝构造,应该有 4 次,但编译器优化掉了,temp 直接作为返回值,没有额外拷贝。:

    完整流程:

    1. 构造 d1 (地址 0x654)
    2. 调用 Test(d1)
    → 拷贝构造形参 d (地址 0x734)
    3. 进入函数体
    → 拷贝构造 temp(d) (地址 0x774)
    4. 准备返回
    → 编译器优化:temp 直接作为返回值 (没有新对象)
    5. 函数结束
    → 析构形参 d (地址 0x734)
    6. main 函数结束
    → 析构 temp (地址 0x774)
    → 析构 d1 (地址 0x654)


    哪些类需要自己写拷贝构造?

    需要自己写的类:

    • 像 Stack、Queue 这类有资源申请的类,必须自己写拷贝构造。因为编译器默认生成的浅拷贝只是把指针的值复制过去,导致多个对象指向同一块内存,析构时会重复释放导致程序崩溃。

    不需要自己写的类:

    • 像 Date 这类只有内置类型成员的类,不需要自己写拷贝构造。因为编译器默认生成的值拷贝(浅拷贝)就够了,把每个成员的值原封不动复制过去,每个对象都有独立的数据,互不影响。
    • 像 MyQueue 这类成员都是自定义类型的类,也不需要自己写拷贝构造。因为编译器默认生成的拷贝构造会自动调用每个成员(如 Stack)的拷贝构造,只要每个成员自己写好了拷贝构造,MyQueue 就不用操心。

    二.函数重载/赋值运算符重载

    1.函数重载(Function Overload)

     定义

    在同一个作用域中,函数名相同、参数列表(个数 / 类型 / 顺序)不同,构成函数重载;返回值不能作为重载区分依据。

    编译器根据实参类型自动匹配调用对应函数。

    2. 重载成立三要素(缺一不可)

  • 同一作用域(同个类 / 同全局域)
  • 函数名完全一致
  • 参数列表不同:参数个数、类型、参数顺序任意一种不一样即可 仅返回值不同,不能构成重载
  • #include<iostream>
    using namespace std;

    // 1. 参数个数不同
    int Add(int a)
    {
    return a;
    }

    int Add(int a, int b)
    {
    return a + b;
    }

    int Add(int a, int b, int c)
    {
    return a + b + c;
    }

    // 2. 参数类型不同
    void Print(int x)
    {
    cout << "int: " << x << endl;
    }

    void Print(double x)
    {
    cout << "double: " << x << endl;
    }

    void Print(const char* x)
    {
    cout << "string: " << x << endl;
    }

    // 3. 参数顺序不同
    void Show(int a, double b)
    {
    cout << "int,double" << endl;
    }

    void Show(double a, int b)
    {
    cout << "double,int" << endl;
    }

    int main()
    {
    // 调用不同的重载函数
    cout << Add(5) << endl; // 调用 Add(int)
    cout << Add(5, 10) << endl; // 调用 Add(int, int)
    cout << Add(5, 10, 15) << endl;// 调用 Add(int, int, int)

    Print(10); // Print(int)
    Print(3.14); // Print(double)
    Print("hello"); // Print(const char*)

    return 0;
    }

    不能构成重载的情况

    1. 只有返回值类型不同

    int func(int x) { return x; }
    double func(int x) { return x; }

    2. 只有参数名不同

    void func(int a) { }
    void func(int b) { }

    3. 参数类型只是 const 和非 const 的区别(值传递时)

    void func(int x) { }
    void func(const int x) { }

    注意:指针和引用可以区分

    void func(int* p) { }
    void func(const int* p) { }

    void func(int& x) { }
    void func(const int& x) { }


    2.运算符重载

    当运算符被⽤于类类型的对象时,C++语⾔允许我们通过运算符重载的形式指定新的含义。C++规定类类型对象使⽤运算符时,必须转换成调⽤对应运算符重载,若没有对应的运算符重载,则会编译报错。

    运算符重载是具有特殊名字的函数

    • 名字是由operator和后⾯要定义的运算符共同构成。
    • 它也具有其返回类型和参数列表以及函数体。

     

    内置类型可以直接使用运算符运算,编译器知道要如何运算。

    自定义类型无法直接使用运算符,编译器不知道要如何运算。

    运算符重载的特点

    • 不能通过连接语法中没有的符号来创建新的操作符:⽐如operator@。
    • .* ::  sizeof  ?: . 注意以上5个运算符不能重载。(选择题⾥⾯常考)
    • 重载操作符⾄少有⼀个类类型参数,不能通过运算符重载改变内置类型对象的含义,如: intoperator+(int x, int y)
    • ⼀个类需要重载哪些运算符,是看哪些运算符重载后有意义,⽐如Date类重载operator-就有意义,但是重载operator+就没有意义。
    • 重载运算符函数的参数个数和该运算符作⽤的运算对象数量⼀样多。⼀元运算符有⼀个参数,⼆元运算符有两个参数,⼆元运算符的左侧运算对象传给第⼀个参数,右侧运算对象传给第⼆个参数。
    • 如果⼀个重载运算符函数是成员函数,则它的第⼀个运算对象默认传给隐式的this指针,因此运算符重载作为成员函数时,参数⽐运算对象少⼀个
    • 运算符重载以后,其优先级和结合性与对应的内置类型运算符保持⼀致。
    • 用于内置类型的运算符,其含义不能改变,例如:内置的整型 +,不能改变其含义。
    • 重载++运算符时,有前置++和后置++,运算符重载函数名都是operator++,⽆法很好的区分。C++规定,后置++重载时,增加⼀个int形参,跟前置++构成函数重载,⽅便区分
    • 重载<<和>>时,需要重载为全局函数,因为重载为成员函数,this指针默认抢占了第⼀个形参位置,第⼀个形参位置是左侧运算对象,调⽤时就变成了 对象<<cout,不符合使⽤习惯和可读性。重载为全局函数把ostream/istream放到第⼀个形参位置就可以了,第⼆个形参位置当类类型对象。

    #include<iostream>
    using namespace std;

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

    // 推荐写法:重载成成员函数
    bool operator==(const Date& d2)
    {
    return _year == d2._year && _month == d2._month && _day == d2._day;
    }

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

    int main()
    {
    Date d1(2026, 6, 3);
    Date d2(2026, 6, 4);

    cout << (d1 == d2) << endl; // 输出0
    return 0;
    }

    运算符重载的两种方式

    方式一:重载成成员函数

    形参数目看起来比运算符需要的参数少一个,因为成员函数有隐含的 this 指针,this 指向调用对象作为第一个参数。

    class Date {
    public:
    // 成员函数:只需要一个参数,另一个是 this
    bool operator==(const Date& d2) {
    return _year == d2._year && _month == d2._month && _day == d2._day;
    }
    private:
    int _year;
    int _month;
    int _day;
    };

    // 调用时:d1 == d2 等价于 d1.operator==(d2)

    方式二:重载成友元函数

    必须有一个参数要是类的对象,通常不这样做,优先重载成成员函数。

    class Date {
    friend bool operator==(const Date& d1, const Date& d2);
    private:
    int _year;
    int _month;
    int _day;
    };

    // 全局函数:两个参数都要写
    bool operator==(const Date& d1, const Date& d2) {
    return d1._year == d2._year && d1._month == d2._month && d1._day == d2._day;
    }

    运算符重载优先重载成成员函数,只有左操作数不是类对象时才用友元函数。

    #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& operator++()
    {
    cout << "前置++" << endl;
    _day++; // 天数加1
    return *this;
    }

    // 后置++:返回临时对象,int参数是占位符
    Date operator++(int)
    {
    cout << "后置++" << endl;
    Date tmp = *this; // 保存自增前的值
    _day++; // 自增
    return tmp; // 返回自增前的值
    }

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

    int main()
    {
    Date d1;
    ++d1; // 调用前置++
    d1++; // 调用后置++
    return 0;
    }

    是前置++和后置++的区别:前置++返回自身引用,后置++返回临时对象(自增前的值),后置++的参数int只是为了和前置++区分,没有实际意义。


    3.赋值运算符重载

    赋值运算符重载是⼀个默认成员函数,⽤于完成两个已经存在的对象直接的拷⻉赋值,这⾥要注意跟拷⻉构造区分,拷⻉构造⽤于⼀个对象拷⻉初始化给另⼀个要创建的对象。

    • 赋值运算符重载格式

    参数类型:const T&

    传递引用可以提高传参效率,避免拷贝构造。

    返回值类型:T&

    返回引用可以提高返回效率,避免拷贝构造;有返回值目的是为了支持连续赋值(如 d1 = d2 = d3)。

    检测是否自己给自己赋值:防止无效操作和无谓的拷贝。

    *返回 this:要符合连续赋值的含义,返回当前对象的引用。

    class Date
    {
    public:
    // 构造函数
    Date(int year = 1, int month = 1, int day = 1)
    {
    _year = year;
    _month = month;
    _day = day;
    }

    // 拷贝构造(不需要返回,删掉 return *this)
    Date(const Date& d)
    {
    _year = d._year;
    _month = d._month;
    _day = d._day;
    }

    // 赋值运算符重载
    Date& operator=(const Date& d)
    {
    if (this != &d) // 检测是否自己给自己赋值
    {
    _year = d._year;
    _month = d._month;
    _day = d._day;
    }
    return *this; // 支持连续赋值
    }

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

    void TestDate()
    {
    Date d1(2023, 9, 13);
    Date d2(d1); // 拷贝构造
    Date d3(2023, 11, 28);
    d2 = d1 = d3; // 赋值
    }

    • 赋值运算符只能重载成成员函数

    原因: 赋值运算符 = 如果不显式实现,编译器会自动生成一个默认的。如果重载成全局函数,会和编译器生成的默认赋值运算符冲突,导致编译错误。

    赋值必须成员函数,其他运算符看情况。

    错误写法:

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

    // 错误:赋值运算符不能重载成全局函数
    Date& operator=(Date& left, const Date& right)
    {
    if (&left != &right)
    {
    left._year = right._year;
    left._month = right._month;
    left._day = right._day;
    }
    return left;
    }

    正确写法:

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

    // 赋值运算符重载成成员函数
    Date& operator=(const Date& right)
    {
    if (this != &right)
    {
    _year = right._year;
    _month = right._month;
    _day = right._day;
    }
    return *this;
    }

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

    • 用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。

    规则:

    • 内置类型成员变量:直接赋值(值拷贝)

    • 自定义类型成员变量:调用对应类的赋值运算符重载完成赋值

    #include<iostream>
    using namespace std;

    class Time
    {
    public:
    Time(int hour = 0, int minute = 0, int second = 0)
    {
    _hour = hour;
    _minute = minute;
    _second = second;
    }

    // Time的赋值运算符重载
    Time& operator=(const Time& t)
    {
    cout << "Time::operator=" << endl;
    if (this != &t)
    {
    _hour = t._hour;
    _minute = t._minute;
    _second = t._second;
    }
    return *this;
    }

    private:
    int _hour;
    int _minute;
    int _second;
    };

    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;

    // 自定义类型
    Time _t;
    };

    int main()
    {
    Date d1(2026, 6, 4);
    Date d2;

    d2 = d1; // 调用默认赋值运算符
    // 1. _year, _month, _day 直接值拷贝
    // 2. _t 调用 Time::operator=

    return 0;
    }

    编译器生成的默认赋值运算符已经可以完成字节序的值拷贝,但像日期类这样的类(只有内置类型成员)是不需要自己实现的。然而,有些需要深拷贝的类,其内部往往很复杂,是需要用户显式定义赋值运算符重载函数来完成深拷贝的。

    typedef int DataType;

    class Stack
    {
    public:
    // 构造函数
    Stack(size_t capacity = 10)
    {
    _array = (DataType*)malloc(capacity * sizeof(DataType));
    if (nullptr == _array)
    {
    perror("malloc申请空间失败");
    return;
    }
    _size = 0;
    _capacity = capacity;
    }

    // 深拷贝:赋值运算符重载
    Stack& operator=(const Stack& s)
    {
    if (this != &s) // 检测自己给自己赋值
    {
    // 释放旧空间
    free(_array);

    // 申请新空间
    _array = (DataType*)malloc(s._capacity * sizeof(DataType));
    if (nullptr == _array)
    {
    perror("malloc申请空间失败");
    return *this;
    }

    // 拷贝数据
    _size = s._size;
    _capacity = s._capacity;
    for (size_t i = 0; i < _size; i++)
    {
    _array[i] = s._array[i];
    }
    }
    return *this;
    }

    void Push(const DataType& data)
    {
    _array[_size] = data;
    _size++;
    }

    ~Stack()
    {
    if (_array)
    {
    free(_array);
    _array = nullptr;
    _capacity = 0;
    _size = 0;
    }
    }

    private:
    DataType* _array;
    size_t _size;
    size_t _capacity;
    };

    int main()
    {
    Stack s1;
    s1.Push(1);
    s1.Push(2);
    s1.Push(3);
    s1.Push(4);

    Stack s2;
    s2 = s1; // 调用深拷贝赋值,不会崩溃

    return 0;
    }

    浅拷贝赋值导致的问题

    第一步:创建 s1

    s1 调用构造函数,在堆上申请了一块空间(地址 0x11223344),存放了 1、2、3、4 四个元素。此时 s1 的 _array 指向 0x11223344,_size 为 4,_capacity 为 10。

    第二步:创建 s2

    s2 调用构造函数,在堆上申请了一块空间(地址 0x22334455),没有存放任何元素。此时 s2 的 _array 指向 0x22334455,_size 为 0,_capacity 为 10。

    第三步:执行 s2 = s1

    由于 Stack 类没有显式实现赋值运算符重载,编译器使用默认的浅拷贝赋值方式,将 s1 中的内容原封不动地拷贝到 s2 中。赋值完成后,s2 的 _array 也指向了 0x11223344,_size 变成 4,_capacity 变成 10。

    导致的两个问题:

    第一个问题:s2 原来指向的空间(0x22334455)没有被释放,这块内存就丢失了,造成内存泄漏。

    第二个问题:s1 和 s2 的 _array 指向同一块内存空间(0x11223344)。程序结束时,先销毁 s2 调用析构函数释放了这块内存,然后销毁 s1 再次调用析构函数释放同一块内存,同一块内存被释放两次,导致程序崩溃。

    解决方法: 自己实现深拷贝赋值运算符,先释放旧空间,再申请新空间,最后拷贝数据。

    赋值运算符重载的特点:

    赋值运算符重载的特点

  • 赋值运算符重载是一个运算符重载,规定必须重载为成员函数。赋值运算重载的参数建议写成 const 当前类类型引用,否则传值传参会有拷贝。

  • 有返回值,且建议写成当前类类型引用,引用返回可以提高效率,有返回值目的是为了支持连续赋值场景。

  • 没有显式实现时,编译器会自动生成一个默认赋值运算符重载,默认赋值运算符重载行为跟默认拷贝构造函数类似,对内置类型成员变量会完成值拷贝/浅拷贝(一个字节一个字节的拷贝),对自定义类型成员变量会调用他的赋值重载函数。

  • 像Date这样的类成员变量全是内置类型且没有指向什么资源,编译器自动生成的赋值运算符重载就可以完成需要的拷贝,所以不需要我们显示实现赋值运算符重载。像Stack这样的类,虽然也都是内置类型,但是_a指向了资源,编译器自动生成的赋值运算符重载完成的值拷贝/浅拷贝不符合我们的需求,所以需要我们自己实现深拷贝(对指向的资源也进行拷贝)。像MyQueue这样的类型内部主要是自定义类型Stack成员,编译器自动生成的赋值运算符重载会调用Stack的赋值运算符重载,也不需要我们显示实现MyQueue的赋值运算符重载。这里还有一个小技巧,如果一个类显示实现了析构并释放资源,那么他就需要显示写赋值运算符重载

  • 三.取地址运算符重载

    3-1const成员函数

    • 将const修饰的成员函数称之为const成员函数,const修饰成员函数放到成员函数参数列表的后⾯。
    •  const实际修饰该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进⾏修改。const 修饰Date类的Print成员函数,Print隐含的this指针由 Date* const this 变为 const Date* const this

    #include<iostream>
    using namespace std;

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

    // const成员函数:不会修改成员变量,const对象和非const对象都可以调用
    void Print() const
    {
    cout << _year << "-" << _month << "-" << _day << endl;
    }

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

    int main()
    {
    // 非const对象也可以调用const成员函数,这是权限的缩小(可以)
    Date d1(2026, 6, 4);
    d1.Print();

    // const对象只能调用const成员函数
    const Date d2(2026, 6, 5);
    d2.Print();

    return 0;
    }

    • 权限放大(错误)

    代码演示:

    class Date
    {
    public:
    void Print() // 非const成员函数
    {
    cout << _year << "-" << _month << "-" << _day << endl;
    }
    };

    int main()
    {
    const Date d2(2026, 6, 4);
    d2.Print(); // 错误!const对象不能调用非const成员函数
    return 0;
    }

    const修饰成员函数是有好处的,这样const对象可以调用,非const对象也可以调用。

    但并不是所有的成员函数都要加const,具体得看成员函数的功能:

    • 如果成员函数是修改型(比如:operator+=、Push),那就不能加const

    • 如果成员函数是只读型(比如:Print、operator+),那就最好加上const

    权限规则:

    • const成员函数(只读)内不可以调用非const成员函数(可读可写),因为这是权限放大

    • 非const成员函数(可读可写)内可以调用const成员函数(只读),因为这是权限缩小

    3-2取地址运算符重载

    取地址运算符重载分为普通取地址运算符重载和const取地址运算符重载,⼀般这两个函数编译器⾃动⽣成的就可以够我们⽤了,不需要去显⽰实现。除⾮⼀些很特殊的场景,⽐如我们不想让别⼈取到当前类对象的地址,就可以⾃⼰实现⼀份,胡乱返回⼀个地址。

    class Date
    {
    public :
    Date* operator&()
    {
    return this;
    // return nullptr;
    }
    const Date* operator&()const
    {
    return this;
    // return nullptr;
    }
    private :
    int _year ; // 年
    int _month ; // ⽉
    int _day ; // ⽇
    };

    Date* operator&()
    {
    return this;
    }

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

    重载了取地址运算符 &,包括普通版本和 const 版本,都返回对象的地址 this。

    • 取地址运算符可以重载,但一般不需要自己写,编译器默认生成的够用。

    • 必须提供两个版本:普通版本和 const 版本,否则 const 对象无法取地址。

    • 返回值:普通版本返回 Date*,const 版本返回 const Date*。

    • 函数内部:直接 return this; 返回对象自身的地址。

    • 特殊用途:可以通过返回 nullptr 来隐藏对象地址(但正常不会这样做)。


    各位老铁,本节博客内容就到这,更多精彩等待后续更新,我们下节见~

    完结!撒花!

     

    赞(0)
    未经允许不得转载:171主机测评 » 【C++】类和对象(中)--(轻松理解C++重载的难点以及必坑点,以及深拷贝,浅拷贝等)
    分享到: 更多 (0)

    评论 抢沙发

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