TCP基础

Java后台开发一般要求的知识是掌握一些开发技能,一般是从SSH框架,问问项目,有些注重基础能力的,就会往Java SE方面问,例如HashMapObject类有几个方法,虚拟机,垃圾回收机制等。大公司会考察分布式应用知识作为加分项,如HadoopMapReduced等。

笔者当年对C++后台开发有错误的认知,认为与Java的要求大约一致,仅仅要求开发技能,也没有到网上搜索攻略,到了真正跑招聘的时候才发现这个岗位除了一般C++知识基础外,还要求有Linux系统操作知识(常用命令在内)、TCP/IP通信基础、Socket开发经验等。其中TCP的要求如下图:

但是这个图背诵起来也不容易,总结一下主要考察的是3次握手4次挥手

三次握手的流程:

1
2
3
4
5
1.server先初始化好了一个监听用的socket,server进入了LISTEN状态;
2.客户端发起了TCP连接,向server发送了一个SYN,进入SYN_SENT状态;
3.server接收到了来自客户端的SYN,向发起请求的客户端发送SYN+ACK,进入SYN_RCVD状态;
4.客户端接收到了server返回的SYN+ACK信号,发送ACK信号给server,进入ESTABLISHED状态;
5.server接收到了ACK,进入ESTABLISHED状态。

整个三次握手的文本描述过程就是这样。

四次握手的客户端状态转换则有点麻烦,主要与server返回FIN和ACK的顺序有关。

(待续)

Effective C++(五)

前面探讨了C++中构造、析构以及赋值方面的问题,随后书上第三个主题就开始是资源管理。在当前流行的JavaPython等语言中,甚至早期的Scheme也有垃圾回收(Garbage Collection, GC)机制。使用者只需要在需要新的对象的时候用new语义创建对象实例,之后就不需要手动用delete语义释放这个对象或考虑对象何时会被释放,完全由运行时环境(虚拟机是运行时环境的子集)决定。

说回C++,因其历史原因等,是几乎不可能让语言集成垃圾回收机制,这样极有可能导致以后版本的编译器无法兼容老旧的代码,甚至造成语言自身分裂成不同的阵容@Python。而且垃圾回收本身也占用系统资源,这对于某些场景(如高频交易)来说,1毫秒的延迟都能造成不小的金融损失。所以C++只能在选择集成垃圾回收机制的以外做抉择。虽然C++11才正式地内建shared_ptrweak_ptrscoped_ptr等对象管理指针的方法,其实早在0x时代Boost库就已经做好了这些功能了,所以笔者觉得C++11很大部分就是把Boost中用得多的特性吸收了进来(Effective C++最后一条也是建议熟悉Boost)。

垃圾回收机制的威力不仅仅体现在释放使用者的脑力,使其能够更加专注与业务代码的构建。在现在硬件性能上升以及计算处理量越来越大的年代,多线程(Multithreaind)编程也变得十分常见。在单线程的场景下,使用者可以通过调试和使用一些内存泄漏检测工具(如valgrind)最终得到一个内存管理十分精确的版本。但是在多线程的环境下,前人积累下来的经验毁于一旦,在这个线程某个对象尚未初始化完成,可能就要接受另一个线程的访问;更恐怖的是在这个线程这个对象已经被析构了,但是被另外一个线程访问了。笔者曾经试过写一个多线程的程序,调试了三天,最终发现是某个变量在主线程被析构了,别的线程仍在访问。但是如果不释放一些对象的话,施以互斥锁应该可以解决对象的正确访问问题,但是这样可能程序就不能长久运行了,毕竟系统内存有限。

因此,结合C++11,使用者也可以摆脱手动管理指针的烦恼,再掌握一些良好的多线程编程习惯,多多少少可以减轻多线程编程恐惧。

条款13:以对象管理资源

这个条款,书上首先介绍了资源取得时机便是初始化时机(Resource Acquisition Is Initialization,RAII),狭义理解为在构造函数中尽可能地使用初始化列表初始化成员。广义上就是资源一分配下来就放入到对象中管理。

在介绍了一个场景之后,就演示了一个用auto_ptr的例子,但是auto_ptrC++11中被弃用了,用unique_ptr:

1
2
3
4
5
6
void fun()
{
std::unique_ptr<Object> objPtr(new Object(...));

objPtr->{调用一些成员函数};
}

这是一个RAII的样例,fun一开始unique_ptr就申请了一个Object对象并且马上置入到unique_ptr对象中;fun结束之后,unique_ptr对象结束了在其作用域内的寿命,调用了析构函数,而这个析构函数就是delete一个Object*

当一个对象在多处被引用,其析构的时机当然是在最后一个引用结束之后,但是这样如何得知其最后一个引用是在哪里?这里提出了一个引用计数器的方法,书上称之为引用计数型智慧指针(reference-counting smart pointer, RCSP)。简要的说,一个对象被引用一次,其引用计数就+1,引用其的某个对象结束了生命周期,该对象的引用便-1。当引用计数为0的时候,就释放该对象。但是这里的引用计数其有个循环引用的问题:

1
2
3
4
5
6
7
8
void cyclesOfReference()
{
shared_ptr<Object> obj1(new Object(...)), obj2(new Object(...));

obj1->attach(shared_ptr<Object>(obj2)); // 假设attach函数是为对象建立某种关系,引起了引用+1;

obj2->attach(shared_ptr<Object>(obj1));
}

这样在cyclesOfReference()的作用域内,obj1obj2对象的引用数至少为2;当函数执行结束的时候,各自减了1,引用数至少为1。因为它们的引用数不为0,所以没被析构释放,但是它们再也不起作用了。其它集成了垃圾回收机制的语言中用了可达性分析的方法解决这个问题,在这里暂不展开讨论。

注意unique_ptrshared_ptr等智能指针在析构的时候对对象用的的delete而不是delete[],所以很遗憾它们只能管理单个对象。

另外shared_ptr也只能保证对象一定会在引用数为0时被析构,而不能保证对象只会被析构1次。

虽然C++也开始提供对象管理内存的机制,但是方便性不及集成垃圾回收机制的语言,使用者当作是个辅助性的手段就好,决不能依赖。

条款14:在资源管理类中小心copying行为

对于该条款,笔者的理解是有些资源是不能共享的,大约是现在笔者已经过了入门时期,自然而然地觉得对象设计根据场景就能得出对象该如何设计,例如书上举例的互斥锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Lock
{
public:
explicit Lock(Mutex* pm) : mutexPtr(pm)
{
lock(mutexPtr);
}

~Lock()
{
unlock(mutexPtr);
}
}

原理也是一个Lock在其所处的作用域中初始化时调用构造函数,在作用域结束时编译器调用其析构函数,这是一个C++编程上的小技巧。

但是:

1
2
3
4
5
6
void fun()
{
Mutex m;
Lock m11(&m);
Lock m12(m11);
}

编译器只能保证在fun的末尾肯定会插入m11m12的构造函数,但是不能保证其顺序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void fun()
{
Mutex::Mutex(&m);

Lock::Lock(&m11, &m);

Lock::Lock(*m12, &m11);

Lock::~Lock(&m12);

Lock::~Lock(&m11);

Mutex::~Mutex(&m);
}

以上是编译为fun编译产生的一种中间代码,m12析构比m11要早,但是因为m12没有获取到锁,因而不能释放锁,程序在这里卡死。

书上给出了如下的解决方法:

  1. 禁止复制,这个很自然就想到了。
  2. 对底层资源祭出引用计数法,在这个例子中就更像把互斥锁升级为了信号量
  3. 复制底部资源,深度复制法,但是对这个场景不适用。
  4. 转移地步资源拥有权。

34点,笔者认为是程序设计阶段的问题,对于这样的复制行为,应该作好协议,规定这类行为的代码规范。

因而这个条款笔者不觉得有太多可讨论的地方。

条款15:在资源管理类中提供对原始资源的访问

其实这个条款也是直觉上就能理解的,毕竟面向对象的编程原则不是万能的,总有不适用的场景存在,例如这个主题中经常出现的智能指针shared_ptr等。C++11智能指针提供了operator->的重载,也就是提供了隐式转换:

1
2
3
4
5
6
7
Object* pobj = new Object();

std::shared_ptr<Object> sobj(pobj);

pobj->fun();

sobj->fun();

其中shared_ptr::operator->()的源码:

1
2
3
4
5
_Tp* operator->() const noexcept
{
_GLIBCXX_DEBUG_PEDASSERT(_M_ptr != 0);
return _M_ptr;
}

笔者也疑惑,这样处理之后的代码应该是:

1
2
3
4
5
6
7
Object* pobj = new Object();

std::shared_ptr<Object> sobj(pobj);

pobj->fun();

(sobj::operator->())fun();

中间似乎缺少了一个->,不过这也只能解释为语言特性了。

