¿Cómo refactorizar un programa OO en uno funcional?

26

Tengo dificultades para encontrar recursos sobre cómo escribir programas en un estilo funcional. El tema más avanzado que pude encontrar discutido en línea fue usar la tipificación estructural para reducir las jerarquías de clase; la mayoría solo trata de cómo usar map / fold / reduce / etc para reemplazar los bucles imperativos.

Lo que realmente me gustaría encontrar es una discusión en profundidad de una implementación OOP de un programa no trivial, sus limitaciones y cómo refactorizarlo en un estilo funcional. No solo un algoritmo o una estructura de datos, sino algo con varios roles y aspectos diferentes, quizás un videojuego. Por cierto, leí la Programación funcional del mundo real de Tomas Petricek, pero me quedo con ganas de más.

Asik
fuente
66
No creo que sea posible. tienes que rediseñar (y reescribir) todo de nuevo.
Bryan Chen el
18
-1, esta publicación está sesgada por la suposición errónea de que la POO y el estilo funcional son contrarios. Esos son en su mayoría conceptos ortogonales, y en mi humilde opinión es un mito que no lo son. "Funcional" es más opuesto a "Procesal", y ambos estilos se pueden usar junto con OOP.
Doc Brown
11
@DocBrown, OOP se basa demasiado en un estado mutable. Los objetos sin estado no encajan bien en la práctica actual de diseño de OOP.
SK-logic
99
@ SK-logic: la clave no son objetos sin estado, sino objetos inmutables. E incluso cuando los objetos son mutables, a menudo se pueden usar en una parte funcional del sistema siempre que no se cambien dentro del contexto dado. Además, supongo que sabes que los objetos y los cierres son intercambiables. Entonces, todo esto muestra que OOP y "funcional" no son contrarios.
Doc Brown
12
@DocBrown: Creo que las construcciones del lenguaje son ortogonales, mientras que la mentalidad tiende a chocar. La gente de OOP tiende a preguntarse "¿cuáles son los objetos y cómo colaboran?"; las personas funcionales tienden a preguntar "¿cuáles son mis datos y cómo quiero transformarlos?". Esas no son las mismas preguntas y conducen a respuestas diferentes. También creo que leíste mal la pregunta. No es "OOP babea y reglas de FP, ¿cómo me deshago de OOP?", Es "obtengo OOP y no obtengo FP, ¿hay alguna manera de transformar un programa OOP en uno funcional, para que pueda obtener alguna idea?
Michael Shaw

Respuestas:

31

Definición de programación funcional

La introducción a La alegría de Clojure dice lo siguiente:

La programación funcional es uno de esos términos informáticos que tiene una definición amorfa. Si le pide a 100 programadores su definición, probablemente recibirá 100 respuestas diferentes ...

La programación funcional concierne y facilita la aplicación y composición de funciones ... Para que un lenguaje se considere funcional, su noción de función debe ser de primera clase. Las funciones de primera clase se pueden almacenar, pasar y devolver como cualquier otro dato. Más allá de este concepto central, [las definiciones de PF pueden incluir] pureza, inmutabilidad, recursividad, pereza y transparencia referencial.

Programación en Scala 2nd Edition p. 10 tiene la siguiente definición:

La programación funcional se guía por dos ideas principales. La primera idea es que las funciones son valores de primera clase ... Puede pasar funciones como argumentos a otras funciones, devolverlas como resultados de funciones o almacenarlas en variables ...

La segunda idea principal de la programación funcional es que las operaciones de un programa deben mapear los valores de entrada a los valores de salida en lugar de cambiar los datos en su lugar.

Si aceptamos la primera definición, entonces lo único que debe hacer para que su código sea "funcional" es cambiar sus bucles al revés. La segunda definición incluye la inmutabilidad.

Funciones de primera clase

Imagine que actualmente obtiene una Lista de Pasajeros de su objeto de Autobús e itera sobre ella disminuyendo la cuenta bancaria de cada pasajero por el monto de la tarifa del autobús. La forma funcional de realizar esta misma acción sería tener un método en Bus, quizás llamado forEachPassenger que tome la función de un argumento. Luego, el Autobús iteraría sobre sus pasajeros, sin embargo, eso se logra mejor y su código de cliente que cobra la tarifa del viaje se pondría en una función y se pasaría a cada Pasajero. Voila! Estás usando programación funcional.

