读书笔记 Effective C++

共有55个法则。

1、视C++为一个语言联邦

C++包含多个次语言:

  • C
  • 面向对象
  • 模板
  • STL

每个次语言都有自己不同的规范。

2、尽量用const,enum,inline代替#define

#define宏定义直接被替换掉,因此像

1
#define NUM 123

当程序获得一个编译错误时,不会出现NUM,因为NUM已经被123替换掉了,可能存在多个123,因此就无从得知123对应的是什么意思,用const就不会有这种问题。此外const还可以定义类内的专属常量,比如:

1
2
3
4
5
class GamePlayer{
private:
static const int NumTurns = 5;
int scores[NumTurns];
}

使用enum hack也可以做到这一点:

1
2
3
4
5
class GamePlayer{
private:
enum { NumTurns = 5 };
int scores[NumTurns];
}

enum中的数值可以当作int来使用。
这种常量是类内专用的,如果用#define就做不到这一点,因为#define的值是作用到全局的。enum hack的行为更像#define,它们都不能取地址,而const可以取地址。

对于函数形式的宏,会导致各种各样的问题,最好用inline函数代替。

3、尽可能使用const

const修饰的对象不能被改变,编译器会强制实行这个约束。

const作用于类的成员函数时,不能对成员变量进行修改,但是通过使用mutable关键字的成员变量可以在const成员函数里被改变。

当const成员函数和non-const成员函数的实现等价时,可以让non-const函数调用const版本避免代码重复,方法是用const_cast去除const属性。如下例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class TextBlock{
public:
const char& operator[](std::size_t position) const
{
...
...
...
return text[position];
}

char& operator[](std::size_t position)
{
return const_cast<char&>( //去除const属性
static_cast<const TextBlock&>(*this) //将*this转为const调用const版本的[]
[position]);
}
}

4、确定对象被使用前已先被初始化

对于内置类型如int、double等,必须手工完成初始化,C++不保证初始化他们。

对于类类型,构造函数必须保证初始化所有的成员,最好的方式是使用初始化列表而不是在函数体内赋值,初始化列表使用对应参数直接进行拷贝构造,而函数体内赋值则是先经过默认构造函数构造然后再进行赋值,效率较低,且const,引用和没有默认构造函数的类成员只能在初始化列表中进行初始化。

对于位于多个不同文件中的static对象,为了避免初始化次序带来的问题,如A的初始化依赖于B,而B不保证在A初始化之前进行初始化,可以将static对象放到一个函数里,在函数里声明局部的static对象,返回这个对象的引用,将直接使用static对象改为调用函数来使用static对象,这样就保证static对象在使用时一定已经经过了初始化。如下是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class FileSystem{...};
FileSystem& tfs(){
static FileSystem fs;
return fs;
}

class Directory{...};
Directory::Directory(params){
...
std::size_t disks = tfs().numDisks();
...
}
Directory& tempDir(){
static Directory td;
return td;
}

tfs()函数保证fs对象在使用前已经进行了初始化。

5、了解C++默默编写并调用哪些函数

编译器会自动为class创建默认构造函数、拷贝构造函数、赋值操作符、析构函数。只要用户自己声明了一个构造函数,编译器就不会自动创建默认构造函数。
如果类成员包含const或者引用,编译器不会自动合成一个赋值操作符,因为const和引用不能赋值,此时需要自己编写赋值操作符。
如果基类的赋值操作符是private的,则编译器也不会为其派生类合成赋值操作符。

6、若不想使用编译器自动生成的函数,就该明确拒绝

可以将成员函数声明为private的,来阻止编译器的自动合成行为。比如我们不希望一个类进行拷贝或赋值,可以将拷贝构造函数和赋值操作符声明为private,且不给出实现,如此当程序试图拷贝这个类时,编译器会报错,当类的成员函数或友元调用这个函数时,由于没有给出实现,因此连接器会报错。