总之为了补全封装性带来的程序设计缺点,总有场景需要提取封装中的内容物,这时候只能提供原始资源访问的接口,接下来就是显式接口,如JavaString要显式地使用charAt(int index)来获取指定索引的字符,这样虽然不方便,但是会防止误用;而隐式接口,C++string提供的operator[](int index)很方便地就符合使用者的直觉。

条款16:成对使用new和delete时要采取相同形式

这个条款提醒了初学者,如果用了new,那么释放的时候就用delete;如果用了new[],就用delete[]释放。这个规则是递归使用的,如果用了多维的定义,那么就得先delete[]低一维之后才释放该维:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int** matrix = new int[10];

for(int row = 0; row < 10; ++row)
{
matrix[row] = new int[10];
for(int col = 0; col < 10; ++col)
{
matrix[row][col] = 0; // 初始化。
}
}

... // 一顿操作。

for(int row = 0; row < 10; ++row)
{
delete[] matrix[row]; // 释放低一维的变量。
matrix[row] = nullptr; // 释放完之后设置空指针是个良好的编程习惯。
}

delete[] matrix; // 再释放高一维变量。

这样的申请释放才不会造成内存泄漏。

注意书上提及的:

1
2
3
std::string* stringArray = new std::string[100];
...
delete stringArray;

这里是用了new[]申请,但是用的delete释放,这样只会释放stringArray[0]

注意前面也提过无论new的时候是什么形式,智能指针只会用delete释放资源,所以用智能指针管理资源的时候就要注意这个细节了。

条款17:以独立语句将newed对象置入智能指针

书上给了一个例子,也提供了具体的场景,函数原型如下:

1
2
int priority();
void processWidget(std::shared_ptr<Widget> pw, int priority);

有如下的调用:

1
processWidget(std::shared_ptr<Widget>(new Widget), priority());

即使有智能指针的辅助,仍然不能根绝内存泄漏的问题,试想上述调用前可能会产生如下的构造:

1
2
3
Widget* pw = new Widget();
int prio = priority();
std::shared_ptr spw(pw);

但是注意上述三行代码在原代码中是处于同一行的,也就是说,如果调用proirity()中出现了异常,导致了std::shared_ptr未构造,这时候显然new出来的对象就逃逸了。

书上给出的建议,笔者认为一个是编程上的技巧,也是编程上的良好的习惯,代码如下:

1
2
3
4
5
std::shared_ptr<Widget> pw(new Widget);

processWidget(pw, priority());

...

这样就不会因为priority()的失败导致一个原始的Widget对象脱离了管理,事实上在调用一个函数的时候,也尽量简化这个调用的表达式,又有如下一个假想例子:

1
2
3
4
5
6
Type ret_val = func(
std::shared_ptr(new Object),
para1 == ANY_VALUE ? para1 : OTHER_VALUE,
func1(),
...
);

这样一长串的调用,使得一个调用中的内容过于复杂,不方便后期维护审阅之外,其中任何一个参数的构造失败使得其它已经构造好的参数变得没有意义,也使对象不便于管理。

这个条款一个是提供编程上的小技巧,笔者认为更加像是一个良好的编程习惯。

Effective C++(四)

条款10:令Operator=返回一个reference to *this

这个条款很直观,就是为了提供一个语法糖类似的功能使之可以:

1
x = y = z;

这样的连续赋值,按照惯例贴下样例实现吧:

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

条款11:在operator=中处理“自我赋值”

例如可能发生如下的情况:

1
2
3
Object o;

o = o;

最简单的方法就是做一次判断,如果是自己就什么都不做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Object
{
public:
...
Object& operator=(const Object& rhs)
{
if(this == &rhs)
{
return *this;
}
...
return *this;
}
...
}

当然书上也给出了另外一个应对异常安全的做法,就是定制一个swap(),这个方法会在条款29提及,以及笔者感觉特意为了这样的自我赋值做一个判断就够了。

条款12:复制对象时切勿忘其每一个成分

书中这个条款只是提醒了类变量成员的复制问题,而且前提是这些成员是对象成员,而不是一个指针,拥有指针时的复制就更加需要注意了。

先来简要介绍一下仅含对象成员:

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
class Base
{
public:
Base(const Base& rhs) : 初始化列表
{
...
}
...
Base& operator=(const Base& rhs)
{
...
}
private:
...
};

class Mem1
{
public:
Mem1(const Mem1& rhs) : 初始化列表
{
...
}
Mem1& operator(const Mem1& rhs)
{
...
}
private:
...
};

class Mem2
{
public:
Mem2(const Mem2& rhs) : 初始化列表
{
...
}
Mem2& operator(const Mem2& rhs)
{
...
}
private:
...
};

class Com : public Base
{
public:
Com(const Com& rhs) : Base(rhs), mem1(rhs.mem1), mem2(rhs.mem2), ...
{
...
}
Com& operator=(const Com& rhs)
{
Base::operator=(rhs);
this.mem1 = rhs.mem1;
this.mem2 = rhs.mem2;
...
}
private:
Mem1 mem1;
Mem2 mem2;
...
}

注意好复制构造函数以及赋值操作符重载基本上问题就不大了,就是编写的时候不要漏掉该处理的成员,调用基类的赋值操作符重载补全复制不到的基类成员。

但是当成员中存在指针的时候,就涉及到深度复制的问题了,设想上述的改动:

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
class Com : public Base
{
public:
Com(const Com& rhs) : Base(rhs), mem1(rhs.mem1), mem2(rhs.mem2), ...
{
...
}
Com& operator=(const Com& rhs)
{
Base::operator=(rhs);
this.mem1 = rhs.mem1;
this.mem2 = rhs.mem2;
...
}
Mem1& getMem1() const
{
return *mem1;
}
...
~Com()
{
delete mem1;
delete mem2;
}
private:
Mem1* mem1;
Mem2* mem2;
...
}

当使用该类的代码这么写:

1
2
3
4
5
6
7
Com* com1 = new Com(...);

Com* com2(com1); // com2 = com1;

delete com1;

com->getMem1().调用一些Mem1的成员函数

此时成员mem1mem2已经被释放了,所以com2中mem1指向的是一块未定义的内存,这样的调用是十分危险的,基本上都会导致程序意外终止,所以涉及到指针的复制要更加慎重。

Java里面,所有类的根类是Object,而其拥有的九个方法其中之一就是clone(),可惜C++编程太自由了,无法强制规定使用者要遵循一些编程规范。以笔者的经验来看,在C++里面一般涉及到深度复制的之后,也只能靠自身养成的良好的编程习惯,例如给Mem1类添加copy()或者clone()函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Mem1
{
public:
...
Mem1* clone() const
{
Mem1* newObj = new Mem1(this->{自身一些非指针型成员数据的初始化列表});

// 如果具有指针型的成员则递归调用其clone或者copy函数,否则在此手动深度复制
}
private:
...
};

这样定义之后,Com可以这样深度复制:

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
class Com : public Base
{
public:
Com(const Com& rhs) : Base(rhs), mem1(rhs.mem1.clone()), mem2(rhs.mem2.clone()), ...
{
...
}
Com& operator=(const Com& rhs)
{
Base::operator=(rhs);
this.mem1 = rhs.mem1.clone();
this.mem2 = rhs.mem2.clone();
...
}
Mem1& getMem1() const
{
return *mem1;
}
...
~Com()
{
delete mem1;
delete mem2;
}
private:
Mem1* mem1;
Mem2* mem2;
...
}

经过这样的处理,即使原来的实例已经被释放了,但是因为复制的数据是完整的,两个对象之间并没有指针引用,所以就不会造成上述的错误。

当然实际应用场景中也有需要对象引用相同的成员的的需求,这时候要依据具体的需求设定好接口,不过clonecopy这类函数的语义最好设定为深度复制。

当然使用指针之后的问题就是需要手动管理内存了,在这里暂时不展开这个话题,毕竟C++内存管理一直是个大问题。

Effective C++(三)

前面Effective C++(二)开始讲述了构造函数赋值函数以及析构函数,在讲述条款5时稍微展开讨论了一下细节,没想到篇幅会变长,看来之后有关Effective C++每篇博客的讨论的条款数量不定,只能以篇幅来限制,所以这个系列会有多少片,笔者也不清楚,碰到一些比较熟悉的条款应用就展开多讨论一些。

条款7:为多态基类声明virtual析构函数

这里涉及到面向对象中的多态这个性质,假设有如下继承关系,Base基类的析构函数为非虚函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Base
{
public:
~Base()
{
cout << "Base::~Base(this=" << this << ")" << endl;
}
};

class Derived : public Base
{
public:
~Derived()
{
cout << "Derived:~Derived(this=" << this << ")" << endl;
}
};

