欢迎光临
我们一直在努力

【C++编程】类和对象(二)---(引入类的6个默认成员函数 | 构造函数 | 析构函数 | 拷贝构造函数)

目录

前言

一、类的6个默认成员函数

二、构造函数

2.1 概念

2.2 特性

三、析构函数

3.1 概念

3.2 特性

四、拷贝构造函数

4.1 概念

4.2 特征

五、总结


前言

上一篇博客:【C++编程】类和对象(一)—(类的初识引入以及定义 | 类的访问限定符及封装特性 | 类的作用域 | 类的实例化以及类对象模型 | this指针)-CSDN博客

首先我们先来简单引入一下接下来要讲的内容:我们在自己实现栈或者队列等数据结构中,会不会经常忘记对数据结构进行初始化导致野指针运行崩溃?又或者忘记对数据结构进行销毁释放空间导致内存泄漏的问题?

比如我们拿栈那里讲过的括号匹配20. 有效的括号 – 力扣(LeetCode)(在栈结构实现解析:后进先出LIFO原理_栈的进出原则-CSDN博客 中有讲解)这个题目中在多处涉及到销毁的部分:

//#define N 10
//typedef struct Stack
//{
//int a[N];
//int top;
//}ST;

typedef char STDataType;//注意数据类型

typedef struct Stack
{
STDataType* a;
int top;
int capacity;
}ST;

//初始化
void STInit(ST* ps);
//销毁栈
void STDestroy(ST* ps);

//入栈
void STPush(ST* ps, STDataType x);
//出栈
void STPop(ST* ps);

//返回栈的元素个数
int STSize(ST* ps);
//判断栈是否为空
bool STEmpty(ST* ps);
//返回栈顶元素
STDataType STTop(ST* ps);

//初始化
void STInit(ST* ps)
{
assert(ps);

ps->a = (STDataType*)malloc(sizeof(STDataType) * 4);
if (ps->a == NULL)
{
perror("malloc fail");
return;
}

ps->capacity = 4;
ps->top = 0; //top代表栈顶元素的下一个位置
//ps->top = -1; //top代表栈顶元素
}

//销毁栈
void STDestroy(ST* ps)
{
assert(ps);

free(ps->a);
ps->a = NULL;
ps->top = 0;
ps->capacity = 0;
}

//入栈
void STPush(ST* ps, STDataType x)
{
assert(ps);

if (ps->top == ps->capacity)
{
STDataType* tmp = (STDataType*)realloc(ps->a, sizeof(STDataType) * ps->capacity * 2);
if (tmp == NULL)
{
perror("realloc fail");
return;
}

ps->a = tmp;
ps->capacity *= 2;
}

ps->a[ps->top] = x;
ps->top++;
}

//出栈
void STPop(ST* ps)
{
assert(ps);
assert(!STEmpty(ps));

ps->top–;
}

//返回栈的元素个数
int STSize(ST* ps)
{
assert(ps);

return ps->top;
}

//判断栈是否为空
bool STEmpty(ST* ps)
{
assert(ps);

return ps->top == 0;
}

//返回栈顶元素
STDataType STTop(ST* ps)
{
assert(ps);
assert(!STEmpty(ps));
return ps->a[ps->top – 1];
}

bool isValid(char* s) {
ST st;
STInit(&st);//初始化栈
int n=strlen(s);//字符串的长度
if(n%2 != 0) //如果字符串为奇数,即肯定有一个符号匹配不到,直接返回false
{
STDestroy(&st);//销毁栈
return false;
}
for(int i=0;i<n;i++)//遍历字符串
{
if(s[i]=='('||s[i]=='['||s[i]=='{')//如果遇到左括号就入栈
{
STPush(&st,s[i]);
}
else
{
//遇到右括号,就先判断是否为空,如果为空,说明右括号多余,返回false
if(STEmpty(&st))
{
STDestroy(&st);//销毁栈
return false;
}

//不为空,就判断栈顶元素与右括号是否匹配
char ch=STTop(&st);
STPop(&st);//记得出栈
if((s[i]==')'&&ch!='(')
|| (s[i]==']'&&ch!='[')
|| (s[i]=='}'&&ch!='{'))
{
STDestroy(&st);//销毁栈
return false;//不匹配直接返回false
}
}
}
//遍历完后,判断栈是否为空,如果不为空,说明左括号多余了,返回false,反之,返回true
//这里需要注意,我们是动态内存,所以后面肯定是要销毁栈的,所以我们不能直接返回,要不然就销毁不掉了,所以我们这里选择先存下结果ret,销毁后,再返回ret
bool ret=STEmpty(&st);//判断是否为空
STDestroy(&st);//销毁栈
return ret;
}

