简单实现 C++ 字符串格式化

字符串格式化是很常见的功能,传统上,我们使用 C 语言的 printf 来格式化。但作为一位 C++ 爱好者,printf 的缺点也很明显: 非类型安全 无法添加自定义类型   std::cout 的问题在于: 进制和 padding 是通过设置流的全局状态实现的 std::ostringstream 的 str() 方法会复制底层的 buffer ,不够高效 无法将结果追加到一个现有的字符串上,只能新建字符串再合并,会多一次拷贝   流行的新一代格式化库如 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 就用的这种 模板偏特化 + 后期 namespace 写入 继承   我一开始用的是模板偏特化的方法,但这种方案的问题是,相关定义是全局的。所以对某一个自定义类型,同一个程序里只能定义一种格式化方法。而且 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 };

2024/4/28
articleCard.readMore

状态机与函数式编程(二)

读了我的上一篇文章的读者可能仍然会觉得状态机,或者说 Puhser 这个概念太抽象,不知道该怎么用。因此我在这篇文章中讲一个实际的例子。   考虑一个常见的数据汇总问题:有一个文件,每行是一个数据,每行数据中包含一个日期和一些统计数字(比如销售额、用户活跃数、用户付费等),需要按月度汇总这些统计数字,并且输出到另一个文件。   我们还有一些限制条件和设计要点: 文件已经按照日期从小到大排序 输出时也应按日期从小到大排序 每个时间分组中的数据行的数量并不确定,例如一个月可能有 31 天,也可能有 28 天 这个文件很大,为了控制内存的使用,不能把所有行读进一个大数组里处理 扩展性:如果以后要求不仅仅是汇总每月,还要按每周、每季度、每年汇总,能否很方便地支持?能否支持任意自定义的日期汇总方式? 扩展性2:汇总方法可能不仅仅是求和,还可能是求平均、求中位数、求标准差之类的,能否方便地支持自定义? 扩展性3:可能在其他部分的代码中也有这种“按时间统计汇总”的需求,能否抽象出一个公共函数 / 类?   这个问题我们一般称为“resample”,在处理时间序列型数据时非常常见。著名的 Python 数据处理库 pandas 还有专门的教程页面。   回到函数式编程,哪种序列变换函数对这个问题最合适?可以一步步地分解思考这个问题: 我们需要一个将原始日期变换成汇总后日期的函数,例如将“2023-10-06”变换成“2023-10”,即提取出“月”的部分 上一步得到的值可以看作一个 key ,我们需要将序列中相邻的且 key 相同的元素放进一个组中 对每个组进行汇总,然后输出   可见这里的重点是,如何将序列中相邻的且 key 相同的元素分组,有什么现成的函数吗?   我首先想到的是,这个需求有点像 GroupBy ,但 GroupBy 跟这个需求有一些不同: GroupBy 的输出结果是一个 Map[Key, Item[]] ,而我们需要输出另一个序列 GroupBy 是全局的,它会把所有 key 相同的汇总,而这个问题中,我们需要汇总的是 key 相同且相邻的元素   然后我找到一个看上去很接近的 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 需要传入两个参数: getKey: 从一行数据中提取出 key ,这个函数的实现是,先从一行数据中提取出日期,再把日期变成汇总后的日期,比如按月的话就是将“2023-10-06”变成“2023-10” next: 数据需要传给的下一个处理环节,类型是 Pusher[[]Row] 。接受一个已经分好组的数据组,汇总然后输出   我们可以画出程序的数据流图(dataflow diagram)如下:   按行读文件 → SeqGroupBy 分组 → 每个组放进汇总器 → 每条汇总结果输出   每个右箭头“→”都对应了一个 Pusher.Push() 的调用。最终可以实现内存中最多持有一个汇总组的数据,做到了随用随销毁。   程序的剩余部分已经很显然了,我想可以留给读者自己完成。从这个例子也可以看出为什么 Pusher 的接口定义中需要有 Flush 。   这个例子我们还可以看出基于状态机的,或 push-style 函数式编程的一个特点:如果说基于迭代器的函数式编程是以数据的源(source)为基础,经过层层变换,最终输出到数据的汇(sink);那么基于状态机的函数式编程则是反过来将数据的汇(sink)层层包装,最后接入数据源。当然,实际上我们也可以同时使用这两种风格,既用迭代器把源变形,又用状态机把汇变形,然后在中间的某个地方拼接在一起,这样,我们可以自由地选择最合适的工具。

2023/12/26
articleCard.readMore

状态机与函数式编程