Imperativo:

for (Passenger p : Bus.getPassengers()) {
    p.debit(fare);
}

Funcional (usando una función anónima o "lambda" en Scala):

myBus = myBus.forEachPassenger(p:Passenger -> { p.debit(fare) })

Versión Scala más azucarada:

myBus = myBus.forEachPassenger(_.debit(fare))

Funciones no de primera clase

Si su idioma no admite funciones de primera clase, esto puede ponerse muy feo. En Java 7 o anterior, debe proporcionar una interfaz de "Objeto funcional" como esta:

// Java 8 has java.util.function.Consumer, but in earlier
// versions you have to roll your own:
public interface Consumer<T> {
    public void accept(T t);
}

Luego, la clase Bus proporciona un iterador interno:

public void forEachPassenger(Consumer<Passenger> c) {
    for (Passenger p : passengers) {
        c.accept(p);
    }
}

Finalmente, pasa un objeto de función anónimo al Bus:

// Java 8 has syntactic sugar to make this look more like
// the Scala solution, but earlier versions require manually
// instantiating a "Function Object," in this case, a
// Consumer:
Bus.forEachPassenger(new Consumer<Passenger>() {
    @Override
    public void accept(final Passenger p) {
        p.debit(fare);
    }
}

Java 8 permite que las variables locales sean capturadas en el alcance de una función anónima, pero en versiones anteriores, cualquier varibales debe declararse final. Para evitar esto, es posible que deba crear una clase de contenedor MutableReference. Aquí hay una clase específica de entero que le permite agregar un contador de bucle al código anterior:

public static class MutableIntWrapper {
    private int i;
    private MutableIntWrapper(int in) { i = in; }
    public static MutableIntWrapper ofZero() {
        return new MutableIntWrapper(0);
    }
    public int value() { return i; }
    public void increment() { i++; }
}

final MutableIntWrapper count = MutableIntWrapper.ofZero();
Bus.forEachPassenger(new Consumer<Passenger>() {
    @Override
    public void accept(final Passenger p) {
        p.debit(fare);
        count.increment();
    }
}

System.out.println(count.value());

Incluso con esta fealdad, a veces es beneficioso eliminar la lógica complicada y repetida de los bucles repartidos por todo el programa al proporcionar un iterador interno.

Esta fealdad se ha corregido en Java 8, pero el manejo de excepciones comprobadas dentro de una función de primera clase sigue siendo realmente feo y Java aún asume el supuesto de mutabilidad en todas sus colecciones. Lo que nos lleva a los otros objetivos a menudo asociados con FP:

Inmutabilidad

El artículo 13 de Josh Bloch es "Prefiere la inmutabilidad". A pesar de que la basura común habla de lo contrario, la POO se puede hacer con objetos inmutables, y hacerlo lo hace mucho mejor. Por ejemplo, String en Java es inmutable. StringBuffer, OTOH necesita ser mutable para construir una cadena inmutable. Algunas tareas, como trabajar con buffers requieren inherentemente mutabilidad.

Pureza

Cada función debe ser al menos memorable: si le da los mismos parámetros de entrada (y no debe tener ninguna entrada además de sus argumentos reales), debe producir la misma salida cada vez sin causar "efectos secundarios" como cambiar el estado global, realizando I / O, o lanzando excepciones.

Se ha dicho que en la Programación Funcional, "generalmente se requiere algo de maldad para realizar el trabajo". 100% de pureza generalmente no es el objetivo. Minimizar los efectos secundarios es.

Conclusión

Realmente, de todas las ideas anteriores, la inmutabilidad ha sido la mayor victoria individual en términos de aplicaciones prácticas para simplificar mi código, ya sea OOP o FP. Pasar funciones a iteradores es la segunda mayor victoria. La documentación de Java 8 Lambdas tiene la mejor explicación de por qué. La recursión es excelente para procesar árboles. La pereza te permite trabajar con infinitas colecciones.

Si le gusta la JVM, le recomiendo que eche un vistazo a Scala y Clojure. Ambas son interpretaciones perspicaces de la programación funcional. Scala es de tipo seguro con una sintaxis algo similar a C, aunque realmente tiene tanta sintaxis en común con Haskell como con C. Clojure no es de tipo seguro y es un Lisp. Recientemente publiqué una comparación de Java, Scala y Clojure con respecto a un problema de refactorización específico. La comparación de Logan Campbell con Game of Life incluye a Haskell y también escribió Clojure.

PD

Jimmy Hoffa señaló que mi clase de autobús es mutable. En lugar de arreglar el original, creo que esto demostrará exactamente el tipo de refactorización de esta pregunta. Esto se puede solucionar haciendo que cada método en Bus sea una fábrica para producir un nuevo Bus, cada método en Passenger una fábrica para producir un nuevo Pasajero. Por lo tanto, he agregado un tipo de retorno a todo lo que significa que copiaré la función java.util.function.Function de Java 8 en lugar de la interfaz de consumidor:

public interface Function<T,R> {
    public R apply(T t);
    // Note: I'm leaving out Java 8's compose() method here for simplicity
}

Luego en autobús:

public Bus mapPassengers(Function<Passenger,Passenger> c) {
    // I have to use a mutable collection internally because Java
    // does not have immutable collections that return modified copies
    // of themselves the way the Clojure and Scala collections do.
    List<Passenger> newPassengers = new ArrayList(passengers.size());
    for (Passenger p : passengers) {
        newPassengers.add(c.apply(p));
    }
    return Bus.of(driver, Collections.unmodifiableList(passengers));
}

Finalmente, el objeto de función anónimo devuelve el estado modificado de las cosas (un nuevo autobús con nuevos pasajeros). Esto supone que p.debit () ahora devuelve un nuevo Pasajero inmutable con menos dinero que el original:

Bus b = b.mapPassengers(new Function<Passenger,Passenger>() {
    @Override
    public Passenger apply(final Passenger p) {
        return p.debit(fare);
    }
}

Con suerte, ahora puede tomar su propia decisión sobre qué tan funcional desea hacer su lenguaje imperativo y decidir si sería mejor rediseñar su proyecto utilizando un lenguaje funcional. En Scala o Clojure, las colecciones y otras API están diseñadas para facilitar la programación funcional. Ambos tienen muy buena interoperabilidad de Java, por lo que puede mezclar y combinar idiomas. De hecho, para la interoperabilidad de Java, Scala compila sus funciones de primera clase en clases anónimas que son casi compatibles con las interfaces funcionales Java 8. Puede leer sobre los detalles en la sección Scala in Depth. 1.3.2 .

GlenPeterson
fuente
Aprecio el esfuerzo, la organización y la comunicación clara en esta respuesta; pero tengo que tener un pequeño problema con algunos de los aspectos técnicos. Una de las claves como se menciona cerca de la parte superior es la composición de funciones, esto se remonta a por qué encapsular en gran medida las funciones dentro de los objetos no tiene un propósito: si una función está dentro de un objeto, debe estar allí para actuar sobre ese objeto; y si actúa sobre ese objeto, debe estar cambiando sus componentes internos. Ahora perdonaré que no todos requieran transparencia referencial o inmutabilidad, pero si cambia el objeto en su lugar, ya no necesita devolverlo
Jimmy Hoffa
Y tan pronto como una función no devuelve un valor, de repente la función no se puede componer con otras, y pierde toda la abstracción de la composición funcional. Podría hacer que la función cambie el objeto en su lugar y luego devuelva el objeto, pero si está haciendo esto, ¿por qué no hacer que la función tome el objeto como parámetro y lo libere de los límites de su objeto padre? Liberado del objeto padre, también podrá trabajar en otros tipos, que es otra parte importante de FP que te falta: abstracción de tipo. su forEachPasenger solo funciona contra pasajeros ...
Jimmy Hoffa
1
La razón por la que abstrae cosas para mapear y reducir, y estas funciones no están vinculadas a contener objetos, es para que puedan usarse en una miríada de tipos a través del polimorfismo paramétrico. Es la conflagración de estas variadas abstracciones que no encuentras en los lenguajes OOP lo que realmente define FP y lo impulsa a tener valor. No es que la pereza, la transparencia referencial, la inmutabilidad o incluso el sistema de tipos HM sean necesarios para crear FP, son cosas más bien efectos secundarios de la creación de lenguajes destinados a la composición funcional donde las funciones pueden abstraer sobre tipos en general
Jimmy Hoffa
@JimmyHoffa Hiciste una crítica muy justa de mi ejemplo. La interfaz de Java8 Consumer me sedujo a la mutabilidad. Además, la definición chouser / fogus de FP no incluía la inmutabilidad y más tarde agregué la definición Odersky / Spoon / Venners. Dejé el ejemplo original, pero agregué una nueva versión inmutable en la sección "PS" en la parte inferior. Es feo Pero creo que demuestra funciones que actúan sobre los objetos para producir nuevos objetos en lugar de cambiar las partes internas de los originales. Gran comentario!
GlenPeterson
1
Esta conversación continúa en The Whiteboard: chat.stackexchange.com/transcript/message/11702383#11702383
GlenPeterson el
12

Tengo experiencia personal "logrando" esto. Al final, no se me ocurrió algo que sea puramente funcional, pero se me ocurrió algo con lo que estoy feliz. Así es como lo hice:

  • Convierta todo el estado externo en un parámetro de la función. Por ejemplo: si el método de un objeto se modifica x, hágalo para que se pase el método en xlugar de llamar this.x.
  • Eliminar el comportamiento de los objetos.
    1. Hacer que los datos del objeto sean públicamente accesibles
    2. Convierta todos los métodos en funciones que el objeto llama.
    3. Haga que el código del cliente que llama al objeto llame a la nueva función pasando los datos del objeto. EG: Convertir x.methodThatModifiesTheFooVar()enfooFn(x.foo)
    4. Eliminar el método original del objeto
  • Vuelva a colocar la mayor cantidad de bucles iterativos que pueda con mayor orden como funciones map, reduce, filter, etc.

No pude deshacerme del estado mutable. Era demasiado poco idiomático en mi lenguaje (JavaScript). Pero, al hacer que todo el estado pase y / o regrese, todas las funciones son posibles de probar. Esto es diferente de OOP, donde configurar el estado llevaría demasiado tiempo o separar dependencias a menudo requiere modificar primero el código de producción.

Además, podría estar equivocado acerca de la definición, pero creo que mis funciones son referencialmente transparentes: mis funciones tendrán el mismo efecto con la misma entrada.

Editar

Como puede ver aquí , no es posible crear un objeto verdaderamente inmutable en JavaScript. Si eres diligente y controlas quién llama tu código, puedes hacerlo creando siempre un nuevo objeto en lugar de mutar el actual. No valió la pena el esfuerzo para mí.

Pero si está utilizando Java , puede usar estas técnicas para hacer que sus clases sean inmutables.

Daniel Kaplan
fuente
+1 Dependiendo de qué es exactamente lo que está tratando de hacer, esto es probablemente lo más lejos que puede llegar sin hacer cambios de diseño que irían mucho más allá de la simple "refactorización".
Evicatos
@Evicatos: No sé, si JavaScript tuviera un mejor soporte para el estado inmutable, creo que mi solución sería tan funcional como lo sería en un lenguaje funcional dinámico como Clojure. ¿Cuál es un ejemplo de algo que requeriría algo más que simplemente refactorizar?
Daniel Kaplan
Creo que deshacerse del estado mutable calificaría. No creo que sea solo una cuestión de mejor soporte en el lenguaje, creo que pasar de mutable a inmutable básicamente siempre requerirá cambios arquitectónicos fundamentales que esencialmente constituyen una reescritura. Ymmv dependiendo de tu definición de refactorización.
Evicatos
@Evicatos ver mi edición
Daniel Kaplan
1
@tieTYT sí, es triste que JS sea tan mutable, pero al menos Clojure puede compilar a JavaScript: github.com/clojure/clojurescript
GlenPeterson
3

No creo que sea realmente posible refactorizar el programa por completo; tendrías que rediseñar y reimplementar en el paradigma correcto.

He visto la refactorización de código definida como una "técnica disciplinada para reestructurar un cuerpo de código existente, alterando su estructura interna sin cambiar su comportamiento externo".

Podría hacer que ciertas cosas sean más funcionales, pero en esencia todavía tiene un programa orientado a objetos. No puede simplemente cambiar pequeños fragmentos para adaptarlo a un paradigma diferente.

Uri
fuente
Añadiría que una buena primera marca es luchar por la transparencia referencial. Una vez que tenga esto, obtendrá ~ 50% de los beneficios de la programación funcional.
Daniel Gratzer
3

Creo que esta serie de artículos es exactamente lo que quieres:

Retrojuegos puramente funcionales

http://prog21.dadgum.com/23.html Parte 1

http://prog21.dadgum.com/24.html Parte 2

http://prog21.dadgum.com/25.html Parte 3

http://prog21.dadgum.com/26.html Parte 4

http://prog21.dadgum.com/37.html Seguimiento

El resumen es:

El autor sugiere un ciclo principal con efectos secundarios (los efectos secundarios deben ocurrir en algún lugar, ¿verdad?) Y la mayoría de las funciones devuelven pequeños registros inmutables que detallan cómo cambiaron el estado del juego.

Por supuesto, al escribir un programa del mundo real, mezclará y combinará varios estilos de programación, utilizando cada uno donde sea más útil. Sin embargo, es una buena experiencia de aprendizaje intentar escribir un programa de la manera más funcional / inmutable y también escribirlo de la manera más espagueti, utilizando solo variables globales :-) (hágalo como un experimento, no en producción, por favor)

marcus
fuente
2

Probablemente tenga que cambiar todo su código al revés ya que OOP y FP tienen dos enfoques opuestos para organizar el código.

OOP organiza el código alrededor de los tipos (clases): diferentes clases pueden implementar la misma operación (un método con la misma firma). Como resultado, OOP es más apropiado cuando el conjunto de operaciones no cambia mucho, mientras que se pueden agregar nuevos tipos muy a menudo. Por ejemplo, considere una biblioteca GUI en el que cada widget tiene un conjunto fijo de métodos ( hide(), show(), paint(), move(), y así sucesivamente), pero nuevos widgets podrían añadirse como la biblioteca se extiende. En OOP es fácil agregar un nuevo tipo (para una interfaz dada): solo necesita agregar una nueva clase e implementar todos sus métodos (cambio de código local). Por otro lado, agregar una nueva operación (método) a una interfaz puede requerir cambiar todas las clases que implementan esa interfaz (aunque la herencia puede reducir la cantidad de trabajo).

FP organiza el código en torno a operaciones (funciones): cada función implementa alguna operación que puede tratar diferentes tipos de diferentes maneras. Esto generalmente se logra enviando el tipo a través de la coincidencia de patrones o algún otro mecanismo. Como consecuencia, FP es más apropiado cuando el conjunto de tipos es estable y se agregan nuevas operaciones con mayor frecuencia. Tomemos, por ejemplo, un conjunto fijo de formatos de imagen (GIF, JPEG, etc.) y algunos algoritmos que desea implementar. Cada algoritmo puede implementarse mediante una función que se comporta de manera diferente según el tipo de imagen. Agregar un nuevo algoritmo es fácil porque solo necesita implementar una nueva función (cambio de código local). Agregar un nuevo formato (tipo) requiere modificar todas las funciones que ha implementado hasta ahora para admitirlo (cambio no local).

En pocas palabras: OOP y FP son fundamentalmente diferentes en la forma en que organizan el código, y cambiar un diseño de OOP en un diseño de FP implicaría cambiar todo su código para reflejar esto. Sin embargo, puede ser un ejercicio interesante. Véanse también estas notas de lectura del libro SICP citadas por mikemay, en particular las diapositivas 13.1.5 a 13.1.10.

Giorgio
fuente