RegameDesk 开发笔记【2】Avro

数据交换采用 Avro。但在开始介绍之前,先进入大家最喜欢的答疑环节。

答疑

  1. 稣这是创业了吗?

没有。创业需要能赚钱的产品,还需要有投资(即,不是赚市场的钱,就是赚投资人的钱),稣两者都没有。再说创业肯定都是偷偷摸摸的。单纯就是回报社会主义社会。

  1. 不赚钱,你是 SX 吗?

众所周知,稣使用自然码双拼,普通的拼音简写早就看不懂了。SX 是啥?

经验

几乎在每家公司都用过 Google Protocol Buffers,除了它还没被开发之前。在网稣时,不仅大家推荐稣(在 Linux)使用 ProtoBuf,稣也推荐大家(在 Windows)用 ProtoBuf。但是在做远程桌面时,却和搭档很默契地不用它!最神奇的是,我俩一个写客户端,一个写服务端,期间也没对过具体协议,联调时数据结构居然 100% 一样,一跑就通。

当然这在工程上,并不是什么好事!因为这说明用的就是最简单的封装,相当于没用数据交换库,纯手工打造。要说为啥,那肯定就是偷懒,当时在 Windows 上使用 PB 还比较麻烦,为了“快速实现”就先牺牲扩展性。

到金斗云时,有的工程不用 ProtoBuf,有的用。原本设计上是:网络通信用 ProtoBuf,IPC 则用 FlatBuffers。但由于采用驱动抓图的方式,避开了 IPC 的使用(具体原因以后分析),所以其实 FlatBuffers 没用上。想用 FlatBuffers 是因为它效率高,但又因为它并不安全,所以不能用于网络上,只能用于 IPC。

在雪蛤油时,采用 ProtoBuf 是原有团队的积累,自己也熟悉,没理由不继续用。但发现两个版本的 Debug 版都会被 Boost.Test 报内存泄漏,这时候就凸显库臃肿的坏处了……ProtoBuf 这么大坨,根本没人愿意去分析,如果升级版本,又会出现两份(因为别组开发的 DLL 库里也用,稣只能升自己这边的,那边的他们不升),最后,看着没啥表面问题就直接弃疗。

分析

目前,RegameDesk 采用 Apache Avro 1.12.0。实际上,但凡有点名气的数据交换(或者叫数据序列化)格式和库都被研究得很多了,大可不必自己去选型,看看论文即可。比如这篇:[2201.03051] A Benchmark of JSON-compatible Binary Serialization Specifications

在大部分的对比项中,Avro 和 Microsoft Bond 常常是前两名。

ASN.1 也挺极致的,但它不好用。

Bond 的代码里有 Haskell,学习成本高于只有纯 C++ 的 Avro。而且它已经快停止维护了,详见 Bond project ending March 2025 #1215

简单看过 Avro 的代码,里面有 Zigzag 算法,用于压缩整形,这可太数学、太极致了,是基于统计学的——0 附近的数字被用得多,所以它们用更少的字节来表示。能考虑到这种点,光是想象中,基本上性能就不会落后(当然前面的论文也有实际验证)。

另外,Avro 本身很小巧,第三方依赖主要是 Boost.Iostreams,但 Boost 本来就是稣惯用的库,可以说维护上毫无压力。相对的,ProtoBuff 还带着不熟悉的 abseil,二进制整体加起来大 Avro 好几倍。以下对比基于 vcpkg 默认配置的编译结果:

文件 大小
ProtoBuff Lite abseil_dll.dll, libprotobuf-lite.dll 3.46MB
ProtoBuff abseil_dll.dll, libprotobuf.dll 14.9MB
Avro avrocpp.dll, boost_iostreams-vc143-mt-x64-1_86.dll, zlib1.dll, bz2.dll, liblzma.dll, zstd.dll 1.84MB

(实际上 4 个压缩库是能优化掉其中不用的几个,即还能更小)

使用案例