在编程中,我们常用的迭代器(iterator)与我今天要说的状态机(state machine)都是对“流”(stream)的抽象,只不过一个是拉(pull),一个是推(push)。   迭代器的定义一般是这样的: class Iterator[T] { T next(); }   即调用一个函数,它不断返回流中的下一个元素。通常还需要其他机制来指示迭代结束,比如添加一个 bool hasNext() 方法(Java),返回两个值,第二个 bool 值表示迭代是否结束(js),抛一个特定异常(Python)等。   任何数据流都有生产者(Producer)和消费者(Consumer),迭代器是生产者的一种抽象,那如果我们把消费者抽象出来,会是什么样的? 在计算机科学中,称这种东西称为状态机(state machine)或自动机(automata) Rx 框架称其为 Observer ,也就是可以订阅由 Observable 发出的事件的东西,由 OnNext, OnComplete, OnError 三个回调函数构成,只不过 Rx 框架更强调自己是异步的 一个更函数式的抽象是从 (状态, 输入) 变换成新的状态,比如 reducer Haskell 将它抽象为 iteratee   我这篇文章还是使用计算机科学的术语,称其为状态机。我理解的状态机的接口定义是这样的: 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 。 相关文章: C++ 中的 Continuation-Passing Style。 RxJS 应用之流式读取文件

2023/12/22
articleCard.readMore

筛选、编辑与网络社区

筛选即 Curated Computing ,也就是像苹果生态这种“所有软件只能从一个经过筛选的应用商店下载”的模式。   编辑指 Editor ,在以前的杂志、报纸时代,人们看的东西都经过编辑编辑,编辑的权力甚至比作者还大,有时可以决定部分内容(想想《三体》和《科幻世界》杂志的编辑,再想想日本漫画杂志编辑对漫画连载内容的影响)。不过随着互联网的兴起,编辑似乎越来越被人们轻视。   以下引用自《科技创业者不是好编辑》: 她们认为自己可以做编辑,但是又打心眼里轻视编辑。这种轻视就她们的立场而言是正确的,因为编辑就是反规模化。 还是去读真正的编辑编出来的东西吧。   过去信息匮乏的时代,重要的是多找信息;而在如今这个信息过剩的时代,重要的是从海量的信息中筛选出真正有价值的东西。而这项工作,是可以外包的,因为别人做得可能比我们自己做得更好,而且可以节省我们的时间。   做这种工作的人就是编辑。 领域 筛选 不筛选 软件 苹果 安卓 / Windows 信息获取 杂志 网络 论坛 控评 自由发言 政治 独裁 民主   选择不筛选就是选择自由,你可以做任何事,比如在你的设备上安装任何软件,但你也要承担相应的责任或代价:你要为自己设备的安全负责。下载到流氓软件?中勒索病毒?微软公司会告诉你:你要自己负责,至少学习一下如何安全使用计算机。但苹果公司告诉你:交给我们吧,你只能用我们挑选过的这些应用,但你不必再劳心费神地负责电脑的安全了。我相信这对很多人来说是一种理性的选择,可能也是更好的选择。   以前我更相信自由,现在我明白,很多时候,限制自由可能是更好的选择。当然前提是,你有选择筛选或者不筛选的自由。   再比如网络文章,理论上你可以看网络上所有的东西,但实际上你能做到吗?所以我们一定要有某种筛选机制。这种信息的筛选可以由人——编辑来完成,也可以由机器——推荐算法来实现。   现在流行的信息流推荐,背后隐含的假设是,编辑这项工作,推荐算法可以做得比人更好。但经过这么多年对网络的观察,我认为,这个假定可能是不正确的。   很多基于算法推荐的网站让我感觉推荐的内容质量很差,所以我还是更倾向阅读博客或听播客,也就是让另一个人来告诉我上网应该去看什么、了解什么,而不是推荐算法。   编辑是有观点(不中立)但有品味的,每个人都有自己的个性,你能从编辑筛选出来的文章上学到一个人的品味和个性。而推荐算法在我看来是无情的、“中立”的,“客观”的、没有观点,同时也没有品味。   再比如网络社区建设:让所有人都自由地发言,就能够自发出现一个良好互动的社区吗?这很可能会让社区变成人们倾倒情绪垃圾的“垃圾场”,比如某著名网络垃圾场,所以我认为一定程度的管理还是有必要的。   另一个例子:我发现 QQ 音乐有一些歌是 VIP ,另外一些不是。如果这些 VIP 歌曲是经过 QQ 音乐内部的人工标注,这何尝又不是一种筛选呢? 寻找中间地带   汉娜·阿伦特在她的《人的境况》中提出了著名的劳动、工作、行动三分法,这启发了我:很多时候事物不是两分的,而可能是三分的。 领域 筛选 中间地带 不筛选 软件 苹果 社区软件仓库,如 F-Droid, Chocolatey 用户自由下载安装软件 信息获取 官媒 民间杂志 网络 论坛 站长控评 版主自治 自由发言 政治 独裁 民主 无政府主义   这里推荐一下我最近看到的一篇文章:The Federation Fallacy   这篇文章告诉我们,从电子邮件、BT 下载,再到最近的长毛象,每次人们都心怀“去中心化之梦”创建一种社区,但每次都失败了。最后的结果证明,互联网的中心化是不可避免的。似乎人们从来没能从历史中学习到经验教训。   原因在于,所谓“去中心化”的理想固然美好,但对每个人的技术要求未免太高。   每个人理论上都可以部署自己的电子邮件服务器,但现在有多少人在用自己的邮件服务器收发邮件?   每个人理论上都可以部署自己的长毛象实例,但又有多少人有技术能力去维护它?   BT 下载对技术能力的要求够低了吧,只要打开一个软件一直挂着就行。为什么一样衰落了?(提示:做种需要带宽、磁盘、电费、法务)   所以作者在文章中提出,也许现实中更可行的,并不是完全的去中心化,而是中心化的网站 + 民主的治理机制。作者举的例子是维基百科。我认为广义上也包括各种维基(比如 Fandom)。   我能想到的例子是过去的论坛有站长,站长可以为每个版选择版主,论坛坛友也可以自荐或投票产生版主。   其他例子包括众包做 minecraft 地图、开源社区管理等等。 总结   世界上不只有两条路,还有第三条路等待我们去发现。 P.S. 有什么不错的网络杂志?我认为作为一本面向程序员的杂志,阮一峰的网络日志就不错。关键是它的创作机制:可以由读者投稿,再由编辑挑选。有人说阮一峰的东西深度不够,但我认为,作为编辑,阮一峰的工作是不错的。

