diff options
| author | eltrac <hi@eltr.ac> | 2026-08-23 20:28:05 +0800 |
|---|---|---|
| committer | eltrac <hi@eltr.ac> | 2026-08-23 20:28:05 +0800 |
| commit | cbc4b164152727a0e01309d10413d40502d91dbe (patch) | |
| tree | 0b99aa3e431328f3971843ab61a71208e139cba6 /content | |
| parent | 3002def33284c3788a11d3633ce7ea2cd0082e7a (diff) | |
[publish] /article/skeleton-programing
Diffstat (limited to 'content')
| -rw-r--r-- | content/article/skeleton-programing.md | 53 |
1 files changed, 27 insertions, 26 deletions
diff --git a/content/article/skeleton-programing.md b/content/article/skeleton-programing.md index 975416b5..f08e7079 100644 --- a/content/article/skeleton-programing.md +++ b/content/article/skeleton-programing.md @@ -1,10 +1,9 @@ +++ title = '骷髅编程' -slug = '' -tags = [] +tags = ["Lisp","编程"] categories = '代码炼金术' -date = 2026-08-22T08:58:47+08:00 -draft = true +date = 2026-08-23 20:27:43 +draft = false toc = true +++ @@ -162,19 +161,19 @@ element.onclick = (event) => { 如你所见,通过编写接口或使用回调函数,我们让软件中某些具体的部分变成了**插件**。还是以 JavaScript 举例,网页中的一个按钮点击之后具体会发生什么就是具体实现,而按钮可以点击这个事实就是一个接口,或者说协议。这个协议可以被更清晰地阐述为:点击了按钮之后就会发生某些事情。所谓的「某些事情」是抽象的、不定的、可替换的、可插拔的。JavaScript 通过回调函数实现这个协议。 -编程实际上就是类似于「点击按钮之后会发生某些事情」的对目标系统的描述,只不过使用了特定的编程语言。如 Frederick Brooks 在《No Silver Bullet》中所言,软件的内在性问题之一是:软件易变。如何应对软件需要经常改变的事实?把相对不容易改变的东西和容易改变的东西分离开来,不容易改变的就是最抽象、最高层次的业务逻辑,容易改变的东西就是数据库、按钮做的「某些事情」的具体实现。网页上每个按钮的行为都不一样,可以说按钮的行为是具体且易变的,那为什么每次有人要做一个按钮的时候,我们不需要修改按钮内部的代码?那是因为我们已经用回调函数把抽象的和具体的隔离开来了。 +编程实际上就是类似于「点击按钮之后会发生某些事情」的对目标系统的描述,只不过使用了特定的编程语言。如 Frederick Brooks 在《No Silver Bullet》中所言,软件的内在性问题之一是易变性。如何应对软件需要经常改变的事实?把相对不容易改变的东西和容易改变的东西分离开来,不容易改变的就是最抽象、最高层次的业务逻辑,容易改变的东西就是数据库、按钮做的「某些事情」的具体实现。网页上每个按钮的行为都不一样,可以说按钮的行为是具体且易变的,那为什么每次有人要做一个按钮的时候,我们不需要修改按钮内部的代码?那是因为我们已经用回调函数把抽象的和具体的隔离开来了。==这种设计无处不在,以至于人们使用的时候毫无感知,自己需要设计的时候就很难考虑到这点了。== -再回顾前文有关存储层的例子。假设我现在已经实现了基于 MySQL 的存储,但用户突然要求兼容 PostgreSQL,我们只需要用 PostgreSQL 的相关库实现 `EntryStorage` 接口(也就是那三个函数),然后在装配 `Engine` 时,把 MySQL 的具体实现换成了 PostgreSQL 的具体实现。存储层是可插拔的。 +再回顾前文有关存储层的例子。假设已经实现了基于 MySQL 的存储,但用户突然要求兼容 PostgreSQL,只需要用 PostgreSQL 的相关库实现 `EntryStorage` 接口(也就是那三个函数),然后在装配 `Engine` 时,把 MySQL 的具体实现换成 PostgreSQL 的具体实现。存储层是可插拔的。 再假设,你在开发这个软件的期间一直使用 `FakeStorage` 把数据存在内存里,等核心的逻辑都完成时,你突然意识到这个软件要存储的数据体量非常小,而且读写的频率很低,连 SQLite 都不用上,用 JSONL 文件存储就行了。这下你就不必设计数据库 Schema 和编写 SQL 语句了,用 Go 语言自带的 JSON 标准库读写磁盘文件就好了。接下来你用几行代码实现了 `JsonEntryStorage`,插入 `Engine`,完全不用修改 `Engine` 里面的代码。 -软件的其他部分也都可以这么做,让各种组件都变成核心业务逻辑的插件,一方面把复杂度外包出去,软件最中心、最内部的组件只需要描述「点击按钮之后会发生某些事情」这类简单且清晰的逻辑;另一方面,面对需求的变更,只要不涉及最核心的业务逻辑,可以把变更集中外围的一两个插件当中。再者,在开发的初期阶段,你完全可以把精力放在最重要、最核心的逻辑上,把数据库等底层细节都抛之脑后。 +软件的其他部分也都可以这么做,让各种组件都变成核心业务逻辑的插件,一方面把复杂度外包出去,==软件最中心、最内部的组件只需要描述「点击按钮之后会发生某些事情」这类简单且清晰的逻辑==;另一方面,面对需求的变更,只要不涉及最核心的业务逻辑,可以把变更集中外围的一两个插件当中。在开发的初期阶段,完全可以把精力放在最重要、最核心的逻辑上,把数据库等底层细节都抛之脑后。 开发者只需要在编写核心业务逻辑的时候留下几个必要的接口,调试时可以使用假的、简化的实现,之后再将真实的实现装配和注入到核心组件中。虽然很不愿意称赞这门邪恶的语言,但是 Java 生态里的 Spring 框架做的就是这件事情。Java/Spring 程序员往往不需要让组件之间相互依赖,Spring 会自动将具体实现装配到对应的类当中。有一说一,这种自动化的依赖注入和控制反转(DI/IoC)框架过于方便,导致我观察到的很多学艺不精的 Java 程序员对基本的架构设计原则毫无认知。 在我看来,这让编程变得跟写伪代码一样简单,描述某个软件行为却发现自己需要用到另一个组件时,或者想要把某个组件分离出去时,只需要假设某个类、某个函数存在,然后直接写就好。比方说,前文我就假设了 `isQualified(entry Entry) bool` 存在(在 Clojure 的例子中,我用的是 `(qualified? entry)`),但之前的写法并没有留出接口,让我稍作修改: -```go {hl_lines=[]} +```go {hl_lines=["11"]} package receiver import "example.com/interfaces" @@ -250,7 +249,7 @@ type EntryJudge interface { 3. 获取外链的外链 4. 如果外链的外链指向一开始给定的网站,就给出一条结果 -我已经看出循环和递归的结构了,这个程序就是一直在提取外链而已。不少程序员的直觉可能是立刻动手开始编写获取 HTML 网页的逻辑,考虑应该使用正则匹配还是类似 [Beautiful Soup](https://beautiful-soup-4.readthedocs.io/en/latest/) 的解析工具,获取到一条环路之后应该存哪这些问题。*But you and I should know better.* +我已经看出循环和递归的结构了,这个程序就是一直在提取外链而已。不少程序员的直觉可能是立刻动手开始编写获取 HTML 网页的逻辑,考虑应该使用正则匹配还是类似 [Beautiful Soup](https://beautiful-soup-4.readthedocs.io/en/latest/) 的解析工具,获取到一条环路之后应该存哪儿这些问题。*But you and I should know better.* 照着我现在的理解,先写一段代码试试看: @@ -360,7 +359,7 @@ type EntryJudge interface { (apply concat results))))))) ``` -现在就可以了,`hop` 并发了更多的递归 `hop`,之后再逐个等待结果。看起来并发之后立刻阻塞并等待,好像和直接调用没有区别,但关键的区别在于,如果把递归结构看成一棵树的话,我们就不是在进行深度优先遍历,而是并发地对每个子树同时进行遍历,效率会高很多。由于我们用了有缓冲的 `channel`,`hop` 在把任务放进爬取队列时就可能因为缓冲区已满而阻塞,只要设置合理的缓冲区大小,就不用担心性能问题。 +现在就可以了,`hop` 并发了更多的递归 `hop`,之后再逐个等待结果。看起来并发之后立刻阻塞并等待,好像和直接调用没有区别,但关键的区别在于,如果把递归结构看成一棵树的话,我们就不是在进行深度优先遍历,而是并发地对每个子树同时进行遍历。[^2][^3]由于我们用了有缓冲的 `channel`,`hop` 在把任务放进爬取队列时就可能因为缓冲区已满而阻塞,只要设置合理的缓冲区大小,就不用担心性能问题。 不过,即便是并发,网络操作仍然很慢,我们不能等程序把所有的环路都找到了之后才打印结果吧?是不是应该每找到一个结果就打印一次?要在 `hop` 里调用 `println`?不不不,如果这个软件要做成 Web 程序、GUI 程序呢?要怎么收集到这些结果?显然 `println` 是具体实现。那只要换成一个抽象函数就好了。 @@ -387,7 +386,7 @@ type EntryJudge interface { 在添加新功能(每找到一个环路就上报一次)的同时顺带重构了代码,现在 `hop` 函数看起来干净了很多: -```clojure {hl_lines=[1,6,10,12]} +```clojure {hl_lines=[1,5,9]} (defn- hop [journey over? pipelines] (future (if (over? journey) ;; if journey's over, print the only one @@ -406,11 +405,11 @@ type EntryJudge interface { 最后一个问题,目前看来 `hop` 如果没有找到满足 `over?` 条件的路径,就会一直执行,是不是应该写一个最大跳数的限制?不不不,别忘了 `over?` 函数是抽象的,最大跳数的限制完全可以由 `over?` 的具体实现去处理,不必为此让核心逻辑变得复杂。 -至此,我通过只思考抽象问题,逐步求精,写完了核心的业务逻辑。即便写了 `defprotocol` 定义,也只有**不到一百行**代码——包括注释和 `require` 语句,`ody.clj` 只有 19 行,`workers.clj` 只有 17 行,各种协议定义 `protocols.clj` 有 15 行(不过这很大程度上要归功于 Clojure 语法的简洁性,而且为了写出这么简洁的代码我可真是想破了脑袋啊)。这 51 行代码就是这个软件的骨架,在填充血肉之前,还有一件重要的事情要做。 +至此,我通过只思考抽象问题,逐步求精,写完了核心的业务逻辑。即便写了 `defprotocol` 定义,也只有**不到一百行**代码——包括注释和 `require` 语句,`ody.clj` 只有 19 行,`workers.clj` 只有 17 行,各种协议定义 `protocols.clj` 有 15 行(不过这很大程度上要归功于 Clojure 语法的简洁性)。这 51 行代码就是这个软件的骨架,在填充血肉之前,还有一件重要的事情要做。 ### 测试骨架 -在我们把 HTTP 客户端、HTML 解析、URL 规范化和对比、`robots.txt` 解析、结果输出、用户界面等一系列无底洞般的细节引入到软件中之前,应当先保证骨架本身有效。Lisp 程序员们可以用 REPL 命令行里直接调用软件内部的函数进行测试,测试的过程中随手定义一些假的实现喂给核心逻辑也非常容易。我虽然写了很多 Clojure 代码(不过才五十几行吧),但本文实际上是与语言无关的,所以我还是用更通用的方式来测试——写测试文件。 +在我们把 HTTP 客户端、HTML 解析、URL 规范化和对比、结果输出、用户界面等一系列无底洞般的细节引入到软件中之前,应当先保证骨架本身有效。Lisp 程序员们可以用 REPL 命令行里直接调用软件内部的函数进行测试,测试的过程中随手定义一些假的实现喂给核心逻辑也非常容易。我虽然写了很多 Clojure 代码(不过才五十几行吧),但本文实际上是与语言无关的,所以我还是用更通用的方式来测试——写测试文件。 不过老实说,更好的做法是测试驱动开发(TDD),即先写测试,再写代码。但…… 我都已经写完了还能怎么办呢? @@ -473,7 +472,7 @@ Ran 1 tests containing 2 assertions. ### 填充血肉 -那些跟 LLM Agent 一起工作得不亦乐乎的人,其实已经可以放手,把写好的接口定义和核心业务逻辑丢给 Agent,让机器实现人没有实现的接口,在 `main` 组件中装配起来,然后就能跑了。跟 Vibe Coding 不同的是,开发者已经对软件的核心逻辑、接线点、输入输出和内部结构了如指掌,不会面对大量安全性、可读性和质量堪忧的代码发懵。开发者不会像 Vibe Coders 那样因为不理解代码而事事求助于机器,不会让自然堆积的屎山代码填满 LLM 的上下文,燃烧大把大把的美钞。再者,由于开发者对代码的理解很充分,他可以写出更清晰的提示词,甚至精确到代码行,让 Agent 消耗更少的 Token 和时间完成任务,而不必把大量的 Token 都用在「推测用户意图」上。 +那些跟 LLM Agent 一起工作得不亦乐乎的人,其实已经可以放手,把写好的接口定义和核心业务逻辑丢给 Agent,让机器实现人没有实现的接口,在 `main` 组件中装配起来,然后就能跑了。跟 Vibe Coding 不同的是,开发者已经对软件的核心逻辑、接线点、输入输出和内部结构了如指掌,不会面对大量安全性、可读性和质量堪忧的代码发懵。开发者不会像 Vibe Coders 那样因为不理解代码而事事求助于机器,不会让自然堆积的屎山代码填满 LLM 的上下文,燃烧大把大把的美钞。再者,由于开发者对代码的理解很充分,他可以写出更清晰的提示词,甚至精确到代码行,让 Agent 消耗更少的 Token 和时间完成任务,而不必把大量的资源都用在「推测用户意图」上。 不过,LLM 生成代码的版权状态未知,我不知道 Agent 填进我仓库的代码是从谁哪里偷的,应该遵从什么开源协议,应该署谁的名字,所以我不会用 LLM 生成的代码。接下来我将手写接口实现,把所有组件都装配起来,产生可运行的软件原型。 @@ -490,17 +489,14 @@ Ran 1 tests containing 2 assertions. [clj-http.client :as client] [html-parser.core :refer [document-from-string select]])) -(defrecord eurylochus [home] Crawler +(defrecord eurylochus [] Crawler (find-ways [_this url] (let [resp (client/get url) status (:status resp) doc (document-from-string (:body resp))] (when (= status 200) (->> (select doc "a") - (map #(.attr % "href")) - (map #(if (= \/ (first %)) - (str home %) - %))))))) + (map #(.attr % "href")))))))) ``` 按下快捷键把这段代码发送到 nREPL 里,然后直接在 REPL 里 Eval 看看效果,就用我自己博客的某期周刊 URL 当作例子吧。 @@ -589,7 +585,6 @@ Ran 1 tests containing 2 assertions. :max-hop 10}) (defn -main - "I don't do a whole lot ... yet." [& args] (let [result-queue (chan) crawl-queue (chan (:crawl-queue-buffer config)) @@ -630,7 +625,7 @@ Full report at: 仔细观察的话,找不到环路很大程度上是因为很多路径一直在原地传送,某个 URL 的内容可能包含和自己一模一样的 URL,而 `polites.clj` 没有把这部分筛掉。再者,程序经常找网站的首页 URL,而包含内容的子页面才更有可能找到我们想要的环路,应该让 URL 中路径更深的排在前面。 -```clojure {hl_lines=[6,8]} +```clojure {hl_lines=[7,9]} ;; polites.clj (defrecord polites [blocklist limit] Gatekeeper (filter-ways [_this urls current] @@ -690,7 +685,7 @@ Full report at: "https://www.geedea.pro/weekly/94/"] ``` -从我的[第 94 期周刊](/weekly/94),先是去到了 Michael Harley 的《Bloggers, can we make better titles for our posts?》,然后去到了 Bubbles(一个博客聚合平台)的社交媒体贴(GoToSocial 的前端不需要 JavaScript 也能显示内容,所以或去到了外链),然后去到了 Bubbles 上这篇文章的页面,然后由回到了 Harley 的文章,由于我引用了他的文章并且发送了 Webmention,最后通过 Webmention 链接回到了我的网站。挺奇妙的旅程。 +从我的[第 94 期周刊](/weekly/94),先是去到了 Michael Harley 的《Bloggers, can we make better titles for our posts?》,然后去到了 Bubbles(一个博客聚合平台)的社交媒体贴(GoToSocial 的前端不需要 JavaScript 也能显示内容,所以获取到了外链),然后去到了 Bubbles 上这篇文章的页面,然后回到了 Harley 的文章,由于我引用了他的文章并且发送了 Webmention,最后通过 Webmention 链接回到了我的网站。挺奇妙的旅程。 不过接下来就没有那么顺利了,很多路径都是达到 `max-hop` 限制放弃的路径,不过倒是发现了不少有趣的链接,或许这个软件的作用也可是帮助从自己的网站出发发现更多的内容。目前的限制是,每个 URL 最多发散出三条外链,一条 `journey` 最多包含 `10` 个节点。即便做了黑名单限制,也很难剪枝掉大部分的任务,最后要爬取的网页数量接近 3 的十次方。 @@ -698,9 +693,13 @@ Full report at: ## 反思:对象是必要的吗? -软件能跑了, +软件能跑了,现在 Ody 已经开源放到了 [Tangled](https://tangled.org) 上,由于还不稳定,所以现在的大部分代码都在 [feat_core](https://tangled.org/eltrac.tngl.sh/ody/tree/feat_core) 分支里,尚未合并。欢迎提 PR,~~如果有人能帮我把那个翻车的并发问题解决掉就好了~~,我这周的 Clojure 功力已经耗尽了。 -https://tangled.org/eltrac.tngl.sh/ody/tree/feat_core +我在写代码的过程中发现,我定义的不少 `defprotocol` 只包含一个方法,为了实现这一个方法而去创建对象,是不是有点太啰唆呢?我那 51 行代码是不是可以更短?我之前其实写过 Common Lisp 的[无类多态](/article/clos/#不属于类的多态),即不需要定义类也能创建抽象函数。不过当时我忙着~~骂 Java~~,竟没有好好吸收 CLOS 的精髓。最近读了明琪琪可的《[Trial 与 CommonLisp 的介绍](https://yumieko.kikowiwu.com/posts/0a80d35a-f927-451e-8f96-8ebc00a29a4d)》,这才意识到 OOP 里的接口仅仅是低配版的 `defmethod` 而已,~~我果然还是被 Go 害惨了啊~~。 + +前面写过的 `over?` 作为写在函数形参里的回调函数,其实也是一种低配但更简洁的抽象函数,唯一的缺点是它不够安全,返回值、形参和行为都难以确定(或许静态类型语言会好一些),而且写太多形参若还要递归或层层传递,想想就觉得枯燥。 + +Clojure 也支持类似 CLOS 的系统,`defmulti` 和 `defmethod` 可以在不依附任何类和对象的情况下实现多态,而且支持多分发。兴许下次用 Clojure 写东西的时候可以试着用用。 ## 推荐阅读 @@ -709,6 +708,8 @@ https://tangled.org/eltrac.tngl.sh/ody/tree/feat_core - 《[整洁架构之道](/library/2026/clean-architecture/)》,Robert C. Martin - 《[Functional Design](/library/2026/functional-design/)》,Robert C. Martin -## Footnotes - [^1]: Objective-Oriented Programing,面向对象编程 + +[^2]: 咳咳,这里是几个小时后的 Eltrac。我发现这段代码有个致命的问题:它实际上仅仅是创建个一个又一个**被阻塞的线程**,实际速度和同步递归没差…… 主要的问题是,任务的生产者和消费者都是同一实体,照理来说应该分开,一开始用 `go` 和 `channel` 的思路是对的。事已至此,先这样吧,我累了。 + +[^3]: 既然已经调用了 `mapv`,换成 `pmap`,再做些调整兴许就能解决。`pmap` 的意思是 Parallel `map`,多个操作是并发执行的,会快很多。 |
