本章主要讲解 C++接口的良好设计与声明
①想要开发一个“容易被正确使用,不易被误用”的接口,首先必须考虑客户可能做出什么样的错误。
比如我们为一个表现日期的类设计一个构造函数。
class Date
{
public:
Date(int d,int m,int y):day(d),month(m),year(y){}
private:
int day, month, year;
};
Date d(30,4,1997); //正确
Date d(30,2,1997); //错误,2月没有30天,但是不会报错
Date d(2,15,1997); //错误,没有15个月,但是不会报错
导入新类型确定每个参数的类型正确, 对于实现接口不被误用很有效果。
//导入新类型Day,Month,Year预防接口被误用
class Day{
public:
explicit Day(int d):day(d){}
private:
int day;
};
class Month{
public:
explicit Month(int m):month(m){}
private:
int month;
};
class Year{
public:
explicit Year(int y):year(y){}
private:
int year;
};
class Date{
public:
Date(const Day& d,const Month& m,const Year& y);
};
Date d(30,4,1997); //错误
Date d(Month(3),Day(30),Year(2000)); //错误,类型错误
Date d(Day(30),Month(3),Year(2000)); //正确
② 想要开发一个“容易被正确使用,不易被误用”的接口,其次应该考虑的是 限制类型内什么事可做,什么事不可做。
常见的限制是加上const,例如我们前面提到过的*运算符返回一个const,以预防if(a * b = c)
的代码书写错误。
③ 想要开发一个“容易被正确使用,不易被误用”的接口,还应该 尽量令你类型的行为与内置类型一致。
没有什么 比一致性 使得 接口容易被正确使用,没有什么比 不一致性 使得 接口容易被误用。例如C++的STL,对于任何容器而言,要想获取容器内元素个数的大小,都使用size()函数,而java就不同。
④ 想要开发一个“容易被正确使用,不易被误用”的接口,还应该 不要让用户管理资源。
在前面我们已经学习了使用资源管理类来管理资源,其中shared_ptr是最为常用的资源管理类,它比原始指针大且慢,而且使用辅助动态内存。在很多程序中这些额外的执行成本并不显著,然而其降低客户错误的成效却是每个人都看得到。
总结:
定义一个新的class,就是定义了一个新的type。重载函数和操作符、控制内存的分配和归还、定义对象的初始化和终结……一个都不能少。
如何设计高效的class呢?我们来思考以下问题。
缺省情况下C++以传值的方式传递参数,也就是说,函数形参是实参的副本,函数返回值也是返回对象的副本。而拷贝过程是由函数的拷贝构造函数完成的,并且在函数结束时会调用析构函数销毁实参副本,这些都有额外的时间开销。
class Person{
public:
person();
~person();
private:
string name;
string add;
};
class Student:public Person{
public:
Student();
~Student();
private:
string school;
};
倘若现在,我们有一个函数接收Student类型的实参。那么我们要对形参进行拷贝构造,在函数结束时要调用析构函数销毁。并且,在Student类中有一个string成员,这意味着string的拷贝构造函数和析构函数也将被调用一次。而且,Student类有一个基类Person,那么基类的拷贝构造函数和析构函数也将被调用。……因此,我们只是以值传递了一个Student类型的对象,却需要调用5次拷贝构造函数,5次析构函数!
如果参数为const的引用const Student& s
而不是Student s
,则没有新副本产生,也不会调用拷贝构造函数和析构函数。那么为什么一定要用const修饰呢?因为以值传递时,我们知道s只是对象的副本,对s的操作不会改变原本的对象;而传const的引用,传递的是对象本身,以const修饰保证对象不会被改变。
并且,传const的引用可以解决对象切割的问题。
class Window{
public:
string name() const;
virtual void dispaly() const; //显示窗口
};
class WindowWithMe:public Window{
public:
void display() const override;
};
void show(Window w)
{
cout << w.name() << endl;
w.display();
}
当我们将派生类的对象传递给函数show时,w的静态类型为Window,编译器会调用Window的构造函数来构造w,尽管它实际上应该是个派生类对象,这个时候对象被切割了,导致display()显示的界面不是我们想要的。
但不是所有参数的传递都是传址更高效,比如内置类型、STL的迭代器、函数对象,采用的都是传值。
总结:
虽然传值的效率非常低,但我们不可一味用引用来代替传值,有时候我们会犯一个致命的错误:传递不存在的对象的reference。在任何时候我们看到reference时,都应该立刻问自己它是谁的别名?
我们有一个表现有理数的类Rational。
class Rational{
private:
int n,d; //分子,分母
friend const Rational operator*(const Rational& lhs,const Rational& rhs)
{ //函数①
return Rational(lhs.n * rhs.n,lhs.d * rhs.d);
}
friend const Rational& operator*(const Rational& lhs,const Rational& rhs)
{ //函数②
Rational temp(lhs.n * rhs.n,lhs.d * rhs.d);
return temp; //函数结束时已经被销毁
}
friend const Rational& operator*(const Rational& lhs,const Rational& rhs)
{ //函数③
Rational *temp = new Rational(lhs.n * rhs.n,lhs.d * rhs.d); //在哪里delete?
return *temp;
}
friend const Rational& operator*(const Rational& lhs,const Rational& rhs)
{ //函数④
static Rational temp;
temp = Rational(lhs.n * rhs.n,lhs.d * rhs.d);
return temp;
}
}
若我们以函数①来实现*运算符,返回const Rational
类型,显然需要进行一次拷贝,有构造和析构上的时间开销。
但若我们返回const Rational&
类型,需要注意的是,我们依然要创建一个对象来存储相乘的结果。并且如函数②所示,局部变量在函数结束之前被销毁,我们返回的是一个被销毁的对象的引用。倘若我们不将它设置成局部变量,而将它建立在堆上,如函数③所示,这时出现了一个新的问题:何时将它delete掉?如果出现以下代码,则会造成资源泄露。
Rational w,x,y,z;
w = x * y * z;
// 这里使用了两次*,new了两个对象,但是没有合理的方式取得new的指针进行资源释放
//而程序员也往往不记得进行资源释放。
这样我们自然而然想到,可以用static变量来解决局部变量被销毁的问题,如函数④所示,但如果是出现以下代码if((a * b) == (c * d))
,那么if语句将永远为true,虽然它们都改变了static变量的值,但由于传回的是static的引用,所以它们一直是相等的。
总结:
为什么不将成员变量声明为public?答案显而易见:封装。
public意味着不封装,将成员变量直接暴露给客户,若某一成员变量删除或修改,客户代码将进行大面积的修改。
如果成员变量不是public,客户唯一能访问到成员变量的方式是通过函数。这样一来,我们保护了成员变量只允许使用函数访问它们,并且可以自有变更函数的实现代码而无需更改客户代码。
某些东西的封装性 与 其内容改变时可能造成的代码破坏量 成反比,成员变量的封装性与成员变量改变时代码破坏量成反比。假设我们使用了一个public变量,而最终取消了它,那么所有使用它的客户代码都被破坏;如果我们使用了一个protected变量,而最终取消了它,那么所有使用它的派生类代码都会被破坏。因此,protected成员与public成员一样缺乏封装性。
总结:
假如有一个类WebBroser,类内有三个成员函数来清除一些记录。
class WebBroser{
public:
void clearCache(); //清除缓存
void clearHistory(); //清除历史记录
void clearCookie(); //清除所有cookie
};
而一些用户想要一整个进行清除操作,这就有两种实现方法,non-member与member。
//member函数
class WebBroser{
public:
...
void clearAll()
{
clearCache();
clearHistory();
clearCookie();
}
};
//non-member函数
void clearAll(WebBroser& w)
{
w.clearCache();
w.clearHistory();
w.clearCookie();
}
那么哪一个比较好呢?member函数还是non-member函数呢?答案是non-member函数较好,因为它具有更好的封装性。
在上一个条款中,我们已经了解到,将成员变量设为private是为了更好的封装。那么就只有成员函数和友元函数可以访问数据。而越多的函数可以访问数据,数据的封装性就越低。member函数可以访问到类的私有数据成员、私有函数、enum等等,而non-member函数都无法访问到。
再者,一个类可能会有多个便捷操作,如WebBroser类可能会有与书签有关的、与打印有关的、与Cookie管理有关的等等,而很多时候,用户并不想使用全部的便捷操作,可能只想使用一种,这个时候,我们就可以 将每类操作定义在同一命名空间下的不同的头文件内,使客户只对他们所用的那一小部分系统形成编译相依,也可以方便客户扩展这一组便利函数。
//头文件"webbrowser.h"
namespace WebBrowserStuff{
class WebBroser{};
... //核心机能,客户都需要的
}
//头文件"webbrowserbookmarks.h"
namespace WebBrowserStuff{
... //与书签相关的便捷函数
}
//头文件"webbrowsercookies.h"
namespace WebBrowserStuff{
... //与cookie相关的便捷函数
}
这正是C++标准程序库的组织方式,在std命名空间内,有数十个头文件< vector > < map >等,每个头文件声明std的某些机能。如果我们只想要使用vector,那只需要引入vector头文件即可。
当客户想要增加便利函数时,直接向相应的头文件内加入即可,而若是将函数声明为member函数,客户没有权利向class中添加成员函数。
总结:
因篇幅问题不能全部显示,请点此查看更多更全内容