2023/8/17
articleCard.readMore

谈谈我最近的编程语言选择

说起来你可能不信,我最近在做个人项目的时候,放弃了 Ruby 和 F# ,而选择 Go 语言。下面我将说明这样做的理由。 缘起   首先我确实需要一门带 GC 的语言来做一些 fast prototyping 式的开发。我对这门语言的期望是,可以快速开发,有一定表达力。   之前我的快速开发首选语言是 Ruby 。但我最近越来越发现 Ruby 的问题: 社区越来越不活跃了,似乎人们在慢慢离开 Ruby ,很多 gems 不怎么维护了 作为动态类型语言,写出来的代码的长期可维护性不太好   所以我希望找一门静态语言。我一开始选择了 F# 。因为: 函数式,有强大的类型系统,非常强的表达力 背靠 .net 社区,再不济还有微软撑着,标准库不会差到哪里去   当时当我真正开始写程序的时候,我查了 .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)的抽象方法一般有两种: 面向对象、继承、封装。一个类必须声明自己实现了哪些接口。实现上采用虚表(vtable)。代表语言有 C++、Java、Python、C# 可组合的接口。一个类不需要声明自己实现了哪些接口。类实现某个接口的代码跟类的代码可以是分开的。实现上采用胖指针(fat pointer)。代表语言有 Rust、Go、Haskell(不知道类型类算不算)   还有一个比较特殊的 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 的错误处理相比异常,使用体验上其实差不多。   而且在使用 Go 的过程中,我对错误处理又有一些新理解。   我们可以把错误分成 3 类: 用户输入错误(包括 URL 参数、配置文件等输入),这类错误需要返回、展示给用户。这类错误最好不要用编程语言内置的错误类型来表示,而是用自定义的类型,比如一个 struct ,或者最简单的一个 string ,又或者用类型中的空值来表示错误,例如 ““, -1, nil 等 程序错误。又可以分成 2 类: 意料之外的、不可恢复的错误。这类错误最好的处理方法是 fail-fast ,打印一个调用栈之后退出 程序员意料之内的,可以恢复或者重试的错误。比如网络错误。这类错误用语言自带的机制(如 Go 的 error 或其他语言的异常)来表示,可以在函数之间返回、传递、保存   看到了吗,我认为只有最后那种错误才适合用 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 写这类运维脚本的优势在于:可以很容易地利用多核并行。 相关链接 Golang和Rust的胖指针与C++的指针指向虚表哪种设计更好? 为什么go和rust语言都舍弃了继承? Were fat pointers a good idea? 相关文章 盘点我用过的编程语言

2023/8/13
articleCard.readMore

我的金融投资书单

在我上大学的时候,我对金融投资漠不关心,后来我慢慢意识到,这些东西就像政治——你不关心政治,政治关心你。   读了一些历史书,我也知道金融是怎么回事了。自从 1970 年美元与黄金脱钩,世界就进入了主权货币时代。我们每个人,只要使用货币,实际上都会被收一种隐形的铸币税——通货膨胀。历史告诉我们,通胀和通缩都是痛苦的,但通缩更痛苦。只要今天、我这一届政府你好我好,谁会管明天、下一届如何?所以各国政府,实际上总是倾向于货币宽松的,也就是通胀。   通货膨胀会导致你持有的货币的购买力下降。假设每年货币购买力下降 6% ,看似不多,但累计 30 年之后呢?   所以在我看来,金融投资是每个人都应该了解的。但投资是一件专业的事,进入市场之前,你应该先学习。我从 2015 年开始投资,根据我最近几年自己投资的经验,这次就推荐一些我认为不错的金融投资类书籍吧。 其他博主   这里推荐我的入门老师:ETF 拯救世界。个人认为指数投资是最适合普通人的投资方式。 微信公众号:长赢指数投资 / chinaetfs 雪球:ETF拯救世界   他的粉丝有时将他尊称为“E大”。我推荐看看: E 大雪球置顶的干货合集 微信公众号“精华回顾”栏目,里面有 E 大的投资书籍推荐,还有公众号的精华文章 入门 货币的教训 - 周其仁教授力作,可以进一步了解通货膨胀及其对普通人的影响 股市长线法宝 - E 大推荐,这本书可以告诉你我们为什么应该投资股票以及为什么应该投资指数 机构投资的创新之路 - 建立资产配置的概念,对 E 大的体系是一个很好的补充 投资心理学 - E 大推荐,最好有一定投资经验再看,你会发现里面的各种心理陷阱你也经常犯 进阶 Your Complete Guide to Factor-Based Investing - 了解因子投资 积极型资产配置指南 - 了解经济周期 期权、期货及其他衍生产品 - 了解衍生品