1
2
3
4
5
6
7
8
class HomeForSale{
public:
...
private:
...
HomeForSale(const HomeForSale&);
HomeForSale& operator=(const HomeForSale&); //只声明不定义
}

第二种方法是设计一个不能拷贝的基类,让其余类派生自这个基类,则这些派生类也就不能拷贝了。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Uncopyable{
public:
//允许构造和析构
Uncopyable();
~Uncopyable();
private:
//不允许拷贝和赋值
Uncopyable(const Uncopyable&);
Uncopyable& operator=(const Uncopyable&);
}
class HomeForSale:private Uncopyable{
...
}

7、为多态基类声明virtual析构函数

考虑下面的例子:

1
2
3
4
5
6
7
8
9
10
11
class TimeKeeper{
public:
TimeKeeper();
~TimeKeeper();
}
class AtomicClock:public TimerKeeper{
...
}
TimeKeeper* tk = new AtomicClock;
...
delete tk;

创建一个基类的指针指向派生类对象,而该指针释放时,却由基类指针进行释放,而且析构函数不是virtual的,因此只有基类的部分进行了析构,而派生类的部分没有得到析构,因此造成了内存泄漏。将基类的析构函数声明为virtual就可解决这个问题。往往一个基类只要包含了其他的virtual函数,它的析构函数也要是virtual的,virtual函数是用来实现多态的,需要用基类的指针指向派生类对象,因此析构函数必须是virtual。若一个基类没有virtual函数,说明这个基类不是用于多态,就不需要virtual析构函数,因为virtual函数带来了额外的vptr的空间开销,因此若不需要多态就不应该将析构函数声明为virtual的。

8、别让异常逃离析构函数

只要析构函数抛出异常,程序可能过早结束或者发生不明确的行为。如果某个操作失败时会发生异常,则应该将这个操作移出析构函数放到另外一个函数里面,在析构函数里调用这个函数,此时析构函数应该捕捉任何异常,并且选择吞下它们不进行传播或者结束程序(abort函数)。

9、绝不在构造和析构过程中调用virtual函数

在构造和析构过程中调用virtual函数往往不会带来预期的结果。在派生类对象构造时,基类部分会先进行构造,如果基类的构造函数里调用了virtual函数,此时派生类部分还未初始化完成,因此这个函数不会下降到派生类去调用派生类里面的virtual函数,此时virtual函数的行为就像一个non-virtual函数一样,只是调用基类的,另一个根本原因是在派生类的基类部分构造期间,对象的类型就是基类而不是派生类,因此不存在多态。析构函数也一样,派生类优先于基类析构,如果此时析构函数调用了virtual函数,因为派生类部分已经被销毁了,调用派生类的virtual函数也是危险的。

10、令operator=返回一个reference to *this

为了实现连锁赋值,operator=应该返回一个指向左侧对象的引用。

1
2
3
4
5
6
7
8
class Widget{
public:
...
Widget& operator=(const Widget& rhs){
...
return *this;
}
}

11、在oprator=里处理自我赋值

由于多个指针或者引用可能指向同一个对象,所以对自我赋值需要特别注意。

1
2
3
4
5
6
7
8
9
10
11
class Widget{
private:
Bitmap* pb;
public:
...
Widget& operator=(const Widget& rhs){
delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}
}

以上的代码未考虑自我赋值情况,如果*this和rhs是同一对象时,第一行的delete就已经将rhs释放掉了。可以加上一个测试以解决自我赋值:

1
2
3
4
5
6
Widget& operator=(const Widget& rhs){
if(this == &rhs) return *this;
delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}

但是上面这个版本不具备异常安全,当new抛出异常时,bp指向的对象已经被释放了,所以返回的是一个指向已经被删除的指针,这种指针非常危险,下面是一个异常安全的做法:

1
2
3
4
5
6
Widget& operator=(const Widget& rhs){
Bitmap* pOrigin = pb; //记住原来的pb
pb = new Bitmap(*rhs.pb); //构造新的pb
delete pOrigin; //释放旧的pb
return *this;
}