可以看到正常来写要时刻考虑初始化以及每一个返回前面都要进行销毁,这对于这种函数要进行多个分类讨论时,会发现相当繁琐。并且最难受的是最后一个,你要销毁但是你又要它的接口返回结果,这个时候只能先提前保存结果,然后销毁,最后返回保存的结果。这样相当的繁琐,所以在C++中提供了一个“自动化”效果的东西,让以后可以不用自己手动初始化和释放。

一、类的6个默认成员函数

如果一个类中什么成员都没有,简称为空类。

空类中真的什么都没有吗?其实并不是,如何类在什么都不写时,编译器会自动生成以下6个默认成员函数。

默认成员函数:用户没有显式实现,编译器会生成的成员函数称为默认成员函数。

class Date {}; //空类

在这6个之中,前4个尤为重要,也就是初始化和清理以及拷贝复制这两部分。

二、构造函数

2.1 概念

对于以下Date类:

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

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

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

int main()
{
Date d1;
d1.Init(2026, 3, 8);
d1.Print();

Date d2;
d2.Init(2026, 1, 15);
d2.Print();
return 0;
}

对于Date类,可以通过Init公有方法给对象设置日期,但如果每次创建对象时都调用该方法设置信息,未免有点麻烦,那能否在对象创建时,就将信息设置进去呢?

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

2.2 特性

构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象。

