17611538698
webmaster@21cto.com

Java 声明式编程

编程语言 2 49 2025-01-07 08:06:24

图片

21CTO导读:


命令式编程:一种软件开发范例,其中解决问题所需的每个步骤中的函数都隐式编码。


声明式编程:一种抽象软件执行操作所需逻辑的控制流的方法。相反,它涉及说明任务或期望结果是什么。

声明式编程是一种特殊的魔法:你只需描述自己想要的逻辑,它就会发生。你无需担心执行的细节,编译器会解决它。

你的代码现在会变得简单而优雅。它可以轻松被其他程序员阅读、审查或贡献。只要确保你正确地指定了最小逻辑,剩下的就很简单了。这绝对是切片面包以来最好的东西,对不对?

嗯……也不完全是,或者至少——不总是。当你只解释要做什么时,“如何”仍然是一个谜,程序员应该害怕有其它谜团。

流(Stream)


让我们举个例子——Java流。


流是在十多年前的Java 8中发布的,它们提供了一种抽象,用于迭代集合,而无需将它们存储在内部数据结构中。


此外,流本质上是函数式的,并允许惰性求值。它们为映射、过滤、分组和缩减等操作提供了丰富的功能——但很难理解其背后发生了什么。


以并行为例——一个返回等效并行流的函数。文档没有提到这种并行性的实现方式。虽然想象出一种用于流式传输数组或列表的实现非常容易,但并行流的实现并不那么简单,并且不能保证所有 Java 提供方都以相同的方式实现它。


即使在抽象的流世界中,你仍然需要明确要求流是并行的,因为它们默认是连续的。你怎么知道何时使用并行流?嗯,显然你有两个考虑并发性的常规因素:我是否有足够的数据(流的大小),对每个元素执行的操作是否足够密集?如果答案是否定的,性能甚至可能会下降,因为并行执行的好处不会抵消线程的开销。对于流,在考虑开销时还有第三个因素:将流拆分为子流的成本。


根据Oracle 的说法:“诸如 ArrayList、HashMap 或简单数组之类的集合可以有效地拆分,而 LinkedList 或基于 I/O 的数据源在这方面效率最低。”


需要做出的另一个决定是如何声明您的逻辑。流的语法允许以几种等效方式执行相同的操作,例如,要对 Long 与流求和,你可以执行以下任一操作:


stream.mapToLong(Long::longValue).sum()stream.reduce(0L, Long::sum)0L, Long::sum)stream.collect(Collectors.summingLong(Long::longValue))
final LongAdder longAdder = new LongAdder();stream.forEach(longAdder::add);


很重要的是,需要对代码进行基准测试。但是,没有样板代码的问题在于你无法对其进行优化。

如果性能不够好,你可以尝试不同的流操作或从另一个角度解决问题——但你无法改变迭代的执行方式。

来玩一个小游戏。我将对以下情况进行基准测试,看看你是否能猜出哪一个更有效率。结果在页面底部。

private static final long MIN_VALUE = 0L;private static final long MAX_VALUE = 10_000L;
...
// With or without "parallel" (simple calculation)?final long streamNoParallelResult = LongStream.range(MIN_VALUE, MAX_VALUE).sum();final long streamParallelResult = LongStream.range(MIN_VALUE, MAX_VALUE).parallel().sum();

// Assume both list have the exact same elements as the range above// ArrayList or LinkedList? map or reduce? final long arrayListResult = arrayList.stream().mapToLong(Long::longValue).sum();final long linkedListResult = linkedList.stream().mapToLong(Long::longValue).sum();final long linkedListReduceResult = linkedList.stream().reduce(0L, Long::sum);
// With or without "parallel" (complicated calculation)?final long arrayListParallelHeavyResult = arrayList .parallelStream() .map(this::heavyOperation) .mapToLong(Long::longValue) .sum();
final long linkedListParallelHeavyResult = linkedList= .parallelStream() .map(this::heavyOperation) .mapToLong(Long::longValue) .sum();
final long arrayListHeavyResult = arrayList .stream() .map(this::heavyOperation) .mapToLong(Long::longValue) .sum();


