Haskell中的面向对象多态性

4

我看到有人问如何在Haskell中进行面向对象编程,例如这个问题。答案大致是“类型类像接口但又不完全相同”。 特别地,类型类不允许构建所有这些类型的列表。例如,我们不能执行map show [1, 1.4, "hello"],尽管它具有逻辑结果。

经过一段时间的思考,我想知道是否有可能做得更好。因此,我尝试为一个简单的Shape类编写多态代码,可以在下面找到(如果您喜欢理智,最好现在停止阅读,并为长度道歉)。

module Shapes (
          Shape(..)
        , Point
        , Circle(..)
        , Triangle(..)
        , Square(..)
        , location
        , area
) where

data Point = Point {
          xcoord :: Float
        , ycoord :: Float
} deriving (Read, Show)

data Shape = CircleT Circle | PolygonT Polygon deriving (Read, Show)

data Circle = Circle {
          cLocation :: Point
        , cRadius :: Float
} deriving (Read, Show)

data Polygon = SquareT Square | TriangleT Triangle deriving (Read, Show)

data Square = Square {
          sLocation :: Point
        , sLength :: Float
} deriving (Read, Show)

-- only right angled triangles for ease of implementation!
data Triangle = Triangle {
          tLocation :: Point
        , tSide1 :: Float
        , tSide2 :: Float
} deriving (Read, Show)

class ShapeIf a where
        location :: a -> Point
        area :: a -> Float

instance ShapeIf Shape where
        location (CircleT a) = location a
        location (PolygonT a) = location a
        area (CircleT a) = area a
        area (PolygonT a) = area a

instance ShapeIf Polygon where
        location (SquareT a) = location a
        location (TriangleT a) = location a
        area (SquareT a) = area a
        area (TriangleT a) = area a

instance ShapeIf Square where
        location = sLocation
        area a = (sLength a) ^ 2

instance ShapeIf Circle where
        location = cLocation
        area a = pi * (cRadius a) ^ 2

instance ShapeIf Triangle where
        location = tLocation
        area a = 0.5 * (tSide1 a) * (tSide2 a)

尽管这样做有些疯狂,但它具有一些不错的特性:我可以有一个形状列表,并且可以对它们进行有意义的函数映射(如位置和面积)。此外,如果我有一个特定的形状(比如三角形),那么我也可以直接调用它的面积。但是这个代码非常可怕。我完全不喜欢它(事实上,我相信在任何面向对象编程语言中都会更短)。
那么我哪里做错了?如何让它更好看?说“不要考虑对象”的话很好,但这似乎有几个应用场景(例如角色扮演游戏中的角色列表...他们具有一些共同属性但不同的能力,或者 GUI 编程中对象通常是有意义的)。

4
使用 data Shape = Shape { location :: Point, area :: Float } 类型和 class IsShape s where toShape :: s -> Shape 类型类有什么问题呢?然后,您可以为想要的每种形状制作不同的数据类型,然后只需编写 IsShape 的实例,以便可以在其上调用 toShape。如果需要计算位置和面积的值,则由于惰性,它们将在需要时再进行计算。您必须手动执行类型转换,但这对面向对象编程并不新鲜。然后,任何使用 Shape 的函数都具有与 ShapeIf 相同的所有信息。 - bheklilr
@bheklilr:如果我们只想要位置和面积属性,那么这很棒。但是你的类非常有损失(一个正方形和一个三角形在同一位置现在是相同的形状记录)。例如,使用这种新类型编写方法 display :: Shape -> IO() 将非常困难。 - dave
1
使用bhklilr的方法,@dave可以编写data Shape = Shape { location :: Point, area :: Float, display :: IO () }。是的,当您添加函数时,您的代码将出现编译错误,但这与在Java中添加抽象方法相同。在这两种情况下,编译错误都是有帮助的。 - Franky
@dave,正如Franky所指出的那样,你可以为Shape类型添加另一个函数来实现。我会将我的回答打成答案,以帮助更清晰地表达。 - bheklilr
4个回答

3

您可以使用简单的数据类型来实现这一目的,而无需使用类型类。如果确实需要使用类型类,则最好将其用于描述转换为基本类型的过程,而不是包含所有实现细节:

