2010-09-16 13 views
10

Me encuentro corriendo un problema comúnmente, al escribir programas más grandes en Haskell. A menudo me encuentro con varios tipos distintos que comparten una representación interna y varias operaciones principales.¿Maneja múltiples tipos con la misma representación interna y un texto estándar mínimo?

Existen dos enfoques relativamente obvios para resolver este problema.

Uno está utilizando una clase de tipo y la extensión GeneralizedNewtypeDeriving. Ponga suficiente lógica en una clase de tipo para admitir las operaciones compartidas que el caso de uso desee. Cree un tipo con la representación deseada y cree una instancia de la clase de tipo para ese tipo. Luego, para cada caso de uso, cree envoltorios para él con newtype y obtenga la clase común.

La otra es declarar el tipo con una variable de tipo fantasma, y ​​luego usar EmptyDataDecls para crear distintos tipos para cada caso de uso diferente.

Mi principal preocupación no es mezclar los valores que comparten la representación interna y las operaciones, pero tienen diferentes significados en mi código. Ambos enfoques resuelven ese problema, pero se sienten significativamente torpes. Mi segunda preocupación es reducir la cantidad de repetidores requeridos, y ambos enfoques funcionan bastante bien.

¿Cuáles son las ventajas y desventajas de cada enfoque? ¿Hay alguna técnica que se acerque a hacer lo que quiero, proporcionando seguridad de tipo sin un código repetitivo?

Respuesta

2

He comparado ejemplos de juguetes y no he encontrado una diferencia de rendimiento entre los dos enfoques, pero el uso por lo general difiere un poco.

Por ejemplo, en algunos casos tiene un tipo genérico cuyos constructores están expuestos y desea utilizar contenedores newtype para indicar un tipo más semánticamente específico. Uso de newtype s conduce entonces a llamar a sitios como,

s1 = Specific1 $ General "Bob" 23 
s2 = Specific2 $ General "Joe" 19 

Cuando el hecho de que las representaciones internas son los mismos entre los diferentes Newtypes específicos es transparente.

La estrategia de etiquetas de tipo casi siempre va de la mano con la representación escondite constructor,

data General2 a = General2 String Int 

y el uso de constructores inteligentes, lo que lleva a una definición de tipo de datos y llamar a sitios como,

mkSpecific1 "Bob" 23 

Parte El motivo es que desea una forma sintácticamente ligera de indicar qué etiqueta desea. Si no proporcionaba constructores inteligentes, el código del cliente a menudo recogería anotaciones de tipo para restringir las cosas, p. Ej.,

myValue = General2 String Int :: General2 Specific1 

Una vez que adopta los constructores inteligentes, puede agregar fácilmente una lógica de validación adicional para capturar los usos indebidos de la etiqueta. Un buen aspecto del enfoque de tipo fantasma es que la coincidencia de patrones no cambia en absoluto para el código interno que tiene acceso a la representación.

internalFun :: General2 a -> General2 a -> Int 
internalFun (General2 _ age1) (General2 _ age2) = age1 + age2 

Por supuesto que puede utilizar los newtype s con los constructores inteligentes y una clase interna para acceder a la representación compartida, pero creo que un punto de decisión clave en este espacio de diseño es si desea mantener sus constructores de representación expuestos. Si el intercambio de representación debe ser transparente, y el código del cliente debe ser libre de usar cualquier etiqueta que desee sin validación adicional, entonces las envolturas newtype con GeneralizedNewtypeDeriving funcionan bien. Pero si va a adoptar constructores inteligentes para trabajar con representaciones opacas, generalmente prefiero los tipos fantasmas.

+0

Si la memoria me sirve, 'datos Foo a = Foo a',' datos Foo ab = Foo a', y 'newtype Bar a = Bar (Foo a)' (con el primer 'Foo') deberían compilar todos al mismo representación en tiempo de ejecución, por lo que encontrar una diferencia no trivial en el rendimiento sería algo inesperado. –

+1

@camccann ¡La belleza de ghc-core y Criterion es una prueba empírica para complementar la memoria! :) Creo que la pregunta sobre el rendimiento tiene más que ver con si las operaciones provenientes de una clase afectan o no su rendimiento en comparación con la representación en tiempo de ejecución del valor en sí. Las funciones polimórficas van desde '(General a, General b) => a -> b -> Int' a' General2 a -> General2 b -> Int'. – Anthony

3

Hay otro enfoque directo.

data MyGenType = Foo | Bar 

op :: MyGenType -> MyGenType 
op x = ... 

op2 :: MyGenType -> MyGenType -> MyGenType 
op2 x y = ... 

newtype MySpecialType {unMySpecial :: MyGenType} 

inMySpecial f = MySpecialType . f . unMySpecial 
inMySpecial2 f x y = ... 

somefun = ... inMySpecial op x ... 
someOtherFun = ... inMySpecial2 op2 x y ... 

Alternativamente,

newtype MySpecial a = MySpecial a 
instance Functor MySpecial where... 
instance Applicative MySpecial where... 

somefun = ... fmap op x ... 
someOtherFun = ... liftA2 op2 x y ... 

creo que estos enfoques son más agradable si usted desea utilizar su tipo general "desnudo" con cualquier frecuencia, y sólo a veces quiere etiquetarlo. Si, por otro lado, generalmente desea utilizarlo etiquetado, entonces el enfoque de tipo fantasma expresa más directamente lo que desea.

+0

Realmente me gusta esa tecla Mayús hoy, ¿eh? –

+0

Vaya. Formateo fijo – sclv

+1

Por cierto, puede auto-generar inMySpecial1,2,3 ../ withMySpecial, etc. con template haskell. ver http://github.com/yairchu/peakachu/blob/master/src/Data/Newtype.hs – yairchu

1

Ponga suficiente lógica en una clase de tipo para admitir las operaciones compartidas que desee el caso de uso. Cree un tipo con la representación deseada y cree una instancia de la clase de tipo para ese tipo. Luego, para cada caso de uso, cree envoltorios para él con newtype y obtenga la clase común.

Esto presenta algunos escollos, dependiendo de la naturaleza del tipo y qué tipo de operaciones están involucradas.

En primer lugar, obliga a que muchas funciones sean innecesariamente polimórficas; incluso si en la práctica cada instancia hace lo mismo para diferentes contenedores, la suposición de mundo abierto para las clases de tipos significa que el compilador tiene que considerar la posibilidad instancias. Si bien GHC es definitivamente más inteligente que el compilador promedio, cuanta más información puede brindarle, más puede hacer para ayudarlo.

En segundo lugar, esto puede crear un cuello de botella para estructuras de datos más complicadas. Cualquier función genérica en los tipos envueltos estará restringida a la interfaz presentada por la clase de tipo, de modo que a menos que esa interfaz sea exhaustiva en términos de expresividad y eficiencia, se corre el riesgo de utilizar algoritmos de cojera que usen el tipo o alterar la clase de tipo repetidamente a medida que encuentra la funcionalidad faltante.

Por otro lado, si el tipo envuelto ya se mantiene abstracto (es decir, no exporta los constructores), el problema del cuello de botella es irrelevante, por lo que una clase de tipo podría tener sentido. De lo contrario, probablemente vaya con las etiquetas de tipo fantasma (o posiblemente el enfoque de identidad Functor que se describió a simple vista).

Cuestiones relacionadas