我们应该能够改变我们的语言
We Should Be Able to Change Our Languages

原始链接: http://jimmyhmiller.com/change-our-languages

长期以来,编程语言一直被视为不可更改的“神圣”产物。由于担心代码的可读性和调试难度,开发者往往排斥宏(macro)等用户自定义的定制功能。业界历来倾向于严格的标准而非灵活性,这导致为了弥补语言特性的缺失,不得不创建极其复杂的构建系统和规范。 然而,作者认为这些反对宏的传统观点已经过时。随着人工智能的出现,理解复杂或非常规的代码变得轻而易举;大型语言模型(LLM)可以解释、可视化并调试那些曾经令开发者困惑的自定义语法。此外,随着人工智能越来越多地参与代码编写与维护,宏提供了一种强大的方式来强制执行一致性,并定义人工智能可以可靠遵循的高级抽象。 为了推动这一转变,作者引入了“Sweetener”。这是一个为 TypeScript 设计的工具,允许开发者自定义语言结构(如管道操作符或代数数据类型),而无需等待委员会审批。通过拥抱宏,开发者无需再将语言设计外包给外部工具和框架,而是可以根据特定需求重塑代码,从而在编程环境中直接重获创新与表达构思的能力。

这篇 Hacker News 的讨论探讨了编程语言演进的价值,以及利用宏构建自定义领域特定语言(DSL)的利弊。 最初的观点认为,由于人工智能能够解读复杂的非标准代码,开发者应将速度和功能置于人类可读性之上。这一观点遭到了社区的强烈抵制,主要论点如下: * **人类可读性至关重要:** 代码主要是为了人与人之间的交流。优先考虑机器优化或“AI 可读”的代码会产生危险的依赖性,实际上是将人类排除在决策闭环之外。 * **DSL 的隐性成本:** 自定义宏和特异的语法给新团队成员带来了高昂的学习成本。尽管一些开发者为宏作为强大的抽象工具进行辩护,但另一些人警告称,这会导致“宏混乱”(macro soup),使代码库难以维护或调试。 * **基本原则:** 批评者认为,依赖 AI 来解读拙劣的架构是一种有缺陷的工程理念。他们强调,软件设计和架构等基本技能无法被“魔法般”的 AI 辅助所取代,因为 AI 在面对极端复杂的情况时可能会失效。 归根结底,虽然一些评论者表达了构建自定义编译器以获得自主控制权的兴趣,但主流共识倾向于优先使用标准、可维护的语言,以确保长期的团队协作和可靠性。
相关文章

原文

Programming languages are fixed artifacts. Defined by committee or that one really smart person. They are sacred artifacts whose contours are fixed for good reasons. The idea of changing your programming language, of customizing it for your own needs, is a drastic measure that should never be done. Or at least that's how people act. In a world where software is becoming increasingly flexible, moldable, editable, our programming languages resist this change.

Instead, we build incredibly complicated build systems. We create complex conventions that give us the semantics we wished our languages had. We reuse existing language constructs and give them different meanings. We form committees to advocate for changes to our languages, bike-shedding endlessly, because the idea of just letting each individual codebase make its own decisions offends our sensibilities.

Why We Resist#

The ability for people to define their language to be the way they want is not new. It isn't difficult. But there has been a large backlash against it for decades. People avoid languages that offer this functionality. Even in languages that have it, some teams choose to completely ban it. But why exactly is this? I think the most straightforward version of the argument typically given is put quite well in Raymond Chen's 2005 blog post A rant against flow control macros.

When you create a flow-control macro, you’re modifying the language. When I fire up an editor on a file whose name ends in “.cpp” I expect that what I see will be C++ and not some strange dialect that strongly resembles C++ except in the places where it doesn’t.

Chen argues in the body of the post that these kinds of control flow macros obscure what one needs to understand when debugging. They hide the actual operations of the code. They replace the semantics expected of (in this case) cpp files with new semantics that are not known immediately to the person debugging the code.

The argument that only the authors of the macros will understand them and for everyone else, they will be a hindrance has been echoed by many, even those who are big proponents of languages with this exact kind of power.