在析构函数中输出一些信息,有如下的main:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int main(int argc, char* argv[])
{
Base* b = new Base();

Derived* d = new Derived();

delete b;

delete d;

cout << "--------Stg--------" << endl;

b = new Derived();

d = new Derived();

delete b;

delete d;
}

会得到如下的输出:

1
2
3
4
5
6
7
Base::~Base(this=0x1d04c20)
Derived:~Derived(this=0x1d04c40)
Base::~Base(this=0x1d04c40)
--------Stg--------
Base::~Base(this=0x1d04c40)
Derived:~Derived(this=0x1d04c20)
Base::~Base(this=0x1d04c20)

首先看到第一个由Derived指针指向的对象只执行了Dervied自身的析构函数,随后正确执行了Base部分的析构。

然后因为上一个对象被回收了,导致下一个创建出来的对象用了上一个对象的地址,笔者在此加了一行输出隔开。用一个Base指针指向的Derived对象,其地址与上一个Derived对象一致,然而输出分割线下这个Base指向,实际为Dervied对象只执行Base部分的析构,这样就造成了Derived部分的内存泄漏了。

随后修改Base:

1
2
3
4
5
6
7
8
class Base
{
public:
virtual ~Base()
{
cout << "Base::~Base(this=" << this << ")" << endl;
}
};

不改动main代码,编译执行,得到如下的输出:

1
2
3
4
5
6
7
8
Base::~Base(this=0x1156c20)
Derived:~Derived(this=0x1156c40)
Base::~Base(this=0x1156c40)
--------Stg--------
Derived:~Derived(this=0x1156c40)
Base::~Base(this=0x1156c40)
Derived:~Derived(this=0x1156c20)
Base::~Base(this=0x1156c20)

第三个new出来,由Base指针指向的Derived对象正确地按照Derived::~Derived()Base::~Base()顺序析构了这个对象。

虽然书上给出的心得:”只有当class内含至少一个virtual函数,才为它声明virtual析构函数”,但是笔者认为这只是一个明显的信号,在实践中,一般能够通过分析得出该类是否会被继承,这时候就可以为其声明virtual析构函数了。

注意std::string的析构函数是non-virtual的,所以把std::string作为基类编写自定义类的时候是一个不明智的行为。

书中提到了如果一个带有纯虚析构函数的基类,其声明纯虚函数的作用是为标记此类为抽象类,但是其析构函数仍然具有行为,则可以在声明其纯虚析构函数后继续给出定义:

1
2
3
4
5
6
7
8
class AWOV 		// Abstract w/o Virtuals
{
public:
virtual ~AWOV() = 0;
}

AWOV::~AWOV()
{}

这样即使抽象类具有数据成员时,因其纯虚的析构函数具有定义,也可执行析构函数成功进行析构动作。

当一个类中带一个纯虚函数的时候,那么该类就是一个抽象类,是不可实例化的。讲到这里,笔者觉得需要与Java的类继承机制作为对比,Java只允许单继承,而允许多实现,例如:

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

interface FunPack1
{...}

interface FunPack2
{...}

class Dervied extends Base implements FunPack1, FunPack2
{...}

单继承纵然会限制了语言的灵活性,多继承存在如下的问题:

如果~A()是一个虚函数,那么一个D的实例被delete的时候其A部分会被析构几次?这个问题可以在对象模型中找到答案,这里就不展开细说。单继承是为了解决多继承的一些缺点,但是从Java多接口实现来看,事实上是多继承的功能的子集,Java的接口只允许声明方法的签名以及一些静态量,当用这个接口类型‘指向’其实现的对象时,则可以调用该接口声明的函数,实施一些行为。

笔者在此不是对比单继承多继承的优缺点,而是说明通过限制C++多继承可以模拟Java的这种方式,提供了面向对象设计的一种思路。毕竟正是因为C++的多样的语法导致其编程人员的上限可以很高,也可以很低,当项目集成的时候容易出现致命问题,所以在真正的C++项目中会事先做好一些约束。

也就是说,当多继承容易出问题的时候,不妨考虑约束拥有纯虚函数的基类不得拥有成员变量,使之成为一个仅仅声明纯虚函数接口的接口类,而且在后来的条款也会建议,可以用组合设计模式解决问题的时候就不考虑继承。以此为原则,基本上可以解决很多问题,起码在笔者的经历中还没碰到过非得用上述菱形继承的问题。

条款8:别让异常逃离析构函数

相比起JavaC++异常处理有着巨大的劣势,毕竟观察C++的发展历程,像是C为了跟上时代而制作的一门语言。毕竟作为一门历史比较悠久的语言,一方面要兼容C,一方面又不能在功能上落后于时代,就如较为前卫的pythonrubylambdaDuck Type等语言特性,C++11才加入了lambda表达式以及auto关键字,而内存管理只能妥协于种种理由出了memory系列,C++1x还在探讨import部分导入代替现在的include整个文件导入。

说回异常,Java在异常发生的时候会显式地抛出异常并输出调用栈,这对于debug过程来说虽然信息冗长,但是花点时间审查信息的话总能筛选出有效的信息来调试程序。Java编译会强制要求处理一些可能抛出的异常,好一点的IDE会在写代码的时候就提示补全。而C++无论写什么都好,对于可能抛出异常的地方编译器也不会在编译期提醒。到了真正抛出异常的时候,就简单地输出一句调用”what()”的信息,没有调用栈信息,有点经验的就在很可能出现问题的代码出使用gdb插入断点等手段验证是不是这里出问题。

不过这只是强行用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
class DBConn
{
public:
...
void close()
{
db.close();
closed = true;
}
~DBConn()
{
if(!closed)
{
try{
db.close();
}
catch(...)
{
写log或者吞下异常。
}
}
}
private:
DBConnection db;
bool closed;
}

这样的话,析构函数只是起了用户忘记关闭连接的时候做善后处理的作用,而调用db.close()时候要是出问题也很大可能是在用户手动调用的时候,要是析构的时候也出异常了,证明程序或者环境配置有问题了,前提是第一次关闭的代码出异常的时候程序还能继续正常运行下去,是用户必须自己去处理的事了。

书上也划了个重点:

析构函数绝对不要吐出异常。

条款9:绝不在构造和析构过程中调用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
class Transaction{
public:
Transaction()
{
...
logTransaction();
}
virtual void logTransaction()
{
cout << "Transaction::logTransaction()" << endl;
}
...
}

class BuyTransaction : public Transaction{
public:
virtual void logTransaction() const
{
cout << "BuyTransaction::logTransaction()" << endl;
}
...
}

class SellTransaction : public Transaction{
public:
virtual void logTransaction() const
{...}
...
}

当定义一个BuyTransaction实例时,其Transaction::Transaction()构造过程首先被调用,运行程序会看到输出:

1
Transaction::logTransaction()

查看编译器为BuyTransaction生成的汇编:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
BuyTransaction::BuyTransaction():
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp
movq %rdi, -8(%rbp)
movq -8(%rbp), %rax
movq %rax, %rdi
call Transaction::Transaction()
movl vtable for BuyTransaction+16, %edx
movq -8(%rbp), %rax
movq %rdx, (%rax)
nop
leave
ret

可以一目了然地看到这个构造函数先调用了Transaction::Transaction()之后再构造BuyTransaction实例的vtbl指针,在此之前这个实例是无法获取得到BuyTransaction中的logTransaction()的地址。

书上给出的解决方案是将logTransaction的属性更改为non-virtual,这个是无懈可击的。但是在笔者的实际应用当中,更多的时候构造函数仅仅只是作为初始化成员变量,最多调用非成员函数用于辅助初始化成员,需要具体的操作就等使用者根据自己的需要调用,最坏的情况也只能是在构造函数中调用non-virtual的成员函数。

Effective C++(二)

上一篇Effective C++(一)介绍了认知C++方面的内容,这一部分将讨论的是构造析构赋值方面的问题。

条款5:了解C++默默编写并调用那些函数

这一条款主要是讲述C++编译器为类合成构造函数析构函数方面的知识,其实应该结合《深度探索C++对象模型》来讲述会更加清楚,但是这一部分展开介绍实在会增加篇幅,笔者将在日后的对象模型博客中详细地介绍。

首先书上给的是C++编译器会如何处理空类,其中手动编写空类的定义:

1
2
class Empty
{};

编译器会自动生成一些成员函数,这时你的类定义可能会看起来是这样:

1
2
3
4
5
6
7
8
class Empty
{
public:
Empty(){}
Empty(const Empty& rhs){}
~Empty(){}
Empty& operator=(const Empty& rhs){}
};

只有代码中用到这一部分的函数的时候,编译器才会去处理这些成员函数。为了查看编译器是不是真的会实施这个行为,用-S选项并分析其生成的汇编码(使用Linux工具分析g++生成的代码)。

首先编写一个没有用到Emptymain函数:

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

查看只为main生成的汇编码:

1
2
3
4
5
6
7
8
main:
pushq %rbp
movq %rsp, %rbp
movl %edi, -4(%rbp)
movq %rsi, -16(%rbp)
movl $0, %eax
popq %rbp
ret

更改main函数为:

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

几乎也不会有什么变化:

1
2
3
4
5
6
7
8
main:
pushq %rbp
movq %rsp, %rbp
movl %edi, -20(%rbp)
movq %rsi, -32(%rbp)
movl $0, %eax
popq %rbp
ret

只是传入到main中参数发生了一些细微的变化,并没有看到构造器以及call行为发生。

那将main改成了书上的样子:

1
2
3
4
5
6
7
8
9
10
int main(int argc, char* argv[])
{
Empty e1;

Empty e2(e1);

e2 = e1;

return 0;
}

可惜的是,这样的代码也没有促使编译器合成构造器等函数,不过考虑到这本书已经有出版时间稍微有点间隔,以及笔者使用的是查看汇编码而不是其他编译器产生的中间代码,不过汇编码可以算是比其他中间代码更有证明力的部分。既然没合成,要么就是编译器已经可以针对这样的空类优化了,要么就是作者方便读者理解而设立出来的架空代码。

不过在此笔者继续探索,假设定义了一个类,其成员非空,先不为其编写构造函数等:

1
2
3
4
5
class Empty
{
private:
int mem1;
};

首先也是main发生上述三个行为时的时候,生成的汇编码中也没有产生成员函数,倒是e2 = e1时的汇编码:

1
2
3
4
5
6
7
8
9
10
11
12
main:
pushq %rbp
movq %rsp, %rbp
movl %edi, -20(%rbp)
movq %rsi, -32(%rbp)
movl -4(%rbp), %eax ; Empty e2(e1)
movl %eax, -8(%rbp)
movl -4(%rbp), %eax ; e2 = e1
movl %eax, -8(%rbp)
movl $0, %eax
popq %rbp
ret

编译器多多少少处理了赋值行为,但是也没有明显地为其生成了一个专门的函数。

如果将Empty类的构造函数、赋值函数以及析构函数给予定义:

1
2
3
4
5
6
7
8
9
10
11
12
class Empty
{
public:
Empty() : mem1(0){}
Empty(const Empty& rhs) : mem1(rhs.mem1) {}
Empty& operator=(const Empty& rhs)
{
mem1 = rhs.mem1;
}
private:
int mem1;
};

main函数没有任何行为的时候,编译器也不会为这个Empty生成任何函数,而当开始需要构造函数的时候:

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

编译器则为其生成了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Empty::Empty():
pushq %rbp
movq %rsp, %rbp
movq %rdi, -8(%rbp)
movq -8(%rbp), %rax
movl $0, (%rax)
nop
popq %rbp
ret

main:
pushq %rbp
movq %rsp, %rbp
subq $32, %rsp
movl %edi, -20(%rbp)
movq %rsi, -32(%rbp)
leaq -4(%rbp), %rax
movq %rax, %rdi
call Empty::Empty()
movl $0, %eax
leave
ret

明显地生成了该类的构造函数,也有call调用函数的行为,再把main修改:

1
2
3
4
5
6
7
8
9
10
int main(int argc, char* argv[])
{
Empty e1;

Empty e2(e1);

e2 = e1;

return 0;
}

则生成了:

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
Empty::Empty():
pushq %rbp
movq %rsp, %rbp
movq %rdi, -8(%rbp)
movq -8(%rbp), %rax
movl $0, (%rax)
nop
popq %rbp
ret

Empty::Empty(Empty const&):
pushq %rbp
movq %rsp, %rbp
movq %rdi, -8(%rbp)
movq %rsi, -16(%rbp)
movq -16(%rbp), %rax
movl (%rax), %edx
movq -8(%rbp), %rax
movl %edx, (%rax)
nop
popq %rbp
ret

Empty::operator=(Empty const&):
pushq %rbp
movq %rsp, %rbp
movq %rdi, -8(%rbp)
movq %rsi, -16(%rbp)
movq -16(%rbp), %rax
movl (%rax), %edx
movq -8(%rbp), %rax
movl %edx, (%rax)
nop
popq %rbp
ret

main:
pushq %rbp
movq %rsp, %rbp
subq $32, %rsp
movl %edi, -20(%rbp)
movq %rsi, -32(%rbp)
leaq -4(%rbp), %rax
movq %rax, %rdi
call Empty::Empty()
leaq -4(%rbp), %rdx
leaq -8(%rbp), %rax
movq %rdx, %rsi
leaq -4(%rbp), %rdx
leaq -8(%rbp), %rax
movq %rdx, %rsi
movq %rax, %rdi
call Empty::Empty(Empty const&)
leaq -4(%rbp), %rdx
leaq -8(%rbp), %rax
movq %rdx, %rsi
movq %rax, %rdi
call Empty::operator=(Empty const&)
movl $0, %eax
leave
ret

在这个例子看来,现在的编译器不会默默地为类合成构造器等函数,并且是在已经使用到的情况下也只是产生了对应的行为,但是不会合成相应的函数,从某种角度来看,原书的说法还是正确的,不过要说明是编译器会合成这些行为,但是不会产生一个专门的函数,起码是在汇编码层面看不到。

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

书上这一条款看起来主要是针对复制构造函数以及赋值操作符,应用场景一般为禁止复制的对象。具体一些的思路就是让复制构造函数以及赋值操作附不可用,但是如果不去声明这些函数的话,编译器会自动合成这些函数。那么手动声明定义这些函数并设置其不可用状态,一般通过声明其为private并且不去实现:

1
2
3
4
5
6
7
8
9
class Object
{
public:
...
private:
...
Object(const Object&);
Object& operator=(const Object&);
}

C++11下,可以这么写:

1
2
3
4
5
6
7
8
9
lass Object
{
public:
Object(const Object&) = delete;
Object& operator=(const Object&) = delete;
...
private:
...
}

这样就声明了这两个函数是删除并且不可用的。

如果实际应用中很多对象都是禁止复制的话,一般是声明一个不可复制的基类:

1
2
3
4
5
6
7
8
9
class Uncopyable
{
public:
Uncopyable() {}
Uncopyable(const Uncopyable&) = delete;
Uncopyable& operator=(const Uncopyable&) = delete;
~Uncopyable() {}
private:
};

这样在写新的不可复制的类是就可以写:

1
2
3
4
class Object : private Uncopyable
{
...
}

那么如果使用者对该类实例进行了复制操作,编译器会尝试生成该类的复制构造函数或者赋值操作符时会以失败告终并告知编译失败。

Effective C++(一)

Scott Meyers的Effective C++是C++程序员推荐的读物之一,其中充满了作者的总结出来的编程经验,当年笔者刚入门编程的时候一口气从谭浩强的《C语言编程》开始,《C Primer》、《C++ Primer Plus》等等加起来有10本书学习了如何在C++下编程。当年读这本书的时候也不具备什么具体的编程实践,除了做了书上的一些代码的实验之外,无非也就是在平时的编程中有意地使用书中的编程技巧。时至今日,笔者目前的工作虽然不是以C++来编写实践项目,像一般的Web项目优先考虑的是快捷的Java开发,但是学过的编程技巧却是跨越语言的。并且从这几年的学习编程的经验来看,真正对自己编程能力有真正提升的并不是学习了XX语言。

现在总结起来,笔者的编程能力有了突变式的提升是在大一下学期有一门《数据结构》,学习每种经典的数据结构时,笔者对ADT(Abstract Data Type,抽象数据类型)毫无招架之力,书中对这些经典的数据结构(栈、队列等)有一个抽象的描述,用脑子想似乎很正常,但是在具体实现时常常不知从何下手,除了一遍一遍地抄书上的代码实践之外抄到熟之外别无他法。这样下来,锻炼到遇到一个问题下来,首先是问题的分解,分解到可以直接想到这部分的代码该是什么样子的,把每个最小的部分想好之后向上整合,形成解决整个问题的具体思路。

到了后来实践具体项目的时候,客户的需求描述比课本的描述更加抽象,更多的是靠工程经验想像出客户的期望,也有实际的工程工具(软件开发模型等)帮助项目的一步一步实现。终归到底,依靠的还是问题分解。

笔者虽然已经读过了Effective C++,也在这几年中生搬硬套应用过书中的知识,多少都有了些心得,于是在打算在这段较为空闲的时间中写下,也算是复习这本书。

条款1:视C++为一个语言联邦

书中提到了应该视C++拥有4个次语言:

  1. C
  2. Object-Oriented C++
  3. Template C++
  4. STL

第1点当然很容易理解,C++兼容C的语法,并且可以直接使用C的库,但是在笔者的观点上看,使用C++更多的是为了第2点,理由稍后描述。而在笔者的编程经历上看,很少(除了用VC写C)用C++写纯C语言项目,尤其是在Linux下。举个Socket编程的例子,用C的话一般这么写:

