DJの小站

记录生活,发现自己

0%

Effective C++ 设计与声明

一、让接口容易被正确使用,不易被误用

  1. 使接口容易被正确使用

    比如我们开发一个表现日期的类:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
     class Date{
    public:
    Date(int month, int day, int year);
    }

    //顾客可能会这样使用:
    Date d(3, 30, 1995); //正确
    Date d(30, 3, 1995); //error
    //或是,超出范围地写出不正确的日期
    Date d(4, 55, 1995);

    完全不合理,故而可以这样设计:

    1
    2
    3
    4
    5
    6
    7
    8
    struct Day{
    explicit Day(int d):val(d){}
    int val;
    }
    class Date{
    public:
    Date(const Month& m, const Day & d, const Year& y){...}
    }

    或者,给代码足够的注释:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    class Date{
    public:
    /**
    *@brief:简介,简单介绍函数作用
    *@param:介绍函数参数
    *@return:函数返回类型说明
    *@exception NSException:可能抛出的异常.
    *@author zhangsan:作者
    *@date 2011-07-27 22:30:00:时间
    *@version 1.0:版本
    *@property:属性介绍
    */
    Date(int m, int d, int y);
    }
  2. 使接口与普遍认可的接口保持一致,比如,STL中,获取容器当前装载个数的接口都是size(),一个好的设计,应当使接口保持一致性。

  3. 阻止接口误用,方法包括:建立新类型,限制类型上的操作,束缚对象值,以及甩锅给客户(你把注释写得足够好,本身代码又没问题,只能怪客户咯)

  4. 曾经不少的DLL会出现一种在某个DLL内部新建一块资源,在另一个DLL内部过早或者不正确地delete掉(比如我们想要的删除行为是释放,或者对计数值减一,而它真的把东西删掉了),产生bug的现象,这种情况下,建议在写类的时候就将资源保管在智能指针内部,并指定它的删除器)

二、设计class如同设计type

当设计一个class时,我们应当尽可能周全地考虑清楚可能发生的事,好的type应当又自然的语法,直观的语义,多个高效的实现,当进行设计前,不妨思考以下问题:

  • 新的type该如何被创建和销毁
  • 对象的初始化和对象赋值应当有何区别
  • 新的type若被传值,会发生什么(copy构造函数行为)
  • 什么是新type的合法值?(比如Month类不该超出12或1)
  • 新的type需要配合某种继承吗,需不需要virtual?
  • 新的type是否需要类型转换
  • 新的type需要用到哪些操作符
  • 什么样的标准函数应当驳回(比如不符合现实逻辑的复制房产证对象)
  • 新的type有多么一般化,是不是该考虑从类升级到类模板
  • 你是否真的需要一个新的type

三、多使用pass by renference to const 替换 pass by value

在一些情况下,pass by renference to const 比 pass by value 更为节约时空成本,举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Person{
private:
string name;
string address;
public:
Person(){}
virtual ~Person(){}
virtual void print(){
std::cout << name << ',' << address << std::endl;
}
}
class Student : public Person{
private:
string school;
String _class;
public:
Student(){}
~Student(){}
void print(){
Person::print();
std::cout << "----------" << std::endl;
std::cout << school << ',' << _class << std::endl;
}
}

试想,现针对Student有一个接口void dosomething(Student s){},当我们用值传递时,这涉及到一次Student copy调用,两次string copy调用(school与class),一次Person copy调用以及两次string copy调用(name与adress),共六次copy调用加这些对象的销毁……但若是使用void dosomething(const Student &s),则只涉及到一个引用传递,极为便捷。

再考虑一个例子,对于Person和Student,我们写一个函数:

1
2
3
4
void printinfo(Person p){
p.print();
//do something else
}

对于这个函数,我们这样使用:

1
2
Student s;
printinfo(s);

当以值传入一个Student时,由于这里进行值传递,printinfo中的p会被构造成一个Person对象,因此打印出的信息只有一部分,这是不合适的,而若使用const Person& 作为参数,这种情况下由于多态的存在,是完全OK的。

