返回 2026-06-11
⚙️ 工程

Nontrailing separators do not spark joyNontrailing separators do not spark joy

Nontrailing separators do not spark joy

Hillel Wayne

这是合法的 JSON:

{
    "a": 1,
    "b": 2,
    "c": 3
}

这是非法的 JSON:

{
    "a": 1,
    "b": 2,
    "c": 3,
}

区别就在最后一个逗号。JSON 语法规定,逗号用于分隔对象中的两个成员,但不能跟在成员后面(“尾随”)。我认为这是一个设计失误。假设我们要在这个结构体中添加两个新键,一个在 "a" 成员前面,一个在 "c" 成员后面。如果允许尾随逗号,代码看起来会是这样:

{
+   "x": 0,
    "a": 1,
    "b": 2,
    "c": 3,
+   "y": 4,
}

无论在哪里添加键,所做的文本修改都是完全一样的。但在当前的模式下,我们却得这样做:

{
+   "x": 0,
    "a": 1,
    "b": 2,
-   "c": 3
+   "c": 3,
+   "y": 4
}

这可是不同的修改操作!同样地,如果你想删除一个元素,不能仅仅删除对应的行1,你必须删掉那一行,然后再检查最后一行是不是多了一个尾随逗号。更别提交换两行时牵扯到的各种边界情况了。

JSON 并不是唯一存在这个问题的语言。Haskell 是这样写记录类型的:

-- from https://play.haskell.org/
data Drone = Drone
  { xPos :: Int
  , yPos :: Int
  , zPos :: Int
  }

这种将分隔符放在行首的“残缺项目符号”风格,使得修改最后一行变得更容易,但修改第一行却变得更麻烦了。

TLA+ 也有这个问题:

\* both valid
VARIABLES a, b, c
vars == <<a, b, c>>

\* both invalid
VARIABLES a, b, c,
vars == <<a, b, c,>>

这个问题很让人头疼,因为 1) 在编写规约时,你会不断地添加新的顶级变量,而且 2) PlusCal DSL 却没有这个问题:

\* Totally fine!
(*--algorithm foo {
variables a; b; c;

在我看来,最让人抓狂的是 Prolog 等逻辑语言。你不仅不能使用尾随分隔符,还得使用一个特殊的终止符:

foo(A, B, C) :-
    A = 1, % comma
    B = 2, % comma
    C = 3. % period!

我猜你可以勉强把它看作长得有点搞笑的大括号:

foo(A, B, C) :-
    A = 1,
    B = 2,
    C = 3 
.

但这不是标准语法,如果你真这么写,别人会用看怪人的眼神看你。而且你依然用不了尾随分隔符。

更好的方案

有些语言允许尾随分隔符:

// go
valid := map[string]int{
        "a": 1,
        "b": 2,
        "c": 3,
    }
# python
valid = {
  "a": 1,
  "b": 2,
  "c": 3,
}

但我认为我们还可以做得更好。Python 和 Go 的逗号可以放在尾部,但不能放在开头,这意味着我们没法百分之百地采用“项目符号”风格:

# python again
invalid = {
    , "a": 1
    , "b": 2
    , "c": 3
}

就我个人而言,我认为“项目符号”风格简直棒极了,希望更多的语言能允许前导分隔符。实际上,TLA+ 就支持前导的合取和析取操作符:

// Not TLA+ but the same semantics
|| && a == 1
   && b == 2

|| && a == 3
   && b == 4

不过你不能把它们放在末尾,也就是说不能写成 (a &&)。

我见过的最灵活的语言是 Alloy,它同时允许前导和尾随逗号:

// Alloy
sig Valid {
    , a: 1
    , b: 2
}

sig AlsoValid {
    a: 1,
    b: 2,
}

Alloy 在这点上确实有点“放飞自我”了,因为它甚至允许空分隔符。

sig StillValid {
    ,, a: 1,,
    ,,,,,,,,,
    ,, b: 2,,
}

我听人把这叫做“口吃(stuttering)”。我想不出这种特性能用来干什么坏事,但谁知道呢。

唱点反调

反对使用尾随分隔符的一个理由是,它会导致解析时产生歧义。看看这段 Prolog 代码:

foo(A, B) :-
    A = 1, 
    B = 2.

bar(c).

很明显,foo 和 bar 是两个独立的定义。但如果我们把规则终止符替换成逗号:

foo(A, B) :-
    A = 1, 
    B = 2,

bar(c),

这就产生了另一种解析方式:bar(c) 成了 foo 定义的一部分——即只有当 bar(c) 也为真时,foo 才为真。

再举一个例子,这是合法的 Ruby 代码:

# prints 5
puts 3.
     succ().
     succ()

如果我们可以“尾随方法调用”,就会产生歧义:

foo.
  bar().
  baz().

quux()

现在就不清楚 quux() 到底是一个顶层函数,还是 foo 的一个方法了。

这两个例子都与控制分隔符有关,而不是数据分隔符。对于尾随数据分隔符,Python 有一个极端的数据场景。该语言将圆括号既用于表达式分组(如 (2+3)),又用于元组定义(如 (2,3))。那么,你该如何区分一个表达式求值和一个单元素元组呢?答案就是加一个尾随逗号!

>>> x = (2+3)
>>> type(x)
<class 'int'>
>>> x = (2+3,)
>>> type(x)
<class 'tuple'>

好了,我能想到的就这些。《Logic for Programmers》全新的(也是最终的)预览版将于下周发布。

  • 显然,如果你的对象值本身就是多行的数组或对象,这招就不管用了,而且这些情况会让“没有尾随逗号”时的代码修改变得更加复杂。 ↩
  • 需要完整排版与评论请前往来源站点阅读。