data Point = Point
    { xcoord :: Float
    , ycoord :: Float
    } deriving (Eq, Read, Show)

data Shape = Shape
    { shapeLocation :: Point
    , shapeArea :: Float
    } deriving (Eq, Show)

这可能是你需要的两种类型,这取决于你的应用程序,因为你可以编写函数。
circle :: Point -> Float -> Shape
circle loc radius = Shape loc $ pi * r * r

square :: Point -> Float -> Shape
square loc sLength = Shape loc $ sLength * sLength

triangle :: Point -> Float -> Float -> Shape
triangle loc base height = Shape loc $ 0.5 * base * height

但是也许您想保留这些参数。此时,为每个参数编写一个数据类型。

data Circle = Circle
    { cLocation :: Point
    , cRadius :: Float
    } deriving (Eq, Show)

data Square = Square
    { sLocation :: Point
    , sLength :: Float
    } deriving (Eq, Show)

data Triangle = Triangle
    { tLocation :: Point
    , tBase :: Float
    , tHeight :: Float
    } deriving (Eq, Show)

为了方便起见,在这里我会使用一个类型类来定义toShape

class IsShape s where
    toShape :: s -> Shape

instance IsShape Shape where
    toShape = id

instance IsShape Circle where
    toShape (Circle loc radius) = Shape loc $ pi * radius * radius

instance IsShape Square where
    toShape (Square loc sideLength) = Shape loc $ sideLength * sideLength

instance IsShape Triangle where
    toShape (Triangle loc base height) = Shape loc $ 0.5 * base * height

但现在的问题是,您必须将每种类型转换为Shape才能以更通用的方式获取其区域或位置,除非您可以添加这些功能。

location :: IsShape s => s -> Point
location = shapeLocation . toShape

area :: IsShape s => s -> Float
area = shapeArea . toShape

我建议将这些内容从IsShape类中移除,以防止其被重新实现,这类似于像replicateM这样能够在所有Monad上工作的函数,但并不是Monad类型类的一部分。现在,您可以编写如下代码:

twiceArea :: IsShape s => s -> Float
twiceArea = (2 *) . area

当您只需要操作单个形状参数时,这种方式是可行的。但如果您想要对它们的集合进行操作:

totalArea :: IsShape s => [s] -> Float
totalArea = sum . map area

为了不依赖于存在性来构建它们的集合,您可以使用以下方式:
> let p = Point 0 0
> totalArea [toShape $ Circle p 5, toShape $ Square p 10, toShape $ Triangle p 10 20]
278.53983
> totalArea $ map (Square p) [1..10]
385.0

这使您可以灵活地处理不同类型的对象列表,或者使用相同的函数处理仅一种类型的对象列表,并且绝对不需要语言扩展。
请记住,这仍然试图在严格的函数式语言中实现某种对象模型,这并不完全理想,但考虑到这允许您拥有
- 多个“接口”(转换为不同类型) - 泛型(totalArea :: IsShape s => [s] -> Float) - 如果您要使用智能构造函数为Shape添加更多方法,然后像arealocation一样别名它们,则为密封方法 - 如果您只允许通过智能构造函数设置这些方法,则为非密封方法 - 公共和私有由模块导出设置
可能还有其他面向对象编程范例,所有这些都比Java或C#中所需的代码少得多,唯一的区别是代码没有全部分组在一起。这有其优点和缺点,例如能够更自由地定义新实例和数据类型,但使代码变得更加难以导航。