在设计上,RegameDesk 支持 4 种操作模式:

模式 具体功能
Assist 协助模式:适用于服务器前有人在操作的场景
1. 屏幕拓扑、分辨率等设置均以服务器为主,不改服务器的任何配置
2. 不会创建虚拟显示器
3. 允许多个此模式的客户端;兼容其它模式
Control 控制模式:适用于服务器为物理机,尤其是办公机
1. 屏幕拓扑、分辨率等设置均以客户端为主,将客户端的配置同步到服务器
2. 必定会创建虚拟显示器
3. 会断开服务器上所有物理显示器,防止服务器侧隐私泄露
4. 会使服务器静音,防止服务器侧隐私泄露,而且会阻止用户取消静音
5. 退出时,还原服务端设置
6. 只允许一个此模式客户端;与 headless 模式互斥
Headless 无头模式:适用于服务器不插显示器的场景(物理机或虚拟机皆可)
1. 假定服务器只有虚拟显示器,如果有物理屏幕,则断开,防止服务器侧隐私泄露
2. 屏幕拓扑、分辨率等设置均以客户端为主,将客户端的配置同步到服务器
3. 会使服务器静音,防止服务器侧隐私泄露,但不会阻止用户取消静音
4. 退出时,不还原服务端任何设置
5. 只允许一个此模式客户端;与 control 模式互斥
View 仅查看模式:不能操控
1. 屏幕拓扑、分辨率等设置均以服务器为主,不改服务器的任何配置
2. 不会创建虚拟显示器
3. 允许多个此模式的客户端;兼容其它模式

用 Avro 来表示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"type": "record",
"name": "OperationMode",
"fields": [
{
"name": "mode",
"type": {
"type": "enum",
"name": "Mode",
"symbols": [ "kAssist", "kControl", "kHeadless", "kView" ]
}
}
]
}

人类都说:JSON 的可读性最好。是这样没错!

RegameDesk 开发笔记【1】立项

故事

在遥远的古代,那时候还没有掐鸡,甚至搜索引擎都才刚刚起步,热爱学习的稣还只能去图书馆查阅资料。机缘巧合下,稣发现厦门大学图书馆不仅有很多“砖头”(工具书),还可以免费上机。是的,免费,当时“白嫖”这词还没被发明。

当年的厦门大学图书馆可以说是高科技满级,直到今天看也并不落后——居然是云架构!瘦终端远程登陆到 NT4 的终端服务,这套东西和今天人们用 Windows 自带的 mstsc.exe 或macOS 上的 Microsoft Remote Desktop/Windows App,远程登录 Windows 2000/XP/2003/Vista/7/8/8.1/10/10……可以说,根本就是一样的。

总之,当时天上打了个雷,震惊程度和高中时代看《黑客帝国》不相上下。

上班后,虽然做过很多产品,但潜意识里一直被远程桌面吸引,十几年的观测后,终于慢慢靠拢。目前,稣在三家公司做过远程桌面。

经验

注:以下公司名都是虚拟的,不会有雷同。

第一家网稣,开发起码有 6 名,稣当时还是救火员到处帮忙,所以只负责保驾护航,没多久就去其它部门,再后来就离职了。当时不算核心开发,只是一起搞了个 Demo,验证我们能做出来原型,而且给网吧内网用问题不大。但要做精,做在互联网上用的,火候差很多。比如当时直接用 Live555 做传输库,没人专项优化 rtsp 协议,抗弱网能力这个指标从没被关注到。外设也只支持键盘、鼠标,还是最基本的 SendInput 重放。

第二家金斗云,开始时就稣一人同时做云游戏两个版本和一个远程桌面 Demo,以及另一些项目……后来远程桌面多了一个十几人的团队,至今已经做了多年。这一次,稣的重心是想放在虚拟驱动上的,研究了各种驱动框架,基本知道如何做好一个远程桌面产品。不过由于家里太穷,两年没加薪,脑波意见很大。部门的新领导正好有意让稣去深圳发展,干脆看看机会,结果有个猎头正好找稣说有个远程桌面的岗位,而且是给公司的员工做的,不需要考虑盈利!无论从事业还是家庭角度看,都合算,于是跑了。

