制作新的类型/数据以增加清晰度是否不好?

16

我想知道做类似这样的事情是否不合适:

data Alignment = LeftAl | CenterAl | RightAl
type Delimiter = Char
type Width     = Int

setW :: Width -> Alignment -> Delimiter -> String -> String

不要像这样:

setW :: Int -> Char -> Char -> String -> String

我确实知道重新制作这些类型实际上什么都不做,只是以几行代码换取更清晰的代码。但是,如果我将Delimiter类型用于多个函数,对于导入此模块或稍后阅读代码的人来说,这会更加清晰。

我相对来说还是Haskell新手,所以我不知道这种情况下的最佳实践。如果这不是一个好主意,或者有其他能提高清晰度的选择,那么应该选择什么呢?


10
不,相反,我认为前者是一种良好的形式。你的类型“Alignment”清楚地表明只有三个有效值,并为它们提供了良好的名称。相比之下,“Char”更加模糊,允许无效的值。类似“Delimiter”和“Width”这样的别名不太有用,但如果有很多使用它们的函数,那么它们是很好的附加功能。 - chi
4
分隔符是你在超市用来测量肉类的工具。分界符则是某物段落之间的边界。 - amalloy
使代码更清晰的另一种可能的解决方案是使用具有命名字段的记录作为参数(这不与新类型的使用互斥)。使用记录的决定取决于参数之间的密切程度,如果我们不介意失去部分应用,如果我们想要提供“默认”记录参数等等。 - danidiaz
5个回答

19

你正在使用类型别名,它们只能稍微提高代码的可读性。然而,为了更好地保证类型安全,最好使用 newtype 而不是 type。像这样:

data Alignment = LeftAl | CenterAl | RightAl
newtype Delimiter = Delimiter { unDelimiter :: Char }
newtype Width     = Width { unWidth :: Int }

setW :: Width -> Alignment -> Delimiter -> String -> String
你将需要处理额外的包装和解包newtype。 但是代码将更加强健,以抵御进一步的重构。 这个样式指南 建议只使用type来专门化多态类型。

8
值得指出的是,对 unDelimiterunWidth 的调用是“零成本”的,因为它们仅用于类型检查;在运行时没有实际调用。 - chepner
3
值得一提的是,当类型别名用于缩短某些东西的名称时,例如一个 Monad 栈 (type App = ReaderT SomeEnvironment IO ()),它是值得使用的。 - chepner
4
@chepner :X 在某种程度上是出于历史原因存在的。几年前,Data.Void 没有被包含在 base 中,因此依赖于 void 包才能使用 Void。后来,pipes 通过自己的实现消除了对它的依赖。当 Data.Void 被纳入到 base 中时,X 成为了它的同义词。 - duplode
类型同义词在 lens 中随处可见,它们用于执行某些新类型会干扰的魔法操作(尽管我想知道 QuantifiedConstraints 现在是否可以解决这个问题)。它们在高阶类型中也更加通用。类型同义词还出现在大量类型代码中作为退化的类型族。type Reverse xs = ReverseOnto '[] xs; type family ReverseOnto acc xs where ...。而且,在使用 ConstraintKinds 时,约束同义词特别有用,可以一次性声明一个类及其唯一实例,当存在许多约束时。 - dfeuer
2
话虽如此,我的一般观点是初学者不应该使用类型同义词。 - dfeuer
显示剩余5条评论

14

我认为这并不算差劲的方式,但显然,我不能代表Haskell社区的大多数人。就我所知,该语言功能存在的目的是为了使代码更易于阅读。

可以在各种“核心”库中找到类型别名的使用示例。例如,Read类定义了这个方法:

readList :: ReadS [a]

ReadS类型只是一个类型别名。

type ReadS a = String -> [(a, String)]

另一个例子是在Data.Tree中的Forest类型,具体可参见此链接

type Forest a = [Tree a]

正如Shersh所指出的那样,您也可以使用newtype声明来封装新类型。如果您需要以某种方式约束原始类型(例如使用智能构造器)或者如果您想向类型添加功能而不创建孤立实例(典型的例子是为不带有此类实例的类型定义QuickCheck Arbitrary实例),这通常非常有用。


6
我同意你的观点。尽管type定义不像newtype那样提供额外的类型检查保证,但它们是快速整理签名的有用手段。这在原型设计阶段特别有用——可以快速汇总所需的类型(以及最终可能不需要的一些类型),而无需编写任何样板代码。但由于它已经是一个可区分的“名称”,稍后可以轻松将其定义更改为适当的newtype,编译器会使调整代码变得容易。 - leftaroundabout
我相信我们已经从所有导出函数的类型签名中删除了“Forest”。为什么?这很令人困惑!你会期望 fmap :: (a -> b) -> Forest a -> Forest b,但事实并非如此!相反,fmap :: (Tree a -> Tree b) -> Forest a -> Forest b。太糟糕了。ReadS也是同样的原因:人们期望解析器具有某些行为方式的FunctorApplicativeMonad实例,而ReadS看起来像是一个解析器类型,但它的实例都是(->) String。非常令人困惑! - dfeuer
额...实际上不是这样的...我以为我们已经...我的意思是...我一定没有合并某些东西... - dfeuer