2023/5/3
articleCard.readMore

谈 xxxholic

这大概是我第二遍完整地看完 xxxholic 的动画和漫画吧。看完之后五味陈杂,有种翻江倒海的感觉,用“三日不知肉味”来形容也不过分。   xxxholic 讲的还是 CLAMP 的一贯主题:爱,或者说感情,是没有道理的。或者说主旨就是展示各式各样的感情。   虽然 xxxholic 跟翼年代记是有联系的,不过我认为分析 xxxholic 还是应该回到只有 xxxholic 才有、只对 xxxholic 本身来说最重要的剧情上。所以我将忽略跟翼年代记连接的、关于四月一日身世的那部分剧情。我认为那部分剧情只能说是一个背景设定,其实无关 xxxholic 的主题。   xxxholic 包含两部分剧情:一是讽喻现实、警醒世人的寓言,也就是各式各样的客人到店里,侑子小姐实现他们的愿望的剧情。另一部分即是所谓的“主线”剧情,是四月一日的成长,以及他跟周围的人的关系如何发展的故事。   第一部分的主旨是告诉读者,不要以自我为中心,也不要以“我们人类”为中心,而应该站在各种各样不同事物的立场上看待问题。在蜘蛛的故事中,侑子小姐说过一段话,大意是,对你来说仅仅是弄掉一张蜘蛛网,但对蜘蛛来说,却是自己的“家”被毁了。这里的蜘蛛,可以理解为非我族类的非人类,也可以理解为跟自己持有不同价值观、不同立场的人。   至于主线,跟四月一日关系比较近的这几个人,也对应了主线的几条线索。明线是四月一日跟百目鬼、小羽,以及座敷童子、小狐狸等非人类,暗线是四月一日跟小葵,还有一条更暗的“背景”线则是四月一日跟侑子小姐。   四月一日跟小葵的感情尤其跌宕起伏。由于我是第二遍看,已经知道了小葵的身世,看前面部分的时候会仔细地观察画面和台词,这时才终于懂了很多之前没懂的剧情,才发现作者埋了多少伏笔。比如小葵基本上没有跟四月一日单独回家,每次都会拉上百目鬼,我原来以为这是因为小葵只把四月一日看成普通朋友。再比如小葵从来不自己做东西,都是买店里的,连加热都是妈妈,其实是因为小葵知道自己的体质。第一遍看的时候感觉对小葵这个人物的描写很寡淡,第二遍看的时候才发现对小葵的刻画全都隐藏在日常细节之中,越看越恍然大悟,对这个人物有了全新的理解。   前面有多少铺垫,最后真相大白的那一刻就有多震撼。四月一日从小葵那里听闻真相后说,我不愿意让你流泪——我认为这是整个漫画的高潮。整部漫画中,四月一日和小葵的感情也许最接近通常意义上的“爱情”:去爱是一种决定,或者说决断,跟其他事情都无关。看他们的爱情故事,让我有一种看《少年维特之烦恼》的感觉。   四月一日和百目鬼之间的感情,我认为更像亲情。   百目鬼和小羽,只有动画是明示了他们在一起,不过动画也有 CLAMP 老师的参与,可以认为是她们的意思。我认为他们是互相欣赏,然后在一起的,而不是像一些网友说的为了四月一日。只不过他们的故事是另一个故事了,作者认为不是重点,省略了这部分内容。   总的说来,我认为 xxxholic 的文学性是很高的,放眼整个日本动漫界都很难再找到类似的作品。 P.S. 本文初稿起草于 2022 年 1 月我刚看完那会

2023/5/2
articleCard.readMore

对 cursor.so 的两个简单测试