四、必须返回临时对象时,使用值传递

若是某个函数需要返回一个函数内进行处理的local对象,一般采用值传递,因为函数的local对象会在函数调用完毕时自动被回收(操作系统管理栈,程序员管理堆),而若local对象是在堆上的,则你必须考虑它的delete以防止资源泄漏,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class complex{
//resource
public:
friend complex& operator * (const complex& a, const complex& b);
}
complex& operator * (const complex& a, const complex& b){
complex* res = new complex;
//some precess
return res;
}

complex a(1, 2);
complex b(3, 4);
complex c(5, 6);
complex d = a * b * c;
//这种情况下,你没有合理的方法去delete掉它当中产生的指针!注意它调用了两次乘法!

所以,一般情况下,从函数返回时使用pass by value

五、将成员变量声明为private

将成员变量声明为private为类提供了细微的访问控制划分,且对于用户而言,日后的改动并不影响之前的代码,private为类提供了其封装性。

对象的封装性与其代码改动时可能造成的代码破坏量成正比,故而对于一个public成员,在改动它时,所有使用它的代码都会受到影响,这是一个不可知量,而对于一个protected成员,在改动它时,所有derived class都会受到影响,这也是一个不可知量,所以在封装的观点中,只有两种访问权限:private,others。

六、若non-member, no friend函数member函数可以做到同样的事,使用non-member函数

对于一个类,如果越多的成员被隐藏,越少的代码可以改变它,那么我们认为这个类的封装性就越好;作为一个粗略的量度,我们认为,越多的函数可以访问它,数据的封装性就越差。因此,在一个non-member, no friend函数和一个member函数中去选择,为了增强数据的封装性,我们往往更倾向于选择前者,因为它不增加能够访问类中private成员的函数数量,也就是说,它提供了更好的封装性。而且,这个non-member, no friend函数可以是另一个类的成员函数。

通常,我们会将这个non-member, no friend函数和需要操作的类写在同一个命名空间中:

1
2
3
4
5
6
namespace WebBroswerStuff{
class WebBroswer{
...
};
void clearBroswer(WebBroswer &);
}

使用命名空间不止增加了程序的可读性,由于命名空间可跨越多个文件,它也为组织代码提供了便利。

比如一个WebBroswer类需要书签,cookie,设置等多个小功能,一个只使用cookie的客户没道理也要编译书签相关的东西。而使用namespace,你可以将它们分离在不同的头文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//头文件"webbroswer.h" WebBroswer核心
namespace WebBroswerStuff{
class WebBroswer{
...
};
void clearBroswer(WebBroswer &);
}

//头文件"webbroswerbookmarks.h"
//书签功能
namespace WebBroswerStuff{
... //书签功能便利函数
}

//头文件"webbroswercookies.h"
//cookie相关
namespace WebBroswerStuff{
... //cookie功能便利函数
}

这样,用户就可以根据自己的需要,选择编译某一部分,降低了代码的编译依存性,也使得用户可以根据自己的需求去扩展功能,他们只需要添加需要的non-member, no friend函数到此命名空间中。

使用non-member, no friend函数叠加namespace可以增加封装性,包裹弹性和技能扩充性

七、若所有参数都需要类型转换,使用non-member函数

比如新定义了一个类Complex:

1
2
3
4
5
6
7
8
9
10
11
class Complex{
private:
double real;
double imag;
public:
Complex(double r = 0, double i = 0):
real(r),
imag(i)
{}
...
}

当我们想为这个Complex类型定义一个乘法运算符重载函数时,有两种选择,一种是使用member函数,一种是使用non-member函数:

1
2
3
4
5
6
7
//member:
class Complex{
Complex operator * (const Complex& r);
}

//non-member:
Complex operator * (const Complex& l, const Complex& r);

若我们直接使用一个整数去乘以一个Complex对象,这个需求是很合理的,但让我们想想,将整数放在 * 号左边和右边带来的影响:

1
2
3
4
5
6
7
8
9
10
11
12
//member
Complex a(5, 2);
Complex b;
b = a * 2; //OK,这里编译器隐式转换2为一个Complex对象
b = 2 * a; //error,这里编译器并不会隐式转换2!

//non-member
Complex a(5, 2);
Complex b;
b = a * 2; //OK
b = 2 * a; //OK
b = 2 * 3; //OK

所以当函数所有参数都需要隐式转换,将他写为一个non-member函数

八、考虑写一个不抛出异常的swap函数

会写一个方便的swap函数对一个优秀的CPP程序员是必不可少的。

在Cpp11前,swap函数定义如下:

1
2
3
4
5
6
7
8
namespace std{
template<T>
void swap(T& a, T& b){
T temp(a);
a = b;
b = temp;
}
}

这样的swap实现十分常见,但对于一些类型,这是完全不必要的,若你使用pimpl手法对设计一个类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class WidgetImpl{
public:
....
private:
vector<int> v;
int a, b, c;
....
}
class Widget{
public:
Widget(const Widget& rhs);
Widget& operator = (const Widget& rhs);
private:
WidgetImpl* pImpl;
}

此时,若是使用std中的swap函数对两个Widget对象进行交换,根据Widget复制构造和复制运算符实现的不同,有可能一个swap会复制三次WidgetImpl对象,这显然是不太对的(Cpp1x中已经有了移动语义,但作者写这本书的时候移动还尚未出现,前人栽树,后人乘凉吧算是)。针对这一情况,我们可以针对Widget,在std中为其写一个特化版本的swap函数,让编译器自动调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Widget{
public:
void swap(Widget& rhs){
using std::swap;
swap(pImpl, rhs.pImpl);
}
private:
WidgetImpl* pImpl;
}

namespace std{ //特化意味着具体化,从template的抽象化变具体,半特化如:template<T, Widget>
template<>
void swap<Widget>(Widget& l, Widget& r){
l.swap(r);
}
}

采用这种方法,不仅解决了问题,而且与STL容器的实现具有一致性,所有STL容器都提供一个public swap和一个std中的swap的特化版本

而对于一个类模板,上述的写法将不再适用。如:

1
2
3
4
5
6
7
8
9
10
11
template<typename T>
class WidgetImpl{...};
template<typename T>
class Widget{...};

namespace std{
template<typename T>
void swap<Widget<T>>(Widget<T>& l, Widget<T>& r){
l.swap(r);
}
}

这种情况是不被允许的,因为std中允许添加全特化版本的templates,但对于新的templates是明确禁止的,故而,我们为Widget模板相关的东西增加一个专有的命名空间:

1
2
3
4
5
6
7
8
9
10
namespace WidgetStuff{
...
template<typename T>
class Widget{...};
...
template<typename T>
void swap<Widget<T>>(Widget<T>& l, Widget<T>& r){
l.swap(r);
}
}

当你编写一个func template,且该模板内需要swap操作时,应当这样写:

1
2
3
4
5
6
7
template<typename T>
void dosomething(T& a, T& b){
using std::swap;
...
swap(a, b);
...
}

这样编写有助于编译器采取更好的swap,如果传入的T的专有命名空间内有一个swap,编译器会优先调用它,若是没有,编译器会使用std空间内部的默认swap。using std::swap的作用是使std中的swap在函数内曝光,若是std::swap(a, b)则只会使得编译器只使用std内部的版本,失去了高效性和灵活性。

  1. 当std::swap效率不高时,提供一个public member的swap函数,并保证它不抛出异常
  2. 提供一个non-member调用member的swap,对于classes,使这个non-member成为一个全特化版本并放入std空间,对于templates,把它放入其专有空间,尽量使得代码保持一致性
  3. 可能用到std::swap时,采用声明:using std::swap,然后正常调用它,编译器会先在class所属命名空间内找,找不到了再去std空间下,而直接使用std::swap(Args …ards)时,会使编译器不使用命名空间的特化版本。