设计同时用于F#和C#的F#库的最佳方法


113

我正在尝试在F#中设计一个库。该库应该易于在F#和C#中使用

这就是我卡住的地方。我可以使它对F#友好,也可以使它对C#友好,但是问题是如何使两者友好。

这是一个例子。想象一下我在F#中具有以下功能:

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

从F#完全可以使用:

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

在C#中,该compose函数具有以下签名:

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

但当然,我不希望FSharpFunc在签名,我要的是FuncAction不是,是这样的:

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

为此,我可以创建如下compose2函数:

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

现在,这在C#中完全可用:

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

但是现在我们在使用compose2F#时遇到了问题!现在,我必须将所有标准F#包装funsFunc和中Action,如下所示:

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"

问题:我们如何为F#和C#用户获得用F#编写的库的一流体验?

到目前为止,我没有比这两种方法更好的方法了:

  1. 两个单独的程序集:一个针对F#用户,第二个针对C#用户。
  2. 一个程序集但名称空间不同:一个用于F#用户,另一个用于C#用户。

对于第一种方法,我将执行以下操作:

  1. 创建一个F#项目,将其命名为FooBarFs并将其编译为FooBarFs.dll。

    • 仅将库定位为F#用户。
    • 隐藏.fsi文件中不需要的所有内容。
  2. 创建另一个F#项目,调用FooBarCs并将其编译为FooFar.dll

    • 在源代码级别重用第一个F#项目。
    • 创建.fsi文件,该文件将隐藏该项目中的所有内容。
    • 创建.fsi文件,以C#方式公开库,并使用C#惯用语来表示名称,名称空间等。
    • 创建委派给核心库的包装器,并在必要时进行转换。

我认为使用名称空间的第二种方法可能会使用户感到困惑,但是您只有一个程序集。

问题:这些都不是理想的,也许我缺少某种编译器标志/开关/属性或某种技巧,还有更好的方法吗?

问题是:是否有其他人试图实现类似目标?如果是,您是如何做到的?

编辑:澄清一下,这个问题不仅涉及函数和委托,而且还涉及具有F#库的C#用户的整体经验。这包括C#固有的名称空间,命名约定,惯用语等。基本上,C#用户不应检测到该库是用F#编写的。反之亦然,F#用户应该感觉像在处理C#库。


编辑2:

从到目前为止的答案和评论中可以看出,我的问题缺乏必要的深度,这可能主要是由于仅使用了一个示例,其中出现了F#和C#之间的互操作性问题,即函数值的问题。我认为这是最明显的示例,因此这使我用它来提出问题,但出于同样的原因,给人的印象是这是我唯一关心的问题。

让我提供更具体的例子。我已经阅读了最出色的《 F#组件设计指南》 文档(非常感谢@gradbot!)。如果使用本文档中的准则,则确实可以解决某些问题,但不能解决所有问题。

该文档分为两个主要部分:1)针对F#用户的准则;和2)定位C#用户的准则。甚至没有人假装有可能采用统一的方法,这正好回应了我的问题:我们可以针对F#,我们可以针对C#,但是同时针对这两种方法的实际解决方案是什么?

提醒一下,目标是要有一个用F#编写的库,并且可以从F#和C#语言中惯用该库。

这里的关键字是惯用语。问题不是一般的互操作性,在这种互操作性下,有可能使用不同语言的库。

