TimothyQiu's Blog

keep it simple stupid

使用 Conan 管理 C++ 依赖

现代 CMake 使用 C++ 依赖项时已经相对方便了。比如 find_package(OpenSSL 1.0 REQUIRED) 就可以自动在本地机器上查找已安装的与 OpenSSL 1.0 兼容的包,然后就可以直接 target_link_libraries(targetName PRIVATE OpenSSL::SSL) 使用,不必再手动写头文件和库文件配置。

要做到「本地机器上已安装」,macOS 和 Linux 系统都非常方便,大多数库都可以直接通过系统级包管理工具安装,而 Windows 上就稍显麻烦。我之前比较喜欢的是,比较复杂的库还是留着在各平台手动安装,简单的则使用 CMake 的 FetchContent 模块下载使用。但这样的缺点是每次重新生成工程时,都需要下载一遍依赖并编译。尤其最近国内的网络状态,HTTPS 连接 GitHub 经常超慢。所以想想还是用现成的包管理工具吧。

目前市面上流行的包管理工具中:vcpkg 虽然很多人用,但是微软那套逻辑我始终表示审美不能;Hunter 虽然是纯 CMake 解决方案,但是官方涵盖的库偶有不足;而 Conan 我一开始是不喜欢的,不但需要使用 Python 安装,而且它的前世 biicode 当年也风光过现在已经挂了。不过现在看来,Conan 似乎是这几个之中比较成熟好用的解决方案,至少,符合我的审美就是了。

安装

官方推荐用 Python 在虚拟环境里用 pip install conan 安装,可以随时保持最新。

我在 macOS 上使用 Homebrew 安装,可以少管理一个虚拟环境。感觉 Arch Linux 这样滚动更新的系统也可以直接使用系统包管理工具安装。

找到想要的库

想要使用 spdlog 时,首先搜索:

$ conan search spdlog -r conan-center
Existing package recipes:

spdlog/0.14.0@bincrafters/stable
spdlog/0.16.3@bincrafters/stable
spdlog/0.17.0@bincrafters/stable
spdlog/1.0.0@bincrafters/stable
spdlog/1.1.0@bincrafters/stable
spdlog/1.2.1@bincrafters/stable
spdlog/1.3.0@bincrafters/stable
spdlog/1.3.1@bincrafters/stable
spdlog/1.4.1@bincrafters/stable
spdlog/1.4.2
spdlog/1.4.2@bincrafters/stable
spdlog/1.5.0

命令行中的 -r conan-center 表示所要搜索的仓库,conan-center 是官方自带的默认仓库,如果你本地添加了多个仓库的话,也可以用 all 表示搜索所有仓库。不带这个选项时则是在本地的缓存中查找。

搜索结果中每一行都是一个可用的包的名称,使用 @user/channel 后缀的是完整的包命名方式。官方 conan-center 仓库中,最近的包都是通过 CI 自动构建二进制文件的,这些包使用name/version 的命名方式。

想要知道某个版本/包的详情,可以使用这样的命令查看:

$ conan inspect spdlog/1.5.0

会列出一些信息和安装时的可选参数。

当然,你也可以直接在网站 https://conan.io/center/ 查找 conan-center 仓库中的包。

依赖的指定、安装、使用

一般使用名为 conanfile.txt 的纯文本文件指定依赖,格式类似 INI 文件。

[requires]
spdlog/1.5.0

[generators]
cmake_find_package

[requires] 部分很简单,列出你所需要依赖的包的名称即可。[generators] 部分指定所需要的「生成器」,可以生成与 CMake、SCons 等工具的对接文件。

使用 conan install /path/to/source-dir 可以安装依赖并生成对接文件,参数为包含 conanfile.txt 的目录。当然,这样做会把「对接文件」生成在当前目录,可以使用 -if 参数指定输出目录,推荐放在 CMake 的构建目录。

这样,Conan 就会把 1.5.0 版本的 spdlog 安装到自己管理的目录(一般是 ~/.conan),然后在输出目录输出一个 Findspdlog.cmake 文件。

CMakeLists.txt 中,要让 find_package 使用 Findspdlog.cmake 文件,把它所在的目录加入到 CMAKE_MODULE_PATH 中即可:

# 因为我们把 Findspdlog.cmake 输出到了构建目录
list(APPEND CMAKE_MODULE_PATH "${CMAKE_BINARY_DIR}")

# 按照正常方式搜索
find_package(spdlog REQUIRED)

# ...

# 这个生成器导出的目标是 package::package
target_link_libraries(targetName PRIVATE spdlog::spdlog)

