以正确的方式在 Haskell 中编写模块

Writing modules in Haskell the right way

(我完全重写了这个问题以使其更加突出;如果您想查看原始内容,可以查看更改历史。)

假设我有两个模块:

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)进行接口,使其能够很好地封装、可重用等?

我看到的问题是

我应该如何设计 Module1(并因此编辑 Module2)以使两者不紧密耦合?

我能想到的一件事是,也许我应该定义一个类型class来表达“一个盒子里面有两个可选的东西”是什么,然后是Module1Module2 会将其用作通用接口。但这应该在两个模块中吗?在其中任何一个?或者在其中 none 个模块中,在第三个模块中?或者也许不需要这样的 class/概念?

我不是计算机科学家,所以我确信这个问题突出了我由于缺乏经验 理论背景而产生的一些误解。欢迎任何帮助填补空白。

我愿意支持的可能的修改

不要定义简单的类型别名;这会公开您如何实现 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 值在直觉上都是“同一类东西”,这让我更喜欢列表(或者如果我觉得特别挑剔, SetBag ), 即使列表最多有两个元素。我只是让它符合我当时对领域的最佳理解,所以我不会更改它,除非我对领域的理解发生重大变化,在这种情况下,审查其所有用途的机会正是我想要的.如果您正在编写尽可能多态的函数,那么通常您不需要更改任何内容,但需要使用含义的特定时刻,需要领域知识的确切时刻(例如理解 [=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 变成类型类(类型类设计完全是另一回事)。

用爱因斯坦的名言,“让它尽可能简单,但不要更简单”。