软件架构风格 仓库风格




实用主义

在本系列中,我们进行了一次旋风之旅,浏览了我认为最重要的与函数式编程相关的主题,以及一些我认为很好的相关知识。 我们从基础开始,定义了我认为是FP的本质,并展示了如何通过使用递归在不进行重新分配的情况下实际可行,以及如何通过尾部调用消除使其成为有效的简单迭代。 我们介绍了一流的函数,lambda表达式和闭包,映射,归约和过滤。 我们研究了高阶函数,函数组成和monad,currying,惰性评估,并以持久数据结构作为总结。

功能风格是声明性的。

最重要的是,我希望使您相信功能样式可以应用于大多数语言,因此很有可能适用于您的日常编程,并且可以节省您的时间和精力。做。 原因是编程的功能风格是声明性的。 功能风格使我们可以根据所需的结果编写更多的程序,而命令式风格迫使我们详细说明如何实现目标。 假设我们要通过某些谓词过滤一组项,然后求和。 在命令式中,我们必须迭代集合,用谓词测试每个项目,并在谓词返回true时对其进行累加。 代码的目的很容易在实现它的细节中迷失了。 在功能样式中,我们可以用很多文字来表达我们想要实现的目标,而无需说明如何完成。

这甚至不是一个新主意。 您可能已经使用称为结构化查询语言的声明性数据处理语言已有多年了。 这种语言允许程序员数据的命名存储库中选择字段, 其中数据符合某些指定的谓词。 您可以选择某些数据字段排序 ,或某些数据字段分组以求计数数据,并且可以包括或排除具有某些属性的数据组。 您无需像以往使用基于旧CODASYL模型建立数据库的程序员那样,以编程方式浏览记录,跟踪链接,搜索所需数据并自己进行整理。 SQL和第3部分和第4部分中描述的一阶函数使收集操作之间的相似之处显而易见。 确实,C#语言的设计者更进一步,为它提供了一种类似于SQL的语法,称为语言集成查询(LINQ)。

功能与面向对象的编程。

函数式编程被视为新的大事,有些人错误地认为FP是面向对象编程的后继者,并以某种方式替代了它。 我看到许多博客文章和文章都宣称“ OO已死”。 我自己也不这么认为。 我认为OO和函数式编程是正交且互补的,而不是彼此相对。 显然,OO编程绝对不能客观地丧失其任何品质,因此,它变得过时的唯一方法是,如果出现其他具有相同好处且更好的东西。 在本系列文章的任何地方,我们都没有看到能做到这一点的任何东西。

有些人认为面向对象的程序设计和函数式编程是相反的,或者是互斥的。 他们可能会这样想,因为一流的函数为接口和抽象类的面向对象方法提供了创建抽象的替代方法。 当然,在Clojure中,我对类和接口的依赖远少于在Java中。 但是正如我们在第3部分中看到的那样,使用一流的函数来实现抽象并不是什么新鲜事。 但是,函数式编程使OO编程过时的想法是完全无知的。 在我看来,这背叛了对OO的全部了解。 OO的真正目的是提供一种便捷而有条理的方法来实现动态调度-换句话说,运行时多态-无需诉诸功能指针。 这样看来,OO和FP之间没有矛盾。 两种样式都可以在同一代码中使用,我认为这样做很好。

毕竟,关于OO强制性的任何事情都必须突变状态。 当然,像Java和C#这样的语言使可变状态变得如此自然,以至于这样做通常是阻力最小的路径:但这不是同一回事。 对于我来说,似乎很想像一种语言,它允许您像OO语言一样将数据和函数耦合在一起,并支持继承和抽象类型,同时仍然对突变状态施加约束。 为什么不? 没有人说过对象必须是可变的。

不要偏离最佳位置。

正如我在整个系列中反复强调的那样,每种语言都有自己的“甜”点或功能风格点。 它们在某些语言中会越来越大,在其他语言中会越来越小,并且位于每种语言的不同位置。 无论您使用哪种语言,我都建议不要尝试将功能样式应用于其最佳应用范围之外。 当您发现自己不得不避免扭曲状态或使用功能性编程构造而不得不进行扭曲时,您将知道何时走得太远。 不要仅仅为了它而这样做。 抛弃实用性和教条地应用规则不是实用的。

有几个例子:Java缺少不可变的集合。 因此,没有实际的方法可以通过向现有集合中添加元素来创建集合,例如在Clojure中可以这样做:

