563

什么是设计/构造大型功能程序的好方法,尤其是在 Haskell 中?

我已经阅读了很多教程(Write Yourself a Scheme 是我最喜欢的,Real World Haskell 紧随其后)——但大多数程序都相对较小,而且用途单一。此外,我不认为其中一些特别优雅(例如,WYAS 中的大量查找表)。

我现在想编写更大的程序,有更多的移动部分——从各种不同的来源获取数据,清理它,以各种方式处理它,在用户界面中显示它,持久化它,通过网络通信等等。怎么可能一种最好的代码结构是清晰、可维护和适应不断变化的需求?

对于大型面向对象的命令式程序,有相当多的文献解决了这些问题。MVC、设计模式等想法是实现诸如关注点分离和面向对象风格中的可重用性等广泛目标的不错的处方。此外,较新的命令式语言适用于“随成长而设计”的重构风格,在我的新手看来,Haskell 似乎不太适合这种风格。

是否有与 Haskell 相当的文献?函数式编程(单子,箭头,应用程序等)中可用的奇异控制结构的动物园如何最好地用于此目的?您可以推荐哪些最佳实践?

谢谢!

编辑(这是唐斯图尔特回答的后续行动):

@dons 提到:“Monads 以类型捕获关键架构设计。”

我想我的问题是:一个人应该如何用一种纯函数式语言来思考关键的架构设计?

考虑几个数据流和几个处理步骤的例子。我可以将数据流的模块化解析器编写为一组数据结构,并且可以将每个处理步骤实现为纯函数。一条数据所需的处理步骤将取决于其价值和其他数据。某些步骤之后应该有一些副作用,例如 GUI 更新或数据库查询。

以一种很好的方式将数据和解析步骤联系起来的“正确”方法是什么?可以编写一个大函数来为各种数据类型做正确的事情。或者可以使用 monad 来跟踪到目前为止已处理的内容,并让每个处理步骤从 monad 状态中获取下一步需要的任何内容。或者可以编写大部分独立的程序并发送消息(我不太喜欢这个选项)。

他链接的幻灯片有一个“我们需要的东西”项目符号:“将设计映射到类型/函数/类/单子的惯用语”。有哪些成语?:)

4

8 回答 8

516

在 Haskell 的 Engineering Large Projects和 XMonad的设计和实现中谈到了这一点。大工程是关于管理复杂性的。Haskell 中用于管理复杂性的主要代码结构机制是:

类型系统

  • 使用类型系统来强制抽象,简化交互。
  • 通过类型强制执行键不变量
    • (例如,某些值不能逃脱某些范围)
    • 那个特定的代码不做IO,不接触磁盘
  • 强制安全:检查异常(可能/要么),避免混淆概念(字、整数、地址)
  • 良好的数据结构(如 zippers)可以使某些类别的测试变得不必要,因为它们可以静态排除例如越界错误。

分析器

  • 提供程序堆和时间配置文件的客观证据。
  • 特别是堆分析是确保没有不必要的内存使用的最佳方法。

纯度

  • 通过删除状态显着降低复杂性。纯功能代码可扩展,因为它是组合的。您所需要的只是确定如何使用某些代码的类型——当您更改程序的其他部分时,它不会神秘地中断。
  • 使用大量“模型/视图/控制器”风格的编程:尽快将外部数据解析为纯函数数据结构,对这些结构进行操作,然后在所有工作完成后,渲染/刷新/序列化。保持大部分代码纯净

测试

  • QuickCheck + Haskell 代码覆盖率,以确保您正在测试您无法使用类型检查的内容。
  • GHC + RTS 非常适合查看您是否在 GC 上花费了太多时间。
  • QuickCheck 还可以帮助您为模块识别干净、正交的 API。如果您的代码的属性难以说明,它们可能太复杂了。继续重构,直到你有一组干净的属性可以测试你的代码,并且组合得很好。那么代码可能也设计得很好。

用于结构化的单子

  • Monads 以类型捕获关键架构设计(此代码访问硬件,此代码是单用户会话等)
  • 例如,xmonad 中的 X monad 精确地捕获了系统的哪些组件可以看到哪些状态的设计。

