欢迎光临
我们一直在努力

3.类和对象(中)

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}

1.类的6个默认成员函数

如果一个类中,一个成员也没有。简称空类;空类里面就什么也没有了吗?其实不是的,任何一个类,在我们不懈的情况下,都会自动生成下面6个默认的成员函数;

6 个默认成员函数

  • 初始化和清理
    • 构造函数主要完成初始化工作
    • 析构函数主要完成清理工作
  • 拷贝复制
    • 拷贝构造是使用同类对象初始化创建对象
    • 赋值重载主要是把一个对象赋值给另一个对象
  • 取地址重载
    • 主要是普通对象和 const 对象取地址,这两个很少会自己实现

2.构造函数

对于一下日期函数;

如果我们使用c语言的思维,我们每次创建对象的时候都需要调用初始换函数,这样非常麻烦,那我们能不能在创建对象之前,就将信息设置进去呢?

1.基本概念;

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

2.使用特性:

构造函数是特殊的成员函数,名字和类名相同,需要注意的是:构造函数虽然名字叫构造,但是其实构造函数的任务并不是开创空间创建对象,而是初始化对象;

特征:

  • 函数名与类名相同。
  • 无返回值。
  • 对象实例化时编译器自动调用对应的构造函数。
  • 构造函数可以重载。
  • 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定 义编译器将不再生成
  • 无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认成员函数。
  • 注意:

    构造函数是创建对象时自动调用的特殊成员函数——名字等于类名、不写返回类型。“不传实参也能调”的那个构造,就叫默认构造函数,它有三种形态(自己写无参、自己写全缺省、编译器隐式生成),三选一。一旦你声明了任何构造函数(连拷贝构造也算),编译器就不再送你默认构造;缺了它,所有需要“零实参构造”的写法(Date d;、数组、new、容器)统统编译不过。而具体调哪个构造,只由实参列表决定:多了必错,少了要靠默认参数补,类型要能隐式转换,两个都同样匹配就报二义。

    注意:在对于必须要传递参数的构造函数的使用方式是:在对象进行实例化时的后面加上一个括号,然后括号中写要传递的参数(传参的方式和普通函数调用一样)。但是对于无参数的构造函数时不能怎么使用,因为这样写会跟函数声明有点冲突,编译器也不好识别;

    Date d1() //可以理解为:返回对象为Date类的一个函数声明;
    Date d1; //正确方法;

    在函数重载的时候,构造函数也可以缺省参数,其中全缺省构造函数是默认构造函数;

    【显示定义】

    我们运行看一下:

    【不显示定义】

    运行看一下:

    这里我们就可以的发现这个问题所在了;打印出来的竟然是随机数,这是怎么回事呢?不是说编译器会自动生成隐式构造函数吗?难道编译器生成隐式构造函数并没有什么用?

    其实,他是调用了编译器自动生成的隐式构造函数的,要理解这个,就需要理解隐式构造函数,在c++中,我们把类型分为内置类型和自定义类型:

    • 内置类型:就是语言提供的数据类型,如:short、int、char、指针……..等等;
    • 自定义类型:就是我们使用class struct union等自定义的类型;

    而编译器自动生成的隐式构造函数对于内置类型成员变量是不做处理的(有些编译器会做处理,但那是个性化处理,不是所有的编译器都会处理),对于自定义类型的成员变量才会进行处理(会去调用他的隐式构造函数);

    可能有些读者,到这里,也会和我一样有一种感觉;这个编译器生成的默认构造函数的作用到底在哪?看不出来啊,不要急,我这里举个例子进行解释:

    【可以编译通过的】

    #include <iostream>
    using namespace std;

    class Date { public: int y; }; // 一个构造函数都不写

    int main() {
    Date d; // ✅ 编译器给了默认构造,能过
    cout << d.y << endl; // 打印垃圾值(0xCCCCCCCC)
    }

    [编译不过的】

    #include <iostream>
    using namespace std;

    class Date { public: Date(int y) {} int y; }; // 只写了带参构造

    int main() {
    Date d; // ❌ 报错:没有默认构造函数
    }

    综上,我们就能看出来,其实啊,这个编译器自动生成的“默认构造函数",就是让我们在没有手写构造函数的情况下,依然能成功创建对象,并确保该对象内部的”其他类成员“被正确初始化;

    在c++11针对内置类型的成员不初始化的缺陷,又打了补丁,既:内置类型成员变量在类中声明时候可以给默认值:

    对特性6进行一下解释:

    class Date {
    public:
    Date() {} // (1)
    Date(int y = 0, int m = 1, int d = 1) {} // (2)
    };

    Date d; // ← 你一个参数都没给

    为什么无参构造函数和全缺省构造函数不能同时出现?

    编译器看到 Date d;,去挑构造:

    • (1) 要 0 个参数 → 能接 ✅
    • (2) 三个参数都有默认值 → 也能接

    出现这种情况你让编译器怎么选?小孩子才做选择,编译器一个都不选,直接报错;

    3.析构函数

    1.基本概念

    构造函数是用来给实例化对象进行初始化的,而程序运行结束后,如果也一定要销毁,就像c语言实现栈一样,需要开辟空间,也需要进行销毁,虽然,程序运行结束之后编译器会自动进行销毁,但是对于一些动态申请的空间,编译器是不会进行销毁的,这时候就会发生内存泄露问题;

    析构函数的作用和构造函数相反,析构函数不是完成对象的销毁,局部对象销毁工作是由编译器完成的,而对象在进行销毁时会调用析构函数,完成类的一些资源清理工作;

    2.特性

    1.析构函数名是在类名前面加上字符~

    2.无参数无返回值类型

    3.一个类只能有一个析构函数,若未显示定义,系统会自动生成默认的析构函数,缀:析构函数不能重载;

    4.对象生命周期结束的时候,c++编译系统自动调用析构函数;

    5.一个局部域有多个对象的时候,后被定义的会先调用析构函数

    6.如果类中没有显示的析构函数,则会自动生成一个无参的默认析构函数,如果类中,没有是申请资源时。析构函数可以不写。直接使用编译器生成默认析构函数,有资源申请时候,一定要写,否则会导致内存资源泄露;

    当程序运行到return的时候才会调用析构函数;

    解释特性5:

    特性6:默认析构函数

    和构造函数一样,如果不显示定义,编译器会自动生成;

    • 对于内置类型成员变量不做处理(因为内置类型成员,销毁时不需要资源清理,最后系统会直接将其内存回收)
    • 对于自定义类型的成员变量才会处理

    #include <iostream>
    using namespace std;
    class Time
    {
    public:
    ~Time()
    {
    cout << "~Time()" << endl;
    }
    private:
    int _hour;
    int _minute;
    int _second;
    };

    class Date
    {
    private:
    // 基本类型(内置类型)
    int _year = 2025;
    int _month = 8;
    int _day = 27;

    // 自定义类型
    Time _t;
    };
    int main()
    {
    Date d;
    return 0;
    }

    对于上面的代码,需要注意的是:析构是“接力“,一层管理一层;

    main只负责“把d这个对象销毁”,他根本不知道d里面还藏着什么,剩下的由d自己的析构函数往下传;

    【执行链条】

    ① main 结束 → 本地对象 d 被销毁
    ↓
    ② 调用 d 的析构函数 ~Date()
    (你没写,编译器自动生成一个)
    ↓
    ③ 自动生成的 ~Date() 里做的事:
    "把每个成员的析构函数调一遍"
    → 调 _t.~Time()
    ↓
    ④ ~Time() 执行 → 打印 "~Time()"

    关键是第三步:这里的~Date是转发器,它负责通知成员:“你也该走了”

    【为什么main不是直接调用~time()】

    因为mian眼中只有d这一个对象

    int main() {
    Date d; // main 只知道这是个 Date
    return 0; // 结束 → 我只负责销毁 d
    }

    _t是d的私有成员,main碰都碰不到,所以他只能调用~Date(),剩下的交给~Date()内部去办;

    结论:只有当动态申请了资源才需要手写析构函数,其他情况都不用写,编译器自动生成析构函数就够用了;

    【不要手写】

    class Date {
    int _year, _month, _day;
    Time _t; // Time 自己会析构
    }; // 不用写 ~Date()

    【手写】

    class SeqList {
    public:
    SeqList(int cap = 10) {
    _pData = (int*)malloc(cap * sizeof(int)); // ← 你亲手要来的
    }
    ~SeqList() {
    free(_pData); // ← 必须你亲手还
    _pData = nullptr;
    }
    private:
    int* _pData;
    };

    4.拷贝构造函数

    一句话就是:用一个已有的对象,造一个新的对象的时候,调用的就是他;

    Date a(2025, 8, 27);
    Date b = a; // ← 这里调的是拷贝构造函数
    Date c(a); // ← 同样

    作用是将a的状态复制给b;默认有编译器生成,逐个成员拷贝;

    1.浅拷贝和深拷贝

    不写拷贝构造函数,编译器会默认生成拷贝构造函数

    • 对于内置类型,则会完成浅拷贝
    • 对于自定类型,则会调用他的拷贝构造函数

    【浅拷贝】

    默认实现的是:”浅拷贝“,这个是关键:

    Date::Date(const Date& other) {
    _year = other._year; // 逐成员复制
    _month = other._month;
    _day = other._day;
    }

    对像int这种类型拷贝完全没问题,但是只要类中有指针,就会出事:

    class SeqList {
    int* _pData; // malloc 出来的
    };

    SeqList a; // a._pData → [堆 X]
    SeqList b = a; // 默认浅拷贝:只复制地址
    // b._pData → [同一块 X] ★

    a._pData ──┐
    ├──→ [ 同一块堆内存 X ]
    b._pData ──┘

    造成的结果就是:

    • 一个就是堆漏洞,叫double free,这是二进制安全学习的内容;也就是同一个堆块被释放两次;
    • 悬垂指针:a释放之后,b._pData还是指向那块已经释放的内存;
    • 互相影响:改b的数据,a跟着变;

    【深拷贝】

    浅拷贝一般我们不需要自己写,但是只要涉及指针等都动态内存申请和释放的都需要深拷贝,这个需要我们自己写;

    SeqList(const SeqList& other)
    : _size(other._size), _capacity(other._capacity)
    {
    _pData = (int*)malloc(_capacity * sizeof(int)); // 自己再申请一块
    memcpy(_pData, other._pData, _size * sizeof(int)); // 内容复制过去
    }

    这样一来两个对象就各自有一块内存,互不干扰,析构也安全。

    a._pData ──→ [ 堆 X' ] (a 自己的)
    b._pData ──→ [ 堆 X'' ] (b 新申请的)

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

    深拷贝实例:

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

    //写一个拷贝构造函数 — 实现深拷贝
    Stack(const Stack& 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;
    }

    ~Stack()
    {
    free(_a);
    _a = nullptr;
    _top = _capacity = 0;
    }
    private:
    int* _a;
    int _capacity;
    int _top;
    };

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

    2.特征

    拷贝构造函数也是特殊的成员函数;

    1.拷贝构造函数是构造函数的一个重载形式。

    2.拷贝构造函数的参数只有一个且必须是类类型对象的引用(最好使用const来修饰),使用传值方式编译器直接报错,因为会引发无穷递归调用。

    3.若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。

    4.类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请时,则拷贝构造函数是一定要写的,否则就是浅拷贝。

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

    (1)使用已存在对象创建新对象

    (2)函数参数类型为类类型对象

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

    【构造函数的定义方式】

    这里有一些问题:

    【问题1:为什么要传引用,而不是传值?】

    如果传值,那么在调用函数的时候就需要传递实参,而定义函数时候的形参是实参的拷贝,在c++中如果是自定义类型就必须要调用拷贝构造函数,而内置类型,则不会调用拷贝构造函数,所以如果拷贝构造函数改为传值调用如下面代码:

    //拷贝构造函数—传值调用
    Date(const Date d) //报错
    {
    _year = d._year;
    _mouth – d._mouth;
    _day = d.day;
    }

    但是这样是会报错的;

    怎么回事呢?

    当我们开始调用拷贝构造的时候,会进行形参的拷贝,这个时候既会调用拷贝构造,又会进行形参的拷贝,就这样无限循环下去…….

    【问题2:为什么最好使用const修饰参数呢?】

    因为const有助于保持常性,防止拷贝原数据被修改;

    3.函数返回值类型为类类型对象

    函数返回值有返回值,和返回引用的情况。由于传值返回会产生一个临时对象调用拷贝构造,传值引用返回的是返回对象的别名。如果返回的是一个局部变量的对象,则出局部域,对象就没有了,这时就不能使用引用返回了。这时就是一个野引用,和野指针一样。所以,如果要返回对象的引用,则一定要确保返回的在出函数局部域后仍然存在。

    【返回引用】

    Date& makeBad() {
    Date d(2025, 8, 27); // d 是局部变量,在栈上
    return d; // 返回 d 的别名
    } // ← 函数结束,d 被销毁

    int main() {
    Date& r = makeBad(); // r 是 d 的别名,但 d 已经死了
    r.Print(); // 读一块已经回收的栈内存
    }

    内存上发生快了什么?

    调用 makeBad() 时:
    main 的栈
    ┌──────────────┐
    │ │
    │ d (在栈上) │ ← 值在这
    │ │
    └──────────────┘

    函数 return 后:
    ┌──────────────┐
    │ │ ← 这块空间已经不归 d 了
    │ (垃圾) │ 但 r 还指着它
    │ │
    └──────────────┘

    r → 指向一块已经"无效"的内存 = 悬垂引用(野引用)

    【返回返回值】

    Date makeGood() {
    Date d(2025, 8, 27);
    return d; // 复制一份带出去
    }

    int main() {
    Date a = makeGood(); // a 在自己的地盘上,和 d 没关系
    a.Print(); // 正常
    }

    5.赋值运算符的重载;

    1.概念

    赋值运算符重载是运算符重载中的一种。运算符重载是C++为了增强代码的可读性引入的,具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表。

    • 函数名字为:关键字operator后面接需要重载的运算符符号。

    • 函数原型:返回值类型 operator操作符(参数列表)

    因为对于类类型的对象,也是需要比较大小的,比如日期类比较日期大小等。由于类用普通运算符比较是不能比较的,所以我们就可以自己来实现类的比较。所以就可以使用运算符重载来实现。

    其实赋值运算翻符重载:就是让a=b对你的类有意义;

    对于int、double这些内置类型,a=b编译器天生就懂,对于自己的类,编译器也会生成一个默认版本,但是如果是有指针的类,那个默认版本就是错的

    2.特性:

    1.赋值运算符重载格式
    (1)参数类型:const T&,传递引用可以提高传参效率;
    (2)返回值类型:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值;
    (3)检测是否自己给自己赋值;
    (4)返回 *this :要复合连续赋值的含义
    2. 赋值运算符只能重载成类的成员函数不能重载成全局函数
    3. 用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。

    3.定义:

    #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)
    {
    _year = d._year;
    _month = d._month;
    _day = d._day;
    }

    // 赋值运算符重载
    // 由于 this 指针,实际传参:Date& operator=(Date* const this, const Date& x)
    // 即:operator=(d1, d2)
    Date& operator=(const Date& x)
    {
    if (this != &x) // 判断是否给自己赋值
    {
    _year = x._year;
    _month = x._month;
    _day = x._day;
    }
    return *this;
    }

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

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

    int main()
    {
    Date d1(2025, 8, 26);
    Date d2(2025, 9, 1);

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

    d1 = d2;
    // 调用,相当于:operator=(d1, d2); 即将 d2 复制给 d1。也等价于d1.operator=(d2);点号左边是谁,this就指向谁;

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

    return 0;
    }

    【运行结果】

    其中的赋值运算符重载的返回值也是类的类型的引用。因为

    • 这样可以支持连等赋值,如果是void类型,则只能赋值一个。

    • 使用引用可以提高效率。

    并且这种类的赋值运算符重载,一定要在类的里面定义,不能在全局定义。其原因如下:

    因为赋值运算符如果不在类中显式实现,编译器就会生成一个默认的。此时用户再在类外自己实现一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,会报错,故赋值运算符重载只能是类的成员函数。

    4.先分清赋值运算重载和拷贝构造的区别:

    SeqList a(10), b(10);

    SeqList c = a; // ① c 是【新对象】 → 拷贝构造
    c = b; // ② c 已经存在 → 拷贝赋值 operator=

    5.默认赋值运算符干了什么?

    SeqList& operator=(const SeqList& other) { // 编译器生成的版本(等价)
    _pData = other._pData; // ← 只复制指针(浅)
    _size = other._size;
    _capacity = other._capacity;
    return *this;
    }

    这里导致什么后果呢?

  • 内存泄漏:_pData 原来指的那块没人 free 了;
  • double free:现在两个对象指同一块,析构时释放两次;
  • 互相影响:改 a 的数据 b 也变
  • 6.正确写法:

    SeqList& operator=(const SeqList& other)
    {
    // ① 判自赋值:a = a 时不能先 free 自己再读自己
    if (this == &other) {
    return *this;
    }

    // ② 先申请新内存(失败就抛异常,此时自己还没被破坏)
    int* p = (int*)malloc(sizeof(int) * other._capacity);
    if (p == nullptr) {
    throw std::bad_alloc();
    }

    // ③ 再释放旧的、接管新的
    free(_pData);
    _pData = p;
    memcpy(_pData, other._pData, sizeof(int) * other._size);
    _size = other._size;
    _capacity = other._capacity;

    // ④ 返回 *this(左值引用),支持链式 a = b = c
    return *this;
    }

    这个和拷贝构造函数差不多,浅拷贝不用我们自己写,编译器会自己分配。但是深拷贝,也就是有动态空间申请的必须写;

    6.取地址操作符的重载

    将const修饰的“成员函数”称之为const成员函数,const修饰类成员函数,实际修饰该成员函数隐含的this指针,表明在该成员函数中不能对该类中的任何成员进行修改。

    也就是说被const修饰的成员函数中的this指针除了指针本身不能改变,还有它指向的内容也不能改变。

    我们发现这里代码报错了,主要是因为这里出现了权限的放大,怎么回事呢?由于d2在实例化的时候是被const修饰了的,所以d2只是可以读的权限,不能进行修改,类型是const Date,但是Print是普通的成员函数,我们在进行调用的时候,会将对象传给this指针,而this指针指向的对象是可以读,也可以改的,出现了权限的放大,所以就报错了;

    这样我们想不报错就需要对this指针进行修改,可以this指针我们又看不到怎么做呢?我们只需要在函数后面加上一个const基可以了;

    发现没这样就不报错了;所以,我们在以后,如果不对*this的内容进行修改,则就可以在函数后面加上const,关于const,只需要直到权限只能平移或缩小,但是不能放大就行了。

     

    📌 相关阅读

    下一篇篇:《类和对象(下)》

    赞(0)
    未经允许不得转载:171主机测评 » 3.类和对象(中)
    分享到: 更多 (0)

    评论 抢沙发

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