第三家雪蛤油,入职时已经有大佬做了一个 Demo,完成度和网稣的那个差不多,只是 UI 丑了很多,毕竟网稣的界面是专业的 DUI 开发 2 人做的。进来前妄想是这公司有钱,人应该比较多,能专心做核心驱动,结果正好相反。一进来就发现开发,包含稣就 2 名,招聘没几天领导就宣告不招了。整个开发周期就 2 名全职 C++ 开发,一名外援 C++ 和一名外援前端。然而,团队又是有许多技术积累、管理手段和周期要求的,按各项最优的来阻力大,所以最终是留着一半原有风格,局部替换为稣认为的足够极致的解。最大收获是产品运营起来后,发现用户的各种毛病怎么防治。毕竟这是一个系统级软件,任何用户自己的问题,都可以被用户怀疑是“软件的 bug”,防范“瓜田李下”是必备的。

新愿景

RegameDesk 将从零开始,吸收在之前三家公司经历过的教训,每项关键点都追求安全和极致。框架上,会比较倾向金斗云时的设计。

产品理念:**为全宇宙打工,搞一个程序员认为安全的软件。**具体说,市面上,除了微软的远程桌面,估计还没有其它能被程序员们认为安全。大部分产品,都有账户体系,会维持和厂商服务器连接,数据可能经过厂商中转。它们可能自称是加密的,但密钥都不能自定义,厂商到底能不能解密都难讲。

罗老师:稣真不是为了赚钱,这样的产品能赚钱才怪。

不建议使用 using namespace

故事

最近稣在梳理一份 Google C++ Coding Style 的更严格版本,其中,有一条是“在大范围禁止使用 using namespace”,结果获得 100% 员工的支持。员工人数:1!

分析

具体来说,大范围指的是比函数更大的范围。通常的 Coding Style 可能会提到:禁止在头文件里使用 using namespace,而这里的禁止显然涉及的地方更多。禁用它的理由通常有以下这些:

  1. 降低可维护性:using namespace 会降低代码的可读性,因为它隐藏了实体的来源,读者需要额外的工作来确定一个名称属于哪个命名空间。如果后续添加或修改了同名的实体,可能导致编译失败,甚至可能引入难以发现的错误。

  2. 降低编译性能:using namespace 可能会导致编译器查找符号时的性能下降,因为它需要在所有导入的命名空间中搜索。

同时,我们放开了例外的限制,没错,您可以在任何地方,比如头文件里,使用 using namespace 各种 std::literals

如果您觉得不使用 using namespace,会导致需要写大量很长的 namespace,那您应该使用 namespace 定义别名来缩短,如果是本项目的 namespace 倾向于使用最短的简称。例如:

1
2
3
4
5
6
namespace d = umutech::regame::diagnostics; // 自己的命名空间,尽量短
namespace fs = boost::filesystem;
namespace nw = boost::nowide;
namespace po = boost::program_options;

using namespace std::string_view_literals; // 例外

microsoft/proxy 学习笔记

1. 用于啥需求?

实现多态性。它允许统一管理和操作多种子类对象,在传统方法中,这通常通过基类指针来实现。

2. 何时使用 Proxy?

每当你在代码中使用 virtual 关键字定义虚函数时,可以考虑使用 Proxy 库作为替代方案,以提高性能和简化代码管理。

3. 有时候 CRTP 也可以替代 virtual,CRTP 也能用 Proxy 替代吗?

虽然 Curiously Recurring Template Pattern (CRTP) 可以在某些情况下替代 virtual 函数,但 Proxy 并不是用来替代 CRTP 的。如果你的目标是代码复用,而不是统一管理多种子类,那么 CRTP 仍然是更合适的选择。例如,如果你有多个项目使用相似的代码段,并且这些代码段被封装在一个基类中,通过 CRTP 实现代码复用,而这些子类只在各自的项目模块中使用,那么继续使用 CRTP 是合适的,使用 Proxy 则不太适合。

