Najlepsze podejście do projektowania bibliotek F # do użytku z języków F # i C #

113

Próbuję zaprojektować bibliotekę w języku F #. Biblioteka powinna być przyjazna do użytku zarówno z języka F #, jak i C # .

I tutaj trochę utknąłem. Mogę uczynić go przyjaznym dla języka F # lub mogę uczynić go przyjaznym dla C #, ale problem polega na tym, jak uczynić go przyjaznym dla obu.

Oto przykład. Wyobraź sobie, że mam następującą funkcję w F #:

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

Jest to doskonale użyteczne z F #:

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

W języku C # composefunkcja ma następujący podpis:

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

Ale oczywiście nie chcę FSharpFuncw podpisie, to czego chcę, Funca Actionzamiast tego tak:

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

Aby to osiągnąć, mogę stworzyć compose2taką funkcję:

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

Teraz jest to doskonale użyteczne w C #:

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

Ale teraz mamy problem compose2z używaniem z F #! Teraz muszę zawinąć wszystkie standardowe F # funsw Funci Actiontak:

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"

Pytanie: jak możemy osiągnąć pierwszorzędne wrażenia z obsługi biblioteki napisanej w języku F # zarówno dla użytkowników języka F #, jak i C #?

Jak dotąd nie mogłem wymyślić nic lepszego niż te dwa podejścia:

  1. Dwa oddzielne zestawy: jeden przeznaczony dla użytkowników języka F #, a drugi dla użytkowników języka C #.
  2. Jeden zestaw, ale różne przestrzenie nazw: jedna dla użytkowników języka F #, a druga dla użytkowników języka C #.

W przypadku pierwszego podejścia zrobiłbym coś takiego:

  1. Utwórz projekt F #, nazwij go FooBarFs i skompiluj do FooBarFs.dll.

    • Skieruj bibliotekę wyłącznie na użytkowników języka F #.
    • Ukryj wszystko, co jest niepotrzebne w plikach .fsi.
  2. Utwórz inny projekt F #, wywołaj if FooBarCs i skompiluj go do FooFar.dll

    • Ponownie użyj pierwszego projektu F # na poziomie źródła.
    • Utwórz plik .fsi, który ukrywa wszystko z tego projektu.
    • Utwórz plik .fsi, który uwidacznia bibliotekę w C # sposób, używając idiomów C # dla nazw, przestrzeni nazw itp.
    • Utwórz opakowania, które delegują do biblioteki podstawowej, wykonując konwersję w razie potrzeby.

Myślę, że drugie podejście z przestrzeniami nazw może być mylące dla użytkowników, ale wtedy masz jeden zespół.

Pytanie: żadne z nich nie jest idealne, być może brakuje mi jakiejś flagi / przełącznika / atrybutu kompilatora lub jakiejś sztuczki, a jest na to lepszy sposób?

Pytanie: czy ktoś inny próbował osiągnąć coś podobnego, a jeśli tak, to jak to zrobiłeś?

EDYCJA: aby wyjaśnić, pytanie dotyczy nie tylko funkcji i delegatów, ale także ogólnego doświadczenia użytkownika C # z biblioteką F #. Obejmuje to przestrzenie nazw, konwencje nazewnictwa, idiomy i tym podobne, które są natywne dla języka C #. Zasadniczo użytkownik C # nie powinien być w stanie wykryć, że biblioteka została utworzona w języku F #. I odwrotnie, użytkownik języka F # powinien mieć ochotę mieć do czynienia z biblioteką C #.


EDYCJA 2:

Z dotychczasowych odpowiedzi i komentarzy widzę, że moje pytanie nie jest wystarczająco szczegółowe, być może głównie z powodu użycia tylko jednego przykładu, w którym pojawiają się problemy z interoperacyjnością między F # i C #, kwestią wartości funkcji. Myślę, że jest to najbardziej oczywisty przykład, więc to skłoniło mnie do zadawania tego pytania, ale tym samym sprawiło wrażenie, że jest to jedyny problem, którym się zajmuję.

Podam bardziej konkretne przykłady. Przeczytałem najbardziej doskonały dokument Wytyczne dotyczące projektowania komponentów F # (wielkie dzięki @gradbot za to!). Wytyczne zawarte w dokumencie, jeśli są stosowane, dotyczą niektórych problemów, ale nie wszystkich.

