阶段性总结 0x01

来说一些闲言碎语,顺便做一下总结。

自从10月13日以来就没有更新blog了,那一天是我读完《C++ Primer Plus》之后准备进行总结的日子。总结的思路也想得很清楚,总体上是根据侯捷老师说的:C++宏观上可以划分成四个部分,面向过程编程,面向对象编程,泛型编程和函数式编程。

我也试图通过这四个部分对已经学习的内容进行归纳,同时将学习过程中觉得十分复杂而且会对未来造成影响的问题一并归纳。首先在面向过程的归纳中,我将类型转换作为其中的一部分内容进行归纳,其中看起来或许有其它部分的成分,但在C++ 的背景下,我认为是比较合理的。

类型往往是了解一门语言的第一扇门,C++的类型转换机制常常使我感叹艰难,若不懂类型转换或许无法理解重载以及由于隐式转换带来的对于构造函数的理解困难。这也是我对其进行总结的原因。

那日之后我的归纳工作便停止了,找一个借口是因为我对剩下部分内容的理解还不够。

网络编程部分

概述

接下来的日子我针对了Linux下的Socket编程进行了学习,主要参照《TCP/IP网络编程》这一本书。我开始意识到一本“薄”的书对于入门的重要意义——它不会让你感到十分困难,甚至还会使你产生跃跃欲试的冲动。

这之前我还购买了《APUE》和《UNP》两本书,不得不说,虽然简介中说明了适合所有人阅读,但还是摆脱不了我对其“就是一本字典”的认知,或许在以后我真的会把它当作字典来用。

《TCP/IP网络编程》这本书介绍的内容十分简单,建立一个可用的服务器的过程也就这样简单:

首先建立起一个服务端的socket,对其监听。对于监听手段,你可以采用socket提供的api本身,或者“一切皆文件”的使用I/O多路复用技术对socket进行监听。监听到socket活动后便可以进行你想要的操作。

是的,这样的服务器已经满足“可用”的状态,可以称之为原型(prototype)。如果想要做一个高性能的服务器就要使用多一些的思考。我的目标也是要实现一个高性能的Linux服务器,书籍我也已经准备好了《Linux高性能服务器编程》。

为了实现高性能,就是要解决原型中存在的性能限制,这可能会依赖某些设计模式。

比如说,单线程的I/O多路复用背景下,在其中处理读写可能会产生阻塞或者十分耗时的操作:解析一个HTTP报头等,这种耗时操作会耽误其它连接请求的处理。我们不希望出现这种阻塞,我们希望我们的服务器竭尽所能地工作,或者可以这样解释:处理连接数是高性能的一个指标,提高连接数量是达到高性能的一个充分条件。

为每一个耗时的操作开启一个线程,减少其对主线程的占用,这是我们的一个办法。

你还可能会想,或许可以用多个线程来处理I/O多路复用的工作。这样的想法很好,多个线程来分摊同一份工作,当然也可以提高服务器的处理量。

这样发展下去,我们的服务器在运行之后的某一个时间点可能会出现大量的线程同时运行的情况(假设我们的CPU超级厉害),然而这样多线程的创建以及销毁对性能都是不少的影响,于是 线程池 可以帮助我们对线程进行管理。

大量的线程操作还可能产生资源的竞争, 以及 原子操作 的使用也是必不可少的。

假设我们建立好了一个高性能的服务器,那么就相当于我们已经有了一个特别特别好的服务器基础设施,剩下的就是来扩展基于该服务的协议,比如用我们的服务器来处理HTTP协议。然而如何处理HTTP协议也是我要学习的事情。

EPOLL学习

在学习EPOLL的过程中真的是解决了我不少的困惑。比如I/O阻塞,read和write何时会阻塞?那自然是读无可读、写不可写的状态时才会阻塞;ET 和 LT 区别?就在于一次epoll_wait后会不会还将其fd放入rdlist;为何要将监听的socket描述符设置成为非阻塞?可以使用反证法,若设置为阻塞,那么该epoll线程会阻塞在socket的IO方法上,这个方法可能是read,然而在该描述符没有新的可读内容到达之前,这个线程无法被唤醒至epoll_wait状态,所以设置为非阻塞,读到不可再读时内核会发出 EAGAIN 的信号,这个信号来帮助我们走出循环回到epoll_wait状态以便于处理其它事件。

