优秀开源库muduo阅读笔记

muduo阅读笔记

目录

muduo开源库的笔记,比较杂,没有详细整理,现在就这么杂乱放着, 等真的需要再好好整理。

设计经验和思想

  1. 对象构造做到线程安全, 唯一的要求就是不要暴露this指针. 即不要在构造函数中注册任何回调; 也不要在构造函数中把this传给跨线程的对象; 即便在构造函数的最后一行也不行。 之所以这样规定,是因为在构造函数执行期间对象还没有完成初始化,如果this被泄露(escape)给了其他对象(其自身创建的子对象除外),那么别的线程有可能访问这个半成品对象,这会造成难以预料的后果。
  2. 对象析构的线程安全,一般通过shared_ptr, 因为shared_ptr不是线程安全,一般需要考虑额外加锁
  3. C++里可能出现的内存问题大致有这么几个方面: 1.缓冲区溢出(buffer overrun)。 2.空悬指针/野指针。 3.重复释放(double delete)。 4.内存泄漏(memory leak)。 5.不配对的new[]/delete。 6.内存碎片(memory fragmentation)。 正确使用智能指针能很轻易地解决前面5个问题,第6个问题需要明白内存碎片不可怕,可靠性只要不低于硬件和操作系统,普通PC故障率为3%-5%。 1.缓冲区溢出:用std::vector<char>/std::string或自己编写Buffer class来管理缓冲区,自动记住用缓冲区的长度,并通过成员函数而不是裸指针来修改缓冲区。 2.空悬指针/野指针:用shared_ptr/weak_ptr,这正是本章的主题。 3.重复释放:用scoped_ptr,只在对象析构的时候释放一次。 4.内存泄漏:用scoped_ptr,对象析构的时候自动释放内存。 5.不配对的new[]/delete:把new[]统统替换为std::vector/scoped_array。
  4. 尽可能使用RAII(RAII包装锁和条件变量实现自动释放,多用STL,智能指针, RAII包装文件描述符等)
  5. 用流水线,生产者消费者,任务队列这些有规律的机制,最低限度地共享数据。这是我所知最好的多线程编程的建议了。
  6. 不要用读写锁(提高性能的错觉,大多数情况下与简单的mutex相比,性能实际降低了)和信号量 , 信号量的问题是没有所有权的概念
  7. 在多线程程序中,使用 signal的第一原则是不要使用 signal
  8. 进程间通信可以只用TCP,即适合单机也适合多机
  9. (计时)只使用gettimeofday(2)来获取当前时间。 ·(定时)只使用timerfd_*系列函数来处理定时任务。 gettimeofday(2)入选原因(这也是muduo::Timestamp class的主要设计考虑): 1.time(2)的精度太低,ftime(3)已被废弃;clock_gettime(2)精度最高,但是其系统调用的开销比gettimeofday(2)大。
  10. muduo日志库采用的是双缓冲( double buffering)技术,基本思路是准备两块 buffer: A和 B,前端负责往 buffer A填数据(日志消息),后端负责将 buffer B的数据写入文件。当 buffer A写满之后,交换 A和 B,让后端将 buffer A的数据写入文件,而前端则往 buffer B填入新的日志消息,如此往复。用两个 buffer的好处是在新建日志消息的时候不必等待磁盘文件操作,也避免每条新日志消息都触发(唤醒)后端日志线程。换言之,前端不是将一条条日志消息分别传送给后端,而是将多条日志消息拼成一个大的 buffer传送给后端,相当于批处理,减少了线程唤醒的频度,降低开销。另外,为了及时将日志消息写入文件,即便 buffer A未满,日志库也会每 3秒执行一次上述交换写入操作。 muduo异步日志的性能开销大约是前端每写一条日志消息耗时 1. 0 μ s ~ 1. 6 μ s。关键代码实际实现采用了四个缓冲区,这样可以进一步减少或避免日志前端的等待。
  11. 1.运行一个单线程的进程; 2.运行一个多线程的进程; 3.运行多个单线程的进程; 4.运行多个多线程的进程。这些模式之间的比较已经是老生常谈,简单地总结如下。·模式 1是不可伸缩的( scalable),不能发挥多核机器的计算能力。·模式 3是目前公认的主流模式。它有以下两种子模式:   3a  简单地把模式 1中的进程运行多份 16   3b  主进程 + woker进程,如果必须绑定到一个 TCP port,比如 httpd + fastcgi·模式 2是被很多人所鄙视的,认为多线程程序难写,而且与模式 3相比并没有什么优势。·模式 4更是千夫所指,它不但没有结合 2和 3的优点,反而汇聚了二者的缺点。本文主要想讨论的是模式 2和模式 3b的优劣,即:什么时候一个服务器程序应该是多线程的。从功能上讲,没有什么是多线程能做到而单线程做不到的,反之亦然,都是状态机嘛(我很高兴看到反例)。从性能上讲,无论是 IO bound还是 CPU bound的服务,多线程都没有什么优势。
  12. 我认为多线程的适用场景是:提高响应速度,让 IO和“计算”相互重叠,降低 latency。虽然多线程不能提高绝对性能,但能提高平均响应性能。一个程序要做成多线程的,大致要满足:·有多个 CPU可用。单核机器上多线程没有性能优势(但或许能简化并发业务逻辑的实现)。·线程间有共享数据,即内存中的全局状态。如果没有共享数据,用模型 3b就行。虽然我们应该把线程间的共享数据降到最低,但不代表没有。·共享的数据是可以修改的,而不是静态的常量表。如果数据不能修改,那么可以在进程间用 shared memory,模式 3就能胜任。·提供非均质的服务。即,事件的响应有优先级差异,我们可以用专门的线程来处理优先级高的事件。防止优先级反转。 ·latency和 throughput同样重要,不是逻辑简单的 IO bound或 CPU bound程序。换言之,程序要有相当的计算量。·利用异步操作。比如 logging。无论往磁盘写 log file,还是往 log server发送消息都不应该阻塞 critical path。·能 scale up。一个好的多线程程序应该能享受增加 CPU数目带来的好处,目前主流是 8核,很快就会用到 16核的机器了。·具有可预测的性能。随着负载增加,性能缓慢下降,超过某个临界点之后会急速下降。线程数目一般不随负载变化。·多线程能有效地划分责任与功能,让每个线程的逻辑比较简单,任务单一,便于编码。而不是把所有逻辑都塞到一个 event loop里,不同类别的事件之间相互影响。
  13. 什么是线程池大小的阻抗匹配原则?我在前文中提到“阻抗匹配原则”,这里大致讲一讲。如果池中线程在执行任务时,密集计算所占的时间比重为 P( 0 < P ≤ 1),而系统一共有 C个 CPU,为了让这 C个 CPU跑满而又不过载,线程池大小的经验公式 T = C/ P。 T是个 hint,考虑到 P值的估计不是很准确, T的最佳值可以上下浮动 50%.这个经验公式的原理很简单, T个线程,每个线程占用 P的 CPU时间,如果刚好占满 C个 CPU,那么必有 T × P = C。下面验证一下边界条件的正确性。假设 C = 8, P = 1. 0,线程池的任务完全是密集计算,那么 T = 8。只要 8个活动线程就能让 8个 CPU饱和,再多也没用,因为 CPU资源已经耗光了。假设 C = 8, P = 0. 5,线程池的任务有一半是计算,有一半等在 IO上,那么 T = 16。考虑操作系统能灵活、合理地调度 sleeping/ writing/ running线程,那么大概 16个“ 50%繁忙的线程”能让 8个 CPU忙个不停。启动更多的线程并不能提高吞吐量,反而因为增加上下文切换的开销而降低性能。如果 P < 0. 2,这个公式就不适用了, T可以取一个固定值,比如 5 × C。另外,公式里的 C不一定是 CPU总数,可以是“分配给这项任务的 CPU数目”,比如在 8核机器上分出 4个核来做一项任务,那么 C = 4。
  14. 模式 2和模式 3a该如何取舍? §3. 5中提到,模式 2是一个多线程的进程,模式 3a是多个相同的单线程进程。我认为,在其他条件相同的情况下,可以根据工作集( work set)的大小来取舍。工作集是指服务程序响应一次请求所访问的内存大小。如果工作集较大,那么就用多线程,避免 CPU cache换入换出,影响性能;否则,就用单线程多进程,享受单线程编程的便利。举例来说·如果程序有一个较大的本地 cache,用于缓存一些基础参考数据( in-memory look-up table),几乎每次请求都会访问 cache,那么多线程更适合一些,因为可以避免每个进程都自己保留一份 cache,增加内存使用。
  15. 不要为了每个计算任务,每次请求去创建线程。一般也不会为每个网络连接创建线程,除非并发连接数与 CPU数相近。一个服务程序的线程数目应该与当前负载无关,而应该与机器的 CPU数目有关,即 load average有比较小(最好不大于 CPU数目)的上限。这样尽量避免出现 thrashing,不会因为负载急剧增加而导致机器失去正常响应。这么做的重要原因是,在机器失去响应期间,我们无法探查它究竟在做什么,也没办法立刻终止有问题的进程,防止损害进一步扩大。如果有实时性方面的要求,线程数目不应该超过 CPU数目,这样可以基本保证新任务总能及时得到执行,因为总有 CPU是空闲的。最好在程序的初始化阶段创建全部工作线程,在程序运行期间不再创建或销毁线程。借助 muduo:: ThreadPool和 muduo:: EventLoop,我们很容易就能把计算任务和 IO任务分配到已有的线程,代价只有新建线程的几分之一。
  16. 线程正常退出的方式只有一种,即自然死亡。任何从外部强行终止线程的做法和想法都是错的 19 20。佐证有: Java的 Thread class把 stop()、 suspend()、 destroy()等函数都废弃( deprecated)了, Boost. Threads根本就不提供 thread:: cancel()成员函数 21。因为强行终止线程的话(无论是自杀还是他杀),它没有机会清理资源。也没有机会释放已经持有的锁,其他线程如果再想对同一个 mutex加锁,那么就会立刻死锁。因此我认为不用去研究 cancellation point这种“鸡肋”概念。如果确实需要强行终止一个耗时很长的计算任务,而又不想在计算期间周期性地检查某个全局退出标志,那么可以考虑把那一部分代码 fork()为新的进程,这样杀( kill( 2))一个进程比杀本进程内的线程要安全得多。当然, fork()的新进程与本进程的通信方式也要慎重选取,最好用文件描述符( pipe( 2)/ socketpair( 2)/ TCP socket)来收发数据,而不要用共享内存和跨进程的互斥器等 IPC,因为这样仍然有死锁的可能。
  17. 理论上只有 read和 write可以分到两个线程去,因为 TCP socket是双向 IO。问题是真的值得把 read和 write拆开成两个线程吗?以上讨论的都是网络 IO,那么多线程可以加速磁盘 IO吗?首先要避免 lseek( 2)/ read( 2)的 race condition(§ 4. 2)。做到这一点之后,据我看,用多个线程 read或 write同一个文件也不会提速。不仅如此,多个线程分别 read或 write同一个磁盘上的多个文件也不见得能提速。因为每块磁盘都有一个操作队列,多个线程的读写请求到了内核是排队执行的。只有在内核缓存了大部分数据的情况下,多线程读这些热数据才可能比单线程快。多线程磁盘 IO的一个思路是每个磁盘配一个线程,把所有针对此磁盘的 IO都挪到同一个线程,这样或许能避免或减少内核中的锁争用。我认为应该用“显然是正确”的方式来编写程序,一个文件只由一个进程中的一个线程来读写,这种做法显然是正确的。为了简单起见,我认为多线程程序应该遵循的原则是:每个文件描述符只由一个线程操作,从而轻松解决消息收发的顺序性问题,也避免了关闭文件描述符的各种 race condition。一个线程可以操作多个文件描述符,但一个线程不能操作别的线程拥有的文件描述符。这一点不难做到, muduo网络库已经把这些细节封装了。
  18. fork()之后,子进程继承了父进程的几乎全部状态,但也有少数例外。子进程会继承地址空间和文件描述符,因此用于管理动态内存和文件描述符的 RAII class都能正常工作。但是子进程不会继承:·父进程的内存锁, mlock( 2)、 mlockall( 2)。·父进程的文件锁, fcntl( 2)。·父进程的某些定时器, setitimer( 2)、 alarm( 2)、 timer_ create( 2)等等。·其他,见 man 2 fork。通常我们会用 RAII手法来管理以上种类的资源(加锁解锁、创建销毁定时器等等),但是在 fork()出来的子进程中不一定正常工作,因为资源在 fork()时已经被释放了。比方说用 RAII技法封装 timer_ create()/ timer_ delete(),在子进程中析构函数调用 timer_ delete()可能会出错,因为试图释放一个不存在的资源。或者更糟糕地把其他对象持有的 timer给释放了(如果碰巧新建的 timer_ t与之重复的话)。因此,我们在编写服务端程序的时候,“是否允许 fork()”是在一开始就应该慎重考虑的问题,在一个没有为 fork()做好准备的程序中使用 fork(),会遇到难以预料的问题。
  19. fork()一般不能在多线程程序中调用 31 32,因为 Linux的 fork()只克隆当前线程的 thread of control,不克隆其他线程。 fork()之后,除了当前线程之外,其他线程都消失了。也就是说不能一下子 fork()出一个和父进程一样的多线程子进程。 Linux没有 forkall()这样的系统调用, forkall()其实也是很难办的(从语意上),因为其他线程可能等在 condition variable上,可能阻塞在系统调用上,可能等着 mutex以跨入临界区,还可能在密集的计算中,这些都不好全盘搬到子进程里。 fork()之后子进程中只有一个线程,其他线程都消失了,这就造成一个危险的局面。其他线程可能正好位于临界区之内,持有了某个锁,而它突然死亡,再也没有机会去解锁了。如果子进程试图再对同一个 mutex加锁,就会立刻死锁。在 fork()之后,子进程就相当于处于 signal handler之中,你不能调用线程安全的函数(除非它是可重入的),而只能调用异步信号安全( async-signal-safe)的函数。
  20. TCP网络编程本质论:三个半事件: 1.连接的建立,包括服务端接受( accept)新连接和客户端成功发起( connect)连接。 TCP连接一旦建立,客户端和服务端是平等的,可以各自收发数据。 2.连接的断开,包括主动断开( close、 shutdown)和被动断开( read( 2)返回 0)。 3.消息到达,文件描述符可读。这是最为重要的一个事件,对它的处理方式决定了网络编程的风格(阻塞还是非阻塞,如何处理分包,应用层的缓冲如何设计,等等)。 3. 5  消息发送完毕,这算半个。对于低流量的服务,可以不必关心这个事件;另外,这里的“发送完毕”是指将数据写入操作系统的缓冲区,将由 TCP协议栈负责数据的发送与重传,不代表对方已经收到数据。这其中有很多难点,也有很多细节需要注意,比方说:如果要主动关闭连接,如何保证对方已经收到全部数据?如果应用层有缓冲(这在非阻塞网络编程中是必需的,见下文),那么如何保证先发送完缓冲区中的数据,然后再断开连接?直接调用 close( 2)恐怕是不行的。如果主动发起连接,但是对方主动拒绝,如何定期(带 back-off地)重试?非阻塞网络编程该用边沿触发( edge trigger)还是电平触发( level trigger)? 12如果是电平触发,那么什么时候关注 EPOLLOUT事件?会不会造成 busy-loop?如果是边沿触发,如何防止漏读造成的饥饿? epoll( 4)一定比 poll( 2)快吗?在非阻塞网络编程中,为什么要使用应用层发送缓冲区?假设应用程序需要发送 40kB数据,但是操作系统的 TCP发送缓冲区只有 25kB剩余空间,那么剩下的 15kB数据怎么办?如果等待 OS缓冲区可用,会阻塞当前线程,因为不知道对方什么时候收到并读取数据。因此网络库应该把这 15kB数据缓存起来,放到这个 TCP链接的应用层发送缓冲区中,等 socket变得可写的时候立刻发送数据,这样“发送”操作不会阻塞。如果应用程序随后又要发送 50kB数据,而此时发送缓冲区中尚有未发送的数据(若干 kB),那么网络库应该将这 50kB数据追加到发送缓冲区的末尾,而不能立刻尝试 write(),因为这样有可能打乱数据的顺序。在非阻塞网络编程中,为什么要使用应用层接收缓冲区?假如一次读到的数据不够一个完整的数据包,那么这些已经读到的数据是不是应该先暂存在某个地方,等剩余的数据收到之后再一并处理?见 lighttpd关于\ r\ n\ r\ n分包的 bug13。假如数据是一个字节一个字节地到达,间隔 10ms,每个字节触发一次文件描述符可读( readable)事件,程序是否还能正常工作? lighttpd在这个问题上出过安全漏洞 14。在非阻塞网络编程中,如何设计并使用缓冲区?一方面我们希望减少系统调用,一次读的数据越多越划算,那么似乎应该准备一个大的缓冲区。另一方面,我们希望减少内存占用。如果有 10000个并发连接,每个连接一建立就分配各 50kB的读写缓冲区( s)的话,将占用 1GB内存,而大多数时候这些缓冲区的使用率很低。 muduo用 readv( 2)结合栈上空间巧妙地解决了这个问题。如果使用发送缓冲区,万一接收方处理缓慢,数据会不会一直堆积在发送方,造成内存暴涨?如何做应用层的流量控制?如何设计并实现定时器?并使之与网络 IO共用一个线程,以避免锁。这些问题在 muduo的代码中可以找到答案。
  21. TcpConnection必须要有 output buffer  考虑一个常见场景:程序想通过 TCP连接发送 100kB的数据,但是在 write()调用中,操作系统只接受了 80kB(受 TCP advertised window的控制,细节见[ TCPv1]),你肯定不想在原地等待,因为不知道会等多久(取决于对方什么时候接收数据,然后滑动 TCP窗口)。程序应该尽快交出控制权,返回 event loop。在这种情况下,剩余的 20kB数据怎么办?对于应用程序而言,它只管生成数据,它不应该关心到底数据是一次性发送还是分成几次发送,这些应该由网络库来操心,程序只要调用 TcpConnection:: send()就行了,网络库会负责到底。网络库应该接管这剩余的 20kB数据,把它保存在该 TCP connection的 output buffer里,然后注册 POLLOUT事件,一旦 socket变得可写就立刻发送数据。当然,这第二次 write()也不一定能完全写入 20kB,如果还有剩余,网络库应该继续关注 POLLOUT事件;如果写完了 20kB,网络库应该停止关注 POLLOUT,以免造成 busy loop。( muduo EventLoop采用的是 epoll level trigger,原因见下方。)如果程序又写入了 50kB,而这时候 output buffer里还有待发送的 20kB数据,那么网络库不应该直接调用 write(),而应该把这 50kB数据 append在那 20kB数据之后,等 socket变得可写的时候再一并写入。如果 output buffer里还有待发送的数据,而程序又想关闭连接(对程序而言,调用 TcpConnection:: send()之后他就认为数据迟早会发出去),那么这时候网络库不能立刻关闭连接,而要等数据发送完毕,见此处“为什么 TcpConnection:: shutdown()没有直接关闭 TCP连接”中的讲解。综上,要让程序在 write操作上不阻塞,网络库必须要给每个 TCP connection配置 output buffer。
  22. TcpConnection必须要有 input buffer   TCP是一个无边界的字节流协议,接收方必须要处理“收到的数据尚不构成一条完整的消息”和“一次收到两条消息的数据”等情况。一个常见的场景是,发送方 send()了两条 1kB的消息(共 2kB),接收方收到数据的情况可能是:·一次性收到 2kB数据;·分两次收到,第一次 600B,第二次 1400B;·分两次收到,第一次 1400B,第二次 600B;·分两次收到,第一次 1kB,第二次 1kB;·分三次收到,第一次 600B,第二次 800B,第三次 600B;·其他任何可能。一般而言,长度为 n字节的消息分块到达的可能性有 2n-1种。网络库在处理“ socket可读”事件的时候,必须一次性把 socket里的数据读完(从操作系统 buffer搬到应用层 buffer),否则会反复触发 POLLIN事件,造成 busy-loop。那么网络库必然要应对“数据不完整”的情况,收到的数据先放到 input buffer里,等构成一条完整的消息再通知程序的业务逻辑。这通常是 codec的职责,见§ 7. 3“ Boost. Asio的聊天服务器”中的“ TCP分包”的论述与代码。所以,在 TCP网络编程中,网络库必须要给每个 TCP connection配置 input buffer。 muduo EventLoop采用的是 epoll( 4) level trigger,而不是 edge trigger。一是为了与传统的 poll( 2)兼容,因为在文件描述符数目较少,活动文件描述符比例较高时, epoll( 4)不见得比 poll( 2)更高效 13,必要时可以在进程启动时切换 Poller。二是 level trigger编程更容易,以往 select( 2)/ poll( 2)的经验都可以继续用,不可能发生漏掉事件的 bug。三是读写的时候不必等候出现 EAGAIN,可以节省系统调用次数,降低延迟。
  23. Buffer:: readFd()  我在此处写道:在非阻塞网络编程中,如何设计并使用缓冲区?一方面我们希望减少系统调用,一次读的数据越多越划算,那么似乎应该准备一个大的缓冲区。另一方面希望减少内存占用。如果有 10000个并发连接,每个连接一建立就分配各 50kB的读写缓冲区的话,将占用 1GB内存,而大多数时候这些缓冲区的使用率很低。 muduo用 readv( 2)结合栈上空间巧妙地解决了这个问题。具体做法是,在栈上准备一个 65536字节的 extrabuf,然后利用 readv()来读取数据, iovec有两块,第一块指向 muduo Buffer中的 writable字节,另一块指向栈上的 extrabuf。这样如果读入的数据不多,那么全部都读到 Buffer中去了;如果长度超过 Buffer的 writable字节数,就会读到栈上的 extrabuf里,然后程序再把 extrabuf里的数据 append()到 Buffer中,代码见§ 8. 7. 2。这么做利用了临时栈上空间 14,避免每个连接的初始 Buffer过大造成的内存浪费,也避免反复调用 read()的系统开销(由于缓冲区足够大,通常一次 readv()系统调用就能读完全部数据)。由于 muduo的事件触发采用 level trigger,因此这个函数并不会反复调用 read()直到其返回 EAGAIN,从而可以降低消息处理的延迟。