user=> (def v [1 2 3])
#'user/v
user=> (into v [4])
[1 2 3 4]

当然,您可以在Java中实现此功能,而无需通过串联流并将结果收集到列表中来改变状态,但这确实不值得。 这将使您的代码更难于理解和掩盖其意图:与功能样式应带来的主要好处完全相反。 只需顺其自然,并在本地更改状态即可:

<T> List<T> into(List<T> list, T value) {
    var newList = new ArrayList<T>();
    newList.addAll(list);
    newList.add(value);
    return newList;
}

Java缺少的另一件事是文字集合初始化程序。 我很希望能够使用Java轻松创建清单和地图,就像在Kotlin或Groovy中那样,但是在编写本文时,我们仍然不能。 您可能很想在Java中使用流来解决此问题,但务实。 这似乎是一个好主意:

List<String> s = Stream.of("fee", "fi", "fo", "fum").collect(toList());

但不要忘记,您可以始终这样做:

List<String> s = Arrays.asList("fee", "fi", "fo", "fum");

溪流不是城里唯一的游戏,可变的做事方式通常是最简单的。 例如,您可以执行此操作以创建地图,但是请不要:

Map<String, Integer> map = Stream.of(
        new AbstractMap.SimpleEntry<>("one", 1),
        new AbstractMap.SimpleEntry<>("two", 2))
        .collect(Collectors.toMap(
            AbstractMap.SimpleEntry::getKey, 
            AbstractMap.SimpleEntry::getValue));

因为这样做更简单,更自然:

Map<String, Integer> map = new HashMap<>();
map.put("one", 1);
map.put("two", 2);

请记住,改变状态不是邪恶或有罪的,没有副作用的程序将毫无用处。 我们不想避免所有的状态变异,我们只是想受到约束。 我们想知道状态在哪里,如何以及何时发生突变,并对其进行封装,以便包含其影响。

为此,请勿使用高阶函数。

考虑到我花了整个系列的四个整集来解释您可以使用一等和高阶函数执行的操作,所以这个建议似乎很奇怪,但我的确是真的。 如果您可以使用更高级的函数而不用比使用它们更高级的函数来编写代码,请选择不使用它们。 一流的高阶函数会增加程序的复杂性,这是一项成本,因此请确保它们能给您带来回报,并使您获利。 使用它们可以使您的代码更好,而不仅仅是更多的“功能”。 函数式编程是一种手段,而不是目的。 在所有其他条件都相同的情况下,最干净的代码应始终获胜; 如果碰巧是命令式代码,那就这样吧。

这些不是您要查找的抽象。

如果我们同意可以用一种面向对象的语言以功能样式进行编程,那么就可以认为放弃OO编程给我们带来的好处是没有意义的。 最重要的是,我的意思是在这里我们可以轻松地创建根据问题域进行表达的类型。 函数式编程也促进了抽象,但是我观察到,精通FP的程序员倾向于创建本质上更具算法性的类型,而不是用领域语言进行交流。 我不会说您应该避免创建算法抽象,但是我强烈建议您不要错过编写特定于域的代码的机会。 类型的谈话在程序旨在解决问题的条款- “内容” -将是谁需要了解你的代码都不见了比的问题是如何被语言陈述的类型后,程序员更大的援助解决了。 请记住,以后的程序员可能是您未来的自己!

还请记住,无论是以OO风格还是功能风格或同时以这两种方式编程,抽象都是要使用的工具,而不是目的本身。 如果有设计目的,则设计应变得更加抽象,但不需要抽象的默认设置应始终是编写直接实现需求的代码。

功能代码更加线程安全。

采用函数式编程还有其他动机,我们在本系列文章中没有涉及到,但值得一提。 将纪律保持在变异状态还有助于避免并发编程的陷阱。 Kevlin Henney喜欢用像这样的象限图来说明这一点。 它显示了共享数据与非共享数据,可变数据与不可变数据的四种可能的组合:


仓库管理架构图怎么做 仓库架构设计风格_人工智能

在此图中,只有一个象限是有问题的,但是程序倾向于在其中收集象似乎在施加某种引力场。 原因并不神秘。 最初,并发编程不存在,因为计算机硬件和操作系统缺少对并发的支持。 因此,所有计算机程序都存在于该图的左侧“非共享”部分。 此外,由于资源的限制和计算机体系结构的原因,数据是易变的,因为这样做很方便。 因此,大多数编程都发生在左上角的“不可共享且可变的”一角,并且这种情况持续了数十年,直到多个核在00年代中期开始成为消费级硬件的标准,然后编程权移至了“共享且可变的” ”一角。 那就是要找到痛苦的地方。

