许多程序员抱怨人工智能只能快速搭建一些CRUD应用,除此之外就没什么用了。当有人指出这种说法不成立时,大家(理所当然地)都会要求举例。网上关于人工智能的详细示例并不多,所以我决定分享一些我今年圣诞节期间编写的项目。
寒假期间,我开始开发Beep——一种我设想的编程语言,我想把它从脑海中变成可运行的代码(哪怕只是为了摆脱它,让我不再想它)。我只有两周时间,想尽可能多地完成,所以所有代码都是我和 Claude Code/Opus 4.5 合作编写的。
(这个词) “合作”并非偶然。我特意选择了这个词,因为它准确地描述了与克劳德一起工作的体验,就像你有一个编程伙伴一样。)
例一:词汇范围阴影
Beep 支持词法作用域。你可以这样说,它就能正常工作:
let x = 1 def foo() x # `foo` can see `x` end foo()
实现这个功能的一个非常简单的方法是,将x: 1放入一个绑定映射表中,并在函数定义时引用这个foo表。当调用foo函数时,它就可以在这个映射表中查找x的值。
Beep 也支持阴影效果。你可以这样做,它也能正常工作:
let x = 1, y = 1 def foo() [x, y] end let x = 2 def bar() [x, y] end foo() # returns [1, 1] bar() # returns [2, 1]
一种简单的实现方法是使用绑定映射的单链表。每次遇到let时,就创建一个新的绑定映射并将其链接到前一个映射。在上面的示例中,解释器执行以下步骤:
-
let x = 1, y = 1,创建一个绑定映射,并将x: 1, y: 1放入其中。 - 参见
def foo,步骤1中foo定义中的参考映射。 -
let x = 2,创建一个新的绑定映射,并将x: 2放入其中。将此映射与我们在步骤1中创建的映射关联起来。 - 参见
def bar,参考步骤3中bar定义的映射。 - 用户调用
foo()。解释器跟随对第一个映射的引用,查找x和y,并返回值[1, 1]。 - 用户调用
bar()。解释器沿着引用找到第二个映射,查找x和y。找到x: 1,但找不到y,因此沿着链找到第一个映射。找到y: 1,并返回值[2, 1]。
这种方法的一个问题是,如何追踪最新地图并不明显。请看以下示例:
let x = 1 def foo() x # x is 1 here let x = 2 x # x is 2 here let x = 3 x # x is 3 here end foo() x # x is 1 again
这里有三个let语句。每次遇到 let 语句,我们都会创建一个新的 map,指向前一个 map,并将其设为新的 map。 “当前”。当我们看到end ,我们需要返回到第一张地图并将其重新设为当前。但是我们如何跟踪需要返回多少张地图呢?
我考虑了两种方法:
-
在解释器状态中维护一个整数栈。每次解释器进入一个代码块(例如函数定义)时,我们都会向该栈中压入零。每当出现 `let` 语句时,我们都会加一栈顶的计数器。代码块退出时,我们丢弃与计数结果相同的映射,并将栈顶的计数器弹出。这种方法很简单,但会引入运行时开销(在玩具解释器中可以忽略不计),并且会使解释器本身更加复杂。
-
另行执行一次转换,以静态方式引入此信息。此转换会将上面的示例转换为以下形式:
let x = 1 in def foo() x # x is 1 here let x = 2 in x # x is 2 here let x = 3 in x # x is 3 here end end end x # x is 1 again end传统语言会在语法中显式地处理这种情况,但我不想在 Beep 中把这项工作转嫁给用户。实现 pass 本质上是采用了第一种方法,但只在解析时运行一次。
我向 Claude Code 寻求建议,它又提出了两种方法:
- 与上述方法相同,但不是单独进行转换,而是直接在解析器中进行转换。Claude 立刻给了我一个正确的差异,让我进行这项更改。虽然我最终没有选择这种方法,但它很简单,而且是我自己想不到的。
- 停止在显式解释器状态中跟踪最新的绑定帧。而是让
eval返回值和新的帧。大多数AST节点会返回它们获取到的相同帧,但let节点会返回一个新的帧。这将使宿主编程语言的栈自动处理这个问题。
我选择了后一种方案。我很确定即使没有 Claude,我自己也能发现问题所在,但这需要进行大量的机械重构,我想我的潜意识里对此有所抵触。当然,我完全不必自己进行重构。我让 Claude Code 帮我完成了重构,它完美地完成了(参见提交4a022a04 )。现在我可以放心地进行大规模的机械重构了,但要让潜意识重新适应新工具的功能,确实需要时间。
示例二:动态作用域变更
Beep 的另一个特性是动态作用域。动态作用域的变量以$符号为前缀。例如:
def foo() $x end def bar() let $x = 1 foo() # returns 1 end bar() # returns 1 foo() # error, $x is not defined
动态作用域变量对于在调用栈底部引入状态并让栈上的所有组件都能访问它非常有用。例如,Web 服务器可以将配置信息放在动态变量中,这样每个请求处理程序都能访问到它,而无需显式传递状态。这在任何需要使用全局变量的地方都非常有用,而且更加安全。它不会污染全局命名空间,作用域仅限于你的调用,并且更容易实现线程安全。动态作用域变量是全局变量(或者说是人们过去有时使用单例模式来实现的那种变量)的更好版本。
我使用第二个绑定帧链表实现了动态作用域变量,然后遇到了一个问题:这些变量应该是可变的吗?我的直觉是,你不应该能够在foo中给$x赋值,因为这会引入远程攻击——调用栈深处的任何人都可以在你不知情的情况下修改你的变量。但我同时也认为,你应该能够在bar中给它赋值。换句话说:你只能给你自己创建的动态变量赋值。
但解释器如何知道要这样做呢?它必须能够追踪动态作用域变量的引入位置。这似乎有点复杂,我懒得去推导,于是就问了 Claude Code。Claude 提出了一个非常简单优雅的方案——在每个词法绑定框架中添加一个集合;每次遇到动态作用域变量的声明时,就将其添加到该集合中。其他一切都由现有机制处理。我请 Claude 实现这个方案,结果再次完美地实现了差异(我将其拆分为提交6694c8ad和a09c3af8 )。
这几乎肯定是我最终也会发现的,但我需要一些时间去思考和理解。当你刚开始接触一个新领域时,自己摸索解决方案确实能帮助你进步。但当你已经相当熟悉问题领域时,你往往能一眼认出优秀的解决方案,这能让你比自己动手更高效地抓住问题的核心。对我来说,这种情况显然就发生在这里。
示例三:解析器组合器黑客技术
大约一年前,我写了ts-parsec——一个用 TypeScript 编写的类型安全的解析器组合器库。我对这个库了如指掌,所以编写一次性解析器非常容易,而且类型安全也带来了良好的用户体验。至少最初是这么想的。但实际上,当语法稍微复杂一些时,我却发现自己要花好几个小时来处理类型系统,才能让它通过类型检查。
ts-parsec并不是一个热门库——它在 GitHub 上只有 13 个 star,每周 npm 下载量也只有 395 次。然而,Claude 使用它的水平却远胜于我。我猜想它的训练集中包含了足够多的解析器组合库,但ts-parsec独特性以及与其他类似库的显著差异,使得使用它需要对细节有相当深入的理解。
Beep 的parser.ts文件是我完全不关心的代码库部分。如果 Beep 最终脱离了玩具阶段,我会弃用parser.ts ,要么用成熟的解析器生成器重写,要么手动编写解析器。所以对于解析操作,我只会给 Claude 提供一些非常高级的指令。例如:
我想引入形如
let x = 1, $y = 2的 let 表达式。将它们添加到解析器和抽象语法树 (AST)中,暂时不用担心它们的求值问题。
let Code 会直接这么做。let 形式相当简单,但任何做过解析的人都知道,它可能会变得非常复杂。这里就不深入探讨了,像ts-parsec这样的PEG解析器组合库将词法分析和语法分析合并为一个步骤,这会使一些事情变得困难。你需要对解析库和语法有深入的理解才能使其正常工作。
例如, struct是 Beep 的一个关键字,用于定义记录:
struct Person name, age end
但ts-parsec比较激进,所以如果你输入的是structure ,它会先解析struct ,然后再抛出一个解析错误。我遇到这类 bug 时,会告诉 Claude 去修复,他总能毫不费力地完成。
如果你查看parser.ts文件,会发现代码结构很差。解析器组合器的顺序不直观,代码难以阅读。部分原因是解析器组合器的代码本来就是这样的。但更重要的原因是,我刻意放弃了对代码质量的关注,而且 Claude Code/Opus 4.5 默认情况下也不会神奇地生成结构良好的代码库。
不过,克劳德的表现给我留下了非常深刻的印象。他用一个领域(解析器)比较小众、类型系统也比较晦涩的库,几乎解决了所有问题,速度比我自己快至少一个数量级,尽管我就是这个库的作者。
反例:换行符敏感性
我希望Beep语法能够用换行符分隔语句,或者用分号分隔同一行中的语句:
def foo() 1 2 3 4; 5 end
ts-parsec最初并不支持这种做法。它的读取器只有两种模式:丢弃或保留所有空格。Claude Code 陷入了反复尝试各种方法的困境,但都行不通,因为底层库没有足够的支持来实现这一点。Claude 研究了ts-parsec ,了解到它缺少对保留换行符的支持,但他无法直接告诉我该如何解决这个问题。于是,我修改了ts-parsec读取器,使其支持第三种模式keep_newlines 。修改完成后,Claude Code 只需简单地修改语法即可支持以换行符分隔的语句。
在大多数区分换行符的语言中,语法允许你使用分号;\n来结束一个语句。例如,在 JavaScript 中,你可以这样写:
1 2 1; 2
这助长了各种代码风格的泛滥。有些代码库每行以换行符结尾,有些以分号结尾,还有些则两者混用。我不想把这些决定交给代码检查工具,而是决定用 Beep 来编码。所以我请 Claude 修改语法,让;\n非法字符。结果 Claude Code 又卡住了——它尝试了各种方法,每一种方法又会破坏其他东西,始终无法摆脱这个循环。过了一段时间,我只好自己修复了语法。
当我尝试发布新版本的ts-parsec时,又遇到了一个无限循环。我的版本位于@spakhm/ts-parsec ,它被称为……我遇到了一个“作用域”包的问题。npm 的身份验证出了点问题,我懒得去解决,就向 Claude 求助。他给了我各种各样的建议,但都没用,直到我最终下定决心,认真阅读了npm 文档,自己解决了这个问题。
在这个项目中,只有这三个例子对 Claude Code 来说难度过高。我上次尝试时(Opus 4.5 之前),遇到这类问题的频率要高得多。Opus 4.5 提高了触发故障模式的难度,即使是更难的问题,出现故障的频率也降低了。但它有时仍然会卡在一些简单的操作上,比如发布作用域 npm 包的命令。
结语
很多人担心人工智能会抛弃他们。我并不这么认为。考虑到它的功能范围,可以说这是有史以来最直观的技术。你可以用你的母语和它交流,它就会按照你的意愿行事。你不需要专门学习 Claude Code——它比VS Code 更容易上手!你可能只需要 30 分钟就能熟悉它的界面,接下来的一两天,它就会融入你的日常使用,变得如此自然,以至于你都忘了自己正在使用它。而且随着它变得越来越智能,它会更好地理解你。你不需要提升自己使用人工智能的技巧,人工智能会不断提升自己被你使用的能力。
(此外,还有地缘政治方面的担忧,以及经济和科幻小说中描绘的世界末日景象。我认为也有充分的理由对此保持乐观,尽管我对此不太有信心。由于我对这些话题并不熟悉,所以我会避免这篇文章变成一篇评论文章。)
另一个观点认为人工智能全是粗糙的代码和糟糕的代码。我也不认同这种看法。的确,大规模地生成糟糕的 PR 比以往任何时候都更容易,我们必须更好地解决这个问题。在这个项目中,我并没有把 Claude Code 当作一台随意编码的机器,而是把它当作一台精准的代码手术机器人,以及一个可以一起碰撞设计灵感的伙伴。在这种模式下,Claude 显著提升了代码库的质量。它降低了繁琐的重构门槛,让代码库的维护和偿还技术债务变得更加容易。它还降低了头脑风暴的成本,让我能够选择比自己单独思考时更简洁的解决方案。
Beep 目前的功能尚不完善,因此很难判断其发展进度。它已经具备一些相当高级的功能,例如用户自定义类型、方法、闭包和不同的作用域模式。但它缺少一些基本功能,例如条件语句和循环语句。我可以肯定地说,如果没有 Claude,我绝不可能在短短两周内完成这么多工作,而且期间还经常被节假日和家庭事务打断。更有趣的是,如果我独自工作,项目的发展方向会截然不同。我会先做一些简单的功能,把更复杂的功能留到以后再做。Claude Code 让我能够优先开发复杂的功能,因为它让这一切变得轻松有趣,而且我始终知道,如果需要实现一个简单的功能,只需几分钟就能借助 Claude 完成。
编辑:条件语句和循环语句现在可以正常工作了。
我会把这篇文章中的问题归结为…… “优秀本科生”水平。在美国中等水平的大学里,可能只有排名前 5% 的计算机科学专业学生能够使用 Claude Code,而在常春藤盟校,85% 的计算机科学专业学生都能使用。我并不是说 Claude Code 是一款优秀的本科生工具——它完全是另一回事。它能以超人的速度进行代码重构,但却无法发布到 npm。我的意思是,如果你正在处理这种难度级别的代码,Claude Code 绝对是一款出色的编程助手。抛开所有关于效率的争论不谈,它通过消除许多令人烦恼的事情,极大地提升了编程的乐趣。我再也回不到没有它的世界了。