服务端编程设计

  1. 不必7*24小时可用,应该允许每个进程都能随时重启
  2. 内存碎片不可怕,可靠性只要不低于硬件和操作系统,普通PC故障率为3%-5%
  3. 千兆以太网最大125M/s,这个速度比起CPU和内存带宽来说小得可怜。只要让吞吐饱和,用什么语言其实无所谓,这也是会用java的原因。
  4. 千兆以太网上,一个eventLoop就足以了,如果有优先级,则可以创建多个event loop
  5. 以四元组ip:port:pid:start_time作为进程唯一标识
  6. 心跳要在工作线程中,心跳是必须的,可能重启没有发送FIN,或者FIN丢包
  7. 如何解决文件符用完的问题,软约束
  8. 处理空闲连接,直接超时踢掉
  9. 使用多线程的场合:多个CPU核,
    • 不同线程指派不同任务,增加可读性。即划分责任与功能
    • 提高响应速度,让IO和计算重叠。
    • 利用异步操作,比如写日志,一个线程写,一个线程读
  10. 必须单线程场合
    • 程序可能fork
    • 限制程序的CPU占有率
  11. 多个单线程的进程还是一个多线程的进程? 看内存大小,如果内存大,则用后者,避免进程间使用同一个内存带来的cache换入换出
  12. 线程只能自然死亡,不推荐读写锁,优先使用mutex
  13. 负载均衡:根据主服务器发送给其它多个计算服务器失败最少的作为本次发送的目标服务器(已发出而未收到相应最小的)
  14. TCP三个半事件
    1. 连接建立
    2. 连接断开,close, shutdown, read(2)(read返回0),主动关闭还是被动关闭
    3. 消息到达,即可读
    4. 消息发送完毕,算半个,即如何知道对方已经接收到数据
  15. linux下有一个接口函数,可以在到达设定时间后,使得对应的文件描述符变为可读,这个接口函数就是timerfd
  16. wakeup,主要适用于将loop从epoll_wait中唤醒,不让其阻塞其中
  17. 日志打印模块的设计思路是将所有要打印的放到buffer中,待到超过作用域时,析构函数会调用,在析构函数中会将buffer内容打印出来
  18. 一个可供学习的自动释放Socket的设计方法为 unique_ptr, 使用unique_ptr使得套接字只有一个,并可实现自动释放功能