剧透:使用stream.range是非常高效的;即使没有并行性,ArrayList 也比 LinkedList 更好(这并不奇怪,因为 ArrayList 使用连续内存,这对于读取和缓存非常有用);除非你的任务繁重,否则不应使用 parallelStream,并且 Stream 的 Reduce 与将其映射到 LongStream 并应用 sum 方法之间没有显著差异。

同样值得一提的是,此基准测试是按顺序执行的,因此在实际应用中,其他线程可能不太可用,从而使并行性的好处不那么明显。

在基准测试中,我们发现即使是非常小的变化,比如单个单词并行,性能也会发生巨大变化。程序员和审阅者都很容易忽略它(它的存在或缺失),这使得流有点性能风险。

但是,代码的某些部分对单元测试等性能问题不太敏感。没有人关心它们的执行方式,只要它们最终是绿色的(并且不会将 CI/CD 延迟太久)。认真地问一下我的审阅者,他们可以毫无理由地流式传输 TreeList ,或者使用可能在将来使用Bogosort实现的排序方法的神秘调用。只要它清晰并正确涵盖所有情况,那就没有问题。

注解


单元测试中常用的另一种声明性手段是注解。我真的不关心 Junit 如何运行用@Test注解的方法,也不关心 Mockito 如何初始化@mock字段,只要它始终有效就行。有时简单的代码比高效的代码更重要。


但是,声明性注释也用于代码中更关键的部分,例如 Spring 的@Controller(用于微服务)和@RequestMapping(用于控制器的节点)。


在这种情况下,实现的效率很重要,忽略潜在的瓶颈是有风险的。在选择框架之前,你应该尝试对其进行基准测试,测试其他替代方案,或者编写自己的实现,例如 Outbrain 的内部ob1k。一旦我决定使用框架,我别无选择,只能按预期使用它。在这种情况下,一旦选择了框架,拥有一个简单的注释而没有样板代码正是我想要的。


值得一提的是,注释也有其自身的缺点:它们在运行时进行解释,增加了出现运行时错误的风险,而这些错误可能在编译期间就被检测到,例如错误的类型或错误的值。它们很难测试:虽然 Java 代码可以在单元测试中轻松调用和测试,但注释可能会悄无声息地失败,并且验证是否存在所有正确的注释(并且只有它们)很棘手,而且通常需要使用处理它们的代码,这可能会复杂得多。最后,很难在功能上扩展注释或将它们与其他注释组合在一起。


结论


半个世纪前,玛格丽特·汉密尔顿和她的团队为阿波罗飞行系统编写了 145,000 行汇编代码。


图片

我并不指望你们中的任何人会因为低级语言的必要性而迁移你们基于 Java 的现代微服务或基于 Python 的机器学习库,但在很多情况下,你们至少应该尝试了解底层工作原理。

一层又一层的过甜的语法糖会让你的味蕾和编程技巧变得迟钝。不考虑后果就轻率地使用比康定斯基更抽象的概念是危险的。

声明式编程非常适合清晰地传达想法,或者对于你不关心如何执行的代码部分,无论是因为性能不是问题,还是执行逻辑由外部库等另一方管理,都是一个不错的选择。

对于你希望完全控制的代码部分,命令式编程是更好的选择。如果您希望能够修改或优化完成事情的方式 - 你应该自己动手。即使声明式方式的当前行为看起来不错,你也永远不知道下一次代码更改会导致什么,更不用说 - 一旦负责“如何”的工具更新,即使是未更改的代码的性能也可能会发生变化。

声明式编程并不完美。还有其他方法可以表达相同的想法,这可能会影响性能。如果你最终努力优化声明,例如许多人对 SQL 查询所做的那样,我们的技术可能并不像您想象的那样具有声明性。也许下一代声明式编程将包含一个 NLP 模型,该模型可以理解我们的最终目标并根据其对内部实现的熟悉程度为其选择最佳解决方案。那真的会很神奇。

作者:场长

评论