10
使用newtype——创建一个具有与底层类型相同但不可替代的新类型——被认为是良好的形式。这是一种廉价的避免 基础类型偏执 的方法,特别适用于Haskell,因为在Haskell中函数参数的名称在签名中不可见。
Newtypes也可以是挂载有用的类型类实例的地方。
鉴于newtypes在Haskell中无处不在,随着时间的推移,语言已经获得了一些工具和惯用语来操作它们:
  • Coercible 一种“神奇”的类型类,简化了 newtypes 和其底层类型之间的转换,当 newtype 构造函数在作用域内时。通常对于避免函数实现中的样板很有用。

    ghci> coerce (Sum (5::Int)) :: Int

    ghci> coerce [Sum (5::Int)] :: [Int]

    ghci> coerce ((+) :: Int -> Int -> Int) :: Identity Int -> Identity Int -> Identity Int

  • ala。一种习惯用法(在各种包中实现),简化了我们可能想要与像foldMap这样的函数一起使用的新类型的选择。

    ala Sum foldMap [1,2,3,4 :: Int] :: Int

  • GeneralizedNewtypeDeriving。一种扩展,可以基于底层类型中可用的实例自动派生您的新类型的实例。

  • DerivingVia。一种更通用的扩展,可以基于具有相同底层类型的其他新类型中可用的实例自动派生您的新类型的实例。


1
直接使用 coerce 是粗略的,因为推断需要极高的指导,但非常强大。理想情况下,您永远不必直接使用它,但我们还没有到达那里。很好的回答! - Iceland_jack

6
需要翻译的内容如下:

需要注意的一点是,AlignmentChar之间的区别不仅仅是清晰度的问题,而是正确性的问题。您的Alignment类型表达了只有三种有效对齐方式,而不是Char具有多少个元素。通过使用它,您可以避免出现无效值和操作的问题,并且如果打开了警告,则还可以启用GHC在不完整模式匹配时向您提供有益信息。

至于同义词,不同人有不同的意见。就我个人而言,我认为对于像Int这样的小类型,type同义词可能会增加认知负荷,因为您需要跟踪不同的名称来表示严格相同的东西。话虽如此,leftaroundabout提出了一个很好的观点,即在原型化解决方案的早期阶段,此类同义词可能会很有用,当时您不一定想担心您将采用的具体表示的细节。

(值得一提的是,在这里关于type的评论在很大程度上不适用于newtype。用例不同,虽然:而type仅为相同的东西引入了不同的名称,newtype则通过法令引入了不同的东西。这可能是一个令人惊讶的强有力的举动--请参见danidiaz的答案进行进一步讨论。)


2
最初的回答肯定是好的,这里有另一个例子,假设您有一种带有某些操作的数据类型:
data Form = Square Int | Rectangle Int Int | EqTriangle Int

perimeter :: Form -> Int
perimeter (Square s)      = s * 4
perimeter (Rectangle b h) = (b * h) * 2
perimeter (EqTriangle s)  = s * 3

area :: Form -> Int
area (Square s)      = s ^ 2
area (Rectangle b h) = (b * h)
area (EqTriangle s)  = (s ^ 2) `div` 2 

最初的回答:现在想象一下你添加了一个圆形:
现在假设你添加了一个圆形:
data Form = Square Int | Rectangle Int Int | EqTriangle Int | Cicle Int

add its operations:

perimeter (Cicle r )      = pi * 2 * r

area (Cicle r)       = pi * r ^ 2

这个不太好,对吧?现在我想使用浮点型...那我得将每个整数改成浮点型。

最初的回答:这不是很好,对吧?现在我想要使用浮点数... 我需要将每个整数都改成浮点数。
data Form = Square Double | Rectangle Double Double | EqTriangle Double | Cicle Double


area :: Form -> Double

perimeter :: Form -> Double

"最初的回答" 只是原始的答案,但是,如果为了清晰和重复使用,我使用类型(type)会怎么样呢?
data Form = Square Side | Rectangle Side Side | EqTriangle Side | Cicle Radius

type Distance = Int
type Side = Distance
type Radius = Distance
type Area = Distance

perimeter :: Form -> Distance
perimeter (Square s)      = s * 4
perimeter (Rectangle b h) = (b * h) * 2
perimeter (EqTriangle s)  = s * 3
perimeter (Cicle r )      = pi * 2 * r

area :: Form -> Area
area (Square s)      = s * s
area (Rectangle b h) = (b * h)
area (EqTriangle s)  = (s * 2) / 2
area (Cicle r)       = pi * r * r

那让我只需更改代码中的一行即可更改类型,比如我想要距离是整数类型,我只需要更改那一行。最初的回答。
perimeter :: Form -> Distance
...

totalDistance :: [Form] -> Distance
totalDistance = foldr (\x rs -> perimeter x + rs) 0

我希望结果为浮点数,所以我只需要更改以下代码:

Original Answer

type Distance = Float

如果我想将它更改为Int,我必须在函数中进行一些调整,但这是另一个问题。"最初的回答"

1
好的例子。虽然1.我认为Distance一开始就不应该是Int也不应该是Float,而应该是Double - 但是你在这里非常清楚地表达了一个重点,即_使用typedef可以轻松更改此实现细节_。2. 在这里实际上进行更强的类型区分,即使用newtype而不是typedef是有意义的。请注意,Distance实际上不是数字类型:将两个距离相乘会得到一个Area,将距离加到面积上是没有意义的。 - leftaroundabout

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接