Project RC

一种标准阿西的设计与实现。

在 main 函数运行之前修改全局变量

创建于
分类:Dev
标签:C++奇技淫巧

动机

写 C++ 时往往有这样一种需求,即在 main 函数运行之前就对全局变量进行修改,比如向全局的容器对象中填充内容。考虑下面这个需求:作为一个第三方库,我希望向用户提供一种事件机制,在发生某些事件时,触发用户编写的某些代码,但我不希望用户自己编写 main 函数并在里面设置事件回调,而是提供一个更强制的接口。在这个需求下,设计接口如下:

// 用户代码 app.cpp

#include <somelib.hpp>

ON_EVENT(handler1) {
    // 事件处理程序 1
}

ON_EVENT(handler2) {
    // 事件处理程序 2
}

此时,用户的两个处理程序,就需要在静态区初始化时被注册到我所提供的第三方库的某个全局容器对象中。

有问题的实现

最开始通过下面的代码来实现上述机制:

// somelib.hpp

extern std::vector<std::function<void()>> event_callbacks;

#define ON_EVENT_A(Name)                                \
    static void __handler_##Name();                     \
    static bool __dummy_val_##Name = [] {               \
        event_callbacks.emplace_back(__handler_##Name); \
        return true;                                    \
    }();                                                \
    static void __handler_##Name()
// somelib.cpp

std::vector<std::function<void()>> event_callbacks;

这种实现乍看起来没有什么问题,并且在很多情况下真的可以正常工作。然而,这种表面上的正常是建立在 app.cpp 中的全局变量 __dummy_val_##Name 迟于 somelib.cpp 中的 event_callbacks 初始化的情况下的,也就是说,一旦 __dummy_val_##Nameevent_callbacks 之前初始化,那么调用 event_callbacks.emplace_back 的那个 lambda 表达式将会访问一个无意义的 event_callbacks

那么这种错误情况到底有没有可能出现呢,花费好几个小时调试程序的事实已经告诉我有可能。后来查阅资料发现,在同一个翻译单元,全局变量的初始化顺序是按定义顺序来的,但在不同的翻译单元之间,初始化顺序是未定义的。上面的实现中,两个变量正是处于不同的翻译单元。

正确的实现

同一篇资料里也提出了解决这个问题的方法,那就是利用函数内静态变量在第一次使用时初始化的特性。只需要一点点修改即可得到正确的实现:

// somelib.hpp

inline auto &event_callbacks() {
    static std::vector<std::function<void()>> _event_callbacks;
    return _event_callbacks;
}

#define ON_EVENT_A(Name)                                  \
    static void __handler_##Name();                       \
    static bool __dummy_val_##Name = [] {                 \
        event_callbacks().emplace_back(__handler_##Name); \
        return true;                                      \
    }();                                                  \
    static void __handler_##Name()

同时不再需要 somelib.cpp 了。

这里 event_callbacks() 函数调用保证了当它返回引用时,_event_callbacks 静态变量必然已经被初始化,因此 emplace_back 必然有效。

为什么可以是 inline

这是另一个话题了。上面的 event_callbacks 被标记为 inline 函数,这意味着它将在可能的情况下被原地展开。那么这会不会导致不同的用户 cpp 文件中拿到的 _event_callbacks 引用不一样呢?答案是不会,因为 inline 有一个特性就是,编译器会自动将多次导入的 inline 函数合并为一个,因此其中的静态变量也只有一个。

参考资料

在 main 函数运行之前修改全局变量

关于 Multimethods

创建于
分类:Dev
标签:编程语言多态多分派MultimethodsC++

最近学到了一个编程语言中叫作 multimethods 的概念,在这里做个笔记。

为什么

要解释这个概念,首先提出两个程序设计中会遇到的问题。

1. Expression Problem

案例

考虑 C++ 中这样一种情况:

struct Event {
    virtual ~Event() {}
};

struct MessageEvent : Event {
    virtual ~MessageEvent() {}

    std::string message;
    int user_id;
};

struct NoticeEvent : Event {
    virtual ~NoticeEvent() {}

    std::string notice;
};

如果此时需要给这些 Event 类添加一个序列化对象为 JSON 的成员函数,从面向对象的角度自然会想到给 Event 加一个虚函数,然后在子类中实现:

struct Event {
    virtual ~Event() {}
    virtual std::string to_json() const = 0;
};

struct MessageEvent : Event {
    virtual ~MessageEvent() {}

    std::string message;
    int user_id;

    std::string to_json() const override {
        return "json for message event";
    }
};

这种方法的问题在于,这些 Event 类很可能是第三方库中的,而它们对应的实现文件可能已经编译成了链接库,要修改类定义是很困难的。

