C++类与对象(类中的六大默认成员函数)

391 阅读9分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第1天,点击查看活动详情

类的默认成员函数

如果一个类中,什么成员都没有,我们称之为空类。但是空类中也并不是什么都没有,编译器会自动生成6个默认成员函数,如果我们去实现默认成员函数,编译器就不会默认生成。 默认成员函数存在的意义在于防止我们忘记进行一些必要的操作。

构造函数

概念

构造函数是一个特殊成员函数,名字与类名相同创建对象时由编译器自动调用,并且在对象的声明周期中只出现一次。 需要注意的是,构造函数虽然名为构造,但不是创建对象,而是初始化对象。 构造函数体现在,只要变量被创建就会调用该函数,以及其特殊的传参方式。

特征

1.函数名与类名相同。 2.无返回值。 3.对象实例化时,编译器自动调用对应的构造函数。 4.构造函数可以重载。(一个类中可以定义多个构造函数,根据参数性质不同进行不同的初始化)

自己定义构造函数

类中编写

如果我们自己来写默认成员函数,编译器就不会自动生成,它会根据没有返回值而且函数名和类名相同来判断构造函数已经由用户自己生成了。

class Data
{
private:
    int _year;
    int _month;
    int _day;
public:
    Data(int year, int month, int day)
    {
        _year = year;
        _month = month;
        _day = day;
    }
    void Print()
    {
        cout << _year << "-" << _month << "-" << _day << endl;
    }
};
int main()
{
    Data d1(2022,3,1);
    d1.Print();
}

在这里插入图片描述 这里就是我们自己编写的构造函数。

传参方法

Data d1(2022,3,1);

这段代码表示的是构造函数的使用,此时d1的值会被进行初始化。打印的结果为: 在这里插入图片描述 当我们自己来写构造函数的时候,我们可以把它写成无参,全缺省或者半缺省。 就算将其写成无参,在创建变量的时候,程序也会去调用构造函数。(只要创建变量它都会去调用)但是没什么意义,我们自己构建构造函数的目的是自己传参进行初始化。 在这里插入图片描述 注意:当将其写成全缺省的形式时,d1传参的方式不能写成Data d1(),而要写成Data d1; 当写成半缺省的形式时,规则和半缺省函数传参一致。 自己编写构造函数建议写成全缺省形式。

编译器自动生成的构造函数

当我们自己不去定义构造函数的时候,编译器会自动生成一个构造函数。且不需要进行传参。

定义变量方式

Data d1;

正常定义即可。

初始化规则

在说明初始化规则之前,我们需要先了解一个概念:默认构造函数。(前面是6大默认成员函数注意区分) 默认构造函数指的是不需要传参就可以调用的构造函数,比如无参构造函数,全缺省构造函数,编译器默认生成的构造函数属于无参构造函数也就属于默认构造函数。

1.对于类中内置类型如int,double等,编译器不会对其进行初始化,存放的是一个随机值。 2.对于类中自定义类型(这里主要指类中的类),编译器会回去调用该类的默认构造函数去对其进行初始化。如果该类没有默认构造函数的话,编译器会报错。

class A
{
public:
private:
    int _a;
    int _b;
};
class Data
{
private:
    int _year;
    int _month;
    int _day;
    A a;
public:
    /*Data(int year=2022, int month=2, int day=4)
    {
        _year = year;
        _month = month;
        _day = day;
    }*/
    void Print()
    {
        cout << _year << "-" << _month << "-" << _day << endl;
    }
};
int main()
{
    Data d1;
}

这段代码中,Data类中我们没自己实现构造函数,所以使用的是编译器自动生成的构造函数,定义变量的方式为:Data d1,不需要传参数。 在对象d1中,存在内置变量:int year ,int _month,int _day。这三者是没有进行初始化的,是一个随机值。 还存在一个自定义类型A a,由于存在自定义类型,编译器会到类A中寻找默认构造函数来对a进行初始化处理。注意是默认构造函数,而不是构造函数。在类中定义a时,不能像在主函数中定义使进行初始化 当该类中没有默认构造函数时会发生报错(一般是自己定义构造函数且不是全缺省就不会有默认构造函数)。 A类中没有自己定义的构造函数,所以是编译器自动生成的构造函数,上面说过编译器自动生成的构造函数就是默认构造函数。而编译器生成的默认构造函数不对内置类型进行处理,所以a中的a与_b的值都是随机值。 将A类改成这样会更明显一些:

class A
{
public:
    A()
    {
        cout << "调用A的默认构造函数" << endl;
    }
private:
    int _a;
    int _b;
};

