4. 类成员函数(改变第2种的) 设计类改变成员变量的成员函数,需要考虑的因素非常多,但是这些因素大致可以分为两类:一类是比较通用的,另一类呢就是有类体系的前提; (1)是否真需要成为成员函数 (2)是否有必要返回对象?如果有必要返回对象,那么不要返回其引用 (3)函数参数宁以pass-by-reference-to-const传递替换pass-by-value (4)是否需要提供一些适合类操作的运算符?如果是,那么提供哪些运算符重载是合理的? (5)类型是否需要提供转换? (6)类成员函数是否与类继承关系有关?如果有,什么样的继承关系?如何审慎应用继承关系 (1)~(3)应该可以被视为是第一类,通用性的考虑因素;后三者则该被视为是与类层级关系有关系的考虑因素; (1)是否真的需要称为成员函数
C++代码 代码摘自《Effective C++》条款23 class WebBrowser { public: ... void clearCache(); void clearHistory(); void removeCookies(); ... void clearEverything(); }; 在上述的代码中表示了一个WebBrowser类,在这样一个类中提供了很多成员操作函数,如clearCache(), clearHistory(), removeCookies()等,其中许多用户想提供一个整体化执行这些上述三个操作的函数,因此又提供了一个clearEverything()的类成员函数; 当然,这个功能可以由另一个非成员函数调用适当的成员函数而实现: C++代码 void clearBrowser(WebBrowser& wb){ wb.clearCache(); wb.clearHistory(); wb.removeCookies(); } 那么上面这两种实现,哪个更好呢?或许有些人会选择clearEverything()成员函数更好些,因为这样更符合面向对象设计原则(数据与数据操作绑定在一起)。实际上,这个选择并不是遵守想象中这条设计原则,因为这个选择带来了别clearBrowser更差的封装性。那么为什么这么说呢? 当一个类的提供了很多的成员函数可以用于操作或者改变private成员变量,那么意味着这个类没什么封装性可言。就此而言,clearBrowser()因为不是成员函数,并未给客户提供增加操作类私有成员变量的可能性,因此封装性比clearEverything()好。更重要的一点是non-member函数,或者non-friend函数可以为类设计提供更好包裹弹性(package flexibility)。 再考虑下面的代码 C++代码 代码摘自《Effective C++》条款24 class Rational { public: Rational(int numerator = 0, int denominator = 1); int numerator() const; int denominator() const; const Rational operator* (const Rational& rhs) const private: ... }; Rational oneEighth(1,8); Rational oneHalf(1,2); Rational result = oneHalf * oneEighth; //good result = result * oneEighth; // good result = oneHalf * 2; //good, but implicit type conversion occurs result = 2 * oneHalf; //error! oneHalf是一个内含operator*函数的class的对象,所以编译器调用该函数。但是当整数2并没有相应的class,也就没有operator*成员函数。编译器也会尝试寻找可被调用的non-member operator*,可惜的是也没有;因此报错了。 如果上述的operator*成员函数以non-member函数的形式提供 C++代码 const Rational operator*(const Rational& lhs, const Rational& rhs){ return Rational(lhs.numerator() * rhs.numerator(), lhs.denomenator() * rhs.denominator()); } 这样上述result = 2 * oneHalf就通过编译了。 因此根据上述两个例子,考虑函数是否真的有必要称为成员函数; 《Effective C++》条款23, 24: 条款23:宁以non-member, non-friend替换member函数 条款24:若所有参数皆需类型转换,请为此采用non-member函数 (2)是否有必要返回对象?如果有必要返回对象,那么不要返回其引用 C++代码 //代码摘自《Effective C++》条款21 const Rational& operator*(const Rational& lhs, const Rational& rhs){ Rational result(lhs.n*rhs.n, lhs.d*rhs.d); //on-stack return result; } const Rational& operator(const Rational& lhs, const Rational& rhs){ Rational *result = new Rational(lhs.n*rhs.n, lhs.d*rhs.d); //on-heap return *result; } 注意引用的语义只是另一个既有对象的别名;如果那个既有的对象已经被销毁了,或者其他的原因已经不存在了,再进行引用招致不必要的风险了。所以返回引用时,一定是在函数外已经创建,且函数可见的变量或者对象,而不是函数体内local的on-stack或者on-heap创建的函数局域变量或者对象;这是很危险的操作! (3)函数参数宁以pass-by-reference-to-const传递替换pass-by-value C++默认方式下,是通过pass-by-value传递函数参数的,因此都是函数参数默认情况以传递的参数为初始值,调用参数类型的拷贝构造函数构造一个副本,因此操作起来成本可谓是十分昂贵的。 C++代码 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; }; 值传递方式: C++代码 bool validateStudent(Student s); Student plato; bool platoIsOK = validateStudent(plato); 每次以pass-by-value方式调用validateStudent的成本: 调用Student的拷贝构造函数一次 调用Person的拷贝构造函数一次 Student对象内包含的两个string对象 Person对象内包含的两个string对象 一旦使用完毕后,相应地还需要调用析构函数。因此总成本是6次构造函数,6次析构函数。 引用传递 C++代码 bool validateStudent(const Student& s); 这种方式避免了不必要的6次构造函数和6次析构函数成本;但是注意到上述的引用传递中,更重要的是使用了const修饰了引用传递的参数;因为在值传递中,函数实际上是对参数的副本做操作,而引用传递中,函数直接操作参数本身,并非副本。为了避免函数对传进的参数有改动,使用const修饰,就不必担心validateStudent是否会改变传入的那个参数了。 另外以引用传递方式传递参数还可以避免对象切割问题。对于某些内置类型而言,pass-by-value实际上有可能比pass-by-reference效率更高些,为什么呢? 指针的调用的成本比起一些内置类型的拷贝成本更高,取决于机器instruction的位数,以及内置类型的设定长度。 (4)是否需要提供一些适合类操作的运算符?如果是,那么提供哪些运算符重载是合理的? 用户可以重载的运算符 C++代码 + - * / % ^ & | ~ ! = < > += -= *= /= %= ^= &= |= << >> >>= <<= == != <= >= && || ++ -- ->* , -> [] () new new[] delete delete[] 用户不可以重载的运算符 C++代码 :: //scope resolution . //member selection .* //member selection through pointer to member 二元运算符与一元运算符 × 一个二元运算符可以定义为取一个参数的非静态成员函数,也可以定义为取两个参数的非成员函数。 × 一个一元运算符可以定义为无参数的非静态成员函数,也可以定义为取一个参数的非成员函数; 那么根据上述运算符的定义,除了考虑需要提供哪些合理的类运算符之外,更需要考虑的就是这类运算符的提供应该以non-member的方式提供,还是以member的方式提供;(参考(1)) 另外,那就是需要注意一些特殊意义的运算符以及相关的注意事项; I. operator=返回一个reference to *this; 需要注意处理"自我赋值" II. operator(); // function call I. operator= 返回一个reference to *this; 需要注意处理"自我赋值" C++代码 int x, y, z; x = y = z = 15; 为了实现上面的“连续赋值”,赋值运算符必须返回一个reference指向运算符左侧实参; 当然这个不仅适用于以上标准赋值形式,也适用于所有赋值相关运算。 C++代码 class Widget { ... }; Widget w; ... w = w; 或许上面的代码已然让我们感觉到惊讶!怎么会存有这样的赋值呢?当然会有,下面的这个就比较隐蔽了 C++代码 a[i] = a[j]; //i==j 如果运用对象管理资源,而且你可以确定所谓“资源管理对象”在copy发生时有正确的举措。这种情况下赋值运算符或许是“安全”的,不需要额外小心。但是如果在尝试自行管理资源时,很可能会掉进“在停止使用资源前意外释放了它”的陷阱。看下面的code C++代码 class Bitmap { ... }; class Widget { ... private: Bitmap *pb; }; Widget& Widget::operator=(const Widget& rhs){ delete pb; pb = new Bitmap(*rhs.pb); return *this; } 如果上面的operator=函数内的*this和rhs是一个对象的话,那么delete不仅仅销毁了当前的pb,而且删除了rhs.pb;想象这样的后果会怎样?解决方法可以是identity test,也可以是copy and swap。 C++代码 Widget& Widget::operator=(const Widget& rhs){ if(this == &rhs) return *this; //identity test delete pb; pb = new Bitmap(*rhs.pb); return *this; } /* * below is copy and swap */ class Widget { ... void swap(Widget& rhs); ... }; Widget& Widget::operator=(const Widget& rhs){ Widget temp(rhs); swap(temp); return *this; } II. operator() // 函数调用 相当于一个二元运算符@ : expression @ expression-list 重载函数调用运算符非常有用,提别是对定义那些只有一个运算的类型,和那些具有某个主导运算的类型。 函数调用运算符最明显或许也是最重要的应用是为了对象提供常规的函数调用语法形式,使它们具有像函数一样的行为方式。一个行为像函数的对象常被称为函数对象。 C++代码 class Add { complex val; public: Add(complex c):val(c){} Add(double r, double i){val=complex(r,i);} void operator()(complex& c) const { c += val; } }; void h(vector<complex>& aa, list<complex>& ll, complex z){ for_each(aa.begin(), aa.end(), Add(2,3)); for_each(ll.begin(), ll.end(), Add(z)); } (5)类型是否需要提供转换? 原则上我们并不希望转换,如果需要转换,那么尽量缩小转换的使用范围。其他原则同(4)。注意隐式类型转换。 (6)类成员函数是否与类继承关系有关?如果有,什么样的继承关系?如何审慎应用继承关系 继承关系在C++语言中,十分复杂。继承关系也有如下种类: I.公有继承【is a的关系,适用于base类的每件事也可以应用到其衍生类】 II.保护继承 III.私有继承【is implementated in terms of a 】 IV.接口继承【纯虚函数】 V.实现继承【非纯虚函数、已经实现的成员函数】 VI.多重继承 VII.虚继承 【钻石继承,虚基类】 在具体说到设计成员函数的考虑之前,有必要逐一说明一下以上的几种继承关系; I. 公有继承 C++代码 class Bird { public: virtual void fly(); ... }; class Penguin: public Bird { ... }; C++代码 class derived_class_name:public base_class_name 即表示公有继承; 切忌,在C++的语义中,公有继承表示着“is a”的逻辑关系。 如上述代码通过公有继承表示这样一个逻辑关系:“企鹅是一种鸟类“;虽然从动物学分类的角度看,这种逻辑关系是正确的,但是从C++语义上不仅仅单纯地表示了企鹅是一种鸟类,而且还表现出了更深一层的语义,那就是”企鹅会飞”这样一个虚假的事实,即使这是违背程序员意志的一种错误语义。 因此,在面向对象程序设计中涉及公有继承关系时,需要知道C++的语义不仅仅单纯地表示了“is a"这样一个逻辑关系,还表示了更深一层的语义:即基类可完成的动作,同样地可以应用到其子类中,因为每个衍生类对象也都是一个基类对象。 II. 保护继承 C++代码 class drived_class_name:protected base_class_name 表示保护继承; 保护继承表现的逻辑关系不像公有继承和私有继承那样十分明确。从语义上说,这种继承关系允许子类知道它与基类的继承关系; III. 私有继承 C++代码 class drived_class_name:private base_class_name 表示私有继承; 私有继承表现的逻辑关系是”is implemented in terms of"。 在C#,JAVA语言中,默认的继承关系只有公有继承;(虽然通过compostion的方式可以表现出私有继承的逻辑关系) IV. 接口继承与实现继承 C++中并没有abstract关键字表示该类是抽象类;但是如果类中含有纯虚函数,那么该类就是抽象类。 C++代码 class Shape { //abstract class public: virtual void rotate(int) = 0; //pure virtual function virtual void draw() = 0; // pure virtual function virtual bool is_closed() = 0; //pure virtual function // ... }; 抽象类不能实例化,因此必须通过继承,在子类覆盖纯虚函数后或者提供纯虚函数的实现,才可实例化子类对象。那么在抽象类中的纯虚函数就是接口继承,而非纯虚函数或者已经提供实现的函数就是实现继承。 JAVA,或C#语言中的interface关键字定义的接口类十分类似C++的虚基类(即只有纯虚函数声明的抽象类)。JAVA或C#的一个类可实现多接口,实际内部上就是多重继承。为什么这么说呢?原因在虚继承一节中具体说明。 VI.多重继承 多重继承,顾名思义,一个子类(衍生类)可以继承多个基类;例如: C++代码 class File { ... }; class InputFile: public File { ... }; class OutputFile: public File { ... }; class IOFile : public InputFile, public OutputFile { ... }; 多重继承在一些特定的应用场景下,的确给我们带来了好处,但是总是有弊的一面,如果继承的基类中,有名称相冲突的成员函数,或者成员变量,都会导致子类在使用过程中的歧义。另外,像上面的代码中所表示的,IOFile中含有了两套File类的成员变量,这显然导致了不必要的空间浪费。因此,C++又引入了虚继承; VI.虚继承 【钻石继承,虚基类】 C++代码 class File { ... }; class InputFile: virtual public File { ... }; class OutputFile: virtual public File { ... }; class IOFile: public InputFile, public OutputFile { ... }; 虚继承解决了因多重继承招致的基类成员变量重复的问题。但是虚继承也带来更复杂的问题与后果: 虚继承的那些实例化的类对象通常体积要比那些非虚继承产生的类对象要大访问虚基类的成员变量时,也比访问非虚基类的成员变量速度慢虚基类的成本还包括其他方面。支配虚基类初始化的规则比起非虚基类的情况更复杂且不直观。 缺省情况下,虚基类的初始化责任是由继承体系中的最低层的衍生类负责,这暗示了若派生自虚基类而需要初始化,必须认知虚基类,不论那些虚基类距离多远。同时也暗示了当一个新的派生类加入到继承体系中,它有可能承担其虚基类的初始化责任。 注意,JAVA和C#的实现多接口的机制其实质就是多重虚继承;为了避免纯虚基类初始化责任,因此JAVA和C#不允许在接口类中含有任何数据。 通常前三种继承可以与后四种进行组合搭配起来。(哇!C++在这点上,为程序开发人员提供了很大的灵活性,但是作为程序开发人员来讲,必须为这种灵活性付出代价!增加了学习曲线和复杂度。不过这种复杂度一旦under control,那变庖丁解牛,游刃有余了!似乎这也是很多C++高手们骄傲的地方)。 书归正传,成员函数的设计一定要注意到所在类在类层级结构中扮演的角色,然后再考虑继承关系属于上述哪几种;最后,在特定继承关系下,考虑应该如何避免一些歧义的产生,如何避免高额的成本等等。以下请参考《Effective C++》提供一些考虑因素 如果是基类的成员函数,那么是否需要用virtual声明?如果需要用virtual来声明能否可以用其他的选择替代virtual?(别忘记基类中应该提供virtual的析构函数)如果是衍生类中的成员函数,那么该成员函数是否是因为继承关系得来的?如果是,继承得来的是接口还是实现?如果是实现,则绝不要重新定义继承而来的函数实现,也绝不重新定义继承而来的缺省参数值。(参考《Effective C++》条款36,37)如果衍生类中成员函数与继承得来的成员函数名称相同,尽量避免遮掩继承来的名称;(参考《Effective C++》条款33)