关于接口的设计与声明--对封装性的理解[C++]
设计与声明
所谓软件设计,是“令软件做出你希望它做的事情”的步骤和方法,通常以颇为一般性的构想开始,最终十足的细节,以允许特殊接口(interface)的开发。这些接口而后必须转换为c++声明式。本文讨论对良好c++接口的设计和声明。
1. 让接口容易被正确使用,不易被误用
c++拥有许多的接口,function接口,class接口,template接口….每一种接口实施客户与你的代码互动的手段。理想情况下,客户总是会准确的使用你的接口并获得理想的结果,而如果客户错误的使用了接口,代码就不应该通过编译。
用结构体限制参数类型
假设我们现在需要做一个表示时间的class
class date { public: date(int month, int day, int year); ... };
乍看起来,这个类的构造函数并没有什么问题,但其实存在着很多的隐患。我们当然希望用户可以准确的使用我们的类,但用户却有可能因为某些特定的原因无法正确使用我们的类,例如没有按照月,天,年的顺序来完成构造。而此时,为了避免用户犯错,我们需要强制用户按照我们的设计来用这个类:
// special design // 缺省情况下,struct内部都是public访问限制。 struct day { explicit day(int d) : val(d) { } int val; }; struct month { explicit month(int m) : val(d) { } int val; }; struct year { explicit year(int y) : val(d) { } int val; }; class date { public: date(const month &m, const day &d, const year &y); ... }; date d1(30, 3, 1996); // error! date d2(month(3), day(30), year(1996)); // right!
用struct来封装数据,可以明智而审慎地导入新类型并预防“接口被误用”。
一旦类型限定了,限定其值也是合情合理的了。例如一年只有12个月,所以month应该反映这一点。办法之一就是用enum表现月份,但enum不具备我们希望的类型安全性,例如enum可以被当做一个int使用。比较安全的做法是:预先定义所有有效的month。
class month { public: static month jan() { return month(1); } static month feb() { return month(2); } .... static month dec() { return month(12); } private: explicit month(int m); .. }; date d(month::mar(), day(30), year(1996));
以函数替换对象,表现某个特定的月份是一种相当不错的方法。
限制类型内什么能做,什么不能做
除非有更好的理由,否则尽量让你的type的行为与内置type一致!
用户很清楚像int这样的type有什么行为,所以你应该努力让你的type在合情合理的前提下也有相同的操作。例如,如果a和b都是int,那么对a*b赋值就是不合法的。
避免无端与内置类型不兼容,真正的理由是为了==提供行为一致的接口==。很少有其他性质比”一致性“更能导致”接口被正确使用“,也很少有性质比得上”不一致性“更加剧接口的恶化。
2. 设计class犹如设计type
新type的对象应该如何被创建和销毁?这回应该到你如何设计class的构造函数和析构函数以及内存分配函数和释放函数。 对象的初始化和对象的赋值有什么样的差别?这决定了你如何设计构造函数和赋值操作符。最重要的是别混淆“初始化”和“赋值”,因为他们对应不同的函数调用。 新type对象如果被passed by value,意味着什么?记住,copy构造函数用来定义一个type的pass by value如何实现。 什么是新type的“合法值”?这意味你的成员函数必须进行错误检查工作,也影响了函数抛出的异常、以及函数异常明细列。 你的type需要配合某个继承体系吗?如果你继承自某些既有的class,你就会受到这些class设计的束缚,特别是受到他们的函数是virtual或non-virtual的影响。如果你允许你的class被其他class继承,那会影戏到你的析构函数是否会virtual。 你的新type需要什么样的转换?因为你的type存在于其他大量的type之间,这决定了你是否需要让自己type有途径转换为其他的type(隐式还是显式的?) 什么样的操作符和函数对此新type而言是合理的?这取决于你的成员函数的设计。 什么样的标准函数应该驳回?那些就是你声明为private的对象。 谁该取用新type的成员?这决定了如何安排函数是public,protected还是private,以及那些函数/类是friend。 什么是新type的“未声明接口”?他对效率、异常安全性以及资源运用提供何种保证? 你的新type有多么一般化?如果你并不是为了定义一个新type而是要定义一整个type家族,那么应该定义一个新的class template。 你是否真的需要一个新的type?如果你只是为了给base class添加某些功能,那么定义一个或多个non-member 函数或template,更好。c++就像其他oop语言一样,当你定义一个新class,也就定义了一个新的type。包括,重载函数和操作符、控制内存的分配和归还、定义对象的初始化和析构……全都在你控制,因而你应该带着和“语言设计者当初设计语言内置类型时”一样的谨慎来设计class。以下给出了部分class设计规范。
设计class是一件非常具有挑战的事情,所以如果你希望设计一个class,最好像设计一个type一样,把各种问题都思考一遍。
3. 宁以pass by reference to const替换pass by value
在缺省情况下c++总是以pass-by-value的方式传递对象至函数,实际上,就是传递复件,而这些复件都是由copy构造函数产生的,这可能使得pass-by-value称为昂贵而耗时的操作。
问题产生
class person() { public: person(); virtual ~person(); ... private: std::string name; std::string address; }; class student : public person { public: student(); ~student(); ... private: std::string schoolname; std::string schooladdress; }; // in main: bool checkstudent(student s); student one; bool whoh = checkstudent(one);
在checkstudent调用时,发生了什么?
这显然是一个pass-by-value的函数,也就意味着一定会出现copy构造函数,对于此函数而言,参数的传递成本是“一次student copy构造函数调用,加上一次student析构函数调用”。不仅如此,student还继承于person,所以还有一次person构造函数和person析构函数,以及student里面的两个string对象,和person里面的两个string对象,总而言之,总体成本就是“六次构造函数和六次析构函数!”多么可怕的开销!
问题解决
解决这个问题非常的简单。只要使用pass by reference to const就可以了。因为by reference不会导致构造函数和析构函数的使用,节省了大量开销,同时因为是const,也保证了参数不会再函数内被更改。
bool checkstudent(const student &s);
问题产生2
pass-by-value还会导致对象切割问题(slicing)。当一个dereived class对象以by value方式传递并被视为一个base class对象时,bass class的copy构造函数就会被调用,而“造成此对象的行为像个derived class对象”的那些特化性质全部被切割掉,只剩下base class对象。这并不奇怪。
class window { public: ... std::string name() const; virtual void display() const; }; class specialwindow { public: .. virtual void display() const; }; .... // in main: void print(window w) { cout << w.name(); w.display(); }
当你把一个specialwindow对象传递给void print(window w)函数时,就像前文所说的,会使得specialwindow的特化性质全部被切割掉,于是乎,你本想着输出specialwindow的特别内容结果只输出了window内容。
问题解决2
解决这个问题仍然是使用reference。由此来引发动态绑定,从而使用specialwindow的display。
void print(const window& w) { cout << w.name(); w.display(); }
总结
窥视c++编译器的底层就会发现,实际上reference就是以指针实现出来了,pass by reference通常意味着真正传递的是指针。因此,如果你有个对象属于内置类型(如int),pass-by-value通常来说效率会更好。这对于stl的迭代器和函数对象同样适用。因为习惯上他们都是设计为pass-by-value。迭代器和函数对象的实践者都有责任看看他们是否高效且不受切割问题。
有人认为,所有小型type对象都应该适用pass-by-value,甚至对于用户定义的class。实际上是不准确的。第一,对象小,并不意味着他的copy构造函数开销小;2)即使是小型对象并不拥有昂贵的copy构造函数,也可能存在效率上的问题,例如某些编译器不愿意把只由一个double组成的对象放进缓存器,但如果你使用reference,编译器一定会把指针(就是reference的实现体)放进缓存器。3)作为用户自定义类型,其大小是很容易被改变的。随着不断的使用,对象可能会越来越大。
一般而言,合理假设“pass-by-value更合适”的唯一对象就是内置类型和stl的迭代器和函数对象,其他的最好还是使用by reference。
4. 必须返回对象时,别妄想返回其reference
前面我们讨论了pass-by-reference可以提高效率,于是乎,有的人就开始坚定地使用reference,甚至开始传递一些refereence指向其实并不存在的对象。
问题产生
此问题产生的理由非常的简单,就是作者希望可以节省开销提高效率。并因此而产生大量的错误。
class rational { public: rational(int num1 = 0, int num2 = 1); ... private: int n1, n2; friend rational& operator*(const rational& lhs, const rational& rhs);
operator*试图返回一个引用,并为此寻找合乎逻辑的实现代码。
尝试1:直接返回
rational& operator*(const rational& lhs, const rational& rhs) { rational result(lhs.n1 * rhs.n1, lhs.n2 * rhs.n2); return result; }
问题显然。因为result是一个on the stack对象,在作用域结束后,对象就被销毁,于是返回了一个没有指向的reference。尝试失败!
尝试2:返回on the heap对象
rational& operator*(const rational& lhs, const rational& rhs) { rational* result = new rational(lhs.n1 * rhs.n1, lhs.n2 * rhs.n2); return *result; }
此代码乍看起来似乎没什么问题,但其实隐含杀机。你在函数中动态申请了一块内存放这个变量,这也就意味着你必须管理这块资源(见前文:资源管理)。然而管理这块资源几乎不可能,因为你不可能希望在main函数里一直有一个变量在守着这块资源并且及时的delete掉。而且当大量使用*操作符时,管理大量的资源根本不可能!就算你有这样的毅力这么管理,也不可能希望有用户愿意做这样的体力活。
尝试3:使用static变量
rational& operator*(const rational& lhs, const rational& rhs) { static rational result(lhs.n1 * rhs.n1, lhs.n2 * rhs.n2); return result; }
这代码乍看起好像又要成功了?!其实并没有。问题出现的十分隐蔽:
bool operator == (const rational& lhs, const rational& rhs); if ((a*b) == (c*d)) { ... } else { ... }
问题就出在等号操作,等号永远会成立!因为,在operator == 被调用前,已有两个操作符被调用,每一个都返回操作函数内部的static对象,而这两个对象实际上就是一个对象!(对于调用端来说,确实如此!)于是乎,你根本就没有完成*操作符所应该具备的功能。
问题解决
问题的解决就是,别挣扎了!使用pass-by-value吧。不就是一点构造函数和析构函数的开销嘛。比起大量的错误和内存的管理。这点开销还是很划算的。
class rational { public: rational(int num1 = 0, int num2 = 1); ... private: int n1, n2; friend rational operator*(const rational& lhs, const rational& rhs) { return rational(lhs.n1*rhs.n1, rhs.n2*rhs.n2); }
5. 将成员变量声明为private
理由一:语法一致性。在我们最初学习c++ oop时就有一天准则,成员变量总是要声明为private。本节我们来讨论为何成员变量要被声明为private。
因为成员变量不是public,客户唯一能够访问对象的办法就是通过成员函数。如果public接口内的每一样东西都是函数,客户就不用纠结调用他时是否需要使用小括号。如此便能省下大量的时间。 理由二:使用函数可以让你对成员变量的处理有更准确的控制。
如果成员变量是public,那么每个人都可以对他进行读写,但如果你以函数取得或设定其值,就可以实现“不准访问”,“只读访问”,“读写访问”等访问控制。
如以下代码:
class accesslevel { private: int noaccess; int readonly; int writeonly; int readwrite; public: // ... int getreadonly() { return readonly; } void setwriteonly(int i) { writeonly = i; } void setreadwrite(int i) { readwrite = i; } int readreadwrite() { return readwrite; } };
如此精细地对各个数据成员进行访问限制是有必要的。
理由三:封装!这是最有说服力的理由了!c++ oop其中最重要的一条性质就是封装性!将数据成员封装在接口的后面,可以为“所有可能的实现”提供弹性。
封装的重要性比我们最初见到它时更重要。如果我们对客户隐藏成员变量,就可以确保class的约束条件受到维护,因为只有成员函数可以影响他们。public意味着不封装,而几乎可以说不封装意味着不可改变,特别是对被广泛使用的class而言。被广泛使用的class是最需要封装的一个族群,因为他们能够从“改采用一个教佳实现版本”中获益。
我们继续来讨论protected的封装性。
一般人会认为protected比public更具有封装性。其实不然。更准确的判断方法是:某些东西的封装性与“当其内容改变时可能造成的代码破坏量”成反比。所谓改变,也许是从class中移除他。于是乎,我们可以进行以下分析。对于public的成员变量,如果我们移除他,意味着我们要破坏所有使用它的客户代码。(破坏量很大吧?)而对于protected的成员变量呢,如果我们移除它,意味着要破坏所有derived class(破坏量也很大吧?)因此protected和public的封装性其实是一样的。这也就意味着,一旦我们决定把某个成员变量声明为public或protected,就很难改变某个成员变量所涉及的一切。
结论就是,其实只有两种访问权限:private(实现封装)和其他(不实现封装)
6. 宁以non-member、non-friend替换member函数
面向对象守则要求,数据以及操作数据的那些函数应该被捆绑在一起,这意味着它建议所有操作数据成员的函数都应该是member函数。然而事实上是如此吗?
问题产生
假设我们希望写一个类来描述网页:
class webbrowser { public: ... void clearcache(); void clearhistory(); void removecookies(); ... // 用户希望有一个函数能够清楚所有信息 // 问题是,该函数是否应该声明为member? void cleareverything(); }; // 也可以声明为non-member void cleareverything(webbrowser &web) { ... }
那么哪种选择更好呢?
问题解决
根据面向对象守则要求,声明为member函数应该是更好的选择。然而,这是对面向对象真实意义的一个误解。面向对象要求数据应该尽可能被封装,然而与直观相反地,member函数cleareverything带来的封装性比non-member函数的低。此外,提供non-member函数可允许对webbrowser相关机能有更大的包裹弹性,从而最终导致较低的编译相依度,增加webbrowser的可衍生性。以下我们给出理由。
封装性。愈多的东西被封装,越少人可以按到它,那么我们就有越大的弹性去改变它,而我们的改变只会影响看到改变的那些人和事物。这就是我们推崇封装性的原因:它使我们能够改变事物而只影响有限客户。 考虑对象内数据。越少代码可以看到数据,越多的数据可被封装,而我们也就越能自动地改变对象数据。越多的函数可以访问数据成员,数据的封装性就越差!因此,因为non-member non-friend函数不能直接改变数据成员,因此他就可以最大限度的实现封装。
解答优化
在c++中,最自然的做法,是让cleareverything称为一个non-member函数并且位于webbrowser所在的同一个namespace内:
namespace webbrowserstuff { class webbrowser {...}; void cleareverything(webbroswer &web); ... }
namespace和class是不用的!前者可以跨越多个文件而后者不能,这很重要!
像cleareverything这样的函数就是便利函数,虽然没有对webbrowser有特殊的访问权限,但可以极大的便利客户。而实际上,我们会补充大量的类似的便利函数,并且他们可能分属于不同的模块,于是我们便采用把不同模块便利函数写于不同的头文件中,但他们都隶属于同一个命名空间:
#include "webbrowser.h" 提供class声明本身,以及其中核心机能 namespace webbrowserstuff { class webbroser { ... }; ... // 核心机能,几乎所有用户都需要的non-member便利函数 } // 头文件 “webbrowserbookmarks.h" // 与标签相关 namespace webbrowserstuff { ... // 与标签相关的便利函数 } // 头文件 ”webbrowsercookies.h" namespace webbrowserstuff{ ... // 与cookie相关的便利函数 } ...
注意这是c++标准程序库的组织方式。标准程序库中并不是拥有单一、整体、庞大的
7. 若所有参数皆需类型转换,请为此采用non-member函数
令class支持隐式类型转换通常是个糟糕的注意。当然也有例外,例如你在建立数值类型时。
问题产生
假设我们需要设计一个有理数类:
class rational { public: rational(int numerator = 0, int denominator = 1); int numerator() const; int denominator() const; private: ... };
class rational { public: ... const rational operator*(const rational& rhs) const; }; // 于是乎可以轻松实现乘法 rational oneeighth(1, 8); rational onehalf(1, 2); rational result = onehalf * oneeighth; // 没问题 result = result * oneeighth; // 没问题
到目前为止还没有实现致命问题,然而:
result = onehalf * 2; // ok! result = 2 * onehalf; // error! // result = 2.operator*(onehalf); of course wrong!
第一个式子能够成立,是因为实现了隐式类型转换。编译器知道你在传递一个int,而函数需要的是rational,但它也知道只要调用rational构造函数并赋予你所提供的int,就可以变出一个适当的rational出来,于是就这么做了。相当于:
const rational temp(2); result = onehalf * temp;
当然这只涉及non-explicit构造函数,才能这么做。如果是explicit构造函数,这个语句无法通过编译。
问题解决
result = onehalf * 2; // ok! result = 2 * onehalf; // error!
只有当参数被列于参数列内,这个参数才是隐式类型转换的合格参与者。地位相当于“被调用之成员函数所隶属的那个对象”-即this对象-那个隐喻参数,绝不是隐式转换的合格参与者。这就是为什么语句1能够通过编译而语句2不可以。
于是,方法就是,让operator*称为一个non-member函数,允许编译器在每一实参身上执行隐式类型转换。
const rational operator*(const rational& lhs, const rational& rhs) { ... } rational onefourth(1, 4); rational result = onefourth * 2; // right! result = 2 * onefourth; // right!
补充思考:
是否应该把该operator*声明为friend?
答案是否定的!请注意,member的反面不是friend,而是non-member!在此代码中,operator*完全可以借由rational的public接口完成任务,于是便不必把他声明为friend。无论何时,如果可以避免friend函数就应该避免。
总结:
如果你需要为某个函数的所有参数(包括this)进行类型转换,那么这个函数必须是个non-member。
8. 考虑如何写出特化的swap函数
swap作为stl的一部分,而后成为异常安全性的脊柱,以及用来处理自我赋值可能性的一个常见机制。由于此函数如此有用,也意味着他具有非凡哥的复杂度。本节谈论这些复杂度以及相应处理。
问题产生1
namespace std { template void swap(t &a, t &b) { t temp(a); a = b; b = temp; } }
这是标准程序库提供的swap算法。非常地简单,只要t有copying相关操作即可。然而这个算法对于有些情况却显得不那么高效。例如,在处理“以指针指向一个对象,内含真正数据”的那种类型。(这种设计的常见形式是所谓“pimpl手法:pointer to implemention)
class widgetimpl { // 实现细节不重要。 public: // 针对widget设计的class ... private: int a, b, c; std::vector v; ... }; class widget { public: widget(const widget& rhs); widget& operator=(const widget& rhs) { ... *pimpl = *(rhs.pimpl); ... } private: widgetimpl* pimpl; };
对此类调用算法库的swap就会非常低效。因为他总共要复制三个widget和三个widgetimpl对象!而事实上,只需要改变指针的指向就可以了。
问题解决1
我们可能尝试用以下方法解决,让swap针对widget特化。
尝试一:
namespace std { template<> // 表示他是std::swap的一个全特化 void swap(widget &a, widget &b) { swap(a.pimpl, b.pimpl); } }
通常来说,我们是不能够改变std命名空间内的任何东西,但可以(被允许)为标准template制造特化版本的。
但实际上这个是无法通过编译的。因为他企图调用class的私有成员。
所以更合理的做法,是令他调用成员函数。
解法:
class widget { public: ... void swap(widget& other) { using std::swap; swap(pimpl, other.pinmpl); } ... }; private: widgetimpl* pimpl; }; namespace std { template<> void swap(widget &a, widget &b) { a.swap(b); } }
这个做法不仅能够通过编译,而且与stl容器有一致性。
问题产生2
假设widget和widgetimpl都是class template而非class,也许我们可以试试把widgetimpl内的数据类型加以参数化:
template class widgetimpl {...}; template class widget {...}; // 在widget里面放入swap成员函数就像以往一样简单 // 但在写特化std::swap时出现了问题 namespace std { template void swap< widget > (widget& a, widget& b) { a.swap(b); } }
以上特化swap其实有问题的。我们企图偏特化这个function template,但c++只允许对class template偏特化。(随后会介绍全特化和偏特化)。当你尝试偏特化一个function template时,更常见的做法是添加重载函数:
namespace std { template void swap(widget& a, widget& b) { a.swap(b); } }
但实际上,这也是不行的!因为std是个特殊的命名空间,其管理规则比较特殊,客户可以全特化std内的template,但不可以添加新的template到std里面。
问题解决2
解决这个问题的方法就是,声明一个non-member swap让它调用member swap,但不在将那个non-member swap声明为std::swap特化版或重载版本。
namespace widgetstuff { template class widgetimpl {...}; template class widget {...}; ... template void swap(widget& a, widget& b) { a.swap(b); } }
现在,任何时候如果打算置换两个widget对象,因而调用swap,c++的名称查找法则都会找到widgetstuff内的widget专属版本。
这个做法对class和class template都行得通。如果你想让你的”class“专属版swap在尽可能多的语境下被调用,你需要同时在该class所在命名空间内写一个non-member版本以及一个std::swap特化版本。
另外,如果没有像上面那样额外使用某个命名空间,上述每件事情仍然使用。但你又何必再global命名空间里面塞这么多东西呢?
补充思考
目前提到得都是和swap编写有关的。现在我们换位思考,从客户观点看看问题。假设我们需要写一个function template:
template void dosomething(t& obj1, t& obj2) { ... swap(obj1, obj2); ... }
此时swap是调用哪个版本呢?我们当然希望是调用t专属版本,并且在该版本不存在的情况下,调用std内的一般化版本。
template void dosomething(t& obj1, t& obj2) { using std::swap; ... swap(obj1, obj2); // 为t类型对象调用最佳swap版本。 ... }
c++名称查找法则确保将找到global作用域或t所在命名空间内的任何t专属的swap。如果t是widget并位于命名空间widgetstuff内,编译器会使用”实参取决之查找规则“找出widgetstuff内的swap。如果没有t专属之swap存在,编译器就使用std内的swap。
以下是我设计的一个不大合乎逻辑的代码,但证明了上述说法是合理的。
#include using namespace std; namespace test { class trys { public: void swap(trys &one, trys &two) { cout << "yes!" << endl; } }; void swap(trys &one, trys &two) { cout << "yes!" << endl; } } int main(int argc, const char * argv[]) { // insert code here... test::trys a; int b = 12; { using std::swap; swap(b, b); swap(a, a); } return 0; } /* yes! program ended with exit code: 0 */
总结:
如果swap缺省实现版的效率不足,(那几乎意味着你的class或template使用了某种pimpl手法),可以试着做以下事情:
提供一个public swap成员函数,让他高效地置换你的类型的两个对象值。 在你的class或template所在的命名空间内提供一个non-member swap,并命它调用上述swap成员函数。 如果你在编写一个class,并为你的class特化std::swap,并令他调用你的swap成员函数。最后,如果你调用swap,请确保包含一个using声明式。
补充内容:(全特化和偏特化)
模板为什么要特化,因为编译器认为,对于特定的类型,如果你能对某一功能更好的实现,那么就该听你的。
模板分为类模板与函数模板,特化分为全特化与偏特化。全特化就是限定死模板实现的具体类型,偏特化就是如果这个模板有多个类型,那么只限定其中的一部分。
先看类模板:
template class test { public: test(t1 i,t2 j):a(i),b(j){cout<<"模板类"< class test { public: test(int i, char j):a(i),b(j){cout<<"全特化"< class test { public: test(char i, t2 j):a(i),b(j){cout<<"偏特化"<
那么下面3句依次调用类模板、全特化与偏特化:
test t1(0.1,0.2); test t2(1,'a'); test t3('a',true);
而对于函数模板,却只有全特化,不能偏特化:
//模板函数 template void fun(t1 a , t2 b) { cout<<"模板函数"< void fun(int a, char b) { cout<<"全特化"< void fun(char a, t2 b) { cout<<"偏特化"<
至于为什么函数不能偏特化,似乎不是因为语言实现不了,而是因为偏特化的功能可以通过函数的重载完成。