所以对于EPOLL的使用通常会有如下建议:

  1. 最好使用ET模式,ET模式下一定要将监听的文件设置为非阻塞。
  2. 一定要在一次事件发生后处理好全部的缓冲。

《Effective C++》

对于这本书的学习是零散的,我对C++的知识点记忆得不深也是从这本书的阅读过程中发现的,这种时刻往往是进行相关知识点复习的好机会。

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

这是每一个刚刚学习继承的内容时要牢记的知识点。

编译器在处理派生类的析构函数时,会在其析构函数中加入基类析构函数的调用。因为编译器规定:释放派生类时要先释放其基类。

在这个场景下,若基类未使用 virtual 声明析构函数,则在编译时将为该指针绑定基类的析构函数,而非派生类的析构函数。这样的后果是未定义的,但一般的情况都是基类对象空间被释放,而派生类未被释放。如此一来便是错误的。

将基类析构函数声明为 virtual ,则编译器在处理基类指针指向时,会正确的将该指针视为派生类对象,从而正确的调用派生类的析构函数,完成正确顺序的析构函数的调用。

条款08:不要让析构函数抛出异常

析构函数的地位十分特殊,它往往意味着资源的释放。在资源释放中出现异常是及其危险的,这可能导致意料之外的程序结束或者不明确行为发生。

所以对于资源释放时的异常处理,我们(设计者)往往这样做:

  1. 在析构函数 当中 捕获异常,此时有两种选择,吞掉abort使程序终止。绝对不可以 抛出该异常。

  2. 如果用户有明确该异常的需要,那么将这部分资源的释放设计为析构函数之外的任何成员都可以,用户使用这部分成员来完成资源释放,此时析构函数用来做资源释放的第二道保险。

    使得析构函数之外的成员函数抛出异常就可以遵守这一条款。这样用户可以包裹住该成员函数以获取异常。

对于析构函数做如此的要求是C++的主意,因为他不喜欢析构函数抛出异常。为了让我们的编译器开心——不至于导致程序意外结束或产生不明确行为,我们应当遵守。

条款09:不在构造和析构函数中调用 virtual 函数

我们先来说明这一场景:何时我们会想要在构造和析构函数中调用 virtual 函数。

virtual 是多态的标识,每一个 derived class 拥有自己的 virtual 实现,用以形成多态。然而有时我们会试图base class 去调用 derived class 中的多态实现,这便是产生问题的原因。

要知道,我们无法在 base class 中知晓 derived class 的消息,因为 base class 并不知道自己会被谁所继承和实现,这是拒绝这种行为的一种解释。

另外一种解释是,在编译期间,base class内部通过函数名直接调用的virtual函数是直接resolve到该类当中。若为derived class则情况有些变化,因为此时可以获得base class的信息,以至于当自身并无virtual函数实现的话,编译器便会 resolvebase class 当中。

如果真的有必要让base class去适应derived class的变化的话,可以将相应的变化传递给base class进行调用,比如向 base class 传递一个函数指针。

条款10:令operator=返回一个对象引用(reference to *this)

这仅仅是一个建议,用于满足 = 连续赋值的特性,所以使得operator=返回对象引用如Object& operator=(const Object& rhs)

这仅仅是一个建议,当然在某些情况下自己便会判断出何时需要返回引用何时仅返回副本。

更需要值得一提的是 运算符 重载的限制:

  1. 对于运算符的重载一定要保证运算符原有的语义。
  2. 重载时至少有一个操作数是用户定义的类型,防止覆盖基础类型的运算符定义。
  3. 有几个运算符是无法进行重载的,有一些操作符是只可以定义在类中的,还有一些操作符最好作为友元来存在的。

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

这是一个非常值得重视的条款,在侯捷老师的课中也有提到过。

我们为了实现 = 的语义,通常是将this对象的各个组分替换为参数对象的各个组分。然而当成员中存在 pointer 时,如char *str 通常为一个字符串,在 = 实现时,我们会先将this对象的 str 通过 delete 释放,然而这便是错误的温床。

试想,如果此时用户进行如下操作:

a = a; // a 含有 char *str 成员

此时我们的 operator= 中不加思索的将 str 进行释放,然而参数对象指向了this对象,这一释放就使得程序停止。

