Soy tu desarrollador de programación orientada a objetos clásico. Sin embargo, desde que descubrí los lenguajes de programación puramente funcionales, siempre me ha intrigado el por qué , ya que la programación orientada a objetos parecía resolver la mayoría de los casos comerciales de una manera razonable.
Ahora he llegado al punto en mi experiencia de desarrollo de software donde estoy buscando lenguajes más concisos y expresivos. Por lo general, escribo mi software en C#, pero para mi último proyecto decidí dar el salto y crear un servicio empresarial con F#. Al hacerlo, me resulta muy difícil entender cómo se realiza el desacoplamiento con un enfoque puramente funcional.
El caso es este. Tengo una fuente de datos, que es WooCommerce, pero no quiero vincular las definiciones de mis funciones a esa fuente de datos específica.
En C# me parece evidente que quiero un servicio que se parezca a esto
public record Category(string Name); public interface ICategoryService { Task<IEnumerable<Category>> GetAllAsync(); } // With a definition for the service that specifies WooCommerce public class WcCategoryService : ICategoryService { private readonly WCRestEndpoint wcRest; // WooCommerce specific dependencies public WcCategoryService(WCRestEndpoint wcRest) { this.wcRest = wcRest; } public Task<IEnumerable<Category>> GetAllAsync() { // Call woocommerce REST and map the category to our domain category } }Ahora, en el futuro, si decido que necesitamos una nueva tienda para proporcionar categorías, puedo definir una nueva implementación para ese servicio específico, reemplazar el tipo inyectado y no estropear los dependientes debido a este cambio.
Tratando de entender cómo se resuelve el enfoque de dependencia funcional, me encontré con este caso (leyendo "Modelado de dominio hecho funcional") donde la firma de tipo define directamente las dependencias, por lo que el equivalente de C# anterior se convertiría en una definición altamente acoplada
type Category = { Name: string } type GetCategories = WCRestEndpoint -> Category listDe repente, si debo cambiar la fuente de las categorías, tendría que cambiar la firma funcional o proporcionar una nueva definición para usar que se propagaría a través de la aplicación y, por lo tanto, no sería muy sólida.
Lo que tengo curiosidad es si estoy malinterpretando algo fundamental.
Con mi cerebro OOP todo lo que puedo pensar en hacer es algo como esto
type Category = { Name: string } // No longer directly dependent on WCRestEndpoint type GetCategories = unit -> Category list // But the definition would require scoped inclusion of the dependency // Also how does the configuration get passed in without having the core library be dependent on the Environment or a config in the assembly? let rest = WCRestEndpoint(/* Config... */) type getCategories: GetCategories = fun () -> let wcCategories = rest.GetCategories() // Convert the result into a Category typeHe buscado y no he encontrado ninguna explicación sobre cómo se maneja el cambio con un enfoque puramente funcional, que es lo que me llevó a creer que hay algo fundamental que he entendido mal.
¿Cómo expone una API funcional sin vincular las firmas de tipo de función en tipos específicos de implementación? ¿Estoy pensando en esto mal?
Primero, como se ha señalado @Karl Bielefeldt, el tipo correcto para devolver aquí es Async<seq<Category>> . Entonces, su función originalmente debería ser del tipo WCRestEndpoint -> Async<seq<Category>> .
Pero ese no es el problema real aquí. El problema real es esta afirmación:
De repente, si debo cambiar la fuente de las categorías, tendría que cambiar la firma funcional o proporcionar una nueva definición para usar que se propagaría a través de la aplicación y, por lo tanto, no sería muy sólida.
Esta afirmación no tiene ningún sentido para mí porque la refactorización es más sencilla en el caso de F#.
No importa cómo codifique esto, siempre necesitará escribir código que tome un WCRestEndpoint y genere una secuencia de Category s. Si decide que en realidad va a obtener la secuencia de Category de alguna otra manera, entonces necesitará escribir un nuevo código para hacer esto sin importar qué.
Por ejemplo, supongamos que decido que necesito modificar mi código para obtener categorías de un OtherCatGetter en lugar de un WCRestEndpoint . En su código C#, necesitaría reemplazar
public class WcCategoryService : ICategoryService { private readonly WCRestEndpoint wcRest; // WooCommerce specific dependencies public WcCategoryService(WCRestEndpoint wcRest) { this.wcRest = wcRest; } public Task<IEnumerable<Category>> GetAllAsync() { // Call woocommerce REST and map the category to our domain category } }con
public class OtherCategoryService : ICategoryService { private readonly OtherCatGetter getter; // WooCommerce specific dependencies public WcCategoryService(OtherCatGetter getter) { this.getter = getter; } public Task<IEnumerable<Category>> GetAllAsync() { // Do something with getter to get the categories } } También tendríamos que reemplazar cada llamada a new WcCategoryService(wcRest) con una llamada a new OtherCategoryService(getter) .
En el lado de F# , tendríamos que reemplazar
let getCategoriesFromWC (wcRest: WCRestEndpoint) = ... // get categories from wcRestcon
let getCategoriesFromOther (getter: OtherCatGetter) = ... // get categories from getter y reemplace cada ocurrencia de GetCategoriesFromWC wcRest con una ocurrencia de getCategoriesFromOther getter .
Claramente, es la versión de F# la que requiere menos refactorización cuando necesita cambiar la forma en que obtiene sus Category , ya que F# no tiene que lidiar con el estándar de definir una nueva clase pública con un solo campo de solo lectura y un constructor de un argumento. Si necesita definir una forma diferente de obtener una secuencia de Category , simplemente hágalo en lugar de saltar a través de aros innecesarios.
No creo que haya una sola respuesta correcta para esto, pero aquí hay un par de puntos a considerar.
Primero, creo que el código funcional del mundo real a menudo tiene una "estructura de sándwich" con un manejo de entrada, seguido de una transformación puramente funcional y un manejo de salida. Las partes de E/S en F# a menudo implican una interfaz con bibliotecas imperativas y OO .NET. Por lo tanto, la lección clave es mantener las E/S en el exterior y mantener el manejo funcional central separado de eso. En otras palabras, tiene mucho sentido usar algún código OO imperativo en el exterior para el manejo de entrada.
En segundo lugar, creo que la idea de desacoplar es algo que es más valioso en el código OO donde esperas tener interfaces complejas con lógica entrelazada. En el código funcional, esto es (creo) mucho menos preocupante. En otras palabras, creo que es perfectamente razonable no preocuparse por esto para la E/S, porque es solo el lado exterior de la "estructura sándwich". Si necesita cambiarlo, puede cambiarlo sin tocar la lógica de transformación funcional central (que puede probar independientemente de la E/S).
En tercer lugar, desde el punto de vista práctico, es perfectamente razonable usar interfaces en F#. Si realmente quisiera hacer el desacoplamiento, podría simplemente definir una interfaz:
type Category { Name: string } type CategoryService = abstract GetAllAsync : unit -> Async<seq<Category>>Y luego puede implementar la interfaz usando expresiones de objeto:
let myCategoryService = { new CategoryService with member x.GetAllAsync() = async { ... } } Entonces, tendría una función principal que transforma el seq<Category> en cualquier resultado que desee y esto no necesitaría tomar CategoryService como argumento. Sin embargo, en su código principal, tomaría esto como argumento (o lo inicializaría en algún lugar cuando se inicie el programa), usaría el servicio para obtener los datos y llamar a su lógica de transformación principal.
Luché con esta pregunta durante años antes de darme cuenta de que la estaba viendo de manera incorrecta. Viniendo del desarrollo orientado a objetos y la inyección de dependencia, seguí buscando una alternativa funcional a la inyección de dependencia. Finalmente me di cuenta de que la inyección de dependencia hace que todo sea impuro , lo que significa que no puedes usar ese enfoque ( ni siquiera una aplicación parcial ) si quieres aplicar una arquitectura funcional .
La pista falsa es centrarse en las dependencias. En su lugar, concéntrese en escribir una función pura. Todavía puede usar el principio de inversión de dependencia , pero en lugar de centrarse en acciones e interacciones, céntrese en los datos . Si una función requiere algunos datos, páselos como argumento. Si una función tiene que tomar una decisión, devuélvela como una estructura de datos .
No proporciona ningún ejemplo de dónde le gustaría usar una lista de valores de Category , pero una función que depende de dichos datos tendría un tipo como este:
Category list -> 'a Tal función está completamente desvinculada de la fuente de las categorías. Solo depende del tipo de Category en sí, que es parte del Modelo de Dominio.
En última instancia, deberá obtener las categorías de algún lugar, pero este trabajo lo lleva al límite del sistema, por ejemplo, Main :
let Main () = let categories = getCategories () let result = myFunction categories resultPor lo tanto, si cambia de opinión acerca de cómo obtener las categorías, solo tiene que cambiar una línea de código. Este tipo de arquitectura es similar a un sándwich , con acciones impuras que rodean el corazón puro de la aplicación. También se conoce como núcleo funcional, shell imperativo .
Si todo lo que quieres es no usar objetos, es una reescritura bastante mecánica.
Una interfaz de método único es solo una firma de función con nombre, por lo que esto:
public interface ICategoryService { Task<IEnumerable<Category>> GetAllAsync(); } async Task UseCategoriesToDoSomething(ICategoryService service) { var categories = await service.GetAllAsync(); ... }se convierte en:
let useCategoriesToDoSomething(getAllAsync: unit -> Async<seq<Category>>) = async { let! categories = getAllAsync() ... }Su raíz de composición se convierte en una cuestión de aplicar funciones parcialmente con las implementaciones concretas de estos argumentos de función.
Dicho esto, no hay nada de malo en usar objetos como tales; F# rechaza principalmente la mutabilidad y la herencia, pero adopta interfaces, notación de puntos, etc.
Hay una gran diapositiva sobre OO en F # de una charla que dio Don Syme : 
Necesita una lista de categorías calculada de forma asíncrona. Ya hay un tipo para eso: Async<seq<Category>> . Tal vez esto fue creado por una función usando un WCRestEndpoint . Tal vez esto se creó con algunos valores ficticios constantes en una prueba unitaria. Tal vez esto fue creado para una prueba unitaria y siempre genera un error. Al código consumidor no le importa. Solo importa que haya una manera de obtener las categorías.
Este tipo es mucho más reutilizable que un tipo ICategoryService específico de la aplicación. Por ejemplo, tal vez tenga una función que tome Async<'a> y maneje los errores de forma estándar. Tal vez tenga una función que tome un Async<seq<'a>> y valide que la lista no esté vacía.
Honestamente, no necesita un nombre especial solo para la cosa que busca un tipo de cosa.
¡Felicitaciones por haber tomado la muy buena decisión de probar F#!
Para responder a su pregunta de otra manera:
@Asik ya mencionó el uso de una función en lugar de una interfaz de método único. Esta idea puede muy bien extenderse a tener registros que agrupen un montón de funciones relacionadas; por ejemplo:
type MyEntityRepository = { Fetch: Guid -> Async<MyEntity> Add: MyEntity -> Async<unit> Delete: Guid -> Async<unit> } Siempre es posible usar interfaces también, pero me gusta más este método porque es más fácil de simular (simplemente asigne Unchecked.defaultof<_> a cualquier campo que el código de prueba no usará) y porque la sintaxis se ve mucho mejor, entre otros cosas.
Si necesita dependencias anidadas (que seguramente las necesitará), simplemente puede usar cierres:
let createRepository (connection: IDbConnection) = { Add = fun entity -> connection.Execute(...) Fetch = fun id -> connection.Query(...) }Básicamente, proporciona las dependencias a una función de fábrica y luego las dependencias se capturarán en los cierres de las lambdas, lo que le permitirá anidar las dependencias tan profundamente como sea necesario. Este patrón de uso de funciones de fábrica también funciona bastante bien con el contenedor DI integrado de ASP.Net.