C++我们必须要熟悉的事之具体做法(3)——类的设计与声明
1. 让接口被正确使用
最重要的方法是:保持与内置类型的一致性。
方法1:外覆类型(wrapper types)
例如在需要年月日时,使用
struct day {
explicit day(int d) : val(d) { }
private:
int val;
};
方法2:函数替代对象
class month {
public:
static month jan() { return month(1); }
…
private:
explicit month(int); //禁止生成新的月
…
};
month::jan();等等
方法3:返回至限制为cont作为右值
方法4: 返回指针时,返回shared_ptr类型
testclass *create();虽然使用shared_ptr可以避免delete,但是最好像这样申明。
shared_ptr<testclass> create();
这个方法我们可以指定删除器,还可以“cross-DLL problem”(在一个DLL中new在另外一个DLL中delete)。
2. 设计类时就像设计一个type
类代表了一个新的类型和新的作用域。
需要考虑的问题:
(1) 是否需要新type
可能使用derived class、一个或多个non-member函数或者模板就能达到要求。
(2) 一般性
是否要作为class template。
(3) 新的类型如何创建和销毁
这影响类的构造函数、析构函数。
首先考虑我们需不需要自己写构造函数:当类含有普通的内置类型、指针时需要我们自己编写构造函数。
自己编写构造函数: 要考虑是否要构造基类、是否为explicit、参数传递是否const、是否要&。
往往当类:含有指针、需要复制资源时需要析构函数,我们可能可以通过智能避免需要自己写析构函数。
(4)对象的复制控制
copy构造函数的行为、copy assign的行为,这里需要考虑是否有继承,要复制基类的数据。
追重要的是考虑我们是否需要这两个函数。
若不需要我们就明确禁止它们。
若需要,则考虑默认实现是否能满足我们的要求,若可以则不必写。
一般当需要析构函数时,也需要它们两个。
(5) 是否需要转换
若禁止其他类型转化为本类类型,则可使单实参的构造函数为explicit。
若类需要转换为其他类型,考虑重载operator。
(6) 新类型的合法值
这个主要涉及到某些成员函数(构造函数、赋值操作符和setter函数等)的错误检测。
(7) 类的继承关系
若类继承了某些类或者作为基类,则需要考虑哪些成员函数需要为virtual、哪些不需要。
作为基类时,往往要使析构函数为virtual。
(8)考虑数据成员
哪些为public、protect、private。
非需要继承的都为private、否则protect。
是否static成员、是否const成员、是否是&。
(9) 考虑函数
哪些函数需要成为它的成员函数、哪些非成员、哪些函数和类是friend。
我们需要哪些成员函数,哪些作为public、哪些作为protect、private。
函数接口:是否为virtual、是否const成员函数、形参是否const是否要&、返回值是否const是否&。
(10) 未声明接口
对效率、异常安全性、资源运用提供什么保证,这些保证将为你的类加上相应的约束条件。
3. 考虑reference-to-const作为参数传递
在by value传递参数时,传递的是副本,对于类设计到类的复制构造函数、析构函数。带来额外的开销。
我们多数情况下应该by reference-to-const作为参数传递,这样有两个好处:
(1) 避免了复制和析构。提高了效率。const一般是必须的,这使我们不能更改参数,同时const&可以绑定到右值。
(2) 可以避免slicing(对象切割)问题。by value方式传递参数时,若派生类传递给基类时会发生切割,只传递了基类的部分。
误解:有的人认为小的对象应该使用by value方式传递。
理由:(1) 小类型复制构造函数代价不高比如一个指针,但是我们复制这种对象却要“复制那些指针所指的每样东西”,代价可能就高了。
(2) 某些编译器拒绝将用户自定义类型放入缓存中,可能降低效率。而引用往往是用指针实现的,传递引用通常意味着真正传递的是指针。而指针肯定会被放入到缓存中。
(3) 小型类型作为一个用户自定义的类型,其大小可能会变化,将来可能会变大。甚至在不同的编译器中大小可能都不同,如:string的不同实现在不同编译器中的大小可能不同。
但是有些参数适合by value方式传递:包括内置类型(c语言中就是这么做的)、STL迭代器(一个智能指针)、函数对象(一个定义了operator()的类)。
其他的往往都是传递引用比较好。
4. 不是所有函数都可以返回引用,该返回对象时就应该这么做!
引用指向并不存在的对象肯定会造成错误,例如指向函数内部的局部变量等等。
对于有些函数我们妄想返回引用,肯定是错误的。无论是指向内部new的对象(谁来delete的问题)、一个static变量(函数的多次调用结果却是相同的)等等。
这些函数往往的特征是需要满足参数满足交换率,例如+、*、==等等。
这些函数往往都是类的non member函数(因为要满足交换律,左右参数都需要实现隐式类型转换)、但是却是friend(需要访问了的成员函数)的函数。
它们只能返回对象不能返回引用,因为它们不是成员函数,没有this指针引用不能只想类内部的数据成员。
往往作为成员函数的函数可以返回引用,例如:&operator[], opreator*等等。
5. 类相关的函数何时成为non-member
这些相关函数往往就是类的需要operator的函数,往往是在所有参数都需要类型转换时成为non member函数,典型的是operator+、operator*(乘号)、operaotr==等等。
这些函数需要满足交换率,两边都需要隐式类型转换。而只有参数类表中的形参才会被执行隐式转换,this指针绝不会执行隐式类型转换。
我们往往令这些函数为non member函数,不需要是友元,而且若要访问成员变量而可通过成员函数,而且他们的构造函数必须不能是explicit。
同时也证明了:若不能成为member函数,应该作为non member函数,而不是成为friend函数。
但是在template编程中,opreator+等重载函数设为friend,但是目的却不是为了访问其数据成员,而是模板实例化所必须的。
6. 成员函数应该声明为private
此时通过成员函数访问数据成员成为唯一的方式,可以满足语法的一致性。
使用成员函数可以让你对成员变量的处理有更精确地控制。若成员变量为public这样就可能被无限多的函数访问它,我们就不能控制了。
最最重要的是:封装:将成员变量隐藏在函数接口的背后,可以为所有可能的实现提供弹性。当我们更改成员函数的不同实现形式时,不必重新修改函数接口,可以从一个较好实现中受益。
若果你对客户隐藏成员变量(就是封装它们),你可以确保class的约束条件总会获得维护,因为只有成员函数可以修改它们,确保了你日后变更实现的权利。
同样的道理也适用于protected,包括语法一致性、细微划分之访问控制和封装。
“成员的封装性”与“当其内容改变是可能造成的代码破坏量”成反比。
对于public成员变量,取消时所有使用它的客户码都会被破坏,只是一个不可知的量。
对于protected成员变量,所有使用它的derived类都会被破坏,往往也是一个不可知的量。
所以protected成员变量也想public一样缺乏封装性。
因此从封装的角度,只有两种权限:private(封装)和其他(不封装)。
7. 用non-member、non-member替换member函数
当可以用类的成员函数组合成一个功能函数时,或者说提供相同的功能时,是把这个函数作为non-member、non-friend更好,而不是member。
作为成员函数,则多了一个成员函数访问数据成员,降低了类的封装性。
数据的封装性可用:越多函数可访问它,封装性越低来粗略衡量。
我们可以将non-member函数可以放入多了头文件但是隶属同一个命名空间中。命名空间可以跨越多个源文件,客户可以扩充这种函数。
这也是STL的组织形式。
8.不抛出异常的swap函数
swap是异常安全性的脊柱、处理自我赋值的常用机制, 原本只是STL的一部分。
8.1. 最典型的实现为:
#include <utility>
template<typename T>
void swap(T &lhs, T &rhs)
{
T tmp(move(lhs)); //move语义,tmp值变成lhs的值,lhs变成默认构造下的值。一般对于内置类型不变,但是如string等会变为空“”。
lhs = move(rhs);
rhs = (tmp);
}
需要满足:copy构造函数、copy assignment操作符。
对于所有STL容器类型,都会有一个成员函数的swap,并在std中完全特化一个swap调用STL成员的swap。
例如:对于vector,内部会定义了一个成员函数
template<typename T>
void vector<T>::swap(vector<T> &rhs)
{
//仅仅交换内部指针
start = rhs.start;
finish = rhs.finish;
end_of_storage = rhs.end_of_storage;
}
而在std中会定义一个完全特化版本:
namespace std {
template<typename T>
void swap<vector>(vector<T> &lhs, vector<T> &rhs)
{
//调用内部版本
lhs.swap(rhs);
}
};
8.2. 普通类中实现swap
对于那种以指针指向一个对象,内含真正数据的类型,也就是使用“pimpl”(pointer to implementation)使用指针去实现的方式最需要自己的swap。
例如对于类:
class testclass {
public:
testclass(const testclass &rhs);
testclass& opreator=(const testclass &rhs);
private:
bigclass *pbc; //指针所指对象复制需要花时间
};
类似于STL的做法:
在类内定义成员swap(), 在std中定义特化版本。
对于std命名空间我们不允许改变空间内的任何东西,但是我们可以为标准模板(如swap)制造特化版本。
class testclass {
public:
void swap(testclass &rhs)
{
using std::swap;
swap(pbc, rhs.pbc); //置换对象我们只是置换指针
}
private:
bigclass *pbc; //指针所指对象复制需要花时间
};
namespace std {
//完全特化版本
template<typename T>
void swap<testclass>(testclass &lhs, testclass &rhs)
{
//调用内部版本
lhs.swap(rhs);
}
};
8.3. 类模板的swap
对于类模板
template<typename T>
testclass { //内含swap
}:
由于函数模板不支持偏特化,只有类模板支持。所以不能定义下面的这种类型:
namespace std {
//偏特化版本:不支持,错误的。
template<typename T>
void swap<testclass<T> >(testclass<T> &lhs, testclass<T> &rhs)
{
lhs.swap(rhs);
}
};
正确的做法是定义一个swap的重载版本,但是不能放在std中,我们允许在std中添加东西,只能完全特化其中的模板。
可以将swap放在我们自己的命名空间中。
namespace mystd {
template<typename T>
testclass { //内含swap
}:
//一个重载版本
template<typename T>
void swap (testclass<T> &lhs, testclass<T> &rhs)
{
lhs.swap(rhs);
}
};
在swap(testclass1, testclass2);时,根据C++名称查找法则(name lookup rules)具体的是argument-dependent-lookup或Koenig-lookup法则会找到mystd中的testclass专属版本。
使用的方式:
template<typename T>
void dosomething(T &obj1, T &obj2)
{
using std::swap; //使可以使用STL中swap
swap(obj1, obj2); //根据实参相依查找:(1) 在全局作用于或者obj所在命名空间查找专用的swap(模板类需要重载版本);
//(2) 在std中查找swap的特化版本(对于普通类)。(3) 使用swap的一般化版本。
}
总结:
(1) 当std::swap效率不高时(往往是class或template class使用了pimpl手法),考虑提供一个public成员swap成员函数,
让它高效的置换你的类型和两个对象值,并确保这个函数不抛出异常。因为swap最好的应用是提供强烈的异常安全性,在这里不能抛出异常。
(2)对于class或这template应该提供一个non-member swap来调用member swap。对于class还应该提供一个完全特化的std::swap。
(3) 调用swap,使用using 声明式,以便使std::swap在你的函数内曝光课件,然后不加任何命名空间修饰符的使用swap。
(4) 为“用户自定义类型”进行std namespace全特化是好的,但是不要在std内加入std而言全新的东西。