编程语言的复杂性与理解性


如果语法糖和更多的语言功能对于有经验的用户来说是一种方便,那么

  • 为什么 Java 在非常简单的情况下却取得了如此大的成功呢?
  • 为什么 Go 能够成功?
  • 为什么Swift 问世时受到质疑?
  • 为什么如此厌恶 C++?

有很多因素:

  • 一个是陡峭的学习曲线,
  • 另一个是炫技(showboaters)

学习曲线
表达能力是有代价的。这些概念增加了语言的复杂性,加大了学习难度。

  • 首先,我被教导 "在 Java 中,一切都是对象",
  • 然后又被教导 "在 Java 中,除了原始类型,一切都是对象",
  • 然后我们不得不添加一些脚注,说明 lambda 只是一个对象的语法,其中有一个方法是临时创建的。

诸如此类,我们不断添加一些小例外或小褶皱。即使所有的概念都能很好地结合在一起,但对于新手来说,要学习的东西仍然非常多。

但这是值得的。只需付出一次学习成本,就能换来更富表现力的语言。初学者的可读性会受到影响,但重要的是经验丰富的语言用户的可读性,而去除重复的模板可以提高可读性。

然而,复杂性也会使问题复杂化。

复杂性加剧
我可以单独向你展示一种语言的每个语言特性,以及这些特性如何让事情变得更好。但现实世界中的代码可能会更加复杂。在实践中,专家们会同时使用多种语言特性。对于局外人来说,这可能非常令人困惑。

如果一种语言有足够的表现力,再加上一群强大的开发人员,最终就会出现像《天空深处的焦点》(The Focused in A Deepness In The Sky)这样的代码:

雷诺特切断了音频:"他们这样持续了很多天。大部分都是私人用语,是一对亲密无间的'专注者'经常发明的东西。"
诺在椅子上直起身来。
"如果他们只能互相交谈,我们就无法进入。你把他们弄丢了吗?"
"没有,至少不是以通常的方式。"

这本书非常棒,不用说太多,书中一群痴迷于某个问题的专家可以衍生出外人无法理解的内部术语(UL)。
在最坏的情况下,他们可能会对重要问题提出强有力的答案,但整个事情对外人来说是难以理解的。

当最熟悉 Java 8 的开发人员继承了一个执行相对简单任务的 Scala 程序并打开它:发现它是用某些函数效果系统编写的时,就会发生这种情况。

def program: Effect[Unit] = for {
  h <- StateT[Effect1, Requests, String]((requests: Requests) => 
    ReaderT[Effect0, Config, (Requests, String)]((config: Config) => 
      EitherT(Task.delay(
        ((requests, host.run(config))).right[Error]
      ))))
  p <- StateT[Effect1, Requests, Int]((requests: Requests) => 
    ReaderT[Effect0, Config, (Requests, Int)]((config: Config) => 
      EitherT(Task.delay(
        ((requests, port.run(config))).right[Error]
      ))))
  _ <- println(s"Using weather service at http://\$h:\$p\n")
    .liftM[ErrorEither]
    .liftM[ConfigReader]
    .liftM[RequestsState]
  _ <- askFetchJudge.forever
} yield ()

这个例子有点夸张,但是当有人第一次遇到一种同时使用复杂语法、高级概念和简洁代码的语言的代码时,这种反应是真实发生的。


这也不仅仅是函数式编程的事情。下面是一些简单易懂的 Go 代码:

func maximumCount(nums []int) int {
    var pos, neg int = 0, 0
    for _, e := range nums {
        if e > 0 {
            pos++
        } else if e < 0 {
            neg++
        }
    }

    if pos > neg {
        return pos
    } else {
        return neg
    }
}

Code Report 的 C++ 解决方案需要了解 count_if 以及 C++ 如何使用 lambdas:

int maximumCount(vector<int>& nums) {
    return std::max(
        std::ranges::count_if(nums, [](auto e) { return e > 0; }),
        std::ranges::count_if(nums, [](auto e) { return e < 0; })
    );
}

而 Rust 解决方案需要迭代器、过滤器等方面的知识。

pub fn maximum_count(nums: Vec<i32>) -> i32 {
    let pos = nums.clone().into_iter().filter(|e| *e > 0).count() as i32;
    let neg = nums.into_iter().filter(|e| *e < 0).count() as i32;
    std::cmp::max(pos, neg)
}

这些 Scala、C++ 和 Rust 代码可能都会让不熟悉的人产生一种特殊的反应:"为什么要使用这么多概念,而解决方案却可以如此简单"。但这些示例却截然不同。Scala 的例子是在炫技,而其他例子则不是


纯粹的炫技表演很少见