当然,官方教程中使用的是 cmake 生成器,它不会生成 FindXXX.cmake,而是生成一个 conanbuildinfo.cmake,你需要在 CMakeLists.txt 中手动初始化:

include(${CMAKE_BINARY_DIR}/conanbuildinfo.cmake)
conan_basic_setup(TARGETS)

# ...

# 这个生成器导出的目标是 CONAN_PKG::package
target_link_libraries(targetName PRIVATE CONAN_PKG::spdlog)

这种方法把 Conan 显式写入了 CMake 配置里,我个人不是很喜欢。(但是 CONAN_PKG::package 的表述确实比 package::package 好一些,因为一些库官方提供的目标并不都是 package::package。)

其它零碎

构建版本

Conan 默认安装/构建的二进制是 Release 版本的。而 CMake 的默认构建方式则是 Debug。

所以,尤其在使用类似 MSVC 的编译器时,你可能需要手动指定安装 Debug 版:

$ conan install . -s build_type=Debug

当然你也可以试试 cmake_multi 或者 cmake_find_package_multi 生成器,可以同时安装 Debug 和 Release 版本。抑或是使用官方提供的CMake 集成,自己写脚本把 CMake 和 Conan 的构建类型同步起来。

包的参数

在使用 conanfile.txt 指定依赖时,还可以同时指定一些可选参数。比如指定使用 spdlog 的动态链接版本:

[options]
spdlog:shared=True

好了,这就是大致的 Conan 使用介绍。

真正上手,还请参阅官方文档 https://docs.conan.io/

如何在 RESTful API 中表示批量操作

作为 RESTful API 的拥趸,最常被质疑的就是「批量操作很难 RESTful 起来」,似乎找不出对应批量操作的 HTTP 动词来。

早年间看文章,不少解决方案是先创建一个临时资源表示需要批量操作的资源,然后针对这个临时资源进行操作。将批量操作拆分到两个接口,多少有些削足适履的意思。

现在再看,情况就明朗起来了:用 PATCH 方法配合 JSON Patch 就能很好地表示批量操作。

PATCH 方法平时比较少用到,即便使用,一般也是以 JSON Merge Patch 格式更新单个资源的部分字段。例如修改一篇文章的发布时间和标题、删除广告:

PATCH /articles/42 HTTP/1.1
Content-Type: application/merge-patch+json

{
    "title": "Oops!",
    "published_at": "2019-01-02T03:04:05Z",
    "advertisement": null
}

相当于是给出了资源的部分表示,要求合并服务器上的表示与客户端所发送的表示。而 JSON Patch 格式的内容则是针对修改的结构化描述,例如上面的例子就会变成:

PATCH /articles/42 HTTP/1.1
Content-Type: application/json-patch+json

[
    {"op": "replace", "path": "/title", "value": "Oops!"},
    {"op": "replace", "path": "/published_at", "value": "2019-01-02T03:04:05Z"},
    {"op": "remove", "path": "/advertisement"}
]

其中 op 表示需要进行的操作,path 则是 JSON Pointer,用来指向 URL 所表示资源中的具体某个对象。数组中的多个操作依次进行。

使用 JSON Patch 来表示对单独资源的修改有些大材小用。不过如果将 JSON Patch 应用于合集资源,就可以很方便地表示所需的批量操作了。例如批量删除 ID 为 42 和 43 的文章,同时将 ID 为 45 的文章设为隐藏,并且新建一篇文章:

PATCH /articles HTTP/1.1
Content-Type: application/json-patch+json

[
    {"op": "remove", "path": "/42"},
    {"op": "remove", "path": "/43"},
    {"op": "replace", "path": "/45/visible", "value": false},
    {"op": "add", "path": "/-", "value": {
        "title": "Start Wars",
        "content": "A long time ago, in a country far, far away..."
    }}
]

甚至有些过于批量……

上面例子中,因为 PATCH 操作的 URL 资源是 /articles,所以 JSON Pointer /42 指向的就是 /articles/42 资源;同理 /45/visible 指向 /articles/45 资源的 visible 字段。而 /- 中的 - 则是 JSON Patch 中用来表示数组末尾的特殊索引。

这样一来,就可以名正言顺地对合集资源做 PATCH,进行批量操作了 😄

记一次有意义的调试

最近阳光明媚,天气晴朗,我为了更好地折腾理解 CMake,尝试把以前在 macOS 上写的一个 OpenGL 程序从 Makefile 改成 CMake 构建,顺带看看同样的东西在 Windows 上编译、运行效果如何。然而在 Windows 上用 MSVC 2015 构建后运行,发现场景里的东西没有了,并且没有任何错误输出。

