为了让F#库能够在F#和C#中使用,最好的设计方法是什么?

119

我正在尝试设计一个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);
}

但是现在我们在使用 F# 中的 compose2 时遇到了问题!现在我必须将所有标准的 F# funs 包装成 FuncAction,就像这样:

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#的用户编写的库实现一流的体验?

到目前为止,我想到的最好的方法是这两种方法之一:

  1. 两个独立的程序集:一个面向F#用户,另一个面向C#用户。
  2. 一个程序集但不同的命名空间:一个用于F#用户,另一个用于C#用户。

对于第一种方法,我会像这样做:

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

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

    • 在源级别上重用第一个F#项目。
    • 创建一个.fsi文件,其中隐藏了该项目的所有内容。
    • 创建一个.fsi文件,使用C#习语为名称,命名空间等以C#方式公开库。
    • 创建委托到核心库的包装器,在必要时进行转换。
我认为第二种方法使用命名空间可能会让用户感到困惑,但这样你只需要一个程序集。 问题:这些都不是理想的解决方案,也许我错过了某种编译器标志/开关/属性或某种技巧,有更好的方法吗? 问题:是否还有其他人尝试过类似的事情,如果有,他们是如何做到的?
编辑:为了澄清,问题不仅涉及函数和委托,还涉及C#用户使用F#库的整体体验。这包括命名空间、命名约定、惯用语等等,这些都是C#本地化的。基本上,C#用户不应该能够检测到库是由F#编写的。反之亦然,F#用户应该感觉自己在处理C#库。

编辑2:

从迄今为止的答案和评论中,我可以看出我的问题缺乏必要的深度,可能主要是由于只使用了一个例子,即函数值之间的互操作性问题,我认为这是最明显的例子,因此我使用它来提问,但同时给人留下的印象是这是我关心的唯一问题。

让我提供更具体的例子。我已经阅读了最优秀的F#组件设计指南文档(非常感谢@gradbot!)。如果使用该文档中的指南,则可以解决其中的某些问题,但并非全部。

该文档分为两个主要部分:1)针对F#用户的指南;2)针对C#用户的指南。它甚至没有试图假装有可能拥有统一的方法,这正好回应了我的问题:我们可以针对F#,我们可以针对C#,但针对两者的实际解决方案是什么?