Cursor 官网:https://www.cursor.so/ ,是一个基于 ChatGPT 的代码生成和理解工具。 AI 能否将混淆过的 js 还原成正常可读的代码?   我之前听说,将一段红黑树的代码中的函数和变量名改乱,AI 照样能识别,还能给你解释这段代码。我于是触类旁通地想到,能否让 AI 帮我理解混淆过的 js 。   在编辑器里贴一段混淆过的 js ,如下: window["\x65\x76\x61\x6c"](function(p,a,c,k,e,d){e=function(c){return(c<a?"":e(parseInt(c/a)))+((c=c%a)>35?String.fromCharCode(c+29):c.toString(36))};if(!''.replace(/^/,String)){while(c--)d[e(c)]=k[c]||e(c);k=[function(e){return d[e]}];e=function(){return'\\w+'};c=1;};while(c--)if(k[c])p=p.replace(new RegExp('\\b'+e(c)+'\\b','g'),k[c]);return p;}('3x.1Z({"1s":5,"1r":"1q","1p":"5.4","1o":1n,"1m":"1l","1k":["0-1j.4.2","0-1i.4.2","0-1t.4.2","0-1h.4.2","0-1f.4.2","0-1e.4.2","0-1d.3.2","0-1c.3.2","0-1b.3.2","0-1a.3.2","0-19.3.2","0-18.3.2","0-17.3.2","0-16.3.2","0-15.3.2","0-1g.3.2","0-1u.3.2","0-1v.3.2","0-1w.3.2","0-1V.3.2","0-1U.3.2","0-1T.3.2","0-1S.3.2","0-1R.3.2","0-1Q.3.2","0-1P.3.2","0-1O.3.2","0-1N.3.2","0-1M.3.2","0-1L.3.2","0-1K.3.2","0-1J.3.2","0-1I.3.2","0-1H.3.2","0-1G.3.2","0-1F.3.2","0-1E.3.2","0-1D.3.2","0-1C.4.2","0-1B.4.2","0-1A.4.2","0-1z.4.2","0-1y.4.2","0-1x.4.2","0-1W.3.2","0-12.3.2","0-Z.3.2","0-v.3.2","0-u.3.2","0-t.3.2","0-s.3.2","0-r.3.2","0-q.3.2","0-p.3.2","0-o.3.2","0-n.3.2","0-l.3.2","0-k.3.2","0-j.3.2","0-i.3.2","0-h.3.2","0-g.3.2","0-f.3.2","0-d.3.2","0-c.3.2","0-b.3.2","0-a.3.2","0-9.3.2","0-8.3.2","0-7.3.2","0-6.3.2","0-w.3.2","0-y.3.2","0-11.3.2","0-A.3.2","0-z.3.2","0-Y.3.2","0-X.3.2","0-W.3.2","0-V.3.2","0-U.3.2","0-T.3.2","0-S.3.2","0-R.3.2","0-Q.3.2","0-P.3.2","0-O.3.2","0-N.3.2","0-M.3.2","0-L.3.2","0-K.3.2","0-J.3.2","0-I.3.2","0-H.3.2","0-G.3.2","0-F.3.2","0-E.3.2","0-D.3.2","0-C.3.2","0-B.3.2","0-14.3.2","0-1X.3.2","0-2m.3.2","0-1Y.3.2","0-3g.3.2","0-3f.3.2","0-3e.3.2","0-3d.3.2","0-3c.3.2","0-3b.3.2","0-3a.3.2","0-39.3.2","0-38.3.2","0-37.3.2","0-36.3.2","0-3h.3.2","0-35.3.2","0-33.3.2","0-32.3.2","0-31.3.2","0-30.3.2","0-2Z.3.2","0-2Y.3.2","0-2X.3.2","0-2W.3.2","0-2V.3.2","0-2U.3.2","0-3j.3.2","0-34.3.2","0-3i.3.2","0-3v.3.2","0-3k.3.2","0-3z.3.2","0-3A.3.2","0-3B.3.2","0-3C.3.2","0-3D.3.2","0-3F.3.2","0-3K.3.2","0-3G.3.2","0-3H.3.2","0-3I.3.2","0-3J.3.2","0-3y.3.2","0-3E.3.2","0-3w.3.2","0-3u.3.2","0-3t.3.2","0-3s.3.2","0-3r.3.2","0-3q.3.2","0-3p.3.2","0-3o.3.2","0-3n.3.2","0-3m.3.2","0-3l.3.2","0-2S.3.2","0-2T.3.2","0-2Q.3.2","0-2C.3.2","0-2R.3.2","0-2l.3.2","0-2k.3.2","0-2j.3.2","0-2i.3.2","0-2h.3.2","0-2g.3.2","0-2f.3.2","0-2e.3.2","0-2d.3.2","0-2c.3.2","0-2b.3.2","0-2a.3.2","0-29.3.2","0-28.3.2","0-27.3.2","0-26.3.2","0-25.3.2","0-24.3.2","0-23.3.2","0-22.3.2","0-21.3.2","0-20.3.2","0-2n.3.2","0-2o.3.2","0-2p.3.2","0-2q.3.2","0-2O.3.2","0-2N.3.2","0-2M.3.2","0-2L.3.2","0-2K.3.2","0-2J.3.2","0-2I.3.2","0-2H.3.2"],"2G":2F,"2E":2P,"2D":"/2B/x/2A/10/","2z":1,"2y":"","2x":2w,"2v":2u,"2t":{"e":2s,"m":"2r"}}).13();',62,233,'BoZwngpgJgtgjABgD5IO4QEYAclYHYDmSAVlkQMwAcCArEguQOz3kBsGLrLNHDdDAFl7kBLchzEsATMKks4wuCwTDk9KQE5em9ZW2V1jbcwRT26rqZ4sjKBgYZaWG+jQWultFa+QIBTvxc/PXoBBwFbPxMBcz9LAWs/fgEhUNE/cVDyUJkc0Pc/TwFvP18pOhYQ01SsACcIAEk8AEsAF1c5BAQghCre+gRIwYHYhEtaXloB1K707snMrs6EJQAzZoAbCBAkQBpvREB3ZSQAYzwAQxgIJDgNDRFmY+aoJGxm46RABudAMcjAFA9AGLlAMdygDu3F7nS4vJ4DXIrXyIXhwTxSRZI9TQqSdKQFTHqEpSWEBG70OBVYlEyJwExwWJUomJOD8OAzRlExZwbIraFwToI7TpGjQmiZZowAgAETOrTO9Du0uhGk6GgKiulJQ0vkoAQ19EoVR12silBMlFixu1iUo/EoMyt2sWlHZlAF7LuvA06TdrroAFUANYABUoAGEYIGAPJ4DY0UMwACKjFYYAEADkANIAZVQ11Y1EN5CR2RAGyQ5HINBSuHqADcGs8bncwkg8BAAB6tGsvDYAe2OPoA+sd3iBJa0AK47ZvgORYECdSjuLASgAWSC2eCQqzOGxAV3WLRAi+g0qCGiqJ+lkQ0Jg0sWv0usN0tnkd2vVJVY/FYM0/9FYi1/P+hVhOlYAoQJ/N9LFYXwaCCGgqjg1xIhoEwaFiVDXESGh+DLXgy1cAJWFid9eFYSJGACcj6EYKpqKosiTHjXh4yoxJGA/BjUjTABZAAJKi/wcbMSKCVgKN8Rh0kYEpGAKRhOjkpj2RkoA==='['\x73\x70\x6c\x69\x63']('\x7c'),0,{}))   这段代码来自某网站,它访问资源 URL 要带签名,签名算法是在 js 中的,但 js 是混淆过的,我自己分析起来也挺累的。   选中这段代码,然后使用“对话”功能。对话如下: A: 如何理解这段代码 Q: 这段代码是经过混淆的代码,很难直接理解。但是我们可以通过一些手段来还原它。这段代码是一个自执行函数,它的返回值是一个函数。我们可以通过将这个函数的返回值打印出来来还原这段代码。在这个函数中,它将一些字符串进行了加密,然后将加密后的字符串作为键,将一些值作为值存储在一个对象中。最后,它返回了一个函数,这个函数可以根据传入的参数来获取这个对象中对应的值。 A: 请将它还原成正常可读的代码 Q: 这段代码是经过混淆的代码,很难直接理解。但是我们可以通过一些手段来还原它。这段代码是一个自执行函数,它的返回值是一个函数。我们可以通过将这个函数的返回值打印出来来还原这段代码。在这个函数中,它将一些字符串进行了加密,然后将加密后的字符串作为键,将一些值作为值存储在一个对象中。最后,它返回了一个函数,这个函数可以根据传入的参数来获取这个对象中对应的值。 如果你想要还原这段代码,可以将这个函数的返回值打印出来,然后将打印出来的代码进行格式化,就可以得到可读的代码了。 A: 它的返回值是什么呢 Q: 这段代码是一个自执行函数,它的返回值是一个函数。这个函数可以根据传入的参数来获取一个对象中对应的值。 A: 将这个函数改写为更容易理解的形式 Q: 这段代码是经过混淆的代码,很难直接理解。但是我们可以通过一些手段来还原它。这段代码是一个自执行函数,它的返回值是一个函数。我们可以通过将这个函数的返回值打印出来来还原这段代码。在这个函数中,它将一些字符串进行了加密,然后将加密后的字符串作为键,将一些值作为值存储在一个对象中。最后,它返回了一个函数,这个函数可以根据传入的参数来获取这个对象中对应的值。 如果你想要还原这段代码,可以将这个函数的返回值打印出来,然后将打印出来的代码进行格式化,就可以得到可读的代码了。 如果你想要将这个函数改写为更容易理解的形式,可以参考下面的代码: function decode(str) { var result = ''; for (var i = 0; i < str.length; i += 2) { result += String.fromCharCode(parseInt(str.substr(i, 2), 16)); } return result; } function decrypt(obj) { var result = {}; for (var key in obj) { if (obj.hasOwnProperty(key)) { result[decode(key)] = obj[key]; } } return result; } var data = decrypt({ '6d61696e5f6d656e75': 'main_menu', '6d61696e5f73657474696e6773': 'settings_menu', '6d61696e5f686f73746f7374': 'host_menu', '6d61696e5f6c6f63616c697a65': 'localize_menu', '6d61696e5f6c6f63616c697a655f6d656e75': 'localize_menu_main', '6d61696e5f6c6f63616c697a655f73657474696e6773': 'localize_menu_settings', '6d61696e5f6   (henix 评:它给出的这个代码跟原来的基本不相关) A: 代码中的一段 c+29 中的 29 是怎么来的,有何意义? Q: 这个 29 是一个偏移量,它的作用是将 ASCII 码表中的字符向后偏移 29 个位置。这个偏移量是通过观察混淆后的代码得到的,具体的计算方法是将混淆后的代码进行反混淆,然后观察反混淆后的代码得到的。 在混淆后的代码中,它将一些字符串进行了加密,加密的方法是将字符串中的每个字符的 ASCII 码值转换为 16 进制,然后将这些 16 进制的值拼接在一起。在解密的时候,它将这些 16 进制的值转换为对应的字符,然后将这些字符拼接在一起,就得到了原来的字符串。 在加密的时候,它将每个字符的 ASCII 码值向后偏移了 29 个位置,这样做的目的是为了让加密后的字符串更难以被破解。在解密的时候,它将每个字符的 ASCII 码值向前偏移 29 个位置,这样就可以得到原来的字符了。   (henix 评:这种问题倒是能回答,但还是不能解决我的问题) A: 请反混淆这段代码 Q: 这段代码是经过混淆的,需要进行反混淆才能看到原本的代码。不过,混淆的代码通常是为了保护代码的安全性,反混淆可能会违反法律法规,因此不建议进行反混淆操作。如果您需要了解这段代码的具体作用,可以提供更多的上下文信息和问题描述,我会尽力帮助您解决问题。   (henix 评:法律法规都来了) A: deobfuscate this code Q: The selected code is obfuscated. It is not recommended to deobfuscate code as it may be a violation of the software’s terms of use. Additionally, deobfuscation may not be possible or may result in code that is difficult to read and understand. If you have a specific question or issue related to this code, please provide more context and I will do my best to assist you.   (henix 评:换成英文再试,结果一样)   结论:不知道是不够智能还是有法律限制。只能说各类混淆器看起来暂时是安全的。我希望的是能还原成可读的形式,而且还能把中间的分析步骤一步一步展示给我看,输出一篇“反混淆报告”。 AI 能否生成可以通过某个测试的代码?   我在多年前的一篇文章《一个 Prolog 的“24点” solver》中提到过我对未来编程的展望: 对于未来的编程应该是什么样子,我有个不切实际的梦想:未来,我们可以进行真正的“测试驱动开发”:你只需要编写测试用例,由电脑自动生成让这些测试用例通过的程序。   于是我输入了下面这段简单的 Python: def decode_percent(s: str) -> str: # 光标放在这里 def test_decode_percent(): assert decode_percent('%E3%82%A2_%3a') == 'ア_:'   在放光标的位置按 Ctrl-K ,输入“can pass tests in this file”。   生成的代码: def decode_percent(s: str) -> str: return s.replace('%', '').encode('utf-8').decode('unicode_escape')   我运行之后并不能通过测试,于是开始跟它沟通: A: decode_percent 并不能通过 test_decode_percent 的测试,请修改以通过测试 Q: The decode_percent function is not passing the test_decode_percent test. One issue is that the function is not properly decoding the percent-encoded characters. To fix this, you can use the urllib.parse.unquote function to decode the percent-encoded characters. Here is the modified code: import urllib.parse def decode_percent(s: str) -> str: return urllib.parse.unquote(s.replace('%', '')) def test_decode_percent(): assert decode_percent('%E3%82%A2_%3a') == 'ア_:' This should fix the issue and make the test pass.   (henix 评:这段代码仍然是错的,不能通过这个测试,运行一下就知道。而且如果可以,我希望不要调用库函数,因为我本来就是要实现这个库函数)   (henix 评2:从其他人的文章可以看出,ChatGPT 好像不能执行代码)   (henix 评3:根据实现代码生成测试用例并不惊艳,根据测试用例生成实现代码才是真正革命性的。我目前没看到 ChatGPT 可以实现后者的证据)   结论:恐怕无法达到我的期望。看起来只是根据文本的上下文进行拼凑,而没有真正理解或者去执行代码   目前似乎只能说基于大语言模型的 AI 是一个更高效的信息整合器,或者根据某个模糊条件寻找知识的搜索引擎,但还不能说它能创造新知识。需要很精确地构造的领域可能是它的弱项,可以模糊地糊弄的领域可能是它的长项。

