Haskell 中的大规模设计? [关闭]
Posted
技术标签:
【中文标题】Haskell 中的大规模设计? [关闭]【英文标题】:Large-scale design in Haskell? [closed] 【发布时间】:2011-03-05 21:45:24 【问题描述】:什么是设计/构造大型函数式程序的好方法,尤其是在 Haskell 中?
我已经阅读了很多教程(Write Yourself a Scheme 是我最喜欢的,Real World Haskell 紧随其后)——但大多数程序都相对较小,而且用途单一。此外,我不认为其中一些特别优雅(例如,WYAS 中的大量查找表)。
我现在想编写更大的程序,有更多的移动部件 - 从各种不同的来源获取数据,清理它,以各种方式处理它,在用户界面中显示它,持久化它,通过网络通信等. 怎样才能最好地构建这样的代码,使其清晰易读、可维护并适应不断变化的需求?
对于大型面向对象的命令式程序,有相当多的文献解决了这些问题。 MVC、设计模式等想法是实现诸如关注点分离和面向对象风格中的可重用性等广泛目标的不错的处方。此外,较新的命令式语言适用于“随成长而设计”的重构风格,在我的新手看来,Haskell 似乎不太适合。
是否有与 Haskell 相当的文献?函数式编程(单子,箭头,应用程序等)中可用的奇异控制结构的动物园如何最好地用于此目的?您可以推荐哪些最佳做法?
谢谢!
编辑(这是唐斯图尔特回答的后续):
@dons 提到:“Monads 以类型捕获关键架构设计。”
我想我的问题是:应该如何考虑用纯函数式语言进行关键架构设计?
考虑几个数据流和几个处理步骤的例子。我可以将数据流的模块化解析器编写为一组数据结构,并且可以将每个处理步骤实现为纯函数。一条数据所需的处理步骤将取决于其价值和其他数据。某些步骤之后应该有一些副作用,例如 GUI 更新或数据库查询。
以一种很好的方式将数据和解析步骤联系起来的“正确”方法是什么?可以编写一个大函数来为各种数据类型做正确的事情。或者可以使用 monad 来跟踪到目前为止已处理的内容,并让每个处理步骤从 monad 状态中获取下一步需要的任何内容。或者可以编写大部分独立的程序并发送消息(我不太喜欢这个选项)。
他链接的幻灯片有一个“我们需要的东西”项目符号:“将设计映射到 types/functions/classes/monads”。成语是什么?:)
【问题讨论】:
我认为用函数式语言编写大型程序的核心思想是通过消息传递进行通信的小型、专用和无状态模块。当然,您必须假装一点,因为真正的程序需要状态。我认为这就是 F# 超越 Haskell 的地方。 @Chaos 但默认情况下只有 Haskell 强制执行无状态。你别无选择,必须努力在 Haskell 中引入状态(以打破组合性):-) @ChaosPandion:理论上我并不反对。当然,在命令式语言(或围绕消息传递设计的函数式语言)中,我很可能会这样做。但是 Haskell 有其他处理状态的方法,也许它们让我保留了更多的“纯”好处。 我在本文档的“设计指南”下写了一点:community.haskell.org/~ndm/downloads/… @JonHarrop 我们不要忘记,虽然 MLOC 在比较类似语言的项目时是一个很好的指标,但对于跨语言比较来说没有多大意义,尤其是像 Haskell 这样代码重用的语言与现有的某些语言相比,模块化更加简单和安全。 【参考方案1】:我在Engineering Large Projects in Haskell 和Design and Implementation of XMonad. 中谈到了这一点,大工程是关于管理复杂性的。 Haskell 中用于管理复杂性的主要代码结构机制是:
类型系统
使用类型系统强制抽象,简化交互。 通过类型强制执行键不变量 (例如,某些值无法逃脱某些范围) 某些代码不进行 IO,不接触磁盘 强制安全:检查异常(Maybe/Either),避免混淆概念(Word、Int、Address) 良好的数据结构(如拉链)可以使某些类别的测试变得不必要,因为它们排除了例如静态越界错误。分析器
提供程序堆和时间配置文件的客观证据。 尤其是堆分析是确保没有不必要的内存使用的最佳方法。纯度
通过移除状态显着降低复杂性。纯功能代码可扩展,因为它是组合的。您所需要的只是确定如何使用某些代码的类型——当您更改程序的其他部分时,它不会神秘地中断。 使用大量“模型/视图/控制器”风格的编程:尽快将外部数据解析为纯函数数据结构,对这些结构进行操作,然后在所有工作完成后,渲染/刷新/序列化。让您的大部分代码保持纯净测试
QuickCheck + Haskell 代码覆盖率,以确保您正在测试您无法使用类型检查的内容。 GHC + RTS 非常适合查看您是否在 GC 上花费了太多时间。 QuickCheck 还可以帮助您为您的模块识别干净、正交的 API。如果您的代码的属性难以说明,它们可能太复杂了。继续重构,直到你有一组干净的属性可以测试你的代码,并且组合得很好。那么代码可能也设计得很好。结构单子
Monad 以类型捕获关键架构设计(此代码访问硬件,此代码是单用户会话等) 例如xmonad 中的 X monad 准确地捕获了系统的哪些组件可见的状态的设计。类型类和存在类型
使用类型类提供抽象:将实现隐藏在多态接口后面。并发性和并行性
将par
潜入您的程序,以轻松、可组合的并行性击败竞争对手。
重构
您可以在 Haskell 中重构很多。如果您明智地使用类型,这些类型可确保您的大规模更改是安全的。这将有助于您的代码库扩展。确保您的重构在完成之前会导致类型错误。明智地使用 FFI
FFI 使使用外来代码更容易,但外来代码可能很危险。 在假设返回数据的形状时要非常小心。元编程
一些 Template Haskell 或泛型可以删除样板。包装和分发
使用阴谋集团。不要滚动你自己的构建系统。 (编辑:实际上您现在可能想使用Stack 来开始使用。)。 使用 Haddock 获取优秀的 API 文档 graphmod 等工具可以显示您的模块结构。 尽可能依赖 Haskell 平台版本的库和工具。这是一个稳定的基础。 (编辑:同样,这些天您可能希望使用Stack 来获得稳定的基础并运行。)警告
使用-Wall
保持您的代码没有异味。您还可以查看 Agda、Isabelle 或 Catch 以获得更多保证。对于类似 lint 的检查,请参阅伟大的 hlint,它将提出改进建议。
使用所有这些工具,您可以控制复杂性,尽可能多地消除组件之间的交互。理想情况下,你有一个非常大的纯代码库,这很容易维护,因为它是组合的。这并不总是可能的,但值得瞄准。
一般来说:分解系统的逻辑单元为最小的引用透明组件,然后在模块中实现它们。组件集(或内部组件)的全局或本地环境可能会映射到 monad。使用代数数据类型来描述核心数据结构。广泛分享这些定义。
【讨论】:
感谢 Don,您的回答非常好 - 这些都是有价值的指导方针,我会定期参考。不过,我想我的问题在人们需要所有这些之前发生了一步。我真正想知道的是“将设计映射到类型/函数/类/单子的惯用语”......我可以尝试发明自己的,但我希望可能在某处提炼出一组最佳实践 -或者,如果没有,推荐结构良好的代码来阅读大型系统(而不是,比如说,一个专注的库)。我编辑了我的帖子以更直接地提出同样的问题。 我添加了一些关于模块分解设计的文本。您的目标是将逻辑相关的功能识别到与系统其他部分具有引用透明接口的模块中,并尽快、尽可能多地使用纯功能数据类型,以安全地对外部世界进行建模。 xmonad 设计文档涵盖了很多内容:xmonad.wordpress.com/2009/09/09/… 我试图从 Engineering Large Projects in Haskell 演讲中下载幻灯片,但链接似乎已损坏。这是一个有效的:galois.com/~dons/talks/dons-londonhug-decade.pdf 我设法找到了这个新的下载链接:pau-za.cz/data/2/sprava.pdf @Heather 尽管我之前在评论中提到的页面上的下载链接不起作用,但看起来幻灯片仍然可以在 scribd 上查看:scribd.com/doc/19503176/The-Design-and-Implementation-of-xmonad【参考方案2】:Don 为您提供了上面的大部分详细信息,但这是我在 Haskell 中做系统守护程序等真正细节的有状态程序的两分钱。
最后,你生活在一个单子变压器堆栈中。底部是 IO。除此之外,每个主要模块(在抽象意义上,而不是在文件中的模块意义上)将其必要的状态映射到该堆栈中的一个层。因此,如果您将数据库连接代码隐藏在模块中,则将其全部编写为 MonadReader Connection m => ... -> m ... 类型,然后您的数据库函数始终可以在没有其他函数的情况下获得它们的连接模块必须知道它的存在。您最终可能会使用一层承载您的数据库连接,另一层承载您的配置,第三层承载各种信号量和 mvar,用于解决并行性和同步问题,另一层承载您的日志文件句柄,等等。
弄清楚你的错误处理首先。目前 Haskell 在大型系统中最大的弱点是过多的错误处理方法,包括像 Maybe 这样的糟糕方法(这是错误的,因为您无法返回有关问题的任何信息;总是使用 Either 而不是 Maybe,除非您真的只是意味着缺失值)。首先弄清楚你将如何做,然后将你的库和其他代码使用的各种错误处理机制设置为最终的适配器。这将在以后为您节省一个悲伤的世界。
附录(摘自 cmets;感谢 Lii 和 liminalisht)— 更多关于将大型程序分割成堆栈中的 monad 的不同方法的讨论:
Ben Kolera 对该主题进行了非常实用的介绍,Brian Hurt 讨论了lift
将单子动作放入自定义单子的问题的解决方案。 George Wilson 展示了如何使用 mtl
编写代码,该代码适用于实现所需类型类的任何 monad,而不是您的自定义 monad 类型。 Carlo Hamalainen 写了一些简短有用的笔记来总结 George 的演讲。
【讨论】:
两个优点!这个答案具有相当具体的优点,而其他答案则不然。阅读更多关于将大型程序切片为堆栈中的 monad 的不同方法的讨论会很有趣。如有此类文章,请张贴链接! @Lii Ben Kolera 对该主题进行了非常实用的介绍,Brian Hurt 讨论了lift
将单子动作放入自定义单子的问题的解决方案。 George Wilson 展示了如何使用 mtl
编写代码,该代码适用于实现所需类型类的任何 monad,而不是您的自定义 monad 类型。 Carlo Hamalainen 写了一些简短而有用的笔记来总结 George 的演讲。
我同意 monad 转换器堆栈往往是关键的架构基础,但我非常努力地将 IO 排除在外。这并不总是可能的,但如果你考虑一下 monad 中的“然后”是什么意思,你可能会发现在底部某处确实有一个延续或自动机,然后可以通过“运行”函数将其解释为 IO。跨度>
正如@PaulJohnson 已经指出的那样,这种 Monad Transformer Stack 方法似乎与 Michael Snoyman 的 ReaderT Design Pattern 冲突。【参考方案3】:
用 Haskell 设计大型程序与用其他语言设计并没有什么不同。 大型编程是将您的问题分解为可管理的部分,以及如何将它们组合在一起;实现语言不太重要。
也就是说,在大型设计中,最好尝试利用类型系统来确保您只能以正确的方式将各个部分组合在一起。这可能涉及新类型或幻像类型,以使看起来具有相同类型的事物有所不同。
在您进行代码重构时,纯度是一大福音,因此请尽量保持代码的纯度。纯代码很容易重构,因为它与程序的其他部分没有隐藏的交互。
【讨论】:
我实际上发现如果需要更改数据类型,重构是非常令人沮丧的。它需要繁琐地修改大量构造函数和模式匹配的数量。 (我同意将纯函数重构为相同类型的其他纯函数很容易——只要不涉及数据类型) @Dan 当您使用记录时,您可以通过较小的更改(例如添加一个字段)完全免费。有些人可能想让记录成为一种习惯(我就是其中之一^^”)。 @Dan 我的意思是,如果您更改任何语言的函数的数据类型,您不必这样做吗?我看不出像 Java 或 C++ 这样的语言在这方面会如何帮助你。如果你说你可以使用两种类型都遵循的某种通用接口,那么你应该在 Haskell 中使用 Typeclasses。 @semicon 像 Java 这样的语言的不同之处在于存在成熟的、经过良好测试的和全自动的重构工具。通常,这些工具具有出色的编辑器集成,并消除了与重构相关的大量繁琐工作。 Haskell 为我们提供了一个出色的类型系统,用于检测在重构中必须更改的内容,但实际执行重构的工具(目前)非常有限,尤其是与 Java 中已经可用的工具相比生态系统超过 10 年。【参考方案4】:我第一次使用this book 确实学习了结构化函数式编程。 它可能不是您正在寻找的东西,但对于函数式编程的初学者来说,这可能是学习构建函数式程序的最佳第一步之一 - 与规模无关。在所有抽象级别上,设计都应始终具有清晰排列的结构。
函数式编程的工艺
http://www.cs.kent.ac.uk/people/staff/sjt/craft2e/
【讨论】:
与 FP 的工艺一样伟大——我从中学到了 Haskell——它是一个介绍性文本给初学者的程序员,而不是Haskell 中大型系统的设计。 嗯,这是我所知道的关于设计 API 和隐藏实现细节的最好的书。通过这本书,我成为了一个更好的 C++ 程序员——因为我学会了更好的方法来组织我的代码。好吧,你的经验(和答案)肯定比这本书好,但 Dan 可能仍然是 Haskell 的初学者。 (where beginner=do write $ tutorials `about` Monads
)【参考方案5】:
我目前正在写一本名为“功能设计与建筑”的书。它为您提供了一套完整的技术,如何使用纯函数方法构建大型应用程序。它描述了许多功能模式和想法,同时构建了一个类似于 SCADA 的应用程序“Andromeda”,用于从头开始控制宇宙飞船。我的主要语言是 Haskell。本书涵盖:
使用图表进行架构建模的方法; 需求分析; 嵌入式 DSL 域建模; 外部 DSL 设计和实现; Monad 作为具有效果的子系统; 作为功能接口的免费 monad; 箭头 eDSL; 使用 Free monadic eDSL 实现控制反转; 软件事务内存; 镜头; 状态、读取器、写入器、RWS、ST monad; 不纯状态:IORef、MVar、STM; 多线程和并发域建模; 图形界面; UML、SOLID、GRASP等主流技术和方法的适用性; 与不纯子系统的交互。您可能会熟悉本书的代码here 和'Andromeda' 项目代码。
我希望在 2017 年底完成这本书。在此之前,您可以阅读我的文章“函数式编程中的设计和架构”(Rus)here。
更新
我在网上分享了我的书(前 5 章)。见post on Reddit
【讨论】:
Alexander,您能否在本书完成后更新此笔记,以便我们跟进。干杯。 当然!现在我完成了一半的文本,但它是整个工作的 1/3。所以,保持你的兴趣,这对我很有启发! 嗨!我在网上分享了我的书(只有前 5 章)。请参阅 Reddit 上的帖子:reddit.com/r/haskell/comments/6ck72h/… 感谢分享和工作! 真的很期待这个!【参考方案6】:Gabriel 的博文Scalable program architectures 可能值得一提。
Haskell 设计模式与主流设计模式的不同之处在于 重要途径:
传统架构:将几个组件组合在一起 类型 A 生成 B 类型的“网络”或“拓扑”
Haskell 架构:将多个 A 型组件组合在一起,以 生成相同类型 A 的新组件,在 其取代部分的字符
我常常觉得,一个看似优雅的架构往往会以自下而上的方式从表现出这种良好的同质感的库中脱颖而出。在 Haskell 中,这一点尤为明显——传统上被认为是“自上而下的架构”的模式往往会在 mvc、Netwire 和 Cloud Haskell 等库中捕获。也就是说,我希望这个答案不会被解释为试图替换该线程中的任何其他答案,只是结构选择可以而且应该在理想情况下由领域专家在库中抽象出来。在我看来,构建大型系统的真正困难在于评估这些库的架构“优点”与您所有的实用问题。
正如 liminalisht 在 cmets 中提到的那样,The category design pattern 是 Gabriel 就该主题发表的另一篇帖子,内容类似。
【讨论】:
我会提到 Gabriel Gonzalez 在category design pattern 上的另一篇文章。他的基本论点是,我们函数式程序员所认为的“良好架构”实际上是“组合架构”——它使用保证组合的项目来设计程序。由于类别法则保证在组合下保持同一性和关联性,因此通过使用我们拥有类别的抽象来实现组合架构 - 例如。纯函数、一元动作、管道等【参考方案7】:我发现 Alejandro Serrano 的论文 "Teaching Software Architecture Using Haskell" (pdf) 对于思考 Haskell 中的大规模结构很有用。
【讨论】:
【参考方案8】:也许您必须退后一步,首先考虑如何将问题的描述转化为设计。由于 Haskell 的层次如此之高,它可以以数据结构的形式捕获对问题的描述,将动作作为过程,将纯粹的转换作为函数。然后你有一个设计。当您编译此代码并在代码中发现有关缺少字段、缺少实例和缺少 monadic 转换器的具体错误时,开发就开始了,因为例如,您从需要在 IO 过程中具有特定状态 monad 的库执行数据库访问。瞧,有程序。编译器为您提供心理草图,并为设计和开发提供连贯性。
通过这种方式,您从一开始就受益于 Haskell 的帮助,并且编码是自然的。如果您想到的是一个具体的普通问题,我不会在意做一些“功能性”或“纯粹”或足够通用的事情。我认为过度工程是 IT 中最危险的事情。当问题是创建一个抽象一组相关问题的库时,情况就不同了。
【讨论】:
以上是关于Haskell 中的大规模设计? [关闭]的主要内容,如果未能解决你的问题,请参考以下文章
类型流(TypeFlow)——世俗化的函数式编程和改进的过程式设计