另一个方案是采用swap and copy:

1
2
3
4
5
Widget& operator=(const Widget& rhs){
Widget temp(rhs); //copy rhs
swap(temp); //swap
return *this;
}//temp对象析构,此时temp指向的原来的pb,被析构掉了

12、复制对象时勿忘其每一个成分

拷贝构造函数和赋值函数应该确保对每一个成员进行复制,特别是一个派生类的拷贝构造函数应该调用基类的构造函数来复制基类的部分。

1
2
3
4
5
class Base{...};
class Derived:public Base{
public:
Derived(const Derived& rhs):Base(rhs){...};
}

拷贝构造函数和赋值函数不应该互相调用对方,如果有相同的操作,应该放到第三个函数中,由两个函数调用。

分享到

编译原理学习笔记(五)代码生成

任务

后端的第一步,对抽象语法树进行处理,生成一个或多个中间代码,最后生成机器能识别的目标代码。
有两个重要任务,第一是给源代码的数据分配资源,二是给源代码选择合适的指令。
资源有寄存器、数据区、代码区、栈、堆等。寄存器的存取速度最快,但是数量有限。
指令有算数运算、比较、跳转、函数调用返回等。不同的机器指令集也不同。

分享到

编译原理学习笔记(四)语义分析

语义分析的任务

对抽象语法树进行分析,检查有无语义错误,上下文相关属性,如变量使用前是否声明,函数参数是否一致等。
需要两个输入,一个是语法树,一个是程序语言的语义。输出为中间代码。

实现方法

通过类型检查算法和符号表来实现检查。
类型检查算法用来检查每个表达式或语句的类型是什么,是否符合规范。

符号表

用来存储程序中各个变量的相关信息,如类型,作用域,访问控制信息,是一种key-value结构。
节省时间,可以用哈希表,节省空间,可以用红黑树。
符号表处理作用域的方法:一是用一张表,进入作用域就插入元素,离开作用域就删除元素,二是用多张表,构成一个栈,进入作用域就插入一张新表,离开就删除栈顶的表。
符号表处理名字空间的方法:引入标签,标号来区别不同类型。

分享到

编译原理学习笔记(三)语法分析

语法分析的任务

语法分析接受词法分析产生的记号流,对其进行分析,检查有无语法错误,有错误返回错误信息,无错误则生成抽象语法树。

语法分析还需要知道一组语法规则,利用这些规则来判断输入的记号流有无语法错误。

语法分析实现方式

语法规则用上下文无关文法来描述。

分析过程分为两类,自顶向下分析,自底向上分析。

自顶向下分析

有分析树,递归下降法和LL(1)分析算法。
分析树需要回溯,效率低。而递归下降和LL分析算法都是线性时间复杂度。

递归下降的思想是每个非终结符构造一个分析函数,用一个前看符号来指导产生式规则的选择。
根据FIRST集和FOLLOW集来构造分析表,避免递归回溯

LL(1)分析算法的意思是从左向右读入字符,从左推导,采用一个前看符号,是一种基于表驱动的分析算法。

自底向上分析

LR分析算法。运行高效,有现成工具能用,如YACC,bison等。
LR(0)分析算法,核心思想是移进,归约,利用分析表产生一个逆序的最右推倒。
SLR和LR(1)都是对LR(0)的改进算法。

语法制导翻译

给每条产生式规则附加一个语义动作,当归约时,就执行这个动作进行计算,到所有的计算完成后,就可以得到结果。

抽象语法树

与分析树非常类似,去除了无用的节点,是语法分析的最后一个过程,分析完毕后输出一个抽象语法树。
抽象语法树的数据结构和树类似,可以手工编码或者自动生成。

自动生成的方法是在语法制导翻译时,语义动作就定义为语法树的构造函数,自底向上地构造树。

分享到

编译原理学习笔记(二)词法分析

词法分析的任务

词法分析是编译器中第一个模块,接收用户的输入(高级语言的源代码),生成一种内部的数据结构(记号流),交给下一个模块语法分析进行处理。