类型类和存在类型

  • 使用类型类提供抽象:将实现隐藏在多态接口后面。

并发和并行

  • 潜入par您的程序,以简单、可组合的并行性击败竞争对手。

重构

  • 您可以在 Haskell 中进行很多重构。如果您明智地使用类型,这些类型可确保您的大规模更改是安全的。这将有助于您的代码库扩展。确保您的重构将导致类型错误,直到完成。

明智地使用 FFI

  • FFI 使使用外来代码更容易,但外来代码可能很危险。
  • 在假设返回数据的形状时要非常小心。

元编程

  • 一些模板 Haskell 或泛型可以删除样板。

包装和分销

  • 使用阴谋集团。不要滚动你自己的构建系统。(编辑:实际上您现在可能想使用Stack来开始使用。)。
  • 使用 Haddock 获取优秀的 API 文档
  • graphmod这样的工具可以显示你的模块结构。
  • 尽可能依赖 Haskell 平台版本的库和工具。这是一个稳定的基础。(编辑:同样,这些天您可能希望使用Stack来获得稳定的基础并运行。)

警告

  • 用于-Wall保持您的代码没有异味。您还可以查看 Agda、Isabelle 或 Catch 以获得更多保证。对于类似 lint 的检查,请参阅伟大的hlint,它将提出改进建议。

使用所有这些工具,您可以控制复杂性,尽可能多地消除组件之间的交互。理想情况下,你有一个非常大的纯代码库,这很容易维护,因为它是组合的。这并不总是可能的,但值得瞄准。

一般来说:将系统的逻辑单元分解成最小的引用透明组件,然后在模块中实现它们。组件集(或内部组件)的全局或本地环境可能会映射到 monad。使用代数数据类型来描述核心数据结构。广泛分享这些定义。

于 2010-06-20T01:42:05.047 回答
117

Don 为您提供了上面的大部分细节,但这是我在 Haskell 中执行非常详细的有状态程序(如系统守护程序)中的两分钱。

  1. 最后,您生活在一个单子变压器堆栈中。底部是 IO。除此之外,每个主要模块(在抽象意义上,而不是在文件中的模块意义上)将其必要的状态映射到该堆栈中的一个层。因此,如果您将数据库连接代码隐藏在模块中,则将其全部编写为 MonadReader Connection m => ... -> m ... 类型,然后您的数据库函数始终可以在没有其他函数的情况下获得它们的连接模块必须知道它的存在。您最终可能会得到一层承载您的数据库连接,另一层承载您的配置,第三层承载各种信号量和 mvar,用于解决并行和同步问题,另一层承载您的日志文件句柄等。

  2. 首先弄清楚你的错误处理。目前 Haskell 在大型系统中最大的弱点是过多的错误处理方法,包括像 Maybe 这样的糟糕方法(这是错误的,因为您无法返回有关问题的任何信息;始终使用 Either 而不是 Maybe,除非您真的只是意味着缺失值)。首先弄清楚你将如何做,然后将你的库和其他代码使用的各种错误处理机制设置为最终的适配器。这将在以后为您节省一个悲伤的世界。

附录(摘自评论;感谢Liiliminalisht)——
更多关于将大型程序分割成堆栈中的 monad 的不同方法的讨论:

Ben Kolera对这个主题进行了非常实用的介绍,Brian Hurt讨论了将lift单子操作添加到自定义单子中的问题的解决方案。George Wilson展示了如何使用mtl实现所需类型类的任何 monad 编写代码,而不是您的自定义 monad 类型。Carlo Hamalainen写了一些简短而有用的笔记来总结 George 的演讲。

于 2010-06-21T10:39:08.187 回答
43

用 Haskell 设计大型程序与用其他语言设计并没有什么不同。大型编程是将您的问题分解为可管理的部分,以及如何将它们组合在一起;实现语言不太重要。

也就是说,在大型设计中,最好尝试利用类型系统来确保您只能以正确的方式将您的部分组合在一起。这可能涉及新类型或幻像类型,以使看起来具有相同类型的事物有所不同。

在进行代码重构时,纯度是一大福音,因此请尽量保持代码的纯度。纯代码很容易重构,因为它与程序的其他部分没有隐藏的交互。

