哈斯克尔迷你图案手册

2020-08-19 02:49:41

尽管Haskell是一种强大的语言,可以帮助实现健壮和可维护的程序,但在Haskell可能性的海洋中导航是具有挑战性的。该语言为您提供了大量令人惊叹的方法,但要了解如何以及在哪里正确使用它们并不总是微不足道的。

幸运的是,与任何其他主流编程语言一样,Haskell也有其生成高质量代码的最佳实践和推荐方法。了解Haskell编程模式可以帮助您创建更好的库和应用程序,并让它们的用户更满意。是的,除了与其他语言共享的最佳实践之外,Haskell实际上还有面向FP的编程模式。

如果您知道解决常见问题的最佳方法,那么通过使用特定于该语言的更高效的编程技术,您可以成为一名更好的Haskell开发人员。同样,您可以在Haskell之外成功地应用特定于Haskell的模式。

这篇博客文章包含了Haskell中一些编程迷你模式的结构化集合,其中有详细的描述和示例,一些小的“生活质量”改进将帮助每个人在他们的开发之旅中。

📚在本文中,每个模式至少包含一个具有隐藏解决方案的任务,因此您可以通过解决建议的任务来测试您对模式的理解。

使用相同的基元类型(Int、Text等)时。表示语义上不同的实体(名称、标题、描述等)。

最常见和最有用的Haskell特性之一是NewType。NewType是具有名称和构造函数的普通数据类型。但是,只有当数据类型正好有一个具有一个字段的构造函数时,才能将该数据类型定义为Newtype,而不是Data。

在Haskell中定义Newtype非常容易,因为与数据类型声明相比,用户不需要额外的工作。例如,以下是有效的新类型定义:

--+双重Newtype Volume=Volume Double包装的有效定义--+也是记录字段Newtype Size=Size{unSize::Int}的有效Newtype定义。

-无效的Newtype定义:多个字段Newtype Point=Point{pointX::int,pointy::int}-无效的Newtype定义:多个构造函数Newtype Shape=Circle Double|Square Double。

新类型有很多好处,但在本节中我将只关注代码的可维护性。

您可以将Newtype视为一种轻量级数据类型。NewType在内存中的表示形式与包装类型相同。包装和展开在运行时也是免费操作。因此,新型是一种零成本的抽象。

尽管从编译器的角度来看,不同的新类型是不同的数据类型。由于与普通数据类型相比,它们没有任何运行时开销,因此您可以在不牺牲性能的情况下为API提供更安全的接口。

现在我们来看一下这个例子好吗?假设您有一个函数,它接受密码和散列,并验证给定的散列是否是给定密码的散列。由于密码和散列都只是文本数据,我们可以编写以下函数:

此函数的问题在于,您可以很容易地以不同的顺序传递参数并得到错误的结果。每次调用此函数时,都需要考虑参数的正确顺序。它应该是validateHash密码散列还是validateHash散列密码?这种方法容易出错。

Newtype Password=password{unPassword::ByteString}Newtype PasswordHash=PasswordHash{unPasswordHash::ByteString}

您可以实现更加类型安全的validateHash函数版本,此外,这还提高了代码的可读性:

现在,validateHash散列密码是一个编译时错误,它使得不可能混淆参数的顺序。

另一种在不牺牲性能的情况下提高代码可读性的流行方法是通过使用type关键字引入新的别名(这只是数据类型的另一个名称)。不幸的是,这种方法只是一种部分解决方案,因为它只对开发人员有帮助,对编译器没有帮助,因为编译器看不到类型别名和类型本身之间的区别。如果你不帮助你的编译器,编译器就不会在你需要的时候出现。

当然,上面的类型签名比Int->;Int->;Int更好,但是编译器完全是这样看待它的。因此,您仍然可以编写culateDamage monsterDefense playerAttack并获得运行时错误。

当您有许多相似的数据类型时,使用类型而不是新类型的方法可能会带来更大的损害。您拥有的类型越多,在没有外部帮助的情况下维护它们就越困难。下面您可以看到来自其中一个Haskell库的代码示例:

