C++是一个面向对象(OOP)的语言,继承、封装和多态是其三大特性。所以在我们通过C++设计一个类的时候,需要考虑很多因素,比如重载函数、操作符、控制内存的分配与归还、定义对象的初始化和终结等。设计较好的类有自然地语法、直观的语义,所以如何设计好一个高效的类是我们在编程时需要仔细考虑的问题。
在设计类之前,Effective C++中将设计类比作是设计type,提供了如下几种考虑点:
1、类的对象应如何被创建和销毁——影响类的构造函数和析构函数、内存分配函数和内存释放函数(包括operator new、operator new[]、operator delete、operator delete[])设计。
2、类的对象在初始化和赋值方面该有什么样的差别——影响类的构造函数、重载赋值操作符(operator=)设计。
3、类的对象被以值传递(如做函数形参时)会发生什么——影响类的拷贝构造函数设计。
4、类是否有继承关系——如果类设计继承某些既有的类,就需要考虑是否受virtual或non-virtual函数影响,如果允许其他类继承本类,需要考虑哪些函数需要设计成虚函数,尤其是析构函数是否需要为虚析构函数。
5、类中需要定义哪些函数——这个问题的答案将主要影响类的功能,函数根据需求进行定义,以及设计哪些函数是成员函数。
6、类中成员需要被保护的部分——考虑哪些函数和变量应该被设计为private,哪些应该是protected、哪些是public,以及哪些应该是友元函数。
7、类中成员变量和成员函数的合法值应该是哪些——对于成员变量而言,通常有一些有效的数值集,成员函数包括构造函数、赋值操作符、setter函数需进行的一些错误检查、异常抛出等。
8、类应该被怎样转换——类和其他类之间是否可以进行隐式转换,如编写operator T1等。若定义了explicit构造函数,就要专门写显式的负责类型转换的函数。
9、定义的类是否具有一般化——是否应该设计为模板class template。
接下来具体指出一些设计类、设计函数接口时需要遵守的几个原则:
一、设计接口时的定义原则:
在设计类中函数时,也就是设计接口时,应保持一个原则:让接口容易被正确使用,不易被误用。在一个整体的项目中,产品并不是由一整个模块构成的,而是由不同的层次组成,每一个层次之间都通过接口进行调用,每个接口完成自己各自的功能,从而最终扭结在一起行程一整套完整的架构。那么,设计一个好的接口就至关重要。每一个接口的使用者都是客户,所以客户在使用上一层定义的接口时,应该秉持:客户使用的接口如果不能达到预期的行为,那就不应该被编译通过,如果代码能够通过编译,那它的行为就该是客户想要的。
以一个简单易懂的例子表示,设计一个用来表现日期的class的构造函数:
class Date
{
public:
Date(int month, int day, int year);
};
这个接口设计看起来没有不合理的地方,但是,客户在使用时,可能会犯这样的错误:
Date d1(30, 3, 1995);
Date d2(2, 30, 1995);
第一种因为没有做任何限制,导致使用错误,将月份和日期传反了,第二种是写成2月30日。这两种错误在编译时都不会报错,因为我们设计的接口并没有任何语法上的限制,但在项目中使用时,就会发现得不到预期结果。这就是设计的缺陷之一,不能达到预期结果的接口,就应该被设计为错误使用时不能通过编译。如果改进一步这样设计:
class Date
{
public:
struct Day
{
explicit Day(int d) : val(d) {}
int val;
};
struct Month
{
explicit Month(int m) : val(m) {}
int val;
};
struct Year
{
explicit Year(int y) : val(y) {}
int val;
};
Date(const Month& m, const Day& d, const Year& year);
};
这个设计将Day、Month、Year进行了封装,比直接穿三个int好,虽然使用者见名知意,不会传错day、month和year,但因为未设置合法值,比如月份,有些系统中是从0开始表示1月,有些是从1开始,依然还是可能传入错误但却编译通过。所以,最好的设计法就是将有效地值直接规定好,使调用者直接使用规定的值进行调用,比如月份:
class Month
{
public:
static Month Jan() {return Month(1); }
static Month Feb() {return Month(2); }
//...
private:
explicit Month(int m);
};
调用者这样调用就不可能出现与预期不符合的错误了:
Date d(Month::Feb(), Day(30), Year(1995));
在设计接口时,为了能够让接口更容易被正确使用,接口设计一般都要具有行为一致性。举例来说,就像C++ STL容器,无论vector、set、map等容器,都有一个名为size的接口,它会告诉调用者目前容器内有多少个对象,这就是行为一致性,假设换成一个容器使用size、另一个容器使用length、再一个容器使用count,则对调用者来说,将很不容易使用。另一方面,我们设计接口时,不能乞求想让调用者去做什么事,假设我们定义的接口需要让调用者自己最终使用完毕删除指针,就容易造成调用者忘记删除指针或者删除指针超过一次这样的错误,比如说,简简单单返回指针的接口就可以使用智能指针的方式代替,调用者就不需要关注指针的析构问题。多线程情形下的互斥锁使用,调用者只需要在该加锁的地方直接调用加锁接口,在接口执行完毕时自动释放锁就可以,而不需要手动释放。也许我们认为只要把接口声明好后,注释详细写清楚就好了,但在大型项目中,接口的调用者和维护者不一定固定,时间久了势必会有人更改流程或更改调用方法,不可能每个人都清楚每一个接口的注意事项的,所以在设计时,就注意到这一点是非常必要的。
二、设计接口传递参数时的原则:
在设计一些需要传递函数对象的接口中,C++默认都是以传递值的方式进行的,也就是说,函数参数都是按照实际参数的复件为初值,调用端所获得的都是函数返回值的一个复件。这些复件都是通过调用对象的拷贝构造函数产生的,所以在传递对象时,我们最好使用传递引用的方式进行:
#include <iostream>
using namespace std;
class Person
{
public:
Person()
{
cout << "Person Constructor" << endl;
}
~Person()
{
cout << "Person Descontructor" << endl;
}
Person(const Person& p)
{
this->name = p.name;
this->address = p.address;
cout << "Person copy Constructor" << endl;
}
void behavior() const
{
cout << "Person behavior" << endl;
}
private:
std::string name;
std::string address;
};
class Student: public Person
{
public:
Student()
{
cout << "Student Constructor" << endl;
}
~Student()
{
cout << "Student Descontructor" << endl;
}
Student(const Student& p)
{
this->schoolName = p.schoolName;
this->schoolAddress = p.schoolAddress;
cout << "Student copy Constructor" << endl;
}
void behavior() const
{
cout << "Student behavior" << endl;
}
private:
std::string schoolName;
std::string schoolAddress;
};
//接口
bool validateStudent(Student s)
{
cout << "validate s is a Student" << endl;
return true;
}
//接口
void printBehavior(Person p)
{
p.behavior();
}
int main()
{
Student xiaoming;
cout << "-----" << endl;
bool ret = validateStudent(xiaoming);
printBehavior(xiaoming);
return 0;
}
这个例子中,validateStudent接口需要的是Student对象,也就是按值传递的,那么调用它时,Student由于是继承自Person类,所以调用了Person、Student构造函数,结束时,又调用了Student、Person的析构函数,同时,类中还带有两个string类的成员,所以传递时,也同时调用了四次string的构造函数和四次string的析构函数,这只是一个非常小的类,可见在大型项目中,类众多,继承关系复杂,成员变量众多,如果接口都是以传值的方式进行,就会造成大量的构造和析构函数被调用,效率是很低的。还有一点,在Person类和Student中,都定义了一个接口叫behavior,但是在使用这个接口时,printBehavior接口接收的是一个父类的对象的值,当我们给它传递子类的对象值时,可以看出打印的结果是父类的behavior而非子类的behavior,也就是说,对象被切割了,子类对象的特性被切割了,只保留了父类的特性,出现这两种问题的根因就是传值导致的,所以这种时候,接口使用传递引用的方式,可以大大减少这种不必要的动作:
//接口
bool validateStudent(const Student& s)
{
cout << "validate s is a Student" << endl;
return true;
}
//接口
void printBehavior(const Person& p)
{
p.behavior();
}
int main()
{
Student xiaoming;
cout << "-----" << endl;
bool ret = validateStudent(xiaoming);
printBehavior(xiaoming);
return 0;
}
//result:
Person Constructor
Student Constructor
-----
validate s is a Student
Student behavior
Student Descontructor
Person Descontructor
传递引用不仅没有调用构造函数和析构函数,也没有造成对象切割问题,传入子类的对象引用,得到的就是子类的行为,而不是父类的,因为传递引用就是传递的自身,并不会做一个复件出来。但是这种传递引用的方式虽然很高效,但一般不适用于内置类型和STL的迭代器,所以在定义要传递内置类型和STL容器迭代器类型的接口时,直接传递值更高效,对于其他用户自定义类型,均使用传引用方式传递。
三、设计接口返回值的原则:
虽然函数参数中有用户自定义对象时,传递引用的方式更高效,但并不代表在需要返回一个对象的时候,返回一个引用也高效。就像下面这个例子:
#include <iostream>
using namespace std;
class Rational
{
public:
Rational(int numerator = 0, int denominator = 1)
{
n = numerator;
d = denominator;
}
//...
private:
int n, d;
friend const Rational operator*(const Rational& lhs, const Rational& rhs);
friend ostream& operator<<(ostream& out, const Rational& r);
};
ostream& operator<<(ostream& out, const Rational& r)
{
out << r.n << "/" << r.d << endl;
return out;
}
const Rational operator*(const Rational& lhs, const Rational& rhs)
{
Rational result(lhs.n * rhs.n, lhs.d * rhs.d);
return result;
}
int main()
{
Rational x(1, 2); //1/2
Rational y(3, 5); //3/5
Rational w = x * y; //3/10
cout << w << endl;
return 0;
}
//result:
3/10
Rational类用来表示分数,其中重载了operator*用来计算两个分数对象相乘的结果,从接口的声明中,可以看出我们需要返回的是一个Rational对象值,那是否可以将这个返回的对象值更改为Rational对象的引用呢?答案是否定的,首先看这种方式:
const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
Rational result(lhs.n * rhs.n, lhs.d * rhs.d);
return result;
}
这种方式尝试返回一个引用,但这么写,就等于是在栈上分配了一个result对象,但随着operator*函数的结束,栈上的空间会被析构,那么这个函数内分配出来的对象就也会被析构,从而造成返回的是一个已经被析构的对象,这会造成程序挂死等问题出现。再看这种方式:
const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
Rational *result = new Rational(lhs.n * rhs.n, lhs.d * rhs.d);
return *result;
}
这种方式也尝试返回一个引用,函数中在堆上分配了一块不会随着函数执行结束而被析构的内存,这么做可以得出正确的结果,但是函数内分配的内存就无法释放了,造成了内存泄漏,所以,这么做也是不行的。再或者这样做:
const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
static Rational result(lhs.n * rhs.n, lhs.d * rhs.d);
return result;
}
int main()
{
Rational x(1, 2); //1/2
Rational y(3, 5); //3/5
Rational a(3, 2); //1/2
Rational b(3, 5); //3/5
Rational w = x * y; //3/10
Rational z = a * b; //9/10
if(w == z)
{
cout << "w == z" << endl;
}
cout << w << endl;
cout << z << endl;
return 0;
}
//result:
w == z
3/10
3/10
这种返回引用的方式考虑到了前面定义在栈上和堆上的不合理之处,将对象定义成了static类型,但这样做,正如main中所做的比较一样,当两个不相等的Rational去做==操作时,发现结果总是相等,这是因为operator==是将operator*内的static Rational对象值和operator*内的static Rational对象值进行比较,结果当然永远相等。再有,如果改成static Rational[]数组可以解决问题,但那样做程序的效率也将大大下降,为了避免一个返回对象值而硬生生做成以static数组返回一个引用的方式得不偿失,并且还有一个问题是引用其实只是一个别称,我们使用引用时一般都是等于使用某个对象自身,所以返回一个引用的话,就要有一个对象自身去接收它,像上面的例子,两个对象相乘,我们使用一个本身就是a*b的对象去接收a*b的引用本来也是矛盾的,所以在需要返回一个对象的时候非要去返回一个引用,这是不合理的。
四、设计类成员变量时的封装原则:
在设计类的成员变量时,要把它们的访问权限定义成private。这样可以提供给调用者访问数据的一致性,并能细化数据访问的权限,并且类提供更大的可扩展性。首先来说,如果把成员变量都设置为public,调用者就可以直接调用成员变量,在使用上来说,这需要调用者记住调用时哪些是变量哪些是函数,这不利于前面所说的接口一致性,如果变量都是private,则调用者只需要记住调用的都是函数接口而非变量。对于类设计者而言,如果变量都声明为private,只提供以接口形式来更改或者读取变量的话,这可以让设计者对类的成员变量处理有更精确的控制,比如将变量划分成“不准访问”、“只读访问”、“读写访问”、“只写访问”:
class Levels
{
public:
int getReadOnly() const {return readOnly; }
void setReadWrite(int value) {readWrite = value; }
int getReadWrite() const { return readWrite; }
void setWriteOnly(int value) {writeOnly = value; }
private:
int noAccess;
int readOnly;
int readWrite;
int writeOnly;
};
还有一个把成员变量声明为private对调用者而言最有利的地方就是封装效应。当我们把成员变量private之后,调用者无法调用到这些成员变量,而只能调用开放的接口,这样一旦后期类的设计有所更改,比如某个变量不再使用而删除了,对于调用者来说,并不要做任何改变,因为本来也没有直接调用过那个变量。这样,设计者只需要“暗箱操作”,对于调用者而言是不可见的,这样一来,就不会对整个项目的代码造成任何影响。如果声明为protected,调用者依然还是可以调用到变量,从封装的角度来看,其实只有两种访问权限:private(封装)和其他(不提供封装)。所以,声明成员变量为private是一个很有用的原则。
五、设计类成员函数时,多考虑其合理性:
拿前面举过的Rational类的例子来说,计算两个Rational的乘法重载了operator*函数,使用的是非成员函数,那这里为什么要设计成非成员函数而非成员函数呢?如果写成成员函数,则是以下这样的情形:
class Rational
{
public:
Rational(int numerator = 0, int denominator = 1)
{
n = numerator;
d = denominator;
}
const Rational operator*(const Rational& rhs) const
{
return Rational(this->n * rhs.n, this->d * rhs.d);
}
//...
private:
int n, d;
friend ostream& operator<<(ostream& out, const Rational& r);
};
int main()
{
Rational x(1, 2); //1/2
Rational y(3, 5); //3/5
Rational w = x * y; //3/10
Rational v = w * 3; //9/10
Rational v1 = 3 * w; //编译不通过
cout << w << endl;
cout << v << endl;
return 0;
}
上述代码为什么w*3就可以编译通过并运行出正确结果,3*w就不可以?因为w*3发生了隐式类型转换,因为Rational的构造函数前并未声明explicit,所以编译器会这样做隐式类型转换:
const Rational temp(3);
result = w * temp;
但将3写在w前面,编译器就不知道3要被转换成什么类型,它只会认为3是个int,却又找不到这样的构造函数,也就不会编译通过了,但如果在Rational的构造函数前加explicit声明,则无论w*3还是3*w都将不会发生隐式类型转换,都不会编译通过。所以,要想让3*w这种写法也能编译通过并运行处正确的结果,就要把operator*的实现变成非成员函数,但operator*中访问到了Rational类中的私有变量,我们可以将他做成友元函数,使他可以访问类Rational中的私有变量,但这样做,又破坏了上一原则中提到的封装性,所以更好的做法是提供:
int Rational::numerator() const {return numerator;}
int Rational::denominator() const {return denominator;}
然后在const Rational operator*(const Rational& lhs, const Rational& rhs)非成员函数中,使用这两个接口操作私有成员变量相乘,如果这个非成员函数和class Rational不在同一个文件内,则在另一个文件内包含class Rational的头文件即可。