此时A()就是类A的构造函数,由于不用参数就可以调用,所以当编译器对a进行初始化时,会调用它的类中的A(),运行的结果为: 在这里插入图片描述 但是调用构造函数的目的一般为初始化,所以A中的构造函数应该具有初始化功能。

class A
{
public:
    A()
    {
        _a = 10;
        _b = 20;
    }
private:
    int _a;
    int _b;
};

我们此时可以通过调试看到初始化的a: 在这里插入图片描述 也可以看到内置类型是没有进行初始化操作的。

析构函数

概念

析构函数与构造函数相反,析构函数不是完成对象的销毁(对象的销毁是由栈帧控制的),局部对象销毁工作是由编译器完成的,而对象在销毁时会自动调用析构函数,完成对象中一些资源清理操作。

特性

1.析构函数函数名是在类名前面加一个~。 2.无参数无返回值。 3.一个类有且只有一个析构函数,若没有自己定义,编译器会自动生成默认的析构函数。 4.对象生命周期结束时,C++编译系统系统自动调用析构函数。

自己定义析构函数

当自己编写析构函数时,编译器就不会自动生成析构函数了,编译器会根据~类名,以及无返回值来判断用户是否已经实现了析构函数。 析构函数清理的资源一般是在堆中开辟的空间,而变量的销毁时由函数栈帧的销毁完成的。

class Stack
{
public:
    Stack(int capacity = 4)
    {
        _a = (int*)malloc(sizeof(int) * capacity);
        if (_a == nullptr)
        {
            cout << "malloc fail\n" << endl;
            exit(-1);
        }
​
        _top = 0;
        _capacity = capacity;
    }
    ~Stack()
    {
        free(_a);
        _a = nullptr;
        _top = _capacity = 0;
    }
private:
    int* _a;
    size_t _top;
    size_t _capacity;
};
int main()
{
    Stack a;
}

首先我们使用了自己定义的构造函数,为a这个进行了初始化。 然后又自己来定义了析构函数,在a的生命周期结束之后(即主函数运行完),编译器会自动调用我们自己定义的析构函数来清理开辟的堆区的空间。 在这里插入图片描述 我们看到当程序执行到第33行后,会进入析构函数中,将a在堆区开辟的空间进行清理。 为了体现最后调用了析构函数,我们还可以在析构函数中打印点啥: 在这里插入图片描述 还要注意析构的顺序,是先析构后定义的对象,然后再析构先定义的对象。

    Stack a;
    Stack b;

此时析构的顺序就是先析构b再析构a。

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

当我们自己不写析构函数时,编译器会自动生成一个析构函数。 编译器自动生成的析构函数清理空间的方式与构造函数相似。

1.内置类型不进行清理。 2.自定义类型(这里指类)会去调用该类的析构函数去进行清理。

class Stack
{
public:
    Stack(int capacity = 4)
    {
        _a = (int*)malloc(sizeof(int) * capacity);
        if (_a == nullptr)
        {
            cout << "malloc fail\n" << endl;
            exit(-1);
        }
​
        _top = 0;
        _capacity = capacity;
    }
    ~Stack()
    {
        free(_a);
        _a = nullptr;
        _top = _capacity = 0;
    }
private:
    int* _a;
    size_t _top;
    size_t _capacity;
};
class MyStack
{
public:
private:
    int p;
    Stack pushstack;
    Stack popstack;
};
int main()
{
    Stack a;
    Stack b;
    MyStack mq;
}

在这段代码中,定义了类mq,但是在类mq中我们没有自己定义析构函数,所以会调用编译器默认生成的析构函数来进行处理,根据规则,p变量不会被处理(p变量的销毁由函数栈帧来完成),pushstack与popstack两个对象会去调用它们的类的析构函数进行销毁堆中空间。 如果该类中没有自己定义析构函数的话,这段堆中的空间就只能当程序结束的时候free掉了。

拷贝构造函数

概念

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

特性

1.拷贝构造函数是构造函数的一个重载形式。 2.拷贝构造函数的参数只有一个且必须使用引用传参,使用传值方式会引发无穷递归调用。

调用方法

    Data d1(2022,3,5);
    Data d2(d1);

这里表示的是将d1中的内容拷贝到d2中。

自己定义拷贝构造函数

class Data
{
private:
    int _year;
    int _month;
    int _day;
public:
    Data(int year=2022, int month=2, int day=4)
    {
        _year = year;
        _month = month;
        _day = day;
    }
    Data(Data& aa)
    {
        _year = aa._year;
        _month = aa._month;
        _day = aa._day;
    }
    void Print()
    {
        cout << _year << "-" << _month << "-" << _day << endl;
    }
};
int main()
{
    Data d1(2022,3,5);
    Data d2(d1);
    d2.Print();
}