Windows 平台编译 FFmpeg

需求

  • FFmpeg 7.0 开始已经支持 D3D12VA,可以自己编译一份采用 LGPL 协议的定制版了。

  • 以前都使用 ShiftMediaProject,现在想学习一种新的编译方式。

解决

这次采用较为普遍的方式:在 MSYS2 环境下编译。

1. 下载、安装

官网下载,并且官网有 Installation 说明。安装完 MSYS2 本身后,需要在 MSYS2 环境下安装编译 FFmpeg 所需的工具:

1
pacman -S autotools yasm

当然,您还需要下载 FFmpeg 代码,目前合适的版本为 7.1。下载完,解压到合适的目录,比如 D:\devel\ffmpeg,这样代码目录就是 D:\devel\ffmpeg\ffmpeg-7.1

2. 启动编译环境

为了编译 x64 版本,运行 x64 Native Tools Command Prompt for VS 2022。然后进入 MSYS2 安装目录,输入以下命令:

1
msys2_shell.cmd -use-full-path

此时获得一个新打开的 MSYS2 实例窗口,后面的命令在这里输入(即,不需要 x64 Native Tools Command Prompt for VS 2022 窗口了)。

3. 编译

由于稣只需要 D3D12VA 对 HEVC 的支持,所以编译其实很快,命令行如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
cd /d/devel/ffmpeg/ffmpeg-7.1
export OUT_DIR=../build
./configure \
--target-os=win64 \
--arch=x86_64 \
--toolchain=msvc \
--enable-shared \
--disable-all \
--disable-autodetect \
--enable-swresample \
--enable-avcodec \
--enable-encoder=hevc_d3d12va \
--enable-decoder=hevc \
--enable-d3d12va \
--enable-hwaccel=hevc_d3d12va \
--prefix="$OUT_DIR"

make -j
make install

Windows 平台用 VBS 保护私钥安全

问题

之前稣写过《macOS 上用触控 ID 安全登录 SSH》和《Debian 12 上用 TPM 2.0 安全登陆 SSH》,为何没 Windows 版?

分析

当然有!只是太简单,稣以为大家都会……直到今天才发现不少人都不懂保护自己的私钥,真以为没人惦记你的三千块钱存款吗?

解决方案

网络上能轻易找到的一种解决方案是使用虚拟智能卡,它使用 TPM 2.0 保护私钥,而且使用时需要输入 PIN,安全性是可以的,唯一的问题就是很麻烦。

详情参考:Secure SSH Access with TPM2-Backed Key

稣的解决方案基于 VBS,全称 Virtualization-based Security那啥,不是 VBScript 这种已经去世几十年的东西。 VBS 的底层有一部分是 TPM 2.0,安全性有保障。

1. 离线产生私钥

  • 专门买一台百来块的擸𢶍小主机,从不接入网络,是从不,连局域网都不。

  • 产生密钥导出时需要使用强密码保护。

  • 将加密的密钥文件(.pfx)复制到一个安全的 U 盘,但是这个 U 盘丢了也不怕,因为擸𢶍小主机上还有密钥,而且 U 盘里的文件也有密码。

当然,如果您觉得私钥丢了也无所谓,那可以不用保存私钥副本,只需要保证私钥不被偷。

2. 导入使用密钥的机器

这步隐含“使用密钥的机器”是不那么安全的,所以需要处处防御:

  • 机器拔网线、断 WiFi,重启,进入安全模式。

  • 插入 U 盘,快速导入私钥,导入时必须选择“使用虚拟化安全保护私钥(不可导出)”,快速移除 U 盘。

  • 重启。