源代码被看成是字符流,词法分析分析这些字符,产生记号流,记号流是编译器内部定义的一种数据结构。

词法分析的实现方式

分为手工编码和自动生成方式。
手工编码即为整个分析器都自己手动编写,由于细节很多,实现起来较为复杂。
自动生成方式为通过词法分析器的生成器来自动生成词法分析的代码。代码量比手工编码小,但不容易控制细节。

自动生成

在自动生成的方式下,只需要输入一些声明式的规范,然后经过自动生成器,就可以产生词法分析器的代码。

声明式的规范使用正则表达式来描述。

词法分析器的代码使用有限状态自动机。分为确定有限状态自动机(DFA)和不确定有限状态自动机(NFA)。

整个流程是从正则表达式转化为NFA,NFA转化为DFA,DFA最小化,最后生成词法分析器的代码。

  • 正则表达式–>NFA:Thompson算法
  • NFA–>DFA:子集构造算法
  • DFA最小化:Hopcroft算法
  • DFA–>词法分析器代码:转移表、哈希表、跳转表
分享到

编译原理学习笔记(一)概述

什么是编译器

编译器是一类程序,用来将高级语言的源代码转化成机器能够识别的目标代码。

什么是解释器

解释器也是用来解析高级语言的,将源代码转化成对应的结果输出。

上述二者的区别

输入相同,输出不同,解释器直接输出程序的结果,而编译器先生成中间代码然后再生成可执行的目标代码。

编译器的模块构成

编译器是由多个功能模块构成的流水线结构。

  • 词法分析:将源程序看作一个字符流,处理得到记号流。
  • 语法分析:处理记号流,生成抽象语法树。
  • 语义分析:对语法树进行语义检查,保证合法性。
  • 中间代码生成:根据语义分析器的输出生成中间代码。具体代码和机器类型有关。
  • 中间代码优化:相关的优化工作。
  • 目标代码生成:与机器的指令相关。

编译器和解释器都包括前3个模块,而解释器的第四部直接输出程序结果。
c

分享到

tcp的三次握手和四次挥手

三次握手

三次握手发生在客户端和服务器建立连接的阶段。流程如下:

  • 客户端向服务器发送一个同步报文SYN i
  • 服务器向客户端响应一个报文SYN j,同时对SYN i进行确认ACK i+1
  • 客户端再向服务器发送一个确认报文ACK j+1
    如下图:
    tcp3

    为什么需要三次握手

    防止已经失效的连接请求突然又传到服务器,因而产生错误。假设客户端向服务器发送第一个连接请求,但这个请求丢失了,服务器没有接收到,于是客户端向服务器发送第二个连接请求,这次正常建立了连接,交换完数据后关闭连接,而此时第一个丢失的连接请求又到达了服务器,服务器以为是客户端又重新发起了连接,于是向客户端发送响应报文,如果只有前两次握手,则服务器不等客户端回应就建立连接,等待客户端的数据,而这次连接是之前失效的连接,并不是客户端发起的连接,因而客户端也就没有数据传输,服务器就一直等待客户端,造成了服务器资源的浪费。若采用三次握手即可解决此问题,服务器要接收到客户端的第三次握手才会建立连接,而此次连接是无效的,因此客户端不会向服务器发起第三次握手,因此连接不会建立。

四次挥手

四次挥手发生在客户端和服务器关闭连接的阶段。流程如下:

  • 客户端主动关闭,向服务器发送一个FIN
  • 服务器接受到FIN,对这个FIN进行确认,发送ACK
  • 服务器向客户端也发送一个FIN
  • 客户端对服务器的FIN进行确认
    如下图:
    tcp4

    为什么需要四次挥手

    TCP连接是全双工的,数据可以双向传递,因此每个方向必须单独进行关闭。关闭连接时,服务器可能还有数据要发送,不能立即关闭连接,因此先发送一个ACK,等所有数据发送完,再向客户端发送FIN。