std::bind 和 std::function (基于 closure闭包 的编程)

  • 一直以来,我对面向对象都有一种厌恶感,叠床架屋,绕来绕去的,一拳拳打在棉花上,不解决实际问题。面向对象的三要素是封装、继承和多态。我认为封装是根本的,继承和多态则是可有可无的。用 class 来表示 concept,这是根本的;至于继承和多态,其耦合性太强,往往不划算。继承和多态不仅规定了函数的名称、参数、返回类型,还规定了类的继承关系。在现代的 OO 编程语言里,借助反射和attribute/annotation,已经大大放宽了限制。举例来说,JUnit 3.x 是用反射,找出派生类里的名字符合 void test *() 的函数来执行的,这里就没继承什么事,只是对函数的名称有部分限制(继承是全面限制,一字不差)。至于 JUnit 4.x 和 NUnit 2.x 则更进一步,以 annotation/attribute 来标明testcase,更没继承什么事了。我的猜测是,当初提出面向对象的时候,closure 还没有一个通用的实现,所以它没能算作基本的抽象工具之一。现在既然 closure 已经这么方便了,或许我们应该重新审视面向对象设计,至少不要那么滥用继承。
  • 自从找到了boost::function+boost::bind这对“神兵利器”,不用再考虑class之间的继承关系,只需要基于对象的设计(object-based),拳拳到肉,程序写起来顿时顺手了很多。 对面向对象设计模式的影响 既然虚函数能用closure代替,那么很多OO设计模式,尤其是行为模式,就失去了存在的必要。另外,既然没有继承体系,那么很多创建型模式似乎也没啥用了(比如Factory Method可以用boost::function<Base* ()>替代, Factory里的接口类直接用这个代替)。 最明显的是Strategy,不用累赘的Strategy基类和ConcreteStrategyA、ConcreteStrategyB等派生类,一个boost::function成员就能解决问题。另外一个例子是Command模式,有了boost::function,函数调用可以直接变成对象,似乎就没Command什么事了。同样的道理,Template Method可以不必使用基类与继承,只要传入几个boost::function对象,在原来调用虚函数的地方换成调用boost::function对象就能解决问题。 在《设计模式》这本书中提到了23个模式,在我看来其更多的是弥补了C++这种静态类型语言在动态性方面的不足。在动态语言中,由于语言内置了一等公民的类型和函数18,这使得很多模式失去了存在的必要19。或许它们解决了面向对象中的常见问题,不过要是我的程序里连面向对象(指继承和多态)都不用,那似乎也不用叨扰面向对象设计模式了。 或许基于closure的编程将作为一种新的编程范式(paradigm)而流行起来。
  • 基于接口的设计 这个问题来自那个经典的讨论:不会飞的企鹅(Penguin)究竟应不应该继承自 鸟(Bird),如果 Bird 定义了 virtual function fly() 的话。讨论的结果是,把具体的行为提出来,作为 interface,比如 Flyable(能飞的),Runnable(能跑的),然后让企鹅实现 Runnable,麻雀实现 Flyable 和 Runnable。(其实麻雀只能双脚跳,不能 跑,这里不作深究。) 进一步的讨论表明,interface 的粒度应足够小,或许包含一个 method 就够了, 那么 interface 实际上退化成了给类型打的标签(tag)。在这种情况下,完全可以使用 boost::function 来代替,比如:
    // 这种设计的优势是,不同接口不会耦合的,无需考虑繁复的继承关系,而是先定义具体对象,至于对象有哪些接口,我再一一用boost::function定义出来。如果用设计模式那种处理的话,就需要考虑run这一公共接口该如何定义,非常别扭。
    
    class Penguin // 企鹅能游泳,也能跑
    {
    public:
      void run();
      void swim();
    };
    class Sparrow // 麻雀能飞,也能跑
    {
    public:
      void fly();
      void run();
    };
    
    // 以 boost::function 作为接口
    typedef boost::function<void()> FlyCallback;
    typedef boost::function<void()> RunCallback;
    typedef boost::function<void()> SwimCallback;
    
    // 一个既用到 run,也用到 fly 的客户 class
    class Foo
    {
    public:
      Foo(FlyCallback flyCb, RunCallback runCb)
      : flyCb_(flyCb), runCb_(runCb)
      { }
    private:
      FlyCallback flyCb_;
      RunCallback runCb_;
    };
    // 一个既用到 run,也用到 swim 的客户 class
    class Bar
    {
    public:
      Bar(SwimCallback swimCb, RunCallback runCb)
      : swimCb_(swimCb), runCb_(runCb)
      { }
    private:
      SwimCallback swimCb_;
      RunCallback runCb_;
    };
    
    int main()
    {
      Sparrow s;
      Penguin p;
      // 装配起来,Foo 要麻雀,Bar 要企鹅。
      Foo foo(bind(&Sparrow::fly, &s), bind(&Sparrow::run, &s));
      Bar bar(bind(&Penguin::swim, &p), bind(&Penguin::run, &p));
    }
    

参考资料

muduo 源码剖析

muduo开源代码github路径

  • 20
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值