强类型的通用结构体作为Guid

41

我已经两次在代码中犯了如下的同样的错误:

void Foo(Guid appId, Guid accountId, Guid paymentId, Guid whateverId)
{
...
}

Guid appId = ....;
Guid accountId = ...;
Guid paymentId = ...;
Guid whateverId =....;

//BUG - parameters are swapped - but compiler compiles it
Foo(appId, paymentId, accountId, whateverId);

好的,我希望能够预防这些漏洞,因此我创建了强类型GUID:

[ImmutableObject(true)]
public struct AppId
{
    private readonly Guid _value;

    public AppId(string value)
    {            
        var val = Guid.Parse(value);
        CheckValue(val);
        _value = val;
    }      

    public AppId(Guid value)
    {
        CheckValue(value);
        _value = value;           
    }

    private static void CheckValue(Guid value)
    {
        if(value == Guid.Empty)
            throw new ArgumentException("Guid value cannot be empty", nameof(value));
    }

    public override string ToString()
    {
        return _value.ToString();
    }
}

还有一个针对PaymentId的内容:

[ImmutableObject(true)]
public struct PaymentId
{
    private readonly Guid _value;

    public PaymentId(string value)
    {            
        var val = Guid.Parse(value);
        CheckValue(val);
        _value = val;
    }      

    public PaymentId(Guid value)
    {
        CheckValue(value);
        _value = value;           
    }

    private static void CheckValue(Guid value)
    {
        if(value == Guid.Empty)
            throw new ArgumentException("Guid value cannot be empty", nameof(value));
    }

    public override string ToString()
    {
        return _value.ToString();
    }
}

这些结构体几乎相同,有很多代码重复。是吗?

我无法找到任何优雅的解决方法,除了使用类替换结构体。但我更愿意使用结构体,因为它具有空值检查、较小的内存占用和没有垃圾收集器开销等优点...

你有什么办法可以在不重复代码的情况下使用结构体吗?


1
有趣的需求。我有一个问题。抛开代码重复不谈,当你想要创建其中一个结构体时,你必须用某些东西来初始化它们。你如何确保它们被正确地初始化?例如...var appIdStruct=new AppId(paymentId) 不应该编译。 - Bogdan Beda
我不能保证它。我无法检查一切。即便如此,漏洞的空间也会减少一些。 - Tomas Kubes
2
从这个角度来看,我认为漏洞的空间是相同的,你只是将源代码移动到不同的位置。此外,你正在复杂化事情,这实际上会长期增加那个空间。你打算分配很多吗?它们会在内存中长久存在吗? - Bogdan Beda
不多,但空值检查也是使用结构体的好理由。 - Tomas Kubes
你是否考虑过创建一个参数类,比如FooParams,并将其传递给函数?如果你真的使用了有意义的变量名,那么错误赋值会非常明显且容易捕捉,例如fooParams.PaymentId = accountId;。 - hazimdikenli
5个回答

44

首先,这是一个非常好的想法。一个简短的旁白:

我希望C#在创建整数、字符串、ID等便宜类型的包装器时更加容易。作为程序员,我们非常喜欢使用字符串和整数;很多东西都被表示为字符串和整数,但可能在类型系统中跟踪更多信息;我们不希望将客户名称分配给客户地址。一段时间以前,我写了一系列关于在OCaml中编写虚拟机的博客文章(从未完成!),其中最好的事情之一就是用表明其目的的类型包装了虚拟机中的每个整数。那样可以防止很多错误!OCaml使得创建小型包装类型非常容易;而C#则不然。

其次,我不会太担心代码重复。这只是一个简单的复制粘贴,您不太可能经常编辑代码或犯错。 花时间解决真正的问题。少量复制粘贴的代码并不重要。

如果您确实想避免复制粘贴的代码,则建议使用泛型,如下所示:

struct App {}
struct Payment {}

public struct Id<T>
{
    private readonly Guid _value;
    public Id(string value)
    {            
        var val = Guid.Parse(value);
        CheckValue(val);
        _value = val;
    }

    public Id(Guid value)
    {
        CheckValue(value);
        _value = value;           
    }

