问题描述
(我将完全重写此问题以使其更加集中;如果您想查看原始内容,可以看到更改的历史记录。)
假设我有两个模块:
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)
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
)进行接口,以使其具有良好的封装性,可重用性等? / p>
我看到的问题是
- 我有一天可以决定不再使用一对返回两个结果;我可以决定使用2个元素列表;或另一对同构的类型(我认为这是正确的形容词,对吗?)如果执行此操作,所有客户端代码都会中断
- 导出
TwoOpts
类型的同义词并不能解决任何问题,因为Module1
仍然可以更改其实现,从而破坏客户端代码。 -
Module1
还将两个可选内容的类型强制为相同,但是我不确定这是否与该问题确实相关...
我应该如何设计Module1
(并同时编辑Module2
)以使两者不紧密耦合?
我能想到的一件事是,也许我应该定义一个类型class
来表达“其中有两个可选东西的盒子”,然后Module1
和Module2
使用它作为通用接口。但这应该同时存在于两个模块中吗?在他们两个中?还是没有,在第三个模块中?或者也许不需要这样的class
/概念?
我不是计算机科学家,所以我可以肯定这个问题突出了由于缺乏经验和理论背景而对我的误解。欢迎您提供任何帮助以弥补空白。
我想支持的可能修改
- 关于chepner在对其回答的评论中建议的内容,在某些时候,我可能希望将支持范围从2元组扩展到2元组和3元组,并为其指定不同的访问者名称,例如{ {1}} /
get1of2
(假设这些是我们初次设计get2of2
时使用的名称)与Module1
/get1of3
/get2of3
。 - 在某些时候,我还可以用其他方式来补充这种类似2元组的类型,例如,一个可选的包含
get3of3
两个主要内容的和¹,前提是它们都是{{1} },如果两个主要内容中的至少一个是Just
,则为Just
。我猜在这种情况下,此类的内部表示将类似于nothing
(¹总和确实是一个愚蠢的示例,因此我在这里使用nothing
而不是((Maybe a,Maybe a),Maybe b)
比总和要求的范围更广泛。)
解决方法
对我来说,Haskell设计都是以类型为中心的。函数的设计规则只是“使用最通用,最准确的类型来完成工作”,而Haskell的整个设计问题就是要为工作找到最佳类型。
我们希望这些类型中没有“垃圾”,因此对于要表示的每个值,它们都有一个唯一的表示形式。例如。 String
是数字的错误表示,因为"0","0.0","-0"
都是同一意思,也因为"The Prisoner"
不是数字-这是没有有效符号的有效表示。如果出于性能考虑,如果相同的符号可以用多种方式表示,则类型的API应该使用户看不见该差异。
因此,在您的情况下,(Maybe a,Maybe a)
是完美的-这恰恰意味着您需要的含义。使用更复杂的东西是不必要的,只会使用户复杂化。在某些时候,无论您公开什么内容,第一件事都必须转换为Maybe a
,而第二件事必须转换为Maybe a
,并且没有多余的信息,因此元组是完美的。是否使用类型同义词是一个样式问题-我宁愿完全不使用同义词,而只在考虑到更正式的抽象时才给出类型名称。
内涵很重要。例如,如果我有一个函数可以找到二次多项式的根,那么即使最多有两个,我可能也不会使用TwoOpts
。从直觉上说,我的返回值都是“同一类东西”,这使我更喜欢列表(或者,如果我感到特别挑剔,则使用Set
或Bag
),即使该列表最多包含两个元素。我只是使它与当时对领域的最佳理解相匹配,所以除非我对领域的理解发生了重大变化,否则我不会更改它,在这种情况下,正是我想要的机会来审查其所有用途。如果您要编写的函数尽可能多态,那么除了使用含义的特定时刻,需要准确的时刻域知识(例如了解{{1} }和TwoOpts
)。如果它是由足够柔软的多态材料制成的,则无需“重做管道”。
假设您没有像Set
这样的标准类型的纯同构,并且您想将(Maybe a,Maybe a)
形式化。这里的方法是根据其构造函数,组合程序和消除程序来构建API。例如:
TwoOpts
在这种情况下,消除器将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)
您可以自由更改此表示形式,只要它仍实现上面的组合API。如果您有理由使用同一API的不同表示形式,请将API设置为类型类(类型类设计完全是另外一回事)。
用爱因斯坦的著名话来说,“使它尽可能简单,但不要简单”。
,不定义简单的类型别名;这暴露了如何实现TwoOpts
的细节。
相反,定义一个新类型,但不要导出数据构造函数,而是导出用于访问两个组件的函数。然后,您可以随意更改自己喜欢的类型的实现,而无需更改接口,因为用户无法对类型TwoOpts a
的值进行模式匹配。
module Module1 (TwoOpts,inverseAndSqrt,getFirstOpt,getSecondOpt) where
data TwoOpts a = TwoOpts (Maybe a) (Maybe a)
getFirstOpt,getSecondOpt :: TwoOpts a -> Maybe a
getFirstOpt (TwoOpts a _) = a
getSecondOpt (TwoOpts _ b) = b
inverseAndSqrt :: Int -> TwoOpts Float
inverseAndSqrt x = TwoOpts (safeInverse x) (safeSqrt x)
where safeInverse 0 = Nothing
safeInverse x = Just (1.0 / fromIntegral x)
safeSqrt x | x >= 0 = Just $ sqrt $ fromIntegral x
| otherwise = Nothing
和
module Module2 where
import Module1
fun :: TwoOpts Float -> Float
fun a = case (getFirstOpts a,getSecondOpt a) of
(Just x,Just y) -> x + y
(Just x,Nothing) -> x
(Nothing,Just y) -> y
exportedFun :: Int -> Float
exportedFun = fun . inverseAndSqrt
稍后,当您意识到重新实现了类型产品时,可以在不影响任何用户代码的情况下更改定义。
newtype TwoOpts a = TwoOpts { getOpts :: (Maybe a,Maybe a) }
getFirstOpt,getSecondOpt :: TwoOpts a -> Maybe a
getFirstOpt = fst . getOpts
getSecondOpt = snd . getOpts