Melhor abordagem para projetar bibliotecas F # para uso em F # e C #

113

Estou tentando criar uma biblioteca em F #. A biblioteca deve ser amigável para uso em F # e C # .

E é aqui que estou um pouco preso. Posso torná-lo compatível com F # ou C #, mas o problema é como torná-lo amigável para ambos.

Aqui está um exemplo. Imagine que tenho a seguinte função em F #:

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

Isso é perfeitamente utilizável no F #:

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

Em C #, a composefunção possui a seguinte assinatura:

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

Mas claro, eu não quero FSharpFuncna assinatura, o que eu quero é Funce ao Actioninvés disso, assim:

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

Para conseguir isso, posso criar compose2funções como esta:

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

Agora, isso é perfeitamente utilizável em C #:

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

Mas agora temos um problema com o compose2F #! Agora eu tenho que envolver todo o F # padrão funsem Funce Action, assim:

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"

A pergunta: como podemos obter uma experiência de primeira classe com a biblioteca escrita em F # para usuários de F # e C #?

Até agora, não consegui pensar em nada melhor do que essas duas abordagens:

  1. Dois assemblies separados: um voltado para usuários F # e o segundo para usuários C #.
  2. Um assembly, mas namespaces diferentes: um para usuários F # e o segundo para usuários C #.

Para a primeira abordagem, eu faria algo assim:

  1. Crie um projeto F #, chame-o de FooBarFs e compile-o em FooBarFs.dll.

    • Direcione a biblioteca exclusivamente para usuários F #.
    • Oculte tudo o que for desnecessário dos arquivos .fsi.
  2. Crie outro projeto F #, chame se FooBarCs e compile-o em FooFar.dll

    • Reutilize o primeiro projeto F # no nível de origem.
    • Crie um arquivo .fsi que esconde tudo daquele projeto.
    • Crie um arquivo .fsi que expõe a biblioteca em C #, usando expressões idiomáticas C # para nome, namespaces, etc.
    • Crie wrappers que delegam à biblioteca central, fazendo a conversão quando necessário.

Acho que a segunda abordagem com os namespaces pode ser confusa para os usuários, mas você tem um assembly.

A pergunta: nenhum desses é ideal, talvez eu esteja faltando algum tipo de sinalizador / switch / atributo do compilador ou algum tipo de truque e há uma maneira melhor de fazer isso?

A questão: mais alguém tentou alcançar algo semelhante e, em caso afirmativo, como você o fez?

EDITAR: para esclarecer, a questão não é apenas sobre funções e delegados, mas a experiência geral de um usuário C # com uma biblioteca F #. Isso inclui namespaces, convenções de nomenclatura, expressões idiomáticas e semelhantes que são nativos do C #. Basicamente, um usuário C # não deve ser capaz de detectar que a biblioteca foi criada em F #. E vice-versa, um usuário F # deve sentir vontade de lidar com uma biblioteca C #.


EDIT 2:

Eu posso ver pelas respostas e comentários até agora que minha pergunta não tem a profundidade necessária, talvez principalmente devido ao uso de apenas um exemplo em que surgem problemas de interoperabilidade entre F # e C #, a questão dos valores de função. Acho que este é o exemplo mais óbvio e isso me levou a usá-lo para fazer a pergunta, mas da mesma forma deu a impressão de que essa é a única questão que me preocupa.

Deixe-me fornecer exemplos mais concretos. Eu li o mais excelente documento F # Component Design Guidelines (muito obrigado @gradbot por isso!). As diretrizes do documento, se utilizadas, tratam de alguns dos problemas, mas não de todos.

O documento está dividido em duas partes principais: 1) diretrizes para direcionar os usuários do F #; e 2) diretrizes para direcionar usuários C #. Em nenhum lugar ele tenta fingir que é possível ter uma abordagem uniforme, o que ecoa exatamente minha pergunta: podemos direcionar F #, podemos direcionar C #, mas qual é a solução prática para direcionar ambos?

Para relembrar, o objetivo é ter uma biblioteca criada em F #, e que possa ser usada linguisticamente nas linguagens F # e C #.

A palavra-chave aqui é idiomática . O problema não é a interoperabilidade geral, onde apenas é possível usar bibliotecas em diferentes linguagens.

