haskell - 以正确的方式在 Haskell 中编写模块
问题描述
(我完全重写了这个问题,以便更好地关注它;如果您想查看原件,可以查看更改的历史。)
假设我有两个模块:
- 一个模块定义了功能
inverseAndSqrt
。这个函数实际上做了什么并不重要;重要的是它不返回任何东西、一个东西或两个东西,客户可以区分哪个是哪个;
module Module1 (inverseAndSqrt) where
type TwoOpts a = (Maybe a, Maybe a)
inverseAndSqrt :: Int -> TwoOpts Float
inverseAndSqrt x = (if x /= 0 then Just (1.0/(fromIntegral x)) else Nothing,
if x >= 0 then Just (sqrt $ fromIntegral x) else Nothing)
- 另一个模块根据
inverseAndSqrt
其类型定义其他功能
module Module2 where
import Module1
fun :: (Maybe Float, Maybe Float) -> Float
fun (Just x, Just y) = x + y
fun (Just x, Nothing) = x
fun (Nothing, Just y) = y
exportedFun :: Int -> Float
exportedFun = fun . inverseAndSqrt
我想从设计原则的角度理解的是:我应该如何Module1
与其他模块(例如Module2
)进行接口,使其能够很好地封装、可重用等?
我看到的问题是
- 有一天我可以决定不再使用一对来返回两个结果;我可以决定使用 2 元素列表;或另一种与一对同构的类型(我认为这是正确的形容词,不是吗?);如果我这样做,所有客户端代码都会中断
- 导出
TwoOpts
类型同义词并不能解决任何问题,因为Module1
仍然可以更改其实现,从而破坏客户端代码。 Module1
也强制两个选项的类型相同,但我不确定这与这个问题是否真的相关......
我应该如何设计Module1
(并因此Module2
也进行编辑)以使两者不紧密耦合?
我能想到的一件事是,也许我应该定义一个类型来表达“一个里面有两个可选东西的盒子”是什么,class
然后将它用作一个通用接口。但这应该在两个模块中吗?在他们中的任何一个?或者它们都没有,在第三个模块中?或者也许不需要这样的 /concept?Module1
Module2
class
我不是计算机科学家,所以我敢肯定,由于缺乏经验和理论背景,这个问题凸显了我对我的一些误解。欢迎任何帮助填补空白。
我想支持的可能修改
- 与 chepner 在对他的回答的评论中建议的内容相关,在某些时候我可能希望将支持从 2 元组事物扩展到 2 元组和 3 元组事物,它们具有不同的访问器名称,例如
get1of2
/get2of2
(假设这些是我们第一次设计时使用的名称)Module1
vsget1of3
//get2of3
.get3of3
- 在某些时候,我还可以用其他东西来补充这种类似 2 元组的类型,例如,
Just
仅当它们都是Just
s 时,才包含两个主要内容的总和¹的可选项,或者Nothing
如果两个主要内容中至少有一个,则包含内容是一个Nothing
。我猜在这种情况下,这个类的内部表示会是这样的((Maybe a, Maybe a), Maybe b)
(¹总和真的是一个愚蠢的例子,所以我在b
这里使用了而不是a
比总和所需的更通用)。
解决方案
对我来说,Haskell 设计都是以类型为中心的。函数的设计规则只是“使用最通用和最准确的类型来完成这项工作”,而 Haskell 中的整个设计问题就是为这项工作提出最佳类型。
我们希望类型中没有“垃圾”,以便它们对您要表示的每个值都有一个表示。例如String
,对于数字来说是一个不好的表示,因为"0", "0.0", "-0"
它们都意味着相同的东西,而且因为"The Prisoner"
它不是一个数字——它是一个没有有效外延的有效表示。如果出于性能原因,可以以多种方式表示相同的表示,则类型的 API 应该使用户看不到这种差异。
所以在你的情况下,(Maybe a, Maybe a)
是完美的——这正是你需要的意思。使用更复杂的东西是不必要的,只会使用户的事情复杂化。在某些时候,您公开的任何内容都必须可转换为Maybe a
用于第一件事的 a 和Maybe a
用于第二件事的 a,并且没有额外的信息,因此元组是完美的。是否使用类型同义词是一种风格问题——我根本不喜欢使用同义词,并且只在我想到更正式的抽象时才给出类型名称。
内涵很重要。例如,如果我有一个查找二次多项式根的函数,我可能不会使用TwoOpts
,即使最多有两个。从直觉上看,我的返回值都是“同一种东西”,这一事实让我更喜欢一个列表(或者如果我感觉特别挑剔,一个Set
或Bag
),即使列表最多有两个元素。我只是让它符合我当时对领域的最佳理解,所以我不会改变它,除非我对领域的理解发生了重大变化,在这种情况下,审查其所有用途的机会正是我想要的. 如果您正在编写尽可能多态的函数,那么您通常不需要更改任何内容,但使用含义的特定时刻,需要领域知识的确切时刻(例如理解和之间的关系TwoOpts
)Set
。如果它由足够灵活的多态材料制成,则无需“重做管道”。
假设您没有与标准类型类似的干净同构(Maybe a, Maybe a)
,并且您想形式化TwoOpts
. 这里的方法是从它的构造函数、组合器和消除器中构建一个 API。例如:
data TwoOpts a -- abstract, not exposed
-- constructors
none :: TwoOpts a
justLeft :: a -> TwoOpts a
justRight :: a -> TwoOpts a
both :: a -> a -> TwoOpts a
-- combinators
-- Semigroup and Monoid at least
swap :: TwoOpts a -> TwoOpts a
-- eliminators
getLeft :: TwoOpts a -> Maybe a
getRight :: TwoOpts a -> Maybe a
在这种情况下,消除器准确地将您的表示(Maybe a, Maybe a)
作为它们的最终代数。
-- same as the tuple in a newtype, just more conventional
data TwoOpts a = TwoOpts (Maybe a) (Maybe a)
或者,如果您想专注于构造函数方面,您可以使用初始代数
data TwoOpts a
= None
| JustLeft a
| JustRight a
| Both a a
只要它仍然实现了上面的组合 API,你就可以自由地改变这个表示。如果您有理由使用相同 API 的不同表示,请将 API 制作为类型类(类型类设计完全是另一回事)。
用爱因斯坦的话来说,“让它尽可能简单,但不要更简单”。