func main() {
    numbers := []int{1, 2, 3}

    sumChan := make(chan int)
    go func() {
        sum := 0
        for _, num := range numbers {
            sum += num
        }
        sumChan <- sum
    }()

    totalSum := <-sumChan
    fmt.Printf("Total Sum: %d\n", totalSum)
}

我认为纯粹的炫技是很少见的,而且很少是完全有意为之。如果你了解一门语言的所有复杂函数,那么在创建解决方案时,你可能会使用这些功能。而且,你可能并不清楚这样做会让新手感到吃力;这只是结构化解决方案的一种显而易见的方式。这就是知识的诅咒。

此外,大多数使用表达式语言的人都很享受他们所掌握的权力。如果你了解到一个新的函数、库、技术或其他东西,你可能就会想使用它。也许有时你会在不太需要的时候使用它。有一天,你一觉醒来,发现你的团队之外没有人能理解你的代码。

所以,要使用一些约束。每个人都会经历一个最大化阶段,在这个阶段,他们会使用一些超出他们应该使用的功能,将一个概念推向极限。但也许可以在一个辅助项目中这样做,而不是让别人继承你的代码。

...
from y in Enumerable.Range(0, screenHeight)
  let recenterY = -(y - (screenHeight / 2.0)) / (2.0 * screenHeight)
  select from x in Enumerable.Range(0, screenWidth)
    let recenterX = (x - (screenWidth / 2.0)) / (2.0 * screenWidth)
    let point = ...
    let ray = // And so on for 60 lines
...

如果你在一个小组中,已经形成了自己解决问题的风格,那可能很好。但是,如果有一个聪明、热心的新人加入,而你却很难让他们跟上节奏,这可能是一个合理的信号,让你反思一下为什么会出现这种情况。

给它一个机会
如果你遇到的代码甚至整个编程语言都显得过于聪明,我认为你应该先保留一下自己的意见。

一旦你了解了使用中的习惯用法以及各种功能的工作和交互方式,就能更容易地判断一个给定的解决方案。那个看起来很陌生的解决方案可能很好地概括了语言的表达方式,只是你现在还不熟悉而已。也许,一个只有 if 和循环的解决方案会非常冗长,很难一下子记在脑子里。

也许不是。有些人会寻找挑战,并在没有挑战的地方创造挑战。但是......如果你不了解语言的习惯用语和模式,现在下结论可能为时尚早。

编程语言的特性可以让代码更清晰。它们可以提高可读性。但每个功能也都是编写糟糕代码的新工具。这比人们用其他更简单的结构编写糟糕的代码更糟糕吗?也许是的,但如果你不了解这门语言,就很难判断解决方案的好坏。

如果你打算把职业生涯都花在这上面,那么不断学习是有意义的。要使用一种能让专家们干净利落地解决问题的语言。当然,也不要留下一堆深奥的代码让别人继承。

作者:亚当·戈登·贝尔

网友讨论:
Scala 的许多问题现在也是 Java 的问题,因为该语言已经发展得如此之快,以至于现在有许多不同的做事方式。

使用流进行处理很少有助于提高可读性。使用选项来控制流程是令人厌恶的。也许代码最终会变得更加简洁,也许当你编写它时看起来很好,但是对于你或其他任何人来说,当他们必须进行一些更新时要理清它通常要困难得多。无法理解代码的任何一部分,因为一切都依赖于其他部分,并且过程中没有变量名。

这与正则表达式的问题基本相同。你的代码可能并不难写,但代价是更难的是需要理解发生了什么,更难更新代码以适应不断变化的需求。

=====================================================

使用流进行处理:在某些情况下它们确实有助于提高可读性,因为问题就像流一样。
有时,解决方案所带来的聪明才智可能会给它带来坏名声

流确实对很多事情都有好处:
Set<String> senders = emails.stream().map(Email::senderAddress).collect(Collectors.toSet());
代替

Set<String> senders = new HashSet<>();
for (Email email : emails) { 
  senders.add(email.senderAddress);
}

这有助于提高可读性。
3个月后,当代码变成

Set<String> senders = emails.stream().filter(m -> m.type != Type.Newsletter).map(Email::senderAddress).filter(address -> !address.startsWith("no-reply@")).collect(Collectors.toSet());

你可能会开始希望使用 for 循环。

但是还有其他重构它的方法:只需将其分成多行,就变成了:

Set<String> senders = emails.stream()
    .filter(m -> m.type != Type.Newsletter)
    .map(Email::senderAddress)
    .filter(address -> !address.startsWith("no-reply@"))
    .collect(Collectors.toSet());

如果流太复杂而难以阅读,您可以随时提取其中的一部分并给它添加含义的名称。

filter(address -> !address.startsWith("no-reply@"))
变成
filter(SomeFilterHelper::filterNoreply)