机器学习练习笔记(一)

题目1.1 - 分布及期望值

第一问求参数c的值。根据概率论的定义,应该要有,进一步求解:

可以求得,即密度分布函数:

第二问求的均值,根据定义,

在笔者整理的数学方法技巧整理中提及了如何对复合函数进行积分:

于是有

这题最后求的方差,根据定义,,对于连续型分布来说,

上一题已经得出了,现在是求即可。

题目1.2 - 边缘分布

已知, 求边缘密度函数。

随机变量X边缘密度函数:

同理可得Y的边缘密度函数:

第二问两个变量的相关性。

其中:

首先通过定义以及XY各自的边缘分布函数求得

而:

求得。同时根据题1.1的方差定义式,各自求得

题目1.3 - 泰勒展开

第三题是求三级泰勒展开

首先:

故得到:

题目1.4 - 矩阵的行列式及迹

令矩阵

求三阶矩阵的行列式有公式,加上运算量大,笔者直接用R计算了得到:

迹就简单了,根据定义式 甚至手算一下:

因为太简单了,R甚至不提供这个函数,可以使用:

1
sum(diag(A))

题目1.5 - 驻点

有如下两个二元函数:

其中c是实数常数,证明是两个函数的驻点。

首先求函数的一阶导数:

令:

都解得,即为两个函数的一个驻点。

接下来是通过Hessian矩阵判断是这两个函数的极值点。

首先求得两个函数的二阶导:

显然正定矩阵,故f(x,y)的极小值点。

顺序主子式,为不定矩阵,无法判断极值情况。

题目1.6 - 贝叶斯法则

首先先要知道公式

简要介绍问题:假设有的人口患某种病,表示此人患某病,而表示此人健康,某检测方法测试结果表示阳性,表示阴性。病患被检测出阳性的概率是,健康阴性结果的概率是

第一问求

第二问求

首先提取第一句以及后面两句话的信息:

进而推出:

根据贝叶斯定理有:

同理第二问:

Linux Socket编程基础

之前完成了Effective C++系列之后,整个人就懒起来了,加之春节假期,几乎整个2月份就没有整理写博客了。

回到之前,因为C++后台开发基本上都要求熟悉Linux以及Socket,所以学习TCP以及Socket编程是不能避免的事情。

本篇博客首先介绍基本的接口,随后给出一个简单的单线程,面向单个用户的演示程序。

用到的接口

1
2
3
4
5
6
7
8
9
10
11
// 创建一个Socket并返回对应的文件描述符。
int socket(int domain, int type, int protocol);

// 将指定的sockfd与指定地址addr绑定起来。
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

// 使指定sockfd可以被监听,即支持后续的accept()函数。
int listen(int sockfd, int backlog);

// 通过sockfd接受外部的连接,成功接受后,addr会存有外部连接的地址信息。
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

注意socket并不一定是TCP的,也可以是unix等通信协议,无非是TCP用得比较多而已。

在编写的过程中,通过man得知使用上述函数所需要的头文件,并且其中带有样例,可以参考怎么初始化一些数据结构。

Effective C++(十二)

该部分主题是杂项讨论,已经是书的尾声了。先介绍剩下的三个条款,然后再发表笔者这段时间的感想吧。

条款53:不要轻忽编译器的警告

笔者认为如果不是没办法解决,no warnings应该是需要满足的,这个条款也不需要过多的介绍,直接给出书上的建议吧:

  • 严肃对待编译器发出的警告信息。努力在你的编译器的最高警告级别下争取no warnings
  • 不要过度依赖编译器的报警能力,因为不同编译器对待事情的态度并不相同。一旦移植到另一个编译器上,原本依赖的警告信息有可能消失。

条款54:让自己熟悉包括TR1在内的标准程序库

既然作为C++的使用者,当开始开发实际的项目的时候,肯定会用到语言提供的库,因为重复造轮子对于项目开发来说是一件成本极高的事情。而了解STL的接口,可以节省大部分的开发时间。

TR1自身只是一份文档,而条款55提及的Boost库实现了文档中的大多数。

条款55:让自己熟悉Boost

笔者认为,Boost里面很多特性已经成为了C++11标准了,其中的智能指针,function对象等。C++因为历史原因,语言特性的更新速度较其他流行语言慢,虽然现在C++1x不断地提出新的特性标准。但到目前为止(2018年),C++11才成为主流,虽然C++14已经发布了不少时间,C++17接近发布的尾声了。然而现在大部分的厂商才较好地实现了C++11的特性,所以一些实用的功能而标准库,C++程序员可以尝试去Boost里面找找。

书上也讲了不少,但是笔者认为,最后这两个条款,告诉读者去哪里能了解就可以了。至此,Effective C++一书完结。

后记

在写Effective C++这个系列的博客的时候,中间有那么一丝一丝的感想,到了结束写后记的时候却感觉没什么可以写的。

笔者应该算是脱离了初学者时期的人了,在根据自己理解写下博客的时候,前半本的条款应该多多少少有实践经验,写起来比较快。到了2/3这样地方的时候,明显实践不够了,需要照着原书内容看几遍才能想通道理。这个时候脑子里轻轻飘过放弃这个系列后面部分的念头,不过最后还是坚持下来了。

终于完成了这个系列。同时也意识到自己实践还不够,革命尚未成功。

感想就先写这么多吧。

Effective C++(十一)

从此开始进入定制new和delete主题,因为C++手动管理内存的双面性,因此了解newdelete是十分必要的。时至今日,垃圾回收(Garbage Collection,GC)十分流行,主流的的语言里面也只有CC++没有GC的支持,下图是2018的TIOBE语言使用统计:

可以看到Top 10的语言里面除CC++以外都是具有GC的语言。GC固然会带来程序性能的下降,但是免去手动管理内存带来的开发效率的提高更加明显,Top 1Java就很好地说明了这点。而且在多线程的挑战下,手动内存管理变得尤其困难,笔者曾尝试过写一个多线程Socket程序,却总是崩溃,找了三天才找出是自己没有正确管理好内存。当一个执行线程开始使用一个对象的时候,该对象已经在其他线程中被析构了。因此从这点看来,C++需要耗费不少精力才能学好。

Effective C++里面只有operator new的出现,如果读者已经阅读过《深度探索C++对象模型》的话,应该也接触过new operator,要注意这两者的区别。

条款49:了解new-handler的行为

operator new因无法满足所要求的内存需求而抛出异常前,其中一步会调用客户制定的错误处理函数,即所谓的new-handler:

1
2
3
4
5
namespace std
{
typedef void (*new_handler)();
new_handler set_new_handler(new_handler p) throw();
}

也就是说一旦调用了set_new_handler之后,在申请内存失败的时候则会调用输入p函数。而一个设计良好的new-handler则应该有以下的行为:

  • 让更多的内存可被使用。类似GC过程,或者在程序初始化时开辟更大块的内存。
  • 安装另外一个new-handler。让有能力处理的new-handler接手。
  • 卸除new-handler。直接让operator new失败时抛出异常。
  • 抛出bad_alloc异常。
  • 不返回,调用abort()或者exit()。

一般没有太大的必要去定制new-handler,如果非要定制的话,引用书上的实现:

1
2
3
4
5
6
7
8
class Widget
{
public:
static std::new_handler set_new_handler(std::new_handler p) throw();
static void* operator new(std::size_t size) throw(std::bad_alloc);
private:
static std::new_handler currentHandler;
};

除非是const的静态成员,否则需要把定义式写在类的定义外:

1
std::new_handler Widget::currentHandler = 0;	// 初始化为null

标准版的set_new_handler:

1
2
3
4
5
6
7
8
std::new_handler Widget::set_new_handler(std::new_handler p) throw()
{
std::new_handler oldHandler = currentHandler;

currentHandler = p;

return oldHandler;
}

Widget::operator new行为如下:

  1. 调用标准set_new_handler,告知Widget的错误处理函数。会将Widget::currentHandler = ${global new-handler}。
  2. 调用global operator new,执行实际的内存分配。如果分配失败则调用Widget::currentHandler。
  3. global operator new成功分配,返回内存指针。Widget::~Widget()会管理global new-handler,会恢复Widget::operator new调用前global new-handler。

后面原作也用了详细的代码介绍了上面的三个过程,这里省略掉一是因为这部分可以查原书代码了解,二是笔者对这部分了解不深,基本上仍然处于初次接触的水平,三是该条款重点是了解set_new_handler以及相关的过程即可。

条款50:了解new和delete的合理替换时机

对于绝大多数的C++程序员,语言提供的原始的operator newoperator delete基本上可以满足程序的需求,很少人想到去重载(原书上用的“替换”)这两个operator。书上列出了三个最常见的理由:

  • 用来检测运用上的错误。
  • 为了强化效能。例如插入一些内存碎片整理的算法。
  • 为了收集使用上的统计数据。

总的来说,就是改变原有的行为,修改分配规则,或者插入一些信息记录的步骤。书上也给出定制operator new的一般方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 让分配的对象具有签名
static const int signatrue = 0xDEADBEEF; // 特征值
typedef unsigned char Byte;

// 暂时忽略一些小错误
void* operator new(std::size_t size) throw(std::bad_alloc)
{
using namespace std;

size_t realSize = size + 2 * sizeof(int); // 空出2个int存放signature

void* pMem = malloc(realSize); // 调用malloc分配内存

if(!pMen) throw bad_alloc();

*(static_cast<int*>(pMen)) = signatrue; // 在对象头存放signature

// 找到分配块的最后两个字节,并写入signatrue
*(reinterpret_cast<int*>(static_cast<Byte*>(pMem) + realSize - sizeof(int))) = signatrue;

// 返回原本申请大小对象实际指针位置,在开头signature后的第一个字节
return static_cast<Byte*>(pMem) + sizeof(int);
}

例子中的主要缺陷是没有遵循operator new的原则,如应该有一个循环调用new-handling的行为,直到所有尝试都失败,具体可以看条款51。

对齐(alignment)的问题,因为C++底层的代码必然接触到操作系统、甚至硬件相关的这样的细节,重载operator new可以做到对齐的修正,提高运行速度。

一般需要修改默认的operator newoperator delete的理由如下:

  • 为了检测运用错误。
  • 为了收集动态分配内存的使用统计信息。
  • 为了增加分配和归还的速度。
  • 为了降低缺省内存管理器带来的额外空间开销。
  • 为了弥补缺省分配器的非最佳对齐(suboptimal alignment)。例如指针值在32位系统是4倍数等。
  • 为了将相关对象成簇集中。减少缺页异常(Page Faults)
  • 为了获得非传统的行为。

条款51:编写new和delete时固守常规

先给出结论:

  • operator new应该内含一个无穷循环,并在其中尝试分配内存,如果它无法满足内存需求,就该调用new-handler。它也应该有能力处理0 bytes申请。Class专属版本则应该处理“比正确大小更大的(错误)申请”。
  • operator delete应该在收到null指针时不做任何事。Class专属版本则还应该处理“比正确大小更大的(错误)申请”。

C++规定,即便要求客户要求0 bytesoperator new也会返回一个合法指针。一份常规的non-member operator new伪码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
void* operator new(std::size_t size) throw(std::bad_alloc)	// 定制的函数可以接受额外的参数
{
using namespace std;

if(size == 0) // 0-byte提为1-byte请求
{
size = 1;
}

while(true) // 直到申请成功为止,或者程序崩溃
{
尝试分配 size bytes;

if(成功分配)
{
return 成功分配的指针值;
}

// 分配失败
new_handler globalHandler = set_new_handler(0);

set_new_handler(globalHandler);

if(globalHandler)
{
(*globalHandler)();
}
else
{
throw std::bad_alloc();
}
}
}