要在不改变类定义的情况下给类添加新功能,这就是 expression problem

横切关注点(Cross-cutting Concern)

Expression problem 是横切关注点问题的一部分,以上面为例,to_json 是一种非常普遍的操作,不仅是 Event 类,其它各种类都可能需要此操作。类似的还有 to_stringtime_it 等,它们都是一种与很多模块都相关的横切面。

2. 多分派(Multiple Dispatch)

在 OOP 中,最常用的操作是通过形如 obj.operate() 的方式调用对象的方法(C++ 中的成员函数)。C++ 会隐式地给 operate 函数增加一个 this 参数,指示调用的方法所属的对象。这种操作使用一个参数来确定要调用的函数,因此称为单分派(single dispatch)

如果要使用两个或多个参数来确定要调用的函数,也就是多分派,该怎么办呢?一种方案是函数重载(overload),例如:

struct Shape {
    virtual ~Shape() {}
    // ...
};

struct Circle : Shape {
    virtual ~Circle() {}
    // ...
};

struct Rect : Shape {
    virtual ~Rect() {}
    // ...
};

bool intersect(const Circle &a, const Circle &b);
bool intersect(const Rect &a, const Rect &b);
bool intersect(const Circle &a, const Rect &b);

但问题是,重载函数的选择是在编译期进行的,但实际编程中,常常会把各类型的对象都通过 Shape & 来引用,此时必须在运行期动态确定要调用的函数,函数重载就不可行了。

Open Multimethods

Open multimethods 可同时解决上面的两个问题,这里之所以是 open,是因为 method(方法)通常指类内定义的函数,这里 open 意思是方法定义在类外。

YOMM2 库提供的矩阵类为例可以比较容易理解 open multimethods 的思路:

// 不可修改的库代码部分:

// 矩阵基类
struct Matrix {
    virtual ~Matrix() {}
    // ...
};

// 稠密矩阵
struct DenseMatrix : Matrix { /* ... */ };
// 对角矩阵
struct DiagonalMatrix : Matrix { /* ... */ };

// 可修改的应用程序代码部分:

#include <yorel/yomm2/cute.hpp>

using yorel::yomm2::virtual_;

register_class(Matrix);
register_class(DenseMatrix, Matrix);
register_class(DiagonalMatrix, Matrix);

declare_method(string, to_json, (virtual_<const Matrix &>));

define_method(string, to_json, (const DenseMatrix &m)) {
    return "json for dense matrix...";
}

define_method(string, to_json, (const DiagonalMatrix &m)) {
    return "json for diagonal matrix...";
}

int main() {
    yorel::yomm2::update_methods();

    shared_ptr<const Matrix> a = make_shared<DenseMatrix>();
    shared_ptr<const Matrix> b = make_shared<DiagonalMatrix>();

    cout << to_json(*a) << "\n"; // json for dense matrix
    cout << to_json(*b) << "\n"; // json for diagonal matrix

    return 0;
}

可以看到,open multimethods 的定义和函数重载相似,但 ab 都是 Matrix 的指针,当调用 to_json 时,程序能够正确地在运行期根据 ab 的实际类型来选择函数。同时,不需要将 to_json 写到 Matrix 类的内部,减少了侵入和重新编译的成本。

除此之外,YOMM2 还用一个矩阵乘法的例子演示了多分派的实现:

declare_method(
    shared_ptr<const Matrix>,
    times,
    (virtual_<shared_ptr<const Matrix>>, virtual_<shared_ptr<const Matrix>>));

// 任意 Matrix * Matrix -> DenseMatrix
define_method(
    shared_ptr<const Matrix>,
    times,
    (shared_ptr<const Matrix> a, shared_ptr<const Matrix> b)) {
    return make_shared<DenseMatrix>();
}

// DiagonalMatrix * DiagonalMatrix -> DiagonalMatrix
define_method(
    shared_ptr<const Matrix>,
    times,
    (shared_ptr<const DiagonalMatrix> a, shared_ptr<const DiagonalMatrix> b)) {
    return make_shared<DiagonalMatrix>();
}

程序将在运行时通过 abtypeid 来选择最 specific 的函数来调用。

参考资料

关于 Multimethods

你好,2020

创建于
分类:Misc
标签:年度总结

终于有一个闲下来的下午,可以总结一下过去这忙碌却又无为、充实却又单调的一年,对全新的 2020 年做一些展望了。

2019

2019 年有一个开心的开始,和女票一起跨年,之后去上海松江旅游,在松江还和 CQHTTP 交流群的几个朋友们面了基。回学校之后就开始张罗着给 18 级的新生做 Python 和 QQ 机器人主题的培训。