TIME_WAIT状态

首先发起主动关闭的一方,在发送最后一个ACK之后会进入time_wait的状态,也就说该发送方会保持2MSL时间之后才会回到初始状态。MSL值是数据包在网络中的最大生存时间。产生这种结果使得这个TCP连接在2MSL连接等待期间,定义这个连接的四元组(客户端IP地址和端口,服务端IP地址和端口号)不能被使用。

为什么需要TIME_WAIT状态

1、假设客户端最后发送的ACK在网络上丢失,服务器没收到ACK向客户端重新发送FIN,因此客户端必须保持连接以发送ACK确认报文,这保证了TCP连接的可靠关闭。
2、假设没有TIME_WAIT状态,一个客户端关闭连接后又有一个新的客户端以相同的ip地址和端口号建立连接,此时在服务器看来,第二个客户端即为第一个客户端,因为TCP协议栈是无法区分前后两条TCP连接的不同的,因此会发生这种现象,前一个客户端发送给服务器的数据和后一个客户端发送的数据产生了混乱,会产生不可预期的后果。因此TIME_WAIT的第二个作用是使旧的数据包在网络上因为过期而消失。

最后,tcp的状态转移图:
tcp

分享到

select、poll、epoll的比较

I/O复用是指将要监听的多个文件描述符集中起来,当其中有描述符就绪时(可读,可写,异常等),可以对多个描述符进行操作,而不用阻塞在一个描述符上。select、poll、epoll都是I/O复用的一种实现。它们监听多个文件描述符,直到一个或多个描述符上有事件发生时返回。

从以下几个方面来比较这三种方式。

1、事件集

这三种方式都通过某种结构体变量告诉内核要监听哪些文件描述符上的哪些事件。

select使用fd_set类型,它仅仅是一个描述符集合,没有事件的信息,因此需要提供3组fd_set来注册不同的事件,可读,可写,异常。这使得select不能处理更多的事件,另一方面由于每次循环内核对fd_set进行修改,程序再次调用select前需要重置这3组fd_set。

poll使用pollfd类型,它将文件描述符和事件类型都定义在其中,任何事件都被统一处理。且pollfd有两个成员events和revents,内核每次修改的是revents,events保持不变,这样不需要每次调用poll之前再重置将事件集。poll可以看成是对select的改进,使得编程接口简洁许多。

select和poll返回的事件集都是整个事件集。

epoll采用的方式与select和poll不同,它在内核中维护一个事件表,epoll_ctl函数负责向事件表中添加、删除、修改事件,它需要一个额外的文件描述符指向这个事件表,这样每次调用epoll之后,内核会直接返回就绪的文件描述符,而不是所有描述符。

2、索引就绪文件描述符的时间复杂度

select和poll返回的都是所有文件描述符,因此索引的复杂度为O(n),而epoll只是返回就绪的文件描述符,故复杂度为O(1)。

3、实现原理

select和poll都是采用轮询的方式来检测描述符是否就绪,即扫描整个文件描述符集合,因此检测的时间复杂度为O(n),而epoll采用的是回调的方式,内核检测到就绪文件描述符时,会触发回调函数,回调函数将该文件描述符上的事件插入内核就绪事件队列,最后内核在适当的时机将事件队列中的事件拷贝到用户空间,检测的复杂度为O(1),因此epoll不需要对整个集合进行扫描,由内核来处理就绪的事件。

但是当活动连接较多时,回调函数触发过于频繁,此时epoll的效率未必比select和poll高,因此epoll适合连接数量多,活动连接较少的情况。此外epoll是linux系统独有的,如果考虑到跨平台,select、poll更适合。

分享到

利用hexo和github搭建自己的博客

Hexo 是一个快速、简洁且高效的博客框架。Hexo 使用 Markdown(或其他渲染引擎)解析文章,在几秒内,即可利用靓丽的主题生成静态网页。

参考这篇教程进行博客的搭建。

分享到