如果是Class专属版的operator new:

1
2
3
4
5
6
7
8
9
10
11
class Base
{
public:
static void* operator new(std::size_t size) throw(std::bad_alloc);
...
};

class Derived : public Base // Derived并没有声明自己的operator new
{...};

Derived* pd = new Derived;

那么问题就很明显了,Derived的大小大于等于Base,这样重载的operator new执行出来的Derived的尺寸是正确的吗?

虽然很怪异,但是如果Base::operator new这样实现:

1
2
3
4
5
6
7
8
void* Base::operator new(std::size_t size) throw(std::bad_alloc)
{
if(size != sizeof(Base)) // 尺寸匹配不上
{
return ::operator new(size); // 交给标准的operator new处理
}
... // 正常流程
}

把责任甩开可以解决问题。

如果重载operator new[]的话,考虑到Base的子子孙孙,bytes % sizeof(Base)的值就不一定为零了,所以重载的时候要考虑的问题还真不少。

接下来就是operator delete的问题,首先记住“删除null指针永远安全”,如下一份non-member operator delete的伪码:

1
2
3
4
5
6
void operator delete(void* rawMemory) throw()
{
if(rawMemory == 0) return; // 不处理空指针

... // 剩余的delete动作
}

同样,考虑到Base的子子孙孙,Base专属版的operator delete`应该这么实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Base
{
public:
static void* operator new(std::size_t size) throw(std::bad_alloc);
static void operator delete(void* rawMemory, std::size_t size) throw();
...
};

void Base::operator delete(void* rawMemory, std::size_t size) throw()
{
if(rawMemory == 0) return;

if(size != sizeof(Base)) // 甩锅给标准版operator delete
{
::operator delete(rawMemory);
return;
}

... // 剩余的归还动作。
}

注意,如果没有virtual关键字修饰的析构函数,operator delete也不会接收到正确的size。所以在一个继承里面,virtual关键字是必须要考虑的。

条款52:写了placement new也要写placement delete

placement newplacement delete应该是更少见的语法了。什么是placement new:

1
如果operator new接受的参数除了一定会有的那个size_t之外还有其他,这边是所谓的placement new。

有很多版本的placement new,其中最为有用的一个是“接受一个指针指向对象该被构造之处”:

1
void* operator new(std::size_t, void* pMemory) throw();

同样,如果接受额外参数的operator delete也成为placement delete。原书上给出了加了日志功能的placement new/delete的例子:

1
2
3
4
5
6
7
8
9
10
11
12
class Widget
{
public:
...
static void* operator new(std::size_t, std::ostream& logStream) throw(std::bad_alloc);

static void operator delete(void* pMemory) throw();

static void operator delete(void* pMemory, std::ostream& logStream) throw();

...
};

placement new一定要有对应版本的placement delete,若placement new过程抛出了异常,需要恢复而调用operator delete时,是寻找参数匹配的placement delete,假如没有提供的话,那么就不会执行恢复步骤产生内存泄漏。例如上述代码的成对的placement new/delete。当:

1
Widget* pw = new(std::cerr) Widget;

发生异常,除了原有的log输出到std::cerr中,因为具有参数匹配的placement delete被调用,因而不会有泄漏的问题。

还有,写了placement new就会产生名字掩盖问题:

1
2
3
4
5
6
7
8
9
10
11
class Base
{
public:
...
static void* operator new(std::size_t size, std::ostream& logStream) throw(std::bad_alloc);
// 这里只有placement new而没有operator new,global operator new被掩盖了
};

Base* pb = new Base; // 调用失败,正常的operator new被掩盖了

Base* pb = new (std::cerr) Base; // OK

同理:

1
2
3
4
5
6
7
8
9
10
11
12
class Derived : public Base
{
public:
...
static void* operator new(std::size_t size) throw(std::bad_alloc);

...
};

Derived* pd = new (std::clog) Derived; // 错误,Base中的placement new被掩盖了

Derived* pd = new Derived; // OK

所以在写自己的placement new/delete时,要考虑该类需不需要一般形式的operator new

即是,声明placement new/delete时,不要忘记了这样会掩盖正常版本的operator new/delete

Effective C++(十)

从此开始进入模板与泛型编程,这也是一般C++程序员较少接触的部分。如果读者有使用C++里的标准模板库(Standard Template Librarry,STL)的话,能够多多少少地体会到模板的威力。

正如书上所说,这里的内容不能让读者成为专家级的template程序员,但可以让读者成为一个比较好的template程序员。笔者认为这里的意思是介绍一些模板编程的基本知识,例如更好地使用STL,或者进行一些简单的模板编程。因为一些更加进阶的玩法,例如在编译器利用模板编程完成某些运算等这些内容,要对模板元编程(Template Metaprogramming)十分熟悉,这要求编程人员对C++的语法特性十分了解,甚至要对准备编译源文件的编译器十分了解。

笔者自上个主题后期开始的条款就呈现出经验不足,对条款的解释能力开始下降,总结一下无非自己的编程经历也只能理解到书的前半部分,后面部分笔者仍然任重而道远。笔者在剩下的部分里尽自己最大的能力,不足的地方请多指教。

条款41:了解隐式接口和编译期多态

有关C++模板编程的原理,读者们可以读《C++ Template》这本书了解。在开始介绍条款之前,读者们首先要了解模板是怎么编译的,例如:

1
2
3
4
5
6
7
8
// 假定有个列表类
template<typename T>
class List
{
public:
T operator[](int idx) const; // 假设该List类模板重载下标访问元素函数
...
};

在编程中,这个类模板被使用了:

1
List<int> intList;

在编译期,template是不会被编译的,而是先产生一个模板类:

1
2
3
4
5
6
7
// 一般会有mangling发生,例如这个模板类会名为IList之类的,但是这里不考虑mangling的问题
class List
{
public:
int operator[](int idx) const; // int版的List,该函数的返回类型T被替换为了int
...
};

然后编译器才会去编译这个类模板的实例。介绍这个原理的意义在于要说明,在类模板有实例之前,类模板里面有什么内容,编译器是不会理会的,例如:

1
2
3
4
5
6
7
8
9
template<typename T>
class Container
{
public:
void fun(const T& obj)
{
obj.call(); // T类具有call函数
}
};

如果来了一个Container<float>,可想而知,当然是报错的。编译器也不会对类模板以及模板类有太多的检查,只要产生出来的模板类有符合模板类里面有的行为即可。笔者仿照书上的例子来说明结合隐式类型转换,模板编程会变得多困难:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class A
{
public:
operator int()() // 假定类A有个向int转换的重载
{
return 33;
}
};

class B
{
public:
A& size() // 一般的size认为是返回对象的大小,但是B不按套路来,返回a
{
return a;
}
private:
A a;
};

template<typename T>
void fun(T& obj)
{
if(obj.size() > 10) // 这个函数模板要求T类型有size()函数
{
cout << "Greater than 10." << endl;
}
else
{
cout << "Less than 10" << endl;
}
}

int main(int argc, char* argv)
{
B b;

fun(b);

return 0;
}

看到输出:

1
Greater than 10.

也就是说,fun里面,B的实例是符合有size函数这个约束的,而B::size()返回的A类型不能与int直接比较,而是调用了A::operator int()作了隐式转换之后比较。

这个例子要说明的是,模板推断的双面性,一方面类型推断的能力为编程人员带来了遍历,可以少写一些类型转换等语句,也就是语法糖吧;坏处就是推断太强大了,以至于做了隐式转换可能产生非预期的结果。

而这个推断是基于有效表达式的。这也是书上要介绍的编译期多态,也就是在编译器决议出用什么版本的函数。

条款42:了解typename的双重意义

在声明模板的时候:

1
2
3
template<typename T>
class Container
{...};

与:

1
2
3
template<class T>
class Container
{...};

里的typenameclass是等价的关键字,但是在用到某个类模板里的内置类型时,例如:

1
std::vector<Object>::iterator iter;		// 用到了vector里面的迭代器

然而有可能在编译过程中,编译器可能认为std::vector<Object>::iteratorvector里面的一个成员变量,而真实情况是vector里面的一个嵌套从属类型,编译器有可能会报错。这时改成:

1
typename std::vector<Object>::iterator iter;

问题解决,这时候的typename是不能用class关键字代替的,这里的typename声明std::vector<Object>::iterator是一个类型,而不是某种实例变量。

条款43:学习处理模板化基类内的名称

该条款涉及到:

笔者反复看这个条款,最后也完全理解这个条款想讲的是什么。但是推断应该是与类设计相关的问题。引用书上的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class CompanyA
{
public:
// 每个公司都有向该公司发送文本的函数
void sendCleartext(const std::string& msg);
void sendEncryted(const std::string& msg);
...
};

class CompanyB
{
public:
// 公司B也有
void sendCleartext(const std::string& msg);
void sendEncryted(const std::string& msg);
...
};

...

class MsgInfo {...}; // 承载信息实体的类

template<typename Company>
class MsgSender // 发送消息给公司的类模板
{
public:
...
void sendClear(const MsgInfo& info)
{
std::string msg;
// 根据info产生信息
Company c;
c.sendCleartext(msg); // 模板中调用sendCleartext()函数发送消息
}

void sendSecret(const MsgInfo& info)
{
std::string msg;
// 根据info产生信息
Company c;
c.sendEncryted(msg); // 模板中调用sendEncryted()函数发送加密消息
}
};

接着为了让发送消息有日志,编写一个可以记日志的LoggingMsgSender:

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename Company>
class LoggingMsgSender : public MsgSender<Company>
{
public:
...
void sendClearMsg(const MsgInfo& info)
{
... // 发送前记日志
sendClear(info); // 调用MsgSender::sendClear(),但是无法通过编译
... // 发送后记日志
}
...
};

问题的根源在于,当编译器处理class template LoggingMsgSender定义式时,首先要知道MsgSender的定义式,但是MsgSender是一个模板,也就是说需要一个MsgSender<Company>的实例的定义式。而MsgSender有可能有特化的版本,特化的版本与一般的模板又不一样,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class CompanyZ			// Z公司类
{
public:
...
// 这个类不提供sendCleartext函数
void sendEncryted(const std::string& msg); // 仅提供发送加密消息的函数
...
};

template<> // 为了CompanyZ与众不同的行为提供一个全特化版本的类模板
class MsgSender<CompanyZ>
{
public:
...
void sendSecret(const MsgInfo& info) // 因此这个特化的类模板也只提供发送密文的函数
{...}
};

这个时候,LoggingMsgSender:

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename Company>
class LoggingMsgSender : public MsgSender<Company>
{
public:
...
void sendClearMsg(const MsgInfo& info)
{
... // 发送前记日志
sendClear(info); // 调用Company == CompanyZ时候,基类就不提供sendClear()函数了
... // 发送后记日志
}
...
};

因为MsgSender有可能存在全特化的版本,因而编译器不会去处理基类相关的内容。

为了能够正常编译代码,有三个办法(假设所有的Company行为一致,不会像CompanyZ有与众不同的行为):

  • this关键字
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    template<typename Company>
    class LoggingMsgSender : public MsgSender<Company>
    {
    public:
    ...
    void sendClearMsg(const MsgInfo& info)
    {
    ... // 发送前记日志
    this->sendClear(info); // 告诉编译器这个函数是类成员,强迫编译器从整个继承体系搜索
    ... // 发送后记日志
    }
    ...
    };
  • using声明式:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    template<typename Company>
    class LoggingMsgSender : public MsgSender<Company>
    {
    public:
    using MsgSender<Company>::sendClear; // 告诉编译器假定sendClear由基类提供
    ...
    void sendClearMsg(const MsgInfo& info)
    {
    ... // 发送前记日志
    sendClear(info); // 假设sendClear存在于基类,则编译通过
    ... // 发送后记日志
    }
    ...
    };
  • 显式指出函数的版本:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    template<typename Company>
    class LoggingMsgSender : public MsgSender<Company>
    {
    public:
    ...
    void sendClearMsg(const MsgInfo& info)
    {
    ... // 发送前记日志
    MsgSender<Company>::sendClear(info); // 告诉编译器用MsgSender<Company>::sendClear
    ... // 发送后记日志
    }
    ...
    };
    第二三个方法显式地声明了函数,会使得virtual函数的多态行为失效。

还有,如果采用了解决方案之后,还出现LoggingMsgSender<CompanyZ>的实例化,编译当然就失败了。

条款44:将与参数无关的代码抽离templates

该条款第一个提到的问题是非参数类型带来的代码膨胀template支持如下的语法:

1
2
3
template<typename T, std::size_t size>	// 假设该类为size*size大小的方阵
class SquareMatrix
{...};

在写下:

1
2
3
4
5
SquareMatrix<int, 3> m1;

SquareMatrix<int, 4> m2;

...

编译器会为SquareMatrix<int, 3>以及SquareMatrix<int, 4>模板类各自生成实例,可能产生如下的代码:

1
2
3
4
5
6
7
8
9
10
11
class I3SquareMatrix
{
public:
...
};

class I4SquareMatrix
{
public:
...
};

当需要产生很多大小不一的方阵实例的时候,类模板的实例代码随之膨胀。然而仅仅只是因为尺寸的问题就带来代码膨胀,这是不必要的。通过成员变量一般可以解决问题:

1
2
3
4
5
6
7
8
9
10
11
template<typename T>
class SquareMatrix
{
public:
...
private:
...
std::size_t matrix_size; // 通过一个成员变量记录方阵的大小
T* data_ptr; // 存储实际数据的指针,也可以是T**类型
...
};

这样就解决了来自于非参数类型导致的代码膨胀。

而对于来自与类型参数的代码膨胀,一般不需要处理,要么就要有高超的编程技巧。例如SquareMatrix<int>SquareMatrix<const int>SquareMatrix<long>明明在二进制表示上一致(一般平台int和long一样),但是编译器会分别为它们生成不同的类模板实例。往往这样做的话需要用到void*类型来操作一些手法,这个就是很底层的代码的问题了,而且STL大部分容器是做了这个优化的,因而笔者认为该部分大多数人了解即可。

条款45:运用成员函数模板接受所有兼容类型

书上用了智能指针来作说明,如下一个继承体系:

1
2
3
4
5
class Top {...};

class Middle : public Top {...};

class Bottom : public Middle {...};

第一版的智能指针类定义:

1
2
3
4
5
6
7
template<typename T>
class SmartPtr
{
public:
explicit SmartPtr(T* ptr); // 提供原生的指针进行初始化
...
};

原生的继承体系,用父类的指针指向子类实例:

1
2
3
Top* top1 = new Middle;

Top* top2 = new Bottom;

都没有任何问题,但是:

1
2
3
4
5
6
7
8
9
10
void fun(SmartPtr<Top> top)
{
// 处理一些只与Top类型相关的操作
}

...

SmartPtr<Middle> middlePtr(new Middle); // 假定开发者在这个作用域知道实际的类型

fun(middlePtr); // 根据继承体系,传入一个Middle实例应该是没问题的

但是问题就出现在这里了,原生的继承体系Top<-Middle<-Bottom是没有问题的,一般的编译器会为上面的使用生成如下的类模板实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 模板的实例化
class TopSmartPtr // 为SmartPtr<Top>生成的模板类
{
public:
explicit TopSmartPtr(Top* ptr);
...
};

class MiddleSmartPtr // 为SmartPtr<Middle>生成的模板类
{
public:
explicit MiddleSmartPtr(Middle* ptr);
...
};

注意并不是:

1
2
class MiddleSmartPtr : public TopSmartPtr
{...};

也就是说SmartPtr<Middle>SmartPtr<Top>没有任何关系,目前也没有代码提供它们之间的显式或者隐式类型转换,自然编译不能通过了。

为了解决这个问题,让SmartPtr<Top> top(SmartPtr<Middle>(new Middle))这类的语法能够正常工作,SmartPtr的类定义则应该这么写:

1
2
3
4
5
6
7
8
9
10
11
template<typname T>
class SmartPtr
{
public:
template<typename U>
explicit SmartPtr(U* instance); // T兼容U,例如例子中的继承关系

template<typename U>
SmartPtr& operator=(const SmartPtr<U>& sptr); // 兼容类型的复制函数
...
};

更具体的设计可以查看std::shared_ptr的源码,在这里要提示一点,在写下上面的成员函数的定义时,应该是:

1
2
3
4
5
6
template<typename T>
template<typename U> // 笔者习惯加个缩进表示U参数是在T参数的作用域内
SmartPtr<T>& operator=(const SmartPtr<U>& sptr)
{
... // 实现代码
}

而不能写成不能通过的:

1
2
3
4
5
template<typename T, typename U>
SmartPtr<T>& operator=(const SmartPtr<U>& sptr)
{
... // 实现代码
}

这样就解决了泛化的问题。

条款46:需要类型转换时请为模板定义非成员函数

问题类似与条款24,不同的是这次加入了模板,假设有个有理数的类模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename T>
class Rational
{
public:
Rational(const T& numerator = 0, const T& denominator); // 条款20
const T numerator() const; // 条款28
const T denominator() const; // 条款3
...
};

template<typename T>
const Rational<T> operator*(const Rational<T>& lhs, const Rational<T>& rhs)
{...}

根据常理,有理数直接的操作:

1
2
3
Rational<int> oneHalf(1, 2);

Rational result = oneHalf * 2;

应该是没问题的,但是在强类型语言中,编译器试图寻找匹配的函数,但是目前也只能匹配到:

1
2
template<typename T>
const Rational<T> operator*(const Rational<T>& lhs, const Rational<T>& rhs)

显然根据地一个参数,Rational<int>被推导出来了,然而template实参推导过程不会考虑通过构造函数而发生的隐式类型转换。在编程人员尚未提供规则使得int可以向const Rational<int>转换,在产生模板函数过程自然推导失败了。

书上提供的解决方法:

1
2
3
4
5
6
7
8
9
10
11
12
template<typename T>
class Rational
{
public:
...
// 将operator*重载变为类的友元函数
friend const Rational operator*(const Rational& lhs, const Rational& rhs);
};

template<typename T>
const Rational<T> operator*(const Rational& lhs, const Rational& rhs)
{...}

这样做的原理是让编译器在产生Rational<int>类模板实例时,带上友元函数一起产生,使其省略上一个版本让编译器先为operator*推导的步骤,然后迫使oneHalf * 2套用函数时,对int强制做隐式类型转换。

编译通过,但是会链接失败,神奇的是,把operator*的定义放回类内部又可以了:

1
2
3
4
5
6
7
8
9
10
template<typename T>
class Rational
{
public:
...
friend const Rational operator*(const Rational& lhs, const Rational& rhs)
{
... // 函数定义
}
};

friend关键字原本是用于让类外部的函数,或者类,可以访问私有成员,但是这里并没有这样的使用。

当类模板相关的函数需要隐式类型转换的时候,将函数定义为类模板内部的friend函数

条款47:请使用traits classes表现类型信息

这个条款对于写轮子的人来说比较有用,STL用的人应该不少,但是阅读过STL源码的人可能不多,连笔者至今也没有系统读过C++ STL的源码,而STL中才会有大量涉及traits classes代码。

Traits并不是C++关键字或者一个预先定义好的构建:它们是一种技术,也是一个C++程序员共同遵守的协议。

因为模板编程的初衷是将与类型无关的操作提取出来,减少代码的重复性。不从实现角度来讨论,在该条款里面,模板编程表现得像接口一样,引用书上advance函数的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// advance作为一个STL算法实现,作用是让迭代器向前移动d步
// DistT是距离变量的类型
template<typename IterT, typename DistT>
void advance(IterT& iter, DistT d)
{
if(iter是一个随机访问的迭代器)
{
iter += d; // 直接跳d步
}
else
{
if(d >= 0){while(d--) ++iter;} // 一步一步跳d步
else {while(d++) --iter;} // 当d小于0时是回退d步
}
}

advance例子里面,if分支里面的描述用到的就是输入的迭代器的Traits信息,假设迭代器的遵循Traits协议:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<typename IterT>
struct IteratorTraits // Traits通常用struct做实现
{
const static bool is_random_access = false; // 在特化前,所有的迭代器默认没有随机访问的能力
... // 其他有关迭代器的Traits描述
};

...

template<> // 全特化,假定存在RandomAccessIterator这个类
struct IteratorTraits<RandomAccessIterator> // 针对该迭代器特化一个模板
{
const static bool is_random_access = true; // 该类迭代器具备随机访问能力
... // 其他Traits描述
};

有了类似这样的实现,那么上面的advance例子的判断语句可以具体为:

1
if(IterT::is_random_access)

这时的advance就像是一个接口,你只需要输入兼容的迭代器即可,无需关心该迭代器的特性会影响如何完成这个操作。

笔者前面的模拟实现是需要运行时的判断,而模板元编程强大之处,也是设计思想,尽可能地在编译期完成计算,提高运行时的效率。那么Traits应该改成较为合理的形式(参考STL):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template<...>		// 省略参数
class deque // STL中的双向队列
{
public
class iterator // 双向队列自带的迭代器实现
{
typedef random_access_iterator_tag iterator_category; // 说明自己的分类是随机迭代器
...
};
...
};

// 根据上面STL中提供的迭代器定义,可以简单地提取其中自带的Traits信息,一般的实现
template<typename IterT>
struct iterator_traits
{
typedef typename IterT::iterator_category iterator_category; // 说明自身是何种迭代器的traits
...
};

可以根据STL的设计,提供一个运行时的判断:

1
2
3
4
5
6
7
8
template<typename IterT, typename DistT>
void advance(IterT& iter, DistT d)
{
// 判断遵循标准使用typeid实现
if(typeid(typename std::iterator_traits<IterT>::iterator_category) ==
typeid(std::random_access_iterator_tag))
...
}

为了优化运行时,同时不改变接口的情况下,这时候advance更像一个接口了:

1
2
3
4
5
6
7
8
template<typename IterT, typename DistT>
void doAdvance(IterT iter, DistT d,
std::random_access_iterator_tag) // 用于随机迭代器的advance实际执行函数
{
iter += d;
}

... // 其他种类迭代器的doAdvance实现

这时的advance仅仅是单纯转发(forwarding):

1
2
3
4
5
6
7
8
template<typename IterT, typename DistT>
void advance(IterT iter, DistT d,)
{
// 用Traits提供迭代器种类信息,随后让编译器决议用哪个版本的doAdvance
doAdvance(iter, d,
typename std::iterator_traits<IterT>::iterator_category)
);
}

以上介绍了Traits Classes的基本原理和实现。

条款48:认识template元编程

模板元编程(Template Metaprogramming,TMP)是在编译期将运算完成,以此减少在运行时的运算,但是滥用模板也会导致代码膨胀更多的详细介绍

在给出例子之前首先说明模板元编程里面的循环是用递归进行模拟的,读者也可以回想到模板里面没有直接支持模板循环的语法。

来一个阶乘计算的例子:

1
2
3
4
5
6
7
8
9
10
11
template<unsigned n>
struct Factorial
{
enum { value = n * Factorial<n-1>::value }; // f(n) = n * f(n-1)递归完成n*(n-1)*(n-2)*...*1的模拟
};

template<>
struct Factorial<0> // 递归终止条件
{
enum { value = 1 };
};

然后写下:

1
2
cout << Factorial<5>::value << endl;		// 5!
cout << Factorial<10>::value << endl; // 10!

即可得到结果,当然TMP可以做更多的事情,但是也很考验程序员的设计能力。要是这么简单的话,TMP早就因其强大的编译期优化而流行了。

因此该条款也只是简单地介绍TMP,有兴趣的读者可以在网上搜索到更多的TMP奇淫技巧。

Effective C++(九)

继承与面向对象设计这个主题内容比较多,在进入下一个主题前需要一篇新的文章放下剩余的内容。

条款36:绝不重新定义继承而来的non-virtual函数

这个条款用个例子就能说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class Base
{
public:
void mf()
{
cout << "Base::mf()" << endl;
}
};

class Derived : public Base
{
public:
void mf()
{
cout << "Derived::mf()" << endl;
}
};

void call(Base& base)
{
cout << "in call(): ";
base.mf();
}

int main(int argc, char* argv[])
{
Derived d;

Base* pb = &d;

Derived* pd = &d;

pb->mf();

pd->mf();

call(d);

return 0;
}

编译运行,得到:

1
2
3
Base::mf()
Derived::mf()
in call(): Base::mf()

也就是说调用哪个版本的函数是由当前持有对象的holder(中文笔者不知道如何表达,代之指针或者普通变量)的类型决定。这个现象是可以通过C++对象模型来解释的,不过重点是这样的设计不符合多态的原则,容易造成误用。

其根源就是里面所有的表达式都是在编译期完成决议的,所有都是静态绑定,以下是静态绑定的定义:

1
静态绑定是指在程序编译过程中,把函数(方法或者过程)调用与响应调用所需的代码结合的过程称之为静态绑定。

总之如果一个函数是non-virtual的,该类的子类就不要重新定义该函数,避免后续的错误。

条款37:绝不重新定义继承而来的缺省参数值

在开始该条款之前,读者应该了解:

这个条款要介绍的矛盾是由于缺省参数值静态绑定virtual函数动态绑定引起的。用书上的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
enum ShapeColor{RED, GREEN, BLUE};	// 图形颜色

// 几何图形的基本类
class Shape
{
public:

virtual void draw(ShapeColor color = ShapeColor::RED) const // 所有的形状都有一个描绘的函数
{
cout << "Calling Shape::draw(ShapeColor=";

switch(color) // 加个判断,用于判定输入的参数的实际值
{
case ShapeColor::RED : cout << "RED)" << endl;break;
case ShapeColor::GREEN : cout << "GREEN)" << endl;break;
case ShapeColor::BLUE : cout << "BLUE)" << endl;break;
}
}
};

class Rectangle : public Shape
{
public:
virtual void draw(ShapeColor color = GREEN) const // 覆写了父类的函数
{
cout << "Calling Rectangle::draw(ShapeColor=";

switch(color) // 加个判断,用于判定输入的参数的实际值
{
case ShapeColor::RED : cout << "RED)" << endl;break;
case ShapeColor::GREEN : cout << "GREEN)" << endl;break;
case ShapeColor::BLUE : cout << "BLUE)" << endl;break;
}
}
};

class Circle : public Shape
{
public:
virtual void draw(ShapeColor color) const // 这个子类强制用户输入颜色
{
cout << "Calling Circle::draw(ShapeColor=";

switch(color) // 加个判断,用于判定输入的参数的实际值
{
case ShapeColor::RED : cout << "RED)" << endl;break;
case ShapeColor::GREEN : cout << "GREEN)" << endl;break;
case ShapeColor::BLUE : cout << "BLUE)" << endl;break;
}
}
};

int main(int argc, char* argv[])
{
// 三个变量都是Shape*静态类型
Shape* ps;

Shape* pc = new Circle; // 指向实际类型为Circle

Shape* pr = new Rectangle; // 指向实际类型为Rectangle

pc->draw();

pr->draw();

ps = pc; // ps的动态类型是Circle

ps->draw();

ps = pr; // ps的动态类型是Rectangle

ps->draw();

Circle* cpc = new Circle; // 对照组,用于对比静态类型为子类的情况

cpc->draw(ShapeColor::BLUE);

Rectangle* rpr = new Rectangle;

rpr->draw();

return 0;
}

运行得到:

1
2
3
4
5
6
Calling Circle::draw(ShapeColor=RED)
Calling Rectangle::draw(ShapeColor=RED)
Calling Circle::draw(ShapeColor=RED)
Calling Rectangle::draw(ShapeColor=RED)
Calling Circle::draw(ShapeColor=BLUE)
Calling Rectangle::draw(ShapeColor=GREEN)

这个现象中,所有的多态表现正确。pspcpr三个指针的静态类型都是Shape*,但是分别指向了CircleRectangle实例,上面的输出显示都根据其动态类型成功地调用了实际类型中的draw函数。但是一方面又很奇怪,缺省参数值是根据静态类型来决议的,在静态类型是Shape*的情况下,所有的缺省参数值都采用了Shape::draw的版本,不受实际类型的影响。甚至连Circle::draw本身不具有缺省参数也采用了父类的版本。

笔者原本以为会引起编译错误,但是仔细想了想也是合理的,首先静态编译期就根据变量类型决议了缺省参数的版本,并不影响其运行时。

书上也给出了解决这个问题的技巧,这样的做法并不提倡,因而笔者就不给出方法了。遇到这样的问题更应该从设计上来解决,避免总是用技巧,总体原则就是不要在virtual函数里面施加太多的约束,留有灵活性使得子类继承时方便修改。

条款38:通过复合塑模出has-a或者”根据某物实现出”

该条款并不是编程方面的问题,而是有关概念以及设计的问题,例如书上说的”人有一个住址”:

1
2
3
4
5
6
7
class Person
{
public:
...
private:
Address address;
};

而不是”人是一个住址”:

1
2
class Person : public Address
{...};

这个是编程人员对所需要实现的软件的认知引起的问题,当然这个例子是很简单的,书上既然给出了这个条款,那么也是提示读者要注重概念上的问题,否则因为这种与编程无关的事情增加了复杂度那就太浪费人力了。正如上面这个”人是一个住址”错误的示例,读者可以想想这样的设计,在把一个Person实例持久化到数据库的时候,Address表Person表的关系,以及sql语句的设计。

然后就是is-ais-implemented-in-terms-of的区分问题。在编程里面,集合列表是两种不同概念的数据结构,书上给的例子是复用列表实现集合:

1
2
3
template<typename T>
class Set : public std::list<T>
{...};

这样基本上可不用写多少代码就实现了功能,但是集合是不允许集合的重复的而列表允许,也就是说产生了概念冲突。

但是如果集合仅仅是借列表来实现的话:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template<typename T>
class Set
{
public:
...
void insert(const T& item);
...
private:
std::list<T> container;
};

...
template<typename T>
void Set<T>::insert(const T& item)
{
... // 加入集合前判断元素是否唯一等概念上的操作
containter.insert(item); // 实际插入操作
... // 其他处理等
}
...

这样就解决了Set不是list这个概念上的问题,因为这个Set是”根据list实现出”来的。

这个条款笔者认为没有太多技术上的东西,更多的是编程人员的基础概念认知的知识问题。

条款39:明智而审慎地使用private继承

该条款提到了:

笔者没有该条款的实践,毕竟private继承在实际应用中太少了,少到几乎没有见到过。

private继承意味implemented-in-terms-of关系。

例如书上的例子:

1
2
3
4
5
6
7
class Timer
{
public:
explicit Timer(int tickFrequency); // 设定计时器频率
virtual void onTick() const; // 周期性事件
...
};

然后一个Widget需要定时器功能,但是显然Widget不是一个Timer,需要表现出is-implemented-in-terms-of关系才能符合常用逻辑,也就是Widget定时事件功能是根据Time实现的:

1
2
3
4
5
6
class Widget : private Timer
{
private:
virtual void onTick() const; // 周期性事件
...
};

这样看起来乖乖的,明明是一种继承语法,却是表现不出is-a关系设计的实现。这就是为何private继承罕见的原因了。也可以改进为一种更加符合阅读理解的表现形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Widget
{
public:
...
private:
class WidgetTimer : public Timer // 为Widget专门定制的Timer
{ // 内部类,无法从外部创建实例
public:
virtual void onTick const;
...
};

WidgetTimer timer; // Widget的定时器功能就根据这个定制的Timer实现
...
};

第二种做法还有用到的地方,起码这种实现方式容易让人理解。介绍这个条款并不是推广这种private继承的技巧,而是别无他法的时候才采纳这个实现方案。

条款40:明智而审慎地使用多重继承

多重继承(Multiple Inheritance,MI)是C++其中一把很著名的双刃剑,一方面给C++带来了丰富灵活的继承语法,另一方面又给C++的继承带来混乱。其中为了解决这个混乱,Java只允许单一继承,解决了不少问题,而且引入了接口(interface)关键字弥补灵活性的缺失。

首先:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Base1
{
public:
void f();
...
};

class Base2
{
public:
void f();
...
};

class Derived : public Base1, Base2
{
...
};

这样的话,当发生:

1
2
3
Derived d;

d.f();

就引起了歧义,引起编译错误。为了消除歧义,必须这样指定:

1
d.Base1::f();

明确调用哪个版本。

接下来就是菱形继承问题:

写段代码测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class A
{
public:
A()
{
cout << "A::A(" << this << ")" << endl;
}
};

class B : public A
{
public:
B()
{
cout << "B::B(" << this << ")" << endl;
}
};

class C : public A
{
public:
C()
{
cout << "C::C(" << this << ")" << endl;
}
};

class D : public B, public C
{
public:
D()
{
cout << "D::D(" << this << ")" << endl;
}
};

实例化一个D对象时,程序输出:

1
2
3
4
5
A::A(0x7ffecb763bce)
B::B(0x7ffecb763bce)
A::A(0x7ffecb763bcf)
C::C(0x7ffecb763bcf)
D::D(0x7ffecb763bce)

也就是说一个D实例中的有两份A实例,一份是属于BA以及一份属于CA

为了解决这样不统一的问题,可以用virtual继承:

1
2
3
4
5
6
7
8
9
10
11
class A
{...};

class B : virtual public A
{...};

class C : virtual public A
{...};

class D : public B, public C
{...};

这样实例化一个D对象时,程序输出:

1
2
3
4
A::A(0x7fffdd669580)
B::B(0x7fffdd669580)
C::C(0x7fffdd669588)
D::D(0x7fffdd669580)

这样就能够处理好公共爷类的问题了。考虑到继承树下面可能会有D的子类也出现这样的情况,所有的继承应用virtual继承防止,然而这样会使得实例占用的内存膨胀,也就是说virtual继承最好不要滥用。

作者在书上给出的建议,笔者认为就是Java单一继承多实现。也就是说约束继承语法使得程序只有单一继承原则,而灵活性由接口的多继承来补充,关于接口笔者在条款34中介绍过。

具体的做法,读者们可以去写写Java继承接口,就能总结出在C++中怎么写了。

Effective C++(八)

这里开始进入到继承与面向对象设计主题。虽然这是Effective C++书上的内容,但是笔者认为这部分的知识不仅仅局限于语言,毕竟从上个世纪面向对象这个概念被提出之后就被普遍地实践,C++的前身也是因为要适应这股潮流才被创造出来的。

笔者认为面向对象这个思想更关注的是代码的逻辑结构符不符合人的直观感受,或者是经验感受,接下来开始介绍这个主题下的条款吧。

条款32:确定你的public继承塑模出is-a关系

省略书上开头的例子,第一个涉及到的有关OOP的概念是里斯科夫替换原则(Liskov Substitution Principle,LSP),简称里式替换原则,这里再扩展一下SOLID原则,有兴趣的读者可以深入了解https://en.wikipedia.org/wiki/SOLID_(object-oriented_design)。LSP主要观点是“派生类(子类)对象能够替换其基类(超类)对象被使用”。假设有个继承关系AB的父类,A可以使用的场景,那么B一定可以应用在该场景中;反之不一定成立。

书上随后给出了学生这个基本的is-a关系,论证了public继承可以符合这个关系的实现。而后给出了“企鹅是一种鸟”,“鸟会飞”这两个基本事实,事实上应该说”鸟一般会飞“这个描述更为准确一些。用public继承描述了这样的关系:

1
2
3
4
5
6
7
8
9
class Bird{
public:
virtual void fly(); // 鸟”一般“可以飞
...
};

class Penguin : public Bird{ // 企鹅是一种鸟
...
};

然后就违背了“企鹅不会飞”这个事实。为了解决这个问题,鸟首先被分为了可以飞不会飞的两个类,然后企鹅继承不会飞的鸟类,problem solved:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Bird{
...
};

class FlyingBird : public Bird{
public:
virtual void fly();
...
};

class Penguin : public Bird{
...
};

这样便开始增加了继承体系的复杂性。这个复杂性笔者认为是来自于业务,因为构造这样的继承体系需要生物分类的基本事实,除非是为了开发一个覆盖现已发现的所有生物的相关的业务系统,否则继承体系不应该100%还原生物分类体系,并且还有一些分类不清的生物也增加了设计这个继承体系的难度,解决这个复杂度笔者认为不是数据结构设计上的问题,更多的是相关业务开发人员对该业务领域的认知能力。

书上给出了一个针对解决企鹅不会飞这个特例的解决方案:

1
2
3
4
5
6
classs Penguin : public Bird{
public:
virtual void fly(){
throw std::exception("Attempt to make a penguin fly!"); // 原书用一个自己实现的error函数
}
};

无论是原书用自己实现的一个error函数,还是笔者自行修改的抛出异常,本质上都是运行时报错。这个对代码的使用人员太不友好,也只能算是下下策。

还有办法就是取消鸟类里面的飞的函数,直到某个层次开始分类出能够飞或者不会飞的类属,这样在编译器就可以阻止使用者的错误的使用方式。但是这样问题就回到前面提到的继承体系复杂度的问题。

书上继续给出了矩形正方形的例子,贴上书上的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Rectangle{
public:
virtual void setHeight(int newHeight);
virtual void setWidth(int newWidth);
virtual int height() const;
virtual int width() const;
...
};

void makeBigger(Rectangle& r) // 增加r的面积
{
int oldHeight = r.height();
r.setWidth(r.width() + 10); // 增加r的宽10个单位
assert(r.height() == oldHeight); // 笔者不理解为什么要断言这个变化,这个约束也太严格了
}

上述代码保证了只改变矩形的宽,利用断言确定高度不会被改变,正方形的出场带来了一个难题:

1
2
3
4
5
6
class Square : public Rectangle{...};
Square s;
...
assert(s.width() == s.height()) // 对正方形的正常约束检查
makeBigger(s); // 1.问题来了
assert(s.width() == s.height()) // 对于正方形,这个约束应该成立

一个稍微认真考虑过正方形的设置长宽函数实现的编程人员应该会下意识的让setHeightsetWidth的行为一致,这个就会马上与makeBigger中的断言发生冲突。或者说忘记了正方形的性质,而导致正方形的长宽一致的断言失败。

书上没有讨论是谁的错,但是笔者认为是makeBigger函数的不合理导致的:

  1. 既然是改变面积的功能,从OOP角度考虑可以设计为图形的成员函数,并声明为virtual,赋予子类应对场景的能力;
  2. 也是从OOP角度来考虑,“makeBigger”这个函数对功能的描述模棱两可,根据行为来看,设置为“makeWidder”更加容易让人读懂。

所有的例子强调了public继承就要实现好is-a这个模型,使得其符合里氏替换原则,不然要么就是代码有问题,要么就是本身需要实现的模型就有问题。

此外还有常见的has-a(有一个)以及is-implemented-in-terms-of(根据某物实现出)这个关系,分别在条款38和39中介绍。

条款33:避免遮掩继承而来的名称

该条款涉及到的知识是作用域(Scopes),在介绍前面条款的时候,笔者也引用到了作用域的部分知识,贴上书上的代码作为例子:

1
2
3
4
5
6
int x;					// 全局变量
void fun()
{
double x; // 局部变量,此时在fun整个作用域内的x都是该变量
std::cin >> x; // 对x进行一些操作
}

在经过编程训练之后,笔者会把fun函数内变量x和全局变量x视作不同的变量,即便它们拥有相同的名字。类比成现实的话,就是一个名字可以指的是人,也可以是狗,即便是指人,也有可能是存在多个同名的人,这时候就需要提供更多的信息来对应了。用编译原理的知识来表达的话,就是根据上下文(Context)来推导。

至于推导的规则,一般是从当前的作用域内查找,失败就扩大作用域,直到整个上下文环境都查找失败为止。

同样,用书上的例子解释该条款:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Base{
private:
int x;
public:
virtual void mf1() = 0;
virtual void mf1(int);
virtual void mf2();
void mf3();
void mf3(double);
...
};

class Derived : public Base{
public:
virtual void mf1();
void mf3();
void mf4();
...
};

使用子类:

1
2
3
4
5
6
7
8
Derived d;
int x;
...
d.mf1(); // OK,使用Derived::mf1
d.mf1(1); // 错误,Derived::mf1遮掩了所有的Base::mf1
d.mf2(); // OK,Base::mf2
d.mf3(); // OK,Derived::mf3
d.mf3(); // 错误,Derived::mf3遮掩了所有的Base::mf3

注意两个mf1都是virtual的,笔者在自己做实验做验证的时候的设想是无参版本的mf1被复写了,所以能够正常调用;而带int参数的mf1则因为声明了virtual并且在Base中具有了实现,理应调用Base中的实现,然而实验结果证明了无论函数是否声明为virtual,只要在继承类中声明了与基类同名的函数,则基类中所有的同名函数都会被覆盖。

但是笔者同时也在实验中注意到,设置如下代码通过编译:

1
2
3
4
5
Derived d;
int x = 10;
d.mf1();
d.mf2();
d.mf3();

使用-S参数以及c++filt工具查看生成的汇编,则会发现编译器为以下:

1
2
3
4
Base::mf1(int)
Base::mf2()
Derived::mf1()
Derived::mf3()

这些成员函数生成了汇编码,其它成员函数因为编译器优化探测到没有使用而没有被生成。

有趣的是,生成的汇编码中对代码的解释有vtable的描述(经过人工处理):

1
2
3
4
5
6
7
8
vtable for Derived:
.quad Derived::mf1()
.quad Base::mf1(int)
.quad Base::mf2()
vtable for Base:
.quad __cxa_pure_virtual ;这应该是Base::mf1()
.quad Base::mf1(int)
.quad Base::mf2()

其中Derived类中的vtable竟然存在Base::mf1(int)这个描述,但是在代码主体中却没有,笔者也不了解为什么会这样,但是这始终只是一个描述性的部分,决定行为的还是代码主体,鉴于笔者当前的水平也只能先挖出这个奇异点了。

接着使用GDB中的info fuctions命令进行运行时查看各个类下的成员函数:

1
2
3
4
5
6
7
void Derived::Derived();
void Derived::mf1();
void Derived::mf3();

void Base::Base();
void Base::mf1(int);
void Base::mf2();

结果也对上了汇编码。

说了这么多,解决这个问题的方法有两个,一个是:

1
2
3
4
5
6
7
8
9
class Derived : public Base{
public:
using Base::mf1;
using Base::mf3;
virtual void mf1();
void mf3();
void mf4();
...
};

也就是显式声明让编译器把Base中被遮掩的函数在该子类中暴露,或者使用转交函数(forwarding function):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Base{
public:
virtual void mf1() = 0;
virtual void mf1(int);
...
};

class Derived : private Base{ // private继承使得只有这个直系子类能够调用Base中public的部分,Derived的子类则不可以访问Base中的任何部分
public:
virtual void mf1()
{ // 转交函数
Base::mf1(); // 隐式成为了inline
}
...
};

但是在Derived的实例中调用Base::mf1(int)仍然是错误的,因为遮掩仍在存在。

条款34:区分接口继承和实现继承

如果读者使用过Javainterface关键字做过一些实验的话,并对设计与实现分离这个原则有深刻的理解的话,这个条款应该可以跳过了。不过接口这个概念在C++中也可以很简单地就模拟出来:

  • 所有成员都是public
  • 没有成员变量
  • 所有成员函数都是纯虚函数

根源上来讲interface只是类的一种特殊情况,无非是Java在语法方面做了限制。正是这样的约束,提高了Java程序的质量下限,不需要了解为什么有interface关键字,只知道Java可以单继承,多实现的语法就行了。顺带一提,Java 8开始也支持接口中的方法写缺省实现了。

关于该条款,笔者认为书上的飞机例子就够用了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class Airport
{...};

class Airplane{
public:
virtual void fly(const Airport& destination) = 0; // 不提供fly的缺省实现,防止编译器让子类自动继承实现
protected:
void defaultFly(const Airport& destination); // 假如子类的fly用的缺省方式,则子类的fly实现显式调用该函数
};

void Airplane::defaultFly(const Airport& destination)
{
// 飞机飞向目的地的缺省行为
}

class ModelA : public Airplane
{
public:
virtual void fly(const Airport& destination)
{
defaultFly(destination); // 缺省飞行方式
}
...
};

class ModelB : public Airplane
{
public:
virtual void fly(const Airport& destination)
{
defaultFly(destination); // 缺省飞行方式
}
...
};

class ModelC : public Airplane
{
public:
virtual void fly(const Airport& destination)
{
// 该型号有别的飞行方式
}
...
};

如果还是想懒又优雅,可以继续给纯虚函数提供缺省实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class Airport
{...};

class Airplane{
public:
virtual void fly(const Airport& destination) = 0; // 提供fly的缺省实现,子类则需要在不使用缺省方式时显式提供自身的实现
};

void Airplane::fly(const Airport& destination)
{
// 飞机飞向目的地的缺省行为
}

class ModelA : public Airplane
{
public:
virtual void fly(const Airport& destination)
{
Airplane::fly(destination); // 缺省飞行方式
}
...
};

class ModelB : public Airplane
{
public:
virtual void fly(const Airport& destination)
{
Airplane::fly(destination); // 缺省飞行方式
}
...
};

class ModelC : public Airplane
{
public:
virtual void fly(const Airport& destination)
{
// 该型号有别的飞行方式
}
...
};

这些例子呈现设计与实现分离这个原则不够明显,更体现不出其威力。事实上这个原则有点解耦的意味,如果读者有接触过Java项目开发的话,应该很熟悉动不动就一个interface和一个对应的缺省实现。例如项目中的某个实现发现了可以性能改进的地方,但是不需要改进接口,如果接口和实现放在一起的话,那么意味着这个类需要整个编译一遍;如果采用了接口实现分离,只需要重新编译发生改动的类就可以了。极端一些,假设大片实现都需要更新,而接口不需要更改,这时候编译量的差就很客观了。

笔者入门时也不是太理解这个条款,这里只是提供一个原则,需要经过实践才能体验到这样做带来的好处。

这个条款主要是让读者能够区分接口继承以及实现继承,读者可以结合Javainterface理解。

条款35:考虑virtual函数以外的其他选择

该条款涉及到:

这两个设计模式,这跟软件开发的范畴十分相关,而且看起来十分相像,不过区分一下模板方法是由外部来决定一个具体的实现;而策略更多的是由对象自身的状态来决定使用什么实现,wiki上给了中国和美国交税的方法不同,笔者根据自身理解写一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// Template Method设计模式样例
class Tax
{
public:
virtual double payTax() = 0; // 声明template method
...
};

class ChnTax : class Tax
{
public:
virtual double payTax()
{
// 一些具体的代码
}
...
};

class UsaTax : class Tax
{
public:
virtual double payTax()
{
// 一些具体的代码
}
...
};

...

// 使用样例,由外部来决定使用什么实现
Tax tax = new ChnTax();

tax.payTax(); // 交中国税

...

// tax = new UsaTax();

tax.payTax(); // 交美国税

...

对比策略模式模式的构想:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class Tax
{
public:
enum Country{CHN, USA, ...}; // 对象所处的状态由具体的环境决定,在这个例子里面的状态就是所处国家

Tax(Country ct) : country(ct){} // 对象在被实例化时就应该赋予状态,或者专门设定一个状态变化的函数

void payTax()
{
if(country == Country::CHN) // 根据对象的状态采用对应的实现
{
chnPayTax();
}
else if(country == Country::USA)
{
usaPayTax();
}
...
}
private:
void chnPayTax() // 这些实现声明为私有
{
// 具体的代码
}

void usaPayTax()
{
// 具体的代码
}

Country country;
};

// 使用样例,外部赋予对象初始状态,或者对象自身就有状态

Tax tax = new Tax(Tax::Country::CHN); // 中国对象实例

tax.payTax(); // 内部已经有了状态,根据自身状态调用对应的策略

...

tax = new Tax(Tax::Country::USA); // 或者是一个美国状态实例

...

这是两种设计模式的介绍,书中给出的例子虽然跟笔者自己写的例子不是很相像,但是其核心思想是一样的。这也是初学者到进阶的其中一步,也就是遵循某种思想设计出具体的算法,比根据算法的抽象描述或者伪代码实现需要有更加进阶的编程能力要求。

但是笔者经过再读这个模板方法的例子,结合后文的修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class GameCharacter
{
public:
int healthValue() const // 该类子类不重新定义这个函数
{
...
int retVal = doHealthValue(); // 调用实际的实现版本
...
return retValue;
}
...
protected: // 最初是private,但是为了让子类能够正确感知到这个实际的实现并可以重新定义,protected比较合理
virtual int doHealthValue() const
{
... // 缺省的实现
}

};

笔者认为,这样做是把模板方法实现,使得调用的接口固定,对使用者友好,这样使用者就可以认为这样的方法只有一个,而不需要考虑多态的问题(虽然内部实现依靠了多态);而对于实现提供者,只需要处理实际实现的函数即可,而不需要关注接口的问题。

而接下来用策略注入(该名词为笔者根据)的方法实现策略模式。书上第一版的实现用的函数指针,这样的话就无法处理有参数变化的策略函数了,例如书上的:

1
2
3
4
typdef int(*HealthCalcFunc)(const GameCharacter);

GameCharacter::GameCharacter(HealthCalcFunc* hcf = defaultHealthFunc) : healthFunc(hcf);
{}

若日后计算的方法发生了改变使得这个计算函数的接口发生了变化,那么接收策略的构造器也会跟着出问题。在作者那个时期还没有C++11,所以作者用的boost::tr1,采用了tr1::function代替函数指针类型,而且可以使用bind解决上述提及的接口变化问题。

书上的例子比较复杂,也比较难直接看出是策略模式的一种实现,笔者还是用上面计算税的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
class Tax
{
public:
std::function<double()> CalculateTaxFun; // 声明没有参数,返回为double的函数类型

explicit Tax(CalculateTaxFun calcFun) : calcStrategy(calcFun){} // 构造器接受计算税的策略

void payTax() // 交税函数,不过在这个例子的用处是调用注入的计算税的策略
{
double taxVal = calcStrategy(); // 调用注入进来的计算税的策略获得应缴税的值
... // 处理税务
}
private:

CalculateTaxFun calcStrategy; // CalculateTaxFun的实例
};

enum State{CHN, USA, ...}; // 状态,其实是上个例子的国家

double calculateTax(State state)
{
double taxVal;

if(state == State::CHN)
{
... // 在中国,按照基本法算稅
}
else if(state == State::USA)
{
... // 美国
}
... // 其他情况

return taxVal;
}

....
// 使用样例,这时候策略的决定交由外部
Tax::CalculateTaxFun taxCalc(std::bind(calcStrategy, State::CHN, std::placeholders::_1)); // 定义一个中国计算税的策略
/*
* 上述的过程就是,用std::bind把calcStrategy这个函数的第一个参数绑定为State::CHN,
* 返回一个std::function<double()>的实例用于初始化一个策略
* 注意即便声明了using namespace std::placeholders,直接用_1也会直接出错,
* 所以只能打全称
*/
Tax tax(taxCalc); // 将计算税的策略注入到税实例中

tax.payTax(); // 交税,计算税的策略已经在里面了

Tax::CalculateTaxFun usaTaxCalc(std::bind(calcStrategy, State::USA, std::placeholders::_1)); // 美国的计算税策略

...

上面的例子讲述了用std::function实现的策略模式,读者可以细读上个例子,理解策略模式的思想(上面例子没有体现出对象根据自身状态选择策略,而是交给了外部),以及这个模式的一般实现。

Effective C++(七)

从这里开始进入实现主题,在此之前先区分好声明定义,对于这两者的概念,笔者从网上摘抄下来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
声明(declaration)指定了一个变量的标识符,用来描述变量的类型,是类型还是对象,或者函数等。声明,用于编译器(compiler)识别变量名所引用的实体。以下这些就是声明:

extern int bar;

extern int g(int, int);

double f(int, double); // 对于函数声明,extern关键字是可以省略的。

class foo; // 类的声明,前面是不能加class的。

定义(definition)是对声明的实现或者实例化。连接器(linker)需要它(定义)来引用内存实体。与上面的声明相应的定义如下:

int bar;

int g(int lhs, int rhs) {return lhs*rhs;}

double f(int i, double d) {return i+d;}

class foo {};// foo

带着这个基础知识,开始介绍本主题的内容。

条款26:尽可能延后变量定义式的出现时间

在大规模的软件项目出现之前,良好的编程习惯是,在该作用域的开始,就把所需要的变量声明以及定义好,这样后面的代码就集中于处理逻辑,代码组织也在某种程度上符合审美标准。但是现在推荐的做法是将在需要该变量前的一刻才定义这个变量,特别是一些涉及到要根据if-else逻辑产生的变量,书上就给出了一个很好的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
std::string encryptPassword(const std::string& password)
{
using namespace std;
if(password.length() < MINIMUM_PASSWORD_LENGTH)
{
throw logic_error("Password is too short");
}
... // 还有一些针对密码的规定的if-else处理进来的明文。

string encrypted(password); // 通过检查,可以加密原文了。
encrypt(encrypted); // 加密操作。
return encrypted;
}

当涉及到循环的时候,来看看怎么写:

1
2
3
4
5
6
7
// 方法A:定义于循环外
Widget w;
for(int i = 0; i < n; ++i)
{
w = 取决于i的某个值;
...
}

这样的开销是: 一次构造 + 一次析构 + n次赋值。

再来看看:

1
2
3
4
5
6
// 方法B:定义于循环内
for(int i = 0; i < n; ++i)
{
Widget w(取决于i的某个值);
...
}

开销: n次构造 + n次析构。

然后对比的就是构造+析构和赋值的开销了,根据场景取舍。

条款27:尽量少做转型动作

在开始讨论这个条款之前,先介绍转型语法,首先旧式的转型:

  • (T)expression,C风格转型动作。
  • T(expression),函数风格的转型动作。

C++提供4中新的转型动作:

  • const_cast<T>(expression),移除对象的常量性(cast away the constness)。
  • dynamic_cast<T>(expression),将一个父类对象安全向下转型(safe downcasting)为继承体系下的某个子类型。
  • reinterpret_cast<T>(expression),执行低级转型,实际动作和结果取决于编译器,使得程序变得不可移植。例如将pointer to int转为一个int
  • static_cast<T>(expression),强迫隐式转换,笔者认为效果等同与上述两种旧式转型,但是无法去除对象的常量性。

介绍完转型语法之后,书上也讲了不少转型的例子和分析,但是总的来讲就是程序设计不佳,使得在使用这份代码的时候不得不进行一些类型转换。在实际应用中,static_cast大多数用于基础类型的转换,例如int转换成float等;const_cast这种移除常量性的需求笔者尚未遇到过;dynamic_cast的使用表征着这个继承体系的基类成员接口没有设计好,笔者也只是在少数情况下才采用这个转型;reinterpret_cast估计只有写嵌入式程序方面的人才有可能用到。

dynamic_cast的问题一般很好解决,例如连串:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Window{...};
... // Window的子类定义。
typedef std::vector<std::shared_ptr<Window>> VPW;
VPW winPtrs;
...
for (VPW::iterator iter = winPtrs.begin();
iter != winPtrs.end(); ++iter)
{
if(SpecialWindow1 * psw1 = dynamic_cast<SpecialWindow1*>(iter->get())) {...}
else if(SpecialWindow2 * psw2 = dynamic_cast<SpecialWindow2*>(iter->get())) {...}
else if(SpecialWindow3 * psw3 = dynamic_cast<SpecialWindow3*>(iter->get())) {...}
...
}

那证明这个体系中,其子类共有的操作可以成为基类中的虚函数,是类型设计上的问题。

尽量避免使用转型,如果非转型不可,也尽可能使用新式转型语法。

不过考虑到现今的C++项目大多数开始都是老项目,而轻易修改老项目原代码是大忌,所以这些转型还是看着点用。

条款28:避免返回handles指向对象内部成分

这一条笔者认为与条款22有强烈联系,属于面向对象的设计原则之一,例如:

1
2
3
4
5
6
7
8
9
10
class Rational          // 有理数类
{
public:
Rational(int numerator = 0, int denominator = 1);
int numerator() const;
int denominator() const;
const Rational operator* (const Rational& rhs) const; // 用于支持有理数相乘
private:
...
};

既然已经把数据与类成员函数绑定在了一起,原本已经声明了成员变量都是private,那么如果返回指向对象内部的handles(引用,指针,迭代器等),那就相当于破坏了封装性,getterssetters形同虚设,使用者可以绕开这些设定好的接口和约束直接修改内部成员,极有可能破坏既定的行为。

因此,在没有特别的理由支持下,保持封装性比开放访问性是个更好的选择。

条款29:为“异常安全”而努力是值得的

正如这个系列刚开始,笔者探讨C with class的问题时提出的场景一样,如果程序动不动就要处理错误,那么为了处理这些错误,可以写出比原来为了满足需求而实现的代码长好几倍的代码。笔者尚未经历过C++老项目的维护,但是从其他项目的经验来看,异常处理这一部分并不会特别关注。一个是由于各种框架的出现,使得底层设施这种复用性高,又是关键部分的代码已经得到了很好的异常处理,而使用者仅仅处理业务层的代码,框架一般也提供了事务机制,这就使得使用者只需要在发生错误的时候回滚就行了;二是异常本来就不应该是频发的程序场景,如果一个程序中运行频繁地出现异常,要么就是运行环境太差,要么就是编程人员基础太差。

不过为了满足程序的健壮性,一些异常处理仍然是必须的,但是要处理到何种程度,笔者也不能给出评价标准,毕竟笔者也见过没有任何异常处理的程序稳定运行的案例。如果是在实际项目中,功能的实现是第一目标,异常处理是辅助手段。

书上定义了带有异常安全性的函数该有的行为,即异常被抛出的时候:

  1. 不泄漏任何资源。
  2. 不允许数据败坏。

为了满足这两个行为,笔者给出socket编程中的经典例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Initialize server parameters.
listenFileDescriptor = socket(AF_INET, SOCK_STREAM, 0);

bzero(&serverAddr, sizeof(serverAddr));

serverAddr.sin_family = AF_INET;
serverAddr.sin_addr.s_addr = htonl(INADDR_ANY);
serverAddr.sin_port = htons(SERVER_PORT);
// Parameters set. Injecting the server parameters to system.

bind(listenFileDescriptor, (sockaddr*) &serverAddr, sizeof(serverAddr));

listen(listenFileDescriptor, LISTEN_QUEUE);
// Server initialization finished.

里面的socketbindlisten函数都有可能因为系统资源的不足而初始化失败,虽然这里没有用到C++的异常语法,但是也能代表为了满足异常处理要怎么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// Initialize server parameters.
listenFileDescriptor = socket(AF_INET, SOCK_STREAM, 0);

if(listenFileDescriptor < 0)
{
// 可以输出一些错误提示。
return ERROR_CODE_SOCKET; // 或者调用exit等。
}

bzero(&serverAddr, sizeof(serverAddr));

serverAddr.sin_family = AF_INET;
serverAddr.sin_addr.s_addr = htonl(INADDR_ANY);
serverAddr.sin_port = htons(SERVER_PORT);
// Parameters set. Injecting the server parameters to system.

int ret_val = bind(listenFileDescriptor, (sockaddr*) &serverAddr, sizeof(serverAddr));

if(ret_val != 0)
{
// 错误提示。
close(listenFileDescriptor); // 因为初始化失败,要释放资源。
return ERROR_CODE_BIND; // 同理可以采用exit。
}

ret_val = listen(listenFileDescriptor, LISTEN_QUEUE);

if(ret_val != 0)
{
// 错误提示。
close(listenFileDescriptor); // 因为初始化失败,要释放资源。
return ERROR_CODE_LISTEN; // 同理可以采用exit。
}
// Server initialization finished.

为了作出上述的两个保证,代码不得不变长。

异常安全函数(Exception-safe functions)提供以下三个保证之一:

  1. 基本承诺:如果异常被抛出,程序内的任何事物仍然保持在有效状态下。没有任何对象或数据结构会因此而败坏,所有对象都处于一中内部前后一致的状态。然而程序的现实状态(exact state)恐怕不可预料。
  2. 强烈保证:如果异常被抛出,程序状态不改变。如果函数成功则完全成功,如果失败则回滚,具体例子可以看看上面Server Socket初始化。
  3. 不抛掷(nothrow)保证:承诺绝不抛出异常,因为它们总是能够完成它们原先承诺的功能。作用于内置类型身上的所有操作都提供nothrow保证。

看完了介绍的概念,笔者认为书上除了例子之外值得做笔记的地方就是copy and swap,具体方法是在作用于一个对象前先保存其副本,中途任何失败都放弃修改,返回修改前的对象副本。

条款30:透彻了解inlining的里里外外

关于该条款,笔者认为较为重要的是其:

1
inline只是对编译器的一个申请,不是强制命令。

也就是说给一个函数加上inline关键字修饰时,编译器视该修饰为一个建议,不一定会对其进行代码展开。

同时滥用inline会导致代码膨胀,甚至造成比不加inline时的效率更低的可能。

书上建议将大多数inlining限制在小型、被频繁调用的函数上。因为被inline牵涉的函数的调用者在进行二进制升级的时候也会被牵连着更行。

条款31:将文件间的编译依存关系降至最低

有良好的编程习惯的程序员应该会习惯性地把一个类的声明以及其定义分开为两个文件。当类间呈现出聚合或者组合等关系时,被嵌入的类就要在包含它的类中声明,例如,设计一个基本的类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Mem1
{
public:
Mem1();

void setIntMem(int val);

void setFloatMem(float val);

int getIntMem() const;

float getFloatMem() const;
private:
int intMem;
float floatMem;
};

然后外覆一个包裹器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Wrapper
{
public:
Wrapper();

void setIntMem(int val);

void setFloatMem(float val);

int getIntMem() const;

float getFloatMem() const;
private:

Mem1 mem1;
};

这个Wrapper的声明文件里面没有用到Mem1这个类的任何方法和成员,但是必须得获取得到Mem.h里面的内容,也就是该头文件里面必须包含这个Mem1类的头文件。如果使用编译命令:

1
$ g++ -E Wrapper

可以看到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# 1 "Projects/ShallNotLiveLong/interlink/Wrapper.h"
# 1 "<built-in>"
# 1 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 1 "<command-line>" 2
# 1 "Projects/ShallNotLiveLong/interlink/Wrapper.h"
# 1 "Projects/ShallNotLiveLong/interlink/Mem1.h" 1
class Mem1
{
public:
Mem1();

void setIntMem(int val);

void setFloatMem(float val);

int getIntMem() const;

float getFloatMem() const;
private:
int intMem;
float floatMem;
};
# 2 "Projects/ShallNotLiveLong/interlink/Wrapper.h" 2

class Wrapper
{
public:
Wrapper();

void setIntMem(int val);

void setFloatMem(float val);

int getIntMem() const;

float getFloatMem() const;
private:

Mem1 mem1;
};

也就是说这个Wrapper的头文件需要读入整个Mem1.h的所有内容,然后再编译,当关系链很长的时候,处于末端的类的声明文件也就随之膨胀了。而且这个头文件里面也没有用到其包含的类的函数等接口。

解决的方法是前置声明,也就是在该文件中先声明该类会包含那些类,但是不去获取其具体的声明(其实也可以叫类的定义,但是其成员函数没有定义,仅仅声明)。使用这个手法,则Wrapper的声明文件要这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Mem1;

class Wrapper
{
public:
Wrapper();

void setIntMem(int val);

void setFloatMem(float val);

int getIntMem() const;

float getFloatMem() const;
private:

Mem1* mem1;
};

注意:一一旦开始使用前置声明,那么这个被声明的类在该头文件中的实例只能被声明为该类的指针变量或者引用,否则该头文件仍然会要求读取被包含的类的定义。

施用该手法之后,使用g++ -E Wrapper.h命令输出看展开后的文件,发现Mem1类的声明内容消失了,成功地把具体的内容向后移至实现文件以及链接期中。

注意对于标准库,例如std::string等就不要吝啬这点编译空间了,一个是std::string是一个typedef而不是;第二是这个是标准库的内容,性能是经过考验的,要担心的还是自己的实现部分。

该手法的缺点也存在的,第一点当然是指针的使用使得实现者不得不手动管理内存,或者引入智能指针等来管理这些动态分配的内容。

这个条款后面也有介绍与这个手法组合起来相关的内容,例如接口与实现分离代理类等与设计模式相关的概念,在这里就先不展开介绍了。

总的来说,这个条款推荐的是用声明式取代定义式,使得编译的依存性最小化,同时也有避免编译出来的二进制文件发生不必要的代码膨胀的作用。

Effective C++(六)

从这里开始就进入了设计与声明主题,这个主题是对入门者,尤其是在校的学生来说是很有用的编程建议。一来是学生项目一般小型,不需要大规模的组织;第二是没有生命周期,完成之后交付即可,所以实战项目让学生非常痛苦的事就是阅读代码,尤其是理顺逻辑。

在经历过实战项目历练之后才会知道组织代码的重要性,包括变量函数命名、写文档(简易文档也有参考性)等等,这样在日后项目有改动的时候不至于不知从何入手。笔者接触的实战项目尚少,也只能写点这样的感慨了。

条款18:让接口容易被正确使用,不易被误用

笔者阅读过该条款,认为该问题的主要来源是C++隐式类型转换缺省参数自动推导等特性综合起来导致的。扩展开来讲的话,应该是C++类型的管理宽松所导致的。虽然C++从语法上看是强类型语言,但是通过设置编译参数就可以跳过类型转换等操作,从内存层面来看的话更像是弱类型语言,如下例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class A
{
public:
int mem1;
};

class B
{
public:
int mem1;
float mem2;
};

int main(int argc, char* argv[])
{
A* objA = new A;

objA->mem1 = 3;

B* objB = (B*)objA;

cout << objB->mem1 << endl;

objB->mem1 = 4;

cout << objA->mem1 << endl;

delete objA;

return 0;
}

编译过程(即便加了-Wall参数)没有警告,运行得出:

1
2
3
4

甚至可以在违背事实的情况下,作出objB->mem2=0.12等操作造成难以发现的隐患。

如果同样在Java里面实现一样的代码的话,编译肯定是不通过的,但是这样的操作却在C++是允许的。这一方面为C++增加了不少的自由度,可以写出很灵活的代码;另一方面却给C++入门者一个极大的挑战,本身指针这个概念也难倒了不少入门者,当成功跨越这个门槛之后,却发现指针背后深藏更多的挑战,造成了开发调试上的困难。

好了,现在引用书上的例子,假设有个日期类:

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

书上是按美国的日期顺序月-日-年,笔者改成了更符合国情的格式。直到程序出现不符合预期之前,一般使用者也不会特意去查看文档(如果有的话),那么就会有如下的使用:

1
2
Date d1(2017, 12, 19);
Date d2(12, 19, 2017);

因为都是整型,编译器也不会给出什么错误,所以估计出错前也不会有人在意,这样的误用,该怎么样在早期就能提醒?书上给出了外覆类型(Wrapper types),就是让编译器做类型检查:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
struct Day{
explicit Day(int d)
: val(d)
{}
int val;
};

struct Month{
explicit Month(int m)
: val(m)
{}
int val;
};

struct Year{
explicit Year(int y)
: val(y)
{}
int val;
};

class Date
{
public:
Date(const Year& y, const Month& m, const Day& d);
...
}

这样就会强迫使用者:

1
Date d(Year(2017), Month(12), Day(19));

当然也可以使用枚举类型做一些替换,笔者觉得本质上也是外覆类型的一种变化,总之就是启用编译器的类型检查来纠错该接口的误用

条款19:设计class犹如设计type

这个条款提出了一系列引导思考的问题。

新type的对象应该如何被创建和销毁?

  • 构造函数的设计,复制构造函数等。
  • 动态分配的成员,在构造中new还是new[],对应好析构器中的delete形式。
  • 结合前面,动态分配的成员在该类复制行为中该如何处理。

对象的初始化和对象的赋值行为该有什么样的差别?

  • 初始化构造器。
  • assignment赋值操作符。

新type的对象如果被pass by value(值传递),意味着什么?

什么是新type的“合法值”?

  • 例如上个条款中的Date,其月份和日有着相应的值域范围。

你的新type需要配合某个继承图系(inheritance graph)吗?

  • virtual关键字声明的时机。

你的新type需要什么样的转换?

  • 显式的转换调用增加程序的可靠性,隐式转换增加易用性。

什么样的操作符和函数对此新type而言是合理的?

  • 函数接口还能根据场景限制,操作符重载就真的得小心设计了,错误的设计会坑害使用者。

什么样的标准函数应该驳回?

  • 条款6.

谁该取用新type的成员?

  • 遵照面向对象设计原则,所有成员应该为private,然而这个不是万能的。所谓的原则就是在一头雾水的情况下先顶着用的后备方案。

什么是新type的“未声明接口”(undeclared interface)?

  • 对效率、异常安全性以及资源运用提供何种保证?依此加上约束条件。

你的新type有多么一般化?

  • 通用性很强的话,就是一组types了,定一个新的class template会大大减少代码的重复。

你真的需要一个新type吗?

  • 不要重复造轮子。

条款20:宁以pass-by-reference-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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
class Base
{
public:
Base(string name_para = "base") : name(name_para)
{
cout << "Base::Base(this=" << this << ")" << endl;
}
// 在稍后的processVal函数中,涉及到复制构造,如果不重载复制构造函数,就看不到试验结果。
Base(const Base& rhs) : name(rhs.name)
{
cout << "Base::Base(this=" << this << ", &rhs=" << &rhs << ")" << endl;
}
virtual void display() const
{
cout << "(Base Object)" << name << endl;
}
virtual ~Base()
{
cout << "Base::~Base(" << this << ")" << endl;
}
protected:
string name;
};

class Derived : public Base
{
public:
Derived(string name_para = "base") : Base(name_para)
{
cout << "Derived::Derived(this=" << this << ")" << endl;
}
// Derived也需要重载作为对比。
Derived(const Derived& rhs) : Base(rhs)
{
cout << "Derived::Derived(this=" << this << ", &rhs=" << &rhs << ")" << endl;
}
virtual void display() const
{
cout << "(Derived Object)" << name << endl;
}
~Derived()
{
cout << "Derived::~Derived(" << this << ")" << endl;
}

private:
};

void processVal(Base base)
{
base.display();
}

// 不能重载processVal函数,否则因为重载决议推断一直使用Derived版,所以这里独立写出一个函数。
void processDerVal(Derived derived)
{
derived.display();
}

void processRef(const Base& base)
{
base.display();
}

void processPtr(const Base* const base)
{
base->display();
}

int main(int argc, char* argv[])
{
Derived d;

cout << "----Process by Base value----" << endl;

processVal(d);

cout << "----Process by Derived value----" << endl;

processDerVal(d);

cout << "----Process by refernce----" << endl;

processRef(d);

cout << "----Process by pointer----" << endl;

processPtr(&d);

cout << "----All done----" << endl;

return 0;
}

编译运行得到输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Base::Base(this=0x7ffce31ef230)
Derived::Derived(this=0x7ffce31ef230)
----Process by Base value----
Base::Base(this=0x7ffce31ef290, &rhs=0x7ffce31ef230)
(Base Object)base
Base::~Base(0x7ffce31ef290)
----Process by Derived value----
Base::Base(this=0x7ffce31ef2c0, &rhs=0x7ffce31ef230)
Derived::Derived(this=0x7ffce31ef2c0, &rhs=0x7ffce31ef230)
(Derived Object)base
Derived::~Derived(0x7ffce31ef2c0)
Base::~Base(0x7ffce31ef2c0)
----Process by refernce----
(Derived Object)base
----Process by pointer----
(Derived Object)base
----All done----
Derived::~Derived(0x7ffce31ef230)
Base::~Base(0x7ffce31ef230)

输出首尾是在main函数作用域,不需要关注,看到四个函数中,凡是以值传递的函数都调用了其传入参数对应的构造函数,新建出新的对象,并且造成了切割(slicing)现象。

笔者认为切割现象只是以值传递造成的新对象被构造出来的附带现象,其主要原因还是该方式是复制新的对象进入函数作用域中。且不说切割的问题,若该函数被频繁调用,那么频繁的构造和析构也能带来客观的性能开销了,更不要说更大的对象的构造问题了。

条款21:必须返回对象时,别妄想返回其reference

经过上个条款的学习之后,大部分初学者应该会不留余力地去除pass-by-value这样的使用,以至于一个函数的返回值都返回一个对象的引用。其实这也是初学者常犯的一个错误,但是一旦经过学习,这个编程习惯还是挺容易就记住的。这里面涉及到的主要的知识点无非是变量的作用域,或者说是变量的生命周期,看看上个条款中笔者给出的例子,分析得出:

1
2
3
4
5
6
7
8
9
10
在main构造一个Derived实例derived;
---过渡带,保存一些当前上下文的信息,准备进入processVal函数上下文
以derived实例为原本复制一个Base实例,暂且变量名base;
---进入processVal函数的作用域
调用base.display();
---退出processVal作用域
析构base实例;
---切换上下文,回到main
... // 涉及到以值传递的函数都会发生上述过程。
在main中析构derived实例;

假设有个函数新建一个Base对象,并且返回其引用,原型如下:

1
2
3
4
5
const Base& generateBase()
{
Base base;
return base;
}

并在main中使用:

1
2
3
4
5
int main(int argc, char* argv[])
{
generateBase().display();
return 0;
}

幸运的话也许还能正常输出,不过基本上都是报错告终的了。

把main像上面那样翻译一下:

1
2
3
4
5
6
7
8
9
        ---保存main上下文
---进入generateBase函数作用域
构造一个Base实例;
析构这个Base实例;
返回这个Base实例的引用;
---退出generateBase函数作用域
把返回的引用加入到main上下文;
---切换回main的上下文
调用这个返回的base对象的display函数

显然,Base对象已经被析构了,调用一个被析构的对象当然产生为定义的行为。

记得被调用的函数的作用域肯定比调用该函数的函数的作用域段,那么被调用的函数里面的对象当然不能返回其引用给上一级了。

这个时候要么乖乖地返回一个值,向性能效率妥协;要么就在堆上新建对象返回其指针,对自己的程序设计有信心的话,手动管理内存,幸好现在智能指针是标准库的一部分,也可以考虑考虑。

条款22:将成员变量声明为private

在学习过Java之后,笔者都忘了为何要这么设计一个类的。不过从书上看来,这个是前人总结出来的经验,如果不是对自己的项目设计能力特别有信心的话,循着经验来总不会错的。

将成员变量设置为private,然后通过gettersetter的实现来控制好外部对该类内部成员变量的控制。前人的经验,虽然有时候会有些不方便,但是有个总体的原则在,程序就不会太乱了。

条款23:宁以non-member、non-friend替换member函数

对于该条款,笔者再次阅读的时候也没完全理解,不过大概的意思就是过度封装的问题,面向对象的设计思想确实很强大,也很好用,但是滥用也是会出问题的。也有可能有时候从业务逻辑上看,某个函数是某个对象的成员函数不太符合直观感觉;或者某个种行为对该模块里面的类通用,抽取出来变为一个通用的函数。

其实这样的问题会在Java这类完全以对象为基础的语言中更加突出,例如库函数sincos等明明可以成为一个独立的函数,在Java中却必须定义在某个类中,哪怕是static也好。例如进行一次sin:

1
Math.sin(1);

C++容器类(vector, map, set等)和算法库(<alogorithm>)就是一个对抗过度封装的很好的例子,举一个find的例子:

1
2
3
4
vector<int> vecCon;
map<int,int> mapCon;
vector<int>::iterator vIter = find(vecCon.begin(), vecCon.end(), 3);
map<int,int>::iterator mIter = find(mapCon.begin(), mapCon.end(), make_pair(1, 3));

算法库中有不少这样的函数,对于每种容器类,其通用操作查找,添加等,如果为每个容器都写一次,那么代码的重复性就太高了,当然使用者会很感谢的。并且这样的操作本身也不会与容器中的类中的成员有绑定现象。

其实归根结底还是写代码的人对与场景的分析。

条款24:若所有参数皆需要类型转换,请为此采用non-member函数

这个条款从书上的例子出发:

1
2
3
4
5
6
7
8
9
10
class Rational          // 有理数类
{
public:
Rational(int numerator = 0, int denominator = 1);
int numerator() const;
int denominator() const;
const Rational operator* (const Rational& rhs) const; // 用于支持有理数相乘
private:
...
};

有如下的调用:

1
2
3
4
5
Rational oneEight(1, 8);
Rational oneHalf(1, 2);
Rational result = onHalf * oneEight; // ok
result = oneHalf * 2; // ok
result = 2 * oneHalf; // 编译不通过

对于第二个相乘,编译器产生如下代码:

1
2
const Rational temp(2);
result = oneHalf * temp; // 调用Rational::operator*(const Rational& rhs);

但是对于数值2,起码C++没有为数值提供类定义,只是一个普通的数值,如果非得用面向对象来看的话那么第三个相乘操作调用的是:

1
const Rational int::operator*(const Rational& rhs);

显然就算有int这个类,因为是内建类型,修改其定义是非常疯狂的行为。因为无法定义这个函数,编译器也无法获得Rationalint的隐式转换,当然编译不通过了。于是书上将相乘操作提出来成为一个独立的函数:

1
2
3
4
const Rational operator*(const Ratinal& lhs, const Rational& rhs)
{
return Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator());
}

这样原来的2 * oneHalf自动推导中会得出:

1
2
const Rational temp(2);
result = temp * oneHalf;

完成隐式类型转换并推导出使用const Rational operator*(const Ratinal& lhs, const Rational& rhs)这个函数,编译通过。

条款25:考虑写一个不抛异常的swap函数

笔者对该条款没有什么特别的感受,所以简述书上的总结带过好了。

  1. 当std::swap对你的类型效率不高时,提供一个swap成员函数,并确定这个函数不抛出异常。
  2. 如果你提供一个member swap,也该提供一个non-member swap用来调用前者。对月classes(而非templates),也请特化std::swap。
  3. 调用swap时应针对std::swap使用using声明式,然后调用swap并且不带任何“明明空间资格修饰”。
  4. 为“用户定义类型”进行std templates全特化是好的,但千万不要尝试在std内加入某些对std而言全新的东西。