类型WorkerId=UUID类型SupervisorId=UUID类型ProcessId=UUID类型ProcessName=Text类型SupervisorName=Text类型WorkerName=Text。

通过用新类型替换所有这些类型别名,可以提高库的安全性,甚至可以帮助发现由于以错误的顺序传递参数而发生的一些错误。

此外,Newtype方法更加灵活,因为您可以提供自定义实例或限制某些实例,从而允许您以不安全的方式创建值。

使用NewType的成本很小-您只需要在必要时将其包装和解开。但好处远远超过这个小小的代价。

数据播放器=player{playerHealth::int,playerArmor::int,playerAttack::int,playerStrength::int}culatePlayerDamage::int->;Int->;Int culatePlayerDamage攻击强度=攻击+强度culatePlayerDefense::Int->;Int-&>;Int culatePlayerDefense Armor Dexterity=。Int计算Hit伤害防御生命值=健康+防御-损坏--第二个玩家击中第一个玩家,新的第一个玩家返回hitPlayer::player-&>;player->;player hitPlayer player1 player2=let Damage=culatePlayerDamage(PlayerAttack Player2)(PlayerStrength Player2)Defense=culatePlayerDefense(PlayerArmor Player1)(PlayerDexterity Player1)Newhalth。

新型健康=健康Int新型装甲=攻击Int新型灵巧=灵巧Int新型强度=强度Int新型伤害=伤害Int新型防御=防御Int数据播放器=Player{playerHealth::Health,PlayerArmor::Armor,PlayerAttack::Attack,PlayerDexterity::Dexterity,PlayerStrength::Strength}计算播放器伤害::攻击-&。伤害计算玩家伤害(攻击攻击)(强度强度)=伤害(攻击+强度)计算玩家防御::盔甲->;灵巧度->;防御计算玩家防御(装甲盔甲)(灵巧度)=防御(盔甲*灵巧度)计算Hit::Damage->;Defense->;Health->;健康计算Hit(伤害)(防御防御)(健康健康)=健康(健康+防御-伤害)--第二个玩家击中第一个玩家,新的第一个玩家返回hitPlayer::player-&>;player->;player hitPlayer player1 player2=let Damage=culatePlayerDamage(PlayerAttack Player2)(PlayerStrength Player2)Defense=culatePlayerDefense(PlayerArmor Player1)(PlayerArmor Player1)。

请注意,hitPlayer函数的实现完全没有更改。但是,如果您现在尝试交换不同函数中的一些参数,编译器将防止您意外提交错误。

当数据类型限制某些值时(例如,并非每个ByteString都是有效密码)。

一旦您有了一个新类型,首先您需要创建它的值来使用它。有时,您需要在继续操作之前验证值。通常,您将使用数据类型的构造函数来创建该数据类型的值。但是,当以模块化方式编程时,您希望在接口中设置边界,并避免提供不安全的方式来构造未经验证的值。

Haskell中的这种编程模式称为智能构造函数。通过查看基于密码数据类型的这种方法的实现,可以更好地理解这一点:

模块密码(Password(UnPassword),mkPassword)其中import Data.ByteString(ByteString)将限定的Data.ByteString导入为ByteString Newtype Password=Password{unPassword::ByteString}--|智能构造函数。不允许空密码。MkPassword::ByteString->;可能是密码mkPassword PWD|ByteString。NULL PWD=Nothing|ELSE=Just(密码PWD)

在本模块中,我们要拒绝空密码。这就是mkPassword函数的作用。我们不愿意导出密码构造函数。但是我们需要一种方法来解构PASSWORD类型的值,因此导出列表中的PASSWORD(UnPassword)行。

即使您不允许创建未经验证的密码,您也可能需要在测试套件中创建密码,而不需要额外的麻烦。因此,我们可以创建一个名称中带有提示的函数unsafePassword。

如果您在代码检查期间注意到应用程序代码中使用了此函数,则很明显存在问题。此外,甚至可以设置一些自动工具来为您执行此类检查。

您可以在野外找到此模式的多个变体,它们在某些实现细节上有所不同:

将构造函数重命名为UnsafePassword,而不是使用单独的unsafePassword函数(但是,由于强制,这是一种不太安全的方法)。