Macros encourage people who are not good at language design to do something equivalent to language design, using tools that don't help, and with effects that are too powerful. This makes code unreadable to people joining later and for the authors after time has passed. Well-designed macros are well documented, but this doesn't happen much. - Richard P. Gabriel

Gabriel isn't fully against macros. But is hesitant for people to write them. Believing there is some bar that must be met that most people don't meet.

Now, of course, there are other arguments that can be given. But I'll admit, I haven't really heard any that can't be cast in this same light. Reading code chock-full of macros is confusing. You are reading a new programming language, and we don't learn those super often. People hate macros for the same reason google made a language without generics: they make things more complicated.

Do These Reasons Hold Up Anymore?#

I'll be honest, I've never found the arguments against macros convincing. Control flow macros I've seen in the wild were always introduced to solve the very things Chen was worried about (I was in Clojure, not cpp though); they made sure that every site's resources were handled well. I didn't have to audit the code over and over at each call site, but once at a macro point. But I doubt most of my readers feel the same way. Defining our own language constructs has remained controversial, and these arguments hold weight in the minds of many. So instead, I will try to convince you that the times have changed. That given the state of programming today, macros no longer pose as serious a problem even if we take the criticisms seriously.

It Has Never Been Easier to Understand Code#

Today, you aren't reliant on finding a local expert on some code, consulting the original author, asking on stackoverflow and hoping someone answers you, or spending a long time trying things out and reading docs to understand a complicated piece of code. Instead, you can use an LLM to help you gain understanding in the ways that fit how you learn. For me, this often means custom visualizations, demos, and asking increasingly specific questions. But sometimes a simple explanation and example are more than enough. Consider code I recently found that I didn't fully understand (not macro-related).

export type _ActionCreatorWithPreparedPayload<
  PA extends PrepareAction<any> | void,
  T extends string = string
> = PA extends PrepareAction<infer P>
  ? ActionCreatorWithPreparedPayload<
      Parameters<PA>,
      P,
      T,
      ReturnType<PA> extends {
        error: infer E
      }
        ? E
        : never,
      ReturnType<PA> extends {
        meta: infer M
      }
        ? M
        : never
    >
  : void

This is the kind of code where I can kind of piece together what each individual part might be doing, but trying to understand the why and the what is difficult for me. I get lost in all the details. But with two questions to claude it was all cleared up for me. If you don't understand it, do the same. See how much easier this is than in the past?

Understanding Each Bit of Code is Less Important Than It's Ever Been#

There's quite a bit of controversy around the idea of committing code that no one has read and understood. I've always worked in codebases large enough and old enough (and ugly enough) that no one understands all the code written in them. In fact, I'll even contend that much of the code that was written wasn't understood when it was written. So this idea isn't super controversial to me. But I think people are starting to see what can be accomplished today without anyone understanding the code underneath. Full, useful applications can be built. Complicated projects can be launched.

Things have been changing drastically. There was once a point where it made sense to create suboptimal code if it meant that your team understood it better. Why? Because changes to the code provided more business value than the code itself being optimal, and if your team didn't understand it, no one could change things. But this is becoming less and less the case. If you can make code faster, better, even at the cost of readability and understandability, by you, but the AI can work with it perfectly fine, why wouldn't you?

Sweetener, Macros for TypeScript#

So I think the time is ripe for macros. AI makes it trivial to write macros. AI makes it trivial to understand complex macros. Debugging code has become easier. And as I'll touch on later, I think AI-written code might benefit greatly from macros. But there still is a problem. Languages don't have them. At least not powerful ones. C-style macros don't count. Rust has intentionally limited its macros in a way where they are not language constructs at all, but rather explicit, delimited things. Lisps are too weird for people. For a brief period in time, SweetJs was the perfect vehicle. But sadly, with the rewrite of it being abandoned, it no longer served that purpose. So, I have resurrected the idea of it, now aimed at TypeScript, called Sweetener.

Our Own Constructs#

So let's take a super brief tour of the kinds of things you can do with macros in sweetener. The pipe operator is a much-debated, almost certainly dead proposal to JavaScript. Here is the good version (imo) of the pipe operator. The simple thread-first version.

