C++学习-入门到精通-【8】运算符重载:string类
运算符重载:string类
前言
本章将介绍如何将C++中的运算符与类对象结合在一起使用,这个过程被称为运算符重载。<<
就是C++内置的重载运算符的一个例子,它即可以作为流插入运算符,又可以作为左移运算符。除此之外,C++语言还重载了加法运算符(+)和减法运算符(-),在基础数据类型如整数算术运算、浮点数算术运算和指针算术运算中,这两个运算符会根据上下文执行不同的运算。
C++允许程序员重载大部分由类使用的运算符——编译器基于操作数的类型产生合适的代码。由重载运算符完成的工作也可以通过显式函数调用完成,但通常采用运算符形式更自然。
一、使用标准库中string类的重载运算符
看下面代码:
#include <iostream>
#include <string>
using namespace std;
int main()
{
string s1("happy");
string s2(" birthday");
string s3; // 使用默认构造函数,将string类的对象s3初始化为空字符串
// 使用cout和重载的 << 运算符将string类对象输出到屏幕
cout << "s1 is \"" << s1 << "\"; s2 is \"" << s2 << "\"; s3 is \"" << s3 << "\""
<< "\n\nThe results of comparing s2 and s1: "
<< "\ns2 == s1 yields " << (s2 == s1 ? "true" : "false") // 使用了重载的 == 运算符
<< "\ns2 != s1 yields " << (s2 != s1 ? "true" : "false") // 使用了重载的 != 运算符
<< "\ns2 > s1 yields " << (s2 > s1 ? "true" : "false") // 使用了重载的 > 运算符
<< "\ns2 < s1 yields " << (s2 < s1 ? "true" : "false") // 使用了重载的 < 运算符
<< "\ns2 >= s1 yields " << (s2 >= s1 ? "true" : "false") // 使用了重载的 >= 运算符
<< "\ns2 <= s1 yields " << (s2 <= s1 ? "true" : "false"); // 使用了重载的 <= 运算符
// 调用了string类提供的成员函数empty判断该string类对象是否为空(长度为0)
cout << "\n\nTesting s3.empty():" << endl;
if (s3.empty())
{
cout << "s3 is empty; assigning s1 to s3;" << endl;
s3 = s1; // 使用了重载的 = 运算符,将对象s1的值赋值给s3
cout << "s3 is \"" << s3 << "\"";
}
cout << "\n\ns1 += s2 yields s1 = ";
s1 += s2; // 使用了重载的 += 运算符,用于连接两个字符串
cout << s1;
cout << "\n\ns1 += \" to you\" yields" << endl;
s1 += " to you";
cout << "s1 = " << s1 << "\n\n";
// 调用string类的成员函数substr返回字符串指定位置指定长度的子串
cout << "The substring of s1 starting at location 0 for\n"
<< "14 characters, s1.substr(0, 14), is:\n"
<< s1.substr(0, 14) << "\n\n";
// 从s1对象的第15个字符开始一直到字符串结尾,第二个参数len使用默认实参npos
cout << "The substring of s1 starting at\n"
<< "location 15, s1.substr(15), is\n"
<< s1.substr(15) << endl;
// 用一个string对象初始化另一个string对象
string s4(s1);
cout << "\ns4 = " << s4 << "\n\n";
// 使用string类中重载的 [] 运算符创建左值,使新的字符可以s1中原有字符
s1[0] = 'H';
s1[6] = 'B';
cout << "\ns1 after s1[0] = 'H' and s1[6] = 'B' is: "
<< s1 << endl;
// 尝试访问一个string对象范围之外的元素
try
{
cout << "Attempt to assign 'd' to s1.at(30) yields: " << endl;
s1.at(30) = 'd';
}
catch (out_of_range& ex)
{
cout << "An exception occured: " << ex.what() << endl;
}
}
运行结果:
上面代码中使用的一些string类的成员函数:
注意在string类中重载的[]
并不会进行范围检查,所以需要程序员保证使用这个重载的运算符时不会意外的操作string对象有效范围外的元素。标准string类中提供了成员函数at,它提供了范围检查,如果其参数是一个无效下标,它会抛出一个out_of_range
类型的异常。
二、运算符重载的基础知识
从上面的代码中,我们可以看出,重载的运算符给程序员提供了简洁的符号来操作string对象。当然你也可以使用你自己定义类型的运算符。尽管C++不允许创建新的运算符,但它允许大部分现有的运算符被重载。
运算符重载并不是自动的,你必须定义运算符重载函数来描述你需要的功能。通过像往常编写非static成员函数定义或者非成员函数定义一样就可以实现运算符重载,只不过现在的函数名是关键字operator
后接重载的运算符,例如,函数名operator +
用于重载某些类的加法运算符(+)。如果以成员函数方式重载运算符,那么这样的成员函数必须是非static的,因为重载的运算符必须由类对象来调用,并作用于这个对象上。
要在类的对象上使用运算符,必须定义该类的运算符重载函数,但是有三个例外:
- 绝大多数的类都可以用赋值运算符
=
对其数据成员进行逐个成员赋值操作——将“源”对象(右侧)的每个数据成员赋给“目标”对象(左侧)的数据成员。但是对于具有指针成员的类,这样默认的逐个成员赋值是危险的(后面会进行说明)。所以我们需要显式地为这种类重载赋值运算符; - 取址运算符
&
返回对象的地址,这个运算符也能被重载; - 逗号
,
运算符从左到右对表达式进行求值,并返回最后表达式的值,这个运算符也能被重载;
三、运算符重载的规则的限制
当你准备为你的类重载运算符时,以下的规则和限制需要牢记于心:
- 一个运算符的优先级不能被重载改变;
- 运算符的结合性不能被重载改变——如果一个运算符通常从左到右结合,那么它所有的重载版本也应该这样做;
- 你不能改变运算符的“元数”——重载的一元运算符依然是一元运算符;重载的二元运算符仍然是二元运算符;运算符
&、*、+ 和 -
都同时拥有一元和二元版本,这些一元和二元版本能够被单独重载; - 不能创造新的运算符,只能重载已有的运算符;
- 运算符作用到基本类型上的方式不能被运算符重载改变,例如,不能将+运算符重载为两个int类型变量相减。运算符重载仅适用于用户自定义类型或用户定义类型和基本类型的混合;
- 关系运算符,如
+
和+=
必须被单独重载; - 当重载
()、[]、->
或任何赋值操作符时,运算符重载函数必须被声明为类成员。对所有其他的可重载的运算符来说,运算符重载函数可以是成员函数或非成员函数;
注意:对类类型进行运算符的重载,应该使重载的运算符尽可能效仿内置运算符对基本类型的作用方式。
四、重载二元运算符
二元运算符可以重载为一个带有一个参数的非static成员函数,或者两个参数(其中一个必须是类的对象或者类对象的引用)的非成员函数。一个非成员运算符函数因为性能原因经常被声明为类的友元。
作为成员函数的二元重载运算符
现在考虑使用 > 运算符来比较你所定义的String类的两个对象。当二元运算符 “<” 重载为String类的带有一个参数的非static成员函数时,如果y和z是String类的对象,那么 y<z
就会被处理为y.operator < (z)
,调用声明如下的operator <
成员函数:
class String
{
public:
bool operator <(const String&) const;
// ....
};
仅当左操作数是该类的对象时,二元运算符的重载函数才能作为一个成员函数。
作为非成员函数的二元重载运算符
作为非成员函数,二元运算符"<"必须带有两个参数,其中一个参数必须是与重载运算符有关系的类对象或者类对象的引用。如果y和z是String类对象或者是String类对象的引用,那么y<z就会被处理成operator(y, z),调用声明如下的函数operator<
:
bool operator<(const String&, const String &);
五、重载二元流插入运算符和流提取运算符
借助流提取运算符>>
和流插入运算符<<
,C++能够输入和输出基本类型的数据。C++类库重载了这些运算符以处理所有基本类型,包括指针和C风格的char*字符串。流插入运算符和流提取运算符还可以通过重载用于实现用户自定义类型数据的输入和输出。
下面给出一个重载<<、>>
的例子:
PhoneNumber.h
#include <iostream>
#include <string>
class PhoneNumber
{
friend std::ostream& operator<<(std::ostream&, const PhoneNumber &);
friend std::istream& operator>>(std::istream&, PhoneNumber &);
private:
std::string areaCode;
std::string exchange;
std::string line;
};
PhoneNumber.cpp
#include <iomanip>
#include "PhoneNumber.h"
using namespace std;
// 这里重载函数的返回值使用引用的原因是为了实现链式调用
// 比如 cout << a << b << c;
// << 是一个二元运算符,第一个操作数为标准流类型、自定义流类型或者其他任意重载了<<运算符的类型
// 将 << 的重载函数返回一个该类型的值,它又可以作为 << 运算符的左操作数继续调用 <<
ostream& operator<<(ostream& output, const PhoneNumber& number)
{
output << "(" << number.areaCode << ") "
<< number.exchange << "-" << number.line;
return output;
}
// 使用引用的原因同上
istream& operator>>(istream& input, PhoneNumber& number)
{
input.ignore(); // 跳过 (
input >> setw(3) >> number.areaCode;
input.ignore(2); // 跳过 ) 和 空格
input >> setw(3) >> number.exchange;
input.ignore(); // 跳过 -
input >> setw(4) >> number.line;
return input;
}
使用的标准类型成员函数:
test.cpp
#include <iostream>
#include "PhoneNumber.h"
using namespace std;
int main()
{
PhoneNumber phone;
cout << "Enter phone number in the form (123) 456-7890:" << endl;
// 使用重载运算符 >> 输入一个电话号码
cin >> phone;
// or operator>>(cin, phone);
// 使用重载运算符 << 输出一个电话号码
cout << "The phone number entered was: ";
cout << phone << endl;
// or operator<<(cout, phone);
}
运行结果:
重载流提取>>运算符
上面代码中的流提取运算符函数operator>>以一个istream引用和一个PhoneNumber引用作为其参数,并返回一个istream引用。运算符operator>>将如下格式的电话号码(111)222-3333,输入到PhoneNumber类的对象中,当编译器遇到如cin >> phone;
这样的表达式时,就会产生如下的非成员函数调用operator>>(cin, phone);
。
当这个函数调用执行时,引用形参input成为cin的别名,而引用形参number成为phone的别名。运算符函数把电话号码的3个部分作为字符串分别读到由形参number引用的PhoneNumber对象的成员变量areaCode、exchange和line
中。流操纵符setw()限定了读到每个字符数组的字符个数。当和cin及字符串一起使用时,setw把读入的字符个数限定为其参数指定的字符个数,即setw(3)
允许读入3个字符。通过调用istream的成员函数ignore,输入的圆括号、空格和破折号都被跳过。函数ignore丢弃输入流中指定个数的字符(默认为1个字符)。
而该函数返回一个istream引用对象,使得PhoneNumber对象上的输入操作可以和其他PhoneNumber对象或者其他数据类型对象的输入操作串联起来。例如:程序可以在一条语句中输入两个PhoneNumber对象:
cin >> phone1 >> phone2;
它们可以分成cin >> phone1;即operator(cin, phone1);
,它返回一个istream对象的引用,cin也是一个istream的引用,所以将cin >> phone1看作一个整体input,随后执行operator(input, phone2);
。
重载流插入<<运算符
同上面的流提取运算符一样,该函数接收两个参数一个为ostream的引用,一个为const PhoneNumber引用,并返回一个ostream引用。
作为非成员友元函数的重载运算符
当重载为类的非成员友元函数时,因为在这种情况下,类对象都是作为运算符的右操作数出现。如果它们是类的成员函数(以PhoneNumber类为例子),在这种情况下,我们要输入和输出一个PhoneNumber需要使用下面的语句:
phone << cout;
phone >> cin;
这样的语句与我们之前使用的操作基本类型的语句相反,会降低代码的可读性。
注:二元运算符的重载运算符可以作为成员函数来实现的前提是左操作数是该函数所在类的对象。
如果重载的运算符需要直接访问非public的类成员,或者这个类无法提供合适的获取函数,那么这些运算符应该声明为友元。
为什么流插入和流提取被重载为非成员函数
因为C++中是不允许修改C++标准库类型的,而>>和<<
作为类stream的成员,它的使用习惯是无法改变的(该运算符的右操作数是用户自定义类型或基本类型)。为了符合这一使用习惯,它们必须被声明为非成员函数,且它们可能需要访问类对象的私有数据成员,所以将它们声明为类的友元函数。
六、重载一元运算符
类的一元运算符可以重载为不带参数的非static成员函数或者带一个参数的非成员函数(参数必须是该类的对象或者该类对象的引用)。
作为成员的一元重载运算符
下面我们考虑重载一下一元运算符!
,用以测试我们的String类的对象是否为空,并返回一个bool类型的结果。当一元运算符重载为不带参数的成员函数且编译器遇到表达式!s(s是String类的一个对象)时,编译器就会生成函数调用s.operator!()
。操作数s就是调用String类成员函数operator! 的类对象。该函数的声明如下:
class String
{
public:
bool operator!() const;
};
作为非成员的一元重载运算符
除了声明为一个成员函数之外,!
这样的一元运算符还可以重载为带有一个参数的非成员函数。继续假设s是String类的一个对象(或者是该类对象的一个引用),那么!s
就会处理为operator!(s)。该非成员函数的声明如下:
bool operator! (const String &);
七、重载一元前置与后置运算符:++和--
自增和自减运算符各自的前置和后置形式都可以重载。本节将介绍编译器如何区分自增或者自减运算符的前置和后置形式的。
为了使重载自增运算符既支持前置的写法又支持后置的写法,每个重载的运算符函数都必须拥有各自明显的特征。前置形式的重载方式与任何其他前置的一元运算符的重载方法完全相同。
例如,现在要重载Date类的前置++运算符,假设该运算符将Date类的对象d1的天数增加1。当编译器遇到前置自增运算的表达式++d1
时,它会产生下列的函数调用。
重载函数是一个成员函数:
d1.operator++();
这个运算符函数的原型为:
Date& operator++();
这里返回值类型设为Date类对象的引用的原因是,使用基本类型的自增时,这个表达式可以作为其他运算符的操作数。
如果以非成员函数实现前置的自增运算符,那么当编译器遇到表达式++d1
时,将产生如下的函数调用:
operator++(d1);
该非成员函数的原型为:
Date& operator++(Date&);
重载后置的自增运算符
要重载后置的自增运算符,首先编译器必须要能够识别出重载的前置和后置自增运算符函数各自的特征。C++采用的约定是:当编译器遇到后置自增运算的表达式d1++
时,它会产生如下的成员函数调用:
d1.operator++(0)
,
它的函数原型为:
Date operator++(int);
实参0纯粹是一个“哑值”,它使编译器能够区分前置的和后置的自增运算符函数。相似的语法也在前置与后置的自减运算符函数中使用。
如果以非成员函数实现后置的自增运算,那么当编译器遇到d1++
时,会产生如下的函数调用:
operator++(d1, 0);
它的函数原型为:
Date operator++(Date&, int);
同样的,这里的参数0是编译器用来区分以非成员函数实现的前置和后置自减运算符的。
注意:后置的自增(减)运算符返回Date对象,而前置的自增(减)运算符按引用返回Date对象。
这是因为,在进行自增(减)前,后置的运算符通常先返回一个包含对象原始值的临时对象。C++将这样的对象作为右值,使其不能用来赋值运算符的左侧。前置的自增(减)运算符返回实际自增(减)后的具有新值的对象。这种对象在连续的表达式中可以作为左值使用。
提示
由于后置的自增(减)创建的临时对象可能对性能造成很大影响,尤其是在循环中使用这个运算符时。出于这个原因,我们更倾向于使用前置自增(减)运算符。
八、实例研究:Date类
这个类使用重载的前置和后置的自增运算符将Date对象中的天数加1,必要时将使年、月递增。
其中Date头文件指定了Date类的public接口,包括下列函数:一个重载的流插入运算符、一个默认的构造函数、一个setDate函数、一个重载的前置自增运算符、一个重载的后置自增运算符、一个重载的加法赋值运算符"+="、一个闰年判断函数和一个判断某天是否为当月的最后一天的函数。
Date.h
#include <array>
#include <iostream>
class Date
{
friend std::ostream& operator<<(std::ostream&, const Date&);
public:
Date(int m = 1, int d = 1, int y = 1900); // 这里的参数名并没有实际意义,仅是为了标记每个参数表示什么
void setDate(int, int, int);
Date& operator++(); // 前置++
Date operator++(int); // 后置++
Date& operator+=(unsigned int);
static bool leapYear(int);
bool endOfMonth(int) const;
private:
unsigned int year;
unsigned int month;
unsigned int day;
static const std::array<unsigned int, 13> days;
void helpIncrement();
};
Date.cpp
#include <iostream>
#include <string>
#include <stdexcept>
#include "Date.h"
using namespace std;
const array<unsigned int, 13> Date::days =
{
0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31
};
Date::Date(int m, int d, int y)
{
setDate(m, d, y);
}
void Date::setDate(int m, int d, int y)
{
if (m >= 1 && m <= 12)
{
month = m;
}
else
{
throw(invalid_argument("Month must be 1-12."));
}
if (y >= 1900 && y <= 2100)
{
year = y;
}
else
{
throw(invalid_argument("Year must be >= 1900 and <= 2100."));
}
if ((month == 2 && leapYear(year) && d >= 1 && d <= 29) || // 月份为2月,且为闰年,该月的最大天数为29
(d >= 1 && d <= days[month]))
{
day = d;
}
else
{
throw invalid_argument("Day is out of range for current month and year.");
}
}
// 前置++
Date& Date::operator++()
{
helpIncrement(); // 对天数进行增加
return *this;
}
Date Date::operator++(int)
{
Date temp = *this; // 先保存当前值
helpIncrement(); // 再增加天数
return temp;
}
Date& Date::operator+=(unsigned int additionalDays)
{
for (int i = 0; i < additionalDays; i++)
{
helpIncrement(); // 调用前置++的函数
}
return *this;
}
bool Date::leapYear(int y)
{
if (y % 400 == 0 ||
(y % 100 != 0 && y % 4 == 0))
return true;
else
return false;
}
// 判断参数d是否是当月的最后一天
bool Date::endOfMonth(int d) const
{
if (month == 3 && leapYear(year))
{
return d == 29;
}
else
{
return d == days[month];
}
}
void Date::helpIncrement()
{
if (!endOfMonth(day))
{
++day;
}
else
{
if (month < 12)
{
++month;
day = 1;
}
else
{
++year;
month = 1;
day = 1;
}
}
}
ostream& operator<<(ostream& output, const Date& date)
{
static string monthName[13] = {
"", "January", "February", "March", "April", "May", "June",
"July", "Augest", "September", "October", "November", "December"
};
output << monthName[date.month] << ' ' << date.day << ", " << date.year;
return output;
}
test.cpp
#include <iostream>
#include "Date.h"
using namespace std;
int main()
{
Date d1(12, 27, 2077); // 将对象d1初始化为 December 27, 2077
Date d2; // 调用默认构造函数进行初始化
// 输出当前的d1和d2对象,使用重载运算符输出Date对象
cout << "d1 is " << d1
<< "\nd2 is " << d2;
cout << "\n\nd1 += 7 is " << (d1 += 7); // 使用重载的+=运算符
d2.setDate(2, 28, 2008); // 将对象d2设置为 February 28, 2008,这是一个闰年
cout << "\n\n d2 is " << d2;
cout << "\n++d2 is " << ++d2 << "leap year allows 29th.";
Date d3(7, 13, 2025); // 声明第三个对象
// 测试重载的前置++运算符
cout << "\n\nTesting the prefix increment operator:\n"
<< " d3 is " << d3 << endl;
cout << "++d3 is " << ++d3 << endl;
cout << "d3 is " << d3 << endl;
// 测试重载的后置++运算符
cout << "\nTesting the postfix inceremet operator:\n"
<< " d3 is " << d3 << endl;
cout << "d3++ is " << d3++ << endl;
cout << " d3 is " << d3 << endl;
}
运行结果:
上面代码中,使用工具函数helpIncrement来实现天数加1。
上面的后置++运算符重载函数中,使用了一个局部变量temp来接收对象当前的值,之后会返回这个值,注意重载的后置++运算符函数,不可以返回该类对象的引用,因为这会产生一个虚悬引用
。
九、动态内存管理
在之前写的程序中,我们使用的所有变量,它们的内存空间在程序创建时就已经确定,不可再更改。
下面我们将介绍运算符new和delete
,它们实现变量的空间在程序执行过程中的改变,这便是所谓的动态内存管理。
我们可以使用new
运算符在执行期间为对象或数组动态分配恰好容纳它所需要的内存空间。对象或数组在自由存储区(堆)被创建,这是一个程序中专门用来存储动态分配对象的内存区域。一旦内存在堆上被分配,就可以通过new返回的指针进行访问。当不再需要内存时,可以使用delete
来释放内存,然后内存返还给堆区,以待将来的new操作复用。
使用new来动态获取内存
Time* timePtr = new Time();
上面的new运算符为一个Time类型的对象分配大小合适的内存空间,调用默认的构造函数来初始化这个对象并返回一个指向new运算符右边类型的指针(也就是Time*)。如果new无法在内存中为对象找到足够的空间,它就会抛出一个异常。
使用delete来动态释放内存
要销毁一个动态分配的对象并且释放这个对象占用的空间,应以如下方式使用delete运算符:
delete timePtr;
这条语句首先调用timePtr所指对象析构函数,然后收回对象占用的内存空间,把内存返还给堆。在前述语句执行结束后,系统就可以再次使用这块内存,分配给其他对象。
注意
- 当动态分配的内存不再使用时不进行释放,会导致内存泄漏;
- 删除非new分配的内存的行为是未定义的;
- 在delete一个动态分配的内存块之后,确保不要再次释放它,如果这样会出错。可以在delete一个内存块之后,紧接着将对应的指针的值设为nullptr。delete一个nullptr是没有影响的;
动态内存的初始化
我们可以为新建立的基本类型变量提供初始化值,例如:
double* ptr = new double(3.1415926);
这条语句将新好建立的double对象初始化为3.1415926,并将结果指针赋给ptr。
同样的语法可以用来将由逗号分隔开的参数列表指定给对象的构造函数。例如:
Time* timePtr = new Time(12, 45, 0);
上面的这条语句将一个新建的Time对象初始化为12:45 PM,并把结果指针赋予timePtr。
使用new[]动态分配内置数组
在前面我们提到new可以用来动态地分配内置数组。例如,可以像下面一样分配一个10个元素的整型数组并把这个数组指派给gradeArray:
int* gradeArray = new int[10]();
上面的代码声明指针gradeArray
,并且将指向一个动态分配的10元素整数数组第一个元素的指针赋给它。在new int[10]
值后面的括号(圆括号内不能有任何表达式)初始化数组元素——基本数值类型被置为0,bool类型被置为false,指针类型被置为nullptr,而对象则通过它的默认构造函数进行初始化。在编译时创建的数组的大小必须用常量整数表达式来指定(声明为const的常变量也不行)。但是在程序执行过程中,可以使用任何非负表达式来指定。
C++11使用列表初始化动态分配的数组
在C++11之前,在动态分配数组对象时,无法传递参数给每个对象的构造函数,每个在数组中的对象由默认构造函数初始化。在C++11中,我们可以使用初始化列表来初始化一个动态分配数组的元素。如:
int* gradeArray = new int[10]{}
此处的空的花括号表示每个元素使用默认初始化列表——对于基本类型来说就是每个元素设为0,花括号中可能包括逗号分隔的数组元素的初始化列表。
使用delete[]动态释放内置数组
要删除由gradeArray指向的动态分配的数组的内存,必须使用如下语句:
delete [] gradeArray;
如果上面代码中的指针指向一个对象数组,那么语句首先调用数组中每个对象的析构函数,然后再收回空间。如果上述的语句不包括方括号并且gradeArray指向一个对象的数组,那么结果是未定义的。
C++11使用unique_ptr管理动态分配的内存
C++11 中的新特性unique_ptr
是一个用于动态管理分配的内存的“智能指针”。当一个unique_ptr
超出范围时,它的析构函数自动把其管理的内存返还到堆区。之后章节我们会演示如何使用unique_ptr管理动态分配的对象或一个动态分配的数组。
十、实例研究:Array类
下面我们将开发一个优于内置数组的自定义数组类。它能够进行范围检查,以保证数组下标保持在有效范围内。这个类允许通过赋值将一个Array类对象赋值给另一个Array类对象。Array类对象知道自己的规模大小,因此当向函数传递Array参数时,不需要将其规模大小作为实参传递给函数。可以使用流提取和流插入运算符输入和输出整个Array对象。另外,还可以使相等运算符(==和!=
)进行Array对象的比较。
Array.h
#pragma once
#include <iostream>
class Array
{
friend std::ostream& operator<<(std::ostream&, const Array&);
friend std::istream& operator>>(std::istream&, Array&);
public:
explicit Array(int = 10); // 默认构造函数
Array(const Array&); // 拷贝构造函数
~Array(); // 析构函数
size_t getSize() const; // 获取数据成员size
// 重载赋值运算符,使得可以使用赋值运算符对该类对象进行赋值
const Array& operator=(const Array&); // 赋值运算符可以串联使用,所以返回一个Array对象的引用
// 重载相等运算符,使得能使用==判断两个Array对象是否相等
bool operator==(const Array&) const; // 返回bool类型
bool operator!=(const Array&) const; // 重载不相等运算符
// 重载下标运算符,使用该类的对象可以使用[]指定访问的元素
// 存在两种情况:
// 1.声明const的对象使用[]访问元素
// 2.非const的对象使用[]访问元素
// 且因为声明为const的对象只能调用同样声明为const的成员函数
// 且声明为const的对象使用该运算符时,不会对它进行修改,为了避免修改行为,声明为const的成员函数返回类型不为引用
// 非const的对象调用该运算符时,可能会对对象的数据进行修改,所以需要返回一个引用
// 所以该运算符的重载函数需要提供两个版本
// 1.可作为左值
int& operator[](unsigned int);
// 2.只能作为右值
int operator[](unsigned int) const;
private:
size_t size; // 保存数组的大小
int* ptr; // 保存数组的首元素地址
};
Array.cpp
#include <iostream>
#include <stdexcept>
#include <iomanip>
#include "Array.h"
using namespace std;
ostream& operator<<(ostream& output, const Array& array)
{
// 循环将数组的所有元素插入到输出流output中
for (int i = 0; i < array.getSize(); i++)
{
output << setw(12) <<array[i];
if ((i + 1) % 4 == 0)
{
output << endl;
}
}
// 输出结束,输出一个新行
if (array.getSize() % 4 != 0)
{
output << endl;
}
return output;
}
istream& operator>>(istream& input, Array& array)
{
// 循环提取数组的所有元素
for (int i = 0; i < array.getSize(); i++)
{
input >> array[i];
}
return input;
}
// 默认构造函数
Array::Array(int s)
: size(s > 0 ? s :
throw invalid_argument("Array size must be greater than 0.")),
ptr(new int[s])
{
for (unsigned int i = 0; i < size; i++)
{
ptr[i] = 0; // 将数组中的所有元素全部初始化为0
}
}
// 拷贝构造函数
Array::Array(const Array& array)
: size(array.size), // 一个已存在的Array对象的大小肯定合法
ptr(new int[size])
{
for (unsigned int i = 0; i < size; i++)
{
ptr[i] = array[i]; // 将数组中的所有元素全部初始化为0
}
}
// 析构函数
Array::~Array()
{
delete [] ptr; // 释放动态分配的内存
}
size_t Array::getSize() const
{
return size;
}
const Array& Array::operator=(const Array& array)
{
if ((this != &array)) // 避免自我赋值,只有当两个对象指向不同空间时才能进行赋值操作
{
if (size != array.getSize()) // 原对象的大小与赋值的数组大小不相等
{
delete [] ptr; // 释放原来的内存空间
size = array.getSize();
ptr = new int[size]; // 分配新的空间
}
// 为新建立的数组赋值
for (unsigned int i = 0; i < size; i++)
{
ptr[i] = array[i];
}
}
return *this;
}
bool Array::operator==(const Array& array) const
{
// 两个数组要相等,首先它们的大小要相等
if (size == array.getSize())
{
// 大小相等之后,判断每个元素是否相等
for (unsigned int i = 0; i < size; i++)
{
if (ptr[i] != array[i])
{
return false;
}
}
return true;
}
else
{
return false;
}
}
bool Array::operator!=(const Array& array) const
{
return !(*this == array); // 返回判断两个对象相等的相反值
}
int& Array::operator[](unsigned int n)
{
if(n < 0 || n >= size)
throw invalid_argument("Subscript out of range.");
return (*this).ptr[n];
}
int Array::operator[](unsigned int n) const
{
if (n < 0 || n >= size)
throw invalid_argument("Subscript out of range.");
return (*this).ptr[n];
}
test.cpp
#include <iostream>
#include <stdexcept>
#include "Array.h"
using namespace std;
int main()
{
// 创建两个Array对象,一个有7个元素,另一个默认有10个元素
Array integers1(7);
Array integers2;
cout << "Size of Array integers1 is " << integers1.getSize()
<< "\nArray after initialization:\n"
<< integers1 << endl;
cout << "Size of Array integers2 is " << integers2.getSize()
<< "\nArray after initialization:\n"
<< integers2 << endl;
// 输入17个int类型数据
cout << "Enter 17 integers:" << endl;
cin >> integers1 >> integers2;
cout << "\nAfter input, the arrays contains:\n"
<< "integers1:\n" << integers1
<< "integers2:\n" << integers2 << endl;
// 测试重载的 != 运算符
cout << "Evaluating: integers1 != integers2" << endl;
if (integers1 != integers2)
{
cout << "integers1 and integers2 are not equal." << endl;
}
// 使用拷贝构造函数创建一个新对象
Array integers3(integers1);
cout << "\nSize of Array integers3 is " << integers3.getSize()
<< "\nArray after initialization is\n"
<< integers3 << endl;
// 测试重载的 = 运算符
cout << "Evaluating: Assigning integers2 to integers1.\n";
integers1 = integers2;
cout << "integers1:\n" << integers1
<< "integers2:\n" << integers2 << endl;
cout << "Evaluating: integers1 == integers2." << endl;
if (integers1 == integers2)
{
cout << "integers1 and integers2 are equal." << endl;
}
// 测试重载的 [] 运算符
cout << "\nintegers1[5] is " << integers1[5] << endl;
cout << "\nAssigning 1000 to integers1[5]" << endl;
integers1[5] = 1000;
cout << "integers1 is:\n" << integers1 << endl;
// 尝试访问数组外的元素
try
{
cout << "Attempt to assigne 1000 to integers1[15]." << endl;
integers1[15] = 1000;
}
catch (invalid_argument& e)
{
cout << "An exception occured: " << e.what() << endl;
}
}
运行结果:
在上面代码中声明对象integers3时,使用了拷贝构造函数,如果换成下面的语句效果相同:
Array integers3 = integers1;
这里的=
并不是赋值运算符。当等号出现在对象声明中时,它调用该对象的拷贝构造函数。这种形式可用来向构造函数传递单个参数——也就是=
等号右边的值。
注意,流插入和流提取函数不可以是Array类的成员函数,因为Array对象总是位于流插入和流提取运算符的右侧。
在上面代码中存在使用计数器控制的for循环,你可能想使用基于范围的for循环来替换它们,但是遗憾的是,基于范围的for循环不能用于动态分配的内置数组。
上面还定义了一个拷贝构造函数,它通过建立现有Array对象的副本来初始化一个Array对象。这样的复制必须非常谨慎,避免出现将两个Array对象指向同一块动态分配内存的危险境地
。这里并不能使用由编译器提供的默认拷贝构造函数,因为编译器提供的默认拷贝构造函数都浅拷贝
,仅仅是逐个数据成员的值复制,当对象中包含一个指针成员时,使用拷贝构造函数创建的对象的指针将会指向拷贝的对象的指针指向的位置,两个指针此时就会指向同一块内存空间。所以我们需要对于包含指针数据成员的类需要显式提供一个拷贝构造函数
并重载赋值运算符(=)
来实现深拷贝
,以满足该类对象的拷贝操作。
两个对象指向同一块动态分配内存时,当其中一个对象使用delete释放了内存,那么另一个对象的指针指向的内存将不会保留,此时该指针就变成了一个“虚悬指针”。
在析构函数中,我们释放了动态分配的内存后,并没有手动将指针的值置为0,这并不会导致使用该指针会访问无效位置,因为当调用析构函数后,这个对象被销毁,指针都没有了。
对于重载的=
运算符,肯定有细心的同学发现了它的返回值是一个const Array的引用,这是为什么呢? 这是因为类似(x = y) = z
的操作是不被允许的,如果=
运算符返回一个Array的引用,(x = y)就可以作为左值,也就是可以接受z的赋值,在基本类型中这是不被允许的,而返回值是一个const Array对象的引用时,一样可以串联调用,且不会存在上面的问题。
在上面实现的重载赋值运算符函数中检查了是否是自我赋值
——一种危险举动。C++中赋值运算符的标准实现并不会检查两边的值是否相同。上面的代码检查了两边是否相同,所以从上面代码中看不出出现自我赋值时的危害。
按照标准实现的重载的赋值运算符,如果没有检查是否是自我赋值,它会首先将左操作对象中的动态分配的内存释放,再重新为它分配空间。这样就导致了原来的信息丢失。所以才说自我赋值
是一种极其危险的行为。
对于重载的!=
运算符,它的完整函数可以定义在头文件中。这样编译器可以内联operator!=的定义,消除额外的函数调用开销。
成员函数如果在类的定义内部直接实现,比如在头文件的类声明中直接写函数体,那么即使没有使用“inline”关键字,这些函数默认是内联的,编译器会将这些函数隐式的视为内联函数。
浅拷贝VS深拷贝
浅拷贝:仅复制数据的“表层”,比如指针的值,并不复制指针指向的实际资源。
深拷贝:复制数据的“表层”和“深层”(即指针指向的实际资源)。
对于不含指针的类对象,浅拷贝和深拷贝相同。
对于包含指针的类,浅拷贝会导致两个对象的指针指向同一块内存,这可能会导致一系列的严重问题。
注意:深拷贝并不仅限于在类中使用,它适用于所有包含指针或资源引用的数据结构。
所以当类的对象包含指向动态分配内存的指针时,如果不为其提供重载的赋值运算符和拷贝构造函数,会导致逻辑错误。
C++11:移动构造函数和移动赋值运算符
C++11中增加了移动构造函数和移动赋值运算符的概念,我们会在之后章节讨论。
C++:删除类中不想要的成员函数
在C++11之前,我们可以通过声明private的构造函数和重载赋值防止类对象被构造或对其进行重载。在C++11中,只需要简单地把这些函数从你的类中删除。例如:
Array(const Array&) = delete;
const Array& operator=(const Array&) = delete;
虽然我们可以删除任意的成员函数,但是通常我们仅删除编译器自动生成的成员函数,如默认构造函数、拷贝函数、赋值函数、移动构造函数和移动赋值函数。
C++11:使用unique_ptr管理动态内存
在上面代码中,析构函数使用delete[]
把动态分配的内置数组返回给堆区,而C++还允许使用unique_ptr
在Array对象超出范围时自动释放内存。之后章节我们会详细介绍如何会用它。
传递初始化列表给构造函数
还记得我们之前介绍过的初始化吗,C++11中的任何对象都能使用初始化列表来进行初始化。例如一个array类型的对象,可以使用如下语句来初始化:
array <int, 5 > { 1, 2, 3, 4, 5 };
C++也允许对自定义的类使用列表初始化。例如可以通过如下语句实现Array对象的实例化:
Array integers = { 1, 2, 3, 4, 5 };
等号可以省略。
为了支持列表初始化,可以定义一个接受类模板initalizer_list
的对象的构造函数。对于Array类,首先要包含头文件<initializer_list>
,然后,在首行定义如下的构造函数:
Array::Array(initializer_list<int> list)
,与类模板array、vector的使用方法类似,类对象的类型为initializer_list,元素类型为int类型,对象名称为list。
可以通过调用size成员函数来确定list参数中元素数目。为了获得第一个初始化值并且把它们复制到动态分配的数组中,可以使用如下语句:
int i = 0;
for(int item : list)
ptr[i++] = item;
构造函数的定义
Array::Array(initializer_list <int> list)
: size(list.size()),
ptr(new int[size])
{
int i = 0;
for(int item : list)
ptr[i++] = item;
}
十一、运算符作为成员函数和非成员函数的比较
无论运算符函数是通过成员函数实现还是非成员函数实现,运算符在表达式中的用法都是相同的。那么哪种实现方式更好呢?
当运算符函数作为成员函数实现时,最左边的操作数必须是包含运算符的类的一个类对象(或一个引用)。如果左操作数必须是一个不同类的对象或者是一个基本类型对象,那么该运算符必须作为非成员函数来实现。如果非成员运算符函数必须直接访问类的private或protected成员,那么该函数可以指定为该类的友元函数。
一个特定类的运算符成员函数仅在下面两种情形下(由编译器隐式地)调用:
- 当二元运算符的左操作数的确是该的类的对象时;
- 当一元运算符唯一的操作数是该类的对象时;
可交换的运算符
我们可能选择非成员函数来重载运算符的另一个原因是使用运算符具有可交换性。例如:一个long int类型的变量number和HugeInteger类的对象bigInteger(HugeInteger类中的整数可以是任意大小,不受底层硬件的机器字大小限制)。加法运算符(+)生成一个临时的HugeInteger对象,作为HugeInteger和long int类型对象之和(如bigInteger + number),或者long int和HugeInteger类型对象之和(如number + bigInteger)。因此我们要求这里的加法运算符具有可交换性(就像它的两个操作数都是int类型时表现的一样)。
当加法运算符作为成员函数重载时,那么该类的对象(HugeInteger类)就必须出现在运算符的左边,因此,我们需要以非成员函数重载加法运算符,使用HugeInteger类对象可以出现在运算符右边。
处理位于左侧的HugeInteger的operator+函数仍然可以作为一个成员函数,而位于右边的非成员重载函数只要简单地调换它们的参数,然后调用成员函数即可。
十二、类型转换
大多数程序都可以处理很多种类型的信息,有时所有的操作都会“集中在同一种类型上”。例如,int数据和int数据相加产生一个int数据(只要结果不超过int类型所能表示的数据范围)。然而,我们经常需要将数据从一种类型转换为另一种类型,比如在赋值、计算、传递值到函数和从函数返回值等各种情形中。编译器知道如何在基本类型之间进行特定转换。程序员可以使用cast强制类型转换运算符在基本类型之间进行强制转换。
但是用户自定义的类型又如何呢?编译器显然无法预先知道在用户自定义的类型之间、用户自定义类型和基本类型之间如何转换。因此程序员必须详细说明该怎样做。这样的转换可以用转换构造函数实现,它们是一种将其他类型(包括基本类型)的对象转换成特定类的对象的单参数构造函数。
转换运算符
转换运算符也称为强制类型转换运算符,可用于将某一类的对象转换成另一个类的对象。这种转换运算符必须是非static的成员函数
。下面的函数原型
Myclass::operator char*() const;
声明了一个重载的强制类型转换运算符函数,它可以把用户自定义类型Myclass的对象转换成一个临时的char*对象。因为这个运算符并不会修改原始对象,所以它声明为const。重载的强制类型转换运算符不指定返回类型
,因为返回类型就是对象正要转换成的目标类型。如果s是该类的一个对象,当编译器遇到表达式static_cast<char*>(s)
时,它会产生函数调用:
s.operator char*();
把操作数转换成char*。
重载强制类型转换运算符函数
通过定义重载强制类型转换运算符函数,可以将用户自定义类型的对象转换为基础类型或其他用户定义类型。下面的原型
Myclass::operator int() const;
Myclass::operator OtherClass() const;
声明了两个重载的强制类型转换运算符函数,它们分别可以将用户自定义类型Myclass的对象转换成整数和自定义类型OtherClass的对象。
强制类型转换运算符和转换构造函数的隐式调用
强制类型转换运算符和转换构造函数的优点之一就是:必要时,编译器可以隐式的调用这些函数来创建临时对象。例如,如果用户自定义的String类的对象s出现在程序中一个本该出现变通char*数据的位置上,如下所示:cout << s;
,那么编译器就可以调用重载的强制类型转换运算符函数operator char*()
将对象s强制转换为char*,并在表达式中使用这个转换结果。如果我们为String类提供了这个强制类型转换运算符,那么就不必重载流插入运算符便可以使用cout输出String类对象了。
十三、explicit构造函数与转换运算符
任何单参数的构造函数都可以被编译器用来执行隐式转换,即构造函数接收的类型会转换为定义了该构造函数的类的对象。除了拷贝构造函数之外,任何单参数并且不被声明为explicit
的构造函数都可以被编译器用来进行隐式转换。有时我们并不希望发生这种隐式转换,它们有时会产生错误。
举个例子:
上面我们定义的Array类中有一个声明为explicit的构造函数,现在我们把explicit去掉,然后使用下面代码进行测试:
#include <iostream>
#include "Array.h"
using namespace std;
void outputArray(const Array&);
int main()
{
Array integers1(7);
outputArray(integers1);
outputArray(3);
}
void outputArray(const Array& array)
{
cout << "The Array received has " << array.getSize()
<< " elements. The content are: \n" << array << endl;
}
运行结果:
可以看到在上面代码中调用的outputArray(3)这个函数将int类型的数据3转换成了一个大小为3的Array对象。
这是因为当编译器发现这个函数的实参为3时,且此时程序并没有提供一个接收int参数的名为outputArray的函数,只有一个接收const Array&
类型的函数,因为编译器需要确定Array类中有没有提供能将int转换成Array的转换构造函数。由于任何单参数的、没有声明为explicit的构造函数都可以看作转换构造函数,所以编译器此时就认为接收一个int类型参数的Array函数是一个转换构造函数,并用它将参数3转换成了一个有3个元素的临时Array对象。然后编译器就将这个临时的Array对象传递给函数outputArray,输出它的内容。
这样显然并不符合我们的设计预期。所以对于单参数构造函数,如果我们如果不打算让其作为一个转换构造函数,应该在其前面加上explicit。
当然如果上面代码中使用一个一个不是int的其他类型,只要它能被转换成int类型,此时都能使用该类的这个转换构造函数进行转换,当然如果使用一个无法强制转换成int类型的类型作为参数,此时就会出现编译错误。
在将之前Array类中的构造函数前面加上explicit之后,我们应该如何调用这个函数来输出一个有三个元素的Array对象呢?可以使用下面的语句:
outputArray(Array(3));
在调用函数outputArray之前,先调用explicit构造函数创建一个包含3个元素的临时Array对象,之后将这个临时对象传递给outputArray函数。
explicit转换运算符
C++11中除了像上面一个在单参数构造函数之前使用explicit之外,还可以声明explicit的转换运算符来防止编译器使用它们进行隐式转换。例如:
explicit Myclass::operator char*() const
如果是上面的这种函数原型来重载这个转换运算符,且类中没有提供重载的流插入运算符,那么就不能使用cout << s;
这样的语句来输出一个Myclass类的对象s。
因为编译器此时无法隐式的调用重载的转换运算符将该对象转换成一个char*对象。
总结
关键字explicit是用于将一个函数声明为不可隐式调用的。
十四、重载函数调用运算符()
重载函数调用运算符()来获取类对象的子类
重载函数调用运算符()的一个应用是通过使用重载的函数调用运算符来获取一个类对象中的部分。例如,在自定义的String类中,我们就可以重载该运算符来选择一个String的子类——两个整型作为参数的运算符能指明开始位置和选择的子串的长度。该operator()函数能检查诸如起始位置超界或负长度这样的错误。
重载的函数调用运算符必须是一个非静态的成员函数而且可以被以如下的方式定义:
String String::operator()(size_t index, siez_t length) const;
定义为const的成员函数是因为该运算符函数不应该修改原来的对象。
假设str是一个包含字符串AEIOU
的String对象,当编译器遇到表达式str(2, 3)
时,它就会生成如下的一个成员函数调用:
str.operator()(2, 3);
该调用返回字符串IOU
。
重载函数调用运算符()来访问二维数组
重载函数调用运算符()的另一个应用是通过使用重载的函数调用运算符来访问一个二维数组的数据。通常要访问一个二维数组的数据需要使用两个方括号对,例如,chessBoard[row][column]
,我们可以通过重载函数调用运算符来实现使用chessBoard(row, column)
来达到同样的效果。