Tipos de suma vs polimorfismo

10

El año pasado di el salto y aprendí un lenguaje de programación funcional (F #) y una de las cosas más interesantes que he encontrado es cómo afecta la forma en que diseño el software OO. Las dos cosas que me faltan más en los idiomas OO son la coincidencia de patrones y los tipos de suma. En todas partes que miro veo situaciones que serían modeladas trivialmente con una unión discriminada, pero soy reacio a la palanca en alguna implementación de OO DU que se siente poco natural para el paradigma.

Esto generalmente me lleva a crear tipos intermedios para manejar las orrelaciones que un tipo de suma manejaría por mí. También parece conducir a una buena ramificación. Si leo a personas como Misko Hevery , sugiere que un buen diseño de OO puede minimizar la ramificación a través del polimorfismo.

Una de las cosas que evito tanto como sea posible en el código OO son los tipos con nullvalores. Obviamente, la orrelación puede ser modelada por un tipo con un nullvalor y otro sin nullvalor, pero esto significa nullpruebas en todas partes. ¿Hay alguna forma de modelar polimórficamente los tipos heterogéneos pero lógicamente asociados? Las estrategias o patrones de diseño serían muy útiles, o simplemente formas de pensar acerca de los tipos heterogéneos y asociados generalmente en el paradigma OO.

Patrick D
fuente
3
"Un buen diseño de OO puede minimizar la ramificación a través del polimorfismo" : mueve la ramificación de la lógica comercial real al código de inicialización / configuración. El beneficio generalmente es que la "inicialización y configuración" ocurre mucho menos (como ocurre en el código, no en términos de "ejecución") de lo que se necesitaría una ramificación explícita en la lógica de negocios. La desventaja es que no hay espacio para o dependiendo del tipo de objetos objetivo dentro de la lógica de negocios ...
Timothy Truckle
3
Esto puede ser de su interés (básicamente, el autor modela un tipo de suma como una jerarquía, con un montón de métodos anulados en las subclases como una forma de modelar la coincidencia de patrones); Además, en OO, las comprobaciones nulas se pueden evitar utilizando el Patrón de objeto nulo (solo un objeto que no hace nada para una operación polimórfica dada).
Filip Milovanović
El patrón compuesto podría valer la pena leerlo.
candied_orange
1
¿Puedes dar un ejemplo del tipo de cosas que quieres mejorar?
JimmyJames
@TimothyTruckle Buena explicación, pero no siempre es "inicialización / configuración". La ramificación ocurre cuando invoca el método, de manera invisible, pero un lenguaje dinámico puede permitirle agregar clases dinámicamente, en cuyo caso la ramificación también está cambiando dinámicamente.
Frank Hileman

Respuestas:

15

Al igual que usted, desearía que los sindicatos discriminados fueran más frecuentes; sin embargo, la razón por la que son útiles en la mayoría de los lenguajes funcionales es que brindan una concordancia exhaustiva de patrones, y sin esto, son solo una sintaxis bonita: no solo coincidencia de patrones: coincidencia exhaustiva de patrones, de modo que el código no se compila si no lo hace ' Cubra todas las posibilidades: esto es lo que le da poder.

La única forma de hacer algo útil con un tipo de suma es descomponerlo y ramificar según el tipo que sea (p. Ej., Por coincidencia de patrones). Lo mejor de las interfaces es que no te importa de qué tipo es algo, porque sabes que puedes tratarlo como un iface: no se necesita una lógica única para cada tipo: no hay ramificación.

Este no es un "código funcional que tiene más ramificaciones, el código OO tiene menos", es un "'lenguajes funcionales' que se adaptan mejor a los dominios donde tiene uniones, que obligan a la ramificación, y los 'idiomas OO' son más adecuados para el código donde puede exponer el comportamiento común como una interfaz común, lo que podría parecer que se ramifica menos ". La ramificación es una función de su diseño y el dominio. En pocas palabras, si sus "tipos heterogéneos pero asociados lógicamente" no pueden exponer una interfaz común, entonces debe ramificar / combinar patrones sobre ellos. Este es un problema de dominio / diseño.

A lo que Misko puede estar refiriéndose es a la idea general de que si puede exponer sus tipos como una interfaz común, entonces el uso de características OO (interfaces / polimorfismo) mejorará su vida al poner un comportamiento específico de tipo en el tipo en lugar de en el consumidor código.

Es importante reconocer que las interfaces y las uniones son algo opuestas entre sí: una interfaz define algunas cosas que el tipo tiene que implementar, y la unión define algunas cosas que el consumidor debe tener en cuenta. Si agrega un método a una interfaz, ha cambiado ese contrato y ahora cada tipo que lo implementó anteriormente debe actualizarse. Si agrega un nuevo tipo a una unión, ha cambiado ese contrato, y ahora cada patrón exhaustivo que coincida con la unión debe actualizarse. Cumplen diferentes roles, y aunque a veces puede ser posible implementar un sistema 'de cualquier manera', lo que usted elige es una decisión de diseño: ninguno es inherentemente mejor.

Una ventaja de usar interfaces / polimorfismo es que el código de consumo es más extensible: puede pasar un tipo que no se definió en el momento del diseño, siempre que exponga la interfaz acordada. Por otro lado, con una unión estática, puede explotar comportamientos que no se consideraron en el momento del diseño escribiendo nuevas coincidencias de patrones exhaustivas, siempre y cuando se cumplan con el contrato de la unión.


Con respecto al 'Patrón de objeto nulo': esto no es una bala de plata y no reemplaza los nullcheques. Todo lo que hace proporciona una manera de evitar algunas comprobaciones 'nulas' donde el comportamiento 'nulo' puede quedar expuesto detrás de una interfaz común. Si no puede exponer el comportamiento 'nulo' detrás de la interfaz del tipo, entonces estará pensando "Realmente desearía poder hacer coincidir exhaustivamente este patrón" y terminará realizando una verificación de "ramificación".

VisualMelon
fuente
44
relacionado con el penúltimo párrafo: en.wikipedia.org/wiki/Expression_problem
jk.
"una interfaz define algunas cosas que el tipo tiene que implementar, y la unión define algunas cosas que el consumidor debe tener en cuenta" - no tiene que mirar las interfaces de esa manera. Un componente puede definir una interfaz requerida : lo que otro componente tiene que implementar; y una interfaz provista , una que un componente del consumidor debe considerar (es decir, debe ser programada).
Filip Milovanović
@ FilipMilovanović sí, no he sido muy preciso allí. Estaba tratando de evitar entrar en el 'triángulo' de dependencias con interfaces (consumidor -> interfaz <- implementador / tipo) en lugar de las dependencias 'lineales' con una unión (consumidor -> unión -> tipos), porque estoy Realmente solo trato de expresar dónde se está llevando a cabo la 'toma de decisiones' (por ejemplo, ¿dónde definimos qué hacer si se nos presenta este tipo?)
VisualMelon
3

Hay una forma bastante "estándar" de codificar tipos de suma en un lenguaje orientado a objetos.

Aquí hay dos ejemplos:

type Either<'a, 'b> = Left of 'a | Right of 'b

En C #, podríamos representar esto como:

interface Either<A, B> {
    C Match<C>(Func<A, C> left, Func<B, C> right);
}

class Left<A, B> : Either<A, B> {
    private readonly A a;
    public Left(A a) { this.a = a; }
    public C Match<C>(Func<A, C> left, Func<B, C> right) {
        return left(a);
    }
}

class Right<A, B> : Either<A, B> {
    private readonly B b;
    public Right(B b) { this.b = b; }
    public C Match<C>(Func<A, C> left, Func<B, C> right) {
        return right(b);
    }
}

F # nuevamente:

type List<'a> = Nil | Cons of 'a * List<'a>

C # nuevamente:

interface List<A> {
    B Match<B>(B nil, Func<A, List<A>, B> cons);
}

class Nil<A> : List<A> {
    public Nil() {}
    public B Match<B>(B nil, Func<A, List<A>, B> cons) {
        return nil;
    }
}

class Cons<A> : List<A> {
    private readonly A head;
    private readonly List<A> tail;
    public Cons(A head, List<A> tail) {
        this.head = head;
        this.tail = tail;
    }
    public B Match<B>(B nil, Func<A, List<A>, B> cons) {
        return cons(head, tail);
    }
}

La codificación es completamente mecánica. Esta codificación produce un resultado que tiene la mayoría de las mismas ventajas y desventajas de los tipos de datos algebraicos. También puede reconocer esto como una variación del Patrón de visitante. Podríamos recopilar los parámetros Matchjuntos en una interfaz que podríamos llamar un visitante.

En el lado de las ventajas, esto le proporciona una codificación basada en principios de tipos de suma. (Es la codificación Scott ). Le brinda una exhaustiva "coincidencia de patrones" aunque solo una "capa" de coincidencia a la vez. Matches de alguna manera una interfaz "completa" para estos tipos y cualquier operación adicional que deseemos puede definirse en términos de la misma. Presenta una perspectiva diferente en muchos patrones OO, como el Patrón de objeto nulo y el Patrón de estado, como indiqué en la respuesta de Ryathal, así como el Patrón de visitante y el Patrón compuesto. El tipo Option/ Maybees como un patrón de objeto nulo genérico. El patrón compuesto es similar a la codificación type Tree<'a> = Leaf of 'a | Children of List<Tree<'a>>. El patrón de estado es básicamente una codificación de una enumeración.

En el lado de las desventajas, como lo escribí, el Matchmétodo impone algunas restricciones sobre qué subclases se pueden agregar significativamente, especialmente si queremos mantener la Propiedad de sustituibilidad de Liskov. Por ejemplo, aplicar esta codificación a un tipo de enumeración no le permitiría extender significativamente la enumeración. Si quisiera extender la enumeración, tendría que cambiar todas las personas que llaman e implementadores en todas partes como si estuviera usando enumy switch. Dicho esto, esta codificación es algo más flexible que la original. Por ejemplo, podemos agregar un Appendimplementador Listque solo contiene dos listas, lo que nos da un apéndice de tiempo constante. Esto se comportaría como las listas adjuntas, pero se representaría de manera diferente.

Por supuesto, muchos de estos problemas tienen que ver con el hecho de que Matchestá un tanto (conceptual pero intencionalmente) vinculado a las subclases. Si utilizamos métodos que no son tan específicos, obtenemos diseños OO más tradicionales y recuperamos la extensibilidad, pero perdemos la "integridad" de la interfaz y, por lo tanto, perdemos la capacidad de definir cualquier operación en este tipo en términos de interfaz. Como se mencionó en otra parte, esta es una manifestación del problema de expresión .

Podría decirse que los diseños como los anteriores se pueden usar sistemáticamente para eliminar por completo la necesidad de ramificar para lograr un ideal OO. Smalltalk, por ejemplo, usa este patrón a menudo incluso para los propios booleanos. Pero como sugiere la discusión anterior, esta "eliminación de ramificación" es bastante ilusoria. Acabamos de implementar la ramificación de una manera diferente, y todavía tiene muchas de las mismas propiedades.

Derek Elkins dejó SE
fuente
1

El manejo nulo se puede hacer con el patrón de objeto nulo . La idea es crear una instancia de sus objetos que devuelva valores predeterminados para cada miembro y que tenga métodos que no hagan nada, pero que tampoco generen errores. Esto no elimina las verificaciones nulas por completo, pero significa que solo necesita verificar nulos en la creación del objeto y devolver su objeto nulo.

El patrón de estado es una forma de minimizar la ramificación y brindar algunos de los beneficios de la coincidencia de patrones. Nuevamente, empuja la lógica de ramificación a la creación de objetos. Cada estado es una implementación separada de una interfaz base, por lo que todo el código consumidor solo necesita llamar a DoStuff () y se llama al método adecuado. Algunos lenguajes también están agregando coincidencia de patrones como característica, C # es un ejemplo.

Ryathal
fuente
(¿Un?) Irónicamente, estos son ejemplos de la forma "estándar" de codificar tipos de unión discriminados en OOP.
Derek Elkins dejó SE