opciones - Reestructuración de un tipo de datos OOP en tipos Haskell
multiplicar haskell (2)
Es posible que esté buscando un equivalente de Haskell de despacho dinámico , de modo que pueda almacenar una lista heterogénea de valores que soporten distintas implementaciones de una interfaz Shape
común.
Los tipos existenciales de Haskell respaldan este tipo de uso. Es bastante raro que un programa Haskell realmente necesite tipos existenciales, como lo demuestra la respuesta de Ben, los tipos de suma pueden manejar este tipo de problema. Sin embargo, los tipos existenciales son apropiados para una gran colección de casos abiertos:
{-# LANGUAGE ExistentialQuantification #-}
...
class Shape a where
bounds :: a -> AABB
draw :: a -> IO ()
data AnyShape = forall a. Shape a => AnyShape a
Esto le permite declarar instancias en un estilo abierto:
data Line = Line Point Point
instance Shape Line where ...
data Circle= Circle {center :: Point, radius :: Double}
instance Shape Circle where ...
...
Entonces, puedes construir tu lista heterogénea:
shapes = [AnyShape(Line a b),
AnyShape(Circle a 3.0),
AnyShape(Circle b 1.8)]
y usarlo de manera uniforme:
drawIn box xs = sequence_ [draw s | AnyShape s <- xs, bounds s `hits` box]
Tenga en cuenta que necesita desenvolver su AnyShape
para usar las funciones de interfaz de class Shape
. También tenga en cuenta que debe usar las funciones de clase para acceder a sus datos heterogéneos: ¡no hay otra manera de "bajar" los valores existenciales no s
! Su tipo solo tiene sentido dentro del alcance local, por lo que el compilador no lo dejará escapar.
Si está tratando de usar tipos existenciales, pero se encuentra con la necesidad de "abatirlos", los tipos de suma podrían ser una mejor opción.
Viniendo de un fondo OOP, el sistema de tipos de Haskell y la forma en que interactúan los constructores de datos y las clases de tipos es difícil de conceptualizar. Puedo entender cómo se usan cada uno de ellos para ejemplos simples, pero algunos ejemplos más complicados de estructuras de datos que son muy adecuados para un estilo OOP están demostrando ser no triviales para traducir a tipos similarmente elegantes y comprensibles.
En particular, tengo un problema con la organización de una jerarquía de datos como la siguiente.
Esta es una estructura de herencia jerárquica profundamente anidada, y la falta de apoyo para la subtipificación no deja claro cómo convertir esta estructura en una alternativa de sentimiento natural en Haskell. Puede estar bien reemplazar algo como Polygon
con un tipo de datos de suma, declarándolo como
data Polygon
= Quad Point Point
| Triangle Point Point Point
| RegularNGon Int Radius
| ...
Pero esto pierde parte de la estructura, y solo puede lograrse satisfactoriamente para un nivel de la jerarquía. Las clases de tipo se pueden usar para implementar una forma de herencia y subestructura en que una clase de tipo Polygon
podría ser una subclase de una Shape
, por lo que quizás todas las instancias de Polygon
tengan implementaciones para centroid :: Point
y también vertices :: [Point]
, pero esto parece insatisfactorio. ¿Cuál sería una buena forma de capturar la estructura de la imagen en Haskell?
Puede usar tipos de suma para representar toda la jerarquía, sin perder estructura. Algo como esto lo haría:
data Shape = IsPoint Point
| IsLine Line
| IsPolygon Polygon
data Point = Point { x :: Int, y :: Int }
data Line = Line { a :: Point, b :: Point }
data Polygon = IsTriangle Triangle
| IsQuad Quad
| ...
Y así. El patrón básico es traducir cada clase abstracta OO en un tipo suma Haskell, con cada una de sus subclases inmediatas OO (que pueden ser abstractas) como variantes en el tipo suma. Las clases concretas son tipos de producto / registro con los miembros de datos reales en ellos. 1
Lo que pierde en comparación con el OOP al que está acostumbrado al modelar las cosas de esta manera no es la capacidad de representar su jerarquía, sino la capacidad de extenderla sin tocar el código existente. Los tipos de suma están "cerrados", donde la herencia OO es "abierta". Si luego decides que quieres una opción de Circle
para Shape
, debes agregarla a Shape
y luego agregar casos para ella en todos los sitios donde coincida con el patrón en una Shape
.
Sin embargo, este tipo de jerarquía probablemente requiera downcasting bastante liberal en OO. Por ejemplo, si desea una función que pueda indicar si dos formas se intersectan, probablemente sea un método abstracto en Shape
como Shape.intersects(Shape other)
, para que cada subtipo escriba su propia implementación. Pero cuando estoy escribiendo Rectangle.intersects(Shape other)
es básicamente imposible de forma genérica, sin saber qué otras subclases de Shape
existen. Tendré que usar verificaciones de isinstance
para ver qué es realmente other
. Pero eso en realidad significa que probablemente no pueda simplemente agregar mi nueva subclase Circle
sin volver a visitar el código existente; una jerarquía OO donde se necesitan verificaciones de las instancias es de facto tan "cerrada" como la jerarquía de tipos de suma de Haskell. Básicamente, la coincidencia de patrones en uno de los tipos de suma generados al aplicar este patrón es el equivalente de isinstancing y downcasting en la versión OO. Solo porque los tipos de suma son exhaustivamente conocidos por el compilador (solo es posible porque están cerrados), si agrego un caso de Circle
a Shape
el compilador puede contarme sobre todos los lugares que necesito volver a visitar para manejar ese caso. . 2
Si tiene una jerarquía que no necesita una gran cantidad de downcasting, significa que las diversas clases base tienen interfaces sustanciales y útiles que garantizan que estén disponibles, y generalmente usa cosas a través de esa interfaz en lugar de activar lo que posiblemente sea ser, entonces probablemente puedas usar clases de tipo. Todavía necesita todos los tipos de datos "hoja" (los tipos de productos con los campos de datos reales), solo que en lugar de agregar envoltorios de suma para agruparlos agrega clases de tipos para la interfaz común. Si puede usar este estilo de traducción, puede agregar nuevos casos más fácilmente (simplemente agregue el nuevo tipo de datos Circle
, y una instancia para decir cómo implementa la clase de tipo Shape
; todos los lugares que son polimórficos de cualquier tipo en el Shape
clase Shape
manejará ahora Circle
s también). Pero si estás haciendo eso en OO, siempre tienes downcasts disponibles como una escotilla de escape cuando resulta que no puedes manejar formas genéricamente; con este diseño en Haskell es imposible . 3
Pero mi respuesta "real" a "cómo represento las jerarquías tipo OO en Haskell" es desafortunadamente la más trillada: no lo hago. Diseño de manera diferente en Haskell que en los lenguajes OO 4 , y en la práctica no es un gran problema. Pero para decir cómo diseñaría este caso de manera diferente, tendría que saber más sobre para qué los usa. Por ejemplo, podría hacer algo como representar una forma como una función Point -> Bool
(que le indica si un punto determinado está dentro de la forma) y tener cosas como circle :: Point -> Int -> (Point -> Bool)
para generar tales funciones correspondientes a formas normales; esa representación es asombrosa para formar formas de intersección / unión compuestas sin saber nada sobre ellas (se intersect shapeA shapeB = /point -> shapeA point && shapeB point
), pero es terrible para calcular cosas como áreas y circunferencias.
1 Si tiene clases abstractas con miembros de datos, o tiene clases concretas que también tienen subclases adicionales, puede insertar manualmente los miembros de datos en las "hojas", factorizar los miembros de datos heredados en un registro compartido y crear todos los " leaves "contiene uno de esos, divide una capa para que tengas un tipo de producto que contenga los miembros de datos heredados y un tipo de suma (donde ese tipo de suma luego se" divide "en las opciones para las subclases), cosas así.
2 Si usa patrones globales, la advertencia puede no ser exhaustiva, por lo que no siempre es a prueba de balas, pero a prueba de balas depende de cómo codifique.
3 A menos que opte por la información del tipo de tiempo de ejecución con una solución como Typeable
, pero eso no es un cambio invisible; tus interlocutores también deben optar por ello.
4 De hecho, probablemente no diseñaría una jerarquía como esta incluso en OO languages. Encuentro que no resulta tan útil como piensas en los programas reales, de ahí el consejo de "composición de la ventaja sobre la herencia".