当线程或进程之间共享可变数据时,需要进行同步以防止它们相互干扰,这通常被称为“锁定”。 但是锁就像程序中的门,每次只能通过一个线程执行门:它们使并发的性能优势无效。 与锁定相关的开销甚至可能意味着,如果锁太多或它们位于严重影响性能的位置,则不需要锁的单线程程序的性能要比使用锁的多线程程序更好

如果您的共享数据是不可变的,这些同步问题就会烟消云散。 当数据不会改变时,线程之间共享数据没有问题。 我不会断言这解决了所有并发问题,但是确实可以大大简化事情。 简而言之,函数式编程使并发编程更加容易。

默认情况下,倾向于清晰。

我很想说您应该“赞成简单性”,实际上,编程的简单性一直是我追求的目标,但这说起来容易做起来难。 具有讽刺意味的是,简单绝非简单:我需要多年的经验来学习了解更简单的做事方法。 我可以通过查看我的旧代码并观察现在看起来过于复杂的方式来说明这一点。 因此,尽管我确实建议您偏爱简单性,但是当我知道它真的很难学习时,我不会轻易地说出来。 相反,我将通过提醒Donald Knuth经常引用的警告来结束,因为我认为它特别适合函数式编程:

过早的优化是万恶之源。

这与清晰度有何关系? 我的意思是, 在进行测量和发现有必要之前 ,应避免出于效率低下的考虑而放弃功能样式。 这就是Knuth所说的过早优化。 这是真正不需要优化的97%的情况。 在没有真正的性能和内存问题的情况下,最好调整设计的表现力。 换句话说,在修改代码以使其能被机器有效执行之前,请编写代码以供人类阅读。 在我看来,功能风格做得好会使代码更易于理解。

同样,质疑您对代码效率的假设:通常,人们总是清楚地看到简单的算法要比难以理解的“优化”算法好。 在内存很小的机器上用裸机进行编程的日子已经一去不复返了,准确预测机器在执行程序时的行为将变得不容易。 当今的计算架构非常复杂且多层化:微代码,分支预测和推测执行,CPU缓存,多个内核,虚拟机管理程序,仿真器,容器,操作系统,线程和进程,高级语言和编译器,虚拟运行时,即时编译和解释语言,集群和集群,面向服务的体系结构,无服务器计算等。 随着计算机系统的发展,编程技术大受欢迎。 就像以前人们为了更快地执行循环而展开循环一样,CPU缓存使这种做法适得其反,但是缓存的大小增加了,并且这种做法再次变得有效。

我的观点是,所接受的智慧往往没有它的适用性。 因此,以效率为名进行设计选择时,请确保这些选择实际上是有效的。 测试,测量和比较。 在没有观察到任何性能问题的情况下,最好不要完全针对效率进行优化。

结论。

正如Michael Feathers 在2010年发布的推文

OO通过封装运动部件使代码易于理解。 FP通过最大程度地减少运动部件来使代码易于理解。

我认为这是理解它的好方法。 通过“移动零件”,他清楚地表示突变状态,并注意他说最小化而不是消除 。 当有很多状态在运动时,要预测程序执行时机器的全局状态将如何演变要困难得多。 这是一个问题,因为完整而准确的执行心智模型对于正确编写程序至关重要。 功能样式有助于简化这种心理建模,这只是一件好事。

我写本系列文章的主要目的是用有经验的程序员可以使用的术语来解释函数式编程,但不求助于某些函数式编程爱好者所钟爱的奥秘数学理论。 如果您对数学感兴趣,请学习一下所有内容并从中获得乐趣。 我不反对。 我只是认为这不是在程序中采用功能样式的先决条件。 实际上,我发现它完全是正交的:在听说类别理论之前,我就对函数式编程的有效性深信不疑。 事后学习它并没有增加或减少我的信念。 因此,我想在这里尝试为函数式编程提供一个理由,以反映我自己欣赏它的方式。 通过代码示例演示可以在多种编程语言中采用的功能样式,这样做会带来好处。

我希望本系列文章能激发您的胃口,并希望您以这种方式编写更多代码。 如果是这样,那么我的任务将会成功