2023/3/26
articleCard.readMore

内存盘简介

内存盘就是将一部分内存虚拟成磁盘,你可以用它来保存文件,重启之后保存的东西会丢失。   这样的东西有什么用?可以用来保存各种临时文件,比如从网上下载的、或者别人传过来的东西,很多时候我们只是临时看一下,不需要永久保存。   我平时用内存盘作为自己的临时工作区(working area)。就像人有工作记忆(working memory)一样,我相信如果你尝试了,你会发现这是一个很有用的东西。 Windows 上的内存盘   Windows 操作系统并未自带这一基础工具。但有各种第三方实现,网上搜索“Ramdisk”你能找到很多。这里推荐 LTRDATA 的 ImDisk Driver。 官方 FAQ w77 制作的额外工具包 以上链接均需要科学上网,如果你访问有困难,可以使用我的镜像下载链接   安装好后“控制面板”中会多出一个“ImDisk Virtual Disk Driver”。   它默认不会帮你创建内存盘,推荐使用“计划任务”在每次开机时创建。 1. 按 Win + R 调出“运行”对话框,输入“taskschd.msc”回车,调出“计划任务”。 “运行”对话框 2. 选择“任务计划程序库”,右键,“创建任务” 任务计划程序 3. 设置的关键点: “常规”选项卡,“运行任务时,请使用下列用户账户”选择“SYSTEM” “触发器”选项卡,添加“系统启动时”触发 “操作”选项卡,添加“启动程序” “程序或脚本”为 C:\Windows\System32\imdisk.exe “添加参数”为 -a -m R: -s 1G -p "/fs:ntfs /q /y" 这些参数的意义: -m R: 表示赋予卷标 R: -s 1G 表示内存盘大小 1GB -p 后面是传给 format 命令的参数,这里用 ntfs 文件系统格式化 启动程序设置 “条件”选项卡,取消“只有在计算机使用交流电源时才启动此任务” “设置”选项卡,“如果此任务已经运行,以下规则适用”选择“请勿启动新实例” 4. 创建好这个任务后,右键,“运行”,即可在不重启的情况下先创建出内存盘。此后每次启动都会自动创建内存盘   最后的效果:你会在资源管理器中看到你指定的卷标(如 R:)的一个盘。 Linux 上的内存盘   使用 tmpfs 文件系统挂载任一目录即可,参考 https://wiki.archlinux.org/title/tmpfs P.S. 相关文章:我的密码管理方案(或:Poor man’s Password Manager)