导入时有几个可选项:

  • “启用强私钥保护。如果启用这个选项,每次应用程序使用私钥时,你都会收到提示。”稣一般也开启,以便知道哪些应用不老实。

  • 密码。如果机器只有自己使用,可以不用设置,把“此密钥需要密码”点掉。即使不设置密码,被 VBS 保护的私钥也不可能被偷走。

后记

本文没有任何命令和图片,单纯传授心法,因为绝大多数的观众老爷们根本没有安全需求,贴出图片也是浪费时间和流量。

Diffie-Hellman 密钥交换协议

Diffie-Hellman 密钥交换协议,允许双方在没有预先共享秘密密钥的情况下通过不安全的通信渠道建立共享的秘密密钥。这个名字来源于发明者 Whitfield Diffie 和 Martin Hellman。

Diffie-Hellman 密钥交换原理简介

Diffie-Hellman 密钥交换(通常简称为 DH)依赖于数论中的一个事实:在一个有限字段上,给定生成元 g 和素数 p,计算 $g^a \mod p$ 相对容易,但是尝试反向计算,即已知 $g^a \mod p$ 求 a,则是计算上不可行的,这种问题被称为离散对数问题。

流程

  1. 参数生成和共享:首先,双方同意使用一个公共的大素数 p 和基数 g(这两个值就是所谓的 “DH Parameters”),g 通常是 2。

  2. 私钥和公钥生成:

  • 每一方选择一个私有(不与对方共享)的随机数,作为自己的私钥。设发送方私钥为 $\alpha$,接收方为 $\beta$。
  • 然后计算公钥 $A = g^\alpha \mod p$(发送方)和 $B = g^\beta \mod p$(接收方),并互相交换这些公钥。
  1. 计算共享秘密:
  • 发送方计算 $S = B^\alpha \mod p$。
  • 接收方计算 $S = A^\beta \mod p$。
  • 如果计算正确,双方将得到相同的值 S,该值可以用作通信双方的共享秘密。

由于反向计算离散对数是计算上不可行的,因此第三方无法仅仅通过拦截 A 和 B 来确定共享秘密 S,除非他们能够解决离散对数问题,这在实践中是非常困难的,尤其是对于足够大的 p。

使用场景

Diffie-Hellman 主要用于在不安全的通道上安全地交换密钥,这些密钥之后可以用于加密后续通信。例如,在 TLS/SSL 握手过程中就可能使用它来建立会话密钥。

密码套件和 DH 参数

TLS 支持多种类型的密码套件,这决定了密钥交换、消息认证、加密和密钥材料生成算法。当涉及到 Diffie-Hellman 密钥交换的密码套件时,它们通常会分为两类:

  • 传统 DH 密钥交换:在这种情况下,服务器和客户端事先同意一组 DH Parameters(公共的大素数 p 和基数 g)。服务器在 TLS 协议的相应阶段将这些参数发送给客户端。然后,服务器和客户端各自生成一个随机私钥,并基于此私钥和接收的参数计算公钥,再互换公钥以建立一个共享秘密。由于这些参数(特别是素数 p 和基数 g)是公开交换的,所以对安全性几乎没有影响,关键是私钥的私密性。

  • 椭圆曲线 Diffie-Hellman 密钥交换(ECDH):与传统 DH 类似,但使用椭圆曲线数学而非模幂运算。在 ECDH 中,参数是指椭圆曲线的定义,以及用于生成公私钥对的基点。这些参数同样需要在通信双方间共享或事先约定。

SSL/TLS 握手和 DH 参数

在实际的 SSL(或 TLS)握手过程中,以下步骤涉及 DH Parameters 的使用:

  1. 服务器 Hello 消息:服务器选择一个密码套件,该套件可能包含 DH 密钥交换方法。

  2. 服务器 Key Exchange 消息:如果所选的密码套件基于 Diffie-Hellman 密钥交换,服务器将在这个消息中发送 DH Parameters(对于 ECDH,发送椭圆曲线参数和服务器的公钥)。对于某些 DH 套件,服务器的公钥可能已经在一个证书中,这种情况下,参数可能不需要在这条消息中发送。

  3. 客户端 Key Exchange 消息:客户端接收到服务器的 DH Parameters 后,生成自己的 DH 密钥对,发送自己的公钥给服务器。

  4. Premaster Secret 的计算:一旦双方都有了对方的公钥,它们就可以独立计算出一个共同的秘密值(Premaster Secret),接下来使用这个秘密值生成会话使用的加密密钥。