1
2
3
4
5
6
int fd = socket(AF_INET, SOCK_STREAM, 0);

if(fd < 0)
{
// 做一些申请失败的处理代码,如果程序不能离开这个资源的话,甚至需要退出整个程序。
}

这里一小段还好,如果是写服务器端的代码的话,用到bindlistenaccept等函数,每次调用完之后都要判断返回值,后续还有设置其他参数等,这样的代码组织方面不会说出大的问题,但是不雅观。那么用C++一般是这么写的:

1
Socket socket(8080);

当然Socket类里面封装的代码大体还是跟原来的C部分相同,这里用到的是第2点中的封装性质,但是这里又出一个问题了,原来的C代码中,每一步一旦申请资源失败都可以在调用者方进行处理;进行了封装之后,中间的过程申请资源失败了,那么该怎么处理?是在本函数体内处理完异常?还是返回错误信息给调用者?如果是内部处理异常的话,那么发生了调用者不期望的行为,而调用者放心地把所有问题交给了封装好的对象,那么调用者不理会的话,程序会因为严重错误而终止运行;如果是返回错误信息给调用者的话,那么这个封装的意义何在?所以有的论调是用C++不如用C进行项目开发,除了平添开销之外没有好处,从上述的例子上看多少有点道理。但是看看隔壁的Java面向对象风生水起,证明了面向对象的开发可以大大提高软件的开发效率(注意是开发效率)。总之就是见仁见智吧。

第3点是Template C++,泛型编程,为了减少代码的重复而存在,例如大小的比较,总不可能一个int就写一个专门的int型比较函数,一个float型就写一个专门的float型比较函数吧。当然这只是基础的背景,事实上泛型编程有更多的应用,学习难度更高,例如泛型元编程(template metaprogramming),然而笔者还没在应用中见识过这样的东西,一般都是做着实验玩的居多。

第4点是标准模板库(STL,Standard Template Library),其实就是把经典的数据结构及其算法集成在语言基础库中,也是为了减少代码的重复,不然一个项目重新做一个轮子,这样的时间耗费基本没有意义。STL提供的库可以显著地改变代码风格,其中的迭代器(Iterator)是一个很重要的概念,用了STL提供的概念之后,自己实现的代码中很可能都不需要写任何算法方面的代码,也没有循环的存在,有可能两三句就把一个功能完成了。

条款2:尽量以const,enum,inline替换#define

这一条款书上提供了具体的例子,#define定义的常量不计入符号表,到时报错的时候很可能就是一个简单的数值,这让开发者无从着手调试。而用const定义的常量,一是具有类型,不怕使用者错误地使用这个常量值;二是这样定义的常量记入符号表,这样编译报错起码之后错了的变量名。

使用define无法限制该变量的作用域,即便是在类中声明定义的define也能在全局中访问到。

enum取代define书上没有太多的陈述,总体来说就是为了维护代码的可读性吧,加上C++11支持class enum,即强类型的enum。例如:

1
2
enum class Day
{MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY};

C++11之前不能用enum class而用enum的时候,编程者可以通过

1
2
3
Day afDay = Day::MONDAY;

aDay == 0;

直接对比Day类型和0的值,虽然一般情况下这样第一个枚举量的值是0,但是不排除会有别的赋值,这样会导致非期望的行为,这也是编程者错误使用的一种方式。

而加入enum class之后,编译器会检查这个枚举实例的类型,如果类型不一致就会报错,从而避免而编程者的错误使用,提高了代码的可维护性。

当某一段代码重复出现的频率很高时,会考虑将这部分代码抽象为一个函数,例如maxmin等;调用函数会产生上下文切换开销,当函数调用频率非常高,频繁的上下文切换开销会变得非常客观。当然现在的编译器有针对这个情况的优化,在这里暂时忽略。所以用#define抽象这个过程,一来可以抽象这段代码,而来又不会招致额外的开销。但是这样做也有明显的缺点,例如书中提到的例子:

1
#define CALL_WITH_MAX(a, b) f((a) > (b) ? (a) : (b))

当开始调用这个宏定义的时候:

1
2
3
int a = 5, b = 0;
CALL_WITH_MAX(++a, b);
CALL_WITH_MAX(++a, b+10);

会被展开为:

1
2
f((++a) > (b) ? (++a) : (b));
f((++a) > (b) ? (++a) : (b+10));

第一行因为是++a比较大,所以++a执行了两次,大概一般的程序员都不会期待这样的结果。

替代的方法就是:

1
2
3
4
5
template<typename T>
inline void callWithMax(const T& a, const T& b) // 条款20
{
f(a > b ? a : b);
}

这样++a就不会重复两遍了。

注意,inline只是提供优化意见给编译器,编译器并不会强制执行。

条款3:尽可能使用const

依笔者的经验,用const限制变量的修改性是比较有意义的事,这样可以很大程度地避免使用者错误地使用你提供的代码,例如在类成员函数加上const表示该方法不能修改该类实例中的成员变量。但是奇怪的是C++又提供了mutable关键字修饰成员变量,使之可以在const成员函数中被修改,当然这样也是因为其特殊的应用场景,毕竟应用代码的场景太多了,不能因为自己没有遇到过而否定这样的编程方式。

其他情况诸如friend友元关键字,明明声明了一个类成员是private,但是声明了友元之后却可以在类外部被直接访问,这样又破坏了面向对象中的封装性。但是不通过这样的编程方式的话,又难以达成

1
cout << {自定义的类实例};

这样的语法糖。造成C++今日这种语法复杂,各种原则衍生,互相破坏的原因大概也是因为前人设计该语言的能力有限,但是也证明了通过兼容各种设计原则才能让C++支撑到今日。

书上关于const的介绍例子很多,但是笔者认为只要程序员认为该处不应该发生修改行为,就可以施用const语法。但是存在一个问题,一个类的成员函数作用是返回其中一个成员的引用,但是该函数用了const来修饰,如果调用者修改了这个引用,应该就是发生了非期望的行为。这个编程习惯应该避免。

另外摘抄书上一段关于const指针声明,毕竟这个经常让人混乱:“如果关键字const出现在星号左边,表示被指物是常量;如果出现在星号右边,表示指针自身是常量;如果出现在星号两边,表示被指物和指针两者都是常量”。

条款4:确定对象被使用前已先被初始化

其实这也是一个编程习惯上的问题,例如书上的例子:

1
2
3
4
5
6
7
8
9
int x;

class Point
{
public:
// getters and setters
private:
int x,y;
};

一般情况下声明这些变量实例的时候,这些int的实例会被初始化为0,但是显然可能发生别的值的问题,还是显式地声明初始值比较好:

1
2
3
4
5
6
7
8
9
10
11
12
13
int x = 0;

class Point
{
public:
explicit Point(int x_, int y_) : x(x_), y(y_)
{}
//getters and setters
private:
int x,y;
};

Point point(0, 0);

加上explicit关键字,强迫实例必须具有初始值,这样就可以保证实例能被正确地初始化。注意示例代码中用了初始化列表,这个是常用的编程技巧,结合《深度探索C++对象模型》来讲述,如果用初始化列表会产生如下的构造器代码(猜测):

1
2
3
4
5
Point::Point(Point* this, int x_, int y_)
{
this->x(x_);
this->y(y_);
}

但是如果将构造器原始代码改成:

1
2
3
4
5
6
7
8
9
10
11
12
class Point
{
public:
explicit Point(int x_, int y_)
{
x = x_;
y = y_;
}
//getters and setters
private:
int x,y;
};

则可能产生如下的代码:

1
2
3
4
5
6
7
Point::Point(Point* this, int x_, int y_)
{
this->x();
this->y();
this->x=x_;
this->y=y_;
}

以上代码仅仅作一个示例说明,在此就不详细展开了,日后笔者在写C++对象模型的博客时会提到。

书上还提示要记住初始化列表的顺序要与类变量在声明时的顺序保持一致。

这一条款中笔者比较少碰到的就是跨文件类实例初始化问题。例如Point中设定一个原点:

1
2
3
4
5
6
7
8
9
10
11
class Point
{
public:
explicit Point(int x_, int y_) : x(x_), y(y_)
{}
//getters and setters
private:
int x,y;
};

extern Point origin(0, 0);

而当在外部源码文件使用到origin这个文件时,因为

1
C++对“定义于不同的编译单元内的non-local static对象”的初始化相对顺序并无明确定义。

也就是说假设使用者定义了一个二维坐标系的类实例并用到了origin变量时,这个二维坐标系实例已经完成了初始化,而origin可能尚未初始化。

解决这个问题用的技巧则是依赖于”C++保证函数内的local static对象会在‘该函数被调用期间’、‘首次遇上该对象之定义式’时被初始化”。转化成代码则是在原来的文件上添加:

1
2
3
4
5
Point& getOrigin()
{
static Point origin(0,0);
return origin;
}

这样就可以确保这个origin类实例可以在使用前完成初始化。

Eclat算法实现

之前实现了Apriori算法以及FP-growth算法,用的同一个数据集:

单号 购物清单
T100 I1,I2,I5
T200 I2,I4
T300 I2,I3
T400 I1,I2,I4
T500 I1,I3
T600 I2,I3
T700 I1,I3
T800 I1,I2,I3,I5
T900 I1,I2,I3

AprioriFP-growth算法都是以单号为一条记录,依次读入一条这样的事务数据并进行頻数计数。

Eclat算法主体思想是先将这样的数据做一次”转换”,直接以每条事务记录中的项为关键字,在这个样例数据中,这样的一条事务数据中的项就成为了单号。例如上述的数据集就可以转化成了:

物品ID 包含该物品的单号
I1 T100,T400,T500,T700,T800,T900
I2 T100,T200,T300,T400,T600,T800,T900
I3 T300,T500,T600,T700,T800,T900
I4 T200,T400
I5 T100,T800

这样在统计支持度的时候就可以通过计算每条记录中的元素个数了。例如在统计1-频繁项集的时候,对每条记录施以计算集合的大小,就可以得出,{I1}的支持度计数为6等,然后筛选。在进行2-频繁项集的时候对剩下的记录进行笛卡尔积,然后求交集的大小即可得到2-频繁项集的支持度。依次类推得到k-频繁项集的支持度计数。

代码

读入数据部分与前面两个算法一样,采用的样例数据也一样,在此就不展开了。

较为关键的是数据变换的部分:

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
def transpose_dataset(dataset, min_sup = 0):

transed_dta = {}

record_idx = 0

for record in dataset: # 原单条的事务数据

for ele in record:

if frozenset({ele}) in transed_dta: # 以原事务数据中的项为关键字查找记录

transed_dta[frozenset({ele})].add(record_idx)

else:

transed_dta[frozenset({ele})] = {record_idx}

record_idx += 1

qualified_data = {}

for k, v in transed_dta.items(): # 筛选支持度不符的数据

if len(v) >= min_sup:

qualified_data[k] = v

return qualified_data

后面大体的过程与Apriori算法差不多,不过就简单了很多:

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
def eclat(dataset, min_sup=0):

tran_data = transpose_dataset(dataset, min_sup) # 转换数据,顺便做了1-频繁项集的筛选

k_sets = [tran_data]

frequent_itemsets = []

frequent_item_list = []

for rec, val in tran_data.items():

frequent_item_list.append((rec, len(val)))

frequent_itemsets.append(frequent_item_list.copy())

k = 1 # 从2-频繁项集开始

while len(k_sets[k - 1]) > 0:

k_1_set = k_sets[k - 1]

k_set = {}

for k1, v1 in k_1_set.items():

for k2, v2 in k_1_set.items():

new_key = k1.union(k2) # 由k-1频繁项集並集产生k-频繁项集

if len(new_key) == (k + 1) and new_key not in k_set: # 检查是否为k

intersec = v1.intersection(v2) # 由原来两个k-1频繁项集的数据集合交集产生k频繁项集的数据集合

if len(intersec) >= min_sup: # 这里的支持度计数就很简单了,直接球集合的大小

k_set[new_key] = intersec

frequent_item_list.clear()

for rec, val in k_set.items():

frequent_item_list.append((rec, len(val)))

frequent_itemsets.append(frequent_item_list.copy()) # 加入结果集

k_sets.append(k_set)

k += 1

return frequent_itemsets

Eclat的算法很简单,但是其思想启发了数据挖掘的一个新思路。

Fedora 25配置资料整合

网络配置

WiFi热点

Fedora 25直接用gnome的网络界面管理里面可以将无线网卡设置为热点,但是会没有响应,解决办法在此。

nm-connection-editor命令打开界面,填好SSID,以及设置Security中的WiFi密码,然后回到gnome中的网络管理界面开启热点即可。

Chrome中导入XX-Net的http证书无效问题

Chrome在设置中导入了其CA.crt文件之后,使用http代理还是会报站点不信任问题,根据Linux证书管理的教程:

1
$ sudo dnf install nss-tools		# 安装证书导入工具

然后导入GAE的证书:

1
$ certutil -d sql:$HOME/.pki/nssdb -A -t TC -n "GoAgent XX-Net" -i ${XX-Net的安装目录}/data/gae_proxy/CA.crt

随后无论是重启系统,还是重复导入多次还是无效。

有时候想查看导入的证书的信息:

1
$ certutil -d sql:$HOME/.pki/nssdb -L

会报:

1
certutil: function failed: SEC_ERROR_BAD_DATABASE: security library: bad database.

一般可能是用~来表示当前的用户的根目录,貌似这个sql:后面跟~会出错,用$HOME来代替一般可以,实在不行就只能重新建立一个库再导入了:

1
2
3
4
$ mv ~/.pki/nssdb ~/.pki/nssdb.corrupted
$ mkdir ~/.pki/nssdb
$ chmod 700 ~/.pki/nssdb
$ certutil -d sql:$HOME/.pki/nssdb -N

笔者之前搜索了好长一段时间,最后找到一个原因说是Fedora 25不支持SSL中的某个算法(具体是什么因为有点久远找不到了),所以就算导入了证书之后也没用,以下有两个代替方案:

  1. FireFox,加载插件AutoProxy,随后导入GAE证书,就可以使用HTTP翻墙了。
  2. 使用XX-Net中的X-Tunnel功能,这个协议比HTTP方便多了,不用导入直接使用。缺点是流量不是免费的,要么买,要么就贡献自己的GAE获得流量。

之前GAE没墙那么紧的时候试过,还是报不信任的错误。最终笔者今天整理资料做尝试的时候,因为GAEIP已经基本给封得扫不出来了,在启用了IPv6之后通过HTTP协议科学上网成功,这样导入证书还是有效的。

但是在原来的方法上无法使用时,笔者推荐使用其X-Tunnel功能。

安装TIM

万恶的企鹅一直不出Linux版的通信软件,而在日常中又经常使用,但是WebQQ又太烂了,不好用,一般的Wine QQ用的是2013国际版,轻量版的TIM去除了很多不必要的功能,成为了笔者常用的通信软件,于是动了在Fedora下安装一个的念头。

笔者在多番寻找之后终于找到一个可用的Fedora 25TIM教程

最后笔者在安装Wine重启系统之后无法进入系统,最后卸载了Wine才恢复了正常,所以说实在要用鹅厂软件的话,还是老老实实用虚拟机比较好。

Linux动态增删系统调用

环境支持

  • Fedora 25
  • gcc

系统调用

Linux的系统调用是操作系统提供给用户程序调用的一组接口,用户程序可以在用户态下调用这些接口获得内核提供的服务,而无须进入内核态,这样就会少了很多权限上的问题,而且用户态与内核态的切换是存在开销的。

增删系统调用的方法

1.重新编译内核

修改内核库函数的相关文件,将准备添加的系统调用声明以及实现代码等加入进去,然后使用make命令重新编译一个新的内核,添加到grub启动列表等等。

过程挺麻烦的,而且涉及到编译内核,要准备不少环境不说了,一旦与搜索引擎搜索出来的教程环境不一样的话,还有不小概率会编译失败。

2.动态加载模块

这个方法不需要修改内核库相关的文件,自己的代码源码和makefile写好,基本上就可以了。主要的原理是利用动态加载模块的时候的权限访问以及修改系统相关的一些信息。而系统调用表是操作系统级的数据,所以在动态加载的过程中获得系统调用表并修改之。

这个过程也不简单,而且编译成功也需要花费点心思,毕竟大多数搜出来的教程什么的,不是祖传的就是拷贝的,当自己的实验环境跟教程不一样的话,只能靠自己摸索了。

环境参数收集

1.获取当前系统已使用的系统调用号:

1
$ cat /usr/include/asm/unistd.h

然后根据自身系统的位数,例如笔者的Fedora 25是64位机:

1
$ cat /usr/include/asm/unistd_64.h

获得最后一个系统调用号:

1
#define __NR_statx 332

这样在后续设置准备添加的系统调用号就为333以后的值,然而这个是祖传教程里面可以采用的做法,笔者尝试了在本机64位Fedora 25下添加调用号为333的系统调用,失败了,也没能找到类似NR_syscalls这类的宏定义来修改。只能通过替换332以内的值来替换系统当前使用中的系统调用。

替换已有的系统调用号,在卸除自己添加的系统调用的时候还原回去。但是系统调用会被众多系统中的应用调用的,一个不小心可能会导致系统中一些应用的崩溃,所以这个是能是个实验,看来在64位机上要做应用级的系统调用还是得重新编译内核。

2.获取系统系统调用表的内存地址:

解决这个问题的方法有很多,笔者采用的是:

1
$ sudo cat /proc/kallsyms | grep sys_call_table

就会得到:

1
2
ffffffff90a00200 R sys_call_table
ffffffff90a00e80 R ia32_sys_call_table

只需要用到sys_call_table的值即可。

注意到它们的只读R属性,这意味着即使拿到了超级权限,也无法修改,怎么解决这个问题后面会提及。

演示目标说明

既然是一个实验,那肯定需要用一个演示程序及其演示结果来让实验者得知目标已达成,在这个实验里面,用户态的程序是发起系统调用,获得系统正在运行进程信息,并显示出来,在这里虽然设计了深度来表示进程是有几个前驱进程创建出来的,但是没有表现父子间的关系,只有简单的层数。

那么被调用的系统调用自然就是查询系统中的进程的数据结构,采集需要的数据并存到起来,返回给用户态的程序。

定义结构体

这个结构体是用来采集系统中进程的信息,这个定义在用户态程序的代码和内核态程序的代码要分开定义,但是要保持一致。

1
2
3
4
5
6
7
8
struct process_info_t
{
unsigned int pid;

unsigned int depth;

char process_name[TASK_COMM_LEN];
};

定义准备替换的函数的功能

定义准备替换系统调用的函数功能,注意函数参数中的__user定义了该参数是从用户态传入。调用process_tree访问系统中的进程信息并采集,随后调用copy_to_user函数将数据复制到用户态内存区域上。

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
asmlinkage long __my_syscall__(char __user* buf)
{
int depth = 0;

process_counter = 0;

reset_proc_info_t_arr_val(pro_info_array_kernel, 512);

struct task_struct* p;

printk("Evan's system call is executing.\n");

p = &init_task; // 获得系统进程信息的表头。

process_tree(p, depth);

if(copy_to_user((struct process_info_t*)buf, pro_info_array_kernel, 512 * sizeof(struct process_info_t)))
{
return -EFAULT;
}
else
{
return sizeof(pro_info_array_kernel);
}
}

具体执行采集进程信息的process_tree函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void process_tree(struct task_struct* _p, int _depth)
{
struct list_head* l;

pro_info_array_kernel[process_counter].pid = _p->pid;

pro_info_array_kernel[process_counter].depth = _depth;

strncpy(pro_info_array_kernel[process_counter].process_name, _p->comm, TASK_COMM_LEN);

++process_counter; // 全局变量,用于记录采集信息数组的下标值。

// 递归复制子进程的信息。
for(l = _p->children.next; l != &(_p->children); l = l->next)
{
struct task_struct* t = list_entry(l, struct task_struct, sibling);

process_tree(t, _depth + 1);
}
}

替换系统调用的函数代码

前面提到过系统调用表的具有不可修改的标志,甚至用超级权限也是,解决办法是用汇编访问到状态寄存器CR0,而CR0中的从低到高数20号索引(注意索引从0开始)是内存写保护位,将其设置为0之后即可为所欲为了。

所以思路是修改系统调用表前设置CR0状态,设置写保护关闭,改完表之后设回原来的值。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 关闭写保护的函数
uint64_t clr_and_ret_cr0(void)
{
uint64_t cr0 = 0;

uint64_t ret;

asm("movq %%cr0, %%rax":"=a"(cr0)); // 获取CR0的值。

ret = cr0;

// cr0 &= 0xfffffffffffeffff; // 两句是等价的,设置20号位为0。
cr0 &= ~0x10000LL;

asm("movq %%rax, %%cr0"::"a"(cr0)); // 设置CR0的值。

return ret; // 将修改前的值返回到调用者。
}

// 复原状态的函数
void setback_cr0(uint64_t val)
{
asm volatile("movq %%rax, %%cr0"::"a"(val));
}

这里用到了汇编的知识,之前的教程都是32位的汇编码,如果不加C编译参数设置编译环境是32位的话,当然在64位机编译失败。刚好笔者前一段时间科普了一下汇编,才稍微懂得把32位汇编代码改到64位机可用。

定义加载模块时的行为

修改系统调用和编写内核模块是分开两个技术范畴,只是在这个实验中借着加载以及卸载内核模块的过程修改系统调用表。前面定义了准备替换的函数行为以及修改系统写权限的代码,现在定义程序是如何实施修改系统调用表行为。

在加载内核模块的时候,告知系统加载模块时的行为用module_init,而告知系统卸载时的行为用module_exit

