El mejor enfoque para diseñar bibliotecas de F # para su uso desde F # y C #

113

Estoy tratando de diseñar una biblioteca en F #. La biblioteca debe ser amigable para su uso tanto en F # como en C # .

Y aquí es donde estoy un poco atrapado. Puedo hacerlo compatible con F #, o puedo hacerlo compatible con C #, pero el problema es cómo hacerlo compatible con ambos.

Aquí hay un ejemplo. Imagina que tengo la siguiente función en F #:

let compose (f: 'T -> 'TResult) (a : 'TResult -> unit) = f >> a

Esto es perfectamente utilizable desde F #:

let useComposeInFsharp() =
    let composite = compose (fun item -> item.ToString) (fun item -> printfn "%A" item)
    composite "foo"
    composite "bar"

En C #, la composefunción tiene la siguiente firma:

FSharpFunc<T, Unit> compose<T, TResult>(FSharpFunc<T, TResult> f, FSharpFunc<TResult, Unit> a);

Pero claro, no quiero FSharpFuncen la firma, lo que quiero es Funcy en Actioncambio, así:

Action<T> compose2<T, TResult>(Func<T, TResult> f, Action<TResult> a);

Para lograr esto, puedo crear una compose2función como esta:

let compose2 (f: Func<'T, 'TResult>) (a : Action<'TResult> ) = 
    new Action<'T>(f.Invoke >> a.Invoke)

Ahora, esto es perfectamente utilizable en C #:

void UseCompose2FromCs()
{
    compose2((string s) => s.ToUpper(), Console.WriteLine);
}

¡Pero ahora tenemos un problema al usar compose2desde F #! Ahora tengo que envolver todos los F # estándar funsen Funcy Action, así:

let useCompose2InFsharp() =
    let f = Func<_,_>(fun item -> item.ToString())
    let a = Action<_>(fun item -> printfn "%A" item)
    let composite2 = compose2 f a

    composite2.Invoke "foo"
    composite2.Invoke "bar"

La pregunta: ¿Cómo podemos lograr una experiencia de primera clase para la biblioteca escrita en F # para los usuarios de F # y C #?

Hasta ahora, no pude encontrar nada mejor que estos dos enfoques:

  1. Dos ensamblados separados: uno dirigido a usuarios de F # y el segundo a usuarios de C #.
  2. Un ensamblado pero diferentes espacios de nombres: uno para usuarios de F # y el segundo para usuarios de C #.

Para el primer enfoque, haría algo como esto:

  1. Cree un proyecto de F #, llámelo FooBarFs y compílelo en FooBarFs.dll.

    • Apunte la biblioteca exclusivamente a usuarios de F #.
    • Oculte todo lo innecesario de los archivos .fsi.
  2. Cree otro proyecto de F #, llame si FooBarCs y compílelo en FooFar.dll

    • Reutilice el primer proyecto de F # en el nivel de origen.
    • Cree un archivo .fsi que oculta todo de ese proyecto.
    • Cree un archivo .fsi que exponga la biblioteca en forma C #, utilizando modismos C # para el nombre, espacios de nombres, etc.
    • Cree contenedores que deleguen a la biblioteca central, realizando la conversión cuando sea necesario.

Creo que el segundo enfoque con los espacios de nombres puede ser confuso para los usuarios, pero luego tiene un ensamblado.

La pregunta: Ninguno de estos es ideal, tal vez me falta algún tipo de indicador / conmutador / atributo del compilador o algún tipo de truco y ¿hay una mejor manera de hacerlo?

La pregunta: ¿ alguien más ha intentado lograr algo similar y, de ser así, cómo lo hizo?

EDITAR: para aclarar, la pregunta no es solo sobre funciones y delegados, sino también sobre la experiencia general de un usuario de C # con una biblioteca de F #. Esto incluye espacios de nombres, convenciones de nombres, modismos y similares que son nativos de C #. Básicamente, un usuario de C # no debería poder detectar que la biblioteca se creó en F #. Y viceversa, un usuario de F # debería tener ganas de lidiar con una biblioteca de C #.


EDITAR 2:

Puedo ver en las respuestas y comentarios hasta ahora que mi pregunta carece de la profundidad necesaria, quizás principalmente debido al uso de un solo ejemplo donde surgen problemas de interoperabilidad entre F # y C #, el problema de los valores de función. Creo que este es el ejemplo más obvio y, por lo tanto, esto me llevó a usarlo para hacer la pregunta, pero de la misma manera me dio la impresión de que este es el único tema que me preocupa.

Permítanme brindar ejemplos más concretos. He leído el documento más excelente de Pautas de diseño de componentes de F # (¡muchas gracias @gradbot por esto!). Las pautas del documento, si se utilizan, abordan algunos de los problemas, pero no todos.

El documento se divide en dos partes principales: 1) pautas para dirigirse a los usuarios de F #; y 2) pautas para dirigirse a usuarios de C #. En ninguna parte intenta siquiera fingir que es posible tener un enfoque uniforme, lo que se hace eco exactamente de mi pregunta: podemos apuntar a F #, podemos apuntar a C #, pero ¿cuál es la solución práctica para apuntar a ambos?

Para recordar, el objetivo es tener una biblioteca creada en F #, y que se pueda usar idiomáticamente en los lenguajes F # y C #.

La palabra clave aquí es idiomática . El problema no es la interoperabilidad general donde es posible utilizar bibliotecas en diferentes idiomas.

Ahora a los ejemplos, que tomo directamente de F # Component Design Guidelines .

  1. Módulos + funciones (F #) vs espacios de nombres + tipos + funciones

    • F #: use espacios de nombres o módulos para contener sus tipos y módulos. El uso idiomático es colocar funciones en módulos, por ejemplo:

      // library
      module Foo
      let bar() = ...
      let zoo() = ...
      
      
      // Use from F#
      open Foo
      bar()
      zoo()
    • C #: use espacios de nombres, tipos y miembros como la estructura organizativa principal para sus componentes (a diferencia de los módulos), para las API vanilla .NET.

      Esto es incompatible con la guía de F #, y el ejemplo debería reescribirse para adaptarse a los usuarios de C #:

      [<AbstractClass; Sealed>]
      type Foo =
          static member bar() = ...
          static member zoo() = ...

      Sin embargo, al hacerlo, rompemos el uso idiomático de F # porque ya no podemos usar bary zoosin prefijarlo con Foo.

  2. Uso de tuplas

    • F #: use tuplas cuando sea apropiado para los valores de retorno.

    • C #: Evite el uso de tuplas como valores de retorno en las API de .NET vanilla.

  3. Async

    • F #: utilice Async para la programación asincrónica en los límites de la API de F #.

    • C #: exponga las operaciones asincrónicas utilizando el modelo de programación asincrónica de .NET (BeginFoo, EndFoo) o como métodos que devuelven tareas de .NET (Task), en lugar de como objetos F # Async.

  4. Uso de Option

    • F #: considere usar valores de opción para los tipos de retorno en lugar de generar excepciones (para el código con orientación F #).

    • Considere usar el patrón TryGetValue en lugar de devolver valores de opción de F # (opción) en las API vanilla .NET, y prefiera la sobrecarga de métodos en lugar de tomar valores de opción de F # como argumentos.

  5. Sindicatos discriminados

    • F #: use uniones discriminadas como una alternativa a las jerarquías de clases para crear datos estructurados en árbol

    • C #: no hay pautas específicas para esto, pero el concepto de uniones discriminadas es ajeno a C #

  6. Funciones al curry

    • F #: las funciones al curry son idiomáticas para F #

    • C #: no utilice la conversión de parámetros en las API de .NET vanilla.

  7. Comprobando valores nulos

    • F #: esto no es idiomático para F #

    • C #: considere la posibilidad de verificar valores nulos en los límites de la API de .NET estándar.

  8. El uso de tipos F # list, map, set, etc.

    • F #: es idiomático usar estos en F #

    • C #: considere usar los tipos de interfaz de colección .NET IEnumerable e IDictionary para parámetros y valores de retorno en las API vanilla .NET. ( Es decir, no utilizan F # list, map,set )

  9. Tipos de funciones (la obvia)

    • F #: el uso de funciones de F # como valores es idiomático para F #, obviamente

    • C #: utilice los tipos de delegado .NET en lugar de los tipos de función F # en las API de .NET vanilla.

Creo que esto debería ser suficiente para demostrar la naturaleza de mi pregunta.

Por cierto, las pautas también tienen una respuesta parcial:

... una estrategia de implementación común al desarrollar métodos de orden superior para bibliotecas .NET vanilla es crear toda la implementación usando tipos de función F #, y luego crear la API pública usando delegados como una fachada delgada sobre la implementación real de F #.

Resumir.

Hay una respuesta definitiva: no hay trucos del compilador que me haya perdido .

De acuerdo con el documento de directrices, parece que la creación de F # primero y luego la creación de un contenedor de fachada para .NET es una estrategia razonable.

La pregunta entonces permanece con respecto a la implementación práctica de esto:

  • ¿Asambleas separadas? o

  • ¿Diferentes espacios de nombres?

Si mi interpretación es correcta, Tomas sugiere que usar espacios de nombres separados debería ser suficiente y debería ser una solución aceptable.

Creo que estaré de acuerdo con eso dado que la elección de los espacios de nombres es tal que no sorprende ni confunde a los usuarios de .NET / C #, lo que significa que el espacio de nombres para ellos probablemente debería verse como el espacio de nombres principal para ellos. Los usuarios de F # tendrán que asumir la carga de elegir un espacio de nombres específico de F #. Por ejemplo:

  • FSharp.Foo.Bar -> espacio de nombres para F # frente a la biblioteca

  • Foo.Bar -> espacio de nombres para el contenedor .NET, idiomático para C #

Philip P.
fuente
Aparte de transmitir funciones de primera clase, ¿cuáles son sus preocupaciones? F # ya contribuye en gran medida a brindar una experiencia fluida en otros idiomas.
Daniel
Re: su edición, todavía no tengo claro por qué cree que una biblioteca creada en F # parecería no estándar de C #. En su mayor parte, F # proporciona un superconjunto de funciones de C #. Hacer que una biblioteca se sienta natural es principalmente una cuestión de convención, lo cual es cierto sin importar qué idioma esté usando. Todo eso para decir, F # proporciona todas las herramientas que necesita para hacer esto.
Daniel
Honestamente, aunque incluye una muestra de código, está haciendo una pregunta bastante abierta. Pregunta sobre "la experiencia general de un usuario de C # con una biblioteca de F #", pero el único ejemplo que da es algo que realmente no se admite bien en ningún lenguaje imperativo. Tratar las funciones como ciudadanos de primera clase es un principio bastante central de la programación funcional, que se asigna mal a la mayoría de los lenguajes imperativos.
Onorio Catenacci
2
@KomradeP .: con respecto a su EDIT 2, FSharpx proporciona algunos medios para hacer que la interoperabilidad sea más sencilla. Puede encontrar algunos consejos en esta excelente publicación de blog .
pad

Respuestas:

40

Daniel ya explicó cómo definir una versión compatible con C # de la función F # que escribiste, así que agregaré algunos comentarios de nivel superior. En primer lugar, debe leer las Pautas de diseño de componentes de F # (a las que ya hace referencia gradbot). Este es un documento que explica cómo diseñar bibliotecas de F # y .NET usando F # y debería responder muchas de sus preguntas.

Cuando usa F #, hay básicamente dos tipos de bibliotecas que puede escribir:

  • La biblioteca F # está diseñada para usarse solo desde F #, por lo que su interfaz pública está escrita en un estilo funcional (usando tipos de función F #, tuplas, uniones discriminadas, etc.)

  • La biblioteca .NET está diseñada para usarse desde cualquier lenguaje .NET (incluidos C # y F #) y normalmente sigue el estilo orientado a objetos .NET. Esto significa que expondrá la mayor parte de la funcionalidad como clases con método (y, a veces, métodos de extensión o métodos estáticos, pero principalmente el código debe escribirse en el diseño OO).

En su pregunta, está preguntando cómo exponer la composición de funciones como una biblioteca .NET, pero creo que funciones como la suya composeson conceptos de nivel demasiado bajo desde el punto de vista de la biblioteca .NET. Puede exponerlos como métodos que funcionan con FuncyAction , pero probablemente no sea así como diseñaría una biblioteca .NET normal en primer lugar (tal vez usaría el patrón Builder en su lugar o algo así).

En algunos casos (es decir, cuando se diseñan bibliotecas numéricas que no encajan realmente bien con el estilo de la biblioteca .NET), tiene sentido diseñar una biblioteca que combine los estilos F # y .NET en una sola biblioteca. La mejor manera de hacer esto es tener una API normal de F # (o .NET normal) y luego proporcionar envoltorios para uso natural en el otro estilo. Los contenedores pueden estar en un espacio de nombres separado (como MyLibrary.FSharpy MyLibrary).

En su ejemplo, podría dejar la implementación de F # MyLibrary.FSharpy luego agregar envoltorios .NET (compatibles con C #) (similares al código que publicó Daniel) en el MyLibraryespacio de nombres como método estático de alguna clase. Pero nuevamente, la biblioteca .NET probablemente tendría una API más específica que la composición de funciones.

Tomas Petricek
fuente
1
Las pautas de diseño de componentes de F # ahora están alojadas aquí
Jan Schiefer
19

Solo tiene que envolver los valores de las funciones ( funciones parcialmente aplicadas, etc.) con Funco Action, el resto se convierte automáticamente. Por ejemplo:

type A(arg) =
  member x.Invoke(f: Func<_,_>) = f.Invoke(arg)

let a = A(1)
a.Invoke(fun i -> i + 1)

Por lo tanto, tiene sentido usar Func/ Actiondonde corresponda. ¿Elimina esto sus preocupaciones? Creo que las soluciones que proponen son demasiado complicadas. Puede escribir toda su biblioteca en F # y usarla sin dolor desde F # y C # (lo hago todo el tiempo).

Además, F # es más flexible que C # en términos de interoperabilidad, por lo que generalmente es mejor seguir el estilo tradicional de .NET cuando esto es una preocupación.

EDITAR

El trabajo requerido para hacer dos interfaces públicas en espacios de nombres separados, creo, solo se justifica cuando son complementarias o la funcionalidad de F # no se puede usar desde C # (como las funciones en línea, que dependen de metadatos específicos de F #).

Tomando sus puntos a su vez:

  1. Los letenlaces de módulo + y los miembros estáticos de tipo sin constructor + aparecen exactamente iguales en C #, así que vaya con los módulos si puede. Puede utilizar CompiledNameAttributepara dar a los miembros nombres compatibles con C #.

  2. Puede que me equivoque, pero supongo que las Directrices de los componentes se redactaron antes de System.Tupleagregarse al marco. (En versiones anteriores, F # definía su propio tipo de tupla). Desde entonces, se ha vuelto más aceptable para usar Tupleen una interfaz pública para tipos triviales.

  3. Aquí es donde creo que debes hacer las cosas de la manera C # porque F # juega bien Taskpero C # no funciona bien Async. Puede usar async internamente y luego llamar Async.StartAsTaskantes de regresar de un método público.

  4. La adopción de nullpuede ser el mayor inconveniente al desarrollar una API para usar desde C #. En el pasado, probé todo tipo de trucos para evitar considerar nulo en el código interno de F # pero, al final, fue mejor marcar tipos con constructores públicos con [<AllowNullLiteral>]y verificar args para nulos. No es peor que C # a este respecto.

  5. Las uniones discriminadas generalmente se compilan en jerarquías de clases, pero siempre tienen una representación relativamente amigable en C #. Yo diría, márquelos con [<AllowNullLiteral>]y úselos.

  6. Las funciones curry producen valores de función , que no deberían usarse.

  7. Descubrí que era mejor adoptar null que depender de que se detectara en la interfaz pública e ignorarlo internamente. YMMV.

  8. Tiene mucho sentido usar list/ map/ setinternamente. Todos pueden exponerse a través de la interfaz pública como IEnumerable<_>. También, seq, dict, y Seq.readonlyson con frecuencia útiles.

  9. Ver # 6.

La estrategia que tome dependerá del tipo y tamaño de su biblioteca, pero, en mi experiencia, encontrar el punto óptimo entre F # y C # requirió menos trabajo, a largo plazo, que crear API independientes.

Daniel
fuente
sí, puedo ver que hay algunas formas de hacer que las cosas funcionen, no estoy del todo convencido. Re módulos. Si lo tiene, module Foo.Bar.Zooentonces en C #, esto estará class Fooen el espacio de nombres Foo.Bar. Sin embargo, si tiene módulos anidados como module Foo=... module Bar=... module Zoo==, esto generará una clase Foocon clases internas Bary Zoo. Re IEnumerable, esto puede no ser aceptable cuando realmente necesitamos trabajar con mapas y conjuntos. Revalores nully AllowNullLiteral- una de las razones por las que me gusta F # es porque estoy cansado de nullC #, ¡realmente no querría contaminar todo con él de nuevo!
Philip P.
En general, favorezca los espacios de nombres sobre los módulos anidados para la interoperabilidad. Re: null, estoy de acuerdo contigo, ciertamente es una regresión tener que considerarlo, pero algo con lo que tienes que lidiar si tu API se usará desde C #.
Daniel
2

Aunque probablemente sería una exageración, podría considerar escribir una aplicación usando Mono.Cecil (tiene un soporte increíble en la lista de correo) que automatizaría la conversión en el nivel de IL. Por ejemplo, si implementas tu ensamblado en F #, usando la API pública de estilo F #, la herramienta generará un contenedor compatible con C # sobre él.

Por ejemplo, en F # obviamente usaría option<'T>( None, específicamente) en lugar de usar nullcomo en C #. Escribir un generador de envoltura para este escenario debería ser bastante fácil: el método de envoltura invocaría el método original: si su valor de retorno era Some x, entonces retorna x, de lo contrario retorna null.

Debería manejar el caso cuando Tes un tipo de valor, es decir, no anulable; tendría que ajustar el valor de retorno del método contenedor Nullable<T>, lo que lo hace un poco doloroso.

Nuevamente, estoy bastante seguro de que valdría la pena escribir una herramienta de este tipo en su escenario, tal vez excepto si trabajará en esta biblioteca de este tipo (que se puede usar sin problemas desde F # y C #) con regularidad. En cualquier caso, creo que sería un experimento interesante, uno que incluso podría explorar en algún momento.

ShdNx
fuente
suena interesante. ¿Usar Mono.Cecilsignificaría básicamente escribir un programa para cargar el ensamblado de F #, encontrar elementos que requieran mapeo y emitir otro ensamblado con los contenedores de C #? ¿Hice esto bien?
Philip P.
@Komrade P .: También puedes hacer eso, pero eso es mucho más complicado, porque entonces tendrías que ajustar todas las referencias para apuntar a los miembros de la nueva asamblea. Es mucho más simple si simplemente agrega los nuevos miembros (los contenedores) al ensamblado escrito en F # existente.
ShdNx
2

Borrador de las directrices de diseño de componentes F # (agosto de 2010)

Descripción general Este documento analiza algunos de los problemas relacionados con el diseño y la codificación de componentes de F #. En particular, cubre:

  • Pautas para diseñar bibliotecas .NET “vanilla” para su uso desde cualquier lenguaje .NET.
  • Directrices para las bibliotecas de F # a F # y el código de implementación de F #.
  • Sugerencias sobre convenciones de codificación para el código de implementación de F #
gradbot
fuente