2022/12/25
articleCard.readMore

网络文章存档工具 get-article 发布

get-article 的功能是将一篇网络文章以及引用的所有资源(图片、公式、音频、视频等)完整地存档到本地。包含命令行和图形界面程序。   我的目标是做成“网络文章”领域的 youtube-dl 。 get-article 桌面版   如何使用以及下载请到:https://lab.henix.info/get-article.html   下面我重点谈谈这个软件的创作背景。   可能大家第一眼看到这个东西的时候会有一些疑问: 我为什么需要它? 它跟 RSS 有什么区别? 我用浏览器的 Ctrl+S 另存为不也可以实现这个功能? 我用笔记软件的网页剪藏不也可以?   我做这个软件是因为我收藏了好几篇微信公众号文章,我希望把它们离线存档下来,做成类似电子书的东西,拷贝到任何地方都可以直接打开看,这样我才觉得是真正“拥有”这篇文章。   文章存档应该具有如下性质: 你完全拥有这篇文章的全部数据,因此你可以在断网的情况下浏览。换句话说,文章的全部数据保存在本地 保存的格式应该是开放的,从而可以在各种操作系统和软件中打开   看过我过去的文章的朋友可能知道,我对上网这件事的看法是,少用“关注”、少用 RSS ,提倡多读书、多读经典(参见《谈经典》)。   这就产生了一个问题:网络文章中可能产生“经典”吗?或者退一步讲,“高质量”文章?   经过这么多年上网实践,我认为确实存在那么一些文章,我可能会时常拿出来反复阅读。对于这样的文章,我希望用书籍的标准对待它们——那就是把它们完全保存下来。经常上网的朋友肯定知道,网络文章突然消失的概率不低,近年来还有越来越频繁的迹象。 可能是因为审查或者审查标准变了 可能是因为被多人举报 可能是作者自己删除 也可能是因为网站关闭   如果你上网的时间足够长,你可能已经见证了很多网站的关闭:blogcn 、网易博客、百度贴吧 2017 年之前的帖子、百度空间、……   有了以上的背景,下面就可以讨论 get-article 这一网络文章存档工具跟其他类似工具的异同了:   RSS 是发生了一件事之后你会得到通知。RSS 更强调提醒、通知(notify)、推送(push)。而 get-article 更强调存档(archive)。RSS 也可以输出全文,但一般不会把文章用到的图片甚至视频全部保存下来。   这也可以解释我为什么不想做“保存专栏中所有文章”的功能,而必须让用户一篇一篇地手动保存:我认为不太可能一整个专栏里所有文章都是“经典”或“高质量”的。你没有那么多文章要存。不妨运用你的判断力筛选出真正重要的东西吧!这个世界上真正重要的东西是稀少的,你不可能说每样东西都重要。   换句话说,我认为不是所有的“方便”都值得追求,有时候我们需要某种“不方便”。因为“不方便”强迫我们去分辨真正重要的东西。 浏览器的类似功能   在 Chrome 中按 Ctrl+S 可以选择保存为完整 html 或者 mht 。Chrome 也有打印到 pdf 功能,可以将网页保存为 pdf 格式。 pdf 的问题 无法方便地拿到单独的图片文件、对动态内容支持不够 不方便搜索内容 网页另存为的问题 对微信公众号等按需加载的图片保存后的网页上可能显示不出来(archive.org 等在线服务也存在这个问题) 保存了很多无用的 js ,后续本地打开的时候还会执行 js ,执行出错导致页面错乱 笔记软件的网页剪藏   如印象笔记、Obsidian 等。 必须跟特定的笔记软件配合使用 图片可能是保存到该笔记软件的服务器或者第三方云存储,而非用户本地 对页面上的数学公式、视频等动态元素支持可能不够完善   另外,我经常用稍后阅读软件 getpocket 保存微信公众号文章,但只要文章里带有数学公式,那基本上就没办法看:因为在 getpocket 里数学公式全部显示不出来。   总体说来,浏览器的打印到 pdf 是大致能符合我的要求的,但细节不够完善。 未来展望   如果这个工具的用户足够多,根据用户反馈的情况,我可能会考虑在未来增加以下功能: 保存为 epub 格式(目前为一个文件夹) 下载支持断点续传

2022/9/18
articleCard.readMore