所以通常在程序的开始,进行检查:参数是否指向this本身,如果是便没有必要进行赋值操作。

上文所述为 赋值安全,对于operator=还有另一方面的事情,即 异常安全

申请内存空间是会产生异常的操作,我们需要保证得是:在进行 = 操作中不会因为异常而影响 this 本身的成员。

解决这一问题的做法通常是将自身成员进行备份,然后申请内存,最后删除备份。当内存申请失败时,备份的成员可以进行还原,这是备份的意义。

这样的操作同样也是 赋值安全 的,可以思考一下原因。

因为备份以及额外申请空间的存在,最后的删除操作并不会误删this本身。

最好的处理办法 copy and swap

延续上文的思想——备份。这里将利用复制构造函数将参数对象备份,然后交换this和备份所指向的内容。 备份的过程可以通过函数参数声明*reference by value*的方式来实现。

这个想法是非常精妙的,但是swap似乎需要我们自己来实现。因为我按照书中的做法进行实验时,使用得是标准库当中得swap函数,发现swap的调用出现了无止境的递归调用,会递归调用复制构造函数。原因是在std::swap 的内部实现也调用了 operator=,然而在 operator= 中还要调用 std::swap 如此形成了毫无止境的递归调用。

回避的方法可以进行假设,能否保留 operator= 原来的实现?

我记得在 C++ primer plus 中提到过这一点,需要进行复习,这一内容已经加入了任务卡片。

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

最容易忘记的是继承关系中的基类部分

复制操作有 复制构造函数 还有 拷贝赋值函数operator=)。你可能不会忘记在构造中构造基类的部分(即使没有用于构造基类的参数),但在 拷贝赋值函数 当中或许会忘记拷贝基类的部分。

如何在派生类中使用基类的成员?

base::memeber(args*);

如何在派生类中获得基类对象?

// 以下几种方法都可以
base *temp = (base *) this;
base* temp = dynamic_cast<base *> (this);
base* temp = static_cast<base *> (this);

用于拷贝基类部分便可以使用:

base::operator=(rhs);

来将参数的基类部分拷贝至this当中。

一些零碎的记录

01 指针赋值

这是在237. 删除链表中的节点出现的问题。

我试图让一个指针的内容赋值给另外一个指针的内容,结果却写成了 node = node->next 赋值两侧都是指针而非指针所指对象,这样的更改是毫无意义的,需要对其解引用才是 *node = *node->next ,另外 -> 的优先级要比 * 高。

02 野指针和double free

在线性表的数组实现中产生的问题。在 clear() 函数实现时将指针成员 delete ,之后该成员便成为了野指针。程序结束后自动进行析构时出现了double free的问题,初步判断是因为该指针成员已经被释放无法再次进行释放。

所以我将delete后的指针成员指向 nullptr ,解决了double free的问题。

这样一看,野指针是指针使用不规范导致的现象,而double free是指针未好好处理衍生的问题。或许解决这种问题便是智能指针的意义。

03 释放已申请的堆内存

看到了这样的代码:

template <class T>
void arrayList<T>::erase(int index)
{
    checkIndex(index);
    std::copy(element + index + 1, element + listSize, element + index);
    element[--listSize].~T(); // 释放了末尾元素的内容
}

我误以为最后的析构是连同数组末尾的空间一起释放,实际上仅仅是将末尾元素的内容进行释放。另外要补充得是:只能将 new 返回的地址执行 delete 操作,不能 delete 任意一个地址空间。

我感觉我们的内存管理器在记每一笔帐,谁申请了多少内存,申请了哪个位置的内存,但是不可以分期还的那种。

04 递归树和回溯算法

数据结构与算法的第一章课后习题就有很大篇幅来将递归的算法。

递归树和回溯算法相辅相成,递归树是我们每一次递归的结果集形成的树,回溯是我们用来遍历这颗树的手段。

在回溯算法中起到了很好的作用,push将我们代入下一个状态,pop使我们回溯到上一状态。

这部分算法在之后专项训练中肯定还会进行学习和掌握。

05 有关模板类

这是最近遇到的知识点。

第一点:一个普通的类,我们通常会将其声明和定义放在不同的文件中进行编译,然而模板类不可以(C++11)。

第二点:关于模板类中 友元运算符 的重载

