以正确的方式在 Haskell 中编写模块
Writing modules in Haskell the right way
(我完全重写了这个问题以使其更加突出;如果您想查看原始内容,可以查看更改历史。)
假设我有两个模块:
- 一个模块定义了函数
inverseAndSqrt
。这个函数实际做什么并不重要;重要的是,它 returns none,两件事中的一个或两个,客户可以区分哪个是哪个;
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
)进行接口,使其能够很好地封装、可重用等?
我看到的问题是
- 我可能有一天会决定不再使用一对 return 这两个结果;我可以决定使用 2 元素列表;或另一种同构的类型(我认为这是正确的形容词,不是吗?)到一对;如果我这样做,所有客户端代码都会中断
- 导出
TwoOpts
类型同义词没有解决任何问题,因为 Module1
仍然可以更改其实现,从而破坏客户端代码。
Module1
也强制两个可选类型相同,但我不确定这是否真的与这个问题相关...
我应该如何设计 Module1
(并因此编辑 Module2
)以使两者不紧密耦合?
我能想到的一件事是,也许我应该定义一个类型class
来表达“一个盒子里面有两个可选的东西”是什么,然后是Module1
和Module2
会将其用作通用接口。但这应该在两个模块中吗?在其中任何一个?或者在其中 none 个模块中,在第三个模块中?或者也许不需要这样的 class
/概念?
我不是计算机科学家,所以我确信这个问题突出了我由于缺乏经验 和 理论背景而产生的一些误解。欢迎任何帮助填补空白。
我愿意支持的可能的修改
- 与 chepner 在对他的回答的评论中提出的建议相关,在某些时候我可能想将支持从 2 元组事物扩展到 2 元组事物和 3 元组事物,它们具有不同的访问器名称,例如作为
get1of2
/get2of2
(假设这些是我们第一次设计时使用的名称 Module1
)与 get1of3
/get2of3
/get3of3
。
- 在某些时候,我也可以用其他东西来补充这个类似于 2 元组的类型,例如一个包含
Just
两个主要内容的总和¹的可选类型,前提是它们都是 Just
s,如果两个主要内容中至少有一个是 Nothing
,则为 Nothing
。我想在这种情况下 class 的内部表示会类似于 ((Maybe a, Maybe a), Maybe b)
(¹ 总和确实是一个愚蠢的例子,所以我在这里使用 b
而不是 a
比求和要求更笼统)。
不要定义简单的类型别名;这会公开您如何实现 TwoOpts
.
的细节
相反,定义一个新类型,但不导出数据构造函数,而是导出用于访问这两个组件的函数。然后你可以随意更改类型的实现而不更改接口,因为用户不能 pattern-match 类型 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
对我来说,Haskell 设计就是一切 type-centric。函数的设计规则只是“使用最通用和最准确的类型来完成工作”,Haskell 中的整个设计问题就是为工作提出最佳类型。
我们希望类型中没有“垃圾”,以便它们对您要表示的每个值只有一种表示。例如。 String
是数字的错误表示,因为 "0", "0.0", "-0"
都表示同一件事,还因为 "The Prisoner"
不是数字——它是没有有效表示的有效表示.如果出于性能原因,相同的表示可以用多种方式表示,则类型的 API 应该使用户看不到这种差异。
所以在你的情况下,(Maybe a, Maybe a)
是完美的——它的意思正是你需要它的意思。使用更复杂的东西是不必要的,只会让用户的事情复杂化。在某些时候,无论您公开什么,第一件事都必须转换为 Maybe a
,第二件事必须转换为 Maybe a
,除此之外没有额外的信息,因此元组是完美的。是否使用类型同义词是一种风格问题——我更喜欢完全不使用同义词,并且只在我有更正式的抽象时才给出类型名称。
内涵很重要。例如,如果我有一个求二次多项式根的函数,我可能不会使用 TwoOpts
,即使它们最多有两个。事实上,我的 return 值在直觉上都是“同一类东西”,这让我更喜欢列表(或者如果我觉得特别挑剔, Set
或 Bag
), 即使列表最多有两个元素。我只是让它符合我当时对领域的最佳理解,所以我不会更改它,除非我对领域的理解发生重大变化,在这种情况下,审查其所有用途的机会正是我想要的.如果您正在编写尽可能多态的函数,那么通常您不需要更改任何内容,但需要使用含义的特定时刻,需要领域知识的确切时刻(例如理解 [=19= 之间的关系) ] 和 Set
)。如果它是由足够灵活的多态 material.
构成的,则无需“重做管道”
假设您没有像 (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 变成类型类(类型类设计完全是另一回事)。
用爱因斯坦的名言,“让它尽可能简单,但不要更简单”。
(我完全重写了这个问题以使其更加突出;如果您想查看原始内容,可以查看更改历史。)
假设我有两个模块:
- 一个模块定义了函数
inverseAndSqrt
。这个函数实际做什么并不重要;重要的是,它 returns none,两件事中的一个或两个,客户可以区分哪个是哪个;
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
)进行接口,使其能够很好地封装、可重用等?
我看到的问题是
- 我可能有一天会决定不再使用一对 return 这两个结果;我可以决定使用 2 元素列表;或另一种同构的类型(我认为这是正确的形容词,不是吗?)到一对;如果我这样做,所有客户端代码都会中断
- 导出
TwoOpts
类型同义词没有解决任何问题,因为Module1
仍然可以更改其实现,从而破坏客户端代码。 Module1
也强制两个可选类型相同,但我不确定这是否真的与这个问题相关...
我应该如何设计 Module1
(并因此编辑 Module2
)以使两者不紧密耦合?
我能想到的一件事是,也许我应该定义一个类型class
来表达“一个盒子里面有两个可选的东西”是什么,然后是Module1
和Module2
会将其用作通用接口。但这应该在两个模块中吗?在其中任何一个?或者在其中 none 个模块中,在第三个模块中?或者也许不需要这样的 class
/概念?
我不是计算机科学家,所以我确信这个问题突出了我由于缺乏经验 和 理论背景而产生的一些误解。欢迎任何帮助填补空白。
我愿意支持的可能的修改
- 与 chepner 在对他的回答的评论中提出的建议相关,在某些时候我可能想将支持从 2 元组事物扩展到 2 元组事物和 3 元组事物,它们具有不同的访问器名称,例如作为
get1of2
/get2of2
(假设这些是我们第一次设计时使用的名称Module1
)与get1of3
/get2of3
/get3of3
。 - 在某些时候,我也可以用其他东西来补充这个类似于 2 元组的类型,例如一个包含
Just
两个主要内容的总和¹的可选类型,前提是它们都是Just
s,如果两个主要内容中至少有一个是Nothing
,则为Nothing
。我想在这种情况下 class 的内部表示会类似于((Maybe a, Maybe a), Maybe b)
(¹ 总和确实是一个愚蠢的例子,所以我在这里使用b
而不是a
比求和要求更笼统)。
不要定义简单的类型别名;这会公开您如何实现 TwoOpts
.
相反,定义一个新类型,但不导出数据构造函数,而是导出用于访问这两个组件的函数。然后你可以随意更改类型的实现而不更改接口,因为用户不能 pattern-match 类型 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
对我来说,Haskell 设计就是一切 type-centric。函数的设计规则只是“使用最通用和最准确的类型来完成工作”,Haskell 中的整个设计问题就是为工作提出最佳类型。
我们希望类型中没有“垃圾”,以便它们对您要表示的每个值只有一种表示。例如。 String
是数字的错误表示,因为 "0", "0.0", "-0"
都表示同一件事,还因为 "The Prisoner"
不是数字——它是没有有效表示的有效表示.如果出于性能原因,相同的表示可以用多种方式表示,则类型的 API 应该使用户看不到这种差异。
所以在你的情况下,(Maybe a, Maybe a)
是完美的——它的意思正是你需要它的意思。使用更复杂的东西是不必要的,只会让用户的事情复杂化。在某些时候,无论您公开什么,第一件事都必须转换为 Maybe a
,第二件事必须转换为 Maybe a
,除此之外没有额外的信息,因此元组是完美的。是否使用类型同义词是一种风格问题——我更喜欢完全不使用同义词,并且只在我有更正式的抽象时才给出类型名称。
内涵很重要。例如,如果我有一个求二次多项式根的函数,我可能不会使用 TwoOpts
,即使它们最多有两个。事实上,我的 return 值在直觉上都是“同一类东西”,这让我更喜欢列表(或者如果我觉得特别挑剔, Set
或 Bag
), 即使列表最多有两个元素。我只是让它符合我当时对领域的最佳理解,所以我不会更改它,除非我对领域的理解发生重大变化,在这种情况下,审查其所有用途的机会正是我想要的.如果您正在编写尽可能多态的函数,那么通常您不需要更改任何内容,但需要使用含义的特定时刻,需要领域知识的确切时刻(例如理解 [=19= 之间的关系) ] 和 Set
)。如果它是由足够灵活的多态 material.
假设您没有像 (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 变成类型类(类型类设计完全是另一回事)。
用爱因斯坦的名言,“让它尽可能简单,但不要更简单”。