类和对象 (拷贝构造函数和运算符重载)上
拷贝构造函数存在的原因及解决的 C 语言问题
1. 浅拷贝带来的问题
在 C 语言里,当对结构体或者数组进行拷贝操作时,执行的是浅拷贝。所谓浅拷贝,就是单纯地把一个对象的所有成员变量的值复制到另一个对象中。要是成员变量包含指针,那么仅仅是复制指针的值,也就是地址,并非复制指针所指向的内容。这就可能引发一些问题,比如多个指针指向同一块内存区域,在释放内存时就会出现重复释放的情况,进而导致程序崩溃。
下面是一个 C 语言的示例代码,展示了浅拷贝带来的问题:
#include <stdio.h>
#include <stdlib.h>typedef struct {int *data;int size;
} MyArray;// 初始化数组
void initArray(MyArray *arr, int size) {arr->size = size;arr->data = (int *)malloc(size * sizeof(int));for (int i = 0; i < size; i++) {arr->data[i] = i;}
}// 释放数组内存
void freeArray(MyArray *arr) {free(arr->data);
}int main() {MyArray arr1;initArray(&arr1, 5);// 浅拷贝MyArray arr2 = arr1;// 释放arr1的内存freeArray(&arr1);// 尝试访问arr2的数据,会导致未定义行为for (int i = 0; i < arr2.size; i++) {printf("%d ", arr2.data[i]);}return 0;
}
在这个例子中,arr2
是 arr1
的浅拷贝,它们的 data
指针指向同一块内存区域。当释放 arr1
的内存后,arr2
的 data
指针就变成了悬空指针,此时再访问 arr2.data
就会引发未定义行为。
2. C++ 拷贝构造函数的作用
C++ 的拷贝构造函数能够解决浅拷贝带来的问题。拷贝构造函数是一种特殊的构造函数(也就是和构造函数构成重载),当用一个已存在的对象来初始化另一个新对象时会被调用。在拷贝构造函数里,能够实现深拷贝,也就是复制指针所指向的内容,而不只是复制指针的值。
以下是一个 C++ 的示例代码,使用拷贝构造函数实现深拷贝:
#include <iostream>class MyArray {
private:int *data;int size;
public:// 构造函数MyArray(int size) : size(size) {data = new int[size];for (int i = 0; i < size; i++) {data[i] = i;}}// 拷贝构造函数,实现深拷贝MyArray(const MyArray& other) : size(other.size) {data = new int[size];//这里新开了一个大小和arr1中data数组相同的空间for (int i = 0; i < size; i++) {data[i] = other.data[i];}}// 析构函数~MyArray() {delete[] data;}// 打印数组元素void print() const {for (int i = 0; i < size; i++) {std::cout << data[i] << " ";}std::cout << std::endl;}
};int main() {MyArray arr1(5);MyArray arr2(arr1); // 自动调用拷贝构造函数arr1.print();arr2.print();return 0;
}
在这个例子中,MyArray
类的拷贝构造函数实现了深拷贝,确保 arr2
和 arr1
的 data
指针指向不同的内存区域,这样在释放内存时就不会出现重复释放的问题。
C 语言中的拷贝和 C++ 中的拷贝的区别
1. 拷贝方式
- C 语言:主要采用浅拷贝,对于结构体和数组,只是简单地按位复制,不考虑指针指向的内容。
- C++:默认情况下也是浅拷贝,但可以通过定义拷贝构造函数来实现深拷贝,从而避免浅拷贝带来的问题。
2. 语法和灵活性
- C 语言:拷贝操作通常使用赋值语句或者自定义的拷贝函数,语法较为简单,但缺乏灵活性,需要手动处理内存管理。
- C++:拷贝操作可以通过拷贝构造函数和赋值运算符重载来实现,语法更加灵活,能够自动处理内存管理,提高代码的安全性和可维护性。
3. 资源管理
- C 语言:需要手动管理内存,在进行拷贝操作时容易出现内存泄漏和重复释放的问题。
- C++:可以利用拷贝构造函数和析构函数来自动管理资源,减少内存管理的错误。
拷贝构造函数的特征
语法
拷贝构造函数的一般形式如下:
class ClassName {
public:// 拷贝构造函数ClassName(const ClassName& other) {// 拷贝操作}
};
在上述代码中,ClassName
是类名,other
是已存在对象的引用,通常使用 const
修饰,避免在拷贝过程中修改原对象。
调用场景
拷贝构造函数在以下几种常见情况下会被(自动)调用:
- 用一个对象初始化另一个对象:
ClassName obj1;
ClassName obj2(obj1); // 调用拷贝构造函数
- 对象作为实参传递给函数:
void func(ClassName obj) {// 函数体
}
ClassName obj;
func(obj); // 调用拷贝构造函数
我们前面说过了,形参是实参的拷贝,传参数的时候会对实参进行一个拷贝复制,复制出来的那个参数就是形参。
- 函数返回对象:
ClassName func() {ClassName obj;return obj; // 调用拷贝构造函数
}
要知道是, 当函数调用结束.函数里面所有开的空间都是要归还系统的,我们函数中return的变量也都是函数里面的。这里用上面的代码说明就是:返回的变量并不是函数中obj而是,obj这个变量的拷贝,也就是说,函数返回的变量是要返回变量的克隆体。明白吧,当这个变量类型是“某某类”(也就是自定义类型)的时候,这时候就会自动调用拷贝构造函数。
特征和性质(第一点我会画图解释)
- 参数类型为引用:拷贝构造函数的参数必须是引用类型,通常是
const
引用(const ClassName&
)。如果使用值传递,会引发无限递归调用,因为值传递会再次调用拷贝构造函数来复制参数,从而导致栈溢出。 - 没有返回值:和其他构造函数一样,拷贝构造函数没有返回值,也不使用
void
声明。它的主要作用是初始化新对象,所以不需要返回任何值。 - 默认生成:若没有为类显式定义拷贝构造函数**(也就是我们没有写拷贝构造函数),编译器会自动生成一个默认的拷贝构造函数。(就类似构造函数一样,编译器会自动生成,但是编译器生成的只能处理一些非常非常简单类型)默认拷贝构造函数执行浅拷贝,即逐个复制对象的成员变量(比如日期类这些,我们就可以不用写拷贝构造函数,用编译器自动生成的就可以了)**。不过,当类包含动态分配的资源(如动态内存、文件句柄等)时,浅拷贝可能会引发问题,此时需要显式定义拷贝构造函数来实现深拷贝。
- 支持自定义操作:(这一点先不管)在显式定义的拷贝构造函数中,可以执行自定义的操作,比如深拷贝、更新计数器、记录日志等。这使得拷贝对象时可以根据类的具体需求进行特殊处理。
- 与析构函数和赋值运算符的关联:(这一点也先不管)遵循三 / 五 / 零法则。如果类需要显式定义析构函数、拷贝构造函数或拷贝赋值运算符中的任何一个,那么它很可能也需要显式定义另外两个。在 C++11 及以后,还需要考虑移动构造函数和移动赋值运算符。
运算符重载
运算符重载的基本概念
运算符重载本质上是一种函数,它的函数名由关键字operator
和运算符组成。通过运算符重载,你能够让自定义类型的对象像内置类型对象那样使用运算符。
运算符重载存在的意义和解决的问题
1. 增强代码的可读性和可维护性
在处理自定义类型时,若没有运算符重载,你需要调用特定的成员函数来完成操作,这样会让代码显得冗长且难以阅读。运算符重载能让自定义类型的操作和内置类型操作保持一致,从而提高代码的可读性和可维护性。
2. 实现自定义类型的运算
内置运算符只能处理内置类型,而对于自定义类型,你可以通过运算符重载来定义适合它们的运算规则。
运算符重载的示例代码
以下是一个简单的示例,展示了如何重载>
运算符来实现自定义类对象的加法:(用日期类来举例)
#include <iostream>
#include <cassert>
using std::cout;
using std::endl;class Date
{
public:Date(int year = 1900, int month = 1, int day = 1){_year = year;_month = month;_day = day;}Date(const Date& dd){_year = dd._year;_month = dd._month;_day = dd._day;}void Print(){cout << _year << "/" << _month << "/" << _day << endl;}bool operator>(const Date& other)//这是比较日期大小的{assert(_year>=1&&other._year>=1);assert(_month>=1 && _month<=12 && other._month>=1 && other._month<=12);assert(_day>=1 && other._month>=1);if(_year>other._year)return true;if(_year==other._year && _month>other._month)return true;if(_year==other._year && _month==other._month && _day>other._day)return true;return false;}bool operator==(const Date& other)//这是判断两日期是否相同的{assert(_year>=1&&other._year>=1);assert(_month>=1 && _month<=12 && other._month>=1 && other._month<=12);assert(_day>=1 && other._month>=1);if(_year==other._year &&_month==other._month &&_day==other._day)return true;return false;}private:int _year;int _month;int _day;
};int main() {Date d1;Date d2(2004,4,4);Date d3(d1);bool ret1= d2>d1;bool ret2= d1>d2;d2.Print();d1.Print();cout<<ret1<<endl<<ret2<<endl;bool ret3= d1==d3;d1.Print();d3.Print();cout<<ret3<<endl;return 0;
}
运算符重载的语法这里其实没多少东西:
返回类型 operator 运算符(参数列表) {// 函数体
}
它就是为了让你写的自定义类型比较的时候大家都能直观的看懂,不用费劲吧咧的想一个函数名。
其中,返回类型
是运算符重载函数的返回值类型,operator
是关键字,运算符
是要重载的运算符,参数列表
是传递给运算符函数的参数。对于二元运算符,参数通常是另一个操作数;对于一元运算符,通常没有显式参数。
运算符重载我们一般都写在类里面,为啥呢?因为成员变量绝大多数的时候都是私有的,你在类外定义函数虽然也可以,但是你要额外的写几个获取成员变量的函数,就是那些什么int getx()
什么的。当然了,你也可以声明为友元函数,但是其实也没必要。所以为了方便,我们一般直接在类里面定义运算符重载,那么类里面定义的就属于成员函数。
二元运算符
二元运算符需要两个操作数才能完成运算,像 +
、-
、*
、/
这类。当使用成员函数重载二元运算符时,该函数是类的一个成员,而对象自身会隐式地作为运算符的左操作数,所以参数列表里通常只需传入另一个操作数(右操作数)。
别忘了,我们前面说过this指针就是其中的一个隐藏参数,所以类里面的成员函数中其实都是有一个隐藏参数的,所以在这里,我们只需要传另一个参数就可以了。一元运算符同理。
一元运算符
一元运算符仅需一个操作数就能完成运算,例如 ++
、--
、!
等。当使用成员函数重载一元运算符时,对象自身会隐式地作为运算符的操作数,所以通常不需要显式的参数。
类外定义运算符重载
在 C++ 中,除了可以在类内定义运算符重载函数,也能在类外进行定义。类外定义运算符重载函数通常有两种情况,一种是普通的非成员函数,另一种是使用友元函数。下面为你详细介绍这两种方式。
普通非成员函数重载运算符
当使用普通非成员函数重载运算符时,需要将所有操作数作为参数传递给运算符函数(也就是说,该有几个参数就是几个参数)。因为非成员函数没有隐含的 this
指针,所以不能直接访问类的私有成员,除非类提供了相应的公有访问函数。
友元函数重载运算符(这个先不管)
若运算符重载函数需要访问类的私有成员,可将其声明为类的友元函数。友元函数虽然不是类的成员函数,但它可以访问类的私有和保护成员。
运算符重载的优先级和结合性
在 C++ 中,运算符重载时,其优先级和结合性是由所重载的运算符本身决定的,而非由重载函数的定义决定。也就是说,重载后的运算符会保持其在原生运算符中的优先级和结合性。
优先级
运算符优先级规定了在一个表达式中不同运算符执行的先后顺序。例如,乘法运算符 *
的优先级高于加法运算符 +
,所以在表达式 2 + 3 * 4
中,会先计算 3 * 4
,再将结果与 2
相加。当你重载这些运算符时,它们的优先级依然保持不变。
结合性
结合性决定了相同优先级的运算符在表达式中是从左到右还是从右到左进行计算。例如,加法运算符 +
是左结合的,在表达式 2 + 3 + 4
中,会先计算 2 + 3
,再将结果与 4
相加;而赋值运算符 =
是右结合的,在表达式 a = b = c
中,会先将 c
的值赋给 b
,再将 b
的值赋给 a
。重载运算符时,结合性同样保持不变。
改变优先级和结合性
如果你想改变优先级和结合性,那就加()呗,2 + 3 * 4
加上括号( 2 + 3 ) * 4
优先级就改变了,结合性同理哈。
注意事项
- 并不是所有运算符都可以重载,例如
.
、::
、?:
等运算符不能被重载。 - 重载运算符时,其操作数的数量、优先级和结合性不能改变。
- 重载运算符的目的是提高代码的可读性和可维护性,应该避免过度使用或滥用运算符重载。
- 运算符重载和函数重载没有任何关系。
日期类的实现
前面我已经给大家实现了比较日期的大小,和判断是否相等。那么接下来就是我们实现一些对日期类有意义的运算符重载。什么叫有意义呢?有意义就是对现实生活有意义的,比如+=天数之后是什么日期,比如2025年4月29日+=1,结果就是2025年4月30日。明白没。实现+天数也可以的,不过不改变日期类本身,+=是改变被加数本身的嘛。
那哪些是没有意义的呢?你觉得一个日期*
一个日期或者一个日期/
一个日期,这样有啥作用不? 这个对现实世界是不是没啥作用?所以我们就没必要去写,我们写一下对现实世界有意义的就行。
这个计算我们没学运算符重载也可以写,运算符重载只是为了我们使用的时候看起来更简洁,更易懂一些,更直观,明白嘛?
#include <iostream>
#include <cassert>
using std::cout;
using std::endl;class Date
{
public:Date(int year = 2025, int month = 4, int day = 29){_year = year;_month = month;_day = day;}Date(const Date& dd){_year = dd._year;_month = dd._month;_day = dd._day;}void Print(){cout << _year << "/" << _month << "/" << _day << endl;}bool operator>(const Date& other){assert(_year>=1&&other._year>=1);assert(_month>=1 && _month<=12 && other._month>=1 && other._month<=12);assert(_day>=1 && other._month>=1);if(_year>other._year)return true;if(_year==other._year && _month>other._month)return true;if(_year==other._year && _month==other._month && _day>other._day)return true;return false;}bool operator==(const Date& other){assert(_year>=1&&other._year>=1);assert(_month>=1 && _month<=12 && other._month>=1 && other._month<=12);assert(_day>=1 && other._month>=1);if(_year==other._year &&_month==other._month &&_day==other._day)return true;return false;}int monthday(){assert(_month>=1 && _month<=12);int day[13]={0,31,28,31,30,31,30,31,31,30,31,30,31};if(_month==2 && ((_year%4==0 && _year%100!=0) || _year%400==0)){return 29;}else{return day[_month];}}Date& operator+=(int day){_day+=day;while (_day>monthday()){_day-=monthday();_month+=1;if(_month>12){_year+=1;_month=1;}}return *this;}Date operator+(int day){Date tmp(*this);tmp+=day;return tmp;}
private:int _year;int _month;int _day;
};int main() {Date d1;Date d2=d1+100;d1.Print();d2.Print();return 0;
}
这里新增了一个运算符+和运算符+=的重载,逻辑其实很简单,需要注意一下主要是:
赋值运算符=重载
这个运算符重载有点特殊,不过不难理解。这里直接以日期类举例://运行一遍代码再往下看
class Date
{
public:Date(int year = 2025, int month = 4, int day = 29){_year = year;_month = month;_day = day;}Date(const Date& dd){_year = dd._year;_month = dd._month;_day = dd._day;}Date& operator=(const Date& other){if (this != &other){_year=other._year;_month=other._month;_day=other._day;}return *this;}void Print(){cout << _year << "/" << _month << "/" << _day << endl;}
private:int _year;int _month;int _day;
};int main() {Date d1;Date d2(1999,9,9);d2.Print();d2=d1;d2.Print();return 0;
}
区别
1. 调用时机不同
- 拷贝构造函数:在创建新对象时,使用另一个同类型的对象来初始化它时调用。常见的调用场景包括用一个对象初始化另一个对象、对象作为实参传递给函数、函数返回对象等。
class MyClass {
public:MyClass(const MyClass& other) {// 拷贝操作}
};MyClass obj1;
MyClass obj2(obj1); // 调用拷贝构造函数
- 赋值运算符重载:在对象已经创建后,将一个对象的值赋给另一个对象时调用。
class MyClass {
public:MyClass& operator=(const MyClass& other) {if (this != &other) {// 赋值操作}return *this;}
};MyClass obj1, obj2;
obj1 = obj2; // 调用赋值运算符重载
2. 语法形式不同
- 拷贝构造函数:是一种特殊的构造函数,没有返回值,参数通常是
const
引用。
MyClass(const MyClass& other);
- 赋值运算符重载:是一个成员函数,返回值通常是对象的引用(
MyClass&
),以支持连续赋值操作,参数也通常是const
引用。
MyClass& operator=(const MyClass& other);
3. 处理自我赋值的方式不同
在赋值运算符重载中,需要考虑自我赋值的情况(即 obj = obj
),并进行相应的处理,避免不必要的操作或错误。而拷贝构造函数不会涉及自我赋值的问题,因为它是在创建新对象时调用的。
MyClass& operator=(const MyClass& other) {if (this != &other) {// 避免自我赋值,进行赋值操作}return *this;
}
未完待续.