这一问题的背景是,需要为模板类重载运算符 operator<< ,重载的运算符需要该模板类的对象作为参数,且该运算符不会成为模板类的成员。

这样的背景下,我们的函数可以有下面几种选择的可能:

  1. 不作为友元,也不是模板函数。
  2. 不作为友元,是模板函数。
  3. 作为友元,但不是模板函数。
  4. 作为友元,是模板函数。

是否为友元,意味着该运算符能否直接使用类的私有成员;是否为模板函数,意味着运算符适用范围的大小。

那我们来讨论一下上面几种方案可不可行以及如何设计。

不作为友元,也不是模板函数

不为友元,该运算符便无法使用类中的私有成员,这需要我们在类中定义一个间接的公有方法来获得数据成员。

不作为模板函数,对模板类的支持要求我们只可以使用该类的具体化对象作为参数,这意味着我们需要考虑到所有模板类的具体化对象(或者是我们需要的具体化对象)。

举个例子,如果我们有模板类 ArrayList<T> ,但我们只需要 int 类型的具体化对象,那么这个操作符的重载签名可以为:

ostream& operator<<(ostream& out,const ArrayList<int> &object);

注意到 ArrayList<int> ,这是我们对 int 类型完成的具体化操作,试想一下如果我们需要支持更多的类型呢?这样的做法便是不可取的。

不作为友元,是模板函数

经过上文的讨论,我们可以做出假设:那如果我的重载函数是一个模板函数呢?

答案是可以的,而且是合理的解决方案。由模板定义来使编译器帮助我们生成相应类型的函数,这正是我们所需要的。

这样的话,我们的签名可以这样写:

template <class T>
ostream& operator<<(ostream& out,const ArrayList<T> &object);

作为友元,但不是模板函数

这是可能的,但是不合理的。

由于上文所述,若不是模板函数则需要我们为每一个用到的类型进行具体化,之后将一个个具体化后的重载函数作为模板类的友元来声明。从这句话中你便能够获得不合理的感觉。

作为友元,是模板函数

这个过程是比较复杂的,但是也是正确的解法。

首先,将重载函数的声明放在模板类之前。

template <class T>
std::ostream &operator<<(std::ostream &out, const arrayList<T> &x);

然后再模板类中的友元具体化:

friend std::ostream &operator<<<>(std::ostream &out, const arrayList<T> &x);
// 或者如下声明
friend std::ostream &operator<<<T>(std::ostream &out, const arrayList<T> &x);

之后定义我们的模板函数即可。

上述代码并不完全正确,这当中还存在一个循环引用的问题:operator<< 在声明时用到了 arrayList<T> 类,然而该类在之前并没有声明,所以此时会编译失败。解决的办法也很简单:将 arrayList<T> 前置声明

template <class T>
class arrayList;
template <class T>
std::ostream &operator<<(std::ostream &out, const arrayList<T> &x);

这样就解决了全部的问题。

你可能会问,如果友元没有具体化会如何?这样编译器会提醒你,如果你意图将其作为模板函数的话,可以在函数名后加上 <> 。(大概是这个意思)

warning: friend declaration ‘std::ostream& operator<<(std::ostream&, const arrayList<T>&)’ declares a non-template function
note: (if this is not what you intended, make sure the function template has already been declared and add ‘<>’ after the function name here)

总结

这段时间的任务都使用 Trello 看板进行管理,这些内容大多数都停留在任务卡片的评论区当中,因为评论的功能就在手边,与其打开文档不如直接随手就记最后进行整理。

已经过去了一个月的时间了,在写这篇总结之前浏览了评论区,感觉没有多少内容可写,甚至还低落了一阵——明明都一个月过去了,收获却如此之少。但是内容写完之时发现,还可以,还不错,甚至又发现补充了一些问题和内容。这带给我的感觉是很好的。

这段时期的精神内耗也比较严重,不过我在自我调整,放慢步伐一点点的前进,毕竟已经到了比较艰难的阶段之下。同样我也在寻找一些练习以避免光说不练。这段时间可能会开启设计模式的学习,我也意识到了不犯错是不会意识到《Effective C++》这本书的精髓的。

所以接下来的时间,可能是一边进行着数据结构的学习,一边进行着面向对象(设计模式)的学习。从这两个点出发,或许能够延申到现在所看的这几本书中的内容。