因为似乎坊间传言 Windows 对 OpenGL 的支持很迷,所以我的第一直觉是 GLSL 的兼容问题,不过这个角度似乎很难排查。

所以还是搞个最小的可重现问题的代码吧,于是从经典的「三角形」Hello World 开始很快地试了一下,发现在 Shader 里做矩阵乘法就有问题;进而发现只要不乘投影矩阵就没有大问题,至少场景里的东西还是可以显示出来的。

蛤?这是什么鬼?总不见得 glm::perspective 有移植性问题吧?

经过一番调试,终于找到了问题所在,结论让人哭笑不得:

我在代码里自己封装了摄像机类,初始化时需要设置投影矩阵所需的远近裁剪平面,相当于下面这样。

Camera(float far, float near)
    : far_{far}, near_{near}
{
    /* blah blah */
}

虽然在其它平台没什么问题,但 windows.h 里有这种东西:

#define far
#define near

于是代码就相当于变成了

Camera(float far, float near)
    : far_{}, near_{}
{
    /* blah blah */
}

把远近裁剪平面的两个变量都初始化成了 0,也难怪场景里什么东西都看不见了。


唉,写代码的时候忘记了 far / near 指针这种历史遗留爆点,不过还是要吐槽下 windows.h 如此暴力的解决方法。

以上,真是有意义的一天呀~

psycopg2 的导入警告

最近发现如果使用最新的 2.7.4 版本的 psycopg2,import psycopg2 时会出现警告:

UserWarning: The psycopg2 wheel package will be renamed from release 2.8; in order to keep installing from binary please use "pip install psycopg2-binary" instead. For details see: http://initd.org/psycopg/docs/install.html#binary-install-from-pypi

大致就是因为老版本中自带的预编译 wheel 二进制可能会造成崩溃问题,官方决定 psycopg2 从 2.8 版本开始将不再包含预编译二进制,必须通过编译安装。为此还作出了两个调整:

  1. 2.7.4 ~ 2.8 之间为过渡期,使用 psycopg2 的预编译二进制时会生成警告
  2. 新增了 psycopg2-binary 包,除了仍旧提供预编译二进制外,与 psycopg2 完全一致(即与老版本行为一致)。

找了一下相关的讨论,感觉比较赞同这个 Issue 中的反对观点:

官方在说明中推荐开发用 psycopg2-binary、生产用 psycopg2,我总觉得有些坏味道。(同时也懒 :-P)

如果对此无所谓,可以在导入时自行捕获警告。(如果是通过 SQLAlchemy 间接导入,则在 create_engine 时捕获即可。)

import warnings

with warnings.catch_warnings(record=True):
    import psycopg2

个人觉得比较 Sane 的方法可能是在 pip install 时使用 --no-binary <NAME> 来跳过预编译二进制的使用。如果你和我一样用的是 Pipenv,则需要用环境变量来控制 pip 的行为:

$ PIP_NO_BINARY=psycopg2 pipenv install psycopg2

但愿官方能把 psycopg2-binary 取消掉。

以上,默默水了一篇。

我所知道的 constexpr

在知乎上看到《constexpr 究竟有什么用?》的问题,正巧前段时间趁着了解 constexpr if 的时候刚做过相关资料的搜集,拼凑了一下搞了一个回答。篇幅所限,完整版放在这里,主要是追加一些例子并补完一些周边内容 😄

constexpr 关键词是 C++11 引入的;C++14 中放宽了对 constexpr 函数的语法要求;而 C++17 则复用了该关键字,引入了 constexpr if。

constexpr 主要为 C++03 引入了以下变动:

拓展常量表达式的概念

之所以要拓展常量表达式的概念,是因为 C++03 中的一些尴尬,这从原本的标准库中就能看出来。

比如我们都知道 INT_MAX 是 C 语言的遗产,C++ 则更希望大家使用 std::numeric_limits<int>::max() 来拿 int 型的上限。然而不幸的是,后者是个函数调用而不是整型常量,使用起来可能需要花更多心思在性能或者别的东西上,没有前者那么自由。

int foo[std::numeric_limits<char>::max()];  // error: 不是常量表达式

又比如标准文件流,它的构造函数可以带上这样的第二个参数:

std::fstream foo("foo.txt", std::ios::in | std::ios::out);

这个参数是 openmode 类型的,是由实现具体定义的一种 Bitmask 类型。出于类型安全的考虑,通常用枚举值实现而不是整型。但是这样一来就有个问题,同样是写 std::ios::in | std::ios::out,如果用整型的话可以作为常量表达式使用,而为了类型安全考虑换用枚举实现(尤其是重载 operator|)后,就再也不可能是常量表达式了。