其特性如下:

  • 函数名与类名相同。
  • 无返回值(也不需要写void)。
  • 对象实例化时编译器自动调用对应的构造函数。
  • 构造函数可以重载。 class Date
    {
    public:
    // 1.无参构造函数
    Date()
    {
    std::cout << "Date()" << std::endl;
    }
    // 2.带参构造函数
    Date(int year, int month, int day)
    {
    std::cout << "Date(int)" << std::endl;
    _year = year;
    _month = month;
    _day = day;
    }

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

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

    int main()
    {
    Date d1; // 调用无参构造函数
    Date d2(2026, 3, 8);// 调用带参构造函数

    // 注意:如果通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明
    // 以下代码的函数,声明了d3函数,该函数无参,返回一个日期类型的对象
    // warning C4930:“Date d3(void)”: 未调用原型函数(是否是有意用变量定义的?)
    Date d3();
    return 0;
    }

    从上图中的关系可以看出来,构造函数不能和其他的函数看做一类,它是相当特殊的。

  • 如果类中没有显示定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。 class Date
    {
    public:
    /*
    // 如果用户显式定义了构造函数,编译器将不再生成
    Date(int year, int month, int day)
    {
    std::cout << "Date(int)" << std::endl;
    _year = year;
    _month = month;
    _day = day;
    }
    */

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

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

    int main()
    {

    // 将Date类中构造函数屏蔽后,代码可以通过编译,因为编译器生成了一个无参的默认构造函数
    // 将Date类中构造函数放开,代码编译失败,因为一旦显式定义任何构造函数,编译器将不再生成
    // 无参构造函数,放开后报错:error C2512:“Date”: 没有合适的默认构造函数可用
    Date d1;
    // 虽然编译器生成了无参的默认构造函数,但是成员变量依旧是随机值
    d1.Print();
    return 0;
    }

  • 关于编译器生成的默认成员函数,可能很多人会有疑惑:不实现构造函数的情况下,编译器会生成默认的构造函数。但是看起来默认构造函数又没什么用?d1对象调用了编译器生成的默认构造函数,但是d1对象_year/_month/_day,依旧是随机值(正常来说是随机值,但是有些编译器会在自己生成的默认构造函数中将其初始化为0,这些属于编译器个性化的行为)。也就是说在这里编译器生成的默认构造函数并没有什么用? 解答:C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的数据类型,如:int/char/以及任何形式的指针…,自定义类型就是我们使用class/struct/union等自己定义的类型:Stack类、Date类…,看下面的程序,就会发现编译器生成默认的构造函数会对自定义成员_t调用它的默认成员函数。 class Time
    {
    public:
    Time()
    {
    std::cout << "Time()" << std::endl;
    _hour = 0;
    _minute = 0;
    _second = 0;
    }
    private:
    int _hour;
    int _minute;
    int _second;
    };

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

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

    int main()
    {
    Date d1;
    // 编译运行后,屏幕出现:Time(),编译器生成默认的构造函数会对自定类型成员_t调用它的默认成员函数。
    return 0;
    }

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

    class Date
    {
    private:
    // 基本类型(内置类型)
    int _year = 2026;//设立默认值
    int _month = 1;
    int _day = 15;

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

    在C++11及以后的标准中,类成员可以在定义时直接赋予默认值。如果用户没有定义任何构造函数,编译器生成的默认构造函数会使用这些默认值初始化成员。即使用户定义了构造函数,只要没有在构造函数初始化列表中显式初始化某个成员,该成员仍会使用类内默认值初始化。只有当构造函数显式初始化了某个成员时,才会覆盖默认值。

  • 无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数。所以这3个类型的构造函数只能存在其中一个。 class Date
    {
    public:
    Date()
    {
    _year = 2026;
    _month = 1;
    _day = 15;
    }

    Date(int year = 2026, int month = 1, int day = 15)
    {
    _year = 2026;
    _month = 1;
    _day = 15;
    }
    private:
    int _year;
    int _month;
    int _day;
    };

    int main()
    {
    //报错:
    //error C2668:“Date::Date”: 对重载函数的调用不明确
    //类 "Date" 包含多个默认构造函数
    Date d1;
    return 0;
    }

  • 三、析构函数

    3.1 概念

    通过前面构造函数的学习,我们知道一个对象是怎么来的,那一个对象又是怎么没的呢?

    析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。

    3.2 特性

    析构函数是特殊的成员函数,其特征如下:

  • 析构函数名是在类名前加上字符 ~ 。
  • 无参数无返回值类型。
  • 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构函数不能重载。
  • 对象生命周期结束时,C++编译系统自动调用析构函数。 上一篇中,我们用C++实现的Stack类: typedef int STDataType;
    class Stack
    {
    public:
    // 构造函数
    Stack(int defaultCapacity = 4)
    {
    _a = (STDataType*)malloc(sizeof(STDataType) * defaultCapacity);
    if (_a == nullptr)
    {
    perror("malloc fail");
    return;
    }

    _capacity = defaultCapacity;
    _top = 0;
    }

    //入栈
    void Push(STDataType x)
    {
    if (_top == _capacity)
    {
    STDataType* tmp = (STDataType*)realloc(_a, sizeof(STDataType) * _capacity * 2);
    if (tmp == NULL)
    {
    perror("realloc fail");
    return;
    }

    _a = tmp;
    _capacity *= 2;
    }

    _a[_top] = x;
    _top++;
    }

    // 其他方法…

    // 析构函数
    ~Stack()
    {
    free(_a);
    _a = nullptr;
    _top = 0;
    _capacity = 0;
    }

    private:
    STDataType* _a;
    int _top;
    int _capacity;
    };

    int main()
    {
    Stack s;

    s.Push(1);
    s.Push(2);
    return 0;
    }

  • 关于编译器自动生成的析构函数,是否会完成一些事情呢?下面的程序我们会看到,编译器生成的默认析构函数,对自定类型成员调用它的析构函数。 class Time
    {
    public:
    ~Time()
    {
    std::cout << "~Time()" << std::endl;
    }
    private:
    int _hour;
    int _minute;
    int _second;
    };

    class Date
    {
    private:
    // 基本类型(内置类型)
    int _year = 2026;
    int _month = 1;
    int _day = 15;

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

    int main()
    {
    Date d;
    // 程序运行结束后输出:~Time()
    return 0;
    } 上面程序运行结束后输出:~Time(),但是我们发现在 main 函数中根本没有直接创建 Time 类对象,为什么最后会调用 Time 类的析构函数? 这是因为:在 main 函数中创建了 Date 对象 d ,而 d 中包含 4 个成员变量。其中_year,_month,_day 三个是内置类型成员,销毁时不需要资源清理,系统直接将其内存回收即可;而 _t 是 Time 类对象,所以在 d 销毁时,要将其内部包含的 Time 类的 _t 对象销毁,所以要调用 Time 类的析构函数。 但是:main 函数中不能直接调用 Time 类的析构函数,实际要释放的是 Date 类对象,所以编译器会调用 Date 类的析构函数,由于 Date 没有显式提供析构函数,则编译器会为 Date 类生成一个默认的析构函数,目的是在其内部调用 Time 类的析构函数——即当 Date 对象销毁时,保证其内部每个自定义对象都可以正常销毁。 注意:创建哪个类的对象则调用该类的构造函数,销毁哪个类的对象则调用该类的析构函数。

  • 如果类中没有申请资源时,或者需要释放资源的成员都是自定义类型时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如Date类;有资源申请时,一定要写,否则会造成资源泄漏,比如Stack类。
  • 四、拷贝构造函数

    4.1 概念

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

    4.2 特征

    拷贝构造函数也是特殊的成员函数,其特征如下:

  • 拷贝构造函数是构造函数的一个重载形式。
  • 拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用。 class Date
    {
    public:
    Date(int year = 2026, int month = 1, int day = 15)
    {
    _year = year;
    _month = month;
    _day = day;
    }

    // 拷贝构造函数
    //Date(Date d)// 错误写法:编译报错,会引发无穷递归
    // d2(d1)
    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);
    return 0;
    } 上面程序就是拷贝构造函数基本的写法,将对象d1拷贝到对象d2上。这里我们着重的讲解一下为什么不能直接Date d这样用传值的方式去拷贝。 首先const应该很好理解,虽然不加也可以,但是拷贝的意义就在于不想修改原对象的值,所以这里加个const常量修饰,也是为了能拷贝到const修饰的对象,不然会涉及权限放大的问题。 然后就是最重要的引用问题了,我们来看下面这个程序: void func(Date d) {}

    void func(int x) {}

    int main()
    {
    Date d1;
    func(d1);
    func(10);
    return 0;
    } 这里我们进行调试观察发现:

    我们发现,在进行调用func函数,传参为对象d1时,会进入Date类的拷贝构造函数。这就说明了当传参为自定义类型时,会调用相应的拷贝构造函数。 即C++中有一个规定:传值调用是内置类型的话直接拷贝,是自定义类型时必须调用拷贝构造完成拷贝。至于为什么自定义类型传值的时候必须调用拷贝构造函数,我们在下一个特性会讲解原因。 我们知道这个性质后,再来看看不加引用为什么会出现无限递归。

    在Date d2(d1)时,会调用拷贝构造函数,相当于将对象d1拷贝到形参date中,然后就相当于date(d1)所以这时候又会调用拷贝构造函数,又进入d1拷贝到形参date这个循环之中……。 所以要加个引用,让date直接就是d1的别名,就不会去为了将d1拷贝到形参date来调用拷贝构造了引发无限递归了。但其实这时候用指针也可以,因为不管什么类型的指针都是内置类型,就是直接拷贝,不会调用拷贝构造函数,但是这远不如加个引用轻松。  

  • 若未显式定义,编译器会生成默认的拷贝构造函数。默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。 这个和前面构造函数和析构函数不对内置类型处理不一样,在拷贝构造函数是内置类型是按照字节方式直接拷贝的,而自定义类型是调用其拷贝构造函数完成拷贝的。 我们调试发现,编译器生成的默认拷贝构造函数实现了按字节序的浅拷贝(值拷贝)了,那还需要自己显式实现吗?如果像Date日期类这样的类是没必要,但是如果是Stack类呢?我们仔细观察一下对象sk1和对象sk2,它们的成员变量_a既然指向同一个地址,这会发生什么呢? 运行后就会发现,出现了程序崩溃。这是因为: 当成员_a都指向同一块空间时,会出现两个问题,第一个就是当你修改拷贝对象的数据时,也会修改被拷贝对象的数据。第二个,当程序结束时,类的对象会调用自己的析构函数,所以会释放malloc的空间,根据栈帧销毁,后进先出的原则,对象sk2是比对象sk1晚创建的,所以会首先销毁sk2中_a的空间,然后再销毁sk1中_a的空间,然后同一块空间销毁两次就会程序崩溃。 这也是为什么在上面说,传自定义类型的参数时,不能直接值拷贝到形参,这样会发生程序崩溃。 那怎么解决呢?就是要自己实现拷贝构造函数实现深拷贝: // 自己实现深拷贝
    Stack(const Stack& sk)
    {
    // 再malloc个拷贝对象_a同样大小的空间
    _a = (STDataType*)malloc(sizeof(STDataType) * sk._capacity);
    if (_a == nullptr)
    {
    perror("malloc fail");
    return;
    }
    memcpy(_a, sk._a, sizeof(int) * sk._top);
    _capacity = sk._capacity;
    _top = sk._top;
    } 但是深拷贝实际情况会更加复杂,后面再详细了解,这里只是做简单的引入。 注意:类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请时,则拷贝构造函数是一定要写的,否则就是浅拷贝。
  • 拷贝构造函数典型调用场景: 使用已存在对象创建新对象、函数参数类型为类类型对象、函数返回值类型为类类型对象 class Date
    {
    public:
    Date(int year, int month, int day)
    {
    std::cout << "Date(int,int,int):" << this << std::endl;
    }

    Date(const Date& d)
    {
    std::cout << "Date(const Date& d):" << this << std::endl;
    }

    ~Date()
    {
    std::cout << "~Date():" << this << std::endl;
    }
    private:
    int _year;
    int _month;
    int _day;
    };

    Date Test(Date d)
    {
    Date temp(d);
    return temp;
    }

    int main()
    {
    Date d1(2022, 1, 13);
    Test(d1);
    return 0;
    }

    我们来看上面这个程序,会出现什么情况: 这是编译器没有优化时,程序进行的操作,会调用4次构造4次析构。而目前的编译器都会对函数Test返回值进行优化,会直接将temp的内存对象“转移”给返回值,避免了创建临时对象和调用拷贝构造函数。但是这也不能忽视效率上的损失,在Test传d1利用拷贝构造给形参d时,会又生成一次对象,导致再一次调用构造和析构,如果是类似与Stack类的对象,会又开一次空间,如果数据量一多,会造成很大的效率损耗。 所以为了提高程序效率,一般对象传参时,尽量使用引用类型,返回时根据实际场景,能用引用尽量使用引用。这点和尽量用引用做返回值是一样的。

  • 五、总结

    本篇先引出了C++类中一6个默认成员函数,也就是如果创建一个空或者没写对应成员函数的类,那么编译器会自动生成的成员函数。主要是“自动化”的功能,解决很多之前的问题。这篇先讲解3个重要的默认成员函数:构造函数、析构函数、拷贝构造函数。下一篇会着重讲解非常重要的运算符重载的内容,借助运算符重载实现一个相对完整的日期类。最后再写一篇首尾就结束类和对象篇了。

    写文不易,希望各位给个三连~

    言已至此,感谢各位读者花费时间阅读,本人浅学才疏,如有文笔拙劣之处还望见谅~

    赞(0)
    未经允许不得转载:171主机测评 » 【C++编程】类和对象(二)---(引入类的6个默认成员函数 | 构造函数 | 析构函数 | 拷贝构造函数)
    分享到: 更多 (0)

    评论 抢沙发

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