在整个过程中,即使 DH Parameters(素数 p、基数 g 和相应的公钥)是公开的,由于 Diffie-Hellman 问题的难解性,攻击者不能轻易地计算出共享秘密,从而保证了密钥交换过程的安全性。

注意

虽然 Diffie-Hellman 本身是一种安全的密钥交换协议,但它不提供身份验证功能。因此,在某些实现中,它通常会与数字证书或其他身份验证机制结合使用以防范中间人攻击。

真实的程序员情商高得可怕!

情绪商数(Emotional Quotient,EQ)是指一个人对自己和他人情感的感知、理解和应对能力。但长期以来,在传统文化浸濡下,很多人以为情商是处事圆滑,甚至是 PUA 那套。但凡有人拒绝被 PUA,就会迎来一顿“情商低”的舆论攻击。

程序员,就是最大的受害者群体,可能有之一,但几乎……没有!

以下从四个模型五个视角来说明——其实天生的程序员,情商高得可怕!

四个模型

  1. 情绪知觉(Perceiving Emotions):程序员最善于观察,所以很容易知道对方是否被激怒,随时会调整自己的话术,既不能让对方被气死,更不能让对方动手打人。

  2. 情绪理解(Understanding Emotions):程序员最善于理解前向传播和后向传播。对方如何被激怒,甚至气得说不出话的,都是全程跟踪和拿捏的。甚至还能为下一次战争调整模型参数。

  3. 情绪运用(Using Emotions):程序员最善于隐藏情绪。隐藏实力、知己知彼,才能百战不败。打不过时,有的脾气爆发,辞职走人,有的留下一堆诗山,辞职走人。

  4. 情绪调节(Managing Emotions):程序员最善于保持脸瘫,情绪一直良好。

五个视角

  1. 自我意识(Self-awareness):程序员平均智商高于常人,自我意识同样更强。

  2. 自我管理(Self-management):程序员自我驱动力很强。

  3. 社交意识(Social awareness):世人对程序员最大的误解在此。其实程序员的社交意识特别强烈,不然为何有世上最大的同性社交平台?程序员们只是排斥无效社交而已,人们理解的那种酒桌文化就是程序员认为的无效社交。但如果哪天需要创业,需要演戏,也不是不行哦。稣曾经见过某些隐藏实力的程序员爆发惊人的酒量,您想说这是个例?非也,都是人,这种个例的比例不会低于整体人群的比例。

  4. 关系管理(Relationship management):程序员特别能管理关系!能力差的,很容易就被管理出局。能力强的也特别突出,一般用来搞上面的人,争取气死一批是一批。

  5. 自我激励(Self-motivation):程序员特别擅长跳槽加薪……另一方面,别人眼中枯燥无味的东西,程序员乐此不疲,不也是自我激励吗?

真实案例

程序员天生会《孙子兵法》,而且运用自如。下面看看一些经典案例。

  1. 产品经理:理解能力怎么这么低下?

故意的。他是觉得产品经理没干啥活,故意不懂,让产品经理把事情理清楚,流程画完整再来。你想浪费时间争辩,我也正好休息一会儿,发泄一下,争取当场气死你。

  1. 领导:你怎么把问题又抛给我?

原因大约有三类:一是你管太多了。疑人不用,用人不疑。管是没问题的,不要管技术细节,你真这么懂,自己做?让程序员花时间解释,你怎么不自己看代码?二是你交代的需求不明确,只能你自己选型。三是你带着偏好,比如你喜欢某方案,一个劲提起,那不然你随便实现了?

  1. 写个文档都写不好?