现在来看示例,我直接取自《 F#组件设计指南》

  1. 模块+功能(F#)与命名空间+类型+功能

    • F#:请使用名称空间或模块来包含您的类型和模块。习惯用法是将功能放置在模块中,例如:

      // library
      module Foo
      let bar() = ...
      let zoo() = ...
      
      
      // Use from F#
      open Foo
      bar()
      zoo()
    • C#:对于原始.NET API,请务必使用名称空间,类型和成员作为组件(而不是模块)的主要组织结构。

      这与F#准则不兼容,需要重新编写示例以适合C#用户:

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

      通过这样做,我们打破了F#的惯用用法,因为我们不能再使用它,bar并且zoo不能在它前面加上Foo

  2. 元组的使用

    • F#:在适合返回值时,请使用元组。

    • C#:避免将元组用作原始.NET API中的返回值。

  3. 异步

    • F#:请在F#API边界使用Async进行异步编程。

    • C#:请使用.NET异步编程模型(BeginFoo,EndFoo)或作为返回.NET任务的方法(任务)而不是F#异步对象公开异步操作。

  4. 用于 Option

    • F#:考虑将选项值用于返回类型,而不是引发异常(对于面向F#的代码)。

    • 考虑使用TryGetValue模式,而不是在原始.NET API中返回F#选项值(选项),并且比将F#选项值作为参数更喜欢方法重载。

  5. 歧视工会

    • F#:请使用区分联合作为类层次结构的替代方法,以创建树结构数据

    • C#:对此没有特定的指导原则,但是区别工会的概念对C#来说是陌生的

  6. 咖喱函数

    • F#:咖喱函数是F#惯用的

    • C#:请勿在普通的.NET API中使用参数传递。

  7. 检查空值

    • F#:这不是F#的惯用语

    • C#:考虑检查香草.NET API边界上的空值。

  8. F#类型的使用listmapset

    • F#:在F#中使用它们是惯用的

    • C#:考虑将.NET集合接口类型IEnumerable和IDictionary用于原始.NET API中的参数和返回值。(即不使用F# ,listmapset

  9. 函数类型(显而易见的一种)

    • F#:使用F#函数作为值对于F#来说是惯用的

    • C#:在.NET API中,请优先使用.NET委托类型,而不要使用F#函数类型。

我认为这些应该足以证明我的问题的性质。

顺便说一句,指南也有部分答案:

在为香草.NET库开发高阶方法时,常见的实现策略是使用F#函数类型编写所有实现,然后使用委托作为实际F#实现之上的薄外观来创建公共API。

总结一下。

有一个明确的答案:没有我错过的编译器技巧

根据指南文档,似乎首先为F#创作,然后为.NET创建外观包装是一种合理的策略。

关于此的实际实施,问题仍然存在:

  • 单独的组件?要么

  • 不同的名称空间?

如果我的解释正确,Tomas建议使用单独的名称空间就足够了,应该是可以接受的解决方案。

考虑到命名空间的选择不会使.NET / C#用户感到惊讶或困惑,我想我同意这一点,这意味着它们的命名空间可能看起来像是它们的主要命名空间。F#用户将不得不承担选择F#特定名称空间的负担。例如:

  • FSharp.Foo.Bar-> F#面向库的名称空间

  • Foo.Bar-> .NET包装的名称空间,对于C#是惯用的



除了传递一流的功能之外,您还有什么要担心的?F#在提供其他语言的无缝体验方面已经走了很长一段路。
丹尼尔(Daniel)

回复:您的编辑,关于您为什么认为用F#编写的库对于C#而言似乎是非标准的,我仍然不清楚。在大多数情况下,F#提供了C#功能的超集。使库看起来自然很主要是约定俗成的问题,无论您使用哪种语言,这都是正确的。综上所述,F#提供了执行此操作所需的所有工具。
丹尼尔(Daniel)

老实说,即使您包括一个代码示例,您仍在问一个相当开放的问题。您询问“具有F#库的C#用户的总体体验”,但是您给出的唯一示例是,在任何命令式语言中,都确实没有很好地支持该示例。将函数视为一等公民是函数式编程的核心原则-很难映射到大多数命令式语言。
Onorio Catenacci 2012年

2
@KomradeP .:关于您的EDIT 2,FSharpx提供了一些使互操作性更加隐蔽的方法。您可以在这篇出色的博客文章中找到一些提示。
填充

Answers:


40

Daniel已经解释了如何定义您编写的F#函数的C#友好版本,因此我将添加一些更高级别的注释。首先,您应该阅读《F#组件设计指南》(已被gradbot参考)。该文档说明了如何使用F#设计F#和.NET库,它应该回答您的许多问题。

使用F#时,基本上可以编写两种库:

  • F#库被设计用来从F#,所以它的公共接口是用一个实用的风格(使用F#函数类型,元组,识别联合等)

  • .NET库旨在用于任何 .NET语言(包括C#和F#),并且通常遵循.NET面向对象的样式。这意味着您会将大多数功能作为带有方法的类公开(有时是扩展方法或静态方法,但大多数代码应在OO设计中编写)。

在您的问题中,您正在询问如何将函数组成作为.NET库公开,但是compose从.NET库的角度来看,我认为像您这样的函数还是太低级的概念。您可以将它们公开为与Func和一起使用的方法Action,但这可能不是您一开始就设计普通的.NET库的方式(也许您会使用Builder模式或类似的方式)。

在某些情况下(例如,在设计与.NET库样式非常不匹配的数字库时),设计一个将F#.NET样式混合在一个库中的库是很有意义的。最好的方法是拥有普通的F#(或普通的.NET)API,然后以其他样式自然使用包装器。包装器可以位于单独的名称空间(如MyLibrary.FSharpMyLibrary)中。

在您的示例中,您可以保留F#实现MyLibrary.FSharp,然后在MyLibrary命名空间中添加.NET(C#友好)包装器(类似于Daniel发布的代码)作为某些类的静态方法。但同样,.NET库可能具有比函数组成更特定的API。


1
F#组件设计指南现在在此处
Jan Schiefer

19

你只需要包裹函数与(部分应用的功能,等)FuncAction外,其余都自动转换。例如:

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

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

因此,在适用的地方使用Func/ 是有意义的Action。这是否消除了您的担忧?我认为您提出的解决方案过于复杂。您可以用F#编写整个库,并在F# C#中轻松使用它(我一直都在这样做)。

同样,就互操作性而言,F#比C#更具灵活性,因此,在担心这一点时,通常最好遵循传统的.NET风格。

编辑

我认为,仅当它们是互补的或C#无法使用F#功能(例如,内联函数,它们依赖于F#特定的元数据)时,才需要在单独的命名空间中创建两个公共接口所需的工作。

依次提出您的观点:

  1. 模块+ let绑定和较少构造函数的类型+静态成员在C#中看起来完全相同,因此,如果可以,请使用模块。您可以CompiledNameAttribute用来为成员提供C#友好名称。

  2. 我可能是错的,但我的猜测是组件准则是在System.Tuple添加到框架之前编写的。(在早期版本中,F#定义了它自己的元组类型。)从那时起,Tuple在普通类型的公共接口中使用它已变得越来越容易接受。

  3. 我认为这是您以C#方式进行操作的地方,因为F#可以很好地使用,Task但是C#不能很好地使用Async。您可以Async.StartAsTask在内部使用async,然后在从公共方法返回之前进行调用。

  4. null开发用于C#的API时,拥抱可能是最大的缺点。过去,我尝试了各种技巧来避免在内部F#代码中考虑空值,但最后,最好使用public构造函数将类型标记[<AllowNullLiteral>]为,并检查args是否为空值。在这方面,它不比C#差。

  5. 区分联合通常被编译为类层次结构,但在C#中始终具有相对友好的表示形式。我会说,[<AllowNullLiteral>]用它们标记并使用它们。

  6. 咖喱函数产生的函数不应该使用。

  7. 我发现拥抱null胜过依赖于在公共接口上捕获它并在内部忽略它。YMMV。

  8. 这使得很多的意义,使用list/ map/ set内部。它们都可以通过public接口公开为IEnumerable<_>。此外,seqdict,和Seq.readonly经常有用。

  9. 参见#6。

采取哪种策略取决于库的类型和大小,但是根据我的经验,从长远来看,与创建单独的API相比,找到F#和C#之间的最佳结合点所需的工作更少。


是的,我可以看到有一些方法可以使事情正常进行,我并不完全相信。重新模块。如果您使用的module Foo.Bar.Zoo是C#,它将class Foo位于namespace中Foo.Bar。但是,如果您有类似的嵌套模块module Foo=... module Bar=... module Zoo==,则会生成Foo带有内部类Bar和的类Zoo。关于IEnumerable,当我们确实需要使用地图和集合时,这可能是不可接受的。重nullAllowNullLiteral-我喜欢F#的原因之一是因为我对nullC#感到厌倦,真的不想再用它污染一切!
菲利普P.

通常,名称空间优于用于互操作的嵌套模块。回复:空,我同意你的看法,考虑到它肯定是回归,但是如果要从C#中使用API​​,则必须处理一些事情。
丹尼尔(Daniel)

2

尽管这可能会过高,但是您可以考虑使用Mono.Cecil(在邮件列表中提供强大的支持)编写应用程序,该应用程序可以在IL级别自动进行转换。例如,您使用F#样式的公共API在F#中实现程序集,然后该工具将在其上生成一个C#友好的包装器。

例如,在F#中,您显然会使用option<'T>None,专门)而不是null在C#中使用like。为这种情况编写包装器生成器应该很容易:包装器方法将调用原始方法:如果返回值是Some x,则返回x,否则返回null

T是一个值类型,即非可为空时,您将需要处理这种情况。您必须将wrapper方法的返回值包装到中Nullable<T>,这使它有些痛苦。

再次重申,我非常确定在您的方案中编写这样的工具会有所回报,也许除非您要定期使用这种库(可从F#和C#无缝使用)。无论如何,我认为这将是一个有趣的实验,我什至可能会在某个时候进行探索。


听起来不错。是否会使用方法Mono.Cecil基本上编写程序来加载F#程序集,查找需要映射的项目,然后使用C#包装程序发出另一个程序集?我说对了吗?
菲利普P.

@Komrade P .:您也可以这样做,但是要复杂得多,因为那样的话,您必须调整所有引用以指向新程序集的成员。如果仅将新成员(包装器)添加到现有的F#编写的程序集中,则要简单得多。
ShdNx 2012年

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.