提醒一下,目标是编写一个F#库,可以从F#和C#语言中“惯用地”使用。
关键词在于“惯用地”。问题不在于通用的互操作性,而是在于可以在不同的语言中使用库的情况。
现在来看一些例子,这些例子直接来自F#组件设计指南
  1. Modules+functions (F#) vs Namespaces+Types+functions

    • F#: Do use namespaces or modules to contain your types and modules. The idiomatic use is to place functions in modules, e.g.:

      // library
      module Foo
      let bar() = ...
      let zoo() = ...
      
      
      // Use from F#
      open Foo
      bar()
      zoo()
      
    • C#: Do use namespaces, types and members as the primary organizational structure for your components (as opposed to modules), for vanilla .NET APIs.

      This is incompatible with the F# guideline, and the example would need to be re-written to fit the C# users:

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

      By doing so though, we break the idiomatic use from F# because we can no longer use bar and zoo without prefixing it with Foo.

  2. Use of tuples

    • F#: Do use tuples when appropriate for return values.

    • C#: Avoid using tuples as return values in vanilla .NET APIs.

  3. Async

    • F#: Do use Async for async programming at F# API boundaries.

    • C#: Do expose asynchronous operations using either the .NET asynchronous programming model (BeginFoo, EndFoo), or as methods returning .NET tasks (Task), rather than as F# Async objects.

  4. Use of Option

    • F#: Consider using option values for return types instead of raising exceptions (for F#-facing code).

    • Consider using the TryGetValue pattern instead of returning F# option values (option) in vanilla .NET APIs, and prefer method overloading over taking F# option values as arguments.

  5. Discriminated unions

    • F#: Do use discriminated unions as an alternative to class hierarchies for creating tree-structured data

    • C#: no specific guidelines for this, but the concept of discriminated unions is foreign to C#

  6. Curried functions

    • F#: curried functions are idiomatic for F#

    • C#: Do not use currying of parameters in vanilla .NET APIs.

  7. Checking for null values

    • F#: this is not idiomatic for F#

    • C#: Consider checking for null values on vanilla .NET API boundaries.

  8. Use of F# types list, map, set, etc

    • F#: it is idiomatic to use these in F#

    • C#: Consider using the .NET collection interface types IEnumerable and IDictionary for parameters and return values in vanilla .NET APIs. (i.e. do not use F# list, map, set)

  9. Function types (the obvious one)

    • F#: use of F# functions as values is idiomatic for F#, obviously

    • C#: Do use .NET delegate types in preference to F# function types in vanilla .NET APIs.

我认为这些足以展示我的问题的性质。

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

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

总结。

有一个明确的答案:我没有遗漏任何编译器技巧

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

然后问题仍然关于这个的实际实现:

  • 分离的程序集? 或者

  • 不同的命名空间?

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

考虑到命名空间的选择不会让.NET/C#用户感到惊讶或混淆,我认为我会同意这一点,这意味着对于他们来说,命名空间可能需要看起来像是他们的主要命名空间。F#用户将需要承担选择特定于F#的命名空间的负担。例如:

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

  • Foo.Bar -> .NET包装器的命名空间,符合C#惯用法


8
有些相关的内容 http://research.microsoft.com/en-us/um/cambridge/projects/fsharp/manual/fsharp-component-design-guidelines.pdf - gradbot
除了传递一流函数之外,您还有什么顾虑吗?F#已经在向其他语言提供无缝体验方面走了很长的路。 - Daniel
@gradbot 我认为你的评论确实值得成为一个答案。 - Philip P.
@OnorioCatenacci,我提供了不止函数的各种例子,请参见 EDIT 2。 - Philip P.
2
@KomradeP。关于你的EDIT 2,[FSharpx](https://github.com/fsharp/fsharpx)提供了一些使互操作性更加无缝的方法。您可以在[这篇优秀的博客文章](http://bugsquash.blogspot.com/2011/10/10-reasons-to-use-f-runtime-in-your-c.html) 中找到一些提示。 - pad
显示剩余6条评论
4个回答

41
丹尼尔已经解释了如何定义一个适用于C#的版本的F#函数,因此我会添加一些更高层次的评论。首先,你应该阅读F#组件设计指南(已经被gradbot引用)。这是一份文件,它解释了如何使用F#和.NET库来设计F#和.NET库,它应该能回答你很多问题。
在使用F#时,基本上有两种库可以编写:
- F#库:设计为仅从F#中使用,因此其公共接口以函数式风格编写(使用F#函数类型、元组、辨识联合等)。 - .NET库:设计为从任何.NET语言(包括C#和F#)中使用,通常遵循.NET面向对象的风格。这意味着你将把大部分功能公开为具有方法的类(有时是扩展方法或静态方法,但大多数情况下代码应该以面向对象的设计方式编写)。
在你的问题中,你正在问如何将函数组合公开为.NET库,但我认为像你的compose这样的函数在.NET库方面太低级别了。你可以将它们公开为使用Func和Action的方法,但这可能不是你首先设计普通.NET库的方式(也许你会使用构建器模式或类似的东西)。在某些情况下(例如设计与.NET库风格不完全匹配的数值库),在一个单一的库中混合使用F#和.NET风格是有意义的。最好的方法是拥有普通的F# (或普通的.NET) API,然后提供包装器以便在另一个风格中自然地使用。这些包装器可以在一个单独的命名空间中(如MyLibrary.FSharpMyLibrary)。
在你的例子中,你可以将F#实现保留在MyLibrary.FSharp中,然后添加.NET(C#友好)的包装器(类似于Daniel发布的代码)到MyLibrary命名空间作为某个类的静态方法。但是,.NET库可能会比函数组合具有更具体的API。

2
F#组件设计指南现在托管在这里 - Jan Schiefer

19
你只需要使用 Func 或者 Action 来包装函数 values (部分应用的函数等),其他内容会自动转换。例如:
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风格,当这是一个问题时。
编辑
我认为,在两个公共接口位于不同命名空间中时,需要进行的工作仅在它们互补或F#功能无法从C#中使用(例如内联函数,这取决于F#特定的元数据)时才有必要。
依次考虑您的观点:
  1. 在C#中,模块+let绑定和没有构造函数的类型+静态成员看起来完全相同,所以如果可以,请使用模块。您可以使用CompiledNameAttribute为成员提供友好的C#名称。

  2. 我可能错了,但我猜组件指南是在添加System.Tuple到框架之前编写的。(在早期版本中,F#定义了自己的元组类型。)现在更容易将Tuple用于公共接口中的微不足道的类型。

  3. 这就是我认为您必须按照C#的方式进行的地方,因为F#与Task很搭配,但C#与Async不太搭配。您可以在内部使用async,然后在从公共方法返回之前调用Async.StartAsTask

  4. 拥抱null可能是开发用于从C#使用的API的最大缺点。过去,我尝试了各种技巧来避免在内部F#代码中考虑null,但最终最好使用带有[<AllowNullLiteral>]的公共构造函数标记类型并检查null参数。在这方面,它与C#不相上下。

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

  6. 柯里化函数产生函数值,不应使用。

  7. 我发现拥抱null比依赖于它在公共接口被捕获并在内部忽略更好。你的情况可能有所不同。

  8. 在内部使用list/map/set非常有意义。它们都可以通过公共接口作为IEnumerable<_>暴露出来。此外,seqdictSeq.readonly经常很有用。

  9. 参见#6。

你采取哪种策略,取决于你的库的类型和大小,但在我的经验中,找到F#和C#之间的最佳平衡点需要的工作量比创建单独的API少,从长远来看。


是的,我知道有一些方法可以让事情正常运作,但我并不完全相信。关于模块,如果你有module Foo.Bar.Zoo,那么在C#中这将是命名空间Foo.Bar中的class Foo。然而,如果你有像module Foo=... module Bar=... module Zoo==这样的嵌套模块,这将生成带有内部类BarZooFoo类。关于IEnumerable,当我们真正需要使用映射和集合时,这可能不可接受。关于null值和AllowNullLiteral——我喜欢F#的一个原因是因为我厌倦了C#中的null,真的不想再用它来污染一切! - Philip P.
通常情况下,为了实现互操作性,我们更倾向于使用命名空间而不是嵌套模块。关于 null 值,我同意你的观点,必须考虑到它确实是一种退化情况,但如果你的 API 将被 C# 使用,那么你必须处理这个问题。 - Daniel

2
虽然这可能有些过度,但你可以考虑使用 Mono.Cecil 编写一个应用程序(它在邮件列表上有很棒的支持),以在 IL 级别上自动转换。例如,你可以使用 F# 实现你的程序集,并使用 F# 风格的公共 API,然后该工具将生成一个适用于 C# 的包装器。
例如,在 F# 中,你显然会使用 option<'T>(特别是 None),而不像在 C# 中使用 null。为此场景编写包装器生成器应该相当容易:包装器方法将调用原始方法:如果其返回值是 Some x,则返回 x,否则返回 null
你需要处理 T 是值类型(即非可空类型)的情况;你必须将包装器方法的返回值包装到 Nullable<T> 中,这会有点痛苦。
再次强调,我相信在你的情况下编写这样的工具会很有价值,除非您将经常使用此类库(可从F#和C#无缝使用)。无论如何,我认为这将是一个有趣的实验,我可能会在某个时候探索一下。

听起来很有趣。使用 Mono.Cecil 是否意味着基本上要编写一个程序来加载 F# 组件,查找需要映射的项,然后发出另一个带有 C# 包装器的组件?我理解的对吗? - Philip P.
@Komrade P.:你也可以这样做,但那会更加复杂,因为你需要调整所有引用以指向新程序集的成员。如果你只是将新成员(包装器)添加到现有的 F# 编写的程序集中,那么就简单得多了。 - ShdNx

2

F#组件设计指南草案(2010年8月)

概述 本文档介绍了与F#组件设计和编码相关的一些问题。具体而言,它涵盖了以下内容:

  • 用于设计“普通”的.NET库以供任何.NET语言使用的指南。
  • 用于F#到F#库和F#实现代码的指南。
  • 有关F#实现代码编码约定的建议。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接