C++-反应式编程(全)

C++ 反应式编程(全)

原文:annas-archive.org/md5/e4e6a4bd655b0a85e570c3c31e1be9a2

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

这本书将帮助您学习如何使用 C++实现响应式编程范式,以构建异步和并发应用程序。响应式编程模型在编程模型(OOP/FP)、事件驱动 GUI 编程、语言级并发、无锁编程、设计模式和事件流编程方面需要大量的先决条件。前六章详细介绍了这些主题。在剩下的章节中,我们基于工业级 RxCpp 库进行讨论。涵盖的主题包括 RxCpp 编程模型的介绍,RxCpp 编程模型的五个关键元素,使用 Qt 进行 GUI 编程,编写自定义操作符,Rx 设计模式,响应式微服务和高级异常/操作符。通过本书,您将能够自信地将 Rx 构造嵌入到您的程序中,以使用 C++编写更好的并发和并行应用程序。

这本书是为谁准备的

如果您是一名对使用响应式编程构建异步和并发应用程序感兴趣的 C++开发人员,您会发现这本书非常有用。本书不假设读者具有响应式编程的任何先前知识。我们在第二章,现代 C++及其关键习惯的介绍,第三章,C++语言级并发和并行性,以及第四章,C++中的异步和无锁编程中涵盖了编写响应式程序所需的现代 C++构造。任何对经典 C++有合理熟悉度的 C++程序员都可以轻松地阅读本书。

这本书涵盖了什么

第一章,响应式编程模型-概述和历史,介绍了 GUI 工具包(如 Windows API,XLib API,Qt 和 MFC)实现的各种事件处理技术。本章还在编写跨平台控制台应用程序和使用 MFC 库编写 GUI 应用程序的背景下,介绍了 Rx 编程模型的一些关键数据结构。

第二章,现代 C++及其关键习惯的介绍,涵盖了编写响应式程序所需的现代 C++构造。本章重点介绍了新的 C++特性,类型推断,可变模板,右值引用,移动语义,lambda 函数,基本函数式编程,可管道化操作符,迭代器和观察者的实现。

第三章,C++语言级并发和并行性,讨论了 C++标准提供的线程库。您将学习如何启动和管理线程。我们将讨论线程库的不同方面。本章为现代 C++引入的并发支持奠定了良好的基础。

第四章,C++中的异步和无锁编程,讨论了标准库提供的用于实现基于任务的并行性的设施。它还讨论了现代 C++语言提供的新的多线程感知内存模型。

第五章,可观察对象简介,讨论了 GoF 观察者模式并解释了它的缺点。您将学习如何使用我们设计的技术,将实现 GoF 组合/访问者模式的程序转换为可观察流,这是在建模表达树的背景下进行的。

第六章,使用 C++进行事件流编程简介,专注于事件流编程的主题。我们还将介绍 Streamulus 库,该库提供了一种领域特定嵌入式语言DSEL)方法来操作事件流。

第七章,《数据流计算和 RxCpp 库简介》,从数据流计算范式的概念概述开始,迅速转向编写一些基本的基于 RxCpp 的程序。您将了解 RxCpp 库支持的一组操作符。

第八章,《RxCpp - 关键元素》,让您了解 Rx 编程的各个部分如何在 Rx 编程模型的整体和 RxCpp 库的特定上下文中相互配合。详细涵盖的主题包括 Observables、Observer、Operators、Subscribers、Schedulers(Rx 编程模型的五个关键元素)。

第九章,《使用 Qt/C++进行响应式 GUI 编程》,涉及使用 Qt 框架进行响应式 GUI 编程的主题。您将了解 Qt 框架中的概念,如 Qt 对象层次结构、元对象系统、信号和槽。最后,您将使用 RxCpp 库编写一个应用程序,以响应式方式处理鼠标事件并对其进行过滤。

第十章,《在 RxCpp 中创建自定义操作符》,涵盖了如何在 RxCpp 中创建自定义响应式操作符的高级主题,如果现有的操作符集不适用于特定目的。我们将介绍如何利用 Lift Meta Operator 并向 RxCpp 库添加操作符。此主题还将帮助您通过组合现有操作符来创建复合操作符。

第十一章,《C++ Rx 编程的设计模式和习语》,深入探讨了设计模式和习语的奇妙世界。从 GOF 设计模式开始,我们将转向响应式编程模式。我们将涵盖 Composite/Visitor/Iterator(来自 GOF 目录)、Active Object、Cell、Resource Loan 和 Event Bus Pattern。

第十二章,《使用 C++编写响应式微服务》,介绍了如何使用 Rx 编程模型来编写使用 C++的响应式微服务。它向您介绍了 Microsoft C++ REST SDK 及其编程模型。您将学习如何利用 RxCpp 库以响应式方式编写聚合服务并访问基于 HTTP 的服务。

第十三章,《高级流和错误处理》,讨论了 RxCpp 中的错误处理,以及处理 RxCpp 库中的流的一些高级构造和操作符。我们将讨论在出现错误时如何继续流,如何等待流的生产者纠正错误并继续序列,以及如何执行适用于成功和错误路径的常见操作。

要充分利用本书

为了跟进本书中的主题,您需要了解 C++编程。本书涵盖了所有其他主题。当然,您需要搜索网络或阅读其他材料,以对一些主题有专家级的理解(这对任何主题都是真实的)。

下载示例代码文件

您可以从www.packtpub.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便直接通过电子邮件接收文件。

您可以按照以下步骤下载代码文件:

  1. 登录或在www.packtpub.com注册。

  2. 选择“支持”选项卡。

  3. 单击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

下载文件后,请确保使用最新版本的解压缩或提取文件夹:

  • Windows 的 WinRAR/7-Zip

  • Mac 的 Zipeg/iZip/UnRarX

  • Linux 的 7-Zip/PeaZip

该书的代码包也托管在 GitHub 上:github.com/PacktPublishing/CPP-Reactive-Programming。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还有其他代码包,来自我们丰富的书籍和视频目录,可在github.com/PacktPublishing/上找到。去看看吧!

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在这里下载:www.packtpub.com/sites/default/files/downloads/CPPReactiveProgramming_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。这是一个例子:“前面的代码片段通过名为WNDCLASS(或现代系统中的WNDCLASSEX)的结构进行初始化,为窗口提供必要的模板。”

代码块设置如下:

/* close connection to server */
XCloseDisplay(display);

return 0;
}

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

/* close connection to server */
XCloseDisplay(display);

return 0;
}

任何命令行输入或输出都以以下方式编写:

$ mkdir css
$ cd css

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。这是一个例子:“在窗口术语中,它被称为消息循环。”

警告或重要说明会出现在这样。

提示和技巧会出现在这样。

第一章:反应式编程模型-概述和历史

X Windows 系统、Microsoft Windows 和 IBM OS/2 Presentation Manager 使得 GUI 编程在 PC 平台上变得流行。这是从字符模式用户界面和批处理式编程模型到他们之前存在的重大转变。对事件的响应成为全球软件开发人员的主要关注点,平台供应商转而创建了基于低级 C 的 API,依赖于函数指针和回调来使程序员能够处理事件。编程模型大多基于合作式多线程模型,并随着更好的微处理器的出现,大多数平台开始支持抢占式多线程。处理事件(和其他异步任务)变得更加复杂,以传统方式响应事件变得不太可扩展。尽管出现了出色的基于 C++的 GUI 工具包,事件处理大多是使用消息 ID、函数指针分发和其他低级技术来完成的。甚至一家知名的编译器供应商尝试添加 C++语言的语言扩展来实现更好的 Windows 编程。处理事件、异步性和相关问题需要重新审视问题。幸运的是,现代 C++标准支持函数式编程、语言级并发(带有内存模型)和更好的内存管理技术,使程序员能够处理异步数据流(将事件视为流)的编程模型称为反应式编程。为了让事情更清晰,本章将概述以下主题:

  • 事件驱动编程模型及其在各种平台上的实现。

  • 什么是反应式编程?

  • 反应式编程的不同模型。

  • 一些简单的程序以更好地理解概念。

  • 我们书的理念。

事件驱动编程模型

事件驱动编程是一种编程模型,其中流程控制由事件决定。事件的例子包括鼠标点击、按键、手势、传感器数据、来自其他程序的消息等等。事件驱动应用程序具有在几乎实时基础上检测事件并通过调用适当的事件处理过程来响应或反应的机制。由于早期的事件处理程序大多使用 C/C++编写,它们采用低级技术,如回调(使用函数指针)来编写这些事件处理程序。后来的系统,如 Visual Basic、Delphi 和其他快速应用程序开发工具确实增加了对事件驱动编程的本地支持。为了更清楚地阐明问题,我们将介绍各种平台的事件处理机制。这将帮助读者理解反应式编程模型解决的问题(从 GUI 编程的角度)。

反应式编程将数据视为流和窗口系统中的事件可以被视为流以便以统一的方式进行处理。反应式编程模型支持从不同来源收集事件作为流,过滤流,转换流,对流执行操作等。编程模型处理异步性,调度细节作为框架的一部分。本章主要基于反应式编程模型的关键数据结构以及我们如何实现基本的反应式程序。在工业强度的反应式程序中,编写的代码将是异步的,而本章的示例是同步的。在讨论乱序执行和调度之前,我们在接下来的章节中提供必要的背景信息和语言构造。这些实现是为了阐明问题,并可以作为学习示例。

X Windows 上的事件驱动编程

X Windows 编程模型是一个跨平台 API,在 POSIX 系统上得到了广泛支持,甚至已经移植到了 Microsoft Windows。事实上,X 是一个网络窗口协议,需要一个窗口管理器来管理窗口堆栈。屏幕内容由 X 服务器管理,客户端库将内容拉取并在本地机器上显示。在桌面环境中,服务器在同一台机器上本地运行。以下程序将帮助读者了解 XLib 编程模型的要点以及平台上如何处理事件:

#include <X11/Xlib.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void)
{
    Display *display;
    Window window;
    XEvent event;
    char *msg = "Hello, World!";
    int s;

前面的代码片段包括了程序员应该包含的正确的头文件,以获取 XLib C 库提供的函数原型。在从头开始编写 XLib 程序时,程序员应该了解一些数据结构。如今,人们使用诸如 Qt、WxWidgets、Gtk+、Fox toolkit 等库来编写商业质量的 X 程序。

    /* open connection with the server */
    display = XOpenDisplay(NULL);
    if (display == NULL){
        fprintf(stderr, "Cannot open display\n");
        exit(1);
    }
    s = DefaultScreen(display);
    /* create window */
    window = XCreateSimpleWindow(display,
             RootWindow(display, s), 10, 10, 200, 200, 1,
             BlackPixel(display, s), WhitePixel(display, s));

    /* select kind of events we are interested in */
    XSelectInput(display, window, ExposureMask | KeyPressMask);

    /* map (show) the window */
    XMapWindow(display, window);

前面的代码片段初始化了服务器并根据特定规格创建了一个窗口。传统上,大多数 X Windows 程序在管理级窗口下运行。我们通过在显示窗口之前调用XSelectInput API 来选择我们感兴趣的消息:

    /* event loop */
    for (;;)
    {
        XNextEvent(display, &event);

        /* draw or redraw the window */
        if (event.type == Expose)
        {
            XFillRectangle(display, window,
                DefaultGC(display, s), 20, 20, 10, 10);
            XDrawString(display, window,
                DefaultGC(display, s), 50, 50, msg, strlen(msg));
        }
        /* exit on key press */
        if (event.type == KeyPress)
        break;
    }

然后,程序进入一个无限循环,同时轮询任何事件,并使用适当的 Xlib API 在窗口上绘制字符串。在窗口术语中,这被称为消息循环。事件的检索将通过XNextEvent API 调用来完成:

    /* close connection to server */
    XCloseDisplay(display);

    return 0;
    }

一旦我们退出无限消息循环,与服务器的连接将被关闭。

微软 Windows 上的事件驱动编程

微软公司创建了一个 GUI 编程模型,可以被认为是世界上最成功的窗口系统。Windows 软件的第三版(1990 年)取得了巨大成功,随后微软推出了 Windows NT 和 Windows 95/98/ME 系列。让我们来看看微软 Windows 的事件驱动编程模型(请参阅微软文档,详细了解这个编程模型的工作原理)。以下程序将帮助我们了解使用 C/C++编写 Windows 编程所涉及的要点:

#include <windows.h>
//----- Prtotype for the Event Handler Function
LRESULT CALLBACK WndProc(HWND hWnd, UINT message,
                         WPARAM wParam, LPARAM lParam);
//--------------- Entry point for a Idiomatic Windows API function
int WINAPI WinMain(HINSTANCE hInstance,
              HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{

MSG msg = {0};
WNDCLASS wc = {0};
wc.lpfnWndProc = WndProc;
wc.hInstance = hInstance;
wc.hbrBackground = (HBRUSH)(COLOR_BACKGROUND);
wc.lpszClassName = "minwindowsapp";
if( !RegisterClass(&wc) )
  return 1;

前面的代码片段初始化了一个名为WNDCLASS(或现代系统上的WNDCLASSEX)的结构,并提供了一个窗口的必要模板。结构中最重要的字段是lpfnWndProc,它是响应此窗口实例中事件的函数的地址:

if( !CreateWindow(wc.lpszClassName,
                  "Minimal Windows Application",
                  WS_OVERLAPPEDWINDOW|WS_VISIBLE,
                  0,0,640,480,0,0,hInstance,NULL))
    return 2;

我们将调用CreateWindow(或现代系统上的CreateWindowEx)API 调用,根据WNDCLASS.lpszClassname参数中提供的类名创建一个窗口:

    while( GetMessage( &msg, NULL, 0, 0 ) > 0 )
        DispatchMessage( &msg );
    return 0;
}

前面的代码片段进入了一个无限循环,消息将从消息队列中检索,直到我们收到一个WM_QUIT消息。WM_QUIT消息将使我们退出无限循环。有时在调用DispatchMessage API 之前会对消息进行翻译。DispatchMessage调用窗口回调过程(lpfnWndProc):

LRESULT CALLBACK WndProc(HWND hWnd, UINT message,
                         WPARAM wParam, LPARAM lParam) {
switch(message){
  case WM_CLOSE:
    PostQuitMessage(0);break;
  default:
    return DefWindowProc(hWnd, message, wParam, lParam);
}
return 0;
}

前面的代码片段是一个最简化的callback函数。您可以查阅微软文档,了解 Windows API 编程以及这些程序中如何处理事件

Qt 下的事件驱动编程

Qt 框架是一个工业级、跨平台和多平台的 GUI 工具包,可在 Windows、GNU Linux、macOS X 和其他 Mac 系统上运行。该工具包已经编译到嵌入式系统和移动设备中。C++编程模型利用了称为元对象编译器MOC)的东西,它将浏览指令的源代码(源代码中嵌入的一堆宏和语言扩展)并生成适当的附加源代码以生成事件处理程序。因此,在 C++编译器获取源代码之前,必须运行 MOC pass 以通过删除那些特定于 Qt 系统的额外语言构造生成合法的 ANSI C++。请参阅 Qt 文档以了解更多信息。以下简单的 Qt 程序将演示 Qt 编程及其事件处理系统的关键方面:

#include <qapplication.h>
#include <qdialog.h>
#include <qmessagebox.h>
#include <qobject.h>
#include <qpushbutton.h>

class MyApp : public QDialog {
  Q_OBJECT
public:
    MyApp(QObject* /*parent*/ = 0):
    button(this)
    {
      button.setText("Hello world!"); button.resize(100, 30);

      // When the button is clicked, run button_clicked
      connect(&button,
              &QPushButton::clicked, this, &MyApp::button_clicked);
    }

Q_OBJECT是指示 MOC 生成事件分发表的指令。当我们将事件源连接到事件接收器时,将向事件分发表中添加一个条目。生成的代码将与 C++代码一起编译以生成可执行文件:

public slots:
    void button_clicked() {
      QMessageBox box;
      box.setWindowTitle("Howdy");
      box.setText("You clicked the button");
      box.show();
      box.exec();
    }

protected:
  QPushButton button;
};

语言扩展public slots将被 MOC 剥离(在完成源代码生成的工作后)以与 ANSI C/C++编译器兼容的形式:

int main(int argc, char** argv) {
  QApplication app(argc, argv);
  MyApp myapp;
  myapp.show();
  return app.exec();
}

前面的代码片段初始化了 Qt 应用程序对象并显示了主窗口。在实际应用中,Qt 是 C++语言最重要的应用程序开发框架,它还与 Python 编程语言有很好的绑定。

MFC 下的事件驱动编程

Microsoft Foundation 类库仍然是编写基于 Microsoft Windows 的桌面程序的流行库。如果我们将ActiveX 模板库ATL)与之混合使用,它确实对 Web 编程提供了一些支持。作为一个 C++库,MFC 使用一种称为消息映射的机制来处理事件。每个 MFC 程序都有一些给定的宏作为样本事件处理表:

BEGIN_MESSAGE_MAP(CClockFrame,CFrameWnd)
    ON_WM_CREATE()
    ON_WM_PAINT()
    ON_WM_TIMER()
END_MESSAGE_MAP()

前面的消息映射将响应OnCreateOnPaintOntimer标准 Windows API 消息。深入了解这些消息映射,它们实际上就是数组,我们将使用消息 ID作为索引来分派事件。仔细检查后,它与标准的 Windows API 消息模型并没有太大的不同。

这里没有给出代码清单,因为我们全局上使用了 MFC 来实现响应式编程模型的一个关键接口的 GUI。该实现基于 MFC 库,读者可以通过注释清单来理解 MFC 中的非平凡事件处理。

其他基于事件驱动的编程模型

诸如 COM+和 CORBA 之类的分布式对象处理框架确实有自己的事件处理框架。COM+事件模型基于连接点的概念(由IConnectionPointContainer/IConnectionPoint接口建模),而 CORBA 确实有自己的事件服务模型。CORBA 标准提供了基于拉取和推送的事件通知。COM+和 CORBA 超出了本书的范围,读者应该查阅各自的文档。

经典事件处理模型的限制

进行事件处理的整个目的是为了正确地看待事物。这些平台中的事件响应逻辑大多与编写代码的平台耦合在一起。随着多核编程的出现,编写低级多线程代码变得困难,而使用 C++编程语言可以使用声明式任务模型。但是,事件源大多在 C++标准之外!C++语言没有标准的 GUI 编程库,也没有访问外部设备的接口标准等。有什么办法?幸运的是,外部数据和事件可以聚合成流(或序列),并且通过使用 Lambda 函数等函数式编程构造可以被高效地处理。额外的好处是,如果我们对变量和流的可变性以及并发性方面进行一些限制,那么并发性和并行性就内置到流处理模型中了。

响应式编程模型

简而言之,响应式编程就是使用异步数据流进行编程。通过对流应用各种操作,我们可以实现不同的计算目标。响应式程序的主要任务是将数据转换为流,而不管数据的来源是什么。在编写现代图形用户界面应用程序时,我们处理鼠标移动和点击事件。目前,大多数系统都会得到回调,并在事件发生时处理这些事件。大部分时间,处理程序在调用与事件调用相关的动作方法之前会进行一系列的过滤操作。在这种特定的上下文中,响应式编程帮助我们将鼠标移动和点击事件聚合到一个集合中,并在通知处理程序逻辑之前对它们进行过滤。这样,应用程序/处理程序逻辑就不会被不必要地执行。

流处理模型是众所周知的,并且非常容易由应用程序开发人员编码。几乎任何东西都可以转换成流。这些候选对象包括消息、日志、属性、Twitter 动态、博客文章、RSS 动态等。函数式编程技术非常擅长处理流。像现代 C++这样对面向对象/函数式编程提供了出色支持的语言,是编写响应式程序的自然选择。响应式编程的基本思想是,有一些数据类型代表随时间变化的值。在这种编程范式中,这些数据类型(或者说数据序列)被表示为可观察序列。涉及这些变化(依赖时间)的值的计算本身也会随时间变化,并且需要异步地接收通知(在依赖数据发生变化时)。

函数式响应式编程

几乎所有现代编程语言都支持函数式编程构造。函数式编程构造,如转换、应用、过滤、折叠等,非常适合处理流。使用函数式编程构造来编程异步数据流通常被称为函数式响应式编程(在实际目的上)。这里给出的定义是一个操作性的定义。请参考 Conal Elliott 和 Paul Hudak 在 Haskell 社区所做的工作,以了解严格的定义。将响应式编程与函数式编程混合在一起在开发人员中越来越受欢迎。Rx.Net、RxJava、RxJs、RxCpp 等库的出现证明了这一点。

尽管响应式编程是本书的核心主题,但在本章中,我们将坚持面向对象的方法。这是因为我们需要引入一些标准接口(在 C++中使用虚函数模拟)来进行响应式编程。之后,在学习 C++支持的 FP 构造之后,读者可以将 OOP 构造进行一些心智模型映射到 FP 构造。在本章中,我们还将远离并发内容,专注于软件接口。第二章,现代 C++及其关键习语之旅,第三章,C++中的语言级并发和并行性,以及第四章,C++中的异步和无锁编程,将为理解使用 FP 构造进行响应式编程提供必要的背景。

响应式程序的关键接口

为了帮助您理解响应式程序内部实际发生的事情,我们将编写一些玩具程序,以便将事情放在适当的背景下。从软件设计的角度来看,如果将并发/并行性放在一边,专注于软件接口,响应式程序应该具有:

  • 实现IObservable<T>的事件源

  • 实现IObserver<T>的事件接收器

  • 一个向事件源添加订阅者的机制

  • 当数据出现在源头时,订阅者将收到通知

在本章中,我们使用了经典的 C++构造编写了代码。这是因为我们还没有介绍现代 C++构造。我们还使用了原始指针,这在编写现代 C++代码时可以大多避免。本章中的代码是一般遵循 ReactiveX 文档编写的。在 C++中,我们不像在 Java 或 C#中那样使用基于继承的技术。

为了开始,让我们定义 Observer、Observable 和CustomException类:

#pragma once 
//Common2.h 

struct CustomException /*:*public std::exception */ {
   const char * what() const throw () { 
         return "C++ Exception"; 
   } 
}; 

CustomException类只是一个占位符,以使接口完整。由于我们决定在本章中只使用经典的 C++,我们不会偏离std::exception类:

template<class T> class IEnumerator {
public:
      virtual bool HasMore() = 0;
      virtual T next() = 0;
      //--------- Omitted Virtual destructor for brevity
};
template <class T> class IEnumerable{
public:
      virtual IEnumerator<T> *GetEnumerator() = 0;
      //---------- Omitted Virtual destructor for brevity
};

Enumerable接口由数据源使用,我们可以枚举数据,并且客户端将使用IEnuerator<T>进行迭代。

定义迭代器接口(IEnuerable<T>/IEnumerator<T>)的目的是让读者理解它们与Observer<T>/Observable<T>模式非常密切相关。我们将定义Observer<T>/Observable<T>如下:

template<class T> class IObserver
{
public:
      virtual void OnCompleted() = 0;
      virtual void OnError(CustomException *exception) = 0;
      virtual void OnNext(T value) = 0;
};
template<typename T>
class IObservable
{
public:
      virtual bool Subscribe(IObserver<T>& observer) = 0;
};

IObserver<T>是数据接收器将用于从数据源接收通知的接口。数据源将实现IObservable<T>接口。

我们已经定义了IObserver<T>接口,并且它有三种方法。它们是OnNext(当项目通知给观察者时),OnCompleted(当没有更多数据时),和OnError(当遇到异常时)。Observable<T>由事件源实现,事件接收器可以插入实现IObserver<T>以接收通知的对象。

拉取与推送式响应式编程

响应式程序可以被分类为基于推送基于拉取。基于拉取的系统等待需求,将数据流推送给请求者(或我们的订阅者)。这是经典情况,其中数据源被主动轮询以获取更多信息。这使用了迭代器模式,而IEnumerable <T>/IEnumerator <T>接口专门设计用于这种同步性质的场景(应用程序在拉取数据时可能会阻塞)。另一方面,基于推送的系统聚合事件并通过信号网络推送以实现计算。在这种情况下,与基于拉取的系统不同,数据和相关更新是从源头(在这种情况下是 Observable 序列)传递给订阅者。这种异步性质是通过不阻塞订阅者,而是使其对变化做出反应来实现的。正如您所看到的,采用这种推送模式在丰富的 UI 环境中更有益,因为您不希望在等待某些事件时阻塞主 UI 线程。这变得理想,从而使响应式程序具有响应性。

IEnumerable/IObservable 对偶性

如果您仔细观察,这两种模式之间只有微妙的差异。IEnumerable<T>可以被认为是基于拉取的等价于基于推送的IObservable<T>。实际上,它们是对偶的。当两个实体交换信息时,一个实体的拉取对应于另一个实体推送信息。这种对偶性在下图中有所说明:

让我们通过查看这个示例代码,一个数字序列生成器,来理解这种对偶性:

我们努力使用经典的 C++构造来编写本章的程序,因为还有关于现代 C++语言特性、语言级并发、无锁编程以及实现现代 C++中的响应式构造的相关主题。

#include <iostream>
#include <vector>
#include <iterator>
#include <memory>
#include "../Common2.h"
using namespace std;

class ConcreteEnumberable : public IEnumerable<int>
{
      int *numberlist,_count;
public:
      ConcreteEnumberable(int numbers[], int count):
            numberlist(numbers),_count(count){}
      ~ConcreteEnumberable() {}

      class Enumerator : public IEnumerator<int>
      {
      int *inumbers, icount, index;
      public:
      Enumerator(int *numbers,
            int count):inumbers(numbers),icount(count),index(0) {}
      bool HasMore() { return index < icount; }
      //---------- ideally speaking, the next function should throw
      //---------- an exception...instead it just returns -1 when the 
      //---------- bound has reached
      int next() { return (index < icount) ?
                   inumbers[index++] : -1; }
      ~Enumerator() {}
      };
      IEnumerator<int> *GetEnumerator()
            { return new Enumerator(numberlist, _count); }
};

前面的类以整数数组作为参数,并且我们可以枚举元素,因为我们已经实现了IEnumerable<T>接口。Enumeration逻辑由嵌套类实现,该嵌套类实现了IEnumerator<T>接口:

int main()
{
      int x[] = { 1,2,3,4,5 };
      //-------- Has used Raw pointers on purpose here as we have
      //------- not introduced unique_ptr,shared_ptr,weak_ptr yet
      //-------- using auto_ptr will be confusting...otherwise
      //-------- need to use boost library here... ( an overkill)
      ConcreteEnumberable *t = new ConcreteEnumberable(x, 5);
      IEnumerator<int> * numbers = t->GetEnumerator();
      while (numbers->HasMore())
            cout << numbers->next() << endl;
      delete numbers;delete t;
      return 0;
}

主程序实例化了ConcreteEnuerable类的一个实现,并遍历每个元素。

我们将编写一个偶数序列生成器,以演示这些数据类型如何在将基于拉取的程序转换为推送程序时一起工作。鲁棒性方面给予了较低的优先级,以保持清单的简洁性:

#include "stdafx.h"
#include <iostream>
#include <vector>
#include <iterator>
#include <memory>
#include "../Common2.h"
using namespace std;

class EvenNumberObservable : IObservable<int>{
      int *_numbers,_count;
public:
      EvenNumberObservable(int numbers[],
            int count):_numbers(numbers),_count(count){}
      bool Subscribe(IObserver<int>& observer){
            for (int i = 0; i < _count; ++i)
                  if (_numbers[i] % 2 == 0)
                        observer.OnNext(_numbers[i]);
            observer.OnCompleted();
            return true;
      }
};

前面的程序接受一个整数数组,过滤掉奇数,并在遇到偶数时通知Observer<T>。在这种情况下,数据源将数据推送给observerObserver<T>的实现如下所示:

class SimpleObserver : public IObserver<int>{
public:
      void OnNext(int value) { cout << value << endl; }
      void OnCompleted() { cout << _T("hello completed") << endl; }
      void OnError( CustomException * ex) {}
};

SimpleObserver类实现了IObserver<T>接口,并具有接收通知并对其做出反应的能力:

int main()
{
      int x[] = { 1,2,3,4,5 };
      EvenNumberObservable *t = new EvenNumberObservable(x, 5);
      IObserver<int>> *xy = new SimpleObserver();
      t->Subscribe(*xy);
      delete xy; delete t;
      return 0;
}

从前面的例子中,您可以看到如何自然地订阅自然数的 Observable 序列中的偶数。当检测到偶数时,系统将自动向观察者(订阅者)“推送”(发布)值。代码为关键接口提供了明确的实现,以便人们可以理解或推测在幕后到底发生了什么。

将事件转换为 IObservable

我们现在已经理解了如何将基于IEnumerable<T>的拉取程序转换为基于IObservable<T>/IObserver<T>的推送程序。在现实生活中,事件源并不像我们之前给出的数字流示例中那么简单。让我们看看如何将MouseMove事件转换为一个小型 MFC 程序中的流:

我们选择了 MFC 来实现这个特定的实现,因为我们有一章专门讲解基于 Qt 的响应式编程。在那一章中,我们将以成语异步推送流的方式实现响应式程序。在这个 MFC 程序中,我们只是进行了一个过滤操作,以查看鼠标是否在一个边界矩形内移动,如果是,则通知observer。我们在这里使用同步分发。这个示例也是同步的:

#include "stdafx.h"
#include <afxwin.h>
#include <afxext.h>
#include <math.h>
#include <vector>
#include "../Common2.h"

using namespace std;
class CMouseFrame :public CFrameWnd,IObservable<CPoint>
{
private:
      RECT _rect;
      POINT _curr_pos;
      vector<IObserver<CPoint> *> _event_src;
public:
      CMouseFrame(){
            HBRUSH brush =
                  (HBRUSH)::CreateSolidBrush(RGB(175, 238, 238));
            CString mywindow = AfxRegisterWndClass(
                  CS_HREDRAW | CS_VREDRAW | CS_DBLCLKS,
                  0, brush, 0);
            Create(mywindow, _T("MFC Clock By Praseed Pai"));
      }

代码的前面部分定义了一个Frame类,它从MFC库的CFrameWnd类派生,并实现了IObservable<T>接口,以强制程序员实现Subscribe方法。一个IObserver<T>的向量将存储observersSubscribers的列表。在本例中,我们只有一个observer。代码中没有对observer的数量进行限制:

      virtual bool Subscribe(IObserver<CPoint>& observer) {
            _event_src.push_back(&observer);
            return true;
      }

Subscribe方法只是将observer的引用存储到一个向量中并返回true:当鼠标移动时,我们从MFC库中获得通知,如果它在一个矩形区域内,observer将会被通知(通知代码如下):

      bool FireEvent(const CPoint& pt) {
            vector<IObserver<CPoint> *>::iterator it =
                  _event_src.begin();
            while (it != _event_src.end()){
                  IObserver<CPoint> *observer = *it;
                  observer->OnNext(pt);
                  //---------- In a Real world Rx programs there is a 
                  //--------- sequence stipulated to call methods...
                  //--------- OnCompleted will be called only when 
                  //--------- all the data is processed...this code
                  //--------- is written to demonstrate the call schema
                  observer->OnCompleted();
                  it++;
            }
            return true;
      }

FireEvent方法遍历observer并调用observerOnNext方法。它还调用每个 Observer 的OnCompleted方法:Rx 调度机制在调用observer方法时遇到一些规则。如果调用了OnComplete方法,同一个observer将不再调用OnNext。同样,如果调用了OnError,将不会再向observer分发消息。如果我们需要遵循 Rx 模型规定的约定,代码将变得复杂。这里给出的代码目的是以一种概要的方式展示 Rx 编程模型的工作原理。

      int OnCreate(LPCREATESTRUCT l){
            return CFrameWnd::OnCreate(l);
      }
      void SetCurrentPoint(CPoint pt) {
            this->_curr_pos = pt;
            Invalidate(0);
      }

SetCurrentPoint方法由observer调用以设置文本绘制的当前点。调用Invalidate方法触发WM_PAINT消息,MFC子系统将其路由到OnPaint(因为它在Message映射中被连接):

      void OnPaint()
      {
            CPaintDC d(this);
            CBrush b(RGB(100, 149, 237));
            int x1 = -200, y1 = -220, x2 = 210, y2 = 200;
            Transform(&x1, &y1); Transform(&x2, &y2);
            CRect rect(x1, y1, x2, y2);
            d.FillRect(&rect, &b);
            CPen p2(PS_SOLID, 2, RGB(153, 0, 0));
            d.SelectObject(&p2);

            char *str = "Hello Reactive C++";
            CFont f;
            f.CreatePointFont(240, _T("Times New Roman"));
            d.SelectObject(&f);
            d.SetTextColor(RGB(204, 0, 0));
            d.SetBkMode(TRANSPARENT);
            CRgn crgn;
            crgn.CreateRectRgn(rect.left,rect.top,
            rect.right ,rect.bottom);
            d.SelectClipRgn(&crgn);
            d.TextOut(_curr_pos.x, _curr_pos.y,
            CString(str), strlen(str));
      }

当调用Invalidate时,OnPaint方法由MFC框架调用。该方法在屏幕上绘制literal字符串Hello Reactive C++

      void Transform(int *px, int *py) {
            ::GetClientRect(m_hWnd, &_rect);
            int width = (_rect.right - _rect.left) / 2,
            height = (_rect.bottom - _rect.top) / 2;
           *px = *px + width; *py = height - *py;
      }

Transform方法计算Frame的客户区域的边界,并将Cartesian坐标转换为设备坐标。这种计算可以通过世界坐标变换更好地完成:

      void OnMouseMove(UINT nFlags, CPoint point)
      {
            int x1 = -200,y1= -220, x2 = 210,y2 = 200;
            Transform(&x1, &y1);Transform(&x2, &y2);
            CRect rect(x1, y1, x2, y2);
            POINT pts;
            pts.x = point.x; pts.y = point.y;
            rect.NormalizeRect();
            //--- In a real program, the points will be aggregated
            //---- into a list (stream)
            if (rect.PtInRect(point)) {
                  //--- Ideally speaking this notification has to go
                  //--- through a non blocking call
                  FireEvent(point);
            }
      }

OnMouseMove方法检查鼠标位置是否在屏幕内的一个矩形区域内,并向observer发出通知:

      DECLARE_MESSAGE_MAP();
};

BEGIN_MESSAGE_MAP(CMouseFrame, CFrameWnd)
      ON_WM_CREATE()
      ON_WM_PAINT()
      ON_WM_MOUSEMOVE()
END_MESSAGE_MAP()
class WindowHandler : public IObserver<CPoint>
{
private:
      CMouseFrame *window;
public:
      WindowHandler(CMouseFrame *win) : window(win) { }
      virtual ~WindowHandler() { window = 0; }
      virtual void OnCompleted() {}
      virtual void OnError(CustomException *exception) {}
      virtual void OnNext(CPoint value) {
            if (window) window->SetCurrentPoint(value);
      }
};

前面的WindowHandler类实现了IObserver<T>接口,并处理了由CMouseFrame通知的事件,后者实现了IObservable<CPoint>接口。在这个示例中,我们通过调用SetCurrentPoint方法来设置当前点,以便在鼠标位置绘制字符串:

class CMouseApp :public CWinApp
{
      WindowHandler *reactive_handler;
public:
      int InitInstance(){
            CMouseFrame *p = new CMouseFrame();
            p->ShowWindow(1);
            reactive_handler = new WindowHandler(p);
            //--- Wire the observer to the Event Source
            //--- which implements IObservable<T>
            p->Subscribe(*reactive_handler);
            m_pMainWnd = p;
            return 1;
      }
      virtual ~CMouseApp() {
            if (reactive_handler) {
                  delete reactive_handler;
                  reactive_handler = 0;
           }
      }
};

CMouseApp a;

我们的书的哲学

本章的目的是向读者介绍响应式编程模式的关键接口,它们是IObservable<T>IObserver<T>。实际上,它们是IEnumerable<T>IEnumerator<T>接口的对偶。我们学习了如何在经典 C++中对这些接口进行建模(大部分),并对它们进行了玩具实现。最后,我们实现了一个捕获鼠标移动并通知一系列观察者的 GUI 程序。这些玩具实现是为了让我们初步了解响应式编程模式的思想和理想。我们的实现可以被视为基于面向对象的响应式编程的实现。

要精通 C++响应式编程,程序员必须熟悉以下主题:

  • 现代 C++提供的高级语言构造

  • 现代 C++提供的函数式编程构造

  • 异步编程(RxCpp 为您处理!)模型

  • 事件流处理

  • 对 RxCpp 等工业级库的了解

  • RxCpp 在 GUI 和 Web 编程中的应用

  • 高级响应式编程构造

  • 处理错误和异常

本章主要讨论了关键的习语以及为什么我们需要一个强大的模型来处理异步数据。接下来的三章将涵盖现代 C++的语言特性,使用 C++标准构造处理并发/并行性,以及无锁编程(由内存模型保证实现)。前面的主题将为用户提供坚实的基础,以便掌握函数式响应式编程。

在[第五章]《可观察对象简介》中,我们将再次回到可观察对象的主题,并以函数式的方式实现接口,重申一些概念。在[第六章]《使用 C++进行事件流编程简介》中,我们将借助两个工业级库,使用领域特定嵌入式语言(DSEL)方法处理高级事件流处理主题。

到目前为止,用户将有机会接触工业级 RxCpp 库及其细微之处,以编写专业质量的现代 C++程序。在第七章《数据流计算和 RxCpp 库简介》和第八章《RxCpp - 关键要素》中,我们将介绍这个出色的库。接下来的章节将涵盖使用 Qt 库进行响应式 GUI 编程以及 RxCpp 中的高级操作符。

最后三章涵盖了响应式设计模式、C++中的微服务以及错误/异常处理的高级主题。在本书结束时,从经典 C++开始的读者将不仅在编写响应式程序方面取得了很大进展,而且在 C++语言本身方面也有了很大进步。由于主题的性质,我们将涵盖 C++ 17 的大部分特性(在撰写时)。

总结

在本章中,我们了解了 Rx 编程模型的一些关键数据结构。我们实现了它们的玩具版本,以熟悉支撑它们的概念细微差别。我们从 Windows API、XLib API、MFC 和 Qt 处理 GUI 事件开始。我们还简要介绍了在 COM+/CORBA 中如何处理事件。然后,我们快速概述了响应式编程。在介绍了一些接口后,我们从头开始实现了它们。最后,为了完整起见,我们在 MFC 上实现了这些接口的 GUI 版本。我们还处理了本书的一些关键哲学方面。

在下一章中,我们将快速浏览现代 C++(C++版本 11/14/17)的关键特性,重点介绍移动语义、Lambda、类型推断、基于范围的循环、可管道的操作符、智能指针等。这对于编写响应式编程的基本代码至关重要。

第二章:现代 C++及其关键习语之旅

经典的 C++编程语言在 1998 年被标准化,随后在 2003 年进行了一次小的修订(主要是更正)。为了支持高级抽象,开发人员依赖于 Boost (www.boost.org)库和其他公共领域库。由于下一波标准化的到来,语言(从 C++ 11 开始)得到了增强,现在开发人员可以在不依赖外部库的情况下编码大多数其他语言支持的抽象。甚至线程和文件系统接口,原本属于库的范畴,现在已成为标准语言的一部分。现代 C++(代表 C++版本 11/14/17)包含了对语言和其库的出色增强,使得 C++成为编写工业级生产软件的事实选择。本章涵盖的功能是程序员必须了解的最小功能集,以便使用响应式编程构造,特别是 RxCpp。本章的主要目标是介绍语言的最重要的增强功能,使得实现响应式编程构造更加容易,而不需要使用神秘的语言技术。本章将涵盖以下主题:

  • C++编程语言设计的关键问题

  • 一些用于编写更好代码的 C++增强功能

  • 通过右值引用和移动语义实现更好的内存管理

  • 使用增强的智能指针实现更好的对象生命周期管理

  • 使用 Lambda 函数和表达式进行行为参数化

  • 函数包装器(std::function类型)

  • 其他功能

  • 编写迭代器和观察者(将所有内容整合在一起)

C++编程语言的关键问题

就开发人员而言,C++编程语言设计者关注的三个关键问题是(现在仍然是):

  • 零成本抽象 - 高级抽象不会带来性能惩罚

  • 表现力 - 用户定义类型(UDT)或类应该与内置类型一样具有表现力

  • 可替代性 - UDT 可以在期望内置类型的任何地方替代(如通用数据结构和算法)

我们将简要讨论这些内容。

零成本抽象

C++编程语言一直帮助开发人员编写利用微处理器的代码(生成的代码运行在微处理器上),并在需要时提高抽象级别。在提高抽象级别的同时,语言的设计者们一直试图最小化(几乎消除)性能开销。这被称为零成本抽象或零开销成本抽象。你所付出的唯一显著代价是间接调用的成本(通过函数指针)来分派虚拟函数。尽管向语言添加了大量功能,设计者们仍然保持了语言从一开始就暗示的“零成本抽象”保证。

表现力

C++帮助开发人员编写用户定义类型或类,可以像编程语言的内置类型一样具有表现力。这使得可以编写任意精度算术类(在某些语言中被称为BigInteger/BigFloat),其中包含了双精度或浮点数的所有特性。为了说明,我们定义了一个SmartFloat类,它包装了 IEEE 双精度浮点数,并重载了大多数双精度数据类型可用的运算符。以下代码片段显示,可以编写模仿内置类型(如 int、float 或 double)语义的类型:

//---- SmartFloat.cpp
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
class SmartFloat {
     double _value; // underlying store
   public:
      SmartFloat(double value) : _value(value) {}
      SmartFloat() : _value(0) {}
      SmartFloat( const SmartFloat& other ) { _value = other._value; }
      SmartFloat& operator = ( const SmartFloat& other ) {
          if ( this != &other ) { _value = other._value;}
          return *this;
      }
      SmartFloat& operator = (double value )
       { _value = value; return *this;}
      ~SmartFloat(){ }

SmartFloat类包装了一个 double 值,并定义了一些构造函数和赋值运算符来正确初始化实例。在下面的代码片段中,我们将定义一些操作符来增加值。前缀和后缀变体的操作符都已定义:

      SmartFloat& operator ++ () { _value++; return *this; }
      SmartFloat operator ++ (int) { // postfix operator
             SmartFloat nu(*this); ++_value; return nu;
      }
      SmartFloat& operator -- () { _value--; return *this; }
      SmartFloat operator -- (int) {
           SmartFloat nu(*this); --_value; return nu;
      }

前面的代码片段实现了增量运算符(前缀和后缀),仅用于演示目的。在真实的类中,我们将检查浮点溢出和下溢,以使代码更加健壮。包装类型的整个目的是编写健壮的代码!

     SmartFloat& operator += ( double x ) { _value += x; return *this;}
     SmartFloat& operator -= ( double x ) { _value -= x;return *this; }
     SmartFloat& operator *= ( double x ) { _value *= x; return *this;}
     SmartFloat& operator /= ( double x ) { _value /= x; return *this;}

前面的代码片段实现了 C++风格的赋值运算符,再次为了简洁起见,我们没有检查是否存在任何浮点溢出或下溢。我们也没有处理异常,以保持清单的简洁。

      bool operator > ( const SmartFloat& other )
        { return _value > other._value; }
      bool operator < ( const SmartFloat& other )
       {return _value < other._value;}
      bool operator == ( const SmartFloat& other )
        { return _value == other._value;}
      bool operator != ( const SmartFloat& other )
        { return _value != other._value;}
      bool operator >= ( const SmartFloat& other )
        { return _value >= other._value;}
      bool operator <= ( const SmartFloat& other )
        { return _value <= other._value;}

前面的代码实现了关系运算符,并且大部分与双精度浮点数相关的语义都已经实现如下:

      operator int () { return _value; }
      operator double () { return _value;}
};

为了完整起见,我们已经实现了到intdouble的转换运算符。我们将编写两个函数来聚合存储在数组中的值。第一个函数期望一个double数组作为参数,第二个函数期望一个SmartFloat数组作为参数。两个例程中的代码是相同的,只是类型不同。两者将产生相同的结果:

double Accumulate( double a[] , int count ){
    double value = 0;
    for( int i=0; i<count; ++i) { value += a[i]; }
    return value;
}
double Accumulate( SmartFloat a[] , int count ){
    SmartFloat value = 0;
    for( int i=0; i<count; ++i) { value += a[i]; }
    return value;
}
int main() {
    // using C++ 1z's initializer list
    double x[] = { 10.0,20.0,30,40 };
    SmartFloat y[] = { 10,20.0,30,40 };
    double res = Accumulate(x,4); // will call the double version
    cout << res << endl;
    res = Accumulate(y,4); // will call the SmartFloat version
    cout << res << endl;
}

C++语言帮助我们编写富有表现力的类型,增强基本类型的语义。语言的表现力还帮助我们使用语言支持的多种技术编写良好的值类型和引用类型。通过支持运算符重载、转换运算符、放置 new 和其他相关技术,与其同时代的其他语言相比,该语言已将类设计提升到了一个更高的水平。但是,能力与责任并存,有时语言会给你足够的自由让你自食其果。

可替代性

在前面的例子中,我们看到了如何使用用户定义的类型来表达对内置类型进行的所有操作。C++的另一个目标是以一种通用的方式编写代码,其中我们可以替换一个模拟内置类型(如floatdoubleint等)语义的用户定义类:

//------------- from SmartValue.cpp
template <class T>
T Accumulate( T a[] , int count ) {
    T value = 0;
    for( int i=0; i<count; ++i) { value += a[i]; }
    return value;
}
int main(){
    //----- Templated version of SmartFloat
    SmartValue<double> y[] = { 10,20.0,30,40 };
    double res = Accumulate(y,4);
    cout << res << endl;
}

C++编程语言支持不同的编程范式,前面概述的三个原则只是其中的一些。该语言支持可以帮助创建健壮类型(特定领域)以编写更好代码的构造。这三个原则确实为我们带来了一个强大而快速的编程语言。现代 C++确实添加了许多新的抽象,以使程序员的生活更加轻松。但是,为了实现这些目标,之前概述的三个设计原则并没有以任何方式被牺牲。这在一定程度上是可能的,因为语言由于模板机制的无意中图灵完备性而具有元编程支持。使用您喜欢的搜索引擎阅读有关模板元编程TMP)和图灵完备性的内容。

C++增强以编写更好的代码

在过去的十年里,编程语言的世界发生了很大变化,这些变化应该反映在 C++编程语言的新版本中。现代 C++中的大部分创新涉及处理高级抽象,并引入函数式编程构造以支持语言级并发。大多数现代语言都有垃圾收集器,运行时管理这些复杂性。C++编程语言没有自动垃圾收集作为语言标准的一部分。C++编程语言以其隐式的零成本抽象保证(你不用为你不使用的东西付费)和最大的运行时性能,必须依靠大量的编译时技巧和元编程技术来实现 C#、Java 或 Scala 等语言支持的抽象级别。其中一些在以下部分中概述,你可以自行深入研究这些主题。网站en.cppreference.com是提高你对 C++编程语言知识的一个好网站。

类型推断和推理

现代 C++语言编译器在程序员指定的表达式和语句中推断类型方面做得非常出色。大多数现代编程语言都支持类型推断,现代 C++也是如此。这是从 Haskell 和 ML 等函数式编程语言借鉴来的习惯用法。类型推断已经在 C#和 Scala 编程语言中可用。我们将编写一个小程序来启动我们的类型推断:

//----- AutoFirst.cpp
#include <iostream>
#include <vector>
using namespace std;
int main(){
    vector<string> vt = {"first", "second", "third", "fourth"};
    //--- Explicitly specify the Type ( makes it verbose)
    for (vector<string>::iterator it = vt.begin();
        it != vt.end(); ++it)
    cout << *it << " ";
    //--- Let the compiler infer the type for us
    for (auto it2 = vt.begin(); it2 != vt.end(); ++it2)
        cout << *it2 << " ";
    return 0;
}

auto关键字指定变量的类型将根据初始化和表达式中指定的函数的返回值由编译器推导出来。在这个特定的例子中,我们并没有获得太多。随着我们的声明变得更加复杂,最好让编译器进行类型推断。我们的代码清单将使用 auto 来简化整本书的代码。现在,让我们编写一个简单的程序来更清楚地阐明这个想法:

//----- AutoSecond.cpp
#include <iostream>
#include <vector>
#include <initializer_list>
using namespace std;
int main() {
    vector<double> vtdbl = {0, 3.14, 2.718, 10.00};
    auto vt_dbl2 = vtdbl; // type will be deduced
    auto size = vt_dbl2.size(); // size_t
    auto &rvec = vtdbl; // specify a auto reference
    cout << size << endl;
    // Iterate - Compiler infers the type
    for ( auto it = vtdbl.begin(); it != vtdbl.end(); ++it)
        cout << *it << " ";
    // 'it2' evaluates to iterator to vector of double
    for (auto it2 = vt_dbl2.begin(); it2 != vt_dbl2.end(); ++it2)
        cout << *it2 << " ";
    // This will change the first element of vtdbl vector
    rvec[0] = 100;
    // Now Iterate to reflect the type
    for ( auto it3 = vtdbl.begin(); it3 != vtdbl.end(); ++it3)
        cout << *it3 << " ";
    return 0;
}

前面的代码演示了在编写现代 C++代码时使用类型推断。C++编程语言还有一个新关键字,用于查询给定参数的表达式的类型。关键字的一般形式是decltype(<expr>)。以下程序有助于演示这个特定关键字的用法:

//---- Decltype.cpp
#include <iostream>
using namespace std;
int foo() { return 10; }
char bar() { return 'g'; }
auto fancy() -> decltype(1.0f) { return 1;} //return type is float
int main() {
    // Data type of x is same as return type of foo()
    // and type of y is same as return type of bar()
    decltype(foo()) x;
    decltype(bar()) y;
    //--- in g++, Should print i => int
    cout << typeid(x).name() << endl;
    //--- in g++, Should print c => char 
    cout << typeid(y).name() << endl;
    struct A { double x; };
    const A* a = new A();
    decltype(a->x) z; // type is double
    decltype((a->x)) t= z; // type is const double&
    //--- in g++, Should print  d => double
    cout << typeid(z).name() << endl;
    cout << typeid(t).name() << endl;
    //--- in g++, Should print  f => float
    cout << typeid(decltype(fancy())).name() << endl;
    return 0;
}

decltype是一个编译时构造,它有助于指定变量的类型(编译器将进行艰苦的工作来找出它),并且还可以帮助我们强制变量的类型(参见前面的fancy()函数)。

变量的统一初始化

经典 C++对变量的初始化有一些特定的 ad-hoc 语法。现代 C++支持统一初始化(我们已经在类型推断部分看到了示例)。语言为开发人员提供了辅助类,以支持他们自定义类型的统一初始化:

//----------------Initialization.cpp
#include <iostream>
#include <vector>
#include <initializer_list>
using namespace std;
template <class T>
struct Vector_Wrapper {
    std::vector<T> vctr;
    Vector_Wrapper(std::initializer_list<T> l) : vctr(l) {}
    void Append(std::initializer_list<T> l)
    { vctr.insert(vctr.end(), l.begin(), l.end());}
};
int main() {
    Vector_Wrapper<int> vcw = {1, 2, 3, 4, 5}; // list-initialization
    vcw.Append({6, 7, 8}); // list-initialization in function call
    for (auto n : vcw.vctr) { std::cout << n << ' '; }
    std::cout << '\n';
}

前面的清单显示了如何使程序员创建的自定义类启用初始化列表。

可变模板

在 C++ 11 及以上版本中,标准语言支持可变模板。可变模板是一个接受可变数量的模板参数的模板类或模板函数。在经典 C++中,模板实例化发生在固定数量的参数中。可变模板在类级别和函数级别都得到支持。在本节中,我们将处理可变函数,因为它们在编写函数式程序、编译时编程(元编程)和可管道函数中被广泛使用:

//Variadic.cpp
#include <iostream>
#include <iterator>
#include <vector>
#include <algorithm>
using namespace std;
//--- add given below is a base case for ending compile time
//--- recursion
int add() { return 0; } // end condition
//---- Declare a Variadic function Template
//---- ... is called parameter pack. The compiler
//--- synthesize a function based on the number of arguments
//------ given by the programmer.
//----- decltype(auto) => Compiler will do Type Inference
template<class T0, class ... Ts>
decltype(auto) add(T0 first, Ts ... rest) {
    return first + add(rest ...);
}
int main() { int n = add(0,2,3,4); cout << n << endl; }

在上面的代码中,编译器根据传递的参数数量合成一个函数。编译器理解add是一个可变参数函数,并通过在编译时递归展开参数来生成代码。编译时递归将在编译器处理完所有参数时停止。基本情况版本是一个提示编译器停止递归的方法。下一个程序展示了可变模板和完美转发如何用于编写接受任意数量参数的函数:

//Variadic2.cpp
#include <iostream>
#include <iterator>
#include <vector>
#include <algorithm>
using namespace std;
//--------- Print values to the console for basic types
//-------- These are base case versions
void EmitConsole(int value) { cout << "Integer: " << value << endl; }
void EmitConsole(double value) { cout << "Double: " << value << endl; }
void EmitConsole(const string& value){cout << "String: "<<value<< endl; }

EmitConsole 的三个变体将参数打印到控制台。我们有打印intdoublestring的函数。利用这些函数作为基本情况,我们将编写一个使用通用引用和完美转发的函数,以编写接受任意值的函数:

template<typename T>
void EmitValues(T&& arg) { EmitConsole(std::forward<T>(arg)); }

template<typename T1, typename... Tn>
void EmitValues(T1&& arg1, Tn&&... args){
    EmitConsole(std::forward<T1>(arg1));
    EmitValues(std::forward<Tn>(args)...);
}

int main() { EmitValues(0,2.0,"Hello World",4); }

右值引用

如果你长时间在 C++中编程,你可能知道 C++引用可以帮助你给变量取别名,并且可以对引用进行赋值以反映变量别名的变化。C++支持的引用类型称为左值引用(因为它们是引用可以出现在赋值的左侧的变量的引用)。以下代码片段展示了左值引用的用法:

//---- Lvalue.cpp
#include <iostream>
using namespace std;
int main() {
  int i=0;
  cout << i << endl; //prints 0
  int& ri = i;
  ri = 20;
  cout << i << endl; // prints 20
}

int& 是左值引用的一个实例。在现代 C++中,有右值引用的概念。右值被定义为任何不是左值的东西,可以出现在赋值的右侧。在经典的 C++中,没有右值引用的概念。现代 C++引入了它:

///---- Rvaluref.cpp
#include <iostream>using namespace std;
int main() {
    int&& j = 42;int x = 3,y=5; int&& z = x + y; cout << z << endl;
    z = 10; cout << z << endl;j=20;cout << j << endl;
}

右值引用由两个&&表示。以下程序将清楚地演示了在调用函数时使用右值引用:

//------- RvaluerefCall.cpp
#include <iostream>
using namespace std;
void TestFunction( int & a ) {cout << a << endl;}
void TestFunction( int && a ){
    cout << "rvalue references" << endl;
    cout << a << endl;
}
int main() {
int&& j = 42;
int x = 3,y=5;
int&& z = x + y;
    TestFunction(x + y ); // Should call rvalue reference function
    TestFunction(j); // Calls Lvalue Refreence function
}

右值引用的真正威力在于内存管理方面。C++编程语言具有复制构造函数和赋值运算符的概念。它们大多数情况下是复制源对象的内容。借助右值引用,可以通过交换指针来避免昂贵的复制,因为右值引用是临时的或中间表达式。下一节将演示这一点。

移动语义

C++编程语言隐式地为我们设计的每个类提供了一个复制构造函数、赋值运算符和一个析构函数(有时是虚拟的)。这是为了在克隆对象或对现有对象进行赋值时进行资源管理。有时复制对象是非常昂贵的,通过指针的所有权转移有助于编写快速的代码。现代 C++提供了移动构造函数和移动赋值运算符的功能,以帮助开发人员避免复制大对象,在创建新对象或对新对象进行赋值时。右值引用可以作为一个提示,告诉编译器在涉及临时对象时,构造函数的移动版本或赋值的移动版本更适合于上下文:

//----- FloatBuffer.cpp
#include <iostream>
#include <vector>
using namespace std;
class FloatBuffer {
    double *bfr; int count;
public:
    FloatBuffer():bfr(nullptr),count(0){}
    FloatBuffer(int pcount):bfr(new double[pcount]),count(pcount){}
        // Copy constructor.
    FloatBuffer(const FloatBuffer& other) : count(other.count)
        , bfr(new double[other.count])
    { std::copy(other.bfr, other.bfr + count, bfr); }
    // Copy assignment operator - source code is obvious
    FloatBuffer& operator=(const FloatBuffer& other) {
        if (this != &other) {
          if ( bfr != nullptr) 
            delete[] bfr; // free memory of the current object
            count = other.count;
            bfr = new double[count]; //re-allocate
            std::copy(other.bfr, other.bfr + count, bfr);
        }
        return *this;
    }
    // Move constructor to enable move semantics
    // The Modern STL containers supports move sementcis
    FloatBuffer(FloatBuffer&& other) : bfr(nullptr) , count(0) {
    cout << "in move constructor" << endl;
    // since it is a move constructor, we are not copying elements from
    // the source object. We just assign the pointers to steal memory
    bfr = other.bfr;
    count = other.count;
    // Now that we have grabbed our memory, we just assign null to
    // source pointer
    other.bfr = nullptr;
    other.count = 0;
    }
// Move assignment operator.
FloatBuffer& operator=(FloatBuffer&& other) {
    if (this != &other)
    {
        // Free the existing resource.
        delete[] bfr;
       // Copy the data pointer and its length from the
       // source object.
       bfr = other.bfr;
       count = other.count;
       // We have stolen the memory, now set the pinter to null
       other.bfr = nullptr;
       other.count = 0;
    }
    return *this;
}

};
int main() {
    // Create a vector object and add a few elements to it.
    // Since STL supports move semantics move methods will be called.
    // in this particular case (Modern Compilers are smart)
    vector<FloatBuffer> v;
    v.push_back(FloatBuffer(25));
    v.push_back(FloatBuffer(75));
}

std::move 函数可用于指示(在传递参数时)候选对象是可移动的,编译器将调用适当的方法(移动赋值或移动构造函数)来优化与内存管理相关的成本。基本上,std::move 是对右值引用的static_cast

智能指针

管理对象生命周期一直是 C++编程语言的一个问题。如果开发人员不小心,程序可能会泄漏内存并降低性能。智能指针是围绕原始指针的包装类,其中重载了解引用(*)和引用(->)等操作符。智能指针可以进行对象生命周期管理,充当有限形式的垃圾回收,释放内存等。现代 C++语言具有:

  • unique_ptr<T>

  • shared_ptr<T>

  • weak_ptr<T>

unique_ptr<T>是一个具有独占所有权的原始指针的包装器。以下代码片段将演示<unique_ptr>的使用:

//---- Unique_Ptr.cpp
#include <iostream>
#include <deque>#include <memory>
using namespace std;
int main( int argc , char **argv ) {
    // Define a Smart Pointer for STL deque container...
    unique_ptr< deque<int> > dq(new deque<int>() );
    //------ populate values , leverages -> operator
    dq->push_front(10); dq->push_front(20);
    dq->push_back(23); dq->push_front(16);
    dq->push_back(41);
    auto dqiter = dq->begin();
    while ( dqiter != dq->end())
    { cout << *dqiter << "\n"; dqiter++; }
    //------ SmartPointer will free reference
    //------ and it's dtor will be called here
    return 0;
}

std::shared_ptr是一个智能指针,它使用引用计数来跟踪对对象实例的引用。当指向它的最后一个shared_ptr被销毁或重置时,底层对象将被销毁:

//----- Shared_Ptr.cpp
#include <iostream>
#include <memory>
#include <stdio.h>
using namespace std;
////////////////////////////////////////
// Even If you pass shared_ptr<T> instance
// by value, the update is visible to callee
// as shared_ptr<T>'s copy constructor reference
// counts to the orgininal instance
//

void foo_byvalue(std::shared_ptr<int> i) { (*i)++;}

///////////////////////////////////////
// passed by reference,we have not
// created a copy.
//
void foo_byreference(std::shared_ptr<int>& i) { (*i)++; }
int main(int argc, char **argv )
{
    auto sp = std::make_shared<int>(10);
    foo_byvalue(sp);
    foo_byreference(sp);
    //--------- The output should be 12
    std::cout << *sp << std::endl;
}

std:weak_ptr是一个原始指针的容器。它是作为shared_ptr的副本创建的。weak_ptr的存在或销毁对shared_ptr或其其他副本没有影响。在所有shared_ptr的副本被销毁后,所有weak_ptr的副本都变为空。以下程序演示了使用weak_ptr来检测失效指针的机制:

//------- Weak_Ptr.cpp
#include <iostream>
#include <deque>
#include <memory>

using namespace std;
int main( int argc , char **argv )
{
    std::shared_ptr<int> ptr_1(new int(500));
    std::weak_ptr<int> wptr_1 = ptr_1;
    {
        std::shared_ptr<int> ptr_2 = wptr_1.lock();
        if(ptr_2)
        {
            cout << *ptr_2 << endl; // this will be exeucted
        }
    //---- ptr_2 will go out of the scope
    }

    ptr_1.reset(); //Memory is deleted.

    std::shared_ptr<int> ptr_3= wptr_1.lock();
    //-------- Always else part will be executed
    //-------- as ptr_3 is nullptr now 
    if(ptr_3)
        cout << *ptr_3 << endl;
    else
        cout << "Defunct Pointer" << endl;
    return 0;
}

经典 C++有一个名为auto_ptr的智能指针类型,已从语言标准中删除。需要使用unique_ptr代替。

Lambda 函数

C++语言的一个主要增强是 Lambda 函数和 Lambda 表达式。它们是程序员可以在调用站点定义的匿名函数,用于执行一些逻辑。这简化了逻辑,代码的可读性也以显着的方式增加。

与其定义 Lambda 函数是什么,不如编写一段代码来帮助我们计算vector<int>中正数的数量。在这种情况下,我们需要过滤掉负值并计算剩下的值。我们将使用 STL count_if来编写代码:

//LambdaFirst.cpp
#include <iostream>
#include <iterator>
#include <vector>
#include <algorithm>
using namespace std;
int main() {
    auto num_vect =
        vector<int>{ 10, 23, -33, 15, -7, 60, 80};
    //---- Define a Lambda Function to Filter out negatives
    auto filter = [](int const value) {return value > 0; };
    auto cnt= count_if(
        begin(num_vect), end(num_vect),filter);
    cout << cnt << endl;
}

在上面的代码片段中,变量 filter 被赋予了一个匿名函数,并且我们在count_if STL函数中使用了 filter。现在,让我们编写一个简单的 Lambda 函数,在函数调用时指定。我们将使用 STL accumulate 来聚合向量中的值:

//-------------- LambdaSecond.cpp
#include <iostream>
#include <iterator>
#include <vector>
#include <algorithm>
#include <numeric>
using namespace std;
int main() {
    auto num_vect =
        vector<int>{ 10, 23, -33, 15, -7, 60, 80};
    //-- Define a BinaryOperation Lambda at the call site
    auto accum = std::accumulate(
        std::begin(num_vect), std::end(num_vect), 0,
        [](auto const s, auto const n) {return s + n;});
    cout << accum << endl;
}

函数对象和 Lambda

在经典的 C++中,使用 STL 时,我们广泛使用函数对象或函数符号,通过重载函数运算符来编写转换过滤器和对 STL 容器执行减少操作:

//----- LambdaThird.cpp
#include <iostream>
#include <numeric>
using namespace std;
//////////////////////////
// Functors to add and multiply two numbers
template <typename T>
struct addition{
    T operator () (const T& init, const T& a ) { return init + a; }
};
template <typename T>
struct multiply {
    T operator () (const T& init, const T& a ) { return init * a; }
};
int main()
{
    double v1[3] = {1.0, 2.0, 4.0}, sum;
    sum = accumulate(v1, v1 + 3, 0.0, addition<double>());
    cout << "sum = " << sum << endl;
    sum = accumulate(v1,v1+3,0.0, [] (const double& a ,const double& b   ) {
        return a +b;
    });
    cout << "sum = " << sum << endl;
    double mul_pi = accumulate(v1, v1 + 3, 1.0, multiply<double>());
    cout << "mul_pi = " << mul_pi << endl;
    mul_pi= accumulate(v1,v1+3,1, [] (const double& a , const double& b ){
        return a *b;
    });
    cout << "mul_pi = " << mul_pi << endl;
}

以下程序清楚地演示了通过编写一个玩具排序程序来使用 Lambda。我们将展示如何使用函数对象和 Lambda 来编写等效的代码。该代码以一种通用的方式编写,但假设数字是预期的(doublefloatinteger或用户定义的等效类型):

/////////////////
//-------- LambdaFourth.cpp
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
//--- Generic functions for Comparison and Swap
template <typename T>
bool Cmp( T& a , T&b ) {return ( a > b ) ? true: false;}
template <typename T>
void Swap( T& a , T&b ) { T c = a;a = b;b = c;}

CmpSwap是通用函数,将用于比较相邻元素和交换元素,同时执行排序操作:

template <typename T>
void BubbleSortFunctor( T *arr , int length ) {
    for( int i=0; i< length-1; ++i )
        for(int j=i+1; j< length; ++j )
            if ( Cmp( arr[i] , arr[j] ) )
                Swap(arr[i],arr[j] );
}

有了 Cmp 和 Swap,编写冒泡排序就变得简单了。我们需要有一个嵌套循环,在其中我们将比较两个元素,如果 Cmp 返回 true,我们将调用 Swap 来交换值:

template <typename T>
void BubbleSortLambda( T *arr , int length ) {
    auto CmpLambda = [] (const auto& a , const auto& b )
    { return ( a > b ) ? true: false; };
    auto SwapLambda = [] ( auto& a , auto& b )
    { auto c = a;a = b;b = c;};
    for( int i=0; i< length-1; ++i )
        for(int j=i+1; j< length; ++j )
            if ( CmpLambda( arr[i] , arr[j] ) )
                SwapLambda (arr[i],arr[j] );
}

在上面的例程中,我们将比较和交换函数定义为 Lambda。Lambda 函数是一种在调用站点内指定代码或表达式的机制,通常称为匿名函数。定义可以使用 C++语言指定的语法,并且可以赋值给变量,作为参数传递,或者从函数返回。在上面的函数中,变量CmpLambdaSwapLambda是 Lambda 语法中指定的匿名函数的示例。Lambda 函数的主体与之前的函数版本没有太大的不同。要了解有关 Lambda 函数和表达式的更多信息,可以参考en.cppreference.com/w/cpp/language/lambda页面。

template <typename T>
void Print( const T& container){
    for(auto i = container.begin() ; i != container.end(); ++i )
        cout << *i << "\n" ;
}

Print例程只是循环遍历容器中的元素,并将内容打印到控制台:

int main( int argc , char **argv ){
    double ar[4] = {20,10,15,-41};
    BubbleSortFunctor(ar,4);
    vector<double> a(ar,ar+4);
    Print(a);
    cout << "=========================================" << endl;
    ar[0] = 20;ar[1] = 10;ar[2] = 15;ar[3] = -41;
    BubbleSortLambda(ar,4);
    vector<double> a1(ar,ar+4);
    Print(a1);
    cout << "=========================================" << endl;
}

组合、柯里化和部分函数应用

Lambdas 的一个优点是你可以将两个函数组合在一起,创建函数的组合,就像你在数学中所做的那样(在数学和函数式编程的上下文中阅读有关函数组合的内容,使用喜欢的搜索引擎)。以下程序演示了这个想法。这是一个玩具实现,撰写通用实现超出了本章的范围:

//------------ Compose.cpp
//----- g++ -std=c++1z Compose.cpp
#include <iostream>
using namespace std;
//---------- base case compile time recursion
//---------- stops here
template <typename F, typename G>
auto Compose(F&& f, G&& g)
{ return = { return f(g(x)); };}
//----- Performs compile time recursion based
//----- on number of parameters
template <typename F, typename... R>
auto Compose(F&& f, R&&... r){
    return = { return f(Compose(r...)(x)); };
}

Compose是一个可变模板函数,编译器通过递归扩展Compose参数生成代码,直到处理完所有参数。在前面的代码中,我们使用[=]指示编译器应该按值捕获 Lambda 体中引用的所有变量。您可以在函数式编程的上下文中学习更多关于闭包和变量捕获的内容。C++语言允许通过值(以及使用[&])或通过显式指定要捕获的变量(如[&var])来灵活地Capture变量。

函数式编程范式基于由美国数学家阿隆佐·邱奇发明的一种数学形式主义,称为 Lambda 演算。Lambda 演算仅支持一元函数,柯里化是一种将多参数函数分解为一系列一次接受一个参数的函数评估的技术。

使用 Lambdas 和以特定方式编写函数,我们可以在 C++中模拟柯里化:

auto CurriedAdd3(int x) {
    return x { //capture x
        return x, y{ return x + y + z; };
    };
};

部分函数应用涉及将具有多个参数的函数转换为固定数量的参数。如果固定数量的参数少于函数的 arity(参数计数),则将返回一个新函数,该函数期望其余的参数。当接收到所有参数时,将调用该函数。我们可以将部分应用视为某种形式的记忆化,其中参数被缓存,直到我们接收到所有参数以调用它们。

在以下代码片段中,我们使用了模板参数包和可变模板。模板参数包是一个接受零个或多个模板参数(非类型、类型或模板)的模板参数。函数参数包是一个接受零个或多个函数参数的函数参数。至少有一个参数包的模板称为可变模板。对参数包和可变模板的良好理解对于理解sizeof...构造是必要的。

template <typename... Ts>
auto PartialFunctionAdd3(Ts... xs) {
    //---- http://en.cppreference.com/w/cpp/language/parameter_pack
    //---- http://en.cppreference.com/w/cpp/language/sizeof...
    static_assert(sizeof...(xs) <= 3);
    if constexpr (sizeof...(xs) == 3){
        // Base case: evaluate and return the sum.
        return (0 + ... + xs);
    }
    else{
        // Recursive case: bind `xs...` and return another
        return xs...{
            return PartialFunctionAdd3(xs..., ys...);
        };
    }
}
int main() {
    // ------------- Compose two functions together
    //----https://en.wikipedia.org/wiki/Function_composition
    auto val = Compose(
        [](int const a) {return std::to_string(a); },
        [](int const a) {return a * a; })(4); // val = "16"
    cout << val << std::endl; //should print 16
    // ----------------- Invoke the Curried function
    auto p = CurriedAdd3(4)(5)(6);
    cout << p << endl;
    //-------------- Compose a set of function together
    auto func = Compose(
        [](int const n) {return std::to_string(n); },
        [](int const n) {return n * n; },
        [](int const n) {return n + n; },
        [](int const n) {return std::abs(n); });
    cout << func(5) << endl;
    //----------- Invoke Partial Functions giving different arguments
    PartialFunctionAdd3(1, 2, 3);
    PartialFunctionAdd3(1, 2)(3);
    PartialFunctionAdd3(1)(2)(3);
}

函数包装器

函数包装器是可以包装任何函数、函数对象或 Lambdas 成可复制对象的类。包装器的类型取决于类的函数原型。来自<functional>头文件的std::function(<prototype>)表示一个函数包装器:

//---------------- FuncWrapper.cpp Requires C++ 17 (-std=c++1z )
#include <functional>
#include <iostream>
using namespace std;
//-------------- Simple Function call
void PrintNumber(int val){ cout << val << endl; }
// ------------------ A class which overloads function operator
struct PrintNumber {
    void operator()(int i) const { std::cout << i << '\n';}
};
//------------ To demonstrate the usage of method call
struct FooClass {
    int number;
    FooClass(int pnum) : number(pnum){}
    void PrintNumber(int val) const { std::cout << number + val<< endl; }
};
int main() {
    // ----------------- Ordinary Function Wrapped
    std::function<void(int)> 
    displaynum = PrintNumber;
    displaynum(0xF000);
    std::invoke(displaynum,0xFF00); //call through std::invoke
    //-------------- Lambda Functions Wrapped
    std::function<void()> lambdaprint = []() { PrintNumber(786); };
        lambdaprint();
        std::invoke(lambdaprint);
        // Wrapping member functions of a class
        std::function<void(const FooClass&, int)>
        class display = &FooClass::PrintNumber;
        // creating an instance
        const FooClass fooinstance(100);
        class display (fooinstance,100);
}

在接下来的章节中,我们将广泛使用std::function,因为它有助于将函数调用作为数据进行处理。

使用管道运算符将函数组合在一起

Unix 操作系统的命令行 shell 允许将一个函数的标准输出管道到另一个函数,形成一个过滤器链。后来,这个特性成为大多数操作系统提供的每个命令行 shell 的一部分。在编写函数式风格的代码时,当我们通过函数组合来组合方法时,由于深层嵌套,代码变得难以阅读。现在,使用现代 C++,我们可以重载管道(|)运算符,以允许将多个函数链接在一起,就像我们在 Unix shell 或 Windows PowerShell 控制台中执行命令一样。这就是为什么有人重新将 LISP 语言称为许多令人恼火和愚蠢的括号。RxCpp 库广泛使用|运算符来组合函数。以下代码帮助我们了解如何创建可管道化的函数。我们将看一下这个原则上如何实现。这里给出的代码仅用于解释目的:

//---- PipeFunc2.cpp
//-------- g++ -std=c++1z PipeFunc2.cpp
#include <iostream>
using namespace std;

struct AddOne {
    template<class T>
    auto operator()(T x) const { return x + 1; }
};
struct SumFunction {
    template<class T>
    auto operator()(T x,T y) const { return x + y;} // Binary Operator
};

前面的代码创建了一组 Callable 类,并将其用作函数组合链的一部分。现在,我们需要创建一种机制,将任意函数转换为闭包:

//-------------- Create a Pipable Closure Function (Unary)
//-------------- Uses Variadic Templates Paramter pack
template<class F>
struct PipableClosure : F{
    template<class... Xs>
    PipableClosure(Xs&&... xs) : // Xs is a universal reference
    F(std::forward<Xs>(xs)...) // perfect forwarding
    {}
};
//---------- A helper function which converts a Function to a Closure
template<class F>
auto MakePipeClosure(F f)
{ return PipableClosure<F>(std::move(f)); }
// ------------ Declare a Closure for Binary
//------------- Functions
//
template<class F>
struct PipableClosureBinary {
    template<class... Ts>
    auto operator()(Ts... xs) const {
        return MakePipeClosure(= -> decltype(auto)
        { return F()(x, xs...);}); }
};
//------- Declare a pipe operator
//------- uses perfect forwarding to invoke the function
template<class T, class F> //---- Declare a pipe operator
decltype(auto) operator|(T&& x, const PipableClosure<F>& pfn)
{ return pfn(std::forward<T>(x)); }

int main() {
    //-------- Declare a Unary Function Closure
    const PipableClosure<AddOne> fnclosure = {};
    int value = 1 | fnclosure| fnclosure;
    std::cout << value << std::endl;
    //--------- Decalre a Binary function closure
    const PipableClosureBinary<SumFunction> sumfunction = {};
    int value1 = 1 | sumfunction(2) | sumfunction(5) | fnclosure;
    std::cout << value1 << std::endl;
}

现在,我们可以创建一个带有一元函数作为参数的PipableClosure实例,并将一系列调用链接(或组合)到闭包中。前面的代码片段应该在控制台上打印出三。我们还创建了一个PipableBinaryClosure实例,以串联一元和二元函数。

杂项功能

到目前为止,我们已经介绍了从 C++ 11 标准开始的语言中最重要的语义变化。本章的目的是突出一些可能有助于编写现代 C++程序的关键变化。C++ 17 标准在语言中添加了一些新内容。我们将突出语言的一些其他特性来结束这个讨论。

折叠表达式

C++ 17 标准增加了对折叠表达式的支持,以简化可变函数的生成。编译器进行模式匹配,并通过推断程序员的意图生成代码。以下代码片段演示了这个想法:

//---------------- Folds.cpp
//--------------- Requires C++ 17 (-std=c++1z )
//--------------- http://en.cppreference.com/w/cpp/language/fold
#include <functional>
#include <iostream>

using namespace std;
template <typename... Ts>
auto AddFoldLeftUn(Ts... args) { return (... + args); }
template <typename... Ts>
auto AddFoldLeftBin(int n,Ts... args){ return (n + ... + args);}
template <typename... Ts>
auto AddFoldRightUn(Ts... args) { return (args + ...); }
template <typename... Ts>
auto AddFoldRightBin(int n,Ts... args) { return (args + ... + n); }
template <typename T,typename... Ts>
auto AddFoldRightBinPoly(T n,Ts... args) { return (args + ... + n); }
template <typename T,typename... Ts>
auto AddFoldLeftBinPoly(T n,Ts... args) { return (n + ... + args); }

int main() {
    auto a = AddFoldLeftUn(1,2,3,4);
    cout << a << endl;
    cout << AddFoldRightBin(a,4,5,6) << endl;
    //---------- Folds from Right
    //---------- should produce "Hello  World C++"
    auto b = AddFoldRightBinPoly("C++ "s,"Hello "s,"World "s );
    cout << b << endl;
    //---------- Folds (Reduce) from Left
    //---------- should produce "Hello World C++"
    auto c = AddFoldLeftBinPoly("Hello "s,"World "s,"C++ "s );
    cout << c << endl;
}

控制台上的预期输出如下

10
 25
 Hello World C++
 Hello World C++

变体类型

变体的极客定义将是“类型安全的联合”。在定义变体时,我们可以将一系列类型作为模板参数。在任何给定时间,对象将仅保存模板参数列表中的一种数据类型。如果我们尝试访问不包含当前值的索引,将抛出std::bad_variant_access异常。以下代码不处理此异常:

//------------ Variant.cpp
//------------- g++ -std=c++1z Variant.cpp
#include <variant>
#include <string>
#include <cassert>
#include <iostream>
using namespace std;

int main(){
    std::variant<int, float,string> v, w;
    v = 12.0f; // v contains now contains float
    cout << std::get<1>(v) << endl;
    w = 20; // assign to int
    cout << std::get<0>(w) << endl;
    w = "hello"s; //assign to string
    cout << std::get<2>(w) << endl;
}

其他重要主题

现代 C++支持诸如语言级并发、内存保证和异步执行等功能,这些功能将在接下来的两章中介绍。该语言支持可选数据类型和std::any类型。其中最重要的功能之一是大多数 STL 算法的并行版本。

基于范围的 for 循环和可观察对象

在本节中,我们将实现自己编写的自定义类型上的基于范围的 for 循环,以帮助您了解如何将本章中提到的所有内容组合起来编写支持现代习语的程序。我们将实现一个返回在范围内的一系列数字的类,并将实现基于范围的 for 循环的值的迭代的基础设施支持。首先,我们将利用基于范围的 for 循环编写“Iterable/Iterator”(又名“Enumerable/Enumerable”)版本。经过一些调整,实现将转变为 Observable/Observer(响应式编程的关键接口)模式:此处 Observable/Observer 模式的实现仅用于阐明目的,不应被视为这些模式的工业级实现。

以下的iterable类是一个嵌套类:

// Iterobservable.cpp
// we can use Range Based For loop as given below (see the main below)
// for (auto l : EnumerableRange<5, 25>()) { std::cout << l << ' '; }
// std::cout << endl;
#include <iostream>
#include <vector>
#include <iterator>
#include <algorithm>
#include <functional>
using namespace std;

template<long START, long END>
class EnumerableRange {
public:

    class iterable : public std::iterator<
        std::input_iterator_tag, // category
        long, // value_type
        long, // difference_type
        const long*, // pointer type
        long> // reference type
        {
            long current_num = START;
            public:
                reference operator*() const { return current_num; }
                explicit iterable(long val = 0) : current_num(val) {}
                iterable& operator++() {
                    current_num = ( END >= START) ? current_num + 1 :
                        current_num - 1;
                return *this;
            }
            iterable operator++(int) {
                iterable retval = *this; ++(*this); return retval;
            }
            bool operator==(iterable other) const
                { return current_num == other.current_num; }
            bool operator!=(iterable other) const
                { return !(*this == other); }
    };

前面的代码实现了一个内部类,该类派生自std::iterator,以满足类型通过基于范围的 for 循环进行枚举的要求。现在我们将编写两个公共方法(begin()end()),以便类的使用者可以使用基于范围的 for 循环:

iterable begin() { return iterable(START); }
    iterable end() { return iterable(END >= START ? END + 1 :
        END - 1); }
};

现在,我们可以编写代码来使用前面的类:

for (long l : EnumerableRange<5, 25>())
    { std::cout << l << ' '; }

在上一章中,我们定义了IEnumerable<T>接口。这个想法是遵循 Reactive eXtensions 的文档。可迭代类与上一章中的IEnumerable<T>实现非常相似。正如在上一章中概述的那样,如果我们稍微调整代码,前面的类可以变为推送型。让我们编写一个包含三个方法的OBSERVER类。我们将使用标准库提供的函数包装器来定义这些方法:

struct OBSERVER {
    std::function<void(const long&)> ondata;
    std::function<void()> oncompleted;
    std::function<void(const std::exception &)> onexception;
};

这里给出的ObservableRange类包含一个存储订阅者列表的vector<T>。当生成新数字时,事件将通知所有订阅者。如果我们从异步方法中分派通知调用,消费者将与范围流的生产者解耦。我们还没有为以下类实现IObserver/IObserver<T>接口,但我们可以通过订阅方法订阅通知:

template<long START, long END>
class ObservableRange {
    private:
        //---------- Container to store observers
        std::vector<
            std::pair<const OBSERVER&,int>> _observers;
        int _id = 0;

我们将以std::pair的形式将订阅者列表存储在std::vector中。std::pair中的第一个值是对OBSERVER的引用,std::pair中的第二个值是唯一标识订阅者的整数。消费者应该使用订阅方法返回的 ID 来取消订阅:

//---- The following implementation of iterable does
//---- not allow to take address of the pointed value  &(*it)
//---- Eg- &(*iterable.begin()) will be ill-formed
//---- Code is just for demonstrate Obervable/Observer
class iterable : public std::iterator<
    std::input_iterator_tag, // category
    long, // value_type
    long, // difference_type
    const long*, // pointer type
    long> // reference type
    {
        long current_num = START;
    public:
        reference operator*() const { return current_num; }
        explicit iterable(long val = 0) : current_num(val) {}
        iterable& operator++() {
            current_num = ( END >= START) ? current_num + 1 :
                current_num - 1;
            return *this;
        }
        iterable operator++(int) {
            iterable retval = *this; ++(*this); return retval;
        }
        bool operator==(iterable other) const
            { return current_num == other.current_num; }
        bool operator!=(iterable other) const
            { return !(*this == other); }
        };
    iterable begin() { return iterable(START); }
    iterable end() { return iterable(END >= START ? END + 1 : END - 1); }
// generate values between the range
// This is a private method and will be invoked from the generate
// ideally speaking, we should invoke this method with std::asnyc
void generate_async()
{
    auto& subscribers = _observers;
    for( auto l : *this )
        for (const auto& obs : subscribers) {
            const OBSERVER& ob = obs.first;
            ob.ondata(l);
    }
}

//----- The public interface of the call include generate which triggers
//----- the generation of the sequence, subscribe/unsubscribe pair
public:
    //-------- the public interface to trigger generation
    //-------- of thevalues. The generate_async can be executed
    //--------- via std::async to return to the caller
    void generate() { generate_async(); }
    //---------- subscribe method. The clients which
    //----------- expects notification can register here
    int subscribe(const OBSERVER& call) {
        // https://en.cppreference.com/w/cpp/container/vector/emplace_back
        _observers.emplace_back(call, ++_id);
        return _id;
    }
    //------------ has just stubbed unsubscribe to keep
    //------------- the listing small
    void unsubscribe(const int subscription) {}

};

int main() {
    //------ Call the Range based enumerable
    for (long l : EnumerableRange<5, 25>())
        { std::cout << l << ' '; }
    std::cout << endl;
    // instantiate an instance of ObservableRange
    auto j = ObservableRange<10,20>();
    OBSERVER test_handler;
    test_handler.ondata = [=
    {cout << r << endl; };
    //---- subscribe to the notifiactions
    int cnt = j.subscribe(test_handler);
    j.generate(); //trigget events to generate notifications
    return 0;
}

摘要

在本章中,我们了解了 C++程序员在编写响应式程序或其他类型的程序时应该熟悉的编程语言特性。我们谈到了类型推断、可变模板、右值引用和移动语义、Lambda 函数、基本的函数式编程、可管道化的操作符以及迭代器和观察者的实现。在下一章中,我们将学习 C++编程语言提供的并发编程支持。

第三章:C++中的语言级并发和并行

自 C++ 11 语言标准发布以来,C++一直对并发编程提供了出色的支持。在那之前,线程是由特定于平台的库处理的事务。微软公司有自己的线程库,其他平台(GNU Linux/macOS X)支持 POSIX 线程模型。作为语言的一部分的线程机制帮助 C++程序员编写可在多个平台上运行的可移植代码。

最初的 C++标准于 1998 年发布,语言设计委员会坚信线程、文件系统、GUI 库等最好留给特定平台的库。Herb Sutter 在《Dr. Dobbs Journal》上发表了一篇有影响力的文章,题为《免费午餐结束了》,他在文章中提倡利用多核处理器中的多个核心的编程技术。在编写并行代码时,函数式编程模型非常适合这项任务。线程、Lambda 函数和表达式、移动语义和内存保证等特性帮助人们轻松地编写并发或并行代码。本章旨在使开发人员能够利用线程库及其最佳实践。

在本章中,我们将涵盖以下主题:

  • 什么是并发?

  • 使用多个线程的特征 Hello World 程序

  • 如何管理线程的生命周期和资源

  • 在线程之间共享数据

  • 如何编写线程安全的数据结构

什么是并发?

在基本层面上,并发代表着多个活动同时发生。我们可以将并发与我们的许多现实生活情况联系起来,比如我们一边吃爆米花一边看电影,或者同时用两只手进行不同的功能,等等。那么,在计算机中,并发是什么呢?

几十年前,计算机系统已经能够进行任务切换,多任务操作系统也存在了很长时间。为什么计算领域突然对并发产生了新的兴趣?微处理器制造商通过将更多的硅片塞入处理器来增加计算能力。在这个过程的某个阶段,由于达到了基本的物理极限,他们无法再将更多的东西塞入相同的区域。那个时代的 CPU 一次只能执行一条执行路径,并通过切换任务(指令流)来运行多条指令路径。在 CPU 级别上,只有一个指令流在执行,由于事情发生得非常快(与人类感知相比),用户感觉动作是同时发生的。

大约在 2005 年,英特尔宣布了他们的新多核处理器(支持硬件级别的多条执行路径),这是一个改变游戏规则的事件。多核处理器不再是通过在任务之间切换来执行每个任务的处理器,而是作为一个解决方案来实际并行执行它们。但这给程序员带来了另一个挑战,即编写他们的代码以利用硬件级别的并发性。此外,实际硬件并发行为与任务切换所创建的幻觉之间存在差异的问题也出现了。直到多核处理器出现之前,芯片制造商一直在竞相增加他们的计算能力,期望在 21 世纪初达到 10 GHz。正如 Herb Sutter 在《免费午餐结束了》中所说的:“如果软件要利用这种增加的计算能力,它必须设计成能够同时运行多个任务”。Herb 警告程序员,那些忽视并发性的人在编写程序时也必须考虑这一点。

现代 C++标准库提供了一套机制来支持并发和并行。首先,std::thread以及同步对象(如std::mutexstd::lock_guardsstd::unique_lockstd::condition_variables等)使程序员能够使用标准 C++编写并发的多线程代码。其次,为了使用基于任务的并行(如.NET 和 Java),C++引入了std::futurestd::promise类,它们配对工作以分离函数调用和等待结果。

最后,为了避免管理线程的额外开销,C++引入了一个名为std::async的类,它将在接下来的章节中详细介绍,讨论重点将是编写无锁并发程序(至少在可能的情况下最小化锁定)。

并发是指两个或更多个线程或执行路径可以在重叠的时间段内启动、运行和完成(以某种交错的执行方式)。并行意味着两个任务可以同时运行(就像在多核 CPU 上看到的那样)。并发是关于响应时间,而并行主要是利用可用资源。

并发的 Hello World(使用 std::thread)

现在,让我们开始使用std::thread库编写我们的第一个程序。我们期望您有 C++ 11 或更高版本来编译我们将在本章讨论的程序。在深入讨论多线程的 Hello World 之前,让我们以一个简单的经典的 Hello World 示例作为参考:

//---- Thanks to Dennis Ritchie and Brian Kernighan, this is a norm for all languages
#include <iostream> 
int main() 
{ 
   std::cout << "Hello World\n"; 
} 

这个程序简单地将 Hello World 写入标准输出流(主要是控制台)。现在,让我们看另一个例子,它做同样的事情,但是使用一个后台线程(通常称为工作线程):

#include <iostream> 
#include <thread> 
#include <string> 
//---- The following function will be invoked by the thread library 
void thread_proc(std::string msg) 
{ 
   std::cout << "ThreadProc msg:" << msg; 
}  
int main() 
{ 
   // creates a new thread and execute thread_proc on it. 
   std::thread t(thread_proc, "Hello World\n");  
   // Waiting for the thread_proc to complete its execution 
   // before exiting from the program 
   t.join(); 
} 

与传统代码的第一个区别是包含了<thread>标准头文件。所有的多线程支持函数和类都声明在这个新头文件中。但是为了实现同步和共享数据保护,支持类是在其他头文件中可用的。如果您熟悉 Windows 或 POSIX 系统中的平台级线程,所有线程都需要一个初始函数。标准库也遵循相同的概念。在这个例子中,thread_proc函数是在主函数中声明的线程的初始函数。初始函数(通过函数指针)在std::thread对象t的构造函数中指定,并且构造开始执行线程。

最显著的区别是现在应用程序从一个新线程(后台线程)向标准输出流写入消息,这导致在此应用程序中有两个线程或执行路径。一旦新线程启动,主线程就会继续执行。如果主线程不等待新启动的线程完成,main()函数将结束,这样应用程序就会结束——甚至在新线程有机会完成执行之前。这就是在主线程完成之前调用join()的原因,以等待新线程t的结束。

管理线程

在运行时,执行从用户入口点main()开始(在启动代码执行之后),并且将在已创建的默认线程中执行。因此,每个程序都至少有一个执行线程。在程序执行期间,可以通过标准库或特定于平台的库创建任意数量的线程。如果 CPU 核心可用于执行它们,这些线程可以并行运行。如果线程数多于 CPU 核心数,即使存在并行性,我们也无法同时运行所有线程。因此,线程切换也在这里发生。程序可以从主线程启动任意数量的线程,并且这些线程在初始线程上同时运行。正如我们所看到的,程序线程的初始函数是main(),并且当主线程从其执行返回时程序结束。这将终止所有并行线程。因此,主线程需要等待直到所有子线程完成执行。因此,让我们看看线程的启动和加入是如何发生的。

线程启动

在前面的示例中,我们看到初始化函数作为参数传递给std::thread构造函数,并且线程被启动。此函数在自己的线程上运行。线程启动发生在线程对象的构造期间,但初始化函数也可以有其他替代方案。函数对象是线程类的另一个可能参数。C++标准库确保std::thread与任何可调用类型一起工作。

现代 C++标准支持通过以下方式初始化线程:

  • 函数指针(如前一节中)

  • 实现调用运算符的对象

  • Lambda

任何可调用实体都可以用于初始化线程。这使得std::thread能够接受具有重载函数调用运算符的类对象:

class parallel_job 
{ 
public: 
void operator() () 
{ 
    some_implementation(); 
} 
};  
parallel_job job; 
std::thread t(job); 

在这里,新创建的线程将对象复制到其存储中,因此必须确保复制行为。在这里,我们还可以使用std::move来避免与复制相关的问题:

std::thread t(std::move(job)); 

如果传递临时对象(rvalue)而不是函数对象,则语法如下:

std::thread t(parallel_job()); 

编译器可以将此代码解释为接受函数指针并返回std::thread对象的函数声明。但是,我们可以通过使用新的统一初始化语法来避免这种情况,如下所示:

std::thread t{ parallel_job() };

在以下代码片段中给出的额外一组括号也可以避免将std::thread对象声明解释为函数声明:

std::thread t((parallel_job()));

启动线程的另一个有趣的方法是通过将 C++ Lambda 作为参数传递给std::thread构造函数。Lambda 可以捕获局部变量,从而避免不必要地使用任何参数。当涉及编写匿名函数时,Lambda 非常有用,但这并不意味着它们应该随处使用。

Lambda 函数可以与线程声明一起使用,如下所示:

std::thread t([]{ 
    some_implementation(); 
}); 

线程加入

在 Hello World 示例中,您可能已经注意到在main()结束之前使用了t.join()。在函数离开之前,对关联线程实例的join()调用确保启动的函数将等待直到后台线程完成执行。如果没有 join,线程将在线程开始之前终止,直到当前上下文完成(它们的子线程也将被终止)。

join()是一个直接的函数,可以等待线程完成,也可以不等待。为了更好地控制线程,我们还有其他机制,比如互斥锁、条件变量和期物,它们将在本章和下一章的后面部分进行讨论。调用join()会清理与线程相关联的存储,因此确保对象不再与启动的线程相关联。这意味着join()函数只能每个线程调用一次;在调用join()后,调用joinable()将始终返回 false。前面的使用函数对象的示例可以修改如下以理解join()

class parallel_job 
{ 
   int& _iterations; 

public: 
    parallel_job(int& input): _iterations(input) 
    {} 

    void operator() () 
    { 
        for (int i = 0; i < _iterations; ++i) 
        { 
            some_implementation(i); 
        } 
    } 
}; 
void func() 
{ 
    int local_Val = 10000; 
    parallel_job job(local_Val); 
    std::thread t(job); 

    if(t.joinable()) 
        t.join(); 
} 

在这种情况下,在func()函数结束时,验证线程对象以确认线程是否仍在执行。在放置 join 调用之前,我们调用joinable()来查看其返回值。

为了防止在func()上等待,标准引入了一种机制,即使父函数完成执行,也可以继续执行。这可以通过另一个标准函数detach()来实现:

if(t.joinable()) 
         t.detach(); 

在分离线程之前,我们需要考虑几件事情;当func()退出时,线程t可能仍在运行。根据前面示例中给出的实现,线程使用了在func()中创建的局部变量的引用,这不是一个好主意,因为在大多数架构上,旧的堆栈变量随时可能被覆盖。在编写代码时,必须始终解决这些情况。处理这种情况的最常见方法是使线程自包含,并将数据复制到线程中,而不是共享它。

将参数传递给线程

因此,我们已经找出了如何启动和等待线程。现在,让我们看看如何将参数传递给线程初始化函数。让我们看一个计算阶乘的示例:

class Factorial 
{ 
private: 
    long long myFact; 

public: 
    Factorial() : myFact(1) 
    { 
    } 

    void operator() (int number) 
    { 
        myFact = 1; 
        for (int i = 1; i <= number; ++i) 
        { 
            myFact *= i; 
        } 
        std::cout << "Factorial of " << number << " is " << myFact; 
    } 
}; 

int main() 
{ 
    Factorial fact; 

    std::thread t1(fact, 10); 

    t1.join(); 
} 

从这个例子中,可以清楚地看出,通过向std::thread()声明中传递额外的参数,可以实现将参数传递给线程函数或线程可调用对象。我们必须记住一件事;传递的参数被复制到线程的内部存储以供进一步执行。对于线程的执行来说,拥有自己的参数副本是很重要的,因为我们已经看到了与局部变量作用域结束相关的问题。要进一步讨论将参数传递给线程,让我们回到本章的第一个 Hello World 示例:

void thread_proc(std::string msg); 

std::thread t(thread_proc, "Hello World\n"); 

在这种情况下,thread_proc()函数以std::string作为参数,但我们将const char*作为参数传递给线程函数。只有在线程的情况下,参数才会被传递、转换并复制到线程的内部存储中。在这里,const char*将被转换为std::string。必须在选择线程提供的参数类型时考虑到这一点。让我们看看如果将指针作为参数提供给线程会发生什么:

void thread_proc(std::string msg); 
void func() 
{ 
   char buf[512]; 
   const char* hello = "Hello World\n"; 
   std::strcpy(buf, hello); 

   std::thread t(thread_proc, buf); 
   t.detach(); 
} 

在前面的代码中,提供给线程的参数是指向局部变量buf的指针。func()函数在线程上发生buf转换为std::string之前可能会退出。这可能导致未定义的行为。可以通过在声明中将buf变量转换为std::string来解决这个问题,如下所示:

std::thread t(thread_proc, std::string(buf)); 

现在,让我们看看当您希望在线程中更新引用时的情况。在典型情况下,线程会复制传递给线程的值,以确保安全执行,但标准库还提供了一种通过引用传递参数给线程的方法。在许多实际系统中,您可能已经看到在线程内部更新共享数据结构。以下示例展示了如何在线程中实现按引用传递:

void update_data(shared_data& data);

void another_func() 
{ 
   shared_data data; 
   std::thread t(update_data, std::ref(data)); 
   t.join(); 
   do_something_else(data); 
} 

在前面的代码中,使用std::ref将传递给std::thread构造函数的参数包装起来,确保线程内部使用的变量是实际参数的引用。您可能已经注意到,线程初始化函数的函数原型接受了对shared_data对象的引用,但为什么在线程调用中仍然需要std::ref()包装呢?考虑以下线程调用的代码:

std::thread t(update_data, data);

在这种情况下,update_data()函数期望shared_data参数被视为实际参数的引用。但当用作线程初始化函数时,参数会在内部被简单地复制。当调用update_data()时,它将传递给参数的内部副本的引用,而不是实际参数的引用。

使用 Lambda

现在,让我们看一下 Lambda 表达式在多线程中的用处。在以下代码中,我们将创建五个线程,并将它们放入一个向量容器中。每个线程将使用 Lambda 函数作为初始化函数。在以下代码中初始化的线程通过值捕获循环索引:

int main() 
{ 
    std::vector<std::thread> threads; 

    for (int i = 0; i < 5; ++i) 
    { 
        threads.push_back(std::thread( [i]() { 
            std::cout << "Thread #" << i << std::endl; 
        })); 
    } 

    std::cout << "nMain function"; 

    std::for_each(threads.begin(), threads.end(), [](std::thread &t) { 
        t.join(); 
    }); 
} 

向量容器线程存储了在循环内创建的五个线程。一旦执行结束,它们将在main()函数的末尾被连接。前面代码的输出可能如下所示:

Thread # Thread # Thread # Thread # Thread #
Main function
0
4
1
3
2

程序的输出可能在每次运行时都不同。这个程序是一个很好的例子,展示了并发编程中的不确定性。在接下来的部分中,我们将讨论std::thread对象的移动属性。

所有权管理

从本章迄今讨论的示例中,您可能已经注意到启动线程的函数必须使用join()函数等待线程完成执行,否则它将以程序失去对线程的控制为代价调用detach()。在现代 C++中,许多标准类型是可移动的,但不能被复制;std::thread就是其中之一。这意味着线程执行的所有权可以在std::thread实例之间通过移动语义移动。

有许多情况下,我们希望将所有权移交给另一个线程,例如,如果我们希望线程在创建线程的函数上后台运行而不等待它。这可以通过将线程所有权传递给调用函数来实现,而不是在创建的函数中等待它完成。在另一种情况下,将所有权传递给另一个函数,该函数将等待线程完成其执行。这两种情况都可以通过将一个线程实例的所有权传递给另一个线程实例来实现。

为了进一步解释,让我们定义两个函数来用作线程函数:

void function1() 
{ 
    std::cout << "function1()n"; 
} 

void function2() 
{ 
    std::cout << "function2()n"; 
} 

让我们来看一下从先前声明的函数中生成线程的主要函数:

int main() 
{ 
    std::thread t1(function1); 

    // Ownership of t1 is transferred to t2 
    std::thread t2 = std::move(t1);

在前面的代码中,main()的第一行启动了一个新的线程t1。然后,使用std::move()函数将所有权转移到t2,该函数调用了与t2关联的std::thread的移动构造函数。现在,t1 实例没有关联的线程执行。初始化函数function1()现在与t2关联:

    t1 = std::thread(function2); 

然后,使用 rvalue 启动了一个新的线程,这将调用与t1关联的std::thread的移动赋值运算符。由于我们使用了 rvalue,因此不需要显式调用std::move()

    // thread instance Created without any associated thread execution 
    std::thread t3; 

    // Ownership of t2 is transferred to t3 
    t3 = std::move(t2); 

t3是在没有任何线程执行的情况下实例化的,这意味着它正在调用默认构造函数。然后,通过显式调用std::move()函数,通过移动赋值运算符将当前与t2关联的所有权转移到t3

    // No need to join t1, no longer has any associated thread of execution 
    if (t1.joinable())  t1.join(); 
    if (t3.joinable())  t3.join(); 

    return 0; 
} 

最后,与关联执行线程的std::thread实例在程序退出之前被连接。在这里,t1t3是与关联执行线程的实例。

现在,让我们假设在前面示例中的线程join()之前存在以下代码:

t1 = std::move(t3); 

在这里,实例t1已经与正在运行的函数(function2)相关联。当std::move()试图将function1的所有权转移回t1时,将调用std::terminate()来终止程序。这保证了std::thread析构函数的一致性。

std::thread中的移动支持有助于将线程的所有权从函数中转移出来。以下示例演示了这样的情况:

void func() 
{ 
    std::cout << "func()n"; 
} 

std::thread thread_creator() 
{ 
    return std::thread(func); 
} 

void thread_wait_func() 
{ 
    std::thread t = thread_creator(); 

    t.join(); 
} 

在这里,thread_creator()函数返回与func()函数相关联的std::threadthread_wait_func()函数调用thread_creator(),然后返回线程对象,这是一个 rvalue,分配给了一个std::thread对象。这将线程的所有权转移到std::thread对象t中,对象t正在等待转移函数中线程执行的完成。

在线程之间共享数据

我们已经看到了如何启动线程和管理它们的不同方法。现在,让我们讨论如何在线程之间共享数据。并发的一个关键特性是它能够在活动的线程之间共享数据。首先,让我们看看线程访问共同(共享)数据所带来的问题。

如果在线程之间共享的数据是不可变的(只读),那么就不会有问题,因为一个线程读取的数据不受其他线程是否读取相同数据的影响。当线程开始修改共享数据时,问题就开始出现了。

例如,如果线程正在访问一个共同的数据结构,如果正在进行更新,与数据结构相关的不变量将被破坏。在这种情况下,数据结构中存储了元素的数量,通常需要修改多个值。考虑自平衡树或双向链表的删除操作。如果不采取任何特殊措施来确保否则,如果一个线程正在读取数据结构,而另一个正在删除一个节点,很可能会导致读取线程看到具有部分删除节点的数据结构,因此不变量被破坏。这可能最终会永久损坏数据结构,并可能导致程序崩溃。

不变量是一组在程序执行或对象生命周期中始终为真的断言。在代码中放置适当的断言来查看不变量是否被违反将产生健壮的代码。这是一种很好的记录软件的方式,也是防止回归错误的良好机制。关于这一点可以在以下维基百科文章中阅读更多:en.wikipedia.org/wiki/Invariant_(computer_science)

这经常导致一种称为竞争条件的情况,这是并发程序中最常见的错误原因。在多线程中,竞争条件意味着线程竞争执行各自的操作。在这里,结果取决于两个或更多线程中操作的执行相对顺序。通常,竞争条件一词指的是问题性的竞争条件;正常的竞争条件不会导致任何错误。问题性的竞争条件通常发生在完成操作需要修改两个或更多位数据的情况下,例如在树数据结构或双向链表中删除节点。因为修改必须访问不同的数据片段,当另一个线程尝试访问数据结构时,这些数据必须在单独的指令中进行修改。这发生在先前修改的一半已经完成时。

竞争条件通常很难找到,也很难复制,因为它们发生在非常短的执行窗口内。对于使用并发的软件,实现的主要复杂性来自于避免问题性的竞争条件。

有许多方法可以处理问题性的竞争条件。常见且最简单的选择是使用同步原语,这是基于锁的保护机制。它通过使用一些锁定机制来包装数据结构,以防止其他线程在其执行期间访问。我们将在本章中详细讨论可用的同步原语及其用途。

另一个选择是修改数据结构及其不变量的设计,以确保修改可以保证代码的顺序一致性,即使跨多个线程。这是一种编写程序的困难方式,通常被称为无锁编程。无锁编程和 C++内存模型将在第四章中进行介绍,《C++中的异步和无锁编程》。

然后,还有其他机制,比如将对数据结构的更新视为事务,就像对数据库的更新是在事务中完成的一样。目前,这个主题不在本书的范围内,因此不会涉及。

现在,让我们考虑 C++标准中用于保护共享数据的最基本机制,即互斥锁

互斥锁

互斥锁是用于并发控制的机制,用于防止竞争条件。互斥锁的功能是防止执行线程在另一个并发线程进入其自己的临界区时进入其临界区。它是一个可锁定的对象,设计用于在代码的临界区需要独占访问时发出信号,从而限制其他并发线程在执行和内存访问方面具有相同的保护。C++ 11 标准引入了std::mutex类到标准库中,以实现跨并发线程的数据保护。

std::mutex类包括lock()unlock()函数,用于在代码中创建临界区。在使用成员函数创建临界区时要记住的一件事是,永远不要跳过与锁定函数相关联的解锁函数,以标记代码中的临界区。

现在,让我们讨论与线程一起使用 Lambda 时所使用的相同代码。在那里,我们观察到程序的输出由于与共享资源std::coutstd::ostream操作符的竞争条件而混乱。现在,该代码正在使用std::mutex进行重写,以打印线程索引:

#include <iostream> 
#include <thread> 
#include <mutex> 
#include <vector>  
std::mutex m; 
int main() 
{ 
    std::vector<std::thread> threads; 

    for (int i = 1; i < 10; ++i) 
    { 
        threads.push_back(std::thread( [i]() { 
            m.lock(); 
            std::cout << "Thread #" << i << std::endl; 
            m.unlock();
        })); 
    }      
    std::for_each(threads.begin(), threads.end(), [](std::thread &t) { 
        t.join(); 
    }); 
} 

前面代码的输出可能如下所示:

Thread #1 
Thread #2 
Thread #3 
Thread #4 
Thread #5 
Thread #6 
Thread #7 
Thread #8 
Thread #9 

在前面的代码中,互斥锁用于保护共享资源,即std::cout和级联的std::ostream操作符。与旧示例不同,现在代码中添加了互斥锁,避免了混乱的输出,但输出将以随机顺序出现。在std::mutex类中使用lock()unlock()函数可以保证输出不会混乱。然而,直接调用成员函数的做法并不推荐,因为你需要在函数的每个代码路径上调用解锁,包括异常情况。相反,C++标准引入了一个新的模板类std::lock_guard,它为互斥锁实现了资源获取即初始化RAII)习惯用法。它在构造函数中锁定提供的互斥锁,并在析构函数中解锁。这个模板类的实现在<mutex>标准头文件库中可用。前面的示例可以使用std::lock_guard进行重写,如下所示:

std::mutex m; 
int main() 
{ 
    std::vector<std::thread> threads;  
    for (int i = 1; i < 10; ++i) 
    { 
        threads.push_back(std::thread( [i]() { 
            std::lock_guard<std::mutex> local_lock(m); 
            std::cout << "Thread #" << i << std::endl; 
        })); 
    }      
    std::for_each(threads.begin(), threads.end(), [](std::thread &t) { 
        t.join(); 
    }); 
}

在前面的代码中,保护临界区的互斥锁位于全局范围,而std::lock_guard对象在每次线程执行时都是局部的 Lambda。这样,一旦对象被构造,互斥锁就会获得锁。当 Lambda 执行结束时,调用析构函数解锁互斥锁。

RAII 是 C++的一种习惯用法,其中诸如数据库/文件句柄、套接字句柄、互斥锁、堆上动态分配的内存等实体的生命周期都与持有它的对象的生命周期绑定。你可以在以下维基百科页面上阅读更多关于 RAII 的内容:en.wikipedia.org/wiki/Resource_acquisition_is_initialization

避免死锁

在处理互斥锁时,可能出现的最大问题就是死锁。要理解死锁是什么,想象一下 iPod。为了实现 iPod 的目的,它需要 iPod 和耳机。如果两个兄弟共享一个 iPod,有时候两个人都想同时听音乐。想象一个人拿到了 iPod,另一个拿到了耳机,他们都不愿意分享自己拥有的物品。现在他们陷入僵局,除非其中一个人试图友好一点,让另一个人听音乐。

在这里,兄弟们在争夺 iPod 和耳机,但回到我们的情况,线程在争夺互斥锁上的锁。在这里,每个线程都有一个互斥锁,并且正在等待另一个线程。没有互斥锁可以继续进行,因为每个线程都在等待另一个线程释放其互斥锁。这种情况被称为死锁

避免死锁有时候相当简单,因为不同的互斥锁用于不同的目的,但也有一些情况处理起来并不那么明显。我能给你的最好建议是,为了避免死锁,始终以相同的顺序锁定多个互斥锁。这样,你就永远不会遇到死锁情况。

考虑一个具有两个线程的程序的例子;每个线程都打算单独打印奇数和偶数。由于两个线程的意图不同,程序使用两个互斥锁来控制每个线程。两个线程之间的共享资源是std::cout。让我们看一个具有死锁情况的以下程序:

// Global mutexes 
std::mutex evenMutex; 
std::mutex oddMutex;  
// Function to print even numbers 
void printEven(int max) 
{ 
    for (int i = 0; i <= max; i +=2) 
    { 
        oddMutex.lock(); 
        std::cout << i << ","; 
        evenMutex.lock(); 
        oddMutex.unlock(); 
        evenMutex.unlock(); 
    } 
} 

printEven()函数被定义为将所有小于max值的正偶数打印到标准控制台中。同样,让我们定义一个printOdd()函数,以打印小于max的所有正奇数,如下所示:

// Function to print odd numbers 
void printOdd(int max) 
{ 
    for (int i = 1; i <= max; i +=2) 
    { 
        evenMutex.lock(); 
        std::cout << i << ","; 
        oddMutex.lock(); 
        evenMutex.unlock(); 
        oddMutex.unlock(); 

    } 
} 

现在,让我们编写main函数,生成两个独立的线程,使用先前定义的函数作为每个操作的线程函数来打印奇数和偶数:

int main() 
{ 
    auto max = 100; 

    std::thread t1(printEven, max); 
    std::thread t2(printOdd, max); 

    if (t1.joinable()) 
        t1.join(); 
    if (t2.joinable()) 
        t2.join(); 
} 

在这个例子中,std::cout受到两个互斥锁printEvenprintOdd的保护,它们以不同的顺序进行锁定。使用这段代码,我们总是陷入死锁,因为每个线程明显都在等待另一个线程锁定的互斥锁。运行这段代码将导致程序挂起。如前所述,可以通过以相同的顺序锁定它们来避免死锁,如下所示:

void printEven(int max) 
{ 
    for (int i = 0; i <= max; i +=2) 
    { 
        evenMutex.lock(); 
        std::cout << i << ","; 
        oddMutex.lock(); 
        evenMutex.unlock(); 
        oddMutex.unlock(); 
    } 
}  
void printOdd(int max) 
{ 
    for (int i = 1; i <= max; i +=2) 
    { 
        evenMutex.lock(); 
        std::cout << i << ","; 
        oddMutex.lock(); 
        evenMutex.unlock(); 
        oddMutex.unlock(); 

    } 
} 

但是这段代码显然不够干净。你已经知道使用 RAII 习惯用法的互斥锁可以使代码更清晰、更安全,但为了确保锁定的顺序,C++标准库引入了一个新函数std::lock——一个可以一次锁定两个或更多互斥锁而不会出现死锁风险的函数。以下示例展示了如何在先前的奇偶程序中使用这个函数:

void printEven(int max) 
{ 
    for (int i = 0; i <= max; i +=2) 
    { 
        std::lock(evenMutex, oddMutex); 
        std::lock_guard<std::mutex> lk_even(evenMutex, std::adopt_lock); 
        std::lock_guard<std::mutex> lk_odd(oddMutex, std::adopt_lock); 
        std::cout << i << ","; 
    } 
}  
void printOdd(int max) 
{ 
    for (int i = 1; i <= max; i +=2) 
    { 
        std::lock(evenMutex, oddMutex); 
        std::lock_guard<std::mutex> lk_even(evenMutex, std::adopt_lock); 
        std::lock_guard<std::mutex> lk_odd(oddMutex, std::adopt_lock); 

        std::cout << i << ","; 

    } 
} 

在这种情况下,一旦线程执行进入循环,对std::lock的调用会锁定两个互斥锁。为每个互斥锁构造了两个std::lock_guard实例。除了互斥锁实例之外,还提供了std::adopt_lock参数给std::lock_guard,以指示互斥锁已经被锁定,它们应该只是接管现有锁的所有权,而不是尝试在构造函数中锁定互斥锁。这保证了安全的解锁,即使在异常情况下也是如此。

然而,std::lock可以帮助您避免死锁,因为程序要求同时锁定两个或多个互斥锁时,它并不会帮助您解决问题。死锁是多线程程序中可能发生的最困难的问题之一。它最终依赖于程序员的纪律,不要陷入任何死锁情况。

使用 std::unique_lock 进行锁定

std::lock_guard相比,std::unique_lock在操作上提供了更多的灵活性。std::unique_lock实例并不总是拥有与之关联的互斥锁。首先,您可以将std::adopt_lock作为第二个参数传递给构造函数,以管理与std::lock_guard类似的互斥锁上的锁。其次,通过将std::defer_lock作为第二个参数传递给构造函数,在构造期间互斥锁可以保持未锁定状态。因此,稍后在代码中,可以通过在同一std::unique_lock对象上调用lock()来获取锁。但是,std::unique_lock提供的灵活性是有代价的;它在存储额外信息方面比lock_guard慢一些,并且需要更新。因此,建议除非确实需要std::unique_lock提供的灵活性,否则使用lock_guard

关于std::unique_lock的另一个有趣特性是其所有权转移的能力。由于std::unique_lock必须拥有其关联的互斥锁,这导致互斥锁的所有权转移。与std::thread类似,std::unique_lock类也是一种只能移动的类型。C++标准库中提供的所有移动语义语言细微差别和右值引用处理都适用于std::unique_lock

std::mutex类似,具有lock()unlock()等成员函数的可用性增加了它在代码中的灵活性,相对于std::lock_guard。在std::unique_lock实例被销毁之前释放锁的能力意味着,如果明显不再需要锁,可以在代码的任何地方选择性地释放它。不必要地持有锁会严重降低应用程序的性能,因为等待锁的线程会被阻止执行比必要时间更长的时间。因此,std::unique_lock是 C++标准库引入的非常方便的功能,支持 RAII 习惯用法,并且可以有效地最小化适用代码的关键部分的大小:

void retrieve_and_process_data(data_params param) 
{ 
   std::unique_lock<std::mutex> local_lock(global_mutex, std::defer_lock); 
   prepare_data(param); 

   local_lock.lock(); 
   data_class data = get_data_to_process(); 
   local_lock.unlock(); 

   result_class result = process_data(data); 

   local_lock.lock(); 
   strore_result(result); 
} 

在前面的代码中,您可以看到通过利用std::unique_lock的灵活性实现的细粒度锁定。当函数开始执行时,使用global_mutex构造了一个处于未锁定状态的std::unique_lock对象。立即准备了不需要独占访问的参数,它可以自由执行。在检索准备好的数据之前,local_lock使用std::unique_lock中的 lock 成员函数标记了关键部分的开始。一旦数据检索完成,锁将被释放,标志着关键部分的结束。在此之后,调用process_data()函数,再次不需要独占访问,可以自由执行。最后,在执行store_result()函数之前,锁定互斥锁以保护更新处理结果的写操作。在退出函数时,当std::unique_lock的局部实例被销毁时,锁将被释放。

条件变量

我们已经知道互斥锁可以用于共享公共资源并在线程之间同步操作。但是,如果不小心使用互斥锁进行同步,会变得有点复杂并容易发生死锁。在本节中,我们将讨论如何使用条件变量等待事件,以及如何以更简单的方式在同步中使用它们。

当涉及使用互斥锁进行同步时,如果等待的线程已经获得了对互斥锁的锁定,那么任何其他线程都无法锁定它。此外,通过定期检查由互斥锁保护的状态标志来等待一个线程完成执行是一种浪费 CPU 资源。这是因为这些资源可以被系统中的其他线程有效利用,而不必等待更长的时间。

为了解决这些问题,C++标准库提供了两种条件变量的实现:std::condition_variablestd::condition_variable_any。两者都声明在<condition_variable>库头文件中,两种实现都需要与互斥锁一起工作以同步线程。std::condition_variable的实现仅限于与std::mutex一起工作。另一方面,std::condition_variable_any可以与满足类似互斥锁标准的任何东西一起工作,因此带有_any后缀。由于其通用行为,std::condition_variable_any最终会消耗更多内存并降低性能。除非有真正的、定制的需求,否则不建议使用它。

以下程序是我们在讨论互斥锁时讨论过的奇偶线程的实现,现在正在使用条件变量进行重新实现。

std::mutex numMutex; 
std::condition_variable syncCond; 
auto bEvenReady = false; 
auto bOddReady  = false; 
void printEven(int max) 
{ 
    for (int i = 0; i <= max; i +=2) 
    { 
        std::unique_lock<std::mutex> lk(numMutex); 
        syncCond.wait(lk, []{return bEvenReady;}); 

        std::cout << i << ","; 

        bEvenReady = false; 
        bOddReady  = true; 
        syncCond.notify_one(); 
    } 
}

程序从全局声明一个互斥锁、一个条件变量和两个布尔标志开始,以便在两个线程之间进行同步。printEven函数在一个工作线程中执行,并且只打印从 0 开始的偶数。在这里,当它进入循环时,互斥锁受到std::unique_lock的保护,而不是std::lock_guard;我们马上就会看到原因。然后线程调用std::condition_variable中的wait()函数,传递锁对象和一个 Lambda 谓词函数,表达了正在等待的条件。这可以用任何返回 bool 的可调用对象替换。在这个函数中,谓词函数返回bEvenReady标志,以便在它变为 true 时函数继续执行。如果谓词返回 false,wait()函数将解锁互斥锁并等待另一个线程通知它,因此std::unique_lock对象在这里非常方便,提供了锁定和解锁的灵活性。

一旦std::cout打印循环索引,bEvenReady标志就会被设置为 false,bOddReady标志则会被设置为 true。然后,与syncCond相关联的notify_one()函数的调用会向等待的奇数线程发出信号,要求其将奇数写入标准输出流:

void printOdd(int max) 
{ 
    for (int i = 1; i <= max; i +=2) 
    { 
        std::unique_lock<std::mutex> lk(numMutex); 
        syncCond.wait(lk, []{return bOddReady;}); 

        std::cout << i << ","; 

        bEvenReady = true; 
        bOddReady  = false; 
        syncCond.notify_one(); 
    } 
} 

printOdd函数在另一个工作线程中执行,并且只打印从1开始的奇数。与printEven函数不同,循环迭代并打印由全局声明的条件变量和互斥锁保护的索引。在std::condition_variablewait()函数中使用的谓词返回bOddReadybEvenReady标志被设置为truebOddReady标志被设置为false。随后,调用与syncCond相关联的notify_one()函数会向等待的偶数线程发出信号,要求其将偶数写入标准输出流。这种奇偶数交替打印将持续到最大值:

int main() 
{ 
    auto max = 10; 
    bEvenReady = true; 

    std::thread t1(printEven, max); 
    std::thread t2(printOdd, max); 

    if (t1.joinable()) 
        t1.join(); 
    if (t2.joinable()) 
        t2.join(); 

} 

主函数启动两个后台线程,t1printEven函数相关联,t2printOdd函数相关联。输出在确认偶数奇数性之前开始,通过将bEvenReady标志设置为 true。

线程安全的堆栈数据结构

到目前为止,我们已经讨论了如何启动和管理线程,以及如何在并发线程之间同步操作。但是,当涉及到实际系统时,数据以数据结构的形式表示,必须根据情况选择适当的数据结构,以确保程序的性能。在本节中,我们将讨论如何使用条件变量和互斥量设计并发栈。以下程序是 std::stack 的包装器,声明在库头文件 <stack> 下,并且栈包装器将提供不同的 pop 和 push 功能的重载(这样做是为了保持清单的简洁,并且还演示了如何将顺序数据结构调整为在并发上下文中工作):

template <typename T> 
class Stack 
{ 
private: 
    std::stack<T> myData; 
    mutable std::mutex myMutex; 
    std::condition_variable myCond; 

public: 
    Stack() = default; 
    ~Stack() = default; 
    Stack& operator=(const Stack&) = delete; 

    Stack(const Stack& that) 
    { 
        std::lock_guard<std::mutex> lock(that.myMutex); 
        myData = that.myData; 
    }

Stack 类包含模板类 std::stack 的对象,以及 std::mutexstd::condition_variable 的成员变量。类的构造函数和析构函数标记为默认,让编译器为其生成默认实现,并且复制赋值运算符标记为删除,以防止在编译时调用此类的赋值运算符。定义了复制构造函数,它通过调用自己的复制赋值运算符来复制 std::stack 成员对象 myData,该操作受到右侧对象的互斥量保护:

      void push(T new_value) 
      { 
          std::lock_guard<std::mutex> local_lock(myMutex); 
          myData.push(new_value); 
          myCond.notify_one(); 
      } 

成员函数 push() 包装了 std::stack 容器的 push 函数。可以看到,互斥量成员变量 myMutexstd::lock_guard 对象锁定,以保护接下来的 push 操作。随后,使用成员 std::condition_variable 对象调用 notify_one() 函数,以通过相同的条件变量引发事件来通知等待的线程。在以下代码清单中,您将看到 pop 操作的两个重载,它们等待在此条件变量上得到信号:

    bool try_pop(T& return_value) 
    { 
        std::lock_guard<std::mutex> local_lock(myMutex); 
        if (myData.empty()) return false; 
        return_value = myData.top(); 
        myData.pop(); 
        return true; 
    }

try_pop() 函数以模板参数作为引用。由于实现从不等待栈至少填充一个元素,因此使用 std::lock_guard 对象来保护线程。如果栈为空,函数返回 false,否则返回 true。在这里,输出通过调用 std::stacktop() 函数分配给输入引用参数,该函数返回栈中的顶部元素,然后调用 pop() 函数来清除栈中的顶部元素。所有 pop 函数的重载都调用 top() 函数,然后调用 std::stackpop() 函数:

    std::shared_ptr<T> try_pop() 
    { 
        std::lock_guard<std::mutex> local_lock(myMutex); 
        if (myData.empty()) return std::shared_ptr<T>(); 

        std::shared_ptr<T> return_value(std::make_shared<T>(myData.top())); 
        myData.pop(); 

        return return_value;
    } 

这是 try_pop() 函数的另一个重载,它返回模板类型的 std::shared_ptr(智能指针)的实例。正如您已经看到的,try_pop 函数有多个重载,并且从不等待栈至少填充一个元素;因此,此实现使用 std::lock_guard。如果内部栈为空,函数返回 std::shared_ptr 的实例,并且不包含栈的任何元素。否则,返回包含栈顶元素的 std::shared_ptr 实例:

    void wait_n_pop(T& return_value) 
    { 
        std::unique_lock<std::mutex> local_lock(myMutex); 
        myCond.wait(local_lock, [this]{ return !myData.empty(); }); 
        return_value = myData.top(); 
        myData.pop(); 
    }      
    std::shared_ptr<T> wait_n_pop() 
    { 
        std::unique_lock<std::mutex> local_lock(myMutex); 
        myCond.wait(local_lock, [this]{ return !myData.empty(); }); 
        std::shared_ptr<T> return_value(std::make_shared<T>(myData.top())); 
        return return_value; 
    }   
}; 

到目前为止,pop函数的重载不会等待堆栈至少填充一个元素,如果它是空的。为了实现这一点,添加了pop函数的另外两个重载,它们使用与std::condition_variable相关的等待函数。第一个实现将模板值作为输出参数返回,第二个实现返回一个std::shared_ptr实例。这两个函数都使用std::unique_lock来控制互斥锁,以便提供std::condition_variablewait()函数。在wait函数中,predicate函数正在检查堆栈是否为空。如果堆栈为空,那么wait()函数会解锁互斥锁,并继续等待,直到从push()函数接收到通知。一旦调用了 push,predicate 将返回 true,wait_n_pop继续执行。函数重载接受模板引用,并将顶部元素分配给输入参数,后一个实现返回一个包含顶部元素的std::shared_ptr实例。

总结

在本章中,我们讨论了 C++标准库中可用的线程库。我们看到了如何启动和管理线程,并讨论了线程库的不同方面,比如如何将参数传递给线程,线程对象的所有权管理,线程之间数据的共享等等。C++标准线程库可以执行大多数可调用对象作为线程!我们看到了所有可用的可调用对象与线程的关联的重要性,比如std::function,Lambda 和函数对象。我们讨论了 C++标准库中可用的同步原语,从简单的std::mutex开始,使用 RAII 习惯用法来保护互斥锁免受未处理的退出情况的影响,以避免显式解锁,并使用诸如std::lock_guardstd::unique_lock之类的类。我们还讨论了条件变量(std::condition_variable)在线程同步的上下文中。本章为现代 C++引入的并发支持奠定了良好的基础,为本书进入功能习惯打下了基础。

在接下来的章节中,我们将涵盖 C++中更多的并发库特性,比如基于任务的并行性和无锁编程。

第四章:C++中的异步和无锁编程

在上一章中,我们看到了现代 C++引入的线程库以及创建、管理和同步线程的各种方法。使用线程编写代码的方式是相当低级的,并且容易出现与并发代码相关的潜在错误(死锁、活锁等)。尽管许多程序员没有注意到,但现代 C++语言提供了一个标准的内存模型,有助于更好地编写并发代码。作为一种并发编程语言,语言必须向开发人员提供有关内存访问和运行时执行顺序的某些保证。如果我们使用诸如互斥锁、条件变量和 futures 来发出信号事件,就不需要了解内存模型。但是了解内存模型及其保证将有助于我们使用无锁编程技术编写更快的并发代码。锁可以使用称为原子操作的东西来模拟,我们将深入研究这种技术。

正如我们在第二章中讨论的,零成本抽象仍然是 C++编程语言最基本的原则之一。C++始终是系统程序员的语言,标准委员会设法在语言支持的高级抽象机制和访问低级资源以编写系统程序的能力之间取得良好的平衡。C++公开了原子类型和一组相关操作,以对程序的执行进行细粒度控制。标准委员会已经发布了内存模型的详细语义,语言还有一组库,帮助程序员利用它们。

在上一章中,我们学习了如何使用条件变量在单独的线程中同步操作。本章讨论了标准库提供的设施,使用futures执行基于任务的并行性。在本章中,我们将涵盖:

  • C++中的基于任务的并行性

  • C++内存模型

  • 原子类型和原子操作

  • 同步操作和内存排序

  • 如何编写无锁数据结构

C++中的基于任务的并行性

任务是一种计算,可以与其他计算同时执行。线程是任务的系统级表示。在上一章中,我们学习了如何通过构造一个std::thread对象并将任务作为其构造函数的参数来并发执行任务,同时还可以启动其他任务。任务可以是任何可调用对象,如函数、Lambda 或仿函数。但是使用std::thread并发执行函数的方法称为基于线程的方法。并发执行的首选选择是基于任务的方法,本章将讨论这一点。基于任务的方法优于基于线程的方法的优势在于在任务的(更高)概念级别上操作,而不是直接在线程和锁的较低级别上操作。通过遵循标准库特性实现了基于任务的并行性:

  • 用于从与单独线程相关联的任务返回值的 future 和 promise

  • packaged_task用于帮助启动任务并提供返回结果的机制

  • async()用于启动类似函数调用的任务

Future 和 promise

C++任务通常表现得像一种数据通道。发送端通常称为 promise,将数据发送到接收端,通常称为future。关于 future 和 promise 的重要概念是它们使两个任务之间的值传输无需显式使用锁。值的传输由系统(运行时)本身处理。futurepromise背后的基本概念很简单;当一个任务想要将一个值传递到另一个任务时,它将该值放入一个promise中。

标准库确保与此承诺相关联的未来获得此值。另一个任务可以从这个future中读取这个值(下面的图表必须从右向左阅读):

如果调用线程需要等待特定的一次性事件,则 future 非常方便。代表此事件的 future 使自身对调用线程可用,并且一旦 future 准备就绪(当值设置为相应的 promise 时),调用线程就可以访问该值。在执行期间,future 可能具有与之关联的数据,也可能没有。一旦事件发生,future 中将可用数据,并且无法重置。

与基于任务的并行性相关的模板类在库头文件<future>中声明。标准库中有两种类型的 future:独占 future(std::future<>)和共享 future(std::shared_future<>)。您可以将这些与智能指针std::unique_ptr<>std::shared_ptr<>*相对应。std::future实例指的是与关联事件的唯一实例。相反,多个std::shared_future实例可能指向同一事件。在shared_future的情况下,与共同事件关联的所有实例将同时准备就绪,并且它们可以访问与事件关联的数据。模板参数是关联数据,如果没有与之关联的数据,则应使用std::future<void>std::shared_future<void>模板规范。尽管线程之间的数据通信由 future 在内部管理,但 future 对象本身不提供同步访问。如果多个线程需要访问单个std::future对象,则必须使用互斥锁或其他同步机制进行保护。

std::futurestd::promise类成对工作,分别用于任务调用和等待结果。对于std::future<T>对象f,我们可以使用std::future类的get()函数访问与之关联的值T。类似地,对于std::promise<T>,它有两个可用的放置操作函数(set_value()set_exception())与之匹配 future 的get()。对于 promise 对象,您可以使用set_value()给它一个值,或者使用set_exception()传递异常给它。例如,以下伪代码帮助您看到如何在 promise 中设置值(在func1中),以及在调用future<T>:: get()的函数中如何消耗这些值(func2):

// promise associated with the task launched 
void func1(std::promise<T>& pr) 
{ 
    try 
    { 
        T val; 
        process_data(val); 
        pr.set_value(val); // Can be retrieved by future<T>::get() 
    } 
    catch(...) 
    { 
        // Can be retrieved by future<T>::get() 
        // At the future level, when we call get(), the  
        // get will propagate the exception  
        pr.set_exception(std::current_exception()); 
    } 
} 

在前面的情况下,处理和获取结果后,类型为Tval被设置为 promise pr。如果执行期间发生任何异常,异常也将被设置为 promise。现在,让我们看看如何访问您设置的值:

// future corresponding to task already launched 
void func2(std::future<T>& ft) 
{ 
    try 
    { 
        // An exception will be thrown here, if the corresponding  
        // promise had set an exception ..otherwise, retrieve the  
        // value sets by the promise.  
        T result = ft.get() 
    } 
    catch(...)
    { 
        // Handle exception  
    } 
} 

在这里,使用作为参数传递的 future 来访问相应承诺中设置的值。与std::future()相关联的get()函数在任务执行期间检索存储的值。调用get()必须准备捕获通过 future 传递的异常并处理它。在解释完std::packaged_task之后,我们将展示一个完整的示例,其中 future 和 promise 共同发挥作用。

std::packaged_task

现在,让我们讨论如何将与 future 关联的返回值引入到需要结果的代码中。std::packaged_task是标准库中提供的一个模板类,用于通过 future 和 promise 实现基于任务的并行处理。通过在线程中设置 future 和 promise,它简化了设置任务而无需为共享结果设置显式锁。packaged_task实例提供了一个包装器,用于将返回值或捕获的异常放入 promise 中。std::packaged_task中的成员函数get_future()将为您提供与相应 promise 关联的 future 实例。让我们看一个示例,该示例使用 packaged task 来找到向量中所有元素的总和(promise 的工作深入到packaged_task的实现中):

// Function to calculate the sum of elements in an integer vector 
int calc_sum(std::vector<int> v) 
{ 
    int sum = std::accumulate(v.begin(), v.end(), 0); 
    return sum; 
} 

int main() 
{ 
    // Creating a packaged_task encapsulates a function 
    std::packaged_task<int(std::vector<int>)> task(calc_sum); 

    // Fetch associated future from packaged_task 
    std::future<int> result = task.get_future(); 

    std::vector<int> nums{1,2,3,4,5,6,7,8,9,10}; 

    // Pass packaged_task to thread to run asynchronously 
    std::thread t(std::move(task), std::move(nums)); 

    t.join();
    // Fetch the result of packaged_task, the value returned by calc_sum() 
    int sum = result.get(); 

    std::cout << "Sum = " << sum << std::endl; 
    return 0; 
}

packaged_task对象以任务类型作为其模板参数,并以函数指针(calc_sum)作为构造函数参数。通过调用任务对象的get_future()函数获得 future 实例。由于packaged_task实例无法复制,因此使用显式的std::move()。这是因为它是一个资源句柄,并负责其任务可能拥有的任何资源。然后,调用get()函数从任务中获取结果并打印它。

现在,让我们看看packaged_task如何与 Lambda 一起使用:

    std::packaged_task<int(std::vector<int>)> task([](std::vector<int> 
    v) { 
        return std::accumulate(v.begin(), v.end(), 0); 
    }); 

在这里,packaged_task的构造函数中传递了一个 Lambda,而不是函数指针。正如您在之前的章节中已经看到的,对于并发运行的小代码块,Lambda 非常方便。future 的主要概念是能够获得结果,而不必担心通信管理的机制。此外,这两个操作在两个不同的线程中运行,因此是并行的。

std::async

现代 C++提供了一种执行任务的机制,就像执行可能或可能不会并行执行的函数一样。在这里,我们指的是std::async,它在内部管理线程细节。std::async以可调用对象作为其参数,并返回一个std::future,该对象将存储已启动任务的结果或异常。让我们重新编写我们之前的示例,使用std::async计算向量中所有元素的总和:

// Function to calculate the sum of elements in a vector 
int calc_sum(std::vector<int> v) 
{ 
   int sum = std::accumulate(v.begin(), v.end(), 0); 
   return sum; 
} 

int main() 
{ 
   std::vector<int> nums{1,2,3,4,5,6,7,8,9,10}; 

   // task launch using std::async 
   std::future<int> result(std::async(std::launch::async, calc_sum,    std::move(nums))); 

   // Fetch the result of async, the value returned by calc_sum() 
   int sum = result.get(); 

   std::cout << "Sum = " << sum << std::endl; 
   return 0; 
} 

主要是,当使用std::async进行基于任务的并行处理时,任务的启动和从任务中获取结果遵循直观的语法,并且与任务执行分开。在前面的代码中,std::async接受三个参数:

  • async标志确定了async任务的启动策略,std::launch::async表示async在新的执行线程上执行任务。std::launch::deferred标志不会生成新线程,但会执行延迟评估。如果两个标志都设置为std::launch::asyncstd::launch::deferred,则由实现决定是执行异步执行还是延迟评估。如果您没有显式地传递任何启动策略到std::async中,那么再次由实现选择执行方法。

  • std::async的第二个参数是可调用对象,可以是函数指针、函数对象或 Lambda。在这个例子中,calc_sum函数是在单独的线程中执行的任务。

  • 第三个参数是任务的输入参数。通常,这是一个可变参数,可以传递任务可调用对象所需的参数数量。

现在,让我们看看async和 Lambda 如何一起用于相同的示例:

// Fetch associated future from async
std::future<int> result( async([](std::vector<int> v) {
return std::accumulate(v.begin(), v.end(), 0); 
}, std::move(nums))); 

在这个例子中,可调用对象参数中包含一个 Lambda 函数,该函数返回std::accumulate()的结果。与往常一样,Lambda 与简单操作一起美化了代码的整体外观并提高了可读性。

使用async,你不必考虑线程和锁。只需考虑异步执行计算的任务,你不知道会使用多少线程,因为这取决于内部实现根据调用时可用的系统资源来决定。它在决定使用多少线程之前会检查可用的空闲核心(处理器)。这指出了async的明显局限性,即需要用于共享资源并需要锁的任务。

C++内存模型

经典的 C++本质上是一种单线程语言。即使人们在 C++中编写多线程程序,他们也是使用各自平台的线程设施来编写它们。现代 C++可以被认为是一种并发编程语言。语言标准提供了一个标准的线程和任务机制(正如我们已经看到的),借助于标准库。由于它是标准库的一部分,语言规范已经定义了在平台上如何精确地行为。在程序运行时实现一致的平台无关行为对于线程、任务等是一个巨大的挑战,标准委员会处理得非常好。委员会设计并指定了一个标准内存模型,以实现一致的行为。内存模型包括两个方面:

  • 结构方面,涉及数据在内存中的布局。

  • 并发方面,涉及内存的并发访问

对于 C++程序,所有数据都由对象组成。语言将对象定义为存储区域,它以其类型和生命周期进行定义。对象可以是基本类型的实例,如 int 或 double,也可以是用户定义类型的实例。一些对象可能有子对象,但其他对象则没有。关键点是每个变量都是一个对象,包括其他对象的成员对象,每个对象都至少占用一些内存位置。现在,让我们看看这与并发有什么关系。

内存访问和并发

对于多线程应用程序,一切都取决于那些内存位置。如果多个线程访问不同的内存位置,一切都正常。但如果两个线程访问相同的内存位置,那么你必须非常小心。正如你在第三章中看到的那样,C++中的语言级并发和并行性,多个线程尝试从相同的内存位置读取不会引起问题,但只要任何一个线程尝试修改共同的内存位置中的数据,就会出现竞争条件的可能性。

问题性的竞争条件只能通过在多个线程之间强制排序访问来避免。如第三章中所讨论的,C++中的语言级并发和并行性,使用互斥锁进行基于锁的内存访问是一种流行的选择。另一种方法是利用原子操作的同步属性,通过在两个线程之间强制排序访问。在本章的后面部分,你将看到使用原子操作来强制排序的示例。

原子操作在并发编程中对系统的其余部分是立即发生的,不会被中断(在原子操作期间不会发生任务切换)。原子性是对中断、信号、并发进程和线程的隔离的保证。关于这个主题可以在维基百科的文章en.wikipedia.org/wiki/Linearizability中阅读更多内容。

如果没有强制规定从不同线程对单个内存位置进行多次访问之间的顺序,其中一个或两个访问都不是原子的。如果涉及写操作,那么它可能会导致数据竞争,并可能导致未定义的行为。数据竞争是一个严重的错误,必须尽一切努力避免。原子操作可以避免未定义的行为,但不能防止竞争情况。原子操作确保在操作进行时不会发生线程切换。这是对内存交错访问的保证。原子操作保证了交错内存访问的排除(串行顺序),但不能防止竞争条件(因为有可能覆盖更新)。

修改合同

在程序或进程执行时,系统中的所有线程都应同意修改顺序(对于内存)。每个程序都在一个环境中执行,其中包括指令流、内存、寄存器、堆、栈、缓存、虚拟内存等等。这种修改顺序是程序员和系统之间的合同,由内存模型定义。系统由将程序转换为可执行代码的编译器(和链接器)、执行指定流中指定的指令集的处理器、缓存和程序的相关状态组成。合同要求程序员遵守某些规则,这些规则使系统能够生成一个完全优化的程序。程序员在编写访问内存的代码时必须遵守的一组规则(或启发式)是通过标准库中引入的原子类型和原子操作来实现的。

这些操作不仅是原子的,而且会在程序执行中创建同步和顺序约束。与第三章中讨论的更高级别的基于锁的同步原语(互斥锁和条件变量)相比,《C++中的语言级并发和并行性》,您可以根据自己的需要定制同步和顺序约束。从 C++内存模型中重要的收获是:尽管语言采用了许多现代编程习惯和语言特性,但作为系统程序员的语言,C++为您的内存资源提供了更低级别的控制,以便根据您的需求优化代码。

C++中的原子操作和类型

通常,非原子操作可能被其他线程视为半成品。正如在第三章中所讨论的那样,《C++中的语言级并发和并行性》,在这种情况下,与共享数据结构相关的不变性将被破坏。当修改共享数据结构需要修改多个值时,就会发生这种情况。最好的例子是二叉树的部分移除节点。如果另一个线程同时尝试从这个数据结构中读取,不变性将被破坏,并可能导致未定义的行为。

使用原子操作,您无法从系统中的任何线程观察到半成品的操作,因为原子操作是不可分割的。如果与对象相关联的任何操作(例如读取)是原子的,那么对对象的所有修改也是原子的。C++提供了原子类型,以便您可以根据需要使用原子性。

原子类型

标准库定义的所有原子类型都可以在<atomic>头文件库中找到。系统保证这些类型的原子性以及与这些类型相关的所有操作。某些操作可能不是原子的,但在这种情况下,系统会产生原子性的幻觉。标准原子类型使用一个成员函数is_lock_free(),允许用户确定给定类型的操作是直接使用原子指令进行的(is_lock_free()返回true),还是使用编译器和库内部锁进行的(is_lock_free()返回false)。

std::atomic_flag在所有原子类型中是不同的。这种类型上的操作需要按照标准是原子的。因此,它不提供is_lock_free()成员函数。这是一种非常简单的类型,具有一组允许的最小操作,例如test_and_set()(可以查询或设置)或clear()(清除值)。

其余的原子类型遵循std::atomic<>类模板的规范。与std::atomic_flag相比,这些类型更加全面,但并非所有操作都是原子的。操作的原子性也高度取决于平台。在流行的平台上,内置类型的原子变体确实是无锁的,但这并不是在所有地方都能保证的。

不使用std::atomic<>模板类,可以使用实现提供的直接类型,如下表所示:

原子类型 对应的特化
atomic_bool std::atomic<bool>
atomic_char std::atomic<char>
atomic_schar std::atomic<signed char>
atomic_uchar std::atomic<unsigned char>
atomic_int std::atomic<int>
atomic_uint std::atomic<unsigned>
atomic_short std::atomic<short>
atomic_ushort std::atomic<unsigned short>
atomic_long std::atomic<long>
atomic_ulong std::atomic<unsigned long>
atomic_llong std::atomic<long long>
atomic_ullong std::atomic<unsigned long long>
atomic_char16_t std::atomic<char16_t>
atomic_char32_t std::atomic<char32_t>
atomic_wchar_t std::atomic<wchar_t>

除了所有这些基本的原子类型之外,C++标准库还提供了一组与标准库中的typedefs相比的原子类型的typedefs。有一个简单的模式来识别typedefs的对应原子版本:对于任何标准typedef T,使用atomic_前缀:atomic_T。以下表格列出了标准原子typedefs及其对应的内置typedefs

原子 typedef 标准库 typedef
atomic_size_t size_t
atomic_intptr_t intptr_t
atomic_uintptr_t uintptr_t
atomic_ptrdiff_t ptrdiff_t
atomic_intmax_t intmax_t
atomic_uintmax_t uintmax_t
atomic_int_least8_t int_least8_t
atomic_uint_least8_t uint_least8_t
atomic_int_least16_t int_least16_t
atomic_uint_least16_t uint_least16_t
atomic_int_least32_t int_least32_t
atomic_uint_least32_t uint_least32_t
atomic_int_least64_t int_least64_t
atomic_uint_least64_t uint_least64_t
atomic_int_fast8_t int_fast8_t
atomic_uint_fast8_t uint_fast8_t
atomic_int_fast16_t int_fast16_t
atomic_uint_fast16_t uint_fast16_t
atomic_int_fast32_t int_fast32_t
atomic_uint_fast32_t uint_fast32_t
atomic_int_fast64_t int_fast64_t
atomic_uint_fast64_t uint_fast64_t

std::atomic<>类模板不仅仅是一组特化;它们有一个主模板来扩展用户定义类型的原子变体。作为一个通用模板类,支持的操作仅限于load()store()exchange()compare_exchange_weak()compare_exchange_strong()。原子类型的每个操作都有一个可选参数,用于指定所需的内存排序语义。内存排序的概念将在本章的后面部分详细介绍。现在,只需记住所有原子操作可以分为三类:

  • 存储操作:这些操作可以具有memory_order_relaxedmemory_order_releasememory_order_seq_cst排序

  • 加载操作:这些可以具有memory_order_relaxedmemory_order_consumememory_order_acquirememory_order_seq_cst排序

  • 读-修改-写操作:这些操作可以具有memory_order_relaxedmemory_order_consumememory_order_acquirememory_order_releasememory_order_acq_relmemory_order_seq_cst排序

所有原子操作的默认内存排序都是memory_order_seq_cst

与传统的标准 C++类型相比,标准原子类型不可复制赋值。这意味着它们没有复制构造函数或复制赋值运算符。除了直接成员函数外,它们还支持从和到相应的内置类型的隐式转换。原子类型的所有操作都被定义为原子操作,赋值和复制构造涉及两个对象。涉及两个不同对象的操作不能是原子的。在这两个操作中,值必须从一个对象读取并写入另一个对象。因此,这些操作不能被视为原子操作。

现在,让我们看看您可以在每种标准原子类型上执行的操作,从std::atomic_flag开始。

std::atomic_flag

std::atomic_flag表示一个布尔标志,它是标准库中所有原子类型中最简单的。这是唯一一个在每个平台上所有操作都需要是无锁的类型。这种类型非常基础,因此只用作构建块。

std::atomic_flag对象必须始终使用ATOMIC_FLAG_INIT进行初始化,以将状态设置为clear

std::atomic_flag flg = ATOMIC_FLAG_INIT;

这是唯一需要这种初始化的原子类型,无论其声明的范围如何。一旦初始化,只有三种操作可以使用这种类型:销毁它,清除它,或者设置一个查询以获取先前的值。这分别对应于析构函数、clear()成员函数和test_and_set()成员函数。clear()是一个存储操作,而test_and_set()是一个读-修改-写操作,正如前一节中所讨论的:

flg.clear()
bool val = flg.test_and_set(std::memory_order_relaxed);

在上述代码片段中,clear()函数调用请求使用默认内存顺序清除标志,即std::memory_order_seq_cst,而test_and_set()的调用使用了松散的语义(更多信息请参阅松散排序),这些语义明确用于设置标志和检索旧值。

std::atomic_flag的原始实现使其成为自旋锁互斥量的理想选择。让我们看一个自旋锁的例子:

class spin_lock
{
    std::atomic_flag flg;
    public:
    spin_lock() : flg(ATOMIC_FLAG_INIT){}
    void lock() {
        // simulates a lock here... and spin
        while (flg.test_and_set(std::memory_order_acquire));
        //----- Do some action here
        //----- Often , the code to be guarded will be sequenced as
        // sp.lock() ...... Action_to_Guard() .....sp.unlock()
    }
    void unlock() {
        //------ End of Section to be guarded
        flg.clear(std::memory_order_release); // release lock
    }
};

在上述代码片段中,实例变量flg(类型为std::atomic_flag)最初被清除。在锁定方法中,它尝试通过测试flg来设置标志,以查看值是否被清除。

如果值被清除,值将被设置,我们将退出循环。只有当unlock()方法清除标志时,标志中的值才会被重置。换句话说,这种实现通过在lock()中进行忙等待来实现互斥排他。

由于其限制,std::atomic_flag不能用作布尔原子类型,并且不支持任何非修改查询操作。因此,让我们研究std::atomic<bool>来弥补原子布尔标志的要求。

std::atomic

std::atomic_flag相比,std::atomic<bool>是一个功能齐全的原子布尔类型。但是,这种类型既不能进行复制构造,也不能进行赋值。std::atomic<bool>对象的值最初可以是truefalse。此类型的对象可以从非原子bool构造或赋值:

std::atomic<bool> flg(true);
flg = false;

关于原子类型的赋值运算符需要注意一点,即该运算符返回非原子类型的值,而不是返回引用,这与传统方案不同。如果返回引用而不是值,那么会出现这样一种情况,即赋值的结果会得到另一个线程的修改结果,即如果它依赖于赋值运算符的结果。通过将赋值运算符的结果作为非原子值返回,可以避免这种额外的加载,并且您可以推断得到的值是实际存储的值。

现在,让我们继续讨论std::atomic<bool>支持的操作。首先,store()成员函数可用于std::atomic<bool>的写操作(truefalse),它取代了std::atomic_flag的相应的限制性clear()函数。此外,store()函数是一个原子存储操作。类似地,test_and_set()函数已经被更通用的exchange()成员函数有效地取代,它允许您用选择的新值替换存储的值并检索原始值。这是一个原子的读-修改-写操作。然后,std::atomic<bool>支持通过显式调用load()进行简单的非修改查询值的操作,这是一个原子加载操作:

std::atomic<bool> flg;
flg.store(true);
bool val = flg.load(std::memory_order_acquire);
val = flg.exchange(false, std::memory_order_acq_rel);

除了exchange()之外,std::atomic<bool>还引入了一个执行流行的原子比较和交换CAS)指令的操作来执行读-修改-写操作。此操作在当前值等于期望值时存储新值。这称为比较/交换操作。标准库原子类型中有两种实现此操作的方式:compare_exchange_weak()compare_exchange_strong()。此操作将原子变量的值与提供的期望值进行比较,并在它们相等时存储提供的值。如果这些值不相等,则更新期望值为原子变量的实际值。比较/交换函数的返回类型是bool,如果执行了存储,则为true;否则为false

对于compare_exchange_weak(),即使期望值和原始值相等,存储也可能不成功。在这种情况下,值的交换不会发生,函数将返回false。这在缺乏单个比较和交换指令的平台上最常见,这意味着处理器无法保证操作将被原子执行。在这样的机器上,执行操作的线程可能在执行与操作相关的指令序列的一半时被切换出去,并且操作系统会以更多线程运行而不是可用处理器数量的条件安排另一个线程代替它。这种情况被称为虚假失败

由于compare_exchange_weak()可能导致虚假失败,应该在循环中使用:

bool expected = false;
atomic<bool> flg;
...
while(!flg.compare_exchange_weak(expected, true));

在上述代码中,只要 expected 为false,循环就会继续迭代,并且它表示compare_exchange_weak()调用发生了虚假失败。相反,如果实际值不等于期望值,compare_exchange_strong()保证返回false。这可以避免在以前的情况下需要循环来了解变量状态与运行线程的情况。

比较/交换函数可以接受两个内存排序参数,以允许在成功和失败的情况下内存排序语义不同。这些内存排序语义仅对存储操作有效,不能用于失败情况,因为存储操作不会发生:

bool expected;
std::atomic<bool> flg;
b.compare_exchange_weak(expected, true, std::memory_order_acq_rel, std::memory_order_acquire);
b.compare_exchange_weak(expected, true, std::memory_order_release);

如果您不指定任何内存排序语义,对于成功和失败的情况都将采用默认的memory_order_seq_cst。如果您不为失败指定任何排序,那么假定与成功的排序相同,只是省略了排序的释放部分。memory_order_acq_rel变为memory_order_acquirememory_order_release变为memory_order_relaxed

内存排序的规范和后果将在本章的内存排序部分详细讨论。现在,让我们看看原子整数类型作为一组的用法。

标准原子整数类型

std::atomic<bool>类似,标准原子整数类型既不能进行复制构造,也不能进行复制赋值。但是,它们可以从相应的非原子标准变体构造和赋值。除了强制的is_lock_free()成员函数之外,标准原子整数类型,比如std::atomic<int>std::atomic<unsigned long long>,还有load()store()exchange()compare_exchange_weak()compare_exchange_strong()成员函数,其语义与std::atomic<bool>的类似。

原子类型的整数变体支持数学运算,比如fetch_add()fetch_sub()fetch_and()fetch_or()fetch_xor(),复合赋值运算符(+=-=&=|=^=),以及++--的前置和后置递增和递减运算符。

命名函数,比如fetch_add()fetch_sub(),会原子地执行它们的操作并返回旧值,但复合赋值运算符会返回新值。前置和后置递增/递减按照通常的 C/C++约定工作:后置递增/递减执行操作,但返回旧值,而前置递增/递减运算符执行操作并返回新值。下面的简单示例可以很容易地演示这些操作的规范:

int main() 
{ 
std::atomic<int> value; 

std::cout << "Result returned from Operation: " << value.fetch_add(5) << 'n'; 
std::cout << "Result after Operation: " << value << 'n'; 

std::cout << "Result returned from Operation: " << value.fetch_sub(3) << 'n'; 
std::cout << "Result after Operation: " << value << 'n'; 

std::cout << "Result returned from Operation: " << value++ << 'n'; 
std::cout << "Result after Operation: " << value << 'n'; 

std::cout << "Result returned from Operation: " << ++value << 'n'; 
std::cout << "Result after Operation: " << value << 'n'; 

value += 1; 
std::cout << "Result after Operation: " << value << 'n'; 

value -= 1; 
std::cout << "Result after Operation: " << value << 'n'; 
} 

此代码的输出应如下所示:

Result returned from Operation: 0 
Result after Operation: 5 
Result returned from Operation: 5 
Result after Operation: 2 
Result returned from Operation: 2 
Result after Operation: 3 
Result returned from Operation: 4 
Result after Operation: 4 
Result after Operation: 5 
Result after Operation: 4 

除了std::atomic_flagstd::atomic<bool>之外,第一张表中列出的所有其他原子类型都是原子整数类型。现在,让我们来看一下原子指针特化,std::atomic<T*>

std::atomic<T*> – 指针算术

除了通常的操作,比如load()store()exchange()compare_exchange_weak()compare_exchange_strong()之外,原子指针类型还加载了指针算术操作。成员函数fetch_add()fetch_sub()提供了对类型进行原子加法和减法的操作支持,运算符+=-=,以及前置和后置递增/递减,使用++--运算符。

运算符的工作方式与标准的非原子指针算术运算相同。如果objstd::atomic<some_class*>,则对象指向some_class对象数组的第一个条目。obj+=2将其更改为指向数组中的第三个元素,并返回一个指向数组中第三个元素的some_class*的原始指针。如标准原子整数类型部分所讨论的,诸如fetch_add()fetch_sub之类的命名函数在原子类型上执行操作,但返回数组中第一个元素的指针。

原子操作的函数形式还允许在函数调用的附加参数中指定内存排序语义:

obj.fetch_add(3, std::memory_order_release);

由于fetch_add()fetch_sub都是读取-修改-写操作,它们可以在标准原子库中使用任何内存排序语义。但是,对于操作符形式,无法指定内存排序,因此这些操作符将始终具有memory_order_seq_cst语义。

std::atomic<>主类模板

标准库中的主要类模板允许用户创建用户定义类型UDT)的原子变体。要将用户定义类型用作原子类型,您必须在实现类之前遵循一些标准。对于用户定义类 UDT,如果该类型具有平凡的复制赋值运算符,则std::atomic<UDT>是可能的。这意味着用户定义类不应包含任何虚函数或虚基类,并且必须使用编译器生成的默认复制赋值运算符。此外,用户定义类的每个基类和非静态数据成员必须具有平凡的复制赋值运算符。这使得编译器可以执行memcpy()或等效的操作以进行赋值操作,因为没有用户编写的代码需要执行。

除了赋值运算符的要求之外,用户定义类型必须是位相等可比的。这意味着您必须能够使用memcmp()比较实例是否相等。这个保证是必需的,以确保比较/交换操作能够正常工作。

对于具有用户定义类型T的标准原子类型的实例,即std::atomic<T>,接口仅限于std::atomic<bool>可用的操作:load()store()exchange()compare_exchange_weak()compare_exchange_strong()和对类型T的实例的赋值和转换。

内存排序

我们已经了解了标准库中可用的原子类型和原子操作。在对原子类型执行操作时,我们需要为某些操作指定内存排序。现在,我们将讨论不同内存排序语义的重要性和用例。原子操作背后的关键思想是在多个线程之间提供数据访问的同步,并通过强制执行执行顺序来实现这一点。例如,如果写入数据发生在读取数据之前,那么一切都会很好。否则,你就麻烦了!标准库提供了六种内存排序选项,可应用于原子类型的操作:memory_order_relaxedmemory_order_consumememory_order_acquirememory_order_releasememory_order_acq_relmemory_order_seq_cst。对于所有原子类型的原子操作,memory_order_seq_cst是默认的内存顺序,除非您指定其他内容。

这六个选项可以分为三类:

  • 顺序一致排序:memory_order_seq_cst

  • 获取-释放排序memory_order_consumememory_order_releasememory_order_acquirememory_order_acq_rel

  • 松散排序memory_order_relaxed

执行成本因不同的 CPU 和不同的内存排序模型而异。不同的内存排序模型的可用性允许专家利用比阻塞顺序一致排序更精细的排序关系来提高性能,但是要选择适当的内存模型,就应该了解这些选项如何影响程序的行为。让我们首先看看顺序一致性模型。

顺序一致性

顺序一致性的概念是由 Leslie Lamport 在 1979 年定义的。顺序一致性在程序执行中提供了两个保证。首先,程序的指令的内存排序按照源代码顺序执行,或者编译器将保证源代码顺序的幻觉。然后,所有线程中所有原子操作的全局顺序。

对于程序员来说,顺序一致性的全局排序行为,即所有线程中的所有操作都在全局时钟中发生,是一个有趣的高地,但也是一个缺点。

关于顺序一致性的有趣之处在于,代码按照我们对多个并发线程的直觉工作,但系统需要做大量的后台工作。以下程序是一个简单的示例,让我们了解顺序一致性:

std::string result; 
std::atomic<bool> ready(false); 

void thread1() 
{ 
    while(!ready.load(std::memory_order_seq_cst)); 
    result += "consistency"; 
} 

void thread2() 
{ 
    result = "sequential "; 
    ready=true; 
} 

int main() 
{ 
    std::thread t1(thread1); 
    std::thread t2(thread2); 
    t1.join(); 
    t2.join(); 

    std::cout << "Result : " << result << 'n'; 
} 

前面的程序使用顺序一致性来同步线程thread1thread2。由于顺序一致性,执行是完全确定的,因此该程序的输出始终如下:

Result : sequential consistency 

在这里,thread1在 while 循环中等待,直到原子变量readytrue。一旦thread2中的ready变为truethread1就会继续执行,因此结果总是以相同的顺序更新字符串。顺序一致性的使用允许两个线程以相同的顺序看到其他线程的操作,因此两个线程都遵循相同的全局时钟。循环语句还有助于保持两个线程的同步的时间时钟。

获取-释放语义的细节将在下一节中介绍。

获取-释放排序

现在,让我们深入研究 C++标准库提供的内存排序语义。这是程序员对多线程代码中排序的直觉开始消失的地方,因为在原子操作的获取-释放语义中,线程之间没有全局同步。这些语义只允许在同一原子变量上的原子操作之间进行同步。简而言之,一个线程上的原子变量的加载操作可以与另一个线程上同一原子变量的存储操作进行同步。程序员必须提取这个特性,建立原子变量之间的happen-before关系,以实现线程之间的同步。这使得使用获取-释放模型有点困难,但同时也更加刺激。获取-释放语义缩短了通向无锁编程的道路,因为你不需要担心线程的同步,但需要思考的是不同线程中相同原子变量的同步。

正如我们之前解释的,获取-释放语义的关键思想是在同一原子变量上的释放操作与获取操作之间的同步,并建立一个ordering constant。现在,顾名思义,获取操作涉及获取锁,其中包括用于读取原子变量的操作,如load()test_and_set()函数。因此,释放锁是一个释放操作,其中包括store()clear()等原子操作。

换句话说,mutex的锁是一个获取操作,而解锁是一个释放操作。因此,在临界区中,对变量的操作不能在任何方向上进行。但是,变量可以从外部移入临界区,因为变量从一个未受保护的区域移动到了一个受保护的区域。这在下图中表示:

临界区包含单向屏障:获取屏障和释放屏障。相同的推理也可以应用于启动线程和在线程上放置 join 调用,以及标准库中提供的所有其他同步原语相关的操作。

由于同步是在原子变量级别而不是线程级别进行的,让我们重新审视一下使用std::atomic_flag实现的自旋锁:

class spin_lock 
{ 
    std::atomic_flag flg; 

public: 
    spin_lock() : flg(ATOMIC_FLAG_INIT) 
    {} 

    void lock() 
    { 
        // acquire lock and spin 
        while (flg.test_and_set(std::memory_order_acquire)); 
    } 

    void unlock() 
    { 
        // release lock 
        flg.clear(std::memory_order_release); 
    } 
}; 

在这段代码中,lock()函数是一个acquire操作。现在不再使用前一个示例中使用的默认顺序一致的内存排序,而是现在使用了显式的 acquire 内存排序标志。此外,unlock()函数,也是一个释放操作,之前也是使用默认的内存顺序,现在已经被替换为显式的释放语义。因此,两个线程的顺序一致的重量级同步被轻量级和高性能的 acquire-release 语义所取代。

当使用spin_lock的线程数量超过两个时,使用std::memory_order_acquire的一般获取语义将不足够,因为锁方法变成了一个获取-释放操作。因此,内存模型必须更改为std::memory_order_acq_rel

到目前为止,我们已经看到顺序一致的排序确保了线程之间的同步,而获取-释放排序在多个线程上确立了对同一原子变量的读写操作的顺序。现在,让我们看一下松散内存排序的规范。

松散排序

使用标签std::memory_order_relaxed进行松散内存排序的原子类型的操作不是同步操作。与标准库中提供的其他排序选项相比,它们不会对并发内存访问施加顺序。松散内存排序语义只保证同一线程内相同原子类型的操作不能被重新排序,这个保证被称为修改顺序一致性。事实上,松散排序只保证了原子性和修改顺序一致性。因此,其他线程可以以不同的顺序看到这些操作。

松散内存排序可以有效地用于不需要同步或排序的地方,并且原子性可以成为性能提升的一个优势。一个典型的例子是增加计数器,比如std::shared_ptr的引用计数器,它们只需要原子性。但是减少引用计数需要与这个模板类的析构函数进行获取-释放同步。

让我们看一个简单的例子来计算使用松散排序生成的线程数量:

std::atomic<int> count = {0}; 

void func() 
{ 
    count.fetch_add(1, std::memory_order_relaxed); 
} 

int main() 
{ 
    std::vector<std::thread> v; 
    for (int n = 0; n < 10; ++n) 
    { 
        v.emplace_back(func); 
    } 
    for (auto& t : v) 
    { 
        t.join(); 
    } 

    std::cout << "Number of spawned threads : " << count << 'n'; 
} 

在这段代码中,从main()函数生成了十个线程,每个线程都使用线程函数func(),在每个线程上,使用原子操作fetch_add()将原子整数值增加一。与std::atomic<int>提供的复合赋值运算符和后置和前置递增运算符相反,fetch_add()函数可以接受内存排序参数,它是std::memory_order_relaxed

程序打印出程序中生成的线程数量如下:

Number of spawned threads : 10 

程序的输出对于任何其他相关的内存排序标签都是相同的,但是松散的内存排序确保了原子性,从而提高了性能。

到目前为止,我们已经讨论了不同内存模型的级别,以及它们对原子和非原子操作的影响。现在,让我们深入研究使用原子操作实现无锁数据结构。

无锁数据结构队列

正如我们已经知道的,实际系统中的数据通常以数据结构的形式表示,当涉及到数据结构的并发操作时,性能是一个大问题。在第三章中,C++中的语言级并发和并行性,我们学习了如何编写一个线程安全的栈。然而,我们使用了锁和条件变量来实现它。为了解释如何编写一个无锁数据结构,让我们使用生产者/消费者范式来编写一个非常基本的队列系统,而不使用锁或条件变量。这肯定会提高代码的性能。我们不使用标准数据类型的包装器,而是从头开始编写。我们假设在这种情况下有一个生产者和一个消费者:

template<typename T> 
class Lock_free_Queue 
{ 
private: 
    struct Node 
    { 
        std::shared_ptr<T> my_data; 
        Node* my_next_node; 
        Node() : my_next_node(nullptr) 
        {} 
    }; 

    std::atomic<Node*> my_head_node; 
    std::atomic<Node*> my_tail_node; 

    Node* pop_head_node() 
    { 
        Node* const old_head_node = my_head_node.load(); 
        if(old_head_node == my_tail_node.load()) 
        { 
            return nullptr; 
        } 
        my_head_node.store(old_head_node->my_next_node); 
        return old_head_node; 
    } 

Lock_free_stack类包含一个用于表示队列节点的结构(命名为Node),其中包含用于表示节点数据(my_data)和指向下一个节点的指针的数据成员。然后,该类包含两个原子指针实例,指向用户定义的结构Node,该结构已在类内部定义。一个实例存储队列头节点的指针,而另一个指向尾节点。最后,使用private pop_head_node()函数通过调用原子store操作来检索队列的头节点,但仅当队列包含至少一个元素时。在这里,原子操作遵循默认的顺序一致的内存排序语义:

public: 
Lock_free_Queue() : my_head_node(new Node), my_tail_node(my_head_node.load()) 
    {} 
    Lock_free_Queue(const Lock_free_Queue& other) = delete; 
    Lock_free_Queue& operator= (const Lock_free_Queue& other) = delete; 

    ~Lock_free_Queue() 
    { 
        while(Node* const old_head_node = my_head_node.load()) 
        { 
            my_head_node.store(old_head_node->my_next_node); 
            delete old_head_node; 
        } 
    }

头节点在队列对象构造时被实例化,并且尾部指向该内存。复制构造函数和复制赋值运算符被标记为删除,以防止它们被使用。在析构函数内,队列中的所有元素都被迭代删除:

    std::shared_ptr<T> dequeue() 
    { 
        Node* old_head_node = pop_head_node(); 
        if(!old_head_node) 
        { 
            return std::shared_ptr<T>(); 
        } 
        std::shared_ptr<T> const result(old_head_node->my_data); 
        delete old_head_node; 
        return result; 
    } 

    void enqueue(T new_value) 
    { 
        std::shared_ptr<T> new_data(std::make_shared<T>(new_value)); 
        Node* p = new Node; 
        Node* const old_tail_node = my_tail_node.load(); 
        old_tail_node->my_data.swap(new_data); 
        old_tail_node->my_next_node = p; 
        my_tail_node.store(p); 
    } 
}; 

前面的代码片段实现了标准队列操作,即 Enqueue 和 Dequeue。在这里,我们使用了 swap 和 store 原子操作,确保 Enqueue 和 Dequeue 之间存在happens before关系。

摘要

在本章中,我们讨论了标准库提供的用于编写基于任务的并行性的工具。我们看到了如何使用std::packaged_taskstd::async与 futures 和 promises。我们讨论了现代 C++语言提供的新的多线程感知内存模型。之后,我们讨论了原子类型及其相关操作。我们学到的最重要的事情是语言的各种内存排序语义。简而言之,这一章和前一章将使我们能够推理响应式编程模型的并发方面。

在接下来的章节中,我们将把注意力从语言和并发转移到响应式编程模型的标准接口。我们将介绍 Observables!

第五章:Observables 的介绍

在最后三章中,我们学习了现代 C++的语言特性:多线程、无锁编程模型等。那里涵盖的主题可以被视为开始学习响应式编程模型的先决条件。响应式编程模型需要掌握函数式编程、并发编程、调度器、对象/函数式编程、设计模式和事件流处理等技能。我们已经在上一章中涵盖或涉及了函数式编程、对象/函数式编程以及与调度相关的一些主题。这次,我们将涵盖设计模式的精彩世界,以理解响应式编程的要点以及特别是 Observables。在下一章中,我们将在跳入 RxCpp 库之前处理事件流编程的主题。设计模式运动随着一本名为设计模式:可复用面向对象软件的元素的书籍的出版而达到了临界质量,这本书由四人帮GoF)编写,其中列出了一组分为创建型、结构型和行为型家族的 23 种模式。GoF 目录将观察者模式定义为行为模式的一种。我们想要在这里传达的一个关键信息是,通过了解可敬的 GoF 模式,可以理解响应式编程模型。在本章中,我们将涵盖:

  • GoF 观察者模式

  • GoF 观察者模式的局限性

  • 对设计模式和 Observables 进行全面审视

  • 使用复合设计模式对建模现实世界的层次结构

  • 使用访问者对复合物进行行为处理

  • 将复合物扁平化并通过迭代器模式进行导航

  • 通过改变视角,从迭代器转换为 Observable/Observer!

GoF 观察者模式

GoF 观察者模式在 GoF 书中也被称为发布-订阅模式。这个想法很简单。EventSource(发出事件的类)将与事件接收器(监听事件通知的类)建立一对多的关系。每个EventSource都将有一个机制,让事件接收器订阅以获取不同类型的通知。单个EventSource可能会发出多个事件。当EventSource的状态发生变化或其领域发生重大事件时,它可以向成千上万的订阅者(事件接收器或监听器)发送通知。EventSource将遍历订阅者列表并逐个通知它们。GoF 书是在世界大多数时间都在进行顺序编程的时候编写的。诸如并发性之类的主题大多与特定于平台的库或POSIX线程库相关。我们将编写一个简单的 C++程序来演示观察者模式的整个思想。目的是快速理解观察者模式,鲁棒性等想法被次要地给予了优先级。这个清单是自包含的并且容易理解的:

//-------------------- Observer.cpp 
#include <iostream> 
#include  <vector> 
#include <memory> 
using namespace std; 
//---- Forward declaration of event sink 
template<class T> 
class EventSourceValueObserver; 
//----------A toy implementation of EventSource
template<class T> 
class EventSourceValueSubject{ 
   vector<EventSourceValueObserver<T> *> sinks;  
   T State; // T is expected to be a value type 
  public: 
   EventSourceValueSubject() { State = 0; } 
   ~EventSourceValueSubject() { 
       sinks.clear(); 
   } 
   bool Subscribe( EventSourceValueObserver<T> *sink ) { sinks.push_back(sink);} 
   void NotifyAll() { for (auto sink : sinks) { sink->Update(State); }} 
   T GetState() { return State; } 
   void SetState(T pstate) { State = pstate; NotifyAll(); } 
};

上面的代码片段实现了一个微不足道的EventSource,它可以潜在地存储一个整数值作为状态。在现代 C++中,我们可以使用类型特征来检测消费者是否已经用整数类型实例化了这个类。由于我们的重点是阐明,我们没有添加与类型约束相关的断言。在下一个 C++标准中,有一个称为concept(在其他语言中称为约束)的概念,将有助于直接强制执行这一点(而不需要类型特征)。在现实生活中,EventSource可能存储大量变量或值流。对它们的任何更改都将广播给所有订阅者。在SetState方法中,当EventSource类的消费者(事件接收器本身是这个类中的消费者)改变状态时,NotifyAll()方法将被触发。NotifyAll()方法通过接收器列表工作,并调用Update()方法。然后,事件接收器可以执行特定于其上下文的任务。我们没有实现取消订阅等方法,以便专注于核心问题:

//--------------------- An event sink class for the preceding EventSources 
template <class T> 
class EventSourceValueObserver{ 
    T OldState; 
  public: 
    EventSourceValueObserver() { OldState = 0; } 
    virtual ~EventSorceValueObserver() {} 
    virtual void Update( T State ) { 
       cout << "Old State " << OldState << endl; 
       OldState = State; 
       cout << "Current State " << State << endl;  
    } 
}; 

EventSourceValueObserver类已经实现了Update方法来执行与其上下文相关的任务。在这里,它只是将旧状态和当前状态的值打印到控制台上。在现实生活中,接收器可能会修改 UX 元素或通过通知将状态的传播传递给其他对象。让我们再写一个事件接收器,它将继承自EventSourceValueObserver

//------------ A simple specialized Observe 
class AnotherObserver : public EventSourceValueObserver<double> { 
  public: 
    AnotherObserver():EventSourceValueObserver() {} 
    virtual ~AnotherObserver() {} 
    virtual void Update( double State )  
    { cout << " Specialized Observer" << State <<  endl; } 
};

我们为演示目的实现了观察者的专门版本。这样做是为了表明我们可以有两个类的实例(可以从EventSourceObserver<T>继承)作为订阅者。在这里,当我们从EventSource收到通知时,我们也不做太多事情:

int main() { 
   unique_ptr<EventSourceValueSubject<double>> 
                 evsrc(new EventSourceValueSubject<double>()); 
    //---- Create Two instance of Observer and Subscribe 
   unique_ptr<AnotherObserver> evobs( new AnotherObserver());
   unique_ptr<EventSourceValueObserver<double>> 
               evobs2( new EventSourceValueObserver<double>());
   evsrc->Subscribe( evobs.get() );
   evsrc->Subscribe( evobs2.get());
   //------ Change the State of the EventSource 
   //------ This should trigger call to Update of the Sink 
   evsrc->SetState(100); 
} 

上面的代码片段实例化了一个EventSource对象并添加了两个订阅者。当我们改变EventSource的状态时,订阅者将收到通知。这是观察者模式的关键。在普通的面向对象编程程序中,对象的消费是以以下方式进行的:

  1. 实例化对象

  2. 调用方法计算某个值或改变状态

  3. 根据返回值或状态变化执行有用的操作

在这里,在观察者的情况下,我们已经做了以下工作:

  1. 实例化对象(EventSource

  2. 通过实现观察者(用于事件监听)进行通知订阅

  3. EventSource发生变化时,您将收到通知

  4. 对通过通知接收到的值执行某些操作

这里概述的Method函数有助于关注点的分离,并实现了模块化。这是实现事件驱动代码的良好机制。与其轮询事件,不如要求被通知。大多数 GUI 工具包今天都使用类似的范例。

GoF 观察者模式的局限性

GoF 模式书是在世界真正进行顺序编程的时候编写的。从当前的编程模型世界观来看,观察者模式实现的架构有很多异常。以下是其中一些:

  • 主题和观察者之间的紧密耦合。

  • EventSource的生命周期由观察者控制。

  • 观察者(接收器)可以阻塞EventSource

  • 实现不是线程安全的。

  • 事件过滤是在接收器级别进行的。理想情况下,数据应该在数据所在的地方(在通知之前的主题级别)进行过滤。

  • 大多数时候,观察者并不做太多事情,CPU 周期将被浪费。

  • EventSource理想上应该将值发布到环境中。环境应该通知所有订阅者。这种间接层次可以促进诸如事件聚合、事件转换、事件过滤和规范化事件数据等技术。

随着不可变变量、函数式组合、函数式风格转换、无锁并发编程等功能编程技术的出现,我们可以规避经典 Observer 模式的限制。行业提出的解决方案是 Observables 的概念。

在经典 Observer 模式中,一个勤奋的读者可能已经看到了异步编程模型被整合的潜力。EventSource可以对订阅者方法进行异步调用,而不是顺序循环订阅者。通过使用一种“发射并忘记”的机制,我们可以将EventSource与其接收器解耦。调用可以从后台线程、异步任务或打包任务,或适合上下文的合适机制进行。通知方法的异步调用具有额外的优势,即如果任何客户端阻塞(进入无限循环或崩溃),其他客户端仍然可以收到通知。异步方法遵循以下模式:

  1. 定义处理数据、异常和数据结束的方法(在事件接收器方面)

  2. Observer(事件接收器)接口应该有OnDataOnErrorOnCompleted方法

  3. 每个事件接收器应该实现 Observer 接口

  4. 每个EventSource(Observable)应该有订阅和取消订阅的方法

  5. 事件接收器应该通过订阅方法订阅 Observable 的实例

  6. 当事件发生时,Observable 会通知 Observer

这些事情有些已经在第一章中提到过,响应式编程模型-概述和历史。当时我们没有涉及异步部分。在本章中,我们将重新审视这些想法。根据作者们在技术演示和与开发人员的互动中积累的经验,直接跳入编程的 Observable/Observer 模型并不能帮助理解。大多数开发人员对 Observable/Observer 感到困惑,因为他们不知道这种模式解决了什么特定的问题。这里给出的经典 GoF Observer 实现是为了为 Observable Streams 的讨论设定背景。

对 GoF 模式的整体观察

设计模式运动始于一个时期,当时世界正在努力应对面向对象软件设计方法的复杂性。GoF 书籍和相关的模式目录为开发人员提供了一套设计大型系统的技术。诸如并发和并行性之类的主题并不在设计目录的设计者们的考虑之中。(至少,他们的工作没有反映出这一点!)

我们已经看到,通过经典 Observer 模式进行事件处理存在一些局限性,这在某些情况下可能是个问题。有什么办法?我们需要重新审视事件处理的问题,退一步。我们将稍微涉及一些哲学的主题,以不同的视角看待响应式编程模型(使用 Observable Streams 进行编程!)试图解决的问题。我们的旅程将帮助我们从 GOF 模式过渡到使用函数式编程构造的响应式编程世界。

本节中的内容有些抽象,并且是为了提供一个概念性背景,从这个背景中,本书的作者们接触了本章涵盖的主题。我们解释 Observables 的方法是从 GoF Composite/Visitor 模式开始,逐步达到 Observables 的主题。这种方法的想法来自一本关于阿德瓦伊塔·维丹塔(Advaita Vedanta)的书,这是一种起源于印度的神秘哲学传统。这个主题已经用西方哲学术语解释过。如果某个问题看起来有点抽象,可以随意忽略它。

Nataraja Guru(1895-1973)是一位印度哲学家,他是阿德瓦伊塔维达塔哲学的倡导者,这是一所基于至高力量的非二元论的印度哲学学派。根据这个哲学学派,我们周围所看到的一切,无论是人类、动物还是植物,都是绝对(梵文中称为婆罗门)的表现,它唯一的积极肯定是 SAT-CHIT-ANAND(维达塔哲学使用否定和反证来描述婆罗门)。这可以被翻译成英语为存在、本质和幸福(这里幸福的隐含含义是“好”)。在 DK Print World 出版的一本名为《统一哲学》的书中,他将 SAT-CHIT-ANAND 映射到本体论、认识论和价值论(哲学的三个主要分支)。以下表格给出了 SAT-CHIT-ANAND 可能与其他意义相近的实体的映射。

SAT CHIT ANAND
存在 本质 幸福
本体论 认识论 价值论
我是谁? 我能知道什么? 我应该做什么?
结构 行为 功能

在 Vedanta(阿德瓦伊塔学派)哲学中,整个世界被视为存在、本质和幸福。从表中,我们将软件设计世界中的问题映射为结构、行为和功能的问题。世界上的每个系统都可以从结构、行为和功能的角度来看待。面向对象程序的规范结构是层次结构。我们将感兴趣的世界建模为层次结构,并以规范的方式处理它们。GOF 模式目录中有组合模式(结构)用于建模层次结构和访问者模式(行为)用于处理它们。

面向对象编程模型和层次结构

这一部分在概念上有些复杂,那些没有涉足过 GoF 设计模式的人可能会觉得有些困难。最好的策略可能是跳过这一部分,专注于运行示例。一旦理解了运行示例,就可以重新访问这一部分。

面向对象编程非常擅长建模层次结构。事实上,层次结构可以被认为是面向对象数据处理的规范数据模型。在 GoF 模式世界中,我们使用组合模式来建模层次结构。组合模式被归类为结构模式。每当使用组合模式时,访问者模式也将成为系统的一部分。访问者模式适用于处理组合以向结构添加行为。访问者/组合模式在现实生活中成对出现。当然,组合的一个实例可以由不同的访问者处理。在编译器项目中,抽象语法树AST)将被建模为一个组合,并且将有访问者实现用于类型检查、代码优化、代码生成和静态分析等。

访问者模式的问题之一是它必须对组合的结构有一定的概念才能进行处理。此外,在需要处理组合层次结构中可用数据的筛选子集的上下文中,它将导致代码膨胀。我们可能需要为每个过滤条件使用不同的访问者。GoF 模式目录中还有另一个属于行为类别的模式,称为 Iterator,这是每个 C++程序员都熟悉的东西。Iterator 模式擅长以结构无关的方式处理数据。任何层次结构都必须被线性化或扁平化,以便被 Iterator 处理。例如,树可以使用 BFS Iterator 或 DFS Iterator 进行处理。对于应用程序员来说,树突然变成了线性结构。我们需要将层次结构扁平化,使其处于适合 Iterator 处理的状态。这个过程将由实现 API 的人来实现。Iterator 模式也有一些局限性(它是基于拉的),我们将通过一种称为 Observable/Observer 的模式将系统改为基于推的。这一部分有点抽象,但在阅读整个章节后,你可以回来理解发生了什么。简而言之,我们可以总结整个过程如下:

  • 我们可以使用组合模式来建模层次结构

  • 我们可以使用 Visitor 模式处理组合

  • 我们可以通过 Iterator 来展开或线性化组合

  • Iterators 遵循拉取方法,我们需要为基于推的方案逆转视线

  • 现在,我们已经成功地实现了 Observable/Observer 的方式来实现事物

  • Observables 和 Iterators 是二进制对立的(一个人的推是另一个人的拉!)

我们将实现所有前述观点,以对 Observables 有牢固的基础。

用于表达式处理的组合/访问者模式

为了演示从 GoF 模式目录到 Observables 的过程,我们将模拟一个四则运算计算器作为一个运行示例。由于表达式树或 AST 本质上是层次结构的,它们将是一个很好的例子,可以作为组合模式的模型。我们故意省略了编写解析器,以保持代码清单的简洁:

#include <iostream> 
#include <memory> 
#include <list> 
#include <stack> 
#include <functional> 
#include <thread> 
#include <future> 
#include <random> 
#include "FuncCompose.h" // available int the code base 
using namespace std; 
//---------------------List of operators supported by the evaluator 
enum class OPERATOR{ ILLEGAL,PLUS,MINUS,MUL,DIV,UNARY_PLUS,UNARY_MINUS };  

我们定义了一个枚举类型来表示四个二元运算符(+-*/)和两个一元运算符(+-)。除了标准的 C++头文件,我们还包含了一个自定义头文件(FuncCompose.h),它可以在与本书相关的 GitHub 存储库中找到。它包含了 Compose 函数和管道运算符(|)的代码,用于函数组合。我们可以使用 Unix 管道风格的组合来将一系列转换联系在一起:

//------------ forward declarations for the Composites  
class Number;  //----- Stores IEEE double precision floating point number  
class BinaryExpr; //--- Node for Binary Expression 
class UnaryExpr;  //--- Node for Unary Expression 
class IExprVisitor; //---- Interface for the Visitor  
//---- Every node in the expression tree will inherit from the Expr class 
class Expr { 
  public: 
   //---- The standard Visitor double dispatch method 
   //---- Normally return value of accept method are void.... and Concrete
   //---- classes store the result which can be retrieved later
   virtual double accept(IExprVisitor& expr_vis) = 0; 
   virtual ~Expr() {} 
}; 
//----- The Visitor interface contains methods for each of the concrete node  
//----- Normal practice is to use 
struct IExprVisitor{ 
   virtual  double Visit(Number& num) = 0; 
   virtual  double Visit(BinaryExpr& bin) = 0; 
   virtual  double Visit(UnaryExpr& un)=0 ; 
}; 

Expr 类将作为表达式树中所有节点的基类。由于我们的目的是演示组合/访问者 GoF 模式,我们只支持常数、二元表达式和一元表达式。Expr 类中的 accept 方法接受一个 Visitor 引用作为参数,方法的主体对所有节点都是相同的。该方法将把调用重定向到 Visitor 实现上的适当处理程序。为了更深入地了解本节涵盖的整个主题,通过使用您喜欢的搜索引擎搜索双重分派Visitor 模式

Visitor 接口(IExprVisitor)包含处理层次结构支持的所有节点类型的方法。在我们的情况下,有处理常数、二元运算符和一元运算符的方法。让我们看看节点类型的代码。我们从 Number 类开始:

//---------A class to represent IEEE 754 interface 
class Number : public Expr { 
   double NUM; 
  public: 
   double getNUM() { return NUM;}    
   void setNUM(double num)   { NUM = num; } 
   Number(double n) { this->NUM = n; } 
   ~Number() {} 
   double accept(IExprVisitor& expr_vis){ return expr_vis.Visit(*this);} 
}; 

Number 类封装了 IEEE 双精度浮点数。代码很明显,我们需要关心的只是accept方法的内容。该方法接收一个visitor类型的参数(IExprVisitor&)。该例程只是将调用反映到访问者实现的适当节点上。在这种情况下,它将在IExpressionVisitor上调用Visit(Number&)

//-------------- Modeling Binary Expresison  
class BinaryExpr : public Expr { 
   Expr* left; Expr* right; OPERATOR OP; 
  public: 
   BinaryExpr(Expr* l,Expr* r , OPERATOR op ) { left = l; right = r; OP = op;} 
   OPERATOR getOP() { return OP; } 
   Expr& getLeft() { return *left; } 
   Expr& getRight() { return *right; } 
   ~BinaryExpr() { delete left; delete right;left =0; right=0; } 
   double accept(IExprVisitor& expr_vis) { return expr_vis.Visit(*this);} 
};  

BinaryExpr类模拟了具有左右操作数的二元运算。操作数可以是层次结构中的任何类。候选类包括NumberBinaryExprUnaryExpr。这可以到任意深度。在我们的情况下,终端节点是 Number。先前的代码支持四个二元运算符:

//-----------------Modeling Unary Expression 
class UnaryExpr : public Expr { 
   Expr * right; OPERATOR op; 
  public: 
   UnaryExpr( Expr *operand , OPERATOR op ) { right = operand;this-> op = op;} 
   Expr& getRight( ) { return *right; } 
   OPERATOR getOP() { return op; } 
   virtual ~UnaryExpr() { delete right; right = 0; } 
   double accept(IExprVisitor& expr_vis){ return expr_vis.Visit(*this);} 
};  

UnaryExpr方法模拟了带有运算符和右侧表达式的一元表达式。我们支持一元加和一元减。右侧表达式可以是UnaryExprBinaryExprNumber。现在我们已经为所有支持的节点类型编写了实现,让我们专注于访问者接口的实现。我们将编写一个树遍历器和评估器来计算表达式的值:

//--------An Evaluator for Expression Composite using Visitor Pattern  
class TreeEvaluatorVisitor : public IExprVisitor{ 
  public: 
   double Visit(Number& num){ return num.getNUM();} 
   double Visit(BinaryExpr& bin) { 
     OPERATOR temp = bin.getOP(); double lval = bin.getLeft().accept(*this); 
     double rval = bin.getRight().accept(*this); 
     return (temp == OPERATOR::PLUS) ? lval + rval: (temp == OPERATOR::MUL) ?  
         lval*rval : (temp == OPERATOR::DIV)? lval/rval : lval-rval;   
   } 
   double Visit(UnaryExpr& un) { 
     OPERATOR temp = un.getOP(); double rval = un.getRight().accept(*this); 
     return (temp == OPERATOR::UNARY_PLUS)  ? +rval : -rval; 
   } 
};

这将对 AST 进行深度优先遍历,并递归评估节点。让我们编写一个表达式处理器(IExprVisitor的实现),它将以逆波兰表示法RPN)形式将表达式树打印到控制台上:

//------------A Visitor to Print Expression in RPN
class ReversePolishEvaluator : public IExprVisitor {
    public:
    double Visit(Number& num){cout << num.getNUM() << " " << endl; return 42;}
    double Visit(BinaryExpr& bin){
        bin.getLeft().accept(*this); bin.getRight().accept(*this);
        OPERATOR temp = bin.getOP();
        cout << ( (temp==OPERATOR::PLUS) ? " + " :(temp==OPERATOR::MUL) ?
        " * " : (temp == OPERATOR::DIV) ? " / ": " - " ) ; return 42;
    }
    double Visit(UnaryExpr& un){
        OPERATOR temp = un.getOP();un.getRight().accept(*this);
        cout << (temp == OPERATOR::UNARY_PLUS) ?" (+) " : " (-) "; return 42;
    }
};

RPN 表示法也称为后缀表示法,其中运算符位于操作数之后。它们适合使用评估堆栈进行处理。它们构成了 Java 虚拟机和.NET CLR 所利用的基于堆栈的虚拟机架构的基础。现在,让我们编写一个主函数将所有内容整合在一起:

int main( int argc, char **argv ){ 
     unique_ptr<Expr>   
            a(new BinaryExpr( new Number(10) , new Number(20) , OPERATOR::PLUS)); 
     unique_ptr<IExprVisitor> eval( new TreeEvaluatorVisitor()); 
     double result = a->accept(*eval); 
     cout << "Output is => " << result << endl; 
     unique_ptr<IExprVisitor>  exp(new ReversePolishEvaluator()); 
     a->accept(*exp); 
}

此代码片段创建了一个组合的实例(BinaryExpr的一个实例),并实例化了TreeEvaluatorVisitorReversePolshEvaluator的实例。然后,调用 Expr 的accept方法开始处理。我们将在控制台上看到表达式的值和表达式的 RPN 等价形式。在本节中,我们学习了如何创建一个组合,并使用访问者接口处理组合。组合/访问者的其他潜在示例包括存储目录内容及其遍历、XML 处理、文档处理等。普遍观点认为,如果您了解组合/访问者二者,那么您已经很好地理解了 GoF 模式目录。

我们已经看到,组合模式和访问者模式作为一对来处理系统的结构和行为方面,并提供一些功能。访问者必须以一种假定了组合结构的认知方式编写。从抽象的角度来看,这可能是一个潜在的问题。层次结构的实现者可以提供一种将层次结构展平为列表的机制(在大多数情况下是可能的)。这将使 API 实现者能够提供基于迭代器的 API。基于迭代器的 API 也适用于函数式处理。让我们看看它是如何工作的。

展平组合以进行迭代处理

我们已经了解到,访问者模式必须了解复合体的结构,以便有人编写访问者接口的实例。这可能会产生一个称为抽象泄漏的异常。GoF 模式目录中有一个模式,将帮助我们以结构不可知的方式导航树的内容。是的,你可能已经猜对了:迭代器模式是候选者!为了使迭代器发挥作用,复合体必须被扁平化为列表序列或流。让我们编写一些代码来扁平化我们在上一节中建模的表达式树。在编写扁平化复合体的逻辑之前,让我们创建一个数据结构,将 AST 的内容作为列表存储。列表中的每个节点必须存储操作符或值,具体取决于我们是否需要存储操作符或操作数。我们为此描述了一个名为EXPR_ITEM的数据结构:

//////////////////////////// 
// A enum to store discriminator -> Operator or a Value? 
enum class ExprKind{  ILLEGAL_EXP,  OPERATOR , VALUE }; 
// A Data structure to store the Expression node. 
// A node will either be a Operator or Value 
struct EXPR_ITEM { 
    ExprKind knd; double Value; OPERATOR op; 
    EXPR_ITEM():op(OPERATOR::ILLEGAL),Value(0),knd(ExprKind::ILLEGAL_EXP){} 
    bool SetOperator( OPERATOR op ) 
    {  this->op = op;this->knd = ExprKind::OPERATOR; return true; } 
    bool SetValue(double value)  
    {  this->knd = ExprKind::VALUE;this->Value = value;return true;} 
    string toString() {DumpContents();return "";} 
   private: 
      void DumpContents() { //---- Code omitted for brevity } 
}; 

list<EXPR_ITEM>数据结构将以线性结构存储复合的内容。让我们编写一个类来扁平化复合体:

//---- A Flattener for Expressions 
class FlattenVisitor : public IExprVisitor { 
        list<EXPR_ITEM>  ils; 
        EXPR_ITEM MakeListItem(double num) 
        { EXPR_ITEM temp; temp.SetValue(num); return temp; } 
        EXPR_ITEM MakeListItem(OPERATOR op) 
        { EXPR_ITEM temp;temp.SetOperator(op); return temp;} 
        public: 
        list<EXPR_ITEM> FlattenedExpr(){ return ils;} 
        FlattenVisitor(){} 
        double Visit(Number& num){ 
           ils.push_back(MakeListItem(num.getNUM()));return 42; 
        } 
        double Visit(BinaryExpr& bin) { 
            bin.getLeft().accept(*this);bin.getRight().accept(*this); 
            ils.push_back(MakeListItem(bin.getOP()));return 42; 
        } 
         double Visit(UnaryExpr& un){ 
            un.getRight().accept(*this); 
            ils.push_back(MakeListItem(un.getOP())); return 42; 
        } 
};  

FlattenerVistor类将复合Expr节点扁平化为EXPR_ITEM列表。一旦复合体被线性化,就可以使用迭代器模式处理项目。让我们编写一个小的全局函数,将Expr树转换为list<EXPR_ITEM>

list<EXPR_ITEM> ExprList(Expr* r) { 
   unique_ptr<FlattenVisitor> fl(new FlattenVisitor()); 
    r->accept(*fl); 
    list<EXPR_ITEM> ret = fl->FlattenedExpr();return ret; 
 }

全局子例程ExprList将扁平化一个任意表达式树的EXPR_ITEM列表。一旦我们扁平化了复合体,我们可以使用迭代器来处理内容。在将结构线性化为列表后,我们可以使用堆栈数据结构来评估表达式数据以产生输出:

//-------- A minimal stack to evaluate RPN expression 
class DoubleStack : public stack<double> { 
   public: 
    DoubleStack() { } 
    void Push( double a ) { this->push(a);} 
    double Pop() { double a = this->top(); this->pop(); return a; } 
};  

DoubleStack是 STL 堆栈容器的包装器。这可以被视为一种帮助程序,以保持清单的简洁。让我们为扁平化表达式编写一个求值器。我们将遍历列表<EXPR_ITEM>并将值推送到堆栈中,如果遇到值的话。如果遇到操作符,我们将从堆栈中弹出值并应用操作。结果再次推入堆栈。在迭代结束时,堆栈中现有的元素将是与表达式相关联的值:

//------Iterator through eachn element of Expression list 
double Evaluate( list<EXPR_ITEM> ls) { 
   DoubleStack stk; double n; 
   for( EXPR_ITEM s : ls ) { 
     if (s.knd == ExprKind::VALUE) { stk.Push(s.Value); } 
     else if ( s.op == OPERATOR::PLUS) { stk.Push(stk.Pop() + stk.Pop());} 
     else if (s.op == OPERATOR::MINUS ) { stk.Push(stk.Pop() - stk.Pop());} 
     else if ( s.op ==  OPERATOR::DIV) { n = stk.Pop(); stk.Push(stk.Pop() / n);} 
     else if (s.op == OPERATOR::MUL) { stk.Push(stk.Pop() * stk.Pop()); } 
     else if ( s.op == OPERATOR::UNARY_MINUS) { stk.Push(-stk.Pop()); } 
    } 
   return stk.Pop(); 
} 
//-----  Global Function Evaluate an Expression Tree 
double Evaluate( Expr* r ) { return Evaluate(ExprList(r)); } 

让我们编写一个主程序,调用这个函数来评估表达式。求值器中的代码清单易于理解,因为我们正在减少一个列表。在基于树的解释器中,事情并不明显:

int main( int argc, char **argv ){      
     unique_ptr<Expr>
         a(new BinaryExpr( new Number(10) , new Number(20) , OPERATOR::PLUS)); 
     double result = Evaluate( &(*a)); 
     cout << result << endl; 
} 

列表上的 Map 和 Filter 操作

Map 是一个功能操作符,其中一个函数将被应用于列表。Filter 将对列表应用谓词并返回另一个列表。它们是任何功能处理管道的基石。它们也被称为高阶函数。我们可以编写一个通用的 Map 函数,使用std::transform用于std::liststd::vector

template <typename R, typename F> 
R Map(R r , F&& fn) { 
      std::transform(std::begin(r), std::end(r), std::begin(r), 
         std::forward<F>(fn)); 
      return r; 
} 

让我们还编写一个函数来过滤std::list(我们假设只会传递一个列表)。相同的方法也适用于std::vector。我们可以使用管道操作符来组合一个高阶函数。复合函数也可以作为谓词传递:

template <typename R, typename F> 
R Filter( R r , F&& fn ) { 
   R ret(r.size()); 
   auto first = std::begin(r), last = std::end(r) , result = std::begin(ret);  
   bool inserted = false; 
   while (first!=last) { 
    if (fn(*first)) { *result = *first; inserted = true; ++result; }  
    ++first; 
   } 
   if ( !inserted ) { ret.clear(); ret.resize(0); } 
   return ret; 
}

在这个 Filter 的实现中,由于std::copy_if的限制,我们被迫自己编写迭代逻辑。通常建议使用 STL 函数的实现来编写包装器。对于这种特殊情况,我们需要检测列表是否为空:

//------------------ Global Function to Iterate through the list  
void Iterate( list<EXPR_ITEM>& s ){ 
    for (auto n : s ) { std::cout << n.toString()  << 'n';} 
} 

让我们编写一个主函数将所有内容组合在一起。代码将演示如何在应用程序代码中使用MapFilter。功能组合和管道操作符的逻辑在FuncCompose.h中可用:

int main( int argc, char **argv ){ 
     unique_ptr<Expr>   
        a(new BinaryExpr( new Number(10.0) , new Number(20.0) , OPERATOR::PLUS)); 
      //------ExprList(Expr *) will flatten the list and Filter will by applied 
      auto cd = Filter( ExprList(&(*a)) , 
            [](auto as) {  return as.knd !=   ExprKind::OPERATOR;} ); 
      //-----  Square the Value and Multiply by 3... used | as composition Operator 
      //---------- See FuncCompose.h for details 
      auto cdr = Map( cd, [] (auto s ) {  s.Value *=3; return s; } |  
                  [] (auto s ) { s.Value *= s.Value; return s; } ); 
      Iterate(cdr);  
} 

Filter例程创建一个新的list<Expr>,其中只包含表达式中使用的值或操作数。Map例程在值列表上应用复合函数以返回一个新列表。

逆转注视可观察性!

我们已经学会了如何将复合转换为列表,并通过迭代器遍历它们。迭代器模式从数据源中提取数据,并在消费者级别操纵结果。我们面临的最重要的问题之一是我们正在耦合EventSource和事件接收器。GoF 观察者模式在这里也没有帮助。

让我们编写一个可以充当事件中心的类,事件接收器将订阅该类。通过拥有事件中心,我们现在将有一个对象,它将充当EventSource和事件接收器之间的中介。这种间接的一个优点很容易明显,即我们的类可以在到达消费者之前聚合、转换和过滤事件。消费者甚至可以在事件中心级别设置转换和过滤条件:

//----------------- OBSERVER interface 
struct  OBSERVER { 
    int id; 
    std::function<void(const double)> ondata; 
    std::function<void()> oncompleted; 
    std::function<void(const std::exception &)> onexception; 
}; 
//--------------- Interface to be implemented by EventSource 
struct OBSERVABLE { 
   virtual bool Subscribe( OBSERVER * obs ) = 0; 
    // did not implement unsuscribe  
}; 

我们已经在第一章中介绍了OBSERVABLEOBSERVER响应式编程模型-概述和历史和第二章,现代 C++及其关键习惯的概览EventSource实现了OBSERVABLE,事件接收器实现了OBSERVER接口。从OBSERVER派生的类将实现以下方法:

  • ondata(用于接收数据)

  • onexception(异常处理)

  • oncompleted(数据结束)

EventSource类将从OBSERVABLE派生,并且必须实现:

  • Subscribe(订阅通知)

  • Unsubscribe(在我们的情况下未实现)

//------------------A toy implementation of EventSource 
template<class T,class F,class M, class Marg, class Farg > 
class EventSourceValueSubject : public OBSERVABLE { 
   vector<OBSERVER> sinks;  
   T *State;  
   std::function<bool(Farg)> filter_func; 
   std::function<Marg(Marg)> map_func;

map_funcfilter_func是可以帮助我们在将值异步分派给订阅者之前转换和过滤值的函数。在实例化EventSource类时,我们将这些值作为参数给出。目前,我们已经根据假设编写了代码,即只有Expr对象将存储在EventSource中。我们可以有一个表达式的列表或向量,并将值流式传输给订阅者。为此,实现可以将标量值推送到监听器:

  public: 
   EventSourceValueSubject(Expr *n,F&& filter, M&& mapper) { 
       State = n; map_func = mapper; filter_func = filter; NotifyAll();  
   } 
   ~EventSourceValueSubject() {  sinks.clear(); } 
   //------ used Raw Pointer ...In real life, a shared_ptr<T>
   //------ is more apt here
   virtual  bool Subscribe( OBSERVER  *sink ) { sinks.push_back(*sink); return true;} 

我们做出了一些假设,即Expr对象将由调用者拥有。我们还省略了取消订阅方法的实现。构造函数接受一个Expr对象,一个Filter谓词(可以是使用|运算符的复合函数),以及一个Mapping函数(可以是使用|运算符的复合函数):

   void NotifyAll() { 
      double ret = Evaluate(State); 
      list<double> ls; ls.push_back(ret); 
      auto result = Map( ls, map_func);; // Apply Mapping Logic 
      auto resulttr = Filter( result,filter_func); //Apply Filter 
      if (resulttr.size() == 0 ) { return; } 

在评估表达式后,标量值将放入 STL 列表中。然后,将在列表上应用 Map 函数以转换值。将来,我们将处理一系列值。一旦我们映射或转换了值,我们将对列表应用过滤器。如果列表中没有值,则方法将返回而不通知订阅者:

      double dispatch_number = resulttr.front(); 
      for (auto sink : sinks) {  
           std::packaged_task<int()> task([&]()  
           { sink.ondata(dispatch_number); return 1;  }); 
           std::future<int> result = task.get_future();task(); 
           double dresult = result.get(); 
         } 
     }

在此代码中,我们将调用packaged_task将数据分派到事件接收器。工业级库使用称为调度器的代码片段来执行此任务的一部分。由于我们使用的是 fire and forget,接收器将无法阻止EventSource。这是 Observables 的最重要用例之一:

      T* GetState() { return State; } 
      void SetState(T *pstate) { State = pstate; NotifyAll(); } 
}; 

现在,让我们编写一个方法,根据现代 C++随机数生成器发出随机表达式,具有均匀概率分布。选择这种分布是相当任意的。我们也可以尝试其他分布,以查看不同的结果:

Expr *getRandomExpr(int start, int end) { 
    std::random_device rd; 
    std::default_random_engine reng(rd()); 
    std::uniform_int_distribution<int> uniform_dist(start, end); 
    double mean = uniform_dist(reng); 
    return  new  
          BinaryExpr( new Number(mean*1.0) , new Number(mean*2.0) , OPERATOR::PLUS); 
} 

现在,让我们编写一个主函数将所有内容组合在一起。我们将使用ExprFilterMapper实例化EventSourceValueSubject类:

int main( int argc, char **argv ){ 
     unique_ptr<Expr>   
         a(new BinaryExpr( new Number(10) , new Number(20) , OPERATOR::PLUS)); 
     EventSourceValueSubject<Expr,std::function<bool(double)>, 
                    std::function<double(double)>,double,double>  
                    temp(&(*a),[] (auto s ) {   return s > 40.0;  }, 
                    []  (auto s ) { return s+ s ; }  | 
                    []  (auto s ) { return s*2;} ); 

在实例化对象时,我们使用管道运算符来组合两个 Lambda。这是为了演示我们可以组合任意数量的函数以形成复合函数。当我们编写 RxCpp 程序时,我们将大量利用这种技术。

     OBSERVER obs_one ;     OBSERVER obs_two ; 
     obs_one.ondata = [](const double  r) {  cout << "*Final Value " <<  r << endl;}; 
     obs_two.ondata = [] ( const double r ){ cout << "**Final Value " << r << endl;};

在这段代码中,我们实例化了两个OBSERVER对象,并使用 Lambda 函数将它们分配给 ondata 成员。我们没有实现其他方法。这仅用于演示目的:

     temp.Subscribe(&obs_one); temp.Subscribe(&obs_two);   

我们订阅了使用OBSERVER实例的事件通知。我们只实现了 ondata 方法。实现onexceptiononcompleted是微不足道的任务:

     Expr *expr = 0; 
     for( int i= 0; i < 10; ++i ) { 
           cout << "--------------------------" <<  i << " "<< endl; 
           expr = getRandomExpr(i*2, i*3 ); temp.SetState(expr); 
           std::this_thread::sleep_for(2s); delete expr; 
     } 
} 

我们通过将表达式设置为EventSource对象来评估一系列随机表达式。经过转换和过滤,如果还有值剩下,该值将通知给OBSERVER,并打印到控制台。通过这种方式,我们成功地使用packaged_taks编写了一个非阻塞的EventSource。在本章中,我们演示了以下内容:

  • 使用复合对表达树进行建模

  • 通过 Visitor 接口处理复合

  • 将表达树展平为列表,并通过迭代器进行处理(拉)

  • EventSource到事件接收端(推送)的凝视反转

总结

在本章中,我们涵盖了很多内容,朝着响应式编程模型迈进。我们了解了 GoF Observer 模式并理解了它的缺点。然后,我们偏离了哲学,以了解从结构、行为和功能的角度看世界的方法。我们在表达树建模的背景下学习了 GoF Composite/Visitor 模式。我们学会了如何将层次结构展平为列表,并通过迭代器对其进行导航。最后,我们稍微改变了事物的方案,以达到 Observables。通常,Observables 与 Streams 一起工作,但在我们的情况下,它是一个标量值。在下一章中,我们将学习有关事件流处理,以完成学习响应式编程的先决条件。

第六章:使用 C++介绍事件流编程

本章将是使用 C++编程反应性系统所需的先决章节系列的最后一章。我们需要经历许多概念的原因是,反应式编程模型统一了许多计算概念,实现了其强大的编程模型。要开始以反应式方式思考,程序员必须熟悉面向对象编程、函数式编程、语言级并发、无锁编程、异步编程模型、设计模式、调度算法、数据流编程模型、声明式编程风格,甚至一点图论!我们从书中窥探了各种 GUI 系统的事件驱动编程模型以及围绕它们构建代码的方式。我们涵盖了现代 C++的核心要点第二章,现代 C++及其关键习语之旅。在第三章中,C++中的语言级并发和并行性,以及第四章,C++中的异步和无锁编程,我们分别介绍了 C++语言支持的语言级并发和无锁编程。在第五章中,可观察对象简介,我们重点介绍了如何将反应式编程模型放入 GOF 模式的背景中处理。剩下的是事件流编程。现在我们将专注于处理事件流或事件流编程。在本章中,我们将讨论以下内容:

  • 什么是流编程模型?

  • 流编程模型的优势

  • 使用 C++和公共领域库进行流编程

  • 使用 Streamulus 进行流编程

  • 事件流编程

什么是流编程模型?

在我们深入讨论流编程模型之前,我们将退一步,看看与 POSIX shell 编程模型的相似之处。在典型的命令行 shell 程序中,每个命令都是一个程序,每个程序都是一个命令。在实现计算目标或任务后,我们可以将一个程序的输出传递给另一个程序。实际上,我们可以链接一系列命令来实现更大的计算任务。我们可以将其视为一系列数据通过一系列过滤器或转换以获取输出。我们也可以称之为命令组合。有现实情况下,巨大的程序被少量的 shell 代码使用命令组合替代。同样的过程可以在 C++程序中实现,将函数的输入视为流、序列或列表。数据可以从一个函数或函数对象(也称为函数对象)传递到另一个函数,作为标准数据容器。

传奇计算机科学家和斯坦福大学教授唐纳德·克努斯博士被要求编写一个程序:

  • 读取文本文件并确定n个常用单词

  • 打印出一个按单词频率排序的单词列表

Knuth 的解决方案是一个十页的 Pascal 程序!Doug McIlroy 只用以下 shell 脚本就实现了相同的功能:

tr -cs A-Za-z ' n ' | tr A-Z a-z | sor t | uniq -c | sor t -rn | sed ${1}q命令组合的威力就是这样了。

流编程模型的优势

传统的 OOP 程序很好地模拟了层次结构,处理层次结构大多比处理线性集合更困难。在流编程模型中,我们可以将输入视为放入容器的实体流,将输出视为实体的集合,而不修改输入数据流。使用 C++通用编程技术,我们可以编写与容器无关的代码来处理流。这种模型的一些优势包括:

  • 流编程简化了程序逻辑

  • 流可以支持惰性评估和函数式转换

  • 流更适合并发编程模型(源流是不可变的)

  • 我们可以组合函数来创建高阶函数来处理它们

  • 流促进了声明式编程模型

  • 它们可以从不同的源聚合、过滤和转换数据

  • 它们解耦了数据源和处理数据的实体

  • 它们提高了代码的可读性(开发人员可以更快地理解代码)

  • 它们可以利用数据并行性和任务并行性

  • 我们可以利用数百个定义良好的流操作符(算法)来处理数据

使用 Streams 库进行应用流编程

在本节中,我们将介绍使用Streams库进行流编程的主题,这是由 Jonah Scheinerman 编写的一个公共领域库。该库托管在github.com/jscheiny/Streams,API 文档可从jscheiny.github.io/Streams/api.html#获取。以下是一个介绍(摘自库的 GitHub 页面):

Streams是一个 C++库,提供了对数据的惰性评估和函数式转换,以便更轻松地使用 C++标准库的容器和算法。Streams支持许多常见的函数操作,如 map、filter 和 reduce,以及其他各种有用的操作,如各种集合操作(并集、交集、差集)、部分和、相邻差分,以及其他许多操作。

我们可以看到,熟悉标准模板库(STL)的程序员将会对这个库感到非常舒适。STL 容器被视为流数据源,STL 算法可以被视为对流数据源的转换。该库使用现代 C++支持的函数式编程习语,并且支持惰性评估。在这里,惰性评估的概念非常重要,因为它是函数式编程模型和 Rx 编程模型的基石。

惰性评估

在编程语言中,有两种突出的评估函数参数的方法,它们如下:

  • 应用程序顺序评估(AO)

  • 正常顺序评估(NO)

在 AO 的情况下,参数在调用上下文中被评估,然后传递给被调用者。大多数传统的编程语言都遵循这种方法。在 NO 的情况下,变量的评估被推迟,直到在被调用者的上下文中需要计算结果。一些函数式编程语言,如 Haskell、F#和 ML,遵循 NO 模型。在函数式编程语言中,大部分函数的评估是引用透明的(函数的调用不会产生副作用);我们只需要对表达式进行一次评估(对于特定值作为参数),并且结果可以在再次执行相同函数相同参数的评估时共享。这被称为惰性评估。因此,惰性评估可以被认为是 NO 与先前计算结果的共享相结合。C++编程语言默认不支持函数参数的惰性评估,但可以使用不同的技术来模拟,例如可变模板和表达式模板。

一个简单的流程序

要开始使用Streams库,让我们编写一个小程序来生成一个数字流并计算前十个数字的平方:

//--------- Streams_First.cpp 
#include "Stream.h" 
using namespace std; 
using namespace Stream; 
using namespace Stream::op; 
int main(){ 
  //-------- counter(n) - Generate a series of value 
  //-------- Map (Apply a Lambda) 
  //-------- limit(n) -- Take first ten items 
  //-------- Sum -- aggregate 
  int total = MakeStream::counter(1) 
    | map_([] (int x) { return x * x; } // Apply square on each elements 
    | limit(10) //take first ten elements
   | sum();  // sum the Stream contents Streams::op::sum 
   //----------- print the result 
   cout << total << endl; 
} 

前面的代码片段生成了一个值列表(使用MakeStream::counter(1)),生成的值将使用 map 函数进行转换(在这种情况下,计算平方)。当在流中组装了十个元素(limit(10))时,我们在流上调用 sum 操作符。

使用流范式聚合值

现在我们了解了 Stream 库所设想的流编程的基础知识,让我们编写一段代码,计算存储在std::vector容器中的数字的平均值:

//--------------- Streams_Second.cpp 
// g++ -I./Streams-master/sources Streams_Second.cpp 
// 
#include "Stream.h" 
#include <ioStream> 
#include <vector> 
#include <algorithm> 
#include <functional> 
using namespace std; 
using namespace Stream; 
using namespace Stream::op; 
int main() { 
  std::vector<double> a = { 10,20,30,40,50 }; 
  //------------ Make a Stream and reduce  
  auto val =  MakeStream::from(a)  | reduce(std::plus<void>()); 
  //------ Compute the arithematic average 
  cout << val/a.size() << endl; 
} 

前面的代码片段从std::vector创建了一个流,并使用std::plus函数对象进行了归约处理。这等同于对流中的值进行聚合。最后,我们将聚合值除以std::vector中的元素数量。

STL 和流范式

Streams库可以与 STL 容器无缝配合。以下代码片段将在流上映射一个函数,并将结果数据转换为一个向量容器:

//--------------- Streams_Third.cpp 
// g++ -I./Streams-master/sources Streams_Third.cpp 
// 
#include "Stream.h" 
#include <ioStream> 
#include <vector> 
#include <algorithm> 
#include <functional> 
#include <cmath> 
using namespace std; 
using namespace Stream; 
using namespace Stream::op; 
double square( double a ) { return a*a; } 
int main() { 
  std::vector<double> values = { 1,2,3,4,5 }; 
  std::vector<double> outputs = MakeStream::from(values) 
               | map_([] (double a ) { return a*a;})  
               | to_vector(); 
  for(auto pn : outputs ) 
  { cout << pn << endl; } 
} 

前面的代码片段将std::vector<double>转换为一个流,应用平方函数,然后将结果转换回std:::vector<double>。之后,对向量进行迭代以打印内容。Streams库的文档非常详尽,包含许多代码示例,可以用来编写生产质量的应用程序。请参阅 API 文档,网址为jscheiny.github.io/Streams/api.html

关于 Streams 库

Streams库是一个设计良好的软件,具有直观的编程模型。任何曾经使用过函数式编程和流编程的程序员都会在几个小时内真正感到舒适。熟悉 STL 的人也会觉得这个库非常直观。从编程模型的角度来看,API 可以分为:

  • 核心方法(流初始化)

  • 生成器(流创建者)

  • 有状态的中间操作符(函数式不可变转换)

  • 无状态的中间操作符

  • 终端操作符

前面提到的库文档阐明了这个出色库的各个方面。

事件流编程

我们对流编程模型的工作有了一定的了解。当我们将事件作为流处理时,可以将其归类为事件流编程。在编程社区中,事件驱动架构被认为是打造现代程序的更好模型。一个依赖于事件流编程的软件的绝佳例子是版本控制系统。在版本控制系统中,一切都被视为事件。典型的例子包括检出代码、提交、回滚和分支。

事件流编程的优势

将事件作为流聚合并在下游系统中处理与传统的事件编程模型相比有许多优势。一些关键优势包括:

  • 事件源和事件接收器没有耦合

  • 事件接收器可以处理事件而不必理会事件源

  • 我们可以应用流处理操作符来处理和过滤流

  • 转换和过滤可以在聚合级别进行处理

  • 事件可以通过流处理网络传播

  • 事件处理可以很容易地并行化(声明式并行)

Streamulus 库及其编程模型

Streamulus 库,来自 Irit Katiel,是一个库,通过实现特定领域嵌入式语言DSEL)的编程模型,使事件流的编程更加容易。为了理解编程模型,让我们检查一个将数据流入聚合接收到的数据的类的程序:

#include "Streamulus.h" 
#include <ioStream> 
using namespace std; 
using namespace Streamulus; 
struct print {     
    static double temp; 
    print() { } 
    template<typename T> 
    T operator()(const T& value) const {  
        print::temp += value; 
        std::cout << print::temp << std::endl;  return value; 
     } 
}; 
double print::temp = 0; 

前面的函数对象只是将传递的值累积到静态变量中。对于每次由Streamify模板(Streamify<print>(s))调用的函数,到目前为止累积的值将被打印到控制台。通过查看以下清单,可以更好地理解这一点:

void hello_Stream() { 
    using namespace Streamulus; 
    // Define an input Stream of strings, whose name is "Input Stream" 
    InputStream<double> s = 
             NewInputStream<double>("Input Stream", true /* verbose */); 
    // Construct a Streamulus instance 
    Streamulus Streamulus_engine;   

我们使用 NewInputStream<T> 模板方法创建一个流。该函数期望一个参数,用于确定是否应将日志打印到控制台。通过将第二个参数设置为 false,我们可以关闭详细模式。我们需要创建一个 Streamulus 引擎的实例来协调数据流。Streamulus 引擎对流表达式进行拓扑排序,以确定变化传播顺序:

    // For each element of the Stream:  
    //     aggregate the received value into a running sum
    //     print it  
    Streamulus_engine.Subscribe(Streamify<print>( s));    

我们使用 Streamify<f> strop(流操作符)来序列化刚刚创建的打印函子的调用。我们可以创建自己的流操作符,通常 Streamify 对我们来说就足够了。Streamfiy 创建一个单事件函子和一个 strop:

    // Insert data to the input Stream 
    InputStreamPut<double>(s, 10); 
    InputStreamPut<double>(s, 20); 
    InputStreamPut<double>(s, 30);     
} 
int main() {  hello_Stream();  return 0; } 

先前的代码片段将一些值发射到流中。我们将能够在控制台上看到累积和打印三次。在主函数中,我们调用 hello_Stream 函数来触发所有操作。

现在我们已经学会了 Streamulus 系统如何与简单程序一起工作,让我们编写一个更好地阐明库语义的程序。以下程序通过一系列单参数函子流数据,以演示库的功能。我们还在列表中大量使用流表达式:

/////////////////////////// 
//  g++ -I"./Streamulus-master/src"  -I<PathToBoost>s Streamulus_second.cpp 
#include "Streamulus.h" 
#include <ioStream> 
using namespace std; 
using namespace Streamulus; 
//-------  Functors for doubling/negating and halfving values 
struct twice {     
    template<typename T> 
    T operator()(const T& value) const {return value*2;} 
}; 
struct neg {     
    template<typename T> 
    T operator()(const T& value) const{ return -value; } 
}; 
struct half{     
    template<typename T> 
    T operator()(const T& value) const { return 0.5*value;} 
};

前面一组函子在性质上是算术的。twice 函子将参数加倍,neg 函子翻转参数的符号,half 函子将值缩放 0.5 以减半参数的值:

struct print{     
    template<typename T> 
    T operator()(const T& value) const{  
        std::cout << value << std::endl; 
        return value; 
    } 
}; 
struct as_string  { 
    template<typename T> 
    std::string operator()(const T& value) const {  
        std::stringStream ss; 
        ss << value; 
        return ss.str(); 
    } 
};

前面两个函数对象的工作方式是显而易见的——第一个(print)只是将值输出到控制台。as_string 使用 std::stringStream 类将参数转换为字符串:

void DataFlowGraph(){ 
    // Define an input Stream of strings, whose name is "Input Stream" 
    InputStream<double> s = 
          NewInputStream<double>("Input Stream", false /* verbose */); 
    // Construct a Streamulus instance 
    Streamulus Streamulus_engine;             
    // Define a Data Flow Graph for Stream based computation  
    Subscription<double>::type val2 =  Streamulus_engine.Subscribe(Streamify<neg> 
                         (Streamify<neg>(Streamify<half>(2*s)))); 
    Subscription<double>::type val3 = Streamulus_engine.Subscribe( 
                                      Streamify<twice>(val2*0.5)); 
    Streamulus_engine.Subscribe(Streamify<print>(Streamify<as_string>(val3*2))); 
    //------------------ Ingest data into the Stream 
    for (int i=0; i<5; i++) 
        InputStreamPut(s, (double)i); 
}

DataFlowGraph() 创建了 InputStream<T> 来处理双值流。在实例化 Streamulus 对象(引擎)之后,我们通过 Streamify<f> 流操作符将一系列函子连接起来。该操作可以被视为一种具有单参数函数的函数组合。设置机制后,我们使用 InputStreamPut 函数向流中注入数据:

int main(){ 
    DataFlowGraph(); //Trigger all action 
    return 0; 
} 

Streamulus 库 - 其内部的一瞥

Streamulus 库基本上创建了一个变化传播图,以简化流处理。我们可以将图的节点视为计算,将边视为从一个节点到另一个节点的缓冲区。几乎所有数据流系统都遵循相同的语义。Streamulus 库帮助我们构建一个依赖变量的图,这有助于我们将更改传播到子节点。应该更新变量的顺序将通过对图进行拓扑排序来定义。

图是一种数据结构,其中一组依赖实体表示为节点(或顶点),它们之间的关系(作为边)表示为边。在计算机科学中,特别是在调度和分析依赖关系时,有一种特定版本的图,称为有向无环图,因其独特的特性而受到青睐。DAG 是一个没有循环的有向图。我们可以执行称为拓扑排序的操作来确定实体的线性依赖顺序。拓扑排序只能在 DAG 上执行,它们不是唯一的。在下图中,我们可以找到多个拓扑排序:

Streamulus 库 - 表达式处理的一瞥

我们将看看 Streamulus 如何使用简单的流表达式处理表达式:

InputStream<int>::type x = NewInputStream<int>("X"); 
Engine.Subscribe( -(x+1)); 

- (x+1) 流表达式将产生以下图表。术语 strop 代表流操作符,每个节点都组织为一个 strop:

一旦节点被正确标记,将对图进行拓扑排序以确定执行顺序。下图显示了一个拓扑排序(可以有多个拓扑排序):

Streamulus 引擎遍历图表,找出在数据传播过程中必须应用流操作符的顺序。TO标签代表拓扑顺序。拓扑排序后,将产生一个按拓扑顺序排名的流操作符的线性列表。执行引擎将按照拓扑顺序执行代码。

Streamulus 引擎使用 boost proto 库执行其魔术。后者管理 Streamulus 库的表达式树。要真正查看库的源代码,您需要熟悉模板元编程,特别是表达式模板。元编程是一种我们编写代码来生成或转换源代码的技术。1994 年,Erwin Unruh 发现 C++模板机制是图灵完备的。

电子表格库-变更传播引擎

电子表格经常被吹捧为反应系统的典型示例。在电子表格中,页面被组织为单元格矩阵。当单元格发生变化时,所有依赖单元格将重新计算以反映变化。这对每个单元格都是如此。实际上,如果您有诸如 Streamulus 之类的库,对电子表格进行建模是很容易的。幸运的是,该库的设计者本身编写了另一个依赖于 Streamulus 进行变更传播的库。

电子表格是一个 C++库,可以实现电子表格编程,即设置变量(单元格),其中每个单元格都被分配一个可能包含其他单元格值的表达式。更改将传播到所有依赖单元格,就像在电子表格中一样。电子表格是为了演示 Streamulus 的使用而开发的。电子表格是一个仅包含头文件的库。它使用 boost 和 Streamulus。因此,请将这三个库放在您的包含路径中。该库的详细信息可以在github.com/iritkatriel/spreadsheet上找到。

我们将介绍一个利用“电子表格”库的示例程序,该库包含在项目的 GitHub 存储库(main.cpp)中:

#include "spreadsheet.hpp" 
#include <ioStream> 
int main (int argc, const char * argv[]) {  
    using namespace spreadsheet; 
    Spreadsheet sheet; 
    Cell<double> a = sheet.NewCell<double>(); 
    Cell<double> b = sheet.NewCell<double>(); 
    Cell<double> c = sheet.NewCell<double>(); 
    Cell<double> d = sheet.NewCell<double>(); 
    Cell<double> e = sheet.NewCell<double>(); 
    Cell<double> f = sheet.NewCell<double>();

前面的代码片段创建了一组单元格,作为 IEEE 双精度浮点数的容器。初始化单元格后,我们将开始使用以下一组表达式改变单元格的值:

    c.Set(SQRT(a()*a() + b()*b())); 
    a.Set(3.0); 
    b.Set(4.0); 
    d.Set(c()+b()); 
    e.Set(d()+c()); 

现在,我们将使用前述表达式改变值。通过Set方法进行每次赋值后,将通过单元格触发计算传递。Streamulus库管理底层流:

    std::cout << " a=" << a.Value()  
              << " b=" << b.Value()  
              << " c=" << c.Value()  
              << " d=" << d.Value()  
              << " e=" << e.Value()  
              << std::endl;

前面的代码片段将单元格的值打印到控制台。我们将再次更改单元格的表达式以触发计算流图:

    c.Set(2*(a()+b())); 
    c.Set(4*(a()+b())); 
    c.Set(5*(a()+b())); 
    c.Set(6*(a()+b())); 
    c.Set(7*(a()+b())); 
    c.Set(8*(a()+b())); 
    c.Set(a()); 
    std::cout << " a=" << a.Value()  
              << " b=" << b.Value()  
              << " c=" << c.Value()  
              << " d=" << d.Value()  
              << " e=" << e.Value()  
              << std::endl;     
    std::cout << "Goodbye!n"; 
    return 0; 
} 

可以查看库的源代码以了解库的内部工作原理。电子表格是 Streamulus 库如何被利用来编写健壮软件的绝佳示例。

RaftLib-另一个流处理库

RaftLib 是一个值得检查的库,适用于任何对并行编程或基于流的编程感兴趣的人(开发人员)。该库可在github.com/RaftLib/RaftLib上找到。前述网站提供了以下描述

RaftLib 是一个用于实现流/数据流并行计算的 C++库。使用简单的右移操作符(就像您用于字符串操作的 C++流一样),您可以将并行计算内核链接在一起。使用 RaftLib,我们摆脱了显式使用 pthread、std.thread、OpenMP 或任何其他并行线程库。这些通常被误用,导致非确定性行为。RaftLib 的模型允许无锁 FIFO 样式访问连接每个计算内核的通信通道。整个系统具有许多自动并行化、优化和便利功能,可以相对简单地编写高性能应用程序。

由于空间限制,本书不会详细介绍RaftLib。该库的作者 Jonathan Beard 有一次精彩的演讲,可在www.youtube.com/watch?v=IiQ787fJgmU观看。让我们来看一个代码片段,展示了这个库的工作原理:

#include <raft> 
#include <raftio> 
#include <cstdlib> 
#include <string> 

class hi : public raft::kernel 
{ 
public: 
    hi() : raft::kernel(){ output.addPort< std::string >( "0" ); } 
    virtual raft::kstatus run(){ 
        output[ "0" ].push( std::string( "Hello Worldn" ) ); 
        return( raft::stop );  
    } 
}; 

int main( int argc, char **argv ) { 
    /** instantiate print kernel **/ 
    raft::print< std::string > p; 
    /** instantiate hello world kernel **/ 
    hi hello; 
    /** make a map object **/ 
    raft::map m; 
    /** add kernels to map, both hello and p are executed concurrently **/ 
    m += hello >> p; 
    /** execute the map **/ 
    m.exe(); 
    return( EXIT_SUCCESS ); 
} 

作为程序员,您应该为自定义计算定义一个内核,并使用>>运算符来流式传输数据。在前面的代码中,hi类就是这样一个内核。请查阅Raftlib文档(可在前面的 RaftLib URL 找到)和源代码示例,以了解更多关于这个精彩库的信息。

这些东西与 Rx 编程有什么关系?

基本上,响应式编程模型将事件视为通过变化传播图传播的数据流。为了实现这一点,我们需要将事件元素聚合到基于容器的数据结构中,并从中创建一个流。有时,如果数据很多,我们甚至会应用统计技术来对事件进行采样。生成的流可以在源级别使用函数转换进行过滤和转换,然后通知等待通知的观察者。事件源应该采取一种点火并忘记的方式来分发事件流,以避免事件源汇和事件汇之间的耦合。何时分派事件数据将由调度软件确定,该软件以异步方式运行函数转换管道。因此,响应式编程的关键元素是:

  • Observables(其他人感兴趣的数据流)

  • 观察者(对 Observable 感兴趣并订阅通知的实体)

  • 调度器(确定流何时应该在网络上传播)

  • 功能操作符(事件过滤和转换)

简而言之,调度器(Rx 引擎的一部分)会异步地对Observable进行过滤和转换,然后再通知订阅者,如下所示:

摘要

在本章中,我们涵盖了事件流编程的主题。将事件视为流在许多方面优于传统的事件处理模型。我们从Streams库开始,了解了它的编程模型。我们还编写了一些程序,以熟悉该库及其语义。Streams库有很好的文档,您应该查阅其文档以了解更多信息。在 Streams 库之后,我们看了一下 Streamulus 库,它提供了一种 DSEL 方法来操作事件流。我们编写了一些程序,还学习了一些附带Streamulus库的示例程序。我们还提到了Raftlib库,这是流处理的另一种选择。通过对事件流编程模型的覆盖,我们现在已经完成了理解响应式编程一般和 RxCpp 库特别的先决条件。在下一章中,我们将开始使用 RxCpp 库,进入响应式系统设计的编程模型。

第七章:数据流计算和 RxCpp 库的介绍

从这一章开始,我们将深入了解响应式编程模型的核心。你可以把之前的章节看作是理解响应式编程模型的先决条件,更具体地说是使用 C++ 编程语言进行响应式编程的先决条件。回顾一下,我们已经涵盖了必要的先决条件,其中包括以下内容:

  • 各种 GUI 平台上的事件编程模型

  • 现代 C++ 语言的快速介绍(包括函数式编程)

  • C++ 中的语言级并发,以实现更好的并发系统

  • 无锁编程模型(作为朝向声明式编程的一步)

  • 高级设计模式和 Observables 的概念

  • 使用 C++ 进行事件流编程

所有这些主题在函数式响应式编程FRP)的情况下以系统化的方式结合在一起。在这里,FRP 缩写被用于使用函数式编程构造来编程响应式系统的宽泛意义上。

简而言之,响应式编程无非就是使用异步数据流进行编程。通过对流应用各种操作,我们可以实现不同的计算目标。在响应式程序中的主要任务是将数据转换为流,无论数据的来源是什么。事件流通常被称为Observables,事件流的订阅者被称为Observers。在 Observables 和 Observers 之间,有流操作符(过滤器/转换器)。

由于默认假设数据源在数据通过操作符时不会被改变,我们可以在 Observables 和 Observers 之间有多个操作符路径。不可变性为乱序执行提供了选项,并且调度可以委托给一个名为调度器的特殊软件。因此,Observables、Observers、流操作符和调度器构成了响应式编程模型的支柱。

在本章中,我们将涵盖以下主题:

  • 关于数据流计算范式的简要讨论

  • 介绍 RxCpp 库及其编程模型

  • 一些基本的 RxCpp 程序来入门

  • Rx 流操作符

  • 弹珠图

  • 调度

  • flatmap/concatmap 的奇特之处

  • 附加的 Rx 操作符

数据流计算范式

传统上,程序员以控制流的形式编码计算机程序。这意味着我们将程序编码为一系列小语句(顺序、分支、迭代)或函数(包括递归),以及它们关联的状态。我们使用诸如选择(if/else)、迭代(while/for)和函数(递归函数也包括在内)等构造来编码我们的计算。处理这些类型的程序的并发和状态管理真的很困难,并且在管理可变的状态信息时会导致微妙的错误。我们需要在共享的可变状态周围放置锁和其他同步原语。在编译器级别,语言编译器将解析源代码以生成抽象语法树AST),进行类型分析和代码生成。实际上,AST 是一个信息流图,你可以在其中执行数据流分析(用于数据/寄存器级优化)和控制流分析,以利用处理器级别的代码管道优化。尽管程序员以控制流的形式编码程序,但编译器(至少部分)也试图以数据流的形式看待程序。这里的关键是,每个计算机程序中都存在一个潜在的隐式数据流图。

数据流计算将计算组织为一个显式图,其中节点是计算,边是数据在节点之间流动的路径。如果我们对计算图中的节点上的计算施加一些限制,例如通过在输入数据的副本上工作来保留数据状态(避免原地算法),我们可以利用并行性的机会。调度器将通过对图数据结构进行拓扑排序来找到并行性的机会。我们将使用流(Path)和流操作(Node)构建图数据结构。这可以以声明方式完成,因为操作符可以被编码为 lambda,对节点进行一些本地计算。有一组原始标准(函数/流)操作符,如mapreducefiltertake等,被函数式编程社区确定,可以在流上工作。在每个数据流计算框架中,都有一种将数据转换为流的方法。用于机器学习的 TensorFlow 库就是一个使用数据流范式的库。尽管图的创建过程不是完全显式的,RxCpp 库也可以被视为一个数据流计算库。由于函数式编程构造支持惰性评估,当我们使用异步数据流和操作构建流水线时,我们实际上正在创建一个计算流图。这些图由调度子系统执行。

RxCpp 库简介

我们将在本书的其余部分中使用 RxCpp 库来编写我们的响应式程序。RxCpp 库是一个仅包含头文件的 C++库,可以从 GitHub 仓库下载:reactive-extensions.github.io/RxCpp/。RxCpp 库依赖于现代 C++构造,如语言级并发、lambda 函数/表达式、函数式组合/转换和运算符重载,以实现响应式编程构造。RxCpp 库的结构类似于Rx.netRxjava等库。与任何其他响应式编程框架一样,在编写第一行代码之前,每个人都应该了解一些关键构造。它们是:

  • Observables(Observable Streams)

  • 观察者(订阅 Observables 的人)

  • 操作符(例如,过滤器、转换和减少)

  • 调度器

RxCpp 是一个仅包含头文件的库,大部分计算都基于 Observables 的概念。该库提供了许多原语,用于从各种数据源创建 Observable Streams。数据源可以是数组、C++范围、STL 容器等。我们可以在 Observables 和它们的消费者(被称为 Observers)之间放置 Operators。由于函数式编程支持函数的组合,我们可以将一系列操作符作为一个单一实体放置在 Observables 和订阅流的 Observers 之间。与库相关的调度器将确保当 Observable Streams 中有数据可用时,它将通过一系列 Operators 传递,并向订阅者发出通知。观察者将通过 on_next、on_completed 或 on_error lambda 收到通知,每当管道中发生重要事件时。因此,观察者可以专注于它们主要负责的任务,因为数据将通过通知到达它们。

RxCpp 库及其编程模型

在这一部分,我们将编写一些程序,帮助读者理解 RxCpp 库的编程模型。这些程序的目的是阐明 Rx 概念,它们大多是微不足道的。代码将足以让程序员在进行轻微调整后将其纳入生产实现。在这一部分,数据生产者及其 Observables 将基于 C++范围、STL 容器等,以使清单足够简单,以便理解这里概述的核心概念。

一个简单的 Observable/Observer 交互

让我们编写一个简单的程序,帮助我们理解 RxCpp 库的编程模型。在这个特定的程序中,我们将有一个 Observable Stream 和一个订阅该 Stream 的 Observer。我们将使用一个范围对象从 1 到 12 生成一系列数字。在创建值的范围和一个 Observable 之后,我们将它们连接在一起。当我们执行程序时,它将在控制台上打印一系列数字。最后,一个字面字符串("Oncompleted")也将打印在控制台上。

////////// 
// First.cpp 
// g++ -I<PathToRxCpplibfoldersrc> First.cpp 
#include "rxcpp/rx.hpp" 
#include <ioStream> 
int main() { 
 //------------- Create an Observable.. a Stream of numbers 
 //------------- Range will produce a sequence from 1 to 12 
 auto observable = rxcpp::observable<>::range(1, 12);
 //------------ Subscribe (only OnNext and OnCompleted Lambda given 
 observable.Subscribe(  
    [](int v){printf("OnNext: %dn", v);}, 
    [](){printf("OnCompleted\n");}); 
} 

前面的程序将在控制台上显示数字,并且字面字符串"OnCompleted"也将显示在控制台上。这个程序演示了如何创建一个 Observable Stream,并使用 subscribe 方法将 Observer 连接到创建的 Observable Stream。

使用 Observables 进行过滤和转换

以下程序将帮助我们理解过滤和map操作符的工作原理,以及使用 subscribe 方法将 Observer 连接到 Observable Streams 的通常机制。filter 方法对流的每个项目进行谓词评估,如果评估产生积极断言,该项目将出现在输出流中。map操作符对其输入流的每个元素应用一个 lambda 表达式,并在每次产生一个输出值(可以通过管道传播)时帮助产生一个输出值:

/////////////////////////////////////// 
// Second.cpp 
#include "rxcpp/rx.hpp" 
#include <ioStream> 
int main() { 
  auto values = rxcpp::observable<>::range(1, 12). 
      filter([](int v){ return v % 2 ==0 ;}). 
      map([](int x) {return x*x;});  
  values.subscribe( 
           [](int v){printf("OnNext: %dn", v);}, 
           [](){printf("OnCompleted\n");}); 
} 

前面的程序生成一系列数字(作为 Observable),并通过一个 filter 函数传递流的内容。filter函数尝试检测数字是否为偶数。如果谓词为真,则数据将传递给map函数,该函数将对其输入进行平方。最终,流的内容将显示在控制台上。

从 C++容器中流出值

STL 容器中的数据被视为存在于空间中的数据(已经捕获的数据)。尽管 Rx 流用于处理随时间变化的数据(动态数据),我们可以将 STL 容器转换为 Rx 流。我们需要使用 Iterate 操作符进行转换。这在某些时候可能很方便,并且有助于集成使用 STL 的代码库中的代码。

// STLContainerStream.cpp
#include "rxcpp/rx.hpp"
#include <ioStream>
#include <array>
int main() {
    std::array< int, 3 > a={{1, 2, 3}};
    auto values = rxcpp::observable<>::iterate(a);
    values.subscribe([](int v){printf("OnNext: %dn", v);},
    [](){printf("OnCompleted\n");});
}

从头开始创建 Observables

到目前为止,我们已经编写了代码,从一个范围对象或 STL 容器创建了一个 Observable Stream。让我们看看如何可以从头开始创建一个 Observable Stream。嗯,几乎:

// ObserverFromScratch.cpp 
#include "rxcpp/rx.hpp" 
#include "rxcpp/rx-test.hpp" 
int main() { 
      auto ints = rxcpp::observable<>::create<int>( 
                  [](rxcpp::subscriber<int> s){ 
                       s.on_next(1); 
                       s.on_next(4); 
                       s.on_next(9); 
                       s.on_completed(); 
                 }); 
    ints.subscribe( [](int v){printf("OnNext: %dn", v);}, 
                             [](){printf("OnCompletedn");}); 
} 

前面的程序调用on_ext方法来发出一系列完全平方数。这些数字(1,4,9)将被打印到控制台上。

连接 Observable Streams

我们可以连接两个流来形成一个新的流,在某些情况下这可能很方便。让我们通过编写一个简单的程序来看看这是如何工作的:

//------------- Concactatenate.cpp 
#include "rxcpp/rx.hpp" 
#include "rxcpp/rx-test.hpp" 
int main() { 
 auto values = rxcpp::observable<>::range(1);  
 auto s1 = values.take(3).map([](int prime) { return 2*prime;);}); 
 auto s2 = values.take(3).map([](int prime) { return prime*prime);}); 
 s1.concat(s2).subscribe(rxcpp::util::apply_to( 
            []( int p) { printf(" %dn", p);})); 
} 

concat 操作符通过保持顺序,将组成的 Observable Streams 的内容依次附加在一起。在前面的代码中,在创建一个 Observable(values)之后,我们创建了另外两个 Observables(s1 和 s2),并附加了第二个 Observable Stream(s2)生成的内容,以产生一个组合的 Observable Stream(s1.concat(s2))。最后,我们订阅了组合的 Observable。

取消订阅 Observable Streams

以下程序展示了如何订阅 Observable 并在需要时停止订阅。在某些程序的情况下,这个选项非常有用。请参阅 Rxcpp 文档,了解更多关于订阅以及如何有效使用它们的信息。与此同时,以下程序将演示如何取消订阅 Observable。

//---------------- Unsubscribe.cpp 
#include "rxcpp/rx.hpp" 
#include "rxcpp/rx-test.hpp" 
#include <iostream> 
int main() { 
    auto subs = rxcpp::composite_subscription(); 
    auto values = rxcpp::observable<>::range(1, 10); 
    values.subscribe( 
        subs,&subs{ 
            printf("OnNext: %dn", v); 
            if (v == 6) 
                subs.unsubscribe(); //-- Stop recieving events 
        }, 
        [](){printf("OnCompletedn");}); 
}

在上面的程序中,当发出的值达到阈值时,我们调用取消订阅(subs.unsubscribe())方法。

关于大理石图表的视觉表示的介绍

很难将 Rx Streams 可视化,因为数据是异步流动的。Rx 系统的设计者创建了一组名为大理石图表的可视化线索:让我们编写一个小程序,并将 map 操作符的逻辑描述为大理石图表。

//------------------ Map.cpp 
#include "rxcpp/rx.hpp" 
#include "rxcpp/rx-test.hpp" 
#include <ioStream> 
#include <array> 
int main() { 
    auto ints = rxcpp::observable<>::range(1,10). 
                 map( [] ( int n  ) {return n*n; }); 
    ints.subscribe( 
            [](int v){printf("OnNext: %dn", v);}, 
            [](){printf("OnCompletedn");}); 
} 

与其描述大理石图表,不如看一个描述 map 操作符的大理石图表:

大理石图表的顶部显示了一个时间线,其中显示了一系列值(表示为圆圈)。每个值将通过一个 map 操作符,该操作符以 lambda 作为参数。lambda 将应用于流的每个元素,以产生输出流(在图表的底部部分显示为菱形)。

RxCpp(流)操作符

流导向处理的主要优势之一是我们可以在其上应用函数式编程原语。在 RxCpp 术语中,处理是使用操作符完成的。它们只是对流的过滤、转换、聚合和减少。我们已经看到了 mapfiltertake 操作符在之前的示例中是如何工作的。让我们进一步探索它们。

平均值操作符

average 操作符计算来自 Observable Streams 的值的算术平均值。其他支持的统计操作符包括:

  • Min

  • Max

  • 计数

  • Sum

以下程序只是演示了 average 操作符。在前面的列表中,其他操作符的模式是相同的:

//----------- Average.cpp 
#include "rxcpp/rx.hpp" 
#include "rxcpp/rx-test.hpp" 
#include <ioStream> 
int main() { 
    auto values = rxcpp::observable<>::range(1, 20).average(); 
    values.subscribe( 
            [](double v){printf("average: %lfn", v);}, 
            [](){printf("OnCompletedn");}); 
} 

扫描操作符

scan 操作符对流的每个元素依次应用函数,并将值累积到种子值中。以下程序在值累积时产生一系列数字的平均值:

//----------- Scan.cpp 
#include "rxcpp/rx.hpp" 
#include "rxcpp/rx-test.hpp" 
#include <ioStream> 
int main() { 
    int count = 0; 
    auto values = rxcpp::observable<>::range(1, 20). 
        scan( 0,&count{ 
                count++; 
                return seed + v; 
            }); 
    values.subscribe( 
        &{printf("Average through Scan: %fn", (double)v/count);}, 
        [](){printf("OnCompletedn");}); 
} 

运行平均值将打印到控制台。在调用 OnCompleted 之前,OnNext functor 将被调用二十次。

通过管道操作符组合操作符

RxCpp 库允许开发者链式或组合操作符以启用操作符组合。该库允许您使用 pipe (|) 操作符来组合操作符(而不是使用 "." 的通常流畅接口),程序员可以将一个操作符的输出管道传递给另一个,就像在 UNIX shell 的命令行中一样。这有助于理解(代码的作用是什么)。以下程序使用 | 操作符来映射一个范围。RxCpp 示例包含许多使用管道函数的示例:

//------------------ Map_With_Pipe.cpp 
#include "rxcpp/rx.hpp" 
#include "rxcpp/rx-test.hpp" 
namespace Rx { 
using namespace rxcpp; 
using namespace rxcpp::sources; 
using namespace rxcpp::operators; 
using namespace rxcpp::util; 
} 
using namespace Rx; 
#include <ioStream> 
int main() { 
    //---------- chain map to the range using the pipe operator 
    //----------- avoids the use of . notation. 
    auto ints = rxcpp::observable<>::range(1,10) |  
                 map( [] ( int n  ) {return n*n; }); 
    ints.subscribe( 
            [](int v){printf("OnNext: %dn", v);}, 
            [](){printf("OnCompletedn");}); 
}

使用调度器

我们已经在上面的部分学习了 Observables、Operators 和 Observers。我们现在知道,在 Observables 和 Observers 之间,我们可以应用标准的 Rx 操作符来过滤和转换流。在函数式编程的情况下,我们编写不可变的函数(没有副作用的函数),不可变性的结果是可能出现无序执行。如果我们可以保证操作符的输入永远不会被修改,那么执行函数/函子的顺序就不重要了。由于 Rx 程序将操作多个 Observables 和 Observers,我们可以将选择执行顺序的任务委托给调度程序模块。默认情况下,Rxcpp 是单线程的。RxCpp 将在我们调用subscribe方法的线程中安排操作符的执行。可以使用observe_onsubscribe_on操作符指定不同的线程。此外,一些 Observable 操作符以调度程序作为参数,执行可以在调度程序管理的线程中进行。

RxCpp 库支持以下两种调度程序类型:

  • ImmediateScheduler

  • EventLoopScheduler

RxCpp 库默认是单线程的。但是你可以使用特定的操作符来配置它在多个线程中运行:

//----------ObserveOn.cpp 
#include "rxcpp/rx.hpp" 
#include "rxcpp/rx-test.hpp" 
#include <ioStream> 
#include <thread> 
int main(){ 
 //---------------- Generate a range of values 
 //---------------- Apply Square function 
 auto values = rxcpp::observable<>::range(1,4). 
               map([](int v){ return v*v;}); 
 //------------- Emit the current thread details 
 std::cout  << "Main Thread id => "  
            << std::this_thread::get_id()  
            << std::endl; 
 //---------- observe_on another thread.... 
 //---------- make it blocking to  
 //--------- Consult the Rxcpp documentation on observe_on and schedulers
 values.observe_on(rxcpp::synchronize_new_thread()).as_blocking(). 
 subscribe( [](int v){  
                   std::cout << "Observable Thread id => "  
                             << std::this_thread::get_id()  
                             << "  " << v << std::endl ;}, 
                  [](){ std::cout << "OnCompleted" << std::endl; }); 
 //------------------ Print the main thread details 
 std::cout << "Main Thread id => "  
           << std::this_thread::get_id()  
           << std::endl;   
} 

前面的程序将产生以下输出。我们将使用 STD C++线程 ID 来帮助我们区分在新线程中安排的项目(其中一个与主线程不同):

Main Thread id => 1 
Observable Thread id => 2  1 
Observable Thread id => 2  4 
Observable Thread id => 2  9 
Observable Thread id => 2  16 
OnCompleted 
Main Thread id => 1 

以下程序将演示subscribe_on方法的用法。在行为上,observe_onsubscribe_on方法之间存在微妙的差异。以下列表的目的是展示声明式调度的选项:

//---------- SubscribeOn.cpp 
#include "rxcpp/rx.hpp" 
#include "rxcpp/rx-test.hpp" 
#include <ioStream> 
#include <thread> 
#include <mutex> 
//------ A global mutex for output synch. 
std::mutex console_mutex; 
//------ Print the Current Thread details 
void CTDetails() { 
   console_mutex.lock(); 
   std::cout << "Current Thread id => "  
           << std::this_thread::get_id()  << std::endl;  
   console_mutex.unlock();  
} 
//---------- a function to Yield control to other threads 
void Yield( bool y ) { 
   if (y) { std::this_thread::yield(); } 

} 
int main(){ 
    auto threads = rxcpp::observe_on_event_loop(); 
    auto values = rxcpp::observable<>::range(1); 
    //------------- Schedule it in another thread 
    auto s1 = values.subscribe_on(threads). 
        map([](int prime) {  
             CTDetails(); Yield(true); return std::make_tuple("1:", prime);}); 
    //-------- Schedule it in Yet another theread 
    auto s2 = values. subscribe_on(threads).  
        map([](int prime) { 
           CTDetails(); Yield(true) ; return std::make_tuple("2:", prime);}); 

    s1.merge(s2). take(6).as_blocking().subscribe(rxcpp::util::apply_to( 
            [](const char* s, int p) { 
                CTDetails(); 
                console_mutex.lock(); 
                printf("%s %dn", s, p); 
                console_mutex.unlock(); 
            })); 
} 

两个操作符的故事- flatmap 与 concatmap

开发人员经常围绕 flatmap 和concatmap操作符产生困惑。它们的区别非常重要,我们将在本节中进行介绍。让我们看一下 flatmap 操作符以及它的工作原理:

//----------- Flatmap.cpp 
#include "rxcpp/rx.hpp" 
#include "rxcpp/rx-test.hpp" 
#include <ioStream> 
namespace rxu=rxcpp::util; 
#include <array> 
int main() { 
     std::array< std::string,4 > a={{"Praseed", "Peter", "Sanjay","Raju"}}; 
     //---------- Apply Flatmap on the array of names 
     //---------- Flatmap returns an Observable<T> ( map returns T ) 
     //---------- The First lamda creates a new Observable<T> 
     //---------- The Second Lambda manipulates primary Observable and  
     //---------- Flatmapped Observable 
     auto values = rxcpp::observable<>::iterate(a).flat_map( 
              [] (std::string v ) { 
                   std::array<std::string,3> salutation= 
                       { { "Mr." ,  "Monsieur" , "Sri" }}; 
                   return rxcpp::observable<>::iterate(salutation); 
              }, 
              [] ( std::string f , std::string s ) {return s + " " +f;}); 
     //-------- As usual subscribe  
     //-------- Here the value will be interleaved as flat_map merges the  
     //-------- Two Streams 
     values.subscribe(  
              [] (std::string f) { std::cout << f <<  std::endl; } ,  
              [] () {std::cout << "Hello World.." << std::endl;} ); 
      } 

前面的程序产生了不可预测的输出序列。程序的一次运行的输出如下所示。这不一定是再次运行时得到的结果。这种行为的原因与映射操作后的流的后处理有关:flatmap 使用 merge 操作符对流进行后处理。

Mr. Praseed 
Monsieur Praseed 
Mr. Peter 
Sri Praseed 
Monsieur Peter 
Mr. Sanjay 
Sri Peter 
Monsieur Sanjay 
Mr. Raju 
Sri Sanjay 
Monsieur Raju 
Sri Raju 
Hello World.. 

以下的弹珠图显示了操作的模式。flat_map对 Observable Stream 应用 lambda 并产生一个新的 Observable Stream。产生的流被合并在一起以提供输出。在图中,红色的球被转换成一对红色的菱形,而绿色和蓝色的球的输出在新创建的 Observable 中产生交错的菱形:

让我们通过以下列表来看一下concat_map操作符。程序列表与之前的程序相同。唯一的变化是用concat_map替换了flat_map。尽管列表中没有实际区别,但输出行为上有明显的不同。也许concat_map产生的输出更适合程序员的同步心理模型:

//----------- ConcatMap.cpp 
#include "rxcpp/rx.hpp" 
#include "rxcpp/rx-test.hpp" 
#include <ioStream> 
namespace rxu=rxcpp::util; 

#include <array> 
int main() { 

     std::array< std::string,4 > a={{"Praseed", "Peter", "Sanjay","Raju"}}; 
     //---------- Apply Concat map on the array of names 
     //---------- Concat Map returns an Observable<T> ( oncat returns T ) 
     //---------- The First lamda creates a new Observable<T> 
     //---------- The Second Lambda manipulates primary Observable and  
     //---------- Concatenated Observable 
     auto values = rxcpp::observable<>::iterate(a).flat_map( 
              [] (std::string v ) { 
                   std::array<std::string,3> salutation= 
                       { { "Mr." ,  "Monsieur" , "Sri" }}; 
                   return rxcpp::observable<>::iterate(salutation); 
              }, 
              [] ( std::string f , std::string s ) {return s + " " +f;}); 

     //-------- As usual subscribe  
     //-------- Here the value will be interleaved as concat_map concats the  
     //-------- Two Streams 
     values.subscribe(  
              [] (std::string f) { std::cout << f <<  std::endl; } ,  
              [] () {std::cout << "Hello World.." << std::endl;} ); 
 } 

输出将如下所示:

Mr. Praseed 
Monsieur Praseed 
Sri Praseed 
Mr. Peter 
Monsieur Peter 
Sri Peter 
Mr. Sanjay 
Monsieur Sanjay 
Sri Sanjay 
Mr. Raju 
Monsieur Raju 
Sri Raju 
Hello World.. 

以下的弹珠图显示了concat_map的操作。与 Flatmap 弹珠图不同,输出是同步的(红色、绿色和蓝色的球按照输入处理的顺序产生相同颜色的输出):

flat_map的情况下,我们以交错的方式得到了输出。但在concat_map的情况下,我们按照预期的顺序得到了值。这里真正的区别是什么?为了澄清区别,让我们看看两个操作符:concatmerge。让我们看看流的连接方式。它基本上是将流的内容一个接一个地附加,保持顺序:

//---------------- Concat.cpp 
#include "rxcpp/rx.hpp" 
#include "rxcpp/rx-test.hpp" 
#include <ioStream> 
#include <array> 
int main() { 
    auto o1 = rxcpp::observable<>::range(1, 3); 
    auto o3 = rxcpp::observable<>::from(4, 6); 
    auto values = o1.concat(o2); 
    values.subscribe( 
            [](int v){printf("OnNext: %dn", v);},[](){printf("OnCompletedn");}); 
} 

以下弹珠图清楚地显示了当concat操作符应用于两个流时会发生什么。我们通过将第二个流的内容附加到第一个流的内容来创建一个新流。这保留了顺序:

现在,让我们看看当两个流合并时会发生什么。以下代码显示了如何合并两个流:

//------------ Merge.cpp 
#include "rxcpp/rx.hpp" 
#include "rxcpp/rx-test.hpp" 
#include <ioStream> 
#include <array> 
int main() { 
    auto o1 = rxcpp::observable<>::range(1, 3); 
    auto o2 = rxcpp::observable<>::range(4, 6); 
    auto values = o1.merge(o2); 
    values.subscribe( 
            [](int v){printf("OnNext: %dn", v);}, 
             [](){printf("OnCompletedn");}); 
} 

以下弹珠图清楚地显示了当我们合并两个 Observable 流时会发生什么。输出流的内容将是两个流的交错组合:

flat_mapconcat_map基本上做相同的操作。区别在于值的组合方式。flat_map使用merge操作符,而concact_map使用concact操作符进行结果的后处理。在merge的情况下,顺序并不重要。concat操作符将 Observable 一个接一个地附加。这就是为什么使用concat_map会得到同步的输出,而flat_map会产生无序的结果。

其他重要操作符

我们现在理解了响应式编程模型的要点,因为我们涵盖了诸如 Observables、Observers、Operators 和 Schedulers 等基本主题。还有一些我们应该了解以更好地编写逻辑的操作符。在本节中,我们将介绍tapdeferbuffer操作符。我们将首先探讨tap操作符,它可以帮助查看流的内容:

//----------- TapExample.cpp 
#include "rxcpp/rx.hpp" 
#include "rxcpp/rx-test.hpp" 
#include <ioStream> 
int main() { 
    //---- Create a mapped Observable 
     auto ints = rxcpp::observable<>::range(1,3). 
                 map( [] ( int n  ) {return n*n; }); 
     //---- Apply the tap operator...The Operator  
     //---- will act as a filter/debug operator 
     auto values = ints.tap( 
          [](int v)  {printf("Tap -       OnNext: %dn", v);}, 
          [](){printf("Tap -       OnCompletedn"); 
     }); 
     //------- Do some action 
     values.subscribe( 
          [](int v){printf("Subscribe - OnNext: %dn", v);}, 
          [](){printf("Subscribe - OnCompletedn");}); 
 } 

现在,让我们看看defer操作符。defer操作符将 Observable 工厂作为参数,为每个订阅它的客户端创建一个 Observable。在下面的程序中,当有人尝试连接到指定的 Observable 时,我们调用observable_factory lambda:

//----------- DeferExample.cpp 
#include "rxcpp/rx.hpp" 
#include "rxcpp/rx-test.hpp" 
#include <ioStream> 
int main() { 
    auto observable_factory = [](){ 
         return rxcpp::observable<>::range(1,3). 
                 map( [] ( int n  ) {return n*n; }); 
    }; 
    auto ints = rxcpp::observable<>::defer(observable_factory); 
    ints.subscribe([](int v){printf("OnNext: %dn", v);}, 
            [](){printf("OnCompletedn");}); 
    ints.subscribe( 
            [](int v){printf("2nd OnNext: %dn", v);}, 
            [](){printf("2nd OnCompletedn");}); 
} 

buffer操作符发出一个 Observable,其中包含 Observable 的非重叠内容,每个 Observable 最多包含由 count 参数指定的项目数。这将帮助我们以适合内容的方式处理项目:

//----------- BufferExample.cpp 
#include "rxcpp/rx.hpp" 
#include "rxcpp/rx-test.hpp" 
#include <ioStream> 
int main() { 
   auto values = rxcpp::observable<>::range(1, 10).buffer(2); 
   values.subscribe( [](std::vector<int> v){ 
                printf("OnNext:{"); 
                std::for_each(v.begin(), v.end(), [](int a){ 
                    printf(" %d", a); 
                }); 
                printf("}n"); 
            }, 
            [](){printf("OnCompletedn");}); 
} 

timer操作符发出一个 Observable,以间隔周期作为参数。有一个选项可以指定Scheduler对象作为参数。库中有这个函数的各种版本;我们在下面的代码中展示了其中一个:

//----------- TimerExample.cpp 
#include "rxcpp/rx.hpp" 
#include "rxcpp/rx-test.hpp" 
#include <ioStream> 
int main() { 
     auto Scheduler = rxcpp::observe_on_new_thread(); 
     auto period = std::chrono::milliseconds(1); 
     auto values = rxcpp::observable<>::timer(period, Scheduler). 
            finally([](){ 
            printf("The final actionn"); 
        });     
      values.as_blocking().subscribe( 
         [](int v){printf("OnNext: %dn", v);}, 
         [](){printf("OnCompletedn");}); 
} 

我们尚未涵盖的事物一瞥

Rx 编程模型可以被认为是以下内容的汇合:

  • 数据流计算

  • 声明式并发

  • 函数式编程

  • 流处理(事件)

  • 设计模式和习语

要全面了解整个学科,您需要广泛地使用编程模型。最初,事情不会有太多意义。在某个时候,您会达到一个点燃点,一切都会开始有意义。到目前为止,我们已经涵盖了以下主题:

  • Observables 和 Observers

  • 基本和中级操作符

  • 基本和中级调度

这只是一个开始,我们需要涵盖更多的主题,以熟悉编程模型。它们是:

  • 热和冷 Observables(第八章,RxCpp - 关键元素

  • Rx 组件的详细探索(第八章RxCpp - 关键元素

  • 高级调度(第八章RxCpp - 关键元素

  • 编程 GUI 系统(第九章使用 Qt/C++进行响应式 GUI 编程

  • 高级操作符(第十章,在 RxCpp 中创建自定义操作符)

  • 响应式设计模式(第十一章,C++ Rx 编程的设计模式和习语

  • 编程的健壮性(第十三章,高级流和错误处理

总结

在本章中,我们在理解 Rx 编程模型和 RxCpp 库方面涵盖了相当多的内容。我们从数据流计算范式的概念概述开始,迅速转向编写一些基本的 RxCpp 程序。在介绍 Rx 弹珠图后,我们了解了 RxCpp 库支持的一组操作符。我们还介绍了调度器这一重要主题,最后讨论了flatmapconcatmap操作符之间的区别。在下一章中,我们将涵盖hotcold可观察对象,高级调度以及一些本章未涵盖的主题。

第八章:RxCpp - 关键元素

在上一章中,我们介绍了 RxCpp 库及其编程模型。我们编写了一些程序来了解库的工作原理,并介绍了 RxCpp 库的最基本元素。在本章中,我们将深入介绍响应式编程的关键元素,包括以下内容:

  • Observables

  • 观察者及其变体(订阅者)

  • 主题

  • 调度程序

  • 操作符

实际上,响应式编程的关键方面如下:

  • Observables 是观察者可以订阅以获取通知的流

  • 主题是 Observable 和 Observer 的组合

  • 调度程序执行与操作符相关的操作,并帮助数据从 Observables 流向 Observers

  • 操作符是接受 Observable 并发出另一个 Observable 的函数(嗯,几乎是!)

Observables

在上一章中,我们从头开始创建了 Observables 并订阅了这些 Observables。在我们的所有示例中,Observables 创建了Producer类的实例(数据)。Producer类产生一个事件流。换句话说,Observables 是将订阅者(观察者)连接到生产者的函数。

在我们继续之前,让我们剖析一下 Observable 及其相关的核心活动:

  • Observable 是一个以 Observer 作为参数并返回函数的函数

  • Observable 将 Observer 连接到 Producer(Producer 对 Observer 是不透明的)

  • 生产者是 Observable 的值来源

  • 观察者是一个具有on_nexton_erroron_completed方法的对象

生产者是什么?

简而言之,生产者是 Observable 的值来源。生产者可以是 GUI 窗口、定时器、WebSockets、DOM 树、集合/容器上的迭代器等。它们可以是任何可以成为值来源并传递给 Observer 的值的东西(在RxCpp中,observer.on_next(value))。当然,值可以传递给操作符,然后传递给操作符的内部观察者。

热 Observable 与冷 Observable

在上一章的大多数示例中,我们看到 Producers 是在 Observable 函数中创建的。生产者也可以在 Observable 函数之外创建,并且可以将对生产者的引用放在 Observable 函数内。引用到在其范围之外创建的生产者的 Observable 称为热 Observable。任何我们在 Observable 中创建了生产者实例的 Observable 称为冷 Observable。为了搞清楚问题,让我们编写一个程序来演示冷 Observable:

//---------- ColdObservable.cpp 
#include <rxcpp/rx.hpp> 
#include <memory> 
int main(int argc, char *argv[])  
{
 //----------- Get a Coordination 
 auto eventloop = rxcpp::observe_on_event_loop(); 
 //----- Create a Cold Observable 
 auto values = rxcpp::observable<>::interval( 
               std::chrono::seconds(2)).take(2);

在上面的代码中,interval 方法创建了一个冷 Observable,因为事件流的生产者是在interval函数中实例化的。当订阅者或观察者附加到冷 Observable 时,它将发出数据。即使在两个观察者之间订阅存在延迟,结果也将是一致的。这意味着我们将获得 Observable 发出的所有数据的两个观察者:

 //----- Subscribe Twice

values.subscribe_on(eventloop). 
    subscribe([](int v){printf("[1] onNext: %dn", v);}, 
        [](){printf("[1] onCompleted\n");}); 
 values.subscribe_on(eventloop). 
    subscribe([](int v){printf("[2] onNext: %dn", v);}, 
        [](){printf("[2] onCompleted\n");}); 
  //---- make a blocking subscription to see the results 
 values.as_blocking().subscribe(); 
 //----------- Wait for Two Seconds 
 rxcpp::observable<>::timer(std::chrono::milliseconds(2000)). 
       subscribe(&{ }); 
} 

程序发出的输出如下。对于每次运行,控制台中内容的顺序可能会改变,因为我们在同一线程中调度执行观察者方法。但是,由于订阅延迟,不会有数据丢失:

[1] onNext: 1 
[2] onNext: 1 
[2] onNext: 2 
[1] onNext: 2 
[2] onCompleted 
[1] onCompleted 

热 Observable

我们可以通过调用 Observable 的publish方法将冷 Observable 转换为热 Observable。将冷 Observable 转换为热 Observable 的后果是数据可能会被后续的订阅所错过。热 Observable 会发出数据,无论是否有订阅。以下程序演示了这种行为:

//---------- HotObservable.cpp

#include <rxcpp/rx.hpp> 
#include <memory> 
int main(int argc, char *argv[]) { 
 auto eventloop = rxcpp::observe_on_event_loop(); 
 //----- Create a Cold Observable 
 //----- Convert Cold Observable to Hot Observable  
 //----- using .Publish(); 
 auto values = rxcpp::observable<>::interval( 
               std::chrono::seconds(2)).take(2).publish();   
 //----- Subscribe Twice 
 values. 
    subscribe_on(eventloop). 
    subscribe( 
        [](int v){printf("[1] onNext: %dn", v);}, 
        [](){printf("[1] onCompletedn");}); 
  values. 
    subscribe_on(eventloop). 
    subscribe( 
        [](int v){printf("[2] onNext: %dn", v);}, 
        [](){printf("[2] onCompletedn");}); 
 //------ Connect to Start Emitting Values 
 values.connect(); 
 //---- make a blocking subscription to see the results 
 values.as_blocking().subscribe(); 
 //----------- Wait for Two Seconds 
 rxcpp::observable<>::timer( 
       std::chrono::milliseconds(2000)). 
       subscribe(&{ }); 
} 

在下一个示例中,我们将看一下RxCpp 库支持的publish_synchronized机制。从编程接口的角度来看,这只是一个小改变。看一下以下程序:

//---------- HotObservable2.cpp 
#include <rxcpp/rx.hpp> 
#include <memory> 

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

 auto eventloop = rxcpp::observe_on_event_loop(); 
 //----- Create a Cold Observable 
 //----- Convert Cold Observable to Hot Observable  
 //----- using .publish_synchronized(); 
 auto values = rxcpp::observable<>::interval( 
               std::chrono::seconds(2)). 
               take(5).publish_synchronized(eventloop);   
 //----- Subscribe Twice 
 values. 
    subscribe( 
        [](int v){printf("[1] onNext: %dn", v);}, 
        [](){printf("[1] onCompletedn");}); 

 values. 
    subscribe( 
        [](int v){printf("[2] onNext: %dn", v);}, 
        [](){printf("[2] onCompletedn");}); 

 //------ Start Emitting Values 
 values.connect(); 
 //---- make a blocking subscription to see the results 
 values.as_blocking().subscribe(); 

 //----------- Wait for Two Seconds 
 rxcpp::observable<>::timer( 
       std::chrono::milliseconds(2000)). 
       subscribe(&{ }); 
} 

程序的输出如下。我们可以看到输出很好地同步,即输出按正确的顺序显示:

[1] onNext: 1 
[2] onNext: 1 
[1] onNext: 2 
[2] onNext: 2 
[1] onNext: 3 
[2] onNext: 3 
[1] onNext: 4 
[2] onNext: 4 
[1] onNext: 5 
[2] onNext: 5 
[1] onCompleted 
[2] onCompleted

热可观察对象和重放机制

热可观察对象会发出数据,无论是否有订阅者可用。这在我们期望订阅者持续接收数据的情况下可能会成为问题。在响应式编程中有一种机制可以缓存数据,以便稍后的订阅者可以被通知可观察对象的可用数据。我们可以使用.replay()方法来创建这样的可观察对象。让我们编写一个程序来演示重放机制,这在编写涉及热可观察对象的程序时非常有用:

//---------- ReplayAll.cpp 
#include <rxcpp/rx.hpp> 
#include <memory> 
int main(int argc, char *argv[]) { 

  auto values = rxcpp::observable<>::interval( 
                std::chrono::milliseconds(50),  
                rxcpp::observe_on_new_thread()). 
                take(5).replay(); 
    // Subscribe from the beginning 
    values.subscribe( 
        [](long v){printf("[1] OnNext: %ldn", v);}, 
        [](){printf("[1] OnCompletedn");}); 
    // Start emitting 
    values.connect(); 
    // Wait before subscribing 
    rxcpp::observable<>::timer( 
         std::chrono::milliseconds(125)).subscribe(&{ 
        values.as_blocking().subscribe( 
            [](long v){printf("[2] OnNext: %ldn", v);}, 
            [](){printf("[2] OnCompletedn");}); 
    }); 
 //----------- Wait for Two Seconds 
 rxcpp::observable<>::timer( 
       std::chrono::milliseconds(2000)). 
       subscribe(&{ }); 

} 

在编写响应式程序时,您确实需要了解热和冷可观察对象之间的语义差异。我们只是涉及了这个主题的一些方面。请参考 RxCpp 文档和 ReactiveX 文档以了解更多关于热和冷可观察对象的信息。互联网上有无数关于这个主题的文章。

观察者及其变体(订阅者)

观察者订阅可观察对象并等待事件通知。观察者已经在上一章中介绍过了。因此,我们将专注于订阅者,它们是观察者和订阅的组合。订阅者有取消订阅观察者的功能,而“普通”观察者只能订阅。以下程序很好地解释了这些概念:

//---- Subscriber.cpp 
#include "rxcpp/rx.hpp" 
int main() { 
     //----- create a subscription object 
     auto subscription = rxcpp::composite_subscription(); 
     //----- Create a Subscription  
     auto subscriber = rxcpp::make_subscriber<int>( 
        subscription, 
        &{ 
            printf("OnNext: --%dn", v); 
            if (v == 3) 
                subscription.unsubscribe(); // Demonstrates Un Subscribes 
        }, 
        [](){ printf("OnCompletedn");}); 

    rxcpp::observable<>::create<int>( 
        [](rxcpp::subscriber<int> s){ 
            for (int i = 0; i < 5; ++i) { 
                if (!s.is_subscribed())  
                    break; 
                s.on_next(i); 
           } 
            s.on_completed();   
    }).subscribe(subscriber); 
    return 0; 
} 

对于使用并发和动态性(异步时间变化事件)编写复杂程序,订阅和取消订阅的能力非常方便。通过查阅 RxCpp 文档来更深入地了解这个主题。

主题

主题是既是观察者又是可观察对象的实体。它有助于从一个可观察对象(通常)传递通知给一组观察者。我们可以使用主题来实现诸如缓存和数据缓冲之类的复杂技术。我们还可以使用主题将热可观察对象转换为冷可观察对象。在RxCpp 库中实现了四种主题的变体。它们如下:

  • SimpleSubject

  • 行为主题

  • ReplaySubject

  • SynchronizeSubject

让我们编写一个简单的程序来演示主题的工作。代码清单将演示如何将数据推送到主题并使用主题的观察者端检索它们。

//------- SimpleSubject.cpp 
#include <rxcpp/rx.hpp> 
#include <memory> 
int main(int argc, char *argv[]) { 
    //----- Create an instance of Subject 
    rxcpp::subjects::subject<int> subject; 
    //----- Retreive the Observable  
    //----- attached to the Subject 
    auto observable = subject.get_observable(); 
    //------ Subscribe Twice 
    observable.subscribe( [] ( int v ) { printf("1------%dn",v ); }); 
    observable.subscribe( [] ( int v ) { printf("2------%dn",v );}); 
    //--------- Get the Subscriber Interface 
    //--------- Attached to the Subject 
    auto subscriber = subject.get_subscriber(); 
    //----------------- Emit Series of Values 
    subscriber.on_next(1); 
    subscriber.on_next(4); 
    subscriber.on_next(9); 
    subscriber.on_next(16); 
    //----------- Wait for Two Seconds 
    rxcpp::observable<>::timer(std::chrono::milliseconds(2000)). 
       subscribe(&{ }); 
}

BehaviorSubject是 Subject 的一种变体,它作为其实现的一部分存储最后发出的(当前)值。任何新的订阅者都会立即获得当前值。否则,它的行为就像一个普通的 Subject。BehaviorSubject在某些领域中也被称为属性或单元。它在我们更新特定单元或内存区域的一系列数据时非常有用,比如在事务上下文中。让我们编写一个程序来演示BehaviorSubject的工作原理:

//-------- BehaviorSubject.cpp 
#include <rxcpp/rx.hpp> 
#include <memory> 

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

    rxcpp::subjects::behavior<int> behsubject(0); 

    auto observable = behsubject.get_observable(); 
    observable.subscribe( [] ( int v ) { 
        printf("1------%dn",v ); 
     }); 

     observable.subscribe( [] ( int v ) { 
        printf("2------%dn",v ); 
     }); 

    auto subscriber = behsubject.get_subscriber(); 
    subscriber.on_next(1); 
    subscriber.on_next(2); 

    int n = behsubject.get_value(); 

    printf ("Last Value ....%dn",n); 

} 

ReplaySubject是 Subject 的一种变体,它存储已经发出的数据。我们可以指定参数来指示主题必须保留多少个值。在处理热可观察对象时,这非常方便。各种重放重载的函数原型如下:

replay (Coordination cn,[optional] composite_subscription cs) 
replay (std::size_t count, Coordination cn, [optional]composite_subscription cs) 
replay (duration period, Coordination cn, [optional] composite_subscription cs) 
replay (std::size_t count, duration period, Coordination cn,[optional] composite_subscription cs).

让我们编写一个程序来理解ReplaySubject的语义:

//------------- ReplaySubject.cpp 
#include <rxcpp/rx.hpp> 
#include <memory> 
int main(int argc, char *argv[]) { 
    //----------- instantiate a ReplaySubject 
    rxcpp::subjects::replay<int,rxcpp::observe_on_one_worker>       
           replay_subject(10,rxcpp::observe_on_new_thread()); 
    //---------- get the observable interface 
    auto observable = replay_subject.get_observable(); 
    //---------- Subscribe! 
    observable.subscribe( [] ( int v ) {printf("1------%dn",v );}); 
    //--------- get the subscriber interface 
    auto subscriber = replay_subject.get_subscriber(); 
    //---------- Emit data  
    subscriber.on_next(1); 
    subscriber.on_next(2); 
    //-------- Add a new subscriber 
    //-------- A normal subject will drop data 
    //-------- Replay subject will not 
    observable.subscribe( [] ( int v ) {  printf("2------%dn",v );}); 
     //----------- Wait for Two Seconds 
    rxcpp::observable<>::timer( 
       std::chrono::milliseconds(2000)). 
       subscribe(&{ }); 
} 

在本节中,我们介绍了主题的三种变体。主要用例是通过使用可观察接口从不同来源获取事件和数据,并允许一组订阅者消耗获取的数据。SimpleSubject可以作为可观察对象和观察者来处理一系列值。BehaviorSubject用于监视一段时间内属性或变量的变化,而ReplaySubject将帮助您避免由于订阅延迟而导致的数据丢失。最后,SynchronizeSubject是一个具有同步逻辑的主题。

调度器

RxCpp库拥有一个声明性的线程机制,这要归功于其内置的强大调度子系统。从一个 Observable 中,数据可以通过不同的路径流经变化传播图。通过给流处理管道提供提示,我们可以在相同线程、不同线程或后台线程中安排操作符和观察者方法的执行。这有助于更好地捕捉程序员的意图。

RxCpp中的声明性调度模型是可能的,因为操作符实现中的流是不可变的。流操作符将一个 Observable 作为参数,并返回一个新的 Observable 作为结果。输入参数根本没有被改变(这种行为从操作符的实现中隐含地期望)。这有助于无序执行。RxCpp的调度子系统包含以下构造(特定于 Rxcpp v2):

  • 调度程序

  • Worker

  • 协调

  • 协调员

  • 可调度的

  • 时间线

RxCpp的第 2 版从RxJava系统中借用了其调度架构。它依赖于RxJava使用的调度程序和 Worker 习语。以下是关于调度程序的一些重要事实:

  • 调度程序有一个时间线。

  • 调度程序可以在时间线上创建许多 Worker。

  • Worker 拥有时间线上的可调度队列。

  • schedulable拥有一个函数(通常称为Action)并拥有生命周期。

  • Coordination函数作为协调员的工厂,并拥有一个调度程序。

  • 每个协调员都有一个 Worker,并且是以下内容的工厂:

  • 协调的schedulable

  • 协调的 Observables 和订阅者

我们一直在程序中使用 Rx 调度程序,而不用担心它们在幕后是如何工作的。让我们编写一个玩具程序,来帮助我们理解调度程序在幕后是如何工作的:

//------------- SchedulerOne.cpp 
#include "rxcpp/rx.hpp" 
int main(){ 
    //---------- Get a Coordination  
    auto Coordination function= rxcpp::serialize_new_thread(); 
    //------- Create a Worker instance  through a factory method  
    auto worker = coordination.create_coordinator().get_worker(); 
    //--------- Create a action object 
    auto sub_action = rxcpp::schedulers::make_action( 
         [] (const rxcpp::schedulers::schedulable&) {   
          printf("Action Executed in Thread # : %dn",  
          std::this_thread::get_id());   
          } );  
    //------------- Create a schedulable and schedule the action 
    auto scheduled = rxcpp::schedulers::make_schedulable(worker,sub_action); 
    scheduled.schedule(); 
    return 0; 
} 

RxCpp中,所有接受多个流作为输入或涉及对时间有影响的任务的操作符都将Coordination函数作为参数。一些使用特定调度程序的Coordination函数如下:

  • identity_immediate()

  • identity_current_thread()

  • identity_same_worker(worker w)

  • serialize_event_loop()

  • serialize_new_thread()

  • serialize_same_worker(worker w)

  • observe_on_event_loop()

  • observe_on_new_thread()

在前面的程序中,我们手动安排了一个操作(实际上只是一个 lambda)。让我们继续调度程序的声明方面。我们将编写一个使用Coordination函数安排任务的程序:

//----------- SchedulerTwo.cpp 
#include "rxcpp/rx.hpp" 
int main(){ 
    //-------- Create a Coordination function 
    auto Coordination function= rxcpp::identity_current_thread(); 
    //-------- Instantiate a coordinator and create a worker     
    auto worker = coordination.create_coordinator().get_worker(); 
    //--------- start and the period 
    auto start = coordination.now() + std::chrono::milliseconds(1); 
    auto period = std::chrono::milliseconds(1);      
    //----------- Create an Observable (Replay ) 
    auto values = rxcpp::observable<>::interval(start,period). 
    take(5).replay(2, coordination); 
    //--------------- Subscribe first time using a Worker 
    worker.schedule(&{ 
       values.subscribe( [](long v){ printf("#1 -- %d : %ldn",  
                   std::this_thread::get_id(),v);  }, 
                        [](){ printf("#1 --- OnCompletedn");}); 
    }); 
    worker.schedule(&{ 
      values.subscribe( [](long v){printf("#2 -- %d : %ldn",  
                   std::this_thread::get_id(),v); }, 
                     [](){printf("#2 --- OnCompletedn");});  
    }); 
    //----- Start the emission of values  
   worker.schedule(& 
   { values.connect();}); 
   //------- Add blocking subscription to see results 
   values.as_blocking().subscribe(); return 0; 
}

我们使用重放机制创建了一个热 Observable 来处理一些观察者的延迟订阅。我们还创建了一个 Worker 来进行订阅的调度,并将观察者与 Observable 连接起来。前面的程序演示了RxCpp中调度程序的工作原理。

ObserveOn 与 SubscribeOn

ObserveOnSubscribeOn操作符的行为方式不同,这一直是反应式编程新手困惑的来源。ObserveOn操作符改变了其下方的操作符和观察者的线程。而SubscribeOn则影响其上方和下方的操作符和方法。以下程序演示了SubscribeOnObserveOn操作符的行为方式对程序运行时行为的微妙变化。让我们编写一个使用ObserveOn操作符的程序:

//-------- ObservableOnScheduler.cpp 
#include "rxcpp/rx.hpp" 
int main(){ 
    //------- Print the main thread id 
    printf("Main Thread Id is %dn",  
             std::this_thread::get_id()); 
    //-------- We are using observe_on here 
    //-------- The Map will use the main thread 
    //-------- Subscribed Lambda will use a new thread 
    rxcpp::observable<>::range(0,15). 
        map([](int i){ 
            printf("Map %d : %dn", std::this_thread::get_id(),i);  
            return i; }). 
        take(5).observe_on(rxcpp::synchronize_new_thread()). 
        subscribe(&{ 
           printf("Subs %d : %dn", std::this_thread::get_id(),i);  
        }); 
    //----------- Wait for Two Seconds 
    rxcpp::observable<>::timer( 
       std::chrono::milliseconds(2000)). 
       subscribe(&{ }); 

    return 0; 
}

前述程序的输出如下:

Main Thread Id is 1 
Map 1 : 0 
Map 1 : 1 
Subs 2 : 0 
Map 1 : 2 
Subs 2 : 1 
Map 1 : 3 
Subs 2 : 2 
Map 1 : 4 
Subs 2 : 3 
Subs 2 : 4 

前述程序的输出清楚地显示了map在主线程中工作,而subscribe方法在次要线程中被调度。这清楚地表明ObserveOn只对其下方的操作符和订阅者起作用。让我们编写一个几乎相同的程序,使用SubscribeOn操作符而不是ObserveOn操作符。看一下这个:

//-------- SubscribeOnScheduler.cpp 
#include "rxcpp/rx.hpp" 
int main(){ 
    //------- Print the main thread id 
    printf("Main Thread Id is %dn",  
             std::this_thread::get_id()); 
    //-------- We are using subscribe_on here 
    //-------- The Map and subscribed Lambda will  
    //--------- use the secondary thread 
    rxcpp::observable<>::range(0,15). 
        map([](int i){ 
            printf("Map %d : %dn", std::this_thread::get_id(),i);  
            return i; 
        }). 
        take(5).subscribe_on(rxcpp::synchronize_new_thread()). 
        subscribe(&{ 
           printf("Subs %d : %dn", std::this_thread::get_id(),i);  
        }); 
    //----------- Wait for Two Seconds 
    rxcpp::observable<>::timer( 
       std::chrono::milliseconds(2000)). 
       subscribe(&{ }); 

    return 0; 
}

前述程序的输出如下:

Main Thread Id is 1 
Map 2 : 0 
Subs 2 : 0 
Map 2 : 1 
Subs 2 : 1 
Map 2 : 2 
Subs 2 : 2 
Map 2 : 3 
Subs 2 : 3 
Map 2 : 4 
Subs 2 : 4 

前述程序的输出显示 map 和订阅方法都在次要线程中工作。这清楚地显示了SubscribeOn改变了它之前和之后的项目的线程行为。

RunLoop 调度程序

RxCpp 库没有内置的主线程调度程序的概念。你能做的最接近的是利用run_loop类来模拟在主线程中进行调度。在下面的程序中,Observable 在后台线程执行,订阅方法在主线程运行。我们使用subscribe_onobserve_on来实现这个目标:

//------------- RunLoop.cpp 
#include "rxcpp/rx.hpp" 
int main(){ 
    //------------ Print the Main Thread Id 
    printf("Main Thread Id is %dn",  
                std::this_thread::get_id()); 
    //------- Instantiate a run_loop object 
    //------- which will loop in the main thread 
    rxcpp::schedulers::run_loop rlp; 
    //------ Create a Coordination functionfor run loop 
    auto main_thread = rxcpp::observe_on_run_loop(rlp); 
    auto worker_thread = rxcpp::synchronize_new_thread(); 
    rxcpp::composite_subscription scr; 
    rxcpp::observable<>::range(0,15). 
        map([](int i){ 
            //----- This will get executed in worker 
            printf("Map %d : %dn", std::this_thread::get_id(),i);  
            return i; 
        }).take(5).subscribe_on(worker_thread). 
        observe_on(main_thread). 
        subscribe(scr, &{ 
            //--- This will get executed in main thread 
            printf("Sub %d : %dn", std::this_thread::get_id(),i); }); 
    //------------ Execute the Run Loop 
    while (scr.is_subscribed() || !rlp.empty()) { 
        while (!rlp.empty() && rlp.peek().when < rlp.now()) 
        { rlp.dispatch();} 
    }  
    return 0; 
} 

前述程序的输出如下:

Main Thread Id is 1 
Map 2 : 0 
Map 2 : 1 
Sub 1 : 0 
Sub 1 : 1 
Map 2 : 2 
Map 2 : 3 
Sub 1 : 2 
Map 2 : 4 
Sub 1 : 3 
Sub 1 : 4 

我们可以看到 map 被调度在工作线程中,订阅方法在主线程中执行。这是因为我们巧妙地放置了 subscribe_on 和 observe_on 运算符,这是我们在前一节中介绍的。

运算符

运算符是应用于 Observable 以产生新的 Observable 的函数。在这个过程中,原始 Observable 没有被改变,并且可以被认为是一个纯函数。我们已经在我们编写的示例程序中涵盖了许多运算符。在第十章中,在 Rxcpp 中创建自定义运算符,我们将学习如何创建在 Observables 上工作的自定义运算符。运算符不改变(输入)Observable 的事实是声明式调度在 Rx 编程模型中起作用的原因。Rx 运算符可以被分类如下:

  • 创建运算符

  • 变换运算符

  • 过滤运算符

  • 组合运算符

  • 错误处理运算符

  • 实用运算符

  • 布尔运算符

  • 数学运算符

还有一些更多的运算符不属于这些类别。我们将提供一个来自前述类别的关键运算符列表,作为一个快速参考的表格。作为开发人员,可以根据上面给出的表格来选择运算符,根据上下文来选择运算符。

创建运算符

这些运算符将帮助开发人员从输入数据中创建各种类型的 Observables。我们已经在我们的示例代码中演示了 create、from、interval 和 range 运算符的使用。请参考这些示例和 RxCpp 文档以了解更多信息。下面给出了一张包含一些运算符的表格:

Observables 描述
create 通过以编程方式调用 Observer 方法创建一个 Observable
defer 为每个 Observer/Subscriber 创建一个新的 Observable
empty 创建一个不发出任何内容的 Observable(只在完成时发出)
from 根据参数创建一个 Observable(多态)
interval 创建一个在时间间隔内发出一系列值的 Observable
just 创建一个发出单个值的 Observable
range 创建一个发出一系列值的 Observable
never 创建一个永远不发出任何内容的 Observable
repeat 创建一个重复发出值的 Observable
timer 创建一个在延迟因子之后发出值的 Observable,可以将其指定为参数
throw 创建一个发出错误的 Observable

变换运算符

这些运算符帮助开发人员创建一个新的 Observable,而不修改源 Observable。它们通过在源 Observable 上应用 lambda 或函数对象来作用于源 Observable 中的单个项目。下面给出了一张包含一些最有用的变换运算符的表格。

Observables 描述
buffer 收集过去的值并在收到信号时发出的 Observable
flat_map 发出应用于源 Observable 和集合 Observable 发出的一对值的函数的结果的 Observable
group_by 帮助从 Observable 中分组值的 Observable
map 通过指定的函数转换源 Observable 发出的项目的 Observable
scan 发出累加器函数的每次调用的结果的 Observable
window 发出连接的、不重叠的项目窗口的 Observable。 每个窗口将包含特定数量的项目,该数量作为参数给出。 参数名为 count。

过滤运算符

过滤流的能力是流处理中的常见活动。 Rx 编程模型定义了许多过滤类别的运算符并不罕见。 过滤运算符主要是谓词函数或 lambda。 以下表格包含过滤运算符的列表:

Observables Description
debounce 如果经过一段特定的时间间隔而没有从源 Observable 发出另一个项目,则发出一个项目的 Observable
distinct 发出源 Observable 中不同的项目的 Observable
element_at 发出位于指定索引位置的项目的 Observable
filter 只发出由过滤器评估为 true 的源 Observable 发出的项目的 Observable
first 只发出源 Observable 发出的第一个项目的 Observable
ignore_eleements 从源 Observable 发出终止通知的 Observable
last 只发出源 Observable 发出的最后一个项目的 Observable
sample 在周期时间间隔内发出源 Observable 发出的最近的项目的 Observable
skip 与源 Observable 相同的 Observable,只是它不会发出源 Observable 发出的前 t 个项目
skip_last 与源 Observable 相同的 Observable,只是它不会发出源 Observable 发出的最后 t 个项目
take 只发出源 Observable 发出的前 t 个项目,或者如果该 Observable 发出的项目少于 t 个,则发出源 Observable 的所有项目
take_last 只发出源 Observable 发出的最后 t 个项目的 Observable

组合运算符

Rx 编程模型的主要目标之一是将事件源与事件接收器解耦。 显然,需要能够组合来自各种来源的流的运算符。 RxCpp 库实现了一组此类运算符。 以下表格概述了一组常用的组合运算符:

Observables Description
combine_latest 当两个 Observables 中的任一 Observable 发出项目时,通过指定的函数组合每个 Observable 发出的最新项目,并根据该函数的结果发出项目
merge 通过合并它们的发射将多个 Observables 合并为一个
start_with 在开始发出源 Observable 的项目之前,发出指定的项目序列
switch_on_next 将发出 Observables 的 Observable 转换为发出最近发出的 Observable 发出的项目的单个 Observable
zip 通过指定的函数将多个 Observables 的发射组合在一起,并根据该函数的结果发出每个组合的单个项目

错误处理运算符

这些是在管道执行过程中发生异常时帮助我们进行错误恢复的运算符。

Observables Description
Catch RxCpp不支持
retry 如果调用on_error,则会重新订阅源 Observable 的 Observable,最多重试指定次数

Observable 实用程序运算符

以下是用于处理 Observables 的有用实用程序运算符工具箱: observe_on 和 subscribe_on 运算符帮助我们进行声明式调度。 我们已经在上一章中介绍过它们。

Observables Description
finally Observable 发出与源 Observable 相同的项目,然后调用给定的操作
observe_on 指定观察者将观察此 Observable 的调度程序
subscribe 对 Observable 的发射和通知进行操作
subscribe_on 指定 Observable 订阅时应使用的调度程序
scope 创建与 Observable 寿命相同的一次性资源

条件和布尔运算符

条件和布尔运算符是评估一个或多个 Observable 或 Observable 发出的项目的运算符:

Observables Description
all 如果源 Observable 发出的每个项目都满足指定条件,则发出 true 的 Observable;否则,它发出 false
amb Observable 发出与源 Observables 中首先发出项目或发送终止通知的相同序列
contains 如果源 Observable 发出了指定的项目,则发出 true 的 Observable;否则发出 false
default_if_empty 如果源 Observable 发出了指定的项目,则发出 true 的 Observable;否则发出 false
sequence_equal 只有在发出相同顺序的相同项目序列后正常终止时,Observable 才会发出 true;否则,它将发出 false
skip_until 直到第二个 Observable 发出项目之前,丢弃由 Observable 发出的项目
skip_while 直到指定条件变为 false 后,丢弃由 Observable 发出的项目
take_until 在第二个 Observable 发出项目或终止后,丢弃由 Observable 发出的项目
take_while 在指定条件变为 false 后,丢弃由 Observable 发出的项目

数学和聚合运算符

这些数学和聚合运算符是一类操作符,它们对 Observable 发出的整个项目序列进行操作:它们基本上将 Observable减少为类型 T 的某个值。它们不会返回 Observable。

Observables Description
average 计算 Observable 发出的数字的平均值并发出此平均值
concat 发出两个或多个 Observable 的发射,而不对它们进行交错
count 计算源 Observable 发出的项目数量并仅发出此值
max 确定并发出 Observable 发出的最大值项目
min 确定并发出 Observable 发出的最小值项目
reduce 对 Observable 发出的每个项目依次应用函数,并发出最终值
sum 计算 Observable 发出的数字的总和并发出此总和

可连接的 Observable 运算符

可连接的 Observable 是具有更精确控制的订阅动态的特殊 Observable。以下表格列出了一些具有高级订阅语义的关键运算符

Observables Description
connect 指示可连接的 Observable 开始向其订阅者发出项目
publish 将普通 Observable 转换为可连接的 Observable
ref_count 使可连接的 Observable 表现得像普通的 Observable
replay 确保所有观察者看到相同的发出项目序列,即使它们在 Observable 开始发出项目后订阅。此运算符与热 Observable 一起使用

摘要

在本章中,我们了解了 Rx 编程模型的各个部分是如何配合的。我们从 Observables 开始,迅速转移到热和冷 Observables 的主题。然后,我们讨论了订阅机制及其使用。接着,我们转向了 Subjects 这一重要主题,并了解了多种 Scheduler 实现的工作方式。最后,我们对 RxCpp 系统中提供的各种操作符进行了分类。在下一章中,我们将学习如何利用迄今为止所学的知识,以一种反应式的方式使用 Qt 框架编写 GUI 程序。

第九章:使用 Qt/C++进行响应式 GUI 编程

Qt(发音为可爱)生态系统是一个全面的基于 C++的框架,用于编写跨平台和多平台 GUI 应用程序。如果您使用库的可移植核心编写程序,可以利用该框架支持的“一次编写,到处编译”范式。在某些情况下,人们使用特定于平台的功能,例如支持 ActiveX 编程模型以编写基于 Windows 的应用程序。

我们遇到了一些情况,Qt 在 Windows 上编写应用程序时优于 MFC。这可能是因为编程简单,因为 Qt 仅使用 C++语言特性的一个非常小的子集来构建其库。该框架的最初目标当然是跨平台开发。Qt 在各个平台上的单一源可移植性、功能丰富性、源代码的可用性以及完善的文档使其成为一个非常友好的框架。这些特点使其在 1995 年首次发布以来,已经繁荣了二十多年。

Qt 提供了一个完整的接口环境,支持开发多平台 GUI 应用程序、Webkit API、媒体流、文件系统浏览器、OpenGL API 等。涵盖这个精彩库的全部功能需要一本专门的书。本章的目的是介绍如何通过利用 Qt 和 RxCpp 库来编写响应式 GUI 应用程序。我们已经在第七章“数据流计算和 RxCpp 库介绍”和第八章“RxCpp - 关键元素”中介绍了响应式编程模型的核心。现在是时候将我们在前几章中学到的知识付诸实践了!Qt 框架本身具有强大的事件处理系统,人们需要学习这些库特性,然后才能将 RxCpp 构造整合到其中。

在本章中,我们将探讨:

  • Qt GUI 编程的快速介绍

  • Hello World - Qt 程序

  • Qt 事件模型,使用信号/槽/MOC - 一个例子

  • 将 RxCpp 库与 Qt 事件模型集成

  • 在 Rxcpp 中创建自定义操作符

Qt GUI 编程的快速介绍

Qt 是一个跨平台应用程序开发框架,用于编写可以在多个平台上作为本机应用程序运行的软件,而无需更改太多代码,具有本机平台功能和速度。除了 GUI 应用程序,我们还可以使用该框架编写控制台或命令行应用程序,但主要用例是图形用户界面。

尽管使用 Qt 编写的应用程序通常是用 C++编写的,但也存在 QML 绑定到其他语言的情况。Qt 简化了 C++开发的许多方面,使用了全面而强大的 API 和工具。Qt 支持许多编译器工具链,如 GCC C++编译器和 Visual C++编译器。Qt 还提供了 Qt Quick(包括 QML,一种基于 ECMAScript 的声明性脚本语言)来编写逻辑。这有助于快速开发移动平台应用程序,尽管逻辑可以使用本机代码编写以获得最佳性能。ECMAScript/C++组合提供了声明式开发和本机代码速度的最佳结合。

Qt 目前由 The Qt Company 开发和维护,并且该框架可用于开源和专有许可证。刚开始时,Qt 使用自己的绘图引擎和控件来模拟不同平台的外观和感觉(由于自定义绘图引擎,可以在 GNU Linux 下创建 Windows 的外观和感觉)。这有助于开发人员轻松地跨平台移植,因为目标平台依赖性很小。由于模拟不完美,Qt 开始使用平台的本机样式 API,以及自己的本机小部件集。这解决了 Qt 自己的绘图引擎模拟的问题,但代价是在各个平台上不再具有统一的外观和感觉。Qt 库与 Python 编程语言有很好的绑定,被称为 PyQt。

在程序员利用库之前,有一些基本的东西程序员必须了解。在接下来的几节中,我们将快速介绍 Qt 对象模型、信号和槽、事件系统和元对象系统的方面。

Qt 对象模型

在 GUI 框架中,运行时效率和高级灵活性是关键因素。标准 C++对象模型提供了非常高效的运行时支持,但其静态性在某些问题领域是不灵活的。Qt 框架将 C++的速度与 Qt 对象模型的灵活性结合起来。

Qt 对象模型支持以下功能:

  • 信号和槽,用于无缝对象通信

  • 可查询和可设计的对象属性

  • 强大的事件和事件过滤器

  • 强大的内部驱动定时器,实现在事件驱动的 GUI 中许多任务的平滑、非阻塞工作

  • 国际化与上下文字符串翻译

  • 受保护的指针(QPointers),当引用的对象被销毁时自动设置为 0

  • 跨库边界工作的动态转换

其中许多功能是作为标准 C++类实现的,基于从QObject继承。其他功能,如信号和槽以及对象属性系统,需要 Qt 自己的元对象编译器MOC)提供的元对象系统。元对象系统是 C++语言的扩展,使其更适合 GUI 编程。MOC 充当预编译器,根据源代码中嵌入的提示生成代码,并删除这些提示,以便 ANSI C++编译器执行其正常的编译任务。

让我们来看看 Qt 对象模型中的一些类:

类名 描述
QObject 所有 Qt 对象的基类(doc.qt.io/archives/qt-4.8/qobject.html
QPointer QObject提供受保护指针的模板类(doc.qt.io/archives/qt-4.8/qpointer.html
QSignalMapper 将可识别发送者的信号捆绑在一起(doc.qt.io/archives/qt-4.8/qsignalmapper.html
QVariant 作为最常见的 Qt 数据类型的联合体(doc.qt.io/archives/qt-4.8/qvariant.html
QMetaClassInfo 类的附加信息(doc.qt.io/archives/qt-4.8/qmetaclassinfo.html
QMetaEnum 枚举类型的元数据(doc.qt.io/archives/qt-4.8/qmetaenum.html
QMetaMethod 成员函数的元数据(doc.qt.io/archives/qt-4.8/qmetamethod.html
QMetaObject 包含有关 Qt 对象的元信息(doc.qt.io/archives/qt-4.8/qmetaobject.html
QMetaProperty 关于属性的元数据(doc.qt.io/archives/qt-4.8/qmetaproperty.html
QMetaType 管理元对象系统中的命名类型(doc.qt.io/archives/qt-4.8/qmetatype.html
QObjectCleanupHandler 监视多个QObject的生命周期(doc.qt.io/archives/qt-4.8/qobjectcleanuphandler.html

Qt 对象通常被视为标识,而不是值。标识被克隆,而不是复制或分配;克隆标识是比复制或分配值更复杂的操作。因此,QObject和所有QObject的子类(直接或间接)都禁用了它们的复制构造函数和赋值运算符。

信号和槽

信号和槽是 Qt 中用于实现对象间通信的机制。信号和槽机制是 Qt 的一个核心特性,作为 GUI 框架。在 Qt 中,小部件通过这种机制得知其他小部件的变化。一般来说,任何类型的对象都使用这种机制相互通信。例如,当用户点击关闭按钮时,我们可能希望调用窗口的close()函数。

信号和槽是 C/C++中回调技术的替代品。当特定事件发生时,会发出信号。Qt 框架中的所有小部件都有预定义的信号,但我们总是可以对小部件进行子类化,以添加我们自己的信号。槽是响应信号调用的函数。与预定义信号类似,Qt 小部件有许多预定义的槽,但我们可以添加自定义槽来处理我们感兴趣的信号。

来自 Qt 官方文档(doc.qt.io/archives/qt-4.8/signalsandslots.html)的以下图表演示了通过信号和槽进行对象间通信的过程:

信号和槽是松散耦合的通信机制;发出信号的类不关心接收信号的槽。信号是忘记即发的完美例子。信号和槽系统确保如果信号连接到槽,槽将在适当的时间以信号的参数被调用。信号和槽都可以接受任意数量和任意类型的参数,并且它们是完全类型安全的。因此,信号和接收槽的签名必须匹配;因此,编译器可以帮助我们检测类型不匹配,作为一个奖励。

所有从QObject或其任何子类(如QWidget)继承的对象都可以包含信号和槽。当对象改变其状态时,会发出信号,这可能对其他对象很有趣。对象不知道(或不关心)接收端是否有任何对象。一个信号可以连接到尽可能多的槽。同样,我们可以将尽可能多的信号连接到单个槽。甚至可以将一个信号连接到另一个信号;因此,信号链是可能的。

因此,信号和系统一起构成了一个非常灵活和可插拔的组件编程机制。

事件系统

在 Qt 中,事件代表应用程序中发生的事情或应用程序需要知道的用户活动。在 Qt 中,事件是从抽象的QEvent类派生的对象。任何QObject子类的实例都可以接收和处理事件,但它们对小部件特别相关。

每当事件发生时,适当的QEvent子类实例被构造,并通过调用其event()函数将其所有权交给特定的QObject实例(或任何相关的子类)。这个函数本身不处理事件;根据传递的事件类型,它调用特定类型事件的事件处理程序,并根据事件是否被接受或被忽略发送响应。

一些事件,比如QCloseEventQMoveEvent,来自应用程序本身;一些,比如QMouseEventQKeyEvent,来自窗口系统;还有一些,比如QTimerEvent,来自其他来源。大多数事件都有从QEvent派生的特定子类,并且有时还有特定于事件的函数来满足扩展事件的特定行为。举例来说,QMouseEvent类添加了x()y()函数,以便小部件发现鼠标光标的位置。

每个事件都有与之关联的类型,在QEvent::Type下定义,这是一种方便的运行时类型信息的来源,用于快速识别事件从哪个子类构造而来。

事件处理程序

通常,通过调用相关的虚函数来渲染事件。虚函数负责按预期响应。如果自定义虚函数实现不执行所有必要的操作,我们可能需要调用基类的实现。

例如,以下示例处理自定义标签小部件上的鼠标左键单击,同时将所有其他按钮单击传递给基类QLabel类:

void my_QLabel::mouseMoveEvent(QMouseEvent *evt)
{
    if (event->button() == Qt::LeftButton) {
        // handle left mouse button here
        qDebug() <<" X: " << evt->x() << "t Y: " << evt->y() << "n";
    }
    else {
        // pass on other buttons to base class
        QLabel::mouseMoveEvent(event);
    }
}

如果我们想要替换基类功能,我们必须在虚函数覆盖中实现所有内容。如果要求只是简单地扩展基类功能,我们可以实现我们想要的内容,并调用基类函数处理我们不想处理的其他情况。

发送事件

许多使用 Qt 框架的应用程序希望发送自己的事件,就像框架提供的事件一样。可以通过使用事件对象构造适当的自定义事件,并使用QCoreApplication::sendEvent()QCoreApplication::postEvent()发送它们。

sendEvent()是同步执行的;因此,它会立即处理事件。对于许多事件类,有一个名为isAccepted()的函数,告诉我们上一个被调用的处理程序是否接受或拒绝了事件。

postEvent()是异步执行的;因此,它将事件发布到队列中以供以后调度。下次 Qt 的主事件循环运行时,它会调度所有发布的事件,进行一些优化。例如,如果有多个调整大小事件,它们会被压缩成一个,作为所有调整大小事件的并集,从而避免用户界面的闪烁。

元对象系统

Qt 元对象系统实现了信号和槽机制用于对象间通信,动态属性系统和运行时类型信息。

Qt 元对象系统基于三个关键方面:

  • QObject类:为 Qt 对象提供元对象系统的优势的基类

  • Q_OBJECT宏:在类声明的私有部分提供的宏,用于启用元对象特性,如动态属性、信号和槽

  • MOC:为每个QObject子类提供实现元对象特性所需的代码

MOC 在 Qt 源文件的实际编译之前执行。当 MOC 发现包含Q_OBJECT宏的类声明时,它会为这些类中的每一个生成另一个带有元对象代码的 C++源文件。生成的源文件要么通过#include包含在类的源文件中,要么更常见的是与类的实现一起编译和链接。

Hello World - Qt 程序

现在,让我们开始使用 Qt/C++进行 GUI 应用程序开发。在进入下面的章节之前,从 Qt 的官方网站(www.qt.io/download)下载 Qt SDK 和 Qt Creator。我们将在本章讨论的代码完全符合 LGPL,并且将通过编写纯 C++代码手工编码。Qt 框架旨在使编码愉快和直观,以便您可以手工编写整个应用程序,而不使用 Qt Creator IDE。

Qt Creator 是一个跨平台的 C++、JavaScript 和 QML 集成开发环境,是 Qt GUI 应用程序开发框架的一部分。它包括一个可视化调试器和集成的 GUI 布局和表单设计器。编辑器的功能包括语法高亮和自动补全。Qt Creator 在 Linux 和 FreeBSD 上使用 GNU 编译器集合的 C++编译器。在 Windows 上,它可以使用 MinGW 或 MSVC,默认安装时还可以使用 Microsoft 控制台调试器,当从源代码编译时。也支持 Clang。- 维基百科 (en.wikipedia.org/wiki/Qt_Creator)

让我们从一个简单的Hello World程序开始,使用一个标签小部件。在这个例子中,我们将创建并显示一个带有文本Hello World, QT!的标签小部件:

#include <QApplication> 
#include <QLabel> 

int main (int argc, char* argv[]) 
{ 
    QApplication app(argc, argv); 
    QLabel label("Hello World, QT!"); 
    Label.show(); 
    return app.execute(); 
}

在这段代码中,我们包含了两个库:<QApplication><QLabel>QApplication对象定义在QApplication库中,它管理应用程序中的资源,并且是运行任何 Qt 基于 GUI 的应用程序所必需的。这个对象接受程序的命令行参数,当调用app.execute()时,Qt 事件循环就会启动。

事件循环是一种程序结构,允许事件被优先级排序、排队和分派给对象。在基于事件的应用程序中,某些函数被实现为被动接口,以响应某些事件的调用。事件循环通常会持续运行,直到发生终止事件(例如用户点击退出按钮)。

QLabel是所有 Qt 小部件中最简单的小部件,定义在<QLabel>中。在这段代码中,标签被实例化为文本Hello World, QT。当调用label.show()时,一个带有实例化文本的标签将出现在屏幕上,显示在自己的窗口框架中。

现在,要构建和运行应用程序,我们需要的第一件事是一个项目文件。要创建一个项目文件并编译应用程序,我们需要按照以下步骤进行:

  1. 创建一个目录,并将源代码保存在该目录中的 CPP 文件中。

  2. 打开一个 shell,并使用qmake -v命令验证安装的qmake版本。如果找不到qmake,则需要将安装路径添加到环境变量中。

  3. 现在,在 shell 中切换到 Qt 文件路径,并执行qmake -project命令。这将为应用程序创建一个项目文件。

  4. 打开项目文件,并在INCLUDEPATH之后的.pro文件中添加以下行:

... 
INCLUDEPATH += . 
QT += widgets 
... 
  1. 然后,运行qmake而不带参数,以创建包含构建应用程序规则的make文件。

  2. 运行make(根据平台的不同可能是nmakegmake),它将根据Makefile中指定的规则构建应用程序。

  3. 如果你运行应用程序,一个带有标签的小窗口将出现,上面写着 Hello World, QT!。

构建任何 Qt GUI 应用程序的步骤都是相同的,只是可能需要在项目文件中进行一些更改。对于我们将在本章讨论的所有未来示例,构建和运行意味着遵循这些步骤。

在我们继续下一个示例之前,让我们玩一些。用以下代码替换QLabel的实例化:

QLabel label("<h2><i>Hello World</i>, <font color=green>QT!</font></h2>"); 

现在,重新构建并运行应用程序。正如这段代码所说明的,通过使用一些简单的 HTML 样式格式化,定制 Qt 的用户界面是很容易的。

在下一节中,我们将学习如何处理 Qt 事件以及使用信号和槽来进行对象通信。

Qt 事件模型与信号/槽/MOC - 一个例子

在这一节中,我们将创建一个应用程序来处理QLabel中的鼠标事件。我们将在自定义的QLabel中重写鼠标事件,并在放置自定义标签的对话框中处理它们。这个应用程序的方法如下:

  1. 创建一个自定义的my_QLabel类,继承自框架QLabel类,并重写鼠标事件,如鼠标移动、鼠标按下和鼠标离开。

  2. my_QLabel中定义与这些事件对应的信号,并从相应的事件处理程序中发出它们。

  3. 创建一个从QDialog类继承的对话框类,并手动编写所有小部件的位置和布局,包括用于处理鼠标事件的自定义小部件。

  4. 在对话框类中,定义槽来处理从my_QLabel对象发出的信号,并在对话框中显示适当的结果。

  5. QApplication对象下实例化这个对话框,并执行。

  6. 创建项目文件以构建小部件应用程序并使其运行起来。

创建一个自定义小部件

让我们编写头文件my_qlabel.h来声明类my_QLabel

#include <QLabel> 
#include <QMouseEvent> 

class my_QLabel : public QLabel 
{ 
    Q_OBJECT 
public: 
    explicit my_QLabel(QWidget *parent = nullptr); 

    void mouseMoveEvent(QMouseEvent *evt); 
    void mousePressEvent(QMouseEvent* evt); 
    void leaveEvent(QEvent* evt); 

    int x, y; 

signals: 
    void Mouse_Pressed(); 
    void Mouse_Position(); 
    void Mouse_Left(); 
}; 

QLabelQMouseEvent在包含的库<QLabel><QMouseEvent>中被定义。该类从QLabel派生,以继承其默认行为,并且QObject被赋予处理信号机制的属性。

在头文件的私有部分,我们添加了一个Q_OBJECT宏,通知 MOC 它必须为这个类生成元对象代码。元对象代码是信号和槽机制、运行时类型信息和动态属性系统所必需的。

在类头部,除了构造函数声明之外,还重写了鼠标事件,如鼠标移动事件、鼠标按下事件和鼠标离开事件。此外,公共整数变量保存了鼠标指针的当前XY坐标。最后,在信号部分声明了从每个鼠标事件发出的信号。

现在,让我们在一个 CPP 文件my_qlabel.cpp中定义这些项目:

#include "my_qlabel.h" 

my_QLabel::my_QLabel(QWidget *parent) : QLabel(parent), x(0), y(0)  {} 

void my_QLabel::mouseMoveEvent(QMouseEvent *evt) 
{ 
    this->x = evt->x(); 
    this->y = evt->y(); 
    emit Mouse_Position(); 
} 

在构造函数中,将父类传递给QLabel基类,以继承重写类中未处理的情况,并将坐标变量初始化为零。在mouse-move事件处理程序中,更新保存鼠标坐标的成员变量,并发出信号Mouse_Position()。使用my_QLabel的对话框可以将这个信号连接到父对话框类中相应的mouse-move槽,并更新 GUI:

void my_QLabel::mousePressEvent(QMouseEvent *evt) 
{ 
    emit Mouse_Pressed(); 
} 

void my_QLabel::leaveEvent(QEvent *evt) 
{ 
   emit Mouse_Left(); 
} 

mouse-press事件处理程序中发出信号Mouse_Pressed(),从mouse-leave事件中发出Mouse_Left()信号。这些信号被连接到父窗口(Dialog类)中相应的槽,并更新 GUI。因此,我们编写了一个自定义标签类来处理鼠标事件。

创建应用程序对话框

由于标签类已经被实现,我们需要实现对话框类来放置所有的小部件,并处理从my_QLabel对象发出的所有信号。让我们从dialog.h头文件开始:

#include <QDialog> 

class my_QLabel; 
class QLabel; 

class Dialog : public QDialog 
{ 
    Q_OBJECT 
public: 
    explicit Dialog(QWidget *parent = 0); 
    ~Dialog(); 

private slots: 
    void Mouse_CurrentPosition(); 
    void Mouse_Pressed(); 
    void Mouse_Left(); 

private: 
    void initializeWidgets(); 
    my_QLabel *label_MouseArea; 
    QLabel *label_Mouse_CurPos; 
    QLabel *label_MouseEvents; 
}; 

在这里,我们创建了一个从QDialog继承的Dialog类,在<QDialog>库下定义。在这个类头文件中,QLabelmy_QLabel类被提前声明,因为实际的库将被包含在类定义文件中。正如我们已经讨论过的,必须包含Q_OBJECT宏来生成元对象代码,以启用信号和槽机制、运行时类型信息和动态属性系统。

除了构造函数和析构函数声明之外,还声明了私有槽,用于连接到my_QLabel对象发出的信号。这些槽是普通函数,可以正常调用;它们唯一的特殊功能是可以连接到信号。Mouse_CurrentPosition()槽将连接到my_QLabel对象的mouseMoveEvent()发出的信号。类似地,Mouse_Pressed()将连接到mousePressEvent()MouseLeft()将连接到my_QLabel对象的leaveEvent()

最后,声明了所有部件指针和一个名为initializeWidgets()的私有函数,用于在对话框中实例化和布局部件。

Dialog类的实现属于dialog.cpp

#include "dialog.h" 
#include "my_qlabel.h" 
#include <QVBoxLayout> 
#include <QGroupBox> 

Dialog::Dialog(QWidget *parent) : QDialog(parent) 
{ 
    this->setWindowTitle("My Mouse-Event Handling App"); 
    initializeWidgets(); 

    connect(label_MouseArea, SIGNAL(Mouse_Position()), this, SLOT(Mouse_CurrentPosition())); 
    connect(label_MouseArea, SIGNAL(Mouse_Pressed()), this, SLOT(Mouse_Pressed())); 
    connect(label_MouseArea, SIGNAL(Mouse_Left()), this, SLOT(Mouse_Left())); 
} 

在构造函数中,应用程序对话框的标题设置为My Mouse-Event Handling App。然后调用initializeWidgets()函数—该函数将在稍后解释。在创建和设置布局后调用initializeWidgets(),从my_QLabel对象发出的信号将连接到Dialog类中声明的相应槽:

void Dialog::Mouse_CurrentPosition() 
{ 
    label_Mouse_CurPos->setText(QString("X = %1, Y = %2") 
                                    .arg(label_MouseArea->x) 
                                    .arg(label_MouseArea->y)); 
    label_MouseEvents->setText("Mouse Moving!"); 
} 

Mouse_CurrentPosition()函数是与my_QLabel对象的鼠标移动事件发出的信号相连接的槽。在这个函数中,标签部件label_Mouse_CurPos会被当前鼠标坐标更新,而label_MouseEvents会将其文本更新为Mouse Moving!

void Dialog::Mouse_Pressed() 
{ 
    label_MouseEvents->setText("Mouse Pressed!"); 
} 

Mouse_Pressed()函数是与鼠标按下事件发出的信号相连接的槽,每次用户在鼠标区域(my_QLabel对象)内单击时都会调用该函数。该函数会将label_MouseEvents标签中的文本更新为"Mouse Pressed!"

void Dialog::Mouse_Left() 
{ 
    label_MouseEvents->setText("Mouse Left!"); 
} 

最后,每当鼠标离开鼠标区域时,my_QLabel对象的鼠标离开事件会发出一个信号,连接到Mouse_Left()槽函数。然后,它会将label_MouseEvents标签中的文本更新为"Mouse Left!"

使用initializeWidgets()函数在对话框中实例化和设置布局,如下所示:

void Dialog::initializeWidgets() 
{ 
    label_MouseArea = new my_QLabel(this); 
    label_MouseArea->setText("Mouse Area"); 
    label_MouseArea->setMouseTracking(true); 
    label_MouseArea->setAlignment(Qt::AlignCenter|Qt::AlignHCenter); 
    label_MouseArea->setFrameStyle(2); 

在这段代码中,使用自定义标签类my_QLabel实例化了label_MouseArea对象。然后修改了标签属性(例如将标签文本修改为"Mouse Area"),在label_MouseArea对象内启用了鼠标跟踪,将对齐设置为居中,并将框架样式设置为粗线。

label_Mouse_CurPos = new QLabel(this);
label_Mouse_CurPos->setText("X = 0, Y = 0");
label_Mouse_CurPos->setAlignment(Qt::AlignCenter|Qt::AlignHCenter);
label_Mouse_CurPos->setFrameStyle(2);
label_MouseEvents = new QLabel(this);
label_MouseEvents->setText("Mouse current events!");
label_MouseEvents->setAlignment(Qt::AlignCenter|Qt::AlignHCenter);
label_MouseEvents->setFrameStyle(2);

label_Mouse_CurPoslabel_MouseEvents标签对象正在更新其属性,例如文本对齐和框架样式,与label_MouseArea对象类似。但是,label_Mouse_CurPos中的文本最初设置为"X = 0, Y = 0",而label_MouseEvents标签设置为"Mouse current events!"

    QGroupBox *groupBox = new QGroupBox(tr("Mouse Events"), this); 
    QVBoxLayout *vbox = new QVBoxLayout; 
    vbox->addWidget(label_Mouse_CurPos); 
    vbox->addWidget(label_MouseEvents); 
    vbox->addStretch(0); 
    groupBox->setLayout(vbox); 

    label_MouseArea->move(40, 40); 
    label_MouseArea->resize(280,260); 
    groupBox->move(330,40); 
    groupBox->resize(200,150); 
}

最后,创建了一个垂直框布局(QVBoxLayout),并将label_Mouse_CurPoslabel_MouseEvents标签部件添加到其中。还创建了一个带有标签Mouse Events的分组框,并将分组框的布局设置为垂直框布局,用部件创建。最后,将鼠标区域标签和鼠标事件分组框的位置和大小设置为预定义值。因此,部件的创建和布局设置已完成。

执行应用程序

现在我们可以编写main.cpp来创建Dialog类并显示它:

#include "dialog.h" 
#include <QApplication> 

int main(int argc, char *argv[]) 
{ 
    QApplication app(argc, argv); 
    Dialog dialog; 
    dialog.resize(545, 337); 
    dialog.show(); 
    return app.exec(); 
} 

这段代码与我们讨论过的 Hello World Qt 应用程序完全相同。我们实例化了我们创建的Dialog类,将对话框窗口框架的大小调整为预定义值,然后应用程序准备构建和运行。但是,在构建应用程序之前,让我们手动编写项目文件:

QT += widgets 

SOURCES +=  
        main.cpp  
        dialog.cpp  
    my_qlabel.cpp 

HEADERS +=  
        dialog.h  
    my_qlabel.h 

现在,构建应用程序并运行。对话框将如下弹出(Windows 平台):

当我们将鼠标指针悬停在左侧标签(鼠标区域)上时,鼠标的坐标将在右侧的第一个标签中更新,右侧的第二个标签将显示文本“鼠标移动!”。在鼠标区域按下任意鼠标按钮时,第二个标签中的文本将更改为“鼠标按下!”当鼠标指针离开鼠标区域时,文本将更新为“鼠标离开!”

在这一部分,我们学习了如何创建对话框窗口、对话框下的小部件、小部件中的布局等。我们还学习了如何启用自定义小部件(标签小部件),以及如何处理系统事件。然后,我们学习了使用用户定义的信号和槽创建和连接对象。最后,我们使用了所有这些小部件,包括自定义小部件,并创建了一个应用程序来处理窗口中的 Qt 鼠标事件。

现在,让我们实现一个类似的应用程序来处理QLabel中的鼠标事件,并在另一个标签中显示鼠标坐标。在这里,事件处理是通过使用RxCpp可观察对象和 Qt 事件过滤器进行事件订阅和事件过滤的。

将 RxCpp 库与 Qt 事件模型集成

在之前的部分中,我们已经从鸟瞰视角看到了 Qt 框架。我们学习了如何处理 Qt 事件,特别是鼠标事件和信号/槽机制。我们还在前两章中学习了RxCpp库及其编程模型。在这个过程中,我们遇到了许多重要的响应式操作符,这些操作符在编写利用响应式方法的程序时很重要。

在这一部分,我们将编写一个应用程序来处理标签小部件中的鼠标事件,这与之前的示例类似。在这个例子中,我们不是像在上一个例子中那样处理鼠标事件来发出信号,而是使用RxCpp订阅者订阅 Qt 鼠标事件,并将不同的鼠标事件从结果鼠标事件流中过滤出来。事件(未被过滤掉的)将与订阅者相关联。

Qt 事件过滤器-一种响应式方法

如前所述,Qt 框架具有强大的事件机制。我们需要在 Qt 和 RxCpp 的事务之间建立桥梁。为了开始使用这个应用程序,我们将编写一个头文件rx_eventfilter.h,其中包含所需的 RxCpp 头文件和 Qt 事件过滤器。

#include <rxcpp/rx.hpp> 
#include <QEvent> 
namespace rxevt { 
    // Event filter object class 
    class EventEater: public QObject  { 
    Public: 
        EventEater(QObject* parent, QEvent::Type type, rxcpp::subscriber<QEvent*> s): 
        QObject(parent), eventType(type), eventSubscriber(s) {} 
       ~EventEater(){ eventSubscriber.on_completed();}

包含<rxcpp/rx.hpp>库以获取RxxCppsubscriberobservable的定义,我们在这个类中使用这些定义,以及<QEvent>库以获取QEvent的定义。整个头文件都在rxevt命名空间下定义。现在,EventEater类是一个 Qt 事件过滤器类,用于filter-in只有成员eventType初始化的 Qt 事件。为了实现这一点,该类有两个成员变量。第一个是eventSubscriber,它是QEvent类型的rxcpp::subscriber,下一个是eventType,用于保存QEvent::Type

在构造函数中,将父QObject(需要过滤事件的小部件)传递给基类QObject。成员变量eventTypeeventSubscriber使用需要过滤的QEvent::Type和相应事件类型的rxcpp::subscriber进行初始化:

        bool eventFilter(QObject* obj, QEvent* event) { 
            if(event->type() == eventType) 
            { eventSubscriber.on_next(event);} 
            return QObject::eventFilter(obj, event); 
        } 

我们重写了eventFilter()函数,只有在事件类型与初始化的类型相同时才调用on_next()EventEater是一个事件过滤器对象,它接收发送到该对象的所有事件。过滤器可以停止事件,也可以将其转发到该对象。EventEater对象通过其eventFilter()函数接收事件。eventFilter()函数(doc.qt.io/qt-5/qobject.html#eventFilter)必须在事件应该被过滤(换句话说,停止)时返回 true;否则,必须返回false

    private: 
        QEvent::Type eventType; 
        rxcpp::subscriber<QEvent*> eventSubscriber; 
    }; 

因此,让我们在同一个头文件下编写一个实用函数,使用EventEater对象从事件流创建并返回一个rxcpp::observable

    // Utility function to retrieve the rxcpp::observable of filtered events 
    rxcpp::observable<QEvent*> from(QObject* qobject, QEvent::Type type) 
    { 
        if(!qobject) return rxcpp::sources::never<QEvent*>(); 
         return rxcpp::observable<>::create<QEvent*>( 
            qobject, type { 
                qobject->installEventFilter(new EventEater(qobject, type, s)); 
            } 
        ); 
    } 
} // rxevt 

在这个函数中,我们从事件流中返回QEvent的 observable,我们将使用EventEater对象进行过滤。在后者对象看到它们之前,可以设置QObject实例来监视另一个QObject实例的事件。这是 Qt 事件模型的一个非常强大的特性。installEventFilter()函数的调用使其成为可能,EventEater类具有执行过滤的条件。

创建窗口-设置布局和对齐

现在,让我们编写应用程序代码来创建包含两个标签小部件的窗口小部件。一个标签将用作鼠标区域,类似于上一个示例,另一个将用于显示过滤后的鼠标事件和鼠标坐标。

让我们将main.cpp中的代码分为两个部分。首先,我们将讨论创建和设置小部件布局的代码:

#include "rx_eventfilter.h" 
int main(int argc, char *argv[]) 
{ 
    QApplication app(argc, argv); 
    // Create the application window 
    auto widget = std::unique_ptr<QWidget>(new QWidget()); 
    widget->resize(280,200); 
        // Create and set properties of mouse area label 
    auto label_mouseArea   = new QLabel("Mouse Area"); 
    label_mouseArea->setMouseTracking(true); 
    label_mouseArea->setAlignment(Qt::AlignCenter|Qt::AlignHCenter); 
    label_mouseArea->setFrameStyle(2); 
    // Create and set properties of message display label 
    auto label_coordinates = new QLabel("X = 0, Y = 0"); 
    label_coordinates->setAlignment(Qt::AlignCenter|Qt::AlignHCenter); 
    label_coordinates->setFrameStyle(2);

我们已经包含了rx_eventfilter.h头文件,以使用RxCpp库实现的事件过滤机制。在这个应用程序中,不是在对话框内创建这些小部件,而是创建了一个QWidget对象,并将两个QLabel小部件添加到QVBoxLayout布局中;这被设置为应用程序窗口的布局。应用程序窗口的大小是预定义的,宽度为200 像素,高度为280 像素。与之前的应用程序类似,为第一个标签启用了鼠标跟踪:

    // Adjusting the size policy of widgets to allow stretching 
    // inside the vertical layout 
    label_mouseArea->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); 
    label_coordinates->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); 
    auto layout = new QVBoxLayout; 
    layout->addWidget(label_mouseArea); 
    layout->addWidget(label_coordinates); 
    layout->setStretch(0, 4); 
    layout->setStretch(1, 1); 
    widget->setLayout(layout); 

两个小部件的大小策略都设置为QSizePolicy::Expanding,以允许垂直布局框内的小部件拉伸。这使我们可以使鼠标区域标签比状态显示标签更大。setStretch()函数设置位置索引处的拉伸系数。

特定事件类型的 observables

订阅rxcpp::observable的鼠标事件的代码如下:

  • 鼠标移动

  • 鼠标按钮按下

  • 鼠标按钮双击

程序如下:

    // Display the mouse move message and the mouse coordinates 
    rxevt::from(label_mouseArea, QEvent::MouseMove) 
            .subscribe(&label_coordinates{ 
        auto me = static_cast<const QMouseEvent*>(e); 
        label_coordinates->setText(QString("Mouse Moving : X = %1, Y = %2") 
                                   .arg(me->x()) 
                                   .arg(me->y())); 
    });

rxevt::from()函数返回基于我们传递的QEvent::Type参数的label_mouseArea事件的rxcpp::observable。在这段代码中,我们正在订阅label_mouseArea中的事件的 Observable,这些事件的类型是QEvent::MouseMove。在这里,我们正在使用鼠标指针的当前XY位置更新label_coordinates文本:

    // Display the mouse signle click message and the mouse coordinates 
    rxevt::from(label_mouseArea, QEvent::MouseButtonPress) 
            .subscribe(&label_coordinates{ 
        auto me = static_cast<const QMouseEvent*>(e); 
        label_coordinates->setText(QString("Mouse Single click at X = %1, Y = %2") 
                                   .arg(me->x()) 
                                   .arg(me->y())); 
    }); 

与鼠标移动过滤类似,rxevt::from()函数返回QEvent的 observable,仅包括类型为QEvent::MouseButtonPress的事件。然后,在label_coordinates中更新鼠标点击的位置:

    // Display the mouse double click message and the mouse coordinates 
    rxevt::from(label_mouseArea, QEvent::MouseButtonDblClick) 
            .subscribe(&label_coordinates{ 
        auto me = static_cast<const QMouseEvent*>(e); 
        label_coordinates->setText(QString("Mouse Double click at X = %1, Y = %2") 
                                   .arg(me->x()) 
                                   .arg(me->y())); 
    }); 
    widget->show(); 
    return app.exec(); 
} // End of main 

最后,事件类型QEvent::MouseButtonDblClick也类似于单击鼠标,更新了label_coordinates中的文本,并显示了双击位置。然后,调用应用程序窗口小部件的show()函数,并调用exec()函数启动事件循环。

项目文件Mouse_EventFilter.pro如下:

QT += core widgets 
CONFIG += c++14 

TARGET = Mouse_EventFilter 
INCLUDEPATH += include 

SOURCES +=  
    main.cpp 
HEADERS +=  
    rx_eventfilter.h  

由于 RxCpp 库是一个仅包含头文件的库,在项目目录内创建了一个名为include的文件夹,并将 RxCpp 库文件夹复制到其中。更新INCLUDEPATH将帮助应用程序获取指定目录中存在的任何包含文件。现在,让我们构建并运行应用程序。

RxQt 简介

RxQt库是一个基于RxCpp库编写的公共领域库,它使得以一种响应式的方式使用 Qt 事件和信号变得容易。为了理解该库,让我们跳转到一个示例中,这样我们就可以跟踪鼠标事件并使用该库提供的 observable 进行过滤。该库可以从 GitHub 存储库github.com/tetsurom/rxqt下载:

#include <QApplication> 
#include <QLabel> 
#include <QMouseEvent> 
#include "rxqt.hpp" 

int main(int argc, char *argv[]) 
{ 
    QApplication app(argc, argv); 

    auto widget = new QWidget(); 
    widget->resize(350,300); 
    widget->setCursor(Qt::OpenHandCursor); 

    auto xDock = new QLabel((QWidget*)widget); 
    xDock->setStyleSheet("QLabel { background-color : red}"); 
    xDock->resize(9,9); 
    xDock->setGeometry(0, 0, 9, 9); 

    auto yDock = new QLabel((QWidget*)widget); 
    yDock->setStyleSheet("QLabel { background-color : blue}"); 
    yDock->resize(9,9); 
    yDock->setGeometry(0, 0, 9, 9); 

上述代码创建了一个QWidget,它充当另外两个QLabel的父类。创建了两个标签小部件,以在父小部件内移动,沿着窗口的顶部和左边缘。沿X轴的可停靠标签为红色,Y轴的标签为蓝色。

    rxqt::from_event(widget, QEvent::MouseButtonPress) 
            .filter([](const QEvent* e) { 
        auto me = static_cast<const QMouseEvent*>(e); 
        return (Qt::LeftButton == me->buttons()); 
    }) 
            .subscribe(& { 
        auto me = static_cast<const QMouseEvent*>(e); 
        widget->setCursor(Qt::ClosedHandCursor); 
        xDock->move(me->x(), 0); 
        yDock->move(0, me->y()); 
    }); 

在上述代码中,rxqt::from_event()函数过滤了除QEvent::MouseButtonPress事件之外的所有小部件类事件,并返回了一个rxcpp::observable<QEvent*>实例。这里的rxcpp::observable已经根据鼠标事件进行了过滤,如果按钮是左鼠标按钮。然后,在subscribe()方法的 Lambda 函数内,我们将光标更改为Qt::ClosedHandCursor。我们还将xDock的位置设置为鼠标x位置值,以及窗口的顶部边缘,将yDock的位置设置为鼠标y位置值,以及窗口的左边缘:

    rxqt::from_event(widget, QEvent::MouseMove) 
            .filter([](const QEvent* e) { 
        auto me = static_cast<const QMouseEvent*>(e); 
        return (Qt::LeftButton == me->buttons()); 
    }) 
            .subscribe(& { 
        auto me = static_cast<const QMouseEvent*>(e); 
        xDock->move(me->x(), 0); 
        yDock->move(0, me->y()); 
    });

在这段代码中,我们使用RxQt库过滤了窗口小部件的所有鼠标移动事件。这里的 observable 是一个包含鼠标移动和左鼦按键事件的鼠标事件流。在 subscribe 方法内,代码更新了xDockyDock的位置,沿着窗口的顶部和左边缘:

    rxqt::from_event(widget, QEvent::MouseButtonRelease) 
            .subscribe(&widget { 
        widget->setCursor(Qt::OpenHandCursor); 
    }); 

    widget->show(); 
    return app.exec(); 
} 

最后,过滤了鼠标释放事件,并将鼠标光标设置回Qt::OpenHandCursor。为了给这个应用程序增添一些乐趣,让我们创建一个与xDockyDock类似的小部件;这将是一个重力对象。当按下鼠标时,重力对象将跟随鼠标光标移动:

#ifndef GRAVITY_QLABEL_H 
#define GRAVITY_QLABEL_H 

#include <QLabel> 

class Gravity_QLabel : public QLabel 
{ 
   public: 
    explicit Gravity_QLabel(QWidget *parent = nullptr): 
         QLabel(parent), prev_x(0), prev_y(0){} 

    int prev_x, prev_y; 
}; 

#endif // GRAVITY_QLABEL_H 

现在,我们必须在应用程序窗口下创建一个gravity小部件的实例(从新创建的Gravity_QLabel类):

    auto gravityDock = new Gravity_QLabel((QWidget*)widget); 
    gravityDock->setStyleSheet("QLabel { background-color : green}"); 
    gravityDock->resize(9,9); 
    gravityDock->setGeometry(0, 0, 9, 9);

xDockyDock的创建和大小设置类似,新的gravityDock对象已经创建。此外,每当抛出press事件时,必须将此对象的位置设置为鼠标坐标值。因此,在QEvent::MouseButtonPresssubscribe方法的 Lambda 函数内,我们需要添加以下代码行:

    gravityDock->move(me->x(),me->y()); 

最后,需要根据鼠标移动更新gravityDock的位置。为了实现这一点,在QEvent::MouseMovesubscribe方法的 Lambda 函数内,我们需要添加以下代码:

    gravityDock->prev_x = gravityDock->prev_x * .96 + me->x() * .04; 
    gravityDock->prev_y = gravityDock->prev_y * .96 + me->y() * .04; 
    gravityDock->move(gravityDock->prev_x, gravityDock->prev_y); 

在这里,gravityDock的位置更新为一个新值,该值是先前值的 96%和新位置的 4%之和。因此,我们使用RxQt和 RxCpp 库来过滤 Qt 事件,以创建X-Y鼠标位置指示器和重力对象。现在,让我们构建并运行应用程序。

总结

在本章中,我们讨论了使用 Qt 进行响应式 GUI 编程的主题。我们从快速概述使用 Qt 进行 GUI 应用程序开发开始。我们了解了 Qt 框架中的概念,如 Qt 对象层次结构,元对象系统以及信号和槽。我们使用简单的标签小部件编写了一个基本的“Hello World”应用程序。然后,我们使用自定义标签小部件编写了一个鼠标事件处理应用程序。在该应用程序中,我们更多地了解了 Qt 事件系统的工作原理,以及如何使用信号和槽机制进行对象通信。最后,我们编写了一个应用程序,使用RxCpp订阅模型和 Qt 事件过滤器来处理鼠标事件并对其进行过滤。我们介绍了如何在 GUI 框架(如 Qt)中使用 RxCpp 来遵循响应式编程模型。我们还介绍了RxQt库,这是一个集成了 RxCpp 和 Qt 库的公共领域。

在进入下一章之前,您需要了解如何为 RxCpp observables 编写自定义操作符。这个主题在在线部分有介绍。您可以参考以下链接:www.packtpub.com/sites/default/files/downloads/Creating_Custom_Operators_in_RxCpp.pdf

在您完成阅读上述提到的主题之后,我们可以继续下一章,我们将看一下 C++响应式编程的设计模式和习语。

第十章:在 RxCpp 中创建自定义操作符

在过去的三章中,我们学习了 RxCpp 库及其编程模型。我们还将所学内容应用到了 GUI 编程的上下文中。从心智模型的角度来看,任何想以响应式方式编写程序的开发人员都必须理解可观察对象、观察者以及它们之间的操作符。当然,调度器和主题也很重要。响应式程序的大部分逻辑都驻留在操作符中。RxCpp 库作为其实现的一部分提供了许多内置(库存)操作符。我们已经在我们的程序中使用了其中一些。在本章中,我们将学习如何实现自定义操作符。要编写自定义操作符,我们需要深入了解与 RxCpp 库相关的一些高级主题。本章涵盖的主题如下:

  • Rx 操作符的哲学

  • 链接库存操作符

  • 编写基本的 RxCpp 操作符

  • 编写不同类型的自定义操作符

  • 使用lift<T>元操作符编写自定义操作符

  • 向 RxCpp 库源代码中添加操作符

Rx 操作符的哲学

如果你看任何响应式程序,我们会看到一系列操作符堆叠在可观察对象和观察者之间。开发人员使用流畅接口来链接操作符。在 RxCpp 中,可以使用点(.)或管道(|)来执行操作符链式调用。从软件接口的角度来看,每个操作符都接受一个可观察对象,并返回一个相同类型或不同类型的可观察对象。

RxCpp 可观察对象/观察者交互的一般用法(伪代码)如下:

   Observable().     // Source Observable 
          Op1().     // First operator 
          Op2().     // Second operator 
                     ..                         
                     .. 
          Opn().subscribe( on_datahandler, 
                            on_errorhandler, 
                            on_completehandler); 

尽管在操作符链式调用时我们使用流畅接口,但实际上我们是在将函数组合在一起。为了将函数组合在一起,函数的返回值应该与组合链中的函数的参数类型兼容。

操作符以可观察对象作为参数,并返回另一个可观察对象。有一些情况下,它返回的是除可观察对象之外的值。只有那些返回可观察对象的操作符才能成为操作符链式调用的一部分。

要编写一个新的操作符,使其成为操作符链式调用方法的一部分,最好的方法是将它们作为observable<T>类型的方法添加。然而,编写一个可以在不同上下文中运行的生产质量操作符最好留给 RxCpp 内部的专家。另一个选择是使用 RxCpp 库中提供的lift<t>...)操作符。我们将在本章中涵盖这两种策略。

每个操作符实现都应该具有的另一个非常重要的属性是它们应该是无副作用的。至少,它们不应该改变输入可观察对象的内容。换句话说,充当操作符的函数或函数对象应该是一个纯函数。

链接库存操作符

我们已经学到了 RxCpp 操作符是在可观察对象上操作的(作为输入接收),并返回可观察对象。这使得这些操作符可以通过操作符链式调用一一调用。链中的每个操作符都会转换从前一个操作符接收到的流中的元素。源流在这个过程中不会被改变。在链式调用操作符时,我们使用流畅接口语法。

开发人员通常在实现 GOF 构建器模式的类的消费上使用流畅接口。构建器模式的实现是以无序的方式实现的。尽管操作符链式调用的语法类似,但在响应式世界中操作符被调用的顺序确实很重要。

让我们编写一个简单的程序,帮助我们理解可观察对象操作符链式执行顺序的重要性。在这个特定的例子中,我们有一个可观察流,在这个流中我们应用 map 操作符两次:一次是为了找出平方,然后是为了找出值的两个实例。我们先应用平方函数,然后是两次函数:

//----- operatorChaining1.cpp 
//----- Square and multiplication by 2 in order 
#include "rxcpp/rx.hpp" 
int main() 
{ 
    auto values = rxcpp::observable<>::range(1, 3). 
        map([](int x) { return x * x; }). 
        map([](int x) { return x * 2; }); 
    values.subscribe( 
        [](int v) {printf("OnNext: %dn", v); }, 
        []() {printf("OnCompletedn"); }); 
    return 0; 
} 

前面的程序将产生以下输出:

OnNext: 2 
OnNext: 8 
OnNext: 18 
OnCompleted

现在,让我们颠倒应用顺序(先缩放 2 倍,两次,然后是参数的平方),然后查看输出,看看我们会得到不同的输出(在第一种情况下,先应用了平方,然后是缩放 2 倍)。以下程序将解释执行顺序,如果我们将程序生成的输出与之前的程序进行比较:

//----- operatorChaining2.cpp 
//----- Multiplication by 2 and Square in order 
#include "rxcpp/rx.hpp" 
int main() 
{ 
    auto values = rxcpp::observable<>::range(1, 3). 
        map([](int x) { return x * 2; }). 
        map([](int x) { return x * x; }); 
    values.subscribe( 
        [](int v) {printf("OnNext: %dn", v); }, 
        []() {printf("OnCompletedn"); }); 
    return 0; 
} 

程序产生的输出如下:

OnNext: 4 
OnNext: 16 
OnNext: 36 
OnCompleted 

在 C++中,我们可以很好地组合函数,因为 Lambda 函数和 Lambda 函数的惰性评估。RxCpp 库利用了这一事实来实现操作符。如果有三个函数(FGH)以observable<T>作为输入参数并返回observable<T>,我们可以象征性地将它们组合如下:

F(G( H(x)) 

如果我们使用操作符链,可以写成如下形式:

x.H().G().F() 

现在我们已经学会了操作符链实际上是在进行操作符组合。两者产生类似的结果,但操作符链更易读和直观。本节的一个目的是建立这样一个事实,即操作符组合和操作符链提供类似的功能。最初我们实现的操作符可以组合在一起(不能被链式调用),我们将学习如何创建适合操作符链的操作符。

编写基本的 RxCpp 自定义操作符

在上一节中,我们讨论了操作符链。操作符链是可能的,因为库存操作符是作为observable<T>类型的一部分实现的。我们最初要实现的操作符不能成为操作符链策略的一部分。在本节中,我们将实现一些 RxCpp 操作符,可以转换 Observable 并返回另一个 Observable。

将 RxCpp 操作符写为函数

为了开始讨论,让我们编写一个简单的操作符,它可以在 observable上工作。该操作符只是在流中的每个项目之前添加文字Hello

//----------- operatorSimple.cpp 
#include "rxcpp/rx.hpp" 
#include "rxcpp/rx-test.hpp" 
#include <iostream> 
namespace rxu=rxcpp::util; 
#include <array> 
using namespace rxcpp; 
using namespace rxcpp::operators; 
// Write a Simple Reactive operator Takes an Observable<string> and 
// Prefix Hello to every item and return another Observable<string> 
observable<std::string> helloNames(observable<std::string> src ) { 
    return src.map([](std::string s) { return "Hello, " + s + "!"; }); 
} 

我们实现的自定义操作符是为了演示如何编写一个可以在 Observable 上工作的操作符。编写的操作符必须使用函数语义来调用,并且实现不适合操作符链。既然我们已经实现了一个操作符,让我们编写一个主函数来测试操作符的工作方式:

int main() { 
     std::array< std::string,4 > a={{"Praseed", "Peter", "Sanjay","Raju"}}; 
     // Apply helloNames operator on the observable<string>  
     // This operator cannot be part of the method chaining strategy 
     // We need to invoke it as a function  
     // If we were implementing this operator as part of the
     //          RxCpp observable<T> 
     //   auto values = rxcpp::observable<>:iterate(a).helloNames(); 
     auto values = helloNames(rxcpp::observable<>::iterate(a));  
     //-------- As usual subscribe  
     values.subscribe(  
              [] (std::string f) { std::cout << f <<  std::endl; } ,  
              [] () {std::cout << "Hello World.." << std::endl;} ); 
} 

程序将产生以下输出:

Hello, Praseed! 
Hello, Peter! 
Hello, Sanjay! 
Hello, Raju! 
Hello World.. 

将 RxCpp 操作符写为 Lambda 函数

我们已经将我们的第一个自定义操作符写成了一个unary函数。所有操作符都是以 Observables 作为参数的unary函数。该函数以observable<string>作为参数,并返回另一个observable<string>。我们可以通过将操作符(内联)作为 Lambda 来实现相同的效果。让我们看看如何做到:

//----------- operatorInline.cpp 
#include "rxcpp/rx.hpp" 
#include "rxcpp/rx-test.hpp" 
#include <iostream> 
namespace rxu=rxcpp::util; 
#include <array> 
using namespace rxcpp; 
using namespace rxcpp::operators; 
int main() { 
     std::array< std::string,4 > a={{"Praseed", "Peter", "Sanjay","Raju"}}; 
     auto helloNames = [] (observable<std::string> src ) { 
           return src.map([](std::string s) {  
             return "Hello, " + s + "!";  
             }); 
     }; 
     // type of values will be observable<string> 
     // Lazy Evaluation  
     auto values = helloNames(rxcpp::observable<>::iterate(a));  
     //-------- As usual subscribe  
     values.subscribe(  
              [] (std::string f) { std::cout << f <<  std::endl; } ,  
              [] () {std::cout << "Hello World.." << std::endl;} ); 
} 

程序的输出如下:

Hello, Praseed! 
Hello, Peter! 
Hello, Sanjay! 
Hello, Raju! 
Hello World.. 

输出显示,程序行为是相同的,无论是使用普通函数还是 Lambda 函数。Lambda 函数的优势在于调用站点的创建和函数的消耗。

组合自定义 RxCpp 操作符

我们已经在本书中学习了函数组合(第二章,现代 C++及其关键习语之旅)。函数组合是可能的,当一个函数的返回值与另一个函数的输入参数兼容时。在操作符的情况下,由于大多数操作符返回 Observables 并将 Observables 作为参数,它们适合函数组合。在本节中,我们的操作符适合组合,但它们还不能被链式调用。让我们看看如何组合操作符:

//----------- operatorCompose.cpp 
#include "rxcpp/rx.hpp" 
#include "rxcpp/rx-test.hpp" 
#include <iostream> 
namespace rxu=rxcpp::util; 
#include <array> 
using namespace rxcpp; 
using namespace rxcpp::operators; 
int main() { 
     std::array< int ,4 > a={{10, 20,30,40}}; 
     // h-function (idempotent) 
     auto h = [] (observable<int> src ) { 
       return src.map([](int n ) { return n; }); 
     }; 
     // g-function 
     auto g = [] (observable<int> src ) { 
          return src.map([](int n ) { return n*2; }); 
     }; 
     // type of values will be observable<string> 
     // Lazy Evaluation ... apply h over observable<string> 
     // on the result, apply g  
     auto values = g(h(rxcpp::observable<>::iterate(a)));  
     //-------- As usual subscribe  
     values.subscribe(  
              [] (int f) { std::cout << f <<  std::endl; } ,  
              [] () {std::cout << "Hello World.." << std::endl;} ); 
} 

程序的输出如下:

20 
40 
60 
80 
Hello World.. 

不同类型的自定义操作符

RxCpp 库包含作为库存提供的不同类型的运算符。RxCpp 的默认运算符集对于大多数应用程序来说已经足够了。可用运算符的不同类型如下:

  • 创建运算符

  • 转换运算符

  • 过滤运算符

  • 组合运算符

  • 错误处理运算符

  • 实用运算符

  • 布尔运算符

  • 数学运算符

运算符的分类为开发人员提供了一个选择适当运算符的良好框架。在本节中,我们将实现以下内容:

  • 自定义创建运算符

  • 自定义转换运算符

  • 涉及调度程序的自定义操作

编写自定义创建运算符

大多数 RxCpp 运算符函数接受 Observable 并返回一个 Observable 以实现运算符的组合。我们需要做一些额外的工作,以使组合具有可链式的方式(在下一节中,我们将介绍lift<t>和向RxCpp库中的[observable<T>] Observable 添加运算符的主题)。我们在本节中实现的运算符将帮助我们从输入数据创建一个 Observable。我们可以从任何类型的单个值、一系列值、STL 容器的迭代器、另一个 Observable 等创建 Observable 流。让我们讨论一个接受 STL 容器并创建 Observable 的示例程序,然后进行一些转换:

//------ CustomOperator1.cpp 
#include "rxcpp/rx.hpp" 
namespace rx { 
    using namespace rxcpp;  
    using namespace rxcpp::operators; 
    using namespace rxcpp::sources; 
    using namespace rxcpp::util; 
} 

template<typename Container> 
rx::observable<std::string> helloNames(Container items) { 
    auto str = rx::observable<>::iterate(items); 
    return str. 
    filter([](std::string s){ 
        return s.length() > 5; 
    }). 
    map([](std::string s){ 
        return "Hello, " + s + "!"; 
    }). 
    //------ Translating exception 
    on_error_resume_next([](std::exception_ptr){ 
        return rx::error<std::string>(std::runtime_error("custom exception")); 
    }); 
} 

helloNames()函数接受任何标准库容器并创建一个字符串类型的 Observable(observable<string>)。然后对 Observable 进行过滤,以获取长度超过五个字符的项目,并在每个项目前加上Hello字符串。发生的异常将通过使用标准 RxCpp 运算符on_error_resume_next()进行转换:现在,让我们编写主程序来看看如何使用这个运算符:

int main() { 
    //------ Create an observable composing the custom operator 
    auto names = {"Praseed", "Peter", "Joseph", "Sanjay"}; 
    auto value = helloNames(names).take(2); 

    auto error_handler = = { 
        try { rethrow_exception(e); } 
        catch (const std::exception &ex) { 
            std::cerr << ex.what() << std::endl; 
        } 
    }; 

    value. 
    subscribe( 
              [](std::string s){printf("OnNext: %sn", s.c_str());}, 
              error_handler, 
              [](){printf("OnCompletedn");}); 
} 

名字列表作为参数传递到新定义的运算符中,我们得到以下输出:

OnNext: Hello, Praseed! 
OnNext: Hello, Joseph! 
OnCompleted

编写自定义转换运算符

让我们编写一个简单的程序,通过组合其他运算符来实现一个自定义运算符,在这个程序中,我们过滤奇数的数字流,将数字转换为其平方,并仅取流中的前三个元素:

//------ CustomOperator1.cpp 
#include "rxcpp/rx.hpp" 
namespace rx { 
    using namespace rxcpp; 
    using namespace rxcpp::operators; 
    using namespace rxcpp::sources; 
    using namespace rxcpp::util; 
} 
//------ operator to filter odd number, find square & take first three items 
std::function<rx::observable<int>(rx::observable<int>)> getOddNumSquare() { 
    return [](rx::observable<int> item) { 
        return item. 
        filter([](int v){ return v%2; }). 
        map([](const int v) { return v*v; }). 
        take(3). 
        //------ Translating exception 
        on_error_resume_next([](std::exception_ptr){ 
            return rx::error<int>(std::runtime_error("custom exception")); }); 
    }; 
} 
int main() { 
    //------ Create an observable composing the custom operator 
    auto value = rxcpp::observable<>::range(1, 7) | 
    getOddNumSquare(); 
    value. 
    subscribe( 
              [](int v){printf("OnNext: %dn", v);}, 
              [](){printf("OnCompletedn");}); 
} 

在这个例子中,自定义运算符是用不同的方法实现的。运算符函数不是返回所需类型的简单 Observable,而是返回一个接受并返回int类型的 Observable 的函数对象。这允许用户使用管道(|)运算符执行高阶函数的执行。在编写复杂程序时,使用用户定义的转换实现自定义运算符并将其与现有运算符组合在一起非常方便。通常最好通过组合现有运算符来组合新运算符,而不是从头实现新运算符(不要重复造轮子!)。

编写涉及调度程序的自定义运算符

RxCpp 库默认是单线程的,RxCpp 将在调用订阅方法的线程中安排执行。有一些运算符接受调度程序作为参数,执行可以在调度程序管理的线程中进行。让我们编写一个程序来实现一个自定义运算符,以处理调度程序参数:

//----------- CustomOperatorScheduler.cpp 
#include "rxcpp/rx.hpp" 
template <typename Duration> 
auto generateObservable(Duration durarion) { 
    //--------- start and the period 
    auto start = rxcpp::identity_current_thread().now(); 
    auto period = durarion; 
    //--------- Observable upto 3 items 
    return rxcpp::observable<>::interval(start, period).take(3); 
} 

int main() { 
    //-------- Create a coordination 
    auto coordination = rxcpp::observe_on_event_loop(); 
    //-------- Instantiate a coordinator and create a worker 
    auto worker = coordination.create_coordinator().get_worker(); 
    //----------- Create an Observable (Replay ) 
    auto values = generateObservable(std::chrono::milliseconds(2)). 
        replay(2, coordination); 
    //--------------- Subscribe first time 
    worker.schedule(& { 
        values.subscribe([](long v) { printf("#1 -- %d : %ldn", 
            std::this_thread::get_id(), v); }, 
                         []() { printf("#1 --- OnCompletedn"); }); 
    }); 
    worker.schedule(& { 
        values.subscribe([](long v) { printf("#2 -- %d : %ldn", 
            std::this_thread::get_id(), v); }, 
                         []() { printf("#2 --- OnCompletedn"); }); }); 
    //----- Start the emission of values 
    worker.schedule(& { 
        values.connect(); 
    }); 
    //------- Add blocking subscription to see results 
    values.as_blocking().subscribe(); 
    return 0; 
} 

编写可以链式组合的自定义运算符

RxCpp 库提供的内置运算符的一个关键优点是可以使用流畅的接口链式操作运算符。这显著提高了代码的可读性。到目前为止,我们创建的自定义运算符可以组合在一起,但不能像标准运算符那样链式组合。在本节中,我们将实现可以使用以下方法进行链式组合的运算符:

  • 使用lift<T>元运算符

  • 通过向 RxCpp 库添加代码来编写新运算符

使用 lift运算符编写自定义运算符

RxCpp 库中的observable<T>实现中有一个名为liftlift<t>)的操作符。实际上,它可以被称为元操作符,因为它具有将接受普通变量(intfloatdoublestruct等)的一元函数或函数对象转换为兼容处理observable<T>流的能力。observable<T>::lift的 RxCpp 实现期望一个 Lambda,该 Lambda 以rxcpp::subscriber<T>作为参数,并且在 Lambda 的主体内,我们可以应用一个操作(Lambda 或函数)。在本节中,可以对lift<t>操作符的目的有一个概述。

lift 操作符接受任何函数或 Lambda,该函数或 Lambda 将接受 Observable 的 Subscriber 并产生一个新的 Subscriber。这旨在允许使用make_subscriber的外部定义的操作符连接到组合链中。lift 的函数原型如下:

template<class ResultType , class operator > 
auto rxcpp::operators::lift(Operator && op) -> 
                 detail::lift_factory<ResultType, operator> 

lift<t>期望的 Lambda 的签名和主体如下:

={ 
         return rxcpp::make_subscriber<T>( 
                dest,rxcpp::make_observer_dynamic<T>( 
                      ={ 
                         //---- Apply an action Lambda on each items 
                         //---- typically "action_lambda" is declared in the 
                         //---- outside scope (captured)
                         dest.on_next(action_lambda(n)); 
                      }, 
                      ={dest.on_error(e);}, 
                      [=](){dest.on_completed();})); 
}; 

为了理解lift<T>操作符的工作原理,让我们编写一个使用它的程序。lift<T>的优势在于所创建的操作符可以成为 RxCpp 库的操作符链式结构的一部分。

//----------- operatorLiftFirst.cpp 
#include "rxcpp/rx.hpp" 
#include "rxcpp/rx-test.hpp" 
#include <iostream> 
namespace rxu=rxcpp::util; 
#include <array> 
using namespace rxcpp; 
using namespace rxcpp::operators; 

int main() { 
     std::array< int ,4 > a={{10, 20,30,40}}; 
     //////////////////////////////////////////////////// 
     // The following Lambda will be lifted  
     auto lambda_fn = [] ( int n ) { return n*2; }; 
     ///////////////////////////////////////////////////////////// 
     // The following Lambda expects a rxcpp::subscriber and returns 
     // a subscriber which implements on_next,on_error,on_completed 
     // The Lambda lifting happens because, we apply lambda_fn on  
     // each item. 
     auto transform = ={ 
         return rxcpp::make_subscriber<int>( 
                dest,rxcpp::make_observer_dynamic<int>( 
                      ={ 
                         dest.on_next(lambda_fn(n)); 
                      }, 
                      ={dest.on_error(e);}, 
                      [=](){dest.on_completed();})); 
     }; 
     // type of values will be observable<int> 
     // Lazy Evaluation  
     auto values = rxcpp::observable<>::iterate(a);  
     //-------- As usual subscribe  
     values.lift<int>(transform).subscribe(  
              [] (int f) { std::cout << f <<  std::endl; } ,  
              [] () {std::cout << "Hello World.." << std::endl;} ); 
} 

我们现在已经学会了如何使用lift<t>操作符。observable<T>实例及其 lift 方法接受具有特定参数类型的 Lambda 并产生一个observable<T>lift<T>的优势在于我们可以使用操作符链式结构。

将任意 Lambda 转换为自定义 Rx 操作符

在前一节中,我们了解到可以使用lift<t>操作符来实现自定义操作符,这些操作符可以成为 RxCpp 库的操作符链式结构的一部分。lift<T>的工作有点复杂,我们将编写一个Adapter类来将接受基本类型参数的任意 Lambda 转换为lift<T>操作符可以应用的形式。

适配器代码将帮助我们进行这样的调用:

observable<T>::lift<T>( liftaction( lambda<T> ) )

让我们编写一个Adapter类实现和一个通用函数包装器,以便在程序中使用:

//----------- operatorLiftSecond.cpp 
#include "rxcpp/rx.hpp" 
#include "rxcpp/rx-test.hpp" 
#include <iostream> 
namespace rxu=rxcpp::util; 
#include <array> 
using namespace rxcpp; 
using namespace rxcpp::operators; 
///////////////////////////////////////////////// 
// The LiftAction class  ( an adapter class) converts an Action ( a Lambda ) 
// and wraps it into a form which can help us to connect 
// to an observable<T> using the observable<T>::lift<T> method.  
template<class Action> 
struct LiftAction { 
    typedef typename std::decay<Action>::type action_type; 
    action_type action; 

    LiftAction(action_type t): action(t){} 
    ////////////////////////////////////// 
    // Create an Internal observer to gather  
    // data from observable<T>  
    // 
    template<class Subscriber> 
    struct action_observer : public  
              rxcpp::observer_base<typename  
              std::decay<Subscriber>::type::value_type> 
    { 
        ///////////////////////////////////////////// 
        // typedefs for  
        //        * this_type (action_observer) 
        //        * base_type (observable_base)  
        //        * value_type  
        //        * dest_type 
        //        * observer_type 
        typedef action_observer<Subscriber> this_type; 
        typedef rxcpp::observer_base<typename             
                std::decay<Subscriber>::type::value_type> base_type; 
        typedef typename base_type::value_type value_type; 
        typedef typename std::decay<Subscriber>::type dest_type; 
        typedef rxcpp::observer<value_type, this_type> observer_type; 

        //------ destination subscriber and action 
        dest_type dest; 
        action_type action; 
        action_observer(dest_type d, action_type t) 
            : dest(d), action(t){} 

        //--------- subscriber/observer methods 
        //--------  on_next implementation needs more  
        //--------- robustness by supporting exception handling 
        void on_next(typename dest_type::value_type v) const  
        {dest.on_next(action(v));} 
        void on_error(std::exception_ptr e) const  
        { dest.on_error(e);} 
        void on_completed() const { 
            dest.on_completed(); 
        } 
        //--------- Create a subscriber with requisite parameter 
        //--------- types 
        static rxcpp::subscriber<value_type, observer_type>  
                 make(const dest_type& d, const action_type& t) { 
            return rxcpp::make_subscriber<value_type> 
                 (d, observer_type(this_type(d, t))); 
        } 
    }; 

在 RxCpp 操作符实现中,我们将有一个内部 Observer 拦截流量,并在将控制传递给链中的下一个操作符之前对项目应用一些逻辑。action_observer类就是按照这些方式结构的。由于我们使用 Lambda(延迟评估),只有当调度程序触发执行时,流水线中接收到数据时才会发生执行:

    template<class Subscriber> 
    auto operator()(const Subscriber& dest) const 
        -> decltype(action_observer<Subscriber>::make(dest, action)) { 
        return      action_observer<Subscriber>::make(dest, action); 
    } 
}; 
////////////////////////////////////// 
// liftaction takes a Universal reference  
// and uses perfect forwarding  
template<class Action> 
auto liftaction(Action&& p) ->  LiftAction<typename std::decay<Action>::type> 
{  
   return  LiftAction<typename  
           std::decay<Action>::type>(std::forward<Action>(p)); 
} 

现在我们已经学会了如何实现Adapter类以将 Lambda 转换为lift<T>可以接受的形式,让我们编写一个程序来演示如何利用前面的代码:

int main() { 
     std::array< int ,4 > a={{10, 20,30,40}}; 
     auto h = [] (observable<int> src ) { 
         return src.map([](int n ) { return n; }); 
     }; 
     auto g = [] (observable<int> src ) { 
         return src.map([](int n ) { return n*2; }); 
     }; 
     // type of values will be observable<int> 
     // Lazy Evaluation  ... the Lift operator 
     // converts a Lambda to be part of operator chaining
     auto values = g(h(rxcpp::observable<>::iterate(a))) 
       .lift<int> (liftaction( [] ( int r ) { return 2*r; }));  
     //-------- As usual subscribe  
     values.subscribe(  
              [] (int f) { std::cout << f <<  std::endl; } ,  
              [] () {std::cout << "Hello World.." << std::endl;} ); 
} 

程序的输出如下:

40 
80 
120 
160 
Hello World.. 

在库中创建自定义 RxCpp 操作符

RxCpp库中的每个操作符都在rxcpp::operators命名空间下定义。在rxcpp::operators命名空间内,库设计者创建了一个名为 details 的嵌套命名空间,其中通常指定了操作符逻辑的实现。为了演示从头开始实现操作符,我们克隆了 map 操作符的实现,创建了另一个名为eval的操作符。eval的语义与map操作符相同。源代码清单可在与本书相关的 GitHub 存储库中的特定章节文件夹中找到。

我们决定将书中的代码移动到 GitHub 存储库,因为清单有点长,对于理解在RxCpp库中实现操作符的概念没有太大贡献。前面概述的liftaction实现向我们展示了如何编写内部 Observer。每个操作符实现都遵循一个标准模式:

  • 它通过创建一个私有 Observer 订阅源 Observable

  • 根据操作符的目的转换 Observable 的元素

  • 将转换后的值推送给其自己的订阅者

eval运算符实现的骨架源代码如下。源文件的实现包括以下内容:

源文件 关键更改

| rx-eval.hpp | eval运算符的实现:


//rx-eval.hpp   
#if   !defined(RXCPP_OPERATORS_RX_EVAL_HPP)   
#define   RXCPP_OPERATORS_RX_EVAL_HPP   
//------------ all headers are   included here   
#include "../rx-includes.hpp"   
namespace rxcpp {   
    namespace operators {   
        namespace detail {   
          //-------------- operator   implementation goes here   
        }
    }
}
#endif   

|

| rx-includes.h | 修改后的头文件,包含了Rx-eval.hpp的引入。rx-includes.h将在文件中添加一个额外的条目,如下所示:

#include "operators/rx-eval.hpp"   

|

| rx-operators.h | 修改后的头文件,包含了eval_tag的定义。rx-operators.h包含以下标签条目:

struct eval_tag {   
    template<class Included>   
    struct include_header{   
          static_assert(Included::value, 
           "missing include: please 
                   #include   <rxcpp/operators/rx-eval.hpp>");   
};   
};   

|

| rx-observables.h | 修改后的头文件,其中包含eval运算符的定义:

template<class... AN>   
auto eval(AN&&... an)   const-> decltype(observable_member(eval_tag{},   
 *(this_type*)nullptr,   std::forward<AN>(an)...)){   
        return    observable_member(eval_tag{},                 
                   *this, std::forward<AN>(an)...);   
}   

|

让我们编写一个使用eval运算符的程序。eval运算符的原型(类似于map)如下:

observaable<T>::eval<T>( lambda<T>)

你可以检查实现的源代码,以更好地理解eval运算符。现在,让我们编写一个利用eval运算符的程序:

//----------- operatorComposeCustom.cpp 
#include "rxcpp/rx.hpp" 
#include "rxcpp/rx-test.hpp" 
#include <iostream> 
namespace rxu=rxcpp::util; 
#include <array> 
using namespace std; 
using namespace rxcpp; 
using namespace rxcpp::operators; 
int main() { 
     std::array< string ,4 > a={{"Bjarne","Kirk","Herb","Sean"}}; 
     auto h = [] (observable<string> src ) { 
          return src.eval([](string s ) { return s+"!"; }); 
     }; 
     //-------- We will Lift g using eval 
     auto g = [](string s) { return "Hello : " + s; }; 
     // use apply h first and then call eval 
     auto values = h(rxcpp::observable<>::iterate(a)).eval(g);  
     //-------- As usual subscribe  
     values.subscribe(  
              [] (string f) { std::cout << f <<  std::endl; } ,  
              [] () {std::cout << "Hello World.." << std::endl;} ); 
} 

程序的输出如下:

Hello : Bjarne! 
Hello : Kirk! 
Hello : Herb! 
Hello : Sean! 
Hello World.. 

编写以通用方式实现的自定义运算符需要对 RxCpp 内部有深入的了解。在尝试自定义运算符之前,您需要了解一些基本运算符的实现。我们编写的运算符可以成为您实现此类运算符的起点。再次强调,从头开始编写自定义运算符应该是最后的选择!

摘要

在本章中,我们学习了如何编写自定义运算符。我们首先编写了可以执行基本任务的简单运算符。尽管我们编写的运算符(最初)是可组合的,但我们无法像标准的 RxCpp 运算符那样将它们链接在一起。在编写了不同类型的运算符之后,我们使用lift<T>元运算符实现了可链接的自定义运算符。最后,我们看到了如何将运算符添加到observable<T>中。在下一章中,我们将深入探讨 Rx 编程的设计模式和习惯用法。我们将从 GOF 设计模式开始,并实现不同的响应式编程模式。

第十一章:C++ Rx 编程的设计模式和成语

我们已经在使用 C++的响应式编程模型方面取得了相当大的进展。到目前为止,我们已经了解了 RxCpp 库及其编程模型、RxCpp 库的关键元素、响应式 GUI 编程以及编写自定义操作符的主题。现在,为了将问题提升到下一个级别,我们将涵盖一些设计模式和成语,这些模式和成语有助于我们进行高级软件开发任务。

在本章中,我们将涵盖以下主题:

  • 模式和模式运动的介绍

  • GOF 设计模式和响应式编程

  • 一些响应式编程模式和成语

面向对象编程和设计模式运动

在 90 年代初,面向对象编程(OOP)达到了临界点,当时 C++编程语言开始在 C 编程语言是主要编程语言的领域中取得进展。1992 年微软 C++编译器的出现,随后是微软基础类(MFC)库,使 C++编程成为了微软 Windows 下的主流。在 POSIX 世界中,C++ GUI 工具包如 WxWidgets 和 Qt,标志着面向对象编程的到来。面向对象编程运动的早期先驱者在各种杂志上写文章,如《Dr. Dobb's Journal》、《C++ Report》、《Microsoft Systems Journal》等,以传播他们的想法。

詹姆斯·科普利恩出版了一本有影响力的书,名为《高级 C++风格和成语》,讨论了与 C++编程语言的使用相关的低级模式(成语)。尽管它并不被广泛引用,但这本书的作者们认为它是一本记录面向对象编程最佳实践和技术的重要书籍。

埃里希·伽玛开始在他的博士论文中编写模式目录,从克里斯托弗·亚历山大的《城镇和建筑的模式》一书中获得灵感。在论文的过程中,有类似想法的人,即拉尔夫·约翰逊、约翰·弗利西德斯和理查德·赫尔姆,与埃里希·伽玛一起创建了一个包含 23 种设计模式的目录,现在被称为四人帮GOF)设计模式。Addison Wesley 在 1994 年出版了基于他们工作的书籍《设计模式:可重用面向对象软件的元素》。这很快成为程序员的重要参考,并推动了面向模式的软件开发。GOF 目录主要集中在软件设计上,很快模式目录开始出现在建筑、企业应用集成、企业应用架构等领域。

1996 年,西门子的一群工程师出版了《面向模式的软件架构(POSA)》一书,主要关注系统建设的架构方面。整个 POSA 模式目录被记录在由约翰·威利和儿子出版的五本书中。在这两项倡议之后,出现了一大波活动。其他值得注意的模式目录如下

  • 《企业应用架构模式》,作者马丁·福勒等。

  • 《企业集成模式》,作者格雷戈尔·霍普和鲍比·沃尔夫。

  • 《核心 J2EE 模式》,作者迪帕克·阿卢等。

  • 《领域驱动设计》,作者埃里克·埃文斯。

  • 《企业模式和 MDA》,作者吉姆·阿洛和伊拉·纽斯塔特。

尽管这些书在自己的领域内具有重要意义,但它们偏向于当时蓬勃发展的企业软件开发领域。对于 C++开发人员,GOF 目录和 POSA 目录是最重要的。

关键模式目录

模式是软件设计中常见问题的命名解决方案。模式通常被编入某种存储库。其中一些被出版成书。最受欢迎和广泛使用的模式目录是 GOF。

GOF 目录

Gang of Four(GOF)以目录的创建者命名,开始了模式运动。创建者们主要关注面向对象软件的设计和架构。克里斯托弗·亚历山大的想法从建筑架构中借鉴并应用到软件工程中。很快,人们开始在应用架构、并发、安全等领域进行模式倡议。Gang Of Four 将目录分为结构、创建和行为模式。原始书籍使用 C++和 Smalltalk 来解释这些概念。这些模式已经被移植并在今天存在的大多数面向对象的编程语言中得到利用。下表列出了 GOF 目录中的模式。

序号 模式类型 模式
1 创建模式 抽象工厂,生成器,工厂方法,原型,单例
2 结构模式 适配器,桥接,组合,装饰器,外观,享元,代理
3 行为模式 责任链,命令,解释器,迭代器,中介者,备忘录,观察者,状态,策略,模板方法,访问者

我们认为对 GOF 模式的深入理解对于任何程序员都是必要的。这些模式无论在应用领域如何,都随处可见。GOF 模式帮助我们以一种与语言无关的方式来交流和推理软件系统。它们在 C++、.NET 和 Java 世界中得到广泛实现。Qt 框架广泛利用了 GOF 存储库中的模式,为 C++编程语言提供了直观的编程模型,主要用于编写 GUI 应用程序。

POSA 目录

软件架构模式(五卷)是一本有影响力的书系,涵盖了开发关键任务系统的大部分适用模式。该目录适合编写大型软件的关键子系统的人,特别是数据库引擎、分布式系统、中间件系统等。该目录的另一个优点是非常适合 C++程序员。

该目录共有五卷,值得独立研究。如果我们想要编写像 Web 服务器、协议服务器、数据库服务器等工业强度的中间件软件,这个目录非常方便。以下表格包含了一系列模式类型和相关模式

序号 模式类型 模式
1 架构 层,管道和过滤器,黑板,经纪人,MVC,表示-抽象-控制,微内核,反射
2 设计 整体-部分,主从,代理,命令处理器,视图处理器,转发-接收器,客户端-调度器-服务器,发布者-订阅者
3 服务访问和配置模式 包装器外观,组件配置器,拦截器,扩展接口
4 事件处理模式 反应器,主动器,异步完成令牌,接收器-连接器
5 同步模式 作用域锁定,策略化锁定,线程安全接口,双重检查锁定优化
6 并发模式 主动对象,监视器对象,半同步/半异步,领导者/跟随者,线程特定存储
7 资源获取模式 查找,延迟获取,急切获取,部分获取
8 资源生命周期 缓存,池化,协调器,资源生命周期管理器
9 资源释放模式 租赁,驱逐者
10 分布式计算的模式语言 不是引入新模式,而是在分布式编程的上下文中对来自不同目录的模式进行整合
11 关于模式和模式语言 这最后一卷提供了有关模式、模式语言和使用的一些元信息

需要研究 POSA 目录,以深入了解部署在全球范围内的大型系统的架构基础。我们认为,尽管其重要性,这个目录并没有得到应有的关注。

设计模式重温

GOF 模式和响应式编程确实有比表面上显而易见的更深层次的联系。GOF 模式主要关注编写基于面向对象的软件。响应式编程是函数式编程、流编程和并发编程的结合。我们已经了解到,响应式编程纠正了经典的 GOF 观察者模式的一些缺陷(在第五章的第一节“可观察对象简介”中,我们涵盖了这个问题)。

编写面向对象的软件基本上是关于建模层次结构,从模式世界来看,组合模式是建模部分/整体层次结构的方法。无论何处有一个组合(用于建模结构),都会有一系列访问者模式的实现(用于建模行为)。访问者模式的主要目的是处理组合。换句话说,组合-访问者二元组是编写面向对象系统的规范模型。

访问者的实现应该对组合的结构具有一定的认识。使用访问者模式进行行为处理变得困难,因为给定组合的访问者数量不断增加。此外,向处理层添加转换和过滤进一步复杂化了问题。

引入迭代器模式,用于导航序列或项目列表。使用对象/函数式编程构造,我们可以非常容易地过滤和转换序列。微软的 LINQ 和 Java(8 及以上)中使用 lambda 处理集合类的例子都是迭代器模式的好例子。

那么,我们如何将层次数据转换为线性结构呢?大多数层次结构可以被展平为一个列表以进行进一步处理。最近,人们已经开始做以下事情:

  • 使用组合模式对它们的层次进行建模。

  • 使用专门用于此目的的访问者将层次结构展平为序列。

  • 使用迭代器模式导航这些序列。

  • 在执行操作之前,对序列应用一系列转换和过滤。

上述方法被称为“拉”式编程方法。消费者或客户端从事件或数据源中拉取数据进行处理。这种方案存在以下问题:

  • 数据被不必要地拉入客户端。

  • 转换和过滤应用在事件接收器(客户端)端。

  • 事件接收器可以阻塞服务器。

  • 这种风格不适合异步处理,其中数据随时间变化。

解决这个问题的一个好方法是逆向注视,即数据从服务器异步地作为流推送,事件接收器将对流做出反应。这种系统的另一个优点是在事件源端放置转换和过滤。这导致了一个场景,即只有绝对必要的数据需要在接收端进行处理。

方案如下:

  • 数据被视为称为可观察对象的流。

  • 我们可以对它们应用一系列操作符,或者更高级的操作符。

  • 操作符总是接收一个可观察对象并返回另一个可观察对象。

  • 我们可以订阅一个可观察对象以获取通知。

  • 观察者有标准机制来处理它们。

在本节中,我们学习了面向对象编程模式和响应式编程是如何密切相关的。合理地混合这两种范式可以产生高质量、可维护的代码。我们还讨论了如何将面向对象编程设计模式(组合/访问者)转换(扁平化结构)以利用迭代器模式。我们讨论了如何通过轻微的改进(在事件源端使用一种忘记即可的习语)来改进迭代方案,从而得到可观察对象。在下一节中,我们将通过编写代码来演示整个技术。

从设计模式到响应式编程

尽管设计模式运动与面向对象编程相一致,而响应式编程则更倾向于函数式编程,但它们之间存在着密切的相似之处。在前一章(第五章,可观察对象简介)中,我们学到了以下内容:

  • 面向对象编程模型适用于对系统的结构方面进行建模。

  • 函数式编程模型适用于对系统的行为方面进行建模。

为了说明面向对象编程和响应式编程之间的联系,我们将编写一个程序,用于遍历目录以枚举给定文件夹中的文件和子文件夹。

我们将创建一个包含以下内容的组合结构:

  • 一个继承自抽象类EntryNodeFileNode,用于模拟文件信息

  • 一个继承自抽象类EntryNodeDirectoryNode,用于模拟文件夹信息

在定义了上述的组合后,我们将为以下内容定义访问者:

  • 打印文件名和文件夹名

  • 将组合层次结构转换为文件名列表

话不多说,让我们来看看这段代码:

//---------- DirReact.cpp 
#include <rxcpp/rx.hpp> 
#include <memory> 
#include <map> 
#include <algorithm> 
#include <string> 
#include <vector> 
#include <windows.h> // This is omitted in POSIX version 
#include <functional> 
#include <thread> 
#include <future> 
using namespace std; 
//////////////////////////////////// 
//-------------- Forward Declarations 
//-------------- Model Folder/File 
class FileNode; 
class DirectoryNode; 
//////////////////////////////// 
//------------- The Visitor Interface 
class IFileFolderVisitor; 

上述的前向声明是为了在编译程序时消除编译器发出的错误和警告。FileNode存储文件名和文件大小作为实例变量。DirectoryNode存储文件夹名和FileNode列表,以表示目录中的文件和文件夹。FileNode/DirectoryNode层次结构由IFileFolderVisitor接口处理。现在,让我们为这些数据类型进行声明。

///////////////////////////////// 
//------ a Type to store FileInformation 
struct FileInformation{ 
   string name; 
   long size; 
   FileInformation( string pname,long psize ) 
   { name = pname;size = psize; } 
}; 
////////////////////////////// 
//-------------- Base class for File/Folder data structure 
class EntryNode{ 
    protected: 
      string  name; 
      int isdir; 
      long size; 
    public: 
      virtual bool Isdir() = 0; 
      virtual long getSize() = 0; 
      virtual void Accept(IFileFolderVisitor& ivis)=0; 
      virtual ~EntryNode() {} 
};

当我们创建一个组合时,我们需要创建一个作为层次结构所有成员的基类的节点类。在我们的情况下,EntryNode类就是这样做的。我们在基类中存储文件或文件夹的名称、大小等。除了应该由派生类实现的三个虚拟函数之外,我们还有一个虚拟析构函数。虚拟析构函数的存在确保了适当地应用析构函数,以避免资源泄漏。现在,让我们看看下面给出的访问者基类声明。

//-------------The Visitor Interface 
class IFileFolderVisitor{ 
   public: 
    virtual void Visit(FileNode& fn )=0; 
    virtual void Visit(DirectoryNode& dn )=0; 
}; 

每当我们使用组合模式风格的实现来定义层次结构时,我们会定义一个访问者接口来处理组合中的节点。对于组合中的每个节点,在访问者接口中都会有一个相应的visit方法。组合中类层次结构的每个节点都将有一个accept方法,在遍历组合时,访问者接口会将调用分派到相应节点的accept方法。accept方法将调用正确的访问者中的visit方法。这个过程被称为双重分派

// The Node which represents Files 
class FileNode : public EntryNode { 
   public:  
   FileNode(string pname, long psize) {  isdir = 0; name = pname; size = psize;} 
   ~FileNode() {cout << "....Destructor FileNode ...." << name << endl; } 
   virtual bool  Isdir() { return isdir == 1; } 
   string getname() { return name; }
   virtual long getSize() {return size; } 
   //------------- accept method 
   //------------- dispatches call to correct node in
   //------------- the Composite
   virtual void Accept( IFileFolderVisitor& ivis ){ivis.Visit(*this);} 
}; 

FileNode类只存储文件的名称和大小。该类还实现了基类(EntryNode)中声明的所有虚拟方法。accept方法将调用重定向到正确的访问者级别方法,如下所示:

// Node which represents Directory 
class DirectoryNode : public EntryNode { 
  list<unique_ptr<EntryNode>> files;   
public: 
  DirectoryNode(string pname)  
  { files.clear(); isdir = 1; name = pname;} 
  ~DirectoryNode() {files.clear();} 
  list<unique_ptr<EntryNode>>& GetAllFiles() {return files;} 
  bool AddFile( string pname , long size) { 
       files.push_back(unique_ptr<EntryNode> (new FileNode(pname,size))); 
       return true; 
  } 
  bool AddDirectory( DirectoryNode *dn ) { 
        files.push_back(unique_ptr<EntryNode>(dn)); 
        return true; 
  } 
  bool Isdir() { return isdir == 1; } 
  string  getname() { return name; } 
  void   setname(string pname) { name = pname; } 
  long getSize() {return size; } 
  //
  //--------------------- accept method
  void Accept( IFileFolderVisitor& ivis ){ivis.Visit(*this); } 
}; 

DirectoryNode 类模拟了一个带有文件和子文件夹列表的文件夹。我们使用智能指针来存储条目。和往常一样,我们也实现了与 EntryNode 类相关的所有虚拟函数。AddFileAddDirectory 方法用于填充列表。在使用特定于操作系统的函数遍历目录时,我们使用前面两种方法填充了 DirectoryNode 对象的内容。让我们看一下目录遍历辅助函数的原型。我们省略了源代码的完整列表(可在网上找到)。

//------Directory Helper Has to be written for Each OS 
class DirHelper { 
 public: 
    static  DirectoryNode  *SearchDirectory(
            const std::string& refcstrRootDirectory){ 
           //--------------- Do some OS specific stuff to retrieve 
           //--------------- File/Folder hierarchy from the root folder 
           return DirNode; 
}}; 

DirHelper 逻辑在 Windows 和 GNU Linux/macOS X 之间有所不同。我们省略了书中实现的源代码。相关网站包含了前述类的完整源代码。基本上,该代码递归遍历目录以填充数据结构。现在,我们将转移到上面创建的 Composite 的遍历主题。以下代码展示了如何使用实现了 IFileFolderVisitor 接口的 Visitor 类来遍历 Composite。

///////////////////////////////////// 
//----- A Visitor Interface that prints 
//----- The contents of a Folder 
class PrintFolderVisitor : public IFileFolderVisitor 
{ 
  public: 
    void Visit(FileNode& fn ) {cout << fn.getname() << endl; } 
    void Visit(DirectoryNode& dn ) { 
      cout << "In a directory " << dn.getname() << endl; 
      list<unique_ptr<EntryNode>>& ls = dn.GetAllFiles(); 
      for ( auto& itr : ls ) { itr.get()->Accept(*this);} 
    } 
}; 

PrintFolderVisitor 类是一个 Visitor 实现,用于在控制台上显示文件和文件夹信息。该类演示了如何为 Composite 实现一个基本的访问者。在我们的情况下,Composite 只有两个节点,编写访问者实现非常容易。

在某些情况下,层次结构中节点类型的数量很多,编写访问者实现并不容易。为访问者编写过滤器和转换可能很困难,逻辑是临时的。让我们编写一个程序来打印文件夹的内容。代码如下:

//--------------- has used raw pointers 
//--------------- in a production implementation, use smart pointer
void TestVisitor( string directory ){ 
  // Search files including subdirectories 
  DirectoryNode *dirs = DirHelper::SearchDirectory(directory); 
  if ( dirs == 0 ) {return;} 
  PrintFolderVisitor *fs = new PrintFolderVisitor (); 
  dirs->Accept(*fs); delete fs; delete dirs; 
} 

上述函数递归遍历目录并创建一个 Composite(DirectoryNode *)。我们使用 PrintFolderVisitor 来打印文件夹的内容,如下所示:

int main(int argc, char *argv[]) {  TestVisitor("D:\\Java"); }

将层次结构展平以便遍历

访问者实现必须对 Composite 的结构有一定的了解。在某些 Composite 实现中,需要实现大量的访问者。此外,在访问者接口的情况下,对节点应用转换和过滤有些困难。GOF 模式目录中有一个迭代器模式,可用于遍历一系列项。问题是:如何使用迭代器模式将层次结构线性化以进行处理?大多数层次结构可以通过编写用于此目的的访问者实现来展平为列表、序列或流。让我们为所述任务编写一个展平访问者。

看一下以下代码:

// Flatten the File/Folders into a linear list 
class FlattenVisitor : public IFileFolderVisitor{ 
    list <FileInformation> files; 
    string CurrDir; 
 public: 
    FlattenVisitor() { CurrDir = "";} 
    ~FlattenVisitor() { files.clear();} 
    list<FileInformation> GetAllFiles() { return files; } 
    void Visit(FileNode& fn ) { 
       files.push_back( FileInformation{ 
                  CurrDir +"\" + fn.getname(),fn.getSize())); 
    } 
    void Visit(DirectoryNode& dn ) { 
        CurrDir = dn.getname(); 
        files.push_back( FileInformation( CurrDir, 0 )); 
        list<unique_ptr<EntryNode>>& ls = dn.GetAllFiles(); 
        for ( auto& itr : ls ) { itr.get()->Accept(*this);} 
    } 
}; 

FlattenVisitor 类在 STL 列表中收集文件和文件夹。对于每个目录,我们遍历文件列表并使用熟悉的双重分发调用 accept 方法。让我们编写一个函数,返回一个 FileInformation 列表供我们遍历。代码如下:

list<FileInformation> GetAllFiles(string dirname ){ 
   list<FileInformation> ret_val; 
   // Search files including subdirectories 
   DirectoryNode *dirs = DirHelper::SearchDirectory(dirname); 
   if ( dirs == 0 ) {return ret_val;} 
   //--  We have used Raw pointers here...
   //--- In Modern C++, one can use smart pointer here
   //  unique_ptr<FlattenVisitor> fs(new FlattenVisitor());
   //  We can avoid delete fs
   FlattenVisitor *fs = new FlattenVisitor(); 
   dirs->Accept(*fs); 
   ret_val = fs->GetAllFiles(); 
   //--------- use of Raw pointer 
   delete fs; delete dirs; 
   return ret_val; 
} 
int main(int argc, char *argv[]) { 
  list<FileInformation> rs = GetAllFiles("D:\JAVA"); 
  for( auto& as : rs ) 
    cout << as.name << endl; 
} 

FlattenVisitor 类遍历 DirectoryNode 层次结构,并将完全展开的路径名收集到 STL 列表容器中。一旦我们将层次结构展平为列表,就可以对其进行迭代。

我们已经学会了如何将层次结构建模为 Composite,并最终将其展平为适合使用迭代器模式进行导航的形式。在下一节中,我们将学习如何将迭代器转换为可观察对象。我们将使用 RxCpp 来实现可观察对象,通过使用一种推送值从事件源到事件接收端的“发射并忘记”模型。

从迭代器到可观察对象

迭代器模式是从 STL 容器、生成器和流中拉取数据的标准机制。它们非常适合在空间中聚合的数据。基本上,这意味着我们预先知道应该检索多少数据,或者数据已经被捕获。有些情况下,数据是异步到达的,消费者不知道有多少数据或数据何时到达。在这种情况下,迭代器需要等待,或者我们需要采用超时策略来处理这种情况。在这种情况下,基于推送的方法似乎是更好的选择。使用 Rx 的 Subject 构造,我们可以使用 fire and forget 策略。让我们编写一个类,发出目录的内容,如下所示:

////////////////////////////// 
// A Toy implementation of Active  
// Object Pattern... Will be explained as a separate pattern
template <class T> 
struct ActiveObject { 
    rxcpp::subjects::subject<T> subj; 
    // fire-and-forget 
    void FireNForget(T & item){subj.get_subscriber().on_next(item);} 
    rxcpp::observable<T> GetObservable()  
    { return subj.get_observable(); } 
    ActiveObject(){}  
    ~ActiveObject() {} 
}; 
/////////////////////// 
// The class uses a FireNForget mechanism to  
// push data to the Data/Event sink 
// 
class DirectoryEmitter { 
      string rootdir; 
      //-------------- Active Object ( a Pattern in it's own right ) 
      ActiveObject<FileInformation> act; // more on this below  
  public: 
      DirectoryEmitter(string s )   { 
         rootdir = s; 
         //----- Subscribe  
         act.GetObservable().subscribe([] ( FileInformation item ) { 
            cout << item.name << ":" << item.size << endl; 
         }); 
      } 
      bool Trigger() { 
           std::packaged_task<int()> task([&]() {  EmitDirEntry(); return 1; }); 
           std::future<int> result = task.get_future(); 
           task(); 
           //------------ Comment the below lineto return immediately 
           double dresult = result.get(); 
           return true; 
      } 
      //----- Iterate over the list of files  
      //----- uses ActiveObject Pattern to do FirenForget 
      bool EmitDirEntry() { 
           list<FileInformation> rs = GetAllFiles(rootdir); 
           for( auto& a : rs ) { act.FireNForget(a); } 
           return false; 
      } 
}; 
int main(int argc, char *argv[]) { 
  DirectoryEmitter emitter("D:\\JAVA"); 
  emitter.Trigger(); return 0; 
} 

DirectoryEmitter类使用现代 C++的packaged_task构造以 fire and forget 的方式进行异步调用。在前面的列表中,我们正在等待结果(使用std::future<T>)。我们可以在上面的代码列表中注释一行(参见列表中的内联注释),以立即返回。

Cell 模式

我们已经学到,响应式编程是关于处理随时间变化的值。响应式编程模型以 Observable 的概念为中心。Observable 有两种变体,如下所示:

  • 单元:单元是一个实体(变量或内存位置),其值随时间定期更新。在某些情境中,它们也被称为属性或行为。

  • 流:流代表一系列事件。它们通常与动作相关的数据。当人们想到 Observable 时,他们脑海中有 Observable 的流变体。

我们将实现一个 Cell 编程模式的玩具版本。我们只专注于实现基本功能。该代码需要整理以供生产使用。

以下的实现可以进行优化,如果我们正在实现一个名为 Cell controller 的控制器类。然后,Cell controller 类(包含所有单元的单个 Rx Subject)可以从所有单元(到一个中央位置)接收通知,并通过评估表达式来更新依赖关系。在这里,我们已经为每个单元附加了 Subject。这个实现展示了 Cell 模式是一个可行的依赖计算机制:

//------------------ CellPattern.cpp 
#include <rxcpp/rx.hpp> 
#include <memory> 
#include <map> 
#include <algorithm> 
using namespace std; 
class Cell 
{ 
  private: 
    std::string name; 
    std::map<std::string,Cell *> parents; 
    rxcpp::subjects::behavior<double> *behsubject;   
  public: 
    string get_name() { return name;} 
    void SetValue(double v )  
    { behsubject->get_subscriber().on_next(v);} 
    double GetValue()  
    { return behsubject->get_value(); } 
    rxcpp::observable<double> GetObservable()  
    { return behsubject->get_observable(); } 
    Cell(std::string pname) { 
       name = pname; 
       behsubject = new rxcpp::subjects::behavior<double>(0); 
    } 
    ~Cell() {delete behsubject; parents.clear();} 
    bool GetCellNames( string& a , string& b ) 
    { 
         if ( parents.size() !=2 ) { return false; } 
         int i = 0; 
         for(auto p  : parents ) { 
            ( i == 0 )? a = p.first : b = p.first; 
            i++;      
         } 
         return true; 
    } 
    ///////////////////////////// 
    // We will just add two parent cells... 
    // in real life, we need to implement an  
    // expression evaluator 
    bool Recalculate() { 
        string as , bs ; 
        if (!GetCellNames(as,bs) ) { return false; } 
        auto a = parents[as]; 
        auto b = parents[bs]; 
        SetValue( a->GetValue() + b->GetValue() ); 
        return true; 
    } 
    bool Attach( Cell& s ) { 
       if ( parents.size() >= 2 ) { return false; } 
       parents.insert(pair<std::string,Cell *>(s.get_name(),&s)); 
       s.GetObservable().subscribe( [=] (double a ) { Recalculate() ;}); 
       return true; 
    } 
    bool Detach( Cell& s ) { //--- Not Implemented  
    } }; 

Cell 类假设每个单元有两个父依赖关系(为了简化实现),每当父级的值发生变化时,单元的值将被重新计算。我们只实现了加法运算符(为了保持列表的简洁)。recalculate方法实现了上面显示的逻辑:让我们编写一个主程序把所有东西放在一起。

int main(int argc, char *argv[]) {     
    Cell a("a");  
    Cell b("b"); 
    Cell c("c"); 
    Cell d("d"); 
    Cell e("e"); 
    //-------- attach a to c 
    //-------- attach b to c 
    //-------- c is a + b  
    c.Attach(a); 
    c.Attach(b); 
    //---------- attach c to e 
    //---------- attach d to e 
    //---------- e is c + d or e is a + b + d; 
    e.Attach(c); 
    e.Attach(d); 
    a.SetValue(100);  // should print 100 
    cout << "Value is " << c.GetValue() << endl; 
    b.SetValue(200);  // should print 300 
    cout << "Value is " << c.GetValue() << endl; 
    b.SetValue(300);  // should print 400 
    cout << "Value is " << c.GetValue() << endl; 
    d.SetValue(-400); // should be Zero 
    cout << "Value is " << e.GetValue() << endl; 
} 

主程序演示了如何使用 Cell 模式将更改传播到依赖项中。通过更改 cless 中的值,我们强制重新计算依赖单元中的值。

Active 对象模式

Active 对象是一个将方法调用和方法执行分离的类,非常适合于 fire and forget 的异步调用。附加到类的调度程序处理执行请求。该模式由六个元素组成,如下所示:

  • 代理,为客户端提供具有公开可访问方法的接口

  • 定义 Active 对象上的方法请求的接口

  • 来自客户端的待处理请求列表

  • 决定下一个要执行的请求的调度程序

  • Active 对象方法的实现

  • 客户端接收结果的回调或变量

我们将剖析 Active 对象模式的实现。这个程序是为了阐明而编写的;在生产中使用,我们需要使用更复杂的方法。尝试生产质量的实现会使代码变得相当长。让我们看一下代码:

#include <rxcpp/rx.hpp> 
#include <memory> 
#include <map> 
#include <algorithm> 
#include <string> 
#include <vector> 
#include <windows.h> 
#include <functional> 
#include <thread> 
#include <future> 
using namespace std; 
//------- Active Object Pattern Implementation 
template <class T> 
class ActiveObject { 
    //----------- Dispatcher Object 
    rxcpp::subjects::subject<T> subj; 
    protected: 
    ActiveObject(){ 
       subj.get_observable().subscribe([=] (T s ) 
       { Execute(s); }); 
    }  
    virtual void Execute(T s) {} 
    public: 
    // fire-and-forget 
    void FireNForget(T item){ subj.get_subscriber().on_next(item);} 
    rxcpp::observable<T> GetObservable() { return subj.get_observable(); } 
    virtual ~ActiveObject() {} 
}; 

前面的实现声明了一个subject<T>类的实例,作为通知机制。FireNForget方法通过调用get_subscriber方法将值放入 subject 中。该方法立即返回,订阅方法将检索该值并调用Execute方法。该类应该被具体实现所重写。让我们来看一下代码:

class ConcreteObject : public ActiveObject<double> { 
    public: 
     ConcreteObject() {} 
     virtual void Execute(double a ) 
     { cout << "Hello World....." << a << endl;} 
}; 
int main(int argc, char *argv[]) { 
  ConcreteObject temp; 
  for( int i=0; i<=10; ++i ) 
      temp.FireNForget(i*i); 
  return 0; 
}

前面的代码片段调用了FireNForget方法,传入了一个双精度值。在控制台上,我们可以看到该值被显示出来。重写的Execute方法会自动被调用。

资源借贷模式

借贷模式,正如其名字所示,将资源借给一个函数。在下面给出的示例中,文件句柄被借给了类的消费者。它执行以下步骤:

  1. 它创建一个可以使用的资源(文件句柄)

  2. 它将资源(文件句柄)借给将使用它的函数(lambda)

  3. 这个函数由调用者传递并由资源持有者执行

  4. 资源(文件句柄)由资源持有者关闭或销毁

以下代码实现了资源管理的资源借贷模式。该模式有助于在编写代码时避免资源泄漏:

//----------- ResourceLoan.cpp 
#include <rxcpp/rx.hpp> 
using namespace std; 
////////////////////////// 
// implementation of Resource Loan  Pattern. The Implementation opens a file 
// and does not pass the file handle to user  defined Lambda. The Ownership remains with 
// the class  
class ResourceLoan { 
   FILE *file;  // This is the resource which is being loaned
   string filename; 
  public: 
     ResourceLoan(string pfile) { 
        filename = pfile; 
        //---------- Create the resource
        file = fopen(filename.c_str(),"rb"); 
     }   
     //////////////////////////// 
     // Read upto 1024 bytes to a buffer  
     // return the buffer contents and number of bytes 
     int ReadBuffer( std::function<int(char pbuffer[],int val )> func ) 
     { 
          if (file == nullptr ) { return -1; } 
          char buffer[1024]; 
          int result = fread (buffer,1,1024,file); 
          return func(buffer,result); 
     }  
     //---------- close the resource 
     ~ResourceLoan() { fclose(file);} 
}; 
//////////////////////////////// 
// A Sample Program to invoke the preceding 
// class 
// 
int main(int argc, char *argv[]) { 
  ResourceLoan res("a.bin"); 
  int nread ; 
  //------------- The conents of the buffer 
  //------------- and size of buffer is stored in val 
  auto rlambda =  [] (char buffer[] , int val ) { 
       cout <<  "Size " << val << endl; 
       return val; 
  }; 
  //------- The File Handle is not available to the  
  //------- User defined Lambda It has been loaned to the 
  //-------- consumer of the class
  while ((nread = res.ReadBuffer(rlambda)) > 0) {} 
  //---- When the ResourceLoan object goes out of scope 
  //---- File Handle is closed 
  return 0; 
} 

资源借贷模式适用于避免资源泄漏。资源的持有者从不直接将资源的句柄或指针交给其消费者。主程序演示了我们如何消费该实现。ResourceLoan 类从不允许其消费者直接访问文件句柄。

事件总线模式

事件总线充当事件源和事件接收器之间的中介。事件源或生产者向总线发出事件,订阅事件的类(消费者)将收到通知。该模式可以是中介者设计模式的一个实例。在事件总线实现中,我们有以下原型

  • 生产者:产生事件的类

  • 消费者:消费事件的类

  • 控制器:充当生产者和消费者的类

在接下来的实现中,我们省略了控制器的实现。以下代码实现了事件总线的一个玩具版本:

//----------- EventBus.cpp 
#include <rxcpp/rx.hpp> 
#include <memory> 
#include <map> 
#include <algorithm> 
using namespace std; 
//---------- Event Information 
struct EVENT_INFO{ 
   int id; 
   int err_code; 
   string description; 
   EVENT_INFO() { id = err_code = 0 ; description ="default";} 
   EVENT_INFO(int pid,int perr_code,string pdescription ) 
   { id = pid; err_code = perr_code; description = pdescription; } 
   void Print() { 
      cout << "id & Error Code" << id << ":" << err_code << ":"; 
      cout << description << endl; 
   } 
}; 

EVENT_INFO结构模拟了一个事件,它包含以下内容:

  • Id:事件 ID

  • err_code:错误代码

  • description:事件描述

其余的代码相当明显;在这里是:

//----------- The following method 
//----------- will be invoked by  
//----------- Consumers 
template <class T> 
void DoSomeThingWithEvent( T ev ) 
{ev.Print();} 

//---------- Forward Declarations  
template <class T> 
class EventBus; 
//------------- Event Producer 
//------------- Just Inserts event to a Bus 
template <class T> 
class Producer { 
  string name; 
 public: 
   Producer(string pname ) { name = pname;} 
   bool Fire(T ev,EventBus<T> *bev ) { 
         bev->FireEvent(ev); 
         return false; 
   } 
}; 

生产者类的实现相当简单。骨架实现相当琐碎。Fire方法以兼容的EventBus<T>作为参数,并调用EventBus<T>类的FireEvent方法。生产实现需要一些花里胡哨的东西。让我们来看一下消费者类的代码。

//------------ Event Consumer 
//------------ Subscribes to a Subject 
//------------ to Retrieve Events 
template <class T> 
class Consumer { 
  string name; 
  //--------- The subscription member helps us to 
  //--------- Unsubscribe to an Observable  
  rxcpp::composite_subscription subscription; 
public: 
  Consumer(string pname) { name = pname;} 
  //--------- Connect a Consumer to a Event Bus 
  bool Connect( EventBus<T> *bus ) { 
      //------ If already subscribed, Unsubscribe! 
      if ( subscription.is_subscribed() ) 
             subscription.unsubscribe(); 
      //------- Create a new Subscription 
      //------- We will call DoSomeThingWithEvent method 
      //------- from Lambda function 
      subscription = rxcpp::composite_subscription(); 
      auto subscriber = rxcpp::make_subscriber<T>( 
        subscription,={ 
            DoSomeThingWithEvent<T>(value); 
        },[](){ printf("OnCompletedn");}); 
      //----------- Subscribe! 
      bus->GetObservable().subscribe(subscriber); 
      return true; 
  } 
  //-------- DTOR ....Unsubscribe 
  ~Consumer() { Disconnect(); } 
  bool Disconnect() { 
       if (subscription.is_subscribed() ) 
        subscription.unsubscribe(); 
  } 
}; 

Consumer<T>的功能非常明显。Connect方法负责订阅EventBus<T>类中 Subject 的 Observable 端。每当有新的连接请求时,现有的订阅将被取消订阅,如下所示:

//--- The implementation of the EventBus class 
//--- We have not taken care of Concurrency issues 
//--- as our purpose is to demonstrate the pattern 
template <class T> 
class EventBus 
{ 
  private: 
    std::string name; 
    //----- Reference to the Subject... 
    //----- Consumers get notification by  
    //----- Subscribing to the Observable side of the subject 
    rxcpp::subjects::behavior<T> *replaysubject;  
  public: 
    EventBus<T>() {replaysubject = new rxcpp::subjects::behavior<T>(T());} 
    ~EventBus() {delete replaysubject;} 
    //------ Add a Consumer to the Bus... 
    bool AddConsumer( Consumer<T>& b ) {b.Connect(this);} 
    //------ Fire the Event... 
    bool FireEvent ( T& event ) { 
       replaysubject->get_subscriber().on_next(event); 
       return true; 
    } 
    string get_name() { return name;} 
    rxcpp::observable<T> GetObservable()  
    { return replaysubject->get_observable(); } 
}; 

EventBus<T>类充当生产者和消费者之间的导管。我们在底层使用replaysubject来通知消费者。现在,我们已经完成了生产者和消费者类的编写,让我们看看如何利用上面编写的代码。

///////////////////// 
//The EntryPoint 
// 
// 
int main(int argc, char *argv[]) { 
    //---- Create an instance of the EventBus 
    EventBus<EVENT_INFO> program_bus; 
    //---- Create a Producer and Two Consumers 
    //---- Add Consumers to the EventBus 
    Producer<EVENT_INFO> producer_one("first"); 
    Consumer<EVENT_INFO> consumer_one("one"); 
    Consumer<EVENT_INFO> consumer_two("two"); 
    program_bus.AddConsumer(consumer_one); 
    program_bus.AddConsumer(consumer_two); 
    //---- Fire an Event... 
    EVENT_INFO ev; 
    ev.id = 100; 
    ev.err_code = 0; 
    ev.description = "Hello World.."; 
    producer_one.Fire(ev,&program_bus); 
    //---- fire another by creating a second  
    //---- Producer 
    ev.id = 100; 
    ev.err_code = 10; 
    ev.description = "Error Happened.."; 
    Producer<EVENT_INFO> producer_two("second"); 
    producer_two.Fire(ev,&program_bus); 
} 

在主函数中,我们正在执行以下任务:

  1. 创建EventBus<T>的实例

  2. 创建生产者的实例

  3. 创建消费者的实例

  4. 向总线分发事件

我们只涵盖了适用于编写响应式程序的设计模式的子集。主要,我们的重点是从 GOF 设计模式过渡到响应式编程世界。事实上,本书的作者认为响应式编程模型是经典 GOF 设计模式的增强实现。这种增强是由于现代编程语言中添加的函数式编程构造。事实上,对象/函数式编程是编写现代 C++代码的良好方法。本章在很大程度上是基于这个想法。

总结

在本章中,我们深入探讨了与 C++编程和响应式编程相关的设计模式/习惯用法的美妙世界。从 GOF 设计模式开始,我们转向了响应式编程模式,逐渐过渡从面向对象编程到响应式编程是本章的亮点。之后,我们涵盖了诸如 Cell、Active object、Resource loan 和 Event bus 等响应式编程模式。从 GOF 模式过渡到响应式编程有助于你以更广泛的视角看待响应式编程。在下一章中,我们将学习使用 C++进行微服务开发。

第十二章:使用 C++的响应式微服务

到目前为止,我们已经涵盖了使用 C++进行响应式编程的基本方面。涵盖的一些关键主题包括:

  • 响应式编程模型及其认知先决条件

  • RxCpp 库及其编程模型

  • 使用 Qt/RxCpp 进行响应式 GUI 编程

  • 编写自定义操作符

  • 设计模式和响应式编程模型

如果你仔细看,这本书中到目前为止的所有例子都与进程内发生的事情有关。或者,我们基本上关注的是共享内存并发和并行技术。Rx.net、RxJava 和大多数 Rx 实现基本上都涉及共享内存并发和并行编程。像 Akka 这样的系统将响应式编程模型应用于分布式世界。在 Akka 中,我们可以编写跨进程和机器的响应式逻辑。响应式编程模型也适用于暴露基于 REST 的 Web 服务和消费它们。RxJs 库主要用于从浏览器页面消费基于 REST 的服务。RxCpp 库可用于编写用于聚合来自各种服务端点的内容的 Web 客户端。我们可以从控制台和 GUI 应用程序中利用 RxCpp 库。另一个用例是从多个细粒度服务中聚合数据并将其传递给 Web 客户端。

在本章中,我们将使用 C++编写一个基本的 Web 应用程序,利用 Microsoft C++ REST SDK 编写服务器部分,并使用(C++ REST SDK)客户端库来消费这些服务。在这个过程中,我们将解释什么是微服务以及如何消费它们。我们还将解释如何使用 RxCpp 库来访问 REST 端点和 HTML 页面,通过在libcurl库的顶部编写包装器。我们计划利用 Kirk Shoop 的 RxCurl 库(作为他的 Twitter 分析应用程序的一部分编写)来演示这种技术。

C++语言和 Web 编程

如今,大多数面向 Web 的应用程序都是使用 Python、Java、C#、PHP 和其他高级语言开发的。但是,对于这些应用程序,人们会放置反向代理,如 NGINX、Apache Web 服务器或 IIS 重定向器,来管理流量。所有这些反向代理都是用 C++编写的。同样,大多数 Web 浏览器和 HTTP 客户端库,如libwwwlibcurlWinInet,都是使用 C++编写的。

Java、(静态类型的)C#和其他动态语言(如 Python、Ruby 和 PHP)变得流行的一个原因是,这些语言支持反射能力(在静态语言如 C#/Java 的情况下)和鸭子类型(动态语言支持)。这些功能帮助 Web 应用程序服务器动态加载 Web 页面处理程序。通过搜索关键字如Reflection APIDuck Typing来了解它们。

REST 编程模型

REST 代表表现状态转移,是由 Roy Fielding 作为他的博士论文的一部分推动的一种架构风格。如今,它是最流行的暴露和消费 Web 服务的技术之一。REST 遵循以资源为中心的方法,并很好地映射到 CRUD 模式,这在熟悉编写企业业务应用程序的程序员中很受欢迎。在编写 REST 服务时,我们使用JavaScript 对象表示法(也称为JSON)作为有效载荷,而不是 XML 格式(这在 SOAP 服务中很流行)。REST 编程模型依赖于 HTTP 动词(GET、POST、PUT、DELETE 等),以指示在接收 REST API 调用时要执行的操作类型。支持的最流行的方法有:

  • POST:创建新资源

  • GET:检索资源

  • PUT:更新现有资源(如果是新资源,则行为类似于POST

  • DELETE:删除资源

C++ REST SDK

C++ REST SDK 是一个 Microsoft 项目,用于使用现代异步 C++ API 设计在本机代码中进行基于云的客户端-服务器通信。这个工具包旨在帮助 C++开发人员连接和与基于 HTTP 的服务进行交互。该 SDK 具有以下功能,可帮助您编写健壮的服务:

  • HTTP 客户端/服务器

  • JSON 支持

  • 异步流

  • WebSocket 的客户端

  • oAuth 支持

C++ REST SDK 依赖于并行模式库的任务 API。PPL 任务是一个基于现代 C++特性组合异步操作的强大模型。C++ REST SDK 支持 Windows 桌面、Windows Store(UWP)、Linux、macOS、Unix、iOS 和 Android。

使用 C++ REST SDK 进行 HTTP 客户端编程

C++ REST SDK 编程模型本质上是异步的,我们也可以以同步的方式调用 API 调用。以下程序将演示我们如何异步调用 HTTP 客户端 API 调用。该程序演示了 C++ REST SDK 支持的 HTTP 协议的客户端端的工作方式。我们在这里使用了一种称为任务继续(一种链接代码块的技术)的技术来从网页中检索数据并将其存储在本地磁盘文件中。C++ REST SDK 遵循异步 I/O 模型,我们将操作链接在一起。最后,我们使用wait()方法调用组合:

#include <cpprest/http_client.h> 
#include <cpprest/filestream.h> 
//----- Some standard C++ headers emitted for brevity
#include "cpprest/json.h" 
#include "cpprest/http_listener.h" 
#include "cpprest/uri.h" 
#include "cpprest/asyncrt_utils.h" 
//////////////////////////////////////////////// 
// A Simple HTTP Client to Demonstrate  
// REST SDK Client programming model 
// The Toy sample shows how one can read  
// contents of a web page 
// 
using namespace utility;  // Common utilities like string conversions 
using namespace web;      // Common features like URIs. 
using namespace web::http;// Common HTTP functionality 
using namespace web::http::client;// HTTP client features 
using namespace concurrency::streams;// Asynchronous streams 

int main(int argc, char* argv[]) 
{ 
   auto fileStream = std::make_shared<ostream>(); 
   // Open stream to output file. 
   pplx::task<void> requestTask =  
              fstream::open_ostream(U("google_home.html")). 
         then(= 
   { 
         *fileStream = outFile; 
         // Create http_client to send the request. 
         http_client client(U("http://www.google.com")); 
         // Build request URI and start the request. 
          uri_builder builder(U("/")); 
         return client.request(methods::GET, builder.to_string()); 

   }).then(= 
   { 
         printf("Received response status code:%un",  
                                    response.status_code()); 
             return response.body(). 
                           read_to_end(fileStream->streambuf()); 
   }).then(={ 
         return fileStream->close(); 
   }); 

   // We have not started execution, just composed 
   // set of tasks in a Continuation Style 
   // Wait for all the outstanding I/O to complete  
   // and handle any exceptions, If any  
   try { 
         //-- All Taskss will get triggered here 
         requestTask.wait(); 
   } 
   catch (const std::exception &e) { 
         printf("Error exception:%sn", e.what()); 
   } 
   //---------------- pause for a key  
   getchar(); 
   return 0; 
} 

上述程序演示了任务继续编程风格的工作方式。大部分代码都是关于组合 lambda 表达式,实际执行是在调用wait()方法时开始的。lambda 函数的惰性评估策略帮助我们以上述方式组合代码。我们也可以以同步的方式调用逻辑。请参阅 Microsoft C++ REST SDK 文档以了解更多信息。

使用 C++ REST SDK 进行 HTTP 服务器编程

我们已经了解了 C++ REST SDK 支持的 HTTP 客户端编程模型。我们使用了基于异步任务继续的 API 来检索网页内容并将其保存到磁盘文件中。现在,是时候开始集中精力研究 REST SDK 的 HTTP 服务器编程了。C++ REST SDK 具有一个监听器接口,用于处理 HTTP 请求,我们可以为每种 HTTP 动词类型(如GETPUTPOST等)放置处理程序。

///////////////////////////////// 
//  A Simple Web Application with C++ REST SDK 
//  We can use Postman Or Curl to test the Server 
using namespace std; 
using namespace web; 
using namespace utility; 
using namespace http; 
using namespace web::http::experimental::listener; 
///////////////////////////// 
// SimpleServer is a Wrapper over  
// http_listener class available with C++ REST SDK 
class SimpleServer 
{ 
public: 

   SimpleServer(utility::string_t url); 
   ~SimpleServer() {} 
   pplx::task<void> Open() { return m_listener.open(); } 
   pplx::task<void> Close() { return m_listener.close(); } 

private: 
   //--- Handlers for HTTP verbs 
   void HandleGet(http_request message); 
   void HandlePut(http_request message); 
   void HandlePost(http_request message); 
   void HandleDelete(http_request message); 
   //--------------- The  HTTP listener class 
   http_listener m_listener; 
};

SimpleServer C++类基本上是 C++ REST SDK 支持的http_listener类的包装器。该类监听传入的 HTTP 请求,可以为每种请求类型(GETPOSTPUT等)设置请求处理程序。当请求到达时,http_listener将根据 HTTP 动词将请求信息分派给关联的处理程序。

////////////////////////////////// 
// The Constructor Binds HTTP verbs to instance methods 
// Based on the naming convention, we can infer what is happening 
SimpleServer::SimpleServer(utility::string_t url) : m_listener(url) 
{ 
   m_listener.support(methods::GET, std::bind(&SimpleServer::HandleGet, 
               this, std::placeholders::_1)); 
   m_listener.support(methods::PUT, std::bind(&SimpleServer::HandlePut, 
               this, std::placeholders::_1)); 
   m_listener.support(methods::POST, std::bind(&SimpleServer::HandlePost,  
               this, std::placeholders::_1)); 
   m_listener.support(methods::DEL, std::bind(&SimpleServer::HandleDelete,  
                this, std::placeholders::_1)); 

} 

前面的代码片段将请求处理程序绑定到http_request对象。我们只关注GETPUTPOSTDELETE动词。这些动词是所有 REST 实现都支持的最流行的命令:

///////////////////////////////////// 
// For this implementation, what we do is  
// spit the HTTP request details on the Server Console 
// and return 200 OK and a String which indicates  Success of Operations  
void SimpleServer::HandleGet(http_request message){ 
   ucout << message.to_string() << endl; 
   message.reply(status_codes::OK,L"GET Operation Succeeded"); 
} 
void SimpleServer::HandlePost(http_request message){ 
   ucout << message.to_string() << endl; 
   message.reply(status_codes::OK, L"POST Operation Succeeded"); 
}; 

void SimpleServer::HandleDelete(http_request message){ 
   ucout << message.to_string() << endl; 
   message.reply(status_codes::OK, L"DELETE Operation Succeeded"); 
} 
void SimpleServer::HandlePut(http_request message){ 
   ucout << message.to_string() << endl; 
   message.reply(status_codes::OK, L"PUT Operation Succeeded"); 
}; 

上面的代码块遵循一种模式,任何开发人员都可以轻松解读。处理程序的所有操作只是将请求参数打印到服务器的控制台上,并向客户端返回一个字符串,指示请求已成功完成(HTTP 状态码-200)。我们将在下一节中展示如何通过 POSTMAN 和 CURL 工具访问这些服务。

//////////////////////////////// 
// A Smart Pointer for Server Instance... 
// 
std::unique_ptr<SimpleServer> g_http; 
////////////////////////////////////////////////// 
// STart the Server with the Given URL 
// 
void StartServer(const string_t& address) 
{ 
   // Build our listener's URI from the address given 
   // We just append DBDEMO/ to the base URL 
   uri_builder uri(address); 
   uri.append_path(U("DBDEMO/")); 
   auto addr = uri.to_uri().to_string(); 
   ///////////////////////////////// 
   // Create an Instance of the Server and Invoke Wait to  
   // start the Server... 
   g_http = std::unique_ptr<SimpleServer>(new SimpleServer(addr)); 
   g_http->Open().wait(); 
   //---- Indicate the start and spit URI to the Console 
   ucout << utility::string_t(U("Listening for requests at: ")) <<  
                addr << std::endl; 

   return; 
} 

//////////////////////////////////////// 
// Simply Closes the Connection... Close returns  
// pplx::task<void> ...we need to Call wait to invoke the  
// operation... 
void ShutDown(){ 
   g_http->Close().wait(); 
   return; 
} 
/////////////////////////////// 
// EntryPoint function 
int wmain(int argc, wchar_t *argv[]) 
{ 
   utility::string_t port = U("34567"); 
   if (argc == 2){ port = argv[1];} 
   //--- Create the Server URI base address 
   utility::string_t address = U("http://localhost:"); 
   address.append(port); 
   StartServer(address); 
   std::cout << "Press ENTER to exit." << std::endl; 
   //--- Wait Indefenintely, Untill some one has  
   // pressed a key....and Shut the Server down 
   std::string line; 
   std::getline(std::cin, line); 
   ShutDown(); 
   return 0; 
} 

主函数通过StartServer函数实例化SimpleListener类的实例。然后,main函数在调用ShutDown函数之前等待按键。一旦我们启动了应用程序,我们可以使用CURL工具或POSTMAN来测试程序是否工作。

使用 CURL 和 POSTMAN 测试 HTTP 服务器

CURL是一个跨 Windows、GNU Linux、MacOS 和其他 POSIX 兼容系统的命令行工具。该工具有助于使用各种基于 TCP/IP 的应用协议传输数据。一些常见的支持的协议包括 HTTP、HTTPS、FTP、FTPS、SCP、SFTP、TFTP、DICT、TELNET 和 LDAP 等。

我们将使用CURL工具来测试我们编写的 HTTP 服务器。可以通过给定必要的命令行参数来调用命令行实用程序,以发送带有关联动词的 HTTP 请求。我们给出了调用GETPUT请求到我们编写的服务器的命令行参数:

    curl -X PUT http://localhost:34567/DBDEMO/ 
          -H "Content-Type: application/json" -d '{"SimpleContent":"Value"}'
    curl -X GET 
          -H "Content-Type: application/json"      http://localhost:34567/DBDEMO/

将上一个命令嵌入批处理文件或 shell 脚本中,具体取决于您的平台。控制台上的输出应该如下所示:

PUT Operation Succeeded
GET Operation Succeeded

同样,通过查阅CURL文档,我们也可以测试其他 HTTP 动词。

POSTMAN 是一个强大的 HTTP 客户端,用于测试基于 HTTP 的服务。它最初是由一位名叫 Abhinav Asthana 的印度开发人员作为一个副业项目开始的。它是一个在 Chrome 上广受欢迎的插件。今天,它是一个独立的平台,并且围绕这个应用程序成立了一家公司,Asthana 先生是 CEO。您可以下载 POSTMAN 工具来测试这些服务。由于下载 URL 可能会更改,请查阅您喜欢的搜索引擎以找到当前的下载 URL。(搜索“POSTMAN HTTP 客户端”)

libcurl 和 HTTP 客户端编程

我们已经了解了 CURL 实用程序,实际上是libcurl库的一个包装器。我们将使用 libcurl 库来访问我们在本章中编写的 REST 服务。为了让您熟悉 libcurl 库及其编程模型,我们将使用该库编写一个基本的 HTTP 客户端:该程序将 ping http://example.com

/////////////////////////////////// 
// A Simple Program to demonstrate  
// the usage of libcurl library 
// 
#include <stdio.h> 
#include <curl/curl.h> 
/////////////////////// 
// Entrypoint for the program 
//  
int main(void) 
{ 
  CURL *curl; 
  CURLcode res; 
  /////////////////////////// 
  // Initialize the library 
  // 
  curl = curl_easy_init(); 
  if(curl) { 
    //----------- Set the URL  
    curl_easy_setopt(curl, CURLOPT_URL,  
                     "http://example.com"); 
    ////////////////////////////////////////// 
    // To support URL re-direction, we need to configure 
    // the lib curl library with CURLOPT_FOLLOWLOCATION 
    //  
    curl_easy_setopt(curl,  
               CURLOPT_FOLLOWLOCATION, 1L); 

    /////////////////////////////////////////////////// 
    // Now that, we have setup the options necessary, 
    // invoke the operation to pull data  
    // 
    res = curl_easy_perform(curl); 

    if(res != CURLE_OK) { 
      //----- if error, print the error on console 
      cout << "curl_easy_perform() failed: " 
              << curl_easy_strerror(res) << endl; 
    } 
    curl_easy_cleanup(curl); 
  } 
  return 0; 
} 

上面的代码会 ping example.com URL 以检索其内容,并在控制台上显示它们。编程模型非常简单,库的文档真的很好。它是访问 TCP/IP 应用服务的最受欢迎的库之一。在下一节中,我们将在 libcurl 库的顶部使用一个响应式包装器。

Kirk Shoop 的 libCURL 包装库

RxCpp 库的主要实现者是 Kirk Shoop,他目前与微软有关。他编写了一个 Twitter 分析示例应用程序(github.com/kirkshoop/twitter),以演示响应式编程的各个方面。作为该倡议的一部分,他做的一件事是编写一个响应式包装器覆盖libcurl,以实现 HTTP 的GETPOST方法。本书的作者已经扩展了他的代码,以支持PUTDELETE方法。

查看本书源代码捆绑的RxCurl库:(列表太长,无法在此处包含)


///////////////////////////////// 
// A Simple program to pull HTTP content  
// using a Rx wrapper on top of the Libcurl 
// 
// 
#include <iostream> 
#include <stdio.h> 
#include <stdlib.h> 
#include <map> 
#include <chrono> 
using namespace std; 
using namespace std::chrono; 
//////////////////////// 
// include Curl Library and  
// Rxcpp library  
// 
#include <curl/curl.h> 
#include <rxcpp/rx.hpp> 
using namespace rxcpp; 
using namespace rxcpp::rxo; 
using namespace rxcpp::rxs; 
////////////////////////// 
// include the modified rxcurl library from  
// Kirk Shoop's Twitter Analysis app 
// 
#include "rxcurl.h" 
using namespace rxcurl; 
int main() { 
     ///////////////////////////////////// 
     // 
     // Create a factory object to create  
     // HTTP request.  The http_request structure 
     // is defined in rxcurl.h 
     string url = "http://example.com"; 
     auto factory = create_rxcurl(); 
     auto request  = factory.create(http_request{url, "GET",{}, {}}) | 
            rxo::map([](http_response r){ 
                return r.body.complete; 
            });

我们使用factory类创建 HTTP request对象来创建一个observablemap函数只是检索响应对象的主体。整个代码中最重要的结构是http_request结构,其定义如下:

 struct http_request{ 
       string url; 
       string method; 
       std::map<string, string> headers; 
       string body; 
     }; 

从上面的声明中,可以很明显地看出http_request结构的目的。成员是

  • url - 目标 URL

  • method - HTTP 动词

  • headers - HTTP 头

  • body - 请求的主体


     //////////////////////////////////////// 
     // make a blocking call to the url.. 
     observable<string>   response_message; 
     request.as_blocking().subscribe([&] (observable<string> s) { 
               response_message = s.sum(); 
     } ,[] () {}); 

request Observable 可以通过订阅on_next来使用 lambda 函数,该函数以observable<string>为参数,因为map函数返回observable<string>。在on_next函数的主体中,我们使用observable<string>::sum()约简器来聚合内容以生成一个字符串:

     /////////////////////////////// 
     // retrieve the html content form the site  
     string html; 
     response_message.as_blocking().subscribe( [&html] ( string temp ) {          
                   html = temp; 
     }, [&html] () { } ); 
     //------------ Print to the Console... 
     cout << html << endl; 
} 

response_message Observable 通过 lambda 进行订阅,该 lambda 以字符串作为参数。在on_next函数的主体中,我们简单地将包含 HTML 的字符串分配给html变量。最后,我们将内容显示在控制台上。请查看rxcurl.h头文件,以了解库的工作原理。

JSON 和 HTTP 协议

The payload format for invoking web services were once monopolized by the XML format. The SOAP-based services mostly support the XML format. With the advent of REST-based services, developers use JavaScript Object Notation (JSON) as the payload format. 用于调用 Web 服务的有效载荷格式曾经被 XML 格式垄断。基于 SOAP 的服务大多支持 XML 格式。随着基于 REST 的服务的出现,开发人员使用JavaScript 对象表示JSON)作为有效载荷格式。

The following table shows a comparison between XML and corresponding JSON object: 以下表格显示了 XML 和相应的 JSON 对象之间的比较:

XML JSON

| <person>    <firstName>John</firstName>

   <lastName>Smith</lastName> <姓>史密斯</姓>

   <age>25</age> <年龄>25</年龄>

   <address> <地址>

     <streetAddress>21 2nd <街道地址>21 2nd

     Street</streetAddress> 街道

     <city>New York</city> <城市>纽约</城市>

     <state>NY</state> <州>纽约</州>

     <postalCode>10021</postalCode> 10021

   </address> </地址>

   <phoneNumber> <电话号码>

     <type>home</type> <类型>家庭</类型>

     <number>212 555-1234</number> <号码>212 555-1234</号码>

   </phoneNumber> </电话号码>

   <phoneNumber> <电话号码>

     <type>fax</type> <类型>传真</类型>

     <number>646 555-4567</number> <号码>646 555-4567</号码>

   </phoneNumber> </电话号码>

   <gender> <性别>

<type>male</type>男性

   </gender> </性别>

</person> | {    "firstName": "John", </人> | { `   "firstName": "John",

   "lastName": "Smith", "姓":"史密斯"

   "age": 25, "年龄":25,

   "address": { "地址":{

     "streetAddress": "21 2nd "街道地址":"21 2nd

Street",` 街道",

     "city": "New York", "城市":"纽约"

     "state": "NY", "州":"纽约"

     "postalCode": "10021" "邮政编码":"10021"

 }, },

 "phoneNumber": [ "电话号码":[

   { {

     "type": "home", "类型":"家庭"

     "number": "212 555-1234" "号码":"212 555-1234"

   }, },

   { {

     "type": "fax", "类型":"传真"

     "number": "646 555-4567" "号码":"646 555-4567"

   } }

   ], ],

  "gender": { "性别":{

     "type": "male" "类型":"男性"

   } }

} |

The JSON format contains following data types: JSON 格式包含以下数据类型:

  • String

  • Number

  • Object (JSON object) 对象(JSON 对象)

  • Array 数组

  • Boolean

Let us inspect a JSON object, to see how preceding data types are represented in the real world. 让我们检查一个 JSON 对象,看看前面的数据类型是如何在现实世界中表示的。

{ 
 { "name":"John" }, 
 { "age":35 }, 
 { 
   "spouse":{ "name":"Joanna",  
              "age":30,  
              "city":"New York" } 
 }, 
 { 
    "siblings":["Bob", "Bill", "Peter" ] 
 }, 
 { "employed":true } 
} 

The mappings are: 映射如下:

  • name: The value is string type ("john") 名字:值是字符串类型("john")

  • age: The value is number (35) 年龄:值是数字(35)

  • spouse: This is a JSON object 配偶:这是一个 JSON 对象

  • siblings: This is an array 兄弟姐妹:这是一个数组

  • employed: This is a Boolean (true) 就业:这是一个布尔值(true

Now that we have a better understanding of JSON and its core aspects, we will write a simple program that demonstrates usage of the JSON API, available as part of the C++ REST SDK: 现在我们对 JSON 及其核心方面有了更好的理解,我们将编写一个简单的程序,演示作为 C++ REST SDK 的一部分可用的 JSON API 的用法:

/////////////////////////////////// 
// A Console Application to demonstrate JSON API 
// available as part of the C++ SDK 
using namespace std; 
using namespace web; 
using namespace utility; 
using namespace http; 
using namespace web::http::experimental::listener; 
/////////////////////////////////////// 
// Define a Simple struct to demonstrate the  
// Working of JSON API 
struct EMPLOYEE_INFO{ 
   utility::string_t name; 
   int age; 
   double salary; 
   ///////////////////////////////// 
   // Convert a JSON Object to a C++ Struct 
   // 
   static EMPLOYEE_INFO JSonToObject(const web::json::object & object){ 
         EMPLOYEE_INFO result; 
         result.name = object.at(U("name")).as_string(); 
         result.age = object.at(U("age")).as_integer(); 
         result.salary = object.at(U("salary")).as_double(); 
         return result; 
   }

The JSonToObject static method converts a JSON object to the EMPLOYEE_INFO structure. json::at returns a reference to json::value based on the string that we used to index it. The resultant json::value reference is used to invoke the type-specific conversion methods, such as as_string, as_integer, and as_double: JSonToObject静态方法将 JSON 对象转换为EMPLOYEE_INFO结构。json::at根据我们用于索引的字符串返回对json::value的引用。结果json::value引用用于调用特定类型的转换方法,例如as_stringas_integeras_double

   /////////////////////////////////////////// 
   // Convert a C++ struct to a Json Value 
   // 
   web::json::value ObjectToJson() const{ 
         web::json::value result = web::json::value::object(); 
         result[U("name")] = web::json::value::string(name); 
         result[U("age")] = web::json::value::number(age); 
         result[U("salary")] = web::json::value::number(salary); 
         return result; 
   } 
}; 

ObjectToJson is an instance method of EMPLOYEE_STRUCT, which helps to produce JSON output from the instance data. Here, we use conversion methods to transfer instance data to json::value. Next, we will focus on how we can create json::object from scratch: ObjectToJsonEMPLOYEE_STRUCT的一个实例方法,它帮助从实例数据生成 JSON 输出。在这里,我们使用转换方法将实例数据转移到json::value。接下来,我们将专注于如何从头开始创建json::object

///////////////////////////////////////// 
// Create a Json Object group and Embed and  
// Array in it... 
void MakeAndShowJSONObject(){ 
   // Create a JSON object (the group) 
   json::value group; 
   group[L"Title"] = json::value::string(U("Native Developers")); 
   group[L"Subtitle"] =  
              json::value::string(U("C++ devekioers on Windws/GNU LINUX")); 
   group[L"Description"] =  
               json::value::string(U("A Short Description here ")); 
   // Create a JSON object (the item) 
   json::value item; 
   item[L"Name"] = json::value::string(U("Praseed Pai")); 
   item[L"Skill"] = json::value::string(U("C++ / java ")); 
   // Create a JSON object (the item) 
   json::value item2; 
   item2[L"Name"] = json::value::string(U("Peter Abraham")); 
   item2[L"Skill"] = json::value::string(U("C++ / C# ")); 
   // Create the items array 
   json::value items; 
   items[0] = item; 
   items[1] = item2; 
   // Assign the items array as the value for the Resources key 
   group[L"Resources"] = items; 
   // Write the current JSON value to wide char string stream 
   utility::stringstream_t stream; 
   group.serialize(stream); 
   // Display the string stream 
   std::wcout << stream.str(); 
} 

int wmain(int argc, wchar_t *argv[]) 
{ 
   EMPLOYEE_INFO dm; 
   dm.name = L"Sabhir Bhatia"; 
   dm.age = 50; 
   dm.salary = 10000; 
   wcout << dm.ObjectToJson().serialize() << endl; 

We create an EMPLOYEE_INFO struct and assign some values into the fields. We then invoke EMPLOYEE_INFO::ObjectToJSon() to create a json::value object. We call the serialize() method to generate the JSON textual output: 我们创建一个EMPLOYEE_INFO结构并将一些值分配到字段中。然后我们调用EMPLOYEE_INFO::ObjectToJSon()来创建一个json::value对象。我们调用serialize()方法来生成 JSON 文本输出:

      utility::string_t port =  
           U("{"Name": "Alex Stepanov","Age": 55,"salary":20000}");; 
      web::json::value json_par; 
      json::value obj = json::value::parse(port); 
      wcout << obj.serialize() << endl; 

The previous code snippets demonstrate the use to parse textual strings to produce json::value objects. We invoked the serialize method to print the JSON string to the console: 前面的代码片段演示了解析文本字符串以生成json::value对象的用法。我们调用serialize方法将 JSON 字符串打印到控制台:

   MakeAndShowJSONObject(); 
   getchar(); 
   return 0; 
} 

The C++ REST SDK-based REST server 基于 C++ REST SDK 的 REST 服务器

In this section, we have leveraged code from Marius Bancila's excellent article about the C++ REST SDK. In fact, the key/value database code is borrowed from his implementation. The authors are thankful to him for the excellent article, available at mariusbancila.ro/blog/2017/11/19/revisited-full-fledged-client-server-example-with-c-rest-sdk-2-10/. 在本节中,我们利用了 Marius Bancila 关于 C++ REST SDK 的优秀文章中的代码。实际上,键/值数据库代码是从他的实现中借用的。作者对他提供的优秀文章表示感谢,该文章可在mariusbancila.ro/blog/2017/11/19/revisited-full-fledged-client-server-example-with-c-rest-sdk-2-10/上找到。

Let's write a micro-service application that puts everything together we have learned so far in the context of Microsoft C++ REST SDK. We will consume REST services by leveraging the RxCurl library written by Kirk Shoop, as part of his Twitter analysis application. We have added support to the DELETE and PUT verbs, as the original implementation contained only support for GET and POST verbs. The REST service implemented here supports the following verbs: 让我们编写一个微服务应用程序,将我们迄今为止在 Microsoft C++ REST SDK 的上下文中学到的一切整合起来。我们将通过利用 Kirk Shoop 编写的 RxCurl 库来消费 REST 服务,作为他的 Twitter 分析应用程序的一部分。我们已经添加了对 DELETE 和 PUT 动词的支持,因为原始实现只包含对 GET 和 POST 动词的支持。这里实现的 REST 服务支持以下动词:

  • GET: Lists all the key/value pairs in the storage. The response will be in the { key:value,key:value} format. 获取:列出存储中的所有键/值对。响应将以{ key:value,key:value}格式呈现。

  • POST:检索与一组键对应的值。请求应该是[key1,...,keyn]格式。响应将以{key:value,key:value....}格式返回。

  • PUT:将一组键/值对插入存储中。请求应该是{key:value,key:value}格式。

  • DELETE:从存储中删除一组键及其相应的值。请求应该是[key,key]格式。

让我们来看一下代码:

// MicroServiceController.cpp : Defines the entry point for the console application. 
#include <cpprest/http_client.h> 
#include <cpprest/filestream.h> 
//------------- Omitted some standard C++ headers for terse code listing
#include "cpprest/json.h" 
#include "cpprest/http_listener.h" 
#include "cpprest/uri.h" 
#include "cpprest/asyncrt_utils.h" 

#ifdef _WIN32 
#ifndef NOMINMAX 
#define NOMINMAX 
#endif 
#include <Windows.h> 
#else 
# include <sys/time.h> 
#endif 

using namespace std; 
using namespace web; 
using namespace utility; 
using namespace http; 
using namespace web::http::experimental::listener; 

////////////////////////////// 
// 
// The following code dumps a json to the Console... 
void  DisplayJSON(json::value const & jvalue){ 
   wcout << jvalue.serialize() << endl; 
} 

/////////////////////////////////////////////// 
// A Workhorse routine to perform an action on the request data type 
// takes a lambda as parameter along with request type 
// The Lambda should contain the action logic...whether it is 
// GET, PUT,POST or DELETE 
// 
void RequeatWorker( http_request& request, 
function<void(json::value const &, json::value &)> handler) { 
   auto result = json::value::object(); 
   request.extract_json().then(&result,
        &handler    {      
        try{ 
            auto const & jvalue = task.get(); 
            if (!jvalue.is_null()) 
                  handler(jvalue, result); // invoke the lambda 
         } 
         catch (http_exception const & e) { 
               //----------- do exception processsing  
               wcout << L"Exception ->" << e.what() << endl; 
         } 
   }).wait(); 
    request.reply(status_codes::OK, result); 
} 

RequestWorker是一个全局函数,它以http_request作为参数,以及一个具有特定签名的 lambda。lambda 接受两个参数:

  • json::value类型的输入 JSON 对象(一个常量参数)

  • 包含来自 lambda 调用结果的输出 JSON 对象

JSON 有效载荷被提取并传递给then继续。一旦数据被检索,处理程序 lambda 被调用。由于结果是通过引用传递的,我们可以使用生成 HTTP 响应的结果 JSON。现在,我们将创建一个简单的键/值数据存储来模拟一个工业强度的键/值数据库:

///////////////////////////////////////// 
// A Mock data base Engine which Simulates a key/value DB 
// In Real life, one should use an Industrial strength DB 
// 
class HttpKeyValueDBEngine { 
   ////////////////////////////////// 
   //----------- Map , which we save,retrieve,  update and  
   //----------- delete data  
   map<utility::string_t, utility::string_t> storage; 
public: 
   HttpKeyValueDBEngine() { 
         storage[L"Praseed"]= L"45"; 
         storage[L"Peter"] = L"28"; 
         storage[L"Andrei"] = L"50"; 
   } 

为了便于实现,键/值对存储在 STL 映射中。在构造函数中,我们使用一些记录初始化 STL 映射。我们可以使用PUTPOST来添加额外的记录,使用DELETE来删除记录:让我们剖析处理 GET 请求的函数的源代码。

   //////////////////////////////////////////////////////// 
   // GET - ?Just Iterates through the Map and Stores 
   // the data in a JSon Object. IT is emitted to the  
   // Response Stream 
   void GET_HANDLER(http_request& request) { 
         auto resp_obj = json::value::object(); 
         for (auto const & p : storage) 
             resp_obj[p.first] = json::value::string(p.second); 
         request.reply(status_codes::OK, resp_obj); 
   } 

当 HTTP 监听器遇到请求有效负载的一部分时,GET_HANLDER方法将被调用。创建json::value::object后,我们将存储映射的内容填充到其中。生成的 JSON 对象将返回给 HTTP 客户端:让我们看一下 POST 处理程序的源代码。

   ////////////////////////////////////////////////// 
   // POST - Retrieves a Set of Values from the DB 
   // The PAyload should be in ["Key1" , "Key2"...,"Keyn"] 
   // format 
   void POST_HANDLER(http_request& request) {       
       RequeatWorker(request, &{ 
         //---------- Write to the Console for Diagnostics 
         DisplayJSON(jvalue); 
             for (auto const & e : jvalue.as_array()){ 
               if (e.is_string()){ 
                     auto key = e.as_string(); 
                     auto pos = storage.find(key); 
                    if (pos == storage.end()){ 
                        //--- Indicate to the Client that Key is not found 
                         result[key] = json::value::string(L"notfound"); 
                     } 
                     else { 
                     //------------- store the key value pair in the result 
                     //------------- json. The result will be send back to  
                     //------------- the client 
                     result[pos->first] = json::value::string(pos->second); 
                     } 
               } 
         } 
         });      
   } 

POST_HANDLER期望在请求主体中有一个 JSON 值数组,并循环遍历每个元素以检索相应键提供的数据。结果对象存储返回的值。如果一些键不在键/值 DB 中,将返回一个字面字符串("notond")来指示未找到该值:

   //////////////////////////////////////////////////////// 
   // PUT - Updates Data, If new KEy is found  
   //       Otherwise, Inserts it 
   // REST Payload should be in  
   //      { Key1..Value1,...,Keyn,Valuen}  format 
   // 
   // 
   void PUT_HANDLER(http_request& request) { 
         RequeatWorker( request, 
               &{ 
               DisplayJSON(jvalue); 
               for (auto const & e : jvalue.as_object()){ 
                     if (e.second.is_string()){ 
                           auto key = e.first; 
                           auto value = e.second.as_string(); 
                           if (storage.find(key) == storage.end())
                           { 
                                 //--- Indicate to the client that we have 
                                 //--- created a new record 
                                 result[key] = 
                                   json::value::string(L"<put>"); 
                           } 
                           else { 
                                 //--- Indicate to the client that we have 
                                 //--- updated a new record 
                                 result[key] = 
                                    json::value::string(L"<updated>"); 
                           } 
                           storage[key] = value; 
                     } 
               } 
         });    
   } 

PUT_HANDLER期望以 JSON 格式的键/值对列表。对键的集合进行迭代以在存储中查找。如果键已经存在于存储中,则更新值,否则将键/值插入存储中。返回一个 JSON 对象(结果)以指示对每个键执行的操作(是插入还是更新)。

   /////////////////////////////////////////////////// 
   // DEL - Deletes a Set of Records 
   // REST PayLoad should be in 
   //      [ Key1,....,Keyn] format 
   // 
   void DEL_HANDLER(http_request& request) 
   { 
      RequeatWorker( request,
         & 
         { 
               //--------------- We aggregate all keys into this set 
               //--------------- and delete in one go 
               set<utility::string_t> keys; 
               for (auto const & e : jvalue.as_array()){ 
                     if (e.is_string()){ 
                           auto key = e.as_string(); 
                           auto pos = storage.find(key); 
                           if (pos == storage.end()){ 
                                 result[key] = 
                                    json::value::string(L"<failed>"); 
                           } 
                           else { 
                                 result[key] = 
                                    json::value::string(L"<deleted>"); 
                                 //---------- Insert in to the delete list 
                                 keys.insert(key); 
                           } 
                     } 
               } 
               //---------------Erase all 
               for (auto const & key : keys) 
                     storage.erase(key); 
         }); 
   } 
};

DEL_HANDLER期望一个键数组作为输入,并循环遍历数组以检索数据。如果键已经存在于存储中,键将被添加到删除列表(-一个 STL 集合)。一个 JSON 对象(结果)将被填充以指示对每个键执行的操作。结果对象将返回给客户端:

/////////////////////////////////////////////// 
// 
// Instantiates the Global instance of key/value DB 
HttpKeyValueDBEngine g_dbengine; 

现在我们有了一个功能模拟的键/值数据库engine,我们将使用数据库的功能作为 REST 服务端点与GETPOSTPUTDELETE命令对外部世界提供服务。HTTP 处理程序只是将调用委托给HttpValueDBEngine实例。这段代码与我们为SimpleServer类编写的代码非常相似:

class RestDbServiceServer{ 
public: 
   RestDbServiceServer(utility::string_t url); 
   pplx::task<void> Open() { return m_listener.open(); } 
   pplx::task<void> Close() { return m_listener.close(); } 
private: 
   void HandleGet(http_request message); 
   void HandlePut(http_request message); 
   void HandlePost(http_request message); 
   void HandleDelete(http_request message); 
   http_listener m_listener; 
}; 
RestDbServiceServer::RestDbServiceServer(utility::string_t url) : m_listener(url) 
{ 
    m_listener.support(methods::GET,  
       std::bind(&RestDbServiceServer::HandleGet, this, std::placeholders::_1)); 
    m_listener.support(methods::PUT,  
       std::bind(&RestDbServiceServer::HandlePut, this, std::placeholders::_1)); 
    m_listener.support(methods::POST,  
       std::bind(&RestDbServiceServer::HandlePost, this, std::placeholders::_1)); 
    m_listener.support(methods::DEL,  
        std::bind(&RestDbServiceServer::HandleDelete, 
        this,std::placeholders::_1)); 
}

上面的代码将 HTTP 动词绑定到相应的处理程序。处理程序的主体在性质上是相似的,因为处理程序只是将 HTTP 调用委托给键/值引擎:

void RestDbServiceServer::HandleGet(http_request message) 
{g_dbengine.GET_HANDLER(message);}; 
void RestDbServiceServer::HandlePost(http_request message) 
{g_dbengine.POST_HANDLER(message);}; 
void RestDbServiceServer::HandleDelete(http_request message) 
{g_dbengine.DEL_HANDLER(message);} 
void RestDbServiceServer::HandlePut(http_request message) 
{g_dbengine.PUT_HANDLER(message);}; 
//---------------- Create an instance of the Server  
std::unique_ptr<RestDbServiceServer> g_http; 
void StartServer(const string_t& address) 
{ 
   uri_builder uri(address); 
   uri.append_path(U("DBDEMO/")); 
   auto addr = uri.to_uri().to_string(); 
   g_http = std::unique_ptr<RestDbServiceServer>(new RestDbServiceServer(addr)); 
   g_http->Open().wait(); 
   ucout << utility::string_t(U("Listening for requests at: ")) << 
               addr << std::endl; 
   return; 
} 
void ShutDown(){ 
      g_http->Close().wait(); 
      return; 
} 
/////////////////////////////// 
// The EntryPoint function 
int wmain(int argc, wchar_t *argv[]){ 
   utility::string_t port = U("34567"); 
   if (argc == 2){port = argv[1];} 
   utility::string_t address = U("http://localhost:"); 
   address.append(port); 
   StartServer(address); 
   std::cout << "Press ENTER to exit." << std::endl; 
   std::string line; 
   std::getline(std::cin, line); 
   ShutDown(); 
   return 0; 
}

HTTP 控制器的代码与我们在本章前面编写的SimpleServer没有区别。我们在这里提供列表是为了完整起见。通过这样,我们学会了如何使用 C++ REST SDK 向外部世界公开 REST 服务端点。

我们已经讨论了如何公开 REST 端点以及如何为各种 HTTP 动词编写处理程序。在微服务架构风格中,我们将独立部署许多 REST 端点。将粗粒度服务拆分为微服务的过程是高度依赖于上下文的艺术。微服务有时通过聚合服务向外部世界公开。聚合服务将向多个端点发出请求,并在响应其客户端之前聚合来自不同端点的结果。聚合服务是编写用于访问 REST 微服务的反应式客户端逻辑的候选者。由于网络调用是异步的,反应式编程模型在这里是自然的。

使用 RxCurl 库调用 REST 服务

由 Kirk Shoop 编写的RxCurl库最初支持GETPOST动词。Twitter 分析应用程序只支持这两个。本书的作者已经为PUTDELETE动词添加了支持。您可以参考rxcurl.h的源代码,查看为支持这些额外动词所做的必要更改,在 Github 存储库中:让我们看看如何使用修改后的库来调用上面我们编写的 REST 服务器。

#include <iostream> 
#include <stdio.h> 
#include <iostream> 
#include <stdio.h> 
#include <stdlib.h> 
#include <map> 
#include <chrono> 
using namespace std; 
using namespace std::chrono; 
//////////////////////// 
// include Curl Library and  
// Rxcpp library  
// 
#include <curl/curl.h> 
#include <rxcpp/rx.hpp> 
using namespace rxcpp; 
using namespace rxcpp::rxo; 
using namespace rxcpp::rxs; 
////////////////////////// 
// include the modified rxcurl library from  
// Kirk Shoop's Twitter Analysis app 
// 
#include "rxcurl.h" 
using namespace rxcurl; 
rxcurl::rxcurl factory; 

使用factory对象,我们可以通过调用create方法创建请求对象。create方法期望:

  • URL 端点

  • HTTP 方法

  • HTTP 头

  • HTTP 请求的主体:

string HttpCall( string url ,  
               string method, 
               std::map<string,string> headers, 
               string  body  ) {         

     auto request  = factory.create(http_request{url,method,
                     headers,body}) | 
                     rxo::map([](http_response r){ 
                          return r.body.complete; 
                     });      

上述代码通过组合创建 HTTP 请求和从http_response映射到 HTTP 主体的函数来创建request对象。有一个选项可以返回数据块。我们这里没有使用它,因为我们只期望响应的数据量很小。

     //////////////////////////////////////// 
     // make a blocking call to the url.. 
     observable<string>   response_message; 
     request.as_blocking().subscribe([&] (observable<string> s) { 
               response_message = s.sum(); 
     } ,[] () {printf("");});

上述代码对我们之前创建的observable进行了阻塞调用。subscribe方法的主体的on_next函数将内容连接起来形成另一个 Observable。在现实生活中,我们也可以以异步方式进行这种调用。这需要更多的编程工作。此外,代码清单不适合可用的页面预算:


     /////////////////////////////// 
     // 
     // retrieve the html content form the site  
     string html; 
     response_message.as_blocking().subscribe( [&html] ( string temp ) {          
                   html = temp; 
     }, [] () { printf(""); } ); 
     return html; 
} 
///////////////////////// 
// The EntryPoint... 
// 
int main() { 

     /////////////////////////////////// 
     // set the url and create the rxcurl object 
     string url = "http://localhost:34567/DBDEMO/"; 
     factory = create_rxcurl(); 
     ///////////////////////////////// 
     // default header values 
     std::map<string,string> headers; 
     headers["Content-Type"] = "application/json"; 
     headers["Cache-Control"] = "no-cache"; 

     //------- invoke GET to retrieve the contents 
     string html = HttpCall( url,"GET",headers, "" ); 
     cout << html << endl; 

     //------- Retrieve values for the following  
     string body = string("["Praseed"]rn"); 
     html = HttpCall( url,"POST", headers,body); 
     cout << html << endl; 
     //--------- Add new Values using PUT 
     body = string("rn{"Praveen": "29","Rajesh" :"41"}rn"); 
     html = HttpCall( url,"PUT", headers,body); 
     cout << html << endl; 
     //-------- See whether values has been added 
     html = HttpCall( url,"GET",headers, "" ); 
     cout << "-------------------------current database state" << endl; 
     cout << html << endl; 
     //--------------- DELETE a particular record 
     body = string("["Praseed"]rn"); 
     html = HttpCall( url,"DELETE", headers,body); 
     cout << "Delleted..." << html << endl; 
     html = HttpCall( url,"GET",headers, "" ); 
     cout << "-------------------------current database state" << endl; 
     cout << html << endl; 
} 

main方法演示了我们如何调用我们创建的HttpCall函数。提供了代码,以展示如何利用 RxCurl 库。我们可以使用该库异步发出多个请求,并等待它们的完成。读者可以调整代码以支持这样的功能。

关于反应式微服务架构的一点说明

我们已经学会了如何使用 C++ REST SDK 编写微服务控制器。也许我们可以说,我们刚刚实现的服务器可以是一个微服务实例。在现实生活中的微服务架构场景中,将在不同的盒子(Docker 容器或虚拟机)中托管多个服务,并且微服务控制器将访问这些独立部署的服务以满足客户端的需求。微服务控制器将从不同服务中聚合输出,以作为响应发送给客户端。微服务应用程序的基本架构如下图所示:

在上图中,REST(HTTP)客户端向微服务控制器发出 HTTP 调用,该控制器包装了http_listener对象。控制器调用三个微服务来检索数据,并将结果数据组装或合并以向 REST 客户端提供响应。端点可以使用 Docker 等技术在容器中或在不同的容器中部署。

根据 Martin Fowler:

“微服务架构”这个术语在过去几年中出现,用来描述设计软件应用程序的一种特定方式,即独立部署的服务套件。虽然对这种架构风格没有明确的定义,但围绕业务能力的组织、自动化部署、端点的智能和语言和数据的分散控制等方面有一些共同的特征。”

微服务架构的主题本身就是一个独立的课题,值得一本专门的书来探讨。我们在这里所涵盖的是如何利用 C++编程语言来以这种风格编写 Web 应用程序。这里给出的描述旨在指引读者找到正确的信息。响应式编程模型适合从不同的服务端点聚合信息并统一呈现给客户端。服务的聚合是关键问题,读者应进一步研究。

当我们谈论微服务架构时,我们需要了解以下主题:

  • 细粒度服务

  • 多语言持久性

  • 独立部署

  • 服务编排和服务编舞

  • 响应式 Web 服务调用

我们将在以下章节中详细讨论它们。

细粒度服务

传统的 SOA 和基于 REST 的服务大多是粗粒度的服务,并且是以减少网络往返为核心关注点而编写的。为了减少网络往返,开发人员经常创建了复合(多个数据元素打包在一起)的有效负载格式。因此,一个端点或 URI 被用于处理多个关注点,并违反了关注点分离的原则。微服务架构期望服务执行单一职责,并且有效负载格式是为此量身定制的。这样,服务变得更加细粒度。

多语言持久性

多语言持久性是一个术语,用来表示在持久化数据时使用多种存储技术。这个术语来自于“多语言编程”的术语,其中编程语言的选择取决于上下文。在多语言编程的情况下,我们混合使用不同的编程语言。本书的作者们曾遇到过使用 Java 编写应用服务器代码、Scala 进行流处理、C++处理存储相关问题、C#编写 Web 层,当然还有 TypeScript/JavaScript 用于客户端编程的系统。在多语言持久性的情况下,我们可以选择使用关系型数据库、键值存储、文档数据库、图数据库、列数据库,甚至时间序列数据库。

电子商务门户是多语言持久性可以真正派上用场的经典例子。这样的平台将处理许多类型的数据(例如,购物车、库存和已完成的订单)。我们可以选择使用关系型数据库(记录交易)、键值数据库(缓存和查找)、文档数据库(存储日志)等,而不是试图将所有这些数据存储在一个数据库中。在这里,“为您的关注点和上下文选择正确的持久性模型”是主要的座右铭。

独立部署

微服务架构和传统 SOA 之间最大的区别在于部署领域。随着容器技术的发展,我们可以非常容易地独立和隔离地部署服务。DevOps 运动在推广服务和应用程序的独立部署模型方面起到了很大的帮助。我们现在可以自动化虚拟机和相关容器的配置过程,包括 CPU、内存、存储、附加磁盘、虚拟网络、防火墙、负载均衡、自动扩展等,将其附加到 AWS、Azure 或 Google Cloud 等云服务的部署策略中。策略可以帮助您以自动化脚本的方式自动部署微服务。

在使用微服务架构风格开发应用程序时,容器技术的概念会一再出现。一个相关的运动,称为 DevOps,被引入到讨论的范围之内。在独立部署的情况下,涵盖 DevOps 和容器化(以及集群管理)超出了本书的范围。您可以搜索 Docker、Kubernetes 和“基础设施即代码”,以更深入地了解这些技术。

服务编排和编舞

让我们从服务编排开始。您可以通过固定逻辑将多个服务组合在一起。这个逻辑在一个地方描述。但我们可能部署多个相同服务的实例,以确保可用性。一个聚合器服务将独立调用这些服务并为下游系统聚合数据。另一方面,在服务编舞中,决策逻辑是分布式的,没有集中的点。对服务的调用将在数据到达下游系统之前触发多次服务之间的调用。服务编舞比实现编排需要更多的工作。您可以通过使用您喜欢的搜索引擎在网络上阅读更多关于服务编排和编舞的信息。

响应式网络服务调用

Web 请求的处理很好地映射到了响应式编程模型。在具有响应式 UI 的应用程序中,我们通常只需向服务器发出一次调用。在服务器上运行的聚合器服务将异步生成一系列请求。生成的响应被聚合以向 UI 层提供响应。修改后的 RxCurl 可以作为一种机制,用于在使用 C++ 编程语言的项目中调用多个服务。

总结

在本章中,我们介绍了如何使用 Rx 编程模型来使用 C++ 编写响应式微服务。作为这个过程的一部分,我们向您介绍了微软的 C++ REST SDK 及其编程模型。C++ REST SDK 遵循一种基于任务继续样式的异步编程模型,用于编写客户端代码。为了编写 REST 客户端,我们利用了 Kirk Shoop 的 RxCurl 库,并对其进行了一些修改以支持 PUTDELETE 动词。最后,我们以一种响应式的方式编写了一个 REST 服务器并对其进行了消费。在下一章中,我们将学习如何使用 RxCpp 库中可用的构造来处理错误和异常。

第十三章:高级数据流和错误处理

在本书中,我们已经涵盖了现代 C++技术和RxCpp库的许多内容。我们从使用 C++进行反应式编程的先决条件开始。前六章主要是关于先决条件和适应一般函数式反应式编程特征以及RxCpp库的特性。我们在宽泛的意义上使用了函数式反应式编程这个术语——我们正在利用函数式编程技术来编写反应式程序。一些纯粹主义者与我们意见不同。他们不认为 Rx 系列库是函数式反应式编程的完整实现。程序员必须经历的最大转变是接受声明式编程范式的心态转变。

传统上,我们设计复杂的数据结构,并在这些数据结构上编写算法来编写我们的程序。这适用于操作存在于空间中的数据的程序。当时间成为一个因素时,异步性是一个自然的结果。在反应式编程中,我们将复杂的数据结构简化为数据流,并在数据流中放置操作符,然后根据通知执行某些操作。我们已经看到了这如何简化了使用 C++编程语言的 GUI 程序、Web 程序和控制台应用程序的编程。

在我们的例子中,我们故意省略了反应式程序中的异常处理(和错误处理)逻辑。这是为了专注于核心反应式元素及其交互。现在我们已经涵盖了所有必要的内容,甚至更多,我们将专注于反应式程序中的异常处理。在讨论错误和异常处理之前,我们将介绍反应式系统的特征。

在本章中,我们将涵盖以下主题:

  • 反应式系统特征的简要回顾

  • RxCpp—错误处理操作符

  • 调度和错误处理

  • 基于事件的数据流处理—一些例子

反应式系统特征的简要回顾

我们现在生活在一个需要更高可扩展性和快速响应的世界。反应式编程的概念是为了满足高可用性、可扩展性和快速响应的需求而产生的。根据反应式宣言(www.reactivemanifesto.org/),反应式系统具有以下特点:

  • 响应式:系统在规定时间内完成分配的任务的能力。响应性也意味着问题能够被快速检测并有效处理。关键点在于系统的一致行为。一致性有助于用户对系统建立信心。

  • 弹性:在行为变化的情况下,系统抵御失败的能力就是弹性。它与响应性相关,因为一致性也保证了错误处理。弹性是通过隔离和容错组件来实现的,以保护系统免受故障影响。

  • 弹性:弹性是系统根据工作负载的变化自动重新分配所需资源的能力。反过来,每个时间点上使用的资源尽可能地匹配需求。反应式系统通过提供相关的实时性能指标来实现弹性。

  • 消息驱动:通过异步消息传递机制,反应式系统实现了系统的隔离和松耦合。通过使用消息队列,反应式系统可以实现不同模块和命令的相互依赖处理。通过消息驱动架构的非阻塞通信,接收者只在活动时消耗资源:

来自响应式宣言的以下图表展示了响应式系统的所有支柱是如何相互关联的:

通过在其所有构建层次上应用这些原则,响应式系统变得可组合。

本章的重点将是通过解释高级流和错误处理来解释响应式系统的弹性特性。

RxCpp 错误和异常处理操作符

在现实世界的场景中,没有一个系统是完美的。正如我们在前一节中讨论的那样,弹性是响应式系统的特质之一。系统如何处理错误和异常决定了该系统的未来。早期检测和无缝处理错误使系统一致和响应。与命令式编程方法相比,响应式编程模型帮助用户单独处理错误,当系统检测到错误或抛出异常时。

在本节中,我们将看看如何使用RxCpp库处理异常和错误。有各种RxCpp操作符可用于对来自 Observables 的on_error通知做出反应。例如,我们可能会:

  • 通过优雅地退出序列来处理错误

  • 忽略错误并切换到备用 Observable 以继续序列

  • 忽略错误并发出默认值

  • 忽略错误并立即尝试重新启动失败的 Observable

  • 忽略错误并在一段退避间隔后尝试重新启动失败的 Observable

异常处理是可能的,因为observer<>包含三种方法:

  • on_next

  • on_completed

  • on_error

on_error方法用于处理异常发生时,或者由observable<>或组合链中的任何操作符抛出异常。到目前为止,示例忽略了系统的错误处理方面。观察者方法的原型如下:

  • void observer::on_next(T);

  • void observer::on_error(std::exception_ptr);

  • void observer::on_completed();

在发生错误时执行操作

当发生错误时,我们需要以一种优雅的方式处理它。到目前为止,在本书讨论的RxCpp程序中,程序只处理了subscribe方法中的on_nexton_completed情况。subscribe函数还有一个方法,它也可以接受一个 Lambda 函数来处理on_error情况。让我们看一个简单的例子来理解如何在subscribe函数中使用错误处理程序:

//------ OnError1 
#include "rxcpp/rx.hpp" 

int main() 
{ 
    //------ Creating Observable with an error appended 
    //------ A canned example to demonstrate error 
    auto values = rxcpp::observable<>::range(1, 3). 
                  concat(rxcpp::observable<>:: 
                  error<int>(std::runtime_error("Error from producer!"))); 

    values. 
        subscribe( 
         //--------------- on_next 
            [](int v) { printf("OnNext: %dn", v); }, 
            //---------------- on_error 
            [](std::exception_ptr ep) { 
                 printf("OnError: %sn", rxcpp::util::what(ep).c_str()); 
            }, 
            //---------------- on_completed 
            []() { printf("OnCompletedn"); }); 
} 

通过第二个 Lambda,传递给subscribe函数的函数调用在发生错误时调用所需的操作。代码的输出将如下所示:

OnNext: 1 
OnNext: 2 
OnNext: 3 
OnError: Error from producer!

在上面的代码中,错误被附加到 Observable 流中,以启动订阅者端的异常/错误处理讨论。让我们看看异常如何通过 Observable 流传播到订阅者级别:

//------- OnError2.cpp 
#include "rxcpp/rx.hpp" 

int main() { 
    //------- Create a subject instance  
    //------  and retrieve subscriber abd Observable handle  
    rxcpp::rxsub::subject<int> sub; 
    auto subscriber = sub.get_subscriber(); 
    auto observable = sub.get_observable(); 

    //--------------------------- Subscribe! 
    observable.subscribe( 
        [](int v) { printf("OnNext: %dn", v); }, 
        [](std::exception_ptr ep) { 
            printf("OnError: %sn", rxcpp::util::what(ep).c_str()); 
        }, 
        []() { printf("OnCompletedn"); } 
    );

上面的代码创建了一个subject<T>类的实例,我们在第十章《在 RxCpp 中创建自定义操作符》中介绍过。我们订阅subject<T>的 Observable 部分。我们还检索订阅者句柄以将值或异常发射到流中:

    for (int i = 1; i <= 10; ++i) { 
        if (i > 5) { 
            try { 
                std::string().at(1); 
            } 
            catch (std::out_of_range& ex) { 
                //------------ Emit exception. 
                subscriber.on_error(std::make_exception_ptr(ex));
                break;
            } 
        } 
        subscriber.on_next(i * 10); 
    } 
    subscriber.on_completed(); 
} 

on_next()函数向订阅者发出新值,并且该函数将被多次调用。一旦在流上调用了on_completed()on_error()on_next()函数将不会被调用。on_completed()函数通知订阅者 Observable 已经完成发送推送式通知。如果 Observable 已经调用了on_error()函数,它将不会调用此函数。最后,on_error()函数通知订阅者 Observable 遇到了错误条件,如果 Observable 调用了此函数,它将不会在此后调用on_next()on_completed()

当发生错误时恢复

错误发生会打破标准响应式流的序列流程。RxCpp库还提供了在发生错误时调用操作的机制。然而,有时用户希望使用默认选项恢复序列;这就是on_error_resume_next()的作用:

//------- OnError3.cpp 
#include "rxcpp/rx.hpp" 

int main() 
{ 
    //------- Create an Observable with appended error 
    auto values = rxcpp::observable<>::range(1, 3). 
        concat(rxcpp::observable<>:: 
        error<int>(std::runtime_error("Error from producer!    "))). 
        //------- Resuming with another Stream 
        on_error_resume_next([](std::exception_ptr ep) { 
            printf("Resuming after: %sn", rxcpp::util::what(ep).c_str()); 
            return rxcpp::observable<>::range(4,6); 
        }); 

    values. 
        subscribe( 
            [](int v) {printf("OnNext: %dn", v); }, 
            [](std::exception_ptr ep) { 
                printf("OnError: %sn", rxcpp::util::what(ep).c_str()); }, 
            []() {printf("OnCompletedn"); }); 
} 

如果流中出现错误,可观察操作符on_error_resume_next()将被执行。在这段代码中,从作为参数给定的 Lambda 返回一个新的流,以使用这个新的流恢复序列。这样,可以通过继续有意义的序列来防止错误传播。上一个程序的输出将如下所示:

OnNext: 1 
OnNext: 2 
OnNext: 3 
Resuming after: Error from producer! 
OnNext: 4 
OnNext: 5 
OnNext: 6 
OnCompleted 

除了使用另一个序列进行恢复,还可以使用默认的单个项目进行恢复。在前面的例子中,用以下行替换对on_error_resume_next()操作符的调用:

        //------- Resuming with a default single value 
        on_error_resume_next([](std::exception_ptr ep) { 
            printf("Resuming after: %sn", rxcpp::util::what(ep).c_str()); 
            return rxcpp::observable<>::just(-1); 
        });

替换代码后,输出将如下所示:

OnNext: 1 
OnNext: 2 
OnNext: 3 
Resuming after: Error from source 
OnNext: -1 
OnCompleted

让我们看一下描述on_error_resume_next()操作符的弹珠图:

简而言之,on_error_resume_next()函数在遇到特定可观察对象的错误时返回一个可观察实例。流切换到新的可观察对象并恢复执行。

on_error_resume_next()操作符在许多地方非常方便,用户需要继续传播错误。例如,在流的创建和订阅之间,流可能会经历不同的转换和减少。此外,正如第九章中所解释的那样,使用 Qt/C++进行响应式 GUI 编程,用户定义的操作符可以通过组合现有的RxCpp操作符来构建。在这种情况下,打算在每个聚合和转换阶段使用on_error_resume_next()操作符来转换异常/错误直到订阅阶段。与此操作符发出的默认值或序列类似,错误本身也可以被重新传输,以恢复错误的流动直到subscribe()操作符的错误处理程序:

auto processed_strm = Source_observable. 
map([](const string& s) { 
return do_string_operation(s); 
      }). 
// Translating exception from the source 
on_error_resume_next([](std::exception_ptr){ 
return rxcpp::sources::error<string>(runtime_error(rxcpp::util::what(ep).c_str())); 
      });

上面的代码片段解释了如何使用on_error_resume_next()操作符来转换错误。

发生错误时重试

在许多情况下,正常的顺序可能会因为生产者端的暂时故障而被打破。在这种情况下,值得考虑的是有一个选项,可以等待直到生产者端的异常被修复,以继续正常的执行流程。RxCpp为用户提供了一个非常类似的选项,当发生错误时重试。重试选项最适合当您预期序列会遇到可预测的问题时。

重试操作符对来自源 Observable 的on_error通知做出响应,通过重新订阅源 Observable,而不是将该调用传递给其观察者。这给了源另一个机会来完成其序列而不出现错误。重试总是将on_next通知传递给其观察者,即使是从以错误终止的序列中;这可能会导致重复的发射。下面的弹珠图将进一步解释这一点:

以下是一个使用retry()操作符的示例:

//------- Retry1.cpp 
#include "rxcpp/rx.hpp" 

int main() 
{ 
    auto values = rxcpp::observable<>::range(1, 3). 
        concat(rxcpp::observable<>:: 
        error<int>(std::runtime_error("Error from producer!"))). 
        retry(). 
        take(5); 

    //----- Subscription 
    values. 
        subscribe( 
            [](int v) {printf("OnNext: %dn", v); }, 
            []() {printf("OnCompletedn"); }); 
} 

在这个例子中,由于错误被添加到流中使用concat()操作符,我们使用take()操作符来避免无限等待。由于在错误情况下重试操作符的无限等待,订阅者可以省略订阅中使用的错误处理程序。

这段代码的输出将是:

OnNext: 1 
OnNext: 2 
OnNext: 3 
OnNext: 1 
OnNext: 2 
OnCompleted 

大多数情况下,最好对错误情况使用固定数量的重试。这可以通过retry()的另一个重载来实现,该重载接受重试次数:

//------- Retry2.cpp 
#include "rxcpp/rx.hpp" 

int main() 
{ 
    auto source = rxcpp::observable<>::range(1, 3). 
        concat(rxcpp::observable<>:: 
        error<int>(std::runtime_error("Error from producer!"))). 
        retry(2); 

    source. 
        subscribe( 
            [](int v) {printf("OnNext: %dn", v); }, 
            [](std::exception_ptr ep) { 
                printf("OnError: %sn", rxcpp::util::what(ep).c_str()); }, 
            []() {printf("OnCompletedn"); }); 
}

代码的输出将如下所示:

OnNext: 1 
OnNext: 2 
OnNext: 3 
OnNext: 1 
OnNext: 2 
OnNext: 3 
OnError: Error from producer!

使用 finally()操作符进行清理

到目前为止,在本章中,我们已经看到RxCpp中的源序列可以在抛出异常后正常终止。当我们使用外部资源时,或者在程序的其他部分分配了一些资源需要释放时,finally() Operator 是非常有用的。正如我们所知,在 C++中已经编写了数百万行代码来构建各种系统,很可能我们需要在使用传统外部依赖时处理资源管理。这就是RxCppfinally()派上用场的地方:

//------- Finally.cpp 
#include "rxcpp/rx.hpp" 

int main() 
{ 
    auto values = rxcpp::observable<>::range(1, 3). 
        concat(rxcpp::observable<>:: 
        error<int>(std::runtime_error("Error from producer!"))). 
        //----- Final action 
        finally([]() { printf("The final actionn"); 
    }); 

    values. 
        subscribe( 
            [](int v) {printf("OnNext: %dn", v); }, 
            [](std::exception_ptr ep) { 
                  printf("OnError: %sn", rxcpp::util::what(ep).c_str()); }, 
            []() {printf("OnCompletedn"); }); 
}

finally() Operator 在新创建的 Observables 的末尾添加了一个新的动作。前一个程序的输出如下所示:

OnNext: 1 
OnNext: 2 
OnNext: 3 
OnError: Error from producer! 
The final action

可以看到,在先前的输出中,如果源生成错误,最终动作仍然会被调用。

如果我们从源 Observable 中移除连接的错误,程序的输出将如下所示:

OnNext: 1 
OnNext: 2 
OnNext: 3 
OnCompleted 
The final action 

调度程序和错误处理

我们已经在第八章“RxCpp - 关键元素”中涵盖了调度的主题。RxCpp中的调度程序排队并传递排队的值,使用提供的协调。协调可以是当前执行线程、RxCpp运行循环、RxCpp事件循环或一个新线程。通过使用RxCpp的 Operators,如observe_on()subscribe_on(),可以实现调度程序操作。这些 Operators 接受所选择的协调作为参数。默认情况下,RxCpp库是单线程的,因此它执行调度程序操作。用户必须显式选择执行发生的线程:

//----------OnError_ObserveOn1.cpp  
#include "rxcpp/rx.hpp" 
#include <iostream> 
#include <thread> 

int main() { 
    //---------------- Generate a range of values 
    //---------------- Apply Square function 
    auto values = rxcpp::observable<>::range(1, 4). 
        transform([](int v) { return v * v; }). 
        concat(rxcpp::observable<>:: 
        error<int>(std::runtime_error("Error from producer!"))); 

    //------------- Emit the current thread details 
    std::cout << "Main Thread id => " 
        << std::this_thread::get_id() 
        << std::endl;

我们使用 range Operator 创建了一个 Observable Stream,并连接了一个错误,以演示在RxCpp中如何使用调度程序进行基本错误处理:

    //---------- observe_on another thread.... 
    //---------- make it blocking too 
    values.observe_on(rxcpp::synchronize_new_thread()).as_blocking(). 
        subscribe([](int v) { 
             std::cout << "Observable Thread id => " 
            << std::this_thread::get_id() 
            << " " << v << std::endl; }, 
            [](std::exception_ptr ep) { 
            printf("OnError: %sn", rxcpp::util::what(ep).c_str()); }, 
            []() { std::cout << "OnCompleted" << std::endl; }); 

    //------------------ Print the main thread details 
    std::cout << "Main Thread id => " 
        << std::this_thread::get_id() 
        << std::endl; 
} 

使用observe_on() Operator,Observable Stream 被订阅到一个新线程中作为其协调。与本章讨论的先前例子类似,错误处理程序是通过subscribe()函数提供的。代码的输出可能如下所示:

Main Thread id => 5776 
Observable Thread id => 12184 1 
Observable Thread id => 12184 4 
Observable Thread id => 12184 9 
Observable Thread id => 12184 16 
OnError: Error from producer! 
Main Thread id => 5776 

现在,让我们看另一个例子,从同一源中有两个订阅者。这些订阅者应该在两个不同的线程中被通知:

//------- OnError_ObserveOn2.cpp 
#include "rxcpp/rx.hpp" 
#include <mutex> 

std::mutex printMutex; 

int main() { 

    rxcpp::rxsub::subject<int> sub; 
    auto subscriber = sub.get_subscriber(); 
    auto observable1 = sub.get_observable(); 
    auto observable2 = sub.get_observable();

创建了一个subject实例来向源 Stream 添加数据;从 subject 实例中,创建了一个订阅者和两个 Observables,以在两个不同的线程中进行调度:

    auto onNext = [](int v) { 
        std::lock_guard<std::mutex> lock(printMutex); 
        std::cout << "Observable Thread id => " 
            << std::this_thread::get_id() 
            << "t OnNext: " << v << std::endl; 
    }; 

    auto onError = [](std::exception_ptr ep) { 
        std::lock_guard<std::mutex> lock(printMutex); 
        std::cout << "Observable Thread id => " 
            << std::this_thread::get_id() 
            << "t OnError: " 
            << rxcpp::util::what(ep).c_str() << std::endl; 
    }; 

为了与subscribe方法一起使用,声明了两个 Lambda 函数,并在使用std::ostream Operator 时应用了互斥同步,以获得有组织的输出。在std::ostream周围放置一个互斥锁将避免在写入 Stream 时发生线程切换导致的交错输出:

    //------------- Schedule it in another thread 
    observable1\. 
        observe_on(rxcpp::synchronize_new_thread()). 
        subscribe(onNext, onError, 
            []() {printf("OnCompletedn"); }); 

    //------------- Schedule it in yet another thread 
    observable2\. 
        observe_on(rxcpp::synchronize_event_loop()). 
        subscribe(onNext, onError, 
            []() {printf("OnCompletedn"); });

从源 Stream 中检索到了两个 Observables,并安排它们从不同的线程中进行观察。对于observable1函数对象,通过在observe_on() Operator 中传递rxcpp::synchronize_new_thread()作为参数来指定一个单独的 C++线程作为协调者。对于observable2对象,通过将rxcpp::observe_on_event_loop()传递给observe_on()来指定一个事件循环作为协调者:

    //------------- Adding new values into the source Stream 
    //------------- Adding error into Stream when exception occurs 
    for (int i = 1; i <= 10; ++i) { 
        if (i > 5) { 
            try { 
                std::string().at(1); 
            } 
            catch (...) { 
                std::exception_ptr eptr = std::current_exception(); 
                subscriber.on_error(eptr);
                break;
            } 
        } 
        subscriber.on_next(i * 10); 
    } 
    subscriber.on_completed(); 

    //----------- Wait for Two Seconds 
    rxcpp::observable<>::timer(std::chrono::milliseconds(2000)). 
        subscribe(& {}); 
}     

最后,通过使用一个 subject 实例将值添加到 Observable Stream 中,并显式地向 Stream 传递一个异常,以了解调度程序和错误处理程序的行为。这段代码的输出将如下所示:

Observable Thread id => 2644    OnNext: 10 
Observable Thread id => 2304    OnNext: 10 
Observable Thread id => 2644    OnNext: 20 
Observable Thread id => 2304    OnNext: 20 
Observable Thread id => 2644    OnNext: 30 
Observable Thread id => 2304    OnNext: 30 
Observable Thread id => 2644    OnNext: 40 
Observable Thread id => 2304    OnNext: 40 
Observable Thread id => 2304    OnNext: 50 
Observable Thread id => 2304    OnError: invalid string position 
Observable Thread id => 2644    OnNext: 50 
Observable Thread id => 2644    OnError: invalid string position

这个例子演示了数据是如何通过两个订阅了共同源的独立 Observables 进行传播的。源中生成的错误被两个 Observables 在相应的subscribe函数中接收和处理。现在,让我们看一个例子,演示了如何使用subscribe_on() Operator 在调度中进行错误处理:

//---------- SubscribeOn.cpp 
#include "rxcpp/rx.hpp" 
#include <thread> 
#include <mutex> 

//------ A global mutex for output sync. 
std::mutex printMutex; 

int main() { 
    //-------- Creating Observable Streams 
    auto values1 = rxcpp::observable<>::range(1, 4). 
        transform([](int v) { return v * v; }); 

    auto values2 = rxcpp::observable<>::range(5, 9). 
                   transform([](int v) { return v * v; }). 
                   concat(rxcpp::observable<>: 
:error<int>(std::runtime_error("Error from source"))); 

使用rxcpp::observable<>::range()操作符创建了两个随机整数 Observable Streams,并且一个流与一个错误连接,以解释在计划序列中的错误处理:

    //-------- Schedule it in another thread 
    auto s1 = values1.subscribe_on(rxcpp::observe_on_event_loop()); 

    //-------- Schedule it in Yet another thread 
    auto s2 = values2.subscribe_on(rxcpp::synchronize_new_thread()); 

使用subscribe_on()操作符将 Observable Streams 排队到不同的线程中。第一个流使用事件循环作为其协调线程进行调度,第二个流在另一个 C++线程上进行调度:

    auto onNext = [](int v) { 
        std::lock_guard<std::mutex> lock(printMutex); 
        std::cout << "Observable Thread id => " 
                  << std::this_thread::get_id() 
                  << "tOnNext: " << v << std::endl; 
    }; 

    auto onError = [](std::exception_ptr ep) { 
        std::lock_guard<std::mutex> lock(printMutex); 
        std::cout << "Observable Thread id => " 
                  << std::this_thread::get_id() 
                  << "tOnError: " 
                  << rxcpp::util::what(ep).c_str() << std::endl; 
    }; 

前面的 Lambda 函数被定义为传递给subscribe方法的on_nexton_error函数的参数。这些 Lambda 函数受到互斥锁的保护,以同步对std::ostream操作符的调用:

    //-------- Subscribing the merged sequence 
    s1.merge(s2).as_blocking().subscribe( 
        onNext, onError, 
        []() { std::cout << "OnCompleted" << std::endl; }); 

    //-------- Print the main thread details 
    std::cout << "Main Thread id => " 
        << std::this_thread::get_id() 
        << std::endl; 
} 

代码的输出如下:

Observable Thread id => 12380   OnNext: 1 
Observable Thread id => 9076    OnNext: 25 
Observable Thread id => 12380   OnNext: 4 
Observable Thread id => 9076    OnNext: 36 
Observable Thread id => 12380   OnNext: 9 
Observable Thread id => 12380   OnNext: 16 
Observable Thread id => 9076    OnNext: 49 
Observable Thread id => 9076    OnNext: 64 
Observable Thread id => 9076    OnNext: 81 
Observable Thread id => 9076    OnError: Error from producer! 
Main Thread id => 10692

基于事件的流处理-一些示例

在我们结束本章之前,让我们讨论一些示例,使用RxCpp库处理基于事件的系统。在本节中,我们将讨论两个示例,以了解RxCpp库在满足现实场景中的有效性。我们将讨论一个示例,演示如何使用RxCpp库进行流数据聚合和应用程序事件处理。

基于流数据的聚合

在本节中,流项是一个用户定义的类型,用于表示员工,并且代码旨在根据员工的角色和薪水对输入流进行分组:

#include "rxcpp/rx.hpp" 

namespace Rx { 
    using namespace rxcpp; 
    using namespace rxcpp::sources; 
    using namespace rxcpp::subjects; 
    using namespace rxcpp::util; 
} 

using namespace std; 

struct Employee { 
    string name; 
    string role; 
    int salary; 
}; 

在代码中包含了所需的库和命名空间,并声明了表示Employee的数据结构。Employee类型是一个简单的结构,具有数据项,如namerolesalary。我们将薪水字段视为整数:

int main() 
{ 
    Rx::subject<Employee> employees; 

    // Group Salaries by Role 
    auto role_sal = employees.
    get_observable(). 
        group_by( 
            [](Employee& e) { return e.role; }, 
            [](Employee& e) { return e.salary; }); 

main()函数中,使用Employee类型创建了一个主题,以创建一个热 Observable。基于角色的分组被执行,并且工资属性被提取出来形成结果分组 Observable。RxCpp操作符group_by()返回一个发出grouped_observables的 Observable,每个grouped_observables对应于源 Observable 的唯一键/值对:

    // Combine min max and average reductions based on salary. 
    auto result = role_sal. 
        map([](Rx::grouped_observable<string, int> group) { 
            return group. 
                count(). 
                zip(= { 
                return make_tuple(group.get_key(), count, min, max, average); 
        }, 
        group.min(), 
        group.max(), 
        group.map([](int salary) -> double { return salary; }).average()); 
    }). 
    merge();

在这里,结果 Observable 结合了基于角色的 Observable,并且通过附加最低工资、最高工资和每个角色的平均工资来执行基于工资的减少。zip()内部的 Lambda 将在所有参数都有值时被调用。在这种情况下,当特定组完成时,与该组对应的流中的所有值都将被减少为单个元组。因此,Lambda 仅在每个角色中调用一次,每次迭代的最终值。在这里,应用于group的 map 返回了类型为observable<tuple<string, int, int, int, double>>的 Observable,而merge()操作符返回了类型为tuple<string, int, int, int, double>的 Observable。合并是为了防止数据丢失,因为分组的 Observable 是热的,如果不立即订阅,数据将丢失:

    // Display the aggregated result 
    result. 
        subscribe(Rx::apply_to( 
        [](string role, int count, int min, int max, double avg) { 
          std::cout << role.c_str() << ":tCount = " << count <<  
           ", Salary Range = [" << min  
            << "-" << max << "], Average Salary = " << avg << endl; 
        })); 

    // Supplying input data 
    Rx::observable<>::from( 
        Employee{ "Jon", "Engineer", 60000 }, 
        Employee{ "Tyrion", "Manager", 120000 }, 
        Employee{ "Arya", "Engineer", 92000 }, 
        Employee{ "Sansa", "Manager", 150000 }, 
        Employee{ "Cersei", "Accountant", 76000 }, 
        Employee{ "Jaime", "Engineer", 52000 }). 
        subscribe(employees.get_subscriber()); 

    return 0; 
} 

然后订阅了结果 Observable,以显示输入数据的聚合结果。数据项从使用Employees类型创建的employees主题中提供给订阅者。在前面的代码中,源可以是任何东西,例如从网络或另一个线程检索的数据。由于此处创建的 Observable 是热 Observable,因此基于提供的最新数据执行聚合。

此代码的输出如下:

Accountant:    Count = 1, Salary Range = [76000-76000], Average Salary = 76000 
Engineer:      Count = 3, Salary Range = [52000-92000], Average Salary = 68000 
Manager:       Count = 2, Salary Range = [120000-150000], Average Salary = 135000 

应用程序事件处理示例

以下示例是一个命令行程序,其中的事件表示用户界面应用程序的原始操作。我们将使用RxCpp来处理这些事件的流程。这是为了简洁起见在代码清单中完成的:

//--------- UI_EventsApp.cpp 
#include <rxcpp/rx.hpp> 
#include <cassert> 
#include <cctype> 
#include <clocale> 

namespace Rx { 
    using namespace rxcpp; 
    using namespace rxcpp::sources; 
    using namespace rxcpp::operators; 
    using namespace rxcpp::util; 
    using namespace rxcpp::subjects; 
} 

using namespace Rx; 
using namespace std::chrono; 

// Application events 
enum class AppEvent { 
    Active, 
    Inactive, 
    Data, 
    Close, 
    Finish, 
    Other 
};

程序中将使用的库和命名空间在这里包含(声明)。此外,声明了一个枚举AppEvent,用于表示可以从通用系统发出的一些基本事件状态:

int main() 
{ 
    //------------------- 
    // A or a - Active 
    // I or i - Inactive 
    // D or d - Data 
    // C or c - Close 
    // F or f - Finish 
    // default - Other 
    auto events = Rx::observable<>::create<AppEvent>( 
        [](Rx::subscriber<AppEvent> dest) { 
        std::cout << "Enter Application Events:\n"; 
        for (;;) { 
            int key = std::cin.get(); 
            AppEvent current_event = AppEvent::Other; 

            switch (std::tolower(key)) { 
            case 'a': current_event = AppEvent::Active; break; 
            case 'i': current_event = AppEvent::Inactive; break; 
            case 'd': current_event = AppEvent::Data; break; 
            case 'c': current_event = AppEvent::Close; break; 
            case 'f': current_event = AppEvent::Finish; break; 
            default:  current_event = AppEvent::Other; 
            } 

            if (current_event == AppEvent::Finish) { 
                dest.on_completed(); 
                break; 
            } 
            else { 
                dest.on_next(current_event); 
            } 
        } 
    }). 
    on_error_resume_next([](std::exception_ptr ep) { 
        return rxcpp::observable<>::just(AppEvent::Finish); 
    }). 
    publish();

在前面的代码中,我们通过将一些键盘输入映射到已定义的事件类型,创建了AppEvent类型的 Observable 流。create函数中 Lambda 内部的无限循环代表 GUI 应用程序中的event_loop/message_loop。为了将冷 Observable 转换为热 Observable,并使得与源的连接独立于后续订阅,使用了publish()操作符。它还有助于将流中的最新值发送给新的订阅者:

    // Observable containing application active events
    auto appActive = events. 
        filter([](AppEvent const& event) { 
        return event == AppEvent::Active; 
    }); 

    // Observable containing application inactive events
    auto appInactive = events. 
        filter([](AppEvent const& event) { 
        return event == AppEvent::Inactive; 
    }); 

    // Observable containing application data events 
    auto appData = events. 
        filter([](AppEvent const& event) { 
        return event == AppEvent::Data; 
    }); 

    // Observable containing application close events
    auto appClose = events. 
        filter([](AppEvent const& event) { 
        return event == AppEvent::Close; 
    });

定义了一些经过筛选的 Observables,以处理响应式系统的用例。appActive是一个 Observable,其中包含从源 Observable 中筛选出的AppEvent::Active事件,每当它在源流中可用时。同样,appInactive Observable 包含AppEvent::Inactive事件,appData Observable 包含AppEvent::Data事件,而appClose Observable 从源 Observable 中提取AppEvent::Close事件:

    auto dataFromApp = appActive. 
        map(= { 
        std::cout << "**Application Active**n" << std::flush; 
        return appData. // Return all the data events 
            take_until(appInactive). // Stop recieving data when the application goes inactive 
            finally([]() { 
            std::cout << "**Application Inactive**n"; 
        }); 
    }). 
        switch_on_next(). // only listen to most recent data 
        take_until(appClose). // stop everything when Finish/Close event recieved 
        finally([]() { 
        std::cout << "**Application Close/Finish**n"; 
    }); 

    dataFromApp. 
        subscribe([](AppEvent const& event) { 
        std::cout << "**Application Data**n" << std::flush; 
    }); 

    events.connect(); 

    return 0; 
} 

只有在接收到AppEvent::Active事件时,程序才会开始接受来自事件 Observable 的数据流。然后,应用程序将接受数据,直到接收到AppEvent::Inactive。只有在发出下一个AppEvent::Active时,事件流才会恢复。当发出AppEvent::CloseAppEvent::Finish时,应用程序将以优雅的方式退出,类似于 GUI 应用程序中的CloseApply事件/消息。

摘要

在本章中,我们讨论了RxCpp中的错误处理,以及一些高级构造和操作符,以处理RxCpp库中的流。我们讨论了响应式系统的基本原则,并在讨论错误处理机制时更加强调了响应式系统的一个关键支柱,即弹性。我们讨论了错误处理程序(on_error)等功能,需要与订阅一起使用。此外,我们还讨论了RxCpp操作符,如on_error_resume_next()retry()finally(),讨论了在出现错误时如何继续流,如何等待流的生产者纠正错误并继续序列,以及如何执行适用于成功和错误路径的常见操作。最后,我们讨论了两个示例程序,以更多地了解流处理。这些程序说明了RxCpp库如何用于处理 UX 事件流(使用控制台程序模拟)和聚合数据流。

posted @ 2024-05-04 22:44  绝不原创的飞龙  阅读(62)  评论(0编辑  收藏  举报