在编译时验证静态已知值,因此在编译时而不是运行时会出现错误,并且在知道值有效时不需要不安全的函数。

不幸的是,对于实现智能构造器的唯一真正方法是什么,社会各界并没有达成共识。所有的方式在人体工程学和命名方案上都略有不同,它们都各司其职,适合不同系统的不同用例。但所有方法的总体思路都是一样的。

模块标记,其中--|标记为非空字符串。Newtype Tag=标记字符串mkTag::string->;标记mkTag标记|null tag=error";空标记!";|否则=标记

导入数据的模块标记列表。List.NonEmpty(NonEmpty(..))。Import tag(tag,mkTag)--|非空标签的非空列表。Newtype TagsList=TagsList(非空标签)mkTagsList::[String]->;TagsList mkTagsList[]=错误";mkTagsList(Tag:Tag)=TagsList$mkTag标签:|映射mkTag标签。

模块标记,其中--|标记为非空字符串。Newtype tag=标签字符串mkTag::string->;可能标签mkTag标签|null标签=无|否则=Just(标签标签)。

模块标记列表,其中import Control.Applicative(LiftA2)import Data.List.NonEmpty(NonEmpty(..))。Import tag(tag,mkTag)--|非空标签的非空列表。Newtype TagsList=TagsList(非空标签)mkTagsList::[String]->;可能TagsList mkTagsList[]=无mkTagsList(Tag:Tag)=TagsList<;$>;liftA2(:|)(mkTag标签)(遍历mkTag标签)。

一直都是。但最重要的是,当您想要确保数据经过验证或想要在将来重用该知识时。

本主题自然完成了前面的“智能构造函数”模式。这种方法以前在各种优秀的博客文章中都有介绍:

以上所有帖子都对证据模式进行了令人惊叹的描述和解释。在这里,我们只想用一个小示例添加一个简短的概述。

添加::(a->;可能是Int)->;(a->;可能是Int)->;a->;可能是Int添加f g x=if isNothing(F X)||isNothing(G X)则只有(from(F X)+from(G X))。

这是一个迹象,表明您正在遵循布尔盲性反模式,现在是立即重构代码的时候了。

这里的关键问题是,通过调用返回Bool的函数,您会丢失有关以前执行的验证的信息。相反,您可以通过对验证或结果进行显式模式匹配来保留此信息。

📚练习:尝试在不使用isNothing和FromJust函数的情况下重构上面的代码。可以作为单子使用的加分。

您仍然可以忘记在应用程序代码中调用此验证函数,并允许用户使用无效密码。

GetUserPage,access拒绝::User->;IO页面登录User::User-&>;PasswordHash->;IO页面登录User User PWD pwdHash=if validateHash PWD pwd pwdHash则获取UserPage用户否则访问指定用户。

即使在Else分支中,您也可以调用getUserPage并允许用户使用无效密码输入。

另一种类型安全的方法(但也是更重要的解决方案)是返回某种类型的见证,以确认密码已被验证,然后在将来的函数中需要该见证。

--只能使用';validateHash';data UserAuth创建的不透明数据类型--我们返回的不是';UserAuth';validateHash::password->;PasswordHash->;可能是UserAuth;,而不是返回';Bool';

由于UserAuth只能使用validateHash创建,因此返回用户页面的唯一方法是执行密码验证:

LoginUser::user-&>;password->;PasswordHash->;IO()loginUser User pwdHash=case validateHash PWD pwdHash Of Nothing->;Accessed User Just Auth->;getUserPage Auth User。

您可以看到我们如何通过一个小的更改使代码变得更安全和更健壮。而且,像往常一样,解决这个问题有多种方法,包括使用更先进的Haskell功能来提供更好的保证、更好的人体工程学或解决更困难的问题。例如,下面的博客文章描述了使用更高级的Haskell功能实施类似问题:

导入数据。IntMap(IntMap)导入数据。可能(From MJust)导入符合条件的数据。IntMap作为IntMap getNearestValues::IntMap Double--^从位置映射到值->;Int--^当前位置->;Double getNearestValues位置--两个位置都在地图中:返回它们的总和|IntMap.ember(pos-1)保留&;&;IntMap.ember(pos+1)vales=fromJust(IntMap.lookup(pos-1)vales)+fromJust(IntMap.lookup(pos+1)vales)--只有左边的位置在Map|IntMap.ember(pos-1)vales=fromJust(IntMap.lookup(pos-1)vales)--只有右边的位置在Map|IntMap.ember(pos+1)valsions=from mJust(IntMap.lookup(pos-1)valals)--只有右边的位置在Map|IntMap.ember(pos+1)vales=fromJust(。

导入数据。IntMap(IntMap)导入符合条件的数据。IntMap作为IntMap getNearestValues::IntMap Double--^从位置映射到值->;Int--^当前位置->;Double getNearestValues pos=case(IntMap.lookup(pos-1)值,IntMap.lookup(pos+1)值)的(就在左边,就在右边)->;左+右(就在左边,右(无,无)->;0.0。

数据类型精确地描述了域,允许创建所有有效值,并且不可能构造无效值。

对于不了解整体情况的人来说,很难引入bug。

此模式与智能构造函数和证据模式密切相关,它们可以并且应该一起使用。

“使非法州不可代表”的格言在FP社区中广为人知。函数式编程特性(如代数数据类型(ADT)、参数多态性等)允许更精确地描述有效数据的形状,以至于不可能构造任何无效值。

要给出这个概念的一个简单示例,请考虑一个函数,该函数接受两个可选值,并对这些值执行某些操作,但前提是这两个值都存在。该函数假定您不会在没有第二个元素的情况下只传递单个值,因此它不会费心处理这种情况。

HandleTwoOptionals::可能a->;可能b->;IO()handleTwoOptionals(只有a)(只有b)=...。使用';a';和';b#39;handleTwoOptionals Nothing=...。在不使用值handleTwoOptionals__=error";的情况下继续操作,您必须同时指定两个值";

HandleTwoOptionals类型只允许传递a和Nothing::可能是b,但实际上该函数不会处理这样的组合。您可以注意到,通过简单地更改类型来修复这个特定问题非常简单:您不需要分别传递两个可能值,而需要传递可能成对的值。

HandleTwoOptionals::可能(a,b)->;IO()handleTwoOptionals(Just(a,b))=...。使用';a&39;和';b#39;handleTwoOptionals Nothing=...。在没有值的情况下继续。

通过这个细微的更改,我们不可能只将单个值指定为Nothing。如果您只传递某项内容,则必须始终同时提供这两个值。

让我们转到另一个例子。现在我们想要编写一个接受两个列表的函数,但是这些列表必须具有相同的长度。当列表大小不同时,我们的函数不起作用。类型签名可能如下所示:

由调用者来验证两个列表是否具有相同的大小。但是,如果您不验证列表长度,则processTwoList函数将失败。此外,即使调用方检查了此属性,类型签名仍不会捕获验证结果。请注意,牢记这一事实可以从证据模式的使用中获益。

同样,通过更改processTwoList的类型可以非常容易地解决问题:

该函数不是传递两个列表并期望它们具有相同的大小,而是简单地获取一个配对列表,因此它具有相同数量的as和bs。

再举一个例子。想象一下,您正在编写一个具有后端和前端的Web应用程序。你有一个功能,可以处理启动你的应用的后端和前端的设置。这两种设置配置都可以是可选的,但应至少指定其中一个设置部分。

数据设置=设置{settingsBackend::可能是后端设置,settingsFronend::可能FrontendSettings}runApp::Settings->;IO()runApp Settings{..}=case(settingsBackend,settingsFronend)of(就在后面,就在前面)->;configureBack;>;configureFront>;>;run(就在后面,不运行)->;configureBack。ConfigureFront>;>;run(Nothing,Nothing)->;抛出";您必须至少指定一个设置";

但是上面的函数有相同的问题:数据类型使得指定在现实生活中不应该发生的值成为可能。要解决此问题,我们需要使用SUM类型通过以下方式更改设置数据类型的形状:

数据设置=OnlyBackend BackendSettings|OnlyFronend FrontendSettings。

.