Tipos de suma vs polimorfismo

7

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 más me faltan en los idiomas de OO son la coincidencia de patrones y los tipos de suma. En todos los lugares que veo, veo situaciones que se modelarían de forma trivial con una unión discriminada, pero me resisto a utilizar una acción de OO DU que me parece antinatural al paradigma.

Esto generalmente me lleva a crear tipos intermedios para manejar las relaciones or que un tipo de suma manejaría por mí. También parece conducir a una gran cantidad de ramificación. Si leo 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 valores de null . Obviamente, la relación or puede ser modelada por un tipo con un valor null y un valor que no sea null , pero esto significa null pruebas en todas partes. ¿Hay alguna forma de modelar polimorfos tipos heterogéneos pero asociados lógicamente? Las estrategias o patrones de diseño serían muy útiles, o simplemente formas de pensar sobre tipos heterogéneos y asociados en general en el paradigma OO.

    
pregunta Patrick D 12.03.2018 - 11:30

3 respuestas

9

Al igual que usted, desearía que prevalecieran las uniones discriminadas; sin embargo, la razón por la que son útiles en la mayoría de los lenguajes funcionales es que proporcionan una concordancia de patrones exhaustiva, y sin esto, son simplemente bastante sintaxis: no solo de concordancia de patrones: exhaustiva de concordancia de patrones, por lo que el código no No compile si no cubre 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 (por ejemplo, mediante la coincidencia de patrones). Lo mejor de las interfaces es que no le importa qué tipo de tipo es, porque sabe que puede tratarlo como un iface : no se necesita una lógica única para cada tipo: sin ramificación.

Este no es un "código funcional tiene más ramificaciones, código OO tiene menos", es un "lenguaje funcional" que se adapta mejor a los dominios en los que tiene sindicatos, que obligan a la ramificación, y los idiomas "OO" son mejores adecuado para el código donde se puede exponer el comportamiento común como una interfaz común, que puede parecer que hace menos ramificación ". La ramificación es una función de su diseño y el dominio. Sencillamente, si sus "tipos heterogéneos pero asociados lógicamente" no pueden exponer una interfaz común, entonces tiene que dividirse / emparejar patrones sobre ellos. Este es un problema de dominio / diseño.

A lo que se está refiriendo Misko es la idea general de que si puede exponer sus tipos como una interfaz común, el uso de funciones OO (interfaces / polimorfismo) mejorará su vida al escribir type- comportamiento específico en el tipo en lugar de en el código consumidor.

Es importante reconocer que las interfaces y las uniones son opuestas entre sí: una interfaz define algunas cosas que el tipo tiene que implementar, y la unión define algunas cosas que el consumidor tiene que considerar. 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 se deben actualizar todas las coincidencias de patrones exhaustivas sobre la unión. Cumplen diferentes roles, y aunque a veces es posible implementar un sistema 'de cualquier manera', lo que se toma como una decisión de diseño: ninguno es inherentemente mejor.

Un beneficio de ir con 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 al escribir nuevos patrones exhaustivos que coincidan 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 null cheques. Todo lo que hace proporciona una manera de evitar algunas comprobaciones 'nulas' donde el comportamiento 'nulo' puede exponerse 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 unir este patrón exhaustivamente" y terminará realizando una verificación de 'bifurcación'.

    
respondido por el VisualMelon 12.03.2018 - 18:28
2

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 hacer 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 # otra vez:

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

C # otra vez:

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 para Match juntos en una interfaz que podríamos llamar un visitante.

En el lado de las ventajas, esto le da una codificación de principio de los tipos de suma. (Es la codificación de Scott .) Le brinda una "combinación de patrones" exhaustiva aunque solo una capa " "de hacer coincidir a la vez. Match es de alguna manera una interfaz "completa" para estos tipos y cualquier operación adicional que podamos desear puede definirse en términos de esta. Presenta una perspectiva diferente de muchos patrones OO, como el Patrón de objeto nulo y 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 / Maybe es como un patrón de objeto nulo genérico. El patrón compuesto es similar a la codificación de 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 método Match pone algunas restricciones sobre qué subclases se pueden agregar de manera significativa, especialmente si queremos mantener la propiedad de sustitución de Liskov. Por ejemplo, aplicar esta codificación a un tipo de enumeración no le permitiría extender la enumeración de manera significativa. Si quisiera ampliar la enumeración, tendría que cambiar todos los llamadores e implementadores en todas partes como si estuviera utilizando enum y switch . Dicho esto, esta codificación es algo más flexible que la original. Por ejemplo, podemos agregar un implementador Append de List que solo contiene dos listas, lo que nos proporciona una adición de tiempo constante. Esto se comportaría como las listas adjuntas, pero se representaría de una manera diferente.

Por supuesto, muchos de estos problemas tienen que ver con el hecho de que Match está ligado (conceptualmente pero intencionalmente) 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 completamente la necesidad de ramificación para lograr un OO ideal. Smalltalk, por ejemplo, usa este patrón a menudo incluido 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.

    
respondido por el Derek Elkins 12.03.2018 - 22:44
1

El manejo del 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 tenga métodos que no hagan nada, pero que tampoco cometan errores. Esto no elimina las comprobaciones nulas por completo, pero significa que solo necesita comprobar si hay nulos en la creación del objeto y devolver su objeto nulo.

El patrón de estado es una forma de minimizar las ramificaciones y ofrecer algunos de los beneficios de la coincidencia de patrones. De nuevo, empuja la lógica de bifurcació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 idiomas también están agregando la coincidencia de patrones como una característica, C # es un ejemplo.

    
respondido por el Ryathal 12.03.2018 - 13:36

Lea otras preguntas en las etiquetas