Dokument jest podzielony na dwie główne części: 1) wytyczne dotyczące kierowania na użytkowników języka F #; oraz 2) wskazówki dotyczące kierowania na użytkowników języka C #. Nigdzie nawet nie próbuje udawać, że możliwe jest jednolite podejście, co dokładnie odpowiada mojemu pytaniu: możemy kierować na język F #, możemy kierować na C #, ale jakie jest praktyczne rozwiązanie dla obu?

Przypominamy, że celem jest posiadanie biblioteki utworzonej w języku F #, która może być używana idiomatycznie z języków F # i C #.

Słowo kluczowe jest tutaj idiomatyczne . Problemem nie jest ogólna interoperacyjność, w przypadku której możliwe jest po prostu korzystanie z bibliotek w różnych językach.

Przejdźmy teraz do przykładów, które biorę bezpośrednio z wytycznych dotyczących projektowania komponentów języka F # .

  1. Moduły + funkcje (F #) a przestrzenie nazw + typy + funkcje

    • F #: używaj przestrzeni nazw lub modułów do przechowywania typów i modułów. Idiomatyczne zastosowanie to umieszczanie funkcji w modułach, np .:

      // library
      module Foo
      let bar() = ...
      let zoo() = ...
      
      
      // Use from F#
      open Foo
      bar()
      zoo()
    • C #: Używaj przestrzeni nazw, typów i członków jako podstawowej struktury organizacyjnej dla swoich komponentów (w przeciwieństwie do modułów), dla podstawowych interfejsów API .NET.

      Jest to niezgodne z wytycznymi języka F #, a przykład musiałby zostać ponownie napisany, aby pasował do użytkowników C #:

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

      Robiąc to jednak, przerywamy idiomatyczne użycie z F #, ponieważ nie możemy go już używać bari zoobez przedrostka Foo.

  2. Korzystanie z krotek

    • F #: używaj krotek, gdy jest to odpowiednie dla zwracanych wartości.

    • C #: Unikaj używania krotek jako wartości zwracanych w podstawowych interfejsach API platformy .NET.

  3. Async

    • F #: używaj Async do programowania asynchronicznego na granicach interfejsu API F #.

    • C #: udostępniaj operacje asynchroniczne przy użyciu asynchronicznego modelu programowania platformy .NET (BeginFoo, EndFoo) lub jako metody zwracające zadania platformy .NET (Task), a nie jako obiekty F # Async.

  4. Zastosowanie Option

    • F #: Rozważ użycie wartości opcji dla typów zwracanych zamiast zgłaszania wyjątków (dla kodu F # -facing).

    • Rozważ użycie wzorca TryGetValue zamiast zwracania wartości opcji F # (opcja) w podstawowych interfejsach API platformy .NET i preferuj przeciążanie metod zamiast przyjmowania wartości opcji F # jako argumentów.

  5. Związki dyskryminowane

    • F #: używaj związków rozłącznych jako alternatywy dla hierarchii klas do tworzenia danych o strukturze drzewa

    • C #: brak konkretnych wytycznych w tym zakresie, ale pojęcie związków dyskryminowanych jest obce dla języka C #

  6. Funkcje curry

    • F #: funkcje curried są idiomatyczne dla F #

    • C #: nie używaj currying parametrów w podstawowych interfejsach API .NET.

  7. Sprawdzanie wartości null

    • F #: to nie jest idiomatyczne dla F #

    • C #: rozważ sprawdzenie wartości null w podstawowych granicach interfejsu API platformy .NET.

  8. Korzystanie z F # typów list, map, set, etc

    • F #: idiomatyczne jest używanie ich w F #

    • C #: Rozważ użycie IEnumerable i IDictionary typów interfejsów kolekcji .NET dla parametrów i wartości zwracanych w podstawowych interfejsach API platformy .NET. ( Czyli nie używać F # list, map,set )

  9. Typy funkcji (oczywiste)

    • F #: użycie funkcji F # jako wartości jest oczywiście idiomatyczne dla F #

    • C #: używaj typów delegatów .NET zamiast typów funkcji F # w podstawowych interfejsach API .NET.

Myślę, że to powinno wystarczyć do zademonstrowania natury mojego pytania.

Nawiasem mówiąc, wytyczne zawierają również częściową odpowiedź:

... typową strategią implementacji przy opracowywaniu metod wyższego rzędu dla podstawowych bibliotek .NET jest stworzenie całej implementacji przy użyciu typów funkcji F #, a następnie utworzenie publicznego interfejsu API przy użyciu delegatów jako cienkiej fasady na szczycie rzeczywistej implementacji języka F #.

Podsumować.

Jest jedna ostateczna odpowiedź: nie ma żadnych sztuczek kompilatora, których nie przegapiłem .

Zgodnie z dokumentem z wytycznymi wydaje się, że rozsądną strategią jest najpierw napisanie dla języka F #, a następnie utworzenie opakowania fasady dla platformy .NET.

Pozostaje zatem pytanie dotyczące praktycznej realizacji tego:

  • Oddzielne zespoły? lub

  • Różne przestrzenie nazw?

Jeśli moja interpretacja jest poprawna, Tomas sugeruje, że użycie oddzielnych przestrzeni nazw powinno być wystarczające i powinno być akceptowalnym rozwiązaniem.

Myślę, że zgodzę się z tym, biorąc pod uwagę, że wybór przestrzeni nazw jest taki, że nie zaskakuje ani nie myli użytkowników .NET / C #, co oznacza, że ​​przestrzeń nazw dla nich powinna prawdopodobnie wyglądać tak, jakby była dla nich podstawową przestrzenią nazw. Użytkownicy języka F # będą musieli wziąć na siebie ciężar wybierania przestrzeni nazw specyficznej dla języka F #. Na przykład:

  • FSharp.Foo.Bar -> przestrzeń nazw dla języka F # zwróconego w stronę biblioteki

  • Foo.Bar -> przestrzeń nazw dla otoki .NET, idiomatyczna dla C #

Philip P.
źródło
Jakie są Twoje obawy, poza omijaniem pierwszorzędnych funkcji? F # już teraz robi długą drogę w kierunku zapewnienia bezproblemowego korzystania z innych języków.
Daniel
Odp .: Twoja edycja, nadal nie jest jasne, dlaczego uważasz, że biblioteka utworzona w F # wyglądałaby niestandardowo z C #. W przeważającej części F # zapewnia nadzbiór funkcji języka C #. Sprawienie, by biblioteka wyglądała naturalnie, jest głównie kwestią konwencji, która jest prawdą niezależnie od używanego języka. Mówiąc wszystko, F # zapewnia wszystkie narzędzia potrzebne do tego.
Daniel
Szczerze mówiąc, nawet jeśli dołączasz próbkę kodu, zadajesz dość otwarte pytanie. Pytasz o „ogólne wrażenia użytkownika C # z biblioteką F #”, ale jedynym przykładem, który podajesz, jest coś, co naprawdę nie jest dobrze obsługiwane w żadnym imperatywnym języku. Traktowanie funkcji jako obywateli pierwszej klasy jest dość centralną zasadą programowania funkcjonalnego, która kiepsko odpowiada większości języków imperatywnych.
Onorio Catenacci
2
@KomradeP .: jeśli chodzi o EDIT 2, FSharpx zapewnia pewne środki, dzięki którym interoperacyjność jest mniej widoczna. Możesz znaleźć kilka wskazówek w tym doskonałym wpisie na blogu .
pad

Odpowiedzi:

40

Daniel wyjaśnił już, jak zdefiniować napisaną przez siebie funkcję F # przyjazną dla języka C #, więc dodam kilka komentarzy wyższego poziomu. Przede wszystkim powinieneś przeczytać Wytyczne dotyczące projektowania komponentów F # (do których odwołuje się już gradbot). Jest to dokument, w którym wyjaśniono, jak projektować biblioteki F # i .NET przy użyciu języka F # i powinien on zawierać odpowiedzi na wiele pytań.

Korzystając z języka F #, można pisać zasadniczo dwa rodzaje bibliotek:

  • Biblioteka F # jest przeznaczona do użytku tylko z F #, więc jej interfejs publiczny jest napisany w stylu funkcjonalnym (przy użyciu typów funkcji F #, krotek, związków rozłącznych itp.)

  • Biblioteka .NET jest przeznaczona do użytku z dowolnym językiem .NET (w tym C # i F #) i zazwyczaj jest zgodna ze stylem obiektowym .NET. Oznacza to, że będziesz udostępniać większość funkcji jako klasy z metodą (a czasami metody rozszerzające lub metody statyczne, ale głównie kod powinien być napisany w projekcie obiektowym).

W swoim pytaniu pytasz, jak wyeksponować kompozycję funkcji jako bibliotekę .NET, ale myślę, że funkcje takie jak twoja composesą koncepcjami zbyt niskiego poziomu z punktu widzenia biblioteki .NET. Możesz pokazać je jako metody współpracujące z Funci Action, ale prawdopodobnie nie w taki sposób zaprojektowałbyś normalną bibliotekę .NET w pierwszej kolejności (być może zamiast tego użyłbyś wzorca Builder lub czegoś podobnego).

W niektórych przypadkach (np. Podczas projektowania bibliotek numerycznych, które tak naprawdę nie pasują do stylu biblioteki .NET), dobrze jest zaprojektować bibliotekę, która łączy style F # i .NET w jednej bibliotece. Najlepszym sposobem na to jest posiadanie normalnego interfejsu API F # (lub normalnego .NET), a następnie udostępnienie opakowań do naturalnego użytku w innym stylu. Opakowania mogą znajdować się w oddzielnej przestrzeni nazw (jak MyLibrary.FSharpi MyLibrary).

W naszym przykładzie możesz pozostawić implementację F # w, MyLibrary.FSharpa następnie dodać opakowania .NET (przyjazne dla C #) (podobne do kodu, który opublikował Daniel) w MyLibraryprzestrzeni nazw jako metodę statyczną jakiejś klasy. Ale znowu, biblioteka .NET prawdopodobnie miałaby bardziej specyficzne API niż kompozycja funkcji.

Tomas Petricek
źródło
1
Wytyczne dotyczące projektowania komponentów F # są teraz hostowane tutaj
Jan Schiefer
19

Wystarczy zawinąć wartości funkcji ( funkcje częściowo zastosowane itp.) Za pomocą Funclub Action, pozostałe są konwertowane automatycznie. Na przykład:

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

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

Dlatego sensowne jest użycie Func/ w Actionstosownych przypadkach. Czy to eliminuje Twoje obawy? Uważam, że proponowane przez Ciebie rozwiązania są zbyt skomplikowane. Możesz napisać całą swoją bibliotekę w F # i korzystać z niej bezboleśnie z poziomu F # i C # (robię to cały czas).

Ponadto język F # jest bardziej elastyczny niż C # pod względem współdziałania, więc ogólnie najlepiej jest postępować zgodnie z tradycyjnym stylem .NET, gdy jest to istotne.

EDYTOWAĆ

Wydaje mi się, że praca wymagana do utworzenia dwóch publicznych interfejsów w oddzielnych przestrzeniach nazw jest uzasadniona tylko wtedy, gdy są one komplementarne lub funkcja F # nie jest użyteczna z języka C # (na przykład funkcje wbudowane, które zależą od metadanych specyficznych dla języka F #).

Zbierając punkty po kolei:

  1. Moduł + letpowiązania i typ bez konstruktora + statyczne elementy członkowskie wyglądają dokładnie tak samo w C #, więc jeśli możesz, korzystaj z modułów. Możesz użyć, CompiledNameAttributeaby nadać członkom przyjazne dla języka C # nazwy.

  2. Może się mylę, ale przypuszczam, że wytyczne dotyczące składników zostały napisane przed System.Tupledodaniem do frameworka. (We wcześniejszych wersjach F # zdefiniował swój własny typ krotki). Od tego czasu bardziej akceptowalne stało się użycie Tuplew interfejsie publicznym dla trywialnych typów.

  3. Myślę, że w tym miejscu musisz robić rzeczy w C # sposób, ponieważ F Task# działa dobrze, ale C # nie działa dobrze Async. Możesz użyć async wewnętrznie, a następnie wywołać Async.StartAsTaskprzed powrotem z metody publicznej.

  4. Objęcie nullmoże być największą pojedynczą wadą podczas tworzenia interfejsu API do użytku z języka C #. W przeszłości próbowałem różnych sztuczek, aby uniknąć uwzględniania wartości null w wewnętrznym kodzie F #, ale ostatecznie najlepiej było oznaczać typy za pomocą publicznych konstruktorów [<AllowNullLiteral>]i sprawdzać argumenty pod kątem wartości null. Pod tym względem nie jest gorszy niż C #.

  5. Związki rozłączne są zwykle kompilowane do hierarchii klas, ale zawsze mają względnie przyjazną reprezentację w języku C #. Powiedziałbym, oznaczyć je [<AllowNullLiteral>]i używać.

  6. Funkcje curried generują wartości funkcji , których nie należy używać.

  7. Okazało się, że lepiej jest objąć null niż polegać na tym, że zostanie przechwycony przez interfejs publiczny i zignorować go wewnętrznie. YMMV.

  8. Używanie list/ map/ setwewnętrznie ma sens . Wszystkie mogą być ujawniane za pośrednictwem interfejsu publicznego jako IEnumerable<_>. Również seq, dicti Seq.readonlyczęsto są użyteczne.

  9. Zobacz # 6.

Wybór strategii zależy od typu i rozmiaru biblioteki, ale z mojego doświadczenia wynika, że ​​znalezienie idealnego punktu między językami F # i C # wymagało mniej pracy - na dłuższą metę - niż tworzenie oddzielnych interfejsów API.

Daniel
źródło
tak, widzę, że są sposoby, aby wszystko działało, nie jestem do końca przekonany. Ponownie moduły. Jeśli tak, module Foo.Bar.Zoow C # będzie to class Foow przestrzeni nazw Foo.Bar. Jeśli jednak masz zagnieżdżone moduły, takie jak module Foo=... module Bar=... module Zoo==, spowoduje to wygenerowanie klasy Fooz klasami wewnętrznymi Bari Zoo. Re IEnumerable, to może być nie do zaakceptowania, gdy naprawdę musimy pracować z mapami i zestawami. Re nullwartości i AllowNullLiteral- jednym z powodów, dla których lubię F # jest to, że jestem zmęczony językiem nullC #, naprawdę nie chciałbym ponownie zanieczyszczać tego wszystkiego!
Philip P.,
Ogólnie rzecz biorąc, preferuj przestrzenie nazw zamiast zagnieżdżonych modułów do współpracy. Re: null, zgadzam się z tobą, to z pewnością regresja, aby to wziąć pod uwagę, ale coś, z czym musisz sobie poradzić, jeśli twoje API będzie używane z C #.
Daniel
2

Chociaż prawdopodobnie byłaby to przesada, możesz rozważyć napisanie aplikacji wykorzystującej Mono.Cecil (ma świetne wsparcie na liście mailingowej), która zautomatyzowałaby konwersję na poziomie IL. Na przykład zaimplementujesz zestaw w języku F # przy użyciu publicznego interfejsu API w stylu F #, a następnie narzędzie wygeneruje otokę przyjazną dla języka C #.

Na przykład w języku F # oczywiście używałbyś option<'T>( Nonekonkretnie) zamiast używania nulljak w C #. Napisanie generatora opakowania dla tego scenariusza powinno być dość łatwe: metoda opakowująca wywołałaby oryginalną metodę: jeśli zwracana była wartość Some x, to return x, w przeciwnym razie return null.

Będziesz musiał obsłużyć przypadek, gdy Tjest typem wartości, tj. Nie dopuszcza wartości null; musiałbyś zawijać wartość zwracaną przez metodę opakowującą Nullable<T>, co sprawia, że ​​jest to trochę bolesne.

Ponownie, jestem pewien, że warto byłoby napisać takie narzędzie w swoim scenariuszu, być może z wyjątkiem sytuacji, gdy będziesz regularnie pracować nad tą biblioteką (bezproblemowo używaną z F # i C #). W każdym razie myślę, że byłby to interesujący eksperyment, który może nawet kiedyś zbadam.

ShdNx
źródło
brzmi interesująco. Czy użycie Mono.Ceciloznaczałoby w zasadzie napisanie programu do ładowania zestawu F #, znajdowania elementów wymagających mapowania i emitowania przez nie innego zestawu z opakowaniami C #? Czy dobrze to zrobiłem?
Philip P.
@Komrade P .: Ty też możesz to zrobić, ale jest to o wiele bardziej skomplikowane, ponieważ wtedy musiałbyś dostosować wszystkie odniesienia, aby wskazywały na członków nowego zespołu. Jest to znacznie prostsze, jeśli po prostu dodasz nowe elementy członkowskie (otoki) do istniejącego zestawu napisanego F #.
ShdNx
2

Wersja robocza wytycznych dotyczących projektowania składników języka F # (sierpień 2010)

Omówienie W tym dokumencie omówiono niektóre problemy związane z projektowaniem i kodowaniem składników języka F #. W szczególności obejmuje:

  • Wskazówki dotyczące projektowania „zwykłych” bibliotek .NET do użytku z dowolnym językiem .NET.
  • Wytyczne dotyczące bibliotek F # -to-F # i kodu implementacji F #.
  • Sugestie dotyczące konwencji kodowania dla kodu implementacji języka F #
gradbot
źródło