两类比较大的可能:一是写的这类文档不应该程序员写,某些角色不够专业或上心,应该自己写的,推给程序员。二是这是机密技术,怎么能写明白呢?杀人诛心啊!还是装傻保命吧。

  1. 怎么没有架构设计文档?是不是偷懒?

相反,正是因为实在,所以“对不起,没有!”。这通常是一个不合事宜的问题,尤其是在敏捷开发的团队。一些业务为主的产品,有合格的需求文档基本就行,开发者每个都是架构师。而以技术深度著称的产品,架构通常是固化的,或者有知名的最优解,没得选,没人只是架构师。而以量级大为特色的产品,基本就是抄,架构是现成的,比如国产操作系统。总之,在真实的商业软件里架构设计是一个特别尴尬的东西,往往是后补上的。但是,万一真的有一个架构师职位的人,一定要让他事先写好,以后实现发现不对,再来讨伐他。em……花时间写这东西,还可能被批判,情商高得可怕的程序员,怎么会写呢?

  1. 测试环境好好的,怎么到了用户环境就出 bug?

这是为了 GDP、为了民生考虑的伟大情操!业界不成文的规则:开发阶段只解决理想环境的需求,测试阶段解决产品、测试人员的饭碗,发布阶段解决运营人员和自己后续的饭碗。开发周期短,其它周期就长,程序员是精于计算的,不打没胜算的仗。如果不能养活一群人,那和当畜生直接给吃掉有啥区别?

该不该用 std::string_view 替代 const std::string&?

问题

  • 有的大佬建议使用 std::string_view 替代 const std::string&,也有的大佬表示反对,到底该不该呢?

  • 以下代码运行后显示什么?

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>
#include <string_view>

using namespace std::string_view_literals;

int main() {
auto s = "Hello C++ 20!"sv.substr(0, 5);
printf("%.*s\n", static_cast<int>(s.size()), s.data());
std::cout << s << '\n';
printf("%s\n", s.data()); // buggy
}

分析

反对者可能是 C 语言的受害者!(这里没说反对得不对哦!)

先看看 CppCoreGuidelines 的说法:

In C++17, we might use string_view as the argument, rather than const string& to allow more flexibility to callers

这里说的是使用 std::string_view 作为参数,能够适配更多的调用者。不管传入 const char* 类型,还是 std::string 类型的变量,都能编译。

反之,如果参数是 const std::string& 类型,则有以下缺点:

  • 传入 std::string_view 类型无法编译。

  • 传入 const char* 类型,会多构造一个 std::string 类型的临时变量。

但是 CppCoreGuidelines 并没有建议把 const std::string& 参数都替换成 std::string_view,而是说字符串是一个大话题,需要分很多种情况讨论。具体可见 SL.str: String 节,下面列出大概意思:

Text manipulation is a huge topic.
std::string doesn’t cover all of it.
This section primarily tries to clarify std::string’s relation to char*, zstring, string_view, and gsl::span<char>.
The important issue of non-ASCII character sets and encodings (e.g., wchar_t, Unicode, and UTF-8) will be covered elsewhere.

  • SL.str.1: Use std::string to own character sequences
  • SL.str.2: Use std::string_view or gsl::span<char> to refer to character sequences
  • SL.str.3: Use zstring or czstring to refer to a C-style, zero-terminated, sequence of characters
  • SL.str.4: Use char* to refer to a single character
  • SL.str.5: Use std::byte to refer to byte values that do not necessarily represent characters
  • SL.str.10: Use std::string when you need to perform locale-sensitive string operations
  • SL.str.11: Use gsl::span<char> rather than std::string_view when you need to mutate a string
  • SL.str.12: Use the s suffix for string literals meant to be standard-library strings

那么,究竟啥场景能用 std::string_view 类型参数?