import { (|>) } from "./macros.sts" for syntax;

function map...
function reduce...

export const total = [1, 2, 3]
|> map((n) => n * 2)
|> reduce((sum, n) => sum + n, 0);

export const longest = ["pipes", "read", "left", "to", "right"]
|> map((word) => word.length)
|> reduce((most, length) => Math.max(most, length), 0);

Rather than waiting for a committee for nearly a decade. We can create the operator ourselves. And what code do we need to accomplish this?

export operator (|>):expr {
  fixity infix;
  associativity left;
  precedence 35;

  rule { 
    $value:expr 
    |> $function:ident $(. $member:ident)* ($($argument:expr),*) 
  } => {
    $function $(. $member)*($value #if(present $argument) {, $($argument),*})
  }

  rule { 
    $value:expr 
    |> $function:ident $(. $member:ident)* 
  } => {
    $function $(. $member)*($value)
  }
}

But simply adding a custom operator is not enough. You can add any constructor you want. Maybe you're a fan of Rust-style enums, aka algebraic datatypes.

Algebraic Datatypes#

data Tree<T> = Leaf() | Node(left: Tree<T>, value: T, right: Tree<T>);

export const total = (tree: Tree<number>): number => {
  return match (tree) {
    Leaf() => 0;
    Node(left, value, right) => total(left) + value + total(right);
  };
}

Now, you can easily define them yourself and customize things however you want. This version has pattern matching and exhaustiveness checking built-in.

Control Flow in TSX#

export const list = (items: readonly Item[]) => (
  <ul>
    {each (items as item, index)}
      <li key={item.id}>{index}: {item.name}</li>
    {end}
  </ul>
);

I know a ton of people hate writing the map stuff in tsx. With sweetener, you can define anything you want and add it to your tsx without any pain or asking anyone else to support it.

Why Care About Macros If I'm Not Reading the Code#

It is perhaps the greatest irony that I am proposing things that make code look different when many people are no longer looking at code. Don't AIs just not care about all this higher-level stuff? Can't we just have them write the lowest-level code? Deal with all the boilerplate, and they will just get it right?

I actually think macros might have an even greater benefit for AI-written code. In my experience, AI-written code is quite good at introducing inconsistencies across repeated things. As codebases grow, they are not seeing the codebase as a whole. They do not follow the full conventions. What they do is instead copy code around them and match the constructs they see. A file full of macros for some nice error handling or for defining types in a nice standard way, or using the pipeline operator to make sure you have a nice discipline for how functions are defined, can help you tame your codebases implicitly. Macros encode larger patterns and automatically make sure AIs follow them.

Macros are not Enough#

I created sweetener in the hopes that it can give more people a chance to explore macros in a familiar context. But also, because macros in a setting like this are a fantastic way to explore language design. Rather than needing to create a compiler, we can play with the shape of a language feature that no existing language has and see if we can make something interesting. For example, the morning paper covered a paper that generalizes the idea of method names to allow regular expression like parts. We can now prototype this and play with it.

But there is so much more we need to do with our languages. We have spent countless engineering hours building tooling, parsers, linters, type checkers, build tools, all because our languages don't give us the ability to do what we need. Rather than letting us express the concepts we need to express as first-class parts of our programs, rather than trusting us to use our powers well, to give us full expressivity, we have outsourced this to complicated external build tools, complicated external linting systems, testing frameworks with their own runners and conventions for running code.

And why? Because we didn't trust the average programmer to do it right. We didn't trust them to document it well. We didn't trust our future coworkers to understand what we had written. Code that was homegrown was suspicious. Instead, we needed others to build our tooling for us. We needed to buy into a complicated external framework. We needed that division, because it was just too hard.

That's no longer the case.

It has never been easier to build what you want to build. To imagine things and bring them to life. It's also never been easier to learn new things in programming. It has never been easier to migrate to a new language. To adopt new technology, or to make the customizations you need to if your language lets you. We are living with languages created when this wasn't reality. Languages that are afraid that their users will use them wrong. Languages that don't give us the power to do what we want to do. It is time that changed.

联系我们 contact @ memedata.com