1
你可以使用存在量词来实现这样的目的:
{-# LANGUAGE ExistentialQuantification #-}

data Point = Point {
          xcoord :: Float
        , ycoord :: Float
} deriving (Read, Show)

data Circle = Circle {
          cLocation :: Point
        , cRadius :: Float
} deriving (Read, Show)

data Square = Square {
          sLocation :: Point
        , sLength :: Float
} deriving (Read, Show)

data Triangle = Triangle {
          tLocation :: Point
        , tSide1 :: Float
        , tSide2 :: Float
} deriving (Read, Show)

class ShapeIf a where
        location :: a -> Point
        area :: a -> Float

instance ShapeIf Square where
        location = sLocation
        area a = (sLength a) ^ 2

instance ShapeIf Circle where
        location = cLocation
        area a = pi * (cRadius a) ^ 2

instance ShapeIf Triangle where
        location = tLocation
        area a = 0.5 * (tSide1 a) * (tSide2 a)

data Shape = forall a. ShapeIf a => Shape a

instance ShapeIf Shape where
    location (Shape s) = location s
    area     (Shape s) = area s

p = Point 0 0

shlist :: [Shape]        
shlist = [Shape (Square p 0), Shape (Circle p 1), Shape (Triangle p 2 3)]

main = print $ map area shlist

但请注意,Haskell 中没有 downcast,因此它不是 Java 风格的子类型的直接类比。还可以查看 this

2
这经常被认为是反模式。例如,参见Luke Palmer的广受引用的帖子 - Christian Conkle
1
@Christian Conkle,我在我的回答的最后一句提到了它。然而,我不同意这个观点。 - effectfully
1
经过进一步思考,我意识到反模式判断背后的一个假设是 Shape 是同构的,即不包含比 (Point, Float) 更多的信息,那么为什么不直接显式地存储 [(Point, Float)] 呢?但是在存在 Data.Typeable 的情况下,您可以恢复并使用真实类型(有限制)。换句话说,如果您在 Shape 上的约束列表中添加了 Typeable a,则可以使用 cast 进行向下转换,只要您尊重存在性类型变量的范围。 - Christian Conkle
@Christian Conkle,我不喜欢使用函数记录的方法的原因是它强制你在实际需要之前预先计算所有内容,这会破坏语义。看看@bheklilr的回答:为什么要将某些东西转换为“Shape”数据类型来计算其面积?假设我已经有了“Circle”,“Square”和其他数据类型,以及一些包含不同函数的类型类,并且我已经为这些数据类型实现了实例。现在我想在列表中存储一些形状。我应该完全重写整个开发过程吗? - effectfully
不,我不应该这样做。对于这个问题,有存在类型的解决方案——一种特别的解决方案,它可以工作,并且不会强制我改变任何东西。 - effectfully
显示剩余3条评论

1

在被指出 this 关于存在量词是反模式的博客文章后(我稍微笨拙地重新发明了它),我试着重写并想出了:

module Shapes (Shape(), Point, Circle(..), Triangle(..), Square(..), location, area) where

data Point = Point {
          xcoord :: Float
        , ycoord :: Float
} deriving (Read, Show)

data Shape = Shape {
      location :: Point
    , shape :: ShapeT
}

data ShapeT = CircleT Circle | PolygonT Polygon deriving (Read, Show)

data Circle = Circle {
          cRadius :: Float
} deriving (Read, Show)

data Polygon = SquareT Square | TriangleT Triangle deriving (Read, Show)

data Square = Square {
          sLength :: Float
} deriving (Read, Show)

-- only right angled triangles for ease of implementation!
data Triangle = Triangle {
          tSide1 :: Float
        , tSide2 :: Float
} deriving (Read, Show)

square :: Point -> Float -> Shape
square p l = Shape p (PolygonT $ SquareT (Square l))

circle :: Point -> Float -> Shape
circle p r = Shape p (CircleT (Circle r))

triangle :: Point -> Float -> Float -> Shape
triangle p s1 s2 = Shape p (PolygonT $ TriangleT (Triangle s1 s2))

area :: Shape -> Float
area = area' . shape

area' (PolygonT (SquareT (a))) = (sLength a) ^ 2
area' (CircleT (a)) = pi * (cRadius a) ^ 2
area' (PolygonT (TriangleT (a))) = 0.5 * (tSide1 a) * (tSide2 a)

1
你可以变得更加愤怒。
以Haskell术语分析,声明Java风格的类会做几件事情:
1.声明一组类型的存在,这些类型共享一个公共接口,要求所有成员都是所有基类关联类型集合的成员。 2.声明具体记录类型。 3.声明新数据类型是新类型集合和所有基类关联集合的成员。 4.声明存在类型,能够容纳任何是新类型集合成员的具体类型。
嗯。像接口、最终类等功能基本上允许您跳过列表中不需要/不想要的部分。除此之外,Java风格的类还提供了一个模块系统,我将不做任何解释。
看起来,如果您使用“面向对象设计模式”自己实现每个功能,您可以在Haskell中获得上述所有内容。但是在像Java这样的语言中,语言提供了很多帮助,如果它存在于Haskell中,则会表现为合理的默认值和语法糖。一个例子是继承,它基本上是超类记录在子类记录中的自动包含和从子类实现自动委托到超类实现。Haskell将不会提供任何此类帮助,因此一切都必须明确,并且“面向对象设计模式”变得非常冗长。
第1部分很容易看出来;共享公共接口的一组类型就是类型类。Haskell还允许我们在新类型类上放置超类约束。完成。
第2部分也很简单;只需声明一个新数据类型来保存所有成员变量即可。请注意,如果您打算能够“继承”此“类”并使用相同的访问器来获取成员变量,则需要将其作为类型类的一部分,而不仅仅是使用Haskell的记录语法为您声明它们。如果您正在从其他“OO模式”类“继承”,则需要将它们的数据类型包括在您的新数据类型的成员中。
第三部分是语言不够帮助的地方开始变得乏味。您需要为OO继承层次结构隐含的每个类型类实现实例,一直到最高层(即不仅仅是直接基类)。如果您没有覆盖“方法”,那么这将非常机械化和乏味,因为您可以将所有“继承”的方法委派给基类的包含成员数据(如果您遵循该模式,则应已具备所有所需的实例)。这是手动实现OO继承为您默认值的内容。
第四部分是重点。OO程序员是存在量化类型的大师,他们只是不知道而已。Haskell支持存在量化类型,但只能通过扩展以及有些笨拙地支持。而且,语言、惯用法和库并不真正希望您大量使用存在类型,因此您将开始经历许多摩擦;主要以烦人的类型错误的形式出现,当您设法找出正确的显式类型时,这些错误就会消失,偶尔您需要显式eta扩展(即将f = foo转换为f x = foo x,其中高阶函数的逻辑应该说这没有区别)。
你可能认为我们不需要存在类型,因为受类型类约束的类型变量足以允许代码在类型类的任何成员上工作。问题在于,受类型类约束的类型变量必须在每次调用类型类中的任何一个类型时进行实例化(选择是由调用者而不是运行时发生的数据决定)。
这就是为什么类型类不允许使用异构列表的原因;虽然类型 Shape a => [a] 可以容纳实现 Shape 接口的任何类型的对象,但所有列表元素都必须是相同的“实现 Shape 接口的任何类型”。存在类型是一个包装器,它包含带有类型变量的数据,但包装器本身没有该类型变量在其自己的类型中。这使您可以只拥有一个 [Shape] 列表,其中内部包含一个 ShapeI a => a
我认为我已经详细解释了这个问题,但以下是示例代码。警告:它非常丑陋:
{-# LANGUAGE ExistentialQuantification, GADTs, RankNTypes #-}


newtype Point = Point (Double, Double)
  deriving (Show, Eq)


-- The Shape common interface
-- Shape is just an interface, so no member data type
class ShapeI a
  where area :: a -> Double

-- The Shape existential reference
data Shape 
  where Shape :: ShapeI a => a -> Shape 


-- The Polygon common interface: 'subtype' of Shape
-- Polygon is just an interface, so no member data type
class ShapeI a => PolygonI a
  where vertexes :: a -> [Point]

-- The Polygon existential reference
data Polygon
  where Polygon :: PolygonI a => a -> Polygon


-- The Circle common interface
class ShapeI a => CircleI a
  where centre :: a -> Point 
        radius :: a -> Double

-- The Circle existential reference
data Circle
  where Circle :: CircleI a => a -> Circle

-- The Circle member data type
data CircleM = CircleM Point Double
  deriving (Show, Eq)

-- Circles are Shapes
instance ShapeI CircleM
  where area (CircleM _ r) = pi * r * r 

-- Circles are Circles
instance CircleI CircleM
  where centre (CircleM c _) = c
        radius (CircleM _ r) = r


data Colour = Med | Blue
  deriving (Show, Eq)

-- The ColouredCircle member data type
-- ColouredCircle is final, so not bothering with a type class or existential reference
data CircleColouredM = CircleColouredM CircleM Colour
  deriving (Show, Eq)


-- ColouredCircles are Shapes
instance ShapeI CircleColouredM
  where area (CircleColouredM circle _) = area circle

-- ColouredCircles are Circles
-- Note there is no actual implementation logic here, ColouredCircleM implements
-- the Circle methods purely by using Circle's implementations
instance CircleI CircleColouredM
  where centre (CircleColouredM circle _) = centre circle
        radius (CircleColouredM circle _) = radius circle


-- The Triangle member data type
-- Triangle is final, so not bothering with a type class or existential refernce
data TriangleM = TriangleM Point Point Point
  deriving (Show, Eq)

instance ShapeI TriangleM
  where area = const 7 -- In this hypothetical universe, all triangles have area 7

instance PolygonI TriangleM
  where vertexes (TriangleM a b c) = [a, b, c]

鉴于这一切:

-- Heterogenous list of different types of objects which are all Circles
circles :: [Circle]
circles = [Circle (CircleM (Point (3, 7)) 2), Circle (CircleColouredM (CircleM (Point (8, 1)) 1) Blue)]


-- Casts a Circle existential to a Shape existential
-- Note that the object *indside* the existential reference is the same; we're
-- just treating it as a Shape now
circleToShape :: Circle -> Shape
circleToShape (Circle c) = Shape c


-- Heterogenous list of different types of objects which are all Shapes
-- Note we are able to uniformly cast the circles list to shapes in order store
-- them in this list, even though they're not all the same type already
shapes :: [Shape]
shapes = [Shape (TriangleM (Point (0, 0)) (Point (10, 0)) (Point (0, 10)))] ++ map circleToShape circles


-- Helper function; can apply any function that is polymorphic in ShapeI to a
-- Shape existential; the explicit type is necessary, because it's a rank 2 type
apply :: (forall a. ShapeI a => a -> b) -> Shape -> b
apply f (Shape x) = f x


areas = map (apply area) shapes

因此,您可以看到我们确实获得了异构列表(或通用类型可以独立地持有“类”的任何成员并允许访问该类的公共接口),OO样式继承层次结构(尽管需要手动编写模板以不变地继承方法),甚至是向上转型。

您可能会遇到的另一个问题是Haskell在类型规范方面非常严格。您将无法进行向下转型;实际上,您将无法引用除了由ShapeI接口所隐含的内容之外的任何Shape存在性质。关于它特别包含什么的所有知识都消失了。

这也意味着 shapes 列表几乎没有用处;我们能够做的唯一有意义的事情是 map (apply area) shapes,因此我们可能会直接创建一个 Double 列表,而不是使用大量样板文件。另外,在面向对象语言中,根类通常提供出人意料的多功能性;在Java中,您可以toString任意对象,比较它们是否相等等等。这里没有任何东西。一旦某些东西成为存在参考,您就只能访问其约束所说的内容,除此之外没有任何内容可用。没有Show约束,没有show方法(即使这里使用的所有类型都实现了Show)。同样,没有Eq约束,没有 == 函数;这可能不会按您的意愿工作,因为(作为惯用的Haskell函数,不希望处理模拟OO类层次结构的存在参考) == 仅适用于两个保证是相同类型的值,并且存在参考放弃了关于任何特定类型的所有知识,因此您永远无法保证。
我相信您可以改进上面的模式,使其更易用,甚至可以自动化一些部分(我们能写一个通用的upcast函数吗?TemplateHaskell能为我们生成样板文件吗?)。如果您加入像Typeable这样的约束,您甚至应该能够获得运行时检查的下降转换(如果您真的想要),并且可能能够实现一个有效的等号运算符(当类型不匹配时返回False,当类型匹配时委托给==)。但是就个人而言,我不太倾向于尝试进一步完善它。

简而言之:OO风格的类(忽略变异)基本上是类型类、持有成员数据的类型、存在类型的特定组合,还有大量默认机制使其易于工作,而不是成为巨大的痛苦。鉴于Haskell将每个这些部分作为正交最小概念提供给您,我发现更容易单独了解和理解这些概念,并根据需要单独或协同应用它们,而不是采用OO的瑞士军刀方法,并尝试强制每个程序适合该结构所提供的设施。


我本以为我的尝试很丑陋。哇,我要求它变得更干净而不是更糟糕;-)。但我认为你的 TL;DR(它不仅仅是总结,而且还引入了一个新概念...这会让你的教授扣分)就是答案:“更容易地...根据需要单独应用它们[面向对象的概念]”。 - dave

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