    private static void CheckValue(Guid value)
    {
        if(value == Guid.Empty)
            throw new ArgumentException("Guid value cannot be empty", nameof(value));
    }

    public override string ToString()
    {
        return _value.ToString();
    }
}

现在你已经完成了。你有了Id<App>Id<Payment>类型,而不是AppIdPaymentId, 但你仍然无法将Id<App>赋值给Id<Payment>Guid

另外,如果你喜欢使用AppIdPaymentId,那么在文件顶部你可以这样写:

using AppId = MyNamespace.Whatever.Id<MyNamespace.Whatever.App>

等等还有其他需要的特性。我假设这些还没有完成。比如说,你可能需要相等性来检查两个id是否相同。

此外,需要注意的是,default(Id<App>) 仍然会给你一个“空guid”标识符,因此你的防范措施并不能真正起到作用;仍然可以创建一个空id。实际上并没有很好的解决办法。


2
你认为为这种类型定义一个Guid转换或公开只读属性以暴露Guid是否有用?这对于与持久性框架进行交互时需要“原始”Guid的情况非常有用。 - Sergey Kalinichenko
2
@dasblinkenlight:这似乎是一个合理的选择。也许明确地转换为和从Guid转换会很有用。 - Eric Lippert
3
一个有趣的小知识:在函数式编程背景下,像这段代码中使用 T 的类型被称为幽灵类型 :它被用作类型参数,但是从来不会有类型为 T 的值存在。 - Lii
1
@SolomonUcko:我认为在大多数情况下这是更好的设计。也就是说,有一个特定的类AppId继承Id。或者将两种方法结合起来,让AppId扩展Id<AppId> - Lii
1
@Lii:虽然这是一个合理的想法,但它的问题在于你只能通过引用类型来获得扩展,这会导致集合压力和过度内存分配。请阅读原帖作者的最后一段话;他们明确表示他们希望使用基于结构体的解决方案,出于这些原因。 - Eric Lippert
显示剩余9条评论

5

我们也是这样做的,效果很好。

是的,复制粘贴确实很多,但这正是代码生成的用途。

在Visual Studio中,你可以使用T4模板来实现这一点。你只需编写一次类,然后在模板中指定“我需要这个类用于应用程序、支付、账户...”,Visual Studio将为每个类生成一个源代码文件。

这样你就只有一个单一的源(T4模板),如果你发现类中有错误,只需更改一次即可在不考虑更改所有标识符的情况下传播到所有标识符。


2

使用record实现这个相当容易。

public readonly record struct UserId(Guid Id)
{
    public override string ToString() => Id.ToString();
    public static implicit operator Guid(UserId userId) => userId.Id;
}

隐式操作符允许我们在适当的情况下将强类型的UserId用作常规的Guid
var id = Guid.NewGuid();
GuidTypeImportant(id);                 // ERROR
GuidTypeImportant(new UserId(id));     // OK
DontCareAboutGuidType(new UserId(id)); // OK
DontCareAboutGuidType(id);             // OK

void GuidTypeImportant(UserId id) { }
void DontCareAboutGuidType(Guid id) { }

自从C# 10的出现,这肯定是正确的答案。 - Bellarmine Head

0

这有一个很好的副作用。您可以为add方法创建这些重载:

void Add(Account account);
void Add(Payment payment);

然而,您不能为get方法进行重载:

Account Get(Guid id);
Payment Get(Guid id);

我一直不喜欢这种不对称性。你必须要做:

Account GetAccount(Guid id);
Payment GetPayment(Guid id);

通过上述方法,这是可能的:

Account Get(Id<Account> id);
Payment Get(Id<Payment> id);

对称已实现。


-10

你可能可以在不同的编程语言中使用子类化。


所有结构类型都需要继承自 ValueType,不能继承其他任何东西。 - supercat
哦,你能用一个类来代替吗? - Solomon Ucko
@SolomonUcko 类不能继承自结构体。 - Andrey
Guid 可以是一个类吗? - Solomon Ucko
如果Guid是类,它将会消耗更多的内存,由垃圾收集器进行管理,并且它可以为空(造成空指针检查问题)。 - Tomas Kubes

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