农历新年也是为数不多的一个心情很好的年,年三十贴了春联,晚上和群友们玩得很 high,第二天初一,第一次一个人开车,和同学去看了《流浪地球》。

寒假结束,2 月底,正式开始每天去图书馆复习考研了。经历了中科院计算所和清华计算机之间的纠结,最后在 6 月决定考上海交大软件工程,直接目标就是 IPADS 实验室。

年初还搞了两次开源软件协会小聚,第一次还行,之后似乎没什么人想来了,有点失望吧。

3 月意外地收到了腾讯面试邀请,感觉聊得还不错,不过因为已经决定考研,就没有继续面下去。

2 月底到 12 月底的 10 个月,300 天,就是充实又单调的考研复习了,间歇性地觉得自己可以冲清华,又间歇性地觉得自己好菜。努力了 300 天,大部分精力花在了数学上,最后在考场上数学还是炸了,卷子写到中途整个脑子已经一片空白了,好在其它几门发挥正常,现在还不知道能不能过复试线……不论结果如何,都要感谢家人的支持、女朋友的陪伴、朋友们的鼓励。

考完研之后,在种种不满中还是被安排到了昆山进行校企合作培养,几天下来似乎也适应了这里的节奏。

这一年,经历了无数次情绪的高涨和低落,经历了许多人情冷暖。

考研复习的 300 天,觉得自己很努力,可是看到朋友们都学了很多新技术、做了很多新项目,会很难受,如果最后真的没考上,这时间可就相当于浪费掉了……

但这一年也是有收获的,这是从小到大第一次可以把一个计划、一个决定贯彻执行一整年,似乎渐渐明白了如何控制自己的执行力和管理时间,也变得对生活更加有动力了。

2020

2020 年最重要的一件事,就是不论有没有考上研,都要不断突破自己的舒适圈,用考研这段时间积累的自我管理能力去真正地学习一些新东西、做一些以前做到一半就放弃的事情。希望自己可以成功啊!

你好,2020

我的 2018 年

创建于
分类:Misc
标签:年度总结

2018 年的最后一天是时候总结一下这一年都干了什么了~

一年的计划回顾

今年刚开始的时候打算学的东西基本上全都了,比如:

  • 网球
  • 滑板
  • 概率论
  • 计算机网络
  • 编译原理
  • 日语

年初的时候算是学了些计算机组成原理和体系结构相关的东西,然而并不深入……其它的时间基本上都花在水项目上了。

今年看了的书

  • 《苏菲的世界》
  • 《C++ Primer》
  • 《算法》
  • 《数字电⼦技术基础》
  • 《数字设计和计算机体系结构》
  • 《汇编语言》
  • 《计算机组成与设计》
  • 《精通比特币》

除了《精通比特币》,其它基本上都是今年上半年看的,之后就没怎么看过书,一直在写代码了。

今年值得记住的「第一次」

  • 第一次去酒吧
  • 第一次跑 10 公里
  • 第一次实习
  • 第一次加班
  • 第一次面试别人
  • 第一次在外地过生日
  • 第一次辞职
  • 第一次被租房中介坑
  • 第一次做菜
  • 第一次组建社团
  • 第一次开培训班

项目

今年主要用的编程语言是 Python 和 C++,主要维护的项目全都是跟 QQ 机器人相关的:

  • coolq-http-api
  • coolq-cpp-sdk
  • python-cqhttp
  • python-aiocqhttp
  • nonebot
  • amadeus

社交

首先,最重要的!收获了一个无敌可爱的女朋友,让我整个人都变可爱了!在一起经历了很多事情,有开心也有难过,希望以后也能一直走下去!

今年在 CQHTTP 插件的交流群里也认识了很多新朋友,面了很多次基。真的很感谢写这个插件的经历,不仅在项目经验上有很大提升,也认识了很多很志同道合的朋友。

下半年接近年末的时候开始尝试组建开源软件协会,在学校也认识了很多有趣的人,有 18 级的小学弟们,还有其它年级以前不曾了解的大佬们。

新年计划?

看到开头咕了的那些了吗,计划的结果就是这样的,还计什么划。

感觉一次计划一年是非常不切实际的事情,一年的时间太长了,有太多太多无法确定的事情,2018 年的计划我坚持执行到了 6 月,但最终还是变成了想到啥干啥。这么看来也许可以半年订一次计划,应该能够更容易实现一些。

嘛,明天再说吧,先放松放松,享受今年最后的几个小时吧!

我的 2018 年

基于 QDP 协议实现 HTTP 代理