inline openmode operator|(openmode lhs, openmode rhs)
{
    return openmode(int_type(lhs) | int_type(rhs));
}

明明是这样简单的函数,对它的调用却不是常量表达式,就更别提编译时求值了。这就让委员会陷入了必须在「类型安全」和「效率」里二选一的尴尬境地。

标准库里会遇到这样的问题,大家日常使用也会遇到。加之标准委员会很想借此机会把原本标准中对于「常量表达式」(尤其是整型常量表达式)复杂的定义重构简化,引入 constexpr 来标记一些简单的函数,让对它们的调用能够作为常量表达式存在就很合情合理了。

而 constexpr 成员函数也可以类推,比独立的 constexpr 函数多一些对于类本身的限制便是了。

至于 constexpr 函数「到底应该多简单」,其实并没有必要刻意去记那些限制。如果你标记了 constexpr 的函数不够简单,编译器会提醒你哪里有问题的。

强制要求表达式编译时求值

说到这里,「constexpr 函数」并不能和「编译时求值」划等号,它只表达了「这个函数具备这样的能力」。

如果需要强制要求表达式在编译时求值,那么我们需要在变量定义前添加 constexpr,这样,用来初始化这个变量的表达式就「必须」是常量表达式,否则会报错。

#include <fstream>

// 该函数不是 constexpr 函数
int identity(std::ios::openmode mode)
{
    return mode;
}

constexpr auto out = std::ios::out;  // 此处为 constexpr

int main()
{
    constexpr int modeFail = std::ios::in | identity(out);  // 出错
              int modePass = std::ios::in | identity(out);  // 正常
}

constexpr 函数只有同时满足

  1. 所有参数都是常量表达式
  2. 返回的结果被用于常量表达式(比如用于初始化 constexpr 数据)

才会触发编译时求值。如果只有参数是常量表达式而结果不是,那么是否触发编译时求值取决于具体实现。

constexpr if

C++17 的 constexpr if 严格意义上不是 constexpr 而是 if 的一部分。

constexpr if 的主要用途是简化模板代码(这也意味着除非你是「库作者」或者「模版狂魔」,很少会用到)。很多原本需要绕弯借助类型 Tag 或者 SFINAE 来实现,需要拆成 N 个函数的功能,可以借助 constexpr if 写到一个函数里。

以下面的 get_value 函数为例。这个函数正常情况下会把参数原样返回,但如果传入指针,则返回被指针指向的内容。

如果用简单的类型 Tag 来实现,需要拆成三个模板:

template <typename T>
auto get_value(T t, std::true_type) {
    return *t;
}

template <typename T>
auto get_value(T t, std::false_type) {
    return t;
}

template <typename T>
auto get_value(T t) {
    return get_value(t, std::is_pointer<T>{}); 
}

如果用 SFINAE 来实现,需要拆成两个模板:

template <typename T, std::enable_if_t<std::is_pointer_v<T>, int> = 0>
auto get_value(T t) {
    return *t;
}

template <typename T, typename std::enable_if_t<!std::is_pointer_v<T>, int> = 0>
auto get_value(T t) {
    return t;
}

而如果用 constexpr if 来实现,可以简化为一个:

template <typename T>
auto get_value(T t)
{
    if constexpr (std::is_pointer_v<T>) {
        return *t;
    } else {
        return t;
    }
}

也有人把 constexpr if 拿来代替当作 #if 用,相信你可以想象得出具体的用法。但……我觉得那是邪道,所以不写了 😛

constexprconst

最初接触到 C++11 时,constexprconst 的关系让我很是害怕,因为排列组合似乎很多。不过这回终于下了决心,整理了下,没有初见时想象的那么复杂,总共似乎也有两个情况。

在修饰数据(变量)时,constexpr 是隐含 const 语义的。同时,constexpr 只和变量本身有关,遇到类似 const int *pint *const p 的破事时,建议还是写明:

constexpr int const foo = 42;
constexpr int       foo = 42;    // 和上面等价
constexpr int const *pb = &bar;  // 此处 &bar 必须是常量表达式

在涉及 constexpr 成员函数时,成员函数可能会有 const 修饰的情况。C++11 中,constexpr 成员函数是隐含 const 的;C++14 以来则没有这个限制。这里似乎可以借用 Python 的哲学「Better explicit than implicit」来表达一下,总共就几个字母的事情,就别偷懒了,你没法保证别人和你一样清楚这些「不那么直观」的东西。


以上就是所有内容了,感觉还是有些流水账的样子。

顺便小小吐槽一下「模板」,macOS 自带的拼音输入法只能通过 mú bǎn 打出来,所以我老是打成「版」……