区分联合类型 - 允许模式匹配但限制构造

12

我有一个F#判别联合类型,我希望对用于构造联合类型的任何值应用一些“构造函数逻辑”。假设联合类型看起来像这样:

type ValidValue =
| ValidInt of int
| ValidString of string
// other cases, etc.

现在,我想对实际传入的值应用一些逻辑,以确保它们是有效的。为了确保我不会处理那些不真正有效(没有使用验证逻辑构造)的ValidValue实例,我将构造函数设为私有,并公开一个公共函数来强制执行我的逻辑以构造它们。

type ValidValue = 
    private
    | ValidInt of int
    | ValidString of string

module ValidValue =
    let createInt value =
        if value > 0 // Here's some validation logic
        then Ok <| ValidInt value
        else Error "Integer values must be positive"

    let createString value =
        if value |> String.length > 0 // More validation logic
        then Ok <| ValidString value
        else Error "String values must not be empty"

这个方法能够让我实施验证逻辑并确保每个ValidValue实例都是有效的。但问题在于,模块外部没有人能够对ValidValue进行模式匹配以检查其结果,从而限制了Discriminated Union的实用性。

我希望允许外部用户仍然像使用任何其他DU一样对ValidValue进行模式匹配和操作,但如果它具有私有构造函数,则不可能实现。我能想到的唯一解决方案是在单个情况下将DU中的每个值包装在一个带有私有构造函数的联合类型中,并保留实际的ValidValue构造函数。这将向外部公开情况,允许它们进行匹配,但仍然大部分防止外部调用者构造它们,因为实例化每种情况所需的值具有私有构造函数:

type VInt = private VInt of int
type VString = private VString of string

type ValidValue = 
| ValidInt of VInt
| ValidString of VString

module ValidValue =
    let createInt value =
        if value > 0 // Here's some validation logic
        then Ok <| ValidInt (VInt value)
        else Error "Integer values must be positive"

    let createString value =
        if value |> String.length > 0 // More validation logic
        then Ok <| ValidString (VString value)
        else Error "String values must not be empty"

现在调用者可以匹配ValidValue的情况,但是他们无法读取联合类型中包含的实际整数和字符串值,因为它们被包装在具有私有构造函数的类型中。可以通过为每种类型创建value函数来解决这个问题:
module VInt =
    let value (VInt i) = i

module VString =
    let value (VString s) = s

不幸的是,现在呼叫者的负担增加了:

// Example Caller
let result = ValidValue.createInt 3

match result with
| Ok validValue ->
    match validValue with
    | ValidInt vi ->
        let i = vi |> VInt.value // Caller always needs this extra line
        printfn "Int: %d" i
    | ValidString vs ->
        let s = vs |> VString.value // Can't use the value directly
        printfn "String: %s" s
| Error error ->
    printfn "Invalid: %s" error

有没有更好的方法,在不增加后续负担的情况下,强制执行我想要的构造函数逻辑?

2个回答

9
您可以拥有私有的构造函数,同时使用相同名称的公共活动模式。以下是如何定义和使用它们(为了简洁起见,创建函数被省略):
module Helpers =
    type ValidValue = 
        private
        | ValidInt of int
        | ValidString of string

    let (|ValidInt|ValidString|) = function
        | ValidValue.ValidInt i -> ValidInt i
        | ValidValue.ValidString s -> ValidString s

module Usage =
    open Helpers

    let validValueToString = function
        | ValidInt i -> string i
        | ValidString s -> s
    //  Easy to use ✔

    // Let's try to make our own ValidInt 
    ValidInt -1
    // error FS1093: The union cases or fields of the type
    // 'ValidValue' are not accessible from this code location
    //  Blocked by the compiler ✔

我看到了一个像这样的 Haskell 答案,使用 View Patterns,它似乎是一个可能的折衷方案。唯一我不喜欢的是 Active Patterns 不会强制执行对所有情况的全面匹配,因此如果添加了新的联合情况或开发人员不知道所有可能的 Active Patterns,则可能会出现错误。尽管如此,考虑到语言的限制,这可能是我们能做到的最好的。 - Aaron M. Eshbach
@AaronM.Eshbach 实际上,这是一个完整的活动模式,因此您可以获得穷尽性检查。只有在部分活动模式下才不行。请参见此处 - TheQuickBrownFox
1
不幸的是,当我尝试实现这个解决方案时,我遇到了错误“Active Patterns不能返回超过7个可能性”。我认为这是因为我的DU有16个case。你有什么想法可以解决这个问题吗? - Aaron M. Eshbach
1
@AaronM.Eshbach 我从未听说过这种限制!它似乎相当武断。不幸的是,我不知道有什么解决方法,除了将您的类型拆分为具有 7 个或更少子类型的较小类型。回到您最初的解决方案,您可以向 VInt 等添加 Value 成员属性,这样您就可以只写 vi.Value 而不是 vi |> VInt.value - TheQuickBrownFox

2

除非有特殊原因需要使用联合类型,根据您提供的具体用例,似乎您实际上并不需要使用联合类型,因为活动模式会更有用。例如:

let (|ValidInt|ValidString|Invalid|) (value:obj) = 
    match value with
    | :? int as x -> if x > 0 then ValidInt x else Invalid
    | :? string as x -> if x.Length > 0 then ValidString x else Invalid
    | _ -> Invalid

在那时,呼叫者可以匹配并确信逻辑已被应用。
match someValue with
| ValidInt x -> // ...
| _ -> // ...

3
我认为这并不能提供所有相同的好处。拥有一个受限制的类型可能很好,这样任何接收实例的函数都可以获得某些数据属性的类型级别保证,并且不必进行任何进一步的检查/分支。 - TheQuickBrownFox
@TheQuickBrownFox 我同意你的观点,然而在许多情况下,这种准依赖类型行为往往被用来代替验证。在后一种情况下,由于输入将来自外部,例如表单或数据库查询,因此编译器无法保证,所以您将会遇到运行时错误... - s952163
1
即使是数据库查询,也无法将数据放入受限类型中,因为私有构造函数需要使用受限类型模块的代码。只要您确信该模块中的验证是正确的,那么您就可以在其他任何地方做出假设。当然,这只是一个软保证,因为它依赖于函数的正确实现。 - TheQuickBrownFox
1
我的例子是一个简化的用例。在我的真实场景中,我肯定希望使用区分联合类型,因为我正在表示一组固定状态,每个状态都有不同的规则来控制它们所包含的值。 - Aaron M. Eshbach
@TheQuickBrownFox 当然。我指的是编译时警告/错误和确保代码工作的区别,以及进入非纯净世界时必要的验证/错误/异常处理。 - s952163

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