加载内核模块的时候修改系统调用表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static int __init __init_extra_syscall__(void)
{
printk("Evan's System call successfully added.\n");

__sys_call_table_ptr__ = (unsigned long*)__SYS_CALL_TABLE_ADDR__;

printk("System call's address: %lx. TASK_COMM_LEN's value: %d.\n", __sys_call_table_ptr__, TASK_COMM_LEN);

funptr = (int(*)(void)) (__sys_call_table_ptr__[__MY_SYS_CALL_NUM__]); // 保存原来表中该函数的指针。

orig_cr0 = clr_and_ret_cr0(); // 关闭写保护。

__sys_call_table_ptr__[__MY_SYS_CALL_NUM__] = (unsigned long)&__my_syscall__; // 替换为自定义的函数。

setback_cr0(orig_cr0); // 复原。

return 0;

卸载内核模块的时候还原系统调用表:

1
2
3
4
5
6
7
8
9
10
static void __exit __exit_extra_syscall__(void)
{
orig_cr0 = clr_and_ret_cr0();

__sys_call_table_ptr__[__MY_SYS_CALL_NUM__] = (unsigned long)funptr;

setback_cr0(orig_cr0);

printk("Evan's system call exited.\n");
}

然后告知系统加载卸载内核模块行为:

1
2
3
4
5
module_init(__init_extra_syscall__);

module_exit(__exit_extra_syscall__);

MODULE_LICENSE("GPL");

最后一句许可是不可省略的,不然会编译失败。

用户态程序行为

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
int main(int argc, char* argv[])
{
int i, j, err;

// 系统调用332号,并传入参数。
err = syscall(332, &processes);

printf("Syscall result: %d.\n", err);

if(err != 0)
{
printf("%s\n", strerror(errno));
}

printf("Syscall result: %d.\n", syscall(332, &processes));

// 打印进程信息。
for(i = 0; i < 512; ++i)
{
for(j = 0; j < processes[i].depth; ++j)
{
printf("|-");
}

printf("%d-%s\n", processes[i].pid, processes[i].pname);

if(processes[i + 1].pid == 0)
{
break;
}
}

return 0;
}

用户态的程序很简单,就是调用,然后显示返回的数据。

Makefile的编写

make命令已经集成好编译内核模块,指定好参数,然后make即可。注意obj-m要对应好,如笔者保存的修改系统调用表的源码文件命名为ProcessTree.c,所以需要obj-m:=ProcessTree.o

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
KERNEL_VERSION = `uname -r`

obj-m:=ProcessTree.o
# EXTRA_CFLAGS=-m32 # 在64位系统编译32位代码的标志。

build: kernel_modules test_run

kernel_modules:
make -C /lib/modules/${KERNEL_VERSION}/build/ M=${CURDIR} modules

test_run:
gcc -o test_run test_run.c

clean:
make -C /lib/modules/${KERNEL_VERSION}/build M=${CURDIR} clean; \
rm -rf ${CURDIR}/test_run

完整源码再此。

编译加载运行

在目录下执行:

1
$ make

即会产生目标文件,随后执行加载模块命令:

1
$ sudo insmod ProcessTree.ko

查看加载模块信息:

1
$ sudo dmesg

例如,在这个例子中会看到如下输出:

1
2
[18128.569537] Evan's System call successfully added.
[18128.569540] System call's address: ffffffffa5a00200. TASK_COMM_LEN's value: 16.

执行用户态程序,顺利的话,会得到运行结果,输出太长了就不贴了。

再用dmesg可以看到:

1
[18158.067630] Evan's system call is executing.

表明这个系统调用成功执行了。

卸载模块:

1
$ sudo rmmod ProcessTree

再用dmesg查看:

1
[18190.508284] Evan's system call exited.

模块卸载了,系统调用表也还原了。

动态修改系统调用表的尝试至此为止了。之前在用32位的代码做实验的时候挺顺利的,后来想想在64位机上理论上也可以做到同样的效果,将代码改为支持64的汇编还好。中间是卡在了之前添加系统调用的设想上,明明获取了正确的系统调用表内存地址,dmesg也表明修改成功了,但是在用户态调用添加的333号调用上老是报错函数未实现,但是直接替换系统当前已有的332号就可以。所以猜想系统中应该存在类似NR_syscalls这类变量控制好系统调用表的边界,然而动用了Google搜索引擎也没找到期望的结果,只能放弃了。

从这个实验看,Linux系统的安全性也越来越好了。

FP-growth算法实现

之前实现了Apriori算法(Apriori算法实现),但是该算法存在着不少的算法性能缺陷,例如光是产生候选集都有这么多,加上用了平凡方式实现的原因,存储候选集产生了不少额外的内存使用。而FP-growth中的FP-tree则是用于压缩这些候选集的存储空间,候选集间会共享相同的元素。例如{A,B,C}{A,B,D},在Apriori中是单独的记录,而在FP-growth中用树存储,它们会共享{A,B}这个父节点,然后产生{C}{D}两个子节点,当模式越长的时候,这种压缩方法带来的存储开销优化越明显。

当然为了能够构造这样的树,还要设计额外的算法构建这棵树,一旦FP-tree构建了起来,检索方式就特别方便了,这个例子是典型的牺牲算法复杂度换取存储复杂度的例子。

样例数据集

FP-growth算法过程比Apriori还复杂,需要用一个例子来引导学习者。这里用的是《数据挖掘概念与技术》中的例子。

单号 购物清单
T100 I1,I2,I5
T200 I2,I4
T300 I2,I3
T400 I1,I2,I4
T500 I1,I3
T600 I2,I3
T700 I1,I3
T800 I1,I2,I3,I5
T900 I1,I2,I3

生成FP-tree

第一步与Apriori一样,根据最小支持度产生1项集。按照上述的样例数据,可产生:

项集 支持度计数
{I1} 6
{I2} 7
{I3} 6
{I4} 2
{I5} 2

但是FP-growth在此开始就不产生2以上的候选集了。首先按照频数给频繁集排序:{I2:7, I1:6, I3:6, I4:2, I5:2}。以null创建FP-tree的根。

随后扫描每条事务,按照排过序的频繁集读入一条事务。例如读入T100将产生:

再读入T200:

这样,两条事务就共享了一个前缀T2,通过同样的方法,最终构建出一棵FP-tree:

左边的表用于记录频繁集散布在树的位置。

这样生成树之后,只要从树的叶子节点开始,或者从根节点开始,只要设定好支持度阈值或其他参数,都能频繁树中获得目标模式。

伪代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
算法 FP-Growth。使用FP树,通过模式增长挖掘频繁模式。
输入:
D: 事务数据库。
min_sup: 最小支持度阈值。
输出:
频繁模式的完全集。
方法:
1.按以下步骤构造FP树:
(a)扫描事务数据库D一次。收集频繁项集的集合F和它们的支持度计数。对F按支持度计数降序排序,结果为频繁项列表L。
(b)创建FP树的根节点,以"null"标记它。对于D中每个事务Trans,执行:
选择Trans中的频繁项,并按L中的次序排序。设Trans排序后的频繁项列表为[p|P],其中p是第一个元素,而P是剩余元素的列表。调用insert_tree([p|P], T)。该过程执行情况如下。如果T有子女N使得N.item-name=p.item-name,则N的计数增加1;否则,创建一个新结点N,将其计数设置为1,链接到它的父结点T,并且通过结点链结构将其链接到具有相同item-name的结点。如果P非空,则递归地调用insert_tree(P,N)。
2.FP树的挖掘通过调用FP_growth(FP_tree, null)实现。该实现过程如下。
procedure FP_growth(Tree, α)
if Tree 包含单个路径 P then
for 路径 P 中结点的每个组合(记作β)
产生模式α ∪ β,其支持度计数support_count等于β中结点的最小支持度计数;
else
for Tree的表头的每个ai
{
产生一个模式β = α ∪ ai,其支持度技术support_count=ai.support_count;
构造β的条件模式基,然后构造β的条件FP树Treeβ;
if Treeβ!=Empty Set then
调用FP_growth(Treeβ, β);
}

Python实现

先从定义FpTreeNode开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class FpTreeNode:
def __init__(self, layer, node_tag='', val=0, parent=None, child_list=None):
if child_list is None: # 有不定个子结点。
child_list = []

self.layer = layer

self.node_tag = node_tag

self.val = val

self.parent = parent

self.child_list = child_list

def increase_val(self, inc_val=1):
self.val += inc_val

def add_child_node(self, child_node):
self.child_list.append(child_node)

def __str__(self):
return 'Node Name: %s, Value: %d' % (self.node_tag, self.val)

然后定义FpTree:

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
94
95
96
97
98
99
100
101
102
103
104
105
106
107
class FpTree:
def __init__(self, fre_list):
self.root = FpTreeNode(layer=0, node_tag='null') # 根结点。

self.fre_list = fre_list

self.lnk_tbl = None

def rearrange_ptn_as_fre_list(self, ptn): # 对进来的记录按照频繁集中的顺序重新排列。

rearranged_record = []

for (key, val) in self.fre_list:

if key in ptn:

rearranged_record.append(key)

return rearranged_record

def absorb_pattern(self, ptn, cnt): # 处理进来的一条记录。

tree_iter = self.root

layer_count = 0

ptn = self.rearrange_ptn_as_fre_list(ptn) # 排序。

for ele in ptn:

next_node = None

for child_node in tree_iter.child_list: # 查找前缀是否已存在。

if child_node.node_tag == ele:

child_node.increase_val(cnt) # 存在则直接增加结点的计数值。

next_node = child_node

break

if next_node is None: # 需要产生新的结点。

next_node = FpTreeNode(layer=layer_count + 1, node_tag=ele, val=cnt, parent=tree_iter)

tree_iter.add_child_node(next_node) # 增加子结点。

tree_iter = next_node # 进入子结点。

layer_count += 1

def gen_link_tbl(self): # 产生FP树的时候,需要记录频繁集结点的分布。

link_tbl = {} # 以便快速检索。

for (key, val) in self.fre_list:

link_tbl[key] = [val]

queue = []

for node in self.root.child_list:

queue.append(node)

while len(queue) > 0: # BFS遍历。

node = queue[0]

queue.pop(0)

for child_node in node.child_list:

queue.append(child_node)

link_tbl[node.node_tag].append(node) # 添加结点位置记录,用item ID作为字典的关键字。

return link_tbl

def gen_prefix_paths(self, key, update_lnk_tbl=False): # 产生前缀路径。

if self.lnk_tbl is None or update_lnk_tbl: # 获得频繁集中的结点分布。

self.lnk_tbl = self.gen_link_tbl()

prefix_paths = {}

for node in self.lnk_tbl[key][1::]: # 链表中第一个元素是频繁集支持度,所以索引从1开始。

node_iter = node.parent # 自下向上获取前缀。

prefix_path = set()

while node_iter.node_tag != 'null':

prefix_path.add(node_iter.node_tag)

node_iter = node_iter.parent

if len(prefix_path) > 0: prefix_paths[frozenset(prefix_path)] = node.val # 路径的支持度是路径中的结点的最小支持度。如果路径存在频繁项,则添加到结果中。

return prefix_paths

def is_empty(self):

return True if len(self.root.child_list) < 1 else False

生成FP-Tree:

1
2
3
4
5
6
7
8
9
10
11
12
13
def gen_fp_tree(dataset, min_sup=1):

frequent_list = find_frequent_1_itemsets(dataset, min_sup) # 产生1频繁项集。

frequent_list = sorted(frequent_list.items(), key=lambda d: d[1], reverse=True) # 降序排序。

fp_tree = FpTree(frequent_list) # 注入到树中。

for record, cnt in data_set.items(): # 把事务数据注入到树中,挖掘频繁模式。

fp_tree.absorb_pattern(record, cnt)

return fp_tree

生成了树之后,就可以调用FP_growth生成频繁模式了:

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
def FP_growth(fp_tree, min_sup=1, prefix=None, fre_item_list=None):

if fre_item_list is None:

fre_item_list = {}

if prefix is None:

prefix = set()

for base_ptn, val in fp_tree.fre_list: # 自上向下扫描FP-tree。

new_fre_set = prefix.copy() # 前缀也按一层一层地增长。

new_fre_set.add(base_ptn) # 以上一层生成的前缀为基础。

fre_item_list[frozenset(new_fre_set)] = val # 设置该频繁项集的支持度。

cond_ptn_bases = fp_tree.gen_prefix_paths(base_ptn) # 生成前缀路径。

cond_fp_tree = gen_fp_tree(cond_ptn_bases, min_sup) # 生成条件模式树。

if not cond_fp_tree.is_empty(): # 递归子结点。

FP_growth(cond_fp_tree, min_sup, new_fre_set, fre_item_list)

return fre_item_list

代码中涉及到的数据加载以及产生1频繁项集的代码都与Apriori算法实现一致,故本文就没有写上。

在这里要说明的是,在递归向下的时候,条件模式树会被不断地生成,以及生成去掉了前缀元素后的子树,因为暂时没有办法解决在不生成新的条件模式树的情况下,让下一层递归能够正确处理更新后的数据。所以笔者这里是一个可以优化的地方。

至此,FP-growth实现完毕。