这段代码就使用了拷贝构造函数: 在这里插入图片描述

注意在自己编写拷贝构造函数时,形参一定要写成引用类型。 这是因为一旦写成传值类型会发生无限循环调用 因为传值的意思是再开辟一段空间(aa),将变量d1的内容拷贝到新开辟的空间中,然后再对d2中内容赋值,当拷贝d1的内容时又相当于一次拷贝构造,这一次拷贝构造有需要先传值,一直循环下去。 总结一下,就是将实参传递给形参的过程中也是一次拷贝构造。表现为Data aa(d1)。

编译器自己生成的拷贝构造函数

当我们没有实现拷贝构造函数时,编译器会自己生成。 它的规则是:按内存存储字节序完成拷贝。 也就是说在上面的程序中如果我们自己不去实现拷贝构造函数的话,Data d2(d1)也是可以完成拷贝构造的。 那么是不是说我们自己就不用了实现拷贝构造函数了呢?当然不是,我的宝。

class Stack
{
public:
    Stack(int capacity = 4)
    {
        _a = (int*)malloc(sizeof(int) * capacity);
        if (_a == nullptr)
        {
            cout << "malloc fail\n" << endl;
            exit(-1);
        }
​
        _top = 0;
        _capacity = capacity;
    }
    ~Stack()
    {
        free(_a);
        _a = nullptr;
        _top = _capacity = 0;
    }
private:
    int* _a;
    size_t _top;
    size_t _capacity;
};
int main()
{
    Stack a;
    Stack a(b);
}

如果是这样定义的类的话(在堆区开辟了空间),程序会崩溃的。 在这里插入图片描述 这是因为在进行拷贝构造的时候,b中指针是a中指针的复制,两者指向同一块空间,而更改a中指针指向内容时也会更改b中指针指向的内容,更重要的是,在进行析构的过程中,该块内存空间会被释放两次,所以会报错。

编译器生成的拷贝构造函数的规则: 1.对于内置类型成员,会完成按字节序的拷贝(如上面两个例子) 2.对于自定义成员,会调用它的拷贝构造。

class A
{
private:
int _a;
public:
    A()
    {
        cout << "调用了默认构造函数" << endl;
    }
    A(A& a)
{
    cout << "调用了拷贝构造" << endl;
}
};
class Data
{
private:
    int _year;
    int _month;
    int _day;
    A a;
public:
    Data(int year = 2022, int month = 2, int day = 4)
    {
        _year = year;
        _month = month;
        _day = day;
    }
    void Print()
    {
        cout << _year << "-" << _month << "-" << _day << endl;
    }
};
int main()
{
    Data d1(2022, 3, 5);
    Data d2(d1);
}

这里Data类中有自定义类型A,从打印的结果我们可以看到调用了拷贝构造函数。 在这里插入图片描述 当然这是为了打印效果,正常的拷贝构造函数中应该这样写:

A(A& a)
    {
        _a = a._a;
    }

这样就可以完成_a的拷贝了。

拷贝构造函数的调用时机 1.用一个对象初始化另一个对象。 2.函数传参。 3.传值返回。

运算符重载(含赋值运算符重载)

概念

赋值操作符重载是运算符重载的一种情况,要了解赋值操作符重载需要先了解运算符重载。 C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回类型,函数名及参数列表。

特性

1.函数名为:关键字operator后面接需要重载的运算符符号。 2.函数原型:返回值类型operator操作符(参数列表) 3.不能通过连接其他符号来创建新的操作符如:operator@。 4.重载操作符必须有一个类类型或者枚举类型的操作数。 5.用于内置类型的操作符,其含义不能被改变,例如:内置类型+,不能改变其含义。 6.作为类成员的重载函数时,其形参看起来比操作数数目少1成员函数的操作符有一个默认的形参this,限定为第一个形参。 7. .* ,::,sizeof,?:,.以上五个运算符不能重载,这个经常在笔试选择题中出现。

使用方法

    Data d1(2022, 1, 3);
    Data d2(2022, 3, 15);
    d1 < d2;

这里我们定义了一个日期类,但是直接比较d1与d2的大小是不被允许的,这就需要我们创建一个运算符重载函数。 由于要比较日期就需要比较year, month,_day这些都是私有类型,所以函数要定义在类中。 注意,如果我们在类中这样定义函数:

void operator<(const Data&d1,const Data&d2);

编译器会报错,因为我们忽略了this指针,this指针是一定要接收变量的。 所以正确的书写方式是:

class Data
{
private:
    int _year;
    int _month;
    int _day;
public:
    Data(int year = 0, int month = 1, int day = 1)
    {
        _year = year;
        _month = month;
        _day = day;
    }
    void Print()
    {
        cout << _year << " " << _month << " " << _day << endl;
    }
    bool operator<(const Data& d)//运算符重载函数
    {
        if (_year < d._year)
        {
            return true;
        }
        else if (_month < d._month)
        {
            return true;
        }
        else if (_day < d._day)
        {
            return true;
        }
        else
        {
            return false;
        }
    }
};
int main()
{
    Data d1(2022, 1, 3);
    Data d2(2022, 3, 15);
    d1 < d2;
    cout << (d1 < d2) << endl;
}

注意:this指针接收的是d1的地址,d与d2是同一个变量。 我们也可以使用函数的形式来进行比较大小。

d1.operator<(d2);
d1<d2;

这两段代码是等价的。 如果_year等是可以在类外访问的,那么也可以将运算符重载函数定义在全局中,但是就不能使用this指针传参了(正常传参即可),d1<d2会首先在全局中找函数,然后再去类中找

赋值重载函数

自己定义赋值重载函数

赋值重载函数自己书写的形式与上面举的例子"<"是一样的。

Data& operator=(Data& d)
    {
        if (this != &d)
        {
            _year = d._year;
            _month = d._month;
            _day = d._day;
        }
        return *this;
    }

返回值 1.返回对象可以进行连续赋值操作。 2.this指针是在函数中创建的,会在函数结束时被销毁,但是this指针所指向的内容(d1)不会,所以可以使用传引用返回,减少不必要的拷贝。

编译器自己生成的赋值重载函数

1.内置类型成员,会完成字节序值拷贝。 2.对自定义类型变量,会调用它的赋值重载函数进行赋值。 这里和拷贝构造函数是极其相似的。

class A {
private: int _a;
public:
    void operator=(A& d)
    {
        cout << "调用赋值重载函数" << endl;
    }
};
class Data
{
private:
    int _year;
    int _month;
    int _day;
    A a;
public:
    Data(int year = 0, int month = 1, int day = 1)
    {
        _year = year;
        _month = month;
        _day = day;
    }
    void Print()
    {
        cout << _year << " " << _month << " " << _day << endl;
    }
};
int main()
{
    Data d1(2022, 1, 3);
    Data d2(2022, 3, 15);
    d1 = d2;
    d1.Print();
}

这段程序运行的结果是: 在这里插入图片描述 可以看到调用了A中的赋值重载函数。 注意,这里完成的也只有浅拷贝,当类中出现指向堆空间的指针时,我们仍然需要自己定义赋值重载函数,这与拷贝构造函数是相同的,这里不多赘述。

拷贝构造与赋值重载的区别

下面这段代码调用的是拷贝构造还是赋值重载呢?

Data d2(2022,2,2);
Data d1=d2;

答案是拷贝构造函数,两者的区别在于拷贝构造是给一个刚刚创建的对象进行赋值,而赋值重载是对两个已经存在的对象进行操作

取地址及const取地址操作符重载

概念

它们属于操作符重载的范畴,这两个函数一般不需要自己来定义,编译器会默认生成,所以将这两个放在一起说。 这两个运算符只有特殊情况才需要进行重载,比如想让别人获取指定内容时

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

操作符重载

Data d1(2022,2,2);
cout<<d1;

在没写操作符重载函数的时候,这段代码是错误的。 操作符重载是无法定义在类里面的,因为对于cout<<d1来说,第一个必须传入的是this指针,但是这里将cout传给了this指针,所以会发生错误,只能定义在全局中。但我们还要访问到_year等内容,这就需要添加友元函数。

class Data
{
    friend void operator<<(ostream& out, const Data& d);
private:
    int _year;
    int _month;
    int _day;
public:
    Data(int year = 0, int month = 1, int day = 1)
    {
        _year = year;
        _month = month;
        _day = day;
    }
};
void operator<<(ostream& out, const Data& d)
{
    out << d._year<<" " << d._month << " " << d._day;
}
int main()
{
    Data d1(2022, 1, 3);
    Data d2(2022, 3, 15);
    cout << d1;
}

const成员

如果我们想要打印的对象拥有常属性的话,比如:

const Data d1(2022,2,2);
d1.Print();

是无法进行打印的,因为Print中默认的传参是Data* const this,而现在传入的是const Data* &d1,这属于权限的放大,因此不能进行打印。 同时,this是不可以体现在形参的定义上的,遇到这样的情况可以在函数后加const来处理。

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

这样就可以正常打印了: 在这里插入图片描述

总结 成员函数加const是好的,建议能加的都加上。 这样普通对象和const对象都可以调用了。 但如果要修改成员变量的函数就不要加了。