创建于
分类:Dev
标签:QDP代理HTTP代理通信协议QQ酷QCQHTTP

动机

简单实现了 QDP 之后,想通过这个协议寻求对计算机网络一些知识的深入学习,通过跟朋友们的交流,知道了可以通过实现 TUN/TAP 虚拟网络设备来兼容现有的 TCP/IP 协议栈,这是一个有趣的方向,不过还是打算先验证一下自己最开始的想法,也就是基于 QDP 实现一个 HTTP 代理,算是学习和实践一下 HTTP 代理的原理吧。

思路

根据 HTTP 代理原理及实现(一),HTTP 代理的原理,分为两种:第一种,浏览器将请求直接发送给 HTTP 代理,后者将 HTTP 请求转发给服务端(以客户端的身份),随后再将服务端的响应转发给浏览器(以服务端的身份);第二种,浏览器通过 CONNECT 方法请求代理建立一条隧道,通过该隧道转发 TCP 数据。

QDP 协议在这个实验中的作用,实际上是用于在「与浏览器通信的本地 HTTP 代理」(后面称此为「代理前端」)和「用于转发请求到真实目标站点的伪客户端」(后面称此为「代理后端」)之间传输数据,从而让真实的流量通过 QQ 消息传送。

由于 QDP 被用在代理程序的两个部分之间的通信,因此还需要设计一种数据交换协议(实际上是一种 RPC)(后面称此为「代理协议」)来作为 QDP 的有效载荷。

到这里,从思路上来说,整个工作流程已经比较清晰了:代理前端开放 HTTP 代理端口,接受浏览器的代理请求,然后进行必要的处理,再通过代理协议,把必要的数据和指令发送给代理后端,后者根据这些数据和指令,向代理请求的实际目标网站发起连接,并继续通过代理协议在代理前端和目标网站之间转发数据。

实现

第一步首先根据 Jerry Qu 的博客内容来实现一个正常的 HTTP 代理,源码见 demo/http_proxy.py。这一步遇到了一些坑,最后因为没有适当的第三方 HTTP 库,转而直接使用 asyncio 自带的流 API,也算是粗糙地实现了。

接着就是要把代理的前端和后端拆开。

先设计它们的通信协议(上面说的代理协议),为了简便起见,直接使用 JSON 来定义:

{
    "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
    "method": "connect",
    "params": {
        "host": "www.example.com",
        "port": 443
    }
}
{
    "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
    "method": "transfer",
    "params": {
        "data": "<base64 encoded bytes>"
    }
}
{
    "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
    "method": "close",
    "params": {}
}

上面的 id 字段是 UUID,用于唯一标识一个代理请求。method 字段定义了代理协议的三个方法,分别是 connecttransferclose,这三个方法是通过分析先前实现的 HTTP 代理所进行的操作得来的,首先代理前端需要通知后端连接目标站点(使用 connect 方法),然后两端需要互相转发数据(使用 transfer 方法),最后请求完成后还需要关闭连接(使用 close 方法)。params 字段是相应方法所需的参数。

有了代理协议之后,就可以开始分别实现代理前端和代理后端了。

代理前端非常简单,直接在正常的 HTTP 代理的代码上修改。接受代理请求的部分不用动,当需要建立连接的时候,向代理后端发送 connect 协议包;然后当从代理请求的连接中读取数据之后,使用 transfer 协议包向代理后端发送数据,与此同时,当后端发来数据(同样是 transfer 方法)时,从中读取数据并转发给代理请求方(浏览器)。源码见 demo/http_proxy_frontend.py

代理后端不能直接从正常的 HTTP 代理代码修改,需要写一些新的逻辑,主要就是不断地接收代理前端发来的协议包,如果是 connect,就开启一个协程,向目标网站建立连接,然后不断接收对应 id 的协议包,如果是 transfer 则转发,close 则关闭,实际代码不是很多。源码见 demo/http_proxy_backend.py

上面代码虽然说起来简单,但实际编写的时候还是经历了一些艰难的 debug 的……由于代码量不算非常多,就没怎么加注释了,阅读起来应该不会很困难。

效果

编写代码时代理前后端都是跑在本地的,测试成功后,将后端和其对应的 QQ 移到阿里云上海的某个 VPS,成功运行,访问 ip.cn 来验证 IP 地址确实已经是阿里云上海的地址:

从上图的 DevTools 可以看出,这个代理的速度基本上慢到不可用了(本来 QDP 就已经足够慢了,现在代理协议又需要占用额外的空间),但作为一个概念验证已经足够了。

参考资料

基于 QDP 协议实现 HTTP 代理