于 2010-06-20T09:29:46.157 回答
16

我确实是第一次通过这本书学习了结构化函数式编程。它可能不是您正在寻找的东西,但对于函数式编程的初学者来说,这可能是学习构建函数式程序的最佳第一步之一 - 与规模无关。在所有抽象层次上,设计都应始终具有清晰排列的结构。

函数式编程的工艺

函数式编程的工艺

http://www.cs.kent.ac.uk/people/staff/sjt/craft2e/

于 2010-10-17T23:23:50.793 回答
11

我目前正在写一本名为“功能设计与建筑”的书。它为您提供了一整套如何使用纯函数式方法构建大型应用程序的技术。它描述了许多功能模式和想法,同时构建了一个类似 SCADA 的应用程序“Andromeda”,用于从头开始控制宇宙飞船。我的主要语言是 Haskell。本书涵盖:

  • 使用图表进行架构建模的方法;
  • 需求分析;
  • 嵌入式 DSL 领域建模;
  • 外部DSL设计和实现;
  • 单子作为具有效果的子系统;
  • 自由单子作为功能接口;
  • 带箭头的 eDSL;
  • 使用 Free monadic eDSL 进行控制反转;
  • 软件事务内存;
  • 镜片;
  • 状态、读取器、写入器、RWS、ST 单子;
  • 不纯状态:IORef、MVar、STM;
  • 多线程和并发域建模;
  • 图形用户界面;
  • UML、SOLID、GRASP等主流技术和方法的适用性;
  • 与不纯子系统的交互。

您可能会在这里熟悉本书的代码,以及“Andromeda”项目代码。

我希望在 2017 年底完成这本书。在此之前,您可以在此处阅读我的文章“函数式编程中的设计和架构”(Rus) 。

更新

我在网上分享了我的书(前 5 章)。见Reddit 上的帖子

于 2016-11-20T05:25:58.980 回答
7

Gabriel 的博文Scalable program architectures可能值得一提。

Haskell 设计模式在一个重要方面不同于主流设计模式:

  • 传统架构:将 A 类型的多个组件组合在一起以生成 B 类型的“网络”或“拓扑”

  • Haskell 架构:将 A 类型的多个组件组合在一起以生成相同类型 A 的新组件,其特征与其替代部分无法区分

经常让我感到震惊的是,一个明显优雅的架构往往会以自下而上的方式从表现出这种良好的同质感的库中脱颖而出。在 Haskell 中,这一点尤为明显——传统上被认为是“自上而下的架构”的模式往往会在mvcNetwireCloud Haskell等库中被捕获。也就是说,我希望这个答案不会被解释为试图取代这个线程中的任何其他答案,只是结构选择可以而且应该在理想情况下由领域专家在库中抽象出来。在我看来,构建大型系统的真正困难在于评估这些库的架构“优点”与您所有的实用问题。

正如liminalisht在评论中提到的那样,类别设计模式是 Gabriel 关于该主题的另一篇文章,以类似的方式。

于 2014-08-31T10:07:39.960 回答
5

我发现 Alejandro Serrano 的论文使用 Haskell 教授软件架构”(pdf)对于思考 Haskell 中的大规模结构很有用。

于 2016-02-18T19:24:30.863 回答
3

也许您必须退后一步,首先考虑如何将问题的描述转化为设计。由于 Haskell 的层次如此之高,它可以将问题的描述以数据结构的形式捕获,将动作作为过程,将纯粹的转换作为函数。然后你有一个设计。当您编译此代码并在代码中发现有关缺少字段、缺少实例和缺少 monadic 转换器的具体错误时,开发就开始了,因为例如,您从需要在 IO 过程中具有特定状态 monad 的库执行数据库访问。瞧,有程序。编译器为您提供心理草图,并为设计和开发提供连贯性。

通过这种方式,您从一开始就受益于 Haskell 的帮助,并且编码是自然的。如果您想到的是一个具体的普通问题,我不会在意做一些“功能性”或“纯粹”或足够通用的事情。我认为过度工程是 IT 中最危险的事情。当问题是创建一个抽象一组相关问题的库时,情况就不同了。

于 2013-04-30T08:50:39.170 回答