Agora, para os exemplos, que peguei direto das Diretrizes de design de componentes do F # .

  1. Módulos + funções (F #) vs Namespaces + Tipos + funções

    • F #: Use namespaces ou módulos para conter seus tipos e módulos. O uso idiomático é colocar funções em módulos, por exemplo:

      // library
      module Foo
      let bar() = ...
      let zoo() = ...
      
      
      // Use from F#
      open Foo
      bar()
      zoo()
    • C #: Use namespaces, tipos e membros como a estrutura organizacional primária para seus componentes (ao contrário de módulos), para APIs .NET básicas.

      Isso é incompatível com a diretriz F # e o exemplo precisaria ser reescrito para se adequar aos usuários C #:

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

      Ao fazer isso, porém, quebramos o uso idiomático do F # porque não podemos mais usar bare zoosem prefixá-lo com Foo.

  2. Uso de tuplas

    • F #: Use tuplas quando apropriado para valores de retorno.

    • C #: Evite usar tuplas como valores de retorno em APIs .NET do vanilla.

  3. Assíncrono

    • F #: Use Async para programação assíncrona nos limites da API F #.

    • C #: Exponha operações assíncronas usando o modelo de programação assíncrona .NET (BeginFoo, EndFoo) ou como métodos que retornam tarefas .NET (Tarefa), em vez de objetos F # Async.

  4. Uso de Option

    • F #: Considere usar valores de opção para tipos de retorno em vez de gerar exceções (para código voltado para F #).

    • Considere usar o padrão TryGetValue em vez de retornar valores de opção F # (opção) em APIs .NET vanilla e prefira a sobrecarga de método em vez de usar valores de opção F # como argumentos.

  5. Sindicatos discriminados

    • F #: Use uniões discriminadas como alternativa às hierarquias de classe para criar dados estruturados em árvore

    • C #: não há diretrizes específicas para isso, mas o conceito de sindicatos discriminados é estranho ao C #

  6. Funções de curry

    • F #: funções curried são idiomáticas para F #

    • C #: Não use currying de parâmetros em APIs .NET do vanilla.

  7. Verificando valores nulos

    • F #: isso não é idiomático para F #

    • C #: Considere verificar se há valores nulos nos limites da API .NET vanilla.

  8. Uso de tipos de F # list, map, set, etc

    • F #: é idiomático usá-los em F #

    • C #: Considere usar os tipos de interface de coleção .NET IEnumerable e IDictionary para parâmetros e valores de retorno em APIs .NET vanilla. ( Ou seja, não usar F # list, map,set )

  9. Tipos de função (o óbvio)

    • F #: o uso de funções F # como valores é idiomático para F #, obviamente

    • C #: Use tipos de delegado .NET em preferência aos tipos de função F # em APIs .NET vanilla.

Acho que isso deve ser suficiente para demonstrar a natureza da minha pergunta.

A propósito, as diretrizes também têm uma resposta parcial:

... uma estratégia de implementação comum ao desenvolver métodos de ordem superior para bibliotecas .NET vanilla é criar toda a implementação usando tipos de função F # e, em seguida, criar a API pública usando delegados como uma fachada fina sobre a implementação real do F #.

Para resumir.

Há uma resposta definitiva: não há truques do compilador que eu perdi .

De acordo com o documento de diretrizes, parece que criar primeiro para F # e depois criar um invólucro de fachada para .NET é uma estratégia razoável.

A questão então permanece em relação à implementação prática disso:

  • Conjuntos separados? ou

  • Namespaces diferentes?

Se minha interpretação estiver correta, Tomas sugere que usar namespaces separados deve ser suficiente e deve ser uma solução aceitável.

Acho que vou concordar com isso, visto que a escolha de namespaces é tal que não surpreende ou confunde os usuários .NET / C #, o que significa que o namespace para eles provavelmente deve parecer o seu principal. Os usuários do F # terão que assumir o encargo de escolher o namespace específico do F #. Por exemplo:

  • FSharp.Foo.Bar -> namespace para F # voltado para a biblioteca

  • Foo.Bar -> namespace para .NET wrapper, idiomático para C #

Philip P.
fonte
Além de transmitir funções de primeira classe, quais são suas preocupações? O F # já percorre um longo caminho para fornecer uma experiência contínua de outras linguagens.
Daniel
Re: sua edição, ainda não estou claro por que você acha que uma biblioteca criada em F # pareceria fora do padrão de C #. Na maior parte, o F # fornece um superconjunto de funcionalidade C #. Fazer com que uma biblioteca pareça natural é principalmente uma questão de convenção, o que é verdade independentemente do idioma que você está usando. Tudo isso para dizer que o F # fornece todas as ferramentas de que você precisa para fazer isso.
Daniel
Honestamente, mesmo que você inclua um exemplo de código, você está fazendo uma pergunta bastante aberta. Você pergunta sobre "a experiência geral de um usuário C # com uma biblioteca F #", mas o único exemplo que você dá é algo que realmente não é bem suportado em nenhuma linguagem imperativa. Tratar funções como cidadãos de primeira classe é um princípio central da programação funcional - que mapeia mal para a maioria das linguagens imperativas.
Onorio Catenacci
2
@KomradeP .: em relação ao EDIT 2, o FSharpx fornece alguns meios para tornar a interoperabilidade mais simples. Você pode encontrar algumas dicas nesta excelente postagem do blog .
pad

Respostas:

40

Daniel já explicou como definir uma versão amigável do C # da função F # que você escreveu, portanto, acrescentarei alguns comentários de nível superior. Em primeiro lugar, você deve ler as Diretrizes de design de componentes F # (já referenciadas pelo gradbot). Este é um documento que explica como projetar bibliotecas F # e .NET usando F # e deve responder a muitas de suas perguntas.

Ao usar F #, existem basicamente dois tipos de bibliotecas que você pode escrever:

  • A biblioteca F # foi projetada para ser usada apenas a partir do F #, então sua interface pública é escrita em um estilo funcional (usando tipos de função F #, tuplas, uniões discriminadas, etc.)

  • A biblioteca .NET foi projetada para ser usada em qualquer linguagem .NET (incluindo C # e F #) e normalmente segue o estilo orientado a objetos .NET. Isso significa que você vai expor a maior parte da funcionalidade como classes com método (e às vezes métodos de extensão ou métodos estáticos, mas principalmente o código deve ser escrito no design OO).

Em sua pergunta, você está perguntando como expor a composição de funções como uma biblioteca .NET, mas acho que funções como o seu compose são conceitos de nível muito baixo do ponto de vista da biblioteca .NET. Você pode expô-los como métodos que trabalham com Funce Action, mas provavelmente não é assim que você projetaria uma biblioteca .NET normal em primeiro lugar (talvez você usasse o padrão Builder em vez disso ou algo parecido).

Em alguns casos (ou seja, ao projetar bibliotecas numéricas que não se adaptam bem ao estilo de biblioteca .NET), faz sentido projetar uma biblioteca que combine os estilos F # e .NET em uma única biblioteca. A melhor maneira de fazer isso é ter a API F # normal (ou .NET normal) e, em seguida, fornecer invólucros para uso natural no outro estilo. Os wrappers podem estar em um namespace separado (como MyLibrary.FSharpe MyLibrary).

Em seu exemplo, você poderia deixar a implementação do F # MyLibrary.FSharpe adicionar wrappers .NET (C # -friendly) (semelhante ao código que Daniel postou) no MyLibrarynamespace como método estático de alguma classe. Mas, novamente, a biblioteca .NET provavelmente teria API mais específica do que composição de função.

Tomas Petricek
fonte
1
As diretrizes de design de componentes do F # agora estão hospedadas aqui
Jan Schiefer
19

Você só precisa agrupar os valores da função ( funções parcialmente aplicadas, etc) com Funcou Action, o resto é convertido automaticamente. Por exemplo:

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

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

Portanto, faz sentido usar Func/ Actionquando aplicável. Isso elimina suas preocupações? Acho que as soluções propostas são excessivamente complicadas. Você pode escrever sua biblioteca inteira em F # e usá-la sem problemas a partir de F # e C # (eu faço isso o tempo todo).

Além disso, F # é mais flexível do que C # em termos de interoperabilidade, portanto, geralmente é melhor seguir o estilo .NET tradicional quando isso for uma preocupação.

EDITAR

O trabalho necessário para fazer duas interfaces públicas em namespaces separados, eu acho, só é garantido quando elas são complementares ou a funcionalidade F # não pode ser usada em C # (como funções embutidas, que dependem de metadados específicos do F #).

Resumindo seus pontos:

  1. Módulo + letligações e membros do tipo sem construtor + estáticos aparecem exatamente da mesma forma em C #, então vá com módulos, se puder. Você pode usar CompiledNameAttributepara dar aos membros nomes amigáveis ​​ao C #.

  2. Posso estar errado, mas meu palpite é que as Diretrizes de Componentes foram escritas antes de System.Tupleserem adicionadas à estrutura. (Em versões anteriores, F # definia seu próprio tipo de tupla.) Desde então, tornou-se mais aceitável usar Tupleem uma interface pública para tipos triviais.

  3. É aqui que eu acho que você deve fazer as coisas da maneira C # porque F # funciona bem com, Taskmas C # não funciona bem com Async. Você pode usar async internamente e depois chamar Async.StartAsTaskantes de retornar de um método público.

  4. A adoção de nullpode ser a maior desvantagem ao desenvolver uma API para uso em C #. No passado, tentei todos os tipos de truques para evitar considerar nulo no código F # interno, mas, no final, era melhor marcar tipos com construtores públicos com [<AllowNullLiteral>]e verificar args para nulos. Não é pior do que C # nesse aspecto.

  5. As uniões discriminadas são geralmente compiladas para hierarquias de classes, mas sempre têm uma representação relativamente amigável em C #. Eu diria, marque-os [<AllowNullLiteral>]e use-os.

  6. Funções curried produzem valores de função , que não devem ser usados.

  7. Achei melhor abraçar o null do que depender de ele ser capturado na interface pública e ignorá-lo internamente. YMMV.

  8. Faz muito sentido usar list/ map/ setinternamente. Todos eles podem ser expostos por meio da interface pública como IEnumerable<_>. Além disso, seq, dict, e Seq.readonlysão freqüentemente úteis.

  9. Veja # 6.

A estratégia que você adota depende do tipo e tamanho da sua biblioteca, mas, em minha experiência, encontrar o ponto ideal entre F # e C # exigiu menos trabalho - a longo prazo - do que criar APIs separadas.

Daniel
fonte
sim, posso ver que existem algumas maneiras de fazer as coisas funcionarem, não estou totalmente convencido. Re módulos. Se você tiver module Foo.Bar.Zooem C #, será class Foono namespace Foo.Bar. No entanto, se você tiver módulos aninhados como module Foo=... module Bar=... module Zoo==, isso irá gerar uma classe Foocom classes internas Bare Zoo. Re IEnumerable, isso pode não ser aceitável quando realmente precisamos trabalhar com mapas e conjuntos. Re nullvalores e AllowNullLiteral- uma razão que eu como F # é porque eu estou cansado de nullem C #, realmente não gostaria de tudo poluir com ele novamente!
Philip P.
Geralmente favorece namespaces em vez de módulos aninhados para interoperabilidade. Re: null, concordo com você, certamente é uma regressão ter que considerar isso, mas algo que você terá que lidar se sua API for usada em C #.
Daniel
2

Embora provavelmente seja um exagero, você pode considerar escrever um aplicativo usando Mono.Cecil (ele tem um suporte incrível na lista de discussão) que automatizaria a conversão no nível IL. Por exemplo, se você implementar seu assembly em F #, usando a API pública no estilo F #, a ferramenta gerará um wrapper amigável C # sobre ele.

Por exemplo, em F # você obviamente usaria option<'T>( None, especificamente) em vez de usar nullcomo em C #. Escrever um gerador de wrapper para este cenário deve ser bastante fácil: o método de wrapper invocaria o método original: se seu valor de retorno fosse Some x, então retorne x, caso contrário, retornaria null.

Você precisaria lidar com o caso em que Té um tipo de valor, ou seja, não anulável; você teria que envolver o valor de retorno do método wrapper em Nullable<T>, o que o torna um pouco complicado.

Mais uma vez, estou certo de que valeria a pena escrever tal ferramenta em seu cenário, talvez exceto se você estiver trabalhando nesta biblioteca (utilizável perfeitamente em F # e C #) regularmente. Em todo caso, acho que seria uma experiência interessante, que poderia até explorar algum dia.

ShdNx
fonte
Soa interessante. Usar Mono.Cecilsignificaria basicamente escrever um programa para carregar assembly F #, localizar itens que requerem mapeamento e emitir outro assembly com os wrappers C #? Eu entendi direito?
Philip P.
@Komrade P .: Você também pode fazer isso, mas é muito mais complicado, porque então você teria que ajustar todas as referências para apontar para os membros da nova montagem. É muito mais simples se você apenas adicionar os novos membros (os invólucros) ao assembly existente escrito em F #.
ShdNx
2

Rascunho das diretrizes de design de componentes F # (agosto de 2010)

Visão geral Este documento examina alguns dos problemas relacionados ao design e codificação do componente F #. Em particular, cobre:

  • Diretrizes para projetar bibliotecas .NET “vanilla” para uso em qualquer linguagem .NET.
  • Diretrizes para bibliotecas F #-a-F # e código de implementação F #.
  • Sugestões sobre convenções de codificação para código de implementação F #
gradbot
fonte