[英]Best approach for designing F# libraries for use from both F# and C#
我正在尝试用 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
,我想要的是Func
和Action
,就像这样:
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
包装到Func
和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# 编写的库的一流体验?
到目前为止,我想不出比这两种方法更好的方法:
对于第一种方法,我会做这样的事情:
创建一个 F# 项目,将其命名为 FooBarFs 并将其编译为 FooBarFs.dll。
创建另一个F#项目,调用if FooBarCs并编译成FooFar.dll
我认为使用命名空间的第二种方法可能会让用户感到困惑,但是你有一个程序集。
问题:这些都不理想,也许我缺少某种编译器标志/开关/属性或某种技巧,并且有更好的方法来做到这一点?
问题:有没有其他人试图实现类似的目标,如果是这样,你是如何做到的?
编辑:澄清一下,问题不仅与函数和委托有关,而且与使用 F# 库的 C# 用户的整体体验有关。 这包括 C# 原生的命名空间、命名约定、习惯用法等。 基本上,C# 用户不应该能够检测到该库是用 F# 编写的。 反之亦然,F# 用户应该感觉像是在处理 C# 库。
编辑2:
从目前的答案和评论中可以看出,我的问题缺乏必要的深度,这可能主要是由于仅使用了一个示例,其中出现了 F# 和 C# 之间的互操作性问题,即函数值问题。 我认为这是最明显的例子,所以这让我用它来提出问题,但同样的原因给人的印象是这是我唯一关心的问题。
让我提供更具体的例子。 我已经阅读了最优秀的F# 组件设计指南文档(非常感谢@gradbot!)。 文档中的指南(如果使用)确实解决了一些问题,但不是全部。
该文档分为两个主要部分:1) 针对 F# 用户的指南; 和 2) 针对 C# 用户的指南。 它甚至没有试图假装可能有一个统一的方法,这恰好回应了我的问题:我们可以针对 F#,也可以针对 C#,但是针对两者的实际解决方案是什么?
提醒一下,我们的目标是拥有一个用 F# 编写的库,并且可以在 F# 和 C# 语言中惯用地使用它。
这里的关键字是惯用的。 问题不在于可以使用不同语言的库的一般互操作性。
现在来看我直接从F# 组件设计指南中获取的示例。
模块+函数 (F#) 与命名空间+类型+函数
F#:请使用命名空间或模块来包含您的类型和模块。 惯用的用法是在模块中放置函数,例如:
// library module Foo let bar() = ... let zoo() = ... // Use from F# open Foo bar() zoo()
C#:对于 vanilla .NET API,务必使用命名空间、类型和成员作为组件(而不是模块)的主要组织结构。
这与 F# 指南不兼容,并且需要重新编写示例以适合 C# 用户:
[<AbstractClass; Sealed>] type Foo = static member bar() = ... static member zoo() = ...
但是,通过这样做,我们打破了 F# 的惯用用法,因为我们不能再使用bar
和zoo
而不用Foo
前缀。
元组的使用
F#:在适合返回值时使用元组。
C#:避免在 vanilla .NET API 中使用元组作为返回值。
异步
F#:务必在 F# API 边界使用 Async 进行异步编程。
C#:使用 .NET 异步编程模型(BeginFoo、EndFoo)或作为返回 .NET 任务(Task)的方法,而不是作为 F# 异步对象公开异步操作。
Option
使用
F#:考虑对返回类型使用选项值而不是引发异常(对于面向 F# 的代码)。
考虑使用 TryGetValue 模式而不是在 vanilla .NET API 中返回 F# 选项值(选项),并且更喜欢方法重载而不是将 F# 选项值作为参数。
受歧视的工会
F#:使用可区分联合作为类层次结构的替代方法来创建树结构数据
C#:没有具体的指导方针,但歧视联合的概念对 C# 来说是陌生的
咖喱函数
F#:柯里化函数是 F# 惯用的
C#:不要在 vanilla .NET API 中使用柯里化参数。
检查空值
F#:这不是 F# 的惯用语
C#:考虑在 vanilla .NET API 边界上检查空值。
使用 F# 类型list
、 map
、 set
等
F#:在 F# 中使用这些是惯用的
C#:考虑将 .NET 集合接口类型 IEnumerable 和 IDictionary 用于 vanilla .NET API 中的参数和返回值。 (即不使用 F# list
, map
, set
)
函数类型(最明显的一种)
F#:显然,使用 F# 函数作为值是 F# 的惯用语
C#:在 vanilla .NET API 中优先使用 .NET 委托类型而不是 F# 函数类型。
我认为这些应该足以证明我的问题的性质。
顺便说一句,指南也有部分答案:
... 为 vanilla .NET 库开发高阶方法时的一个常见实现策略是使用 F# 函数类型编写所有实现,然后使用委托创建公共 API 作为实际 F# 实现之上的薄外观。
总结一下。
有一个明确的答案:我没有遗漏任何编译器技巧。
根据指南文档,似乎首先为 F# 创作然后为 .NET 创建外观包装器是一个合理的策略。
那么问题仍然是关于这个的实际实施:
单独的组件? 要么
不同的命名空间?
如果我的解释是正确的,Tomas 建议使用单独的命名空间就足够了,并且应该是一个可以接受的解决方案。
我想我会同意这一点,因为命名空间的选择不会让 .NET/C# 用户感到惊讶或混淆,这意味着他们的命名空间应该看起来像是他们的主要命名空间。 F# 用户将不得不承担选择特定于 F# 的命名空间的负担。 例如:
FSharp.Foo.Bar -> 面向库的 F# 命名空间
Foo.Bar -> .NET 包装器的命名空间,C# 惯用的命名空间
Daniel 已经解释了如何定义您编写的 F# 函数的 C# 友好版本,因此我将添加一些更高级别的注释。 首先,您应该阅读F# 组件设计指南(已被 gradbot 引用)。 这是一个解释如何使用 F# 设计 F# 和 .NET 库的文档,它应该可以回答您的许多问题。
使用 F# 时,基本上可以编写两种库:
F# 库被设计为只能在 F# 中使用,因此它的公共接口是以函数式风格编写的(使用 F# 函数类型、元组、可区分联合等)
.NET 库旨在用于任何.NET 语言(包括 C# 和 F#),它通常遵循 .NET 面向对象的风格。 这意味着您将大部分功能公开为带有方法的类(有时是扩展方法或静态方法,但大多数代码应该在 OO 设计中编写)。
在您的问题中,您询问如何将函数组合公开为 .NET 库,但我认为从 .NET 库的角度来看,像您的compose
这样的函数是太低级的概念。 您可以将它们公开为使用Func
和Action
,但这可能不是您首先设计普通 .NET 库的方式(也许您会改用 Builder 模式或类似的东西)。
在某些情况下(即在设计与 .NET 库风格不太匹配的数字库时),设计一个在单个库中混合F#和.NET风格的库是很有意义的。 最好的方法是使用普通的 F#(或普通的 .NET)API,然后提供包装器以在其他风格中自然使用。 包装器可以位于单独的命名空间中(如MyLibrary.FSharp
和MyLibrary
)。
在您的示例中,您可以将 F# 实现保留在MyLibrary.FSharp
,然后在MyLibrary
命名空间中添加 .NET(C# 友好)包装器(类似于 Daniel 发布的代码)作为某个类的静态方法。 但同样,.NET 库可能比函数组合具有更具体的 API。
你只需要用Func
或Action
包装函数值(部分应用的函数等),其余的会自动转换。 例如:
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# 特定的元数据)时才需要。
依次拿你的分数:
模块 + let
绑定和无构造函数类型 + 静态成员在 C# 中看起来完全相同,所以如果可以,请使用模块。 您可以使用CompiledNameAttribute
为成员提供 C# 友好的名称。
我可能错了,但我的猜测是组件指南是在System.Tuple
添加到框架之前编写的。 (在早期版本中,F# 定义了它自己的元组类型。)从那时起,在普通类型的公共接口中使用Tuple
变得更容易接受。
这就是我认为你可以用 C# 方式做事的地方,因为 F# 与Task
配合得很好,但 C# 与Async
配合得不好。 您可以在内部使用异步,然后在从公共方法返回之前调用Async.StartAsTask
。
在开发用于 C# 的 API 时,拥抱null
可能是一个最大的缺点。 过去,我尝试了各种技巧来避免在内部 F# 代码中考虑 null,但最终,最好使用[<AllowNullLiteral>]
标记具有公共构造函数的类型,并检查 args 是否为 null。 在这方面它并不比 C# 差。
可区分联合通常编译为类层次结构,但在 C# 中始终具有相对友好的表示形式。 我会说,用[<AllowNullLiteral>]
标记它们并使用它们。
柯里化函数产生函数值,不应使用。
我发现拥抱 null 比依赖它在公共接口上被捕获并在内部忽略它更好。 天啊。
在内部使用list
/ map
/ set
很有意义。 它们都可以通过公共接口公开为IEnumerable<_>
。 此外, seq
、 dict
和Seq.readonly
经常有用。
见#6。
您采用哪种策略取决于您的库的类型和大小,但根据我的经验,从长远来看,找到 F# 和 C# 之间的最佳点比创建单独的 API 需要更少的工作。
概述本文档着眼于与 F# 组件设计和编码相关的一些问题。 特别是,它涵盖:
- 设计用于任何 .NET 语言的“vanilla”.NET 库的指南。
- F# 到 F# 库和 F# 实现代码的准则。
- 关于 F# 实现代码的编码约定的建议
尽管这可能有点矫枉过正,但您可以考虑使用Mono.Cecil编写一个应用程序(它在邮件列表上有很棒的支持),可以在 IL 级别自动转换。 例如,您在 F# 中使用 F# 样式的公共 API 实现程序集,然后该工具将在其上生成 C# 友好的包装器。
例如,在 F# 中,您显然会使用option<'T>
(特别是None
)而不是像在 C# 中那样使用null
。 为这种情况编写包装器生成器应该相当容易:包装器方法将调用原始方法:如果它的返回值为Some x
,则返回x
,否则返回null
。
您需要处理T
是值类型(即不可为空)的情况; 您必须将包装器方法的返回值包装到Nullable<T>
,这让它有点痛苦。
同样,我很确定在您的场景中编写这样一个工具是值得的,也许除非您定期处理这个这样的库(可以从 F# 和 C# 无缝使用)。 无论如何,我认为这将是一个有趣的实验,我什至可能会在某个时候进行探索。
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.