F.25: Use a zstring or a not_null<zstring> to designate a C-style string 节有一句话:

If you don’t need null termination, use string_view.

这句是重点!因为 std::string_view 并不保证 Null-terminated,如果函数使用 std::string_view 参数,却做了 Null-terminated 的假定,那么,在把参数传给其它 C-Style API 时,就可能导致 bug,甚至崩溃。

例如,这位大佬的文章说的情况:

简单地说,std::string 是保证 Null-terminated 的,而 std::string_view 不保证,当 const std::string& 参数被替换为 std::string_view 时,对于部分没有 Null-terminated 的字符序列,C-Style API 可能读入超过范围的字符,而导致逻辑上的错误,还可能因为读到无权限的地址而崩溃。

总结

简单地说,看情况而定。总之,为了写高质量的 C++ 代码,必须细分这么多的情况。

但您如果熟悉 C 语言,可能会提出疑问:大部分 CRT 函数或 C-Style API 不都做了字符串是 Null-terminated 的假定?确实如此!如果人人写好注释,人人遵守规则,那确实能把代码简化。问题是:异常和意外,哪个先来?

在容器的遍历中使用迭代器删除元素

问题

  • 小伙伴在 std::map 的遍历中使用 map.erase(iter++) 删除元素,稣在审查代码时提醒:最好养成习惯使用 iter = map.erase(iter),因为对于其它容器,前者可能是错的。

分析

先跑代码测试,再讲道理:

例一

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <deque>
#include <iostream>
#include <list>
#include <vector>

int main() {
// std::list<int> c = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
// std::deque<int> c = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
std::vector<int> c = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
for (const auto& e : c) {
std::cout << e << ' ';
}
std::cout << '\n';

for (auto iter = c.begin(); iter != c.end();) {
if (*iter & 1) { // remove odd elements
// iter = c.erase(iter); // This is correct in all cases

c.erase(iter++); // For list/map/set, this is correct;
// but for deque/vector, this may crash!
} else {
++iter;
}
}

for (const auto& e : c) {
std::cout << e << ' ';
}
std::cout << '\n';
}

使用 gcc version 12.2.0 (Debian 12.2.0-14) 测试:

1
2
3
4
5
6
7
8
9
$ g++ -std=c++20 crash.cpp -o crash

$ ll
-rwxr-xr-x 1 618 618 32K 7月13日 15:56 crash*
-rw-r--r-- 1 618 618 741 7月13日 15:55 crash.cpp

$ ./crash
0 1 2 3 4 5 6 7 8 9
fish: Job 1, './crash' terminated by signal SIGSEGV (Address boundary error)

在 std::vector(和 std::deque)中,删除元素会导致后续元素移动,当删除最后一个元素 9 时,有以下具体步骤:

  • 对 iter 取值(给 erase),它指向 9;
  • iter++,使 iter 变成 end;
  • erase 9 使“现 end”前移了一位,那么 iter 指向的“原 end”就不再是“现 end”;
  • 循环条件 iter != c.end(); 满足,继续循环……

其它容器,比如 std::list(链表)、std::map/std::set(红黑树),其删除操作不会影响到其他迭代器,所以使用 .erase(iter++) 是安全且高效的。

例二

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <deque>
#include <iostream>
#include <list>
#include <vector>

int main() {
std::vector<int> c = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
for (const auto& e : c) {
std::cout << e << ' ';
}
std::cout << '\n';

for (auto iter = c.begin(); iter != c.end();) {
if (2 == *iter || 3 == *iter) { // remove 2 and 3
// iter = c.erase(iter); // This is correct in all cases

c.erase(iter++); // for vector, this is incorrect!
} else {
++iter;
}
}

for (const auto& e : c) {
std::cout << e << ' ';
}
std::cout << '\n';
}

删除 std::vector 中相邻的元素,结果漏删了 3。

解决

唯一安全并通用的方式是 iter = c.erase(iter);,要养成习惯。

附录

  • Erase-Remove Idiom in C++