类是C++中面向对象编程的核心,通过数据隐藏和方法封装,用户只需要关心对象的数据以及操作内容。对于数据如何存储,操作如何实现等细节则无需处理。
本文结合C++PrimerPlus以及网上的一些文章,讲述一下类实现的一些重点难点,有些基本概念可能会跳过,不明白的内容可以自行百度。
1. 构造函数和析构函数
类的构造函数和析构函数是其中两个很重要的标准函数。当类创建时,将会调用构造函数,当类被删除时,将会调用析构函数。因此一个类所包含的最小内容就是构造和析构函数了。
1.1 构造函数
假设有以下的一个类:
class Stock // class declaration
{
private:
std::string company;
long shares;
double share_val;
double total_val;
void set_tot() { total_val = shares * share_val; }
public:
void acquire(const std::string & co, long n, double pr);
void buy(long num, double price);
void sell(long num, double price);
void update(double price);
void show();
};
类中并没有显式提供构造函数,这时编译器会提供一个默认构造函数,这时创建一个类对象时,其私有数据成员将不会被初始化,这时数据中的值将是随机的,根据分配内存中原来的值而定。
Stock A; //A中的数据成员不确定,需要用成员函数进行分配。
不能指望用户在使用类时,同时还会知道需要用哪个成员函数来对私有数据进行赋值,因此在设计类时,可以用默认构造函数对类成员进行隐式初始化。
Stock::Stock(){
company = "no name";
shares = 0;
share_val = 0.;
total_val = 0.;
}
Stock St = new Stock();//创建一个Stock对象并调用默认构造函数
当然,构造函数也能够自行定义,如下:
Stock::Stock(const string &co, long n, double pr){
company = co;
share = n;
share_val = pr;
total_val = pr * n;
}
Stock *ps = new Stock("game",0,1);//创建一个Stock并对其初始化
要注意的是,一旦在类中创建了一个自定义的构造函数,则类中的默认构造函数就不能用了。例如定义了以上的构造函数后,以下的定义将会出错。
Stock St;//没有默认构造函数
1.2 析构函数
在程序结束时或其他情况使创建出的类对象不能再被继续使用时,程序将会调用构造函数,将类中的对象删除。构造函数的定义如下:
Stock::~Stock(){cout<<"Stock End"<<endl;}
以上定义了一个Stock类的析构函数,当类消亡时,将会打印出”Stock End“的字样。
如果没有显示定义类的析构函数,则编译器会自己生成一个空的析构函数。
析构函数不需要,也不能在程序中显式调用,其只能在类对象在要消亡时隐式被程序调用。
析构函数主要做一些收尾工作,比如说在类的构造函数中用new创建出一些对象时,则需要在析构函数中用delete将其删除,否则将会发生内存泄漏。
2 this指针
类的成员函数可以将类本身作为一个返回类型,比如可以定义以下函数:
const Stock& Stock::topval(const Stock &s) const;
函数输入一个Stock类引用对象,并且用const限定,表明传入的对象不允许在函数中被修改。
函数末尾也用const限定,表明在函数中不允许对调用此函数的类对象成员进行修改。
函数开头也用const限定,表明返回的类引用对象不允许被修改,或赋值到一个非const的变量上。
此函数是用于比较输入对象与调用该方法的对象,并且返回较大那个,定义如下:
const Stock& Stock::topval(const Stock &s) const{
if(s.total_val>total_val) return s;
else return *this;
}
当输入的对象s的值较大时,返回该对象是容易的,但是如果是调用方法的对象较大,需要返回时,就需要用到this指针了。
this指针指向调用该方法的对象地址。当需要返回该对象时,使用return *this即可。
在函数中if语句用也可以用另外一个写法
if(s.total_val>this->total_val)
此语句也表明,this的确是指向被调用的对象。
3.运算符重载和友元函数
3.1 运算符重载
运算符重载是C++中的一种多态实现,目的是为了让一些操作更加简洁。
比如说如果要让两个长度为10的数组每个元素对应相加,则需要用以下代码实现。
for(int i = 0; i < 10; i++){
result[i] = a[i] + b[i];
}
而更简单的方法,如果已经对+号运算符重载的话,可以直接让两个数组相加,得到一个新的数组。
result = a + b;
下边用一个简单的例子来说明如何在类中重载+号运算符。
以下代码创建了一个关于时间的类,包含时和分。并且重载了+号运算符,让两个时间对象可以相加。
class Time{
private:
int hours;
int minutes;
public:
Time operator+(const Time &t) const;
Time(int h, int m):hours(h),minutes(m) {}//使用初始化列表创建构造函数
Time() {}
void Show();
};
Time Time::operator+(const Time &t) const{
Time sum;
sum.minutes = minutes + t.minutes;
sum.hours = hours + t.hours + sum.minutes/60;
sum.minutes%=60;
return sum;
}
void Time::Show(){
cout<<"Now is "<<hours<<" "<<minutes<<endl;
}
int main(){
Time t1(2,30);
Time t2(1,45);
Time t3 = t1 + t2;
t3.Show();
return 0;
}
可以看到,运算符重载的写法就跟写一个普通的函数没太大区别,很多运算符都可以重载,除了加减乘除等于号这些运算符,[],%,+=,-=等等这些运算符都可以重载。
要注意的是,加减乘除这些二元运算符,运算符的左右两个对象是有顺序的。运算符的左边是调用运算符的对象,运算符右边是传入的参数对象,也就是operator+(const Time &t) 中的t。
当运算符左右两个是同类对象时,这种声明不会有问题,因为对于Time a,b来说,a+b和b+a是等价的。但是如果运算符左右两个对象类别不一样的话,就有问题了。
比如说现在要重载乘号运算符,使时间对象可以与数字相乘。
Time Time::operator*(const int Num) const{
Time Result;
Result.minutes = minutes * Num;
Result.hours = hours * Num + Result.minutes/60;
Result.minutes%=60;
return Result;
}
int main(){
Time t1(2,30);
Time t2 = t1 * 3;
t2.Show();
return 0;
}
打印t2的输出信息,应该发现是7时30分,刚好是2时30分的3倍。
程序中重载的*运算符用于计算t1 * 3,在这里调用*运算符的是类Time的对象t1。因此这里的*运算符是类的成员函数。
如果要计算 3 * t1,情况就不一样了,此时类Time位于* 右边,而发起调用的常数3并不属于Time类,因此不能在Time的成员函数中利用重载运算符的方式来计算3 * Time。此时如果要重载*运算符,只能用以下介绍的友元函数。
3.2 友元函数
一般来说,类中的数据都是封装好的,只能用类的成员函数进行修改。但是由于一些操作比较特殊,比如上节提到的重载运算符,需要设计一类特殊的非成员函数来访问类的数据成员,这种函数就称为友元函数。
创建友元函数的第一步就是将函数原型放到类声明中,在原型前添加friend关键字
friend Time operator*(int N, const Time &t);
友元函数有以下两个特征:
- 其虽然在类中声明,但是并不是成员函数,因此不能通过类来访问。
- 虽然其不是成员函数,但是其权限与成员函数一样,能够访问类的私有成员。
编写函数定义时,有两点需要注意:
1.友元函数不是类成员函数,因此不用在函数开头写作用域解析运算符Time::
2.不用在函数定义中写关键字 friend
Time operator*(int N, const Time &t){
Time result;
result.minutes = t.minutes * N;
result.hours = t.hours * N + result.minutes/60;
result.minutes%=60;
return result;
}
int main(){
Time t1(2,30);
Time t2 = 3 * t1;
t2.Show();
return 0;
}
4. 类和动态内存分配
在使用类时,不可避免会在构造函数和析构函数中使用到动态内存分配,如使用new和delete等。但是错误的使用可能会导致类的对象产生错误。
以下例子构建一个类并展示了一个错误的用法
#include<iostream>
using namespace std;
class StringBad{
private:
char *str;// pointer of string
int len;// length of string
static int num_strings;// number of objects
public:
StringBad(const char *s);//constructor
StringBad();//default constructor
~StringBad();//destructor
friend ostream& operator<<(ostream &os, StringBad &s);
};
ostream& operator<<(ostream &os, StringBad &s){
os<<s.str;
return os;
}
StringBad::StringBad(const char *s){
len = strlen(s);
str = new char[len+1];
strcpy(str,s);
num_strings++;
cout<<num_strings<<" \""<<str<<"\" objects created"<<endl;
}
StringBad::StringBad(){
len = 4;
str = new char[len+1];
strcpy(str,"C++");
num_strings++;
cout<<num_strings<<" \""<<str<<"\" objects created"<<endl;
}
StringBad::~StringBad(){
cout<<"\""<<str<<"\" object deleted, ";
--num_strings;
cout<<num_strings<<" left"<<endl;
delete []str;
}
void callme1(StringBad &s){//pass by reference
cout<<"String pass by reference: "<<s<<endl;
}
void callme2(StringBad s){
cout<<"String pass by value: "<<s<<endl;
}
int StringBad::num_strings = 0;
int main(){
cout<<"function start"<<endl;
StringBad s1("s1");
StringBad s2("s2");
StringBad s3("s3");
cout<<endl;
callme1(s1);
cout<<"s1 after callme1: "<<s1<<endl;
callme2(s2);
cout<<"s2 after callme2: "<<s2<<endl;
StringBad Stest1 = s1;
cout<<"Stest1 from s1: "<<Stest1<<endl;
StringBad Stest2;
Stest2 = s3;
cout<<"Stest2 from s3: "<<Stest2<<endl;
return 0;
}
上方代码构建了一个字符串String的使用,使用StringBad意味着这个类是没完善,有bug的,下面先来分析一下这个类,再看main函数中的使用结果。
StringBad中有2个私有成员和1个用static声明的静态成员。2个私有成员分别是字符串指针以及记录字符串长度的整形变量。静态成员是用来记录类StringBad在main函数中对象个数。
StringBad中有3个成员函数以及1个友元函数。3个成员函数分别是自定义的构造函数、默认构造函数和析构函数。友元函数重载了"<<"运算符,用于打印类中字符串的值。
在自定义构造函数中,输入参数的是一个字符串,函数中用new开辟一个空间后用strcpy函数将字符串深拷贝到开辟的空间中。默认构造函数功能类似,此时输入参数为空,默认字符串为“C++”。在构造函数中静态变量num_string都会+1,表明创建了一个新的对象,并且会打印相应的字符串值
在析构函数中,功能为将构造函数中new出的字符串指针删除,并且num_string会-1,表明删除了一个对象。并且会打印此对象已删除的字样。
下边来分析main函数,根据不同的编译器,main函数有可能成功也可能终止,即使成功,其打印的字符串也会有问题,下边是我的电脑跑出来的结果。
function start
1 “string1” objects created
2 “string2” objects created
3 “string3” objects created
String pass by reference: s1
s1 after callme1: s1
String pass by value: s2
“s2” object deleted, 2 left
s2 after callme2: s2
Stest1 from s1: s1
3: “C++” default object created
Stest2 from s3: s3
“s3” object deleted, 2 left
“s1” object deleted, 1 left
“” object deleted, 0 left
a.out(9393,0x7fffae464380) malloc: *** error for object 0x7f84afc02740: pointer being freed was not allocated
*** set a breakpoint in malloc_error_break to debug
可以看到函数是异常终止的,报错原因是重复释放已经被释放的指针,并且看上一行中用于记录main函数的对象个数的num_string变成了-1。因此错误原因可能是对象在以某种方式创建时没有被num_string跟踪到,而删除时记录下来了,因此对象个数变为-1。下边来分析一下打印的信息与main函数的对应关系。
首先函数用自定义构造函数创建了3个对象,并成功打印出3个对象的字符串值。
进入到callme1函数中,并打印字符串s1的值。
进入到callme2函数中,并打印字符串s2的值。
创建Stest1对象并将s1赋值,打印Stest1的值,与s1一致
用默认构造函数创建Stest2对象然后将s3赋值,打印Stest3,是s3的值。
然后程序退出,退出时会每个对象都会调用析构函数,同时删除本对象时会打印剩余的对象数目。由于C++中是用栈的方式来管理对象,因此删除时会先删除最后创建的对象。
最后创建的对象是Stest2,其名字与s3一样,因此打印先删除s3。
之后删除的对象应该是Stest1,其名字与s1一样,因此打印删除s1。
然后程序开始有问题了,删除对象并没有名字。然后程序报错退出。下边来分析一下错误原因。
程序有3个错误,分别是函数callme2,创建Stest1,创建Stest2的方式。
首先在callme2中,函数是以传值的方式将参数传入函数中。按值的话实际上就是传递对象的副本,而在对对象进行拷贝的时候,有可能会出现严重的问题。
4.1拷贝构造函数和赋值运算符
类对象拷贝的时候,是由拷贝构造函完成的。而在StringBad类里并没有定义拷贝构造函数,此时编译器会自动生成一个默认拷贝构造函数,将类对象的所有内容拷贝到新的对象中。一般来说这不会出什么问题,但是由于对象中的字符串是用new生成的,在拷贝的时候默认只会进行浅拷贝,并且不会更新num_strings变量,不能跟踪到此对象的生成。因此传递给callme2的s2对象以及callme2中的s2对象副本,两个字符串是指向同一块内存的,当函数结束时,会调用析构函数删除s2对象副本,同时也会删除该字符串内存,此时原s2对象中的字符串指针已经被删除,因此在整个程序结束时再尝试删除此内存会报错。
创建Stest1时的错误与callme2函数类似,在创建同时将s1赋值,这将会调用默认拷贝构造函数。删除时也会对s1进行两次删除。
创建Stest2的错误与之前有所不同,代码首先用默认构造函数创建Stest2对象,然后再用"=“号将s3对象传递给Stest2。问题就出现在这个”=“号上。
在类中”=“号是可以被重载的运算符,其默认的运算与拷贝构造函数类似,将“=”右边号对象中的所有内容赋值给”="号左边的对象。当对象中有用new出来的变量时,两个对象中的该变量将会指向同一块内存。调用析构函数删除其中一个对象时,另外一个对象相应的内存也会被删除,同时报错。
要修复这两个错误,可以尝试自定义拷贝构造函数和重载"="运算符。通过对new生成的对象进行深拷贝,就可以避免对同一块内存删除两次的问题。
StringBad::StringBad(const StringBad &s){
len = s.len;
num_strings++;
str = new char[strlen(s.str)+1];
strcpy(str,s.str);
cout<<num_strings<<" \""<<str<<"\" objects created by copy constructor"<<endl;
}
StringBad & StringBad::operator=(StringBad &s){
if(&s==this) return *this;
delete[] str;
str = new char[strlen(s.str)+1];
strcpy(str,s.str);
return *this;
}
拷贝构造函数的写法比较简单,直接创建一个对象并将字符串指针进行深拷贝即可。
赋值运算符就需要注意一下,首先调用的对象可能已经有原先存在的字符串指针,要用delete释放这部分内存。
要避免对象赋予给自身,如果等号左右是同一个对象,则用delete删除时可能会删除此对象的内容。
函数需要返回一个指向调用对象的引用。
因此赋值运算符一开始需要先判断赋值运算符两端是否为同一个对象,如果是则直接返回自身引用即可。否则要用delete删除自身对象的字符串指针,重新申请一块内存并用深拷贝方式复制新字符串即可。
在设计类时,编译器会自动生成的默认函数有构造函数,拷贝构造函数,析构函数,赋值运算符等,如果类中的内存模型比较复杂,默认生成的函数可能不能正确表示,这时就需要使用自定义的函数,以及重载运算符来完成。
5.类继承
类继承是C++的核心功能之一,通过继承可以让代码重用成为可能。在开发大型软件时,如果能使用已经开发好的代码能够很好地节省时间和精力。
类继承可以完成的工作如下:
- 可以在已有类的基础上添加新的功能
- 可以在已有类的基础上添加新的数据
- 可以修改类方法的行为。
下边设计一个类来说明类继承的使用。从一个类派生出另一个类时,原始类成为基类,继承类成为派生类,为说明继承,首先需要一个基类。这里是设计一个简单的TableTennisPlayer类
class TableTennisPlayer{
private:
string firstname;
string lastname;
bool hasTable;
public:
TableTennisPlayer(const string &fn = "none", const string &ln = "none", bool ht = false);
void Name() const{cout<<lastname<<", "<<firstname<<endl;}
bool HasTable() const{return hasTable;}
void ResetTable(bool v) {hasTable = v;}
};
TableTennisPlayer::TableTennisPlayer(const string &fn, const string &ln, bool ht)
:firstname(fn),lastname(ln),hasTable(ht) {}
int main(){
TableTennisPlayer player1("Donald","Trump",true);
player1.Name();
return 0;
}
编译成功的话,运行时可以顺利打印类名字"Donald Trump"。
以上类包含的成员为firstname何lastname,以及有无桌子的选项。
类的构造函数采取了初始化列表的方式来给成员赋值。另外一种赋值方法如下
TableTennisPlayer::TableTennisPlayer(const string &fn, const string &ln, bool ht) {
firstname = fn;
lastname = ln;
hasTable = ht;
}
当成员是简单的整形,double类型这种变量时,两种赋值方式是没区别的,但是当赋值成员是类是,就有细微的区别,比如在这个例子中赋值成员是C++库中string类。
利用初始化列表时,类成员是通过复制构造函数创建的,直接将参数赋值给类的成员。而不用初始化列表的方式,类成员会先调用默认构造函数,然后通过赋值运算符将参数赋值给类成员,对比初始化列表,此方法多了一个步骤,当创建的类很大时,此步骤可能会多花一定的时间。
下边声明一个RatedPlayer类,声明从TableTennisPlayer类中派生而来
class RatedPlayer : public TableTennisPlayer{
private:
unsigned int rating;
public:
RatedPlayer(unsigned int r = 0, const string &fn = "none", const string &ln = "none", bool ht = false)
: TableTennisPlayer(fn,ln,ht),rating(r){}
RatedPlayer(unsigned int r, const TableTennisPlayer &tp):rating(r),TableTennisPlayer(tp){}
unsigned int Rating() const{return rating;}
void ResetRating(unsigned int r) {rating = r;}
};
int main(){
RatedPlayer rt(10,"Donald","Trump",false);
rt.Name();
return 0;
}
编译成功的话,同样可以打印类的名字"Donald Trump"。
设计派生类的时候,有几点需要注意的:
- 派生类自动包含了基类的所有数据成员以及方法,因此不用在设计时重新声明。
- 派生类不能直接访问基类的私有成员,只能通过基类的方法来访问。
- 使用派生类时,程序会先创建基类对象,因此必须在派生类的构造函数中使用基类的构造函数。并且在使用时最好能够在初始化列表中利用基类的构造函数创建基类对象,否则其只能通过默认构造函数函数创建,并在代码中用赋值运算符建立基类对象。
派生类和基类有一些特殊关系,如基类指针或引用可以不用显式转换的情况下直接指向派生类,如下代码是可以运行成功的
RatedPlayer rt(10,"Donald","Trump",false);
TableTennisPlayer &tp = rt;
TableTennisPlayer *ttp = &rt;
tp.Name();
ttp->Name();
这种规则是有道理的,因为派生类对象肯定包含了基类的所有方法,因此即使将其声明为基类的对象或指针,也能正常使用。
当然反过来让派生类的指针或引用指向基类就不行了,因为派生类的有些方法并不能在基类中使用。
这种特性在函数中也能够使用
void Show(TableTennisPlayer &tp){
tp.Name();
}
int main(){
RatedPlayer rt(10,"Donald","Trump",false);
TableTennisPlayer &tp = rt;
TableTennisPlayer *ttp = &rt;
Show(rt);
Show(tp);
return 0;
}
在函数中传入的参数应该是基类对象,但是使用时,传入的可以是基类,也可以是派生类。
5.1 多态公有继承
有时候我们在设计类时,希望派生类的某些方法与基类的不一致,因此需要重新定义。而重新定义后根据调用类对象将会调用不同的方法,这种复杂的行为成为多态。下面用一个在C++PrimerPlus中简化过的程序来说明这个情况。
class Brass{
private:
string FullName;
double Balance;//存款数
public:
Brass(const string &s = "none", double bal = 0):FullName(s),Balance(bal){}
virtual void ViewAcct() const;
virtual ~Brass(){};
};
class BrassPlus : public Brass{
private:
double MaxLoan;
public:
BrassPlus(const string &s = "none", double bal = 0, double ml = 0):Brass(s,bal),MaxLoan(ml){}
virtual void ViewAcct() const;
};
void Brass::ViewAcct() const{
cout<<"In Brass, FullName: "<<FullName<<" Balance: "<<Balance<<endl;
}
void BrassPlus::ViewAcct() const {
Brass::ViewAcct();
cout<<"MaxLoan: "<<MaxLoan<<endl;
}
int main(){
Brass B("Tom",1000);
BrassPlus BP("Jack",3000,100);
B.ViewAcct();
BP.ViewAcct();
return 0;
}
In Brass, FullName: Tom Balance: 1000
In Brass, FullName: Jack Balance: 3000
MaxLoan: 100
在本例中,首先创建了一个银行账号Brass类,数据包括存款人的名字以及存款数据。方法ViewAcct用于打印对象的名字以及存款数目。而BrassPlus继承自Brass,添加了可贷款数目MaxLoan,并且修改了在ViewAcct中的行为,除了打印对象的名字及存款数目,还打印了贷款数目。注意在BrassPlus的ViewAcct中是首先使用Brass中ViewAcct方法,然后再添加MaxLoan的内容。派生类引用基类的方法必须使用作用域解析运算符,直接用ViewAcct的话会产生一个无限递归的函数。
在此例子中,可以看到两个类的ViewAcct函数前面都有virtual关键字,表明了这是虚方法,虚方法是多态里一个很重要的特性,在此例中如果没有virtual关键字是不会有影响的,main函数中的两个对象依然能够打印各自的信息。
本例中virtual关键字有无并不影响结果,是因为两个类的方法都是通过对象调用的,如果两个类是通过指针或者引用调用的,将会出现问题,如下:
int main(){
BrassPlus BP("Jack",3000,100);
Brass &B_ref = BP;
Brass *B_poi = &BP;
B_ref.ViewAcct();
B_poi->ViewAcct();
return 0;
}
In Brass, FullName: Jack Balance: 3000
MaxLoan: 100
In Brass, FullName: Jack Balance: 3000
MaxLoan: 100
在main函数中,创建了一个BrassPlus对象,之后分别创建了指向该对象的Brass引用和指针,然后用ViewAcct方法打印其信息。如果ViewAcct方法不是虚方法,那么就和上一节一样,会调用Brass类里的来打印,但是ViewAcct用virtual限定为虚方法后,即使用Brass引用或指针指向BrassPlus对象,仍然能够用BrassPlus的方法。
5.2 虚函数的工作原理
虚函数的工作原理与编译器的工作相关,首先看看两种编译方式,静态联编和动态联编。
静态联编指的是编译器在编译过程中完成所有过程,在程序运行的时候能够按顺序直接运行。
与其相对于的动态联编,则是因为在编译过程中有些内容是不能全部完成的,比如如果要根据用户输入来进行后续操作,则编译器需要生成一些能够在程序运行时选择正确运行方式的代码,这就是动态联编。
在多态继承中,用virtual限定的虚方法需要使用动态联编来进行编译。就如上例所述,虽然声明了Brass的指针或者引用,但是调用ViewAcct时还是要根据其指向的对象类别来选择调用的方法。处理虚方法的原理如下。
在编译时,编译器会给每个对象创建一个指向函数地址的数组指针,称为虚函数表,表中存储了类中所有虚函数的地址。
在基类中,其包含的虚函数表存储了所有的虚函数指针,比如说创建了一个Brass类,ViewAcct以及析构函数都会以指针形式保存在虚函数表中。
在派生类中,会有同样一个虚函数表,如果虚函数在派生类中被重新编辑了,则其函数指针会替代基类的指针,如BrassPlus对象中的ViewAcct指针将会替代其本来Brass的ViewAcct指针的位置。
在实际使用时,程序将会查看对象在虚函数表的地址,并根据其地址转向相应的函数进行调用。
最后提一下析构函数,为了保持良好的习惯,基类中的析构函数应该为虚函数。因为如果其不是虚函数,在派生类对象消亡时将会调用基类的析构函数,当其内存模型比较复杂时,调用基类的析构函数可能导致派生类有些内存没有删除干净,导致内存泄漏。