2024-04-28 00:00:00
字符串格式化是很常见的功能,传统上,我们使用 C 语言的 printf 来格式化。但作为一位 C++ 爱好者,printf 的缺点也很明显:
std::cout 的问题在于:
流行的新一代格式化库如 fmt 的问题在于:
市面上其他的 C++ 格式化库也无法让我满意,于是我在自己的 C++ 通用库 xlib 中实现了一个简单的 fmt 模块,使用方法如下:
BasicFormat fmt;
// pad 可实现用 0 补齐
fmt("pi = ", fmt.pad(4, 314), ' '); // => "pi = 0314 "
可以通过继承来添加自定义类型:
struct Date {
int year, month, day;
};
struct MyFormat: public BasicFormat {
using BasicFormat::append;
static void append(const Date& d, StringPusher& push) {
append_all(push, d.year, '-', pad(2, d.month), '-', pad(2, d.day));
}
X_FMT_IMPLEMENT_FORMAT
};
MyFormat fmt;
fmt(Date { 2012, 4, 1 }); // => "2012-04-01"
支持自定义类型的方法有这么几种:
operator<<
,std::cout 就用的这种我一开始用的是模板偏特化的方法,但这种方案的问题是,相关定义是全局的。所以对某一个自定义类型,同一个程序里只能定义一种格式化方法。而且 C++ 的 namespace 是开放的:即后面 include 进来的文件可以往任意 namespace 添加东西,我认为这样太动态。
我后来改为用继承实现,因为:
附:fmt 的简化实现
class StringPusher {
std::string& str;
public:
StringPusher(std::string& str): str(str) {}
void operator()(char c) {
str.push_back(c);
}
void operator()(std::string_view buf) {
str.append(buf.data(), buf.size());
}
};
// 待格式化整数
template<class Int>
struct FormattedInt {
Int n;
uint8_t radix; // 进制
CharCase charCase; // 大小写
uint8_t padTo; // 补齐位数,0 表示不补齐
};
/**
* https://stackoverflow.com/questions/27375089/what-is-the-easiest-way-to-print-a-variadic-parameter-pack-using-stdostream
*/
#define X_FMT_IMPLEMENT_FORMAT template<class... Args>\
static void append_all(StringPusher& push, Args&&... args) {\
using _expander = int[];\
(void)_expander{ (append(std::forward<Args>(args), push), 0)... };\
}\
template<class... Args>\
std::string operator()(Args&&... args) {\
std::string res;\
StringPusher push(res);\
append_all(push, args...);\
return res;\
}
struct BasicFormat {
// 字符串
static void append(char c, StringPusher& push) {
push(c);
}
static void append(const char* s, StringPusher& push) {
push(s);
}
static void append(std::string_view s, StringPusher& push) {
push(s);
}
static void append(const std::string& s, StringPusher& push) {
push(s);
}
// 整数
template<class Int, typename std::enable_if_t<std::is_integral_v<Int>>* = nullptr>
static void append(Int n, StringPusher& push) {
x::ser::formatInt(n, 10, CharCase::Lower, 0, push); // 省略 formatInt 定义
}
template<class Int>
static FormattedInt<Int> pad(uint8_t n, Int x) {
return FormattedInt<Int> { x, 10, CharCase::Lower, n };
}
template<class Int>
static void append(FormattedInt<Int> fn, StringPusher& push) {
x::ser::formatInt(fn.n, fn.radix, fn.charCase, fn.padTo, push); // 省略 formatInt 定义
}
X_FMT_IMPLEMENT_FORMAT
};
2023-12-26 00:00:00
读了我的上一篇文章的读者可能仍然会觉得状态机,或者说 Puhser 这个概念太抽象,不知道该怎么用。因此我在这篇文章中讲一个实际的例子。
考虑一个常见的数据汇总问题:有一个文件,每行是一个数据,每行数据中包含一个日期和一些统计数字(比如销售额、用户活跃数、用户付费等),需要按月度汇总这些统计数字,并且输出到另一个文件。
我们还有一些限制条件和设计要点:
这个问题我们一般称为“resample”,在处理时间序列型数据时非常常见。著名的 Python 数据处理库 pandas 还有专门的教程页面。
回到函数式编程,哪种序列变换函数对这个问题最合适?可以一步步地分解思考这个问题:
可见这里的重点是,如何将序列中相邻的且 key 相同的元素分组,有什么现成的函数吗?
我首先想到的是,这个需求有点像 GroupBy ,但 GroupBy 跟这个需求有一些不同:
Map[Key, Item[]]
,而我们需要输出另一个序列然后我找到一个看上去很接近的 lo.PartitionBy ,但仔细观察后发现,这个函数会调整元素的顺序,依然不是我们需要的。
所以只能自己实现了,我称之为“SeqGroupBy”:
type Pusher[T any] interface {
Push(T)
Flush()
}
type PusherSeqGroupBy[K comparable, A any] struct {
next Pusher[[]A]
getKey func(A) K
curKey K
items []A
}
func NewPusherSeqGroupBy[K comparable, A any](getKey func(A) K, next Pusher[[]A]) *PusherSeqGroupBy[K, A] {
return &PusherSeqGroupBy[K, A]{next: next, getKey: getKey}
}
func (t *PusherSeqGroupBy[K, A]) Push(a A) {
newKey := t.getKey(a)
if len(t.items) == 0 {
t.items = append(t.items, a)
t.curKey = newKey
} else {
if newKey == t.curKey {
t.items = append(t.items, a)
} else {
t.next.Push(t.items)
t.curKey = newKey
t.items = []A{a}
}
}
return
}
func (t *PusherSeqGroupBy[K, A]) Flush() {
if len(t.items) > 0 {
t.next.Push(t.items)
t.next.Flush()
}
t.items = nil
return
}
构造一个 PusherSeqGroupBy 需要传入两个参数:
我们可以画出程序的数据流图(dataflow diagram)如下:
按行读文件 → SeqGroupBy 分组 → 每个组放进汇总器 → 每条汇总结果输出
每个右箭头“→”都对应了一个 Pusher.Push() 的调用。最终可以实现内存中最多持有一个汇总组的数据,做到了随用随销毁。
程序的剩余部分已经很显然了,我想可以留给读者自己完成。从这个例子也可以看出为什么 Pusher 的接口定义中需要有 Flush 。
这个例子我们还可以看出基于状态机的,或 push-style 函数式编程的一个特点:如果说基于迭代器的函数式编程是以数据的源(source)为基础,经过层层变换,最终输出到数据的汇(sink);那么基于状态机的函数式编程则是反过来将数据的汇(sink)层层包装,最后接入数据源。当然,实际上我们也可以同时使用这两种风格,既用迭代器把源变形,又用状态机把汇变形,然后在中间的某个地方拼接在一起,这样,我们可以自由地选择最合适的工具。
2023-12-22 00:00:00
在编程中,我们常用的迭代器(iterator)与我今天要说的状态机(state machine)都是对“流”(stream)的抽象,只不过一个是拉(pull),一个是推(push)。
迭代器的定义一般是这样的:
class Iterator[T] {
T next();
}
即调用一个函数,它不断返回流中的下一个元素。通常还需要其他机制来指示迭代结束,比如添加一个 bool hasNext() 方法(Java),返回两个值,第二个 bool 值表示迭代是否结束(js),抛一个特定异常(Python)等。
任何数据流都有生产者(Producer)和消费者(Consumer),迭代器是生产者的一种抽象,那如果我们把消费者抽象出来,会是什么样的?
我这篇文章还是使用计算机科学的术语,称其为状态机。我理解的状态机的接口定义是这样的:
class Pusher[T] {
void Push(T); // 将一个元素推给状态机
void Flush(); // 标记一个阶段结束
}
如果跟 Go 语言中的 io.Writer 接口对比,是不是非常相似?事实上,我认为 Go 的 io.Reader 可以类比为迭代器,io.Writer 可以类比为状态机。
下面就是有意思的部分了:如果我们可以在迭代器的接口上实现各种函数式编程的操作(如 filter, map ),那我们能否在状态机的接口上实现同样的操作呢(只不过是以 push 的视角)?答案是肯定的。
当然,Rx 框架也可以看作一种实现,只不过我认为它太复杂了,还不如我自己实现。
下面是我用 Go 语言实现的简易 Pusher 及其 filter 定义:
type Pusher[T any] interface {
Push(T)
Flush()
}
type PusherFilter[A any] struct {
next Pusher[A]
filter func(A) bool
}
func NewPusherFilter[A any](f func(A) bool, next Pusher[A]) *PusherFilter[A] {
return &PusherFilter[A]{next, f}
}
func (t *PusherFilter[A]) Push(a A) {
if t.filter(a) {
t.next.Push(a)
}
}
func (t *PusherFilter[A]) Flush() {
t.next.Flush()
}
使用:
pusher := NewPusherFilter(func(item *Item) bool { return productSet[item.Product] }, files)
这种东西有什么用?我们通常更习惯使用迭代器和基于迭代器的函数式操作,但很多时候,设计一个基于迭代器的流式接口可能是很麻烦的。考虑一个例子:遍历文件目录树,流式地返回每个文件的信息。如果一定要做成迭代器,调用一个函数才返回下一个文件信息,那我们不得不手动构造一个栈(stack)来保存中间状态,程序写起来并不直观。
但如果我们用状态机来设计这个东西的接口又如何?我们可能会得到如下接口:
void walk_dir(string dirname, Pusher[FileInfo] pusher);
这样做的好处是让 walk_dir
可以用递归来实现。这里的 pusher 就是一个状态机,你也可以把它近似地看成一个回调函数,但跟回调函数的不同之处在于,Pusher 是可组合(composable)的!我们可以通过 map 、filter 等组合子(combinators)构造出新的匿名 Pusher 。这种可组合性(composability)跟迭代器如出一辙。当然也有不好的一面:失去了迭代器的可以在中间任意位置停下来的能力。
回到 Go 语言本身,由于 Go 语言的迭代器接口还遥遥无期,我这里提出的 Pusher 是一种围魏救赵的办法。关于 pull 和 push 的对偶性(duality),也可以参考 Go 语言核心开发者 rsc 的讨论:user-defined iteration using range over func values · golang/go · Discussion #56413 。
相关文章:
2023-08-17 00:00:00
筛选即 Curated Computing ,也就是像苹果生态这种“所有软件只能从一个经过筛选的应用商店下载”的模式。
编辑指 Editor ,在以前的杂志、报纸时代,人们看的东西都经过编辑编辑,编辑的权力甚至比作者还大,有时可以决定部分内容(想想《三体》和《科幻世界》杂志的编辑,再想想日本漫画杂志编辑对漫画连载内容的影响)。不过随着互联网的兴起,编辑似乎越来越被人们轻视。
以下引用自《科技创业者不是好编辑》:
她们认为自己可以做编辑,但是又打心眼里轻视编辑。这种轻视就她们的立场而言是正确的,因为编辑就是反规模化。
还是去读真正的编辑编出来的东西吧。
过去信息匮乏的时代,重要的是多找信息;而在如今这个信息过剩的时代,重要的是从海量的信息中筛选出真正有价值的东西。而这项工作,是可以外包的,因为别人做得可能比我们自己做得更好,而且可以节省我们的时间。
做这种工作的人就是编辑。
领域 | 筛选 | 不筛选 |
---|---|---|
软件 | 苹果 | 安卓 / Windows |
信息获取 | 杂志 | 网络 |
论坛 | 控评 | 自由发言 |
政治 | 独裁 | 民主 |
选择不筛选就是选择自由,你可以做任何事,比如在你的设备上安装任何软件,但你也要承担相应的责任或代价:你要为自己设备的安全负责。下载到流氓软件?中勒索病毒?微软公司会告诉你:你要自己负责,至少学习一下如何安全使用计算机。但苹果公司告诉你:交给我们吧,你只能用我们挑选过的这些应用,但你不必再劳心费神地负责电脑的安全了。我相信这对很多人来说是一种理性的选择,可能也是更好的选择。
以前我更相信自由,现在我明白,很多时候,限制自由可能是更好的选择。当然前提是,你有选择筛选或者不筛选的自由。
再比如网络文章,理论上你可以看网络上所有的东西,但实际上你能做到吗?所以我们一定要有某种筛选机制。这种信息的筛选可以由人——编辑来完成,也可以由机器——推荐算法来实现。
现在流行的信息流推荐,背后隐含的假设是,编辑这项工作,推荐算法可以做得比人更好。但经过这么多年对网络的观察,我认为,这个假定可能是不正确的。
很多基于算法推荐的网站让我感觉推荐的内容质量很差,所以我还是更倾向阅读博客或听播客,也就是让另一个人来告诉我上网应该去看什么、了解什么,而不是推荐算法。
编辑是有观点(不中立)但有品味的,每个人都有自己的个性,你能从编辑筛选出来的文章上学到一个人的品味和个性。而推荐算法在我看来是无情的、“中立”的,“客观”的、没有观点,同时也没有品味。
再比如网络社区建设:让所有人都自由地发言,就能够自发出现一个良好互动的社区吗?这很可能会让社区变成人们倾倒情绪垃圾的“垃圾场”,比如某著名网络垃圾场,所以我认为一定程度的管理还是有必要的。
另一个例子:我发现 QQ 音乐有一些歌是 VIP ,另外一些不是。如果这些 VIP 歌曲是经过 QQ 音乐内部的人工标注,这何尝又不是一种筛选呢?
汉娜·阿伦特在她的《人的境况》中提出了著名的劳动、工作、行动三分法,这启发了我:很多时候事物不是两分的,而可能是三分的。
领域 | 筛选 | 中间地带 | 不筛选 |
---|---|---|---|
软件 | 苹果 | 社区软件仓库,如 F-Droid, Chocolatey | 用户自由下载安装软件 |
信息获取 | 官媒 | 民间杂志 | 网络 |
论坛 | 站长控评 | 版主自治 | 自由发言 |
政治 | 独裁 | 民主 | 无政府主义 |
这里推荐一下我最近看到的一篇文章:The Federation Fallacy
这篇文章告诉我们,从电子邮件、BT 下载,再到最近的长毛象,每次人们都心怀“去中心化之梦”创建一种社区,但每次都失败了。最后的结果证明,互联网的中心化是不可避免的。似乎人们从来没能从历史中学习到经验教训。
原因在于,所谓“去中心化”的理想固然美好,但对每个人的技术要求未免太高。
每个人理论上都可以部署自己的电子邮件服务器,但现在有多少人在用自己的邮件服务器收发邮件?
每个人理论上都可以部署自己的长毛象实例,但又有多少人有技术能力去维护它?
BT 下载对技术能力的要求够低了吧,只要打开一个软件一直挂着就行。为什么一样衰落了?(提示:做种需要带宽、磁盘、电费、法务)
所以作者在文章中提出,也许现实中更可行的,并不是完全的去中心化,而是中心化的网站 + 民主的治理机制。作者举的例子是维基百科。我认为广义上也包括各种维基(比如 Fandom)。
我能想到的例子是过去的论坛有站长,站长可以为每个版选择版主,论坛坛友也可以自荐或投票产生版主。
其他例子包括众包做 minecraft 地图、开源社区管理等等。
世界上不只有两条路,还有第三条路等待我们去发现。
P.S. 有什么不错的网络杂志?我认为作为一本面向程序员的杂志,阮一峰的网络日志就不错。关键是它的创作机制:可以由读者投稿,再由编辑挑选。有人说阮一峰的东西深度不够,但我认为,作为编辑,阮一峰的工作是不错的。
2023-08-13 00:00:00
说起来你可能不信,我最近在做个人项目的时候,放弃了 Ruby 和 F# ,而选择 Go 语言。下面我将说明这样做的理由。
首先我确实需要一门带 GC 的语言来做一些 fast prototyping 式的开发。我对这门语言的期望是,可以快速开发,有一定表达力。
之前我的快速开发首选语言是 Ruby 。但我最近越来越发现 Ruby 的问题:
所以我希望找一门静态语言。我一开始选择了 F# 。因为:
当时当我真正开始写程序的时候,我查了 .net 的文档,尤其是实现 http server 的 HttpListener 。我发现 F# 的问题是:标准库跟 C# 共享一套,而 C# 的标准库基本上是学 Java ,充斥着 OOP 设计。他们并没有利用 F# 的函数式特性专门为 F# 设计一套至少涵盖 IO 、 HTTP 、 JSON 的标准库。所以我开始转向 Go 。
如果我写 F# ,我要忍受的就是这充斥着面向对象遗毒的标准库。而如果我写 Go ,我要忍受的是这简陋不堪的语法。
两相其害取其轻,我更愿意忍受 Go 的语法(何况 Go 1.18 还支持了泛型)。Go 的语法虽然简陋,但够用。
对我来说,Go 的加分项是它的标准库采用可组合接口(composable interfaces)的设计,我认为非常漂亮,我自己写 C 代码的时候,常常参考 Go 标准库的设计。
任何实用的程序不可能没有抽象,动态多态(polymorphism)的抽象方法一般有两种:
还有一个比较特殊的 C 语言,这两种范式都能支持,不过都要自己模拟实现。
如果你的程序需要动态分派(dynamic dispatch),你必然需要为抽象付出某种代价。重点是,这个代价是由哪部分代码来支付?
在面向对象的语言中,代价由实现接口的类定义来支付。其形式就是虚表。例如,C++ 中的每个对象,如果继承了某个带 virtual 方法的基类,就会在对象的开头多出一个指向虚表的指针。
而在可组合接口的语言(范式)中,代价是由接口的使用方来支付的。使用方需要两个指针,一个指向对象,一个指向虚表。
举个例子,很多时候我们都会遇到一个任务:如何将任意对象转为字符串?
假设我们定义了一个接口 Stringable 表示可以转为 String:
interface Stringable {
string toString();
}
在传统的面向对象的编程语言中,每一个我们希望它可以转为字符串的类,比如 Integer 、Date 等,都需要实现这个接口。所以我们不如搞出一个超级基类,然后在这个超级基类上定义一个 toString 方法(想想 Java 的 Object.toString)。
但问题在于,这种设计不具备可扩展性。如果以后出现了 JSON 、 BSON 或者其他某种序列化格式,难道每种格式都要往这个基类上添加一个方法吗?很多时候基类定义在基础库中,我们不可能修改它的定义。
这个问题的另一种解决方案是,不要折腾定义方了,我们折腾使用方。
比如我定义了一个 Date 类,上面根本没有什么 toString:
class Date {
int year, month, day;
}
但是在使用这个 Date 的地方,传入两个指针:一个是 Date 对象本身,另一个是 Date_toString ,即将这个对象转为 String 的函数(指针)。
void printDate(Date d, func dateToString);
这里的 dateToString 是一个函数,它的使用方法是,传入一个 Date 对象,返回一个 String 。
这样,对象和接口就分离开了,以后如果需要 toJSON 、 toBSON ,也不用修改原始的 Date 对象的源代码,可以把这些代码放在新的模块。
面向对象的核心理念是:数据和相关的操作应该绑定在一起。但从这个例子我们可以看到,在很多情况下,数据和操作应该是分离的,强行绑定在一起会增加不必要的耦合。从我自己的编程实践来看,对于一些高层次的模块,数据和操作分离的话可以让代码更容易复用。
也许有人会说:用 Java 也可以这样写程序啊。但是 Java 的整个标准库都是围绕面向对象来设计的,已经积重难返。而 Go 语言没有历史包袱,标准库是完全围绕可组合接口来设计的,所以我认为 Go 的标准库非常值得学习。
网上经常能看到的对 Go 的另一个抱怨是错误处理不好用。我认为 Go 的错误处理相比异常,使用体验上其实差不多。
而且在使用 Go 的过程中,我对错误处理又有一些新理解。
我们可以把错误分成 3 类:
用户输入错误(包括 URL 参数、配置文件等输入),这类错误需要返回、展示给用户。这类错误最好不要用编程语言内置的错误类型来表示,而是用自定义的类型,比如一个 struct ,或者最简单的一个 string ,又或者用类型中的空值来表示错误,例如 ““, -1, nil 等
程序错误。又可以分成 2 类:
看到了吗,我认为只有最后那种错误才适合用 error ,其他错误要么用自定义类型,要么直接 fail-fast 。如果用这种思路来处理错误,我认为 Go 的“将错误作为值”的方式并不难用。
你要做的是更多地使用如下代码片段来 fail-fast:
func Ok(err error) {
if (err != nil) {
panic(err)
}
}
选择个人项目的语言其实是很私人的事情,重点是,你能从使用这门语言的过程中学习到什么。我从 Go 语言学习到的主要是如何使用可组合的接口设计标准库,这对我来说很有帮助,这就足够了。
P.S. 因为同样的原因,我的个人项目的“重型语言”也从 C++ 转向 C 了。
P.S.2 现在甚至我的一些 bash 脚本都开始用 Go 写了,用 Go 写这类运维脚本的优势在于:可以很容易地利用多核并行。